socketduct 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/.dockerignore +11 -0
  2. package/.env.example +131 -0
  3. package/AGENTS.md +168 -0
  4. package/Dockerfile +30 -0
  5. package/README.md +134 -0
  6. package/changelog.d/20260716083144-v2-ack-wire-format.md +3 -0
  7. package/changelog.d/20260716092044-v2-only-stream-framing.md +1 -0
  8. package/changelog.d/20260727113000-relay-connect-timeout-destroy.md +1 -0
  9. package/changelog.d/20260813093000-private-relay-hardening.md +1 -0
  10. package/changelog.d/20260824183412-initial-target-recovery-https.md +1 -0
  11. package/changelog.d/20260824230000-tls-relay-session-close.md +1 -0
  12. package/changelog.d/20260825120000-reconnect-close-target-timeout.md +1 -0
  13. package/changelog.d/20260829120000-protocol-v3-multiplexing.md +1 -0
  14. package/changelog.d/20260829160000-v3-review-corrections.md +1 -0
  15. package/changelog.d/20260830000000-fix-socket-idle-timeout.md +1 -0
  16. package/changelog.d/20260830090000-multi-gateway-transport-set.md +1 -0
  17. package/changelog.d/20260830093000-relay-close-settlement.md +1 -0
  18. package/changelog.d/20260830120000-immutable-gateway-image.md +1 -0
  19. package/changelog.d/20260902210000-native-reverse-gateway.md +1 -0
  20. package/changelog.d/20260902230000-relay-resume-terminal-order.md +1 -0
  21. package/changelog.d/20260902230708-multiplexed-open-ownership.md +1 -0
  22. package/changelog.d/20260902231541-multiplexed-terminal-close.md +1 -0
  23. package/changelog.d/20260902231542-reverse-close-position.md +1 -0
  24. package/changelog.d/20260902231543-reverse-terminal-connect.md +1 -0
  25. package/changelog.d/20260902233000-reverse-gateway-bounds.md +1 -0
  26. package/changelog.d/20260903001000-reverse-data-ordering.md +1 -0
  27. package/changelog.d/20260903003000-reverse-hub-restart-window.md +1 -0
  28. package/changelog.d/20260903010000-reverse-terminal-lifecycle.md +1 -0
  29. package/changelog.d/20260904142743-transport-set-readiness-wait.md +1 -0
  30. package/changelog.d/20260904152855-deferred-open-snapshots-binary-views.md +1 -0
  31. package/changelog.d/20260904163041-tensorbuzz-container-workflow.md +1 -0
  32. package/changelog.d/20260904174955-transport-set-terminal-causes.md +1 -0
  33. package/changelog.d/20260905081000-contain-reverse-auth-write-errors.md +1 -0
  34. package/changelog.d/20260905100520-reverse-hub-close-race.md +1 -0
  35. package/changelog.d/20260905120000-persistent-jsonl-logging.md +1 -0
  36. package/changelog.d/20260908071009-jsonl-partial-append-recovery.md +1 -0
  37. package/changelog.d/20260908074916-private-log-directory-setup.md +1 -0
  38. package/changelog.d/20260909093057-reverse-large-write-backpressure.md +1 -0
  39. package/changelog.d/20260909135830-actionable-error-diagnostics.md +1 -0
  40. package/changelog.d/20260909150000-transport-set-error-redaction.md +1 -0
  41. package/docker-compose.dev.yml +19 -0
  42. package/docker-compose.reverse-gateway.yml +36 -0
  43. package/docker-compose.yml +73 -0
  44. package/docs/architecture.md +264 -0
  45. package/docs/docker-topology.md +164 -0
  46. package/docs/docker.md +281 -0
  47. package/docs/logging.md +185 -0
  48. package/docs/plans/initial-implementation.md +205 -0
  49. package/docs/plans/socket-docker-integration.md +221 -0
  50. package/docs/protocol.md +558 -0
  51. package/docs/releasing.md +18 -0
  52. package/docs/reverse-gateway.md +186 -0
  53. package/docs/runbooks/jsonl-integrity-rollout.md +144 -0
  54. package/docs/running.md +215 -0
  55. package/docs/security.md +258 -0
  56. package/docs/session-lifecycle.md +127 -0
  57. package/docs/socket-api.md +448 -0
  58. package/eslint.config.js +36 -0
  59. package/package.json +44 -0
  60. package/scripts/deploy-gateway.js +194 -0
  61. package/scripts/test.js +28 -0
  62. package/src/auth/passwords.js +38 -0
  63. package/src/auth/static-token-store.js +41 -0
  64. package/src/auth/user-store.js +139 -0
  65. package/src/client/connect.js +76 -0
  66. package/src/client/deferred-virtual-socket.js +312 -0
  67. package/src/client/http-agent.js +101 -0
  68. package/src/client/https-agent.js +366 -0
  69. package/src/client/multiplexed-connection.js +1504 -0
  70. package/src/client/multiplexed-transport-set.js +917 -0
  71. package/src/client/multiplexed.js +3 -0
  72. package/src/client/socketduct-socket.js +1174 -0
  73. package/src/client/virtual-socket.js +256 -0
  74. package/src/config/environment.js +400 -0
  75. package/src/edge/tcp-edge.js +82 -0
  76. package/src/index.js +101 -0
  77. package/src/logging/structured-logger.js +745 -0
  78. package/src/protocol/compression.js +325 -0
  79. package/src/protocol/frame.js +341 -0
  80. package/src/protocol/v3-compression.js +44 -0
  81. package/src/protocol/v3-frame.js +209 -0
  82. package/src/proxy/multiplexed-relay.js +798 -0
  83. package/src/proxy/tcp-relay.js +1191 -0
  84. package/src/relay.js +3 -0
  85. package/src/reverse/client.js +721 -0
  86. package/src/reverse/common.js +88 -0
  87. package/src/reverse/gateway.js +663 -0
  88. package/src/reverse/hub.js +563 -0
  89. package/src/reverse/index.js +3 -0
  90. package/src/reverse/runtime.js +78 -0
  91. package/src/server/control-handler.js +170 -0
  92. package/src/server/primary.js +64 -0
  93. package/src/server/relay-runtime.js +124 -0
  94. package/src/server/worker.js +39 -0
  95. package/src/sessions/session.js +212 -0
  96. package/src/spool/spool-file.js +451 -0
  97. package/tensorbuzz.yml +36 -0
  98. package/test/e2e/gateway-process.test.js +262 -0
  99. package/test/e2e/reverse-gateway-process.test.js +285 -0
  100. package/test/fixtures/logging/enospc-matrix.mjs +238 -0
  101. package/test/fixtures/logging/enospc-recovery.mjs +146 -0
  102. package/test/fixtures/logging/process-writer.mjs +20 -0
  103. package/test/helpers/tls-certificates.js +115 -0
  104. package/test/integration/initial-target-recovery.test.js +518 -0
  105. package/test/integration/logging-recovery.test.js +163 -0
  106. package/test/integration/multiplexed-compatibility.test.js +254 -0
  107. package/test/integration/multiplexed-corrections.test.js +551 -0
  108. package/test/integration/multiplexed-failures.test.js +339 -0
  109. package/test/integration/multiplexed-transport-set.test.js +456 -0
  110. package/test/integration/multiplexed-transport.test.js +208 -0
  111. package/test/integration/reverse-gateway.test.js +1293 -0
  112. package/test/integration/socketduct-http-agent.test.js +435 -0
  113. package/test/integration/socketduct-https-agent.test.js +669 -0
  114. package/test/integration/stream-compression.test.js +1267 -0
  115. package/test/integration/tcp-forwarding.test.js +2878 -0
  116. package/test/integration/tls-forwarding.test.js +748 -0
  117. package/test/unit/auth/auth.test.js +64 -0
  118. package/test/unit/auth/static-token-store.test.js +14 -0
  119. package/test/unit/client/connect.test.js +58 -0
  120. package/test/unit/client/multiplexed-spool-budget.test.js +309 -0
  121. package/test/unit/client/multiplexed-transport-set.test.js +906 -0
  122. package/test/unit/client/socketduct-socket.test.js +1124 -0
  123. package/test/unit/client/virtual-socket.test.js +98 -0
  124. package/test/unit/config/docker-gateway-files.test.js +274 -0
  125. package/test/unit/config/environment.test.js +507 -0
  126. package/test/unit/index.test.js +78 -0
  127. package/test/unit/logging/structured-logger.test.js +674 -0
  128. package/test/unit/protocol/compression.test.js +421 -0
  129. package/test/unit/protocol/frame.test.js +336 -0
  130. package/test/unit/protocol/v3-compression.test.js +31 -0
  131. package/test/unit/protocol/v3-frame.test.js +57 -0
  132. package/test/unit/release-configuration.test.js +21 -0
  133. package/test/unit/server/control-handler.test.js +291 -0
  134. package/test/unit/server/primary.test.js +59 -0
  135. package/test/unit/server/relay-runtime.test.js +434 -0
  136. package/test/unit/sessions/session.test.js +84 -0
  137. package/test/unit/spool/spool-file.test.js +295 -0
  138. package/tsconfig.json +20 -0
@@ -0,0 +1,258 @@
1
+ # Security Model
2
+
3
+ Socketduct must not become an open proxy.
4
+
5
+ ## Authentication
6
+
7
+ Authentication is required before any session operation:
8
+
9
+ - `session.create`
10
+ - `session.resume`
11
+ - stream attachment
12
+ - admin/user management
13
+
14
+ Initial implementation can use local users with password hashes.
15
+
16
+ Preferred password storage:
17
+
18
+ 1. Argon2id
19
+ 2. scrypt
20
+ 3. bcrypt
21
+
22
+ Never store plaintext passwords.
23
+
24
+ ## Authorization
25
+
26
+ Every session belongs to one user.
27
+
28
+ A user may only:
29
+
30
+ - create sessions within their allowed destination policy
31
+ - resume their own sessions
32
+ - close their own sessions
33
+ - inspect their own session status
34
+
35
+ Admin operations should be separate from normal tunnel operations.
36
+
37
+ ## Destination policy
38
+
39
+ Default should be deny-by-default for generic proxying.
40
+
41
+ Protocol v3 authenticates the physical connection before accepting any OPEN,
42
+ DATA or resume operation. Authentication is performed once per physical
43
+ generation, but authorization is deliberately not cached across targets: every
44
+ virtual stream OPEN passes its exact `{host, port}` and authenticated principal
45
+ through the deny-by-default target policy. A denied/open-failed stream is reset
46
+ without affecting authorized siblings. Stream ID zero cannot carry application
47
+ data.
48
+
49
+ The current MVP relay is not a generic open proxy: every target is supplied in `session.create`, validated by an injected authorization function such as `authorizeTarget({principal, target})`, and stored on the session. Stream data is rejected until a `session.create` or `session.resume` message authenticates successfully. Broader proxy modes must keep this deny-by-default shape with explicit per-user allowlists before accepting arbitrary destinations.
50
+
51
+ Recommended first policy shape:
52
+
53
+ ```json
54
+ {
55
+ "allow": [
56
+ { "host": "10.0.0.15", "port": 22 },
57
+ { "host": "example.com", "port": 443 }
58
+ ]
59
+ }
60
+ ```
61
+
62
+ Later:
63
+
64
+ - CIDR allowlists
65
+ - DNS suffix allowlists
66
+ - per-user policies
67
+ - max bandwidth/session limits
68
+
69
+ ## Tokens
70
+
71
+ After username/password login, the server may issue a short-lived reconnect token.
72
+
73
+ Token properties:
74
+
75
+ - random opaque value
76
+ - bound to user id
77
+ - optionally bound to session id
78
+ - expiration time
79
+ - revocable
80
+
81
+ Do not log token values.
82
+
83
+ ## Spool files
84
+
85
+ Spool files may contain application traffic and must be treated as sensitive.
86
+
87
+ Requirements:
88
+
89
+ - owner-only file permissions (`0600`) where supported
90
+ - predictable directory but unpredictable filenames or session-owned paths
91
+ - no world-readable temp files
92
+ - cleanup on close/expiry
93
+ - safe startup cleanup of abandoned files
94
+
95
+ Optional later hardening:
96
+
97
+ - encrypt spool files at rest
98
+ - per-session spool keys
99
+ - authenticated checksums
100
+
101
+ ## Logging
102
+
103
+ The optional persistent JSONL logger uses a closed event/field allowlist. It
104
+ records process/role identity, lifecycle state, bounded counters, validated
105
+ gateway IDs, and normalized error codes/classes. Unknown event names are
106
+ dropped and unknown error codes become `UNKNOWN`.
107
+
108
+ It never records protocol passwords, tokens, authentication frames, headers,
109
+ certificates or keys, environment values, session/stream names or identifiers,
110
+ spool paths/contents, raw application payload, or arbitrary objects. Error
111
+ diagnostics retain bounded error names, messages, and stacks so operational
112
+ failures can be investigated while credential-shaped values are redacted. Each
113
+ JSON line is capped at 1024 bytes. Logger I/O and custom-writer failures are
114
+ isolated from network behavior and produce only one bounded stderr
115
+ classification. See [Persistent structured logging](logging.md) for
116
+ configuration and rotation.
117
+
118
+ V3 status is payload-free and credential-free: physical counts/generation,
119
+ virtual stream state counts, active local targets, bounded queued/spool bytes,
120
+ resume outcomes, stable reset-reason counters and aggregate stream-open latency.
121
+ It never includes bearer tokens, passwords, target credentials, certificate/key
122
+ contents, stream names, payloads or spool contents.
123
+
124
+ Multi-gateway transport-set status follows the same boundary. It exposes only
125
+ configured gateway IDs, lifecycle/counter totals, assignment counts and bounded
126
+ byte counts. It never exposes relay or target addresses, bearer tokens, TLS
127
+ material, stream identifiers/names, payloads or spool contents. Gateway IDs are
128
+ restricted to short URL-safe values; opaque per-gateway connection identities
129
+ and spool subdirectories are derived with SHA-256 from the stable base identity
130
+ and gateway ID, preventing path traversal and cross-gateway spool collisions.
131
+
132
+ Transport-set lifecycle events include only normalized error codes and classes;
133
+ they never include arbitrary member error names, messages, or stacks.
134
+
135
+ Each gateway entry still authenticates its own v3 physical connection before
136
+ any target stream opens, and every target is authorized by its selected relay.
137
+ The set does not add unauthenticated fallback, anonymous members, or migration
138
+ to a sibling after an uncertain write. Shared process byte reservations prevent
139
+ multiple gateway members from multiplying the intended memory/spool ceiling.
140
+ Durable spool reservations remain charged until truncate/remove succeeds; a
141
+ cleanup failure is counted and closes the affected member without exposing its
142
+ stream identity, path or contents. Recovery requires the original configured
143
+ gateway ID before startup, preventing durable state from being probed or moved
144
+ through a sibling path.
145
+
146
+ Native reverse mode adds two independent authorization decisions. The hub
147
+ binds each configured gateway ID to an authenticated principal and authorizes
148
+ every client/gateway/target route. The selected home gateway then checks the
149
+ exact target against its own local deny-by-default allowlist. A gateway ID
150
+ cannot be claimed by another stable connection identity, stale generations are
151
+ rejected, and a newer generation from the same identity fences the old owner.
152
+ Reverse transports require verified TLS and negotiated zstd. There is no
153
+ plaintext or certificate-verification opt-out. Docker port 2375 is permitted
154
+ only as a private `docker-server:2375` target and is never host-published.
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.
160
+
161
+ ## Network exposure
162
+
163
+ Defaults should be conservative:
164
+
165
+ - edge local proxy listens on `127.0.0.1` unless configured otherwise
166
+ - relay tunnel endpoint listens on configured public interface
167
+ - admin API listens on `127.0.0.1` unless explicitly exposed
168
+ - require TLS or equivalent transport security before real use
169
+
170
+ Current implementation notes:
171
+
172
+ - `startTcpEdge` and `startTcpRelay` default to `127.0.0.1`.
173
+ - `startTcpRelay` has no static upstream target; callers must provide target authorization.
174
+ - `startTcpEdge` includes the requested target in `session.create`.
175
+ - The MVP protocol is framed TCP; protect it with TLS (configured via `SOCKETDUCT_TLS_CERT_FILE`/`SOCKETDUCT_TLS_KEY_FILE`) before exposing it on untrusted networks.
176
+ - Tokens are opaque and short-lived in memory, but should still be treated as bearer credentials.
177
+ - Spool chunks contain application payload and are written with owner-only file permissions where the platform supports them.
178
+
179
+ ### TLS
180
+
181
+ For production deployment, configure TLS on the relay listener:
182
+
183
+ - Set `SOCKETDUCT_TLS_CERT_FILE` and `SOCKETDUCT_TLS_KEY_FILE` as an atomic pair pointing to PEM-encoded certificate and private key files. Both must be set or neither.
184
+ - Optionally set `SOCKETDUCT_TLS_CLIENT_CA_FILE` to a PEM-encoded client CA certificate to enable mutual TLS (mTLS). When set, clients must present a certificate signed by this CA.
185
+ - Certificate material is read at runtime with `readFileSync` and never logged. The relay fails fast on missing or unreadable certificate files.
186
+ - The relay enforces `rejectUnauthorized: true` for all TLS connections by default.
187
+ - When mTLS is enabled (`SOCKETDUCT_TLS_CLIENT_CA_FILE`), both `requestCert: true` and `rejectUnauthorized: true` are set.
188
+
189
+ ### Insecure public opt-out
190
+
191
+ Binding to a non-loopback address (any interface other than `127.0.0.0/8`, `::1`, or `localhost`) without TLS is refused at startup unless you explicitly opt out:
192
+
193
+ - Set `SOCKETDUCT_ALLOW_INSECURE_PUBLIC=1` or `SOCKETDUCT_ALLOW_INSECURE_PUBLIC=true`.
194
+ - Any other value is rejected with a configuration error.
195
+ - Loopback plaintext binds remain compatible without this flag for local development and testing.
196
+
197
+ ### Client-side TLS
198
+
199
+ The raw client (`connectSocketduct`, `SocketductSocket`, `SocketductHttpAgent`, `createSocketductConnectionFactory`) accepts an optional `tls` configuration for the edge-to-relay connection with:
200
+
201
+ - `caFile`: path to a PEM-encoded CA certificate to verify the relay server certificate.
202
+ - `certFile` and `keyFile`: an atomic pair of PEM-encoded client certificate and private key for mTLS. Both must be set or neither. Used when the relay requires client certificates.
203
+ - `servername`: SNI hostname for certificate identity validation. Defaults to `relayHost` by Node.js but can be overridden when the certificate CN/SAN differs from the connection host.
204
+ - `rejectUnauthorized`: defaults to `true`. Set to `false` only for testing.
205
+
206
+ `SocketductHttpsAgent` uses the clearer name `relayTls` for those relay settings and a separate `targetTls` object for target `ca`, client `cert`/`key`, `rejectUnauthorized`, and certificate `servername`. It never logs certificate or key material. Target verification defaults to strict, client certificate/key must be supplied atomically, and IP identities are verified without sending an IP literal as SNI.
207
+
208
+ Initial target retry does not weaken authorization: the requested destination is authenticated and allowlisted once before the session enters `creating`, and every attempt uses that stored target. No stream frames are accepted before readiness. After `session.created`, target failure and uncertain application writes are terminal to prevent duplicated privileged operations.
209
+
210
+ ### Migration and rollback
211
+
212
+ Protocol v3 migration is explicit because v2 and v3 use different first bytes.
213
+ Set `SOCKETDUCT_PROTOCOL_VERSION=3` on the relay and use
214
+ `MultiplexedSocketductConnection` clients together. Keep the old v2 listener or
215
+ deployment available during a staged migration. Rollback consists of selecting
216
+ version `2` (the runtime default) and switching clients back to
217
+ `connectSocketduct`/the legacy agents. Never attempt an unauthenticated framing
218
+ fallback on the same connection.
219
+
220
+ To migrate an existing plaintext deployment to TLS:
221
+
222
+ 1. Generate a TLS certificate and key pair for the relay hostname.
223
+ 2. Place the certificate and key files where the relay container can read them (e.g., mount into `/certs`).
224
+ 3. Set `SOCKETDUCT_TLS_CERT_FILE` and `SOCKETDUCT_TLS_KEY_FILE` in `.env`.
225
+ 4. Mount the certificate directory in `docker-compose.yml` (`SOCKETDUCT_TLS_CERT_DIR` or the default `./certs`).
226
+ 5. Update edges to use TLS by providing the server CA certificate via `tls.caFile`.
227
+ 6. Set the verified digest-pinned `SOCKETDUCT_IMAGE` and restart the relay with
228
+ `npm run gateway:deploy` (or add `-- --docker-host` when using the private
229
+ Docker socket shim).
230
+ 7. Verify edges connect successfully with TLS.
231
+ 8. Once all edges are on TLS, optionally enable mTLS by setting `SOCKETDUCT_TLS_CLIENT_CA_FILE`.
232
+
233
+ To roll back TLS: remove the TLS env vars from `.env`. If the relay binds to a non-loopback address (`0.0.0.0`), you must set `SOCKETDUCT_ALLOW_INSECURE_PUBLIC=1` to start the relay without TLS on a public interface. For a loopback-only relay, plaintext works without the opt-out flag. Re-apply the previously verified image digest with the deployment command and restart edges without TLS options. Image rollback itself never uses a tag: change `SOCKETDUCT_IMAGE` back to the prior digest and re-apply.
234
+
235
+ ### Threats to explicitly test
236
+
237
+ - unauthenticated session create is rejected
238
+ - wrong password is rejected
239
+ - user A cannot resume user B's session
240
+ - unknown identifier is rejected
241
+ - replaying an old spool file does not duplicate already-acked data
242
+ - spool path traversal is impossible
243
+ - destination not in allowlist is rejected
244
+ - non-loopback public bind without TLS or explicit opt-out is rejected at startup
245
+ - SOCKETDUCT_TLS_CERT_FILE without SOCKETDUCT_TLS_KEY_FILE (or vice versa) fails at config parse
246
+ - wrong CA on the client side is rejected during TLS handshake
247
+ - mTLS with a missing or untrusted client certificate is rejected
248
+ - plaintext clients can still connect to a plaintext relay
249
+ - TLS reconnection/replay preserves spooled data across TLS session resumption
250
+ - unauthenticated connections are terminated after the configurable handshake timeout
251
+ - connections exceeding the per-connection input byte budget before authentication are disconnected
252
+ - an initially unavailable authorized target is retried only before `session.created`, with abort/close returning all resources to baseline
253
+ - target HTTPS rejects a wrong CA or hostname and target mTLS rejects a missing/untrusted client certificate
254
+ - a slow/partial frame (fragmented uint32, incomplete header, incomplete payload) does not accumulate unbounded memory
255
+ - reverse registration rejects wrong principals, duplicate IDs, and stale generations
256
+ - reverse clients reject unknown/unavailable gateway IDs and wrong TLS CAs
257
+ - 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
@@ -0,0 +1,127 @@
1
+ # Session Lifecycle
2
+
3
+ ## Protocol-v3 connection and stream lifecycle
4
+
5
+ V3 separates the physical state (`connecting -> authenticated -> ready ->
6
+ interrupted -> resuming -> ready`) from each virtual stream (`opening -> open ->
7
+ half-closed -> closed`, with terminal `reset`). One transport interruption moves
8
+ all active streams into the same connection-wide resume attempt; it does not
9
+ spawn per-stream reconnects.
10
+
11
+ ## Reverse-gateway lifecycle
12
+
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.
23
+
24
+ The bounded, paginated resume manifest carries stable identifiers and both
25
+ directional acknowledgement positions. The relay returns an independent result
26
+ per stream. A stream omitted because it was closed while disconnected is
27
+ cleaned up at the relay. A target lost during interruption returns a stream-local
28
+ resume reset and a typed session-loss error, while retained siblings continue.
29
+ Only bytes above the peer acknowledgement are replayed.
30
+
31
+ ## States
32
+
33
+ ```text
34
+ creating -> active -> interrupted -> resuming -> active
35
+ | |
36
+ v v
37
+ expired closed
38
+ ```
39
+
40
+ Additional terminal state:
41
+
42
+ ```text
43
+ failed
44
+ ```
45
+
46
+ ## Create
47
+
48
+ 1. Client connects to relay.
49
+ 2. Client authenticates.
50
+ 3. Client requests `session.create` with a human-readable `name` and requested `target` `{host, port}`.
51
+ 4. Server validates the target through the authenticated user's allowlist policy.
52
+ 5. Server creates or accepts a stable opaque `identifier`.
53
+ 6. Server associates the session with the authenticated user and target metadata.
54
+ 7. The authenticated session remains `creating` while the relay opens its target-side socket.
55
+ 8. An initial refusal or per-attempt connect timeout produces no client error or readiness event. The relay retries after approximately 250ms, doubling to a configurable 5s cap while the creating tunnel remains open. Each connect attempt has a separately configurable 10s default bound.
56
+ 9. Once target TCP connect succeeds, the relay transitions the session to `active` and returns `session.created` exactly once.
57
+ 10. The client emits `connect`; only then can stream data reach the target.
58
+
59
+ The authentication deadline is cleared after successful authentication and authorization, before target acquisition. A tunnel close or client abort before `session.created` immediately deletes the unannounced session and cancels its target attempt and retry timer; it is not resumable.
60
+
61
+ This retry is deliberately narrow. It is safe because no application bytes have been accepted by the relay before `session.created`. Once a target has connected and the session has been announced, target close, uncertain writes, overflow, authorization failures, and protocol failures are terminal. Socketduct never reconnects the established target or automatically replays an application operation.
62
+
63
+ ## Normal active streaming
64
+
65
+ - Every data frame has a monotonic sequence.
66
+ - Receiver sends acknowledgements.
67
+ - Sender keeps unacknowledged chunks in memory and/or spool.
68
+ - Acked chunks can be discarded.
69
+
70
+ ## Interruption
71
+
72
+ When the edge loses the WAN tunnel:
73
+
74
+ 1. Edge keeps the app-facing socket open if configured limits allow.
75
+ 2. Edge marks the session `interrupted`.
76
+ 3. Edge writes session metadata and unacknowledged chunks to a temporary spool file.
77
+ 4. Edge reconnects in the background.
78
+ 5. If local buffers fill, edge applies backpressure to the app-facing socket.
79
+ 6. If max interruption time or spool size is exceeded, edge closes the app-facing socket and marks the session `failed` or `expired`.
80
+
81
+ ## Resume
82
+
83
+ 1. Edge reconnects to relay.
84
+ 2. Edge authenticates using token or username/password.
85
+ 3. Edge sends `session.resume` with the existing `identifier`.
86
+ 4. Relay verifies user ownership and session state.
87
+ 5. Relay reattaches the new tunnel socket to the existing session and retained target socket when possible.
88
+ 6. Relay answers with last known sequence/ack positions.
89
+ 7. Edge reads the temporary spool file.
90
+ 8. Edge sends spool metadata and unacknowledged frames from the requested sequence.
91
+ 9. Relay deduplicates already-seen frames.
92
+ 10. Streaming continues.
93
+ 11. Edge truncates/deletes acked spool data.
94
+
95
+ ## Clean close
96
+
97
+ 1. Either side sends `session.close`.
98
+ 2. When the relay processes a client-requested logical close it answers with
99
+ `{"type":"session.close","direction":"target-to-client","acknowledged":true}`.
100
+ A target-originated terminal close omits `acknowledged`; this distinction
101
+ prevents an already-in-flight terminal notification from being mistaken for
102
+ confirmation that a later client close reached the relay.
103
+ 3. Both sides flush pending stream acknowledgements.
104
+ 4. Target/app sockets close.
105
+ 5. Spool files are deleted.
106
+ 6. Session metadata is marked `closed`.
107
+
108
+ ## Expiry
109
+
110
+ A session expires when:
111
+
112
+ - interruption exceeds configured duration
113
+ - spool file exceeds configured age
114
+ - target-side connection closes and cannot be held
115
+ - target hold/resume limits are exceeded
116
+ - user/admin deletes it
117
+ - authentication/token policy invalidates it
118
+
119
+ ## Initial defaults to consider
120
+
121
+ - Max interruption hold time: 60 seconds.
122
+ - Max spool size per session: 64 MiB.
123
+ - Max sessions per user: 32.
124
+ - Initial target acquisition: 250ms -> 500ms -> 1s -> 2s -> 4s -> 5s, capped.
125
+ - Session identifier: UUIDv7 or 128-bit random URL-safe string.
126
+
127
+ These are starting points, not final decisions.