chore: update architecture and readme
Kim committed
Mar 19, 2026 at 15:46 UTC
f3df91bd63a7f4278c914b92a2deb3e33f3ef4f6
2 files changed
+53
-15
README.md
+1
@@ -25,6 +25,7 @@ Unlike other tunneling services, Portal is self-hosted and permissionless. You c
25
- **End-to-end encryption**: Supports TLS passthrough with relay keyless certificates
26
- **Permissionless Hosting**: Anyone can run their own Portal — no approval needed
27
- **One-Command Setup**: Expose any local app with a single command
28
+- **UDP Relay (Experimental)**: Supports raw UDP relay use cases, but the transport model and operational behavior may still change
29
30
## Components
31
docs/architecture.md
+52
-15
@@ -2,17 +2,23 @@
2
3
## Overview
4
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.
5
+Portal publishes local services on public subdomains and optional UDP ports through a relay.
6
+Backends connect outward to the relay. Stream traffic is routed by SNI, and tenant TLS remains end-to-end between the client and the SDK/tunnel endpoint for the stream path.
7
8
High-level path:
9
10
```text
11
-Client (Browser)
11
+Stream client
12
-> Relay SNI listener (:443 by default)
13
-> Claimed reverse session
14
-> SDK / portal-tunnel
15
-> Local service
16
+
17
+UDP client
18
+ -> Relay lease UDP port (29900-29999 by default)
19
+ -> Internal QUIC tunnel
20
+ -> SDK / portal-tunnel
21
+ -> Local UDP service
22
```
23
24
## Architecture Invariants
@@ -23,7 +29,7 @@ Client (Browser)
29
- Do not introduce websocket or legacy compatibility paths unless a new ADR supersedes ADR-0002.
30
- Derive lease hostnames from the full normalized `PORTAL_URL` host, not from apex extraction.
31
- Preserve explicit root-host fallback through SNI no-route handling to the admin/API listener.
26
-- All leases are TLS-only. Registration does not negotiate or preserve a non-TLS mode.
32
+- Stream ingress is TLS-only. UDP exposure, when enabled, is raw UDP.
33
34
### TLS and Identity
35
@@ -61,7 +67,7 @@ Client (Browser)
67
68
## Connection Model
69
64
-Portal has two distinct network roles:
70
+Portal has three distinct network roles:
71
72
- **Control-plane HTTP requests**
73
- `POST /sdk/register`
@@ -72,9 +78,13 @@ Portal has two distinct network roles:
78
- `GET /sdk/connect?lease_id=...`
79
- HTTP/1.1 only
80
- hijacked into a long-lived raw TCP session
75
- - starts idle in the relay broker, then becomes the tenant data path when claimed
81
+ - starts idle in the per-lease stream ready queue, then becomes the tenant data path when claimed
82
+- **Internal datagram tunnel**
83
+ - QUIC on `API_PORT/udp`
84
+ - authenticated by a QUIC control stream
85
+ - carries relay-to-SDK/tunnel datagram traffic only
86
77
-That distinction matters because `/sdk/connect` stops being ordinary HTTP once hijacked.
87
+That distinction matters because `/sdk/connect` stops being ordinary HTTP once hijacked, while the UDP backhaul is a separate internal QUIC carrier.
88
89
## Core Components
90
@@ -91,8 +101,8 @@ That distinction matters because `/sdk/connect` stops being ordinary HTTP once h
101
102
- `Server`: owns listeners, lease registry, API handlers, and shutdown lifecycle
103
- `routeTable`: exact + single-label wildcard hostname lookup
94
-- `leaseBroker`: per-lease ready queue for reverse sessions
95
-- `reverseSession`: idle keepalive + activation state machine for one reverse TCP connection
104
+- `transport.RelayStream`: per-lease ready queue for reverse stream sessions
105
+- `transport.RelayDatagram`: per-lease raw UDP port and datagram backhaul runtime
106
- `acme`: Cloudflare/Route53-backed root/wildcard A-record sync + certificate provisioning/renewal for the relay root host and wildcard
107
- `keyless`: admin/API TLS attach helpers and tenant-side signer integration
108
@@ -101,7 +111,7 @@ That distinction matters because `/sdk/connect` stops being ordinary HTTP once h
111
- `WithDefaultRelayURLs`: fetches the default Portal relay list from the repository-root `registry.json`, appends explicit relay inputs, and normalizes the combined list
112
- Entry points can opt out of registry defaults and call `utils.NormalizeRelayURLs` directly when they need explicit relay inputs only
113
- `Listener`: validates one relay URL locally, then starts relay compatibility checks, lease registration, reverse session maintenance, and lease renewal in the background until ready
104
-- `relayclient.go`: internal relay transport helper for control-plane requests and reverse session dialing
114
+- `api_client.go`: internal relay client for control-plane requests, reverse session dialing, and internal QUIC tunnel setup
115
- `ListenerConfig.RetryCount <= 0` means retry forever; positive values close the listener after the retry budget is exhausted
116
- Default app flow is `WithDefaultRelayURLs -> NewListener -> PublicURL -> http.Server.Serve(listener)` or `WithDefaultRelayURLs -> Expose -> PublicURLs -> http.Server.Serve(exposure)`, with an opt-out path for explicit relay inputs only
117
- `expose.go`: optional `RunHTTP` helper for serving one handler on both a local HTTP port and the relay listener
@@ -116,6 +126,7 @@ That distinction matters because `/sdk/connect` stops being ordinary HTTP once h
126
- Creates one SDK listener per relay through the SDK and consumes one aggregate listener
127
- Accepts claimed tenant connections from the relay
128
- Proxies raw TCP to a local target passed to `portal expose`
129
+- Optionally proxies raw UDP to a separate local UDP target passed with `--udp-addr`
130
- Returns an HTTP 503 response when the local target is unavailable
131
132
## Transport Model
@@ -124,15 +135,40 @@ That distinction matters because `/sdk/connect` stops being ordinary HTTP once h
135
136
1. SDK/tunnel registers one lease per relay with `POST /sdk/register`.
137
2. SDK opens one or more reverse sessions per registered lease with `GET /sdk/connect?lease_id=...`.
127
-3. Each relay hijacks `/sdk/connect` requests and places the connection in the per-lease broker ready queue.
138
+3. Each relay hijacks `/sdk/connect` requests and places the connection in the per-lease stream ready queue.
139
4. While idle, the relay writes `0x00` keepalive markers.
129
-5. A browser connects to the relay SNI listener.
130
-6. Relay extracts SNI from ClientHello, resolves a lease, and waits up to `ClaimTimeout` for one reverse session from that lease broker.
140
+5. A stream client connects to the relay SNI listener.
141
+6. Relay extracts SNI from ClientHello, resolves a lease, and waits up to `ClaimTimeout` for one reverse session from that lease stream queue.
142
7. Relay writes `0x02` to activate the claimed session.
143
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.
144
145
Result: the relay decides routing, but tenant TLS termination still happens at the SDK/tunnel side.
146
147
+### UDP datagram transport
148
+
149
+1. SDK/tunnel registers a lease with `udp_enabled=true`.
150
+2. Relay validates that the datagram plane is enabled and allocates one public UDP port for that lease from the configured `UDP_PORT_MIN` to `UDP_PORT_MAX` range.
151
+3. SDK/tunnel opens an internal QUIC tunnel to the relay on `API_PORT/udp` and authenticates it with a QUIC control stream.
152
+4. A UDP client sends raw UDP packets to the lease `UDPAddr`.
153
+5. Relay receives those packets on the lease UDP port, maps client traffic to a flow ID, and forwards payloads over QUIC DATAGRAM frames.
154
+6. SDK/tunnel receives the datagrams and either:
155
+ - delivers them to application code through `sdk.Exposure.AcceptDatagram()`, or
156
+ - proxies them to a local UDP service in `portal-tunnel`.
157
+7. Replies return over the same QUIC tunnel and are written back to the original client through the lease UDP port.
158
+
159
+### UDP Characteristics and Constraints
160
+
161
+- UDP support is currently experimental. Expect transport details and operational behavior to keep changing while the model is being validated.
162
+- Public UDP ingress is raw UDP only. Portal does not currently provide public QUIC or HTTP/3 ingress.
163
+- The QUIC tunnel is internal only. It is a relay-to-SDK/tunnel backhaul, not a public client entry point.
164
+- Public UDP and internal QUIC serve different roles:
165
+ - public UDP keeps the external interface compatible with existing raw UDP clients
166
+ - internal QUIC gives the backhaul one outbound, multiplexed, TLS-protected carrier
167
+- Each UDP-enabled lease gets one dedicated public UDP port. The configured UDP port range therefore limits the number of concurrent UDP-enabled leases, not the number of clients behind one lease.
168
+- The public UDP leg is not relay-terminated TLS. Only the internal QUIC backhaul is TLS-protected by default.
169
+- UDP is intended for native UDP clients and services such as game servers, DNS-like protocols, and custom UDP daemons. It is not a browser-native public interface.
170
+- In the current public contract, stream/TCP remains available and UDP is additive when enabled.
171
+
172
## Control Plane Flow
173
174
### 1. Register
@@ -144,7 +180,7 @@ Result: the relay decides routing, but tenant TLS termination still happens at t
180
- `reverse_token`
181
- optional `metadata`
182
- optional `ttl`
147
-- No non-TLS mode is accepted or negotiated
183
+- optional `udp_enabled` (experimental)
184
- `name` must be a valid single DNS label and relay publishes the lease at `<name>.<root host>`
185
- Registration reserves the hostname and publishes the route immediately; if no reverse session is ready yet, inbound SNI claims wait up to `ClaimTimeout`
186
- `PORTAL_URL` is normalized to its host component only; path/query segments are ignored for routing
@@ -237,10 +273,11 @@ Relay-local frontend asset filenames stay in `cmd/relay-server`, not `types/`.
273
274
- Reverse-only backend connectivity
275
- One canonical raw TCP reverse transport
276
+- Raw public UDP exposure with an internal QUIC datagram backhaul
277
- SNI-based routing with root-host fallback
278
- End-to-end tenant TLS with relay-backed keyless signing
279
- Per-lease reverse token authorization for reverse session lifecycle
243
-- Lease-local reverse session ownership through `leaseBroker`
280
+- Lease-local stream and datagram ownership through per-lease transport runtimes
281
282
## ADRs
283