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,435 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import {mkdtemp, readdir, rm} from "node:fs/promises"
|
|
3
|
+
import http from "node:http"
|
|
4
|
+
import net from "node:net"
|
|
5
|
+
import {tmpdir} from "node:os"
|
|
6
|
+
import {join} from "node:path"
|
|
7
|
+
import {setTimeout as delay} from "node:timers/promises"
|
|
8
|
+
import test from "node:test"
|
|
9
|
+
|
|
10
|
+
import {InMemoryUserStore, login, TokenStore} from "../../src/auth/user-store.js"
|
|
11
|
+
import {SocketductHttpAgent} from "../../src/client/http-agent.js"
|
|
12
|
+
import {SocketductRelayError} from "../../src/client/socketduct-socket.js"
|
|
13
|
+
import {startTcpRelay} from "../../src/proxy/tcp-relay.js"
|
|
14
|
+
import {SessionStore} from "../../src/sessions/session.js"
|
|
15
|
+
import {generateTestCertificates} from "../helpers/tls-certificates.js"
|
|
16
|
+
|
|
17
|
+
const EMPTY_RELAY_STATUS = {
|
|
18
|
+
activeTunnels: 0,
|
|
19
|
+
activeTargets: 0,
|
|
20
|
+
sessions: 0,
|
|
21
|
+
attachedSessions: 0,
|
|
22
|
+
detachedSessions: 0,
|
|
23
|
+
bufferedTargetBytes: 0,
|
|
24
|
+
targetConnectionAttempts: 0,
|
|
25
|
+
targetRetryTimers: 0
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {import('node:net').Server} server - Server to bind.
|
|
30
|
+
* @returns {Promise<number>} - Bound port.
|
|
31
|
+
*/
|
|
32
|
+
async function listen(server) {
|
|
33
|
+
await new Promise((resolve, reject) => {
|
|
34
|
+
server.once("error", reject)
|
|
35
|
+
server.listen(0, "127.0.0.1", () => {
|
|
36
|
+
server.off("error", reject)
|
|
37
|
+
resolve(undefined)
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
const address = server.address()
|
|
41
|
+
if (address === null || typeof address === "string") {
|
|
42
|
+
throw new Error("Server did not bind to a TCP port")
|
|
43
|
+
}
|
|
44
|
+
return /** @type {import('node:net').AddressInfo} */ (address).port
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {import('node:net').Server} server - Server to close.
|
|
49
|
+
* @returns {Promise<void>} - Resolves when closed.
|
|
50
|
+
*/
|
|
51
|
+
async function closeServer(server) {
|
|
52
|
+
await new Promise((resolve, reject) => {
|
|
53
|
+
server.close((error) => {
|
|
54
|
+
if (error) {
|
|
55
|
+
reject(error)
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
resolve(undefined)
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {() => boolean | Promise<boolean>} predicate - Completion predicate.
|
|
65
|
+
* @param {number} [timeoutMs] - Maximum wait.
|
|
66
|
+
* @returns {Promise<void>} - Resolves when the predicate succeeds.
|
|
67
|
+
*/
|
|
68
|
+
async function waitUntil(predicate, timeoutMs = 2_000) {
|
|
69
|
+
const deadline = Date.now() + timeoutMs
|
|
70
|
+
while (!await predicate()) {
|
|
71
|
+
if (Date.now() >= deadline) throw new Error("timed out waiting for condition")
|
|
72
|
+
await new Promise((resolve) => setTimeout(resolve, 2))
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param {import('node:http').RequestOptions} options - Request options.
|
|
78
|
+
* @returns {Promise<{statusCode: number | undefined, body: string}>} - Response details.
|
|
79
|
+
*/
|
|
80
|
+
async function request(options) {
|
|
81
|
+
return await new Promise((resolve, reject) => {
|
|
82
|
+
const req = http.request(options, (res) => {
|
|
83
|
+
/** @type {Buffer[]} */
|
|
84
|
+
const chunks = []
|
|
85
|
+
res.on("data", (chunk) => chunks.push(chunk))
|
|
86
|
+
res.on("end", () => {
|
|
87
|
+
resolve({statusCode: res.statusCode, body: Buffer.concat(chunks).toString("utf8")})
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
req.on("error", reject)
|
|
91
|
+
req.end()
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
test("SocketductHttpAgent sends HTTP requests over a Socketduct socket", async () => {
|
|
96
|
+
const spoolDirectory = await mkdtemp(join(tmpdir(), "socketduct-http-agent-test-"))
|
|
97
|
+
const targetServer = http.createServer((req, res) => {
|
|
98
|
+
if (req.url === "/_ping") {
|
|
99
|
+
res.writeHead(200, {"content-type": "text/plain"})
|
|
100
|
+
res.end("OK")
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
res.writeHead(404)
|
|
104
|
+
res.end("missing")
|
|
105
|
+
})
|
|
106
|
+
const targetPort = await listen(targetServer)
|
|
107
|
+
|
|
108
|
+
const users = new InMemoryUserStore()
|
|
109
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
110
|
+
await users.createUser({username: "kasper", password: "correct horse battery staple"})
|
|
111
|
+
const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
|
|
112
|
+
const relay = await startTcpRelay({
|
|
113
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
114
|
+
tokens,
|
|
115
|
+
sessions: new SessionStore({now: () => 1000})
|
|
116
|
+
})
|
|
117
|
+
const agent = new SocketductHttpAgent({
|
|
118
|
+
relay: {host: "127.0.0.1", port: relay.port, token: token.value},
|
|
119
|
+
target: {host: "127.0.0.1", port: targetPort},
|
|
120
|
+
spoolDirectory,
|
|
121
|
+
sessionNamePrefix: "http-agent"
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
const response = await request({agent, host: "docker", port: 80, path: "/_ping", method: "GET"})
|
|
125
|
+
|
|
126
|
+
assert.equal(response.statusCode, 200)
|
|
127
|
+
assert.equal(response.body, "OK")
|
|
128
|
+
agent.destroy()
|
|
129
|
+
await relay.close()
|
|
130
|
+
await closeServer(targetServer)
|
|
131
|
+
await rm(spoolDirectory, {recursive: true, force: true})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test("SocketductHttpAgent reuses keep-alive sockets", async () => {
|
|
135
|
+
const spoolDirectory = await mkdtemp(join(tmpdir(), "socketduct-http-agent-keepalive-test-"))
|
|
136
|
+
let targetConnections = 0
|
|
137
|
+
const targetServer = http.createServer((req, res) => {
|
|
138
|
+
res.writeHead(200, {"content-type": "text/plain"})
|
|
139
|
+
res.end(`pong:${req.url}`)
|
|
140
|
+
})
|
|
141
|
+
targetServer.on("connection", () => {
|
|
142
|
+
targetConnections += 1
|
|
143
|
+
})
|
|
144
|
+
const targetPort = await listen(targetServer)
|
|
145
|
+
|
|
146
|
+
const users = new InMemoryUserStore()
|
|
147
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
148
|
+
await users.createUser({username: "kasper", password: "correct horse battery staple"})
|
|
149
|
+
const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
|
|
150
|
+
const relay = await startTcpRelay({
|
|
151
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
152
|
+
tokens,
|
|
153
|
+
sessions: new SessionStore({now: () => 1000})
|
|
154
|
+
})
|
|
155
|
+
const agent = new SocketductHttpAgent({
|
|
156
|
+
relay: {host: "127.0.0.1", port: relay.port, token: token.value},
|
|
157
|
+
target: {host: "127.0.0.1", port: targetPort},
|
|
158
|
+
spoolDirectory,
|
|
159
|
+
sessionNamePrefix: "http-agent-keepalive",
|
|
160
|
+
keepAlive: true,
|
|
161
|
+
maxSockets: 1
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
const first = await request({agent, host: "docker", port: 80, path: "/one", method: "GET"})
|
|
165
|
+
const second = await request({agent, host: "docker", port: 80, path: "/two", method: "GET"})
|
|
166
|
+
|
|
167
|
+
assert.equal(first.body, "pong:/one")
|
|
168
|
+
assert.equal(second.body, "pong:/two")
|
|
169
|
+
assert.equal(targetConnections, 1)
|
|
170
|
+
agent.destroy()
|
|
171
|
+
await waitUntil(() => {
|
|
172
|
+
const status = relay.status()
|
|
173
|
+
return status.sessions === 0 && status.activeTunnels === 0 && status.activeTargets === 0
|
|
174
|
+
})
|
|
175
|
+
assert.deepEqual(relay.status(), EMPTY_RELAY_STATUS)
|
|
176
|
+
await relay.close()
|
|
177
|
+
await closeServer(targetServer)
|
|
178
|
+
await rm(spoolDirectory, {recursive: true, force: true})
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test("SocketductHttpAgent closes a reused session after target EOF through a TLS relay", async () => {
|
|
182
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-http-agent-tls-relay-close-"))
|
|
183
|
+
const certs = generateTestCertificates(join(root, "certs"))
|
|
184
|
+
const spoolDirectory = join(root, "spool")
|
|
185
|
+
/** @type {Set<import('node:net').Socket>} */
|
|
186
|
+
const targetSockets = new Set()
|
|
187
|
+
const targetServer = http.createServer((req, res) => {
|
|
188
|
+
if (req.url === "/close") res.setHeader("connection", "close")
|
|
189
|
+
res.end(req.url === "/close" ? "tls-relay-close" : "tls-relay-keepalive")
|
|
190
|
+
})
|
|
191
|
+
targetServer.on("connection", (socket) => {
|
|
192
|
+
targetSockets.add(socket)
|
|
193
|
+
socket.once("close", () => targetSockets.delete(socket))
|
|
194
|
+
})
|
|
195
|
+
const targetPort = await listen(targetServer)
|
|
196
|
+
|
|
197
|
+
const users = new InMemoryUserStore()
|
|
198
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
199
|
+
await users.createUser({username: "kasper", password: "correct horse battery staple"})
|
|
200
|
+
const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
|
|
201
|
+
const relay = await startTcpRelay({
|
|
202
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
203
|
+
tokens,
|
|
204
|
+
sessions: new SessionStore({now: () => 1000}),
|
|
205
|
+
tls: {certFile: certs.serverCertFile, keyFile: certs.serverKeyFile}
|
|
206
|
+
})
|
|
207
|
+
/** @type {(() => void) | undefined} */
|
|
208
|
+
let resolveSocketClosed
|
|
209
|
+
/** @type {import('../../src/client/socketduct-socket.js').SocketductSocket | undefined} */
|
|
210
|
+
let observedSocket
|
|
211
|
+
const socketClosed = new Promise((resolve) => { resolveSocketClosed = () => resolve(undefined) })
|
|
212
|
+
class ObservedSocketductHttpAgent extends SocketductHttpAgent {
|
|
213
|
+
/**
|
|
214
|
+
* @override
|
|
215
|
+
* @param {import('node:http').ClientRequestArgs} options - HTTP connection options.
|
|
216
|
+
* @param {(error: Error | null, socket: import('../../src/client/socketduct-socket.js').SocketductSocket) => void} [callback] - Agent callback.
|
|
217
|
+
* @returns {import('../../src/client/socketduct-socket.js').SocketductSocket} Socketduct socket.
|
|
218
|
+
*/
|
|
219
|
+
createConnection(options, callback) {
|
|
220
|
+
const socket = super.createConnection(options, callback)
|
|
221
|
+
observedSocket = socket
|
|
222
|
+
socket.once("close", () => resolveSocketClosed?.())
|
|
223
|
+
return socket
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const agent = new ObservedSocketductHttpAgent({
|
|
227
|
+
relay: {host: "127.0.0.1", port: relay.port, token: token.value},
|
|
228
|
+
target: {host: "127.0.0.1", port: targetPort},
|
|
229
|
+
spoolDirectory,
|
|
230
|
+
sessionNamePrefix: "http-agent-tls-relay-close",
|
|
231
|
+
keepAlive: true,
|
|
232
|
+
maxSockets: 1,
|
|
233
|
+
tls: {caFile: certs.caCertFile, servername: "localhost", rejectUnauthorized: true}
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
assert.deepEqual(
|
|
238
|
+
await request({agent, host: "docker", port: 80, path: "/keepalive", method: "GET"}),
|
|
239
|
+
{statusCode: 200, body: "tls-relay-keepalive"}
|
|
240
|
+
)
|
|
241
|
+
assert.equal(targetSockets.size, 1)
|
|
242
|
+
assert.equal(relay.status().attachedSessions, 1)
|
|
243
|
+
assert.deepEqual(
|
|
244
|
+
await request({agent, host: "docker", port: 80, path: "/close", method: "GET"}),
|
|
245
|
+
{statusCode: 200, body: "tls-relay-close"}
|
|
246
|
+
)
|
|
247
|
+
await socketClosed
|
|
248
|
+
|
|
249
|
+
agent.destroy()
|
|
250
|
+
agent.destroy()
|
|
251
|
+
await waitUntil(async () => relay.status().sessions === 0 && targetSockets.size === 0 &&
|
|
252
|
+
(await readdir(spoolDirectory).catch((error) => {
|
|
253
|
+
if (error instanceof Error && /** @type {Error & {code?: string}} */ (error).code === "ENOENT") return []
|
|
254
|
+
throw error
|
|
255
|
+
})).length === 0).catch((error) => {
|
|
256
|
+
throw new Error(`TLS relay resources did not return to zero: ${JSON.stringify(relay.status())}; targets=${targetSockets.size}`, {cause: error})
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
assert.deepEqual(relay.status(), EMPTY_RELAY_STATUS)
|
|
260
|
+
assert.equal(Object.keys(agent.sockets).length, 0)
|
|
261
|
+
assert.equal(Object.keys(agent.freeSockets).length, 0)
|
|
262
|
+
assert.equal(Object.keys(agent.requests).length, 0)
|
|
263
|
+
assert.equal(observedSocket?.listenerCount("connect"), 0)
|
|
264
|
+
observedSocket = undefined
|
|
265
|
+
} finally {
|
|
266
|
+
agent.destroy()
|
|
267
|
+
await relay.close()
|
|
268
|
+
for (const socket of targetSockets) socket.destroy()
|
|
269
|
+
await closeServer(targetServer)
|
|
270
|
+
await rm(root, {recursive: true, force: true})
|
|
271
|
+
}
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
test("SocketductHttpAgent reports unauthorized targets as HTTP request errors", async () => {
|
|
275
|
+
const spoolDirectory = await mkdtemp(join(tmpdir(), "socketduct-http-agent-deny-test-"))
|
|
276
|
+
const users = new InMemoryUserStore()
|
|
277
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
278
|
+
await users.createUser({username: "kasper", password: "correct horse battery staple"})
|
|
279
|
+
const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
|
|
280
|
+
const relay = await startTcpRelay({
|
|
281
|
+
authorizeTarget: () => false,
|
|
282
|
+
tokens,
|
|
283
|
+
sessions: new SessionStore({now: () => 1000})
|
|
284
|
+
})
|
|
285
|
+
const agent = new SocketductHttpAgent({
|
|
286
|
+
relay: {host: "127.0.0.1", port: relay.port, token: token.value},
|
|
287
|
+
target: {host: "127.0.0.1", port: 2375},
|
|
288
|
+
spoolDirectory,
|
|
289
|
+
sessionNamePrefix: "http-agent-denied"
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
await assert.rejects(request({agent, host: "docker", port: 80, path: "/_ping", method: "GET"}), (error) => {
|
|
293
|
+
assert.ok(error instanceof SocketductRelayError)
|
|
294
|
+
assert.equal(error.code, "TARGET_UNAUTHORIZED")
|
|
295
|
+
assert.equal(error.retryable, false)
|
|
296
|
+
return true
|
|
297
|
+
})
|
|
298
|
+
agent.destroy()
|
|
299
|
+
await relay.close()
|
|
300
|
+
await rm(spoolDirectory, {recursive: true, force: true})
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
test("SocketductHttpAgent callback receives a typed pre-connect relay failure exactly once", async () => {
|
|
304
|
+
const spoolDirectory = await mkdtemp(join(tmpdir(), "socketduct-http-agent-callback-error-test-"))
|
|
305
|
+
const users = new InMemoryUserStore()
|
|
306
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
307
|
+
await users.createUser({username: "kasper", password: "correct horse battery staple"})
|
|
308
|
+
const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
|
|
309
|
+
const relay = await startTcpRelay({
|
|
310
|
+
authorizeTarget: () => false,
|
|
311
|
+
tokens,
|
|
312
|
+
sessions: new SessionStore({now: () => 1000})
|
|
313
|
+
})
|
|
314
|
+
const agent = new SocketductHttpAgent({
|
|
315
|
+
relay: {host: "127.0.0.1", port: relay.port, token: token.value},
|
|
316
|
+
target: {host: "127.0.0.1", port: 2375},
|
|
317
|
+
spoolDirectory,
|
|
318
|
+
sessionNamePrefix: "http-agent-callback-error"
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
let callbackCount = 0
|
|
322
|
+
const result = await new Promise((resolve) => {
|
|
323
|
+
const socket = agent.createConnection({}, (error, connectedSocket) => {
|
|
324
|
+
callbackCount += 1
|
|
325
|
+
resolve({connectedSocket, error})
|
|
326
|
+
})
|
|
327
|
+
socket.on("error", () => {})
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
assert.equal(callbackCount, 1)
|
|
331
|
+
assert.ok(result.error instanceof SocketductRelayError)
|
|
332
|
+
assert.equal(result.error.code, "TARGET_UNAUTHORIZED")
|
|
333
|
+
assert.equal(result.error.retryable, false)
|
|
334
|
+
assert.ok(result.connectedSocket)
|
|
335
|
+
agent.destroy()
|
|
336
|
+
await relay.close()
|
|
337
|
+
await rm(spoolDirectory, {recursive: true, force: true})
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
test("SocketductHttpAgent handles delayed streaming response close without relay socket errors", async () => {
|
|
341
|
+
const spoolDirectory = await mkdtemp(join(tmpdir(), "socketduct-http-agent-stream-close-test-"))
|
|
342
|
+
const targetServer = http.createServer((req, res) => {
|
|
343
|
+
if (req.url === "/logs") {
|
|
344
|
+
res.writeHead(200, {"content-type": "application/octet-stream"})
|
|
345
|
+
void (async () => {
|
|
346
|
+
res.write("chunk-one")
|
|
347
|
+
await delay(10)
|
|
348
|
+
res.write("chunk-two")
|
|
349
|
+
await delay(10)
|
|
350
|
+
res.end()
|
|
351
|
+
})()
|
|
352
|
+
return
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
res.writeHead(404)
|
|
356
|
+
res.end("missing")
|
|
357
|
+
})
|
|
358
|
+
const targetPort = await listen(targetServer)
|
|
359
|
+
|
|
360
|
+
const users = new InMemoryUserStore()
|
|
361
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
362
|
+
await users.createUser({username: "kasper", password: "correct horse battery staple"})
|
|
363
|
+
const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
|
|
364
|
+
const relay = await startTcpRelay({
|
|
365
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
366
|
+
tokens,
|
|
367
|
+
sessions: new SessionStore({now: () => 1000})
|
|
368
|
+
})
|
|
369
|
+
const agent = new SocketductHttpAgent({
|
|
370
|
+
relay: {host: "127.0.0.1", port: relay.port, token: token.value},
|
|
371
|
+
target: {host: "127.0.0.1", port: targetPort},
|
|
372
|
+
spoolDirectory,
|
|
373
|
+
sessionNamePrefix: "http-agent-stream-close"
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
const response = await request({agent, host: "docker", port: 80, path: "/logs", method: "GET"})
|
|
377
|
+
|
|
378
|
+
assert.equal(response.statusCode, 200)
|
|
379
|
+
assert.equal(response.body, "chunk-onechunk-two")
|
|
380
|
+
agent.destroy()
|
|
381
|
+
await relay.close()
|
|
382
|
+
await closeServer(targetServer)
|
|
383
|
+
await rm(spoolDirectory, {recursive: true, force: true})
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
test("SocketductHttpAgent ends responses that rely on target TCP close", async () => {
|
|
387
|
+
const spoolDirectory = await mkdtemp(join(tmpdir(), "socketduct-http-agent-target-eof-test-"))
|
|
388
|
+
const targetServer = net.createServer((socket) => {
|
|
389
|
+
socket.once("data", () => {
|
|
390
|
+
socket.write("HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\n\r\n")
|
|
391
|
+
socket.write("close-delimited-body")
|
|
392
|
+
socket.end()
|
|
393
|
+
})
|
|
394
|
+
})
|
|
395
|
+
const targetPort = await listen(targetServer)
|
|
396
|
+
|
|
397
|
+
const users = new InMemoryUserStore()
|
|
398
|
+
const tokens = new TokenStore({now: () => 1000, tokenTtlMs: 60_000})
|
|
399
|
+
await users.createUser({username: "kasper", password: "correct horse battery staple"})
|
|
400
|
+
const token = await login(users, tokens, {username: "kasper", password: "correct horse battery staple"})
|
|
401
|
+
const relay = await startTcpRelay({
|
|
402
|
+
authorizeTarget: ({target}) => target.host === "127.0.0.1" && target.port === targetPort,
|
|
403
|
+
tokens,
|
|
404
|
+
sessions: new SessionStore({now: () => 1000})
|
|
405
|
+
})
|
|
406
|
+
const agent = new SocketductHttpAgent({
|
|
407
|
+
relay: {host: "127.0.0.1", port: relay.port, token: token.value},
|
|
408
|
+
target: {host: "127.0.0.1", port: targetPort},
|
|
409
|
+
spoolDirectory,
|
|
410
|
+
sessionNamePrefix: "http-agent-target-eof"
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
const timeout = AbortSignal.timeout(1000)
|
|
414
|
+
const response = await new Promise((resolve, reject) => {
|
|
415
|
+
const req = http.request({agent, host: "docker", port: 80, path: "/raw", method: "POST", signal: timeout}, (res) => {
|
|
416
|
+
/** @type {Buffer[]} */
|
|
417
|
+
const chunks = []
|
|
418
|
+
res.on("data", (chunk) => chunks.push(chunk))
|
|
419
|
+
res.on("end", () => resolve({statusCode: res.statusCode, body: Buffer.concat(chunks).toString("utf8")}))
|
|
420
|
+
})
|
|
421
|
+
req.on("error", reject)
|
|
422
|
+
req.end("{}")
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
assert.deepEqual(response, {statusCode: 200, body: "close-delimited-body"})
|
|
426
|
+
agent.destroy()
|
|
427
|
+
await waitUntil(() => {
|
|
428
|
+
const status = relay.status()
|
|
429
|
+
return status.sessions === 0 && status.activeTunnels === 0 && status.activeTargets === 0
|
|
430
|
+
})
|
|
431
|
+
assert.deepEqual(relay.status(), EMPTY_RELAY_STATUS)
|
|
432
|
+
await relay.close()
|
|
433
|
+
await closeServer(targetServer)
|
|
434
|
+
await rm(spoolDirectory, {recursive: true, force: true})
|
|
435
|
+
})
|