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,669 @@
1
+ import assert from "node:assert/strict"
2
+ import {createHook} from "node:async_hooks"
3
+ import {once} from "node:events"
4
+ import {mkdtemp, readdir, readFile, rm} from "node:fs/promises"
5
+ import https from "node:https"
6
+ import {createServer as createTcpServer} from "node:net"
7
+ import {tmpdir} from "node:os"
8
+ import {join} from "node:path"
9
+ import test from "node:test"
10
+
11
+ import {InMemoryUserStore, login, TokenStore} from "../../src/auth/user-store.js"
12
+ import {SocketductHttpsAgent} from "../../src/client/https-agent.js"
13
+ import {startTcpRelay} from "../../src/proxy/tcp-relay.js"
14
+ import {SessionStore} from "../../src/sessions/session.js"
15
+ import {generateTestCertificates, generateTestServerCertificate} from "../helpers/tls-certificates.js"
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
+ /**
45
+ * @param {() => boolean} predicate - Completion predicate.
46
+ * @param {number} [timeoutMs] - Maximum wait.
47
+ * @returns {Promise<void>}
48
+ */
49
+ async function waitUntil(predicate, timeoutMs = 2_000) {
50
+ const deadline = Date.now() + timeoutMs
51
+ while (!predicate()) {
52
+ if (Date.now() >= deadline) throw new Error("timed out waiting for condition")
53
+ await new Promise((resolve) => setTimeout(resolve, 2))
54
+ }
55
+ }
56
+
57
+ /**
58
+ * @param {() => Promise<boolean>} predicate - Async completion predicate.
59
+ * @param {number} [timeoutMs] - Maximum wait.
60
+ * @returns {Promise<void>}
61
+ */
62
+ async function waitUntilAsync(predicate, timeoutMs = 2_000) {
63
+ const deadline = Date.now() + timeoutMs
64
+ while (!await predicate()) {
65
+ if (Date.now() >= deadline) throw new Error("timed out waiting for async condition")
66
+ await new Promise((resolve) => setTimeout(resolve, 2))
67
+ }
68
+ }
69
+
70
+ /**
71
+ * @param {string} directory - Directory to read when present.
72
+ * @returns {Promise<string[]>} Directory entries or an empty list when absent.
73
+ */
74
+ async function readDirectoryOrEmpty(directory) {
75
+ return await readdir(directory).catch((error) => {
76
+ if (error instanceof Error && /** @type {Error & {code?: string}} */ (error).code === "ENOENT") return []
77
+ throw error
78
+ })
79
+ }
80
+
81
+ /**
82
+ * @param {import('node:https').RequestOptions} options - Request options.
83
+ * @param {(socket: import('node:stream').Duplex) => void} [onSocketductSocket] - Optional raw socket observer.
84
+ * @returns {Promise<{body: string, statusCode: number | undefined}>} Response.
85
+ */
86
+ async function request(options, onSocketductSocket) {
87
+ return await new Promise((resolve, reject) => {
88
+ const req = https.request(options, (res) => {
89
+ if (onSocketductSocket !== undefined) {
90
+ const tlsSocket = /** @type {import('node:tls').TLSSocket & {ssl?: {_parentWrap?: {stream?: import('node:stream').Duplex}}}} */ (res.socket)
91
+ const socket = tlsSocket.ssl?._parentWrap?.stream
92
+ if (socket === undefined) {
93
+ reject(new Error("target TLS socket does not expose its wrapped Socketduct stream"))
94
+ res.destroy()
95
+ return
96
+ }
97
+ onSocketductSocket(socket)
98
+ }
99
+ /** @type {Buffer[]} */
100
+ const chunks = []
101
+ res.on("data", (chunk) => chunks.push(chunk))
102
+ res.on("error", reject)
103
+ res.on("end", () => resolve({body: Buffer.concat(chunks).toString("utf8"), statusCode: res.statusCode}))
104
+ })
105
+ req.on("error", reject)
106
+ req.end()
107
+ })
108
+ }
109
+
110
+ /**
111
+ * @param {number} targetPort - Authorized target port.
112
+ * @param {Partial<import('../../src/proxy/tcp-relay.js').TcpRelayOptions>} [options] - Relay overrides.
113
+ * @returns {Promise<{relay: import('../../src/proxy/tcp-relay.js').TcpRelayHandle, sessions: SessionStore, token: import('../../src/auth/user-store.js').AuthToken}>} Relay fixture.
114
+ */
115
+ async function authenticatedRelay(targetPort, options = {}) {
116
+ const users = new InMemoryUserStore()
117
+ const tokens = new TokenStore({now: () => 1_000, tokenTtlMs: 60_000})
118
+ await users.createUser({username: "https-user", password: "correct horse battery staple"})
119
+ const token = await login(users, tokens, {username: "https-user", password: "correct horse battery staple"})
120
+ const sessions = new SessionStore({now: () => 1_000})
121
+ const relay = await startTcpRelay({
122
+ authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
123
+ initialTargetRetryDelayMs: 10,
124
+ initialTargetRetryMaxDelayMs: 20,
125
+ sessions,
126
+ tokens,
127
+ ...options
128
+ })
129
+ return {relay, sessions, token}
130
+ }
131
+
132
+ /**
133
+ * @param {{ca: Buffer, cert?: Buffer, key?: Buffer, servername?: string}} targetTls
134
+ * @param {{port: number, relay: Awaited<ReturnType<typeof authenticatedRelay>>["relay"], relayTls?: import('../../src/client/socketduct-socket.js').SocketductTlsOptions, spoolDirectory: string, token: {value: string}}} fixture
135
+ * @returns {SocketductHttpsAgent} Configured agent.
136
+ */
137
+ function createAgent(targetTls, fixture) {
138
+ return new SocketductHttpsAgent({
139
+ relay: {host: "127.0.0.1", port: fixture.relay.port, token: fixture.token.value},
140
+ ...(fixture.relayTls === undefined ? {} : {relayTls: fixture.relayTls}),
141
+ target: {host: "127.0.0.1", port: fixture.port},
142
+ targetTls: {rejectUnauthorized: true, servername: "localhost", ...targetTls},
143
+ spoolDirectory: fixture.spoolDirectory,
144
+ sessionNamePrefix: "https-agent",
145
+ keepAlive: false
146
+ })
147
+ }
148
+
149
+ test("SocketductHttpsAgent rejects callback-less direct connections before allocating resources", async () => {
150
+ const root = await mkdtemp(join(tmpdir(), "socketduct-https-unowned-"))
151
+ let targetConnections = 0
152
+ const targetServer = createTcpServer(() => { targetConnections += 1 })
153
+ const port = await listen(targetServer)
154
+ const fixture = await authenticatedRelay(port)
155
+ const agent = createAgent({ca: Buffer.alloc(0)}, {
156
+ port, relay: fixture.relay, spoolDirectory: join(root, "spool"), token: fixture.token
157
+ })
158
+ try {
159
+ assert.throws(() => Reflect.apply(agent.createConnection, agent, [{host: "localhost", port: 443}]), {
160
+ code: "ERR_INVALID_ARG_TYPE"
161
+ })
162
+ await new Promise((resolve) => setImmediate(resolve))
163
+ assert.equal(targetConnections, 0)
164
+ assert.deepEqual(fixture.relay.status(), {
165
+ activeTunnels: 0, activeTargets: 0, sessions: 0, attachedSessions: 0,
166
+ detachedSessions: 0, bufferedTargetBytes: 0, targetConnectionAttempts: 0, targetRetryTimers: 0
167
+ })
168
+ assert.deepEqual(await readdir(join(root, "spool")).catch((error) => {
169
+ if (error instanceof Error && /** @type {Error & {code?: string}} */ (error).code === "ENOENT") return []
170
+ throw error
171
+ }), [])
172
+ } finally {
173
+ agent.destroy()
174
+ await fixture.relay.close()
175
+ await closeServer(targetServer)
176
+ await rm(root, {recursive: true, force: true})
177
+ }
178
+ })
179
+
180
+ test("SocketductHttpsAgent rejects an already-aborted request before allocating resources", async () => {
181
+ const root = await mkdtemp(join(tmpdir(), "socketduct-https-pre-aborted-"))
182
+ let targetConnections = 0
183
+ const targetServer = createTcpServer(() => { targetConnections += 1 })
184
+ const port = await listen(targetServer)
185
+ const fixture = await authenticatedRelay(port)
186
+ const agent = createAgent({ca: Buffer.alloc(0)}, {
187
+ port, relay: fixture.relay, spoolDirectory: join(root, "spool"), token: fixture.token
188
+ })
189
+ const controller = new AbortController()
190
+ controller.abort()
191
+ try {
192
+ await assert.rejects(request({agent, host: "localhost", port: 443, path: "/already-aborted", signal: controller.signal}), (error) => {
193
+ return error instanceof Error && error.name === "AbortError"
194
+ })
195
+ await new Promise((resolve) => setImmediate(resolve))
196
+ assert.equal(targetConnections, 0)
197
+ assert.equal(fixture.relay.status().sessions, 0)
198
+ assert.equal(fixture.relay.status().activeTunnels, 0)
199
+ assert.equal(fixture.relay.status().activeTargets, 0)
200
+ } finally {
201
+ agent.destroy()
202
+ await fixture.relay.close()
203
+ await closeServer(targetServer)
204
+ await rm(root, {recursive: true, force: true})
205
+ }
206
+ })
207
+
208
+ test("SocketductHttpsAgent verifies the target CA and logical hostname", async () => {
209
+ const root = await mkdtemp(join(tmpdir(), "socketduct-https-strict-"))
210
+ const certs = generateTestCertificates(join(root, "certs"))
211
+ const ca = await readFile(certs.caCertFile)
212
+ const targetServer = https.createServer({cert: await readFile(certs.serverCertFile), key: await readFile(certs.serverKeyFile)}, (req, res) => {
213
+ res.writeHead(200)
214
+ res.end("secure-ok")
215
+ })
216
+ const port = await listen(targetServer)
217
+ const fixture = await authenticatedRelay(port, {tls: {certFile: certs.serverCertFile, keyFile: certs.serverKeyFile}})
218
+ const agent = createAgent({ca}, {
219
+ port,
220
+ relay: fixture.relay,
221
+ relayTls: {caFile: certs.caCertFile, servername: "localhost"},
222
+ spoolDirectory: join(root, "spool"),
223
+ token: fixture.token
224
+ })
225
+ try {
226
+ assert.deepEqual(await request({agent, host: "localhost", port: 443, path: "/_ping"}), {body: "secure-ok", statusCode: 200})
227
+ } finally {
228
+ agent.destroy()
229
+ await fixture.relay.close()
230
+ await closeServer(targetServer)
231
+ await rm(root, {recursive: true, force: true})
232
+ }
233
+ })
234
+
235
+ test("SocketductHttpsAgent omits IP SNI while strictly verifying the target IP", async () => {
236
+ const root = await mkdtemp(join(tmpdir(), "socketduct-https-ip-"))
237
+ const certs = generateTestCertificates(join(root, "certs"))
238
+ const ca = await readFile(certs.caCertFile)
239
+ /** @type {Array<string | false | null>} */
240
+ const servernames = []
241
+ const targetServer = https.createServer({cert: await readFile(certs.serverCertFile), key: await readFile(certs.serverKeyFile)}, (req, res) => res.end("ip-ok"))
242
+ targetServer.on("secureConnection", (socket) => servernames.push(socket.servername))
243
+ const port = await listen(targetServer)
244
+ const fixture = await authenticatedRelay(port)
245
+ const successAgent = createAgent({ca, servername: "127.0.0.1"}, {
246
+ port, relay: fixture.relay, spoolDirectory: join(root, "spool"), token: fixture.token
247
+ })
248
+ const mismatchAgent = createAgent({ca, servername: "127.0.0.2"}, {
249
+ port, relay: fixture.relay, spoolDirectory: join(root, "spool"), token: fixture.token
250
+ })
251
+ try {
252
+ assert.equal((await request({agent: successAgent, host: "127.0.0.1", port: 443, path: "/ip"})).body, "ip-ok")
253
+ successAgent.destroy()
254
+ await waitUntil(() => fixture.relay.status().sessions === 0)
255
+ assert.deepEqual(servernames, [false])
256
+
257
+ await assert.rejects(request({agent: mismatchAgent, host: "127.0.0.2", port: 443, path: "/ip-mismatch"}), {
258
+ code: "ERR_TLS_CERT_ALTNAME_INVALID"
259
+ })
260
+ mismatchAgent.destroy()
261
+ await waitUntil(() => fixture.relay.status().sessions === 0)
262
+ } finally {
263
+ successAgent.destroy()
264
+ mismatchAgent.destroy()
265
+ await fixture.relay.close()
266
+ await closeServer(targetServer)
267
+ await rm(root, {recursive: true, force: true})
268
+ }
269
+ })
270
+
271
+ test("SocketductHttpsAgent derives an omitted target TLS identity from the fixed IP without sending SNI", async () => {
272
+ const root = await mkdtemp(join(tmpdir(), "socketduct-https-derived-ip-"))
273
+ const certs = generateTestCertificates(join(root, "certs"), {serverSubjectAltName: "IP:127.0.0.1"})
274
+ const ca = await readFile(certs.caCertFile)
275
+ /** @type {Array<string | false | null>} */
276
+ const servernames = []
277
+ const targetServer = https.createServer({
278
+ cert: await readFile(certs.serverCertFile),
279
+ key: await readFile(certs.serverKeyFile)
280
+ }, (req, res) => res.end("derived-ip-ok"))
281
+ targetServer.on("secureConnection", (socket) => servernames.push(socket.servername))
282
+ const port = await listen(targetServer)
283
+ const fixture = await authenticatedRelay(port)
284
+ const spoolDirectory = join(root, "spool")
285
+ const agent = new SocketductHttpsAgent({
286
+ relay: {host: "127.0.0.1", port: fixture.relay.port, token: fixture.token.value},
287
+ target: {host: "127.0.0.1", port},
288
+ targetTls: {ca, rejectUnauthorized: true},
289
+ spoolDirectory,
290
+ keepAlive: false
291
+ })
292
+ try {
293
+ assert.equal((await request({agent, host: "127.0.0.1", port: 443, path: "/derived-ip"})).body, "derived-ip-ok")
294
+ agent.destroy()
295
+ await waitUntil(() => {
296
+ const status = fixture.relay.status()
297
+ return status.sessions === 0 && status.activeTunnels === 0 && status.activeTargets === 0
298
+ })
299
+ assert.deepEqual(servernames, [false])
300
+ assert.deepEqual(fixture.relay.status(), {
301
+ activeTunnels: 0, activeTargets: 0, sessions: 0, attachedSessions: 0,
302
+ detachedSessions: 0, bufferedTargetBytes: 0, targetConnectionAttempts: 0, targetRetryTimers: 0
303
+ })
304
+ await waitUntilAsync(async () => (await readDirectoryOrEmpty(spoolDirectory)).length === 0)
305
+ assert.deepEqual(await readDirectoryOrEmpty(spoolDirectory), [])
306
+ } finally {
307
+ agent.destroy()
308
+ await fixture.relay.close()
309
+ await closeServer(targetServer)
310
+ await rm(root, {recursive: true, force: true})
311
+ }
312
+ })
313
+
314
+ test("SocketductHttpsAgent rejects a trusted different identity when target TLS servername is omitted", async () => {
315
+ const root = await mkdtemp(join(tmpdir(), "socketduct-https-derived-ip-mismatch-"))
316
+ const certs = generateTestCertificates(join(root, "certs"), {serverSubjectAltName: "IP:127.0.0.1"})
317
+ const wrongCerts = generateTestServerCertificate(join(root, "wrong-certs"), {
318
+ caCertFile: certs.caCertFile,
319
+ caKeyFile: certs.caKeyFile,
320
+ commonName: "localhost",
321
+ subjectAltName: "DNS:localhost"
322
+ })
323
+ const ca = await readFile(certs.caCertFile)
324
+ const targetServer = https.createServer({
325
+ cert: await readFile(wrongCerts.serverCertFile),
326
+ key: await readFile(wrongCerts.serverKeyFile)
327
+ }, (req, res) => res.end("wrongly-accepted"))
328
+ const port = await listen(targetServer)
329
+ const fixture = await authenticatedRelay(port)
330
+ const spoolDirectory = join(root, "spool")
331
+ const agent = new SocketductHttpsAgent({
332
+ relay: {host: "127.0.0.1", port: fixture.relay.port, token: fixture.token.value},
333
+ target: {host: "127.0.0.1", port},
334
+ targetTls: {ca, rejectUnauthorized: true},
335
+ spoolDirectory,
336
+ keepAlive: false
337
+ })
338
+ try {
339
+ await assert.rejects(request({agent, host: "127.0.0.1", port: 443, path: "/wrong-identity"}), {
340
+ code: "ERR_TLS_CERT_ALTNAME_INVALID"
341
+ })
342
+ agent.destroy()
343
+ await waitUntil(() => {
344
+ const status = fixture.relay.status()
345
+ return status.sessions === 0 && status.activeTunnels === 0 && status.activeTargets === 0
346
+ })
347
+ assert.deepEqual(fixture.relay.status(), {
348
+ activeTunnels: 0, activeTargets: 0, sessions: 0, attachedSessions: 0,
349
+ detachedSessions: 0, bufferedTargetBytes: 0, targetConnectionAttempts: 0, targetRetryTimers: 0
350
+ })
351
+ await waitUntilAsync(async () => (await readDirectoryOrEmpty(spoolDirectory)).length === 0)
352
+ assert.deepEqual(await readDirectoryOrEmpty(spoolDirectory), [])
353
+ } finally {
354
+ agent.destroy()
355
+ await fixture.relay.close()
356
+ await closeServer(targetServer)
357
+ await rm(root, {recursive: true, force: true})
358
+ }
359
+ })
360
+
361
+ test("SocketductHttpsAgent rejects an explicitly empty target TLS servername before allocation", () => {
362
+ assert.throws(() => new SocketductHttpsAgent({
363
+ relay: {host: "127.0.0.1", port: 1, token: "unused-test-token"},
364
+ target: {host: "127.0.0.1", port: 1},
365
+ targetTls: {servername: ""},
366
+ spoolDirectory: "/unused"
367
+ }), /Target TLS servername must be a non-empty string/)
368
+ })
369
+
370
+ test("SocketductHttpsAgent supports target mTLS", async () => {
371
+ const root = await mkdtemp(join(tmpdir(), "socketduct-https-mtls-"))
372
+ const certs = generateTestCertificates(join(root, "certs"))
373
+ const ca = await readFile(certs.caCertFile)
374
+ const targetServer = https.createServer({
375
+ ca,
376
+ cert: await readFile(certs.serverCertFile),
377
+ key: await readFile(certs.serverKeyFile),
378
+ requestCert: true,
379
+ rejectUnauthorized: true
380
+ }, (req, res) => {
381
+ res.writeHead(200)
382
+ res.end(/** @type {import('node:tls').TLSSocket} */ (req.socket).authorized ? "mtls-ok" : "unauthorized")
383
+ })
384
+ const port = await listen(targetServer)
385
+ const fixture = await authenticatedRelay(port)
386
+ const agent = createAgent({
387
+ ca,
388
+ cert: await readFile(certs.clientCertFile),
389
+ key: await readFile(certs.clientKeyFile)
390
+ }, {port, relay: fixture.relay, spoolDirectory: join(root, "spool"), token: fixture.token})
391
+ try {
392
+ assert.equal((await request({agent, host: "localhost", port: 443, path: "/_ping"})).body, "mtls-ok")
393
+ } finally {
394
+ agent.destroy()
395
+ await fixture.relay.close()
396
+ await closeServer(targetServer)
397
+ await rm(root, {recursive: true, force: true})
398
+ }
399
+ })
400
+
401
+ test("SocketductHttpsAgent keeps one target TLS connection through relay interruption", async () => {
402
+ const root = await mkdtemp(join(tmpdir(), "socketduct-https-resume-"))
403
+ const certs = generateTestCertificates(join(root, "certs"))
404
+ const ca = await readFile(certs.caCertFile)
405
+ let targetConnections = 0
406
+ /** @type {(() => void) | undefined} */
407
+ let releaseResponse
408
+ const continueResponse = new Promise((resolve) => { releaseResponse = () => resolve(undefined) })
409
+ /** @type {(() => void) | undefined} */
410
+ let firstChunkResolve
411
+ const firstChunk = new Promise((resolve) => { firstChunkResolve = () => resolve(undefined) })
412
+ const targetServer = https.createServer({
413
+ cert: await readFile(certs.serverCertFile),
414
+ key: await readFile(certs.serverKeyFile)
415
+ }, (req, res) => {
416
+ res.writeHead(200)
417
+ res.write("before-")
418
+ firstChunkResolve?.()
419
+ void continueResponse.then(() => res.end("after"))
420
+ })
421
+ targetServer.on("connection", () => { targetConnections += 1 })
422
+ const port = await listen(targetServer)
423
+ const fixture = await authenticatedRelay(port)
424
+ const spoolDirectory = join(root, "spool")
425
+ const agent = createAgent({ca}, {port, relay: fixture.relay, spoolDirectory, token: fixture.token})
426
+ /** @type {Promise<void> | undefined} */
427
+ let socketductClosed
428
+ try {
429
+ const responsePromise = request(
430
+ {agent, host: "localhost", port: 443, path: "/stream"},
431
+ (socket) => { socketductClosed = once(socket, "close").then(() => undefined) }
432
+ )
433
+ await firstChunk
434
+ await waitUntil(() => fixture.relay.status().attachedSessions === 1)
435
+ await fixture.relay.closeActiveTunnel()
436
+ await waitUntil(() => fixture.relay.status().attachedSessions === 1)
437
+ releaseResponse?.()
438
+ assert.equal((await responsePromise).body, "before-after")
439
+ assert.equal(targetConnections, 1)
440
+ agent.destroy()
441
+ await socketductClosed
442
+ assert.deepEqual(await readDirectoryOrEmpty(spoolDirectory), [])
443
+ } finally {
444
+ releaseResponse?.()
445
+ agent.destroy()
446
+ await socketductClosed
447
+ await fixture.relay.close()
448
+ await closeServer(targetServer)
449
+ await rm(root, {recursive: true, force: true})
450
+ }
451
+ })
452
+
453
+ test("SocketductHttpsAgent preserves TLS verification errors and settles its callback once", async () => {
454
+ const root = await mkdtemp(join(tmpdir(), "socketduct-https-failure-"))
455
+ const certs = generateTestCertificates(join(root, "certs"))
456
+ const targetServer = https.createServer({cert: await readFile(certs.serverCertFile), key: await readFile(certs.serverKeyFile)})
457
+ const port = await listen(targetServer)
458
+ const fixture = await authenticatedRelay(port)
459
+ const agent = createAgent({ca: await readFile(certs.caCertFile), servername: "wrong.example.test"}, {
460
+ port, relay: fixture.relay, spoolDirectory: join(root, "spool"), token: fixture.token
461
+ })
462
+ let callbackCount = 0
463
+ try {
464
+ const result = await new Promise((resolve) => {
465
+ agent.createConnection({host: "wrong.example.test", port: 443}, (error, socket) => {
466
+ callbackCount += 1
467
+ resolve({error, socket})
468
+ })
469
+ })
470
+ assert.equal(callbackCount, 1)
471
+ assert.ok(result.error instanceof Error)
472
+ assert.equal(result.error.code, "ERR_TLS_CERT_ALTNAME_INVALID")
473
+ assert.equal(result.socket, undefined)
474
+ await waitUntil(() => fixture.relay.status().sessions === 0).catch(() => {
475
+ assert.fail(`relay resources after TLS verification failure: ${JSON.stringify(fixture.relay.status())}`)
476
+ })
477
+ } finally {
478
+ agent.destroy()
479
+ await fixture.relay.close()
480
+ await closeServer(targetServer)
481
+ await rm(root, {recursive: true, force: true})
482
+ }
483
+ })
484
+
485
+ test("SocketductHttpsAgent aborts and times out stalled target TLS handshakes without resources", {timeout: 5_000}, async () => {
486
+ const root = await mkdtemp(join(tmpdir(), "socketduct-https-stalled-"))
487
+ const certs = generateTestCertificates(join(root, "certs"))
488
+ /** @type {Set<import('node:net').Socket>} */
489
+ const accepted = new Set()
490
+ let acceptedCount = 0
491
+ /** @type {Set<{count: number, resolve: () => void}>} */
492
+ const acceptanceWaiters = new Set()
493
+ const stalledTarget = createTcpServer((socket) => {
494
+ socket.once("close", () => accepted.delete(socket))
495
+ socket.once("data", () => {
496
+ accepted.add(socket)
497
+ acceptedCount += 1
498
+ for (const waiter of acceptanceWaiters) {
499
+ if (acceptedCount < waiter.count) continue
500
+ acceptanceWaiters.delete(waiter)
501
+ waiter.resolve()
502
+ }
503
+ })
504
+ })
505
+ const port = await listen(stalledTarget)
506
+ const fixture = await authenticatedRelay(port)
507
+ const agent = createAgent({ca: await readFile(certs.caCertFile)}, {
508
+ port, relay: fixture.relay, spoolDirectory: join(root, "spool"), token: fixture.token
509
+ })
510
+ /**
511
+ * @param {number} count - Expected accepted sockets.
512
+ * @returns {Promise<void>}
513
+ */
514
+ const waitForAccepted = async (count) => {
515
+ if (acceptedCount >= count) return
516
+ await new Promise((resolve) => {
517
+ acceptanceWaiters.add({count, resolve: () => resolve(undefined)})
518
+ })
519
+ }
520
+ const repeatingTimeouts = new Set()
521
+ const timeoutHook = createHook({
522
+ init: (asyncId, type, _triggerAsyncId, resource) => {
523
+ const repeat = /** @type {{_repeat?: unknown}} */ (resource)._repeat
524
+ if (type === "Timeout" && typeof repeat === "number" && repeat > 0) repeatingTimeouts.add(asyncId)
525
+ },
526
+ destroy: (asyncId) => repeatingTimeouts.delete(asyncId)
527
+ })
528
+ try {
529
+ const controller = new AbortController()
530
+ /** @type {import('node:http').ClientRequest | undefined} */
531
+ let signalRequest
532
+ let signalErrorCount = 0
533
+ const signalAborted = new Promise((resolve, reject) => {
534
+ signalRequest = https.request({agent, host: "localhost", port: 443, path: "/signal-abort", signal: controller.signal}, resolve)
535
+ signalRequest.on("error", (error) => {
536
+ signalErrorCount += 1
537
+ reject(error)
538
+ })
539
+ signalRequest.end()
540
+ })
541
+ void signalAborted.catch(() => {})
542
+ await waitForAccepted(1)
543
+ controller.abort()
544
+ await waitUntil(() => fixture.relay.status().sessions === 0 && accepted.size === 0, 100).catch(() => {
545
+ assert.fail(`relay resources after AbortSignal: ${JSON.stringify(fixture.relay.status())}; accepted=${accepted.size}`)
546
+ })
547
+ await assert.rejects(signalAborted, (error) => error instanceof Error && error.name === "AbortError")
548
+ await new Promise((resolve) => setImmediate(resolve))
549
+ assert.equal(signalErrorCount, 1)
550
+ assert.equal(signalRequest?.listeners("abort").some((listener) => listener.name === "abort"), false)
551
+ assert.equal(signalRequest?.listeners("close").some((listener) => listener.name === "onClose"), false)
552
+
553
+ timeoutHook.enable()
554
+ /** @type {import('node:http').ClientRequest | undefined} */
555
+ let abortRequest
556
+ const aborted = new Promise((resolve, reject) => {
557
+ abortRequest = https.request({agent, host: "localhost", port: 443, path: "/abort"}, resolve)
558
+ abortRequest.once("error", reject)
559
+ abortRequest.end()
560
+ })
561
+ await waitForAccepted(2)
562
+ await new Promise((resolve) => setImmediate(resolve))
563
+ const repeatingTimeoutCount = repeatingTimeouts.size
564
+ timeoutHook.disable()
565
+ abortRequest?.abort()
566
+ await assert.rejects(aborted, (error) => error instanceof Error && error.name === "AbortError")
567
+ assert.equal(repeatingTimeoutCount, 0, "a pending request must not install a polling timer")
568
+ await waitUntil(() => fixture.relay.status().sessions === 0 && accepted.size === 0).catch(() => {
569
+ assert.fail(`relay resources after request abort: ${JSON.stringify(fixture.relay.status())}; accepted=${accepted.size}`)
570
+ })
571
+
572
+ const timedOut = assert.rejects(
573
+ request({agent, host: "localhost", port: 443, path: "/timeout", timeout: 25}),
574
+ (error) => error instanceof Error && /** @type {Error & {code?: string}} */ (error).code === "ETIMEDOUT"
575
+ )
576
+ await waitForAccepted(3)
577
+ await timedOut
578
+ await waitUntil(() => fixture.relay.status().sessions === 0 && accepted.size === 0)
579
+ } finally {
580
+ timeoutHook.disable()
581
+ agent.destroy()
582
+ for (const socket of accepted) socket.destroy()
583
+ await fixture.relay.close()
584
+ await closeServer(stalledTarget)
585
+ await rm(root, {recursive: true, force: true})
586
+ }
587
+ })
588
+
589
+ test("SocketductHttpsAgent destroy cancels a stalled handshake exactly once", {timeout: 5_000}, async () => {
590
+ const root = await mkdtemp(join(tmpdir(), "socketduct-https-destroy-"))
591
+ const certs = generateTestCertificates(join(root, "certs"))
592
+ /** @type {((socket: import('node:net').Socket) => void) | undefined} */
593
+ let acceptedResolve
594
+ const accepted = new Promise((resolve) => { acceptedResolve = resolve })
595
+ let targetClosed = false
596
+ const stalledTarget = createTcpServer((socket) => {
597
+ socket.once("close", () => { targetClosed = true })
598
+ socket.once("data", () => acceptedResolve?.(socket))
599
+ })
600
+ const port = await listen(stalledTarget)
601
+ const fixture = await authenticatedRelay(port)
602
+ const agent = createAgent({ca: await readFile(certs.caCertFile)}, {
603
+ port, relay: fixture.relay, spoolDirectory: join(root, "spool"), token: fixture.token
604
+ })
605
+ let callbackCount = 0
606
+ const result = new Promise((resolve) => {
607
+ agent.createConnection({host: "localhost", port: 443}, (error, socket) => {
608
+ callbackCount += 1
609
+ resolve({error, socket})
610
+ })
611
+ })
612
+ const targetSocket = /** @type {import('node:net').Socket} */ (await accepted)
613
+ try {
614
+ agent.destroy()
615
+ const settled = await result
616
+ assert.equal(callbackCount, 1)
617
+ assert.ok(settled.error instanceof Error)
618
+ assert.equal(settled.socket, undefined)
619
+ await waitUntil(() => fixture.relay.status().sessions === 0 && targetClosed).catch(() => {
620
+ assert.fail(`relay resources after agent destroy: ${JSON.stringify(fixture.relay.status())}; targetClosed=${targetClosed}`)
621
+ })
622
+ } finally {
623
+ agent.destroy()
624
+ targetSocket.destroy()
625
+ await fixture.relay.close()
626
+ await closeServer(stalledTarget)
627
+ await rm(root, {recursive: true, force: true})
628
+ }
629
+ })
630
+
631
+ test("SocketductHttpsAgent repeated success and verification failure returns to baseline without spool leaks", async () => {
632
+ const root = await mkdtemp(join(tmpdir(), "socketduct-https-bounded-"))
633
+ const certs = generateTestCertificates(join(root, "certs"))
634
+ const ca = await readFile(certs.caCertFile)
635
+ const targetServer = https.createServer({cert: await readFile(certs.serverCertFile), key: await readFile(certs.serverKeyFile)}, (req, res) => res.end("ok"))
636
+ const port = await listen(targetServer)
637
+ const fixture = await authenticatedRelay(port)
638
+ try {
639
+ for (let index = 0; index < 3; index += 1) {
640
+ const successAgent = createAgent({ca}, {port, relay: fixture.relay, spoolDirectory: join(root, "spool"), token: fixture.token})
641
+ assert.equal((await request({agent: successAgent, host: "localhost", port: 443, path: `/ok-${index}`})).body, "ok")
642
+ successAgent.destroy()
643
+ await waitUntil(() => fixture.relay.status().sessions === 0)
644
+
645
+ const failureAgent = createAgent({ca, servername: "wrong.example.test"}, {
646
+ port, relay: fixture.relay, spoolDirectory: join(root, "spool"), token: fixture.token
647
+ })
648
+ await assert.rejects(request({agent: failureAgent, host: "localhost", port: 443, path: `/fail-${index}`}), {
649
+ code: "ERR_TLS_CERT_ALTNAME_INVALID"
650
+ })
651
+ failureAgent.destroy()
652
+ await waitUntil(() => fixture.relay.status().sessions === 0)
653
+ assert.equal(fixture.relay.status().activeTargets, 0)
654
+ assert.equal(fixture.relay.status().targetConnectionAttempts, 0)
655
+ assert.equal(fixture.relay.status().targetRetryTimers, 0)
656
+ }
657
+ const readSpoolEntries = async () => await readdir(join(root, "spool")).catch((error) => {
658
+ if (error instanceof Error && /** @type {Error & {code?: string}} */ (error).code === "ENOENT") return []
659
+ throw error
660
+ })
661
+ await waitUntilAsync(async () => (await readSpoolEntries()).length === 0)
662
+ const spoolEntries = await readSpoolEntries()
663
+ assert.deepEqual(spoolEntries, [])
664
+ } finally {
665
+ await fixture.relay.close()
666
+ await closeServer(targetServer)
667
+ await rm(root, {recursive: true, force: true})
668
+ }
669
+ })