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,256 @@
|
|
|
1
|
+
/* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-param-type, jsdoc/require-returns */
|
|
2
|
+
import {createHash} from "node:crypto"
|
|
3
|
+
import {constants} from "node:fs"
|
|
4
|
+
import {lstat, open, readdir, stat} from "node:fs/promises"
|
|
5
|
+
import {join} from "node:path"
|
|
6
|
+
|
|
7
|
+
import {inspectLogBatch} from "./log-batch.js"
|
|
8
|
+
import {INDEX_ENTRY_BYTES, INDEX_HEADER_BYTES, parseIndexEntry, parseIndexHeader, uuidBytes} from "./log-segment-index.js"
|
|
9
|
+
import {decompressLogFrame} from "./zstd-frame-codec.js"
|
|
10
|
+
|
|
11
|
+
const SEGMENT_NAME = /^([a-z][a-z0-9-]{0,63})-([A-Za-z0-9_-]{22})-([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})\.jsonl\.zst$/
|
|
12
|
+
const DEFAULT_MAX_DECODED_FRAME_BYTES = 16 * 1024 * 1024
|
|
13
|
+
|
|
14
|
+
/** Incremental durable-index reader for long-running tail consumers. */
|
|
15
|
+
export class CompressedLogTailReader {
|
|
16
|
+
/** @type {string} */ #directory
|
|
17
|
+
/** @type {{filters?: Record<string, unknown>, maxDecodedFrameBytes?: number}} */ #options
|
|
18
|
+
/** @type {Map<string, {nextEntry: number, expectedSequence: bigint | undefined, expectedOffset: bigint, sealed: boolean}>} */ #segments = new Map()
|
|
19
|
+
/** @type {number} */ #decodedFrames = 0
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {string} directory - Private compressed-log directory.
|
|
23
|
+
* @param {{filters?: Record<string, unknown>, maxDecodedFrameBytes?: number}} [options] - Read policy.
|
|
24
|
+
*/
|
|
25
|
+
constructor(directory, options = {}) {
|
|
26
|
+
if (typeof directory !== "string" || directory.length === 0) throw new TypeError("compressed log directory is required")
|
|
27
|
+
this.#directory = directory
|
|
28
|
+
this.#options = options
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Reads only index entries committed since the prior poll. */
|
|
32
|
+
async poll() {
|
|
33
|
+
await assertPrivateDirectory(this.#directory)
|
|
34
|
+
const entries = (await readdir(this.#directory)).filter((entry) => entry.endsWith(".jsonl.zst")).sort()
|
|
35
|
+
const present = new Set(entries)
|
|
36
|
+
for (const name of this.#segments.keys()) if (!present.has(name)) this.#segments.delete(name)
|
|
37
|
+
/** @type {Array<Record<string, unknown>>} */
|
|
38
|
+
const records = []
|
|
39
|
+
/** @type {Array<{segment: string, code: string}>} */
|
|
40
|
+
const errors = []
|
|
41
|
+
for (const entry of entries) {
|
|
42
|
+
const previous = this.#segments.get(entry)
|
|
43
|
+
if (previous?.sealed === true) continue
|
|
44
|
+
try {
|
|
45
|
+
const result = await readCompressedLogSegmentIncremental(join(this.#directory, entry), this.#options, previous)
|
|
46
|
+
this.#segments.set(entry, result.cursor)
|
|
47
|
+
this.#decodedFrames = Math.min(Number.MAX_SAFE_INTEGER, this.#decodedFrames + result.decodedFrames)
|
|
48
|
+
records.push(...result.records)
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (evidenceErrorCode(error) === "ENOENT") {
|
|
51
|
+
this.#segments.delete(entry)
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
errors.push({segment: entry, code: evidenceErrorCode(error)})
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
records.sort(compareRecords)
|
|
58
|
+
const filtered = this.#options.filters === undefined ? records : records.filter((record) => matchesFilters(record, this.#options.filters ?? {}))
|
|
59
|
+
return {records: filtered, incomplete: errors.length > 0, errors}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Returns bounded cursor count and cumulative decode work for health/tests. */
|
|
63
|
+
status() { return {trackedSegments: this.#segments.size, decodedFrames: this.#decodedFrames} }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Reads and validates every durable indexed frame, preserving evidence errors.
|
|
68
|
+
* @param directory
|
|
69
|
+
* @param options
|
|
70
|
+
*/
|
|
71
|
+
export async function readCompressedLogDirectory(/** @type {string} */ directory, /** @type {{filters?: Record<string, unknown>, maxDecodedFrameBytes?: number}} */ options = {}) {
|
|
72
|
+
await assertPrivateDirectory(directory)
|
|
73
|
+
const entries = await readdir(directory)
|
|
74
|
+
/** @type {Array<Record<string, unknown>>} */
|
|
75
|
+
const records = []
|
|
76
|
+
/** @type {Array<{segment: string, code: string}>} */
|
|
77
|
+
const errors = []
|
|
78
|
+
for (const entry of entries.sort()) {
|
|
79
|
+
if (!entry.endsWith(".jsonl.zst")) continue
|
|
80
|
+
try {
|
|
81
|
+
const selected = await readCompressedLogSegment(join(directory, entry), options)
|
|
82
|
+
records.push(...selected)
|
|
83
|
+
} catch (error) {
|
|
84
|
+
errors.push({segment: entry, code: evidenceErrorCode(error)})
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
records.sort(compareRecords)
|
|
88
|
+
const filtered = options.filters === undefined ? records : records.filter((record) => matchesFilters(record, options.filters ?? {}))
|
|
89
|
+
return {records: filtered, incomplete: errors.length > 0, errors}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Reads one segment frame-by-frame from its durable index.
|
|
94
|
+
* @param dataPath
|
|
95
|
+
* @param options
|
|
96
|
+
*/
|
|
97
|
+
export async function readCompressedLogSegment(/** @type {string} */ dataPath, /** @type {{maxDecodedFrameBytes?: number}} */ options = {}) {
|
|
98
|
+
return (await readCompressedLogSegmentIncremental(dataPath, options)).records
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Reads newly indexed frames from one segment and advances its validated cursor.
|
|
103
|
+
* @param {string} dataPath - Segment data path.
|
|
104
|
+
* @param {{maxDecodedFrameBytes?: number}} options - Decode bounds.
|
|
105
|
+
* @param {{nextEntry: number, expectedSequence: bigint | undefined, expectedOffset: bigint, sealed: boolean}} [cursor] - Prior cursor.
|
|
106
|
+
*/
|
|
107
|
+
async function readCompressedLogSegmentIncremental(dataPath, options, cursor = undefined) {
|
|
108
|
+
const name = dataPath.slice(dataPath.lastIndexOf("/") + 1)
|
|
109
|
+
const match = SEGMENT_NAME.exec(name)
|
|
110
|
+
if (match === null) throw evidenceError("INVALID_SEGMENT_NAME")
|
|
111
|
+
const writerEpoch = Buffer.from(/** @type {string} */ (match[2]), "base64url")
|
|
112
|
+
const segmentUuid = uuidBytes(/** @type {string} */ (match[3]))
|
|
113
|
+
if (writerEpoch.length !== 16) throw evidenceError("INVALID_WRITER_EPOCH")
|
|
114
|
+
const indexPath = `${dataPath}.idx`
|
|
115
|
+
if (await pathExists(`${dataPath}.quarantine`)) throw evidenceError("LOG_EVIDENCE_QUARANTINED")
|
|
116
|
+
await Promise.all([assertPrivateRegularFile(dataPath), assertPrivateRegularFile(indexPath)])
|
|
117
|
+
const [dataHandle, indexHandle] = await Promise.all([
|
|
118
|
+
open(dataPath, constants.O_RDONLY | constants.O_NOFOLLOW),
|
|
119
|
+
open(indexPath, constants.O_RDONLY | constants.O_NOFOLLOW)
|
|
120
|
+
])
|
|
121
|
+
try {
|
|
122
|
+
const [dataStats, indexStats] = await Promise.all([dataHandle.stat(), indexHandle.stat()])
|
|
123
|
+
if (indexStats.size < INDEX_HEADER_BYTES) throw evidenceError("INDEX_HEADER_TORN")
|
|
124
|
+
const headerBytes = Buffer.alloc(INDEX_HEADER_BYTES)
|
|
125
|
+
await readExactly(indexHandle, headerBytes, 0)
|
|
126
|
+
const header = parseIndexHeader(headerBytes)
|
|
127
|
+
if (!header.segmentUuid.equals(segmentUuid) || !header.writerEpoch.equals(writerEpoch)) throw evidenceError("INDEX_IDENTITY_MISMATCH")
|
|
128
|
+
const completeEntryBytes = indexStats.size - INDEX_HEADER_BYTES
|
|
129
|
+
const sealed = await pathExists(`${dataPath}.sealed`)
|
|
130
|
+
if (completeEntryBytes % INDEX_ENTRY_BYTES !== 0 && sealed) throw evidenceError("INDEX_TAIL_TORN")
|
|
131
|
+
const entryCount = Math.floor(completeEntryBytes / INDEX_ENTRY_BYTES)
|
|
132
|
+
const nextEntry = cursor?.nextEntry ?? 0
|
|
133
|
+
if (!Number.isSafeInteger(nextEntry) || nextEntry < 0 || nextEntry > entryCount) throw evidenceError("INDEX_CURSOR_INVALID")
|
|
134
|
+
/** @type {Array<Record<string, unknown>>} */
|
|
135
|
+
const records = []
|
|
136
|
+
let expectedSequence = cursor?.expectedSequence
|
|
137
|
+
let expectedOffset = cursor?.expectedOffset ?? 0n
|
|
138
|
+
let decodedFrames = 0
|
|
139
|
+
for (let index = nextEntry; index < entryCount; index += 1) {
|
|
140
|
+
const entryBytes = Buffer.alloc(INDEX_ENTRY_BYTES)
|
|
141
|
+
await readExactly(indexHandle, entryBytes, INDEX_HEADER_BYTES + index * INDEX_ENTRY_BYTES)
|
|
142
|
+
const entry = parseIndexEntry(entryBytes, {segmentUuid, writerEpoch})
|
|
143
|
+
if (expectedSequence !== undefined && entry.firstSequence !== expectedSequence) throw evidenceError("INDEX_SEQUENCE_GAP")
|
|
144
|
+
if (entry.dataOffset !== expectedOffset) throw evidenceError("INDEX_DATA_GAP")
|
|
145
|
+
if (entry.dataOffset + entry.compressedLength > BigInt(dataStats.size)) throw evidenceError("INDEX_DATA_BOUNDS")
|
|
146
|
+
if (entry.compressedLength > BigInt(Number.MAX_SAFE_INTEGER) || entry.uncompressedLength > BigInt(options.maxDecodedFrameBytes ?? DEFAULT_MAX_DECODED_FRAME_BYTES)) throw evidenceError("FRAME_SIZE_LIMIT")
|
|
147
|
+
const frame = Buffer.alloc(Number(entry.compressedLength))
|
|
148
|
+
await readExactly(dataHandle, frame, Number(entry.dataOffset))
|
|
149
|
+
if (!createHash("sha256").update(frame).digest().equals(entry.frameSha256)) throw evidenceError("FRAME_HASH_MISMATCH")
|
|
150
|
+
const raw = await decompressLogFrame(frame)
|
|
151
|
+
if (BigInt(raw.length) !== entry.uncompressedLength) throw evidenceError("FRAME_LENGTH_MISMATCH")
|
|
152
|
+
const inspected = inspectLogBatch(raw, writerEpoch)
|
|
153
|
+
if (inspected.firstSequence !== entry.firstSequence || inspected.lastSequence !== entry.lastSequence || inspected.recordCount !== entry.recordCount || inspected.minTimestamp !== entry.minTimestamp || inspected.maxTimestamp !== entry.maxTimestamp || !inspected.batchId.equals(entry.batchId)) throw evidenceError("FRAME_METADATA_MISMATCH")
|
|
154
|
+
records.push(...inspected.records)
|
|
155
|
+
decodedFrames += 1
|
|
156
|
+
expectedSequence = entry.lastSequence + 1n
|
|
157
|
+
expectedOffset = entry.dataOffset + entry.compressedLength
|
|
158
|
+
}
|
|
159
|
+
if (sealed && expectedOffset !== BigInt(dataStats.size)) throw evidenceError("SEALED_DATA_TAIL")
|
|
160
|
+
return {records, decodedFrames, cursor: {nextEntry: entryCount, expectedSequence, expectedOffset, sealed}}
|
|
161
|
+
} finally {
|
|
162
|
+
await Promise.allSettled([dataHandle.close(), indexHandle.close()])
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* @param {import('node:fs/promises').FileHandle} handle - Open file. @param {Buffer} buffer - Exact destination. @param {number} position - File position.
|
|
168
|
+
* @param buffer
|
|
169
|
+
* @param position
|
|
170
|
+
*/
|
|
171
|
+
async function readExactly(handle, buffer, position) {
|
|
172
|
+
let offset = 0
|
|
173
|
+
while (offset < buffer.length) {
|
|
174
|
+
const {bytesRead} = await handle.read(buffer, offset, buffer.length - offset, position + offset)
|
|
175
|
+
if (bytesRead === 0) throw evidenceError("SHORT_READ")
|
|
176
|
+
offset += bytesRead
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
*
|
|
182
|
+
* @param path
|
|
183
|
+
*/
|
|
184
|
+
async function assertPrivateDirectory(/** @type {string} */ path) {
|
|
185
|
+
const stats = await lstat(path)
|
|
186
|
+
if (!stats.isDirectory() || (stats.mode & 0o777) !== 0o700) throw evidenceError("UNSAFE_LOG_DIRECTORY")
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
*
|
|
191
|
+
* @param path
|
|
192
|
+
*/
|
|
193
|
+
async function assertPrivateRegularFile(/** @type {string} */ path) {
|
|
194
|
+
const stats = await lstat(path)
|
|
195
|
+
if (!stats.isFile() || (stats.mode & 0o777) !== 0o600) throw evidenceError("UNSAFE_LOG_FILE")
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
*
|
|
200
|
+
* @param path
|
|
201
|
+
*/
|
|
202
|
+
async function pathExists(/** @type {string} */ path) {
|
|
203
|
+
try { await stat(path); return true } catch (error) { if (error !== null && typeof error === "object" && /** @type {{code?: unknown}} */ (error).code === "ENOENT") return false; throw error }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
*
|
|
208
|
+
* @param record
|
|
209
|
+
* @param filters
|
|
210
|
+
*/
|
|
211
|
+
function matchesFilters(/** @type {Record<string, unknown>} */ record, /** @type {Record<string, unknown>} */ filters) {
|
|
212
|
+
const mappings = {
|
|
213
|
+
event: "event",
|
|
214
|
+
incident: "disconnectIncidentId",
|
|
215
|
+
gateway: "gatewayId",
|
|
216
|
+
routeClass: "routeClass",
|
|
217
|
+
clientGeneration: "clientConnectionGeneration",
|
|
218
|
+
gatewayGeneration: "gatewayConnectionGeneration",
|
|
219
|
+
reason: "disconnectReason"
|
|
220
|
+
}
|
|
221
|
+
for (const [filter, field] of Object.entries(mappings)) if (filters[filter] !== undefined && String(record[field]) !== String(filters[filter])) return false
|
|
222
|
+
const timestamp = typeof record.timestamp === "string" ? Date.parse(record.timestamp) : Number.NaN
|
|
223
|
+
if (filters.since !== undefined && timestamp < /** @type {number} */ (filters.since)) return false
|
|
224
|
+
if (filters.until !== undefined && timestamp > /** @type {number} */ (filters.until)) return false
|
|
225
|
+
return true
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
*
|
|
230
|
+
* @param left
|
|
231
|
+
* @param right
|
|
232
|
+
*/
|
|
233
|
+
function compareRecords(/** @type {Record<string, unknown>} */ left, /** @type {Record<string, unknown>} */ right) {
|
|
234
|
+
const time = String(left.timestamp).localeCompare(String(right.timestamp))
|
|
235
|
+
if (time !== 0) return time
|
|
236
|
+
const leftEpoch = Buffer.from(String(left.writerEpoch), "base64url")
|
|
237
|
+
const rightEpoch = Buffer.from(String(right.writerEpoch), "base64url")
|
|
238
|
+
const epoch = Buffer.compare(leftEpoch, rightEpoch)
|
|
239
|
+
return epoch !== 0 ? epoch : BigInt(String(left.eventSequence)) < BigInt(String(right.eventSequence)) ? -1 : BigInt(String(left.eventSequence)) > BigInt(String(right.eventSequence)) ? 1 : 0
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
*
|
|
244
|
+
* @param error
|
|
245
|
+
*/
|
|
246
|
+
function evidenceErrorCode(/** @type {unknown} */ error) {
|
|
247
|
+
return error !== null && typeof error === "object" && typeof /** @type {{code?: unknown}} */ (error).code === "string" ? /** @type {{code: string}} */ (error).code : "LOG_EVIDENCE_INVALID"
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
*
|
|
252
|
+
* @param code
|
|
253
|
+
*/
|
|
254
|
+
function evidenceError(/** @type {string} */ code) {
|
|
255
|
+
return Object.assign(new Error("Compressed log evidence is incomplete or corrupt"), {code})
|
|
256
|
+
}
|