# 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" ]; }; 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" ]; 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 '' )