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,262 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import {spawn} from "node:child_process"
|
|
3
|
+
import {mkdtemp, rm} from "node:fs/promises"
|
|
4
|
+
import {createConnection, createServer} from "node:net"
|
|
5
|
+
import {tmpdir} from "node:os"
|
|
6
|
+
import {join} from "node:path"
|
|
7
|
+
import test from "node:test"
|
|
8
|
+
|
|
9
|
+
import {startTcpEdge} from "../../src/edge/tcp-edge.js"
|
|
10
|
+
import {encodeFrame, FrameDecoder} from "../../src/protocol/frame.js"
|
|
11
|
+
import {SessionSpool} from "../../src/spool/spool-file.js"
|
|
12
|
+
|
|
13
|
+
const PROCESS_TIMEOUT_MS = 5_000
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {import('node:net').Server} server - Server to bind.
|
|
17
|
+
* @returns {Promise<number>} Bound port.
|
|
18
|
+
*/
|
|
19
|
+
async function listen(server) {
|
|
20
|
+
await new Promise((resolve, reject) => {
|
|
21
|
+
server.once("error", reject)
|
|
22
|
+
server.listen(0, "127.0.0.1", () => {
|
|
23
|
+
server.off("error", reject)
|
|
24
|
+
resolve(undefined)
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
const address = server.address()
|
|
28
|
+
if (address === null || typeof address === "string") {
|
|
29
|
+
throw new Error("Server did not bind to a TCP port")
|
|
30
|
+
}
|
|
31
|
+
return address.port
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** @returns {Promise<number>} Available TCP port. */
|
|
35
|
+
async function reservePort() {
|
|
36
|
+
const server = createServer()
|
|
37
|
+
const port = await listen(server)
|
|
38
|
+
await closeServer(server)
|
|
39
|
+
return port
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {import('node:net').Server} server - Server to close.
|
|
44
|
+
* @returns {Promise<void>} Close completion.
|
|
45
|
+
*/
|
|
46
|
+
async function closeServer(server) {
|
|
47
|
+
await new Promise((resolve, reject) => {
|
|
48
|
+
server.close((error) => {
|
|
49
|
+
if (error) {
|
|
50
|
+
reject(error)
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
resolve(undefined)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {import('node:net').Socket} socket - Socket to wait on.
|
|
60
|
+
* @returns {Promise<void>} Connect completion.
|
|
61
|
+
*/
|
|
62
|
+
async function waitConnect(socket) {
|
|
63
|
+
await new Promise((resolve, reject) => {
|
|
64
|
+
socket.once("error", reject)
|
|
65
|
+
socket.once("connect", () => {
|
|
66
|
+
socket.off("error", reject)
|
|
67
|
+
resolve(undefined)
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {import('node:net').Socket} socket - Socket to read.
|
|
74
|
+
* @returns {Promise<Buffer>} Next chunk.
|
|
75
|
+
*/
|
|
76
|
+
async function readOnce(socket) {
|
|
77
|
+
return await withTimeout(new Promise((resolve) => {
|
|
78
|
+
socket.once("data", (chunk) => resolve(chunk))
|
|
79
|
+
}), PROCESS_TIMEOUT_MS, "Timed out waiting for socket data")
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {Promise<T>} promise - Promise to wrap.
|
|
84
|
+
* @param {number} timeoutMs - Timeout in milliseconds.
|
|
85
|
+
* @param {string} message - Timeout message.
|
|
86
|
+
* @template T
|
|
87
|
+
* @returns {Promise<T>} Promise result.
|
|
88
|
+
*/
|
|
89
|
+
async function withTimeout(promise, timeoutMs, message) {
|
|
90
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
91
|
+
let timer
|
|
92
|
+
const timeout = new Promise((_, reject) => {
|
|
93
|
+
timer = setTimeout(() => reject(new Error(message)), timeoutMs)
|
|
94
|
+
})
|
|
95
|
+
try {
|
|
96
|
+
return await Promise.race([promise, timeout])
|
|
97
|
+
} finally {
|
|
98
|
+
if (timer !== undefined) {
|
|
99
|
+
clearTimeout(timer)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @param {Record<string, string>} env - Relay process environment.
|
|
106
|
+
* @returns {Promise<{process: import('node:child_process').ChildProcessByStdio<null, import('node:stream').Readable, import('node:stream').Readable>, output: () => string}>} Started relay process.
|
|
107
|
+
*/
|
|
108
|
+
async function startRelayProcess(env) {
|
|
109
|
+
const child = spawn(process.execPath, ["src/index.js"], {
|
|
110
|
+
cwd: process.cwd(),
|
|
111
|
+
env: {...process.env, ...env},
|
|
112
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
113
|
+
})
|
|
114
|
+
let output = ""
|
|
115
|
+
child.stdout.on("data", (chunk) => { output += chunk.toString("utf8") })
|
|
116
|
+
child.stderr.on("data", (chunk) => { output += chunk.toString("utf8") })
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
await withTimeout(new Promise((resolve, reject) => {
|
|
120
|
+
const cleanup = () => {
|
|
121
|
+
child.off("exit", onExit)
|
|
122
|
+
child.stdout.off("data", onStdout)
|
|
123
|
+
}
|
|
124
|
+
/** @type {(code: number | null) => void} */
|
|
125
|
+
const onExit = (code) => {
|
|
126
|
+
cleanup()
|
|
127
|
+
reject(new Error(`Relay exited before listening with ${code}: ${output}`))
|
|
128
|
+
}
|
|
129
|
+
const onStdout = () => {
|
|
130
|
+
if (output.includes('"event":"relay.started"')) {
|
|
131
|
+
cleanup()
|
|
132
|
+
resolve(undefined)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
child.once("exit", onExit)
|
|
137
|
+
child.stdout.on("data", onStdout)
|
|
138
|
+
onStdout()
|
|
139
|
+
}), PROCESS_TIMEOUT_MS, `Timed out waiting for relay startup: ${output}`)
|
|
140
|
+
} catch (error) {
|
|
141
|
+
await stopProcess(child)
|
|
142
|
+
throw error
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return {process: child, output: () => output}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* @param {import('node:child_process').ChildProcess} child - Child process.
|
|
150
|
+
* @returns {Promise<void>} Stop completion.
|
|
151
|
+
*/
|
|
152
|
+
async function stopProcess(child) {
|
|
153
|
+
if (child.exitCode !== null || child.killed) {
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
child.kill("SIGTERM")
|
|
157
|
+
await withTimeout(new Promise((resolve) => {
|
|
158
|
+
child.once("exit", () => resolve(undefined))
|
|
159
|
+
}), PROCESS_TIMEOUT_MS, "Timed out waiting for relay shutdown")
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
test("e2e process gateway forwards edge traffic to an allowlisted target", async () => {
|
|
163
|
+
const tempDir = await mkdtemp(join(tmpdir(), "socketduct-e2e-spool-"))
|
|
164
|
+
const targetServer = createServer((socket) => {
|
|
165
|
+
socket.on("data", (chunk) => socket.write(Buffer.concat([Buffer.from("echo:"), chunk])))
|
|
166
|
+
})
|
|
167
|
+
const targetPort = await listen(targetServer)
|
|
168
|
+
const relayPort = await reservePort()
|
|
169
|
+
const relay = await startRelayProcess({
|
|
170
|
+
SOCKETDUCT_MODE: "relay",
|
|
171
|
+
SOCKETDUCT_RELAY_HOST: "127.0.0.1",
|
|
172
|
+
SOCKETDUCT_RELAY_PORT: String(relayPort),
|
|
173
|
+
SOCKETDUCT_AUTH_TOKENS: "tensorbuzz-edge:e2e-token-not-placeholder",
|
|
174
|
+
SOCKETDUCT_TARGET_ALLOWLIST: `127.0.0.1:${targetPort}`
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
const edge = await startTcpEdge({
|
|
178
|
+
relayHost: "127.0.0.1",
|
|
179
|
+
relayPort,
|
|
180
|
+
token: "e2e-token-not-placeholder",
|
|
181
|
+
sessionName: "e2e process gateway",
|
|
182
|
+
target: {host: "127.0.0.1", port: targetPort},
|
|
183
|
+
spool: new SessionSpool({rootDir: tempDir}),
|
|
184
|
+
reconnectDelayMs: 10
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
test.after(async () => {
|
|
188
|
+
await edge.close()
|
|
189
|
+
await stopProcess(relay.process)
|
|
190
|
+
await closeServer(targetServer)
|
|
191
|
+
await rm(tempDir, {recursive: true, force: true})
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
const appSocket = createConnection({host: "127.0.0.1", port: edge.port})
|
|
195
|
+
await waitConnect(appSocket)
|
|
196
|
+
appSocket.write("hello-tensorbuzz")
|
|
197
|
+
assert.equal((await readOnce(appSocket)).toString("utf8"), "echo:hello-tensorbuzz")
|
|
198
|
+
appSocket.destroy()
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
test("e2e process gateway denies non-allowlisted target requests", async () => {
|
|
202
|
+
const allowedTarget = createServer((socket) => {
|
|
203
|
+
socket.on("data", (chunk) => socket.write(chunk))
|
|
204
|
+
})
|
|
205
|
+
const allowedPort = await listen(allowedTarget)
|
|
206
|
+
const blockedPort = await reservePort()
|
|
207
|
+
const relayPort = await reservePort()
|
|
208
|
+
const relay = await startRelayProcess({
|
|
209
|
+
SOCKETDUCT_MODE: "relay",
|
|
210
|
+
SOCKETDUCT_RELAY_HOST: "127.0.0.1",
|
|
211
|
+
SOCKETDUCT_RELAY_PORT: String(relayPort),
|
|
212
|
+
SOCKETDUCT_AUTH_TOKENS: "tensorbuzz-edge:e2e-token-not-placeholder",
|
|
213
|
+
SOCKETDUCT_TARGET_ALLOWLIST: `127.0.0.1:${allowedPort}`
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
test.after(async () => {
|
|
217
|
+
await stopProcess(relay.process)
|
|
218
|
+
await closeServer(allowedTarget)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
const tunnel = createConnection({host: "127.0.0.1", port: relayPort})
|
|
222
|
+
const decoder = new FrameDecoder()
|
|
223
|
+
await waitConnect(tunnel)
|
|
224
|
+
tunnel.write(encodeFrame({
|
|
225
|
+
type: "session.create",
|
|
226
|
+
token: "e2e-token-not-placeholder",
|
|
227
|
+
name: "blocked process gateway target",
|
|
228
|
+
target: {host: "127.0.0.1", port: blockedPort},
|
|
229
|
+
framing: [2]
|
|
230
|
+
}))
|
|
231
|
+
|
|
232
|
+
const [frame] = decoder.push(await readOnce(tunnel))
|
|
233
|
+
assert.equal(frame?.header.type, "error")
|
|
234
|
+
assert.equal(frame?.header.code, "TARGET_UNAUTHORIZED")
|
|
235
|
+
tunnel.destroy()
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
test("e2e process gateway rejects placeholder tokens before listening", async () => {
|
|
239
|
+
const relayPort = await reservePort()
|
|
240
|
+
const child = spawn(process.execPath, ["src/index.js"], {
|
|
241
|
+
cwd: process.cwd(),
|
|
242
|
+
env: {
|
|
243
|
+
...process.env,
|
|
244
|
+
SOCKETDUCT_MODE: "relay",
|
|
245
|
+
SOCKETDUCT_RELAY_HOST: "127.0.0.1",
|
|
246
|
+
SOCKETDUCT_RELAY_PORT: String(relayPort),
|
|
247
|
+
SOCKETDUCT_AUTH_TOKENS: "tensorbuzz-edge:replace-with-generated-token",
|
|
248
|
+
SOCKETDUCT_TARGET_ALLOWLIST: "127.0.0.1:443"
|
|
249
|
+
},
|
|
250
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
251
|
+
})
|
|
252
|
+
let output = ""
|
|
253
|
+
child.stdout.on("data", (chunk) => { output += chunk.toString("utf8") })
|
|
254
|
+
child.stderr.on("data", (chunk) => { output += chunk.toString("utf8") })
|
|
255
|
+
|
|
256
|
+
const exitCode = await withTimeout(new Promise((resolve) => {
|
|
257
|
+
child.once("exit", (code) => resolve(code))
|
|
258
|
+
}), PROCESS_TIMEOUT_MS, "Timed out waiting for placeholder-token process exit")
|
|
259
|
+
|
|
260
|
+
assert.notEqual(exitCode, 0)
|
|
261
|
+
assert.match(output, /placeholder tokens/)
|
|
262
|
+
})
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-param, jsdoc/require-returns */
|
|
2
|
+
import assert from "node:assert/strict"
|
|
3
|
+
import {spawn} from "node:child_process"
|
|
4
|
+
import {mkdtemp, readFile, readdir, rm} from "node:fs/promises"
|
|
5
|
+
import {createServer} from "node:net"
|
|
6
|
+
import {tmpdir} from "node:os"
|
|
7
|
+
import {join} from "node:path"
|
|
8
|
+
import test from "node:test"
|
|
9
|
+
|
|
10
|
+
import {ReverseGatewayConnection} from "../../src/reverse/client.js"
|
|
11
|
+
import {generateTestCertificates} from "../helpers/tls-certificates.js"
|
|
12
|
+
|
|
13
|
+
const PROCESS_TIMEOUT_MS = 8_000
|
|
14
|
+
const PROCESS_LISTEN_ATTEMPTS = 3
|
|
15
|
+
|
|
16
|
+
/** A child exit observed before its expected startup event. */
|
|
17
|
+
class ProcessStartupExitError extends Error {
|
|
18
|
+
/**
|
|
19
|
+
* @param {number | null} code - Exit code.
|
|
20
|
+
* @param {string} event - Expected startup event.
|
|
21
|
+
* @param {string} output - Complete child output.
|
|
22
|
+
* @param {import('node:child_process').ChildProcess} child - Exited child.
|
|
23
|
+
*/
|
|
24
|
+
constructor(code, event, output, child) {
|
|
25
|
+
super(`Socketduct process exited ${String(code)} before ${event}: ${output}`)
|
|
26
|
+
this.name = "ProcessStartupExitError"
|
|
27
|
+
this.child = child
|
|
28
|
+
this.reason = isListenAddressInUse(output) ? "listen-address-in-use" : "process-exit"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** @param {string} output - Complete child output. @returns {boolean} Whether Node reported listen EADDRINUSE. */
|
|
33
|
+
function isListenAddressInUse(output) {
|
|
34
|
+
return /^Error: listen EADDRINUSE:/mu.test(output) &&
|
|
35
|
+
/^\s*code: ['"]EADDRINUSE['"],?\s*$/mu.test(output) &&
|
|
36
|
+
/^\s*syscall: ['"]listen['"],?\s*$/mu.test(output)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** @param {import('node:net').Server} server - Server. */
|
|
40
|
+
async function listen(server) {
|
|
41
|
+
await new Promise((resolve, reject) => {
|
|
42
|
+
server.once("error", reject)
|
|
43
|
+
server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(undefined) })
|
|
44
|
+
})
|
|
45
|
+
const address = server.address()
|
|
46
|
+
assert(address !== null && typeof address !== "string")
|
|
47
|
+
return address.port
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Reserves a currently unused loopback port. */
|
|
51
|
+
async function reservePort() {
|
|
52
|
+
const server = createServer()
|
|
53
|
+
const port = await listen(server)
|
|
54
|
+
await new Promise((resolve) => server.close(() => resolve(undefined)))
|
|
55
|
+
return port
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** @param {Record<string, string>} environment - Runtime environment. @param {string} event - Expected startup event. */
|
|
59
|
+
async function startProcess(environment, event) {
|
|
60
|
+
const child = spawn(process.execPath, ["src/index.js"], {
|
|
61
|
+
cwd: process.cwd(),
|
|
62
|
+
env: {...process.env, ...environment},
|
|
63
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
64
|
+
})
|
|
65
|
+
let output = ""
|
|
66
|
+
child.stdout.on("data", (chunk) => { output += chunk.toString() })
|
|
67
|
+
child.stderr.on("data", (chunk) => { output += chunk.toString() })
|
|
68
|
+
try {
|
|
69
|
+
await withTimeout(new Promise((resolve, reject) => {
|
|
70
|
+
const onClose = (/** @type {number | null} */ code) => reject(new ProcessStartupExitError(code, event, output, child))
|
|
71
|
+
const onData = () => {
|
|
72
|
+
if (!output.includes(`"event":"${event}"`)) return
|
|
73
|
+
child.off("close", onClose)
|
|
74
|
+
child.stdout.off("data", onData)
|
|
75
|
+
resolve(undefined)
|
|
76
|
+
}
|
|
77
|
+
child.once("close", onClose)
|
|
78
|
+
child.stdout.on("data", onData)
|
|
79
|
+
onData()
|
|
80
|
+
}), `process event ${event}`)
|
|
81
|
+
} catch (error) {
|
|
82
|
+
await stopProcess(child)
|
|
83
|
+
throw error
|
|
84
|
+
}
|
|
85
|
+
return child
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Starts a listener process on a reserved port, retrying only a classified pre-start bind collision.
|
|
90
|
+
* @param {Record<string, string>} environment - Runtime environment without its listener port.
|
|
91
|
+
* @param {string} event - Expected startup event.
|
|
92
|
+
* @param {{reservePort?: () => Promise<number>, startProcess?: typeof startProcess}} [options] - Test dependencies.
|
|
93
|
+
* @returns {Promise<{child: import('node:child_process').ChildProcess, port: number, attempts: import('node:child_process').ChildProcess[]}>} Started process details.
|
|
94
|
+
*/
|
|
95
|
+
async function startProcessOnReservedPort(environment, event, options = {}) {
|
|
96
|
+
const reserve = options.reservePort ?? reservePort
|
|
97
|
+
const start = options.startProcess ?? startProcess
|
|
98
|
+
/** @type {import('node:child_process').ChildProcess[]} */
|
|
99
|
+
const attempts = []
|
|
100
|
+
for (let attempt = 1; attempt <= PROCESS_LISTEN_ATTEMPTS; attempt += 1) {
|
|
101
|
+
const port = await reserve()
|
|
102
|
+
try {
|
|
103
|
+
const child = await start({...environment, SOCKETDUCT_RELAY_PORT: String(port)}, event)
|
|
104
|
+
attempts.push(child)
|
|
105
|
+
return {child, port, attempts}
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (error instanceof ProcessStartupExitError) attempts.push(error.child)
|
|
108
|
+
if (!(error instanceof ProcessStartupExitError && error.reason === "listen-address-in-use") || attempt === PROCESS_LISTEN_ATTEMPTS) throw error
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
throw new Error("Listener startup attempts exhausted")
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** @param {import('node:child_process').ChildProcess} child - Child. */
|
|
115
|
+
async function stopProcess(child) {
|
|
116
|
+
if (child.exitCode !== null || child.killed) return
|
|
117
|
+
child.kill("SIGTERM")
|
|
118
|
+
await withTimeout(new Promise((resolve) => child.once("exit", () => resolve(undefined))), "process shutdown")
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** @param {Promise<unknown>} promise - Work. @param {string} label - Timeout. */
|
|
122
|
+
async function withTimeout(promise, label) {
|
|
123
|
+
let timer
|
|
124
|
+
try {
|
|
125
|
+
return await Promise.race([promise, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`Timed out: ${label}`)), PROCESS_TIMEOUT_MS) })])
|
|
126
|
+
} finally {
|
|
127
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** @param {import('node:events').EventEmitter} emitter - Source. @param {string} event - Event. */
|
|
132
|
+
async function onceEvent(emitter, event) {
|
|
133
|
+
await new Promise((resolve, reject) => {
|
|
134
|
+
emitter.once("error", reject)
|
|
135
|
+
emitter.once(event, () => { emitter.off("error", reject); resolve(undefined) })
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
test("reserved reverse-hub startup retries a pre-start listen EADDRINUSE without leaking attempts", async (t) => {
|
|
140
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-port-retry-"))
|
|
141
|
+
t.after(() => rm(root, {recursive: true, force: true}))
|
|
142
|
+
const certificates = generateTestCertificates(root)
|
|
143
|
+
const occupiedListener = createServer()
|
|
144
|
+
const occupiedPort = await listen(occupiedListener)
|
|
145
|
+
t.after(() => new Promise((resolve) => occupiedListener.close(() => resolve(undefined))))
|
|
146
|
+
let reservations = 0
|
|
147
|
+
const {child, port, attempts} = await startProcessOnReservedPort({
|
|
148
|
+
SOCKETDUCT_MODE: "reverse-hub",
|
|
149
|
+
SOCKETDUCT_RELAY_HOST: "127.0.0.1",
|
|
150
|
+
SOCKETDUCT_TLS_CERT_FILE: certificates.serverCertFile,
|
|
151
|
+
SOCKETDUCT_TLS_KEY_FILE: certificates.serverKeyFile,
|
|
152
|
+
SOCKETDUCT_AUTH_TOKENS: "home-o2:process-gateway-token,tensorbuzz:process-client-token",
|
|
153
|
+
SOCKETDUCT_REVERSE_GATEWAY_BINDINGS: "o2:home-o2",
|
|
154
|
+
SOCKETDUCT_REVERSE_ROUTES: "tensorbuzz:o2:127.0.0.1:1",
|
|
155
|
+
SOCKETDUCT_STATUS_INTERVAL_MS: "0"
|
|
156
|
+
}, "reverse-hub.started", {
|
|
157
|
+
reservePort: async () => {
|
|
158
|
+
reservations += 1
|
|
159
|
+
return reservations === 1 ? occupiedPort : reservePort()
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
t.after(() => stopProcess(child))
|
|
163
|
+
|
|
164
|
+
assert.equal(reservations, 2)
|
|
165
|
+
assert.notEqual(port, occupiedPort)
|
|
166
|
+
assert.equal(attempts.length, 2)
|
|
167
|
+
const [failedAttempt, successfulAttempt] = attempts
|
|
168
|
+
assert(failedAttempt !== undefined)
|
|
169
|
+
assert(successfulAttempt !== undefined)
|
|
170
|
+
assert(failedAttempt.exitCode !== null || failedAttempt.signalCode !== null)
|
|
171
|
+
assert.equal(successfulAttempt, child)
|
|
172
|
+
assert.equal(child.exitCode, null)
|
|
173
|
+
assert.equal(child.signalCode, null)
|
|
174
|
+
assert.equal(occupiedListener.listening, true)
|
|
175
|
+
|
|
176
|
+
await stopProcess(child)
|
|
177
|
+
assert(child.exitCode !== null || child.signalCode !== null)
|
|
178
|
+
const releasedPortProbe = createServer()
|
|
179
|
+
t.after(() => {
|
|
180
|
+
if (!releasedPortProbe.listening) return undefined
|
|
181
|
+
return new Promise((resolve) => releasedPortProbe.close(() => resolve(undefined)))
|
|
182
|
+
})
|
|
183
|
+
await new Promise((resolve, reject) => {
|
|
184
|
+
releasedPortProbe.once("error", reject)
|
|
185
|
+
releasedPortProbe.listen(port, "127.0.0.1", () => {
|
|
186
|
+
releasedPortProbe.off("error", reject)
|
|
187
|
+
resolve(undefined)
|
|
188
|
+
})
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
test("reserved process startup does not retry an unclassified EADDRINUSE failure", async () => {
|
|
193
|
+
const failure = new Error("untrusted EADDRINUSE text is not a listener-bind classification")
|
|
194
|
+
let reservations = 0
|
|
195
|
+
let starts = 0
|
|
196
|
+
await assert.rejects(startProcessOnReservedPort({}, "reverse-hub.started", {
|
|
197
|
+
reservePort: async () => {
|
|
198
|
+
reservations += 1
|
|
199
|
+
return 31_337
|
|
200
|
+
},
|
|
201
|
+
startProcess: async () => {
|
|
202
|
+
starts += 1
|
|
203
|
+
throw failure
|
|
204
|
+
}
|
|
205
|
+
}), (error) => error === failure)
|
|
206
|
+
assert.equal(reservations, 1)
|
|
207
|
+
assert.equal(starts, 1)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
test("spawned reverse hub and outbound gateway expose an allowlisted Docker stream over verified TLS", async (t) => {
|
|
211
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-process-"))
|
|
212
|
+
t.after(() => rm(root, {recursive: true, force: true}))
|
|
213
|
+
const certificates = generateTestCertificates(root)
|
|
214
|
+
const logDirectory = join(root, "logs")
|
|
215
|
+
const targetSockets = new Set()
|
|
216
|
+
const target = createServer((socket) => {
|
|
217
|
+
targetSockets.add(socket)
|
|
218
|
+
socket.once("close", () => targetSockets.delete(socket))
|
|
219
|
+
socket.on("data", (chunk) => socket.write(Buffer.concat([Buffer.from("docker:"), chunk])))
|
|
220
|
+
})
|
|
221
|
+
const targetPort = await listen(target)
|
|
222
|
+
t.after(() => {
|
|
223
|
+
for (const socket of targetSockets) socket.destroy()
|
|
224
|
+
return new Promise((resolve) => target.close(() => resolve(undefined)))
|
|
225
|
+
})
|
|
226
|
+
const {child: hub, port: hubPort} = await startProcessOnReservedPort({
|
|
227
|
+
SOCKETDUCT_MODE: "reverse-hub",
|
|
228
|
+
SOCKETDUCT_RELAY_HOST: "127.0.0.1",
|
|
229
|
+
SOCKETDUCT_TLS_CERT_FILE: certificates.serverCertFile,
|
|
230
|
+
SOCKETDUCT_TLS_KEY_FILE: certificates.serverKeyFile,
|
|
231
|
+
SOCKETDUCT_AUTH_TOKENS: "home-o2:process-gateway-token,tensorbuzz:process-client-token",
|
|
232
|
+
SOCKETDUCT_REVERSE_GATEWAY_BINDINGS: "o2:home-o2",
|
|
233
|
+
SOCKETDUCT_REVERSE_ROUTES: `tensorbuzz:o2:127.0.0.1:${targetPort}`,
|
|
234
|
+
SOCKETDUCT_STATUS_INTERVAL_MS: "0",
|
|
235
|
+
SOCKETDUCT_LOG_DIRECTORY: logDirectory,
|
|
236
|
+
SOCKETDUCT_LOG_MAX_FILE_BYTES: "4096",
|
|
237
|
+
SOCKETDUCT_LOG_MAX_FILES: "2"
|
|
238
|
+
}, "reverse-hub.started")
|
|
239
|
+
t.after(() => stopProcess(hub))
|
|
240
|
+
const gateway = await startProcess({
|
|
241
|
+
SOCKETDUCT_MODE: "reverse-gateway",
|
|
242
|
+
SOCKETDUCT_REVERSE_GATEWAY_ID: "o2",
|
|
243
|
+
SOCKETDUCT_REVERSE_CONNECTION_ID: "process_home_o2_gateway",
|
|
244
|
+
SOCKETDUCT_REVERSE_HUB_HOST: "127.0.0.1",
|
|
245
|
+
SOCKETDUCT_REVERSE_HUB_PORT: String(hubPort),
|
|
246
|
+
SOCKETDUCT_REVERSE_TOKEN: "process-gateway-token",
|
|
247
|
+
SOCKETDUCT_REVERSE_HUB_CA_FILE: certificates.caCertFile,
|
|
248
|
+
SOCKETDUCT_REVERSE_HUB_SERVERNAME: "localhost",
|
|
249
|
+
SOCKETDUCT_TARGET_ALLOWLIST: `127.0.0.1:${targetPort}`,
|
|
250
|
+
SOCKETDUCT_STATUS_INTERVAL_MS: "0",
|
|
251
|
+
SOCKETDUCT_LOG_DIRECTORY: logDirectory,
|
|
252
|
+
SOCKETDUCT_LOG_MAX_FILE_BYTES: "4096",
|
|
253
|
+
SOCKETDUCT_LOG_MAX_FILES: "2"
|
|
254
|
+
}, "reverse-gateway.started")
|
|
255
|
+
t.after(() => stopProcess(gateway))
|
|
256
|
+
|
|
257
|
+
const client = new ReverseGatewayConnection({
|
|
258
|
+
gatewayId: "o2",
|
|
259
|
+
connectionId: "process_tensorbuzz_client",
|
|
260
|
+
hub: {host: "127.0.0.1", port: hubPort, token: "process-client-token"},
|
|
261
|
+
tls: {caFile: certificates.caCertFile, servername: "localhost"}
|
|
262
|
+
})
|
|
263
|
+
t.after(() => client.close())
|
|
264
|
+
await withTimeout(client.connect(), "process client connect")
|
|
265
|
+
const socket = client.createConnection({host: "127.0.0.1", port: targetPort, sessionName: "docker-version-process"})
|
|
266
|
+
await withTimeout(onceEvent(socket, "connect"), "process stream open")
|
|
267
|
+
const response = new Promise((resolve, reject) => { socket.once("error", reject); socket.once("data", resolve) })
|
|
268
|
+
socket.write("GET /version")
|
|
269
|
+
assert.equal(Buffer.from(/** @type {Uint8Array} */ (await withTimeout(response, "process response"))).toString(), "docker:GET /version")
|
|
270
|
+
assert.equal(client.status().compression, "zstd")
|
|
271
|
+
socket.destroy()
|
|
272
|
+
|
|
273
|
+
assert(hub.pid !== undefined)
|
|
274
|
+
assert(gateway.pid !== undefined)
|
|
275
|
+
const files = (await readdir(logDirectory)).sort()
|
|
276
|
+
assert(files.includes(`reverse-hub-${hub.pid}.jsonl`))
|
|
277
|
+
assert(files.includes(`reverse-gateway-${gateway.pid}.jsonl`))
|
|
278
|
+
const hubEntries = (await readFile(join(logDirectory, `reverse-hub-${hub.pid}.jsonl`), "utf8")).trim().split("\n").map((line) => JSON.parse(line))
|
|
279
|
+
const gatewayContents = await readFile(join(logDirectory, `reverse-gateway-${gateway.pid}.jsonl`), "utf8")
|
|
280
|
+
const gatewayEntries = gatewayContents.trim().split("\n").map((line) => JSON.parse(line))
|
|
281
|
+
assert(hubEntries.some(({event}) => event === "process.started"))
|
|
282
|
+
assert(gatewayEntries.some(({event}) => event === "process.started"))
|
|
283
|
+
assert(gatewayEntries.some(({event}) => event === "transport.connected"))
|
|
284
|
+
assert.doesNotMatch(gatewayContents, /process-gateway-token|127\.0\.0\.1|docker-server/)
|
|
285
|
+
})
|