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,291 @@
1
+ import assert from "node:assert/strict"
2
+ import test from "node:test"
3
+
4
+ import {InMemoryUserStore, login, TokenStore} from "../../../src/auth/user-store.js"
5
+ import {handleControlMessage as rawHandleControlMessage} from "../../../src/server/control-handler.js"
6
+ import {SessionStore} from "../../../src/sessions/session.js"
7
+
8
+ /**
9
+ * @returns {Promise<{token: import('../../../src/auth/user-store.js').AuthToken, tokens: TokenStore, sessions: SessionStore}>} - Authenticated test stores.
10
+ */
11
+ async function createAuthenticatedStores() {
12
+ const users = new InMemoryUserStore()
13
+ const tokens = new TokenStore({now: () => 1000})
14
+ await users.createUser({username: "kasper", password: "correct horse battery staple"})
15
+ const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
16
+ return {token, tokens, sessions: new SessionStore({now: () => 1000})}
17
+ }
18
+
19
+ /**
20
+ * Calls the handler with the required v2 capabilities used by normal peers.
21
+ * @param {Parameters<typeof rawHandleControlMessage>[0]} options - Handler options.
22
+ * @param {Parameters<typeof rawHandleControlMessage>[1]} message - Control message.
23
+ * @returns {ReturnType<typeof rawHandleControlMessage>} Control response.
24
+ */
25
+ function handleControlMessage(options, message) {
26
+ return rawHandleControlMessage({...options, supportedFraming: [2]}, {...message, framing: [2]})
27
+ }
28
+
29
+ test("handleControlMessage requires auth before creating sessions", () => {
30
+ const response = handleControlMessage({tokens: new TokenStore(), sessions: new SessionStore()}, {
31
+ type: "session.create",
32
+ name: "laptop shell"
33
+ })
34
+
35
+ assert.deepEqual(response, {
36
+ type: "error",
37
+ code: "AUTH_REQUIRED",
38
+ message: "Authentication is required.",
39
+ retryable: false
40
+ })
41
+ })
42
+
43
+ test("handleControlMessage creates a named session for authenticated users", async () => {
44
+ const {token, tokens, sessions} = await createAuthenticatedStores()
45
+
46
+ const response = handleControlMessage({tokens, sessions, authorizeTarget: () => true}, {
47
+ type: "session.create",
48
+ token: token.value,
49
+ name: "laptop shell",
50
+ identifier: "session-123",
51
+ target: {host: "127.0.0.1", port: 5432}
52
+ })
53
+
54
+ assert.deepEqual(response, {
55
+ type: "session.created",
56
+ identifier: "session-123",
57
+ name: "laptop shell",
58
+ state: "creating",
59
+ target: {host: "127.0.0.1", port: 5432},
60
+ framing: 2
61
+ })
62
+ })
63
+
64
+ test("handleControlMessage returns a protocol error for duplicate session identifiers", async () => {
65
+ const {token, tokens, sessions} = await createAuthenticatedStores()
66
+ const message = {
67
+ type: "session.create",
68
+ token: token.value,
69
+ name: "laptop shell",
70
+ identifier: "session-123",
71
+ target: {host: "127.0.0.1", port: 5432}
72
+ }
73
+
74
+ assert.equal(handleControlMessage({tokens, sessions, authorizeTarget: () => true}, message).type, "session.created")
75
+ assert.deepEqual(handleControlMessage({tokens, sessions, authorizeTarget: () => true}, message), {
76
+ type: "error",
77
+ code: "SESSION_EXISTS",
78
+ message: "Session identifier already exists.",
79
+ retryable: false
80
+ })
81
+ })
82
+
83
+ test("handleControlMessage resumes only sessions owned by the authenticated user", async () => {
84
+ const {token, tokens, sessions} = await createAuthenticatedStores()
85
+ const created = handleControlMessage({tokens, sessions, authorizeTarget: () => true}, {
86
+ type: "session.create",
87
+ token: token.value,
88
+ name: "laptop shell",
89
+ identifier: "session-123",
90
+ target: {host: "127.0.0.1", port: 5432}
91
+ })
92
+ assert.equal(created.type, "session.created")
93
+ sessions.transition("session-123", "active")
94
+ sessions.updateSequences("session-123", {clientSequence: 42, relaySequence: 7})
95
+ sessions.transition("session-123", "interrupted")
96
+
97
+ const resumed = handleControlMessage({tokens, sessions, authorizeTarget: () => true}, {
98
+ type: "session.resume",
99
+ token: token.value,
100
+ identifier: "session-123"
101
+ })
102
+
103
+ assert.deepEqual(resumed, {
104
+ type: "session.resumed",
105
+ identifier: "session-123",
106
+ name: "laptop shell",
107
+ state: "resuming",
108
+ serverLastSequence: 7,
109
+ serverLastAck: 42,
110
+ replayFromSequence: 43,
111
+ target: {host: "127.0.0.1", port: 5432},
112
+ framing: 2
113
+ })
114
+ })
115
+
116
+ test("handleControlMessage rejects missing or malformed session targets", async () => {
117
+ const {token, tokens, sessions} = await createAuthenticatedStores()
118
+
119
+ assert.deepEqual(handleControlMessage({tokens, sessions, authorizeTarget: () => true}, {
120
+ type: "session.create",
121
+ token: token.value,
122
+ name: "missing target"
123
+ }), {
124
+ type: "error",
125
+ code: "PROTOCOL_VIOLATION",
126
+ message: "Target host and port are required.",
127
+ retryable: false
128
+ })
129
+
130
+ assert.deepEqual(handleControlMessage({tokens, sessions, authorizeTarget: () => true}, {
131
+ type: "session.create",
132
+ token: token.value,
133
+ name: "bad target",
134
+ target: {host: "", port: 0}
135
+ }), {
136
+ type: "error",
137
+ code: "PROTOCOL_VIOLATION",
138
+ message: "Target host and port are required.",
139
+ retryable: false
140
+ })
141
+ })
142
+
143
+ test("handleControlMessage rejects unauthorized targets", async () => {
144
+ const {token, tokens, sessions} = await createAuthenticatedStores()
145
+
146
+ const response = handleControlMessage({
147
+ tokens,
148
+ sessions,
149
+ authorizeTarget: () => false
150
+ }, {
151
+ type: "session.create",
152
+ token: token.value,
153
+ name: "blocked target",
154
+ target: {host: "10.0.0.1", port: 22}
155
+ })
156
+
157
+ assert.deepEqual(response, {
158
+ type: "error",
159
+ code: "TARGET_UNAUTHORIZED",
160
+ message: "Target is not authorized for this principal.",
161
+ retryable: false
162
+ })
163
+ })
164
+
165
+ test("handleControlMessage selects a mutually supported stream.data codec on create", async () => {
166
+ const {token, tokens, sessions} = await createAuthenticatedStores()
167
+
168
+ const response = handleControlMessage({tokens, sessions, authorizeTarget: () => true, supportedCodecs: ["zstd"]}, {
169
+ type: "session.create",
170
+ token: token.value,
171
+ name: "compressed",
172
+ target: {host: "127.0.0.1", port: 5432},
173
+ compression: ["zstd"]
174
+ })
175
+
176
+ assert.equal(response.type, "session.created")
177
+ assert.equal(response.compression, "zstd")
178
+ })
179
+
180
+ test("handleControlMessage omits compression for a legacy client that offers none", async () => {
181
+ const {token, tokens, sessions} = await createAuthenticatedStores()
182
+
183
+ const response = handleControlMessage({tokens, sessions, authorizeTarget: () => true, supportedCodecs: ["zstd"]}, {
184
+ type: "session.create",
185
+ token: token.value,
186
+ name: "legacy",
187
+ target: {host: "127.0.0.1", port: 5432}
188
+ })
189
+
190
+ assert.equal(response.type, "session.created")
191
+ assert.equal("compression" in response, false)
192
+ })
193
+
194
+ test("handleControlMessage omits compression when the relay supports no codec", async () => {
195
+ const {token, tokens, sessions} = await createAuthenticatedStores()
196
+
197
+ const response = handleControlMessage({tokens, sessions, authorizeTarget: () => true, supportedCodecs: []}, {
198
+ type: "session.create",
199
+ token: token.value,
200
+ name: "disabled",
201
+ target: {host: "127.0.0.1", port: 5432},
202
+ compression: ["zstd"]
203
+ })
204
+
205
+ assert.equal(response.type, "session.created")
206
+ assert.equal("compression" in response, false)
207
+ })
208
+
209
+ test("handleControlMessage omits compression when no offered codec is supported", async () => {
210
+ const {token, tokens, sessions} = await createAuthenticatedStores()
211
+
212
+ const response = handleControlMessage({tokens, sessions, authorizeTarget: () => true, supportedCodecs: ["zstd"]}, {
213
+ type: "session.create",
214
+ token: token.value,
215
+ name: "unknown codec",
216
+ target: {host: "127.0.0.1", port: 5432},
217
+ compression: ["brotli"]
218
+ })
219
+
220
+ assert.equal(response.type, "session.created")
221
+ assert.equal("compression" in response, false)
222
+ })
223
+
224
+ test("handleControlMessage renegotiates the stream.data codec on resume", async () => {
225
+ const {token, tokens, sessions} = await createAuthenticatedStores()
226
+
227
+ const created = handleControlMessage({tokens, sessions, authorizeTarget: () => true, supportedCodecs: ["zstd"]}, {
228
+ type: "session.create",
229
+ token: token.value,
230
+ name: "resumable",
231
+ target: {host: "127.0.0.1", port: 5432},
232
+ compression: ["zstd"]
233
+ })
234
+ assert.equal(created.type, "session.created")
235
+ const identifier = /** @type {string} */ (created.identifier)
236
+ sessions.transition(identifier, "active")
237
+
238
+ const resumed = handleControlMessage({tokens, sessions, authorizeTarget: () => true, supportedCodecs: ["zstd"]}, {
239
+ type: "session.resume",
240
+ token: token.value,
241
+ identifier,
242
+ compression: ["zstd"]
243
+ })
244
+
245
+ assert.equal(resumed.type, "session.resumed")
246
+ assert.equal(resumed.compression, "zstd")
247
+ })
248
+
249
+ test("handleControlMessage negotiates compact framing v2 only when offered and supported", async () => {
250
+ const first = await createAuthenticatedStores()
251
+ const base = {type: "session.create", token: first.token.value, name: "compact", target: {host: "127.0.0.1", port: 80}}
252
+ const selected = rawHandleControlMessage({tokens: first.tokens, sessions: first.sessions, authorizeTarget: () => true, supportedFraming: [2]}, {...base, framing: [2]})
253
+ assert.equal(selected.framing, 2)
254
+
255
+ const second = await createAuthenticatedStores()
256
+ const missing = rawHandleControlMessage({tokens: second.tokens, sessions: second.sessions, authorizeTarget: () => true, supportedFraming: [2]}, {...base, token: second.token.value, identifier: "missing-framing"})
257
+ assert.equal(missing.type, "error")
258
+ assert.equal(missing.code, "PROTOCOL_VIOLATION")
259
+ assert.equal(second.sessions.get("missing-framing"), undefined)
260
+
261
+ const third = await createAuthenticatedStores()
262
+ const legacy = rawHandleControlMessage({tokens: third.tokens, sessions: third.sessions, authorizeTarget: () => true, supportedFraming: [2]}, {...base, token: third.token.value, identifier: "legacy-framing", framing: [1]})
263
+ assert.equal(legacy.type, "error")
264
+ assert.equal(legacy.code, "PROTOCOL_VIOLATION")
265
+ assert.equal(third.sessions.get("legacy-framing"), undefined)
266
+ })
267
+
268
+ test("handleControlMessage rejects legacy resume before authentication or session mutation", async () => {
269
+ const {token, tokens, sessions} = await createAuthenticatedStores()
270
+ handleControlMessage({tokens, sessions, authorizeTarget: () => true}, {
271
+ type: "session.create",
272
+ token: token.value,
273
+ name: "resumable",
274
+ identifier: "resume-framing",
275
+ target: {host: "127.0.0.1", port: 80}
276
+ })
277
+ sessions.transition("resume-framing", "active")
278
+ sessions.transition("resume-framing", "interrupted")
279
+
280
+ let authenticationCalls = 0
281
+ const response = rawHandleControlMessage({
282
+ tokens: {authenticate: () => { authenticationCalls += 1; return {userId: "ignored", username: "ignored"} }},
283
+ sessions,
284
+ supportedFraming: [2]
285
+ }, {type: "session.resume", token: "ignored", identifier: "resume-framing", framing: [1]})
286
+
287
+ assert.equal(response.type, "error")
288
+ assert.equal(response.code, "PROTOCOL_VIOLATION")
289
+ assert.equal(authenticationCalls, 0)
290
+ assert.equal(sessions.get("resume-framing")?.state, "interrupted")
291
+ })
@@ -0,0 +1,59 @@
1
+ import assert from "node:assert/strict"
2
+ import test from "node:test"
3
+
4
+ import {resolveWorkerCount, startPrimary} from "../../../src/server/primary.js"
5
+
6
+ test("resolveWorkerCount uses configured workers or available CPUs", () => {
7
+ assert.equal(resolveWorkerCount(3, () => 99), 3)
8
+ assert.equal(resolveWorkerCount(undefined, () => 4), 4)
9
+ assert.equal(resolveWorkerCount(undefined, () => 0), 1)
10
+ assert.throws(() => resolveWorkerCount(0), /Invalid worker count/)
11
+ })
12
+
13
+ test("startPrimary forks configured workers and restarts exits", () => {
14
+ /** @type {Array<Record<string, string> | undefined>} */
15
+ const forks = []
16
+ /** @type {undefined | ((worker: {id: number}, code: number | null, signal: string | null) => void)} */
17
+ let exitListener
18
+ /** @type {string[]} */
19
+ const warnings = []
20
+
21
+ const result = startPrimary({
22
+ workers: 2,
23
+ clusterApi: {
24
+ isPrimary: true,
25
+ fork: (env) => {
26
+ forks.push(env)
27
+ return {id: forks.length}
28
+ },
29
+ on: (_event, listener) => {
30
+ exitListener = listener
31
+ }
32
+ },
33
+ logger: {warn: (message) => warnings.push(message)}
34
+ })
35
+
36
+ assert.deepEqual(result, {role: "primary", workers: 2})
37
+ assert.deepEqual(forks, [{SOCKETDUCT_WORKER_INDEX: "0"}, {SOCKETDUCT_WORKER_INDEX: "1"}])
38
+
39
+ assert.ok(exitListener)
40
+ exitListener({id: 7}, 1, null)
41
+ assert.equal(forks.length, 3)
42
+ assert.equal(warnings.length, 1)
43
+ assert.match(String(warnings[0]), /worker 7 exited/)
44
+ })
45
+
46
+ test("startPrimary reports worker role without forking", () => {
47
+ const result = startPrimary({
48
+ workers: 2,
49
+ clusterApi: {
50
+ isPrimary: false,
51
+ fork: () => {
52
+ throw new Error("should not fork from worker")
53
+ },
54
+ on: () => {}
55
+ }
56
+ })
57
+
58
+ assert.deepEqual(result, {role: "worker", workers: 0})
59
+ })