Files
nixos-router/tests/basic.nix
T
nilsandClaude Sonnet 5.5 b1fa7b1396 Add token expiry, edit notice, log allowlist, local-zone test; merge PLAN.md into SPECS.md
get_logs needs a unit from an allowlist (router-*, kea, unbound, networkd,
nftables, ddclient, acme, sshd, kernel). API tokens are valid for 1, 7, 30
(default) or 90 days, or without limit. The overview and the Changes page
say which token made the unapplied changes.

options.json (router://options) no longer holds the options' source paths:
they differ between builds of a release, so a router rebuilding itself
offline had to build the file and failed (found by the update test).

Web UI: panels flow into columns on wide screens and the width is no longer
capped; on phones the navigation is a menu button; the Advanced page uses
panels; VLAN dropdowns show their chosen names; the duplicate WireGuard
heading is gone.

basic: a subtest for wan -> local rules.

SPECS.md is now the only document (decisions, how each part works, and what
is not done yet); PLAN.md is gone.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-11-01 19:06:27 +01:00

594 lines
22 KiB
Nix

# Single WAN + single LAN: DHCP, DNS, NAT, SLAAC and the default firewall
# policy for both address families. `wan4` selects how the IPv4 uplink works:
#
# 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;
clientV4 = "192.168.10.100";
clientV6 = "2001:db8:2::1234";
peerV4 = "192.168.10.51";
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}"
'';
common = import ./common.nix;
in
pkgs.testers.runNixOSTest {
name = "router-${if pppoe then "pppoe" else "basic"}";
defaults.imports = [ common.tools ];
interactive.defaults = common.interactiveDefaults;
nodes = {
# 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 [ ];
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.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, pkgs, ... }: {
imports = [ self.nixosModules.router ];
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 [ ];
networking.interfaces.eth2.ipv4.addresses = lib.mkForce [ ];
router = {
enable = true;
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";
internet = true;
ipv4 = {
address = "192.168.10.1";
};
ipv6.pd = true;
dhcp.pool = {
start = "192.168.10.100";
end = "192.168.10.199";
};
};
dns.forwarders = [ ispV4 ];
# A port forward is a translation and the rule that lets it through.
firewall = {
nat =
let
fwd =
r:
{
type = "dnat";
from = "wan";
to = "main";
toAddress = clientV4;
protocol = "tcp";
}
// r;
in
[
(fwd {
ports = [ 8080 ];
hairpin = true;
})
(fwd {
ports = [ 8083 ];
toPort = 8084;
})
(fwd { ports = [ "9000-9002" ]; })
(fwd {
ports = [ 8085 ];
sources = [ "${ispV4}/32" ];
})
(fwd {
ports = [ 8086 ];
sources = [ "192.0.2.0/24" ];
})
(fwd { ports = [ 8087 ]; })
];
rules =
let
allow =
r:
{
from = "wan";
to = "main";
destination = clientV4;
protocol = "tcp";
natOnly = true;
}
// r;
in
[
(allow {
ports = [ 8080 ];
log = true;
})
(allow { ports = [ 8084 ]; })
(allow { ports = [ "9000-9002" ]; })
(allow {
ports = [ 8085 ];
sources = [ "${ispV4}/32" ];
})
(allow {
ports = [ 8086 ];
sources = [ "192.0.2.0/24" ];
})
(allow {
ports = [ 8087 ];
rateLimit = "3/minute";
log = true;
})
{
from = "wan";
to = "main";
destination = clientV6;
protocol = "tcp";
ports = [ 8081 ];
}
# The router itself: no destination, only these senders and ports.
{
from = "wan";
to = "local";
protocol = "tcp";
ports = [ 8022 ];
sources = [ "${ispV4}/32" ];
}
{
from = "wan";
to = "local";
protocol = "tcp";
ports = [ 8023 ];
sources = [ "192.0.2.0/24" ];
}
{
from = "main";
to = "local";
protocol = "tcp";
ports = [ 8024 ];
}
];
};
};
# Unbound answers `.test` locally (RFC 6761) and would validate DNSSEC
# against a fake ISP resolver that cannot provide it; neither applies to
# a real forwarder, so relax both for the made-up test domain only.
environment.systemPackages = [ pkgs.netcat ];
services.unbound.settings.server = {
local-zone = [ "test. transparent" ];
domain-insecure = [ "example.test" ];
};
};
client =
{ lib, ... }:
{
virtualisation.vlans = [ 2 ];
networking.useNetworkd = true;
networking.useDHCP = false;
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ];
systemd.network.networks."10-lan" = {
matchConfig.Name = "eth1";
networkConfig.DHCP = "ipv4";
# Fixed address inside the delegated /64: the IPv6 pinhole target.
address = [ "${clientV6}/64" ];
};
networking.firewall.enable = false;
# Every listener echoes the peer address it sees and its own port.
services.nginx = {
enable = true;
virtualHosts.ports = {
listen =
map
(port: {
addr = "0.0.0.0";
inherit port;
})
[
8080
8084
9000
9001
9002
8085
8086
8087
]
++
map
(port: {
addr = "[::]";
inherit port;
})
[
8081
8082
8080
];
locations."/".return = ''200 "$remote_addr $server_port"'';
};
};
};
# A second LAN host, outside the DHCP pool.
peer =
{ lib, ... }:
{
virtualisation.vlans = [ 2 ];
networking.useNetworkd = true;
networking.useDHCP = false;
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ];
systemd.network.networks."10-lan" = {
matchConfig.Name = "eth1";
address = [ "${peerV4}/24" ];
gateway = [ "192.168.10.1" ];
};
networking.firewall.enable = false;
};
};
testScript = ''
start_all()
upstream.wait_for_unit("dnsmasq.service")
upstream.wait_for_unit("nginx.service")
with subtest("router obtains its IPv4 uplink and brings up services"):
router.wait_for_unit("network-online.target")
${indent 4 uplinkWait}
router.wait_for_unit("nftables.service")
router.wait_for_unit("kea-dhcp4-server.service")
router.wait_for_unit("unbound.service")
with subtest("client gets address, gateway and DNS from the router"):
client.wait_until_succeeds("ip -4 addr show dev eth1 | grep -q 192.168.10.")
client.succeed("ip route show default | grep -q 'via 192.168.10.1'")
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 ${ispV4}")
with subtest("client reaches the 'internet' and is NATed"):
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 ${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 ${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("a wan -> local rule opens the router's port to the named sender only"):
for port in (8022, 8023, 8024, 8025):
router.succeed(f"systemd-run --unit=probe{port} ${pkgs.bash}/bin/bash -c 'while true; do ${pkgs.netcat}/bin/nc -l {port} < /dev/null; done'")
router.wait_until_succeeds("ss -ltn | grep -c ':8025 ' >/dev/null", timeout=30)
upstream.succeed("nc -z -w 2 ${routerV4} 8022")
# Open for another sender only, for the LAN only, and not at all.
upstream.fail("nc -z -w 2 ${routerV4} 8023")
upstream.fail("nc -z -w 2 ${routerV4} 8024")
upstream.fail("nc -z -w 2 ${routerV4} 8025")
client.succeed("nc -z -w 2 192.168.10.1 8024")
client.fail("nc -z -w 2 192.168.10.1 8022")
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 | grep -v ::1234 | 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")
with subtest("port forward keeps the source address and honours toPort"):
for _ in range(3):
# Positive control: the listener answers on the LAN.
peer.wait_until_succeeds("curl -sf --max-time 5 http://${clientV4}:8080/")
out = upstream.succeed("curl -sf --max-time 5 http://${routerV4}:8080/")
assert out.strip() == "${ispV4} 8080", out
out = upstream.succeed("curl -sf --max-time 5 http://${routerV4}:8083/")
assert out.strip() == "${ispV4} 8084", out
# The translated port is not exposed directly.
upstream.fail("curl -sf --max-time 3 http://${routerV4}:8084/")
with subtest("port ranges open exactly their ports"):
for port in (9000, 9001, 9002):
out = upstream.succeed(f"curl -sf --max-time 5 http://${routerV4}:{port}/")
assert out.strip() == f"${ispV4} {port}", out
upstream.fail("curl -sf --max-time 3 http://${routerV4}:9003/")
with subtest("source allow-list admits matching clients only"):
out = upstream.succeed("curl -sf --max-time 5 http://${routerV4}:8085/")
assert out.strip() == "${ispV4} 8085", out
upstream.fail("curl -sf --max-time 3 http://${routerV4}:8086/")
with subtest("per-source rate limit drops excess new connections"):
ok = 0
for _ in range(12):
status, _out = upstream.execute("curl -sf --max-time 2 http://${routerV4}:8087/")
ok += status == 0
assert 0 < ok < 12, f"expected some but not all connections to pass, got {ok}/12"
with subtest("IPv6 reaches only the explicit pinholes"):
out = upstream.succeed("curl -sf -g --max-time 5 'http://[${clientV6}]:8081/'")
assert out.strip() == "2001:db8:1::1 8081", out
# The listener exists, but there is no pinhole for it.
client.succeed("nc -6 -z -w 2 ${clientV6} 8082")
upstream.fail("curl -sf -g --max-time 3 'http://[${clientV6}]:8082/'")
# Port 8080 is forwarded for IPv4 only.
client.succeed("nc -6 -z -w 2 ${clientV6} 8080")
upstream.fail("curl -sf -g --max-time 3 'http://[${clientV6}]:8080/'")
with subtest("hairpin NAT works for forwards that enable it"):
out = peer.succeed("curl -sf --max-time 5 http://${routerV4}:8080/")
# Masqueraded: the server sees the router, not the peer.
assert out.strip() == "192.168.10.1 8080", out
# No hairpin on the range forward: the router does not translate it.
peer.fail("curl -sf --max-time 3 http://${routerV4}:9001/")
with subtest("logging rules leave a trace in the kernel log"):
klog = router.succeed("journalctl -k --no-pager | grep 'router fwd#'")
assert "accepted: " in klog, klog
assert "rate-limited: " in klog, klog
with subtest("a LAN cable plugged in after Kea started still gets DHCP"):
# Like booting with nothing plugged in: Kea starts while the LAN NIC
# (eth2, QEMU netdev vlan2) has no carrier and skips it.
router.send_monitor_command("set_link vlan2 off")
router.wait_until_succeeds("grep -qx 0 /sys/class/net/eth2/carrier")
router.succeed("systemctl restart kea-dhcp4-server.service")
router.wait_until_succeeds("journalctl -u kea-dhcp4-server --since=-1min | grep -q 'eth2 is not running'")
router.send_monitor_command("set_link vlan2 on")
client.succeed("networkctl down eth1 && ip -4 addr flush dev eth1 && networkctl up eth1")
client.wait_until_succeeds("ip -4 addr show dev eth1 | grep -q 192.168.10.", timeout=60)
'';
}