Add port forwards and WAN-originated firewall rules
Extend router.firewall.rules with a reserved `wan` source zone: DNAT (dnat, toPort), port ranges, source allow-lists, per-source rate limits, logging and optional hairpin NAT. IPv6 is opened only by explicit pinholes. Build-time assertions require WAN rules to name a destination, protocol and ports so a typo cannot expose a whole zone. Covered by the basic/pppoe VM tests and the assertion tests. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -79,7 +79,7 @@ Learned along the way:
|
|||||||
- In NixOS tests `fail(...)` also passes on "command not found": install probe tools explicitly and add a positive control per negative check.
|
- In NixOS tests `fail(...)` also passes on "command not found": install probe tools explicitly and add a positive control per negative check.
|
||||||
- Unbound answers `.test` locally (RFC 6761) and validates DNSSEC; tests using fake domains need both `local-zone ... transparent` and `domain-insecure` (both verified necessary; set in the test, not the module).
|
- Unbound answers `.test` locally (RFC 6761) and validates DNSSEC; tests using fake domains need both `local-zone ... transparent` and `domain-insecure` (both verified necessary; set in the test, not the module).
|
||||||
- `modules/router/lib.nix` has IPv4 helpers (parse/format/netmask/cidr/overlap). `ipv4.subnet` is now derived from `address` + `prefixLength` (read-only). Assertions also cover pool bounds, pool containing the router address, and overlapping LANs. IPv6 helpers (prefix arithmetic for PD subnet-IDs) are still to do.
|
- `modules/router/lib.nix` has IPv4 helpers (parse/format/netmask/cidr/overlap). `ipv4.subnet` is now derived from `address` + `prefixLength` (read-only). Assertions also cover pool bounds, pool containing the router address, and overlapping LANs. IPv6 helpers (prefix arithmetic for PD subnet-IDs) are still to do.
|
||||||
- Checks: `basic`, `pppoe`, `vlans` and `wireguard` (VM tests), `lib` (helper unit tests), `assertions` (broken configs must fail with exact messages). Not yet covered: port forwards, WAN-originated rules, NPTv6.
|
- Checks: `basic`, `pppoe`, `vlans` and `wireguard` (VM tests), `lib` (helper unit tests), `assertions` (broken configs must fail with exact messages). Not yet covered: NPTv6.
|
||||||
|
|
||||||
## IPv6 design (confirmed)
|
## IPv6 design (confirmed)
|
||||||
|
|
||||||
@@ -105,6 +105,14 @@ 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)
|
||||||
|
|
||||||
|
- 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).
|
||||||
|
- 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).
|
||||||
|
|
||||||
## WireGuard (confirmed)
|
## WireGuard (confirmed)
|
||||||
|
|
||||||
- **Use cases**: road warrior (listening, dual-stack), site-to-site (no transfer network), and a provider VPN the router dials into (dual-stack transfer net, masquerade). All via `router.wireguard.<name>`.
|
- **Use cases**: road warrior (listening, dual-stack), site-to-site (no transfer network), and a provider VPN the router dials into (dual-stack transfer net, masquerade). All via `router.wireguard.<name>`.
|
||||||
|
|||||||
@@ -96,25 +96,46 @@ let
|
|||||||
}) (lib.filter (l: l.zone == zone && validIp l.ipv4.address) lans)
|
}) (lib.filter (l: l.zone == zone && validIp l.ipv4.address) lans)
|
||||||
++ lib.concatMap wgV4Nets (lib.filter (w: w.zone == zone) wgs);
|
++ lib.concatMap wgV4Nets (lib.filter (w: w.zone == zone) wgs);
|
||||||
|
|
||||||
|
portOk =
|
||||||
|
p:
|
||||||
|
if builtins.isInt p then
|
||||||
|
p >= 1 && p <= 65535
|
||||||
|
else
|
||||||
|
let
|
||||||
|
m = builtins.match "([0-9]+)-([0-9]+)" p;
|
||||||
|
lo = lib.toInt (builtins.elemAt m 0);
|
||||||
|
hi = lib.toInt (builtins.elemAt m 1);
|
||||||
|
in
|
||||||
|
lo >= 1 && hi <= 65535 && lo < hi;
|
||||||
|
|
||||||
ruleAssertions =
|
ruleAssertions =
|
||||||
r:
|
r:
|
||||||
let
|
let
|
||||||
where = "router.firewall.rules (${r.from} -> ${r.to})";
|
where = "router.firewall.rules (${r.from} -> ${r.to})";
|
||||||
dest = if r.destination == null then null else ipv4.parseNet r.destination;
|
fromWan = r.from == "wan";
|
||||||
|
v6Dest = r.destination != null && isV6 r.destination;
|
||||||
|
dest = if r.destination == null || v6Dest then null else ipv4.parseNet r.destination;
|
||||||
inZone =
|
inZone =
|
||||||
dest != null
|
dest != null
|
||||||
&& 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
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
assertion = lib.elem r.from zoneNames;
|
assertion = fromWan || lib.elem r.from zoneNames;
|
||||||
message = "router: ${where}: unknown source zone `${r.from}` (zones: ${lib.concatStringsSep ", " zoneNames}).";
|
message = "router: ${where}: unknown source zone `${r.from}` (zones: wan, ${lib.concatStringsSep ", " zoneNames}).";
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
assertion = lib.elem r.to zoneNames;
|
assertion = lib.elem r.to zoneNames;
|
||||||
message = "router: ${where}: unknown destination zone `${r.to}` (zones: ${lib.concatStringsSep ", " zoneNames}).";
|
message =
|
||||||
|
if r.to == "wan" then
|
||||||
|
"router: ${where}: `wan` cannot be a destination; use `internet = true` on the LAN or tunnel."
|
||||||
|
else
|
||||||
|
"router: ${where}: unknown destination zone `${r.to}` (zones: ${lib.concatStringsSep ", " zoneNames}).";
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
assertion = r.from != r.to;
|
assertion = r.from != r.to;
|
||||||
@@ -124,20 +145,93 @@ let
|
|||||||
assertion = r.ports == [ ] || r.protocol == "tcp" || r.protocol == "udp";
|
assertion = r.ports == [ ] || r.protocol == "tcp" || r.protocol == "udp";
|
||||||
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 = lib.all portOk r.ports;
|
||||||
|
message = "router: ${where}: a port or port range is out of range (1-65535, ranges need low < high).";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
assertion = lib.all validCidr (
|
||||||
|
map (
|
||||||
|
x:
|
||||||
|
if lib.hasInfix "/" x then
|
||||||
|
x
|
||||||
|
else if isV6 x then
|
||||||
|
"${x}/128"
|
||||||
|
else
|
||||||
|
"${x}/32"
|
||||||
|
) r.sources
|
||||||
|
);
|
||||||
|
message = "router: ${where}: every source must be an IPv4/IPv6 address or CIDR.";
|
||||||
|
}
|
||||||
|
]
|
||||||
|
# Rules from the outside must not be able to expose a whole zone by accident.
|
||||||
|
++ lib.optionals fromWan [
|
||||||
|
{
|
||||||
|
assertion = r.destination != null;
|
||||||
|
message = "router: ${where}: rules from `wan` must name a destination host or network.";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
assertion = r.protocol != "any";
|
||||||
|
message = "router: ${where}: rules from `wan` must name a protocol (tcp, udp or icmp).";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
assertion = r.protocol == "icmp" || r.protocol == "any" || r.ports != [ ];
|
||||||
|
message = "router: ${where}: rules from `wan` must list the ports they open.";
|
||||||
|
}
|
||||||
]
|
]
|
||||||
++ lib.optionals (r.destination != null) [
|
++ lib.optionals (r.destination != null) [
|
||||||
{
|
{
|
||||||
assertion = dest != null;
|
assertion = v6Dest || dest != null;
|
||||||
message = "router: ${where}: destination `${r.destination}` is not a valid IPv4 address or CIDR.";
|
message = "router: ${where}: destination `${r.destination}` is not a valid IPv4 address or CIDR.";
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
assertion = dest == null || !(lib.elem r.to zoneNames) || inZone;
|
assertion = v6Dest || dest == null || !(lib.elem r.to zoneNames) || inZone;
|
||||||
message = "router: ${where}: destination `${r.destination}` is not inside any network of zone `${r.to}`.";
|
message = "router: ${where}: destination `${r.destination}` is not inside any network of zone `${r.to}`.";
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
assertion = dest == null || ipv4.networkOf dest.address dest.prefixLength == dest.address;
|
assertion = v6Dest || dest == null || ipv4.networkOf dest.address dest.prefixLength == dest.address;
|
||||||
message = "router: ${where}: destination `${r.destination}` has host bits set.";
|
message = "router: ${where}: destination `${r.destination}` has host bits set.";
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
assertion =
|
||||||
|
!v6Dest
|
||||||
|
|| v6Cidr (if lib.hasInfix "/" r.destination then r.destination else "${r.destination}/128");
|
||||||
|
message = "router: ${where}: destination `${r.destination}` is not a valid IPv6 address or CIDR.";
|
||||||
|
}
|
||||||
|
]
|
||||||
|
++ lib.optionals r.dnat [
|
||||||
|
{
|
||||||
|
assertion = fromWan;
|
||||||
|
message = "router: ${where}: dnat is only valid for rules from `wan`.";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
assertion = singleHost;
|
||||||
|
message = "router: ${where}: dnat needs a single IPv4 host as destination.";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
assertion = r.protocol == "tcp" || r.protocol == "udp";
|
||||||
|
message = "router: ${where}: dnat needs protocol tcp or udp.";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
assertion = lib.all (x: !isV6 x) r.sources;
|
||||||
|
message = "router: ${where}: dnat is IPv4-only, so its sources must be IPv4.";
|
||||||
|
}
|
||||||
|
]
|
||||||
|
++ lib.optionals (r.toPort != null) [
|
||||||
|
{
|
||||||
|
assertion = r.dnat && lib.length r.ports == 1 && plainPorts;
|
||||||
|
message = "router: ${where}: toPort needs dnat and exactly one (non-range) port.";
|
||||||
|
}
|
||||||
|
]
|
||||||
|
++ lib.optionals r.hairpin [
|
||||||
|
{
|
||||||
|
assertion = r.dnat;
|
||||||
|
message = "router: ${where}: hairpin only applies to dnat rules.";
|
||||||
|
}
|
||||||
|
{
|
||||||
|
assertion = internetMember;
|
||||||
|
message = "router: ${where}: hairpin needs at least one LAN or tunnel with internet = true.";
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
overlapsLan = n: lib.any (l: ipv4.overlaps n l) lanNets;
|
overlapsLan = n: lib.any (l: ipv4.overlaps n l) lanNets;
|
||||||
@@ -246,6 +340,12 @@ in
|
|||||||
]
|
]
|
||||||
++ lib.concatLists (lib.mapAttrsToList perLan lansByName)
|
++ lib.concatLists (lib.mapAttrsToList perLan lansByName)
|
||||||
++ lib.concatMap ruleAssertions cfg.firewall.rules
|
++ lib.concatMap ruleAssertions cfg.firewall.rules
|
||||||
|
++ [
|
||||||
|
{
|
||||||
|
assertion = !(lib.elem "wan" zoneNames);
|
||||||
|
message = "router: `wan` is a reserved zone name; give the LAN or tunnel a different `zone`.";
|
||||||
|
}
|
||||||
|
]
|
||||||
++ lib.concatLists (lib.mapAttrsToList wgAssertions cfg.wireguard)
|
++ lib.concatLists (lib.mapAttrsToList wgAssertions cfg.wireguard)
|
||||||
++ [
|
++ [
|
||||||
{
|
{
|
||||||
|
|||||||
+120
-12
@@ -60,21 +60,121 @@ let
|
|||||||
) zones
|
) zones
|
||||||
);
|
);
|
||||||
|
|
||||||
ruleToNft =
|
setOf = xs: "{ ${lib.concatMapStringsSep ", " toString xs} }";
|
||||||
|
|
||||||
|
# Layer 4 match; `ports` may be ints or "a-b" ranges.
|
||||||
|
l4 =
|
||||||
|
proto: ports:
|
||||||
|
if proto == "any" then
|
||||||
|
""
|
||||||
|
else if proto == "icmp" then
|
||||||
|
"meta l4proto { icmp, ipv6-icmp } "
|
||||||
|
else
|
||||||
|
"${proto} dport ${if ports == [ ] then "1-65535" else setOf ports} ";
|
||||||
|
|
||||||
|
# Destination match, by family of the address.
|
||||||
|
daddrOf =
|
||||||
|
r:
|
||||||
|
lib.optionalString (r.destination != null) (
|
||||||
|
"${if isV6 r.destination then "ip6" else "ip"} daddr ${r.destination} "
|
||||||
|
);
|
||||||
|
|
||||||
|
# One variant per source family; no sources means one unrestricted variant.
|
||||||
|
# `key` is the family selector used for per-source rate limiting (null: both).
|
||||||
|
variantsOf =
|
||||||
r:
|
r:
|
||||||
let
|
let
|
||||||
dest = lib.optionalString (r.destination != null) "ip daddr ${r.destination} ";
|
v4 = lib.filter (x: !isV6 x) r.sources;
|
||||||
proto =
|
v6 = lib.filter isV6 r.sources;
|
||||||
if r.protocol == "any" then
|
destFamily =
|
||||||
""
|
if r.destination == null then
|
||||||
else if r.protocol == "icmp" then
|
null
|
||||||
"meta l4proto { icmp, ipv6-icmp } "
|
else if isV6 r.destination then
|
||||||
|
"ip6"
|
||||||
else
|
else
|
||||||
"${r.protocol} dport ${
|
"ip";
|
||||||
if r.ports == [ ] then "1-65535" else "{ ${lib.concatMapStringsSep ", " toString r.ports} }"
|
|
||||||
} ";
|
|
||||||
in
|
in
|
||||||
"iifname ${set (zoneDevices r.from)} oifname ${set (zoneDevices r.to)} ${dest}${proto}accept # ${r.from} -> ${r.to}";
|
if r.sources == [ ] then
|
||||||
|
[
|
||||||
|
{
|
||||||
|
src = "";
|
||||||
|
key = destFamily;
|
||||||
|
}
|
||||||
|
]
|
||||||
|
else
|
||||||
|
lib.optional (v4 != [ ]) {
|
||||||
|
src = "ip saddr ${setOf v4} ";
|
||||||
|
key = "ip";
|
||||||
|
}
|
||||||
|
++ lib.optional (v6 != [ ]) {
|
||||||
|
src = "ip6 saddr ${setOf v6} ";
|
||||||
|
key = "ip6";
|
||||||
|
};
|
||||||
|
|
||||||
|
# Everything one rule contributes. Indexed so meters/prefixes are unique.
|
||||||
|
ruleParts =
|
||||||
|
i: r:
|
||||||
|
let
|
||||||
|
id = toString i;
|
||||||
|
label = "${r.from}->${r.to}";
|
||||||
|
fromDevs = if r.from == "wan" then uplinks else zoneDevices r.from;
|
||||||
|
toDevs = zoneDevices r.to;
|
||||||
|
# After DNAT the packet carries the translated port.
|
||||||
|
fwdPorts = if r.dnat && r.toPort != null then [ r.toPort ] else r.ports;
|
||||||
|
base = "iifname ${set fromDevs} oifname ${set toDevs} ${daddrOf r}${l4 r.protocol fwdPorts}${lib.optionalString r.dnat "ct status dnat "}";
|
||||||
|
logPrefix = what: ''log prefix "router fwd#${id} ${label} ${what}: " '';
|
||||||
|
limitLines =
|
||||||
|
v:
|
||||||
|
lib.optionals (r.rateLimit != null) (
|
||||||
|
map
|
||||||
|
(
|
||||||
|
k:
|
||||||
|
"${base}${v.src}ct state new meter rl${id}-${k} { ${k} saddr limit rate over ${r.rateLimit} } ${lib.optionalString r.log (logPrefix "rate-limited")}drop"
|
||||||
|
)
|
||||||
|
(
|
||||||
|
if v.key == null then
|
||||||
|
[
|
||||||
|
"ip"
|
||||||
|
"ip6"
|
||||||
|
]
|
||||||
|
else
|
||||||
|
[ v.key ]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
acceptLines =
|
||||||
|
v:
|
||||||
|
lib.optional r.log "${base}${v.src}ct state new ${logPrefix "accepted"}"
|
||||||
|
++ [ "${base}${v.src}accept # ${label}" ];
|
||||||
|
dnatTarget = "dnat to ${r.destination}${
|
||||||
|
lib.optionalString (r.toPort != null) ":${toString r.toPort}"
|
||||||
|
}";
|
||||||
|
in
|
||||||
|
{
|
||||||
|
forward = lib.concatMap (v: limitLines v ++ acceptLines v) (variantsOf r);
|
||||||
|
prerouting =
|
||||||
|
lib.optionals r.dnat (
|
||||||
|
map (v: "iifname ${set uplinks} ${v.src}${l4 r.protocol r.ports}${dnatTarget} # ${label}") (
|
||||||
|
variantsOf r
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# NAT loopback: internal clients addressing the router's public address.
|
||||||
|
++
|
||||||
|
lib.optional (r.dnat && r.hairpin)
|
||||||
|
"iifname ${set internetIfaces} fib daddr type local ip daddr != ${setOf internalAddrs} ${l4 r.protocol r.ports}${dnatTarget} # hairpin ${label}";
|
||||||
|
hairpinForward = lib.optional (r.dnat && r.hairpin) (
|
||||||
|
"iifname ${set internetIfaces} oifname ${set toDevs} ip daddr ${r.destination} ${l4 r.protocol fwdPorts}ct status dnat accept # hairpin ${label}"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
parts = lib.imap0 ruleParts cfg.firewall.rules;
|
||||||
|
anyHairpin = lib.any (r: r.dnat && r.hairpin) cfg.firewall.rules;
|
||||||
|
# Addresses the router itself holds on internal interfaces; anything else
|
||||||
|
# that is local is a public (uplink) address.
|
||||||
|
internalAddrs =
|
||||||
|
map (l: l.ipv4.address) lans
|
||||||
|
++ map (a: builtins.head (lib.splitString "/" a)) (
|
||||||
|
lib.filter (a: !isV6 a) (lib.concatMap (w: w.addresses) wgs)
|
||||||
|
);
|
||||||
|
|
||||||
uplink4 = cfg.wan.ipv4.interface;
|
uplink4 = cfg.wan.ipv4.interface;
|
||||||
pppoe = cfg.wan.ipv4.type == "pppoe";
|
pppoe = cfg.wan.ipv4.type == "pppoe";
|
||||||
@@ -146,7 +246,9 @@ in
|
|||||||
) "iifname ${set internetIfaces} oifname ${set uplinks} accept"}
|
) "iifname ${set internetIfaces} oifname ${set uplinks} accept"}
|
||||||
|
|
||||||
# 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 " (intraZone ++ map ruleToNft cfg.firewall.rules)}
|
${lib.concatStringsSep "\n " (
|
||||||
|
intraZone ++ lib.concatMap (p: p.hairpinForward ++ p.forward) parts
|
||||||
|
)}
|
||||||
}
|
}
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
@@ -154,9 +256,15 @@ in
|
|||||||
networking.nftables.tables.router-nat = {
|
networking.nftables.tables.router-nat = {
|
||||||
family = "ip";
|
family = "ip";
|
||||||
content = ''
|
content = ''
|
||||||
|
chain prerouting {
|
||||||
|
type nat hook prerouting priority dstnat; policy accept;
|
||||||
|
${lib.concatStringsSep "\n " (lib.concatMap (p: p.prerouting) parts)}
|
||||||
|
}
|
||||||
|
|
||||||
chain postrouting {
|
chain postrouting {
|
||||||
type nat hook postrouting priority srcnat; policy accept;
|
type nat hook postrouting priority srcnat; policy accept;
|
||||||
oifname "${uplink4}" masquerade
|
oifname "${uplink4}" masquerade
|
||||||
|
${lib.optionalString anyHairpin "ct status dnat iifname ${set memberIfaces} oifname ${set memberIfaces} masquerade # hairpin"}
|
||||||
${lib.optionalString (
|
${lib.optionalString (
|
||||||
masqueradeIfaces != [ ]
|
masqueradeIfaces != [ ]
|
||||||
) "oifname ${set masqueradeIfaces} masquerade # tunnels"}
|
) "oifname ${set masqueradeIfaces} masquerade # tunnels"}
|
||||||
|
|||||||
@@ -317,14 +317,18 @@ in
|
|||||||
description = ''
|
description = ''
|
||||||
Allow rules between zones, on top of the default (deny between zones).
|
Allow rules between zones, on top of the default (deny between zones).
|
||||||
Rules are one-directional; return traffic is allowed by connection
|
Rules are one-directional; return traffic is allowed by connection
|
||||||
tracking. Both ends must be zones of LANs defined in `router.lans`.
|
tracking. `to` must be the zone of a LAN or tunnel; `from` may also be
|
||||||
|
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.
|
||||||
'';
|
'';
|
||||||
type = types.listOf (
|
type = types.listOf (
|
||||||
types.submodule {
|
types.submodule {
|
||||||
options = {
|
options = {
|
||||||
from = mkOption {
|
from = mkOption {
|
||||||
type = types.str;
|
type = types.str;
|
||||||
description = "Source zone.";
|
description = "Source zone, or `wan`.";
|
||||||
};
|
};
|
||||||
to = mkOption {
|
to = mkOption {
|
||||||
type = types.str;
|
type = types.str;
|
||||||
@@ -340,9 +344,17 @@ in
|
|||||||
default = "any";
|
default = "any";
|
||||||
};
|
};
|
||||||
ports = mkOption {
|
ports = mkOption {
|
||||||
type = types.listOf types.port;
|
type = types.listOf (types.either types.port (types.strMatching "[0-9]+-[0-9]+"));
|
||||||
default = [ ];
|
default = [ ];
|
||||||
description = "Destination ports (tcp/udp only); empty means all ports.";
|
example = [
|
||||||
|
443
|
||||||
|
"50000-50100"
|
||||||
|
];
|
||||||
|
description = ''
|
||||||
|
Destination ports or ranges (tcp/udp only); empty means all
|
||||||
|
ports. With `dnat` these are the ports on the router's
|
||||||
|
public address.
|
||||||
|
'';
|
||||||
};
|
};
|
||||||
destination = mkOption {
|
destination = mkOption {
|
||||||
type = types.nullOr types.str;
|
type = types.nullOr types.str;
|
||||||
@@ -350,9 +362,50 @@ in
|
|||||||
example = "192.168.10.5";
|
example = "192.168.10.5";
|
||||||
description = ''
|
description = ''
|
||||||
Restrict the rule to an IPv4 address or CIDR inside the
|
Restrict the rule to an IPv4 address or CIDR inside the
|
||||||
destination zone; null means the whole zone.
|
destination zone; null means the whole zone. IPv6 addresses
|
||||||
|
are accepted too (for pinholes) but cannot be checked against
|
||||||
|
the zone, because delegated prefixes are not known at build time.
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
sources = mkOption {
|
||||||
|
type = types.listOf types.str;
|
||||||
|
default = [ ];
|
||||||
|
example = [ "198.51.100.0/24" ];
|
||||||
|
description = "Only match traffic from these IPv4/IPv6 addresses or CIDRs; empty means any source.";
|
||||||
|
};
|
||||||
|
dnat = 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).
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
rateLimit = mkOption {
|
||||||
|
type = types.nullOr (types.strMatching "[0-9]+/(second|minute|hour)");
|
||||||
|
default = null;
|
||||||
|
example = "10/minute";
|
||||||
|
description = "Limit new connections per source address; the excess is dropped.";
|
||||||
|
};
|
||||||
|
log = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = false;
|
||||||
|
description = "Log new connections accepted by this rule, and connections dropped by `rateLimit` (kernel log).";
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -506,6 +506,222 @@ let
|
|||||||
};
|
};
|
||||||
expect = [ "is not inside any network of zone `s2s`" ];
|
expect = [ "is not inside any network of zone `s2s`" ];
|
||||||
};
|
};
|
||||||
|
wanValid = {
|
||||||
|
router = {
|
||||||
|
lans.main = lan { internet = true; };
|
||||||
|
firewall.rules = [
|
||||||
|
{
|
||||||
|
from = "wan";
|
||||||
|
to = "main";
|
||||||
|
destination = "192.168.10.5";
|
||||||
|
protocol = "tcp";
|
||||||
|
ports = [ 8080 ];
|
||||||
|
dnat = true;
|
||||||
|
toPort = 8081;
|
||||||
|
sources = [ "198.51.100.0/24" ];
|
||||||
|
rateLimit = "3/minute";
|
||||||
|
log = true;
|
||||||
|
hairpin = true;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
from = "wan";
|
||||||
|
to = "main";
|
||||||
|
destination = "2001:db8:1::5";
|
||||||
|
protocol = "tcp";
|
||||||
|
ports = [ "9000-9002" ];
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
expect = [ ];
|
||||||
|
};
|
||||||
|
wanNoDestination = {
|
||||||
|
router = {
|
||||||
|
lans.main = lan { };
|
||||||
|
firewall.rules = [
|
||||||
|
{
|
||||||
|
from = "wan";
|
||||||
|
to = "main";
|
||||||
|
destination = null;
|
||||||
|
protocol = "tcp";
|
||||||
|
ports = [ 8080 ];
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
expect = [ "must name a destination" ];
|
||||||
|
};
|
||||||
|
wanAnyProtocol = {
|
||||||
|
router = {
|
||||||
|
lans.main = lan { };
|
||||||
|
firewall.rules = [
|
||||||
|
{
|
||||||
|
from = "wan";
|
||||||
|
to = "main";
|
||||||
|
destination = "192.168.10.5";
|
||||||
|
protocol = "any";
|
||||||
|
ports = [ ];
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
expect = [ "must name a protocol" ];
|
||||||
|
};
|
||||||
|
wanNoPorts = {
|
||||||
|
router = {
|
||||||
|
lans.main = lan { };
|
||||||
|
firewall.rules = [
|
||||||
|
{
|
||||||
|
from = "wan";
|
||||||
|
to = "main";
|
||||||
|
destination = "192.168.10.5";
|
||||||
|
protocol = "tcp";
|
||||||
|
ports = [ ];
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
expect = [ "must list the ports" ];
|
||||||
|
};
|
||||||
|
toWan = {
|
||||||
|
router = {
|
||||||
|
lans.main = lan { };
|
||||||
|
firewall.rules = [
|
||||||
|
{
|
||||||
|
from = "main";
|
||||||
|
to = "wan";
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
expect = [ "`wan` cannot be a destination" ];
|
||||||
|
};
|
||||||
|
dnatNeedsHost = {
|
||||||
|
router = {
|
||||||
|
lans.main = lan { };
|
||||||
|
firewall.rules = [
|
||||||
|
{
|
||||||
|
from = "wan";
|
||||||
|
to = "main";
|
||||||
|
destination = "192.168.10.0/24";
|
||||||
|
protocol = "tcp";
|
||||||
|
ports = [ 8080 ];
|
||||||
|
dnat = true;
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
expect = [ "dnat needs a single IPv4 host" ];
|
||||||
|
};
|
||||||
|
dnatV6Sources = {
|
||||||
|
router = {
|
||||||
|
lans.main = lan { };
|
||||||
|
firewall.rules = [
|
||||||
|
{
|
||||||
|
from = "wan";
|
||||||
|
to = "main";
|
||||||
|
destination = "192.168.10.5";
|
||||||
|
protocol = "tcp";
|
||||||
|
ports = [ 8080 ];
|
||||||
|
dnat = true;
|
||||||
|
sources = [ "2001:db8::/32" ];
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
expect = [ "its sources must be IPv4" ];
|
||||||
|
};
|
||||||
|
toPortWithoutDnat = {
|
||||||
|
router = {
|
||||||
|
lans.main = lan { };
|
||||||
|
firewall.rules = [
|
||||||
|
{
|
||||||
|
from = "wan";
|
||||||
|
to = "main";
|
||||||
|
destination = "192.168.10.5";
|
||||||
|
protocol = "tcp";
|
||||||
|
ports = [ 8080 ];
|
||||||
|
toPort = 8081;
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
expect = [ "toPort needs dnat" ];
|
||||||
|
};
|
||||||
|
hairpinNoDnat = {
|
||||||
|
router = {
|
||||||
|
lans.main = lan { };
|
||||||
|
firewall.rules = [
|
||||||
|
{
|
||||||
|
from = "wan";
|
||||||
|
to = "main";
|
||||||
|
destination = "192.168.10.5";
|
||||||
|
protocol = "tcp";
|
||||||
|
ports = [ 8080 ];
|
||||||
|
hairpin = true;
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
expect = [
|
||||||
|
"hairpin only applies to dnat"
|
||||||
|
"hairpin needs at least one"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
hairpinNoInternet = {
|
||||||
|
router = {
|
||||||
|
lans.main = lan { };
|
||||||
|
firewall.rules = [
|
||||||
|
{
|
||||||
|
from = "wan";
|
||||||
|
to = "main";
|
||||||
|
destination = "192.168.10.5";
|
||||||
|
protocol = "tcp";
|
||||||
|
ports = [ 8080 ];
|
||||||
|
dnat = true;
|
||||||
|
hairpin = true;
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
expect = [ "hairpin needs at least one" ];
|
||||||
|
};
|
||||||
|
badPortRange = {
|
||||||
|
router = {
|
||||||
|
lans.main = lan { };
|
||||||
|
firewall.rules = [
|
||||||
|
{
|
||||||
|
from = "wan";
|
||||||
|
to = "main";
|
||||||
|
destination = "192.168.10.5";
|
||||||
|
protocol = "tcp";
|
||||||
|
ports = [ "9002-9000" ];
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
expect = [ "out of range" ];
|
||||||
|
};
|
||||||
|
badSource = {
|
||||||
|
router = {
|
||||||
|
lans.main = lan { };
|
||||||
|
firewall.rules = [
|
||||||
|
{
|
||||||
|
from = "wan";
|
||||||
|
to = "main";
|
||||||
|
destination = "192.168.10.5";
|
||||||
|
protocol = "tcp";
|
||||||
|
ports = [ 8080 ];
|
||||||
|
sources = [ "nonsense" ];
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
expect = [ "every source must be" ];
|
||||||
|
};
|
||||||
|
badV6Destination = {
|
||||||
|
router = {
|
||||||
|
lans.main = lan { };
|
||||||
|
firewall.rules = [
|
||||||
|
{
|
||||||
|
from = "wan";
|
||||||
|
to = "main";
|
||||||
|
destination = "2001:db8::zz";
|
||||||
|
protocol = "tcp";
|
||||||
|
ports = [ 8080 ];
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
expect = [ "not a valid IPv6" ];
|
||||||
|
};
|
||||||
nonOverlappingLans = {
|
nonOverlappingLans = {
|
||||||
router.lans = {
|
router.lans = {
|
||||||
a = lan { };
|
a = lan { };
|
||||||
@@ -552,6 +768,7 @@ let
|
|||||||
"vlansOnOneTrunk"
|
"vlansOnOneTrunk"
|
||||||
"ruleValid"
|
"ruleValid"
|
||||||
"wgValid"
|
"wgValid"
|
||||||
|
"wanValid"
|
||||||
];
|
];
|
||||||
|
|
||||||
subnet =
|
subnet =
|
||||||
|
|||||||
+165
-11
@@ -21,6 +21,9 @@ let
|
|||||||
ispV4 = if pppoe then "198.51.100.1" else "203.0.113.1";
|
ispV4 = if pppoe then "198.51.100.1" else "203.0.113.1";
|
||||||
routerV4 = if pppoe then "198.51.100.100" else "203.0.113.100";
|
routerV4 = if pppoe then "198.51.100.100" else "203.0.113.100";
|
||||||
pppoeVlan = 7;
|
pppoeVlan = 7;
|
||||||
|
clientV4 = "192.168.10.100";
|
||||||
|
clientV6 = "2001:db8:2::1234";
|
||||||
|
peerV4 = "192.168.10.51";
|
||||||
|
|
||||||
inherit (pkgs) lib;
|
inherit (pkgs) lib;
|
||||||
# Indent every line but the first (the interpolation site supplies that one).
|
# Indent every line but the first (the interpolation site supplies that one).
|
||||||
@@ -270,6 +273,51 @@ pkgs.testers.runNixOSTest {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
dns.forwarders = [ ispV4 ];
|
dns.forwarders = [ ispV4 ];
|
||||||
|
firewall.rules =
|
||||||
|
let
|
||||||
|
fwd =
|
||||||
|
r:
|
||||||
|
{
|
||||||
|
from = "wan";
|
||||||
|
to = "main";
|
||||||
|
destination = clientV4;
|
||||||
|
protocol = "tcp";
|
||||||
|
dnat = true;
|
||||||
|
}
|
||||||
|
// 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
|
# Unbound answers `.test` locally (RFC 6761) and would validate DNSSEC
|
||||||
@@ -281,17 +329,72 @@ pkgs.testers.runNixOSTest {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
client = { lib, ... }: {
|
client =
|
||||||
virtualisation.vlans = [ 2 ];
|
{ lib, ... }:
|
||||||
networking.useNetworkd = true;
|
{
|
||||||
networking.useDHCP = false;
|
virtualisation.vlans = [ 2 ];
|
||||||
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ];
|
networking.useNetworkd = true;
|
||||||
systemd.network.networks."10-lan" = {
|
networking.useDHCP = false;
|
||||||
matchConfig.Name = "eth1";
|
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ];
|
||||||
networkConfig.DHCP = "ipv4";
|
systemd.network.networks."10-lan" = {
|
||||||
|
matchConfig.Name = "eth1";
|
||||||
|
networkConfig.DHCP = "ipv4";
|
||||||
|
# Fixed address inside the delegated /64: the IPv6 pinhole target.
|
||||||
|
address = [ "${clientV6}/64" ];
|
||||||
|
};
|
||||||
|
networking.firewall.enable = false;
|
||||||
|
|
||||||
|
# Every listener echoes the peer address it sees and its own port.
|
||||||
|
services.nginx = {
|
||||||
|
enable = true;
|
||||||
|
virtualHosts.ports = {
|
||||||
|
listen =
|
||||||
|
map
|
||||||
|
(port: {
|
||||||
|
addr = "0.0.0.0";
|
||||||
|
inherit port;
|
||||||
|
})
|
||||||
|
[
|
||||||
|
8080
|
||||||
|
8084
|
||||||
|
9000
|
||||||
|
9001
|
||||||
|
9002
|
||||||
|
8085
|
||||||
|
8086
|
||||||
|
8087
|
||||||
|
]
|
||||||
|
++
|
||||||
|
map
|
||||||
|
(port: {
|
||||||
|
addr = "[::]";
|
||||||
|
inherit port;
|
||||||
|
})
|
||||||
|
[
|
||||||
|
8081
|
||||||
|
8082
|
||||||
|
8080
|
||||||
|
];
|
||||||
|
locations."/".return = ''200 "$remote_addr $server_port"'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# A second LAN host, outside the DHCP pool.
|
||||||
|
peer =
|
||||||
|
{ lib, ... }:
|
||||||
|
{
|
||||||
|
virtualisation.vlans = [ 2 ];
|
||||||
|
networking.useNetworkd = true;
|
||||||
|
networking.useDHCP = false;
|
||||||
|
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ];
|
||||||
|
systemd.network.networks."10-lan" = {
|
||||||
|
matchConfig.Name = "eth1";
|
||||||
|
address = [ "${peerV4}/24" ];
|
||||||
|
gateway = [ "192.168.10.1" ];
|
||||||
|
};
|
||||||
|
networking.firewall.enable = false;
|
||||||
};
|
};
|
||||||
networking.firewall.enable = false;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
testScript = ''
|
testScript = ''
|
||||||
@@ -340,7 +443,7 @@ pkgs.testers.runNixOSTest {
|
|||||||
with subtest("client autoconfigures a global address from the delegated prefix"):
|
with subtest("client autoconfigures a global address from the delegated prefix"):
|
||||||
client.wait_until_succeeds("ip -6 addr show dev eth1 scope global | grep -q 2001:db8:2:")
|
client.wait_until_succeeds("ip -6 addr show dev eth1 scope global | grep -q 2001:db8:2:")
|
||||||
client.wait_until_succeeds("ip -6 route show default | grep -q 'dev eth1'")
|
client.wait_until_succeeds("ip -6 route show default | grep -q 'dev eth1'")
|
||||||
client_v6 = client.succeed("ip -6 -o addr show dev eth1 scope global | awk '{print $4}' | cut -d/ -f1 | head -1").strip()
|
client_v6 = client.succeed("ip -6 -o addr show dev eth1 scope global | awk '{print $4}' | cut -d/ -f1 | grep -v ::1234 | head -1").strip()
|
||||||
|
|
||||||
with subtest("IPv6 is routed without NAT"):
|
with subtest("IPv6 is routed without NAT"):
|
||||||
client.wait_until_succeeds("ping -6 -c 1 -W 2 2001:db8:1::1")
|
client.wait_until_succeeds("ping -6 -c 1 -W 2 2001:db8:1::1")
|
||||||
@@ -358,5 +461,56 @@ pkgs.testers.runNixOSTest {
|
|||||||
with subtest("LAN can reach SSH and ping on the router"):
|
with subtest("LAN can reach SSH and ping on the router"):
|
||||||
client.succeed("nc -z -w 2 192.168.10.1 22")
|
client.succeed("nc -z -w 2 192.168.10.1 22")
|
||||||
client.succeed("ping -c 1 -W 2 192.168.10.1")
|
client.succeed("ping -c 1 -W 2 192.168.10.1")
|
||||||
|
|
||||||
|
with subtest("port forward keeps the source address and honours toPort"):
|
||||||
|
for _ in range(3):
|
||||||
|
# Positive control: the listener answers on the LAN.
|
||||||
|
peer.wait_until_succeeds("curl -sf --max-time 5 http://${clientV4}:8080/")
|
||||||
|
out = upstream.succeed("curl -sf --max-time 5 http://${routerV4}:8080/")
|
||||||
|
assert out.strip() == "${ispV4} 8080", out
|
||||||
|
out = upstream.succeed("curl -sf --max-time 5 http://${routerV4}:8083/")
|
||||||
|
assert out.strip() == "${ispV4} 8084", out
|
||||||
|
# The translated port is not exposed directly.
|
||||||
|
upstream.fail("curl -sf --max-time 3 http://${routerV4}:8084/")
|
||||||
|
|
||||||
|
with subtest("port ranges open exactly their ports"):
|
||||||
|
for port in (9000, 9001, 9002):
|
||||||
|
out = upstream.succeed(f"curl -sf --max-time 5 http://${routerV4}:{port}/")
|
||||||
|
assert out.strip() == f"${ispV4} {port}", out
|
||||||
|
upstream.fail("curl -sf --max-time 3 http://${routerV4}:9003/")
|
||||||
|
|
||||||
|
with subtest("source allow-list admits matching clients only"):
|
||||||
|
out = upstream.succeed("curl -sf --max-time 5 http://${routerV4}:8085/")
|
||||||
|
assert out.strip() == "${ispV4} 8085", out
|
||||||
|
upstream.fail("curl -sf --max-time 3 http://${routerV4}:8086/")
|
||||||
|
|
||||||
|
with subtest("per-source rate limit drops excess new connections"):
|
||||||
|
ok = 0
|
||||||
|
for _ in range(12):
|
||||||
|
status, _out = upstream.execute("curl -sf --max-time 2 http://${routerV4}:8087/")
|
||||||
|
ok += status == 0
|
||||||
|
assert 0 < ok < 12, f"expected some but not all connections to pass, got {ok}/12"
|
||||||
|
|
||||||
|
with subtest("IPv6 reaches only the explicit pinholes"):
|
||||||
|
out = upstream.succeed("curl -sf -g --max-time 5 'http://[${clientV6}]:8081/'")
|
||||||
|
assert out.strip() == "2001:db8:1::1 8081", out
|
||||||
|
# The listener exists, but there is no pinhole for it.
|
||||||
|
client.succeed("nc -6 -z -w 2 ${clientV6} 8082")
|
||||||
|
upstream.fail("curl -sf -g --max-time 3 'http://[${clientV6}]:8082/'")
|
||||||
|
# Port 8080 is forwarded for IPv4 only.
|
||||||
|
client.succeed("nc -6 -z -w 2 ${clientV6} 8080")
|
||||||
|
upstream.fail("curl -sf -g --max-time 3 'http://[${clientV6}]:8080/'")
|
||||||
|
|
||||||
|
with subtest("hairpin NAT works for forwards that enable it"):
|
||||||
|
out = peer.succeed("curl -sf --max-time 5 http://${routerV4}:8080/")
|
||||||
|
# Masqueraded: the server sees the router, not the peer.
|
||||||
|
assert out.strip() == "192.168.10.1 8080", out
|
||||||
|
# No hairpin on the range forward: the router does not translate it.
|
||||||
|
peer.fail("curl -sf --max-time 3 http://${routerV4}:9001/")
|
||||||
|
|
||||||
|
with subtest("logging rules leave a trace in the kernel log"):
|
||||||
|
klog = router.succeed("journalctl -k --no-pager | grep 'router fwd#'")
|
||||||
|
assert "accepted: " in klog, klog
|
||||||
|
assert "rate-limited: " in klog, klog
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user