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:
+261
-20
@@ -25,16 +25,37 @@ let
|
||||
];
|
||||
}).config;
|
||||
|
||||
lan = overrides: lib.recursiveUpdate {
|
||||
interface = "eth1";
|
||||
ipv4.address = "192.168.10.1";
|
||||
dhcp.pool = { start = "192.168.10.100"; end = "192.168.10.199"; };
|
||||
} overrides;
|
||||
lan =
|
||||
overrides:
|
||||
lib.recursiveUpdate {
|
||||
interface = "eth1";
|
||||
ipv4.address = "192.168.10.1";
|
||||
dhcp.pool = {
|
||||
start = "192.168.10.100";
|
||||
end = "192.168.10.199";
|
||||
};
|
||||
} overrides;
|
||||
|
||||
lan2 =
|
||||
overrides:
|
||||
lan (
|
||||
lib.recursiveUpdate {
|
||||
interface = "eth2";
|
||||
ipv4.address = "192.168.20.1";
|
||||
dhcp.pool = {
|
||||
start = "192.168.20.100";
|
||||
end = "192.168.20.199";
|
||||
};
|
||||
} overrides
|
||||
);
|
||||
|
||||
failed = router: map (a: a.message) (lib.filter (a: !a.assertion) (evalRouter router).assertions);
|
||||
|
||||
cases = {
|
||||
valid = { router.lans.main = lan { }; expect = [ ]; };
|
||||
valid = {
|
||||
router.lans.main = lan { };
|
||||
expect = [ ];
|
||||
};
|
||||
noManagement = {
|
||||
router.lans.main = lan { allowManagement = false; };
|
||||
expect = [ "locked out" ];
|
||||
@@ -46,30 +67,57 @@ let
|
||||
sharedInterface = {
|
||||
router.lans = {
|
||||
a = lan { };
|
||||
b = lan { ipv4.address = "192.168.20.1"; dhcp.pool = { start = "192.168.20.100"; end = "192.168.20.199"; }; };
|
||||
b = lan {
|
||||
ipv4.address = "192.168.20.1";
|
||||
dhcp.pool = {
|
||||
start = "192.168.20.100";
|
||||
end = "192.168.20.199";
|
||||
};
|
||||
};
|
||||
};
|
||||
expect = [ "two LANs share the same interface" ];
|
||||
expect = [ "two LANs share the same device" ];
|
||||
};
|
||||
badAddress = {
|
||||
router.lans.main = lan { ipv4.address = "192.168.10.300"; dhcp.enable = false; };
|
||||
router.lans.main = lan {
|
||||
ipv4.address = "192.168.10.300";
|
||||
dhcp.enable = false;
|
||||
};
|
||||
expect = [ "`192.168.10.300` is not a valid IPv4 address" ];
|
||||
};
|
||||
poolOutsideSubnet = {
|
||||
router.lans.main = lan { dhcp.pool = { start = "192.168.11.100"; end = "192.168.11.199"; }; };
|
||||
router.lans.main = lan {
|
||||
dhcp.pool = {
|
||||
start = "192.168.11.100";
|
||||
end = "192.168.11.199";
|
||||
};
|
||||
};
|
||||
expect = [ "pool is not inside 192.168.10.0/24" ];
|
||||
};
|
||||
poolReversed = {
|
||||
router.lans.main = lan { dhcp.pool = { start = "192.168.10.199"; end = "192.168.10.100"; }; };
|
||||
router.lans.main = lan {
|
||||
dhcp.pool = {
|
||||
start = "192.168.10.199";
|
||||
end = "192.168.10.100";
|
||||
};
|
||||
};
|
||||
expect = [ "starts after it ends" ];
|
||||
};
|
||||
poolContainsRouter = {
|
||||
router.lans.main = lan { dhcp.pool = { start = "192.168.10.1"; end = "192.168.10.50"; }; };
|
||||
router.lans.main = lan {
|
||||
dhcp.pool = {
|
||||
start = "192.168.10.1";
|
||||
end = "192.168.10.50";
|
||||
};
|
||||
};
|
||||
expect = [ "contains the router's own address 192.168.10.1" ];
|
||||
};
|
||||
overlappingLans = {
|
||||
router.lans = {
|
||||
a = lan { };
|
||||
b = lan { interface = "eth2"; ipv4.address = "192.168.10.2"; };
|
||||
b = lan {
|
||||
interface = "eth2";
|
||||
ipv4.address = "192.168.10.2";
|
||||
};
|
||||
};
|
||||
expect = [ "`a` (192.168.10.0/24) and `b` (192.168.10.0/24) overlap" ];
|
||||
};
|
||||
@@ -88,7 +136,10 @@ let
|
||||
b = lan {
|
||||
interface = "eth2";
|
||||
ipv4.address = "192.168.20.1";
|
||||
dhcp.pool = { start = "192.168.20.100"; end = "192.168.20.199"; };
|
||||
dhcp.pool = {
|
||||
start = "192.168.20.100";
|
||||
end = "192.168.20.199";
|
||||
};
|
||||
ipv6.pd = true;
|
||||
};
|
||||
};
|
||||
@@ -126,10 +177,186 @@ let
|
||||
};
|
||||
expect = [ ];
|
||||
};
|
||||
vlansOnOneTrunk = {
|
||||
router.lans = {
|
||||
main = lan { };
|
||||
iot = lan2 {
|
||||
interface = "eth1";
|
||||
vlan = 20;
|
||||
zone = "iot";
|
||||
};
|
||||
cams = lan {
|
||||
interface = "eth1";
|
||||
vlan = 30;
|
||||
zone = "iot";
|
||||
ipv4.address = "192.168.30.1";
|
||||
dhcp.pool = {
|
||||
start = "192.168.30.100";
|
||||
end = "192.168.30.199";
|
||||
};
|
||||
};
|
||||
};
|
||||
expect = [ ];
|
||||
};
|
||||
sameVlanTwice = {
|
||||
router.lans = {
|
||||
a = lan { vlan = 20; };
|
||||
b = lan2 {
|
||||
interface = "eth1";
|
||||
vlan = 20;
|
||||
};
|
||||
};
|
||||
expect = [ "two LANs share the same device" ];
|
||||
};
|
||||
vlanOnWanParent = {
|
||||
router.lans.main = lan {
|
||||
interface = "eth0";
|
||||
vlan = 10;
|
||||
};
|
||||
expect = [ "WAN interface `eth0` is also used by a LAN" ];
|
||||
};
|
||||
deviceNameTooLong = {
|
||||
router.lans.main = lan {
|
||||
interface = "enp1s0f1np1";
|
||||
vlan = 4000;
|
||||
};
|
||||
expect = [ "longer than 15 characters" ];
|
||||
};
|
||||
ruleValid = {
|
||||
router = {
|
||||
lans = {
|
||||
main = lan { };
|
||||
iot = lan2 { zone = "iot"; };
|
||||
};
|
||||
firewall.rules = [
|
||||
{
|
||||
from = "iot";
|
||||
to = "main";
|
||||
protocol = "tcp";
|
||||
ports = [
|
||||
445
|
||||
8080
|
||||
];
|
||||
destination = "192.168.10.5";
|
||||
}
|
||||
{
|
||||
from = "main";
|
||||
to = "iot";
|
||||
}
|
||||
];
|
||||
};
|
||||
expect = [ ];
|
||||
};
|
||||
ruleUnknownZones = {
|
||||
router = {
|
||||
lans.main = lan { };
|
||||
firewall.rules = [
|
||||
{
|
||||
from = "nope";
|
||||
to = "main";
|
||||
}
|
||||
{
|
||||
from = "main";
|
||||
to = "ghost";
|
||||
}
|
||||
];
|
||||
};
|
||||
expect = [
|
||||
"unknown source zone `nope`"
|
||||
"unknown destination zone `ghost`"
|
||||
];
|
||||
};
|
||||
ruleSameZone = {
|
||||
router = {
|
||||
lans = {
|
||||
main = lan { zone = "t"; };
|
||||
other = lan2 { zone = "t"; };
|
||||
};
|
||||
firewall.rules = [
|
||||
{
|
||||
from = "t";
|
||||
to = "t";
|
||||
}
|
||||
];
|
||||
};
|
||||
expect = [ "source and destination are the same zone" ];
|
||||
};
|
||||
rulePortsWithoutProtocol = {
|
||||
router = {
|
||||
lans = {
|
||||
main = lan { };
|
||||
iot = lan2 { };
|
||||
};
|
||||
firewall.rules = [
|
||||
{
|
||||
from = "iot";
|
||||
to = "main";
|
||||
protocol = "icmp";
|
||||
ports = [ 22 ];
|
||||
}
|
||||
];
|
||||
};
|
||||
expect = [ "ports are only valid with protocol tcp or udp" ];
|
||||
};
|
||||
ruleDestinationInvalid = {
|
||||
router = {
|
||||
lans = {
|
||||
main = lan { };
|
||||
iot = lan2 { };
|
||||
};
|
||||
firewall.rules = [
|
||||
{
|
||||
from = "iot";
|
||||
to = "main";
|
||||
destination = "192.168.10.999";
|
||||
}
|
||||
];
|
||||
};
|
||||
expect = [ "not a valid IPv4 address or CIDR" ];
|
||||
};
|
||||
ruleDestinationOutsideZone = {
|
||||
router = {
|
||||
lans = {
|
||||
main = lan { };
|
||||
iot = lan2 { };
|
||||
};
|
||||
firewall.rules = [
|
||||
{
|
||||
from = "iot";
|
||||
to = "main";
|
||||
destination = "192.168.20.5";
|
||||
}
|
||||
];
|
||||
};
|
||||
expect = [ "is not inside any LAN of zone `main`" ];
|
||||
};
|
||||
ruleDestinationHostBits = {
|
||||
router = {
|
||||
lans = {
|
||||
main = lan { };
|
||||
iot = lan2 { };
|
||||
};
|
||||
firewall.rules = [
|
||||
{
|
||||
from = "iot";
|
||||
to = "main";
|
||||
destination = "192.168.10.5/24";
|
||||
}
|
||||
];
|
||||
};
|
||||
expect = [ "has host bits set" ];
|
||||
};
|
||||
nonOverlappingLans = {
|
||||
router.lans = {
|
||||
a = lan { };
|
||||
b = lan { interface = "eth2"; ipv4.address = "192.168.20.1"; dhcp.pool = { start = "192.168.20.100"; end = "192.168.20.199"; }; };
|
||||
b = lan {
|
||||
interface = "eth2";
|
||||
ipv4.address = "192.168.20.1";
|
||||
dhcp.pool = {
|
||||
start = "192.168.20.100";
|
||||
end = "192.168.20.199";
|
||||
};
|
||||
};
|
||||
};
|
||||
expect = [ ];
|
||||
};
|
||||
@@ -142,8 +369,9 @@ let
|
||||
got = failed c.router;
|
||||
missing = lib.filter (e: !lib.any (m: lib.hasInfix e m) got) c.expect;
|
||||
in
|
||||
lib.optional (missing != [ ] || lib.length got != lib.length c.expect)
|
||||
"${name}: expected ${builtins.toJSON c.expect}, got ${builtins.toJSON got}"
|
||||
lib.optional (
|
||||
missing != [ ] || lib.length got != lib.length c.expect
|
||||
) "${name}: expected ${builtins.toJSON c.expect}, got ${builtins.toJSON got}"
|
||||
) cases
|
||||
);
|
||||
|
||||
@@ -152,17 +380,30 @@ let
|
||||
fullEval =
|
||||
name:
|
||||
let
|
||||
r = builtins.tryEval (builtins.seq (evalRouter cases.${name}.router).system.build.toplevel.drvPath true);
|
||||
r = builtins.tryEval (
|
||||
builtins.seq (evalRouter cases.${name}.router).system.build.toplevel.drvPath true
|
||||
);
|
||||
in
|
||||
lib.optional (!r.success) "${name}: full system evaluation failed";
|
||||
fullEvalProblems = lib.concatMap fullEval [
|
||||
"valid"
|
||||
"pdOnOneLan"
|
||||
"pppoeValid"
|
||||
"vlansOnOneTrunk"
|
||||
"ruleValid"
|
||||
];
|
||||
|
||||
subnet = (evalRouter { lans.main = lan { ipv4.prefixLength = 20; ipv4.address = "172.16.37.1"; dhcp.enable = false; }; }).router.lans.main.ipv4.subnet;
|
||||
subnetProblem = lib.optional (subnet != "172.16.32.0/20") "derived subnet: expected 172.16.32.0/20, got ${subnet}";
|
||||
subnet =
|
||||
(evalRouter {
|
||||
lans.main = lan {
|
||||
ipv4.prefixLength = 20;
|
||||
ipv4.address = "172.16.37.1";
|
||||
dhcp.enable = false;
|
||||
};
|
||||
}).router.lans.main.ipv4.subnet;
|
||||
subnetProblem = lib.optional (
|
||||
subnet != "172.16.32.0/20"
|
||||
) "derived subnet: expected 172.16.32.0/20, got ${subnet}";
|
||||
in
|
||||
pkgs.runCommand "router-assertion-tests" { } (
|
||||
if problems ++ subnetProblem ++ fullEvalProblems == [ ] then
|
||||
|
||||
Reference in New Issue
Block a user