Files
nilsandClaude Sonnet 5.5 6015ee2e6d 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>
2026-10-27 17:54:52 +01:00

252 lines
8.5 KiB
Nix

# 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")
'';
}