# Router firewall/NAT policy (nftables). Trust model: # mgmt VLAN -> trusted: router services, all VLANs, WAN # other VLANs -> DNS/DHCP on the router + WAN (if allowWan); no inter-VLAN # WAN (ppp0) -> nothing inbound beyond established/related # mesh -> admin SSH + metrics scrapes (same trust boundary as the fleet) # staging -> admin SSH only inbound (pre-cutover uplink into the old LAN); # allowWan VLANs are NATed out through it while ppp0 is down { settings }: { lib, ... }: let cfg = settings; vlanIfs = lib.mapAttrsToList (name: _: "vlan-${name}") cfg.vlans; wanVlanIfs = lib.mapAttrsToList (name: _: "vlan-${name}") ( lib.filterAttrs (_: vlan: vlan.allowWan) cfg.vlans ); nonMgmtIfs = lib.filter (i: i != "vlan-mgmt") vlanIfs; # allowWan VLANs may also leave through the staging uplink. Same set as # networking.nat.internalInterfaces below, so `allowWan` holds on both # exits. The kernel picks the exit: ppp0 (metric 0, see pppoe.nix) while # the session is up, the staging DHCP route (metric 1024) otherwise. stagingExit = cfg.stagingPort != null && wanVlanIfs != [ ]; wanVlanSet = "{ ${lib.concatMapStringsSep ", " (i: ''"${i}"'') wanVlanIfs} }"; in { networking.nftables.enable = true; # SSH reachable only from the mgmt VLAN (trusted) and the mesh — never # from the WAN or the other VLANs. services.openssh.openFirewall = false; networking.firewall = { enable = true; filterForward = true; trustedInterfaces = [ "vlan-mgmt" ]; # Non-mgmt VLANs may only talk to the router's DNS and DHCP; the staging # uplink (old LAN, pre-cutover) gets admin SSH so the box can be reached # before the mgmt VLAN or the mesh are up. interfaces = lib.genAttrs nonMgmtIfs (_: { allowedTCPPorts = [ 53 ]; allowedUDPPorts = [ 53 67 ]; }) // lib.optionalAttrs (cfg.stagingPort != null) { ${cfg.stagingPort}.allowedTCPPorts = [ 22 ]; }; extraInputRules = '' ip6 saddr ${cfg.mesh.subnet} tcp dport 22 accept comment "admin ssh over the mesh" ip6 saddr ${cfg.mesh.subnet} tcp dport 4000 accept comment "blocky metrics scrape from control" ''; extraForwardRules = '' tcp flags syn tcp option maxseg size set rt mtu comment "MSS clamp for PPPoE mtu 1492" iifname "vlan-mgmt" accept comment "mgmt reaches all VLANs and the WAN" '' + lib.optionalString stagingExit '' iifname ${wanVlanSet} oifname "${cfg.stagingPort}" accept comment "allowWan VLANs out via the staging uplink" ''; }; # networking.nat both masquerades and opens forward-to-WAN for exactly its # internalInterfaces — so this list, not a rule of our own, is where # `allowWan` is enforced. Listing every VLAN here would silently let # allowWan = false VLANs out. networking.nat = { enable = true; externalInterface = "ppp0"; internalInterfaces = wanVlanIfs; }; # networking.nat only masquerades on its single externalInterface; the # staging uplink needs its own postrouting chain (nixos-nat's is # oifname-scoped to ppp0, so the two never both apply). networking.nftables.tables = lib.optionalAttrs stagingExit { router-staging-nat = { family = "ip"; content = '' chain post { type nat hook postrouting priority srcnat; iifname ${wanVlanSet} oifname "${cfg.stagingPort}" masquerade comment "allowWan VLANs out via the staging uplink" } ''; }; }; }