socketduct 0.1.25 → 0.1.27

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 (58) hide show
  1. package/AGENTS.md +2 -2
  2. package/changelog.d/20261010130509-bound-log-frame-compression-memory.md +1 -0
  3. package/changelog.d/20261025070000-reclaim-unreadable-retention-lock.md +14 -0
  4. package/docs/runbooks/jsonl-integrity-rollout.md +8 -6
  5. package/package.json +2 -2
  6. package/scripts/test.js +9 -14
  7. package/src/logging/compressed-log-writer.js +1 -1
  8. package/src/logging/log-retention.js +36 -1
  9. package/src/logging/zstd-frame-codec.js +15 -1
  10. package/test/e2e/gateway-process.test.js +93 -92
  11. package/test/e2e/logger-shutdown-process.test.js +40 -37
  12. package/test/e2e/reverse-client-recovery-process.test.js +107 -103
  13. package/test/e2e/reverse-gateway-process.test.js +328 -318
  14. package/test/integration/initial-target-recovery.test.js +333 -334
  15. package/test/integration/logging-cli.test.js +121 -119
  16. package/test/integration/logging-compressed-recovery.test.js +96 -95
  17. package/test/integration/multiplexed-compatibility.test.js +178 -177
  18. package/test/integration/multiplexed-corrections.test.js +360 -359
  19. package/test/integration/multiplexed-failures.test.js +253 -251
  20. package/test/integration/multiplexed-transport-set.test.js +334 -322
  21. package/test/integration/multiplexed-transport.test.js +135 -134
  22. package/test/integration/reverse-gateway-stream-id-reset.test.js +90 -89
  23. package/test/integration/reverse-gateway.test.js +2309 -2218
  24. package/test/integration/reverse-resume-continuity.test.js +219 -218
  25. package/test/integration/reverse-route-class.test.js +358 -341
  26. package/test/integration/socketduct-http-agent.test.js +314 -312
  27. package/test/integration/socketduct-https-agent.test.js +516 -485
  28. package/test/integration/stream-compression.test.js +862 -824
  29. package/test/integration/tcp-forwarding.test.js +2550 -2447
  30. package/test/integration/tls-forwarding.test.js +593 -592
  31. package/test/unit/auth/auth.test.js +43 -44
  32. package/test/unit/auth/static-token-store.test.js +9 -8
  33. package/test/unit/client/connect.test.js +41 -40
  34. package/test/unit/client/deferred-virtual-socket.test.js +95 -94
  35. package/test/unit/client/multiplexed-spool-budget.test.js +155 -154
  36. package/test/unit/client/multiplexed-transport-set.test.js +830 -748
  37. package/test/unit/client/socketduct-socket.test.js +768 -771
  38. package/test/unit/client/virtual-socket.test.js +198 -168
  39. package/test/unit/config/docker-gateway-files.test.js +224 -224
  40. package/test/unit/config/environment.test.js +472 -492
  41. package/test/unit/index.test.js +53 -52
  42. package/test/unit/logging/compressed-log-worker.test.js +23 -22
  43. package/test/unit/logging/compressed-log-writer.test.js +155 -154
  44. package/test/unit/logging/log-retention.test.js +71 -48
  45. package/test/unit/logging/log-segment-index.test.js +84 -81
  46. package/test/unit/logging/zstd-frame-codec.test.js +28 -19
  47. package/test/unit/protocol/compression.test.js +254 -298
  48. package/test/unit/protocol/frame.test.js +280 -276
  49. package/test/unit/protocol/v3-compression.test.js +21 -23
  50. package/test/unit/protocol/v3-frame.test.js +42 -41
  51. package/test/unit/release-configuration.test.js +15 -12
  52. package/test/unit/reverse/disconnect-attribution.test.js +65 -64
  53. package/test/unit/reverse-terminal-outcomes.test.js +53 -50
  54. package/test/unit/server/control-handler.test.js +232 -231
  55. package/test/unit/server/primary.test.js +51 -49
  56. package/test/unit/server/relay-runtime.test.js +280 -294
  57. package/test/unit/sessions/session.test.js +48 -62
  58. package/test/unit/spool/spool-file.test.js +401 -385
@@ -1,10 +1,9 @@
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 {mkdtemp, rm} from "node:fs/promises"
4
4
  import {createServer} from "node:net"
5
5
  import {tmpdir} from "node:os"
6
6
  import {join} from "node:path"
7
- import test from "node:test"
8
7
 
9
8
  import {StaticTokenStore} from "../../src/auth/static-token-store.js"
10
9
  import {V3FrameType} from "../../src/protocol/v3-frame.js"
@@ -18,7 +17,7 @@ async function listen(server) {
18
17
  server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(undefined) })
19
18
  })
20
19
  const address = server.address()
21
- assert(address !== null && typeof address !== "string")
20
+ if (address === null || typeof address === "string") throw new Error("Server did not bind to a TCP port")
22
21
  return address.port
23
22
  }
24
23
 
@@ -86,365 +85,383 @@ async function exchange(socket, payload) {
86
85
  return await withTimeout(response, `response for ${payload}`)
87
86
  }
88
87
 
89
- test("route-class clients fail closed per OPEN while unaffected routes and legacy exact clients remain usable", async (t) => {
90
- const root = await mkdtemp(join(tmpdir(), "socketduct-route-class-"))
91
- t.after(() => rm(root, {recursive: true, force: true}))
92
- const certificates = generateTestCertificates(root)
93
- const targetA = await createEchoTarget("a:")
94
- const targetB = await createEchoTarget("b:")
95
- t.after(() => targetA.close())
96
- t.after(() => targetB.close())
88
+ describe("reverse gateway route-class", () => {
89
+ it("route-class clients fail closed per OPEN while unaffected routes and legacy exact clients remain usable", async () => withTestCleanup(async (after) => {
90
+ const root = await mkdtemp(join(tmpdir(), "socketduct-route-class-"))
91
+ after(() => rm(root, {recursive: true, force: true}))
92
+ const certificates = generateTestCertificates(root)
93
+ const targetA = await createEchoTarget("a:")
94
+ const targetB = await createEchoTarget("b:")
95
+ after(() => targetA.close())
96
+ after(() => targetB.close())
97
97
 
98
- const tokens = new StaticTokenStore([
99
- {username: "home-a", token: "gateway-a-token"},
100
- {username: "home-b", token: "gateway-b-token"},
101
- {username: "tensorbuzz", token: "client-token"},
102
- {username: "untrusted", token: "untrusted-token"}
103
- ])
104
- let ambiguousTargetA = false
105
- const hub = await startReverseGatewayHub({
106
- tokens,
107
- tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile},
108
- knownGatewayIds: ["server-a-starlink", "server-b-starlink"],
109
- knownRouteClasses: ["starlink"],
110
- authorizeGateway: ({principal, gatewayId}) => (
111
- (principal.username === "home-a" && gatewayId === "server-a-starlink")
112
- || (principal.username === "home-b" && gatewayId === "server-b-starlink")
113
- ),
114
- authorizeClientGateway: ({principal, gatewayId}) => principal.username === "tensorbuzz" && gatewayId === "server-a-starlink",
115
- authorizeClientRouteClass: ({principal}) => principal.username === "tensorbuzz",
116
- resolveOpenGateways: ({principal, routeClass, target}) => {
117
- if (principal.username !== "tensorbuzz" || routeClass !== "starlink" || target.host !== "127.0.0.1") return []
118
- if (target.port === targetA.port) return ambiguousTargetA ? ["server-a-starlink", "server-b-starlink"] : ["server-a-starlink"]
119
- if (target.port === targetB.port) return ["server-b-starlink"]
120
- return []
121
- },
122
- authorizeOpen: ({principal, gatewayId, target}) => principal.username === "tensorbuzz" && (
123
- (gatewayId === "server-a-starlink" && target.host === targetA.host && target.port === targetA.port)
124
- || (gatewayId === "server-b-starlink" && target.host === targetB.host && target.port === targetB.port)
125
- )
126
- })
127
- t.after(() => hub.close())
128
- const tls = {caFile: certificates.caCertFile, servername: "localhost"}
129
- const gatewayA = new OutboundReverseGateway({
130
- gatewayId: "server-a-starlink",
131
- connectionId: "route_class_gateway_a",
132
- hub: {host: "127.0.0.1", port: hub.port, token: "gateway-a-token"},
133
- tls,
134
- authorizeTarget: (target) => target.host === targetA.host && target.port === targetA.port,
135
- reconnectDelayMs: 10,
136
- maxReconnectDelayMs: 20
137
- })
138
- const gatewayB = new OutboundReverseGateway({
139
- gatewayId: "server-b-starlink",
140
- connectionId: "route_class_gateway_b",
141
- hub: {host: "127.0.0.1", port: hub.port, token: "gateway-b-token"},
142
- tls,
143
- authorizeTarget: (target) => target.host === targetB.host && target.port === targetB.port,
144
- reconnectDelayMs: 10,
145
- maxReconnectDelayMs: 20
146
- })
147
- t.after(() => gatewayA.close())
148
- t.after(() => gatewayB.close())
149
- await Promise.all([gatewayA.start(), gatewayB.start()])
98
+ const tokens = new StaticTokenStore([
99
+ {username: "home-a", token: "gateway-a-token"},
100
+ {username: "home-b", token: "gateway-b-token"},
101
+ {username: "tensorbuzz", token: "client-token"},
102
+ {username: "untrusted", token: "untrusted-token"}
103
+ ])
104
+ let ambiguousTargetA = false
105
+ const hub = await startReverseGatewayHub({
106
+ tokens,
107
+ tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile},
108
+ knownGatewayIds: ["server-a-starlink", "server-b-starlink"],
109
+ knownRouteClasses: ["starlink"],
110
+ authorizeGateway: ({principal, gatewayId}) => (
111
+ (principal.username === "home-a" && gatewayId === "server-a-starlink")
112
+ || (principal.username === "home-b" && gatewayId === "server-b-starlink")
113
+ ),
114
+ authorizeClientGateway: ({principal, gatewayId}) => principal.username === "tensorbuzz" && gatewayId === "server-a-starlink",
115
+ authorizeClientRouteClass: ({principal}) => principal.username === "tensorbuzz",
116
+ resolveOpenGateways: ({principal, routeClass, target}) => {
117
+ if (principal.username !== "tensorbuzz" || routeClass !== "starlink" || target.host !== "127.0.0.1") return []
118
+ if (target.port === targetA.port) return ambiguousTargetA ? ["server-a-starlink", "server-b-starlink"] : ["server-a-starlink"]
119
+ if (target.port === targetB.port) return ["server-b-starlink"]
120
+ return []
121
+ },
122
+ authorizeOpen: ({principal, gatewayId, target}) => principal.username === "tensorbuzz" && (
123
+ (gatewayId === "server-a-starlink" && target.host === targetA.host && target.port === targetA.port)
124
+ || (gatewayId === "server-b-starlink" && target.host === targetB.host && target.port === targetB.port)
125
+ )
126
+ })
127
+ after(() => hub.close())
128
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
129
+ const gatewayA = new OutboundReverseGateway({
130
+ gatewayId: "server-a-starlink",
131
+ connectionId: "route_class_gateway_a",
132
+ hub: {host: "127.0.0.1", port: hub.port, token: "gateway-a-token"},
133
+ tls,
134
+ authorizeTarget: (target) => target.host === targetA.host && target.port === targetA.port,
135
+ reconnectDelayMs: 10,
136
+ maxReconnectDelayMs: 20
137
+ })
138
+ const gatewayB = new OutboundReverseGateway({
139
+ gatewayId: "server-b-starlink",
140
+ connectionId: "route_class_gateway_b",
141
+ hub: {host: "127.0.0.1", port: hub.port, token: "gateway-b-token"},
142
+ tls,
143
+ authorizeTarget: (target) => target.host === targetB.host && target.port === targetB.port,
144
+ reconnectDelayMs: 10,
145
+ maxReconnectDelayMs: 20
146
+ })
147
+ after(() => gatewayA.close())
148
+ after(() => gatewayB.close())
149
+ await Promise.all([gatewayA.start(), gatewayB.start()])
150
150
 
151
- const unknown = new ReverseGatewayConnection({
152
- routeClass: "missing",
153
- connectionId: "route_class_unknown_client",
154
- hub: {host: "127.0.0.1", port: hub.port, token: "client-token"},
155
- tls,
156
- continuousReconnect: false
157
- })
158
- t.after(() => unknown.close())
159
- await assert.rejects(withTimeout(unknown.connect(), "unknown route class"), (error) => /** @type {{code?: string}} */ (error).code === "UNKNOWN_ROUTE_CLASS")
151
+ const unknown = new ReverseGatewayConnection({
152
+ routeClass: "missing",
153
+ connectionId: "route_class_unknown_client",
154
+ hub: {host: "127.0.0.1", port: hub.port, token: "client-token"},
155
+ tls,
156
+ continuousReconnect: false
157
+ })
158
+ after(() => unknown.close())
159
+ /** @type {Error & {code?: string} | undefined} */
160
+ let error
161
+ try {
162
+ await withTimeout(unknown.connect(), "unknown route class")
163
+ } catch (caught) {
164
+ error = /** @type {Error & {code?: string}} */ (caught)
165
+ }
166
+ expect(error).toBeDefined()
167
+ expect(error?.code).toBe("UNKNOWN_ROUTE_CLASS")
160
168
 
161
- const unauthorized = new ReverseGatewayConnection({
162
- routeClass: "starlink",
163
- connectionId: "route_class_unauthorized_client",
164
- hub: {host: "127.0.0.1", port: hub.port, token: "untrusted-token"},
165
- tls,
166
- continuousReconnect: false
167
- })
168
- t.after(() => unauthorized.close())
169
- await assert.rejects(withTimeout(unauthorized.connect(), "unauthorized route class"), (error) => /** @type {{code?: string}} */ (error).code === "ROUTE_CLASS_UNAUTHORIZED")
169
+ const unauthorized = new ReverseGatewayConnection({
170
+ routeClass: "starlink",
171
+ connectionId: "route_class_unauthorized_client",
172
+ hub: {host: "127.0.0.1", port: hub.port, token: "untrusted-token"},
173
+ tls,
174
+ continuousReconnect: false
175
+ })
176
+ after(() => unauthorized.close())
177
+ /** @type {Error & {code?: string} | undefined} */
178
+ let unauthorizedError
179
+ try {
180
+ await withTimeout(unauthorized.connect(), "unauthorized route class")
181
+ } catch (caught) {
182
+ unauthorizedError = /** @type {Error & {code?: string}} */ (caught)
183
+ }
184
+ expect(unauthorizedError).toBeDefined()
185
+ expect(unauthorizedError?.code).toBe("ROUTE_CLASS_UNAUTHORIZED")
170
186
 
171
- const client = new ReverseGatewayConnection({
172
- routeClass: "starlink",
173
- connectionId: "route_class_tensorbuzz_client",
174
- hub: {host: "127.0.0.1", port: hub.port, token: "client-token"},
175
- tls
176
- })
177
- t.after(() => client.close())
178
- await client.connect()
179
- assert.equal(client.status().physicalConnects, 1)
180
- assert.equal(hub.status().clientPhysicalConnections, 1)
187
+ const client = new ReverseGatewayConnection({
188
+ routeClass: "starlink",
189
+ connectionId: "route_class_tensorbuzz_client",
190
+ hub: {host: "127.0.0.1", port: hub.port, token: "client-token"},
191
+ tls
192
+ })
193
+ after(() => client.close())
194
+ await client.connect()
195
+ expect(client.status().physicalConnects).toBe(1)
196
+ expect(hub.status().clientPhysicalConnections).toBe(1)
181
197
 
182
- const denied = client.createConnection({host: "127.0.0.1", port: 65_534})
183
- assert.equal((await nextError(denied)).code, "TARGET_UNAUTHORIZED")
198
+ const denied = client.createConnection({host: "127.0.0.1", port: 65_534})
199
+ expect((await nextError(denied)).code).toBe("TARGET_UNAUTHORIZED")
184
200
 
185
- ambiguousTargetA = true
186
- const ambiguous = client.createConnection({host: targetA.host, port: targetA.port})
187
- assert.equal((await nextError(ambiguous)).code, "AMBIGUOUS_ROUTE")
188
- ambiguousTargetA = false
201
+ ambiguousTargetA = true
202
+ const ambiguous = client.createConnection({host: targetA.host, port: targetA.port})
203
+ expect((await nextError(ambiguous)).code).toBe("AMBIGUOUS_ROUTE")
204
+ ambiguousTargetA = false
189
205
 
190
- const legacy = new ReverseGatewayConnection({
191
- gatewayId: "server-a-starlink",
192
- connectionId: "legacy_exact_gateway_client",
193
- hub: {host: "127.0.0.1", port: hub.port, token: "client-token"},
194
- tls
195
- })
196
- t.after(() => legacy.close())
197
- await legacy.connect()
198
- const legacyStream = legacy.createConnection({host: targetA.host, port: targetA.port})
199
- await onceEvent(legacyStream, "connect")
200
- assert.equal(await exchange(legacyStream, "legacy"), "a:legacy")
201
- legacyStream.destroy()
202
- await legacy.close()
206
+ const legacy = new ReverseGatewayConnection({
207
+ gatewayId: "server-a-starlink",
208
+ connectionId: "legacy_exact_gateway_client",
209
+ hub: {host: "127.0.0.1", port: hub.port, token: "client-token"},
210
+ tls
211
+ })
212
+ after(() => legacy.close())
213
+ await legacy.connect()
214
+ const legacyStream = legacy.createConnection({host: targetA.host, port: targetA.port})
215
+ await onceEvent(legacyStream, "connect")
216
+ expect(await exchange(legacyStream, "legacy")).toBe("a:legacy")
217
+ legacyStream.destroy()
218
+ await legacy.close()
203
219
 
204
- const streamA = client.createConnection({host: targetA.host, port: targetA.port})
205
- const streamB = client.createConnection({host: targetB.host, port: targetB.port})
206
- await Promise.all([onceEvent(streamA, "connect"), onceEvent(streamB, "connect")])
207
- assert.deepEqual(await Promise.all([exchange(streamA, "one"), exchange(streamB, "two")]), ["a:one", "b:two"])
220
+ const streamA = client.createConnection({host: targetA.host, port: targetA.port})
221
+ const streamB = client.createConnection({host: targetB.host, port: targetB.port})
222
+ await Promise.all([onceEvent(streamA, "connect"), onceEvent(streamB, "connect")])
223
+ expect(await Promise.all([exchange(streamA, "one"), exchange(streamB, "two")])).toEqual(["a:one", "b:two"])
208
224
 
209
- const resumed = Promise.all([onceEvent(streamA, "ready"), onceEvent(streamB, "ready")])
210
- await hub.closeActiveGateway("server-a-starlink")
211
- await withTimeout(resumed, "route-class streams resume after gateway transport replacement")
212
- await waitUntil(() => hub.status().healthyGateways === 2, "gateway A re-registration")
213
- assert.equal(await exchange(streamA, "resumed"), "a:resumed")
214
- assert.equal(await exchange(streamB, "still-open"), "b:still-open")
225
+ const resumed = Promise.all([onceEvent(streamA, "ready"), onceEvent(streamB, "ready")])
226
+ await hub.closeActiveGateway("server-a-starlink")
227
+ await withTimeout(resumed, "route-class streams resume after gateway transport replacement")
228
+ await waitUntil(() => hub.status().healthyGateways === 2, "gateway A re-registration")
229
+ expect(await exchange(streamA, "resumed")).toBe("a:resumed")
230
+ expect(await exchange(streamB, "still-open")).toBe("b:still-open")
215
231
 
216
- const anotherA = client.createConnection({host: targetA.host, port: targetA.port})
217
- await onceEvent(anotherA, "connect")
218
- assert.equal(await exchange(anotherA, "new-a"), "a:new-a")
219
- const anotherB = client.createConnection({host: targetB.host, port: targetB.port})
220
- await onceEvent(anotherB, "connect")
221
- assert.equal(await exchange(anotherB, "new"), "b:new")
232
+ const anotherA = client.createConnection({host: targetA.host, port: targetA.port})
233
+ await onceEvent(anotherA, "connect")
234
+ expect(await exchange(anotherA, "new-a")).toBe("a:new-a")
235
+ const anotherB = client.createConnection({host: targetB.host, port: targetB.port})
236
+ await onceEvent(anotherB, "connect")
237
+ expect(await exchange(anotherB, "new")).toBe("b:new")
222
238
 
223
- streamA.destroy()
224
- anotherA.destroy()
225
- await gatewayA.close()
226
- const replacementA = new OutboundReverseGateway({
227
- gatewayId: "server-a-starlink",
228
- connectionId: "route_class_gateway_a",
229
- initialGeneration: gatewayA.status().connectionGeneration,
230
- hub: {host: "127.0.0.1", port: hub.port, token: "gateway-a-token"},
231
- tls,
232
- authorizeTarget: (target) => target.host === targetA.host && target.port === targetA.port
233
- })
234
- t.after(() => replacementA.close())
235
- await replacementA.start()
236
- const rejoinedA = client.createConnection({host: targetA.host, port: targetA.port})
237
- await onceEvent(rejoinedA, "connect")
238
- assert.equal(await exchange(rejoinedA, "rejoined"), "a:rejoined")
239
- assert(client.status().physicalConnects >= 2)
240
- assert.equal(hub.status().clientPhysicalConnections, 1)
241
- streamB.destroy()
242
- anotherB.destroy()
243
- rejoinedA.destroy()
244
- })
239
+ streamA.destroy()
240
+ anotherA.destroy()
241
+ await gatewayA.close()
242
+ const replacementA = new OutboundReverseGateway({
243
+ gatewayId: "server-a-starlink",
244
+ connectionId: "route_class_gateway_a",
245
+ initialGeneration: gatewayA.status().connectionGeneration,
246
+ hub: {host: "127.0.0.1", port: hub.port, token: "gateway-a-token"},
247
+ tls,
248
+ authorizeTarget: (target) => target.host === targetA.host && target.port === targetA.port
249
+ })
250
+ after(() => replacementA.close())
251
+ await replacementA.start()
252
+ const rejoinedA = client.createConnection({host: targetA.host, port: targetA.port})
253
+ await onceEvent(rejoinedA, "connect")
254
+ expect(await exchange(rejoinedA, "rejoined")).toBe("a:rejoined")
255
+ expect(client.status().physicalConnects).toBeGreaterThanOrEqual(2)
256
+ expect(hub.status().clientPhysicalConnections).toBe(1)
257
+ streamB.destroy()
258
+ anotherB.destroy()
259
+ rejoinedA.destroy()
260
+ }))
245
261
 
246
- test("a backpressured gateway does not block DATA or a new OPEN for another route-class gateway", async (t) => {
247
- const root = await mkdtemp(join(tmpdir(), "socketduct-route-class-backpressure-"))
248
- t.after(() => rm(root, {recursive: true, force: true}))
249
- const certificates = generateTestCertificates(root)
250
- const targetA = await createEchoTarget("a:")
251
- const targetB = await createEchoTarget("b:")
252
- t.after(() => targetA.close())
253
- t.after(() => targetB.close())
262
+ it("a backpressured gateway does not block DATA or a new OPEN for another route-class gateway", async () => withTestCleanup(async (after) => {
263
+ const root = await mkdtemp(join(tmpdir(), "socketduct-route-class-backpressure-"))
264
+ after(() => rm(root, {recursive: true, force: true}))
265
+ const certificates = generateTestCertificates(root)
266
+ const targetA = await createEchoTarget("a:")
267
+ const targetB = await createEchoTarget("b:")
268
+ after(() => targetA.close())
269
+ after(() => targetB.close())
254
270
 
255
- const hub = await startReverseGatewayHub({
256
- tokens: new StaticTokenStore([
257
- {username: "home-a", token: "gateway-a-token"},
258
- {username: "home-b", token: "gateway-b-token"},
259
- {username: "tensorbuzz", token: "client-token"}
260
- ]),
261
- tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile},
262
- knownGatewayIds: ["server-a-starlink", "server-b-starlink"],
263
- knownRouteClasses: ["starlink"],
264
- authorizeGateway: ({principal, gatewayId}) => (
265
- (principal.username === "home-a" && gatewayId === "server-a-starlink")
266
- || (principal.username === "home-b" && gatewayId === "server-b-starlink")
267
- ),
268
- authorizeClientGateway: () => false,
269
- authorizeClientRouteClass: ({principal, routeClass}) => principal.username === "tensorbuzz" && routeClass === "starlink",
270
- resolveOpenGateways: ({routeClass, target}) => {
271
- if (routeClass !== "starlink" || target.host !== "127.0.0.1") return []
272
- if (target.port === targetA.port) return ["server-a-starlink"]
273
- if (target.port === targetB.port) return ["server-b-starlink"]
274
- return []
275
- },
276
- authorizeOpen: ({gatewayId, target}) => (
277
- (gatewayId === "server-a-starlink" && target.port === targetA.port)
278
- || (gatewayId === "server-b-starlink" && target.port === targetB.port)
279
- ),
280
- maxOutputBytesPerConnection: 1_024
281
- })
282
- t.after(() => hub.close())
283
- /** @type {import('node:tls').TLSSocket[]} */
284
- const accepted = []
285
- hub.server.on("secureConnection", (socket) => accepted.push(socket))
286
- const tls = {caFile: certificates.caCertFile, servername: "localhost"}
287
- const gatewayA = new OutboundReverseGateway({
288
- gatewayId: "server-a-starlink",
289
- connectionId: "backpressure_gateway_a",
290
- hub: {host: "127.0.0.1", port: hub.port, token: "gateway-a-token"},
291
- tls,
292
- authorizeTarget: (target) => target.host === targetA.host && target.port === targetA.port,
293
- reconnectDelayMs: 10_000,
294
- maxReconnectDelayMs: 10_000
295
- })
296
- const gatewayB = new OutboundReverseGateway({
297
- gatewayId: "server-b-starlink",
298
- connectionId: "backpressure_gateway_b",
299
- hub: {host: "127.0.0.1", port: hub.port, token: "gateway-b-token"},
300
- tls,
301
- authorizeTarget: (target) => target.host === targetB.host && target.port === targetB.port,
302
- reconnectDelayMs: 10_000,
303
- maxReconnectDelayMs: 10_000
304
- })
305
- t.after(() => gatewayA.close())
306
- t.after(() => gatewayB.close())
307
- await gatewayA.start()
308
- await gatewayB.start()
309
- assert.equal(accepted.length, 2)
271
+ const hub = await startReverseGatewayHub({
272
+ tokens: new StaticTokenStore([
273
+ {username: "home-a", token: "gateway-a-token"},
274
+ {username: "home-b", token: "gateway-b-token"},
275
+ {username: "tensorbuzz", token: "client-token"}
276
+ ]),
277
+ tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile},
278
+ knownGatewayIds: ["server-a-starlink", "server-b-starlink"],
279
+ knownRouteClasses: ["starlink"],
280
+ authorizeGateway: ({principal, gatewayId}) => (
281
+ (principal.username === "home-a" && gatewayId === "server-a-starlink")
282
+ || (principal.username === "home-b" && gatewayId === "server-b-starlink")
283
+ ),
284
+ authorizeClientGateway: () => false,
285
+ authorizeClientRouteClass: ({principal, routeClass}) => principal.username === "tensorbuzz" && routeClass === "starlink",
286
+ resolveOpenGateways: ({routeClass, target}) => {
287
+ if (routeClass !== "starlink" || target.host !== "127.0.0.1") return []
288
+ if (target.port === targetA.port) return ["server-a-starlink"]
289
+ if (target.port === targetB.port) return ["server-b-starlink"]
290
+ return []
291
+ },
292
+ authorizeOpen: ({gatewayId, target}) => (
293
+ (gatewayId === "server-a-starlink" && target.port === targetA.port)
294
+ || (gatewayId === "server-b-starlink" && target.port === targetB.port)
295
+ ),
296
+ maxOutputBytesPerConnection: 1_024
297
+ })
298
+ after(() => hub.close())
299
+ /** @type {import('node:tls').TLSSocket[]} */
300
+ const accepted = []
301
+ hub.server.on("secureConnection", (socket) => accepted.push(socket))
302
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
303
+ const gatewayA = new OutboundReverseGateway({
304
+ gatewayId: "server-a-starlink",
305
+ connectionId: "backpressure_gateway_a",
306
+ hub: {host: "127.0.0.1", port: hub.port, token: "gateway-a-token"},
307
+ tls,
308
+ authorizeTarget: (target) => target.host === targetA.host && target.port === targetA.port,
309
+ reconnectDelayMs: 10_000,
310
+ maxReconnectDelayMs: 10_000
311
+ })
312
+ const gatewayB = new OutboundReverseGateway({
313
+ gatewayId: "server-b-starlink",
314
+ connectionId: "backpressure_gateway_b",
315
+ hub: {host: "127.0.0.1", port: hub.port, token: "gateway-b-token"},
316
+ tls,
317
+ authorizeTarget: (target) => target.host === targetB.host && target.port === targetB.port,
318
+ reconnectDelayMs: 10_000,
319
+ maxReconnectDelayMs: 10_000
320
+ })
321
+ after(() => gatewayA.close())
322
+ after(() => gatewayB.close())
323
+ await gatewayA.start()
324
+ await gatewayB.start()
325
+ expect(accepted.length).toBe(2)
310
326
 
311
- const client = new ReverseGatewayConnection({
312
- routeClass: "starlink",
313
- connectionId: "backpressure_route_client",
314
- hub: {host: "127.0.0.1", port: hub.port, token: "client-token"},
315
- tls,
316
- continuousReconnect: false,
317
- reconnectDelayMs: 10_000,
318
- maxReconnectDelayMs: 10_000
319
- })
320
- t.after(() => client.close())
321
- await client.connect()
322
- const streamA = client.createConnection({host: targetA.host, port: targetA.port})
323
- const streamB = client.createConnection({host: targetB.host, port: targetB.port})
324
- await Promise.all([onceEvent(streamA, "connect"), onceEvent(streamB, "connect")])
327
+ const client = new ReverseGatewayConnection({
328
+ routeClass: "starlink",
329
+ connectionId: "backpressure_route_client",
330
+ hub: {host: "127.0.0.1", port: hub.port, token: "client-token"},
331
+ tls,
332
+ continuousReconnect: false,
333
+ reconnectDelayMs: 10_000,
334
+ maxReconnectDelayMs: 10_000
335
+ })
336
+ after(() => client.close())
337
+ await client.connect()
338
+ const streamA = client.createConnection({host: targetA.host, port: targetA.port})
339
+ const streamB = client.createConnection({host: targetB.host, port: targetB.port})
340
+ await Promise.all([onceEvent(streamA, "connect"), onceEvent(streamB, "connect")])
325
341
 
326
- const gatewayASocket = /** @type {import('node:tls').TLSSocket} */ (accepted[0])
327
- const writable = /** @type {{write: (chunk: Buffer) => boolean}} */ (/** @type {unknown} */ (gatewayASocket))
328
- const originalWrite = writable.write
329
- let blocked = false
330
- /** @type {() => void} */
331
- let markBlocked = () => {}
332
- const backpressured = new Promise((resolve) => { markBlocked = () => resolve(undefined) })
333
- writable.write = (chunk) => {
334
- const acceptedWrite = Reflect.apply(originalWrite, gatewayASocket, [chunk])
335
- if (!blocked && chunk[5] === V3FrameType.DATA) {
336
- blocked = true
337
- markBlocked()
338
- return false
342
+ const gatewayASocket = /** @type {import('node:tls').TLSSocket} */ (accepted[0])
343
+ const writable = /** @type {{write: (chunk: Buffer) => boolean}} */ (/** @type {unknown} */ (gatewayASocket))
344
+ const originalWrite = writable.write
345
+ let blocked = false
346
+ /** @type {() => void} */
347
+ let markBlocked = () => {}
348
+ const backpressured = new Promise((resolve) => { markBlocked = () => resolve(undefined) })
349
+ writable.write = (chunk) => {
350
+ const acceptedWrite = Reflect.apply(originalWrite, gatewayASocket, [chunk])
351
+ if (!blocked && chunk[5] === V3FrameType.DATA) {
352
+ blocked = true
353
+ markBlocked()
354
+ return false
355
+ }
356
+ return acceptedWrite
339
357
  }
340
- return acceptedWrite
341
- }
342
- t.after(() => {
343
- writable.write = originalWrite
344
- if (blocked) gatewayASocket.emit("drain")
345
- })
358
+ after(() => {
359
+ writable.write = originalWrite
360
+ if (blocked) gatewayASocket.emit("drain")
361
+ })
346
362
 
347
- streamA.write("blocked")
348
- await withTimeout(backpressured, "gateway A backpressure")
349
- const newStreamB = client.createConnection({host: targetB.host, port: targetB.port})
350
- t.after(() => newStreamB.destroy())
351
- const newStreamBExchange = (async () => {
352
- await onceEvent(newStreamB, "connect")
353
- return await exchange(newStreamB, "new-open")
354
- })()
355
- assert.deepEqual(await withTimeout(Promise.all([
356
- exchange(streamB, "existing"),
357
- newStreamBExchange
358
- ]), "healthy gateway progress while gateway A is backpressured"), ["b:existing", "b:new-open"])
359
- assert.equal(client.status().physicalConnects, 1)
360
- assert(hub.status().queuedOutputBytes > 0)
361
- const streamAError = nextError(streamA)
362
- const queuedAErrors = Array.from({length: 8}, () => {
363
- const socket = client.createConnection({host: targetA.host, port: targetA.port, sessionName: "a".repeat(256)})
364
- t.after(() => socket.destroy())
365
- return nextError(socket)
366
- })
367
- const outputError = /** @type {Error & Record<string, unknown>} */ (await withTimeout(streamAError, "gateway A output limit"))
368
- assert.equal(outputError.code, "OUTPUT_BACKPRESSURE_LIMIT")
369
- assert.equal(outputError.disconnectReason, "output_backpressure_limit")
370
- assert.equal(outputError.incidentOrigin, "reverse_hub")
371
- assert.equal(outputError.physicalRole, "gateway")
372
- assert.match(/** @type {string} */ (outputError.disconnectIncidentId), /^[A-Za-z0-9_-]{22}$/)
373
- await withTimeout(Promise.all(queuedAErrors), "queued gateway A stream cleanup")
374
- assert.equal(await exchange(streamB, "after-limit"), "b:after-limit")
375
- assert.equal(hub.status().healthyGateways, 1)
376
- assert.equal(hub.status().clientPhysicalConnections, 1)
377
- assert.equal(client.status().physicalConnects, 1)
378
- writable.write = originalWrite
379
- assert.equal(hub.status().queuedOutputBytes, 0)
380
- streamA.destroy()
381
- streamB.destroy()
382
- })
363
+ streamA.write("blocked")
364
+ await withTimeout(backpressured, "gateway A backpressure")
365
+ const newStreamB = client.createConnection({host: targetB.host, port: targetB.port})
366
+ after(() => { newStreamB.destroy() })
367
+ const newStreamBExchange = (async () => {
368
+ await onceEvent(newStreamB, "connect")
369
+ return await exchange(newStreamB, "new-open")
370
+ })()
371
+ expect(await withTimeout(Promise.all([
372
+ exchange(streamB, "existing"),
373
+ newStreamBExchange
374
+ ]), "healthy gateway progress while gateway A is backpressured")).toEqual(["b:existing", "b:new-open"])
375
+ expect(client.status().physicalConnects).toBe(1)
376
+ expect(hub.status().queuedOutputBytes).toBeGreaterThan(0)
377
+ const streamAError = nextError(streamA)
378
+ const queuedAErrors = Array.from({length: 8}, () => {
379
+ const socket = client.createConnection({host: targetA.host, port: targetA.port, sessionName: "a".repeat(256)})
380
+ after(() => { socket.destroy() })
381
+ return nextError(socket)
382
+ })
383
+ const outputError = /** @type {Error & Record<string, unknown>} */ (await withTimeout(streamAError, "gateway A output limit"))
384
+ expect(outputError.code).toBe("OUTPUT_BACKPRESSURE_LIMIT")
385
+ expect(outputError.disconnectReason).toBe("output_backpressure_limit")
386
+ expect(outputError.incidentOrigin).toBe("reverse_hub")
387
+ expect(outputError.physicalRole).toBe("gateway")
388
+ expect(/** @type {string} */ (outputError.disconnectIncidentId)).toMatch(/^[A-Za-z0-9_-]{22}$/)
389
+ await withTimeout(Promise.all(queuedAErrors), "queued gateway A stream cleanup")
390
+ expect(await exchange(streamB, "after-limit")).toBe("b:after-limit")
391
+ expect(hub.status().healthyGateways).toBe(1)
392
+ expect(hub.status().clientPhysicalConnections).toBe(1)
393
+ expect(client.status().physicalConnects).toBe(1)
394
+ writable.write = originalWrite
395
+ expect(hub.status().queuedOutputBytes).toBe(0)
396
+ streamA.destroy()
397
+ streamB.destroy()
398
+ }))
383
399
 
384
- test("hub announces route-class availability at selection and the client exposes gateway unavailability", async (t) => {
385
- const root = await mkdtemp(join(tmpdir(), "socketduct-route-class-selectable-"))
386
- t.after(() => rm(root, {recursive: true, force: true}))
387
- const certificates = generateTestCertificates(root)
388
- const targetA = await createEchoTarget("a:")
389
- t.after(() => targetA.close())
400
+ it("hub announces route-class availability at selection and the client exposes gateway unavailability", async () => withTestCleanup(async (after) => {
401
+ const root = await mkdtemp(join(tmpdir(), "socketduct-route-class-selectable-"))
402
+ after(() => rm(root, {recursive: true, force: true}))
403
+ const certificates = generateTestCertificates(root)
404
+ const targetA = await createEchoTarget("a:")
405
+ after(() => targetA.close())
390
406
 
391
- const hub = await startReverseGatewayHub({
392
- tokens: new StaticTokenStore([
393
- {username: "home-a", token: "gateway-a-token"},
394
- {username: "tensorbuzz", token: "client-token"}
395
- ]),
396
- tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile},
397
- knownGatewayIds: ["server-a-starlink"],
398
- knownRouteClasses: ["starlink"],
399
- authorizeGateway: ({principal, gatewayId}) => principal.username === "home-a" && gatewayId === "server-a-starlink",
400
- authorizeClientGateway: () => false,
401
- authorizeClientRouteClass: ({principal, routeClass}) => principal.username === "tensorbuzz" && routeClass === "starlink",
402
- resolveRouteClassGateways: () => ["server-a-starlink"],
403
- resolveOpenGateways: ({routeClass, target}) => (routeClass === "starlink" && target.host === targetA.host ? ["server-a-starlink"] : []),
404
- authorizeOpen: ({gatewayId, target}) => gatewayId === "server-a-starlink" && target.port === targetA.port
405
- })
406
- t.after(() => hub.close())
407
- const tls = {caFile: certificates.caCertFile, servername: "localhost"}
407
+ const hub = await startReverseGatewayHub({
408
+ tokens: new StaticTokenStore([
409
+ {username: "home-a", token: "gateway-a-token"},
410
+ {username: "tensorbuzz", token: "client-token"}
411
+ ]),
412
+ tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile},
413
+ knownGatewayIds: ["server-a-starlink"],
414
+ knownRouteClasses: ["starlink"],
415
+ authorizeGateway: ({principal, gatewayId}) => principal.username === "home-a" && gatewayId === "server-a-starlink",
416
+ authorizeClientGateway: () => false,
417
+ authorizeClientRouteClass: ({principal, routeClass}) => principal.username === "tensorbuzz" && routeClass === "starlink",
418
+ resolveRouteClassGateways: () => ["server-a-starlink"],
419
+ resolveOpenGateways: ({routeClass, target}) => (routeClass === "starlink" && target.host === targetA.host ? ["server-a-starlink"] : []),
420
+ authorizeOpen: ({gatewayId, target}) => gatewayId === "server-a-starlink" && target.port === targetA.port
421
+ })
422
+ after(() => hub.close())
423
+ const tls = {caFile: certificates.caCertFile, servername: "localhost"}
408
424
 
409
- const client = new ReverseGatewayConnection({
410
- routeClass: "starlink",
411
- connectionId: "route_class_selectable_client",
412
- hub: {host: "127.0.0.1", port: hub.port, token: "client-token"},
413
- tls
414
- })
415
- t.after(() => client.close())
425
+ const client = new ReverseGatewayConnection({
426
+ routeClass: "starlink",
427
+ connectionId: "route_class_selectable_client",
428
+ hub: {host: "127.0.0.1", port: hub.port, token: "client-token"},
429
+ tls
430
+ })
431
+ after(() => client.close())
416
432
 
417
- // No gateway registered for the route class yet: the hub must announce selectable=false
418
- // and the client must surface the member as gateway-unavailable before any open is attempted.
419
- await client.connect()
420
- assert.equal(client.status().physicalState, "ready")
421
- assert.equal(client.status().gatewayUnavailable, true)
433
+ // No gateway registered for the route class yet: the hub must announce selectable=false
434
+ // and the client must surface the member as gateway-unavailable before any open is attempted.
435
+ await client.connect()
436
+ expect(client.status().physicalState).toBe("ready")
437
+ expect(client.status().gatewayUnavailable).toBe(true)
422
438
 
423
- // Once the gateway registers, a replacement selection announces selectable=true and clears the mark.
424
- const gatewayA = new OutboundReverseGateway({
425
- gatewayId: "server-a-starlink",
426
- connectionId: "selectable_gateway_a",
427
- hub: {host: "127.0.0.1", port: hub.port, token: "gateway-a-token"},
428
- tls,
429
- authorizeTarget: (target) => target.host === targetA.host && target.port === targetA.port,
430
- reconnectDelayMs: 10,
431
- maxReconnectDelayMs: 20
432
- })
433
- t.after(() => gatewayA.close())
434
- await gatewayA.start()
435
- await waitUntil(() => hub.status().healthyGateways === 1, "gateway A registration")
439
+ // Once the gateway registers, a replacement selection announces selectable=true and clears the mark.
440
+ const gatewayA = new OutboundReverseGateway({
441
+ gatewayId: "server-a-starlink",
442
+ connectionId: "selectable_gateway_a",
443
+ hub: {host: "127.0.0.1", port: hub.port, token: "gateway-a-token"},
444
+ tls,
445
+ authorizeTarget: (target) => target.host === targetA.host && target.port === targetA.port,
446
+ reconnectDelayMs: 10,
447
+ maxReconnectDelayMs: 20
448
+ })
449
+ after(() => gatewayA.close())
450
+ await gatewayA.start()
451
+ await waitUntil(() => hub.status().healthyGateways === 1, "gateway A registration")
436
452
 
437
- const replacement = new ReverseGatewayConnection({
438
- routeClass: "starlink",
439
- connectionId: "selectable_client_replacement",
440
- hub: {host: "127.0.0.1", port: hub.port, token: "client-token"},
441
- tls
442
- })
443
- t.after(() => replacement.close())
444
- await replacement.connect()
445
- assert.equal(replacement.status().gatewayUnavailable, false)
446
- const stream = replacement.createConnection({host: targetA.host, port: targetA.port})
447
- await onceEvent(stream, "connect")
448
- assert.equal(await exchange(stream, "probe"), "a:probe")
449
- stream.destroy()
453
+ const replacement = new ReverseGatewayConnection({
454
+ routeClass: "starlink",
455
+ connectionId: "selectable_client_replacement",
456
+ hub: {host: "127.0.0.1", port: hub.port, token: "client-token"},
457
+ tls
458
+ })
459
+ after(() => replacement.close())
460
+ await replacement.connect()
461
+ expect(replacement.status().gatewayUnavailable).toBe(false)
462
+ const stream = replacement.createConnection({host: targetA.host, port: targetA.port})
463
+ await onceEvent(stream, "connect")
464
+ expect(await exchange(stream, "probe")).toBe("a:probe")
465
+ stream.destroy()
466
+ }))
450
467
  })