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
package/docs/protocol.md
ADDED
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
# Socketduct wire protocol
|
|
2
|
+
|
|
3
|
+
Socketduct has two separately selected wire protocols. Protocol v3 is the
|
|
4
|
+
multiplexed transport. Protocol v2 remains available unchanged for migration
|
|
5
|
+
and rollback. A listener is configured for one version; there is no ambiguous
|
|
6
|
+
fallback after bytes have been sent.
|
|
7
|
+
|
|
8
|
+
## Multiplexed protocol version 3
|
|
9
|
+
|
|
10
|
+
V3 carries many independent virtual byte streams on one authenticated TCP or
|
|
11
|
+
TLS connection. The physical connection performs TLS and bearer authentication
|
|
12
|
+
once. Stream ID `0` is reserved for connection control; client-created stream
|
|
13
|
+
IDs are monotonically allocated odd uint32 values and are never reused during a
|
|
14
|
+
connection identity's lifetime.
|
|
15
|
+
|
|
16
|
+
### Binary frame
|
|
17
|
+
|
|
18
|
+
Every frame has a 24-byte big-endian header followed by its payload:
|
|
19
|
+
|
|
20
|
+
```text
|
|
21
|
+
offset size field
|
|
22
|
+
0 4 ASCII magic "SDV3"
|
|
23
|
+
4 1 version (3)
|
|
24
|
+
5 1 frame type
|
|
25
|
+
6 2 flags
|
|
26
|
+
8 4 stream ID
|
|
27
|
+
12 8 sequence (restricted to JavaScript safe integers)
|
|
28
|
+
20 4 payload length
|
|
29
|
+
24 N payload
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Unknown types, bad magic/version, unsafe sequences, stream/control ID
|
|
33
|
+
confusion, oversized payloads, non-object control JSON, gaps, overlaps and
|
|
34
|
+
invalid acknowledgements fail loudly. Control JSON and DATA frames are bounded
|
|
35
|
+
independently. Ordinary v3 relay connections retain raw DATA. Native reverse
|
|
36
|
+
connections negotiate zstd and reserve DATA flag `0x0001`; no ordinary-v3 peer
|
|
37
|
+
emits it and ordinary handlers reject it as unnegotiated.
|
|
38
|
+
|
|
39
|
+
### Connection control (stream 0)
|
|
40
|
+
|
|
41
|
+
- `AUTH` / `AUTH_OK` / `AUTH_ERROR`: negotiate exactly version 3 and
|
|
42
|
+
authenticate a stable connection identity. No stream operation is accepted
|
|
43
|
+
before `AUTH_OK`.
|
|
44
|
+
- `PING` / `PONG`: connection heartbeat and readiness.
|
|
45
|
+
- `RESUME` / `RESUMED`: bounded pages of stream manifests. Each entry contains
|
|
46
|
+
stream ID, stable identifier and both directional acknowledgement positions.
|
|
47
|
+
- `GOAWAY`: terminal physical-connection shutdown. Authentication,
|
|
48
|
+
authorization and TLS failures are terminal and are not retried forever.
|
|
49
|
+
- `GATEWAY_REGISTER` / `GATEWAY_REGISTERED`: reverse-only authenticated stable
|
|
50
|
+
gateway registration and generation fencing.
|
|
51
|
+
- `GATEWAY_SELECT` / `GATEWAY_SELECTED`: reverse-only binding of a client
|
|
52
|
+
physical member to one currently registered gateway before readiness.
|
|
53
|
+
|
|
54
|
+
### Stream control (nonzero stream ID)
|
|
55
|
+
|
|
56
|
+
- `OPEN` / `OPENED`: stable opaque identifier, human-readable name, exact
|
|
57
|
+
target and initial receive window. The relay authorizes the target for every
|
|
58
|
+
OPEN before creating its one local target socket.
|
|
59
|
+
- `DATA`: raw bytes with a cumulative end sequence.
|
|
60
|
+
- `ACK`: cumulative committed/delivered sequence; acknowledged replay bytes are
|
|
61
|
+
removed from the spool and never replayed.
|
|
62
|
+
- `WINDOW`: explicit additional receive credit when needed.
|
|
63
|
+
- `END`: directional half-close.
|
|
64
|
+
- `CLOSE`: clean terminal close. The receiver echoes `CLOSE` after delivery;
|
|
65
|
+
the sender retains the stream until that terminal acknowledgement arrives,
|
|
66
|
+
so cleanup cannot race the last bytes or leak relay stream capacity.
|
|
67
|
+
- `RESET`: stream-local error/cancellation. Resetting one stream never closes a
|
|
68
|
+
sibling or the shared physical transport.
|
|
69
|
+
|
|
70
|
+
Each direction has an independent per-stream window. Send/replay bytes are
|
|
71
|
+
bounded per stream and in aggregate per connection. Control frames have
|
|
72
|
+
priority; DATA frames use round-robin scheduling with bounded frame sizes, so a
|
|
73
|
+
large upload cannot monopolize the physical connection.
|
|
74
|
+
|
|
75
|
+
Flow-control credit and scheduler position belong to one physical generation.
|
|
76
|
+
After both peers reconcile cumulative durable acknowledgements in RESUME, each
|
|
77
|
+
surviving stream begins the replacement generation with a fresh negotiated
|
|
78
|
+
window and replay is rebuilt strictly above the reconciled ACK. Credit consumed
|
|
79
|
+
by a failed transport is never carried forward.
|
|
80
|
+
|
|
81
|
+
Relay retention is bounded independently per stream, per connection identity,
|
|
82
|
+
and process-wide. The process-wide reservation is owned by the relay runtime
|
|
83
|
+
and is released idempotently on acknowledgement, reset, close, error or expiry.
|
|
84
|
+
|
|
85
|
+
### Physical reconnect and resume
|
|
86
|
+
|
|
87
|
+
One `MultiplexedSocketductConnection` owns one connect/auth promise and one
|
|
88
|
+
reconnect timer. Concurrent first opens share that promise. After a transport
|
|
89
|
+
drop, active streams remain represented by their stable identifiers and spool
|
|
90
|
+
state while exactly one new physical generation authenticates. The client sends
|
|
91
|
+
the paginated resume manifest; the relay answers per stream and replays only
|
|
92
|
+
bytes above the peer's acknowledgement. A missing target or invalid resume
|
|
93
|
+
resets only that stream with a typed session-loss error. Streams locally closed
|
|
94
|
+
during the outage are omitted and the relay closes their retained targets.
|
|
95
|
+
The connect/authentication deadline remains active until `AUTH_OK`; a peer that
|
|
96
|
+
accepts TCP/TLS and stays silent cannot retain the shared open promise.
|
|
97
|
+
Replacement objects advertise only the durable receive acknowledgement because
|
|
98
|
+
they do not own unread bytes buffered by the previous `VirtualSocket`; a
|
|
99
|
+
physical reconnect on the same object may acknowledge its still-owned readable
|
|
100
|
+
buffer. A durable `OPEN` without `OPENED` is resumed and reconciled by the
|
|
101
|
+
relay's per-stream result rather than being sent again.
|
|
102
|
+
|
|
103
|
+
### Native reverse-gateway extension
|
|
104
|
+
|
|
105
|
+
Reverse mode reuses the v3 physical transport and stream frames directly. A
|
|
106
|
+
home gateway registers outbound; a Server3 client selects it. Client streams
|
|
107
|
+
are odd and the hub maps them to even gateway-side IDs. The hub authorizes the
|
|
108
|
+
principal/gateway/target tuple and the gateway separately authorizes its local
|
|
109
|
+
target. Registration reconnect uses a higher generation, but any established
|
|
110
|
+
stream is reset on gateway loss and never replayed.
|
|
111
|
+
|
|
112
|
+
Stream IDs must increase monotonically on each physical connection. END and
|
|
113
|
+
CLOSE positions must match the sender's accepted raw-byte sequence; a
|
|
114
|
+
gateway-originated CLOSE is retained until the client acknowledges it with
|
|
115
|
+
CLOSE. RESET is immediately terminal. Repeats, data after END, and stale ACKs
|
|
116
|
+
are protocol violations.
|
|
117
|
+
|
|
118
|
+
Reverse peers must negotiate `zstd` before selection/registration completes.
|
|
119
|
+
A compressed DATA payload is `uint32_be(uncompressedLength)` followed by one
|
|
120
|
+
independent zstd frame and carries flag `0x0001`. Raw DATA carries flag zero.
|
|
121
|
+
Sequences, acknowledgements, windows, and all buffer limits count uncompressed
|
|
122
|
+
bytes. Invalid metadata, unavailable codecs, decompression bombs, or corrupt
|
|
123
|
+
payloads terminate the physical connection. TLS certificate verification is
|
|
124
|
+
mandatory for every reverse client and gateway transport. The complete role
|
|
125
|
+
contract is in [Native reverse gateway](reverse-gateway.md).
|
|
126
|
+
|
|
127
|
+
## Legacy protocol version 2
|
|
128
|
+
|
|
129
|
+
V2 starts every connection with the length-prefixed JSON envelope
|
|
130
|
+
described below. That envelope remains the bootstrap format, but authenticated
|
|
131
|
+
sessions require compact framing version 2 for all stream records.
|
|
132
|
+
|
|
133
|
+
## Compact framing version 2
|
|
134
|
+
|
|
135
|
+
The client must add `"framing":[2]` to the length-prefixed `session.create` or
|
|
136
|
+
`session.resume` header. The relay must reject an absent offer or `"framing":[1]`
|
|
137
|
+
with `PROTOCOL_VIOLATION` before authentication and before creating or resuming
|
|
138
|
+
a session. Every successful `session.created` and `session.resumed` response
|
|
139
|
+
contains `"framing":2`; the client treats any response without that exact value
|
|
140
|
+
as a failed handshake. The offer and selection are connection-local and are
|
|
141
|
+
repeated on resume. There is no older framing fallback.
|
|
142
|
+
|
|
143
|
+
After selection, every `stream.data` and `stream.ack` uses a compact record.
|
|
144
|
+
Bootstrap, error, lifecycle (including `session.close`), and other control
|
|
145
|
+
messages retain the length-prefixed JSON envelope because compact v2 has no
|
|
146
|
+
equivalent record for them. A compact record starts with a byte whose high bit
|
|
147
|
+
is set, while a valid JSON-envelope header length
|
|
148
|
+
starts with a zero byte under the 64 KiB header limit, so controls and compact
|
|
149
|
+
records can be mixed in one TCP byte stream.
|
|
150
|
+
|
|
151
|
+
After a successful V2 handshake, both peers reject any length-prefixed
|
|
152
|
+
`stream.data` or `stream.ack` input as a protocol violation. Parsers use the
|
|
153
|
+
decoded `header.compact` marker to enforce this at the authenticated session
|
|
154
|
+
boundary while continuing to accept length-prefixed control, lifecycle, and
|
|
155
|
+
error messages.
|
|
156
|
+
|
|
157
|
+
```text
|
|
158
|
+
0x80 varint(endSequence) varint(payloadLength) rawPayload
|
|
159
|
+
0x81 varint(endSequence) varint(uncompressedLength) varint(payloadLength) zstdPayload
|
|
160
|
+
0x82 varint(ackSequence)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Integers are canonical unsigned LEB128 and must be JavaScript safe integers.
|
|
164
|
+
Overlong encodings, overflow, truncated records, and payload lengths above the
|
|
165
|
+
configured frame limit are protocol violations. Direction and session are
|
|
166
|
+
implicit in the authenticated connection: data travels toward the receiving
|
|
167
|
+
endpoint and an ACK acknowledges the opposite data direction. Decoders
|
|
168
|
+
normalize these records into the same internal headers used by the envelope decoder, so zstd
|
|
169
|
+
negotiation and expansion checks, cumulative uncompressed byte sequences,
|
|
170
|
+
ACKs, replay retention, and raw spool storage have identical semantics.
|
|
171
|
+
|
|
172
|
+
## No-delay coalescing
|
|
173
|
+
|
|
174
|
+
A sole application write, or a sole chunk read from the relay's target socket,
|
|
175
|
+
begins spool/compression/send work immediately.
|
|
176
|
+
Socketduct does not use a timer, debounce, immediate, or microtask to wait for
|
|
177
|
+
more data. If Node has already queued several application writes behind
|
|
178
|
+
pre-existing async spool, compression, send, or drain work, the edge may merge
|
|
179
|
+
those queued original bytes in order. Likewise, the relay may merge adjacent
|
|
180
|
+
raw target chunks only when they are already waiting behind its per-session
|
|
181
|
+
compression/send/drain queue. The first target chunk entering an idle queue is
|
|
182
|
+
not held. Each merged record is bounded by the configured maximum decompressed
|
|
183
|
+
size. Close, error, replay, ACK retention, and spool ordering use the same
|
|
184
|
+
serialized pipelines as unmerged writes; cumulative sequences always count
|
|
185
|
+
the exact uncompressed bytes.
|
|
186
|
+
|
|
187
|
+
## Requirements
|
|
188
|
+
|
|
189
|
+
The protocol must support:
|
|
190
|
+
|
|
191
|
+
- authentication before session operations
|
|
192
|
+
- session creation with `name`, `identifier`, and authorized `target`
|
|
193
|
+
- session resume by `identifier`
|
|
194
|
+
- ordered byte-stream transfer
|
|
195
|
+
- acknowledgements
|
|
196
|
+
- reconnection after short interruptions
|
|
197
|
+
- replay from temporary spool files
|
|
198
|
+
- clear failures when resume is impossible
|
|
199
|
+
|
|
200
|
+
## Framing
|
|
201
|
+
|
|
202
|
+
Use a length-prefixed frame format rather than raw JSON lines.
|
|
203
|
+
|
|
204
|
+
A practical MVP can use:
|
|
205
|
+
|
|
206
|
+
```text
|
|
207
|
+
uint32_be headerLength
|
|
208
|
+
json header
|
|
209
|
+
bytes payload
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Header fields:
|
|
213
|
+
|
|
214
|
+
```json
|
|
215
|
+
{
|
|
216
|
+
"version": 1,
|
|
217
|
+
"type": "stream.data",
|
|
218
|
+
"session": "018ff4c1-4cd9-7a25-b610-50cf8e10a4e7",
|
|
219
|
+
"sequence": 12345,
|
|
220
|
+
"payloadLength": 4096
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Later, this can move to a binary header if profiling shows JSON headers matter.
|
|
225
|
+
|
|
226
|
+
## Authentication flow
|
|
227
|
+
|
|
228
|
+
```text
|
|
229
|
+
client -> server: auth.login
|
|
230
|
+
server -> client: auth.ok | auth.error
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Request:
|
|
234
|
+
|
|
235
|
+
```json
|
|
236
|
+
{
|
|
237
|
+
"type": "auth.login",
|
|
238
|
+
"username": "kasper",
|
|
239
|
+
"password": "<redacted>",
|
|
240
|
+
"clientName": "office-edge"
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Response:
|
|
245
|
+
|
|
246
|
+
```json
|
|
247
|
+
{
|
|
248
|
+
"type": "auth.ok",
|
|
249
|
+
"token": "<opaque-session-token>",
|
|
250
|
+
"expiresAt": "2026-07-07T12:00:00.000Z"
|
|
251
|
+
}
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
After login, reconnects should prefer token auth:
|
|
255
|
+
|
|
256
|
+
```json
|
|
257
|
+
{
|
|
258
|
+
"type": "auth.resume",
|
|
259
|
+
"token": "<opaque-session-token>"
|
|
260
|
+
}
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
## Session create
|
|
264
|
+
|
|
265
|
+
```text
|
|
266
|
+
client -> server: session.create
|
|
267
|
+
server -> client: session.created | error
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Request:
|
|
271
|
+
|
|
272
|
+
```json
|
|
273
|
+
{
|
|
274
|
+
"type": "session.create",
|
|
275
|
+
"name": "work-ssh",
|
|
276
|
+
"identifier": null,
|
|
277
|
+
"target": {
|
|
278
|
+
"host": "10.0.0.15",
|
|
279
|
+
"port": 22
|
|
280
|
+
},
|
|
281
|
+
"framing": [2]
|
|
282
|
+
}
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
Response:
|
|
286
|
+
|
|
287
|
+
```json
|
|
288
|
+
{
|
|
289
|
+
"type": "session.created",
|
|
290
|
+
"identifier": "018ff4c1-4cd9-7a25-b610-50cf8e10a4e7",
|
|
291
|
+
"name": "work-ssh",
|
|
292
|
+
"state": "active",
|
|
293
|
+
"target": {
|
|
294
|
+
"host": "10.0.0.15",
|
|
295
|
+
"port": 22
|
|
296
|
+
},
|
|
297
|
+
"framing": 2
|
|
298
|
+
}
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
The client may propose an identifier, but the server must verify that it is valid, owned by the user, and not already active for another incompatible session. The server must also validate `target` before creating the session. The relay is not configured with one static upstream target; it applies an explicit allowlist policy such as `authorizeTarget({principal, target})` for each create request.
|
|
302
|
+
|
|
303
|
+
After authentication and authorization, the server stores the session in `creating` but withholds `session.created` until target TCP connect succeeds. Initial connection refusal is not a wire error: the relay retries at a bounded exponential rate while the authenticated tunnel remains open. Compact stream records received before readiness are a terminal `PROTOCOL_VIOLATION`. If the tunnel closes before the response is announced, the relay deletes the creating session immediately, so it cannot be resumed.
|
|
304
|
+
|
|
305
|
+
`session.created` is the safety boundary. Before it, the relay has accepted no application bytes and may retry target acquisition. After it, target failure or write uncertainty is terminal and Socketduct does not reconnect the target or replay the application operation.
|
|
306
|
+
|
|
307
|
+
## Session resume
|
|
308
|
+
|
|
309
|
+
```text
|
|
310
|
+
client -> server: session.resume
|
|
311
|
+
server -> client: session.resumed | error
|
|
312
|
+
client -> server: spool.manifest
|
|
313
|
+
client -> server: stream.data frames for unacknowledged chunks
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
Request:
|
|
317
|
+
|
|
318
|
+
```json
|
|
319
|
+
{
|
|
320
|
+
"type": "session.resume",
|
|
321
|
+
"identifier": "018ff4c1-4cd9-7a25-b610-50cf8e10a4e7",
|
|
322
|
+
"name": "work-ssh",
|
|
323
|
+
"clientLastSequence": 180224,
|
|
324
|
+
"clientLastAck": 171520,
|
|
325
|
+
"framing": [2]
|
|
326
|
+
}
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
Response:
|
|
330
|
+
|
|
331
|
+
```json
|
|
332
|
+
{
|
|
333
|
+
"type": "session.resumed",
|
|
334
|
+
"identifier": "018ff4c1-4cd9-7a25-b610-50cf8e10a4e7",
|
|
335
|
+
"serverLastSequence": 99120,
|
|
336
|
+
"serverLastAck": 171520,
|
|
337
|
+
"replayFromSequence": 171521,
|
|
338
|
+
"target": {
|
|
339
|
+
"host": "10.0.0.15",
|
|
340
|
+
"port": 22
|
|
341
|
+
},
|
|
342
|
+
"framing": 2
|
|
343
|
+
}
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
The server tells the client which sequence to replay from. The edge then sends the relevant spooled data.
|
|
347
|
+
|
|
348
|
+
## Stream data
|
|
349
|
+
|
|
350
|
+
```json
|
|
351
|
+
{
|
|
352
|
+
"type": "stream.data",
|
|
353
|
+
"session": "018ff4c1-4cd9-7a25-b610-50cf8e10a4e7",
|
|
354
|
+
"direction": "client-to-target",
|
|
355
|
+
"sequence": 171521,
|
|
356
|
+
"payloadLength": 4096
|
|
357
|
+
}
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
The payload bytes follow the header.
|
|
361
|
+
|
|
362
|
+
## Stream data compression
|
|
363
|
+
|
|
364
|
+
`stream.data` payloads may be compressed. Nothing else is: authentication,
|
|
365
|
+
session and other control frames are always sent raw.
|
|
366
|
+
|
|
367
|
+
### Negotiation
|
|
368
|
+
|
|
369
|
+
Compression is opt-in per connection and rides on the existing handshake frames,
|
|
370
|
+
because unknown header fields are ignored by both peers while unknown frame
|
|
371
|
+
types are a protocol violation.
|
|
372
|
+
|
|
373
|
+
The client advertises the codecs it can use on `session.create` and
|
|
374
|
+
`session.resume`:
|
|
375
|
+
|
|
376
|
+
```json
|
|
377
|
+
{
|
|
378
|
+
"type": "session.create",
|
|
379
|
+
"token": "<redacted>",
|
|
380
|
+
"name": "office-edge",
|
|
381
|
+
"target": {"host": "10.0.0.15", "port": 22},
|
|
382
|
+
"compression": ["zstd"]
|
|
383
|
+
}
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
The relay answers on `session.created` / `session.resumed` with the single codec
|
|
387
|
+
it selected, or omits the field entirely:
|
|
388
|
+
|
|
389
|
+
```json
|
|
390
|
+
{
|
|
391
|
+
"type": "session.created",
|
|
392
|
+
"identifier": "018ff4c1-4cd9-7a25-b610-50cf8e10a4e7",
|
|
393
|
+
"compression": "zstd"
|
|
394
|
+
}
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
If compression is omitted, stream payloads are uncompressed but still use
|
|
398
|
+
compact framing v2. Compression negotiation is redone on every reconnect, so a
|
|
399
|
+
resumed session may land on a different codec (or none) than it started with.
|
|
400
|
+
|
|
401
|
+
### Frame metadata
|
|
402
|
+
|
|
403
|
+
A compressed compact `stream.data` record carries two extra normalized header
|
|
404
|
+
fields. Both are absent on an uncompressed compact record.
|
|
405
|
+
|
|
406
|
+
```json
|
|
407
|
+
{
|
|
408
|
+
"type": "stream.data",
|
|
409
|
+
"direction": "target-to-client",
|
|
410
|
+
"sequence": 171521,
|
|
411
|
+
"encoding": "zstd",
|
|
412
|
+
"dataLength": 4096
|
|
413
|
+
}
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
- `encoding` — the negotiated codec. Absent means the payload is uncompressed.
|
|
417
|
+
- `dataLength` — the **uncompressed** byte length. Required whenever `encoding`
|
|
418
|
+
is present.
|
|
419
|
+
|
|
420
|
+
Each frame is an independent zstd frame with no shared dictionary or window, so
|
|
421
|
+
any frame can be decoded on its own. That is what keeps reconnect, resume and
|
|
422
|
+
replay correct: spools and relay replay buffers store **raw** chunks, and a
|
|
423
|
+
replayed chunk is re-encoded under whatever codec the current connection
|
|
424
|
+
negotiated.
|
|
425
|
+
|
|
426
|
+
`sequence`, `ackSequence`, spool offsets, byte limits and backpressure are all
|
|
427
|
+
counted in **uncompressed** bytes. `dataLength`, not the wire payload length, is
|
|
428
|
+
what the relay's contiguity check (`sequence == previous + length`) uses.
|
|
429
|
+
|
|
430
|
+
### Validation
|
|
431
|
+
|
|
432
|
+
A receiver fails closed. Any of the following is a `PROTOCOL_VIOLATION` that
|
|
433
|
+
ends the connection; a frame that cannot be decoded is never written to the
|
|
434
|
+
target, and there is no fallback to treating it as raw:
|
|
435
|
+
|
|
436
|
+
- `encoding` present but not the codec negotiated for this connection.
|
|
437
|
+
- `dataLength` missing, not a positive safe integer, or above the configured
|
|
438
|
+
maximum.
|
|
439
|
+
- `dataLength` present without an `encoding`.
|
|
440
|
+
- An uncompressed payload above the configured maximum `stream.data` size.
|
|
441
|
+
- An expansion ratio (`dataLength` / compressed size) above the configured
|
|
442
|
+
maximum.
|
|
443
|
+
- A payload that fails to decompress, or whose decompressed size does not
|
|
444
|
+
exactly equal the advertised `dataLength`.
|
|
445
|
+
|
|
446
|
+
Decompression is bounded by the advertised `dataLength`, so a compression bomb
|
|
447
|
+
can never produce more bytes than the sender promised.
|
|
448
|
+
|
|
449
|
+
### Send policy
|
|
450
|
+
|
|
451
|
+
Compression is adaptive and entirely send-side; a receiver needs no knowledge of
|
|
452
|
+
it, because every frame declares its own encoding.
|
|
453
|
+
|
|
454
|
+
- Payloads below `SOCKETDUCT_MIN_COMPRESS_BYTES` (default 256) are sent raw.
|
|
455
|
+
- The compressed form is kept only if it actually saves bytes, so a frame never
|
|
456
|
+
grows.
|
|
457
|
+
- Streams that do not compress (TLS, media, already-compressed data) stop being
|
|
458
|
+
compressed after a few probes and are re-probed periodically.
|
|
459
|
+
|
|
460
|
+
### Runtime support
|
|
461
|
+
|
|
462
|
+
zstd is provided by `node:zlib`, which only exposes it from Node 22.15 onward,
|
|
463
|
+
while `engines` accepts Node >=22.0. Support is therefore feature-detected at
|
|
464
|
+
runtime: a peer whose Node lacks zstd advertises no codec and the tunnel runs
|
|
465
|
+
raw. No runtime dependency is added.
|
|
466
|
+
|
|
467
|
+
## Acknowledgements
|
|
468
|
+
|
|
469
|
+
```json
|
|
470
|
+
{
|
|
471
|
+
"type": "stream.ack",
|
|
472
|
+
"session": "018ff4c1-4cd9-7a25-b610-50cf8e10a4e7",
|
|
473
|
+
"direction": "client-to-target",
|
|
474
|
+
"ackSequence": 175616
|
|
475
|
+
}
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
Acked chunks can be truncated from the spool file.
|
|
479
|
+
|
|
480
|
+
## Session close
|
|
481
|
+
|
|
482
|
+
`session.close` remains an envelope control frame. A relay response confirming
|
|
483
|
+
that it processed a client-requested logical close includes
|
|
484
|
+
`"acknowledged": true`; a target-originated terminal close omits that field.
|
|
485
|
+
Clients must not treat an unacknowledged terminal notification already in
|
|
486
|
+
flight as confirmation of a later close request. A transport EOF is not a
|
|
487
|
+
logical close and retains the announced session for normal resume.
|
|
488
|
+
If the application requests a logical close while the relay transport is
|
|
489
|
+
interrupted, the client first reconnects, authenticates, and receives
|
|
490
|
+
`session.resumed`; only then does it send `session.close`. No application data
|
|
491
|
+
is replayed by this close-only reconnect.
|
|
492
|
+
|
|
493
|
+
## Backpressure
|
|
494
|
+
|
|
495
|
+
The protocol should allow explicit pause/resume:
|
|
496
|
+
|
|
497
|
+
```json
|
|
498
|
+
{
|
|
499
|
+
"type": "stream.pause",
|
|
500
|
+
"session": "018ff4c1-4cd9-7a25-b610-50cf8e10a4e7",
|
|
501
|
+
"direction": "client-to-target",
|
|
502
|
+
"reason": "remote-buffer-full"
|
|
503
|
+
}
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
```json
|
|
507
|
+
{
|
|
508
|
+
"type": "stream.resume",
|
|
509
|
+
"session": "018ff4c1-4cd9-7a25-b610-50cf8e10a4e7",
|
|
510
|
+
"direction": "client-to-target"
|
|
511
|
+
}
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
## Error handling
|
|
515
|
+
|
|
516
|
+
Errors should be explicit and machine-readable:
|
|
517
|
+
|
|
518
|
+
```json
|
|
519
|
+
{
|
|
520
|
+
"type": "error",
|
|
521
|
+
"code": "SESSION_NOT_FOUND",
|
|
522
|
+
"message": "Session does not exist or is not owned by this user.",
|
|
523
|
+
"retryable": false
|
|
524
|
+
}
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
Useful error codes:
|
|
528
|
+
|
|
529
|
+
- `AUTH_REQUIRED`
|
|
530
|
+
- `AUTH_FAILED`
|
|
531
|
+
- `SESSION_NOT_FOUND`
|
|
532
|
+
- `SESSION_ALREADY_ACTIVE`
|
|
533
|
+
- `SESSION_EXPIRED`
|
|
534
|
+
- `REPLAY_TOO_OLD`
|
|
535
|
+
- `SPOOL_TOO_LARGE`
|
|
536
|
+
- `TARGET_CLOSED`
|
|
537
|
+
- `TARGET_BUFFER_OVERFLOW`
|
|
538
|
+
- `TARGET_UNAUTHORIZED`
|
|
539
|
+
- `PROTOCOL_VIOLATION`
|
|
540
|
+
- `HANDSHAKE_TIMEOUT`
|
|
541
|
+
|
|
542
|
+
`TARGET_CLOSED`, `TARGET_BUFFER_OVERFLOW`, spool overflow, authorization errors, and protocol errors are terminal (`retryable:false`). Initial target refusal does not emit `TARGET_UNAVAILABLE` or any other error frame.
|
|
543
|
+
|
|
544
|
+
## Open protocol decisions
|
|
545
|
+
|
|
546
|
+
- Use TLS-over-TCP, WebSocket, HTTP/2, or QUIC as the first edge-relay transport?
|
|
547
|
+
- Should session identifiers be client-generated UUIDv7 or server-generated random IDs?
|
|
548
|
+
- Should password auth be part of the tunnel protocol, or should the first version use pre-created bearer tokens?
|
|
549
|
+
- How much target-side state should the relay keep after an edge disconnect?
|
|
550
|
+
|
|
551
|
+
## Transport security
|
|
552
|
+
|
|
553
|
+
The relay supports TLS via Node.js built-in `node:tls`. When configured with `SOCKETDUCT_TLS_CERT_FILE` and `SOCKETDUCT_TLS_KEY_FILE`, the protocol runs over a TLS socket. The mixed wire format (length-prefixed control envelopes and compact V2 stream records) is unchanged regardless of whether the transport is plain TCP or TLS.
|
|
554
|
+
|
|
555
|
+
- Server certificate verification is enabled by default on the client (`rejectUnauthorized: true`).
|
|
556
|
+
- Optional mutual TLS: when `SOCKETDUCT_TLS_CLIENT_CA_FILE` is set, the relay requests and verifies client certificates.
|
|
557
|
+
- The relay fails fast at startup if certificate files are missing or unreadable.
|
|
558
|
+
- Binding to a non-loopback address without TLS is refused unless `SOCKETDUCT_ALLOW_INSECURE_PUBLIC=1` or `true`.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Releasing
|
|
2
|
+
|
|
3
|
+
Socketduct publishes patch releases from a clean, current `master` checkout:
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm run release:patch
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
`release-patch` owns the version bump, lockfile update, annotated tag, atomic
|
|
10
|
+
Git push, npm publication, and registry verification. Do not run `npm version`
|
|
11
|
+
or create release tags manually for ordinary patch releases.
|
|
12
|
+
|
|
13
|
+
Confirm CI is green and `npm run check` passes before releasing. If publication
|
|
14
|
+
fails after the commit and tag are pushed, resume the exact release with:
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
npx release-patch --resume
|
|
18
|
+
```
|