diff --git a/nixos/modules/virtualisation/nixos-containers.nix b/nixos/modules/virtualisation/nixos-containers.nix index 469f083c1507..a952e64c5b65 100644 --- a/nixos/modules/virtualisation/nixos-containers.nix +++ b/nixos/modules/virtualisation/nixos-containers.nix @@ -56,8 +56,13 @@ let # Initialise the container side of the veth pair. if [[ -n "''${HOST_ADDRESS-}" ]] || [[ -n "''${HOST_ADDRESS6-}" ]] || [[ -n "''${LOCAL_ADDRESS-}" ]] || [[ -n "''${LOCAL_ADDRESS6-}" ]] || - [[ -n "''${HOST_BRIDGE-}" ]]; then + [[ -n "''${HOST_BRIDGE-}" ]] || [[ -n "''${LOCAL_MAC_ADDRESS-}" ]]; then ip link set host0 name eth0 + + if [[ -n "''${LOCAL_MAC_ADDRESS-}" ]]; then + ip link set dev eth0 address "$LOCAL_MAC_ADDRESS" + fi + ip link set dev eth0 up if [[ -n "''${LOCAL_ADDRESS-}" ]]; then @@ -140,7 +145,8 @@ let fi if [[ -n "''${HOST_ADDRESS-}" ]] || [[ -n "''${LOCAL_ADDRESS-}" ]] || - [[ -n "''${HOST_ADDRESS6-}" ]] || [[ -n "''${LOCAL_ADDRESS6-}" ]]; then + [[ -n "''${HOST_ADDRESS6-}" ]] || [[ -n "''${LOCAL_ADDRESS6-}" ]] || + [[ -n "''${LOCAL_MAC_ADDRESS-}" ]]; then extraFlags+=("--network-veth") fi @@ -207,6 +213,7 @@ let --setenv LOCAL_ADDRESS="''${LOCAL_ADDRESS-}" \ --setenv HOST_ADDRESS6="''${HOST_ADDRESS6-}" \ --setenv LOCAL_ADDRESS6="''${LOCAL_ADDRESS6-}" \ + --setenv LOCAL_MAC_ADDRESS="''${LOCAL_MAC_ADDRESS-}" \ --setenv HOST_PORT="''${HOST_PORT-}" \ --setenv PATH="$PATH" \ ${optionalString cfg.ephemeral "--ephemeral"} \ @@ -489,6 +496,18 @@ let ''; }; + localMacAddress = mkOption { + type = types.nullOr (lib.types.strMatching "([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}"); + default = null; + example = "de:b7:73:01:10:90"; + description = '' + The MAC address assigned to the interface in the container. This address + is assigned early during container boot, and can thus be reliably used + for setups like IPv6 SLAAC with router advertisements. If this option is + not specified, the veth devices gets assigned a random, + locally-administered unicast MAC address. + ''; + }; }; dummyConfig = { @@ -501,6 +520,7 @@ let hostAddress6 = null; localAddress = null; localAddress6 = null; + localmacAddress = null; tmpfs = null; }; @@ -1115,6 +1135,9 @@ in ${optionalString (cfg.localAddress6 != null) '' LOCAL_ADDRESS6=${cfg.localAddress6} ''} + ${optionalString (cfg.localMacAddress != null) '' + LOCAL_MAC_ADDRESS=${cfg.localMacAddress} + ''} ''} ${optionalString (cfg.networkNamespace != null) '' NETWORK_NAMESPACE_PATH=${cfg.networkNamespace} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index b276d07786bc..28ca9b54009d 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -387,6 +387,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") + ''; +}