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
|
@@ -7,7 +7,10 @@ import {Duplex} from "node:stream"
|
|
|
7
7
|
* @property {(streamId: number, callback: (error?: Error | null) => void) => void} _endStream - End the writable side.
|
|
8
8
|
* @property {(streamId: number, error: Error | null) => void} _destroyStream - Destroy the virtual stream.
|
|
9
9
|
* @property {(streamId: number) => void} _resumeReadable - Resume inbound flow.
|
|
10
|
+
* @property {(streamId: number, data: Buffer, callback: (error?: Error | null) => void) => void} [_writeDurableStream] - Atomically admit one durable application operation.
|
|
11
|
+
* @property {(streamId: number, bytes: number) => void} [_deliverReadable] - Report bytes exposed to the application.
|
|
10
12
|
* @property {(streamId: number, bytes: number) => void} [_consumeReadable] - Report bytes returned to the application.
|
|
13
|
+
* @property {(streamId: number, bytes: number) => Promise<number>} [_acknowledgeReadable] - Durably acknowledge application bytes.
|
|
11
14
|
* @property {() => {address?: string, port?: number}} physicalAddress - Current physical endpoint.
|
|
12
15
|
* @property {() => unknown} ref - Reference owner resources.
|
|
13
16
|
* @property {() => unknown} unref - Unreference owner resources.
|
|
@@ -15,6 +18,16 @@ import {Duplex} from "node:stream"
|
|
|
15
18
|
|
|
16
19
|
/** A stream-local failure where the physical connection may remain usable. */
|
|
17
20
|
export class SocketductStreamError extends Error {
|
|
21
|
+
/** @type {string | undefined} */ disconnectIncidentId
|
|
22
|
+
/** @type {string | undefined} */ disconnectReason
|
|
23
|
+
/** @type {'reverse_hub' | 'reverse_client' | 'reverse_gateway' | undefined} */ incidentOrigin
|
|
24
|
+
/** @type {'client' | 'gateway' | undefined} */ physicalRole
|
|
25
|
+
/** @type {number | undefined} */ clientConnectionGeneration
|
|
26
|
+
/** @type {number | undefined} */ gatewayConnectionGeneration
|
|
27
|
+
/** @type {number | undefined} */ fanOutCount
|
|
28
|
+
/** @type {number | undefined} */ resumeSuccessCount
|
|
29
|
+
/** @type {number | undefined} */ terminalStreamCount
|
|
30
|
+
|
|
18
31
|
/**
|
|
19
32
|
* @param {string} message - Safe description.
|
|
20
33
|
* @param {string} code - Stable code.
|
|
@@ -64,6 +77,18 @@ export class VirtualSocket extends Duplex {
|
|
|
64
77
|
/** @type {number} */
|
|
65
78
|
#emittedDataBytes = 0
|
|
66
79
|
|
|
80
|
+
/** @type {boolean} */
|
|
81
|
+
#manualReceiveAck
|
|
82
|
+
|
|
83
|
+
/** @type {number} */
|
|
84
|
+
#deliveredReadableBytes = 0
|
|
85
|
+
|
|
86
|
+
/** @type {number} */
|
|
87
|
+
#requestedReceiveAckBytes = 0
|
|
88
|
+
|
|
89
|
+
/** @type {WeakMap<Buffer, {resolve: () => void, reject: (error: Error) => void}>} */
|
|
90
|
+
#durableWrites = new WeakMap()
|
|
91
|
+
|
|
67
92
|
/** @type {boolean} */
|
|
68
93
|
connecting = true
|
|
69
94
|
|
|
@@ -94,7 +119,7 @@ export class VirtualSocket extends Duplex {
|
|
|
94
119
|
/**
|
|
95
120
|
* @param {VirtualSocketOwner} connection - Owner.
|
|
96
121
|
* @param {number} streamId - Allocated stream ID.
|
|
97
|
-
* @param {{host: string, port: number, allowHalfOpen?: boolean, readableHighWaterMark?: number, writableHighWaterMark?: number}} options - Socket metadata/options.
|
|
122
|
+
* @param {{host: string, port: number, allowHalfOpen?: boolean, readableHighWaterMark?: number, writableHighWaterMark?: number, manualReceiveAck?: boolean}} options - Socket metadata/options.
|
|
98
123
|
*/
|
|
99
124
|
constructor(connection, streamId, options) {
|
|
100
125
|
super({
|
|
@@ -104,6 +129,7 @@ export class VirtualSocket extends Duplex {
|
|
|
104
129
|
})
|
|
105
130
|
this.#connection = connection
|
|
106
131
|
this.#streamId = streamId
|
|
132
|
+
this.#manualReceiveAck = options.manualReceiveAck ?? false
|
|
107
133
|
this.remoteAddress = options.host
|
|
108
134
|
this.remotePort = options.port
|
|
109
135
|
this.remoteFamily = options.host.includes(":") ? "IPv6" : "IPv4"
|
|
@@ -114,6 +140,11 @@ export class VirtualSocket extends Duplex {
|
|
|
114
140
|
return this.#streamId
|
|
115
141
|
}
|
|
116
142
|
|
|
143
|
+
/** @returns {boolean} Whether application-controlled receive ACK is enabled. */
|
|
144
|
+
get manualReceiveAck() {
|
|
145
|
+
return this.#manualReceiveAck
|
|
146
|
+
}
|
|
147
|
+
|
|
117
148
|
/** @returns {this} Socket-style no-op connect; opening starts at construction. */
|
|
118
149
|
connect() {
|
|
119
150
|
return this
|
|
@@ -141,7 +172,11 @@ export class VirtualSocket extends Duplex {
|
|
|
141
172
|
if (chunk !== null) {
|
|
142
173
|
const bytes = typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.length
|
|
143
174
|
const emittedBytes = this.#emittedDataBytes - emittedBefore
|
|
144
|
-
if (bytes > emittedBytes)
|
|
175
|
+
if (bytes > emittedBytes) {
|
|
176
|
+
this.#deliveredReadableBytes += bytes - emittedBytes
|
|
177
|
+
this.#connection._deliverReadable?.(this.#streamId, bytes - emittedBytes)
|
|
178
|
+
this.#connection._consumeReadable?.(this.#streamId, bytes - emittedBytes)
|
|
179
|
+
}
|
|
145
180
|
}
|
|
146
181
|
return chunk
|
|
147
182
|
}
|
|
@@ -155,14 +190,57 @@ export class VirtualSocket extends Duplex {
|
|
|
155
190
|
* @override
|
|
156
191
|
*/
|
|
157
192
|
emit(event, ...args) {
|
|
158
|
-
const emitted = super.emit(event, ...args)
|
|
159
193
|
const chunk = args[0]
|
|
160
194
|
if (event === "data" && (typeof chunk === "string" || Buffer.isBuffer(chunk))) {
|
|
161
195
|
const bytes = typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.length
|
|
162
196
|
this.#emittedDataBytes += bytes
|
|
197
|
+
this.#deliveredReadableBytes += bytes
|
|
198
|
+
this.#connection._deliverReadable?.(this.#streamId, bytes)
|
|
199
|
+
const emitted = super.emit(event, ...args)
|
|
163
200
|
this.#connection._consumeReadable?.(this.#streamId, bytes)
|
|
201
|
+
return emitted
|
|
164
202
|
}
|
|
165
|
-
return
|
|
203
|
+
return super.emit(event, ...args)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Durably admits one complete application operation before any of its bytes
|
|
208
|
+
* become eligible for transmission.
|
|
209
|
+
* @param {string | ArrayBufferView} chunk - Complete serialized operation.
|
|
210
|
+
* @param {BufferEncoding} [encoding] - String encoding.
|
|
211
|
+
* @returns {Promise<void>} Resolves after all bytes and recovery metadata are committed.
|
|
212
|
+
*/
|
|
213
|
+
writeDurable(chunk, encoding = "utf8") {
|
|
214
|
+
if (this.destroyed) return Promise.reject(contractError("Socketduct stream is destroyed", "ERR_STREAM_DESTROYED"))
|
|
215
|
+
if (this.writableEnded || this.writableFinished) return Promise.reject(contractError("Socketduct stream has ended", "ERR_STREAM_WRITE_AFTER_END"))
|
|
216
|
+
if (this.#connection._writeDurableStream === undefined) return Promise.reject(contractError("Durable write admission is unavailable for this Socketduct transport", "SOCKETDUCT_DURABLE_WRITE_UNSUPPORTED"))
|
|
217
|
+
const data = bufferFromView(chunk, encoding)
|
|
218
|
+
this.#resetTimeout()
|
|
219
|
+
return new Promise((resolve, reject) => {
|
|
220
|
+
this.#durableWrites.set(data, {resolve, reject})
|
|
221
|
+
this.write(data, (error) => {
|
|
222
|
+
if (error !== undefined && error !== null) reject(error)
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Advances the durable receive ACK by application bytes after parser/journal
|
|
229
|
+
* state is durable.
|
|
230
|
+
* @param {number} bytes - Newly durable application bytes.
|
|
231
|
+
* @returns {Promise<number>} New cumulative durable receive sequence.
|
|
232
|
+
*/
|
|
233
|
+
acknowledgeReceive(bytes) {
|
|
234
|
+
if (!this.#manualReceiveAck) throw contractError("Manual receive acknowledgement is not enabled for this socket", "SOCKETDUCT_MANUAL_ACK_DISABLED")
|
|
235
|
+
if (!Number.isSafeInteger(bytes) || bytes <= 0) throw contractError("Receive acknowledgement bytes must be a positive safe integer", "SOCKETDUCT_INVALID_RECEIVE_ACK")
|
|
236
|
+
if (this.destroyed) return Promise.reject(contractError("Socketduct stream is destroyed", "ERR_STREAM_DESTROYED"))
|
|
237
|
+
if (this.#requestedReceiveAckBytes + bytes > this.#deliveredReadableBytes) throw contractError("Receive acknowledgement exceeds bytes delivered to the application", "SOCKETDUCT_INVALID_RECEIVE_ACK")
|
|
238
|
+
if (this.#connection._acknowledgeReadable === undefined) return Promise.reject(contractError("Manual receive acknowledgement is unavailable for this Socketduct transport", "SOCKETDUCT_MANUAL_ACK_UNSUPPORTED"))
|
|
239
|
+
this.#requestedReceiveAckBytes += bytes
|
|
240
|
+
return this.#connection._acknowledgeReadable(this.#streamId, bytes).catch((error) => {
|
|
241
|
+
this.#requestedReceiveAckBytes -= bytes
|
|
242
|
+
throw error
|
|
243
|
+
})
|
|
166
244
|
}
|
|
167
245
|
|
|
168
246
|
/**
|
|
@@ -173,6 +251,24 @@ export class VirtualSocket extends Duplex {
|
|
|
173
251
|
* @returns {void}
|
|
174
252
|
*/
|
|
175
253
|
_write(chunk, encoding, callback) {
|
|
254
|
+
const durableChunk = Buffer.isBuffer(chunk) ? /** @type {Buffer} */ (chunk) : undefined
|
|
255
|
+
const durable = durableChunk === undefined ? undefined : this.#durableWrites.get(durableChunk)
|
|
256
|
+
if (durable !== undefined) {
|
|
257
|
+
const data = /** @type {Buffer} */ (durableChunk)
|
|
258
|
+
this.#durableWrites.delete(data)
|
|
259
|
+
this.#resetTimeout()
|
|
260
|
+
this.#connection._writeDurableStream?.(this.#streamId, data, (error) => {
|
|
261
|
+
if (error !== undefined && error !== null) durable.reject(error)
|
|
262
|
+
else {
|
|
263
|
+
this.bytesWritten += data.length
|
|
264
|
+
durable.resolve()
|
|
265
|
+
}
|
|
266
|
+
// Admission failures reject writeDurable() but remain retryable, so the
|
|
267
|
+
// Writable queue advances without turning the whole socket terminal.
|
|
268
|
+
callback()
|
|
269
|
+
})
|
|
270
|
+
return
|
|
271
|
+
}
|
|
176
272
|
const data = typeof chunk === "string" ? Buffer.from(chunk, encoding) : Buffer.from(chunk)
|
|
177
273
|
this.#resetTimeout()
|
|
178
274
|
this.#connection._writeStream(this.#streamId, data, (error) => {
|
|
@@ -296,3 +392,22 @@ export class VirtualSocket extends Duplex {
|
|
|
296
392
|
this.#timeoutTimer.unref?.()
|
|
297
393
|
}
|
|
298
394
|
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* @param {string} message - Safe message.
|
|
398
|
+
* @param {string} code - Stable code.
|
|
399
|
+
*/
|
|
400
|
+
function contractError(message, code) {
|
|
401
|
+
return new SocketductStreamError(message, code)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* @param {string | ArrayBufferView} value - Bytes.
|
|
406
|
+
* @param {BufferEncoding} encoding - String encoding.
|
|
407
|
+
* @returns {Buffer} Owned bytes.
|
|
408
|
+
*/
|
|
409
|
+
function bufferFromView(value, encoding) {
|
|
410
|
+
if (typeof value === "string") return Buffer.from(value, encoding)
|
|
411
|
+
if (!ArrayBuffer.isView(value)) throw new TypeError("Durable write requires a string, TypedArray, or DataView")
|
|
412
|
+
return Buffer.from(value.buffer, value.byteOffset, value.byteLength)
|
|
413
|
+
}
|
|
@@ -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,14 +1,15 @@
|
|
|
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"
|
|
11
|
-
export {createMultiplexedSocketductTransportSet, MultiplexedSocketductTransportSet, SocketductTransportSetLimitError, SocketductTransportSetNotReadyError, SocketductTransportSetTerminalError} from "./client/multiplexed-transport-set.js"
|
|
12
|
+
export {createMultiplexedSocketductTransportSet, MultiplexedSocketductTransportSet, SocketductRecoveryLookupError, SocketductTransportSetLimitError, SocketductTransportSetNotReadyError, SocketductTransportSetTerminalError} from "./client/multiplexed-transport-set.js"
|
|
12
13
|
export {SocketductSessionLostError, SocketductStreamError, VirtualSocket} from "./client/virtual-socket.js"
|
|
13
14
|
export {createOutboundReverseGateway, createReverseGatewayConnection, OutboundReverseGateway, ReverseGatewayConnection, startReverseGatewayHub} from "./reverse/index.js"
|
|
14
15
|
|
|
@@ -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]}`) {
|