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