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,518 @@
1
+ import assert from "node:assert/strict"
2
+ import {createConnection, createServer} from "node:net"
3
+ import test from "node:test"
4
+
5
+ import {InMemoryUserStore, login, TokenStore} from "../../src/auth/user-store.js"
6
+ import {connectSocketduct} from "../../src/client/socketduct-socket.js"
7
+ import {encodeCompactData, encodeFrame, FrameDecoder} from "../../src/protocol/frame.js"
8
+ import {startTcpRelay} from "../../src/proxy/tcp-relay.js"
9
+ import {SessionStore} from "../../src/sessions/session.js"
10
+ import {mkdtemp, rm} from "node:fs/promises"
11
+ import {tmpdir} from "node:os"
12
+ import {join} from "node:path"
13
+ import {EventEmitter} from "node:events"
14
+
15
+ /** @typedef {ReturnType<FrameDecoder["push"]>[number]} DecodedFrame */
16
+
17
+ /**
18
+ * @param {import('node:net').Server} server - Server to bind.
19
+ * @param {number} [port] - Requested port.
20
+ * @returns {Promise<number>} Bound port.
21
+ */
22
+ async function listen(server, port = 0) {
23
+ await new Promise((resolve, reject) => {
24
+ server.once("error", reject)
25
+ server.listen(port, "127.0.0.1", () => {
26
+ server.off("error", reject)
27
+ resolve(undefined)
28
+ })
29
+ })
30
+ const address = server.address()
31
+ if (address === null || typeof address === "string") throw new Error("server did not bind")
32
+ return address.port
33
+ }
34
+
35
+ /**
36
+ * @param {import('node:net').Server} server - Server to close.
37
+ * @returns {Promise<void>}
38
+ */
39
+ async function closeServer(server) {
40
+ if (!server.listening) return
41
+ await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve(undefined)))
42
+ }
43
+
44
+ /** @returns {Promise<number>} An unused loopback port. */
45
+ async function reservePort() {
46
+ const server = createServer()
47
+ const port = await listen(server)
48
+ await closeServer(server)
49
+ return port
50
+ }
51
+
52
+ /**
53
+ * @param {() => boolean} predicate - Completion predicate.
54
+ * @param {number} [timeoutMs] - Maximum wait.
55
+ * @returns {Promise<void>}
56
+ */
57
+ async function waitUntil(predicate, timeoutMs = 1_000) {
58
+ const deadline = Date.now() + timeoutMs
59
+ while (!predicate()) {
60
+ if (Date.now() >= deadline) throw new Error("timed out waiting for condition")
61
+ await new Promise((resolve) => setTimeout(resolve, 2))
62
+ }
63
+ }
64
+
65
+ /**
66
+ * @param {import('node:net').Socket} socket - Socket to await.
67
+ * @returns {Promise<void>}
68
+ */
69
+ async function waitConnect(socket) {
70
+ await new Promise((resolve, reject) => {
71
+ socket.once("error", reject)
72
+ socket.once("connect", () => {
73
+ socket.off("error", reject)
74
+ resolve(undefined)
75
+ })
76
+ })
77
+ }
78
+
79
+ /**
80
+ * @param {import('node:net').Socket} socket - Socket to await.
81
+ * @returns {Promise<void>}
82
+ */
83
+ async function waitClose(socket) {
84
+ if (socket.destroyed) return
85
+ await new Promise((resolve) => socket.once("close", resolve))
86
+ }
87
+
88
+ /** @returns {Promise<{sessions: SessionStore, token: import('../../src/auth/user-store.js').AuthToken, tokens: TokenStore}>} Authenticated stores. */
89
+ async function authenticatedStores() {
90
+ const users = new InMemoryUserStore()
91
+ const tokens = new TokenStore({now: () => 1_000, tokenTtlMs: 60_000})
92
+ await users.createUser({username: "retry-user", password: "correct horse battery staple"})
93
+ const token = await login(users, tokens, {username: "retry-user", password: "correct horse battery staple"})
94
+ return {sessions: new SessionStore({now: () => 1_000}), token, tokens}
95
+ }
96
+
97
+ /**
98
+ * @param {import('node:net').Socket} socket
99
+ * @param {FrameDecoder} decoder
100
+ * @returns {{frames: DecodedFrame[], waitFor: (predicate: (frame: DecodedFrame) => boolean) => Promise<DecodedFrame>}} Frame collector.
101
+ */
102
+ function collectFrames(socket, decoder) {
103
+ /** @type {DecodedFrame[]} */
104
+ const frames = []
105
+ /** @type {Set<() => void>} */
106
+ const changes = new Set()
107
+ socket.on("data", (chunk) => {
108
+ frames.push(...decoder.push(chunk))
109
+ for (const change of changes) change()
110
+ })
111
+ return {
112
+ frames,
113
+ waitFor: async (predicate) => {
114
+ const deadline = Date.now() + 1_000
115
+ while (true) {
116
+ const frame = frames.find(predicate)
117
+ if (frame !== undefined) return frame
118
+ const remaining = deadline - Date.now()
119
+ if (remaining <= 0) throw new Error("timed out waiting for frame")
120
+ await new Promise((resolve, reject) => {
121
+ const changed = () => {
122
+ clearTimeout(timer)
123
+ changes.delete(changed)
124
+ resolve(undefined)
125
+ }
126
+ const timer = setTimeout(() => {
127
+ changes.delete(changed)
128
+ reject(new Error("timed out waiting for frame"))
129
+ }, remaining)
130
+ changes.add(changed)
131
+ })
132
+ }
133
+ }
134
+ }
135
+ }
136
+
137
+ class HangingTargetSocket extends EventEmitter {
138
+ constructor() {
139
+ super()
140
+ this.destroyed = false
141
+ this.destroyCount = 0
142
+ }
143
+
144
+ destroy() {
145
+ this.destroyed = true
146
+ this.destroyCount += 1
147
+ return this
148
+ }
149
+ }
150
+
151
+ test("authenticated create stays pending without an error and the same request succeeds when the target starts", async () => {
152
+ const targetPort = await reservePort()
153
+ const {sessions, token, tokens} = await authenticatedStores()
154
+ const relay = await startTcpRelay({
155
+ authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
156
+ handshakeTimeoutMs: 10,
157
+ initialTargetRetryDelayMs: 30,
158
+ initialTargetRetryMaxDelayMs: 30,
159
+ sessions,
160
+ tokens
161
+ })
162
+ const tunnel = createConnection({host: "127.0.0.1", port: relay.port})
163
+ const collector = collectFrames(tunnel, new FrameDecoder({compactDirection: "target-to-client"}))
164
+ const targetServer = createServer((socket) => socket.on("data", (chunk) => socket.write(chunk)))
165
+ try {
166
+ await waitConnect(tunnel)
167
+ tunnel.write(encodeFrame({
168
+ type: "session.create", token: token.value, name: "pending-create", identifier: "pending-create",
169
+ target: {host: "127.0.0.1", port: targetPort}, framing: [2]
170
+ }))
171
+
172
+ await waitUntil(() => sessions.get("pending-create") !== undefined)
173
+ assert.equal(sessions.get("pending-create")?.state, "creating")
174
+ await waitUntil(() => relay.status().targetRetryTimers === 1)
175
+ assert.equal(collector.frames.length, 0, "initial refusal must not announce or emit an error")
176
+
177
+ // Deliberately cross the shorter authentication deadline while a retry is pending.
178
+ await new Promise((resolve) => setTimeout(resolve, 15))
179
+ assert.equal(collector.frames.some((frame) => frame.header.code === "HANDSHAKE_TIMEOUT"), false)
180
+
181
+ await listen(targetServer, targetPort)
182
+ const created = await collector.waitFor((frame) => frame.header.type === "session.created")
183
+ assert.equal(created.header.identifier, "pending-create")
184
+ assert.equal(created.header.state, "active")
185
+ assert.equal(sessions.get("pending-create")?.state, "active")
186
+ assert.equal(collector.frames.filter((frame) => frame.header.type === "session.created").length, 1)
187
+ } finally {
188
+ tunnel.destroy()
189
+ await relay.close()
190
+ await closeServer(targetServer)
191
+ }
192
+ })
193
+
194
+ test("SocketductSocket does not deliver queued application bytes before readiness", async () => {
195
+ const targetPort = await reservePort()
196
+ const spoolDirectory = await mkdtemp(join(tmpdir(), "socketduct-initial-readiness-"))
197
+ const {sessions, token, tokens} = await authenticatedStores()
198
+ const relay = await startTcpRelay({
199
+ authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
200
+ initialTargetRetryDelayMs: 10,
201
+ initialTargetRetryMaxDelayMs: 20,
202
+ sessions,
203
+ tokens
204
+ })
205
+ let clientConnected = false
206
+ let bytesBeforeConnect = 0
207
+ const targetServer = createServer((socket) => {
208
+ socket.on("data", (chunk) => {
209
+ if (!clientConnected) bytesBeforeConnect += chunk.length
210
+ socket.write(chunk)
211
+ })
212
+ })
213
+ const client = connectSocketduct({
214
+ relay: {host: "127.0.0.1", port: relay.port, token: token.value},
215
+ sessionName: "queued-before-ready",
216
+ spoolDirectory,
217
+ target: {host: "127.0.0.1", port: targetPort}
218
+ })
219
+ client.on("error", () => {})
220
+ client.once("connect", () => { clientConnected = true })
221
+ client.write("queued-application-bytes")
222
+ try {
223
+ await waitUntil(() => relay.status().targetRetryTimers === 1)
224
+ assert.equal(client.connecting, true)
225
+ assert.equal(bytesBeforeConnect, 0)
226
+ await listen(targetServer, targetPort)
227
+ const echoed = await new Promise((resolve, reject) => {
228
+ const timer = setTimeout(() => reject(new Error("timed out waiting for queued echo")), 1_000)
229
+ client.once("data", (chunk) => {
230
+ clearTimeout(timer)
231
+ resolve(chunk.toString("utf8"))
232
+ })
233
+ })
234
+ assert.equal(echoed, "queued-application-bytes")
235
+ assert.equal(bytesBeforeConnect, 0)
236
+ } finally {
237
+ client.destroy()
238
+ await relay.close()
239
+ await closeServer(targetServer)
240
+ await rm(spoolDirectory, {recursive: true, force: true})
241
+ }
242
+ })
243
+
244
+ test("relay rejects stream frames while an authenticated create is not ready", async () => {
245
+ const targetPort = await reservePort()
246
+ const {sessions, token, tokens} = await authenticatedStores()
247
+ const relay = await startTcpRelay({
248
+ authorizeTarget: () => true,
249
+ initialTargetRetryDelayMs: 50,
250
+ initialTargetRetryMaxDelayMs: 50,
251
+ sessions,
252
+ tokens
253
+ })
254
+ const tunnel = createConnection({host: "127.0.0.1", port: relay.port})
255
+ const collector = collectFrames(tunnel, new FrameDecoder({compactDirection: "target-to-client"}))
256
+ try {
257
+ await waitConnect(tunnel)
258
+ tunnel.write(encodeFrame({
259
+ type: "session.create", token: token.value, name: "premature-data", identifier: "premature-data",
260
+ target: {host: "127.0.0.1", port: targetPort}, framing: [2]
261
+ }))
262
+ await waitUntil(() => sessions.get("premature-data") !== undefined)
263
+ tunnel.write(encodeCompactData({sequence: 4, payload: Buffer.from("nope")}))
264
+ const violation = await collector.waitFor((frame) => frame.header.type === "error")
265
+ assert.equal(violation.header.code, "PROTOCOL_VIOLATION")
266
+ assert.equal(violation.header.retryable, false)
267
+ await waitUntil(() => sessions.get("premature-data") === undefined)
268
+ } finally {
269
+ tunnel.destroy()
270
+ await relay.close()
271
+ }
272
+ })
273
+
274
+ test("closing an unannounced tunnel deletes it and stale work cannot activate a reused identifier", async () => {
275
+ const targetPort = await reservePort()
276
+ const {sessions, token, tokens} = await authenticatedStores()
277
+ const relay = await startTcpRelay({
278
+ authorizeTarget: () => true,
279
+ initialTargetRetryDelayMs: 50,
280
+ initialTargetRetryMaxDelayMs: 50,
281
+ sessions,
282
+ tokens
283
+ })
284
+ const tunnel = createConnection({host: "127.0.0.1", port: relay.port})
285
+ let targetConnections = 0
286
+ const targetServer = createServer(() => { targetConnections += 1 })
287
+ await waitConnect(tunnel)
288
+ tunnel.write(encodeFrame({
289
+ type: "session.create", token: token.value, name: "cancel-pending", identifier: "cancel-pending",
290
+ target: {host: "127.0.0.1", port: targetPort}, framing: [2]
291
+ }))
292
+ await waitUntil(() => relay.status().targetRetryTimers === 1)
293
+ tunnel.destroy()
294
+ await waitClose(tunnel)
295
+ await waitUntil(() => relay.status().sessions === 0)
296
+ assert.equal(sessions.get("cancel-pending"), undefined)
297
+ assert.deepEqual(relay.status(), {
298
+ activeTunnels: 0, activeTargets: 0, sessions: 0, attachedSessions: 0,
299
+ detachedSessions: 0, bufferedTargetBytes: 0, targetConnectionAttempts: 0, targetRetryTimers: 0
300
+ })
301
+
302
+ const replacementTunnel = createConnection({host: "127.0.0.1", port: relay.port})
303
+ const replacementCollector = collectFrames(replacementTunnel, new FrameDecoder({compactDirection: "target-to-client"}))
304
+ try {
305
+ await listen(targetServer, targetPort)
306
+ await waitConnect(replacementTunnel)
307
+ replacementTunnel.write(encodeFrame({
308
+ type: "session.create", token: token.value, name: "replacement", identifier: "cancel-pending",
309
+ target: {host: "127.0.0.1", port: targetPort}, framing: [2]
310
+ }))
311
+ await replacementCollector.waitFor((frame) => frame.header.type === "session.created")
312
+ assert.equal(sessions.get("cancel-pending")?.state, "active")
313
+ assert.equal(targetConnections, 1)
314
+ } finally {
315
+ replacementTunnel.destroy()
316
+ await relay.close()
317
+ await closeServer(targetServer)
318
+ }
319
+ })
320
+
321
+ test("relay close cancels all pending target attempts, retry timers, and creating sessions", async () => {
322
+ const targetPort = await reservePort()
323
+ const {sessions, token, tokens} = await authenticatedStores()
324
+ const relay = await startTcpRelay({
325
+ authorizeTarget: () => true,
326
+ initialTargetRetryDelayMs: 100,
327
+ initialTargetRetryMaxDelayMs: 100,
328
+ sessions,
329
+ tokens
330
+ })
331
+ const tunnels = Array.from({length: 3}, (_, index) => {
332
+ const tunnel = createConnection({host: "127.0.0.1", port: relay.port})
333
+ tunnel.once("connect", () => tunnel.write(encodeFrame({
334
+ type: "session.create", token: token.value, name: `relay-close-${index}`, identifier: `relay-close-${index}`,
335
+ target: {host: "127.0.0.1", port: targetPort}, framing: [2]
336
+ })))
337
+ tunnel.on("error", () => {})
338
+ return tunnel
339
+ })
340
+ await waitUntil(() => relay.status().targetRetryTimers === tunnels.length)
341
+ await relay.close()
342
+ assert.equal(relay.status().targetRetryTimers, 0)
343
+ assert.equal(relay.status().targetConnectionAttempts, 0)
344
+ assert.equal(relay.status().sessions, 0)
345
+ for (let index = 0; index < tunnels.length; index += 1) {
346
+ assert.equal(sessions.get(`relay-close-${index}`), undefined)
347
+ }
348
+ })
349
+
350
+ test("target closure after announcement is terminal and never starts target recovery", async () => {
351
+ let targetConnections = 0
352
+ /** @type {import('node:net').Socket | undefined} */
353
+ let targetSocket
354
+ const targetServer = createServer((socket) => {
355
+ targetConnections += 1
356
+ targetSocket = socket
357
+ socket.on("error", () => {})
358
+ })
359
+ const targetPort = await listen(targetServer)
360
+ const {sessions, token, tokens} = await authenticatedStores()
361
+ const relay = await startTcpRelay({
362
+ authorizeTarget: () => true,
363
+ initialTargetRetryDelayMs: 10,
364
+ initialTargetRetryMaxDelayMs: 20,
365
+ sessions,
366
+ tokens
367
+ })
368
+ const tunnel = createConnection({host: "127.0.0.1", port: relay.port})
369
+ const collector = collectFrames(tunnel, new FrameDecoder({compactDirection: "target-to-client"}))
370
+ try {
371
+ await waitConnect(tunnel)
372
+ tunnel.write(encodeFrame({
373
+ type: "session.create", token: token.value, name: "terminal-target", identifier: "terminal-target",
374
+ target: {host: "127.0.0.1", port: targetPort}, framing: [2]
375
+ }))
376
+ await collector.waitFor((frame) => frame.header.type === "session.created")
377
+ assert.ok(targetSocket !== undefined)
378
+ targetSocket.destroy()
379
+ await collector.waitFor((frame) => frame.header.type === "session.close" || frame.header.code === "TARGET_CLOSED")
380
+ assert.equal(relay.status().targetRetryTimers, 0)
381
+ assert.equal(targetConnections, 1)
382
+ await new Promise((resolve) => setTimeout(resolve, 50))
383
+ assert.equal(targetConnections, 1, "an announced target must never reconnect")
384
+ } finally {
385
+ tunnel.destroy()
386
+ await relay.close()
387
+ await closeServer(targetServer)
388
+ }
389
+ })
390
+
391
+ test("repeated pending creates and aborts return to a zero-resource baseline", async () => {
392
+ const targetPort = await reservePort()
393
+ const {sessions, token, tokens} = await authenticatedStores()
394
+ const relay = await startTcpRelay({
395
+ authorizeTarget: () => true,
396
+ initialTargetRetryDelayMs: 20,
397
+ initialTargetRetryMaxDelayMs: 20,
398
+ sessions,
399
+ tokens
400
+ })
401
+ try {
402
+ for (let index = 0; index < 5; index += 1) {
403
+ const identifier = `bounded-pending-${index}`
404
+ const tunnel = createConnection({host: "127.0.0.1", port: relay.port})
405
+ tunnel.on("error", () => {})
406
+ await waitConnect(tunnel)
407
+ tunnel.write(encodeFrame({
408
+ type: "session.create", token: token.value, name: identifier, identifier,
409
+ target: {host: "127.0.0.1", port: targetPort}, framing: [2]
410
+ }))
411
+ await waitUntil(() => relay.status().targetRetryTimers === 1)
412
+ tunnel.destroy()
413
+ await waitClose(tunnel)
414
+ await waitUntil(() => relay.status().sessions === 0)
415
+ assert.equal(sessions.get(identifier), undefined)
416
+ assert.equal(relay.status().targetRetryTimers, 0)
417
+ assert.equal(relay.status().targetConnectionAttempts, 0)
418
+ }
419
+ } finally {
420
+ await relay.close()
421
+ }
422
+ })
423
+
424
+ test("a hanging initial target attempt times out, is destroyed, and enters normal backoff", async () => {
425
+ const {sessions, token, tokens} = await authenticatedStores()
426
+ /** @type {HangingTargetSocket[]} */
427
+ const attempts = []
428
+ const relay = await startTcpRelay({
429
+ authorizeTarget: () => true,
430
+ createTargetConnection: () => {
431
+ const attempt = new HangingTargetSocket()
432
+ attempts.push(attempt)
433
+ return /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (attempt))
434
+ },
435
+ initialTargetConnectTimeoutMs: 10,
436
+ initialTargetRetryDelayMs: 10,
437
+ initialTargetRetryMaxDelayMs: 10,
438
+ sessions,
439
+ tokens
440
+ })
441
+ const tunnel = createConnection({host: "127.0.0.1", port: relay.port})
442
+ try {
443
+ await waitConnect(tunnel)
444
+ tunnel.write(encodeFrame({
445
+ type: "session.create", token: token.value, name: "hanging-target", identifier: "hanging-target",
446
+ target: {host: "fixed-target.invalid", port: 2375}, framing: [2]
447
+ }))
448
+ await waitUntil(() => attempts.length === 2)
449
+ assert.equal(attempts[0]?.destroyCount, 1)
450
+ assert.equal(attempts[0]?.listenerCount("connect"), 0)
451
+ assert.equal(attempts[0]?.listenerCount("error"), 0)
452
+ assert.equal(attempts[0]?.listenerCount("close"), 0)
453
+ assert.equal(relay.status().targetConnectionAttempts, 1)
454
+ assert.equal(sessions.get("hanging-target")?.state, "creating")
455
+
456
+ tunnel.destroy()
457
+ await relay.closeActiveTunnel()
458
+ await waitUntil(() => relay.status().sessions === 0)
459
+ assert.equal(attempts[1]?.destroyCount, 1)
460
+ assert.equal(attempts[1]?.listenerCount("connect"), 0)
461
+ assert.equal(attempts[1]?.listenerCount("error"), 0)
462
+ assert.equal(attempts[1]?.listenerCount("close"), 0)
463
+ assert.deepEqual(relay.status(), {
464
+ activeTunnels: 0, activeTargets: 0, sessions: 0, attachedSessions: 0,
465
+ detachedSessions: 0, bufferedTargetBytes: 0, targetConnectionAttempts: 0, targetRetryTimers: 0
466
+ })
467
+ } finally {
468
+ tunnel.destroy()
469
+ await relay.close()
470
+ }
471
+ })
472
+
473
+ test("relay validates the initial target connect timeout before opening resources", async () => {
474
+ const {sessions, tokens} = await authenticatedStores()
475
+ await assert.rejects(
476
+ () => startTcpRelay({
477
+ authorizeTarget: () => true,
478
+ initialTargetConnectTimeoutMs: 0,
479
+ sessions,
480
+ tokens
481
+ }),
482
+ /initialTargetConnectTimeoutMs must be a positive integer/
483
+ )
484
+ })
485
+
486
+ test("relay close cancels a hanging target attempt and its connect timeout", async () => {
487
+ const {sessions, token, tokens} = await authenticatedStores()
488
+ /** @type {HangingTargetSocket[]} */
489
+ const attempts = []
490
+ const relay = await startTcpRelay({
491
+ authorizeTarget: () => true,
492
+ createTargetConnection: () => {
493
+ const attempt = new HangingTargetSocket()
494
+ attempts.push(attempt)
495
+ return /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (attempt))
496
+ },
497
+ initialTargetConnectTimeoutMs: 60_000,
498
+ sessions,
499
+ tokens
500
+ })
501
+ const tunnel = createConnection({host: "127.0.0.1", port: relay.port})
502
+ tunnel.on("error", () => {})
503
+ await waitConnect(tunnel)
504
+ tunnel.write(encodeFrame({
505
+ type: "session.create", token: token.value, name: "relay-close-hanging", identifier: "relay-close-hanging",
506
+ target: {host: "fixed-target.invalid", port: 2375}, framing: [2]
507
+ }))
508
+ await waitUntil(() => attempts.length === 1)
509
+
510
+ await relay.close()
511
+ assert.equal(attempts[0]?.destroyCount, 1)
512
+ assert.equal(attempts[0]?.listenerCount("connect"), 0)
513
+ assert.equal(attempts[0]?.listenerCount("error"), 0)
514
+ assert.equal(attempts[0]?.listenerCount("close"), 0)
515
+ assert.equal(relay.status().targetConnectionAttempts, 0)
516
+ assert.equal(relay.status().targetRetryTimers, 0)
517
+ assert.equal(relay.status().sessions, 0)
518
+ })
@@ -0,0 +1,163 @@
1
+ import {describe, expect, it, withTestCleanup} from "@velocious/testing"
2
+ import {execFile} from "node:child_process"
3
+ import {once} from "node:events"
4
+ import {chmod, mkdtemp, readFile, readdir, rm, stat, writeFile} from "node:fs/promises"
5
+ import {createServer} from "node:net"
6
+ import {homedir, tmpdir} from "node:os"
7
+ import {join} from "node:path"
8
+ import {fileURLToPath} from "node:url"
9
+ import {promisify} from "node:util"
10
+
11
+ import {StaticTokenStore} from "../../src/auth/static-token-store.js"
12
+ import {MultiplexedSocketductConnection} from "../../src/client/multiplexed-connection.js"
13
+ import {createJsonlFileLogger} from "../../src/logging/structured-logger.js"
14
+ import {startMultiplexedTcpRelay} from "../../src/proxy/multiplexed-relay.js"
15
+
16
+ const runProcess = promisify(execFile)
17
+ const writerPath = fileURLToPath(new URL("../fixtures/logging/process-writer.mjs", import.meta.url))
18
+
19
+ describe("logging recovery", {timeoutMs: 15000}, () => {
20
+ describe("fresh processes reuse a persistent home-bind log family without changing torn bytes", {timeoutMs: 15_000}, () => {
21
+ for (const maxFiles of [1, 2, 5]) {
22
+ it(`maxFiles=${maxFiles}`, async () => withTestCleanup(async (after) => {
23
+ const root = await mkdtemp(join(homedir(), ".socketduct-jsonl-restart-"))
24
+ after(() => rm(root, {recursive: true, force: true}))
25
+ const active = join(root, "reverse-gateway-1.jsonl")
26
+ const first = await runProcess(process.execPath, [writerPath, root, String(maxFiles), "1024"])
27
+ expect(first.stderr).toBe("")
28
+ expect(JSON.parse(first.stdout).reports).toEqual([])
29
+ await writeFile(active, '{"timestamp":"interrupted', {flag: "a"})
30
+ const torn = await readFile(active)
31
+ const original = await stat(active)
32
+
33
+ const second = await runProcess(process.execPath, [writerPath, root, String(maxFiles), "1024"])
34
+ expect(second.stderr).toBe("")
35
+ const evidencePath = maxFiles === 1 ? active : `${active}.1`
36
+ expect((await readFile(evidencePath)).equals(torn)).toBeTruthy()
37
+ expect((await stat(evidencePath)).ino).toBe(original.ino)
38
+ expect((await stat(evidencePath)).dev).toBe(original.dev)
39
+ if (maxFiles === 1) {
40
+ expect(JSON.parse(second.stdout).reports).toEqual(["Socketduct logger failure (LOGGER_IO_ERROR)\n"])
41
+ } else {
42
+ expect(JSON.parse(second.stdout).reports).toEqual([])
43
+ const contents = await readFile(active, "utf8")
44
+ expect(contents.endsWith("\n")).toBeTruthy()
45
+ expect(contents.trimEnd().split("\n").map((line) => JSON.parse(line).event)).toEqual(["process.started", "runtime.status"])
46
+ }
47
+ const third = await runProcess(process.execPath, [writerPath, root, String(maxFiles), "1024"])
48
+ expect(third.stderr).toBe("")
49
+ expect((await readFile(evidencePath)).equals(torn)).toBeTruthy()
50
+ expect((await readdir(root)).length <= maxFiles).toBeTruthy()
51
+ for (const file of await readdir(root)) {
52
+ const stats = await stat(join(root, file))
53
+ expect(stats.size <= 1024).toBeTruthy()
54
+ expect(stats.mode & 0o777).toBe(0o600)
55
+ }
56
+ expect((await stat(root)).mode & 0o777).toBe(0o700)
57
+ }))
58
+ }
59
+ })
60
+
61
+ it("real live and unprobeable PID families survive another process startup", {timeoutMs: 10_000}, async () => withTestCleanup(async (after) => {
62
+ const root = await mkdtemp(join(tmpdir(), "socketduct-jsonl-real-pids-"))
63
+ after(() => rm(root, {recursive: true, force: true}))
64
+ const evidence = Buffer.from('{}\n{"timestamp":"interrupted')
65
+ const files = []
66
+ for (const processId of [process.pid, 2 ** 40]) {
67
+ for (const suffix of ["", ".1", ".2"]) {
68
+ const path = join(root, `reverse-gateway-${processId}.jsonl${suffix}`)
69
+ files.push(path)
70
+ await writeFile(path, evidence, {mode: 0o600})
71
+ }
72
+ }
73
+ expect(process.kill(process.pid, 0)).toBe(true)
74
+ await expect(Promise.resolve().then(() => process.kill(2 ** 40, 0))).rejects.toMatchObject({code: "ERR_INVALID_ARG_TYPE"})
75
+ const result = await runProcess(process.execPath, [writerPath, root, "1", "1024"])
76
+ expect(JSON.parse(result.stdout).reports).toEqual([])
77
+ for (const path of files) expect((await readFile(path)).equals(evidence)).toBeTruthy()
78
+ }))
79
+
80
+ it("authenticated local payload traffic continues during file logger failure and recovery", {timeoutMs: 15_000}, async () => withTestCleanup(async (after) => {
81
+ const root = await mkdtemp(join(tmpdir(), "socketduct-jsonl-traffic-"))
82
+ const active = join(root, "embedded-client-1.jsonl")
83
+ const torn = Buffer.from('{}\n{"timestamp":"interrupted')
84
+ await writeFile(active, torn, {mode: 0o600})
85
+ await chmod(root, 0o500)
86
+ /** @type {string[]} */
87
+ const reports = []
88
+ const logger = createJsonlFileLogger({
89
+ directory: root, role: "embedded-client", processId: 1,
90
+ stderr: {write: (/** @type {string} */ message) => reports.push(message)}
91
+ })
92
+ const target = createServer((socket) => socket.on("data", (chunk) => socket.write(chunk)))
93
+ target.listen(0, "127.0.0.1")
94
+ await once(target, "listening")
95
+ const address = target.address()
96
+ if (address === null || typeof address === "string") throw new Error("Expected a bound TCP address")
97
+ const token = "private-fixture-token-not-for-logging"
98
+ const relay = await startMultiplexedTcpRelay({
99
+ tokens: new StaticTokenStore([{username: "fixture", token}]),
100
+ authorizeTarget: ({target: requested}) => requested.host === "127.0.0.1" && requested.port === address.port
101
+ })
102
+ const connection = new MultiplexedSocketductConnection({
103
+ relay: {host: "127.0.0.1", port: relay.port, token}, logger
104
+ })
105
+ after(async () => {
106
+ await connection.close()
107
+ await relay.close()
108
+ await new Promise((resolve) => target.close(resolve))
109
+ await chmod(root, 0o700)
110
+ await rm(root, {recursive: true, force: true})
111
+ })
112
+ const stream = connection.createConnection({host: "127.0.0.1", port: address.port, sessionName: "private-fixture-session"})
113
+ await once(stream, "connect")
114
+ expect(relay.status().authenticatedConnections).toBe(1)
115
+ expect(relay.status().physicalAccepts).toBe(1)
116
+ const payload = Buffer.from("private-fixture-payload\0\xff", "utf8")
117
+ const firstEcho = readPayload(stream, payload.length)
118
+ stream.write(payload)
119
+ expect((await firstEcho).equals(payload)).toBeTrue()
120
+ expect((await readFile(active)).equals(torn)).toBeTruthy()
121
+ expect(reports).toEqual(["Socketduct logger failure (LOGGER_IO_ERROR)\n"])
122
+
123
+ await chmod(root, 0o700)
124
+ const second = connection.createConnection({host: "127.0.0.1", port: address.port, sessionName: "private-fixture-session-two"})
125
+ await once(second, "connect")
126
+ const secondEcho = readPayload(second, payload.length)
127
+ second.write(payload)
128
+ expect((await secondEcho).equals(payload)).toBeTrue()
129
+ expect(relay.status().authenticatedConnections).toBe(1)
130
+ expect(relay.status().physicalAccepts).toBe(1)
131
+ expect((await readFile(`${active}.1`)).equals(torn)).toBeTruthy()
132
+ const contents = await readFile(active, "utf8")
133
+ expect(contents.endsWith("\n")).toBeTruthy()
134
+ const entries = contents.trimEnd().split("\n").map((line) => JSON.parse(line))
135
+ expect(entries.some(({event}) => event === "virtual_stream.opened")).toBeTruthy()
136
+ expect(contents).not.toMatch(/private-fixture|127\.0\.0\.1/)
137
+ expect(reports).toEqual(["Socketduct logger failure (LOGGER_IO_ERROR)\n"])
138
+ }))
139
+ })
140
+
141
+ /**
142
+ * @param {import('node:stream').Duplex} stream - Authenticated stream.
143
+ * @param {number} length - Expected payload length.
144
+ * @returns {Promise<Buffer>} Complete echoed payload.
145
+ */
146
+ async function readPayload(stream, length) {
147
+ return await new Promise((resolve, reject) => {
148
+ /** @type {Buffer[]} */
149
+ const chunks = []
150
+ let received = 0
151
+ /** @param {Buffer} chunk - Stream data. */
152
+ const onData = (chunk) => {
153
+ chunks.push(chunk)
154
+ received += chunk.length
155
+ if (received < length) return
156
+ stream.off("data", onData)
157
+ stream.off("error", reject)
158
+ resolve(Buffer.concat(chunks))
159
+ }
160
+ stream.on("data", onData)
161
+ stream.once("error", reject)
162
+ })
163
+ }