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
+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 = [
{
+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)}
}
'';
};
+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)) [
+49 -10
View File
@@ -5,6 +5,17 @@ let
pppoe = cfg.wan.ipv4.type == "pppoe";
vlan = cfg.wan.ipv4.pppoe.vlan;
vlanDevice = "${cfg.wan.interface}.${toString vlan}";
lans = lib.attrValues cfg.lans;
vlanLans = lib.filter (l: l.vlan != null) lans;
# VLAN devices hang off their parent; the parent's own unit must list them.
vlansOn = parent: map (l: l.device) (lib.filter (l: l.interface == parent) vlanLans);
untaggedParents = map (l: l.interface) (lib.filter (l: l.vlan == null) lans);
# Parents that carry only tagged LANs still need a unit to be brought up.
trunkParents = lib.filter (p: !(lib.elem p untaggedParents)) (
lib.unique (map (l: l.interface) vlanLans)
);
dhcp = if pppoe then (if v6 then "ipv6" else "no") else (if v6 then "yes" else "ipv4");
in
{
@@ -17,15 +28,26 @@ in
"net.ipv6.conf.all.forwarding" = 1;
};
systemd.network.netdevs = lib.optionalAttrs (pppoe && vlan != null) {
"05-wan-pppoe" = {
netdevConfig = {
Kind = "vlan";
Name = vlanDevice;
systemd.network.netdevs =
lib.optionalAttrs (pppoe && vlan != null) {
"05-wan-pppoe" = {
netdevConfig = {
Kind = "vlan";
Name = vlanDevice;
};
vlanConfig.Id = vlan;
};
vlanConfig.Id = vlan;
};
};
}
// lib.mapAttrs' (
name: lan:
lib.nameValuePair "06-lan-${name}" {
netdevConfig = {
Kind = "vlan";
Name = lan.device;
};
vlanConfig.Id = lan.vlan;
}
) (lib.filterAttrs (_: l: l.vlan != null) cfg.lans);
systemd.network.networks = {
"10-wan" = {
@@ -68,13 +90,16 @@ in
name: lan:
lib.nameValuePair "20-lan-${name}" (
{
matchConfig.Name = lan.interface;
matchConfig.Name = lan.device;
address = [ "${lan.ipv4.address}/${toString lan.ipv4.prefixLength}" ];
networkConfig = {
ConfigureWithoutCarrier = true;
# Never accept RAs from the inside.
IPv6AcceptRA = false;
}
// lib.optionalAttrs (lan.vlan == null && vlansOn lan.interface != [ ]) {
VLAN = vlansOn lan.interface;
}
// lib.optionalAttrs lan.ipv6.pd {
DHCPPrefixDelegation = true;
IPv6SendRA = true;
@@ -91,6 +116,20 @@ in
ipv6SendRAConfig.EmitDNS = false;
}
)
) cfg.lans;
) cfg.lans
// lib.listToAttrs (
map (
parent:
lib.nameValuePair "19-trunk-${parent}" {
matchConfig.Name = parent;
networkConfig = {
LinkLocalAddressing = "no";
IPv6AcceptRA = false;
ConfigureWithoutCarrier = true;
VLAN = vlansOn parent;
};
}
) trunkParents
);
};
}
+84 -2
View File
@@ -78,13 +78,46 @@ in
description = "LAN segments served by this router, keyed by name.";
type = types.attrsOf (
types.submodule (
{ config, ... }:
{ name, config, ... }:
{
options = {
interface = mkOption {
type = types.str;
example = "eth1";
description = "Interface (or bridge/VLAN device) carrying this LAN.";
description = ''
Interface carrying this LAN. With `vlan` set this is the
parent (trunk) interface on which the VLAN device is created.
'';
};
vlan = mkOption {
type = types.nullOr (types.ints.between 1 4094);
default = null;
description = "802.1Q VLAN ID, or null for an untagged LAN on `interface`.";
};
device = mkOption {
type = types.str;
readOnly = true;
internal = true;
description = "Device the LAN's addresses live on (derived).";
};
zone = mkOption {
type = types.str;
default = name;
description = ''
Firewall zone this LAN belongs to. LANs sharing a zone may
talk to each other freely; traffic between zones is denied
unless a rule in `router.firewall.rules` allows it. Defaults
to a zone named after the LAN.
'';
};
internet = mkOption {
type = types.bool;
default = false;
description = "Whether this LAN may reach the internet (opt-in).";
};
ipv4 = {
@@ -139,6 +172,9 @@ in
};
};
config.device =
if config.vlan == null then config.interface else "${config.interface}.${toString config.vlan}";
# "invalid" is only ever seen when assertions.nix is already failing.
config.ipv4.subnet =
if rlib.ipv4.parse config.ipv4.address == null then
@@ -150,6 +186,52 @@ in
);
};
firewall.rules = mkOption {
default = [ ];
description = ''
Allow rules between zones, on top of the default (deny between zones).
Rules are one-directional; return traffic is allowed by connection
tracking. Both ends must be zones of LANs defined in `router.lans`.
'';
type = types.listOf (
types.submodule {
options = {
from = mkOption {
type = types.str;
description = "Source zone.";
};
to = mkOption {
type = types.str;
description = "Destination zone.";
};
protocol = mkOption {
type = types.enum [
"any"
"tcp"
"udp"
"icmp"
];
default = "any";
};
ports = mkOption {
type = types.listOf types.port;
default = [ ];
description = "Destination ports (tcp/udp only); empty means all ports.";
};
destination = mkOption {
type = types.nullOr types.str;
default = null;
example = "192.168.10.5";
description = ''
Restrict the rule to an IPv4 address or CIDR inside the
destination zone; null means the whole zone.
'';
};
};
}
);
};
dns.forwarders = mkOption {
type = types.listOf types.str;
default = [ ];