Derive LAN subnets and validate addressing at build time

Add an IPv4 helper lib (parse, netmask, cidr, overlap) and derive
router.lans.<name>.ipv4.subnet from address and prefixLength instead of
requiring it twice. New assertions reject invalid addresses, DHCP pools that
are reversed, outside the subnet or contain the router address, and
overlapping LANs.

Add `lib` unit tests and an `assertions` check that evaluates broken configs
and compares the exact error messages.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 15:00:24 +01:00
co-authored by Claude Sonnet 5.5
parent 0ba4d49766
commit 96e5b7b859
9 changed files with 362 additions and 47 deletions
+108
View File
@@ -0,0 +1,108 @@
# 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 = {
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;
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 interface" ];
};
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" ];
};
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
);
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 == [ ] then
"touch $out"
else
''
printf '%s\n' ${lib.escapeShellArgs (problems ++ subnetProblem)} >&2
exit 1
''
)
+1 -1
View File
@@ -62,7 +62,7 @@ pkgs.testers.runNixOSTest {
wan.interface = "eth1";
lans.main = {
interface = "eth2";
ipv4 = { address = "192.168.10.1"; subnet = "192.168.10.0/24"; };
ipv4 = { address = "192.168.10.1"; };
dhcp.pool = { start = "192.168.10.100"; end = "192.168.10.199"; };
};
dns.forwarders = [ "203.0.113.1" ];
+2
View File
@@ -1,4 +1,6 @@
{ self, pkgs }:
{
basic = import ./basic.nix { inherit self pkgs; };
lib = import ./lib.nix { inherit pkgs; };
assertions = import ./assertions.nix { inherit self pkgs; };
}
+60
View File
@@ -0,0 +1,60 @@
# Unit tests for modules/router/lib.nix, run at evaluation time.
{ pkgs }:
let
inherit (pkgs) lib;
r = import ../modules/router/lib.nix { inherit lib; };
inherit (r) ipv4;
ip = ipv4.parse;
failures = lib.runTests {
testParse = { expr = ip "192.168.10.1"; expected = 3232238081; };
testParseZero = { expr = ip "0.0.0.0"; expected = 0; };
testParseMax = { expr = ip "255.255.255.255"; expected = 4294967295; };
testParseBadOctet = { expr = ip "192.168.10.256"; expected = null; };
testParseShort = { expr = ip "192.168.10"; expected = null; };
testParseJunk = { expr = ip "192.168.10.x"; expected = null; };
testFormatRoundTrip = { expr = ipv4.format (ip "10.20.30.40"); expected = "10.20.30.40"; };
testFormatZero = { expr = ipv4.format 0; expected = "0.0.0.0"; };
testNetMask24 = { expr = ipv4.format (ipv4.netMask 24); expected = "255.255.255.0"; };
testNetMask20 = { expr = ipv4.format (ipv4.netMask 20); expected = "255.255.240.0"; };
testNetMask8 = { expr = ipv4.format (ipv4.netMask 8); expected = "255.0.0.0"; };
testCidr24 = { expr = ipv4.cidr "192.168.10.77" 24; expected = "192.168.10.0/24"; };
testCidr20 = { expr = ipv4.cidr "172.16.37.1" 20; expected = "172.16.32.0/20"; };
testCidr30 = { expr = ipv4.cidr "10.0.0.6" 30; expected = "10.0.0.4/30"; };
testInSubnetYes = {
expr = ipv4.inSubnet { address = ip "192.168.10.1"; prefixLength = 24; } (ip "192.168.10.200");
expected = true;
};
testInSubnetNo = {
expr = ipv4.inSubnet { address = ip "192.168.10.1"; prefixLength = 24; } (ip "192.168.11.1");
expected = false;
};
testOverlapNested = {
expr = ipv4.overlaps
{ address = ip "10.0.0.1"; prefixLength = 8; }
{ address = ip "10.1.2.3"; prefixLength = 24; };
expected = true;
};
testOverlapSame = {
expr = ipv4.overlaps
{ address = ip "192.168.10.1"; prefixLength = 24; }
{ address = ip "192.168.10.9"; prefixLength = 24; };
expected = true;
};
testOverlapDisjoint = {
expr = ipv4.overlaps
{ address = ip "192.168.10.1"; prefixLength = 24; }
{ address = ip "192.168.11.1"; prefixLength = 24; };
expected = false;
};
};
in
pkgs.runCommand "router-lib-tests" { } (
if failures == [ ] then
"touch $out"
else
''
echo ${lib.escapeShellArg (lib.generators.toPretty { } failures)} >&2
exit 1
''
)