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:
2026-10-30 15:54:29 +01:00
co-authored by Claude Sonnet 5.5
parent 3e9c2e4a7a
commit a58600823f
19 changed files with 1018 additions and 505 deletions
+103 -21
View File
@@ -393,6 +393,100 @@ in
'';
};
firewall.nat = mkOption {
default = [ ];
description = ''
Address translation, separate from the allow rules. `dnat` sends
traffic that arrives in zone `from` (`wan` for the uplink) and matches
the rule to `toAddress` (a host in zone `to`); `snat` and `masquerade`
rewrite the source of traffic leaving through zone `to` (`wan` for the
uplink) to `toAddress`, or to the address of the outgoing interface.
LANs with `internet = true` and tunnels with `masquerade` are already
masqueraded; these rules come first. A translation does not allow
anything: a forward needs a matching entry in `firewall.rules`.
'';
type = types.listOf (
types.submodule {
options = {
type = mkOption {
type = types.enum [
"dnat"
"snat"
"masquerade"
];
description = "Kind of translation.";
};
from = mkOption {
type = types.nullOr types.str;
default = null;
description = "Zone the traffic comes from, or `wan`. For `snat` and `masquerade` null means any.";
};
to = mkOption {
type = types.str;
description = ''
`dnat`: the zone of the host the traffic is sent to. `snat` and
`masquerade`: the zone (or `wan`) the traffic leaves through.
'';
};
protocol = mkOption {
type = types.enum [
"any"
"tcp"
"udp"
"icmp"
];
default = "any";
};
ports = mkOption {
type = types.listOf (types.either types.port (types.strMatching "[0-9]+-[0-9]+"));
default = [ ];
description = "Destination ports or ranges to match (tcp/udp only); empty means all.";
};
sourcePorts = mkOption {
type = types.listOf (types.either types.port (types.strMatching "[0-9]+-[0-9]+"));
default = [ ];
description = "Source ports or ranges to match (tcp/udp only).";
};
sources = mkOption {
type = types.listOf types.str;
default = [ ];
description = "Only match traffic from these IPv4 addresses or CIDRs; empty means any.";
};
destination = mkOption {
type = types.nullOr types.str;
default = null;
example = "203.0.113.7";
description = ''
Only match traffic addressed to this IPv4 address or CIDR.
For `dnat` null means the router's own addresses (from `wan`:
anything that arrives on the uplink).
'';
};
toAddress = mkOption {
type = types.nullOr types.str;
default = null;
example = "192.168.10.5";
description = "`dnat`: the host to send the traffic to. `snat`: the source address to use. Not for `masquerade`.";
};
toPort = mkOption {
type = types.nullOr types.port;
default = null;
description = "`dnat`: the port on the host when it differs (needs exactly one `ports` entry).";
};
hairpin = mkOption {
type = types.bool;
default = false;
description = ''
`dnat` from `wan` only: let LANs with `internet = true` reach
the forward through the router's public address (NAT
loopback, the source is masqueraded) and allow it to the host.
'';
};
};
}
);
};
firewall.rules = mkOption {
default = [ ];
description = ''
@@ -402,7 +496,8 @@ in
the reserved zone `wan` (traffic arriving on the uplinks), which
publishes services to the outside: such rules must name a
`destination` and protocol (and ports for tcp/udp), so a typo cannot
expose a whole zone.
expose a whole zone. Address translation is separate (`firewall.nat`);
a port forward needs an allow rule as well, usually with `natOnly`.
'';
type = types.listOf (
types.submodule {
@@ -433,15 +528,15 @@ in
];
description = ''
Destination ports or ranges (tcp/udp only); empty means all
ports. With `dnat` these are the ports on the router's
public address.
ports. After a port forward these are the translated ports
(its `toPort`, if it has one).
'';
};
sourcePorts = mkOption {
type = types.listOf (types.either types.port (types.strMatching "[0-9]+-[0-9]+"));
default = [ ];
example = [ 53 ];
description = "Source ports or ranges (tcp/udp only); empty means any. Not for port forwards.";
description = "Source ports or ranges (tcp/udp only); empty means any.";
};
destination = mkOption {
type = types.nullOr types.str;
@@ -460,26 +555,13 @@ in
example = [ "198.51.100.0/24" ];
description = "Only match traffic from these IPv4/IPv6 addresses or CIDRs; empty means any source.";
};
dnat = mkOption {
natOnly = mkOption {
type = types.bool;
default = false;
description = ''
Port forward: traffic from `wan` to `ports` on the router's
public IPv4 address is redirected to `destination` (one IPv4
host). The source address is preserved.
'';
};
toPort = mkOption {
type = types.nullOr types.port;
default = null;
description = "Port on `destination` when it differs from the public port (single-port forwards only).";
};
hairpin = mkOption {
type = types.bool;
default = false;
description = ''
Let LANs with `internet = true` reach this forward through the
router's public address (NAT loopback, source is masqueraded).
Only match connections that a `firewall.nat` port forward
translated (the allow rule that goes with a forward), not
traffic addressed directly to the destination.
'';
};
rateLimit = mkOption {