Add MVP router flake with VM test and tmux lab

router.* schema (wan, lans, dns.forwarders) compiled to systemd-networkd,
a generated nftables ruleset with NAT, Kea DHCPv4 and Unbound, plus build-time
assertions against lock-out and interface conflicts.

tests/basic.nix is a three-node VM test (upstream/router/client) covering WAN
DHCP, LAN DHCP and DNS, NAT, WAN-deny and LAN SSH/ping. `nix run .#lab` boots
the same topology in tmux with one console pane per VM and the driver REPL.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 13:38:41 +01:00
co-authored by Claude Sonnet 5.5
commit dcef7665b4
14 changed files with 630 additions and 0 deletions
+128
View File
@@ -0,0 +1,128 @@
# Single WAN (DHCP) + single LAN: DHCP, DNS, NAT and the default firewall policy.
#
# upstream (ISP, 203.0.113.1) -- vlan 1 -- router -- vlan 2 -- client
{ self, pkgs }:
let
# `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 ];
};
in
pkgs.testers.runNixOSTest {
name = "router-basic";
defaults.imports = [ tools ];
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"; } ];
};
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";
};
};
services.nginx = {
enable = true;
virtualHosts."example.test".locations."/".return = ''200 "$remote_addr"'';
};
};
router = { lib, ... }: {
imports = [ self.nixosModules.router ];
virtualisation.vlans = [ 1 2 ];
# 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";
lans.main = {
interface = "eth2";
ipv4 = { address = "192.168.10.1"; subnet = "192.168.10.0/24"; };
dhcp.pool = { start = "192.168.10.100"; end = "192.168.10.199"; };
};
dns.forwarders = [ "203.0.113.1" ];
};
# 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.
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";
};
networking.firewall.enable = false;
};
};
testScript = ''
start_all()
upstream.wait_for_unit("dnsmasq.service")
upstream.wait_for_unit("nginx.service")
with subtest("router obtains its WAN lease 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")
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 203.0.113.1")
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}"
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.fail("ping -c 1 -W 2 192.168.10.1")
upstream.fail("ping -c 1 -W 2 203.0.113.100")
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("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
@@ -0,0 +1,4 @@
{ self, pkgs }:
{
basic = import ./basic.nix { inherit self pkgs; };
}
+44
View File
@@ -0,0 +1,44 @@
# Shell script for `nix run .#lab` (see flake.nix).
{ lib, driver, nodes }:
''
sock=nixos-router-lab
log=$(mktemp -t nixos-router-lab.XXXXXX)
trap 'tmux -L $sock kill-server 2>/dev/null || true; rm -f "$log"' EXIT
tmux -L $sock kill-server 2>/dev/null || true
# Bottom pane: the test-driver REPL. `script` keeps it on a tty while
# logging everything it prints, which the per-VM panes below filter.
# When the REPL exits, take the whole session down.
repl=$(tmux -L $sock new-session -d -x 220 -y 60 -s lab -P -F '#{pane_id}' \
"script -qfc '${driver}/bin/nixos-test-driver --interactive' '$log'; tmux -L $sock kill-server")
tmux -L $sock set -g pane-border-status top
tmux -L $sock select-pane -t "$repl" -T repl
# Top row: one pane per VM, console lines only (prefixed "<name> #").
prev="$repl"
first=1
remaining=${toString (builtins.length nodes - 1)}
for node in ${lib.escapeShellArgs nodes}; do
cmd="tail -F '$log' 2>/dev/null | grep -a --line-buffered -E \"(^|\\[[0-9;]*m)$node #\""
if [ "$first" = 1 ]; then
prev=$(tmux -L $sock split-window -vb -l 65% -t "$prev" -P -F '#{pane_id}' "$cmd")
first=0
else
# Give the new pane its share of the pane being split, so the row ends up even.
prev=$(tmux -L $sock split-window -h -l "$((100 * remaining / (remaining + 1)))%" -t "$prev" -P -F '#{pane_id}' "$cmd")
remaining=$((remaining - 1))
fi
tmux -L $sock select-pane -t "$prev" -T "$node"
done
# Boot the VMs once the REPL is ready, and leave a reminder at the prompt.
for _ in $(seq 60); do
tmux -L $sock capture-pane -p -t "$repl" | grep -q CPython && break
sleep 0.5
done
tmux -L $sock send-keys -t "$repl" 'start_all()' Enter
tmux -L $sock send-keys -t "$repl" -l 'print("router.shell_interact() | client.succeed(\"ip a\") | test_script() | Ctrl-D quits, Ctrl-b d detaches")'
tmux -L $sock send-keys -t "$repl" Enter
tmux -L $sock select-pane -t "$repl"
tmux -L $sock attach -t lab
''