Add a generic NAT table: port forward, source NAT, masquerade
router.firewall.nat replaces the dnat/toPort/hairpin fields of the filter rules. Filter rules gain natOnly, and may use wan with log and rate limit in the web UI. The Firewall page edits the NAT table and can add the matching allow rule. Settings schema 1 (migrations/1.jq) converts old forwards. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
This commit is contained in:
@@ -16,6 +16,8 @@ let
|
||||
|
||||
settings = pkgs.writeText "settings.json" (
|
||||
builtins.toJSON {
|
||||
# The schema of this source: the number of migrations.
|
||||
version = lib.count (lib.hasSuffix ".jq") (builtins.attrNames (builtins.readDir ../migrations));
|
||||
system = {
|
||||
hostName = "router";
|
||||
stateVersion = "26.11";
|
||||
|
||||
+170
-33
@@ -609,13 +609,11 @@ let
|
||||
to = "main";
|
||||
destination = "192.168.10.5";
|
||||
protocol = "tcp";
|
||||
ports = [ 8080 ];
|
||||
dnat = true;
|
||||
toPort = 8081;
|
||||
ports = [ 8081 ];
|
||||
natOnly = true;
|
||||
sources = [ "198.51.100.0/24" ];
|
||||
rateLimit = "3/minute";
|
||||
log = true;
|
||||
hairpin = true;
|
||||
}
|
||||
{
|
||||
from = "wan";
|
||||
@@ -685,91 +683,230 @@ let
|
||||
};
|
||||
expect = [ "`wan` cannot be a destination" ];
|
||||
};
|
||||
dnatNeedsHost = {
|
||||
natDnatNeedsHost = {
|
||||
router = {
|
||||
lans.main = lan { };
|
||||
firewall.rules = [
|
||||
firewall.nat = [
|
||||
{
|
||||
type = "dnat";
|
||||
from = "wan";
|
||||
to = "main";
|
||||
destination = "192.168.10.0/24";
|
||||
protocol = "tcp";
|
||||
ports = [ 8080 ];
|
||||
dnat = true;
|
||||
}
|
||||
];
|
||||
};
|
||||
expect = [ "dnat needs a single IPv4 host" ];
|
||||
expect = [ "dnat and snat need a toAddress" ];
|
||||
};
|
||||
dnatV6Sources = {
|
||||
natDnatHostOutsideZone = {
|
||||
router = {
|
||||
lans.main = lan { };
|
||||
firewall.rules = [
|
||||
firewall.nat = [
|
||||
{
|
||||
type = "dnat";
|
||||
from = "wan";
|
||||
to = "main";
|
||||
destination = "192.168.10.5";
|
||||
protocol = "tcp";
|
||||
ports = [ 8080 ];
|
||||
dnat = true;
|
||||
toAddress = "10.9.9.9";
|
||||
}
|
||||
];
|
||||
};
|
||||
expect = [ "is not inside any network of zone `main`" ];
|
||||
};
|
||||
natV6Sources = {
|
||||
router = {
|
||||
lans.main = lan { };
|
||||
firewall.nat = [
|
||||
{
|
||||
type = "dnat";
|
||||
from = "wan";
|
||||
to = "main";
|
||||
protocol = "tcp";
|
||||
ports = [ 8080 ];
|
||||
toAddress = "192.168.10.5";
|
||||
sources = [ "2001:db8::/32" ];
|
||||
}
|
||||
];
|
||||
};
|
||||
expect = [ "its sources must be IPv4" ];
|
||||
expect = [ "translation is IPv4 only" ];
|
||||
};
|
||||
toPortWithoutDnat = {
|
||||
natToPortNeedsOnePort = {
|
||||
router = {
|
||||
lans.main = lan { };
|
||||
firewall.rules = [
|
||||
firewall.nat = [
|
||||
{
|
||||
type = "dnat";
|
||||
from = "wan";
|
||||
to = "main";
|
||||
destination = "192.168.10.5";
|
||||
protocol = "tcp";
|
||||
ports = [ 8080 ];
|
||||
toPort = 8081;
|
||||
ports = [
|
||||
8080
|
||||
8081
|
||||
];
|
||||
toAddress = "192.168.10.5";
|
||||
toPort = 80;
|
||||
}
|
||||
];
|
||||
};
|
||||
expect = [ "toPort needs dnat" ];
|
||||
expect = [ "toPort needs exactly one" ];
|
||||
};
|
||||
hairpinNoDnat = {
|
||||
natToPortOnMasquerade = {
|
||||
router = {
|
||||
lans.main = lan { };
|
||||
firewall.rules = [
|
||||
lans = {
|
||||
main = lan { };
|
||||
iot = lan2 { };
|
||||
};
|
||||
firewall.nat = [
|
||||
{
|
||||
from = "wan";
|
||||
type = "masquerade";
|
||||
from = "iot";
|
||||
to = "main";
|
||||
toPort = 80;
|
||||
}
|
||||
];
|
||||
};
|
||||
expect = [ "toPort and hairpin only apply to port forwards" ];
|
||||
};
|
||||
natHairpinFromLan = {
|
||||
router = {
|
||||
lans = {
|
||||
main = lan { internet = true; };
|
||||
iot = lan2 { };
|
||||
};
|
||||
firewall.nat = [
|
||||
{
|
||||
type = "dnat";
|
||||
from = "iot";
|
||||
to = "main";
|
||||
destination = "192.168.10.5";
|
||||
protocol = "tcp";
|
||||
ports = [ 8080 ];
|
||||
toAddress = "192.168.10.5";
|
||||
hairpin = true;
|
||||
}
|
||||
];
|
||||
};
|
||||
expect = [
|
||||
"hairpin only applies to dnat"
|
||||
"hairpin needs at least one"
|
||||
];
|
||||
expect = [ "hairpin only applies to forwards from `wan`" ];
|
||||
};
|
||||
hairpinNoInternet = {
|
||||
natHairpinNoInternet = {
|
||||
router = {
|
||||
lans.main = lan { };
|
||||
firewall.rules = [
|
||||
firewall.nat = [
|
||||
{
|
||||
type = "dnat";
|
||||
from = "wan";
|
||||
to = "main";
|
||||
destination = "192.168.10.5";
|
||||
protocol = "tcp";
|
||||
ports = [ 8080 ];
|
||||
dnat = true;
|
||||
toAddress = "192.168.10.5";
|
||||
hairpin = true;
|
||||
}
|
||||
];
|
||||
};
|
||||
expect = [ "hairpin needs at least one" ];
|
||||
};
|
||||
natMasqueradeWithAddress = {
|
||||
router = {
|
||||
lans.main = lan { };
|
||||
firewall.nat = [
|
||||
{
|
||||
type = "masquerade";
|
||||
to = "wan";
|
||||
toAddress = "203.0.113.5";
|
||||
}
|
||||
];
|
||||
};
|
||||
expect = [ "dnat and snat need a toAddress; masquerade has none" ];
|
||||
};
|
||||
natUnknownZones = {
|
||||
router = {
|
||||
lans.main = lan { };
|
||||
firewall.nat = [
|
||||
{
|
||||
type = "snat";
|
||||
from = "ghost";
|
||||
to = "nowhere";
|
||||
toAddress = "203.0.113.5";
|
||||
}
|
||||
];
|
||||
};
|
||||
expect = [
|
||||
"unknown source zone `ghost`"
|
||||
"unknown zone `nowhere`"
|
||||
];
|
||||
};
|
||||
natDnatToWan = {
|
||||
router = {
|
||||
lans.main = lan { };
|
||||
firewall.nat = [
|
||||
{
|
||||
type = "dnat";
|
||||
from = "main";
|
||||
to = "wan";
|
||||
protocol = "tcp";
|
||||
ports = [ 80 ];
|
||||
toAddress = "192.168.10.5";
|
||||
}
|
||||
];
|
||||
};
|
||||
expect = [ "unknown zone `wan`" ];
|
||||
};
|
||||
natValid = {
|
||||
router = {
|
||||
lans = {
|
||||
main = lan { internet = true; };
|
||||
iot = lan2 { };
|
||||
};
|
||||
firewall = {
|
||||
rules = [
|
||||
{
|
||||
from = "wan";
|
||||
to = "main";
|
||||
destination = "192.168.10.5";
|
||||
protocol = "tcp";
|
||||
ports = [ 8081 ];
|
||||
natOnly = true;
|
||||
rateLimit = "3/minute";
|
||||
log = true;
|
||||
}
|
||||
];
|
||||
nat = [
|
||||
{
|
||||
type = "dnat";
|
||||
from = "wan";
|
||||
to = "main";
|
||||
protocol = "tcp";
|
||||
ports = [ 8080 ];
|
||||
sources = [ "198.51.100.0/24" ];
|
||||
toAddress = "192.168.10.5";
|
||||
toPort = 8081;
|
||||
hairpin = true;
|
||||
}
|
||||
{
|
||||
type = "dnat";
|
||||
from = "iot";
|
||||
to = "main";
|
||||
protocol = "udp";
|
||||
ports = [ "5000-5100" ];
|
||||
destination = "192.168.20.1";
|
||||
toAddress = "192.168.10.6";
|
||||
}
|
||||
{
|
||||
type = "masquerade";
|
||||
from = "iot";
|
||||
to = "main";
|
||||
}
|
||||
{
|
||||
type = "snat";
|
||||
to = "wan";
|
||||
sources = [ "192.168.10.0/24" ];
|
||||
toAddress = "203.0.113.5";
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
expect = [ ];
|
||||
};
|
||||
badPortRange = {
|
||||
router = {
|
||||
lans.main = lan { };
|
||||
|
||||
+72
-40
@@ -273,51 +273,83 @@ pkgs.testers.runNixOSTest {
|
||||
};
|
||||
};
|
||||
dns.forwarders = [ ispV4 ];
|
||||
firewall.rules =
|
||||
let
|
||||
fwd =
|
||||
r:
|
||||
# A port forward is a translation and the rule that lets it through.
|
||||
firewall = {
|
||||
nat =
|
||||
let
|
||||
fwd =
|
||||
r:
|
||||
{
|
||||
type = "dnat";
|
||||
from = "wan";
|
||||
to = "main";
|
||||
toAddress = clientV4;
|
||||
protocol = "tcp";
|
||||
}
|
||||
// r;
|
||||
in
|
||||
[
|
||||
(fwd {
|
||||
ports = [ 8080 ];
|
||||
hairpin = 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 ]; })
|
||||
];
|
||||
rules =
|
||||
let
|
||||
allow =
|
||||
r:
|
||||
{
|
||||
from = "wan";
|
||||
to = "main";
|
||||
destination = clientV4;
|
||||
protocol = "tcp";
|
||||
natOnly = true;
|
||||
}
|
||||
// r;
|
||||
in
|
||||
[
|
||||
(allow {
|
||||
ports = [ 8080 ];
|
||||
log = true;
|
||||
})
|
||||
(allow { ports = [ 8084 ]; })
|
||||
(allow { ports = [ "9000-9002" ]; })
|
||||
(allow {
|
||||
ports = [ 8085 ];
|
||||
sources = [ "${ispV4}/32" ];
|
||||
})
|
||||
(allow {
|
||||
ports = [ 8086 ];
|
||||
sources = [ "192.0.2.0/24" ];
|
||||
})
|
||||
(allow {
|
||||
ports = [ 8087 ];
|
||||
rateLimit = "3/minute";
|
||||
log = true;
|
||||
})
|
||||
{
|
||||
from = "wan";
|
||||
to = "main";
|
||||
destination = clientV4;
|
||||
destination = clientV6;
|
||||
protocol = "tcp";
|
||||
dnat = true;
|
||||
ports = [ 8081 ];
|
||||
}
|
||||
// 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
|
||||
|
||||
+22
-10
@@ -200,16 +200,28 @@ pkgs.testers.runNixOSTest {
|
||||
};
|
||||
# Only the PPP device is an uplink, so this must work from the ISP
|
||||
# and must not fire for traffic arriving from the LAN.
|
||||
firewall.rules = [
|
||||
{
|
||||
from = "wan";
|
||||
to = "main";
|
||||
destination = "192.168.10.51";
|
||||
protocol = "tcp";
|
||||
ports = [ 8080 ];
|
||||
dnat = true;
|
||||
}
|
||||
];
|
||||
firewall = {
|
||||
nat = [
|
||||
{
|
||||
type = "dnat";
|
||||
from = "wan";
|
||||
to = "main";
|
||||
toAddress = "192.168.10.51";
|
||||
protocol = "tcp";
|
||||
ports = [ 8080 ];
|
||||
}
|
||||
];
|
||||
rules = [
|
||||
{
|
||||
from = "wan";
|
||||
to = "main";
|
||||
destination = "192.168.10.51";
|
||||
protocol = "tcp";
|
||||
ports = [ 8080 ];
|
||||
natOnly = true;
|
||||
}
|
||||
];
|
||||
};
|
||||
dns = {
|
||||
forwarders = [ ispV4 ];
|
||||
domain = "lan.test";
|
||||
|
||||
@@ -50,6 +50,8 @@ let
|
||||
name = "v1";
|
||||
version = "1.0.0";
|
||||
serial = 1000;
|
||||
# Settings version 0: the real migrations are not part of this test.
|
||||
extra = "rm $out/migrations/*.jq";
|
||||
};
|
||||
v2 = mkVersion {
|
||||
name = "v2";
|
||||
|
||||
+59
-1
@@ -9,6 +9,10 @@
|
||||
# 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
|
||||
@@ -164,6 +168,32 @@ pkgs.testers.runNixOSTest {
|
||||
}
|
||||
];
|
||||
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" ];
|
||||
};
|
||||
|
||||
@@ -198,7 +228,22 @@ pkgs.testers.runNixOSTest {
|
||||
};
|
||||
};
|
||||
|
||||
iot = host 20;
|
||||
# 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;
|
||||
};
|
||||
|
||||
@@ -208,6 +253,7 @@ pkgs.testers.runNixOSTest {
|
||||
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")
|
||||
@@ -269,6 +315,18 @@ pkgs.testers.runNixOSTest {
|
||||
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")
|
||||
|
||||
+3
-3
@@ -137,12 +137,12 @@ pkgs.testers.runNixOSTest {
|
||||
assert post("/firewall", {"csrf": csrf("/firewall"), **rule}) == "422"
|
||||
|
||||
with subtest("a port forward is saved and listed"):
|
||||
fwd = {"forwards": "1", "fwd.0.protocol": "tcp", "fwd.0.ports": "8080", "fwd.0.to": "main",
|
||||
"fwd.0.destination": "192.168.10.5", "fwd.0.nat": "on"}
|
||||
fwd = {"nats": "1", "nat.0.type": "dnat", "nat.0.from": "wan", "nat.0.protocol": "tcp", "nat.0.ports": "8080",
|
||||
"nat.0.to": "main", "nat.0.toaddress": "192.168.10.5", "nat.0.allow": "on"}
|
||||
assert post("/firewall", {"csrf": csrf("/firewall"), **fwd}) == "303"
|
||||
assert 'value="192.168.10.5"' in curl(f"{ui}/firewall")
|
||||
saved = router.succeed(f"cat {cfg}")
|
||||
assert '"dnat": true' in saved and '"from": "wan"' in saved, saved
|
||||
assert '"type": "dnat"' in saved and '"from": "wan"' in saved and '"natOnly": true' in saved, saved
|
||||
|
||||
with subtest("dynamic DNS is saved with its secret kept out of the settings"):
|
||||
dd = {"enabled": "on", "protocol": "cloudflare", "username": "token", "zone": "example.org",
|
||||
|
||||
Reference in New Issue
Block a user