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:
@@ -62,6 +62,8 @@ examples/ # sample configs
|
||||
|
||||
## Status
|
||||
|
||||
**VLANs + zones done**: `vlans` VM test (5 VMs: ISP, router, untagged `main`, VLAN 20 `iot`, VLAN 30 `cams`) covers VLAN devices, per-VLAN DHCP/gateway, opt-in internet, DNS vs SSH per LAN, intra-zone forwarding across VLANs, one-directional inter-zone rules and the destination restriction (each mutation-tested). Not covered yet: IPv6 on VLAN LANs (only the PD LAN has IPv6), port forwards from the WAN.
|
||||
|
||||
**PPPoE done**: `pppoe` VM test = the same topology with a fake ISP running `pppoe-server` (VLAN 7, CHAP, password containing a quote) — covers session, NAT, MTU, MSS clamp (mutation-tested) and WAN-deny. `basic` remains the DHCPv4 variant.
|
||||
|
||||
**IPv6 + prefix delegation done (single /64)**: `router.wan.ipv6.enable` and `router.lans.<name>.ipv6.pd` (at most one LAN, asserted). The VM test now covers the router's DHCPv6 address and delegated /64, SLAAC on the client, routing without NAT66, and WAN-deny over IPv6 (mutation-tested). Still open: RDNSS, other LANs are IPv4-only, DHCPv6 reservations.
|
||||
@@ -75,7 +77,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.
|
||||
- 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.
|
||||
- Checks: `basic` and `pppoe` (VM tests), `lib` (helper unit tests), `assertions` (broken configs must fail with exact messages). Not yet covered: VLANs, WireGuard, formal zones.
|
||||
- Checks: `basic`, `pppoe` and `vlans` (VM tests), `lib` (helper unit tests), `assertions` (broken configs must fail with exact messages). Not yet covered: WireGuard, port forwards, WAN-originated rules.
|
||||
|
||||
## IPv6 design (confirmed)
|
||||
|
||||
@@ -93,6 +95,14 @@ Learned along the way:
|
||||
- pppd (`pppoe.so`) runs as `router-pppoe.service`, ordered after the VLAN device. Credentials: `username` inline, password from `pppoe.passwordFile` (type forbids Nix-store paths); the options file is generated into a root-only runtime dir. **Deployment must provide that file** on persistent storage (or via agenix/sops).
|
||||
- **MTU 1492 + TCP MSS clamp** (1452) in both directions on the PPP device; RFC 4638 not implemented.
|
||||
|
||||
## VLANs and zones (confirmed)
|
||||
|
||||
- **VLAN devices** are created by the router: `router.lans.<name> = { interface = <trunk>; vlan = <id>; }` makes `<trunk>.<id>` via networkd. A trunk may also carry one untagged LAN. Assertions: duplicate device, device name > 15 chars, WAN interface reused as trunk.
|
||||
- **Zones** are an attribute on the LAN (`zone`, default = the LAN's own name); LANs sharing a zone may forward to each other, traffic **between zones is denied** unless a rule in `router.firewall.rules` allows it (`from`, `to`, `protocol`, `ports`, optional IPv4 `destination` that must lie inside a LAN of the destination zone). Rules are one-directional; return traffic via conntrack. Assertions reject unknown zones, from == to, ports without tcp/udp, bad/out-of-zone/host-bit destinations.
|
||||
- **Internet is opt-in per LAN** (`internet`, default false) — this replaces the earlier "LAN allow-out" default; any LAN that should reach the WAN must set it.
|
||||
- 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).
|
||||
|
||||
## Open items
|
||||
|
||||
- **Web UI** (stats and possibly configuration) — to be discussed later. Considerations: read-only stats first is cheap; config editing must write declarative config (Nix/JSON) that feeds the module, not mutate the live system.
|
||||
|
||||
@@ -31,6 +31,11 @@ let
|
||||
assertion = addrOk;
|
||||
message = "router: ${where}.ipv4.address `${lan.ipv4.address}` is not a valid IPv4 address.";
|
||||
}
|
||||
{
|
||||
# IFNAMSIZ is 16 including the terminating NUL.
|
||||
assertion = lib.stringLength lan.device <= 15;
|
||||
message = "router: ${where}: device name `${lan.device}` is longer than 15 characters.";
|
||||
}
|
||||
]
|
||||
++ lib.optionals lan.dhcp.enable (
|
||||
[
|
||||
@@ -55,6 +60,58 @@ let
|
||||
]
|
||||
);
|
||||
|
||||
zoneNames = lib.unique (map (l: l.zone) lans);
|
||||
|
||||
ruleAssertions =
|
||||
r:
|
||||
let
|
||||
where = "router.firewall.rules (${r.from} -> ${r.to})";
|
||||
dest = if r.destination == null then null else ipv4.parseNet r.destination;
|
||||
destLans = lib.filter (l: l.zone == r.to && validIp l.ipv4.address) lans;
|
||||
inZone =
|
||||
dest != null
|
||||
&& lib.any (
|
||||
l:
|
||||
dest.prefixLength >= l.ipv4.prefixLength
|
||||
&& ipv4.inSubnet {
|
||||
address = ipv4.parse l.ipv4.address;
|
||||
inherit (l.ipv4) prefixLength;
|
||||
} dest.address
|
||||
) destLans;
|
||||
in
|
||||
[
|
||||
{
|
||||
assertion = lib.elem r.from zoneNames;
|
||||
message = "router: ${where}: unknown source zone `${r.from}` (zones: ${lib.concatStringsSep ", " zoneNames}).";
|
||||
}
|
||||
{
|
||||
assertion = lib.elem r.to zoneNames;
|
||||
message = "router: ${where}: unknown destination zone `${r.to}` (zones: ${lib.concatStringsSep ", " zoneNames}).";
|
||||
}
|
||||
{
|
||||
assertion = r.from != r.to;
|
||||
message = "router: ${where}: source and destination are the same zone; traffic within a zone is already allowed.";
|
||||
}
|
||||
{
|
||||
assertion = r.ports == [ ] || r.protocol == "tcp" || r.protocol == "udp";
|
||||
message = "router: ${where}: ports are only valid with protocol tcp or udp.";
|
||||
}
|
||||
]
|
||||
++ lib.optionals (r.destination != null) [
|
||||
{
|
||||
assertion = dest != null;
|
||||
message = "router: ${where}: destination `${r.destination}` is not a valid IPv4 address or CIDR.";
|
||||
}
|
||||
{
|
||||
assertion = dest == null || !(lib.elem r.to zoneNames) || inZone;
|
||||
message = "router: ${where}: destination `${r.destination}` is not inside any LAN of zone `${r.to}`.";
|
||||
}
|
||||
{
|
||||
assertion = dest == null || ipv4.networkOf dest.address dest.prefixLength == dest.address;
|
||||
message = "router: ${where}: destination `${r.destination}` has host bits set.";
|
||||
}
|
||||
];
|
||||
|
||||
overlapPairs = lib.concatLists (
|
||||
lib.imap0 (
|
||||
i: a:
|
||||
@@ -98,13 +155,14 @@ in
|
||||
{
|
||||
assertion =
|
||||
let
|
||||
ifaces = map (l: l.interface) lans;
|
||||
devices = map (l: l.device) lans;
|
||||
in
|
||||
lib.length ifaces == lib.length (lib.unique ifaces);
|
||||
message = "router: two LANs share the same interface.";
|
||||
lib.length devices == lib.length (lib.unique devices);
|
||||
message = "router: two LANs share the same device (same interface and VLAN).";
|
||||
}
|
||||
]
|
||||
++ lib.concatLists (lib.mapAttrsToList perLan lansByName)
|
||||
++ lib.concatMap ruleAssertions cfg.firewall.rules
|
||||
++ map (
|
||||
{ a, b }:
|
||||
let
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
let
|
||||
cfg = config.router;
|
||||
dhcpLans = lib.filter (l: l.dhcp.enable) (lib.attrValues cfg.lans);
|
||||
ipv4 = (import ./lib.nix { inherit lib; }).ipv4;
|
||||
in
|
||||
{
|
||||
config = lib.mkIf (cfg.enable && dhcpLans != [ ]) {
|
||||
@@ -9,7 +10,7 @@ in
|
||||
enable = true;
|
||||
settings = {
|
||||
interfaces-config = {
|
||||
interfaces = map (l: l.interface) dhcpLans;
|
||||
interfaces = map (l: l.device) dhcpLans;
|
||||
# Interfaces are configured asynchronously by networkd.
|
||||
re-detect = true;
|
||||
};
|
||||
@@ -19,10 +20,12 @@ in
|
||||
name = "/var/lib/kea/dhcp4.leases";
|
||||
};
|
||||
valid-lifetime = 3600;
|
||||
subnet4 = lib.imap1 (id: l: {
|
||||
inherit id;
|
||||
# The ID keys persistent leases, so derive it from the network address:
|
||||
# stable when LANs are added or removed (an index would shift).
|
||||
subnet4 = map (l: {
|
||||
id = ipv4.networkOf (ipv4.parse l.ipv4.address) l.ipv4.prefixLength;
|
||||
inherit (l.ipv4) subnet;
|
||||
interface = l.interface;
|
||||
interface = l.device;
|
||||
pools = [ { pool = "${l.dhcp.pool.start} - ${l.dhcp.pool.end}"; } ];
|
||||
option-data = [
|
||||
{
|
||||
|
||||
@@ -4,8 +4,38 @@ let
|
||||
lans = lib.attrValues cfg.lans;
|
||||
|
||||
set = ifaces: "{ ${lib.concatMapStringsSep ", " (i: ''"${i}"'') ifaces} }";
|
||||
lanIfaces = map (l: l.interface) lans;
|
||||
mgmtIfaces = map (l: l.interface) (lib.filter (l: l.allowManagement) lans);
|
||||
lanIfaces = map (l: l.device) lans;
|
||||
mgmtIfaces = map (l: l.device) (lib.filter (l: l.allowManagement) lans);
|
||||
internetIfaces = map (l: l.device) (lib.filter (l: l.internet) lans);
|
||||
|
||||
# zone name -> devices of its member LANs
|
||||
zones = lib.mapAttrs (_: ls: map (l: l.device) ls) (lib.groupBy (l: l.zone) lans);
|
||||
zoneDevices = zone: zones.${zone} or [ ];
|
||||
|
||||
# LANs sharing a zone may forward to each other (a single LAN is plain L2).
|
||||
intraZone = lib.concatLists (
|
||||
lib.mapAttrsToList (
|
||||
zone: devs:
|
||||
lib.optional (lib.length devs > 1) "iifname ${set devs} oifname ${set devs} accept # zone ${zone}"
|
||||
) zones
|
||||
);
|
||||
|
||||
ruleToNft =
|
||||
r:
|
||||
let
|
||||
dest = lib.optionalString (r.destination != null) "ip daddr ${r.destination} ";
|
||||
proto =
|
||||
if r.protocol == "any" then
|
||||
""
|
||||
else if r.protocol == "icmp" then
|
||||
"meta l4proto { icmp, ipv6-icmp } "
|
||||
else
|
||||
"${r.protocol} dport ${
|
||||
if r.ports == [ ] then "1-65535" else "{ ${lib.concatMapStringsSep ", " toString r.ports} }"
|
||||
} ";
|
||||
in
|
||||
"iifname ${set (zoneDevices r.from)} oifname ${set (zoneDevices r.to)} ${dest}${proto}accept # ${r.from} -> ${r.to}";
|
||||
|
||||
uplink4 = cfg.wan.ipv4.interface;
|
||||
pppoe = cfg.wan.ipv4.type == "pppoe";
|
||||
# IPv4 leaves via the PPP device, IPv6 via the physical uplink.
|
||||
@@ -14,7 +44,7 @@ let
|
||||
uplink4
|
||||
];
|
||||
mss = cfg.wan.ipv4.pppoe.mtu - 40;
|
||||
dhcpIfaces = map (l: l.interface) (lib.filter (l: l.dhcp.enable) lans);
|
||||
dhcpIfaces = map (l: l.device) (lib.filter (l: l.dhcp.enable) lans);
|
||||
in
|
||||
{
|
||||
config = lib.mkIf cfg.enable {
|
||||
@@ -64,7 +94,12 @@ in
|
||||
iifname "${uplink4}" tcp flags syn tcp option maxseg size > ${toString mss} tcp option maxseg size set ${toString mss}
|
||||
oifname "${uplink4}" tcp flags syn tcp option maxseg size > ${toString mss} tcp option maxseg size set ${toString mss}
|
||||
''}
|
||||
iifname ${set lanIfaces} oifname ${set uplinks} accept
|
||||
${lib.optionalString (
|
||||
internetIfaces != [ ]
|
||||
) "iifname ${set internetIfaces} oifname ${set uplinks} accept"}
|
||||
|
||||
# Zones: free inside a zone, denied between zones unless a rule says so.
|
||||
${lib.concatStringsSep "\n " (intraZone ++ map ruleToNft cfg.firewall.rules)}
|
||||
}
|
||||
'';
|
||||
};
|
||||
|
||||
@@ -18,6 +18,20 @@ rec {
|
||||
else
|
||||
lib.foldl' (acc: o: acc * 256 + o) 0 octets;
|
||||
|
||||
# "10.0.0.5" or "10.0.0.0/24" -> { address (int); prefixLength }, or null.
|
||||
parseNet =
|
||||
s:
|
||||
let
|
||||
m = builtins.match "([0-9.]+)(/([0-9]{1,2}))?" s;
|
||||
address = if m == null then null else parse (builtins.elemAt m 0);
|
||||
prefixLength =
|
||||
if m == null || builtins.elemAt m 2 == null then 32 else lib.toInt (builtins.elemAt m 2);
|
||||
in
|
||||
if m == null || address == null || prefixLength > 32 then
|
||||
null
|
||||
else
|
||||
{ inherit address prefixLength; };
|
||||
|
||||
format =
|
||||
n:
|
||||
lib.concatMapStringsSep "." (shift: toString (lib.mod (n / pow2 shift) 256)) [
|
||||
|
||||
@@ -5,6 +5,17 @@ let
|
||||
pppoe = cfg.wan.ipv4.type == "pppoe";
|
||||
vlan = cfg.wan.ipv4.pppoe.vlan;
|
||||
vlanDevice = "${cfg.wan.interface}.${toString vlan}";
|
||||
|
||||
lans = lib.attrValues cfg.lans;
|
||||
vlanLans = lib.filter (l: l.vlan != null) lans;
|
||||
# VLAN devices hang off their parent; the parent's own unit must list them.
|
||||
vlansOn = parent: map (l: l.device) (lib.filter (l: l.interface == parent) vlanLans);
|
||||
untaggedParents = map (l: l.interface) (lib.filter (l: l.vlan == null) lans);
|
||||
# Parents that carry only tagged LANs still need a unit to be brought up.
|
||||
trunkParents = lib.filter (p: !(lib.elem p untaggedParents)) (
|
||||
lib.unique (map (l: l.interface) vlanLans)
|
||||
);
|
||||
|
||||
dhcp = if pppoe then (if v6 then "ipv6" else "no") else (if v6 then "yes" else "ipv4");
|
||||
in
|
||||
{
|
||||
@@ -17,7 +28,8 @@ in
|
||||
"net.ipv6.conf.all.forwarding" = 1;
|
||||
};
|
||||
|
||||
systemd.network.netdevs = lib.optionalAttrs (pppoe && vlan != null) {
|
||||
systemd.network.netdevs =
|
||||
lib.optionalAttrs (pppoe && vlan != null) {
|
||||
"05-wan-pppoe" = {
|
||||
netdevConfig = {
|
||||
Kind = "vlan";
|
||||
@@ -25,7 +37,17 @@ in
|
||||
};
|
||||
vlanConfig.Id = vlan;
|
||||
};
|
||||
}
|
||||
// lib.mapAttrs' (
|
||||
name: lan:
|
||||
lib.nameValuePair "06-lan-${name}" {
|
||||
netdevConfig = {
|
||||
Kind = "vlan";
|
||||
Name = lan.device;
|
||||
};
|
||||
vlanConfig.Id = lan.vlan;
|
||||
}
|
||||
) (lib.filterAttrs (_: l: l.vlan != null) cfg.lans);
|
||||
|
||||
systemd.network.networks = {
|
||||
"10-wan" = {
|
||||
@@ -68,13 +90,16 @@ in
|
||||
name: lan:
|
||||
lib.nameValuePair "20-lan-${name}" (
|
||||
{
|
||||
matchConfig.Name = lan.interface;
|
||||
matchConfig.Name = lan.device;
|
||||
address = [ "${lan.ipv4.address}/${toString lan.ipv4.prefixLength}" ];
|
||||
networkConfig = {
|
||||
ConfigureWithoutCarrier = true;
|
||||
# Never accept RAs from the inside.
|
||||
IPv6AcceptRA = false;
|
||||
}
|
||||
// lib.optionalAttrs (lan.vlan == null && vlansOn lan.interface != [ ]) {
|
||||
VLAN = vlansOn lan.interface;
|
||||
}
|
||||
// lib.optionalAttrs lan.ipv6.pd {
|
||||
DHCPPrefixDelegation = true;
|
||||
IPv6SendRA = true;
|
||||
@@ -91,6 +116,20 @@ in
|
||||
ipv6SendRAConfig.EmitDNS = false;
|
||||
}
|
||||
)
|
||||
) cfg.lans;
|
||||
) cfg.lans
|
||||
// lib.listToAttrs (
|
||||
map (
|
||||
parent:
|
||||
lib.nameValuePair "19-trunk-${parent}" {
|
||||
matchConfig.Name = parent;
|
||||
networkConfig = {
|
||||
LinkLocalAddressing = "no";
|
||||
IPv6AcceptRA = false;
|
||||
ConfigureWithoutCarrier = true;
|
||||
VLAN = vlansOn parent;
|
||||
};
|
||||
}
|
||||
) trunkParents
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -78,13 +78,46 @@ in
|
||||
description = "LAN segments served by this router, keyed by name.";
|
||||
type = types.attrsOf (
|
||||
types.submodule (
|
||||
{ config, ... }:
|
||||
{ name, config, ... }:
|
||||
{
|
||||
options = {
|
||||
interface = mkOption {
|
||||
type = types.str;
|
||||
example = "eth1";
|
||||
description = "Interface (or bridge/VLAN device) carrying this LAN.";
|
||||
description = ''
|
||||
Interface carrying this LAN. With `vlan` set this is the
|
||||
parent (trunk) interface on which the VLAN device is created.
|
||||
'';
|
||||
};
|
||||
|
||||
vlan = mkOption {
|
||||
type = types.nullOr (types.ints.between 1 4094);
|
||||
default = null;
|
||||
description = "802.1Q VLAN ID, or null for an untagged LAN on `interface`.";
|
||||
};
|
||||
|
||||
device = mkOption {
|
||||
type = types.str;
|
||||
readOnly = true;
|
||||
internal = true;
|
||||
description = "Device the LAN's addresses live on (derived).";
|
||||
};
|
||||
|
||||
zone = mkOption {
|
||||
type = types.str;
|
||||
default = name;
|
||||
description = ''
|
||||
Firewall zone this LAN belongs to. LANs sharing a zone may
|
||||
talk to each other freely; traffic between zones is denied
|
||||
unless a rule in `router.firewall.rules` allows it. Defaults
|
||||
to a zone named after the LAN.
|
||||
'';
|
||||
};
|
||||
|
||||
internet = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = "Whether this LAN may reach the internet (opt-in).";
|
||||
};
|
||||
|
||||
ipv4 = {
|
||||
@@ -139,6 +172,9 @@ in
|
||||
};
|
||||
};
|
||||
|
||||
config.device =
|
||||
if config.vlan == null then config.interface else "${config.interface}.${toString config.vlan}";
|
||||
|
||||
# "invalid" is only ever seen when assertions.nix is already failing.
|
||||
config.ipv4.subnet =
|
||||
if rlib.ipv4.parse config.ipv4.address == null then
|
||||
@@ -150,6 +186,52 @@ in
|
||||
);
|
||||
};
|
||||
|
||||
firewall.rules = mkOption {
|
||||
default = [ ];
|
||||
description = ''
|
||||
Allow rules between zones, on top of the default (deny between zones).
|
||||
Rules are one-directional; return traffic is allowed by connection
|
||||
tracking. Both ends must be zones of LANs defined in `router.lans`.
|
||||
'';
|
||||
type = types.listOf (
|
||||
types.submodule {
|
||||
options = {
|
||||
from = mkOption {
|
||||
type = types.str;
|
||||
description = "Source zone.";
|
||||
};
|
||||
to = mkOption {
|
||||
type = types.str;
|
||||
description = "Destination zone.";
|
||||
};
|
||||
protocol = mkOption {
|
||||
type = types.enum [
|
||||
"any"
|
||||
"tcp"
|
||||
"udp"
|
||||
"icmp"
|
||||
];
|
||||
default = "any";
|
||||
};
|
||||
ports = mkOption {
|
||||
type = types.listOf types.port;
|
||||
default = [ ];
|
||||
description = "Destination ports (tcp/udp only); empty means all ports.";
|
||||
};
|
||||
destination = mkOption {
|
||||
type = types.nullOr types.str;
|
||||
default = null;
|
||||
example = "192.168.10.5";
|
||||
description = ''
|
||||
Restrict the rule to an IPv4 address or CIDR inside the
|
||||
destination zone; null means the whole zone.
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
dns.forwarders = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [ ];
|
||||
|
||||
+258
-17
@@ -25,16 +25,37 @@ let
|
||||
];
|
||||
}).config;
|
||||
|
||||
lan = overrides: lib.recursiveUpdate {
|
||||
lan =
|
||||
overrides:
|
||||
lib.recursiveUpdate {
|
||||
interface = "eth1";
|
||||
ipv4.address = "192.168.10.1";
|
||||
dhcp.pool = { start = "192.168.10.100"; end = "192.168.10.199"; };
|
||||
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
@@ -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";
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
];
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
'';
|
||||
}
|
||||
Reference in New Issue
Block a user