{ config, lib, ... }: let cfg = config.router; lans = lib.attrValues cfg.lans; set = ifaces: "{ ${lib.concatMapStringsSep ", " (i: ''"${i}"'') ifaces} }"; wgs = lib.attrValues cfg.wireguard; isV6 = lib.hasInfix ":"; # Everything that is a firewall zone member: LANs and tunnels. members = map (l: { inherit (l) device zone internet allowManagement ; }) lans ++ map (w: { device = w.interface; inherit (w) zone internet allowManagement; }) wgs; devicesOf = pred: map (m: m.device) (lib.filter pred members); lanIfaces = map (l: l.device) lans; memberIfaces = devicesOf (_: true); dnsIfaces = lanIfaces ++ map (w: w.interface) (lib.filter (w: w.dns) wgs); mgmtIfaces = devicesOf (m: m.allowManagement); internetIfaces = devicesOf (m: m.internet); listenPorts = lib.unique (map (w: w.listenPort) (lib.filter (w: w.listenPort != null) wgs)); masqueradeIfaces = map (w: w.interface) (lib.filter (w: w.masquerade) wgs); # Destinations a kill-switched tunnel must never leak to an uplink. killSwitched = lib.filter (w: w.killSwitch) wgs; killNets = lib.concatMap (w: lib.concatMap (p: p.allowedIPs) (lib.attrValues w.peers)) killSwitched; killRules = let v4 = lib.filter (n: !isV6 n) killNets; v6 = lib.filter isV6 killNets; list = ns: "{ ${lib.concatStringsSep ", " ns} }"; in lib.optional (v4 != [ ]) "oifname ${set uplinks} ip daddr ${list v4} drop" ++ lib.optional (v6 != [ ]) "oifname ${set uplinks} ip6 daddr ${list v6} drop"; # zone name -> devices of its members zones = lib.mapAttrs (_: ms: map (m: m.device) ms) (lib.groupBy (m: m.zone) members); zoneDevices = zone: zones.${zone} or [ ]; # Members of one zone may forward to each other, but never back out of the # device they came in on (a tunnel's peers do not see each other). intraZone = lib.concatLists ( lib.mapAttrsToList ( zone: devs: lib.optionals (lib.length devs > 1) ( map ( d: "iifname ${set [ d ]} oifname ${set (lib.filter (x: x != d) devs)} accept # zone ${zone}" ) devs ) ) zones ); 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 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 "ip"; in 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"; # IPv4 leaves via the PPP device, IPv6 via the physical uplink. # When the physical link also carries LANs, only the PPP device is an uplink. sharedLink = lib.elem cfg.wan.interface (map (l: l.interface) lans); uplinks = lib.unique ([ uplink4 ] ++ lib.optional (!sharedLink) cfg.wan.interface); mss = cfg.wan.ipv4.pppoe.mtu - 40; mss6 = cfg.wan.ipv4.pppoe.mtu - 60; v6ppp = cfg.wan.ipv6.enable && cfg.wan.ipv6.transport == "ppp"; # Where DHCPv6 replies arrive. v6dev = if v6ppp then uplink4 else cfg.wan.interface; dhcpIfaces = map (l: l.device) (lib.filter (l: l.dhcp.enable) lans); in { config = lib.mkIf cfg.enable { # The generated ruleset replaces networking.firewall entirely. networking.firewall.enable = false; networking.nftables.enable = true; networking.nftables.tables.router = { family = "inet"; content = '' chain input { type filter hook input priority filter; policy drop; iifname "lo" accept ct state established,related accept ct state invalid drop # Error types and ND are required on every interface (WAN included); # echo is only answered on LANs. icmp type { destination-unreachable, time-exceeded, parameter-problem } accept icmpv6 type { destination-unreachable, packet-too-big, time-exceeded, parameter-problem, nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert } accept iifname ${set memberIfaces} icmp type echo-request accept iifname ${set memberIfaces} icmpv6 type echo-request accept ${lib.optionalString (!pppoe) ''iifname "${cfg.wan.interface}" udp dport 68 accept''} ${lib.optionalString cfg.wan.ipv6.enable ''iifname "${v6dev}" udp dport 546 accept''} iifname ${set dnsIfaces} udp dport 53 accept iifname ${set dnsIfaces} tcp dport 53 accept ${lib.optionalString (listenPorts != [ ]) "iifname ${set uplinks} udp dport { ${ lib.concatMapStringsSep ", " toString listenPorts } } accept # wireguard" } ${lib.optionalString (dhcpIfaces != [ ]) "iifname ${set dhcpIfaces} udp dport 67 accept"} ${lib.optionalString (mgmtIfaces != [ ]) "iifname ${set mgmtIfaces} tcp dport { ${ lib.concatMapStringsSep ", " toString ([ 22 ] ++ config.router.managementPorts) } } accept"} } chain forward { type filter hook forward priority filter; policy drop; ${lib.concatStringsSep "\n " killRules} ct state established,related accept ct state invalid drop icmpv6 type { destination-unreachable, packet-too-big, time-exceeded, parameter-problem } accept ${lib.optionalString pppoe '' # Clamp MSS to the PPP MTU in both directions (never raised). iifname "${uplink4}" meta nfproto ipv4 tcp flags syn tcp option maxseg size > ${toString mss} tcp option maxseg size set ${toString mss} oifname "${uplink4}" meta nfproto ipv4 tcp flags syn tcp option maxseg size > ${toString mss} tcp option maxseg size set ${toString mss} ${lib.optionalString v6ppp '' iifname "${uplink4}" meta nfproto ipv6 tcp flags syn tcp option maxseg size > ${toString mss6} tcp option maxseg size set ${toString mss6} oifname "${uplink4}" meta nfproto ipv6 tcp flags syn tcp option maxseg size > ${toString mss6} tcp option maxseg size set ${toString mss6} ''} ''} ${lib.optionalString ( internetIfaces != [ ] ) "iifname ${set internetIfaces} oifname ${set uplinks} accept"} # 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 )} } ''; }; 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"} } ''; }; services.openssh = { enable = true; openFirewall = false; settings = { PasswordAuthentication = false; KbdInteractiveAuthentication = false; PermitRootLogin = "prohibit-password"; }; }; }; }