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
+61 -3
View File
@@ -31,6 +31,11 @@ let
assertion = addrOk;
message = "router: ${where}.ipv4.address `${lan.ipv4.address}` is not a valid IPv4 address.";
}
{
# IFNAMSIZ is 16 including the terminating NUL.
assertion = lib.stringLength lan.device <= 15;
message = "router: ${where}: device name `${lan.device}` is longer than 15 characters.";
}
]
++ lib.optionals lan.dhcp.enable (
[
@@ -55,6 +60,58 @@ let
]
);
zoneNames = lib.unique (map (l: l.zone) lans);
ruleAssertions =
r:
let
where = "router.firewall.rules (${r.from} -> ${r.to})";
dest = if r.destination == null then null else ipv4.parseNet r.destination;
destLans = lib.filter (l: l.zone == r.to && validIp l.ipv4.address) lans;
inZone =
dest != null
&& lib.any (
l:
dest.prefixLength >= l.ipv4.prefixLength
&& ipv4.inSubnet {
address = ipv4.parse l.ipv4.address;
inherit (l.ipv4) prefixLength;
} dest.address
) destLans;
in
[
{
assertion = lib.elem r.from zoneNames;
message = "router: ${where}: unknown source zone `${r.from}` (zones: ${lib.concatStringsSep ", " zoneNames}).";
}
{
assertion = lib.elem r.to zoneNames;
message = "router: ${where}: unknown destination zone `${r.to}` (zones: ${lib.concatStringsSep ", " zoneNames}).";
}
{
assertion = r.from != r.to;
message = "router: ${where}: source and destination are the same zone; traffic within a zone is already allowed.";
}
{
assertion = r.ports == [ ] || r.protocol == "tcp" || r.protocol == "udp";
message = "router: ${where}: ports are only valid with protocol tcp or udp.";
}
]
++ lib.optionals (r.destination != null) [
{
assertion = dest != null;
message = "router: ${where}: destination `${r.destination}` is not a valid IPv4 address or CIDR.";
}
{
assertion = dest == null || !(lib.elem r.to zoneNames) || inZone;
message = "router: ${where}: destination `${r.destination}` is not inside any LAN of zone `${r.to}`.";
}
{
assertion = dest == null || ipv4.networkOf dest.address dest.prefixLength == dest.address;
message = "router: ${where}: destination `${r.destination}` has host bits set.";
}
];
overlapPairs = lib.concatLists (
lib.imap0 (
i: a:
@@ -98,13 +155,14 @@ in
{
assertion =
let
ifaces = map (l: l.interface) lans;
devices = map (l: l.device) lans;
in
lib.length ifaces == lib.length (lib.unique ifaces);
message = "router: two LANs share the same interface.";
lib.length devices == lib.length (lib.unique devices);
message = "router: two LANs share the same device (same interface and VLAN).";
}
]
++ lib.concatLists (lib.mapAttrsToList perLan lansByName)
++ lib.concatMap ruleAssertions cfg.firewall.rules
++ map (
{ a, b }:
let