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);