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:
2026-10-27 20:48:40 +01:00
co-authored by Claude Sonnet 5.5
parent 30ab1a0ed6
commit 3f8f2f4199
6 changed files with 676 additions and 36 deletions
+217
View File
@@ -506,6 +506,222 @@ let
};
expect = [ "is not inside any network of zone `s2s`" ];
};
wanValid = {
router = {
lans.main = lan { internet = true; };
firewall.rules = [
{
from = "wan";
to = "main";
destination = "192.168.10.5";
protocol = "tcp";
ports = [ 8080 ];
dnat = true;
toPort = 8081;
sources = [ "198.51.100.0/24" ];
rateLimit = "3/minute";
log = true;
hairpin = true;
}
{
from = "wan";
to = "main";
destination = "2001:db8:1::5";
protocol = "tcp";
ports = [ "9000-9002" ];
}
];
};
expect = [ ];
};
wanNoDestination = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "wan";
to = "main";
destination = null;
protocol = "tcp";
ports = [ 8080 ];
}
];
};
expect = [ "must name a destination" ];
};
wanAnyProtocol = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "wan";
to = "main";
destination = "192.168.10.5";
protocol = "any";
ports = [ ];
}
];
};
expect = [ "must name a protocol" ];
};
wanNoPorts = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "wan";
to = "main";
destination = "192.168.10.5";
protocol = "tcp";
ports = [ ];
}
];
};
expect = [ "must list the ports" ];
};
toWan = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "main";
to = "wan";
}
];
};
expect = [ "`wan` cannot be a destination" ];
};
dnatNeedsHost = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "wan";
to = "main";
destination = "192.168.10.0/24";
protocol = "tcp";
ports = [ 8080 ];
dnat = true;
}
];
};
expect = [ "dnat needs a single IPv4 host" ];
};
dnatV6Sources = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "wan";
to = "main";
destination = "192.168.10.5";
protocol = "tcp";
ports = [ 8080 ];
dnat = true;
sources = [ "2001:db8::/32" ];
}
];
};
expect = [ "its sources must be IPv4" ];
};
toPortWithoutDnat = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "wan";
to = "main";
destination = "192.168.10.5";
protocol = "tcp";
ports = [ 8080 ];
toPort = 8081;
}
];
};
expect = [ "toPort needs dnat" ];
};
hairpinNoDnat = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "wan";
to = "main";
destination = "192.168.10.5";
protocol = "tcp";
ports = [ 8080 ];
hairpin = true;
}
];
};
expect = [
"hairpin only applies to dnat"
"hairpin needs at least one"
];
};
hairpinNoInternet = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "wan";
to = "main";
destination = "192.168.10.5";
protocol = "tcp";
ports = [ 8080 ];
dnat = true;
hairpin = true;
}
];
};
expect = [ "hairpin needs at least one" ];
};
badPortRange = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "wan";
to = "main";
destination = "192.168.10.5";
protocol = "tcp";
ports = [ "9002-9000" ];
}
];
};
expect = [ "out of range" ];
};
badSource = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "wan";
to = "main";
destination = "192.168.10.5";
protocol = "tcp";
ports = [ 8080 ];
sources = [ "nonsense" ];
}
];
};
expect = [ "every source must be" ];
};
badV6Destination = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "wan";
to = "main";
destination = "2001:db8::zz";
protocol = "tcp";
ports = [ 8080 ];
}
];
};
expect = [ "not a valid IPv6" ];
};
nonOverlappingLans = {
router.lans = {
a = lan { };
@@ -552,6 +768,7 @@ let
"vlansOnOneTrunk"
"ruleValid"
"wgValid"
"wanValid"
];
subnet =