Add a static ULA per LAN, announced via SLAAC and RDNSS
lans.<n>.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 <noreply@anthropic.com>
This commit is contained in:
@@ -151,7 +151,7 @@ Done: PPPoE on VLAN 1001 over the shared NIC (`eno1`, LAN untagged), DNS records
|
|||||||
|
|
||||||
Open (in rough order):
|
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.
|
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.<n>.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?).
|
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.
|
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`.
|
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`.
|
||||||
|
|||||||
@@ -43,6 +43,27 @@ let
|
|||||||
message = "router: ${where}: device name `${lan.device}` is longer than 15 characters.";
|
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 (
|
++ lib.optionals lan.dhcp.enable (
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -55,13 +55,19 @@ in
|
|||||||
enable = true;
|
enable = true;
|
||||||
settings = {
|
settings = {
|
||||||
server = {
|
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.
|
# LAN addresses are assigned by networkd, possibly after Unbound starts.
|
||||||
ip-freebind = true;
|
ip-freebind = true;
|
||||||
access-control = [
|
access-control = [
|
||||||
"127.0.0.0/8 allow"
|
"127.0.0.0/8 allow"
|
||||||
]
|
]
|
||||||
++ map (l: "${l.ipv4.subnet} allow") lans
|
++ 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 (a: "${netOf a} allow") dnsAddrs
|
||||||
++ map (n: "${n} allow") dnsPeerNets;
|
++ map (n: "${n} allow") dnsPeerNets;
|
||||||
local-zone =
|
local-zone =
|
||||||
|
|||||||
@@ -62,4 +62,20 @@ rec {
|
|||||||
in
|
in
|
||||||
networkOf a.address p == networkOf b.address p;
|
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";
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,7 +129,10 @@ in
|
|||||||
lib.nameValuePair "20-lan-${name}" (
|
lib.nameValuePair "20-lan-${name}" (
|
||||||
{
|
{
|
||||||
matchConfig.Name = lan.device;
|
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 = {
|
networkConfig = {
|
||||||
ConfigureWithoutCarrier = true;
|
ConfigureWithoutCarrier = true;
|
||||||
# Never accept RAs from the inside.
|
# Never accept RAs from the inside.
|
||||||
@@ -140,9 +143,22 @@ in
|
|||||||
}
|
}
|
||||||
// lib.optionalAttrs lan.ipv6.pd {
|
// lib.optionalAttrs lan.ipv6.pd {
|
||||||
DHCPPrefixDelegation = true;
|
DHCPPrefixDelegation = true;
|
||||||
|
}
|
||||||
|
// lib.optionalAttrs (lan.ipv6.pd || lan.ipv6.address != null) {
|
||||||
IPv6SendRA = true;
|
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 {
|
// lib.optionalAttrs lan.ipv6.pd {
|
||||||
dhcpPrefixDelegationConfig = {
|
dhcpPrefixDelegationConfig = {
|
||||||
UplinkInterface = v6dev;
|
UplinkInterface = v6dev;
|
||||||
@@ -150,8 +166,19 @@ in
|
|||||||
Announce = true;
|
Announce = true;
|
||||||
Assign = 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
|
) cfg.lans
|
||||||
|
|||||||
@@ -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 {
|
allowManagement = mkOption {
|
||||||
type = types.bool;
|
type = types.bool;
|
||||||
default = true;
|
default = true;
|
||||||
|
|||||||
@@ -979,6 +979,26 @@ let
|
|||||||
"also used by a LAN"
|
"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 = {
|
nonOverlappingLans = {
|
||||||
router.lans = {
|
router.lans = {
|
||||||
a = lan { };
|
a = lan { };
|
||||||
@@ -1028,6 +1048,7 @@ let
|
|||||||
"wanValid"
|
"wanValid"
|
||||||
"sharedLinkValid"
|
"sharedLinkValid"
|
||||||
"pppTransportValid"
|
"pppTransportValid"
|
||||||
|
"ulaValid"
|
||||||
"dnsValid"
|
"dnsValid"
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,25 @@ let
|
|||||||
inherit (r) ipv4;
|
inherit (r) ipv4;
|
||||||
ip = ipv4.parse;
|
ip = ipv4.parse;
|
||||||
|
|
||||||
|
inherit (r) ipv6;
|
||||||
|
|
||||||
failures = lib.runTests {
|
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 = {
|
testParse = {
|
||||||
expr = ip "192.168.10.1";
|
expr = ip "192.168.10.1";
|
||||||
expected = 3232238081;
|
expected = 3232238081;
|
||||||
|
|||||||
+23
-2
@@ -192,6 +192,7 @@ pkgs.testers.runNixOSTest {
|
|||||||
internet = true;
|
internet = true;
|
||||||
ipv4.address = "192.168.10.1";
|
ipv4.address = "192.168.10.1";
|
||||||
ipv6.pd = true;
|
ipv6.pd = true;
|
||||||
|
ipv6.address = "fd2c:cdef:cde9::1/64";
|
||||||
dhcp.pool = {
|
dhcp.pool = {
|
||||||
start = "192.168.10.100";
|
start = "192.168.10.100";
|
||||||
end = "192.168.10.199";
|
end = "192.168.10.199";
|
||||||
@@ -209,7 +210,11 @@ pkgs.testers.runNixOSTest {
|
|||||||
dnat = true;
|
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"):
|
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.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")
|
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]/'")
|
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}"
|
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"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 {client_v6} 22")
|
||||||
upstream.fail(f"nc -6 -z -w 2 {ll}%ppp0 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
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user