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
+7 -4
View File
@@ -2,6 +2,7 @@
let
cfg = config.router;
dhcpLans = lib.filter (l: l.dhcp.enable) (lib.attrValues cfg.lans);
ipv4 = (import ./lib.nix { inherit lib; }).ipv4;
in
{
config = lib.mkIf (cfg.enable && dhcpLans != [ ]) {
@@ -9,7 +10,7 @@ in
enable = true;
settings = {
interfaces-config = {
interfaces = map (l: l.interface) dhcpLans;
interfaces = map (l: l.device) dhcpLans;
# Interfaces are configured asynchronously by networkd.
re-detect = true;
};
@@ -19,10 +20,12 @@ in
name = "/var/lib/kea/dhcp4.leases";
};
valid-lifetime = 3600;
subnet4 = lib.imap1 (id: l: {
inherit id;
# The ID keys persistent leases, so derive it from the network address:
# stable when LANs are added or removed (an index would shift).
subnet4 = map (l: {
id = ipv4.networkOf (ipv4.parse l.ipv4.address) l.ipv4.prefixLength;
inherit (l.ipv4) subnet;
interface = l.interface;
interface = l.device;
pools = [ { pool = "${l.dhcp.pool.start} - ${l.dhcp.pool.end}"; } ];
option-data = [
{