diff --git a/PLAN.md b/PLAN.md
index b0fcee3..bd38acf 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -105,10 +105,11 @@ Learned along the way:
- Router services stay per LAN: DNS and DHCP always, SSH only with `allowManagement`.
- Kea subnet IDs are derived from the network address, so adding or removing LANs does not shift IDs (and orphan persistent leases).
-## Port forwards (confirmed)
+## Port forwards and NAT (confirmed)
-- Implemented by extending `router.firewall.rules` with a reserved source zone `wan` (no LAN/tunnel may use that zone name; `wan` is never a destination).
-- Extra rule fields: `dnat` (IPv4 only, single host destination, tcp/udp), `toPort`, port ranges (`"9000-9002"`), `sources` allow-list (v4/v6 CIDRs), `rateLimit` (`N/second|minute|hour`, per source via nft meter), `log`, `hairpin` (NAT loopback for LANs/tunnels with `internet = true`; masquerades so replies return via the router).
+- Filtering and translation are separate. `router.firewall.rules` only filter and may use the reserved source zone `wan` (no LAN/tunnel may use that zone name; `wan` is never a destination). Rule fields: port ranges (`"9000-9002"`), `sources` allow-list (v4/v6 CIDRs), `rateLimit` (`N/second|minute|hour`, per source via nft meter), `log`, `natOnly` (only connections a DNAT rule translated).
+- `router.firewall.nat` is the translation table: `type` `dnat` (IPv4 only, one host `toAddress` in the `to` zone, tcp/udp, optional `toPort`, `hairpin` from `wan`: NAT loopback for LANs/tunnels with `internet = true`; masquerades so replies return via the router), `snat` (`toAddress`) and `masquerade`, matching `from`, `sources`, `destination` (default: the router's own addresses), protocol and ports.
+- The web UI's NAT table can also add the matching filter rule ("also allow", a `natOnly` rule). Schema 1 (`migrations/1.jq`) converts the old rules with `dnat` into a NAT row plus such a rule.
- Safeguards: a rule from `wan` must name a destination, an explicit protocol and its ports, so a typo cannot expose a whole zone.
- IPv6 has no NAT: it is opened only by explicit pinholes (`from = "wan"`, IPv6 `destination`). The prefix is dynamic, so v6 destinations are not checked against the zone.
- Tested in `basic`/`pppoe` (source preserved, toPort, ranges, sources, rate limit, v6 pinhole, hairpin, log lines) and in `assertions`; mutation-tested (source match, hairpin masquerade, rate limit).
@@ -157,7 +158,7 @@ Open (in rough order):
3. ~~**Tunnel trust and addressing**~~ **done**: tunnels may have no address; the new `wireguard..sourceAddresses` sets the source (`PreferredSource`) for router-originated traffic into an addressless tunnel (e.g. DNS to Troisdorf; tested, mutation-tested). Antigua's blanket `trustedInterfaces` is spelled out as per-tunnel `allowManagement`/`dns` plus zone rules in `examples/home`. The default device name `wg-` must fit 15 characters (caught by an assertion: `mobile-clients` became `mobile`).
4. ~~**PPP device name**~~ **done**: `ddclient` in `examples/home` uses `config.router.wan.ipv4.interface` (`pppoe-wan`) instead of a hard-coded name. DDNS has since moved into the module (`router.ddns`, a web UI page; tested in `ddns`): `examples/home` can drop its `services.ddclient` for it.
5. **Credentials**: the PPPoE password is assigned by the ISP (cannot be rotated) and is plaintext in antigua's repo/store; never copy it into a repo again. Here it is read from `/var/lib/router/pppoe-password` (delivered with `router-install --secrets`). The PPPoE username in `examples/home` is a placeholder.
-6. **Services that stay on antigua** (k3s, Gitea, Syncthing, WoW, Traefik) were reachable from the WAN because antigua *was* the router. On the new router they need explicit port forwards to the host's address (`firewall.rules` with `from = "wan"`); antigua's address on the LAN also changes (k3s nodeIP and the `antigua.mch` record point at 192.168.168.254 today). **Open: needs the host's new address and the list of public ports.**
+6. **Services that stay on antigua** (k3s, Gitea, Syncthing, WoW, Traefik) were reachable from the WAN because antigua *was* the router. On the new router they need explicit port forwards to the host's address (a NAT row on the Firewall page); antigua's address on the LAN also changes (k3s nodeIP and the `antigua.mch` record point at 192.168.168.254 today). **Open: needs the host's new address and the list of public ports.**
7. `examples/home` (`nixosConfigurations.home`, `packages.installer-home`) ports antigua's routing role; its toplevel builds as the `home` check. Not VM-testable as a whole (needs the real ISP); its pieces are covered by the component tests.
## Installer and image (in progress)
diff --git a/flake.nix b/flake.nix
index 28c164e..57e7a77 100644
--- a/flake.nix
+++ b/flake.nix
@@ -170,6 +170,7 @@
(self.lib.mkRouterSystem {
settings = builtins.toFile "settings.json" (
builtins.toJSON {
+ version = thisRelease.schema;
router = {
wan.interface = "eth0";
lans.lan = {
diff --git a/migrations/1.jq b/migrations/1.jq
new file mode 100644
index 0000000..70eba7c
--- /dev/null
+++ b/migrations/1.jq
@@ -0,0 +1,21 @@
+# Port forwards move from `firewall.rules` (dnat, toPort, hairpin) to
+# `firewall.nat`; the allow rule that went with them stays as a rule of its
+# own, limited to translated connections (natOnly). The rest of a rule
+# (rateLimit, log, sources) stays on the allow rule.
+def natRow:
+ {type: "dnat", from: .from, to: .to, protocol: .protocol, ports: .ports, toAddress: .destination}
+ + (if .toPort != null then {toPort: .toPort} else {} end)
+ + (if ((.sources // []) | length) > 0 then {sources: .sources} else {} end)
+ + (if .hairpin == true then {hairpin: true} else {} end);
+
+def allowRule:
+ del(.dnat, .hairpin, .toPort)
+ + {natOnly: true}
+ + (if .toPort != null then {ports: [.toPort]} else {} end);
+
+if ((.router.firewall.rules // []) | any(.dnat == true or .hairpin != null or .toPort != null)) then
+ .router.firewall.nat = ((.router.firewall.nat // []) + [.router.firewall.rules[] | select(.dnat == true) | natRow])
+ | .router.firewall.rules |= map(if .dnat == true then allowRule else del(.dnat, .hairpin, .toPort) end)
+else
+ .
+end
diff --git a/modules/router/assertions.nix b/modules/router/assertions.nix
index 123378c..2069db1 100644
--- a/modules/router/assertions.nix
+++ b/modules/router/assertions.nix
@@ -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);
diff --git a/modules/router/firewall.nix b/modules/router/firewall.nix
index 857274e..67a6ae5 100644
--- a/modules/router/firewall.nix
+++ b/modules/router/firewall.nix
@@ -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 (
diff --git a/modules/router/options.nix b/modules/router/options.nix
index 4aeaa77..8c5bca3 100644
--- a/modules/router/options.nix
+++ b/modules/router/options.nix
@@ -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 {
diff --git a/pkgs/router-webui/firewall.go b/pkgs/router-webui/firewall.go
index c64eeef..b1a1b6d 100644
--- a/pkgs/router-webui/firewall.go
+++ b/pkgs/router-webui/firewall.go
@@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"net/netip"
+ "reflect"
"regexp"
"slices"
"strconv"
@@ -22,6 +23,9 @@ type FirewallRule struct {
Destination string // one address or CIDR, empty: the whole zone
Ports string // destination ports
Protocol string
+ NATOnly bool // only connections that a port forward translated
+ Log bool // log new connections
+ RateLimit string // per source address, like 10/minute
Remove bool
}
@@ -36,7 +40,7 @@ type ZoneMember struct {
var portRangeRe = regexp.MustCompile(`^[0-9]+(-[0-9]+)?$`)
// simpleRuleKeys are the settings keys the page edits.
-var simpleRuleKeys = []string{"from", "to", "protocol", "ports", "sourcePorts", "destination", "sources"}
+var simpleRuleKeys = []string{"from", "to", "protocol", "ports", "sourcePorts", "destination", "sources", "natOnly", "log", "rateLimit"}
// zoneMembers are the LANs, then the tunnels, by name. A member is in a zone
// named after itself unless it says otherwise.
@@ -85,9 +89,6 @@ func (s Settings) zones() []string {
}
func ruleSimple(r map[string]any) bool {
- if str(r, "from") == "wan" {
- return false
- }
for k := range r {
if !slices.Contains(simpleRuleKeys, k) {
return false
@@ -123,9 +124,6 @@ func (s Settings) firewallRules() (simple []FirewallRule, other []string) {
if r == nil {
continue
}
- if wanRuleSimple(r) {
- continue
- }
if !ruleSimple(r) {
desc := str(r, "from") + " → " + str(r, "to")
if d := str(r, "destination"); d != "" {
@@ -140,9 +138,6 @@ func (s Settings) firewallRules() (simple []FirewallRule, other []string) {
if p := portsText(r["ports"]); p != "" {
desc += " " + p
}
- if dnat, _ := r["dnat"].(bool); dnat {
- desc += " (port forward)"
- }
other = append(other, desc)
continue
}
@@ -152,7 +147,8 @@ func (s Settings) firewallRules() (simple []FirewallRule, other []string) {
}
simple = append(simple, FirewallRule{From: str(r, "from"), To: str(r, "to"), Protocol: proto,
Ports: portsText(r["ports"]), SourcePorts: portsText(r["sourcePorts"]),
- Sources: strings.Join(stringList(r, "sources"), ", "), Destination: str(r, "destination")})
+ Sources: strings.Join(stringList(r, "sources"), ", "), Destination: str(r, "destination"),
+ NATOnly: boolOf(r, "natOnly"), Log: boolOf(r, "log"), RateLimit: str(r, "rateLimit")})
}
return simple, other
}
@@ -263,7 +259,7 @@ func (s Settings) applyZones(zf ZoneForm, errs FormErrors) {
// applyFirewall changes the zones, then replaces the rules the page edits with
// rows (an untouched "add" row is skipped); the other rules stay as they are.
-func (s Settings) applyFirewall(zf ZoneForm, rows []FirewallRule, fwds []WANRule) error {
+func (s Settings) applyFirewall(zf ZoneForm, rows []FirewallRule, nats []NATRule) error {
errs := FormErrors{}
s.applyZones(zf, errs)
zones := s.zones()
@@ -280,7 +276,7 @@ func (s Settings) applyFirewall(zf ZoneForm, rows []FirewallRule, fwds []WANRule
if r.Remove || (r.From == "" && r.To == "" && r.Ports == "" && r.SourcePorts == "" && r.Sources == "" && r.Destination == "") {
continue
}
- if !slices.Contains(zones, r.From) {
+ if !slices.Contains(zones, r.From) && r.From != "wan" {
errs[field("from")] = "choose a zone"
}
if !slices.Contains(zones, r.To) {
@@ -314,11 +310,35 @@ func (s Settings) applyFirewall(zf ZoneForm, rows []FirewallRule, fwds []WANRule
if r.Destination != "" && !validAddress(r.Destination) {
errs[field("destination")] = "one address or CIDR, or empty for the whole zone"
}
+ if r.From == "wan" {
+ // What the module requires, so that a typo cannot open a whole zone.
+ if r.Destination == "" {
+ errs[field("destination")] = "rules from the internet must name the host or network inside"
+ }
+ if r.Protocol == "any" {
+ errs[field("protocol")] = "rules from the internet must name a protocol"
+ } else if r.Protocol != "icmp" && len(ports) == 0 {
+ errs[field("ports")] = "rules from the internet must list the ports they open"
+ }
+ }
+ r.RateLimit = strings.TrimSpace(r.RateLimit)
+ if r.RateLimit != "" && !rateLimitRe.MatchString(r.RateLimit) {
+ errs[field("ratelimit")] = "like 10/minute (second, minute or hour)"
+ }
if seen[r] {
errs[field("from")] = "the same rule twice"
}
seen[r] = true
rule := map[string]any{"from": r.From, "to": r.To}
+ if r.NATOnly {
+ rule["natOnly"] = true
+ }
+ if r.Log {
+ rule["log"] = true
+ }
+ if r.RateLimit != "" {
+ rule["rateLimit"] = r.RateLimit
+ }
if r.Protocol != "any" {
rule["protocol"] = r.Protocol
}
@@ -336,16 +356,21 @@ func (s Settings) applyFirewall(zf ZoneForm, rows []FirewallRule, fwds []WANRule
}
next = append(next, rule)
}
- next = append(next, s.forwardRules(fwds, zones, errs)...)
+ natRows, allows := s.natSettings(nats, zones, errs)
+ for _, a := range allows {
+ if !slices.ContainsFunc(next, func(v any) bool { return reflect.DeepEqual(v, a) }) {
+ next = append(next, a)
+ }
+ }
router := obj(s, "router")
fw := obj(router, "firewall")
old, _ := fw["rules"].([]any)
for _, v := range old {
r, _ := v.(map[string]any)
- if r == nil || ruleSimple(r) || wanRuleSimple(r) {
+ if r == nil || ruleSimple(r) {
continue
}
- // Port forwards and the like are not edited here, but must keep a zone to point at.
+ // Rules with options not edited here must keep their zones.
if f := str(r, "from"); f != "wan" && !slices.Contains(zones, f) {
errs["zone."+f] = "a rule that is not editable here still uses this zone"
}
@@ -362,6 +387,11 @@ func (s Settings) applyFirewall(zf ZoneForm, rows []FirewallRule, fwds []WANRule
} else {
fw["rules"] = next
}
+ if len(natRows) == 0 {
+ delete(fw, "nat")
+ } else {
+ fw["nat"] = natRows
+ }
if len(fw) == 0 {
delete(router, "firewall")
}
@@ -369,19 +399,21 @@ func (s Settings) applyFirewall(zf ZoneForm, rows []FirewallRule, fwds []WANRule
}
type firewallData struct {
- Rows []FirewallRule
- Forwards []WANRule
- Other []string
- Zones []string
- Members []ZoneMember
- Empty []string // zones without members, which can be removed
- New string
- Errors FormErrors
+ Rows []FirewallRule
+ NATs []NATRule
+ FromZones []string // the zones and wan
+ Other []string
+ Zones []string
+ Members []ZoneMember
+ Empty []string // zones without members, which can be removed
+ New string
+ Errors FormErrors
}
-func (s Settings) firewallData(rows []FirewallRule, fwds []WANRule, members []ZoneMember, errs FormErrors) firewallData {
+func (s Settings) firewallData(rows []FirewallRule, nats []NATRule, members []ZoneMember, errs FormErrors) firewallData {
_, other := s.firewallRules()
- d := firewallData{Rows: rows, Forwards: fwds, Other: other, Zones: s.zones(), Members: members, Errors: errs}
+ d := firewallData{Rows: rows, NATs: nats, Other: other, Zones: s.zones(), Members: members, Errors: errs}
+ d.FromZones = append([]string{"wan"}, d.Zones...)
if d.Members == nil {
d.Members = s.zoneMembers()
}
@@ -403,7 +435,7 @@ func (a *app) firewallPage(w http.ResponseWriter, r *http.Request, sess session)
}
rows, _ := s.firewallRules()
add := FirewallRule{From: r.URL.Query().Get("from"), To: r.URL.Query().Get("to"), Protocol: "any"}
- p.Data = s.firewallData(append(rows, add), append(s.portForwards(), WANRule{Protocol: "tcp", NAT: true}), nil, nil)
+ p.Data = s.firewallData(append(rows, add), append(s.natRules(), NATRule{Type: "dnat", Protocol: "tcp", Allow: true}), nil, nil)
a.render(w, "firewall", p)
}
@@ -422,27 +454,28 @@ func (a *app) firewall(w http.ResponseWriter, r *http.Request, sess session) {
zf.Members = append(zf.Members, ZoneMember{Kind: v("kind"), Name: v("name"), Device: v("device"), Zone: v("zone")})
}
n, _ := strconv.Atoi(r.PostFormValue("rows"))
- nf, _ := strconv.Atoi(r.PostFormValue("forwards"))
- var fwds []WANRule
- for i := range min(nf, 512) {
- v := func(k string) string { return r.PostFormValue(fmt.Sprintf("fwd.%d.%s", i, k)) }
- fwds = append(fwds, WANRule{Protocol: v("protocol"), Ports: v("ports"), To: v("to"), Destination: v("destination"),
- ToPort: v("toport"), Sources: v("sources"), NAT: v("nat") == "on", Hairpin: v("hairpin") == "on",
- Log: v("log") == "on", RateLimit: v("ratelimit"), Remove: v("remove") == "on"})
+ nn, _ := strconv.Atoi(r.PostFormValue("nats"))
+ var nats []NATRule
+ for i := range min(nn, 512) {
+ v := func(k string) string { return r.PostFormValue(fmt.Sprintf("nat.%d.%s", i, k)) }
+ nats = append(nats, NATRule{Type: v("type"), From: v("from"), To: v("to"), Protocol: v("protocol"), Ports: v("ports"),
+ SourcePorts: v("sourceports"), Sources: v("sources"), Destination: v("destination"), ToAddress: v("toaddress"),
+ ToPort: v("toport"), Hairpin: v("hairpin") == "on", Allow: v("allow") == "on", Remove: v("remove") == "on"})
}
var rows []FirewallRule
for i := range min(n, 512) {
v := func(k string) string { return r.PostFormValue(fmt.Sprintf("rule.%d.%s", i, k)) }
rows = append(rows, FirewallRule{From: v("from"), Sources: v("sources"), SourcePorts: v("sourceports"),
- To: v("to"), Destination: v("destination"), Ports: v("ports"), Protocol: v("protocol"), Remove: v("remove") == "on"})
+ To: v("to"), Destination: v("destination"), Ports: v("ports"), Protocol: v("protocol"),
+ NATOnly: v("natonly") == "on", Log: v("log") == "on", RateLimit: v("ratelimit"), Remove: v("remove") == "on"})
}
- if err := s.applyFirewall(zf, rows, fwds); err != nil {
+ if err := s.applyFirewall(zf, rows, nats); err != nil {
var fe FormErrors
errors.As(err, &fe)
// Show what was entered; the zone lists are those before this attempt.
s, _ = loadSettings(a.sys.settingsPath())
p.Error = "Please correct the marked fields."
- d := s.firewallData(rows, fwds, zf.Members, fe)
+ d := s.firewallData(rows, nats, zf.Members, fe)
d.New = zf.New
p.Data = d
w.WriteHeader(http.StatusUnprocessableEntity)
diff --git a/pkgs/router-webui/firewall_test.go b/pkgs/router-webui/firewall_test.go
index bc9add8..1457a13 100644
--- a/pkgs/router-webui/firewall_test.go
+++ b/pkgs/router-webui/firewall_test.go
@@ -14,9 +14,9 @@ func TestFirewallRulesRoundTrip(t *testing.T) {
if got := s.zones(); strings.Join(got, ",") != "main,vpn" {
t.Fatalf("zones = %v", got)
}
- // A rule the page does not edit (it logs) must survive.
+ // A rule the page does not edit (it has a key the page lacks) must survive.
obj(obj(s, "router"), "firewall")["rules"] = []any{
- map[string]any{"from": "main", "to": "vpn", "log": true},
+ map[string]any{"from": "main", "to": "vpn", "extra": true},
}
rows, other := s.firewallRules()
if len(rows) != 0 || len(other) != 1 || !strings.Contains(other[0], "main → vpn") {
@@ -38,7 +38,7 @@ func TestFirewallRulesRoundTrip(t *testing.T) {
t.Errorf("rows = %+v", rows)
}
out := string(mustMarshal(t, s))
- for _, want := range []string{`"log": true`, `"5000-5100"`} {
+ for _, want := range []string{`"extra": true`, `"5000-5100"`} {
if !strings.Contains(out, want) {
t.Errorf("%s missing:\n%s", want, out)
}
@@ -235,98 +235,122 @@ func (s Settings) applyRules(zf ZoneForm, rows []FirewallRule) error {
return s.applyFirewall(zf, rows, nil)
}
-func TestPortForwardsRoundTrip(t *testing.T) {
+func TestNATRoundTrip(t *testing.T) {
s := mustParse(t)
- obj(obj(s, "router"), "firewall")["rules"] = []any{
- map[string]any{"from": "main", "to": "vpn", "log": true, "rateLimit": "5/minute"}, // not editable here, kept
- map[string]any{"from": "wan", "to": "main", "dnat": true, "destination": "192.168.10.5", "protocol": "tcp",
+ obj(obj(s, "router"), "firewall")["nat"] = []any{
+ map[string]any{"type": "dnat", "from": "wan", "to": "main", "toAddress": "192.168.10.5", "protocol": "tcp",
"ports": []any{float64(8080)}, "toPort": float64(80), "hairpin": true},
}
- got := s.portForwards()
- if len(got) != 1 || got[0].Ports != "8080" || got[0].ToPort != "80" || !got[0].NAT || !got[0].Hairpin {
- t.Fatalf("forwards = %+v", got)
+ got := s.natRules()
+ if len(got) != 1 || got[0].Type != "dnat" || got[0].Ports != "8080" || got[0].ToPort != "80" || !got[0].Hairpin {
+ t.Fatalf("nat = %+v", got)
}
- if _, other := s.firewallRules(); len(other) != 1 || strings.Contains(other[0], "port forward") {
- t.Errorf("other = %v (the forward is edited, the zone rule is not)", other)
- }
- add := []WANRule{
+ add := []NATRule{
got[0],
- {Protocol: "udp", Ports: "51000-51010", To: "main", Destination: "192.168.10.5", NAT: true, Sources: "198.51.100.0/24"},
- {Protocol: "tcp", Ports: "443", To: "main", Destination: "fd00::5"}, // an opening, no NAT
- {Protocol: "tcp", NAT: true}, // the empty add row
+ {Type: "dnat", From: "wan", To: "main", Protocol: "udp", Ports: "51000-51010", ToAddress: "192.168.10.5", Sources: "198.51.100.0/24", Allow: true},
+ {Type: "masquerade", From: "main", To: "wan", Sources: "192.168.10.0/24"},
+ {Type: "snat", To: "wan", ToAddress: "203.0.113.7", Destination: "10.0.0.0/8"},
+ {Type: "dnat", Protocol: "tcp"}, // the empty add row
}
if err := s.applyFirewall(ZoneForm{}, nil, add); err != nil {
t.Fatal(err)
}
- if got := s.portForwards(); len(got) != 3 || got[1].Ports != "51000-51010" || got[2].NAT || got[2].Destination != "fd00::5" {
- t.Fatalf("forwards = %+v", got)
+ got = s.natRules()
+ if len(got) != 4 || got[1].Ports != "51000-51010" || got[2].Type != "masquerade" || got[3].ToAddress != "203.0.113.7" {
+ t.Fatalf("nat = %+v", got)
}
- if !strings.Contains(string(mustMarshal(t, s)), `"rateLimit": "5/minute"`) {
- t.Error("a rule the editor does not handle was lost")
- }
- add[0].Remove, add[1].Remove, add[2].Remove = true, true, true
+ // "Also allow" added a filter rule; saving again does not add it twice.
if err := s.applyFirewall(ZoneForm{}, nil, add); err != nil {
t.Fatal(err)
}
- if got := s.portForwards(); len(got) != 0 {
+ if n := strings.Count(string(mustMarshal(t, s)), `"natOnly": true`); n != 1 {
+ t.Errorf("%d natOnly rules, want 1:\n%s", n, mustMarshal(t, s))
+ }
+ for i := range add {
+ add[i].Remove = true
+ }
+ if err := s.applyFirewall(ZoneForm{}, nil, add); err != nil {
+ t.Fatal(err)
+ }
+ if got := s.natRules(); len(got) != 0 {
t.Errorf("not removed: %+v", got)
}
}
-func TestPortForwardsAreChecked(t *testing.T) {
- good := WANRule{Protocol: "tcp", Ports: "8080", To: "main", Destination: "192.168.10.5", NAT: true}
- for name, mod := range map[string]func(*WANRule){
- "to": func(r *WANRule) { r.To = "wan" },
- "protocol": func(r *WANRule) { r.Protocol = "any" },
- "ports": func(r *WANRule) { r.Ports = "" },
- "destination": func(r *WANRule) { r.Destination = "10.9.9.9" }, // outside the LAN
- "sources": func(r *WANRule) { r.Sources = "2001:db8::/32" },
- "toport": func(r *WANRule) { r.ToPort = "80"; r.Ports = "80, 81" },
- "hairpin": func(r *WANRule) { r.NAT = false; r.Hairpin = true },
- "ratelimit": func(r *WANRule) { r.RateLimit = "lots" },
+func TestNATRowsAreChecked(t *testing.T) {
+ good := NATRule{Type: "dnat", From: "wan", To: "main", Protocol: "tcp", Ports: "8080", ToAddress: "192.168.10.5"}
+ for name, mod := range map[string]func(*NATRule){
+ "type": func(r *NATRule) { r.Type = "bogus" },
+ "from": func(r *NATRule) { r.From = "" },
+ "to": func(r *NATRule) { r.To = "nowhere" },
+ "protocol": func(r *NATRule) { r.Protocol = "any" },
+ "ports": func(r *NATRule) { r.Ports = "" },
+ "toaddress": func(r *NATRule) { r.ToAddress = "10.9.9.9" }, // outside the LAN
+ "sources": func(r *NATRule) { r.Sources = "2001:db8::/32" },
+ "toport": func(r *NATRule) { r.ToPort = "80"; r.Ports = "80, 81" },
+ "hairpin": func(r *NATRule) { r.From = "main"; r.To = "main"; r.ToAddress = "192.168.10.5"; r.Hairpin = true },
} {
r := good
mod(&r)
- fe, ok := mustParse(t).applyFirewall(ZoneForm{}, nil, []WANRule{r}).(FormErrors)
- if !ok || fe["fwd[0]."+name] == "" {
+ fe, ok := mustParse(t).applyFirewall(ZoneForm{}, nil, []NATRule{r}).(FormErrors)
+ if !ok || fe["nat[0]."+name] == "" {
t.Errorf("%s accepted: %v", name, fe)
}
}
- r := good
- r.Destination = "192.168.10.0/24"
- if fe, ok := mustParse(t).applyFirewall(ZoneForm{}, nil, []WANRule{r}).(FormErrors); !ok || fe["fwd[0].destination"] == "" {
- t.Errorf("a forward to a whole network accepted: %v", fe)
+ m := NATRule{Type: "masquerade", From: "main", To: "main"}
+ if fe, ok := mustParse(t).applyFirewall(ZoneForm{}, nil, []NATRule{m}).(FormErrors); !ok || fe["nat[0].to"] == "" {
+ t.Errorf("masquerade into the same zone accepted: %v", fe)
}
- r.NAT = false // an opening to a network is fine
- if err := mustParse(t).applyFirewall(ZoneForm{}, nil, []WANRule{r}); err != nil {
- t.Errorf("opening a network: %v", err)
+ m = NATRule{Type: "masquerade", From: "main", To: "wan", ToAddress: "192.0.2.1"}
+ if fe, ok := mustParse(t).applyFirewall(ZoneForm{}, nil, []NATRule{m}).(FormErrors); !ok || fe["nat[0].toaddress"] == "" {
+ t.Errorf("masquerade with an address accepted: %v", fe)
}
}
-func TestPortForwardPage(t *testing.T) {
+func TestWANFilterRule(t *testing.T) {
+ good := FirewallRule{From: "wan", To: "main", Destination: "192.168.10.5", Protocol: "tcp", Ports: "443", NATOnly: true, Log: true, RateLimit: "10/minute"}
+ if err := mustParse(t).applyRules(ZoneForm{}, []FirewallRule{good}); err != nil {
+ t.Fatal(err)
+ }
+ for name, mod := range map[string]func(*FirewallRule){
+ "destination": func(r *FirewallRule) { r.Destination = "" },
+ "protocol": func(r *FirewallRule) { r.Protocol = "any" },
+ "ports": func(r *FirewallRule) { r.Ports = "" },
+ "ratelimit": func(r *FirewallRule) { r.RateLimit = "lots" },
+ } {
+ r := good
+ mod(&r)
+ fe, ok := mustParse(t).applyRules(ZoneForm{}, []FirewallRule{r}).(FormErrors)
+ if !ok || fe["rule[0]."+name] == "" {
+ t.Errorf("%s accepted: %v", name, fe)
+ }
+ }
+}
+
+func TestNATPage(t *testing.T) {
a, sys := newTestApp(t)
h := a.routes()
cookie := do(h, "POST", "/login", url.Values{"password": {"hunter2hunter2"}}, nil).Result().Cookies()[0]
body := do(h, "GET", "/firewall", nil, cookie).Body.String()
- if !strings.Contains(body, `name="forwards" value="1"`) || !strings.Contains(body, "fwd.0.nat") {
- t.Fatalf("no row to add a forward:\n%s", body)
+ if !strings.Contains(body, `name="nats" value="1"`) || !strings.Contains(body, "nat.0.type") {
+ t.Fatalf("no row to add a NAT rule:\n%s", body)
}
token := regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(body)[1]
- form := url.Values{"csrf": {token}, "forwards": {"1"}, "fwd.0.protocol": {"tcp"}, "fwd.0.ports": {"8080"},
- "fwd.0.to": {"main"}, "fwd.0.destination": {"192.168.10.5"}, "fwd.0.nat": {"on"}, "fwd.0.toport": {"80"}, "fwd.0.hairpin": {"on"}}
- form.Set("fwd.0.destination", "192.168.10.0/24")
- if rec := do(h, "POST", "/firewall", form, cookie); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "one IPv4 address") {
- t.Fatalf("a forward to a network: %d\n%s", rec.Code, rec.Body)
+ form := url.Values{"csrf": {token}, "nats": {"1"}, "nat.0.type": {"dnat"}, "nat.0.from": {"wan"}, "nat.0.protocol": {"tcp"},
+ "nat.0.ports": {"8080"}, "nat.0.to": {"main"}, "nat.0.toaddress": {"10.9.9.9"}, "nat.0.toport": {"80"},
+ "nat.0.hairpin": {"on"}, "nat.0.allow": {"on"}}
+ if rec := do(h, "POST", "/firewall", form, cookie); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "not inside a network") {
+ t.Fatalf("a forward outside the LAN: %d\n%s", rec.Code, rec.Body)
}
- form.Set("fwd.0.destination", "192.168.10.5")
+ form.Set("nat.0.toaddress", "192.168.10.5")
if rec := do(h, "POST", "/firewall", form, cookie); rec.Code != http.StatusSeeOther {
t.Fatalf("save: %d\n%s", rec.Code, rec.Body)
}
s, _ := loadSettings(sys.settingsPath())
- if f := s.portForwards(); len(f) != 1 || f[0].ToPort != "80" || !f[0].Hairpin {
+ if f := s.natRules(); len(f) != 1 || f[0].ToPort != "80" || !f[0].Hairpin {
t.Fatalf("not saved: %+v", f)
}
- if body := do(h, "GET", "/firewall", nil, cookie).Body.String(); !strings.Contains(body, `value="192.168.10.5"`) {
- t.Errorf("the forward is not listed:\n%s", body)
+ if body := do(h, "GET", "/firewall", nil, cookie).Body.String(); !strings.Contains(body, `value="192.168.10.5"`) || !strings.Contains(body, `name="rule.0.natonly" checked`) {
+ t.Errorf("the forward or its allow rule is not listed:\n%s", body)
}
}
diff --git a/pkgs/router-webui/forwards.go b/pkgs/router-webui/forwards.go
deleted file mode 100644
index 8f40071..0000000
--- a/pkgs/router-webui/forwards.go
+++ /dev/null
@@ -1,226 +0,0 @@
-package main
-
-import (
- "fmt"
- "net/netip"
- "regexp"
- "slices"
- "strconv"
- "strings"
-)
-
-// WANRule is a rule from the internet: a port forward (NAT, IPv4) or an
-// opening to an address inside (IPv4 or IPv6, no translation).
-type WANRule struct {
- Protocol string // tcp, udp or icmp
- Ports string // on the router's public address (NAT) or on the host
- To string // zone
- Destination string // the host; for a forward a single IPv4 address
- ToPort string // the host's port when it differs (forwards, one port)
- Sources string // only from these addresses, empty: anywhere
- NAT bool
- Hairpin bool
- Log bool
- RateLimit string // "10/minute" per source address
- Remove bool
-}
-
-var rateLimitRe = regexp.MustCompile(`^[0-9]+/(second|minute|hour)$`)
-
-// wanRuleKeys are the settings keys the port forward editor handles.
-var wanRuleKeys = []string{"from", "to", "protocol", "ports", "destination", "toPort", "sources", "dnat", "hairpin", "rateLimit", "log"}
-
-func wanRuleSimple(r map[string]any) bool {
- if str(r, "from") != "wan" {
- return false
- }
- for k := range r {
- if !slices.Contains(wanRuleKeys, k) {
- return false
- }
- }
- return true
-}
-
-func boolOf(r map[string]any, k string) bool {
- b, _ := r[k].(bool)
- return b
-}
-
-// portForwards are the rules from the internet, in order.
-func (s Settings) portForwards() []WANRule {
- router, _ := s["router"].(map[string]any)
- fw, _ := router["firewall"].(map[string]any)
- rules, _ := fw["rules"].([]any)
- var out []WANRule
- for _, v := range rules {
- r, _ := v.(map[string]any)
- if r == nil || !wanRuleSimple(r) {
- continue
- }
- out = append(out, WANRule{Protocol: str(r, "protocol"), Ports: portsText(r["ports"]), To: str(r, "to"),
- Destination: str(r, "destination"), ToPort: str(r, "toPort"), Sources: strings.Join(stringList(r, "sources"), ", "),
- NAT: boolOf(r, "dnat"), Hairpin: boolOf(r, "hairpin"), Log: boolOf(r, "log"), RateLimit: str(r, "rateLimit")})
- }
- return out
-}
-
-// zoneV4Nets are the IPv4 networks of a zone's LANs; ok is false when a tunnel
-// is in the zone too (its networks are the peers' routes, not checked here).
-func (s Settings) zoneV4Nets(zone string) (nets []netip.Prefix, ok bool) {
- lans := map[string]netip.Prefix{}
- for _, l := range s.form().LANs {
- a, aok := ipv4(l.Address)
- if !aok {
- continue
- }
- bits := 24
- if n, nok, _ := optionalInt(l.Prefix, 8, 30); nok {
- bits = n
- }
- lans[l.Name] = netip.PrefixFrom(a, bits).Masked()
- }
- for _, m := range s.zoneMembers() {
- if m.Zone != zone {
- continue
- }
- if m.Kind != "lan" {
- return nil, false
- }
- if p, found := lans[m.Name]; found {
- nets = append(nets, p)
- }
- }
- return nets, len(nets) > 0
-}
-
-// forwardRules checks the rows and returns them as settings rules.
-func (s Settings) forwardRules(rows []WANRule, zones []string, errs FormErrors) []any {
- var out []any
- seen := map[WANRule]bool{}
- for i, r := range rows {
- field := func(n string) string { return fmt.Sprintf("fwd[%d].%s", i, n) }
- r.Ports, r.Sources = strings.Join(splitList(r.Ports), ", "), strings.Join(splitList(r.Sources), ", ")
- r.To, r.Destination = strings.TrimSpace(r.To), strings.TrimSpace(r.Destination)
- r.ToPort, r.RateLimit = strings.TrimSpace(r.ToPort), strings.TrimSpace(r.RateLimit)
- if r.Remove || (r.Ports == "" && r.To == "" && r.Destination == "" && r.ToPort == "" && r.Sources == "" && r.RateLimit == "") {
- continue
- }
- if !slices.Contains(zones, r.To) {
- errs[field("to")] = "choose a zone"
- }
- if !slices.Contains([]string{"tcp", "udp", "icmp"}, r.Protocol) {
- errs[field("protocol")] = "tcp, udp or icmp"
- }
- var ports []any
- if r.Protocol == "icmp" {
- if r.Ports != "" {
- errs[field("ports")] = "icmp has no ports"
- }
- } else {
- var msg string
- if ports, msg = portList(r.Ports); msg != "" {
- errs[field("ports")] = msg
- } else if len(ports) == 0 {
- errs[field("ports")] = "name the ports to open"
- }
- }
- dest, destOK := netip.Prefix{}, false
- switch {
- case r.Destination == "":
- errs[field("destination")] = "the host or network inside"
- case !validAddress(r.Destination):
- errs[field("destination")] = "an address (or CIDR)"
- default:
- if p, err := netip.ParsePrefix(r.Destination); err == nil {
- dest = p
- if p.Masked() != p {
- errs[field("destination")] = "has host bits set"
- }
- } else {
- a, _ := netip.ParseAddr(r.Destination)
- dest = netip.PrefixFrom(a, a.BitLen())
- }
- destOK = errs[field("destination")] == ""
- }
- if destOK && dest.Addr().Is4() {
- if nets, ok := s.zoneV4Nets(r.To); ok && !slices.ContainsFunc(nets, func(n netip.Prefix) bool {
- return n.Bits() <= dest.Bits() && n.Contains(dest.Addr())
- }) {
- errs[field("destination")] = "not inside a network of zone " + r.To
- }
- }
- var sources []any
- for _, a := range splitList(r.Sources) {
- switch {
- case !validAddress(a):
- errs[field("sources")] = a + " is not an address or CIDR"
- case r.NAT && strings.Contains(a, ":"):
- errs[field("sources")] = "port forwards are IPv4 only"
- }
- sources = append(sources, a)
- }
- if r.NAT {
- if r.Protocol == "icmp" {
- errs[field("protocol")] = "a port forward needs tcp or udp"
- }
- if destOK && (!dest.Addr().Is4() || dest.Bits() != 32) {
- errs[field("destination")] = "a port forward goes to one IPv4 address"
- }
- }
- toPort := 0
- if r.ToPort != "" {
- n, err := strconv.Atoi(r.ToPort)
- switch {
- case err != nil || n < 1 || n > 65535:
- errs[field("toport")] = "a port number"
- case !r.NAT:
- errs[field("toport")] = "only for port forwards"
- case len(ports) != 1 || strings.Contains(r.Ports, "-"):
- errs[field("toport")] = "needs exactly one public port"
- }
- toPort = n
- }
- if r.Hairpin && !r.NAT {
- errs[field("hairpin")] = "only for port forwards"
- }
- if r.RateLimit != "" && !rateLimitRe.MatchString(r.RateLimit) {
- errs[field("ratelimit")] = "like 10/minute (second, minute or hour)"
- }
- if seen[r] {
- errs[field("to")] = "the same rule twice"
- }
- seen[r] = true
- rule := map[string]any{"from": "wan", "to": r.To, "protocol": r.Protocol}
- if len(ports) > 0 {
- rule["ports"] = ports
- }
- if destOK {
- if r.NAT || dest.Bits() == dest.Addr().BitLen() {
- rule["destination"] = dest.Addr().String()
- } else {
- rule["destination"] = dest.String()
- }
- }
- if len(sources) > 0 {
- rule["sources"] = sources
- }
- if r.NAT {
- rule["dnat"] = true
- }
- if toPort > 0 {
- rule["toPort"] = toPort
- }
- if r.Hairpin {
- rule["hairpin"] = true
- }
- if r.Log {
- rule["log"] = true
- }
- if r.RateLimit != "" {
- rule["rateLimit"] = r.RateLimit
- }
- out = append(out, rule)
- }
- return out
-}
diff --git a/pkgs/router-webui/nat.go b/pkgs/router-webui/nat.go
new file mode 100644
index 0000000..4662707
--- /dev/null
+++ b/pkgs/router-webui/nat.go
@@ -0,0 +1,241 @@
+package main
+
+import (
+ "fmt"
+ "net/netip"
+ "regexp"
+ "slices"
+ "strconv"
+ "strings"
+)
+
+// NATRule is a row of the address translation table (router.firewall.nat).
+type NATRule struct {
+ Type string // dnat, snat or masquerade
+ From string // zone, "wan", or empty (any) for snat and masquerade
+ To string // dnat: the host's zone; snat and masquerade: the zone or wan it leaves through
+ Protocol string
+ Ports string // destination ports to match
+ SourcePorts string
+ Sources string // IPv4 addresses or CIDRs to match
+ Destination string // IPv4 address or CIDR the traffic is addressed to; empty: the router itself
+ ToAddress string // dnat: the host; snat: the source address to use
+ ToPort string // dnat: the host's port when it differs
+ Hairpin bool // dnat from wan: LANs reach it through the public address too
+ Allow bool // form only: also add the filter rule that lets a forward through
+ Remove bool
+}
+
+var rateLimitRe = regexp.MustCompile(`^[0-9]+/(second|minute|hour)$`)
+
+func boolOf(r map[string]any, k string) bool {
+ b, _ := r[k].(bool)
+ return b
+}
+
+func (s Settings) natRules() []NATRule {
+ router, _ := s["router"].(map[string]any)
+ fw, _ := router["firewall"].(map[string]any)
+ rows, _ := fw["nat"].([]any)
+ var out []NATRule
+ for _, v := range rows {
+ r, _ := v.(map[string]any)
+ if r == nil {
+ continue
+ }
+ proto := str(r, "protocol")
+ if proto == "" {
+ proto = "any"
+ }
+ out = append(out, NATRule{Type: str(r, "type"), From: str(r, "from"), To: str(r, "to"), Protocol: proto,
+ Ports: portsText(r["ports"]), SourcePorts: portsText(r["sourcePorts"]), Sources: strings.Join(stringList(r, "sources"), ", "),
+ Destination: str(r, "destination"), ToAddress: str(r, "toAddress"), ToPort: str(r, "toPort"), Hairpin: boolOf(r, "hairpin")})
+ }
+ return out
+}
+
+// zoneV4Nets are the IPv4 networks of a zone's LANs; ok is false when a tunnel
+// is in the zone too (its networks are the peers' routes, not checked here).
+func (s Settings) zoneV4Nets(zone string) (nets []netip.Prefix, ok bool) {
+ lans := map[string]netip.Prefix{}
+ for _, l := range s.form().LANs {
+ a, aok := ipv4(l.Address)
+ if !aok {
+ continue
+ }
+ bits := 24
+ if n, nok, _ := optionalInt(l.Prefix, 8, 30); nok {
+ bits = n
+ }
+ lans[l.Name] = netip.PrefixFrom(a, bits).Masked()
+ }
+ for _, m := range s.zoneMembers() {
+ if m.Zone != zone {
+ continue
+ }
+ if m.Kind != "lan" {
+ return nil, false
+ }
+ if p, found := lans[m.Name]; found {
+ nets = append(nets, p)
+ }
+ }
+ return nets, len(nets) > 0
+}
+
+func validV4Net(v string) bool {
+ if p, err := netip.ParsePrefix(v); err == nil {
+ return p.Addr().Is4()
+ }
+ a, err := netip.ParseAddr(v)
+ return err == nil && a.Is4()
+}
+
+// natSettings checks the rows and returns them as settings, together with the
+// filter rules the rows ask for ("also allow").
+func (s Settings) natSettings(rows []NATRule, zones []string, errs FormErrors) (nat []any, allows []any) {
+ seen := map[string]bool{}
+ for i, r := range rows {
+ field := func(n string) string { return fmt.Sprintf("nat[%d].%s", i, n) }
+ r.From, r.To = strings.TrimSpace(r.From), strings.TrimSpace(r.To)
+ r.Ports, r.SourcePorts = strings.Join(splitList(r.Ports), ", "), strings.Join(splitList(r.SourcePorts), ", ")
+ r.Sources = strings.Join(splitList(r.Sources), ", ")
+ r.Destination, r.ToAddress, r.ToPort = strings.TrimSpace(r.Destination), strings.TrimSpace(r.ToAddress), strings.TrimSpace(r.ToPort)
+ if r.Protocol == "" {
+ r.Protocol = "any"
+ }
+ if r.Remove || (r.From == "" && r.To == "" && r.Ports == "" && r.SourcePorts == "" && r.Sources == "" &&
+ r.Destination == "" && r.ToAddress == "" && r.ToPort == "") {
+ continue
+ }
+ dnat, snat := r.Type == "dnat", r.Type == "snat"
+ if !dnat && !snat && r.Type != "masquerade" {
+ errs[field("type")] = "port forward, source NAT or masquerade"
+ }
+ inZones := func(z string) bool { return slices.Contains(zones, z) || z == "wan" }
+ switch {
+ case dnat && r.From == "":
+ errs[field("from")] = "where the traffic comes from"
+ case r.From != "" && !inZones(r.From):
+ errs[field("from")] = "choose a zone"
+ }
+ switch {
+ case dnat && !slices.Contains(zones, r.To):
+ errs[field("to")] = "the zone of the host"
+ case !dnat && !inZones(r.To):
+ errs[field("to")] = "choose a zone"
+ case !dnat && r.From == r.To:
+ errs[field("to")] = "source and destination are the same zone"
+ }
+ if !slices.Contains([]string{"any", "tcp", "udp", "icmp"}, r.Protocol) {
+ errs[field("protocol")] = "any, tcp, udp or icmp"
+ }
+ ports, msg := portList(r.Ports)
+ if msg != "" {
+ errs[field("ports")] = msg
+ }
+ sports, msg := portList(r.SourcePorts)
+ if msg != "" {
+ errs[field("sourceports")] = msg
+ }
+ if (len(ports) > 0 || len(sports) > 0) && r.Protocol != "tcp" && r.Protocol != "udp" {
+ errs[field("ports")] = "ports need protocol tcp or udp"
+ }
+ var sources []any
+ for _, a := range splitList(r.Sources) {
+ if !validV4Net(a) {
+ errs[field("sources")] = a + " is not an IPv4 address or CIDR"
+ break
+ }
+ sources = append(sources, a)
+ }
+ if r.Destination != "" && !validV4Net(r.Destination) {
+ errs[field("destination")] = "an IPv4 address or CIDR"
+ }
+ switch {
+ case dnat || snat:
+ if a, ok := ipv4(r.ToAddress); !ok {
+ errs[field("toaddress")] = "an IPv4 address"
+ } else if dnat {
+ if nets, ok := s.zoneV4Nets(r.To); ok && !slices.ContainsFunc(nets, func(n netip.Prefix) bool { return n.Contains(a) }) {
+ errs[field("toaddress")] = "not inside a network of zone " + r.To
+ }
+ }
+ case r.ToAddress != "":
+ errs[field("toaddress")] = "masquerade uses the outgoing interface's address"
+ }
+ toPort := 0
+ if r.ToPort != "" {
+ n, err := strconv.Atoi(r.ToPort)
+ switch {
+ case !dnat:
+ errs[field("toport")] = "only for port forwards"
+ case err != nil || n < 1 || n > 65535:
+ errs[field("toport")] = "a port number"
+ case len(ports) != 1 || strings.Contains(r.Ports, "-"):
+ errs[field("toport")] = "needs exactly one forwarded port"
+ }
+ toPort = n
+ }
+ if dnat {
+ if r.Protocol != "tcp" && r.Protocol != "udp" {
+ errs[field("protocol")] = "a port forward needs tcp or udp"
+ } else if len(ports) == 0 {
+ errs[field("ports")] = "name the ports to forward"
+ }
+ }
+ if r.Hairpin && (!dnat || r.From != "wan") {
+ errs[field("hairpin")] = "only for port forwards from wan"
+ }
+ key := fmt.Sprint(r.Type, r.From, r.To, r.Protocol, r.Ports, r.SourcePorts, r.Sources, r.Destination, r.ToAddress, r.ToPort)
+ if seen[key] {
+ errs[field("type")] = "the same rule twice"
+ }
+ seen[key] = true
+
+ row := map[string]any{"type": r.Type, "to": r.To}
+ if r.From != "" {
+ row["from"] = r.From
+ }
+ if r.Protocol != "any" {
+ row["protocol"] = r.Protocol
+ }
+ if len(ports) > 0 {
+ row["ports"] = ports
+ }
+ if len(sports) > 0 {
+ row["sourcePorts"] = sports
+ }
+ if len(sources) > 0 {
+ row["sources"] = sources
+ }
+ if r.Destination != "" {
+ row["destination"] = r.Destination
+ }
+ if r.ToAddress != "" && (dnat || snat) {
+ row["toAddress"] = r.ToAddress
+ }
+ if toPort > 0 {
+ row["toPort"] = toPort
+ }
+ if r.Hairpin {
+ row["hairpin"] = true
+ }
+ nat = append(nat, row)
+ if r.Allow && dnat {
+ // What lets the forward through: translated connections to the host.
+ allow := map[string]any{"from": r.From, "to": r.To, "destination": r.ToAddress, "protocol": r.Protocol, "natOnly": true}
+ switch {
+ case toPort > 0:
+ allow["ports"] = []any{toPort}
+ case len(ports) > 0:
+ allow["ports"] = ports
+ }
+ if len(sources) > 0 {
+ allow["sources"] = sources
+ }
+ allows = append(allows, allow)
+ }
+ }
+ return nat, allows
+}
diff --git a/pkgs/router-webui/templates/firewall.html b/pkgs/router-webui/templates/firewall.html
index d5fd0f3..8fcf50e 100644
--- a/pkgs/router-webui/templates/firewall.html
+++ b/pkgs/router-webui/templates/firewall.html
@@ -40,11 +40,11 @@
-
From zone
From address
From port
To zone
To address
To port
Protocol
Remove
+
From zone
From address
From port
To zone
To address
To port
Protocol
Forwarded only
Log
Rate limit
Remove
{{range $i, $r := .Rows}}
@@ -56,50 +56,62 @@
+
+
+
{{if or $r.From $r.To}}{{end}}
- {{range $f := list "from" "sources" "sourcePorts" "to" "destination" "ports" "protocol"}}{{with index $e (printf "rule[%d].%s" $i $f)}}
{{$f}}: {{.}}
{{end}}{{end}}
+ {{range $f := list "from" "sources" "sourcePorts" "to" "destination" "ports" "protocol" "ratelimit"}}{{with index $e (printf "rule[%d].%s" $i $f)}}
{{$f}}: {{.}}
{{end}}{{end}}
{{end}}
Fill in the last row to add a rule. Addresses (several separated by commas) are IPv4 or IPv6, single or CIDR;
- to address is one address or network. Ports (tcp and udp only): 22, 443, 5000-5100.
+ to address is one address or network. Ports (tcp and udp only): 22, 443, 5000-5100.
+ From wan (the internet) a rule must name the address inside, the protocol and the ports, and only lets
+ traffic through; a port forward is set up below. Forwarded only limits a rule to connections that a
+ port forward translated. Rate limit caps new connections per sender, like 10/minute.
diff --git a/scripts/webui-dev.sh b/scripts/webui-dev.sh
index 3aee4f7..13210b6 100644
--- a/scripts/webui-dev.sh
+++ b/scripts/webui-dev.sh
@@ -8,7 +8,7 @@ mkdir -p "$dir/config"
wan=$(ip -j route show default 2>/dev/null | jq -r '.[0].dev // empty')
lan=$(ip -j link show 2>/dev/null | jq -r --arg wan "$wan" '[.[] | select(.link_type == "ether" and .ifname != $wan) | .ifname][0] // "eth1"')
cat > "$dir/config/settings.json" < "$dir/router-release"
+echo '{"version": "0.0.0-dev", "serial": 1, "schema": 1}' > "$dir/router-release"
cd "$root/pkgs/router-webui"
echo devpassword1 | go run . passwd "$dir/webui-password"
echo
diff --git a/tests/apply.nix b/tests/apply.nix
index 1cd4006..312d42e 100644
--- a/tests/apply.nix
+++ b/tests/apply.nix
@@ -16,6 +16,8 @@ let
settings = pkgs.writeText "settings.json" (
builtins.toJSON {
+ # The schema of this source: the number of migrations.
+ version = lib.count (lib.hasSuffix ".jq") (builtins.attrNames (builtins.readDir ../migrations));
system = {
hostName = "router";
stateVersion = "26.11";
diff --git a/tests/assertions.nix b/tests/assertions.nix
index 6dbbc45..4f32249 100644
--- a/tests/assertions.nix
+++ b/tests/assertions.nix
@@ -609,13 +609,11 @@ let
to = "main";
destination = "192.168.10.5";
protocol = "tcp";
- ports = [ 8080 ];
- dnat = true;
- toPort = 8081;
+ ports = [ 8081 ];
+ natOnly = true;
sources = [ "198.51.100.0/24" ];
rateLimit = "3/minute";
log = true;
- hairpin = true;
}
{
from = "wan";
@@ -685,91 +683,230 @@ let
};
expect = [ "`wan` cannot be a destination" ];
};
- dnatNeedsHost = {
+ natDnatNeedsHost = {
router = {
lans.main = lan { };
- firewall.rules = [
+ firewall.nat = [
{
+ type = "dnat";
from = "wan";
to = "main";
- destination = "192.168.10.0/24";
protocol = "tcp";
ports = [ 8080 ];
- dnat = true;
}
];
};
- expect = [ "dnat needs a single IPv4 host" ];
+ expect = [ "dnat and snat need a toAddress" ];
};
- dnatV6Sources = {
+ natDnatHostOutsideZone = {
router = {
lans.main = lan { };
- firewall.rules = [
+ firewall.nat = [
{
+ type = "dnat";
from = "wan";
to = "main";
- destination = "192.168.10.5";
protocol = "tcp";
ports = [ 8080 ];
- dnat = true;
+ toAddress = "10.9.9.9";
+ }
+ ];
+ };
+ expect = [ "is not inside any network of zone `main`" ];
+ };
+ natV6Sources = {
+ router = {
+ lans.main = lan { };
+ firewall.nat = [
+ {
+ type = "dnat";
+ from = "wan";
+ to = "main";
+ protocol = "tcp";
+ ports = [ 8080 ];
+ toAddress = "192.168.10.5";
sources = [ "2001:db8::/32" ];
}
];
};
- expect = [ "its sources must be IPv4" ];
+ expect = [ "translation is IPv4 only" ];
};
- toPortWithoutDnat = {
+ natToPortNeedsOnePort = {
router = {
lans.main = lan { };
- firewall.rules = [
+ firewall.nat = [
{
+ type = "dnat";
from = "wan";
to = "main";
- destination = "192.168.10.5";
protocol = "tcp";
- ports = [ 8080 ];
- toPort = 8081;
+ ports = [
+ 8080
+ 8081
+ ];
+ toAddress = "192.168.10.5";
+ toPort = 80;
}
];
};
- expect = [ "toPort needs dnat" ];
+ expect = [ "toPort needs exactly one" ];
};
- hairpinNoDnat = {
+ natToPortOnMasquerade = {
router = {
- lans.main = lan { };
- firewall.rules = [
+ lans = {
+ main = lan { };
+ iot = lan2 { };
+ };
+ firewall.nat = [
{
- from = "wan";
+ type = "masquerade";
+ from = "iot";
+ to = "main";
+ toPort = 80;
+ }
+ ];
+ };
+ expect = [ "toPort and hairpin only apply to port forwards" ];
+ };
+ natHairpinFromLan = {
+ router = {
+ lans = {
+ main = lan { internet = true; };
+ iot = lan2 { };
+ };
+ firewall.nat = [
+ {
+ type = "dnat";
+ from = "iot";
to = "main";
- destination = "192.168.10.5";
protocol = "tcp";
ports = [ 8080 ];
+ toAddress = "192.168.10.5";
hairpin = true;
}
];
};
- expect = [
- "hairpin only applies to dnat"
- "hairpin needs at least one"
- ];
+ expect = [ "hairpin only applies to forwards from `wan`" ];
};
- hairpinNoInternet = {
+ natHairpinNoInternet = {
router = {
lans.main = lan { };
- firewall.rules = [
+ firewall.nat = [
{
+ type = "dnat";
from = "wan";
to = "main";
- destination = "192.168.10.5";
protocol = "tcp";
ports = [ 8080 ];
- dnat = true;
+ toAddress = "192.168.10.5";
hairpin = true;
}
];
};
expect = [ "hairpin needs at least one" ];
};
+ natMasqueradeWithAddress = {
+ router = {
+ lans.main = lan { };
+ firewall.nat = [
+ {
+ type = "masquerade";
+ to = "wan";
+ toAddress = "203.0.113.5";
+ }
+ ];
+ };
+ expect = [ "dnat and snat need a toAddress; masquerade has none" ];
+ };
+ natUnknownZones = {
+ router = {
+ lans.main = lan { };
+ firewall.nat = [
+ {
+ type = "snat";
+ from = "ghost";
+ to = "nowhere";
+ toAddress = "203.0.113.5";
+ }
+ ];
+ };
+ expect = [
+ "unknown source zone `ghost`"
+ "unknown zone `nowhere`"
+ ];
+ };
+ natDnatToWan = {
+ router = {
+ lans.main = lan { };
+ firewall.nat = [
+ {
+ type = "dnat";
+ from = "main";
+ to = "wan";
+ protocol = "tcp";
+ ports = [ 80 ];
+ toAddress = "192.168.10.5";
+ }
+ ];
+ };
+ expect = [ "unknown zone `wan`" ];
+ };
+ natValid = {
+ router = {
+ lans = {
+ main = lan { internet = true; };
+ iot = lan2 { };
+ };
+ firewall = {
+ rules = [
+ {
+ from = "wan";
+ to = "main";
+ destination = "192.168.10.5";
+ protocol = "tcp";
+ ports = [ 8081 ];
+ natOnly = true;
+ rateLimit = "3/minute";
+ log = true;
+ }
+ ];
+ nat = [
+ {
+ type = "dnat";
+ from = "wan";
+ to = "main";
+ protocol = "tcp";
+ ports = [ 8080 ];
+ sources = [ "198.51.100.0/24" ];
+ toAddress = "192.168.10.5";
+ toPort = 8081;
+ hairpin = true;
+ }
+ {
+ type = "dnat";
+ from = "iot";
+ to = "main";
+ protocol = "udp";
+ ports = [ "5000-5100" ];
+ destination = "192.168.20.1";
+ toAddress = "192.168.10.6";
+ }
+ {
+ type = "masquerade";
+ from = "iot";
+ to = "main";
+ }
+ {
+ type = "snat";
+ to = "wan";
+ sources = [ "192.168.10.0/24" ];
+ toAddress = "203.0.113.5";
+ }
+ ];
+ };
+ };
+ expect = [ ];
+ };
badPortRange = {
router = {
lans.main = lan { };
diff --git a/tests/basic.nix b/tests/basic.nix
index a8bfc5e..c34bf92 100644
--- a/tests/basic.nix
+++ b/tests/basic.nix
@@ -273,51 +273,83 @@ pkgs.testers.runNixOSTest {
};
};
dns.forwarders = [ ispV4 ];
- firewall.rules =
- let
- fwd =
- r:
+ # A port forward is a translation and the rule that lets it through.
+ firewall = {
+ nat =
+ let
+ fwd =
+ r:
+ {
+ type = "dnat";
+ from = "wan";
+ to = "main";
+ toAddress = clientV4;
+ protocol = "tcp";
+ }
+ // r;
+ in
+ [
+ (fwd {
+ ports = [ 8080 ];
+ hairpin = true;
+ })
+ (fwd {
+ ports = [ 8083 ];
+ toPort = 8084;
+ })
+ (fwd { ports = [ "9000-9002" ]; })
+ (fwd {
+ ports = [ 8085 ];
+ sources = [ "${ispV4}/32" ];
+ })
+ (fwd {
+ ports = [ 8086 ];
+ sources = [ "192.0.2.0/24" ];
+ })
+ (fwd { ports = [ 8087 ]; })
+ ];
+ rules =
+ let
+ allow =
+ r:
+ {
+ from = "wan";
+ to = "main";
+ destination = clientV4;
+ protocol = "tcp";
+ natOnly = true;
+ }
+ // r;
+ in
+ [
+ (allow {
+ ports = [ 8080 ];
+ log = true;
+ })
+ (allow { ports = [ 8084 ]; })
+ (allow { ports = [ "9000-9002" ]; })
+ (allow {
+ ports = [ 8085 ];
+ sources = [ "${ispV4}/32" ];
+ })
+ (allow {
+ ports = [ 8086 ];
+ sources = [ "192.0.2.0/24" ];
+ })
+ (allow {
+ ports = [ 8087 ];
+ rateLimit = "3/minute";
+ log = true;
+ })
{
from = "wan";
to = "main";
- destination = clientV4;
+ destination = clientV6;
protocol = "tcp";
- dnat = true;
+ ports = [ 8081 ];
}
- // r;
- in
- [
- (fwd {
- ports = [ 8080 ];
- hairpin = true;
- log = true;
- })
- (fwd {
- ports = [ 8083 ];
- toPort = 8084;
- })
- (fwd { ports = [ "9000-9002" ]; })
- (fwd {
- ports = [ 8085 ];
- sources = [ "${ispV4}/32" ];
- })
- (fwd {
- ports = [ 8086 ];
- sources = [ "192.0.2.0/24" ];
- })
- (fwd {
- ports = [ 8087 ];
- rateLimit = "3/minute";
- log = true;
- })
- {
- from = "wan";
- to = "main";
- destination = clientV6;
- protocol = "tcp";
- ports = [ 8081 ];
- }
- ];
+ ];
+ };
};
# Unbound answers `.test` locally (RFC 6761) and would validate DNSSEC
diff --git a/tests/onestick.nix b/tests/onestick.nix
index e1db419..1c6dbc7 100644
--- a/tests/onestick.nix
+++ b/tests/onestick.nix
@@ -200,16 +200,28 @@ pkgs.testers.runNixOSTest {
};
# Only the PPP device is an uplink, so this must work from the ISP
# and must not fire for traffic arriving from the LAN.
- firewall.rules = [
- {
- from = "wan";
- to = "main";
- destination = "192.168.10.51";
- protocol = "tcp";
- ports = [ 8080 ];
- dnat = true;
- }
- ];
+ firewall = {
+ nat = [
+ {
+ type = "dnat";
+ from = "wan";
+ to = "main";
+ toAddress = "192.168.10.51";
+ protocol = "tcp";
+ ports = [ 8080 ];
+ }
+ ];
+ rules = [
+ {
+ from = "wan";
+ to = "main";
+ destination = "192.168.10.51";
+ protocol = "tcp";
+ ports = [ 8080 ];
+ natOnly = true;
+ }
+ ];
+ };
dns = {
forwarders = [ ispV4 ];
domain = "lan.test";
diff --git a/tests/update.nix b/tests/update.nix
index 5d060ed..c6294a8 100644
--- a/tests/update.nix
+++ b/tests/update.nix
@@ -50,6 +50,8 @@ let
name = "v1";
version = "1.0.0";
serial = 1000;
+ # Settings version 0: the real migrations are not part of this test.
+ extra = "rm $out/migrations/*.jq";
};
v2 = mkVersion {
name = "v2";
diff --git a/tests/vlans.nix b/tests/vlans.nix
index 50ab8c9..4122308 100644
--- a/tests/vlans.nix
+++ b/tests/vlans.nix
@@ -9,6 +9,10 @@
# iot -> trusted, tcp 9090, only from source ports 40000-40010
# "lab" (no interface yet) -> trusted: matches nothing
#
+# nat: trusted -> router:7070 is forwarded to iot:8081 (source kept)
+# trusted -> iot tcp 8082 is masqueraded (iot sees the router)
+# trusted -> iot tcp 8083 is source-translated to 192.168.20.1
+#
# upstream (ISP) -- vlan 1 -- router -- vlan 2 (trunk) -- main / iot / cams
{ self, pkgs }:
let
@@ -164,6 +168,32 @@ pkgs.testers.runNixOSTest {
}
];
firewall.zones = [ "lab" ];
+ firewall.nat = [
+ {
+ type = "dnat";
+ from = "trusted";
+ to = "iot";
+ protocol = "tcp";
+ ports = [ 7070 ];
+ toAddress = "192.168.20.100";
+ toPort = 8081;
+ }
+ {
+ type = "masquerade";
+ from = "trusted";
+ to = "iot";
+ protocol = "tcp";
+ ports = [ 8082 ];
+ }
+ {
+ type = "snat";
+ from = "trusted";
+ to = "iot";
+ protocol = "tcp";
+ ports = [ 8083 ];
+ toAddress = "192.168.20.1";
+ }
+ ];
dns.forwarders = [ "203.0.113.1" ];
};
@@ -198,7 +228,22 @@ pkgs.testers.runNixOSTest {
};
};
- iot = host 20;
+ # Echoes the address it sees, to tell translated connections from plain ones.
+ iot =
+ { ... }:
+ {
+ imports = [ (host 20) ];
+ services.nginx = {
+ enable = true;
+ virtualHosts."ports" = {
+ listen = map (port: {
+ addr = "0.0.0.0";
+ inherit port;
+ }) [ 8081 8082 8083 ];
+ locations."/".return = ''200 "$remote_addr"'';
+ };
+ };
+ };
cams = host 30;
};
@@ -208,6 +253,7 @@ pkgs.testers.runNixOSTest {
upstream.wait_for_unit("dnsmasq.service")
upstream.wait_for_unit("nginx.service")
main.wait_for_unit("nginx.service")
+ iot.wait_for_unit("nginx.service")
with subtest("router is up with VLAN devices on the trunk"):
router.wait_for_unit("network-online.target")
@@ -269,6 +315,18 @@ pkgs.testers.runNixOSTest {
host.fail("nc -z -w 2 -p 50000 192.168.10.100 9090")
host.fail("nc -z -w 2 -p 40005 192.168.10.150 9090")
+ with subtest("a port forward from a LAN keeps the source address"):
+ out = main.succeed("curl -sf --max-time 5 http://192.168.10.1:7070/").strip()
+ assert out == "192.168.10.100", out
+ # Not addressed to the router: not forwarded.
+ main.fail("curl -sf --max-time 3 http://192.168.10.1:7071/")
+
+ with subtest("masquerade and source NAT rewrite the source between zones"):
+ assert main.succeed("curl -sf --max-time 5 http://192.168.20.100:8081/").strip() == "192.168.10.100"
+ assert main.succeed("curl -sf --max-time 5 http://192.168.20.100:8082/").strip() == "192.168.20.1"
+ assert main.succeed("curl -sf --max-time 5 http://192.168.20.100:8083/").strip() == "192.168.20.1"
+ router.succeed("nft list ruleset | grep -q 'snat to 192.168.20.1'")
+
with subtest("a zone without interfaces does not break the ruleset"):
router.succeed("nft list ruleset | grep -q 'sport 40000-40010'")
router.succeed("systemctl is-active nftables.service")
diff --git a/tests/webui.nix b/tests/webui.nix
index b3e7ca5..389de72 100644
--- a/tests/webui.nix
+++ b/tests/webui.nix
@@ -137,12 +137,12 @@ pkgs.testers.runNixOSTest {
assert post("/firewall", {"csrf": csrf("/firewall"), **rule}) == "422"
with subtest("a port forward is saved and listed"):
- fwd = {"forwards": "1", "fwd.0.protocol": "tcp", "fwd.0.ports": "8080", "fwd.0.to": "main",
- "fwd.0.destination": "192.168.10.5", "fwd.0.nat": "on"}
+ fwd = {"nats": "1", "nat.0.type": "dnat", "nat.0.from": "wan", "nat.0.protocol": "tcp", "nat.0.ports": "8080",
+ "nat.0.to": "main", "nat.0.toaddress": "192.168.10.5", "nat.0.allow": "on"}
assert post("/firewall", {"csrf": csrf("/firewall"), **fwd}) == "303"
assert 'value="192.168.10.5"' in curl(f"{ui}/firewall")
saved = router.succeed(f"cat {cfg}")
- assert '"dnat": true' in saved and '"from": "wan"' in saved, saved
+ assert '"type": "dnat"' in saved and '"from": "wan"' in saved and '"natOnly": true' in saved, saved
with subtest("dynamic DNS is saved with its secret kept out of the settings"):
dd = {"enabled": "on", "protocol": "cloudflare", "username": "token", "zone": "example.org",