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
300 lines
11 KiB
Nix
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";
|
|
};
|
|
};
|
|
};
|
|
}
|