Files
nixos-router/modules/router/firewall.nix
T
nilsandClaude Sonnet 5.5 eda7080d94 Zone-based firewall page, per-sensor temperatures, 15 minute default
The firewall page now manages zones (assign each LAN or tunnel, add and
remove zones) and rules with zone, address and port on both sides plus the
protocol. router.firewall.zones declares zones without members and rules
take sourcePorts. The temperature chart draws every thermal zone and hwmon
sensor; the overview defaults to 15 minutes, 1h stays an option.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-10-30 09:26:25 +01:00

300 lines
11 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
);
setOf = xs: "{ ${lib.concatMapStringsSep ", " toString xs} }";
# Layer 4 match; `ports` and `sports` may be ints or "a-b" ranges.
l4 =
proto: ports: sports:
if proto == "any" then
""
else if proto == "icmp" then
"meta l4proto { icmp, ipv6-icmp } "
else
"${lib.optionalString (sports != [ ]) "${proto} sport ${setOf sports} "}${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 r.sourcePorts}${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
{
# A zone nothing is assigned to yet has no devices; the rule waits for one.
forward = lib.optionals (fromDevs != [ ] && toDevs != [ ]) (
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 && toDevs != [ ]) (
"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";
};
};
};
}