/** * Greenwave C4000XG Router Simulator * * Implements the REST API subset used by celilo's greenwave module: * POST /cgi/cgi_action — Login/logout * GET /cgi/cgi_get — Read config objects (public IP, port mappings) * POST /cgi/cgi_set — Add/modify port forwarding rules * * Port forwards are actually applied via iptables, making them * functional in the simulated Docker network. */ import { addPortMapping, currentDevice, getDhcpPool, getPublicIp, isAuthenticated, listPortMappings, login, logout, setDhcpPool, swapDevice, } from './state'; import type { CgiResponse } from './types'; const PORT = 443; function getSessionId(req: Request): string | null { const cookie = req.headers.get('cookie') || ''; const match = cookie.match(/Session-Id=([^;]+)/); return match ? match[1] : null; } function parseUrlEncoded(body: string): Record { const params: Record = {}; for (const pair of body.split('&')) { const [key, ...rest] = pair.split('='); if (key) params[decodeURIComponent(key)] = decodeURIComponent(rest.join('=')); } return params; } function jsonResponse(data: CgiResponse, status = 200): Response { return Response.json(data, { status }); } // Generate self-signed TLS cert for HTTPS const tlsOptions = (() => { try { const { execSync } = require('node:child_process'); const { readFileSync, existsSync } = require('node:fs'); if (!existsSync('/tmp/router-cert.pem')) { execSync( 'openssl req -x509 -newkey rsa:2048 -keyout /tmp/router-key.pem -out /tmp/router-cert.pem -days 365 -nodes -subj "/CN=router.local"', { timeout: 10000 }, ); } return { cert: readFileSync('/tmp/router-cert.pem'), key: readFileSync('/tmp/router-key.pem'), }; } catch { console.warn('[greenwave] Failed to generate TLS cert, falling back to HTTP'); return undefined; } })(); // Bind to internal interface only (like a real router's management UI) // The external interface should only handle port-forwarded traffic via iptables const BIND_HOST = process.env.ROUTER_BIND_HOST || '10.226.1.1'; const server = Bun.serve({ port: PORT, hostname: BIND_HOST, tls: tlsOptions, async fetch(req) { const url = new URL(req.url); // --- POST /sim/swap-device — HARNESS CONTROL, not part of the CGI API --- // // Replaces the physical device this container impersonates, on a RUNNING // network, modelling an ISP swapping the box on the wall. Namespaced under // /sim/ and deliberately unauthenticated: it is not reachable from any // module (nothing in modules/ knows this path exists) and the whole // container is a simulator. Real firmware has no such endpoint, which is // exactly why it cannot live under /cgi/. if (url.pathname === '/sim/swap-device' && req.method === 'POST') { const body = await req.text(); const params = parseUrlEncoded(body); if (!params.vendorPrefix) { return new Response('vendorPrefix is required', { status: 400 }); } const result = swapDevice({ vendorPrefix: params.vendorPrefix, username: params.username, password: params.password, }); return new Response(JSON.stringify({ ...result, device: currentDevice() }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } // --- GET /sim/device — what is currently being impersonated --- if (url.pathname === '/sim/device' && req.method === 'GET') { return new Response(JSON.stringify(currentDevice()), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } // --- POST /cgi/cgi_action (login/logout) --- if (url.pathname === '/cgi/cgi_action' && req.method === 'POST') { const body = await req.text(); const params = parseUrlEncoded(body); // Logout if (params.logout === 'true') { const sessionId = getSessionId(req); if (sessionId) logout(sessionId); return new Response('OK', { status: 200 }); } // Login const { username, password } = params; if (!username || !password) { return new Response('Missing credentials', { status: 400 }); } const sessionId = login(username, password); if (!sessionId) { return new Response('Invalid credentials', { status: 444 }); } return new Response(JSON.stringify({ SessionId: sessionId }), { status: 200, headers: { 'Content-Type': 'application/json', 'Set-Cookie': `Session-Id=${sessionId}; Path=/`, }, }); } // --- GET /cgi/cgi_get (read objects) --- if (url.pathname === '/cgi/cgi_get' && req.method === 'GET') { const sessionId = getSessionId(req); if (!isAuthenticated(sessionId)) { return new Response('Unauthorized', { status: 403 }); } const objectPath = url.searchParams.get('Object') || ''; if (objectPath.startsWith('Device.IP.Interface.3.IPv4Address')) { return jsonResponse({ Objects: [getPublicIp()] }); } if (objectPath.startsWith('Device.NAT.PortMapping')) { return jsonResponse({ Objects: listPortMappings() }); } if (objectPath.startsWith('Device.DHCPv4.Server.Pool.1')) { return jsonResponse({ Objects: [getDhcpPool()] }); } return jsonResponse({ Objects: [] }); } // --- POST /cgi/cgi_set (modify objects) --- if (url.pathname === '/cgi/cgi_set' && req.method === 'POST') { const sessionId = getSessionId(req); if (!isAuthenticated(sessionId)) { return new Response('Unauthorized', { status: 403 }); } const body = await req.text(); const params = parseUrlEncoded(body); if (params.Operation === 'Add' && params.Object?.startsWith('Device.NAT.PortMapping')) { const result = addPortMapping(params); return jsonResponse({ Objects: result }); } if (params.Object?.startsWith('Device.DHCPv4.Server.Pool.1')) { const result = setDhcpPool(params); return jsonResponse({ Objects: result }); } return jsonResponse({ Objects: [] }); } return new Response('Not Found', { status: 404 }); }, }); console.log( `[greenwave] C4000XG simulator listening on ${BIND_HOST}:${server.port} (${tlsOptions ? 'HTTPS' : 'HTTP'})`, );