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,73 @@
1
+ services:
2
+ socketduct-relay:
3
+ image: ${SOCKETDUCT_IMAGE:?Set SOCKETDUCT_IMAGE to ghcr.io/kaspernj/socketduct@sha256 followed by 64 lowercase hex characters}
4
+ restart: unless-stopped
5
+ init: true
6
+ user: "1000:1000"
7
+ read_only: true
8
+ cap_drop:
9
+ - ALL
10
+ security_opt:
11
+ - no-new-privileges:true
12
+ pids_limit: 256
13
+ stop_grace_period: 45s
14
+ tmpfs:
15
+ - /tmp:rw,noexec,nosuid,nodev,size=64m
16
+ env_file:
17
+ - .env
18
+ environment:
19
+ SOCKETDUCT_MODE: relay
20
+ SOCKETDUCT_RELAY_HOST: 0.0.0.0
21
+ SOCKETDUCT_RELAY_PORT: ${SOCKETDUCT_RELAY_PORT:-3100}
22
+ SOCKETDUCT_PROTOCOL_VERSION: ${SOCKETDUCT_PROTOCOL_VERSION:-2}
23
+ SOCKETDUCT_TLS_CERT_FILE: ${SOCKETDUCT_TLS_CERT_FILE:-}
24
+ SOCKETDUCT_TLS_KEY_FILE: ${SOCKETDUCT_TLS_KEY_FILE:-}
25
+ SOCKETDUCT_TLS_CLIENT_CA_FILE: ${SOCKETDUCT_TLS_CLIENT_CA_FILE:-}
26
+ SOCKETDUCT_ALLOW_INSECURE_PUBLIC: ${SOCKETDUCT_ALLOW_INSECURE_PUBLIC:-}
27
+ SOCKETDUCT_STATUS_INTERVAL_MS: ${SOCKETDUCT_STATUS_INTERVAL_MS:-30000}
28
+ SOCKETDUCT_INITIAL_TARGET_RETRY_DELAY_MS: ${SOCKETDUCT_INITIAL_TARGET_RETRY_DELAY_MS:-250}
29
+ SOCKETDUCT_INITIAL_TARGET_RETRY_MAX_DELAY_MS: ${SOCKETDUCT_INITIAL_TARGET_RETRY_MAX_DELAY_MS:-5000}
30
+ ports:
31
+ - "${SOCKETDUCT_PUBLISH_HOST:-127.0.0.1}:${SOCKETDUCT_RELAY_PORT:-3100}:${SOCKETDUCT_RELAY_PORT:-3100}"
32
+ networks:
33
+ - socketduct-internal
34
+ volumes:
35
+ - ${SOCKETDUCT_TLS_CERT_DIR:-./certs}:/certs:ro
36
+ - ${SOCKETDUCT_LOG_HOST_DIRECTORY:-./logs}:/var/log/socketduct
37
+ healthcheck:
38
+ test:
39
+ - CMD-SHELL
40
+ - >-
41
+ node --input-type=module -e "import net from 'node:net'; const port=Number(process.env.SOCKETDUCT_RELAY_PORT || 3100); const socket=net.createConnection({host:'127.0.0.1', port}, () => { socket.end(); process.exit(0) }); socket.setTimeout(2000, () => { socket.destroy(); process.exit(1) }); socket.on('error', () => process.exit(1))"
42
+ interval: 30s
43
+ timeout: 3s
44
+ retries: 3
45
+ start_period: 10s
46
+
47
+ docker-socket-shim:
48
+ image: alpine/socat:1.8.0.0
49
+ restart: unless-stopped
50
+ init: true
51
+ read_only: true
52
+ cap_drop:
53
+ - ALL
54
+ security_opt:
55
+ - no-new-privileges:true
56
+ pids_limit: 128
57
+ tmpfs:
58
+ - /tmp:rw,noexec,nosuid,nodev,size=16m
59
+ profiles:
60
+ - docker-host
61
+ command:
62
+ - tcp-listen:2375,fork,reuseaddr
63
+ - unix-connect:/var/run/docker.sock
64
+ expose:
65
+ - "2375"
66
+ volumes:
67
+ - /var/run/docker.sock:/var/run/docker.sock
68
+ networks:
69
+ - socketduct-internal
70
+
71
+ networks:
72
+ socketduct-internal:
73
+ driver: bridge
@@ -0,0 +1,264 @@
1
+ # Architecture
2
+
3
+ ## Problem
4
+
5
+ A normal app opens a TCP/socket connection through an internet path that sometimes drops for a few seconds. The app sees the socket close and must reconnect, often losing stream/session state.
6
+
7
+ Socketduct moves the unstable part into a controlled tunnel:
8
+
9
+ ```text
10
+ Stable LAN Unstable WAN Stable remote
11
+ ---------- ------------ -------------
12
+ Application -> local edge/proxy -> resumable tunnel -> relay/server -> target service
13
+ ```
14
+
15
+ The application only talks to the local edge over a stable LAN. The edge and relay speak Socketduct's resumable protocol across the flaky WAN.
16
+
17
+ ## Components
18
+
19
+ ### Protocol-v3 multiplexed connection
20
+
21
+ `MultiplexedSocketductConnection` is the WAN ownership boundary. It owns
22
+ exactly one `net.Socket` or `tls.TLSSocket`, authentication, heartbeat,
23
+ generation negotiation, the shared reconnect lock, paginated resume manifests,
24
+ aggregate buffer limits and fair scheduling. Concurrent virtual opens share its
25
+ single connection/authentication promise.
26
+
27
+ The relay runtime additionally owns one process-wide retained-byte reservation
28
+ across all authenticated v3 connection identities. Per-stream and
29
+ per-connection bounds remain independent safeguards. Physical-generation
30
+ window credit is discarded and recomputed from durable ACK positions on resume.
31
+
32
+ Each `VirtualSocket` is only a stream boundary. It owns a collision-free stream
33
+ ID, stable resumable identifier/name, authorized target metadata, directional
34
+ sequences and acknowledgements, flow-control windows, replay state, half-close,
35
+ clean close and reset. On the relay each virtual stream owns one local target
36
+ socket; those local sockets are not WAN Socketduct transports. A stream reset
37
+ cannot close its siblings.
38
+
39
+ For process replacement, callers opt in with a stable connection identity and
40
+ stable stream identifiers. Bounded per-stream recovery metadata records the
41
+ wire stream ID, target, generation and directional ACK state alongside the
42
+ outbound spool. Fresh creation is exclusive; recovery never truncates an
43
+ existing spool and rejects corrupt or identity-mismatched state.
44
+
45
+ ### Process-scoped multi-gateway transport set
46
+
47
+ `MultiplexedSocketductTransportSet` is a client-side ownership layer, not a new
48
+ wire protocol. It constructs one long-lived `MultiplexedSocketductConnection`
49
+ for each explicit gateway ID and pinned endpoint/factory. Each member continues
50
+ to own exactly one physical lane, connectionId, spool namespace, stream IDs,
51
+ flow control, heartbeat, reconnect lock and v3 resume manifest. The set never
52
+ copies stream/replay state between members.
53
+
54
+ All configured members warm concurrently. Minimum readiness defaults to one;
55
+ unready paths continue member-local reconnect attempts while ready siblings
56
+ serve new opens. The set keeps a configured-order cursor and advances it once
57
+ per successful virtual allocation, skipping members that are not ready. Thus
58
+ continuously ready members differ by at most one assignment. A recovered member
59
+ rejoins future selection at the cursor without taking existing sockets.
60
+
61
+ The set also owns the application-facing readiness gap. It returns a bounded
62
+ deferred `VirtualSocket` immediately when startup has not reached minimum
63
+ readiness or every member is reconnecting, and holds opens FIFO without choosing
64
+ a gateway. Once eligible, the facade is bridged to one ready member and its
65
+ gateway ID becomes immutable. Destruction cancels an unassigned open; terminal
66
+ startup, terminal all-member loss and set shutdown fail the facade through
67
+ normal socket error/close behavior. Target TLS wraps the facade immediately, so
68
+ the TLS layer continues to own its handshake and `secureConnect` lifecycle.
69
+
70
+ Retained outbound bytes are both memory and durable-spool reservations. Pending
71
+ pre-assignment bytes reserve only the shared memory and queued-byte budgets and
72
+ are released before the selected member reserves its normal live/durable bytes.
73
+ A shared set budget reserves those bytes before a member accepts them, while
74
+ control frames reserve the shared queued-byte budget separately. ACK, reset and
75
+ close paths release memory as soon as its buffer is gone, but release spool
76
+ capacity only after the serialized durable truncate/remove succeeds. Cleanup
77
+ failure retains the spool reservation and fails that member rather than
78
+ advertising capacity that still exists on disk. These process bounds supplement,
79
+ rather than replace, member and stream limits.
80
+
81
+ Replacement recovery is registered before set startup and addresses the
82
+ original configured gateway ID directly. The set derives the same member
83
+ identity/spool namespace and delegates recovery to that member before any
84
+ physical attempt; round-robin is never involved. An orderly set detach preserves
85
+ active spools while retiring empty warmed lanes. Ordinary new opens cannot name
86
+ a gateway.
87
+
88
+ Protocol v2 retains the original one-session-per-tunnel architecture for
89
+ rollback. A relay listener selects v2 or v3 explicitly.
90
+
91
+ ### Native reverse gateway
92
+
93
+ The reverse hub is a rendezvous and stream-ID translation boundary on Server3.
94
+ Authenticated home gateway processes connect outward, register stable IDs, and
95
+ accept hub-created even stream IDs. Authenticated local application members
96
+ select one ID and create odd-ID streams. The hub maps frames without opening
97
+ the target or nesting another Socketduct transport. Each home gateway applies
98
+ the final local allowlist before connecting to `docker-server:2375`.
99
+
100
+ Gateway registration reconnects with generation fencing. Established reverse
101
+ streams are terminal on transport loss because replaying an ambiguous Docker
102
+ operation would be unsafe. Live-transport buffers remain bounded; durable
103
+ spooling and resume continue to apply to ordinary Socketduct sessions only.
104
+ See [Native reverse gateway](reverse-gateway.md).
105
+
106
+ ### Edge
107
+
108
+ Runs near the application, usually on the same LAN.
109
+
110
+ Responsibilities:
111
+
112
+ - expose local TCP/SOCKS/HTTP proxy endpoints
113
+ - authenticate to the relay
114
+ - create named sessions with a requested target host/port and resume them by identifier
115
+ - keep app-facing sockets open during short WAN interruptions
116
+ - spool unacknowledged data to temporary files
117
+ - replay spooled data after resume
118
+ - apply local limits for buffering, reconnect duration, and memory usage
119
+
120
+ ### Relay/server
121
+
122
+ Runs on a reachable stable machine/VPS/server.
123
+
124
+ Responsibilities:
125
+
126
+ - manage users and password hashes
127
+ - authenticate clients
128
+ - authorize session create/resume operations
129
+ - validate each `session.create.target` against an allowlist policy
130
+ - route stream frames to the target connection owned by that session
131
+ - keep session target sockets alive across short edge-relay tunnel drops when limits allow
132
+ - track session sequence/ack state
133
+ - reject unauthorized session identifiers
134
+ - in v3, authenticate each physical connection once and authorize every
135
+ `stream.open` target independently
136
+ - clean up expired sessions and spool metadata
137
+ - run across multiple CPU cores
138
+
139
+ ### Worker pool
140
+
141
+ The server should use Node.js multi-core support from the start.
142
+
143
+ Recommended MVP shape:
144
+
145
+ - one primary/control process
146
+ - `N = availableParallelism()` cluster workers
147
+ - each worker accepts connections and owns the sessions assigned to it
148
+ - shared durable state is minimal at first: user store + session metadata + spool files
149
+
150
+ For the first version, session stickiness can be simple:
151
+
152
+ - create/resume requests include `session.identifier`
153
+ - primary process or a shared registry maps `identifier -> workerId`
154
+ - if the original worker died, resume can recover from persisted spool/session metadata
155
+
156
+ ## Session identity
157
+
158
+ Each session has:
159
+
160
+ - `name`: human-readable label, e.g. `work-ssh`, `obs-stream`, `api-upload`
161
+ - `identifier`: opaque stable id, e.g. UUIDv7 or 128-bit random token
162
+ - `target`: authorized `{host, port}` metadata captured at creation time
163
+
164
+ The name is for humans. The identifier is the real resume key.
165
+
166
+ Example session creation:
167
+
168
+ ```json
169
+ {
170
+ "type": "session.create",
171
+ "name": "work-ssh",
172
+ "target": {
173
+ "host": "10.0.0.15",
174
+ "port": 22
175
+ }
176
+ }
177
+ ```
178
+
179
+ Example response:
180
+
181
+ ```json
182
+ {
183
+ "type": "session.created",
184
+ "identifier": "018ff4c1-4cd9-7a25-b610-50cf8e10a4e7",
185
+ "name": "work-ssh"
186
+ }
187
+ ```
188
+
189
+ The relay stores a new session as `creating` and withholds this response until the authorized target TCP connection succeeds. Only pre-response target acquisition is retried; after this response, target failure is terminal and application bytes are never automatically replayed against a replacement target.
190
+
191
+ ## Temporary spool files
192
+
193
+ Spool files are used to survive brief disconnections and process restarts.
194
+
195
+ Suggested default location:
196
+
197
+ ```text
198
+ $XDG_RUNTIME_DIR/socketduct/sessions/<identifier>.spool
199
+ ```
200
+
201
+ Fallback:
202
+
203
+ ```text
204
+ /tmp/socketduct-$UID/sessions/<identifier>.spool
205
+ ```
206
+
207
+ Spool file should contain:
208
+
209
+ - protocol version
210
+ - session identifier
211
+ - user id or subject id
212
+ - last local sequence
213
+ - last remote acknowledgement
214
+ - pending chunks not acknowledged by relay
215
+ - checksum for corruption detection
216
+
217
+ Spool files are not permanent storage. They should be deleted when:
218
+
219
+ - the session closes cleanly
220
+ - the session expires
221
+ - the user deletes the session
222
+ - the spool exceeds configured age/size limits
223
+
224
+ ## What Socketduct can and cannot guarantee
225
+
226
+ Socketduct can hide short interruptions on the WAN leg if:
227
+
228
+ - the app-facing LAN connection remains open
229
+ - buffer limits are not exceeded
230
+ - reconnect happens before the target/service side times out irrecoverably
231
+ - the protocol being proxied tolerates short stalls
232
+
233
+ Socketduct cannot perfectly resume every arbitrary upstream TCP connection if the remote target has already closed or invalidated its state. The relay can keep the target-facing connection open during short edge disconnects, but once the target closes, Socketduct can only reconnect at the application/protocol level if that protocol supports it.
234
+
235
+ ## Initial implementation direction
236
+
237
+ Start with one transport and one proxy mode:
238
+
239
+ - edge exposes a local TCP forwarder
240
+ - edge includes the desired target in `session.create`
241
+ - relay authorizes the target using deny-by-default policy such as `authorizeTarget({principal, target})`
242
+ - relay connects to and retains target sockets per session identifier, not per relay instance
243
+ - edge-relay protocol uses framed messages over TLS or QUIC-like transport
244
+ - session resume handles edge-relay interruption first
245
+
246
+ ### Transport security model
247
+
248
+ The relay enforces a fail-closed security posture:
249
+
250
+ - Non-loopback public binds (any interface other than `127.0.0.0/8`, `::1`, `localhost`) without TLS are refused at startup unless explicitly opted out via `SOCKETDUCT_ALLOW_INSECURE_PUBLIC=1`.
251
+ - Loopback plaintext binds remain compatible for local development and testing.
252
+ - TLS is enabled via `SOCKETDUCT_TLS_CERT_FILE` + `SOCKETDUCT_TLS_KEY_FILE` (atomic pair).
253
+ - Optional mTLS via `SOCKETDUCT_TLS_CLIENT_CA_FILE` enforces client certificate verification.
254
+ - Certificate material is read at runtime; missing or unreadable files cause an immediate startup failure.
255
+ - The client defaults to `rejectUnauthorized: true`.
256
+
257
+ Later additions:
258
+
259
+ - SOCKS5 proxy mode
260
+ - HTTP CONNECT mode
261
+ - destination allowlists per user
262
+ - admin API/UI
263
+ - metrics
264
+ - pluggable auth backends
@@ -0,0 +1,164 @@
1
+ # Docker Topology for Socketduct
2
+
3
+ > The topology below documents the ordinary edge-to-relay deployment. For the
4
+ > TensorBuzz home-server design where Zyxel and Starlink have no public address,
5
+ > use the [native reverse gateway](reverse-gateway.md): three home members
6
+ > connect outbound to Server3, directly reach `docker-server:2375` on a private
7
+ > Compose network, and require neither inbound forwarding nor a socket shim.
8
+
9
+ This document describes how Socketduct should be used for Docker traffic from TensorBuzz through `docker-quack`.
10
+
11
+ ## Target topology
12
+
13
+ ```text
14
+ TensorBuzz backend
15
+ -> docker-quack
16
+ -> Socketduct connection factory / HTTP agent
17
+ -> Socketduct relay endpoint for the selected Docker server
18
+ -> Docker server-local Socketduct container
19
+ -> Docker daemon TCP/Unix socket
20
+ ```
21
+
22
+ Each Docker server owns its Socketduct endpoint. TensorBuzz chooses a Docker server, reads that server's Socketduct connection settings, and gives `docker-quack` a transport that opens Socketduct sessions to the Docker daemon reachable from that same server.
23
+
24
+ ## Why the relay lives beside Docker
25
+
26
+ The critical requirement is that Docker traffic returns to the Docker server that originated the gateway container. Running Socketduct on or next to each Docker server keeps the target allowlist local and narrow:
27
+
28
+ - relay target `docker-api:2375` or another private-network service name when Docker exposes a TCP endpoint reachable from the Socketduct container;
29
+ - relay target `docker-socket-shim:2375` when Docker is only exposed as a Unix socket through a private sidecar shim;
30
+ - relay target `127.0.0.1:2375` only when Socketduct shares the Docker host network namespace;
31
+ - never a broad allowlist for arbitrary network hosts.
32
+
33
+ The Docker server-local container becomes the stable remote endpoint. TensorBuzz and `docker-quack` connect through Socketduct rather than directly relying on a fragile long-lived TCP path.
34
+
35
+ ## Container modes
36
+
37
+ ### Mode A: TCP target
38
+
39
+ Use this when the Docker daemon is already reachable from the Socketduct container as TCP through a private Docker network. `127.0.0.1` inside a normal bridge-networked Socketduct container is the Socketduct container itself, not the Docker host, so use a reachable service name or host address here.
40
+
41
+ ```env
42
+ SOCKETDUCT_MODE=relay
43
+ SOCKETDUCT_RELAY_HOST=0.0.0.0
44
+ SOCKETDUCT_RELAY_PORT=3100
45
+ SOCKETDUCT_AUTH_TOKENS=tensorbuzz:replace-with-generated-token
46
+ SOCKETDUCT_TARGET_ALLOWLIST=docker-api:2375
47
+ ```
48
+
49
+ ### Mode B: Unix socket target through a local shim
50
+
51
+ Use this when Docker is only available as `/var/run/docker.sock`.
52
+
53
+ ```text
54
+ Socketduct relay container -> docker-socket-shim:2375 -> /var/run/docker.sock
55
+ ```
56
+
57
+ The shim should be server-local, private, and only reachable from the Socketduct container network. The Socketduct target allowlist still points at exactly one host/port inside that private network.
58
+
59
+ ### Mode C: host network
60
+
61
+ Use only where acceptable operationally. The relay container can run with host networking and allowlist `127.0.0.1:2375`. This is simple but less portable than an explicit private Compose network.
62
+
63
+ ## TensorBuzz configuration model
64
+
65
+ TensorBuzz should add optional Socketduct fields to each Docker server configuration while keeping direct Docker connection as a fallback during rollout.
66
+
67
+ Suggested model fields:
68
+
69
+ - `socketduct_enabled` — boolean rollout flag.
70
+ - `socketduct_relay_host` — hostname/IP TensorBuzz can reach.
71
+ - `socketduct_relay_port` — relay port, default `3100`.
72
+ - `socketduct_token_secret_ref` — backend secret reference, never raw token in UI logs.
73
+ - `socketduct_target_host` — Docker target as seen from the relay, usually a private service name such as `docker-api` or `docker-socket-shim`; use `127.0.0.1` only for host-network deployments.
74
+ - `socketduct_target_port` — Docker target port, usually `2375`.
75
+ - `socketduct_resume_window_ms` — expected server-side hold window.
76
+ - `socketduct_rollout_mode` — `off`, `shadow_probe`, `preferred`, or `required`.
77
+
78
+ Rollout modes:
79
+
80
+ - `off`: existing direct Docker transport only.
81
+ - `shadow_probe`: direct Docker remains primary; background `_ping` probes Socketduct and records health.
82
+ - `preferred`: try Socketduct first and fall back to direct Docker for safe idempotent operations.
83
+ - `required`: use Socketduct only; direct fallback disabled.
84
+
85
+ ## docker-quack integration model
86
+
87
+ `docker-quack` should not depend directly on TensorBuzz. It should gain a transport abstraction that can be passed to `SnapReq`/HTTP plumbing:
88
+
89
+ ```js
90
+ const connection = new DockerConnection({
91
+ transport: createSocketductDockerTransport({
92
+ relay: {host, port, token},
93
+ target: {host: "docker-api", port: 2375}
94
+ })
95
+ })
96
+ ```
97
+
98
+ The transport should provide either:
99
+
100
+ - an `http.Agent` compatible object; or
101
+ - a `createConnection(options, callback)` hook accepted by the underlying HTTP client.
102
+
103
+ The preferred shape is an agent because Docker API keep-alive, streaming responses, and hijack-style upgraded streams are all socket lifecycle problems that fit Node's HTTP client model.
104
+
105
+ ## Security requirements
106
+
107
+ - Authenticate every Socketduct tunnel before session create/resume.
108
+ - Keep target allowlists per Docker server and deny-by-default.
109
+ - Store bearer tokens in TensorBuzz backend secrets, not code, changelogs, PR bodies, or task comments.
110
+ - Redact token values in logs and error reports.
111
+ - Treat Docker API traffic as privileged root-equivalent traffic.
112
+ - Prefer private networks/firewalls around relay ports; add TLS or private overlay networking before internet exposure.
113
+ - Keep fallback direct Docker credentials independent from Socketduct rollout flags so rollback is quick.
114
+
115
+ ## Observability
116
+
117
+ TensorBuzz should record enough metadata to debug rollout without leaking secrets:
118
+
119
+ - selected Docker server id/name;
120
+ - transport mode: `direct` or `socketduct`;
121
+ - Socketduct session id prefix/hash, not full identifier if considered sensitive;
122
+ - connection phase: auth, session_create, http_request, stream, resume;
123
+ - retry/resume counts and durations;
124
+ - Docker endpoint path/method, not request body payload;
125
+ - error code (`AUTH_FAILED`, `TARGET_UNAUTHORIZED`, `SESSION_EXPIRED`, `ECONNRESET`, etc.).
126
+
127
+ ## Test strategy
128
+
129
+ ### Socketduct repo
130
+
131
+ - Unit tests for socket duck typing and state transitions.
132
+ - Integration tests for HTTP over `SocketductHttpAgent` and strict HTTPS/mTLS over `SocketductHttpsAgent` against fake Docker API servers.
133
+ - Process-level e2e tests with relay startup and allowed/denied target policies.
134
+ - Streaming and upgrade-style tests that keep a `Duplex` alive after headers.
135
+
136
+ ### docker-quack repo
137
+
138
+ - Transport abstraction tests with a fake socket factory/agent.
139
+ - Buffered request tests (`/_ping`, `/version`).
140
+ - Streaming response tests (`/events`, logs-style responses).
141
+ - Hijack/upgrade conformance tests using a fake Docker daemon.
142
+
143
+ ### TensorBuzz repo
144
+
145
+ - Configuration parsing/model tests for Socketduct rollout modes.
146
+ - Build container handler tests that choose direct vs Socketduct transport.
147
+ - Gateway lifecycle/provisioning tests for Docker server setup.
148
+ - Cross-repo smoke test using local Socketduct relay + fake Docker server + docker-quack.
149
+
150
+ ## Rollback
151
+
152
+ Every rollout step must be reversible:
153
+
154
+ 1. Set Docker server rollout mode to `off`.
155
+ 2. Keep direct Docker settings intact.
156
+ 3. Stop/remove the Socketduct sidecar after traffic drains.
157
+ 4. Rotate the Socketduct token if it may have been exposed during debugging.
158
+
159
+ ## Open decisions for implementation tasks
160
+
161
+ - Whether Socketduct should ship its own Unix socket shim or document a small external shim first.
162
+ - Whether TensorBuzz stores raw encrypted token values or references an existing secret store path.
163
+ - Whether `docker-quack` should expose `agent`, `createConnection`, or both.
164
+ - Whether Socketduct session ids should be logged as prefixes or stable hashes.