mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
feat(api): traffic-inspector ws + requests + replay + annotation routes (F6)
This commit is contained in:
62
src/app/api/tools/traffic-inspector/export.har/route.ts
Normal file
62
src/app/api/tools/traffic-inspector/export.har/route.ts
Normal 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" },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
}
|
||||
24
src/app/api/tools/traffic-inspector/requests/[id]/route.ts
Normal file
24
src/app/api/tools/traffic-inspector/requests/[id]/route.ts
Normal 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);
|
||||
}
|
||||
44
src/app/api/tools/traffic-inspector/requests/route.ts
Normal file
44
src/app/api/tools/traffic-inspector/requests/route.ts
Normal 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 });
|
||||
}
|
||||
142
src/app/api/tools/traffic-inspector/ws/route.ts
Normal file
142
src/app/api/tools/traffic-inspector/ws/route.ts
Normal 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 });
|
||||
}
|
||||
Reference in New Issue
Block a user