# End-to-end VM test of the router service: a PPPoE access concentrator plays # the ISP on the WAN port, a trunk carries tagged lan/iot VLANs to `client`, # and an untagged access port carries mgmt to `admin`. # # isp ---(vlan 1: PPPoE)--- wan [gw] trunk ---(vlan 2: tagged 20/40)--- client # access --(vlan 3: untagged mgmt)--- admin # # What is proven: PPPoE dial-in with the vars-provided credentials, bridge # VLAN tagging/untagging, Kea leases and reservations per VLAN, Blocky # answering on the VLAN with the blocklist active, NAT to the WAN, and the # firewall trust model (allowWan, mgmt-only SSH, no inter-VLAN forwarding). { pkgs, lib, ... }: let # The vars mock answers every prompt with "mock-prompt-value-"; the # ISP side must accept exactly those. chapSecrets = ''"mock-prompt-value-username" * "mock-prompt-value-password" *''; ispAddress = "192.0.2.1"; clientMac = "02:00:00:00:00:20"; clientAddress = "10.9.20.50"; adminMac = "02:00:00:00:00:10"; adminAddress = "10.9.10.50"; in { name = "router"; clan = { directory = ./.; # Bridges, VLAN netdevs, PPPoE and nftables need a real kernel. test.useContainers = false; inventory = { # Every node is a clan machine (the test framework's defaults require # it); only gw gets the router role. machines = { gw = { }; isp = { }; client = { }; admin = { }; }; instances.router = { module.name = "router"; module.input = "self"; roles.default.machines.gw.settings = { site = "tst"; siteId = 9; mesh.subnet = "fd00:7e57:c1a1:c0de::/64"; wan.interface = "wan"; trunkPorts = [ "trunk" ]; accessPorts.access = "mgmt"; vlans = { mgmt = { id = 10; dhcp.reservations.admin = { hwAddress = adminMac; ipAddress = adminAddress; }; }; lan = { id = 20; dhcp.reservations.client = { hwAddress = clientMac; ipAddress = clientAddress; }; }; iot = { id = 40; allowWan = false; }; }; # Access point on the simulated radio wlan0 (mac80211_hwsim, address # 02:00:00:00:00:00): two SSIDs, one per VLAN. wifi = { enable = true; countryCode = "US"; networks = { home.vlan = "lan"; things = { vlan = "iot"; security = "wpa2"; hidden = true; isolateClients = true; }; }; radios.wlan0 = { channel = 6; macAddress = "02:00:00:00:00:00"; networks = [ "home" "things" ]; }; }; }; }; }; }; nodes = { gw = { virtualisation.interfaces = { wan = { vlan = 1; assignIP = false; }; trunk = { vlan = 2; assignIP = false; }; access = { vlan = 3; assignIP = false; }; }; # Something must listen on 22 for the mgmt-only SSH rule to be observable # (a refused and a dropped connection look the same to the client). services.openssh.enable = true; # The sandbox has no internet: serve the blocklist from a local file # instead of GitHub, and skip CrowdSec, whose hub sync needs the network # (it is not what this test exercises). services.blocky.settings.blocking.denylists.ads = lib.mkForce [ (toString (pkgs.writeText "ads.hosts" "0.0.0.0 ads.example.com\n")) ]; services.crowdsec.enable = lib.mkForce false; services.crowdsec-firewall-bouncer.enable = lib.mkForce false; # Two simulated radios: wlan0 is the AP (settings above), wlan1 plays a # wireless client. It lives in its own network namespace, like the # separate host it stands in for — otherwise its lease would add a # second 10.9.20.0/24 route to the router's own table. Its DHCP lease # must come from Kea on the SSID's VLAN: wlan1 -> air -> wlan0 -> br0 -> # vlan-lan. The mock passphrase is what the vars mock fed into the # wifi-home-passphrase generator. boot.kernelModules = [ "mac80211_hwsim" ]; systemd.services.wifi-station = { wantedBy = [ "multi-user.target" ]; # No BindsTo: the device unit vanishes once wlan1 moves into the netns. after = [ "sys-subsystem-net-devices-wlan1.device" ]; path = [ pkgs.iproute2 pkgs.iw ]; preStart = '' ip netns add sta iw phy phy1 set netns name sta ip netns exec sta ip link set lo up mkdir -p /run/wpa_supplicant/client # nixpkgs' wpa_cli keeps its sockets here ''; serviceConfig.ExecStart = "${pkgs.iproute2}/bin/ip netns exec sta ${pkgs.wpa_supplicant}/bin/wpa_supplicant -i wlan1 -c " + pkgs.writeText "sta.conf" '' ctrl_interface=/run/wpa_supplicant/control network={ ssid="home" key_mgmt=SAE sae_password="mock-prompt-value-passphrase" ieee80211w=2 scan_freq=2437 } ''; }; environment.systemPackages = [ pkgs.wpa_supplicant (pkgs.writeShellScriptBin "sta-dhcp" '' # One DHCP round on the station, applying the offered address. exec ${pkgs.iproute2}/bin/ip netns exec sta ${pkgs.busybox}/bin/udhcpc -i wlan1 -n -q -f \ -s ${pkgs.writeShellScript "udhcpc-apply" '' case "$1" in bound|renew) ${pkgs.iproute2}/bin/ip addr replace "$ip/$mask" dev "$interface" ;; esac ''} '') ]; }; isp = { virtualisation.interfaces.wan = { vlan = 1; assignIP = false; }; # PPPoE access concentrator: one session, peer gets 192.0.2.10. systemd.services.pppoe-server = { wantedBy = [ "multi-user.target" ]; after = [ "network.target" ]; serviceConfig.ExecStart = "${pkgs.rp-pppoe}/sbin/pppoe-server -F -O /etc/ppp/pppoe-server-options" + " -q ${pkgs.ppp}/sbin/pppd -I wan -L ${ispAddress} -R 192.0.2.10"; }; environment.etc = { "ppp/pppoe-server-options".text = '' plugin pppoe.so require-chap lcp-echo-interval 10 lcp-echo-failure 2 nobsdcomp noccp novj ''; "ppp/chap-secrets" = { text = chapSecrets; mode = "0640"; }; }; }; client = { virtualisation.interfaces.trunk = { vlan = 2; assignIP = false; }; networking.useDHCP = false; networking.vlans = { lan0 = { id = 20; interface = "trunk"; }; iot0 = { id = 40; interface = "trunk"; }; }; networking.interfaces.lan0 = { useDHCP = true; macAddress = clientMac; }; networking.interfaces.iot0.useDHCP = true; # Dual-homed on purpose (lan + iot). Both leases bring a default route: # prefer lan0 so replies to other VLANs and the WAN leave where the # router expects them (its rp-filter would drop them on vlan-iot), and # loosen this client's own rp-filter so a WAN reply on iot0 would be # seen — the negative allowWan check must fail at the router, not here. systemd.network.networks."40-iot0".dhcpV4Config.RouteMetric = 2048; networking.firewall.checkReversePath = "loose"; environment.systemPackages = [ pkgs.dnsutils pkgs.netcat ]; }; admin = { virtualisation.interfaces.access = { vlan = 3; assignIP = false; }; networking.useDHCP = false; networking.interfaces.access = { useDHCP = true; macAddress = adminMac; }; environment.systemPackages = [ pkgs.netcat ]; }; }; testScript = '' start_all() with subtest("PPPoE session comes up with the vars credentials"): gw.wait_for_unit("pppd-wan.service") gw.wait_until_succeeds("ping -c1 -W1 ${ispAddress}") with subtest("DHCP hands out reserved leases per VLAN"): gw.wait_for_unit("kea-dhcp4-server.service") client.wait_until_succeeds("ip -4 addr show lan0 | grep -q 'inet ${clientAddress}/24'") client.wait_until_succeeds("ip -4 addr show iot0 | grep -q 'inet 10.9.40.1[0-9][0-9]/24'") admin.wait_until_succeeds("ip -4 addr show access | grep -q 'inet ${adminAddress}/24'") with subtest("Blocky serves the VLAN and blocks the denylist"): gw.wait_for_unit("blocky.service") answer = client.wait_until_succeeds("dig +short +time=2 @10.9.20.1 ads.example.com") assert answer.strip() == "0.0.0.0", f"expected blocked answer, got: {answer!r}" with subtest("NAT to the WAN only for VLANs with allowWan"): client.wait_until_succeeds("ping -c1 -W1 -I lan0 ${ispAddress}") # iot has a route to the WAN; the router is what refuses to forward. client.succeed("ip route show dev iot0 | grep -q ^default") client.fail("ping -c1 -W2 -I iot0 ${ispAddress}") with subtest("mgmt reaches other VLANs, other VLANs do not"): admin.succeed("ping -c1 -W2 ${clientAddress}") client.fail("ping -c1 -W2 -I lan0 ${adminAddress}") with subtest("SSH on the router only from mgmt"): gw.wait_for_open_port(22) admin.succeed("nc -z -w2 10.9.10.1 22") client.fail("nc -z -w2 10.9.20.1 22") with subtest("Wireless SSIDs are bridge access ports of their VLAN"): gw.wait_for_unit("hostapd.service") # Second SSID: derived BSSID, hidden, bridged into the iot VLAN. gw.wait_until_succeeds("ip link show wlan0-1 | grep -q '06:00:00:00:00:00'") gw.wait_until_succeeds("bridge vlan show dev wlan0-1 | grep -q '40 PVID Egress Untagged'") gw.succeed("hostapd_cli -i wlan0-1 get_config | grep -q '^ssid=things'") # The second BSS section of the rendered config carries hidden + isolation. things = gw.succeed("sed -n '/^bss=wlan0-1/,$p' /run/hostapd/wlan0.hostapd.conf") assert "ignore_broadcast_ssid=1" in things and "ap_isolate=1" in things, things assert "sae_password=mock-prompt-value-passphrase" not in things, "wpa2 SSID must not carry SAE entries" # A WPA3 (SAE) station on the first SSID authenticates with the vars # passphrase and gets its lease from Kea on the lan VLAN, through the bridge. gw.wait_for_unit("wifi-station.service") gw.wait_until_succeeds("ip netns exec sta wpa_cli -i wlan1 status | grep -q wpa_state=COMPLETED") gw.succeed("timeout 60 sta-dhcp") gw.succeed("ip netns exec sta ip -4 addr show wlan1 | grep -q 'inet 10.9.20.1[0-9][0-9]/24'") ''; }