Web UI: dark/light themes (appliance layout, sidebar, switch checkboxes), theme preference on the Advanced page, Changes page with the pending diff. Interface names: router.interfaces.<name>.mac, lans.<l>.deviceName and pppoe.vlanName; a rename is staged for the next reboot by router-apply. Remote access: an MCP server (POST /mcp) and an OpenAPI REST API (/api/v1, /api/openapi.json) with the same tokens, scopes, limits and audit log; tools to read status, settings, leases, logs (get_logs), and to edit and check the saved settings (never applied). Tokens on the API access page. Firewall: a reserved `local` zone (the router itself) as the destination of rules, so the web UI or SSH can be allowed from the WAN. PLAN.md lists what is still left. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
338 lines
12 KiB
Nix
338 lines
12 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, its device is called dmz), no internet,
|
|
# no management
|
|
#
|
|
# rules: trusted -> iot (anything)
|
|
# iot -> trusted, tcp 8080, only to 192.168.10.100
|
|
# iot -> trusted, tcp 9090, only from source ports 40000-40010
|
|
# "lab" (no interface yet) -> trusted: matches nothing
|
|
#
|
|
# nat: trusted -> router:7070 is forwarded to iot:8081 (source kept)
|
|
# trusted -> iot tcp 8082 is masqueraded (iot sees the router)
|
|
# trusted -> iot tcp 8083 is source-translated to 192.168.20.1
|
|
#
|
|
# 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;
|
|
deviceName = "dmz"; # a VLAN device with a name of its own
|
|
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";
|
|
}
|
|
{
|
|
from = "iot";
|
|
to = "trusted";
|
|
protocol = "tcp";
|
|
ports = [ 9090 ];
|
|
sourcePorts = [ "40000-40010" ];
|
|
destination = "192.168.10.100";
|
|
}
|
|
{
|
|
from = "lab";
|
|
to = "trusted";
|
|
}
|
|
];
|
|
firewall.zones = [ "lab" ];
|
|
firewall.nat = [
|
|
{
|
|
type = "dnat";
|
|
from = "trusted";
|
|
to = "iot";
|
|
protocol = "tcp";
|
|
ports = [ 7070 ];
|
|
toAddress = "192.168.20.100";
|
|
toPort = 8081;
|
|
}
|
|
{
|
|
type = "masquerade";
|
|
from = "trusted";
|
|
to = "iot";
|
|
protocol = "tcp";
|
|
ports = [ 8082 ];
|
|
}
|
|
{
|
|
type = "snat";
|
|
from = "trusted";
|
|
to = "iot";
|
|
protocol = "tcp";
|
|
ports = [ 8083 ];
|
|
toAddress = "192.168.20.1";
|
|
}
|
|
];
|
|
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"'';
|
|
};
|
|
};
|
|
|
|
# Echoes the address it sees, to tell translated connections from plain ones.
|
|
iot =
|
|
{ ... }:
|
|
{
|
|
imports = [ (host 20) ];
|
|
services.nginx = {
|
|
enable = true;
|
|
virtualHosts."ports" = {
|
|
listen = map (port: {
|
|
addr = "0.0.0.0";
|
|
inherit port;
|
|
}) [ 8081 8082 8083 ];
|
|
locations."/".return = ''200 "$remote_addr"'';
|
|
};
|
|
};
|
|
};
|
|
cams = host 30;
|
|
};
|
|
|
|
testScript = ''
|
|
start_all()
|
|
|
|
upstream.wait_for_unit("dnsmasq.service")
|
|
upstream.wait_for_unit("nginx.service")
|
|
main.wait_for_unit("nginx.service")
|
|
iot.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 dev, vid in (("eth2.20", 20), ("dmz", 30)):
|
|
out = router.succeed(f"ip -d link show {dev}")
|
|
assert f"vlan protocol 802.1Q id {vid}" in out, out
|
|
router.fail("ip link show eth2.30")
|
|
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 dmz | 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")
|
|
|
|
with subtest("a rule can match the source port too"):
|
|
for host in (iot, cams):
|
|
host.succeed("nc -z -w 2 -p 40005 192.168.10.100 9090")
|
|
host.fail("nc -z -w 2 -p 50000 192.168.10.100 9090")
|
|
host.fail("nc -z -w 2 -p 40005 192.168.10.150 9090")
|
|
|
|
with subtest("a port forward from a LAN keeps the source address"):
|
|
out = main.succeed("curl -sf --max-time 5 http://192.168.10.1:7070/").strip()
|
|
assert out == "192.168.10.100", out
|
|
# Not addressed to the router: not forwarded.
|
|
main.fail("curl -sf --max-time 3 http://192.168.10.1:7071/")
|
|
|
|
with subtest("masquerade and source NAT rewrite the source between zones"):
|
|
assert main.succeed("curl -sf --max-time 5 http://192.168.20.100:8081/").strip() == "192.168.10.100"
|
|
assert main.succeed("curl -sf --max-time 5 http://192.168.20.100:8082/").strip() == "192.168.20.1"
|
|
assert main.succeed("curl -sf --max-time 5 http://192.168.20.100:8083/").strip() == "192.168.20.1"
|
|
router.succeed("nft list ruleset | grep -q 'snat to 192.168.20.1'")
|
|
|
|
with subtest("a zone without interfaces does not break the ruleset"):
|
|
router.succeed("nft list ruleset | grep -q 'sport 40000-40010'")
|
|
router.succeed("systemctl is-active nftables.service")
|
|
'';
|
|
}
|