diff --git a/src/app/api/tools/agent-bridge/cert/route.ts b/src/app/api/tools/agent-bridge/cert/route.ts index 2797c8088b..a235026cd5 100644 --- a/src/app/api/tools/agent-bridge/cert/route.ts +++ b/src/app/api/tools/agent-bridge/cert/route.ts @@ -4,7 +4,7 @@ * LOCAL_ONLY: registered in routeGuard.ts */ import { z } from "zod"; -import { installCert, checkCertInstalled } from "@/mitm/cert/install"; +import { installCert, uninstallCert, checkCertInstalled } from "@/mitm/cert/install"; import { resolveMitmDataDir } from "@/mitm/dataDir"; import { getCachedPassword } from "@/mitm/manager"; import path from "path"; @@ -56,3 +56,32 @@ export async function POST(request: Request): Promise { return createErrorResponse({ status: 500, message: msg }); } } + +/** + * DELETE /api/tools/agent-bridge/cert — untrust (uninstall) the MITM root CA. + * + * OmniRoute keeps the CA installed across normal stop/start to avoid repeated + * sudo prompts (same as mitmproxy/Charles), so removal is an explicit action. + * Idempotent: removing an absent cert reports success. (Gap 9 — a persistent + * always-trusted MITM root CA whose key lives on disk is an attack surface.) + */ +export async function DELETE(request: Request): Promise { + const raw = await request.json().catch(() => ({})); + const parsed = CertTrustBodySchema.safeParse(raw); + const sudoPassword = + (parsed.success ? parsed.data.sudoPassword : undefined) ?? getCachedPassword() ?? ""; + + try { + const crtPath = certPath(); + if (!fs.existsSync(crtPath)) { + // No cert on disk → nothing to untrust. Idempotent success. + return Response.json({ ok: true, trusted: false }); + } + await uninstallCert(sudoPassword, crtPath); + const trusted = await checkCertInstalled(crtPath); + return Response.json({ ok: true, trusted }); + } catch (err) { + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return createErrorResponse({ status: 500, message: msg }); + } +} diff --git a/src/app/api/tools/agent-bridge/repair/route.ts b/src/app/api/tools/agent-bridge/repair/route.ts new file mode 100644 index 0000000000..dbaa9afb31 --- /dev/null +++ b/src/app/api/tools/agent-bridge/repair/route.ts @@ -0,0 +1,35 @@ +/** + * POST /api/tools/agent-bridge/repair + * + * Undo orphaned MITM system state (DNS spoof entries, root CA, system proxy) + * left behind by a crash or SIGKILL. Idempotent — safe to call when state is + * already clean. LOCAL_ONLY: covered by the "/api/tools/agent-bridge/" prefix + * in routeGuard.ts (Hard Rules #15 + #17). + * + * Gap 7 — the application-layer analogue of ProxyBridge's `--cleanup` flag. + */ +import { z } from "zod"; +import { repairMitm, getCachedPassword } from "@/mitm/manager"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; +import { createErrorResponse } from "@/lib/api/errorResponse"; + +// Exported for unit testing. Next.js only treats GET/POST/etc. as route +// handlers; additional named exports are ignored by the App Router. +export const RepairBodySchema = z.object({ + sudoPassword: z.string().optional(), +}); + +export async function POST(request: Request): Promise { + const raw = await request.json().catch(() => ({})); + const parsed = RepairBodySchema.safeParse(raw); + const sudoPassword = + (parsed.success ? parsed.data.sudoPassword : undefined) ?? getCachedPassword() ?? ""; + + try { + const result = await repairMitm(sudoPassword); + return Response.json({ ok: true, repaired: result.repaired }); + } catch (err) { + const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err)); + return createErrorResponse({ status: 500, message: msg }); + } +} diff --git a/src/mitm/inspector/httpProxyServer.ts b/src/mitm/inspector/httpProxyServer.ts index 5fdb7dc80b..d1a10dd401 100644 --- a/src/mitm/inspector/httpProxyServer.ts +++ b/src/mitm/inspector/httpProxyServer.ts @@ -19,6 +19,7 @@ import { randomUUID } from "node:crypto"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; import { sanitizeHeaders } from "../sanitizeHeaders.ts"; import { maskSecret } from "../maskSecrets.ts"; +import { applyIdleTimeout, MITM_IDLE_TIMEOUT_MS } from "../socketTimeouts.ts"; import { globalTrafficBuffer } from "./buffer.ts"; import type { InterceptedRequest } from "./types.ts"; @@ -238,6 +239,13 @@ export function startHttpProxyServer(port: number = DEFAULT_PORT): Promise { const server = http.createServer(); + // Bound request/idle lifetimes + reap idle sockets so hung tunnels cannot + // exhaust file descriptors under load (Gap 10). + server.requestTimeout = MITM_IDLE_TIMEOUT_MS * 5; + server.headersTimeout = MITM_IDLE_TIMEOUT_MS; + server.keepAliveTimeout = MITM_IDLE_TIMEOUT_MS; + server.on("connection", (socket) => applyIdleTimeout(socket)); + server.on("request", (req, res) => handleHttp(req, res)); server.on("connect", (req, socket, head) => handleConnect(req, socket as net.Socket, head)); diff --git a/src/mitm/manager.stub.ts b/src/mitm/manager.stub.ts index f8a401224d..09bd784c49 100644 --- a/src/mitm/manager.stub.ts +++ b/src/mitm/manager.stub.ts @@ -16,7 +16,18 @@ export const getCachedPassword = () => null; export const setCachedPassword = (_pwd: string) => {}; export const clearCachedPassword = () => {}; export const getMitmStatus = async () => - ({ running: false, pid: null, dnsConfigured: false, certExists: false }) as const; + ({ + running: false, + pid: null, + dnsConfigured: false, + certExists: false, + orphanedStateDetected: false, + }) as const; +// Repair is a no-op in the bundled (Docker) build: the container has no MITM +// system state to undo, so "repairing nothing" trivially succeeds rather than +// throwing (mirrors getMitmStatus's graceful-degradation contract). (Gap 7.) +export const repairMitm = async (_sudoPassword: string): Promise<{ repaired: string[] }> => + ({ repaired: [] }); // Must be exported or the Turbopack build fails ("Export getAllAgentsStatus doesn't // exist") — /api/tools/agent-bridge/state imports it statically. Returns the truthful // empty agent list in the bundled build rather than throwing (see file header). See #3066. diff --git a/src/mitm/manager.ts b/src/mitm/manager.ts index 38bf86ef57..43f330291c 100644 --- a/src/mitm/manager.ts +++ b/src/mitm/manager.ts @@ -2,9 +2,9 @@ import { spawn, type ChildProcess } from "child_process"; import path from "path"; import fs from "fs"; import { resolveMitmDataDir } from "./dataDir.ts"; -import { addDNSEntry, addDNSEntries, removeDNSEntry } from "./dns/dnsConfig.ts"; +import { addDNSEntry, addDNSEntries, removeDNSEntry, removeDNSEntries } from "./dns/dnsConfig.ts"; import { generateCert } from "./cert/generate.ts"; -import { installCert } from "./cert/install.ts"; +import { installCert, 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"; @@ -56,6 +56,15 @@ export function interpretMitmStartupError(stderr: string, port: number): string 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; @@ -182,6 +191,146 @@ function isProcessAlive(pid: number): boolean { } } +/** + * 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 */ @@ -190,6 +339,7 @@ export async function getMitmStatus(): Promise<{ 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; @@ -203,8 +353,12 @@ export async function getMitmStatus(): Promise<{ running = true; pid = savedPid; } else { - // Stale PID file, clean up + // 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 { @@ -225,7 +379,13 @@ export async function getMitmStatus(): Promise<{ const certDir = path.join(resolveMitmDataDir(), "mitm"); const certExists = fs.existsSync(path.join(certDir, "server.crt")); - return { running, pid, dnsConfigured, certExists }; + return { + running, + pid, + dnsConfigured, + certExists, + orphanedStateDetected: _orphanedStateDetected, + }; } /** @@ -243,6 +403,9 @@ export async function startMitm( 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 { @@ -457,9 +620,20 @@ export async function stopMitm(sudoPassword: string): Promise<{ running: false; serverPid = null; } - // 2. Remove DNS entry - log.info("Removing DNS entry..."); + // 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 diff --git a/src/mitm/server.cjs b/src/mitm/server.cjs index eb8890999a..55a9aaf821 100644 --- a/src/mitm/server.cjs +++ b/src/mitm/server.cjs @@ -34,6 +34,11 @@ const LOCAL_PORT = Number.isInteger(parsedLocalPort) && parsedLocalPort > 0 && parsedLocalPort <= 65535 ? parsedLocalPort : 443; +// Idle timeout for sockets/tunnels. Mirrors ProxyBridge's 60s relay timeout so +// hung/half-open connections cannot accumulate and exhaust fds. (Gap 10.) +const parsedIdleTimeout = Number.parseInt(process.env.MITM_IDLE_TIMEOUT_MS || "60000", 10); +const MITM_IDLE_TIMEOUT_MS = + Number.isInteger(parsedIdleTimeout) && parsedIdleTimeout > 0 ? parsedIdleTimeout : 60000; const ROUTER_BASE_URL = ( process.env.OMNIROUTE_BASE_URL || process.env.BASE_URL || @@ -513,6 +518,14 @@ function rawTcpForward(clientSocket, head, host, port, label) { if (head && head.length > 0) targetSocket.write(head); targetSocket.pipe(clientSocket); clientSocket.pipe(targetSocket); + // Reap a half-open/hung tunnel after the idle timeout so neither side leaks + // an fd when the upstream never sends FIN/RST (Gap 10). + const destroyBoth = () => { + clientSocket.destroy(); + targetSocket.destroy(); + }; + clientSocket.setTimeout(MITM_IDLE_TIMEOUT_MS, destroyBoth); + targetSocket.setTimeout(MITM_IDLE_TIMEOUT_MS, destroyBoth); }); // Best-effort cleanup; never crash the proxy on tunnel errors. @@ -598,6 +611,12 @@ server.on("connect", (req, clientSocket, head) => { rawTcpForward(clientSocket, head, connectHost, connectPort, "passthrough"); }); +// Bound full-request / header / keep-alive lifetimes so a slow or hung client +// cannot pin a connection indefinitely (Gap 10). +server.requestTimeout = MITM_IDLE_TIMEOUT_MS * 5; // hard cap on a full request +server.headersTimeout = MITM_IDLE_TIMEOUT_MS; // time allowed to send headers +server.keepAliveTimeout = MITM_IDLE_TIMEOUT_MS; // idle keep-alive window + server.listen(LOCAL_PORT, () => { stats.startedAt = new Date().toISOString(); writeStats(); @@ -609,6 +628,8 @@ server.on("connection", (socket) => { // already-counted socket into the TLS layer via emit("connection") above. if (socket.__mitmCounted) return; socket.__mitmCounted = true; + // Reap idle sockets so hung connections cannot exhaust fds (Gap 10). + socket.setTimeout(MITM_IDLE_TIMEOUT_MS, () => socket.destroy()); stats.activeConnections++; writeStats(); socket.on("close", () => { diff --git a/src/mitm/socketTimeouts.ts b/src/mitm/socketTimeouts.ts new file mode 100644 index 0000000000..9e0417f4af --- /dev/null +++ b/src/mitm/socketTimeouts.ts @@ -0,0 +1,24 @@ +/** + * Shared socket idle-timeout helper for the MITM proxy + HTTP-proxy listeners. + * + * Mirrors ProxyBridge's 60s relay idle timeout (ProxyBridge.c uses + * poll(..., 60000) on every relay connection) so hung / half-open tunnels — + * dropped Wi-Fi, dead upstreams that never send FIN/RST — cannot accumulate and + * exhaust file descriptors, wedging the proxy under real agent traffic. (Gap 10.) + */ +import type { Socket } from "node:net"; + +function parseEnvNumber(value: string | undefined, fallback: number): number { + if (!value) return fallback; + const n = Number(value); + return Number.isFinite(n) && n > 0 ? n : fallback; +} + +export const MITM_IDLE_TIMEOUT_MS = parseEnvNumber(process.env.MITM_IDLE_TIMEOUT_MS, 60000); + +/** Destroy `socket` if it is idle (no I/O) for `ms` milliseconds. */ +export function applyIdleTimeout(socket: Socket, ms: number = MITM_IDLE_TIMEOUT_MS): void { + socket.setTimeout(ms, () => { + socket.destroy(); + }); +} diff --git a/tests/unit/agent-bridge-repair-route-validation.test.ts b/tests/unit/agent-bridge-repair-route-validation.test.ts new file mode 100644 index 0000000000..3b19b88e5e --- /dev/null +++ b/tests/unit/agent-bridge-repair-route-validation.test.ts @@ -0,0 +1,28 @@ +/** + * POST /api/tools/agent-bridge/repair validates its body with RepairBodySchema + * via safeParse (route validation gate t06) and falls back to the cached sudo + * password when none is supplied. These tests pin that schema contract. (Gap 7.) + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { RepairBodySchema } = await import( + "../../src/app/api/tools/agent-bridge/repair/route.ts" +); + +test("accepts a body with a string sudoPassword", () => { + const parsed = RepairBodySchema.safeParse({ sudoPassword: "hunter2" }); + assert.equal(parsed.success, true); + assert.equal(parsed.success && parsed.data.sudoPassword, "hunter2"); +}); + +test("accepts an empty body (sudoPassword optional, falls back to cached)", () => { + const parsed = RepairBodySchema.safeParse({}); + assert.equal(parsed.success, true); + assert.equal(parsed.success && parsed.data.sudoPassword, undefined); +}); + +test("rejects a non-string sudoPassword instead of trusting raw input", () => { + const parsed = RepairBodySchema.safeParse({ sudoPassword: 12345 }); + assert.equal(parsed.success, false); +}); diff --git a/tests/unit/mitm-cert-removal-wiring.test.ts b/tests/unit/mitm-cert-removal-wiring.test.ts new file mode 100644 index 0000000000..304d94d58e --- /dev/null +++ b/tests/unit/mitm-cert-removal-wiring.test.ts @@ -0,0 +1,34 @@ +/** + * Gap 9 regression: uninstallCert() was fully implemented but had ZERO + * production call sites — the OmniRoute root CA stayed trusted machine-wide + * forever after MITM was disabled. These tests pin the wiring contract: + * (a) the cert module exports uninstallCert + checkCertInstalled, and + * (b) the cert route now exposes a DELETE handler that performs the removal. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const certModule = await import("../../src/mitm/cert/install.ts"); +const certRoute = await import( + "../../src/app/api/tools/agent-bridge/cert/route.ts" +); + +test("cert module exports uninstallCert", () => { + assert.equal(typeof certModule.uninstallCert, "function", "uninstallCert must be exported"); +}); + +test("cert module exports checkCertInstalled for status UX", () => { + assert.equal( + typeof certModule.checkCertInstalled, + "function", + "checkCertInstalled must be exported" + ); +}); + +test("cert route exposes a DELETE handler that removes the trusted CA", () => { + assert.equal( + typeof certRoute.DELETE, + "function", + "the cert route must export a DELETE handler so the CA can be untrusted on demand (Gap 9)" + ); +}); diff --git a/tests/unit/mitm-manager-cleanup-symmetry.test.ts b/tests/unit/mitm-manager-cleanup-symmetry.test.ts new file mode 100644 index 0000000000..1175da20c4 --- /dev/null +++ b/tests/unit/mitm-manager-cleanup-symmetry.test.ts @@ -0,0 +1,83 @@ +/** + * Gap 8 regression: every host OmniRoute can spoof in startMitm() must be + * enumerated by collectManagedHosts() so stopMitm() can remove it. Without + * this, agent + custom-host /etc/hosts lines leak across start/stop cycles and + * keep hijacking those hostnames machine-wide after the user thinks MITM is off. + * + * Uses the project's DATA_DIR-tmp + resetDbInstance pattern so the Node native + * test runner does not hang on open SQLite handles (CLAUDE.md PII learning #3). + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync( + path.join(os.tmpdir(), "omniroute-mitm-cleanup-symmetry-") +); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const customHostsDb = await import("../../src/lib/db/inspectorCustomHosts.ts"); +const manager = await import("../../src/mitm/manager.ts"); +const { ALL_TARGETS } = await import("../../src/mitm/targets/index.ts"); + +async function resetStorage() { + core.resetDbInstance(); + for (let attempt = 0; attempt < 10; attempt++) { + try { + if (fs.existsSync(TEST_DATA_DIR)) { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } + break; + } catch (error: unknown) { + const code = (error as { code?: string } | null)?.code; + if ((code === "EBUSY" || code === "EPERM") && attempt < 9) { + await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); + } else { + throw error; + } + } + } + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("collectManagedHosts includes every host of every agent target", () => { + const managed = new Set(manager.collectManagedHosts()); + for (const target of ALL_TARGETS) { + for (const host of target.hosts) { + assert.ok( + managed.has(host), + `managed host set is missing agent host "${host}" (${target.id}) — it would leak in /etc/hosts after stop` + ); + } + } +}); + +test("collectManagedHosts returns a de-duplicated list", () => { + const list = manager.collectManagedHosts(); + assert.equal( + list.length, + new Set(list).size, + "collectManagedHosts must not return duplicates" + ); +}); + +test("collectManagedHosts includes custom hosts persisted in the DB", () => { + customHostsDb.addCustomHost("api.my-internal-llm.test", "custom"); + const managed = new Set(manager.collectManagedHosts()); + assert.ok( + managed.has("api.my-internal-llm.test"), + "a custom host added to the DB must be enumerated for cleanup" + ); +}); diff --git a/tests/unit/mitm-manager-repair.test.ts b/tests/unit/mitm-manager-repair.test.ts new file mode 100644 index 0000000000..4a72a7f1f4 --- /dev/null +++ b/tests/unit/mitm-manager-repair.test.ts @@ -0,0 +1,72 @@ +/** + * Gap 7 regression: repairMitm() must be able to undo every system mutation + * startMitm() makes. buildRepairPlan() is the pure, testable description of + * that teardown (DNS hosts to remove + cert removal + system-proxy revert), + * separated from repairMitm() so the enumeration is unit-testable without + * touching the OS or requiring sudo. + * + * DATA_DIR-tmp + resetDbInstance pattern prevents the Node test runner from + * hanging on open SQLite handles (CLAUDE.md PII learning #3). + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync( + path.join(os.tmpdir(), "omniroute-mitm-repair-") +); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const manager = await import("../../src/mitm/manager.ts"); + +async function resetStorage() { + core.resetDbInstance(); + for (let attempt = 0; attempt < 10; attempt++) { + try { + if (fs.existsSync(TEST_DATA_DIR)) { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } + break; + } catch (error: unknown) { + const code = (error as { code?: string } | null)?.code; + if ((code === "EBUSY" || code === "EPERM") && attempt < 9) { + await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); + } else { + throw error; + } + } + } + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("buildRepairPlan enumerates DNS hosts and the CA + proxy teardown steps", () => { + const plan = manager.buildRepairPlan(); + assert.ok(Array.isArray(plan.dnsHostsToRemove), "plan.dnsHostsToRemove must be an array"); + assert.ok( + plan.dnsHostsToRemove.length > 0, + "must remove at least the agent target hosts" + ); + assert.equal(plan.removeCert, true, "repair must include CA removal"); + assert.equal(plan.revertSystemProxy, true, "repair must attempt system-proxy revert"); +}); + +test("buildRepairPlan reuses collectManagedHosts (same managed host set)", () => { + const plan = manager.buildRepairPlan(); + assert.deepEqual( + [...plan.dnsHostsToRemove].sort(), + [...manager.collectManagedHosts()].sort(), + "repair must target exactly the managed host set so teardown stays symmetric" + ); +}); diff --git a/tests/unit/mitm-server-timeouts.test.ts b/tests/unit/mitm-server-timeouts.test.ts new file mode 100644 index 0000000000..e364e9c595 --- /dev/null +++ b/tests/unit/mitm-server-timeouts.test.ts @@ -0,0 +1,44 @@ +/** + * Gap 10: idle sockets must be destroyed after MITM_IDLE_TIMEOUT_MS so hung + * tunnels cannot exhaust file descriptors. We test the pure helper against a + * fake socket (no real network). + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { applyIdleTimeout, MITM_IDLE_TIMEOUT_MS } from "../../src/mitm/socketTimeouts.ts"; + +test("MITM_IDLE_TIMEOUT_MS defaults to 60s (matches ProxyBridge relay timeout)", () => { + assert.equal(MITM_IDLE_TIMEOUT_MS, 60000); +}); + +test("applyIdleTimeout sets the timeout and destroys the socket on fire", () => { + let setMs = 0; + let destroyed = false; + let timeoutCb: (() => void) | null = null; + const fakeSocket = { + setTimeout(ms: number, cb: () => void) { + setMs = ms; + timeoutCb = cb; + }, + destroy() { + destroyed = true; + }, + }; + applyIdleTimeout(fakeSocket as never, 1234); + assert.equal(setMs, 1234, "must call setTimeout with the given ms"); + assert.equal(typeof timeoutCb, "function"); + (timeoutCb as unknown as () => void)(); + assert.equal(destroyed, true, "must destroy the socket when the idle timeout fires"); +}); + +test("applyIdleTimeout uses MITM_IDLE_TIMEOUT_MS by default", () => { + let setMs = 0; + const fakeSocket = { + setTimeout(ms: number) { + setMs = ms; + }, + destroy() {}, + }; + applyIdleTimeout(fakeSocket as never); + assert.equal(setMs, MITM_IDLE_TIMEOUT_MS, "default must be the module constant"); +});