File size: 8,903 Bytes
6111b2b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 | /**
* WebSocket reverse proxy for embedded service UIs.
*
* Runs a lightweight HTTP server (port EMBED_WS_PROXY_PORT, default 20131)
* that accepts WebSocket upgrade requests and tunnels them to the matching
* embedded service.
*
* URL pattern: WebSocket connect to host:20131/[name]/[...path]
* [name] β resolved via the services registry (e.g. "9router")
* [...path] β forwarded verbatim to the upstream WS endpoint
*
* Security:
* - Target host is always 127.0.0.1 and port comes from the registry β never
* from user input. No SSRF risk.
* - Server binds to 127.0.0.1 only (loopback) unless EMBED_WS_PROXY_HOST
* is set explicitly. The OmniRoute LOCAL_ONLY rule is enforced at the
* dashboard layer; the proxy itself is loopback-only as defence-in-depth.
* - Max 50 concurrent connections per service. The 51st request receives 503.
* - Idle timeout: 5 minutes without any data β both sockets are destroyed.
* - Hop-by-hop headers cookie/authorization/origin are stripped from the
* upgrade request; Authorization is replaced by Bearer <serviceApiKey>.
*/
import http from "node:http";
import net from "node:net";
import type { IncomingMessage } from "node:http";
import { getSupervisor } from "./registry";
import { getOrCreateApiKey } from "./apiKey";
const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_PORT = 20131;
/** Maximum concurrent WebSocket bridges per service name. */
const MAX_CONNECTIONS_PER_SERVICE = 50;
/** Idle timeout in milliseconds (5 minutes). */
const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
/** Headers to strip from the client upgrade request (case-insensitive). */
const STRIPPED_HEADERS = new Set(["cookie", "authorization", "origin"]);
declare global {
var __omnirouteEmbedWsStarted: boolean | undefined;
}
/**
* Tracks active client sockets per service name.
* Used to enforce MAX_CONNECTIONS_PER_SERVICE.
*/
const activeConnections = new Map<string, Set<net.Socket>>();
/** Regex that matches /<name>/<path> or /<name> */
const PATH_RE = /^\/([^/?#]+)(\/.*)?$/;
function writeError(socket: net.Socket, status: number, message: string): void {
if (!socket.writable || socket.destroyed) return;
const body = Buffer.from(JSON.stringify({ error: message }), "utf8");
const lines = [
`HTTP/1.1 ${status} ${http.STATUS_CODES[status] ?? "Error"}`,
"Connection: close",
"Content-Type: application/json; charset=utf-8",
`Content-Length: ${body.length}`,
"",
"",
];
socket.write(lines.join("\r\n"));
socket.end(body);
}
/**
* Register a client socket into the per-service active set.
* Returns false (and writes 503) if the limit is already reached.
*/
function registerConnection(name: string, socket: net.Socket): boolean {
let set = activeConnections.get(name);
if (!set) {
set = new Set();
activeConnections.set(name, set);
}
if (set.size >= MAX_CONNECTIONS_PER_SERVICE) {
writeError(
socket,
503,
`Service '${name}' connection limit reached (max ${MAX_CONNECTIONS_PER_SERVICE})`
);
return false;
}
set.add(socket);
return true;
}
/** Remove a client socket from the per-service active set. */
function unregisterConnection(name: string, socket: net.Socket): void {
activeConnections.get(name)?.delete(socket);
}
/**
* Build the filtered header list for the upstream upgrade request.
* Strips cookie, authorization, and origin; rewrites host; injects Bearer token.
*/
function buildUpstreamHeaders(rawHeaders: string[], port: number, apiKey: string): string[] {
const lines: string[] = [];
let wroteHost = false;
for (let i = 0; i < rawHeaders.length; i += 2) {
const headerName = rawHeaders[i];
const headerValue = rawHeaders[i + 1] ?? "";
const lower = headerName.toLowerCase();
if (lower === "host") {
lines.push(`Host: 127.0.0.1:${port}`);
wroteHost = true;
} else if (!STRIPPED_HEADERS.has(lower)) {
lines.push(`${headerName}: ${headerValue}`);
}
// cookie / authorization / origin are intentionally dropped here
}
if (!wroteHost) lines.push(`Host: 127.0.0.1:${port}`);
// Always inject the service API key regardless of what the client sent
lines.push(`Authorization: Bearer ${apiKey}`);
return lines;
}
async function proxyUpgrade(req: IncomingMessage, socket: net.Socket, head: Buffer): Promise<void> {
const rawUrl = req.url ?? "/";
const match = PATH_RE.exec(rawUrl.split("?")[0]);
if (!match) {
writeError(socket, 400, "Invalid path");
return;
}
const [, name, rest = "/"] = match;
const supervisor = getSupervisor(name);
if (!supervisor) {
writeError(socket, 404, `Service '${name}' not found`);
return;
}
const { state, port } = supervisor.getStatus();
if (state !== "running") {
writeError(socket, 503, `Service '${name}' is not running (state: ${state})`);
return;
}
// Enforce max concurrent connections per service
if (!registerConnection(name, socket)) {
// writeError already written inside registerConnection
return;
}
// Clean up connection tracking when the client socket closes
socket.once("close", () => unregisterConnection(name, socket));
socket.once("error", () => unregisterConnection(name, socket));
// Fetch the service API key (never cached β key may rotate)
const apiKey = await getOrCreateApiKey(name);
// Rebuild the search string if present
const search = rawUrl.includes("?") ? rawUrl.slice(rawUrl.indexOf("?")) : "";
const upstreamPath = `${rest}${search}`;
const upstream = net.connect(port, "127.0.0.1");
// Idle timeout: reset on any data in either direction; destroy both on expiry
let idleTimer: ReturnType<typeof setTimeout> | null = null;
function resetIdleTimer(): void {
if (idleTimer !== null) clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
socket.destroy();
upstream.destroy();
}, IDLE_TIMEOUT_MS);
}
function clearIdleTimer(): void {
if (idleTimer !== null) {
clearTimeout(idleTimer);
idleTimer = null;
}
}
upstream.once("connect", () => {
const requestLine = `${req.method ?? "GET"} ${upstreamPath} HTTP/${req.httpVersion}`;
const headerLines = buildUpstreamHeaders(req.rawHeaders, port, apiKey);
upstream.write(`${requestLine}\r\n${headerLines.join("\r\n")}\r\n\r\n`);
if (head.length > 0) upstream.write(head);
// Start idle timer once the tunnel is live
resetIdleTimer();
socket.on("data", resetIdleTimer);
upstream.on("data", resetIdleTimer);
socket.pipe(upstream);
upstream.pipe(socket);
});
upstream.on("error", () => {
clearIdleTimer();
writeError(socket, 502, "Upstream connection error");
});
socket.on("error", () => {
clearIdleTimer();
upstream.destroy();
});
socket.on("close", () => {
clearIdleTimer();
upstream.destroy();
});
upstream.on("close", () => {
clearIdleTimer();
socket.destroy();
});
}
/**
* Start the embed WebSocket proxy server.
* Idempotent β safe to call multiple times.
*/
export function initEmbedWsProxy(): void {
if (globalThis.__omnirouteEmbedWsStarted) return;
const host = process.env.EMBED_WS_PROXY_HOST ?? DEFAULT_HOST;
const port = parseInt(process.env.EMBED_WS_PROXY_PORT ?? String(DEFAULT_PORT), 10);
const server = http.createServer((_req, res) => {
res.writeHead(426, "Upgrade Required", { "content-type": "application/json" });
res.end(JSON.stringify({ error: "upgrade_required", message: "Use WebSocket." }));
});
server.on("upgrade", (req: IncomingMessage, socket: net.Socket, head: Buffer) => {
proxyUpgrade(req, socket, head).catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
writeError(socket, 500, `Internal proxy error: ${msg}`);
});
});
server.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE") {
console.warn(`[EmbedWsProxy] Port ${port} is already in use β embed WS proxy disabled.`);
return;
}
console.warn("[EmbedWsProxy] Failed to start:", err.message);
});
server.listen(port, host, () => {
globalThis.__omnirouteEmbedWsStarted = true;
console.log(`[EmbedWsProxy] Listening on ${host}:${port}`);
});
}
// βββ Exported for testing ββββββββββββββββββββββββββββββββββββββββββββββββββββ
export {
activeConnections,
registerConnection,
unregisterConnection,
buildUpstreamHeaders,
MAX_CONNECTIONS_PER_SERVICE,
IDLE_TIMEOUT_MS,
};
|