socketduct 0.1.16 → 0.1.17

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.
@@ -6,6 +6,7 @@ import {availableCodecs, negotiateCodec, zstdAvailable} from "../protocol/compre
6
6
  import {decodeV3Data, encodeV3Data, AdaptiveCompressor} from "../protocol/v3-compression.js"
7
7
  import {decodeV3Control, encodeV3Control, encodeV3Frame, V3FrameDecoder, V3FrameType} from "../protocol/v3-frame.js"
8
8
  import {
9
+ asError,
9
10
  DEFAULT_FRAME_BYTES,
10
11
  DEFAULT_HANDSHAKE_MS,
11
12
  DEFAULT_KEEP_ALIVE_INITIAL_DELAY_MS,
@@ -17,10 +18,13 @@ import {
17
18
  parseTarget,
18
19
  positiveInteger,
19
20
  protocolError,
21
+ ROUTE_CLASS,
20
22
  STABLE_IDENTIFIER,
21
23
  writeSocket
22
24
  } from "./common.js"
23
25
 
26
+ const DEFAULT_OUTPUT_BUFFER_BYTES = 32 * 1024 * 1024
27
+
24
28
  /**
25
29
  * @typedef {{userId: string, username: string}} Principal
26
30
  * @typedef {{host: string, port: number}} Target
@@ -31,8 +35,11 @@ import {
31
35
  * @property {{certFile: string, keyFile: string, clientCaFile?: string}} tls - Mandatory outer TLS server configuration.
32
36
  * @property {(input: {principal: Principal, gatewayId: string}) => boolean} authorizeGateway - Stable gateway identity binding.
33
37
  * @property {(input: {principal: Principal, gatewayId: string}) => boolean} authorizeClientGateway - Client-to-gateway selection policy.
38
+ * @property {(input: {principal: Principal, routeClass: string}) => boolean} [authorizeClientRouteClass] - Client-to-route-class selection policy.
34
39
  * @property {(input: {principal: Principal, gatewayId: string, target: Target}) => boolean} authorizeOpen - Hub-side route policy.
40
+ * @property {(input: {principal: Principal, routeClass: string, target: Target}) => string[]} [resolveOpenGateways] - Eligible exact gateways for a route-class OPEN.
35
41
  * @property {string[]} [knownGatewayIds] - Configured IDs that may be temporarily unavailable.
42
+ * @property {string[]} [knownRouteClasses] - Configured route classes available to authorized clients.
36
43
  * @property {number} [initialWindowBytes] - Per-direction flow-control window.
37
44
  * @property {number} [maxFrameBytes] - Maximum uncompressed DATA frame.
38
45
  * @property {number} [maxStreams] - Process-wide bridge limit.
@@ -40,6 +47,7 @@ import {
40
47
  * @property {number} [maxConnectionIdentities] - Retained stale-generation identity bound.
41
48
  * @property {number} [handshakeTimeoutMs] - Authentication deadline.
42
49
  * @property {number} [maxInputBytesPerConnection] - Pre-authentication byte bound.
50
+ * @property {number} [maxOutputBytesPerConnection] - Per-physical queued hub output bound.
43
51
  * @property {import('../protocol/compression.js').CompressionLimits} [compressionLimits] - zstd safety bounds.
44
52
  * @typedef {object} HubPhysical
45
53
  * @property {import('node:tls').TLSSocket} socket
@@ -49,12 +57,19 @@ import {
49
57
  * @property {number} generation
50
58
  * @property {string | undefined} codec
51
59
  * @property {string | undefined} gatewayId
60
+ * @property {string | undefined} routeClass
52
61
  * @property {Map<number, Bridge>} streams
53
62
  * @property {number} lastClientStreamId
54
63
  * @property {number} nextGatewayStreamId
55
64
  * @property {boolean} authenticated
56
65
  * @property {boolean} closing
57
66
  * @property {(() => void) | undefined} clearHandshake
67
+ * @property {HubOutputItem[]} outputQueue
68
+ * @property {number} outputBytes
69
+ * @property {boolean} outputWriting
70
+ * @typedef {object} HubOutputItem
71
+ * @property {Buffer} bytes
72
+ * @property {(delivered: boolean) => void} resolve
58
73
  * @typedef {object} GatewayOwner
59
74
  * @property {string} gatewayId
60
75
  * @property {string} connectionId
@@ -92,6 +107,7 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
92
107
  const maxStreams = positiveInteger(options.maxStreams, "maxStreams", DEFAULT_MAX_STREAMS)
93
108
  const maxIdentities = positiveInteger(options.maxConnectionIdentities, "maxConnectionIdentities", 4096)
94
109
  const maxPhysicalConnections = positiveInteger(options.maxPhysicalConnections, "maxPhysicalConnections", 8192)
110
+ const maxOutputBytes = positiveInteger(options.maxOutputBytesPerConnection, "maxOutputBytesPerConnection", DEFAULT_OUTPUT_BUFFER_BYTES)
95
111
  /** @type {Map<string, GatewayOwner>} */
96
112
  const gateways = new Map()
97
113
  /** @type {Map<string, ClientOwner>} */
@@ -153,12 +169,16 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
153
169
  generation: 0,
154
170
  codec: undefined,
155
171
  gatewayId: undefined,
172
+ routeClass: undefined,
156
173
  streams: new Map(),
157
174
  lastClientStreamId: 0,
158
175
  nextGatewayStreamId: 2,
159
176
  authenticated: false,
160
177
  closing: false,
161
- clearHandshake: undefined
178
+ clearHandshake: undefined,
179
+ outputQueue: [],
180
+ outputBytes: 0,
181
+ outputWriting: false
162
182
  }
163
183
  physicals.add(physical)
164
184
  const decoder = new V3FrameDecoder({maxFrameBytes: Math.max(MAX_CONTROL_BYTES, maxFrameBytes + 4), maxBufferedBytes: Math.max(MAX_CONTROL_BYTES, maxFrameBytes + 4) * 2})
@@ -174,10 +194,9 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
174
194
  if (!physical.authenticated && unauthenticatedBytes > (options.maxInputBytesPerConnection ?? DEFAULT_MAX_INPUT_BYTES)) throw protocolError("INPUT_LIMIT_EXCEEDED")
175
195
  const frames = decoder.push(chunk)
176
196
  for (const frame of frames) await handleFrame(physical, frame)
177
- }).catch(async (error) => {
197
+ }).catch((error) => {
178
198
  const code = typeof /** @type {{code?: unknown}} */ (error).code === "string" ? /** @type {{code: string}} */ (error).code : "PROTOCOL_VIOLATION"
179
- await sendControl(physical, V3FrameType.GOAWAY, 0, 0, {code}).catch(() => {})
180
- socket.destroy()
199
+ terminate(physical, code)
181
200
  }).finally(() => {
182
201
  if (!socket.destroyed && !physical.closing) socket.resume()
183
202
  })
@@ -237,9 +256,9 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
237
256
  await registerGateway(physical, frame)
238
257
  return
239
258
  }
240
- if (physical.role === "client" && physical.gatewayId === undefined) {
259
+ if (physical.role === "client" && physical.gatewayId === undefined && physical.routeClass === undefined) {
241
260
  if (frame.type !== V3FrameType.GATEWAY_SELECT) throw protocolError("GATEWAY_SELECTION_REQUIRED")
242
- await selectGateway(physical, frame)
261
+ await selectClientRoute(physical, frame)
243
262
  return
244
263
  }
245
264
  if (frame.type === V3FrameType.GATEWAY_REGISTER || frame.type === V3FrameType.GATEWAY_REGISTERED || frame.type === V3FrameType.GATEWAY_SELECT || frame.type === V3FrameType.GATEWAY_SELECTED || frame.type === V3FrameType.AUTH || frame.type === V3FrameType.AUTH_OK || frame.type === V3FrameType.AUTH_ERROR || frame.type === V3FrameType.RESUME || frame.type === V3FrameType.RESUMED) throw protocolError("UNSUPPORTED_FRAME")
@@ -309,17 +328,28 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
309
328
  }
310
329
 
311
330
  /** @param {HubPhysical} physical - Authenticated client. @param {import('../protocol/v3-frame.js').V3Frame} frame - Selection frame. */
312
- async function selectGateway(physical, frame) {
331
+ async function selectClientRoute(physical, frame) {
313
332
  if (frame.streamId !== 0 || frame.sequence !== physical.generation || frame.flags !== 0) throw protocolError("INVALID_GATEWAY_SELECTION")
314
333
  const request = decodeV3Control(frame.payload)
315
- if (typeof request.gatewayId !== "string" || !GATEWAY_ID.test(request.gatewayId)) throw protocolError("INVALID_GATEWAY_SELECTION")
316
- if (physical.principal === undefined || options.authorizeClientGateway({principal: physical.principal, gatewayId: request.gatewayId}) !== true) throw protocolError("GATEWAY_UNAUTHORIZED")
317
- const owner = gateways.get(request.gatewayId)
318
- const known = owner !== undefined || options.knownGatewayIds?.includes(request.gatewayId) === true
319
- if (!known) throw protocolError("UNKNOWN_GATEWAY_ID")
320
- if (owner?.physical === undefined) throw protocolError("GATEWAY_UNAVAILABLE")
321
- physical.gatewayId = request.gatewayId
322
- await sendControl(physical, V3FrameType.GATEWAY_SELECTED, 0, physical.generation, {gatewayId: request.gatewayId, generation: physical.generation})
334
+ const hasGatewayId = Object.hasOwn(request, "gatewayId")
335
+ const hasRouteClass = Object.hasOwn(request, "routeClass")
336
+ if (hasGatewayId === hasRouteClass || physical.principal === undefined) throw protocolError("INVALID_GATEWAY_SELECTION")
337
+ if (hasGatewayId) {
338
+ if (typeof request.gatewayId !== "string" || !GATEWAY_ID.test(request.gatewayId)) throw protocolError("INVALID_GATEWAY_SELECTION")
339
+ if (options.authorizeClientGateway({principal: physical.principal, gatewayId: request.gatewayId}) !== true) throw protocolError("GATEWAY_UNAUTHORIZED")
340
+ const owner = gateways.get(request.gatewayId)
341
+ const known = owner !== undefined || options.knownGatewayIds?.includes(request.gatewayId) === true
342
+ if (!known) throw protocolError("UNKNOWN_GATEWAY_ID")
343
+ if (owner?.physical === undefined) throw protocolError("GATEWAY_UNAVAILABLE")
344
+ physical.gatewayId = request.gatewayId
345
+ await sendControl(physical, V3FrameType.GATEWAY_SELECTED, 0, physical.generation, {gatewayId: request.gatewayId, generation: physical.generation})
346
+ return
347
+ }
348
+ if (typeof request.routeClass !== "string" || !ROUTE_CLASS.test(request.routeClass)) throw protocolError("INVALID_ROUTE_CLASS_SELECTION")
349
+ if (options.authorizeClientRouteClass?.({principal: physical.principal, routeClass: request.routeClass}) !== true) throw protocolError("ROUTE_CLASS_UNAUTHORIZED")
350
+ if (options.knownRouteClasses?.includes(request.routeClass) !== true) throw protocolError("UNKNOWN_ROUTE_CLASS")
351
+ physical.routeClass = request.routeClass
352
+ await sendControl(physical, V3FrameType.GATEWAY_SELECTED, 0, physical.generation, {routeClass: request.routeClass, generation: physical.generation})
323
353
  }
324
354
 
325
355
  /** @param {HubPhysical} client - Client physical. @param {import('../protocol/v3-frame.js').V3Frame} frame - OPEN. */
@@ -329,20 +359,51 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
329
359
  client.lastClientStreamId = frame.streamId
330
360
  const request = decodeV3Control(frame.payload)
331
361
  const target = parseTarget(request.target)
332
- if (target === undefined || typeof request.gatewayId !== "string" || request.gatewayId !== client.gatewayId || !GATEWAY_ID.test(request.gatewayId) || typeof request.identifier !== "string" || !STABLE_IDENTIFIER.test(request.identifier) || typeof request.name !== "string" || request.name.length === 0 || request.name.length > 256 || client.principal === undefined) {
362
+ if (target === undefined || typeof request.identifier !== "string" || !STABLE_IDENTIFIER.test(request.identifier) || typeof request.name !== "string" || request.name.length === 0 || request.name.length > 256 || client.principal === undefined) {
333
363
  await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: "INVALID_STREAM_OPEN"})
334
364
  return
335
365
  }
336
- if (options.authorizeOpen({principal: client.principal, gatewayId: request.gatewayId, target}) !== true) {
366
+ let gatewayId
367
+ if (client.routeClass !== undefined) {
368
+ if (request.routeClass !== client.routeClass || Object.hasOwn(request, "gatewayId")) {
369
+ await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: "ROUTE_CLASS_MISMATCH"})
370
+ return
371
+ }
372
+ const candidates = options.resolveOpenGateways?.({principal: client.principal, routeClass: client.routeClass, target})
373
+ if (!Array.isArray(candidates) || candidates.some((candidate) => typeof candidate !== "string" || !GATEWAY_ID.test(candidate))) {
374
+ rejectedOpens += 1
375
+ await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: "INVALID_ROUTE_RESOLUTION"})
376
+ return
377
+ }
378
+ if (candidates.length === 0) {
379
+ rejectedOpens += 1
380
+ await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: "TARGET_UNAUTHORIZED"})
381
+ return
382
+ }
383
+ if (candidates.length !== 1 || new Set(candidates).size !== 1) {
384
+ rejectedOpens += 1
385
+ await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: "AMBIGUOUS_ROUTE"})
386
+ return
387
+ }
388
+ gatewayId = /** @type {string} */ (candidates[0])
389
+ } else {
390
+ if (typeof request.gatewayId !== "string" || request.gatewayId !== client.gatewayId || !GATEWAY_ID.test(request.gatewayId) || Object.hasOwn(request, "routeClass")) {
391
+ await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: "INVALID_STREAM_OPEN"})
392
+ return
393
+ }
394
+ gatewayId = request.gatewayId
395
+ }
396
+ if (options.authorizeOpen({principal: client.principal, gatewayId, target}) !== true) {
337
397
  rejectedOpens += 1
338
398
  await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: "TARGET_UNAUTHORIZED"})
339
399
  return
340
400
  }
341
- const gatewayOwner = gateways.get(request.gatewayId)
401
+ const gatewayOwner = gateways.get(gatewayId)
342
402
  const gateway = gatewayOwner?.physical
343
- if (gateway === undefined || gateway.socket.destroyed || gateway.gatewayId !== request.gatewayId) {
403
+ if (gatewayOwner === undefined || gateway === undefined || gateway.socket.destroyed || gateway.role !== "gateway" || gateway.gatewayId !== gatewayId || gatewayOwner.gatewayId !== gatewayId || gatewayOwner.userId !== gateway.principal?.userId) {
344
404
  rejectedOpens += 1
345
- await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: gatewayOwner === undefined ? "UNKNOWN_GATEWAY_ID" : "GATEWAY_UNAVAILABLE"})
405
+ const known = gatewayOwner !== undefined || options.knownGatewayIds?.includes(gatewayId) === true
406
+ await sendControl(client, V3FrameType.RESET, frame.streamId, 0, {code: known ? "GATEWAY_UNAVAILABLE" : "UNKNOWN_GATEWAY_ID"})
346
407
  return
347
408
  }
348
409
  const gatewayStreamId = gateway.nextGatewayStreamId
@@ -372,7 +433,7 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
372
433
  client.streams.set(frame.streamId, bridge)
373
434
  gateway.streams.set(gatewayStreamId, bridge)
374
435
  bridges += 1
375
- await sendControl(gateway, V3FrameType.OPEN, gatewayStreamId, 0, {identifier: request.identifier, name: request.name, target, initialWindow})
436
+ forwardControl(gateway, V3FrameType.OPEN, gatewayStreamId, 0, {identifier: request.identifier, name: request.name, target, initialWindow})
376
437
  }
377
438
 
378
439
  /** @param {HubPhysical} source - Frame source. @param {import('../protocol/v3-frame.js').V3Frame} frame - Stream frame. */
@@ -390,7 +451,7 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
390
451
  const detail = decodeV3Control(frame.payload)
391
452
  if (detail.identifier !== bridge.identifier) throw protocolError("OPEN_IDENTIFIER_MISMATCH")
392
453
  bridge.state = "open"
393
- await sendControl(destination, V3FrameType.OPENED, destinationStreamId, 0, {identifier: bridge.identifier, initialWindow})
454
+ forwardControl(destination, V3FrameType.OPENED, destinationStreamId, 0, {identifier: bridge.identifier, initialWindow})
394
455
  return
395
456
  }
396
457
  if (bridge.state === "opening" && frame.type !== V3FrameType.RESET) throw protocolError("STREAM_NOT_OPEN")
@@ -400,12 +461,12 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
400
461
  if (!Number.isSafeInteger(frame.sequence) || frame.sequence < previous || frame.sequence > maximum) throw protocolError("INVALID_ACK")
401
462
  if (fromClient) bridge.gatewayToClientAck = frame.sequence
402
463
  else bridge.clientToGatewayAck = frame.sequence
403
- if (bridge.state !== "closing") await sendControl(destination, V3FrameType.ACK, destinationStreamId, frame.sequence, {})
464
+ if (bridge.state !== "closing") forwardControl(destination, V3FrameType.ACK, destinationStreamId, frame.sequence, {})
404
465
  return
405
466
  }
406
467
  if (frame.type === V3FrameType.WINDOW) {
407
468
  if (frame.sequence <= 0 || frame.sequence > initialWindow) throw protocolError("INVALID_WINDOW")
408
- if (bridge.state !== "closing") await sendControl(destination, V3FrameType.WINDOW, destinationStreamId, frame.sequence, {})
469
+ if (bridge.state !== "closing") forwardControl(destination, V3FrameType.WINDOW, destinationStreamId, frame.sequence, {})
409
470
  return
410
471
  }
411
472
  if (bridge.state === "closing") {
@@ -426,8 +487,7 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
426
487
  const encoded = await encodeV3Data(raw, destination.codec, fromClient ? bridge.clientToGatewayCompressor : bridge.gatewayToClientCompressor, options.compressionLimits)
427
488
  rawDataBytes += encoded.rawBytes
428
489
  wireDataBytes += encoded.wireBytes
429
- const delivered = await writeSocket(destination.socket, encodeV3Frame({type: V3FrameType.DATA, streamId: destinationStreamId, sequence: frame.sequence, flags: encoded.flags}, encoded.payload))
430
- if (!delivered) closeBridge(bridge, "DESTINATION_CLOSED", source)
490
+ forwardOutput(destination, encodeV3Frame({type: V3FrameType.DATA, streamId: destinationStreamId, sequence: frame.sequence, flags: encoded.flags}, encoded.payload))
431
491
  return
432
492
  }
433
493
  if (frame.type === V3FrameType.END) {
@@ -436,14 +496,14 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
436
496
  if (fromClient) bridge.clientEnded = true
437
497
  else bridge.gatewayEnded = true
438
498
  bridge.state = "half-closed"
439
- await sendControl(destination, V3FrameType.END, destinationStreamId, frame.sequence, {})
499
+ forwardControl(destination, V3FrameType.END, destinationStreamId, frame.sequence, {})
440
500
  return
441
501
  }
442
502
  if (frame.type === V3FrameType.CLOSE || frame.type === V3FrameType.RESET) {
443
503
  const expected = fromClient ? bridge.clientToGatewaySequence : bridge.gatewayToClientSequence
444
504
  if ((frame.type === V3FrameType.CLOSE && frame.sequence !== expected) || (frame.type === V3FrameType.RESET && frame.sequence !== 0)) throw protocolError("INVALID_STREAM_CLOSE")
445
505
  const detail = frame.payload.length === 0 ? {} : decodeV3Control(frame.payload)
446
- await sendControl(destination, frame.type, destinationStreamId, frame.sequence, detail)
506
+ forwardControl(destination, frame.type, destinationStreamId, frame.sequence, detail)
447
507
  if (frame.type === V3FrameType.RESET || fromClient) removeBridge(bridge)
448
508
  else bridge.state = "closing"
449
509
  return
@@ -458,6 +518,16 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
458
518
  if (existed) bridges = Math.max(0, bridges - 1)
459
519
  }
460
520
 
521
+ /** @param {HubPhysical} destination - Frame destination. @param {Buffer} bytes - Encoded frame. */
522
+ function forwardOutput(destination, bytes) {
523
+ void enqueueOutput(destination, bytes)
524
+ }
525
+
526
+ /** @param {HubPhysical} destination - Frame destination. @param {number} type - Frame type. @param {number} streamId - Destination stream. @param {number} sequence - Frame sequence. @param {Record<string, unknown>} value - Control body. */
527
+ function forwardControl(destination, type, streamId, sequence, value) {
528
+ forwardOutput(destination, encodeV3Frame({type, streamId, sequence}, encodeV3Control(value)))
529
+ }
530
+
461
531
  /** @param {Bridge} bridge - Stream. @param {string} code - Reset code. @param {HubPhysical} [excluded] - Already-dead peer. */
462
532
  function closeBridge(bridge, code, excluded) {
463
533
  if (bridge.client !== excluded && !bridge.client.socket.destroyed) void sendControl(bridge.client, V3FrameType.RESET, bridge.clientStreamId, 0, {code})
@@ -471,7 +541,7 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
471
541
  physical.closing = true
472
542
  const forceTimer = setTimeout(() => physical.socket.destroy(), 1_000)
473
543
  forceTimer.unref?.()
474
- void sendControl(physical, V3FrameType.GOAWAY, 0, 0, {code}).finally(() => {
544
+ void sendControl(physical, V3FrameType.GOAWAY, 0, 0, {code}, true).finally(() => {
475
545
  clearTimeout(forceTimer)
476
546
  physical.socket.destroy()
477
547
  })
@@ -481,6 +551,7 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
481
551
  function detachPhysical(physical, code) {
482
552
  if (!physicals.delete(physical)) return
483
553
  physical.closing = true
554
+ discardQueuedOutput(physical)
484
555
  for (const bridge of [...physical.streams.values()]) closeBridge(bridge, physical.role === "gateway" ? "GATEWAY_DISCONNECTED" : code, physical)
485
556
  if (physical.gatewayId !== undefined) {
486
557
  const owner = gateways.get(physical.gatewayId)
@@ -497,9 +568,64 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
497
568
  }
498
569
  }
499
570
 
500
- /** @param {HubPhysical} physical - Destination. @param {number} type - Frame type. @param {number} streamId - Stream ID. @param {number} sequence - Sequence. @param {Record<string, unknown>} value - Control body. */
501
- async function sendControl(physical, type, streamId, sequence, value) {
502
- return await writeSocket(physical.socket, encodeV3Frame({type, streamId, sequence}, encodeV3Control(value)))
571
+ /** @param {HubPhysical} physical - Destination. @param {Buffer} bytes - Encoded frame. @param {boolean} [allowClosing] - Allow a final GOAWAY after closing starts. @returns {Promise<boolean>} Delivery result. */
572
+ function enqueueOutput(physical, bytes, allowClosing = false) {
573
+ if (physical.socket.destroyed || (physical.closing && !allowClosing)) return Promise.resolve(false)
574
+ if (physical.outputBytes + bytes.length > maxOutputBytes) {
575
+ abortOutput(physical)
576
+ return Promise.resolve(false)
577
+ }
578
+ physical.outputBytes += bytes.length
579
+ const queued = new Promise((resolve) => physical.outputQueue.push({bytes, resolve}))
580
+ void drainOutput(physical)
581
+ return queued
582
+ }
583
+
584
+ /** @param {HubPhysical} physical - Destination output owner. */
585
+ async function drainOutput(physical) {
586
+ if (physical.outputWriting) return
587
+ physical.outputWriting = true
588
+ try {
589
+ while (physical.outputQueue.length > 0) {
590
+ const item = /** @type {HubOutputItem} */ (physical.outputQueue.shift())
591
+ let delivered = false
592
+ try {
593
+ delivered = await writeSocket(physical.socket, item.bytes)
594
+ } catch (error) {
595
+ physical.socket.destroy(asError(error))
596
+ }
597
+ physical.outputBytes = Math.max(0, physical.outputBytes - item.bytes.length)
598
+ item.resolve(delivered)
599
+ if (!delivered) {
600
+ discardQueuedOutput(physical)
601
+ abortOutput(physical, "DESTINATION_CLOSED")
602
+ break
603
+ }
604
+ }
605
+ } finally {
606
+ physical.outputWriting = false
607
+ if (physical.outputQueue.length > 0) void drainOutput(physical)
608
+ }
609
+ }
610
+
611
+ /** @param {HubPhysical} physical - Destination whose pending output is no longer deliverable. */
612
+ function discardQueuedOutput(physical) {
613
+ for (const item of physical.outputQueue.splice(0)) {
614
+ physical.outputBytes = Math.max(0, physical.outputBytes - item.bytes.length)
615
+ item.resolve(false)
616
+ }
617
+ }
618
+
619
+ /** @param {HubPhysical} physical - Destination whose output transport failed. @param {string} [code] - Terminal code. */
620
+ function abortOutput(physical, code = "OUTPUT_BACKPRESSURE_LIMIT") {
621
+ physical.closing = true
622
+ discardQueuedOutput(physical)
623
+ if (!physical.socket.destroyed) physical.socket.destroy(protocolError(code))
624
+ }
625
+
626
+ /** @param {HubPhysical} physical - Destination. @param {number} type - Frame type. @param {number} streamId - Stream ID. @param {number} sequence - Sequence. @param {Record<string, unknown>} value - Control body. @param {boolean} [allowClosing] - Allow a final GOAWAY. @returns {Promise<boolean>} Delivery result. */
627
+ function sendControl(physical, type, streamId, sequence, value, allowClosing = false) {
628
+ return enqueueOutput(physical, encodeV3Frame({type, streamId, sequence}, encodeV3Control(value)), allowClosing)
503
629
  }
504
630
 
505
631
  await new Promise((resolve, reject) => {
@@ -517,6 +643,8 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
517
643
  physicalConnections: tcpSockets.size,
518
644
  gatewayPhysicalConnections: [...physicals].filter((physical) => physical.role === "gateway" && physical.gatewayId !== undefined).length,
519
645
  clientPhysicalConnections: [...physicals].filter((physical) => physical.role === "client" && physical.authenticated).length,
646
+ routeClassClientPhysicalConnections: [...physicals].filter((physical) => physical.role === "client" && physical.routeClass !== undefined).length,
647
+ queuedOutputBytes: [...physicals].reduce((total, physical) => total + physical.outputBytes, 0),
520
648
  retainedClientIdentities: clients.size,
521
649
  registeredGateways: gateways.size,
522
650
  healthyGateways: [...gateways.values()].filter((owner) => owner.physical !== undefined).length,
@@ -557,6 +685,9 @@ function validateOptions(options) {
557
685
  if (typeof options.tls !== "object" || options.tls === null || typeof options.tls.certFile !== "string" || typeof options.tls.keyFile !== "string") throw new TypeError("Reverse gateway hub requires TLS certFile and keyFile")
558
686
  if (!zstdAvailable()) throw new Error("Reverse gateway hub requires Node.js zstd support")
559
687
  if (options.knownGatewayIds !== undefined && (!Array.isArray(options.knownGatewayIds) || options.knownGatewayIds.some((gatewayId) => typeof gatewayId !== "string" || !GATEWAY_ID.test(gatewayId)) || new Set(options.knownGatewayIds).size !== options.knownGatewayIds.length)) throw new TypeError("knownGatewayIds must contain unique valid gateway IDs")
688
+ const routeClassPolicyConfigured = options.knownRouteClasses !== undefined || options.authorizeClientRouteClass !== undefined || options.resolveOpenGateways !== undefined
689
+ if (routeClassPolicyConfigured && (!Array.isArray(options.knownRouteClasses) || options.knownRouteClasses.length === 0 || typeof options.authorizeClientRouteClass !== "function" || typeof options.resolveOpenGateways !== "function")) throw new TypeError("Route-class hub policy requires knownRouteClasses, authorizeClientRouteClass, and resolveOpenGateways")
690
+ if (options.knownRouteClasses !== undefined && (options.knownRouteClasses.some((routeClass) => typeof routeClass !== "string" || !ROUTE_CLASS.test(routeClass)) || new Set(options.knownRouteClasses).size !== options.knownRouteClasses.length)) throw new TypeError("knownRouteClasses must contain unique valid route classes")
560
691
  const frame = positiveInteger(options.maxFrameBytes, "maxFrameBytes", DEFAULT_FRAME_BYTES)
561
692
  const window = positiveInteger(options.initialWindowBytes, "initialWindowBytes", DEFAULT_WINDOW)
562
693
  if (frame > window) throw new RangeError("maxFrameBytes must not exceed initialWindowBytes")
@@ -9,13 +9,28 @@ export async function startReverseHubRuntime(config) {
9
9
  if (config.relayTls === undefined) throw new Error("Reverse hub mode requires SOCKETDUCT_TLS_CERT_FILE and SOCKETDUCT_TLS_KEY_FILE")
10
10
  if (config.authTokens.length === 0) throw new Error("SOCKETDUCT_AUTH_TOKENS must contain hub client and gateway credentials")
11
11
  if (config.reverseGatewayBindings === undefined || config.reverseGatewayBindings.length === 0) throw new Error("SOCKETDUCT_REVERSE_GATEWAY_BINDINGS must bind at least one gateway ID to a credential username")
12
- if (config.reverseRoutes === undefined || config.reverseRoutes.length === 0) throw new Error("SOCKETDUCT_REVERSE_ROUTES must allow at least one client/gateway/target route")
12
+ const exactRoutes = config.reverseRoutes ?? []
13
+ const routeClassRoutes = config.reverseRouteClassRoutes ?? []
14
+ if (exactRoutes.length === 0 && routeClassRoutes.length === 0) throw new Error("SOCKETDUCT_REVERSE_ROUTES or SOCKETDUCT_REVERSE_ROUTE_CLASS_ROUTES must allow at least one client route")
13
15
  if (config.compression === "off") throw new Error("Reverse hub mode requires SOCKETDUCT_COMPRESSION=auto")
14
16
  const bindings = new Map(config.reverseGatewayBindings.map((binding) => [binding.gatewayId, binding.username]))
15
- const routes = new Set(config.reverseRoutes.map((route) => formatRoute(route.username, route.gatewayId, {host: route.host, port: route.port})))
16
- const selections = new Set(config.reverseRoutes.map((route) => `${route.username}\0${route.gatewayId}`))
17
+ const authorizedRoutes = new Set([...exactRoutes, ...routeClassRoutes].map((route) => formatRoute(route.username, route.gatewayId, {host: route.host, port: route.port})))
18
+ const exactRouteKeys = new Set(exactRoutes.map((route) => formatRoute(route.username, route.gatewayId, {host: route.host, port: route.port})))
19
+ const exactSelections = new Set(exactRoutes.map((route) => `${route.username}\0${route.gatewayId}`))
20
+ const routeClassSelections = new Set(routeClassRoutes.map((route) => `${route.username}\0${route.routeClass}`))
21
+ const knownRouteClasses = [...new Set(routeClassRoutes.map((route) => route.routeClass))]
22
+ /** @type {Map<string, string[]>} */
23
+ const routeClassGateways = new Map()
24
+ for (const route of routeClassRoutes) {
25
+ const key = formatRouteClassRoute(route.username, route.routeClass, {host: route.host, port: route.port})
26
+ const gatewayIds = routeClassGateways.get(key) ?? []
27
+ gatewayIds.push(route.gatewayId)
28
+ routeClassGateways.set(key, gatewayIds)
29
+ }
30
+ const routeClassRows = new Set(routeClassRoutes.map((route) => `${formatRouteClassRoute(route.username, route.routeClass, {host: route.host, port: route.port})}\0${route.gatewayId}`))
17
31
  if (bindings.size !== config.reverseGatewayBindings.length) throw new Error("SOCKETDUCT_REVERSE_GATEWAY_BINDINGS contains a duplicate gateway ID")
18
- if (routes.size !== config.reverseRoutes.length) throw new Error("SOCKETDUCT_REVERSE_ROUTES contains a duplicate route")
32
+ if (exactRouteKeys.size !== exactRoutes.length) throw new Error("SOCKETDUCT_REVERSE_ROUTES contains a duplicate route")
33
+ if (routeClassRows.size !== routeClassRoutes.length) throw new Error("SOCKETDUCT_REVERSE_ROUTE_CLASS_ROUTES contains a duplicate route")
19
34
  const relayTls = config.relayTls
20
35
  const tls = {
21
36
  certFile: relayTls.certFile,
@@ -28,9 +43,14 @@ export async function startReverseHubRuntime(config) {
28
43
  tls,
29
44
  tokens: new StaticTokenStore(config.authTokens),
30
45
  knownGatewayIds: [...bindings.keys()],
46
+ ...(knownRouteClasses.length === 0 ? {} : {knownRouteClasses}),
31
47
  authorizeGateway: ({principal, gatewayId}) => bindings.get(gatewayId) === principal.username,
32
- authorizeClientGateway: ({principal, gatewayId}) => selections.has(`${principal.username}\0${gatewayId}`),
33
- authorizeOpen: ({principal, gatewayId, target}) => routes.has(formatRoute(principal.username, gatewayId, target)),
48
+ authorizeClientGateway: ({principal, gatewayId}) => exactSelections.has(`${principal.username}\0${gatewayId}`),
49
+ ...(knownRouteClasses.length === 0 ? {} : {
50
+ authorizeClientRouteClass: ({principal, routeClass}) => routeClassSelections.has(`${principal.username}\0${routeClass}`),
51
+ resolveOpenGateways: ({principal, routeClass, target}) => [...(routeClassGateways.get(formatRouteClassRoute(principal.username, routeClass, target)) ?? [])]
52
+ }),
53
+ authorizeOpen: ({principal, gatewayId, target}) => authorizedRoutes.has(formatRoute(principal.username, gatewayId, target)),
34
54
  handshakeTimeoutMs: config.handshakeTimeoutMs,
35
55
  maxInputBytesPerConnection: config.maxInputBytesPerConnection,
36
56
  compressionLimits: compressionLimits(config)
@@ -68,6 +88,11 @@ function formatRoute(username, gatewayId, target) {
68
88
  return `${username}\0${gatewayId}\0${target.host}\0${target.port}`
69
89
  }
70
90
 
91
+ /** @param {string} username - Client. @param {string} routeClass - Route class. @param {{host: string, port: number}} target - Target. */
92
+ function formatRouteClassRoute(username, routeClass, target) {
93
+ return `${username}\0${routeClass}\0${target.host}\0${target.port}`
94
+ }
95
+
71
96
  /** @param {import('../config/environment.js').RelayEnvironmentConfig} config - Runtime limits. @returns {import('../protocol/compression.js').CompressionLimits} Defined codec limits. */
72
97
  function compressionLimits(config) {
73
98
  return {
@@ -6,6 +6,7 @@ import {createServer} from "node:net"
6
6
  import {tmpdir} from "node:os"
7
7
  import {join} from "node:path"
8
8
  import test from "node:test"
9
+ import {connect as connectTls} from "node:tls"
9
10
 
10
11
  import {ReverseGatewayConnection} from "../../src/reverse/client.js"
11
12
  import {generateTestCertificates} from "../helpers/tls-certificates.js"
@@ -283,3 +284,95 @@ test("spawned reverse hub and outbound gateway expose an allowlisted Docker stre
283
284
  assert(gatewayEntries.some(({event}) => event === "transport.connected"))
284
285
  assert.doesNotMatch(gatewayContents, /process-gateway-token|127\.0\.0\.1|docker-server/)
285
286
  })
287
+
288
+ test("one route-class client physical connection interleaves streams through different gateway processes", async (t) => {
289
+ const root = await mkdtemp(join(tmpdir(), "socketduct-route-class-process-"))
290
+ t.after(() => rm(root, {recursive: true, force: true}))
291
+ const certificates = generateTestCertificates(root)
292
+ const targetSockets = new Set()
293
+ const targetA = createServer((socket) => {
294
+ targetSockets.add(socket)
295
+ socket.once("close", () => targetSockets.delete(socket))
296
+ socket.on("data", (chunk) => socket.write(Buffer.concat([Buffer.from("a:"), chunk])))
297
+ })
298
+ const targetB = createServer((socket) => {
299
+ targetSockets.add(socket)
300
+ socket.once("close", () => targetSockets.delete(socket))
301
+ socket.on("data", (chunk) => socket.write(Buffer.concat([Buffer.from("b:"), chunk])))
302
+ })
303
+ const targetAPort = await listen(targetA)
304
+ const targetBPort = await listen(targetB)
305
+ t.after(() => {
306
+ for (const socket of targetSockets) socket.destroy()
307
+ return Promise.all([
308
+ new Promise((resolve) => targetA.close(() => resolve(undefined))),
309
+ new Promise((resolve) => targetB.close(() => resolve(undefined)))
310
+ ])
311
+ })
312
+
313
+ const {child: hub, port: hubPort} = await startProcessOnReservedPort({
314
+ SOCKETDUCT_MODE: "reverse-hub",
315
+ SOCKETDUCT_RELAY_HOST: "127.0.0.1",
316
+ SOCKETDUCT_TLS_CERT_FILE: certificates.serverCertFile,
317
+ SOCKETDUCT_TLS_KEY_FILE: certificates.serverKeyFile,
318
+ SOCKETDUCT_AUTH_TOKENS: "home-a:gateway-a-token,home-b:gateway-b-token,tensorbuzz:client-token",
319
+ SOCKETDUCT_REVERSE_GATEWAY_BINDINGS: "server-a-starlink:home-a,server-b-starlink:home-b",
320
+ SOCKETDUCT_REVERSE_ROUTE_CLASS_ROUTES: `tensorbuzz:starlink:server-a-starlink:127.0.0.1:${targetAPort},tensorbuzz:starlink:server-b-starlink:127.0.0.1:${targetBPort}`,
321
+ SOCKETDUCT_STATUS_INTERVAL_MS: "0"
322
+ }, "reverse-hub.started")
323
+ t.after(() => stopProcess(hub))
324
+
325
+ const gatewayA = await startProcess({
326
+ SOCKETDUCT_MODE: "reverse-gateway",
327
+ SOCKETDUCT_REVERSE_GATEWAY_ID: "server-a-starlink",
328
+ SOCKETDUCT_REVERSE_CONNECTION_ID: "route_class_process_gateway_a",
329
+ SOCKETDUCT_REVERSE_HUB_HOST: "127.0.0.1",
330
+ SOCKETDUCT_REVERSE_HUB_PORT: String(hubPort),
331
+ SOCKETDUCT_REVERSE_TOKEN: "gateway-a-token",
332
+ SOCKETDUCT_REVERSE_HUB_CA_FILE: certificates.caCertFile,
333
+ SOCKETDUCT_REVERSE_HUB_SERVERNAME: "localhost",
334
+ SOCKETDUCT_TARGET_ALLOWLIST: `127.0.0.1:${targetAPort}`,
335
+ SOCKETDUCT_STATUS_INTERVAL_MS: "0"
336
+ }, "reverse-gateway.started")
337
+ t.after(() => stopProcess(gatewayA))
338
+ const gatewayB = await startProcess({
339
+ SOCKETDUCT_MODE: "reverse-gateway",
340
+ SOCKETDUCT_REVERSE_GATEWAY_ID: "server-b-starlink",
341
+ SOCKETDUCT_REVERSE_CONNECTION_ID: "route_class_process_gateway_b",
342
+ SOCKETDUCT_REVERSE_HUB_HOST: "127.0.0.1",
343
+ SOCKETDUCT_REVERSE_HUB_PORT: String(hubPort),
344
+ SOCKETDUCT_REVERSE_TOKEN: "gateway-b-token",
345
+ SOCKETDUCT_REVERSE_HUB_CA_FILE: certificates.caCertFile,
346
+ SOCKETDUCT_REVERSE_HUB_SERVERNAME: "localhost",
347
+ SOCKETDUCT_TARGET_ALLOWLIST: `127.0.0.1:${targetBPort}`,
348
+ SOCKETDUCT_STATUS_INTERVAL_MS: "0"
349
+ }, "reverse-gateway.started")
350
+ t.after(() => stopProcess(gatewayB))
351
+
352
+ let clientPhysicalConnections = 0
353
+ const client = new ReverseGatewayConnection({
354
+ routeClass: "starlink",
355
+ connectionId: "route_class_process_client",
356
+ hub: {host: "127.0.0.1", port: hubPort, token: "client-token"},
357
+ tls: {caFile: certificates.caCertFile, servername: "localhost"},
358
+ createPhysicalSocket: ({host, port, tls}) => {
359
+ clientPhysicalConnections += 1
360
+ return connectTls({host, port, ...tls})
361
+ }
362
+ })
363
+ t.after(() => client.close())
364
+ await withTimeout(client.connect(), "route-class process client connect")
365
+ const streamA = client.createConnection({host: "127.0.0.1", port: targetAPort, sessionName: "route-class-a"})
366
+ const streamB = client.createConnection({host: "127.0.0.1", port: targetBPort, sessionName: "route-class-b"})
367
+ await withTimeout(Promise.all([onceEvent(streamA, "connect"), onceEvent(streamB, "connect")]), "route-class process streams")
368
+ const responseA = new Promise((resolve, reject) => { streamA.once("error", reject); streamA.once("data", resolve) })
369
+ const responseB = new Promise((resolve, reject) => { streamB.once("error", reject); streamB.once("data", resolve) })
370
+ streamA.write("one")
371
+ streamB.write("two")
372
+ assert.equal(Buffer.from(/** @type {Uint8Array} */ (await withTimeout(responseA, "route-class response A"))).toString(), "a:one")
373
+ assert.equal(Buffer.from(/** @type {Uint8Array} */ (await withTimeout(responseB, "route-class response B"))).toString(), "b:two")
374
+ assert.equal(clientPhysicalConnections, 1)
375
+ assert.equal(client.status().physicalConnects, 1)
376
+ streamA.destroy()
377
+ streamB.destroy()
378
+ })