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