From c395a43a78d5899fce2ef992dda83e3a8555cd16 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Wed, 28 Oct 2026 15:48:47 +0100 Subject: [PATCH] Add a static ULA per LAN, announced via SLAAC and RDNSS lans..ipv6.address (a ULA /64) is assigned to the LAN, announced with an explicit prefix (networkd only announces the delegated prefix when prefix delegation is on) and advertised as RDNSS; Unbound listens and answers on it. Checked at build time: valid IPv6 CIDR, /64, fc00::/7. Covered by the onestick VM test (client SLAAC address, ping and DNS over the ULA, RA captured on the wire), assertion cases and a unit test for the /64 prefix helper. Co-Authored-By: Claude Sonnet 5.5 --- PLAN.md | 2 +- modules/router/assertions.nix | 21 +++++++++++++++++++++ modules/router/dns.nix | 8 +++++++- modules/router/lib.nix | 16 ++++++++++++++++ modules/router/networking.nix | 33 ++++++++++++++++++++++++++++++--- modules/router/options.nix | 13 +++++++++++++ tests/assertions.nix | 21 +++++++++++++++++++++ tests/lib.nix | 18 ++++++++++++++++++ tests/onestick.nix | 25 +++++++++++++++++++++++-- 9 files changed, 150 insertions(+), 7 deletions(-) diff --git a/PLAN.md b/PLAN.md index 1bfabbe..da9e337 100644 --- a/PLAN.md +++ b/PLAN.md @@ -151,7 +151,7 @@ Done: PPPoE on VLAN 1001 over the shared NIC (`eno1`, LAN untagged), DNS records Open (in rough order): 1. ~~**IPv6 over the PPP session**~~ **done**: `wan.ipv6.transport = "ppp"` (default `native`) enables IPv6CP in pppd, RA + DHCPv6-PD via networkd on the PPP device, PD to the LAN, DHCPv6 replies accepted on the PPP device and the IPv6 MSS clamped to MTU-60 (1432). Allowed on a shared link (single NIC). Tested in `onestick` (ISP with radvd and a minimal DHCPv6-PD responder, since Kea cannot serve point-to-point links); mutation-tested (MSS clamp, DHCPv6 input rule). `ipv6cp-use-ipaddr` is set as on antigua. -2. **ULA on the LAN** (`fd2c:cdef:cde9::1/64`, announced by RA): needs an extra static address / ULA option per LAN. +2. ~~**ULA on the LAN**~~ **done**: `lans..ipv6.address` (a ULA /64, e.g. `fd2c:cdef:cde9::1/64`) is assigned, announced via SLAAC (explicit `[IPv6Prefix]`, because networkd announces only the delegated prefix when PD is on) and advertised as RDNSS; Unbound listens on it. Build-time checks: valid IPv6 CIDR, /64, fc00::/7. Tested in `onestick` (client SLAAC ULA, ping and DNS over the ULA, RA captured on the wire) and assertion cases; mutation-tested. 3. **Tunnel trust and addressing**: antigua trusts `family`, `freifunk`, `mobile-clients` fully and gives two of them no address; check the schema covers this (`allowManagement`, `dns`, `internet` per tunnel; `addresses` may be empty?). 4. **PPP device name**: `wan` on antigua, `pppoe-wan` here; `ddclient` (Cloudflare, uses the interface address) must follow, or make the name configurable. 5. **Secrets**: the PPPoE credentials are plaintext in antigua's repo/store (rotate the password); here they are read from `/var/lib/router/pppoe-password`. diff --git a/modules/router/assertions.nix b/modules/router/assertions.nix index bd0a108..a921032 100644 --- a/modules/router/assertions.nix +++ b/modules/router/assertions.nix @@ -43,6 +43,27 @@ let message = "router: ${where}: device name `${lan.device}` is longer than 15 characters."; } ] + ++ lib.optionals (lan.ipv6.address != null) ( + let + m = builtins.match "([0-9a-fA-F:]+)/([0-9]{1,3})" lan.ipv6.address; + in + [ + { + assertion = isV6 lan.ipv6.address && v6Cidr lan.ipv6.address && m != null; + message = "router: ${where}.ipv6.address `${lan.ipv6.address}` is not an IPv6 address in CIDR notation."; + } + ] + ++ lib.optionals (m != null) [ + { + assertion = builtins.elemAt m 1 == "64"; + message = "router: ${where}.ipv6.address must be a /64 (SLAAC needs one)."; + } + { + assertion = builtins.match "[fF][cCdD][0-9a-fA-F]{2}:.*" (builtins.elemAt m 0) != null; + message = "router: ${where}.ipv6.address must be a unique-local address (fc00::/7)."; + } + ] + ) ++ lib.optionals lan.dhcp.enable ( [ { diff --git a/modules/router/dns.nix b/modules/router/dns.nix index a0cd434..9999cea 100644 --- a/modules/router/dns.nix +++ b/modules/router/dns.nix @@ -55,13 +55,19 @@ in enable = true; settings = { server = { - interface = [ "127.0.0.1" ] ++ map (l: l.ipv4.address) lans ++ map addrOf dnsAddrs; + interface = [ + "127.0.0.1" + ] + ++ map (l: l.ipv4.address) lans + ++ map (l: addrOf l.ipv6.address) (lib.filter (l: l.ipv6.address != null) lans) + ++ map addrOf dnsAddrs; # LAN addresses are assigned by networkd, possibly after Unbound starts. ip-freebind = true; access-control = [ "127.0.0.0/8 allow" ] ++ map (l: "${l.ipv4.subnet} allow") lans + ++ map (l: "${l.ipv6.address} allow") (lib.filter (l: l.ipv6.address != null) lans) ++ map (a: "${netOf a} allow") dnsAddrs ++ map (n: "${n} allow") dnsPeerNets; local-zone = diff --git a/modules/router/lib.nix b/modules/router/lib.nix index a5d79fd..761e147 100644 --- a/modules/router/lib.nix +++ b/modules/router/lib.nix @@ -62,4 +62,20 @@ rec { in networkOf a.address p == networkOf b.address p; }; + + ipv6 = { + # The first 64 bits of an address (any form, with or without a prefix + # length) as a /64 prefix, e.g. fd2c:cdef:cde9::1/64 -> fd2c:cdef:cde9:0::/64. + prefix64 = + address: + let + bare = builtins.head (lib.splitString "/" address); + halves = lib.splitString "::" bare; + groups = s: lib.filter (g: g != "") (lib.splitString ":" s); + head = groups (builtins.elemAt halves 0); + tail = lib.optionals (lib.length halves > 1) (groups (builtins.elemAt halves 1)); + full = head ++ lib.replicate (8 - lib.length head - lib.length tail) "0" ++ tail; + in + "${lib.concatStringsSep ":" (lib.take 4 full)}::/64"; + }; } diff --git a/modules/router/networking.nix b/modules/router/networking.nix index bac9349..13356b2 100644 --- a/modules/router/networking.nix +++ b/modules/router/networking.nix @@ -129,7 +129,10 @@ in lib.nameValuePair "20-lan-${name}" ( { matchConfig.Name = lan.device; - address = [ "${lan.ipv4.address}/${toString lan.ipv4.prefixLength}" ]; + address = [ + "${lan.ipv4.address}/${toString lan.ipv4.prefixLength}" + ] + ++ lib.optional (lan.ipv6.address != null) lan.ipv6.address; networkConfig = { ConfigureWithoutCarrier = true; # Never accept RAs from the inside. @@ -140,9 +143,22 @@ in } // lib.optionalAttrs lan.ipv6.pd { DHCPPrefixDelegation = true; + } + // lib.optionalAttrs (lan.ipv6.pd || lan.ipv6.address != null) { IPv6SendRA = true; }; } + // lib.optionalAttrs (lan.ipv6.address != null) { + # With prefix delegation networkd announces only the delegated + # prefix, so announce the ULA explicitly (SLAAC, on-link). + ipv6Prefixes = [ + { + Prefix = (import ./lib.nix { inherit lib; }).ipv6.prefix64 lan.ipv6.address; + OnLink = true; + AddressAutoconfiguration = true; + } + ]; + } // lib.optionalAttrs lan.ipv6.pd { dhcpPrefixDelegationConfig = { UplinkInterface = v6dev; @@ -150,8 +166,19 @@ in Announce = true; Assign = true; }; - # No RDNSS yet: the global address is unknown at evaluation time. - ipv6SendRAConfig.EmitDNS = false; + } + // lib.optionalAttrs (lan.ipv6.pd || lan.ipv6.address != null) { + # RDNSS points hosts at the router's resolver on its stable ULA; the + # delegated global address is unknown at evaluation time, so without + # a static `ipv6.address` there is nothing to announce. + ipv6SendRAConfig = + if lan.ipv6.address != null then + { + EmitDNS = true; + DNS = [ (builtins.head (lib.splitString "/" lan.ipv6.address)) ]; + } + else + { EmitDNS = false; }; } ) ) cfg.lans diff --git a/modules/router/options.nix b/modules/router/options.nix index 2aa14ab..170c8c9 100644 --- a/modules/router/options.nix +++ b/modules/router/options.nix @@ -209,6 +209,19 @@ in ''; }; + ipv6.address = mkOption { + type = types.nullOr types.str; + default = null; + example = "fd2c:cdef:cde9::1/64"; + description = '' + Static unique-local (ULA) address of the router on this LAN, + as a /64. It is announced via SLAAC, so hosts get a stable + internal IPv6 address even without a delegated prefix, and + the router's resolver answers on it (also advertised as + RDNSS). Independent of `ipv6.pd`. + ''; + }; + allowManagement = mkOption { type = types.bool; default = true; diff --git a/tests/assertions.nix b/tests/assertions.nix index 7e7f96f..7cc3984 100644 --- a/tests/assertions.nix +++ b/tests/assertions.nix @@ -979,6 +979,26 @@ let "also used by a LAN" ]; }; + ulaValid = { + router.lans.main = lan { ipv6.address = "fd2c:cdef:cde9::1/64"; }; + expect = [ ]; + }; + ulaNotUla = { + router.lans.main = lan { ipv6.address = "2001:db8::1/64"; }; + expect = [ "unique-local" ]; + }; + ulaNot64 = { + router.lans.main = lan { ipv6.address = "fd2c:cdef:cde9::1/48"; }; + expect = [ "must be a /64" ]; + }; + ulaGarbage = { + router.lans.main = lan { ipv6.address = "nonsense"; }; + expect = [ "not an IPv6 address in CIDR" ]; + }; + ulaIpv4 = { + router.lans.main = lan { ipv6.address = "192.168.1.1/24"; }; + expect = [ "not an IPv6 address in CIDR" ]; + }; nonOverlappingLans = { router.lans = { a = lan { }; @@ -1028,6 +1048,7 @@ let "wanValid" "sharedLinkValid" "pppTransportValid" + "ulaValid" "dnsValid" ]; diff --git a/tests/lib.nix b/tests/lib.nix index 7b83a5f..0e18303 100644 --- a/tests/lib.nix +++ b/tests/lib.nix @@ -6,7 +6,25 @@ let inherit (r) ipv4; ip = ipv4.parse; + inherit (r) ipv6; + failures = lib.runTests { + testPrefix64 = { + expr = map ipv6.prefix64 [ + "fd2c:cdef:cde9::1/64" + "fd2c:cdef:cde9:1:2:3:4:5" + "fd00::1" + "fd12:3456:789a:bc::1/64" + "fd12::5:6" + ]; + expected = [ + "fd2c:cdef:cde9:0::/64" + "fd2c:cdef:cde9:1::/64" + "fd00:0:0:0::/64" + "fd12:3456:789a:bc::/64" + "fd12:0:0:0::/64" + ]; + }; testParse = { expr = ip "192.168.10.1"; expected = 3232238081; diff --git a/tests/onestick.nix b/tests/onestick.nix index 9842bd2..e1db419 100644 --- a/tests/onestick.nix +++ b/tests/onestick.nix @@ -192,6 +192,7 @@ pkgs.testers.runNixOSTest { internet = true; ipv4.address = "192.168.10.1"; ipv6.pd = true; + ipv6.address = "fd2c:cdef:cde9::1/64"; dhcp.pool = { start = "192.168.10.100"; end = "192.168.10.199"; @@ -209,7 +210,11 @@ pkgs.testers.runNixOSTest { dnat = true; } ]; - dns.forwarders = [ ispV4 ]; + dns = { + forwarders = [ ispV4 ]; + domain = "lan.test"; + hosts.nas = [ "192.168.10.20" ]; + }; }; }; @@ -294,7 +299,7 @@ pkgs.testers.runNixOSTest { with subtest("the client autoconfigures from the delegated prefix, without NAT"): client.wait_until_succeeds("ip -6 addr show dev eth1 scope global | grep -q 2001:db8:2:") - client_v6 = client.succeed("ip -6 -o addr show dev eth1 scope global | awk '{print $4}' | cut -d/ -f1 | head -1").strip() + client_v6 = client.succeed("ip -6 -o addr show dev eth1 scope global | awk '{print $4}' | cut -d/ -f1 | grep 2001:db8:2: | head -1").strip() client.wait_until_succeeds("ping -6 -c 1 -W 2 2001:db8:1::1") out = client.succeed("curl -sf -g --max-time 5 'http://[2001:db8:1::1]/'") assert out.strip() == client_v6, f"expected {client_v6}, got {out!r}" @@ -313,5 +318,21 @@ pkgs.testers.runNixOSTest { upstream.fail(f"ping -6 -c 1 -W 2 {client_v6}") upstream.fail(f"nc -6 -z -w 2 {client_v6} 22") upstream.fail(f"nc -6 -z -w 2 {ll}%ppp0 22") + + with subtest("the ULA is announced and the resolver answers on it"): + router.succeed("ip -6 addr show dev eth1 | grep -q fd2c:cdef:cde9::1/64") + client.wait_until_succeeds("ip -6 addr show dev eth1 scope global | grep -q fd2c:cdef:cde9:") + client.succeed("ping -6 -c 1 -W 2 fd2c:cdef:cde9::1") + out = client.succeed("dig +short +time=2 +tries=1 @fd2c:cdef:cde9::1 nas.lan.test").strip() + assert out == "192.168.10.20", out + # The RA carries the ULA prefix (SLAAC, on-link) and the resolver as RDNSS. + # A link bounce makes the client solicit one right away. + router.succeed("(timeout 20 tcpdump -nn -vv -i eth1 -c 1 'icmp6 and ip6[40] == 134' > /tmp/ra.txt 2>&1 &)") + router.wait_until_succeeds("pgrep tcpdump") + router.sleep(1) + client.succeed("ip link set eth1 down; ip link set eth1 up") + ra = router.wait_until_succeeds("grep -q 'router lifetime' /tmp/ra.txt && cat /tmp/ra.txt") + assert "fd2c:cdef:cde9::/64, Flags [onlink, auto]" in ra, ra + assert "rdnss option" in ra and "fd2c:cdef:cde9::1" in ra, ra ''; }