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,434 @@
1
+ import assert from "node:assert/strict"
2
+ import {createConnection, createServer} from "node:net"
3
+ import {mkdtempSync, rmSync} from "node:fs"
4
+ import {tmpdir} from "node:os"
5
+ import {join} from "node:path"
6
+ import test from "node:test"
7
+
8
+ import {encodeFrame, FrameDecoder} from "../../../src/protocol/frame.js"
9
+ import {encodeV3Control, encodeV3Frame, V3FrameType} from "../../../src/protocol/v3-frame.js"
10
+ import {MultiplexedSocketductConnection} from "../../../src/client/multiplexed-connection.js"
11
+ import {startRelayRuntime} from "../../../src/server/relay-runtime.js"
12
+ import {generateTestCertificates} from "../../helpers/tls-certificates.js"
13
+
14
+ /** @returns {Promise<{host: string, port: number, close: () => Promise<void>}>} Started TCP target. */
15
+ async function startTargetServer() {
16
+ const server = createServer((socket) => {
17
+ socket.on("data", (chunk) => socket.write(chunk))
18
+ })
19
+
20
+ await new Promise((resolve, reject) => {
21
+ server.once("error", reject)
22
+ server.listen(0, "127.0.0.1", () => {
23
+ server.off("error", reject)
24
+ resolve(undefined)
25
+ })
26
+ })
27
+
28
+ const address = server.address()
29
+ if (address === null || typeof address === "string") {
30
+ throw new Error("Target server did not bind to a TCP port")
31
+ }
32
+
33
+ return {
34
+ host: "127.0.0.1",
35
+ port: address.port,
36
+ close: async () => {
37
+ await new Promise((resolve, reject) => {
38
+ server.close((error) => {
39
+ if (error) {
40
+ reject(error)
41
+ return
42
+ }
43
+ resolve(undefined)
44
+ })
45
+ })
46
+ }
47
+ }
48
+ }
49
+
50
+ test("startRelayRuntime starts a token-authenticated allowlisted relay", async () => {
51
+ const target = await startTargetServer()
52
+ const runtime = await startRelayRuntime({
53
+ mode: "relay",
54
+ relayHost: "127.0.0.1",
55
+ relayPort: 0,
56
+ resumeWindowMs: 30_000,
57
+ maxTargetBufferBytes: 1_048_576,
58
+ maxTotalBufferBytes: 16_777_216,
59
+ handshakeTimeoutMs: 10_000,
60
+ maxInputBytesPerConnection: 65_536,
61
+ authTokens: [{username: "edge", token: "secret-token"}],
62
+ targetAllowlist: [{host: target.host, port: target.port}],
63
+ allowInsecurePublic: false
64
+ })
65
+
66
+ test.after(async () => {
67
+ await runtime.close()
68
+ await target.close()
69
+ })
70
+
71
+ assert.deepEqual(runtime.relay.status(), {
72
+ activeTunnels: 0,
73
+ activeTargets: 0,
74
+ sessions: 0,
75
+ attachedSessions: 0,
76
+ detachedSessions: 0,
77
+ bufferedTargetBytes: 0,
78
+ targetConnectionAttempts: 0,
79
+ targetRetryTimers: 0
80
+ })
81
+
82
+ const socket = await new Promise((resolve, reject) => {
83
+ const connection = createConnection({host: "127.0.0.1", port: runtime.relay.port})
84
+ connection.once("error", reject)
85
+ connection.once("connect", () => {
86
+ connection.off("error", reject)
87
+ resolve(connection)
88
+ })
89
+ })
90
+
91
+ const response = await sendControl(socket, {
92
+ type: "session.create",
93
+ token: "secret-token",
94
+ name: "docker-test",
95
+ target: {host: target.host, port: target.port}
96
+ })
97
+
98
+ assert.equal(response.type, "session.created")
99
+ socket.destroy()
100
+ })
101
+
102
+ test("startRelayRuntime can select protocol v3 while v2 remains the default", async () => {
103
+ const target = await startTargetServer()
104
+ const runtime = await startRelayRuntime({
105
+ mode: "relay",
106
+ protocolVersion: 3,
107
+ relayHost: "127.0.0.1",
108
+ relayPort: 0,
109
+ resumeWindowMs: 30_000,
110
+ maxTargetBufferBytes: 1_048_576,
111
+ maxTotalBufferBytes: 16_777_216,
112
+ handshakeTimeoutMs: 10_000,
113
+ maxInputBytesPerConnection: 65_536,
114
+ authTokens: [{username: "edge", token: "secret-token"}],
115
+ targetAllowlist: [{host: target.host, port: target.port}],
116
+ allowInsecurePublic: false
117
+ })
118
+ const connection = new MultiplexedSocketductConnection({
119
+ relay: {host: "127.0.0.1", port: runtime.relay.port, token: "secret-token"}
120
+ })
121
+ try {
122
+ const socket = connection.createConnection({host: target.host, port: target.port})
123
+ await new Promise((resolve, reject) => {
124
+ socket.once("connect", resolve)
125
+ socket.once("error", reject)
126
+ })
127
+ const response = new Promise((resolve, reject) => {
128
+ socket.once("data", (chunk) => resolve(chunk.toString()))
129
+ socket.once("error", reject)
130
+ })
131
+ socket.write("v3-runtime")
132
+ assert.equal(await response, "v3-runtime")
133
+ assert.equal(/** @type {{physicalAccepts: number}} */ (runtime.relay.status()).physicalAccepts, 1)
134
+ socket.destroy()
135
+ } finally {
136
+ await connection.close()
137
+ await runtime.close()
138
+ await target.close()
139
+ }
140
+ })
141
+
142
+ test("startRelayRuntime enforces configured cumulative v3 unauthenticated input bytes", async () => {
143
+ const runtime = await startRelayRuntime({
144
+ mode: "relay",
145
+ protocolVersion: 3,
146
+ relayHost: "127.0.0.1",
147
+ relayPort: 0,
148
+ resumeWindowMs: 30_000,
149
+ maxTargetBufferBytes: 1_048_576,
150
+ maxTotalBufferBytes: 16_777_216,
151
+ handshakeTimeoutMs: 10_000,
152
+ maxInputBytesPerConnection: 32,
153
+ authTokens: [{username: "edge", token: "secret-token"}],
154
+ targetAllowlist: [{host: "127.0.0.1", port: 1}],
155
+ allowInsecurePublic: false
156
+ })
157
+ const socket = createConnection({host: "127.0.0.1", port: runtime.relay.port})
158
+ try {
159
+ await new Promise((resolve, reject) => {
160
+ socket.once("connect", resolve)
161
+ socket.once("error", reject)
162
+ })
163
+ const auth = encodeV3Frame({type: V3FrameType.AUTH, streamId: 0, sequence: 1}, encodeV3Control({
164
+ version: 3,
165
+ token: "secret-token",
166
+ connectionId: "runtime_input_limit_01",
167
+ generation: 1
168
+ }))
169
+ assert(auth.length > 33)
170
+ socket.resume()
171
+ const closed = new Promise((resolve) => socket.once("close", resolve))
172
+ socket.write(auth.subarray(0, 33))
173
+ await Promise.race([
174
+ closed,
175
+ new Promise((_, reject) => setTimeout(() => reject(new Error("v3 unauthenticated input limit was not enforced")), 250))
176
+ ])
177
+ } finally {
178
+ socket.destroy()
179
+ await runtime.close()
180
+ }
181
+ })
182
+
183
+ test("startRelayRuntime requires tokens and target allowlist", async () => {
184
+ await assert.rejects(
185
+ () => startRelayRuntime({
186
+ mode: "relay",
187
+ relayHost: "127.0.0.1",
188
+ relayPort: 0,
189
+ resumeWindowMs: 30_000,
190
+ maxTargetBufferBytes: 1_048_576,
191
+ maxTotalBufferBytes: 16_777_216,
192
+ handshakeTimeoutMs: 10_000,
193
+ maxInputBytesPerConnection: 65_536,
194
+ authTokens: [],
195
+ targetAllowlist: [{host: "127.0.0.1", port: 1}],
196
+ allowInsecurePublic: false
197
+ }),
198
+ /SOCKETDUCT_AUTH_TOKENS/
199
+ )
200
+
201
+ await assert.rejects(
202
+ () => startRelayRuntime({
203
+ mode: "relay",
204
+ relayHost: "127.0.0.1",
205
+ relayPort: 0,
206
+ resumeWindowMs: 30_000,
207
+ maxTargetBufferBytes: 1_048_576,
208
+ maxTotalBufferBytes: 16_777_216,
209
+ handshakeTimeoutMs: 10_000,
210
+ maxInputBytesPerConnection: 65_536,
211
+ authTokens: [{username: "edge", token: "secret-token"}],
212
+ targetAllowlist: [],
213
+ allowInsecurePublic: false
214
+ }),
215
+ /SOCKETDUCT_TARGET_ALLOWLIST/
216
+ )
217
+ })
218
+
219
+ /**
220
+ * @param {import('node:net').Socket} socket - Connected relay socket.
221
+ * @param {Record<string, unknown> & {type: string}} header - Control frame header.
222
+ * @returns {Promise<Record<string, unknown>>} Decoded response header.
223
+ */
224
+ async function sendControl(socket, header) {
225
+ const decoder = new FrameDecoder()
226
+ return await new Promise((resolve, reject) => {
227
+ socket.on("data", (chunk) => {
228
+ for (const frame of decoder.push(chunk)) {
229
+ resolve(frame.header)
230
+ }
231
+ })
232
+ socket.once("error", reject)
233
+ const framedHeader = header.type === "session.create" || header.type === "session.resume"
234
+ ? {...header, framing: header.framing ?? [2]}
235
+ : header
236
+ socket.write(encodeFrame(framedHeader))
237
+ })
238
+ }
239
+
240
+ test("startRelayRuntime refuses non-loopback public bind without TLS or opt-out", async () => {
241
+ await assert.rejects(
242
+ () => startRelayRuntime({
243
+ mode: "relay",
244
+ relayHost: "0.0.0.0",
245
+ relayPort: 0,
246
+ resumeWindowMs: 30_000,
247
+ maxTargetBufferBytes: 1_048_576,
248
+ maxTotalBufferBytes: 16_777_216,
249
+ handshakeTimeoutMs: 10_000,
250
+ maxInputBytesPerConnection: 65_536,
251
+ authTokens: [{username: "edge", token: "secret-token"}],
252
+ targetAllowlist: [{host: "127.0.0.1", port: 1}],
253
+ allowInsecurePublic: false
254
+ }),
255
+ /public host.*without TLS/
256
+ )
257
+
258
+ await assert.rejects(
259
+ () => startRelayRuntime({
260
+ mode: "relay",
261
+ relayHost: "192.168.1.1",
262
+ relayPort: 0,
263
+ resumeWindowMs: 30_000,
264
+ maxTargetBufferBytes: 1_048_576,
265
+ maxTotalBufferBytes: 16_777_216,
266
+ handshakeTimeoutMs: 10_000,
267
+ maxInputBytesPerConnection: 65_536,
268
+ authTokens: [{username: "edge", token: "secret-token"}],
269
+ targetAllowlist: [{host: "127.0.0.1", port: 1}],
270
+ allowInsecurePublic: false
271
+ }),
272
+ /public host.*without TLS/
273
+ )
274
+ })
275
+
276
+ test("startRelayRuntime allows non-loopback public bind with explicit opt-out", async () => {
277
+ const target = await startTargetServer()
278
+ const runtime = await startRelayRuntime({
279
+ mode: "relay",
280
+ relayHost: "0.0.0.0",
281
+ relayPort: 0,
282
+ resumeWindowMs: 30_000,
283
+ maxTargetBufferBytes: 1_048_576,
284
+ maxTotalBufferBytes: 16_777_216,
285
+ handshakeTimeoutMs: 10_000,
286
+ maxInputBytesPerConnection: 65_536,
287
+ authTokens: [{username: "edge", token: "secret-token"}],
288
+ targetAllowlist: [{host: target.host, port: target.port}],
289
+ allowInsecurePublic: true
290
+ })
291
+
292
+ test.after(async () => {
293
+ await runtime.close()
294
+ await target.close()
295
+ })
296
+
297
+ const socket = await new Promise((resolve, reject) => {
298
+ const connection = createConnection({host: "127.0.0.1", port: runtime.relay.port})
299
+ connection.once("error", reject)
300
+ connection.once("connect", () => {
301
+ connection.off("error", reject)
302
+ resolve(connection)
303
+ })
304
+ })
305
+
306
+ const response = await sendControl(socket, {
307
+ type: "session.create",
308
+ token: "secret-token",
309
+ name: "insecure-test",
310
+ target: {host: target.host, port: target.port}
311
+ })
312
+
313
+ assert.equal(response.type, "session.created")
314
+ socket.destroy()
315
+ })
316
+
317
+ test("startRelayRuntime accepts loopback bind without TLS", async () => {
318
+ const target = await startTargetServer()
319
+ const runtime = await startRelayRuntime({
320
+ mode: "relay",
321
+ relayHost: "127.0.0.1",
322
+ relayPort: 0,
323
+ resumeWindowMs: 30_000,
324
+ maxTargetBufferBytes: 1_048_576,
325
+ maxTotalBufferBytes: 16_777_216,
326
+ handshakeTimeoutMs: 10_000,
327
+ maxInputBytesPerConnection: 65_536,
328
+ authTokens: [{username: "edge", token: "secret-token"}],
329
+ targetAllowlist: [{host: target.host, port: target.port}],
330
+ allowInsecurePublic: false
331
+ })
332
+
333
+ test.after(async () => {
334
+ await runtime.close()
335
+ await target.close()
336
+ })
337
+
338
+ const socket = await new Promise((resolve, reject) => {
339
+ const connection = createConnection({host: "127.0.0.1", port: runtime.relay.port})
340
+ connection.once("error", reject)
341
+ connection.once("connect", () => {
342
+ connection.off("error", reject)
343
+ resolve(connection)
344
+ })
345
+ })
346
+
347
+ const response = await sendControl(socket, {
348
+ type: "session.create",
349
+ token: "secret-token",
350
+ name: "loopback-test",
351
+ target: {host: target.host, port: target.port}
352
+ })
353
+
354
+ assert.equal(response.type, "session.created")
355
+ socket.destroy()
356
+ })
357
+
358
+ test("startRelayRuntime starts a TLS relay with valid certificate files", async () => {
359
+ const target = await startTargetServer()
360
+ const certDir = mkdtempSync(join(tmpdir(), "socketduct-tls-startup-"))
361
+ const certs = generateTestCertificates(certDir)
362
+
363
+ const runtime = await startRelayRuntime({
364
+ mode: "relay",
365
+ relayHost: "127.0.0.1",
366
+ relayPort: 0,
367
+ resumeWindowMs: 30_000,
368
+ maxTargetBufferBytes: 1_048_576,
369
+ maxTotalBufferBytes: 16_777_216,
370
+ handshakeTimeoutMs: 10_000,
371
+ maxInputBytesPerConnection: 65_536,
372
+ authTokens: [{username: "edge", token: "secret-token"}],
373
+ targetAllowlist: [{host: target.host, port: target.port}],
374
+ relayTls: {certFile: certs.serverCertFile, keyFile: certs.serverKeyFile},
375
+ allowInsecurePublic: false
376
+ })
377
+
378
+ test.after(async () => {
379
+ await runtime.close()
380
+ await target.close()
381
+ rmSync(certDir, {recursive: true, force: true})
382
+ })
383
+
384
+ assert.ok(runtime.relay.port > 0)
385
+ })
386
+
387
+ test("startRelayRuntime fails loudly on missing TLS certificate file", async () => {
388
+ await assert.rejects(
389
+ () => startRelayRuntime({
390
+ mode: "relay",
391
+ relayHost: "127.0.0.1",
392
+ relayPort: 0,
393
+ resumeWindowMs: 30_000,
394
+ maxTargetBufferBytes: 1_048_576,
395
+ maxTotalBufferBytes: 16_777_216,
396
+ handshakeTimeoutMs: 10_000,
397
+ maxInputBytesPerConnection: 65_536,
398
+ authTokens: [{username: "edge", token: "secret-token"}],
399
+ targetAllowlist: [{host: "127.0.0.1", port: 1}],
400
+ relayTls: {certFile: "/nonexistent/cert.crt", keyFile: "/nonexistent/key.key"},
401
+ allowInsecurePublic: false
402
+ }),
403
+ /ENOENT/
404
+ )
405
+ })
406
+
407
+ test("startRelayRuntime allows non-loopback bind with TLS configured", async () => {
408
+ const target = await startTargetServer()
409
+ const certDir = mkdtempSync(join(tmpdir(), "socketduct-tls-loopback-"))
410
+ const certs = generateTestCertificates(certDir)
411
+
412
+ const runtime = await startRelayRuntime({
413
+ mode: "relay",
414
+ relayHost: "0.0.0.0",
415
+ relayPort: 0,
416
+ resumeWindowMs: 30_000,
417
+ maxTargetBufferBytes: 1_048_576,
418
+ maxTotalBufferBytes: 16_777_216,
419
+ handshakeTimeoutMs: 10_000,
420
+ maxInputBytesPerConnection: 65_536,
421
+ authTokens: [{username: "edge", token: "secret-token"}],
422
+ targetAllowlist: [{host: target.host, port: target.port}],
423
+ relayTls: {certFile: certs.serverCertFile, keyFile: certs.serverKeyFile},
424
+ allowInsecurePublic: false
425
+ })
426
+
427
+ test.after(async () => {
428
+ await runtime.close()
429
+ await target.close()
430
+ rmSync(certDir, {recursive: true, force: true})
431
+ })
432
+
433
+ assert.ok(runtime.relay.port > 0)
434
+ })
@@ -0,0 +1,84 @@
1
+ import assert from "node:assert/strict"
2
+ import test from "node:test"
3
+
4
+ import {
5
+ createSession,
6
+ resumeSession,
7
+ transitionSession,
8
+ SessionStore
9
+ } from "../../../src/sessions/session.js"
10
+
11
+ test("createSession creates a named creating session with an opaque identifier", () => {
12
+ const session = createSession({name: "Office link", ownerId: "user-1", target: {host: "127.0.0.1", port: 22}})
13
+
14
+ assert.equal(session.name, "Office link")
15
+ assert.equal(session.ownerId, "user-1")
16
+ assert.equal(session.state, "creating")
17
+ assert.match(session.identifier, /^[a-zA-Z0-9_-]{32,}$/)
18
+ assert.equal(session.clientSequence, 0)
19
+ assert.equal(session.relaySequence, 0)
20
+ assert.deepEqual(session.target, {host: "127.0.0.1", port: 22})
21
+ })
22
+
23
+ test("transitionSession only allows legal lifecycle transitions", () => {
24
+ const session = createSession({name: "Flaky line", ownerId: "user-1", target: {host: "127.0.0.1", port: 22}, identifier: "session_123456789012345678901234567890"})
25
+
26
+ assert.equal(transitionSession(session, "active").state, "active")
27
+ assert.equal(transitionSession({...session, state: "interrupted"}, "resuming").state, "resuming")
28
+ assert.equal(transitionSession({...session, state: "resuming"}, "active").state, "active")
29
+
30
+ assert.throws(
31
+ () => transitionSession({...session, state: "active"}, "resuming"),
32
+ /illegal session state transition/i
33
+ )
34
+ })
35
+
36
+ test("SessionStore resumes sessions by identifier for the owning user", () => {
37
+ const store = new SessionStore()
38
+ const session = store.create({name: "Müggelheim relay", ownerId: "kasper", target: {host: "127.0.0.1", port: 22}})
39
+ store.transition(session.identifier, "active")
40
+ store.transition(session.identifier, "interrupted")
41
+
42
+ const resumed = resumeSession(store, {
43
+ identifier: session.identifier,
44
+ ownerId: "kasper"
45
+ })
46
+
47
+ assert.equal(resumed.identifier, session.identifier)
48
+ assert.equal(resumed.state, "resuming")
49
+ })
50
+
51
+ test("SessionStore rejects resume for a missing or expired session", () => {
52
+ const store = new SessionStore({now: () => 2_000})
53
+ store.create({name: "Expired", ownerId: "user-1", target: {host: "127.0.0.1", port: 22}, identifier: "expired_session_12345678901234567890", expiresAt: 1_000})
54
+
55
+ assert.throws(
56
+ () => resumeSession(store, {identifier: "missing_session_12345678901234567890", ownerId: "user-1"}),
57
+ /not found/i
58
+ )
59
+ assert.throws(
60
+ () => resumeSession(store, {identifier: "expired_session_12345678901234567890", ownerId: "user-1"}),
61
+ /expired/i
62
+ )
63
+ })
64
+
65
+ test("SessionStore rejects resume while initial target acquisition is still creating", () => {
66
+ const store = new SessionStore()
67
+ const session = store.create({name: "Pending", ownerId: "user-1", target: {host: "127.0.0.1", port: 22}})
68
+
69
+ assert.throws(
70
+ () => resumeSession(store, {identifier: session.identifier, ownerId: "user-1"}),
71
+ /not resumable/i
72
+ )
73
+ assert.equal(store.get(session.identifier)?.state, "creating")
74
+ })
75
+
76
+ test("SessionStore prevents a different user from resuming a session", () => {
77
+ const store = new SessionStore()
78
+ const session = store.create({name: "Private", ownerId: "owner", target: {host: "127.0.0.1", port: 22}})
79
+
80
+ assert.throws(
81
+ () => resumeSession(store, {identifier: session.identifier, ownerId: "other-user"}),
82
+ /does not belong/i
83
+ )
84
+ })