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:
@@ -105,10 +105,11 @@ Learned along the way:
|
||||
- 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).
|
||||
|
||||
## 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).
|
||||
- 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).
|
||||
- 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).
|
||||
- `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.
|
||||
- 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).
|
||||
@@ -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`).
|
||||
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.
|
||||
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.
|
||||
|
||||
## Installer and image (in progress)
|
||||
|
||||
@@ -170,6 +170,7 @@
|
||||
(self.lib.mkRouterSystem {
|
||||
settings = builtins.toFile "settings.json" (
|
||||
builtins.toJSON {
|
||||
version = thisRelease.schema;
|
||||
router = {
|
||||
wan.interface = "eth0";
|
||||
lans.lan = {
|
||||
|
||||
@@ -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
|
||||
@@ -201,9 +201,6 @@ let
|
||||
&& lib.any (n: dest.prefixLength >= n.prefixLength && ipv4.inSubnet n dest.address) (
|
||||
zoneV4Nets r.to
|
||||
);
|
||||
singleHost = dest != null && dest.prefixLength == 32;
|
||||
plainPorts = lib.all builtins.isInt r.ports;
|
||||
internetMember = lib.any (l: l.internet) lans || lib.any (w: w.internet) wgs;
|
||||
in
|
||||
[
|
||||
{
|
||||
@@ -227,8 +224,8 @@ let
|
||||
message = "router: ${where}: ports are only valid with protocol tcp or udp.";
|
||||
}
|
||||
{
|
||||
assertion = r.sourcePorts == [ ] || ((r.protocol == "tcp" || r.protocol == "udp") && !r.dnat);
|
||||
message = "router: ${where}: sourcePorts are only valid with protocol tcp or udp, and not on a port forward.";
|
||||
assertion = r.sourcePorts == [ ] || r.protocol == "tcp" || r.protocol == "udp";
|
||||
message = "router: ${where}: sourcePorts are only valid with protocol tcp or udp.";
|
||||
}
|
||||
{
|
||||
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.";
|
||||
}
|
||||
]
|
||||
++ 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;
|
||||
message = "router: ${where}: dnat is only valid for rules from `wan`.";
|
||||
assertion = n.from == null || n.from == "wan" || lib.elem n.from zoneNames;
|
||||
message = "router: ${where}: unknown source zone `${toString n.from}` (zones: wan, ${lib.concatStringsSep ", " zoneNames}).";
|
||||
}
|
||||
{
|
||||
assertion = singleHost;
|
||||
message = "router: ${where}: dnat needs a single IPv4 host as destination.";
|
||||
assertion = lib.elem n.to zoneNames || (!isDnat && n.to == "wan");
|
||||
message = "router: ${where}: unknown zone `${n.to}` (zones: ${lib.concatStringsSep ", " zoneNames}${lib.optionalString (!isDnat) ", wan"}).";
|
||||
}
|
||||
{
|
||||
assertion = r.protocol == "tcp" || r.protocol == "udp";
|
||||
message = "router: ${where}: dnat needs protocol tcp or udp.";
|
||||
assertion = !isDnat || n.from != null;
|
||||
message = "router: ${where}: a port forward needs a source zone (`from`).";
|
||||
}
|
||||
{
|
||||
assertion = lib.all (x: !isV6 x) r.sources;
|
||||
message = "router: ${where}: dnat is IPv4-only, so its sources must be IPv4.";
|
||||
assertion = (n.ports == [ ] && n.sourcePorts == [ ]) || n.protocol == "tcp" || n.protocol == "udp";
|
||||
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;
|
||||
message = "router: ${where}: toPort needs dnat and exactly one (non-range) port.";
|
||||
}
|
||||
]
|
||||
++ lib.optionals r.hairpin [
|
||||
{
|
||||
assertion = r.dnat;
|
||||
message = "router: ${where}: hairpin only applies to dnat rules.";
|
||||
assertion = n.protocol == "tcp" || n.protocol == "udp";
|
||||
message = "router: ${where}: a port forward needs protocol tcp or udp.";
|
||||
}
|
||||
{
|
||||
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.";
|
||||
}
|
||||
]
|
||||
++ lib.optionals (!isDnat) [
|
||||
{
|
||||
assertion = n.from == null || n.from != n.to;
|
||||
message = "router: ${where}: source and destination are the same zone.";
|
||||
}
|
||||
];
|
||||
|
||||
dnsCfg = cfg.dns;
|
||||
@@ -508,6 +561,7 @@ in
|
||||
++ lib.concatLists (lib.mapAttrsToList perLan lansByName)
|
||||
++ dnsAssertions
|
||||
++ lib.concatMap ruleAssertions cfg.firewall.rules
|
||||
++ lib.concatMap natAssertions cfg.firewall.nat
|
||||
++ [
|
||||
{
|
||||
assertion = !(lib.elem "wan" zoneNames);
|
||||
|
||||
+47
-20
@@ -121,9 +121,7 @@ let
|
||||
label = "${r.from}->${r.to}";
|
||||
fromDevs = if r.from == "wan" then uplinks else zoneDevices r.from;
|
||||
toDevs = zoneDevices r.to;
|
||||
# After DNAT the packet carries the translated port.
|
||||
fwdPorts = if r.dnat && r.toPort != null then [ r.toPort ] else r.ports;
|
||||
base = "iifname ${set fromDevs} oifname ${set toDevs} ${daddrOf r}${l4 r.protocol fwdPorts r.sourcePorts}${lib.optionalString r.dnat "ct status dnat "}";
|
||||
base = "iifname ${set fromDevs} oifname ${set toDevs} ${daddrOf r}${l4 r.protocol r.ports r.sourcePorts}${lib.optionalString r.natOnly "ct status dnat "}";
|
||||
logPrefix = what: ''log prefix "router fwd#${id} ${label} ${what}: " '';
|
||||
limitLines =
|
||||
v:
|
||||
@@ -147,32 +145,60 @@ let
|
||||
v:
|
||||
lib.optional r.log "${base}${v.src}ct state new ${logPrefix "accepted"}"
|
||||
++ [ "${base}${v.src}accept # ${label}" ];
|
||||
dnatTarget = "dnat to ${r.destination}${
|
||||
lib.optionalString (r.toPort != null) ":${toString r.toPort}"
|
||||
}";
|
||||
in
|
||||
{
|
||||
# A zone nothing is assigned to yet has no devices; the rule waits for one.
|
||||
forward = lib.optionals (fromDevs != [ ] && toDevs != [ ]) (
|
||||
lib.concatMap (v: limitLines v ++ acceptLines v) (variantsOf r)
|
||||
);
|
||||
prerouting =
|
||||
lib.optionals r.dnat (
|
||||
map (v: "iifname ${set uplinks} ${v.src}${l4 r.protocol r.ports [ ]}${dnatTarget} # ${label}") (
|
||||
variantsOf r
|
||||
)
|
||||
)
|
||||
};
|
||||
|
||||
# Address translation (router.firewall.nat). Indexed like the rules.
|
||||
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.
|
||||
++
|
||||
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}"
|
||||
++ 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}"
|
||||
);
|
||||
# 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;
|
||||
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
|
||||
# that is local is a public (uplink) address.
|
||||
internalAddrs =
|
||||
@@ -261,7 +287,7 @@ in
|
||||
|
||||
# Zones: free inside a zone, denied between zones unless a rule says so.
|
||||
${lib.concatStringsSep "\n " (
|
||||
intraZone ++ lib.concatMap (p: p.hairpinForward ++ p.forward) parts
|
||||
intraZone ++ lib.concatMap (p: p.hairpinForward) natRules ++ lib.concatMap (p: p.forward) parts
|
||||
)}
|
||||
}
|
||||
'';
|
||||
@@ -272,11 +298,12 @@ in
|
||||
content = ''
|
||||
chain prerouting {
|
||||
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 {
|
||||
type nat hook postrouting priority srcnat; policy accept;
|
||||
${lib.concatStringsSep "\n " (lib.concatMap (p: p.postrouting) natRules)}
|
||||
oifname "${uplink4}" masquerade
|
||||
${lib.optionalString anyHairpin "ct status dnat iifname ${set memberIfaces} oifname ${set memberIfaces} masquerade # hairpin"}
|
||||
${lib.optionalString (
|
||||
|
||||
+103
-21
@@ -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 {
|
||||
default = [ ];
|
||||
description = ''
|
||||
@@ -402,7 +496,8 @@ in
|
||||
the reserved zone `wan` (traffic arriving on the uplinks), which
|
||||
publishes services to the outside: such rules must name a
|
||||
`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 (
|
||||
types.submodule {
|
||||
@@ -433,15 +528,15 @@ in
|
||||
];
|
||||
description = ''
|
||||
Destination ports or ranges (tcp/udp only); empty means all
|
||||
ports. With `dnat` these are the ports on the router's
|
||||
public address.
|
||||
ports. After a port forward these are the translated ports
|
||||
(its `toPort`, if it has one).
|
||||
'';
|
||||
};
|
||||
sourcePorts = mkOption {
|
||||
type = types.listOf (types.either types.port (types.strMatching "[0-9]+-[0-9]+"));
|
||||
default = [ ];
|
||||
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 {
|
||||
type = types.nullOr types.str;
|
||||
@@ -460,26 +555,13 @@ in
|
||||
example = [ "198.51.100.0/24" ];
|
||||
description = "Only match traffic from these IPv4/IPv6 addresses or CIDRs; empty means any source.";
|
||||
};
|
||||
dnat = mkOption {
|
||||
natOnly = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Port forward: traffic from `wan` to `ports` on the router's
|
||||
public IPv4 address is redirected to `destination` (one IPv4
|
||||
host). The source address is preserved.
|
||||
'';
|
||||
};
|
||||
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).
|
||||
Only match connections that a `firewall.nat` port forward
|
||||
translated (the allow rule that goes with a forward), not
|
||||
traffic addressed directly to the destination.
|
||||
'';
|
||||
};
|
||||
rateLimit = mkOption {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
@@ -22,6 +23,9 @@ type FirewallRule struct {
|
||||
Destination string // one address or CIDR, empty: the whole zone
|
||||
Ports string // destination ports
|
||||
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
|
||||
}
|
||||
|
||||
@@ -36,7 +40,7 @@ type ZoneMember struct {
|
||||
var portRangeRe = regexp.MustCompile(`^[0-9]+(-[0-9]+)?$`)
|
||||
|
||||
// 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
|
||||
// named after itself unless it says otherwise.
|
||||
@@ -85,9 +89,6 @@ func (s Settings) zones() []string {
|
||||
}
|
||||
|
||||
func ruleSimple(r map[string]any) bool {
|
||||
if str(r, "from") == "wan" {
|
||||
return false
|
||||
}
|
||||
for k := range r {
|
||||
if !slices.Contains(simpleRuleKeys, k) {
|
||||
return false
|
||||
@@ -123,9 +124,6 @@ func (s Settings) firewallRules() (simple []FirewallRule, other []string) {
|
||||
if r == nil {
|
||||
continue
|
||||
}
|
||||
if wanRuleSimple(r) {
|
||||
continue
|
||||
}
|
||||
if !ruleSimple(r) {
|
||||
desc := str(r, "from") + " → " + str(r, "to")
|
||||
if d := str(r, "destination"); d != "" {
|
||||
@@ -140,9 +138,6 @@ func (s Settings) firewallRules() (simple []FirewallRule, other []string) {
|
||||
if p := portsText(r["ports"]); p != "" {
|
||||
desc += " " + p
|
||||
}
|
||||
if dnat, _ := r["dnat"].(bool); dnat {
|
||||
desc += " (port forward)"
|
||||
}
|
||||
other = append(other, desc)
|
||||
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,
|
||||
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
|
||||
}
|
||||
@@ -263,7 +259,7 @@ func (s Settings) applyZones(zf ZoneForm, errs FormErrors) {
|
||||
|
||||
// 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.
|
||||
func (s Settings) applyFirewall(zf ZoneForm, rows []FirewallRule, fwds []WANRule) error {
|
||||
func (s Settings) applyFirewall(zf ZoneForm, rows []FirewallRule, nats []NATRule) error {
|
||||
errs := FormErrors{}
|
||||
s.applyZones(zf, errs)
|
||||
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 == "") {
|
||||
continue
|
||||
}
|
||||
if !slices.Contains(zones, r.From) {
|
||||
if !slices.Contains(zones, r.From) && r.From != "wan" {
|
||||
errs[field("from")] = "choose a zone"
|
||||
}
|
||||
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) {
|
||||
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] {
|
||||
errs[field("from")] = "the same rule twice"
|
||||
}
|
||||
seen[r] = true
|
||||
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" {
|
||||
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, 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")
|
||||
fw := obj(router, "firewall")
|
||||
old, _ := fw["rules"].([]any)
|
||||
for _, v := range old {
|
||||
r, _ := v.(map[string]any)
|
||||
if r == nil || ruleSimple(r) || wanRuleSimple(r) {
|
||||
if r == nil || ruleSimple(r) {
|
||||
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) {
|
||||
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 {
|
||||
fw["rules"] = next
|
||||
}
|
||||
if len(natRows) == 0 {
|
||||
delete(fw, "nat")
|
||||
} else {
|
||||
fw["nat"] = natRows
|
||||
}
|
||||
if len(fw) == 0 {
|
||||
delete(router, "firewall")
|
||||
}
|
||||
@@ -369,19 +399,21 @@ func (s Settings) applyFirewall(zf ZoneForm, rows []FirewallRule, fwds []WANRule
|
||||
}
|
||||
|
||||
type firewallData struct {
|
||||
Rows []FirewallRule
|
||||
Forwards []WANRule
|
||||
Other []string
|
||||
Zones []string
|
||||
Members []ZoneMember
|
||||
Empty []string // zones without members, which can be removed
|
||||
New string
|
||||
Errors FormErrors
|
||||
Rows []FirewallRule
|
||||
NATs []NATRule
|
||||
FromZones []string // the zones and wan
|
||||
Other []string
|
||||
Zones []string
|
||||
Members []ZoneMember
|
||||
Empty []string // zones without members, which can be removed
|
||||
New string
|
||||
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()
|
||||
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 {
|
||||
d.Members = s.zoneMembers()
|
||||
}
|
||||
@@ -403,7 +435,7 @@ func (a *app) firewallPage(w http.ResponseWriter, r *http.Request, sess session)
|
||||
}
|
||||
rows, _ := s.firewallRules()
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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")})
|
||||
}
|
||||
n, _ := strconv.Atoi(r.PostFormValue("rows"))
|
||||
nf, _ := strconv.Atoi(r.PostFormValue("forwards"))
|
||||
var fwds []WANRule
|
||||
for i := range min(nf, 512) {
|
||||
v := func(k string) string { return r.PostFormValue(fmt.Sprintf("fwd.%d.%s", i, k)) }
|
||||
fwds = append(fwds, WANRule{Protocol: v("protocol"), Ports: v("ports"), To: v("to"), Destination: v("destination"),
|
||||
ToPort: v("toport"), Sources: v("sources"), NAT: v("nat") == "on", Hairpin: v("hairpin") == "on",
|
||||
Log: v("log") == "on", RateLimit: v("ratelimit"), Remove: v("remove") == "on"})
|
||||
nn, _ := strconv.Atoi(r.PostFormValue("nats"))
|
||||
var nats []NATRule
|
||||
for i := range min(nn, 512) {
|
||||
v := func(k string) string { return r.PostFormValue(fmt.Sprintf("nat.%d.%s", i, k)) }
|
||||
nats = append(nats, NATRule{Type: v("type"), From: v("from"), To: v("to"), Protocol: v("protocol"), Ports: v("ports"),
|
||||
SourcePorts: v("sourceports"), Sources: v("sources"), Destination: v("destination"), ToAddress: v("toaddress"),
|
||||
ToPort: v("toport"), Hairpin: v("hairpin") == "on", Allow: v("allow") == "on", Remove: v("remove") == "on"})
|
||||
}
|
||||
var rows []FirewallRule
|
||||
for i := range min(n, 512) {
|
||||
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"),
|
||||
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
|
||||
errors.As(err, &fe)
|
||||
// Show what was entered; the zone lists are those before this attempt.
|
||||
s, _ = loadSettings(a.sys.settingsPath())
|
||||
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
|
||||
p.Data = d
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
|
||||
@@ -14,9 +14,9 @@ func TestFirewallRulesRoundTrip(t *testing.T) {
|
||||
if got := s.zones(); strings.Join(got, ",") != "main,vpn" {
|
||||
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{
|
||||
map[string]any{"from": "main", "to": "vpn", "log": true},
|
||||
map[string]any{"from": "main", "to": "vpn", "extra": true},
|
||||
}
|
||||
rows, other := s.firewallRules()
|
||||
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)
|
||||
}
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
|
||||
func TestPortForwardsRoundTrip(t *testing.T) {
|
||||
func TestNATRoundTrip(t *testing.T) {
|
||||
s := mustParse(t)
|
||||
obj(obj(s, "router"), "firewall")["rules"] = []any{
|
||||
map[string]any{"from": "main", "to": "vpn", "log": true, "rateLimit": "5/minute"}, // not editable here, kept
|
||||
map[string]any{"from": "wan", "to": "main", "dnat": true, "destination": "192.168.10.5", "protocol": "tcp",
|
||||
obj(obj(s, "router"), "firewall")["nat"] = []any{
|
||||
map[string]any{"type": "dnat", "from": "wan", "to": "main", "toAddress": "192.168.10.5", "protocol": "tcp",
|
||||
"ports": []any{float64(8080)}, "toPort": float64(80), "hairpin": true},
|
||||
}
|
||||
got := s.portForwards()
|
||||
if len(got) != 1 || got[0].Ports != "8080" || got[0].ToPort != "80" || !got[0].NAT || !got[0].Hairpin {
|
||||
t.Fatalf("forwards = %+v", got)
|
||||
got := s.natRules()
|
||||
if len(got) != 1 || got[0].Type != "dnat" || got[0].Ports != "8080" || got[0].ToPort != "80" || !got[0].Hairpin {
|
||||
t.Fatalf("nat = %+v", got)
|
||||
}
|
||||
if _, other := s.firewallRules(); len(other) != 1 || strings.Contains(other[0], "port forward") {
|
||||
t.Errorf("other = %v (the forward is edited, the zone rule is not)", other)
|
||||
}
|
||||
add := []WANRule{
|
||||
add := []NATRule{
|
||||
got[0],
|
||||
{Protocol: "udp", Ports: "51000-51010", To: "main", Destination: "192.168.10.5", NAT: true, Sources: "198.51.100.0/24"},
|
||||
{Protocol: "tcp", Ports: "443", To: "main", Destination: "fd00::5"}, // an opening, no NAT
|
||||
{Protocol: "tcp", NAT: true}, // the empty add row
|
||||
{Type: "dnat", From: "wan", To: "main", Protocol: "udp", Ports: "51000-51010", ToAddress: "192.168.10.5", Sources: "198.51.100.0/24", Allow: true},
|
||||
{Type: "masquerade", From: "main", To: "wan", Sources: "192.168.10.0/24"},
|
||||
{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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := s.portForwards(); len(got) != 3 || got[1].Ports != "51000-51010" || got[2].NAT || got[2].Destination != "fd00::5" {
|
||||
t.Fatalf("forwards = %+v", got)
|
||||
got = s.natRules()
|
||||
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"`) {
|
||||
t.Error("a rule the editor does not handle was lost")
|
||||
}
|
||||
add[0].Remove, add[1].Remove, add[2].Remove = true, true, true
|
||||
// "Also allow" added a filter rule; saving again does not add it twice.
|
||||
if err := s.applyFirewall(ZoneForm{}, nil, add); err != nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortForwardsAreChecked(t *testing.T) {
|
||||
good := WANRule{Protocol: "tcp", Ports: "8080", To: "main", Destination: "192.168.10.5", NAT: true}
|
||||
for name, mod := range map[string]func(*WANRule){
|
||||
"to": func(r *WANRule) { r.To = "wan" },
|
||||
"protocol": func(r *WANRule) { r.Protocol = "any" },
|
||||
"ports": func(r *WANRule) { r.Ports = "" },
|
||||
"destination": func(r *WANRule) { r.Destination = "10.9.9.9" }, // outside the LAN
|
||||
"sources": func(r *WANRule) { r.Sources = "2001:db8::/32" },
|
||||
"toport": func(r *WANRule) { r.ToPort = "80"; r.Ports = "80, 81" },
|
||||
"hairpin": func(r *WANRule) { r.NAT = false; r.Hairpin = true },
|
||||
"ratelimit": func(r *WANRule) { r.RateLimit = "lots" },
|
||||
func TestNATRowsAreChecked(t *testing.T) {
|
||||
good := NATRule{Type: "dnat", From: "wan", To: "main", Protocol: "tcp", Ports: "8080", ToAddress: "192.168.10.5"}
|
||||
for name, mod := range map[string]func(*NATRule){
|
||||
"type": func(r *NATRule) { r.Type = "bogus" },
|
||||
"from": func(r *NATRule) { r.From = "" },
|
||||
"to": func(r *NATRule) { r.To = "nowhere" },
|
||||
"protocol": func(r *NATRule) { r.Protocol = "any" },
|
||||
"ports": func(r *NATRule) { r.Ports = "" },
|
||||
"toaddress": func(r *NATRule) { r.ToAddress = "10.9.9.9" }, // outside the LAN
|
||||
"sources": func(r *NATRule) { r.Sources = "2001:db8::/32" },
|
||||
"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
|
||||
mod(&r)
|
||||
fe, ok := mustParse(t).applyFirewall(ZoneForm{}, nil, []WANRule{r}).(FormErrors)
|
||||
if !ok || fe["fwd[0]."+name] == "" {
|
||||
fe, ok := mustParse(t).applyFirewall(ZoneForm{}, nil, []NATRule{r}).(FormErrors)
|
||||
if !ok || fe["nat[0]."+name] == "" {
|
||||
t.Errorf("%s accepted: %v", name, fe)
|
||||
}
|
||||
}
|
||||
r := good
|
||||
r.Destination = "192.168.10.0/24"
|
||||
if fe, ok := mustParse(t).applyFirewall(ZoneForm{}, nil, []WANRule{r}).(FormErrors); !ok || fe["fwd[0].destination"] == "" {
|
||||
t.Errorf("a forward to a whole network accepted: %v", fe)
|
||||
m := NATRule{Type: "masquerade", From: "main", To: "main"}
|
||||
if fe, ok := mustParse(t).applyFirewall(ZoneForm{}, nil, []NATRule{m}).(FormErrors); !ok || fe["nat[0].to"] == "" {
|
||||
t.Errorf("masquerade into the same zone accepted: %v", fe)
|
||||
}
|
||||
r.NAT = false // an opening to a network is fine
|
||||
if err := mustParse(t).applyFirewall(ZoneForm{}, nil, []WANRule{r}); err != nil {
|
||||
t.Errorf("opening a network: %v", err)
|
||||
m = NATRule{Type: "masquerade", From: "main", To: "wan", ToAddress: "192.0.2.1"}
|
||||
if fe, ok := mustParse(t).applyFirewall(ZoneForm{}, nil, []NATRule{m}).(FormErrors); !ok || fe["nat[0].toaddress"] == "" {
|
||||
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)
|
||||
h := a.routes()
|
||||
cookie := do(h, "POST", "/login", url.Values{"password": {"hunter2hunter2"}}, nil).Result().Cookies()[0]
|
||||
body := do(h, "GET", "/firewall", nil, cookie).Body.String()
|
||||
if !strings.Contains(body, `name="forwards" value="1"`) || !strings.Contains(body, "fwd.0.nat") {
|
||||
t.Fatalf("no row to add a forward:\n%s", body)
|
||||
if !strings.Contains(body, `name="nats" value="1"`) || !strings.Contains(body, "nat.0.type") {
|
||||
t.Fatalf("no row to add a NAT rule:\n%s", body)
|
||||
}
|
||||
token := regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(body)[1]
|
||||
form := url.Values{"csrf": {token}, "forwards": {"1"}, "fwd.0.protocol": {"tcp"}, "fwd.0.ports": {"8080"},
|
||||
"fwd.0.to": {"main"}, "fwd.0.destination": {"192.168.10.5"}, "fwd.0.nat": {"on"}, "fwd.0.toport": {"80"}, "fwd.0.hairpin": {"on"}}
|
||||
form.Set("fwd.0.destination", "192.168.10.0/24")
|
||||
if rec := do(h, "POST", "/firewall", form, cookie); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "one IPv4 address") {
|
||||
t.Fatalf("a forward to a network: %d\n%s", rec.Code, rec.Body)
|
||||
form := url.Values{"csrf": {token}, "nats": {"1"}, "nat.0.type": {"dnat"}, "nat.0.from": {"wan"}, "nat.0.protocol": {"tcp"},
|
||||
"nat.0.ports": {"8080"}, "nat.0.to": {"main"}, "nat.0.toaddress": {"10.9.9.9"}, "nat.0.toport": {"80"},
|
||||
"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(), "not inside a network") {
|
||||
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 {
|
||||
t.Fatalf("save: %d\n%s", rec.Code, rec.Body)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if body := do(h, "GET", "/firewall", nil, cookie).Body.String(); !strings.Contains(body, `value="192.168.10.5"`) {
|
||||
t.Errorf("the forward is not listed:\n%s", body)
|
||||
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 or its allow rule is not listed:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,11 +40,11 @@
|
||||
<input type="hidden" name="rows" value="{{len .Rows}}">
|
||||
<div class="scroll">
|
||||
<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}}
|
||||
<tr>
|
||||
<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>
|
||||
<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>
|
||||
@@ -56,50 +56,62 @@
|
||||
<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}}
|
||||
</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>
|
||||
</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}}
|
||||
</table>
|
||||
</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;
|
||||
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>
|
||||
<legend>From the internet</legend>
|
||||
<input type="hidden" name="forwards" value="{{len .Forwards}}">
|
||||
<legend>Address translation (NAT)</legend>
|
||||
<input type="hidden" name="nats" value="{{len .NATs}}">
|
||||
<div class="scroll">
|
||||
<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>
|
||||
{{range $i, $r := .Forwards}}
|
||||
<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 := .NATs}}
|
||||
<tr>
|
||||
<td><select name="fwd.{{$i}}.protocol">
|
||||
{{range $p := list "tcp" "udp" "icmp"}}<option value="{{$p}}" {{if eq $p $r.Protocol}}selected{{end}}>{{$p}}</option>{{end}}
|
||||
<td><select name="nat.{{$i}}.type">
|
||||
{{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>
|
||||
<td><input name="fwd.{{$i}}.ports" value="{{$r.Ports}}" size="10" placeholder="443"></td>
|
||||
<td><select name="fwd.{{$i}}.to"><option value=""></option>
|
||||
{{range $.Data.Zones}}<option value="{{.}}" {{if eq . $r.To}}selected{{end}}>{{.}}</option>{{end}}
|
||||
<td><select name="nat.{{$i}}.from"><option value="">any</option>
|
||||
{{range $.Data.FromZones}}<option value="{{.}}" {{if eq . $r.From}}selected{{end}}>{{.}}</option>{{end}}
|
||||
</select></td>
|
||||
<td><input name="fwd.{{$i}}.destination" value="{{$r.Destination}}" size="16" spellcheck="false" placeholder="192.168.10.5"></td>
|
||||
<td><input name="fwd.{{$i}}.toport" value="{{$r.ToPort}}" size="6" placeholder="same"></td>
|
||||
<td><input name="fwd.{{$i}}.sources" value="{{$r.Sources}}" size="14" spellcheck="false" placeholder="anywhere"></td>
|
||||
<td><input type="checkbox" name="fwd.{{$i}}.nat" {{if $r.NAT}}checked{{end}}></td>
|
||||
<td><input type="checkbox" name="fwd.{{$i}}.hairpin" {{if $r.Hairpin}}checked{{end}}></td>
|
||||
<td><input type="checkbox" name="fwd.{{$i}}.log" {{if $r.Log}}checked{{end}}></td>
|
||||
<td><input name="fwd.{{$i}}.ratelimit" value="{{$r.RateLimit}}" size="9" placeholder="none"></td>
|
||||
<td>{{if or $r.Ports $r.Destination}}<input type="checkbox" name="fwd.{{$i}}.remove">{{end}}</td>
|
||||
<td><input name="nat.{{$i}}.sources" value="{{$r.Sources}}" size="14" spellcheck="false" placeholder="any"></td>
|
||||
<td><input name="nat.{{$i}}.destination" value="{{$r.Destination}}" size="14" spellcheck="false" placeholder="the router"></td>
|
||||
<td><select name="nat.{{$i}}.protocol">
|
||||
{{range $p := list "any" "tcp" "udp" "icmp"}}<option value="{{$p}}" {{if eq $p $r.Protocol}}selected{{end}}>{{$p}}</option>{{end}}
|
||||
</select></td>
|
||||
<td><input name="nat.{{$i}}.ports" value="{{$r.Ports}}" size="10" placeholder="443"></td>
|
||||
<td><select name="nat.{{$i}}.to"><option value=""></option>
|
||||
{{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>
|
||||
{{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}}
|
||||
</table>
|
||||
</div>
|
||||
<p class="hint">Everything from the internet is blocked unless listed here. A <strong>port forward</strong> sends tcp or udp
|
||||
traffic for the router's public IPv4 address to one host inside (to address is that host; to port only if it differs,
|
||||
with a single public port); the sender's address is kept. Without it the rule just lets traffic through to an address
|
||||
or network inside, for IPv6 or for a public range. <strong>Hairpin</strong> lets your LANs use the forward through the
|
||||
router's public address. Rate limit caps new connections per sender, like <code>10/minute</code>.
|
||||
Fill in the last row to add one.</p>
|
||||
<p class="hint"><strong>Port forward:</strong> traffic from the zone (<code>wan</code> is the internet) addressed to the router
|
||||
(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>;
|
||||
the sender's address is kept. <em>Also allow</em> adds the filter rule that lets it through (without it the firewall still
|
||||
blocks it). <em>Hairpin</em> lets your LANs use a forward from the internet through the router's public address.<br>
|
||||
<strong>Masquerade</strong> and <strong>source NAT</strong> rewrite where traffic from the zone (or any zone) leaves through
|
||||
<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>
|
||||
<button>Save</button>
|
||||
</form>
|
||||
|
||||
@@ -8,7 +8,7 @@ mkdir -p "$dir/config"
|
||||
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"')
|
||||
cat > "$dir/config/settings.json" <<JSON
|
||||
{"version": 0, "system": {"hostName": "dev"},
|
||||
{"version": 1, "system": {"hostName": "dev"},
|
||||
"router": {"wan": {"interface": "${wan:-eth0}", "ipv4": {"type": "dhcp"}},
|
||||
"lans": {"lan": {"interface": "$lan", "internet": true, "ipv4": {"address": "192.168.10.1"},
|
||||
"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.120,52:54:00:aa:bb:cc,,3600,$((now + 1200)),1,0,0,phone,0,,0
|
||||
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"
|
||||
echo devpassword1 | go run . passwd "$dir/webui-password"
|
||||
echo
|
||||
|
||||
@@ -16,6 +16,8 @@ let
|
||||
|
||||
settings = pkgs.writeText "settings.json" (
|
||||
builtins.toJSON {
|
||||
# The schema of this source: the number of migrations.
|
||||
version = lib.count (lib.hasSuffix ".jq") (builtins.attrNames (builtins.readDir ../migrations));
|
||||
system = {
|
||||
hostName = "router";
|
||||
stateVersion = "26.11";
|
||||
|
||||
+170
-33
@@ -609,13 +609,11 @@ let
|
||||
to = "main";
|
||||
destination = "192.168.10.5";
|
||||
protocol = "tcp";
|
||||
ports = [ 8080 ];
|
||||
dnat = true;
|
||||
toPort = 8081;
|
||||
ports = [ 8081 ];
|
||||
natOnly = true;
|
||||
sources = [ "198.51.100.0/24" ];
|
||||
rateLimit = "3/minute";
|
||||
log = true;
|
||||
hairpin = true;
|
||||
}
|
||||
{
|
||||
from = "wan";
|
||||
@@ -685,91 +683,230 @@ let
|
||||
};
|
||||
expect = [ "`wan` cannot be a destination" ];
|
||||
};
|
||||
dnatNeedsHost = {
|
||||
natDnatNeedsHost = {
|
||||
router = {
|
||||
lans.main = lan { };
|
||||
firewall.rules = [
|
||||
firewall.nat = [
|
||||
{
|
||||
type = "dnat";
|
||||
from = "wan";
|
||||
to = "main";
|
||||
destination = "192.168.10.0/24";
|
||||
protocol = "tcp";
|
||||
ports = [ 8080 ];
|
||||
dnat = true;
|
||||
}
|
||||
];
|
||||
};
|
||||
expect = [ "dnat needs a single IPv4 host" ];
|
||||
expect = [ "dnat and snat need a toAddress" ];
|
||||
};
|
||||
dnatV6Sources = {
|
||||
natDnatHostOutsideZone = {
|
||||
router = {
|
||||
lans.main = lan { };
|
||||
firewall.rules = [
|
||||
firewall.nat = [
|
||||
{
|
||||
type = "dnat";
|
||||
from = "wan";
|
||||
to = "main";
|
||||
destination = "192.168.10.5";
|
||||
protocol = "tcp";
|
||||
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" ];
|
||||
}
|
||||
];
|
||||
};
|
||||
expect = [ "its sources must be IPv4" ];
|
||||
expect = [ "translation is IPv4 only" ];
|
||||
};
|
||||
toPortWithoutDnat = {
|
||||
natToPortNeedsOnePort = {
|
||||
router = {
|
||||
lans.main = lan { };
|
||||
firewall.rules = [
|
||||
firewall.nat = [
|
||||
{
|
||||
type = "dnat";
|
||||
from = "wan";
|
||||
to = "main";
|
||||
destination = "192.168.10.5";
|
||||
protocol = "tcp";
|
||||
ports = [ 8080 ];
|
||||
toPort = 8081;
|
||||
ports = [
|
||||
8080
|
||||
8081
|
||||
];
|
||||
toAddress = "192.168.10.5";
|
||||
toPort = 80;
|
||||
}
|
||||
];
|
||||
};
|
||||
expect = [ "toPort needs dnat" ];
|
||||
expect = [ "toPort needs exactly one" ];
|
||||
};
|
||||
hairpinNoDnat = {
|
||||
natToPortOnMasquerade = {
|
||||
router = {
|
||||
lans.main = lan { };
|
||||
firewall.rules = [
|
||||
lans = {
|
||||
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";
|
||||
destination = "192.168.10.5";
|
||||
protocol = "tcp";
|
||||
ports = [ 8080 ];
|
||||
toAddress = "192.168.10.5";
|
||||
hairpin = true;
|
||||
}
|
||||
];
|
||||
};
|
||||
expect = [
|
||||
"hairpin only applies to dnat"
|
||||
"hairpin needs at least one"
|
||||
];
|
||||
expect = [ "hairpin only applies to forwards from `wan`" ];
|
||||
};
|
||||
hairpinNoInternet = {
|
||||
natHairpinNoInternet = {
|
||||
router = {
|
||||
lans.main = lan { };
|
||||
firewall.rules = [
|
||||
firewall.nat = [
|
||||
{
|
||||
type = "dnat";
|
||||
from = "wan";
|
||||
to = "main";
|
||||
destination = "192.168.10.5";
|
||||
protocol = "tcp";
|
||||
ports = [ 8080 ];
|
||||
dnat = true;
|
||||
toAddress = "192.168.10.5";
|
||||
hairpin = true;
|
||||
}
|
||||
];
|
||||
};
|
||||
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 = {
|
||||
router = {
|
||||
lans.main = lan { };
|
||||
|
||||
+72
-40
@@ -273,51 +273,83 @@ pkgs.testers.runNixOSTest {
|
||||
};
|
||||
};
|
||||
dns.forwarders = [ ispV4 ];
|
||||
firewall.rules =
|
||||
let
|
||||
fwd =
|
||||
r:
|
||||
# A port forward is a translation and the rule that lets it through.
|
||||
firewall = {
|
||||
nat =
|
||||
let
|
||||
fwd =
|
||||
r:
|
||||
{
|
||||
type = "dnat";
|
||||
from = "wan";
|
||||
to = "main";
|
||||
toAddress = clientV4;
|
||||
protocol = "tcp";
|
||||
}
|
||||
// r;
|
||||
in
|
||||
[
|
||||
(fwd {
|
||||
ports = [ 8080 ];
|
||||
hairpin = true;
|
||||
})
|
||||
(fwd {
|
||||
ports = [ 8083 ];
|
||||
toPort = 8084;
|
||||
})
|
||||
(fwd { ports = [ "9000-9002" ]; })
|
||||
(fwd {
|
||||
ports = [ 8085 ];
|
||||
sources = [ "${ispV4}/32" ];
|
||||
})
|
||||
(fwd {
|
||||
ports = [ 8086 ];
|
||||
sources = [ "192.0.2.0/24" ];
|
||||
})
|
||||
(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 ];
|
||||
rateLimit = "3/minute";
|
||||
log = true;
|
||||
})
|
||||
{
|
||||
from = "wan";
|
||||
to = "main";
|
||||
destination = clientV4;
|
||||
destination = clientV6;
|
||||
protocol = "tcp";
|
||||
dnat = true;
|
||||
ports = [ 8081 ];
|
||||
}
|
||||
// r;
|
||||
in
|
||||
[
|
||||
(fwd {
|
||||
ports = [ 8080 ];
|
||||
hairpin = true;
|
||||
log = true;
|
||||
})
|
||||
(fwd {
|
||||
ports = [ 8083 ];
|
||||
toPort = 8084;
|
||||
})
|
||||
(fwd { ports = [ "9000-9002" ]; })
|
||||
(fwd {
|
||||
ports = [ 8085 ];
|
||||
sources = [ "${ispV4}/32" ];
|
||||
})
|
||||
(fwd {
|
||||
ports = [ 8086 ];
|
||||
sources = [ "192.0.2.0/24" ];
|
||||
})
|
||||
(fwd {
|
||||
ports = [ 8087 ];
|
||||
rateLimit = "3/minute";
|
||||
log = true;
|
||||
})
|
||||
{
|
||||
from = "wan";
|
||||
to = "main";
|
||||
destination = clientV6;
|
||||
protocol = "tcp";
|
||||
ports = [ 8081 ];
|
||||
}
|
||||
];
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
# Unbound answers `.test` locally (RFC 6761) and would validate DNSSEC
|
||||
|
||||
+22
-10
@@ -200,16 +200,28 @@ pkgs.testers.runNixOSTest {
|
||||
};
|
||||
# Only the PPP device is an uplink, so this must work from the ISP
|
||||
# and must not fire for traffic arriving from the LAN.
|
||||
firewall.rules = [
|
||||
{
|
||||
from = "wan";
|
||||
to = "main";
|
||||
destination = "192.168.10.51";
|
||||
protocol = "tcp";
|
||||
ports = [ 8080 ];
|
||||
dnat = true;
|
||||
}
|
||||
];
|
||||
firewall = {
|
||||
nat = [
|
||||
{
|
||||
type = "dnat";
|
||||
from = "wan";
|
||||
to = "main";
|
||||
toAddress = "192.168.10.51";
|
||||
protocol = "tcp";
|
||||
ports = [ 8080 ];
|
||||
}
|
||||
];
|
||||
rules = [
|
||||
{
|
||||
from = "wan";
|
||||
to = "main";
|
||||
destination = "192.168.10.51";
|
||||
protocol = "tcp";
|
||||
ports = [ 8080 ];
|
||||
natOnly = true;
|
||||
}
|
||||
];
|
||||
};
|
||||
dns = {
|
||||
forwarders = [ ispV4 ];
|
||||
domain = "lan.test";
|
||||
|
||||
@@ -50,6 +50,8 @@ let
|
||||
name = "v1";
|
||||
version = "1.0.0";
|
||||
serial = 1000;
|
||||
# Settings version 0: the real migrations are not part of this test.
|
||||
extra = "rm $out/migrations/*.jq";
|
||||
};
|
||||
v2 = mkVersion {
|
||||
name = "v2";
|
||||
|
||||
+59
-1
@@ -9,6 +9,10 @@
|
||||
# iot -> trusted, tcp 9090, only from source ports 40000-40010
|
||||
# "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
|
||||
{ self, pkgs }:
|
||||
let
|
||||
@@ -164,6 +168,32 @@ pkgs.testers.runNixOSTest {
|
||||
}
|
||||
];
|
||||
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" ];
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -208,6 +253,7 @@ pkgs.testers.runNixOSTest {
|
||||
upstream.wait_for_unit("dnsmasq.service")
|
||||
upstream.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"):
|
||||
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 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"):
|
||||
router.succeed("nft list ruleset | grep -q 'sport 40000-40010'")
|
||||
router.succeed("systemctl is-active nftables.service")
|
||||
|
||||
+3
-3
@@ -137,12 +137,12 @@ pkgs.testers.runNixOSTest {
|
||||
assert post("/firewall", {"csrf": csrf("/firewall"), **rule}) == "422"
|
||||
|
||||
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.0.destination": "192.168.10.5", "fwd.0.nat": "on"}
|
||||
fwd = {"nats": "1", "nat.0.type": "dnat", "nat.0.from": "wan", "nat.0.protocol": "tcp", "nat.0.ports": "8080",
|
||||
"nat.0.to": "main", "nat.0.toaddress": "192.168.10.5", "nat.0.allow": "on"}
|
||||
assert post("/firewall", {"csrf": csrf("/firewall"), **fwd}) == "303"
|
||||
assert 'value="192.168.10.5"' in curl(f"{ui}/firewall")
|
||||
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"):
|
||||
dd = {"enabled": "on", "protocol": "cloudflare", "username": "token", "zone": "example.org",
|
||||
|
||||
Reference in New Issue
Block a user