| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| import http from "node:http";
|
| import net from "node:net";
|
| import { randomUUID } from "node:crypto";
|
| import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
| import { sanitizeHeaders } from "../sanitizeHeaders.ts";
|
| import { maskSecret } from "../maskSecrets.ts";
|
| import { globalTrafficBuffer } from "./buffer.ts";
|
| import type { InterceptedRequest } from "./types.ts";
|
|
|
| const DEFAULT_PORT = parseEnvNumber(process.env.INSPECTOR_HTTP_PROXY_PORT, 8080);
|
|
|
| function parseEnvNumber(value: string | undefined, fallback: number): number {
|
| if (!value) return fallback;
|
| const parsed = Number(value);
|
| if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
| return parsed;
|
| }
|
|
|
| export interface HttpProxyServerHandle {
|
| port: number;
|
| server: http.Server;
|
| stop(): Promise<void>;
|
| }
|
|
|
| |
| |
| |
|
|
| function buildFetchHeaders(raw: http.IncomingHttpHeaders): Record<string, string> {
|
|
|
|
|
|
|
| const out: Record<string, string> = {};
|
| for (const [name, value] of Object.entries(raw)) {
|
| if (value === undefined || value === null) continue;
|
| const lower = name.toLowerCase();
|
|
|
| if (
|
| lower === "host" ||
|
| lower === "connection" ||
|
| lower === "keep-alive" ||
|
| lower === "proxy-authenticate" ||
|
| lower === "proxy-authorization" ||
|
| lower === "te" ||
|
| lower === "trailer" ||
|
| lower === "transfer-encoding" ||
|
| lower === "upgrade" ||
|
| lower === "content-length"
|
| ) {
|
| continue;
|
| }
|
| out[lower] = Array.isArray(value) ? value.join(", ") : String(value);
|
| }
|
| return out;
|
| }
|
|
|
| function safeUrl(rawUrl: string | undefined, hostHeader: string | undefined): URL | null {
|
| if (!rawUrl) return null;
|
| try {
|
| if (/^https?:\/\//i.test(rawUrl)) return new URL(rawUrl);
|
| if (hostHeader) return new URL(`http://${hostHeader}${rawUrl}`);
|
| } catch {
|
| return null;
|
| }
|
| return null;
|
| }
|
|
|
| async function readBody(req: http.IncomingMessage): Promise<Buffer> {
|
| const chunks: Buffer[] = [];
|
| for await (const chunk of req) {
|
| chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
| }
|
| return Buffer.concat(chunks);
|
| }
|
|
|
| function handleHttp(req: http.IncomingMessage, res: http.ServerResponse): void {
|
| const startedAt = performance.now();
|
| const intercepted: InterceptedRequest = {
|
| id: randomUUID(),
|
| source: "http-proxy",
|
| timestamp: new Date().toISOString(),
|
| method: req.method ?? "GET",
|
| host: req.headers.host ?? "",
|
| path: req.url ?? "/",
|
| requestHeaders: sanitizeHeaders(req.headers),
|
| requestBody: null,
|
| requestSize: 0,
|
| responseHeaders: {},
|
| responseBody: null,
|
| responseSize: 0,
|
| status: "in-flight",
|
| };
|
|
|
| globalTrafficBuffer.push(intercepted);
|
|
|
| void (async () => {
|
| try {
|
| const target = safeUrl(req.url, req.headers.host);
|
| if (!target) {
|
| throw new Error("Invalid request URL");
|
| }
|
| intercepted.host = target.host;
|
| intercepted.path = target.pathname + target.search;
|
|
|
| const body = await readBody(req);
|
| intercepted.requestSize = body.length;
|
| intercepted.requestBody = body.length > 0 ? maskSecret(body.toString("utf8")) : null;
|
|
|
| const upstreamHeaders = buildFetchHeaders(req.headers);
|
| const upstream = await fetch(target.toString(), {
|
| method: req.method ?? "GET",
|
| headers: upstreamHeaders,
|
| body: body.length > 0 ? body : undefined,
|
| redirect: "manual",
|
| });
|
|
|
| const respBuf = Buffer.from(await upstream.arrayBuffer());
|
| const totalLatencyMs = performance.now() - startedAt;
|
|
|
| intercepted.responseHeaders = sanitizeHeaders(
|
| Object.fromEntries(upstream.headers) as Record<string, string>
|
| );
|
| intercepted.responseBody = maskSecret(respBuf.toString("utf8"));
|
| intercepted.responseSize = respBuf.length;
|
| intercepted.status = upstream.status;
|
| intercepted.totalLatencyMs = totalLatencyMs;
|
| intercepted.upstreamLatencyMs = totalLatencyMs;
|
| intercepted.proxyLatencyMs = 0;
|
|
|
| const safeRespHeaders: Record<string, string> = {};
|
| upstream.headers.forEach((value, key) => {
|
| if (key.toLowerCase() === "content-length") return;
|
| if (key.toLowerCase() === "transfer-encoding") return;
|
| safeRespHeaders[key] = value;
|
| });
|
| res.writeHead(upstream.status, safeRespHeaders);
|
| res.end(respBuf);
|
|
|
| globalTrafficBuffer.update(intercepted.id, intercepted);
|
| } catch (err) {
|
| intercepted.status = "error";
|
| intercepted.error = sanitizeErrorMessage(err);
|
| intercepted.totalLatencyMs = performance.now() - startedAt;
|
| globalTrafficBuffer.update(intercepted.id, intercepted);
|
| if (!res.headersSent) {
|
| res.writeHead(502, { "content-type": "text/plain" });
|
| res.end("Bad Gateway");
|
| } else {
|
| res.end();
|
| }
|
| }
|
| })();
|
| }
|
|
|
| function handleConnect(
|
| req: http.IncomingMessage,
|
| clientSocket: net.Socket,
|
| head: Buffer
|
| ): void {
|
| const target = req.url ?? "";
|
| const [host, rawPort] = target.split(":");
|
| const port = Number(rawPort) || 443;
|
|
|
| const intercepted: InterceptedRequest = {
|
| id: randomUUID(),
|
| source: "http-proxy",
|
| timestamp: new Date().toISOString(),
|
| method: "CONNECT",
|
| host,
|
| path: `:${port}`,
|
| requestHeaders: sanitizeHeaders(req.headers),
|
| requestBody: null,
|
| requestSize: 0,
|
| responseHeaders: {},
|
| responseBody: null,
|
| responseSize: 0,
|
| status: "in-flight",
|
| note: "TLS tunnel — for body capture, redirect host via Custom Hosts mode",
|
| };
|
|
|
| globalTrafficBuffer.push(intercepted);
|
|
|
| const targetSocket = net.connect(port, host);
|
|
|
| const finalize = (status: number | "error", err?: unknown): void => {
|
| intercepted.status = status;
|
| if (err !== undefined) intercepted.error = sanitizeErrorMessage(err);
|
| globalTrafficBuffer.update(intercepted.id, intercepted);
|
| };
|
|
|
| targetSocket.once("connect", () => {
|
| clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
| if (head && head.length > 0) targetSocket.write(head);
|
| targetSocket.pipe(clientSocket);
|
| clientSocket.pipe(targetSocket);
|
| finalize(200);
|
| });
|
|
|
| const onError = (err: unknown): void => {
|
| finalize("error", err);
|
| try {
|
| clientSocket.end();
|
| } catch {
|
|
|
| }
|
| try {
|
| targetSocket.destroy();
|
| } catch {
|
|
|
| }
|
| };
|
|
|
| targetSocket.once("error", onError);
|
| clientSocket.once("error", onError);
|
| }
|
|
|
| |
| |
| |
| |
|
|
| export function startHttpProxyServer(port: number = DEFAULT_PORT): Promise<HttpProxyServerHandle> {
|
| return new Promise((resolve, reject) => {
|
| const server = http.createServer();
|
|
|
| server.on("request", (req, res) => handleHttp(req, res));
|
| server.on("connect", (req, socket, head) => handleConnect(req, socket as net.Socket, head));
|
|
|
| server.once("error", (err: NodeJS.ErrnoException) => {
|
|
|
| reject(Object.assign(err, { code: err.code ?? "ELISTEN" }));
|
| });
|
|
|
| server.once("listening", () => {
|
| const addr = server.address();
|
| const boundPort = typeof addr === "object" && addr ? addr.port : port;
|
| resolve({
|
| port: boundPort,
|
| server,
|
| stop: () =>
|
| new Promise<void>((res) => {
|
| server.close(() => res());
|
| }),
|
| });
|
| });
|
|
|
| server.listen(port, "127.0.0.1");
|
| });
|
| }
|
|
|