Files
nixos-router/tests/assertions.nix
T
nilsandClaude Sonnet 5.5 b5429ad876 Add local DNS records, DHCP reservations and an installer ISO
DNS and DHCP (needed to replace a dnsmasq-based router):
- router.dns.domain/hosts/overrides/forwardZones: authoritative local
  domain, split-horizon host records with reverse entries, wildcard
  overrides and per-zone forwarders (private zones skip DNSSEC).
- lans.<n>.dhcp.reservations: Kea host reservations, published in DNS.
- Tunnels with dns = true admit what their peers route, so a
  site-to-site peer without a transfer address can use the resolver.

Installer:
- router-install wipes a disk, creates an ESP and an ext4 root and
  installs a prebuilt system with its secrets; nothing is built on the
  box. installed-layout is the shared disk layout contract.
- lib.mkInstaller builds an ISO for a given nixosSystem;
  packages.installer-example is the example's ISO.

Covered by the dns and installer VM tests (the latter installs onto an
empty disk and boots it under UEFI), wireguard DNS and assertion cases.
PLAN.md records the remaining gaps for replacing the hand-written router.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 12:31:44 +01:00

1015 lines
24 KiB
Nix

# Evaluation-time tests: broken configs must fail with exactly the expected
# assertion messages, and a valid one with none.
{ self, pkgs }:
let
inherit (pkgs) lib;
evalRouter =
router:
(import "${pkgs.path}/nixos/lib/eval-config.nix" {
system = "x86_64-linux";
modules = [
self.nixosModules.router
{
boot.loader.grub.enable = false;
fileSystems."/" = {
device = "x";
fsType = "ext4";
};
system.stateVersion = "25.05";
router = lib.recursiveUpdate {
enable = true;
wan.interface = "eth0";
} router;
}
];
}).config;
lan =
overrides:
lib.recursiveUpdate {
interface = "eth1";
ipv4.address = "192.168.10.1";
dhcp.pool = {
start = "192.168.10.100";
end = "192.168.10.199";
};
} overrides;
lan2 =
overrides:
lan (
lib.recursiveUpdate {
interface = "eth2";
ipv4.address = "192.168.20.1";
dhcp.pool = {
start = "192.168.20.100";
end = "192.168.20.199";
};
} overrides
);
# A throwaway public key, valid in format only.
pub = "7MkFXOxlCsmXm98uv0txvYzx5Ce80mkg2mDsswY4FyI=";
wg =
overrides:
lib.recursiveUpdate {
privateKeyFile = "/var/lib/secrets/wg.key";
peers.p1 = {
publicKey = pub;
allowedIPs = [ "10.100.0.2/32" ];
};
} overrides;
failed = router: map (a: a.message) (lib.filter (a: !a.assertion) (evalRouter router).assertions);
cases = {
valid = {
router.lans.main = lan { };
expect = [ ];
};
noManagement = {
router.lans.main = lan { allowManagement = false; };
expect = [ "locked out" ];
};
wanIsLan = {
router.lans.main = lan { interface = "eth0"; };
expect = [ "WAN interface `eth0` is also used by a LAN" ];
};
sharedInterface = {
router.lans = {
a = lan { };
b = lan {
ipv4.address = "192.168.20.1";
dhcp.pool = {
start = "192.168.20.100";
end = "192.168.20.199";
};
};
};
expect = [ "two LANs share the same device" ];
};
badAddress = {
router.lans.main = lan {
ipv4.address = "192.168.10.300";
dhcp.enable = false;
};
expect = [ "`192.168.10.300` is not a valid IPv4 address" ];
};
poolOutsideSubnet = {
router.lans.main = lan {
dhcp.pool = {
start = "192.168.11.100";
end = "192.168.11.199";
};
};
expect = [ "pool is not inside 192.168.10.0/24" ];
};
poolReversed = {
router.lans.main = lan {
dhcp.pool = {
start = "192.168.10.199";
end = "192.168.10.100";
};
};
expect = [ "starts after it ends" ];
};
poolContainsRouter = {
router.lans.main = lan {
dhcp.pool = {
start = "192.168.10.1";
end = "192.168.10.50";
};
};
expect = [ "contains the router's own address 192.168.10.1" ];
};
overlappingLans = {
router.lans = {
a = lan { };
b = lan {
interface = "eth2";
ipv4.address = "192.168.10.2";
};
};
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 = [ ];
};
vlansOnOneTrunk = {
router.lans = {
main = lan { };
iot = lan2 {
interface = "eth1";
vlan = 20;
zone = "iot";
};
cams = lan {
interface = "eth1";
vlan = 30;
zone = "iot";
ipv4.address = "192.168.30.1";
dhcp.pool = {
start = "192.168.30.100";
end = "192.168.30.199";
};
};
};
expect = [ ];
};
sameVlanTwice = {
router.lans = {
a = lan { vlan = 20; };
b = lan2 {
interface = "eth1";
vlan = 20;
};
};
expect = [ "two LANs share the same device" ];
};
vlanOnWanParent = {
router.lans.main = lan {
interface = "eth0";
vlan = 10;
};
expect = [ "WAN interface `eth0` is also used by a LAN" ];
};
deviceNameTooLong = {
router.lans.main = lan {
interface = "enp1s0f1np1";
vlan = 4000;
};
expect = [ "longer than 15 characters" ];
};
ruleValid = {
router = {
lans = {
main = lan { };
iot = lan2 { zone = "iot"; };
};
firewall.rules = [
{
from = "iot";
to = "main";
protocol = "tcp";
ports = [
445
8080
];
destination = "192.168.10.5";
}
{
from = "main";
to = "iot";
}
];
};
expect = [ ];
};
ruleUnknownZones = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "nope";
to = "main";
}
{
from = "main";
to = "ghost";
}
];
};
expect = [
"unknown source zone `nope`"
"unknown destination zone `ghost`"
];
};
ruleSameZone = {
router = {
lans = {
main = lan { zone = "t"; };
other = lan2 { zone = "t"; };
};
firewall.rules = [
{
from = "t";
to = "t";
}
];
};
expect = [ "source and destination are the same zone" ];
};
rulePortsWithoutProtocol = {
router = {
lans = {
main = lan { };
iot = lan2 { };
};
firewall.rules = [
{
from = "iot";
to = "main";
protocol = "icmp";
ports = [ 22 ];
}
];
};
expect = [ "ports are only valid with protocol tcp or udp" ];
};
ruleDestinationInvalid = {
router = {
lans = {
main = lan { };
iot = lan2 { };
};
firewall.rules = [
{
from = "iot";
to = "main";
destination = "192.168.10.999";
}
];
};
expect = [ "not a valid IPv4 address or CIDR" ];
};
ruleDestinationOutsideZone = {
router = {
lans = {
main = lan { };
iot = lan2 { };
};
firewall.rules = [
{
from = "iot";
to = "main";
destination = "192.168.20.5";
}
];
};
expect = [ "is not inside any network of zone `main`" ];
};
ruleDestinationHostBits = {
router = {
lans = {
main = lan { };
iot = lan2 { };
};
firewall.rules = [
{
from = "iot";
to = "main";
destination = "192.168.10.5/24";
}
];
};
expect = [ "has host bits set" ];
};
wgValid = {
router = {
wan.interface = "eth0";
lans.main = lan { };
wireguard = {
rw = wg {
listenPort = 51820;
addresses = [
"10.100.0.1/24"
"fd42:100::1/64"
];
dns = true;
peers.p1.allowedIPs = [
"10.100.0.2/32"
"fd42:100::2/128"
];
};
s2s = wg {
listenPort = 51821;
zone = "remote";
peers.p1 = {
endpoint = "203.0.113.60:51821";
allowedIPs = [ "192.168.50.0/24" ];
persistentKeepalive = 25;
};
};
ff = wg {
zone = "freifunk";
addresses = [
"10.200.0.2/32"
"fd42:200::2/128"
];
masquerade = true;
peers.p1 = {
endpoint = "[2001:db8::1]:51822";
allowedIPs = [
"198.18.0.0/24"
"fd00:ff::/32"
];
};
};
};
firewall.rules = [
{
from = "main";
to = "remote";
destination = "192.168.50.7";
}
{
from = "main";
to = "freifunk";
}
];
};
expect = [ ];
};
wgNoPeers = {
router = {
lans.main = lan { };
wireguard.rw = wg { peers = lib.mkForce { }; };
};
expect = [ "has no peers" ];
};
wgBadAddress = {
router = {
lans.main = lan { };
wireguard.rw = wg { addresses = [ "10.100.0.1" ]; };
};
expect = [ "addresses must be IPv4/IPv6 addresses in CIDR notation" ];
};
wgDefaultRoute = {
router = {
lans.main = lan { };
wireguard.rw = wg { peers.p1.allowedIPs = [ "0.0.0.0/0" ]; };
};
expect = [ "default-route tunnels" ];
};
wgDuplicateAllowedIps = {
router = {
lans.main = lan { };
wireguard.rw = wg {
peers.p2 = {
publicKey = pub;
allowedIPs = [ "10.100.0.2/32" ];
};
};
};
expect = [ "appears more than once" ];
};
wgOverlapsLan = {
router = {
lans.main = lan { };
wireguard.rw = wg { peers.p1.allowedIPs = [ "192.168.10.0/25" ]; };
};
expect = [ "overlaps one of the LAN subnets" ];
};
wgBadKey = {
router = {
lans.main = lan { };
wireguard.rw = wg { peers.p1.publicKey = "not-a-key"; };
};
expect = [ "not a valid WireGuard public key" ];
};
wgBadEndpoint = {
router = {
lans.main = lan { };
wireguard.rw = wg { peers.p1.endpoint = "vpn.example.org"; };
};
expect = [ "endpoint must look like host:port" ];
};
wgSamePort = {
router = {
lans.main = lan { };
wireguard = {
a = wg { listenPort = 51820; };
b = wg { listenPort = 51820; };
};
};
expect = [ "listen on the same UDP port" ];
};
wgDeviceClash = {
router = {
lans.main = lan { };
wireguard.rw = wg { interface = "eth1"; };
};
expect = [ "clashes with a LAN device or the WAN interface" ];
};
wgNameTooLong = {
router = {
lans.main = lan { };
wireguard.a-very-long-tunnel-name = wg { };
};
expect = [ "longer than 15 characters" ];
};
wgRuleDestinationOutside = {
router = {
lans.main = lan { };
wireguard.s2s = wg { peers.p1.allowedIPs = [ "192.168.50.0/24" ]; };
firewall.rules = [
{
from = "main";
to = "s2s";
destination = "192.168.60.1";
}
];
};
expect = [ "is not inside any network of zone `s2s`" ];
};
wanValid = {
router = {
lans.main = lan { internet = true; };
firewall.rules = [
{
from = "wan";
to = "main";
destination = "192.168.10.5";
protocol = "tcp";
ports = [ 8080 ];
dnat = true;
toPort = 8081;
sources = [ "198.51.100.0/24" ];
rateLimit = "3/minute";
log = true;
hairpin = true;
}
{
from = "wan";
to = "main";
destination = "2001:db8:1::5";
protocol = "tcp";
ports = [ "9000-9002" ];
}
];
};
expect = [ ];
};
wanNoDestination = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "wan";
to = "main";
destination = null;
protocol = "tcp";
ports = [ 8080 ];
}
];
};
expect = [ "must name a destination" ];
};
wanAnyProtocol = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "wan";
to = "main";
destination = "192.168.10.5";
protocol = "any";
ports = [ ];
}
];
};
expect = [ "must name a protocol" ];
};
wanNoPorts = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "wan";
to = "main";
destination = "192.168.10.5";
protocol = "tcp";
ports = [ ];
}
];
};
expect = [ "must list the ports" ];
};
toWan = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "main";
to = "wan";
}
];
};
expect = [ "`wan` cannot be a destination" ];
};
dnatNeedsHost = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "wan";
to = "main";
destination = "192.168.10.0/24";
protocol = "tcp";
ports = [ 8080 ];
dnat = true;
}
];
};
expect = [ "dnat needs a single IPv4 host" ];
};
dnatV6Sources = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "wan";
to = "main";
destination = "192.168.10.5";
protocol = "tcp";
ports = [ 8080 ];
dnat = true;
sources = [ "2001:db8::/32" ];
}
];
};
expect = [ "its sources must be IPv4" ];
};
toPortWithoutDnat = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "wan";
to = "main";
destination = "192.168.10.5";
protocol = "tcp";
ports = [ 8080 ];
toPort = 8081;
}
];
};
expect = [ "toPort needs dnat" ];
};
hairpinNoDnat = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "wan";
to = "main";
destination = "192.168.10.5";
protocol = "tcp";
ports = [ 8080 ];
hairpin = true;
}
];
};
expect = [
"hairpin only applies to dnat"
"hairpin needs at least one"
];
};
hairpinNoInternet = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "wan";
to = "main";
destination = "192.168.10.5";
protocol = "tcp";
ports = [ 8080 ];
dnat = true;
hairpin = true;
}
];
};
expect = [ "hairpin needs at least one" ];
};
badPortRange = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "wan";
to = "main";
destination = "192.168.10.5";
protocol = "tcp";
ports = [ "9002-9000" ];
}
];
};
expect = [ "out of range" ];
};
badSource = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "wan";
to = "main";
destination = "192.168.10.5";
protocol = "tcp";
ports = [ 8080 ];
sources = [ "nonsense" ];
}
];
};
expect = [ "every source must be" ];
};
badV6Destination = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "wan";
to = "main";
destination = "2001:db8::zz";
protocol = "tcp";
ports = [ 8080 ];
}
];
};
expect = [ "not a valid IPv6" ];
};
sharedLinkValid = {
router = {
wan = {
ipv4 = {
type = "pppoe";
pppoe = {
vlan = 1001;
username = "u";
passwordFile = "/var/lib/pw";
};
};
};
lans.main = lan { interface = "eth0"; };
};
expect = [ ];
};
sharedLinkIpv6 = {
router = {
wan = {
ipv4 = {
type = "pppoe";
pppoe = {
vlan = 1001;
username = "u";
passwordFile = "/var/lib/pw";
};
};
ipv6.enable = true;
};
lans.main = lan { interface = "eth0"; };
};
expect = [ "also used by a LAN" ];
};
sharedLinkUntaggedUplink = {
router = {
wan = {
ipv4.type = "dhcp";
};
lans.main = lan { interface = "eth0"; };
};
expect = [ "also used by a LAN" ];
};
sharedLinkVlanClash = {
router = {
wan = {
ipv4 = {
type = "pppoe";
pppoe = {
vlan = 1001;
username = "u";
passwordFile = "/var/lib/pw";
};
};
};
lans.main = lan {
interface = "eth0";
vlan = 1001;
};
};
expect = [ "used by both the PPPoE uplink and a LAN" ];
};
dnsValid = {
router = {
lans.main = lan {
dhcp.reservations = {
nas = {
mac = "a8:a1:59:0b:4b:97";
address = "192.168.10.20";
};
};
};
dns = {
domain = "home.example.org";
hosts = {
printer = [ "192.168.10.30" ];
"git.example.org" = [
"10.43.0.50"
"fd00::50"
];
};
overrides."k8s.example.org" = [ "10.43.0.50" ];
forwardZones."branch.example.org" = [ "10.1.0.254" ];
};
};
expect = [ ];
};
dnsShortNoDomain = {
router = {
lans.main = lan { dhcp.reservations = { }; };
dns = {
hosts.printer = [ "192.168.10.30" ];
};
};
expect = [ "router.dns.domain must be set" ];
};
dnsBadName = {
router = {
lans.main = lan { dhcp.reservations = { }; };
dns = {
domain = "home.example.org";
hosts."bad name" = [ "192.168.10.30" ];
};
};
expect = [ "not a valid DNS name" ];
};
dnsBadAddress = {
router = {
lans.main = lan { dhcp.reservations = { }; };
dns = {
domain = "home.example.org";
hosts.x = [ "192.168.10.300" ];
};
};
expect = [ "valid IPv4 or IPv6 address" ];
};
dnsBadZone = {
router = {
lans.main = lan { dhcp.reservations = { }; };
dns = {
forwardZones."a.example.org" = [ ];
};
};
expect = [ "valid resolver address" ];
};
dnsHostVsReservation = {
router = {
lans.main = lan {
dhcp.reservations = {
nas = {
mac = "a8:a1:59:0b:4b:97";
address = "192.168.10.20";
};
};
};
dns = {
domain = "home.example.org";
hosts.nas = [ "192.168.10.99" ];
};
};
expect = [ "also a DHCP reservation" ];
};
resBadMac = {
router = {
lans.main = lan {
dhcp.reservations = {
nas = {
mac = "nonsense";
address = "192.168.10.20";
};
};
};
dns = { };
};
expect = [ "is not a MAC address" ];
};
resOutside = {
router = {
lans.main = lan {
dhcp.reservations = {
nas = {
mac = "a8:a1:59:0b:4b:97";
address = "192.168.99.20";
};
};
};
dns = { };
};
expect = [ "is not inside" ];
};
resRouterAddress = {
router = {
lans.main = lan {
dhcp.reservations = {
nas = {
mac = "a8:a1:59:0b:4b:97";
address = "192.168.10.1";
};
};
};
dns = { };
};
expect = [ "router's own address" ];
};
resDuplicates = {
router = {
lans.main = lan {
dhcp.reservations = {
a = {
mac = "a8:a1:59:0b:4b:97";
address = "192.168.10.20";
};
b = {
mac = "A8:A1:59:0B:4B:97";
address = "192.168.10.20";
};
};
};
dns = { };
};
expect = [
"share a MAC address"
"share an address"
];
};
resBadHostname = {
router = {
lans.main = lan {
dhcp.reservations = {
"bad name" = {
mac = "a8:a1:59:0b:4b:97";
address = "192.168.10.20";
};
};
};
dns = { };
};
expect = [ "not a valid hostname" ];
};
nonOverlappingLans = {
router.lans = {
a = lan { };
b = lan {
interface = "eth2";
ipv4.address = "192.168.20.1";
dhcp.pool = {
start = "192.168.20.100";
end = "192.168.20.199";
};
};
};
expect = [ ];
};
};
problems = lib.concatLists (
lib.mapAttrsToList (
name: c:
let
got = failed c.router;
missing = lib.filter (e: !lib.any (m: lib.hasInfix e m) got) c.expect;
in
lib.optional (
missing != [ ] || lib.length got != lib.length c.expect
) "${name}: expected ${builtins.toJSON c.expect}, got ${builtins.toJSON got}"
) 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"
"vlansOnOneTrunk"
"ruleValid"
"wgValid"
"wanValid"
"sharedLinkValid"
"dnsValid"
];
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 ++ fullEvalProblems == [ ] then
"touch $out"
else
''
printf '%s\n' ${lib.escapeShellArgs (problems ++ subnetProblem ++ fullEvalProblems)} >&2
exit 1
''
)