socketduct 0.1.25 → 0.1.26

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 (56) hide show
  1. package/AGENTS.md +2 -2
  2. package/changelog.d/20261010130509-bound-log-frame-compression-memory.md +1 -0
  3. package/docs/runbooks/jsonl-integrity-rollout.md +8 -6
  4. package/package.json +2 -2
  5. package/scripts/test.js +9 -14
  6. package/src/logging/compressed-log-writer.js +1 -1
  7. package/src/logging/zstd-frame-codec.js +15 -1
  8. package/test/e2e/gateway-process.test.js +93 -92
  9. package/test/e2e/logger-shutdown-process.test.js +40 -37
  10. package/test/e2e/reverse-client-recovery-process.test.js +107 -103
  11. package/test/e2e/reverse-gateway-process.test.js +328 -318
  12. package/test/integration/initial-target-recovery.test.js +333 -334
  13. package/test/integration/logging-cli.test.js +121 -119
  14. package/test/integration/logging-compressed-recovery.test.js +96 -95
  15. package/test/integration/multiplexed-compatibility.test.js +178 -177
  16. package/test/integration/multiplexed-corrections.test.js +360 -359
  17. package/test/integration/multiplexed-failures.test.js +253 -251
  18. package/test/integration/multiplexed-transport-set.test.js +334 -322
  19. package/test/integration/multiplexed-transport.test.js +135 -134
  20. package/test/integration/reverse-gateway-stream-id-reset.test.js +90 -89
  21. package/test/integration/reverse-gateway.test.js +2309 -2218
  22. package/test/integration/reverse-resume-continuity.test.js +219 -218
  23. package/test/integration/reverse-route-class.test.js +358 -341
  24. package/test/integration/socketduct-http-agent.test.js +314 -312
  25. package/test/integration/socketduct-https-agent.test.js +516 -485
  26. package/test/integration/stream-compression.test.js +862 -824
  27. package/test/integration/tcp-forwarding.test.js +2550 -2447
  28. package/test/integration/tls-forwarding.test.js +593 -592
  29. package/test/unit/auth/auth.test.js +43 -44
  30. package/test/unit/auth/static-token-store.test.js +9 -8
  31. package/test/unit/client/connect.test.js +41 -40
  32. package/test/unit/client/deferred-virtual-socket.test.js +95 -94
  33. package/test/unit/client/multiplexed-spool-budget.test.js +155 -154
  34. package/test/unit/client/multiplexed-transport-set.test.js +830 -748
  35. package/test/unit/client/socketduct-socket.test.js +768 -771
  36. package/test/unit/client/virtual-socket.test.js +198 -168
  37. package/test/unit/config/docker-gateway-files.test.js +224 -224
  38. package/test/unit/config/environment.test.js +472 -492
  39. package/test/unit/index.test.js +53 -52
  40. package/test/unit/logging/compressed-log-worker.test.js +23 -22
  41. package/test/unit/logging/compressed-log-writer.test.js +155 -154
  42. package/test/unit/logging/log-retention.test.js +52 -47
  43. package/test/unit/logging/log-segment-index.test.js +84 -81
  44. package/test/unit/logging/zstd-frame-codec.test.js +28 -19
  45. package/test/unit/protocol/compression.test.js +254 -298
  46. package/test/unit/protocol/frame.test.js +280 -276
  47. package/test/unit/protocol/v3-compression.test.js +21 -23
  48. package/test/unit/protocol/v3-frame.test.js +42 -41
  49. package/test/unit/release-configuration.test.js +15 -12
  50. package/test/unit/reverse/disconnect-attribution.test.js +65 -64
  51. package/test/unit/reverse-terminal-outcomes.test.js +53 -50
  52. package/test/unit/server/control-handler.test.js +232 -231
  53. package/test/unit/server/primary.test.js +51 -49
  54. package/test/unit/server/relay-runtime.test.js +280 -294
  55. package/test/unit/sessions/session.test.js +48 -62
  56. package/test/unit/spool/spool-file.test.js +401 -385
@@ -1,4 +1,4 @@
1
- import assert from "node:assert/strict"
1
+ import {describe, expect, it} from "@velocious/testing"
2
2
  import {createHook} from "node:async_hooks"
3
3
  import {once} from "node:events"
4
4
  import {mkdtemp, readdir, readFile, rm} from "node:fs/promises"
@@ -6,7 +6,6 @@ import https from "node:https"
6
6
  import {createServer as createTcpServer} from "node:net"
7
7
  import {tmpdir} from "node:os"
8
8
  import {join} from "node:path"
9
- import test from "node:test"
10
9
 
11
10
  import {InMemoryUserStore, login, TokenStore} from "../../src/auth/user-store.js"
12
11
  import {SocketductHttpsAgent} from "../../src/client/https-agent.js"
@@ -146,524 +145,556 @@ function createAgent(targetTls, fixture) {
146
145
  })
147
146
  }
148
147
 
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"
148
+ describe("socketduct https agent", () => {
149
+ it("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
194
157
  })
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
158
+ try {
159
+ let error
160
+ try {
161
+ Reflect.apply(agent.createConnection, agent, [{host: "localhost", port: 443}])
162
+ } catch (caught) {
163
+ error = caught
164
+ }
165
+ expect(error).toBeDefined()
166
+ expect(/** @type {Error & {code?: string}} */ (error).code).toBe("ERR_INVALID_ARG_TYPE")
167
+ await new Promise((resolve) => setImmediate(resolve))
168
+ expect(targetConnections).toBe(0)
169
+ expect(fixture.relay.status()).toEqual({
170
+ activeTunnels: 0, activeTargets: 0, sessions: 0, attachedSessions: 0,
171
+ detachedSessions: 0, bufferedTargetBytes: 0, targetConnectionAttempts: 0, targetRetryTimers: 0
172
+ })
173
+ expect(await readdir(join(root, "spool")).catch((error) => {
174
+ if (error instanceof Error && /** @type {Error & {code?: string}} */ (error).code === "ENOENT") return []
175
+ throw error
176
+ })).toEqual([])
177
+ } finally {
178
+ agent.destroy()
179
+ await fixture.relay.close()
180
+ await closeServer(targetServer)
181
+ await rm(root, {recursive: true, force: true})
182
+ }
224
183
  })
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
184
 
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
185
+ it("SocketductHttpsAgent rejects an already-aborted request before allocating resources", async () => {
186
+ const root = await mkdtemp(join(tmpdir(), "socketduct-https-pre-aborted-"))
187
+ let targetConnections = 0
188
+ const targetServer = createTcpServer(() => { targetConnections += 1 })
189
+ const port = await listen(targetServer)
190
+ const fixture = await authenticatedRelay(port)
191
+ const agent = createAgent({ca: Buffer.alloc(0)}, {
192
+ port, relay: fixture.relay, spoolDirectory: join(root, "spool"), token: fixture.token
193
+ })
194
+ const controller = new AbortController()
195
+ controller.abort()
196
+ try {
197
+ let error
198
+ try {
199
+ await request({agent, host: "localhost", port: 443, path: "/already-aborted", signal: controller.signal})
200
+ } catch (caught) {
201
+ error = caught
202
+ }
203
+ expect(error).toBeDefined()
204
+ expect(error instanceof Error && error.name === "AbortError").toBe(true)
205
+ await new Promise((resolve) => setImmediate(resolve))
206
+ expect(targetConnections).toBe(0)
207
+ expect(fixture.relay.status().sessions).toBe(0)
208
+ expect(fixture.relay.status().activeTunnels).toBe(0)
209
+ expect(fixture.relay.status().activeTargets).toBe(0)
210
+ } finally {
211
+ agent.destroy()
212
+ await fixture.relay.close()
213
+ await closeServer(targetServer)
214
+ await rm(root, {recursive: true, force: true})
215
+ }
250
216
  })
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
217
 
257
- await assert.rejects(request({agent: mismatchAgent, host: "127.0.0.2", port: 443, path: "/ip-mismatch"}), {
258
- code: "ERR_TLS_CERT_ALTNAME_INVALID"
218
+ it("SocketductHttpsAgent verifies the target CA and logical hostname", async () => {
219
+ const root = await mkdtemp(join(tmpdir(), "socketduct-https-strict-"))
220
+ const certs = generateTestCertificates(join(root, "certs"))
221
+ const ca = await readFile(certs.caCertFile)
222
+ const targetServer = https.createServer({cert: await readFile(certs.serverCertFile), key: await readFile(certs.serverKeyFile)}, (req, res) => {
223
+ res.writeHead(200)
224
+ res.end("secure-ok")
259
225
  })
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
226
+ const port = await listen(targetServer)
227
+ const fixture = await authenticatedRelay(port, {tls: {certFile: certs.serverCertFile, keyFile: certs.serverKeyFile}})
228
+ const agent = createAgent({ca}, {
229
+ port,
230
+ relay: fixture.relay,
231
+ relayTls: {caFile: certs.caCertFile, servername: "localhost"},
232
+ spoolDirectory: join(root, "spool"),
233
+ token: fixture.token
234
+ })
235
+ try {
236
+ expect(await request({agent, host: "localhost", port: 443, path: "/_ping"})).toEqual({body: "secure-ok", statusCode: 200})
237
+ } finally {
238
+ agent.destroy()
239
+ await fixture.relay.close()
240
+ await closeServer(targetServer)
241
+ await rm(root, {recursive: true, force: true})
242
+ }
291
243
  })
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
244
+
245
+ it("SocketductHttpsAgent omits IP SNI while strictly verifying the target IP", async () => {
246
+ const root = await mkdtemp(join(tmpdir(), "socketduct-https-ip-"))
247
+ const certs = generateTestCertificates(join(root, "certs"))
248
+ const ca = await readFile(certs.caCertFile)
249
+ /** @type {Array<string | false | null>} */
250
+ const servernames = []
251
+ const targetServer = https.createServer({cert: await readFile(certs.serverCertFile), key: await readFile(certs.serverKeyFile)}, (req, res) => res.end("ip-ok"))
252
+ targetServer.on("secureConnection", (socket) => servernames.push(socket.servername))
253
+ const port = await listen(targetServer)
254
+ const fixture = await authenticatedRelay(port)
255
+ const successAgent = createAgent({ca, servername: "127.0.0.1"}, {
256
+ port, relay: fixture.relay, spoolDirectory: join(root, "spool"), token: fixture.token
298
257
  })
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
258
+ const mismatchAgent = createAgent({ca, servername: "127.0.0.2"}, {
259
+ port, relay: fixture.relay, spoolDirectory: join(root, "spool"), token: fixture.token
303
260
  })
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
- })
261
+ try {
262
+ expect((await request({agent: successAgent, host: "127.0.0.1", port: 443, path: "/ip"})).body).toBe("ip-ok")
263
+ successAgent.destroy()
264
+ await waitUntil(() => fixture.relay.status().sessions === 0)
265
+ expect(servernames).toEqual([false])
313
266
 
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
267
+ await expect(request({agent: mismatchAgent, host: "127.0.0.2", port: 443, path: "/ip-mismatch"})).rejects.toMatchObject({
268
+ code: "ERR_TLS_CERT_ALTNAME_INVALID"
269
+ })
270
+ mismatchAgent.destroy()
271
+ await waitUntil(() => fixture.relay.status().sessions === 0)
272
+ } finally {
273
+ successAgent.destroy()
274
+ mismatchAgent.destroy()
275
+ await fixture.relay.close()
276
+ await closeServer(targetServer)
277
+ await rm(root, {recursive: true, force: true})
278
+ }
337
279
  })
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"
280
+
281
+ it("SocketductHttpsAgent derives an omitted target TLS identity from the fixed IP without sending SNI", async () => {
282
+ const root = await mkdtemp(join(tmpdir(), "socketduct-https-derived-ip-"))
283
+ const certs = generateTestCertificates(join(root, "certs"), {serverSubjectAltName: "IP:127.0.0.1"})
284
+ const ca = await readFile(certs.caCertFile)
285
+ /** @type {Array<string | false | null>} */
286
+ const servernames = []
287
+ const targetServer = https.createServer({
288
+ cert: await readFile(certs.serverCertFile),
289
+ key: await readFile(certs.serverKeyFile)
290
+ }, (req, res) => res.end("derived-ip-ok"))
291
+ targetServer.on("secureConnection", (socket) => servernames.push(socket.servername))
292
+ const port = await listen(targetServer)
293
+ const fixture = await authenticatedRelay(port)
294
+ const spoolDirectory = join(root, "spool")
295
+ const agent = new SocketductHttpsAgent({
296
+ relay: {host: "127.0.0.1", port: fixture.relay.port, token: fixture.token.value},
297
+ target: {host: "127.0.0.1", port},
298
+ targetTls: {ca, rejectUnauthorized: true},
299
+ spoolDirectory,
300
+ keepAlive: false
341
301
  })
342
- agent.destroy()
343
- await waitUntil(() => {
344
- const status = fixture.relay.status()
345
- return status.sessions === 0 && status.activeTunnels === 0 && status.activeTargets === 0
302
+ try {
303
+ expect((await request({agent, host: "127.0.0.1", port: 443, path: "/derived-ip"})).body).toBe("derived-ip-ok")
304
+ agent.destroy()
305
+ await waitUntil(() => {
306
+ const status = fixture.relay.status()
307
+ return status.sessions === 0 && status.activeTunnels === 0 && status.activeTargets === 0
308
+ })
309
+ expect(servernames).toEqual([false])
310
+ expect(fixture.relay.status()).toEqual({
311
+ activeTunnels: 0, activeTargets: 0, sessions: 0, attachedSessions: 0,
312
+ detachedSessions: 0, bufferedTargetBytes: 0, targetConnectionAttempts: 0, targetRetryTimers: 0
313
+ })
314
+ await waitUntilAsync(async () => (await readDirectoryOrEmpty(spoolDirectory)).length === 0)
315
+ expect(await readDirectoryOrEmpty(spoolDirectory)).toEqual([])
316
+ } finally {
317
+ agent.destroy()
318
+ await fixture.relay.close()
319
+ await closeServer(targetServer)
320
+ await rm(root, {recursive: true, force: true})
321
+ }
322
+ })
323
+
324
+ it("SocketductHttpsAgent rejects a trusted different identity when target TLS servername is omitted", async () => {
325
+ const root = await mkdtemp(join(tmpdir(), "socketduct-https-derived-ip-mismatch-"))
326
+ const certs = generateTestCertificates(join(root, "certs"), {serverSubjectAltName: "IP:127.0.0.1"})
327
+ const wrongCerts = generateTestServerCertificate(join(root, "wrong-certs"), {
328
+ caCertFile: certs.caCertFile,
329
+ caKeyFile: certs.caKeyFile,
330
+ commonName: "localhost",
331
+ subjectAltName: "DNS:localhost"
346
332
  })
347
- assert.deepEqual(fixture.relay.status(), {
348
- activeTunnels: 0, activeTargets: 0, sessions: 0, attachedSessions: 0,
349
- detachedSessions: 0, bufferedTargetBytes: 0, targetConnectionAttempts: 0, targetRetryTimers: 0
333
+ const ca = await readFile(certs.caCertFile)
334
+ const targetServer = https.createServer({
335
+ cert: await readFile(wrongCerts.serverCertFile),
336
+ key: await readFile(wrongCerts.serverKeyFile)
337
+ }, (req, res) => res.end("wrongly-accepted"))
338
+ const port = await listen(targetServer)
339
+ const fixture = await authenticatedRelay(port)
340
+ const spoolDirectory = join(root, "spool")
341
+ const agent = new SocketductHttpsAgent({
342
+ relay: {host: "127.0.0.1", port: fixture.relay.port, token: fixture.token.value},
343
+ target: {host: "127.0.0.1", port},
344
+ targetTls: {ca, rejectUnauthorized: true},
345
+ spoolDirectory,
346
+ keepAlive: false
350
347
  })
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")
348
+ try {
349
+ await expect(request({agent, host: "127.0.0.1", port: 443, path: "/wrong-identity"})).rejects.toMatchObject({
350
+ code: "ERR_TLS_CERT_ALTNAME_INVALID"
351
+ })
352
+ agent.destroy()
353
+ await waitUntil(() => {
354
+ const status = fixture.relay.status()
355
+ return status.sessions === 0 && status.activeTunnels === 0 && status.activeTargets === 0
356
+ })
357
+ expect(fixture.relay.status()).toEqual({
358
+ activeTunnels: 0, activeTargets: 0, sessions: 0, attachedSessions: 0,
359
+ detachedSessions: 0, bufferedTargetBytes: 0, targetConnectionAttempts: 0, targetRetryTimers: 0
360
+ })
361
+ await waitUntilAsync(async () => (await readDirectoryOrEmpty(spoolDirectory)).length === 0)
362
+ expect(await readDirectoryOrEmpty(spoolDirectory)).toEqual([])
363
+ } finally {
364
+ agent.destroy()
365
+ await fixture.relay.close()
366
+ await closeServer(targetServer)
367
+ await rm(root, {recursive: true, force: true})
368
+ }
383
369
  })
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
370
 
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"))
371
+ it("SocketductHttpsAgent rejects an explicitly empty target TLS servername before allocation", () => {
372
+ expect(() => new SocketductHttpsAgent({
373
+ relay: {host: "127.0.0.1", port: 1, token: "unused-test-token"},
374
+ target: {host: "127.0.0.1", port: 1},
375
+ targetTls: {servername: ""},
376
+ spoolDirectory: "/unused"
377
+ })).toThrow(/Target TLS servername must be a non-empty string/)
420
378
  })
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
379
 
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())}`)
380
+ it("SocketductHttpsAgent supports target mTLS", async () => {
381
+ const root = await mkdtemp(join(tmpdir(), "socketduct-https-mtls-"))
382
+ const certs = generateTestCertificates(join(root, "certs"))
383
+ const ca = await readFile(certs.caCertFile)
384
+ const targetServer = https.createServer({
385
+ ca,
386
+ cert: await readFile(certs.serverCertFile),
387
+ key: await readFile(certs.serverKeyFile),
388
+ requestCert: true,
389
+ rejectUnauthorized: true
390
+ }, (req, res) => {
391
+ res.writeHead(200)
392
+ res.end(/** @type {import('node:tls').TLSSocket} */ (req.socket).authorized ? "mtls-ok" : "unauthorized")
476
393
  })
477
- } finally {
478
- agent.destroy()
479
- await fixture.relay.close()
480
- await closeServer(targetServer)
481
- await rm(root, {recursive: true, force: true})
482
- }
483
- })
394
+ const port = await listen(targetServer)
395
+ const fixture = await authenticatedRelay(port)
396
+ const agent = createAgent({
397
+ ca,
398
+ cert: await readFile(certs.clientCertFile),
399
+ key: await readFile(certs.clientKeyFile)
400
+ }, {port, relay: fixture.relay, spoolDirectory: join(root, "spool"), token: fixture.token})
401
+ try {
402
+ expect((await request({agent, host: "localhost", port: 443, path: "/_ping"})).body).toBe("mtls-ok")
403
+ } finally {
404
+ agent.destroy()
405
+ await fixture.relay.close()
406
+ await closeServer(targetServer)
407
+ await rm(root, {recursive: true, force: true})
408
+ }
409
+ })
484
410
 
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
- }
411
+ it("SocketductHttpsAgent keeps one target TLS connection through relay interruption", async () => {
412
+ const root = await mkdtemp(join(tmpdir(), "socketduct-https-resume-"))
413
+ const certs = generateTestCertificates(join(root, "certs"))
414
+ const ca = await readFile(certs.caCertFile)
415
+ let targetConnections = 0
416
+ /** @type {(() => void) | undefined} */
417
+ let releaseResponse
418
+ const continueResponse = new Promise((resolve) => { releaseResponse = () => resolve(undefined) })
419
+ /** @type {(() => void) | undefined} */
420
+ let firstChunkResolve
421
+ const firstChunk = new Promise((resolve) => { firstChunkResolve = () => resolve(undefined) })
422
+ const targetServer = https.createServer({
423
+ cert: await readFile(certs.serverCertFile),
424
+ key: await readFile(certs.serverKeyFile)
425
+ }, (req, res) => {
426
+ res.writeHead(200)
427
+ res.write("before-")
428
+ firstChunkResolve?.()
429
+ void continueResponse.then(() => res.end("after"))
503
430
  })
431
+ targetServer.on("connection", () => { targetConnections += 1 })
432
+ const port = await listen(targetServer)
433
+ const fixture = await authenticatedRelay(port)
434
+ const spoolDirectory = join(root, "spool")
435
+ const agent = createAgent({ca}, {port, relay: fixture.relay, spoolDirectory, token: fixture.token})
436
+ /** @type {Promise<void> | undefined} */
437
+ let socketductClosed
438
+ try {
439
+ const responsePromise = request(
440
+ {agent, host: "localhost", port: 443, path: "/stream"},
441
+ (socket) => { socketductClosed = once(socket, "close").then(() => undefined) }
442
+ )
443
+ await firstChunk
444
+ await waitUntil(() => fixture.relay.status().attachedSessions === 1)
445
+ await fixture.relay.closeActiveTunnel()
446
+ await waitUntil(() => fixture.relay.status().attachedSessions === 1)
447
+ releaseResponse?.()
448
+ expect((await responsePromise).body).toBe("before-after")
449
+ expect(targetConnections).toBe(1)
450
+ agent.destroy()
451
+ await socketductClosed
452
+ expect(await readDirectoryOrEmpty(spoolDirectory)).toEqual([])
453
+ } finally {
454
+ releaseResponse?.()
455
+ agent.destroy()
456
+ await socketductClosed
457
+ await fixture.relay.close()
458
+ await closeServer(targetServer)
459
+ await rm(root, {recursive: true, force: true})
460
+ }
504
461
  })
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)})
462
+
463
+ it("SocketductHttpsAgent preserves TLS verification errors and settles its callback once", async () => {
464
+ const root = await mkdtemp(join(tmpdir(), "socketduct-https-failure-"))
465
+ const certs = generateTestCertificates(join(root, "certs"))
466
+ const targetServer = https.createServer({cert: await readFile(certs.serverCertFile), key: await readFile(certs.serverKeyFile)})
467
+ const port = await listen(targetServer)
468
+ const fixture = await authenticatedRelay(port)
469
+ const agent = createAgent({ca: await readFile(certs.caCertFile), servername: "wrong.example.test"}, {
470
+ port, relay: fixture.relay, spoolDirectory: join(root, "spool"), token: fixture.token
518
471
  })
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)
472
+ let callbackCount = 0
473
+ try {
474
+ const result = await new Promise((resolve) => {
475
+ agent.createConnection({host: "wrong.example.test", port: 443}, (error, socket) => {
476
+ callbackCount += 1
477
+ resolve({error, socket})
478
+ })
479
+ })
480
+ expect(callbackCount).toBe(1)
481
+ expect(result.error instanceof Error).toBeTruthy()
482
+ expect(result.error.code).toBe("ERR_TLS_CERT_ALTNAME_INVALID")
483
+ expect(result.socket).toBe(undefined)
484
+ await waitUntil(() => fixture.relay.status().sessions === 0).catch(() => {
485
+ throw new Error(`relay resources after TLS verification failure: ${JSON.stringify(fixture.relay.status())}`)
486
+ })
487
+ } finally {
488
+ agent.destroy()
489
+ await fixture.relay.close()
490
+ await closeServer(targetServer)
491
+ await rm(root, {recursive: true, force: true})
492
+ }
527
493
  })
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)
494
+
495
+ it("SocketductHttpsAgent aborts and times out stalled target TLS handshakes without resources", {timeoutMs: 5_000}, async () => {
496
+ const root = await mkdtemp(join(tmpdir(), "socketduct-https-stalled-"))
497
+ const certs = generateTestCertificates(join(root, "certs"))
498
+ /** @type {Set<import('node:net').Socket>} */
499
+ const accepted = new Set()
500
+ let acceptedCount = 0
501
+ /** @type {Set<{count: number, resolve: () => void}>} */
502
+ const acceptanceWaiters = new Set()
503
+ const stalledTarget = createTcpServer((socket) => {
504
+ socket.once("close", () => accepted.delete(socket))
505
+ socket.once("data", () => {
506
+ accepted.add(socket)
507
+ acceptedCount += 1
508
+ for (const waiter of acceptanceWaiters) {
509
+ if (acceptedCount < waiter.count) continue
510
+ acceptanceWaiters.delete(waiter)
511
+ waiter.resolve()
512
+ }
538
513
  })
539
- signalRequest.end()
540
514
  })
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()
515
+ const port = await listen(stalledTarget)
516
+ const fixture = await authenticatedRelay(port)
517
+ const agent = createAgent({ca: await readFile(certs.caCertFile)}, {
518
+ port, relay: fixture.relay, spoolDirectory: join(root, "spool"), token: fixture.token
560
519
  })
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}`)
520
+ /**
521
+ * @param {number} count - Expected accepted sockets.
522
+ * @returns {Promise<void>}
523
+ */
524
+ const waitForAccepted = async (count) => {
525
+ if (acceptedCount >= count) return
526
+ await new Promise((resolve) => {
527
+ acceptanceWaiters.add({count, resolve: () => resolve(undefined)})
528
+ })
529
+ }
530
+ const repeatingTimeouts = new Set()
531
+ const timeoutHook = createHook({
532
+ init: (asyncId, type, _triggerAsyncId, resource) => {
533
+ const repeat = /** @type {{_repeat?: unknown}} */ (resource)._repeat
534
+ if (type === "Timeout" && typeof repeat === "number" && repeat > 0) repeatingTimeouts.add(asyncId)
535
+ },
536
+ destroy: (asyncId) => repeatingTimeouts.delete(asyncId)
570
537
  })
538
+ try {
539
+ const controller = new AbortController()
540
+ /** @type {import('node:http').ClientRequest | undefined} */
541
+ let signalRequest
542
+ let signalErrorCount = 0
543
+ const signalAborted = new Promise((resolve, reject) => {
544
+ signalRequest = https.request({agent, host: "localhost", port: 443, path: "/signal-abort", signal: controller.signal}, resolve)
545
+ signalRequest.on("error", (error) => {
546
+ signalErrorCount += 1
547
+ reject(error)
548
+ })
549
+ signalRequest.end()
550
+ })
551
+ void signalAborted.catch(() => {})
552
+ await waitForAccepted(1)
553
+ controller.abort()
554
+ await waitUntil(() => fixture.relay.status().sessions === 0 && accepted.size === 0, 100).catch(() => {
555
+ throw new Error(`relay resources after AbortSignal: ${JSON.stringify(fixture.relay.status())}; accepted=${accepted.size}`)
556
+ })
557
+ let signalError
558
+ try {
559
+ await signalAborted
560
+ } catch (caught) {
561
+ signalError = caught
562
+ }
563
+ expect(signalError).toBeDefined()
564
+ expect(signalError instanceof Error && signalError.name === "AbortError").toBe(true)
565
+ await new Promise((resolve) => setImmediate(resolve))
566
+ expect(signalErrorCount).toBe(1)
567
+ expect(signalRequest?.listeners("abort").some((listener) => listener.name === "abort")).toBe(false)
568
+ expect(signalRequest?.listeners("close").some((listener) => listener.name === "onClose")).toBe(false)
571
569
 
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
- })
570
+ timeoutHook.enable()
571
+ /** @type {import('node:http').ClientRequest | undefined} */
572
+ let abortRequest
573
+ const aborted = new Promise((resolve, reject) => {
574
+ abortRequest = https.request({agent, host: "localhost", port: 443, path: "/abort"}, resolve)
575
+ abortRequest.once("error", reject)
576
+ abortRequest.end()
577
+ })
578
+ await waitForAccepted(2)
579
+ await new Promise((resolve) => setImmediate(resolve))
580
+ const repeatingTimeoutCount = repeatingTimeouts.size
581
+ timeoutHook.disable()
582
+ abortRequest?.abort()
583
+ let abortError
584
+ try {
585
+ await aborted
586
+ } catch (caught) {
587
+ abortError = caught
588
+ }
589
+ expect(abortError).toBeDefined()
590
+ expect(abortError instanceof Error && abortError.name === "AbortError").toBe(true)
591
+ expect(repeatingTimeoutCount).toBe(0)
592
+ await waitUntil(() => fixture.relay.status().sessions === 0 && accepted.size === 0).catch(() => {
593
+ throw new Error(`relay resources after request abort: ${JSON.stringify(fixture.relay.status())}; accepted=${accepted.size}`)
594
+ })
588
595
 
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
596
+ const timedOut = (async () => {
597
+ let timedOutError
598
+ try {
599
+ await request({agent, host: "localhost", port: 443, path: "/timeout", timeout: 25})
600
+ } catch (caught) {
601
+ timedOutError = caught
602
+ }
603
+ expect(timedOutError).toBeDefined()
604
+ expect(timedOutError instanceof Error && /** @type {Error & {code?: string}} */ (timedOutError).code === "ETIMEDOUT").toBe(true)
605
+ })()
606
+ await waitForAccepted(3)
607
+ await timedOut
608
+ await waitUntil(() => fixture.relay.status().sessions === 0 && accepted.size === 0)
609
+ } finally {
610
+ timeoutHook.disable()
611
+ agent.destroy()
612
+ for (const socket of accepted) socket.destroy()
613
+ await fixture.relay.close()
614
+ await closeServer(stalledTarget)
615
+ await rm(root, {recursive: true, force: true})
616
+ }
604
617
  })
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})
618
+
619
+ it("SocketductHttpsAgent destroy cancels a stalled handshake exactly once", {timeoutMs: 5_000}, async () => {
620
+ const root = await mkdtemp(join(tmpdir(), "socketduct-https-destroy-"))
621
+ const certs = generateTestCertificates(join(root, "certs"))
622
+ /** @type {((socket: import('node:net').Socket) => void) | undefined} */
623
+ let acceptedResolve
624
+ const accepted = new Promise((resolve) => { acceptedResolve = resolve })
625
+ let targetClosed = false
626
+ const stalledTarget = createTcpServer((socket) => {
627
+ socket.once("close", () => { targetClosed = true })
628
+ socket.once("data", () => acceptedResolve?.(socket))
610
629
  })
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}`)
630
+ const port = await listen(stalledTarget)
631
+ const fixture = await authenticatedRelay(port)
632
+ const agent = createAgent({ca: await readFile(certs.caCertFile)}, {
633
+ port, relay: fixture.relay, spoolDirectory: join(root, "spool"), token: fixture.token
621
634
  })
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
- })
635
+ let callbackCount = 0
636
+ const result = new Promise((resolve) => {
637
+ agent.createConnection({host: "localhost", port: 443}, (error, socket) => {
638
+ callbackCount += 1
639
+ resolve({error, socket})
640
+ })
641
+ })
642
+ const targetSocket = /** @type {import('node:net').Socket} */ (await accepted)
643
+ try {
644
+ agent.destroy()
645
+ const settled = await result
646
+ expect(callbackCount).toBe(1)
647
+ expect(settled.error instanceof Error).toBeTruthy()
648
+ expect(settled.socket).toBe(undefined)
649
+ await waitUntil(() => fixture.relay.status().sessions === 0 && targetClosed).catch(() => {
650
+ throw new Error(`relay resources after agent destroy: ${JSON.stringify(fixture.relay.status())}; targetClosed=${targetClosed}`)
651
+ })
652
+ } finally {
653
+ agent.destroy()
654
+ targetSocket.destroy()
655
+ await fixture.relay.close()
656
+ await closeServer(stalledTarget)
657
+ await rm(root, {recursive: true, force: true})
658
+ }
659
+ })
630
660
 
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)
661
+ it("SocketductHttpsAgent repeated success and verification failure returns to baseline without spool leaks", async () => {
662
+ const root = await mkdtemp(join(tmpdir(), "socketduct-https-bounded-"))
663
+ const certs = generateTestCertificates(join(root, "certs"))
664
+ const ca = await readFile(certs.caCertFile)
665
+ const targetServer = https.createServer({cert: await readFile(certs.serverCertFile), key: await readFile(certs.serverKeyFile)}, (req, res) => res.end("ok"))
666
+ const port = await listen(targetServer)
667
+ const fixture = await authenticatedRelay(port)
668
+ try {
669
+ for (let index = 0; index < 3; index += 1) {
670
+ const successAgent = createAgent({ca}, {port, relay: fixture.relay, spoolDirectory: join(root, "spool"), token: fixture.token})
671
+ expect((await request({agent: successAgent, host: "localhost", port: 443, path: `/ok-${index}`})).body).toBe("ok")
672
+ successAgent.destroy()
673
+ await waitUntil(() => fixture.relay.status().sessions === 0)
644
674
 
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"
675
+ const failureAgent = createAgent({ca, servername: "wrong.example.test"}, {
676
+ port, relay: fixture.relay, spoolDirectory: join(root, "spool"), token: fixture.token
677
+ })
678
+ await expect(request({agent: failureAgent, host: "localhost", port: 443, path: `/fail-${index}`})).rejects.toMatchObject({
679
+ code: "ERR_TLS_CERT_ALTNAME_INVALID"
680
+ })
681
+ failureAgent.destroy()
682
+ await waitUntil(() => fixture.relay.status().sessions === 0)
683
+ expect(fixture.relay.status().activeTargets).toBe(0)
684
+ expect(fixture.relay.status().targetConnectionAttempts).toBe(0)
685
+ expect(fixture.relay.status().targetRetryTimers).toBe(0)
686
+ }
687
+ const readSpoolEntries = async () => await readdir(join(root, "spool")).catch((error) => {
688
+ if (error instanceof Error && /** @type {Error & {code?: string}} */ (error).code === "ENOENT") return []
689
+ throw error
650
690
  })
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)
691
+ await waitUntilAsync(async () => (await readSpoolEntries()).length === 0)
692
+ const spoolEntries = await readSpoolEntries()
693
+ expect(spoolEntries).toEqual([])
694
+ } finally {
695
+ await fixture.relay.close()
696
+ await closeServer(targetServer)
697
+ await rm(root, {recursive: true, force: true})
656
698
  }
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
- }
699
+ })
669
700
  })