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,1293 @@
1
+ /* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-param, jsdoc/require-returns */
2
+ import assert from "node:assert/strict"
3
+ import {mkdtemp, rm} from "node:fs/promises"
4
+ import {connect as connectTcp, createServer} from "node:net"
5
+ import {tmpdir} from "node:os"
6
+ import {join} from "node:path"
7
+ import {Duplex} from "node:stream"
8
+ import test from "node:test"
9
+ import {connect as connectTls} from "node:tls"
10
+
11
+ import {StaticTokenStore} from "../../src/auth/static-token-store.js"
12
+ import {MultiplexedSocketductTransportSet} from "../../src/client/multiplexed-transport-set.js"
13
+ import {encodeV3Control, encodeV3Frame, V3FrameType} from "../../src/protocol/v3-frame.js"
14
+ import {OutboundReverseGateway, ReverseGatewayConnection, startReverseGatewayHub} from "../../src/reverse/index.js"
15
+ import {generateTestCertificates} from "../helpers/tls-certificates.js"
16
+
17
+ /** @param {import('node:net').Server} server - Server. */
18
+ async function listen(server) {
19
+ await new Promise((resolve, reject) => {
20
+ server.once("error", reject)
21
+ server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(undefined) })
22
+ })
23
+ const address = server.address()
24
+ assert(address !== null && typeof address !== "string")
25
+ return address.port
26
+ }
27
+
28
+ /** @param {import('node:events').EventEmitter} emitter - Emitter. @param {string} event - Event. */
29
+ async function onceEvent(emitter, event) {
30
+ await new Promise((resolve, reject) => {
31
+ emitter.once("error", reject)
32
+ emitter.once(event, () => { emitter.off("error", reject); resolve(undefined) })
33
+ })
34
+ }
35
+
36
+ /** @param {import('node:stream').Duplex} socket - Socket. @param {number} bytes - Expected bytes. */
37
+ async function readBytes(socket, bytes) {
38
+ return await new Promise((resolve, reject) => {
39
+ /** @type {Buffer[]} */
40
+ const chunks = []
41
+ let length = 0
42
+ const cleanup = () => { socket.off("data", onData); socket.off("error", onError) }
43
+ /** @param {Error} error - Stream failure. */
44
+ const onError = (error) => { cleanup(); reject(error) }
45
+ /** @param {Buffer} chunk - Stream bytes. */
46
+ const onData = (chunk) => {
47
+ chunks.push(chunk)
48
+ length += chunk.length
49
+ if (length < bytes) return
50
+ cleanup()
51
+ resolve(Buffer.concat(chunks))
52
+ }
53
+ socket.on("data", onData)
54
+ socket.on("error", onError)
55
+ })
56
+ }
57
+
58
+ /** @param {import('node:stream').Duplex} socket - Stream. @returns {Promise<Buffer>} All bytes through EOF. */
59
+ async function readToEnd(socket) {
60
+ return await new Promise((resolve, reject) => {
61
+ /** @type {Buffer[]} */
62
+ const chunks = []
63
+ socket.on("data", (chunk) => chunks.push(chunk))
64
+ socket.once("end", () => resolve(Buffer.concat(chunks)))
65
+ socket.once("error", reject)
66
+ })
67
+ }
68
+
69
+ /** @param {Promise<unknown>} promise - Work. @param {string} label - Timeout label. */
70
+ async function withTimeout(promise, label) {
71
+ let timer
72
+ try {
73
+ return await Promise.race([
74
+ promise,
75
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`Timed out: ${label}`)), 2_000) })
76
+ ])
77
+ } finally {
78
+ if (timer !== undefined) clearTimeout(timer)
79
+ }
80
+ }
81
+
82
+ /** @param {import('node:stream').Duplex} socket - Stream. @returns {Promise<Error & {code?: string}>} Next stream error. */
83
+ async function nextError(socket) {
84
+ return await new Promise((resolve) => socket.once("error", (error) => resolve(/** @type {Error & {code?: string}} */ (error))))
85
+ }
86
+
87
+ /** @param {() => boolean} predicate - Completion predicate. @param {string} label - Failure label. */
88
+ async function waitUntil(predicate, label) {
89
+ const deadline = Date.now() + 4_000
90
+ while (!predicate()) {
91
+ if (Date.now() >= deadline) throw new Error(`Timed out: ${label}`)
92
+ await new Promise((resolve) => setTimeout(resolve, 10))
93
+ }
94
+ }
95
+
96
+ /**
97
+ * @param {{connect(): Promise<void>}} connection - Terminal reverse connection.
98
+ * @param {string} label - Failure context.
99
+ * @param {(error: Error & {code?: string}) => boolean} predicate - Expected error.
100
+ */
101
+ async function assertRepeatedConnectRejection(connection, label, predicate) {
102
+ /** @type {Error & {code?: string} | undefined} */
103
+ let firstError
104
+ await assert.rejects(withTimeout(connection.connect(), label), (error) => {
105
+ firstError = /** @type {Error & {code?: string}} */ (error)
106
+ return predicate(firstError)
107
+ })
108
+ /** @type {Error | undefined} */
109
+ let repeatedError
110
+ void connection.connect().catch((error) => { repeatedError = error })
111
+ await new Promise((resolve) => setImmediate(resolve))
112
+ assert.strictEqual(repeatedError, firstError, `${label} was not preserved for a repeated connect`)
113
+ }
114
+
115
+ class HeldWriteTarget extends Duplex {
116
+ /** @type {Buffer[]} */
117
+ chunks = []
118
+ /** @type {(() => void) | undefined} */
119
+ releaseFirstWrite
120
+ /** @type {Promise<void>} */
121
+ firstWrite
122
+ /** @type {() => void} */
123
+ #resolveFirstWrite = () => {}
124
+ /** @type {boolean} */
125
+ #held = false
126
+
127
+ constructor() {
128
+ super()
129
+ this.firstWrite = new Promise((resolve) => { this.#resolveFirstWrite = resolve })
130
+ setImmediate(() => this.emit("connect"))
131
+ }
132
+
133
+ /** @override */
134
+ _read() {}
135
+
136
+ /** @param {Buffer} chunk - Written bytes. @param {BufferEncoding} _encoding - Ignored encoding. @param {(error?: Error | null) => void} callback - Completion. @override */
137
+ _write(chunk, _encoding, callback) {
138
+ this.chunks.push(Buffer.from(chunk))
139
+ if (this.#held) { callback(); return }
140
+ this.#held = true
141
+ this.releaseFirstWrite = callback
142
+ this.#resolveFirstWrite()
143
+ }
144
+
145
+ release() {
146
+ const release = this.releaseFirstWrite
147
+ this.releaseFirstWrite = undefined
148
+ release?.()
149
+ }
150
+ }
151
+
152
+ class ObservedMemoryBudget {
153
+ /** @type {number} */ bytes = 0
154
+ /** @type {number} */ peakBytes = 0
155
+
156
+ /** @param {number} maxBytes - Hard reservation ceiling. */
157
+ constructor(maxBytes) {
158
+ this.maxBytes = maxBytes
159
+ }
160
+
161
+ /** @param {number} bytes - Reverse-stream bytes to retain. */
162
+ reserveMemory(bytes) {
163
+ assert(this.bytes + bytes <= this.maxBytes, "reverse client exceeded the aggregate retained-byte ceiling")
164
+ this.bytes += bytes
165
+ this.peakBytes = Math.max(this.peakBytes, this.bytes)
166
+ }
167
+
168
+ /** @param {number} bytes - Reverse-stream bytes to release. */
169
+ releaseMemory(bytes) {
170
+ assert(bytes <= this.bytes, "reverse client released more aggregate bytes than it reserved")
171
+ this.bytes -= bytes
172
+ }
173
+
174
+ reserveData() { assert.fail("reverse client must reserve non-durable bytes as memory") }
175
+ releaseSpool() { assert.fail("reverse client must not reserve spool bytes") }
176
+ reserveControl() {}
177
+ releaseControl() {}
178
+ }
179
+
180
+ /**
181
+ * @param {import('node:test').TestContext} t - Test context.
182
+ * @param {string} suffix - Stable fixture identity suffix.
183
+ * @param {{hubWindowBytes?: number, hubFrameBytes?: number, gatewayWindowBytes?: number, gatewayFrameBytes?: number, maxStreamBufferBytes?: number, maxConnectionBufferBytes?: number}} [options] - Flow-control fixture overrides.
184
+ */
185
+ async function createBackpressuredFixture(t, suffix, options = {}) {
186
+ const root = await mkdtemp(join(tmpdir(), `socketduct-reverse-client-${suffix}-`))
187
+ t.after(() => rm(root, {recursive: true, force: true}))
188
+ const certificates = generateTestCertificates(root)
189
+ const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
190
+ const hub = await startReverseGatewayHub({tokens, tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile}, authorizeGateway: () => true, authorizeClientGateway: () => true, authorizeOpen: () => true, initialWindowBytes: options.hubWindowBytes ?? 32, maxFrameBytes: options.hubFrameBytes ?? 16})
191
+ t.after(() => hub.close())
192
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
193
+ /** @type {HeldWriteTarget[]} */
194
+ const targets = []
195
+ const gateway = new OutboundReverseGateway({gatewayId: "o2", connectionId: `${suffix}_gateway`, hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"}, tls, authorizeTarget: () => true, initialWindowBytes: options.gatewayWindowBytes ?? 32, maxFrameBytes: options.gatewayFrameBytes ?? 16, createTargetConnection: () => {
196
+ const target = new HeldWriteTarget()
197
+ targets.push(target)
198
+ return /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (target))
199
+ }})
200
+ t.after(() => gateway.close())
201
+ await gateway.start()
202
+ const maxConnectionBufferBytes = options.maxConnectionBufferBytes ?? 32
203
+ const budget = new ObservedMemoryBudget(maxConnectionBufferBytes)
204
+ const client = new ReverseGatewayConnection({gatewayId: "o2", connectionId: `${suffix}_client`, hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls, initialWindowBytes: 32, maxFrameBytes: 16, maxStreamBufferBytes: options.maxStreamBufferBytes ?? 32, maxConnectionBufferBytes, aggregateBudget: budget})
205
+ t.after(() => client.close())
206
+ t.after(() => { for (const target of targets) target.release() })
207
+ await client.connect()
208
+ return {budget, client, targets}
209
+ }
210
+
211
+ /**
212
+ * @param {import('node:test').TestContext} t - Test context.
213
+ * @param {string} suffix - Stable fixture identity suffix.
214
+ * @param {{hubWindowBytes?: number, hubFrameBytes?: number, gatewayWindowBytes?: number, gatewayFrameBytes?: number, maxStreamBufferBytes?: number, maxConnectionBufferBytes?: number}} [options] - Flow-control fixture overrides.
215
+ */
216
+ async function createBackpressuredClient(t, suffix, options = {}) {
217
+ const {budget, client, targets} = await createBackpressuredFixture(t, suffix, options)
218
+ const socket = client.createConnection({host: "docker-server", port: 2375})
219
+ await onceEvent(socket, "connect")
220
+ const target = targets[0]
221
+ assert(target !== undefined)
222
+ return {budget, client, socket, target}
223
+ }
224
+
225
+ class AuthWriteSocket extends Duplex {
226
+ /** @override */
227
+ _read() {}
228
+
229
+ /** @param {Buffer} _chunk - Ignored bytes. @param {BufferEncoding} _encoding - Ignored encoding. @param {(error?: Error | null) => void} callback - Completion. @override */
230
+ _write(_chunk, _encoding, callback) { callback() }
231
+
232
+ setKeepAlive() {}
233
+
234
+ setNoDelay() {}
235
+ }
236
+
237
+ test("reverse client contains a TLS AUTH write race inside the transport", async (t) => {
238
+ /** @type {AuthWriteSocket | undefined} */
239
+ let physical
240
+ const client = new ReverseGatewayConnection({
241
+ gatewayId: "o2",
242
+ connectionId: "auth_write_race_client",
243
+ hub: {host: "127.0.0.1", port: 3100, token: "client-token"},
244
+ tls: {servername: "localhost"},
245
+ createPhysicalSocket: () => {
246
+ physical = new AuthWriteSocket()
247
+ physical.write = /** @type {typeof physical.write} */ (() => { throw new Error("simulated AUTH write race") })
248
+ return /** @type {import('node:tls').TLSSocket} */ (/** @type {unknown} */ (physical))
249
+ }
250
+ })
251
+ const connecting = client.connect()
252
+ void connecting.catch(() => {})
253
+ t.after(() => client.close())
254
+
255
+ physical?.emit("secureConnect")
256
+ await new Promise((resolve) => setImmediate(resolve))
257
+
258
+ assert.equal(physical?.destroyed, true)
259
+ await client.close()
260
+ await assert.rejects(connecting, /closed/i)
261
+ })
262
+
263
+ class DataThenErrorTarget extends Duplex {
264
+ /** @param {Buffer} payload - Bytes observed before the terminal target error. */
265
+ constructor(payload) {
266
+ super()
267
+ setImmediate(() => {
268
+ this.emit("connect")
269
+ this.emit("data", payload)
270
+ this.emit("error", new Error("target failed after data"))
271
+ })
272
+ }
273
+
274
+ /** @override */
275
+ _read() {}
276
+
277
+ /** @param {Buffer} _chunk - Ignored bytes. @param {BufferEncoding} _encoding - Ignored encoding. @param {(error?: Error | null) => void} callback - Completion. @override */
278
+ _write(_chunk, _encoding, callback) { callback() }
279
+ }
280
+
281
+ class HalfCloseTarget extends Duplex {
282
+ constructor() {
283
+ super()
284
+ setImmediate(() => {
285
+ this.emit("connect")
286
+ this.emit("end")
287
+ })
288
+ }
289
+
290
+ /** @override */
291
+ _read() {}
292
+
293
+ /** @param {Buffer} _chunk - Ignored bytes. @param {BufferEncoding} _encoding - Ignored encoding. @param {(error?: Error | null) => void} callback - Completion. @override */
294
+ _write(_chunk, _encoding, callback) { callback() }
295
+
296
+ closeTarget() { this.emit("close") }
297
+ }
298
+
299
+ class CloseDuringFirstWriteTarget extends Duplex {
300
+ /** @type {Buffer[]} */ chunks = []
301
+ /** @type {((error?: Error | null) => void) | undefined} */ #releaseWrite
302
+
303
+ constructor() {
304
+ super()
305
+ setImmediate(() => this.emit("connect"))
306
+ }
307
+
308
+ /** @override */
309
+ _read() {}
310
+
311
+ /** @param {Buffer} chunk - Request bytes. @param {BufferEncoding} _encoding - Ignored encoding. @param {(error?: Error | null) => void} callback - Completion. @override */
312
+ _write(chunk, _encoding, callback) {
313
+ this.chunks.push(Buffer.from(chunk))
314
+ this.#releaseWrite = callback
315
+ this.emit("end")
316
+ this.emit("close")
317
+ }
318
+
319
+ release() {
320
+ const callback = this.#releaseWrite
321
+ this.#releaseWrite = undefined
322
+ callback?.()
323
+ }
324
+ }
325
+
326
+ class DataThenLocalCloseTarget extends Duplex {
327
+ /** @param {Buffer} payload - Target data sent before the controlled local close. */
328
+ constructor(payload) {
329
+ super()
330
+ setImmediate(() => {
331
+ if (this.destroyed) return
332
+ this.emit("connect")
333
+ this.emit("data", payload)
334
+ })
335
+ }
336
+
337
+ /** @override */
338
+ _read() {}
339
+
340
+ /** @param {Buffer} _chunk - Ignored bytes. @param {BufferEncoding} _encoding - Ignored encoding. @param {(error?: Error | null) => void} callback - Completion. @override */
341
+ _write(_chunk, _encoding, callback) { callback() }
342
+
343
+ closeLocally() {
344
+ this.emit("end")
345
+ this.emit("close")
346
+ }
347
+ }
348
+
349
+ test("reverse gateway registers outbound over TLS and multiplexes compressed streams", async (t) => {
350
+ /** @type {Array<{event: string} & Record<string, unknown>>} */
351
+ const gatewayLifecycle = []
352
+ /** @type {Array<{event: string} & Record<string, unknown>>} */
353
+ const clientLifecycle = []
354
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-"))
355
+ t.after(() => rm(root, {recursive: true, force: true}))
356
+ const certificates = generateTestCertificates(root)
357
+ let targetAccepts = 0
358
+ const targetSockets = new Set()
359
+ const target = createServer((socket) => {
360
+ targetAccepts += 1
361
+ targetSockets.add(socket)
362
+ socket.once("close", () => targetSockets.delete(socket))
363
+ socket.on("data", (chunk) => socket.write(chunk))
364
+ })
365
+ const targetPort = await listen(target)
366
+ t.after(() => {
367
+ for (const socket of targetSockets) socket.destroy()
368
+ return new Promise((resolve) => target.close(() => resolve(undefined)))
369
+ })
370
+
371
+ const tokens = new StaticTokenStore([
372
+ {username: "home-o2", token: "gateway-test-token"},
373
+ {username: "tensorbuzz", token: "client-test-token"}
374
+ ])
375
+ const hub = await startReverseGatewayHub({
376
+ tokens,
377
+ tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile},
378
+ authorizeGateway: (/** @type {{principal: {username: string}, gatewayId: string}} */ {principal, gatewayId}) => principal.username === "home-o2" && gatewayId === "o2",
379
+ authorizeClientGateway: (/** @type {{principal: {username: string}, gatewayId: string}} */ {principal, gatewayId}) => principal.username === "tensorbuzz" && gatewayId === "o2",
380
+ authorizeOpen: (/** @type {{principal: {username: string}, gatewayId: string, target: {host: string, port: number}}} */ {principal, gatewayId, target: requested}) => principal.username === "tensorbuzz" && gatewayId === "o2" && requested.host === "127.0.0.1" && requested.port === targetPort
381
+ })
382
+ t.after(() => hub.close())
383
+
384
+ const gateway = new OutboundReverseGateway({
385
+ gatewayId: "o2",
386
+ connectionId: "home_o2_gateway_01",
387
+ hub: {host: "127.0.0.1", port: hub.port, token: "gateway-test-token"},
388
+ tls: {caFile: certificates.caCertFile, servername: "localhost"},
389
+ authorizeTarget: (requested) => requested.host === "127.0.0.1" && requested.port === targetPort,
390
+ reconnectDelayMs: 10,
391
+ logger: {log: (/** @type {string} */ event, /** @type {Record<string, unknown>} */ fields = {}) => gatewayLifecycle.push({event, ...fields})}
392
+ })
393
+ t.after(() => gateway.close())
394
+ await withTimeout(gateway.start(), "gateway registration")
395
+
396
+ const client = new ReverseGatewayConnection({
397
+ gatewayId: "o2",
398
+ connectionId: "tensorbuzz_member_o2_01",
399
+ hub: {host: "127.0.0.1", port: hub.port, token: "client-test-token"},
400
+ tls: {caFile: certificates.caCertFile, servername: "localhost"},
401
+ logger: {log: (/** @type {string} */ event, /** @type {Record<string, unknown>} */ fields = {}) => clientLifecycle.push({event, ...fields})}
402
+ })
403
+ t.after(() => client.close())
404
+ await withTimeout(client.connect(), "client authentication")
405
+
406
+ const first = client.createConnection({host: "127.0.0.1", port: targetPort, sessionName: "docker-version"})
407
+ const second = client.createConnection({host: "127.0.0.1", port: targetPort, sessionName: "docker-events"})
408
+ await withTimeout(Promise.all([onceEvent(first, "connect"), onceEvent(second, "connect")]), "reverse stream opens")
409
+
410
+ const payload = Buffer.from("docker-api-payload-".repeat(20_000))
411
+ const firstRead = readBytes(first, payload.length)
412
+ const secondRead = readBytes(second, payload.length)
413
+ first.write(payload)
414
+ second.write(payload)
415
+ assert.deepEqual(await withTimeout(firstRead, "first echo"), payload)
416
+ assert.deepEqual(await withTimeout(secondRead, "second echo"), payload)
417
+
418
+ assert.equal(targetAccepts, 2)
419
+ assert.equal(hub.status().registeredGateways, 1)
420
+ assert.equal(hub.status().gatewayPhysicalConnections, 1)
421
+ assert.equal(gateway.status().compression, "zstd")
422
+ assert(gateway.status().compressionBytesSaved > 0)
423
+
424
+ const firstClosed = onceEvent(first, "close")
425
+ first.end()
426
+ await withTimeout(firstClosed, "clean reverse stream close")
427
+ await waitUntil(() => hub.status().virtualStreams === 1, "clean stream bridge cleanup")
428
+ assert.equal(gatewayLifecycle.filter(({event}) => event === "reverse_gateway.target_opened").length, 2)
429
+ assert(gatewayLifecycle.some(({event}) => event === "reverse_gateway.target_closed"))
430
+ assert(clientLifecycle.some(({event}) => event === "transport.connected"))
431
+ assert(clientLifecycle.some(({event}) => event === "virtual_stream.opened"))
432
+ assert(clientLifecycle.some(({event}) => event === "virtual_stream.closed"))
433
+ assert.doesNotMatch(JSON.stringify([gatewayLifecycle, clientLifecycle]), /gateway-test-token|client-test-token|docker-version|docker-events|127\.0\.0\.1/)
434
+ })
435
+
436
+ test("reverse client sends END after flow-controlled DATA", async (t) => {
437
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-end-order-"))
438
+ t.after(() => rm(root, {recursive: true, force: true}))
439
+ const certificates = generateTestCertificates(root)
440
+ const targetSockets = new Set()
441
+ /** @type {(value: Buffer) => void} */
442
+ let resolveTargetData
443
+ const targetData = new Promise((resolve) => { resolveTargetData = resolve })
444
+ const target = createServer((socket) => {
445
+ targetSockets.add(socket)
446
+ socket.once("close", () => targetSockets.delete(socket))
447
+ /** @type {Buffer[]} */
448
+ const chunks = []
449
+ socket.on("data", (chunk) => chunks.push(chunk))
450
+ socket.once("end", () => resolveTargetData(Buffer.concat(chunks)))
451
+ })
452
+ const targetPort = await listen(target)
453
+ t.after(() => {
454
+ for (const socket of targetSockets) socket.destroy()
455
+ return new Promise((resolve) => target.close(() => resolve(undefined)))
456
+ })
457
+ const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
458
+ const hub = await startReverseGatewayHub({tokens, tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile}, authorizeGateway: () => true, authorizeClientGateway: () => true, authorizeOpen: () => true, initialWindowBytes: 8192, maxFrameBytes: 4096})
459
+ t.after(() => hub.close())
460
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
461
+ const gateway = new OutboundReverseGateway({gatewayId: "o2", connectionId: "end_order_gateway", hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"}, tls, authorizeTarget: () => true, initialWindowBytes: 8192, maxFrameBytes: 4096})
462
+ t.after(() => gateway.close())
463
+ await gateway.start()
464
+ const client = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "end_order_client", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls, initialWindowBytes: 8192, maxFrameBytes: 4096})
465
+ t.after(() => client.close())
466
+ await client.connect()
467
+ const socket = client.createConnection({host: "127.0.0.1", port: targetPort})
468
+ await onceEvent(socket, "connect")
469
+ const payload = Buffer.alloc(20_000, 0x5a)
470
+ socket.end(payload)
471
+ assert.deepEqual(await withTimeout(targetData, "flow-controlled DATA before END"), payload)
472
+ socket.destroy()
473
+ })
474
+
475
+ test("reverse client CLOSE acknowledges only flow-controlled DATA transmitted before target close", async (t) => {
476
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-close-position-"))
477
+ t.after(() => rm(root, {recursive: true, force: true}))
478
+ const certificates = generateTestCertificates(root)
479
+ const siblingSockets = new Set()
480
+ const siblingTarget = createServer((socket) => {
481
+ siblingSockets.add(socket)
482
+ socket.once("close", () => siblingSockets.delete(socket))
483
+ socket.on("data", (chunk) => socket.write(chunk))
484
+ })
485
+ const siblingPort = await listen(siblingTarget)
486
+ t.after(() => { for (const socket of siblingSockets) socket.destroy(); return new Promise((resolve) => siblingTarget.close(() => resolve(undefined))) })
487
+ const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
488
+ const hub = await startReverseGatewayHub({tokens, tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile}, authorizeGateway: () => true, authorizeClientGateway: () => true, authorizeOpen: () => true, initialWindowBytes: 4096, maxFrameBytes: 4096})
489
+ t.after(() => hub.close())
490
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
491
+ /** @type {CloseDuringFirstWriteTarget | undefined} */
492
+ let closingTarget
493
+ const gateway = new OutboundReverseGateway({gatewayId: "o2", connectionId: "close_position_gateway", hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"}, tls, authorizeTarget: () => true, initialWindowBytes: 4096, maxFrameBytes: 4096, createTargetConnection: (target) => {
494
+ if (target.host !== "closing-target") return connectTcp(target)
495
+ closingTarget = new CloseDuringFirstWriteTarget()
496
+ return /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (closingTarget))
497
+ }})
498
+ t.after(() => { closingTarget?.release(); return gateway.close() })
499
+ await gateway.start()
500
+ /** @type {() => void} */
501
+ let releaseClientData = () => {}
502
+ /** @type {() => void} */
503
+ let markClientDataBackpressured = () => {}
504
+ /** @type {Promise<void>} */
505
+ const clientDataBackpressured = new Promise((resolve) => { markClientDataBackpressured = () => resolve() })
506
+ const client = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "close_position_client", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls, initialWindowBytes: 4096, maxFrameBytes: 4096, createPhysicalSocket: (options) => {
507
+ const physical = connectTls({host: options.host, port: options.port, ...options.tls})
508
+ const originalWrite = physical.write.bind(physical)
509
+ let heldData = false
510
+ physical.write = /** @type {typeof physical.write} */ ((chunk, ...args) => {
511
+ const accepted = Reflect.apply(originalWrite, physical, [chunk, ...args])
512
+ if (heldData || !Buffer.isBuffer(chunk) || chunk[5] !== V3FrameType.DATA) return accepted
513
+ heldData = true
514
+ releaseClientData = () => physical.emit("drain")
515
+ markClientDataBackpressured()
516
+ return false
517
+ })
518
+ return physical
519
+ }})
520
+ t.after(() => { releaseClientData(); return client.close() })
521
+ await client.connect()
522
+ const sibling = client.createConnection({host: "127.0.0.1", port: siblingPort})
523
+ const closing = client.createConnection({host: "closing-target", port: 2375})
524
+ await withTimeout(Promise.all([onceEvent(sibling, "connect"), onceEvent(closing, "connect")]), "reverse streams before early target close")
525
+ const closed = readToEnd(closing)
526
+ closing.write(Buffer.alloc(20_000, 0x63))
527
+ await clientDataBackpressured
528
+ assert.equal((await withTimeout(closed, "early target CLOSE")).length, 0)
529
+ await waitUntil(() => hub.status().virtualStreams !== 2, "early target bridge cleanup")
530
+ assert.equal(hub.status().virtualStreams, 1)
531
+ assert.equal(Buffer.concat(closingTarget?.chunks ?? []).length, 4096)
532
+ closingTarget?.release()
533
+ const echoed = readBytes(sibling, 13)
534
+ sibling.write("sibling-alive")
535
+ assert.equal((await withTimeout(echoed, "sibling after early target CLOSE")).toString(), "sibling-alive")
536
+ assert.equal(client.status().physicalConnects, 1)
537
+ sibling.destroy()
538
+ })
539
+
540
+ test("reverse hub keeps sibling streams alive when ACK and WINDOW arrive after a gateway-local close", async (t) => {
541
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-close-race-"))
542
+ t.after(() => rm(root, {recursive: true, force: true}))
543
+ const certificates = generateTestCertificates(root)
544
+ const siblingSockets = new Set()
545
+ const siblingTarget = createServer((socket) => {
546
+ siblingSockets.add(socket)
547
+ socket.once("close", () => siblingSockets.delete(socket))
548
+ socket.on("data", (chunk) => socket.write(chunk))
549
+ socket.once("end", () => socket.end())
550
+ })
551
+ const siblingPort = await listen(siblingTarget)
552
+ t.after(() => { for (const socket of siblingSockets) socket.destroy(); return new Promise((resolve) => siblingTarget.close(() => resolve(undefined))) })
553
+ const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
554
+ const hub = await startReverseGatewayHub({tokens, tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile}, authorizeGateway: () => true, authorizeClientGateway: () => true, authorizeOpen: () => true, initialWindowBytes: 4096, maxFrameBytes: 4096})
555
+ t.after(() => hub.close())
556
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
557
+ const closingPayload = Buffer.from("response-before-local-close")
558
+ /** @type {DataThenLocalCloseTarget | undefined} */
559
+ let closingTarget
560
+ const gateway = new OutboundReverseGateway({gatewayId: "o2", connectionId: "close_race_gateway", hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"}, tls, authorizeTarget: () => true, initialWindowBytes: 4096, maxFrameBytes: 4096, createTargetConnection: (target) => {
561
+ if (target.host !== "closing-target") return connectTcp(target)
562
+ closingTarget = new DataThenLocalCloseTarget(closingPayload)
563
+ return /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (closingTarget))
564
+ }})
565
+ t.after(() => gateway.close())
566
+ await gateway.start()
567
+ /** @type {number | undefined} */
568
+ let closingStreamId
569
+ /** @type {Buffer | undefined} */
570
+ let delayedAck
571
+ /** @type {() => void} */
572
+ let markAckDelayed = () => {}
573
+ /** @type {Promise<void>} */
574
+ const ackDelayed = new Promise((resolve) => { markAckDelayed = () => resolve() })
575
+ /** @type {() => void} */
576
+ let markLateControlsSent = () => {}
577
+ /** @type {Promise<void>} */
578
+ const lateControlsSent = new Promise((resolve) => { markLateControlsSent = () => resolve() })
579
+ const client = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "close_race_client", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls, initialWindowBytes: 4096, maxFrameBytes: 4096, reconnectDelayMs: 10_000, createPhysicalSocket: (options) => {
580
+ const physical = connectTls({host: options.host, port: options.port, ...options.tls})
581
+ const originalWrite = physical.write.bind(physical)
582
+ physical.write = /** @type {typeof physical.write} */ ((chunk, ...args) => {
583
+ if (Buffer.isBuffer(chunk) && chunk[5] === V3FrameType.ACK && chunk.readUInt32BE(8) === closingStreamId && delayedAck === undefined) {
584
+ delayedAck = Buffer.from(chunk)
585
+ markAckDelayed()
586
+ return true
587
+ }
588
+ if (Buffer.isBuffer(chunk) && chunk[5] === V3FrameType.CLOSE && chunk.readUInt32BE(8) === closingStreamId && delayedAck !== undefined) {
589
+ const streamId = chunk.readUInt32BE(8)
590
+ const window = encodeV3Frame({type: V3FrameType.WINDOW, streamId, sequence: 4096}, encodeV3Control({}))
591
+ const controlsAndClose = Buffer.concat([delayedAck, window, chunk])
592
+ delayedAck = undefined
593
+ const accepted = Reflect.apply(originalWrite, physical, [controlsAndClose, ...args])
594
+ markLateControlsSent()
595
+ return accepted
596
+ }
597
+ return Reflect.apply(originalWrite, physical, [chunk, ...args])
598
+ })
599
+ return physical
600
+ }})
601
+ t.after(() => client.close())
602
+ await client.connect()
603
+ const closing = client.createConnection({host: "closing-target", port: 2375, allowHalfOpen: true})
604
+ closingStreamId = closing.streamId
605
+ const closingRead = readToEnd(closing)
606
+ const sibling = client.createConnection({host: "127.0.0.1", port: siblingPort})
607
+ await withTimeout(Promise.all([onceEvent(closing, "connect"), onceEvent(sibling, "connect")]), "reverse streams before local close race")
608
+ await withTimeout(ackDelayed, "ACK queued before local close")
609
+ assert(closingTarget !== undefined)
610
+ closingTarget.closeLocally()
611
+ await withTimeout(lateControlsSent, "late ACK and WINDOW release")
612
+ assert.deepEqual(await withTimeout(closingRead, "gateway-local close"), closingPayload)
613
+ await waitUntil(() => hub.status().virtualStreams === 1, "locally closed bridge cleanup")
614
+ const siblingRead = readToEnd(sibling)
615
+ sibling.end("sibling-completes")
616
+ assert.equal((await withTimeout(siblingRead, "sibling completion after close race")).toString(), "sibling-completes")
617
+ assert.equal(client.status().physicalState, "ready")
618
+ assert.equal(client.status().physicalConnects, 1)
619
+ })
620
+
621
+ test("reverse gateway sends target END after payload received with FIN", async (t) => {
622
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-target-end-order-"))
623
+ t.after(() => rm(root, {recursive: true, force: true}))
624
+ const certificates = generateTestCertificates(root)
625
+ const payload = Buffer.from("final-target-payload-".repeat(512))
626
+ const targetSockets = new Set()
627
+ const target = createServer((socket) => {
628
+ targetSockets.add(socket)
629
+ socket.once("close", () => targetSockets.delete(socket))
630
+ socket.end(payload)
631
+ })
632
+ const targetPort = await listen(target)
633
+ t.after(() => { for (const socket of targetSockets) socket.destroy(); return new Promise((resolve) => target.close(() => resolve(undefined))) })
634
+ const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
635
+ const hub = await startReverseGatewayHub({tokens, tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile}, authorizeGateway: () => true, authorizeClientGateway: () => true, authorizeOpen: () => true})
636
+ t.after(() => hub.close())
637
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
638
+ const gateway = new OutboundReverseGateway({gatewayId: "o2", connectionId: "target_end_order_gateway", hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"}, tls, authorizeTarget: () => true})
639
+ t.after(() => gateway.close())
640
+ await gateway.start()
641
+ const client = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "target_end_order_client", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls})
642
+ t.after(() => client.close())
643
+ await client.connect()
644
+ const socket = client.createConnection({host: "127.0.0.1", port: targetPort})
645
+ assert.deepEqual(await withTimeout(readToEnd(socket), "target payload before FIN"), payload)
646
+ })
647
+
648
+ test("reverse gateway sends target DATA observed before a terminal target error", async (t) => {
649
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-target-error-order-"))
650
+ t.after(() => rm(root, {recursive: true, force: true}))
651
+ const certificates = generateTestCertificates(root)
652
+ const payload = Buffer.from("data-before-target-error-".repeat(512))
653
+ const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
654
+ const hub = await startReverseGatewayHub({tokens, tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile}, authorizeGateway: () => true, authorizeClientGateway: () => true, authorizeOpen: () => true})
655
+ t.after(() => hub.close())
656
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
657
+ const gateway = new OutboundReverseGateway({gatewayId: "o2", connectionId: "target_error_order_gateway", hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"}, tls, authorizeTarget: () => true, createTargetConnection: () => /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (new DataThenErrorTarget(payload)))})
658
+ t.after(() => gateway.close())
659
+ await gateway.start()
660
+ const client = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "target_error_order_client", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls})
661
+ t.after(() => client.close())
662
+ await client.connect()
663
+ const socket = client.createConnection({host: "docker-server", port: 2375})
664
+ /** @type {Buffer[]} */
665
+ const received = []
666
+ socket.on("data", (chunk) => received.push(chunk))
667
+ const error = nextError(socket)
668
+ await onceEvent(socket, "connect")
669
+ assert.equal((await withTimeout(error, "terminal target error")).code, "TARGET_CLOSED")
670
+ assert.deepEqual(Buffer.concat(received), payload)
671
+ assert.equal(gateway.status().virtualStreams, 0)
672
+ })
673
+
674
+ test("reverse client retires terminal CLOSE streams with allowHalfOpen", async (t) => {
675
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-terminal-close-"))
676
+ t.after(() => rm(root, {recursive: true, force: true}))
677
+ const certificates = generateTestCertificates(root)
678
+ const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
679
+ const hub = await startReverseGatewayHub({tokens, tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile}, authorizeGateway: () => true, authorizeClientGateway: () => true, authorizeOpen: () => true})
680
+ t.after(() => hub.close())
681
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
682
+ /** @type {HalfCloseTarget[]} */
683
+ const targets = []
684
+ const gateway = new OutboundReverseGateway({gatewayId: "o2", connectionId: "terminal_close_gateway", hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"}, tls, authorizeTarget: () => true, createTargetConnection: () => {
685
+ const target = new HalfCloseTarget()
686
+ targets.push(target)
687
+ return /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (target))
688
+ }})
689
+ t.after(() => gateway.close())
690
+ await gateway.start()
691
+ const client = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "terminal_close_client", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls, maxStreams: 1})
692
+ t.after(() => client.close())
693
+ await client.connect()
694
+ const first = client.createConnection({host: "docker-server", port: 2375, allowHalfOpen: true})
695
+ assert.equal((await withTimeout(readToEnd(first), "first terminal response")).length, 0)
696
+ assert.equal(first.writable, true)
697
+ assert.equal(client.status().virtualStreams, 1)
698
+ assert.equal(targets.length, 1)
699
+ const firstTarget = targets[0]
700
+ assert(firstTarget !== undefined)
701
+ firstTarget.closeTarget()
702
+ await waitUntil(() => hub.status().virtualStreams === 0, "first terminal CLOSE handshake")
703
+ await waitUntil(() => !first.writable && client.status().virtualStreams === 0, "terminal client stream retirement")
704
+ assert.equal(first.destroyed, true)
705
+ assert.equal(client.status().virtualStreams, 0)
706
+ const second = client.createConnection({host: "docker-server", port: 2375, allowHalfOpen: true})
707
+ await onceEvent(second, "connect")
708
+ second.destroy()
709
+ })
710
+
711
+ test("reverse client DATA respects a decompression limit below maxFrameBytes", async (t) => {
712
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-client-chunks-"))
713
+ t.after(() => rm(root, {recursive: true, force: true}))
714
+ const certificates = generateTestCertificates(root)
715
+ const payload = Buffer.alloc(4096, 0x61)
716
+ /** @type {(value: Buffer) => void} */
717
+ let resolveTargetData
718
+ const targetData = new Promise((resolve) => { resolveTargetData = resolve })
719
+ const targetSockets = new Set()
720
+ const target = createServer((socket) => {
721
+ targetSockets.add(socket)
722
+ socket.once("close", () => targetSockets.delete(socket))
723
+ /** @type {Buffer[]} */
724
+ const chunks = []
725
+ let length = 0
726
+ socket.on("data", (chunk) => {
727
+ chunks.push(chunk)
728
+ length += chunk.length
729
+ if (length >= payload.length) resolveTargetData(Buffer.concat(chunks))
730
+ })
731
+ })
732
+ const targetPort = await listen(target)
733
+ t.after(() => { for (const socket of targetSockets) socket.destroy(); return new Promise((resolve) => target.close(() => resolve(undefined))) })
734
+ const compressionLimits = {maxDecompressedBytes: 1024}
735
+ const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
736
+ const hub = await startReverseGatewayHub({tokens, tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile}, authorizeGateway: () => true, authorizeClientGateway: () => true, authorizeOpen: () => true, maxFrameBytes: 4096, compressionLimits})
737
+ t.after(() => hub.close())
738
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
739
+ const gateway = new OutboundReverseGateway({gatewayId: "o2", connectionId: "client_chunks_gateway", hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"}, tls, authorizeTarget: () => true, maxFrameBytes: 4096, compressionLimits})
740
+ t.after(() => gateway.close())
741
+ await gateway.start()
742
+ const client = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "client_chunks_client", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls, maxFrameBytes: 4096, compressionLimits})
743
+ t.after(() => client.close())
744
+ await client.connect()
745
+ const socket = client.createConnection({host: "127.0.0.1", port: targetPort})
746
+ await onceEvent(socket, "connect")
747
+ socket.write(payload)
748
+ assert.deepEqual(await withTimeout(targetData, "bounded client DATA"), payload)
749
+ socket.destroy()
750
+ })
751
+
752
+ test("reverse gateway DATA respects a decompression limit below maxFrameBytes", async (t) => {
753
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-gateway-chunks-"))
754
+ t.after(() => rm(root, {recursive: true, force: true}))
755
+ const certificates = generateTestCertificates(root)
756
+ const payload = Buffer.alloc(4096, 0x62)
757
+ const targetSockets = new Set()
758
+ const target = createServer((socket) => {
759
+ targetSockets.add(socket)
760
+ socket.once("close", () => targetSockets.delete(socket))
761
+ socket.write(payload)
762
+ })
763
+ const targetPort = await listen(target)
764
+ t.after(() => { for (const socket of targetSockets) socket.destroy(); return new Promise((resolve) => target.close(() => resolve(undefined))) })
765
+ const compressionLimits = {maxDecompressedBytes: 1024}
766
+ const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
767
+ const hub = await startReverseGatewayHub({tokens, tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile}, authorizeGateway: () => true, authorizeClientGateway: () => true, authorizeOpen: () => true, maxFrameBytes: 4096, compressionLimits})
768
+ t.after(() => hub.close())
769
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
770
+ const gateway = new OutboundReverseGateway({gatewayId: "o2", connectionId: "gateway_chunks_gateway", hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"}, tls, authorizeTarget: () => true, maxFrameBytes: 4096, compressionLimits})
771
+ t.after(() => gateway.close())
772
+ await gateway.start()
773
+ const client = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "gateway_chunks_client", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls, maxFrameBytes: 4096, compressionLimits})
774
+ t.after(() => client.close())
775
+ await client.connect()
776
+ const socket = client.createConnection({host: "127.0.0.1", port: targetPort})
777
+ assert.deepEqual(await withTimeout(readBytes(socket, payload.length), "bounded gateway DATA"), payload)
778
+ socket.destroy()
779
+ })
780
+
781
+ test("reverse client honors the smaller hub receive window", async (t) => {
782
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-client-window-"))
783
+ t.after(() => rm(root, {recursive: true, force: true}))
784
+ const certificates = generateTestCertificates(root)
785
+ const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
786
+ const hub = await startReverseGatewayHub({tokens, tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile}, authorizeGateway: () => true, authorizeClientGateway: () => true, authorizeOpen: () => true, initialWindowBytes: 8192, maxFrameBytes: 4096})
787
+ t.after(() => hub.close())
788
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
789
+ /** @type {HeldWriteTarget | undefined} */
790
+ let target
791
+ const gateway = new OutboundReverseGateway({gatewayId: "o2", connectionId: "client_window_gateway", hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"}, tls, authorizeTarget: () => true, initialWindowBytes: 20_000, maxFrameBytes: 4096, createTargetConnection: () => {
792
+ target = new HeldWriteTarget()
793
+ return /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (target))
794
+ }})
795
+ t.after(() => gateway.close())
796
+ await gateway.start()
797
+ const client = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "client_window_client", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls, initialWindowBytes: 20_000, maxFrameBytes: 4096})
798
+ t.after(() => client.close())
799
+ await client.connect()
800
+ const socket = client.createConnection({host: "docker-server", port: 2375})
801
+ await onceEvent(socket, "connect")
802
+ const payload = Buffer.alloc(20_000, 0x63)
803
+ await new Promise((resolve, reject) => socket.write(payload, (error) => error ? reject(error) : resolve(undefined)))
804
+ assert(target !== undefined)
805
+ const targetStream = target
806
+ await targetStream.firstWrite
807
+ await waitUntil(() => hub.status().rawDataBytes >= 8192, "initial client window")
808
+ await new Promise((resolve) => setImmediate(resolve))
809
+ assert.equal(hub.status().rawDataBytes, 8192)
810
+ targetStream.release()
811
+ await waitUntil(() => Buffer.concat(targetStream.chunks).length === payload.length, "remaining client DATA after window credit")
812
+ assert.deepEqual(Buffer.concat(targetStream.chunks), payload)
813
+ socket.destroy()
814
+ })
815
+
816
+ test("reverse client backpressures a write larger than its retained-byte limit", async (t) => {
817
+ const {budget, client, socket, target} = await createBackpressuredClient(t, "large_write")
818
+ /** @type {Error | undefined} */
819
+ let emittedError
820
+ socket.on("error", (error) => { emittedError = error })
821
+ const payload = Buffer.from(Array.from({length: 96}, (_value, index) => index))
822
+ let callbacks = 0
823
+ const writing = new Promise((resolve, reject) => socket.write(payload, (error) => {
824
+ callbacks += 1
825
+ if (error) reject(error)
826
+ else resolve(undefined)
827
+ }))
828
+ await withTimeout(Promise.race([target.firstWrite, writing]), "first chunk of backpressured large write")
829
+ assert.equal(client.status().queuedBytes, 32)
830
+ assert.equal(budget.peakBytes, 32)
831
+ target.release()
832
+ await withTimeout(writing, "backpressured large write callback")
833
+ await waitUntil(() => Buffer.concat(target.chunks).length === payload.length, "complete backpressured large write")
834
+ await waitUntil(() => client.status().queuedBytes === 0, "large write acknowledgements")
835
+ assert.deepEqual(Buffer.concat(target.chunks), payload)
836
+ assert.equal(budget.bytes, 0)
837
+ assert.equal(budget.peakBytes, 32)
838
+ assert.equal(callbacks, 1)
839
+ assert.equal(emittedError, undefined)
840
+ socket.destroy()
841
+ })
842
+
843
+ test("reverse client chunks a large write to the negotiated stream window", async (t) => {
844
+ const {budget, client, socket, target} = await createBackpressuredClient(t, "negotiated_window", {
845
+ hubWindowBytes: 8,
846
+ hubFrameBytes: 8,
847
+ gatewayWindowBytes: 8,
848
+ gatewayFrameBytes: 8,
849
+ maxStreamBufferBytes: 16,
850
+ maxConnectionBufferBytes: 16
851
+ })
852
+ const payload = Buffer.from(Array.from({length: 48}, (_value, index) => index))
853
+ const writing = new Promise((resolve, reject) => socket.write(payload, (error) => error ? reject(error) : resolve(undefined)))
854
+ void writing.catch(() => {})
855
+ await withTimeout(target.firstWrite, "first large-write chunk within negotiated stream window")
856
+ assert.equal(client.status().queuedBytes, 16)
857
+ assert.equal(budget.peakBytes, 16)
858
+ target.release()
859
+ await withTimeout(writing, "negotiated-window large-write callback")
860
+ await waitUntil(() => Buffer.concat(target.chunks).length === payload.length, "complete negotiated-window large write")
861
+ await waitUntil(() => client.status().queuedBytes === 0, "negotiated-window acknowledgements")
862
+ assert(target.chunks.every((chunk) => chunk.length <= 8))
863
+ assert.deepEqual(Buffer.concat(target.chunks), payload)
864
+ assert.equal(budget.bytes, 0)
865
+ socket.destroy()
866
+ })
867
+
868
+ test("reverse client shares released connection capacity among pending streams", async (t) => {
869
+ const {budget, client, targets} = await createBackpressuredFixture(t, "fair_pending_writes", {
870
+ maxStreamBufferBytes: 32,
871
+ maxConnectionBufferBytes: 16
872
+ })
873
+ const firstSocket = client.createConnection({host: "docker-server", port: 2375})
874
+ const secondSocket = client.createConnection({host: "docker-server", port: 2375})
875
+ await Promise.all([onceEvent(firstSocket, "connect"), onceEvent(secondSocket, "connect")])
876
+ const firstTarget = targets[0]
877
+ const secondTarget = targets[1]
878
+ assert(firstTarget !== undefined)
879
+ assert(secondTarget !== undefined)
880
+ const firstPayload = Buffer.from(Array.from({length: 64}, (_value, index) => index))
881
+ const firstWriting = new Promise((resolve, reject) => firstSocket.write(firstPayload, (error) => error ? reject(error) : resolve("first")))
882
+ void firstWriting.catch(() => {})
883
+ await withTimeout(firstTarget.firstWrite, "first stream connection-pressure chunk")
884
+ assert.equal(client.status().queuedBytes, 16)
885
+
886
+ const secondPayload = Buffer.alloc(16, 0xa5)
887
+ const secondWriting = new Promise((resolve, reject) => secondSocket.write(secondPayload, (error) => error ? reject(error) : resolve(undefined)))
888
+ void secondWriting.catch(() => {})
889
+ firstTarget.release()
890
+ assert.equal(await withTimeout(Promise.race([secondTarget.firstWrite.then(() => "second"), firstWriting]), "fair pending-stream progression"), "second")
891
+ assert.equal(budget.peakBytes, 16)
892
+
893
+ secondTarget.release()
894
+ await withTimeout(Promise.all([firstWriting, secondWriting]), "complete fairly scheduled writes")
895
+ await waitUntil(() => Buffer.concat(firstTarget.chunks).length === firstPayload.length, "complete first fairly scheduled write")
896
+ await waitUntil(() => Buffer.concat(secondTarget.chunks).length === secondPayload.length, "complete second fairly scheduled write")
897
+ await waitUntil(() => client.status().queuedBytes === 0, "fair pending-stream acknowledgements")
898
+ assert.deepEqual(Buffer.concat(firstTarget.chunks), firstPayload)
899
+ assert.deepEqual(Buffer.concat(secondTarget.chunks), secondPayload)
900
+ assert.equal(budget.bytes, 0)
901
+ firstSocket.destroy()
902
+ secondSocket.destroy()
903
+ })
904
+
905
+ test("reverse client close fails a backpressured write and releases retained bytes", async (t) => {
906
+ const {budget, client, socket, target} = await createBackpressuredClient(t, "close_large_write")
907
+ const payload = Buffer.alloc(96, 0x72)
908
+ let callbacks = 0
909
+ const writing = new Promise((resolve, reject) => socket.write(payload, (error) => {
910
+ callbacks += 1
911
+ if (error) reject(error)
912
+ else resolve(undefined)
913
+ }))
914
+ await withTimeout(Promise.race([target.firstWrite, writing]), "first chunk before reverse client close")
915
+ assert.equal(client.status().queuedBytes, 32)
916
+ await client.close()
917
+ await assert.rejects(withTimeout(writing, "backpressured write close callback"), (error) => /** @type {{code?: string}} */ (error).code === "ERR_STREAM_DESTROYED")
918
+ assert.equal(callbacks, 1)
919
+ assert.equal(socket.destroyed, true)
920
+ assert.equal(client.status().queuedBytes, 0)
921
+ assert.equal(budget.bytes, 0)
922
+ target.release()
923
+ })
924
+
925
+ test("reverse gateway honors the smaller hub receive window", async (t) => {
926
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-gateway-window-"))
927
+ t.after(() => rm(root, {recursive: true, force: true}))
928
+ const certificates = generateTestCertificates(root)
929
+ const payload = Buffer.alloc(20_000, 0x64)
930
+ const targetSockets = new Set()
931
+ const target = createServer((socket) => {
932
+ targetSockets.add(socket)
933
+ socket.once("close", () => targetSockets.delete(socket))
934
+ socket.write(payload)
935
+ })
936
+ const targetPort = await listen(target)
937
+ t.after(() => { for (const socket of targetSockets) socket.destroy(); return new Promise((resolve) => target.close(() => resolve(undefined))) })
938
+ const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
939
+ const hub = await startReverseGatewayHub({tokens, tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile}, authorizeGateway: () => true, authorizeClientGateway: () => true, authorizeOpen: () => true, initialWindowBytes: 8192, maxFrameBytes: 4096})
940
+ t.after(() => hub.close())
941
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
942
+ const gateway = new OutboundReverseGateway({gatewayId: "o2", connectionId: "gateway_window_gateway", hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"}, tls, authorizeTarget: () => true, initialWindowBytes: 20_000, maxFrameBytes: 4096})
943
+ t.after(() => gateway.close())
944
+ await gateway.start()
945
+ const client = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "gateway_window_client", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls, initialWindowBytes: 8192, maxFrameBytes: 4096})
946
+ t.after(() => client.close())
947
+ await client.connect()
948
+ const socket = client.createConnection({host: "127.0.0.1", port: targetPort, readableHighWaterMark: 1})
949
+ await onceEvent(socket, "connect")
950
+ await waitUntil(() => gateway.status().rawDataBytes >= 8192, "initial gateway window")
951
+ await new Promise((resolve) => setImmediate(resolve))
952
+ assert.equal(gateway.status().rawDataBytes, 8192)
953
+ assert.deepEqual(await withTimeout(readBytes(socket, payload.length), "remaining gateway DATA after window credit"), payload)
954
+ socket.destroy()
955
+ })
956
+
957
+ test("reverse peers reconnect after graceful hub shutdown", async (t) => {
958
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-hub-restart-"))
959
+ t.after(() => rm(root, {recursive: true, force: true}))
960
+ const certificates = generateTestCertificates(root)
961
+ const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
962
+ const hubOptions = {tokens, tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile}, knownGatewayIds: ["o2"], authorizeGateway: () => true, authorizeClientGateway: () => true, authorizeOpen: () => false}
963
+ let hub = await startReverseGatewayHub(hubOptions)
964
+ const hubSockets = new Set()
965
+ hub.server.on("secureConnection", (socket) => { hubSockets.add(socket); socket.once("close", () => hubSockets.delete(socket)) })
966
+ const port = hub.port
967
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
968
+ const gateway = new OutboundReverseGateway({gatewayId: "o2", connectionId: "hub_restart_gateway", hub: {host: "127.0.0.1", port, token: "gateway-token"}, tls, authorizeTarget: () => false, reconnectDelayMs: 10, maxReconnectDelayMs: 20})
969
+ const client = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "hub_restart_client", hub: {host: "127.0.0.1", port, token: "client-token"}, tls, reconnectDelayMs: 10, maxReconnectDelayMs: 20})
970
+ t.after(async () => { await client.close(); await gateway.close(); await hub.close() })
971
+ await gateway.start()
972
+ await client.connect()
973
+ assert.equal(hubSockets.size, 2)
974
+ const shutdown = encodeV3Frame({type: V3FrameType.GOAWAY, streamId: 0, sequence: 0}, encodeV3Control({code: "HUB_SHUTDOWN"}))
975
+ await Promise.all([...hubSockets].map((socket) => new Promise((resolve, reject) => socket.write(shutdown, (/** @type {Error | null | undefined} */ error) => error ? reject(error) : resolve(undefined)))))
976
+ await withTimeout(hub.close(), "graceful hub shutdown")
977
+ hub = await startReverseGatewayHub({...hubOptions, port})
978
+ await waitUntil(() => gateway.status().physicalState === "ready" && client.status().physicalState === "ready" && gateway.status().physicalConnects >= 2 && client.status().physicalConnects >= 2, "reverse peers after hub restart")
979
+ })
980
+
981
+ test("reverse hub bounds and times out TCP sockets stalled before TLS", async (t) => {
982
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-pre-tls-"))
983
+ t.after(() => rm(root, {recursive: true, force: true}))
984
+ const certificates = generateTestCertificates(root)
985
+ const hub = await startReverseGatewayHub({
986
+ tokens: new StaticTokenStore([]),
987
+ tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile},
988
+ authorizeGateway: () => false,
989
+ authorizeClientGateway: () => false,
990
+ authorizeOpen: () => false,
991
+ maxPhysicalConnections: 1,
992
+ handshakeTimeoutMs: 50
993
+ })
994
+ /** @type {import('node:net').Socket | undefined} */
995
+ let first
996
+ /** @type {import('node:net').Socket | undefined} */
997
+ let second
998
+ t.after(async () => { first?.destroy(); second?.destroy(); await hub.close() })
999
+ first = connectTcp({host: "127.0.0.1", port: hub.port})
1000
+ const firstClosed = onceEvent(first, "close")
1001
+ await onceEvent(first, "connect")
1002
+ await waitUntil(() => hub.status().physicalConnections === 1, "pre-TLS socket admission")
1003
+ second = connectTcp({host: "127.0.0.1", port: hub.port})
1004
+ const secondClosed = onceEvent(second, "close")
1005
+ await onceEvent(second, "connect")
1006
+ await withTimeout(secondClosed, "excess pre-TLS socket rejection")
1007
+ await withTimeout(firstClosed, "pre-TLS handshake timeout")
1008
+ await waitUntil(() => hub.status().physicalConnections === 0, "pre-TLS socket cleanup")
1009
+ })
1010
+
1011
+ test("reverse hub evicts disconnected client identities at its bound", async (t) => {
1012
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-identities-"))
1013
+ t.after(() => rm(root, {recursive: true, force: true}))
1014
+ const certificates = generateTestCertificates(root)
1015
+ const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
1016
+ const hub = await startReverseGatewayHub({tokens, tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile}, knownGatewayIds: ["o2"], authorizeGateway: () => true, authorizeClientGateway: () => true, authorizeOpen: () => false, maxConnectionIdentities: 1})
1017
+ t.after(() => hub.close())
1018
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
1019
+ const gateway = new OutboundReverseGateway({gatewayId: "o2", connectionId: "bounded_identity_gateway", hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"}, tls, authorizeTarget: () => false})
1020
+ t.after(() => gateway.close())
1021
+ await gateway.start()
1022
+ const first = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "bounded_identity_one", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls, continuousReconnect: false})
1023
+ await first.connect()
1024
+ await first.close()
1025
+ await waitUntil(() => hub.status().clientPhysicalConnections === 0, "first identity disconnect")
1026
+ const second = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "bounded_identity_two", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls, continuousReconnect: false})
1027
+ t.after(() => second.close())
1028
+ await second.connect()
1029
+ assert.equal(second.status().physicalState, "ready")
1030
+ assert.equal(hub.status().retainedClientIdentities, 1)
1031
+ const third = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "bounded_identity_three", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls, continuousReconnect: false})
1032
+ t.after(() => third.close())
1033
+ await assert.rejects(third.connect(), (error) => /** @type {{code?: string}} */ (error).code === "IDENTITY_LIMIT")
1034
+ assert.equal(second.status().physicalState, "ready")
1035
+ })
1036
+
1037
+ test("reverse hub rejects bad credentials, unknown gateways, duplicate owners, and stale generations", async (t) => {
1038
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-auth-"))
1039
+ t.after(() => rm(root, {recursive: true, force: true}))
1040
+ const certificates = generateTestCertificates(root)
1041
+ const tokens = new StaticTokenStore([
1042
+ {username: "home-o2", token: "gateway-valid-token"},
1043
+ {username: "tensorbuzz", token: "client-valid-token"},
1044
+ {username: "untrusted", token: "untrusted-client-token"}
1045
+ ])
1046
+ const hub = await startReverseGatewayHub({
1047
+ tokens,
1048
+ tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile},
1049
+ knownGatewayIds: ["o2"],
1050
+ authorizeGateway: (/** @type {{principal: {username: string}, gatewayId: string}} */ {principal, gatewayId}) => principal.username === "home-o2" && gatewayId === "o2",
1051
+ authorizeClientGateway: (/** @type {{principal: {username: string}}} */ {principal}) => principal.username === "tensorbuzz",
1052
+ authorizeOpen: () => false
1053
+ })
1054
+ t.after(() => hub.close())
1055
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
1056
+
1057
+ const badGateway = new OutboundReverseGateway({gatewayId: "o2", connectionId: "bad_gateway_identity", hub: {host: "127.0.0.1", port: hub.port, token: "wrong-token"}, tls, authorizeTarget: () => false})
1058
+ t.after(() => badGateway.close())
1059
+ await assert.rejects(withTimeout(badGateway.start(), "bad gateway auth"), (error) => /** @type {{code?: string}} */ (error).code === "AUTH_REQUIRED")
1060
+
1061
+ const badClient = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "bad_client_identity", hub: {host: "127.0.0.1", port: hub.port, token: "wrong-token"}, tls})
1062
+ t.after(() => badClient.close())
1063
+ await assertRepeatedConnectRejection(badClient, "bad client auth", (error) => error.code === "AUTH_REQUIRED")
1064
+
1065
+ const unknown = new ReverseGatewayConnection({gatewayId: "missing", connectionId: "unknown_gateway_client", hub: {host: "127.0.0.1", port: hub.port, token: "client-valid-token"}, tls})
1066
+ t.after(() => unknown.close())
1067
+ await assertRepeatedConnectRejection(unknown, "unknown gateway", (error) => error.code === "UNKNOWN_GATEWAY_ID")
1068
+
1069
+ const primary = new OutboundReverseGateway({gatewayId: "o2", connectionId: "stable_gateway_owner", hub: {host: "127.0.0.1", port: hub.port, token: "gateway-valid-token"}, tls, authorizeTarget: () => false})
1070
+ t.after(() => primary.close())
1071
+ await withTimeout(primary.start(), "primary gateway")
1072
+
1073
+ const untrusted = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "untrusted_client_identity", hub: {host: "127.0.0.1", port: hub.port, token: "untrusted-client-token"}, tls})
1074
+ t.after(() => untrusted.close())
1075
+ await assertRepeatedConnectRejection(untrusted, "unauthorized gateway selection", (error) => error.code === "GATEWAY_UNAUTHORIZED")
1076
+
1077
+ const duplicate = new OutboundReverseGateway({gatewayId: "o2", connectionId: "different_gateway_owner", hub: {host: "127.0.0.1", port: hub.port, token: "gateway-valid-token"}, tls, authorizeTarget: () => false})
1078
+ t.after(() => duplicate.close())
1079
+ await assert.rejects(withTimeout(duplicate.start(), "duplicate gateway"), (error) => /** @type {{code?: string}} */ (error).code === "DUPLICATE_GATEWAY")
1080
+
1081
+ const stale = new OutboundReverseGateway({gatewayId: "o2", connectionId: "stable_gateway_owner", initialGeneration: 1, hub: {host: "127.0.0.1", port: hub.port, token: "gateway-valid-token"}, tls, authorizeTarget: () => false})
1082
+ t.after(() => stale.close())
1083
+ await assert.rejects(withTimeout(stale.start(), "stale gateway generation"), (error) => /** @type {{code?: string}} */ (error).code === "STALE_GENERATION")
1084
+ assert.equal(hub.status().healthyGateways, 1)
1085
+ assert.equal(hub.status().rejectedRegistrations, 2)
1086
+
1087
+ const replacement = new OutboundReverseGateway({gatewayId: "o2", connectionId: "stable_gateway_owner", initialGeneration: primary.status().connectionGeneration + 100, hub: {host: "127.0.0.1", port: hub.port, token: "gateway-valid-token"}, tls, authorizeTarget: () => false})
1088
+ t.after(() => replacement.close())
1089
+ await replacement.start()
1090
+ await waitUntil(() => primary.status().physicalState === "closed", "newer generation fences prior owner")
1091
+ assert.equal(hub.status().healthyGateways, 1)
1092
+ })
1093
+
1094
+ test("hub and gateway both enforce target policy without opening a proxy", async (t) => {
1095
+ /** @type {Array<{event: string} & Record<string, unknown>>} */
1096
+ const gatewayLifecycle = []
1097
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-policy-"))
1098
+ t.after(() => rm(root, {recursive: true, force: true}))
1099
+ const certificates = generateTestCertificates(root)
1100
+ let targetAccepts = 0
1101
+ const targetSockets = new Set()
1102
+ const target = createServer((socket) => {
1103
+ targetAccepts += 1
1104
+ targetSockets.add(socket)
1105
+ socket.once("close", () => targetSockets.delete(socket))
1106
+ })
1107
+ const targetPort = await listen(target)
1108
+ t.after(() => {
1109
+ for (const socket of targetSockets) socket.destroy()
1110
+ return new Promise((resolve) => target.close(() => resolve(undefined)))
1111
+ })
1112
+ const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
1113
+ let hubAllows = false
1114
+ const hub = await startReverseGatewayHub({
1115
+ tokens,
1116
+ tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile},
1117
+ authorizeGateway: () => true,
1118
+ authorizeClientGateway: () => true,
1119
+ authorizeOpen: () => hubAllows
1120
+ })
1121
+ t.after(() => hub.close())
1122
+ let gatewayAllows = false
1123
+ const gateway = new OutboundReverseGateway({
1124
+ gatewayId: "o2",
1125
+ connectionId: "policy_gateway_01",
1126
+ hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"},
1127
+ tls: {caFile: certificates.caCertFile, servername: "localhost"},
1128
+ authorizeTarget: () => gatewayAllows,
1129
+ logger: {log: (/** @type {string} */ event, /** @type {Record<string, unknown>} */ fields = {}) => gatewayLifecycle.push({event, ...fields})}
1130
+ })
1131
+ t.after(() => gateway.close())
1132
+ await gateway.start()
1133
+ const client = new ReverseGatewayConnection({
1134
+ gatewayId: "o2",
1135
+ connectionId: "policy_client_01",
1136
+ hub: {host: "127.0.0.1", port: hub.port, token: "client-token"},
1137
+ tls: {caFile: certificates.caCertFile, servername: "localhost"}
1138
+ })
1139
+ t.after(() => client.close())
1140
+ await client.connect()
1141
+
1142
+ const hubDenied = client.createConnection({host: "127.0.0.1", port: targetPort})
1143
+ assert.equal((await nextError(hubDenied)).code, "TARGET_UNAUTHORIZED")
1144
+ hubAllows = true
1145
+ const gatewayDenied = client.createConnection({host: "127.0.0.1", port: targetPort})
1146
+ assert.equal((await nextError(gatewayDenied)).code, "TARGET_UNAUTHORIZED")
1147
+ assert.equal(targetAccepts, 0)
1148
+
1149
+ gatewayAllows = true
1150
+ const allowed = client.createConnection({host: "127.0.0.1", port: targetPort})
1151
+ await onceEvent(allowed, "connect")
1152
+ assert.equal(targetAccepts, 1)
1153
+ assert(gatewayLifecycle.some(({event, errorCode}) => event === "reverse_gateway.target_open_failed" && errorCode === "TARGET_UNAUTHORIZED"))
1154
+ assert(gatewayLifecycle.some(({event}) => event === "reverse_gateway.target_opened"))
1155
+ allowed.destroy()
1156
+ })
1157
+
1158
+ test("gateway loss terminates uncertain streams, reconnects one owner, and accepts clean new work", async (t) => {
1159
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-reconnect-"))
1160
+ t.after(() => rm(root, {recursive: true, force: true}))
1161
+ const certificates = generateTestCertificates(root)
1162
+ const targetSockets = new Set()
1163
+ const target = createServer((socket) => {
1164
+ targetSockets.add(socket)
1165
+ socket.once("close", () => targetSockets.delete(socket))
1166
+ socket.on("data", (chunk) => socket.write(chunk))
1167
+ })
1168
+ const targetPort = await listen(target)
1169
+ t.after(() => {
1170
+ for (const socket of targetSockets) socket.destroy()
1171
+ return new Promise((resolve) => target.close(() => resolve(undefined)))
1172
+ })
1173
+ const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
1174
+ const hub = await startReverseGatewayHub({tokens, tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile}, authorizeGateway: () => true, authorizeClientGateway: () => true, authorizeOpen: () => true})
1175
+ t.after(() => hub.close())
1176
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
1177
+ const gateway = new OutboundReverseGateway({gatewayId: "o2", connectionId: "reconnecting_gateway", hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"}, tls, authorizeTarget: () => true, reconnectDelayMs: 10, maxReconnectDelayMs: 20})
1178
+ t.after(() => gateway.close())
1179
+ await gateway.start()
1180
+ const client = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "reconnecting_client", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls, reconnectDelayMs: 10, maxReconnectDelayMs: 20})
1181
+ t.after(() => client.close())
1182
+ await client.connect()
1183
+ const uncertain = client.createConnection({host: "127.0.0.1", port: targetPort})
1184
+ await onceEvent(uncertain, "connect")
1185
+ const lost = nextError(uncertain)
1186
+ await hub.closeActiveGateway("o2")
1187
+ assert.equal((await lost).code, "GATEWAY_DISCONNECTED")
1188
+ await waitUntil(() => gateway.status().physicalConnects >= 2 && client.status().physicalConnects >= 2 && gateway.status().physicalState === "ready" && client.status().physicalState === "ready", "gateway and selected member reconnect")
1189
+ await waitUntil(() => targetSockets.size === 0, "uncertain target cleanup")
1190
+ assert.equal(hub.status().virtualStreams, 0)
1191
+ assert.equal(targetSockets.size, 0)
1192
+
1193
+ const clean = client.createConnection({host: "127.0.0.1", port: targetPort})
1194
+ await onceEvent(clean, "connect")
1195
+ const echoed = readBytes(clean, 12)
1196
+ clean.write("clean-retry!")
1197
+ assert.equal((await echoed).toString(), "clean-retry!")
1198
+ assert(gateway.status().physicalConnects >= 2)
1199
+ assert(client.status().physicalConnects >= 2)
1200
+ clean.destroy()
1201
+ })
1202
+
1203
+ test("reverse mode enforces verified TLS and stream resource bounds", async (t) => {
1204
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-limits-"))
1205
+ const wrongRoot = await mkdtemp(join(tmpdir(), "socketduct-reverse-wrong-ca-"))
1206
+ t.after(() => Promise.all([rm(root, {recursive: true, force: true}), rm(wrongRoot, {recursive: true, force: true})]))
1207
+ const certificates = generateTestCertificates(root)
1208
+ const wrongCertificates = generateTestCertificates(wrongRoot)
1209
+ const targetSockets = new Set()
1210
+ const target = createServer((socket) => { targetSockets.add(socket); socket.once("close", () => targetSockets.delete(socket)); socket.on("data", (chunk) => socket.write(chunk)) })
1211
+ const targetPort = await listen(target)
1212
+ t.after(() => { for (const socket of targetSockets) socket.destroy(); return new Promise((resolve) => target.close(() => resolve(undefined))) })
1213
+ const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
1214
+ const hub = await startReverseGatewayHub({tokens, tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile}, authorizeGateway: () => true, authorizeClientGateway: () => true, authorizeOpen: () => true})
1215
+ t.after(() => hub.close())
1216
+
1217
+ assert.throws(() => new ReverseGatewayConnection({
1218
+ gatewayId: "o2",
1219
+ connectionId: "insecure_tls_client",
1220
+ hub: {host: "127.0.0.1", port: hub.port, token: "client-token"},
1221
+ tls: /** @type {{caFile: string, rejectUnauthorized: boolean}} */ ({caFile: certificates.caCertFile, rejectUnauthorized: false})
1222
+ }), /Unsupported reverse-gateway TLS option/)
1223
+
1224
+ const wrongCa = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "wrong_ca_client_01", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls: {caFile: wrongCertificates.caCertFile, servername: "localhost"}})
1225
+ t.after(() => wrongCa.close())
1226
+ await assertRepeatedConnectRejection(wrongCa, "wrong CA rejection", (error) => /certificate|issuer|verify/i.test(error.message))
1227
+
1228
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
1229
+ const gateway = new OutboundReverseGateway({gatewayId: "o2", connectionId: "bounded_gateway_01", hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"}, tls, authorizeTarget: () => true, maxStreams: 1, initialWindowBytes: 8192, maxFrameBytes: 4096})
1230
+ t.after(() => gateway.close())
1231
+ await gateway.start()
1232
+ const client = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "bounded_client_01", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls, initialWindowBytes: 8192, maxFrameBytes: 4096, maxStreamBufferBytes: 16})
1233
+ t.after(() => client.close())
1234
+ await client.connect()
1235
+ const first = client.createConnection({host: "127.0.0.1", port: targetPort})
1236
+ await onceEvent(first, "connect")
1237
+ const second = client.createConnection({host: "127.0.0.1", port: targetPort})
1238
+ assert.equal((await nextError(second)).code, "GATEWAY_STREAM_LIMIT")
1239
+ assert.equal(client.status().physicalState, "ready")
1240
+ first.destroy()
1241
+ })
1242
+
1243
+ test("existing health-aware transport set distributes equally across three reverse members", async (t) => {
1244
+ const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-set-"))
1245
+ t.after(() => rm(root, {recursive: true, force: true}))
1246
+ const certificates = generateTestCertificates(root)
1247
+ const targetSockets = new Set()
1248
+ const target = createServer((socket) => { targetSockets.add(socket); socket.once("close", () => targetSockets.delete(socket)); socket.on("data", (chunk) => socket.write(chunk)) })
1249
+ const targetPort = await listen(target)
1250
+ t.after(() => { for (const socket of targetSockets) socket.destroy(); return new Promise((resolve) => target.close(() => resolve(undefined))) })
1251
+ const gatewayIds = ["o2", "zyxel", "starlink"]
1252
+ const tokens = new StaticTokenStore([
1253
+ ...gatewayIds.map((gatewayId) => ({username: `home-${gatewayId}`, token: `token-${gatewayId}`})),
1254
+ {username: "tensorbuzz", token: "tensorbuzz-token"}
1255
+ ])
1256
+ const hub = await startReverseGatewayHub({
1257
+ tokens,
1258
+ tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile},
1259
+ knownGatewayIds: gatewayIds,
1260
+ authorizeGateway: (/** @type {{principal: {username: string}, gatewayId: string}} */ {principal, gatewayId}) => principal.username === `home-${gatewayId}`,
1261
+ authorizeClientGateway: (/** @type {{principal: {username: string}}} */ {principal}) => principal.username === "tensorbuzz",
1262
+ authorizeOpen: (/** @type {{principal: {username: string}, target: {host: string, port: number}}} */ {principal, target: requested}) => principal.username === "tensorbuzz" && requested.host === "127.0.0.1" && requested.port === targetPort
1263
+ })
1264
+ t.after(() => hub.close())
1265
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
1266
+ const gateways = gatewayIds.map((gatewayId) => new OutboundReverseGateway({
1267
+ gatewayId,
1268
+ connectionId: `home_${gatewayId}_member_01`,
1269
+ hub: {host: "127.0.0.1", port: hub.port, token: `token-${gatewayId}`},
1270
+ tls,
1271
+ authorizeTarget: (requested) => requested.host === "127.0.0.1" && requested.port === targetPort
1272
+ }))
1273
+ t.after(() => Promise.all(gateways.map((gateway) => gateway.close())))
1274
+ await Promise.all(gateways.map((gateway) => gateway.start()))
1275
+
1276
+ const set = new MultiplexedSocketductTransportSet({
1277
+ connectionId: "tensorbuzz_reverse_set_01",
1278
+ minimumReadyMembers: 3,
1279
+ gateways: gatewayIds.map((gatewayId) => ({
1280
+ gatewayId,
1281
+ endpoint: {reverseHub: {host: "127.0.0.1", port: hub.port, token: "tensorbuzz-token"}, tls}
1282
+ }))
1283
+ })
1284
+ t.after(() => set.close())
1285
+ await set.start()
1286
+ const sockets = Array.from({length: 6}, () => set.createConnection({host: "127.0.0.1", port: targetPort}))
1287
+ await Promise.all(sockets.map((socket) => onceEvent(socket, "connect")))
1288
+ assert.deepEqual(sockets.map((socket) => socket.gatewayId), ["o2", "zyxel", "starlink", "o2", "zyxel", "starlink"])
1289
+ assert.deepEqual(set.status().members.map((member) => [member.gatewayId, member.assignments]), [["o2", 2], ["zyxel", 2], ["starlink", 2]])
1290
+ assert.equal(hub.status().gatewayPhysicalConnections, 3)
1291
+ assert.equal(hub.status().clientPhysicalConnections, 3)
1292
+ sockets.forEach((socket) => socket.destroy())
1293
+ })