Files
nixos-router/tests/assertions.nix
T
nilsandClaude Sonnet 5.5 0653b5cf2f Add router-on-a-stick, commit-confirm rollback and an example config
- Allow the WAN link to also carry LANs when the uplink is PPPoE on a
  VLAN with IPv6 off; only the PPP device counts as an uplink then, so
  LAN traffic can never match WAN rules.
- Add router.rollback: router-arm / router-confirm / router-rollback
  plus `nix run .#deploy`, a commit-confirm deployment flow.
- Wait for the LAN devices instead of the uplink in networkd
  wait-online, so an unplugged uplink cannot block boot or activation.
- Add examples/desktop (single NIC, PPPoE on VLAN 1001) as
  nixosConfigurations.example, built as a flake check.

Covered by the onestick and rollback VM tests and new assertion cases.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 23:09:09 +01:00

857 lines
20 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" ];
};
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"
];
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
''
)