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,456 @@
1
+ /* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-param, jsdoc/require-returns */
2
+ import assert from "node:assert/strict"
3
+ import {readFileSync} from "node:fs"
4
+ import {mkdtemp, rm} from "node:fs/promises"
5
+ import {createConnection, createServer} from "node:net"
6
+ import {tmpdir} from "node:os"
7
+ import {join} from "node:path"
8
+ import test from "node:test"
9
+ import {createServer as createTlsServer} 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 {encodeV3Frame, V3FrameDecoder, V3FrameType} from "../../src/protocol/v3-frame.js"
14
+ import {startMultiplexedTcpRelay} from "../../src/proxy/multiplexed-relay.js"
15
+ import {SessionSpool} from "../../src/spool/spool-file.js"
16
+ import {generateTestCertificates} from "../helpers/tls-certificates.js"
17
+
18
+ /** @param {import('node:net').Server} server - Server. @returns {Promise<number>} */
19
+ async function listen(server) {
20
+ await new Promise((resolve, reject) => {
21
+ server.once("error", reject)
22
+ server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(undefined) })
23
+ })
24
+ const address = server.address()
25
+ assert(address !== null && typeof address !== "string")
26
+ return address.port
27
+ }
28
+
29
+ /** @param {import('node:events').EventEmitter} emitter - Emitter. @param {string} event - Event. @returns {Promise<void>} */
30
+ async function onceEvent(emitter, event) {
31
+ await new Promise((resolve, reject) => {
32
+ emitter.once("error", reject)
33
+ emitter.once(event, () => { emitter.off("error", reject); resolve(undefined) })
34
+ })
35
+ }
36
+
37
+ /**
38
+ * @param {MultiplexedSocketductTransportSet} set - Transport set.
39
+ * @param {(status: ReturnType<MultiplexedSocketductTransportSet['status']>) => boolean} predicate - Condition.
40
+ * @returns {Promise<void>}
41
+ */
42
+ async function waitForStatus(set, predicate) {
43
+ if (predicate(set.status())) return
44
+ await new Promise((resolve, reject) => {
45
+ const timeout = setTimeout(() => { cleanup(); reject(new Error("timed out waiting for transport-set status")) }, 2_000)
46
+ const changed = () => {
47
+ if (!predicate(set.status())) return
48
+ cleanup()
49
+ resolve(undefined)
50
+ }
51
+ const cleanup = () => {
52
+ clearTimeout(timeout)
53
+ set.off("stateChange", changed)
54
+ }
55
+ set.on("stateChange", changed)
56
+ })
57
+ }
58
+
59
+ /** @template T @param {T[]} values - Values. @param {number} index - Index. @returns {T} */
60
+ function at(values, index) {
61
+ const value = values[index]
62
+ assert(value !== undefined)
63
+ return value
64
+ }
65
+
66
+ /** @param {number} relayPort - Relay port. */
67
+ async function createFirstDataDropProxy(relayPort) {
68
+ let accepts = 0
69
+ /** @type {() => void} */
70
+ let resolveDropped = () => {}
71
+ const dropped = new Promise((resolve) => { resolveDropped = () => resolve(undefined) })
72
+ const sockets = new Set()
73
+ const server = createServer((downstream) => {
74
+ sockets.add(downstream)
75
+ accepts += 1
76
+ const generation = accepts
77
+ const upstream = createConnection({host: "127.0.0.1", port: relayPort})
78
+ sockets.add(upstream)
79
+ const cleanup = () => {
80
+ sockets.delete(downstream)
81
+ sockets.delete(upstream)
82
+ }
83
+ downstream.once("close", cleanup)
84
+ upstream.once("close", cleanup)
85
+ downstream.on("error", () => {})
86
+ upstream.on("error", () => {})
87
+ if (generation !== 1) {
88
+ downstream.pipe(upstream)
89
+ upstream.pipe(downstream)
90
+ return
91
+ }
92
+ const decoder = new V3FrameDecoder({maxFrameBytes: 64 * 1024, maxBufferedBytes: 128 * 1024})
93
+ downstream.on("data", (chunk) => {
94
+ for (const frame of decoder.push(chunk)) {
95
+ if (frame.type === V3FrameType.DATA) {
96
+ resolveDropped()
97
+ downstream.destroy()
98
+ upstream.destroy()
99
+ return
100
+ }
101
+ upstream.write(encodeV3Frame(frame, frame.payload))
102
+ }
103
+ })
104
+ upstream.pipe(downstream)
105
+ })
106
+ const port = await listen(server)
107
+ return {
108
+ port,
109
+ dropped,
110
+ accepts: () => accepts,
111
+ async close() {
112
+ for (const socket of sockets) socket.destroy()
113
+ await new Promise((resolve) => server.close(() => resolve(undefined)))
114
+ }
115
+ }
116
+ }
117
+
118
+ test("three pinned gateways warm once and balance virtual sockets without extra physical accepts", async (t) => {
119
+ const root = await mkdtemp(join(tmpdir(), "socketduct-set-integration-"))
120
+ const target = createServer((socket) => socket.on("data", (chunk) => socket.write(chunk)))
121
+ const targetPort = await listen(target)
122
+ t.after(() => new Promise((resolve) => target.close(() => resolve(undefined))))
123
+ const relays = await Promise.all(["starlink", "zyxel", "o2gateway"].map(() => startMultiplexedTcpRelay({
124
+ tokens: new StaticTokenStore([{username: "edge", token: "valid-token"}]),
125
+ authorizeTarget: ({target: requested}) => requested.host === "127.0.0.1" && requested.port === targetPort,
126
+ maxStreams: 256
127
+ })))
128
+ const gatewayIds = ["starlink", "zyxel", "o2gateway"]
129
+ t.after(() => Promise.all(relays.map((relay) => relay.close())))
130
+
131
+ const set = new MultiplexedSocketductTransportSet({
132
+ connectionId: "integration_process_01",
133
+ spoolDirectory: root,
134
+ minimumReadyMembers: 3,
135
+ maxTotalBufferBytes: 32 * 1024 * 1024,
136
+ maxTotalSpoolBytes: 32 * 1024 * 1024,
137
+ maxTotalQueuedBytes: 33 * 1024 * 1024,
138
+ memberOptions: {maxStreams: 256, reconnectDelayMs: 10},
139
+ gateways: relays.map((relay, index) => ({
140
+ gatewayId: at(gatewayIds, index),
141
+ endpoint: {relay: {host: "127.0.0.1", port: relay.port, token: "valid-token"}}
142
+ }))
143
+ })
144
+ t.after(async () => {
145
+ await set.close()
146
+ await rm(root, {recursive: true, force: true})
147
+ })
148
+ const started = set.start()
149
+ const firstSocket = set.createConnection({
150
+ host: "127.0.0.1",
151
+ port: targetPort,
152
+ sessionName: "set-flow-1"
153
+ })
154
+ const firstConnected = onceEvent(firstSocket, "connect")
155
+ assert.equal(firstSocket.gatewayId, undefined)
156
+ await Promise.all([started, firstConnected])
157
+ assert.deepEqual(relays.map((relay) => relay.status().physicalAccepts), [1, 1, 1])
158
+ assert.deepEqual(relays.map((relay) => relay.status().virtualStreams), [1, 0, 0])
159
+
160
+ const sockets = [firstSocket, ...Array.from({length: 99}, (_, index) => set.createConnection({
161
+ host: "127.0.0.1",
162
+ port: targetPort,
163
+ sessionName: `set-flow-${index + 2}`
164
+ }))]
165
+ await Promise.all(sockets.slice(1).map((socket) => onceEvent(socket, "connect")))
166
+ assert.deepEqual(set.status().members.map((member) => member.assignments), [34, 33, 33])
167
+ assert.deepEqual(relays.map((relay) => relay.status().physicalAccepts), [1, 1, 1])
168
+ assert.deepEqual(relays.map((relay) => relay.status().virtualStreams), [34, 33, 33])
169
+ sockets.forEach((socket) => socket.destroy())
170
+ })
171
+
172
+ test("a failed gateway keeps its streams pinned while healthy siblings take new opens and recovery rejoins", async (t) => {
173
+ const root = await mkdtemp(join(tmpdir(), "socketduct-set-recovery-"))
174
+ const target = createServer((socket) => socket.on("data", (chunk) => socket.write(chunk)))
175
+ const targetPort = await listen(target)
176
+ t.after(() => new Promise((resolve) => target.close(() => resolve(undefined))))
177
+ const relays = await Promise.all([0, 1, 2].map(() => startMultiplexedTcpRelay({
178
+ tokens: new StaticTokenStore([{username: "edge", token: "valid-token"}]),
179
+ authorizeTarget: ({target: requested}) => requested.port === targetPort
180
+ })))
181
+ const gatewayIds = ["starlink", "zyxel", "o2gateway"]
182
+ t.after(() => Promise.all(relays.map((relay) => relay.close())))
183
+ const set = new MultiplexedSocketductTransportSet({
184
+ connectionId: "integration_process_recovery_01",
185
+ spoolDirectory: root,
186
+ maxTotalBufferBytes: 32 * 1024 * 1024,
187
+ maxTotalSpoolBytes: 32 * 1024 * 1024,
188
+ maxTotalQueuedBytes: 33 * 1024 * 1024,
189
+ memberOptions: {reconnectDelayMs: 100},
190
+ gateways: relays.map((relay, index) => ({
191
+ gatewayId: at(gatewayIds, index),
192
+ endpoint: {relay: {host: "127.0.0.1", port: relay.port, token: "valid-token"}}
193
+ }))
194
+ })
195
+ t.after(async () => {
196
+ await set.close()
197
+ await rm(root, {recursive: true, force: true})
198
+ })
199
+ await set.start()
200
+ await waitForStatus(set, (status) => status.readyMembers === 3)
201
+ const sticky = Array.from({length: 3}, () => set.createConnection({host: "127.0.0.1", port: targetPort}))
202
+ await Promise.all(sticky.map((socket) => onceEvent(socket, "connect")))
203
+ const recovered = onceEvent(at(sticky, 1), "ready")
204
+ await at(relays, 1).closeActiveTransport()
205
+ await waitForStatus(set, (status) => at(status.members, 1).state === "reconnecting")
206
+
207
+ const siblingOpens = Array.from({length: 10}, () => set.createConnection({host: "127.0.0.1", port: targetPort}))
208
+ assert.equal(at(set.status().members, 1).assignments, 1)
209
+ const siblingCounts = [at(set.status().members, 0).assignments, at(set.status().members, 2).assignments]
210
+ assert(Math.max(...siblingCounts) - Math.min(...siblingCounts) <= 1)
211
+ await Promise.all(siblingOpens.map((socket) => onceEvent(socket, "connect")))
212
+ await recovered
213
+ await waitForStatus(set, (status) => status.readyMembers === 3)
214
+ const before = at(set.status().members, 1).assignments
215
+ const rejoined = Array.from({length: 3}, () => set.createConnection({host: "127.0.0.1", port: targetPort}))
216
+ assert(at(set.status().members, 1).assignments > before)
217
+ assert.equal(at(sticky, 1).destroyed, false)
218
+ assert.equal(at(relays, 1).status().physicalAccepts, 2)
219
+ sticky.forEach((socket) => socket.destroy())
220
+ siblingOpens.forEach((socket) => socket.destroy())
221
+ rejoined.forEach((socket) => socket.destroy())
222
+ })
223
+
224
+ test("all reconnecting members hold a new socket until one real member recovers", async (t) => {
225
+ const root = await mkdtemp(join(tmpdir(), "socketduct-set-all-reconnect-"))
226
+ const target = createServer((socket) => socket.on("data", (chunk) => socket.write(chunk)))
227
+ const targetPort = await listen(target)
228
+ const relays = await Promise.all([0, 1, 2].map(() => startMultiplexedTcpRelay({
229
+ tokens: new StaticTokenStore([{username: "edge", token: "valid-token"}]),
230
+ authorizeTarget: ({target: requested}) => requested.port === targetPort
231
+ })))
232
+ const gatewayIds = ["starlink", "zyxel", "o2gateway"]
233
+ const set = new MultiplexedSocketductTransportSet({
234
+ connectionId: "integration_all_reconnect_01",
235
+ spoolDirectory: root,
236
+ minimumReadyMembers: 3,
237
+ memberOptions: {reconnectDelayMs: 1_000},
238
+ gateways: relays.map((relay, index) => ({
239
+ gatewayId: at(gatewayIds, index),
240
+ endpoint: {relay: {host: "127.0.0.1", port: relay.port, token: "valid-token"}}
241
+ }))
242
+ })
243
+ let siblingRelaysClosed = false
244
+ t.after(async () => {
245
+ await set.close()
246
+ if (!siblingRelaysClosed) await Promise.all(relays.slice(1).map((relay) => relay.close()))
247
+ await at(relays, 0).close()
248
+ await new Promise((resolve) => target.close(() => resolve(undefined)))
249
+ await rm(root, {recursive: true, force: true})
250
+ })
251
+ await set.start()
252
+ await Promise.all(relays.map((relay) => relay.closeActiveTransport()))
253
+ await waitForStatus(set, (status) => status.readyMembers === 0)
254
+ await Promise.all(relays.slice(1).map((relay) => relay.close()))
255
+ siblingRelaysClosed = true
256
+
257
+ const socket = set.createConnection({host: "127.0.0.1", port: targetPort})
258
+ assert.equal(socket.gatewayId, undefined)
259
+ assert.equal(set.status().pendingOpens, 1)
260
+ await onceEvent(socket, "connect")
261
+ assert.equal(socket.gatewayId, "starlink")
262
+ assert.equal(set.status().pendingOpens, 0)
263
+ assert.deepEqual(set.status().members.map((member) => member.assignments), [1, 0, 0])
264
+ socket.destroy()
265
+ })
266
+
267
+ test("terminal authentication failure rejects start and the pending socket once", async (t) => {
268
+ const root = await mkdtemp(join(tmpdir(), "socketduct-set-start-failure-"))
269
+ const relay = await startMultiplexedTcpRelay({
270
+ tokens: new StaticTokenStore([{username: "edge", token: "valid-token"}]),
271
+ authorizeTarget: () => true
272
+ })
273
+ const set = new MultiplexedSocketductTransportSet({
274
+ connectionId: "integration_start_failure_01",
275
+ spoolDirectory: root,
276
+ gateways: [{gatewayId: "starlink", endpoint: {relay: {host: "127.0.0.1", port: relay.port, token: "invalid-token"}}}]
277
+ })
278
+ t.after(async () => {
279
+ await set.close()
280
+ await relay.close()
281
+ await rm(root, {recursive: true, force: true})
282
+ })
283
+ const started = set.start()
284
+ let callbacks = 0
285
+ const callbackResult = new Promise((resolve) => {
286
+ set.createConnection({host: "target.internal", port: 443}, (error, socket) => {
287
+ callbacks += 1
288
+ resolve({error, socket})
289
+ })
290
+ })
291
+ await assert.rejects(started, (error) => error instanceof AggregateError)
292
+ const result = /** @type {{error: Error, socket: import('../../src/client/virtual-socket.js').VirtualSocket}} */ (await callbackResult)
293
+ assert(result.error instanceof AggregateError)
294
+ assert.equal(callbacks, 1)
295
+ assert.equal(result.socket.destroyed, true)
296
+ assert.equal(set.status().state, "failed")
297
+ assert.equal(relay.status().virtualStreams, 0)
298
+ })
299
+
300
+ test("createTlsConnection preserves callback ownership on the selected sticky member", async (t) => {
301
+ const root = await mkdtemp(join(tmpdir(), "socketduct-set-tls-"))
302
+ const certs = generateTestCertificates(join(root, "certs"))
303
+ const target = createTlsServer({
304
+ cert: readFileSync(certs.serverCertFile),
305
+ key: readFileSync(certs.serverKeyFile)
306
+ }, (socket) => socket.on("data", (chunk) => socket.write(chunk)))
307
+ const targetPort = await listen(target)
308
+ const relay = await startMultiplexedTcpRelay({
309
+ tokens: new StaticTokenStore([{username: "edge", token: "valid-token"}]),
310
+ authorizeTarget: ({target: requested}) => requested.host === "127.0.0.1" && requested.port === targetPort
311
+ })
312
+ const set = new MultiplexedSocketductTransportSet({
313
+ connectionId: "integration_process_tls_01",
314
+ spoolDirectory: root,
315
+ gateways: [{gatewayId: "starlink", endpoint: {relay: {host: "127.0.0.1", port: relay.port, token: "valid-token"}}}]
316
+ })
317
+ t.after(async () => {
318
+ await set.close()
319
+ await relay.close()
320
+ await new Promise((resolve) => target.close(() => resolve(undefined)))
321
+ await rm(root, {recursive: true, force: true})
322
+ })
323
+ const started = set.start()
324
+ let callbacks = 0
325
+ const secure = /** @type {import('node:tls').TLSSocket & {readonly gatewayId: string | undefined}} */ (await new Promise((resolve, reject) => {
326
+ const returned = set.createTlsConnection({
327
+ host: "127.0.0.1",
328
+ port: targetPort,
329
+ servername: "localhost",
330
+ ca: readFileSync(certs.caCertFile)
331
+ }, (error, socket) => {
332
+ callbacks += 1
333
+ if (error === null) resolve(socket)
334
+ else reject(error)
335
+ })
336
+ assert(returned !== undefined)
337
+ assert.equal(returned.gatewayId, undefined)
338
+ }))
339
+ await started
340
+ assert.equal(callbacks, 1)
341
+ assert.equal(secure.gatewayId, "starlink")
342
+ const response = new Promise((resolve, reject) => {
343
+ secure.once("data", (chunk) => resolve(Buffer.from(chunk)))
344
+ secure.once("error", reject)
345
+ })
346
+ secure.write("tls-over-set")
347
+ const chunk = /** @type {Buffer} */ (await response)
348
+ assert.equal(chunk.toString(), "tls-over-set")
349
+ assert.equal(at(set.status().members, 0).assignments, 1)
350
+ secure.destroy()
351
+ })
352
+
353
+ test("process replacement recovers a durable stream on its original named gateway before start", async (t) => {
354
+ const root = await mkdtemp(join(tmpdir(), "socketduct-set-process-recovery-"))
355
+ let targetAccepts = 0
356
+ let received = Buffer.alloc(0)
357
+ /** @type {() => void} */
358
+ let resolveReceived = () => {}
359
+ const receivedAll = new Promise((resolve) => { resolveReceived = () => resolve(undefined) })
360
+ const target = createServer((socket) => {
361
+ targetAccepts += 1
362
+ socket.on("data", (chunk) => {
363
+ received = Buffer.concat([received, chunk])
364
+ if (received.length === 32) resolveReceived()
365
+ })
366
+ })
367
+ const targetPort = await listen(target)
368
+ const relays = await Promise.all([0, 1, 2].map(() => startMultiplexedTcpRelay({
369
+ tokens: new StaticTokenStore([{username: "edge", token: "valid-token"}]),
370
+ authorizeTarget: ({target: requested}) => requested.host === "127.0.0.1" && requested.port === targetPort,
371
+ initialWindowBytes: 32,
372
+ maxFrameBytes: 32,
373
+ resumeWindowMs: 2_000
374
+ })))
375
+ const fault = await createFirstDataDropProxy(at(relays, 0).port)
376
+ const gatewayIds = ["starlink", "zyxel", "o2gateway"]
377
+ const physicalCreates = [0, 0, 0]
378
+ const physicalFactories = relays.map((_relay, index) => (/** @type {{host: string, port: number}} */ options) => {
379
+ physicalCreates[index] = at(physicalCreates, index) + 1
380
+ return createConnection(options)
381
+ })
382
+ const setOptions = {
383
+ connectionId: "replacement_process_identity_01",
384
+ spoolDirectory: root,
385
+ minimumReadyMembers: 3,
386
+ maxTotalBufferBytes: 128,
387
+ maxTotalSpoolBytes: 128,
388
+ maxTotalQueuedBytes: 1024,
389
+ memberOptions: {initialWindowBytes: 32, maxFrameBytes: 32, reconnectDelayMs: 1_000},
390
+ gateways: relays.map((relay, index) => ({
391
+ gatewayId: at(gatewayIds, index),
392
+ endpoint: {
393
+ relay: {host: "127.0.0.1", port: index === 0 ? fault.port : relay.port, token: "valid-token"},
394
+ createPhysicalSocket: at(physicalFactories, index)
395
+ }
396
+ }))
397
+ }
398
+ /** @type {MultiplexedSocketductTransportSet | undefined} */
399
+ let replacement
400
+ t.after(async () => {
401
+ await replacement?.close()
402
+ await Promise.all(relays.map((relay) => relay.close()))
403
+ await fault.close()
404
+ await new Promise((resolve) => target.close(() => resolve(undefined)))
405
+ await rm(root, {recursive: true, force: true})
406
+ })
407
+
408
+ const original = new MultiplexedSocketductTransportSet(setOptions)
409
+ await original.start()
410
+ assert.deepEqual(physicalCreates, [1, 1, 1])
411
+ const initial = original.createConnection({
412
+ host: "127.0.0.1",
413
+ port: targetPort,
414
+ identifier: "gateway_bound_stream_01",
415
+ sessionName: "gateway-bound"
416
+ })
417
+ await onceEvent(initial, "connect")
418
+ await new Promise((resolve, reject) => initial.write(Buffer.alloc(32, 0x72), (error) => error ? reject(error) : resolve(undefined)))
419
+ await fault.dropped
420
+ await original.detach()
421
+
422
+ const originalReadChunksAfter = SessionSpool.prototype.readChunksAfter
423
+ /** @type {() => void} */
424
+ let resolveRecoveryRead = () => {}
425
+ const recoveryRead = new Promise((resolve) => { resolveRecoveryRead = () => resolve(undefined) })
426
+ SessionSpool.prototype.readChunksAfter = async function (/** @type {string} */ identifier, /** @type {number} */ sequence) {
427
+ const chunks = await originalReadChunksAfter.call(this, identifier, sequence)
428
+ if (identifier === "gateway_bound_stream_01") resolveRecoveryRead()
429
+ return chunks
430
+ }
431
+ t.after(() => { SessionSpool.prototype.readChunksAfter = originalReadChunksAfter })
432
+ replacement = new MultiplexedSocketductTransportSet(setOptions)
433
+ const recovered = replacement.recoverConnection({
434
+ gatewayId: "starlink",
435
+ host: "127.0.0.1",
436
+ port: targetPort,
437
+ identifier: "gateway_bound_stream_01",
438
+ sessionName: "gateway-bound"
439
+ })
440
+ await recoveryRead
441
+ await new Promise((resolve) => setImmediate(resolve))
442
+ assert.deepEqual(physicalCreates, [1, 1, 1])
443
+ assert.equal(fault.accepts(), 1)
444
+ assert.deepEqual(replacement.status().members.map((member) => member.assignments), [1, 0, 0])
445
+ const connected = onceEvent(recovered, "connect")
446
+ await replacement.start()
447
+ await connected
448
+ assert.deepEqual(physicalCreates, [2, 2, 2])
449
+ await receivedAll
450
+
451
+ assert.equal(received.toString(), "r".repeat(32))
452
+ assert.equal(targetAccepts, 1)
453
+ assert.equal(fault.accepts(), 2)
454
+ assert.deepEqual(relays.slice(1).map((relay) => relay.status().virtualStreams), [0, 0])
455
+ recovered.destroy()
456
+ })
@@ -0,0 +1,208 @@
1
+ /* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-param-type, jsdoc/require-returns */
2
+ import assert from "node:assert/strict"
3
+ import {createServer} from "node:net"
4
+ import test from "node:test"
5
+
6
+ import {StaticTokenStore} from "../../src/auth/static-token-store.js"
7
+ import {MultiplexedSocketductConnection} from "../../src/client/multiplexed-connection.js"
8
+ import {startMultiplexedTcpRelay} from "../../src/proxy/multiplexed-relay.js"
9
+
10
+ /** @param {import('node:net').Server} server - Server. @returns {Promise<number>} */
11
+ async function listen(server) {
12
+ await new Promise((resolve, reject) => {
13
+ server.once("error", reject)
14
+ server.listen(0, "127.0.0.1", () => {
15
+ server.off("error", reject)
16
+ resolve(undefined)
17
+ })
18
+ })
19
+ const address = server.address()
20
+ assert(address !== null && typeof address !== "string")
21
+ return address.port
22
+ }
23
+
24
+ /**
25
+ * @param {import('node:events').EventEmitter} emitter - Emitter. @param {string} event - Event. @returns {Promise<void>}
26
+ * @param event
27
+ */
28
+ async function onceEvent(emitter, event) {
29
+ await new Promise((resolve, reject) => {
30
+ emitter.once("error", reject)
31
+ emitter.once(event, () => {
32
+ emitter.off("error", reject)
33
+ resolve(undefined)
34
+ })
35
+ })
36
+ }
37
+
38
+ /**
39
+ * @param {import('node:stream').Duplex} socket - Socket. @param {string} expected - Expected text. @returns {Promise<void>}
40
+ * @param expected
41
+ */
42
+ async function readExactly(socket, expected) {
43
+ await new Promise((resolve, reject) => {
44
+ let value = ""
45
+ /** @param {Error} error - Read error. */
46
+ const onError = (error) => { cleanup(); reject(error) }
47
+ /** @param {Buffer} chunk - Data. */
48
+ const onData = (chunk) => {
49
+ value += chunk.toString()
50
+ if (value.length < expected.length) return
51
+ cleanup()
52
+ try {
53
+ assert.equal(value, expected)
54
+ resolve(undefined)
55
+ } catch (error) {
56
+ reject(error)
57
+ }
58
+ }
59
+ const cleanup = () => {
60
+ socket.off("error", onError)
61
+ socket.off("data", onData)
62
+ }
63
+ socket.on("error", onError)
64
+ socket.on("data", onData)
65
+ })
66
+ }
67
+
68
+ test("two virtual sockets use one authenticated physical transport", async (t) => {
69
+ let targetAccepts = 0
70
+ const target = createServer((socket) => {
71
+ targetAccepts += 1
72
+ socket.on("data", (chunk) => socket.write(chunk))
73
+ })
74
+ const targetPort = await listen(target)
75
+ t.after(() => new Promise((resolve) => target.close(() => resolve(undefined))))
76
+
77
+ const relay = await startMultiplexedTcpRelay({
78
+ tokens: new StaticTokenStore([{username: "edge", token: "valid-token"}]),
79
+ authorizeTarget: ({target: requested}) => requested.host === "127.0.0.1" && requested.port === targetPort
80
+ })
81
+ t.after(() => relay.close())
82
+
83
+ const connection = new MultiplexedSocketductConnection({
84
+ relay: {host: "127.0.0.1", port: relay.port, token: "valid-token"}
85
+ })
86
+ t.after(() => connection.close())
87
+
88
+ const first = connection.createConnection({host: "127.0.0.1", port: targetPort, sessionName: "first"})
89
+ const second = connection.createConnection({host: "127.0.0.1", port: targetPort, sessionName: "second"})
90
+ await Promise.all([onceEvent(first, "connect"), onceEvent(second, "connect")])
91
+
92
+ first.write("one")
93
+ second.write("two")
94
+ await Promise.all([readExactly(first, "one"), readExactly(second, "two")])
95
+
96
+ assert.equal(relay.status().physicalAccepts, 1)
97
+ assert.equal(relay.status().authenticatedConnections, 1)
98
+ assert.equal(targetAccepts, 2)
99
+ assert.equal(connection.status().virtualStreams, 2)
100
+
101
+ first.destroy()
102
+ assert.equal(second.destroyed, false)
103
+ second.destroy()
104
+ })
105
+
106
+ test("one transport drop has one reconnect owner and resumes sibling streams without gaps", async (t) => {
107
+ /** @type {Array<{event: string} & Record<string, unknown>>} */
108
+ const lifecycle = []
109
+ const logger = {log: (/** @type {string} */ event, /** @type {Record<string, unknown>} */ fields = {}) => lifecycle.push({event, ...fields})}
110
+ const target = createServer((socket) => socket.on("data", (chunk) => socket.write(chunk)))
111
+ const targetPort = await listen(target)
112
+ t.after(() => new Promise((resolve) => target.close(() => resolve(undefined))))
113
+ const relay = await startMultiplexedTcpRelay({
114
+ tokens: new StaticTokenStore([{username: "edge", token: "valid-token"}]),
115
+ authorizeTarget: ({target: requested}) => requested.host === "127.0.0.1" && requested.port === targetPort
116
+ })
117
+ t.after(() => relay.close())
118
+ const connection = new MultiplexedSocketductConnection({
119
+ relay: {host: "127.0.0.1", port: relay.port, token: "valid-token"},
120
+ reconnectDelayMs: 10,
121
+ logger
122
+ })
123
+ t.after(() => connection.close())
124
+ const first = connection.createConnection({host: "127.0.0.1", port: targetPort, sessionName: "resume-a"})
125
+ const second = connection.createConnection({host: "127.0.0.1", port: targetPort, sessionName: "resume-b"})
126
+ await Promise.all([onceEvent(first, "connect"), onceEvent(second, "connect")])
127
+ first.write("before-a")
128
+ second.write("before-b")
129
+ await Promise.all([readExactly(first, "before-a"), readExactly(second, "before-b")])
130
+
131
+ const ready = Promise.all([onceEvent(first, "ready"), onceEvent(second, "ready")])
132
+ await relay.closeActiveTransport()
133
+ first.write("after-a")
134
+ second.write("after-b")
135
+ await ready
136
+ await Promise.all([readExactly(first, "after-a"), readExactly(second, "after-b")])
137
+
138
+ assert.equal(relay.status().physicalAccepts, 2)
139
+ assert.equal(connection.status().reconnectAttempts, 1)
140
+ assert.equal(connection.status().reconnectSuccesses, 1)
141
+ assert.equal(relay.status().resumeSuccesses, 2)
142
+ const closed = Promise.all([onceEvent(first, "close"), onceEvent(second, "close")])
143
+ first.destroy()
144
+ second.destroy()
145
+ await closed
146
+ for (const event of [
147
+ "transport.connecting",
148
+ "transport.connected",
149
+ "transport.disconnected",
150
+ "transport.reconnect_scheduled",
151
+ "transport.reconnect_succeeded",
152
+ "virtual_stream.opening",
153
+ "virtual_stream.opened",
154
+ "virtual_stream.closed"
155
+ ]) assert(lifecycle.some((entry) => entry.event === event), `missing ${event}`)
156
+ assert.doesNotMatch(JSON.stringify(lifecycle), /valid-token|resume-a|resume-b|127\.0\.0\.1/)
157
+ })
158
+
159
+ test("100 concurrent build-like streams create zero extra physical accepts", async (t) => {
160
+ let targetAccepts = 0
161
+ const target = createServer((socket) => {
162
+ targetAccepts += 1
163
+ socket.on("data", (chunk) => {
164
+ const split = Math.max(1, Math.floor(chunk.length / 2))
165
+ socket.write(chunk.subarray(0, split))
166
+ socket.write(chunk.subarray(split))
167
+ })
168
+ })
169
+ const targetPort = await listen(target)
170
+ t.after(() => new Promise((resolve) => target.close(() => resolve(undefined))))
171
+ const relay = await startMultiplexedTcpRelay({
172
+ tokens: new StaticTokenStore([{username: "edge", token: "valid-token"}]),
173
+ authorizeTarget: ({target: requested}) => requested.host === "127.0.0.1" && requested.port === targetPort,
174
+ maxStreams: 128
175
+ })
176
+ t.after(() => relay.close())
177
+ const connection = new MultiplexedSocketductConnection({
178
+ relay: {host: "127.0.0.1", port: relay.port, token: "valid-token"},
179
+ maxStreams: 128
180
+ })
181
+ t.after(() => connection.close())
182
+
183
+ const sockets = Array.from({length: 100}, (_, index) => connection.createConnection({
184
+ host: "127.0.0.1",
185
+ port: targetPort,
186
+ sessionName: `build-flow-${index + 1}`
187
+ }))
188
+ await Promise.all(sockets.map((socket) => onceEvent(socket, "connect")))
189
+ const reads = sockets.map((socket, index) => readExactly(socket, `flow-${index + 1}:${"x".repeat(1024)}`))
190
+ sockets.forEach((socket, index) => socket.write(`flow-${index + 1}:${"x".repeat(1024)}`))
191
+ await Promise.all(reads)
192
+
193
+ assert.equal(relay.status().physicalAccepts, 1)
194
+ assert.equal(relay.status().physicalConnections, 1)
195
+ assert.equal(targetAccepts, 100)
196
+ assert.equal(connection.status().virtualStreams, 100)
197
+
198
+ const resumed = Promise.all(sockets.map((socket) => onceEvent(socket, "ready")))
199
+ await relay.closeActiveTransport()
200
+ await resumed
201
+ const resumedReads = sockets.map((socket, index) => readExactly(socket, `resumed-${index + 1}`))
202
+ sockets.forEach((socket, index) => socket.write(`resumed-${index + 1}`))
203
+ await Promise.all(resumedReads)
204
+ assert.equal(relay.status().physicalAccepts, 2)
205
+ assert.equal(relay.status().resumeSuccesses, 100)
206
+ assert.equal(connection.status().reconnectAttempts, 1)
207
+ sockets.forEach((socket) => socket.destroy())
208
+ })