Add a generic NAT table: port forward, source NAT, masquerade

router.firewall.nat replaces the dnat/toPort/hairpin fields of the filter
rules. Filter rules gain natOnly, and may use wan with log and rate limit
in the web UI. The Firewall page edits the NAT table and can add the
matching allow rule. Settings schema 1 (migrations/1.jq) converts old
forwards.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
This commit is contained in:
2026-10-30 15:54:29 +01:00
co-authored by Claude Sonnet 5.5
parent 3e9c2e4a7a
commit a58600823f
19 changed files with 1018 additions and 505 deletions
+5 -4
View File
@@ -105,10 +105,11 @@ Learned along the way:
- Router services stay per LAN: DNS and DHCP always, SSH only with `allowManagement`. - Router services stay per LAN: DNS and DHCP always, SSH only with `allowManagement`.
- Kea subnet IDs are derived from the network address, so adding or removing LANs does not shift IDs (and orphan persistent leases). - Kea subnet IDs are derived from the network address, so adding or removing LANs does not shift IDs (and orphan persistent leases).
## Port forwards (confirmed) ## Port forwards and NAT (confirmed)
- Implemented by extending `router.firewall.rules` with a reserved source zone `wan` (no LAN/tunnel may use that zone name; `wan` is never a destination). - Filtering and translation are separate. `router.firewall.rules` only filter and may use the reserved source zone `wan` (no LAN/tunnel may use that zone name; `wan` is never a destination). Rule fields: port ranges (`"9000-9002"`), `sources` allow-list (v4/v6 CIDRs), `rateLimit` (`N/second|minute|hour`, per source via nft meter), `log`, `natOnly` (only connections a DNAT rule translated).
- Extra rule fields: `dnat` (IPv4 only, single host destination, tcp/udp), `toPort`, port ranges (`"9000-9002"`), `sources` allow-list (v4/v6 CIDRs), `rateLimit` (`N/second|minute|hour`, per source via nft meter), `log`, `hairpin` (NAT loopback for LANs/tunnels with `internet = true`; masquerades so replies return via the router). - `router.firewall.nat` is the translation table: `type` `dnat` (IPv4 only, one host `toAddress` in the `to` zone, tcp/udp, optional `toPort`, `hairpin` from `wan`: NAT loopback for LANs/tunnels with `internet = true`; masquerades so replies return via the router), `snat` (`toAddress`) and `masquerade`, matching `from`, `sources`, `destination` (default: the router's own addresses), protocol and ports.
- The web UI's NAT table can also add the matching filter rule ("also allow", a `natOnly` rule). Schema 1 (`migrations/1.jq`) converts the old rules with `dnat` into a NAT row plus such a rule.
- Safeguards: a rule from `wan` must name a destination, an explicit protocol and its ports, so a typo cannot expose a whole zone. - Safeguards: a rule from `wan` must name a destination, an explicit protocol and its ports, so a typo cannot expose a whole zone.
- IPv6 has no NAT: it is opened only by explicit pinholes (`from = "wan"`, IPv6 `destination`). The prefix is dynamic, so v6 destinations are not checked against the zone. - IPv6 has no NAT: it is opened only by explicit pinholes (`from = "wan"`, IPv6 `destination`). The prefix is dynamic, so v6 destinations are not checked against the zone.
- Tested in `basic`/`pppoe` (source preserved, toPort, ranges, sources, rate limit, v6 pinhole, hairpin, log lines) and in `assertions`; mutation-tested (source match, hairpin masquerade, rate limit). - Tested in `basic`/`pppoe` (source preserved, toPort, ranges, sources, rate limit, v6 pinhole, hairpin, log lines) and in `assertions`; mutation-tested (source match, hairpin masquerade, rate limit).
@@ -157,7 +158,7 @@ Open (in rough order):
3. ~~**Tunnel trust and addressing**~~ **done**: tunnels may have no address; the new `wireguard.<n>.sourceAddresses` sets the source (`PreferredSource`) for router-originated traffic into an addressless tunnel (e.g. DNS to Troisdorf; tested, mutation-tested). Antigua's blanket `trustedInterfaces` is spelled out as per-tunnel `allowManagement`/`dns` plus zone rules in `examples/home`. The default device name `wg-<name>` must fit 15 characters (caught by an assertion: `mobile-clients` became `mobile`). 3. ~~**Tunnel trust and addressing**~~ **done**: tunnels may have no address; the new `wireguard.<n>.sourceAddresses` sets the source (`PreferredSource`) for router-originated traffic into an addressless tunnel (e.g. DNS to Troisdorf; tested, mutation-tested). Antigua's blanket `trustedInterfaces` is spelled out as per-tunnel `allowManagement`/`dns` plus zone rules in `examples/home`. The default device name `wg-<name>` must fit 15 characters (caught by an assertion: `mobile-clients` became `mobile`).
4. ~~**PPP device name**~~ **done**: `ddclient` in `examples/home` uses `config.router.wan.ipv4.interface` (`pppoe-wan`) instead of a hard-coded name. DDNS has since moved into the module (`router.ddns`, a web UI page; tested in `ddns`): `examples/home` can drop its `services.ddclient` for it. 4. ~~**PPP device name**~~ **done**: `ddclient` in `examples/home` uses `config.router.wan.ipv4.interface` (`pppoe-wan`) instead of a hard-coded name. DDNS has since moved into the module (`router.ddns`, a web UI page; tested in `ddns`): `examples/home` can drop its `services.ddclient` for it.
5. **Credentials**: the PPPoE password is assigned by the ISP (cannot be rotated) and is plaintext in antigua's repo/store; never copy it into a repo again. Here it is read from `/var/lib/router/pppoe-password` (delivered with `router-install --secrets`). The PPPoE username in `examples/home` is a placeholder. 5. **Credentials**: the PPPoE password is assigned by the ISP (cannot be rotated) and is plaintext in antigua's repo/store; never copy it into a repo again. Here it is read from `/var/lib/router/pppoe-password` (delivered with `router-install --secrets`). The PPPoE username in `examples/home` is a placeholder.
6. **Services that stay on antigua** (k3s, Gitea, Syncthing, WoW, Traefik) were reachable from the WAN because antigua *was* the router. On the new router they need explicit port forwards to the host's address (`firewall.rules` with `from = "wan"`); antigua's address on the LAN also changes (k3s nodeIP and the `antigua.mch` record point at 192.168.168.254 today). **Open: needs the host's new address and the list of public ports.** 6. **Services that stay on antigua** (k3s, Gitea, Syncthing, WoW, Traefik) were reachable from the WAN because antigua *was* the router. On the new router they need explicit port forwards to the host's address (a NAT row on the Firewall page); antigua's address on the LAN also changes (k3s nodeIP and the `antigua.mch` record point at 192.168.168.254 today). **Open: needs the host's new address and the list of public ports.**
7. `examples/home` (`nixosConfigurations.home`, `packages.installer-home`) ports antigua's routing role; its toplevel builds as the `home` check. Not VM-testable as a whole (needs the real ISP); its pieces are covered by the component tests. 7. `examples/home` (`nixosConfigurations.home`, `packages.installer-home`) ports antigua's routing role; its toplevel builds as the `home` check. Not VM-testable as a whole (needs the real ISP); its pieces are covered by the component tests.
## Installer and image (in progress) ## Installer and image (in progress)
+1
View File
@@ -170,6 +170,7 @@
(self.lib.mkRouterSystem { (self.lib.mkRouterSystem {
settings = builtins.toFile "settings.json" ( settings = builtins.toFile "settings.json" (
builtins.toJSON { builtins.toJSON {
version = thisRelease.schema;
router = { router = {
wan.interface = "eth0"; wan.interface = "eth0";
lans.lan = { lans.lan = {
+21
View File
@@ -0,0 +1,21 @@
# Port forwards move from `firewall.rules` (dnat, toPort, hairpin) to
# `firewall.nat`; the allow rule that went with them stays as a rule of its
# own, limited to translated connections (natOnly). The rest of a rule
# (rateLimit, log, sources) stays on the allow rule.
def natRow:
{type: "dnat", from: .from, to: .to, protocol: .protocol, ports: .ports, toAddress: .destination}
+ (if .toPort != null then {toPort: .toPort} else {} end)
+ (if ((.sources // []) | length) > 0 then {sources: .sources} else {} end)
+ (if .hairpin == true then {hairpin: true} else {} end);
def allowRule:
del(.dnat, .hairpin, .toPort)
+ {natOnly: true}
+ (if .toPort != null then {ports: [.toPort]} else {} end);
if ((.router.firewall.rules // []) | any(.dnat == true or .hairpin != null or .toPort != null)) then
.router.firewall.nat = ((.router.firewall.nat // []) + [.router.firewall.rules[] | select(.dnat == true) | natRow])
| .router.firewall.rules |= map(if .dnat == true then allowRule else del(.dnat, .hairpin, .toPort) end)
else
.
end
+78 -24
View File
@@ -201,9 +201,6 @@ let
&& lib.any (n: dest.prefixLength >= n.prefixLength && ipv4.inSubnet n dest.address) ( && lib.any (n: dest.prefixLength >= n.prefixLength && ipv4.inSubnet n dest.address) (
zoneV4Nets r.to 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 in
[ [
{ {
@@ -227,8 +224,8 @@ let
message = "router: ${where}: ports are only valid with protocol tcp or udp."; message = "router: ${where}: ports are only valid with protocol tcp or udp.";
} }
{ {
assertion = r.sourcePorts == [ ] || ((r.protocol == "tcp" || r.protocol == "udp") && !r.dnat); assertion = r.sourcePorts == [ ] || r.protocol == "tcp" || r.protocol == "udp";
message = "router: ${where}: sourcePorts are only valid with protocol tcp or udp, and not on a port forward."; message = "router: ${where}: sourcePorts are only valid with protocol tcp or udp.";
} }
{ {
assertion = lib.all portOk (r.ports ++ r.sourcePorts); assertion = lib.all portOk (r.ports ++ r.sourcePorts);
@@ -284,39 +281,95 @@ let
message = "router: ${where}: destination `${r.destination}` is not a valid IPv6 address or CIDR."; message = "router: ${where}: destination `${r.destination}` is not a valid IPv6 address or CIDR.";
} }
] ]
++ lib.optionals r.dnat [ ;
natAssertions =
n:
let
where = "router.firewall.nat (${n.type} ${toString n.from} -> ${n.to})";
isDnat = n.type == "dnat";
isSnat = n.type == "snat";
v4Ok = a: (v4Cidr (if lib.hasInfix "/" a then a else "${a}/32"));
toAddr = if n.toAddress != null && validIp n.toAddress then ipv4.parseNet "${n.toAddress}/32" else null;
inZone =
toAddr != null
&& lib.any (z: toAddr.prefixLength >= z.prefixLength && ipv4.inSubnet z toAddr.address) (zoneV4Nets n.to);
internetMember = lib.any (l: l.internet) lans || lib.any (w: w.internet) wgs;
plainPorts = lib.all builtins.isInt n.ports;
in
[
{ {
assertion = fromWan; assertion = n.from == null || n.from == "wan" || lib.elem n.from zoneNames;
message = "router: ${where}: dnat is only valid for rules from `wan`."; message = "router: ${where}: unknown source zone `${toString n.from}` (zones: wan, ${lib.concatStringsSep ", " zoneNames}).";
} }
{ {
assertion = singleHost; assertion = lib.elem n.to zoneNames || (!isDnat && n.to == "wan");
message = "router: ${where}: dnat needs a single IPv4 host as destination."; message = "router: ${where}: unknown zone `${n.to}` (zones: ${lib.concatStringsSep ", " zoneNames}${lib.optionalString (!isDnat) ", wan"}).";
} }
{ {
assertion = r.protocol == "tcp" || r.protocol == "udp"; assertion = !isDnat || n.from != null;
message = "router: ${where}: dnat needs protocol tcp or udp."; message = "router: ${where}: a port forward needs a source zone (`from`).";
} }
{ {
assertion = lib.all (x: !isV6 x) r.sources; assertion = (n.ports == [ ] && n.sourcePorts == [ ]) || n.protocol == "tcp" || n.protocol == "udp";
message = "router: ${where}: dnat is IPv4-only, so its sources must be IPv4."; message = "router: ${where}: ports are only valid with protocol tcp or udp.";
}
{
assertion = lib.all portOk (n.ports ++ n.sourcePorts);
message = "router: ${where}: a port or port range is out of range (1-65535, ranges need low < high).";
}
{
assertion = lib.all v4Ok n.sources;
message = "router: ${where}: every source must be an IPv4 address or CIDR (translation is IPv4 only).";
}
{
assertion = n.destination == null || v4Ok n.destination;
message = "router: ${where}: destination `${toString n.destination}` is not an IPv4 address or CIDR.";
}
{
assertion = isDnat || (n.toPort == null && !n.hairpin);
message = "router: ${where}: toPort and hairpin only apply to port forwards (dnat).";
}
{
assertion = (isDnat || isSnat) == (n.toAddress != null);
message = "router: ${where}: dnat and snat need a toAddress; masquerade has none.";
}
{
assertion = n.toAddress == null || validIp n.toAddress;
message = "router: ${where}: toAddress `${toString n.toAddress}` is not a valid IPv4 address.";
} }
] ]
++ lib.optionals (r.toPort != null) [ ++ lib.optionals isDnat [
{ {
assertion = r.dnat && lib.length r.ports == 1 && plainPorts; assertion = n.protocol == "tcp" || n.protocol == "udp";
message = "router: ${where}: toPort needs dnat and exactly one (non-range) port."; message = "router: ${where}: a port forward needs protocol tcp or udp.";
}
]
++ lib.optionals r.hairpin [
{
assertion = r.dnat;
message = "router: ${where}: hairpin only applies to dnat rules.";
} }
{ {
assertion = internetMember; assertion = n.ports != [ ];
message = "router: ${where}: a port forward must list the ports it forwards.";
}
{
assertion = toAddr == null || !(lib.elem n.to zoneNames) || inZone;
message = "router: ${where}: toAddress `${toString n.toAddress}` is not inside any network of zone `${n.to}`.";
}
{
assertion = n.toPort == null || (lib.length n.ports == 1 && plainPorts);
message = "router: ${where}: toPort needs exactly one (non-range) port.";
}
{
assertion = !n.hairpin || n.from == "wan";
message = "router: ${where}: hairpin only applies to forwards from `wan`.";
}
{
assertion = !n.hairpin || internetMember;
message = "router: ${where}: hairpin needs at least one LAN or tunnel with internet = true."; message = "router: ${where}: hairpin needs at least one LAN or tunnel with internet = true.";
} }
]
++ lib.optionals (!isDnat) [
{
assertion = n.from == null || n.from != n.to;
message = "router: ${where}: source and destination are the same zone.";
}
]; ];
dnsCfg = cfg.dns; dnsCfg = cfg.dns;
@@ -508,6 +561,7 @@ in
++ lib.concatLists (lib.mapAttrsToList perLan lansByName) ++ lib.concatLists (lib.mapAttrsToList perLan lansByName)
++ dnsAssertions ++ dnsAssertions
++ lib.concatMap ruleAssertions cfg.firewall.rules ++ lib.concatMap ruleAssertions cfg.firewall.rules
++ lib.concatMap natAssertions cfg.firewall.nat
++ [ ++ [
{ {
assertion = !(lib.elem "wan" zoneNames); assertion = !(lib.elem "wan" zoneNames);
+47 -20
View File
@@ -121,9 +121,7 @@ let
label = "${r.from}->${r.to}"; label = "${r.from}->${r.to}";
fromDevs = if r.from == "wan" then uplinks else zoneDevices r.from; fromDevs = if r.from == "wan" then uplinks else zoneDevices r.from;
toDevs = zoneDevices r.to; toDevs = zoneDevices r.to;
# After DNAT the packet carries the translated port. base = "iifname ${set fromDevs} oifname ${set toDevs} ${daddrOf r}${l4 r.protocol r.ports r.sourcePorts}${lib.optionalString r.natOnly "ct status dnat "}";
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}: " ''; logPrefix = what: ''log prefix "router fwd#${id} ${label} ${what}: " '';
limitLines = limitLines =
v: v:
@@ -147,32 +145,60 @@ let
v: v:
lib.optional r.log "${base}${v.src}ct state new ${logPrefix "accepted"}" lib.optional r.log "${base}${v.src}ct state new ${logPrefix "accepted"}"
++ [ "${base}${v.src}accept # ${label}" ]; ++ [ "${base}${v.src}accept # ${label}" ];
dnatTarget = "dnat to ${r.destination}${
lib.optionalString (r.toPort != null) ":${toString r.toPort}"
}";
in in
{ {
# A zone nothing is assigned to yet has no devices; the rule waits for one. # A zone nothing is assigned to yet has no devices; the rule waits for one.
forward = lib.optionals (fromDevs != [ ] && toDevs != [ ]) ( forward = lib.optionals (fromDevs != [ ] && toDevs != [ ]) (
lib.concatMap (v: limitLines v ++ acceptLines v) (variantsOf r) 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}") ( # Address translation (router.firewall.nat). Indexed like the rules.
variantsOf r natParts =
) i: n:
) let
label = "nat ${n.type} ${toString n.from}->${n.to}";
fromDevs =
if n.from == null then
[ ]
else if n.from == "wan" then
uplinks
else
zoneDevices n.from;
toDevs = if n.to == "wan" then uplinks else zoneDevices n.to;
iif = lib.optionalString (n.from != null) "iifname ${set fromDevs} ";
saddr = lib.optionalString (n.sources != [ ]) "ip saddr ${setOf n.sources} ";
daddr = lib.optionalString (n.destination != null) "ip daddr ${n.destination} ";
match = "${iif}${saddr}${daddr}${l4 n.protocol n.ports n.sourcePorts}";
target = "${n.toAddress}${lib.optionalString (n.toPort != null) ":${toString n.toPort}"}";
# Zones with nothing assigned yet have no devices; the rule waits for one.
live = (n.from == null || fromDevs != [ ]) && toDevs != [ ];
in
{
prerouting = lib.optionals (live && n.type == "dnat") (
[
# Without a destination, from the uplink anything that arrives is
# translated; from inside only what is addressed to the router.
"${match}${
lib.optionalString (n.destination == null && n.from != "wan") "fib daddr type local "
}dnat to ${target} # ${label}"
]
# NAT loopback: internal clients addressing the router's public address. # NAT loopback: internal clients addressing the router's public address.
++ ++ lib.optional n.hairpin "iifname ${set internetIfaces} ${saddr}${daddr}fib daddr type local ip daddr != ${setOf internalAddrs} ${l4 n.protocol n.ports [ ]}dnat to ${target} # hairpin ${label}"
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}"
); );
# The hairpin allow: these LANs may reach the host through the router's address.
hairpinForward = lib.optional (live && n.type == "dnat" && n.hairpin) "iifname ${set internetIfaces} oifname ${set toDevs} ip daddr ${n.toAddress} ${l4 n.protocol (if n.toPort != null then [ n.toPort ] else n.ports) [ ]}ct status dnat accept # hairpin ${label}";
postrouting = lib.optional (live && n.type == "masquerade") "${
lib.optionalString (n.from != null) "iifname ${set fromDevs} "
}oifname ${set toDevs} ${saddr}${daddr}${l4 n.protocol n.ports n.sourcePorts}masquerade # ${label}"
++ lib.optional (
live && n.type == "snat"
) "${iif}oifname ${set toDevs} ${saddr}${daddr}${l4 n.protocol n.ports n.sourcePorts}snat to ${n.toAddress} # ${label}";
}; };
parts = lib.imap0 ruleParts cfg.firewall.rules; parts = lib.imap0 ruleParts cfg.firewall.rules;
anyHairpin = lib.any (r: r.dnat && r.hairpin) cfg.firewall.rules; natRules = lib.imap0 natParts cfg.firewall.nat;
anyHairpin = lib.any (n: n.type == "dnat" && n.hairpin) cfg.firewall.nat;
# Addresses the router itself holds on internal interfaces; anything else # Addresses the router itself holds on internal interfaces; anything else
# that is local is a public (uplink) address. # that is local is a public (uplink) address.
internalAddrs = internalAddrs =
@@ -261,7 +287,7 @@ in
# Zones: free inside a zone, denied between zones unless a rule says so. # Zones: free inside a zone, denied between zones unless a rule says so.
${lib.concatStringsSep "\n " ( ${lib.concatStringsSep "\n " (
intraZone ++ lib.concatMap (p: p.hairpinForward ++ p.forward) parts intraZone ++ lib.concatMap (p: p.hairpinForward) natRules ++ lib.concatMap (p: p.forward) parts
)} )}
} }
''; '';
@@ -272,11 +298,12 @@ in
content = '' content = ''
chain prerouting { chain prerouting {
type nat hook prerouting priority dstnat; policy accept; type nat hook prerouting priority dstnat; policy accept;
${lib.concatStringsSep "\n " (lib.concatMap (p: p.prerouting) parts)} ${lib.concatStringsSep "\n " (lib.concatMap (p: p.prerouting) natRules)}
} }
chain postrouting { chain postrouting {
type nat hook postrouting priority srcnat; policy accept; type nat hook postrouting priority srcnat; policy accept;
${lib.concatStringsSep "\n " (lib.concatMap (p: p.postrouting) natRules)}
oifname "${uplink4}" masquerade oifname "${uplink4}" masquerade
${lib.optionalString anyHairpin "ct status dnat iifname ${set memberIfaces} oifname ${set memberIfaces} masquerade # hairpin"} ${lib.optionalString anyHairpin "ct status dnat iifname ${set memberIfaces} oifname ${set memberIfaces} masquerade # hairpin"}
${lib.optionalString ( ${lib.optionalString (
+103 -21
View File
@@ -393,6 +393,100 @@ in
''; '';
}; };
firewall.nat = mkOption {
default = [ ];
description = ''
Address translation, separate from the allow rules. `dnat` sends
traffic that arrives in zone `from` (`wan` for the uplink) and matches
the rule to `toAddress` (a host in zone `to`); `snat` and `masquerade`
rewrite the source of traffic leaving through zone `to` (`wan` for the
uplink) to `toAddress`, or to the address of the outgoing interface.
LANs with `internet = true` and tunnels with `masquerade` are already
masqueraded; these rules come first. A translation does not allow
anything: a forward needs a matching entry in `firewall.rules`.
'';
type = types.listOf (
types.submodule {
options = {
type = mkOption {
type = types.enum [
"dnat"
"snat"
"masquerade"
];
description = "Kind of translation.";
};
from = mkOption {
type = types.nullOr types.str;
default = null;
description = "Zone the traffic comes from, or `wan`. For `snat` and `masquerade` null means any.";
};
to = mkOption {
type = types.str;
description = ''
`dnat`: the zone of the host the traffic is sent to. `snat` and
`masquerade`: the zone (or `wan`) the traffic leaves through.
'';
};
protocol = mkOption {
type = types.enum [
"any"
"tcp"
"udp"
"icmp"
];
default = "any";
};
ports = mkOption {
type = types.listOf (types.either types.port (types.strMatching "[0-9]+-[0-9]+"));
default = [ ];
description = "Destination ports or ranges to match (tcp/udp only); empty means all.";
};
sourcePorts = mkOption {
type = types.listOf (types.either types.port (types.strMatching "[0-9]+-[0-9]+"));
default = [ ];
description = "Source ports or ranges to match (tcp/udp only).";
};
sources = mkOption {
type = types.listOf types.str;
default = [ ];
description = "Only match traffic from these IPv4 addresses or CIDRs; empty means any.";
};
destination = mkOption {
type = types.nullOr types.str;
default = null;
example = "203.0.113.7";
description = ''
Only match traffic addressed to this IPv4 address or CIDR.
For `dnat` null means the router's own addresses (from `wan`:
anything that arrives on the uplink).
'';
};
toAddress = mkOption {
type = types.nullOr types.str;
default = null;
example = "192.168.10.5";
description = "`dnat`: the host to send the traffic to. `snat`: the source address to use. Not for `masquerade`.";
};
toPort = mkOption {
type = types.nullOr types.port;
default = null;
description = "`dnat`: the port on the host when it differs (needs exactly one `ports` entry).";
};
hairpin = mkOption {
type = types.bool;
default = false;
description = ''
`dnat` from `wan` only: let LANs with `internet = true` reach
the forward through the router's public address (NAT
loopback, the source is masqueraded) and allow it to the host.
'';
};
};
}
);
};
firewall.rules = mkOption { firewall.rules = mkOption {
default = [ ]; default = [ ];
description = '' description = ''
@@ -402,7 +496,8 @@ in
the reserved zone `wan` (traffic arriving on the uplinks), which the reserved zone `wan` (traffic arriving on the uplinks), which
publishes services to the outside: such rules must name a publishes services to the outside: such rules must name a
`destination` and protocol (and ports for tcp/udp), so a typo cannot `destination` and protocol (and ports for tcp/udp), so a typo cannot
expose a whole zone. expose a whole zone. Address translation is separate (`firewall.nat`);
a port forward needs an allow rule as well, usually with `natOnly`.
''; '';
type = types.listOf ( type = types.listOf (
types.submodule { types.submodule {
@@ -433,15 +528,15 @@ in
]; ];
description = '' description = ''
Destination ports or ranges (tcp/udp only); empty means all Destination ports or ranges (tcp/udp only); empty means all
ports. With `dnat` these are the ports on the router's ports. After a port forward these are the translated ports
public address. (its `toPort`, if it has one).
''; '';
}; };
sourcePorts = mkOption { sourcePorts = mkOption {
type = types.listOf (types.either types.port (types.strMatching "[0-9]+-[0-9]+")); type = types.listOf (types.either types.port (types.strMatching "[0-9]+-[0-9]+"));
default = [ ]; default = [ ];
example = [ 53 ]; example = [ 53 ];
description = "Source ports or ranges (tcp/udp only); empty means any. Not for port forwards."; description = "Source ports or ranges (tcp/udp only); empty means any.";
}; };
destination = mkOption { destination = mkOption {
type = types.nullOr types.str; type = types.nullOr types.str;
@@ -460,26 +555,13 @@ in
example = [ "198.51.100.0/24" ]; example = [ "198.51.100.0/24" ];
description = "Only match traffic from these IPv4/IPv6 addresses or CIDRs; empty means any source."; description = "Only match traffic from these IPv4/IPv6 addresses or CIDRs; empty means any source.";
}; };
dnat = mkOption { natOnly = mkOption {
type = types.bool; type = types.bool;
default = false; default = false;
description = '' description = ''
Port forward: traffic from `wan` to `ports` on the router's Only match connections that a `firewall.nat` port forward
public IPv4 address is redirected to `destination` (one IPv4 translated (the allow rule that goes with a forward), not
host). The source address is preserved. traffic addressed directly to the destination.
'';
};
toPort = mkOption {
type = types.nullOr types.port;
default = null;
description = "Port on `destination` when it differs from the public port (single-port forwards only).";
};
hairpin = mkOption {
type = types.bool;
default = false;
description = ''
Let LANs with `internet = true` reach this forward through the
router's public address (NAT loopback, source is masqueraded).
''; '';
}; };
rateLimit = mkOption { rateLimit = mkOption {
+63 -30
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/netip" "net/netip"
"reflect"
"regexp" "regexp"
"slices" "slices"
"strconv" "strconv"
@@ -22,6 +23,9 @@ type FirewallRule struct {
Destination string // one address or CIDR, empty: the whole zone Destination string // one address or CIDR, empty: the whole zone
Ports string // destination ports Ports string // destination ports
Protocol string Protocol string
NATOnly bool // only connections that a port forward translated
Log bool // log new connections
RateLimit string // per source address, like 10/minute
Remove bool Remove bool
} }
@@ -36,7 +40,7 @@ type ZoneMember struct {
var portRangeRe = regexp.MustCompile(`^[0-9]+(-[0-9]+)?$`) var portRangeRe = regexp.MustCompile(`^[0-9]+(-[0-9]+)?$`)
// simpleRuleKeys are the settings keys the page edits. // simpleRuleKeys are the settings keys the page edits.
var simpleRuleKeys = []string{"from", "to", "protocol", "ports", "sourcePorts", "destination", "sources"} var simpleRuleKeys = []string{"from", "to", "protocol", "ports", "sourcePorts", "destination", "sources", "natOnly", "log", "rateLimit"}
// zoneMembers are the LANs, then the tunnels, by name. A member is in a zone // zoneMembers are the LANs, then the tunnels, by name. A member is in a zone
// named after itself unless it says otherwise. // named after itself unless it says otherwise.
@@ -85,9 +89,6 @@ func (s Settings) zones() []string {
} }
func ruleSimple(r map[string]any) bool { func ruleSimple(r map[string]any) bool {
if str(r, "from") == "wan" {
return false
}
for k := range r { for k := range r {
if !slices.Contains(simpleRuleKeys, k) { if !slices.Contains(simpleRuleKeys, k) {
return false return false
@@ -123,9 +124,6 @@ func (s Settings) firewallRules() (simple []FirewallRule, other []string) {
if r == nil { if r == nil {
continue continue
} }
if wanRuleSimple(r) {
continue
}
if !ruleSimple(r) { if !ruleSimple(r) {
desc := str(r, "from") + " → " + str(r, "to") desc := str(r, "from") + " → " + str(r, "to")
if d := str(r, "destination"); d != "" { if d := str(r, "destination"); d != "" {
@@ -140,9 +138,6 @@ func (s Settings) firewallRules() (simple []FirewallRule, other []string) {
if p := portsText(r["ports"]); p != "" { if p := portsText(r["ports"]); p != "" {
desc += " " + p desc += " " + p
} }
if dnat, _ := r["dnat"].(bool); dnat {
desc += " (port forward)"
}
other = append(other, desc) other = append(other, desc)
continue continue
} }
@@ -152,7 +147,8 @@ func (s Settings) firewallRules() (simple []FirewallRule, other []string) {
} }
simple = append(simple, FirewallRule{From: str(r, "from"), To: str(r, "to"), Protocol: proto, simple = append(simple, FirewallRule{From: str(r, "from"), To: str(r, "to"), Protocol: proto,
Ports: portsText(r["ports"]), SourcePorts: portsText(r["sourcePorts"]), Ports: portsText(r["ports"]), SourcePorts: portsText(r["sourcePorts"]),
Sources: strings.Join(stringList(r, "sources"), ", "), Destination: str(r, "destination")}) Sources: strings.Join(stringList(r, "sources"), ", "), Destination: str(r, "destination"),
NATOnly: boolOf(r, "natOnly"), Log: boolOf(r, "log"), RateLimit: str(r, "rateLimit")})
} }
return simple, other return simple, other
} }
@@ -263,7 +259,7 @@ func (s Settings) applyZones(zf ZoneForm, errs FormErrors) {
// applyFirewall changes the zones, then replaces the rules the page edits with // applyFirewall changes the zones, then replaces the rules the page edits with
// rows (an untouched "add" row is skipped); the other rules stay as they are. // rows (an untouched "add" row is skipped); the other rules stay as they are.
func (s Settings) applyFirewall(zf ZoneForm, rows []FirewallRule, fwds []WANRule) error { func (s Settings) applyFirewall(zf ZoneForm, rows []FirewallRule, nats []NATRule) error {
errs := FormErrors{} errs := FormErrors{}
s.applyZones(zf, errs) s.applyZones(zf, errs)
zones := s.zones() zones := s.zones()
@@ -280,7 +276,7 @@ func (s Settings) applyFirewall(zf ZoneForm, rows []FirewallRule, fwds []WANRule
if r.Remove || (r.From == "" && r.To == "" && r.Ports == "" && r.SourcePorts == "" && r.Sources == "" && r.Destination == "") { if r.Remove || (r.From == "" && r.To == "" && r.Ports == "" && r.SourcePorts == "" && r.Sources == "" && r.Destination == "") {
continue continue
} }
if !slices.Contains(zones, r.From) { if !slices.Contains(zones, r.From) && r.From != "wan" {
errs[field("from")] = "choose a zone" errs[field("from")] = "choose a zone"
} }
if !slices.Contains(zones, r.To) { if !slices.Contains(zones, r.To) {
@@ -314,11 +310,35 @@ func (s Settings) applyFirewall(zf ZoneForm, rows []FirewallRule, fwds []WANRule
if r.Destination != "" && !validAddress(r.Destination) { if r.Destination != "" && !validAddress(r.Destination) {
errs[field("destination")] = "one address or CIDR, or empty for the whole zone" errs[field("destination")] = "one address or CIDR, or empty for the whole zone"
} }
if r.From == "wan" {
// What the module requires, so that a typo cannot open a whole zone.
if r.Destination == "" {
errs[field("destination")] = "rules from the internet must name the host or network inside"
}
if r.Protocol == "any" {
errs[field("protocol")] = "rules from the internet must name a protocol"
} else if r.Protocol != "icmp" && len(ports) == 0 {
errs[field("ports")] = "rules from the internet must list the ports they open"
}
}
r.RateLimit = strings.TrimSpace(r.RateLimit)
if r.RateLimit != "" && !rateLimitRe.MatchString(r.RateLimit) {
errs[field("ratelimit")] = "like 10/minute (second, minute or hour)"
}
if seen[r] { if seen[r] {
errs[field("from")] = "the same rule twice" errs[field("from")] = "the same rule twice"
} }
seen[r] = true seen[r] = true
rule := map[string]any{"from": r.From, "to": r.To} rule := map[string]any{"from": r.From, "to": r.To}
if r.NATOnly {
rule["natOnly"] = true
}
if r.Log {
rule["log"] = true
}
if r.RateLimit != "" {
rule["rateLimit"] = r.RateLimit
}
if r.Protocol != "any" { if r.Protocol != "any" {
rule["protocol"] = r.Protocol rule["protocol"] = r.Protocol
} }
@@ -336,16 +356,21 @@ func (s Settings) applyFirewall(zf ZoneForm, rows []FirewallRule, fwds []WANRule
} }
next = append(next, rule) next = append(next, rule)
} }
next = append(next, s.forwardRules(fwds, zones, errs)...) natRows, allows := s.natSettings(nats, zones, errs)
for _, a := range allows {
if !slices.ContainsFunc(next, func(v any) bool { return reflect.DeepEqual(v, a) }) {
next = append(next, a)
}
}
router := obj(s, "router") router := obj(s, "router")
fw := obj(router, "firewall") fw := obj(router, "firewall")
old, _ := fw["rules"].([]any) old, _ := fw["rules"].([]any)
for _, v := range old { for _, v := range old {
r, _ := v.(map[string]any) r, _ := v.(map[string]any)
if r == nil || ruleSimple(r) || wanRuleSimple(r) { if r == nil || ruleSimple(r) {
continue continue
} }
// Port forwards and the like are not edited here, but must keep a zone to point at. // Rules with options not edited here must keep their zones.
if f := str(r, "from"); f != "wan" && !slices.Contains(zones, f) { if f := str(r, "from"); f != "wan" && !slices.Contains(zones, f) {
errs["zone."+f] = "a rule that is not editable here still uses this zone" errs["zone."+f] = "a rule that is not editable here still uses this zone"
} }
@@ -362,6 +387,11 @@ func (s Settings) applyFirewall(zf ZoneForm, rows []FirewallRule, fwds []WANRule
} else { } else {
fw["rules"] = next fw["rules"] = next
} }
if len(natRows) == 0 {
delete(fw, "nat")
} else {
fw["nat"] = natRows
}
if len(fw) == 0 { if len(fw) == 0 {
delete(router, "firewall") delete(router, "firewall")
} }
@@ -370,7 +400,8 @@ func (s Settings) applyFirewall(zf ZoneForm, rows []FirewallRule, fwds []WANRule
type firewallData struct { type firewallData struct {
Rows []FirewallRule Rows []FirewallRule
Forwards []WANRule NATs []NATRule
FromZones []string // the zones and wan
Other []string Other []string
Zones []string Zones []string
Members []ZoneMember Members []ZoneMember
@@ -379,9 +410,10 @@ type firewallData struct {
Errors FormErrors Errors FormErrors
} }
func (s Settings) firewallData(rows []FirewallRule, fwds []WANRule, members []ZoneMember, errs FormErrors) firewallData { func (s Settings) firewallData(rows []FirewallRule, nats []NATRule, members []ZoneMember, errs FormErrors) firewallData {
_, other := s.firewallRules() _, other := s.firewallRules()
d := firewallData{Rows: rows, Forwards: fwds, Other: other, Zones: s.zones(), Members: members, Errors: errs} d := firewallData{Rows: rows, NATs: nats, Other: other, Zones: s.zones(), Members: members, Errors: errs}
d.FromZones = append([]string{"wan"}, d.Zones...)
if d.Members == nil { if d.Members == nil {
d.Members = s.zoneMembers() d.Members = s.zoneMembers()
} }
@@ -403,7 +435,7 @@ func (a *app) firewallPage(w http.ResponseWriter, r *http.Request, sess session)
} }
rows, _ := s.firewallRules() rows, _ := s.firewallRules()
add := FirewallRule{From: r.URL.Query().Get("from"), To: r.URL.Query().Get("to"), Protocol: "any"} add := FirewallRule{From: r.URL.Query().Get("from"), To: r.URL.Query().Get("to"), Protocol: "any"}
p.Data = s.firewallData(append(rows, add), append(s.portForwards(), WANRule{Protocol: "tcp", NAT: true}), nil, nil) p.Data = s.firewallData(append(rows, add), append(s.natRules(), NATRule{Type: "dnat", Protocol: "tcp", Allow: true}), nil, nil)
a.render(w, "firewall", p) a.render(w, "firewall", p)
} }
@@ -422,27 +454,28 @@ func (a *app) firewall(w http.ResponseWriter, r *http.Request, sess session) {
zf.Members = append(zf.Members, ZoneMember{Kind: v("kind"), Name: v("name"), Device: v("device"), Zone: v("zone")}) zf.Members = append(zf.Members, ZoneMember{Kind: v("kind"), Name: v("name"), Device: v("device"), Zone: v("zone")})
} }
n, _ := strconv.Atoi(r.PostFormValue("rows")) n, _ := strconv.Atoi(r.PostFormValue("rows"))
nf, _ := strconv.Atoi(r.PostFormValue("forwards")) nn, _ := strconv.Atoi(r.PostFormValue("nats"))
var fwds []WANRule var nats []NATRule
for i := range min(nf, 512) { for i := range min(nn, 512) {
v := func(k string) string { return r.PostFormValue(fmt.Sprintf("fwd.%d.%s", i, k)) } v := func(k string) string { return r.PostFormValue(fmt.Sprintf("nat.%d.%s", i, k)) }
fwds = append(fwds, WANRule{Protocol: v("protocol"), Ports: v("ports"), To: v("to"), Destination: v("destination"), nats = append(nats, NATRule{Type: v("type"), From: v("from"), To: v("to"), Protocol: v("protocol"), Ports: v("ports"),
ToPort: v("toport"), Sources: v("sources"), NAT: v("nat") == "on", Hairpin: v("hairpin") == "on", SourcePorts: v("sourceports"), Sources: v("sources"), Destination: v("destination"), ToAddress: v("toaddress"),
Log: v("log") == "on", RateLimit: v("ratelimit"), Remove: v("remove") == "on"}) ToPort: v("toport"), Hairpin: v("hairpin") == "on", Allow: v("allow") == "on", Remove: v("remove") == "on"})
} }
var rows []FirewallRule var rows []FirewallRule
for i := range min(n, 512) { for i := range min(n, 512) {
v := func(k string) string { return r.PostFormValue(fmt.Sprintf("rule.%d.%s", i, k)) } v := func(k string) string { return r.PostFormValue(fmt.Sprintf("rule.%d.%s", i, k)) }
rows = append(rows, FirewallRule{From: v("from"), Sources: v("sources"), SourcePorts: v("sourceports"), rows = append(rows, FirewallRule{From: v("from"), Sources: v("sources"), SourcePorts: v("sourceports"),
To: v("to"), Destination: v("destination"), Ports: v("ports"), Protocol: v("protocol"), Remove: v("remove") == "on"}) To: v("to"), Destination: v("destination"), Ports: v("ports"), Protocol: v("protocol"),
NATOnly: v("natonly") == "on", Log: v("log") == "on", RateLimit: v("ratelimit"), Remove: v("remove") == "on"})
} }
if err := s.applyFirewall(zf, rows, fwds); err != nil { if err := s.applyFirewall(zf, rows, nats); err != nil {
var fe FormErrors var fe FormErrors
errors.As(err, &fe) errors.As(err, &fe)
// Show what was entered; the zone lists are those before this attempt. // Show what was entered; the zone lists are those before this attempt.
s, _ = loadSettings(a.sys.settingsPath()) s, _ = loadSettings(a.sys.settingsPath())
p.Error = "Please correct the marked fields." p.Error = "Please correct the marked fields."
d := s.firewallData(rows, fwds, zf.Members, fe) d := s.firewallData(rows, nats, zf.Members, fe)
d.New = zf.New d.New = zf.New
p.Data = d p.Data = d
w.WriteHeader(http.StatusUnprocessableEntity) w.WriteHeader(http.StatusUnprocessableEntity)
+80 -56
View File
@@ -14,9 +14,9 @@ func TestFirewallRulesRoundTrip(t *testing.T) {
if got := s.zones(); strings.Join(got, ",") != "main,vpn" { if got := s.zones(); strings.Join(got, ",") != "main,vpn" {
t.Fatalf("zones = %v", got) t.Fatalf("zones = %v", got)
} }
// A rule the page does not edit (it logs) must survive. // A rule the page does not edit (it has a key the page lacks) must survive.
obj(obj(s, "router"), "firewall")["rules"] = []any{ obj(obj(s, "router"), "firewall")["rules"] = []any{
map[string]any{"from": "main", "to": "vpn", "log": true}, map[string]any{"from": "main", "to": "vpn", "extra": true},
} }
rows, other := s.firewallRules() rows, other := s.firewallRules()
if len(rows) != 0 || len(other) != 1 || !strings.Contains(other[0], "main → vpn") { if len(rows) != 0 || len(other) != 1 || !strings.Contains(other[0], "main → vpn") {
@@ -38,7 +38,7 @@ func TestFirewallRulesRoundTrip(t *testing.T) {
t.Errorf("rows = %+v", rows) t.Errorf("rows = %+v", rows)
} }
out := string(mustMarshal(t, s)) out := string(mustMarshal(t, s))
for _, want := range []string{`"log": true`, `"5000-5100"`} { for _, want := range []string{`"extra": true`, `"5000-5100"`} {
if !strings.Contains(out, want) { if !strings.Contains(out, want) {
t.Errorf("%s missing:\n%s", want, out) t.Errorf("%s missing:\n%s", want, out)
} }
@@ -235,98 +235,122 @@ func (s Settings) applyRules(zf ZoneForm, rows []FirewallRule) error {
return s.applyFirewall(zf, rows, nil) return s.applyFirewall(zf, rows, nil)
} }
func TestPortForwardsRoundTrip(t *testing.T) { func TestNATRoundTrip(t *testing.T) {
s := mustParse(t) s := mustParse(t)
obj(obj(s, "router"), "firewall")["rules"] = []any{ obj(obj(s, "router"), "firewall")["nat"] = []any{
map[string]any{"from": "main", "to": "vpn", "log": true, "rateLimit": "5/minute"}, // not editable here, kept map[string]any{"type": "dnat", "from": "wan", "to": "main", "toAddress": "192.168.10.5", "protocol": "tcp",
map[string]any{"from": "wan", "to": "main", "dnat": true, "destination": "192.168.10.5", "protocol": "tcp",
"ports": []any{float64(8080)}, "toPort": float64(80), "hairpin": true}, "ports": []any{float64(8080)}, "toPort": float64(80), "hairpin": true},
} }
got := s.portForwards() got := s.natRules()
if len(got) != 1 || got[0].Ports != "8080" || got[0].ToPort != "80" || !got[0].NAT || !got[0].Hairpin { if len(got) != 1 || got[0].Type != "dnat" || got[0].Ports != "8080" || got[0].ToPort != "80" || !got[0].Hairpin {
t.Fatalf("forwards = %+v", got) t.Fatalf("nat = %+v", got)
} }
if _, other := s.firewallRules(); len(other) != 1 || strings.Contains(other[0], "port forward") { add := []NATRule{
t.Errorf("other = %v (the forward is edited, the zone rule is not)", other)
}
add := []WANRule{
got[0], got[0],
{Protocol: "udp", Ports: "51000-51010", To: "main", Destination: "192.168.10.5", NAT: true, Sources: "198.51.100.0/24"}, {Type: "dnat", From: "wan", To: "main", Protocol: "udp", Ports: "51000-51010", ToAddress: "192.168.10.5", Sources: "198.51.100.0/24", Allow: true},
{Protocol: "tcp", Ports: "443", To: "main", Destination: "fd00::5"}, // an opening, no NAT {Type: "masquerade", From: "main", To: "wan", Sources: "192.168.10.0/24"},
{Protocol: "tcp", NAT: true}, // the empty add row {Type: "snat", To: "wan", ToAddress: "203.0.113.7", Destination: "10.0.0.0/8"},
{Type: "dnat", Protocol: "tcp"}, // the empty add row
} }
if err := s.applyFirewall(ZoneForm{}, nil, add); err != nil { if err := s.applyFirewall(ZoneForm{}, nil, add); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got := s.portForwards(); len(got) != 3 || got[1].Ports != "51000-51010" || got[2].NAT || got[2].Destination != "fd00::5" { got = s.natRules()
t.Fatalf("forwards = %+v", got) if len(got) != 4 || got[1].Ports != "51000-51010" || got[2].Type != "masquerade" || got[3].ToAddress != "203.0.113.7" {
t.Fatalf("nat = %+v", got)
} }
if !strings.Contains(string(mustMarshal(t, s)), `"rateLimit": "5/minute"`) { // "Also allow" added a filter rule; saving again does not add it twice.
t.Error("a rule the editor does not handle was lost")
}
add[0].Remove, add[1].Remove, add[2].Remove = true, true, true
if err := s.applyFirewall(ZoneForm{}, nil, add); err != nil { if err := s.applyFirewall(ZoneForm{}, nil, add); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got := s.portForwards(); len(got) != 0 { if n := strings.Count(string(mustMarshal(t, s)), `"natOnly": true`); n != 1 {
t.Errorf("%d natOnly rules, want 1:\n%s", n, mustMarshal(t, s))
}
for i := range add {
add[i].Remove = true
}
if err := s.applyFirewall(ZoneForm{}, nil, add); err != nil {
t.Fatal(err)
}
if got := s.natRules(); len(got) != 0 {
t.Errorf("not removed: %+v", got) t.Errorf("not removed: %+v", got)
} }
} }
func TestPortForwardsAreChecked(t *testing.T) { func TestNATRowsAreChecked(t *testing.T) {
good := WANRule{Protocol: "tcp", Ports: "8080", To: "main", Destination: "192.168.10.5", NAT: true} good := NATRule{Type: "dnat", From: "wan", To: "main", Protocol: "tcp", Ports: "8080", ToAddress: "192.168.10.5"}
for name, mod := range map[string]func(*WANRule){ for name, mod := range map[string]func(*NATRule){
"to": func(r *WANRule) { r.To = "wan" }, "type": func(r *NATRule) { r.Type = "bogus" },
"protocol": func(r *WANRule) { r.Protocol = "any" }, "from": func(r *NATRule) { r.From = "" },
"ports": func(r *WANRule) { r.Ports = "" }, "to": func(r *NATRule) { r.To = "nowhere" },
"destination": func(r *WANRule) { r.Destination = "10.9.9.9" }, // outside the LAN "protocol": func(r *NATRule) { r.Protocol = "any" },
"sources": func(r *WANRule) { r.Sources = "2001:db8::/32" }, "ports": func(r *NATRule) { r.Ports = "" },
"toport": func(r *WANRule) { r.ToPort = "80"; r.Ports = "80, 81" }, "toaddress": func(r *NATRule) { r.ToAddress = "10.9.9.9" }, // outside the LAN
"hairpin": func(r *WANRule) { r.NAT = false; r.Hairpin = true }, "sources": func(r *NATRule) { r.Sources = "2001:db8::/32" },
"ratelimit": func(r *WANRule) { r.RateLimit = "lots" }, "toport": func(r *NATRule) { r.ToPort = "80"; r.Ports = "80, 81" },
"hairpin": func(r *NATRule) { r.From = "main"; r.To = "main"; r.ToAddress = "192.168.10.5"; r.Hairpin = true },
} { } {
r := good r := good
mod(&r) mod(&r)
fe, ok := mustParse(t).applyFirewall(ZoneForm{}, nil, []WANRule{r}).(FormErrors) fe, ok := mustParse(t).applyFirewall(ZoneForm{}, nil, []NATRule{r}).(FormErrors)
if !ok || fe["fwd[0]."+name] == "" { if !ok || fe["nat[0]."+name] == "" {
t.Errorf("%s accepted: %v", name, fe) t.Errorf("%s accepted: %v", name, fe)
} }
} }
r := good m := NATRule{Type: "masquerade", From: "main", To: "main"}
r.Destination = "192.168.10.0/24" if fe, ok := mustParse(t).applyFirewall(ZoneForm{}, nil, []NATRule{m}).(FormErrors); !ok || fe["nat[0].to"] == "" {
if fe, ok := mustParse(t).applyFirewall(ZoneForm{}, nil, []WANRule{r}).(FormErrors); !ok || fe["fwd[0].destination"] == "" { t.Errorf("masquerade into the same zone accepted: %v", fe)
t.Errorf("a forward to a whole network accepted: %v", fe)
} }
r.NAT = false // an opening to a network is fine m = NATRule{Type: "masquerade", From: "main", To: "wan", ToAddress: "192.0.2.1"}
if err := mustParse(t).applyFirewall(ZoneForm{}, nil, []WANRule{r}); err != nil { if fe, ok := mustParse(t).applyFirewall(ZoneForm{}, nil, []NATRule{m}).(FormErrors); !ok || fe["nat[0].toaddress"] == "" {
t.Errorf("opening a network: %v", err) t.Errorf("masquerade with an address accepted: %v", fe)
} }
} }
func TestPortForwardPage(t *testing.T) { func TestWANFilterRule(t *testing.T) {
good := FirewallRule{From: "wan", To: "main", Destination: "192.168.10.5", Protocol: "tcp", Ports: "443", NATOnly: true, Log: true, RateLimit: "10/minute"}
if err := mustParse(t).applyRules(ZoneForm{}, []FirewallRule{good}); err != nil {
t.Fatal(err)
}
for name, mod := range map[string]func(*FirewallRule){
"destination": func(r *FirewallRule) { r.Destination = "" },
"protocol": func(r *FirewallRule) { r.Protocol = "any" },
"ports": func(r *FirewallRule) { r.Ports = "" },
"ratelimit": func(r *FirewallRule) { r.RateLimit = "lots" },
} {
r := good
mod(&r)
fe, ok := mustParse(t).applyRules(ZoneForm{}, []FirewallRule{r}).(FormErrors)
if !ok || fe["rule[0]."+name] == "" {
t.Errorf("%s accepted: %v", name, fe)
}
}
}
func TestNATPage(t *testing.T) {
a, sys := newTestApp(t) a, sys := newTestApp(t)
h := a.routes() h := a.routes()
cookie := do(h, "POST", "/login", url.Values{"password": {"hunter2hunter2"}}, nil).Result().Cookies()[0] cookie := do(h, "POST", "/login", url.Values{"password": {"hunter2hunter2"}}, nil).Result().Cookies()[0]
body := do(h, "GET", "/firewall", nil, cookie).Body.String() body := do(h, "GET", "/firewall", nil, cookie).Body.String()
if !strings.Contains(body, `name="forwards" value="1"`) || !strings.Contains(body, "fwd.0.nat") { if !strings.Contains(body, `name="nats" value="1"`) || !strings.Contains(body, "nat.0.type") {
t.Fatalf("no row to add a forward:\n%s", body) t.Fatalf("no row to add a NAT rule:\n%s", body)
} }
token := regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(body)[1] token := regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(body)[1]
form := url.Values{"csrf": {token}, "forwards": {"1"}, "fwd.0.protocol": {"tcp"}, "fwd.0.ports": {"8080"}, form := url.Values{"csrf": {token}, "nats": {"1"}, "nat.0.type": {"dnat"}, "nat.0.from": {"wan"}, "nat.0.protocol": {"tcp"},
"fwd.0.to": {"main"}, "fwd.0.destination": {"192.168.10.5"}, "fwd.0.nat": {"on"}, "fwd.0.toport": {"80"}, "fwd.0.hairpin": {"on"}} "nat.0.ports": {"8080"}, "nat.0.to": {"main"}, "nat.0.toaddress": {"10.9.9.9"}, "nat.0.toport": {"80"},
form.Set("fwd.0.destination", "192.168.10.0/24") "nat.0.hairpin": {"on"}, "nat.0.allow": {"on"}}
if rec := do(h, "POST", "/firewall", form, cookie); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "one IPv4 address") { if rec := do(h, "POST", "/firewall", form, cookie); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "not inside a network") {
t.Fatalf("a forward to a network: %d\n%s", rec.Code, rec.Body) t.Fatalf("a forward outside the LAN: %d\n%s", rec.Code, rec.Body)
} }
form.Set("fwd.0.destination", "192.168.10.5") form.Set("nat.0.toaddress", "192.168.10.5")
if rec := do(h, "POST", "/firewall", form, cookie); rec.Code != http.StatusSeeOther { if rec := do(h, "POST", "/firewall", form, cookie); rec.Code != http.StatusSeeOther {
t.Fatalf("save: %d\n%s", rec.Code, rec.Body) t.Fatalf("save: %d\n%s", rec.Code, rec.Body)
} }
s, _ := loadSettings(sys.settingsPath()) s, _ := loadSettings(sys.settingsPath())
if f := s.portForwards(); len(f) != 1 || f[0].ToPort != "80" || !f[0].Hairpin { if f := s.natRules(); len(f) != 1 || f[0].ToPort != "80" || !f[0].Hairpin {
t.Fatalf("not saved: %+v", f) t.Fatalf("not saved: %+v", f)
} }
if body := do(h, "GET", "/firewall", nil, cookie).Body.String(); !strings.Contains(body, `value="192.168.10.5"`) { if body := do(h, "GET", "/firewall", nil, cookie).Body.String(); !strings.Contains(body, `value="192.168.10.5"`) || !strings.Contains(body, `name="rule.0.natonly" checked`) {
t.Errorf("the forward is not listed:\n%s", body) t.Errorf("the forward or its allow rule is not listed:\n%s", body)
} }
} }
-226
View File
@@ -1,226 +0,0 @@
package main
import (
"fmt"
"net/netip"
"regexp"
"slices"
"strconv"
"strings"
)
// WANRule is a rule from the internet: a port forward (NAT, IPv4) or an
// opening to an address inside (IPv4 or IPv6, no translation).
type WANRule struct {
Protocol string // tcp, udp or icmp
Ports string // on the router's public address (NAT) or on the host
To string // zone
Destination string // the host; for a forward a single IPv4 address
ToPort string // the host's port when it differs (forwards, one port)
Sources string // only from these addresses, empty: anywhere
NAT bool
Hairpin bool
Log bool
RateLimit string // "10/minute" per source address
Remove bool
}
var rateLimitRe = regexp.MustCompile(`^[0-9]+/(second|minute|hour)$`)
// wanRuleKeys are the settings keys the port forward editor handles.
var wanRuleKeys = []string{"from", "to", "protocol", "ports", "destination", "toPort", "sources", "dnat", "hairpin", "rateLimit", "log"}
func wanRuleSimple(r map[string]any) bool {
if str(r, "from") != "wan" {
return false
}
for k := range r {
if !slices.Contains(wanRuleKeys, k) {
return false
}
}
return true
}
func boolOf(r map[string]any, k string) bool {
b, _ := r[k].(bool)
return b
}
// portForwards are the rules from the internet, in order.
func (s Settings) portForwards() []WANRule {
router, _ := s["router"].(map[string]any)
fw, _ := router["firewall"].(map[string]any)
rules, _ := fw["rules"].([]any)
var out []WANRule
for _, v := range rules {
r, _ := v.(map[string]any)
if r == nil || !wanRuleSimple(r) {
continue
}
out = append(out, WANRule{Protocol: str(r, "protocol"), Ports: portsText(r["ports"]), To: str(r, "to"),
Destination: str(r, "destination"), ToPort: str(r, "toPort"), Sources: strings.Join(stringList(r, "sources"), ", "),
NAT: boolOf(r, "dnat"), Hairpin: boolOf(r, "hairpin"), Log: boolOf(r, "log"), RateLimit: str(r, "rateLimit")})
}
return out
}
// zoneV4Nets are the IPv4 networks of a zone's LANs; ok is false when a tunnel
// is in the zone too (its networks are the peers' routes, not checked here).
func (s Settings) zoneV4Nets(zone string) (nets []netip.Prefix, ok bool) {
lans := map[string]netip.Prefix{}
for _, l := range s.form().LANs {
a, aok := ipv4(l.Address)
if !aok {
continue
}
bits := 24
if n, nok, _ := optionalInt(l.Prefix, 8, 30); nok {
bits = n
}
lans[l.Name] = netip.PrefixFrom(a, bits).Masked()
}
for _, m := range s.zoneMembers() {
if m.Zone != zone {
continue
}
if m.Kind != "lan" {
return nil, false
}
if p, found := lans[m.Name]; found {
nets = append(nets, p)
}
}
return nets, len(nets) > 0
}
// forwardRules checks the rows and returns them as settings rules.
func (s Settings) forwardRules(rows []WANRule, zones []string, errs FormErrors) []any {
var out []any
seen := map[WANRule]bool{}
for i, r := range rows {
field := func(n string) string { return fmt.Sprintf("fwd[%d].%s", i, n) }
r.Ports, r.Sources = strings.Join(splitList(r.Ports), ", "), strings.Join(splitList(r.Sources), ", ")
r.To, r.Destination = strings.TrimSpace(r.To), strings.TrimSpace(r.Destination)
r.ToPort, r.RateLimit = strings.TrimSpace(r.ToPort), strings.TrimSpace(r.RateLimit)
if r.Remove || (r.Ports == "" && r.To == "" && r.Destination == "" && r.ToPort == "" && r.Sources == "" && r.RateLimit == "") {
continue
}
if !slices.Contains(zones, r.To) {
errs[field("to")] = "choose a zone"
}
if !slices.Contains([]string{"tcp", "udp", "icmp"}, r.Protocol) {
errs[field("protocol")] = "tcp, udp or icmp"
}
var ports []any
if r.Protocol == "icmp" {
if r.Ports != "" {
errs[field("ports")] = "icmp has no ports"
}
} else {
var msg string
if ports, msg = portList(r.Ports); msg != "" {
errs[field("ports")] = msg
} else if len(ports) == 0 {
errs[field("ports")] = "name the ports to open"
}
}
dest, destOK := netip.Prefix{}, false
switch {
case r.Destination == "":
errs[field("destination")] = "the host or network inside"
case !validAddress(r.Destination):
errs[field("destination")] = "an address (or CIDR)"
default:
if p, err := netip.ParsePrefix(r.Destination); err == nil {
dest = p
if p.Masked() != p {
errs[field("destination")] = "has host bits set"
}
} else {
a, _ := netip.ParseAddr(r.Destination)
dest = netip.PrefixFrom(a, a.BitLen())
}
destOK = errs[field("destination")] == ""
}
if destOK && dest.Addr().Is4() {
if nets, ok := s.zoneV4Nets(r.To); ok && !slices.ContainsFunc(nets, func(n netip.Prefix) bool {
return n.Bits() <= dest.Bits() && n.Contains(dest.Addr())
}) {
errs[field("destination")] = "not inside a network of zone " + r.To
}
}
var sources []any
for _, a := range splitList(r.Sources) {
switch {
case !validAddress(a):
errs[field("sources")] = a + " is not an address or CIDR"
case r.NAT && strings.Contains(a, ":"):
errs[field("sources")] = "port forwards are IPv4 only"
}
sources = append(sources, a)
}
if r.NAT {
if r.Protocol == "icmp" {
errs[field("protocol")] = "a port forward needs tcp or udp"
}
if destOK && (!dest.Addr().Is4() || dest.Bits() != 32) {
errs[field("destination")] = "a port forward goes to one IPv4 address"
}
}
toPort := 0
if r.ToPort != "" {
n, err := strconv.Atoi(r.ToPort)
switch {
case err != nil || n < 1 || n > 65535:
errs[field("toport")] = "a port number"
case !r.NAT:
errs[field("toport")] = "only for port forwards"
case len(ports) != 1 || strings.Contains(r.Ports, "-"):
errs[field("toport")] = "needs exactly one public port"
}
toPort = n
}
if r.Hairpin && !r.NAT {
errs[field("hairpin")] = "only for port forwards"
}
if r.RateLimit != "" && !rateLimitRe.MatchString(r.RateLimit) {
errs[field("ratelimit")] = "like 10/minute (second, minute or hour)"
}
if seen[r] {
errs[field("to")] = "the same rule twice"
}
seen[r] = true
rule := map[string]any{"from": "wan", "to": r.To, "protocol": r.Protocol}
if len(ports) > 0 {
rule["ports"] = ports
}
if destOK {
if r.NAT || dest.Bits() == dest.Addr().BitLen() {
rule["destination"] = dest.Addr().String()
} else {
rule["destination"] = dest.String()
}
}
if len(sources) > 0 {
rule["sources"] = sources
}
if r.NAT {
rule["dnat"] = true
}
if toPort > 0 {
rule["toPort"] = toPort
}
if r.Hairpin {
rule["hairpin"] = true
}
if r.Log {
rule["log"] = true
}
if r.RateLimit != "" {
rule["rateLimit"] = r.RateLimit
}
out = append(out, rule)
}
return out
}
+241
View File
@@ -0,0 +1,241 @@
package main
import (
"fmt"
"net/netip"
"regexp"
"slices"
"strconv"
"strings"
)
// NATRule is a row of the address translation table (router.firewall.nat).
type NATRule struct {
Type string // dnat, snat or masquerade
From string // zone, "wan", or empty (any) for snat and masquerade
To string // dnat: the host's zone; snat and masquerade: the zone or wan it leaves through
Protocol string
Ports string // destination ports to match
SourcePorts string
Sources string // IPv4 addresses or CIDRs to match
Destination string // IPv4 address or CIDR the traffic is addressed to; empty: the router itself
ToAddress string // dnat: the host; snat: the source address to use
ToPort string // dnat: the host's port when it differs
Hairpin bool // dnat from wan: LANs reach it through the public address too
Allow bool // form only: also add the filter rule that lets a forward through
Remove bool
}
var rateLimitRe = regexp.MustCompile(`^[0-9]+/(second|minute|hour)$`)
func boolOf(r map[string]any, k string) bool {
b, _ := r[k].(bool)
return b
}
func (s Settings) natRules() []NATRule {
router, _ := s["router"].(map[string]any)
fw, _ := router["firewall"].(map[string]any)
rows, _ := fw["nat"].([]any)
var out []NATRule
for _, v := range rows {
r, _ := v.(map[string]any)
if r == nil {
continue
}
proto := str(r, "protocol")
if proto == "" {
proto = "any"
}
out = append(out, NATRule{Type: str(r, "type"), From: str(r, "from"), To: str(r, "to"), Protocol: proto,
Ports: portsText(r["ports"]), SourcePorts: portsText(r["sourcePorts"]), Sources: strings.Join(stringList(r, "sources"), ", "),
Destination: str(r, "destination"), ToAddress: str(r, "toAddress"), ToPort: str(r, "toPort"), Hairpin: boolOf(r, "hairpin")})
}
return out
}
// zoneV4Nets are the IPv4 networks of a zone's LANs; ok is false when a tunnel
// is in the zone too (its networks are the peers' routes, not checked here).
func (s Settings) zoneV4Nets(zone string) (nets []netip.Prefix, ok bool) {
lans := map[string]netip.Prefix{}
for _, l := range s.form().LANs {
a, aok := ipv4(l.Address)
if !aok {
continue
}
bits := 24
if n, nok, _ := optionalInt(l.Prefix, 8, 30); nok {
bits = n
}
lans[l.Name] = netip.PrefixFrom(a, bits).Masked()
}
for _, m := range s.zoneMembers() {
if m.Zone != zone {
continue
}
if m.Kind != "lan" {
return nil, false
}
if p, found := lans[m.Name]; found {
nets = append(nets, p)
}
}
return nets, len(nets) > 0
}
func validV4Net(v string) bool {
if p, err := netip.ParsePrefix(v); err == nil {
return p.Addr().Is4()
}
a, err := netip.ParseAddr(v)
return err == nil && a.Is4()
}
// natSettings checks the rows and returns them as settings, together with the
// filter rules the rows ask for ("also allow").
func (s Settings) natSettings(rows []NATRule, zones []string, errs FormErrors) (nat []any, allows []any) {
seen := map[string]bool{}
for i, r := range rows {
field := func(n string) string { return fmt.Sprintf("nat[%d].%s", i, n) }
r.From, r.To = strings.TrimSpace(r.From), strings.TrimSpace(r.To)
r.Ports, r.SourcePorts = strings.Join(splitList(r.Ports), ", "), strings.Join(splitList(r.SourcePorts), ", ")
r.Sources = strings.Join(splitList(r.Sources), ", ")
r.Destination, r.ToAddress, r.ToPort = strings.TrimSpace(r.Destination), strings.TrimSpace(r.ToAddress), strings.TrimSpace(r.ToPort)
if r.Protocol == "" {
r.Protocol = "any"
}
if r.Remove || (r.From == "" && r.To == "" && r.Ports == "" && r.SourcePorts == "" && r.Sources == "" &&
r.Destination == "" && r.ToAddress == "" && r.ToPort == "") {
continue
}
dnat, snat := r.Type == "dnat", r.Type == "snat"
if !dnat && !snat && r.Type != "masquerade" {
errs[field("type")] = "port forward, source NAT or masquerade"
}
inZones := func(z string) bool { return slices.Contains(zones, z) || z == "wan" }
switch {
case dnat && r.From == "":
errs[field("from")] = "where the traffic comes from"
case r.From != "" && !inZones(r.From):
errs[field("from")] = "choose a zone"
}
switch {
case dnat && !slices.Contains(zones, r.To):
errs[field("to")] = "the zone of the host"
case !dnat && !inZones(r.To):
errs[field("to")] = "choose a zone"
case !dnat && r.From == r.To:
errs[field("to")] = "source and destination are the same zone"
}
if !slices.Contains([]string{"any", "tcp", "udp", "icmp"}, r.Protocol) {
errs[field("protocol")] = "any, tcp, udp or icmp"
}
ports, msg := portList(r.Ports)
if msg != "" {
errs[field("ports")] = msg
}
sports, msg := portList(r.SourcePorts)
if msg != "" {
errs[field("sourceports")] = msg
}
if (len(ports) > 0 || len(sports) > 0) && r.Protocol != "tcp" && r.Protocol != "udp" {
errs[field("ports")] = "ports need protocol tcp or udp"
}
var sources []any
for _, a := range splitList(r.Sources) {
if !validV4Net(a) {
errs[field("sources")] = a + " is not an IPv4 address or CIDR"
break
}
sources = append(sources, a)
}
if r.Destination != "" && !validV4Net(r.Destination) {
errs[field("destination")] = "an IPv4 address or CIDR"
}
switch {
case dnat || snat:
if a, ok := ipv4(r.ToAddress); !ok {
errs[field("toaddress")] = "an IPv4 address"
} else if dnat {
if nets, ok := s.zoneV4Nets(r.To); ok && !slices.ContainsFunc(nets, func(n netip.Prefix) bool { return n.Contains(a) }) {
errs[field("toaddress")] = "not inside a network of zone " + r.To
}
}
case r.ToAddress != "":
errs[field("toaddress")] = "masquerade uses the outgoing interface's address"
}
toPort := 0
if r.ToPort != "" {
n, err := strconv.Atoi(r.ToPort)
switch {
case !dnat:
errs[field("toport")] = "only for port forwards"
case err != nil || n < 1 || n > 65535:
errs[field("toport")] = "a port number"
case len(ports) != 1 || strings.Contains(r.Ports, "-"):
errs[field("toport")] = "needs exactly one forwarded port"
}
toPort = n
}
if dnat {
if r.Protocol != "tcp" && r.Protocol != "udp" {
errs[field("protocol")] = "a port forward needs tcp or udp"
} else if len(ports) == 0 {
errs[field("ports")] = "name the ports to forward"
}
}
if r.Hairpin && (!dnat || r.From != "wan") {
errs[field("hairpin")] = "only for port forwards from wan"
}
key := fmt.Sprint(r.Type, r.From, r.To, r.Protocol, r.Ports, r.SourcePorts, r.Sources, r.Destination, r.ToAddress, r.ToPort)
if seen[key] {
errs[field("type")] = "the same rule twice"
}
seen[key] = true
row := map[string]any{"type": r.Type, "to": r.To}
if r.From != "" {
row["from"] = r.From
}
if r.Protocol != "any" {
row["protocol"] = r.Protocol
}
if len(ports) > 0 {
row["ports"] = ports
}
if len(sports) > 0 {
row["sourcePorts"] = sports
}
if len(sources) > 0 {
row["sources"] = sources
}
if r.Destination != "" {
row["destination"] = r.Destination
}
if r.ToAddress != "" && (dnat || snat) {
row["toAddress"] = r.ToAddress
}
if toPort > 0 {
row["toPort"] = toPort
}
if r.Hairpin {
row["hairpin"] = true
}
nat = append(nat, row)
if r.Allow && dnat {
// What lets the forward through: translated connections to the host.
allow := map[string]any{"from": r.From, "to": r.To, "destination": r.ToAddress, "protocol": r.Protocol, "natOnly": true}
switch {
case toPort > 0:
allow["ports"] = []any{toPort}
case len(ports) > 0:
allow["ports"] = ports
}
if len(sources) > 0 {
allow["sources"] = sources
}
allows = append(allows, allow)
}
}
return nat, allows
}
+40 -28
View File
@@ -40,11 +40,11 @@
<input type="hidden" name="rows" value="{{len .Rows}}"> <input type="hidden" name="rows" value="{{len .Rows}}">
<div class="scroll"> <div class="scroll">
<table> <table>
<tr><th>From zone</th><th>From address</th><th>From port</th><th>To zone</th><th>To address</th><th>To port</th><th>Protocol</th><th>Remove</th></tr> <tr><th>From zone</th><th>From address</th><th>From port</th><th>To zone</th><th>To address</th><th>To port</th><th>Protocol</th><th title="only connections that a port forward translated">Forwarded only</th><th>Log</th><th>Rate limit</th><th>Remove</th></tr>
{{range $i, $r := .Rows}} {{range $i, $r := .Rows}}
<tr> <tr>
<td><select name="rule.{{$i}}.from"><option value=""></option> <td><select name="rule.{{$i}}.from"><option value=""></option>
{{range $.Data.Zones}}<option value="{{.}}" {{if eq . $r.From}}selected{{end}}>{{.}}</option>{{end}} {{range $.Data.FromZones}}<option value="{{.}}" {{if eq . $r.From}}selected{{end}}>{{.}}</option>{{end}}
</select></td> </select></td>
<td><input name="rule.{{$i}}.sources" value="{{$r.Sources}}" size="16" spellcheck="false" placeholder="any"></td> <td><input name="rule.{{$i}}.sources" value="{{$r.Sources}}" size="16" spellcheck="false" placeholder="any"></td>
<td><input name="rule.{{$i}}.sourceports" value="{{$r.SourcePorts}}" size="8" placeholder="any"></td> <td><input name="rule.{{$i}}.sourceports" value="{{$r.SourcePorts}}" size="8" placeholder="any"></td>
@@ -56,50 +56,62 @@
<td><select name="rule.{{$i}}.protocol"> <td><select name="rule.{{$i}}.protocol">
{{range $p := list "any" "tcp" "udp" "icmp"}}<option value="{{$p}}" {{if eq $p $r.Protocol}}selected{{end}}>{{$p}}</option>{{end}} {{range $p := list "any" "tcp" "udp" "icmp"}}<option value="{{$p}}" {{if eq $p $r.Protocol}}selected{{end}}>{{$p}}</option>{{end}}
</select></td> </select></td>
<td><input type="checkbox" name="rule.{{$i}}.natonly" {{if $r.NATOnly}}checked{{end}}></td>
<td><input type="checkbox" name="rule.{{$i}}.log" {{if $r.Log}}checked{{end}}></td>
<td><input name="rule.{{$i}}.ratelimit" value="{{$r.RateLimit}}" size="9" placeholder="none"></td>
<td>{{if or $r.From $r.To}}<input type="checkbox" name="rule.{{$i}}.remove">{{end}}</td> <td>{{if or $r.From $r.To}}<input type="checkbox" name="rule.{{$i}}.remove">{{end}}</td>
</tr> </tr>
{{range $f := list "from" "sources" "sourcePorts" "to" "destination" "ports" "protocol"}}{{with index $e (printf "rule[%d].%s" $i $f)}}<tr><td colspan="8" class="field-error">{{$f}}: {{.}}</td></tr>{{end}}{{end}} {{range $f := list "from" "sources" "sourcePorts" "to" "destination" "ports" "protocol" "ratelimit"}}{{with index $e (printf "rule[%d].%s" $i $f)}}<tr><td colspan="11" class="field-error">{{$f}}: {{.}}</td></tr>{{end}}{{end}}
{{end}} {{end}}
</table> </table>
</div> </div>
<p class="hint">Fill in the last row to add a rule. Addresses (several separated by commas) are IPv4 or IPv6, single or CIDR; <p class="hint">Fill in the last row to add a rule. Addresses (several separated by commas) are IPv4 or IPv6, single or CIDR;
to address is one address or network. Ports (tcp and udp only): <code>22, 443, 5000-5100</code>.</p> to address is one address or network. Ports (tcp and udp only): <code>22, 443, 5000-5100</code>.
From <code>wan</code> (the internet) a rule must name the address inside, the protocol and the ports, and only lets
traffic through; a port forward is set up below. <strong>Forwarded only</strong> limits a rule to connections that a
port forward translated. Rate limit caps new connections per sender, like <code>10/minute</code>.</p>
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>From the internet</legend> <legend>Address translation (NAT)</legend>
<input type="hidden" name="forwards" value="{{len .Forwards}}"> <input type="hidden" name="nats" value="{{len .NATs}}">
<div class="scroll"> <div class="scroll">
<table> <table>
<tr><th>Protocol</th><th>Public port</th><th>To zone</th><th>To address</th><th>To port</th><th>Only from</th><th>Port forward</th><th>Hairpin</th><th>Log</th><th>Rate limit</th><th>Remove</th></tr> <tr><th>Type</th><th>From zone</th><th>From address</th><th>Sent to</th><th>Protocol</th><th>Port</th><th>To zone</th><th>Translate to</th><th>To port</th><th>Hairpin</th><th>Also allow</th><th>Remove</th></tr>
{{range $i, $r := .Forwards}} {{range $i, $r := .NATs}}
<tr> <tr>
<td><select name="fwd.{{$i}}.protocol"> <td><select name="nat.{{$i}}.type">
{{range $p := list "tcp" "udp" "icmp"}}<option value="{{$p}}" {{if eq $p $r.Protocol}}selected{{end}}>{{$p}}</option>{{end}} {{range $p := list "dnat" "snat" "masquerade"}}<option value="{{$p}}" {{if eq $p $r.Type}}selected{{end}}>{{if eq $p "dnat"}}port forward{{else if eq $p "snat"}}source NAT{{else}}masquerade{{end}}</option>{{end}}
</select></td> </select></td>
<td><input name="fwd.{{$i}}.ports" value="{{$r.Ports}}" size="10" placeholder="443"></td> <td><select name="nat.{{$i}}.from"><option value="">any</option>
<td><select name="fwd.{{$i}}.to"><option value=""></option> {{range $.Data.FromZones}}<option value="{{.}}" {{if eq . $r.From}}selected{{end}}>{{.}}</option>{{end}}
{{range $.Data.Zones}}<option value="{{.}}" {{if eq . $r.To}}selected{{end}}>{{.}}</option>{{end}}
</select></td> </select></td>
<td><input name="fwd.{{$i}}.destination" value="{{$r.Destination}}" size="16" spellcheck="false" placeholder="192.168.10.5"></td> <td><input name="nat.{{$i}}.sources" value="{{$r.Sources}}" size="14" spellcheck="false" placeholder="any"></td>
<td><input name="fwd.{{$i}}.toport" value="{{$r.ToPort}}" size="6" placeholder="same"></td> <td><input name="nat.{{$i}}.destination" value="{{$r.Destination}}" size="14" spellcheck="false" placeholder="the router"></td>
<td><input name="fwd.{{$i}}.sources" value="{{$r.Sources}}" size="14" spellcheck="false" placeholder="anywhere"></td> <td><select name="nat.{{$i}}.protocol">
<td><input type="checkbox" name="fwd.{{$i}}.nat" {{if $r.NAT}}checked{{end}}></td> {{range $p := list "any" "tcp" "udp" "icmp"}}<option value="{{$p}}" {{if eq $p $r.Protocol}}selected{{end}}>{{$p}}</option>{{end}}
<td><input type="checkbox" name="fwd.{{$i}}.hairpin" {{if $r.Hairpin}}checked{{end}}></td> </select></td>
<td><input type="checkbox" name="fwd.{{$i}}.log" {{if $r.Log}}checked{{end}}></td> <td><input name="nat.{{$i}}.ports" value="{{$r.Ports}}" size="10" placeholder="443"></td>
<td><input name="fwd.{{$i}}.ratelimit" value="{{$r.RateLimit}}" size="9" placeholder="none"></td> <td><select name="nat.{{$i}}.to"><option value=""></option>
<td>{{if or $r.Ports $r.Destination}}<input type="checkbox" name="fwd.{{$i}}.remove">{{end}}</td> {{range $.Data.FromZones}}<option value="{{.}}" {{if eq . $r.To}}selected{{end}}>{{.}}</option>{{end}}
</select></td>
<td><input name="nat.{{$i}}.toaddress" value="{{$r.ToAddress}}" size="14" spellcheck="false" placeholder="192.168.10.5"></td>
<td><input name="nat.{{$i}}.toport" value="{{$r.ToPort}}" size="6" placeholder="same"></td>
<td><input type="checkbox" name="nat.{{$i}}.hairpin" {{if $r.Hairpin}}checked{{end}}></td>
<td>{{if or $r.ToAddress $r.Ports}}{{if $r.Allow}}<input type="checkbox" name="nat.{{$i}}.allow" checked>{{else}}<input type="checkbox" name="nat.{{$i}}.allow">{{end}}{{end}}</td>
<td>{{if or $r.ToAddress $r.Ports $r.To}}<input type="checkbox" name="nat.{{$i}}.remove">{{end}}</td>
</tr> </tr>
{{range $f := list "protocol" "ports" "to" "destination" "toport" "sources" "hairpin" "ratelimit"}}{{with index $e (printf "fwd[%d].%s" $i $f)}}<tr><td colspan="11" class="field-error">{{$f}}: {{.}}</td></tr>{{end}}{{end}} {{range $f := list "type" "from" "sources" "destination" "protocol" "ports" "to" "toaddress" "toport" "hairpin"}}{{with index $e (printf "nat[%d].%s" $i $f)}}<tr><td colspan="12" class="field-error">{{$f}}: {{.}}</td></tr>{{end}}{{end}}
{{end}} {{end}}
</table> </table>
</div> </div>
<p class="hint">Everything from the internet is blocked unless listed here. A <strong>port forward</strong> sends tcp or udp <p class="hint"><strong>Port forward:</strong> traffic from the zone (<code>wan</code> is the internet) addressed to the router
traffic for the router's public IPv4 address to one host inside (to address is that host; to port only if it differs, (or to the address in <em>Sent to</em>) on these ports goes to the host in <em>Translate to</em>, in zone <em>To zone</em>;
with a single public port); the sender's address is kept. Without it the rule just lets traffic through to an address the sender's address is kept. <em>Also allow</em> adds the filter rule that lets it through (without it the firewall still
or network inside, for IPv6 or for a public range. <strong>Hairpin</strong> lets your LANs use the forward through the blocks it). <em>Hairpin</em> lets your LANs use a forward from the internet through the router's public address.<br>
router's public address. Rate limit caps new connections per sender, like <code>10/minute</code>. <strong>Masquerade</strong> and <strong>source NAT</strong> rewrite where traffic from the zone (or any zone) leaves through
Fill in the last row to add one.</p> <em>To zone</em>: masquerade with the outgoing interface's address, source NAT with the address in <em>Translate to</em>.
Internet-enabled LANs are already masqueraded out the uplink. Fill in the last row to add one.</p>
</fieldset> </fieldset>
<button>Save</button> <button>Save</button>
</form> </form>
+2 -2
View File
@@ -8,7 +8,7 @@ mkdir -p "$dir/config"
wan=$(ip -j route show default 2>/dev/null | jq -r '.[0].dev // empty') wan=$(ip -j route show default 2>/dev/null | jq -r '.[0].dev // empty')
lan=$(ip -j link show 2>/dev/null | jq -r --arg wan "$wan" '[.[] | select(.link_type == "ether" and .ifname != $wan) | .ifname][0] // "eth1"') lan=$(ip -j link show 2>/dev/null | jq -r --arg wan "$wan" '[.[] | select(.link_type == "ether" and .ifname != $wan) | .ifname][0] // "eth1"')
cat > "$dir/config/settings.json" <<JSON cat > "$dir/config/settings.json" <<JSON
{"version": 0, "system": {"hostName": "dev"}, {"version": 1, "system": {"hostName": "dev"},
"router": {"wan": {"interface": "${wan:-eth0}", "ipv4": {"type": "dhcp"}}, "router": {"wan": {"interface": "${wan:-eth0}", "ipv4": {"type": "dhcp"}},
"lans": {"lan": {"interface": "$lan", "internet": true, "ipv4": {"address": "192.168.10.1"}, "lans": {"lan": {"interface": "$lan", "internet": true, "ipv4": {"address": "192.168.10.1"},
"dhcp": {"pool": {"start": "192.168.10.100", "end": "192.168.10.200"}}}}}} "dhcp": {"pool": {"start": "192.168.10.100", "end": "192.168.10.200"}}}}}}
@@ -19,7 +19,7 @@ address,hwaddr,client_id,valid_lifetime,expire,subnet_id,fqdn_fwd,fqdn_rev,hostn
192.168.10.101,52:54:00:12:34:56,,3600,$((now + 3000)),1,0,0,laptop,0,,0 192.168.10.101,52:54:00:12:34:56,,3600,$((now + 3000)),1,0,0,laptop,0,,0
192.168.10.120,52:54:00:aa:bb:cc,,3600,$((now + 1200)),1,0,0,phone,0,,0 192.168.10.120,52:54:00:aa:bb:cc,,3600,$((now + 1200)),1,0,0,phone,0,,0
LEASES LEASES
echo '{"version": "0.0.0-dev", "serial": 1, "schema": 0}' > "$dir/router-release" echo '{"version": "0.0.0-dev", "serial": 1, "schema": 1}' > "$dir/router-release"
cd "$root/pkgs/router-webui" cd "$root/pkgs/router-webui"
echo devpassword1 | go run . passwd "$dir/webui-password" echo devpassword1 | go run . passwd "$dir/webui-password"
echo echo
+2
View File
@@ -16,6 +16,8 @@ let
settings = pkgs.writeText "settings.json" ( settings = pkgs.writeText "settings.json" (
builtins.toJSON { builtins.toJSON {
# The schema of this source: the number of migrations.
version = lib.count (lib.hasSuffix ".jq") (builtins.attrNames (builtins.readDir ../migrations));
system = { system = {
hostName = "router"; hostName = "router";
stateVersion = "26.11"; stateVersion = "26.11";
+170 -33
View File
@@ -609,13 +609,11 @@ let
to = "main"; to = "main";
destination = "192.168.10.5"; destination = "192.168.10.5";
protocol = "tcp"; protocol = "tcp";
ports = [ 8080 ]; ports = [ 8081 ];
dnat = true; natOnly = true;
toPort = 8081;
sources = [ "198.51.100.0/24" ]; sources = [ "198.51.100.0/24" ];
rateLimit = "3/minute"; rateLimit = "3/minute";
log = true; log = true;
hairpin = true;
} }
{ {
from = "wan"; from = "wan";
@@ -685,91 +683,230 @@ let
}; };
expect = [ "`wan` cannot be a destination" ]; expect = [ "`wan` cannot be a destination" ];
}; };
dnatNeedsHost = { natDnatNeedsHost = {
router = { router = {
lans.main = lan { }; lans.main = lan { };
firewall.rules = [ firewall.nat = [
{ {
type = "dnat";
from = "wan"; from = "wan";
to = "main"; to = "main";
destination = "192.168.10.0/24";
protocol = "tcp"; protocol = "tcp";
ports = [ 8080 ]; ports = [ 8080 ];
dnat = true;
} }
]; ];
}; };
expect = [ "dnat needs a single IPv4 host" ]; expect = [ "dnat and snat need a toAddress" ];
}; };
dnatV6Sources = { natDnatHostOutsideZone = {
router = { router = {
lans.main = lan { }; lans.main = lan { };
firewall.rules = [ firewall.nat = [
{ {
type = "dnat";
from = "wan"; from = "wan";
to = "main"; to = "main";
destination = "192.168.10.5";
protocol = "tcp"; protocol = "tcp";
ports = [ 8080 ]; ports = [ 8080 ];
dnat = true; toAddress = "10.9.9.9";
}
];
};
expect = [ "is not inside any network of zone `main`" ];
};
natV6Sources = {
router = {
lans.main = lan { };
firewall.nat = [
{
type = "dnat";
from = "wan";
to = "main";
protocol = "tcp";
ports = [ 8080 ];
toAddress = "192.168.10.5";
sources = [ "2001:db8::/32" ]; sources = [ "2001:db8::/32" ];
} }
]; ];
}; };
expect = [ "its sources must be IPv4" ]; expect = [ "translation is IPv4 only" ];
}; };
toPortWithoutDnat = { natToPortNeedsOnePort = {
router = { router = {
lans.main = lan { }; lans.main = lan { };
firewall.rules = [ firewall.nat = [
{ {
type = "dnat";
from = "wan"; from = "wan";
to = "main"; to = "main";
destination = "192.168.10.5";
protocol = "tcp"; protocol = "tcp";
ports = [ 8080 ]; ports = [
toPort = 8081; 8080
8081
];
toAddress = "192.168.10.5";
toPort = 80;
} }
]; ];
}; };
expect = [ "toPort needs dnat" ]; expect = [ "toPort needs exactly one" ];
}; };
hairpinNoDnat = { natToPortOnMasquerade = {
router = { router = {
lans.main = lan { }; lans = {
firewall.rules = [ main = lan { };
iot = lan2 { };
};
firewall.nat = [
{ {
from = "wan"; type = "masquerade";
from = "iot";
to = "main";
toPort = 80;
}
];
};
expect = [ "toPort and hairpin only apply to port forwards" ];
};
natHairpinFromLan = {
router = {
lans = {
main = lan { internet = true; };
iot = lan2 { };
};
firewall.nat = [
{
type = "dnat";
from = "iot";
to = "main"; to = "main";
destination = "192.168.10.5";
protocol = "tcp"; protocol = "tcp";
ports = [ 8080 ]; ports = [ 8080 ];
toAddress = "192.168.10.5";
hairpin = true; hairpin = true;
} }
]; ];
}; };
expect = [ expect = [ "hairpin only applies to forwards from `wan`" ];
"hairpin only applies to dnat"
"hairpin needs at least one"
];
}; };
hairpinNoInternet = { natHairpinNoInternet = {
router = { router = {
lans.main = lan { }; lans.main = lan { };
firewall.rules = [ firewall.nat = [
{ {
type = "dnat";
from = "wan"; from = "wan";
to = "main"; to = "main";
destination = "192.168.10.5";
protocol = "tcp"; protocol = "tcp";
ports = [ 8080 ]; ports = [ 8080 ];
dnat = true; toAddress = "192.168.10.5";
hairpin = true; hairpin = true;
} }
]; ];
}; };
expect = [ "hairpin needs at least one" ]; expect = [ "hairpin needs at least one" ];
}; };
natMasqueradeWithAddress = {
router = {
lans.main = lan { };
firewall.nat = [
{
type = "masquerade";
to = "wan";
toAddress = "203.0.113.5";
}
];
};
expect = [ "dnat and snat need a toAddress; masquerade has none" ];
};
natUnknownZones = {
router = {
lans.main = lan { };
firewall.nat = [
{
type = "snat";
from = "ghost";
to = "nowhere";
toAddress = "203.0.113.5";
}
];
};
expect = [
"unknown source zone `ghost`"
"unknown zone `nowhere`"
];
};
natDnatToWan = {
router = {
lans.main = lan { };
firewall.nat = [
{
type = "dnat";
from = "main";
to = "wan";
protocol = "tcp";
ports = [ 80 ];
toAddress = "192.168.10.5";
}
];
};
expect = [ "unknown zone `wan`" ];
};
natValid = {
router = {
lans = {
main = lan { internet = true; };
iot = lan2 { };
};
firewall = {
rules = [
{
from = "wan";
to = "main";
destination = "192.168.10.5";
protocol = "tcp";
ports = [ 8081 ];
natOnly = true;
rateLimit = "3/minute";
log = true;
}
];
nat = [
{
type = "dnat";
from = "wan";
to = "main";
protocol = "tcp";
ports = [ 8080 ];
sources = [ "198.51.100.0/24" ];
toAddress = "192.168.10.5";
toPort = 8081;
hairpin = true;
}
{
type = "dnat";
from = "iot";
to = "main";
protocol = "udp";
ports = [ "5000-5100" ];
destination = "192.168.20.1";
toAddress = "192.168.10.6";
}
{
type = "masquerade";
from = "iot";
to = "main";
}
{
type = "snat";
to = "wan";
sources = [ "192.168.10.0/24" ];
toAddress = "203.0.113.5";
}
];
};
};
expect = [ ];
};
badPortRange = { badPortRange = {
router = { router = {
lans.main = lan { }; lans.main = lan { };
+37 -5
View File
@@ -273,16 +273,18 @@ pkgs.testers.runNixOSTest {
}; };
}; };
dns.forwarders = [ ispV4 ]; dns.forwarders = [ ispV4 ];
firewall.rules = # A port forward is a translation and the rule that lets it through.
firewall = {
nat =
let let
fwd = fwd =
r: r:
{ {
type = "dnat";
from = "wan"; from = "wan";
to = "main"; to = "main";
destination = clientV4; toAddress = clientV4;
protocol = "tcp"; protocol = "tcp";
dnat = true;
} }
// r; // r;
in in
@@ -290,7 +292,6 @@ pkgs.testers.runNixOSTest {
(fwd { (fwd {
ports = [ 8080 ]; ports = [ 8080 ];
hairpin = true; hairpin = true;
log = true;
}) })
(fwd { (fwd {
ports = [ 8083 ]; ports = [ 8083 ];
@@ -305,7 +306,37 @@ pkgs.testers.runNixOSTest {
ports = [ 8086 ]; ports = [ 8086 ];
sources = [ "192.0.2.0/24" ]; sources = [ "192.0.2.0/24" ];
}) })
(fwd { (fwd { ports = [ 8087 ]; })
];
rules =
let
allow =
r:
{
from = "wan";
to = "main";
destination = clientV4;
protocol = "tcp";
natOnly = true;
}
// r;
in
[
(allow {
ports = [ 8080 ];
log = true;
})
(allow { ports = [ 8084 ]; })
(allow { ports = [ "9000-9002" ]; })
(allow {
ports = [ 8085 ];
sources = [ "${ispV4}/32" ];
})
(allow {
ports = [ 8086 ];
sources = [ "192.0.2.0/24" ];
})
(allow {
ports = [ 8087 ]; ports = [ 8087 ];
rateLimit = "3/minute"; rateLimit = "3/minute";
log = true; log = true;
@@ -319,6 +350,7 @@ pkgs.testers.runNixOSTest {
} }
]; ];
}; };
};
# Unbound answers `.test` locally (RFC 6761) and would validate DNSSEC # Unbound answers `.test` locally (RFC 6761) and would validate DNSSEC
# against a fake ISP resolver that cannot provide it; neither applies to # against a fake ISP resolver that cannot provide it; neither applies to
+14 -2
View File
@@ -200,16 +200,28 @@ pkgs.testers.runNixOSTest {
}; };
# Only the PPP device is an uplink, so this must work from the ISP # Only the PPP device is an uplink, so this must work from the ISP
# and must not fire for traffic arriving from the LAN. # and must not fire for traffic arriving from the LAN.
firewall.rules = [ firewall = {
nat = [
{
type = "dnat";
from = "wan";
to = "main";
toAddress = "192.168.10.51";
protocol = "tcp";
ports = [ 8080 ];
}
];
rules = [
{ {
from = "wan"; from = "wan";
to = "main"; to = "main";
destination = "192.168.10.51"; destination = "192.168.10.51";
protocol = "tcp"; protocol = "tcp";
ports = [ 8080 ]; ports = [ 8080 ];
dnat = true; natOnly = true;
} }
]; ];
};
dns = { dns = {
forwarders = [ ispV4 ]; forwarders = [ ispV4 ];
domain = "lan.test"; domain = "lan.test";
+2
View File
@@ -50,6 +50,8 @@ let
name = "v1"; name = "v1";
version = "1.0.0"; version = "1.0.0";
serial = 1000; serial = 1000;
# Settings version 0: the real migrations are not part of this test.
extra = "rm $out/migrations/*.jq";
}; };
v2 = mkVersion { v2 = mkVersion {
name = "v2"; name = "v2";
+59 -1
View File
@@ -9,6 +9,10 @@
# iot -> trusted, tcp 9090, only from source ports 40000-40010 # iot -> trusted, tcp 9090, only from source ports 40000-40010
# "lab" (no interface yet) -> trusted: matches nothing # "lab" (no interface yet) -> trusted: matches nothing
# #
# nat: trusted -> router:7070 is forwarded to iot:8081 (source kept)
# trusted -> iot tcp 8082 is masqueraded (iot sees the router)
# trusted -> iot tcp 8083 is source-translated to 192.168.20.1
#
# upstream (ISP) -- vlan 1 -- router -- vlan 2 (trunk) -- main / iot / cams # upstream (ISP) -- vlan 1 -- router -- vlan 2 (trunk) -- main / iot / cams
{ self, pkgs }: { self, pkgs }:
let let
@@ -164,6 +168,32 @@ pkgs.testers.runNixOSTest {
} }
]; ];
firewall.zones = [ "lab" ]; firewall.zones = [ "lab" ];
firewall.nat = [
{
type = "dnat";
from = "trusted";
to = "iot";
protocol = "tcp";
ports = [ 7070 ];
toAddress = "192.168.20.100";
toPort = 8081;
}
{
type = "masquerade";
from = "trusted";
to = "iot";
protocol = "tcp";
ports = [ 8082 ];
}
{
type = "snat";
from = "trusted";
to = "iot";
protocol = "tcp";
ports = [ 8083 ];
toAddress = "192.168.20.1";
}
];
dns.forwarders = [ "203.0.113.1" ]; dns.forwarders = [ "203.0.113.1" ];
}; };
@@ -198,7 +228,22 @@ pkgs.testers.runNixOSTest {
}; };
}; };
iot = host 20; # Echoes the address it sees, to tell translated connections from plain ones.
iot =
{ ... }:
{
imports = [ (host 20) ];
services.nginx = {
enable = true;
virtualHosts."ports" = {
listen = map (port: {
addr = "0.0.0.0";
inherit port;
}) [ 8081 8082 8083 ];
locations."/".return = ''200 "$remote_addr"'';
};
};
};
cams = host 30; cams = host 30;
}; };
@@ -208,6 +253,7 @@ pkgs.testers.runNixOSTest {
upstream.wait_for_unit("dnsmasq.service") upstream.wait_for_unit("dnsmasq.service")
upstream.wait_for_unit("nginx.service") upstream.wait_for_unit("nginx.service")
main.wait_for_unit("nginx.service") main.wait_for_unit("nginx.service")
iot.wait_for_unit("nginx.service")
with subtest("router is up with VLAN devices on the trunk"): with subtest("router is up with VLAN devices on the trunk"):
router.wait_for_unit("network-online.target") router.wait_for_unit("network-online.target")
@@ -269,6 +315,18 @@ pkgs.testers.runNixOSTest {
host.fail("nc -z -w 2 -p 50000 192.168.10.100 9090") host.fail("nc -z -w 2 -p 50000 192.168.10.100 9090")
host.fail("nc -z -w 2 -p 40005 192.168.10.150 9090") host.fail("nc -z -w 2 -p 40005 192.168.10.150 9090")
with subtest("a port forward from a LAN keeps the source address"):
out = main.succeed("curl -sf --max-time 5 http://192.168.10.1:7070/").strip()
assert out == "192.168.10.100", out
# Not addressed to the router: not forwarded.
main.fail("curl -sf --max-time 3 http://192.168.10.1:7071/")
with subtest("masquerade and source NAT rewrite the source between zones"):
assert main.succeed("curl -sf --max-time 5 http://192.168.20.100:8081/").strip() == "192.168.10.100"
assert main.succeed("curl -sf --max-time 5 http://192.168.20.100:8082/").strip() == "192.168.20.1"
assert main.succeed("curl -sf --max-time 5 http://192.168.20.100:8083/").strip() == "192.168.20.1"
router.succeed("nft list ruleset | grep -q 'snat to 192.168.20.1'")
with subtest("a zone without interfaces does not break the ruleset"): with subtest("a zone without interfaces does not break the ruleset"):
router.succeed("nft list ruleset | grep -q 'sport 40000-40010'") router.succeed("nft list ruleset | grep -q 'sport 40000-40010'")
router.succeed("systemctl is-active nftables.service") router.succeed("systemctl is-active nftables.service")
+3 -3
View File
@@ -137,12 +137,12 @@ pkgs.testers.runNixOSTest {
assert post("/firewall", {"csrf": csrf("/firewall"), **rule}) == "422" assert post("/firewall", {"csrf": csrf("/firewall"), **rule}) == "422"
with subtest("a port forward is saved and listed"): with subtest("a port forward is saved and listed"):
fwd = {"forwards": "1", "fwd.0.protocol": "tcp", "fwd.0.ports": "8080", "fwd.0.to": "main", fwd = {"nats": "1", "nat.0.type": "dnat", "nat.0.from": "wan", "nat.0.protocol": "tcp", "nat.0.ports": "8080",
"fwd.0.destination": "192.168.10.5", "fwd.0.nat": "on"} "nat.0.to": "main", "nat.0.toaddress": "192.168.10.5", "nat.0.allow": "on"}
assert post("/firewall", {"csrf": csrf("/firewall"), **fwd}) == "303" assert post("/firewall", {"csrf": csrf("/firewall"), **fwd}) == "303"
assert 'value="192.168.10.5"' in curl(f"{ui}/firewall") assert 'value="192.168.10.5"' in curl(f"{ui}/firewall")
saved = router.succeed(f"cat {cfg}") saved = router.succeed(f"cat {cfg}")
assert '"dnat": true' in saved and '"from": "wan"' in saved, saved assert '"type": "dnat"' in saved and '"from": "wan"' in saved and '"natOnly": true' in saved, saved
with subtest("dynamic DNS is saved with its secret kept out of the settings"): with subtest("dynamic DNS is saved with its secret kept out of the settings"):
dd = {"enabled": "on", "protocol": "cloudflare", "username": "token", "zone": "example.org", dd = {"enabled": "on", "protocol": "cloudflare", "username": "token", "zone": "example.org",