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
+3
View File
@@ -0,0 +1,3 @@
result
result-*
.nixos-test-history
+86
View File
@@ -0,0 +1,86 @@
# nixos-router — Plan
Working name: `nixos-router`. A NixOS-based distribution for routers/firewalls, delivered as a flake exporting NixOS modules and bootable images (not a nixpkgs fork).
## Decisions
| Area | Decision |
|---|---|
| Audience | Own homelab first; keep structure clean enough to publish later. |
| Config model | Opinionated `router.*` option schema compiled to systemd-networkd + nftables, with build-time assertions. |
| Hardware | x86_64 UEFI first. aarch64 is a planned target; avoid x86-only assumptions. |
| v1 features | IPv6 + prefix delegation, VLANs / multiple LANs, WireGuard. |
| Deferred | Multi-WAN failover, QoS (CAKE/SQM), web UI. |
| Updates (v1) | Push from workstation (`nixos-rebuild --target-host` / colmena) + commit-confirm rollback timer. |
| Updates (later) | **A/B image updates** with automatic rollback. |
| Footprint | Minimal closure, read-only root, no Nix daemon/compiler on device, small persistent `/var`. |
## Architecture
1. **`router.*` schema** — users describe intent (wan, lan, vlans, zones, port forwards, wireguard); modules compile it to:
- systemd-networkd (DHCPv4/v6, DHCPv6-PD, VLANs, bridges, PPPoE later)
- nftables zone-based ruleset (generated, not `networking.firewall`)
- DHCP / DNS / RA services
2. **Build-time validation** — Nix assertions reject lock-out or inconsistent configs (no management access rule, overlapping subnets, zone referencing missing interface); generated ruleset checked with `nft -c`.
3. **Images** — x86_64 first (installer ISO + raw disk), aarch64 later. Read-only root (erofs/squashfs store), small persistent `/var`.
4. **Deployment** — deploy a prebuilt closure (`nix copy` + `switch-to-configuration`); never evaluate or build on the router. This keeps the device light and leads naturally to A/B updates.
## Implications
- No on-device evaluation: all designs must work from a prebuilt closure.
- Flake exposes `x86_64-linux` and `aarch64-linux` outputs from the start; hardware specifics (bootloader, kernel, NIC naming) live in separate `boards/` modules.
- IPv6 PD fan-out to multiple VLANs needs stable per-LAN subnet-ID allocation; this is the hardest part of the schema.
## Default zone policy (confirmed)
- WAN: deny inbound (except established/related, required ICMPv6, explicit port forwards).
- LAN: allow outbound to WAN.
- Other zones (e.g. IoT): isolated from each other and from LAN unless a rule opens them.
## Management access (confirmed)
- Key-only SSH, reachable from LAN zones (trusted, non-WAN, non-isolated zones). No GUI in v1.
- Build-time assertion fails if no management path exists.
- Isolated zones (e.g. IoT) get no router access beyond DHCP/DNS/NDP.
## Services (confirmed)
- DHCP: Kea (DHCPv4 + DHCPv6 incl. PD).
- DNS: Unbound.
- RA: systemd-networkd (or radvd if networkd falls short).
## Repo layout (confirmed)
```
flake.nix
modules/ # router.* schema and service modules
boards/ # hardware-specific modules (x86_64 first, aarch64 later)
tests/ # nixosTest VM tests
images/ # image/ISO builds
examples/ # sample configs
```
## Status
**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.
Learned along the way:
- The default policy now explicitly answers ICMP echo on LANs only; the WAN gets only ICMP error types and ND.
- 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).
- Subnets are given explicitly (`subnet` + `address`) for now; a small CIDR helper lib is needed before IPv6 PD / VLAN subnet-ID allocation.
- Not yet covered: IPv6, VLANs, WireGuard, formal zones, a test for the assertions themselves (verified manually via `nix eval`).
## Open items
- **Web UI** (stats and possibly configuration) — to be discussed later. Considerations: read-only stats first is cheap; config editing must write declarative config (Nix/JSON) that feeds the module, not mutate the live system.
## Phases
1. **MVP** — flake with `router.*` schema: single WAN (DHCP), single LAN, NAT, basic firewall, DHCP + DNS. NixOS VM tests (`nixosTest`) in CI.
2. **Real-world features** — IPv6 PD, VLANs, zone policy, port forwards, WireGuard.
3. **Images and hardware** — installer ISO, read-only root, aarch64.
4. **Operations** — commit-confirm rollback, monitoring (node exporter, nftables counters), A/B image updates; multi-WAN failover.
5. **Optional** — web UI (stats, then configuration).
Generated
+27
View File
@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1792773339,
"narHash": "sha256-rZLrzrC6i+jnTAlvPZHogKDulY7WWDEv9hJeHieO7Zw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "3ec636362e1816bc2f0888fa0fa2c69f1bc9e504",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+46
View File
@@ -0,0 +1,46 @@
{
description = "nixos-router: a NixOS-based distribution for routers/firewalls";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs = { self, nixpkgs }:
let
systems = [ "x86_64-linux" "aarch64-linux" ];
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
in
{
nixosModules = {
router = ./modules/router;
default = self.nixosModules.router;
};
# VM tests need KVM; only wired up for x86_64 until aarch64 CI exists.
checks.x86_64-linux = import ./tests {
inherit self;
pkgs = nixpkgs.legacyPackages.x86_64-linux;
};
# `nix run .#lab`: boot the basic test topology in tmux, one console pane
# per VM plus the interactive test-driver REPL.
apps.x86_64-linux.lab =
let
pkgs = nixpkgs.legacyPackages.x86_64-linux;
test = self.checks.x86_64-linux.basic;
lab = pkgs.writeShellApplication {
name = "router-lab";
runtimeInputs = with pkgs; [ tmux util-linux gnugrep coreutils ];
text = import ./tests/lab.nix {
inherit (pkgs) lib;
driver = test.driverInteractive;
nodes = builtins.attrNames test.nodes;
};
};
in
{
type = "app";
program = "${lab}/bin/router-lab";
};
formatter = forAllSystems (pkgs: pkgs.nixfmt-rfc-style);
};
}
+31
View File
@@ -0,0 +1,31 @@
{ config, lib, ... }:
let
cfg = config.router;
lans = lib.attrValues cfg.lans;
in
{
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = lans != [ ];
message = "router: at least one LAN must be defined.";
}
{
assertion = lib.any (l: l.allowManagement) lans;
message = "router: no LAN has allowManagement = true; you would be locked out of the router.";
}
{
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 =
let
ifaces = map (l: l.interface) lans;
in
lib.length ifaces == lib.length (lib.unique ifaces);
message = "router: two LANs share the same interface.";
}
];
};
}
+10
View File
@@ -0,0 +1,10 @@
{
imports = [
./options.nix
./assertions.nix
./networking.nix
./firewall.nix
./dhcp.nix
./dns.nix
];
}
+35
View File
@@ -0,0 +1,35 @@
{ config, lib, ... }:
let
cfg = config.router;
dhcpLans = lib.filter (l: l.dhcp.enable) (lib.attrValues cfg.lans);
in
{
config = lib.mkIf (cfg.enable && dhcpLans != [ ]) {
services.kea.dhcp4 = {
enable = true;
settings = {
interfaces-config = {
interfaces = map (l: l.interface) dhcpLans;
# Interfaces are configured asynchronously by networkd.
re-detect = true;
};
lease-database = {
type = "memfile";
persist = true;
name = "/var/lib/kea/dhcp4.leases";
};
valid-lifetime = 3600;
subnet4 = lib.imap1 (id: l: {
inherit id;
inherit (l.ipv4) subnet;
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; }
];
}) dhcpLans;
};
};
};
}
+29
View File
@@ -0,0 +1,29 @@
{ config, lib, ... }:
let
cfg = config.router;
lans = lib.attrValues cfg.lans;
in
{
config = lib.mkIf cfg.enable {
services.unbound = {
enable = true;
settings = {
server = {
interface = [ "127.0.0.1" ] ++ map (l: l.ipv4.address) lans;
# 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;
};
} // lib.optionalAttrs (cfg.dns.forwarders != [ ]) {
forward-zone = [
{
name = ".";
forward-addr = cfg.dns.forwarders;
}
];
};
};
};
}
+78
View File
@@ -0,0 +1,78 @@
{ config, lib, ... }:
let
cfg = config.router;
lans = lib.attrValues cfg.lans;
set = ifaces: "{ ${lib.concatMapStringsSep ", " (i: ''"${i}"'') ifaces} }";
lanIfaces = map (l: l.interface) lans;
mgmtIfaces = map (l: l.interface) (lib.filter (l: l.allowManagement) lans);
dhcpIfaces = map (l: l.interface) (lib.filter (l: l.dhcp.enable) lans);
in
{
config = lib.mkIf cfg.enable {
# The generated ruleset replaces networking.firewall entirely.
networking.firewall.enable = false;
networking.nftables.enable = true;
networking.nftables.tables.router = {
family = "inet";
content = ''
chain input {
type filter hook input priority filter; policy drop;
iifname "lo" accept
ct state established,related accept
ct state invalid drop
# Error types and ND are required on every interface (WAN included);
# echo is only answered on LANs.
icmp type { destination-unreachable, time-exceeded, parameter-problem } accept
icmpv6 type {
destination-unreachable, packet-too-big, time-exceeded, parameter-problem,
nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert
} accept
iifname ${set lanIfaces} icmp type echo-request accept
iifname ${set lanIfaces} icmpv6 type echo-request accept
iifname "${cfg.wan.interface}" udp dport 68 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''}
}
chain forward {
type filter hook forward priority filter; policy drop;
ct state established,related accept
ct state invalid drop
icmpv6 type { destination-unreachable, packet-too-big, time-exceeded, parameter-problem } accept
iifname ${set lanIfaces} oifname "${cfg.wan.interface}" accept
}
'';
};
networking.nftables.tables.router-nat = {
family = "ip";
content = ''
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
oifname "${cfg.wan.interface}" masquerade
}
'';
};
services.openssh = {
enable = true;
openFirewall = false;
settings = {
PasswordAuthentication = false;
KbdInteractiveAuthentication = false;
PermitRootLogin = "prohibit-password";
};
};
};
}
+30
View File
@@ -0,0 +1,30 @@
{ config, lib, ... }:
let
cfg = config.router;
in
{
config = lib.mkIf cfg.enable {
networking.useNetworkd = true;
networking.useDHCP = false;
boot.kernel.sysctl = {
"net.ipv4.conf.all.forwarding" = 1;
};
systemd.network.networks = {
"10-wan" = {
matchConfig.Name = cfg.wan.interface;
networkConfig.DHCP = "ipv4";
# 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;
}
) cfg.lans;
};
}
+79
View File
@@ -0,0 +1,79 @@
{ lib, ... }:
let
inherit (lib) mkOption mkEnableOption types;
in
{
options.router = {
enable = mkEnableOption "the router role";
wan = {
interface = mkOption {
type = types.str;
example = "eth0";
description = "Name of the uplink interface.";
};
};
lans = mkOption {
default = { };
description = "LAN segments served by this router, keyed by name.";
type = types.attrsOf (
types.submodule {
options = {
interface = mkOption {
type = types.str;
example = "eth1";
description = "Interface (or bridge/VLAN device) carrying this LAN.";
};
ipv4 = {
address = mkOption {
type = types.str;
example = "192.168.10.1";
description = "Router address on this LAN.";
};
prefixLength = mkOption {
type = types.ints.between 8 30;
default = 24;
};
subnet = mkOption {
type = types.str;
example = "192.168.10.0/24";
description = "Network in CIDR notation; must contain `address`.";
};
};
dhcp = {
enable = mkOption {
type = types.bool;
default = true;
};
pool = {
start = mkOption {
type = types.str;
example = "192.168.10.100";
};
end = mkOption {
type = types.str;
example = "192.168.10.199";
};
};
};
allowManagement = mkOption {
type = types.bool;
default = true;
description = "Whether clients on this LAN may reach SSH on the router.";
};
};
}
);
};
dns.forwarders = mkOption {
type = types.listOf types.str;
default = [ ];
description = "Upstream resolvers. When empty, Unbound resolves recursively.";
};
};
}
+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
''