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,
};