{ config, lib, ... }: let cfg = config.router; lans = lib.attrValues cfg.lans; set = ifaces: "{ ${lib.concatMapStringsSep ", " (i: ''"${i}"'') ifaces} }"; lanIfaces = map (l: l.device) lans; mgmtIfaces = map (l: l.device) (lib.filter (l: l.allowManagement) lans); internetIfaces = map (l: l.device) (lib.filter (l: l.internet) lans); # zone name -> devices of its member LANs zones = lib.mapAttrs (_: ls: map (l: l.device) ls) (lib.groupBy (l: l.zone) lans); zoneDevices = zone: zones.${zone} or [ ]; # LANs sharing a zone may forward to each other (a single LAN is plain L2). intraZone = lib.concatLists ( lib.mapAttrsToList ( zone: devs: lib.optional (lib.length devs > 1) "iifname ${set devs} oifname ${set devs} accept # zone ${zone}" ) zones ); ruleToNft = 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 } " else "${r.protocol} dport ${ if r.ports == [ ] then "1-65535" else "{ ${lib.concatMapStringsSep ", " toString r.ports} }" } "; in "iifname ${set (zoneDevices r.from)} oifname ${set (zoneDevices r.to)} ${dest}${proto}accept # ${r.from} -> ${r.to}"; uplink4 = cfg.wan.ipv4.interface; pppoe = cfg.wan.ipv4.type == "pppoe"; # IPv4 leaves via the PPP device, IPv6 via the physical uplink. uplinks = lib.unique [ cfg.wan.interface uplink4 ]; mss = cfg.wan.ipv4.pppoe.mtu - 40; 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 lanIfaces} icmp type echo-request accept iifname ${set lanIfaces} icmpv6 type echo-request accept ${lib.optionalString (!pppoe) ''iifname "${cfg.wan.interface}" udp dport 68 accept''} ${lib.optionalString cfg.wan.ipv6.enable ''iifname "${cfg.wan.interface}" udp dport 546 accept''} iifname ${set lanIfaces} udp dport 53 accept iifname ${set lanIfaces} tcp dport 53 accept ${lib.optionalString (dhcpIfaces != [ ]) "iifname ${set dhcpIfaces} udp dport 67 accept"} ${lib.optionalString (mgmtIfaces != [ ]) "iifname ${set mgmtIfaces} tcp dport 22 accept"} } chain forward { type filter hook forward priority filter; policy drop; 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}" tcp flags syn tcp option maxseg size > ${toString mss} tcp option maxseg size set ${toString mss} oifname "${uplink4}" tcp flags syn tcp option maxseg size > ${toString mss} tcp option maxseg size set ${toString mss} ''} ${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 ++ map ruleToNft cfg.firewall.rules)} } ''; }; networking.nftables.tables.router-nat = { family = "ip"; content = '' chain postrouting { type nat hook postrouting priority srcnat; policy accept; oifname "${uplink4}" masquerade } ''; }; services.openssh = { enable = true; openFirewall = false; settings = { PasswordAuthentication = false; KbdInteractiveAuthentication = false; PermitRootLogin = "prohibit-password"; }; }; }; }