@limrun/api 0.47.2 → 0.48.1
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/CHANGELOG.md +17 -0
- package/destination-tunnel-dialer.d.mts +22 -0
- package/destination-tunnel-dialer.d.mts.map +1 -0
- package/destination-tunnel-dialer.d.ts +22 -0
- package/destination-tunnel-dialer.d.ts.map +1 -0
- package/destination-tunnel-dialer.js +491 -0
- package/destination-tunnel-dialer.js.map +1 -0
- package/destination-tunnel-dialer.mjs +486 -0
- package/destination-tunnel-dialer.mjs.map +1 -0
- package/destination-tunnel.d.mts +77 -0
- package/destination-tunnel.d.mts.map +1 -0
- package/destination-tunnel.d.ts +77 -0
- package/destination-tunnel.d.ts.map +1 -0
- package/destination-tunnel.js +249 -0
- package/destination-tunnel.js.map +1 -0
- package/destination-tunnel.mjs +236 -0
- package/destination-tunnel.mjs.map +1 -0
- package/internal/destination-tunnel-management.d.mts +23 -0
- package/internal/destination-tunnel-management.d.mts.map +1 -0
- package/internal/destination-tunnel-management.d.ts +23 -0
- package/internal/destination-tunnel-management.d.ts.map +1 -0
- package/internal/destination-tunnel-management.js +122 -0
- package/internal/destination-tunnel-management.js.map +1 -0
- package/internal/destination-tunnel-management.mjs +117 -0
- package/internal/destination-tunnel-management.mjs.map +1 -0
- package/internal/destination-tunnel-url.d.mts +4 -0
- package/internal/destination-tunnel-url.d.mts.map +1 -0
- package/internal/destination-tunnel-url.d.ts +4 -0
- package/internal/destination-tunnel-url.d.ts.map +1 -0
- package/internal/destination-tunnel-url.js +28 -0
- package/internal/destination-tunnel-url.js.map +1 -0
- package/internal/destination-tunnel-url.mjs +23 -0
- package/internal/destination-tunnel-url.mjs.map +1 -0
- package/ios-client.d.mts +22 -0
- package/ios-client.d.mts.map +1 -1
- package/ios-client.d.ts +22 -0
- package/ios-client.d.ts.map +1 -1
- package/ios-client.js +18 -0
- package/ios-client.js.map +1 -1
- package/ios-client.mjs +18 -0
- package/ios-client.mjs.map +1 -1
- package/package.json +21 -1
- package/resources/android-instances.d.mts +1 -1
- package/resources/android-instances.d.ts +1 -1
- package/resources/xcode-instances-helpers.d.mts +2 -1
- package/resources/xcode-instances-helpers.d.mts.map +1 -1
- package/resources/xcode-instances-helpers.d.ts +2 -1
- package/resources/xcode-instances-helpers.d.ts.map +1 -1
- package/resources/xcode-instances-helpers.js.map +1 -1
- package/resources/xcode-instances-helpers.mjs.map +1 -1
- package/src/destination-tunnel-dialer.ts +554 -0
- package/src/destination-tunnel.ts +354 -0
- package/src/internal/destination-tunnel-management.ts +154 -0
- package/src/internal/destination-tunnel-url.ts +25 -0
- package/src/ios-client.ts +48 -0
- package/src/resources/android-instances.ts +1 -1
- package/src/resources/xcode-instances-helpers.ts +2 -1
- package/src/version.ts +1 -1
- package/version.d.mts +1 -1
- package/version.d.ts +1 -1
- package/version.js +1 -1
- package/version.mjs +1 -1
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
import net from 'net';
|
|
2
|
+
import { WebSocket, type RawData } from 'ws';
|
|
3
|
+
import { nodeProxyTransport } from './internal/proxy-transport';
|
|
4
|
+
import {
|
|
5
|
+
DESTINATION_TUNNEL_VERSION,
|
|
6
|
+
DestinationTunnelProtocolError,
|
|
7
|
+
assertDestinationTunnelOpenAllowed,
|
|
8
|
+
assertDestinationTunnelReady,
|
|
9
|
+
decodeDestinationTunnelDataFrame,
|
|
10
|
+
decodeDestinationTunnelServerMessage,
|
|
11
|
+
encodeDestinationTunnelDataFrame,
|
|
12
|
+
encodeDestinationTunnelClientMessage,
|
|
13
|
+
validateDestinationTunnelRoutes,
|
|
14
|
+
type DestinationTunnelClientMessage,
|
|
15
|
+
type DestinationTunnelOpenFailureReason,
|
|
16
|
+
type DestinationTunnelRoute,
|
|
17
|
+
type DestinationTunnelResetReason,
|
|
18
|
+
type DestinationTunnelServerMessage,
|
|
19
|
+
} from './destination-tunnel';
|
|
20
|
+
import type { LogLevel, TunnelConnectionState, TunnelConnectionStateCallback } from './tunnel';
|
|
21
|
+
|
|
22
|
+
export interface DestinationTcpTunnel {
|
|
23
|
+
tunnelId: string;
|
|
24
|
+
close: () => void;
|
|
25
|
+
getConnectionState: () => TunnelConnectionState;
|
|
26
|
+
onConnectionStateChange: (callback: TunnelConnectionStateCallback) => () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface DestinationTcpTunnelOptions {
|
|
30
|
+
routes: DestinationTunnelRoute[];
|
|
31
|
+
logLevel?: LogLevel;
|
|
32
|
+
maxConnections?: number;
|
|
33
|
+
maxPendingBytesPerConnection?: number;
|
|
34
|
+
maxTotalPendingBytes?: number;
|
|
35
|
+
maxBufferedBytes?: number;
|
|
36
|
+
connectTimeoutMs?: number;
|
|
37
|
+
handshakeTimeoutMs?: number;
|
|
38
|
+
livenessTimeoutMs?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface DialConnection {
|
|
42
|
+
socket: net.Socket;
|
|
43
|
+
phase: 'connecting' | 'open';
|
|
44
|
+
connectTimer: NodeJS.Timeout;
|
|
45
|
+
localInputEnded: boolean;
|
|
46
|
+
remoteInputEnded: boolean;
|
|
47
|
+
pendingWriteBytes: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function startDestinationTcpTunnel(
|
|
51
|
+
remoteURL: string,
|
|
52
|
+
token: string,
|
|
53
|
+
options: DestinationTcpTunnelOptions,
|
|
54
|
+
): Promise<DestinationTcpTunnel> {
|
|
55
|
+
const routes = validateDestinationTunnelRoutes(options.routes);
|
|
56
|
+
const logLevel = options.logLevel ?? 'info';
|
|
57
|
+
const maxConnections = positiveInteger(options.maxConnections ?? 64, 'maxConnections');
|
|
58
|
+
const maxPendingBytesPerConnection = positiveInteger(
|
|
59
|
+
options.maxPendingBytesPerConnection ?? 16 * 1024 * 1024,
|
|
60
|
+
'maxPendingBytesPerConnection',
|
|
61
|
+
);
|
|
62
|
+
const maxTotalPendingBytes = positiveInteger(
|
|
63
|
+
options.maxTotalPendingBytes ?? 16 * 1024 * 1024,
|
|
64
|
+
'maxTotalPendingBytes',
|
|
65
|
+
);
|
|
66
|
+
const maxBufferedBytes = positiveInteger(options.maxBufferedBytes ?? 4 * 1024 * 1024, 'maxBufferedBytes');
|
|
67
|
+
const connectTimeoutMs = positiveInteger(options.connectTimeoutMs ?? 10_000, 'connectTimeoutMs');
|
|
68
|
+
const handshakeTimeoutMs = positiveInteger(options.handshakeTimeoutMs ?? 15_000, 'handshakeTimeoutMs');
|
|
69
|
+
const livenessTimeoutMs = positiveInteger(options.livenessTimeoutMs ?? 90_000, 'livenessTimeoutMs');
|
|
70
|
+
const pingIntervalMs = Math.min(30_000, Math.max(1, Math.floor(livenessTimeoutMs / 3)));
|
|
71
|
+
const resumeBelowBufferedBytes = maxBufferedBytes / 4;
|
|
72
|
+
const hardMaxBufferedBytes = maxBufferedBytes + Math.max(64 * 1024, Math.floor(maxBufferedBytes / 4));
|
|
73
|
+
|
|
74
|
+
const logger = {
|
|
75
|
+
debug: (...args: unknown[]) => {
|
|
76
|
+
if (logLevel === 'debug') console.log('[DestinationTunnel]', ...args);
|
|
77
|
+
},
|
|
78
|
+
info: (...args: unknown[]) => {
|
|
79
|
+
if (logLevel === 'info' || logLevel === 'debug') console.log('[DestinationTunnel]', ...args);
|
|
80
|
+
},
|
|
81
|
+
warn: (...args: unknown[]) => {
|
|
82
|
+
if (logLevel === 'warn' || logLevel === 'info' || logLevel === 'debug') {
|
|
83
|
+
console.warn('[DestinationTunnel]', ...args);
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
error: (...args: unknown[]) => {
|
|
87
|
+
if (logLevel !== 'none') console.error('[DestinationTunnel]', ...args);
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
const connections = new Map<number, DialConnection>();
|
|
93
|
+
const recentlyClosed = new Map<number, NodeJS.Timeout>();
|
|
94
|
+
const stateChangeCallbacks = new Set<TunnelConnectionStateCallback>();
|
|
95
|
+
const url = new URL(remoteURL);
|
|
96
|
+
const proxyAgent = nodeProxyTransport.getWebSocketAgent(url.toString());
|
|
97
|
+
const ws = new WebSocket(url.toString(), {
|
|
98
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
99
|
+
...(proxyAgent ? { agent: proxyAgent } : {}),
|
|
100
|
+
perMessageDeflate: false,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
let connectionState: TunnelConnectionState = 'connecting';
|
|
104
|
+
let pingInterval: NodeJS.Timeout | undefined;
|
|
105
|
+
let handshakeTimer: NodeJS.Timeout | undefined;
|
|
106
|
+
let livenessTimer: NodeJS.Timeout | undefined;
|
|
107
|
+
let tunnelReady = false;
|
|
108
|
+
let closed = false;
|
|
109
|
+
let pausedForBackpressure = false;
|
|
110
|
+
let totalPendingWriteBytes = 0;
|
|
111
|
+
|
|
112
|
+
const updateConnectionState = (state: TunnelConnectionState): void => {
|
|
113
|
+
if (connectionState === state) return;
|
|
114
|
+
connectionState = state;
|
|
115
|
+
for (const callback of stateChangeCallbacks) {
|
|
116
|
+
try {
|
|
117
|
+
callback(state);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
logger.error('Connection state callback failed:', error);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const getConnectionState = (): TunnelConnectionState => connectionState;
|
|
125
|
+
|
|
126
|
+
const onConnectionStateChange = (callback: TunnelConnectionStateCallback): (() => void) => {
|
|
127
|
+
stateChangeCallbacks.add(callback);
|
|
128
|
+
return () => stateChangeCallbacks.delete(callback);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const markRecentlyClosed = (connId: number): void => {
|
|
132
|
+
const existingTimer = recentlyClosed.get(connId);
|
|
133
|
+
if (existingTimer) clearTimeout(existingTimer);
|
|
134
|
+
const timer = setTimeout(() => recentlyClosed.delete(connId), 30_000);
|
|
135
|
+
timer.unref();
|
|
136
|
+
recentlyClosed.set(connId, timer);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const removeConnection = (connId: number, destroySocket: boolean): void => {
|
|
140
|
+
const connection = connections.get(connId);
|
|
141
|
+
if (!connection) return;
|
|
142
|
+
connections.delete(connId);
|
|
143
|
+
clearTimeout(connection.connectTimer);
|
|
144
|
+
markRecentlyClosed(connId);
|
|
145
|
+
if (destroySocket && !connection.socket.destroyed) {
|
|
146
|
+
connection.socket.destroy();
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const closeAllConnections = (): void => {
|
|
151
|
+
for (const connId of Array.from(connections.keys())) {
|
|
152
|
+
removeConnection(connId, true);
|
|
153
|
+
}
|
|
154
|
+
for (const timer of recentlyClosed.values()) {
|
|
155
|
+
clearTimeout(timer);
|
|
156
|
+
}
|
|
157
|
+
recentlyClosed.clear();
|
|
158
|
+
pausedForBackpressure = false;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const close = (): void => {
|
|
162
|
+
if (closed) return;
|
|
163
|
+
closed = true;
|
|
164
|
+
if (pingInterval) {
|
|
165
|
+
clearInterval(pingInterval);
|
|
166
|
+
pingInterval = undefined;
|
|
167
|
+
}
|
|
168
|
+
if (handshakeTimer) {
|
|
169
|
+
clearTimeout(handshakeTimer);
|
|
170
|
+
handshakeTimer = undefined;
|
|
171
|
+
}
|
|
172
|
+
if (livenessTimer) {
|
|
173
|
+
clearTimeout(livenessTimer);
|
|
174
|
+
livenessTimer = undefined;
|
|
175
|
+
}
|
|
176
|
+
closeAllConnections();
|
|
177
|
+
ws.removeAllListeners('open');
|
|
178
|
+
ws.removeAllListeners('message');
|
|
179
|
+
ws.removeAllListeners('ping');
|
|
180
|
+
ws.removeAllListeners('pong');
|
|
181
|
+
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
|
182
|
+
ws.close(1000, 'close');
|
|
183
|
+
}
|
|
184
|
+
updateConnectionState('disconnected');
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const failTransport = (error: Error): void => {
|
|
188
|
+
if (closed) return;
|
|
189
|
+
logger.error(error.message);
|
|
190
|
+
close();
|
|
191
|
+
if (!tunnelReady) {
|
|
192
|
+
reject(error);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const pauseForBackpressure = (): void => {
|
|
197
|
+
if (pausedForBackpressure) return;
|
|
198
|
+
pausedForBackpressure = true;
|
|
199
|
+
for (const connection of connections.values()) {
|
|
200
|
+
if (!connection.socket.destroyed) connection.socket.pause();
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const resumeIfDrained = (): void => {
|
|
205
|
+
if (!pausedForBackpressure || ws.bufferedAmount >= resumeBelowBufferedBytes) return;
|
|
206
|
+
pausedForBackpressure = false;
|
|
207
|
+
for (const connection of connections.values()) {
|
|
208
|
+
if (!connection.socket.destroyed) connection.socket.resume();
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const frameQueued = (): void => {
|
|
213
|
+
if (ws.bufferedAmount > hardMaxBufferedBytes) {
|
|
214
|
+
failTransport(new Error('destination tunnel WebSocket send buffer exceeded its hard limit'));
|
|
215
|
+
} else if (!pausedForBackpressure && ws.bufferedAmount > maxBufferedBytes) {
|
|
216
|
+
pauseForBackpressure();
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const frameSent = (error?: Error): void => {
|
|
221
|
+
if (error) {
|
|
222
|
+
failTransport(error);
|
|
223
|
+
} else {
|
|
224
|
+
resumeIfDrained();
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const sendControl = (message: DestinationTunnelClientMessage): void => {
|
|
229
|
+
if (closed || ws.readyState !== WebSocket.OPEN) {
|
|
230
|
+
failTransport(new Error('destination tunnel WebSocket is not open'));
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
ws.send(encodeDestinationTunnelClientMessage(message), frameSent);
|
|
234
|
+
frameQueued();
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const sendData = (connId: number, payload: Buffer): void => {
|
|
238
|
+
if (closed || ws.readyState !== WebSocket.OPEN) {
|
|
239
|
+
failTransport(new Error('destination tunnel WebSocket closed while sending data'));
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
ws.send(encodeDestinationTunnelDataFrame(connId, payload), { binary: true }, frameSent);
|
|
243
|
+
frameQueued();
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const sendOpenFailure = (
|
|
247
|
+
connId: number,
|
|
248
|
+
reason: DestinationTunnelOpenFailureReason,
|
|
249
|
+
osCode?: string,
|
|
250
|
+
): void => {
|
|
251
|
+
sendControl({
|
|
252
|
+
type: 'openFail',
|
|
253
|
+
connId,
|
|
254
|
+
reason,
|
|
255
|
+
...(osCode ? { osCode } : {}),
|
|
256
|
+
});
|
|
257
|
+
markRecentlyClosed(connId);
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const sendReset = (connId: number, reason: DestinationTunnelResetReason, osCode?: string): void => {
|
|
261
|
+
sendControl({
|
|
262
|
+
type: 'reset',
|
|
263
|
+
connId,
|
|
264
|
+
reason,
|
|
265
|
+
...(osCode ? { osCode } : {}),
|
|
266
|
+
});
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const handleOpen = (message: Extract<DestinationTunnelServerMessage, { type: 'open' }>): void => {
|
|
270
|
+
try {
|
|
271
|
+
assertDestinationTunnelOpenAllowed(message, routes);
|
|
272
|
+
} catch {
|
|
273
|
+
sendOpenFailure(message.connId, 'route_not_allowed');
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (connections.has(message.connId) || recentlyClosed.has(message.connId)) {
|
|
277
|
+
throw new DestinationTunnelProtocolError(`server reused connection ID ${message.connId}`);
|
|
278
|
+
}
|
|
279
|
+
if (connections.size >= maxConnections) {
|
|
280
|
+
sendOpenFailure(message.connId, 'resource_exhausted');
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const socket = net.createConnection({
|
|
285
|
+
host: message.host,
|
|
286
|
+
port: message.port,
|
|
287
|
+
allowHalfOpen: true,
|
|
288
|
+
});
|
|
289
|
+
const connectTimer = setTimeout(() => {
|
|
290
|
+
if (connections.get(message.connId)?.phase !== 'connecting') return;
|
|
291
|
+
sendOpenFailure(message.connId, 'connection_timed_out', 'ETIMEDOUT');
|
|
292
|
+
removeConnection(message.connId, true);
|
|
293
|
+
}, connectTimeoutMs);
|
|
294
|
+
connectTimer.unref();
|
|
295
|
+
const connection: DialConnection = {
|
|
296
|
+
socket,
|
|
297
|
+
phase: 'connecting',
|
|
298
|
+
connectTimer,
|
|
299
|
+
localInputEnded: false,
|
|
300
|
+
remoteInputEnded: false,
|
|
301
|
+
pendingWriteBytes: 0,
|
|
302
|
+
};
|
|
303
|
+
connections.set(message.connId, connection);
|
|
304
|
+
if (pausedForBackpressure) {
|
|
305
|
+
socket.pause();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
socket.once('connect', () => {
|
|
309
|
+
if (connections.get(message.connId) !== connection) return;
|
|
310
|
+
clearTimeout(connectTimer);
|
|
311
|
+
connection.phase = 'open';
|
|
312
|
+
sendControl({ type: 'openOk', connId: message.connId });
|
|
313
|
+
logger.debug(`Connected ${message.connId} to ${message.host}:${message.port}`);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
socket.on('data', (payload: Buffer) => {
|
|
317
|
+
if (connections.get(message.connId) !== connection || connection.phase !== 'open') return;
|
|
318
|
+
sendData(message.connId, payload);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
socket.once('end', () => {
|
|
322
|
+
if (connections.get(message.connId) !== connection || connection.phase !== 'open') return;
|
|
323
|
+
connection.localInputEnded = true;
|
|
324
|
+
sendControl({ type: 'fin', connId: message.connId });
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
socket.once('error', (error: NodeJS.ErrnoException) => {
|
|
328
|
+
if (connections.get(message.connId) !== connection) return;
|
|
329
|
+
if (connection.phase === 'connecting') {
|
|
330
|
+
sendOpenFailure(message.connId, classifyOpenFailure(error), error.code);
|
|
331
|
+
} else {
|
|
332
|
+
sendReset(message.connId, 'connection_error', error.code);
|
|
333
|
+
}
|
|
334
|
+
removeConnection(message.connId, true);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
socket.once('close', () => {
|
|
338
|
+
if (connections.get(message.connId) !== connection) return;
|
|
339
|
+
if (connection.phase === 'connecting') {
|
|
340
|
+
sendOpenFailure(message.connId, 'internal');
|
|
341
|
+
} else if (!connection.localInputEnded || !connection.remoteInputEnded) {
|
|
342
|
+
sendReset(message.connId, 'connection_error');
|
|
343
|
+
}
|
|
344
|
+
removeConnection(message.connId, false);
|
|
345
|
+
});
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const findOpenConnection = (connId: number): DialConnection | undefined => {
|
|
349
|
+
const connection = connections.get(connId);
|
|
350
|
+
if (connection?.phase === 'open') return connection;
|
|
351
|
+
if (connection) {
|
|
352
|
+
sendReset(connId, 'protocol_error');
|
|
353
|
+
removeConnection(connId, true);
|
|
354
|
+
} else if (!recentlyClosed.has(connId)) {
|
|
355
|
+
sendReset(connId, 'protocol_error');
|
|
356
|
+
markRecentlyClosed(connId);
|
|
357
|
+
}
|
|
358
|
+
return undefined;
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const handleRemoteFIN = (connId: number): void => {
|
|
362
|
+
const connection = findOpenConnection(connId);
|
|
363
|
+
if (!connection) return;
|
|
364
|
+
if (connection.remoteInputEnded) {
|
|
365
|
+
sendReset(connId, 'protocol_error');
|
|
366
|
+
removeConnection(connId, true);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
connection.remoteInputEnded = true;
|
|
370
|
+
connection.socket.end();
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
const handleRemoteReset = (connId: number): void => {
|
|
374
|
+
if (connections.has(connId)) {
|
|
375
|
+
removeConnection(connId, true);
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
const handleBinary = (frame: Buffer): void => {
|
|
380
|
+
if (!tunnelReady) {
|
|
381
|
+
throw new DestinationTunnelProtocolError('received tunnel data before READY');
|
|
382
|
+
}
|
|
383
|
+
const { connId, payload } = decodeDestinationTunnelDataFrame(frame);
|
|
384
|
+
const connection = findOpenConnection(connId);
|
|
385
|
+
if (!connection) return;
|
|
386
|
+
if (connection.remoteInputEnded) {
|
|
387
|
+
sendReset(connId, 'protocol_error');
|
|
388
|
+
removeConnection(connId, true);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const nextConnectionPendingBytes = connection.pendingWriteBytes + payload.length;
|
|
393
|
+
const nextTotalPendingBytes = totalPendingWriteBytes + payload.length;
|
|
394
|
+
if (
|
|
395
|
+
nextConnectionPendingBytes > maxPendingBytesPerConnection ||
|
|
396
|
+
nextTotalPendingBytes > maxTotalPendingBytes
|
|
397
|
+
) {
|
|
398
|
+
sendReset(connId, 'resource_exhausted');
|
|
399
|
+
removeConnection(connId, true);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
connection.pendingWriteBytes = nextConnectionPendingBytes;
|
|
404
|
+
totalPendingWriteBytes = nextTotalPendingBytes;
|
|
405
|
+
connection.socket.write(payload, (error?: Error | null) => {
|
|
406
|
+
connection.pendingWriteBytes -= payload.length;
|
|
407
|
+
totalPendingWriteBytes -= payload.length;
|
|
408
|
+
if (error && connections.get(connId) === connection) {
|
|
409
|
+
const osCode = (error as NodeJS.ErrnoException).code;
|
|
410
|
+
sendReset(connId, 'connection_error', osCode);
|
|
411
|
+
removeConnection(connId, true);
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
const handleControl = (message: DestinationTunnelServerMessage): void => {
|
|
417
|
+
switch (message.type) {
|
|
418
|
+
case 'ready':
|
|
419
|
+
if (tunnelReady) throw new DestinationTunnelProtocolError('received duplicate READY');
|
|
420
|
+
assertDestinationTunnelReady(message);
|
|
421
|
+
tunnelReady = true;
|
|
422
|
+
if (handshakeTimer) {
|
|
423
|
+
clearTimeout(handshakeTimer);
|
|
424
|
+
handshakeTimer = undefined;
|
|
425
|
+
}
|
|
426
|
+
armLivenessDeadline();
|
|
427
|
+
updateConnectionState('connected');
|
|
428
|
+
logger.info(`Destination tunnel ready with ${routes.length} route(s)`);
|
|
429
|
+
resolve({
|
|
430
|
+
tunnelId: message.tunnelId,
|
|
431
|
+
close,
|
|
432
|
+
getConnectionState,
|
|
433
|
+
onConnectionStateChange,
|
|
434
|
+
});
|
|
435
|
+
return;
|
|
436
|
+
case 'error':
|
|
437
|
+
throw new Error(`destination tunnel failed: ${message.code}`);
|
|
438
|
+
case 'open':
|
|
439
|
+
if (!tunnelReady) throw new DestinationTunnelProtocolError('received OPEN before READY');
|
|
440
|
+
handleOpen(message);
|
|
441
|
+
return;
|
|
442
|
+
case 'fin':
|
|
443
|
+
if (!tunnelReady) throw new DestinationTunnelProtocolError('received FIN before READY');
|
|
444
|
+
handleRemoteFIN(message.connId);
|
|
445
|
+
return;
|
|
446
|
+
case 'reset':
|
|
447
|
+
if (!tunnelReady) throw new DestinationTunnelProtocolError('received RESET before READY');
|
|
448
|
+
handleRemoteReset(message.connId);
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
const armLivenessDeadline = (): void => {
|
|
453
|
+
if (!tunnelReady || closed) return;
|
|
454
|
+
if (livenessTimer) clearTimeout(livenessTimer);
|
|
455
|
+
livenessTimer = setTimeout(() => {
|
|
456
|
+
failTransport(new Error(`destination tunnel received no frames for ${livenessTimeoutMs}ms`));
|
|
457
|
+
}, livenessTimeoutMs);
|
|
458
|
+
livenessTimer.unref();
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
handshakeTimer = setTimeout(() => {
|
|
462
|
+
failTransport(new Error(`destination tunnel was not ready within ${handshakeTimeoutMs}ms`));
|
|
463
|
+
}, handshakeTimeoutMs);
|
|
464
|
+
handshakeTimer.unref();
|
|
465
|
+
|
|
466
|
+
ws.on('open', () => {
|
|
467
|
+
sendControl({ type: 'start', version: DESTINATION_TUNNEL_VERSION, routes });
|
|
468
|
+
pingInterval = setInterval(() => {
|
|
469
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
470
|
+
ws.ping(undefined, true, frameSent);
|
|
471
|
+
frameQueued();
|
|
472
|
+
}
|
|
473
|
+
}, pingIntervalMs);
|
|
474
|
+
pingInterval.unref();
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
ws.on('message', (data: RawData, isBinary: boolean) => {
|
|
478
|
+
try {
|
|
479
|
+
armLivenessDeadline();
|
|
480
|
+
if (isBinary) {
|
|
481
|
+
handleBinary(toBuffer(data));
|
|
482
|
+
} else {
|
|
483
|
+
handleControl(decodeDestinationTunnelServerMessage(JSON.parse(toBuffer(data).toString('utf8'))));
|
|
484
|
+
}
|
|
485
|
+
} catch (error) {
|
|
486
|
+
failTransport(error instanceof Error ? error : new Error(String(error)));
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
ws.on('ping', armLivenessDeadline);
|
|
491
|
+
ws.on('pong', armLivenessDeadline);
|
|
492
|
+
|
|
493
|
+
ws.once('error', (error: Error) => {
|
|
494
|
+
failTransport(error);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
ws.once('close', (code: number, reason: Buffer) => {
|
|
498
|
+
if (closed) {
|
|
499
|
+
ws.removeAllListeners();
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
failTransport(
|
|
503
|
+
new Error(
|
|
504
|
+
`destination tunnel WebSocket closed: ${code}${reason.length ? ` ${reason.toString()}` : ''}`,
|
|
505
|
+
),
|
|
506
|
+
);
|
|
507
|
+
ws.removeAllListeners();
|
|
508
|
+
});
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
export function classifyOpenFailure(error: NodeJS.ErrnoException): DestinationTunnelOpenFailureReason {
|
|
513
|
+
switch (error.code) {
|
|
514
|
+
case 'ENOTFOUND':
|
|
515
|
+
return 'dns_not_found';
|
|
516
|
+
case 'EAI_AGAIN':
|
|
517
|
+
return 'dns_temporary_failure';
|
|
518
|
+
case 'ECONNREFUSED':
|
|
519
|
+
return 'connection_refused';
|
|
520
|
+
case 'ECONNRESET':
|
|
521
|
+
return 'connection_reset';
|
|
522
|
+
case 'ETIMEDOUT':
|
|
523
|
+
return 'connection_timed_out';
|
|
524
|
+
case 'ENETUNREACH':
|
|
525
|
+
case 'EHOSTUNREACH':
|
|
526
|
+
return 'unreachable';
|
|
527
|
+
case 'EACCES':
|
|
528
|
+
case 'EPERM':
|
|
529
|
+
return 'permission_denied';
|
|
530
|
+
case 'EMFILE':
|
|
531
|
+
case 'ENFILE':
|
|
532
|
+
case 'ENOBUFS':
|
|
533
|
+
case 'ENOMEM':
|
|
534
|
+
return 'resource_exhausted';
|
|
535
|
+
case 'ECANCELED':
|
|
536
|
+
return 'cancelled';
|
|
537
|
+
default:
|
|
538
|
+
return 'internal';
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function positiveInteger(value: number, name: string): number {
|
|
543
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
544
|
+
throw new Error(`${name} must be a positive integer`);
|
|
545
|
+
}
|
|
546
|
+
return value;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function toBuffer(data: RawData): Buffer {
|
|
550
|
+
if (Buffer.isBuffer(data)) return data;
|
|
551
|
+
if (Array.isArray(data)) return Buffer.concat(data);
|
|
552
|
+
if (data instanceof ArrayBuffer) return Buffer.from(data);
|
|
553
|
+
throw new DestinationTunnelProtocolError('unsupported WebSocket payload type');
|
|
554
|
+
}
|