feat(api): traffic-inspector ws + requests + replay + annotation routes (F6)

This commit is contained in:
diegosouzapw
2026-05-28 01:24:14 -03:00
parent 1d6fcfd0c4
commit ef79eab224
6 changed files with 393 additions and 0 deletions

View File

@@ -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<Response> {
const url = new URL(request.url);
const rawQuery: Record<string, string> = {};
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" },
});
}
}

View File

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

View File

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

View File

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

View File

@@ -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<Response> {
const url = new URL(request.url);
const rawQuery: Record<string, string> = {};
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<Response> {
globalTrafficBuffer.clear();
return new Response(null, { status: 204 });
}

View File

@@ -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<Response> {
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<void>((resolve) => {
socket.once("close", resolve);
socket.once("error", resolve);
});
cleanup();
return new Response(null, { status: 101 });
}