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