import { execSync } from 'node:child_process'; import { writeFileSync } from 'node:fs'; import type { CgiObject, CgiParam, PortMapping, Session } from './types'; let validUsername = process.env.ROUTER_USERNAME || 'admin'; let validPassword = process.env.ROUTER_PASSWORD || 'admin'; const PUBLIC_IP = process.env.ROUTER_PUBLIC_IP || '203.0.113.100'; /** * The interface facing the ISP — DERIVED from the address it has to serve, not * guessed. * * The port-forward DNAT used to bind an interface (`-i ${EXTERNAL_INTERFACE}`), * defaulting to `eth1`, and nothing anywhere set that variable — not the * compose generator, not any config. On fw-isp `eth1` is the INTERNAL leg * (10.226.1.1/24) and `eth0` carries the public address, so every forward was * bound to the interface facing AWAY from the internet. * * What that costs, measured 2026-09-26: inbound 80/443 to 203.0.113.100 was * refused, so Pebble could complete NEITHER http-01 (`dial 203.0.113.100:80: * connect: connection refused`) nor tls-alpn-01 (`Failed to connect to * 203.0.113.100:443`). No per-test domain could ever obtain a certificate. * celilo-web-console-oidc died at stage 2 on authentik's HTTPS readiness and * again at stage 4 on OIDC discovery — two failures, one cause, neither of them * naming the interface. Adding the eth0 rule by hand made ACME finish in 12s. * * Hairpin traffic from inside DID match, so forwards appeared to work from * every vantage inside the simulation. Only the internet could not get in, * which is the one direction a port forward exists for. * * Deriving it removes the class: whichever interface holds PUBLIC_IP is by * definition the one inbound traffic arrives on. The env var still wins when * set, and the old default remains the last resort so a container whose address * is not yet up behaves as before rather than throwing. */ function interfaceCarrying(address: string): string | null { try { const out = execSync(`ip -4 -o addr show | grep -w ${address} || true`, { encoding: 'utf8', timeout: 5_000, }); // `ip -o addr` columns: ": inet / ..." return out.trim().split('\n')[0]?.trim().split(/\s+/)[1] || null; } catch { return null; } } /** * The interface the DHCP server must bind, derived from the router address it * hands out rather than assumed to be eth0. * * dnsmasq is configured `bind-interfaces`, so this decides which segment it * serves. fw-isp is multi-homed and Docker's interface ordering is NOT stable * across runs — measured 2026-09-26, eth0 and eth1 swapped between two runs on * the same rig. Bound to the wrong one, dnsmasq offers a 10.226.1.0/24 range on * the PUBLIC segment and serves nothing on the LAN it exists for, which is a * worse failure than not starting. * * Same defect as the port-forward DNAT above, same file, found by auditing for * it after that one bit twice in opposite directions. */ function deriveDhcpInterface(routerIp: string): string { const iface = interfaceCarrying(routerIp); if (iface) return iface; console.error( `[greenwave] no interface carries the DHCP router address ${routerIp}; binding eth0 and DHCP may serve the wrong segment`, ); return 'eth0'; } /** * Vendor extension prefix for TR-181 parameters. * * This simulator stands in for two real devices that speak an identical CGI * protocol and differ only here: the GreenWave C4000XG (`X_GWS_Via`, plus the * Lantiq-chipset `X_LANTIQ_COM_INTERFACE`) and the Axon Networks Q1000K, which * folds both into `X_AXON_`. Defaults to the GreenWave spelling so the existing * greenwave suite is byte-for-byte unaffected. */ let vendorPrefix = process.env.ROUTER_VENDOR_PREFIX || 'X_GWS_'; /** * The C4000XG named the interface selector after the chipset vendor rather than * the device vendor, so it does NOT follow the prefix. Axon firmware does. */ function paramVia(): string { return `${vendorPrefix}Via`; } function paramInterface(): string { return vendorPrefix === 'X_GWS_' ? 'X_LANTIQ_COM_INTERFACE' : `${vendorPrefix}INTERFACE`; } /** * Replace the physical device this container is impersonating, WITHOUT * restarting it — the way an ISP swaps the box on the wall while the line, the * public IP and everything downstream stay put. * * `ROUTER_VENDOR_PREFIX` alone cannot model this: it is baked into the compose * file at network-build time and read once at module load, so a mid-run swap * was not expressible and the migration this whole change exists for could not * be tested end to end. * * Three things change together, because all three are true of a real swap and * each one matters to what is under test: * * 1. The vendor prefix, so a module written for the old device now speaks the * wrong TR-181 spelling. * 2. The CREDENTIALS. This is why the fleet wedges in practice rather than * merely misbehaving: the old module cannot even authenticate, so it can * neither drive the new box nor tear its own state down. * 3. Every port forward is GONE. A new device arrives empty. This is the * assertion that gives the migration test its teeth — the forwards must be * recreated by unpausing the consumers that own them, and a simulator that * kept its old table would let a completely broken unpause pass. */ export function swapDevice(options: { vendorPrefix: string; username?: string; password?: string; }): { forwardsDiscarded: number } { const forwardsDiscarded = portMappings.length; vendorPrefix = options.vendorPrefix; if (options.username !== undefined) validUsername = options.username; if (options.password !== undefined) validPassword = options.password; portMappings.length = 0; nextIndex = 1; // Sessions are per-device: a cookie minted by the old box is meaningless to // the new one, so anything still holding one gets a 401 rather than silently // continuing to work. sessions.clear(); return { forwardsDiscarded }; } /** What the simulator is currently impersonating — for assertions and debugging. */ export function currentDevice(): { vendorPrefix: string; username: string } { return { vendorPrefix, username: validUsername }; } const sessions = new Map(); const portMappings: PortMapping[] = []; let nextIndex = 1; // DHCP pool state const dhcpPool: Record = { Alias: 'cpe-Pool-1', Enable: 'true', MinAddress: '10.226.1.2', MaxAddress: '10.226.1.150', SubnetMask: '255.255.255.0', IPRouters: '10.226.1.1', DNSServers: '10.226.1.1', DomainName: '', LeaseTime: '3600', Status: 'Enabled', }; export function login(username: string, password: string): string | null { if (username !== validUsername || password !== validPassword) { return null; } const sessionId = `sim-${Date.now()}-${Math.random().toString(36).slice(2)}`; sessions.set(sessionId, { username, createdAt: Date.now() }); return sessionId; } export function logout(sessionId: string): void { sessions.delete(sessionId); } export function isAuthenticated(sessionId: string | null): boolean { if (!sessionId) return false; return sessions.has(sessionId); } export function getPublicIp(): CgiObject { return { ObjName: 'Device.IP.Interface.3.IPv4Address.1.', Param: [{ ParamName: 'IPAddress', ParamValue: PUBLIC_IP }], }; } export function listPortMappings(): CgiObject[] { return portMappings.map((pm) => ({ ObjName: pm.objName, Param: mappingToParams(pm), })); } export function addPortMapping(params: Record): CgiObject[] { const index = nextIndex++; const objName = `Device.NAT.PortMapping.${index}.`; const mapping: PortMapping = { objName, externalPort: params.ExternalPort || '', externalPortEndRange: params.ExternalPortEndRange || params.ExternalPort || '', internalPort: params.InternalPort || '', internalClient: params.InternalClient || '', enable: params.Enable || '1', description: params.Description || '', protocol: params.Protocol || 'TCP', via: params[paramVia()] || 'UI', remoteHost: params.RemoteHost || '', }; portMappings.push(mapping); // Actually apply the iptables rule so port forwarding works applyIptablesRule(mapping); console.log( `[greenwave] Added port forward: ${mapping.externalPort} -> ${mapping.internalClient}:${mapping.internalPort} (${mapping.protocol})`, ); return [{ ObjName: objName, Param: mappingToParams(mapping) }]; } export function getDhcpPool(): CgiObject { const params: CgiParam[] = Object.entries(dhcpPool).map(([key, value]) => ({ ParamName: key, ParamValue: value, })); return { ObjName: 'Device.DHCPv4.Server.Pool.1.', Param: params, }; } export function setDhcpPool(params: Record): CgiObject[] { let dhcpChanged = false; for (const [key, value] of Object.entries(params)) { if (key === 'Object' || key === 'Operation') continue; if (key in dhcpPool) { dhcpPool[key] = value; console.log(`[greenwave] DHCP pool: ${key} = ${value}`); if (key === 'DNSServers' || key === 'DomainName') { dhcpChanged = true; } } } // Update running dnsmasq when DNS/domain settings change if (dhcpChanged) { rewriteDnsmasqConfig(); } return [ { ObjName: 'Device.DHCPv4.Server.Pool.1.', Param: [{ ParamName: 'Success', ParamValue: 'Success' }], }, ]; } function rewriteDnsmasqConfig(): void { const dnsServers = dhcpPool.DNSServers || '10.226.1.1'; const domainName = dhcpPool.DomainName || ''; const routerIp = dhcpPool.IPRouters || '10.226.1.1'; const lines = [ '# Auto-generated by greenwave simulator', 'port=0', `interface=${deriveDhcpInterface(routerIp)}`, 'bind-interfaces', 'dhcp-range=10.226.1.200,10.226.1.220,255.255.255.0,60s', 'dhcp-option=option:router,10.226.1.1', `dhcp-option=option:dns-server,${dnsServers}`, 'dhcp-lease-max=20', 'log-dhcp', ]; if (domainName) { lines.push(`dhcp-option=option:domain-name,${domainName}`); } try { writeFileSync('/etc/dnsmasq.d/e2e.conf', `${lines.join('\n')}\n`); // dnsmasq requires full restart to re-read config files (SIGHUP only reloads /etc/hosts) try { execSync( 'pkill -9 dnsmasq; sleep 0.5; dnsmasq --conf-dir=/etc/dnsmasq.d --log-facility=/dev/stderr', { timeout: 5000, shell: '/bin/bash', }, ); } catch {} console.log(`[greenwave] dnsmasq restarted: DNS=${dnsServers}, domain=${domainName}`); } catch (err) { console.error('[greenwave] Failed to update dnsmasq:', err); } } function applyIptablesRule(mapping: PortMapping): void { const proto = mapping.protocol === 'TCP/UDP' ? 'tcp' : mapping.protocol.toLowerCase(); const extPort = mapping.externalPort; const intIp = mapping.internalClient; const intPort = mapping.internalPort; function addRules(p: string) { // DNAT incoming traffic on external interface destined for our public IP execSync( // NO `-i` HERE, DELIBERATELY. The destination address IS the // discriminator: only traffic actually addressed to PUBLIC_IP can match, // whichever leg it arrives on. Binding an interface as well makes exactly // one direction work and silently breaks the other. // // Measured 2026-09-26, and it is a COIN FLIP because Docker's interface // ordering is not stable across runs. Same rule, two runs: // run24/25 eth0=203.0.113.100 (public), eth1=10.226.1.1 // -> `-i eth1` matched hairpin only; INBOUND refused, so no // per-test domain could obtain a certificate at all. // run28 eth0=10.226.1.1, eth1=203.0.113.100 (public) // -> `-i eth1` matched inbound only; HAIRPIN refused, so the // SOCKS browser got ERR_SOCKS_CONNECTION_FAILED reaching // the public login path from inside the simulation. // Verified on the live stack with the rule as it stood: from the internal // proxy REFUSED, from Pebble on internet-external OPEN, simultaneously. // // Deriving the "right" interface does not fix this — it only chooses // which direction breaks. Both are real paths: ACME comes in from the // internet, and a browser inside the simulation dials the public name. `iptables -t nat -A PREROUTING -d ${PUBLIC_IP} -p ${p} --dport ${extPort} -j DNAT --to-destination ${intIp}:${intPort}`, { timeout: 5000 }, ); // Allow forwarded traffic execSync(`iptables -A FORWARD -p ${p} -d ${intIp} --dport ${intPort} -j ACCEPT`, { timeout: 5000, }); } try { addRules(proto); if (mapping.protocol === 'TCP/UDP') { addRules('udp'); } } catch (err) { console.error('[greenwave] Failed to apply iptables rule:', err); } } function mappingToParams(pm: PortMapping): CgiParam[] { return [ { ParamName: 'ExternalPort', ParamValue: pm.externalPort }, { ParamName: 'ExternalPortEndRange', ParamValue: pm.externalPortEndRange }, { ParamName: 'InternalPort', ParamValue: pm.internalPort }, { ParamName: 'InternalClient', ParamValue: pm.internalClient }, { ParamName: 'Enable', ParamValue: pm.enable }, { ParamName: 'Description', ParamValue: pm.description }, { ParamName: 'Protocol', ParamValue: pm.protocol }, { ParamName: paramVia(), ParamValue: pm.via }, { ParamName: 'RemoteHost', ParamValue: pm.remoteHost }, { ParamName: paramInterface(), ParamValue: 'wan' }, { ParamName: 'AllInterfaces', ParamValue: '0' }, ]; }