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.
Files changed (45) hide show
  1. package/.env.example +17 -5
  2. package/README.md +3 -3
  3. package/changelog.d/20260925170000-disconnect-attribution-compressed-logs.md +1 -0
  4. package/docs/docker.md +5 -4
  5. package/docs/logging.md +131 -159
  6. package/docs/protocol.md +27 -4
  7. package/docs/reverse-gateway.md +34 -0
  8. package/docs/running.md +14 -11
  9. package/docs/security.md +7 -3
  10. package/package.json +5 -1
  11. package/scripts/verify-zstd-interoperability.mjs +41 -0
  12. package/src/cli/socketduct.js +143 -0
  13. package/src/client/virtual-socket.js +10 -0
  14. package/src/config/environment.js +56 -7
  15. package/src/index.js +64 -26
  16. package/src/logging/compressed-log-reader.js +256 -0
  17. package/src/logging/compressed-log-worker.js +430 -0
  18. package/src/logging/compressed-log-writer.js +538 -0
  19. package/src/logging/log-batch.js +72 -0
  20. package/src/logging/log-retention.js +241 -0
  21. package/src/logging/log-segment-index.js +176 -0
  22. package/src/logging/structured-logger.js +136 -30
  23. package/src/logging/zstd-frame-codec.js +73 -0
  24. package/src/reverse/client.js +71 -11
  25. package/src/reverse/disconnect-attribution.js +191 -0
  26. package/src/reverse/gateway.js +58 -8
  27. package/src/reverse/hub.js +215 -32
  28. package/src/reverse/runtime.js +5 -3
  29. package/tensorbuzz.yml +2 -1
  30. package/test/e2e/logger-shutdown-process.test.js +86 -0
  31. package/test/e2e/reverse-gateway-process.test.js +15 -14
  32. package/test/integration/logging-cli.test.js +168 -0
  33. package/test/integration/logging-compressed-recovery.test.js +186 -0
  34. package/test/integration/reverse-gateway.test.js +176 -0
  35. package/test/integration/reverse-route-class.test.js +6 -1
  36. package/test/unit/config/docker-gateway-files.test.js +2 -0
  37. package/test/unit/config/environment.test.js +54 -14
  38. package/test/unit/index.test.js +3 -0
  39. package/test/unit/logging/compressed-log-worker.test.js +29 -0
  40. package/test/unit/logging/compressed-log-writer.test.js +183 -0
  41. package/test/unit/logging/log-retention.test.js +86 -0
  42. package/test/unit/logging/log-segment-index.test.js +104 -0
  43. package/test/unit/logging/structured-logger.test.js +14 -1
  44. package/test/unit/logging/zstd-frame-codec.test.js +24 -0
  45. 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 {DEFAULT_LOG_MAX_FILE_BYTES, DEFAULT_LOG_MAX_FILES, MAX_LOG_FILES, MIN_LOG_MAX_FILE_BYTES} from "../logging/structured-logger.js"
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, maxFileBytes: number, maxFiles: number}} [fileLogging] - Optional persistent JSONL logging configuration.
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 maxFileBytes = parsePositiveInteger(env.SOCKETDUCT_LOG_MAX_FILE_BYTES, "SOCKETDUCT_LOG_MAX_FILE_BYTES", DEFAULT_LOG_MAX_FILE_BYTES)
139
- if (!Number.isSafeInteger(maxFileBytes) || maxFileBytes < MIN_LOG_MAX_FILE_BYTES) throw new Error(`SOCKETDUCT_LOG_MAX_FILE_BYTES must be a safe integer of at least ${MIN_LOG_MAX_FILE_BYTES}`)
140
- const maxFiles = parsePositiveInteger(env.SOCKETDUCT_LOG_MAX_FILES, "SOCKETDUCT_LOG_MAX_FILES", DEFAULT_LOG_MAX_FILES)
141
- if (maxFiles > MAX_LOG_FILES) throw new Error(`SOCKETDUCT_LOG_MAX_FILES must be no greater than ${MAX_LOG_FILES}`)
142
- fileLogging = {directory: logDirectory, maxFileBytes, maxFiles}
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 {createJsonlFileLogger} from "./logging/structured-logger.js"
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 : createJsonlFileLogger({...config.fileLogging, role: config.mode})
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
- if (config.mode === "relay") {
49
- const runtime = await startRelayRuntime(config)
50
- service = runtime.relay
51
- closeRuntime = runtime.close
52
- } else if (config.mode === "reverse-hub") {
53
- const runtime = await startReverseHubRuntime(config)
54
- service = runtime.hub
55
- closeRuntime = runtime.close
56
- } else {
57
- const runtime = await startOutboundReverseGatewayRuntime(config, logger)
58
- service = runtime.gateway
59
- closeRuntime = runtime.close
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
+ }