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
+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 (