simplify docs

Kim committed Mar 13, 2026 at 11:03 UTC c101e5f223819a80e1a693e2c0787d1b73c4ada6
3 files changed +50 -661
README.md
-2
@@ -31,8 +31,6 @@ Unlike other tunneling services, Portal is self-hosted and permissionless. You c
31 - **Relay**: A server that routes public requests to the right connected app.
32 - **Tunnel**: A CLI agent that proxies your local app through the relay.
33
34 -For details, see [docs/glossary.md](docs/glossary.md).
35 -
34 ## Quick Start
35
36 ### Run Portal Relay
docs/deployment.md
+50 -207
@@ -1,128 +1,28 @@
1 # Portal Relay Deployment Guide
2
3 -This guide covers production deployment of Portal Relay on a public domain.
3 +This guide covers deploying Portal Relay on a public domain with Docker Compose.
4
5 ## 1. Prerequisites
6
7 You need:
8
9 -- A public domain (example: `example.com`)
10 -- A public Linux server with a static public IP
11 -- Open inbound ports: `443/tcp`, `4017/tcp`
9 +- A public domain
10 +- A Linux server with a public IPv4 address, inbound 443/tcp, 4017/tcp
11 - Docker and Docker Compose
13 -- A DNS provider account for ACME DNS-01 automation with a supported provider (`cloudflare` or `route53`)
12 +- `cloudflare` or `route53` credentials for ACME DNS-01
13
15 -## 2. DNS Provider Setup
14 +## 2. DNS Provider and `.env` Setup
15
17 -### 2.1 Choose ACME DNS provider
16 +`PORTAL_URL` sets the relay root host. Portal automatically manages the `A` records and certificates for that host and its wildcard.
17
19 -Set `ACME_DNS_PROVIDER` to one of the currently supported values:
18 +### 2.1 Cloudflare
19
21 -- `ACME_DNS_PROVIDER=cloudflare`, or
22 -- `ACME_DNS_PROVIDER=route53`
23 -
24 -Both providers keep root and wildcard A records synchronized to the relay public IPv4 and use DNS-01 for certificate issuance.
25 -
26 -### 2.2 Cloudflare setup (`ACME_DNS_PROVIDER=cloudflare`)
27 -
28 -#### Add domain to Cloudflare
29 -
30 -1. Cloudflare Dashboard -> `Websites` -> `Add a Site`
31 -2. Enter your domain (`example.com`)
32 -3. Complete onboarding and apply Cloudflare nameservers at your registrar
33 -4. Wait until zone status is `Active`
34 -
35 -#### Create DNS records
36 -
37 -Cloudflare Dashboard -> `DNS` -> `Records`:
38 -
39 -- Record 1 (root host)
40 - - Type: `A`
41 - - Name: `@`
42 - - Content: `<server-ip>`
43 - - Proxy status: `DNS only`
44 -- Record 2 (wildcard)
45 - - Type: `A`
46 - - Name: `*`
47 - - Content: `<server-ip>`
48 - - Proxy status: `DNS only`
49 -
50 -Expected records:
51 -
52 -- `example.com -> <server-ip>`
53 -- `*.example.com -> <server-ip>`
54 -
55 -If you deploy on a non-apex host (for example, `PORTAL_URL=https://portal.example.com:8443`), create host-scoped records instead:
56 -
57 -- `portal.example.com -> <server-ip>`
58 -- `*.portal.example.com -> <server-ip>`
59 -
60 -Portal's relay DNS and certificates cover the normalized `PORTAL_URL` host and its wildcard, and public lease hostnames are published as `<name>.<root host>`.
61 -Requests to the exact root host are not served by the wildcard route; they fall back to the admin/API listener.
62 -
63 -#### Create Cloudflare API token
64 -
65 -Cloudflare Dashboard -> `My Profile` -> `API Tokens` -> `Create Token`.
66 -
67 -Grant:
68 -
69 -- `Zone:Read`
70 -- `DNS:Edit`
71 -
72 -Scope:
73 -
74 -- Zone resources limited to your target zone (for example, `example.com`)
75 -
76 -Save this token for `CLOUDFLARE_TOKEN`.
77 -
78 -### 2.3 Route53 setup (`ACME_DNS_PROVIDER=route53`)
79 -
80 -Create or select a public hosted zone that covers your `PORTAL_URL` root host and provide Route53 write permissions through either static AWS credentials or ambient AWS credentials (for example, an instance role).
81 -
82 -Static credential environment variables:
83 -
84 -- `AWS_ACCESS_KEY_ID`
85 -- `AWS_SECRET_ACCESS_KEY`
86 -- Optional `AWS_SESSION_TOKEN` for temporary credentials
87 -- `AWS_REGION` (for example, `us-east-1`)
88 -
89 -Optional:
90 -
91 -- `AWS_HOSTED_ZONE_ID` (when omitted, relay selects a matching public hosted zone by domain suffix)
92 -
93 -Equivalent relay flags:
94 -
95 -- `--aws-access-key-id`
96 -- `--aws-secret-access-key`
97 -- `--aws-session-token`
98 -- `--aws-region`
99 -- `--aws-hosted-zone-id`
100 -
101 -## 3. Relay Runtime Behavior
102 -
103 -### 3.1 Control Plane and Reverse Sessions
104 -
105 -- `/sdk/register` creates a lease and stores the caller-provided reverse token.
106 -- `/sdk/connect` requires:
107 - - `lease_id` query parameter
108 - - `X-Portal-Token` header
109 - - HTTP/1.1
110 -- `/sdk/renew` and `/sdk/unregister` require `lease_id` + `reverse_token`.
111 -- `/sdk/connect` is hijacked into a long-lived reverse TCP session after validation.
112 -
113 -### 3.2 Certificates and DNS Maintenance
114 -
115 -- Relay certificates live in `KEYLESS_DIR`:
116 - - `fullchain.pem`
117 - - `privatekey.pem`
118 -- On non-localhost deployments, ACME DNS-01 uses the configured supported DNS provider to:
119 - - ensure root and wildcard A records point to the current public IP
120 - - provision the relay certificate
121 - - keep DNS and certificate state refreshed over time
122 -
123 -## 4. Run Relay Server
124 -
125 -### 4.1 Create `.env` at repository root
20 +1. Add your domain to Cloudflare and wait until the zone is `Active`.
21 +2. Create an API token with:
22 + - `Zone:Read`
23 + - `DNS:Edit`
24 + - scope limited to the target zone
25 +3. Create `.env`:
26
27 ```bash
28 PORTAL_URL=https://example.com
@@ -134,121 +34,45 @@ ACME_DNS_PROVIDER=cloudflare
34 CLOUDFLARE_TOKEN=cf_xxxxxxxxxxxxxxxxx
35 ```
36
137 -Route53 example:
37 +### 2.2 Route53
38 +
39 +1. Create or select a public hosted zone for the `PORTAL_URL` host.
40 +2. Prepare AWS credentials with Route53 access.
41 +3. Optional: set `AWS_HOSTED_ZONE_ID` to pin Portal to one hosted zone explicitly.
42 +4. Create `.env`:
43
44 ```bash
45 +PORTAL_URL=https://example.com
46 +BOOTSTRAP_URIS=https://example.com
47 +SNI_PORT=443
48 +ADMIN_SECRET_KEY=your-admin-secret
49 KEYLESS_DIR=./.portal-certs
50 ACME_DNS_PROVIDER=route53
51 AWS_ACCESS_KEY_ID=AKIA...
52 AWS_SECRET_ACCESS_KEY=...
53 AWS_SESSION_TOKEN=...
54 AWS_REGION=us-east-1
146 -# Optional override
55 AWS_HOSTED_ZONE_ID=Z1234567890ABC
56 ```
57
150 -For non-apex deployments, set `PORTAL_URL` and `BOOTSTRAP_URIS` to the same non-apex host value (for example, `https://portal.example.com:8443`).
151 -`PORTAL_URL` path/query segments are ignored for route derivation; only the host component is used.
152 -
153 -If the relay sits behind a reverse proxy or ingress and you want admin/auth and lease IP tracking to use the original client IP, set:
154 -
155 -```bash
156 -TRUST_PROXY_HEADERS=true
157 -```
158 -
159 -By default, forwarded headers are accepted from private, loopback, and link-local proxy source ranges.
160 -If your proxy source addresses are public or you want a stricter allowlist, also set `TRUSTED_PROXY_CIDRS`.
161 -
162 -### 4.2 Start Relay
58 +## 3. Start the Relay
59
60 ```bash
165 -docker compose up
166 -```
167 -
168 -## 5. Auto-Update
169 -
170 -Automatically redeploy when a new `ghcr.io/gosuda/portal:latest` image is pushed.
171 -
172 -### 5.1 Deploy script
173 -
174 -Create `deploy_portal.sh` in your project directory:
175 -
176 -```bash
177 -#!/usr/bin/env bash
178 -set -euo pipefail
179 -
180 -cd "$(dirname "$0")"
181 -
182 -docker compose pull
61 docker compose up -d
184 -docker image prune -f
62 ```
63
187 -### 5.2 Watcher script
188 -
189 -The repository includes `watch_and_deploy.sh`, which polls the remote image digest and runs the deploy script on change.
190 -
191 -Environment variables:
64 +Then open the relay root host in a browser or check `/healthz` once DNS and certificate provisioning are ready.
65
193 -| Variable | Default | Description |
194 -|---|---|---|
195 -| `INTERVAL` | `60` | Poll interval in seconds |
196 -| `DEPLOY_SCRIPT` | `deploy_portal.sh` | Path to deploy script |
197 -| `DIGEST_FILE` | `.portal_image_digest` | File storing the last known digest |
66 +### 3.1 Advanced
67
199 -### 5.3 Register as systemd service
68 +Use the example folders under `examples/` as the source of truth for deployment layouts and automation:
69
201 -Set `WorkingDirectory` and `ExecStart` to the directory where `watch_and_deploy.sh` and `deploy_portal.sh` are located:
70 +- Single-service nginx reverse proxy and deployment automation: [examples/nginx-proxy](examples/nginx-proxy)
71 +- Multi-service nginx reverse proxy: [examples/nginx-proxy-multi-service](examples/nginx-proxy-multi-service)
72
203 -```bash
204 -sudo tee /etc/systemd/system/portal-watcher.service << 'EOF'
205 -[Unit]
206 -Description=Portal Docker Image Watcher
207 -After=network-online.target docker.service
208 -Wants=network-online.target
209 -Requires=docker.service
210 -
211 -[Service]
212 -Type=simple
213 -User=opc
214 -# Set to the directory containing watch_and_deploy.sh and deploy_portal.sh
215 -WorkingDirectory=<path-to-project>
216 -ExecStart=/bin/bash <path-to-project>/watch_and_deploy.sh
217 -Restart=always
218 -RestartSec=10
219 -Environment=INTERVAL=60
220 -Environment=DEPLOY_SCRIPT=deploy_portal.sh
221 -
222 -[Install]
223 -WantedBy=multi-user.target
224 -EOF
225 -
226 -sudo systemctl daemon-reload
227 -sudo systemctl enable --now portal-watcher
228 -```
73 +## 4. Basic Troubleshooting
74
230 -Adjust `User` to match your environment. Ensure the user belongs to the `docker` group:
231 -
232 -```bash
233 -sudo usermod -aG docker opc
234 -```
235 -
236 -### 5.4 Verify and monitor
237 -
238 -```bash
239 -# Service status
240 -sudo systemctl status portal-watcher
241 -
242 -# Live logs
243 -sudo journalctl -u portal-watcher -f
244 -
245 -# Today's logs only
246 -sudo journalctl -u portal-watcher --since today
247 -```
248 -
249 -## 6. Troubleshooting
250 -
251 -### 6.1 Ports blocked
75 +### 4.1 Firewall
76
77 Required inbound ports:
78
@@ -262,3 +86,22 @@ sudo ufw allow 443/tcp
86 sudo ufw allow 4017/tcp
87 sudo ufw status
88 ```
89 +
90 +### 4.2 DNS and Certificate Checks
91 +
92 +- Make sure `PORTAL_URL` matches the host and hosted zone you expect Portal to manage.
93 +- Make sure the configured Cloudflare token or AWS credentials can update DNS for that host.
94 +- For Cloudflare, make sure the target zone is `Active`.
95 +- If certificate issuance is still in progress, watch `docker compose logs -f` until ACME completes.
96 +
97 +### 4.3 Non-Apex and Proxy Setups
98 +
99 +- For non-apex deployments, set `PORTAL_URL` and `BOOTSTRAP_URIS` to the same non-apex host value such as `https://portal.example.com:8443`.
100 +- `KEYLESS_DIR` stores the relay certificate material as `fullchain.pem` and `privatekey.pem`.
101 +- If the relay sits behind a reverse proxy or ingress and you want admin/auth and lease IP tracking to use forwarded client addresses, set:
102 +
103 +```bash
104 +TRUST_PROXY_HEADERS=true
105 +```
106 +
107 +- If your trusted proxy source addresses are public or you want a stricter allowlist, also set `TRUSTED_PROXY_CIDRS`.
docs/greenfield-raw-tcp-sni-keyless.md deleted
-452
@@ -1,452 +0,0 @@
1 -# Greenfield Design: Raw TCP Reverse-Connect + SNI Passthrough + Keyless TLS
2 -
3 -## Status
4 -
5 -Greenfield design. This document is intentionally not constrained by the current implementation, test suite, or ADR set. It describes the replacement system as if built from scratch.
6 -
7 -## Scope
8 -
9 -Keep only these product properties:
10 -
11 -- Raw TCP reverse-connect from backend to relay
12 -- SNI-based tenant routing
13 -- Tenant TLS passthrough end-to-end
14 -- Keyless TLS for relay-owned admin/API TLS
15 -
16 -Everything else is redesignable.
17 -
18 -## Goals
19 -
20 -- One clear state owner per lease
21 -- No fixed worker pools
22 -- No shared global reverse queue logic beyond lease lookup
23 -- Deterministic connection lifecycle
24 -- Easy to instrument and debug
25 -- Backpressure and failure behavior defined up front
26 -
27 -## Non-Goals
28 -
29 -- Backward compatibility with the current internal implementation
30 -- Multiple transport modes
31 -- Relay-side tenant TLS termination
32 -- WebSocket support
33 -- HTTP/2 support
34 -- HTTP/1 and HTTP/2 abstraction at the tunnel layer
35 -
36 -## High-Level Model
37 -
38 -The system has four runtime components:
39 -
40 -1. `ControlPlane`
41 - Validates registration, renewal, unregister, and reverse-connect admission.
42 -
43 -2. `RouteTable`
44 - Maps SNI hostnames to `lease_id`.
45 -
46 -3. `LeaseBroker`
47 - One broker per lease. Owns all idle reverse connections for that lease.
48 -
49 -4. `LeaseAgent`
50 - One agent per SDK listener. Keeps a small target number of idle reverse connections available at the relay.
51 -
52 -The key simplification is this:
53 -
54 -- Relay global state only resolves `lease_id -> LeaseBroker`
55 -- All reverse connection lifecycle for a lease is owned by that broker
56 -- SDK global state only owns `listener -> LeaseAgent`
57 -- All reverse session lifecycle for a listener is owned by that agent
58 -
59 -## Connection Roles
60 -
61 -### 1. Browser to Relay
62 -
63 -- TCP to relay SNI listener
64 -- Relay peeks ClientHello
65 -- SNI resolves to `lease_id`
66 -- Relay claims one idle reverse connection from that lease broker
67 -- Relay writes `TLSStartMarker`
68 -- Relay bridges raw TCP in both directions
69 -
70 -Relay never terminates tenant TLS.
71 -
72 -### 2. SDK to Relay
73 -
74 -- TCP + TLS to admin/API listener
75 -- HTTP `GET /sdk/connect?lease_id=...`
76 -- Reverse token in header
77 -- On success, connection becomes an idle reverse session owned by the lease broker
78 -
79 -### 3. Admin/API TLS
80 -
81 -- Relay terminates root-domain TLS only
82 -- Private key stays behind keyless signer
83 -
84 -## HTTP Version Policy
85 -
86 -Use HTTP/1.1 only everywhere HTTP exists in the system.
87 -
88 -### Relay-Owned HTTP
89 -
90 -- Admin/API listener is HTTP/1.1 only
91 -- `/sdk/connect` is HTTP/1.1 only
92 -- No HTTP/2 on relay listeners
93 -
94 -Reason:
95 -
96 -- `/sdk/connect` depends on connection hijacking semantics
97 -- HTTP/2 adds stream multiplexing with no value for this design
98 -- HTTP/2 makes connection behavior harder to reason about operationally
99 -
100 -### Tenant-Facing HTTP
101 -
102 -Tenant traffic still uses raw TCP + TLS passthrough, so the relay does not enforce HTTP version directly.
103 -
104 -Instead, the tenant-side TLS terminator used by the SDK/tunnel must advertise only:
105 -
106 -- `http/1.1`
107 -
108 -It must not advertise:
109 -
110 -- `h2`
111 -
112 -Reason:
113 -
114 -- HTTP/2 connection coalescing can reuse one TLS connection across multiple subdomains when certificate coverage overlaps
115 -- This design routes once per TCP/TLS connection using SNI
116 -- If multiple origins ride the same HTTP/2 connection, later requests can bypass per-origin SNI routing decisions
117 -- Shared wildcard certificates across tenant subdomains make that risk worse
118 -
119 -This greenfield design therefore chooses a hard rule:
120 -
121 -- SNI passthrough routing plus shared subdomain certificate coverage implies HTTP/1.1 only
122 -
123 -If future work wants tenant HTTP/2, it must first remove cross-tenant certificate overlap or redesign routing away from single-handshake SNI ownership.
124 -
125 -## Per-Lease Relay Design
126 -
127 -`LeaseBroker` is the only owner of reverse-session state for a lease.
128 -
129 -```text
130 -LeaseBroker
131 - lease_id
132 - state: open | closed
133 - ready queue: bounded FIFO of idle reverse sessions
134 - metrics: ready_count, claim_wait_duration
135 -```
136 -
137 -### LeaseBroker API
138 -
139 -- `Offer(session) error`
140 -- `Claim(ctx) (*ReverseSession, error)`
141 -- `Close()`
142 -
143 -### Rules
144 -
145 -- `Offer` rejects immediately if broker is closed
146 -- `Claim` blocks until a valid idle session is available or timeout/cancel fires
147 -- `Close` drains the ready queue, closes all idle sessions, and wakes blocked claimers
148 -
149 -No separate global `dropped` map exists outside the broker.
150 -
151 -## Reverse Session Design
152 -
153 -`ReverseSession` is a single reverse TCP connection plus its state.
154 -
155 -```text
156 -states:
157 - connecting
158 - admitted
159 - idle
160 - claimed
161 - bridged
162 - closed
163 -```
164 -
165 -### State Transitions
166 -
167 -- SDK connects: `connecting -> admitted`
168 -- Broker accepts idle session: `admitted -> idle`
169 -- SNI path claims it: `idle -> claimed`
170 -- Relay writes `TLSStartMarker`: `claimed -> bridged`
171 -- Any close/error: `* -> closed`
172 -
173 -### Session Rules
174 -
175 -- Keepalive is allowed only in `idle`
176 -- Control marker write is allowed only in `claimed`
177 -- Session close is idempotent
178 -- Close unblocks any waiter
179 -
180 -## SDK Design
181 -
182 -`LeaseAgent` replaces fixed reverse worker pools.
183 -
184 -```text
185 -LeaseAgent
186 - lease
187 - relay_url
188 - target_ready
189 - current_sessions
190 - paused: bool
191 - state: running | paused | stopped
192 -```
193 -
194 -### LeaseAgent Behavior
195 -
196 -- Maintain `target_ready` idle reverse sessions
197 -- Default `target_ready = 1`
198 -- Optional burst mode may raise to `2` or `4` based on recent claim rate
199 -- No fixed fan-out like `16 workers`
200 -
201 -### LeaseAgent Loop
202 -
203 -1. If `current_sessions < target_ready`, open one reverse session
204 -2. Complete reverse-connect handshake
205 -3. Wait for `TLSStartMarker`
206 -4. Run backend TLS handshake locally
207 -5. Deliver accepted connection to app `Accept()`
208 -6. Decrement active session count
209 -7. Replenish one new idle session
210 -
211 -This is slot-based, not worker-based.
212 -
213 -## Protocol
214 -
215 -Keep the binary markers minimal:
216 -
217 -- `0x00`: idle keepalive
218 -- `0x02`: activate TLS passthrough
219 -
220 -No non-TLS tenant mode.
221 -
222 -### Reverse Connect Admission
223 -
224 -Admission order remains strict:
225 -
226 -1. IP policy
227 -2. Lease existence/state
228 -3. Reverse token
229 -
230 -Only admitted sessions can enter `LeaseBroker.Offer`.
231 -
232 -## API Surface
233 -
234 -Keep the existing control-plane shape, simplified internally:
235 -
236 -- `POST /sdk/register`
237 -- `GET /sdk/connect?lease_id=...`
238 -- `POST /sdk/renew`
239 -- `POST /sdk/unregister`
240 -- `GET /sdk/domain`
241 -- `POST /v1/sign`
242 -
243 -All HTTP endpoints in this list are HTTP/1.1 only.
244 -
245 -### Register
246 -
247 -- Requires `name` and `reverse_token`
248 -- Creates or resets lease broker
249 -- Registers route in `RouteTable`
250 -
251 -### Connect
252 -
253 -- Admits request
254 -- Hijacks TCP connection
255 -- Creates `ReverseSession`
256 -- Offers it into the lease broker
257 -- Blocks until session is claimed or closed
258 -
259 -### Renew
260 -
261 -- Extends lease TTL
262 -- Keeps broker active
263 -
264 -### Unregister
265 -
266 -- Drops broker
267 -- Removes route
268 -- Deletes lease record
269 -
270 -## Routing
271 -
272 -`RouteTable` owns only routing data:
273 -
274 -- exact match
275 -- single-label wildcard match
276 -- portal-root fallback
277 -
278 -It does not own reverse session state.
279 -
280 -On tenant SNI hit:
281 -
282 -1. Resolve `lease_id`
283 -2. Lookup broker
284 -3. `Claim(ctx)`
285 -4. Write `TLSStartMarker`
286 -5. Bridge raw TCP
287 -
288 -## Shutdown Semantics
289 -
290 -### Lease Drop
291 -
292 -- New reverse offers rejected
293 -- Ready queue drained
294 -- In-flight bridged sessions continue until close
295 -
296 -### Process Stop
297 -
298 -- Control-plane listener stops admitting new reverse sessions
299 -- SNI listener stops admitting new browser sessions
300 -- All brokers enter stopped state
301 -- Idle sessions close immediately
302 -- Bridged sessions close on listener shutdown or peer close
303 -
304 -## Failure Handling
305 -
306 -### Reverse Connect Rejection
307 -
308 -Fatal:
309 -
310 -- banned IP
311 -- missing lease
312 -- invalid token
313 -- unsupported transport
314 -
315 -Transient:
316 -
317 -- relay restart
318 -- temporary dial failure
319 -- temporary route churn
320 -
321 -### SDK Agent Rules
322 -
323 -- Fatal rejection pauses the agent
324 -- Successful renew/register clears pause
325 -- Transient failure retries with bounded backoff
326 -
327 -## Backpressure
328 -
329 -Per-lease ready queue is bounded.
330 -
331 -Recommended defaults:
332 -
333 -- `target_ready = 1`
334 -- `ready_queue_capacity = 8`
335 -- temporary burst increase to `2` or `4`
336 -
337 -If the ready queue is full:
338 -
339 -- Evict the oldest idle session
340 -- Never evict a claimed or bridged session
341 -
342 -## Observability
343 -
344 -Every reverse session gets a `conn_id`.
345 -
346 -Required structured logs:
347 -
348 -- reverse admitted
349 -- reverse offered
350 -- reverse claimed
351 -- TLS marker sent
352 -- bridge started
353 -- bridge ended
354 -- session evicted
355 -- broker closed
356 -- fatal agent pause
357 -
358 -Core metrics:
359 -
360 -- ready sessions per lease
361 -- claim wait duration
362 -- reverse connect failures by reason
363 -- session lifetime by state
364 -- first-claim latency after register
365 -
366 -## Package Shape
367 -
368 -Suggested greenfield package split:
369 -
370 -```text
371 -portal/controlplane
372 -portal/routing
373 -portal/broker
374 -portal/session
375 -portal/keyless
376 -sdk/agent
377 -sdk/listener
378 -```
379 -
380 -### Ownership
381 -
382 -- `controlplane`: admission and API handlers
383 -- `routing`: SNI lookup only
384 -- `broker`: lease-local ready queue and lifecycle
385 -- `session`: reverse session state machine
386 -- `sdk/agent`: maintain target idle sessions
387 -- `sdk/listener`: app-facing `net.Listener`
388 -
389 -## Minimal Relay Pseudocode
390 -
391 -```text
392 -on_sni_client(server_name):
393 - lease_id = route_table.lookup(server_name)
394 - broker = brokers.get(lease_id)
395 - session = broker.claim(ctx)
396 - session.send_tls_start()
397 - bridge(client_conn, session.conn)
398 -```
399 -
400 -```text
401 -on_sdk_connect(lease_id, token, conn):
402 - admit(lease_id, token, client_ip)
403 - session = new_reverse_session(conn)
404 - broker = brokers.get_or_create(lease_id)
405 - broker.offer(session)
406 - wait_until_claimed_or_closed(session)
407 -```
408 -
409 -## Minimal SDK Pseudocode
410 -
411 -```text
412 -run_agent():
413 - while running:
414 - if active_sessions < target_ready:
415 - spawn open_one_session()
416 - wait_for_signal()
417 -```
418 -
419 -```text
420 -open_one_session():
421 - conn = dial_relay()
422 - do_tls()
423 - do_http_connect()
424 - wait_for_tls_start_marker()
425 - tls_server_handshake()
426 - deliver_to_accept_queue()
427 -```
428 -
429 -## Why This Is Simpler
430 -
431 -- Lease-local ownership replaces mixed global and per-connection state
432 -- Slot-based replenishment replaces fixed worker pools
433 -- Reverse lifecycle becomes a state machine instead of loosely coupled goroutines
434 -- Routing, admission, and reverse session storage are separated cleanly
435 -
436 -## Recommended Implementation Order
437 -
438 -1. Build `LeaseBroker` and `ReverseSession` as isolated packages
439 -2. Build SDK `LeaseAgent` with target-ready semantics
440 -3. Replace relay reverse path behind feature flag or alternate binary
441 -4. Reconnect SNI router to broker claims
442 -5. Reconnect control-plane handlers
443 -6. Add observability before traffic testing
444 -
445 -## Cutover Strategy
446 -
447 -Best option: separate greenfield binary or branch, not incremental mutation of the current reverse path.
448 -
449 -Reason:
450 -
451 -- The main value of this design is deleting hidden lifecycle coupling
452 -- Partial migration would preserve too much of the old complexity