From dee54f2955af81786400208973d751f4b0c741e6 Mon Sep 17 00:00:00 2001 From: Leon Schuermann Date: Mon, 17 Nov 2025 23:25:12 -0500 Subject: [PATCH] nixos/tests: add test for NixOS container using IPv6 SLAAC This adds a test for a NixOS container being assigned an IPv6 address and route using stateless auto-configuration, using IPv6 router advertisements sent using systemd-networkd by the host. It exercises the newly added `macAddress` option for the container, as it relies on the container self-assigning an address in the specific IPv6 prefix based on its stable MAC address. It can also serve as an example for how one may bridge a NixOS container into an existing network, and assign it stable IPv4 / IPv6 addresses via DHCP and RAs. --- nixos/tests/all-tests.nix | 1 + nixos/tests/containers-ipv6-slaac.nix | 178 ++++++++++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 nixos/tests/containers-ipv6-slaac.nix diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index ac16eaf315a7..f0feb2027ea0 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -395,6 +395,7 @@ in containers-hosts = runTest ./containers-hosts.nix; containers-imperative = runTest ./containers-imperative.nix; containers-ip = runTest ./containers-ip.nix; + containers-ipv6-slaac = runTest ./containers-ipv6-slaac.nix; containers-macvlans = runTest ./containers-macvlans.nix; containers-names = runTest ./containers-names.nix; containers-nested = runTest ./containers-nested.nix; diff --git a/nixos/tests/containers-ipv6-slaac.nix b/nixos/tests/containers-ipv6-slaac.nix new file mode 100644 index 000000000000..ee35c45fe9e4 --- /dev/null +++ b/nixos/tests/containers-ipv6-slaac.nix @@ -0,0 +1,178 @@ +let + ulaPrefix = "fd5f:e1a2:4f0c::/64"; + hostMAC = "72:ec:00:8b:75:44"; + hostSLAACv6 = "fd5f:e1a2:4f0c:0:70ec:ff:fe8b:7544/64"; + containerMAC = "b2:65:3f:c9:6b:10"; + containerSLAACv6 = "fd5f:e1a2:4f0c:0:b065:3fff:fec9:6b10/64"; +in + +{ pkgs, lib, ... }: +{ + name = "containers-ipv6-slaac"; + meta = { + maintainers = with lib.maintainers; [ + lschuermann + ]; + }; + + nodes.machine = + { pkgs, ... }: + { + networking.useNetworkd = true; + networking.useDHCP = false; + + systemd.network.netdevs."br0".netdevConfig = { + Name = "br0"; + Kind = "bridge"; + MACAddress = hostMAC; + }; + + systemd.network.networks."br0" = { + name = "br0"; + + networkConfig = { + IPv6SendRA = true; + + # Disable privacy extensions, which would assign the host a random + # address in the ULA prefix (defeating the purpose of setting the + # bridge's `MACAddress` to assign it a stable address explicitly): + IPv6PrivacyExtensions = false; + }; + + ipv6SendRAConfig = { + # We assign addresses exclusively through SLAAC, not via DHCPv6: + Managed = false; + + # This router is not a default gateway, as we don't have an IPv6 + # upstream. This causes no default route to be inserted with the RA. + RouterLifetimeSec = 0; + UplinkInterface = ":none"; + }; + + ipv6Prefixes = [ + { + # Assign addresses out of the configured ULA prefix: + Prefix = ulaPrefix; + AddressAutoconfiguration = true; + + # All other addresses in this subnet are reachable via Layer 2 (don't + # need to go through the host as a router): + OnLink = true; + + # Assign the host an address out of this subnet: + Assign = true; + # Use MAC address as the basis for SLAAC address generation: + Token = "eui64"; + } + ]; + + # The router doesn't advertise itself as a default gateway, so we + # announce our ULA prefix explicitly: + ipv6RoutePrefixes = [ + { + Route = ulaPrefix; + LifetimeSec = 1800; + } + ]; + + }; + + containers.webserver = { + autoStart = true; + privateNetwork = true; + hostBridge = "br0"; + localMacAddress = containerMAC; + + config = { + networking.useNetworkd = true; + networking.useHostResolvConf = false; + + systemd.network.networks."eth0" = { + name = "eth0"; + DHCP = "no"; + + # Assign an IPv6 address out of the host-advertised prefix, disable + # privacy extensions (which would assign a random address in the + # announced prefix, defeating the purpose of setting the + # `localMacAddress` option to assign the container a stable + # address): + networkConfig = { + IPv6AcceptRA = true; + IPv6PrivacyExtensions = false; + }; + }; + + services.httpd.enable = true; + networking.firewall.allowedTCPPorts = [ 80 ]; + }; + }; + }; + + testScript = '' + import time + + machine.wait_for_unit("default.target") + assert "webserver" in machine.succeed("nixos-container list") + + with subtest("Start the webserver container"): + assert "up" in machine.succeed("nixos-container status webserver") + + with subtest("veth in container has correct MAC address"): + assert "${containerMAC}" in machine.succeed( + "nixos-container run webserver -- ip link show eth0", + ) + + with subtest("Host gets assigned IPv6 in and route for ULA prefix"): + # This is done by systemd-network internally, so should be available + # instantly: + assert "${hostSLAACv6}" in machine.succeed( + "ip addr show br0" + ) + assert "${ulaPrefix}" in machine.succeed( + "ip -6 route show" + ) + + with subtest("Container gets assigned IPv6 in and route for ULA prefix"): + # Give the container a few seconds to assign itself a v6 out of and set + # up a route for the ULA prefix from the router advertisement: + for _ in range(3): + iface_ips = machine.succeed( + "nixos-container run webserver -- ip addr show eth0", + ) + v6_routes = machine.succeed( + "nixos-container run webserver -- ip -6 route show", + ) + if "${containerSLAACv6}" in iface_ips and "${ulaPrefix}" in v6_routes: + break + else: + time.sleep(1) + else: + raise AssertionError( + "Container either did not assign itself the expected SLAAC " + + "v6 out of the announced ULA prefix (${containerSLAACv6}) " + + "or did not assign a route for the URL prefix " + + f"(${ulaPrefix}).\n\n==> ip addr show eth0:\n{iface_ips}" + + f"\n\n==> ip -6 route show:\n{v6_routes}" + ) + + ip6 = "${containerSLAACv6}".split("/")[0] + + with subtest("Container reponds to ICMPv6 echo requests"): + # IPv6 ND can take some time, so try at most 30 times: + for i in range(30): + print(f"Sending ICMP echo request, attempt #{i}") + exit_status, _out = machine.execute(f"ping -n -c 1 {ip6}") + if exit_status == 0: + break + else: + time.sleep(1) + else: + raise AssertionError("Container doesn't respond to pings!") + + with subtest("Container responds to HTTP requests"): + machine.succeed(f"curl --fail http://[{ip6}]/ > /dev/null") + + # Destroying a declarative container should fail. + machine.fail("nixos-container destroy webserver") + ''; +}