Add port forwards and WAN-originated firewall rules

Extend router.firewall.rules with a reserved `wan` source zone: DNAT
(dnat, toPort), port ranges, source allow-lists, per-source rate
limits, logging and optional hairpin NAT. IPv6 is opened only by
explicit pinholes. Build-time assertions require WAN rules to name a
destination, protocol and ports so a typo cannot expose a whole zone.

Covered by the basic/pppoe VM tests and the assertion tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 20:48:40 +01:00
co-authored by Claude Sonnet 5.5
parent 30ab1a0ed6
commit 3f8f2f4199
6 changed files with 676 additions and 36 deletions
+217
View File
@@ -506,6 +506,222 @@ let
};
expect = [ "is not inside any network of zone `s2s`" ];
};
wanValid = {
router = {
lans.main = lan { internet = true; };
firewall.rules = [
{
from = "wan";
to = "main";
destination = "192.168.10.5";
protocol = "tcp";
ports = [ 8080 ];
dnat = true;
toPort = 8081;
sources = [ "198.51.100.0/24" ];
rateLimit = "3/minute";
log = true;
hairpin = true;
}
{
from = "wan";
to = "main";
destination = "2001:db8:1::5";
protocol = "tcp";
ports = [ "9000-9002" ];
}
];
};
expect = [ ];
};
wanNoDestination = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "wan";
to = "main";
destination = null;
protocol = "tcp";
ports = [ 8080 ];
}
];
};
expect = [ "must name a destination" ];
};
wanAnyProtocol = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "wan";
to = "main";
destination = "192.168.10.5";
protocol = "any";
ports = [ ];
}
];
};
expect = [ "must name a protocol" ];
};
wanNoPorts = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "wan";
to = "main";
destination = "192.168.10.5";
protocol = "tcp";
ports = [ ];
}
];
};
expect = [ "must list the ports" ];
};
toWan = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "main";
to = "wan";
}
];
};
expect = [ "`wan` cannot be a destination" ];
};
dnatNeedsHost = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "wan";
to = "main";
destination = "192.168.10.0/24";
protocol = "tcp";
ports = [ 8080 ];
dnat = true;
}
];
};
expect = [ "dnat needs a single IPv4 host" ];
};
dnatV6Sources = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "wan";
to = "main";
destination = "192.168.10.5";
protocol = "tcp";
ports = [ 8080 ];
dnat = true;
sources = [ "2001:db8::/32" ];
}
];
};
expect = [ "its sources must be IPv4" ];
};
toPortWithoutDnat = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "wan";
to = "main";
destination = "192.168.10.5";
protocol = "tcp";
ports = [ 8080 ];
toPort = 8081;
}
];
};
expect = [ "toPort needs dnat" ];
};
hairpinNoDnat = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "wan";
to = "main";
destination = "192.168.10.5";
protocol = "tcp";
ports = [ 8080 ];
hairpin = true;
}
];
};
expect = [
"hairpin only applies to dnat"
"hairpin needs at least one"
];
};
hairpinNoInternet = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "wan";
to = "main";
destination = "192.168.10.5";
protocol = "tcp";
ports = [ 8080 ];
dnat = true;
hairpin = true;
}
];
};
expect = [ "hairpin needs at least one" ];
};
badPortRange = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "wan";
to = "main";
destination = "192.168.10.5";
protocol = "tcp";
ports = [ "9002-9000" ];
}
];
};
expect = [ "out of range" ];
};
badSource = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "wan";
to = "main";
destination = "192.168.10.5";
protocol = "tcp";
ports = [ 8080 ];
sources = [ "nonsense" ];
}
];
};
expect = [ "every source must be" ];
};
badV6Destination = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "wan";
to = "main";
destination = "2001:db8::zz";
protocol = "tcp";
ports = [ 8080 ];
}
];
};
expect = [ "not a valid IPv6" ];
};
nonOverlappingLans = {
router.lans = {
a = lan { };
@@ -552,6 +768,7 @@ let
"vlansOnOneTrunk"
"ruleValid"
"wgValid"
"wanValid"
];
subnet =
+165 -11
View File
@@ -21,6 +21,9 @@ let
ispV4 = if pppoe then "198.51.100.1" else "203.0.113.1";
routerV4 = if pppoe then "198.51.100.100" else "203.0.113.100";
pppoeVlan = 7;
clientV4 = "192.168.10.100";
clientV6 = "2001:db8:2::1234";
peerV4 = "192.168.10.51";
inherit (pkgs) lib;
# Indent every line but the first (the interpolation site supplies that one).
@@ -270,6 +273,51 @@ pkgs.testers.runNixOSTest {
};
};
dns.forwarders = [ ispV4 ];
firewall.rules =
let
fwd =
r:
{
from = "wan";
to = "main";
destination = clientV4;
protocol = "tcp";
dnat = true;
}
// r;
in
[
(fwd {
ports = [ 8080 ];
hairpin = true;
log = true;
})
(fwd {
ports = [ 8083 ];
toPort = 8084;
})
(fwd { ports = [ "9000-9002" ]; })
(fwd {
ports = [ 8085 ];
sources = [ "${ispV4}/32" ];
})
(fwd {
ports = [ 8086 ];
sources = [ "192.0.2.0/24" ];
})
(fwd {
ports = [ 8087 ];
rateLimit = "3/minute";
log = true;
})
{
from = "wan";
to = "main";
destination = clientV6;
protocol = "tcp";
ports = [ 8081 ];
}
];
};
# Unbound answers `.test` locally (RFC 6761) and would validate DNSSEC
@@ -281,17 +329,72 @@ pkgs.testers.runNixOSTest {
};
};
client = { lib, ... }: {
virtualisation.vlans = [ 2 ];
networking.useNetworkd = true;
networking.useDHCP = false;
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ];
systemd.network.networks."10-lan" = {
matchConfig.Name = "eth1";
networkConfig.DHCP = "ipv4";
client =
{ lib, ... }:
{
virtualisation.vlans = [ 2 ];
networking.useNetworkd = true;
networking.useDHCP = false;
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ];
systemd.network.networks."10-lan" = {
matchConfig.Name = "eth1";
networkConfig.DHCP = "ipv4";
# Fixed address inside the delegated /64: the IPv6 pinhole target.
address = [ "${clientV6}/64" ];
};
networking.firewall.enable = false;
# Every listener echoes the peer address it sees and its own port.
services.nginx = {
enable = true;
virtualHosts.ports = {
listen =
map
(port: {
addr = "0.0.0.0";
inherit port;
})
[
8080
8084
9000
9001
9002
8085
8086
8087
]
++
map
(port: {
addr = "[::]";
inherit port;
})
[
8081
8082
8080
];
locations."/".return = ''200 "$remote_addr $server_port"'';
};
};
};
# A second LAN host, outside the DHCP pool.
peer =
{ lib, ... }:
{
virtualisation.vlans = [ 2 ];
networking.useNetworkd = true;
networking.useDHCP = false;
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ];
systemd.network.networks."10-lan" = {
matchConfig.Name = "eth1";
address = [ "${peerV4}/24" ];
gateway = [ "192.168.10.1" ];
};
networking.firewall.enable = false;
};
networking.firewall.enable = false;
};
};
testScript = ''
@@ -340,7 +443,7 @@ pkgs.testers.runNixOSTest {
with subtest("client autoconfigures a global address from the delegated prefix"):
client.wait_until_succeeds("ip -6 addr show dev eth1 scope global | grep -q 2001:db8:2:")
client.wait_until_succeeds("ip -6 route show default | grep -q 'dev eth1'")
client_v6 = client.succeed("ip -6 -o addr show dev eth1 scope global | awk '{print $4}' | cut -d/ -f1 | head -1").strip()
client_v6 = client.succeed("ip -6 -o addr show dev eth1 scope global | awk '{print $4}' | cut -d/ -f1 | grep -v ::1234 | head -1").strip()
with subtest("IPv6 is routed without NAT"):
client.wait_until_succeeds("ping -6 -c 1 -W 2 2001:db8:1::1")
@@ -358,5 +461,56 @@ pkgs.testers.runNixOSTest {
with subtest("LAN can reach SSH and ping on the router"):
client.succeed("nc -z -w 2 192.168.10.1 22")
client.succeed("ping -c 1 -W 2 192.168.10.1")
with subtest("port forward keeps the source address and honours toPort"):
for _ in range(3):
# Positive control: the listener answers on the LAN.
peer.wait_until_succeeds("curl -sf --max-time 5 http://${clientV4}:8080/")
out = upstream.succeed("curl -sf --max-time 5 http://${routerV4}:8080/")
assert out.strip() == "${ispV4} 8080", out
out = upstream.succeed("curl -sf --max-time 5 http://${routerV4}:8083/")
assert out.strip() == "${ispV4} 8084", out
# The translated port is not exposed directly.
upstream.fail("curl -sf --max-time 3 http://${routerV4}:8084/")
with subtest("port ranges open exactly their ports"):
for port in (9000, 9001, 9002):
out = upstream.succeed(f"curl -sf --max-time 5 http://${routerV4}:{port}/")
assert out.strip() == f"${ispV4} {port}", out
upstream.fail("curl -sf --max-time 3 http://${routerV4}:9003/")
with subtest("source allow-list admits matching clients only"):
out = upstream.succeed("curl -sf --max-time 5 http://${routerV4}:8085/")
assert out.strip() == "${ispV4} 8085", out
upstream.fail("curl -sf --max-time 3 http://${routerV4}:8086/")
with subtest("per-source rate limit drops excess new connections"):
ok = 0
for _ in range(12):
status, _out = upstream.execute("curl -sf --max-time 2 http://${routerV4}:8087/")
ok += status == 0
assert 0 < ok < 12, f"expected some but not all connections to pass, got {ok}/12"
with subtest("IPv6 reaches only the explicit pinholes"):
out = upstream.succeed("curl -sf -g --max-time 5 'http://[${clientV6}]:8081/'")
assert out.strip() == "2001:db8:1::1 8081", out
# The listener exists, but there is no pinhole for it.
client.succeed("nc -6 -z -w 2 ${clientV6} 8082")
upstream.fail("curl -sf -g --max-time 3 'http://[${clientV6}]:8082/'")
# Port 8080 is forwarded for IPv4 only.
client.succeed("nc -6 -z -w 2 ${clientV6} 8080")
upstream.fail("curl -sf -g --max-time 3 'http://[${clientV6}]:8080/'")
with subtest("hairpin NAT works for forwards that enable it"):
out = peer.succeed("curl -sf --max-time 5 http://${routerV4}:8080/")
# Masqueraded: the server sees the router, not the peer.
assert out.strip() == "192.168.10.1 8080", out
# No hairpin on the range forward: the router does not translate it.
peer.fail("curl -sf --max-time 3 http://${routerV4}:9001/")
with subtest("logging rules leave a trace in the kernel log"):
klog = router.succeed("journalctl -k --no-pager | grep 'router fwd#'")
assert "accepted: " in klog, klog
assert "rate-limited: " in klog, klog
'';
}