diff --git a/PLAN.md b/PLAN.md index a23e296..f5e1988 100644 --- a/PLAN.md +++ b/PLAN.md @@ -79,7 +79,7 @@ Learned along the way: - In NixOS tests `fail(...)` also passes on "command not found": install probe tools explicitly and add a positive control per negative check. - Unbound answers `.test` locally (RFC 6761) and validates DNSSEC; tests using fake domains need both `local-zone ... transparent` and `domain-insecure` (both verified necessary; set in the test, not the module). - `modules/router/lib.nix` has IPv4 helpers (parse/format/netmask/cidr/overlap). `ipv4.subnet` is now derived from `address` + `prefixLength` (read-only). Assertions also cover pool bounds, pool containing the router address, and overlapping LANs. IPv6 helpers (prefix arithmetic for PD subnet-IDs) are still to do. -- Checks: `basic`, `pppoe`, `vlans` and `wireguard` (VM tests), `lib` (helper unit tests), `assertions` (broken configs must fail with exact messages). Not yet covered: port forwards, WAN-originated rules, NPTv6. +- Checks: `basic`, `pppoe`, `vlans` and `wireguard` (VM tests), `lib` (helper unit tests), `assertions` (broken configs must fail with exact messages). Not yet covered: NPTv6. ## IPv6 design (confirmed) @@ -105,6 +105,14 @@ 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) + +- 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). +- 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). + ## WireGuard (confirmed) - **Use cases**: road warrior (listening, dual-stack), site-to-site (no transfer network), and a provider VPN the router dials into (dual-stack transfer net, masquerade). All via `router.wireguard.`. diff --git a/modules/router/assertions.nix b/modules/router/assertions.nix index d8e8a3f..1e75f0d 100644 --- a/modules/router/assertions.nix +++ b/modules/router/assertions.nix @@ -96,25 +96,46 @@ let }) (lib.filter (l: l.zone == zone && validIp l.ipv4.address) lans) ++ lib.concatMap wgV4Nets (lib.filter (w: w.zone == zone) wgs); + portOk = + p: + if builtins.isInt p then + p >= 1 && p <= 65535 + else + let + m = builtins.match "([0-9]+)-([0-9]+)" p; + lo = lib.toInt (builtins.elemAt m 0); + hi = lib.toInt (builtins.elemAt m 1); + in + lo >= 1 && hi <= 65535 && lo < hi; + ruleAssertions = r: let where = "router.firewall.rules (${r.from} -> ${r.to})"; - dest = if r.destination == null then null else ipv4.parseNet r.destination; + fromWan = r.from == "wan"; + v6Dest = r.destination != null && isV6 r.destination; + dest = if r.destination == null || v6Dest then null else ipv4.parseNet r.destination; inZone = dest != null && lib.any (n: dest.prefixLength >= n.prefixLength && ipv4.inSubnet n dest.address) ( zoneV4Nets r.to ); + singleHost = dest != null && dest.prefixLength == 32; + plainPorts = lib.all builtins.isInt r.ports; + internetMember = lib.any (l: l.internet) lans || lib.any (w: w.internet) wgs; in [ { - assertion = lib.elem r.from zoneNames; - message = "router: ${where}: unknown source zone `${r.from}` (zones: ${lib.concatStringsSep ", " zoneNames})."; + assertion = fromWan || lib.elem r.from zoneNames; + message = "router: ${where}: unknown source zone `${r.from}` (zones: wan, ${lib.concatStringsSep ", " zoneNames})."; } { assertion = lib.elem r.to zoneNames; - message = "router: ${where}: unknown destination zone `${r.to}` (zones: ${lib.concatStringsSep ", " zoneNames})."; + message = + if r.to == "wan" then + "router: ${where}: `wan` cannot be a destination; use `internet = true` on the LAN or tunnel." + else + "router: ${where}: unknown destination zone `${r.to}` (zones: ${lib.concatStringsSep ", " zoneNames})."; } { assertion = r.from != r.to; @@ -124,20 +145,93 @@ let assertion = r.ports == [ ] || r.protocol == "tcp" || r.protocol == "udp"; message = "router: ${where}: ports are only valid with protocol tcp or udp."; } + { + assertion = lib.all portOk r.ports; + message = "router: ${where}: a port or port range is out of range (1-65535, ranges need low < high)."; + } + { + assertion = lib.all validCidr ( + map ( + x: + if lib.hasInfix "/" x then + x + else if isV6 x then + "${x}/128" + else + "${x}/32" + ) r.sources + ); + message = "router: ${where}: every source must be an IPv4/IPv6 address or CIDR."; + } + ] + # Rules from the outside must not be able to expose a whole zone by accident. + ++ lib.optionals fromWan [ + { + assertion = r.destination != null; + message = "router: ${where}: rules from `wan` must name a destination host or network."; + } + { + assertion = r.protocol != "any"; + message = "router: ${where}: rules from `wan` must name a protocol (tcp, udp or icmp)."; + } + { + assertion = r.protocol == "icmp" || r.protocol == "any" || r.ports != [ ]; + message = "router: ${where}: rules from `wan` must list the ports they open."; + } ] ++ lib.optionals (r.destination != null) [ { - assertion = dest != null; + assertion = v6Dest || dest != null; message = "router: ${where}: destination `${r.destination}` is not a valid IPv4 address or CIDR."; } { - assertion = dest == null || !(lib.elem r.to zoneNames) || inZone; + assertion = v6Dest || dest == null || !(lib.elem r.to zoneNames) || inZone; message = "router: ${where}: destination `${r.destination}` is not inside any network of zone `${r.to}`."; } { - assertion = dest == null || ipv4.networkOf dest.address dest.prefixLength == dest.address; + assertion = v6Dest || dest == null || ipv4.networkOf dest.address dest.prefixLength == dest.address; message = "router: ${where}: destination `${r.destination}` has host bits set."; } + { + assertion = + !v6Dest + || v6Cidr (if lib.hasInfix "/" r.destination then r.destination else "${r.destination}/128"); + message = "router: ${where}: destination `${r.destination}` is not a valid IPv6 address or CIDR."; + } + ] + ++ lib.optionals r.dnat [ + { + assertion = fromWan; + message = "router: ${where}: dnat is only valid for rules from `wan`."; + } + { + assertion = singleHost; + message = "router: ${where}: dnat needs a single IPv4 host as destination."; + } + { + assertion = r.protocol == "tcp" || r.protocol == "udp"; + message = "router: ${where}: dnat needs protocol tcp or udp."; + } + { + assertion = lib.all (x: !isV6 x) r.sources; + message = "router: ${where}: dnat is IPv4-only, so its sources must be IPv4."; + } + ] + ++ lib.optionals (r.toPort != null) [ + { + assertion = r.dnat && lib.length r.ports == 1 && plainPorts; + message = "router: ${where}: toPort needs dnat and exactly one (non-range) port."; + } + ] + ++ lib.optionals r.hairpin [ + { + assertion = r.dnat; + message = "router: ${where}: hairpin only applies to dnat rules."; + } + { + assertion = internetMember; + message = "router: ${where}: hairpin needs at least one LAN or tunnel with internet = true."; + } ]; overlapsLan = n: lib.any (l: ipv4.overlaps n l) lanNets; @@ -246,6 +340,12 @@ in ] ++ lib.concatLists (lib.mapAttrsToList perLan lansByName) ++ lib.concatMap ruleAssertions cfg.firewall.rules + ++ [ + { + assertion = !(lib.elem "wan" zoneNames); + message = "router: `wan` is a reserved zone name; give the LAN or tunnel a different `zone`."; + } + ] ++ lib.concatLists (lib.mapAttrsToList wgAssertions cfg.wireguard) ++ [ { diff --git a/modules/router/firewall.nix b/modules/router/firewall.nix index 9cfdfaf..e47c6f3 100644 --- a/modules/router/firewall.nix +++ b/modules/router/firewall.nix @@ -60,21 +60,121 @@ let ) zones ); - ruleToNft = + setOf = xs: "{ ${lib.concatMapStringsSep ", " toString xs} }"; + + # Layer 4 match; `ports` may be ints or "a-b" ranges. + l4 = + proto: ports: + if proto == "any" then + "" + else if proto == "icmp" then + "meta l4proto { icmp, ipv6-icmp } " + else + "${proto} dport ${if ports == [ ] then "1-65535" else setOf ports} "; + + # Destination match, by family of the address. + daddrOf = + r: + lib.optionalString (r.destination != null) ( + "${if isV6 r.destination then "ip6" else "ip"} daddr ${r.destination} " + ); + + # One variant per source family; no sources means one unrestricted variant. + # `key` is the family selector used for per-source rate limiting (null: both). + variantsOf = r: let - dest = lib.optionalString (r.destination != null) "ip daddr ${r.destination} "; - proto = - if r.protocol == "any" then - "" - else if r.protocol == "icmp" then - "meta l4proto { icmp, ipv6-icmp } " + v4 = lib.filter (x: !isV6 x) r.sources; + v6 = lib.filter isV6 r.sources; + destFamily = + if r.destination == null then + null + else if isV6 r.destination then + "ip6" else - "${r.protocol} dport ${ - if r.ports == [ ] then "1-65535" else "{ ${lib.concatMapStringsSep ", " toString r.ports} }" - } "; + "ip"; in - "iifname ${set (zoneDevices r.from)} oifname ${set (zoneDevices r.to)} ${dest}${proto}accept # ${r.from} -> ${r.to}"; + if r.sources == [ ] then + [ + { + src = ""; + key = destFamily; + } + ] + else + lib.optional (v4 != [ ]) { + src = "ip saddr ${setOf v4} "; + key = "ip"; + } + ++ lib.optional (v6 != [ ]) { + src = "ip6 saddr ${setOf v6} "; + key = "ip6"; + }; + + # Everything one rule contributes. Indexed so meters/prefixes are unique. + ruleParts = + i: r: + let + id = toString i; + label = "${r.from}->${r.to}"; + fromDevs = if r.from == "wan" then uplinks else zoneDevices r.from; + toDevs = zoneDevices r.to; + # After DNAT the packet carries the translated port. + fwdPorts = if r.dnat && r.toPort != null then [ r.toPort ] else r.ports; + base = "iifname ${set fromDevs} oifname ${set toDevs} ${daddrOf r}${l4 r.protocol fwdPorts}${lib.optionalString r.dnat "ct status dnat "}"; + logPrefix = what: ''log prefix "router fwd#${id} ${label} ${what}: " ''; + limitLines = + v: + lib.optionals (r.rateLimit != null) ( + map + ( + k: + "${base}${v.src}ct state new meter rl${id}-${k} { ${k} saddr limit rate over ${r.rateLimit} } ${lib.optionalString r.log (logPrefix "rate-limited")}drop" + ) + ( + if v.key == null then + [ + "ip" + "ip6" + ] + else + [ v.key ] + ) + ); + acceptLines = + v: + lib.optional r.log "${base}${v.src}ct state new ${logPrefix "accepted"}" + ++ [ "${base}${v.src}accept # ${label}" ]; + dnatTarget = "dnat to ${r.destination}${ + lib.optionalString (r.toPort != null) ":${toString r.toPort}" + }"; + in + { + forward = lib.concatMap (v: limitLines v ++ acceptLines v) (variantsOf r); + prerouting = + lib.optionals r.dnat ( + map (v: "iifname ${set uplinks} ${v.src}${l4 r.protocol r.ports}${dnatTarget} # ${label}") ( + variantsOf r + ) + ) + # NAT loopback: internal clients addressing the router's public address. + ++ + lib.optional (r.dnat && r.hairpin) + "iifname ${set internetIfaces} fib daddr type local ip daddr != ${setOf internalAddrs} ${l4 r.protocol r.ports}${dnatTarget} # hairpin ${label}"; + hairpinForward = lib.optional (r.dnat && r.hairpin) ( + "iifname ${set internetIfaces} oifname ${set toDevs} ip daddr ${r.destination} ${l4 r.protocol fwdPorts}ct status dnat accept # hairpin ${label}" + ); + }; + + parts = lib.imap0 ruleParts cfg.firewall.rules; + anyHairpin = lib.any (r: r.dnat && r.hairpin) cfg.firewall.rules; + # Addresses the router itself holds on internal interfaces; anything else + # that is local is a public (uplink) address. + internalAddrs = + map (l: l.ipv4.address) lans + ++ map (a: builtins.head (lib.splitString "/" a)) ( + lib.filter (a: !isV6 a) (lib.concatMap (w: w.addresses) wgs) + ); uplink4 = cfg.wan.ipv4.interface; pppoe = cfg.wan.ipv4.type == "pppoe"; @@ -146,7 +246,9 @@ in ) "iifname ${set internetIfaces} oifname ${set uplinks} accept"} # Zones: free inside a zone, denied between zones unless a rule says so. - ${lib.concatStringsSep "\n " (intraZone ++ map ruleToNft cfg.firewall.rules)} + ${lib.concatStringsSep "\n " ( + intraZone ++ lib.concatMap (p: p.hairpinForward ++ p.forward) parts + )} } ''; }; @@ -154,9 +256,15 @@ in networking.nftables.tables.router-nat = { family = "ip"; content = '' + chain prerouting { + type nat hook prerouting priority dstnat; policy accept; + ${lib.concatStringsSep "\n " (lib.concatMap (p: p.prerouting) parts)} + } + chain postrouting { type nat hook postrouting priority srcnat; policy accept; oifname "${uplink4}" masquerade + ${lib.optionalString anyHairpin "ct status dnat iifname ${set memberIfaces} oifname ${set memberIfaces} masquerade # hairpin"} ${lib.optionalString ( masqueradeIfaces != [ ] ) "oifname ${set masqueradeIfaces} masquerade # tunnels"} diff --git a/modules/router/options.nix b/modules/router/options.nix index f1c0c32..5fc8725 100644 --- a/modules/router/options.nix +++ b/modules/router/options.nix @@ -317,14 +317,18 @@ in description = '' Allow rules between zones, on top of the default (deny between zones). Rules are one-directional; return traffic is allowed by connection - tracking. Both ends must be zones of LANs defined in `router.lans`. + tracking. `to` must be the zone of a LAN or tunnel; `from` may also be + the reserved zone `wan` (traffic arriving on the uplinks), which + publishes services to the outside: such rules must name a + `destination` and protocol (and ports for tcp/udp), so a typo cannot + expose a whole zone. ''; type = types.listOf ( types.submodule { options = { from = mkOption { type = types.str; - description = "Source zone."; + description = "Source zone, or `wan`."; }; to = mkOption { type = types.str; @@ -340,9 +344,17 @@ in default = "any"; }; ports = mkOption { - type = types.listOf types.port; + type = types.listOf (types.either types.port (types.strMatching "[0-9]+-[0-9]+")); default = [ ]; - description = "Destination ports (tcp/udp only); empty means all ports."; + example = [ + 443 + "50000-50100" + ]; + description = '' + Destination ports or ranges (tcp/udp only); empty means all + ports. With `dnat` these are the ports on the router's + public address. + ''; }; destination = mkOption { type = types.nullOr types.str; @@ -350,9 +362,50 @@ in example = "192.168.10.5"; description = '' Restrict the rule to an IPv4 address or CIDR inside the - destination zone; null means the whole zone. + destination zone; null means the whole zone. IPv6 addresses + are accepted too (for pinholes) but cannot be checked against + the zone, because delegated prefixes are not known at build time. ''; }; + sources = mkOption { + type = types.listOf types.str; + default = [ ]; + example = [ "198.51.100.0/24" ]; + description = "Only match traffic from these IPv4/IPv6 addresses or CIDRs; empty means any source."; + }; + dnat = mkOption { + type = types.bool; + default = false; + description = '' + Port forward: traffic from `wan` to `ports` on the router's + public IPv4 address is redirected to `destination` (one IPv4 + host). The source address is preserved. + ''; + }; + toPort = mkOption { + type = types.nullOr types.port; + default = null; + description = "Port on `destination` when it differs from the public port (single-port forwards only)."; + }; + hairpin = mkOption { + type = types.bool; + default = false; + description = '' + Let LANs with `internet = true` reach this forward through the + router's public address (NAT loopback, source is masqueraded). + ''; + }; + rateLimit = mkOption { + type = types.nullOr (types.strMatching "[0-9]+/(second|minute|hour)"); + default = null; + example = "10/minute"; + description = "Limit new connections per source address; the excess is dropped."; + }; + log = mkOption { + type = types.bool; + default = false; + description = "Log new connections accepted by this rule, and connections dropped by `rateLimit` (kernel log)."; + }; }; } ); diff --git a/tests/assertions.nix b/tests/assertions.nix index 92f38b9..1d9c9d3 100644 --- a/tests/assertions.nix +++ b/tests/assertions.nix @@ -506,6 +506,222 @@ let }; expect = [ "is not inside any network of zone `s2s`" ]; }; + wanValid = { + router = { + lans.main = lan { internet = true; }; + firewall.rules = [ + { + from = "wan"; + to = "main"; + destination = "192.168.10.5"; + protocol = "tcp"; + ports = [ 8080 ]; + dnat = true; + toPort = 8081; + sources = [ "198.51.100.0/24" ]; + rateLimit = "3/minute"; + log = true; + hairpin = true; + } + { + from = "wan"; + to = "main"; + destination = "2001:db8:1::5"; + protocol = "tcp"; + ports = [ "9000-9002" ]; + } + ]; + }; + expect = [ ]; + }; + wanNoDestination = { + router = { + lans.main = lan { }; + firewall.rules = [ + { + from = "wan"; + to = "main"; + destination = null; + protocol = "tcp"; + ports = [ 8080 ]; + } + ]; + }; + expect = [ "must name a destination" ]; + }; + wanAnyProtocol = { + router = { + lans.main = lan { }; + firewall.rules = [ + { + from = "wan"; + to = "main"; + destination = "192.168.10.5"; + protocol = "any"; + ports = [ ]; + } + ]; + }; + expect = [ "must name a protocol" ]; + }; + wanNoPorts = { + router = { + lans.main = lan { }; + firewall.rules = [ + { + from = "wan"; + to = "main"; + destination = "192.168.10.5"; + protocol = "tcp"; + ports = [ ]; + } + ]; + }; + expect = [ "must list the ports" ]; + }; + toWan = { + router = { + lans.main = lan { }; + firewall.rules = [ + { + from = "main"; + to = "wan"; + } + ]; + }; + expect = [ "`wan` cannot be a destination" ]; + }; + dnatNeedsHost = { + router = { + lans.main = lan { }; + firewall.rules = [ + { + from = "wan"; + to = "main"; + destination = "192.168.10.0/24"; + protocol = "tcp"; + ports = [ 8080 ]; + dnat = true; + } + ]; + }; + expect = [ "dnat needs a single IPv4 host" ]; + }; + dnatV6Sources = { + router = { + lans.main = lan { }; + firewall.rules = [ + { + from = "wan"; + to = "main"; + destination = "192.168.10.5"; + protocol = "tcp"; + ports = [ 8080 ]; + dnat = true; + sources = [ "2001:db8::/32" ]; + } + ]; + }; + expect = [ "its sources must be IPv4" ]; + }; + toPortWithoutDnat = { + router = { + lans.main = lan { }; + firewall.rules = [ + { + from = "wan"; + to = "main"; + destination = "192.168.10.5"; + protocol = "tcp"; + ports = [ 8080 ]; + toPort = 8081; + } + ]; + }; + expect = [ "toPort needs dnat" ]; + }; + hairpinNoDnat = { + router = { + lans.main = lan { }; + firewall.rules = [ + { + from = "wan"; + to = "main"; + destination = "192.168.10.5"; + protocol = "tcp"; + ports = [ 8080 ]; + hairpin = true; + } + ]; + }; + expect = [ + "hairpin only applies to dnat" + "hairpin needs at least one" + ]; + }; + hairpinNoInternet = { + router = { + lans.main = lan { }; + firewall.rules = [ + { + from = "wan"; + to = "main"; + destination = "192.168.10.5"; + protocol = "tcp"; + ports = [ 8080 ]; + dnat = true; + hairpin = true; + } + ]; + }; + expect = [ "hairpin needs at least one" ]; + }; + badPortRange = { + router = { + lans.main = lan { }; + firewall.rules = [ + { + from = "wan"; + to = "main"; + destination = "192.168.10.5"; + protocol = "tcp"; + ports = [ "9002-9000" ]; + } + ]; + }; + expect = [ "out of range" ]; + }; + badSource = { + router = { + lans.main = lan { }; + firewall.rules = [ + { + from = "wan"; + to = "main"; + destination = "192.168.10.5"; + protocol = "tcp"; + ports = [ 8080 ]; + sources = [ "nonsense" ]; + } + ]; + }; + expect = [ "every source must be" ]; + }; + badV6Destination = { + router = { + lans.main = lan { }; + firewall.rules = [ + { + from = "wan"; + to = "main"; + destination = "2001:db8::zz"; + protocol = "tcp"; + ports = [ 8080 ]; + } + ]; + }; + expect = [ "not a valid IPv6" ]; + }; nonOverlappingLans = { router.lans = { a = lan { }; @@ -552,6 +768,7 @@ let "vlansOnOneTrunk" "ruleValid" "wgValid" + "wanValid" ]; subnet = diff --git a/tests/basic.nix b/tests/basic.nix index 5a43fb3..4c5fc6b 100644 --- a/tests/basic.nix +++ b/tests/basic.nix @@ -21,6 +21,9 @@ let ispV4 = if pppoe then "198.51.100.1" else "203.0.113.1"; routerV4 = if pppoe then "198.51.100.100" else "203.0.113.100"; pppoeVlan = 7; + clientV4 = "192.168.10.100"; + clientV6 = "2001:db8:2::1234"; + peerV4 = "192.168.10.51"; inherit (pkgs) lib; # Indent every line but the first (the interpolation site supplies that one). @@ -270,6 +273,51 @@ pkgs.testers.runNixOSTest { }; }; dns.forwarders = [ ispV4 ]; + firewall.rules = + let + fwd = + r: + { + from = "wan"; + to = "main"; + destination = clientV4; + protocol = "tcp"; + dnat = true; + } + // 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 @@ -281,17 +329,72 @@ pkgs.testers.runNixOSTest { }; }; - client = { lib, ... }: { - virtualisation.vlans = [ 2 ]; - networking.useNetworkd = true; - networking.useDHCP = false; - networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ]; - systemd.network.networks."10-lan" = { - matchConfig.Name = "eth1"; - networkConfig.DHCP = "ipv4"; + client = + { lib, ... }: + { + virtualisation.vlans = [ 2 ]; + networking.useNetworkd = true; + networking.useDHCP = false; + networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ]; + systemd.network.networks."10-lan" = { + matchConfig.Name = "eth1"; + networkConfig.DHCP = "ipv4"; + # Fixed address inside the delegated /64: the IPv6 pinhole target. + address = [ "${clientV6}/64" ]; + }; + networking.firewall.enable = false; + + # Every listener echoes the peer address it sees and its own port. + services.nginx = { + enable = true; + virtualHosts.ports = { + listen = + map + (port: { + addr = "0.0.0.0"; + inherit port; + }) + [ + 8080 + 8084 + 9000 + 9001 + 9002 + 8085 + 8086 + 8087 + ] + ++ + map + (port: { + addr = "[::]"; + inherit port; + }) + [ + 8081 + 8082 + 8080 + ]; + locations."/".return = ''200 "$remote_addr $server_port"''; + }; + }; + }; + + # A second LAN host, outside the DHCP pool. + peer = + { lib, ... }: + { + virtualisation.vlans = [ 2 ]; + networking.useNetworkd = true; + networking.useDHCP = false; + networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ]; + systemd.network.networks."10-lan" = { + matchConfig.Name = "eth1"; + address = [ "${peerV4}/24" ]; + gateway = [ "192.168.10.1" ]; + }; + networking.firewall.enable = false; }; - networking.firewall.enable = false; - }; }; testScript = '' @@ -340,7 +443,7 @@ pkgs.testers.runNixOSTest { with subtest("client autoconfigures a global address from the delegated prefix"): client.wait_until_succeeds("ip -6 addr show dev eth1 scope global | grep -q 2001:db8:2:") client.wait_until_succeeds("ip -6 route show default | grep -q 'dev eth1'") - client_v6 = client.succeed("ip -6 -o addr show dev eth1 scope global | awk '{print $4}' | cut -d/ -f1 | head -1").strip() + client_v6 = client.succeed("ip -6 -o addr show dev eth1 scope global | awk '{print $4}' | cut -d/ -f1 | grep -v ::1234 | head -1").strip() with subtest("IPv6 is routed without NAT"): client.wait_until_succeeds("ping -6 -c 1 -W 2 2001:db8:1::1") @@ -358,5 +461,56 @@ pkgs.testers.runNixOSTest { with subtest("LAN can reach SSH and ping on the router"): client.succeed("nc -z -w 2 192.168.10.1 22") client.succeed("ping -c 1 -W 2 192.168.10.1") + + with subtest("port forward keeps the source address and honours toPort"): + for _ in range(3): + # Positive control: the listener answers on the LAN. + peer.wait_until_succeeds("curl -sf --max-time 5 http://${clientV4}:8080/") + out = upstream.succeed("curl -sf --max-time 5 http://${routerV4}:8080/") + assert out.strip() == "${ispV4} 8080", out + out = upstream.succeed("curl -sf --max-time 5 http://${routerV4}:8083/") + assert out.strip() == "${ispV4} 8084", out + # The translated port is not exposed directly. + upstream.fail("curl -sf --max-time 3 http://${routerV4}:8084/") + + with subtest("port ranges open exactly their ports"): + for port in (9000, 9001, 9002): + out = upstream.succeed(f"curl -sf --max-time 5 http://${routerV4}:{port}/") + assert out.strip() == f"${ispV4} {port}", out + upstream.fail("curl -sf --max-time 3 http://${routerV4}:9003/") + + with subtest("source allow-list admits matching clients only"): + out = upstream.succeed("curl -sf --max-time 5 http://${routerV4}:8085/") + assert out.strip() == "${ispV4} 8085", out + upstream.fail("curl -sf --max-time 3 http://${routerV4}:8086/") + + with subtest("per-source rate limit drops excess new connections"): + ok = 0 + for _ in range(12): + status, _out = upstream.execute("curl -sf --max-time 2 http://${routerV4}:8087/") + ok += status == 0 + assert 0 < ok < 12, f"expected some but not all connections to pass, got {ok}/12" + + with subtest("IPv6 reaches only the explicit pinholes"): + out = upstream.succeed("curl -sf -g --max-time 5 'http://[${clientV6}]:8081/'") + assert out.strip() == "2001:db8:1::1 8081", out + # The listener exists, but there is no pinhole for it. + client.succeed("nc -6 -z -w 2 ${clientV6} 8082") + upstream.fail("curl -sf -g --max-time 3 'http://[${clientV6}]:8082/'") + # Port 8080 is forwarded for IPv4 only. + client.succeed("nc -6 -z -w 2 ${clientV6} 8080") + upstream.fail("curl -sf -g --max-time 3 'http://[${clientV6}]:8080/'") + + with subtest("hairpin NAT works for forwards that enable it"): + out = peer.succeed("curl -sf --max-time 5 http://${routerV4}:8080/") + # Masqueraded: the server sees the router, not the peer. + assert out.strip() == "192.168.10.1 8080", out + # No hairpin on the range forward: the router does not translate it. + peer.fail("curl -sf --max-time 3 http://${routerV4}:9001/") + + with subtest("logging rules leave a trace in the kernel log"): + klog = router.succeed("journalctl -k --no-pager | grep 'router fwd#'") + assert "accepted: " in klog, klog + assert "rate-limited: " in klog, klog ''; }