Add port forwards and WAN-originated firewall rules
Extend router.firewall.rules with a reserved `wan` source zone: DNAT (dnat, toPort), port ranges, source allow-lists, per-source rate limits, logging and optional hairpin NAT. IPv6 is opened only by explicit pinholes. Build-time assertions require WAN rules to name a destination, protocol and ports so a typo cannot expose a whole zone. Covered by the basic/pppoe VM tests and the assertion tests. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
+120
-12
@@ -60,21 +60,121 @@ let
|
||||
) zones
|
||||
);
|
||||
|
||||
ruleToNft =
|
||||
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
|
||||
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 } "
|
||||
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
|
||||
"${r.protocol} dport ${
|
||||
if r.ports == [ ] then "1-65535" else "{ ${lib.concatMapStringsSep ", " toString r.ports} }"
|
||||
} ";
|
||||
"ip";
|
||||
in
|
||||
"iifname ${set (zoneDevices r.from)} oifname ${set (zoneDevices r.to)} ${dest}${proto}accept # ${r.from} -> ${r.to}";
|
||||
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";
|
||||
@@ -146,7 +246,9 @@ in
|
||||
) "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)}
|
||||
${lib.concatStringsSep "\n " (
|
||||
intraZone ++ lib.concatMap (p: p.hairpinForward ++ p.forward) parts
|
||||
)}
|
||||
}
|
||||
'';
|
||||
};
|
||||
@@ -154,9 +256,15 @@ in
|
||||
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"}
|
||||
|
||||
Reference in New Issue
Block a user