mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 06:42:12 +03:00
feat(api): traffic-inspector sessions + internal ingest routes (F6)
This commit is contained in:
127
src/app/api/tools/traffic-inspector/internal/ingest/route.ts
Normal file
127
src/app/api/tools/traffic-inspector/internal/ingest/route.ts
Normal 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();
|
||||
}
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
}
|
||||
123
src/app/api/tools/traffic-inspector/sessions/[id]/route.ts
Normal file
123
src/app/api/tools/traffic-inspector/sessions/[id]/route.ts
Normal 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" },
|
||||
});
|
||||
}
|
||||
}
|
||||
52
src/app/api/tools/traffic-inspector/sessions/route.ts
Normal file
52
src/app/api/tools/traffic-inspector/sessions/route.ts
Normal 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" },
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user