socketduct 0.1.19 → 0.1.20
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/.env.example +17 -5
- package/README.md +3 -3
- package/changelog.d/20260925170000-disconnect-attribution-compressed-logs.md +1 -0
- package/docs/docker.md +5 -4
- package/docs/logging.md +131 -159
- package/docs/protocol.md +27 -4
- package/docs/reverse-gateway.md +34 -0
- package/docs/running.md +14 -11
- package/docs/security.md +7 -3
- package/package.json +5 -1
- package/scripts/verify-zstd-interoperability.mjs +41 -0
- package/src/cli/socketduct.js +143 -0
- package/src/client/virtual-socket.js +10 -0
- package/src/config/environment.js +56 -7
- package/src/index.js +64 -26
- package/src/logging/compressed-log-reader.js +256 -0
- package/src/logging/compressed-log-worker.js +430 -0
- package/src/logging/compressed-log-writer.js +538 -0
- package/src/logging/log-batch.js +72 -0
- package/src/logging/log-retention.js +241 -0
- package/src/logging/log-segment-index.js +176 -0
- package/src/logging/structured-logger.js +136 -30
- package/src/logging/zstd-frame-codec.js +73 -0
- package/src/reverse/client.js +71 -11
- package/src/reverse/disconnect-attribution.js +191 -0
- package/src/reverse/gateway.js +58 -8
- package/src/reverse/hub.js +215 -32
- package/src/reverse/runtime.js +5 -3
- package/tensorbuzz.yml +2 -1
- package/test/e2e/logger-shutdown-process.test.js +86 -0
- package/test/e2e/reverse-gateway-process.test.js +15 -14
- package/test/integration/logging-cli.test.js +168 -0
- package/test/integration/logging-compressed-recovery.test.js +186 -0
- package/test/integration/reverse-gateway.test.js +176 -0
- package/test/integration/reverse-route-class.test.js +6 -1
- package/test/unit/config/docker-gateway-files.test.js +2 -0
- package/test/unit/config/environment.test.js +54 -14
- package/test/unit/index.test.js +3 -0
- package/test/unit/logging/compressed-log-worker.test.js +29 -0
- package/test/unit/logging/compressed-log-writer.test.js +183 -0
- package/test/unit/logging/log-retention.test.js +86 -0
- package/test/unit/logging/log-segment-index.test.js +104 -0
- package/test/unit/logging/structured-logger.test.js +14 -1
- package/test/unit/logging/zstd-frame-codec.test.js +24 -0
- package/test/unit/reverse/disconnect-attribution.test.js +82 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-param, jsdoc/require-returns */
|
|
2
|
+
import assert from "node:assert/strict"
|
|
3
|
+
import {execFile, spawn} from "node:child_process"
|
|
4
|
+
import {mkdtemp, readFile, readdir, rm, utimes, writeFile} from "node:fs/promises"
|
|
5
|
+
import {tmpdir} from "node:os"
|
|
6
|
+
import {join} from "node:path"
|
|
7
|
+
import test from "node:test"
|
|
8
|
+
import {promisify} from "node:util"
|
|
9
|
+
|
|
10
|
+
import {createCompressedJsonlFileLogger} from "../../src/logging/compressed-log-writer.js"
|
|
11
|
+
import {CompressedLogTailReader} from "../../src/logging/compressed-log-reader.js"
|
|
12
|
+
import {enforceLogRetention} from "../../src/logging/log-retention.js"
|
|
13
|
+
|
|
14
|
+
const run = promisify(execFile)
|
|
15
|
+
const cli = new URL("../../src/cli/socketduct.js", import.meta.url)
|
|
16
|
+
|
|
17
|
+
test("owned CLI searches and tails durable compressed records with stable exits", async (t) => {
|
|
18
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-logs-cli-"))
|
|
19
|
+
const directory = join(root, "logs")
|
|
20
|
+
t.after(() => rm(root, {recursive: true, force: true}))
|
|
21
|
+
const logger = createCompressedJsonlFileLogger({directory, role: "reverse-hub"})
|
|
22
|
+
logger.log("process.started", {runtimeMode: "reverse-hub"})
|
|
23
|
+
logger.log("runtime.status", {virtualStreams: 7})
|
|
24
|
+
await logger.close()
|
|
25
|
+
|
|
26
|
+
const match = await invoke("logs", "search", "--directory", directory, "--event", "runtime.status")
|
|
27
|
+
assert.equal(match.code, 0)
|
|
28
|
+
assert.deepEqual(match.stdout.trim().split("\n").map((line) => JSON.parse(line).event), ["runtime.status"])
|
|
29
|
+
|
|
30
|
+
const absent = await invoke("logs", "search", "--directory", directory, "--event", "transport.failed")
|
|
31
|
+
assert.equal(absent.code, 1)
|
|
32
|
+
assert.equal(absent.stdout, "")
|
|
33
|
+
|
|
34
|
+
const tail = await invoke("logs", "tail", "--directory", directory)
|
|
35
|
+
assert.equal(tail.code, 0)
|
|
36
|
+
assert.deepEqual(tail.stdout.trim().split("\n").map((line) => JSON.parse(line).event), ["process.started", "runtime.status", "log_batch_accepted", "log_frame_committed", "log_closed"])
|
|
37
|
+
|
|
38
|
+
const invalid = await invoke("logs", "search", "--directory", directory, "--client-generation", "not-a-number")
|
|
39
|
+
assert.equal(invalid.code, 2)
|
|
40
|
+
assert.match(invalid.stderr, /Socketduct logs error/)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test("owned CLI follow stays live and emits newly durable writer records", async (t) => {
|
|
44
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-logs-follow-"))
|
|
45
|
+
const directory = join(root, "logs")
|
|
46
|
+
t.after(() => rm(root, {recursive: true, force: true}))
|
|
47
|
+
const initial = createCompressedJsonlFileLogger({directory, role: "reverse-client"})
|
|
48
|
+
initial.log("process.started", {runtimeMode: "reverse-client"})
|
|
49
|
+
await initial.close()
|
|
50
|
+
|
|
51
|
+
const child = spawn(process.execPath, [cli.pathname, "logs", "tail", "--directory", directory, "--follow", "--poll-ms", "10"], {stdio: ["ignore", "pipe", "pipe"]})
|
|
52
|
+
let stdout = ""
|
|
53
|
+
let stderr = ""
|
|
54
|
+
child.stdout.on("data", (chunk) => { stdout += chunk.toString() })
|
|
55
|
+
child.stderr.on("data", (chunk) => { stderr += chunk.toString() })
|
|
56
|
+
t.after(() => { if (child.exitCode === null) child.kill("SIGKILL") })
|
|
57
|
+
await waitFor(() => stdout.includes('"eventSequence":"1"'), child, () => stderr)
|
|
58
|
+
|
|
59
|
+
const later = createCompressedJsonlFileLogger({directory, role: "reverse-client"})
|
|
60
|
+
later.log("runtime.status", {virtualStreams: 2})
|
|
61
|
+
await later.close()
|
|
62
|
+
await waitFor(() => stdout.includes('"event":"runtime.status"'), child, () => stderr)
|
|
63
|
+
child.kill("SIGTERM")
|
|
64
|
+
const code = await new Promise((resolve) => child.once("exit", resolve))
|
|
65
|
+
assert.equal(code, 0)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test("owned CLI exits 2 instead of hiding corrupt interior evidence", async (t) => {
|
|
69
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-logs-corrupt-"))
|
|
70
|
+
const directory = join(root, "logs")
|
|
71
|
+
t.after(() => rm(root, {recursive: true, force: true}))
|
|
72
|
+
const logger = createCompressedJsonlFileLogger({directory, role: "reverse-gateway"})
|
|
73
|
+
logger.log("process.started", {runtimeMode: "reverse-gateway"})
|
|
74
|
+
await logger.close()
|
|
75
|
+
const name = (await readdir(directory)).find((entry) => entry.endsWith(".jsonl.zst"))
|
|
76
|
+
assert(name !== undefined)
|
|
77
|
+
const path = join(directory, name)
|
|
78
|
+
const bytes = await readFile(path)
|
|
79
|
+
bytes[bytes.length - 1] = /** @type {number} */ (bytes[bytes.length - 1]) ^ 0xff
|
|
80
|
+
await writeFile(path, bytes)
|
|
81
|
+
|
|
82
|
+
const result = await invoke("logs", "search", "--directory", directory, "--event", "process.started")
|
|
83
|
+
assert.equal(result.code, 2)
|
|
84
|
+
assert.equal(result.stdout, "")
|
|
85
|
+
assert.match(result.stderr, /Socketduct log evidence error/)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test("tail reader incrementally follows rotation and prunes retention-deleted cursors", async (t) => {
|
|
89
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-logs-incremental-tail-"))
|
|
90
|
+
const directory = join(root, "logs")
|
|
91
|
+
t.after(() => rm(root, {recursive: true, force: true}))
|
|
92
|
+
const logger = createCompressedJsonlFileLogger({directory, role: "reverse-hub", frameMaxBytes: 256, maxSegmentBytes: 1024, compressionLevel: 1})
|
|
93
|
+
await logger.flush()
|
|
94
|
+
for (let index = 0; index < 8; index += 1) {
|
|
95
|
+
logger.log("process.started", {runtimeMode: "reverse-hub"})
|
|
96
|
+
await logger.flush()
|
|
97
|
+
}
|
|
98
|
+
await logger.flush()
|
|
99
|
+
|
|
100
|
+
const reader = new CompressedLogTailReader(directory)
|
|
101
|
+
const initial = await reader.poll()
|
|
102
|
+
assert(initial.records.length > 0)
|
|
103
|
+
const identities = new Set(initial.records.map((record) => `${record.writerEpoch}\0${record.eventSequence}`))
|
|
104
|
+
assert.equal(identities.size, initial.records.length)
|
|
105
|
+
const decoded = reader.status().decodedFrames
|
|
106
|
+
assert(decoded > 0)
|
|
107
|
+
assert.deepEqual((await reader.poll()).records, [])
|
|
108
|
+
assert.equal(reader.status().decodedFrames, decoded)
|
|
109
|
+
|
|
110
|
+
logger.log("process.started", {runtimeMode: "reverse-hub"})
|
|
111
|
+
await logger.flush()
|
|
112
|
+
await logger.flush()
|
|
113
|
+
const activeDelta = await reader.poll()
|
|
114
|
+
assert(activeDelta.records.length > 0)
|
|
115
|
+
for (const record of activeDelta.records) assert.equal(identities.has(`${record.writerEpoch}\0${record.eventSequence}`), false)
|
|
116
|
+
await logger.close()
|
|
117
|
+
const closeDelta = await reader.poll()
|
|
118
|
+
assert(closeDelta.records.length > 0)
|
|
119
|
+
for (const record of closeDelta.records) assert.equal(identities.has(`${record.writerEpoch}\0${record.eventSequence}`), false)
|
|
120
|
+
|
|
121
|
+
const oldSegments = (await readdir(directory)).filter((name) => name.endsWith(".jsonl.zst"))
|
|
122
|
+
assert(oldSegments.length > 1, "fixture must rotate through more than one segment")
|
|
123
|
+
for (const name of oldSegments) await utimes(join(directory, name), new Date(1_000), new Date(1_000))
|
|
124
|
+
const later = createCompressedJsonlFileLogger({directory, role: "reverse-hub", compressionLevel: 1})
|
|
125
|
+
later.log("runtime.status", {virtualStreams: 2})
|
|
126
|
+
await later.close()
|
|
127
|
+
const laterSegments = (await readdir(directory)).filter((name) => name.endsWith(".jsonl.zst") && !oldSegments.includes(name))
|
|
128
|
+
for (const name of laterSegments) await utimes(join(directory, name), new Date(10_000), new Date(10_000))
|
|
129
|
+
const laterDelta = await reader.poll()
|
|
130
|
+
assert(laterDelta.records.some((record) => record.event === "runtime.status"))
|
|
131
|
+
|
|
132
|
+
await enforceLogRetention({directory, maxAgeMs: 5_000, maxBytes: 1_000_000, now: () => new Date(10_000)})
|
|
133
|
+
const beforePruneDecode = reader.status().decodedFrames
|
|
134
|
+
assert.deepEqual((await reader.poll()).records, [])
|
|
135
|
+
const survivingSegments = (await readdir(directory)).filter((name) => name.endsWith(".jsonl.zst")).length
|
|
136
|
+
assert(reader.status().trackedSegments <= survivingSegments)
|
|
137
|
+
assert.equal(reader.status().decodedFrames, beforePruneDecode)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
/** @param {...string} args - CLI arguments. */
|
|
141
|
+
async function invoke(...args) {
|
|
142
|
+
try {
|
|
143
|
+
const result = await run(process.execPath, [cli.pathname, ...args])
|
|
144
|
+
return {code: 0, stdout: result.stdout, stderr: result.stderr}
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (error === null || typeof error !== "object") throw error
|
|
147
|
+
return {
|
|
148
|
+
code: /** @type {{code?: number}} */ (error).code,
|
|
149
|
+
stdout: /** @type {{stdout?: string}} */ (error).stdout ?? "",
|
|
150
|
+
stderr: /** @type {{stderr?: string}} */ (error).stderr ?? ""
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** @param {() => boolean} predicate - Completion check. @param {import('node:child_process').ChildProcess} child - CLI process. @param {() => string} stderr - Error output. */
|
|
156
|
+
async function waitFor(predicate, child, stderr) {
|
|
157
|
+
if (predicate()) return
|
|
158
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
159
|
+
let timer
|
|
160
|
+
await new Promise((resolve, reject) => {
|
|
161
|
+
const cleanup = () => { if (timer !== undefined) clearTimeout(timer); child.stdout?.off("data", onData); child.off("exit", onExit) }
|
|
162
|
+
const onData = () => { if (predicate()) { cleanup(); resolve(undefined) } }
|
|
163
|
+
const onExit = (/** @type {number | null} */ code) => { cleanup(); reject(new Error(`CLI exited ${code}: ${stderr()}`)) }
|
|
164
|
+
timer = setTimeout(() => { cleanup(); reject(new Error(`Timed out waiting for CLI output: ${stderr()}`)) }, 5_000)
|
|
165
|
+
child.stdout?.on("data", onData)
|
|
166
|
+
child.once("exit", onExit)
|
|
167
|
+
})
|
|
168
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-param-type, jsdoc/require-returns */
|
|
2
|
+
import assert from "node:assert/strict"
|
|
3
|
+
import {createHash, randomBytes} from "node:crypto"
|
|
4
|
+
import {mkdtemp, readdir, rm, stat, utimes, writeFile} from "node:fs/promises"
|
|
5
|
+
import {tmpdir} from "node:os"
|
|
6
|
+
import {join} from "node:path"
|
|
7
|
+
import test from "node:test"
|
|
8
|
+
|
|
9
|
+
import {createLogBatchId, inspectLogBatch} from "../../src/logging/log-batch.js"
|
|
10
|
+
import {readCompressedLogDirectory} from "../../src/logging/compressed-log-reader.js"
|
|
11
|
+
import {CompressedLogWorkerSink} from "../../src/logging/compressed-log-worker.js"
|
|
12
|
+
import {enforceLogRetention} from "../../src/logging/log-retention.js"
|
|
13
|
+
import {createIndexEntry, createIndexHeader, INDEX_ENTRY_BYTES, INDEX_HEADER_BYTES, uuidBytes} from "../../src/logging/log-segment-index.js"
|
|
14
|
+
import {compressLogFrame} from "../../src/logging/zstd-frame-codec.js"
|
|
15
|
+
|
|
16
|
+
test("recovery indexes a complete data-synced orphan without duplicating its batch", async (t) => {
|
|
17
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-compressed-orphan-"))
|
|
18
|
+
t.after(() => rm(root, {recursive: true, force: true}))
|
|
19
|
+
const fixture = await createRecoveryFixture(root, false)
|
|
20
|
+
const sink = new CompressedLogWorkerSink(fixture.options)
|
|
21
|
+
const recovered = await sink.start()
|
|
22
|
+
assert.equal(recovered.durableHighWater, "2")
|
|
23
|
+
assert.equal(recovered.orphanRecoveredCount, 1)
|
|
24
|
+
assert.equal(recovered.lastDurableBatch?.batchId, fixture.secondBatchId)
|
|
25
|
+
assert.equal((await stat(fixture.indexPath)).size, INDEX_HEADER_BYTES + 2 * INDEX_ENTRY_BYTES)
|
|
26
|
+
|
|
27
|
+
const duplicate = await sink.appendBatch(fixture.secondMessage)
|
|
28
|
+
assert.equal(duplicate.duplicate, true)
|
|
29
|
+
assert.equal((await stat(fixture.dataPath)).size, fixture.firstFrame.length + fixture.secondFrame.length)
|
|
30
|
+
await sink.close()
|
|
31
|
+
const result = await readCompressedLogDirectory(root)
|
|
32
|
+
assert.equal(result.incomplete, false)
|
|
33
|
+
assert.deepEqual(result.records.map((record) => record.eventSequence), ["1", "2"])
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test("recovery truncates only a torn final data frame and accepts its replay", async (t) => {
|
|
37
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-compressed-torn-"))
|
|
38
|
+
t.after(() => rm(root, {recursive: true, force: true}))
|
|
39
|
+
const fixture = await createRecoveryFixture(root, true)
|
|
40
|
+
const sink = new CompressedLogWorkerSink(fixture.options)
|
|
41
|
+
const recovered = await sink.start()
|
|
42
|
+
assert.equal(recovered.durableHighWater, "1")
|
|
43
|
+
assert.equal((await stat(fixture.dataPath)).size, fixture.firstFrame.length)
|
|
44
|
+
|
|
45
|
+
const replayed = await sink.appendBatch(fixture.secondMessage)
|
|
46
|
+
assert.equal(replayed.duplicate, false)
|
|
47
|
+
await sink.close()
|
|
48
|
+
const result = await readCompressedLogDirectory(root)
|
|
49
|
+
assert.equal(result.incomplete, false)
|
|
50
|
+
assert.deepEqual(result.records.map((record) => record.eventSequence), ["1", "2"])
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test("worker acknowledgements retain only the latest durable batch identity", async (t) => {
|
|
54
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-compressed-bounded-ack-"))
|
|
55
|
+
t.after(() => rm(root, {recursive: true, force: true}))
|
|
56
|
+
const writerEpoch = randomBytes(16)
|
|
57
|
+
const options = {directory: root, role: "reverse-hub", writerEpoch: writerEpoch.toString("base64url"), maxSegmentBytes: 4096, compressionLevel: 19}
|
|
58
|
+
const sink = new CompressedLogWorkerSink(options)
|
|
59
|
+
await sink.start()
|
|
60
|
+
let latest = await sink.appendBatch(batchMessage(writerEpoch, 1))
|
|
61
|
+
for (let sequence = 2; sequence <= 40; sequence += 1) latest = await sink.appendBatch(batchMessage(writerEpoch, sequence))
|
|
62
|
+
assert.equal(Object.hasOwn(latest, "durableBatchIds"), false)
|
|
63
|
+
assert.deepEqual(latest.lastDurableBatch, {
|
|
64
|
+
batchId: batchMessage(writerEpoch, 40).batchId,
|
|
65
|
+
firstSequence: "40",
|
|
66
|
+
lastSequence: "40"
|
|
67
|
+
})
|
|
68
|
+
assert.equal((await sink.appendBatch(batchMessage(writerEpoch, 40))).duplicate, true)
|
|
69
|
+
await sink.close()
|
|
70
|
+
|
|
71
|
+
const restarted = new CompressedLogWorkerSink(options)
|
|
72
|
+
const recovered = await restarted.start()
|
|
73
|
+
assert.equal(Object.hasOwn(recovered, "durableBatchIds"), false)
|
|
74
|
+
assert.deepEqual(recovered.lastDurableBatch, latest.lastDurableBatch)
|
|
75
|
+
await restarted.close()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test("recovery accepts a retention-deleted prefix but rejects interior gaps", async (t) => {
|
|
79
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-compressed-retained-prefix-"))
|
|
80
|
+
t.after(() => rm(root, {recursive: true, force: true}))
|
|
81
|
+
const writerEpoch = randomBytes(16)
|
|
82
|
+
const options = {directory: root, role: "reverse-hub", writerEpoch: writerEpoch.toString("base64url"), maxSegmentBytes: 4096, compressionLevel: 19, retentionBytes: 1_000_000}
|
|
83
|
+
|
|
84
|
+
const segments = []
|
|
85
|
+
for (let sequence = 1; sequence <= 3; sequence += 1) {
|
|
86
|
+
const sink = new CompressedLogWorkerSink(options)
|
|
87
|
+
await sink.start()
|
|
88
|
+
await sink.appendBatch(batchMessage(writerEpoch, sequence))
|
|
89
|
+
segments.push(/** @type {string} */ (sink.status().segmentPath))
|
|
90
|
+
await sink.close()
|
|
91
|
+
}
|
|
92
|
+
assert.equal(segments.length, 3)
|
|
93
|
+
for (let index = 0; index < segments.length; index += 1) {
|
|
94
|
+
await utimes(/** @type {string} */ (segments[index]), new Date(1_000 + index), new Date(1_000 + index))
|
|
95
|
+
}
|
|
96
|
+
await enforceLogRetention({directory: root, maxAgeMs: 1, maxBytes: 1_000_000, now: () => new Date(1_003)})
|
|
97
|
+
assert.equal((await readdir(root)).filter((name) => name.endsWith(".jsonl.zst")).length, 1)
|
|
98
|
+
|
|
99
|
+
const recovered = new CompressedLogWorkerSink(options)
|
|
100
|
+
assert.equal((await recovered.start()).durableHighWater, "3")
|
|
101
|
+
assert.equal((await recovered.appendBatch(batchMessage(writerEpoch, 4))).durableHighWater, "4")
|
|
102
|
+
await recovered.close()
|
|
103
|
+
|
|
104
|
+
const gapDirectory = join(root, "interior-gap")
|
|
105
|
+
const gapEpoch = randomBytes(16)
|
|
106
|
+
const gapOptions = {...options, directory: gapDirectory, writerEpoch: gapEpoch.toString("base64url")}
|
|
107
|
+
const gapSegments = []
|
|
108
|
+
for (let sequence = 1; sequence <= 3; sequence += 1) {
|
|
109
|
+
const sink = new CompressedLogWorkerSink(gapOptions)
|
|
110
|
+
await sink.start()
|
|
111
|
+
await sink.appendBatch(batchMessage(gapEpoch, sequence))
|
|
112
|
+
gapSegments.push(/** @type {string} */ (sink.status().segmentPath))
|
|
113
|
+
await sink.close()
|
|
114
|
+
}
|
|
115
|
+
const missing = /** @type {string} */ (gapSegments[1])
|
|
116
|
+
await Promise.all([rm(missing), rm(`${missing}.idx`), rm(`${missing}.sealed`)])
|
|
117
|
+
await assert.rejects(() => new CompressedLogWorkerSink(gapOptions).start(), /interior sequence gap/)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* @param {string} directory - Fixture directory. @param {boolean} torn - Whether to tear the orphan frame.
|
|
122
|
+
* @param torn
|
|
123
|
+
*/
|
|
124
|
+
async function createRecoveryFixture(directory, torn) {
|
|
125
|
+
const writerEpoch = randomBytes(16)
|
|
126
|
+
const writerEpochText = writerEpoch.toString("base64url")
|
|
127
|
+
const segmentUuidText = "123e4567-e89b-42d3-a456-426614174000"
|
|
128
|
+
const segmentUuid = uuidBytes(segmentUuidText)
|
|
129
|
+
const stem = `reverse-hub-${writerEpochText}-${segmentUuidText}.jsonl.zst`
|
|
130
|
+
const dataPath = join(directory, stem)
|
|
131
|
+
const indexPath = `${dataPath}.idx`
|
|
132
|
+
const firstRaw = record(writerEpochText, 1, "process.started")
|
|
133
|
+
const secondRaw = record(writerEpochText, 2, "runtime.status")
|
|
134
|
+
const firstFrame = await compressLogFrame(firstRaw)
|
|
135
|
+
const secondFrame = await compressLogFrame(secondRaw)
|
|
136
|
+
const first = inspectLogBatch(firstRaw, writerEpoch)
|
|
137
|
+
const firstEntry = createIndexEntry({
|
|
138
|
+
segmentUuid,
|
|
139
|
+
writerEpoch,
|
|
140
|
+
batchId: first.batchId,
|
|
141
|
+
firstSequence: first.firstSequence,
|
|
142
|
+
lastSequence: first.lastSequence,
|
|
143
|
+
dataOffset: 0n,
|
|
144
|
+
compressedLength: BigInt(firstFrame.length),
|
|
145
|
+
uncompressedLength: BigInt(firstRaw.length),
|
|
146
|
+
recordCount: 1,
|
|
147
|
+
minTimestamp: first.minTimestamp,
|
|
148
|
+
maxTimestamp: first.maxTimestamp,
|
|
149
|
+
frameSha256: createHash("sha256").update(firstFrame).digest()
|
|
150
|
+
})
|
|
151
|
+
await writeFile(dataPath, Buffer.concat([firstFrame, torn ? secondFrame.subarray(0, secondFrame.length - 3) : secondFrame]), {mode: 0o600})
|
|
152
|
+
await writeFile(indexPath, Buffer.concat([createIndexHeader({segmentUuid, writerEpoch}), firstEntry]), {mode: 0o600})
|
|
153
|
+
const secondBatchId = createLogBatchId(writerEpoch, 2n, 2n, secondRaw).toString("base64url")
|
|
154
|
+
return {
|
|
155
|
+
options: {directory, role: "reverse-hub", writerEpoch: writerEpochText, maxSegmentBytes: 4096, compressionLevel: 19},
|
|
156
|
+
dataPath,
|
|
157
|
+
indexPath,
|
|
158
|
+
firstFrame,
|
|
159
|
+
secondFrame,
|
|
160
|
+
secondBatchId,
|
|
161
|
+
secondMessage: {batchId: secondBatchId, firstSequence: "2", lastSequence: "2", jsonl: secondRaw}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* @param {string} writerEpoch - Epoch. @param {number} sequence - Event sequence. @param {string} event - Event name.
|
|
167
|
+
* @param sequence
|
|
168
|
+
* @param event
|
|
169
|
+
*/
|
|
170
|
+
function record(writerEpoch, sequence, event) {
|
|
171
|
+
return Buffer.from(`${JSON.stringify({timestamp: new Date(1_795_000_000_000 + sequence).toISOString(), event, processId: 1, role: "reverse-hub", writerEpoch, eventSequence: String(sequence)})}\n`)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* @param {Buffer} writerEpoch - Epoch.
|
|
176
|
+
* @param {number} sequence - Event sequence.
|
|
177
|
+
*/
|
|
178
|
+
function batchMessage(writerEpoch, sequence) {
|
|
179
|
+
const jsonl = record(writerEpoch.toString("base64url"), sequence, "runtime.status")
|
|
180
|
+
return {
|
|
181
|
+
batchId: createLogBatchId(writerEpoch, BigInt(sequence), BigInt(sequence), jsonl).toString("base64url"),
|
|
182
|
+
firstSequence: String(sequence),
|
|
183
|
+
lastSequence: String(sequence),
|
|
184
|
+
jsonl
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -307,6 +307,45 @@ test("reverse client contains a TLS AUTH write race inside the transport", async
|
|
|
307
307
|
await assert.rejects(connecting, /closed/i)
|
|
308
308
|
})
|
|
309
309
|
|
|
310
|
+
test("fatal hub GOAWAY preserves validated attribution on the public client error", async (t) => {
|
|
311
|
+
/** @type {AuthWriteSocket | undefined} */
|
|
312
|
+
let physical
|
|
313
|
+
const client = new ReverseGatewayConnection({
|
|
314
|
+
gatewayId: "o2",
|
|
315
|
+
connectionId: "fatal_goaway_attribution_client",
|
|
316
|
+
hub: {host: "127.0.0.1", port: 3100, token: "client-token"},
|
|
317
|
+
tls: {servername: "localhost"},
|
|
318
|
+
continuousReconnect: false,
|
|
319
|
+
createPhysicalSocket: () => {
|
|
320
|
+
physical = new AuthWriteSocket()
|
|
321
|
+
return /** @type {import('node:tls').TLSSocket} */ (/** @type {unknown} */ (physical))
|
|
322
|
+
}
|
|
323
|
+
})
|
|
324
|
+
t.after(() => client.close())
|
|
325
|
+
const connecting = client.connect()
|
|
326
|
+
physical?.emit("secureConnect")
|
|
327
|
+
physical?.emit("data", encodeV3Frame({type: V3FrameType.GOAWAY, streamId: 0, sequence: 0}, encodeV3Control({
|
|
328
|
+
code: "PROTOCOL_VIOLATION",
|
|
329
|
+
disconnectIncidentId: "AAAAAAAAAAAAAAAAAAAAAA",
|
|
330
|
+
disconnectReason: "protocol_violation",
|
|
331
|
+
incidentOrigin: "reverse_hub",
|
|
332
|
+
physicalRole: "client",
|
|
333
|
+
clientConnectionGeneration: 1,
|
|
334
|
+
fanOutCount: 0,
|
|
335
|
+
resumeSuccessCount: 0,
|
|
336
|
+
terminalStreamCount: 0
|
|
337
|
+
})))
|
|
338
|
+
/** @type {Error & Record<string, unknown> | undefined} */
|
|
339
|
+
let rejection
|
|
340
|
+
await assert.rejects(withTimeout(connecting, "fatal attributed GOAWAY"), (error) => {
|
|
341
|
+
rejection = /** @type {Error & Record<string, unknown>} */ (error)
|
|
342
|
+
return rejection.code === "PROTOCOL_VIOLATION"
|
|
343
|
+
})
|
|
344
|
+
assert.equal(rejection?.disconnectIncidentId, "AAAAAAAAAAAAAAAAAAAAAA")
|
|
345
|
+
assert.equal(rejection?.disconnectReason, "protocol_violation")
|
|
346
|
+
assert.equal(rejection?.incidentOrigin, "reverse_hub")
|
|
347
|
+
})
|
|
348
|
+
|
|
310
349
|
test("recovered reverse clients require explicit cold durable identity before any physical attempt", async (t) => {
|
|
311
350
|
const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-recovery-contract-"))
|
|
312
351
|
t.after(() => rm(root, {recursive: true, force: true}))
|
|
@@ -2168,6 +2207,143 @@ test("an expired reverse resume window fails the logical stream once and release
|
|
|
2168
2207
|
assert.equal(targets[0]?.destroyed, true)
|
|
2169
2208
|
})
|
|
2170
2209
|
|
|
2210
|
+
test("one gateway disconnect incident preserves physical cause across fan-out and terminal resume errors", async (t) => {
|
|
2211
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-attribution-"))
|
|
2212
|
+
t.after(() => rm(root, {recursive: true, force: true}))
|
|
2213
|
+
const certificates = generateTestCertificates(root)
|
|
2214
|
+
const tokens = new StaticTokenStore([{username: "home", token: "gateway-attribution-token"}, {username: "app", token: "client-attribution-token"}])
|
|
2215
|
+
/** @type {Array<Record<string, unknown> & {event: string}>} */
|
|
2216
|
+
const hubEvents = []
|
|
2217
|
+
/** @type {Array<Record<string, unknown> & {event: string}>} */
|
|
2218
|
+
const gatewayEvents = []
|
|
2219
|
+
/** @type {Array<Record<string, unknown> & {event: string}>} */
|
|
2220
|
+
const clientEvents = []
|
|
2221
|
+
const hub = await startReverseGatewayHub({
|
|
2222
|
+
tokens,
|
|
2223
|
+
tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile},
|
|
2224
|
+
authorizeGateway: () => true,
|
|
2225
|
+
authorizeClientGateway: () => true,
|
|
2226
|
+
authorizeOpen: () => true,
|
|
2227
|
+
resumeWindowMs: 40,
|
|
2228
|
+
logger: {log: (/** @type {string} */ event, /** @type {Record<string, unknown>} */ fields = {}) => { hubEvents.push({event, ...fields}) }},
|
|
2229
|
+
connectionFingerprint: {key: Buffer.alloc(32, 0x5a), keyEpoch: "test-epoch"}
|
|
2230
|
+
})
|
|
2231
|
+
t.after(() => hub.close())
|
|
2232
|
+
const tls = {caFile: certificates.caCertFile, servername: "localhost"}
|
|
2233
|
+
const targets = []
|
|
2234
|
+
const gateway = new OutboundReverseGateway({
|
|
2235
|
+
gatewayId: "o2",
|
|
2236
|
+
connectionId: "low_entropy_gateway_connection",
|
|
2237
|
+
hub: {host: "127.0.0.1", port: hub.port, token: "gateway-attribution-token"},
|
|
2238
|
+
tls,
|
|
2239
|
+
authorizeTarget: () => true,
|
|
2240
|
+
reconnectDelayMs: 150,
|
|
2241
|
+
maxReconnectDelayMs: 150,
|
|
2242
|
+
logger: {log: (/** @type {string} */ event, /** @type {Record<string, unknown>} */ fields = {}) => { gatewayEvents.push({event, ...fields}) }},
|
|
2243
|
+
createTargetConnection: () => {
|
|
2244
|
+
const target = new InterruptibleTarget()
|
|
2245
|
+
targets.push(target)
|
|
2246
|
+
return /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (target))
|
|
2247
|
+
}
|
|
2248
|
+
})
|
|
2249
|
+
t.after(() => gateway.close())
|
|
2250
|
+
await gateway.start()
|
|
2251
|
+
const client = new ReverseGatewayConnection({
|
|
2252
|
+
gatewayId: "o2",
|
|
2253
|
+
connectionId: "low_entropy_client_connection",
|
|
2254
|
+
hub: {host: "127.0.0.1", port: hub.port, token: "client-attribution-token"},
|
|
2255
|
+
tls,
|
|
2256
|
+
reconnectDelayMs: 5,
|
|
2257
|
+
maxReconnectDelayMs: 10,
|
|
2258
|
+
logger: {log: (/** @type {string} */ event, /** @type {Record<string, unknown>} */ fields = {}) => { clientEvents.push({event, ...fields}) }}
|
|
2259
|
+
})
|
|
2260
|
+
t.after(() => client.close())
|
|
2261
|
+
await client.connect()
|
|
2262
|
+
const sockets = [client.createConnection({host: "target", port: 2375}), client.createConnection({host: "target", port: 2375})]
|
|
2263
|
+
await Promise.all(sockets.map((socket) => onceEvent(socket, "connect")))
|
|
2264
|
+
const terminalErrors = sockets.map((socket) => nextError(socket))
|
|
2265
|
+
|
|
2266
|
+
await hub.closeActiveGateway("o2")
|
|
2267
|
+
const errors = /** @type {Array<Error & Record<string, unknown>>} */ (await Promise.all(terminalErrors))
|
|
2268
|
+
assert.deepEqual(errors.map((error) => error.code), ["RESUME_WINDOW_EXPIRED", "RESUME_WINDOW_EXPIRED"])
|
|
2269
|
+
assert.equal(new Set(errors.map((error) => error.disconnectIncidentId)).size, 1)
|
|
2270
|
+
for (const error of errors) {
|
|
2271
|
+
assert.match(/** @type {string} */ (error.disconnectIncidentId), /^[A-Za-z0-9_-]{22}$/)
|
|
2272
|
+
assert.equal(error.disconnectReason, "unknown_transport_close")
|
|
2273
|
+
assert.equal(error.incidentOrigin, "reverse_hub")
|
|
2274
|
+
assert.equal(error.physicalRole, "gateway")
|
|
2275
|
+
assert.equal(typeof error.clientConnectionGeneration, "number")
|
|
2276
|
+
assert.equal(typeof error.gatewayConnectionGeneration, "number")
|
|
2277
|
+
assert.equal(error.fanOutCount, 2)
|
|
2278
|
+
}
|
|
2279
|
+
const detached = hubEvents.find(({event, physicalRole}) => event === "reverse_hub.physical_detached" && physicalRole === "gateway")
|
|
2280
|
+
assert(detached !== undefined)
|
|
2281
|
+
assert.equal(detached.disconnectIncidentId, /** @type {Error & Record<string, unknown>} */ (errors[0]).disconnectIncidentId)
|
|
2282
|
+
assert.match(/** @type {string} */ (detached.connectionFingerprint), /^[a-f0-9]{32}$/)
|
|
2283
|
+
assert.equal(detached.fingerprintKeyEpoch, "test-epoch")
|
|
2284
|
+
assert(hubEvents.some(({event, fanOutCount}) => event === "reverse_hub.streams_interrupted" && fanOutCount === 2))
|
|
2285
|
+
assert(hubEvents.some(({event, terminalStreamCount}) => event === "reverse_hub.streams_reset" && terminalStreamCount === 2))
|
|
2286
|
+
assert(clientEvents.some(({event}) => event === "reverse_client.physical_detached"))
|
|
2287
|
+
assert(gatewayEvents.some(({event}) => event === "reverse_gateway.physical_detached"))
|
|
2288
|
+
assert.equal(targets.length, 2)
|
|
2289
|
+
assert.doesNotMatch(JSON.stringify([hubEvents, gatewayEvents, clientEvents]), /low_entropy_(?:gateway|client)_connection|attribution-token/)
|
|
2290
|
+
})
|
|
2291
|
+
|
|
2292
|
+
test("peer GOAWAY classification and hub attribution reach terminal non-resumable RESET", async (t) => {
|
|
2293
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-terminal-attribution-"))
|
|
2294
|
+
t.after(() => rm(root, {recursive: true, force: true}))
|
|
2295
|
+
const certificates = generateTestCertificates(root)
|
|
2296
|
+
const tokens = new StaticTokenStore([{username: "home", token: "gateway-token"}, {username: "app", token: "client-token"}])
|
|
2297
|
+
/** @type {Array<Record<string, unknown> & {event: string}>} */
|
|
2298
|
+
const hubEvents = []
|
|
2299
|
+
const hub = await startReverseGatewayHub({
|
|
2300
|
+
tokens,
|
|
2301
|
+
tls: {certFile: certificates.serverCertFile, keyFile: certificates.serverKeyFile},
|
|
2302
|
+
authorizeGateway: () => true,
|
|
2303
|
+
authorizeClientGateway: () => true,
|
|
2304
|
+
authorizeOpen: () => true,
|
|
2305
|
+
logger: {log: (/** @type {string} */ event, /** @type {Record<string, unknown>} */ fields = {}) => { hubEvents.push({event, ...fields}) }}
|
|
2306
|
+
})
|
|
2307
|
+
t.after(() => hub.close())
|
|
2308
|
+
const tls = {caFile: certificates.caCertFile, servername: "localhost"}
|
|
2309
|
+
const gateway = new OutboundReverseGateway({
|
|
2310
|
+
gatewayId: "o2",
|
|
2311
|
+
connectionId: "terminal_attribution_gateway",
|
|
2312
|
+
hub: {host: "127.0.0.1", port: hub.port, token: "gateway-token"},
|
|
2313
|
+
tls,
|
|
2314
|
+
authorizeTarget: () => true,
|
|
2315
|
+
createTargetConnection: () => /** @type {import('node:net').Socket} */ (/** @type {unknown} */ (new InterruptibleTarget())),
|
|
2316
|
+
createPhysicalSocket: (options) => {
|
|
2317
|
+
const physical = connectTls({host: options.host, port: options.port, ...options.tls})
|
|
2318
|
+
const originalWrite = physical.write.bind(physical)
|
|
2319
|
+
physical.write = /** @type {typeof physical.write} */ ((chunk, ...args) => {
|
|
2320
|
+
if (!Buffer.isBuffer(chunk) || chunk[5] !== V3FrameType.GOAWAY) return Reflect.apply(originalWrite, physical, [chunk, ...args])
|
|
2321
|
+
const safeGoaway = encodeV3Frame({type: V3FrameType.GOAWAY, streamId: 0, sequence: 0}, encodeV3Control({code: "GATEWAY_MAINTENANCE", disconnectIncidentId: "BBBBBBBBBBBBBBBBBBBBBB"}))
|
|
2322
|
+
return Reflect.apply(originalWrite, physical, [safeGoaway, ...args])
|
|
2323
|
+
})
|
|
2324
|
+
return physical
|
|
2325
|
+
}
|
|
2326
|
+
})
|
|
2327
|
+
t.after(() => gateway.close())
|
|
2328
|
+
await gateway.start()
|
|
2329
|
+
const client = new ReverseGatewayConnection({gatewayId: "o2", connectionId: "terminal_attribution_client", hub: {host: "127.0.0.1", port: hub.port, token: "client-token"}, tls, continuousReconnect: false})
|
|
2330
|
+
t.after(() => client.close())
|
|
2331
|
+
await client.connect()
|
|
2332
|
+
const socket = client.createConnection({host: "target", port: 2375})
|
|
2333
|
+
await onceEvent(socket, "connect")
|
|
2334
|
+
const terminal = nextError(socket)
|
|
2335
|
+
await gateway.close()
|
|
2336
|
+
const error = /** @type {Error & Record<string, unknown>} */ (await withTimeout(terminal, "terminal peer GOAWAY reset"))
|
|
2337
|
+
assert.equal(error.code, "GATEWAY_MAINTENANCE")
|
|
2338
|
+
assert.equal(error.disconnectReason, "peer_goaway")
|
|
2339
|
+
assert.equal(error.incidentOrigin, "reverse_hub")
|
|
2340
|
+
assert.equal(error.physicalRole, "gateway")
|
|
2341
|
+
assert.equal(error.fanOutCount, 1)
|
|
2342
|
+
assert.equal(error.terminalStreamCount, 1)
|
|
2343
|
+
assert.notEqual(error.disconnectIncidentId, "BBBBBBBBBBBBBBBBBBBBBB")
|
|
2344
|
+
assert(hubEvents.some(({event, terminalStreamCount}) => event === "reverse_hub.streams_reset" && terminalStreamCount === 1), JSON.stringify(hubEvents))
|
|
2345
|
+
})
|
|
2346
|
+
|
|
2171
2347
|
test("reverse mode enforces verified TLS and stream resource bounds", async (t) => {
|
|
2172
2348
|
const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-limits-"))
|
|
2173
2349
|
const wrongRoot = await mkdtemp(join(tmpdir(), "socketduct-reverse-wrong-ca-"))
|
|
@@ -364,7 +364,12 @@ test("a backpressured gateway does not block DATA or a new OPEN for another rout
|
|
|
364
364
|
t.after(() => socket.destroy())
|
|
365
365
|
return nextError(socket)
|
|
366
366
|
})
|
|
367
|
-
|
|
367
|
+
const outputError = /** @type {Error & Record<string, unknown>} */ (await withTimeout(streamAError, "gateway A output limit"))
|
|
368
|
+
assert.equal(outputError.code, "OUTPUT_BACKPRESSURE_LIMIT")
|
|
369
|
+
assert.equal(outputError.disconnectReason, "output_backpressure_limit")
|
|
370
|
+
assert.equal(outputError.incidentOrigin, "reverse_hub")
|
|
371
|
+
assert.equal(outputError.physicalRole, "gateway")
|
|
372
|
+
assert.match(/** @type {string} */ (outputError.disconnectIncidentId), /^[A-Za-z0-9_-]{22}$/)
|
|
368
373
|
await withTimeout(Promise.all(queuedAErrors), "queued gateway A stream cleanup")
|
|
369
374
|
assert.equal(await exchange(streamB, "after-limit"), "b:after-limit")
|
|
370
375
|
assert.equal(hub.status().healthyGateways, 1)
|
|
@@ -36,6 +36,8 @@ test("TensorBuzz exclusively owns verification and container build validation",
|
|
|
36
36
|
const packageMetadata = JSON.parse(packageJson)
|
|
37
37
|
|
|
38
38
|
assert.deepEqual(workflowNames, [])
|
|
39
|
+
assert.match(tensorbuzz, /^dependency_roots:\n {2}- path: \.$/m)
|
|
40
|
+
assert.doesNotMatch(tensorbuzz, /^\s*- npm ci$/m)
|
|
39
41
|
assert.match(tensorbuzz, /DOCKER_HOST: "tcp:\/\/docker_server:2375"/)
|
|
40
42
|
assert.match(tensorbuzz, /docker_server:\n\s+image: "docker:[^"]+-dind"/)
|
|
41
43
|
assert.match(tensorbuzz, /privileged: true/)
|
|
@@ -79,37 +79,77 @@ test("loadEnvironmentConfig parses or disables the status interval", () => {
|
|
|
79
79
|
assert.throws(() => loadEnvironmentConfig({SOCKETDUCT_STATUS_INTERVAL_MS: "-1"}), /non-negative integer/)
|
|
80
80
|
})
|
|
81
81
|
|
|
82
|
-
test("loadEnvironmentConfig keeps persistent logging disabled by default and parses
|
|
82
|
+
test("loadEnvironmentConfig keeps persistent logging disabled by default and parses compressed bounds", () => {
|
|
83
83
|
assert.equal(loadEnvironmentConfig({}).fileLogging, undefined)
|
|
84
84
|
assert.equal(loadEnvironmentConfig({SOCKETDUCT_LOG_DIRECTORY: " "}).fileLogging, undefined)
|
|
85
85
|
assert.deepEqual(loadEnvironmentConfig({
|
|
86
86
|
SOCKETDUCT_LOG_DIRECTORY: " /var/log/socketduct ",
|
|
87
|
-
|
|
88
|
-
|
|
87
|
+
SOCKETDUCT_LOG_FRAME_MAX_BYTES: "2097152",
|
|
88
|
+
SOCKETDUCT_LOG_FLUSH_INTERVAL_MS: "1500",
|
|
89
|
+
SOCKETDUCT_LOG_CRITICAL_FLUSH_INTERVAL_MS: "200",
|
|
90
|
+
SOCKETDUCT_LOG_SEGMENT_MAX_BYTES: "268435456",
|
|
91
|
+
SOCKETDUCT_LOG_RETENTION_DAYS: "14",
|
|
92
|
+
SOCKETDUCT_LOG_RETENTION_MAX_BYTES: "3221225472",
|
|
93
|
+
SOCKETDUCT_LOG_QUEUE_MAX_BYTES: "33554432",
|
|
94
|
+
SOCKETDUCT_LOG_CRITICAL_RESERVE_BYTES: "4194304",
|
|
95
|
+
SOCKETDUCT_LOG_COMPRESSION_LEVEL: "18"
|
|
89
96
|
}).fileLogging, {
|
|
90
97
|
directory: "/var/log/socketduct",
|
|
91
|
-
|
|
92
|
-
|
|
98
|
+
frameMaxBytes: 2_097_152,
|
|
99
|
+
flushIntervalMs: 1_500,
|
|
100
|
+
criticalFlushIntervalMs: 200,
|
|
101
|
+
maxSegmentBytes: 268_435_456,
|
|
102
|
+
retentionDays: 14,
|
|
103
|
+
retentionBytes: 3_221_225_472,
|
|
104
|
+
maxQueueBytes: 33_554_432,
|
|
105
|
+
criticalReserveBytes: 4_194_304,
|
|
106
|
+
compressionLevel: 18
|
|
93
107
|
})
|
|
94
108
|
})
|
|
95
109
|
|
|
96
|
-
test("loadEnvironmentConfig rejects unsafe
|
|
110
|
+
test("loadEnvironmentConfig rejects unsafe compressed logging bounds", () => {
|
|
97
111
|
assert.throws(() => loadEnvironmentConfig({
|
|
98
112
|
SOCKETDUCT_LOG_DIRECTORY: "/var/log/socketduct",
|
|
99
|
-
|
|
100
|
-
}), /
|
|
113
|
+
SOCKETDUCT_LOG_FRAME_MAX_BYTES: "100"
|
|
114
|
+
}), /SOCKETDUCT_LOG_FRAME_MAX_BYTES/)
|
|
101
115
|
assert.throws(() => loadEnvironmentConfig({
|
|
102
116
|
SOCKETDUCT_LOG_DIRECTORY: "/var/log/socketduct",
|
|
103
|
-
|
|
104
|
-
}), /
|
|
117
|
+
SOCKETDUCT_LOG_COMPRESSION_LEVEL: "23"
|
|
118
|
+
}), /SOCKETDUCT_LOG_COMPRESSION_LEVEL/)
|
|
119
|
+
assert.throws(() => loadEnvironmentConfig({
|
|
120
|
+
SOCKETDUCT_LOG_DIRECTORY: "/var/log/socketduct",
|
|
121
|
+
SOCKETDUCT_LOG_QUEUE_MAX_BYTES: "1024",
|
|
122
|
+
SOCKETDUCT_LOG_CRITICAL_RESERVE_BYTES: "1024"
|
|
123
|
+
}), /SOCKETDUCT_LOG_CRITICAL_RESERVE_BYTES/)
|
|
105
124
|
assert.throws(() => loadEnvironmentConfig({
|
|
106
125
|
SOCKETDUCT_LOG_DIRECTORY: "/var/log/socketduct",
|
|
107
|
-
|
|
108
|
-
|
|
126
|
+
SOCKETDUCT_LOG_SEGMENT_MAX_BYTES: "5368709120",
|
|
127
|
+
SOCKETDUCT_LOG_RETENTION_MAX_BYTES: "4294967296"
|
|
128
|
+
}), /SOCKETDUCT_LOG_SEGMENT_MAX_BYTES/)
|
|
109
129
|
assert.throws(() => loadEnvironmentConfig({
|
|
110
130
|
SOCKETDUCT_LOG_DIRECTORY: "/var/log/socketduct",
|
|
111
|
-
|
|
112
|
-
}), /
|
|
131
|
+
SOCKETDUCT_LOG_MAX_FILE_BYTES: "10485760"
|
|
132
|
+
}), /SOCKETDUCT_LOG_MAX_FILE_BYTES/)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test("loadEnvironmentConfig accepts a paired hub-only fingerprint key and epoch", () => {
|
|
136
|
+
const encodedKey = Buffer.alloc(32, 0x5a).toString("base64url")
|
|
137
|
+
const config = loadEnvironmentConfig({
|
|
138
|
+
SOCKETDUCT_MODE: "reverse-hub",
|
|
139
|
+
SOCKETDUCT_LOG_DIRECTORY: "/var/log/socketduct",
|
|
140
|
+
SOCKETDUCT_CONNECTION_FINGERPRINT_KEY: encodedKey,
|
|
141
|
+
SOCKETDUCT_CONNECTION_FINGERPRINT_KEY_EPOCH: "hub-2026-09"
|
|
142
|
+
})
|
|
143
|
+
assert.deepEqual(config.connectionFingerprint, {key: Buffer.alloc(32, 0x5a), keyEpoch: "hub-2026-09"})
|
|
144
|
+
assert.throws(() => loadEnvironmentConfig({
|
|
145
|
+
SOCKETDUCT_MODE: "reverse-hub",
|
|
146
|
+
SOCKETDUCT_LOG_DIRECTORY: "/var/log/socketduct"
|
|
147
|
+
}), /SOCKETDUCT_CONNECTION_FINGERPRINT_KEY/)
|
|
148
|
+
assert.throws(() => loadEnvironmentConfig({
|
|
149
|
+
SOCKETDUCT_MODE: "reverse-gateway",
|
|
150
|
+
SOCKETDUCT_CONNECTION_FINGERPRINT_KEY: encodedKey,
|
|
151
|
+
SOCKETDUCT_CONNECTION_FINGERPRINT_KEY_EPOCH: "hub-2026-09"
|
|
152
|
+
}), /hub-only/)
|
|
113
153
|
})
|
|
114
154
|
|
|
115
155
|
test("loadEnvironmentConfig selects v3 explicitly and keeps v2 as rollback default", () => {
|