router/tests: cover the staging uplink as NATed fallback exit

Neither regression fixed by the previous commit was observable: the VM
test had no staging port, and pinging the ISP's PPPoE address only needs
ppp0's connected route, not the default route pppd refused to install.

Add a fifth node, `oldlan`: a networkd DHCP server on vlan 4 handing gw
its staging lease, with a second address (203.0.113.1) that gw can only
reach through that lease's default route. It has no route back to the
VLANs, so client pings only work if gw masquerades.

Proven: pppd's metric-0 default wins over the DHCP one while ppp0 is up;
lan reaches the old LAN via the staging port and iot (allowWan = false)
does not; after stopping pppd the staging route carries WAN traffic with
the same allowWan split; restarting pppd makes ppp0 preferred again.
This commit is contained in:
2026-09-21 04:32:14 +00:00
parent 8090ab3e6d
commit e1e18dd9f3
+58 -3
View File
@@ -1,14 +1,17 @@
# End-to-end VM test of the router service: a PPPoE access concentrator plays # 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`, # 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 # isp ---(vlan 1: PPPoE)--- wan [gw] trunk ---(vlan 2: tagged 20/40)--- client
# access --(vlan 3: untagged mgmt)--- admin # access --(vlan 3: untagged mgmt)--- admin
# staging -(vlan 4: DHCP client)--- oldlan
# #
# What is proven: PPPoE dial-in with the vars-provided credentials, bridge # What is proven: PPPoE dial-in with the vars-provided credentials, bridge
# VLAN tagging/untagging, Kea leases and reservations per VLAN, Blocky # VLAN tagging/untagging, Kea leases and reservations per VLAN, Blocky
# answering on the VLAN with the blocklist active, NAT to the WAN, and the # answering on the VLAN with the blocklist active, NAT to the WAN, the
# firewall trust model (allowWan, mgmt-only SSH, no inter-VLAN forwarding). # firewall trust model (allowWan, mgmt-only SSH, no inter-VLAN forwarding),
# and the staging uplink as NATed fallback exit behind ppp0.
{ pkgs, lib, ... }: { pkgs, lib, ... }:
let let
# The vars mock answers every prompt with "mock-prompt-value-<name>"; the # The vars mock answers every prompt with "mock-prompt-value-<name>"; the
@@ -20,6 +23,10 @@ let
clientAddress = "10.9.20.50"; clientAddress = "10.9.20.50";
adminMac = "02:00:00:00:00:10"; adminMac = "02:00:00:00:00:10";
adminAddress = "10.9.10.50"; 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 in
{ {
name = "router"; name = "router";
@@ -36,6 +43,7 @@ in
isp = { }; isp = { };
client = { }; client = { };
admin = { }; admin = { };
oldlan = { };
}; };
instances.router = { instances.router = {
@@ -48,6 +56,7 @@ in
wan.interface = "wan"; wan.interface = "wan";
trunkPorts = [ "trunk" ]; trunkPorts = [ "trunk" ];
accessPorts.access = "mgmt"; accessPorts.access = "mgmt";
stagingPort = "staging";
vlans = { vlans = {
mgmt = { mgmt = {
id = 10; id = 10;
@@ -111,6 +120,10 @@ in
vlan = 3; vlan = 3;
assignIP = false; assignIP = false;
}; };
staging = {
vlan = 4;
assignIP = false;
};
}; };
# Something must listen on 22 for the mgmt-only SSH rule to be observable # Something must listen on 22 for the mgmt-only SSH rule to be observable
@@ -251,6 +264,32 @@ in
}; };
environment.systemPackages = [ pkgs.netcat ]; 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 = '' 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.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("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'") 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'")
''; '';
} }