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,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.
|