diff --git a/PLAN.md b/PLAN.md index 2a59da7..fd355b5 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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..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. = { interface = ; vlan = ; }` makes `.` 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. diff --git a/modules/router/assertions.nix b/modules/router/assertions.nix index cd8d15d..b38f2f8 100644 --- a/modules/router/assertions.nix +++ b/modules/router/assertions.nix @@ -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 diff --git a/modules/router/dhcp.nix b/modules/router/dhcp.nix index 93771c5..29ea5ad 100644 --- a/modules/router/dhcp.nix +++ b/modules/router/dhcp.nix @@ -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 = [ { diff --git a/modules/router/firewall.nix b/modules/router/firewall.nix index 7019c35..4c237cd 100644 --- a/modules/router/firewall.nix +++ b/modules/router/firewall.nix @@ -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)} } ''; }; diff --git a/modules/router/lib.nix b/modules/router/lib.nix index 22e20ad..a5d79fd 100644 --- a/modules/router/lib.nix +++ b/modules/router/lib.nix @@ -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)) [ diff --git a/modules/router/networking.nix b/modules/router/networking.nix index 9ea2220..465b769 100644 --- a/modules/router/networking.nix +++ b/modules/router/networking.nix @@ -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,15 +28,26 @@ in "net.ipv6.conf.all.forwarding" = 1; }; - systemd.network.netdevs = lib.optionalAttrs (pppoe && vlan != null) { - "05-wan-pppoe" = { - netdevConfig = { - Kind = "vlan"; - Name = vlanDevice; + systemd.network.netdevs = + lib.optionalAttrs (pppoe && vlan != null) { + "05-wan-pppoe" = { + netdevConfig = { + Kind = "vlan"; + Name = vlanDevice; + }; + vlanConfig.Id = vlan; }; - 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 + ); }; } diff --git a/modules/router/options.nix b/modules/router/options.nix index a6adc14..0f2d481 100644 --- a/modules/router/options.nix +++ b/modules/router/options.nix @@ -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 = [ ]; diff --git a/tests/assertions.nix b/tests/assertions.nix index 772a530..a05da13 100644 --- a/tests/assertions.nix +++ b/tests/assertions.nix @@ -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 diff --git a/tests/basic.nix b/tests/basic.nix index 9e9b248..5a43fb3 100644 --- a/tests/basic.nix +++ b/tests/basic.nix @@ -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"; }; diff --git a/tests/common.nix b/tests/common.nix new file mode 100644 index 0000000..177a9cc --- /dev/null +++ b/tests/common.nix @@ -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" + ]; +} diff --git a/tests/default.nix b/tests/default.nix index 77b7367..9fdb43c 100644 --- a/tests/default.nix +++ b/tests/default.nix @@ -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; }; } diff --git a/tests/lib.nix b/tests/lib.nix index e8d5486..7b83a5f 100644 --- a/tests/lib.nix +++ b/tests/lib.nix @@ -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; }; }; diff --git a/tests/vlans.nix b/tests/vlans.nix new file mode 100644 index 0000000..84c8f12 --- /dev/null +++ b/tests/vlans.nix @@ -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") + ''; +}