@limrun/api 0.48.4 → 0.49.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/CHANGELOG.md +4 -0
- package/client.d.mts +6 -3
- package/client.d.mts.map +1 -1
- package/client.d.ts +6 -3
- package/client.d.ts.map +1 -1
- package/client.js +4 -4
- package/client.js.map +1 -1
- package/client.mjs +2 -2
- package/client.mjs.map +1 -1
- package/destination-tunnel-dialer.d.mts +30 -2
- package/destination-tunnel-dialer.d.mts.map +1 -1
- package/destination-tunnel-dialer.d.ts +30 -2
- package/destination-tunnel-dialer.d.ts.map +1 -1
- package/destination-tunnel-dialer.js +349 -65
- package/destination-tunnel-dialer.js.map +1 -1
- package/destination-tunnel-dialer.mjs +345 -64
- package/destination-tunnel-dialer.mjs.map +1 -1
- package/destination-tunnel-har.d.mts +23 -0
- package/destination-tunnel-har.d.mts.map +1 -0
- package/destination-tunnel-har.d.ts +23 -0
- package/destination-tunnel-har.d.ts.map +1 -0
- package/destination-tunnel-har.js +164 -0
- package/destination-tunnel-har.js.map +1 -0
- package/destination-tunnel-har.mjs +160 -0
- package/destination-tunnel-har.mjs.map +1 -0
- package/destination-tunnel-inspection.d.mts +71 -0
- package/destination-tunnel-inspection.d.mts.map +1 -0
- package/destination-tunnel-inspection.d.ts +71 -0
- package/destination-tunnel-inspection.d.ts.map +1 -0
- package/destination-tunnel-inspection.js +267 -0
- package/destination-tunnel-inspection.js.map +1 -0
- package/destination-tunnel-inspection.mjs +261 -0
- package/destination-tunnel-inspection.mjs.map +1 -0
- package/destination-tunnel.d.mts +119 -14
- package/destination-tunnel.d.mts.map +1 -1
- package/destination-tunnel.d.ts +119 -14
- package/destination-tunnel.d.ts.map +1 -1
- package/destination-tunnel.js +407 -98
- package/destination-tunnel.js.map +1 -1
- package/destination-tunnel.mjs +374 -73
- package/destination-tunnel.mjs.map +1 -1
- package/index.d.mts +6 -0
- package/index.d.mts.map +1 -1
- package/index.d.ts +6 -0
- package/index.d.ts.map +1 -1
- package/index.js +23 -2
- package/index.js.map +1 -1
- package/index.mjs +5 -0
- package/index.mjs.map +1 -1
- package/instance-client.d.mts +105 -1
- package/instance-client.d.mts.map +1 -1
- package/instance-client.d.ts +105 -1
- package/instance-client.d.ts.map +1 -1
- package/instance-client.js +83 -1
- package/instance-client.js.map +1 -1
- package/instance-client.mjs +83 -1
- package/instance-client.mjs.map +1 -1
- package/internal/destination-tunnel-management.d.mts +4 -3
- package/internal/destination-tunnel-management.d.mts.map +1 -1
- package/internal/destination-tunnel-management.d.ts +4 -3
- package/internal/destination-tunnel-management.d.ts.map +1 -1
- package/internal/destination-tunnel-management.js +64 -46
- package/internal/destination-tunnel-management.js.map +1 -1
- package/internal/destination-tunnel-management.mjs +55 -37
- package/internal/destination-tunnel-management.mjs.map +1 -1
- package/internal/destination-tunnel-url.d.mts +2 -0
- package/internal/destination-tunnel-url.d.mts.map +1 -1
- package/internal/destination-tunnel-url.d.ts +2 -0
- package/internal/destination-tunnel-url.d.ts.map +1 -1
- package/internal/destination-tunnel-url.js +45 -5
- package/internal/destination-tunnel-url.js.map +1 -1
- package/internal/destination-tunnel-url.mjs +44 -5
- package/internal/destination-tunnel-url.mjs.map +1 -1
- package/internal/destination-tunnel-wire-reader.d.mts +23 -0
- package/internal/destination-tunnel-wire-reader.d.mts.map +1 -0
- package/internal/destination-tunnel-wire-reader.d.ts +23 -0
- package/internal/destination-tunnel-wire-reader.d.ts.map +1 -0
- package/internal/destination-tunnel-wire-reader.js +110 -0
- package/internal/destination-tunnel-wire-reader.js.map +1 -0
- package/internal/destination-tunnel-wire-reader.mjs +93 -0
- package/internal/destination-tunnel-wire-reader.mjs.map +1 -0
- package/internal/persist-option.d.mts +14 -0
- package/internal/persist-option.d.mts.map +1 -0
- package/internal/persist-option.d.ts +14 -0
- package/internal/persist-option.d.ts.map +1 -0
- package/internal/persist-option.js +18 -0
- package/internal/persist-option.js.map +1 -0
- package/internal/persist-option.mjs +15 -0
- package/internal/persist-option.mjs.map +1 -0
- package/internal/proxy-transport.d.mts +27 -0
- package/internal/proxy-transport.d.mts.map +1 -1
- package/internal/proxy-transport.d.ts +27 -0
- package/internal/proxy-transport.d.ts.map +1 -1
- package/internal/proxy-transport.js +206 -0
- package/internal/proxy-transport.js.map +1 -1
- package/internal/proxy-transport.mjs +205 -0
- package/internal/proxy-transport.mjs.map +1 -1
- package/internal/session-stream.d.mts +39 -0
- package/internal/session-stream.d.mts.map +1 -0
- package/internal/session-stream.d.ts +39 -0
- package/internal/session-stream.d.ts.map +1 -0
- package/internal/session-stream.js +144 -0
- package/internal/session-stream.js.map +1 -0
- package/internal/session-stream.mjs +139 -0
- package/internal/session-stream.mjs.map +1 -0
- package/internal/tslib.js +4 -4
- package/ios-client.d.mts +61 -4
- package/ios-client.d.mts.map +1 -1
- package/ios-client.d.ts +61 -4
- package/ios-client.d.ts.map +1 -1
- package/ios-client.js +48 -2
- package/ios-client.js.map +1 -1
- package/ios-client.mjs +48 -2
- package/ios-client.mjs.map +1 -1
- package/package.json +22 -1
- package/resources/android-instances-helpers.d.mts +25 -0
- package/resources/android-instances-helpers.d.mts.map +1 -0
- package/resources/android-instances-helpers.d.ts +25 -0
- package/resources/android-instances-helpers.d.ts.map +1 -0
- package/resources/android-instances-helpers.js +37 -0
- package/resources/android-instances-helpers.js.map +1 -0
- package/resources/android-instances-helpers.mjs +33 -0
- package/resources/android-instances-helpers.mjs.map +1 -0
- package/resources/index.d.mts +5 -2
- package/resources/index.d.mts.map +1 -1
- package/resources/index.d.ts +5 -2
- package/resources/index.d.ts.map +1 -1
- package/resources/index.js +5 -5
- package/resources/index.js.map +1 -1
- package/resources/index.mjs +2 -2
- package/resources/index.mjs.map +1 -1
- package/resources/ios-instances-helpers.d.mts +25 -0
- package/resources/ios-instances-helpers.d.mts.map +1 -0
- package/resources/ios-instances-helpers.d.ts +25 -0
- package/resources/ios-instances-helpers.d.ts.map +1 -0
- package/resources/ios-instances-helpers.js +37 -0
- package/resources/ios-instances-helpers.js.map +1 -0
- package/resources/ios-instances-helpers.mjs +33 -0
- package/resources/ios-instances-helpers.mjs.map +1 -0
- package/resources/session-artifacts.d.mts +70 -0
- package/resources/session-artifacts.d.mts.map +1 -0
- package/resources/session-artifacts.d.ts +70 -0
- package/resources/session-artifacts.d.ts.map +1 -0
- package/resources/session-artifacts.js +8 -0
- package/resources/session-artifacts.js.map +1 -0
- package/resources/session-artifacts.mjs +7 -0
- package/resources/session-artifacts.mjs.map +1 -0
- package/src/client.ts +4 -2
- package/src/destination-tunnel-dialer.ts +403 -64
- package/src/destination-tunnel-har.ts +200 -0
- package/src/destination-tunnel-inspection.ts +367 -0
- package/src/destination-tunnel.ts +510 -88
- package/src/index.ts +52 -0
- package/src/instance-client.ts +271 -5
- package/src/internal/destination-tunnel-management.ts +67 -43
- package/src/internal/destination-tunnel-url.ts +61 -5
- package/src/internal/destination-tunnel-wire-reader.ts +109 -0
- package/src/internal/persist-option.ts +26 -0
- package/src/internal/proxy-transport.ts +248 -0
- package/src/internal/session-stream.ts +157 -0
- package/src/ios-client.ts +127 -8
- package/src/resources/android-instances-helpers.ts +45 -0
- package/src/resources/index.ts +9 -2
- package/src/resources/ios-instances-helpers.ts +45 -0
- package/src/resources/session-artifacts.ts +95 -0
- 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
|
@@ -1,33 +1,75 @@
|
|
|
1
|
+
import dns from 'dns';
|
|
1
2
|
import net from 'net';
|
|
3
|
+
import tls from 'tls';
|
|
2
4
|
import { WebSocket, type RawData } from 'ws';
|
|
3
5
|
import { nodeProxyTransport } from './internal/proxy-transport';
|
|
6
|
+
import { toBuffer } from './internal/destination-tunnel-wire-reader';
|
|
4
7
|
import {
|
|
8
|
+
startDestinationTunnelInspectionStream,
|
|
9
|
+
type DestinationTunnelInspectionErrorCallback,
|
|
10
|
+
type DestinationTunnelInspectionEventCallback,
|
|
11
|
+
type DestinationTunnelInspectionStream,
|
|
12
|
+
} from './destination-tunnel-inspection';
|
|
13
|
+
import {
|
|
14
|
+
DESTINATION_TUNNEL_DEFAULT_WINDOW,
|
|
5
15
|
DESTINATION_TUNNEL_VERSION,
|
|
6
16
|
DestinationTunnelProtocolError,
|
|
7
17
|
assertDestinationTunnelOpenAllowed,
|
|
8
18
|
assertDestinationTunnelReady,
|
|
19
|
+
classifyDestinationTunnelSelectors,
|
|
9
20
|
decodeDestinationTunnelDataFrame,
|
|
10
21
|
decodeDestinationTunnelServerMessage,
|
|
22
|
+
disabledDestinationTunnelInspection,
|
|
23
|
+
destinationTunnelConfigHash,
|
|
11
24
|
encodeDestinationTunnelDataFrame,
|
|
12
25
|
encodeDestinationTunnelClientMessage,
|
|
13
|
-
|
|
26
|
+
normalizeDestinationTunnelInspection,
|
|
27
|
+
validateDestinationTunnelSelectors,
|
|
14
28
|
type DestinationTunnelClientMessage,
|
|
15
29
|
type DestinationTunnelOpenFailureReason,
|
|
30
|
+
type DestinationTunnelInspectionConfig,
|
|
16
31
|
type DestinationTunnelRoute,
|
|
17
32
|
type DestinationTunnelResetReason,
|
|
33
|
+
type DestinationTunnelSelectorReport,
|
|
34
|
+
type DestinationTunnelSelectors,
|
|
18
35
|
type DestinationTunnelServerMessage,
|
|
19
36
|
} from './destination-tunnel';
|
|
20
37
|
import type { LogLevel, TunnelConnectionState, TunnelConnectionStateCallback } from './tunnel';
|
|
21
38
|
|
|
39
|
+
const blockedResolvedAddresses = new net.BlockList();
|
|
40
|
+
blockedResolvedAddresses.addSubnet('0.0.0.0', 8, 'ipv4');
|
|
41
|
+
blockedResolvedAddresses.addSubnet('127.0.0.0', 8, 'ipv4');
|
|
42
|
+
blockedResolvedAddresses.addSubnet('169.254.0.0', 16, 'ipv4');
|
|
43
|
+
blockedResolvedAddresses.addSubnet('224.0.0.0', 3, 'ipv4');
|
|
44
|
+
blockedResolvedAddresses.addSubnet('::', 96, 'ipv6');
|
|
45
|
+
blockedResolvedAddresses.addSubnet('fe80::', 10, 'ipv6');
|
|
46
|
+
blockedResolvedAddresses.addSubnet('ff00::', 8, 'ipv6');
|
|
47
|
+
blockedResolvedAddresses.addSubnet('::ffff:0.0.0.0', 104, 'ipv6');
|
|
48
|
+
blockedResolvedAddresses.addSubnet('::ffff:127.0.0.0', 104, 'ipv6');
|
|
49
|
+
blockedResolvedAddresses.addSubnet('::ffff:169.254.0.0', 112, 'ipv6');
|
|
50
|
+
blockedResolvedAddresses.addSubnet('::ffff:224.0.0.0', 99, 'ipv6');
|
|
51
|
+
|
|
22
52
|
export interface DestinationTcpTunnel {
|
|
23
53
|
tunnelId: string;
|
|
54
|
+
/** Normalized selectors echoed by the server, including bind reports. */
|
|
55
|
+
selectors: DestinationTunnelSelectorReport[];
|
|
56
|
+
configHash: string;
|
|
57
|
+
inspection: DestinationTunnelInspectionConfig;
|
|
58
|
+
inspectionStream?: DestinationTunnelInspectionStream;
|
|
24
59
|
close: () => void;
|
|
25
60
|
getConnectionState: () => TunnelConnectionState;
|
|
26
61
|
onConnectionStateChange: (callback: TunnelConnectionStateCallback) => () => void;
|
|
27
62
|
}
|
|
28
63
|
|
|
29
64
|
export interface DestinationTcpTunnelOptions {
|
|
30
|
-
|
|
65
|
+
selectors: DestinationTunnelSelectors;
|
|
66
|
+
inspection?: Partial<DestinationTunnelInspectionConfig>;
|
|
67
|
+
/** Called for validated inspection metadata and body frames. */
|
|
68
|
+
onInspectionEvent?: DestinationTunnelInspectionEventCallback;
|
|
69
|
+
/** Called for inspection-only transport, protocol, or callback failures. */
|
|
70
|
+
onInspectionError?: DestinationTunnelInspectionErrorCallback;
|
|
71
|
+
/** Per-flow receive window in bytes. Defaults to 1 MiB. */
|
|
72
|
+
window?: number;
|
|
31
73
|
logLevel?: LogLevel;
|
|
32
74
|
maxConnections?: number;
|
|
33
75
|
maxPendingBytesPerConnection?: number;
|
|
@@ -39,12 +81,40 @@ export interface DestinationTcpTunnelOptions {
|
|
|
39
81
|
}
|
|
40
82
|
|
|
41
83
|
interface DialConnection {
|
|
42
|
-
socket
|
|
84
|
+
socket?: net.Socket;
|
|
43
85
|
phase: 'connecting' | 'open';
|
|
86
|
+
abortController: AbortController;
|
|
44
87
|
connectTimer: NodeJS.Timeout;
|
|
45
88
|
localInputEnded: boolean;
|
|
46
89
|
remoteInputEnded: boolean;
|
|
47
90
|
pendingWriteBytes: number;
|
|
91
|
+
/** Bytes we may still send toward the server. */
|
|
92
|
+
sendCredit: number;
|
|
93
|
+
/** Chunks read from the local socket awaiting send credit, FIFO. */
|
|
94
|
+
creditQueue: Buffer[];
|
|
95
|
+
creditQueueBytes: number;
|
|
96
|
+
/** Local socket ended while chunks were still queued; send fin after they drain. */
|
|
97
|
+
finPending: boolean;
|
|
98
|
+
/** Bytes accepted by the local socket since the last windowUpdate we sent. */
|
|
99
|
+
deliveredSinceUpdate: number;
|
|
100
|
+
dnsMs?: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface OpenDialConnection extends DialConnection {
|
|
104
|
+
socket: net.Socket;
|
|
105
|
+
phase: 'open';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Thrown (message prefix) when the server terminates the session with `error`. */
|
|
109
|
+
export const DESTINATION_TUNNEL_SERVER_ERROR_PREFIX = 'destination tunnel failed: ';
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* True when the failure is a terminal protocol/policy rejection from the
|
|
113
|
+
* server rather than a transient transport problem. Reconnect supervisors
|
|
114
|
+
* must not retry terminal failures.
|
|
115
|
+
*/
|
|
116
|
+
export function isTerminalDestinationTunnelError(error: unknown): boolean {
|
|
117
|
+
return error instanceof Error && error.message.startsWith(DESTINATION_TUNNEL_SERVER_ERROR_PREFIX);
|
|
48
118
|
}
|
|
49
119
|
|
|
50
120
|
export async function startDestinationTcpTunnel(
|
|
@@ -52,8 +122,13 @@ export async function startDestinationTcpTunnel(
|
|
|
52
122
|
token: string,
|
|
53
123
|
options: DestinationTcpTunnelOptions,
|
|
54
124
|
): Promise<DestinationTcpTunnel> {
|
|
55
|
-
const
|
|
125
|
+
const selectors = validateDestinationTunnelSelectors(options.selectors);
|
|
126
|
+
const routes = classifyDestinationTunnelSelectors(selectors).routes ?? [];
|
|
127
|
+
const inspection = normalizeDestinationTunnelInspection(
|
|
128
|
+
options.inspection ?? disabledDestinationTunnelInspection(),
|
|
129
|
+
);
|
|
56
130
|
const logLevel = options.logLevel ?? 'info';
|
|
131
|
+
const creditWindow = positiveInteger(options.window ?? DESTINATION_TUNNEL_DEFAULT_WINDOW, 'window');
|
|
57
132
|
const maxConnections = positiveInteger(options.maxConnections ?? 64, 'maxConnections');
|
|
58
133
|
const maxPendingBytesPerConnection = positiveInteger(
|
|
59
134
|
options.maxPendingBytesPerConnection ?? 16 * 1024 * 1024,
|
|
@@ -106,6 +181,7 @@ export async function startDestinationTcpTunnel(
|
|
|
106
181
|
let livenessTimer: NodeJS.Timeout | undefined;
|
|
107
182
|
let tunnelReady = false;
|
|
108
183
|
let closed = false;
|
|
184
|
+
let inspectionStream: DestinationTunnelInspectionStream | undefined;
|
|
109
185
|
let pausedForBackpressure = false;
|
|
110
186
|
let totalPendingWriteBytes = 0;
|
|
111
187
|
|
|
@@ -140,9 +216,10 @@ export async function startDestinationTcpTunnel(
|
|
|
140
216
|
const connection = connections.get(connId);
|
|
141
217
|
if (!connection) return;
|
|
142
218
|
connections.delete(connId);
|
|
219
|
+
connection.abortController.abort();
|
|
143
220
|
clearTimeout(connection.connectTimer);
|
|
144
221
|
markRecentlyClosed(connId);
|
|
145
|
-
if (destroySocket && !connection.socket.destroyed) {
|
|
222
|
+
if (destroySocket && connection.socket && !connection.socket.destroyed) {
|
|
146
223
|
connection.socket.destroy();
|
|
147
224
|
}
|
|
148
225
|
};
|
|
@@ -174,6 +251,8 @@ export async function startDestinationTcpTunnel(
|
|
|
174
251
|
livenessTimer = undefined;
|
|
175
252
|
}
|
|
176
253
|
closeAllConnections();
|
|
254
|
+
inspectionStream?.close();
|
|
255
|
+
inspectionStream = undefined;
|
|
177
256
|
ws.removeAllListeners('open');
|
|
178
257
|
ws.removeAllListeners('message');
|
|
179
258
|
ws.removeAllListeners('ping');
|
|
@@ -193,11 +272,14 @@ export async function startDestinationTcpTunnel(
|
|
|
193
272
|
}
|
|
194
273
|
};
|
|
195
274
|
|
|
275
|
+
const mayReadFromSocket = (connection: DialConnection): boolean =>
|
|
276
|
+
!pausedForBackpressure && connection.creditQueueBytes === 0;
|
|
277
|
+
|
|
196
278
|
const pauseForBackpressure = (): void => {
|
|
197
279
|
if (pausedForBackpressure) return;
|
|
198
280
|
pausedForBackpressure = true;
|
|
199
281
|
for (const connection of connections.values()) {
|
|
200
|
-
if (!connection.socket.destroyed) connection.socket.pause();
|
|
282
|
+
if (connection.socket && !connection.socket.destroyed) connection.socket.pause();
|
|
201
283
|
}
|
|
202
284
|
};
|
|
203
285
|
|
|
@@ -205,7 +287,12 @@ export async function startDestinationTcpTunnel(
|
|
|
205
287
|
if (!pausedForBackpressure || ws.bufferedAmount >= resumeBelowBufferedBytes) return;
|
|
206
288
|
pausedForBackpressure = false;
|
|
207
289
|
for (const connection of connections.values()) {
|
|
208
|
-
|
|
290
|
+
// Flows still waiting on per-flow credit stay paused; they resume
|
|
291
|
+
// when the server grants more window, keeping one stalled flow from
|
|
292
|
+
// holding every other flow back.
|
|
293
|
+
if (connection.socket && !connection.socket.destroyed && mayReadFromSocket(connection)) {
|
|
294
|
+
connection.socket.resume();
|
|
295
|
+
}
|
|
209
296
|
}
|
|
210
297
|
};
|
|
211
298
|
|
|
@@ -266,11 +353,118 @@ export async function startDestinationTcpTunnel(
|
|
|
266
353
|
});
|
|
267
354
|
};
|
|
268
355
|
|
|
356
|
+
/**
|
|
357
|
+
* Push local socket bytes toward the server through the per-flow credit
|
|
358
|
+
* gate. Chunks beyond the current credit wait in a FIFO queue with the
|
|
359
|
+
* local socket paused, so a server-throttled flow stops reading instead
|
|
360
|
+
* of buffering unboundedly.
|
|
361
|
+
*/
|
|
362
|
+
const pushThroughCreditGate = (connId: number, connection: DialConnection, payload?: Buffer): void => {
|
|
363
|
+
if (payload && payload.length > 0) {
|
|
364
|
+
connection.creditQueue.push(payload);
|
|
365
|
+
connection.creditQueueBytes += payload.length;
|
|
366
|
+
}
|
|
367
|
+
while (connection.creditQueue.length > 0 && connection.sendCredit > 0) {
|
|
368
|
+
const chunk = connection.creditQueue[0]!;
|
|
369
|
+
if (chunk.length <= connection.sendCredit) {
|
|
370
|
+
connection.creditQueue.shift();
|
|
371
|
+
connection.creditQueueBytes -= chunk.length;
|
|
372
|
+
connection.sendCredit -= chunk.length;
|
|
373
|
+
sendData(connId, chunk);
|
|
374
|
+
} else {
|
|
375
|
+
const portion = chunk.subarray(0, connection.sendCredit);
|
|
376
|
+
connection.creditQueue[0] = chunk.subarray(connection.sendCredit);
|
|
377
|
+
connection.creditQueueBytes -= portion.length;
|
|
378
|
+
connection.sendCredit = 0;
|
|
379
|
+
sendData(connId, portion);
|
|
380
|
+
}
|
|
381
|
+
if (closed) return;
|
|
382
|
+
}
|
|
383
|
+
if (connection.creditQueue.length === 0 && connection.finPending) {
|
|
384
|
+
// The local socket ended while data was still waiting on credit; the
|
|
385
|
+
// queue has drained, so half-close can now be signaled in order.
|
|
386
|
+
connection.finPending = false;
|
|
387
|
+
connection.localInputEnded = true;
|
|
388
|
+
sendControl({ type: 'fin', connId });
|
|
389
|
+
}
|
|
390
|
+
if (!connection.socket || connection.socket.destroyed) return;
|
|
391
|
+
if (connection.creditQueueBytes > 0) {
|
|
392
|
+
connection.socket.pause();
|
|
393
|
+
} else if (!pausedForBackpressure) {
|
|
394
|
+
connection.socket.resume();
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
const dialTarget = async (
|
|
399
|
+
message: Extract<DestinationTunnelServerMessage, { type: 'open' }>,
|
|
400
|
+
): Promise<{ host: string; dnsMs: number }> => {
|
|
401
|
+
// Domain selectors resolve through the user's OS resolver and may only
|
|
402
|
+
// reach ordinary unicast targets. Loopback and similar special targets
|
|
403
|
+
// require an explicit exact route grant.
|
|
404
|
+
const startedAt = Date.now();
|
|
405
|
+
const results = await dns.promises.lookup(message.host, { all: true, verbatim: true });
|
|
406
|
+
const dnsMs = Date.now() - startedAt;
|
|
407
|
+
for (const result of results) {
|
|
408
|
+
if (isDialableResolvedAddress(result.address, message.port, routes)) {
|
|
409
|
+
return { host: result.address, dnsMs };
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
const blockedError: NodeJS.ErrnoException = new Error(
|
|
413
|
+
`all resolved addresses for ${message.host} are blocked`,
|
|
414
|
+
);
|
|
415
|
+
blockedError.code = 'EBLOCKED';
|
|
416
|
+
throw blockedError;
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
const wrapTls = (
|
|
420
|
+
socket: net.Socket,
|
|
421
|
+
transport: Extract<DestinationTunnelServerMessage, { type: 'open' }>['transport'] & {
|
|
422
|
+
type: 'tls';
|
|
423
|
+
},
|
|
424
|
+
abortController: AbortController,
|
|
425
|
+
): Promise<tls.TLSSocket> =>
|
|
426
|
+
new Promise((resolveTls, rejectTls) => {
|
|
427
|
+
const tlsSocket = tls.connect({
|
|
428
|
+
socket,
|
|
429
|
+
rejectUnauthorized: true,
|
|
430
|
+
servername: transport.serverName,
|
|
431
|
+
ALPNProtocols: transport.alpnProtocols,
|
|
432
|
+
});
|
|
433
|
+
tlsSocket.allowHalfOpen = true;
|
|
434
|
+
const cleanup = (): void => {
|
|
435
|
+
tlsSocket.removeListener('secureConnect', onSecureConnect);
|
|
436
|
+
tlsSocket.removeListener('error', onError);
|
|
437
|
+
abortController.signal.removeEventListener('abort', onAbort);
|
|
438
|
+
};
|
|
439
|
+
const onSecureConnect = (): void => {
|
|
440
|
+
cleanup();
|
|
441
|
+
tlsSocket.pause();
|
|
442
|
+
resolveTls(tlsSocket);
|
|
443
|
+
};
|
|
444
|
+
const onError = (error: Error): void => {
|
|
445
|
+
cleanup();
|
|
446
|
+
rejectTls(error);
|
|
447
|
+
};
|
|
448
|
+
const onAbort = (): void => {
|
|
449
|
+
cleanup();
|
|
450
|
+
tlsSocket.destroy();
|
|
451
|
+
rejectTls(Object.assign(new Error('TLS connection cancelled'), { code: 'ECANCELED' }));
|
|
452
|
+
};
|
|
453
|
+
tlsSocket.once('secureConnect', onSecureConnect);
|
|
454
|
+
tlsSocket.once('error', onError);
|
|
455
|
+
if (abortController.signal.aborted) {
|
|
456
|
+
onAbort();
|
|
457
|
+
} else {
|
|
458
|
+
abortController.signal.addEventListener('abort', onAbort, { once: true });
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
|
|
269
462
|
const handleOpen = (message: Extract<DestinationTunnelServerMessage, { type: 'open' }>): void => {
|
|
463
|
+
let kind: 'route' | 'domain';
|
|
270
464
|
try {
|
|
271
|
-
assertDestinationTunnelOpenAllowed(message,
|
|
465
|
+
kind = assertDestinationTunnelOpenAllowed(message, selectors);
|
|
272
466
|
} catch {
|
|
273
|
-
sendOpenFailure(message.connId, '
|
|
467
|
+
sendOpenFailure(message.connId, 'selector_not_allowed');
|
|
274
468
|
return;
|
|
275
469
|
}
|
|
276
470
|
if (connections.has(message.connId) || recentlyClosed.has(message.connId)) {
|
|
@@ -281,73 +475,124 @@ export async function startDestinationTcpTunnel(
|
|
|
281
475
|
return;
|
|
282
476
|
}
|
|
283
477
|
|
|
284
|
-
const
|
|
285
|
-
host: message.host,
|
|
286
|
-
port: message.port,
|
|
287
|
-
allowHalfOpen: true,
|
|
288
|
-
});
|
|
478
|
+
const abortController = new AbortController();
|
|
289
479
|
const connectTimer = setTimeout(() => {
|
|
290
480
|
if (connections.get(message.connId)?.phase !== 'connecting') return;
|
|
481
|
+
logger.warn(`Failed to connect to ${message.host}:${message.port}: timed out`);
|
|
291
482
|
sendOpenFailure(message.connId, 'connection_timed_out', 'ETIMEDOUT');
|
|
292
483
|
removeConnection(message.connId, true);
|
|
293
484
|
}, connectTimeoutMs);
|
|
294
485
|
connectTimer.unref();
|
|
295
486
|
const connection: DialConnection = {
|
|
296
|
-
socket,
|
|
297
487
|
phase: 'connecting',
|
|
488
|
+
abortController,
|
|
298
489
|
connectTimer,
|
|
299
490
|
localInputEnded: false,
|
|
300
491
|
remoteInputEnded: false,
|
|
301
492
|
pendingWriteBytes: 0,
|
|
493
|
+
sendCredit: message.window,
|
|
494
|
+
creditQueue: [],
|
|
495
|
+
creditQueueBytes: 0,
|
|
496
|
+
finPending: false,
|
|
497
|
+
deliveredSinceUpdate: 0,
|
|
302
498
|
};
|
|
303
499
|
connections.set(message.connId, connection);
|
|
304
|
-
if (pausedForBackpressure) {
|
|
305
|
-
socket.pause();
|
|
306
|
-
}
|
|
307
500
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
501
|
+
void (async () => {
|
|
502
|
+
let host = message.host;
|
|
503
|
+
if (kind === 'domain') {
|
|
504
|
+
const resolved = await dialTarget(message);
|
|
505
|
+
host = resolved.host;
|
|
506
|
+
connection.dnsMs = resolved.dnsMs;
|
|
507
|
+
}
|
|
508
|
+
if (connections.get(message.connId) !== connection || closed) return;
|
|
509
|
+
|
|
510
|
+
const tcp = await nodeProxyTransport.connectTcp({
|
|
511
|
+
host,
|
|
512
|
+
port: message.port,
|
|
513
|
+
proxyLookupHost: message.host,
|
|
514
|
+
proxyLookupProtocol: message.transport.type === 'tls' ? 'https:' : 'http:',
|
|
515
|
+
timeoutMs: connectTimeoutMs,
|
|
516
|
+
signal: abortController.signal,
|
|
517
|
+
});
|
|
518
|
+
if (connections.get(message.connId) !== connection || closed) {
|
|
519
|
+
tcp.socket.destroy();
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
connection.socket = tcp.socket;
|
|
523
|
+
|
|
524
|
+
let socket: net.Socket = tcp.socket;
|
|
525
|
+
let tlsMs: number | undefined;
|
|
526
|
+
if (message.transport.type === 'tls') {
|
|
527
|
+
const tlsStartedAt = Date.now();
|
|
528
|
+
socket = await wrapTls(tcp.socket, message.transport, abortController);
|
|
529
|
+
tlsMs = Date.now() - tlsStartedAt;
|
|
530
|
+
if (connections.get(message.connId) !== connection || closed) {
|
|
531
|
+
socket.destroy();
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
connection.socket = socket;
|
|
535
|
+
}
|
|
326
536
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
}
|
|
537
|
+
clearTimeout(connectTimer);
|
|
538
|
+
socket.on('data', (payload: Buffer) => {
|
|
539
|
+
if (connections.get(message.connId) !== connection || connection.phase !== 'open') return;
|
|
540
|
+
pushThroughCreditGate(message.connId, connection, payload);
|
|
541
|
+
});
|
|
542
|
+
socket.once('end', () => {
|
|
543
|
+
if (connections.get(message.connId) !== connection || connection.phase !== 'open') return;
|
|
544
|
+
if (connection.creditQueueBytes > 0) {
|
|
545
|
+
// Queued bytes are still waiting on send credit; fin must follow
|
|
546
|
+
// them, so defer it until the credit gate drains the queue.
|
|
547
|
+
connection.finPending = true;
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
connection.localInputEnded = true;
|
|
551
|
+
sendControl({ type: 'fin', connId: message.connId });
|
|
552
|
+
});
|
|
553
|
+
socket.once('error', (error: NodeJS.ErrnoException) => {
|
|
554
|
+
if (connections.get(message.connId) !== connection) return;
|
|
332
555
|
sendReset(message.connId, 'connection_error', error.code);
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
556
|
+
removeConnection(message.connId, true);
|
|
557
|
+
});
|
|
558
|
+
socket.once('close', () => {
|
|
559
|
+
if (connections.get(message.connId) !== connection) return;
|
|
560
|
+
if (!connection.localInputEnded || !connection.remoteInputEnded) {
|
|
561
|
+
sendReset(message.connId, 'connection_error');
|
|
562
|
+
}
|
|
563
|
+
removeConnection(message.connId, false);
|
|
564
|
+
});
|
|
336
565
|
|
|
337
|
-
|
|
566
|
+
connection.phase = 'open';
|
|
567
|
+
sendControl({
|
|
568
|
+
type: 'openOk',
|
|
569
|
+
connId: message.connId,
|
|
570
|
+
transport: {
|
|
571
|
+
type: message.transport.type,
|
|
572
|
+
...(tcp.remoteAddress === undefined ? {} : { remoteAddress: tcp.remoteAddress }),
|
|
573
|
+
...(connection.dnsMs === undefined ? {} : { dnsMs: connection.dnsMs }),
|
|
574
|
+
connectMs: tcp.connectMs,
|
|
575
|
+
...(tlsMs === undefined ? {} : { tlsMs }),
|
|
576
|
+
...(socket instanceof tls.TLSSocket && socket.alpnProtocol ?
|
|
577
|
+
{ alpnProtocol: socket.alpnProtocol }
|
|
578
|
+
: {}),
|
|
579
|
+
},
|
|
580
|
+
window: creditWindow,
|
|
581
|
+
});
|
|
582
|
+
logger.debug(`Forwarding connection ${message.connId} to ${message.host}:${message.port}`);
|
|
583
|
+
if (!pausedForBackpressure && connection.creditQueueBytes === 0) socket.resume();
|
|
584
|
+
})().catch((error: NodeJS.ErrnoException) => {
|
|
338
585
|
if (connections.get(message.connId) !== connection) return;
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
}
|
|
344
|
-
removeConnection(message.connId, false);
|
|
586
|
+
logger.warn(`Failed to connect to ${message.host}:${message.port}: ${error.code ?? error.message}`);
|
|
587
|
+
const reason = error.code === 'EBLOCKED' ? 'selector_not_allowed' : classifyOpenFailure(error);
|
|
588
|
+
sendOpenFailure(message.connId, reason, error.code === 'EBLOCKED' ? undefined : error.code);
|
|
589
|
+
removeConnection(message.connId, true);
|
|
345
590
|
});
|
|
346
591
|
};
|
|
347
592
|
|
|
348
|
-
const findOpenConnection = (connId: number):
|
|
593
|
+
const findOpenConnection = (connId: number): OpenDialConnection | undefined => {
|
|
349
594
|
const connection = connections.get(connId);
|
|
350
|
-
if (connection?.phase === 'open') return connection;
|
|
595
|
+
if (connection?.phase === 'open' && connection.socket) return connection as OpenDialConnection;
|
|
351
596
|
if (connection) {
|
|
352
597
|
sendReset(connId, 'protocol_error');
|
|
353
598
|
removeConnection(connId, true);
|
|
@@ -376,6 +621,14 @@ export async function startDestinationTcpTunnel(
|
|
|
376
621
|
}
|
|
377
622
|
};
|
|
378
623
|
|
|
624
|
+
const handleRemoteWindowUpdate = (connId: number, increment: number): void => {
|
|
625
|
+
const connection = connections.get(connId);
|
|
626
|
+
// Updates racing a local close are expected; ignore unknown flows.
|
|
627
|
+
if (!connection || connection.phase !== 'open') return;
|
|
628
|
+
connection.sendCredit += increment;
|
|
629
|
+
pushThroughCreditGate(connId, connection);
|
|
630
|
+
};
|
|
631
|
+
|
|
379
632
|
const handleBinary = (frame: Buffer): void => {
|
|
380
633
|
if (!tunnelReady) {
|
|
381
634
|
throw new DestinationTunnelProtocolError('received tunnel data before READY');
|
|
@@ -409,15 +662,33 @@ export async function startDestinationTcpTunnel(
|
|
|
409
662
|
const osCode = (error as NodeJS.ErrnoException).code;
|
|
410
663
|
sendReset(connId, 'connection_error', osCode);
|
|
411
664
|
removeConnection(connId, true);
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
// Replenish the server's send window once the local socket accepted
|
|
668
|
+
// the bytes, batching updates to roughly half the window.
|
|
669
|
+
if (connections.get(connId) !== connection) return;
|
|
670
|
+
connection.deliveredSinceUpdate += payload.length;
|
|
671
|
+
if (connection.deliveredSinceUpdate >= Math.ceil(creditWindow / 2)) {
|
|
672
|
+
const increment = connection.deliveredSinceUpdate;
|
|
673
|
+
connection.deliveredSinceUpdate = 0;
|
|
674
|
+
if (!closed && ws.readyState === WebSocket.OPEN) {
|
|
675
|
+
sendControl({ type: 'windowUpdate', connId, increment });
|
|
676
|
+
}
|
|
412
677
|
}
|
|
413
678
|
});
|
|
414
679
|
};
|
|
415
680
|
|
|
416
681
|
const handleControl = (message: DestinationTunnelServerMessage): void => {
|
|
417
682
|
switch (message.type) {
|
|
418
|
-
case 'ready':
|
|
683
|
+
case 'ready': {
|
|
419
684
|
if (tunnelReady) throw new DestinationTunnelProtocolError('received duplicate READY');
|
|
420
685
|
assertDestinationTunnelReady(message);
|
|
686
|
+
const expected = destinationTunnelConfigHash(selectors, inspection);
|
|
687
|
+
if (message.configHash !== expected) {
|
|
688
|
+
throw new DestinationTunnelProtocolError(
|
|
689
|
+
`server acknowledged config ${message.configHash} but ${expected} was negotiated`,
|
|
690
|
+
);
|
|
691
|
+
}
|
|
421
692
|
tunnelReady = true;
|
|
422
693
|
if (handshakeTimer) {
|
|
423
694
|
clearTimeout(handshakeTimer);
|
|
@@ -425,16 +696,35 @@ export async function startDestinationTcpTunnel(
|
|
|
425
696
|
}
|
|
426
697
|
armLivenessDeadline();
|
|
427
698
|
updateConnectionState('connected');
|
|
428
|
-
logger.info(`Destination tunnel ready with ${
|
|
699
|
+
logger.info(`Destination tunnel ready with ${selectors.length} selector(s)`);
|
|
700
|
+
if (inspection.enabled) {
|
|
701
|
+
try {
|
|
702
|
+
inspectionStream = startDestinationTunnelInspectionStream(remoteURL, message.tunnelId, token, {
|
|
703
|
+
...(options.onInspectionEvent ? { onEvent: options.onInspectionEvent } : {}),
|
|
704
|
+
...(options.onInspectionError ? { onError: options.onInspectionError } : {}),
|
|
705
|
+
});
|
|
706
|
+
} catch (error) {
|
|
707
|
+
try {
|
|
708
|
+
options.onInspectionError?.(error instanceof Error ? error : new Error(String(error)));
|
|
709
|
+
} catch {
|
|
710
|
+
// Inspection callbacks are isolated from the main tunnel.
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
429
714
|
resolve({
|
|
430
715
|
tunnelId: message.tunnelId,
|
|
716
|
+
selectors: message.selectors,
|
|
717
|
+
configHash: message.configHash,
|
|
718
|
+
inspection,
|
|
719
|
+
...(inspectionStream ? { inspectionStream } : {}),
|
|
431
720
|
close,
|
|
432
721
|
getConnectionState,
|
|
433
722
|
onConnectionStateChange,
|
|
434
723
|
});
|
|
435
724
|
return;
|
|
725
|
+
}
|
|
436
726
|
case 'error':
|
|
437
|
-
throw new Error(
|
|
727
|
+
throw new Error(`${DESTINATION_TUNNEL_SERVER_ERROR_PREFIX}${message.code}`);
|
|
438
728
|
case 'open':
|
|
439
729
|
if (!tunnelReady) throw new DestinationTunnelProtocolError('received OPEN before READY');
|
|
440
730
|
handleOpen(message);
|
|
@@ -443,6 +733,10 @@ export async function startDestinationTcpTunnel(
|
|
|
443
733
|
if (!tunnelReady) throw new DestinationTunnelProtocolError('received FIN before READY');
|
|
444
734
|
handleRemoteFIN(message.connId);
|
|
445
735
|
return;
|
|
736
|
+
case 'windowUpdate':
|
|
737
|
+
if (!tunnelReady) throw new DestinationTunnelProtocolError('received windowUpdate before READY');
|
|
738
|
+
handleRemoteWindowUpdate(message.connId, message.increment);
|
|
739
|
+
return;
|
|
446
740
|
case 'reset':
|
|
447
741
|
if (!tunnelReady) throw new DestinationTunnelProtocolError('received RESET before READY');
|
|
448
742
|
handleRemoteReset(message.connId);
|
|
@@ -464,7 +758,13 @@ export async function startDestinationTcpTunnel(
|
|
|
464
758
|
handshakeTimer.unref();
|
|
465
759
|
|
|
466
760
|
ws.on('open', () => {
|
|
467
|
-
sendControl({
|
|
761
|
+
sendControl({
|
|
762
|
+
type: 'start',
|
|
763
|
+
version: DESTINATION_TUNNEL_VERSION,
|
|
764
|
+
selectors,
|
|
765
|
+
inspection,
|
|
766
|
+
window: creditWindow,
|
|
767
|
+
});
|
|
468
768
|
pingInterval = setInterval(() => {
|
|
469
769
|
if (ws.readyState === WebSocket.OPEN) {
|
|
470
770
|
ws.ping(undefined, true, frameSent);
|
|
@@ -509,6 +809,28 @@ export async function startDestinationTcpTunnel(
|
|
|
509
809
|
});
|
|
510
810
|
}
|
|
511
811
|
|
|
812
|
+
/**
|
|
813
|
+
* Whether a resolved domain address may be dialed. Ordinary unicast targets
|
|
814
|
+
* (including private ranges the user's machine can reach) are allowed;
|
|
815
|
+
* loopback, link-local, multicast, unspecified, broadcast, and similar
|
|
816
|
+
* special targets are blocked unless an exact route grants them.
|
|
817
|
+
*/
|
|
818
|
+
export function isDialableResolvedAddress(
|
|
819
|
+
address: string,
|
|
820
|
+
port: number,
|
|
821
|
+
routes: readonly DestinationTunnelRoute[],
|
|
822
|
+
): boolean {
|
|
823
|
+
for (const route of routes) {
|
|
824
|
+
if (route.port !== port) continue;
|
|
825
|
+
if (route.host === address) return true;
|
|
826
|
+
if (route.host === 'localhost' && (address === '127.0.0.1' || address === '::1')) return true;
|
|
827
|
+
}
|
|
828
|
+
const version = net.isIP(address);
|
|
829
|
+
if (version === 4) return !blockedResolvedAddresses.check(address, 'ipv4');
|
|
830
|
+
if (version === 6) return !blockedResolvedAddresses.check(address, 'ipv6');
|
|
831
|
+
return false;
|
|
832
|
+
}
|
|
833
|
+
|
|
512
834
|
export function classifyOpenFailure(error: NodeJS.ErrnoException): DestinationTunnelOpenFailureReason {
|
|
513
835
|
switch (error.code) {
|
|
514
836
|
case 'ENOTFOUND':
|
|
@@ -526,6 +848,7 @@ export function classifyOpenFailure(error: NodeJS.ErrnoException): DestinationTu
|
|
|
526
848
|
return 'unreachable';
|
|
527
849
|
case 'EACCES':
|
|
528
850
|
case 'EPERM':
|
|
851
|
+
case 'EPROXYAUTH':
|
|
529
852
|
return 'permission_denied';
|
|
530
853
|
case 'EMFILE':
|
|
531
854
|
case 'ENFILE':
|
|
@@ -534,7 +857,30 @@ export function classifyOpenFailure(error: NodeJS.ErrnoException): DestinationTu
|
|
|
534
857
|
return 'resource_exhausted';
|
|
535
858
|
case 'ECANCELED':
|
|
536
859
|
return 'cancelled';
|
|
860
|
+
case 'CERT_HAS_EXPIRED':
|
|
861
|
+
case 'CERT_NOT_YET_VALID':
|
|
862
|
+
case 'CERT_REJECTED':
|
|
863
|
+
case 'CERT_REVOKED':
|
|
864
|
+
case 'CERT_SIGNATURE_FAILURE':
|
|
865
|
+
case 'CERT_UNTRUSTED':
|
|
866
|
+
case 'DEPTH_ZERO_SELF_SIGNED_CERT':
|
|
867
|
+
case 'ERR_TLS_CERT_ALTNAME_FORMAT':
|
|
868
|
+
case 'SELF_SIGNED_CERT_IN_CHAIN':
|
|
869
|
+
case 'HOSTNAME_MISMATCH':
|
|
870
|
+
case 'INVALID_CA':
|
|
871
|
+
case 'INVALID_PURPOSE':
|
|
872
|
+
case 'PATH_LENGTH_EXCEEDED':
|
|
873
|
+
case 'UNABLE_TO_GET_ISSUER_CERT':
|
|
874
|
+
case 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY':
|
|
875
|
+
case 'UNABLE_TO_VERIFY_LEAF_SIGNATURE':
|
|
876
|
+
case 'ERR_TLS_CERT_ALTNAME_INVALID':
|
|
877
|
+
return 'tls_validation_failed';
|
|
878
|
+
case 'EPROTO':
|
|
879
|
+
return 'tls_protocol_error';
|
|
537
880
|
default:
|
|
881
|
+
if (error.code?.startsWith('ERR_SSL_') || error.code?.startsWith('ERR_TLS_')) {
|
|
882
|
+
return 'tls_handshake_failed';
|
|
883
|
+
}
|
|
538
884
|
return 'internal';
|
|
539
885
|
}
|
|
540
886
|
}
|
|
@@ -545,10 +891,3 @@ function positiveInteger(value: number, name: string): number {
|
|
|
545
891
|
}
|
|
546
892
|
return value;
|
|
547
893
|
}
|
|
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
|
-
}
|