socketduct 0.1.16 → 0.1.18

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/.env.example CHANGED
@@ -115,6 +115,9 @@ SOCKETDUCT_TARGET_ALLOWLIST=docker-socket-shim:2375
115
115
  # SOCKETDUCT_REVERSE_GATEWAY_BINDINGS=o2:home-o2,zyxel:home-zyxel,starlink:home-starlink
116
116
  # Authorize exact client username, gateway ID, and home-local target tuples.
117
117
  # SOCKETDUCT_REVERSE_ROUTES=tensorbuzz:o2:docker-server:2375,tensorbuzz:zyxel:docker-server:2375,tensorbuzz:starlink:docker-server:2375
118
+ # Or bind one client physical connection to a route class and resolve each
119
+ # requested target to one exact registered gateway. Ambiguous rows fail closed.
120
+ # SOCKETDUCT_REVERSE_ROUTE_CLASS_ROUTES=tensorbuzz:starlink:server-a-starlink:docker-a:2375,tensorbuzz:starlink:server-b-starlink:docker-b:2375
118
121
 
119
122
  # Home member: change SOCKETDUCT_MODE to reverse-gateway. Run one process in
120
123
  # each router-pinned namespace and use distinct stable identity/credential data.
@@ -0,0 +1 @@
1
+ Socketduct reverse clients can now share one authenticated physical connection per route class while the hub securely resolves each target stream and isolates backpressured server-local gateways.
@@ -0,0 +1 @@
1
+ Native reverse-gateway streams now keep their app and target sockets alive across transient hub transport loss, preserve unread flow-control and terminal-close state, and replay durably spooled bytes exactly once after paired resume.
@@ -78,6 +78,13 @@ failure retains the spool reservation and fails that member rather than
78
78
  advertising capacity that still exists on disk. These process bounds supplement,
79
79
  rather than replace, member and stream limits.
80
80
 
81
+ For native reverse mode, the set can instead receive `routeClasses`. It creates
82
+ one `ReverseGatewayConnection` and one stable derived connection identity per
83
+ `{process connectionId, routeClass}`. The hub chooses an exact server-local
84
+ gateway independently for each target `OPEN`, so adding target gateways does not
85
+ multiply client physical connections. Allocation metadata is `routeClass`;
86
+ legacy exact-gateway sets keep `gatewayId` and their existing identity derivation.
87
+
81
88
  Replacement recovery is registered before set startup and addresses the
82
89
  original configured gateway ID directly. The set derives the same member
83
90
  identity/spool namespace and delegates recovery to that member before any
@@ -93,14 +100,27 @@ rollback. A relay listener selects v2 or v3 explicitly.
93
100
  The reverse hub is a rendezvous and stream-ID translation boundary on Server3.
94
101
  Authenticated home gateway processes connect outward, register stable IDs, and
95
102
  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.
103
+ select either one exact ID or a route class and create odd-ID streams. The class
104
+ stays bound to the client physical connection while the exact gateway is
105
+ resolved from the requested target for each `OPEN`. The hub maps frames without
106
+ opening the target or nesting another Socketduct transport. Each home gateway
107
+ applies the final local allowlist before connecting to its private Docker shim.
108
+
109
+ Gateway and client registrations reconnect with generation fencing. The hub
110
+ retains each established bridge for a bounded window while the live gateway
111
+ keeps its original target socket and both endpoints durably retain
112
+ unacknowledged send chunks. Replacement physical generations exchange paired
113
+ resume manifests; the hub reconciles receiver-committed sequence positions
114
+ before either side replays. This resumes the same byte stream without reopening
115
+ the target or issuing a second application request. Reverse process replacement
116
+ remains terminal because the original target/app socket objects cannot move to
117
+ a new process.
118
+
119
+ The hub serializes output independently for each physical destination with a
120
+ bounded queue, so one backpressured target gateway cannot stop client frames
121
+ bound for a healthy sibling gateway. Exceeding that destination bound closes
122
+ only the unhealthy physical owner and fails its mapped streams with the causal
123
+ limit error.
104
124
  See [Native reverse gateway](reverse-gateway.md).
105
125
 
106
126
  ### Edge
package/docs/protocol.md CHANGED
@@ -43,13 +43,17 @@ emits it and ordinary handlers reject it as unnegotiated.
43
43
  before `AUTH_OK`.
44
44
  - `PING` / `PONG`: connection heartbeat and readiness.
45
45
  - `RESUME` / `RESUMED`: bounded pages of stream manifests. Each entry contains
46
- stream ID, stable identifier and both directional acknowledgement positions.
46
+ stream ID, stable identifier and both directional sequence/acknowledgement
47
+ positions. Ordinary v3 uses one client manifest; native reverse resume pairs
48
+ independently authenticated client and gateway manifests at the hub.
47
49
  - `GOAWAY`: terminal physical-connection shutdown. Authentication,
48
50
  authorization and TLS failures are terminal and are not retried forever.
49
51
  - `GATEWAY_REGISTER` / `GATEWAY_REGISTERED`: reverse-only authenticated stable
50
- gateway registration and generation fencing.
52
+ gateway registration and generation fencing. The response declares whether
53
+ retained logical streams require a resume manifest.
51
54
  - `GATEWAY_SELECT` / `GATEWAY_SELECTED`: reverse-only binding of a client
52
- physical member to one currently registered gateway before readiness.
55
+ physical member to one exact gateway or route class before readiness. The
56
+ response likewise declares retained resume state.
53
57
 
54
58
  ### Stream control (nonzero stream ID)
55
59
 
@@ -72,11 +76,13 @@ bounded per stream and in aggregate per connection. Control frames have
72
76
  priority; DATA frames use round-robin scheduling with bounded frame sizes, so a
73
77
  large upload cannot monopolize the physical connection.
74
78
 
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.
79
+ Scheduler position belongs to one physical generation, while receive-side
80
+ flow-control debt belongs to the logical stream. After both peers reconcile in
81
+ RESUME, a sender retains its negotiated window minus bytes the receiver already
82
+ accepted but has not application-acknowledged. Those bytes are not replayed,
83
+ but remain spooled until the durable ACK advances; replay starts strictly above
84
+ the receiver's high-water sequence. A failed transport therefore cannot turn
85
+ unread `VirtualSocket` bytes back into fresh credit.
80
86
 
81
87
  Relay retention is bounded independently per stream, per connection identity,
82
88
  and process-wide. The process-wide reservation is owned by the relay runtime
@@ -103,17 +109,55 @@ relay's per-stream result rather than being sent again.
103
109
  ### Native reverse-gateway extension
104
110
 
105
111
  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.
112
+ home gateway registers outbound. A Server3 client selects exactly one legacy
113
+ `gatewayId` or one first-class `routeClass` in `GATEWAY_SELECT`; the response
114
+ echoes the same selector. Client streams are odd and the hub maps them to even
115
+ gateway-side IDs. For a class-bound connection, every `OPEN` repeats the bound
116
+ class and the hub resolves `{authenticated principal, routeClass, target}` to
117
+ exactly one current gateway. The client never supplies that gateway ID. The hub
118
+ then authorizes the exact principal/gateway/target tuple and the gateway
119
+ separately authorizes its local target. Zero, multiple, stale, disconnected, or
120
+ mismatched resolutions reset the stream without weakening sibling routes.
121
+ Registration reconnect uses a higher generation, but any established stream on
122
+ the lost gateway remains bound to its original client, gateway process, and
123
+ target socket during the bounded resume window. The hub also replaces the
124
+ corresponding client physical transport so neither endpoint can advance without
125
+ paired reconciliation.
126
+
127
+ On replacement, both reverse peers send paginated `RESUME` entries containing
128
+ the stable identifier, each direction's allocated sequence and durable ACK,
129
+ and directional END state. The hub verifies authenticated owner, stable stream
130
+ ID/identifier, monotonic bounds, and cross-peer positions. It preserves both
131
+ the receiver high-water sequence and its application-acknowledged boundary in
132
+ each per-stream `RESUMED` result, and only then admits replay. Senders replay
133
+ durable raw chunks strictly above the high-water sequence while retaining
134
+ unacknowledged chunks and charging their outstanding bytes to the new window.
135
+ A DATA frame that entered a failed physical socket but did not reach the
136
+ logical receiver is therefore replayed; a byte already buffered by the receiver
137
+ is not delivered twice. The target socket is never reopened and the application
138
+ request is never recreated as a new stream.
139
+
140
+ Reverse send replay is persisted before transmission on both the Server3 client
141
+ and outbound gateway. ACKs truncate those private temporary spools. Missing
142
+ targets, expired retention, identity or manifest mismatch, and configured
143
+ buffer/spool/output bounds produce one terminal stream error with the causal
144
+ code. Process-replacement recovery remains unsupported for reverse streams
145
+ because a replacement gateway process cannot inherit the original target TCP
146
+ socket.
147
+
148
+ Hub output has one ordered owner per physical destination. Frames for a
149
+ backpressured gateway remain in that gateway's bounded queue while frames for
150
+ other gateways continue independently; a destination that exceeds its output
151
+ bound is disconnected and its streams are reset without blocking healthy
152
+ destinations. Ordering within each destination and stream is preserved.
111
153
 
112
154
  Stream IDs must increase monotonically on each physical connection. END and
113
155
  CLOSE positions must match the sender's accepted raw-byte sequence; a
114
156
  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.
157
+ CLOSE, including across physical resume. A client that echoes CLOSE immediately
158
+ after resume uses the receiver-confirmed client-to-gateway position rather than
159
+ a stale pre-resume transmit position. RESET is immediately terminal. Repeats,
160
+ data after END, and stale ACKs are protocol violations.
117
161
 
118
162
  Reverse peers must negotiate `zstd` before selection/registration completes.
119
163
  A compressed DATA payload is `uint32_be(uncompressedLength)` followed by one
@@ -10,26 +10,29 @@ The roles are deliberately separate:
10
10
 
11
11
  ```text
12
12
  TensorBuzz / local application on Server3
13
- -> ReverseGatewayConnection (verified TLS client, one selected gateway ID)
13
+ -> ReverseGatewayConnection (verified TLS client, one selected route class)
14
14
  -> reverse hub on Server3
15
- <- OutboundReverseGateway (verified TLS client from the home Docker host)
16
- -> docker-server:2375 on a private Compose network
15
+ <- OutboundReverseGateway A -> target A on its private Compose network
16
+ <- OutboundReverseGateway B -> target B on its private Compose network
17
17
  ```
18
18
 
19
19
  - `startReverseGatewayHub(options)` is the reachable Server3 broker. It
20
20
  authenticates both kinds of physical connection, binds gateway IDs to
21
- principals, applies `authorizeClientGateway` before selection, authorizes
22
- every client/gateway/target tuple, and never connects to a target itself.
21
+ principals, authorizes exact-gateway or route-class selection, resolves each
22
+ route-class `OPEN` to exactly one current gateway, authorizes the resulting
23
+ client/gateway/target tuple, and never connects to a target itself.
23
24
  - `OutboundReverseGateway` is one home member. It makes one persistent outbound
24
25
  TLS connection, registers one stable `gatewayId`, applies its own exact local
25
26
  target policy, and multiplexes all accepted target streams on that transport.
26
- - `ReverseGatewayConnection` is the local application API. It selects one
27
- registered gateway during connection readiness and returns ordinary
28
- `VirtualSocket` objects from `createConnection()`.
29
- - `MultiplexedSocketductTransportSet` accepts `endpoint.reverseHub` members, so
30
- TensorBuzz can keep its existing health-aware ordered round-robin behavior.
31
- A three-member set points all three members at the same hub but selects
32
- `o2`, `zyxel`, and `starlink` independently.
27
+ - `ReverseGatewayConnection` is the local application API. Supplying
28
+ `routeClass` binds one authenticated physical connection to that class; each
29
+ `createConnection()` is routed from its requested target. Supplying the
30
+ legacy `gatewayId` instead retains exact-gateway behavior unchanged.
31
+ - `MultiplexedSocketductTransportSet` accepts a `routeClasses` array of
32
+ `endpoint.reverseHub` members. A three-member WAN set therefore owns exactly
33
+ one client physical connection for each class such as `o2`, `zyxel`, and
34
+ `starlink`, independent of how many server-local gateways those classes can
35
+ reach.
33
36
 
34
37
  Import these APIs from `socketduct/reverse`. The ordinary v2 and v3 edge/relay
35
38
  APIs and wire handshakes remain unchanged.
@@ -42,11 +45,17 @@ frames authenticate registration and selection:
42
45
 
43
46
  - `GATEWAY_REGISTER` / `GATEWAY_REGISTERED` bind the authenticated outbound
44
47
  transport to a configured gateway ID and monotonically increasing generation.
45
- - `GATEWAY_SELECT` / `GATEWAY_SELECTED` make client readiness depend on that
46
- gateway currently being registered.
48
+ - `GATEWAY_SELECT` / `GATEWAY_SELECTED` carry exactly one `gatewayId` or
49
+ `routeClass`. Exact selection still makes readiness depend on that gateway;
50
+ route-class readiness authenticates the class independently of individual
51
+ gateway availability.
47
52
  - Client-created streams use odd IDs. The hub maps each one to a hub-created
48
- even ID on the selected gateway transport. `OPEN`, `OPENED`, `DATA`, `ACK`,
49
- `WINDOW`, `END`, `CLOSE`, and `RESET` then retain their v3 meanings.
53
+ even ID on the exact gateway transport selected for that `OPEN`. A class-bound
54
+ `OPEN` repeats its authenticated `routeClass` and never supplies a gateway ID;
55
+ the hub resolves `{principal, routeClass, target}` to exactly one registered
56
+ gateway before applying the existing exact route authorization. `OPEN`,
57
+ `OPENED`, `DATA`, `ACK`, `WINDOW`, `END`, `CLOSE`, and `RESET` then retain their
58
+ v3 meanings.
50
59
  Gateway-originated `CLOSE` is acknowledged by a client `CLOSE`, after which the
51
60
  hub releases both mapped IDs; `RESET` remains immediately terminal.
52
61
  Valid `ACK` and `WINDOW` frames already in flight when that close starts are
@@ -56,9 +65,13 @@ The hub rejects unknown IDs, registration by the wrong principal, a second
56
65
  connection identity claiming the same ID, stale generations, repeated IDs,
57
66
  malformed frames, unauthorized routes, and configured limits. A higher
58
67
  generation from the same stable connection identity fences its prior transport.
59
- At the configured identity bound, the oldest disconnected client identity is
60
- evicted; active owners are never evicted and retain generation fencing.
61
- The home gateway independently rejects any target outside its local allowlist.
68
+ At the configured identity bound, the oldest disconnected, streamless client
69
+ identity is evicted; retained-stream and active owners are never evicted and
70
+ retain generation fencing.
71
+ Zero eligible gateways denies the target, and multiple eligible gateways deny
72
+ the stream as ambiguous. Unknown classes, class mismatches, stale registrations,
73
+ and disconnected resolved gateways also fail closed. The home gateway
74
+ independently rejects any target outside its local allowlist.
62
75
 
63
76
  Reverse DATA compression is negotiated as `zstd`. The one DATA-only flag means
64
77
  the payload starts with a four-byte uncompressed length followed by one
@@ -70,9 +83,30 @@ zstd runtime support and always uses TLS with certificate verification enabled;
70
83
  there is no plaintext or `rejectUnauthorized:false` reverse option.
71
84
 
72
85
  Each peer advertises its receive window during authentication and stream open;
73
- senders use the smaller local and peer window for stream credit. A graceful hub
74
- shutdown is transient and reconnects the client and gateway registrations, while
75
- authentication, authorization, and stale-generation rejections remain terminal.
86
+ senders use the smaller local and peer window for stream credit. A hub-directed
87
+ physical replacement is transient and reconnects the client and gateway
88
+ registrations, while loss of the hub's retained state, authentication,
89
+ authorization, and stale-generation rejections remain terminal.
90
+
91
+ After an established stream loses either reverse physical transport, the hub
92
+ retains its authenticated bridge for the configured resume window and fences the
93
+ old generation. It makes both logical endpoints reconnect so each can present a
94
+ paginated `RESUME` manifest containing the stable stream identifier, both
95
+ directional sequence/ACK positions, and half-close state. The hub releases the
96
+ replacement generation only after both manifests agree. Each sender then
97
+ replays only durable chunks above the receiver-confirmed high-water position,
98
+ with its flow-control window reduced by bytes already buffered but not yet
99
+ consumed at the receiver. A registered gateway completing this paired resume is
100
+ not healthy or eligible for a new selection or `OPEN`; retained peers alone may
101
+ attach to submit their resume manifests.
102
+
103
+ The hub owns a separate ordered output queue for every client and gateway
104
+ physical connection. Each queue defaults to a 32 MiB bound and can be set with
105
+ the programmatic `maxOutputBytesPerConnection` hub option. Backpressure on one
106
+ gateway therefore cannot pause client input for a healthy gateway in the same
107
+ route class. If one destination exceeds its bound, the hub closes that physical
108
+ connection and fails its mapped streams with `OUTPUT_BACKPRESSURE_LIMIT`;
109
+ sibling gateways and the route-class client connection remain usable.
76
110
 
77
111
  An application write may be larger than the configured reverse-stream buffer.
78
112
  The client admits it in bounded slices no larger than the negotiated peer window
@@ -90,19 +124,35 @@ in-memory gateway registry across independent cluster workers.
90
124
  ## Failure and replay boundary
91
125
 
92
126
  Gateway registration reconnects with bounded exponential delay and a higher
93
- generation. An established Docker stream does **not** survive that transport
94
- loss: the hub resets it with `GATEWAY_DISCONNECTED`, both endpoints release the
95
- stream, and the gateway destroys its local Docker socket. This is intentional.
96
- A Docker request whose write reached dockerd but whose response did not reach
97
- TensorBuzz has an ambiguous outcome and must never be replayed automatically.
98
- TensorBuzz must reconcile that Docker operation and then create a new stream.
99
-
100
- Consequently reverse streams have bounded in-memory flow-control/replay queues
101
- only for the live transport and report zero spool bytes. They do not persist an
102
- operation spool across disconnect or process replacement; `recoverConnection()`
103
- fails explicitly. Ordinary resumable Socketduct sessions retain their existing
104
- durable spool behavior. This split is the safety boundary between reconnecting
105
- the gateway registration and replaying privileged Docker operations.
127
+ generation. An established Docker stream survives a transient physical loss
128
+ when the hub process and its retained state, live client object, outbound gateway process, and
129
+ original target socket remain available through the configured resume window.
130
+ Socketduct keeps
131
+ the app-facing socket open, durably spools each direction's unacknowledged raw
132
+ chunks before sending them, reconciles the two receiver-committed ACK positions,
133
+ and separately preserves each receiver's accepted high-water position. It
134
+ replays only bytes above the high-water position, retains buffered-but-unread
135
+ bytes against the flow-control window until ACK, and keeps terminal CLOSE state
136
+ until the echoed CLOSE arrives. It never creates a replacement logical stream
137
+ or reconnects dockerd, so an application request is not executed again merely
138
+ because the WAN transport changed.
139
+
140
+ This is transparent transport replay, not application-operation retry. Bytes
141
+ already committed to dockerd are acknowledged by the surviving gateway and are
142
+ not replayed; bytes already accepted by the surviving app-facing socket are
143
+ likewise not delivered twice. Sequence gaps, overlaps, identity/ownership
144
+ mismatches, invalid manifests, spool/buffer limits, and authorization failures
145
+ fail closed.
146
+
147
+ Reverse replay state is bounded per stream and connection and stored in private
148
+ temporary spool directories. ACKed chunks are truncated, and terminal
149
+ close/reset removes the spool. `status().spoolBytes` reports retained durable
150
+ payload. `registerRecoveredConnection()` remains unsupported for reverse mode:
151
+ a process replacement cannot inherit the live target socket or the prior
152
+ `VirtualSocket`. If either endpoint process disappears, the target socket is
153
+ lost, or the hub resume window expires, the logical stream fails once with the
154
+ causal session-loss/reset code and the application must reconcile at its own
155
+ protocol layer.
106
156
 
107
157
  Target bytes already observed by the gateway are ordered before a subsequent
108
158
  target-error reset. Stream `END` remains a directional half-close, while protocol
@@ -118,40 +168,44 @@ SOCKETDUCT_RELAY_HOST=0.0.0.0 \
118
168
  SOCKETDUCT_RELAY_PORT=3100 \
119
169
  SOCKETDUCT_TLS_CERT_FILE=/certs/hub.crt \
120
170
  SOCKETDUCT_TLS_KEY_FILE=/certs/hub.key \
121
- SOCKETDUCT_AUTH_TOKENS='home-o2:<secret>,home-zyxel:<secret>,home-starlink:<secret>,tensorbuzz:<secret>' \
122
- SOCKETDUCT_REVERSE_GATEWAY_BINDINGS='o2:home-o2,zyxel:home-zyxel,starlink:home-starlink' \
123
- SOCKETDUCT_REVERSE_ROUTES='tensorbuzz:o2:docker-server:2375,tensorbuzz:zyxel:docker-server:2375,tensorbuzz:starlink:docker-server:2375' \
171
+ SOCKETDUCT_AUTH_TOKENS='server-a-starlink:<secret>,server-b-starlink:<secret>,tensorbuzz:<secret>' \
172
+ SOCKETDUCT_REVERSE_GATEWAY_BINDINGS='server-a-starlink:server-a-starlink,server-b-starlink:server-b-starlink' \
173
+ SOCKETDUCT_REVERSE_ROUTE_CLASS_ROUTES='tensorbuzz:starlink:server-a-starlink:docker-a:2375,tensorbuzz:starlink:server-b-starlink:docker-b:2375' \
124
174
  node src/index.js
125
175
  ```
126
176
 
127
177
  Values shown as `<secret>` are placeholders for secret-manager input, not
128
178
  literal values. The runtime refuses to start without TLS, credentials,
129
- bindings, and routes.
179
+ bindings, and either exact or route-class routes. Exact legacy rows remain
180
+ available as `SOCKETDUCT_REVERSE_ROUTES=username:gatewayId:host:port`.
181
+ Route-class rows use `username:routeClass:gatewayId:host:port`. If two rows make
182
+ different gateways eligible for the same authenticated class and target, that
183
+ `OPEN` is rejected as ambiguous rather than choosing one implicitly.
130
184
 
131
- ## One outbound member per router
185
+ ## One outbound member per target and route class
132
186
 
133
- Run this once in each existing router-pinned network namespace, changing the
134
- two stable IDs and credential for the member:
187
+ Run this once in each target server's route-pinned network namespace, changing
188
+ the stable IDs, credential, and private target name for the member:
135
189
 
136
190
  ```sh
137
191
  SOCKETDUCT_MODE=reverse-gateway \
138
- SOCKETDUCT_REVERSE_GATEWAY_ID=o2 \
139
- SOCKETDUCT_REVERSE_CONNECTION_ID=home_o2_gateway_01 \
192
+ SOCKETDUCT_REVERSE_GATEWAY_ID=server-a-starlink \
193
+ SOCKETDUCT_REVERSE_CONNECTION_ID=server_a_starlink_gateway_01 \
140
194
  SOCKETDUCT_REVERSE_HUB_HOST=server3.example.net \
141
195
  SOCKETDUCT_REVERSE_HUB_PORT=3100 \
142
196
  SOCKETDUCT_REVERSE_TOKEN='<secret>' \
143
197
  SOCKETDUCT_REVERSE_HUB_CA_FILE=/certs/hub-ca.crt \
144
198
  SOCKETDUCT_REVERSE_HUB_SERVERNAME=server3.example.net \
145
- SOCKETDUCT_TARGET_ALLOWLIST=docker-server:2375 \
199
+ SOCKETDUCT_TARGET_ALLOWLIST=docker-a:2375 \
146
200
  node src/index.js
147
201
  ```
148
202
 
149
- The namespace/default-route configuration remains outside Socketduct. Pin the
150
- three processes to the routes whose next hops are O2 `192.168.86.1`, Zyxel
151
- `192.168.86.3`, and Starlink `192.168.86.25`. Socketduct needs no inbound rule
152
- on any home router. Do not publish port 2375: the gateway resolves
153
- `docker-server` and reaches plaintext Docker TCP only on their shared private
154
- Compose network.
203
+ The namespace/default-route configuration remains outside Socketduct. Each
204
+ gateway registration represents one exact target server and WAN class;
205
+ Socketduct does not aggregate away either identity. Socketduct needs no inbound
206
+ rule on the WAN router. Do not publish port 2375: the gateway resolves only its
207
+ allowlisted Docker shim and reaches plaintext Docker TCP on that server's
208
+ private Compose network.
155
209
 
156
210
  ## TensorBuzz transport set
157
211
 
@@ -160,8 +214,8 @@ import {MultiplexedSocketductTransportSet} from "socketduct/multiplexed"
160
214
 
161
215
  const set = new MultiplexedSocketductTransportSet({
162
216
  connectionId: "tensorbuzz_docker_transport_01",
163
- gateways: ["o2", "zyxel", "starlink"].map((gatewayId) => ({
164
- gatewayId,
217
+ routeClasses: ["o2", "zyxel", "starlink"].map((routeClass) => ({
218
+ routeClass,
165
219
  endpoint: {
166
220
  reverseHub: {
167
221
  host: "127.0.0.1",
@@ -174,13 +228,25 @@ const set = new MultiplexedSocketductTransportSet({
174
228
  })
175
229
 
176
230
  const starting = set.start()
177
- const socket = set.createConnection({host: "docker-server", port: 2375})
231
+ const socket = set.createConnection({host: "docker-a", port: 2375})
178
232
  await starting
179
233
  ```
180
234
 
235
+ Route-class sockets expose immutable `routeClass` allocation metadata and an
236
+ undefined `gatewayId`. Exact-gateway sets retain immutable `gatewayId` metadata.
237
+ Callers cannot override either selection per open. Reverse streams remain
238
+ sticky to their selected gateway/target but resume transparently after a
239
+ transient physical transport replacement.
240
+
181
241
  The socket is returned immediately and waits inside Socketduct until the set
182
242
  reaches minimum readiness. New sockets rotate equally across ready members and
183
243
  stay on their assigned member. If all members are reconnecting, new sockets wait
184
244
  FIFO without selecting a connecting gateway; a recovered member resumes those
185
245
  opens. A disconnected member leaves readiness and rejoins after authenticated
186
- registration; existing streams on it fail for application-level reconciliation.
246
+ registration and paired stream resume; existing sockets remain open unless the
247
+ target/session is genuinely lost or a configured resume/buffer bound expires.
248
+
249
+ The paired reverse-resume fields are a protocol compatibility boundary. Upgrade
250
+ the hub, outbound gateways, and Server3/TensorBuzz Socketduct consumer together;
251
+ 0.1.17 and older reverse peers do not exchange these manifests and cannot
252
+ provide transparent established-stream recovery.
package/docs/security.md CHANGED
@@ -153,10 +153,31 @@ Reverse transports require verified TLS and negotiated zstd. There is no
153
153
  plaintext or certificate-verification opt-out. Docker port 2375 is permitted
154
154
  only as a private `docker-server:2375` target and is never host-published.
155
155
 
156
- Reverse registration can reconnect; established Docker streams cannot resume
157
- or spool across loss. They fail with an explicit uncertain-operation error so
158
- the caller must reconcile before retrying. This prevents privileged Docker API
159
- writes from being applied twice.
156
+ A route-class client authenticates one explicit class on its physical
157
+ connection. It cannot name a gateway per stream. For every `OPEN`, the hub uses
158
+ the authenticated principal, bound class, and exact requested target to obtain
159
+ the eligible gateway IDs and proceeds only when there is exactly one. Unknown
160
+ or unauthorized classes, missing routes, duplicate eligible routes, class
161
+ mismatches, stale owners, and disconnected gateways fail closed. The resolved
162
+ gateway still passes the existing hub authorization and its own server-local
163
+ target allowlist; route classes introduce no wildcard authorization or fallback.
164
+ Hub output is ordered and byte-bounded per physical destination. A gateway that
165
+ stops reading cannot grow an unbounded queue or stall authorization, DATA, or
166
+ OPEN processing for a healthy sibling; exceeding the bound disconnects only
167
+ that destination and resets its owned streams.
168
+
169
+ Native reverse peers retain established logical streams for a bounded resume
170
+ window when only a hub transport is lost. Both authenticated endpoints must
171
+ reconnect with the same stable identities and newer generations, then present
172
+ matching per-stream identifiers, byte positions, acknowledgements, and
173
+ half-close state. Application bytes are durably spooled before transmission;
174
+ the hub reconciles to each receiver's committed position and permits replay
175
+ only above it. This preserves exact-once delivery without reopening or
176
+ reissuing a privileged Docker request. Ownership mismatches, missing targets,
177
+ invalid or incomplete manifests, resource-bound failures, and expired resume
178
+ windows fail the affected logical stream once instead of falling back to a new
179
+ stream. Process replacement remains terminal because the live app and target
180
+ sockets cannot be inherited safely.
160
181
 
161
182
  ## Network exposure
162
183
 
@@ -255,4 +276,6 @@ To roll back TLS: remove the TLS env vars from `.env`. If the relay binds to a n
255
276
  - reverse registration rejects wrong principals, duplicate IDs, and stale generations
256
277
  - reverse clients reject unknown/unavailable gateway IDs and wrong TLS CAs
257
278
  - both hub route policy and the home-local allowlist deny unauthorized Docker targets
258
- - gateway loss cleans every stream and target without replay, then a fresh stream works after re-registration
279
+ - transient reverse hub transport loss preserves each live stream and target,
280
+ replays only unacknowledged spooled bytes, and cleans all retained state after
281
+ close; target loss and resume expiry fail once with their causal codes
@@ -11,22 +11,33 @@ spawn per-stream reconnects.
11
11
  ## Reverse-gateway lifecycle
12
12
 
13
13
  An outbound gateway moves through `connecting -> authenticated -> registering
14
- -> ready`. Transport loss destroys every local target stream, advances the
15
- gateway generation, and reconnects with bounded exponential delay. Selected
16
- Server3 client members leave readiness when that gateway disappears and rejoin
17
- only after it registers again.
18
-
19
- Reverse stream loss is terminal even when registration later recovers. No
20
- established Docker bytes are persisted or replayed because their outcome may
21
- already be committed at dockerd. The application receives
22
- `GATEWAY_DISCONNECTED`, reconciles the operation, and opens a new stream.
14
+ -> ready`. Transport loss advances the gateway generation and reconnects with
15
+ bounded exponential delay without destroying established local targets. The hub
16
+ retains authenticated bridge ownership for the configured resume window and
17
+ replaces the affected Server3 client physical generation as well.
18
+
19
+ Both reverse endpoints keep their logical sockets alive, persist unacknowledged
20
+ send chunks, and submit their stable identifiers plus both directional
21
+ sequence/ACK positions. The hub resumes a bridge only after the two manifests
22
+ agree and returns both the receiver's accepted high-water sequence and its
23
+ application-consumed ACK to each sender. Bytes above the high-water position
24
+ replay on the same logical stream; accepted-but-unread bytes stay charged to the
25
+ window and spooled until ACK. This preserves exact-once, in-order delivery
26
+ without opening a second target or retrying an application operation.
27
+
28
+ If the original target socket disappears, an owner/identifier or sequence does
29
+ not match, a buffer/spool bound is exceeded, or the resume window expires, that
30
+ logical stream fails once with the causal error and releases both sides. Reverse
31
+ process-replacement recovery remains unsupported because the live target and
32
+ app-facing socket cannot be transferred to replacement JavaScript objects.
23
33
 
24
34
  The bounded, paginated resume manifest carries stable identifiers and both
25
35
  directional acknowledgement positions. The relay returns an independent result
26
36
  per stream. A stream omitted because it was closed while disconnected is
27
37
  cleaned up at the relay. A target lost during interruption returns a stream-local
28
38
  resume reset and a typed session-loss error, while retained siblings continue.
29
- Only bytes above the peer acknowledgement are replayed.
39
+ Only bytes above the peer's received high-water sequence are replayed; spool
40
+ cleanup remains governed by its acknowledgement.
30
41
 
31
42
  ## States
32
43