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