- wan.ipv6.transport = "ppp": IPv6CP in pppd, router advertisements and DHCPv6 prefix delegation on the PPP device, DHCPv6 accepted on the PPP device, IPv6 MSS clamped to MTU-60. Allowed on a shared single-NIC link. A build-time check requires a PPPoE uplink. - IPv4 MSS clamping now only matches IPv4 packets. - The example uses the new box's NIC name (enp2s0). Covered by the onestick VM test (radvd and a minimal DHCPv6-PD responder as the ISP) and assertion cases. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
293 lines
11 KiB
Nix
293 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` 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 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}" 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";
|
|
};
|
|
};
|
|
};
|
|
}
|