/** * Public HTTPS front door for a local port, via a `cloudflared` quick tunnel. * * A quick tunnel needs no Cloudflare account and no DNS: `cloudflared` dials * out, Cloudflare hands back a throwaway `*.trycloudflare.com` hostname, and * traffic arrives on the loopback port we pass in. That is the whole reason it * is used here — the developer's machine keeps no inbound port open. * * **The hostname IS the secret, and it is the only one.** The tunnel carries no * authorization of its own, and the browser runtime's viewer behind it has no * login either — so whoever learns the URL has the browser. Cloudflare's * hostnames are randomly generated, which is what makes that tolerable for a * short, deliberate hand-off, and `stop_browser_view` is how it ends. * * That is weaker than what this file used to front. The agent's own screencast * viewer added a 24-byte token to every path, so the hostname alone was not * enough; the container's viewer has no equivalent. Callers must say plainly * what the URL grants, and it must never front the container's CDP port, which * is unauthenticated total control of the browser. */ import { spawn } from 'node:child_process' import { LOGGER } from '../logger.ts' import { type CloudflaredBinary, resolveCloudflared } from './cloudflared.ts' /** How long to wait for cloudflared to report its hostname before giving up. */ const URL_TIMEOUT_MS = 30_000 /** After the hostname is printed, Cloudflare needs time before the edge routes * to it. Probe briefly, then return the still-running tunnel as propagating so * slow DNS or edge setup does not destroy an otherwise healthy share. */ const READY_TIMEOUT_MS = 20_000 const READY_POLL_MS = 1_000 const STOP_TIMEOUT_MS = 5_000 /** `https://.trycloudflare.com`, as printed on cloudflared's log * stream. Anchored to the scheme so a hostname inside prose can't match. */ const QUICK_TUNNEL_URL = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i /** Tail of cloudflared's log kept for error messages. */ const LOG_TAIL_BYTES = 4096 export interface Tunnel { /** The public HTTPS origin Cloudflare assigned. */ url: string /** Whether the public viewer answered during the initial readiness probe. */ ready: boolean /** Bounded cloudflared output when the edge is still propagating. */ cloudflaredLog?: string /** Terminate the tunnel. Safe to call more than once. */ stop: () => Promise /** Which cloudflared ran, and whether we had to fetch it. */ binary: CloudflaredBinary } /** * Wait until the tunnel is actually carrying traffic. * * A cloudflared "Registered tunnel connection" log is not enough: Cloudflare * can report one while the assigned hostname still returns DNS NXDOMAIN. The * public viewer itself must return a successful response. */ async function waitUntilRouted( url: string, { isRunning, pollMs, timeoutMs, }: { isRunning: () => boolean pollMs: number timeoutMs: number }, ): Promise { const deadline = Date.now() + timeoutMs while(Date.now() < deadline) { if(!isRunning()) { return false } try { const response = await fetch(url, { method: 'GET', redirect: 'manual', signal: AbortSignal.timeout(pollMs * 2), }) if(response.ok) { return true } } catch{} await new Promise((r) => setTimeout(r, pollMs)) } return false } /** * Open a quick tunnel to `http://127.0.0.1:` and resolve once * Cloudflare has assigned a hostname. * * Rejects with an actionable message when `cloudflared` is not installed, * exits early, or never reports a hostname. */ export async function startTunnel( port: number, { autoInstall = true, probePath = '/', readyPollMs = READY_POLL_MS, readyTimeoutMs = READY_TIMEOUT_MS, }: { autoInstall?: boolean /** Public path that must answer before the tunnel is returned. */ probePath?: string /** Test-only timing override. */ readyPollMs?: number /** Test-only timing override. */ readyTimeoutMs?: number } = {}, ): Promise { // Resolves an existing cloudflared, or fetches one on first use. Throws // with an actionable message when neither is possible. const binary = await resolveCloudflared({ autoInstall }) const child = spawn(binary.path, [ 'tunnel', '--no-autoupdate', '--url', `http://127.0.0.1:${port}`, ], { stdio: ['ignore', 'pipe', 'pipe'] }) const exited = new Promise((resolve) => { child.once('exit', () => resolve()) }) let stopPromise: Promise | undefined const stop = () => stopPromise ??= stopChild() return new Promise((resolve, reject) => { let tail = '' let settled = false const finish = (fn: () => void) => { if(settled) { return } settled = true clearTimeout(timer) fn() } const timer = setTimeout(() => { finish(() => { void stop().then(() => reject(new Error( 'cloudflared did not report a tunnel hostname within ' + `${URL_TIMEOUT_MS / 1000}s. Check network egress to ` + 'Cloudflare, or run `cloudflared tunnel --url ' + `http://127.0.0.1:${port}` + '` by hand to see its output.', ))) }) }, URL_TIMEOUT_MS) // cloudflared logs the assigned hostname to stderr, not stdout. Watch // both so a future version that switches streams still works. const onChunk = (chunk: Buffer) => { const text = chunk.toString() // Keep watching after the hostname so a readiness failure can include // cloudflared's account of what happened. tail = (tail + text).slice(-LOG_TAIL_BYTES) // Search the accumulated tail so a URL split across stream chunks is not // missed. The tail is bounded and the URL appears near startup. const match = QUICK_TUNNEL_URL.exec(tail) if(match) { const url = match[0] finish(() => { const probeUrl = new URL(probePath, `${url}/`).toString() void waitUntilRouted(probeUrl, { isRunning: () => child.exitCode === null && child.signalCode === null, pollMs: readyPollMs, timeoutMs: readyTimeoutMs, }).then((ready) => { if(ready) { resolve({ url, ready, stop, binary }) return } if(child.exitCode !== null || child.signalCode !== null) { reject(new Error( 'cloudflared exited before the public viewer became reachable. ' + 'cloudflared output:\n' + tail, )) return } LOGGER.warn({ url, tail }, 'tunnel still propagating; returning the live hostname') resolve({ url, ready, cloudflaredLog: tail, stop, binary, }) }) }) } } child.stdout?.on('data', onChunk) child.stderr?.on('data', onChunk) child.on('error', (err: NodeJS.ErrnoException) => { // `resolveCloudflared` already proved the binary runs, so an ENOENT // here means it vanished between resolving and spawning. finish(() => reject( err.code === 'ENOENT' ? new Error( `cloudflared disappeared from ${binary.path} before it could ` + 'start. Retry — the agent will fetch it again if needed.', ) : err, )) }) child.on('exit', (code) => { LOGGER.debug({ code }, 'cloudflared exited') finish(() => reject(new Error( `cloudflared exited with code ${code} before reporting a ` + 'hostname.', ))) }) }) async function stopChild() { if(child.exitCode !== null || child.signalCode !== null) { return } let timeout: NodeJS.Timeout | undefined const timedOut = new Promise((resolve) => { timeout = setTimeout(resolve, STOP_TIMEOUT_MS) }) child.kill('SIGTERM') await Promise.race([ exited, timedOut, ]) clearTimeout(timeout) if(child.exitCode !== null || child.signalCode !== null) { return } child.kill('SIGKILL') await exited } }