socketduct 0.1.15

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 (138) hide show
  1. package/.dockerignore +11 -0
  2. package/.env.example +131 -0
  3. package/AGENTS.md +168 -0
  4. package/Dockerfile +30 -0
  5. package/README.md +134 -0
  6. package/changelog.d/20260716083144-v2-ack-wire-format.md +3 -0
  7. package/changelog.d/20260716092044-v2-only-stream-framing.md +1 -0
  8. package/changelog.d/20260727113000-relay-connect-timeout-destroy.md +1 -0
  9. package/changelog.d/20260813093000-private-relay-hardening.md +1 -0
  10. package/changelog.d/20260824183412-initial-target-recovery-https.md +1 -0
  11. package/changelog.d/20260824230000-tls-relay-session-close.md +1 -0
  12. package/changelog.d/20260825120000-reconnect-close-target-timeout.md +1 -0
  13. package/changelog.d/20260829120000-protocol-v3-multiplexing.md +1 -0
  14. package/changelog.d/20260829160000-v3-review-corrections.md +1 -0
  15. package/changelog.d/20260830000000-fix-socket-idle-timeout.md +1 -0
  16. package/changelog.d/20260830090000-multi-gateway-transport-set.md +1 -0
  17. package/changelog.d/20260830093000-relay-close-settlement.md +1 -0
  18. package/changelog.d/20260830120000-immutable-gateway-image.md +1 -0
  19. package/changelog.d/20260902210000-native-reverse-gateway.md +1 -0
  20. package/changelog.d/20260902230000-relay-resume-terminal-order.md +1 -0
  21. package/changelog.d/20260902230708-multiplexed-open-ownership.md +1 -0
  22. package/changelog.d/20260902231541-multiplexed-terminal-close.md +1 -0
  23. package/changelog.d/20260902231542-reverse-close-position.md +1 -0
  24. package/changelog.d/20260902231543-reverse-terminal-connect.md +1 -0
  25. package/changelog.d/20260902233000-reverse-gateway-bounds.md +1 -0
  26. package/changelog.d/20260903001000-reverse-data-ordering.md +1 -0
  27. package/changelog.d/20260903003000-reverse-hub-restart-window.md +1 -0
  28. package/changelog.d/20260903010000-reverse-terminal-lifecycle.md +1 -0
  29. package/changelog.d/20260904142743-transport-set-readiness-wait.md +1 -0
  30. package/changelog.d/20260904152855-deferred-open-snapshots-binary-views.md +1 -0
  31. package/changelog.d/20260904163041-tensorbuzz-container-workflow.md +1 -0
  32. package/changelog.d/20260904174955-transport-set-terminal-causes.md +1 -0
  33. package/changelog.d/20260905081000-contain-reverse-auth-write-errors.md +1 -0
  34. package/changelog.d/20260905100520-reverse-hub-close-race.md +1 -0
  35. package/changelog.d/20260905120000-persistent-jsonl-logging.md +1 -0
  36. package/changelog.d/20260908071009-jsonl-partial-append-recovery.md +1 -0
  37. package/changelog.d/20260908074916-private-log-directory-setup.md +1 -0
  38. package/changelog.d/20260909093057-reverse-large-write-backpressure.md +1 -0
  39. package/changelog.d/20260909135830-actionable-error-diagnostics.md +1 -0
  40. package/changelog.d/20260909150000-transport-set-error-redaction.md +1 -0
  41. package/docker-compose.dev.yml +19 -0
  42. package/docker-compose.reverse-gateway.yml +36 -0
  43. package/docker-compose.yml +73 -0
  44. package/docs/architecture.md +264 -0
  45. package/docs/docker-topology.md +164 -0
  46. package/docs/docker.md +281 -0
  47. package/docs/logging.md +185 -0
  48. package/docs/plans/initial-implementation.md +205 -0
  49. package/docs/plans/socket-docker-integration.md +221 -0
  50. package/docs/protocol.md +558 -0
  51. package/docs/releasing.md +18 -0
  52. package/docs/reverse-gateway.md +186 -0
  53. package/docs/runbooks/jsonl-integrity-rollout.md +144 -0
  54. package/docs/running.md +215 -0
  55. package/docs/security.md +258 -0
  56. package/docs/session-lifecycle.md +127 -0
  57. package/docs/socket-api.md +448 -0
  58. package/eslint.config.js +36 -0
  59. package/package.json +44 -0
  60. package/scripts/deploy-gateway.js +194 -0
  61. package/scripts/test.js +28 -0
  62. package/src/auth/passwords.js +38 -0
  63. package/src/auth/static-token-store.js +41 -0
  64. package/src/auth/user-store.js +139 -0
  65. package/src/client/connect.js +76 -0
  66. package/src/client/deferred-virtual-socket.js +312 -0
  67. package/src/client/http-agent.js +101 -0
  68. package/src/client/https-agent.js +366 -0
  69. package/src/client/multiplexed-connection.js +1504 -0
  70. package/src/client/multiplexed-transport-set.js +917 -0
  71. package/src/client/multiplexed.js +3 -0
  72. package/src/client/socketduct-socket.js +1174 -0
  73. package/src/client/virtual-socket.js +256 -0
  74. package/src/config/environment.js +400 -0
  75. package/src/edge/tcp-edge.js +82 -0
  76. package/src/index.js +101 -0
  77. package/src/logging/structured-logger.js +745 -0
  78. package/src/protocol/compression.js +325 -0
  79. package/src/protocol/frame.js +341 -0
  80. package/src/protocol/v3-compression.js +44 -0
  81. package/src/protocol/v3-frame.js +209 -0
  82. package/src/proxy/multiplexed-relay.js +798 -0
  83. package/src/proxy/tcp-relay.js +1191 -0
  84. package/src/relay.js +3 -0
  85. package/src/reverse/client.js +721 -0
  86. package/src/reverse/common.js +88 -0
  87. package/src/reverse/gateway.js +663 -0
  88. package/src/reverse/hub.js +563 -0
  89. package/src/reverse/index.js +3 -0
  90. package/src/reverse/runtime.js +78 -0
  91. package/src/server/control-handler.js +170 -0
  92. package/src/server/primary.js +64 -0
  93. package/src/server/relay-runtime.js +124 -0
  94. package/src/server/worker.js +39 -0
  95. package/src/sessions/session.js +212 -0
  96. package/src/spool/spool-file.js +451 -0
  97. package/tensorbuzz.yml +36 -0
  98. package/test/e2e/gateway-process.test.js +262 -0
  99. package/test/e2e/reverse-gateway-process.test.js +285 -0
  100. package/test/fixtures/logging/enospc-matrix.mjs +238 -0
  101. package/test/fixtures/logging/enospc-recovery.mjs +146 -0
  102. package/test/fixtures/logging/process-writer.mjs +20 -0
  103. package/test/helpers/tls-certificates.js +115 -0
  104. package/test/integration/initial-target-recovery.test.js +518 -0
  105. package/test/integration/logging-recovery.test.js +163 -0
  106. package/test/integration/multiplexed-compatibility.test.js +254 -0
  107. package/test/integration/multiplexed-corrections.test.js +551 -0
  108. package/test/integration/multiplexed-failures.test.js +339 -0
  109. package/test/integration/multiplexed-transport-set.test.js +456 -0
  110. package/test/integration/multiplexed-transport.test.js +208 -0
  111. package/test/integration/reverse-gateway.test.js +1293 -0
  112. package/test/integration/socketduct-http-agent.test.js +435 -0
  113. package/test/integration/socketduct-https-agent.test.js +669 -0
  114. package/test/integration/stream-compression.test.js +1267 -0
  115. package/test/integration/tcp-forwarding.test.js +2878 -0
  116. package/test/integration/tls-forwarding.test.js +748 -0
  117. package/test/unit/auth/auth.test.js +64 -0
  118. package/test/unit/auth/static-token-store.test.js +14 -0
  119. package/test/unit/client/connect.test.js +58 -0
  120. package/test/unit/client/multiplexed-spool-budget.test.js +309 -0
  121. package/test/unit/client/multiplexed-transport-set.test.js +906 -0
  122. package/test/unit/client/socketduct-socket.test.js +1124 -0
  123. package/test/unit/client/virtual-socket.test.js +98 -0
  124. package/test/unit/config/docker-gateway-files.test.js +274 -0
  125. package/test/unit/config/environment.test.js +507 -0
  126. package/test/unit/index.test.js +78 -0
  127. package/test/unit/logging/structured-logger.test.js +674 -0
  128. package/test/unit/protocol/compression.test.js +421 -0
  129. package/test/unit/protocol/frame.test.js +336 -0
  130. package/test/unit/protocol/v3-compression.test.js +31 -0
  131. package/test/unit/protocol/v3-frame.test.js +57 -0
  132. package/test/unit/release-configuration.test.js +21 -0
  133. package/test/unit/server/control-handler.test.js +291 -0
  134. package/test/unit/server/primary.test.js +59 -0
  135. package/test/unit/server/relay-runtime.test.js +434 -0
  136. package/test/unit/sessions/session.test.js +84 -0
  137. package/test/unit/spool/spool-file.test.js +295 -0
  138. package/tsconfig.json +20 -0
@@ -0,0 +1,262 @@
1
+ import assert from "node:assert/strict"
2
+ import {spawn} from "node:child_process"
3
+ import {mkdtemp, rm} from "node:fs/promises"
4
+ import {createConnection, createServer} from "node:net"
5
+ import {tmpdir} from "node:os"
6
+ import {join} from "node:path"
7
+ import test from "node:test"
8
+
9
+ import {startTcpEdge} from "../../src/edge/tcp-edge.js"
10
+ import {encodeFrame, FrameDecoder} from "../../src/protocol/frame.js"
11
+ import {SessionSpool} from "../../src/spool/spool-file.js"
12
+
13
+ const PROCESS_TIMEOUT_MS = 5_000
14
+
15
+ /**
16
+ * @param {import('node:net').Server} server - Server to bind.
17
+ * @returns {Promise<number>} Bound port.
18
+ */
19
+ async function listen(server) {
20
+ await new Promise((resolve, reject) => {
21
+ server.once("error", reject)
22
+ server.listen(0, "127.0.0.1", () => {
23
+ server.off("error", reject)
24
+ resolve(undefined)
25
+ })
26
+ })
27
+ const address = server.address()
28
+ if (address === null || typeof address === "string") {
29
+ throw new Error("Server did not bind to a TCP port")
30
+ }
31
+ return address.port
32
+ }
33
+
34
+ /** @returns {Promise<number>} Available TCP port. */
35
+ async function reservePort() {
36
+ const server = createServer()
37
+ const port = await listen(server)
38
+ await closeServer(server)
39
+ return port
40
+ }
41
+
42
+ /**
43
+ * @param {import('node:net').Server} server - Server to close.
44
+ * @returns {Promise<void>} Close completion.
45
+ */
46
+ async function closeServer(server) {
47
+ await new Promise((resolve, reject) => {
48
+ server.close((error) => {
49
+ if (error) {
50
+ reject(error)
51
+ return
52
+ }
53
+ resolve(undefined)
54
+ })
55
+ })
56
+ }
57
+
58
+ /**
59
+ * @param {import('node:net').Socket} socket - Socket to wait on.
60
+ * @returns {Promise<void>} Connect completion.
61
+ */
62
+ async function waitConnect(socket) {
63
+ await new Promise((resolve, reject) => {
64
+ socket.once("error", reject)
65
+ socket.once("connect", () => {
66
+ socket.off("error", reject)
67
+ resolve(undefined)
68
+ })
69
+ })
70
+ }
71
+
72
+ /**
73
+ * @param {import('node:net').Socket} socket - Socket to read.
74
+ * @returns {Promise<Buffer>} Next chunk.
75
+ */
76
+ async function readOnce(socket) {
77
+ return await withTimeout(new Promise((resolve) => {
78
+ socket.once("data", (chunk) => resolve(chunk))
79
+ }), PROCESS_TIMEOUT_MS, "Timed out waiting for socket data")
80
+ }
81
+
82
+ /**
83
+ * @param {Promise<T>} promise - Promise to wrap.
84
+ * @param {number} timeoutMs - Timeout in milliseconds.
85
+ * @param {string} message - Timeout message.
86
+ * @template T
87
+ * @returns {Promise<T>} Promise result.
88
+ */
89
+ async function withTimeout(promise, timeoutMs, message) {
90
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
91
+ let timer
92
+ const timeout = new Promise((_, reject) => {
93
+ timer = setTimeout(() => reject(new Error(message)), timeoutMs)
94
+ })
95
+ try {
96
+ return await Promise.race([promise, timeout])
97
+ } finally {
98
+ if (timer !== undefined) {
99
+ clearTimeout(timer)
100
+ }
101
+ }
102
+ }
103
+
104
+ /**
105
+ * @param {Record<string, string>} env - Relay process environment.
106
+ * @returns {Promise<{process: import('node:child_process').ChildProcessByStdio<null, import('node:stream').Readable, import('node:stream').Readable>, output: () => string}>} Started relay process.
107
+ */
108
+ async function startRelayProcess(env) {
109
+ const child = spawn(process.execPath, ["src/index.js"], {
110
+ cwd: process.cwd(),
111
+ env: {...process.env, ...env},
112
+ stdio: ["ignore", "pipe", "pipe"]
113
+ })
114
+ let output = ""
115
+ child.stdout.on("data", (chunk) => { output += chunk.toString("utf8") })
116
+ child.stderr.on("data", (chunk) => { output += chunk.toString("utf8") })
117
+
118
+ try {
119
+ await withTimeout(new Promise((resolve, reject) => {
120
+ const cleanup = () => {
121
+ child.off("exit", onExit)
122
+ child.stdout.off("data", onStdout)
123
+ }
124
+ /** @type {(code: number | null) => void} */
125
+ const onExit = (code) => {
126
+ cleanup()
127
+ reject(new Error(`Relay exited before listening with ${code}: ${output}`))
128
+ }
129
+ const onStdout = () => {
130
+ if (output.includes('"event":"relay.started"')) {
131
+ cleanup()
132
+ resolve(undefined)
133
+ }
134
+ }
135
+
136
+ child.once("exit", onExit)
137
+ child.stdout.on("data", onStdout)
138
+ onStdout()
139
+ }), PROCESS_TIMEOUT_MS, `Timed out waiting for relay startup: ${output}`)
140
+ } catch (error) {
141
+ await stopProcess(child)
142
+ throw error
143
+ }
144
+
145
+ return {process: child, output: () => output}
146
+ }
147
+
148
+ /**
149
+ * @param {import('node:child_process').ChildProcess} child - Child process.
150
+ * @returns {Promise<void>} Stop completion.
151
+ */
152
+ async function stopProcess(child) {
153
+ if (child.exitCode !== null || child.killed) {
154
+ return
155
+ }
156
+ child.kill("SIGTERM")
157
+ await withTimeout(new Promise((resolve) => {
158
+ child.once("exit", () => resolve(undefined))
159
+ }), PROCESS_TIMEOUT_MS, "Timed out waiting for relay shutdown")
160
+ }
161
+
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
+ })
200
+
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
+ })
215
+
216
+ test.after(async () => {
217
+ await stopProcess(relay.process)
218
+ await closeServer(allowedTarget)
219
+ })
220
+
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
+ 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,
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
+ assert.notEqual(exitCode, 0)
261
+ assert.match(output, /placeholder tokens/)
262
+ })
@@ -0,0 +1,285 @@
1
+ /* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-param, jsdoc/require-returns */
2
+ import assert from "node:assert/strict"
3
+ import {spawn} from "node:child_process"
4
+ import {mkdtemp, readFile, readdir, rm} from "node:fs/promises"
5
+ import {createServer} from "node:net"
6
+ import {tmpdir} from "node:os"
7
+ import {join} from "node:path"
8
+ import test from "node:test"
9
+
10
+ import {ReverseGatewayConnection} from "../../src/reverse/client.js"
11
+ import {generateTestCertificates} from "../helpers/tls-certificates.js"
12
+
13
+ const PROCESS_TIMEOUT_MS = 8_000
14
+ const PROCESS_LISTEN_ATTEMPTS = 3
15
+
16
+ /** A child exit observed before its expected startup event. */
17
+ class ProcessStartupExitError extends Error {
18
+ /**
19
+ * @param {number | null} code - Exit code.
20
+ * @param {string} event - Expected startup event.
21
+ * @param {string} output - Complete child output.
22
+ * @param {import('node:child_process').ChildProcess} child - Exited child.
23
+ */
24
+ constructor(code, event, output, child) {
25
+ super(`Socketduct process exited ${String(code)} before ${event}: ${output}`)
26
+ this.name = "ProcessStartupExitError"
27
+ this.child = child
28
+ this.reason = isListenAddressInUse(output) ? "listen-address-in-use" : "process-exit"
29
+ }
30
+ }
31
+
32
+ /** @param {string} output - Complete child output. @returns {boolean} Whether Node reported listen EADDRINUSE. */
33
+ function isListenAddressInUse(output) {
34
+ return /^Error: listen EADDRINUSE:/mu.test(output) &&
35
+ /^\s*code: ['"]EADDRINUSE['"],?\s*$/mu.test(output) &&
36
+ /^\s*syscall: ['"]listen['"],?\s*$/mu.test(output)
37
+ }
38
+
39
+ /** @param {import('node:net').Server} server - Server. */
40
+ async function listen(server) {
41
+ await new Promise((resolve, reject) => {
42
+ server.once("error", reject)
43
+ server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(undefined) })
44
+ })
45
+ const address = server.address()
46
+ assert(address !== null && typeof address !== "string")
47
+ return address.port
48
+ }
49
+
50
+ /** Reserves a currently unused loopback port. */
51
+ async function reservePort() {
52
+ const server = createServer()
53
+ const port = await listen(server)
54
+ await new Promise((resolve) => server.close(() => resolve(undefined)))
55
+ return port
56
+ }
57
+
58
+ /** @param {Record<string, string>} environment - Runtime environment. @param {string} event - Expected startup event. */
59
+ async function startProcess(environment, event) {
60
+ const child = spawn(process.execPath, ["src/index.js"], {
61
+ cwd: process.cwd(),
62
+ env: {...process.env, ...environment},
63
+ stdio: ["ignore", "pipe", "pipe"]
64
+ })
65
+ let output = ""
66
+ child.stdout.on("data", (chunk) => { output += chunk.toString() })
67
+ child.stderr.on("data", (chunk) => { output += chunk.toString() })
68
+ try {
69
+ await withTimeout(new Promise((resolve, reject) => {
70
+ const onClose = (/** @type {number | null} */ code) => reject(new ProcessStartupExitError(code, event, output, child))
71
+ const onData = () => {
72
+ if (!output.includes(`"event":"${event}"`)) return
73
+ child.off("close", onClose)
74
+ child.stdout.off("data", onData)
75
+ resolve(undefined)
76
+ }
77
+ child.once("close", onClose)
78
+ child.stdout.on("data", onData)
79
+ onData()
80
+ }), `process event ${event}`)
81
+ } catch (error) {
82
+ await stopProcess(child)
83
+ throw error
84
+ }
85
+ return child
86
+ }
87
+
88
+ /**
89
+ * Starts a listener process on a reserved port, retrying only a classified pre-start bind collision.
90
+ * @param {Record<string, string>} environment - Runtime environment without its listener port.
91
+ * @param {string} event - Expected startup event.
92
+ * @param {{reservePort?: () => Promise<number>, startProcess?: typeof startProcess}} [options] - Test dependencies.
93
+ * @returns {Promise<{child: import('node:child_process').ChildProcess, port: number, attempts: import('node:child_process').ChildProcess[]}>} Started process details.
94
+ */
95
+ async function startProcessOnReservedPort(environment, event, options = {}) {
96
+ const reserve = options.reservePort ?? reservePort
97
+ const start = options.startProcess ?? startProcess
98
+ /** @type {import('node:child_process').ChildProcess[]} */
99
+ const attempts = []
100
+ for (let attempt = 1; attempt <= PROCESS_LISTEN_ATTEMPTS; attempt += 1) {
101
+ const port = await reserve()
102
+ try {
103
+ const child = await start({...environment, SOCKETDUCT_RELAY_PORT: String(port)}, event)
104
+ attempts.push(child)
105
+ return {child, port, attempts}
106
+ } catch (error) {
107
+ if (error instanceof ProcessStartupExitError) attempts.push(error.child)
108
+ if (!(error instanceof ProcessStartupExitError && error.reason === "listen-address-in-use") || attempt === PROCESS_LISTEN_ATTEMPTS) throw error
109
+ }
110
+ }
111
+ throw new Error("Listener startup attempts exhausted")
112
+ }
113
+
114
+ /** @param {import('node:child_process').ChildProcess} child - Child. */
115
+ async function stopProcess(child) {
116
+ if (child.exitCode !== null || child.killed) return
117
+ child.kill("SIGTERM")
118
+ await withTimeout(new Promise((resolve) => child.once("exit", () => resolve(undefined))), "process shutdown")
119
+ }
120
+
121
+ /** @param {Promise<unknown>} promise - Work. @param {string} label - Timeout. */
122
+ async function withTimeout(promise, label) {
123
+ let timer
124
+ try {
125
+ return await Promise.race([promise, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`Timed out: ${label}`)), PROCESS_TIMEOUT_MS) })])
126
+ } finally {
127
+ if (timer !== undefined) clearTimeout(timer)
128
+ }
129
+ }
130
+
131
+ /** @param {import('node:events').EventEmitter} emitter - Source. @param {string} event - Event. */
132
+ async function onceEvent(emitter, event) {
133
+ await new Promise((resolve, reject) => {
134
+ emitter.once("error", reject)
135
+ emitter.once(event, () => { emitter.off("error", reject); resolve(undefined) })
136
+ })
137
+ }
138
+
139
+ test("reserved reverse-hub startup retries a pre-start listen EADDRINUSE without leaking attempts", async (t) => {
140
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-port-retry-"))
141
+ t.after(() => rm(root, {recursive: true, force: true}))
142
+ const certificates = generateTestCertificates(root)
143
+ const occupiedListener = createServer()
144
+ const occupiedPort = await listen(occupiedListener)
145
+ t.after(() => new Promise((resolve) => occupiedListener.close(() => resolve(undefined))))
146
+ let reservations = 0
147
+ const {child, port, attempts} = await startProcessOnReservedPort({
148
+ SOCKETDUCT_MODE: "reverse-hub",
149
+ SOCKETDUCT_RELAY_HOST: "127.0.0.1",
150
+ SOCKETDUCT_TLS_CERT_FILE: certificates.serverCertFile,
151
+ SOCKETDUCT_TLS_KEY_FILE: certificates.serverKeyFile,
152
+ SOCKETDUCT_AUTH_TOKENS: "home-o2:process-gateway-token,tensorbuzz:process-client-token",
153
+ SOCKETDUCT_REVERSE_GATEWAY_BINDINGS: "o2:home-o2",
154
+ SOCKETDUCT_REVERSE_ROUTES: "tensorbuzz:o2:127.0.0.1:1",
155
+ SOCKETDUCT_STATUS_INTERVAL_MS: "0"
156
+ }, "reverse-hub.started", {
157
+ reservePort: async () => {
158
+ reservations += 1
159
+ return reservations === 1 ? occupiedPort : reservePort()
160
+ }
161
+ })
162
+ t.after(() => stopProcess(child))
163
+
164
+ assert.equal(reservations, 2)
165
+ assert.notEqual(port, occupiedPort)
166
+ assert.equal(attempts.length, 2)
167
+ const [failedAttempt, successfulAttempt] = attempts
168
+ assert(failedAttempt !== undefined)
169
+ assert(successfulAttempt !== undefined)
170
+ assert(failedAttempt.exitCode !== null || failedAttempt.signalCode !== null)
171
+ assert.equal(successfulAttempt, child)
172
+ assert.equal(child.exitCode, null)
173
+ assert.equal(child.signalCode, null)
174
+ assert.equal(occupiedListener.listening, true)
175
+
176
+ await stopProcess(child)
177
+ assert(child.exitCode !== null || child.signalCode !== null)
178
+ const releasedPortProbe = createServer()
179
+ t.after(() => {
180
+ if (!releasedPortProbe.listening) return undefined
181
+ return new Promise((resolve) => releasedPortProbe.close(() => resolve(undefined)))
182
+ })
183
+ await new Promise((resolve, reject) => {
184
+ releasedPortProbe.once("error", reject)
185
+ releasedPortProbe.listen(port, "127.0.0.1", () => {
186
+ releasedPortProbe.off("error", reject)
187
+ resolve(undefined)
188
+ })
189
+ })
190
+ })
191
+
192
+ test("reserved process startup does not retry an unclassified EADDRINUSE failure", async () => {
193
+ const failure = new Error("untrusted EADDRINUSE text is not a listener-bind classification")
194
+ let reservations = 0
195
+ let starts = 0
196
+ await assert.rejects(startProcessOnReservedPort({}, "reverse-hub.started", {
197
+ reservePort: async () => {
198
+ reservations += 1
199
+ return 31_337
200
+ },
201
+ startProcess: async () => {
202
+ starts += 1
203
+ throw failure
204
+ }
205
+ }), (error) => error === failure)
206
+ assert.equal(reservations, 1)
207
+ assert.equal(starts, 1)
208
+ })
209
+
210
+ test("spawned reverse hub and outbound gateway expose an allowlisted Docker stream over verified TLS", async (t) => {
211
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-process-"))
212
+ t.after(() => rm(root, {recursive: true, force: true}))
213
+ const certificates = generateTestCertificates(root)
214
+ const logDirectory = join(root, "logs")
215
+ const targetSockets = new Set()
216
+ const target = createServer((socket) => {
217
+ targetSockets.add(socket)
218
+ socket.once("close", () => targetSockets.delete(socket))
219
+ socket.on("data", (chunk) => socket.write(Buffer.concat([Buffer.from("docker:"), chunk])))
220
+ })
221
+ const targetPort = await listen(target)
222
+ t.after(() => {
223
+ for (const socket of targetSockets) socket.destroy()
224
+ return new Promise((resolve) => target.close(() => resolve(undefined)))
225
+ })
226
+ const {child: hub, port: hubPort} = await startProcessOnReservedPort({
227
+ SOCKETDUCT_MODE: "reverse-hub",
228
+ SOCKETDUCT_RELAY_HOST: "127.0.0.1",
229
+ SOCKETDUCT_TLS_CERT_FILE: certificates.serverCertFile,
230
+ SOCKETDUCT_TLS_KEY_FILE: certificates.serverKeyFile,
231
+ SOCKETDUCT_AUTH_TOKENS: "home-o2:process-gateway-token,tensorbuzz:process-client-token",
232
+ SOCKETDUCT_REVERSE_GATEWAY_BINDINGS: "o2:home-o2",
233
+ SOCKETDUCT_REVERSE_ROUTES: `tensorbuzz:o2:127.0.0.1:${targetPort}`,
234
+ SOCKETDUCT_STATUS_INTERVAL_MS: "0",
235
+ SOCKETDUCT_LOG_DIRECTORY: logDirectory,
236
+ SOCKETDUCT_LOG_MAX_FILE_BYTES: "4096",
237
+ SOCKETDUCT_LOG_MAX_FILES: "2"
238
+ }, "reverse-hub.started")
239
+ t.after(() => stopProcess(hub))
240
+ const gateway = await startProcess({
241
+ SOCKETDUCT_MODE: "reverse-gateway",
242
+ SOCKETDUCT_REVERSE_GATEWAY_ID: "o2",
243
+ SOCKETDUCT_REVERSE_CONNECTION_ID: "process_home_o2_gateway",
244
+ SOCKETDUCT_REVERSE_HUB_HOST: "127.0.0.1",
245
+ SOCKETDUCT_REVERSE_HUB_PORT: String(hubPort),
246
+ SOCKETDUCT_REVERSE_TOKEN: "process-gateway-token",
247
+ SOCKETDUCT_REVERSE_HUB_CA_FILE: certificates.caCertFile,
248
+ SOCKETDUCT_REVERSE_HUB_SERVERNAME: "localhost",
249
+ SOCKETDUCT_TARGET_ALLOWLIST: `127.0.0.1:${targetPort}`,
250
+ SOCKETDUCT_STATUS_INTERVAL_MS: "0",
251
+ SOCKETDUCT_LOG_DIRECTORY: logDirectory,
252
+ SOCKETDUCT_LOG_MAX_FILE_BYTES: "4096",
253
+ SOCKETDUCT_LOG_MAX_FILES: "2"
254
+ }, "reverse-gateway.started")
255
+ t.after(() => stopProcess(gateway))
256
+
257
+ const client = new ReverseGatewayConnection({
258
+ gatewayId: "o2",
259
+ connectionId: "process_tensorbuzz_client",
260
+ hub: {host: "127.0.0.1", port: hubPort, token: "process-client-token"},
261
+ tls: {caFile: certificates.caCertFile, servername: "localhost"}
262
+ })
263
+ t.after(() => client.close())
264
+ await withTimeout(client.connect(), "process client connect")
265
+ const socket = client.createConnection({host: "127.0.0.1", port: targetPort, sessionName: "docker-version-process"})
266
+ await withTimeout(onceEvent(socket, "connect"), "process stream open")
267
+ const response = new Promise((resolve, reject) => { socket.once("error", reject); socket.once("data", resolve) })
268
+ socket.write("GET /version")
269
+ assert.equal(Buffer.from(/** @type {Uint8Array} */ (await withTimeout(response, "process response"))).toString(), "docker:GET /version")
270
+ assert.equal(client.status().compression, "zstd")
271
+ socket.destroy()
272
+
273
+ assert(hub.pid !== undefined)
274
+ assert(gateway.pid !== undefined)
275
+ const files = (await readdir(logDirectory)).sort()
276
+ assert(files.includes(`reverse-hub-${hub.pid}.jsonl`))
277
+ assert(files.includes(`reverse-gateway-${gateway.pid}.jsonl`))
278
+ const hubEntries = (await readFile(join(logDirectory, `reverse-hub-${hub.pid}.jsonl`), "utf8")).trim().split("\n").map((line) => JSON.parse(line))
279
+ const gatewayContents = await readFile(join(logDirectory, `reverse-gateway-${gateway.pid}.jsonl`), "utf8")
280
+ const gatewayEntries = gatewayContents.trim().split("\n").map((line) => JSON.parse(line))
281
+ assert(hubEntries.some(({event}) => event === "process.started"))
282
+ assert(gatewayEntries.some(({event}) => event === "process.started"))
283
+ assert(gatewayEntries.some(({event}) => event === "transport.connected"))
284
+ assert.doesNotMatch(gatewayContents, /process-gateway-token|127\.0\.0\.1|docker-server/)
285
+ })