import { execSync } from 'node:child_process'; import { existsSync } from 'node:fs'; import { createConnection } from 'node:net'; import { join } from 'node:path'; import { progress } from './progress'; /** Package root — `docker/` and `config/` live here. */ const PACKAGE_ROOT = join(__dirname, '..'); export type SocksProxyVantage = 'isp-external' | 'internal'; export interface SocksProxyOptions { /** * Network vantage point for the proxy: * - 'isp-external' (default): residential-user view via comcast-resolver, * exercises full public ingress path (DNS publication, fw-ext DNAT, * ACME, Caddy host routing). Recommended for tests that need to * simulate what a real user sees. * - 'internal': operator-on-LAN view, attaches to the internal zone, * uses split-horizon DNS (10.226.1.10) so iamtheinternet.org resolves * to caddy's internal IP directly. Useful for debugging — reaches all * zones via the firewall. */ vantage?: SocksProxyVantage; /** * Resolver the proxy should use instead of the vantage's baked default. * * The PROXY resolves names for a SOCKS5 client: chromium configured with * `socks5://` sends the hostname and never resolves it itself. A test driving * a browser at a name only the fleet's own resolver knows — anything on a * private ingress — therefore has to point the proxy at that resolver. * * More faithful rather than less: the rig's baked stub answers a fixed * split-horizon table, whereas a device on the internal zone is handed the * fleet's resolver. Pass the deployed resolver's DNS-ingress address. */ nameserver?: string; } export interface SocksProxyHandle { /** URL the host can use to reach the proxy, e.g. 'socks5://127.0.0.1:54321' */ hostUrl: string; /** The published host port (the second half of hostUrl) */ hostPort: number; /** Container name (deterministic per project + vantage) */ containerName: string; /** Tear down the proxy. Idempotent. */ stop(): Promise; } /** * Custom image with dante-server + an entrypoint that fixes routing * and DNS for the chosen vantage. Built from * `docker/Dockerfile.socks-proxy` either by `e2e-build` (eager) or * lazily on first use (see ensureProxyImage()). * * Why custom: an off-the-shelf SOCKS5 image (e.g. serjs/go-socks5-proxy) * starts fine on the test network but can't actually route to anything. * Docker's IPAM-default gateway points at an IP with no listener (e.g. * 203.0.113.250 on isp-external), and docker rewrites resolv.conf to * its embedded resolver, which can't cleanly forward to the simulated * network's DNS. The custom image's entrypoint replaces both. */ const PROXY_IMAGE = 'celilo-e2e/socks-proxy'; const PROXY_DOCKERFILE = 'docker/Dockerfile.socks-proxy'; interface VantageConfig { /** Suffix on the docker network name: _ */ network: string; /** Static IP for the proxy in this network (chosen to avoid collisions) */ ip: string; } const VANTAGE_CONFIGS: Record = { 'isp-external': { network: 'isp-external', ip: '203.0.113.150', }, internal: { network: 'internal', ip: '10.226.1.150', }, }; /** * Deterministic per (project, vantage, resolver). * * The resolver is part of the identity because a proxy is REUSED by name: two * calls differing only in `nameserver` would otherwise silently share one * container, and the second would resolve through the first's resolver. That * failure looks like a DNS bug in the fleet, which is the worst possible place * for it to look like anything. */ function containerNameFor( projectName: string, vantage: SocksProxyVantage, nameserver?: string, ): string { const suffix = nameserver ? `-dns-${nameserver.replace(/\./g, '-')}` : ''; return `${projectName}-socks-${vantage}${suffix}`; } /** * Spawn (or reattach to) a SOCKS5 proxy container for the given project. * Idempotent: if a proxy with the deterministic name already exists, the * existing container is reused and its bound port is read back. */ export async function startSocksProxy( projectName: string, options: SocksProxyOptions = {}, ): Promise { const vantage = options.vantage ?? 'isp-external'; const cfg = VANTAGE_CONFIGS[vantage]; const networkName = `${projectName}_${cfg.network}`; const containerName = containerNameFor(projectName, vantage, options.nameserver); // Reuse existing container if one is running for this project + vantage const existing = inspectContainer(containerName); if (existing) { const port = readBoundPort(containerName); progress.done(`SOCKS proxy reused on 127.0.0.1:${port} (vantage: ${vantage})`); return makeHandle(containerName, port); } progress(`starting SOCKS proxy (${vantage})`, 'SOCKS proxy ready'); ensureProxyImage(); // Use docker run -d --rm so the container self-cleans if it exits cleanly. // Let docker pick the host port (-p 127.0.0.1::1080) to avoid collisions // when multiple e2e runs share a host. NET_ADMIN is needed by the // entrypoint to rewrite the routing table; the daemon itself drops to // an unprivileged user once listening. try { execSync( [ 'docker run -d --rm', `--name ${containerName}`, `--network ${networkName}`, `--ip ${cfg.ip}`, '--cap-add NET_ADMIN', `-e VANTAGE=${vantage}`, ...(options.nameserver ? [`-e NAMESERVER_OVERRIDE=${options.nameserver}`] : []), '-p 127.0.0.1::1080', PROXY_IMAGE, ].join(' '), { timeout: 30_000, stdio: 'pipe' }, ); } catch (err) { const msg = err instanceof Error ? err.message : String(err); throw new Error( `Failed to start SOCKS proxy on network ${networkName}: ${msg}\n\n` + `Verify the network exists (docker network ls | grep ${networkName}).`, ); } // The container takes a tick to actually bind the listener and have // its port mapping queryable. Wait for it (with a short timeout) so // we don't race `docker port` against a still-starting container. await waitForContainerListening(containerName); const port = readBoundPort(containerName); // Even after `docker port` reports a mapping, dante itself takes // ~500–800ms to actually accept connections inside the container. // If we return the handle now, the very next `chromium.launch` may // race the daemon — chromium will start its first SOCKS handshake // before dante's listener is up, the connection fails, and the // first navigation surfaces as "Target page, context or browser // has been closed". Probe the host port directly until it accepts. await waitForTcpAccept(port); console.log(`[e2e:socks] vantage=${vantage} listening=127.0.0.1:${port}`); progress.done(`SOCKS proxy listening on 127.0.0.1:${port} (vantage: ${vantage})`); return makeHandle(containerName, port); } /** * Poll the host port with a real TCP connect until the listener * accepts. Bails after the timeout with a descriptive error. Each * attempt has a short connect timeout so a hung daemon doesn't pin us. */ async function waitForTcpAccept(port: number, timeoutMs = 10_000): Promise { const deadline = Date.now() + timeoutMs; let lastErr: string | undefined; while (Date.now() < deadline) { const ok = await tryTcpConnect('127.0.0.1', port, 500).catch((e) => { lastErr = e instanceof Error ? e.message : String(e); return false; }); if (ok) return; // e2e-sleep-ok: poll cadence; tryTcpConnect is re-checked each iteration. await new Promise((r) => setTimeout(r, 100)); } throw new Error( `SOCKS proxy on 127.0.0.1:${port} never accepted connections within ${timeoutMs}ms${lastErr ? ` (last error: ${lastErr})` : ''}`, ); } function tryTcpConnect(host: string, port: number, timeoutMs: number): Promise { return new Promise((resolve, reject) => { const sock = createConnection({ host, port }); const timer = setTimeout(() => { sock.destroy(); reject(new Error('connect timeout')); }, timeoutMs); sock.once('connect', () => { clearTimeout(timer); sock.end(); resolve(true); }); sock.once('error', (err) => { clearTimeout(timer); sock.destroy(); reject(err); }); }); } function makeHandle(containerName: string, port: number): SocksProxyHandle { let stopped = false; return { hostUrl: `socks5://127.0.0.1:${port}`, hostPort: port, containerName, async stop(): Promise { if (stopped) return; stopped = true; // Respect --keep / --reuse: leave the proxy running for manual sessions if (process.env.CELILO_E2E_KEEP === '1' || process.env.CELILO_E2E_REUSE === '1') { console.log(`[e2e:socks] proxy kept alive: ${containerName} (port ${port})`); return; } try { execSync(`docker rm -f ${containerName}`, { timeout: 10_000, stdio: 'pipe' }); } catch { // Best-effort cleanup } }, }; } /** * Build the proxy image on first use if it isn't already tagged. Eager * builds via `e2e-build` are still preferred (warm cache, single * setup), but a lazy build keeps things working for callers that haven't * run e2e-build yet. */ function ensureProxyImage(): void { try { execSync(`docker image inspect ${PROXY_IMAGE} >/dev/null 2>&1`, { timeout: 5_000, stdio: 'pipe', }); return; } catch { // Not built yet — fall through and build it } const dockerfilePath = join(PACKAGE_ROOT, PROXY_DOCKERFILE); if (!existsSync(dockerfilePath)) { throw new Error( `SOCKS proxy Dockerfile not found at ${dockerfilePath}. Expected it to ship with the @celilo/e2e package.`, ); } progress('building SOCKS proxy image (one-time)', 'SOCKS proxy image built'); try { execSync(`docker build -t ${PROXY_IMAGE} -f ${PROXY_DOCKERFILE} .`, { cwd: PACKAGE_ROOT, timeout: 300_000, stdio: 'pipe', }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); throw new Error(`Failed to build ${PROXY_IMAGE} from ${PROXY_DOCKERFILE}: ${msg}`); } } function inspectContainer(name: string): boolean { try { const result = execSync(`docker inspect -f '{{.State.Running}}' ${name} 2>/dev/null`, { timeout: 5_000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], }); return result.trim() === 'true'; } catch { return false; } } /** * Wait for the container to be running with its port published. Bails * out fast (with logs) if the container exits during startup — the * SOCKS image's misconfig modes (auth required, etc.) all fall under * this branch and are much more debuggable with the container's stderr. */ async function waitForContainerListening(name: string): Promise { const deadline = Date.now() + 10_000; let lastInspect = ''; while (Date.now() < deadline) { try { lastInspect = execSync(`docker inspect -f '{{.State.Status}} {{.State.ExitCode}}' ${name}`, { timeout: 3_000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], }).trim(); } catch { // Container may have already self-removed (--rm) after exiting throw new Error( `SOCKS proxy container '${name}' disappeared during startup. It likely exited immediately. Check the image's required env vars.`, ); } const [status] = lastInspect.split(/\s+/); if (status === 'running') { // Confirm a port is actually bound. Some hosts/colima configs // briefly report "running" before the port mapping settles. try { execSync(`docker port ${name} 1080/tcp`, { timeout: 3_000, stdio: ['pipe', 'pipe', 'pipe'], }); return; } catch { // Not yet — keep polling } } if (status === 'exited' || status === 'dead' || status === 'removing') { let logs = ''; try { logs = execSync(`docker logs ${name} 2>&1 | tail -20`, { timeout: 3_000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], }).trim(); } catch {} throw new Error( `SOCKS proxy container '${name}' exited during startup (status=${lastInspect}).\n\n` + `Container logs:\n${logs || '(none)'}`, ); } // e2e-sleep-ok: poll cadence; the container inspect is re-checked each iteration. await new Promise((r) => setTimeout(r, 200)); } throw new Error( `Timed out waiting for SOCKS proxy '${name}' to become ready (last inspect: ${lastInspect}).`, ); } /** * Read the host port docker bound to the container's 1080/tcp. * Output of `docker port 1080/tcp` looks like: * 127.0.0.1:54321 */ function readBoundPort(containerName: string): number { const out = execSync(`docker port ${containerName} 1080/tcp`, { timeout: 5_000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], }).trim(); const match = out.match(/:(\d+)\s*$/m); if (!match) { throw new Error(`Could not parse host port from 'docker port' output: ${out}`); } return Number.parseInt(match[1], 10); }