@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,868 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket-based HTTP tunnel client.
|
|
3
|
+
*
|
|
4
|
+
* Connects to the tunnel server via WebSocket, receives HTTP requests,
|
|
5
|
+
* forwards them to a local port, and sends responses back.
|
|
6
|
+
*
|
|
7
|
+
* Also handles WebSocket relay: the server sends ws-upgrade messages,
|
|
8
|
+
* the client connects to the local WebSocket server using the `ws` library
|
|
9
|
+
* and relays WebSocket messages bidirectionally through the control channel.
|
|
10
|
+
*/
|
|
11
|
+
import http from 'node:http';
|
|
12
|
+
import https from 'node:https';
|
|
13
|
+
import net from 'node:net';
|
|
14
|
+
import WebSocket from 'ws';
|
|
15
|
+
|
|
16
|
+
/** Force HTTP/1.1 for WebSocket connections — HTTP/2 breaks the Upgrade handshake. */
|
|
17
|
+
const http1Agent = new https.Agent({ ALPNProtocols: ['http/1.1'] });
|
|
18
|
+
|
|
19
|
+
/** Minimal logger interface for tunnel client — compatible with pino, console, etc. */
|
|
20
|
+
export interface TunnelLogger {
|
|
21
|
+
info(obj: Record<string, unknown>, msg: string): void;
|
|
22
|
+
warn(obj: Record<string, unknown>, msg: string): void;
|
|
23
|
+
debug(obj: Record<string, unknown>, msg: string): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const defaultLogger: TunnelLogger = {
|
|
27
|
+
info(obj, msg) {
|
|
28
|
+
console.log(msg, obj);
|
|
29
|
+
},
|
|
30
|
+
warn(obj, msg) {
|
|
31
|
+
console.warn(msg, obj);
|
|
32
|
+
},
|
|
33
|
+
debug(obj, msg) {
|
|
34
|
+
console.debug(msg, obj);
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export interface TunnelOptions {
|
|
39
|
+
/** Local port to expose */
|
|
40
|
+
port: number;
|
|
41
|
+
/** Tunnel server URL (e.g., "https://vgit-tunnels.volterapp.com") */
|
|
42
|
+
host: string;
|
|
43
|
+
/** Requested tunnel ID (optional — server generates one if omitted) */
|
|
44
|
+
tunnelId?: string;
|
|
45
|
+
/** Shared secret for tunnel server authentication */
|
|
46
|
+
secret?: string;
|
|
47
|
+
/** Whether the tunnel requires JWT auth for incoming requests (default: true) */
|
|
48
|
+
authRequired?: boolean;
|
|
49
|
+
/** Logger instance (defaults to console-based logger) */
|
|
50
|
+
logger?: TunnelLogger;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface TunnelHandle {
|
|
54
|
+
/** Public tunnel URL (e.g., "https://quick-fox-123.vgit-tunnels.volterapp.com") */
|
|
55
|
+
url: string;
|
|
56
|
+
/** Assigned tunnel ID */
|
|
57
|
+
tunnelId: string;
|
|
58
|
+
/** Close the tunnel connection */
|
|
59
|
+
close: () => void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface TunnelRequest {
|
|
63
|
+
type: 'request';
|
|
64
|
+
reqId: number;
|
|
65
|
+
method: string;
|
|
66
|
+
path: string;
|
|
67
|
+
headers: Record<string, string | string[] | undefined>;
|
|
68
|
+
body: string | null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface TunnelRegistered {
|
|
72
|
+
type: 'registered';
|
|
73
|
+
tunnelId: string;
|
|
74
|
+
url: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface TunnelWsUpgrade {
|
|
78
|
+
type: 'ws-upgrade';
|
|
79
|
+
connId: number;
|
|
80
|
+
path: string;
|
|
81
|
+
headers: Record<string, string | string[] | undefined>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface TunnelWsMessage {
|
|
85
|
+
type: 'ws-message';
|
|
86
|
+
connId: number;
|
|
87
|
+
data: string; // base64
|
|
88
|
+
binary: boolean;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface TunnelWsClose {
|
|
92
|
+
type: 'ws-close';
|
|
93
|
+
connId: number;
|
|
94
|
+
code?: number;
|
|
95
|
+
reason?: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface TunnelRequestAbort {
|
|
99
|
+
type: 'request-abort';
|
|
100
|
+
reqId: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface TunnelError {
|
|
104
|
+
type: 'error';
|
|
105
|
+
message: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
type TunnelMessage =
|
|
109
|
+
| TunnelRequest
|
|
110
|
+
| TunnelRegistered
|
|
111
|
+
| TunnelWsUpgrade
|
|
112
|
+
| TunnelWsMessage
|
|
113
|
+
| TunnelWsClose
|
|
114
|
+
| TunnelRequestAbort
|
|
115
|
+
| TunnelError;
|
|
116
|
+
|
|
117
|
+
// ============================================================================
|
|
118
|
+
// Safe WebSocket close helpers
|
|
119
|
+
//
|
|
120
|
+
// The `ws` library calls socket.destroy() (TCP RST) in two cases:
|
|
121
|
+
// 1. .close() in CONNECTING state → abortHandshake() → stream.socket.destroy()
|
|
122
|
+
// 2. Close timeout (30s after sending close frame) → socket.destroy()
|
|
123
|
+
//
|
|
124
|
+
// TCP RST causes ECONNRESET on the local server, crashing it. These helpers
|
|
125
|
+
// replace .close() with state-aware logic that sends FIN instead of RST.
|
|
126
|
+
// ============================================================================
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Add a no-op error handler to the underlying net.Socket if none exists.
|
|
130
|
+
* Prevents unhandled ECONNRESET from crashing the tunnel client process.
|
|
131
|
+
*/
|
|
132
|
+
function patchSocketErrorHandler(ws: WebSocket): void {
|
|
133
|
+
const internal = ws as unknown as Record<string, unknown>;
|
|
134
|
+
const socket = internal._socket;
|
|
135
|
+
if (socket instanceof net.Socket && socket.listenerCount('error') === 0) {
|
|
136
|
+
socket.on('error', () => {
|
|
137
|
+
// Intentional no-op — the WebSocket 'error' and 'close' events
|
|
138
|
+
// handle cleanup. This just prevents the socket error from being
|
|
139
|
+
// unhandled and crashing the process.
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Close an OPEN WebSocket gracefully, replacing the ws library's 30s
|
|
146
|
+
* destroy timer with one that calls socket.end() (FIN) instead of
|
|
147
|
+
* socket.destroy() (RST).
|
|
148
|
+
*/
|
|
149
|
+
function safeCloseOpen(ws: WebSocket, code: number, reason: string): void {
|
|
150
|
+
patchSocketErrorHandler(ws);
|
|
151
|
+
|
|
152
|
+
// Send the close frame normally
|
|
153
|
+
ws.close(code, reason);
|
|
154
|
+
|
|
155
|
+
// Replace the ws library's internal close timer.
|
|
156
|
+
// ws sets _closeTimer after calling close() — it fires socket.destroy()
|
|
157
|
+
// after 30s if the peer doesn't respond with a close frame.
|
|
158
|
+
const internal = ws as unknown as Record<string, unknown>;
|
|
159
|
+
const existingTimer = internal._closeTimer;
|
|
160
|
+
if (existingTimer) {
|
|
161
|
+
clearTimeout(existingTimer as ReturnType<typeof setTimeout>);
|
|
162
|
+
internal._closeTimer = null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Set our own timer that sends FIN instead of RST
|
|
166
|
+
const socket = internal._socket;
|
|
167
|
+
if (socket instanceof net.Socket) {
|
|
168
|
+
const finTimer = setTimeout(() => {
|
|
169
|
+
if (!socket.destroyed) {
|
|
170
|
+
socket.end(); // FIN, not RST
|
|
171
|
+
}
|
|
172
|
+
}, 30000);
|
|
173
|
+
// Don't let this timer keep the process alive
|
|
174
|
+
finTimer.unref();
|
|
175
|
+
internal._closeTimer = finTimer;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* State-aware close that never sends TCP RST to the local server.
|
|
181
|
+
*
|
|
182
|
+
* - CONNECTING: Don't call .close() (which triggers abortHandshake → destroy).
|
|
183
|
+
* Instead, remove relay listeners and let the connection either open
|
|
184
|
+
* (then close gracefully) or fail naturally.
|
|
185
|
+
* - OPEN: Use safeCloseOpen() to replace the destroy timer.
|
|
186
|
+
* - CLOSING: Already closing, just patch the error handler.
|
|
187
|
+
* - CLOSED: No-op.
|
|
188
|
+
*/
|
|
189
|
+
function safeClose(ws: WebSocket, code?: number, reason?: string): void {
|
|
190
|
+
const closeCode = code ?? 1000;
|
|
191
|
+
const closeReason = reason ?? '';
|
|
192
|
+
|
|
193
|
+
switch (ws.readyState) {
|
|
194
|
+
case WebSocket.CONNECTING: {
|
|
195
|
+
// Don't call .close() — it would call abortHandshake() → socket.destroy()
|
|
196
|
+
// Remove message relay listeners so no data flows if the connection opens
|
|
197
|
+
ws.removeAllListeners('message');
|
|
198
|
+
ws.removeAllListeners('open');
|
|
199
|
+
|
|
200
|
+
// If it eventually opens, close it gracefully then
|
|
201
|
+
ws.on('open', () => {
|
|
202
|
+
safeCloseOpen(ws, closeCode, closeReason);
|
|
203
|
+
});
|
|
204
|
+
// If it errors (ECONNREFUSED, etc.), that's fine — natural teardown
|
|
205
|
+
|
|
206
|
+
// Fallback: if neither open nor error fires within 5s (e.g. TCP connects
|
|
207
|
+
// but HTTP upgrade hangs), terminate to prevent zombie accumulation.
|
|
208
|
+
const zombieTimer = setTimeout(() => {
|
|
209
|
+
if (ws.readyState === WebSocket.CONNECTING) {
|
|
210
|
+
ws.terminate();
|
|
211
|
+
}
|
|
212
|
+
}, 5000);
|
|
213
|
+
ws.on('open', () => clearTimeout(zombieTimer));
|
|
214
|
+
ws.on('error', () => clearTimeout(zombieTimer));
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
case WebSocket.OPEN: {
|
|
218
|
+
safeCloseOpen(ws, closeCode, closeReason);
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
case WebSocket.CLOSING: {
|
|
222
|
+
// Already closing — just make sure the socket error handler is patched
|
|
223
|
+
patchSocketErrorHandler(ws);
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
case WebSocket.CLOSED: {
|
|
227
|
+
// Nothing to do
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Create a tunnel to expose a local port via the tunnel server.
|
|
235
|
+
*/
|
|
236
|
+
/**
|
|
237
|
+
* Resolve which loopback address can reach a given port.
|
|
238
|
+
* Tries 127.0.0.1 (IPv4) first, then ::1 (IPv6).
|
|
239
|
+
* Returns the working address, or '127.0.0.1' as default.
|
|
240
|
+
*/
|
|
241
|
+
async function resolveLocalHost(port: number): Promise<string> {
|
|
242
|
+
for (const addr of ['127.0.0.1', '::1']) {
|
|
243
|
+
const ok = await new Promise<boolean>((resolve) => {
|
|
244
|
+
const sock = net.connect({ host: addr, port }, () => {
|
|
245
|
+
sock.destroy();
|
|
246
|
+
resolve(true);
|
|
247
|
+
});
|
|
248
|
+
sock.on('error', () => resolve(false));
|
|
249
|
+
sock.setTimeout(500, () => {
|
|
250
|
+
sock.destroy();
|
|
251
|
+
resolve(false);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
if (ok) return addr;
|
|
255
|
+
}
|
|
256
|
+
return '127.0.0.1';
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function createTunnel({
|
|
260
|
+
port,
|
|
261
|
+
host,
|
|
262
|
+
tunnelId,
|
|
263
|
+
secret,
|
|
264
|
+
authRequired,
|
|
265
|
+
logger,
|
|
266
|
+
}: TunnelOptions): Promise<TunnelHandle> {
|
|
267
|
+
const log = logger ?? defaultLogger;
|
|
268
|
+
const wsUrl = `${host.replace(/^http/, 'ws')}/ws`;
|
|
269
|
+
|
|
270
|
+
// Resolved loopback address for this port (set before first connection)
|
|
271
|
+
let localHost = '127.0.0.1';
|
|
272
|
+
|
|
273
|
+
// Shared state across reconnections
|
|
274
|
+
let closed = false;
|
|
275
|
+
let reconnectDelay = 1000;
|
|
276
|
+
const MAX_RECONNECT_DELAY = 30000;
|
|
277
|
+
// Set to true on any successful registration. Once true, reconnect attempts that
|
|
278
|
+
// fail (close without receiving 'registered') will still retry instead of giving up.
|
|
279
|
+
let everRegistered = false;
|
|
280
|
+
|
|
281
|
+
function connect(
|
|
282
|
+
onRegistered: (handle: TunnelHandle) => void,
|
|
283
|
+
onFirstError: ((err: Error) => void) | null
|
|
284
|
+
): void {
|
|
285
|
+
if (closed) return;
|
|
286
|
+
|
|
287
|
+
const ws = new WebSocket(wsUrl, { agent: http1Agent });
|
|
288
|
+
let registered = false;
|
|
289
|
+
// Hoisted so every teardown path (a reconnect via the 'close' handler, or an
|
|
290
|
+
// explicit handle.close()) can clear it. It used to be declared inside the
|
|
291
|
+
// 'registered' handler and cleared only on explicit close — so each reconnect
|
|
292
|
+
// leaked an interval that kept firing ws.ping() on a dead socket forever, and
|
|
293
|
+
// when the control connection flaps (reconnecting every second) those pile up
|
|
294
|
+
// fast.
|
|
295
|
+
let keepaliveInterval: ReturnType<typeof setInterval> | null = null;
|
|
296
|
+
// Resets the reconnect backoff once the link has proven stable (see the
|
|
297
|
+
// 'registered' handler). Cleared on close so a connection that drops before
|
|
298
|
+
// proving stable keeps — and keeps growing — its backoff instead of snapping
|
|
299
|
+
// back to a 1s retry.
|
|
300
|
+
let stableTimer: ReturnType<typeof setTimeout> | null = null;
|
|
301
|
+
|
|
302
|
+
// Track local WebSocket connections: connId → WebSocket
|
|
303
|
+
const localWsConnections = new Map<number, WebSocket>();
|
|
304
|
+
// Track active HTTP requests for abort support: reqId → http.ClientRequest
|
|
305
|
+
const activeRequests = new Map<number, http.ClientRequest>();
|
|
306
|
+
|
|
307
|
+
const timeout = setTimeout(() => {
|
|
308
|
+
ws.close();
|
|
309
|
+
if (onFirstError) {
|
|
310
|
+
onFirstError(new Error('Tunnel connection timeout'));
|
|
311
|
+
onFirstError = null;
|
|
312
|
+
}
|
|
313
|
+
}, 10000);
|
|
314
|
+
|
|
315
|
+
ws.on('open', () => {
|
|
316
|
+
ws.send(
|
|
317
|
+
JSON.stringify({
|
|
318
|
+
type: 'register',
|
|
319
|
+
tunnelId,
|
|
320
|
+
secret,
|
|
321
|
+
replace: true,
|
|
322
|
+
authRequired: authRequired !== false,
|
|
323
|
+
})
|
|
324
|
+
);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
ws.on('message', async (data: WebSocket.RawData) => {
|
|
328
|
+
let msg: TunnelMessage;
|
|
329
|
+
try {
|
|
330
|
+
msg = JSON.parse(data.toString());
|
|
331
|
+
} catch (err) {
|
|
332
|
+
log.warn(
|
|
333
|
+
{ component: 'tunnel_client', action: 'message_parse_error', error: String(err) },
|
|
334
|
+
`Failed to parse tunnel message: ${err}`
|
|
335
|
+
);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
log.info(
|
|
340
|
+
{ component: 'tunnel_client', action: 'message_received', port, type: msg.type },
|
|
341
|
+
`[MSG] Received: ${msg.type}`
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
if (msg.type === 'error') {
|
|
345
|
+
clearTimeout(timeout);
|
|
346
|
+
if (onFirstError) {
|
|
347
|
+
onFirstError(new Error(`Tunnel server rejected connection: ${msg.message}`));
|
|
348
|
+
onFirstError = null;
|
|
349
|
+
}
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (msg.type === 'registered') {
|
|
354
|
+
clearTimeout(timeout);
|
|
355
|
+
registered = true;
|
|
356
|
+
everRegistered = true;
|
|
357
|
+
// Reset backoff only once the connection proves stable — NOT the instant
|
|
358
|
+
// we register. A server that accepts the registration then drops us a
|
|
359
|
+
// second later (infra cycling) would otherwise keep resetting us to a 1s
|
|
360
|
+
// retry, and we'd reconnect every second forever, hammering an already
|
|
361
|
+
// struggling server. Letting the backoff grow until the link holds for
|
|
362
|
+
// 30s eases off instead; a healthy link clears the timer well within it.
|
|
363
|
+
if (stableTimer) clearTimeout(stableTimer);
|
|
364
|
+
stableTimer = setTimeout(() => {
|
|
365
|
+
reconnectDelay = 1000;
|
|
366
|
+
}, 30000);
|
|
367
|
+
stableTimer.unref();
|
|
368
|
+
|
|
369
|
+
// Send keepalive every 25 seconds to prevent Fly.io idle timeout (30s)
|
|
370
|
+
keepaliveInterval = setInterval(() => {
|
|
371
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
372
|
+
ws.ping();
|
|
373
|
+
}
|
|
374
|
+
}, 25000);
|
|
375
|
+
|
|
376
|
+
log.info(
|
|
377
|
+
{
|
|
378
|
+
component: 'tunnel_client',
|
|
379
|
+
action: 'registered',
|
|
380
|
+
port,
|
|
381
|
+
tunnelId: msg.tunnelId,
|
|
382
|
+
url: msg.url,
|
|
383
|
+
},
|
|
384
|
+
`Tunnel registered: localhost:${port} → ${msg.url}`
|
|
385
|
+
);
|
|
386
|
+
onRegistered({
|
|
387
|
+
url: msg.url,
|
|
388
|
+
tunnelId: msg.tunnelId,
|
|
389
|
+
close: () => {
|
|
390
|
+
closed = true;
|
|
391
|
+
if (keepaliveInterval) clearInterval(keepaliveInterval);
|
|
392
|
+
if (stableTimer) clearTimeout(stableTimer);
|
|
393
|
+
for (const [, localWs] of localWsConnections) {
|
|
394
|
+
safeClose(localWs);
|
|
395
|
+
}
|
|
396
|
+
localWsConnections.clear();
|
|
397
|
+
ws.close();
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (msg.type === 'request') {
|
|
403
|
+
const localReq = forwardRequest(port, localHost, msg, ws, activeRequests, async (err) => {
|
|
404
|
+
// On ECONNREFUSED, re-resolve loopback and retry once
|
|
405
|
+
if (err.code === 'ECONNREFUSED') {
|
|
406
|
+
const newAddr = await resolveLocalHost(port);
|
|
407
|
+
if (newAddr !== localHost) {
|
|
408
|
+
log.info(
|
|
409
|
+
{
|
|
410
|
+
component: 'tunnel_client',
|
|
411
|
+
action: 're_resolved_local_host',
|
|
412
|
+
port,
|
|
413
|
+
from: localHost,
|
|
414
|
+
to: newAddr,
|
|
415
|
+
},
|
|
416
|
+
`Loopback changed from ${localHost} to ${newAddr} for port ${port}`
|
|
417
|
+
);
|
|
418
|
+
localHost = newAddr;
|
|
419
|
+
const retryReq = forwardRequest(port, localHost, msg, ws, activeRequests);
|
|
420
|
+
activeRequests.set(msg.reqId, retryReq);
|
|
421
|
+
return true; // suppressed the error
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return false;
|
|
425
|
+
});
|
|
426
|
+
activeRequests.set(msg.reqId, localReq);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (msg.type === 'request-abort') {
|
|
430
|
+
const localReq = activeRequests.get(msg.reqId);
|
|
431
|
+
if (localReq) {
|
|
432
|
+
localReq.destroy();
|
|
433
|
+
activeRequests.delete(msg.reqId);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// === WebSocket relay handling (message-level) ===
|
|
438
|
+
|
|
439
|
+
if (msg.type === 'ws-upgrade') {
|
|
440
|
+
log.info(
|
|
441
|
+
{
|
|
442
|
+
component: 'tunnel_client',
|
|
443
|
+
action: 'ws_upgrade_received',
|
|
444
|
+
connId: msg.connId,
|
|
445
|
+
path: msg.path,
|
|
446
|
+
},
|
|
447
|
+
`[WS-RELAY] Received ws-upgrade connId=${msg.connId}`
|
|
448
|
+
);
|
|
449
|
+
handleWsUpgrade(port, localHost, ws, msg, localWsConnections, log);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (msg.type === 'ws-message') {
|
|
453
|
+
const localWs = localWsConnections.get(msg.connId);
|
|
454
|
+
if (localWs && localWs.readyState === WebSocket.OPEN) {
|
|
455
|
+
const buf = Buffer.from(msg.data, 'base64');
|
|
456
|
+
localWs.send(buf, { binary: msg.binary });
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (msg.type === 'ws-close') {
|
|
461
|
+
const localWs = localWsConnections.get(msg.connId);
|
|
462
|
+
if (localWs) {
|
|
463
|
+
const code =
|
|
464
|
+
msg.code &&
|
|
465
|
+
msg.code >= 1000 &&
|
|
466
|
+
msg.code <= 4999 &&
|
|
467
|
+
msg.code !== 1005 &&
|
|
468
|
+
msg.code !== 1006
|
|
469
|
+
? msg.code
|
|
470
|
+
: 1000;
|
|
471
|
+
safeClose(localWs, code, msg.reason || '');
|
|
472
|
+
localWsConnections.delete(msg.connId);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
ws.on('error', (err: Error) => {
|
|
478
|
+
clearTimeout(timeout);
|
|
479
|
+
log.warn(
|
|
480
|
+
{ component: 'tunnel_client', action: 'ws_error', port, error: err.message },
|
|
481
|
+
`[ERROR] WebSocket error: ${err.message}`
|
|
482
|
+
);
|
|
483
|
+
if (onFirstError) {
|
|
484
|
+
onFirstError(err);
|
|
485
|
+
onFirstError = null;
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
ws.on('close', () => {
|
|
490
|
+
clearTimeout(timeout);
|
|
491
|
+
if (keepaliveInterval) {
|
|
492
|
+
clearInterval(keepaliveInterval);
|
|
493
|
+
keepaliveInterval = null;
|
|
494
|
+
}
|
|
495
|
+
if (stableTimer) {
|
|
496
|
+
clearTimeout(stableTimer);
|
|
497
|
+
stableTimer = null;
|
|
498
|
+
}
|
|
499
|
+
for (const [, localWs] of localWsConnections) {
|
|
500
|
+
safeClose(localWs);
|
|
501
|
+
}
|
|
502
|
+
localWsConnections.clear();
|
|
503
|
+
|
|
504
|
+
if (closed) return;
|
|
505
|
+
|
|
506
|
+
// Reconnect with exponential backoff
|
|
507
|
+
const delay = reconnectDelay;
|
|
508
|
+
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
|
|
509
|
+
|
|
510
|
+
if (!registered && !everRegistered) {
|
|
511
|
+
// This connect attempt never registered and we've never had a successful
|
|
512
|
+
// registration — caller got the error via onFirstError, don't retry.
|
|
513
|
+
log.warn(
|
|
514
|
+
{ component: 'tunnel_client', action: 'close_before_registration', port },
|
|
515
|
+
`Tunnel closed before registration completed`
|
|
516
|
+
);
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
log.info(
|
|
521
|
+
{ component: 'tunnel_client', action: 'reconnecting', port, delay, registered },
|
|
522
|
+
`Tunnel disconnected, reconnecting in ${delay}ms`
|
|
523
|
+
);
|
|
524
|
+
setTimeout(() => connect(onRegistered, null), delay);
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
return new Promise((resolve, reject) => {
|
|
529
|
+
// Resolve which loopback address works before connecting
|
|
530
|
+
resolveLocalHost(port).then((addr) => {
|
|
531
|
+
localHost = addr;
|
|
532
|
+
if (addr !== '127.0.0.1') {
|
|
533
|
+
log.info(
|
|
534
|
+
{ component: 'tunnel_client', action: 'resolved_local_host', port, address: addr },
|
|
535
|
+
`Using ${addr} for localhost:${port}`
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
connect(
|
|
539
|
+
(handle) => resolve(handle),
|
|
540
|
+
(err) => reject(err)
|
|
541
|
+
);
|
|
542
|
+
});
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Handle a WebSocket upgrade request from the tunnel server.
|
|
548
|
+
* Uses the `ws` library to connect to the local server (avoids Bun segfault
|
|
549
|
+
* with raw http.request() upgrade to self).
|
|
550
|
+
*/
|
|
551
|
+
function handleWsUpgrade(
|
|
552
|
+
port: number,
|
|
553
|
+
localAddr: string,
|
|
554
|
+
controlWs: WebSocket,
|
|
555
|
+
msg: TunnelWsUpgrade,
|
|
556
|
+
localWsConnections: Map<number, WebSocket>,
|
|
557
|
+
log: TunnelLogger
|
|
558
|
+
): void {
|
|
559
|
+
const wsHost = localAddr.includes(':') ? `[${localAddr}]` : localAddr;
|
|
560
|
+
const localWsUrl = `ws://${wsHost}:${port}${msg.path}`;
|
|
561
|
+
|
|
562
|
+
// Forward the WebSocket subprotocol from the original browser request.
|
|
563
|
+
// Vite 6.x requires "vite-hmr" — without it, the upgrade is silently ignored.
|
|
564
|
+
const rawProtocol = msg.headers['sec-websocket-protocol'];
|
|
565
|
+
const protocols = rawProtocol
|
|
566
|
+
? typeof rawProtocol === 'string'
|
|
567
|
+
? rawProtocol.split(',').map((p) => p.trim())
|
|
568
|
+
: rawProtocol
|
|
569
|
+
: [];
|
|
570
|
+
|
|
571
|
+
log.info(
|
|
572
|
+
{ component: 'tunnel_client', action: 'ws_upgrade_start', connId: msg.connId, port, protocols },
|
|
573
|
+
`[WS-RELAY] Connecting WebSocket to localhost:${port}`
|
|
574
|
+
);
|
|
575
|
+
|
|
576
|
+
// Mirror the HTTP relay: forward the browser's headers (notably the auth
|
|
577
|
+
// cookie) and stamp x-forwarded-* so the local app can both authenticate the
|
|
578
|
+
// upgrade and tell it arrived through the tunnel rather than from a genuine
|
|
579
|
+
// loopback client. Strip the hop-by-hop handshake headers the ws library
|
|
580
|
+
// sets itself (and sec-websocket-protocol, which is passed via `protocols`).
|
|
581
|
+
const headers: Record<string, string | string[] | undefined> = { ...msg.headers };
|
|
582
|
+
if (headers.host) {
|
|
583
|
+
headers['x-forwarded-host'] = headers.host;
|
|
584
|
+
}
|
|
585
|
+
headers['x-forwarded-proto'] = 'https';
|
|
586
|
+
headers.host = `localhost:${port}`;
|
|
587
|
+
headers.origin = `http://localhost:${port}`;
|
|
588
|
+
for (const hopByHop of [
|
|
589
|
+
'connection',
|
|
590
|
+
'upgrade',
|
|
591
|
+
'sec-websocket-key',
|
|
592
|
+
'sec-websocket-version',
|
|
593
|
+
'sec-websocket-extensions',
|
|
594
|
+
'sec-websocket-protocol',
|
|
595
|
+
]) {
|
|
596
|
+
delete headers[hopByHop];
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const localWs = new WebSocket(localWsUrl, protocols, { headers });
|
|
600
|
+
|
|
601
|
+
const connectTimeout = setTimeout(() => {
|
|
602
|
+
log.warn(
|
|
603
|
+
{ component: 'tunnel_client', action: 'ws_upgrade_timeout', connId: msg.connId },
|
|
604
|
+
`[WS-RELAY] Connection timeout for connId=${msg.connId}`
|
|
605
|
+
);
|
|
606
|
+
safeClose(localWs);
|
|
607
|
+
if (controlWs.readyState === WebSocket.OPEN) {
|
|
608
|
+
controlWs.send(
|
|
609
|
+
JSON.stringify({
|
|
610
|
+
type: 'ws-error',
|
|
611
|
+
connId: msg.connId,
|
|
612
|
+
error: 'Connection timeout',
|
|
613
|
+
})
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
}, 15000);
|
|
617
|
+
|
|
618
|
+
localWs.on('open', () => {
|
|
619
|
+
clearTimeout(connectTimeout);
|
|
620
|
+
localWsConnections.set(msg.connId, localWs);
|
|
621
|
+
|
|
622
|
+
log.info(
|
|
623
|
+
{ component: 'tunnel_client', action: 'ws_upgrade_success', connId: msg.connId },
|
|
624
|
+
`[WS-RELAY] Connected, sending ws-ready for connId=${msg.connId}`
|
|
625
|
+
);
|
|
626
|
+
|
|
627
|
+
controlWs.send(
|
|
628
|
+
JSON.stringify({
|
|
629
|
+
type: 'ws-ready',
|
|
630
|
+
connId: msg.connId,
|
|
631
|
+
})
|
|
632
|
+
);
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
localWs.on('message', (data: WebSocket.RawData, isBinary: boolean) => {
|
|
636
|
+
if (controlWs.readyState === WebSocket.OPEN) {
|
|
637
|
+
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data as ArrayBuffer);
|
|
638
|
+
controlWs.send(
|
|
639
|
+
JSON.stringify({
|
|
640
|
+
type: 'ws-message',
|
|
641
|
+
connId: msg.connId,
|
|
642
|
+
data: buf.toString('base64'),
|
|
643
|
+
binary: isBinary,
|
|
644
|
+
})
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
localWs.on('close', (code: number, reason: Buffer) => {
|
|
650
|
+
log.info(
|
|
651
|
+
{ component: 'tunnel_client', action: 'ws_relay_local_close', connId: msg.connId, code },
|
|
652
|
+
`[WS-RELAY] Local WS closed connId=${msg.connId} code=${code}`
|
|
653
|
+
);
|
|
654
|
+
if (controlWs.readyState === WebSocket.OPEN) {
|
|
655
|
+
controlWs.send(
|
|
656
|
+
JSON.stringify({
|
|
657
|
+
type: 'ws-close',
|
|
658
|
+
connId: msg.connId,
|
|
659
|
+
code,
|
|
660
|
+
reason: reason?.toString() || '',
|
|
661
|
+
})
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
localWsConnections.delete(msg.connId);
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
localWs.on('error', (err: Error) => {
|
|
668
|
+
clearTimeout(connectTimeout);
|
|
669
|
+
log.warn(
|
|
670
|
+
{
|
|
671
|
+
component: 'tunnel_client',
|
|
672
|
+
action: 'ws_relay_error',
|
|
673
|
+
connId: msg.connId,
|
|
674
|
+
error: err.message,
|
|
675
|
+
},
|
|
676
|
+
`[WS-RELAY] Local WS error connId=${msg.connId}: ${err.message}`
|
|
677
|
+
);
|
|
678
|
+
if (controlWs.readyState === WebSocket.OPEN) {
|
|
679
|
+
controlWs.send(
|
|
680
|
+
JSON.stringify({
|
|
681
|
+
type: 'ws-error',
|
|
682
|
+
connId: msg.connId,
|
|
683
|
+
error: err.message,
|
|
684
|
+
})
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
localWsConnections.delete(msg.connId);
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function send502(ws: WebSocket, reqId: number, message: string): void {
|
|
692
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
693
|
+
ws.send(
|
|
694
|
+
JSON.stringify({
|
|
695
|
+
type: 'response',
|
|
696
|
+
reqId,
|
|
697
|
+
status: 502,
|
|
698
|
+
headers: { 'content-type': 'text/plain' },
|
|
699
|
+
body: Buffer.from(`Local server error: ${message}`).toString('base64'),
|
|
700
|
+
})
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Forward a tunneled request to the local server, streaming the response
|
|
707
|
+
* back over the WebSocket as response-start / response-chunk / response-end.
|
|
708
|
+
*/
|
|
709
|
+
function forwardRequest(
|
|
710
|
+
port: number,
|
|
711
|
+
localAddr: string,
|
|
712
|
+
msg: TunnelRequest,
|
|
713
|
+
ws: WebSocket,
|
|
714
|
+
activeRequests: Map<number, http.ClientRequest>,
|
|
715
|
+
onConnRefused?: (err: NodeJS.ErrnoException) => Promise<boolean>
|
|
716
|
+
): http.ClientRequest {
|
|
717
|
+
const headers: Record<string, string | string[] | undefined> = { ...msg.headers };
|
|
718
|
+
// Preserve the original Host for apps that build redirect URLs from it (e.g. Clerk/Next.js)
|
|
719
|
+
// Forward the original as X-Forwarded-Host so the app knows the public hostname
|
|
720
|
+
if (headers.host) {
|
|
721
|
+
headers['x-forwarded-host'] = headers.host;
|
|
722
|
+
}
|
|
723
|
+
headers['x-forwarded-proto'] = 'https';
|
|
724
|
+
// Rewrite host so the target server accepts the request
|
|
725
|
+
headers.host = `localhost:${port}`;
|
|
726
|
+
// Rewrite origin and referer to localhost so the local app behaves as if
|
|
727
|
+
// accessed directly. Prevents CSRF rejections from frameworks that check origin.
|
|
728
|
+
const localOrigin = `http://localhost:${port}`;
|
|
729
|
+
if (typeof headers.origin === 'string' && !headers.origin.includes('localhost')) {
|
|
730
|
+
headers.origin = localOrigin;
|
|
731
|
+
}
|
|
732
|
+
if (typeof headers.referer === 'string' && !headers.referer.includes('localhost')) {
|
|
733
|
+
try {
|
|
734
|
+
const ref = new URL(headers.referer);
|
|
735
|
+
headers.referer = `${localOrigin}${ref.pathname}${ref.search}${ref.hash}`;
|
|
736
|
+
} catch {
|
|
737
|
+
headers.referer = localOrigin;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
// Remove headers that shouldn't be forwarded
|
|
741
|
+
delete headers['transfer-encoding'];
|
|
742
|
+
|
|
743
|
+
const req = http.request(
|
|
744
|
+
{
|
|
745
|
+
hostname: localAddr,
|
|
746
|
+
port,
|
|
747
|
+
path: msg.path,
|
|
748
|
+
method: msg.method,
|
|
749
|
+
headers: headers as http.OutgoingHttpHeaders,
|
|
750
|
+
},
|
|
751
|
+
(res) => {
|
|
752
|
+
// Send headers immediately
|
|
753
|
+
const responseHeaders: Record<string, string | string[] | undefined> = {};
|
|
754
|
+
for (const [key, value] of Object.entries(res.headers)) {
|
|
755
|
+
responseHeaders[key] = value;
|
|
756
|
+
}
|
|
757
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
758
|
+
ws.send(
|
|
759
|
+
JSON.stringify({
|
|
760
|
+
type: 'response-start',
|
|
761
|
+
reqId: msg.reqId,
|
|
762
|
+
status: res.statusCode ?? 200,
|
|
763
|
+
headers: responseHeaders,
|
|
764
|
+
})
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// Stream body chunks
|
|
769
|
+
res.on('data', (chunk: Buffer) => {
|
|
770
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
771
|
+
ws.send(
|
|
772
|
+
JSON.stringify({
|
|
773
|
+
type: 'response-chunk',
|
|
774
|
+
reqId: msg.reqId,
|
|
775
|
+
data: chunk.toString('base64'),
|
|
776
|
+
})
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
// Signal completion
|
|
782
|
+
res.on('end', () => {
|
|
783
|
+
activeRequests.delete(msg.reqId);
|
|
784
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
785
|
+
ws.send(JSON.stringify({ type: 'response-end', reqId: msg.reqId }));
|
|
786
|
+
}
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
);
|
|
790
|
+
|
|
791
|
+
req.on('error', (err: NodeJS.ErrnoException) => {
|
|
792
|
+
activeRequests.delete(msg.reqId);
|
|
793
|
+
|
|
794
|
+
// If ECONNREFUSED and caller wants to retry with re-resolved address, let them
|
|
795
|
+
if (onConnRefused && err.code === 'ECONNREFUSED') {
|
|
796
|
+
onConnRefused(err).then((retried) => {
|
|
797
|
+
if (retried) return; // caller handled it
|
|
798
|
+
send502(ws, msg.reqId, err.message);
|
|
799
|
+
});
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
send502(ws, msg.reqId, err.message);
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
if (msg.body) {
|
|
807
|
+
req.end(Buffer.from(msg.body, 'base64'));
|
|
808
|
+
} else {
|
|
809
|
+
req.end();
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
return req;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// ============================================================================
|
|
816
|
+
// CLI entry point — `bun run tunnel-client.ts --port 3000 [--host URL] [--tunnel-id ID]`
|
|
817
|
+
// Prints the public URL to stdout, stays alive until SIGTERM/SIGINT.
|
|
818
|
+
// ============================================================================
|
|
819
|
+
|
|
820
|
+
if (import.meta.main) {
|
|
821
|
+
const args = process.argv.slice(2);
|
|
822
|
+
|
|
823
|
+
function flag(name: string): string | undefined {
|
|
824
|
+
const i = args.indexOf(`--${name}`);
|
|
825
|
+
return i >= 0 ? args[i + 1] : undefined;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
const port = Number(flag('port'));
|
|
829
|
+
if (!port) {
|
|
830
|
+
console.error(
|
|
831
|
+
'Usage: bun run tunnel-client.ts --port <port> [--host <url>] [--tunnel-id <id>] [--auth-not-required]'
|
|
832
|
+
);
|
|
833
|
+
process.exit(1);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
const host =
|
|
837
|
+
flag('host') || process.env.TUNNEL_SERVER_URL || 'https://vgit-tunnels.volterapp.com';
|
|
838
|
+
const secret = process.env.TUNNEL_SECRET;
|
|
839
|
+
const tunnelId = flag('tunnel-id');
|
|
840
|
+
const authNotRequired = args.includes('--auth-not-required');
|
|
841
|
+
|
|
842
|
+
// CLI logger: send to stderr so only the URL goes to stdout
|
|
843
|
+
const cliLogger: TunnelLogger = {
|
|
844
|
+
info(_obj, msg) {
|
|
845
|
+
console.error(msg);
|
|
846
|
+
},
|
|
847
|
+
warn(_obj, msg) {
|
|
848
|
+
console.error(msg);
|
|
849
|
+
},
|
|
850
|
+
debug(_obj, msg) {
|
|
851
|
+
console.error(msg);
|
|
852
|
+
},
|
|
853
|
+
};
|
|
854
|
+
|
|
855
|
+
const opts: TunnelOptions = { port, host, logger: cliLogger };
|
|
856
|
+
if (secret) opts.secret = secret;
|
|
857
|
+
if (tunnelId) opts.tunnelId = tunnelId;
|
|
858
|
+
if (authNotRequired) opts.authRequired = false;
|
|
859
|
+
const handle = await createTunnel(opts);
|
|
860
|
+
console.log(handle.url);
|
|
861
|
+
|
|
862
|
+
const shutdown = () => {
|
|
863
|
+
handle.close();
|
|
864
|
+
process.exit(0);
|
|
865
|
+
};
|
|
866
|
+
process.on('SIGTERM', shutdown);
|
|
867
|
+
process.on('SIGINT', shutdown);
|
|
868
|
+
}
|