feat(api): traffic-inspector hosts + capture-modes + tls routes (F6)

This commit is contained in:
diegosouzapw
2026-05-28 01:24:20 -03:00
parent ef79eab224
commit c16ce8a9e1
7 changed files with 499 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<typeof setTimeout> | null = null;
export function getSystemProxyState(): Readonly<SystemProxyState> {
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;
}