socketduct 0.1.25 → 0.1.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -2
- package/changelog.d/20261010130509-bound-log-frame-compression-memory.md +1 -0
- package/changelog.d/20261025070000-reclaim-unreadable-retention-lock.md +14 -0
- package/docs/runbooks/jsonl-integrity-rollout.md +8 -6
- package/package.json +2 -2
- package/scripts/test.js +9 -14
- package/src/logging/compressed-log-writer.js +1 -1
- package/src/logging/log-retention.js +36 -1
- package/src/logging/zstd-frame-codec.js +15 -1
- package/test/e2e/gateway-process.test.js +93 -92
- package/test/e2e/logger-shutdown-process.test.js +40 -37
- package/test/e2e/reverse-client-recovery-process.test.js +107 -103
- package/test/e2e/reverse-gateway-process.test.js +328 -318
- package/test/integration/initial-target-recovery.test.js +333 -334
- package/test/integration/logging-cli.test.js +121 -119
- package/test/integration/logging-compressed-recovery.test.js +96 -95
- package/test/integration/multiplexed-compatibility.test.js +178 -177
- package/test/integration/multiplexed-corrections.test.js +360 -359
- package/test/integration/multiplexed-failures.test.js +253 -251
- package/test/integration/multiplexed-transport-set.test.js +334 -322
- package/test/integration/multiplexed-transport.test.js +135 -134
- package/test/integration/reverse-gateway-stream-id-reset.test.js +90 -89
- package/test/integration/reverse-gateway.test.js +2309 -2218
- package/test/integration/reverse-resume-continuity.test.js +219 -218
- package/test/integration/reverse-route-class.test.js +358 -341
- package/test/integration/socketduct-http-agent.test.js +314 -312
- package/test/integration/socketduct-https-agent.test.js +516 -485
- package/test/integration/stream-compression.test.js +862 -824
- package/test/integration/tcp-forwarding.test.js +2550 -2447
- package/test/integration/tls-forwarding.test.js +593 -592
- package/test/unit/auth/auth.test.js +43 -44
- package/test/unit/auth/static-token-store.test.js +9 -8
- package/test/unit/client/connect.test.js +41 -40
- package/test/unit/client/deferred-virtual-socket.test.js +95 -94
- package/test/unit/client/multiplexed-spool-budget.test.js +155 -154
- package/test/unit/client/multiplexed-transport-set.test.js +830 -748
- package/test/unit/client/socketduct-socket.test.js +768 -771
- package/test/unit/client/virtual-socket.test.js +198 -168
- package/test/unit/config/docker-gateway-files.test.js +224 -224
- package/test/unit/config/environment.test.js +472 -492
- package/test/unit/index.test.js +53 -52
- package/test/unit/logging/compressed-log-worker.test.js +23 -22
- package/test/unit/logging/compressed-log-writer.test.js +155 -154
- package/test/unit/logging/log-retention.test.js +71 -48
- package/test/unit/logging/log-segment-index.test.js +84 -81
- package/test/unit/logging/zstd-frame-codec.test.js +28 -19
- package/test/unit/protocol/compression.test.js +254 -298
- package/test/unit/protocol/frame.test.js +280 -276
- package/test/unit/protocol/v3-compression.test.js +21 -23
- package/test/unit/protocol/v3-frame.test.js +42 -41
- package/test/unit/release-configuration.test.js +15 -12
- package/test/unit/reverse/disconnect-attribution.test.js +65 -64
- package/test/unit/reverse-terminal-outcomes.test.js +53 -50
- package/test/unit/server/control-handler.test.js +232 -231
- package/test/unit/server/primary.test.js +51 -49
- package/test/unit/server/relay-runtime.test.js +280 -294
- package/test/unit/sessions/session.test.js +48 -62
- package/test/unit/spool/spool-file.test.js +401 -385
package/AGENTS.md
CHANGED
|
@@ -160,9 +160,9 @@ docs/
|
|
|
160
160
|
## Definition of done for implementation PRs
|
|
161
161
|
|
|
162
162
|
- Add a release-note fragment for every user-visible change, bug fix, security fix, or protocol change under `changelog.d/YYYYMMDDHHMMSS-short-slug.md`. Keep it to one concise, user-facing sentence; do not edit generated release notes directly.
|
|
163
|
-
- Tests added or updated.
|
|
163
|
+
- Tests added or updated — test modules declare `describe`/`it`/`expect` from `@velocious/testing`; `node:test`/`node:assert` are not used in test modules.
|
|
164
164
|
- Relevant docs updated.
|
|
165
|
-
- `npm test` passes.
|
|
165
|
+
- `npm test` passes (every `test/**/*.test.js` module runs through the `velocious-test` runner).
|
|
166
166
|
- Lint/typecheck passes once configured.
|
|
167
167
|
- Security-sensitive changes mention how credentials/tokens are protected.
|
|
168
168
|
- No open proxy behavior introduced.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Diagnostic log frames now default to zstd level 9 instead of 19: zstd context memory scales steeply with the level (~1-2 MB per compressed batch at 9 versus ~90 MB at 19), and per-batch contexts accumulated into a multi-GB resident/peak-RSS ratchet in long-lived processes that no JS-level accounting or GC pressure sees.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Reclaim unreadable retention locks
|
|
2
|
+
|
|
3
|
+
Retention now reclaims a `.socketduct-retention.lock` whose owner record this
|
|
4
|
+
process cannot read instead of failing the whole run. A lock written by a
|
|
5
|
+
privileged one-off process (a root repair or cleanup) is mode 0600 and owned by
|
|
6
|
+
another uid, so every later retention attempt raised `EACCES` from
|
|
7
|
+
`readOwnerFile`. That error propagated out of the durable log sink at startup and
|
|
8
|
+
took down the server process that owns the sink.
|
|
9
|
+
|
|
10
|
+
The lock directory is private and peer writers share this process's uid, so an
|
|
11
|
+
unreadable record cannot belong to a live peer of the runtime. Retention now
|
|
12
|
+
unlinks such a file and retries the atomic link. If the file cannot be removed,
|
|
13
|
+
retention fails with `LOG_RETENTION_LOCK_UNREADABLE`, which names the lock path
|
|
14
|
+
and the owning uid instead of surfacing a bare `EACCES`.
|
|
@@ -35,12 +35,14 @@ npm run lint
|
|
|
35
35
|
git diff --check
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
Run the
|
|
39
|
-
runner. `npm test` is the CI entry point: it discovers
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
38
|
+
Run the focused modules sequentially with the published `@velocious/testing`
|
|
39
|
+
runner. `npm test` is the CI entry point: it discovers every module under
|
|
40
|
+
`test/**/*.test.js` and runs each one through the `velocious-test` runner in
|
|
41
|
+
sorted order. `npm test -- --list` prints the discovered modules without running
|
|
42
|
+
tests, and `npm test -- <path-or-args>` forwards arguments to a focused runner
|
|
43
|
+
invocation. Do not run the full suite or shards locally during a correction
|
|
44
|
+
pass; CI owns that coverage. Test cleanup uses the package's supported FIFO
|
|
45
|
+
`withTestCleanup`.
|
|
44
46
|
|
|
45
47
|
The process tests create and remove only their own private home subdirectories;
|
|
46
48
|
the canonical `/home/dev` bind persists across child-process exits. They reuse
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "socketduct",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.27",
|
|
4
4
|
"description": "Resilient authenticated tunnel/proxy for keeping socket streams alive across flaky links.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"@eslint/js": "^10.0.1",
|
|
40
40
|
"@types/node": "^24.0.0",
|
|
41
|
-
"@velocious/testing": "0.0.
|
|
41
|
+
"@velocious/testing": "^0.0.18",
|
|
42
42
|
"awaitery": "^1.0.21",
|
|
43
43
|
"eslint": "^10.0.3",
|
|
44
44
|
"eslint-plugin-jsdoc": "^62.7.1",
|
package/scripts/test.js
CHANGED
|
@@ -1,24 +1,19 @@
|
|
|
1
1
|
import {spawnSync} from "node:child_process"
|
|
2
2
|
import {globSync} from "node:fs"
|
|
3
3
|
|
|
4
|
-
const
|
|
4
|
+
const args = process.argv.slice(2)
|
|
5
5
|
const files = globSync("test/**/*.test.js").sort()
|
|
6
|
-
|
|
7
|
-
if (!files.includes(path)) throw new Error(`Missing migrated test module: ${path}`)
|
|
8
|
-
}
|
|
9
|
-
const legacy = files.filter((path) => !migrated.includes(path))
|
|
10
|
-
if (legacy.length === 0) throw new Error("No legacy test modules discovered")
|
|
11
|
-
const commands = [
|
|
12
|
-
{runner: process.execPath, args: ["--test", ...legacy]},
|
|
13
|
-
...migrated.map((path) => ({runner: "velocious-test", args: [path]}))
|
|
14
|
-
]
|
|
6
|
+
if (files.length === 0) throw new Error("No test modules discovered")
|
|
15
7
|
|
|
16
|
-
if (
|
|
17
|
-
console.log(JSON.stringify({
|
|
8
|
+
if (args.length === 1 && args[0] === "--list") {
|
|
9
|
+
console.log(JSON.stringify({files}, null, 2))
|
|
18
10
|
} else {
|
|
19
|
-
|
|
11
|
+
const commands = args.length === 0
|
|
12
|
+
? files.map((path) => ({args: [path], label: path}))
|
|
13
|
+
: [{args, label: args.join(" ")}]
|
|
20
14
|
for (const command of commands) {
|
|
21
|
-
|
|
15
|
+
if (commands.length > 1) console.log(`\n=== ${command.label} ===`)
|
|
16
|
+
const result = spawnSync("velocious-test", command.args, {stdio: "inherit"})
|
|
22
17
|
if (result.error) throw result.error
|
|
23
18
|
if (result.status !== 0) {
|
|
24
19
|
process.exitCode = result.status ?? 1
|
|
@@ -36,7 +36,7 @@ const NONCRITICAL_EVENTS = new Set(["runtime.status", "transport.connecting", "t
|
|
|
36
36
|
* @property {number} [maxSegmentBytes] - Maximum compressed segment size.
|
|
37
37
|
* @property {number} [maxQueueBytes] - Shared producer/worker allocation bound.
|
|
38
38
|
* @property {number} [criticalReserveBytes] - Queue bytes reserved for critical events.
|
|
39
|
-
* @property {number} [compressionLevel] - Explicit zstd level, default
|
|
39
|
+
* @property {number} [compressionLevel] - Explicit zstd level, default 9. High levels (12+) allocate tens of MB of zstd context memory per compressed batch; see `DEFAULT_LOG_COMPRESSION_LEVEL`.
|
|
40
40
|
* @property {number} [retentionDays] - Closed-segment maximum age, default 30 days.
|
|
41
41
|
* @property {number} [retentionBytes] - Directory quota, default 4 GiB.
|
|
42
42
|
* @property {{write(chunk: string): unknown}} [stderr] - Secret-free health reporting destination.
|
|
@@ -94,7 +94,20 @@ async function acquireRetentionLock(directory, lockPath) {
|
|
|
94
94
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
95
95
|
const created = await createOwnerFile(directory, lockPath, owner)
|
|
96
96
|
if (created !== undefined) return created
|
|
97
|
-
|
|
97
|
+
let existing
|
|
98
|
+
try {
|
|
99
|
+
existing = await readOwnerFile(lockPath)
|
|
100
|
+
} catch (error) {
|
|
101
|
+
// A lock this process cannot read cannot belong to a live peer: peers write
|
|
102
|
+
// their owner record mode 0600 under their own uid into this private
|
|
103
|
+
// directory. Such a file is left behind by a privileged one-off process
|
|
104
|
+
// (a root-run repair or cleanup), and because its record is unreadable here
|
|
105
|
+
// it would block retention for every later run - failing the durable log
|
|
106
|
+
// sink and, with it, the server process that owns the sink.
|
|
107
|
+
if (!isCode(error, "EACCES") && !isCode(error, "EPERM")) throw error
|
|
108
|
+
await reclaimUnreadableLock(directory, lockPath)
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
98
111
|
if (existing === undefined) continue
|
|
99
112
|
if (await ownerIsLive(existing.owner)) return undefined
|
|
100
113
|
const current = await lstat(lockPath).catch((error) => { if (isCode(error, "ENOENT")) return undefined; throw error })
|
|
@@ -106,6 +119,28 @@ async function acquireRetentionLock(directory, lockPath) {
|
|
|
106
119
|
return undefined
|
|
107
120
|
}
|
|
108
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Removes a retention lock that this process cannot read, so the caller can retry
|
|
124
|
+
* the atomic link. Unlink is attempted rather than deferred: the directory is
|
|
125
|
+
* private, and a lock whose owner record is unreadable here cannot be held by a
|
|
126
|
+
* peer of this runtime.
|
|
127
|
+
* @param {string} directory - Private log directory.
|
|
128
|
+
* @param {string} lockPath - Unreadable lock path.
|
|
129
|
+
*/
|
|
130
|
+
async function reclaimUnreadableLock(directory, lockPath) {
|
|
131
|
+
const stats = await lstat(lockPath).catch((error) => { if (isCode(error, "ENOENT")) return undefined; throw error })
|
|
132
|
+
if (stats === undefined) return
|
|
133
|
+
if (!stats.isFile()) throw Object.assign(new Error("Unsafe retention lock"), {code: "LOG_RETENTION_LOCK_INVALID"})
|
|
134
|
+
await unlink(lockPath).catch((error) => {
|
|
135
|
+
if (isCode(error, "ENOENT")) return
|
|
136
|
+
throw Object.assign(
|
|
137
|
+
new Error(`Retention lock ${lockPath} is owned by uid ${stats.uid} and cannot be reclaimed by uid ${process.getuid?.()}: ${error instanceof Error ? error.message : String(error)}`),
|
|
138
|
+
{cause: error, code: "LOG_RETENTION_LOCK_UNREADABLE"}
|
|
139
|
+
)
|
|
140
|
+
})
|
|
141
|
+
await syncDirectory(directory)
|
|
142
|
+
}
|
|
143
|
+
|
|
109
144
|
/**
|
|
110
145
|
* @param {string} directory - Directory.
|
|
111
146
|
* @param {string} lockPath - Destination.
|
|
@@ -2,7 +2,21 @@
|
|
|
2
2
|
import {promisify} from "node:util"
|
|
3
3
|
import {constants, zstdCompress, zstdDecompress} from "node:zlib"
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Default zstd level for per-batch diagnostic log frames.
|
|
7
|
+
*
|
|
8
|
+
* Every batch is compressed as its own frame, and a fresh zstd context's
|
|
9
|
+
* memory scales steeply with the level: level 9 allocates ~1-2 MB per call,
|
|
10
|
+
* level 12 tens of MB, and level 19 roughly 90 MB. Long-lived processes flush
|
|
11
|
+
* log batches continuously, so a high level turns those per-call context
|
|
12
|
+
* allocations into a multi-GB resident/peak-RSS ratchet that no JS-level
|
|
13
|
+
* accounting sees (the memory is freed by the compressor but retained by the
|
|
14
|
+
* allocator while the producing worker's isolate never pressures GC). JSONL
|
|
15
|
+
* diagnostics already compress 50x+ at level 9, and level 19 buys only ~15%
|
|
16
|
+
* smaller frames at ~20x the CPU and ~50x the per-call memory, so the default
|
|
17
|
+
* stays in the bounded-memory range. Raise it per process only deliberately.
|
|
18
|
+
*/
|
|
19
|
+
export const DEFAULT_LOG_COMPRESSION_LEVEL = 9
|
|
6
20
|
|
|
7
21
|
const compress = promisify(zstdCompress)
|
|
8
22
|
const decompress = promisify(zstdDecompress)
|
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import
|
|
1
|
+
import {describe, expect, it, withTestCleanup} from "@velocious/testing"
|
|
2
2
|
import {spawn} from "node:child_process"
|
|
3
3
|
import {mkdtemp, rm} from "node:fs/promises"
|
|
4
4
|
import {createConnection, createServer} from "node:net"
|
|
5
5
|
import {tmpdir} from "node:os"
|
|
6
6
|
import {join} from "node:path"
|
|
7
|
-
import test from "node:test"
|
|
8
7
|
|
|
9
8
|
import {startTcpEdge} from "../../src/edge/tcp-edge.js"
|
|
10
9
|
import {encodeFrame, FrameDecoder} from "../../src/protocol/frame.js"
|
|
@@ -159,104 +158,106 @@ async function stopProcess(child) {
|
|
|
159
158
|
}), PROCESS_TIMEOUT_MS, "Timed out waiting for relay shutdown")
|
|
160
159
|
}
|
|
161
160
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
})
|
|
161
|
+
describe("e2e process gateway", () => {
|
|
162
|
+
it("e2e process gateway forwards edge traffic to an allowlisted target", async () => withTestCleanup(async (after) => {
|
|
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
|
+
})
|
|
200
176
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
})
|
|
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
|
+
})
|
|
215
186
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
187
|
+
after(async () => {
|
|
188
|
+
await edge.close()
|
|
189
|
+
await stopProcess(relay.process)
|
|
190
|
+
await closeServer(targetServer)
|
|
191
|
+
await rm(tempDir, {recursive: true, force: true})
|
|
192
|
+
})
|
|
220
193
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
token: "e2e-token-not-placeholder",
|
|
227
|
-
name: "blocked process gateway target",
|
|
228
|
-
target: {host: "127.0.0.1", port: blockedPort},
|
|
229
|
-
framing: [2]
|
|
194
|
+
const appSocket = createConnection({host: "127.0.0.1", port: edge.port})
|
|
195
|
+
await waitConnect(appSocket)
|
|
196
|
+
appSocket.write("hello-tensorbuzz")
|
|
197
|
+
expect((await readOnce(appSocket)).toString("utf8")).toBe("echo:hello-tensorbuzz")
|
|
198
|
+
appSocket.destroy()
|
|
230
199
|
}))
|
|
231
200
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
const child = spawn(process.execPath, ["src/index.js"], {
|
|
241
|
-
cwd: process.cwd(),
|
|
242
|
-
env: {
|
|
243
|
-
...process.env,
|
|
201
|
+
it("e2e process gateway denies non-allowlisted target requests", async () => withTestCleanup(async (after) => {
|
|
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({
|
|
244
209
|
SOCKETDUCT_MODE: "relay",
|
|
245
210
|
SOCKETDUCT_RELAY_HOST: "127.0.0.1",
|
|
246
211
|
SOCKETDUCT_RELAY_PORT: String(relayPort),
|
|
247
|
-
SOCKETDUCT_AUTH_TOKENS: "tensorbuzz-edge:
|
|
248
|
-
SOCKETDUCT_TARGET_ALLOWLIST:
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
212
|
+
SOCKETDUCT_AUTH_TOKENS: "tensorbuzz-edge:e2e-token-not-placeholder",
|
|
213
|
+
SOCKETDUCT_TARGET_ALLOWLIST: `127.0.0.1:${allowedPort}`
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
after(async () => {
|
|
217
|
+
await stopProcess(relay.process)
|
|
218
|
+
await closeServer(allowedTarget)
|
|
219
|
+
})
|
|
255
220
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
+
expect(frame?.header.type).toBe("error")
|
|
234
|
+
expect(frame?.header.code).toBe("TARGET_UNAUTHORIZED")
|
|
235
|
+
tunnel.destroy()
|
|
236
|
+
}))
|
|
259
237
|
|
|
260
|
-
|
|
261
|
-
|
|
238
|
+
it("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
|
+
expect(exitCode).not.toBe(0)
|
|
261
|
+
expect(output).toMatch(/placeholder tokens/)
|
|
262
|
+
})
|
|
262
263
|
})
|
|
@@ -1,52 +1,53 @@
|
|
|
1
1
|
/* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-param, jsdoc/require-returns */
|
|
2
|
-
import
|
|
2
|
+
import {describe, expect, it, withTestCleanup} from "@velocious/testing"
|
|
3
3
|
import {spawn} from "node:child_process"
|
|
4
4
|
import {mkdtemp, rm} from "node:fs/promises"
|
|
5
5
|
import {createServer} from "node:net"
|
|
6
6
|
import {tmpdir} from "node:os"
|
|
7
7
|
import {join} from "node:path"
|
|
8
|
-
import test from "node:test"
|
|
9
8
|
|
|
10
9
|
import {readCompressedLogDirectory} from "../../src/logging/compressed-log-reader.js"
|
|
11
10
|
|
|
12
11
|
const TIMEOUT_MS = 8_000
|
|
13
12
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
13
|
+
describe("logger shutdown", () => {
|
|
14
|
+
it("SIGTERM durably flushes queued critical lifecycle records before exit", async () => withTestCleanup(async (after) => {
|
|
15
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-logger-shutdown-"))
|
|
16
|
+
const directory = join(root, "logs")
|
|
17
|
+
const port = await reservePort()
|
|
18
|
+
const child = spawn(process.execPath, ["src/index.js"], {
|
|
19
|
+
cwd: process.cwd(),
|
|
20
|
+
env: {
|
|
21
|
+
...process.env,
|
|
22
|
+
SOCKETDUCT_MODE: "relay",
|
|
23
|
+
SOCKETDUCT_RELAY_HOST: "127.0.0.1",
|
|
24
|
+
SOCKETDUCT_RELAY_PORT: String(port),
|
|
25
|
+
SOCKETDUCT_AUTH_TOKENS: "shutdown-test:shutdown-test-token",
|
|
26
|
+
SOCKETDUCT_TARGET_ALLOWLIST: "127.0.0.1:1",
|
|
27
|
+
SOCKETDUCT_STATUS_INTERVAL_MS: "0",
|
|
28
|
+
SOCKETDUCT_LOG_DIRECTORY: directory,
|
|
29
|
+
SOCKETDUCT_LOG_FLUSH_INTERVAL_MS: "60000",
|
|
30
|
+
SOCKETDUCT_LOG_CRITICAL_FLUSH_INTERVAL_MS: "60000"
|
|
31
|
+
},
|
|
32
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
33
|
+
})
|
|
34
|
+
let output = ""
|
|
35
|
+
child.stdout.on("data", (chunk) => { output += chunk.toString() })
|
|
36
|
+
child.stderr.on("data", (chunk) => { output += chunk.toString() })
|
|
37
|
+
after(async () => {
|
|
38
|
+
if (child.exitCode === null) child.kill("SIGKILL")
|
|
39
|
+
await rm(root, {recursive: true, force: true})
|
|
40
|
+
})
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
await waitUntil(() => output.includes('"event":"relay.started"'), child, () => output)
|
|
43
|
+
expect(child.kill("SIGTERM")).toBe(true)
|
|
44
|
+
const exit = await withTimeout(new Promise((resolve) => child.once("exit", (code, signal) => resolve({code, signal}))), "process exit")
|
|
45
|
+
expect(exit).toEqual({code: 0, signal: null})
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
const result = await readCompressedLogDirectory(directory)
|
|
48
|
+
expect(result.incomplete).toBe(false)
|
|
49
|
+
expect(result.records.map((record) => record.event)).toEqual(["process.started", "process.stopped", "log_batch_accepted", "log_frame_committed", "log_closed"])
|
|
50
|
+
}))
|
|
50
51
|
})
|
|
51
52
|
|
|
52
53
|
/** @returns {Promise<number>} A released loopback port. */
|
|
@@ -57,7 +58,9 @@ async function reservePort() {
|
|
|
57
58
|
server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(undefined) })
|
|
58
59
|
})
|
|
59
60
|
const address = server.address()
|
|
60
|
-
|
|
61
|
+
if (address === null || typeof address === "string") {
|
|
62
|
+
throw new Error("Server did not bind to a TCP port")
|
|
63
|
+
}
|
|
61
64
|
await new Promise((resolve) => server.close(() => resolve(undefined)))
|
|
62
65
|
return address.port
|
|
63
66
|
}
|