Add VLANs, firewall zones and inter-zone rules

LANs can sit on an 802.1Q VLAN (`vlan` on a trunk `interface`); networkd
creates the device and brings the trunk up. LANs belong to a named `zone`
(default: their own name). Traffic inside a zone is forwarded freely, traffic
between zones is denied unless a `router.firewall.rules` entry allows it
(protocol, ports, optional destination inside the target zone).

Internet access is now opt-in per LAN (`internet`, default false), replacing
the previous allow-out default. Assertions reject unknown zones, bad
destinations, duplicate devices, over-long device names and a WAN trunk.

Kea subnet IDs now come from the network address so adding LANs does not
renumber them and orphan persistent leases.

Tests: a five-VM `vlans` check (untagged + two VLANs, shared zone, one-way
rules, per-host destination restriction), new assertion cases, parseNet unit
tests, and shared test tooling in tests/common.nix.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 17:54:52 +01:00
co-authored by Claude Sonnet 5.5
parent 6038367252
commit 6015ee2e6d
13 changed files with 931 additions and 86 deletions
+261 -20
View File
@@ -25,16 +25,37 @@ let
];
}).config;
lan = overrides: lib.recursiveUpdate {
interface = "eth1";
ipv4.address = "192.168.10.1";
dhcp.pool = { start = "192.168.10.100"; end = "192.168.10.199"; };
} overrides;
lan =
overrides:
lib.recursiveUpdate {
interface = "eth1";
ipv4.address = "192.168.10.1";
dhcp.pool = {
start = "192.168.10.100";
end = "192.168.10.199";
};
} overrides;
lan2 =
overrides:
lan (
lib.recursiveUpdate {
interface = "eth2";
ipv4.address = "192.168.20.1";
dhcp.pool = {
start = "192.168.20.100";
end = "192.168.20.199";
};
} overrides
);
failed = router: map (a: a.message) (lib.filter (a: !a.assertion) (evalRouter router).assertions);
cases = {
valid = { router.lans.main = lan { }; expect = [ ]; };
valid = {
router.lans.main = lan { };
expect = [ ];
};
noManagement = {
router.lans.main = lan { allowManagement = false; };
expect = [ "locked out" ];
@@ -46,30 +67,57 @@ let
sharedInterface = {
router.lans = {
a = lan { };
b = lan { ipv4.address = "192.168.20.1"; dhcp.pool = { start = "192.168.20.100"; end = "192.168.20.199"; }; };
b = lan {
ipv4.address = "192.168.20.1";
dhcp.pool = {
start = "192.168.20.100";
end = "192.168.20.199";
};
};
};
expect = [ "two LANs share the same interface" ];
expect = [ "two LANs share the same device" ];
};
badAddress = {
router.lans.main = lan { ipv4.address = "192.168.10.300"; dhcp.enable = false; };
router.lans.main = lan {
ipv4.address = "192.168.10.300";
dhcp.enable = false;
};
expect = [ "`192.168.10.300` is not a valid IPv4 address" ];
};
poolOutsideSubnet = {
router.lans.main = lan { dhcp.pool = { start = "192.168.11.100"; end = "192.168.11.199"; }; };
router.lans.main = lan {
dhcp.pool = {
start = "192.168.11.100";
end = "192.168.11.199";
};
};
expect = [ "pool is not inside 192.168.10.0/24" ];
};
poolReversed = {
router.lans.main = lan { dhcp.pool = { start = "192.168.10.199"; end = "192.168.10.100"; }; };
router.lans.main = lan {
dhcp.pool = {
start = "192.168.10.199";
end = "192.168.10.100";
};
};
expect = [ "starts after it ends" ];
};
poolContainsRouter = {
router.lans.main = lan { dhcp.pool = { start = "192.168.10.1"; end = "192.168.10.50"; }; };
router.lans.main = lan {
dhcp.pool = {
start = "192.168.10.1";
end = "192.168.10.50";
};
};
expect = [ "contains the router's own address 192.168.10.1" ];
};
overlappingLans = {
router.lans = {
a = lan { };
b = lan { interface = "eth2"; ipv4.address = "192.168.10.2"; };
b = lan {
interface = "eth2";
ipv4.address = "192.168.10.2";
};
};
expect = [ "`a` (192.168.10.0/24) and `b` (192.168.10.0/24) overlap" ];
};
@@ -88,7 +136,10 @@ let
b = lan {
interface = "eth2";
ipv4.address = "192.168.20.1";
dhcp.pool = { start = "192.168.20.100"; end = "192.168.20.199"; };
dhcp.pool = {
start = "192.168.20.100";
end = "192.168.20.199";
};
ipv6.pd = true;
};
};
@@ -126,10 +177,186 @@ let
};
expect = [ ];
};
vlansOnOneTrunk = {
router.lans = {
main = lan { };
iot = lan2 {
interface = "eth1";
vlan = 20;
zone = "iot";
};
cams = lan {
interface = "eth1";
vlan = 30;
zone = "iot";
ipv4.address = "192.168.30.1";
dhcp.pool = {
start = "192.168.30.100";
end = "192.168.30.199";
};
};
};
expect = [ ];
};
sameVlanTwice = {
router.lans = {
a = lan { vlan = 20; };
b = lan2 {
interface = "eth1";
vlan = 20;
};
};
expect = [ "two LANs share the same device" ];
};
vlanOnWanParent = {
router.lans.main = lan {
interface = "eth0";
vlan = 10;
};
expect = [ "WAN interface `eth0` is also used by a LAN" ];
};
deviceNameTooLong = {
router.lans.main = lan {
interface = "enp1s0f1np1";
vlan = 4000;
};
expect = [ "longer than 15 characters" ];
};
ruleValid = {
router = {
lans = {
main = lan { };
iot = lan2 { zone = "iot"; };
};
firewall.rules = [
{
from = "iot";
to = "main";
protocol = "tcp";
ports = [
445
8080
];
destination = "192.168.10.5";
}
{
from = "main";
to = "iot";
}
];
};
expect = [ ];
};
ruleUnknownZones = {
router = {
lans.main = lan { };
firewall.rules = [
{
from = "nope";
to = "main";
}
{
from = "main";
to = "ghost";
}
];
};
expect = [
"unknown source zone `nope`"
"unknown destination zone `ghost`"
];
};
ruleSameZone = {
router = {
lans = {
main = lan { zone = "t"; };
other = lan2 { zone = "t"; };
};
firewall.rules = [
{
from = "t";
to = "t";
}
];
};
expect = [ "source and destination are the same zone" ];
};
rulePortsWithoutProtocol = {
router = {
lans = {
main = lan { };
iot = lan2 { };
};
firewall.rules = [
{
from = "iot";
to = "main";
protocol = "icmp";
ports = [ 22 ];
}
];
};
expect = [ "ports are only valid with protocol tcp or udp" ];
};
ruleDestinationInvalid = {
router = {
lans = {
main = lan { };
iot = lan2 { };
};
firewall.rules = [
{
from = "iot";
to = "main";
destination = "192.168.10.999";
}
];
};
expect = [ "not a valid IPv4 address or CIDR" ];
};
ruleDestinationOutsideZone = {
router = {
lans = {
main = lan { };
iot = lan2 { };
};
firewall.rules = [
{
from = "iot";
to = "main";
destination = "192.168.20.5";
}
];
};
expect = [ "is not inside any LAN of zone `main`" ];
};
ruleDestinationHostBits = {
router = {
lans = {
main = lan { };
iot = lan2 { };
};
firewall.rules = [
{
from = "iot";
to = "main";
destination = "192.168.10.5/24";
}
];
};
expect = [ "has host bits set" ];
};
nonOverlappingLans = {
router.lans = {
a = lan { };
b = lan { interface = "eth2"; ipv4.address = "192.168.20.1"; dhcp.pool = { start = "192.168.20.100"; end = "192.168.20.199"; }; };
b = lan {
interface = "eth2";
ipv4.address = "192.168.20.1";
dhcp.pool = {
start = "192.168.20.100";
end = "192.168.20.199";
};
};
};
expect = [ ];
};
@@ -142,8 +369,9 @@ let
got = failed c.router;
missing = lib.filter (e: !lib.any (m: lib.hasInfix e m) got) c.expect;
in
lib.optional (missing != [ ] || lib.length got != lib.length c.expect)
"${name}: expected ${builtins.toJSON c.expect}, got ${builtins.toJSON got}"
lib.optional (
missing != [ ] || lib.length got != lib.length c.expect
) "${name}: expected ${builtins.toJSON c.expect}, got ${builtins.toJSON got}"
) cases
);
@@ -152,17 +380,30 @@ let
fullEval =
name:
let
r = builtins.tryEval (builtins.seq (evalRouter cases.${name}.router).system.build.toplevel.drvPath true);
r = builtins.tryEval (
builtins.seq (evalRouter cases.${name}.router).system.build.toplevel.drvPath true
);
in
lib.optional (!r.success) "${name}: full system evaluation failed";
fullEvalProblems = lib.concatMap fullEval [
"valid"
"pdOnOneLan"
"pppoeValid"
"vlansOnOneTrunk"
"ruleValid"
];
subnet = (evalRouter { lans.main = lan { ipv4.prefixLength = 20; ipv4.address = "172.16.37.1"; dhcp.enable = false; }; }).router.lans.main.ipv4.subnet;
subnetProblem = lib.optional (subnet != "172.16.32.0/20") "derived subnet: expected 172.16.32.0/20, got ${subnet}";
subnet =
(evalRouter {
lans.main = lan {
ipv4.prefixLength = 20;
ipv4.address = "172.16.37.1";
dhcp.enable = false;
};
}).router.lans.main.ipv4.subnet;
subnetProblem = lib.optional (
subnet != "172.16.32.0/20"
) "derived subnet: expected 172.16.32.0/20, got ${subnet}";
in
pkgs.runCommand "router-assertion-tests" { } (
if problems ++ subnetProblem ++ fullEvalProblems == [ ] then
+4 -17
View File
@@ -58,28 +58,14 @@ let
assert mss.strip() == "mss 1452", f"expected the SYN clamped to mss 1452, got {mss!r}"
'';
# `fail` in the test script also passes on "command not found", so make sure
# the tools the script relies on are really there.
tools = { pkgs, ... }: {
environment.systemPackages = with pkgs; [
dnsutils
netcat-openbsd
curl
tcpdump
];
};
common = import ./common.nix;
in
pkgs.testers.runNixOSTest {
name = "router-${if pppoe then "pppoe" else "basic"}";
defaults.imports = [ tools ];
defaults.imports = [ common.tools ];
# `driverInteractive` uses the full QEMU, which would open a window per VM;
# `nix run .#lab` wants the consoles in the terminal instead.
interactive.defaults.virtualisation.qemu.options = [
"-display"
"none"
];
interactive.defaults = common.interactiveDefaults;
nodes = {
# Plays the ISP: hands out one WAN lease (v4 and v6) plus a delegated /64,
@@ -273,6 +259,7 @@ pkgs.testers.runNixOSTest {
};
lans.main = {
interface = "eth2";
internet = true;
ipv4 = {
address = "192.168.10.1";
};
+22
View File
@@ -0,0 +1,22 @@
# Pieces shared by the VM tests.
{
# `fail` in a test script also passes on "command not found", so make sure
# the tools the scripts rely on are really there.
tools =
{ pkgs, ... }:
{
environment.systemPackages = with pkgs; [
dnsutils
netcat-openbsd
curl
tcpdump
];
};
# `driverInteractive` uses the full QEMU, which would open a window per VM;
# `nix run .#lab` wants the consoles in the terminal instead.
interactiveDefaults.virtualisation.qemu.options = [
"-display"
"none"
];
}
+1
View File
@@ -5,6 +5,7 @@
inherit self pkgs;
wan4 = "pppoe";
};
vlans = import ./vlans.nix { inherit self pkgs; };
lib = import ./lib.nix { inherit pkgs; };
assertions = import ./assertions.nix { inherit self pkgs; };
}
+127 -25
View File
@@ -7,44 +7,146 @@ let
ip = ipv4.parse;
failures = lib.runTests {
testParse = { expr = ip "192.168.10.1"; expected = 3232238081; };
testParseZero = { expr = ip "0.0.0.0"; expected = 0; };
testParseMax = { expr = ip "255.255.255.255"; expected = 4294967295; };
testParseBadOctet = { expr = ip "192.168.10.256"; expected = null; };
testParseShort = { expr = ip "192.168.10"; expected = null; };
testParseJunk = { expr = ip "192.168.10.x"; expected = null; };
testFormatRoundTrip = { expr = ipv4.format (ip "10.20.30.40"); expected = "10.20.30.40"; };
testFormatZero = { expr = ipv4.format 0; expected = "0.0.0.0"; };
testNetMask24 = { expr = ipv4.format (ipv4.netMask 24); expected = "255.255.255.0"; };
testNetMask20 = { expr = ipv4.format (ipv4.netMask 20); expected = "255.255.240.0"; };
testNetMask8 = { expr = ipv4.format (ipv4.netMask 8); expected = "255.0.0.0"; };
testCidr24 = { expr = ipv4.cidr "192.168.10.77" 24; expected = "192.168.10.0/24"; };
testCidr20 = { expr = ipv4.cidr "172.16.37.1" 20; expected = "172.16.32.0/20"; };
testCidr30 = { expr = ipv4.cidr "10.0.0.6" 30; expected = "10.0.0.4/30"; };
testParse = {
expr = ip "192.168.10.1";
expected = 3232238081;
};
testParseZero = {
expr = ip "0.0.0.0";
expected = 0;
};
testParseMax = {
expr = ip "255.255.255.255";
expected = 4294967295;
};
testParseBadOctet = {
expr = ip "192.168.10.256";
expected = null;
};
testParseShort = {
expr = ip "192.168.10";
expected = null;
};
testParseJunk = {
expr = ip "192.168.10.x";
expected = null;
};
testParseNetHost = {
expr = ipv4.parseNet "10.0.0.5";
expected = {
address = ip "10.0.0.5";
prefixLength = 32;
};
};
testParseNetCidr = {
expr = ipv4.parseNet "192.168.10.0/24";
expected = {
address = ip "192.168.10.0";
prefixLength = 24;
};
};
testParseNetZeroPrefix = {
expr = ipv4.parseNet "0.0.0.0/0";
expected = {
address = 0;
prefixLength = 0;
};
};
testParseNetPrefixTooLong = {
expr = ipv4.parseNet "10.0.0.0/33";
expected = null;
};
testParseNetBadAddress = {
expr = ipv4.parseNet "10.0.0.256/24";
expected = null;
};
testParseNetJunk = {
expr = ipv4.parseNet "10.0.0.0/x";
expected = null;
};
testFormatRoundTrip = {
expr = ipv4.format (ip "10.20.30.40");
expected = "10.20.30.40";
};
testFormatZero = {
expr = ipv4.format 0;
expected = "0.0.0.0";
};
testNetMask24 = {
expr = ipv4.format (ipv4.netMask 24);
expected = "255.255.255.0";
};
testNetMask20 = {
expr = ipv4.format (ipv4.netMask 20);
expected = "255.255.240.0";
};
testNetMask8 = {
expr = ipv4.format (ipv4.netMask 8);
expected = "255.0.0.0";
};
testCidr24 = {
expr = ipv4.cidr "192.168.10.77" 24;
expected = "192.168.10.0/24";
};
testCidr20 = {
expr = ipv4.cidr "172.16.37.1" 20;
expected = "172.16.32.0/20";
};
testCidr30 = {
expr = ipv4.cidr "10.0.0.6" 30;
expected = "10.0.0.4/30";
};
testInSubnetYes = {
expr = ipv4.inSubnet { address = ip "192.168.10.1"; prefixLength = 24; } (ip "192.168.10.200");
expr = ipv4.inSubnet {
address = ip "192.168.10.1";
prefixLength = 24;
} (ip "192.168.10.200");
expected = true;
};
testInSubnetNo = {
expr = ipv4.inSubnet { address = ip "192.168.10.1"; prefixLength = 24; } (ip "192.168.11.1");
expr = ipv4.inSubnet {
address = ip "192.168.10.1";
prefixLength = 24;
} (ip "192.168.11.1");
expected = false;
};
testOverlapNested = {
expr = ipv4.overlaps
{ address = ip "10.0.0.1"; prefixLength = 8; }
{ address = ip "10.1.2.3"; prefixLength = 24; };
expr =
ipv4.overlaps
{
address = ip "10.0.0.1";
prefixLength = 8;
}
{
address = ip "10.1.2.3";
prefixLength = 24;
};
expected = true;
};
testOverlapSame = {
expr = ipv4.overlaps
{ address = ip "192.168.10.1"; prefixLength = 24; }
{ address = ip "192.168.10.9"; prefixLength = 24; };
expr =
ipv4.overlaps
{
address = ip "192.168.10.1";
prefixLength = 24;
}
{
address = ip "192.168.10.9";
prefixLength = 24;
};
expected = true;
};
testOverlapDisjoint = {
expr = ipv4.overlaps
{ address = ip "192.168.10.1"; prefixLength = 24; }
{ address = ip "192.168.11.1"; prefixLength = 24; };
expr =
ipv4.overlaps
{
address = ip "192.168.10.1";
prefixLength = 24;
}
{
address = ip "192.168.11.1";
prefixLength = 24;
};
expected = false;
};
};
+251
View File
@@ -0,0 +1,251 @@
# VLANs and zones: one trunk carrying an untagged LAN plus two VLANs, two of
# the three LANs sharing a zone.
#
# zone trusted: main (untagged, internet)
# zone iot: iot (VLAN 20) + cams (VLAN 30), no internet, no management
#
# rules: trusted -> iot (anything)
# iot -> trusted, tcp 8080, only to 192.168.10.100
#
# upstream (ISP) -- vlan 1 -- router -- vlan 2 (trunk) -- main / iot / cams
{ self, pkgs }:
let
inherit (pkgs) lib;
common = import ./common.nix;
# A host on the trunk, either untagged (id = null) or on a VLAN.
host =
id:
{ lib, ... }:
let
dev = if id == null then "eth1" else "eth1.${toString id}";
in
lib.mkMerge [
{
virtualisation.vlans = [ 2 ];
networking.useNetworkd = true;
networking.useDHCP = false;
networking.firewall.enable = false;
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ];
systemd.network.networks."20-lan" = {
matchConfig.Name = dev;
networkConfig.DHCP = "ipv4";
};
}
(lib.mkIf (id != null) {
systemd.network.netdevs."10-vlan" = {
netdevConfig = {
Kind = "vlan";
Name = dev;
};
vlanConfig.Id = id;
};
systemd.network.networks."10-trunk" = {
matchConfig.Name = "eth1";
networkConfig = {
VLAN = [ dev ];
LinkLocalAddressing = "no";
};
};
})
];
in
pkgs.testers.runNixOSTest {
name = "router-vlans";
defaults.imports = [ common.tools ];
interactive.defaults = common.interactiveDefaults;
nodes = {
# The ISP: one DHCP lease for the router, DNS, and a page echoing the
# address it sees (to prove NAT).
upstream =
{ lib, ... }:
{
virtualisation.vlans = [ 1 ];
networking.useNetworkd = true;
networking.useDHCP = false;
networking.firewall.enable = false;
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ];
systemd.network.networks."10-wan" = {
matchConfig.Name = "eth1";
address = [ "203.0.113.1/24" ];
};
services.dnsmasq = {
enable = true;
settings = {
interface = "eth1";
bind-interfaces = true;
dhcp-range = "203.0.113.100,203.0.113.100,255.255.255.0,1h";
dhcp-option = [ "option:router,203.0.113.1" ];
address = "/example.test/203.0.113.1";
};
};
services.nginx = {
enable = true;
virtualHosts."example.test".locations."/".return = ''200 "$remote_addr"'';
};
};
router =
{ lib, ... }:
{
imports = [ self.nixosModules.router ];
virtualisation.vlans = [
1
2
];
networking.interfaces.eth1.ipv4.addresses = lib.mkForce [ ];
networking.interfaces.eth2.ipv4.addresses = lib.mkForce [ ];
router = {
enable = true;
wan.interface = "eth1";
lans = {
main = {
interface = "eth2";
zone = "trusted";
internet = true;
ipv4.address = "192.168.10.1";
dhcp.pool = {
start = "192.168.10.100";
end = "192.168.10.199";
};
};
iot = {
interface = "eth2";
vlan = 20;
zone = "iot";
allowManagement = false;
ipv4.address = "192.168.20.1";
dhcp.pool = {
start = "192.168.20.100";
end = "192.168.20.199";
};
};
cams = {
interface = "eth2";
vlan = 30;
zone = "iot";
allowManagement = false;
ipv4.address = "192.168.30.1";
dhcp.pool = {
start = "192.168.30.100";
end = "192.168.30.199";
};
};
};
firewall.rules = [
{
from = "trusted";
to = "iot";
}
{
from = "iot";
to = "trusted";
protocol = "tcp";
ports = [ 8080 ];
destination = "192.168.10.100";
}
];
dns.forwarders = [ "203.0.113.1" ];
};
# See basic.nix: relax Unbound for the made-up test domain only.
services.unbound.settings.server = {
local-zone = [ "test. transparent" ];
domain-insecure = [ "example.test" ];
};
};
# Untagged host in the trusted zone; also owns a second address, so the
# destination restriction in the iot -> trusted rule can be told apart
# from a zone-wide opening.
main =
{ lib, ... }:
{
imports = [ (host null) ];
systemd.network.networks."20-lan".address = [ "192.168.10.150/24" ];
services.nginx = {
enable = true;
virtualHosts."ports".listen = [
{
addr = "0.0.0.0";
port = 8080;
}
{
addr = "0.0.0.0";
port = 9090;
}
];
virtualHosts."ports".locations."/".return = ''200 "ok"'';
};
};
iot = host 20;
cams = host 30;
};
testScript = ''
start_all()
upstream.wait_for_unit("dnsmasq.service")
upstream.wait_for_unit("nginx.service")
main.wait_for_unit("nginx.service")
with subtest("router is up with VLAN devices on the trunk"):
router.wait_for_unit("network-online.target")
router.wait_until_succeeds("ip -4 addr show dev eth1 | grep -q 203.0.113.100")
router.wait_for_unit("nftables.service")
router.wait_for_unit("kea-dhcp4-server.service")
router.wait_for_unit("unbound.service")
for vid in (20, 30):
out = router.succeed(f"ip -d link show eth2.{vid}")
assert f"vlan protocol 802.1Q id {vid}" in out, out
router.succeed("ip -4 addr show dev eth2 | grep -q 192.168.10.1")
router.succeed("ip -4 addr show dev eth2.20 | grep -q 192.168.20.1")
router.succeed("ip -4 addr show dev eth2.30 | grep -q 192.168.30.1")
with subtest("each host gets an address and gateway from its own VLAN"):
main.wait_until_succeeds("ip -4 addr show dev eth1 | grep -q 192.168.10.100")
iot.wait_until_succeeds("ip -4 addr show dev eth1.20 | grep -q 192.168.20.100")
cams.wait_until_succeeds("ip -4 addr show dev eth1.30 | grep -q 192.168.30.100")
main.succeed("ip route show default | grep -q 'via 192.168.10.1'")
iot.succeed("ip route show default | grep -q 'via 192.168.20.1'")
cams.succeed("ip route show default | grep -q 'via 192.168.30.1'")
with subtest("internet is opt-in per LAN"):
# Positive control: the ISP is reachable from the LAN that opted in.
out = main.wait_until_succeeds("curl -sf --max-time 5 http://203.0.113.1/ -H 'Host: example.test'")
assert out.strip() == "203.0.113.100", f"expected NAT to 203.0.113.100, got {out!r}"
# The VLAN hosts have a default route, so this is the firewall at work.
iot.fail("curl -sf --max-time 3 http://203.0.113.1/ -H 'Host: example.test'")
cams.fail("curl -sf --max-time 3 http://203.0.113.1/ -H 'Host: example.test'")
with subtest("router services per VLAN: DNS yes, SSH only where allowed"):
iot.succeed("dig +short +time=2 +tries=1 @192.168.20.1 example.test | grep -qx 203.0.113.1")
main.succeed("nc -z -w 2 192.168.10.1 22")
iot.fail("nc -z -w 2 192.168.20.1 22")
cams.fail("nc -z -w 2 192.168.30.1 22")
iot.fail("nc -z -w 2 192.168.10.1 22")
with subtest("hosts in one zone reach each other across VLANs"):
iot.succeed("ping -c 1 -W 2 192.168.30.100")
cams.succeed("ping -c 1 -W 2 192.168.20.100")
with subtest("trusted -> iot is allowed, but not the other way round"):
main.succeed("ping -c 1 -W 2 192.168.20.100")
main.succeed("ping -c 1 -W 2 192.168.30.100")
iot.fail("ping -c 1 -W 2 192.168.10.100")
with subtest("iot -> trusted is limited to tcp 8080 on one host"):
# Positive controls: the listeners work from inside the trusted zone.
main.succeed("nc -z -w 2 192.168.10.100 9090")
main.succeed("nc -z -w 2 192.168.10.150 8080")
for host in (iot, cams):
host.succeed("nc -z -w 2 192.168.10.100 8080")
host.fail("nc -z -w 2 192.168.10.100 9090")
host.fail("nc -z -w 2 192.168.10.150 8080")
'';
}