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.
@@ -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`
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "socketduct",
3
- "version": "0.1.21",
3
+ "version": "0.1.22",
4
4
  "description": "Resilient authenticated tunnel/proxy for keeping socket streams alive across flaky links.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -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
  }
@@ -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: 1024,
70
+ criticalReserveBytes: 2048,
69
71
  frameMaxBytes: 1024,
70
72
  flushIntervalMs: 60_000,
71
73
  criticalFlushIntervalMs: 60_000