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:
+251
@@ -0,0 +1,251 @@
|
||||
# VLANs and zones: one trunk carrying an untagged LAN plus two VLANs, two of
|
||||
# the three LANs sharing a zone.
|
||||
#
|
||||
# zone trusted: main (untagged, internet)
|
||||
# zone iot: iot (VLAN 20) + cams (VLAN 30), no internet, no management
|
||||
#
|
||||
# rules: trusted -> iot (anything)
|
||||
# iot -> trusted, tcp 8080, only to 192.168.10.100
|
||||
#
|
||||
# upstream (ISP) -- vlan 1 -- router -- vlan 2 (trunk) -- main / iot / cams
|
||||
{ self, pkgs }:
|
||||
let
|
||||
inherit (pkgs) lib;
|
||||
common = import ./common.nix;
|
||||
|
||||
# A host on the trunk, either untagged (id = null) or on a VLAN.
|
||||
host =
|
||||
id:
|
||||
{ lib, ... }:
|
||||
let
|
||||
dev = if id == null then "eth1" else "eth1.${toString id}";
|
||||
in
|
||||
lib.mkMerge [
|
||||
{
|
||||
virtualisation.vlans = [ 2 ];
|
||||
networking.useNetworkd = true;
|
||||
networking.useDHCP = false;
|
||||
networking.firewall.enable = false;
|
||||
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ];
|
||||
systemd.network.networks."20-lan" = {
|
||||
matchConfig.Name = dev;
|
||||
networkConfig.DHCP = "ipv4";
|
||||
};
|
||||
}
|
||||
(lib.mkIf (id != null) {
|
||||
systemd.network.netdevs."10-vlan" = {
|
||||
netdevConfig = {
|
||||
Kind = "vlan";
|
||||
Name = dev;
|
||||
};
|
||||
vlanConfig.Id = id;
|
||||
};
|
||||
systemd.network.networks."10-trunk" = {
|
||||
matchConfig.Name = "eth1";
|
||||
networkConfig = {
|
||||
VLAN = [ dev ];
|
||||
LinkLocalAddressing = "no";
|
||||
};
|
||||
};
|
||||
})
|
||||
];
|
||||
in
|
||||
pkgs.testers.runNixOSTest {
|
||||
name = "router-vlans";
|
||||
|
||||
defaults.imports = [ common.tools ];
|
||||
interactive.defaults = common.interactiveDefaults;
|
||||
|
||||
nodes = {
|
||||
# The ISP: one DHCP lease for the router, DNS, and a page echoing the
|
||||
# address it sees (to prove NAT).
|
||||
upstream =
|
||||
{ lib, ... }:
|
||||
{
|
||||
virtualisation.vlans = [ 1 ];
|
||||
networking.useNetworkd = true;
|
||||
networking.useDHCP = false;
|
||||
networking.firewall.enable = false;
|
||||
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ];
|
||||
systemd.network.networks."10-wan" = {
|
||||
matchConfig.Name = "eth1";
|
||||
address = [ "203.0.113.1/24" ];
|
||||
};
|
||||
|
||||
services.dnsmasq = {
|
||||
enable = true;
|
||||
settings = {
|
||||
interface = "eth1";
|
||||
bind-interfaces = true;
|
||||
dhcp-range = "203.0.113.100,203.0.113.100,255.255.255.0,1h";
|
||||
dhcp-option = [ "option:router,203.0.113.1" ];
|
||||
address = "/example.test/203.0.113.1";
|
||||
};
|
||||
};
|
||||
services.nginx = {
|
||||
enable = true;
|
||||
virtualHosts."example.test".locations."/".return = ''200 "$remote_addr"'';
|
||||
};
|
||||
};
|
||||
|
||||
router =
|
||||
{ lib, ... }:
|
||||
{
|
||||
imports = [ self.nixosModules.router ];
|
||||
virtualisation.vlans = [
|
||||
1
|
||||
2
|
||||
];
|
||||
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ];
|
||||
networking.interfaces.eth2.ipv4.addresses = lib.mkForce [ ];
|
||||
|
||||
router = {
|
||||
enable = true;
|
||||
wan.interface = "eth1";
|
||||
lans = {
|
||||
main = {
|
||||
interface = "eth2";
|
||||
zone = "trusted";
|
||||
internet = true;
|
||||
ipv4.address = "192.168.10.1";
|
||||
dhcp.pool = {
|
||||
start = "192.168.10.100";
|
||||
end = "192.168.10.199";
|
||||
};
|
||||
};
|
||||
iot = {
|
||||
interface = "eth2";
|
||||
vlan = 20;
|
||||
zone = "iot";
|
||||
allowManagement = false;
|
||||
ipv4.address = "192.168.20.1";
|
||||
dhcp.pool = {
|
||||
start = "192.168.20.100";
|
||||
end = "192.168.20.199";
|
||||
};
|
||||
};
|
||||
cams = {
|
||||
interface = "eth2";
|
||||
vlan = 30;
|
||||
zone = "iot";
|
||||
allowManagement = false;
|
||||
ipv4.address = "192.168.30.1";
|
||||
dhcp.pool = {
|
||||
start = "192.168.30.100";
|
||||
end = "192.168.30.199";
|
||||
};
|
||||
};
|
||||
};
|
||||
firewall.rules = [
|
||||
{
|
||||
from = "trusted";
|
||||
to = "iot";
|
||||
}
|
||||
{
|
||||
from = "iot";
|
||||
to = "trusted";
|
||||
protocol = "tcp";
|
||||
ports = [ 8080 ];
|
||||
destination = "192.168.10.100";
|
||||
}
|
||||
];
|
||||
dns.forwarders = [ "203.0.113.1" ];
|
||||
};
|
||||
|
||||
# See basic.nix: relax Unbound for the made-up test domain only.
|
||||
services.unbound.settings.server = {
|
||||
local-zone = [ "test. transparent" ];
|
||||
domain-insecure = [ "example.test" ];
|
||||
};
|
||||
};
|
||||
|
||||
# Untagged host in the trusted zone; also owns a second address, so the
|
||||
# destination restriction in the iot -> trusted rule can be told apart
|
||||
# from a zone-wide opening.
|
||||
main =
|
||||
{ lib, ... }:
|
||||
{
|
||||
imports = [ (host null) ];
|
||||
systemd.network.networks."20-lan".address = [ "192.168.10.150/24" ];
|
||||
services.nginx = {
|
||||
enable = true;
|
||||
virtualHosts."ports".listen = [
|
||||
{
|
||||
addr = "0.0.0.0";
|
||||
port = 8080;
|
||||
}
|
||||
{
|
||||
addr = "0.0.0.0";
|
||||
port = 9090;
|
||||
}
|
||||
];
|
||||
virtualHosts."ports".locations."/".return = ''200 "ok"'';
|
||||
};
|
||||
};
|
||||
|
||||
iot = host 20;
|
||||
cams = host 30;
|
||||
};
|
||||
|
||||
testScript = ''
|
||||
start_all()
|
||||
|
||||
upstream.wait_for_unit("dnsmasq.service")
|
||||
upstream.wait_for_unit("nginx.service")
|
||||
main.wait_for_unit("nginx.service")
|
||||
|
||||
with subtest("router is up with VLAN devices on the trunk"):
|
||||
router.wait_for_unit("network-online.target")
|
||||
router.wait_until_succeeds("ip -4 addr show dev eth1 | grep -q 203.0.113.100")
|
||||
router.wait_for_unit("nftables.service")
|
||||
router.wait_for_unit("kea-dhcp4-server.service")
|
||||
router.wait_for_unit("unbound.service")
|
||||
for vid in (20, 30):
|
||||
out = router.succeed(f"ip -d link show eth2.{vid}")
|
||||
assert f"vlan protocol 802.1Q id {vid}" in out, out
|
||||
router.succeed("ip -4 addr show dev eth2 | grep -q 192.168.10.1")
|
||||
router.succeed("ip -4 addr show dev eth2.20 | grep -q 192.168.20.1")
|
||||
router.succeed("ip -4 addr show dev eth2.30 | grep -q 192.168.30.1")
|
||||
|
||||
with subtest("each host gets an address and gateway from its own VLAN"):
|
||||
main.wait_until_succeeds("ip -4 addr show dev eth1 | grep -q 192.168.10.100")
|
||||
iot.wait_until_succeeds("ip -4 addr show dev eth1.20 | grep -q 192.168.20.100")
|
||||
cams.wait_until_succeeds("ip -4 addr show dev eth1.30 | grep -q 192.168.30.100")
|
||||
main.succeed("ip route show default | grep -q 'via 192.168.10.1'")
|
||||
iot.succeed("ip route show default | grep -q 'via 192.168.20.1'")
|
||||
cams.succeed("ip route show default | grep -q 'via 192.168.30.1'")
|
||||
|
||||
with subtest("internet is opt-in per LAN"):
|
||||
# Positive control: the ISP is reachable from the LAN that opted in.
|
||||
out = main.wait_until_succeeds("curl -sf --max-time 5 http://203.0.113.1/ -H 'Host: example.test'")
|
||||
assert out.strip() == "203.0.113.100", f"expected NAT to 203.0.113.100, got {out!r}"
|
||||
# The VLAN hosts have a default route, so this is the firewall at work.
|
||||
iot.fail("curl -sf --max-time 3 http://203.0.113.1/ -H 'Host: example.test'")
|
||||
cams.fail("curl -sf --max-time 3 http://203.0.113.1/ -H 'Host: example.test'")
|
||||
|
||||
with subtest("router services per VLAN: DNS yes, SSH only where allowed"):
|
||||
iot.succeed("dig +short +time=2 +tries=1 @192.168.20.1 example.test | grep -qx 203.0.113.1")
|
||||
main.succeed("nc -z -w 2 192.168.10.1 22")
|
||||
iot.fail("nc -z -w 2 192.168.20.1 22")
|
||||
cams.fail("nc -z -w 2 192.168.30.1 22")
|
||||
iot.fail("nc -z -w 2 192.168.10.1 22")
|
||||
|
||||
with subtest("hosts in one zone reach each other across VLANs"):
|
||||
iot.succeed("ping -c 1 -W 2 192.168.30.100")
|
||||
cams.succeed("ping -c 1 -W 2 192.168.20.100")
|
||||
|
||||
with subtest("trusted -> iot is allowed, but not the other way round"):
|
||||
main.succeed("ping -c 1 -W 2 192.168.20.100")
|
||||
main.succeed("ping -c 1 -W 2 192.168.30.100")
|
||||
iot.fail("ping -c 1 -W 2 192.168.10.100")
|
||||
|
||||
with subtest("iot -> trusted is limited to tcp 8080 on one host"):
|
||||
# Positive controls: the listeners work from inside the trusted zone.
|
||||
main.succeed("nc -z -w 2 192.168.10.100 9090")
|
||||
main.succeed("nc -z -w 2 192.168.10.150 8080")
|
||||
for host in (iot, cams):
|
||||
host.succeed("nc -z -w 2 192.168.10.100 8080")
|
||||
host.fail("nc -z -w 2 192.168.10.100 9090")
|
||||
host.fail("nc -z -w 2 192.168.10.150 8080")
|
||||
'';
|
||||
}
|
||||
Reference in New Issue
Block a user