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
+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).";
};
};
}
);