From a58600823f9792d6dd01f2bef0ff1f2dfc3c1a48 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Fri, 30 Oct 2026 15:54:29 +0100 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F --- PLAN.md | 9 +- flake.nix | 1 + migrations/1.jq | 21 ++ modules/router/assertions.nix | 102 ++++++--- modules/router/firewall.nix | 67 ++++-- modules/router/options.nix | 124 +++++++++-- pkgs/router-webui/firewall.go | 107 ++++++---- pkgs/router-webui/firewall_test.go | 136 +++++++----- pkgs/router-webui/forwards.go | 226 -------------------- pkgs/router-webui/nat.go | 241 ++++++++++++++++++++++ pkgs/router-webui/templates/firewall.html | 68 +++--- scripts/webui-dev.sh | 4 +- tests/apply.nix | 2 + tests/assertions.nix | 203 +++++++++++++++--- tests/basic.nix | 112 ++++++---- tests/onestick.nix | 32 ++- tests/update.nix | 2 + tests/vlans.nix | 60 +++++- tests/webui.nix | 6 +- 19 files changed, 1018 insertions(+), 505 deletions(-) create mode 100644 migrations/1.jq delete mode 100644 pkgs/router-webui/forwards.go create mode 100644 pkgs/router-webui/nat.go 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 @@
- + {{range $i, $r := .Rows}} @@ -56,50 +56,62 @@ + + + - {{range $f := list "from" "sources" "sourcePorts" "to" "destination" "ports" "protocol"}}{{with index $e (printf "rule[%d].%s" $i $f)}}{{end}}{{end}} + {{range $f := list "from" "sources" "sourcePorts" "to" "destination" "ports" "protocol" "ratelimit"}}{{with index $e (printf "rule[%d].%s" $i $f)}}{{end}}{{end}} {{end}}
From zoneFrom addressFrom portTo zoneTo addressTo portProtocolRemove
From zoneFrom addressFrom portTo zoneTo addressTo portProtocolForwarded onlyLogRate limitRemove
{{if or $r.From $r.To}}{{end}}
{{$f}}: {{.}}
{{$f}}: {{.}}

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.

- From the internet - + Address translation (NAT) +
- - {{range $i, $r := .Forwards}} + + {{range $i, $r := .NATs}} - - - - - - - - - - - + + + + + + + + + + - {{range $f := list "protocol" "ports" "to" "destination" "toport" "sources" "hairpin" "ratelimit"}}{{with index $e (printf "fwd[%d].%s" $i $f)}}{{end}}{{end}} + {{range $f := list "type" "from" "sources" "destination" "protocol" "ports" "to" "toaddress" "toport" "hairpin"}}{{with index $e (printf "nat[%d].%s" $i $f)}}{{end}}{{end}} {{end}}
ProtocolPublic portTo zoneTo addressTo portOnly fromPort forwardHairpinLogRate limitRemove
TypeFrom zoneFrom addressSent toProtocolPortTo zoneTranslate toTo portHairpinAlso allowRemove
{{if or $r.Ports $r.Destination}}{{end}}{{if or $r.ToAddress $r.Ports}}{{if $r.Allow}}{{else}}{{end}}{{end}}{{if or $r.ToAddress $r.Ports $r.To}}{{end}}
{{$f}}: {{.}}
{{$f}}: {{.}}
-

Everything from the internet is blocked unless listed here. A port forward sends tcp or udp - traffic for the router's public IPv4 address to one host inside (to address is that host; to port only if it differs, - with a single public port); the sender's address is kept. Without it the rule just lets traffic through to an address - or network inside, for IPv6 or for a public range. Hairpin lets your LANs use the forward through the - router's public address. Rate limit caps new connections per sender, like 10/minute. - Fill in the last row to add one.

+

Port forward: traffic from the zone (wan is the internet) addressed to the router + (or to the address in Sent to) on these ports goes to the host in Translate to, in zone To zone; + the sender's address is kept. Also allow adds the filter rule that lets it through (without it the firewall still + blocks it). Hairpin lets your LANs use a forward from the internet through the router's public address.
+ Masquerade and source NAT rewrite where traffic from the zone (or any zone) leaves through + To zone: masquerade with the outgoing interface's address, source NAT with the address in Translate to. + Internet-enabled LANs are already masqueraded out the uplink. Fill in the last row to add one.

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",