fix(mitm): crash-safe system-state teardown + socket timeouts (ProxyBridge-inspired hardening) (#4084)

Integrated into release/v3.8.28 (Fast QG TIA red = 3 pre-existing timing flakes verified passing locally 82/82; PR own tests green)
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-17 14:57:40 -03:00
committed by GitHub
parent 4ba27c17da
commit 5ea160140f
12 changed files with 571 additions and 8 deletions

View File

@@ -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<Response> {
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<Response> {
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 });
}
}

View File

@@ -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<Response> {
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 });
}
}

View File

@@ -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<HttpP
return new Promise((resolve, reject) => {
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));

View File

@@ -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.

View File

@@ -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<string>();
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<boolean> {
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

View File

@@ -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", () => {

View File

@@ -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();
});
}

View File

@@ -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);
});

View File

@@ -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)"
);
});

View File

@@ -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"
);
});

View File

@@ -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"
);
});

View File

@@ -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");
});