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
+127 -25
View File
@@ -7,44 +7,146 @@ let
ip = ipv4.parse;
failures = lib.runTests {
testParse = { expr = ip "192.168.10.1"; expected = 3232238081; };
testParseZero = { expr = ip "0.0.0.0"; expected = 0; };
testParseMax = { expr = ip "255.255.255.255"; expected = 4294967295; };
testParseBadOctet = { expr = ip "192.168.10.256"; expected = null; };
testParseShort = { expr = ip "192.168.10"; expected = null; };
testParseJunk = { expr = ip "192.168.10.x"; expected = null; };
testFormatRoundTrip = { expr = ipv4.format (ip "10.20.30.40"); expected = "10.20.30.40"; };
testFormatZero = { expr = ipv4.format 0; expected = "0.0.0.0"; };
testNetMask24 = { expr = ipv4.format (ipv4.netMask 24); expected = "255.255.255.0"; };
testNetMask20 = { expr = ipv4.format (ipv4.netMask 20); expected = "255.255.240.0"; };
testNetMask8 = { expr = ipv4.format (ipv4.netMask 8); expected = "255.0.0.0"; };
testCidr24 = { expr = ipv4.cidr "192.168.10.77" 24; expected = "192.168.10.0/24"; };
testCidr20 = { expr = ipv4.cidr "172.16.37.1" 20; expected = "172.16.32.0/20"; };
testCidr30 = { expr = ipv4.cidr "10.0.0.6" 30; expected = "10.0.0.4/30"; };
testParse = {
expr = ip "192.168.10.1";
expected = 3232238081;
};
testParseZero = {
expr = ip "0.0.0.0";
expected = 0;
};
testParseMax = {
expr = ip "255.255.255.255";
expected = 4294967295;
};
testParseBadOctet = {
expr = ip "192.168.10.256";
expected = null;
};
testParseShort = {
expr = ip "192.168.10";
expected = null;
};
testParseJunk = {
expr = ip "192.168.10.x";
expected = null;
};
testParseNetHost = {
expr = ipv4.parseNet "10.0.0.5";
expected = {
address = ip "10.0.0.5";
prefixLength = 32;
};
};
testParseNetCidr = {
expr = ipv4.parseNet "192.168.10.0/24";
expected = {
address = ip "192.168.10.0";
prefixLength = 24;
};
};
testParseNetZeroPrefix = {
expr = ipv4.parseNet "0.0.0.0/0";
expected = {
address = 0;
prefixLength = 0;
};
};
testParseNetPrefixTooLong = {
expr = ipv4.parseNet "10.0.0.0/33";
expected = null;
};
testParseNetBadAddress = {
expr = ipv4.parseNet "10.0.0.256/24";
expected = null;
};
testParseNetJunk = {
expr = ipv4.parseNet "10.0.0.0/x";
expected = null;
};
testFormatRoundTrip = {
expr = ipv4.format (ip "10.20.30.40");
expected = "10.20.30.40";
};
testFormatZero = {
expr = ipv4.format 0;
expected = "0.0.0.0";
};
testNetMask24 = {
expr = ipv4.format (ipv4.netMask 24);
expected = "255.255.255.0";
};
testNetMask20 = {
expr = ipv4.format (ipv4.netMask 20);
expected = "255.255.240.0";
};
testNetMask8 = {
expr = ipv4.format (ipv4.netMask 8);
expected = "255.0.0.0";
};
testCidr24 = {
expr = ipv4.cidr "192.168.10.77" 24;
expected = "192.168.10.0/24";
};
testCidr20 = {
expr = ipv4.cidr "172.16.37.1" 20;
expected = "172.16.32.0/20";
};
testCidr30 = {
expr = ipv4.cidr "10.0.0.6" 30;
expected = "10.0.0.4/30";
};
testInSubnetYes = {
expr = ipv4.inSubnet { address = ip "192.168.10.1"; prefixLength = 24; } (ip "192.168.10.200");
expr = ipv4.inSubnet {
address = ip "192.168.10.1";
prefixLength = 24;
} (ip "192.168.10.200");
expected = true;
};
testInSubnetNo = {
expr = ipv4.inSubnet { address = ip "192.168.10.1"; prefixLength = 24; } (ip "192.168.11.1");
expr = ipv4.inSubnet {
address = ip "192.168.10.1";
prefixLength = 24;
} (ip "192.168.11.1");
expected = false;
};
testOverlapNested = {
expr = ipv4.overlaps
{ address = ip "10.0.0.1"; prefixLength = 8; }
{ address = ip "10.1.2.3"; prefixLength = 24; };
expr =
ipv4.overlaps
{
address = ip "10.0.0.1";
prefixLength = 8;
}
{
address = ip "10.1.2.3";
prefixLength = 24;
};
expected = true;
};
testOverlapSame = {
expr = ipv4.overlaps
{ address = ip "192.168.10.1"; prefixLength = 24; }
{ address = ip "192.168.10.9"; prefixLength = 24; };
expr =
ipv4.overlaps
{
address = ip "192.168.10.1";
prefixLength = 24;
}
{
address = ip "192.168.10.9";
prefixLength = 24;
};
expected = true;
};
testOverlapDisjoint = {
expr = ipv4.overlaps
{ address = ip "192.168.10.1"; prefixLength = 24; }
{ address = ip "192.168.11.1"; prefixLength = 24; };
expr =
ipv4.overlaps
{
address = ip "192.168.10.1";
prefixLength = 24;
}
{
address = ip "192.168.11.1";
prefixLength = 24;
};
expected = false;
};
};