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 network interface facing the ISP (isp-external network) const EXTERNAL_INTERFACE = process.env.EXTERNAL_INTERFACE || 'eth1'; /** * 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 lines = [ '# Auto-generated by greenwave simulator', 'port=0', 'interface=eth0', '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( `iptables -t nat -A PREROUTING -i ${EXTERNAL_INTERFACE} -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' }, ]; }