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
+78 -24
View File
@@ -201,9 +201,6 @@ let
&& lib.any (n: dest.prefixLength >= n.prefixLength && ipv4.inSubnet n dest.address) (
zoneV4Nets r.to
);
singleHost = dest != null && dest.prefixLength == 32;
plainPorts = lib.all builtins.isInt r.ports;
internetMember = lib.any (l: l.internet) lans || lib.any (w: w.internet) wgs;
in
[
{
@@ -227,8 +224,8 @@ let
message = "router: ${where}: ports are only valid with protocol tcp or udp.";
}
{
assertion = r.sourcePorts == [ ] || ((r.protocol == "tcp" || r.protocol == "udp") && !r.dnat);
message = "router: ${where}: sourcePorts are only valid with protocol tcp or udp, and not on a port forward.";
assertion = r.sourcePorts == [ ] || r.protocol == "tcp" || r.protocol == "udp";
message = "router: ${where}: sourcePorts are only valid with protocol tcp or udp.";
}
{
assertion = lib.all portOk (r.ports ++ r.sourcePorts);
@@ -284,39 +281,95 @@ let
message = "router: ${where}: destination `${r.destination}` is not a valid IPv6 address or CIDR.";
}
]
++ lib.optionals r.dnat [
;
natAssertions =
n:
let
where = "router.firewall.nat (${n.type} ${toString n.from} -> ${n.to})";
isDnat = n.type == "dnat";
isSnat = n.type == "snat";
v4Ok = a: (v4Cidr (if lib.hasInfix "/" a then a else "${a}/32"));
toAddr = if n.toAddress != null && validIp n.toAddress then ipv4.parseNet "${n.toAddress}/32" else null;
inZone =
toAddr != null
&& lib.any (z: toAddr.prefixLength >= z.prefixLength && ipv4.inSubnet z toAddr.address) (zoneV4Nets n.to);
internetMember = lib.any (l: l.internet) lans || lib.any (w: w.internet) wgs;
plainPorts = lib.all builtins.isInt n.ports;
in
[
{
assertion = fromWan;
message = "router: ${where}: dnat is only valid for rules from `wan`.";
assertion = n.from == null || n.from == "wan" || lib.elem n.from zoneNames;
message = "router: ${where}: unknown source zone `${toString n.from}` (zones: wan, ${lib.concatStringsSep ", " zoneNames}).";
}
{
assertion = singleHost;
message = "router: ${where}: dnat needs a single IPv4 host as destination.";
assertion = lib.elem n.to zoneNames || (!isDnat && n.to == "wan");
message = "router: ${where}: unknown zone `${n.to}` (zones: ${lib.concatStringsSep ", " zoneNames}${lib.optionalString (!isDnat) ", wan"}).";
}
{
assertion = r.protocol == "tcp" || r.protocol == "udp";
message = "router: ${where}: dnat needs protocol tcp or udp.";
assertion = !isDnat || n.from != null;
message = "router: ${where}: a port forward needs a source zone (`from`).";
}
{
assertion = lib.all (x: !isV6 x) r.sources;
message = "router: ${where}: dnat is IPv4-only, so its sources must be IPv4.";
assertion = (n.ports == [ ] && n.sourcePorts == [ ]) || n.protocol == "tcp" || n.protocol == "udp";
message = "router: ${where}: ports are only valid with protocol tcp or udp.";
}
{
assertion = lib.all portOk (n.ports ++ n.sourcePorts);
message = "router: ${where}: a port or port range is out of range (1-65535, ranges need low < high).";
}
{
assertion = lib.all v4Ok n.sources;
message = "router: ${where}: every source must be an IPv4 address or CIDR (translation is IPv4 only).";
}
{
assertion = n.destination == null || v4Ok n.destination;
message = "router: ${where}: destination `${toString n.destination}` is not an IPv4 address or CIDR.";
}
{
assertion = isDnat || (n.toPort == null && !n.hairpin);
message = "router: ${where}: toPort and hairpin only apply to port forwards (dnat).";
}
{
assertion = (isDnat || isSnat) == (n.toAddress != null);
message = "router: ${where}: dnat and snat need a toAddress; masquerade has none.";
}
{
assertion = n.toAddress == null || validIp n.toAddress;
message = "router: ${where}: toAddress `${toString n.toAddress}` is not a valid IPv4 address.";
}
]
++ lib.optionals (r.toPort != null) [
++ lib.optionals isDnat [
{
assertion = r.dnat && lib.length r.ports == 1 && plainPorts;
message = "router: ${where}: toPort needs dnat and exactly one (non-range) port.";
}
]
++ lib.optionals r.hairpin [
{
assertion = r.dnat;
message = "router: ${where}: hairpin only applies to dnat rules.";
assertion = n.protocol == "tcp" || n.protocol == "udp";
message = "router: ${where}: a port forward needs protocol tcp or udp.";
}
{
assertion = internetMember;
assertion = n.ports != [ ];
message = "router: ${where}: a port forward must list the ports it forwards.";
}
{
assertion = toAddr == null || !(lib.elem n.to zoneNames) || inZone;
message = "router: ${where}: toAddress `${toString n.toAddress}` is not inside any network of zone `${n.to}`.";
}
{
assertion = n.toPort == null || (lib.length n.ports == 1 && plainPorts);
message = "router: ${where}: toPort needs exactly one (non-range) port.";
}
{
assertion = !n.hairpin || n.from == "wan";
message = "router: ${where}: hairpin only applies to forwards from `wan`.";
}
{
assertion = !n.hairpin || internetMember;
message = "router: ${where}: hairpin needs at least one LAN or tunnel with internet = true.";
}
]
++ lib.optionals (!isDnat) [
{
assertion = n.from == null || n.from != n.to;
message = "router: ${where}: source and destination are the same zone.";
}
];
dnsCfg = cfg.dns;
@@ -508,6 +561,7 @@ in
++ lib.concatLists (lib.mapAttrsToList perLan lansByName)
++ dnsAssertions
++ lib.concatMap ruleAssertions cfg.firewall.rules
++ lib.concatMap natAssertions cfg.firewall.nat
++ [
{
assertion = !(lib.elem "wan" zoneNames);
+47 -20
View File
@@ -121,9 +121,7 @@ let
label = "${r.from}->${r.to}";
fromDevs = if r.from == "wan" then uplinks else zoneDevices r.from;
toDevs = zoneDevices r.to;
# After DNAT the packet carries the translated port.
fwdPorts = if r.dnat && r.toPort != null then [ r.toPort ] else r.ports;
base = "iifname ${set fromDevs} oifname ${set toDevs} ${daddrOf r}${l4 r.protocol fwdPorts r.sourcePorts}${lib.optionalString r.dnat "ct status dnat "}";
base = "iifname ${set fromDevs} oifname ${set toDevs} ${daddrOf r}${l4 r.protocol r.ports r.sourcePorts}${lib.optionalString r.natOnly "ct status dnat "}";
logPrefix = what: ''log prefix "router fwd#${id} ${label} ${what}: " '';
limitLines =
v:
@@ -147,32 +145,60 @@ let
v:
lib.optional r.log "${base}${v.src}ct state new ${logPrefix "accepted"}"
++ [ "${base}${v.src}accept # ${label}" ];
dnatTarget = "dnat to ${r.destination}${
lib.optionalString (r.toPort != null) ":${toString r.toPort}"
}";
in
{
# A zone nothing is assigned to yet has no devices; the rule waits for one.
forward = lib.optionals (fromDevs != [ ] && toDevs != [ ]) (
lib.concatMap (v: limitLines v ++ acceptLines v) (variantsOf r)
);
prerouting =
lib.optionals r.dnat (
map (v: "iifname ${set uplinks} ${v.src}${l4 r.protocol r.ports [ ]}${dnatTarget} # ${label}") (
variantsOf r
)
)
};
# Address translation (router.firewall.nat). Indexed like the rules.
natParts =
i: n:
let
label = "nat ${n.type} ${toString n.from}->${n.to}";
fromDevs =
if n.from == null then
[ ]
else if n.from == "wan" then
uplinks
else
zoneDevices n.from;
toDevs = if n.to == "wan" then uplinks else zoneDevices n.to;
iif = lib.optionalString (n.from != null) "iifname ${set fromDevs} ";
saddr = lib.optionalString (n.sources != [ ]) "ip saddr ${setOf n.sources} ";
daddr = lib.optionalString (n.destination != null) "ip daddr ${n.destination} ";
match = "${iif}${saddr}${daddr}${l4 n.protocol n.ports n.sourcePorts}";
target = "${n.toAddress}${lib.optionalString (n.toPort != null) ":${toString n.toPort}"}";
# Zones with nothing assigned yet have no devices; the rule waits for one.
live = (n.from == null || fromDevs != [ ]) && toDevs != [ ];
in
{
prerouting = lib.optionals (live && n.type == "dnat") (
[
# Without a destination, from the uplink anything that arrives is
# translated; from inside only what is addressed to the router.
"${match}${
lib.optionalString (n.destination == null && n.from != "wan") "fib daddr type local "
}dnat to ${target} # ${label}"
]
# NAT loopback: internal clients addressing the router's public address.
++
lib.optional (r.dnat && r.hairpin)
"iifname ${set internetIfaces} fib daddr type local ip daddr != ${setOf internalAddrs} ${l4 r.protocol r.ports [ ]}${dnatTarget} # hairpin ${label}";
hairpinForward = lib.optional (r.dnat && r.hairpin && toDevs != [ ]) (
"iifname ${set internetIfaces} oifname ${set toDevs} ip daddr ${r.destination} ${l4 r.protocol fwdPorts [ ]}ct status dnat accept # hairpin ${label}"
++ lib.optional n.hairpin "iifname ${set internetIfaces} ${saddr}${daddr}fib daddr type local ip daddr != ${setOf internalAddrs} ${l4 n.protocol n.ports [ ]}dnat to ${target} # hairpin ${label}"
);
# The hairpin allow: these LANs may reach the host through the router's address.
hairpinForward = lib.optional (live && n.type == "dnat" && n.hairpin) "iifname ${set internetIfaces} oifname ${set toDevs} ip daddr ${n.toAddress} ${l4 n.protocol (if n.toPort != null then [ n.toPort ] else n.ports) [ ]}ct status dnat accept # hairpin ${label}";
postrouting = lib.optional (live && n.type == "masquerade") "${
lib.optionalString (n.from != null) "iifname ${set fromDevs} "
}oifname ${set toDevs} ${saddr}${daddr}${l4 n.protocol n.ports n.sourcePorts}masquerade # ${label}"
++ lib.optional (
live && n.type == "snat"
) "${iif}oifname ${set toDevs} ${saddr}${daddr}${l4 n.protocol n.ports n.sourcePorts}snat to ${n.toAddress} # ${label}";
};
parts = lib.imap0 ruleParts cfg.firewall.rules;
anyHairpin = lib.any (r: r.dnat && r.hairpin) cfg.firewall.rules;
natRules = lib.imap0 natParts cfg.firewall.nat;
anyHairpin = lib.any (n: n.type == "dnat" && n.hairpin) cfg.firewall.nat;
# Addresses the router itself holds on internal interfaces; anything else
# that is local is a public (uplink) address.
internalAddrs =
@@ -261,7 +287,7 @@ in
# Zones: free inside a zone, denied between zones unless a rule says so.
${lib.concatStringsSep "\n " (
intraZone ++ lib.concatMap (p: p.hairpinForward ++ p.forward) parts
intraZone ++ lib.concatMap (p: p.hairpinForward) natRules ++ lib.concatMap (p: p.forward) parts
)}
}
'';
@@ -272,11 +298,12 @@ in
content = ''
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
${lib.concatStringsSep "\n " (lib.concatMap (p: p.prerouting) parts)}
${lib.concatStringsSep "\n " (lib.concatMap (p: p.prerouting) natRules)}
}
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
${lib.concatStringsSep "\n " (lib.concatMap (p: p.postrouting) natRules)}
oifname "${uplink4}" masquerade
${lib.optionalString anyHairpin "ct status dnat iifname ${set memberIfaces} oifname ${set memberIfaces} masquerade # hairpin"}
${lib.optionalString (
+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 {