diff --git a/src/app/api/tools/traffic-inspector/export.har/route.ts b/src/app/api/tools/traffic-inspector/export.har/route.ts new file mode 100644 index 0000000000..93e972fd35 --- /dev/null +++ b/src/app/api/tools/traffic-inspector/export.har/route.ts @@ -0,0 +1,62 @@ +/** + * GET /api/tools/traffic-inspector/export.har + * + * Exports the entire (optionally filtered) traffic buffer as a HAR v1.2 file. + * The Content-Disposition header triggers a browser download. + * + * Secrets are always masked in the export — see `toHar` implementation. + * + * LOCAL_ONLY enforced by routeGuard. + */ + +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; +import { InspectorListQuerySchema } from "@/shared/schemas/inspector"; +import { globalTrafficBuffer } from "@/mitm/inspector/buffer"; +import { toHar } from "@/lib/inspector/harExport"; +import type { ListFilters } from "@/mitm/inspector/types"; + +export async function GET(request: Request): Promise { + const url = new URL(request.url); + const rawQuery: Record = {}; + url.searchParams.forEach((value, key) => { + rawQuery[key] = value; + }); + + const parsed = InspectorListQuerySchema.safeParse(rawQuery); + if (!parsed.success) { + return new Response( + JSON.stringify(buildErrorBody(400, parsed.error.issues[0]?.message ?? "Invalid query")), + { status: 400, headers: { "content-type": "application/json" } } + ); + } + + const filters: ListFilters = { + profile: parsed.data.profile, + host: parsed.data.host, + agent: parsed.data.agent as ListFilters["agent"], + status: parsed.data.status, + source: parsed.data.source, + sessionId: parsed.data.sessionId, + }; + + try { + const requests = globalTrafficBuffer.list(filters); + const har = toHar(requests); + const json = JSON.stringify(har, null, 2); + + return new Response(json, { + status: 200, + headers: { + "content-type": "application/json", + "content-disposition": 'attachment; filename="traffic.har"', + "cache-control": "no-store", + }, + }); + } catch (err) { + const msg = sanitizeErrorMessage(err); + return new Response(JSON.stringify(buildErrorBody(500, msg || "HAR export failed")), { + status: 500, + headers: { "content-type": "application/json" }, + }); + } +} diff --git a/src/app/api/tools/traffic-inspector/requests/[id]/annotation/route.ts b/src/app/api/tools/traffic-inspector/requests/[id]/annotation/route.ts new file mode 100644 index 0000000000..935c503818 --- /dev/null +++ b/src/app/api/tools/traffic-inspector/requests/[id]/annotation/route.ts @@ -0,0 +1,60 @@ +/** + * PUT /api/tools/traffic-inspector/requests/[id]/annotation + * + * Attaches or replaces a free-text annotation on a buffered entry. + * Mutations are broadcast to all WS subscribers via `buffer.update`. + * + * LOCAL_ONLY enforced by routeGuard. + */ + +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; +import { InspectorAnnotationPutSchema } from "@/shared/schemas/inspector"; +import { globalTrafficBuffer } from "@/mitm/inspector/buffer"; + +interface Params { + params: Promise<{ id: string }>; +} + +export async function PUT(request: Request, { params }: Params): Promise { + const { id } = await params; + + 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 = InspectorAnnotationPutSchema.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 entry = globalTrafficBuffer.get(id); + if (!entry) { + return new Response(JSON.stringify(buildErrorBody(404, "Request not found")), { + status: 404, + headers: { "content-type": "application/json" }, + }); + } + + try { + const updated = { ...entry, annotation: parsed.data.annotation }; + globalTrafficBuffer.update(id, updated); + return Response.json(updated); + } catch (err) { + const msg = sanitizeErrorMessage(err); + return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to update annotation")), { + status: 500, + headers: { "content-type": "application/json" }, + }); + } +} diff --git a/src/app/api/tools/traffic-inspector/requests/[id]/replay/route.ts b/src/app/api/tools/traffic-inspector/requests/[id]/replay/route.ts new file mode 100644 index 0000000000..1b5253e3b3 --- /dev/null +++ b/src/app/api/tools/traffic-inspector/requests/[id]/replay/route.ts @@ -0,0 +1,61 @@ +/** + * POST /api/tools/traffic-inspector/requests/[id]/replay + * + * Re-issues the captured request through the local OmniRoute instance and + * returns the response body. The replay will itself appear in the traffic + * buffer (captured by agentBridgeHook or httpProxyServer depending on path). + * + * LOCAL_ONLY enforced by routeGuard. + */ + +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; +import { globalTrafficBuffer } from "@/mitm/inspector/buffer"; + +interface Params { + params: Promise<{ id: string }>; +} + +const OMNIROUTE_BASE = process.env.OMNIROUTE_BASE_URL ?? "http://127.0.0.1:20128"; + +export async function POST(_request: Request, { params }: Params): Promise { + const { id } = await params; + const entry = globalTrafficBuffer.get(id); + if (!entry) { + return new Response(JSON.stringify(buildErrorBody(404, "Request not found")), { + status: 404, + headers: { "content-type": "application/json" }, + }); + } + + const url = `${OMNIROUTE_BASE}${entry.path}`; + + const replayHeaders: Record = { + "content-type": "application/json", + "x-omniroute-source": "inspector-replay", + }; + // Forward original Authorization if present (masked in buffer — skip if masked) + const origAuth = entry.requestHeaders["authorization"] ?? entry.requestHeaders["Authorization"]; + if (origAuth && !origAuth.includes("***")) { + replayHeaders["authorization"] = origAuth; + } + + try { + const upstream = await fetch(url, { + method: entry.method, + headers: replayHeaders, + body: entry.requestBody ?? undefined, + }); + + const body = await upstream.text(); + return new Response(body, { + status: upstream.status, + headers: { "content-type": upstream.headers.get("content-type") ?? "application/json" }, + }); + } catch (err) { + const msg = sanitizeErrorMessage(err); + return new Response(JSON.stringify(buildErrorBody(502, msg || "Replay failed")), { + status: 502, + headers: { "content-type": "application/json" }, + }); + } +} diff --git a/src/app/api/tools/traffic-inspector/requests/[id]/route.ts b/src/app/api/tools/traffic-inspector/requests/[id]/route.ts new file mode 100644 index 0000000000..d299e7ab3d --- /dev/null +++ b/src/app/api/tools/traffic-inspector/requests/[id]/route.ts @@ -0,0 +1,24 @@ +/** + * GET /api/tools/traffic-inspector/requests/[id] — fetch a single intercepted request + * + * LOCAL_ONLY enforced by routeGuard. + */ + +import { buildErrorBody } from "@omniroute/open-sse/utils/error.ts"; +import { globalTrafficBuffer } from "@/mitm/inspector/buffer"; + +interface Params { + params: Promise<{ id: string }>; +} + +export async function GET(_request: Request, { params }: Params): Promise { + const { id } = await params; + const entry = globalTrafficBuffer.get(id); + if (!entry) { + return new Response(JSON.stringify(buildErrorBody(404, "Request not found")), { + status: 404, + headers: { "content-type": "application/json" }, + }); + } + return Response.json(entry); +} diff --git a/src/app/api/tools/traffic-inspector/requests/route.ts b/src/app/api/tools/traffic-inspector/requests/route.ts new file mode 100644 index 0000000000..cd237e585c --- /dev/null +++ b/src/app/api/tools/traffic-inspector/requests/route.ts @@ -0,0 +1,44 @@ +/** + * GET /api/tools/traffic-inspector/requests — list buffer with optional filters + * DELETE /api/tools/traffic-inspector/requests — clear the entire buffer + * + * LOCAL_ONLY enforced by routeGuard (no extra check needed here). + */ + +import { buildErrorBody } from "@omniroute/open-sse/utils/error.ts"; +import { InspectorListQuerySchema } from "@/shared/schemas/inspector"; +import { globalTrafficBuffer } from "@/mitm/inspector/buffer"; +import type { ListFilters } from "@/mitm/inspector/types"; + +export async function GET(request: Request): Promise { + const url = new URL(request.url); + const rawQuery: Record = {}; + url.searchParams.forEach((value, key) => { + rawQuery[key] = value; + }); + + const parsed = InspectorListQuerySchema.safeParse(rawQuery); + if (!parsed.success) { + return new Response( + JSON.stringify(buildErrorBody(400, parsed.error.issues[0]?.message ?? "Invalid query")), + { status: 400, headers: { "content-type": "application/json" } } + ); + } + + const filters: ListFilters = { + profile: parsed.data.profile, + host: parsed.data.host, + agent: parsed.data.agent as ListFilters["agent"], + status: parsed.data.status, + source: parsed.data.source, + sessionId: parsed.data.sessionId, + }; + + const requests = globalTrafficBuffer.list(filters); + return Response.json({ requests, total: requests.length }); +} + +export async function DELETE(): Promise { + globalTrafficBuffer.clear(); + return new Response(null, { status: 204 }); +} diff --git a/src/app/api/tools/traffic-inspector/ws/route.ts b/src/app/api/tools/traffic-inspector/ws/route.ts new file mode 100644 index 0000000000..b32a5d7cc5 --- /dev/null +++ b/src/app/api/tools/traffic-inspector/ws/route.ts @@ -0,0 +1,142 @@ +/** + * WebSocket endpoint for the Traffic Inspector live stream. + * + * Clients connect here to receive real-time `WsEvent` frames + * (snapshot, new, update, clear) from the `globalTrafficBuffer`. + * + * LOCAL_ONLY enforcement happens unconditionally in the authz pipeline via + * `isLocalOnlyPath("/api/tools/traffic-inspector/")` — this route does not + * need to repeat that check. The WS upgrade uses the raw socket injected by + * Next.js / the standalone server. + * + * Protocol: + * 1. Client connects with `Upgrade: websocket`. + * 2. Server immediately emits `{type:"snapshot", data:[...]}`. + * 3. Subsequent mutations produce `{type:"new"|"update"|"clear", data?}`. + * 4. Server sends ping frames every 30s; client may pong (ignored here). + * 5. Closing the connection removes the subscriber. + */ + +import { createHash } from "node:crypto"; +import { globalTrafficBuffer } from "@/mitm/inspector/buffer"; +import { buildErrorBody } from "@omniroute/open-sse/utils/error.ts"; + +const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; +const PING_INTERVAL_MS = 30_000; + +function acceptKey(clientKey: string): string { + return createHash("sha1") + .update(clientKey + WS_GUID) + .digest("base64"); +} + +function encodeWsFrame(opcode: number, payload: Buffer = Buffer.alloc(0)): Buffer { + const length = payload.length; + let header: Buffer; + if (length < 126) { + header = Buffer.allocUnsafe(2); + header[1] = length; + } else if (length <= 0xffff) { + header = Buffer.allocUnsafe(4); + header[1] = 126; + header.writeUInt16BE(length, 2); + } else { + header = Buffer.allocUnsafe(10); + header[1] = 127; + header.writeBigUInt64BE(BigInt(length), 2); + } + header[0] = 0x80 | (opcode & 0x0f); + return Buffer.concat([header, payload]); +} + +function sendText(socket: import("node:net").Socket, data: unknown): void { + try { + const json = JSON.stringify(data); + const payload = Buffer.from(json, "utf8"); + socket.write(encodeWsFrame(0x01, payload)); + } catch { + // socket may be destroyed; ignore + } +} + +function sendClose(socket: import("node:net").Socket): void { + try { + socket.write(encodeWsFrame(0x08)); + socket.end(); + } catch { + // already closed + } +} + +export async function GET(request: Request): Promise { + const upgrade = request.headers.get("upgrade"); + if (!upgrade || upgrade.toLowerCase() !== "websocket") { + return new Response(JSON.stringify(buildErrorBody(426, "Upgrade Required")), { + status: 426, + headers: { "content-type": "application/json", Upgrade: "websocket" }, + }); + } + + const clientKey = request.headers.get("sec-websocket-key"); + if (!clientKey) { + return new Response(JSON.stringify(buildErrorBody(400, "Missing Sec-WebSocket-Key")), { + status: 400, + headers: { "content-type": "application/json" }, + }); + } + + // @ts-expect-error — Next.js standalone server exposes the raw socket via + // `request.socket` but the Request type does not declare it. + const socket = (request as unknown as { socket?: import("node:net").Socket }).socket; + if (!socket) { + return new Response(JSON.stringify(buildErrorBody(500, "WebSocket upgrade unavailable")), { + status: 500, + headers: { "content-type": "application/json" }, + }); + } + + const acceptHeader = acceptKey(clientKey); + socket.write( + [ + "HTTP/1.1 101 Switching Protocols", + "Upgrade: websocket", + "Connection: Upgrade", + `Sec-WebSocket-Accept: ${acceptHeader}`, + "\r\n", + ].join("\r\n") + ); + + const unsubscribe = globalTrafficBuffer.subscribe((ev) => { + sendText(socket, ev); + }); + + const pingTimer = setInterval(() => { + try { + socket.write(encodeWsFrame(0x09)); // ping + } catch { + cleanup(); + } + }, PING_INTERVAL_MS); + + function cleanup(): void { + clearInterval(pingTimer); + unsubscribe(); + try { + socket.destroy(); + } catch { + // already gone + } + } + + socket.once("close", cleanup); + socket.once("error", cleanup); + + // Never resolve — the socket is the response channel. + await new Promise((resolve) => { + socket.once("close", resolve); + socket.once("error", resolve); + }); + + cleanup(); + return new Response(null, { status: 101 }); +}