Add VLANs, firewall zones and inter-zone rules

LANs can sit on an 802.1Q VLAN (`vlan` on a trunk `interface`); networkd
creates the device and brings the trunk up. LANs belong to a named `zone`
(default: their own name). Traffic inside a zone is forwarded freely, traffic
between zones is denied unless a `router.firewall.rules` entry allows it
(protocol, ports, optional destination inside the target zone).

Internet access is now opt-in per LAN (`internet`, default false), replacing
the previous allow-out default. Assertions reject unknown zones, bad
destinations, duplicate devices, over-long device names and a WAN trunk.

Kea subnet IDs now come from the network address so adding LANs does not
renumber them and orphan persistent leases.

Tests: a five-VM `vlans` check (untagged + two VLANs, shared zone, one-way
rules, per-host destination restriction), new assertion cases, parseNet unit
tests, and shared test tooling in tests/common.nix.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 17:54:52 +01:00
co-authored by Claude Sonnet 5.5
parent 6038367252
commit 6015ee2e6d
13 changed files with 931 additions and 86 deletions
+14
View File
@@ -18,6 +18,20 @@ rec {
else
lib.foldl' (acc: o: acc * 256 + o) 0 octets;
# "10.0.0.5" or "10.0.0.0/24" -> { address (int); prefixLength }, or null.
parseNet =
s:
let
m = builtins.match "([0-9.]+)(/([0-9]{1,2}))?" s;
address = if m == null then null else parse (builtins.elemAt m 0);
prefixLength =
if m == null || builtins.elemAt m 2 == null then 32 else lib.toInt (builtins.elemAt m 2);
in
if m == null || address == null || prefixLength > 32 then
null
else
{ inherit address prefixLength; };
format =
n:
lib.concatMapStringsSep "." (shift: toString (lib.mod (n / pow2 shift) 256)) [