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
+39 -4
View File
@@ -4,8 +4,38 @@ let
lans = lib.attrValues cfg.lans;
set = ifaces: "{ ${lib.concatMapStringsSep ", " (i: ''"${i}"'') ifaces} }";
lanIfaces = map (l: l.interface) lans;
mgmtIfaces = map (l: l.interface) (lib.filter (l: l.allowManagement) lans);
lanIfaces = map (l: l.device) lans;
mgmtIfaces = map (l: l.device) (lib.filter (l: l.allowManagement) lans);
internetIfaces = map (l: l.device) (lib.filter (l: l.internet) lans);
# zone name -> devices of its member LANs
zones = lib.mapAttrs (_: ls: map (l: l.device) ls) (lib.groupBy (l: l.zone) lans);
zoneDevices = zone: zones.${zone} or [ ];
# LANs sharing a zone may forward to each other (a single LAN is plain L2).
intraZone = lib.concatLists (
lib.mapAttrsToList (
zone: devs:
lib.optional (lib.length devs > 1) "iifname ${set devs} oifname ${set devs} accept # zone ${zone}"
) zones
);
ruleToNft =
r:
let
dest = lib.optionalString (r.destination != null) "ip daddr ${r.destination} ";
proto =
if r.protocol == "any" then
""
else if r.protocol == "icmp" then
"meta l4proto { icmp, ipv6-icmp } "
else
"${r.protocol} dport ${
if r.ports == [ ] then "1-65535" else "{ ${lib.concatMapStringsSep ", " toString r.ports} }"
} ";
in
"iifname ${set (zoneDevices r.from)} oifname ${set (zoneDevices r.to)} ${dest}${proto}accept # ${r.from} -> ${r.to}";
uplink4 = cfg.wan.ipv4.interface;
pppoe = cfg.wan.ipv4.type == "pppoe";
# IPv4 leaves via the PPP device, IPv6 via the physical uplink.
@@ -14,7 +44,7 @@ let
uplink4
];
mss = cfg.wan.ipv4.pppoe.mtu - 40;
dhcpIfaces = map (l: l.interface) (lib.filter (l: l.dhcp.enable) lans);
dhcpIfaces = map (l: l.device) (lib.filter (l: l.dhcp.enable) lans);
in
{
config = lib.mkIf cfg.enable {
@@ -64,7 +94,12 @@ in
iifname "${uplink4}" tcp flags syn tcp option maxseg size > ${toString mss} tcp option maxseg size set ${toString mss}
oifname "${uplink4}" tcp flags syn tcp option maxseg size > ${toString mss} tcp option maxseg size set ${toString mss}
''}
iifname ${set lanIfaces} oifname ${set uplinks} accept
${lib.optionalString (
internetIfaces != [ ]
) "iifname ${set internetIfaces} oifname ${set uplinks} accept"}
# Zones: free inside a zone, denied between zones unless a rule says so.
${lib.concatStringsSep "\n " (intraZone ++ map ruleToNft cfg.firewall.rules)}
}
'';
};