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
+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.";
};
};
}