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.
- package/AGENTS.md +2 -2
- package/changelog.d/20261010130509-bound-log-frame-compression-memory.md +1 -0
- package/docs/runbooks/jsonl-integrity-rollout.md +8 -6
- package/package.json +2 -2
- package/scripts/test.js +9 -14
- package/src/client/multiplexed-transport-set.js +20 -2
- package/src/logging/compressed-log-writer.js +1 -1
- package/src/logging/zstd-frame-codec.js +15 -1
- package/src/reverse/client.js +44 -1
- package/src/reverse/hub.js +10 -1
- package/src/reverse/runtime.js +11 -2
- package/test/e2e/gateway-process.test.js +93 -92
- package/test/e2e/logger-shutdown-process.test.js +40 -37
- package/test/e2e/reverse-client-recovery-process.test.js +107 -103
- package/test/e2e/reverse-gateway-process.test.js +328 -318
- package/test/integration/initial-target-recovery.test.js +333 -334
- package/test/integration/logging-cli.test.js +121 -119
- package/test/integration/logging-compressed-recovery.test.js +96 -95
- package/test/integration/multiplexed-compatibility.test.js +178 -177
- package/test/integration/multiplexed-corrections.test.js +360 -359
- package/test/integration/multiplexed-failures.test.js +253 -251
- package/test/integration/multiplexed-transport-set.test.js +334 -322
- package/test/integration/multiplexed-transport.test.js +135 -134
- package/test/integration/reverse-gateway-stream-id-reset.test.js +90 -89
- package/test/integration/reverse-gateway.test.js +2309 -2218
- package/test/integration/reverse-resume-continuity.test.js +219 -218
- package/test/integration/reverse-route-class.test.js +363 -278
- package/test/integration/socketduct-http-agent.test.js +314 -312
- package/test/integration/socketduct-https-agent.test.js +516 -485
- package/test/integration/stream-compression.test.js +862 -824
- package/test/integration/tcp-forwarding.test.js +2550 -2447
- package/test/integration/tls-forwarding.test.js +593 -592
- package/test/unit/auth/auth.test.js +43 -44
- package/test/unit/auth/static-token-store.test.js +9 -8
- package/test/unit/client/connect.test.js +41 -40
- package/test/unit/client/deferred-virtual-socket.test.js +95 -94
- package/test/unit/client/multiplexed-spool-budget.test.js +155 -154
- package/test/unit/client/multiplexed-transport-set.test.js +845 -732
- package/test/unit/client/socketduct-socket.test.js +768 -771
- package/test/unit/client/virtual-socket.test.js +198 -168
- package/test/unit/config/docker-gateway-files.test.js +224 -224
- package/test/unit/config/environment.test.js +472 -492
- package/test/unit/index.test.js +53 -52
- package/test/unit/logging/compressed-log-worker.test.js +23 -22
- package/test/unit/logging/compressed-log-writer.test.js +155 -154
- package/test/unit/logging/log-retention.test.js +52 -47
- package/test/unit/logging/log-segment-index.test.js +84 -81
- package/test/unit/logging/zstd-frame-codec.test.js +28 -19
- package/test/unit/protocol/compression.test.js +254 -298
- package/test/unit/protocol/frame.test.js +280 -276
- package/test/unit/protocol/v3-compression.test.js +21 -23
- package/test/unit/protocol/v3-frame.test.js +42 -41
- package/test/unit/release-configuration.test.js +15 -12
- package/test/unit/reverse/disconnect-attribution.test.js +65 -64
- package/test/unit/reverse-terminal-outcomes.test.js +53 -50
- package/test/unit/server/control-handler.test.js +232 -231
- package/test/unit/server/primary.test.js +51 -49
- package/test/unit/server/relay-runtime.test.js +280 -294
- package/test/unit/sessions/session.test.js +48 -62
- 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
|
|
39
|
-
runner. `npm test` is the CI entry point: it discovers
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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.
|
|
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.
|
|
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
|
|
4
|
+
const args = process.argv.slice(2)
|
|
5
5
|
const files = globSync("test/**/*.test.js").sort()
|
|
6
|
-
|
|
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 (
|
|
17
|
-
console.log(JSON.stringify({
|
|
8
|
+
if (args.length === 1 && args[0] === "--list") {
|
|
9
|
+
console.log(JSON.stringify({files}, null, 2))
|
|
18
10
|
} else {
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
package/src/reverse/client.js
CHANGED
|
@@ -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,
|
|
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
|
package/src/reverse/hub.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/reverse/runtime.js
CHANGED
|
@@ -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})}
|
|
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}\
|
|
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
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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:
|
|
248
|
-
SOCKETDUCT_TARGET_ALLOWLIST:
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
261
|
-
|
|
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
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
}
|