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:
+165
-11
@@ -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
|
||||
'';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user