@limrun/api 0.48.4 → 0.49.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.
Files changed (193) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/build-settings.d.mts.map +1 -1
  3. package/build-settings.d.ts.map +1 -1
  4. package/build-settings.js +4 -5
  5. package/build-settings.js.map +1 -1
  6. package/build-settings.mjs +4 -5
  7. package/build-settings.mjs.map +1 -1
  8. package/client.d.mts +8 -5
  9. package/client.d.mts.map +1 -1
  10. package/client.d.ts +8 -5
  11. package/client.d.ts.map +1 -1
  12. package/client.js +4 -4
  13. package/client.js.map +1 -1
  14. package/client.mjs +2 -2
  15. package/client.mjs.map +1 -1
  16. package/destination-tunnel-dialer.d.mts +30 -2
  17. package/destination-tunnel-dialer.d.mts.map +1 -1
  18. package/destination-tunnel-dialer.d.ts +30 -2
  19. package/destination-tunnel-dialer.d.ts.map +1 -1
  20. package/destination-tunnel-dialer.js +349 -65
  21. package/destination-tunnel-dialer.js.map +1 -1
  22. package/destination-tunnel-dialer.mjs +345 -64
  23. package/destination-tunnel-dialer.mjs.map +1 -1
  24. package/destination-tunnel-har.d.mts +23 -0
  25. package/destination-tunnel-har.d.mts.map +1 -0
  26. package/destination-tunnel-har.d.ts +23 -0
  27. package/destination-tunnel-har.d.ts.map +1 -0
  28. package/destination-tunnel-har.js +164 -0
  29. package/destination-tunnel-har.js.map +1 -0
  30. package/destination-tunnel-har.mjs +160 -0
  31. package/destination-tunnel-har.mjs.map +1 -0
  32. package/destination-tunnel-inspection.d.mts +71 -0
  33. package/destination-tunnel-inspection.d.mts.map +1 -0
  34. package/destination-tunnel-inspection.d.ts +71 -0
  35. package/destination-tunnel-inspection.d.ts.map +1 -0
  36. package/destination-tunnel-inspection.js +267 -0
  37. package/destination-tunnel-inspection.js.map +1 -0
  38. package/destination-tunnel-inspection.mjs +261 -0
  39. package/destination-tunnel-inspection.mjs.map +1 -0
  40. package/destination-tunnel.d.mts +119 -14
  41. package/destination-tunnel.d.mts.map +1 -1
  42. package/destination-tunnel.d.ts +119 -14
  43. package/destination-tunnel.d.ts.map +1 -1
  44. package/destination-tunnel.js +407 -98
  45. package/destination-tunnel.js.map +1 -1
  46. package/destination-tunnel.mjs +374 -73
  47. package/destination-tunnel.mjs.map +1 -1
  48. package/exec-client.d.mts +9 -0
  49. package/exec-client.d.mts.map +1 -1
  50. package/exec-client.d.ts +9 -0
  51. package/exec-client.d.ts.map +1 -1
  52. package/exec-client.js.map +1 -1
  53. package/exec-client.mjs.map +1 -1
  54. package/index.d.mts +8 -1
  55. package/index.d.mts.map +1 -1
  56. package/index.d.ts +8 -1
  57. package/index.d.ts.map +1 -1
  58. package/index.js +26 -2
  59. package/index.js.map +1 -1
  60. package/index.mjs +7 -1
  61. package/index.mjs.map +1 -1
  62. package/instance-client.d.mts +107 -1
  63. package/instance-client.d.mts.map +1 -1
  64. package/instance-client.d.ts +107 -1
  65. package/instance-client.d.ts.map +1 -1
  66. package/instance-client.js +83 -1
  67. package/instance-client.js.map +1 -1
  68. package/instance-client.mjs +83 -1
  69. package/instance-client.mjs.map +1 -1
  70. package/internal/destination-tunnel-management.d.mts +4 -3
  71. package/internal/destination-tunnel-management.d.mts.map +1 -1
  72. package/internal/destination-tunnel-management.d.ts +4 -3
  73. package/internal/destination-tunnel-management.d.ts.map +1 -1
  74. package/internal/destination-tunnel-management.js +64 -46
  75. package/internal/destination-tunnel-management.js.map +1 -1
  76. package/internal/destination-tunnel-management.mjs +55 -37
  77. package/internal/destination-tunnel-management.mjs.map +1 -1
  78. package/internal/destination-tunnel-url.d.mts +2 -0
  79. package/internal/destination-tunnel-url.d.mts.map +1 -1
  80. package/internal/destination-tunnel-url.d.ts +2 -0
  81. package/internal/destination-tunnel-url.d.ts.map +1 -1
  82. package/internal/destination-tunnel-url.js +45 -5
  83. package/internal/destination-tunnel-url.js.map +1 -1
  84. package/internal/destination-tunnel-url.mjs +44 -5
  85. package/internal/destination-tunnel-url.mjs.map +1 -1
  86. package/internal/destination-tunnel-wire-reader.d.mts +23 -0
  87. package/internal/destination-tunnel-wire-reader.d.mts.map +1 -0
  88. package/internal/destination-tunnel-wire-reader.d.ts +23 -0
  89. package/internal/destination-tunnel-wire-reader.d.ts.map +1 -0
  90. package/internal/destination-tunnel-wire-reader.js +110 -0
  91. package/internal/destination-tunnel-wire-reader.js.map +1 -0
  92. package/internal/destination-tunnel-wire-reader.mjs +93 -0
  93. package/internal/destination-tunnel-wire-reader.mjs.map +1 -0
  94. package/internal/persist-option.d.mts +14 -0
  95. package/internal/persist-option.d.mts.map +1 -0
  96. package/internal/persist-option.d.ts +14 -0
  97. package/internal/persist-option.d.ts.map +1 -0
  98. package/internal/persist-option.js +18 -0
  99. package/internal/persist-option.js.map +1 -0
  100. package/internal/persist-option.mjs +15 -0
  101. package/internal/persist-option.mjs.map +1 -0
  102. package/internal/proxy-transport.d.mts +27 -0
  103. package/internal/proxy-transport.d.mts.map +1 -1
  104. package/internal/proxy-transport.d.ts +27 -0
  105. package/internal/proxy-transport.d.ts.map +1 -1
  106. package/internal/proxy-transport.js +206 -0
  107. package/internal/proxy-transport.js.map +1 -1
  108. package/internal/proxy-transport.mjs +205 -0
  109. package/internal/proxy-transport.mjs.map +1 -1
  110. package/internal/session-stream.d.mts +39 -0
  111. package/internal/session-stream.d.mts.map +1 -0
  112. package/internal/session-stream.d.ts +39 -0
  113. package/internal/session-stream.d.ts.map +1 -0
  114. package/internal/session-stream.js +144 -0
  115. package/internal/session-stream.js.map +1 -0
  116. package/internal/session-stream.mjs +139 -0
  117. package/internal/session-stream.mjs.map +1 -0
  118. package/internal/tslib.js +4 -4
  119. package/ios-client.d.mts +63 -4
  120. package/ios-client.d.mts.map +1 -1
  121. package/ios-client.d.ts +63 -4
  122. package/ios-client.d.ts.map +1 -1
  123. package/ios-client.js +48 -2
  124. package/ios-client.js.map +1 -1
  125. package/ios-client.mjs +48 -2
  126. package/ios-client.mjs.map +1 -1
  127. package/package.json +22 -1
  128. package/resources/android-instances-helpers.d.mts +25 -0
  129. package/resources/android-instances-helpers.d.mts.map +1 -0
  130. package/resources/android-instances-helpers.d.ts +25 -0
  131. package/resources/android-instances-helpers.d.ts.map +1 -0
  132. package/resources/android-instances-helpers.js +37 -0
  133. package/resources/android-instances-helpers.js.map +1 -0
  134. package/resources/android-instances-helpers.mjs +33 -0
  135. package/resources/android-instances-helpers.mjs.map +1 -0
  136. package/resources/index.d.mts +6 -3
  137. package/resources/index.d.mts.map +1 -1
  138. package/resources/index.d.ts +6 -3
  139. package/resources/index.d.ts.map +1 -1
  140. package/resources/index.js +6 -5
  141. package/resources/index.js.map +1 -1
  142. package/resources/index.mjs +3 -3
  143. package/resources/index.mjs.map +1 -1
  144. package/resources/ios-instances-helpers.d.mts +25 -0
  145. package/resources/ios-instances-helpers.d.mts.map +1 -0
  146. package/resources/ios-instances-helpers.d.ts +25 -0
  147. package/resources/ios-instances-helpers.d.ts.map +1 -0
  148. package/resources/ios-instances-helpers.js +37 -0
  149. package/resources/ios-instances-helpers.js.map +1 -0
  150. package/resources/ios-instances-helpers.mjs +33 -0
  151. package/resources/ios-instances-helpers.mjs.map +1 -0
  152. package/resources/session-artifacts.d.mts +70 -0
  153. package/resources/session-artifacts.d.mts.map +1 -0
  154. package/resources/session-artifacts.d.ts +70 -0
  155. package/resources/session-artifacts.d.ts.map +1 -0
  156. package/resources/session-artifacts.js +8 -0
  157. package/resources/session-artifacts.js.map +1 -0
  158. package/resources/session-artifacts.mjs +7 -0
  159. package/resources/session-artifacts.mjs.map +1 -0
  160. package/resources/xcode-instances-helpers.d.mts +55 -0
  161. package/resources/xcode-instances-helpers.d.mts.map +1 -1
  162. package/resources/xcode-instances-helpers.d.ts +55 -0
  163. package/resources/xcode-instances-helpers.d.ts.map +1 -1
  164. package/resources/xcode-instances-helpers.js +49 -3
  165. package/resources/xcode-instances-helpers.js.map +1 -1
  166. package/resources/xcode-instances-helpers.mjs +47 -2
  167. package/resources/xcode-instances-helpers.mjs.map +1 -1
  168. package/src/build-settings.ts +4 -5
  169. package/src/client.ts +12 -2
  170. package/src/destination-tunnel-dialer.ts +403 -64
  171. package/src/destination-tunnel-har.ts +200 -0
  172. package/src/destination-tunnel-inspection.ts +367 -0
  173. package/src/destination-tunnel.ts +510 -88
  174. package/src/exec-client.ts +9 -0
  175. package/src/index.ts +58 -0
  176. package/src/instance-client.ts +273 -5
  177. package/src/internal/destination-tunnel-management.ts +67 -43
  178. package/src/internal/destination-tunnel-url.ts +61 -5
  179. package/src/internal/destination-tunnel-wire-reader.ts +109 -0
  180. package/src/internal/persist-option.ts +26 -0
  181. package/src/internal/proxy-transport.ts +248 -0
  182. package/src/internal/session-stream.ts +157 -0
  183. package/src/ios-client.ts +129 -8
  184. package/src/resources/android-instances-helpers.ts +45 -0
  185. package/src/resources/index.ts +14 -2
  186. package/src/resources/ios-instances-helpers.ts +45 -0
  187. package/src/resources/session-artifacts.ts +95 -0
  188. package/src/resources/xcode-instances-helpers.ts +116 -5
  189. package/src/version.ts +1 -1
  190. package/version.d.mts +1 -1
  191. package/version.d.ts +1 -1
  192. package/version.js +1 -1
  193. package/version.mjs +1 -1
@@ -1,14 +1,99 @@
1
+ import crypto from 'crypto';
1
2
  import net from 'net';
3
+ import { domainToASCII } from 'url';
4
+ import {
5
+ DestinationTunnelProtocolError,
6
+ readArray,
7
+ readBoolean,
8
+ readInteger,
9
+ readOptionalNonNegativeInteger,
10
+ readOptionalString,
11
+ readRecord,
12
+ readString,
13
+ } from './internal/destination-tunnel-wire-reader';
14
+
15
+ export { DestinationTunnelProtocolError } from './internal/destination-tunnel-wire-reader';
2
16
 
3
17
  export const DESTINATION_TUNNEL_VERSION = 1;
4
18
  export const DESTINATION_TUNNEL_MAX_ROUTES = 10;
19
+ export const DESTINATION_TUNNEL_MAX_DOMAINS = 64;
5
20
  export const DESTINATION_TUNNEL_CONN_ID_HEADER_BYTES = 4;
21
+ /**
22
+ * IPv4 range used by the device side for synthetic (fake) DNS answers of
23
+ * matched domain selectors.
24
+ */
25
+ export const DESTINATION_TUNNEL_FAKE_RANGE = '198.18.0.0/15';
26
+ /** Upper bound for per-flow receive windows advertised in open/openOk. */
27
+ export const DESTINATION_TUNNEL_MAX_WINDOW = 64 * 1024 * 1024;
28
+ /** Default receive window advertised by generic SDK tunnel clients. */
29
+ export const DESTINATION_TUNNEL_DEFAULT_WINDOW = 1024 * 1024;
30
+ /** Upper bound for a single windowUpdate increment. */
31
+ export const DESTINATION_TUNNEL_MAX_WINDOW_INCREMENT = 0x7fff_ffff;
32
+ export const DESTINATION_TUNNEL_DEFAULT_MAX_BODY_BYTES = 10 * 1024 * 1024;
33
+ export const DESTINATION_TUNNEL_MAX_BODY_BYTES = 64 * 1024 * 1024;
34
+ export const DESTINATION_TUNNEL_DEFAULT_TTL_SECONDS = 72 * 60 * 60;
35
+ export const DESTINATION_TUNNEL_MAX_TTL_SECONDS = 30 * 24 * 60 * 60;
6
36
 
7
37
  export interface DestinationTunnelRoute {
8
38
  host: string;
9
39
  port: number;
10
40
  }
11
41
 
42
+ /** Canonical selector values carried by the public protocol. */
43
+ export type DestinationTunnelSelectors = string[];
44
+
45
+ /** Internal classification used to enforce and dial canonical selectors. */
46
+ export interface DestinationTunnelSelectorCatalog {
47
+ routes?: DestinationTunnelRoute[];
48
+ domains?: string[];
49
+ }
50
+
51
+ export interface DestinationTunnelInspectionConfig {
52
+ enabled: boolean;
53
+ captureBodies: boolean;
54
+ maxBodyBytes: number;
55
+ persist: boolean;
56
+ ttlSeconds: number;
57
+ }
58
+
59
+ export type DestinationTunnelTransportType = 'tcp' | 'tls';
60
+
61
+ export type DestinationTunnelTransportRequest =
62
+ | { type: 'tcp' }
63
+ | {
64
+ type: 'tls';
65
+ serverName: string;
66
+ alpnProtocols: string[];
67
+ };
68
+
69
+ export interface DestinationTunnelTransportResult {
70
+ type: DestinationTunnelTransportType;
71
+ alpnProtocol?: string;
72
+ remoteAddress?: string;
73
+ dnsMs?: number;
74
+ connectMs?: number;
75
+ tlsMs?: number;
76
+ }
77
+
78
+ export type DestinationTunnelSelectorKind = 'route' | 'domain';
79
+
80
+ export type DestinationTunnelBindStatus = 'ok' | 'conflict' | 'error';
81
+
82
+ /** Per-address bind outcome the device server reports for exact routes. */
83
+ export interface DestinationTunnelBindReport {
84
+ address: string;
85
+ status: DestinationTunnelBindStatus;
86
+ osCode?: string;
87
+ }
88
+
89
+ /** Normalized selector the server acknowledges in `ready` and status. */
90
+ export interface DestinationTunnelSelectorReport {
91
+ id: string;
92
+ kind: DestinationTunnelSelectorKind;
93
+ value: string;
94
+ binds?: DestinationTunnelBindReport[];
95
+ }
96
+
12
97
  export type DestinationTunnelOpenFailureReason =
13
98
  | 'dns_not_found'
14
99
  | 'dns_temporary_failure'
@@ -18,7 +103,10 @@ export type DestinationTunnelOpenFailureReason =
18
103
  | 'unreachable'
19
104
  | 'permission_denied'
20
105
  | 'resource_exhausted'
21
- | 'route_not_allowed'
106
+ | 'selector_not_allowed'
107
+ | 'tls_handshake_failed'
108
+ | 'tls_validation_failed'
109
+ | 'tls_protocol_error'
22
110
  | 'cancelled'
23
111
  | 'internal'
24
112
  | (string & {});
@@ -34,16 +122,27 @@ export type DestinationTunnelResetReason =
34
122
  export type DestinationTunnelSessionErrorCode =
35
123
  | 'unsupported_version'
36
124
  | 'invalid_message'
37
- | 'invalid_route'
38
- | 'route_capacity'
125
+ | 'invalid_selector'
39
126
  | 'already_active'
40
127
  | 'unavailable'
41
128
  | 'internal'
42
129
  | (string & {});
43
130
 
44
131
  export type DestinationTunnelClientMessage =
45
- | { type: 'start'; version: number; routes: DestinationTunnelRoute[] }
46
- | { type: 'openOk'; connId: number }
132
+ | {
133
+ type: 'start';
134
+ version: number;
135
+ selectors: DestinationTunnelSelectors;
136
+ inspection: DestinationTunnelInspectionConfig;
137
+ /** Default per-flow receive window this client grants. */
138
+ window: number;
139
+ }
140
+ | {
141
+ type: 'openOk';
142
+ connId: number;
143
+ transport: DestinationTunnelTransportResult;
144
+ window: number;
145
+ }
47
146
  | {
48
147
  type: 'openFail';
49
148
  connId: number;
@@ -51,43 +150,47 @@ export type DestinationTunnelClientMessage =
51
150
  osCode?: string;
52
151
  }
53
152
  | { type: 'fin'; connId: number }
54
- | { type: 'reset'; connId: number; reason: DestinationTunnelResetReason; osCode?: string };
153
+ | { type: 'reset'; connId: number; reason: DestinationTunnelResetReason; osCode?: string }
154
+ | { type: 'windowUpdate'; connId: number; increment: number };
55
155
 
56
156
  export type DestinationTunnelServerMessage =
57
- | { type: 'ready'; version: number; tunnelId: string }
157
+ | {
158
+ type: 'ready';
159
+ version: number;
160
+ tunnelId: string;
161
+ selectors: DestinationTunnelSelectorReport[];
162
+ configHash: string;
163
+ }
58
164
  | {
59
165
  type: 'open';
60
166
  connId: number;
61
- routeId: string;
167
+ selectorId: string;
62
168
  host: string;
63
169
  port: number;
64
- proto: 'tcp';
170
+ transport: DestinationTunnelTransportRequest;
171
+ /** Server's receive window for this flow. */
172
+ window: number;
65
173
  }
66
174
  | { type: 'fin'; connId: number }
67
175
  | { type: 'reset'; connId: number; reason: DestinationTunnelResetReason; osCode?: string }
176
+ | { type: 'windowUpdate'; connId: number; increment: number }
68
177
  | { type: 'error'; code: DestinationTunnelSessionErrorCode };
69
178
 
70
- export type DestinationTunnelRouteErrorCode =
179
+ export type DestinationTunnelSelectorErrorCode =
71
180
  | 'empty'
72
181
  | 'too_many'
73
182
  | 'invalid_host'
74
183
  | 'invalid_port'
184
+ | 'invalid_domain'
75
185
  | 'duplicate';
76
186
 
77
- export class DestinationTunnelRouteError extends Error {
187
+ export class DestinationTunnelSelectorError extends Error {
78
188
  constructor(
79
- readonly code: DestinationTunnelRouteErrorCode,
189
+ readonly code: DestinationTunnelSelectorErrorCode,
80
190
  message: string,
81
191
  ) {
82
192
  super(message);
83
- this.name = 'DestinationTunnelRouteError';
84
- }
85
- }
86
-
87
- export class DestinationTunnelProtocolError extends Error {
88
- constructor(message: string) {
89
- super(message);
90
- this.name = 'DestinationTunnelProtocolError';
193
+ this.name = 'DestinationTunnelSelectorError';
91
194
  }
92
195
  }
93
196
 
@@ -117,12 +220,13 @@ export function decodeDestinationTunnelDataFrame(frame: Buffer): {
117
220
 
118
221
  export function validateDestinationTunnelRoutes(
119
222
  routes: readonly DestinationTunnelRoute[],
223
+ options: { minPort?: number } = {},
120
224
  ): DestinationTunnelRoute[] {
121
225
  if (routes.length === 0) {
122
- throw new DestinationTunnelRouteError('empty', 'at least one tunnel route is required');
226
+ throw new DestinationTunnelSelectorError('empty', 'at least one tunnel route is required');
123
227
  }
124
228
  if (routes.length > DESTINATION_TUNNEL_MAX_ROUTES) {
125
- throw new DestinationTunnelRouteError(
229
+ throw new DestinationTunnelSelectorError(
126
230
  'too_many',
127
231
  `at most ${DESTINATION_TUNNEL_MAX_ROUTES} tunnel routes are allowed`,
128
232
  );
@@ -131,10 +235,10 @@ export function validateDestinationTunnelRoutes(
131
235
  const canonicalRoutes: DestinationTunnelRoute[] = [];
132
236
  const seen = new Set<string>();
133
237
  for (const route of routes) {
134
- const canonical = canonicalizeDestinationTunnelRoute(route);
238
+ const canonical = canonicalizeDestinationTunnelRoute(route, options.minPort);
135
239
  const key = `${canonical.host}\0${canonical.port}`;
136
240
  if (seen.has(key)) {
137
- throw new DestinationTunnelRouteError(
241
+ throw new DestinationTunnelSelectorError(
138
242
  'duplicate',
139
243
  `duplicate tunnel route ${canonical.host}:${canonical.port}`,
140
244
  );
@@ -145,17 +249,188 @@ export function validateDestinationTunnelRoutes(
145
249
  return canonicalRoutes;
146
250
  }
147
251
 
252
+ /**
253
+ * Validate and canonicalize a full selector set. Selector policy specific to
254
+ * one product (such as the Android bind-listener minimum route port) is
255
+ * expressed via options rather than separate message shapes.
256
+ */
257
+ export function validateDestinationTunnelSelectors(
258
+ selectors: readonly string[],
259
+ options: { minRoutePort?: number } = {},
260
+ ): DestinationTunnelSelectors {
261
+ if (!Array.isArray(selectors)) {
262
+ throw new DestinationTunnelSelectorError('invalid_host', 'tunnel selectors must be an array');
263
+ }
264
+ if (selectors.length === 0) {
265
+ throw new DestinationTunnelSelectorError('empty', 'at least one tunnel selector is required');
266
+ }
267
+ const canonical: string[] = [];
268
+ const seen = new Set<string>();
269
+ let routeCount = 0;
270
+ let domainCount = 0;
271
+ for (const selector of selectors) {
272
+ const parsed = parseDestinationTunnelSelector(selector, options.minRoutePort);
273
+ if (parsed.route) routeCount++;
274
+ else domainCount++;
275
+ if (routeCount > DESTINATION_TUNNEL_MAX_ROUTES) {
276
+ throw new DestinationTunnelSelectorError(
277
+ 'too_many',
278
+ `at most ${DESTINATION_TUNNEL_MAX_ROUTES} exact tunnel selectors are allowed`,
279
+ );
280
+ }
281
+ if (domainCount > DESTINATION_TUNNEL_MAX_DOMAINS) {
282
+ throw new DestinationTunnelSelectorError(
283
+ 'too_many',
284
+ `at most ${DESTINATION_TUNNEL_MAX_DOMAINS} domain tunnel selectors are allowed`,
285
+ );
286
+ }
287
+ if (seen.has(parsed.value)) {
288
+ throw new DestinationTunnelSelectorError('duplicate', `duplicate tunnel selector ${parsed.value}`);
289
+ }
290
+ seen.add(parsed.value);
291
+ canonical.push(parsed.value);
292
+ }
293
+ return canonical;
294
+ }
295
+
296
+ export function classifyDestinationTunnelSelectors(
297
+ selectors: readonly string[],
298
+ options: { minRoutePort?: number } = {},
299
+ ): DestinationTunnelSelectorCatalog {
300
+ const canonical = validateDestinationTunnelSelectors(selectors, options);
301
+ const routes: DestinationTunnelRoute[] = [];
302
+ const domains: string[] = [];
303
+ for (const selector of canonical) {
304
+ const parsed = parseDestinationTunnelSelector(selector, options.minRoutePort);
305
+ if (parsed.route) routes.push(parsed.route);
306
+ else domains.push(parsed.domain!);
307
+ }
308
+ return {
309
+ ...(routes.length > 0 ? { routes } : {}),
310
+ ...(domains.length > 0 ? { domains } : {}),
311
+ };
312
+ }
313
+
314
+ export function validateDestinationTunnelDomains(domains: readonly string[]): string[] {
315
+ if (domains.length > DESTINATION_TUNNEL_MAX_DOMAINS) {
316
+ throw new DestinationTunnelSelectorError(
317
+ 'too_many',
318
+ `at most ${DESTINATION_TUNNEL_MAX_DOMAINS} tunnel domains are allowed`,
319
+ );
320
+ }
321
+ const canonical: string[] = [];
322
+ const seen = new Set<string>();
323
+ for (const domain of domains) {
324
+ const normalized = canonicalizeDestinationTunnelDomain(domain);
325
+ if (seen.has(normalized)) {
326
+ throw new DestinationTunnelSelectorError('duplicate', `duplicate tunnel domain ${normalized}`);
327
+ }
328
+ seen.add(normalized);
329
+ canonical.push(normalized);
330
+ }
331
+ return canonical;
332
+ }
333
+
334
+ /**
335
+ * Deterministic hash of the canonical selector configuration. Servers echo it
336
+ * in `ready` so clients can detect config mismatches across reconnects. The
337
+ * hashed JSON has fixed key order.
338
+ */
339
+ export function destinationTunnelConfigHash(
340
+ selectors: DestinationTunnelSelectors,
341
+ inspection: DestinationTunnelInspectionConfig = disabledDestinationTunnelInspection(),
342
+ ): string {
343
+ const canonical = validateDestinationTunnelSelectors(selectors);
344
+ const canonicalInspection = normalizeDestinationTunnelInspection(inspection);
345
+ const parts: string[] = [
346
+ `"version":${DESTINATION_TUNNEL_VERSION}`,
347
+ `"selectors":[${canonical.map((selector) => JSON.stringify(selector)).join(',')}]`,
348
+ ];
349
+ parts.push(
350
+ `"inspection":{"enabled":${canonicalInspection.enabled},"captureBodies":${canonicalInspection.captureBodies},"maxBodyBytes":${canonicalInspection.maxBodyBytes},"persist":${canonicalInspection.persist},"ttlSeconds":${canonicalInspection.ttlSeconds}}`,
351
+ );
352
+ return crypto
353
+ .createHash('sha256')
354
+ .update(`{${parts.join(',')}}`, 'utf8')
355
+ .digest('hex');
356
+ }
357
+
358
+ export function normalizeDestinationTunnelInspection(
359
+ inspection: Partial<DestinationTunnelInspectionConfig>,
360
+ ): DestinationTunnelInspectionConfig {
361
+ const enabled = inspection.enabled ?? false;
362
+ const captureBodies = inspection.captureBodies ?? false;
363
+ const maxBodyBytes = inspection.maxBodyBytes ?? DESTINATION_TUNNEL_DEFAULT_MAX_BODY_BYTES;
364
+ const persist = inspection.persist ?? false;
365
+ const ttlSeconds = inspection.ttlSeconds ?? DESTINATION_TUNNEL_DEFAULT_TTL_SECONDS;
366
+ if (captureBodies && !enabled) {
367
+ throw new DestinationTunnelProtocolError('captureBodies requires inspection to be enabled');
368
+ }
369
+ if (persist && !enabled) {
370
+ throw new DestinationTunnelProtocolError('persist requires inspection to be enabled');
371
+ }
372
+ if (
373
+ !Number.isInteger(maxBodyBytes) ||
374
+ maxBodyBytes < 1 ||
375
+ maxBodyBytes > DESTINATION_TUNNEL_MAX_BODY_BYTES
376
+ ) {
377
+ throw new DestinationTunnelProtocolError(
378
+ `maxBodyBytes must be an integer between 1 and ${DESTINATION_TUNNEL_MAX_BODY_BYTES}`,
379
+ );
380
+ }
381
+ if (!Number.isInteger(ttlSeconds) || ttlSeconds < 1 || ttlSeconds > DESTINATION_TUNNEL_MAX_TTL_SECONDS) {
382
+ throw new DestinationTunnelProtocolError(
383
+ `ttlSeconds must be an integer between 1 and ${DESTINATION_TUNNEL_MAX_TTL_SECONDS}`,
384
+ );
385
+ }
386
+ return { enabled, captureBodies, maxBodyBytes, persist, ttlSeconds };
387
+ }
388
+
389
+ export function disabledDestinationTunnelInspection(): DestinationTunnelInspectionConfig {
390
+ return normalizeDestinationTunnelInspection({ enabled: false, captureBodies: false });
391
+ }
392
+
393
+ /** Selector IDs are one-based positions in the canonical selector array. */
394
+ export function destinationTunnelSelectorIds(selectors: DestinationTunnelSelectors): string[] {
395
+ return validateDestinationTunnelSelectors(selectors).map((_, index) => `selector-${index + 1}`);
396
+ }
397
+
398
+ export function destinationTunnelDomainMatches(pattern: string, host: string): boolean {
399
+ const candidate = host.toLowerCase();
400
+ if (pattern.startsWith('*.')) {
401
+ const base = pattern.slice(2);
402
+ return candidate.length > base.length + 1 && candidate.endsWith(`.${base}`);
403
+ }
404
+ return candidate === pattern;
405
+ }
406
+
407
+ /**
408
+ * Verify a server OPEN against the negotiated selectors. Every OPEN must name
409
+ * a known selector ID and a target that the selector actually covers.
410
+ */
148
411
  export function assertDestinationTunnelOpenAllowed(
149
412
  message: Extract<DestinationTunnelServerMessage, { type: 'open' }>,
150
- routes: readonly DestinationTunnelRoute[],
151
- ): void {
152
- const index = parseRouteId(message.routeId);
153
- const route = routes[index];
154
- if (!route || message.host !== route.host || message.port !== route.port || message.proto !== 'tcp') {
413
+ selectors: DestinationTunnelSelectors,
414
+ ): DestinationTunnelSelectorKind {
415
+ const canonical = validateDestinationTunnelSelectors(selectors);
416
+ const match = /^selector-([1-9]\d*)$/.exec(message.selectorId);
417
+ const index = match?.[1] ? Number(match[1]) - 1 : -1;
418
+ const fail = (): never => {
155
419
  throw new DestinationTunnelProtocolError(
156
- `server requested undeclared route ${message.routeId} ${message.host}:${message.port}/${message.proto}`,
420
+ `server requested undeclared selector ${message.selectorId} ${message.host}:${message.port}`,
157
421
  );
422
+ };
423
+ if (index < 0) fail();
424
+ if (message.port === 53) fail();
425
+ const selector = canonical[index];
426
+ if (selector === undefined) return fail();
427
+ const parsed = parseDestinationTunnelSelector(selector);
428
+ if (parsed.route) {
429
+ if (message.host !== parsed.route.host || message.port !== parsed.route.port) fail();
430
+ return 'route';
158
431
  }
432
+ if (!destinationTunnelDomainMatches(parsed.domain!, message.host)) fail();
433
+ return 'domain';
159
434
  }
160
435
 
161
436
  export function assertDestinationTunnelReady(
@@ -176,10 +451,30 @@ export function encodeDestinationTunnelClientMessage(message: DestinationTunnelC
176
451
  if (version !== DESTINATION_TUNNEL_VERSION) {
177
452
  throw new DestinationTunnelProtocolError(`unsupported tunnel version ${version}`);
178
453
  }
179
- const routes = validateDestinationTunnelRoutes(readArray(record, 'routes').map(readRoute));
180
- return JSON.stringify({ type, version, routes });
454
+ const selectors = validateDestinationTunnelSelectors(
455
+ readArray(record, 'selectors').map((value) => {
456
+ if (typeof value !== 'string') {
457
+ throw new DestinationTunnelProtocolError('tunnel selector must be a string');
458
+ }
459
+ return value;
460
+ }),
461
+ );
462
+ const inspection = readInspectionConfig(record);
463
+ return JSON.stringify({
464
+ type,
465
+ version,
466
+ selectors,
467
+ inspection,
468
+ window: readWindow(record),
469
+ });
181
470
  }
182
471
  case 'openOk':
472
+ return JSON.stringify({
473
+ type,
474
+ connId: readConnectionId(record),
475
+ transport: readTransportResult(record),
476
+ window: readWindow(record),
477
+ });
183
478
  case 'fin':
184
479
  return JSON.stringify({ type, connId: readConnectionId(record) });
185
480
  case 'openFail':
@@ -190,6 +485,12 @@ export function encodeDestinationTunnelClientMessage(message: DestinationTunnelC
190
485
  reason: readString(record, 'reason'),
191
486
  ...readOptionalString(record, 'osCode'),
192
487
  });
488
+ case 'windowUpdate':
489
+ return JSON.stringify({
490
+ type,
491
+ connId: readConnectionId(record),
492
+ increment: readWindowIncrement(record),
493
+ });
193
494
  default:
194
495
  throw new DestinationTunnelProtocolError(`unknown tunnel control message type ${type}`);
195
496
  }
@@ -205,15 +506,20 @@ export function decodeDestinationTunnelServerMessage(value: unknown): Destinatio
205
506
  type,
206
507
  version: readInteger(message, 'version'),
207
508
  tunnelId: readString(message, 'tunnelId'),
509
+ selectors: readArray(message, 'selectors').map((value, index) =>
510
+ readSelectorReport(value, `selector-${index + 1}`),
511
+ ),
512
+ configHash: readString(message, 'configHash'),
208
513
  };
209
514
  case 'open':
210
515
  return {
211
516
  type,
212
517
  connId: readConnectionId(message),
213
- routeId: readString(message, 'routeId'),
518
+ selectorId: readString(message, 'selectorId'),
214
519
  host: readString(message, 'host'),
215
520
  port: readPort(message, 'port'),
216
- proto: readTCP(message),
521
+ transport: readTransportRequest(message),
522
+ window: readWindow(message),
217
523
  };
218
524
  case 'fin':
219
525
  return { type, connId: readConnectionId(message) };
@@ -224,6 +530,12 @@ export function decodeDestinationTunnelServerMessage(value: unknown): Destinatio
224
530
  reason: readString(message, 'reason'),
225
531
  ...readOptionalString(message, 'osCode'),
226
532
  };
533
+ case 'windowUpdate':
534
+ return {
535
+ type,
536
+ connId: readConnectionId(message),
537
+ increment: readWindowIncrement(message),
538
+ };
227
539
  case 'error':
228
540
  return { type, code: readString(message, 'code') };
229
541
  default:
@@ -231,9 +543,49 @@ export function decodeDestinationTunnelServerMessage(value: unknown): Destinatio
231
543
  }
232
544
  }
233
545
 
234
- function canonicalizeDestinationTunnelRoute(route: DestinationTunnelRoute): DestinationTunnelRoute {
235
- if (!Number.isInteger(route.port) || route.port < 1 || route.port > 65_535 || route.port === 53) {
236
- throw new DestinationTunnelRouteError('invalid_port', `invalid tunnel route port ${route.port}`);
546
+ function parseDestinationTunnelSelector(
547
+ selector: string,
548
+ minRoutePort = 1,
549
+ ): { value: string; route?: DestinationTunnelRoute; domain?: string } {
550
+ if (typeof selector !== 'string' || selector.length === 0) {
551
+ throw new DestinationTunnelSelectorError('empty', 'tunnel selector must not be empty');
552
+ }
553
+ if (!selector.startsWith('[') && !selector.includes(':')) {
554
+ const domain = canonicalizeDestinationTunnelDomain(selector);
555
+ return { value: domain, domain };
556
+ }
557
+
558
+ let host: string;
559
+ let portText: string;
560
+ if (selector.startsWith('[')) {
561
+ const match = /^\[([^\]]+)\]:(\d+)$/.exec(selector);
562
+ if (!match?.[1] || !match[2]) {
563
+ throw new DestinationTunnelSelectorError('invalid_host', `invalid tunnel selector ${selector}`);
564
+ }
565
+ host = match[1];
566
+ portText = match[2];
567
+ } else {
568
+ const separator = selector.lastIndexOf(':');
569
+ if (separator <= 0 || selector.indexOf(':') !== separator) {
570
+ throw new DestinationTunnelSelectorError('invalid_host', `invalid tunnel selector ${selector}`);
571
+ }
572
+ host = selector.slice(0, separator);
573
+ portText = selector.slice(separator + 1);
574
+ }
575
+ if (!/^\d+$/.test(portText)) {
576
+ throw new DestinationTunnelSelectorError('invalid_port', `invalid tunnel selector port ${portText}`);
577
+ }
578
+ const route = canonicalizeDestinationTunnelRoute({ host, port: Number(portText) }, minRoutePort);
579
+ const formattedHost = route.host.includes(':') ? `[${route.host}]` : route.host;
580
+ return { value: `${formattedHost}:${route.port}`, route };
581
+ }
582
+
583
+ function canonicalizeDestinationTunnelRoute(
584
+ route: DestinationTunnelRoute,
585
+ minPort = 1,
586
+ ): DestinationTunnelRoute {
587
+ if (!Number.isInteger(route.port) || route.port < minPort || route.port > 65_535 || route.port === 53) {
588
+ throw new DestinationTunnelSelectorError('invalid_port', `invalid tunnel route port ${route.port}`);
237
589
  }
238
590
 
239
591
  const asciiHost = Buffer.byteLength(route.host, 'utf8') === route.host.length;
@@ -249,12 +601,40 @@ function canonicalizeDestinationTunnelRoute(route: DestinationTunnelRoute): Dest
249
601
  const hostname = new URL(`http://[${route.host}]/`).hostname;
250
602
  const canonical = hostname.slice(1, -1);
251
603
  if (canonical !== '::1' && /^::(?:[0-9a-f]{1,4}:)?[0-9a-f]{1,4}$/.test(canonical)) {
252
- throw new DestinationTunnelRouteError('invalid_host', `invalid tunnel route host ${route.host}`);
604
+ throw new DestinationTunnelSelectorError('invalid_host', `invalid tunnel route host ${route.host}`);
253
605
  }
254
606
  return { host: canonicalizeIPv6(canonical), port: route.port };
255
607
  }
256
608
 
257
- throw new DestinationTunnelRouteError('invalid_host', `invalid tunnel route host ${route.host}`);
609
+ throw new DestinationTunnelSelectorError('invalid_host', `invalid tunnel route host ${route.host}`);
610
+ }
611
+
612
+ function canonicalizeDestinationTunnelDomain(domain: string): string {
613
+ const invalid = (): never => {
614
+ throw new DestinationTunnelSelectorError('invalid_domain', `invalid tunnel domain ${domain}`);
615
+ };
616
+ if (typeof domain !== 'string' || domain.length === 0 || domain.length > 260) invalid();
617
+ // ASCII only: reject anything IDNA mapping would change to avoid ambiguity
618
+ // between implementations. Users provide punycode (xn--) names directly.
619
+ if (Buffer.byteLength(domain, 'utf8') !== domain.length) invalid();
620
+ const lowered = domain.toLowerCase();
621
+ const wildcard = lowered.startsWith('*.');
622
+ const base = wildcard ? lowered.slice(2) : lowered;
623
+ if (base.length === 0 || base.length > 253) invalid();
624
+ if (base.includes('*')) invalid();
625
+ if (base.endsWith('.') || base.startsWith('.')) invalid();
626
+ if (net.isIP(base) !== 0) invalid();
627
+ if (base === 'localhost') invalid();
628
+ if (domainToASCII(base) !== base) invalid();
629
+ const labels = base.split('.');
630
+ for (const label of labels) {
631
+ if (label.length === 0 || label.length > 63) invalid();
632
+ if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(label)) invalid();
633
+ }
634
+ // All-numeric final labels would be ambiguous with IPv4 literals.
635
+ const finalLabel = labels[labels.length - 1];
636
+ if (finalLabel !== undefined && /^\d+$/.test(finalLabel)) invalid();
637
+ return wildcard ? `*.${base}` : base;
258
638
  }
259
639
 
260
640
  function canonicalizeIPv6(host: string): string {
@@ -267,62 +647,92 @@ function canonicalizeIPv6(host: string): string {
267
647
  return `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`;
268
648
  }
269
649
 
270
- function parseRouteId(routeId: string): number {
271
- const match = /^route-([1-9]\d*)$/.exec(routeId);
272
- if (!match?.[1]) {
273
- return -1;
650
+ function readSelectorReport(value: unknown, expectedId: string): DestinationTunnelSelectorReport {
651
+ const report = readRecord(value, 'tunnel selector');
652
+ const id = readString(report, 'id');
653
+ if (id !== expectedId) {
654
+ throw new DestinationTunnelProtocolError(`invalid tunnel selector id ${id}`);
274
655
  }
275
- return Number(match[1]) - 1;
276
- }
277
-
278
- function readRoute(value: unknown): DestinationTunnelRoute {
279
- const route = readRecord(value, 'tunnel route');
280
- return {
281
- host: readString(route, 'host'),
282
- port: readPort(route, 'port'),
656
+ const kind = readString(report, 'kind');
657
+ if (kind !== 'route' && kind !== 'domain') {
658
+ throw new DestinationTunnelProtocolError(`invalid tunnel selector kind ${kind}`);
659
+ }
660
+ const result: DestinationTunnelSelectorReport = {
661
+ id,
662
+ kind,
663
+ value: readString(report, 'value'),
283
664
  };
284
- }
285
-
286
- function readRecord(value: unknown, name: string): Record<string, unknown> {
287
- if (typeof value !== 'object' || value === null || Array.isArray(value)) {
288
- throw new DestinationTunnelProtocolError(`${name} must be an object`);
665
+ if (report['binds'] !== undefined) {
666
+ result.binds = readArray(report, 'binds').map(readBindReport);
289
667
  }
290
- return value as Record<string, unknown>;
668
+ return result;
291
669
  }
292
670
 
293
- function readArray(record: Record<string, unknown>, key: string): unknown[] {
294
- const value = record[key];
295
- if (!Array.isArray(value)) {
296
- throw new DestinationTunnelProtocolError(`${key} must be an array`);
671
+ function readBindReport(value: unknown): DestinationTunnelBindReport {
672
+ const bind = readRecord(value, 'tunnel bind report');
673
+ const status = readString(bind, 'status');
674
+ if (status !== 'ok' && status !== 'conflict' && status !== 'error') {
675
+ throw new DestinationTunnelProtocolError(`invalid tunnel bind status ${status}`);
297
676
  }
298
- return value;
677
+ return {
678
+ address: readString(bind, 'address'),
679
+ status,
680
+ ...readOptionalString(bind, 'osCode'),
681
+ };
299
682
  }
300
683
 
301
- function readString(record: Record<string, unknown>, key: string): string {
302
- const value = record[key];
303
- if (typeof value !== 'string') {
304
- throw new DestinationTunnelProtocolError(`${key} must be a string`);
305
- }
306
- return value;
684
+ function readInspectionConfig(record: Record<string, unknown>): DestinationTunnelInspectionConfig {
685
+ const inspection = readRecord(record['inspection'], 'inspection');
686
+ const enabled = readBoolean(inspection, 'enabled');
687
+ const captureBodies = readBoolean(inspection, 'captureBodies');
688
+ const maxBodyBytes = readInteger(inspection, 'maxBodyBytes');
689
+ const persist = readBoolean(inspection, 'persist');
690
+ const ttlSeconds = readInteger(inspection, 'ttlSeconds');
691
+ return normalizeDestinationTunnelInspection({
692
+ enabled,
693
+ captureBodies,
694
+ maxBodyBytes,
695
+ persist,
696
+ ttlSeconds,
697
+ });
307
698
  }
308
699
 
309
- function readOptionalString(record: Record<string, unknown>, key: string): { [K in typeof key]?: string } {
310
- const value = record[key];
311
- if (value === undefined) {
312
- return {};
700
+ function readTransportRequest(record: Record<string, unknown>): DestinationTunnelTransportRequest {
701
+ const transport = readRecord(record['transport'], 'transport');
702
+ const type = readString(transport, 'type');
703
+ if (type === 'tcp') {
704
+ return { type };
313
705
  }
314
- if (typeof value !== 'string') {
315
- throw new DestinationTunnelProtocolError(`${key} must be a string`);
706
+ if (type === 'tls') {
707
+ const serverName = readString(transport, 'serverName');
708
+ if (serverName.length === 0) {
709
+ throw new DestinationTunnelProtocolError('TLS transport requires serverName');
710
+ }
711
+ const alpnProtocols = readArray(transport, 'alpnProtocols').map((protocol) => {
712
+ if (typeof protocol !== 'string' || protocol.length === 0) {
713
+ throw new DestinationTunnelProtocolError('alpnProtocols must contain non-empty strings');
714
+ }
715
+ return protocol;
716
+ });
717
+ return { type, serverName, alpnProtocols };
316
718
  }
317
- return { [key]: value };
719
+ throw new DestinationTunnelProtocolError(`unsupported tunnel transport ${type}`);
318
720
  }
319
721
 
320
- function readInteger(record: Record<string, unknown>, key: string): number {
321
- const value = record[key];
322
- if (!Number.isInteger(value)) {
323
- throw new DestinationTunnelProtocolError(`${key} must be an integer`);
722
+ function readTransportResult(record: Record<string, unknown>): DestinationTunnelTransportResult {
723
+ const transport = readRecord(record['transport'], 'transport');
724
+ const type = readString(transport, 'type');
725
+ if (type !== 'tcp' && type !== 'tls') {
726
+ throw new DestinationTunnelProtocolError(`unsupported tunnel transport ${type}`);
324
727
  }
325
- return value as number;
728
+ return {
729
+ type,
730
+ ...readOptionalString(transport, 'alpnProtocol'),
731
+ ...readOptionalString(transport, 'remoteAddress'),
732
+ ...readOptionalNonNegativeInteger(transport, 'dnsMs'),
733
+ ...readOptionalNonNegativeInteger(transport, 'connectMs'),
734
+ ...readOptionalNonNegativeInteger(transport, 'tlsMs'),
735
+ };
326
736
  }
327
737
 
328
738
  function readPort(record: Record<string, unknown>, key: string): number {
@@ -333,6 +743,26 @@ function readPort(record: Record<string, unknown>, key: string): number {
333
743
  return value;
334
744
  }
335
745
 
746
+ function readWindow(record: Record<string, unknown>): number {
747
+ const value = readInteger(record, 'window');
748
+ if (value < 1 || value > DESTINATION_TUNNEL_MAX_WINDOW) {
749
+ throw new DestinationTunnelProtocolError(
750
+ `window must be an integer between 1 and ${DESTINATION_TUNNEL_MAX_WINDOW}`,
751
+ );
752
+ }
753
+ return value;
754
+ }
755
+
756
+ function readWindowIncrement(record: Record<string, unknown>): number {
757
+ const value = readInteger(record, 'increment');
758
+ if (value < 1 || value > DESTINATION_TUNNEL_MAX_WINDOW_INCREMENT) {
759
+ throw new DestinationTunnelProtocolError(
760
+ `increment must be an integer between 1 and ${DESTINATION_TUNNEL_MAX_WINDOW_INCREMENT}`,
761
+ );
762
+ }
763
+ return value;
764
+ }
765
+
336
766
  function readConnectionId(record: Record<string, unknown>): number {
337
767
  const value = readInteger(record, 'connId');
338
768
  validateConnectionId(value);
@@ -344,11 +774,3 @@ function validateConnectionId(value: number): void {
344
774
  throw new DestinationTunnelProtocolError('connId must be an unsigned 32-bit integer');
345
775
  }
346
776
  }
347
-
348
- function readTCP(record: Record<string, unknown>): 'tcp' {
349
- const value = readString(record, 'proto');
350
- if (value !== 'tcp') {
351
- throw new DestinationTunnelProtocolError(`unsupported tunnel transport ${value}`);
352
- }
353
- return value;
354
- }