import { spawn, execSync, type ChildProcess } from 'child_process'; import fs from 'fs'; import path from 'path'; import os from 'os'; import { paths } from '../shared/paths.js'; import { log } from '../shared/logger.js'; let proc: ChildProcess | null = null; const MIN_CF_SIZE = 10 * 1024 * 1024; // 10 MB — valid cloudflared is ~30-50 MB function findBinary(): string | null { // Check system-wide install const which = process.platform === 'win32' ? 'where cloudflared' : 'which cloudflared'; try { execSync(which, { stdio: 'ignore' }); return 'cloudflared'; } catch {} // Check local install (validate by file size, never execute — avoids Windows popup) if (!fs.existsSync(paths.cloudflared)) return null; const size = fs.statSync(paths.cloudflared).size; if (size < MIN_CF_SIZE) { fs.unlinkSync(paths.cloudflared); return null; } return paths.cloudflared; } export async function installCloudflared(): Promise { const existing = findBinary(); if (existing) return existing; const dir = path.dirname(paths.cloudflared); fs.mkdirSync(dir, { recursive: true }); const p = os.platform(); const a = p === 'win32' ? (process.env.PROCESSOR_ARCHITECTURE || os.arch()).toLowerCase() : os.arch(); const base = 'https://github.com/cloudflare/cloudflared/releases/latest/download'; let url: string; if (p === 'win32') url = `${base}/cloudflared-windows-${a.includes('arm') ? 'arm64' : 'amd64'}.exe`; else if (p === 'darwin') url = `${base}/cloudflared-darwin-${a === 'arm64' ? 'arm64' : 'amd64'}.tgz`; else if (a === 'arm64' || a === 'aarch64') url = `${base}/cloudflared-linux-arm64`; else if (a.startsWith('arm')) url = `${base}/cloudflared-linux-arm`; else url = `${base}/cloudflared-linux-amd64`; log.info('Installing cloudflared...'); if (p === 'win32') { execSync(`curl.exe -fsSL -o "${paths.cloudflared}" "${url}"`, { stdio: 'ignore' }); } else if (url.endsWith('.tgz')) { execSync(`curl -fsSL "${url}" | tar xz -C "${dir}"`, { stdio: 'ignore' }); } else { execSync(`curl -fsSL -o "${paths.cloudflared}" "${url}"`, { stdio: 'ignore' }); } if (p !== 'win32') fs.chmodSync(paths.cloudflared, 0o755); log.ok('cloudflared installed'); return paths.cloudflared; } export function startTunnel(port: number): Promise { return new Promise(async (resolve, reject) => { const bin = await installCloudflared(); const timeout = setTimeout(() => reject(new Error('Tunnel timeout')), 30_000); proc = spawn(bin, ['tunnel', '--url', `http://localhost:${port}`, '--no-autoupdate'], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, }); let buf = ''; const onData = (d: Buffer) => { buf += d.toString(); const m = buf.match(/https:\/\/[^\s]+\.trycloudflare\.com/); if (m) { clearTimeout(timeout); resolve(m[0]); } }; proc.stdout?.on('data', onData); proc.stderr?.on('data', onData); proc.on('error', (e) => { clearTimeout(timeout); reject(e); }); proc.on('exit', (code) => { if (code !== 0) { clearTimeout(timeout); reject(new Error(`cloudflared exited with code ${code}`)); } }); }); } export async function startNamedTunnel(configPath: string, name: string): Promise { const bin = await installCloudflared(); proc = spawn(bin, ['tunnel', '--config', configPath, 'run', name], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, }); proc.stdout?.on('data', (d: Buffer) => log.info(`[named-tunnel] ${d.toString().trim()}`)); proc.stderr?.on('data', (d: Buffer) => log.info(`[named-tunnel] ${d.toString().trim()}`)); proc.on('error', (e) => log.error(`Named tunnel error: ${e.message}`)); proc.on('exit', (code) => { if (code !== 0) log.warn(`Named tunnel exited with code ${code}`); }); } export async function startTunnelForMode( mode: 'off' | 'quick' | 'named', port: number, configPath?: string, name?: string, ): Promise { if (mode === 'off') return null; if (mode === 'named') { if (!configPath || !name) throw new Error('Named tunnel requires configPath and name'); await startNamedTunnel(configPath, name); return null; // URL is known from config.tunnel.domain } // mode === 'quick' return startTunnel(port); } export function stopTunnel(): void { proc?.kill(); proc = null; } /** Check if the cloudflared child process is still running */ export function isTunnelProcessAlive(): boolean { return proc !== null && proc.exitCode === null && proc.signalCode === null; } export async function isTunnelAlive(url: string, localPort?: number): Promise { // 1. Process check — if cloudflared exited, tunnel is definitely dead if (!isTunnelProcessAlive()) return false; // 2. Local reachability check — confirm the local server is still responding. // We probe localhost directly instead of the tunnel URL because on macOS // the server can't reach itself through the Cloudflare tunnel (DNS/firewall). // If localhost responds and cloudflared is running, the tunnel is alive. if (localPort) { try { const res = await fetch(`http://127.0.0.1:${localPort}/api/health?_cb=${Date.now()}`, { signal: AbortSignal.timeout(3000), }); if (res.ok) return true; } catch {} // Local server not responding — but cloudflared is alive, so tunnel is still up. // The worker may be restarting. Don't kill the tunnel for a local issue. return true; } // No local port provided — process alive is enough return true; } export async function restartTunnel(port: number): Promise { stopTunnel(); return startTunnel(port); } export async function restartNamedTunnel(configPath: string, name: string): Promise { stopTunnel(); await startNamedTunnel(configPath, name); }