import { spawn, type ChildProcess } from "child_process"; import path from "path"; import fs from "fs"; import { resolveMitmDataDir } from "./dataDir.ts"; import { addDNSEntry, addDNSEntries, removeDNSEntry, removeDNSEntries } from "./dns/dnsConfig.ts"; import { generateCert } from "./cert/generate.ts"; import { installCertResult, uninstallCert } from "./cert/install.ts"; import { ALL_TARGETS } from "./targets/index.ts"; import { detectAgent } from "./detection/index.ts"; import type { AgentId, DetectionResult, MitmTarget } from "./types.ts"; import { getAllAgentBridgeStates } from "@/lib/db/agentBridgeState.ts"; import { listCustomHosts } from "@/lib/db/inspectorCustomHosts.ts"; import { getUserBypassPatterns } from "@/lib/db/agentBridgeBypass.ts"; import { configureUpstreamCa } from "./upstreamTrust.ts"; import { createLogger } from "@/shared/utils/logger.ts"; const log = createLogger("mitm-manager"); /** * Map the MITM child process (`server.cjs`) stderr to the actual startup-failure * cause. `server.cjs` emits one of several "❌"-prefixed lines on `server.on("error")` * or on a missing API key, then exits. The old code only matched EADDRINUSE and so * always blamed "port 443", misleading users whose real problem was a permission * error or a missing ROUTER_API_KEY (#3606). The returned string is a controlled, * secret-free diagnostic (it carries no stack and no credentials). (#3606) */ export function interpretMitmStartupError(stderr: string, port: number): string { const text = (stderr || "").trim(); const lower = text.toLowerCase(); if (lower.includes("already in use")) { return `MITM server failed to start: port ${port} is already in use`; } if (lower.includes("permission denied")) { return `MITM server failed to start: permission denied for port ${port} (run with elevated privileges, or use a port ≥ 1024)`; } if (lower.includes("router_api_key")) { return "MITM server failed to start: no API key was provided (ROUTER_API_KEY is required). Set a router API key in OmniRoute and retry."; } // Surface the first "❌ " diagnostic line verbatim (marker stripped), // so any other server.cjs failure is reported with its real cause. for (const line of text.split(/\r?\n/)) { const trimmed = line.trim(); if (trimmed.startsWith("❌")) { const detail = trimmed.replace(/^❌\s*/, "").trim(); if (detail) return `MITM server failed to start: ${detail}`; } } // Nothing diagnostic was captured — stay generic instead of guessing port 443. return "MITM server failed to start (no diagnostic output was captured from the MITM server)"; } // Store server process let serverProcess: ChildProcess | null = null; let serverPid: number | null = null; // Set when getMitmStatus() finds a stale PID file (server died without clean // teardown). The dashboard surfaces this to offer a one-click Repair. Cleared // by repairMitm(). (Gap 7.) let _orphanedStateDetected = false; // Guards installCleanupHandlers() so the parent-process signal handlers are // registered at most once. (Gap 7.) let _cleanupHandlersInstalled = false; // Module-scoped password cache (not exposed on globalThis). // Cleared automatically when the MITM proxy is stopped. let _cachedPassword: string | null = null; export function getCachedPassword(): string | null { return _cachedPassword; } export function setCachedPassword(pwd: string | null | undefined): void { _cachedPassword = pwd || null; } export function clearCachedPassword(): void { _cachedPassword = null; } const PID_FILE = path.join(resolveMitmDataDir(), "mitm", ".mitm.pid"); const TARGETS_JSON_FILE = path.join(resolveMitmDataDir(), "mitm", "targets.json"); const BYPASS_JSON_FILE = path.join(resolveMitmDataDir(), "mitm", "bypass.json"); const CA_PATH_FILE = path.join(resolveMitmDataDir(), "mitm", "upstream-ca.path"); /** Read the persisted upstream CA path written by the POST upstream-ca route handler. */ function readStoredUpstreamCaPath(): string | null { try { if (!fs.existsSync(CA_PATH_FILE)) return null; const raw = fs.readFileSync(CA_PATH_FILE, "utf8").trim(); return raw || null; } catch { return null; } } /** * Write the canonical `targets.json` consumed by `server.cjs` at startup. * * The file mirrors the static `ALL_TARGETS` registry; server.cjs treats it as * an extension of its baseline antigravity hosts. Hard Rule #13: only the * declarative target hosts are persisted — no runtime paths, no shell escapes. */ export function writeTargetsJson(targets: MitmTarget[] = ALL_TARGETS): void { const dir = path.join(resolveMitmDataDir(), "mitm"); try { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); } catch { // mkdir failures are non-fatal; the write below will report the real error. } const payload = { version: 1, generatedAt: new Date().toISOString(), targets: targets.map((t) => ({ id: t.id, name: t.name, hosts: t.hosts, endpointPatterns: t.endpointPatterns, viability: t.viability ?? "supported", })), }; fs.writeFileSync(TARGETS_JSON_FILE, JSON.stringify(payload, null, 2)); } /** * Write the canonical `bypass.json` file consumed by `server.cjs` at startup. * * Only USER-configured patterns are persisted here — the default bypass * regexes (banks/gov/okta/auth0) live hard-coded in `server.cjs` and in * `src/mitm/passthrough.ts` so they apply even when the file is missing. * * Plan reference: 11-agent-bridge.plan.md §4.6 + master-plan-group-A.md §3.7. * Hard Rule #13: no shell interpolation, file only. */ export function writeBypassJson(userPatterns?: string[]): void { const dir = path.join(resolveMitmDataDir(), "mitm"); try { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); } catch { // mkdir failures are non-fatal; the write below will report the real error. } const patterns = Array.isArray(userPatterns) && userPatterns.length >= 0 ? userPatterns : getUserBypassPatterns(); const payload = { version: 1, generatedAt: new Date().toISOString(), patterns, }; fs.writeFileSync(BYPASS_JSON_FILE, JSON.stringify(payload, null, 2)); } export interface AgentStatus { id: AgentId; name: string; hosts: string[]; viability: "supported" | "investigating" | "deprecated"; detection: DetectionResult; } /** * Aggregate every registered MITM target with its current installation * detection result. Read-only — used by the AgentBridge dashboard. */ export function getAllAgentsStatus(): AgentStatus[] { return ALL_TARGETS.map((t) => ({ id: t.id, name: t.name, hosts: t.hosts, viability: t.viability ?? "supported", detection: detectAgent(t.id), })); } const MITM_SERVER_URL = new URL("./server.cjs", import.meta.url); const urlPath = process.platform === "win32" && MITM_SERVER_URL.pathname.startsWith("/") ? decodeURIComponent(MITM_SERVER_URL.pathname.slice(1)) : decodeURIComponent(MITM_SERVER_URL.pathname); const cwdPath = path.join(process.cwd(), "src", "mitm", "server.cjs"); const MITM_SERVER_PATH = fs.existsSync(cwdPath) ? cwdPath : urlPath; // Check if a PID is alive function isProcessAlive(pid: number): boolean { try { process.kill(pid, 0); return true; } catch { return false; } } /** * Enumerate every hostname OmniRoute may have written to /etc/hosts during * startMitm(): the full agent-target registry plus all custom hosts. Removal * via removeDNSEntries() is idempotent (absent entries are skipped), so this * set is intentionally over-inclusive — a host that was never spoofed costs * nothing to "remove", but a host we forget to list leaks machine-wide. * (Gap 8 — clean-stop DNS leak.) */ export function collectManagedHosts(): string[] { const hosts = new Set(); for (const target of ALL_TARGETS) { for (const h of target.hosts) hosts.add(h); } try { for (const ch of listCustomHosts()) hosts.add(ch.host); } catch (err) { log.error({ err }, "collectManagedHosts: failed to read custom hosts (continuing)"); } return [...hosts]; } export interface RepairPlan { dnsHostsToRemove: string[]; removeCert: boolean; revertSystemProxy: boolean; } /** * Pure description of what a repair must undo. Separated from repairMitm() so * the enumeration is unit-testable without touching the OS or requiring sudo. * (Gap 7.) */ export function buildRepairPlan(): RepairPlan { return { dnsHostsToRemove: collectManagedHosts(), removeCert: true, revertSystemProxy: true, }; } /** * Best-effort revert of an applied system proxy. The applied state lives * in-memory (captureState), so this only succeeds within the same process that * applied it; after a crash the previousState is gone and this is a no-op. DNS * + cert teardown are always reversible because they read on-disk state. */ async function revertSystemProxyIfApplied(): Promise { try { const { getSystemProxyState, clearSystemProxy } = await import( "@/lib/inspector/captureState" ); const state = getSystemProxyState(); if (!state.applied || !state.previousState) return false; const { revert } = await import("./inspector/systemProxyConfig.ts"); await revert(state.previousState); clearSystemProxy(); return true; } catch (err) { log.error({ err }, "revertSystemProxyIfApplied failed (continuing)"); return false; } } /** * Undo every system mutation startMitm() may have made, WITHOUT requiring the * MITM server to be running. Safe to call when state is already clean (every * step is idempotent). Used by: the /repair route, the CLI cleanup subcommand, * and the stale-PID auto-repair on app startup. (Gap 7 — the application-layer * analogue of ProxyBridge's destructor + `--cleanup`.) */ export async function repairMitm(sudoPassword: string): Promise<{ repaired: string[] }> { const plan = buildRepairPlan(); const repaired: string[] = []; // 1. DNS — remove every host we may have spoofed (idempotent, reads /etc/hosts). try { await removeDNSEntry(sudoPassword); if (plan.dnsHostsToRemove.length > 0) { await removeDNSEntries(plan.dnsHostsToRemove, sudoPassword); } repaired.push("dns"); } catch (err) { log.error({ err }, "repairMitm: DNS cleanup failed (continuing)"); } // 2. Certificate — uninstall the MITM root CA from the trust store. if (plan.removeCert) { try { const certPath = path.join(resolveMitmDataDir(), "mitm", "server.crt"); if (fs.existsSync(certPath)) { await uninstallCert(sudoPassword, certPath); repaired.push("cert"); } } catch (err) { log.error({ err }, "repairMitm: cert removal failed (continuing)"); } } // 3. System proxy — best-effort revert if applied in this process. if (plan.revertSystemProxy) { if (await revertSystemProxyIfApplied()) repaired.push("system-proxy"); } // 4. Stale PID file. try { if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE); } catch { // ignore } clearCachedPassword(); _orphanedStateDetected = false; log.info({ repaired }, "repairMitm completed"); return { repaired }; } /** * Best-effort JS surrogate for ProxyBridge's library destructor + crash signal * handler. On SIGINT/SIGTERM we terminate the spawned child and warn that * privileged cleanup (DNS/CA/proxy) still requires a Repair — we have no sudo * password in a signal handler. Idempotent; never blocks process exit. (Gap 7.) */ export function installCleanupHandlers(): void { if (_cleanupHandlersInstalled) return; _cleanupHandlersInstalled = true; const onSignal = (signal: string) => { try { if (serverProcess && !serverProcess.killed) serverProcess.kill("SIGTERM"); } catch { // ignore } log.warn( { signal }, "MITM parent received signal — child terminated; run Repair if DNS/CA/proxy were applied." ); }; process.once("SIGINT", () => onSignal("SIGINT")); process.once("SIGTERM", () => onSignal("SIGTERM")); } /** * Get MITM status */ export async function getMitmStatus(): Promise<{ running: boolean; pid: number | null; dnsConfigured: boolean; certExists: boolean; orphanedStateDetected: boolean; }> { // Check in-memory process first, then fallback to PID file let running = serverProcess !== null && !serverProcess.killed; let pid = serverPid; if (!running) { try { if (fs.existsSync(PID_FILE)) { const savedPid = parseInt(fs.readFileSync(PID_FILE, "utf-8").trim(), 10); if (savedPid && isProcessAlive(savedPid)) { running = true; pid = savedPid; } else { // Stale PID file: the server died without clean teardown. We cannot // run privileged cleanup here (no sudo password in a status read), // so flag it for the dashboard to offer a one-click Repair. (Gap 7.) fs.unlinkSync(PID_FILE); _orphanedStateDetected = true; log.warn("Stale MITM PID file found — system state may be orphaned (offer Repair)."); } } } catch { // Ignore } } // Check DNS configuration let dnsConfigured = false; try { const hostsContent = fs.readFileSync("/etc/hosts", "utf-8"); dnsConfigured = /\bdaily-cloudcode-pa\.googleapis\.com\b/.test(hostsContent); } catch { // Ignore } // Check cert const certDir = path.join(resolveMitmDataDir(), "mitm"); const certExists = fs.existsSync(path.join(certDir, "server.crt")); return { running, pid, dnsConfigured, certExists, orphanedStateDetected: _orphanedStateDetected, }; } /** * Start MITM proxy * @param {string} apiKey - OmniRoute API key * @param {string} sudoPassword - Sudo password for DNS/cert operations */ export async function startMitm( apiKey: string, sudoPassword: string, options: { port?: number } = {} ): Promise<{ running: true; pid: number | null; certTrusted: boolean }> { // Check if already running if (serverProcess && !serverProcess.killed) { throw new Error("MITM proxy is already running"); } // Register best-effort teardown on parent SIGINT/SIGTERM (Gap 7). installCleanupHandlers(); // 0. Persist the canonical targets.json so server.cjs can pick up the full // AgentBridge target registry alongside its hard-coded antigravity baseline. try { writeTargetsJson(); } catch (err) { log.error({ err }, "Failed to write targets.json (continuing)"); } // 0b. Persist the user bypass patterns to bypass.json so server.cjs can // route CONNECT tunnels for those hostnames without TLS decryption. // Defaults (banks/gov/okta/auth0) are hard-coded in server.cjs. try { writeBypassJson(); } catch (err) { log.error({ err }, "Failed to write bypass.json (continuing)"); } // 0c. Apply upstream CA certificate (env var wins over stored path). // Spec: plan 11 §4.7 + acceptance criterion §12 #18. try { const storedCaPath = readStoredUpstreamCaPath(); const activeCaPath = process.env.AGENTBRIDGE_UPSTREAM_CA_CERT || storedCaPath; if (activeCaPath) { configureUpstreamCa(activeCaPath); log.info({ caPath: activeCaPath }, "Upstream CA certificate configured"); } } catch (err) { log.error( { err }, `AGENTBRIDGE_UPSTREAM_CA_CERT path invalid: ${(err as Error).message ?? err} (continuing without custom CA)` ); } // 1. Generate SSL certificate if not exists const certPath = path.join(resolveMitmDataDir(), "mitm", "server.crt"); if (!fs.existsSync(certPath)) { log.info("Generating SSL certificate..."); await generateCert(); } // 2. Install certificate to system keychain. A failure here must NOT abort the // bridge: in containers/headless the system trust store can't be written, // so we start in "untrusted" mode and let the operator trust the CA by hand // (mirrors the best-effort "continuing" pattern used for DNS below). (#4546) let certTrusted = false; try { const certResult = await installCertResult(sudoPassword, certPath); certTrusted = certResult.installed; if (!certResult.installed) { log.warn( { reason: certResult.reason }, "MITM cert not auto-trusted; bridge starting in skip mode (manual trust required)" ); } } catch (err) { log.error({ err }, "installCertResult threw unexpectedly (continuing without trusted cert)"); } // 3. Add DNS entries: Antigravity defaults + all agents with dns_enabled=true + // all custom hosts with enabled=true. log.info("Adding DNS entries..."); await addDNSEntry(sudoPassword); // Collect hosts from agents that have dns_enabled=true in the DB. try { const agentStates = getAllAgentBridgeStates(); const agentHostsToAdd: string[] = []; for (const state of agentStates) { if (!state.dns_enabled) continue; const target = ALL_TARGETS.find((t) => t.id === state.agent_id); if (target) { agentHostsToAdd.push(...target.hosts); } } if (agentHostsToAdd.length > 0) { log.info({ count: agentHostsToAdd.length }, "Adding DNS for agent host(s)..."); await addDNSEntries(agentHostsToAdd, sudoPassword); } } catch (err) { log.error({ err }, "Failed to add agent DNS entries (continuing)"); } // Collect enabled custom hosts. try { const customHosts = listCustomHosts({ enabledOnly: true }); const customHostNames = customHosts.map((h) => h.host); if (customHostNames.length > 0) { log.info({ count: customHostNames.length }, "Adding DNS for custom host(s)..."); await addDNSEntries(customHostNames, sudoPassword); } } catch (err) { log.error({ err }, "Failed to add custom host DNS entries (continuing)"); } // 4. Start MITM server log.info("Starting MITM server..."); const port = typeof options.port === "number" && Number.isInteger(options.port) && options.port > 0 && options.port <= 65535 ? options.port : 443; // D4 — resolve the inspector ingest token so the spawned proxy can post // captured AgentBridge traffic to the local-only ingest endpoint. The token // is shared with the OmniRoute process: getIngestTokenForBootstrap() returns // the same value the ingest route validates against (env or auto-generated). // Best-effort — if it cannot be resolved, the proxy simply skips capture. let ingestToken = process.env.INSPECTOR_INTERNAL_INGEST_TOKEN || ""; if (!ingestToken) { try { const ingestMod = await import( "@/app/api/tools/traffic-inspector/internal/ingest/route" ); if (typeof ingestMod.getIngestTokenForBootstrap === "function") { ingestToken = ingestMod.getIngestTokenForBootstrap(); } } catch (err) { log.warn({ err }, "Could not resolve inspector ingest token; capture disabled"); } } serverProcess = spawn(process.execPath, [MITM_SERVER_PATH], { env: { ...process.env, ROUTER_API_KEY: apiKey, MITM_LOCAL_PORT: String(port), INSPECTOR_INTERNAL_INGEST_TOKEN: ingestToken, NODE_ENV: "production", }, detached: false, stdio: ["ignore", "pipe", "pipe"], }); const proc = serverProcess; serverPid = proc.pid ?? null; // Save PID to file if (serverPid !== null) { fs.writeFileSync(PID_FILE, String(serverPid)); } // Buffer recent stderr so a startup failure can be reported with its real // cause (capped to avoid unbounded growth on a chatty/looping process). (#3606) let stderrBuffer = ""; // Log server output proc.stdout?.on("data", (data) => { log.info({ source: "mitm-server" }, data.toString().trim()); }); proc.stderr?.on("data", (data) => { const chunk = data.toString(); stderrBuffer = (stderrBuffer + chunk).slice(-4000); log.error({ source: "mitm-server" }, chunk.trim()); }); proc.on("exit", (code) => { log.info({ exitCode: code }, "MITM server exited"); serverProcess = null; serverPid = null; // Remove PID file try { fs.unlinkSync(PID_FILE); } catch (error) { // Ignore } }); // Wait and verify server actually started const started = await new Promise((resolve) => { let resolved = false; const timeout = setTimeout(() => { if (!resolved) { resolved = true; resolve(true); } }, 2000); proc.on("exit", () => { clearTimeout(timeout); if (!resolved) { resolved = true; resolve(false); } }); // Fail fast on any "❌" diagnostic line from server.cjs (covers EADDRINUSE, // EACCES, missing ROUTER_API_KEY, and any other server.on("error") cause). proc.stderr?.on("data", (data) => { const msg = data.toString(); if (msg.includes("❌")) { clearTimeout(timeout); if (!resolved) { resolved = true; resolve(false); } } }); }); if (!started) { throw new Error(interpretMitmStartupError(stderrBuffer, port)); } return { running: true, pid: serverPid, certTrusted, }; } /** * Stop MITM proxy * @param {string} sudoPassword - Sudo password for DNS cleanup */ export async function stopMitm(sudoPassword: string): Promise<{ running: false; pid: null }> { // 1. Kill server process (in-memory or from PID file) const proc = serverProcess; if (proc && !proc.killed) { log.info("Stopping MITM server..."); proc.kill("SIGTERM"); await new Promise((resolve) => setTimeout(resolve, 1000)); if (!proc.killed) { proc.kill("SIGKILL"); } serverProcess = null; serverPid = null; } else { // Fallback: kill by PID file try { if (fs.existsSync(PID_FILE)) { const savedPid = parseInt(fs.readFileSync(PID_FILE, "utf-8").trim(), 10); if (savedPid && isProcessAlive(savedPid)) { log.info({ pid: savedPid }, "Killing MITM server by PID..."); process.kill(savedPid, "SIGTERM"); await new Promise((resolve) => setTimeout(resolve, 1000)); if (isProcessAlive(savedPid)) { process.kill(savedPid, "SIGKILL"); } } } } catch { // Ignore } serverProcess = null; serverPid = null; } // 2. Remove DNS entries — Antigravity defaults PLUS every agent + custom host // that startMitm() may have spoofed. removeDNSEntries is idempotent, so // over-inclusion is safe; under-inclusion leaks /etc/hosts lines that // hijack resolution machine-wide after stop (Gap 8). log.info("Removing DNS entries..."); await removeDNSEntry(sudoPassword); try { const managed = collectManagedHosts(); if (managed.length > 0) { await removeDNSEntries(managed, sudoPassword); } } catch (err) { log.error({ err }, "Failed to remove managed DNS entries during stop (continuing)"); } // 3. Clean up clearCachedPassword(); // Clear password from memory when proxy stops try { fs.unlinkSync(PID_FILE); } catch (error) { // Ignore } return { running: false, pid: null, }; }