socketduct 0.1.24 → 0.1.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "socketduct",
3
- "version": "0.1.24",
3
+ "version": "0.1.25",
4
4
  "description": "Resilient authenticated tunnel/proxy for keeping socket streams alive across flaky links.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -44,6 +44,7 @@ const GATEWAY_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{1,63}$/
44
44
  * @property {import('../logging/structured-logger.js').DiagnosticLogger} [logger] - Optional privacy-safe structured logger shared by the set and its members.
45
45
  * @typedef {object} MemberConnectionStatus
46
46
  * @property {string} physicalState - Member lifecycle.
47
+ * @property {boolean} [gatewayUnavailable] - Hub recently reported no selectable gateway for this member's route class.
47
48
  * @property {number} connectionGeneration - Current authenticated generation.
48
49
  * @property {number} physicalConnects - Authenticated physical generations.
49
50
  * @property {number} reconnectAttempts - Physical reconnect attempts.
@@ -569,17 +570,34 @@ export class MultiplexedSocketductTransportSet extends EventEmitter {
569
570
  return this.#closePromise
570
571
  }
571
572
 
572
- /** @returns {{member: TransportMember, previousCursor: number} | undefined} */
573
+ /**
574
+ * Selects the next ready member, preferring members whose gateway the hub has
575
+ * not recently reported unavailable. When every ready member carries a
576
+ * gateway-unavailable mark, any ready member may still be selected so a stale
577
+ * mark can never wedge the set.
578
+ * @returns {{member: TransportMember, previousCursor: number} | undefined}
579
+ */
573
580
  #selectReadyMember() {
574
581
  if (this.#closed) throw closedError()
575
582
  const previousCursor = this.#cursor
583
+ /** @type {{member: TransportMember, index: number} | undefined} */
584
+ let fallback
576
585
  for (let offset = 0; offset < this.#members.length; offset += 1) {
577
586
  const index = (this.#cursor + offset) % this.#members.length
578
587
  const member = /** @type {TransportMember} */ (this.#members[index])
579
- if (member.connection.status().physicalState !== "ready") continue
588
+ const status = member.connection.status()
589
+ if (status.physicalState !== "ready") continue
590
+ if (status.gatewayUnavailable === true) {
591
+ fallback ??= {member, index}
592
+ continue
593
+ }
580
594
  this.#cursor = (index + 1) % this.#members.length
581
595
  return {member, previousCursor}
582
596
  }
597
+ if (fallback !== undefined) {
598
+ this.#cursor = (fallback.index + 1) % this.#members.length
599
+ return {member: fallback.member, previousCursor}
600
+ }
583
601
  return undefined
584
602
  }
585
603
 
@@ -32,6 +32,7 @@ import {attachDisconnectAttribution, createDisconnectAttribution, parseDisconnec
32
32
  const DEFAULT_STREAM_BUFFER_BYTES = 4 * 1024 * 1024
33
33
  const DEFAULT_CONNECTION_BUFFER_BYTES = 32 * 1024 * 1024
34
34
  const MANIFEST_PAGE_STREAMS = 64
35
+ const GATEWAY_UNAVAILABLE_TTL_MS = 5_000
35
36
 
36
37
  /**
37
38
  * @typedef {object} ReverseGatewayConnectionOptions
@@ -168,6 +169,7 @@ export class ReverseGatewayConnection extends EventEmitter {
168
169
  /** @type {number} */ #spoolBytes = 0
169
170
  /** @type {number} */ #spoolCleanupFailures = 0
170
171
  /** @type {Set<Promise<void>>} */ #spoolCleanupTasks = new Set()
172
+ /** @type {number | undefined} - Wall-clock time of the last hub-reported gateway unavailability. */ #gatewayUnavailableSince
171
173
  /** @type {import('./disconnect-attribution.js').DisconnectAttribution | undefined} */ #disconnectAttribution
172
174
 
173
175
  /** @param {ReverseGatewayConnectionOptions} options - Hub route and safety policy. */
@@ -352,12 +354,41 @@ export class ReverseGatewayConnection extends EventEmitter {
352
354
  return socket
353
355
  }
354
356
 
357
+ /**
358
+ * Records that the hub just reported no selectable gateway for this member's
359
+ * route class, so the transport set can prefer members that can actually open.
360
+ * @param {string} code - Hub reset code.
361
+ * @returns {void}
362
+ */
363
+ #markGatewayUnavailable(code) {
364
+ const firstMark = this.#gatewayUnavailableSince === undefined
365
+ this.#gatewayUnavailableSince = Date.now()
366
+ if (firstMark) {
367
+ logDiagnostic(this.#options.logger, "reverse_client.gateway_unavailable", {
368
+ code,
369
+ generation: this.#generation,
370
+ ...this.#routeIdentityFields()
371
+ })
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Whether the last hub-reported gateway state for this member was unavailable
377
+ * and the report is still fresh. A stale report never pins the member out.
378
+ * @returns {boolean} True while a fresh hub unavailability report is active.
379
+ */
380
+ #gatewayUnavailable() {
381
+ return this.#gatewayUnavailableSince !== undefined
382
+ && Date.now() - this.#gatewayUnavailableSince < GATEWAY_UNAVAILABLE_TTL_MS
383
+ }
384
+
355
385
  status() {
356
386
  /** @type {Record<string, number>} */
357
387
  const streamStates = {}
358
388
  for (const stream of this.#streams.values()) streamStates[stream.state] = (streamStates[stream.state] ?? 0) + 1
359
389
  return {
360
390
  physicalState: this.#state,
391
+ gatewayUnavailable: this.#gatewayUnavailable(),
361
392
  connectionGeneration: this.#generation,
362
393
  virtualStreams: this.#streams.size,
363
394
  streamStates,
@@ -623,6 +654,8 @@ export class ReverseGatewayConnection extends EventEmitter {
623
654
  const selection = this.#routeIdentityFields()
624
655
  if (detail.generation !== generation || ("gatewayId" in selection ? detail.gatewayId !== selection.gatewayId || Object.hasOwn(detail, "routeClass") : detail.routeClass !== selection.routeClass || Object.hasOwn(detail, "gatewayId"))) throw fatalError("GATEWAY_SELECTION_FAILED")
625
656
  if (typeof detail.resumed !== "boolean") throw fatalError("GATEWAY_SELECTION_FAILED")
657
+ if (detail.selectable === true) this.#gatewayUnavailableSince = undefined
658
+ else if (detail.selectable === false) this.#markGatewayUnavailable("GATEWAY_SELECT_UNAVAILABLE")
626
659
  selected = true
627
660
  if (detail.resumed) {
628
661
  clearTimeout(timer)
@@ -745,9 +778,16 @@ export class ReverseGatewayConnection extends EventEmitter {
745
778
  const detail = decodeV3Control(frame.payload)
746
779
  if (stream.state !== "opening" || frame.sequence !== 0 || detail.identifier !== stream.identifier || !validPeerWindow(detail.initialWindow)) throw new Error("Invalid reverse OPENED response")
747
780
  stream.sendWindowLimit = Math.min(stream.sendWindowLimit, detail.initialWindow)
748
- stream.sendWindow = Math.min(stream.sendWindow, stream.sendWindowLimit)
781
+ stream.sendWindow = Math.min(stream.sendWindow, detail.initialWindow)
749
782
  stream.state = stream.localEnded ? "half-closed" : "open"
750
783
  stream.opened = true
784
+ if (this.#gatewayUnavailableSince !== undefined) {
785
+ this.#gatewayUnavailableSince = undefined
786
+ logDiagnostic(this.#options.logger, "reverse_client.gateway_available", {
787
+ generation: this.#generation,
788
+ ...this.#routeIdentityFields()
789
+ })
790
+ }
751
791
  logDiagnostic(this.#options.logger, "virtual_stream.opened", {
752
792
  streamId: stream.id,
753
793
  generation: this.#generation,
@@ -801,6 +841,9 @@ export class ReverseGatewayConnection extends EventEmitter {
801
841
  const detail = decodeV3Control(frame.payload)
802
842
  const code = typeof detail.code === "string" ? detail.code : "STREAM_RESET"
803
843
  stream.state = "reset"
844
+ if (code === "GATEWAY_UNAVAILABLE" || code === "GATEWAY_DISCONNECTED" || code === "GATEWAY_REPLACED") {
845
+ this.#markGatewayUnavailable(code)
846
+ }
804
847
  const error = attachDisconnectAttribution(new SocketductStreamError("Reverse gateway stream was reset", code), validHubAttribution(detail) ?? (code === "GATEWAY_DISCONNECTED" ? this.#disconnectAttribution : undefined))
805
848
  stream.socket._receiveError(error)
806
849
  return
@@ -49,6 +49,7 @@ const MANIFEST_PAGE_STREAMS = 64
49
49
  * @property {(input: {principal: Principal, gatewayId: string}) => boolean} authorizeGateway - Stable gateway identity binding.
50
50
  * @property {(input: {principal: Principal, gatewayId: string}) => boolean} authorizeClientGateway - Client-to-gateway selection policy.
51
51
  * @property {(input: {principal: Principal, routeClass: string}) => boolean} [authorizeClientRouteClass] - Client-to-route-class selection policy.
52
+ * @property {(input: {principal: Principal, routeClass: string}) => string[]} [resolveRouteClassGateways] - All configured gateways for a client route class, used to announce route-class availability at selection time.
52
53
  * @property {(input: {principal: Principal, gatewayId: string, target: Target}) => boolean} authorizeOpen - Hub-side route policy.
53
54
  * @property {(input: {principal: Principal, routeClass: string, target: Target}) => string[]} [resolveOpenGateways] - Eligible exact gateways for a route-class OPEN.
54
55
  * @property {string[]} [knownGatewayIds] - Configured IDs that may be temporarily unavailable.
@@ -480,7 +481,15 @@ export async function startReverseGatewayHub(/** @type {ReverseGatewayHubOptions
480
481
  if (clientOwner.gatewayId !== undefined || (clientOwner.routeClass !== undefined && clientOwner.routeClass !== request.routeClass)) throw protocolError("GATEWAY_SELECTION_MISMATCH")
481
482
  physical.routeClass = request.routeClass
482
483
  clientOwner.routeClass = request.routeClass
483
- await sendControl(physical, V3FrameType.GATEWAY_SELECTED, 0, physical.generation, {routeClass: request.routeClass, generation: physical.generation, resumed: physical.resumeRequired})
484
+ const configuredGatewayIds = options.resolveRouteClassGateways?.({principal: physical.principal, routeClass: request.routeClass})
485
+ let selectable
486
+ if (Array.isArray(configuredGatewayIds) && configuredGatewayIds.length > 0) {
487
+ selectable = configuredGatewayIds.some((gatewayId) => {
488
+ const owner = gateways.get(gatewayId)
489
+ return owner !== undefined && owner.physical !== undefined && !owner.physical.socket.destroyed && !owner.physical.closing && !owner.physical.resumeRequired
490
+ })
491
+ }
492
+ await sendControl(physical, V3FrameType.GATEWAY_SELECTED, 0, physical.generation, {routeClass: request.routeClass, generation: physical.generation, resumed: physical.resumeRequired, ...(selectable === undefined ? {} : {selectable})})
484
493
  armResumeTimeout(physical)
485
494
  }
486
495
 
@@ -27,7 +27,15 @@ export async function startReverseHubRuntime(config, logger) {
27
27
  gatewayIds.push(route.gatewayId)
28
28
  routeClassGateways.set(key, gatewayIds)
29
29
  }
30
- const routeClassRows = new Set(routeClassRoutes.map((route) => `${formatRouteClassRoute(route.username, route.routeClass, {host: route.host, port: route.port})}\0${route.gatewayId}`))
30
+ const routeClassRows = new Set(routeClassRoutes.map((route) => `${formatRouteClassRoute(route.username, route.routeClass, {host: route.host, port: route.port})}\\0${route.gatewayId}`))
31
+ /** @type {Map<string, string[]>} - Every configured gateway for a (username, routeClass) pair, independent of target. */
32
+ const routeClassGatewayUnions = new Map()
33
+ for (const route of routeClassRoutes) {
34
+ const key = `${route.username}\\0${route.routeClass}`
35
+ const gatewayIds = routeClassGatewayUnions.get(key) ?? []
36
+ if (!gatewayIds.includes(route.gatewayId)) gatewayIds.push(route.gatewayId)
37
+ routeClassGatewayUnions.set(key, gatewayIds)
38
+ }
31
39
  if (bindings.size !== config.reverseGatewayBindings.length) throw new Error("SOCKETDUCT_REVERSE_GATEWAY_BINDINGS contains a duplicate gateway ID")
32
40
  if (exactRouteKeys.size !== exactRoutes.length) throw new Error("SOCKETDUCT_REVERSE_ROUTES contains a duplicate route")
33
41
  if (routeClassRows.size !== routeClassRoutes.length) throw new Error("SOCKETDUCT_REVERSE_ROUTE_CLASS_ROUTES contains a duplicate route")
@@ -47,7 +55,8 @@ export async function startReverseHubRuntime(config, logger) {
47
55
  authorizeGateway: ({principal, gatewayId}) => bindings.get(gatewayId) === principal.username,
48
56
  authorizeClientGateway: ({principal, gatewayId}) => exactSelections.has(`${principal.username}\0${gatewayId}`),
49
57
  ...(knownRouteClasses.length === 0 ? {} : {
50
- authorizeClientRouteClass: ({principal, routeClass}) => routeClassSelections.has(`${principal.username}\0${routeClass}`),
58
+ authorizeClientRouteClass: ({principal, routeClass}) => routeClassSelections.has(`${principal.username}\u0000${routeClass}`),
59
+ resolveRouteClassGateways: ({principal, routeClass}) => routeClassGatewayUnions.get(`${principal.username}\u0000${routeClass}`) ?? [],
51
60
  resolveOpenGateways: ({principal, routeClass, target}) => [...(routeClassGateways.get(formatRouteClassRoute(principal.username, routeClass, target)) ?? [])]
52
61
  }),
53
62
  authorizeOpen: ({principal, gatewayId, target}) => authorizedRoutes.has(formatRoute(principal.username, gatewayId, target)),
@@ -380,3 +380,71 @@ test("a backpressured gateway does not block DATA or a new OPEN for another rout
380
380
  streamA.destroy()
381
381
  streamB.destroy()
382
382
  })
383
+
384
+ test("hub announces route-class availability at selection and the client exposes gateway unavailability", async (t) => {
385
+ const root = await mkdtemp(join(tmpdir(), "socketduct-route-class-selectable-"))
386
+ t.after(() => rm(root, {recursive: true, force: true}))
387
+ const certificates = generateTestCertificates(root)
388
+ const targetA = await createEchoTarget("a:")
389
+ t.after(() => targetA.close())
390
+
391
+ const hub = await startReverseGatewayHub({
392
+ tokens: new StaticTokenStore([
393
+ {username: "home-a", token: "gateway-a-token"},
394
+ {username: "tensorbuzz", token: "client-token"}
395
+ ]),
396
+ tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile},
397
+ knownGatewayIds: ["server-a-starlink"],
398
+ knownRouteClasses: ["starlink"],
399
+ authorizeGateway: ({principal, gatewayId}) => principal.username === "home-a" && gatewayId === "server-a-starlink",
400
+ authorizeClientGateway: () => false,
401
+ authorizeClientRouteClass: ({principal, routeClass}) => principal.username === "tensorbuzz" && routeClass === "starlink",
402
+ resolveRouteClassGateways: () => ["server-a-starlink"],
403
+ resolveOpenGateways: ({routeClass, target}) => (routeClass === "starlink" && target.host === targetA.host ? ["server-a-starlink"] : []),
404
+ authorizeOpen: ({gatewayId, target}) => gatewayId === "server-a-starlink" && target.port === targetA.port
405
+ })
406
+ t.after(() => hub.close())
407
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
408
+
409
+ const client = new ReverseGatewayConnection({
410
+ routeClass: "starlink",
411
+ connectionId: "route_class_selectable_client",
412
+ hub: {host: "127.0.0.1", port: hub.port, token: "client-token"},
413
+ tls
414
+ })
415
+ t.after(() => client.close())
416
+
417
+ // No gateway registered for the route class yet: the hub must announce selectable=false
418
+ // and the client must surface the member as gateway-unavailable before any open is attempted.
419
+ await client.connect()
420
+ assert.equal(client.status().physicalState, "ready")
421
+ assert.equal(client.status().gatewayUnavailable, true)
422
+
423
+ // Once the gateway registers, a replacement selection announces selectable=true and clears the mark.
424
+ const gatewayA = new OutboundReverseGateway({
425
+ gatewayId: "server-a-starlink",
426
+ connectionId: "selectable_gateway_a",
427
+ hub: {host: "127.0.0.1", port: hub.port, token: "gateway-a-token"},
428
+ tls,
429
+ authorizeTarget: (target) => target.host === targetA.host && target.port === targetA.port,
430
+ reconnectDelayMs: 10,
431
+ maxReconnectDelayMs: 20
432
+ })
433
+ t.after(() => gatewayA.close())
434
+ await gatewayA.start()
435
+ await waitUntil(() => hub.status().healthyGateways === 1, "gateway A registration")
436
+
437
+ const replacement = new ReverseGatewayConnection({
438
+ routeClass: "starlink",
439
+ connectionId: "selectable_client_replacement",
440
+ hub: {host: "127.0.0.1", port: hub.port, token: "client-token"},
441
+ tls
442
+ })
443
+ t.after(() => replacement.close())
444
+ await replacement.connect()
445
+ assert.equal(replacement.status().gatewayUnavailable, false)
446
+ const stream = replacement.createConnection({host: targetA.host, port: targetA.port})
447
+ await onceEvent(stream, "connect")
448
+ assert.equal(await exchange(stream, "probe"), "a:probe")
449
+ stream.destroy()
450
+ })
@@ -71,6 +71,7 @@ class FakeMember extends EventEmitter {
71
71
  this.state = "configured"
72
72
  this.generation = 0
73
73
  this.physicalConnects = 0
74
+ this.gatewayUnavailable = false
74
75
  /** @type {Promise<void>} */
75
76
  this.connectPromise = new Promise((resolve, reject) => {
76
77
  this.resolveConnect = resolve
@@ -135,6 +136,7 @@ class FakeMember extends EventEmitter {
135
136
  status() {
136
137
  return {
137
138
  physicalState: this.state,
139
+ gatewayUnavailable: this.gatewayUnavailable,
138
140
  connectionGeneration: this.generation,
139
141
  physicalConnects: this.physicalConnects,
140
142
  reconnectAttempts: this.state === "reconnecting" ? 1 : 0,
@@ -524,6 +526,35 @@ test("25 and 100 continuously healthy opens have exact deterministic distributio
524
526
  }
525
527
  })
526
528
 
529
+ test("opens skip a ready member whose gateway the hub reported unavailable", async () => {
530
+ const {set, members} = fixture()
531
+ const started = set.start()
532
+ members.forEach((member) => member.ready())
533
+ await started
534
+ at(members, 0).gatewayUnavailable = true
535
+ for (let offset = 0; offset < 12; offset += 1) {
536
+ const socket = set.createConnection({host: "target.internal", port: 443})
537
+ assert.equal(gatewayOf(socket) === "starlink", false)
538
+ }
539
+ assert.deepEqual(set.status().members.map((member) => member.assignments), [0, 6, 6])
540
+ at(members, 0).gatewayUnavailable = false
541
+ const recovered = set.createConnection({host: "target.internal", port: 443})
542
+ assert.equal(gatewayOf(recovered), "starlink")
543
+ await set.close()
544
+ })
545
+
546
+ test("a stale gateway-unavailable mark cannot wedge the set when every ready member is marked", async () => {
547
+ const {set, members} = fixture()
548
+ const started = set.start()
549
+ members.forEach((member) => member.ready())
550
+ await started
551
+ members.forEach((member) => { member.gatewayUnavailable = true })
552
+ const socket = set.createConnection({host: "target.internal", port: 443}, (error) => assert.equal(error, null))
553
+ assert.equal(socket.gatewayId !== undefined, true)
554
+ assert.equal(set.status().pendingOpens, 0)
555
+ await set.close()
556
+ })
557
+
527
558
  test("all-members reconnecting queues FIFO opens until one ready member recovers", async () => {
528
559
  const {set, members} = fixture()
529
560
  const started = set.start()