fix AGENTS and docs

Kim committed Mar 6, 2026 at 14:43 UTC 6e3831dad944d083bfbd699b5ae0889588cb1a32
7 files changed +277 -208
AGENTS.md
+50 -54
@@ -6,91 +6,85 @@ This file is a high-signal rulebook for future agents.
6 Include only constraints that are expensive to rediscover from quick code search.
7
8 Source of truth for architecture decisions: `docs/adr/README.md` and linked ADRs.
9 +Descriptive docs under `docs/` should match current code paths.
10
11 ## Non-Negotiable Architecture Invariants
12
13 1. **Raw TCP reverse-connect is the canonical transport.**
13 - - Why: ADR-0001 and ADR-0002 accepted this to keep NAT-friendly behavior and reduce protocol complexity.
14 + - Why: keeps backend connectivity NAT-friendly and avoids parallel transport stacks.
15
16 2. **Do not introduce websocket or legacy compatibility paths unless a new ADR supersedes ADR-0002.**
17 - Why: dual transport paths increase security and test surface and reintroduce drift.
18
18 -3. **Derive routing hostnames from full portal root host in `PORTAL_URL` (supports non-apex), not apex extraction.**
19 - - Why: prevents SNI/public URL mismatches in non-apex deployments (ADR-0001).
19 +3. **Derive routing hostnames from the full portal root host in `PORTAL_URL` (supports non-apex), not apex extraction.**
20 + - Why: prevents SNI/public URL mismatches in non-apex deployments.
21
21 -4. **Keep explicit root-domain fallback behavior through SNI no-route handling to admin/API listener.**
22 - - Why: preserves intended control-plane vs tenant routing split (ADR-0001).
22 +4. **Keep explicit root-domain fallback behavior through SNI no-route handling to the admin/API listener.**
23 + - Why: preserves the intended split between root-host control-plane traffic and tenant subdomain traffic.
24
24 -5. **All leases require TLS=true.** The register endpoint rejects `TLS=false`. The `RegisterRequest.TLS` field exists but non-TLS leases are not permitted.
25 - - Why: enforces end-to-end transport security for all tenant routes.
26 -
27 -## Security and Anti-Abuse Invariants
28 -
29 -1. **Admin-managed policy is authoritative for runtime security controls.**
30 - - Why: ADR-0003 requires central policy ownership to avoid endpoint-local drift.
31 -
32 -2. **Do not rely on single-endpoint checks for abuse controls.**
33 - - Why: ADR-0003 expects enforcement across critical ingress paths (registration and reverse admission class paths).
34 -
35 -3. **Reverse connection authorization must remain lease-token validated before bridge/forwarding.**
36 - - Why: prevents unauthorized tunnel attachment (ADR-0003).
37 -
38 -4. **`/sdk/connect` must share the same policy source as `/sdk/register`.**
39 - - Why: registration and reverse admission must apply identical IP-ban + token checks in one enforcement pipeline.
25 +5. **All leases require `TLS=true`.** The register endpoint rejects `TLS=false`.
26 + - Why: all tenant routes are expected to stay on the TLS passthrough path.
27
28 ## TLS and Identity Invariants
29
43 -1. **Relay terminates admin/API TLS directly and also exposes `/v1/sign` as the keyless signer for tenant passthrough TLS.** The relay still does not terminate tenant traffic; it peeks ClientHello for SNI and bridges raw encrypted bytes, while the backend/tunnel endpoint performs the handshake using a `RemoteSigner`.
44 - - Why: preserves SNI passthrough data flow while centralizing certificate signing behind the relay keyless endpoint.
30 +1. **Relay terminates admin/API TLS on the root host and also exposes `/v1/sign` for tenant-side keyless signing.**
31 + - Relay still does not terminate tenant TLS. It peeks ClientHello for SNI and bridges raw encrypted bytes.
32 + - SDK/tunnel endpoints terminate tenant TLS locally with a keyless-backed signer that calls the relay.
33
46 -2. **/sdk/* control-plane auth is token.** Admission order is IP ban -> Lease -> Token.
47 - - Admin/API TLS listener does not request client certificates.
48 - - Keyless TLS (RemoteSigner for /v1/sign) remains independent and is still used for admin/API TLS termination.
49 - - Why: removes browser client-cert prompt side effects while keeping centralized token/IP policy enforcement.
34 +2. **`/sdk/connect`, `/sdk/renew`, and `/sdk/unregister` are authorized by lease existence plus reverse token.**
35 + - `/sdk/register` requires the caller to provide a reverse token for later use, but registration itself is not separately authenticated by that token.
36
51 -3. **All relay URLs must be `https://`.** `NormalizeRelayAPIURL` rejects non-HTTPS. SDK and tunnel hard-fail on `http://`.
52 - - Why: enforces transport security without opt-out.
37 +3. **All relay URLs must be `https://`.**
38 + - Why: SDK and tunnel are expected to hard-fail on insecure relay URLs.
39
54 -4. **`keyless_tls/` is published to `github.com/gosuda/keyless_tls`** and vendored as a directory for co-development. Root `go.mod` pins a specific pseudo-version or tag — no `replace` directive.
55 - - Why: enables external consumers while keeping co-development convenient. Pin version after pushing upstream changes.
40 +4. **HTTP/2 is intentionally disabled on the admin/API TLS listener.**
41 + - Why: `/sdk/connect` depends on HTTP/1.1 hijacking semantics.
42
57 -## SNI Routing Invariants
43 +## Reverse Session Protocol
44
59 -1. **SNI wildcard matching is one-level only.** `sni.Router.GetRoute()` checks `*.parent.example.com` for `foo.parent.example.com` — not arbitrary depth.
60 - - Why: matches RFC TLS wildcard semantics.
45 +1. **SNI wildcard matching is one-level only.**
46 + - `*.parent.example.com` matches `foo.parent.example.com`, not arbitrary depth.
47
62 -2. **Protocol markers on reverse TCP connections:** `0x00` = keepalive, `0x01` = non-TLS start, `0x02` = TLS passthrough activation. The SNI router does true TLS passthrough — it peeks the ClientHello for routing, then bridges the raw encrypted connection without terminating TLS.
63 - - Why: binary protocol, not discoverable from HTTP-layer code.
48 +2. **Protocol markers on reverse TCP connections are still meaningful protocol state.**
49 + - `0x00` = idle keepalive
50 + - `0x02` = TLS passthrough activation
51 + - Why: SDK waits on these bytes before switching an idle reverse session into a claimed tenant TLS session.
52
65 -3. **HTTP/2 is intentionally disabled on the admin HTTP server** (`TLSNextProto: make(…)`).
66 - - Why: the server hijacks connections for `/sdk/connect`; HTTP/2 multiplexing breaks hijack semantics.
53 +3. **`/sdk/connect` must remain HTTP/1.1 only.**
54 + - Why: hijacking and long-lived reverse sessions depend on HTTP/1.1 connection ownership.
55
56 ## API Response Contract
57
70 -1. **All HTTP responses use the `APIEnvelope` wrapper:** `{ ok: bool, data?: any, error?: { code, message } }` (defined in `types/api.go`). Write responses through `writeAPIData()`, `writeAPIOK()`, or `writeAPIError()` helpers — never raw JSON.
71 - - Why: cross-cutting contract across all endpoints; inconsistent envelopes break SDK and frontend parsing.
58 +1. **All JSON control-plane responses use the `APIEnvelope` wrapper:** `{ ok: bool, data?: any, error?: { code, message } }` (defined in `types/api.go`).
59 + - Write responses through `writeAPIData()`, `writeAPIOK()`, or `writeAPIError()`.
60 + - Admin HTML pages, tunnel script/binary responses, and other non-JSON endpoints are exceptions.
61
62 ## Shared Types Package
63
75 -1. **`types/` is reserved for shared wire/public types and protocol constants only.**
76 - - Allowed: request/response DTOs, shared metadata structs, protocol marker/header/path constants.
64 +1. **`types/` is reserved for shared wire/public types and cross-package constants only.**
65 + - Allowed: request/response DTOs, shared metadata structs, protocol markers, shared headers, shared public path constants.
66 - Not allowed: relay runtime state, broker/session state, server config, SDK lifecycle state, generic helpers.
67
79 -2. **Do not import `portal` from `cmd/*` or `sdk` just to reach shared DTOs or protocol constants.**
68 +2. **Shared control-plane and public route constants that cross package boundaries belong in `types/paths.go`.**
69 + - Examples: `/sdk/*`, `/v1/sign`, `/healthz`, `/admin`, `/admin/leases`, `/tunnel`.
70 +
71 +3. **Relay-local frontend asset paths stay local to `cmd/relay-server`.**
72 + - Why: filenames like `favicon.svg` or `portal.jpg` are frontend serving details, not cross-package API contract.
73 +
74 +4. **Do not import `portal` from `cmd/*` or `sdk` just to reach shared DTOs or constants.**
75 - Why: `portal` is relay runtime code; shared public shapes belong in `types/`.
76
77 ## Operational Truths (CI-Aligned, Minimal)
78
84 -1. **CI verification commands:** `make vet`, `make lint`, `make test`, `make vuln`. These are the enforced checks in `.github/workflows/ci.yml`. Note: `make tidy` is a local maintenance/pre-release step, not part of CI.
85 -
86 -2. **`make build-server` does NOT call `make build-frontend`.** If called alone, `//go:embed dist/*` will be stale or empty. The Dockerfile calls both explicitly in order.
87 - - Why: prevents silent broken builds with missing frontend assets.
79 +1. **CI verification commands:** `make vet`, `make lint`, `make test`, `make vuln`.
80 + - `make tidy` is a local maintenance step, not part of CI.
81
89 -3. **`admin_settings.json` persists in the process CWD**, not in `KEYLESS_DIR`. State is lost on container restart unless CWD is a mounted volume.
90 - - Why: prevents state-loss surprises in production.
82 +2. **`make build-server` does not build the frontend first.**
83 + - Why: `cmd/relay-server/dist/*` is embed input; build the frontend explicitly before packaging the relay binary.
84
92 -4. **`onLeaseDeleted` has dual registration.** `portal/relay.go` registers one callback; `cmd/relay-server/main.go` overwrites it with a broader one (adds IP/BPS cleanup). The outer callback supersedes.
93 - - Why: coupling hazard — modifying either registration without understanding both breaks cleanup.
85 +3. **ACME management keeps both root and wildcard DNS A records in sync for non-localhost deployments.**
86 + - Certificates and keys live under `KEYLESS_DIR` as `fullchain.pem` and `privatekey.pem`.
87 + - Localhost uses the development certificate path instead of Cloudflare-managed ACME.
88
89 ## Change Discipline
90
@@ -102,16 +96,18 @@ Source of truth for architecture decisions: `docs/adr/README.md` and linked ADRs
96
97 ## Go Conventions
98
105 -**Imports:** stdlib → external → internal (blank-line separated), local prefix `github.com/gosuda`. **Concurrency:** `errgroup.Group` over `WaitGroup` · `errgroup.SetLimit` for bounded work · `context.WithTimeout` over `time.After` in loops (timer leak) · no bare `go func()` — creator owns lifecycle. **I/O:** `os.Root` (Go 1.24+) for directory-scoped file operations.
99 +**Imports:** stdlib -> external -> internal (blank-line separated), local prefix `gosuda.org/portal`.
100 +**Concurrency:** `errgroup.Group` over `WaitGroup`; `errgroup.SetLimit` for bounded work; `context.WithTimeout` over `time.After` in loops; no bare `go func()` without owned lifecycle.
101 +**I/O:** prefer directory-scoped operations when touching filesystem trees.
102
103 ---
104
105 ## Agent Behavior
106
111 -**Verbalized sampling:** Before changes, sample 3–5 intent hypotheses (rank by likelihood, note one weakness each), assess coupling (structural/temporal/semantic), and tidy-first when coupling is high. Ask the human when trade-offs exist.
107 +**Verbalized sampling:** Before changes, sample 3-5 intent hypotheses (rank by likelihood, note one weakness each), assess coupling (structural/temporal/semantic), and tidy-first when coupling is high. Ask the human when trade-offs exist.
108
109 **Project rules:**
110 - No backward compatibility unless explicitly requested.
111 - No wrapper functions without demonstrated value.
116 -- Consolidate changes in one pass — do not stack minimal patches.
112 +- Consolidate changes in one pass; do not stack minimal patches.
113 - Run tests only when requested, before handoff, or for high-risk changes.
docs/adr/0003-security-and-anti-abuse-hardening.md
+4 -1
@@ -1,6 +1,6 @@
1 # ADR 0003: Security and Anti-Abuse Hardening
2
3 -- Status: `Accepted`
3 +- Status: `Deprecated`
4 - Date: `2026-03-03`
5 - Owners: `Portal maintainers`
6
@@ -8,6 +8,9 @@
8
9 Portal accepts unauthenticated internet traffic on relay/admin edges while managing long-lived reverse tunnel sessions. Abuse controls and security boundaries must be first-class or operational risk rises quickly.
10
11 +This ADR captured a broader policy/bans/admin-settings model than the current runtime now implements.
12 +Keep it as historical context only. Current descriptive docs and `AGENTS.md` are the source for the simplified runtime behavior until a replacement ADR is written.
13 +
14 ## Decision
15
16 - Treat admin-authenticated controls (approval, settings, bans) as authoritative for runtime policy.
docs/adr/README.md
+1 -1
@@ -13,7 +13,7 @@ This directory is the source of truth for major Portal architecture decisions.
13
14 - [0001 - Raw TCP Reverse Connect and ACME TLS for Portal Root](./0001-raw-tcp-reverse-connect-and-autocert-tls.md) (`Accepted`)
15 - [0002 - Remove WebSocket and Legacy Compatibility Paths](./0002-remove-websocket-and-legacy-compatibility.md) (`Accepted`)
16 -- [0003 - Security and Anti-Abuse Hardening](./0003-security-and-anti-abuse-hardening.md) (`Accepted`)
16 +- [0003 - Security and Anti-Abuse Hardening](./0003-security-and-anti-abuse-hardening.md) (`Deprecated`)
17
18 ## Authoring Notes
19
docs/architecture.md
+125 -81
@@ -2,144 +2,188 @@
2
3 ## Overview
4
5 -Portal is a relay network for publishing local services on public subdomains.
6 -The relay is both the control plane and routing plane. Service backends connect outward to the relay (NAT-friendly), and clients connect to the relay domain.
5 +Portal publishes local services on public subdomains through a relay.
6 +Backends connect outward to the relay, the relay routes inbound client traffic by SNI, and tenant TLS remains end-to-end between the browser and the SDK/tunnel endpoint.
7
8 High-level path:
9
10 ```text
11 Client (Browser)
12 - -> Relay (:443 SNI router or :4017 HTTP/API)
13 - -> Reverse tunnel connection
14 - -> Local service (App/Tunnel host)
12 + -> Relay SNI listener (:443 by default)
13 + -> Claimed reverse session
14 + -> SDK / portal-tunnel
15 + -> Local service
16 ```
17
17 -## Connection Responsibilities
18 +## Connection Model
19
19 -- Conn #1 (`browser -> app`) is the tenant-facing data plane.
20 - - Data-plane TLS behavior remains end-to-end between client and app/tunnel host.
21 - - Relay forwards tenant traffic and does not replace app identity policy.
22 -- Conn #2 (`relay -> tunnel`) is the control plane.
23 - - `/sdk/register`, `/sdk/connect`, `/sdk/renew`, and `/sdk/unregister` use token-based control-plane admission.
24 - - Control-plane admission order is strict and deterministic: `IP -> Lease -> Token`.
25 - - Client certificates are not part of runtime control-plane admission.
20 +Portal has two distinct network roles:
21 +
22 +- **Control-plane HTTP requests**
23 + - `POST /sdk/register`
24 + - `POST /sdk/renew`
25 + - `POST /sdk/unregister`
26 + - `GET /sdk/domain`
27 +- **Reverse session connection**
28 + - `GET /sdk/connect?lease_id=...`
29 + - HTTP/1.1 only
30 + - hijacked into a long-lived raw TCP session
31 + - starts idle in the relay broker, then becomes the tenant data path when claimed
32 +
33 +That distinction matters because `/sdk/connect` stops being ordinary HTTP once hijacked.
34
35 ## Core Components
36
37 ### Relay Server (`cmd/relay-server`)
38
31 -- API/Admin server on `--adminport` (default `:4017`)
32 -- SNI router on `--sni-port` (default `:443`)
33 -- SDK registry endpoints under `/sdk/*`
34 -- Keyless signer endpoint at `/v1/sign` (when signer is configured)
39 +- Admin/API TLS listener on `--adminport` (default `:4017`)
40 +- SNI listener on `--sni-port` (default `:443`)
41 +- Public frontend routes under `/`, `/app`, `/assets/*`
42 +- Minimal admin surface at `/admin` and `/admin/leases`
43 +- Tunnel bootstrap routes at `/tunnel` and `/tunnel/bin/*`
44 +- Keyless signer endpoint at `/v1/sign`
45
46 ### Relay Core (`portal/`)
47
38 -- `LeaseManager`: lease registration, renew TTL, ban/list policy
39 -- `ReverseHub`: authenticated reverse connection pool per lease
40 -- `sni.Router`: TCP listener that peeks SNI and routes to lease backends
41 -- `acme` + `keyless`: ACME provisioning and remote signing support
42 -
43 -Anti-abuse policy is driven from admin-managed state and applied consistently for both registration and reverse admission.
48 +- `Server`: owns listeners, lease registry, API handlers, and shutdown lifecycle
49 +- `routeTable`: exact + single-label wildcard hostname lookup
50 +- `leaseBroker`: per-lease ready queue for reverse sessions
51 +- `reverseSession`: idle keepalive + activation state machine for one reverse TCP connection
52 +- `acme`: Cloudflare DNS sync + certificate provisioning/renewal for the relay root host and wildcard
53 +- `keyless`: admin/API TLS attach helpers and tenant-side signer integration
54
55 ### SDK (`sdk/`)
56
47 -- `Client`: bootstrap relay URLs and optional TLS/keyless setup
48 -- `Listener`: relay-backed `net.Listener` used by apps/tunnel clients
49 -- Shared API types and paths (`/sdk/register`, `/sdk/renew`, etc.)
57 +- `Client`: validates relay URL, owns HTTP client and raw TLS dial config
58 +- `Listener`: registers a lease, maintains `readyTarget` reverse sessions, renews lease TTL, and yields accepted tenant TLS connections
59 +- Tenant TLS is created automatically through the relay keyless signer; callers do not provide a local self-signed fallback path
60
61 ### Tunnel (`cmd/portal-tunnel`)
62
53 -- CLI proxy for existing local apps without code changes
54 -- Registers lease via SDK and forwards relay traffic to local `--host`
63 +- Registers a lease through the SDK
64 +- Accepts claimed tenant connections from the relay
65 +- Proxies raw TCP to a local `--host`
66 +- Returns an HTTP 503 response when the local target is unavailable
67
68 ## Transport Model
69
58 -### Raw reverse transport (`lease.TLS=true` only)
70 +### Raw reverse transport (`TLS=true` only)
71
60 -1. Relay requires registered leases to use TLS.
61 -2. Backend opens a raw TCP reverse connection via `GET /sdk/connect?lease_id=...`.
62 -3. Clients connect via HTTPS to relay SNI port.
63 -4. Relay resolves route by SNI and acquires a reverse connection from `ReverseHub`.
64 -5. Tunnel-side listener performs TLS handshake (keyless-backed signer), while relay forwards raw TCP transparently.
65 -6. No alternate control-plane transport is supported; relay/tunnel transport stays raw TCP reverse-connect only.
72 +1. SDK/tunnel registers a lease with `POST /sdk/register`.
73 +2. SDK opens one or more reverse sessions with `GET /sdk/connect?lease_id=...`.
74 +3. Relay hijacks each `/sdk/connect` request and places the connection in the per-lease broker ready queue.
75 +4. While idle, the relay writes `0x00` keepalive markers.
76 +5. A browser connects to the relay SNI listener.
77 +6. Relay extracts SNI from ClientHello, resolves a lease, and claims one ready reverse session.
78 +7. Relay writes `0x02` to activate that session.
79 +8. SDK/tunnel receives `0x02`, starts tenant TLS locally using the relay-backed keyless signer, and the relay bridges raw encrypted bytes end-to-end.
80
67 -Result: the relay handles SNI-based routing and transparent raw TCP forwarding, preserving end-to-end TLS where applicable.
81 +Result: the relay decides routing, but tenant TLS termination still happens at the SDK/tunnel side.
82
83 ## Control Plane Flow
84
85 ### 1. Register
86
73 -- App/tunnel posts to `POST /sdk/register` with:
74 - - `lease_id`
87 +- `POST /sdk/register`
88 +- JSON envelope response
89 +- Caller provides:
90 - `name`
76 - - `metadata`
77 - - `tls`
91 - `reverse_token`
79 -- Relay stores lease and (TLS only) registers SNI route.
80 -- Route hostnames are generated from normalized lease + normalized `PORTAL_URL` host (extract host from URL without scheme/port/path); path segments are ignored, so `https://portal.example.com:8443/admin` and `https://portal.example.com` both map to `portal.example.com`.
81 -- `/sdk/register` admission uses strict order: `IP -> Lease -> Token`.
92 + - `tls=true`
93 + - optional `hostnames`
94 + - optional `metadata`
95 + - optional `ttl_seconds`
96 +- If no hostname is supplied, relay derives one from `name + root host`
97 +- `PORTAL_URL` is normalized to its host component only; path/query segments are ignored for routing
98
99 ### 2. Reverse Connect
100
85 -- Backend opens a raw TCP reverse connection to `GET /sdk/connect` and streams traffic over that long-lived connection
86 - - `/sdk/connect` applies strict admission order before hijacking:
87 - - `IP -> Lease -> Token`
88 -- `X-Portal-Reverse-Token` is validated at HTTP precheck, then validated again in `ReverseHub` with centralized policy callbacks before the connection is pooled.
89 -- Connection is pooled in `ReverseHub` only after token/IP checks pass.
101 +- `GET /sdk/connect?lease_id=...`
102 +- Requires HTTP/1.1
103 +- Requires `X-Portal-Token` header with the lease reverse token
104 +- Relay validates:
105 + - lease exists and is not expired
106 + - reverse token matches the registered lease token
107 +- After hijack, the connection becomes a broker-managed reverse session
108
109 ### 3. Renew
110
93 -- Backend sends `POST /sdk/renew` keepalive.
94 -- `/sdk/renew` requires `reverse_token`.
95 -- Relay refreshes lease TTL and keeps route state current.
111 +- `POST /sdk/renew`
112 +- Requires `lease_id` + `reverse_token`
113 +- Extends lease TTL
114 +- Resets a previously dropped broker back to active
115
116 ### 4. Unregister
117
99 -- Backend sends `POST /sdk/unregister`.
100 -- `/sdk/unregister` validates normalized `lease_id` and token before deletion.
101 -- Relay removes lease, route, and reverse pool.
118 +- `POST /sdk/unregister`
119 +- Requires `lease_id` + `reverse_token`
120 +- Removes the lease, routes, and ready reverse sessions
121
103 -## Admin Lease ID Contract
122 +## Routing Behavior
123
105 -- `/admin/leases` returns plain lease IDs in `Peer`.
106 -- `/admin/leases/banned` returns plain lease IDs (`[]string`).
107 -- Base64URL encoding is used only in admin action path segments (`/admin/leases/{encodedLeaseID}/{action}`).
124 +Route lookup order:
125
109 -## Routing Behavior
126 +1. Exact hostname match
127 +2. Single-label wildcard match (`*.example.com`)
128 +3. Root-host fallback to the admin/API listener
129 +
130 +Notes:
131 +
132 +- Wildcards are one level only.
133 +- The exact root host is never served by the wildcard route.
134 +- For non-apex `PORTAL_URL` values such as `https://portal.example.com:8443/admin`, public lease hosts become `<lease>.portal.example.com`.
135
111 -`sni.Router` route lookup order:
136 +## Admin and Frontend Surface
137
113 -1. Exact host match
114 -2. Single-label wildcard (`*.example.com`)
115 -3. No-route handler (used for exact portal-root host fallback to admin/API listener on the `PORTAL_URL` root host)
138 +Current relay-served public routes:
139
117 -Note: wildcard does not match the portal root host itself (`example.com` or `portal.example.com`), so exact root-host matches trigger fallback to admin/API listener.
118 -`PORTAL_URL` is normalized to its host component (scheme/port/path removed), so non-apex values such as `https://portal.example.com:8443/admin` become `<lease>.portal.example.com` and exact host matches still resolve through no-route fallback.
140 +- `/`
141 +- `/app`
142 +- `/app/*`
143 +- `/assets/*`
144 +- `/admin`
145 +- `/admin/leases`
146 +- `/tunnel`
147 +- `/tunnel/bin/*`
148 +- `/healthz`
149 +- `/v1/sign`
150 +- `/sdk/*`
151 +
152 +The admin surface is intentionally small in the current Go runtime: an HTML index plus a JSON lease list.
153 +
154 +## Shared Contract Surface
155 +
156 +Cross-package public contract lives in:
157 +
158 +- `types/api.go`
159 + - API envelope
160 + - shared request/response DTOs
161 + - lease metadata
162 + - reverse marker/header constants
163 +- `types/paths.go`
164 + - shared `/sdk/*`, admin, health, tunnel, and signer paths
165 +
166 +Relay-local frontend asset filenames stay in `cmd/relay-server`, not `types/`.
167
168 ## Keyless and Certificates
169
122 -- Relay keyless materials are stored in `KEYLESS_DIR`:
170 +- Relay admin/API TLS uses the certificate in `KEYLESS_DIR`
171 - `fullchain.pem`
172 - `privatekey.pem`
125 -- If materials are missing and Cloudflare token is configured, relay can provision via ACME DNS-01.
126 -- SDK/tunnel TLS mode uses keyless signer workflow and `/v1/sign` for remote signatures.
173 +- For non-localhost deployments, ACME DNS-01 uses Cloudflare and keeps:
174 + - root host A record
175 + - wildcard host A record
176 + - relay certificate renewal
177 +- SDK/tunnel fetches the relay certificate chain, verifies it covers tenant hostnames, and uses `/v1/sign` for remote signatures during tenant TLS handshakes
178
179 ## Design Properties
180
130 -- Reverse-only backend connectivity (no inbound port required on the app host)
131 -- Per-lease reverse token authorization
132 -- Separation of control plane (`/sdk/*`) and data plane (SNI + raw TCP forwarding)
133 -- Single relay/tunnel transport policy: raw TCP reverse-connect only
134 -- Control-plane identity policy: token-based admission with deterministic order
135 -- Unified lease abstraction for routing, metadata, and lifecycle
136 -- Shared anti-abuse path: admin-managed bans and lease authorization are enforced both in SDK registration and reverse admission
137 -
138 -## Breaking-Change Upgrade Expectations
139 -
140 -- Control-plane admission is token-only.
141 -- Tunnels/SDK clients must provide valid lease tokens for `/sdk/*` admission.
142 -- Client certificates are not required for `/sdk/*` admission.
181 +- Reverse-only backend connectivity
182 +- One canonical raw TCP reverse transport
183 +- SNI-based routing with root-host fallback
184 +- End-to-end tenant TLS with relay-backed keyless signing
185 +- Per-lease reverse token authorization for reverse session lifecycle
186 +- Lease-local reverse session ownership through `leaseBroker`
187
188 ## ADRs
189
docs/deployment.md
+32 -17
@@ -25,7 +25,7 @@ You need:
25
26 Cloudflare Dashboard -> `DNS` -> `Records`:
27
28 -- Record 1 (apex)
28 +- Record 1 (root host)
29 - Type: `A`
30 - Name: `@`
31 - Content: `<server-ip>`
@@ -41,20 +41,13 @@ Expected records:
41 - `example.com -> <server-ip>`
42 - `*.example.com -> <server-ip>`
43
44 -If you deploy on a non-apex host (for example, `PORTAL_URL=https://portal.example.com:8443`), create host-scoped records:
44 +If you deploy on a non-apex host (for example, `PORTAL_URL=https://portal.example.com:8443`), create host-scoped records instead:
45
46 - `portal.example.com -> <server-ip>`
47 - `*.portal.example.com -> <server-ip>`
48
49 -Portal normalizes `PORTAL_URL` to its host for routing, so public service hosts become `<lease>.portal.example.com`.
50 -Requests to the exact `PORTAL_URL` host (for example, `portal.example.com`) are not wildcard-matched; the router uses no-route fallback and forwards them to the admin/API listener.
51 -Relay/tunnel traffic for reverse admission stays raw TCP on `/sdk/connect`.
52 -
53 -### 2.4 Control-Plane Admission (Token-Only)
54 -
55 -- `/sdk/register`, `/sdk/connect`, `/sdk/renew`, and `/sdk/unregister` use token-based admission.
56 -- Control-plane admission order is fixed: `IP -> Lease -> Token`.
57 -- Clients without valid lease token are rejected.
49 +Portal derives public lease hostnames from the normalized `PORTAL_URL` host.
50 +Requests to the exact root host are not served by the wildcard route; they fall back to the admin/API listener.
51
52 ### 2.3 Create Cloudflare API Token
53
@@ -71,9 +64,31 @@ Scope:
64
65 Save this token for `CLOUDFLARE_TOKEN`.
66
74 -## 3. Run Relay Server
67 +## 3. Relay Runtime Behavior
68 +
69 +### 3.1 Control Plane and Reverse Sessions
70 +
71 +- `/sdk/register` creates a lease and stores the caller-provided reverse token.
72 +- `/sdk/connect` requires:
73 + - `lease_id` query parameter
74 + - `X-Portal-Token` header
75 + - HTTP/1.1
76 +- `/sdk/renew` and `/sdk/unregister` require `lease_id` + `reverse_token`.
77 +- `/sdk/connect` is hijacked into a long-lived reverse TCP session after validation.
78 +
79 +### 3.2 Certificates and DNS Maintenance
80 +
81 +- Relay certificates live in `KEYLESS_DIR`:
82 + - `fullchain.pem`
83 + - `privatekey.pem`
84 +- On non-localhost deployments, ACME DNS-01 uses the Cloudflare token to:
85 + - ensure root and wildcard A records point to the current public IP
86 + - provision the relay certificate
87 + - keep DNS and certificate state refreshed over time
88 +
89 +## 4. Run Relay Server
90
76 -### 3.1 Create `.env` at repository root
91 +### 4.1 Create `.env` at repository root
92
93 ```bash
94 PORTAL_URL=https://example.com
@@ -84,18 +99,18 @@ KEYLESS_DIR=/etc/portal/keyless
99 CLOUDFLARE_TOKEN=cf_xxxxxxxxxxxxxxxxx
100 ```
101
87 -For non-apex deployments, set `PORTAL_URL` and `BOOTSTRAP_URIS` to the same non-apex host value (for example, `https://portal.example.com:8443`). Keep any path segments only for dashboard use, not for routing.
102 +For non-apex deployments, set `PORTAL_URL` and `BOOTSTRAP_URIS` to the same non-apex host value (for example, `https://portal.example.com:8443`).
103 `PORTAL_URL` path/query segments are ignored for route derivation; only the host component is used.
104
90 -### 3.2 Start Relay
105 +### 4.2 Start Relay
106
107 ```bash
108 docker compose up
109 ```
110
96 -## 4. Troubleshooting
111 +## 5. Troubleshooting
112
98 -### 4.1 Ports blocked
113 +### 5.1 Ports blocked
114
115 Required inbound ports:
116
docs/glossary.md
+48 -39
@@ -4,79 +4,88 @@ Key terms used in Portal.
4
5 ## Portal / Relay
6
7 -The central server that handles lease registration, routing, and reverse-connection brokering.
8 -In TLS passthrough mode, it routes transport and does not terminate app payload TLS.
9 -All backend-to-relay ingress uses a long-lived raw TCP reverse-connect channel (`/sdk/connect`).
7 +The central server that handles lease registration, SNI routing, reverse-session brokering, admin/API TLS, and keyless signing.
8 +It does not terminate tenant TLS.
9
10 ## App (Service Publisher)
11
13 -A backend service connected to Portal through Tunnel or Native SDK.
14 -An app publishes one or more leases and serves traffic from local services.
12 +A backend service connected to Portal through `portal-tunnel` or the native Go SDK.
13 +An app publishes one or more leases and serves traffic from a local process.
14
15 ## Client (Service Consumer)
16
18 -A browser or external caller that accesses a published service through relay-managed domains.
17 +A browser or external caller that accesses a published service through relay-managed hostnames.
18
20 -## Conn #1 (Data Plane)
19 +## Control-Plane Request
20
22 -Tenant-facing traffic path between browser/client and app/tunnel endpoint.
23 -This connection keeps existing data-plane TLS behavior.
21 +An ordinary HTTPS request to the relay admin/API listener, such as:
22
25 -## Conn #2 (Control Plane)
23 +- `/sdk/register`
24 +- `/sdk/renew`
25 +- `/sdk/unregister`
26 +- `/sdk/domain`
27
27 -Relay-to-tunnel control path used by `/sdk/register`, `/sdk/connect`, `/sdk/renew`, and `/sdk/unregister`.
28 -This connection uses token-based admission with order `IP -> Lease -> Token`.
28 +These use the JSON API envelope contract.
29
30 -## Tunnel
30 +## Reverse Session
31
32 -The CLI publisher path (`cmd/portal-tunnel`).
33 -It forwards relay traffic to an existing local host/port without app code changes.
34 -
35 -## Native SDK
36 -
37 -The Go integration path (`sdk/`).
38 -It provides relay-backed listener APIs and lease metadata control for direct integration.
32 +A long-lived raw TCP connection opened through `GET /sdk/connect?lease_id=...`.
33 +The relay hijacks it, keeps it idle in a lease broker, and later claims it for one tenant TLS passthrough connection.
34
35 ## Lease
36
37 Portal's routing and advertisement unit.
43 -Each lease maps to one public endpoint and includes identity, name, metadata, TLS flag, and reverse token.
38 +Each lease has an ID, display name, hostnames, metadata, expiry, reverse token, and a broker of ready reverse sessions.
39
40 ## Lease Name
41
47 -The human-readable identifier used for subdomain routing (for example, `myapp` -> `myapp.example.com`).
42 +The human-readable identifier used to derive a hostname (for example, `myapp` -> `myapp.example.com`) when no explicit hostname is supplied.
43 +
44 +## Lease Broker
45 +
46 +The relay-side owner of reverse session state for one lease.
47 +It manages the ready queue, claim/wakeup behavior, drop/stop lifecycle, and idle keepalive policy.
48
49 ## Reverse Token
50
51 -A per-lease secret used to authenticate reverse connections (`/sdk/connect`) from backend to relay.
52 -Token validation is a required admission stage after lease and policy checks.
51 +A per-lease secret supplied at registration time and later required by:
52
54 -## ReverseHub
53 +- `/sdk/connect`
54 +- `/sdk/renew`
55 +- `/sdk/unregister`
56
56 -Relay-side pool of authenticated reverse connections keyed by lease ID.
57 -It supplies raw TCP reverse connections for TLS SNI forwarding.
57 +It authorizes reverse-session and lease-lifecycle operations.
58
59 -## SNI Router
59 +## Route Table
60
61 -The TCP router on relay SNI port (default `443`) that selects lease routes by TLS SNI.
62 -Exact matches on the portal root host (derived from `PORTAL_URL` host) are intentionally routed via no-route fallback to the admin/API listener.
61 +The relay hostname map that resolves exact and single-label wildcard matches to a lease ID.
62 +
63 +## SNI Routing
64 +
65 +The relay reads ClientHello to extract the requested hostname, chooses a lease route, and then bridges the original encrypted TLS stream without terminating it.
66
67 ## Keyless TLS
68
66 -A mode where the backend performs TLS while using the relay signer endpoint (`/v1/sign`) for remote signing.
67 -This avoids distributing private keys to every backend host.
69 +A mode where the SDK/tunnel terminates tenant TLS locally while delegating private-key signing to the relay `/v1/sign` endpoint.
70 +This keeps the relay out of the tenant data plane while avoiding direct private-key distribution to every backend host.
71
72 ## ACME DNS-01
73
71 -Certificate issuance/renewal method used with a Cloudflare DNS API token when keyless materials are missing.
74 +The relay certificate issuance and renewal path for non-localhost deployments.
75 +It uses a Cloudflare DNS API token to provision the root and wildcard certificate coverage used by the relay.
76
73 -## Base Domain
77 +## Base Domain / Root Host
78
75 -The host extracted from `PORTAL_URL` (scheme, port, and path removed) and used to build service subdomains.
76 -For non-apex values such as `https://portal.example.com:8443/admin`, the base host is `portal.example.com`.
77 -The same host is used for exact-match SNI fallback, which routes root-host requests to the admin/API listener.
79 +The host extracted from `PORTAL_URL` after removing scheme, port, path, query, and fragment.
80 +For `https://portal.example.com:8443/admin`, the root host is `portal.example.com`.
81
82 ## Admin/API Server
83
81 -The relay HTTP server (default `:4017`) serving admin UI and control endpoints such as `/sdk/*`, `/admin`, and `/healthz`.
82 -It also receives root-domain fallback traffic from SNI when no more specific lease route is found.
84 +The relay HTTPS listener (default `:4017`) serving:
85 +
86 +- `/sdk/*`
87 +- `/admin`
88 +- `/admin/leases`
89 +- `/healthz`
90 +- `/v1/sign`
91 +- frontend root/app routes through root-host fallback
frontend/AGENTS.md
+17 -15
@@ -5,31 +5,33 @@ High-signal constraints for the relay-server frontend. Only items expensive to r
5 ## Frontend-Backend Contracts (Manually Synced)
6
7 1. **SSR data shape is a 3-way contract.**
8 - Go `leaseRow` (`cmd/relay-server/utils.go`) ↔ TS `ServerData` (`src/hooks/useSSRData.ts`) ↔ `<script id="__SSR_DATA__">` injection (`cmd/relay-server/frontend.go`).
8 + Go `leaseRow` (`cmd/relay-server/frontend.go`) -> TS `ServerData` (`src/hooks/useSSRData.ts`) -> `<script id="__SSR_DATA__">` injection (`cmd/relay-server/frontend.go`).
9 - Why: no shared schema or codegen. Field drift silently breaks SSR hydration. The script tag ID `__SSR_DATA__` is hardcoded in all three locations.
10
11 2. **API path constants require dual maintenance.**
12 - Go definitions in `types/api.go`, TS duplicates in `src/lib/apiPaths.ts`.
13 - - Why: no codegen. A path mismatch produces silent 404s on same-origin requests.
12 + Go definitions live in `types/paths.go`; TS duplicates live in `src/lib/apiPaths.ts`.
13 + - Why: no codegen. A path mismatch produces same-origin 404s.
14 + - Current Go runtime only serves `/admin` and `/admin/leases` on the admin surface. Extra TS admin paths need matching backend work or they will 404.
15
16 3. **API envelope shape must match across Go and TS.**
16 - All responses use `{ ok, data?, error?: { code, message } }`. Go helpers (`utils.go`) and TS parser (`src/lib/apiClient.ts`) must agree. TS treats a missing boolean `ok` field as `invalid_envelope`.
17 - - Why: backend responses that skip the envelope (e.g., raw middleware errors) throw `invalid_envelope` instead of displaying the error.
17 + All JSON control-plane responses use `{ ok, data?, error?: { code, message } }`.
18 + Go shape is `types.APIEnvelope` in `types/api.go`; Go writers live in `portal/api.go`; TS parser lives in `src/lib/apiClient.ts`.
19 + - Why: backend responses that skip the envelope surface as `invalid_envelope` in the frontend.
20
19 -4. **Lease ID encoding uses base64url without padding.**
20 - Frontend encodes via `btoa` + character replacement (`src/lib/apiPaths.ts`). Backend two-pass decodes: `base64.URLEncoding` then `base64.RawURLEncoding` (`cmd/relay-server/utils.go`).
21 - - Why: standard base64 in either direction → 400 errors. `btoa` is browser-only — not portable to Node/SSR without polyfill.
22 -
23 -5. **Build output renames `index.html` to `portal.html`.**
24 - Vite plugin `rename-index` (`vite.config.ts`) performs this post-build. Go backend serves `portal.html`, not `index.html`. The rename is skipped when `VITEST` env is set.
21 +4. **Build output renames `index.html` to `portal.html`.**
22 + Vite plugin `rename-index` (`vite.config.ts`) performs this post-build. Go backend serves `portal.html`, not `index.html`. The rename is skipped when `VITEST` is set.
23 - Why: any tooling or script assuming `index.html` post-build will fail.
24
27 -6. **OG metadata placeholders must match between HTML and Go.**
28 - `index.html` (becomes `portal.html`) contains `[%OG_TITLE%]`, `[%OG_DESCRIPTION%]`, `[%OG_IMAGE_URL%]`. Server-side substitution happens in `cmd/relay-server/frontend.go`.
29 - - Why: renaming a placeholder in one place without the other → raw uninjected strings in responses.
25 +5. **OG metadata placeholders must match between HTML and Go.**
26 + `index.html` (renamed to `portal.html`) contains `[%OG_TITLE%]`, `[%OG_DESCRIPTION%]`, `[%OG_IMAGE_URL%]`. Server-side substitution happens in `cmd/relay-server/frontend.go`.
27 + - Why: renaming a placeholder in one place without the other leaves raw placeholder strings in production HTML.
28 +
29 +6. **Frontend admin action helpers currently outpace the Go runtime.**
30 + `src/lib/apiPaths.ts` still contains action routes such as `/admin/leases/{id}/{action}` and `/admin/settings/*`.
31 + - Why: do not assume those routes exist in the current Go relay unless you implement the backend in the same change.
32
33 ## Frontend Conventions
34
35 1. **Do not use `useCallback` in new code.**
36 React Compiler (`babel-plugin-react-compiler`, enabled in `vite.config.ts`) handles memoization automatically.
35 - - Why: manual `useCallback` is redundant with the compiler and adds noise. Existing usage in `useAdmin.ts` and `ServerListView.tsx` is legacy — remove when touching those files.
37 + - Why: manual `useCallback` is redundant with the compiler and adds noise.