mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
feat(api): traffic-inspector hosts + capture-modes + tls routes (F6)
This commit is contained in:
@@ -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" } }
|
||||
);
|
||||
}
|
||||
}
|
||||
53
src/app/api/tools/traffic-inspector/capture-modes/route.ts
Normal file
53
src/app/api/tools/traffic-inspector/capture-modes/route.ts
Normal 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" } }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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" } }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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() } });
|
||||
}
|
||||
77
src/app/api/tools/traffic-inspector/hosts/[host]/route.ts
Normal file
77
src/app/api/tools/traffic-inspector/hosts/[host]/route.ts
Normal 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" },
|
||||
});
|
||||
}
|
||||
}
|
||||
61
src/app/api/tools/traffic-inspector/hosts/route.ts
Normal file
61
src/app/api/tools/traffic-inspector/hosts/route.ts
Normal 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 });
|
||||
}
|
||||
90
src/lib/inspector/captureState.ts
Normal file
90
src/lib/inspector/captureState.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user