socketduct 0.1.25 → 0.1.27
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.
- package/AGENTS.md +2 -2
- package/changelog.d/20261010130509-bound-log-frame-compression-memory.md +1 -0
- package/changelog.d/20261025070000-reclaim-unreadable-retention-lock.md +14 -0
- package/docs/runbooks/jsonl-integrity-rollout.md +8 -6
- package/package.json +2 -2
- package/scripts/test.js +9 -14
- package/src/logging/compressed-log-writer.js +1 -1
- package/src/logging/log-retention.js +36 -1
- package/src/logging/zstd-frame-codec.js +15 -1
- package/test/e2e/gateway-process.test.js +93 -92
- package/test/e2e/logger-shutdown-process.test.js +40 -37
- package/test/e2e/reverse-client-recovery-process.test.js +107 -103
- package/test/e2e/reverse-gateway-process.test.js +328 -318
- package/test/integration/initial-target-recovery.test.js +333 -334
- package/test/integration/logging-cli.test.js +121 -119
- package/test/integration/logging-compressed-recovery.test.js +96 -95
- package/test/integration/multiplexed-compatibility.test.js +178 -177
- package/test/integration/multiplexed-corrections.test.js +360 -359
- package/test/integration/multiplexed-failures.test.js +253 -251
- package/test/integration/multiplexed-transport-set.test.js +334 -322
- package/test/integration/multiplexed-transport.test.js +135 -134
- package/test/integration/reverse-gateway-stream-id-reset.test.js +90 -89
- package/test/integration/reverse-gateway.test.js +2309 -2218
- package/test/integration/reverse-resume-continuity.test.js +219 -218
- package/test/integration/reverse-route-class.test.js +358 -341
- package/test/integration/socketduct-http-agent.test.js +314 -312
- package/test/integration/socketduct-https-agent.test.js +516 -485
- package/test/integration/stream-compression.test.js +862 -824
- package/test/integration/tcp-forwarding.test.js +2550 -2447
- package/test/integration/tls-forwarding.test.js +593 -592
- package/test/unit/auth/auth.test.js +43 -44
- package/test/unit/auth/static-token-store.test.js +9 -8
- package/test/unit/client/connect.test.js +41 -40
- package/test/unit/client/deferred-virtual-socket.test.js +95 -94
- package/test/unit/client/multiplexed-spool-budget.test.js +155 -154
- package/test/unit/client/multiplexed-transport-set.test.js +830 -748
- package/test/unit/client/socketduct-socket.test.js +768 -771
- package/test/unit/client/virtual-socket.test.js +198 -168
- package/test/unit/config/docker-gateway-files.test.js +224 -224
- package/test/unit/config/environment.test.js +472 -492
- package/test/unit/index.test.js +53 -52
- package/test/unit/logging/compressed-log-worker.test.js +23 -22
- package/test/unit/logging/compressed-log-writer.test.js +155 -154
- package/test/unit/logging/log-retention.test.js +71 -48
- package/test/unit/logging/log-segment-index.test.js +84 -81
- package/test/unit/logging/zstd-frame-codec.test.js +28 -19
- package/test/unit/protocol/compression.test.js +254 -298
- package/test/unit/protocol/frame.test.js +280 -276
- package/test/unit/protocol/v3-compression.test.js +21 -23
- package/test/unit/protocol/v3-frame.test.js +42 -41
- package/test/unit/release-configuration.test.js +15 -12
- package/test/unit/reverse/disconnect-attribution.test.js +65 -64
- package/test/unit/reverse-terminal-outcomes.test.js +53 -50
- package/test/unit/server/control-handler.test.js +232 -231
- package/test/unit/server/primary.test.js +51 -49
- package/test/unit/server/relay-runtime.test.js +280 -294
- package/test/unit/sessions/session.test.js +48 -62
- package/test/unit/spool/spool-file.test.js +401 -385
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
258
|
-
|
|
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
|
-
|
|
261
|
-
await
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
300
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
})
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
const
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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
|
-
|
|
542
|
-
await
|
|
543
|
-
|
|
544
|
-
|
|
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
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
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
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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
|
-
|
|
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
|
})
|