From c16ce8a9e14a47d1998d3526edd2cf6dcd8e85ac Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Thu, 28 May 2026 01:24:20 -0300 Subject: [PATCH] feat(api): traffic-inspector hosts + capture-modes + tls routes (F6) --- .../capture-modes/http-proxy/route.ts | 87 ++++++++++++++++++ .../traffic-inspector/capture-modes/route.ts | 53 +++++++++++ .../capture-modes/system-proxy/route.ts | 92 +++++++++++++++++++ .../capture-modes/tls-intercept/route.ts | 39 ++++++++ .../traffic-inspector/hosts/[host]/route.ts | 77 ++++++++++++++++ .../tools/traffic-inspector/hosts/route.ts | 61 ++++++++++++ src/lib/inspector/captureState.ts | 90 ++++++++++++++++++ 7 files changed, 499 insertions(+) create mode 100644 src/app/api/tools/traffic-inspector/capture-modes/http-proxy/route.ts create mode 100644 src/app/api/tools/traffic-inspector/capture-modes/route.ts create mode 100644 src/app/api/tools/traffic-inspector/capture-modes/system-proxy/route.ts create mode 100644 src/app/api/tools/traffic-inspector/capture-modes/tls-intercept/route.ts create mode 100644 src/app/api/tools/traffic-inspector/hosts/[host]/route.ts create mode 100644 src/app/api/tools/traffic-inspector/hosts/route.ts create mode 100644 src/lib/inspector/captureState.ts diff --git a/src/app/api/tools/traffic-inspector/capture-modes/http-proxy/route.ts b/src/app/api/tools/traffic-inspector/capture-modes/http-proxy/route.ts new file mode 100644 index 0000000000..6a873df386 --- /dev/null +++ b/src/app/api/tools/traffic-inspector/capture-modes/http-proxy/route.ts @@ -0,0 +1,87 @@ +/** + * POST /api/tools/traffic-inspector/capture-modes/http-proxy + * + * Start or stop the HTTP_PROXY listener (default port 8080). + * `EADDRINUSE` is surfaced as 409 with a structured error body. + * + * LOCAL_ONLY enforced by routeGuard. + */ + +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; +import { InspectorCaptureModeActionSchema } from "@/shared/schemas/inspector"; +import { startHttpProxyServer } from "@/mitm/inspector/httpProxyServer"; +import { getHttpProxyHandle, setHttpProxyHandle } from "@/lib/inspector/captureState"; + +const DEFAULT_PORT = Number(process.env.INSPECTOR_HTTP_PROXY_PORT ?? "8080") || 8080; + +export async function POST(request: Request): Promise { + let body: unknown; + try { + body = await request.json(); + } catch { + return new Response(JSON.stringify(buildErrorBody(400, "Invalid JSON body")), { + status: 400, + headers: { "content-type": "application/json" }, + }); + } + + const parsed = InspectorCaptureModeActionSchema.safeParse(body); + if (!parsed.success) { + return new Response( + JSON.stringify(buildErrorBody(400, parsed.error.issues[0]?.message ?? "Validation error")), + { status: 400, headers: { "content-type": "application/json" } } + ); + } + + const { action } = parsed.data; + + if (action === "stop") { + const handle = getHttpProxyHandle(); + if (!handle) { + return Response.json({ ok: true, running: false, port: null }); + } + try { + await handle.stop(); + setHttpProxyHandle(null); + return Response.json({ ok: true, running: false, port: null }); + } catch (err) { + const msg = sanitizeErrorMessage(err); + return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to stop HTTP proxy")), { + status: 500, + headers: { "content-type": "application/json" }, + }); + } + } + + // action === "start" + const existing = getHttpProxyHandle(); + if (existing) { + return Response.json({ ok: true, running: true, port: existing.port }); + } + + try { + const handle = await startHttpProxyServer(DEFAULT_PORT); + setHttpProxyHandle(handle); + return Response.json({ ok: true, running: true, port: handle.port }, { status: 201 }); + } catch (err) { + const nodeErr = err as NodeJS.ErrnoException; + if (nodeErr?.code === "EADDRINUSE") { + return new Response( + JSON.stringify({ + error: { + message: `Port ${DEFAULT_PORT} is already in use`, + type: "conflict", + code: "EADDRINUSE", + port: DEFAULT_PORT, + }, + }), + { status: 409, headers: { "content-type": "application/json" } } + ); + } + const msg = sanitizeErrorMessage(err); + return new Response( + JSON.stringify(buildErrorBody(500, msg || "Failed to start HTTP proxy")), + { status: 500, headers: { "content-type": "application/json" } } + ); + } +} diff --git a/src/app/api/tools/traffic-inspector/capture-modes/route.ts b/src/app/api/tools/traffic-inspector/capture-modes/route.ts new file mode 100644 index 0000000000..b792a8efdb --- /dev/null +++ b/src/app/api/tools/traffic-inspector/capture-modes/route.ts @@ -0,0 +1,53 @@ +/** + * GET /api/tools/traffic-inspector/capture-modes + * + * Returns the current status of all 4 capture modes: + * 1. agentBridge — always active when the MITM server is running + * 2. customHosts — count from DB + * 3. httpProxy — running flag + port + * 4. systemProxy — applied flag + guardUntil + * + * LOCAL_ONLY enforced by routeGuard. + */ + +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; +import { listCustomHosts } from "@/lib/db/inspectorCustomHosts"; +import { + getHttpProxyHandle, + getSystemProxyState, + isTlsInterceptEnabled, +} from "@/lib/inspector/captureState"; + +export async function GET(): Promise { + try { + const customHosts = listCustomHosts(); + const httpProxy = getHttpProxyHandle(); + const systemProxy = getSystemProxyState(); + + return Response.json({ + agentBridge: true, + customHosts: { + count: customHosts.length, + enabledCount: customHosts.filter((h) => h.enabled).length, + }, + httpProxy: { + running: httpProxy !== null, + port: httpProxy?.port ?? null, + }, + systemProxy: { + applied: systemProxy.applied, + guardUntil: systemProxy.guardUntil, + port: systemProxy.port, + }, + tlsIntercept: { + enabled: isTlsInterceptEnabled(), + }, + }); + } catch (err) { + const msg = sanitizeErrorMessage(err); + return new Response( + JSON.stringify(buildErrorBody(500, msg || "Failed to get capture mode status")), + { status: 500, headers: { "content-type": "application/json" } } + ); + } +} diff --git a/src/app/api/tools/traffic-inspector/capture-modes/system-proxy/route.ts b/src/app/api/tools/traffic-inspector/capture-modes/system-proxy/route.ts new file mode 100644 index 0000000000..0f4866d65f --- /dev/null +++ b/src/app/api/tools/traffic-inspector/capture-modes/system-proxy/route.ts @@ -0,0 +1,92 @@ +/** + * POST /api/tools/traffic-inspector/capture-modes/system-proxy + * + * Apply or revert the OS-level system proxy. + * + * `apply` — sets the system proxy to 127.0.0.1: and saves the + * prior state so it can be restored. Starts a guard timer that + * auto-reverts after `guardMinutes` (default 30). + * + * `revert` — restores the previously saved proxy state. + * + * Hard Rule #13: all shell invocations happen in `systemProxyConfig.ts` using + * `execFile` with array args — no interpolation here. + * + * LOCAL_ONLY enforced by routeGuard. + */ + +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; +import { InspectorSystemProxyActionSchema } from "@/shared/schemas/inspector"; +import { apply, revert } from "@/mitm/inspector/systemProxyConfig"; +import { + getSystemProxyState, + setSystemProxyApplied, + clearSystemProxy, +} from "@/lib/inspector/captureState"; + +const DEFAULT_PORT = Number(process.env.INSPECTOR_HTTP_PROXY_PORT ?? "8080") || 8080; +const DEFAULT_GUARD_MINUTES = Number( + process.env.INSPECTOR_SYSTEM_PROXY_GUARD_MINUTES ?? "30" +) || 30; + +export async function POST(request: Request): Promise { + let body: unknown; + try { + body = await request.json(); + } catch { + return new Response(JSON.stringify(buildErrorBody(400, "Invalid JSON body")), { + status: 400, + headers: { "content-type": "application/json" }, + }); + } + + const parsed = InspectorSystemProxyActionSchema.safeParse(body); + if (!parsed.success) { + return new Response( + JSON.stringify(buildErrorBody(400, parsed.error.issues[0]?.message ?? "Validation error")), + { status: 400, headers: { "content-type": "application/json" } } + ); + } + + const { action, port, guardMinutes } = parsed.data; + const resolvedPort = port ?? DEFAULT_PORT; + const resolvedGuard = guardMinutes ?? DEFAULT_GUARD_MINUTES; + + if (action === "revert") { + const state = getSystemProxyState(); + const previousState = state.previousState; + + try { + if (previousState) { + await revert(previousState); + } + clearSystemProxy(); + return Response.json({ ok: true, applied: false }); + } catch (err) { + const msg = sanitizeErrorMessage(err); + return new Response( + JSON.stringify(buildErrorBody(500, msg || "Failed to revert system proxy")), + { status: 500, headers: { "content-type": "application/json" } } + ); + } + } + + // action === "apply" + try { + const result = await apply(resolvedPort); + setSystemProxyApplied(resolvedPort, result.previousState, resolvedGuard); + return Response.json({ + ok: true, + applied: true, + port: resolvedPort, + platform: result.platform, + guardUntil: getSystemProxyState().guardUntil, + }); + } catch (err) { + const msg = sanitizeErrorMessage(err); + return new Response( + JSON.stringify(buildErrorBody(500, msg || "Failed to apply system proxy")), + { status: 500, headers: { "content-type": "application/json" } } + ); + } +} diff --git a/src/app/api/tools/traffic-inspector/capture-modes/tls-intercept/route.ts b/src/app/api/tools/traffic-inspector/capture-modes/tls-intercept/route.ts new file mode 100644 index 0000000000..43334ea563 --- /dev/null +++ b/src/app/api/tools/traffic-inspector/capture-modes/tls-intercept/route.ts @@ -0,0 +1,39 @@ +/** + * POST /api/tools/traffic-inspector/capture-modes/tls-intercept + * + * Toggle TLS body decryption in the MITM proxy. When enabled, the MITM + * server decrypts HTTPS bodies and the Traffic Inspector can show full + * request/response content. When disabled, CONNECT tunnels are passed through + * and only metadata is captured. + * + * State is held in the `captureState` module (process-lifetime). + * + * LOCAL_ONLY enforced by routeGuard. + */ + +import { buildErrorBody } from "@omniroute/open-sse/utils/error.ts"; +import { InspectorTlsInterceptToggleSchema } from "@/shared/schemas/inspector"; +import { isTlsInterceptEnabled, setTlsIntercept } from "@/lib/inspector/captureState"; + +export async function POST(request: Request): Promise { + let body: unknown; + try { + body = await request.json(); + } catch { + return new Response(JSON.stringify(buildErrorBody(400, "Invalid JSON body")), { + status: 400, + headers: { "content-type": "application/json" }, + }); + } + + const parsed = InspectorTlsInterceptToggleSchema.safeParse(body); + if (!parsed.success) { + return new Response( + JSON.stringify(buildErrorBody(400, parsed.error.issues[0]?.message ?? "Validation error")), + { status: 400, headers: { "content-type": "application/json" } } + ); + } + + setTlsIntercept(parsed.data.enabled); + return Response.json({ ok: true, tlsIntercept: { enabled: isTlsInterceptEnabled() } }); +} diff --git a/src/app/api/tools/traffic-inspector/hosts/[host]/route.ts b/src/app/api/tools/traffic-inspector/hosts/[host]/route.ts new file mode 100644 index 0000000000..5619ce972c --- /dev/null +++ b/src/app/api/tools/traffic-inspector/hosts/[host]/route.ts @@ -0,0 +1,77 @@ +/** + * DELETE /api/tools/traffic-inspector/hosts/[host] — remove a custom host + * PATCH /api/tools/traffic-inspector/hosts/[host] — toggle enabled flag + * + * LOCAL_ONLY enforced by routeGuard. + */ + +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; +import { z } from "zod"; +import { removeCustomHost, toggleCustomHost, listCustomHosts } from "@/lib/db/inspectorCustomHosts"; + +interface Params { + params: Promise<{ host: string }>; +} + +const PatchBodySchema = z.object({ + enabled: z.boolean(), +}); + +export async function DELETE(_request: Request, { params }: Params): Promise { + const { host } = await params; + const decodedHost = decodeURIComponent(host); + + try { + removeCustomHost(decodedHost); + return new Response(null, { status: 204 }); + } catch (err) { + const msg = sanitizeErrorMessage(err); + return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to remove host")), { + status: 500, + headers: { "content-type": "application/json" }, + }); + } +} + +export async function PATCH(request: Request, { params }: Params): Promise { + const { host } = await params; + const decodedHost = decodeURIComponent(host); + + let body: unknown; + try { + body = await request.json(); + } catch { + return new Response(JSON.stringify(buildErrorBody(400, "Invalid JSON body")), { + status: 400, + headers: { "content-type": "application/json" }, + }); + } + + const parsed = PatchBodySchema.safeParse(body); + if (!parsed.success) { + return new Response( + JSON.stringify(buildErrorBody(400, parsed.error.issues[0]?.message ?? "Validation error")), + { status: 400, headers: { "content-type": "application/json" } } + ); + } + + try { + toggleCustomHost(decodedHost, parsed.data.enabled); + // Return updated record + const hosts = listCustomHosts(); + const updated = hosts.find((h) => h.host === decodedHost); + if (!updated) { + return new Response(JSON.stringify(buildErrorBody(404, "Host not found")), { + status: 404, + headers: { "content-type": "application/json" }, + }); + } + return Response.json(updated); + } catch (err) { + const msg = sanitizeErrorMessage(err); + return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to toggle host")), { + status: 500, + headers: { "content-type": "application/json" }, + }); + } +} diff --git a/src/app/api/tools/traffic-inspector/hosts/route.ts b/src/app/api/tools/traffic-inspector/hosts/route.ts new file mode 100644 index 0000000000..607f358527 --- /dev/null +++ b/src/app/api/tools/traffic-inspector/hosts/route.ts @@ -0,0 +1,61 @@ +/** + * GET /api/tools/traffic-inspector/hosts — list custom host capture entries + * POST /api/tools/traffic-inspector/hosts — add a host (DB record) + * + * The DB record enables the MITM proxy to SNI-certify the host on demand. + * DNS /etc/hosts edits are out of scope for this route — clients that need + * OS-level redirect must use the Custom Hosts setup guide (requires sudo). + * + * LOCAL_ONLY enforced by routeGuard. + */ + +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; +import { InspectorCustomHostSchema } from "@/shared/schemas/inspector"; +import { listCustomHosts, addCustomHost } from "@/lib/db/inspectorCustomHosts"; + +export async function GET(): Promise { + try { + const hosts = listCustomHosts(); + return Response.json({ hosts }); + } catch (err) { + const msg = sanitizeErrorMessage(err); + return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to list hosts")), { + status: 500, + headers: { "content-type": "application/json" }, + }); + } +} + +export async function POST(request: Request): Promise { + let body: unknown; + try { + body = await request.json(); + } catch { + return new Response(JSON.stringify(buildErrorBody(400, "Invalid JSON body")), { + status: 400, + headers: { "content-type": "application/json" }, + }); + } + + const parsed = InspectorCustomHostSchema.safeParse(body); + if (!parsed.success) { + return new Response( + JSON.stringify(buildErrorBody(400, parsed.error.issues[0]?.message ?? "Validation error")), + { status: 400, headers: { "content-type": "application/json" } } + ); + } + + const { host, kind, label } = parsed.data; + + try { + addCustomHost(host, kind, label ?? undefined); + } catch (err) { + const msg = sanitizeErrorMessage(err); + return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to add host")), { + status: 500, + headers: { "content-type": "application/json" }, + }); + } + + return Response.json({ ok: true, host }, { status: 201 }); +} diff --git a/src/lib/inspector/captureState.ts b/src/lib/inspector/captureState.ts new file mode 100644 index 0000000000..eda6c29bf5 --- /dev/null +++ b/src/lib/inspector/captureState.ts @@ -0,0 +1,90 @@ +/** + * Runtime state for Traffic Inspector capture modes. + * + * Held in module-level variables (process-singleton). Survives across route + * handler calls for the lifetime of the process. + * + * Exported mutation functions are the single write path so all route handlers + * stay stateless. + */ + +import type { HttpProxyServerHandle } from "@/mitm/inspector/httpProxyServer"; +import type { PreviousState } from "@/mitm/inspector/systemProxyConfig"; + +// ── HTTP Proxy ────────────────────────────────────────────────────────────── + +let httpProxyHandle: HttpProxyServerHandle | null = null; + +export function getHttpProxyHandle(): HttpProxyServerHandle | null { + return httpProxyHandle; +} + +export function setHttpProxyHandle(handle: HttpProxyServerHandle | null): void { + httpProxyHandle = handle; +} + +// ── System Proxy ──────────────────────────────────────────────────────────── + +interface SystemProxyState { + applied: boolean; + port: number | null; + guardUntil: string | null; // ISO 8601 + previousState: PreviousState | null; +} + +let systemProxyState: SystemProxyState = { + applied: false, + port: null, + guardUntil: null, + previousState: null, +}; + +let guardTimer: ReturnType | null = null; + +export function getSystemProxyState(): Readonly { + return { ...systemProxyState }; +} + +export function setSystemProxyApplied( + port: number, + previousState: PreviousState, + guardMinutes: number +): void { + if (guardTimer) clearTimeout(guardTimer); + + const guardUntil = new Date(Date.now() + guardMinutes * 60_000).toISOString(); + systemProxyState = { applied: true, port, guardUntil, previousState }; + + guardTimer = setTimeout( + () => { + // Auto-revert after guard period — fire-and-forget. + // Import lazily to avoid circular deps at module load. + import("@/mitm/inspector/systemProxyConfig").then(({ revert }) => { + const ps = systemProxyState.previousState; + systemProxyState = { applied: false, port: null, guardUntil: null, previousState: null }; + if (ps) revert(ps).catch(() => {/* best-effort */}); + }).catch(() => {/* best-effort */}); + }, + guardMinutes * 60_000 + ); +} + +export function clearSystemProxy(): void { + if (guardTimer) { + clearTimeout(guardTimer); + guardTimer = null; + } + systemProxyState = { applied: false, port: null, guardUntil: null, previousState: null }; +} + +// ── TLS Intercept ─────────────────────────────────────────────────────────── + +let tlsInterceptEnabled = process.env.INSPECTOR_TLS_INTERCEPT === "true"; + +export function isTlsInterceptEnabled(): boolean { + return tlsInterceptEnabled; +} + +export function setTlsIntercept(enabled: boolean): void { + tlsInterceptEnabled = enabled; +}