@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.
- package/README.md +91 -0
- package/client/tunnel-client.ts +868 -0
- package/package.json +43 -0
- package/server/Dockerfile +9 -0
- package/server/fly.toml +23 -0
- package/server/package.json +9 -0
- package/server/server.mjs +809 -0
|
@@ -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
|
+
});
|