Files
nixos-router/modules/router/firewall.nix
T
nilsandClaude Sonnet 5.5 30ab1a0ed6 Add WireGuard tunnels as firewall zone members
router.wireguard.<name> configures tunnels through networkd: road warrior
(listening, dual-stack), site-to-site (no transfer network) and provider
dial-out with masquerade. A tunnel has a zone like a LAN, so inter-zone rules
apply; management, DNS and internet are opt-in. Peers' allowedIPs become
routes (destination-based routing; default-route tunnels are rejected).

Tunnels have a kill switch (default on): forwarded packets for their
allowedIPs are dropped if they would leave via an uplink, before the
established-accept so flows that used the tunnel are cut too.

Private keys stay outside the Nix store and are passed to the unprivileged
systemd-networkd with LoadCredential, so key files can be root-only.

Assertions cover CIDR/key/endpoint formats, duplicate allowedIPs, overlaps
with LAN subnets, port and device clashes, and rule destinations inside
tunnel networks. A five-VM `wireguard` check covers road warrior, site-to-site,
the provider tunnel with an IPv6 transfer net, zone rules and the kill switch.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 19:56:45 +01:00

178 lines
6.4 KiB
Nix

{ 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
);
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 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 "${cfg.wan.interface}" 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 22 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}" 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
${lib.optionalString (
masqueradeIfaces != [ ]
) "oifname ${set masqueradeIfaces} masquerade # tunnels"}
}
'';
};
services.openssh = {
enable = true;
openFirewall = false;
settings = {
PasswordAuthentication = false;
KbdInteractiveAuthentication = false;
PermitRootLogin = "prohibit-password";
};
};
};
}