socketduct 0.1.18 → 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 +5 -5
- package/changelog.d/20260925143000-reverse-client-process-recovery.md +1 -0
- package/changelog.d/20260925170000-disconnect-attribution-compressed-logs.md +1 -0
- package/docs/architecture.md +11 -9
- package/docs/docker.md +5 -4
- package/docs/logging.md +131 -159
- package/docs/protocol.md +43 -7
- package/docs/reverse-gateway.md +75 -9
- package/docs/running.md +16 -13
- package/docs/security.md +16 -5
- package/docs/session-lifecycle.md +14 -3
- package/docs/socket-api.md +54 -20
- package/package.json +5 -1
- package/scripts/verify-zstd-interoperability.mjs +41 -0
- package/src/cli/socketduct.js +143 -0
- package/src/client/multiplexed-transport-set.js +12 -5
- package/src/client/virtual-socket.js +52 -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 +372 -82
- package/src/reverse/disconnect-attribution.js +191 -0
- package/src/reverse/gateway.js +68 -18
- package/src/reverse/hub.js +215 -32
- package/src/reverse/runtime.js +5 -3
- package/src/spool/spool-file.js +136 -7
- package/tensorbuzz.yml +4 -2
- package/test/e2e/logger-shutdown-process.test.js +86 -0
- package/test/e2e/reverse-client-recovery-process.test.js +170 -0
- package/test/e2e/reverse-gateway-process.test.js +15 -14
- package/test/fixtures/reverse-client-recovery.mjs +123 -0
- 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 +597 -1
- package/test/integration/reverse-route-class.test.js +6 -1
- package/test/unit/client/multiplexed-transport-set.test.js +12 -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
- package/test/unit/spool/spool-file.test.js +133 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import {createHmac} from "node:crypto"
|
|
3
|
+
import test from "node:test"
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
attachDisconnectAttribution,
|
|
7
|
+
connectionFingerprint,
|
|
8
|
+
createDisconnectAttribution,
|
|
9
|
+
parseDisconnectAttribution,
|
|
10
|
+
sanitizePeerControlDetail
|
|
11
|
+
} from "../../../src/reverse/disconnect-attribution.js"
|
|
12
|
+
|
|
13
|
+
test("disconnect attribution uses one bounded incident and named generations", () => {
|
|
14
|
+
const attribution = createDisconnectAttribution({
|
|
15
|
+
disconnectReason: "remote_socket_error",
|
|
16
|
+
incidentOrigin: "reverse_hub",
|
|
17
|
+
physicalRole: "gateway",
|
|
18
|
+
clientConnectionGeneration: 17,
|
|
19
|
+
gatewayConnectionGeneration: 23,
|
|
20
|
+
fanOutCount: 5,
|
|
21
|
+
resumeSuccessCount: 2,
|
|
22
|
+
terminalStreamCount: 1
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
assert.match(attribution.disconnectIncidentId, /^[A-Za-z0-9_-]{22}$/)
|
|
26
|
+
assert.deepEqual(parseDisconnectAttribution(attribution), attribution)
|
|
27
|
+
assert.equal("generation" in attribution, false)
|
|
28
|
+
|
|
29
|
+
const error = /** @type {Error & Record<string, unknown>} */ (attachDisconnectAttribution(Object.assign(new Error("classified terminal"), {code: "GATEWAY_DISCONNECTED"}), attribution))
|
|
30
|
+
assert.equal(error.code, "GATEWAY_DISCONNECTED")
|
|
31
|
+
assert.equal(error.disconnectReason, "remote_socket_error")
|
|
32
|
+
assert.equal(error.disconnectIncidentId, attribution.disconnectIncidentId)
|
|
33
|
+
assert.equal(error.gatewayConnectionGeneration, 23)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test("peer control detail cannot assert hub-owned disconnect fields", () => {
|
|
37
|
+
const result = sanitizePeerControlDetail({
|
|
38
|
+
code: "TARGET_CLOSED",
|
|
39
|
+
disconnectIncidentId: "AAAAAAAAAAAAAAAAAAAAAA",
|
|
40
|
+
disconnectReason: "hub_shutdown",
|
|
41
|
+
incidentOrigin: "reverse_hub",
|
|
42
|
+
physicalRole: "gateway",
|
|
43
|
+
gatewayConnectionGeneration: 9,
|
|
44
|
+
fanOutCount: 4,
|
|
45
|
+
target: "docker.internal:2375",
|
|
46
|
+
token: "secret"
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
assert.deepEqual(result.detail, {code: "TARGET_CLOSED"})
|
|
50
|
+
assert.equal(result.reservedFieldsStripped, 6)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test("hub connection fingerprints are keyed, role-separated, and truncated to 128 bits", () => {
|
|
54
|
+
const key = Buffer.alloc(32, 0x5a)
|
|
55
|
+
const connectionId = "low_entropy_connection_01"
|
|
56
|
+
const expected = createHmac("sha256", key)
|
|
57
|
+
.update("socketduct-connection-id:v1")
|
|
58
|
+
.update(Buffer.from([0]))
|
|
59
|
+
.update("gateway")
|
|
60
|
+
.update(Buffer.from([0]))
|
|
61
|
+
.update(connectionId)
|
|
62
|
+
.digest()
|
|
63
|
+
.subarray(0, 16)
|
|
64
|
+
.toString("hex")
|
|
65
|
+
|
|
66
|
+
const actual = connectionFingerprint(key, "gateway", connectionId)
|
|
67
|
+
assert.equal(actual, expected)
|
|
68
|
+
assert.match(actual, /^[a-f0-9]{32}$/)
|
|
69
|
+
assert.notEqual(actual, connectionId)
|
|
70
|
+
assert.notEqual(actual, connectionFingerprint(key, "client", connectionId))
|
|
71
|
+
assert.throws(() => connectionFingerprint(Buffer.alloc(31), "gateway", connectionId), /at least 32 bytes/)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test("malformed attribution is rejected instead of partially trusted", () => {
|
|
75
|
+
assert.equal(parseDisconnectAttribution({
|
|
76
|
+
disconnectIncidentId: "AAAAAAAAAAAAAAAAAAAAAA",
|
|
77
|
+
disconnectReason: "not-a-reason",
|
|
78
|
+
incidentOrigin: "reverse_hub",
|
|
79
|
+
physicalRole: "gateway",
|
|
80
|
+
gatewayConnectionGeneration: 1
|
|
81
|
+
}), undefined)
|
|
82
|
+
})
|
|
@@ -191,6 +191,139 @@ test("SessionSpool synchronously rejects expired durable recovery state", async
|
|
|
191
191
|
})
|
|
192
192
|
})
|
|
193
193
|
|
|
194
|
+
test("SessionSpool loadChunksSync restores only unacknowledged owned chunks", async () => {
|
|
195
|
+
await withTempDir(async (root) => {
|
|
196
|
+
const sessionId = "session_recovery_chunks"
|
|
197
|
+
const spool = new SessionSpool({rootDir: root, maxBytes: 16})
|
|
198
|
+
await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
|
|
199
|
+
await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("old")})
|
|
200
|
+
await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 6, data: Buffer.from("new")})
|
|
201
|
+
|
|
202
|
+
const chunks = spool.loadChunksSync(sessionId, "reverse-client-v3", 3, 6)
|
|
203
|
+
|
|
204
|
+
assert.deepEqual(chunks.map((chunk) => chunk.sequence), [6])
|
|
205
|
+
assert.equal(chunks[0]?.data.toString(), "new")
|
|
206
|
+
assert.equal(spool.getUsedBytes(sessionId), 3)
|
|
207
|
+
assert.throws(
|
|
208
|
+
() => spool.loadChunksSync(sessionId, "other-owner", 3, 6),
|
|
209
|
+
/Invalid Socketduct recovery chunks/
|
|
210
|
+
)
|
|
211
|
+
})
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
test("SessionSpool recovery repairs interruption after chunk append before usage update", async () => {
|
|
215
|
+
await withTempDir(async (root) => {
|
|
216
|
+
const sessionId = "session_append_interruption"
|
|
217
|
+
const spool = new SessionSpool({rootDir: root, maxBytes: 16})
|
|
218
|
+
await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
|
|
219
|
+
await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("one")})
|
|
220
|
+
const chunksPath = join(root, sessionId, "chunks.jsonl")
|
|
221
|
+
const committed = await readFile(chunksPath, "utf8")
|
|
222
|
+
await writeFile(chunksPath, `${committed}${JSON.stringify({sequence: 6, dataBase64: Buffer.from("two").toString("base64")})}\n`)
|
|
223
|
+
|
|
224
|
+
const chunks = spool.loadChunksSync(sessionId, "reverse-client-v3", 0, 3)
|
|
225
|
+
|
|
226
|
+
assert.deepEqual(chunks.map((chunk) => chunk.sequence), [3])
|
|
227
|
+
assert.equal(await readFile(chunksPath, "utf8"), committed)
|
|
228
|
+
assert.deepEqual(JSON.parse(await readFile(join(root, sessionId, "usage.json"), "utf8")), {totalBytes: 3})
|
|
229
|
+
assert.equal((await stat(chunksPath)).mode & 0o777, 0o600)
|
|
230
|
+
assert.equal((await stat(join(root, sessionId, "usage.json"))).mode & 0o777, 0o600)
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
test("SessionSpool recovery repairs interruption after usage update before recovery commit", async () => {
|
|
235
|
+
await withTempDir(async (root) => {
|
|
236
|
+
const sessionId = "session_usage_interruption"
|
|
237
|
+
const spool = new SessionSpool({rootDir: root, maxBytes: 16})
|
|
238
|
+
await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
|
|
239
|
+
await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("one")})
|
|
240
|
+
await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 6, data: Buffer.from("two")})
|
|
241
|
+
|
|
242
|
+
const chunks = spool.loadChunksSync(sessionId, "reverse-client-v3", 0, 3)
|
|
243
|
+
|
|
244
|
+
assert.deepEqual(chunks.map((chunk) => chunk.sequence), [3])
|
|
245
|
+
assert.deepEqual(JSON.parse(await readFile(join(root, sessionId, "usage.json"), "utf8")), {totalBytes: 3})
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
test("SessionSpool recovery repairs interruption after acknowledged chunk truncation", async () => {
|
|
250
|
+
await withTempDir(async (root) => {
|
|
251
|
+
const sessionId = "session_truncate_interruption"
|
|
252
|
+
const spool = new SessionSpool({rootDir: root, maxBytes: 16})
|
|
253
|
+
await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
|
|
254
|
+
await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("one")})
|
|
255
|
+
await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 6, data: Buffer.from("two")})
|
|
256
|
+
const chunksPath = join(root, sessionId, "chunks.jsonl")
|
|
257
|
+
await writeFile(chunksPath, `${JSON.stringify({sequence: 6, dataBase64: Buffer.from("two").toString("base64")})}\n`)
|
|
258
|
+
|
|
259
|
+
const chunks = spool.loadChunksSync(sessionId, "reverse-client-v3", 3, 6)
|
|
260
|
+
|
|
261
|
+
assert.deepEqual(chunks.map((chunk) => chunk.sequence), [6])
|
|
262
|
+
assert.deepEqual(JSON.parse(await readFile(join(root, sessionId, "usage.json"), "utf8")), {totalBytes: 3})
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
test("SessionSpool recovery repairs a partial trailing JSONL record after committed chunks", async () => {
|
|
267
|
+
await withTempDir(async (root) => {
|
|
268
|
+
const sessionId = "session_partial_interruption"
|
|
269
|
+
const spool = new SessionSpool({rootDir: root, maxBytes: 16})
|
|
270
|
+
await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
|
|
271
|
+
await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("one")})
|
|
272
|
+
await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 6, data: Buffer.from("two")})
|
|
273
|
+
const chunksPath = join(root, sessionId, "chunks.jsonl")
|
|
274
|
+
const committed = await readFile(chunksPath, "utf8")
|
|
275
|
+
await writeFile(chunksPath, `${committed}{"sequence":9,"dataBase64":"thr`)
|
|
276
|
+
|
|
277
|
+
const chunks = spool.loadChunksSync(sessionId, "reverse-client-v3", 0, 6)
|
|
278
|
+
|
|
279
|
+
assert.deepEqual(chunks.map((chunk) => chunk.sequence), [3, 6])
|
|
280
|
+
assert.equal(await readFile(chunksPath, "utf8"), committed)
|
|
281
|
+
})
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
test("SessionSpool recovery rejects corruption before or outside a partial uncommitted tail", async () => {
|
|
285
|
+
await withTempDir(async (root) => {
|
|
286
|
+
const sessionId = "session_committed_corruption"
|
|
287
|
+
const spool = new SessionSpool({rootDir: root, maxBytes: 16})
|
|
288
|
+
await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
|
|
289
|
+
await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("one")})
|
|
290
|
+
const chunksPath = join(root, sessionId, "chunks.jsonl")
|
|
291
|
+
await writeFile(chunksPath, `${await readFile(chunksPath, "utf8")}{"sequence":6,"dataBase64":"tw`)
|
|
292
|
+
|
|
293
|
+
assert.throws(
|
|
294
|
+
() => spool.loadChunksSync(sessionId, "reverse-client-v3", 0, 6),
|
|
295
|
+
/Invalid Socketduct recovery chunks/
|
|
296
|
+
)
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
await withTempDir(async (root) => {
|
|
300
|
+
const sessionId = "session_tail_corruption"
|
|
301
|
+
const spool = new SessionSpool({rootDir: root, maxBytes: 16})
|
|
302
|
+
await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
|
|
303
|
+
await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("one")})
|
|
304
|
+
const chunksPath = join(root, sessionId, "chunks.jsonl")
|
|
305
|
+
await writeFile(chunksPath, `${await readFile(chunksPath, "utf8")}{not-json}\n`)
|
|
306
|
+
|
|
307
|
+
assert.throws(
|
|
308
|
+
() => spool.loadChunksSync(sessionId, "reverse-client-v3", 0, 3),
|
|
309
|
+
/Invalid Socketduct recovery chunks/
|
|
310
|
+
)
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
await withTempDir(async (root) => {
|
|
314
|
+
const sessionId = "session_usage_corruption"
|
|
315
|
+
const spool = new SessionSpool({rootDir: root, maxBytes: 16})
|
|
316
|
+
await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
|
|
317
|
+
await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 3, data: Buffer.from("one")})
|
|
318
|
+
await writeFile(join(root, sessionId, "usage.json"), "{not-json\n")
|
|
319
|
+
|
|
320
|
+
assert.throws(
|
|
321
|
+
() => spool.loadChunksSync(sessionId, "reverse-client-v3", 0, 3),
|
|
322
|
+
/Invalid Socketduct recovery chunks/
|
|
323
|
+
)
|
|
324
|
+
})
|
|
325
|
+
})
|
|
326
|
+
|
|
194
327
|
test("SessionSpool cleans expired abandoned entries so their stable IDs can be reused", async () => {
|
|
195
328
|
await withTempDir(async (root) => {
|
|
196
329
|
let now = 1_000_000
|