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.
- package/.dockerignore +11 -0
- package/.env.example +131 -0
- package/AGENTS.md +168 -0
- package/Dockerfile +30 -0
- package/README.md +134 -0
- package/changelog.d/20260716083144-v2-ack-wire-format.md +3 -0
- package/changelog.d/20260716092044-v2-only-stream-framing.md +1 -0
- package/changelog.d/20260727113000-relay-connect-timeout-destroy.md +1 -0
- package/changelog.d/20260813093000-private-relay-hardening.md +1 -0
- package/changelog.d/20260824183412-initial-target-recovery-https.md +1 -0
- package/changelog.d/20260824230000-tls-relay-session-close.md +1 -0
- package/changelog.d/20260825120000-reconnect-close-target-timeout.md +1 -0
- package/changelog.d/20260829120000-protocol-v3-multiplexing.md +1 -0
- package/changelog.d/20260829160000-v3-review-corrections.md +1 -0
- package/changelog.d/20260830000000-fix-socket-idle-timeout.md +1 -0
- package/changelog.d/20260830090000-multi-gateway-transport-set.md +1 -0
- package/changelog.d/20260830093000-relay-close-settlement.md +1 -0
- package/changelog.d/20260830120000-immutable-gateway-image.md +1 -0
- package/changelog.d/20260902210000-native-reverse-gateway.md +1 -0
- package/changelog.d/20260902230000-relay-resume-terminal-order.md +1 -0
- package/changelog.d/20260902230708-multiplexed-open-ownership.md +1 -0
- package/changelog.d/20260902231541-multiplexed-terminal-close.md +1 -0
- package/changelog.d/20260902231542-reverse-close-position.md +1 -0
- package/changelog.d/20260902231543-reverse-terminal-connect.md +1 -0
- package/changelog.d/20260902233000-reverse-gateway-bounds.md +1 -0
- package/changelog.d/20260903001000-reverse-data-ordering.md +1 -0
- package/changelog.d/20260903003000-reverse-hub-restart-window.md +1 -0
- package/changelog.d/20260903010000-reverse-terminal-lifecycle.md +1 -0
- package/changelog.d/20260904142743-transport-set-readiness-wait.md +1 -0
- package/changelog.d/20260904152855-deferred-open-snapshots-binary-views.md +1 -0
- package/changelog.d/20260904163041-tensorbuzz-container-workflow.md +1 -0
- package/changelog.d/20260904174955-transport-set-terminal-causes.md +1 -0
- package/changelog.d/20260905081000-contain-reverse-auth-write-errors.md +1 -0
- package/changelog.d/20260905100520-reverse-hub-close-race.md +1 -0
- package/changelog.d/20260905120000-persistent-jsonl-logging.md +1 -0
- package/changelog.d/20260908071009-jsonl-partial-append-recovery.md +1 -0
- package/changelog.d/20260908074916-private-log-directory-setup.md +1 -0
- package/changelog.d/20260909093057-reverse-large-write-backpressure.md +1 -0
- package/changelog.d/20260909135830-actionable-error-diagnostics.md +1 -0
- package/changelog.d/20260909150000-transport-set-error-redaction.md +1 -0
- package/docker-compose.dev.yml +19 -0
- package/docker-compose.reverse-gateway.yml +36 -0
- package/docker-compose.yml +73 -0
- package/docs/architecture.md +264 -0
- package/docs/docker-topology.md +164 -0
- package/docs/docker.md +281 -0
- package/docs/logging.md +185 -0
- package/docs/plans/initial-implementation.md +205 -0
- package/docs/plans/socket-docker-integration.md +221 -0
- package/docs/protocol.md +558 -0
- package/docs/releasing.md +18 -0
- package/docs/reverse-gateway.md +186 -0
- package/docs/runbooks/jsonl-integrity-rollout.md +144 -0
- package/docs/running.md +215 -0
- package/docs/security.md +258 -0
- package/docs/session-lifecycle.md +127 -0
- package/docs/socket-api.md +448 -0
- package/eslint.config.js +36 -0
- package/package.json +44 -0
- package/scripts/deploy-gateway.js +194 -0
- package/scripts/test.js +28 -0
- package/src/auth/passwords.js +38 -0
- package/src/auth/static-token-store.js +41 -0
- package/src/auth/user-store.js +139 -0
- package/src/client/connect.js +76 -0
- package/src/client/deferred-virtual-socket.js +312 -0
- package/src/client/http-agent.js +101 -0
- package/src/client/https-agent.js +366 -0
- package/src/client/multiplexed-connection.js +1504 -0
- package/src/client/multiplexed-transport-set.js +917 -0
- package/src/client/multiplexed.js +3 -0
- package/src/client/socketduct-socket.js +1174 -0
- package/src/client/virtual-socket.js +256 -0
- package/src/config/environment.js +400 -0
- package/src/edge/tcp-edge.js +82 -0
- package/src/index.js +101 -0
- package/src/logging/structured-logger.js +745 -0
- package/src/protocol/compression.js +325 -0
- package/src/protocol/frame.js +341 -0
- package/src/protocol/v3-compression.js +44 -0
- package/src/protocol/v3-frame.js +209 -0
- package/src/proxy/multiplexed-relay.js +798 -0
- package/src/proxy/tcp-relay.js +1191 -0
- package/src/relay.js +3 -0
- package/src/reverse/client.js +721 -0
- package/src/reverse/common.js +88 -0
- package/src/reverse/gateway.js +663 -0
- package/src/reverse/hub.js +563 -0
- package/src/reverse/index.js +3 -0
- package/src/reverse/runtime.js +78 -0
- package/src/server/control-handler.js +170 -0
- package/src/server/primary.js +64 -0
- package/src/server/relay-runtime.js +124 -0
- package/src/server/worker.js +39 -0
- package/src/sessions/session.js +212 -0
- package/src/spool/spool-file.js +451 -0
- package/tensorbuzz.yml +36 -0
- package/test/e2e/gateway-process.test.js +262 -0
- package/test/e2e/reverse-gateway-process.test.js +285 -0
- package/test/fixtures/logging/enospc-matrix.mjs +238 -0
- package/test/fixtures/logging/enospc-recovery.mjs +146 -0
- package/test/fixtures/logging/process-writer.mjs +20 -0
- package/test/helpers/tls-certificates.js +115 -0
- package/test/integration/initial-target-recovery.test.js +518 -0
- package/test/integration/logging-recovery.test.js +163 -0
- package/test/integration/multiplexed-compatibility.test.js +254 -0
- package/test/integration/multiplexed-corrections.test.js +551 -0
- package/test/integration/multiplexed-failures.test.js +339 -0
- package/test/integration/multiplexed-transport-set.test.js +456 -0
- package/test/integration/multiplexed-transport.test.js +208 -0
- package/test/integration/reverse-gateway.test.js +1293 -0
- package/test/integration/socketduct-http-agent.test.js +435 -0
- package/test/integration/socketduct-https-agent.test.js +669 -0
- package/test/integration/stream-compression.test.js +1267 -0
- package/test/integration/tcp-forwarding.test.js +2878 -0
- package/test/integration/tls-forwarding.test.js +748 -0
- package/test/unit/auth/auth.test.js +64 -0
- package/test/unit/auth/static-token-store.test.js +14 -0
- package/test/unit/client/connect.test.js +58 -0
- package/test/unit/client/multiplexed-spool-budget.test.js +309 -0
- package/test/unit/client/multiplexed-transport-set.test.js +906 -0
- package/test/unit/client/socketduct-socket.test.js +1124 -0
- package/test/unit/client/virtual-socket.test.js +98 -0
- package/test/unit/config/docker-gateway-files.test.js +274 -0
- package/test/unit/config/environment.test.js +507 -0
- package/test/unit/index.test.js +78 -0
- package/test/unit/logging/structured-logger.test.js +674 -0
- package/test/unit/protocol/compression.test.js +421 -0
- package/test/unit/protocol/frame.test.js +336 -0
- package/test/unit/protocol/v3-compression.test.js +31 -0
- package/test/unit/protocol/v3-frame.test.js +57 -0
- package/test/unit/release-configuration.test.js +21 -0
- package/test/unit/server/control-handler.test.js +291 -0
- package/test/unit/server/primary.test.js +59 -0
- package/test/unit/server/relay-runtime.test.js +434 -0
- package/test/unit/sessions/session.test.js +84 -0
- package/test/unit/spool/spool-file.test.js +295 -0
- 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.
|
package/docs/running.md
ADDED
|
@@ -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).
|