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:
@@ -96,25 +96,46 @@ let
|
||||
}) (lib.filter (l: l.zone == zone && validIp l.ipv4.address) lans)
|
||||
++ lib.concatMap wgV4Nets (lib.filter (w: w.zone == zone) wgs);
|
||||
|
||||
portOk =
|
||||
p:
|
||||
if builtins.isInt p then
|
||||
p >= 1 && p <= 65535
|
||||
else
|
||||
let
|
||||
m = builtins.match "([0-9]+)-([0-9]+)" p;
|
||||
lo = lib.toInt (builtins.elemAt m 0);
|
||||
hi = lib.toInt (builtins.elemAt m 1);
|
||||
in
|
||||
lo >= 1 && hi <= 65535 && lo < hi;
|
||||
|
||||
ruleAssertions =
|
||||
r:
|
||||
let
|
||||
where = "router.firewall.rules (${r.from} -> ${r.to})";
|
||||
dest = if r.destination == null then null else ipv4.parseNet r.destination;
|
||||
fromWan = r.from == "wan";
|
||||
v6Dest = r.destination != null && isV6 r.destination;
|
||||
dest = if r.destination == null || v6Dest then null else ipv4.parseNet r.destination;
|
||||
inZone =
|
||||
dest != null
|
||||
&& lib.any (n: dest.prefixLength >= n.prefixLength && ipv4.inSubnet n dest.address) (
|
||||
zoneV4Nets r.to
|
||||
);
|
||||
singleHost = dest != null && dest.prefixLength == 32;
|
||||
plainPorts = lib.all builtins.isInt r.ports;
|
||||
internetMember = lib.any (l: l.internet) lans || lib.any (w: w.internet) wgs;
|
||||
in
|
||||
[
|
||||
{
|
||||
assertion = lib.elem r.from zoneNames;
|
||||
message = "router: ${where}: unknown source zone `${r.from}` (zones: ${lib.concatStringsSep ", " zoneNames}).";
|
||||
assertion = fromWan || lib.elem r.from zoneNames;
|
||||
message = "router: ${where}: unknown source zone `${r.from}` (zones: wan, ${lib.concatStringsSep ", " zoneNames}).";
|
||||
}
|
||||
{
|
||||
assertion = lib.elem r.to zoneNames;
|
||||
message = "router: ${where}: unknown destination zone `${r.to}` (zones: ${lib.concatStringsSep ", " zoneNames}).";
|
||||
message =
|
||||
if r.to == "wan" then
|
||||
"router: ${where}: `wan` cannot be a destination; use `internet = true` on the LAN or tunnel."
|
||||
else
|
||||
"router: ${where}: unknown destination zone `${r.to}` (zones: ${lib.concatStringsSep ", " zoneNames}).";
|
||||
}
|
||||
{
|
||||
assertion = r.from != r.to;
|
||||
@@ -124,20 +145,93 @@ let
|
||||
assertion = r.ports == [ ] || r.protocol == "tcp" || r.protocol == "udp";
|
||||
message = "router: ${where}: ports are only valid with protocol tcp or udp.";
|
||||
}
|
||||
{
|
||||
assertion = lib.all portOk r.ports;
|
||||
message = "router: ${where}: a port or port range is out of range (1-65535, ranges need low < high).";
|
||||
}
|
||||
{
|
||||
assertion = lib.all validCidr (
|
||||
map (
|
||||
x:
|
||||
if lib.hasInfix "/" x then
|
||||
x
|
||||
else if isV6 x then
|
||||
"${x}/128"
|
||||
else
|
||||
"${x}/32"
|
||||
) r.sources
|
||||
);
|
||||
message = "router: ${where}: every source must be an IPv4/IPv6 address or CIDR.";
|
||||
}
|
||||
]
|
||||
# Rules from the outside must not be able to expose a whole zone by accident.
|
||||
++ lib.optionals fromWan [
|
||||
{
|
||||
assertion = r.destination != null;
|
||||
message = "router: ${where}: rules from `wan` must name a destination host or network.";
|
||||
}
|
||||
{
|
||||
assertion = r.protocol != "any";
|
||||
message = "router: ${where}: rules from `wan` must name a protocol (tcp, udp or icmp).";
|
||||
}
|
||||
{
|
||||
assertion = r.protocol == "icmp" || r.protocol == "any" || r.ports != [ ];
|
||||
message = "router: ${where}: rules from `wan` must list the ports they open.";
|
||||
}
|
||||
]
|
||||
++ lib.optionals (r.destination != null) [
|
||||
{
|
||||
assertion = dest != null;
|
||||
assertion = v6Dest || dest != null;
|
||||
message = "router: ${where}: destination `${r.destination}` is not a valid IPv4 address or CIDR.";
|
||||
}
|
||||
{
|
||||
assertion = dest == null || !(lib.elem r.to zoneNames) || inZone;
|
||||
assertion = v6Dest || dest == null || !(lib.elem r.to zoneNames) || inZone;
|
||||
message = "router: ${where}: destination `${r.destination}` is not inside any network of zone `${r.to}`.";
|
||||
}
|
||||
{
|
||||
assertion = dest == null || ipv4.networkOf dest.address dest.prefixLength == dest.address;
|
||||
assertion = v6Dest || dest == null || ipv4.networkOf dest.address dest.prefixLength == dest.address;
|
||||
message = "router: ${where}: destination `${r.destination}` has host bits set.";
|
||||
}
|
||||
{
|
||||
assertion =
|
||||
!v6Dest
|
||||
|| v6Cidr (if lib.hasInfix "/" r.destination then r.destination else "${r.destination}/128");
|
||||
message = "router: ${where}: destination `${r.destination}` is not a valid IPv6 address or CIDR.";
|
||||
}
|
||||
]
|
||||
++ lib.optionals r.dnat [
|
||||
{
|
||||
assertion = fromWan;
|
||||
message = "router: ${where}: dnat is only valid for rules from `wan`.";
|
||||
}
|
||||
{
|
||||
assertion = singleHost;
|
||||
message = "router: ${where}: dnat needs a single IPv4 host as destination.";
|
||||
}
|
||||
{
|
||||
assertion = r.protocol == "tcp" || r.protocol == "udp";
|
||||
message = "router: ${where}: dnat needs protocol tcp or udp.";
|
||||
}
|
||||
{
|
||||
assertion = lib.all (x: !isV6 x) r.sources;
|
||||
message = "router: ${where}: dnat is IPv4-only, so its sources must be IPv4.";
|
||||
}
|
||||
]
|
||||
++ lib.optionals (r.toPort != null) [
|
||||
{
|
||||
assertion = r.dnat && lib.length r.ports == 1 && plainPorts;
|
||||
message = "router: ${where}: toPort needs dnat and exactly one (non-range) port.";
|
||||
}
|
||||
]
|
||||
++ lib.optionals r.hairpin [
|
||||
{
|
||||
assertion = r.dnat;
|
||||
message = "router: ${where}: hairpin only applies to dnat rules.";
|
||||
}
|
||||
{
|
||||
assertion = internetMember;
|
||||
message = "router: ${where}: hairpin needs at least one LAN or tunnel with internet = true.";
|
||||
}
|
||||
];
|
||||
|
||||
overlapsLan = n: lib.any (l: ipv4.overlaps n l) lanNets;
|
||||
@@ -246,6 +340,12 @@ in
|
||||
]
|
||||
++ lib.concatLists (lib.mapAttrsToList perLan lansByName)
|
||||
++ lib.concatMap ruleAssertions cfg.firewall.rules
|
||||
++ [
|
||||
{
|
||||
assertion = !(lib.elem "wan" zoneNames);
|
||||
message = "router: `wan` is a reserved zone name; give the LAN or tunnel a different `zone`.";
|
||||
}
|
||||
]
|
||||
++ lib.concatLists (lib.mapAttrsToList wgAssertions cfg.wireguard)
|
||||
++ [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user