socketduct 0.1.21 → 0.1.22
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/changelog.d/20260928120416-reverse-gateway-spool-directory.md +3 -0
- package/changelog.d/20260928124500-critical-reserve-bound-test.md +3 -0
- package/docs/logging.md +5 -1
- package/docs/reverse-gateway.md +8 -0
- package/package.json +1 -1
- package/src/config/environment.js +5 -1
- package/src/reverse/runtime.js +1 -0
- package/test/e2e/reverse-gateway-process.test.js +120 -1
- package/test/unit/config/environment.test.js +25 -0
- package/test/unit/logging/compressed-log-writer.test.js +3 -1
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
# Outbound reverse gateway spool directory environment variable
|
|
2
|
+
|
|
3
|
+
The standalone outbound reverse gateway runtime now accepts `SOCKETDUCT_SPOOL_DIRECTORY`, which points the gateway's durable target-output spool at an operator-chosen persistent path. The default remains the process temporary directory, so containerized gateways can move the bounded spool behind exactly-once resume replay onto a host volume instead of the container's ephemeral, size-bounded `/tmp`. The variable is accepted only in `reverse-gateway` mode and is rejected in every other mode.
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
# Bound the compressed-log critical reserve test to a coverable charge
|
|
2
|
+
|
|
3
|
+
The compressed-log queue-pressure test asserted that a critical disconnect record is accepted while a 4096-byte queue holds a 1024-byte critical reserve, but the record's conservative charge (line bytes × 3 + 128) exceeds the reserve, so the writer correctly dropped it and the test failed on every run. Accepted log sequences are append-only and cannot be evicted without tearing the durable journal, so the test now reserves 2048 bytes — covering the critical record's charge — and the logging docs state the reserve sizing bound explicitly.
|
package/docs/logging.md
CHANGED
|
@@ -28,7 +28,11 @@ SOCKETDUCT_LOG_COMPRESSION_LEVEL=19
|
|
|
28
28
|
Those values are the defaults. Reaching the uncompressed frame limit starts a
|
|
29
29
|
flush immediately. Flush deadlines are measured from the oldest queued work and
|
|
30
30
|
are never extended by later records; critical lifecycle, security, and resume
|
|
31
|
-
events use the shorter deadline and may use the reserved queue capacity.
|
|
31
|
+
events use the shorter deadline and may use the reserved queue capacity. A
|
|
32
|
+
critical record is guaranteed acceptance only while its conservative charge
|
|
33
|
+
(line bytes × 3 + 128) fits the reserve, because accepted sequences are
|
|
34
|
+
append-only and can never be evicted from the durable journal; size the
|
|
35
|
+
reserve for the largest critical record you must retain under queue pressure.
|
|
32
36
|
Segment size, age, and directory quota are bounded independently. Invalid or mutually
|
|
33
37
|
inconsistent values stop startup.
|
|
34
38
|
The former `SOCKETDUCT_LOG_MAX_FILE_BYTES` and `SOCKETDUCT_LOG_MAX_FILES`
|
package/docs/reverse-gateway.md
CHANGED
|
@@ -298,9 +298,17 @@ SOCKETDUCT_REVERSE_TOKEN='<secret>' \
|
|
|
298
298
|
SOCKETDUCT_REVERSE_HUB_CA_FILE=/certs/hub-ca.crt \
|
|
299
299
|
SOCKETDUCT_REVERSE_HUB_SERVERNAME=server3.example.net \
|
|
300
300
|
SOCKETDUCT_TARGET_ALLOWLIST=docker-a:2375 \
|
|
301
|
+
SOCKETDUCT_SPOOL_DIRECTORY=/var/lib/socketduct/spool \
|
|
301
302
|
node src/index.js
|
|
302
303
|
```
|
|
303
304
|
|
|
305
|
+
`SOCKETDUCT_SPOOL_DIRECTORY` is reverse-gateway-only and points the gateway's
|
|
306
|
+
durable target-output spool — the bounded store behind exactly-once resume
|
|
307
|
+
replay — at an operator-chosen path. Without it the spool lives under the
|
|
308
|
+
process temporary directory, which is ephemeral and size-bounded inside a
|
|
309
|
+
container; set it to a persistent host mount so spool capacity and retention
|
|
310
|
+
do not depend on container lifetime. The runtime rejects it in every other mode.
|
|
311
|
+
|
|
304
312
|
The namespace/default-route configuration remains outside Socketduct. Each
|
|
305
313
|
gateway registration represents one exact target server and WAN class;
|
|
306
314
|
Socketduct does not aggregate away either identity. Socketduct needs no inbound
|
package/package.json
CHANGED
|
@@ -54,6 +54,7 @@ const SUPPORTED_PROTOCOL_VERSIONS = new Set([2, 3])
|
|
|
54
54
|
* @property {string} relayHost - Relay listen host.
|
|
55
55
|
* @property {number} relayPort - Relay listen port.
|
|
56
56
|
* @property {number} resumeWindowMs - Resume window in milliseconds.
|
|
57
|
+
* @property {string} [spoolDirectory] - Outbound gateway durable spool root; reverse-gateway mode only.
|
|
57
58
|
* @property {number} maxTargetBufferBytes - Per-session byte limit for buffered target data while the tunnel is detached.
|
|
58
59
|
* @property {number} maxTotalBufferBytes - Process-wide byte limit for buffered target data across all sessions.
|
|
59
60
|
* @property {number} [initialTargetRetryDelayMs] - Initial delay between target acquisition attempts. Default 250.
|
|
@@ -143,6 +144,8 @@ export function loadEnvironmentConfig(env = process.env) {
|
|
|
143
144
|
...(reverseClientCertFile === undefined ? {} : {certFile: reverseClientCertFile, keyFile: /** @type {string} */ (reverseClientKeyFile)}),
|
|
144
145
|
...(env.SOCKETDUCT_REVERSE_HUB_SERVERNAME?.trim() ? {servername: env.SOCKETDUCT_REVERSE_HUB_SERVERNAME.trim()} : {})
|
|
145
146
|
}
|
|
147
|
+
const spoolDirectory = env.SOCKETDUCT_SPOOL_DIRECTORY?.trim() || undefined
|
|
148
|
+
if (spoolDirectory !== undefined && mode !== "reverse-gateway") throw new Error("SOCKETDUCT_SPOOL_DIRECTORY is reverse-gateway-only")
|
|
146
149
|
const logDirectory = env.SOCKETDUCT_LOG_DIRECTORY?.trim() || undefined
|
|
147
150
|
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")
|
|
148
151
|
let fileLogging
|
|
@@ -251,7 +254,8 @@ export function loadEnvironmentConfig(env = process.env) {
|
|
|
251
254
|
port: parsePort(env.SOCKETDUCT_REVERSE_HUB_PORT, "SOCKETDUCT_REVERSE_HUB_PORT", DEFAULT_RELAY_PORT),
|
|
252
255
|
token: reverseToken ?? ""
|
|
253
256
|
},
|
|
254
|
-
reverseHubTls
|
|
257
|
+
reverseHubTls,
|
|
258
|
+
...(spoolDirectory === undefined ? {} : {spoolDirectory})
|
|
255
259
|
} : {})
|
|
256
260
|
}
|
|
257
261
|
}
|
package/src/reverse/runtime.js
CHANGED
|
@@ -80,6 +80,7 @@ export async function startOutboundReverseGatewayRuntime(config, logger) {
|
|
|
80
80
|
maxStreamBufferBytes: config.maxTargetBufferBytes,
|
|
81
81
|
maxTotalBufferBytes: config.maxTotalBufferBytes,
|
|
82
82
|
compressionLimits: compressionLimits(config),
|
|
83
|
+
...(config.spoolDirectory === undefined ? {} : {spoolDirectory: config.spoolDirectory}),
|
|
83
84
|
...(logger === undefined ? {} : {logger})
|
|
84
85
|
})
|
|
85
86
|
await gateway.start()
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-param, jsdoc/require-returns */
|
|
2
2
|
import assert from "node:assert/strict"
|
|
3
3
|
import {spawn} from "node:child_process"
|
|
4
|
-
import {mkdtemp, rm} from "node:fs/promises"
|
|
4
|
+
import {mkdtemp, readFile, readdir, rm} from "node:fs/promises"
|
|
5
5
|
import {createServer} from "node:net"
|
|
6
6
|
import {tmpdir} from "node:os"
|
|
7
7
|
import {join} from "node:path"
|
|
@@ -131,6 +131,31 @@ async function withTimeout(promise, label) {
|
|
|
131
131
|
}
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
/** @param {string} directory - Directory. @returns {Promise<string[]>} Entries, or an empty list for a missing directory. */
|
|
135
|
+
async function readDirectoryOrEmpty(directory) {
|
|
136
|
+
try {
|
|
137
|
+
return await readdir(directory)
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (error instanceof Error && /** @type {Error & {code?: string}} */ (error).code === "ENOENT") return []
|
|
140
|
+
throw error
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Polls until the probe resolves truthy, bounded by the process timeout.
|
|
146
|
+
* @param {() => boolean | Promise<boolean>} probe - Condition to test.
|
|
147
|
+
* @param {string} label - Timeout label.
|
|
148
|
+
* @returns {Promise<void>}
|
|
149
|
+
*/
|
|
150
|
+
async function waitUntil(probe, label) {
|
|
151
|
+
const deadline = Date.now() + PROCESS_TIMEOUT_MS
|
|
152
|
+
for (;;) {
|
|
153
|
+
if (await probe()) return
|
|
154
|
+
if (Date.now() >= deadline) throw new Error(`Timed out: ${label}`)
|
|
155
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
134
159
|
/** @param {import('node:events').EventEmitter} emitter - Source. @param {string} event - Event. */
|
|
135
160
|
async function onceEvent(emitter, event) {
|
|
136
161
|
await new Promise((resolve, reject) => {
|
|
@@ -377,3 +402,97 @@ test("one route-class client physical connection interleaves streams through dif
|
|
|
377
402
|
streamA.destroy()
|
|
378
403
|
streamB.destroy()
|
|
379
404
|
})
|
|
405
|
+
|
|
406
|
+
test("outbound gateway process spools durably under SOCKETDUCT_SPOOL_DIRECTORY when the hub link is lost", async (t) => {
|
|
407
|
+
const root = await mkdtemp(join(tmpdir(), "socketduct-reverse-spool-process-"))
|
|
408
|
+
t.after(() => rm(root, {recursive: true, force: true}))
|
|
409
|
+
const certificates = generateTestCertificates(root)
|
|
410
|
+
const gatewaySpoolDirectory = join(root, "gateway-spool")
|
|
411
|
+
const clientSpoolDirectory = join(root, "client-spool")
|
|
412
|
+
/** @type {import('node:net').Socket | undefined} */
|
|
413
|
+
let targetSocket
|
|
414
|
+
const targetSockets = new Set()
|
|
415
|
+
const target = createServer((socket) => {
|
|
416
|
+
targetSocket = socket
|
|
417
|
+
targetSockets.add(socket)
|
|
418
|
+
socket.once("close", () => targetSockets.delete(socket))
|
|
419
|
+
socket.on("data", (chunk) => socket.write(Buffer.concat([Buffer.from("docker:"), chunk])))
|
|
420
|
+
})
|
|
421
|
+
const targetPort = await listen(target)
|
|
422
|
+
t.after(() => {
|
|
423
|
+
for (const socket of targetSockets) socket.destroy()
|
|
424
|
+
return new Promise((resolve) => target.close(() => resolve(undefined)))
|
|
425
|
+
})
|
|
426
|
+
const {child: hub, port: hubPort} = await startProcessOnReservedPort({
|
|
427
|
+
SOCKETDUCT_MODE: "reverse-hub",
|
|
428
|
+
SOCKETDUCT_RELAY_HOST: "127.0.0.1",
|
|
429
|
+
SOCKETDUCT_TLS_CERT_FILE: certificates.serverCertFile,
|
|
430
|
+
SOCKETDUCT_TLS_KEY_FILE: certificates.serverKeyFile,
|
|
431
|
+
SOCKETDUCT_AUTH_TOKENS: "home-o2:spool-gateway-token,tensorbuzz:spool-client-token",
|
|
432
|
+
SOCKETDUCT_REVERSE_GATEWAY_BINDINGS: "o2:home-o2",
|
|
433
|
+
SOCKETDUCT_REVERSE_ROUTES: `tensorbuzz:o2:127.0.0.1:${targetPort}`,
|
|
434
|
+
SOCKETDUCT_STATUS_INTERVAL_MS: "0"
|
|
435
|
+
}, "reverse-hub.started")
|
|
436
|
+
const gateway = await startProcess({
|
|
437
|
+
SOCKETDUCT_MODE: "reverse-gateway",
|
|
438
|
+
SOCKETDUCT_REVERSE_GATEWAY_ID: "o2",
|
|
439
|
+
SOCKETDUCT_REVERSE_CONNECTION_ID: "spool_process_gateway",
|
|
440
|
+
SOCKETDUCT_REVERSE_HUB_HOST: "127.0.0.1",
|
|
441
|
+
SOCKETDUCT_REVERSE_HUB_PORT: String(hubPort),
|
|
442
|
+
SOCKETDUCT_REVERSE_TOKEN: "spool-gateway-token",
|
|
443
|
+
SOCKETDUCT_REVERSE_HUB_CA_FILE: certificates.caCertFile,
|
|
444
|
+
SOCKETDUCT_REVERSE_HUB_SERVERNAME: "localhost",
|
|
445
|
+
SOCKETDUCT_TARGET_ALLOWLIST: `127.0.0.1:${targetPort}`,
|
|
446
|
+
SOCKETDUCT_STATUS_INTERVAL_MS: "0",
|
|
447
|
+
SOCKETDUCT_SPOOL_DIRECTORY: gatewaySpoolDirectory
|
|
448
|
+
}, "reverse-gateway.started")
|
|
449
|
+
t.after(() => stopProcess(gateway))
|
|
450
|
+
const client = new ReverseGatewayConnection({
|
|
451
|
+
gatewayId: "o2",
|
|
452
|
+
connectionId: "spool_process_client",
|
|
453
|
+
hub: {host: "127.0.0.1", port: hubPort, token: "spool-client-token"},
|
|
454
|
+
tls: {caFile: certificates.caCertFile, servername: "localhost"},
|
|
455
|
+
spoolDirectory: clientSpoolDirectory,
|
|
456
|
+
reconnectDelayMs: 50,
|
|
457
|
+
maxReconnectDelayMs: 100
|
|
458
|
+
})
|
|
459
|
+
t.after(() => client.close())
|
|
460
|
+
await withTimeout(client.connect(), "spool client connect")
|
|
461
|
+
const socket = client.createConnection({host: "127.0.0.1", port: targetPort, sessionName: "docker-spool-process"})
|
|
462
|
+
await withTimeout(onceEvent(socket, "connect"), "spool stream open")
|
|
463
|
+
const initialResponse = new Promise((resolve, reject) => { socket.once("error", reject); socket.once("data", resolve) })
|
|
464
|
+
socket.write("GET /version")
|
|
465
|
+
assert.equal(Buffer.from(/** @type {Uint8Array} */ (await withTimeout(initialResponse, "spool initial response"))).toString(), "docker:GET /version")
|
|
466
|
+
|
|
467
|
+
hub.kill("SIGKILL")
|
|
468
|
+
await withTimeout(new Promise((resolve) => hub.once("exit", () => resolve(undefined))), "hub exit")
|
|
469
|
+
assert(targetSocket !== undefined)
|
|
470
|
+
const spooledMarker = Buffer.alloc(32, 0x61)
|
|
471
|
+
targetSocket.write(spooledMarker)
|
|
472
|
+
const spoolHasPayload = async () => {
|
|
473
|
+
const markerBase64 = spooledMarker.toString("base64")
|
|
474
|
+
for (const session of await readDirectoryOrEmpty(gatewaySpoolDirectory)) {
|
|
475
|
+
const files = await readDirectoryOrEmpty(join(gatewaySpoolDirectory, session))
|
|
476
|
+
if (!files.includes("chunks.jsonl")) continue
|
|
477
|
+
const lines = (await readFile(join(gatewaySpoolDirectory, session, "chunks.jsonl"), "utf8")).split("\n")
|
|
478
|
+
if (lines.some((line) => {
|
|
479
|
+
if (line === "") return false
|
|
480
|
+
try {
|
|
481
|
+
return /** @type {{dataBase64?: unknown}} */ (JSON.parse(line)).dataBase64 === markerBase64
|
|
482
|
+
} catch (error) {
|
|
483
|
+
if (error instanceof SyntaxError) return false
|
|
484
|
+
throw error
|
|
485
|
+
}
|
|
486
|
+
})) return true
|
|
487
|
+
}
|
|
488
|
+
return false
|
|
489
|
+
}
|
|
490
|
+
await waitUntil(spoolHasPayload, "durable spool chunk under SOCKETDUCT_SPOOL_DIRECTORY")
|
|
491
|
+
const defaultGatewaySpoolRoot = join(tmpdir(), `socketduct-${typeof process.getuid === "function" ? process.getuid() : "user"}`, "reverse-gateway")
|
|
492
|
+
const defaultSpooled = (await readDirectoryOrEmpty(defaultGatewaySpoolRoot)).some((entry) => entry.startsWith("o2-spool_process_gateway"))
|
|
493
|
+
assert.equal(defaultSpooled, false, "spool must not fall back to the ephemeral default root")
|
|
494
|
+
|
|
495
|
+
socket.destroy()
|
|
496
|
+
client.close()
|
|
497
|
+
await stopProcess(gateway)
|
|
498
|
+
})
|
|
@@ -337,6 +337,31 @@ test("loadEnvironmentConfig parses outbound reverse gateway verified TLS setting
|
|
|
337
337
|
assert.deepEqual(config.reverseHubTls, {caFile: "/certs/ca.crt", certFile: "/certs/client.crt", keyFile: "/certs/client.key", servername: "server3.example.test"})
|
|
338
338
|
})
|
|
339
339
|
|
|
340
|
+
test("loadEnvironmentConfig parses the reverse-gateway spool directory", () => {
|
|
341
|
+
const config = loadEnvironmentConfig({
|
|
342
|
+
SOCKETDUCT_MODE: "reverse-gateway",
|
|
343
|
+
SOCKETDUCT_REVERSE_GATEWAY_ID: "starlink",
|
|
344
|
+
SOCKETDUCT_REVERSE_CONNECTION_ID: "home_starlink_01",
|
|
345
|
+
SOCKETDUCT_REVERSE_HUB_HOST: "server3.example.test",
|
|
346
|
+
SOCKETDUCT_REVERSE_TOKEN: "gateway-token",
|
|
347
|
+
SOCKETDUCT_TARGET_ALLOWLIST: "docker-server:2375",
|
|
348
|
+
SOCKETDUCT_SPOOL_DIRECTORY: " /spool/reverse-gateway "
|
|
349
|
+
})
|
|
350
|
+
assert.equal(config.spoolDirectory, "/spool/reverse-gateway")
|
|
351
|
+
assert.equal("spoolDirectory" in loadEnvironmentConfig({
|
|
352
|
+
SOCKETDUCT_MODE: "reverse-gateway",
|
|
353
|
+
SOCKETDUCT_REVERSE_GATEWAY_ID: "starlink",
|
|
354
|
+
SOCKETDUCT_REVERSE_CONNECTION_ID: "home_starlink_01",
|
|
355
|
+
SOCKETDUCT_REVERSE_HUB_HOST: "server3.example.test",
|
|
356
|
+
SOCKETDUCT_REVERSE_TOKEN: "gateway-token",
|
|
357
|
+
SOCKETDUCT_TARGET_ALLOWLIST: "docker-server:2375",
|
|
358
|
+
SOCKETDUCT_SPOOL_DIRECTORY: " "
|
|
359
|
+
}), false)
|
|
360
|
+
assert.throws(() => loadEnvironmentConfig({SOCKETDUCT_MODE: "relay", SOCKETDUCT_SPOOL_DIRECTORY: "/spool"}), /reverse-gateway-only/)
|
|
361
|
+
assert.throws(() => loadEnvironmentConfig({SOCKETDUCT_MODE: "reverse-hub", SOCKETDUCT_SPOOL_DIRECTORY: "/spool"}), /reverse-gateway-only/)
|
|
362
|
+
assert.throws(() => loadEnvironmentConfig({SOCKETDUCT_SPOOL_DIRECTORY: "/spool"}), /reverse-gateway-only/)
|
|
363
|
+
})
|
|
364
|
+
|
|
340
365
|
test("loadEnvironmentConfig rejects malformed reverse mode policy and credentials", () => {
|
|
341
366
|
assert.throws(() => loadEnvironmentConfig({SOCKETDUCT_MODE: "unknown"}), /Unsupported SOCKETDUCT_MODE/)
|
|
342
367
|
assert.throws(() => loadEnvironmentConfig({SOCKETDUCT_MODE: "reverse-hub", SOCKETDUCT_REVERSE_GATEWAY_BINDINGS: "missing-user"}), /gatewayId:username/)
|
|
@@ -61,11 +61,13 @@ test("compressed logger durably acknowledges ordered records and closes asynchro
|
|
|
61
61
|
test("compressed logger enforces one shared bounded queue with a critical reserve", async (t) => {
|
|
62
62
|
const root = await mkdtemp(join(tmpdir(), "socketduct-compressed-pressure-"))
|
|
63
63
|
const directory = join(root, "logs")
|
|
64
|
+
// The reserve must cover the critical event's conservative charge (line bytes × 3 + 128);
|
|
65
|
+
// an accepted-but-undelivered sequence can never be evicted without tearing the durable journal.
|
|
64
66
|
const logger = createCompressedJsonlFileLogger({
|
|
65
67
|
directory,
|
|
66
68
|
role: "reverse-client",
|
|
67
69
|
maxQueueBytes: 4096,
|
|
68
|
-
criticalReserveBytes:
|
|
70
|
+
criticalReserveBytes: 2048,
|
|
69
71
|
frameMaxBytes: 1024,
|
|
70
72
|
flushIntervalMs: 60_000,
|
|
71
73
|
criticalFlushIntervalMs: 60_000
|