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
@@ -1,11 +1,10 @@
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, readFile, readdir, 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
  import {connect as connectTls} from "node:tls"
10
9
 
11
10
  import {ReverseGatewayConnection} from "../../src/reverse/client.js"
@@ -46,7 +45,9 @@ async function listen(server) {
46
45
  server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(undefined) })
47
46
  })
48
47
  const address = server.address()
49
- assert(address !== null && typeof address !== "string")
48
+ if (address === null || typeof address === "string") {
49
+ throw new Error("Server did not bind to a TCP port")
50
+ }
50
51
  return address.port
51
52
  }
52
53
 
@@ -164,335 +165,344 @@ async function onceEvent(emitter, event) {
164
165
  })
165
166
  }
166
167
 
167
- test("reserved reverse-hub startup retries a pre-start listen EADDRINUSE without leaking attempts", async (t) => {
168
- const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-port-retry-"))
169
- t.after(() => rm(root, {recursive: true, force: true}))
170
- const certificates = generateTestCertificates(root)
171
- const occupiedListener = createServer()
172
- const occupiedPort = await listen(occupiedListener)
173
- t.after(() => new Promise((resolve) => occupiedListener.close(() => resolve(undefined))))
174
- let reservations = 0
175
- const {child, port, attempts} = await startProcessOnReservedPort({
176
- SOCKETDUCT_MODE: "reverse-hub",
177
- SOCKETDUCT_RELAY_HOST: "127.0.0.1",
178
- SOCKETDUCT_TLS_CERT_FILE: certificates.serverCertFile,
179
- SOCKETDUCT_TLS_KEY_FILE: certificates.serverKeyFile,
180
- SOCKETDUCT_AUTH_TOKENS: "home-o2:process-gateway-token,tensorbuzz:process-client-token",
181
- SOCKETDUCT_REVERSE_GATEWAY_BINDINGS: "o2:home-o2",
182
- SOCKETDUCT_REVERSE_ROUTES: "tensorbuzz:o2:127.0.0.1:1",
183
- SOCKETDUCT_STATUS_INTERVAL_MS: "0"
184
- }, "reverse-hub.started", {
185
- reservePort: async () => {
186
- reservations += 1
187
- return reservations === 1 ? occupiedPort : reservePort()
188
- }
189
- })
190
- t.after(() => stopProcess(child))
168
+ describe("reverse gateway process", () => {
169
+ it("reserved reverse-hub startup retries a pre-start listen EADDRINUSE without leaking attempts", async () => withTestCleanup(async (after) => {
170
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-port-retry-"))
171
+ after(() => rm(root, {recursive: true, force: true}))
172
+ const certificates = generateTestCertificates(root)
173
+ const occupiedListener = createServer()
174
+ const occupiedPort = await listen(occupiedListener)
175
+ after(() => new Promise((resolve) => occupiedListener.close(() => resolve(undefined))))
176
+ let reservations = 0
177
+ const {child, port, attempts} = await startProcessOnReservedPort({
178
+ SOCKETDUCT_MODE: "reverse-hub",
179
+ SOCKETDUCT_RELAY_HOST: "127.0.0.1",
180
+ SOCKETDUCT_TLS_CERT_FILE: certificates.serverCertFile,
181
+ SOCKETDUCT_TLS_KEY_FILE: certificates.serverKeyFile,
182
+ SOCKETDUCT_AUTH_TOKENS: "home-o2:process-gateway-token,tensorbuzz:process-client-token",
183
+ SOCKETDUCT_REVERSE_GATEWAY_BINDINGS: "o2:home-o2",
184
+ SOCKETDUCT_REVERSE_ROUTES: "tensorbuzz:o2:127.0.0.1:1",
185
+ SOCKETDUCT_STATUS_INTERVAL_MS: "0"
186
+ }, "reverse-hub.started", {
187
+ reservePort: async () => {
188
+ reservations += 1
189
+ return reservations === 1 ? occupiedPort : reservePort()
190
+ }
191
+ })
192
+ after(() => stopProcess(child))
191
193
 
192
- assert.equal(reservations, 2)
193
- assert.notEqual(port, occupiedPort)
194
- assert.equal(attempts.length, 2)
195
- const [failedAttempt, successfulAttempt] = attempts
196
- assert(failedAttempt !== undefined)
197
- assert(successfulAttempt !== undefined)
198
- assert(failedAttempt.exitCode !== null || failedAttempt.signalCode !== null)
199
- assert.equal(successfulAttempt, child)
200
- assert.equal(child.exitCode, null)
201
- assert.equal(child.signalCode, null)
202
- assert.equal(occupiedListener.listening, true)
194
+ expect(reservations).toBe(2)
195
+ expect(port).not.toBe(occupiedPort)
196
+ expect(attempts.length).toBe(2)
197
+ const [failedAttempt, successfulAttempt] = attempts
198
+ if (failedAttempt === undefined || successfulAttempt === undefined) throw new Error("Expected two startup attempts")
199
+ expect(failedAttempt.exitCode !== null || failedAttempt.signalCode !== null).toBeTruthy()
200
+ expect(successfulAttempt).toBe(child)
201
+ expect(child.exitCode).toBe(null)
202
+ expect(child.signalCode).toBe(null)
203
+ expect(occupiedListener.listening).toBe(true)
203
204
 
204
- await stopProcess(child)
205
- assert(child.exitCode !== null || child.signalCode !== null)
206
- const releasedPortProbe = createServer()
207
- t.after(() => {
208
- if (!releasedPortProbe.listening) return undefined
209
- return new Promise((resolve) => releasedPortProbe.close(() => resolve(undefined)))
210
- })
211
- await new Promise((resolve, reject) => {
212
- releasedPortProbe.once("error", reject)
213
- releasedPortProbe.listen(port, "127.0.0.1", () => {
214
- releasedPortProbe.off("error", reject)
215
- resolve(undefined)
205
+ await stopProcess(child)
206
+ expect(child.exitCode !== null || child.signalCode !== null).toBeTruthy()
207
+ const releasedPortProbe = createServer()
208
+ after(() => {
209
+ if (!releasedPortProbe.listening) return undefined
210
+ return new Promise((resolve) => releasedPortProbe.close(() => resolve(undefined)))
216
211
  })
217
- })
218
- })
212
+ await new Promise((resolve, reject) => {
213
+ releasedPortProbe.once("error", reject)
214
+ releasedPortProbe.listen(port, "127.0.0.1", () => {
215
+ releasedPortProbe.off("error", reject)
216
+ resolve(undefined)
217
+ })
218
+ })
219
+ }))
219
220
 
220
- test("reserved process startup does not retry an unclassified EADDRINUSE failure", async () => {
221
- const failure = new Error("untrusted EADDRINUSE text is not a listener-bind classification")
222
- let reservations = 0
223
- let starts = 0
224
- await assert.rejects(startProcessOnReservedPort({}, "reverse-hub.started", {
225
- reservePort: async () => {
226
- reservations += 1
227
- return 31_337
228
- },
229
- startProcess: async () => {
230
- starts += 1
231
- throw failure
221
+ it("reserved process startup does not retry an unclassified EADDRINUSE failure", async () => {
222
+ const failure = new Error("untrusted EADDRINUSE text is not a listener-bind classification")
223
+ let reservations = 0
224
+ let starts = 0
225
+ let error
226
+ try {
227
+ await startProcessOnReservedPort({}, "reverse-hub.started", {
228
+ reservePort: async () => {
229
+ reservations += 1
230
+ return 31_337
231
+ },
232
+ startProcess: async () => {
233
+ starts += 1
234
+ throw failure
235
+ }
236
+ })
237
+ } catch (caught) {
238
+ error = caught
232
239
  }
233
- }), (error) => error === failure)
234
- assert.equal(reservations, 1)
235
- assert.equal(starts, 1)
236
- })
237
-
238
- test("spawned reverse hub and outbound gateway expose an allowlisted Docker stream over verified TLS", async (t) => {
239
- const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-process-"))
240
- t.after(() => rm(root, {recursive: true, force: true}))
241
- const certificates = generateTestCertificates(root)
242
- const logDirectory = join(root, "logs")
243
- const targetSockets = new Set()
244
- const target = createServer((socket) => {
245
- targetSockets.add(socket)
246
- socket.once("close", () => targetSockets.delete(socket))
247
- socket.on("data", (chunk) => socket.write(Buffer.concat([Buffer.from("docker:"), chunk])))
248
- })
249
- const targetPort = await listen(target)
250
- t.after(() => {
251
- for (const socket of targetSockets) socket.destroy()
252
- return new Promise((resolve) => target.close(() => resolve(undefined)))
240
+ expect(error).toBeDefined()
241
+ expect(error === failure).toBeTruthy()
242
+ expect(reservations).toBe(1)
243
+ expect(starts).toBe(1)
253
244
  })
254
- const {child: hub, port: hubPort} = await startProcessOnReservedPort({
255
- SOCKETDUCT_MODE: "reverse-hub",
256
- SOCKETDUCT_RELAY_HOST: "127.0.0.1",
257
- SOCKETDUCT_TLS_CERT_FILE: certificates.serverCertFile,
258
- SOCKETDUCT_TLS_KEY_FILE: certificates.serverKeyFile,
259
- SOCKETDUCT_AUTH_TOKENS: "home-o2:process-gateway-token,tensorbuzz:process-client-token",
260
- SOCKETDUCT_REVERSE_GATEWAY_BINDINGS: "o2:home-o2",
261
- SOCKETDUCT_REVERSE_ROUTES: `tensorbuzz:o2:127.0.0.1:${targetPort}`,
262
- SOCKETDUCT_STATUS_INTERVAL_MS: "0",
263
- SOCKETDUCT_LOG_DIRECTORY: logDirectory,
264
- SOCKETDUCT_LOG_SEGMENT_MAX_BYTES: "4096",
265
- SOCKETDUCT_CONNECTION_FINGERPRINT_KEY: TEST_FINGERPRINT_KEY,
266
- SOCKETDUCT_CONNECTION_FINGERPRINT_KEY_EPOCH: "process-test"
267
- }, "reverse-hub.started")
268
- t.after(() => stopProcess(hub))
269
- const gateway = await startProcess({
270
- SOCKETDUCT_MODE: "reverse-gateway",
271
- SOCKETDUCT_REVERSE_GATEWAY_ID: "o2",
272
- SOCKETDUCT_REVERSE_CONNECTION_ID: "process_home_o2_gateway",
273
- SOCKETDUCT_REVERSE_HUB_HOST: "127.0.0.1",
274
- SOCKETDUCT_REVERSE_HUB_PORT: String(hubPort),
275
- SOCKETDUCT_REVERSE_TOKEN: "process-gateway-token",
276
- SOCKETDUCT_REVERSE_HUB_CA_FILE: certificates.caCertFile,
277
- SOCKETDUCT_REVERSE_HUB_SERVERNAME: "localhost",
278
- SOCKETDUCT_TARGET_ALLOWLIST: `127.0.0.1:${targetPort}`,
279
- SOCKETDUCT_STATUS_INTERVAL_MS: "0",
280
- SOCKETDUCT_LOG_DIRECTORY: logDirectory,
281
- SOCKETDUCT_LOG_SEGMENT_MAX_BYTES: "4096"
282
- }, "reverse-gateway.started")
283
- t.after(() => stopProcess(gateway))
284
245
 
285
- const client = new ReverseGatewayConnection({
286
- gatewayId: "o2",
287
- connectionId: "process_tensorbuzz_client",
288
- hub: {host: "127.0.0.1", port: hubPort, token: "process-client-token"},
289
- tls: {caFile: certificates.caCertFile, servername: "localhost"}
290
- })
291
- t.after(() => client.close())
292
- await withTimeout(client.connect(), "process client connect")
293
- const socket = client.createConnection({host: "127.0.0.1", port: targetPort, sessionName: "docker-version-process"})
294
- await withTimeout(onceEvent(socket, "connect"), "process stream open")
295
- const response = new Promise((resolve, reject) => { socket.once("error", reject); socket.once("data", resolve) })
296
- socket.write("GET /version")
297
- assert.equal(Buffer.from(/** @type {Uint8Array} */ (await withTimeout(response, "process response"))).toString(), "docker:GET /version")
298
- assert.equal(client.status().compression, "zstd")
299
- socket.destroy()
300
- client.close()
301
- await stopProcess(gateway)
302
- await stopProcess(hub)
246
+ it("spawned reverse hub and outbound gateway expose an allowlisted Docker stream over verified TLS", async () => withTestCleanup(async (after) => {
247
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-process-"))
248
+ after(() => rm(root, {recursive: true, force: true}))
249
+ const certificates = generateTestCertificates(root)
250
+ const logDirectory = join(root, "logs")
251
+ const targetSockets = new Set()
252
+ const target = createServer((socket) => {
253
+ targetSockets.add(socket)
254
+ socket.once("close", () => targetSockets.delete(socket))
255
+ socket.on("data", (chunk) => socket.write(Buffer.concat([Buffer.from("docker:"), chunk])))
256
+ })
257
+ const targetPort = await listen(target)
258
+ after(() => {
259
+ for (const socket of targetSockets) socket.destroy()
260
+ return new Promise((resolve) => target.close(() => resolve(undefined)))
261
+ })
262
+ const {child: hub, port: hubPort} = await startProcessOnReservedPort({
263
+ SOCKETDUCT_MODE: "reverse-hub",
264
+ SOCKETDUCT_RELAY_HOST: "127.0.0.1",
265
+ SOCKETDUCT_TLS_CERT_FILE: certificates.serverCertFile,
266
+ SOCKETDUCT_TLS_KEY_FILE: certificates.serverKeyFile,
267
+ SOCKETDUCT_AUTH_TOKENS: "home-o2:process-gateway-token,tensorbuzz:process-client-token",
268
+ SOCKETDUCT_REVERSE_GATEWAY_BINDINGS: "o2:home-o2",
269
+ SOCKETDUCT_REVERSE_ROUTES: `tensorbuzz:o2:127.0.0.1:${targetPort}`,
270
+ SOCKETDUCT_STATUS_INTERVAL_MS: "0",
271
+ SOCKETDUCT_LOG_DIRECTORY: logDirectory,
272
+ SOCKETDUCT_LOG_SEGMENT_MAX_BYTES: "4096",
273
+ SOCKETDUCT_CONNECTION_FINGERPRINT_KEY: TEST_FINGERPRINT_KEY,
274
+ SOCKETDUCT_CONNECTION_FINGERPRINT_KEY_EPOCH: "process-test"
275
+ }, "reverse-hub.started")
276
+ after(() => stopProcess(hub))
277
+ const gateway = await startProcess({
278
+ SOCKETDUCT_MODE: "reverse-gateway",
279
+ SOCKETDUCT_REVERSE_GATEWAY_ID: "o2",
280
+ SOCKETDUCT_REVERSE_CONNECTION_ID: "process_home_o2_gateway",
281
+ SOCKETDUCT_REVERSE_HUB_HOST: "127.0.0.1",
282
+ SOCKETDUCT_REVERSE_HUB_PORT: String(hubPort),
283
+ SOCKETDUCT_REVERSE_TOKEN: "process-gateway-token",
284
+ SOCKETDUCT_REVERSE_HUB_CA_FILE: certificates.caCertFile,
285
+ SOCKETDUCT_REVERSE_HUB_SERVERNAME: "localhost",
286
+ SOCKETDUCT_TARGET_ALLOWLIST: `127.0.0.1:${targetPort}`,
287
+ SOCKETDUCT_STATUS_INTERVAL_MS: "0",
288
+ SOCKETDUCT_LOG_DIRECTORY: logDirectory,
289
+ SOCKETDUCT_LOG_SEGMENT_MAX_BYTES: "4096"
290
+ }, "reverse-gateway.started")
291
+ after(() => stopProcess(gateway))
303
292
 
304
- const logs = await readCompressedLogDirectory(logDirectory)
305
- assert.equal(logs.incomplete, false)
306
- const hubEntries = logs.records.filter(({role}) => role === "reverse-hub")
307
- const gatewayEntries = logs.records.filter(({role}) => role === "reverse-gateway")
308
- assert(hubEntries.some(({event}) => event === "process.started"))
309
- assert(gatewayEntries.some(({event}) => event === "process.started"))
310
- assert(gatewayEntries.some(({event}) => event === "transport.connected"))
311
- assert.doesNotMatch(JSON.stringify(logs.records), /process-gateway-token|127\.0\.0\.1|docker-server|process_home_o2_gateway/)
312
- })
293
+ const client = new ReverseGatewayConnection({
294
+ gatewayId: "o2",
295
+ connectionId: "process_tensorbuzz_client",
296
+ hub: {host: "127.0.0.1", port: hubPort, token: "process-client-token"},
297
+ tls: {caFile: certificates.caCertFile, servername: "localhost"}
298
+ })
299
+ after(() => client.close())
300
+ await withTimeout(client.connect(), "process client connect")
301
+ const socket = client.createConnection({host: "127.0.0.1", port: targetPort, sessionName: "docker-version-process"})
302
+ await withTimeout(onceEvent(socket, "connect"), "process stream open")
303
+ const response = new Promise((resolve, reject) => { socket.once("error", reject); socket.once("data", resolve) })
304
+ socket.write("GET /version")
305
+ expect(Buffer.from(/** @type {Uint8Array} */ (await withTimeout(response, "process response"))).toString()).toBe("docker:GET /version")
306
+ expect(client.status().compression).toBe("zstd")
307
+ socket.destroy()
308
+ client.close()
309
+ await stopProcess(gateway)
310
+ await stopProcess(hub)
313
311
 
314
- test("one route-class client physical connection interleaves streams through different gateway processes", async (t) => {
315
- const root = await mkdtemp(join(tmpdir(), "socketduct-route-class-process-"))
316
- t.after(() => rm(root, {recursive: true, force: true}))
317
- const certificates = generateTestCertificates(root)
318
- const targetSockets = new Set()
319
- const targetA = createServer((socket) => {
320
- targetSockets.add(socket)
321
- socket.once("close", () => targetSockets.delete(socket))
322
- socket.on("data", (chunk) => socket.write(Buffer.concat([Buffer.from("a:"), chunk])))
323
- })
324
- const targetB = createServer((socket) => {
325
- targetSockets.add(socket)
326
- socket.once("close", () => targetSockets.delete(socket))
327
- socket.on("data", (chunk) => socket.write(Buffer.concat([Buffer.from("b:"), chunk])))
328
- })
329
- const targetAPort = await listen(targetA)
330
- const targetBPort = await listen(targetB)
331
- t.after(() => {
332
- for (const socket of targetSockets) socket.destroy()
333
- return Promise.all([
334
- new Promise((resolve) => targetA.close(() => resolve(undefined))),
335
- new Promise((resolve) => targetB.close(() => resolve(undefined)))
336
- ])
337
- })
312
+ const logs = await readCompressedLogDirectory(logDirectory)
313
+ expect(logs.incomplete).toBe(false)
314
+ const hubEntries = logs.records.filter(({role}) => role === "reverse-hub")
315
+ const gatewayEntries = logs.records.filter(({role}) => role === "reverse-gateway")
316
+ expect(hubEntries.some(({event}) => event === "process.started")).toBeTruthy()
317
+ expect(gatewayEntries.some(({event}) => event === "process.started")).toBeTruthy()
318
+ expect(gatewayEntries.some(({event}) => event === "transport.connected")).toBeTruthy()
319
+ expect(JSON.stringify(logs.records)).not.toMatch(/process-gateway-token|127\.0\.0\.1|docker-server|process_home_o2_gateway/)
320
+ }))
338
321
 
339
- const {child: hub, port: hubPort} = await startProcessOnReservedPort({
340
- SOCKETDUCT_MODE: "reverse-hub",
341
- SOCKETDUCT_RELAY_HOST: "127.0.0.1",
342
- SOCKETDUCT_TLS_CERT_FILE: certificates.serverCertFile,
343
- SOCKETDUCT_TLS_KEY_FILE: certificates.serverKeyFile,
344
- SOCKETDUCT_AUTH_TOKENS: "home-a:gateway-a-token,home-b:gateway-b-token,tensorbuzz:client-token",
345
- SOCKETDUCT_REVERSE_GATEWAY_BINDINGS: "server-a-starlink:home-a,server-b-starlink:home-b",
346
- SOCKETDUCT_REVERSE_ROUTE_CLASS_ROUTES: `tensorbuzz:starlink:server-a-starlink:127.0.0.1:${targetAPort},tensorbuzz:starlink:server-b-starlink:127.0.0.1:${targetBPort}`,
347
- SOCKETDUCT_STATUS_INTERVAL_MS: "0"
348
- }, "reverse-hub.started")
349
- t.after(() => stopProcess(hub))
322
+ it("one route-class client physical connection interleaves streams through different gateway processes", async () => withTestCleanup(async (after) => {
323
+ const root = await mkdtemp(join(tmpdir(), "socketduct-route-class-process-"))
324
+ after(() => rm(root, {recursive: true, force: true}))
325
+ const certificates = generateTestCertificates(root)
326
+ const targetSockets = new Set()
327
+ const targetA = createServer((socket) => {
328
+ targetSockets.add(socket)
329
+ socket.once("close", () => targetSockets.delete(socket))
330
+ socket.on("data", (chunk) => socket.write(Buffer.concat([Buffer.from("a:"), chunk])))
331
+ })
332
+ const targetB = createServer((socket) => {
333
+ targetSockets.add(socket)
334
+ socket.once("close", () => targetSockets.delete(socket))
335
+ socket.on("data", (chunk) => socket.write(Buffer.concat([Buffer.from("b:"), chunk])))
336
+ })
337
+ const targetAPort = await listen(targetA)
338
+ const targetBPort = await listen(targetB)
339
+ after(() => {
340
+ for (const socket of targetSockets) socket.destroy()
341
+ return Promise.all([
342
+ new Promise((resolve) => targetA.close(() => resolve(undefined))),
343
+ new Promise((resolve) => targetB.close(() => resolve(undefined)))
344
+ ]).then(() => undefined)
345
+ })
350
346
 
351
- const gatewayA = await startProcess({
352
- SOCKETDUCT_MODE: "reverse-gateway",
353
- SOCKETDUCT_REVERSE_GATEWAY_ID: "server-a-starlink",
354
- SOCKETDUCT_REVERSE_CONNECTION_ID: "route_class_process_gateway_a",
355
- SOCKETDUCT_REVERSE_HUB_HOST: "127.0.0.1",
356
- SOCKETDUCT_REVERSE_HUB_PORT: String(hubPort),
357
- SOCKETDUCT_REVERSE_TOKEN: "gateway-a-token",
358
- SOCKETDUCT_REVERSE_HUB_CA_FILE: certificates.caCertFile,
359
- SOCKETDUCT_REVERSE_HUB_SERVERNAME: "localhost",
360
- SOCKETDUCT_TARGET_ALLOWLIST: `127.0.0.1:${targetAPort}`,
361
- SOCKETDUCT_STATUS_INTERVAL_MS: "0"
362
- }, "reverse-gateway.started")
363
- t.after(() => stopProcess(gatewayA))
364
- const gatewayB = await startProcess({
365
- SOCKETDUCT_MODE: "reverse-gateway",
366
- SOCKETDUCT_REVERSE_GATEWAY_ID: "server-b-starlink",
367
- SOCKETDUCT_REVERSE_CONNECTION_ID: "route_class_process_gateway_b",
368
- SOCKETDUCT_REVERSE_HUB_HOST: "127.0.0.1",
369
- SOCKETDUCT_REVERSE_HUB_PORT: String(hubPort),
370
- SOCKETDUCT_REVERSE_TOKEN: "gateway-b-token",
371
- SOCKETDUCT_REVERSE_HUB_CA_FILE: certificates.caCertFile,
372
- SOCKETDUCT_REVERSE_HUB_SERVERNAME: "localhost",
373
- SOCKETDUCT_TARGET_ALLOWLIST: `127.0.0.1:${targetBPort}`,
374
- SOCKETDUCT_STATUS_INTERVAL_MS: "0"
375
- }, "reverse-gateway.started")
376
- t.after(() => stopProcess(gatewayB))
347
+ const {child: hub, port: hubPort} = await startProcessOnReservedPort({
348
+ SOCKETDUCT_MODE: "reverse-hub",
349
+ SOCKETDUCT_RELAY_HOST: "127.0.0.1",
350
+ SOCKETDUCT_TLS_CERT_FILE: certificates.serverCertFile,
351
+ SOCKETDUCT_TLS_KEY_FILE: certificates.serverKeyFile,
352
+ SOCKETDUCT_AUTH_TOKENS: "home-a:gateway-a-token,home-b:gateway-b-token,tensorbuzz:client-token",
353
+ SOCKETDUCT_REVERSE_GATEWAY_BINDINGS: "server-a-starlink:home-a,server-b-starlink:home-b",
354
+ SOCKETDUCT_REVERSE_ROUTE_CLASS_ROUTES: `tensorbuzz:starlink:server-a-starlink:127.0.0.1:${targetAPort},tensorbuzz:starlink:server-b-starlink:127.0.0.1:${targetBPort}`,
355
+ SOCKETDUCT_STATUS_INTERVAL_MS: "0"
356
+ }, "reverse-hub.started")
357
+ after(() => stopProcess(hub))
377
358
 
378
- let clientPhysicalConnections = 0
379
- const client = new ReverseGatewayConnection({
380
- routeClass: "starlink",
381
- connectionId: "route_class_process_client",
382
- hub: {host: "127.0.0.1", port: hubPort, token: "client-token"},
383
- tls: {caFile: certificates.caCertFile, servername: "localhost"},
384
- createPhysicalSocket: ({host, port, tls}) => {
385
- clientPhysicalConnections += 1
386
- return connectTls({host, port, ...tls})
387
- }
388
- })
389
- t.after(() => client.close())
390
- await withTimeout(client.connect(), "route-class process client connect")
391
- const streamA = client.createConnection({host: "127.0.0.1", port: targetAPort, sessionName: "route-class-a"})
392
- const streamB = client.createConnection({host: "127.0.0.1", port: targetBPort, sessionName: "route-class-b"})
393
- await withTimeout(Promise.all([onceEvent(streamA, "connect"), onceEvent(streamB, "connect")]), "route-class process streams")
394
- const responseA = new Promise((resolve, reject) => { streamA.once("error", reject); streamA.once("data", resolve) })
395
- const responseB = new Promise((resolve, reject) => { streamB.once("error", reject); streamB.once("data", resolve) })
396
- streamA.write("one")
397
- streamB.write("two")
398
- assert.equal(Buffer.from(/** @type {Uint8Array} */ (await withTimeout(responseA, "route-class response A"))).toString(), "a:one")
399
- assert.equal(Buffer.from(/** @type {Uint8Array} */ (await withTimeout(responseB, "route-class response B"))).toString(), "b:two")
400
- assert.equal(clientPhysicalConnections, 1)
401
- assert.equal(client.status().physicalConnects, 1)
402
- streamA.destroy()
403
- streamB.destroy()
404
- })
359
+ const gatewayA = await startProcess({
360
+ SOCKETDUCT_MODE: "reverse-gateway",
361
+ SOCKETDUCT_REVERSE_GATEWAY_ID: "server-a-starlink",
362
+ SOCKETDUCT_REVERSE_CONNECTION_ID: "route_class_process_gateway_a",
363
+ SOCKETDUCT_REVERSE_HUB_HOST: "127.0.0.1",
364
+ SOCKETDUCT_REVERSE_HUB_PORT: String(hubPort),
365
+ SOCKETDUCT_REVERSE_TOKEN: "gateway-a-token",
366
+ SOCKETDUCT_REVERSE_HUB_CA_FILE: certificates.caCertFile,
367
+ SOCKETDUCT_REVERSE_HUB_SERVERNAME: "localhost",
368
+ SOCKETDUCT_TARGET_ALLOWLIST: `127.0.0.1:${targetAPort}`,
369
+ SOCKETDUCT_STATUS_INTERVAL_MS: "0"
370
+ }, "reverse-gateway.started")
371
+ after(() => stopProcess(gatewayA))
372
+ const gatewayB = await startProcess({
373
+ SOCKETDUCT_MODE: "reverse-gateway",
374
+ SOCKETDUCT_REVERSE_GATEWAY_ID: "server-b-starlink",
375
+ SOCKETDUCT_REVERSE_CONNECTION_ID: "route_class_process_gateway_b",
376
+ SOCKETDUCT_REVERSE_HUB_HOST: "127.0.0.1",
377
+ SOCKETDUCT_REVERSE_HUB_PORT: String(hubPort),
378
+ SOCKETDUCT_REVERSE_TOKEN: "gateway-b-token",
379
+ SOCKETDUCT_REVERSE_HUB_CA_FILE: certificates.caCertFile,
380
+ SOCKETDUCT_REVERSE_HUB_SERVERNAME: "localhost",
381
+ SOCKETDUCT_TARGET_ALLOWLIST: `127.0.0.1:${targetBPort}`,
382
+ SOCKETDUCT_STATUS_INTERVAL_MS: "0"
383
+ }, "reverse-gateway.started")
384
+ after(() => stopProcess(gatewayB))
405
385
 
406
- test("outbound gateway process spools durably under SOCKETDUCT_SPOOL_DIRECTORY when the hub link is lost", async (t) => {
407
- const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-spool-process-"))
408
- t.after(() => rm(root, {recursive: true, force: true}))
409
- const certificates = generateTestCertificates(root)
410
- const gatewaySpoolDirectory = join(root, "gateway-spool")
411
- const clientSpoolDirectory = join(root, "client-spool")
412
- /** @type {import('node:net').Socket | undefined} */
413
- let targetSocket
414
- const targetSockets = new Set()
415
- const target = createServer((socket) => {
416
- targetSocket = socket
417
- targetSockets.add(socket)
418
- socket.once("close", () => targetSockets.delete(socket))
419
- socket.on("data", (chunk) => socket.write(Buffer.concat([Buffer.from("docker:"), chunk])))
420
- })
421
- const targetPort = await listen(target)
422
- t.after(() => {
423
- for (const socket of targetSockets) socket.destroy()
424
- return new Promise((resolve) => target.close(() => resolve(undefined)))
425
- })
426
- const {child: hub, port: hubPort} = await startProcessOnReservedPort({
427
- SOCKETDUCT_MODE: "reverse-hub",
428
- SOCKETDUCT_RELAY_HOST: "127.0.0.1",
429
- SOCKETDUCT_TLS_CERT_FILE: certificates.serverCertFile,
430
- SOCKETDUCT_TLS_KEY_FILE: certificates.serverKeyFile,
431
- SOCKETDUCT_AUTH_TOKENS: "home-o2:spool-gateway-token,tensorbuzz:spool-client-token",
432
- SOCKETDUCT_REVERSE_GATEWAY_BINDINGS: "o2:home-o2",
433
- SOCKETDUCT_REVERSE_ROUTES: `tensorbuzz:o2:127.0.0.1:${targetPort}`,
434
- SOCKETDUCT_STATUS_INTERVAL_MS: "0"
435
- }, "reverse-hub.started")
436
- const gateway = await startProcess({
437
- SOCKETDUCT_MODE: "reverse-gateway",
438
- SOCKETDUCT_REVERSE_GATEWAY_ID: "o2",
439
- SOCKETDUCT_REVERSE_CONNECTION_ID: "spool_process_gateway",
440
- SOCKETDUCT_REVERSE_HUB_HOST: "127.0.0.1",
441
- SOCKETDUCT_REVERSE_HUB_PORT: String(hubPort),
442
- SOCKETDUCT_REVERSE_TOKEN: "spool-gateway-token",
443
- SOCKETDUCT_REVERSE_HUB_CA_FILE: certificates.caCertFile,
444
- SOCKETDUCT_REVERSE_HUB_SERVERNAME: "localhost",
445
- SOCKETDUCT_TARGET_ALLOWLIST: `127.0.0.1:${targetPort}`,
446
- SOCKETDUCT_STATUS_INTERVAL_MS: "0",
447
- SOCKETDUCT_SPOOL_DIRECTORY: gatewaySpoolDirectory
448
- }, "reverse-gateway.started")
449
- t.after(() => stopProcess(gateway))
450
- const client = new ReverseGatewayConnection({
451
- gatewayId: "o2",
452
- connectionId: "spool_process_client",
453
- hub: {host: "127.0.0.1", port: hubPort, token: "spool-client-token"},
454
- tls: {caFile: certificates.caCertFile, servername: "localhost"},
455
- spoolDirectory: clientSpoolDirectory,
456
- reconnectDelayMs: 50,
457
- maxReconnectDelayMs: 100
458
- })
459
- t.after(() => client.close())
460
- await withTimeout(client.connect(), "spool client connect")
461
- const socket = client.createConnection({host: "127.0.0.1", port: targetPort, sessionName: "docker-spool-process"})
462
- await withTimeout(onceEvent(socket, "connect"), "spool stream open")
463
- const initialResponse = new Promise((resolve, reject) => { socket.once("error", reject); socket.once("data", resolve) })
464
- socket.write("GET /version")
465
- assert.equal(Buffer.from(/** @type {Uint8Array} */ (await withTimeout(initialResponse, "spool initial response"))).toString(), "docker:GET /version")
386
+ let clientPhysicalConnections = 0
387
+ const client = new ReverseGatewayConnection({
388
+ routeClass: "starlink",
389
+ connectionId: "route_class_process_client",
390
+ hub: {host: "127.0.0.1", port: hubPort, token: "client-token"},
391
+ tls: {caFile: certificates.caCertFile, servername: "localhost"},
392
+ createPhysicalSocket: ({host, port, tls}) => {
393
+ clientPhysicalConnections += 1
394
+ return connectTls({host, port, ...tls})
395
+ }
396
+ })
397
+ after(() => client.close())
398
+ await withTimeout(client.connect(), "route-class process client connect")
399
+ const streamA = client.createConnection({host: "127.0.0.1", port: targetAPort, sessionName: "route-class-a"})
400
+ const streamB = client.createConnection({host: "127.0.0.1", port: targetBPort, sessionName: "route-class-b"})
401
+ await withTimeout(Promise.all([onceEvent(streamA, "connect"), onceEvent(streamB, "connect")]), "route-class process streams")
402
+ const responseA = new Promise((resolve, reject) => { streamA.once("error", reject); streamA.once("data", resolve) })
403
+ const responseB = new Promise((resolve, reject) => { streamB.once("error", reject); streamB.once("data", resolve) })
404
+ streamA.write("one")
405
+ streamB.write("two")
406
+ expect(Buffer.from(/** @type {Uint8Array} */ (await withTimeout(responseA, "route-class response A"))).toString()).toBe("a:one")
407
+ expect(Buffer.from(/** @type {Uint8Array} */ (await withTimeout(responseB, "route-class response B"))).toString()).toBe("b:two")
408
+ expect(clientPhysicalConnections).toBe(1)
409
+ expect(client.status().physicalConnects).toBe(1)
410
+ streamA.destroy()
411
+ streamB.destroy()
412
+ }))
466
413
 
467
- hub.kill("SIGKILL")
468
- await withTimeout(new Promise((resolve) => hub.once("exit", () => resolve(undefined))), "hub exit")
469
- assert(targetSocket !== undefined)
470
- const spooledMarker = Buffer.alloc(32, 0x61)
471
- targetSocket.write(spooledMarker)
472
- const spoolHasPayload = async () => {
473
- const markerBase64 = spooledMarker.toString("base64")
474
- for (const session of await readDirectoryOrEmpty(gatewaySpoolDirectory)) {
475
- const files = await readDirectoryOrEmpty(join(gatewaySpoolDirectory, session))
476
- if (!files.includes("chunks.jsonl")) continue
477
- const lines = (await readFile(join(gatewaySpoolDirectory, session, "chunks.jsonl"), "utf8")).split("\n")
478
- if (lines.some((line) => {
479
- if (line === "") return false
480
- try {
481
- return /** @type {{dataBase64?: unknown}} */ (JSON.parse(line)).dataBase64 === markerBase64
482
- } catch (error) {
483
- if (error instanceof SyntaxError) return false
484
- throw error
485
- }
486
- })) return true
414
+ it("outbound gateway process spools durably under SOCKETDUCT_SPOOL_DIRECTORY when the hub link is lost", async () => withTestCleanup(async (after) => {
415
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-spool-process-"))
416
+ after(() => rm(root, {recursive: true, force: true}))
417
+ const certificates = generateTestCertificates(root)
418
+ const gatewaySpoolDirectory = join(root, "gateway-spool")
419
+ const clientSpoolDirectory = join(root, "client-spool")
420
+ /** @type {import('node:net').Socket | undefined} */
421
+ let targetSocket
422
+ const targetSockets = new Set()
423
+ const target = createServer((socket) => {
424
+ targetSocket = socket
425
+ targetSockets.add(socket)
426
+ socket.once("close", () => targetSockets.delete(socket))
427
+ socket.on("data", (chunk) => socket.write(Buffer.concat([Buffer.from("docker:"), chunk])))
428
+ })
429
+ const targetPort = await listen(target)
430
+ after(() => {
431
+ for (const socket of targetSockets) socket.destroy()
432
+ return new Promise((resolve) => target.close(() => resolve(undefined)))
433
+ })
434
+ const {child: hub, port: hubPort} = await startProcessOnReservedPort({
435
+ SOCKETDUCT_MODE: "reverse-hub",
436
+ SOCKETDUCT_RELAY_HOST: "127.0.0.1",
437
+ SOCKETDUCT_TLS_CERT_FILE: certificates.serverCertFile,
438
+ SOCKETDUCT_TLS_KEY_FILE: certificates.serverKeyFile,
439
+ SOCKETDUCT_AUTH_TOKENS: "home-o2:spool-gateway-token,tensorbuzz:spool-client-token",
440
+ SOCKETDUCT_REVERSE_GATEWAY_BINDINGS: "o2:home-o2",
441
+ SOCKETDUCT_REVERSE_ROUTES: `tensorbuzz:o2:127.0.0.1:${targetPort}`,
442
+ SOCKETDUCT_STATUS_INTERVAL_MS: "0"
443
+ }, "reverse-hub.started")
444
+ const gateway = await startProcess({
445
+ SOCKETDUCT_MODE: "reverse-gateway",
446
+ SOCKETDUCT_REVERSE_GATEWAY_ID: "o2",
447
+ SOCKETDUCT_REVERSE_CONNECTION_ID: "spool_process_gateway",
448
+ SOCKETDUCT_REVERSE_HUB_HOST: "127.0.0.1",
449
+ SOCKETDUCT_REVERSE_HUB_PORT: String(hubPort),
450
+ SOCKETDUCT_REVERSE_TOKEN: "spool-gateway-token",
451
+ SOCKETDUCT_REVERSE_HUB_CA_FILE: certificates.caCertFile,
452
+ SOCKETDUCT_REVERSE_HUB_SERVERNAME: "localhost",
453
+ SOCKETDUCT_TARGET_ALLOWLIST: `127.0.0.1:${targetPort}`,
454
+ SOCKETDUCT_STATUS_INTERVAL_MS: "0",
455
+ SOCKETDUCT_SPOOL_DIRECTORY: gatewaySpoolDirectory
456
+ }, "reverse-gateway.started")
457
+ after(() => stopProcess(gateway))
458
+ const client = new ReverseGatewayConnection({
459
+ gatewayId: "o2",
460
+ connectionId: "spool_process_client",
461
+ hub: {host: "127.0.0.1", port: hubPort, token: "spool-client-token"},
462
+ tls: {caFile: certificates.caCertFile, servername: "localhost"},
463
+ spoolDirectory: clientSpoolDirectory,
464
+ reconnectDelayMs: 50,
465
+ maxReconnectDelayMs: 100
466
+ })
467
+ after(() => client.close())
468
+ await withTimeout(client.connect(), "spool client connect")
469
+ const socket = client.createConnection({host: "127.0.0.1", port: targetPort, sessionName: "docker-spool-process"})
470
+ await withTimeout(onceEvent(socket, "connect"), "spool stream open")
471
+ const initialResponse = new Promise((resolve, reject) => { socket.once("error", reject); socket.once("data", resolve) })
472
+ socket.write("GET /version")
473
+ expect(Buffer.from(/** @type {Uint8Array} */ (await withTimeout(initialResponse, "spool initial response"))).toString()).toBe("docker:GET /version")
474
+
475
+ hub.kill("SIGKILL")
476
+ await withTimeout(new Promise((resolve) => hub.once("exit", () => resolve(undefined))), "hub exit")
477
+ if (targetSocket === undefined) throw new Error("Expected a live target socket")
478
+ const spooledMarker = Buffer.alloc(32, 0x61)
479
+ targetSocket.write(spooledMarker)
480
+ const spoolHasPayload = async () => {
481
+ const markerBase64 = spooledMarker.toString("base64")
482
+ for (const session of await readDirectoryOrEmpty(gatewaySpoolDirectory)) {
483
+ const files = await readDirectoryOrEmpty(join(gatewaySpoolDirectory, session))
484
+ if (!files.includes("chunks.jsonl")) continue
485
+ const lines = (await readFile(join(gatewaySpoolDirectory, session, "chunks.jsonl"), "utf8")).split("\n")
486
+ if (lines.some((line) => {
487
+ if (line === "") return false
488
+ try {
489
+ return /** @type {{dataBase64?: unknown}} */ (JSON.parse(line)).dataBase64 === markerBase64
490
+ } catch (error) {
491
+ if (error instanceof SyntaxError) return false
492
+ throw error
493
+ }
494
+ })) return true
495
+ }
496
+ return false
487
497
  }
488
- return false
489
- }
490
- await waitUntil(spoolHasPayload, "durable spool chunk under SOCKETDUCT_SPOOL_DIRECTORY")
491
- const defaultGatewaySpoolRoot = join(tmpdir(), `socketduct-${typeof process.getuid === "function" ? process.getuid() : "user"}`, "reverse-gateway")
492
- const defaultSpooled = (await readDirectoryOrEmpty(defaultGatewaySpoolRoot)).some((entry) => entry.startsWith("o2-spool_process_gateway"))
493
- assert.equal(defaultSpooled, false, "spool must not fall back to the ephemeral default root")
498
+ await waitUntil(spoolHasPayload, "durable spool chunk under SOCKETDUCT_SPOOL_DIRECTORY")
499
+ const defaultGatewaySpoolRoot = join(tmpdir(), `socketduct-${typeof process.getuid === "function" ? process.getuid() : "user"}`, "reverse-gateway")
500
+ const defaultSpooled = (await readDirectoryOrEmpty(defaultGatewaySpoolRoot)).some((entry) => entry.startsWith("o2-spool_process_gateway"))
501
+ // Spool must not fall back to the ephemeral default root.
502
+ expect(defaultSpooled).toBe(false)
494
503
 
495
- socket.destroy()
496
- client.close()
497
- await stopProcess(gateway)
504
+ socket.destroy()
505
+ client.close()
506
+ await stopProcess(gateway)
507
+ }))
498
508
  })