diff --git a/modules/clan/router/tests/vm/default.nix b/modules/clan/router/tests/vm/default.nix index 682921b..ce00aef 100644 --- a/modules/clan/router/tests/vm/default.nix +++ b/modules/clan/router/tests/vm/default.nix @@ -1,14 +1,17 @@ # 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`. +# an untagged access port carries mgmt to `admin`, and `oldlan` is the DHCP +# network the box is staged in before cutover. # # isp ---(vlan 1: PPPoE)--- wan [gw] trunk ---(vlan 2: tagged 20/40)--- client # access --(vlan 3: untagged mgmt)--- admin +# staging -(vlan 4: DHCP client)--- oldlan # # 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). +# answering on the VLAN with the blocklist active, NAT to the WAN, the +# firewall trust model (allowWan, mgmt-only SSH, no inter-VLAN forwarding), +# and the staging uplink as NATed fallback exit behind ppp0. { pkgs, lib, ... }: let # The vars mock answers every prompt with "mock-prompt-value-"; the @@ -20,6 +23,10 @@ let clientAddress = "10.9.20.50"; adminMac = "02:00:00:00:00:10"; adminAddress = "10.9.10.50"; + oldlanAddress = "192.168.88.1"; + # Only reachable through oldlan's router role, i.e. via gw's staging + # default route (metric 1024); ppp0's metric-0 default must win while up. + beyondStaging = "203.0.113.1"; in { name = "router"; @@ -36,6 +43,7 @@ in isp = { }; client = { }; admin = { }; + oldlan = { }; }; instances.router = { @@ -48,6 +56,7 @@ in wan.interface = "wan"; trunkPorts = [ "trunk" ]; accessPorts.access = "mgmt"; + stagingPort = "staging"; vlans = { mgmt = { id = 10; @@ -111,6 +120,10 @@ in vlan = 3; assignIP = false; }; + staging = { + vlan = 4; + assignIP = false; + }; }; # Something must listen on 22 for the mgmt-only SSH rule to be observable @@ -251,6 +264,32 @@ in }; environment.systemPackages = [ pkgs.netcat ]; }; + + # The LAN the box is staged in: a DHCP server handing gw its uplink + # lease, plus an address that is only reachable via that uplink's + # default route. No route back to 10.9.0.0/16: replies only reach the + # clients if gw masquerades them. + oldlan = { + virtualisation.interfaces.staging = { + vlan = 4; + assignIP = false; + }; + networking.useDHCP = false; + networking.useNetworkd = true; + systemd.network.networks."10-staging" = { + matchConfig.Name = "staging"; + address = [ + "${oldlanAddress}/24" + "${beyondStaging}/32" + ]; + networkConfig.DHCPServer = true; + dhcpServerConfig = { + PoolOffset = 100; + PoolSize = 50; + }; + }; + networking.firewall.allowedUDPPorts = [ 67 ]; + }; }; testScript = '' @@ -302,5 +341,21 @@ in 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'") + + with subtest("Staging uplink: NATed exit for allowWan VLANs, behind ppp0 while it is up"): + gw.wait_until_succeeds("ip -4 route show default dev staging | grep -q 'via ${oldlanAddress}'") + # pppd installs its default route despite the DHCP one (defaultroute-metric 0). + gw.succeed("ip route get ${beyondStaging} | grep -q 'dev ppp0'") + # On-link old-LAN hosts are reached through the staging port regardless. + client.succeed("ping -c1 -W2 -I lan0 ${oldlanAddress}") + client.fail("ping -c1 -W2 -I iot0 ${oldlanAddress}") + # ppp0 down: the staging route carries the WAN traffic, allowWan still holds. + gw.systemctl("stop pppd-wan.service") + gw.wait_until_succeeds("ip route get ${beyondStaging} | grep -q 'dev staging'") + client.succeed("ping -c1 -W2 -I lan0 ${beyondStaging}") + client.fail("ping -c1 -W2 -I iot0 ${beyondStaging}") + # ppp0 back: preferred again. + gw.systemctl("start pppd-wan.service") + gw.wait_until_succeeds("ip route get ${beyondStaging} | grep -q 'dev ppp0'") ''; }