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,143 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* eslint-disable jsdoc/escape-inline-tags, jsdoc/no-undefined-types, jsdoc/require-param-type, jsdoc/require-returns */
|
|
3
|
+
import {once} from "node:events"
|
|
4
|
+
|
|
5
|
+
import {CompressedLogTailReader, readCompressedLogDirectory} from "../logging/compressed-log-reader.js"
|
|
6
|
+
|
|
7
|
+
const FILTER_OPTIONS = new Map([
|
|
8
|
+
["--event", "event"],
|
|
9
|
+
["--incident", "incident"],
|
|
10
|
+
["--gateway", "gateway"],
|
|
11
|
+
["--route-class", "routeClass"],
|
|
12
|
+
["--client-generation", "clientGeneration"],
|
|
13
|
+
["--gateway-generation", "gatewayGeneration"],
|
|
14
|
+
["--reason", "reason"],
|
|
15
|
+
["--since", "since"],
|
|
16
|
+
["--until", "until"]
|
|
17
|
+
])
|
|
18
|
+
|
|
19
|
+
/** @typedef {{kind: 'search' | 'tail', directory: string, filters: Record<string, unknown>, follow: boolean, pollMs: number}} LogCommand */
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
*
|
|
23
|
+
* @param argv
|
|
24
|
+
* @param streams
|
|
25
|
+
*/
|
|
26
|
+
export async function runSocketductCli(/** @type {string[]} */ argv, /** @type {{stdout?: NodeJS.WriteStream, stderr?: NodeJS.WriteStream}} */ streams = {}) {
|
|
27
|
+
const stdout = streams.stdout ?? process.stdout
|
|
28
|
+
const stderr = streams.stderr ?? process.stderr
|
|
29
|
+
try {
|
|
30
|
+
const command = parseArguments(argv)
|
|
31
|
+
if (command.kind === "search") {
|
|
32
|
+
const result = await readCompressedLogDirectory(command.directory, {filters: command.filters})
|
|
33
|
+
if (result.incomplete) {
|
|
34
|
+
await writeOutput(stderr, `Socketduct log evidence error (${result.errors[0]?.code ?? "LOG_EVIDENCE_INVALID"})\n`)
|
|
35
|
+
return 2
|
|
36
|
+
}
|
|
37
|
+
for (const record of result.records) await writeOutput(stdout, `${JSON.stringify(record)}\n`)
|
|
38
|
+
return result.records.length > 0 ? 0 : 1
|
|
39
|
+
}
|
|
40
|
+
const reader = new CompressedLogTailReader(command.directory, {filters: command.filters})
|
|
41
|
+
let stopping = false
|
|
42
|
+
const stop = () => { stopping = true }
|
|
43
|
+
if (command.follow) {
|
|
44
|
+
process.once("SIGINT", stop)
|
|
45
|
+
process.once("SIGTERM", stop)
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
do {
|
|
49
|
+
const result = await reader.poll()
|
|
50
|
+
if (result.incomplete) {
|
|
51
|
+
await writeOutput(stderr, `Socketduct log evidence error (${result.errors[0]?.code ?? "LOG_EVIDENCE_INVALID"})\n`)
|
|
52
|
+
return 2
|
|
53
|
+
}
|
|
54
|
+
for (const record of result.records) {
|
|
55
|
+
await writeOutput(stdout, `${JSON.stringify(record)}\n`)
|
|
56
|
+
}
|
|
57
|
+
if (command.follow && !stopping) await waitForPoll(command.pollMs)
|
|
58
|
+
} while (command.follow && !stopping)
|
|
59
|
+
return 0
|
|
60
|
+
} finally {
|
|
61
|
+
if (command.follow) {
|
|
62
|
+
process.off("SIGINT", stop)
|
|
63
|
+
process.off("SIGTERM", stop)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
} catch {
|
|
67
|
+
await writeOutput(stderr, "Socketduct logs error (INVALID_ARGUMENT_OR_IO)\n")
|
|
68
|
+
return 2
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** @param {string[]} argv - CLI arguments. @returns {LogCommand} */
|
|
73
|
+
function parseArguments(argv) {
|
|
74
|
+
if (argv[0] !== "logs" || (argv[1] !== "search" && argv[1] !== "tail")) throw new Error("Expected logs search or logs tail")
|
|
75
|
+
const kind = argv[1]
|
|
76
|
+
let directory
|
|
77
|
+
let follow = false
|
|
78
|
+
let pollMs = 250
|
|
79
|
+
/** @type {Record<string, unknown>} */
|
|
80
|
+
const filters = {}
|
|
81
|
+
for (let index = 2; index < argv.length; index += 1) {
|
|
82
|
+
const option = argv[index]
|
|
83
|
+
if (option === undefined) throw new Error("Missing option")
|
|
84
|
+
if (option === "--follow" && kind === "tail") { follow = true; continue }
|
|
85
|
+
const value = argv[++index]
|
|
86
|
+
if (value === undefined || value.length === 0) throw new Error("Missing option value")
|
|
87
|
+
if (option === "--directory") { directory = value; continue }
|
|
88
|
+
if (option === "--poll-ms" && kind === "tail") {
|
|
89
|
+
pollMs = positiveInteger(value, "poll interval")
|
|
90
|
+
if (pollMs > 60_000) throw new Error("Poll interval exceeds 60000 milliseconds")
|
|
91
|
+
continue
|
|
92
|
+
}
|
|
93
|
+
const filter = FILTER_OPTIONS.get(option)
|
|
94
|
+
if (filter === undefined) throw new Error("Unknown logs option")
|
|
95
|
+
filters[filter] = validateFilter(filter, value)
|
|
96
|
+
}
|
|
97
|
+
if (directory === undefined) throw new Error("Log directory is required")
|
|
98
|
+
return {kind, directory, filters, follow, pollMs}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* @param {string} filter - Internal filter name. @param {string} value - User input. @returns {string | number}
|
|
103
|
+
* @param value
|
|
104
|
+
*/
|
|
105
|
+
function validateFilter(filter, value) {
|
|
106
|
+
if (filter === "clientGeneration" || filter === "gatewayGeneration") return positiveInteger(value, "connection generation", true)
|
|
107
|
+
if (filter === "since" || filter === "until") {
|
|
108
|
+
const timestamp = Date.parse(value)
|
|
109
|
+
if (!Number.isSafeInteger(timestamp)) throw new Error("Invalid time filter")
|
|
110
|
+
return timestamp
|
|
111
|
+
}
|
|
112
|
+
if (filter === "incident" && !/^[A-Za-z0-9_-]{22}$/.test(value)) throw new Error("Invalid incident filter")
|
|
113
|
+
if ((filter === "gateway" || filter === "routeClass") && !/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(value)) throw new Error("Invalid route filter")
|
|
114
|
+
if ((filter === "event" || filter === "reason") && !/^[a-zA-Z][a-zA-Z0-9_.-]{0,95}$/.test(value)) throw new Error("Invalid diagnostic filter")
|
|
115
|
+
return value
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param {string} value - User input. @param {string} name - Setting name. @param {boolean} [allowZero] - Whether zero is valid.
|
|
120
|
+
* @param name
|
|
121
|
+
* @param allowZero
|
|
122
|
+
*/
|
|
123
|
+
function positiveInteger(value, name, allowZero = false) {
|
|
124
|
+
if (!/^(?:0|[1-9][0-9]*)$/.test(value)) throw new Error(`Invalid ${name}`)
|
|
125
|
+
const parsed = Number(value)
|
|
126
|
+
if (!Number.isSafeInteger(parsed) || (allowZero ? parsed < 0 : parsed < 1)) throw new Error(`Invalid ${name}`)
|
|
127
|
+
return parsed
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* @param {NodeJS.WriteStream} stream - Output stream. @param {string} text - Text to write.
|
|
132
|
+
* @param text
|
|
133
|
+
*/
|
|
134
|
+
async function writeOutput(stream, text) {
|
|
135
|
+
if (!stream.write(text)) await once(stream, "drain")
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** @param {number} milliseconds - Poll interval. */
|
|
139
|
+
async function waitForPoll(milliseconds) {
|
|
140
|
+
await new Promise((resolve) => setTimeout(resolve, milliseconds))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (import.meta.url === `file://${process.argv[1]}`) process.exitCode = await runSocketductCli(process.argv.slice(2))
|
|
@@ -15,6 +15,16 @@ import {Duplex} from "node:stream"
|
|
|
15
15
|
|
|
16
16
|
/** A stream-local failure where the physical connection may remain usable. */
|
|
17
17
|
export class SocketductStreamError extends Error {
|
|
18
|
+
/** @type {string | undefined} */ disconnectIncidentId
|
|
19
|
+
/** @type {string | undefined} */ disconnectReason
|
|
20
|
+
/** @type {'reverse_hub' | 'reverse_client' | 'reverse_gateway' | undefined} */ incidentOrigin
|
|
21
|
+
/** @type {'client' | 'gateway' | undefined} */ physicalRole
|
|
22
|
+
/** @type {number | undefined} */ clientConnectionGeneration
|
|
23
|
+
/** @type {number | undefined} */ gatewayConnectionGeneration
|
|
24
|
+
/** @type {number | undefined} */ fanOutCount
|
|
25
|
+
/** @type {number | undefined} */ resumeSuccessCount
|
|
26
|
+
/** @type {number | undefined} */ terminalStreamCount
|
|
27
|
+
|
|
18
28
|
/**
|
|
19
29
|
* @param {string} message - Safe description.
|
|
20
30
|
* @param {string} code - Stable code.
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import {isIP} from "node:net"
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_LOG_COMPRESSION_LEVEL,
|
|
5
|
+
DEFAULT_LOG_CRITICAL_FLUSH_INTERVAL_MS,
|
|
6
|
+
DEFAULT_LOG_CRITICAL_RESERVE_BYTES,
|
|
7
|
+
DEFAULT_LOG_FLUSH_INTERVAL_MS,
|
|
8
|
+
DEFAULT_LOG_FRAME_BYTES,
|
|
9
|
+
DEFAULT_LOG_QUEUE_BYTES,
|
|
10
|
+
DEFAULT_LOG_RETENTION_BYTES,
|
|
11
|
+
DEFAULT_LOG_RETENTION_DAYS,
|
|
12
|
+
DEFAULT_LOG_SEGMENT_BYTES
|
|
13
|
+
} from "../logging/compressed-log-writer.js"
|
|
4
14
|
|
|
5
15
|
const DEFAULT_RELAY_HOST = "127.0.0.1"
|
|
6
16
|
const DEFAULT_RELAY_PORT = 3100
|
|
@@ -52,7 +62,8 @@ const SUPPORTED_PROTOCOL_VERSIONS = new Set([2, 3])
|
|
|
52
62
|
* @property {number} handshakeTimeoutMs - Milliseconds to wait for auth after TCP/TLS connect before closing the socket. Default 10000.
|
|
53
63
|
* @property {number} maxInputBytesPerConnection - Per-connection input byte budget before the first authenticated session frame. Default 65536.
|
|
54
64
|
* @property {number} [statusIntervalMs] - Safe status-log interval; zero disables periodic status logs.
|
|
55
|
-
* @property {{directory: string,
|
|
65
|
+
* @property {{directory: string, frameMaxBytes: number, flushIntervalMs: number, criticalFlushIntervalMs: number, maxSegmentBytes: number, retentionDays: number, retentionBytes: number, maxQueueBytes: number, criticalReserveBytes: number, compressionLevel: number}} [fileLogging] - Optional durable compressed JSONL logging configuration.
|
|
66
|
+
* @property {{key: Buffer, keyEpoch: string}} [connectionFingerprint] - Hub-only keyed diagnostic correlation configuration.
|
|
56
67
|
* @property {'auto' | 'off'} [compression] - Whether to negotiate stream.data compression when the runtime supports it. Defaults to auto.
|
|
57
68
|
* @property {2 | 3} [protocolVersion] - Relay wire protocol; defaults to rollback-compatible v2.
|
|
58
69
|
* @property {number} [minCompressBytes] - Payloads smaller than this are sent raw.
|
|
@@ -133,13 +144,34 @@ export function loadEnvironmentConfig(env = process.env) {
|
|
|
133
144
|
...(env.SOCKETDUCT_REVERSE_HUB_SERVERNAME?.trim() ? {servername: env.SOCKETDUCT_REVERSE_HUB_SERVERNAME.trim()} : {})
|
|
134
145
|
}
|
|
135
146
|
const logDirectory = env.SOCKETDUCT_LOG_DIRECTORY?.trim() || undefined
|
|
147
|
+
if (env.SOCKETDUCT_LOG_MAX_FILE_BYTES !== undefined || env.SOCKETDUCT_LOG_MAX_FILES !== undefined) throw new Error("SOCKETDUCT_LOG_MAX_FILE_BYTES and SOCKETDUCT_LOG_MAX_FILES were replaced by compressed log frame, segment, and retention settings")
|
|
136
148
|
let fileLogging
|
|
137
149
|
if (logDirectory !== undefined) {
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
150
|
+
const frameMaxBytes = parseBoundedInteger(env.SOCKETDUCT_LOG_FRAME_MAX_BYTES, "SOCKETDUCT_LOG_FRAME_MAX_BYTES", DEFAULT_LOG_FRAME_BYTES, 128, 16 * 1024 * 1024)
|
|
151
|
+
const flushIntervalMs = parseBoundedInteger(env.SOCKETDUCT_LOG_FLUSH_INTERVAL_MS, "SOCKETDUCT_LOG_FLUSH_INTERVAL_MS", DEFAULT_LOG_FLUSH_INTERVAL_MS, 1, 60_000)
|
|
152
|
+
const criticalFlushIntervalMs = parseBoundedInteger(env.SOCKETDUCT_LOG_CRITICAL_FLUSH_INTERVAL_MS, "SOCKETDUCT_LOG_CRITICAL_FLUSH_INTERVAL_MS", DEFAULT_LOG_CRITICAL_FLUSH_INTERVAL_MS, 1, 60_000)
|
|
153
|
+
const maxSegmentBytes = parseBoundedInteger(env.SOCKETDUCT_LOG_SEGMENT_MAX_BYTES, "SOCKETDUCT_LOG_SEGMENT_MAX_BYTES", DEFAULT_LOG_SEGMENT_BYTES, 1024, 4 * 1024 * 1024 * 1024)
|
|
154
|
+
const retentionDays = parseBoundedInteger(env.SOCKETDUCT_LOG_RETENTION_DAYS, "SOCKETDUCT_LOG_RETENTION_DAYS", DEFAULT_LOG_RETENTION_DAYS, 1, 3650)
|
|
155
|
+
const retentionBytes = parseBoundedInteger(env.SOCKETDUCT_LOG_RETENTION_MAX_BYTES, "SOCKETDUCT_LOG_RETENTION_MAX_BYTES", DEFAULT_LOG_RETENTION_BYTES, 1024, 1024 * 1024 * 1024 * 1024)
|
|
156
|
+
const maxQueueBytes = parseBoundedInteger(env.SOCKETDUCT_LOG_QUEUE_MAX_BYTES, "SOCKETDUCT_LOG_QUEUE_MAX_BYTES", DEFAULT_LOG_QUEUE_BYTES, 1024, 1024 * 1024 * 1024)
|
|
157
|
+
const criticalReserveBytes = parseBoundedInteger(env.SOCKETDUCT_LOG_CRITICAL_RESERVE_BYTES, "SOCKETDUCT_LOG_CRITICAL_RESERVE_BYTES", DEFAULT_LOG_CRITICAL_RESERVE_BYTES, 0, maxQueueBytes - 1)
|
|
158
|
+
const compressionLevel = parseBoundedInteger(env.SOCKETDUCT_LOG_COMPRESSION_LEVEL, "SOCKETDUCT_LOG_COMPRESSION_LEVEL", DEFAULT_LOG_COMPRESSION_LEVEL, 1, 22)
|
|
159
|
+
if (maxQueueBytes - criticalReserveBytes < 512) throw new Error("SOCKETDUCT_LOG_CRITICAL_RESERVE_BYTES must leave at least 512 noncritical queue bytes")
|
|
160
|
+
if (maxSegmentBytes > retentionBytes) throw new Error("SOCKETDUCT_LOG_SEGMENT_MAX_BYTES must not exceed SOCKETDUCT_LOG_RETENTION_MAX_BYTES")
|
|
161
|
+
fileLogging = {directory: logDirectory, frameMaxBytes, flushIntervalMs, criticalFlushIntervalMs, maxSegmentBytes, retentionDays, retentionBytes, maxQueueBytes, criticalReserveBytes, compressionLevel}
|
|
162
|
+
}
|
|
163
|
+
const fingerprintKeyText = env.SOCKETDUCT_CONNECTION_FINGERPRINT_KEY?.trim() || undefined
|
|
164
|
+
const fingerprintKeyEpoch = env.SOCKETDUCT_CONNECTION_FINGERPRINT_KEY_EPOCH?.trim() || undefined
|
|
165
|
+
if (mode !== "reverse-hub" && (fingerprintKeyText !== undefined || fingerprintKeyEpoch !== undefined)) throw new Error("SOCKETDUCT_CONNECTION_FINGERPRINT_KEY is hub-only")
|
|
166
|
+
if ((fingerprintKeyText === undefined) !== (fingerprintKeyEpoch === undefined)) throw new Error("SOCKETDUCT_CONNECTION_FINGERPRINT_KEY and SOCKETDUCT_CONNECTION_FINGERPRINT_KEY_EPOCH must be supplied together")
|
|
167
|
+
if (mode === "reverse-hub" && fileLogging !== undefined && fingerprintKeyText === undefined) throw new Error("SOCKETDUCT_CONNECTION_FINGERPRINT_KEY is required for reverse-hub persistent logging")
|
|
168
|
+
let connectionFingerprint
|
|
169
|
+
if (fingerprintKeyText !== undefined && fingerprintKeyEpoch !== undefined) {
|
|
170
|
+
if (!/^[A-Za-z0-9_-]+$/.test(fingerprintKeyText)) throw new Error("SOCKETDUCT_CONNECTION_FINGERPRINT_KEY must be unpadded base64url")
|
|
171
|
+
const key = Buffer.from(fingerprintKeyText, "base64url")
|
|
172
|
+
if (key.length < 32 || key.toString("base64url") !== fingerprintKeyText) throw new Error("SOCKETDUCT_CONNECTION_FINGERPRINT_KEY must encode at least 32 bytes")
|
|
173
|
+
if (!/^[A-Za-z0-9_-]{1,32}$/.test(fingerprintKeyEpoch)) throw new Error("SOCKETDUCT_CONNECTION_FINGERPRINT_KEY_EPOCH must be 1-32 URL-safe characters")
|
|
174
|
+
connectionFingerprint = {key, keyEpoch: fingerprintKeyEpoch}
|
|
143
175
|
}
|
|
144
176
|
|
|
145
177
|
return {
|
|
@@ -184,6 +216,7 @@ export function loadEnvironmentConfig(env = process.env) {
|
|
|
184
216
|
DEFAULT_STATUS_INTERVAL_MS
|
|
185
217
|
),
|
|
186
218
|
...(fileLogging === undefined ? {} : {fileLogging}),
|
|
219
|
+
...(connectionFingerprint === undefined ? {} : {connectionFingerprint}),
|
|
187
220
|
compression,
|
|
188
221
|
protocolVersion,
|
|
189
222
|
minCompressBytes: parsePositiveInteger(
|
|
@@ -343,6 +376,22 @@ function parseNonNegativeInteger(value, name, defaultValue) {
|
|
|
343
376
|
return parsed
|
|
344
377
|
}
|
|
345
378
|
|
|
379
|
+
/**
|
|
380
|
+
* Parses one bounded integer environment setting.
|
|
381
|
+
* @param {string | undefined} value - Raw value.
|
|
382
|
+
* @param {string} name - Environment variable name.
|
|
383
|
+
* @param {number} defaultValue - Default value.
|
|
384
|
+
* @param {number} minimum - Inclusive minimum.
|
|
385
|
+
* @param {number} maximum - Inclusive maximum.
|
|
386
|
+
* @returns {number} Parsed bounded integer.
|
|
387
|
+
*/
|
|
388
|
+
function parseBoundedInteger(value, name, defaultValue, minimum, maximum) {
|
|
389
|
+
if (value === undefined || value === "") return defaultValue
|
|
390
|
+
const parsed = Number(value)
|
|
391
|
+
if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) throw new Error(`${name} must be an integer from ${minimum} to ${maximum}`)
|
|
392
|
+
return parsed
|
|
393
|
+
}
|
|
394
|
+
|
|
346
395
|
/**
|
|
347
396
|
* @param {string} value - Comma-separated username:token entries.
|
|
348
397
|
* @returns {StaticTokenConfig[]} Parsed static tokens.
|
package/src/index.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import {loadEnvironmentConfig} from "./config/environment.js"
|
|
2
|
-
import {
|
|
2
|
+
import {createCompressedJsonlFileLogger} from "./logging/compressed-log-writer.js"
|
|
3
3
|
import {startRelayRuntime} from "./server/relay-runtime.js"
|
|
4
4
|
import {startOutboundReverseGatewayRuntime, startReverseHubRuntime} from "./reverse/runtime.js"
|
|
5
5
|
|
|
6
6
|
export {createSocketductConnectionFactory} from "./client/connect.js"
|
|
7
7
|
export {createJsonlFileLogger, createStructuredLogger} from "./logging/structured-logger.js"
|
|
8
|
+
export {createCompressedJsonlFileLogger, CompressedJsonlFileLogger} from "./logging/compressed-log-writer.js"
|
|
8
9
|
export {SocketductHttpAgent} from "./client/http-agent.js"
|
|
9
10
|
export {connectSocketduct, SocketductRelayError, SocketductSocket} from "./client/socketduct-socket.js"
|
|
10
11
|
export {createMultiplexedSocketductConnection, MultiplexedSocketductConnection} from "./client/multiplexed-connection.js"
|
|
@@ -33,11 +34,12 @@ export function getStartupSummary() {
|
|
|
33
34
|
*/
|
|
34
35
|
async function main() {
|
|
35
36
|
const config = loadEnvironmentConfig()
|
|
36
|
-
const logger = config.fileLogging === undefined ? undefined :
|
|
37
|
+
const logger = config.fileLogging === undefined ? undefined : createCompressedJsonlFileLogger({...config.fileLogging, role: config.mode})
|
|
37
38
|
if (config.mode === "summary") {
|
|
38
39
|
const summary = getStartupSummary()
|
|
39
40
|
console.log(`${summary.name} starting in ${summary.mode} mode on ${summary.runtime}`)
|
|
40
|
-
logger?.log("process.started", {runtimeMode: config.mode})
|
|
41
|
+
logger?.log("process.started", {runtimeMode: config.mode, ...loggingConfigurationFields(config.fileLogging)})
|
|
42
|
+
await logger?.close()
|
|
41
43
|
return
|
|
42
44
|
}
|
|
43
45
|
|
|
@@ -45,31 +47,29 @@ async function main() {
|
|
|
45
47
|
let service
|
|
46
48
|
/** @type {() => Promise<void>} */
|
|
47
49
|
let closeRuntime
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
50
|
+
try {
|
|
51
|
+
if (config.mode === "relay") {
|
|
52
|
+
const runtime = await startRelayRuntime(config)
|
|
53
|
+
service = runtime.relay
|
|
54
|
+
closeRuntime = runtime.close
|
|
55
|
+
} else if (config.mode === "reverse-hub") {
|
|
56
|
+
const runtime = await startReverseHubRuntime(config, logger)
|
|
57
|
+
service = runtime.hub
|
|
58
|
+
closeRuntime = runtime.close
|
|
59
|
+
} else {
|
|
60
|
+
const runtime = await startOutboundReverseGatewayRuntime(config, logger)
|
|
61
|
+
service = runtime.gateway
|
|
62
|
+
closeRuntime = runtime.close
|
|
63
|
+
}
|
|
64
|
+
} catch (startupError) {
|
|
65
|
+
try {
|
|
66
|
+
await logger?.close()
|
|
67
|
+
} catch (loggerError) {
|
|
68
|
+
throw new AggregateError([startupError, loggerError], "Socketduct startup and diagnostic logger shutdown both failed", {cause: loggerError})
|
|
69
|
+
}
|
|
70
|
+
throw startupError
|
|
60
71
|
}
|
|
61
72
|
const tlsStatus = config.mode === "relay" && config.relayTls === undefined ? "plaintext" : "TLS"
|
|
62
|
-
console.log(JSON.stringify({
|
|
63
|
-
event: `${config.mode}.started`,
|
|
64
|
-
...(config.mode === "reverse-gateway" ? {gatewayId: config.reverseGatewayId} : {host: config.relayHost, port: service.port}),
|
|
65
|
-
transportSecurity: tlsStatus,
|
|
66
|
-
compression: config.mode.startsWith("reverse-") ? "zstd" : config.compression
|
|
67
|
-
}))
|
|
68
|
-
logger?.log("process.started", {
|
|
69
|
-
runtimeMode: config.mode,
|
|
70
|
-
transportSecurity: tlsStatus,
|
|
71
|
-
compression: config.mode.startsWith("reverse-") ? "zstd" : config.compression
|
|
72
|
-
})
|
|
73
73
|
const statusIntervalMs = config.statusIntervalMs ?? 30_000
|
|
74
74
|
const statusTimer = statusIntervalMs > 0
|
|
75
75
|
? setInterval(() => {
|
|
@@ -80,9 +80,15 @@ async function main() {
|
|
|
80
80
|
: undefined
|
|
81
81
|
statusTimer?.unref()
|
|
82
82
|
|
|
83
|
+
let shuttingDown = false
|
|
83
84
|
const shutdown = () => {
|
|
85
|
+
if (shuttingDown) return
|
|
86
|
+
shuttingDown = true
|
|
84
87
|
if (statusTimer !== undefined) clearInterval(statusTimer)
|
|
85
88
|
closeRuntime()
|
|
89
|
+
.then(() => { logger?.log("process.stopped", {runtimeMode: config.mode}) })
|
|
90
|
+
.then(() => logger?.flush())
|
|
91
|
+
.then(() => logger?.close())
|
|
86
92
|
.then(() => process.exit(0))
|
|
87
93
|
.catch((error) => {
|
|
88
94
|
console.error(error)
|
|
@@ -91,6 +97,38 @@ async function main() {
|
|
|
91
97
|
}
|
|
92
98
|
process.once("SIGINT", shutdown)
|
|
93
99
|
process.once("SIGTERM", shutdown)
|
|
100
|
+
logger?.log("process.started", {
|
|
101
|
+
runtimeMode: config.mode,
|
|
102
|
+
transportSecurity: tlsStatus,
|
|
103
|
+
compression: config.mode.startsWith("reverse-") ? "zstd" : config.compression,
|
|
104
|
+
...loggingConfigurationFields(config.fileLogging)
|
|
105
|
+
})
|
|
106
|
+
console.log(JSON.stringify({
|
|
107
|
+
event: `${config.mode}.started`,
|
|
108
|
+
...(config.mode === "reverse-gateway" ? {gatewayId: config.reverseGatewayId} : {host: config.relayHost, port: service.port}),
|
|
109
|
+
transportSecurity: tlsStatus,
|
|
110
|
+
compression: config.mode.startsWith("reverse-") ? "zstd" : config.compression
|
|
111
|
+
}))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Returns only non-sensitive effective logger settings for the startup record.
|
|
116
|
+
* @param {import('./config/environment.js').RelayEnvironmentConfig['fileLogging']} fileLogging - Managed sink settings.
|
|
117
|
+
* @returns {Record<string, number>} Safe numeric settings.
|
|
118
|
+
*/
|
|
119
|
+
function loggingConfigurationFields(fileLogging) {
|
|
120
|
+
if (fileLogging === undefined) return {}
|
|
121
|
+
return {
|
|
122
|
+
frameMaxBytes: fileLogging.frameMaxBytes,
|
|
123
|
+
flushIntervalMs: fileLogging.flushIntervalMs,
|
|
124
|
+
criticalFlushIntervalMs: fileLogging.criticalFlushIntervalMs,
|
|
125
|
+
maxSegmentBytes: fileLogging.maxSegmentBytes,
|
|
126
|
+
retentionDays: fileLogging.retentionDays,
|
|
127
|
+
retentionBytes: fileLogging.retentionBytes,
|
|
128
|
+
maxQueueBytes: fileLogging.maxQueueBytes,
|
|
129
|
+
criticalReserveBytes: fileLogging.criticalReserveBytes,
|
|
130
|
+
compressionLevel: fileLogging.compressionLevel
|
|
131
|
+
}
|
|
94
132
|
}
|
|
95
133
|
|
|
96
134
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
@@ -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
|
+
}
|