Add port forwards and WAN-originated firewall rules

Extend router.firewall.rules with a reserved `wan` source zone: DNAT
(dnat, toPort), port ranges, source allow-lists, per-source rate
limits, logging and optional hairpin NAT. IPv6 is opened only by
explicit pinholes. Build-time assertions require WAN rules to name a
destination, protocol and ports so a typo cannot expose a whole zone.

Covered by the basic/pppoe VM tests and the assertion tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 20:48:40 +01:00
co-authored by Claude Sonnet 5.5
parent 30ab1a0ed6
commit 3f8f2f4199
6 changed files with 676 additions and 36 deletions
+107 -7
View File
@@ -96,25 +96,46 @@ let
}) (lib.filter (l: l.zone == zone && validIp l.ipv4.address) lans)
++ lib.concatMap wgV4Nets (lib.filter (w: w.zone == zone) wgs);
portOk =
p:
if builtins.isInt p then
p >= 1 && p <= 65535
else
let
m = builtins.match "([0-9]+)-([0-9]+)" p;
lo = lib.toInt (builtins.elemAt m 0);
hi = lib.toInt (builtins.elemAt m 1);
in
lo >= 1 && hi <= 65535 && lo < hi;
ruleAssertions =
r:
let
where = "router.firewall.rules (${r.from} -> ${r.to})";
dest = if r.destination == null then null else ipv4.parseNet r.destination;
fromWan = r.from == "wan";
v6Dest = r.destination != null && isV6 r.destination;
dest = if r.destination == null || v6Dest then null else ipv4.parseNet r.destination;
inZone =
dest != null
&& 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
[
{
assertion = lib.elem r.from zoneNames;
message = "router: ${where}: unknown source zone `${r.from}` (zones: ${lib.concatStringsSep ", " zoneNames}).";
assertion = fromWan || lib.elem r.from zoneNames;
message = "router: ${where}: unknown source zone `${r.from}` (zones: wan, ${lib.concatStringsSep ", " zoneNames}).";
}
{
assertion = lib.elem r.to zoneNames;
message = "router: ${where}: unknown destination zone `${r.to}` (zones: ${lib.concatStringsSep ", " zoneNames}).";
message =
if r.to == "wan" then
"router: ${where}: `wan` cannot be a destination; use `internet = true` on the LAN or tunnel."
else
"router: ${where}: unknown destination zone `${r.to}` (zones: ${lib.concatStringsSep ", " zoneNames}).";
}
{
assertion = r.from != r.to;
@@ -124,20 +145,93 @@ let
assertion = r.ports == [ ] || r.protocol == "tcp" || r.protocol == "udp";
message = "router: ${where}: ports are only valid with protocol tcp or udp.";
}
{
assertion = lib.all portOk r.ports;
message = "router: ${where}: a port or port range is out of range (1-65535, ranges need low < high).";
}
{
assertion = lib.all validCidr (
map (
x:
if lib.hasInfix "/" x then
x
else if isV6 x then
"${x}/128"
else
"${x}/32"
) r.sources
);
message = "router: ${where}: every source must be an IPv4/IPv6 address or CIDR.";
}
]
# Rules from the outside must not be able to expose a whole zone by accident.
++ lib.optionals fromWan [
{
assertion = r.destination != null;
message = "router: ${where}: rules from `wan` must name a destination host or network.";
}
{
assertion = r.protocol != "any";
message = "router: ${where}: rules from `wan` must name a protocol (tcp, udp or icmp).";
}
{
assertion = r.protocol == "icmp" || r.protocol == "any" || r.ports != [ ];
message = "router: ${where}: rules from `wan` must list the ports they open.";
}
]
++ lib.optionals (r.destination != null) [
{
assertion = dest != null;
assertion = v6Dest || dest != null;
message = "router: ${where}: destination `${r.destination}` is not a valid IPv4 address or CIDR.";
}
{
assertion = dest == null || !(lib.elem r.to zoneNames) || inZone;
assertion = v6Dest || dest == null || !(lib.elem r.to zoneNames) || inZone;
message = "router: ${where}: destination `${r.destination}` is not inside any network of zone `${r.to}`.";
}
{
assertion = dest == null || ipv4.networkOf dest.address dest.prefixLength == dest.address;
assertion = v6Dest || dest == null || ipv4.networkOf dest.address dest.prefixLength == dest.address;
message = "router: ${where}: destination `${r.destination}` has host bits set.";
}
{
assertion =
!v6Dest
|| v6Cidr (if lib.hasInfix "/" r.destination then r.destination else "${r.destination}/128");
message = "router: ${where}: destination `${r.destination}` is not a valid IPv6 address or CIDR.";
}
]
++ lib.optionals r.dnat [
{
assertion = fromWan;
message = "router: ${where}: dnat is only valid for rules from `wan`.";
}
{
assertion = singleHost;
message = "router: ${where}: dnat needs a single IPv4 host as destination.";
}
{
assertion = r.protocol == "tcp" || r.protocol == "udp";
message = "router: ${where}: dnat needs protocol tcp or udp.";
}
{
assertion = lib.all (x: !isV6 x) r.sources;
message = "router: ${where}: dnat is IPv4-only, so its sources must be IPv4.";
}
]
++ lib.optionals (r.toPort != null) [
{
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 = internetMember;
message = "router: ${where}: hairpin needs at least one LAN or tunnel with internet = true.";
}
];
overlapsLan = n: lib.any (l: ipv4.overlaps n l) lanNets;
@@ -246,6 +340,12 @@ in
]
++ lib.concatLists (lib.mapAttrsToList perLan lansByName)
++ lib.concatMap ruleAssertions cfg.firewall.rules
++ [
{
assertion = !(lib.elem "wan" zoneNames);
message = "router: `wan` is a reserved zone name; give the LAN or tunnel a different `zone`.";
}
]
++ lib.concatLists (lib.mapAttrsToList wgAssertions cfg.wireguard)
++ [
{
+120 -12
View File
@@ -60,21 +60,121 @@ let
) zones
);
ruleToNft =
setOf = xs: "{ ${lib.concatMapStringsSep ", " toString xs} }";
# Layer 4 match; `ports` may be ints or "a-b" ranges.
l4 =
proto: ports:
if proto == "any" then
""
else if proto == "icmp" then
"meta l4proto { icmp, ipv6-icmp } "
else
"${proto} dport ${if ports == [ ] then "1-65535" else setOf ports} ";
# Destination match, by family of the address.
daddrOf =
r:
lib.optionalString (r.destination != null) (
"${if isV6 r.destination then "ip6" else "ip"} daddr ${r.destination} "
);
# One variant per source family; no sources means one unrestricted variant.
# `key` is the family selector used for per-source rate limiting (null: both).
variantsOf =
r:
let
dest = lib.optionalString (r.destination != null) "ip daddr ${r.destination} ";
proto =
if r.protocol == "any" then
""
else if r.protocol == "icmp" then
"meta l4proto { icmp, ipv6-icmp } "
v4 = lib.filter (x: !isV6 x) r.sources;
v6 = lib.filter isV6 r.sources;
destFamily =
if r.destination == null then
null
else if isV6 r.destination then
"ip6"
else
"${r.protocol} dport ${
if r.ports == [ ] then "1-65535" else "{ ${lib.concatMapStringsSep ", " toString r.ports} }"
} ";
"ip";
in
"iifname ${set (zoneDevices r.from)} oifname ${set (zoneDevices r.to)} ${dest}${proto}accept # ${r.from} -> ${r.to}";
if r.sources == [ ] then
[
{
src = "";
key = destFamily;
}
]
else
lib.optional (v4 != [ ]) {
src = "ip saddr ${setOf v4} ";
key = "ip";
}
++ lib.optional (v6 != [ ]) {
src = "ip6 saddr ${setOf v6} ";
key = "ip6";
};
# Everything one rule contributes. Indexed so meters/prefixes are unique.
ruleParts =
i: r:
let
id = toString i;
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}${lib.optionalString r.dnat "ct status dnat "}";
logPrefix = what: ''log prefix "router fwd#${id} ${label} ${what}: " '';
limitLines =
v:
lib.optionals (r.rateLimit != null) (
map
(
k:
"${base}${v.src}ct state new meter rl${id}-${k} { ${k} saddr limit rate over ${r.rateLimit} } ${lib.optionalString r.log (logPrefix "rate-limited")}drop"
)
(
if v.key == null then
[
"ip"
"ip6"
]
else
[ v.key ]
)
);
acceptLines =
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
{
forward = 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
)
)
# 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) (
"iifname ${set internetIfaces} oifname ${set toDevs} ip daddr ${r.destination} ${l4 r.protocol fwdPorts}ct status dnat accept # hairpin ${label}"
);
};
parts = lib.imap0 ruleParts cfg.firewall.rules;
anyHairpin = lib.any (r: r.dnat && r.hairpin) cfg.firewall.rules;
# Addresses the router itself holds on internal interfaces; anything else
# that is local is a public (uplink) address.
internalAddrs =
map (l: l.ipv4.address) lans
++ map (a: builtins.head (lib.splitString "/" a)) (
lib.filter (a: !isV6 a) (lib.concatMap (w: w.addresses) wgs)
);
uplink4 = cfg.wan.ipv4.interface;
pppoe = cfg.wan.ipv4.type == "pppoe";
@@ -146,7 +246,9 @@ in
) "iifname ${set internetIfaces} oifname ${set uplinks} accept"}
# Zones: free inside a zone, denied between zones unless a rule says so.
${lib.concatStringsSep "\n " (intraZone ++ map ruleToNft cfg.firewall.rules)}
${lib.concatStringsSep "\n " (
intraZone ++ lib.concatMap (p: p.hairpinForward ++ p.forward) parts
)}
}
'';
};
@@ -154,9 +256,15 @@ in
networking.nftables.tables.router-nat = {
family = "ip";
content = ''
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
${lib.concatStringsSep "\n " (lib.concatMap (p: p.prerouting) parts)}
}
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
oifname "${uplink4}" masquerade
${lib.optionalString anyHairpin "ct status dnat iifname ${set memberIfaces} oifname ${set memberIfaces} masquerade # hairpin"}
${lib.optionalString (
masqueradeIfaces != [ ]
) "oifname ${set masqueradeIfaces} masquerade # tunnels"}
+58 -5
View File
@@ -317,14 +317,18 @@ in
description = ''
Allow rules between zones, on top of the default (deny between zones).
Rules are one-directional; return traffic is allowed by connection
tracking. Both ends must be zones of LANs defined in `router.lans`.
tracking. `to` must be the zone of a LAN or tunnel; `from` may also be
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.
'';
type = types.listOf (
types.submodule {
options = {
from = mkOption {
type = types.str;
description = "Source zone.";
description = "Source zone, or `wan`.";
};
to = mkOption {
type = types.str;
@@ -340,9 +344,17 @@ in
default = "any";
};
ports = mkOption {
type = types.listOf types.port;
type = types.listOf (types.either types.port (types.strMatching "[0-9]+-[0-9]+"));
default = [ ];
description = "Destination ports (tcp/udp only); empty means all ports.";
example = [
443
"50000-50100"
];
description = ''
Destination ports or ranges (tcp/udp only); empty means all
ports. With `dnat` these are the ports on the router's
public address.
'';
};
destination = mkOption {
type = types.nullOr types.str;
@@ -350,9 +362,50 @@ in
example = "192.168.10.5";
description = ''
Restrict the rule to an IPv4 address or CIDR inside the
destination zone; null means the whole zone.
destination zone; null means the whole zone. IPv6 addresses
are accepted too (for pinholes) but cannot be checked against
the zone, because delegated prefixes are not known at build time.
'';
};
sources = mkOption {
type = types.listOf types.str;
default = [ ];
example = [ "198.51.100.0/24" ];
description = "Only match traffic from these IPv4/IPv6 addresses or CIDRs; empty means any source.";
};
dnat = 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).
'';
};
rateLimit = mkOption {
type = types.nullOr (types.strMatching "[0-9]+/(second|minute|hour)");
default = null;
example = "10/minute";
description = "Limit new connections per source address; the excess is dropped.";
};
log = mkOption {
type = types.bool;
default = false;
description = "Log new connections accepted by this rule, and connections dropped by `rateLimit` (kernel log).";
};
};
}
);