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.
Files changed (59) 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/changelog.d/20260925180000-manual-ack-durable-admission.md +1 -0
  5. package/docs/docker.md +5 -4
  6. package/docs/logging.md +131 -159
  7. package/docs/protocol.md +48 -7
  8. package/docs/reverse-gateway.md +83 -8
  9. package/docs/running.md +17 -13
  10. package/docs/security.md +7 -3
  11. package/docs/session-lifecycle.md +22 -0
  12. package/docs/socket-api.md +22 -17
  13. package/package.json +5 -1
  14. package/scripts/verify-zstd-interoperability.mjs +41 -0
  15. package/src/cli/socketduct.js +143 -0
  16. package/src/client/deferred-virtual-socket.js +42 -1
  17. package/src/client/multiplexed-connection.js +17 -0
  18. package/src/client/multiplexed-transport-set.js +43 -8
  19. package/src/client/multiplexed.js +1 -1
  20. package/src/client/virtual-socket.js +119 -4
  21. package/src/config/environment.js +56 -7
  22. package/src/index.js +65 -27
  23. package/src/logging/compressed-log-reader.js +256 -0
  24. package/src/logging/compressed-log-worker.js +430 -0
  25. package/src/logging/compressed-log-writer.js +538 -0
  26. package/src/logging/log-batch.js +72 -0
  27. package/src/logging/log-retention.js +241 -0
  28. package/src/logging/log-segment-index.js +176 -0
  29. package/src/logging/structured-logger.js +136 -30
  30. package/src/logging/zstd-frame-codec.js +73 -0
  31. package/src/reverse/client.js +245 -34
  32. package/src/reverse/disconnect-attribution.js +191 -0
  33. package/src/reverse/gateway.js +68 -9
  34. package/src/reverse/hub.js +215 -32
  35. package/src/reverse/runtime.js +5 -3
  36. package/src/spool/spool-file.js +92 -6
  37. package/tensorbuzz.yml +2 -1
  38. package/test/e2e/logger-shutdown-process.test.js +86 -0
  39. package/test/e2e/reverse-client-recovery-process.test.js +3 -0
  40. package/test/e2e/reverse-gateway-process.test.js +15 -14
  41. package/test/fixtures/reverse-client-recovery.mjs +14 -2
  42. package/test/integration/logging-cli.test.js +168 -0
  43. package/test/integration/logging-compressed-recovery.test.js +186 -0
  44. package/test/integration/multiplexed-transport-set.test.js +1 -5
  45. package/test/integration/reverse-gateway.test.js +334 -1
  46. package/test/integration/reverse-route-class.test.js +6 -1
  47. package/test/unit/client/multiplexed-transport-set.test.js +50 -3
  48. package/test/unit/client/virtual-socket.test.js +123 -1
  49. package/test/unit/config/docker-gateway-files.test.js +2 -0
  50. package/test/unit/config/environment.test.js +54 -14
  51. package/test/unit/index.test.js +5 -0
  52. package/test/unit/logging/compressed-log-worker.test.js +29 -0
  53. package/test/unit/logging/compressed-log-writer.test.js +183 -0
  54. package/test/unit/logging/log-retention.test.js +86 -0
  55. package/test/unit/logging/log-segment-index.test.js +104 -0
  56. package/test/unit/logging/structured-logger.test.js +14 -1
  57. package/test/unit/logging/zstd-frame-codec.test.js +24 -0
  58. package/test/unit/reverse/disconnect-attribution.test.js +82 -0
  59. package/test/unit/spool/spool-file.test.js +58 -0
package/.env.example CHANGED
@@ -19,16 +19,23 @@ SOCKETDUCT_RESUME_WINDOW_MS=30000
19
19
  # Emit secret-free JSON runtime counters at this interval. Set 0 to disable.
20
20
  SOCKETDUCT_STATUS_INTERVAL_MS=30000
21
21
 
22
- # Optional bounded persistent JSONL logs. Bind mount this directory from host
23
- # storage in Docker. Files are isolated by role/PID; defaults are 10 MiB and 5
24
- # retained files including the active file. Existing stdout/stderr is preserved.
22
+ # Optional durable compressed JSONL logs. Bind mount this directory from host
23
+ # storage in Docker. Standard checksummed zstd frames and their private index
24
+ # are written by an isolated worker. Existing stdout/stderr is preserved.
25
25
  # Host directory mounted by the checked-in production Compose files. Create it
26
26
  # for uid/gid 1000 before deployment. Logging stays disabled unless the
27
27
  # container-side SOCKETDUCT_LOG_DIRECTORY is also set.
28
28
  # SOCKETDUCT_LOG_HOST_DIRECTORY=/srv/socketduct/logs
29
29
  # SOCKETDUCT_LOG_DIRECTORY=/var/log/socketduct
30
- # SOCKETDUCT_LOG_MAX_FILE_BYTES=10485760
31
- # SOCKETDUCT_LOG_MAX_FILES=5
30
+ # SOCKETDUCT_LOG_FRAME_MAX_BYTES=1048576
31
+ # SOCKETDUCT_LOG_FLUSH_INTERVAL_MS=1000
32
+ # SOCKETDUCT_LOG_CRITICAL_FLUSH_INTERVAL_MS=250
33
+ # SOCKETDUCT_LOG_SEGMENT_MAX_BYTES=134217728
34
+ # SOCKETDUCT_LOG_RETENTION_DAYS=30
35
+ # SOCKETDUCT_LOG_RETENTION_MAX_BYTES=4294967296
36
+ # SOCKETDUCT_LOG_QUEUE_MAX_BYTES=67108864
37
+ # SOCKETDUCT_LOG_CRITICAL_RESERVE_BYTES=8388608
38
+ # SOCKETDUCT_LOG_COMPRESSION_LEVEL=19
32
39
 
33
40
  # Per-session hard byte limit for target-to-client data buffered while the
34
41
  # edge tunnel is detached. When the limit is exceeded the relay terminates
@@ -113,6 +120,11 @@ SOCKETDUCT_TARGET_ALLOWLIST=docker-socket-shim:2375
113
120
  # Server3 hub: change SOCKETDUCT_MODE to reverse-hub, keep the TLS server pair
114
121
  # above, and bind every stable gateway ID to its credential username.
115
122
  # SOCKETDUCT_REVERSE_GATEWAY_BINDINGS=o2:home-o2,zyxel:home-zyxel,starlink:home-starlink
123
+ # Required when a reverse hub enables SOCKETDUCT_LOG_DIRECTORY. Generate 32-64
124
+ # random bytes, encode them as unpadded base64url, keep the key hub-only, and
125
+ # rotate it with the public URL-safe epoch label at least every 30 days.
126
+ # SOCKETDUCT_CONNECTION_FINGERPRINT_KEY=<unpadded-base64url-secret>
127
+ # SOCKETDUCT_CONNECTION_FINGERPRINT_KEY_EPOCH=2026-09
116
128
  # Authorize exact client username, gateway ID, and home-local target tuples.
117
129
  # SOCKETDUCT_REVERSE_ROUTES=tensorbuzz:o2:docker-server:2375,tensorbuzz:zyxel:docker-server:2375,tensorbuzz:starlink:docker-server:2375
118
130
  # Or bind one client physical connection to a route class and resolve each
package/README.md CHANGED
@@ -35,8 +35,8 @@ Normal TCP sockets cannot magically resume after the underlying internet path di
35
35
  sockets, and deterministic per-socket round-robin across ready members
36
36
  - native reverse-gateway v3 lets authenticated private home members register
37
37
  outbound with a Server3 hub, so CGNAT paths need no inbound forwarding
38
- - optional bounded JSONL lifecycle logs can be written to per-process files in
39
- a Docker bind-mounted directory without replacing existing stdout/stderr
38
+ - optional bounded diagnostic JSONL is stored as checksummed standard zstd
39
+ frames with an owned tail/search CLI, without replacing stdout/stderr
40
40
 
41
41
  ## MVP goals
42
42
 
@@ -113,7 +113,7 @@ hub address because `gatewayId` selects the outbound member. Docker remains
113
113
  - [Docker topology](docs/docker-topology.md)
114
114
  - [Node-compatible socket API design](docs/socket-api.md)
115
115
  - [Native reverse gateway](docs/reverse-gateway.md)
116
- - [Persistent structured logging](docs/logging.md)
116
+ - [Durable compressed diagnostic logs](docs/logging.md)
117
117
  - [Initial implementation plan](docs/plans/initial-implementation.md)
118
118
  - [Socket + Docker integration plan](docs/plans/socket-docker-integration.md)
119
119
 
@@ -0,0 +1 @@
1
+ Socketduct now preserves reverse disconnect causes across terminal errors and writes bounded, recovery-safe indexed zstd diagnostics with incremental owned tail/search behavior.
@@ -0,0 +1 @@
1
+ Reverse clients can now durably admit complete writes, manually acknowledge application-committed receive bytes, and recover transport-set streams by stable identifier alone.
package/docs/docker.md CHANGED
@@ -162,14 +162,15 @@ sudo install -d -o 1000 -g 1000 -m 0700 /srv/socketduct/logs
162
162
  ```text
163
163
  SOCKETDUCT_LOG_HOST_DIRECTORY=/srv/socketduct/logs
164
164
  SOCKETDUCT_LOG_DIRECTORY=/var/log/socketduct
165
- SOCKETDUCT_LOG_MAX_FILE_BYTES=10485760
166
- SOCKETDUCT_LOG_MAX_FILES=5
165
+ SOCKETDUCT_LOG_SEGMENT_MAX_BYTES=134217728
166
+ SOCKETDUCT_LOG_RETENTION_DAYS=30
167
+ SOCKETDUCT_LOG_RETENTION_MAX_BYTES=4294967296
167
168
  ```
168
169
 
169
170
  The production relay and reverse-gateway Compose files mount the host directory
170
171
  at `/var/log/socketduct`. Existing container stdout/stderr remains enabled. See
171
- [Persistent structured logging](logging.md) for file names, rotation, embedded
172
- client configuration, and the redaction contract.
172
+ [durable compressed diagnostic logs](logging.md) for the indexed frame layout,
173
+ owned tail/search CLI, rotation, embedded configuration, and privacy contract.
173
174
 
174
175
  Expected startup log (formatted here for readability; the actual line is JSON):
175
176
 
package/docs/logging.md CHANGED
@@ -1,185 +1,157 @@
1
- # Persistent structured logging
1
+ # Durable compressed diagnostic logs
2
2
 
3
- Socketduct can write lifecycle diagnostics as newline-delimited JSON (JSONL) to
4
- a directory mounted from persistent host storage. File logging is opt-in.
5
- Existing startup/status output on stdout and errors on stderr remain unchanged
6
- when file logging is enabled.
3
+ Socketduct’s managed runtime can write sanitized lifecycle diagnostics as
4
+ newline-delimited JSON records inside standard Zstandard frames. Logging remains
5
+ opt-in: set `SOCKETDUCT_LOG_DIRECTORY` for a `relay`, `reverse-hub`,
6
+ `reverse-gateway`, or summary process. Existing stdout/stderr output is unchanged.
7
7
 
8
- ## Standalone runtime configuration
8
+ The managed sink never falls back to plaintext. The older
9
+ `createJsonlFileLogger()` API remains available only for embedded callers that
10
+ explicitly select its legacy plaintext files; managed runtimes use
11
+ `createCompressedJsonlFileLogger()`.
9
12
 
10
- Set `SOCKETDUCT_LOG_DIRECTORY` for a `relay`, `reverse-hub`, or
11
- `reverse-gateway` process:
13
+ ## Configuration
12
14
 
13
15
  ```sh
14
16
  SOCKETDUCT_LOG_DIRECTORY=/var/log/socketduct
15
- SOCKETDUCT_LOG_MAX_FILE_BYTES=10485760
16
- SOCKETDUCT_LOG_MAX_FILES=5
17
+ SOCKETDUCT_LOG_FRAME_MAX_BYTES=1048576
18
+ SOCKETDUCT_LOG_FLUSH_INTERVAL_MS=1000
19
+ SOCKETDUCT_LOG_CRITICAL_FLUSH_INTERVAL_MS=250
20
+ SOCKETDUCT_LOG_SEGMENT_MAX_BYTES=134217728
21
+ SOCKETDUCT_LOG_RETENTION_DAYS=30
22
+ SOCKETDUCT_LOG_RETENTION_MAX_BYTES=4294967296
23
+ SOCKETDUCT_LOG_QUEUE_MAX_BYTES=67108864
24
+ SOCKETDUCT_LOG_CRITICAL_RESERVE_BYTES=8388608
25
+ SOCKETDUCT_LOG_COMPRESSION_LEVEL=19
17
26
  ```
18
27
 
19
- `SOCKETDUCT_LOG_MAX_FILE_BYTES` defaults to 10 MiB and must be at least 1024.
20
- `SOCKETDUCT_LOG_MAX_FILES` defaults to 5, includes the active file, and must be
21
- between 1 and 100. The size/count settings are ignored unless a non-empty log
22
- directory enables the sink.
23
-
24
- Each process writes its own `<role>-<pid>.jsonl` file, with older files renamed
25
- to `.1`, `.2`, and so on. A writer never rotates another live process's active
26
- file, which avoids cross-process rename races. Each file in the current
27
- process/role family is bounded by the configured byte limit and the family
28
- retains at most the configured file count. A reused PID continues the same
29
- bounded filename family.
30
-
31
- On its first write, a logger also caps files left by definitely exited
32
- same-role process instances to the configured file count in total, retaining
33
- the newest files. Repeated signal-0 PID probes must report `ESRCH`, and the file
34
- must remain unchanged, before it can be deleted. A live/reused PID, permission
35
- failure, unsupported probe, changed file, or other indeterminate result
36
- preserves every file in that family. Files are created with mode `0600`; a newly
37
- created log directory uses mode `0700`.
38
-
39
- For Docker, bind mount a persistent host directory and point Socketduct at the
40
- container path:
41
-
42
- ```yaml
43
- services:
44
- socketduct:
45
- environment:
46
- SOCKETDUCT_LOG_DIRECTORY: /var/log/socketduct
47
- SOCKETDUCT_LOG_MAX_FILE_BYTES: "10485760"
48
- SOCKETDUCT_LOG_MAX_FILES: "5"
49
- volumes:
50
- - /srv/socketduct/logs:/var/log/socketduct
51
- ```
28
+ Those values are the defaults. Reaching the uncompressed frame limit starts a
29
+ flush immediately. Flush deadlines are measured from the oldest queued work and
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.
32
+ Segment size, age, and directory quota are bounded independently. Invalid or mutually
33
+ inconsistent values stop startup.
34
+ The former `SOCKETDUCT_LOG_MAX_FILE_BYTES` and `SOCKETDUCT_LOG_MAX_FILES`
35
+ plaintext settings are rejected instead of being silently applied to the new
36
+ format.
52
37
 
53
- Create `/srv/socketduct/logs` with mode `0700` and ownership that permits the
54
- container user to write it; for uid/gid 1000, use
55
- `sudo install -d -o 1000 -g 1000 -m 0700 /srv/socketduct/logs`.
56
- A permission, disk, or rotation error does not change tunnel,
57
- session, target, or reconnect behavior. Socketduct reports only
58
- `Socketduct logger failure (LOGGER_IO_ERROR)` to stderr, at most once per
59
- logger, without including the path or underlying error text.
60
-
61
- ## Interrupted writes and retained evidence
62
-
63
- Before appending, the file sink checks the last byte of a nonempty active file.
64
- This bounded check also runs for a fresh logger or restarted process reusing
65
- the same filename; it does not scan or parse historical records. A failed
66
- append may have written a prefix even when the filesystem reports an error.
67
-
68
- If the active file does not end in a newline, Socketduct moves the entire file
69
- byte-for-byte into the existing numbered retention family and writes subsequent
70
- events to a clean active file. The retained segment may still contain an
71
- incomplete final record: it is incident evidence, not repaired JSONL. Normal
72
- bounded retention eventually ages it out, so preserve incident evidence outside
73
- the managed family before rollout or forensic work. New complete records must
74
- parse independently; ignoring malformed historical lines is not proof of recovery.
75
-
76
- If tail inspection, rotation, clean-file creation, or mode verification fails,
77
- that logging call writes no new record. Later ordinary calls recheck the
78
- filesystem; there is no background retry loop or replay of missed diagnostics.
79
- If an append itself fails, it may leave another incomplete prefix that the next
80
- call must separate. Logging failures still do not interrupt tunnel traffic.
81
-
82
- With `maxFiles: 1`, there is no retained slot: an incomplete active file is left
83
- untouched and file logging pauses. An incomplete active file already larger
84
- than a newly lowered size limit also pauses logging **before startup pruning**;
85
- it is not deleted to satisfy that limit. These pre-existing bytes can remain
86
- outside the newly configured bound, but Socketduct will not grow them. An
87
- operator must preserve the evidence separately and provide a clean path before
88
- logging can continue. Socketduct never truncates or rewrites damaged records.
89
- Already newline-terminated malformed historical records are not repaired.
90
-
91
- The sink requires a regular active file with mode `0600` and a directory with
92
- mode `0700`, including on reused paths. It checks the newly created file's
93
- actual mode before writing and does not follow an active-file symlink. Fix
94
- ownership/modes on an operator-preserved path rather than making logs public.
95
-
96
- See [the private rollout runbook](runbooks/jsonl-integrity-rollout.md) for the
97
- isolated fault commands and evidence-preserving Apoc-only rollout gates.
98
-
99
- The checked-in production relay and reverse-gateway Compose files already mount
100
- `${SOCKETDUCT_LOG_HOST_DIRECTORY:-./logs}` at `/var/log/socketduct`. Enable the
101
- sink in `.env` after creating the host directory for the container's uid/gid
102
- 1000:
38
+ A reverse hub that enables managed logging must also configure a hub-only
39
+ correlation key:
103
40
 
104
41
  ```sh
105
- SOCKETDUCT_LOG_HOST_DIRECTORY=/srv/socketduct/logs
106
- SOCKETDUCT_LOG_DIRECTORY=/var/log/socketduct
42
+ SOCKETDUCT_CONNECTION_FINGERPRINT_KEY='<unpadded-base64url-at-least-32-bytes>'
43
+ SOCKETDUCT_CONNECTION_FINGERPRINT_KEY_EPOCH='2026-09'
107
44
  ```
108
45
 
109
- `SOCKETDUCT_STATUS_INTERVAL_MS` controls both console and file-backed periodic
110
- status events. Its default remains 30000 milliseconds; `0` disables periodic
111
- status only, not lifecycle logging.
46
+ Generate the key from random bytes, keep it out of gateways and clients, rotate
47
+ it at least every 30 days and on suspected exposure, and remove the previous
48
+ key after at most 24 hours of rollout overlap. The epoch is a public 1–32
49
+ character URL-safe label. Socketduct logs only the role-separated first 128 bits
50
+ of an HMAC-SHA-256 fingerprint; it never emits the key or raw connection ID.
112
51
 
113
- ## Embedded client configuration
52
+ ## Storage and durability
114
53
 
115
- Create one file logger per process/role and pass it to the client, agent, or
116
- transport set through its `logger` option:
54
+ The directory must be a real `0700` directory. Socketduct creates exclusive
55
+ `0600` files without following symlinks:
117
56
 
118
- ```js
119
- import {createJsonlFileLogger} from "socketduct"
120
- import {MultiplexedSocketductTransportSet} from "socketduct/multiplexed"
57
+ ```text
58
+ <role>-<writerEpoch>-<segmentUuid>.jsonl.zst
59
+ <role>-<writerEpoch>-<segmentUuid>.jsonl.zst.idx
60
+ <role>-<writerEpoch>-<segmentUuid>.jsonl.zst.sealed
61
+ ```
121
62
 
122
- const logger = createJsonlFileLogger({
123
- directory: "/var/log/socketduct",
124
- role: "tensorbuzz-client",
125
- maxFileBytes: 10 * 1024 * 1024,
126
- maxFiles: 5
127
- })
63
+ Every `.jsonl.zst` segment is a concatenation of independently decodable,
64
+ checksummed, standard zstd frames at level 19. Each frame contains complete
65
+ newline-terminated records only. Compression and filesystem sync run in a
66
+ dedicated worker thread, off the protocol event loop.
67
+
68
+ The versioned big-endian sidecar index contains structural identities, sequence
69
+ ranges, offsets, lengths, timestamps, frame SHA-256, and CRC32C checks. It does
70
+ not contain gateways, routes, incidents, reasons, connection IDs, or search
71
+ digests. A batch becomes durable only after Socketduct writes and `fdatasync`s
72
+ the data frame, appends and `fdatasync`s its index entry, then acknowledges the
73
+ exact deterministic batch identity to the producer.
74
+
75
+ On worker restart, Socketduct validates every retained segment, accepts a
76
+ retention-deleted sequence prefix, and still rejects gaps or duplicates inside
77
+ the retained range. It rebuilds one fully decodable data-synced orphan entry,
78
+ truncates only a torn final data or index tail, and replays only a batch not
79
+ proven durable by the high-water mark plus the single latest batch identity;
80
+ worker memory and acknowledgement size do not grow with history. Interior
81
+ corruption is preserved and marked quarantined; readers report incomplete
82
+ evidence. Retention deletes only oldest sealed, non-quarantined segments under
83
+ an exclusive process/thread-instance lock. A crashed owner is reclaimed using
84
+ PID/TID plus kernel start identities, while a live or unprovable owner remains fail-closed.
85
+ Active and quarantined bytes count toward the quota and are never deleted to
86
+ hide a failure.
87
+
88
+ `log()` is nonblocking and reports `accepted`, `coalesced`, or `dropped`.
89
+ `flush()` is a caller-time sequence barrier: it resolves after every record
90
+ accepted through that call is data-and-index durable, without waiting for later
91
+ producers. Idempotent async `close()` stops acceptance, drains all records and
92
+ bounded sink lifecycle records, seals, syncs, and terminates the worker. `status()` exposes writer and
93
+ durable high-water marks, allocated bytes, health, restart count, and exact drop
94
+ counters. Orderly SIGINT/SIGTERM closes the network runtime first, records the
95
+ final process lifecycle event, then awaits logger flush and close before exit.
96
+ The startup record includes the effective numeric logger settings but never the
97
+ configured directory or key material.
98
+
99
+ ## Owned reader and CLI
100
+
101
+ Do not use `tail -f` on compressed bytes. Socketduct reads each indexed frame
102
+ independently and verifies the full integrity chain:
128
103
 
129
- const transports = new MultiplexedSocketductTransportSet({
130
- connectionId: "runner_01_stable_identity",
131
- spoolDirectory: "/tmp/socketduct-v3-set",
132
- gateways,
133
- logger
134
- })
104
+ ```sh
105
+ socketduct logs tail --directory /var/log/socketduct
106
+ socketduct logs tail --directory /var/log/socketduct --follow
107
+ socketduct logs search --directory /var/log/socketduct --incident '<22-char-id>'
108
+ socketduct logs search --directory /var/log/socketduct --event reverse_hub.physical_detached
135
109
  ```
136
110
 
137
- The same `logger` option is supported by `MultiplexedSocketductConnection`,
138
- `ReverseGatewayConnection`, `OutboundReverseGateway`, `connectSocketduct`,
139
- `createSocketductConnectionFactory`, `SocketductHttpAgent`, and
140
- `SocketductHttpsAgent`. A transport set owns logger propagation to its members;
141
- do not put `logger` inside `memberOptions`.
142
-
143
- For an existing logging system, use the exported line-writer adapter:
111
+ Search filters include `--event`, `--incident`, `--gateway`, `--route-class`,
112
+ `--client-generation`, `--gateway-generation`, `--reason`, `--since`, and
113
+ `--until`. Records from all writers are merged deterministically by timestamp,
114
+ raw writer epoch, and numeric event sequence; that ordering does not imply
115
+ cross-process causality. Search exits `0` for one or more matches, `1` for no
116
+ matches in complete evidence, and `2` for invalid arguments, I/O failure, or
117
+ any selected corrupt/quarantined/incomplete segment. Tail returns `2` on the
118
+ same evidence failures. Closed segments also remain compatible with ordinary
119
+ `zstdcat` and `zstdgrep`. Follow mode advances sidecar-index cursors and decodes
120
+ only newly committed frames. Cursor state is limited to currently retained
121
+ segments and is pruned as retention removes closed segments, so rotation does
122
+ not cause duplicate output or repeated historical decompression.
123
+
124
+ ## Privacy boundary
125
+
126
+ Records retain the existing 1,024-byte closed event/field allowlist. Disconnect
127
+ events may include a random incident ID, first local cause, origin and physical
128
+ role, explicitly named client/gateway generations, fan-out/resume/terminal
129
+ counts, safe gateway or route identity, safe socket code, and the hub-only keyed
130
+ fingerprint. Public terminal codes classify the outcome; they do not overwrite
131
+ the physical cause.
132
+
133
+ Socketduct never persists payload bytes, Docker requests or targets, tokens,
134
+ passwords, certificates or keys, raw connection IDs, HMAC keys, unkeyed identity
135
+ digests, peer-controlled reserved fields, spool paths/data, or arbitrary caller
136
+ objects. Logger health reports use fixed secret-free classifications.
137
+
138
+ ## Embedded use
144
139
 
145
140
  ```js
146
- import {createStructuredLogger} from "socketduct"
141
+ import {createCompressedJsonlFileLogger} from "socketduct"
147
142
 
148
- const logger = createStructuredLogger({
149
- role: "tensorbuzz-client",
150
- writeLine(line) {
151
- destination.append(line)
152
- }
143
+ const logger = createCompressedJsonlFileLogger({
144
+ directory: "/var/log/socketduct",
145
+ role: "tensorbuzz-client"
153
146
  })
147
+
148
+ transportOptions.logger = logger
149
+ // At shutdown, after stopping producers:
150
+ await logger.flush()
151
+ await logger.close()
154
152
  ```
155
153
 
156
- Alternatively, pass an object with `log(event, fields)` directly. Socketduct
157
- calls it only with a known event and already-sanitized fields. A direct custom
158
- logger is responsible for adding its own timestamp/process/role envelope and
159
- for preserving one-object-per-line framing. Synchronous throws and rejected
160
- promises from custom loggers/writers are isolated from connection behavior.
161
-
162
- ## Event and data contract
163
-
164
- The built-in logger emits one JSON object per line, capped at 1024 bytes. Every
165
- entry contains an ISO-8601 `timestamp`, an allowlisted `event`, numeric
166
- `processId`, and validated `role`. Depending on the component, events cover:
167
-
168
- - process startup and periodic status counters
169
- - physical transport connect, disconnect, retry scheduling, recovery, and
170
- terminal failure
171
- - transport-set readiness, member state/generation replacement, and terminal
172
- loss
173
- - reverse-gateway target open success, open failure, and close
174
- - virtual-stream opening, opened, closed, and error transitions
175
-
176
- Only finite allowlisted booleans, non-negative integer counters, validated
177
- gateway IDs, states, and error classifications can be included. Unknown events
178
- are dropped. Unknown error codes become `UNKNOWN`. Errors supplied through
179
- `errorDiagnostics()` retain their name, message, and stack so terminal
180
- transport and virtual-stream failures are actionable.
181
-
182
- The diagnostic path never serializes protocol payloads or arbitrary caller
183
- fields. It serializes error messages and stack traces produced by
184
- `errorDiagnostics()` after redacting bearer credentials, URL userinfo, and
185
- password/token/authorization values.
154
+ The `logger` option remains supported by Socketduct client, agent, transport-set,
155
+ and reverse-gateway APIs. A direct custom logger remains responsible for its own
156
+ durability. The legacy plaintext factory does not gain compressed recovery or
157
+ CLI guarantees and does not rewrite existing `.jsonl` files.
package/docs/protocol.md CHANGED
@@ -133,9 +133,9 @@ each per-stream `RESUMED` result, and only then admits replay. Senders replay
133
133
  durable raw chunks strictly above the high-water sequence while retaining
134
134
  unacknowledged chunks and charging their outstanding bytes to the new window.
135
135
  A DATA frame that entered a failed physical socket but did not reach the
136
- logical receiver is therefore replayed; a byte already buffered by the receiver
137
- is not delivered twice. The target socket is never reopened and the application
138
- request is never recreated as a new stream.
136
+ logical receiver is therefore replayed; a byte durably acknowledged by the
137
+ receiver is not delivered twice. The target socket is never reopened and the
138
+ application request is never recreated as a new stream.
139
139
 
140
140
  Reverse send replay is persisted before transmission on both the Server3 client
141
141
  and outbound gateway. ACKs truncate those private temporary spools. Missing
@@ -151,6 +151,24 @@ returned to the application; merely buffering them below the readable
151
151
  high-water mark is process-local and does not make them safe across replacement.
152
152
  It never emits `OPEN`.
153
153
 
154
+ Reverse sockets default to this automatic application-consumption ACK. With the
155
+ persisted `manualReceiveAck` stream flag, consumption only advances an
156
+ application-delivered counter. An explicit byte count is valid only through
157
+ that counter. Socketduct atomically persists the resulting cumulative
158
+ `receiveAck` before emitting the wire `ACK`; persistence failure emits no ACK
159
+ and terminates the stream. ACK positions are application-byte positions and may
160
+ split a stored DATA chunk, in which case only its suffix remains replayable.
161
+
162
+ The reverse client also supports an all-or-nothing durable application
163
+ admission. It preflights the complete operation against current stream,
164
+ connection, and aggregate budgets, splits it into ordinary bounded DATA chunks,
165
+ writes the complete raw batch, and writes recovery metadata containing the new
166
+ `sendSequence` last. No DATA becomes send-eligible until that commit succeeds.
167
+ An interrupted pre-commit batch is an uncommitted tail: live retry replaces it,
168
+ and replacement recovery discards it using the older committed sequence. After
169
+ commit, ordinary flow-control, compression, transmission, ACK, replay, and END
170
+ ordering are unchanged.
171
+
154
172
  Only the reverse client is replaceable in this protocol boundary. The same hub
155
173
  process and retained bridge, outbound gateway process, and original target TCP
156
174
  socket must survive. Hub restart reconstruction, gateway process/socket-keeper
@@ -181,6 +199,28 @@ payloads terminate the physical connection. TLS certificate verification is
181
199
  mandatory for every reverse client and gateway transport. The complete role
182
200
  contract is in [Native reverse gateway](reverse-gateway.md).
183
201
 
202
+ ### Reverse disconnect attribution
203
+
204
+ Authenticated reverse GOAWAY and terminal RESET controls may carry the hub’s
205
+ closed attribution object: a 22-character random `disconnectIncidentId`, fixed
206
+ `disconnectReason`, `incidentOrigin`, `physicalRole`, applicable
207
+ `clientConnectionGeneration` / `gatewayConnectionGeneration`, and nonnegative
208
+ `fanOutCount`, `resumeSuccessCount`, and `terminalStreamCount`. A valid hub
209
+ incident takes precedence over a locally observed close. Endpoints preserve it
210
+ on public typed errors. The `code` remains a terminal classification and is not
211
+ interpreted as the transport cause.
212
+
213
+ The hub creates attribution before every retained or terminal fan-out. This
214
+ includes protocol/output-limit termination, replacement, peer GOAWAY, orderly
215
+ hub shutdown, direct transport loss, and resume expiry. A syntactically safe
216
+ peer GOAWAY `code` may classify resulting terminal RESETs, but peer attribution
217
+ fields are never trusted and cannot replace the hub-observed physical cause.
218
+
219
+ These names are hub-reserved. Attribution, fingerprint, and epoch fields in a
220
+ gateway-forwarded RESET are stripped before forwarding; malformed or unknown
221
+ values never become trusted telemetry. No control carries raw connection IDs,
222
+ HMAC keys, targets, credentials, certificates, or payload data.
223
+
184
224
  ## Legacy protocol version 2
185
225
 
186
226
  V2 starts every connection with the length-prefixed JSON envelope
@@ -516,10 +556,11 @@ it, because every frame declares its own encoding.
516
556
 
517
557
  ### Runtime support
518
558
 
519
- zstd is provided by `node:zlib`, which only exposes it from Node 22.15 onward,
520
- while `engines` accepts Node >=22.0. Support is therefore feature-detected at
521
- runtime: a peer whose Node lacks zstd advertises no codec and the tunnel runs
522
- raw. No runtime dependency is added.
559
+ zstd is provided by Node 24's `node:zlib`; the package requires Node 24 or
560
+ newer. Stream-data support is still negotiated, so an explicitly disabled
561
+ codec runs raw. Durable diagnostic storage separately requires checksummed
562
+ standard zstd frames and never falls back to plaintext. No runtime dependency
563
+ is added.
523
564
 
524
565
  ## Acknowledgements
525
566
 
@@ -116,6 +116,22 @@ per-stream and per-connection ceilings, and released connection capacity rotates
116
116
  among streams with pending writes. The ceilings limit retained flow-control state,
117
117
  not the size of one application write.
118
118
 
119
+ Reverse `VirtualSocket` instances also expose `writeDurable(bytes)` for one
120
+ complete serialized application operation such as an HTTP request. Unlike an
121
+ ordinary streaming `write()`, it requires the whole operation to fit the
122
+ current per-stream, per-connection, and transport-set retained/spool budgets.
123
+ Socketduct reserves the complete byte count, replaces any uncommitted spool
124
+ tail, writes every frame-sized raw chunk, and commits the new send sequence
125
+ before resolving. Only then can the first byte be transmitted. Limit rejection
126
+ uses `SOCKETDUCT_DURABLE_WRITE_LIMIT` (or an applicable `SOCKETDUCT_SET_*`
127
+ limit), and persistence failure uses
128
+ `SOCKETDUCT_DURABLE_WRITE_PERSISTENCE`; both admit zero bytes and leave the
129
+ socket available for retry. Each successful repeated call is a distinct
130
+ operation. A physical write failure after admission uses normal durable replay.
131
+ Destroy/close rejects with `ERR_STREAM_DESTROYED`, while detach finishes an
132
+ admission already committing, rejects queued admissions with
133
+ `SOCKETDUCT_CONNECTION_DETACHED`, and preserves committed spool state.
134
+
119
135
  Compression and decompression use Node's asynchronous native zstd APIs, whose
120
136
  work runs in the libuv worker pool across CPU cores. The hub keeps registration
121
137
  and stream ownership on one deterministic event loop instead of splitting its
@@ -150,10 +166,17 @@ Reverse replay state is bounded per stream and connection and stored in private
150
166
  spool directories. ACKed chunks are truncated, and terminal close/reset removes
151
167
  the spool. `status().spoolBytes` reports retained durable payload. Reverse-client
152
168
  replacement requires the same explicit `connectionId`, an explicit persistent
153
- `spoolDirectory`, and the same stream identifier, name, target, and one original
154
- selector (`gatewayId` or `routeClass`). Register every stream with
155
- `registerRecoveredConnection({...options, recover: true})` before `connect()`;
156
- registration synchronously rejects missing, expired, corrupt, wrong-owner,
169
+ `spoolDirectory`, and the same stream identifier. A direct
170
+ `ReverseGatewayConnection` may still register the stored name, target, and
171
+ selector with `registerRecoveredConnection({...options, recover: true})` before
172
+ `connect()`. A transport set can instead call
173
+ `recoverConnection({identifier})` before `start()`; it validates every
174
+ configured member namespace and restores the stored target, name, receive-ACK
175
+ mode, and original selector without selecting a live path. No match fails with
176
+ `SOCKETDUCT_RECOVERY_NOT_FOUND`; more than one match fails with
177
+ `SOCKETDUCT_RECOVERY_AMBIGUOUS`. Corrupt, expired, or wrong-owner state fails
178
+ rather than being treated as absent. Legacy selector-based recovery remains
179
+ supported. Registration synchronously rejects missing, expired, corrupt, wrong-owner,
157
180
  mismatched, noncontiguous, or oversized state before any network attempt. The
158
181
  stored generation is advanced and persisted before AUTH, recovered outbound
159
182
  replay starts above the gateway high-water position, and inbound delivery starts
@@ -165,6 +188,23 @@ repair derivable usage accounting after an abrupt process loss; structural,
165
188
  identity, sequence, quota, and non-tail corruption still fail closed. Recovery
166
189
  never sends `OPEN`.
167
190
 
191
+ The default remains automatic receive ACK. A reverse stream opts into durable
192
+ application ownership with `manualReceiveAck: true`. Socketduct then records
193
+ bytes delivered to the application but does not advance the durable ACK until
194
+ `await socket.acknowledgeReceive(bytes)` succeeds after the parser or journal
195
+ commit. Counts are application bytes and may end inside a DATA frame; sender
196
+ memory and spool state retain only the unacknowledged suffix. Calls serialize
197
+ and return the cumulative receive sequence. Non-positive counts or counts above
198
+ delivered, not-yet-acknowledged bytes fail with
199
+ `SOCKETDUCT_INVALID_RECEIVE_ACK`; a repeated successful call acknowledges new
200
+ bytes rather than duplicating the previous call. Calling without opting in
201
+ fails with `SOCKETDUCT_MANUAL_ACK_DISABLED`. Persistence failure rejects the
202
+ call and terminates the stream before a wire ACK advances. Calls after socket
203
+ close reject with `ERR_STREAM_DESTROYED`, and calls after detach reject with
204
+ `SOCKETDUCT_CONNECTION_DETACHED`; an ACK already admitted is persisted before
205
+ detach completes. Recovery preserves manual mode so a replacement cannot
206
+ silently fall back to automatic ACK.
207
+
168
208
  The default reverse spool is under `/tmp`, and the sample reverse-gateway
169
209
  Compose mounts `/tmp` as tmpfs. Those defaults do not survive a container or
170
210
  host replacement. A caller claiming process/container recovery must supply a
@@ -198,6 +238,9 @@ SOCKETDUCT_TLS_KEY_FILE=/certs/hub.key \
198
238
  SOCKETDUCT_AUTH_TOKENS='server-a-starlink:<secret>,server-b-starlink:<secret>,tensorbuzz:<secret>' \
199
239
  SOCKETDUCT_REVERSE_GATEWAY_BINDINGS='server-a-starlink:server-a-starlink,server-b-starlink:server-b-starlink' \
200
240
  SOCKETDUCT_REVERSE_ROUTE_CLASS_ROUTES='tensorbuzz:starlink:server-a-starlink:docker-a:2375,tensorbuzz:starlink:server-b-starlink:docker-b:2375' \
241
+ SOCKETDUCT_LOG_DIRECTORY=/var/log/socketduct \
242
+ SOCKETDUCT_CONNECTION_FINGERPRINT_KEY='<unpadded-base64url-secret>' \
243
+ SOCKETDUCT_CONNECTION_FINGERPRINT_KEY_EPOCH='2026-09' \
201
244
  node src/index.js
202
245
  ```
203
246
 
@@ -209,6 +252,37 @@ Route-class rows use `username:routeClass:gatewayId:host:port`. If two rows make
209
252
  different gateways eligible for the same authenticated class and target, that
210
253
  `OPEN` is rejected as ambiguous rather than choosing one implicitly.
211
254
 
255
+ When hub logging is enabled, the fingerprint key is mandatory, hub-only, at
256
+ least 32 random bytes encoded as canonical unpadded base64url, and never transmitted or
257
+ logged. Rotate it at least every 30 days. The public epoch label identifies the
258
+ key generation used for a 128-bit role-separated HMAC correlation value.
259
+
260
+ ## Disconnect attribution
261
+
262
+ Every reverse physical loss has one 128-bit random `disconnectIncidentId` and
263
+ one first local `disconnectReason`. The initial socket error, EOF, peer GOAWAY,
264
+ protocol violation, output limit, replacement, shutdown, or send-after-loss
265
+ cause wins over later close callbacks. The incident records its originating
266
+ component, physical client/gateway role, explicitly named connection
267
+ generations, and fan-out/resume/terminal stream counts. A public RESET code such
268
+ as `RESUME_WINDOW_EXPIRED` classifies the stream outcome; it does not replace
269
+ the physical cause.
270
+
271
+ The hub propagates its validated incident in RESET/GOAWAY controls and typed
272
+ `SocketductStreamError` / `SocketductSessionLostError` instances. Direct client
273
+ or gateway loss creates and caches an equivalent local incident so concurrent
274
+ stream errors share it, including writes racing the close callback. A new
275
+ authenticated physical clears that cache. Peer-provided reserved attribution
276
+ fields are stripped; peers cannot claim hub origin, inject correlation values,
277
+ or alter named generations.
278
+
279
+ Terminal, non-resumable hub paths assign the incident before removing their
280
+ bridges, including protocol violations, output overflow, replacement, peer
281
+ GOAWAY, and shutdown. A safe peer GOAWAY code survives as terminal
282
+ classification while the first hub-observed `peer_goaway` cause and hub-created
283
+ incident identity remain authoritative; fatal client and gateway connection
284
+ errors retain that validated attribution.
285
+
212
286
  ## One outbound member per target and route class
213
287
 
214
288
  Run this once in each target server's route-pinned network namespace, changing
@@ -265,10 +339,11 @@ Callers cannot override either selection per open. Reverse streams remain
265
339
  sticky to their selected gateway/target but resume transparently after a
266
340
  transient physical transport replacement.
267
341
 
268
- For process replacement, call `recoverConnection()` before `start()` with
269
- exactly the socket's immutable original selector: `gatewayId` for an exact set
270
- or `routeClass` for a route-class set. Recovery derives the original member
271
- identity and spool namespace and never falls back to or probes a sibling.
342
+ For process replacement, call `recoverConnection({identifier})` before
343
+ `start()`. Recovery locates exactly one configured durable member from the
344
+ stable stream identifier, derives its original identity and spool namespace,
345
+ and never falls back to or probes a sibling. The older explicit `gatewayId` or
346
+ `routeClass` form remains available when the caller already stores it.
272
347
 
273
348
  The socket is returned immediately and waits inside Socketduct until the set
274
349
  reaches minimum readiness. New sockets rotate equally across ready members and