Add IPv6 prefix delegation and PPPoE uplink

IPv6: router.wan.ipv6.enable mirrors the existing uplink config (DHCPv6 with
WithoutRA=solicit, delegated prefix). The ISP delegates a single /64, so
exactly one LAN may claim it via router.lans.<name>.ipv6.pd (asserted); other
LANs stay IPv4-only. SLAAC only, no NAT66.

PPPoE: router.wan.ipv4.type = "pppoe" runs pppd on an optional VLAN of the
uplink, with the password read from a file at service start (never the Nix
store). NAT, forwarding and MSS clamping (MTU 1492) use the derived PPP
device; native IPv6 stays on the untagged link.

Tests: basic.nix is parameterised by uplink type; the pppoe variant uses a
fake ISP with pppoe-server (VLAN 7, CHAP). Both variants cover IPv6 PD, SLAAC,
routing without NAT and WAN-deny. Assertion tests gain PD/PPPoE cases and a
full-system evaluation step.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 16:29:52 +01:00
co-authored by Claude Sonnet 5.5
parent 96e5b7b859
commit 6038367252
13 changed files with 671 additions and 72 deletions
+21 -1
View File
@@ -62,6 +62,10 @@ examples/ # sample configs
## Status
**PPPoE done**: `pppoe` VM test = the same topology with a fake ISP running `pppoe-server` (VLAN 7, CHAP, password containing a quote) — covers session, NAT, MTU, MSS clamp (mutation-tested) and WAN-deny. `basic` remains the DHCPv4 variant.
**IPv6 + prefix delegation done (single /64)**: `router.wan.ipv6.enable` and `router.lans.<name>.ipv6.pd` (at most one LAN, asserted). The VM test now covers the router's DHCPv6 address and delegated /64, SLAAC on the client, routing without NAT66, and WAN-deny over IPv6 (mutation-tested). Still open: RDNSS, other LANs are IPv4-only, DHCPv6 reservations.
**Phase 1 (MVP) skeleton done**: flake, `router.*` schema (wan, lans, dns.forwarders), networkd, nftables (zone-less for now: WAN/LAN), Kea DHCPv4, Unbound, assertions. `nix build .#checks.x86_64-linux.basic` runs a 3-node VM test (upstream/router/client) covering WAN DHCP, LAN DHCP+DNS, NAT, WAN-deny and LAN SSH.
Interactive lab: `nix run .#lab` (x86_64) opens tmux with one console pane per VM plus the test-driver REPL (`router.shell_interact()`, `client.succeed(...)`, `test_script()`). The REPL pane also shows all VM output interleaved.
@@ -71,7 +75,23 @@ Learned along the way:
- In NixOS tests `fail(...)` also passes on "command not found": install probe tools explicitly and add a positive control per negative check.
- Unbound answers `.test` locally (RFC 6761) and validates DNSSEC; tests using fake domains need both `local-zone ... transparent` and `domain-insecure` (both verified necessary; set in the test, not the module).
- `modules/router/lib.nix` has IPv4 helpers (parse/format/netmask/cidr/overlap). `ipv4.subnet` is now derived from `address` + `prefixLength` (read-only). Assertions also cover pool bounds, pool containing the router address, and overlapping LANs. IPv6 helpers (prefix arithmetic for PD subnet-IDs) are still to do.
- Checks: `basic` (VM test), `lib` (helper unit tests), `assertions` (broken configs must fail with exact messages). Not yet covered: IPv6, VLANs, WireGuard, formal zones.
- Checks: `basic` and `pppoe` (VM tests), `lib` (helper unit tests), `assertions` (broken configs must fail with exact messages). Not yet covered: VLANs, WireGuard, formal zones.
## IPv6 design (confirmed)
- **WAN**: mirrors the existing router's uplink config — networkd `DHCP` (v6), `IPv6AcceptRA = true`, `KeepConfiguration = yes`, `dhcpV6Config = { WithoutRA = "solicit"; UseDelegatedPrefix = true; }`. The ISP sends RAs (default route) without DHCPv6 flags, so the router solicits on its own.
- **Delegation**: the ISP delegates a single **/64**, which cannot be split. Exactly one LAN may claim it via an explicit flag (`router.lans.<name>.ipv6.pd = true`); an assertion rejects more than one.
- **LANs**: SLAAC only (RA, no DHCPv6). All other LANs/VLANs are **IPv4-only** for now (ULA or NPTv6 can come later).
- **Firewall**: inet table covers v6; WAN→LAN forwarding stays denied, no NAT66.
- **Test**: fake ISP on `upstream` = Kea DHCPv6 (IA_NA + IA_PD /64 from a /48 pool) + networkd RA sender (default route only) + a static route for the delegated prefix back to the router.
- **Known gap**: no RDNSS in RAs yet (the router's global LAN address is not known at eval time); clients still get DNS via DHCPv4.
## PPPoE uplink (confirmed)
- **IPv4 via PPPoE** (`router.wan.ipv4.type = "pppoe"`), carried on an optional tagged **VLAN** on the WAN link (`pppoe.vlan`); native DHCPv6/PD stays on the untagged link.
- `wan.interface` is the physical link; the derived `wan.ipv4.interface` (`pppoe-wan` or the link) is where NAT, MSS clamping and the IPv4 forwarding rule apply. IPv6 forwarding keeps using the physical link.
- pppd (`pppoe.so`) runs as `router-pppoe.service`, ordered after the VLAN device. Credentials: `username` inline, password from `pppoe.passwordFile` (type forbids Nix-store paths); the options file is generated into a root-only runtime dir. **Deployment must provide that file** on persistent storage (or via agenix/sops).
- **MTU 1492 + TCP MSS clamp** (1452) in both directions on the PPP device; RFC 4638 not implemented.
## Open items
+16
View File
@@ -79,6 +79,22 @@ in
assertion = !(lib.elem cfg.wan.interface (map (l: l.interface) lans));
message = "router: the WAN interface `${cfg.wan.interface}` is also used by a LAN.";
}
{
assertion = cfg.wan.ipv4.type != "pppoe" || cfg.wan.ipv4.pppoe.username != "";
message = "router: router.wan.ipv4.pppoe.username must be set for a PPPoE uplink.";
}
{
assertion = cfg.wan.ipv4.type != "pppoe" || cfg.wan.ipv4.pppoe.passwordFile != null;
message = "router: router.wan.ipv4.pppoe.passwordFile must be set for a PPPoE uplink.";
}
{
assertion = lib.length (lib.filter (l: l.ipv6.pd) lans) <= 1;
message = "router: more than one LAN sets ipv6.pd, but the delegated /64 cannot be split.";
}
{
assertion = !(lib.any (l: l.ipv6.pd) lans) || cfg.wan.ipv6.enable;
message = "router: a LAN sets ipv6.pd but router.wan.ipv6.enable is false.";
}
{
assertion =
let
+1
View File
@@ -3,6 +3,7 @@
./options.nix
./assertions.nix
./networking.nix
./pppoe.nix
./firewall.nix
./dhcp.nix
./dns.nix
+8 -2
View File
@@ -25,8 +25,14 @@ in
interface = l.interface;
pools = [ { pool = "${l.dhcp.pool.start} - ${l.dhcp.pool.end}"; } ];
option-data = [
{ name = "routers"; data = l.ipv4.address; }
{ name = "domain-name-servers"; data = l.ipv4.address; }
{
name = "routers";
data = l.ipv4.address;
}
{
name = "domain-name-servers";
data = l.ipv4.address;
}
];
}) dhcpLans;
};
+4 -2
View File
@@ -14,9 +14,11 @@ in
ip-freebind = true;
access-control = [
"127.0.0.0/8 allow"
] ++ map (l: "${l.ipv4.subnet} allow") lans;
]
++ map (l: "${l.ipv4.subnet} allow") lans;
};
} // lib.optionalAttrs (cfg.dns.forwarders != [ ]) {
}
// lib.optionalAttrs (cfg.dns.forwarders != [ ]) {
forward-zone = [
{
name = ".";
+19 -5
View File
@@ -6,6 +6,14 @@ let
set = ifaces: "{ ${lib.concatMapStringsSep ", " (i: ''"${i}"'') ifaces} }";
lanIfaces = map (l: l.interface) lans;
mgmtIfaces = map (l: l.interface) (lib.filter (l: l.allowManagement) lans);
uplink4 = cfg.wan.ipv4.interface;
pppoe = cfg.wan.ipv4.type == "pppoe";
# IPv4 leaves via the PPP device, IPv6 via the physical uplink.
uplinks = lib.unique [
cfg.wan.interface
uplink4
];
mss = cfg.wan.ipv4.pppoe.mtu - 40;
dhcpIfaces = map (l: l.interface) (lib.filter (l: l.dhcp.enable) lans);
in
{
@@ -34,12 +42,13 @@ in
iifname ${set lanIfaces} icmp type echo-request accept
iifname ${set lanIfaces} icmpv6 type echo-request accept
iifname "${cfg.wan.interface}" udp dport 68 accept
${lib.optionalString (!pppoe) ''iifname "${cfg.wan.interface}" udp dport 68 accept''}
${lib.optionalString cfg.wan.ipv6.enable ''iifname "${cfg.wan.interface}" udp dport 546 accept''}
iifname ${set lanIfaces} udp dport 53 accept
iifname ${set lanIfaces} tcp dport 53 accept
${lib.optionalString (dhcpIfaces != [ ]) ''iifname ${set dhcpIfaces} udp dport 67 accept''}
${lib.optionalString (mgmtIfaces != [ ]) ''iifname ${set mgmtIfaces} tcp dport 22 accept''}
${lib.optionalString (dhcpIfaces != [ ]) "iifname ${set dhcpIfaces} udp dport 67 accept"}
${lib.optionalString (mgmtIfaces != [ ]) "iifname ${set mgmtIfaces} tcp dport 22 accept"}
}
chain forward {
@@ -50,7 +59,12 @@ in
icmpv6 type { destination-unreachable, packet-too-big, time-exceeded, parameter-problem } accept
iifname ${set lanIfaces} oifname "${cfg.wan.interface}" accept
${lib.optionalString pppoe ''
# Clamp MSS to the PPP MTU in both directions (never raised).
iifname "${uplink4}" tcp flags syn tcp option maxseg size > ${toString mss} tcp option maxseg size set ${toString mss}
oifname "${uplink4}" tcp flags syn tcp option maxseg size > ${toString mss} tcp option maxseg size set ${toString mss}
''}
iifname ${set lanIfaces} oifname ${set uplinks} accept
}
'';
};
@@ -60,7 +74,7 @@ in
content = ''
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
oifname "${cfg.wan.interface}" masquerade
oifname "${uplink4}" masquerade
}
'';
};
+3 -1
View File
@@ -33,7 +33,9 @@ rec {
networkOf = address: prefixLength: builtins.bitAnd address (netMask prefixLength);
# "192.168.10.1" /24 -> "192.168.10.0/24"
cidr = address: prefixLength: "${format (networkOf (parse address) prefixLength)}/${toString prefixLength}";
cidr =
address: prefixLength:
"${format (networkOf (parse address) prefixLength)}/${toString prefixLength}";
inSubnet =
{ address, prefixLength }: ip: networkOf ip prefixLength == networkOf address prefixLength;
+74 -8
View File
@@ -1,6 +1,11 @@
{ config, lib, ... }:
let
cfg = config.router;
v6 = cfg.wan.ipv6.enable;
pppoe = cfg.wan.ipv4.type == "pppoe";
vlan = cfg.wan.ipv4.pppoe.vlan;
vlanDevice = "${cfg.wan.interface}.${toString vlan}";
dhcp = if pppoe then (if v6 then "ipv6" else "no") else (if v6 then "yes" else "ipv4");
in
{
config = lib.mkIf cfg.enable {
@@ -9,22 +14,83 @@ in
boot.kernel.sysctl = {
"net.ipv4.conf.all.forwarding" = 1;
"net.ipv6.conf.all.forwarding" = 1;
};
systemd.network.netdevs = lib.optionalAttrs (pppoe && vlan != null) {
"05-wan-pppoe" = {
netdevConfig = {
Kind = "vlan";
Name = vlanDevice;
};
vlanConfig.Id = vlan;
};
};
systemd.network.networks = {
"10-wan" = {
matchConfig.Name = cfg.wan.interface;
networkConfig.DHCP = "ipv4";
networkConfig = {
DHCP = dhcp;
}
// lib.optionalAttrs v6 {
IPv6AcceptRA = true;
KeepConfiguration = "yes";
}
// lib.optionalAttrs (pppoe && vlan != null) {
VLAN = [ vlanDevice ];
};
}
// lib.optionalAttrs (!pppoe) {
# The router runs its own resolver; never adopt the ISP's DNS for the host.
dhcpV4Config.UseDNS = false;
};
} // lib.mapAttrs' (
name: lan:
lib.nameValuePair "20-lan-${name}" {
matchConfig.Name = lan.interface;
address = [ "${lan.ipv4.address}/${toString lan.ipv4.prefixLength}" ];
networkConfig.ConfigureWithoutCarrier = true;
}
// lib.optionalAttrs v6 {
# The ISP sends RAs (default route) without DHCPv6 flags, so solicit anyway.
dhcpV6Config = {
WithoutRA = "solicit";
UseDelegatedPrefix = true;
UseDNS = false;
};
};
}
// lib.optionalAttrs (pppoe && vlan != null) {
# pppd configures the session; networkd only has to bring the device up.
"11-wan-pppoe" = {
matchConfig.Name = vlanDevice;
networkConfig = {
LinkLocalAddressing = "no";
IPv6AcceptRA = false;
};
};
}
// lib.mapAttrs' (
name: lan:
lib.nameValuePair "20-lan-${name}" (
{
matchConfig.Name = lan.interface;
address = [ "${lan.ipv4.address}/${toString lan.ipv4.prefixLength}" ];
networkConfig = {
ConfigureWithoutCarrier = true;
# Never accept RAs from the inside.
IPv6AcceptRA = false;
}
// lib.optionalAttrs lan.ipv6.pd {
DHCPPrefixDelegation = true;
IPv6SendRA = true;
};
}
// lib.optionalAttrs lan.ipv6.pd {
dhcpPrefixDelegationConfig = {
UplinkInterface = cfg.wan.interface;
SubnetId = 0;
Announce = true;
Assign = true;
};
# No RDNSS yet: the global address is unknown at evaluation time.
ipv6SendRAConfig.EmitDNS = false;
}
)
) cfg.lans;
};
}
+74 -2
View File
@@ -1,4 +1,4 @@
{ lib, ... }:
{ config, lib, ... }:
let
inherit (lib) mkOption mkEnableOption types;
rlib = import ./lib.nix { inherit lib; };
@@ -11,8 +11,66 @@ in
interface = mkOption {
type = types.str;
example = "eth0";
description = "Name of the uplink interface.";
description = ''
Physical uplink interface. Native IPv6 (DHCPv6) runs on it untagged;
with PPPoE it also carries the (optionally tagged) PPPoE session.
'';
};
ipv4 = {
type = mkOption {
type = types.enum [
"dhcp"
"pppoe"
];
default = "dhcp";
description = "How the router gets its IPv4 uplink.";
};
pppoe = {
vlan = mkOption {
type = types.nullOr (types.ints.between 1 4094);
default = null;
description = "VLAN ID the ISP requires for PPPoE, or null for untagged.";
};
username = mkOption {
type = types.str;
default = "";
description = "PPP authentication username.";
};
passwordFile = mkOption {
type = types.nullOr (
types.pathWith {
absolute = true;
inStore = false;
}
);
default = null;
example = "/var/lib/secrets/pppoe-password";
description = ''
File containing the PPP password, read at service start. Must not
be in the Nix store (it would be world-readable).
'';
};
mtu = mkOption {
type = types.ints.between 576 1500;
default = 1492;
description = "PPP MTU/MRU; TCP MSS is clamped to match on forwarded traffic.";
};
};
interface = mkOption {
type = types.str;
readOnly = true;
internal = true;
description = "Device IPv4 traffic leaves through (derived).";
};
};
ipv6.enable = mkEnableOption ''
IPv6 on the uplink: DHCPv6 (soliciting even without RA flags) plus
prefix delegation. The ISP is expected to delegate a single /64
'';
};
lans = mkOption {
@@ -63,6 +121,17 @@ in
};
};
ipv6.pd = mkOption {
type = types.bool;
default = false;
description = ''
Whether this LAN receives the /64 delegated on the uplink
(assigned to the interface and announced via SLAAC). A /64
cannot be split, so at most one LAN may set this; the others
stay IPv4-only.
'';
};
allowManagement = mkOption {
type = types.bool;
default = true;
@@ -87,4 +156,7 @@ in
description = "Upstream resolvers. When empty, Unbound resolves recursively.";
};
};
config.router.wan.ipv4.interface =
if config.router.wan.ipv4.type == "pppoe" then "pppoe-wan" else config.router.wan.interface;
}
+86
View File
@@ -0,0 +1,86 @@
# PPPoE uplink for IPv4. pppd owns the session (address, default route); the
# VLAN device it runs on is created in networking.nix.
{
config,
lib,
pkgs,
...
}:
let
cfg = config.router;
wan = cfg.wan;
pppoe = wan.ipv4.pppoe;
# Device carrying the PPPoE session: the VLAN on the uplink, or the uplink itself.
device = if pppoe.vlan == null then wan.interface else "${wan.interface}.${toString pppoe.vlan}";
# Unit that appears once udev has seen the device (VLAN devices are created
# by networkd after boot has begun, so pppd must not start before it).
deviceUnit = "sys-subsystem-net-devices-${lib.replaceStrings [ "-" ] [ "\\x2d" ] device}.device";
staticOptions = pkgs.writeText "router-pppoe-options" ''
plugin pppoe.so
nic-${device}
ifname ${wan.ipv4.interface}
user "${pppoe.username}"
noauth
hide-password
defaultroute
replacedefaultroute
noipdefault
nodefaultroute6
noccp
mtu ${toString pppoe.mtu}
mru ${toString pppoe.mtu}
lcp-echo-interval 10
lcp-echo-failure 3
nodetach
persist
maxfail 0
holdoff 5
'';
prepare = pkgs.writeShellScript "router-pppoe-prepare" ''
set -eu
umask 077
password=$(cat ${lib.escapeShellArg (toString pppoe.passwordFile)})
# pppd option files use C-style escapes inside double quotes.
password=''${password//\\/\\\\}
password=''${password//\"/\\\"}
{
cat ${staticOptions}
printf 'password "%s"\n' "$password"
} > /run/router-pppoe/options
'';
in
{
config = lib.mkIf (cfg.enable && wan.ipv4.type == "pppoe") {
boot.kernelModules = [
"pppoe"
"pppox"
"ppp_generic"
];
# pppd refuses to start without a (possibly empty) system options file.
environment.etc."ppp/options".text = "";
systemd.services.router-pppoe = {
description = "PPPoE uplink (${device})";
wantedBy = [ "multi-user.target" ];
wants = [ deviceUnit ];
after = [
"network.target"
"systemd-networkd.service"
deviceUnit
];
serviceConfig = {
RuntimeDirectory = "router-pppoe";
RuntimeDirectoryMode = "0700";
ExecStartPre = prepare;
ExecStart = "${pkgs.ppp}/bin/pppd file /run/router-pppoe/options";
Restart = "always";
RestartSec = 5;
};
};
};
}
+71 -4
View File
@@ -17,10 +17,10 @@ let
fsType = "ext4";
};
system.stateVersion = "25.05";
router = {
router = lib.recursiveUpdate {
enable = true;
wan.interface = "eth0";
} // router;
} router;
}
];
}).config;
@@ -73,6 +73,59 @@ let
};
expect = [ "`a` (192.168.10.0/24) and `b` (192.168.10.0/24) overlap" ];
};
pdOnOneLan = {
router = {
wan.ipv6.enable = true;
lans.main = lan { ipv6.pd = true; };
};
expect = [ ];
};
pdOnTwoLans = {
router = {
wan.ipv6.enable = true;
lans = {
a = lan { ipv6.pd = true; };
b = lan {
interface = "eth2";
ipv4.address = "192.168.20.1";
dhcp.pool = { start = "192.168.20.100"; end = "192.168.20.199"; };
ipv6.pd = true;
};
};
};
expect = [ "delegated /64 cannot be split" ];
};
pdWithoutWanIpv6 = {
router.lans.main = lan { ipv6.pd = true; };
expect = [ "router.wan.ipv6.enable is false" ];
};
pppoeMissingCredentials = {
router = {
wan.ipv4.type = "pppoe";
lans.main = lan { };
};
expect = [
"pppoe.username must be set"
"pppoe.passwordFile must be set"
];
};
pppoeValid = {
router = {
wan = {
ipv4 = {
type = "pppoe";
pppoe = {
vlan = 7;
username = "user@isp";
passwordFile = "/var/lib/secrets/pppoe";
};
};
ipv6.enable = true;
};
lans.main = lan { ipv6.pd = true; };
};
expect = [ ];
};
nonOverlappingLans = {
router.lans = {
a = lan { };
@@ -94,15 +147,29 @@ let
) cases
);
# Assertions alone do not force every option (e.g. networkd unit checks), so
# fully evaluate the system for the configs that are meant to be valid.
fullEval =
name:
let
r = builtins.tryEval (builtins.seq (evalRouter cases.${name}.router).system.build.toplevel.drvPath true);
in
lib.optional (!r.success) "${name}: full system evaluation failed";
fullEvalProblems = lib.concatMap fullEval [
"valid"
"pdOnOneLan"
"pppoeValid"
];
subnet = (evalRouter { lans.main = lan { ipv4.prefixLength = 20; ipv4.address = "172.16.37.1"; dhcp.enable = false; }; }).router.lans.main.ipv4.subnet;
subnetProblem = lib.optional (subnet != "172.16.32.0/20") "derived subnet: expected 172.16.32.0/20, got ${subnet}";
in
pkgs.runCommand "router-assertion-tests" { } (
if problems ++ subnetProblem == [ ] then
if problems ++ subnetProblem ++ fullEvalProblems == [ ] then
"touch $out"
else
''
printf '%s\n' ${lib.escapeShellArgs (problems ++ subnetProblem)} >&2
printf '%s\n' ${lib.escapeShellArgs (problems ++ subnetProblem ++ fullEvalProblems)} >&2
exit 1
''
)
+290 -47
View File
@@ -1,57 +1,255 @@
# Single WAN (DHCP) + single LAN: DHCP, DNS, NAT and the default firewall policy.
# Single WAN + single LAN: DHCP, DNS, NAT, SLAAC and the default firewall
# policy for both address families. `wan4` selects how the IPv4 uplink works:
#
# upstream (ISP, 203.0.113.1) -- vlan 1 -- router -- vlan 2 -- client
{ self, pkgs }:
# dhcp - DHCPv4 on the uplink (ISP 203.0.113.1)
# pppoe - PPPoE over VLAN 7 on the uplink (ISP 198.51.100.1, PPP MTU 1492)
#
# IPv6 is native and untagged on the uplink in both variants.
#
# upstream (ISP) -- vlan 1 -- router -- vlan 2 -- client
#
# The fake ISP mimics the real uplink: RAs carry only a default route (no
# prefix to autoconfigure, no DHCPv6 flags), and Kea hands out one address
# plus a single delegated /64 out of 2001:db8:2::/48.
{
self,
pkgs,
wan4 ? "dhcp",
}:
let
pppoe = wan4 == "pppoe";
ispV4 = if pppoe then "198.51.100.1" else "203.0.113.1";
routerV4 = if pppoe then "198.51.100.100" else "203.0.113.100";
pppoeVlan = 7;
inherit (pkgs) lib;
# Indent every line but the first (the interpolation site supplies that one).
indent =
n: text:
lib.concatStringsSep "\n${lib.fixedWidthString n " " ""}" (
lib.splitString "\n" (lib.removeSuffix "\n" text)
);
uplinkWait =
if pppoe then
''
upstream.wait_for_unit("pppoe-server.service")
router.wait_for_unit("router-pppoe.service")
router.wait_until_succeeds("ip -4 addr show dev pppoe-wan | grep -q ${routerV4}")
router.wait_until_succeeds("ip -4 route show default | grep -q 'dev pppoe-wan'")
# The ISP's route to the LAN needs the session to exist first.
upstream.wait_until_succeeds("ip -4 addr show | grep -q ${ispV4}")
upstream.succeed("ip route replace 192.168.10.0/24 via ${routerV4}")
''
else
''
router.wait_until_succeeds("ip -4 addr show dev eth1 | grep -q ${routerV4}")
'';
mssSubtest = ''
with subtest("PPP link has MTU 1492 and forwarded TCP MSS is clamped"):
assert "mtu 1492" in router.succeed("ip link show pppoe-wan"), "unexpected PPP MTU"
# Capture the SYN as it leaves the router; the client itself offers MSS 1460.
router.succeed("tcpdump -nn -i pppoe-wan -c 1 -w /tmp/syn.pcap 'tcp[tcpflags] & (tcp-syn|tcp-ack) = tcp-syn' >/dev/null 2>&1 &")
router.wait_until_succeeds("pgrep tcpdump")
router.sleep(1)
client.succeed("curl -sf --max-time 5 --resolve example.test:80:${ispV4} http://example.test/")
mss = router.wait_until_succeeds("tcpdump -nn -v -r /tmp/syn.pcap 2>&1 | grep -o 'mss [0-9]*'")
assert mss.strip() == "mss 1452", f"expected the SYN clamped to mss 1452, got {mss!r}"
'';
# `fail` in the test script also passes on "command not found", so make sure
# the tools the script relies on are really there.
tools = { pkgs, ... }: {
environment.systemPackages = with pkgs; [ dnsutils netcat-openbsd curl tcpdump ];
environment.systemPackages = with pkgs; [
dnsutils
netcat-openbsd
curl
tcpdump
];
};
in
pkgs.testers.runNixOSTest {
name = "router-basic";
name = "router-${if pppoe then "pppoe" else "basic"}";
defaults.imports = [ tools ];
# `driverInteractive` uses the full QEMU, which would open a window per VM;
# `nix run .#lab` wants the consoles in the terminal instead.
interactive.defaults.virtualisation.qemu.options = [ "-display" "none" ];
interactive.defaults.virtualisation.qemu.options = [
"-display"
"none"
];
nodes = {
# Plays the ISP: hands out one WAN lease, answers DNS, and serves a page
# that echoes the client address it sees (to prove NAT happens).
upstream = { lib, ... }: {
virtualisation.vlans = [ 1 ];
networking.useDHCP = false;
networking.firewall.enable = false;
networking.interfaces.eth1.ipv4 = {
addresses = lib.mkForce [ { address = "203.0.113.1"; prefixLength = 24; } ];
# Route back to the LAN via the router so that "unreachable" below
# is due to the firewall and not a missing route.
routes = [ { address = "192.168.10.0"; prefixLength = 24; via = "203.0.113.100"; } ];
};
# Plays the ISP: hands out one WAN lease (v4 and v6) plus a delegated /64,
# answers DNS, and serves a page that echoes the client address it sees
# (to prove NAT happens for v4 and does not for v6).
upstream =
{ lib, pkgs, ... }:
lib.mkMerge [
{
virtualisation.vlans = [ 1 ];
networking.useNetworkd = true;
networking.useDHCP = false;
networking.firewall.enable = false;
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ];
services.dnsmasq = {
enable = true;
settings = {
interface = "eth1";
bind-interfaces = true;
dhcp-range = "203.0.113.100,203.0.113.100,255.255.255.0,1h";
dhcp-option = [ "option:router,203.0.113.1" ];
address = "/example.test/203.0.113.1";
};
};
systemd.network.networks."10-wan" = {
matchConfig.Name = "eth1";
address = [ "2001:db8:1::1/64" ] ++ lib.optional (!pppoe) "203.0.113.1/24";
# Routes back to the LAN via the router so that "unreachable" below is
# due to the firewall and not a missing route. (With PPPoE the IPv4
# route can only exist once the session is up; the test adds it.)
routes = [
{
Destination = "2001:db8:2::/48";
Gateway = "2001:db8:1::100";
}
]
++ lib.optional (!pppoe) {
Destination = "192.168.10.0/24";
Gateway = "203.0.113.100";
};
networkConfig = {
IPv6SendRA = true;
}
// lib.optionalAttrs pppoe { VLAN = [ "eth1.${toString pppoeVlan}" ]; };
ipv6SendRAConfig = {
Managed = false;
OtherInformation = false;
RouterLifetimeSec = 1800;
EmitDNS = false;
};
# On-link only: the router must not autoconfigure a second address.
ipv6Prefixes = [
{
Prefix = "2001:db8:1::/64";
OnLink = true;
AddressAutoconfiguration = false;
}
];
};
services.nginx = {
enable = true;
virtualHosts."example.test".locations."/".return = ''200 "$remote_addr"'';
};
};
services.dnsmasq = {
enable = true;
settings = {
address = "/example.test/${ispV4}";
}
// (
if pppoe then
# The PPP interface only exists once a session is up.
{ bind-dynamic = true; }
else
{
interface = "eth1";
bind-interfaces = true;
dhcp-range = "203.0.113.100,203.0.113.100,255.255.255.0,1h";
dhcp-option = [ "option:router,203.0.113.1" ];
}
);
};
services.kea.dhcp6 = {
enable = true;
settings = {
interfaces-config = {
interfaces = [ "eth1" ];
re-detect = true;
};
lease-database = {
type = "memfile";
persist = false;
};
preferred-lifetime = 3000;
valid-lifetime = 4000;
subnet6 = [
{
id = 1;
subnet = "2001:db8:1::/64";
interface = "eth1";
pools = [ { pool = "2001:db8:1::100 - 2001:db8:1::100"; } ];
pd-pools = [
{
prefix = "2001:db8:2::";
prefix-len = 48;
delegated-len = 64;
}
];
}
];
};
};
services.nginx = {
enable = true;
virtualHosts."example.test".locations."/".return = ''200 "$remote_addr"'';
};
}
(lib.mkIf pppoe {
boot.kernelModules = [
"pppoe"
"pppox"
"ppp_generic"
];
systemd.network.netdevs."05-pppoe" = {
netdevConfig = {
Kind = "vlan";
Name = "eth1.${toString pppoeVlan}";
};
vlanConfig.Id = pppoeVlan;
};
systemd.network.networks."11-pppoe" = {
matchConfig.Name = "eth1.${toString pppoeVlan}";
networkConfig.LinkLocalAddressing = "no";
};
environment.etc = {
"ppp/options".text = "";
"ppp/chap-secrets".text = ''
"testuser" * "p4ss\"w0rd" *
'';
"ppp/pppoe-server-options".text = ''
require-chap
noccp
mtu 1492
mru 1492
lcp-echo-interval 10
lcp-echo-failure 3
'';
};
# The ISP's access concentrator: one session, 198.51.100.1 <-> .100.
systemd.services.pppoe-server = {
wantedBy = [ "multi-user.target" ];
wants = [ "sys-subsystem-net-devices-eth1.${toString pppoeVlan}.device" ];
after = [
"systemd-networkd.service"
"sys-subsystem-net-devices-eth1.${toString pppoeVlan}.device"
];
serviceConfig = {
ExecStart = lib.concatStringsSep " " [
"${pkgs.rp-pppoe}/bin/pppoe-server -F -I eth1.${toString pppoeVlan}"
"-L 198.51.100.1 -R 198.51.100.100 -N 1"
"-O /etc/ppp/pppoe-server-options -q ${pkgs.ppp}/bin/pppd"
];
Restart = "always";
};
};
})
];
router = { lib, ... }: {
imports = [ self.nixosModules.router ];
virtualisation.vlans = [ 1 2 ];
virtualisation.vlans = [
1
2
];
environment.etc."pppoe-test-password" = {
text = "p4ss\"w0rd";
mode = "0600";
};
# The test driver preconfigures static addresses on every vlan interface.
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ];
@@ -59,13 +257,32 @@ pkgs.testers.runNixOSTest {
router = {
enable = true;
wan.interface = "eth1";
wan = {
interface = "eth1";
ipv6.enable = true;
ipv4 = {
type = wan4;
}
// lib.optionalAttrs pppoe {
pppoe = {
vlan = pppoeVlan;
username = "testuser";
passwordFile = "/etc/pppoe-test-password";
};
};
};
lans.main = {
interface = "eth2";
ipv4 = { address = "192.168.10.1"; };
dhcp.pool = { start = "192.168.10.100"; end = "192.168.10.199"; };
ipv4 = {
address = "192.168.10.1";
};
ipv6.pd = true;
dhcp.pool = {
start = "192.168.10.100";
end = "192.168.10.199";
};
};
dns.forwarders = [ "203.0.113.1" ];
dns.forwarders = [ ispV4 ];
};
# Unbound answers `.test` locally (RFC 6761) and would validate DNSSEC
@@ -96,9 +313,9 @@ pkgs.testers.runNixOSTest {
upstream.wait_for_unit("dnsmasq.service")
upstream.wait_for_unit("nginx.service")
with subtest("router obtains its WAN lease and brings up services"):
with subtest("router obtains its IPv4 uplink and brings up services"):
router.wait_for_unit("network-online.target")
router.wait_until_succeeds("ip -4 addr show dev eth1 | grep -q 203.0.113.100")
${indent 4 uplinkWait}
router.wait_for_unit("nftables.service")
router.wait_for_unit("kea-dhcp4-server.service")
router.wait_for_unit("unbound.service")
@@ -109,22 +326,48 @@ pkgs.testers.runNixOSTest {
client.succeed("resolvectl status eth1 | grep -q 192.168.10.1")
with subtest("client resolves names through the router"):
client.wait_until_succeeds("dig +short +time=2 +tries=1 @192.168.10.1 example.test | grep -qx 203.0.113.1")
client.wait_until_succeeds("dig +short +time=2 +tries=1 @192.168.10.1 example.test | grep -qx ${ispV4}")
with subtest("client reaches the 'internet' and is NATed"):
out = client.wait_until_succeeds("curl -sf --max-time 5 --resolve example.test:80:203.0.113.1 http://example.test/")
assert out.strip() == "203.0.113.100", f"expected NATed source 203.0.113.100, got {out!r}"
out = client.wait_until_succeeds("curl -sf --max-time 5 --resolve example.test:80:${ispV4} http://example.test/")
assert out.strip() == "${routerV4}", f"expected NATed source ${routerV4}, got {out!r}"
${lib.optionalString pppoe (indent 0 mssSubtest)}
with subtest("WAN cannot initiate to the router or the LAN"):
# Positive control: the probe tool works against a port that is open.
upstream.succeed("nc -z -w 2 203.0.113.1 80")
upstream.fail("nc -z -w 2 203.0.113.100 22")
upstream.fail("nc -z -w 2 203.0.113.100 53")
upstream.succeed("nc -z -w 2 ${ispV4} 80")
upstream.fail("nc -z -w 2 ${routerV4} 22")
upstream.fail("nc -z -w 2 ${routerV4} 53")
upstream.fail("ping -c 1 -W 2 192.168.10.1")
upstream.fail("ping -c 1 -W 2 203.0.113.100")
upstream.fail("ping -c 1 -W 2 ${routerV4}")
client_ip = client.succeed("ip -4 -o addr show dev eth1 | awk '{print $4}' | cut -d/ -f1").strip()
upstream.fail(f"ping -c 1 -W 2 {client_ip}")
with subtest("router gets an IPv6 address, the delegated /64 and a default route"):
router.wait_until_succeeds("ip -6 addr show dev eth1 scope global | grep -q 2001:db8:1::100")
router.wait_until_succeeds("ip -6 addr show dev eth2 scope global | grep -q 2001:db8:2:")
router.wait_until_succeeds("ip -6 route show default | grep -q 'via fe80:.* dev eth1'")
# The WAN prefix is not autoconfigured, so DHCPv6 is the only global address there.
assert router.succeed("ip -6 -o addr show dev eth1 scope global | wc -l").strip() == "1"
with subtest("client autoconfigures a global address from the delegated prefix"):
client.wait_until_succeeds("ip -6 addr show dev eth1 scope global | grep -q 2001:db8:2:")
client.wait_until_succeeds("ip -6 route show default | grep -q 'dev eth1'")
client_v6 = client.succeed("ip -6 -o addr show dev eth1 scope global | awk '{print $4}' | cut -d/ -f1 | head -1").strip()
with subtest("IPv6 is routed without NAT"):
client.wait_until_succeeds("ping -6 -c 1 -W 2 2001:db8:1::1")
out = client.succeed("curl -sf -g --max-time 5 -H 'Host: example.test' 'http://[2001:db8:1::1]/'")
assert out.strip() == client_v6, f"expected the client's own address {client_v6}, got {out!r}"
with subtest("WAN cannot initiate IPv6 to the router or the LAN"):
# Positive control: the ISP really has a route to the client, so a
# failure below is down to the firewall.
upstream.succeed(f"ip -6 route get {client_v6} | grep -q 'via 2001:db8:1::100'")
upstream.fail(f"ping -6 -c 1 -W 2 {client_v6}")
upstream.fail("ping -6 -c 1 -W 2 2001:db8:1::100")
upstream.fail("nc -6 -z -w 2 2001:db8:1::100 22")
with subtest("LAN can reach SSH and ping on the router"):
client.succeed("nc -z -w 2 192.168.10.1 22")
client.succeed("ping -c 1 -W 2 192.168.10.1")
+4
View File
@@ -1,6 +1,10 @@
{ self, pkgs }:
{
basic = import ./basic.nix { inherit self pkgs; };
pppoe = import ./basic.nix {
inherit self pkgs;
wan4 = "pppoe";
};
lib = import ./lib.nix { inherit pkgs; };
assertions = import ./assertions.nix { inherit self pkgs; };
}