socketduct 0.1.19 → 0.1.21
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/changelog.d/20260925180000-manual-ack-durable-admission.md +1 -0
- package/docs/docker.md +5 -4
- package/docs/logging.md +131 -159
- package/docs/protocol.md +48 -7
- package/docs/reverse-gateway.md +83 -8
- package/docs/running.md +17 -13
- package/docs/security.md +7 -3
- package/docs/session-lifecycle.md +22 -0
- package/docs/socket-api.md +22 -17
- package/package.json +5 -1
- package/scripts/verify-zstd-interoperability.mjs +41 -0
- package/src/cli/socketduct.js +143 -0
- package/src/client/deferred-virtual-socket.js +42 -1
- package/src/client/multiplexed-connection.js +17 -0
- package/src/client/multiplexed-transport-set.js +43 -8
- package/src/client/multiplexed.js +1 -1
- package/src/client/virtual-socket.js +119 -4
- package/src/config/environment.js +56 -7
- package/src/index.js +65 -27
- 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 +245 -34
- package/src/reverse/disconnect-attribution.js +191 -0
- package/src/reverse/gateway.js +68 -9
- package/src/reverse/hub.js +215 -32
- package/src/reverse/runtime.js +5 -3
- package/src/spool/spool-file.js +92 -6
- package/tensorbuzz.yml +2 -1
- package/test/e2e/logger-shutdown-process.test.js +86 -0
- package/test/e2e/reverse-client-recovery-process.test.js +3 -0
- package/test/e2e/reverse-gateway-process.test.js +15 -14
- package/test/fixtures/reverse-client-recovery.mjs +14 -2
- package/test/integration/logging-cli.test.js +168 -0
- package/test/integration/logging-compressed-recovery.test.js +186 -0
- package/test/integration/multiplexed-transport-set.test.js +1 -5
- package/test/integration/reverse-gateway.test.js +334 -1
- package/test/integration/reverse-route-class.test.js +6 -1
- package/test/unit/client/multiplexed-transport-set.test.js +50 -3
- package/test/unit/client/virtual-socket.test.js +123 -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 +5 -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 +58 -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
|
+
})
|
|
@@ -63,6 +63,64 @@ test("SessionSpool truncates acknowledged chunks", async () => {
|
|
|
63
63
|
})
|
|
64
64
|
})
|
|
65
65
|
|
|
66
|
+
test("SessionSpool truncates an acknowledgement inside a chunk in application bytes", async () => {
|
|
67
|
+
await withTempDir(async (root) => {
|
|
68
|
+
const spool = new SessionSpool({rootDir: root})
|
|
69
|
+
const sessionId = "session_partial_ack"
|
|
70
|
+
await spool.create({sessionId, ownerId: "user-1"})
|
|
71
|
+
await spool.appendChunk({sessionId, sequence: 8, data: Buffer.from("abcdefgh")})
|
|
72
|
+
|
|
73
|
+
await spool.truncateAcked(sessionId, 3)
|
|
74
|
+
|
|
75
|
+
const chunks = await spool.readChunksAfter(sessionId, 3)
|
|
76
|
+
assert.deepEqual(chunks, [{sequence: 8, data: Buffer.from("defgh")}])
|
|
77
|
+
assert.equal(spool.getUsedBytes(sessionId), 5)
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test("SessionSpool atomically admits a complete chunk batch before recovery commit", async () => {
|
|
82
|
+
await withTempDir(async (root) => {
|
|
83
|
+
const spool = new SessionSpool({rootDir: root, maxBytes: 32})
|
|
84
|
+
const sessionId = "session_durable_batch"
|
|
85
|
+
await spool.createNew({sessionId, ownerId: "reverse-client-v3"})
|
|
86
|
+
await spool.appendChunk({sessionId, ownerId: "reverse-client-v3", sequence: 4, data: Buffer.from("base")})
|
|
87
|
+
await spool.writeRecoveryState(sessionId, {version: 3, sendSequence: 4})
|
|
88
|
+
|
|
89
|
+
await spool.appendChunksAndRecoveryState({
|
|
90
|
+
sessionId,
|
|
91
|
+
ownerId: "reverse-client-v3",
|
|
92
|
+
previousSequence: 4,
|
|
93
|
+
chunks: [
|
|
94
|
+
{sequence: 7, data: Buffer.from("one")},
|
|
95
|
+
{sequence: 10, data: Buffer.from("two")}
|
|
96
|
+
],
|
|
97
|
+
recoveryState: {version: 3, sendSequence: 10}
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
assert.deepEqual((await spool.readChunksAfter(sessionId, 0)).map(({sequence, data}) => [sequence, data.toString()]), [
|
|
101
|
+
[4, "base"],
|
|
102
|
+
[7, "one"],
|
|
103
|
+
[10, "two"]
|
|
104
|
+
])
|
|
105
|
+
assert.deepEqual(spool.readRecoveryStateSync(sessionId, "reverse-client-v3"), {version: 3, sendSequence: 10})
|
|
106
|
+
assert.equal(spool.getUsedBytes(sessionId), 10)
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test("SessionSpool recovery lookup distinguishes missing state from invalid state", async () => {
|
|
111
|
+
await withTempDir(async (root) => {
|
|
112
|
+
const spool = new SessionSpool({rootDir: root})
|
|
113
|
+
assert.equal(spool.findRecoveryStateSync("missing_session", "edge-v3"), undefined)
|
|
114
|
+
|
|
115
|
+
await spool.createNew({sessionId: "present_session", ownerId: "edge-v3"})
|
|
116
|
+
await spool.writeRecoveryState("present_session", {version: 3})
|
|
117
|
+
assert.deepEqual(spool.findRecoveryStateSync("present_session", "edge-v3"), {version: 3})
|
|
118
|
+
|
|
119
|
+
await writeFile(join(root, "present_session", "recovery.json"), "{invalid\n")
|
|
120
|
+
assert.throws(() => spool.findRecoveryStateSync("present_session", "edge-v3"), /Invalid Socketduct recovery state/)
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
66
124
|
test("SessionSpool rejects path traversal and mismatched metadata", async () => {
|
|
67
125
|
await withTempDir(async (root) => {
|
|
68
126
|
const spool = new SessionSpool({rootDir: root})
|