@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.
Files changed (170) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/client.d.mts +6 -3
  3. package/client.d.mts.map +1 -1
  4. package/client.d.ts +6 -3
  5. package/client.d.ts.map +1 -1
  6. package/client.js +4 -4
  7. package/client.js.map +1 -1
  8. package/client.mjs +2 -2
  9. package/client.mjs.map +1 -1
  10. package/destination-tunnel-dialer.d.mts +30 -2
  11. package/destination-tunnel-dialer.d.mts.map +1 -1
  12. package/destination-tunnel-dialer.d.ts +30 -2
  13. package/destination-tunnel-dialer.d.ts.map +1 -1
  14. package/destination-tunnel-dialer.js +349 -65
  15. package/destination-tunnel-dialer.js.map +1 -1
  16. package/destination-tunnel-dialer.mjs +345 -64
  17. package/destination-tunnel-dialer.mjs.map +1 -1
  18. package/destination-tunnel-har.d.mts +23 -0
  19. package/destination-tunnel-har.d.mts.map +1 -0
  20. package/destination-tunnel-har.d.ts +23 -0
  21. package/destination-tunnel-har.d.ts.map +1 -0
  22. package/destination-tunnel-har.js +164 -0
  23. package/destination-tunnel-har.js.map +1 -0
  24. package/destination-tunnel-har.mjs +160 -0
  25. package/destination-tunnel-har.mjs.map +1 -0
  26. package/destination-tunnel-inspection.d.mts +71 -0
  27. package/destination-tunnel-inspection.d.mts.map +1 -0
  28. package/destination-tunnel-inspection.d.ts +71 -0
  29. package/destination-tunnel-inspection.d.ts.map +1 -0
  30. package/destination-tunnel-inspection.js +267 -0
  31. package/destination-tunnel-inspection.js.map +1 -0
  32. package/destination-tunnel-inspection.mjs +261 -0
  33. package/destination-tunnel-inspection.mjs.map +1 -0
  34. package/destination-tunnel.d.mts +119 -14
  35. package/destination-tunnel.d.mts.map +1 -1
  36. package/destination-tunnel.d.ts +119 -14
  37. package/destination-tunnel.d.ts.map +1 -1
  38. package/destination-tunnel.js +407 -98
  39. package/destination-tunnel.js.map +1 -1
  40. package/destination-tunnel.mjs +374 -73
  41. package/destination-tunnel.mjs.map +1 -1
  42. package/index.d.mts +6 -0
  43. package/index.d.mts.map +1 -1
  44. package/index.d.ts +6 -0
  45. package/index.d.ts.map +1 -1
  46. package/index.js +23 -2
  47. package/index.js.map +1 -1
  48. package/index.mjs +5 -0
  49. package/index.mjs.map +1 -1
  50. package/instance-client.d.mts +105 -1
  51. package/instance-client.d.mts.map +1 -1
  52. package/instance-client.d.ts +105 -1
  53. package/instance-client.d.ts.map +1 -1
  54. package/instance-client.js +83 -1
  55. package/instance-client.js.map +1 -1
  56. package/instance-client.mjs +83 -1
  57. package/instance-client.mjs.map +1 -1
  58. package/internal/destination-tunnel-management.d.mts +4 -3
  59. package/internal/destination-tunnel-management.d.mts.map +1 -1
  60. package/internal/destination-tunnel-management.d.ts +4 -3
  61. package/internal/destination-tunnel-management.d.ts.map +1 -1
  62. package/internal/destination-tunnel-management.js +64 -46
  63. package/internal/destination-tunnel-management.js.map +1 -1
  64. package/internal/destination-tunnel-management.mjs +55 -37
  65. package/internal/destination-tunnel-management.mjs.map +1 -1
  66. package/internal/destination-tunnel-url.d.mts +2 -0
  67. package/internal/destination-tunnel-url.d.mts.map +1 -1
  68. package/internal/destination-tunnel-url.d.ts +2 -0
  69. package/internal/destination-tunnel-url.d.ts.map +1 -1
  70. package/internal/destination-tunnel-url.js +45 -5
  71. package/internal/destination-tunnel-url.js.map +1 -1
  72. package/internal/destination-tunnel-url.mjs +44 -5
  73. package/internal/destination-tunnel-url.mjs.map +1 -1
  74. package/internal/destination-tunnel-wire-reader.d.mts +23 -0
  75. package/internal/destination-tunnel-wire-reader.d.mts.map +1 -0
  76. package/internal/destination-tunnel-wire-reader.d.ts +23 -0
  77. package/internal/destination-tunnel-wire-reader.d.ts.map +1 -0
  78. package/internal/destination-tunnel-wire-reader.js +110 -0
  79. package/internal/destination-tunnel-wire-reader.js.map +1 -0
  80. package/internal/destination-tunnel-wire-reader.mjs +93 -0
  81. package/internal/destination-tunnel-wire-reader.mjs.map +1 -0
  82. package/internal/persist-option.d.mts +14 -0
  83. package/internal/persist-option.d.mts.map +1 -0
  84. package/internal/persist-option.d.ts +14 -0
  85. package/internal/persist-option.d.ts.map +1 -0
  86. package/internal/persist-option.js +18 -0
  87. package/internal/persist-option.js.map +1 -0
  88. package/internal/persist-option.mjs +15 -0
  89. package/internal/persist-option.mjs.map +1 -0
  90. package/internal/proxy-transport.d.mts +27 -0
  91. package/internal/proxy-transport.d.mts.map +1 -1
  92. package/internal/proxy-transport.d.ts +27 -0
  93. package/internal/proxy-transport.d.ts.map +1 -1
  94. package/internal/proxy-transport.js +206 -0
  95. package/internal/proxy-transport.js.map +1 -1
  96. package/internal/proxy-transport.mjs +205 -0
  97. package/internal/proxy-transport.mjs.map +1 -1
  98. package/internal/session-stream.d.mts +39 -0
  99. package/internal/session-stream.d.mts.map +1 -0
  100. package/internal/session-stream.d.ts +39 -0
  101. package/internal/session-stream.d.ts.map +1 -0
  102. package/internal/session-stream.js +144 -0
  103. package/internal/session-stream.js.map +1 -0
  104. package/internal/session-stream.mjs +139 -0
  105. package/internal/session-stream.mjs.map +1 -0
  106. package/internal/tslib.js +4 -4
  107. package/ios-client.d.mts +61 -4
  108. package/ios-client.d.mts.map +1 -1
  109. package/ios-client.d.ts +61 -4
  110. package/ios-client.d.ts.map +1 -1
  111. package/ios-client.js +48 -2
  112. package/ios-client.js.map +1 -1
  113. package/ios-client.mjs +48 -2
  114. package/ios-client.mjs.map +1 -1
  115. package/package.json +22 -1
  116. package/resources/android-instances-helpers.d.mts +25 -0
  117. package/resources/android-instances-helpers.d.mts.map +1 -0
  118. package/resources/android-instances-helpers.d.ts +25 -0
  119. package/resources/android-instances-helpers.d.ts.map +1 -0
  120. package/resources/android-instances-helpers.js +37 -0
  121. package/resources/android-instances-helpers.js.map +1 -0
  122. package/resources/android-instances-helpers.mjs +33 -0
  123. package/resources/android-instances-helpers.mjs.map +1 -0
  124. package/resources/index.d.mts +5 -2
  125. package/resources/index.d.mts.map +1 -1
  126. package/resources/index.d.ts +5 -2
  127. package/resources/index.d.ts.map +1 -1
  128. package/resources/index.js +5 -5
  129. package/resources/index.js.map +1 -1
  130. package/resources/index.mjs +2 -2
  131. package/resources/index.mjs.map +1 -1
  132. package/resources/ios-instances-helpers.d.mts +25 -0
  133. package/resources/ios-instances-helpers.d.mts.map +1 -0
  134. package/resources/ios-instances-helpers.d.ts +25 -0
  135. package/resources/ios-instances-helpers.d.ts.map +1 -0
  136. package/resources/ios-instances-helpers.js +37 -0
  137. package/resources/ios-instances-helpers.js.map +1 -0
  138. package/resources/ios-instances-helpers.mjs +33 -0
  139. package/resources/ios-instances-helpers.mjs.map +1 -0
  140. package/resources/session-artifacts.d.mts +70 -0
  141. package/resources/session-artifacts.d.mts.map +1 -0
  142. package/resources/session-artifacts.d.ts +70 -0
  143. package/resources/session-artifacts.d.ts.map +1 -0
  144. package/resources/session-artifacts.js +8 -0
  145. package/resources/session-artifacts.js.map +1 -0
  146. package/resources/session-artifacts.mjs +7 -0
  147. package/resources/session-artifacts.mjs.map +1 -0
  148. package/src/client.ts +4 -2
  149. package/src/destination-tunnel-dialer.ts +403 -64
  150. package/src/destination-tunnel-har.ts +200 -0
  151. package/src/destination-tunnel-inspection.ts +367 -0
  152. package/src/destination-tunnel.ts +510 -88
  153. package/src/index.ts +52 -0
  154. package/src/instance-client.ts +271 -5
  155. package/src/internal/destination-tunnel-management.ts +67 -43
  156. package/src/internal/destination-tunnel-url.ts +61 -5
  157. package/src/internal/destination-tunnel-wire-reader.ts +109 -0
  158. package/src/internal/persist-option.ts +26 -0
  159. package/src/internal/proxy-transport.ts +248 -0
  160. package/src/internal/session-stream.ts +157 -0
  161. package/src/ios-client.ts +127 -8
  162. package/src/resources/android-instances-helpers.ts +45 -0
  163. package/src/resources/index.ts +9 -2
  164. package/src/resources/ios-instances-helpers.ts +45 -0
  165. package/src/resources/session-artifacts.ts +95 -0
  166. package/src/version.ts +1 -1
  167. package/version.d.mts +1 -1
  168. package/version.d.ts +1 -1
  169. package/version.js +1 -1
  170. 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
- validateDestinationTunnelRoutes,
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
- routes: DestinationTunnelRoute[];
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: net.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 routes = validateDestinationTunnelRoutes(options.routes);
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
- if (!connection.socket.destroyed) connection.socket.resume();
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, routes);
465
+ kind = assertDestinationTunnelOpenAllowed(message, selectors);
272
466
  } catch {
273
- sendOpenFailure(message.connId, 'route_not_allowed');
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 socket = net.createConnection({
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
- 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
- });
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
- 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 {
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
- removeConnection(message.connId, true);
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
- socket.once('close', () => {
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
- 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);
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): DialConnection | undefined => {
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 ${routes.length} route(s)`);
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(`destination tunnel failed: ${message.code}`);
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({ type: 'start', version: DESTINATION_TUNNEL_VERSION, routes });
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
- }