@volter/tunnel 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,809 @@
1
+ /**
2
+ * WebSocket-based HTTP tunnel server.
3
+ *
4
+ * - Accepts tunnel client connections via WebSocket at /ws
5
+ * - Routes incoming HTTP requests by subdomain to the right client
6
+ * - Serializes request/response over WebSocket (JSON + base64 for bodies)
7
+ * - Proxies WebSocket connections through the tunnel (message-level relay)
8
+ * - Supports per-tunnel auth (JWT via Bearer header, cookie, or WS query param)
9
+ */
10
+
11
+ import crypto from 'node:crypto';
12
+ import http from 'node:http';
13
+ import jwt from 'jsonwebtoken';
14
+ import { WebSocketServer } from 'ws';
15
+
16
+ const PORT = parseInt(process.env.PORT || '3500');
17
+ const EXTERNAL_PORT = parseInt(process.env.EXTERNAL_PORT || process.env.PORT || '3500');
18
+ const DOMAIN = process.env.TUNNEL_DOMAIN || 'localhost';
19
+ const SECURE = process.env.TUNNEL_SECURE === 'true';
20
+ const TUNNEL_SECRET = process.env.TUNNEL_SECRET || '';
21
+ const JWT_SECRET = process.env.JWT_SECRET || '';
22
+
23
+ // This one process relays every tenant's tunnel, so a single uncaught error must
24
+ // never take the whole server down (it did: an oversized close reason threw a
25
+ // RangeError out of a WS handler, crash-looping the machine to Fly's max-restart
26
+ // cap and dropping every tunnel at once). Log loudly and keep serving — one bad
27
+ // frame on one connection is never worth disconnecting everyone else.
28
+ process.on('uncaughtException', (err) => {
29
+ console.error(`[tunnel] uncaught exception (kept alive): ${err?.stack || err?.message || err}`);
30
+ });
31
+ process.on('unhandledRejection', (reason) => {
32
+ console.error(`[tunnel] unhandled rejection (kept alive): ${reason instanceof Error ? reason.stack : reason}`);
33
+ });
34
+
35
+ // tunnelId → { ws, authRequired }
36
+ const clients = new Map();
37
+ // reqId → { res, timer, tunnelId } for pending HTTP responses
38
+ const pendingRequests = new Map();
39
+ // reqId → { res, tunnelId } for active streaming responses
40
+ const streamingResponses = new Map();
41
+ // connId → { timer, tunnelId } for pending WS upgrades (waiting for ws-ready)
42
+ const pendingUpgrades = new Map();
43
+ // connId → { browserWs, tunnelId, clientWs } for active WS relay connections
44
+ const wsConnections = new Map();
45
+
46
+ let reqIdCounter = 0;
47
+
48
+ // A WebSocket close frame's reason is capped at 123 bytes (a 125-byte control
49
+ // frame minus the 2-byte close code). The `ws` library throws a RangeError if
50
+ // the reason exceeds that. Every tenant's relay runs in this single process, so
51
+ // one oversized reason — e.g. a relayed ws-error string like "WebSocket
52
+ // connection to 'ws://…/ws?terminalId=…' failed: Expected 101 status code" —
53
+ // would throw out of the message handler and crash the WHOLE tunnel server,
54
+ // dropping every tunnel and crash-looping until Fly's max-restart cap. Truncate
55
+ // on a UTF-8 boundary and never let close() throw.
56
+ function truncateUtf8(value, maxBytes) {
57
+ const buf = Buffer.from(String(value ?? ''), 'utf8');
58
+ if (buf.length <= maxBytes) return String(value ?? '');
59
+ // Decode the byte-truncated prefix, then drop a trailing replacement char left
60
+ // by a severed multibyte sequence so the re-encoded string stays within maxBytes.
61
+ return buf.subarray(0, maxBytes).toString('utf8').replace(/�+$/, '');
62
+ }
63
+
64
+ function closeWsSafely(ws, code, reason) {
65
+ try {
66
+ ws.close(code, truncateUtf8(reason, 123));
67
+ } catch (err) {
68
+ console.error(`[ws-relay] close(code=${code}) failed: ${err?.message}`);
69
+ try {
70
+ ws.terminate();
71
+ } catch {
72
+ // Already gone — nothing left to do.
73
+ }
74
+ }
75
+ }
76
+
77
+ function generateId() {
78
+ const adjectives = ['quick', 'bright', 'calm', 'bold', 'cool', 'fast', 'keen', 'warm'];
79
+ const nouns = ['fox', 'owl', 'elk', 'bee', 'cat', 'dog', 'ray', 'ant'];
80
+ const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
81
+ const noun = nouns[Math.floor(Math.random() * nouns.length)];
82
+ const num = crypto.randomInt(100, 999);
83
+ return `${adj}-${noun}-${num}`;
84
+ }
85
+
86
+ function getTunnelIdFromHost(host) {
87
+ if (!host) return null;
88
+ const hostname = host.split(':')[0];
89
+ if (hostname.endsWith('.' + DOMAIN)) {
90
+ return hostname.slice(0, -(DOMAIN.length + 1));
91
+ }
92
+ return null;
93
+ }
94
+
95
+ // ============================================================================
96
+ // JWT Auth helpers
97
+ // ============================================================================
98
+
99
+ /**
100
+ * Parse a cookie header string and return a Map of name → value.
101
+ */
102
+ function parseCookies(cookieHeader) {
103
+ const cookies = new Map();
104
+ if (!cookieHeader) return cookies;
105
+ for (const pair of cookieHeader.split(';')) {
106
+ const idx = pair.indexOf('=');
107
+ if (idx < 0) continue;
108
+ const name = pair.slice(0, idx).trim();
109
+ const value = pair.slice(idx + 1).trim();
110
+ cookies.set(name, value);
111
+ }
112
+ return cookies;
113
+ }
114
+
115
+ /**
116
+ * Validate auth from a request. Checks (in order):
117
+ * 1. Authorization: Bearer <jwt> header
118
+ * 2. ?token= query parameter
119
+ * 3. __volter_auth cookie
120
+ *
121
+ * Returns { payload, token, source } or null.
122
+ * `source` is 'header' | 'query' | 'cookie' — used to decide whether to bootstrap a cookie.
123
+ */
124
+ function validateAuth(req) {
125
+ if (!JWT_SECRET) return null;
126
+
127
+ // 1. Bearer token
128
+ const authHeader = req.headers.authorization;
129
+ if (authHeader && authHeader.startsWith('Bearer ')) {
130
+ const token = authHeader.slice(7);
131
+ try {
132
+ return { payload: jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }), token, source: 'header' };
133
+ } catch {
134
+ return null;
135
+ }
136
+ }
137
+
138
+ // 2. ?__volter_token= query param (used by iframes that can't set headers)
139
+ try {
140
+ const url = new URL(req.url, `http://${req.headers.host}`);
141
+ const queryToken = url.searchParams.get('__volter_token');
142
+ if (queryToken) {
143
+ try {
144
+ return { payload: jwt.verify(queryToken, JWT_SECRET, { algorithms: ['HS256'] }), token: queryToken, source: 'query' };
145
+ } catch {
146
+ return null;
147
+ }
148
+ }
149
+ } catch {
150
+ // URL parse failed — fall through to cookie
151
+ }
152
+
153
+ // 3. Cookie
154
+ const cookies = parseCookies(req.headers.cookie);
155
+ const cookieToken = cookies.get('__volter_auth');
156
+ if (cookieToken) {
157
+ try {
158
+ return { payload: jwt.verify(cookieToken, JWT_SECRET, { algorithms: ['HS256'] }), token: cookieToken, source: 'cookie' };
159
+ } catch {
160
+ return null;
161
+ }
162
+ }
163
+
164
+ return null;
165
+ }
166
+
167
+ /**
168
+ * Strip the __volter_auth cookie from request headers before forwarding to
169
+ * tunnel clients. Prevents downstream services from seeing auth credentials.
170
+ */
171
+ function stripAuthCookie(headers) {
172
+ if (!headers.cookie) return headers;
173
+ const cleaned = headers.cookie
174
+ .split(';')
175
+ .filter((pair) => pair.trim().split('=')[0]?.trim() !== '__volter_auth')
176
+ .join(';')
177
+ .trim();
178
+ const result = { ...headers };
179
+ if (cleaned) {
180
+ result.cookie = cleaned;
181
+ } else {
182
+ delete result.cookie;
183
+ }
184
+ return result;
185
+ }
186
+
187
+ /**
188
+ * Validate auth from a WebSocket upgrade request.
189
+ * Checks ?__volter_token=<jwt> query parameter (browsers can't set headers on WS upgrades).
190
+ * Falls back to cookie.
191
+ */
192
+ function validateWsAuth(req) {
193
+ if (!JWT_SECRET) return null;
194
+
195
+ // 1. ?__volter_token= query param
196
+ try {
197
+ const url = new URL(req.url, `http://${req.headers.host}`);
198
+ const token = url.searchParams.get('__volter_token');
199
+ if (token) {
200
+ try {
201
+ return jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
202
+ } catch {
203
+ return null;
204
+ }
205
+ }
206
+ } catch {
207
+ // URL parse failed — fall through to cookie
208
+ }
209
+
210
+ // 2. Cookie
211
+ const cookies = parseCookies(req.headers.cookie);
212
+ const cookieToken = cookies.get('__volter_auth');
213
+ if (cookieToken) {
214
+ try {
215
+ return jwt.verify(cookieToken, JWT_SECRET, { algorithms: ['HS256'] });
216
+ } catch {
217
+ return null;
218
+ }
219
+ }
220
+
221
+ return null;
222
+ }
223
+
224
+ /**
225
+ * Strip the ?__volter_token= query parameter from a URL path before forwarding
226
+ * to the tunnel client (don't leak auth tokens to downstream services).
227
+ */
228
+ function stripTokenParam(urlPath) {
229
+ try {
230
+ const url = new URL(urlPath, 'http://placeholder');
231
+ url.searchParams.delete('__volter_token');
232
+ const search = url.searchParams.toString();
233
+ return url.pathname + (search ? '?' + search : '');
234
+ } catch {
235
+ return urlPath;
236
+ }
237
+ }
238
+
239
+ /** Set CORS headers on a tunneled response. All tunnel traffic is cross-origin. */
240
+ function setCorsHeaders(req, res) {
241
+ const origin = req.headers.origin;
242
+ if (origin) {
243
+ res.setHeader('Access-Control-Allow-Origin', origin);
244
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
245
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Sandbox-Id');
246
+ res.setHeader('Access-Control-Allow-Credentials', 'true');
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Strip frame-ancestors from CSP headers in proxied responses.
252
+ * Downstream apps may set frame-ancestors 'none' or 'self', but the tunnel
253
+ * server controls the framing context — extension iframes need to load.
254
+ */
255
+ function stripFrameAncestors(headers) {
256
+ for (const key of ['content-security-policy', 'content-security-policy-report-only']) {
257
+ if (headers[key]) {
258
+ headers[key] = headers[key]
259
+ .split(';')
260
+ .filter((d) => !d.trim().startsWith('frame-ancestors'))
261
+ .join(';')
262
+ .trim();
263
+ // Remove header entirely if empty after stripping
264
+ if (!headers[key]) delete headers[key];
265
+ }
266
+ }
267
+ // Also remove X-Frame-Options (superseded by CSP, but browsers still respect it)
268
+ delete headers['x-frame-options'];
269
+ }
270
+
271
+ /** Send a 401 JSON response */
272
+ function send401(res) {
273
+ res.writeHead(401, { 'Content-Type': 'application/json' });
274
+ res.end(JSON.stringify({ error: 'Authentication required' }));
275
+ }
276
+
277
+ // ============================================================================
278
+ // HTTP server
279
+ // ============================================================================
280
+
281
+ const server = http.createServer((req, res) => {
282
+ const tunnelId = getTunnelIdFromHost(req.headers.host);
283
+
284
+ // Bare domain requests (no subdomain)
285
+ if (!tunnelId) {
286
+ // Cookie-setting endpoint: GET /__volter_auth?__volter_token=<jwt>
287
+ if (req.url && req.url.startsWith('/__volter_auth')) {
288
+ setCorsHeaders(req, res);
289
+ if (req.method === 'OPTIONS') {
290
+ res.writeHead(204);
291
+ res.end();
292
+ return;
293
+ }
294
+ try {
295
+ const url = new URL(req.url, `http://${req.headers.host}`);
296
+ const token = url.searchParams.get('__volter_token');
297
+ if (!token || !JWT_SECRET) {
298
+ res.writeHead(400, { 'Content-Type': 'application/json' });
299
+ res.end(JSON.stringify({ error: 'Missing token parameter' }));
300
+ return;
301
+ }
302
+ // Validate JWT
303
+ try {
304
+ jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] });
305
+ } catch {
306
+ res.writeHead(401, { 'Content-Type': 'application/json' });
307
+ res.end(JSON.stringify({ error: 'Invalid token' }));
308
+ return;
309
+ }
310
+ // Set cookie on the tunnel domain. SameSite=None; Secure so the cookie
311
+ // can be set and sent from within cross-origin iframes. Safe because the
312
+ // tunnel server strips the cookie before forwarding to downstream services.
313
+ const cookieDomain = '.' + DOMAIN;
314
+ const maxAge = 3600; // 1 hour
315
+ res.writeHead(200, {
316
+ 'Content-Type': 'application/json',
317
+ 'Set-Cookie': `__volter_auth=${token}; Domain=${cookieDomain}; HttpOnly; SameSite=None; Secure; Path=/; Max-Age=${maxAge}`,
318
+ });
319
+ res.end(JSON.stringify({ ok: true }));
320
+ } catch {
321
+ res.writeHead(400, { 'Content-Type': 'application/json' });
322
+ res.end(JSON.stringify({ error: 'Invalid request' }));
323
+ }
324
+ return;
325
+ }
326
+
327
+ if (req.url === '/api/status') {
328
+ res.writeHead(200, { 'Content-Type': 'application/json' });
329
+ res.end(
330
+ JSON.stringify({
331
+ tunnels: clients.size,
332
+ pending: pendingRequests.size,
333
+ streaming: streamingResponses.size,
334
+ wsRelays: wsConnections.size,
335
+ })
336
+ );
337
+ return;
338
+ }
339
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
340
+ res.end('ws-tunnel server');
341
+ return;
342
+ }
343
+
344
+ // All tunneled requests get CORS headers (tunnel traffic is always cross-origin)
345
+ setCorsHeaders(req, res);
346
+
347
+ // Handle preflight at tunnel server level (no auth check for OPTIONS)
348
+ if (req.method === 'OPTIONS') {
349
+ res.writeHead(204);
350
+ res.end();
351
+ return;
352
+ }
353
+
354
+ const client = clients.get(tunnelId);
355
+ if (!client || client.ws.readyState !== 1) {
356
+ res.writeHead(502, { 'Content-Type': 'text/plain' });
357
+ res.end('Tunnel not connected');
358
+ return;
359
+ }
360
+
361
+ // Auth check for auth-required tunnels
362
+ let bootstrapCookie = null;
363
+ if (client.authRequired && JWT_SECRET) {
364
+ const auth = validateAuth(req);
365
+ if (!auth) {
366
+ send401(res);
367
+ return;
368
+ }
369
+ // When auth came from ?token= query param (iframe initial load),
370
+ // bootstrap a cookie so subsequent requests auth via cookie automatically.
371
+ if (auth.source === 'query') {
372
+ const cookieDomain = '.' + DOMAIN;
373
+ const maxAge = 3600; // 1 hour
374
+ bootstrapCookie = `__volter_auth=${auth.token}; Domain=${cookieDomain}; HttpOnly; SameSite=None; Secure; Path=/; Max-Age=${maxAge}`;
375
+ }
376
+ }
377
+
378
+ // Strip ?token= and __volter_auth cookie before forwarding — don't leak auth to downstream
379
+ const forwardUrl = stripTokenParam(req.url);
380
+ const forwardHeaders = stripAuthCookie(req.headers);
381
+
382
+ const chunks = [];
383
+ req.on('data', (chunk) => chunks.push(chunk));
384
+ req.on('end', () => {
385
+ const reqId = ++reqIdCounter;
386
+ const body = Buffer.concat(chunks);
387
+
388
+ const timer = setTimeout(() => {
389
+ pendingRequests.delete(reqId);
390
+ if (!res.headersSent) {
391
+ res.writeHead(504, { 'Content-Type': 'text/plain' });
392
+ res.end('Tunnel timeout');
393
+ }
394
+ }, 30000);
395
+
396
+ pendingRequests.set(reqId, { res, timer, tunnelId, bootstrapCookie });
397
+
398
+ client.ws.send(
399
+ JSON.stringify({
400
+ type: 'request',
401
+ reqId,
402
+ method: req.method,
403
+ path: forwardUrl,
404
+ headers: forwardHeaders,
405
+ body: body.length > 0 ? body.toString('base64') : null,
406
+ })
407
+ );
408
+ });
409
+ });
410
+
411
+ // WebSocket server for control channels AND browser-side WS relay
412
+ const wss = new WebSocketServer({ noServer: true });
413
+ // Separate WSS for browser-side proxied connections
414
+ const proxyWss = new WebSocketServer({ noServer: true });
415
+
416
+ server.on('upgrade', (req, socket, head) => {
417
+ // Raw upgrade sockets have no default error handler — without this,
418
+ // a socket error (e.g. ECONNRESET) would crash the server.
419
+ socket.on('error', (err) => {
420
+ console.log(`[ws-upgrade] socket error: ${err.message}`);
421
+ });
422
+
423
+ const tunnelId = getTunnelIdFromHost(req.headers.host);
424
+
425
+ // No subdomain + /ws path → control channel for tunnel clients
426
+ if (!tunnelId && req.url === '/ws') {
427
+ wss.handleUpgrade(req, socket, head, (ws) => {
428
+ wss.emit('connection', ws, req);
429
+ });
430
+ return;
431
+ }
432
+
433
+ // Subdomain request → proxy WebSocket to tunnel client
434
+ if (tunnelId) {
435
+ const client = clients.get(tunnelId);
436
+ if (!client || client.ws.readyState !== 1) {
437
+ console.log(`[ws-relay] tunnel ${tunnelId} not connected, rejecting upgrade`);
438
+ socket.write('HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n');
439
+ socket.destroy();
440
+ return;
441
+ }
442
+
443
+ // Auth check for auth-required tunnels (WS uses ?token= query param or cookie)
444
+ if (client.authRequired && JWT_SECRET) {
445
+ const payload = validateWsAuth(req);
446
+ if (!payload) {
447
+ console.log(`[ws-relay] auth failed for tunnel ${tunnelId}, rejecting upgrade`);
448
+ socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
449
+ socket.destroy();
450
+ return;
451
+ }
452
+ }
453
+
454
+ // Strip ?token= from the URL before forwarding to tunnel client
455
+ const cleanUrl = stripTokenParam(req.url);
456
+
457
+ // Complete the upgrade with the browser first — get a proper WebSocket object
458
+ proxyWss.handleUpgrade(req, socket, head, (browserWs) => {
459
+ // Error handler for the pending-upgrade phase (before ws-ready wires up
460
+ // the relay error handler). Without this, errors during the gap between
461
+ // upgrade and ws-ready are unhandled.
462
+ browserWs.on('error', (err) => {
463
+ console.log(`[ws-relay] browserWs error (pre-ready): ${err.message}`);
464
+ });
465
+
466
+ const connId = ++reqIdCounter;
467
+ console.log(
468
+ `[ws-relay] browser WS upgraded, sending ws-upgrade to tunnel client: connId=${connId} tunnelId=${tunnelId}`
469
+ );
470
+
471
+ const timer = setTimeout(() => {
472
+ console.log(`[ws-relay] ws-upgrade TIMEOUT for connId=${connId}`);
473
+ pendingUpgrades.delete(connId);
474
+ browserWs.close(1001, 'Tunnel timeout');
475
+ }, 15000);
476
+
477
+ pendingUpgrades.set(connId, { browserWs, timer, tunnelId });
478
+
479
+ // Ask tunnel client to connect to its local WebSocket
480
+ client.ws.send(
481
+ JSON.stringify({
482
+ type: 'ws-upgrade',
483
+ connId,
484
+ path: cleanUrl,
485
+ headers: stripAuthCookie(req.headers),
486
+ })
487
+ );
488
+
489
+ // Buffer browser messages until tunnel client is ready
490
+ const bufferedMessages = [];
491
+ const bufferHandler = (data, isBinary) => {
492
+ bufferedMessages.push({ data, isBinary });
493
+ };
494
+ browserWs.on('message', bufferHandler);
495
+
496
+ // Store buffer info so ws-ready handler can flush and rewire
497
+ pendingUpgrades.get(connId).bufferedMessages = bufferedMessages;
498
+ pendingUpgrades.get(connId).bufferHandler = bufferHandler;
499
+ });
500
+ return;
501
+ }
502
+
503
+ socket.destroy();
504
+ });
505
+
506
+ wss.on('connection', (ws) => {
507
+ let tunnelId = null;
508
+
509
+ ws.on('message', (data) => {
510
+ let msg;
511
+ try {
512
+ msg = JSON.parse(data);
513
+ } catch {
514
+ return;
515
+ }
516
+
517
+ if (msg.type === 'register') {
518
+ // Reject if TUNNEL_SECRET is configured and client didn't provide a matching secret
519
+ if (TUNNEL_SECRET && msg.secret !== TUNNEL_SECRET) {
520
+ ws.send(JSON.stringify({ type: 'error', message: 'Invalid tunnel secret' }));
521
+ ws.close(4003, 'Invalid tunnel secret');
522
+ return;
523
+ }
524
+
525
+ tunnelId = msg.tunnelId || generateId();
526
+ const existing = clients.get(tunnelId);
527
+ if (existing && existing.ws !== ws) {
528
+ if (msg.replace) {
529
+ console.log(`[tunnel] replacing stale client for tunnelId=${tunnelId}`);
530
+ existing.ws.close(4001, 'Replaced by new client');
531
+ } else {
532
+ ws.send(JSON.stringify({
533
+ type: 'error',
534
+ message: `Tunnel ID '${tunnelId}' is already in use by another client. Pass { replace: true } in the register message to take over the existing tunnel.`,
535
+ }));
536
+ ws.close(4002, 'Tunnel ID already in use');
537
+ return;
538
+ }
539
+ }
540
+
541
+ // Store tunnel client with auth setting (default: true)
542
+ const authRequired = msg.authRequired !== false;
543
+ clients.set(tunnelId, { ws, authRequired });
544
+
545
+ const scheme = SECURE ? 'https' : 'http';
546
+ const portSuffix =
547
+ (!SECURE && EXTERNAL_PORT !== 80) || (SECURE && EXTERNAL_PORT !== 443)
548
+ ? `:${EXTERNAL_PORT}`
549
+ : '';
550
+ const url = `${scheme}://${tunnelId}.${DOMAIN}${portSuffix}`;
551
+
552
+ ws.send(JSON.stringify({ type: 'registered', tunnelId, url }));
553
+ console.log(`[tunnel] registered: ${tunnelId} (${clients.size} active, auth=${authRequired})`);
554
+ }
555
+
556
+ if (msg.type === 'response') {
557
+ const pending = pendingRequests.get(msg.reqId);
558
+ if (!pending) return;
559
+
560
+ clearTimeout(pending.timer);
561
+ pendingRequests.delete(msg.reqId);
562
+
563
+ const { res } = pending;
564
+ if (res.headersSent) return;
565
+
566
+ const headers = msg.headers || {};
567
+ delete headers['transfer-encoding'];
568
+ delete headers['connection'];
569
+ // Strip downstream CORS headers — tunnel server manages CORS via setCorsHeaders()
570
+ delete headers['access-control-allow-origin'];
571
+ delete headers['access-control-allow-methods'];
572
+ delete headers['access-control-allow-headers'];
573
+ delete headers['access-control-allow-credentials'];
574
+ stripFrameAncestors(headers);
575
+
576
+ // Bootstrap cookie on first request (when auth came from ?token= query param)
577
+ if (pending.bootstrapCookie) {
578
+ headers['set-cookie'] = pending.bootstrapCookie;
579
+ }
580
+
581
+ res.writeHead(msg.status || 200, headers);
582
+ if (msg.body) {
583
+ res.end(Buffer.from(msg.body, 'base64'));
584
+ } else {
585
+ res.end();
586
+ }
587
+ }
588
+
589
+ // === Streaming HTTP response messages ===
590
+
591
+ if (msg.type === 'response-start') {
592
+ const pending = pendingRequests.get(msg.reqId);
593
+ if (!pending) return;
594
+ clearTimeout(pending.timer);
595
+ pendingRequests.delete(msg.reqId);
596
+
597
+ const { res } = pending;
598
+ if (res.headersSent) return;
599
+
600
+ const headers = msg.headers || {};
601
+ delete headers['transfer-encoding'];
602
+ delete headers['connection'];
603
+ // Strip downstream CORS headers — tunnel server manages CORS via setCorsHeaders()
604
+ delete headers['access-control-allow-origin'];
605
+ delete headers['access-control-allow-methods'];
606
+ delete headers['access-control-allow-headers'];
607
+ delete headers['access-control-allow-credentials'];
608
+ stripFrameAncestors(headers);
609
+
610
+ // Bootstrap cookie on first request (when auth came from ?token= query param)
611
+ if (pending.bootstrapCookie) {
612
+ headers['set-cookie'] = pending.bootstrapCookie;
613
+ }
614
+
615
+ res.writeHead(msg.status || 200, headers);
616
+
617
+ streamingResponses.set(msg.reqId, { res, tunnelId });
618
+
619
+ // If browser disconnects, tell tunnel client to abort
620
+ res.on('close', () => {
621
+ if (streamingResponses.has(msg.reqId)) {
622
+ streamingResponses.delete(msg.reqId);
623
+ if (ws.readyState === 1) {
624
+ ws.send(JSON.stringify({ type: 'request-abort', reqId: msg.reqId }));
625
+ }
626
+ }
627
+ });
628
+ }
629
+
630
+ if (msg.type === 'response-chunk') {
631
+ const streaming = streamingResponses.get(msg.reqId);
632
+ if (streaming) {
633
+ streaming.res.write(Buffer.from(msg.data, 'base64'));
634
+ }
635
+ }
636
+
637
+ if (msg.type === 'response-end') {
638
+ const streaming = streamingResponses.get(msg.reqId);
639
+ if (streaming) {
640
+ streamingResponses.delete(msg.reqId);
641
+ streaming.res.end();
642
+ }
643
+ }
644
+
645
+ // === WebSocket relay messages (message-level) ===
646
+
647
+ if (msg.type === 'ws-ready') {
648
+ const pending = pendingUpgrades.get(msg.connId);
649
+ if (!pending) {
650
+ console.log(`[ws-relay] WARNING: no pending upgrade for connId=${msg.connId}`);
651
+ return;
652
+ }
653
+
654
+ clearTimeout(pending.timer);
655
+ pendingUpgrades.delete(msg.connId);
656
+
657
+ const { browserWs, bufferedMessages, bufferHandler } = pending;
658
+
659
+ // Store active connection
660
+ wsConnections.set(msg.connId, { browserWs, tunnelId: pending.tunnelId, clientWs: ws });
661
+
662
+ // Remove buffer handler and set up real relay
663
+ browserWs.removeListener('message', bufferHandler);
664
+
665
+ // Flush buffered messages
666
+ for (const { data, isBinary } of bufferedMessages) {
667
+ const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
668
+ ws.send(
669
+ JSON.stringify({
670
+ type: 'ws-message',
671
+ connId: msg.connId,
672
+ data: buf.toString('base64'),
673
+ binary: isBinary,
674
+ })
675
+ );
676
+ }
677
+
678
+ // Relay: browser → tunnel client
679
+ browserWs.on('message', (data, isBinary) => {
680
+ if (ws.readyState === 1) {
681
+ const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
682
+ ws.send(
683
+ JSON.stringify({
684
+ type: 'ws-message',
685
+ connId: msg.connId,
686
+ data: buf.toString('base64'),
687
+ binary: isBinary,
688
+ })
689
+ );
690
+ }
691
+ });
692
+
693
+ browserWs.on('close', (code, reason) => {
694
+ console.log(`[ws-relay] browser WS closed connId=${msg.connId} code=${code}`);
695
+ if (ws.readyState === 1) {
696
+ ws.send(
697
+ JSON.stringify({
698
+ type: 'ws-close',
699
+ connId: msg.connId,
700
+ code,
701
+ reason: reason?.toString() || '',
702
+ })
703
+ );
704
+ }
705
+ wsConnections.delete(msg.connId);
706
+ });
707
+
708
+ browserWs.on('error', (err) => {
709
+ console.log(`[ws-relay] browser WS error connId=${msg.connId}: ${err.message}`);
710
+ if (ws.readyState === 1) {
711
+ ws.send(JSON.stringify({ type: 'ws-close', connId: msg.connId }));
712
+ }
713
+ wsConnections.delete(msg.connId);
714
+ });
715
+
716
+ console.log(`[ws-relay] relay started: connId=${msg.connId} tunnel=${pending.tunnelId}`);
717
+ }
718
+
719
+ if (msg.type === 'ws-error') {
720
+ console.log(`[ws-relay] received ws-error for connId=${msg.connId}: ${msg.error}`);
721
+ const pending = pendingUpgrades.get(msg.connId);
722
+ if (pending) {
723
+ clearTimeout(pending.timer);
724
+ pendingUpgrades.delete(msg.connId);
725
+ closeWsSafely(pending.browserWs, 1001, msg.error || 'Tunnel error');
726
+ }
727
+ }
728
+
729
+ // Relay: tunnel client → browser
730
+ if (msg.type === 'ws-message') {
731
+ const conn = wsConnections.get(msg.connId);
732
+ if (conn && conn.browserWs.readyState === 1) {
733
+ const buf = Buffer.from(msg.data, 'base64');
734
+ conn.browserWs.send(buf, { binary: msg.binary });
735
+ }
736
+ }
737
+
738
+ if (msg.type === 'ws-close') {
739
+ const conn = wsConnections.get(msg.connId);
740
+ if (conn) {
741
+ const code =
742
+ msg.code >= 1000 && msg.code <= 4999 && msg.code !== 1005 && msg.code !== 1006
743
+ ? msg.code
744
+ : 1000;
745
+ closeWsSafely(conn.browserWs, code, msg.reason || '');
746
+ wsConnections.delete(msg.connId);
747
+ }
748
+ }
749
+ });
750
+
751
+ ws.on('close', () => {
752
+ if (tunnelId) {
753
+ clients.delete(tunnelId);
754
+
755
+ // Close all proxied WebSocket connections for this tunnel client
756
+ for (const [connId, conn] of wsConnections) {
757
+ if (conn.tunnelId === tunnelId) {
758
+ conn.browserWs.close(1001, 'Tunnel disconnected');
759
+ wsConnections.delete(connId);
760
+ }
761
+ }
762
+
763
+ // End any active streaming responses for this tunnel
764
+ for (const [reqId, streaming] of streamingResponses) {
765
+ if (streaming.tunnelId === tunnelId) {
766
+ streaming.res.end();
767
+ streamingResponses.delete(reqId);
768
+ }
769
+ }
770
+
771
+ // Clean up pending requests for this tunnel
772
+ for (const [reqId, pending] of pendingRequests) {
773
+ if (pending.tunnelId === tunnelId) {
774
+ clearTimeout(pending.timer);
775
+ if (!pending.res.headersSent) {
776
+ pending.res.writeHead(502, { 'Content-Type': 'text/plain' });
777
+ pending.res.end('Tunnel disconnected');
778
+ }
779
+ pendingRequests.delete(reqId);
780
+ }
781
+ }
782
+
783
+ // Clean up pending upgrades
784
+ for (const [connId, pending] of pendingUpgrades) {
785
+ if (pending.tunnelId === tunnelId) {
786
+ clearTimeout(pending.timer);
787
+ pending.browserWs.close(1001, 'Tunnel disconnected');
788
+ pendingUpgrades.delete(connId);
789
+ }
790
+ }
791
+
792
+ console.log(`[tunnel] disconnected: ${tunnelId} (${clients.size} active)`);
793
+ }
794
+ });
795
+
796
+ ws.on('error', () => {
797
+ if (tunnelId) {
798
+ clients.delete(tunnelId);
799
+ }
800
+ });
801
+ });
802
+
803
+ server.on('error', (err) => {
804
+ console.error(`[server] HTTP server error: ${err.message}`);
805
+ });
806
+
807
+ server.listen(PORT, () => {
808
+ console.log(`ws-tunnel server on port ${PORT}, domain=${DOMAIN}, auth=${JWT_SECRET ? 'enabled' : 'disabled'}`);
809
+ });