feat(api): traffic-inspector sessions + internal ingest routes (F6)

This commit is contained in:
diegosouzapw
2026-05-28 01:24:26 -03:00
parent c16ce8a9e1
commit 668010bcf2
4 changed files with 363 additions and 0 deletions

View File

@@ -0,0 +1,127 @@
/**
* POST /api/tools/traffic-inspector/internal/ingest
*
* Internal endpoint consumed by `server.cjs` (D4 fallback) to push
* intercepted request data into the traffic buffer when the request does
* NOT pass through a TypeScript handler that already calls
* `agentBridgeHook.ts`.
*
* Security model (double LOCAL_ONLY):
* 1. `isLocalOnlyPath("/api/tools/traffic-inspector/")` blocks all non-
* loopback callers unconditionally — this is handled by the authz pipeline.
* 2. The shared secret `INSPECTOR_INTERNAL_INGEST_TOKEN` (set in .env or
* auto-generated at process boot) must match the `Authorization: Bearer`
* header. This prevents any other loopback process from stuffing the buffer.
*
* Body: partial `InterceptedRequest` — only `id`, `timestamp`, `method`,
* `host`, `path` are required; all other fields default.
*
* LOCAL_ONLY enforced by routeGuard + token gate below.
*/
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
import { createHash, timingSafeEqual } from "node:crypto";
import { randomUUID } from "node:crypto";
import { InterceptedRequestSchema } from "@/mitm/inspector/types";
import { globalTrafficBuffer } from "@/mitm/inspector/buffer";
// ── Token management ────────────────────────────────────────────────────────
let _cachedToken: string | null = null;
function getIngestToken(): string {
if (_cachedToken) return _cachedToken;
const env = process.env.INSPECTOR_INTERNAL_INGEST_TOKEN;
if (env && env.length >= 16) {
_cachedToken = env;
} else {
// Auto-generate on first call; persists for the lifetime of the process.
_cachedToken = randomUUID().replace(/-/g, "");
}
return _cachedToken;
}
function tokenMatches(received: string): boolean {
const expected = getIngestToken();
if (!received || !expected) return false;
try {
const a = createHash("sha256").update(expected).digest();
const b = createHash("sha256").update(received).digest();
return timingSafeEqual(a, b);
} catch {
return false;
}
}
// ── Partial schema (only required fields; rest optional) ───────────────────
const IngestBodySchema = InterceptedRequestSchema.partial().required({
id: true,
timestamp: true,
method: true,
host: true,
path: true,
source: true,
requestHeaders: true,
requestSize: true,
responseHeaders: true,
responseSize: true,
status: true,
});
// ── Handler ─────────────────────────────────────────────────────────────────
export async function POST(request: Request): Promise<Response> {
// Token gate (second layer after LOCAL_ONLY IP check).
const authHeader = request.headers.get("authorization") ?? "";
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
if (!tokenMatches(token)) {
return new Response(JSON.stringify(buildErrorBody(403, "Invalid or missing ingest token")), {
status: 403,
headers: { "content-type": "application/json" },
});
}
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 = IngestBodySchema.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 {
// Fill in any missing optional fields with sensible defaults.
const req = {
requestBody: null,
responseBody: null,
...parsed.data,
};
globalTrafficBuffer.push(req);
return Response.json({ ok: true, id: req.id }, { status: 200 });
} catch (err) {
const msg = sanitizeErrorMessage(err);
return new Response(JSON.stringify(buildErrorBody(500, msg || "Ingest failed")), {
status: 500,
headers: { "content-type": "application/json" },
});
}
}
/**
* Expose the auto-generated token for use by `server.cjs` bootstrap.
* Called once at process start via dynamic import.
*/
export function getIngestTokenForBootstrap(): string {
return getIngestToken();
}

View File

@@ -0,0 +1,61 @@
/**
* GET /api/tools/traffic-inspector/sessions/[id]/export.har
*
* Export all requests of a specific session as HAR v1.2.
* Secrets are always masked — see `toHar`.
*
* LOCAL_ONLY enforced by routeGuard.
*/
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
import { getSession, getSessionRequests } from "@/lib/db/inspectorSessions";
import { toHar } from "@/lib/inspector/harExport";
import type { InterceptedRequest } from "@/mitm/inspector/types";
interface Params {
params: Promise<{ id: string }>;
}
export async function GET(_request: Request, { params }: Params): Promise<Response> {
const { id } = await params;
const session = getSession(id);
if (!session) {
return new Response(JSON.stringify(buildErrorBody(404, "Session not found")), {
status: 404,
headers: { "content-type": "application/json" },
});
}
try {
const rows = getSessionRequests(id);
const requests: InterceptedRequest[] = rows
.map((r) => {
try {
return JSON.parse(r.payload) as InterceptedRequest;
} catch {
return null;
}
})
.filter((r): r is InterceptedRequest => r !== null);
const har = toHar(requests);
const sessionName = (session.name ?? `session-${id}`).replace(/[^a-z0-9_-]/gi, "_");
const filename = `${sessionName}.har`;
return new Response(JSON.stringify(har, null, 2), {
status: 200,
headers: {
"content-type": "application/json",
"content-disposition": `attachment; filename="${filename}"`,
"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,123 @@
/**
* GET /api/tools/traffic-inspector/sessions/[id] — session detail + requests
* PATCH /api/tools/traffic-inspector/sessions/[id] — stop or rename
* DELETE /api/tools/traffic-inspector/sessions/[id] — delete + cascade requests
*
* LOCAL_ONLY enforced by routeGuard.
*/
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
import { InspectorSessionPatchSchema } from "@/shared/schemas/inspector";
import {
getSession,
getSessionRequests,
stopSession,
renameSession,
deleteSession,
} from "@/lib/db/inspectorSessions";
interface Params {
params: Promise<{ id: string }>;
}
export async function GET(_request: Request, { params }: Params): Promise<Response> {
const { id } = await params;
try {
const session = getSession(id);
if (!session) {
return new Response(JSON.stringify(buildErrorBody(404, "Session not found")), {
status: 404,
headers: { "content-type": "application/json" },
});
}
const requests = getSessionRequests(id).map((r) => {
try {
return JSON.parse(r.payload) as unknown;
} catch {
return r.payload;
}
});
return Response.json({ session, requests });
} catch (err) {
const msg = sanitizeErrorMessage(err);
return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to get session")), {
status: 500,
headers: { "content-type": "application/json" },
});
}
}
export async function PATCH(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 = InspectorSessionPatchSchema.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 session = getSession(id);
if (!session) {
return new Response(JSON.stringify(buildErrorBody(404, "Session not found")), {
status: 404,
headers: { "content-type": "application/json" },
});
}
try {
if (parsed.data.action === "stop") {
stopSession(id);
} else if (parsed.data.action === "rename") {
if (!parsed.data.name) {
return new Response(
JSON.stringify(buildErrorBody(400, "name is required for rename action")),
{ status: 400, headers: { "content-type": "application/json" } }
);
}
renameSession(id, parsed.data.name);
}
return Response.json(getSession(id));
} catch (err) {
const msg = sanitizeErrorMessage(err);
return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to update session")), {
status: 500,
headers: { "content-type": "application/json" },
});
}
}
export async function DELETE(_request: Request, { params }: Params): Promise<Response> {
const { id } = await params;
const session = getSession(id);
if (!session) {
return new Response(JSON.stringify(buildErrorBody(404, "Session not found")), {
status: 404,
headers: { "content-type": "application/json" },
});
}
try {
deleteSession(id);
return new Response(null, { status: 204 });
} catch (err) {
const msg = sanitizeErrorMessage(err);
return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to delete session")), {
status: 500,
headers: { "content-type": "application/json" },
});
}
}

View File

@@ -0,0 +1,52 @@
/**
* GET /api/tools/traffic-inspector/sessions — list all sessions
* POST /api/tools/traffic-inspector/sessions — start a new recording session
*
* LOCAL_ONLY enforced by routeGuard.
*/
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
import { InspectorSessionStartSchema } from "@/shared/schemas/inspector";
import { listSessions, createSession } from "@/lib/db/inspectorSessions";
export async function GET(): Promise<Response> {
try {
const sessions = listSessions();
return Response.json({ sessions });
} catch (err) {
const msg = sanitizeErrorMessage(err);
return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to list sessions")), {
status: 500,
headers: { "content-type": "application/json" },
});
}
}
export async function POST(request: Request): Promise<Response> {
let body: unknown;
try {
body = await request.json();
} catch {
// Empty body is valid — name is optional
body = {};
}
const parsed = InspectorSessionStartSchema.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 {
const session = createSession({ name: parsed.data.name });
return Response.json(session, { status: 201 });
} catch (err) {
const msg = sanitizeErrorMessage(err);
return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to create session")), {
status: 500,
headers: { "content-type": "application/json" },
});
}
}