socketduct 0.1.15

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 (138) hide show
  1. package/.dockerignore +11 -0
  2. package/.env.example +131 -0
  3. package/AGENTS.md +168 -0
  4. package/Dockerfile +30 -0
  5. package/README.md +134 -0
  6. package/changelog.d/20260716083144-v2-ack-wire-format.md +3 -0
  7. package/changelog.d/20260716092044-v2-only-stream-framing.md +1 -0
  8. package/changelog.d/20260727113000-relay-connect-timeout-destroy.md +1 -0
  9. package/changelog.d/20260813093000-private-relay-hardening.md +1 -0
  10. package/changelog.d/20260824183412-initial-target-recovery-https.md +1 -0
  11. package/changelog.d/20260824230000-tls-relay-session-close.md +1 -0
  12. package/changelog.d/20260825120000-reconnect-close-target-timeout.md +1 -0
  13. package/changelog.d/20260829120000-protocol-v3-multiplexing.md +1 -0
  14. package/changelog.d/20260829160000-v3-review-corrections.md +1 -0
  15. package/changelog.d/20260830000000-fix-socket-idle-timeout.md +1 -0
  16. package/changelog.d/20260830090000-multi-gateway-transport-set.md +1 -0
  17. package/changelog.d/20260830093000-relay-close-settlement.md +1 -0
  18. package/changelog.d/20260830120000-immutable-gateway-image.md +1 -0
  19. package/changelog.d/20260902210000-native-reverse-gateway.md +1 -0
  20. package/changelog.d/20260902230000-relay-resume-terminal-order.md +1 -0
  21. package/changelog.d/20260902230708-multiplexed-open-ownership.md +1 -0
  22. package/changelog.d/20260902231541-multiplexed-terminal-close.md +1 -0
  23. package/changelog.d/20260902231542-reverse-close-position.md +1 -0
  24. package/changelog.d/20260902231543-reverse-terminal-connect.md +1 -0
  25. package/changelog.d/20260902233000-reverse-gateway-bounds.md +1 -0
  26. package/changelog.d/20260903001000-reverse-data-ordering.md +1 -0
  27. package/changelog.d/20260903003000-reverse-hub-restart-window.md +1 -0
  28. package/changelog.d/20260903010000-reverse-terminal-lifecycle.md +1 -0
  29. package/changelog.d/20260904142743-transport-set-readiness-wait.md +1 -0
  30. package/changelog.d/20260904152855-deferred-open-snapshots-binary-views.md +1 -0
  31. package/changelog.d/20260904163041-tensorbuzz-container-workflow.md +1 -0
  32. package/changelog.d/20260904174955-transport-set-terminal-causes.md +1 -0
  33. package/changelog.d/20260905081000-contain-reverse-auth-write-errors.md +1 -0
  34. package/changelog.d/20260905100520-reverse-hub-close-race.md +1 -0
  35. package/changelog.d/20260905120000-persistent-jsonl-logging.md +1 -0
  36. package/changelog.d/20260908071009-jsonl-partial-append-recovery.md +1 -0
  37. package/changelog.d/20260908074916-private-log-directory-setup.md +1 -0
  38. package/changelog.d/20260909093057-reverse-large-write-backpressure.md +1 -0
  39. package/changelog.d/20260909135830-actionable-error-diagnostics.md +1 -0
  40. package/changelog.d/20260909150000-transport-set-error-redaction.md +1 -0
  41. package/docker-compose.dev.yml +19 -0
  42. package/docker-compose.reverse-gateway.yml +36 -0
  43. package/docker-compose.yml +73 -0
  44. package/docs/architecture.md +264 -0
  45. package/docs/docker-topology.md +164 -0
  46. package/docs/docker.md +281 -0
  47. package/docs/logging.md +185 -0
  48. package/docs/plans/initial-implementation.md +205 -0
  49. package/docs/plans/socket-docker-integration.md +221 -0
  50. package/docs/protocol.md +558 -0
  51. package/docs/releasing.md +18 -0
  52. package/docs/reverse-gateway.md +186 -0
  53. package/docs/runbooks/jsonl-integrity-rollout.md +144 -0
  54. package/docs/running.md +215 -0
  55. package/docs/security.md +258 -0
  56. package/docs/session-lifecycle.md +127 -0
  57. package/docs/socket-api.md +448 -0
  58. package/eslint.config.js +36 -0
  59. package/package.json +44 -0
  60. package/scripts/deploy-gateway.js +194 -0
  61. package/scripts/test.js +28 -0
  62. package/src/auth/passwords.js +38 -0
  63. package/src/auth/static-token-store.js +41 -0
  64. package/src/auth/user-store.js +139 -0
  65. package/src/client/connect.js +76 -0
  66. package/src/client/deferred-virtual-socket.js +312 -0
  67. package/src/client/http-agent.js +101 -0
  68. package/src/client/https-agent.js +366 -0
  69. package/src/client/multiplexed-connection.js +1504 -0
  70. package/src/client/multiplexed-transport-set.js +917 -0
  71. package/src/client/multiplexed.js +3 -0
  72. package/src/client/socketduct-socket.js +1174 -0
  73. package/src/client/virtual-socket.js +256 -0
  74. package/src/config/environment.js +400 -0
  75. package/src/edge/tcp-edge.js +82 -0
  76. package/src/index.js +101 -0
  77. package/src/logging/structured-logger.js +745 -0
  78. package/src/protocol/compression.js +325 -0
  79. package/src/protocol/frame.js +341 -0
  80. package/src/protocol/v3-compression.js +44 -0
  81. package/src/protocol/v3-frame.js +209 -0
  82. package/src/proxy/multiplexed-relay.js +798 -0
  83. package/src/proxy/tcp-relay.js +1191 -0
  84. package/src/relay.js +3 -0
  85. package/src/reverse/client.js +721 -0
  86. package/src/reverse/common.js +88 -0
  87. package/src/reverse/gateway.js +663 -0
  88. package/src/reverse/hub.js +563 -0
  89. package/src/reverse/index.js +3 -0
  90. package/src/reverse/runtime.js +78 -0
  91. package/src/server/control-handler.js +170 -0
  92. package/src/server/primary.js +64 -0
  93. package/src/server/relay-runtime.js +124 -0
  94. package/src/server/worker.js +39 -0
  95. package/src/sessions/session.js +212 -0
  96. package/src/spool/spool-file.js +451 -0
  97. package/tensorbuzz.yml +36 -0
  98. package/test/e2e/gateway-process.test.js +262 -0
  99. package/test/e2e/reverse-gateway-process.test.js +285 -0
  100. package/test/fixtures/logging/enospc-matrix.mjs +238 -0
  101. package/test/fixtures/logging/enospc-recovery.mjs +146 -0
  102. package/test/fixtures/logging/process-writer.mjs +20 -0
  103. package/test/helpers/tls-certificates.js +115 -0
  104. package/test/integration/initial-target-recovery.test.js +518 -0
  105. package/test/integration/logging-recovery.test.js +163 -0
  106. package/test/integration/multiplexed-compatibility.test.js +254 -0
  107. package/test/integration/multiplexed-corrections.test.js +551 -0
  108. package/test/integration/multiplexed-failures.test.js +339 -0
  109. package/test/integration/multiplexed-transport-set.test.js +456 -0
  110. package/test/integration/multiplexed-transport.test.js +208 -0
  111. package/test/integration/reverse-gateway.test.js +1293 -0
  112. package/test/integration/socketduct-http-agent.test.js +435 -0
  113. package/test/integration/socketduct-https-agent.test.js +669 -0
  114. package/test/integration/stream-compression.test.js +1267 -0
  115. package/test/integration/tcp-forwarding.test.js +2878 -0
  116. package/test/integration/tls-forwarding.test.js +748 -0
  117. package/test/unit/auth/auth.test.js +64 -0
  118. package/test/unit/auth/static-token-store.test.js +14 -0
  119. package/test/unit/client/connect.test.js +58 -0
  120. package/test/unit/client/multiplexed-spool-budget.test.js +309 -0
  121. package/test/unit/client/multiplexed-transport-set.test.js +906 -0
  122. package/test/unit/client/socketduct-socket.test.js +1124 -0
  123. package/test/unit/client/virtual-socket.test.js +98 -0
  124. package/test/unit/config/docker-gateway-files.test.js +274 -0
  125. package/test/unit/config/environment.test.js +507 -0
  126. package/test/unit/index.test.js +78 -0
  127. package/test/unit/logging/structured-logger.test.js +674 -0
  128. package/test/unit/protocol/compression.test.js +421 -0
  129. package/test/unit/protocol/frame.test.js +336 -0
  130. package/test/unit/protocol/v3-compression.test.js +31 -0
  131. package/test/unit/protocol/v3-frame.test.js +57 -0
  132. package/test/unit/release-configuration.test.js +21 -0
  133. package/test/unit/server/control-handler.test.js +291 -0
  134. package/test/unit/server/primary.test.js +59 -0
  135. package/test/unit/server/relay-runtime.test.js +434 -0
  136. package/test/unit/sessions/session.test.js +84 -0
  137. package/test/unit/spool/spool-file.test.js +295 -0
  138. package/tsconfig.json +20 -0
@@ -0,0 +1,186 @@
1
+ # Native reverse gateway
2
+
3
+ Reverse-gateway mode lets a private home Docker host establish the only
4
+ internet-facing connection. Neither CGNAT nor the absence of inbound port
5
+ forwarding prevents Server3 from opening Docker streams through it.
6
+
7
+ ## Roles and public contract
8
+
9
+ The roles are deliberately separate:
10
+
11
+ ```text
12
+ TensorBuzz / local application on Server3
13
+ -> ReverseGatewayConnection (verified TLS client, one selected gateway ID)
14
+ -> reverse hub on Server3
15
+ <- OutboundReverseGateway (verified TLS client from the home Docker host)
16
+ -> docker-server:2375 on a private Compose network
17
+ ```
18
+
19
+ - `startReverseGatewayHub(options)` is the reachable Server3 broker. It
20
+ authenticates both kinds of physical connection, binds gateway IDs to
21
+ principals, applies `authorizeClientGateway` before selection, authorizes
22
+ every client/gateway/target tuple, and never connects to a target itself.
23
+ - `OutboundReverseGateway` is one home member. It makes one persistent outbound
24
+ TLS connection, registers one stable `gatewayId`, applies its own exact local
25
+ target policy, and multiplexes all accepted target streams on that transport.
26
+ - `ReverseGatewayConnection` is the local application API. It selects one
27
+ registered gateway during connection readiness and returns ordinary
28
+ `VirtualSocket` objects from `createConnection()`.
29
+ - `MultiplexedSocketductTransportSet` accepts `endpoint.reverseHub` members, so
30
+ TensorBuzz can keep its existing health-aware ordered round-robin behavior.
31
+ A three-member set points all three members at the same hub but selects
32
+ `o2`, `zyxel`, and `starlink` independently.
33
+
34
+ Import these APIs from `socketduct/reverse`. The ordinary v2 and v3 edge/relay
35
+ APIs and wire handshakes remain unchanged.
36
+
37
+ ## Protocol decision
38
+
39
+ Reverse mode is a v3 extension, not a Socketduct tunnel inside another tunnel.
40
+ It retains the 24-byte v3 frame and stream flow control. New connection-control
41
+ frames authenticate registration and selection:
42
+
43
+ - `GATEWAY_REGISTER` / `GATEWAY_REGISTERED` bind the authenticated outbound
44
+ transport to a configured gateway ID and monotonically increasing generation.
45
+ - `GATEWAY_SELECT` / `GATEWAY_SELECTED` make client readiness depend on that
46
+ gateway currently being registered.
47
+ - Client-created streams use odd IDs. The hub maps each one to a hub-created
48
+ even ID on the selected gateway transport. `OPEN`, `OPENED`, `DATA`, `ACK`,
49
+ `WINDOW`, `END`, `CLOSE`, and `RESET` then retain their v3 meanings.
50
+ Gateway-originated `CLOSE` is acknowledged by a client `CLOSE`, after which the
51
+ hub releases both mapped IDs; `RESET` remains immediately terminal.
52
+ Valid `ACK` and `WINDOW` frames already in flight when that close starts are
53
+ consumed by the hub, while late `DATA` or `END` resets only the closing stream.
54
+
55
+ The hub rejects unknown IDs, registration by the wrong principal, a second
56
+ connection identity claiming the same ID, stale generations, repeated IDs,
57
+ malformed frames, unauthorized routes, and configured limits. A higher
58
+ generation from the same stable connection identity fences its prior transport.
59
+ At the configured identity bound, the oldest disconnected client identity is
60
+ evicted; active owners are never evicted and retain generation fencing.
61
+ The home gateway independently rejects any target outside its local allowlist.
62
+
63
+ Reverse DATA compression is negotiated as `zstd`. The one DATA-only flag means
64
+ the payload starts with a four-byte uncompressed length followed by one
65
+ independent zstd frame. Sequence, ACK, window, and limit accounting uses raw
66
+ bytes. Compression is adaptive: small or incompressible chunks remain raw.
67
+ Decompression size and expansion are bounded, and outbound DATA chunks use the
68
+ lower of the frame-size and decompressed-size limits. Reverse mode requires Node's
69
+ zstd runtime support and always uses TLS with certificate verification enabled;
70
+ there is no plaintext or `rejectUnauthorized:false` reverse option.
71
+
72
+ Each peer advertises its receive window during authentication and stream open;
73
+ senders use the smaller local and peer window for stream credit. A graceful hub
74
+ shutdown is transient and reconnects the client and gateway registrations, while
75
+ authentication, authorization, and stale-generation rejections remain terminal.
76
+
77
+ An application write may be larger than the configured reverse-stream buffer.
78
+ The client admits it in bounded slices no larger than the negotiated peer window
79
+ as acknowledgements release capacity, holding Node's Writable callback while
80
+ backpressure is active. Retained replay bytes therefore stay within both the
81
+ per-stream and per-connection ceilings, and released connection capacity rotates
82
+ among streams with pending writes. The ceilings limit retained flow-control state,
83
+ not the size of one application write.
84
+
85
+ Compression and decompression use Node's asynchronous native zstd APIs, whose
86
+ work runs in the libuv worker pool across CPU cores. The hub keeps registration
87
+ and stream ownership on one deterministic event loop instead of splitting its
88
+ in-memory gateway registry across independent cluster workers.
89
+
90
+ ## Failure and replay boundary
91
+
92
+ Gateway registration reconnects with bounded exponential delay and a higher
93
+ generation. An established Docker stream does **not** survive that transport
94
+ loss: the hub resets it with `GATEWAY_DISCONNECTED`, both endpoints release the
95
+ stream, and the gateway destroys its local Docker socket. This is intentional.
96
+ A Docker request whose write reached dockerd but whose response did not reach
97
+ TensorBuzz has an ambiguous outcome and must never be replayed automatically.
98
+ TensorBuzz must reconcile that Docker operation and then create a new stream.
99
+
100
+ Consequently reverse streams have bounded in-memory flow-control/replay queues
101
+ only for the live transport and report zero spool bytes. They do not persist an
102
+ operation spool across disconnect or process replacement; `recoverConnection()`
103
+ fails explicitly. Ordinary resumable Socketduct sessions retain their existing
104
+ durable spool behavior. This split is the safety boundary between reconnecting
105
+ the gateway registration and replaying privileged Docker operations.
106
+
107
+ Target bytes already observed by the gateway are ordered before a subsequent
108
+ target-error reset. Stream `END` remains a directional half-close, while protocol
109
+ `CLOSE` retires both virtual-socket directions and releases stream capacity.
110
+
111
+ ## Server3 hub runtime
112
+
113
+ Run one TLS hub:
114
+
115
+ ```sh
116
+ SOCKETDUCT_MODE=reverse-hub \
117
+ SOCKETDUCT_RELAY_HOST=0.0.0.0 \
118
+ SOCKETDUCT_RELAY_PORT=3100 \
119
+ SOCKETDUCT_TLS_CERT_FILE=/certs/hub.crt \
120
+ SOCKETDUCT_TLS_KEY_FILE=/certs/hub.key \
121
+ SOCKETDUCT_AUTH_TOKENS='home-o2:<secret>,home-zyxel:<secret>,home-starlink:<secret>,tensorbuzz:<secret>' \
122
+ SOCKETDUCT_REVERSE_GATEWAY_BINDINGS='o2:home-o2,zyxel:home-zyxel,starlink:home-starlink' \
123
+ SOCKETDUCT_REVERSE_ROUTES='tensorbuzz:o2:docker-server:2375,tensorbuzz:zyxel:docker-server:2375,tensorbuzz:starlink:docker-server:2375' \
124
+ node src/index.js
125
+ ```
126
+
127
+ Values shown as `<secret>` are placeholders for secret-manager input, not
128
+ literal values. The runtime refuses to start without TLS, credentials,
129
+ bindings, and routes.
130
+
131
+ ## One outbound member per router
132
+
133
+ Run this once in each existing router-pinned network namespace, changing the
134
+ two stable IDs and credential for the member:
135
+
136
+ ```sh
137
+ SOCKETDUCT_MODE=reverse-gateway \
138
+ SOCKETDUCT_REVERSE_GATEWAY_ID=o2 \
139
+ SOCKETDUCT_REVERSE_CONNECTION_ID=home_o2_gateway_01 \
140
+ SOCKETDUCT_REVERSE_HUB_HOST=server3.example.net \
141
+ SOCKETDUCT_REVERSE_HUB_PORT=3100 \
142
+ SOCKETDUCT_REVERSE_TOKEN='<secret>' \
143
+ SOCKETDUCT_REVERSE_HUB_CA_FILE=/certs/hub-ca.crt \
144
+ SOCKETDUCT_REVERSE_HUB_SERVERNAME=server3.example.net \
145
+ SOCKETDUCT_TARGET_ALLOWLIST=docker-server:2375 \
146
+ node src/index.js
147
+ ```
148
+
149
+ The namespace/default-route configuration remains outside Socketduct. Pin the
150
+ three processes to the routes whose next hops are O2 `192.168.86.1`, Zyxel
151
+ `192.168.86.3`, and Starlink `192.168.86.25`. Socketduct needs no inbound rule
152
+ on any home router. Do not publish port 2375: the gateway resolves
153
+ `docker-server` and reaches plaintext Docker TCP only on their shared private
154
+ Compose network.
155
+
156
+ ## TensorBuzz transport set
157
+
158
+ ```js
159
+ import {MultiplexedSocketductTransportSet} from "socketduct/multiplexed"
160
+
161
+ const set = new MultiplexedSocketductTransportSet({
162
+ connectionId: "tensorbuzz_docker_transport_01",
163
+ gateways: ["o2", "zyxel", "starlink"].map((gatewayId) => ({
164
+ gatewayId,
165
+ endpoint: {
166
+ reverseHub: {
167
+ host: "127.0.0.1",
168
+ port: 3100,
169
+ token: process.env.SOCKETDUCT_TENSORBUZZ_TOKEN
170
+ },
171
+ tls: {caFile: "/certs/hub-ca.crt", servername: "server3.example.net"}
172
+ }
173
+ }))
174
+ })
175
+
176
+ const starting = set.start()
177
+ const socket = set.createConnection({host: "docker-server", port: 2375})
178
+ await starting
179
+ ```
180
+
181
+ The socket is returned immediately and waits inside Socketduct until the set
182
+ reaches minimum readiness. New sockets rotate equally across ready members and
183
+ stay on their assigned member. If all members are reconnecting, new sockets wait
184
+ FIFO without selecting a connecting gateway; a recovered member resumes those
185
+ opens. A disconnected member leaves readiness and rejoins after authenticated
186
+ registration; existing streams on it fail for application-level reconciliation.
@@ -0,0 +1,144 @@
1
+ # Private JSONL-integrity rollout
2
+
3
+ This runbook applies only to the private Socketduct release and Apoc's three
4
+ gateways. It does not authorize production recreation. The coordinator owns
5
+ independent review, private PR/check monitoring, release, explicit disruption
6
+ approval, production execution and acceptance. Do not publish source, logs,
7
+ images, evidence, credentials or packages publicly; keep `private: true`.
8
+
9
+ ## Evidence is not repair
10
+
11
+ A disk-full append can leave a prefix. The fix separates that segment unchanged
12
+ before accepting another record; it does not make the prefix valid JSON.
13
+ Archive historical files byte-for-byte outside managed retention in a private
14
+ 0700 directory with 0600 files, with hashes, inode/size/offset metadata and the
15
+ exact image/configuration rollback identity. Never truncate, normalize, replace
16
+ newer logs with an older archive, or count skipped parse errors as success.
17
+ Numbered evidence remains subject to ordinary bounded retention.
18
+
19
+ With one retained file, or an incomplete active file larger than a newly lowered
20
+ limit, preserve the file separately before an operator supplies a clean active
21
+ path. Do not delete evidence simply to resume logging. Fix unsafe 0700/0600 modes
22
+ or ownership without weakening access controls. Missing diagnostic events are
23
+ not replayed; transport data remains governed by the existing tunnel protocol.
24
+
25
+ ## Isolated acceptance, never production disk fill
26
+
27
+ Run as UID/GID1000 in the canonical development container:
28
+
29
+ ```sh
30
+ npm exec --no -- velocious-test test/unit/logging/structured-logger.test.js
31
+ npm exec --no -- velocious-test test/integration/logging-recovery.test.js
32
+ npm test
33
+ npm run typecheck
34
+ npm run lint
35
+ git diff --check
36
+ ```
37
+
38
+ Run the two focused modules sequentially with the published `@velocious/testing`
39
+ runner. `npm test` is the CI entry point: it discovers untouched legacy modules
40
+ for Node's runner and then invokes the owning runner separately for each migrated
41
+ logger module. `npm test -- --list` prints both selections without running tests.
42
+ Do not run the full suite or shards locally during a correction pass; CI owns
43
+ that coverage. Test cleanup uses the package's supported FIFO `withTestCleanup`.
44
+
45
+ The process tests create and remove only their own private home subdirectories;
46
+ the canonical `/home/dev` bind persists across child-process exits. They reuse
47
+ the exact filename family and compare retained bytes and inode identity. Other
48
+ tests cover actual permission/rename/create failures, retention 1/2/5 at the
49
+ 1024-byte minimum, privacy, live/unknown PID preservation, and an authenticated,
50
+ destination-allowlisted loopback payload round-trip during logger failure.
51
+
52
+ The Docker coordinator runs both real-fault fixtures against the **exact built
53
+ candidate image**, not a working-source override. Set `IMAGE` to its inspected
54
+ immutable private image ID and `FIXTURE_SOURCE` to the Docker daemon's existing
55
+ canonical checkout `test/fixtures` bind source. The migrated fixtures also need
56
+ the published `@velocious/testing` package: set `TESTING_PACKAGE_SOURCE` to the
57
+ same checkout's registry-installed `node_modules/@velocious/testing` directory
58
+ after normal `npm ci`. The package has no runtime dependencies. Its read-only
59
+ mount is for disposable fault containers only; it stays a dev dependency and
60
+ must not be bundled into production images. Without that additional test-only
61
+ mount, the runtime image's `npm ci --omit=dev` cannot resolve the matcher import.
62
+ The coordinator must account for this changed fixture dependency contract when
63
+ refreshing the image/ENOSPC receipt.
64
+
65
+ Do not guess a host route or mount credentials, a home directory, or a Docker
66
+ socket into the fault container. Only fixtures and their installed test package
67
+ are mounted; fixture source imports still resolve to the candidate's baked
68
+ `/app/src` code, not a working-source override.
69
+
70
+ ```sh
71
+ : "${IMAGE:?Set the immutable candidate image ID}"
72
+ : "${FIXTURE_SOURCE:?Set the canonical fixture bind source}"
73
+ : "${TESTING_PACKAGE_SOURCE:?Set the installed test-only package bind source}"
74
+ for fixture in enospc-recovery.mjs enospc-matrix.mjs; do
75
+ docker run --rm --pull=never --network none --user 1000:1000 --read-only \
76
+ --cap-drop ALL --security-opt no-new-privileges \
77
+ --memory 256m --pids-limit 32 \
78
+ --mount "type=bind,source=$FIXTURE_SOURCE,target=/app/test/fixtures,readonly" \
79
+ --mount "type=bind,source=$TESTING_PACKAGE_SOURCE,target=/app/node_modules/@velocious/testing,readonly" \
80
+ --tmpfs /fault-space:rw,nosuid,nodev,noexec,size=65536,mode=0700,uid=1000,gid=1000 \
81
+ --workdir /app --entrypoint node "$IMAGE" "test/fixtures/logging/$fixture" \
82
+ || exit "$?"
83
+ done
84
+ ```
85
+
86
+ Fixtures refuse to fill any path other than an empty, dedicated 64 KiB Linux
87
+ tmpfs at `/fault-space`. They require actual ENOSPC, nonempty exact short-write
88
+ prefixes, restored filler space, byte-for-byte retained evidence, parseable new
89
+ records, mode/cap/count limits, and once-only sanitized reports. The matrix also
90
+ covers multiple short failures in one logger, several no-byte calls while full,
91
+ retention 1/2/5, low-limit empty-file failures, and a child process that exits
92
+ after a real torn append before a fresh writer resumes. Cleanup removes only
93
+ fixture-owned contents; no sleeps, filesystem mocks, or production writes.
94
+
95
+ Keep stdout/stderr, exit codes, source hashes, image ID and Node/UID identities
96
+ in restricted private evidence. An unchanged defective image must fail the
97
+ correct-output assertion **after** proving actual ENOSPC; a setup failure is not
98
+ RED. Both candidate fixtures must exit zero. Static/unit passes alone do not
99
+ establish real-filesystem GREEN. The internal `tear` mode only prepares a child
100
+ fault and is not a standalone correct-output pass.
101
+
102
+ ## Private release and Apoc-only approval gate
103
+
104
+ 1. Complete the single independent read-only review, bounded corrections, exact
105
+ source validation and real-filesystem GREEN before private PR publication.
106
+ Never request a GitHub Codex review. Require exact-head checks before merge.
107
+ 2. Confirm live tags/version, then use the authorized private patch metadata;
108
+ do not publish to npm. The coordinator builds the existing runtime Dockerfile
109
+ from the exact release revision, verifies version/source/UID, and privately
110
+ distributes a checksum-verified immutable image archive.
111
+ 3. Read the live gateway topology and accepted traffic. Present the exact serial
112
+ recreation commands, service/image identities, traffic impact and rollback
113
+ commands to the operator and obtain **separate explicit approval**. Previous
114
+ fleet interruption approval does not carry over. No host/build-service restart.
115
+ 4. Before recreation, preserve historical logs plus exact previous image and
116
+ configuration in the restricted rollback bundle. Keep mounts, WAN/control
117
+ networks, addresses, restart policy, non-root user, read-only root, dropped
118
+ capabilities and no-new-privileges unchanged. Never copy secrets into this
119
+ runbook or a PR.
120
+ 5. After approval, recreate only `gateway-o2` / `socketduct-apoc-o2`, then
121
+ `gateway-zyxel` / `socketduct-apoc-zyxel`, then
122
+ `gateway-starlink` / `socketduct-apoc-starlink`. Gate each next service on
123
+ authenticated TLS readiness, source/image/UID verification, private bounded
124
+ logging, and ordinary traffic. Do not expand to other hosts, hub or clients.
125
+
126
+ ## Acceptance and serial rollback
127
+
128
+ After all three gateways are ready, collect one baseline and a readback after
129
+ at least five minutes of actual ordinary activity, with multiple complete new
130
+ events per gateway. Track offsets, hashes and identities across rotations.
131
+ Require zero new malformed completed lines, preserved historical evidence,
132
+ proper permissions/bounds and authenticated readiness for all three members.
133
+ Absent activity is incomplete acceptance, not a vacuous pass. Observe a naturally
134
+ scheduled non-root Docker workload through Apoc when available; do not force
135
+ scheduling or modify database/capacity settings. Distinguish this non-recurrence
136
+ window from the isolated deterministic ENOSPC proof.
137
+
138
+ Stop the serial rollout if a gateway fails readiness, harms traffic, loops,
139
+ loses hardening, or produces newly joined records. Restore only that service's
140
+ exact previous configuration and immutable image, keeping current logs and
141
+ archives. Reverify it before proceeding; never overwrite newer logs with old
142
+ bytes. Rollback restores the old logger defect, so record that limitation and
143
+ keep acceptance open. Only the coordinator records completion after review,
144
+ private release and the approved Apoc acceptance are all proven.
@@ -0,0 +1,215 @@
1
+ # Running Socketduct
2
+
3
+ Socketduct is currently an MVP library/runtime skeleton for a resilient TCP forwarder. The checked-in test suite exercises the supported flow end-to-end:
4
+
5
+ ```text
6
+ local app socket -> Socketduct edge -> Socketduct relay -> session-authorized TCP target
7
+ ```
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ npm ci
13
+ ```
14
+
15
+ ## Local verification
16
+
17
+ ```sh
18
+ npm run lint
19
+ npm run typecheck
20
+ npm test
21
+ npm start
22
+ ```
23
+
24
+ ## Production Docker gateway
25
+
26
+ Production Docker deployment uses the image-only contract in
27
+ [`docker-compose.yml`](../docker-compose.yml) and requires
28
+ `SOCKETDUCT_IMAGE=ghcr.io/kaspernj/socketduct@sha256:<64-lowercase-hex>`.
29
+ Run `npm run gateway:deploy` (or add `-- --docker-host` for the private Docker
30
+ socket shim). The command rejects missing or mutable image references before it
31
+ validates Compose, pulls, and applies with `--wait --no-build`. Local source
32
+ builds use the separate `docker-compose.dev.yml` path. See
33
+ [Docker gateway setup](docker.md) for GHCR login, TLS, update, and rollback.
34
+
35
+ `npm start` currently prints the startup summary:
36
+
37
+ ```text
38
+ Socketduct starting in single-worker mode on node
39
+ ```
40
+
41
+ ## Current runtime building blocks
42
+
43
+ - `src/server/primary.js` starts a Node.js `cluster` primary and forks workers.
44
+ - `src/server/worker.js` starts a TCP worker server around an injected connection handler.
45
+ - `src/server/control-handler.js` handles authenticated `session.create` and `session.resume` control messages.
46
+ - `src/proxy/tcp-relay.js` starts the relay side and forwards authenticated stream frames to the target authorized for each session.
47
+ - `src/edge/tcp-edge.js` starts the local edge side, spools client data, reconnects to the relay, and replays unacknowledged chunks.
48
+ - `src/proxy/multiplexed-relay.js` and
49
+ `src/client/multiplexed-connection.js` provide protocol v3's one-physical,
50
+ many-virtual-stream transport.
51
+
52
+ Set `SOCKETDUCT_PROTOCOL_VERSION=3` to run the v3 relay. Version `2` remains the
53
+ default for rollback compatibility. V3 compression is disabled; frame, stream
54
+ and aggregate safety bounds plus fair scheduling apply instead.
55
+
56
+ Native reverse mode has separate `reverse-hub` and `reverse-gateway` runtime
57
+ roles and always uses verified TLS plus adaptive zstd. See
58
+ [Native reverse gateway](reverse-gateway.md) for the environment contract and
59
+ three-member TensorBuzz API.
60
+
61
+ ## Persistent JSONL logs
62
+
63
+ Set `SOCKETDUCT_LOG_DIRECTORY` to opt into bounded, per-process JSONL files. A
64
+ Docker deployment should bind mount persistent host storage at that directory.
65
+ `SOCKETDUCT_LOG_MAX_FILE_BYTES` defaults to 10 MiB and
66
+ `SOCKETDUCT_LOG_MAX_FILES` defaults to five total files per process/role family.
67
+ Existing stdout/stderr output is preserved. See
68
+ [Persistent structured logging](logging.md) for the mount example, embedded
69
+ client `logger` option, rotation layout, event list, and strict redaction
70
+ contract.
71
+
72
+ ## Minimal programmatic relay/edge shape
73
+
74
+ ```js
75
+ import { InMemoryUserStore, login, TokenStore } from './src/auth/user-store.js';
76
+ import { startTcpEdge } from './src/edge/tcp-edge.js';
77
+ import { startTcpRelay } from './src/proxy/tcp-relay.js';
78
+ import { SessionStore } from './src/sessions/session.js';
79
+ import { SessionSpool } from './src/spool/spool-file.js';
80
+
81
+ const users = new InMemoryUserStore();
82
+ const tokens = new TokenStore();
83
+ await users.createUser({ username: 'edge', password: process.env.SOCKETDUCT_PASSWORD });
84
+ const token = await login(users, tokens, { username: 'edge', password: process.env.SOCKETDUCT_PASSWORD });
85
+
86
+ const relay = await startTcpRelay({
87
+ authorizeTarget: ({ principal, target }) => {
88
+ return principal.username === 'edge' && target.host === '127.0.0.1' && target.port === 22;
89
+ },
90
+ tokens,
91
+ sessions: new SessionStore(),
92
+ });
93
+
94
+ const edge = await startTcpEdge({
95
+ relayHost: '127.0.0.1',
96
+ relayPort: relay.port,
97
+ token: token.value,
98
+ sessionName: 'local-ssh',
99
+ target: { host: '127.0.0.1', port: 22 },
100
+ spool: new SessionSpool({ rootDir: '/tmp/socketduct-spool' }),
101
+ });
102
+
103
+ console.log(`Connect local clients to 127.0.0.1:${edge.port}`);
104
+ ```
105
+
106
+ Do not hard-code real passwords or token values. Feed credentials through a secret manager or environment variable and keep logs redacted.
107
+
108
+ ## MVP limitations
109
+
110
+ - No stable CLI flags yet.
111
+ - Relay targets are requested per session and must be explicitly authorized by the injected target policy.
112
+ - User/session stores are in-memory in the current MVP skeleton.
113
+ - Spool files are temporary local files and may contain raw application payload.
114
+
115
+ ## Resource-exhaustion protections
116
+
117
+ ### Initial target acquisition retry
118
+
119
+ `SOCKETDUCT_INITIAL_TARGET_CONNECT_TIMEOUT_MS` defaults to `10000` and bounds each individual target TCP connect attempt. `SOCKETDUCT_INITIAL_TARGET_RETRY_DELAY_MS` defaults to `250`. `SOCKETDUCT_INITIAL_TARGET_RETRY_MAX_DELAY_MS` defaults to `5000` and must be greater than or equal to the initial delay. While an authenticated, authorized session is still `creating`, the relay destroys a refused or timed-out attempt and retries with a doubling delay capped by the maximum. Request timeout, client abort/tunnel close, or relay shutdown cancels the attempt and every associated timer.
120
+
121
+ The retry ends permanently at `session.created`. Established target failure and application-write uncertainty are terminal and are never automatically replayed.
122
+
123
+ The relay enforces two configurable pre-authentication limits to prevent slow/partial resource exhaustion:
124
+
125
+ ### Handshake timeout
126
+
127
+ `SOCKETDUCT_HANDSHAKE_TIMEOUT_MS` (default: `10000`, 10 seconds) limits how long a connection can stay open without sending a `session.create` or `session.resume` frame. When the timer fires before authentication completes, the relay closes the connection with a `HANDSHAKE_TIMEOUT` error frame.
128
+
129
+ This prevents slow-loris-style connections that send data at an arbitrarily low rate to hold relay resources indefinitely.
130
+
131
+ ### Per-connection input budget
132
+
133
+ `SOCKETDUCT_MAX_INPUT_BYTES_PER_CONNECTION` (default: `65536`, 64 KiB) limits the cumulative raw bytes a v2 or v3 connection may send before completing authentication. Once the budget is exceeded, the relay closes the connection; v3 reports `INPUT_LIMIT_EXCEEDED`. Authenticated stream traffic is governed by the separate frame and buffer limits.
134
+
135
+ This prevents an attacker from sending large amounts of data (e.g., 100s of MiB of partial frame fragments) before the handshake deadline expires.
136
+
137
+ ### Incremental frame decoder
138
+
139
+ The `FrameDecoder` uses a linear cursor over a single buffer rather than concatenating a new buffer on every `push()` call. This avoids the O(n²) copying pattern that could be exploited by repeated small TCP chunks.
140
+
141
+ ## TLS configuration
142
+
143
+ The relay supports Node.js built-in TLS via `node:tls`. Configure with environment variables:
144
+
145
+ ```sh
146
+ # Atomic pair: both must be set or neither
147
+ SOCKETDUCT_TLS_CERT_FILE=/certs/relay.crt
148
+ SOCKETDUCT_TLS_KEY_FILE=/certs/relay.key
149
+
150
+ # Optional mTLS
151
+ SOCKETDUCT_TLS_CLIENT_CA_FILE=/certs/client-ca.crt
152
+
153
+ # Explicitly allow non-loopback plaintext (1 or true only)
154
+ SOCKETDUCT_ALLOW_INSECURE_PUBLIC=1
155
+ ```
156
+
157
+ The relay fails closed: binding to a non-loopback address without TLS is refused unless `SOCKETDUCT_ALLOW_INSECURE_PUBLIC` is explicitly set to `1` or `true`. Loopback (`127.0.0.0/8`, `::1`, `localhost`) plaintext binds remain compatible without this flag.
158
+
159
+ Certificate material is read at runtime with `readFileSync` and the process fails loudly on missing or unreadable files. Sensitive material is never logged.
160
+
161
+ Relay startup and periodic status messages are JSON. `SOCKETDUCT_STATUS_INTERVAL_MS` defaults to `30000`; set it to `0` to disable. Status messages include safe connection/session, initial target attempt/timer, and buffered-byte counts—never tokens, targets, payloads, certificate contents, or spool data.
162
+
163
+ ### Client configuration
164
+
165
+ Connect to a TLS relay by providing a `tls` option with the server's CA certificate:
166
+
167
+ ```js
168
+ const socket = connectSocketduct({
169
+ relay: {host: 'relay.example.com', port: 3100, token: '...'},
170
+ sessionName: 'secure-tunnel',
171
+ target: {host: 'docker-api', port: 2375},
172
+ spoolDirectory: '/tmp/socketduct-spool',
173
+ tls: {
174
+ caFile: '/path/to/ca.crt',
175
+ },
176
+ })
177
+ ```
178
+
179
+ `rejectUnauthorized` defaults to `true`. Set it to `false` only for testing with self-signed certificates.
180
+
181
+ For mTLS, the client must present a certificate signed by the relay's client CA. Provide `certFile` and `keyFile` as an atomic pair in the `tls` option:
182
+
183
+ ```js
184
+ const socket = connectSocketduct({
185
+ relay: {host: 'relay.example.com', port: 3100, token: '...'},
186
+ sessionName: 'secure-tunnel',
187
+ target: {host: 'docker-api', port: 2375},
188
+ spoolDirectory: '/tmp/socketduct-spool',
189
+ tls: {
190
+ caFile: '/path/to/ca.crt',
191
+ certFile: '/path/to/client.crt',
192
+ keyFile: '/path/to/client.key',
193
+ },
194
+ })
195
+ ```
196
+
197
+ Set `servername` to override the SNI hostname used for certificate identity validation when the certificate CN/SAN differs from `relayHost`.
198
+
199
+ This `tls` option is relay TLS. For HTTPS or mTLS at the requested target, use `SocketductHttpsAgent` from `socketduct/https` with separate `relayTls` and `targetTls` options as documented in [Node-compatible socket API design](socket-api.md#socketducthttpsagent).
200
+
201
+ For several explicit WAN paths in one process, use
202
+ `MultiplexedSocketductTransportSet` and call `await set.start()` before accepting
203
+ application work. Configure one stable gateway ID and pinned relay endpoint or
204
+ physical socket factory per path. `minimumReadyMembers` defaults to 1, so a dead
205
+ WAN does not block process readiness; inspect `set.status()` or `stateChange`
206
+ notifications for slower members joining or recovering. While all members are
207
+ reconnecting, bounded new opens wait for a ready member. If every member becomes
208
+ terminal after readiness, they fail with `SOCKETDUCT_TRANSPORT_SET_TERMINAL`;
209
+ inspect its ordered aggregate causes or privacy-safe per-member status codes.
210
+ No extra physical member is created. Configure the set-wide buffer/spool/queue
211
+ limits for the whole process rather than multiplying a desired budget by
212
+ gateway count.
213
+ For process replacement, call `recoverConnection()` with each stream's original
214
+ gateway ID before `start()`; see the exact sequence in
215
+ [Node-compatible socket API design](socket-api.md#multiplexedsocketducttransportset-multi-gateway-v3-api).