1# This test verifies that we can request and assign IPv6 prefixes from upstream
2# (e.g. ISP) routers.
3# The setup consits of three VMs. One for the ISP, as your residential router
4# and the third as a client machine in the residential network.
5#
6# There are two VLANs in this test:
7# - VLAN 1 is the connection between the ISP and the router
8# - VLAN 2 is the connection between the router and the client
9
10import ./make-test-python.nix ({ pkgs, lib, ... }: {
11 name = "systemd-networkd-ipv6-prefix-delegation";
12 meta = with lib.maintainers; {
13 maintainers = [ andir hexa ];
14 };
15 nodes = {
16
17 # The ISP's routers job is to delegate IPv6 prefixes via DHCPv6. Like with
18 # regular IPv6 auto-configuration it will also emit IPv6 router
19 # advertisements (RAs). Those RA's will not carry a prefix but in contrast
20 # just set the "Other" flag to indicate to the receiving nodes that they
21 # should attempt DHCPv6.
22 #
23 # Note: On the ISPs device we don't really care if we are using networkd in
24 # this example. That being said we can't use it (yet) as networkd doesn't
25 # implement the serving side of DHCPv6. We will use ISC Kea for that task.
26 isp = { lib, pkgs, ... }: {
27 virtualisation.vlans = [ 1 ];
28 networking = {
29 useDHCP = false;
30 firewall.enable = false;
31 interfaces.eth1 = lib.mkForce {}; # Don't use scripted networking
32 };
33
34 systemd.network = {
35 enable = true;
36
37 networks = {
38 "eth1" = {
39 matchConfig.Name = "eth1";
40 address = [
41 "2001:DB8::1/64"
42 ];
43 networkConfig.IPForward = true;
44 };
45 };
46 };
47
48 # Since we want to program the routes that we delegate to the "customer"
49 # into our routing table we must provide kea with the required capability.
50 systemd.services.kea-dhcp6-server.serviceConfig = {
51 AmbientCapabilities = [ "CAP_NET_ADMIN" ];
52 CapabilityBoundingSet = [ "CAP_NET_ADMIN" ];
53 };
54
55 services = {
56 # Configure the DHCPv6 server to hand out both IA_NA and IA_PD.
57 #
58 # We will hand out /48 prefixes from the subnet 2001:DB8:F000::/36.
59 # That gives us ~8k prefixes. That should be enough for this test.
60 #
61 # Since (usually) you will not receive a prefix with the router
62 # advertisements we also hand out /128 leases from the range
63 # 2001:DB8:0000:0000:FFFF::/112.
64 kea.dhcp6 = {
65 enable = true;
66 settings = {
67 interfaces-config.interfaces = [ "eth1" ];
68 subnet6 = [ {
69 interface = "eth1";
70 subnet = "2001:DB8:F::/36";
71 pd-pools = [ {
72 prefix = "2001:DB8:F::";
73 prefix-len = 36;
74 delegated-len = 48;
75 } ];
76 pools = [ {
77 pool = "2001:DB8:0000:0000:FFFF::-2001:DB8:0000:0000:FFFF::FFFF";
78 } ];
79 } ];
80
81 # This is the glue between Kea and the Kernel FIB. DHCPv6
82 # rightfully has no concept of setting up a route in your
83 # FIB. This step really depends on your setup.
84 #
85 # In a production environment your DHCPv6 server is likely
86 # not the router. You might want to consider BGP, NETCONF
87 # calls, … in those cases.
88 #
89 # In this example we use the run script hook, that lets use
90 # execute anything and passes information via the environment.
91 # https://kea.readthedocs.io/en/kea-2.2.0/arm/hooks.html#run-script-run-script-support-for-external-hook-scripts
92 hooks-libraries = [ {
93 library = "${pkgs.kea}/lib/kea/hooks/libdhcp_run_script.so";
94 parameters = {
95 name = pkgs.writeShellScript "kea-run-hooks" ''
96 export PATH="${lib.makeBinPath (with pkgs; [ coreutils iproute2 ])}"
97
98 set -euxo pipefail
99
100 leases6_committed() {
101 for i in $(seq $LEASES6_SIZE); do
102 idx=$((i-1))
103 prefix_var="LEASES6_AT''${idx}_ADDRESS"
104 plen_var="LEASES6_AT''${idx}_PREFIX_LEN"
105
106 ip -6 route replace ''${!prefix_var}/''${!plen_var} via $QUERY6_REMOTE_ADDR dev $QUERY6_IFACE_NAME
107 done
108 }
109
110 unknown_handler() {
111 echo "Unhandled function call ''${*}"
112 exit 123
113 }
114
115 case "$1" in
116 "leases6_committed")
117 leases6_committed
118 ;;
119 *)
120 unknown_handler "''${@}"
121 ;;
122 esac
123 '';
124 sync = false;
125 };
126 } ];
127 };
128 };
129
130 # Finally we have to set up the router advertisements. While we could be
131 # using networkd or bird for this task `radvd` is probably the most
132 # venerable of them all. It was made explicitly for this purpose and
133 # the configuration is much more straightforward than what networkd
134 # requires.
135 # As outlined above we will have to set the `Managed` flag as otherwise
136 # the clients will not know if they should do DHCPv6. (Some do
137 # anyway/always)
138 radvd = {
139 enable = true;
140 config = ''
141 interface eth1 {
142 AdvSendAdvert on;
143 AdvManagedFlag on;
144 AdvOtherConfigFlag off; # we don't really have DNS or NTP or anything like that to distribute
145 prefix ::/64 {
146 AdvOnLink on;
147 AdvAutonomous on;
148 };
149 };
150 '';
151 };
152
153 };
154 };
155
156 # This will be our (residential) router that receives the IPv6 prefix (IA_PD)
157 # and /128 (IA_NA) allocation.
158 #
159 # Here we will actually start using networkd.
160 router = {
161 virtualisation.vlans = [ 1 2 ];
162 systemd.services.systemd-networkd.environment.SYSTEMD_LOG_LEVEL = "debug";
163
164 boot.kernel.sysctl = {
165 # we want to forward packets from the ISP to the client and back.
166 "net.ipv6.conf.all.forwarding" = 1;
167 };
168
169 networking = {
170 useNetworkd = true;
171 useDHCP = false;
172 # Consider enabling this in production and generating firewall rules
173 # for fowarding/input from the configured interfaces so you do not have
174 # to manage multiple places
175 firewall.enable = false;
176 };
177
178 systemd.network = {
179 networks = {
180 # systemd-networkd will load the first network unit file
181 # that matches, ordered lexiographically by filename.
182 # /etc/systemd/network/{40-eth1,99-main}.network already
183 # exists. This network unit must be loaded for the test,
184 # however, hence why this network is named such.
185
186 # Configuration of the interface to the ISP.
187 # We must request accept RAs and request the PD prefix.
188 "01-eth1" = {
189 name = "eth1";
190 networkConfig = {
191 Description = "ISP interface";
192 IPv6AcceptRA = true;
193 #DHCP = false; # no need for legacy IP
194 };
195 linkConfig = {
196 # We care about this interface when talking about being "online".
197 # If this interface is in the `routable` state we can reach
198 # others and they should be able to reach us.
199 RequiredForOnline = "routable";
200 };
201 # This configures the DHCPv6 client part towards the ISPs DHCPv6 server.
202 dhcpV6Config = {
203 # We have to include a request for a prefix in our DHCPv6 client
204 # request packets.
205 # Otherwise the upstream DHCPv6 server wouldn't know if we want a
206 # prefix or not. Note: On some installation it makes sense to
207 # always force that option on the DHPCv6 server since there are
208 # certain CPEs that are just not setting this field but happily
209 # accept the delegated prefix.
210 PrefixDelegationHint = "::/48";
211 };
212 ipv6SendRAConfig = {
213 # Let networkd know that we would very much like to use DHCPv6
214 # to obtain the "managed" information. Not sure why they can't
215 # just take that from the upstream RAs.
216 Managed = true;
217 };
218 };
219
220 # Interface to the client. Here we should redistribute a /64 from
221 # the prefix we received from the ISP.
222 "01-eth2" = {
223 name = "eth2";
224 networkConfig = {
225 Description = "Client interface";
226 # The client shouldn't be allowed to send us RAs, that would be weird.
227 IPv6AcceptRA = false;
228
229 # Delegate prefixes from the DHCPv6 PD pool.
230 DHCPPrefixDelegation = true;
231 IPv6SendRA = true;
232 };
233
234 # In a production environment you should consider setting these as well:
235 # ipv6SendRAConfig = {
236 #EmitDNS = true;
237 #EmitDomains = true;
238 #DNS= = "fe80::1"; # or whatever "well known" IP your router will have on the inside.
239 # };
240
241 # This adds a "random" ULA prefix to the interface that is being
242 # advertised to the clients.
243 # Not used in this test.
244 # ipv6Prefixes = [
245 # {
246 # ipv6PrefixConfig = {
247 # AddressAutoconfiguration = true;
248 # PreferredLifetimeSec = 1800;
249 # ValidLifetimeSec = 1800;
250 # };
251 # }
252 # ];
253 };
254
255 # finally we are going to add a static IPv6 unique local address to
256 # the "lo" interface. This will serve as ICMPv6 echo target to
257 # verify connectivity from the client to the router.
258 "01-lo" = {
259 name = "lo";
260 addresses = [
261 { addressConfig.Address = "FD42::1/128"; }
262 ];
263 };
264 };
265 };
266
267 # make the network-online target a requirement, we wait for it in our test script
268 systemd.targets.network-online.wantedBy = [ "multi-user.target" ];
269 };
270
271 # This is the client behind the router. We should be receving router
272 # advertisements for both the ULA and the delegated prefix.
273 # All we have to do is boot with the default (networkd) configuration.
274 client = {
275 virtualisation.vlans = [ 2 ];
276 systemd.services.systemd-networkd.environment.SYSTEMD_LOG_LEVEL = "debug";
277 networking = {
278 useNetworkd = true;
279 useDHCP = false;
280 };
281
282 # make the network-online target a requirement, we wait for it in our test script
283 systemd.targets.network-online.wantedBy = [ "multi-user.target" ];
284 };
285 };
286
287 testScript = ''
288 # First start the router and wait for it it reach a state where we are
289 # certain networkd is up and it is able to send out RAs
290 router.start()
291 router.wait_for_unit("systemd-networkd.service")
292
293 # After that we can boot the client and wait for the network online target.
294 # Since we only care about IPv6 that should not involve waiting for legacy
295 # IP leases.
296 client.start()
297 client.wait_for_unit("network-online.target")
298
299 # the static address on the router should not be reachable
300 client.wait_until_succeeds("ping -6 -c 1 FD42::1")
301
302 # the global IP of the ISP router should still not be a reachable
303 router.fail("ping -6 -c 1 2001:DB8::1")
304
305 # Once we have internal connectivity boot up the ISP
306 isp.start()
307
308 # Since for the ISP "being online" should have no real meaning we just
309 # wait for the target where all the units have been started.
310 # It probably still takes a few more seconds for all the RA timers to be
311 # fired etc..
312 isp.wait_for_unit("multi-user.target")
313
314 # wait until the uplink interface has a good status
315 router.wait_for_unit("network-online.target")
316 router.wait_until_succeeds("ping -6 -c1 2001:DB8::1")
317
318 # shortly after that the client should have received it's global IPv6
319 # address and thus be able to ping the ISP
320 client.wait_until_succeeds("ping -6 -c1 2001:DB8::1")
321
322 # verify that we got a globally scoped address in eth1 from the
323 # documentation prefix
324 ip_output = client.succeed("ip --json -6 address show dev eth1")
325
326 import json
327
328 ip_json = json.loads(ip_output)[0]
329 assert any(
330 addr["local"].upper().startswith("2001:DB8:")
331 for addr in ip_json["addr_info"]
332 if addr["scope"] == "global"
333 )
334 '';
335})