socketduct 0.1.24 → 0.1.26

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 (60) hide show
  1. package/AGENTS.md +2 -2
  2. package/changelog.d/20261010130509-bound-log-frame-compression-memory.md +1 -0
  3. package/docs/runbooks/jsonl-integrity-rollout.md +8 -6
  4. package/package.json +2 -2
  5. package/scripts/test.js +9 -14
  6. package/src/client/multiplexed-transport-set.js +20 -2
  7. package/src/logging/compressed-log-writer.js +1 -1
  8. package/src/logging/zstd-frame-codec.js +15 -1
  9. package/src/reverse/client.js +44 -1
  10. package/src/reverse/hub.js +10 -1
  11. package/src/reverse/runtime.js +11 -2
  12. package/test/e2e/gateway-process.test.js +93 -92
  13. package/test/e2e/logger-shutdown-process.test.js +40 -37
  14. package/test/e2e/reverse-client-recovery-process.test.js +107 -103
  15. package/test/e2e/reverse-gateway-process.test.js +328 -318
  16. package/test/integration/initial-target-recovery.test.js +333 -334
  17. package/test/integration/logging-cli.test.js +121 -119
  18. package/test/integration/logging-compressed-recovery.test.js +96 -95
  19. package/test/integration/multiplexed-compatibility.test.js +178 -177
  20. package/test/integration/multiplexed-corrections.test.js +360 -359
  21. package/test/integration/multiplexed-failures.test.js +253 -251
  22. package/test/integration/multiplexed-transport-set.test.js +334 -322
  23. package/test/integration/multiplexed-transport.test.js +135 -134
  24. package/test/integration/reverse-gateway-stream-id-reset.test.js +90 -89
  25. package/test/integration/reverse-gateway.test.js +2309 -2218
  26. package/test/integration/reverse-resume-continuity.test.js +219 -218
  27. package/test/integration/reverse-route-class.test.js +363 -278
  28. package/test/integration/socketduct-http-agent.test.js +314 -312
  29. package/test/integration/socketduct-https-agent.test.js +516 -485
  30. package/test/integration/stream-compression.test.js +862 -824
  31. package/test/integration/tcp-forwarding.test.js +2550 -2447
  32. package/test/integration/tls-forwarding.test.js +593 -592
  33. package/test/unit/auth/auth.test.js +43 -44
  34. package/test/unit/auth/static-token-store.test.js +9 -8
  35. package/test/unit/client/connect.test.js +41 -40
  36. package/test/unit/client/deferred-virtual-socket.test.js +95 -94
  37. package/test/unit/client/multiplexed-spool-budget.test.js +155 -154
  38. package/test/unit/client/multiplexed-transport-set.test.js +845 -732
  39. package/test/unit/client/socketduct-socket.test.js +768 -771
  40. package/test/unit/client/virtual-socket.test.js +198 -168
  41. package/test/unit/config/docker-gateway-files.test.js +224 -224
  42. package/test/unit/config/environment.test.js +472 -492
  43. package/test/unit/index.test.js +53 -52
  44. package/test/unit/logging/compressed-log-worker.test.js +23 -22
  45. package/test/unit/logging/compressed-log-writer.test.js +155 -154
  46. package/test/unit/logging/log-retention.test.js +52 -47
  47. package/test/unit/logging/log-segment-index.test.js +84 -81
  48. package/test/unit/logging/zstd-frame-codec.test.js +28 -19
  49. package/test/unit/protocol/compression.test.js +254 -298
  50. package/test/unit/protocol/frame.test.js +280 -276
  51. package/test/unit/protocol/v3-compression.test.js +21 -23
  52. package/test/unit/protocol/v3-frame.test.js +42 -41
  53. package/test/unit/release-configuration.test.js +15 -12
  54. package/test/unit/reverse/disconnect-attribution.test.js +65 -64
  55. package/test/unit/reverse-terminal-outcomes.test.js +53 -50
  56. package/test/unit/server/control-handler.test.js +232 -231
  57. package/test/unit/server/primary.test.js +51 -49
  58. package/test/unit/server/relay-runtime.test.js +280 -294
  59. package/test/unit/sessions/session.test.js +48 -62
  60. package/test/unit/spool/spool-file.test.js +401 -385
package/AGENTS.md CHANGED
@@ -160,9 +160,9 @@ docs/
160
160
  ## Definition of done for implementation PRs
161
161
 
162
162
  - Add a release-note fragment for every user-visible change, bug fix, security fix, or protocol change under `changelog.d/YYYYMMDDHHMMSS-short-slug.md`. Keep it to one concise, user-facing sentence; do not edit generated release notes directly.
163
- - Tests added or updated.
163
+ - Tests added or updated — test modules declare `describe`/`it`/`expect` from `@velocious/testing`; `node:test`/`node:assert` are not used in test modules.
164
164
  - Relevant docs updated.
165
- - `npm test` passes.
165
+ - `npm test` passes (every `test/**/*.test.js` module runs through the `velocious-test` runner).
166
166
  - Lint/typecheck passes once configured.
167
167
  - Security-sensitive changes mention how credentials/tokens are protected.
168
168
  - No open proxy behavior introduced.
@@ -0,0 +1 @@
1
+ Diagnostic log frames now default to zstd level 9 instead of 19: zstd context memory scales steeply with the level (~1-2 MB per compressed batch at 9 versus ~90 MB at 19), and per-batch contexts accumulated into a multi-GB resident/peak-RSS ratchet in long-lived processes that no JS-level accounting or GC pressure sees.
@@ -35,12 +35,14 @@ npm run lint
35
35
  git diff --check
36
36
  ```
37
37
 
38
- Run the two focused modules sequentially with the published `@velocious/testing`
39
- runner. `npm test` is the CI entry point: it discovers untouched legacy modules
40
- for Node's runner and then invokes the owning runner separately for each migrated
41
- logger module. `npm test -- --list` prints both selections without running tests.
42
- Do not run the full suite or shards locally during a correction pass; CI owns
43
- that coverage. Test cleanup uses the package's supported FIFO `withTestCleanup`.
38
+ Run the focused modules sequentially with the published `@velocious/testing`
39
+ runner. `npm test` is the CI entry point: it discovers every module under
40
+ `test/**/*.test.js` and runs each one through the `velocious-test` runner in
41
+ sorted order. `npm test -- --list` prints the discovered modules without running
42
+ tests, and `npm test -- <path-or-args>` forwards arguments to a focused runner
43
+ invocation. Do not run the full suite or shards locally during a correction
44
+ pass; CI owns that coverage. Test cleanup uses the package's supported FIFO
45
+ `withTestCleanup`.
44
46
 
45
47
  The process tests create and remove only their own private home subdirectories;
46
48
  the canonical `/home/dev` bind persists across child-process exits. They reuse
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "socketduct",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
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",
@@ -38,7 +38,7 @@
38
38
  "devDependencies": {
39
39
  "@eslint/js": "^10.0.1",
40
40
  "@types/node": "^24.0.0",
41
- "@velocious/testing": "0.0.10",
41
+ "@velocious/testing": "^0.0.18",
42
42
  "awaitery": "^1.0.21",
43
43
  "eslint": "^10.0.3",
44
44
  "eslint-plugin-jsdoc": "^62.7.1",
package/scripts/test.js CHANGED
@@ -1,24 +1,19 @@
1
1
  import {spawnSync} from "node:child_process"
2
2
  import {globSync} from "node:fs"
3
3
 
4
- const migrated = ["test/unit/logging/structured-logger.test.js", "test/integration/logging-recovery.test.js"]
4
+ const args = process.argv.slice(2)
5
5
  const files = globSync("test/**/*.test.js").sort()
6
- for (const path of migrated) {
7
- if (!files.includes(path)) throw new Error(`Missing migrated test module: ${path}`)
8
- }
9
- const legacy = files.filter((path) => !migrated.includes(path))
10
- if (legacy.length === 0) throw new Error("No legacy test modules discovered")
11
- const commands = [
12
- {runner: process.execPath, args: ["--test", ...legacy]},
13
- ...migrated.map((path) => ({runner: "velocious-test", args: [path]}))
14
- ]
6
+ if (files.length === 0) throw new Error("No test modules discovered")
15
7
 
16
- if (process.argv.length === 3 && process.argv[2] === "--list") {
17
- console.log(JSON.stringify({legacy, migrated, commands}, null, 2))
8
+ if (args.length === 1 && args[0] === "--list") {
9
+ console.log(JSON.stringify({files}, null, 2))
18
10
  } else {
19
- if (process.argv.length !== 2) throw new Error("Only --list is supported; use the owning runner for focused tests")
11
+ const commands = args.length === 0
12
+ ? files.map((path) => ({args: [path], label: path}))
13
+ : [{args, label: args.join(" ")}]
20
14
  for (const command of commands) {
21
- const result = spawnSync(command.runner, command.args, {stdio: "inherit"})
15
+ if (commands.length > 1) console.log(`\n=== ${command.label} ===`)
16
+ const result = spawnSync("velocious-test", command.args, {stdio: "inherit"})
22
17
  if (result.error) throw result.error
23
18
  if (result.status !== 0) {
24
19
  process.exitCode = result.status ?? 1
@@ -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
 
@@ -36,7 +36,7 @@ const NONCRITICAL_EVENTS = new Set(["runtime.status", "transport.connecting", "t
36
36
  * @property {number} [maxSegmentBytes] - Maximum compressed segment size.
37
37
  * @property {number} [maxQueueBytes] - Shared producer/worker allocation bound.
38
38
  * @property {number} [criticalReserveBytes] - Queue bytes reserved for critical events.
39
- * @property {number} [compressionLevel] - Explicit zstd level, default 19.
39
+ * @property {number} [compressionLevel] - Explicit zstd level, default 9. High levels (12+) allocate tens of MB of zstd context memory per compressed batch; see `DEFAULT_LOG_COMPRESSION_LEVEL`.
40
40
  * @property {number} [retentionDays] - Closed-segment maximum age, default 30 days.
41
41
  * @property {number} [retentionBytes] - Directory quota, default 4 GiB.
42
42
  * @property {{write(chunk: string): unknown}} [stderr] - Secret-free health reporting destination.
@@ -2,7 +2,21 @@
2
2
  import {promisify} from "node:util"
3
3
  import {constants, zstdCompress, zstdDecompress} from "node:zlib"
4
4
 
5
- export const DEFAULT_LOG_COMPRESSION_LEVEL = 19
5
+ /**
6
+ * Default zstd level for per-batch diagnostic log frames.
7
+ *
8
+ * Every batch is compressed as its own frame, and a fresh zstd context's
9
+ * memory scales steeply with the level: level 9 allocates ~1-2 MB per call,
10
+ * level 12 tens of MB, and level 19 roughly 90 MB. Long-lived processes flush
11
+ * log batches continuously, so a high level turns those per-call context
12
+ * allocations into a multi-GB resident/peak-RSS ratchet that no JS-level
13
+ * accounting sees (the memory is freed by the compressor but retained by the
14
+ * allocator while the producing worker's isolate never pressures GC). JSONL
15
+ * diagnostics already compress 50x+ at level 9, and level 19 buys only ~15%
16
+ * smaller frames at ~20x the CPU and ~50x the per-call memory, so the default
17
+ * stays in the bounded-memory range. Raise it per process only deliberately.
18
+ */
19
+ export const DEFAULT_LOG_COMPRESSION_LEVEL = 9
6
20
 
7
21
  const compress = promisify(zstdCompress)
8
22
  const decompress = promisify(zstdDecompress)
@@ -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)),
@@ -1,10 +1,9 @@
1
- import assert from "node:assert/strict"
1
+ import {describe, expect, it, withTestCleanup} from "@velocious/testing"
2
2
  import {spawn} from "node:child_process"
3
3
  import {mkdtemp, rm} from "node:fs/promises"
4
4
  import {createConnection, createServer} from "node:net"
5
5
  import {tmpdir} from "node:os"
6
6
  import {join} from "node:path"
7
- import test from "node:test"
8
7
 
9
8
  import {startTcpEdge} from "../../src/edge/tcp-edge.js"
10
9
  import {encodeFrame, FrameDecoder} from "../../src/protocol/frame.js"
@@ -159,104 +158,106 @@ async function stopProcess(child) {
159
158
  }), PROCESS_TIMEOUT_MS, "Timed out waiting for relay shutdown")
160
159
  }
161
160
 
162
- test("e2e process gateway forwards edge traffic to an allowlisted target", async () => {
163
- const tempDir = await mkdtemp(join(tmpdir(), "socketduct-e2e-spool-"))
164
- const targetServer = createServer((socket) => {
165
- socket.on("data", (chunk) => socket.write(Buffer.concat([Buffer.from("echo:"), chunk])))
166
- })
167
- const targetPort = await listen(targetServer)
168
- const relayPort = await reservePort()
169
- const relay = await startRelayProcess({
170
- SOCKETDUCT_MODE: "relay",
171
- SOCKETDUCT_RELAY_HOST: "127.0.0.1",
172
- SOCKETDUCT_RELAY_PORT: String(relayPort),
173
- SOCKETDUCT_AUTH_TOKENS: "tensorbuzz-edge:e2e-token-not-placeholder",
174
- SOCKETDUCT_TARGET_ALLOWLIST: `127.0.0.1:${targetPort}`
175
- })
176
-
177
- const edge = await startTcpEdge({
178
- relayHost: "127.0.0.1",
179
- relayPort,
180
- token: "e2e-token-not-placeholder",
181
- sessionName: "e2e process gateway",
182
- target: {host: "127.0.0.1", port: targetPort},
183
- spool: new SessionSpool({rootDir: tempDir}),
184
- reconnectDelayMs: 10
185
- })
186
-
187
- test.after(async () => {
188
- await edge.close()
189
- await stopProcess(relay.process)
190
- await closeServer(targetServer)
191
- await rm(tempDir, {recursive: true, force: true})
192
- })
193
-
194
- const appSocket = createConnection({host: "127.0.0.1", port: edge.port})
195
- await waitConnect(appSocket)
196
- appSocket.write("hello-tensorbuzz")
197
- assert.equal((await readOnce(appSocket)).toString("utf8"), "echo:hello-tensorbuzz")
198
- appSocket.destroy()
199
- })
161
+ describe("e2e process gateway", () => {
162
+ it("e2e process gateway forwards edge traffic to an allowlisted target", async () => withTestCleanup(async (after) => {
163
+ const tempDir = await mkdtemp(join(tmpdir(), "socketduct-e2e-spool-"))
164
+ const targetServer = createServer((socket) => {
165
+ socket.on("data", (chunk) => socket.write(Buffer.concat([Buffer.from("echo:"), chunk])))
166
+ })
167
+ const targetPort = await listen(targetServer)
168
+ const relayPort = await reservePort()
169
+ const relay = await startRelayProcess({
170
+ SOCKETDUCT_MODE: "relay",
171
+ SOCKETDUCT_RELAY_HOST: "127.0.0.1",
172
+ SOCKETDUCT_RELAY_PORT: String(relayPort),
173
+ SOCKETDUCT_AUTH_TOKENS: "tensorbuzz-edge:e2e-token-not-placeholder",
174
+ SOCKETDUCT_TARGET_ALLOWLIST: `127.0.0.1:${targetPort}`
175
+ })
200
176
 
201
- test("e2e process gateway denies non-allowlisted target requests", async () => {
202
- const allowedTarget = createServer((socket) => {
203
- socket.on("data", (chunk) => socket.write(chunk))
204
- })
205
- const allowedPort = await listen(allowedTarget)
206
- const blockedPort = await reservePort()
207
- const relayPort = await reservePort()
208
- const relay = await startRelayProcess({
209
- SOCKETDUCT_MODE: "relay",
210
- SOCKETDUCT_RELAY_HOST: "127.0.0.1",
211
- SOCKETDUCT_RELAY_PORT: String(relayPort),
212
- SOCKETDUCT_AUTH_TOKENS: "tensorbuzz-edge:e2e-token-not-placeholder",
213
- SOCKETDUCT_TARGET_ALLOWLIST: `127.0.0.1:${allowedPort}`
214
- })
177
+ const edge = await startTcpEdge({
178
+ relayHost: "127.0.0.1",
179
+ relayPort,
180
+ token: "e2e-token-not-placeholder",
181
+ sessionName: "e2e process gateway",
182
+ target: {host: "127.0.0.1", port: targetPort},
183
+ spool: new SessionSpool({rootDir: tempDir}),
184
+ reconnectDelayMs: 10
185
+ })
215
186
 
216
- test.after(async () => {
217
- await stopProcess(relay.process)
218
- await closeServer(allowedTarget)
219
- })
187
+ after(async () => {
188
+ await edge.close()
189
+ await stopProcess(relay.process)
190
+ await closeServer(targetServer)
191
+ await rm(tempDir, {recursive: true, force: true})
192
+ })
220
193
 
221
- const tunnel = createConnection({host: "127.0.0.1", port: relayPort})
222
- const decoder = new FrameDecoder()
223
- await waitConnect(tunnel)
224
- tunnel.write(encodeFrame({
225
- type: "session.create",
226
- token: "e2e-token-not-placeholder",
227
- name: "blocked process gateway target",
228
- target: {host: "127.0.0.1", port: blockedPort},
229
- framing: [2]
194
+ const appSocket = createConnection({host: "127.0.0.1", port: edge.port})
195
+ await waitConnect(appSocket)
196
+ appSocket.write("hello-tensorbuzz")
197
+ expect((await readOnce(appSocket)).toString("utf8")).toBe("echo:hello-tensorbuzz")
198
+ appSocket.destroy()
230
199
  }))
231
200
 
232
- const [frame] = decoder.push(await readOnce(tunnel))
233
- assert.equal(frame?.header.type, "error")
234
- assert.equal(frame?.header.code, "TARGET_UNAUTHORIZED")
235
- tunnel.destroy()
236
- })
237
-
238
- test("e2e process gateway rejects placeholder tokens before listening", async () => {
239
- const relayPort = await reservePort()
240
- const child = spawn(process.execPath, ["src/index.js"], {
241
- cwd: process.cwd(),
242
- env: {
243
- ...process.env,
201
+ it("e2e process gateway denies non-allowlisted target requests", async () => withTestCleanup(async (after) => {
202
+ const allowedTarget = createServer((socket) => {
203
+ socket.on("data", (chunk) => socket.write(chunk))
204
+ })
205
+ const allowedPort = await listen(allowedTarget)
206
+ const blockedPort = await reservePort()
207
+ const relayPort = await reservePort()
208
+ const relay = await startRelayProcess({
244
209
  SOCKETDUCT_MODE: "relay",
245
210
  SOCKETDUCT_RELAY_HOST: "127.0.0.1",
246
211
  SOCKETDUCT_RELAY_PORT: String(relayPort),
247
- SOCKETDUCT_AUTH_TOKENS: "tensorbuzz-edge:replace-with-generated-token",
248
- SOCKETDUCT_TARGET_ALLOWLIST: "127.0.0.1:443"
249
- },
250
- stdio: ["ignore", "pipe", "pipe"]
251
- })
252
- let output = ""
253
- child.stdout.on("data", (chunk) => { output += chunk.toString("utf8") })
254
- child.stderr.on("data", (chunk) => { output += chunk.toString("utf8") })
212
+ SOCKETDUCT_AUTH_TOKENS: "tensorbuzz-edge:e2e-token-not-placeholder",
213
+ SOCKETDUCT_TARGET_ALLOWLIST: `127.0.0.1:${allowedPort}`
214
+ })
215
+
216
+ after(async () => {
217
+ await stopProcess(relay.process)
218
+ await closeServer(allowedTarget)
219
+ })
255
220
 
256
- const exitCode = await withTimeout(new Promise((resolve) => {
257
- child.once("exit", (code) => resolve(code))
258
- }), PROCESS_TIMEOUT_MS, "Timed out waiting for placeholder-token process exit")
221
+ const tunnel = createConnection({host: "127.0.0.1", port: relayPort})
222
+ const decoder = new FrameDecoder()
223
+ await waitConnect(tunnel)
224
+ tunnel.write(encodeFrame({
225
+ type: "session.create",
226
+ token: "e2e-token-not-placeholder",
227
+ name: "blocked process gateway target",
228
+ target: {host: "127.0.0.1", port: blockedPort},
229
+ framing: [2]
230
+ }))
231
+
232
+ const [frame] = decoder.push(await readOnce(tunnel))
233
+ expect(frame?.header.type).toBe("error")
234
+ expect(frame?.header.code).toBe("TARGET_UNAUTHORIZED")
235
+ tunnel.destroy()
236
+ }))
259
237
 
260
- assert.notEqual(exitCode, 0)
261
- assert.match(output, /placeholder tokens/)
238
+ it("e2e process gateway rejects placeholder tokens before listening", async () => {
239
+ const relayPort = await reservePort()
240
+ const child = spawn(process.execPath, ["src/index.js"], {
241
+ cwd: process.cwd(),
242
+ env: {
243
+ ...process.env,
244
+ SOCKETDUCT_MODE: "relay",
245
+ SOCKETDUCT_RELAY_HOST: "127.0.0.1",
246
+ SOCKETDUCT_RELAY_PORT: String(relayPort),
247
+ SOCKETDUCT_AUTH_TOKENS: "tensorbuzz-edge:replace-with-generated-token",
248
+ SOCKETDUCT_TARGET_ALLOWLIST: "127.0.0.1:443"
249
+ },
250
+ stdio: ["ignore", "pipe", "pipe"]
251
+ })
252
+ let output = ""
253
+ child.stdout.on("data", (chunk) => { output += chunk.toString("utf8") })
254
+ child.stderr.on("data", (chunk) => { output += chunk.toString("utf8") })
255
+
256
+ const exitCode = await withTimeout(new Promise((resolve) => {
257
+ child.once("exit", (code) => resolve(code))
258
+ }), PROCESS_TIMEOUT_MS, "Timed out waiting for placeholder-token process exit")
259
+
260
+ expect(exitCode).not.toBe(0)
261
+ expect(output).toMatch(/placeholder tokens/)
262
+ })
262
263
  })
@@ -1,52 +1,53 @@
1
1
  /* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-param, jsdoc/require-returns */
2
- import assert from "node:assert/strict"
2
+ import {describe, expect, it, withTestCleanup} from "@velocious/testing"
3
3
  import {spawn} from "node:child_process"
4
4
  import {mkdtemp, rm} from "node:fs/promises"
5
5
  import {createServer} from "node:net"
6
6
  import {tmpdir} from "node:os"
7
7
  import {join} from "node:path"
8
- import test from "node:test"
9
8
 
10
9
  import {readCompressedLogDirectory} from "../../src/logging/compressed-log-reader.js"
11
10
 
12
11
  const TIMEOUT_MS = 8_000
13
12
 
14
- test("SIGTERM durably flushes queued critical lifecycle records before exit", async (t) => {
15
- const root = await mkdtemp(join(tmpdir(), "socketduct-logger-shutdown-"))
16
- const directory = join(root, "logs")
17
- const port = await reservePort()
18
- const child = spawn(process.execPath, ["src/index.js"], {
19
- cwd: process.cwd(),
20
- env: {
21
- ...process.env,
22
- SOCKETDUCT_MODE: "relay",
23
- SOCKETDUCT_RELAY_HOST: "127.0.0.1",
24
- SOCKETDUCT_RELAY_PORT: String(port),
25
- SOCKETDUCT_AUTH_TOKENS: "shutdown-test:shutdown-test-token",
26
- SOCKETDUCT_TARGET_ALLOWLIST: "127.0.0.1:1",
27
- SOCKETDUCT_STATUS_INTERVAL_MS: "0",
28
- SOCKETDUCT_LOG_DIRECTORY: directory,
29
- SOCKETDUCT_LOG_FLUSH_INTERVAL_MS: "60000",
30
- SOCKETDUCT_LOG_CRITICAL_FLUSH_INTERVAL_MS: "60000"
31
- },
32
- stdio: ["ignore", "pipe", "pipe"]
33
- })
34
- let output = ""
35
- child.stdout.on("data", (chunk) => { output += chunk.toString() })
36
- child.stderr.on("data", (chunk) => { output += chunk.toString() })
37
- t.after(async () => {
38
- if (child.exitCode === null) child.kill("SIGKILL")
39
- await rm(root, {recursive: true, force: true})
40
- })
13
+ describe("logger shutdown", () => {
14
+ it("SIGTERM durably flushes queued critical lifecycle records before exit", async () => withTestCleanup(async (after) => {
15
+ const root = await mkdtemp(join(tmpdir(), "socketduct-logger-shutdown-"))
16
+ const directory = join(root, "logs")
17
+ const port = await reservePort()
18
+ const child = spawn(process.execPath, ["src/index.js"], {
19
+ cwd: process.cwd(),
20
+ env: {
21
+ ...process.env,
22
+ SOCKETDUCT_MODE: "relay",
23
+ SOCKETDUCT_RELAY_HOST: "127.0.0.1",
24
+ SOCKETDUCT_RELAY_PORT: String(port),
25
+ SOCKETDUCT_AUTH_TOKENS: "shutdown-test:shutdown-test-token",
26
+ SOCKETDUCT_TARGET_ALLOWLIST: "127.0.0.1:1",
27
+ SOCKETDUCT_STATUS_INTERVAL_MS: "0",
28
+ SOCKETDUCT_LOG_DIRECTORY: directory,
29
+ SOCKETDUCT_LOG_FLUSH_INTERVAL_MS: "60000",
30
+ SOCKETDUCT_LOG_CRITICAL_FLUSH_INTERVAL_MS: "60000"
31
+ },
32
+ stdio: ["ignore", "pipe", "pipe"]
33
+ })
34
+ let output = ""
35
+ child.stdout.on("data", (chunk) => { output += chunk.toString() })
36
+ child.stderr.on("data", (chunk) => { output += chunk.toString() })
37
+ after(async () => {
38
+ if (child.exitCode === null) child.kill("SIGKILL")
39
+ await rm(root, {recursive: true, force: true})
40
+ })
41
41
 
42
- await waitUntil(() => output.includes('"event":"relay.started"'), child, () => output)
43
- assert.equal(child.kill("SIGTERM"), true)
44
- const exit = await withTimeout(new Promise((resolve) => child.once("exit", (code, signal) => resolve({code, signal}))), "process exit")
45
- assert.deepEqual(exit, {code: 0, signal: null})
42
+ await waitUntil(() => output.includes('"event":"relay.started"'), child, () => output)
43
+ expect(child.kill("SIGTERM")).toBe(true)
44
+ const exit = await withTimeout(new Promise((resolve) => child.once("exit", (code, signal) => resolve({code, signal}))), "process exit")
45
+ expect(exit).toEqual({code: 0, signal: null})
46
46
 
47
- const result = await readCompressedLogDirectory(directory)
48
- assert.equal(result.incomplete, false)
49
- assert.deepEqual(result.records.map((record) => record.event), ["process.started", "process.stopped", "log_batch_accepted", "log_frame_committed", "log_closed"])
47
+ const result = await readCompressedLogDirectory(directory)
48
+ expect(result.incomplete).toBe(false)
49
+ expect(result.records.map((record) => record.event)).toEqual(["process.started", "process.stopped", "log_batch_accepted", "log_frame_committed", "log_closed"])
50
+ }))
50
51
  })
51
52
 
52
53
  /** @returns {Promise<number>} A released loopback port. */
@@ -57,7 +58,9 @@ async function reservePort() {
57
58
  server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(undefined) })
58
59
  })
59
60
  const address = server.address()
60
- assert(address !== null && typeof address !== "string")
61
+ if (address === null || typeof address === "string") {
62
+ throw new Error("Server did not bind to a TCP port")
63
+ }
61
64
  await new Promise((resolve) => server.close(() => resolve(undefined)))
62
65
  return address.port
63
66
  }