/** * Swap the physical router the `fw-isp` simulator impersonates, on a RUNNING * network — the harness half of the ISP-replaced-the-box scenario. * * `.axonRouter()` on the network builder cannot express this: it sets * `ROUTER_VENDOR_PREFIX` in the generated compose file, which is read once when * the simulator process starts. That is the right tool for "this network has an * Axon in it"; it cannot say "this network had a GreenWave and now has an Axon", * which is the event the whole module-pause change exists to survive. * * The swap models what actually happens to a fleet when an ISP replaces the * hardware, and each part is load-bearing for what the migration test proves: * * - the TR-181 vendor prefix changes, so the OLD module's spelling is wrong; * - the credentials change, which is why the fleet wedges rather than merely * misbehaving — the old module cannot authenticate, so it can neither drive * the new box nor clean up after itself; * - every port forward is gone, because a new device arrives empty. That is * the assertion with teeth: the forwards have to be recreated by unpausing * the consumers that own them, and a simulator that kept its old table would * let a completely broken unpause pass. */ import { greenwaveRouterIp } from './types'; import type { NetworkHandle } from './types'; /** The devices the shared simulator can stand in for. */ export const ROUTER_DEVICES = { /** GreenWave C4000XG — the default, driven by `modules/greenwave`. */ greenwave: { vendorPrefix: 'X_GWS_', username: 'admin', password: 'admin' }, /** Axon Networks Q1000K — driven by `modules/axon`. */ axon: { vendorPrefix: 'X_AXON_', username: 'axonadmin', password: 'axonsecret' }, } as const; export type RouterDevice = keyof typeof ROUTER_DEVICES; export interface RouterSwapResult { /** Port forwards the outgoing device was holding, all of which are now gone. */ forwardsDiscarded: number; device: { vendorPrefix: string; username: string }; } /** * Replace the impersonated device. Runs the request from INSIDE the simulator * container, so the control endpoint never has to be reachable from anywhere * else on the simulated internet. */ export async function swapRouterDevice( net: NetworkHandle, device: RouterDevice, ): Promise { const spec = ROUTER_DEVICES[device]; const result = await net.exec( 'fw-isp', `curl -sk -X POST https://${greenwaveRouterIp()}/sim/swap-device ` + `-d 'vendorPrefix=${spec.vendorPrefix}&username=${spec.username}&password=${spec.password}'`, ); if (result.exitCode !== 0) { throw new Error(`swapRouterDevice(${device}) failed: ${result.stderr || result.stdout}`); } return JSON.parse(result.stdout) as RouterSwapResult; } /** * The port forwards the device is currently holding, read back OFF the device * rather than out of celilo's own records. * * This exists because "verify the contract, not the verdict" is the whole point * of the migration test: celilo reporting that it registered a forward is not * evidence the router has one, and those are exactly the two things that came * apart when the hardware changed underneath. */ export async function listRouterForwards(net: NetworkHandle, device: RouterDevice) { const spec = ROUTER_DEVICES[device]; const login = await net.exec( 'fw-isp', `curl -sk -X POST https://${greenwaveRouterIp()}/cgi/cgi_action ` + `-d 'username=${spec.username}&password=${spec.password}' -c /tmp/swap-cookies`, ); if (login.exitCode !== 0) { throw new Error(`could not log in to the ${device} router: ${login.stderr || login.stdout}`); } const listed = await net.exec( 'fw-isp', `curl -sk 'https://${greenwaveRouterIp()}/cgi/cgi_get?Object=Device.NAT.PortMapping.' -b /tmp/swap-cookies`, ); return listed.stdout; } /** * The external ports currently forwarded on the device, as a sorted list. * * Assert on THIS rather than on a consumer's container IP. A forward's * `InternalClient` is the firewall's natIp DNAT ingress (e.g. `10.226.1.253`), * not the consumer's zone-side address — the delegation chain hops through the * firewall — so matching a module's own IP looks correct and never matches. */ export function forwardedPorts(listing: string): string[] { const ports = [...listing.matchAll(/"ParamName":"ExternalPort","ParamValue":"(\d+)"/g)].map( (m) => m[1], ); return [...new Set(ports)].sort(); } /** * The DHCP pool's advertised DNS servers, read back off the device. * * Queries the pool INSTANCE (`…Pool.1.`), not the collection (`…Pool.`). The * simulator answers the collection form with `{"Objects":[]}` — no error, just * an empty list — so the collection form reads as "the router advertises no DNS * servers" whatever is actually configured. Both `greenwave` and `axon` write * through `Device.DHCPv4.Server.Pool.1.` * (`modules/greenwave/scripts/isp-router-functions.ts:153`), so this reads back * exactly where the module wrote. * * Nothing called this until `e2e/tests/provider-arrival-backfill.test.ts`, which * is how the mismatch survived: against the collection form an assertion fails * identically whether the router was configured or not. */ export async function routerDhcpDnsServers( net: NetworkHandle, device: RouterDevice, ): Promise { const spec = ROUTER_DEVICES[device]; await net.exec( 'fw-isp', `curl -sk -X POST https://${greenwaveRouterIp()}/cgi/cgi_action ` + `-d 'username=${spec.username}&password=${spec.password}' -c /tmp/dhcp-cookies`, ); const listed = await net.exec( 'fw-isp', `curl -sk 'https://${greenwaveRouterIp()}/cgi/cgi_get?Object=Device.DHCPv4.Server.Pool.1.' -b /tmp/dhcp-cookies`, ); return listed.stdout; }