mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 22:02:08 +03:00
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:
committed by
GitHub
parent
4ba27c17da
commit
5ea160140f
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
35
src/app/api/tools/agent-bridge/repair/route.ts
Normal file
35
src/app/api/tools/agent-bridge/repair/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
24
src/mitm/socketTimeouts.ts
Normal file
24
src/mitm/socketTimeouts.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
28
tests/unit/agent-bridge-repair-route-validation.test.ts
Normal file
28
tests/unit/agent-bridge-repair-route-validation.test.ts
Normal 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);
|
||||
});
|
||||
34
tests/unit/mitm-cert-removal-wiring.test.ts
Normal file
34
tests/unit/mitm-cert-removal-wiring.test.ts
Normal 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)"
|
||||
);
|
||||
});
|
||||
83
tests/unit/mitm-manager-cleanup-symmetry.test.ts
Normal file
83
tests/unit/mitm-manager-cleanup-symmetry.test.ts
Normal 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"
|
||||
);
|
||||
});
|
||||
72
tests/unit/mitm-manager-repair.test.ts
Normal file
72
tests/unit/mitm-manager-repair.test.ts
Normal 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"
|
||||
);
|
||||
});
|
||||
44
tests/unit/mitm-server-timeouts.test.ts
Normal file
44
tests/unit/mitm-server-timeouts.test.ts
Normal 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");
|
||||
});
|
||||
Reference in New Issue
Block a user