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
+87 -2
View File
@@ -1,7 +1,68 @@
{ config, lib, ... }:
let
cfg = config.router;
lans = lib.attrValues cfg.lans;
ipv4 = (import ./lib.nix { inherit lib; }).ipv4;
lansByName = cfg.lans;
lans = lib.attrValues lansByName;
validIp = s: ipv4.parse s != null;
# Assertions about a single LAN; skipped (reported once) when its addresses
# do not even parse, so we never feed null into the integer helpers.
perLan =
name: lan:
let
where = "router.lans.${name}";
addrOk = validIp lan.ipv4.address;
startOk = validIp lan.dhcp.pool.start;
endOk = validIp lan.dhcp.pool.end;
addr = ipv4.parse lan.ipv4.address;
start = ipv4.parse lan.dhcp.pool.start;
end = ipv4.parse lan.dhcp.pool.end;
net = {
address = addr;
inherit (lan.ipv4) prefixLength;
};
parsed = addrOk && startOk && endOk;
in
[
{
assertion = addrOk;
message = "router: ${where}.ipv4.address `${lan.ipv4.address}` is not a valid IPv4 address.";
}
]
++ lib.optionals lan.dhcp.enable (
[
{
assertion = startOk && endOk;
message = "router: ${where}.dhcp.pool.start/end must be valid IPv4 addresses.";
}
]
++ lib.optionals parsed [
{
assertion = start <= end;
message = "router: ${where}.dhcp.pool starts after it ends.";
}
{
assertion = ipv4.inSubnet net start && ipv4.inSubnet net end;
message = "router: ${where}.dhcp.pool is not inside ${lan.ipv4.subnet}.";
}
{
assertion = !(start <= addr && addr <= end);
message = "router: ${where}.dhcp.pool contains the router's own address ${lan.ipv4.address}.";
}
]
);
overlapPairs = lib.concatLists (
lib.imap0 (
i: a:
map (b: {
inherit a b;
}) (lib.drop (i + 1) (lib.attrNames lansByName))
) (lib.attrNames lansByName)
);
in
{
config = lib.mkIf cfg.enable {
@@ -26,6 +87,30 @@ in
lib.length ifaces == lib.length (lib.unique ifaces);
message = "router: two LANs share the same interface.";
}
];
]
++ lib.concatLists (lib.mapAttrsToList perLan lansByName)
++ map (
{ a, b }:
let
la = lansByName.${a};
lb = lansByName.${b};
in
{
# Only comparable when both addresses parse; the per-LAN assertion reports the rest.
assertion =
!(validIp la.ipv4.address && validIp lb.ipv4.address)
|| !(ipv4.overlaps
{
address = ipv4.parse la.ipv4.address;
inherit (la.ipv4) prefixLength;
}
{
address = ipv4.parse lb.ipv4.address;
inherit (lb.ipv4) prefixLength;
}
);
message = "router: LANs `${a}` (${la.ipv4.subnet}) and `${b}` (${lb.ipv4.subnet}) overlap.";
}
) overlapPairs;
};
}
+49
View File
@@ -0,0 +1,49 @@
# Small IPv4 helpers (addresses are plain 32-bit integers inside Nix).
# Parsing returns null on malformed input so callers can turn that into an
# assertion message instead of an evaluation crash.
{ lib }:
let
pow2 = n: if n == 0 then 1 else 2 * pow2 (n - 1);
in
rec {
ipv4 = rec {
parse =
s:
let
m = builtins.match "([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})" s;
octets = map lib.toInt m;
in
if m == null || lib.any (o: o > 255) octets then
null
else
lib.foldl' (acc: o: acc * 256 + o) 0 octets;
format =
n:
lib.concatMapStringsSep "." (shift: toString (lib.mod (n / pow2 shift) 256)) [
24
16
8
0
];
netMask = prefixLength: 4294967295 - (pow2 (32 - prefixLength) - 1);
# Network address (integer) of `address` under `prefixLength`.
networkOf = address: prefixLength: builtins.bitAnd address (netMask prefixLength);
# "192.168.10.1" /24 -> "192.168.10.0/24"
cidr = address: prefixLength: "${format (networkOf (parse address) prefixLength)}/${toString prefixLength}";
inSubnet =
{ address, prefixLength }: ip: networkOf ip prefixLength == networkOf address prefixLength;
# Whether two subnets share any address (one contains the other's network).
overlaps =
a: b:
let
p = lib.min a.prefixLength b.prefixLength;
in
networkOf a.address p == networkOf b.address p;
};
}
+52 -41
View File
@@ -1,6 +1,7 @@
{ lib, ... }:
let
inherit (lib) mkOption mkEnableOption types;
rlib = import ./lib.nix { inherit lib; };
in
{
options.router = {
@@ -18,55 +19,65 @@ in
default = { };
description = "LAN segments served by this router, keyed by name.";
type = types.attrsOf (
types.submodule {
options = {
interface = mkOption {
type = types.str;
example = "eth1";
description = "Interface (or bridge/VLAN device) carrying this LAN.";
};
ipv4 = {
address = mkOption {
types.submodule (
{ config, ... }:
{
options = {
interface = mkOption {
type = types.str;
example = "192.168.10.1";
description = "Router address on this LAN.";
example = "eth1";
description = "Interface (or bridge/VLAN device) carrying this LAN.";
};
prefixLength = mkOption {
type = types.ints.between 8 30;
default = 24;
};
subnet = mkOption {
type = types.str;
example = "192.168.10.0/24";
description = "Network in CIDR notation; must contain `address`.";
};
};
dhcp = {
enable = mkOption {
ipv4 = {
address = mkOption {
type = types.str;
example = "192.168.10.1";
description = "Router address on this LAN.";
};
prefixLength = mkOption {
type = types.ints.between 8 30;
default = 24;
};
subnet = mkOption {
type = types.str;
readOnly = true;
description = "Network in CIDR notation, derived from `address` and `prefixLength`.";
};
};
dhcp = {
enable = mkOption {
type = types.bool;
default = true;
};
pool = {
start = mkOption {
type = types.str;
example = "192.168.10.100";
};
end = mkOption {
type = types.str;
example = "192.168.10.199";
};
};
};
allowManagement = mkOption {
type = types.bool;
default = true;
};
pool = {
start = mkOption {
type = types.str;
example = "192.168.10.100";
};
end = mkOption {
type = types.str;
example = "192.168.10.199";
};
description = "Whether clients on this LAN may reach SSH on the router.";
};
};
allowManagement = mkOption {
type = types.bool;
default = true;
description = "Whether clients on this LAN may reach SSH on the router.";
};
};
}
# "invalid" is only ever seen when assertions.nix is already failing.
config.ipv4.subnet =
if rlib.ipv4.parse config.ipv4.address == null then
"invalid"
else
rlib.ipv4.cidr config.ipv4.address config.ipv4.prefixLength;
}
)
);
};