feat(udp): add admin-level UDP transport controls and simplify QUIC config

Hee Sung Son committed Mar 20, 2026 at 09:35 UTC f5c931e4086e5cbf1a3f44b18b5cb08a5676aef6
28 files changed +533 -148
.env.example
+4 -3
@@ -4,9 +4,10 @@ PORTAL_URL=https://localhost:4017
4 # Listener ports
5 API_PORT=4017
6 SNI_PORT=443
7 -UDP_ENABLED=true
8 -UDP_PORT_MIN=29900
9 -UDP_PORT_MAX=29999
7 +
8 +# UDP transport (0 = disabled). Set count > 0 to enable QUIC tunnel + allocate UDP ports starting from 50000.
9 +# e.g., UDP_PORT_COUNT=10 → ports 50000-50009. Also requires enabling UDP in the admin panel.
10 +UDP_PORT_COUNT=0
11
12 # TLS/ACME and keyless materials
13 KEYLESS_DIR=./.portal-certs
cmd/relay-server/admin.go
+31 -1
@@ -20,7 +20,8 @@ import (
20 )
21
22 const cookieName = "portal_admin"
23 -const adminSettingsPath = "admin_settings.json"
23 +
24 +var adminSettingsPath = "admin_settings.json"
25
26 type adminAuth struct {
27 sessions map[string]time.Time
@@ -200,6 +201,30 @@ func (f *Frontend) serveAdmin(w http.ResponseWriter, r *http.Request) {
201 utils.WriteAPIData(w, http.StatusOK, types.AdminSnapshotResponse{
202 ApprovalMode: string(runtime.Approver().Mode()),
203 Leases: f.adminLeaseSnapshots(),
204 + UDP: types.AdminUDPSettingsResponse{
205 + Enabled: runtime.IsUDPEnabled(),
206 + MaxLeases: runtime.UDPMaxLeases(),
207 + },
208 + })
209 + case types.PathAdminUDP:
210 + if r.Method != http.MethodPost {
211 + methodNotAllowed()
212 + return
213 + }
214 + var req types.AdminUDPSettingsRequest
215 + if err := utils.DecodeJSONBody(w, r, &req, 1<<16); err != nil {
216 + utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "invalid request body")
217 + return
218 + }
219 + if req.MaxLeases < 0 {
220 + utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "max_leases must be non-negative")
221 + return
222 + }
223 + runtime.SetUDPPolicy(req.Enabled, req.MaxLeases)
224 + saveAdminState(runtime)
225 + utils.WriteAPIData(w, http.StatusOK, types.AdminUDPSettingsResponse{
226 + Enabled: runtime.IsUDPEnabled(),
227 + MaxLeases: runtime.UDPMaxLeases(),
228 })
229 case types.PathAdminApproval:
230 if r.Method != http.MethodPost {
@@ -395,6 +420,8 @@ type persistedAdminState struct {
420 BannedLeases []string `json:"banned_leases,omitempty"`
421 BannedIPs []string `json:"banned_ips,omitempty"`
422 LeaseBPS map[string]int64 `json:"lease_bps,omitempty"`
423 + UDPEnabled bool `json:"udp_enabled"`
424 + UDPMaxLeases int `json:"udp_max_leases"`
425 }
426
427 func persistedStateFromRuntime(runtime *policy.Runtime) persistedAdminState {
@@ -406,6 +433,8 @@ func persistedStateFromRuntime(runtime *policy.Runtime) persistedAdminState {
433 BannedLeases: runtime.BannedLeases(),
434 BannedIPs: runtime.IPFilter().BannedIPs(),
435 LeaseBPS: runtime.BPSManager().LeaseBPSLimits(),
436 + UDPEnabled: runtime.IsUDPEnabled(),
437 + UDPMaxLeases: runtime.UDPMaxLeases(),
438 }
439 }
440
@@ -422,6 +451,7 @@ func (s persistedAdminState) apply(runtime *policy.Runtime) error {
451 runtime.SetBannedLeases(s.BannedLeases)
452 runtime.IPFilter().SetBannedIPs(s.BannedIPs)
453 runtime.BPSManager().SetLeaseBPSLimits(s.LeaseBPS)
454 + runtime.SetUDPPolicy(s.UDPEnabled, s.UDPMaxLeases)
455 return nil
456 }
457
cmd/relay-server/main.go
+25 -20
@@ -6,6 +6,7 @@ import (
6 "fmt"
7 "os"
8 "os/signal"
9 + "path/filepath"
10 "strings"
11 "syscall"
12 "time"
@@ -20,21 +21,18 @@ import (
21 )
22
23 const (
23 - defaultAPIPort = 4017
24 - defaultSNIPort = 443
25 - defaultUDPPortMin = 29900
26 - defaultUDPPortMax = 29999
27 - defaultPortalURL = "https://localhost:4017"
28 - defaultKeylessDir = "./.portal-certs"
24 + defaultAPIPort = 4017
25 + defaultSNIPort = 443
26 + defaultUDPPortCount = 0
27 + defaultPortalURL = "https://localhost:4017"
28 + defaultKeylessDir = "./.portal-certs"
29 )
30
31 type relayServerConfig struct {
32 PortalURL string
33 APIPort int
34 SNIPort int
35 - UDPEnabled bool
36 - UDPPortMin int
37 - UDPPortMax int
35 + UDPPortCount int
36 AdminSecretKey string
37 TrustProxyHeaders bool
38 TrustedProxyCIDRs string
@@ -60,9 +58,7 @@ func main() {
58 }
59 apiPort := parsePortNumber(os.Getenv("API_PORT"), defaultAPIPort)
60 sniPort := parsePortNumber(os.Getenv("SNI_PORT"), defaultSNIPort)
63 - udpEnabled := utils.ParseBoolEnv("UDP_ENABLED", false)
64 - udpPortMin := parsePortNumber(os.Getenv("UDP_PORT_MIN"), defaultUDPPortMin)
65 - udpPortMax := parsePortNumber(os.Getenv("UDP_PORT_MAX"), defaultUDPPortMax)
61 + udpPortCount := parseNonNegativeInt(os.Getenv("UDP_PORT_COUNT"), defaultUDPPortCount)
62 adminSecretKey := trimmedEnv("ADMIN_SECRET_KEY")
63 trustProxyHeaders := utils.ParseBoolEnv("TRUST_PROXY_HEADERS", false)
64 trustedProxyCIDRs := trimmedEnv("TRUSTED_PROXY_CIDRS")
@@ -70,6 +66,7 @@ func main() {
66 if keylessDir == "" {
67 keylessDir = defaultKeylessDir
68 }
69 + adminSettingsPath = filepath.Join(keylessDir, "admin_settings.json")
70 acmeDNSProvider := trimmedEnv("ACME_DNS_PROVIDER")
71 if acmeDNSProvider == "" {
72 acmeDNSProvider = "cloudflare"
@@ -87,9 +84,7 @@ func main() {
84 flag.StringVar(&cfg.PortalURL, "portal-url", portalURL, "portal base URL (env: PORTAL_URL)")
85 flag.IntVar(&cfg.APIPort, "api-port", apiPort, "Admin/API server port (env: API_PORT)")
86 flag.IntVar(&cfg.SNIPort, "sni-port", sniPort, "TCP SNI router port number (env: SNI_PORT)")
90 - flag.BoolVar(&cfg.UDPEnabled, "udp", udpEnabled, "enable UDP relay ports and the internal QUIC tunnel (env: UDP_ENABLED)")
91 - flag.IntVar(&cfg.UDPPortMin, "udp-port-min", udpPortMin, "Minimum UDP port for lease allocation (env: UDP_PORT_MIN)")
92 - flag.IntVar(&cfg.UDPPortMax, "udp-port-max", udpPortMax, "Maximum UDP port for lease allocation (env: UDP_PORT_MAX)")
87 + flag.IntVar(&cfg.UDPPortCount, "udp-port-count", udpPortCount, "Number of UDP ports to allocate for leases, starting at port 50000 (0=disabled) (env: UDP_PORT_COUNT)")
88
89 flag.StringVar(&cfg.AdminSecretKey, "admin-secret-key", adminSecretKey, "admin auth secret (env: ADMIN_SECRET_KEY)")
90 flag.BoolVar(&cfg.TrustProxyHeaders, "trust-proxy-headers", trustProxyHeaders, "trust X-Forwarded-* and X-Real-IP headers from trusted proxies (env: TRUST_PROXY_HEADERS)")
@@ -108,7 +103,7 @@ func main() {
103 logger.Info().
104 Str("release_version", types.ReleaseVersion).
105 Str("portal_url", cfg.PortalURL).
111 - Bool("udp_enabled", cfg.UDPEnabled).
106 + Bool("udp_enabled", cfg.UDPPortCount > 0).
107 Msg("configured relay server")
108
109 if err := runServer(cfg); err != nil {
@@ -143,11 +138,9 @@ func runServer(cfg relayServerConfig) error {
138 },
139 APIListenAddr: apiListenAddr,
140 SNIListenAddr: sniListenAddr,
146 - UDPEnabled: cfg.UDPEnabled,
141 TrustedProxyCIDRs: trustedProxyCIDRs,
142 TrustProxyHeaders: cfg.TrustProxyHeaders,
149 - UDPPortMin: cfg.UDPPortMin,
150 - UDPPortMax: cfg.UDPPortMax,
143 + UDPPortCount: cfg.UDPPortCount,
144 })
145 if err != nil {
146 return fmt.Errorf("create relay server: %w", err)
@@ -167,7 +160,7 @@ func runServer(cfg relayServerConfig) error {
160 Str("sni_addr", server.SNIAddr()).
161 Str("root_host", rootHost).
162 Str("acme_dns_provider", cfg.ACMEDNSProvider).
170 - Bool("udp_enabled", cfg.UDPEnabled).
163 + Bool("udp_enabled", cfg.UDPPortCount > 0).
164 Bool("acme_enabled", !strings.HasSuffix(rootHost, "localhost") && rootHost != "127.0.0.1" && rootHost != "::1")
165 if quicAddr := server.QUICTunnelAddr(); quicAddr != "" {
166 logEvent = logEvent.Str("internal_quic_tunnel_addr", quicAddr)
@@ -192,3 +185,15 @@ func parsePortNumber(raw string, fallback int) int {
185 }
186 return port
187 }
188 +
189 +func parseNonNegativeInt(raw string, fallback int) int {
190 + raw = strings.TrimSpace(raw)
191 + if raw == "" {
192 + return fallback
193 + }
194 + var v int
195 + if _, err := fmt.Sscanf(raw, "%d", &v); err != nil || v < 0 {
196 + return fallback
197 + }
198 + return v
199 +}
docker-compose.yml
+6 -7
@@ -1,15 +1,16 @@
1 services:
2 portal:
3 - image: ghcr.io/gosuda/portal:2
3 + image: ghcr.io/gosuda/portal:latest
4 build:
5 context: .
6 dockerfile: Dockerfile
7 stop_grace_period: 30s
8 ports:
9 - "${API_PORT:-4017}:${API_PORT:-4017}"
10 - - "${API_PORT:-4017}:${API_PORT:-4017}/udp"
10 - "${SNI_PORT:-443}:${SNI_PORT:-443}"
12 - - "${UDP_PORT_MIN:-29900}-${UDP_PORT_MAX:-29999}:${UDP_PORT_MIN:-29900}-${UDP_PORT_MAX:-29999}/udp"
11 + # Uncomment below when enabling UDP transport (UDP_PORT_COUNT > 0):
12 + # - "${SNI_PORT:-443}:${SNI_PORT:-443}/udp"
13 + # - "50000-50009:50000-50009/udp" # adjust range to match UDP_PORT_COUNT
14 environment:
15 # Public routing
16 PORTAL_URL: ${PORTAL_URL:-https://localhost:${API_PORT:-4017}}
@@ -17,11 +18,9 @@ services:
18 # Listener ports (published to the host below)
19 API_PORT: ${API_PORT:-4017}
20 SNI_PORT: ${SNI_PORT:-443}
20 - UDP_ENABLED: ${UDP_ENABLED:-true}
21
22 - # UDP port allocation range for UDP-enabled leases
23 - UDP_PORT_MIN: ${UDP_PORT_MIN:-29900}
24 - UDP_PORT_MAX: ${UDP_PORT_MAX:-29999}
22 + # UDP transport (0 = disabled, set count > 0 to enable QUIC tunnel + UDP ports)
23 + UDP_PORT_COUNT: ${UDP_PORT_COUNT:-0}
24
25 # Admin/auth configuration
26 ADMIN_SECRET_KEY: ${ADMIN_SECRET_KEY:-}
docs/architecture.md
+50 -26
@@ -105,6 +105,13 @@ That distinction matters because `/sdk/connect` stops being ordinary HTTP once h
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 +- `portal/datagram/`: subpackage for QUIC/UDP datagram transport
109 + - `Session`: owns one active QUIC DATAGRAM connection, decodes frames, exposes `Incoming()` channel
110 + - `FlowMux`: per-lease QUIC connection manager; multiplexes UDP datagrams using flow IDs over DATAGRAM frames (RFC 9221); embeds a `Session` with dispatch and idle-flow cleanup goroutines
111 + - `Relay`: binds a public UDP port per lease, bridges between raw UDP sockets and `FlowMux` using `TouchFlow`/`SendDatagram`
112 + - `PortAllocator`: assigns and recycles per-lease UDP ports from a count-based pool (base port 50000, count via `UDP_PORT_COUNT`) with sticky name-based reservation and grace period
113 + - `ParseQUICInitialSNI`: decrypts QUIC v1 Initial packets and extracts TLS ClientHello SNI for the QUIC SNI router
114 +- `Server` additionally owns: `quicTunnel` (QUIC listener, ALPN `portal-tunnel`), `quicSNI` (raw UDP PacketConn for QUIC SNI routing), `quicSNIRoutes` (cached FlowMux per source address)
115
116 ### SDK (`sdk/`)
117
@@ -119,6 +126,12 @@ That distinction matters because `/sdk/connect` stops being ordinary HTTP once h
126 - `Exposure.RelayURLs()` returns the configured normalized relay URLs, while `Exposure.PublicURLs()` returns only relays that are currently registered and ready
127 - Relay-aware entry inspection is reserved for advanced callers such as `portal-tunnel`
128 - Tenant TLS is created automatically through the relay keyless signer; callers do not provide a local self-signed fallback path
129 +- `Listener` embeds a `datagram.Session` for QUIC datagram transport (no separate UDP listener type)
130 +- `Listener.AcceptDatagram()` / `SendDatagram()`: read/write datagram frames via the session
131 +- `Listener.WaitDatagramReady()`: blocks until relay publishes `udp_addr` and `quic_addr`
132 +- `ExposureDatagram`: wraps a `DatagramFrame` with relay context (FlowID, LeaseID, RelayURL, UDPAddr) and a `Reply()` callback for bidirectional flow
133 +- `Exposure.AcceptDatagram()`: receives datagrams from all backing relay listeners
134 +- `Exposure.WaitDatagramReady()`: blocks until at least one relay's datagram plane is ready
135
136 ### Tunnel (`cmd/portal-tunnel`)
137
@@ -126,8 +139,13 @@ That distinction matters because `/sdk/connect` stops being ordinary HTTP once h
139 - Creates one SDK listener per relay through the SDK and consumes one aggregate listener
140 - Accepts claimed tenant connections from the relay
141 - 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`
142 +- Optionally proxies raw UDP to a separate local UDP target when `--udp` is enabled
143 - Returns an HTTP 503 response when the local target is unavailable
144 +- `--udp` flag (bool, default `false`): enables UDP relay in addition to TCP
145 +- `--udp-addr` flag (string): local UDP target address (`host:port` or port only); required when `--udp` is enabled
146 +- `runUDPBestEffort`: waits for datagram readiness, then calls `proxyExposureDatagrams`
147 +- `proxyExposureDatagrams` (`relays.go`): per-flow UDP sockets to local target with idle cleanup; uses `ExposureDatagram.Reply()` for return path
148 +- Best-effort UDP — failures logged but do not terminate the TCP tunnel
149
150 ## Transport Model
151
@@ -144,30 +162,23 @@ That distinction matters because `/sdk/connect` stops being ordinary HTTP once h
162
163 Result: the relay decides routing, but tenant TLS termination still happens at the SDK/tunnel side.
164
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.
165 +### UDP/QUIC Datagram Transport
166 +
167 +1. SDK/tunnel registers a lease with `udp_enabled=true` via `POST /sdk/register`.
168 +2. Relay validates that the datagram plane is enabled (server has `UDP_PORT_COUNT > 0` AND admin has enabled UDP), allocates a UDP port via `PortAllocator`, creates `FlowMux` + `Relay` in `leaseDatagramRuntime`.
169 +3. Response includes `udp_addr` (public UDP endpoint) and `quic_addr` (QUIC tunnel endpoint).
170 +4. SDK opens a QUIC connection to `quic_addr` (ALPN `portal-tunnel`, TLS 1.3, datagrams enabled).
171 +5. Authentication: SDK sends `{lease_id, reverse_token}` JSON on the first QUIC stream; relay validates and calls `FlowMux.Register(conn)`.
172 +6. External UDP client sends a packet to `udp_addr` → `Relay.readLoop` → `FlowMux.TouchFlow` (assigns flow ID) → `FlowMux.SendDatagram` → QUIC DATAGRAM frame.
173 +7. SDK-side `Session.receiveLoop` decodes frame → `Listener.AcceptDatagram()` → `Exposure.AcceptDatagram()` → `proxyExposureDatagrams` → local UDP target.
174 +8. Return path: local response → per-flow read goroutine → `ExposureDatagram.Reply()` → `Session.Send` → QUIC DATAGRAM → `FlowMux.runDispatchLoop` → reply callback → `conn.WriteToUDP` to original client.
175 +
176 +```text
177 +Client --UDP--> [:50000+ Relay] --DATAGRAM--> [FlowMux/Session] --QUIC--> [Session/Listener] --UDP--> Local Service
178 + <--QUIC DATAGRAM return path--
179 +```
180 +
181 +Wire format (`types/transport.go`): `[flowID uvarint][payload bytes]`
182
183 ## Control Plane Flow
184
@@ -180,9 +191,14 @@ Result: the relay decides routing, but tenant TLS termination still happens at t
191 - `reverse_token`
192 - optional `metadata`
193 - optional `ttl`
183 -- optional `udp_enabled` (experimental)
194 + - optional `udp_enabled` (default `false`)
195 - `name` must be a valid single DNS label and relay publishes the lease at `<name>.<root host>`
196 - Registration reserves the hostname and publishes the route immediately; if no reverse session is ready yet, inbound SNI claims wait up to `ClaimTimeout`
197 +- When datagram-capable, response includes `udp_addr`, `quic_addr`, and `transport`
198 +- UDP registration requires two conditions: server must have `UDP_PORT_COUNT > 0` AND admin must enable UDP in the admin panel
199 +- `APIErrorCodeUDPDisabled` (HTTP 403) when UDP is disabled by admin policy
200 +- `APIErrorCodeUDPCapacityExceeded` (HTTP 503) when admin-configured max UDP lease limit is reached
201 +- `APIErrorCodeUDPPortExhausted` (HTTP 503) when the UDP port pool is exhausted
202 - `PORTAL_URL` is normalized to its host component only; path/query segments are ignored for routing
203
204 ### 2. Reverse Connect
@@ -255,6 +271,11 @@ Cross-package public contract lives in:
271 - reverse marker constants
272 - `types/paths.go`
273 - shared `/sdk/*`, admin, health, install, and signer paths
274 +- `types/transport.go`
275 + - `LeaseCapabilities` (Stream/Datagram booleans)
276 + - `DatagramFrame` wire format
277 + - `EncodeDatagram` / `DecodeDatagram`
278 + - Transport constants: `TransportTCP`, `TransportUDP`, `TransportBoth`
279
280 Relay-local frontend asset filenames stay in `cmd/relay-server`, not `types/`.
281
@@ -278,6 +299,9 @@ Relay-local frontend asset filenames stay in `cmd/relay-server`, not `types/`.
299 - End-to-end tenant TLS with relay-backed keyless signing
300 - Per-lease reverse token authorization for reverse session lifecycle
301 - Lease-local stream and datagram ownership through per-lease transport runtimes
302 +- Optional QUIC/UDP datagram transport coexisting with TCP on the same lease
303 +- Per-lease UDP port allocation with sticky name-based reservation
304 +- QUIC tunnel authentication via control stream (lease ID + reverse token)
305
306 ## ADRs
307
docs/deployment.md
+55 -12
@@ -8,7 +8,8 @@ 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`, `4017/udp`, `29900-29999/udp` (UDP range for QUIC/UDP leases)
11 +- Open inbound ports: `443/tcp`, `4017/tcp`
12 +- Optional UDP ports (if enabling UDP transport): `4017/udp`, `50000+/udp` (see section 3.2)
13 - Docker and Docker Compose
14 - A DNS provider account for ACME DNS-01 automation with a supported provider (`cloudflare` or `route53`)
15
@@ -110,17 +111,57 @@ Equivalent relay flags:
111 - `/sdk/renew` and `/sdk/unregister` require `lease_id` + `reverse_token`.
112 - `/sdk/connect` is hijacked into a long-lived reverse TCP session after validation.
113
113 -### 3.2 UDP/QUIC Transport
114 +### 3.2 Enabling UDP Transport
115
115 -Portal automatically starts a QUIC tunnel listener on `API_PORT/udp` (default `:4017/udp`) and allocates raw UDP ports from the `UDP_PORT_MIN`–`UDP_PORT_MAX` range (default `29900`–`29999`) for UDP leases.
116 +UDP transport is disabled by default. To enable UDP for real-time workloads (game servers, VoIP), complete all steps:
117 +
118 +**Step 1: Open UDP ports on your VM/host**
119 +
120 +If running on a cloud VM (AWS EC2, GCP, OCI, etc.), open the required UDP ports in the security group / firewall rules:
121 +- `4017/udp` — QUIC tunnel listener (relay ↔ tunnel)
122 +- `50000-50009/udp` — Raw UDP lease ports (adjust count to match `UDP_PORT_COUNT`)
123 +
124 +Example (UFW, 10 ports):
125 +```bash
126 +sudo ufw allow 4017/udp
127 +sudo ufw allow 50000:50009/udp
128 +```
129 +
130 +**Step 2: Expose UDP ports in Docker**
131 +
132 +If using Docker with `network_mode: host`, UDP ports are directly accessible on the host — no additional Docker config needed.
133 +
134 +If using bridge networking, map the UDP ports explicitly in `docker-compose.yaml`:
135 +```yaml
136 +ports:
137 + - "4017:4017/udp"
138 + - "50000-50009:50000-50009/udp"
139 +```
140 +
141 +**Step 3: Configure UDP port count in `.env`**
142 +
143 +Set `UDP_PORT_COUNT` to the number of concurrent UDP leases you want to support. Ports are allocated starting from port 50000:
144 +```bash
145 +UDP_PORT_COUNT=10 # allocates ports 50000-50009
146 +```
147
148 | Variable | Default | Description |
149 |---|---|---|
119 -| `UDP_PORT_MIN` | `29900` | Start of the UDP port allocation range |
120 -| `UDP_PORT_MAX` | `29999` | End of the UDP port allocation range |
150 +| `UDP_PORT_COUNT` | `0` (disabled) | Number of UDP ports to allocate, starting at port 50000 |
151 +
152 +**Step 4: Enable UDP in the admin panel**
153 +
154 +Navigate to `/admin`, toggle UDP transport to "Enabled", and optionally set a max concurrent UDP lease limit.
155
156 > **Docker note:** Use `network_mode: host` for the portal container to avoid Docker iptables port-mapping overhead. Docker creates one iptables rule per mapped port, so large UDP ranges cause very slow container start/stop. Host networking bypasses this entirely and allows dynamic UDP port allocation. See the nginx-proxy examples for the recommended setup.
157
158 +> **UDP buffer tuning (Linux):** Increase kernel UDP buffer limits for QUIC performance:
159 +> ```bash
160 +> sudo sysctl -w net.core.rmem_max=7500000
161 +> sudo sysctl -w net.core.wmem_max=7500000
162 +> ```
163 +> To persist across reboots, add to `/etc/sysctl.conf` or a file in `/etc/sysctl.d/`.
164 +
165 ### 3.3 Certificates and DNS Maintenance
166
167 - Relay certificates live in `KEYLESS_DIR`:
@@ -137,7 +178,6 @@ Portal automatically starts a QUIC tunnel listener on `API_PORT/udp` (default `:
178
179 ```bash
180 PORTAL_URL=https://example.com
140 -BOOTSTRAP_URIS=https://example.com
181 SNI_PORT=443
182 ADMIN_SECRET_KEY=your-admin-secret
183 KEYLESS_DIR=./.portal-certs
@@ -158,7 +198,7 @@ AWS_REGION=us-east-1
198 AWS_HOSTED_ZONE_ID=Z1234567890ABC
199 ```
200
161 -For non-apex deployments, set `PORTAL_URL` and `BOOTSTRAP_URIS` to the same non-apex host value (for example, `https://portal.example.com:8443`).
201 +For non-apex deployments, set `PORTAL_URL` to the non-apex host value (for example, `https://portal.example.com:8443`).
202 `PORTAL_URL` path/query segments are ignored for route derivation; only the host component is used.
203
204 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:
@@ -192,7 +232,6 @@ cd "$(dirname "$0")"
232
233 docker compose pull
234 docker compose up -d
195 -docker image prune -f
235 ```
236
237 ### 5.2 Watcher script
@@ -265,15 +304,19 @@ Required inbound ports:
304
305 - `443/tcp` — SNI router (tenant TLS passthrough)
306 - `4017/tcp` — Admin/API listener
268 -- `4017/udp` — QUIC tunnel listener (tunnel ↔ relay)
269 -- `29900-29999/udp` — Raw UDP lease ports (client ↔ relay, adjust to match `UDP_PORT_MAX`)
307 +- `4017/udp` — QUIC tunnel listener (only if `UDP_PORT_COUNT > 0`)
308 +- `50000+/udp` — Raw UDP lease ports (only if `UDP_PORT_COUNT > 0`, adjust range to match count)
309
271 -UFW example:
310 +UFW example (with 10 UDP ports):
311
312 ```bash
313 sudo ufw allow 443/tcp
314 sudo ufw allow 4017/tcp
315 sudo ufw allow 4017/udp
277 -sudo ufw allow 29900:29999/udp
316 +sudo ufw allow 50000:50009/udp
317 sudo ufw status
318 ```
319 +
320 +### 6.2 QUIC UDP buffer warnings
321 +
322 +If relay logs show `failed to sufficiently increase receive buffer size`, the kernel UDP buffer limit is too low. Apply the sysctl settings from section 3.2.
docs/examples/nginx-proxy-multi-service/.env.example new
+31
@@ -0,0 +1,31 @@
1 +# Portal + nginx multi-service deployment configuration
2 +# Copy this file to .env and fill in the values.
3 +
4 +# Public routing (no port — nginx handles :443 externally)
5 +PORTAL_URL=https://portal.example.com
6 +
7 +# Internal listener ports
8 +# API_PORT uses default 4017.
9 +# SNI_PORT is 4443 to avoid conflict with nginx on 443.
10 +API_PORT=4017
11 +SNI_PORT=4443
12 +
13 +# ACME DNS provider (cloudflare or route53)
14 +ACME_DNS_PROVIDER=cloudflare
15 +
16 +# UDP transport (0 = disabled). Allocates ports starting from 50000.
17 +# e.g., count=10 → ports 50000-50009. Also requires enabling UDP in the admin panel.
18 +UDP_PORT_COUNT=0
19 +
20 +# Admin secret for the /admin UI
21 +ADMIN_SECRET_KEY=
22 +
23 +# Cloudflare API token (Zone:Read + DNS:Edit) for portal ACME cert issuance
24 +CLOUDFLARE_TOKEN=
25 +
26 +# Portal's own keyless TLS certificate directory
27 +KEYLESS_DIR=/portal-certs
28 +
29 +# Trust forwarded headers from nginx (required behind reverse proxy)
30 +TRUST_PROXY_HEADERS=true
31 +TRUSTED_PROXY_CIDRS=127.0.0.0/8
docs/examples/nginx-proxy-multi-service/docker-compose.yaml
+11 -6
@@ -50,26 +50,31 @@ services:
50 - app-b-network
51
52 # ─── portal relay ───────────────────────────────────────────────────────────
53 - # NAT-traversal relay with host networking.
53 + # NAT-traversal relay server.
54 # TCP (4017, 4443) is reached by nginx via host.docker.internal.
55 - # UDP (4017, 29900-29999) is bound directly on the host.
55 # SNI_PORT is 4443 to avoid conflicting with nginx on 443.
56 + # If you enable UDP (UDP_PORT_COUNT > 0), expose SNI_PORT/udp and 50000+/udp as well.
57 portal:
58 - image: ghcr.io/gosuda/portal:2
58 + image: ghcr.io/gosuda/portal:latest
59 container_name: portal
60 - network_mode: host
60 + ports:
61 + - "${API_PORT:-4017}:${API_PORT:-4017}/tcp"
62 + - "${SNI_PORT:-4443}:${SNI_PORT:-4443}/tcp"
63 + # Uncomment below when enabling UDP transport (host 443/udp is free — nginx only uses 443/tcp):
64 + # - "443:${SNI_PORT:-4443}/udp"
65 + # - "50000-50009:50000-50009/udp" # adjust range to match UDP_PORT_COUNT
66 stop_grace_period: 30s
67 environment:
68 PORTAL_URL: ${PORTAL_URL:-https://portal.example.com}
69 API_PORT: ${API_PORT:-4017}
70 SNI_PORT: ${SNI_PORT:-4443}
66 - UDP_PORT_MIN: ${UDP_PORT_MIN:-29900}
67 - UDP_PORT_MAX: ${UDP_PORT_MAX:-29999}
71 + UDP_PORT_COUNT: ${UDP_PORT_COUNT:-0}
72 ADMIN_SECRET_KEY: ${ADMIN_SECRET_KEY:-}
73 TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-true}
74 TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-127.0.0.0/8}
75 KEYLESS_DIR: ${KEYLESS_DIR:-/portal-certs}
76 CLOUDFLARE_TOKEN: ${CLOUDFLARE_TOKEN:-}
77 + ACME_DNS_PROVIDER: ${ACME_DNS_PROVIDER:-cloudflare}
78 volumes:
79 - ./portal-certs:/portal-certs
80 restart: unless-stopped
docs/examples/nginx-proxy/.env.example
+6 -3
@@ -8,9 +8,12 @@ PORTAL_URL=https://portal.example.com
8 API_PORT=4017
9 SNI_PORT=443
10
11 -# UDP port allocation range for QUIC/UDP leases
12 -UDP_PORT_MIN=29900
13 -UDP_PORT_MAX=29999
11 +# ACME DNS provider (cloudflare or route53)
12 +ACME_DNS_PROVIDER=cloudflare
13 +
14 +# UDP transport (0 = disabled). Allocates ports starting from 50000.
15 +# e.g., count=10 → ports 50000-50009. Also requires enabling UDP in the admin panel.
16 +UDP_PORT_COUNT=0
17
18 # Admin secret for the /admin UI
19 ADMIN_SECRET_KEY=
docs/examples/nginx-proxy/docker-compose.yaml
+16 -12
@@ -5,12 +5,11 @@
5 # nginx:443/tcp (L4 stream, ssl_preread)
6 # ├─ portal.example.com → 127.0.0.1:8443 (nginx L7, TLS termination) → 127.0.0.1:4017
7 # └─ *.portal.example.com → 127.0.0.1:4443 (portal SNI, raw TCP passthrough)
8 -# portal:4017/udp (QUIC tunnel listener — direct on host)
9 -# portal:29900-29999/udp (per-lease UDP relay ports — direct on host)
8 +# portal:4443/udp (QUIC tunnel listener — shares SNI_PORT, only if UDP_PORT_COUNT > 0)
9 +# portal:50000+/udp (per-lease UDP relay ports — only if UDP_PORT_COUNT > 0)
10 #
11 -# Portal uses host networking so UDP ports are dynamically bound on the host
12 -# without Docker iptables rules. This avoids the slow container start/stop
13 -# caused by large port-range mappings and allows dynamic UDP port allocation.
11 +# If you enable UDP transport (UDP_PORT_COUNT > 0), expose SNI_PORT/udp and
12 +# the allocated UDP port range (50000+) in the portal service ports section.
13 #
14 # Prerequisites:
15 # 1. Copy .env.example to .env and set all required values.
@@ -42,14 +41,19 @@ services:
41 restart: unless-stopped
42
43 # ─── portal relay ───────────────────────────────────────────────────────────
45 - # Relay server with host networking.
44 + # NAT-traversal relay server.
45 # TCP (4017, 4443) is reached by nginx via 127.0.0.1.
47 - # UDP (4017, 29900-29999) is bound directly on the host — no port mapping.
46 # SNI_PORT is set to 4443 to avoid conflicting with nginx on port 443.
47 + # If you enable UDP (UDP_PORT_COUNT > 0), expose SNI_PORT/udp and 50000+/udp as well.
48 portal:
50 - image: ghcr.io/gosuda/portal:2
49 + image: ghcr.io/gosuda/portal:latest
50 container_name: portal
52 - network_mode: host
51 + ports:
52 + - "${API_PORT:-4017}:${API_PORT:-4017}/tcp"
53 + - "${SNI_PORT:-4443}:${SNI_PORT:-4443}/tcp"
54 + # Uncomment below when enabling UDP transport (host 443/udp is free — nginx only uses 443/tcp):
55 + # - "443:${SNI_PORT:-4443}/udp"
56 + # - "50000-50009:50000-50009/udp" # adjust range to match UDP_PORT_COUNT
57 stop_grace_period: 30s
58 environment:
59 PORTAL_URL: ${PORTAL_URL:-https://portal.example.com}
@@ -59,9 +63,8 @@ services:
63 # Use a non-443 port to avoid conflict with nginx on the host.
64 SNI_PORT: ${SNI_PORT:-4443}
65
62 - # UDP port allocation range for QUIC/UDP leases.
63 - UDP_PORT_MIN: ${UDP_PORT_MIN:-29900}
64 - UDP_PORT_MAX: ${UDP_PORT_MAX:-29999}
66 + # UDP transport (0 = disabled, set count to enable).
67 + UDP_PORT_COUNT: ${UDP_PORT_COUNT:-0}
68
69 ADMIN_SECRET_KEY: ${ADMIN_SECRET_KEY:-}
70 TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-true}
@@ -69,6 +72,7 @@ services:
72
73 KEYLESS_DIR: ${KEYLESS_DIR:-/portal-certs}
74 CLOUDFLARE_TOKEN: ${CLOUDFLARE_TOKEN:-}
75 + ACME_DNS_PROVIDER: ${ACME_DNS_PROVIDER:-cloudflare}
76 volumes:
77 - ./portal-certs:/portal-certs
78 restart: unless-stopped
frontend/src/components/ServerCard.tsx
+7
@@ -46,6 +46,7 @@ interface ServerCardProps {
46 ) => void | Promise<void>;
47 onDenyStatusChange?: (leaseId: string, deny: boolean) => void | Promise<void>;
48 onIPBanStatusChange?: (ip: string, isBan: boolean) => void | Promise<void>;
49 + transport?: string;
50 isSelected?: boolean;
51 onToggleSelect?: (leaseId: string) => void;
52 }
@@ -76,6 +77,7 @@ export function ServerCard({
77 onApproveStatusChange,
78 onDenyStatusChange,
79 onIPBanStatusChange,
80 + transport = "tcp",
81 isSelected = false,
82 onToggleSelect,
83 }: ServerCardProps) {
@@ -260,6 +262,11 @@ export function ServerCard({
262 {formattedDuration && online && ` · ${formattedDuration}`}
263 </span>
264 </div>
265 + {showAdminControls && transport !== "tcp" && (
266 + <span className="rounded-full bg-black/40 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-primary backdrop-blur-sm border border-primary/30">
267 + {transport}
268 + </span>
269 + )}
270
271 {showAdminControls ? (
272 <button
frontend/src/components/ServerListView.tsx
+75 -1
@@ -4,7 +4,7 @@ import { SearchBar } from "@/components/SearchBar";
4 import { ServerCard } from "@/components/ServerCard";
5 import { TagCombobox } from "@/components/TagCombobox";
6 import type { ClientServer } from "@/hooks/useServerList";
7 -import type { AdminServer, ApprovalMode } from "@/hooks/useAdmin";
7 +import type { AdminServer, ApprovalMode, UDPSettings } from "@/hooks/useAdmin";
8 import type { SortOption, StatusFilter } from "@/types/filters";
9 import { StatusSelect } from "@/components/select/StatusSelect";
10 import { BanStatusButtons } from "@/components/button/BanStatusButtons";
@@ -45,6 +45,8 @@ interface ServerListViewProps {
45 ) => void | Promise<void>;
46 onBPSChange?: (leaseId: string, bps: number) => void | Promise<void>;
47 onApprovalModeChange?: (mode: ApprovalMode) => void;
48 + udpSettings?: UDPSettings;
49 + onUDPSettingsChange?: (settings: UDPSettings) => void | Promise<void>;
50 onApproveStatusChange?: (
51 leaseId: string,
52 approve: boolean
@@ -89,6 +91,8 @@ export function ServerListView({
91 onBanStatusChange,
92 onBPSChange,
93 onApprovalModeChange,
94 + udpSettings,
95 + onUDPSettingsChange,
96 onApproveStatusChange,
97 onDenyStatusChange,
98 onIPBanStatusChange,
@@ -209,6 +213,24 @@ export function ServerListView({
213 void runBulkAction(onBulkBan);
214 };
215
216 + const [maxLeasesInput, setMaxLeasesInput] = useState(
217 + String(udpSettings?.maxLeases ?? 0)
218 + );
219 +
220 + const handleUDPToggle = (enabled: boolean) => {
221 + if (onUDPSettingsChange && udpSettings) {
222 + void onUDPSettingsChange({ ...udpSettings, enabled });
223 + }
224 + };
225 +
226 + const handleMaxLeasesSave = () => {
227 + if (onUDPSettingsChange && udpSettings) {
228 + const value = Math.max(0, parseInt(maxLeasesInput, 10) || 0);
229 + setMaxLeasesInput(String(value));
230 + void onUDPSettingsChange({ ...udpSettings, maxLeases: value });
231 + }
232 + };
233 +
234 const adminFilterControls = (
235 <>
236 {onBanFilterChange && (
@@ -231,6 +253,57 @@ export function ServerListView({
253 />
254 </div>
255 )}
256 + {onUDPSettingsChange && udpSettings && (
257 + <>
258 + <div className="flex items-center gap-3">
259 + <span className="text-sm font-medium text-text-muted">UDP</span>
260 + <div className="flex rounded-lg overflow-hidden border border-foreground/20">
261 + <button
262 + onClick={() => handleUDPToggle(false)}
263 + className={`cursor-pointer px-4 h-10 text-sm font-medium transition-colors ${
264 + !udpSettings.enabled
265 + ? "bg-primary text-primary-foreground"
266 + : "bg-secondary text-secondary-foreground hover:bg-secondary/80"
267 + }`}
268 + >
269 + Disabled
270 + </button>
271 + <button
272 + onClick={() => handleUDPToggle(true)}
273 + className={`cursor-pointer px-4 h-10 text-sm font-medium transition-colors border-l border-foreground/20 ${
274 + udpSettings.enabled
275 + ? "bg-primary text-primary-foreground"
276 + : "bg-secondary text-secondary-foreground hover:bg-secondary/80"
277 + }`}
278 + >
279 + Enabled
280 + </button>
281 + </div>
282 + </div>
283 + <div className="flex items-center gap-3">
284 + <span className="text-sm font-medium text-text-muted">Max UDP</span>
285 + <div className="flex items-center gap-2">
286 + <input
287 + type="number"
288 + min="0"
289 + value={maxLeasesInput}
290 + onChange={(e) => setMaxLeasesInput(e.target.value)}
291 + onKeyDown={(e) => {
292 + if (e.key === "Enter") handleMaxLeasesSave();
293 + }}
294 + className="w-20 h-10 px-3 text-sm border border-foreground/20 rounded-lg bg-secondary text-foreground"
295 + placeholder="0"
296 + />
297 + <button
298 + onClick={handleMaxLeasesSave}
299 + className="cursor-pointer h-10 px-4 text-sm font-medium rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
300 + >
301 + Save
302 + </button>
303 + </div>
304 + </div>
305 + </>
306 + )}
307 </>
308 );
309
@@ -317,6 +390,7 @@ export function ServerListView({
390 bps={adminServer?.bps}
391 ip={adminServer?.ip}
392 isIPBanned={adminServer?.isIPBanned}
393 + transport={adminServer?.transport}
394 onBanStatusChange={onBanStatusChange}
395 onBPSChange={onBPSChange}
396 onApproveStatusChange={onApproveStatusChange}
frontend/src/components/TunnelCommandModal.tsx
+50
@@ -35,6 +35,8 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
35 const [urlInput, setUrlInput] = useState("");
36 const [copied, setCopied] = useState(false);
37 const [os, setOs] = useState<"unix" | "windows">("unix");
38 + const [enableUDP, setEnableUDP] = useState(false);
39 + const [udpPort, setUdpPort] = useState("");
40 const [thumbnailURL, setThumbnailURL] = useState("");
41 const normalizedThumbnailURL = useMemo(
42 () => normalizeAbsoluteHTTPURL(thumbnailURL),
@@ -111,6 +113,13 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
113 if (normalizedThumbnailURL) {
114 exposeArgs.push(`--thumbnail ${formatToken(normalizedThumbnailURL, os)}`);
115 }
116 + if (enableUDP) {
117 + exposeArgs.push("--udp");
118 + const udpAddrVal = udpPort.trim();
119 + if (udpAddrVal !== "") {
120 + exposeArgs.push(`--udp-addr ${formatToken(udpAddrVal, os)}`);
121 + }
122 + }
123
124 if (os === "windows") {
125 return [
@@ -128,11 +137,13 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
137 }, [
138 currentOrigin,
139 defaultRelays,
140 + enableUDP,
141 name,
142 normalizedThumbnailURL,
143 os,
144 relayUrls,
145 target,
146 + udpPort,
147 ]);
148
149 const handleCopy = async () => {
@@ -157,6 +168,8 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
168 setUrlInput("");
169 setCopied(false);
170 setOs("unix");
171 + setEnableUDP(false);
172 + setUdpPort("");
173 setThumbnailURL("");
174 };
175
@@ -256,6 +269,43 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
269 </div>
270 </div>
271
272 + {/* UDP Transport */}
273 + <div className="space-y-2">
274 + <label className="text-sm font-medium text-foreground">
275 + UDP Transport
276 + </label>
277 + <label className="flex items-center gap-2 cursor-pointer">
278 + <input
279 + type="checkbox"
280 + checked={enableUDP}
281 + onChange={(e) => {
282 + setEnableUDP(e.target.checked);
283 + if (!e.target.checked) {
284 + setUdpPort("");
285 + }
286 + }}
287 + className="h-4 w-4"
288 + />
289 + <span className="text-sm text-muted-foreground">
290 + Enable UDP transport (for game servers, VoIP, etc.)
291 + </span>
292 + </label>
293 + {enableUDP && (
294 + <div className="space-y-1.5">
295 + <Input
296 + id="udp-port"
297 + type="text"
298 + value={udpPort}
299 + onChange={(e) => setUdpPort(e.target.value)}
300 + placeholder={target.trim() || defaultHost}
301 + />
302 + <p className="text-xs text-muted-foreground">
303 + Local UDP port to forward. Defaults to the same as Host.
304 + </p>
305 + </div>
306 + )}
307 + </div>
308 +
309 <div className="space-y-2">
310 <label
311 htmlFor="thumbnail-url"
frontend/src/hooks/useAdmin.ts
+32
@@ -22,6 +22,7 @@ type ApprovalModeResponse = {
22 type AdminSnapshotResponse = {
23 approval_mode?: ApprovalMode;
24 leases?: ServerData[];
25 + udp?: { enabled: boolean; max_leases: number };
26 };
27
28 type LeaseActionResult = ApprovalModeResponse;
@@ -34,6 +35,13 @@ export interface AdminServer extends BaseServer {
35 isDenied: boolean;
36 ip: string;
37 isIPBanned: boolean;
38 + transport: string;
39 + udpPort: number;
40 +}
41 +
42 +export interface UDPSettings {
43 + enabled: boolean;
44 + maxLeases: number;
45 }
46
47 const ADMIN_ERROR_MESSAGE_BY_CODE: Record<string, string> = {
@@ -97,6 +105,8 @@ function toAdminServer(
105 isDenied: row.IsDenied || false,
106 ip: row.ClientIP || "",
107 isIPBanned: row.IsIPBanned || false,
108 + transport: row.Transport || "tcp",
109 + udpPort: row.UDPPort || 0,
110 };
111 }
112
@@ -122,6 +132,7 @@ function dedupeStrings(values: string[]): string[] {
132 interface AdminSnapshot {
133 serverData: ServerData[];
134 approvalMode: ApprovalMode;
135 + udpSettings: UDPSettings;
136 }
137
138 async function loadAdminSnapshot(): Promise<AdminSnapshot> {
@@ -131,12 +142,17 @@ async function loadAdminSnapshot(): Promise<AdminSnapshot> {
142 return {
143 serverData: normalizedLeases,
144 approvalMode: normalizeApprovalMode(snapshot?.approval_mode),
145 + udpSettings: {
146 + enabled: snapshot?.udp?.enabled ?? false,
147 + maxLeases: snapshot?.udp?.max_leases ?? 0,
148 + },
149 };
150 }
151
152 export function useAdmin() {
153 const [serverData, setServerData] = useState<ServerData[]>([]);
154 const [approvalMode, setApprovalMode] = useState<ApprovalMode>("auto");
155 + const [udpSettings, setUDPSettings] = useState<UDPSettings>({ enabled: false, maxLeases: 0 });
156 const [loading, setLoading] = useState(true);
157 const [error, setError] = useState("");
158
@@ -145,6 +161,7 @@ export function useAdmin() {
161 const applySnapshot = (snapshot: AdminSnapshot) => {
162 setServerData(snapshot.serverData);
163 setApprovalMode(snapshot.approvalMode);
164 + setUDPSettings(snapshot.udpSettings);
165 };
166
167 const fetchData = async () => {
@@ -289,6 +306,19 @@ export function useAdmin() {
306 });
307 };
308
309 + const handleUDPSettingsChange = async (settings: UDPSettings) => {
310 + await runAdminAction(async () => {
311 + const response = await apiClient.post<{ enabled: boolean; max_leases: number }>(
312 + API_PATHS.admin.udpSettings,
313 + { enabled: settings.enabled, max_leases: settings.maxLeases }
314 + );
315 + setUDPSettings({
316 + enabled: response?.enabled ?? settings.enabled,
317 + maxLeases: response?.max_leases ?? settings.maxLeases,
318 + });
319 + });
320 + };
321 +
322 const handleApproveStatus = (peerId: string, approve: boolean) =>
323 runAdminAction(() => updateLeaseAction(peerId, "approve", approve));
324
@@ -350,12 +380,14 @@ export function useAdmin() {
380 ...listState,
381 banFilter,
382 approvalMode,
383 + udpSettings,
384 loading,
385 error,
386 handleBanFilterChange,
387 handleBanStatus,
388 handleBPSChange,
389 handleApprovalModeChange,
390 + handleUDPSettingsChange,
391 handleApproveStatus,
392 handleDenyStatus,
393 handleIPBanStatus,
frontend/src/hooks/useSSRData.ts
+2
@@ -19,6 +19,8 @@ export interface ServerData {
19 Hostname: string;
20 Metadata: unknown;
21 Ready: number;
22 + Transport?: string;
23 + UDPPort?: number;
24 IsApproved?: boolean;
25 IsBanned?: boolean;
26 IsDenied?: boolean;
frontend/src/lib/apiPaths.ts
+1
@@ -8,6 +8,7 @@ export const API_PATHS = {
8 leases: "/admin/leases",
9 stats: "/admin/stats",
10 approvalMode: "/admin/settings/approval-mode",
11 + udpSettings: "/admin/settings/udp",
12 },
13 sdk: {
14 prefix: "/sdk",
frontend/src/pages/Admin.tsx
+4
@@ -19,6 +19,7 @@ export function Admin() {
19 selectedTags,
20 banFilter,
21 approvalMode,
22 + udpSettings,
23 favorites,
24 loading,
25 error,
@@ -31,6 +32,7 @@ export function Admin() {
32 handleBanStatus,
33 handleBPSChange,
34 handleApprovalModeChange,
35 + handleUDPSettingsChange,
36 handleApproveStatus,
37 handleDenyStatus,
38 handleIPBanStatus,
@@ -86,10 +88,12 @@ export function Admin() {
88 isAdmin={true}
89 banFilter={banFilter}
90 approvalMode={approvalMode}
91 + udpSettings={udpSettings}
92 onBanFilterChange={handleBanFilterChange}
93 onBanStatusChange={handleBanStatus}
94 onBPSChange={handleBPSChange}
95 onApprovalModeChange={handleApprovalModeChange}
96 + onUDPSettingsChange={handleUDPSettingsChange}
97 onApproveStatusChange={handleApproveStatus}
98 onDenyStatusChange={handleDenyStatus}
99 onIPBanStatusChange={handleIPBanStatus}
portal/api_server.go
+24 -7
@@ -23,13 +23,15 @@ import (
23 )
24
25 var (
26 - errFeatureUnavailable = errors.New(types.APIErrorCodeFeatureUnavailable)
27 - errHostnameConflict = errors.New(types.APIErrorCodeHostnameConflict)
28 - errIPBanned = errors.New(types.APIErrorCodeIPBanned)
29 - errLeaseNotFound = errors.New(types.APIErrorCodeLeaseNotFound)
30 - errLeaseRejected = errors.New(types.APIErrorCodeLeaseRejected)
31 - errTransportMismatch = errors.New(types.APIErrorCodeTransportMismatch)
32 - errUnauthorized = errors.New(types.APIErrorCodeUnauthorized)
26 + errFeatureUnavailable = errors.New(types.APIErrorCodeFeatureUnavailable)
27 + errHostnameConflict = errors.New(types.APIErrorCodeHostnameConflict)
28 + errIPBanned = errors.New(types.APIErrorCodeIPBanned)
29 + errLeaseNotFound = errors.New(types.APIErrorCodeLeaseNotFound)
30 + errLeaseRejected = errors.New(types.APIErrorCodeLeaseRejected)
31 + errTransportMismatch = errors.New(types.APIErrorCodeTransportMismatch)
32 + errUnauthorized = errors.New(types.APIErrorCodeUnauthorized)
33 + errUDPDisabled = errors.New(types.APIErrorCodeUDPDisabled)
34 + errUDPCapacityExceeded = errors.New(types.APIErrorCodeUDPCapacityExceeded)
35 )
36
37 func (s *Server) newAPIServer(listener net.Listener, apiMux *http.ServeMux, apiTLS keyless.TLSMaterialConfig) (net.Listener, *http.Server, io.Closer, error) {
@@ -139,6 +141,12 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
141 if errors.Is(err, transport.ErrPortExhausted) {
142 status, code = http.StatusServiceUnavailable, types.APIErrorCodeUDPPortExhausted
143 }
144 + if errors.Is(err, errUDPDisabled) {
145 + status, code = http.StatusForbidden, types.APIErrorCodeUDPDisabled
146 + }
147 + if errors.Is(err, errUDPCapacityExceeded) {
148 + status, code = http.StatusServiceUnavailable, types.APIErrorCodeUDPCapacityExceeded
149 + }
150 utils.WriteAPIError(w, status, code, err.Error())
151 return
152 }
@@ -390,6 +398,15 @@ func (s *Server) registerLease(req types.RegisterRequest, clientIP string) (type
398 return types.RegisterResponse{}, err
399 }
400
401 + if req.UDPEnabled {
402 + if !s.registry.policy.IsUDPEnabled() {
403 + return types.RegisterResponse{}, errUDPDisabled
404 + }
405 + if max := s.registry.policy.UDPMaxLeases(); max > 0 && s.registry.CountDatagramLeases() >= max {
406 + return types.RegisterResponse{}, errUDPCapacityExceeded
407 + }
408 + }
409 +
410 leaseID := utils.RandomID("lease_")
411 now := time.Now()
412 expiresAt := now.Add(ttl)
portal/lease.go
+13
@@ -212,6 +212,19 @@ func (r *leaseRegistry) removeExpired(now time.Time) []*leaseRecord {
212 return expired
213 }
214
215 +func (r *leaseRegistry) CountDatagramLeases() int {
216 + r.mu.RLock()
217 + defer r.mu.RUnlock()
218 + now := time.Now()
219 + count := 0
220 + for _, record := range r.leaseByID {
221 + if record.datagram != nil && now.Before(record.ExpiresAt) {
222 + count++
223 + }
224 + }
225 + return count
226 +}
227 +
228 func (r *leaseRegistry) Snapshot(record *leaseRecord) types.Lease {
229 if record == nil {
230 return types.Lease{}
portal/policy/runtime.go
+30
@@ -10,6 +10,8 @@ type Runtime struct {
10 bpsManager *BPSManager
11 ipFilter *IPFilter
12 bannedLeases map[string]struct{}
13 + udpEnabled bool
14 + udpMaxLeases int
15 mu sync.RWMutex
16 }
17
@@ -135,6 +137,34 @@ func (r *Runtime) IsLeaseRoutable(leaseID string) bool {
137 return r.EffectiveApproval(leaseID)
138 }
139
140 +func (r *Runtime) SetUDPPolicy(enabled bool, maxLeases int) {
141 + if r == nil {
142 + return
143 + }
144 + r.mu.Lock()
145 + r.udpEnabled = enabled
146 + r.udpMaxLeases = maxLeases
147 + r.mu.Unlock()
148 +}
149 +
150 +func (r *Runtime) IsUDPEnabled() bool {
151 + if r == nil {
152 + return false
153 + }
154 + r.mu.RLock()
155 + defer r.mu.RUnlock()
156 + return r.udpEnabled
157 +}
158 +
159 +func (r *Runtime) UDPMaxLeases() int {
160 + if r == nil {
161 + return 0
162 + }
163 + r.mu.RLock()
164 + defer r.mu.RUnlock()
165 + return r.udpMaxLeases
166 +}
167 +
168 func (r *Runtime) ForgetLease(leaseID string) {
169 if r == nil {
170 return
portal/server.go
+23 -17
@@ -25,14 +25,18 @@ import (
25 )
26
27 const (
28 - defaultLeaseTTL = 30 * time.Second
29 - defaultClaimTimeout = 10 * time.Second
30 - defaultIdleKeepalive = 15 * time.Second
31 - defaultReadyQueueLimit = 8
32 - defaultClientHelloWait = 2 * time.Second
33 - defaultControlBodyLimit = 4 << 20
34 - defaultUDPPortMin = 29900
35 - defaultUDPPortMax = 29999
28 + defaultLeaseTTL = 30 * time.Second
29 + defaultClaimTimeout = 10 * time.Second
30 + defaultIdleKeepalive = 15 * time.Second
31 + defaultReadyQueueLimit = 8
32 + defaultClientHelloWait = 2 * time.Second
33 + defaultControlBodyLimit = 4 << 20
34 + defaultSessionWriteLimit = 5 * time.Second
35 + defaultQUICSNIRouteIdle = 30 * time.Second
36 + defaultQUICSNICleanup = 5 * time.Second
37 +
38 + defaultUDPPortBase = 50000
39 + defaultUDPPortCount = 0
40 )
41
42 type ServerConfig struct {
@@ -41,7 +45,6 @@ type ServerConfig struct {
45 APIListenAddr string
46 SNIListenAddr string
47 QUICListenAddr string
44 - UDPEnabled bool
48 TrustedProxyCIDRs []*net.IPNet
49 LeaseTTL time.Duration
50 ClaimTimeout time.Duration
@@ -49,8 +52,7 @@ type ServerConfig struct {
52 ReadyQueueLimit int
53 ClientHelloTimeout time.Duration
54 TrustProxyHeaders bool
52 - UDPPortMin int
53 - UDPPortMax int
55 + UDPPortCount int
56 }
57
58 type Server struct {
@@ -85,14 +87,18 @@ func NewServer(cfg ServerConfig) (*Server, error) {
87 if rootHost == "" {
88 return nil, errors.New("root host is required")
89 }
88 - cfg.UDPPortMin = utils.IntOrDefault(cfg.UDPPortMin, defaultUDPPortMin)
89 - cfg.UDPPortMax = utils.IntOrDefault(cfg.UDPPortMax, defaultUDPPortMax)
90 if cfg.QUICListenAddr == "" {
91 - cfg.QUICListenAddr = cfg.APIListenAddr
91 + cfg.QUICListenAddr = cfg.SNIListenAddr
92 + }
93 +
94 + portMin, portMax := 0, 0
95 + if cfg.UDPPortCount > 0 {
96 + portMin = defaultUDPPortBase
97 + portMax = defaultUDPPortBase + cfg.UDPPortCount - 1
98 }
99
100 registry := newLeaseRegistry(policy.NewRuntime())
95 - ports := transport.NewPortAllocator(cfg.UDPPortMin, cfg.UDPPortMax, 5*time.Minute)
101 + ports := transport.NewPortAllocator(portMin, portMax, 5*time.Minute)
102
103 s := &Server{
104 cfg: cfg,
@@ -159,7 +165,7 @@ func (s *Server) Start(ctx context.Context, apiMux *http.ServeMux) error {
165 group.Go(func() error { return s.watchContext(groupCtx) })
166 s.acmeManager.Start(serverCtx)
167
162 - if s.cfg.UDPEnabled {
168 + if s.cfg.UDPPortCount > 0 {
169 if err := s.startQUICTunnelListener(apiTLS); err != nil {
170 log.Warn().Err(err).Msg("quic tunnel listener disabled")
171 }
@@ -382,7 +388,7 @@ func (s *Server) resolveStream(serverName string) (*transport.RelayStream, error
388 }
389
390 func (s *Server) datagramPlaneReady() bool {
385 - if s == nil || !s.cfg.UDPEnabled {
391 + if s == nil || s.cfg.UDPPortCount <= 0 {
392 return false
393 }
394 if s.group == nil {
portal/server_test.go
+7 -26
@@ -4,7 +4,6 @@ import (
4 "context"
5 "crypto/tls"
6 "encoding/json"
7 - "net"
7 "net/http"
8 "strings"
9 "testing"
@@ -22,7 +21,7 @@ func TestServerStartInitializesLocalACMEAndSigner(t *testing.T) {
21 ACME: acme.Config{KeyDir: t.TempDir()},
22 APIListenAddr: "127.0.0.1:0",
23 SNIListenAddr: "127.0.0.1:0",
25 - UDPEnabled: true,
24 + UDPPortCount: 1,
25 })
26 if err != nil {
27 t.Fatalf("NewServer() error = %v", err)
@@ -85,7 +84,7 @@ func TestServerStartRejectsMismatchedACMEBaseDomain(t *testing.T) {
84 ACME: acme.Config{BaseDomain: "other.example.com", KeyDir: t.TempDir()},
85 APIListenAddr: "127.0.0.1:0",
86 SNIListenAddr: "127.0.0.1:0",
88 - UDPEnabled: true,
87 + UDPPortCount: 1,
88 })
89 if err != nil {
90 t.Fatalf("NewServer() error = %v", err)
@@ -105,7 +104,7 @@ func TestRegisterLeaseDerivesFixedHostnameFromName(t *testing.T) {
104
105 server, err := NewServer(ServerConfig{
106 PortalURL: "https://portal.example.com",
108 - UDPEnabled: true,
107 + UDPPortCount: 1,
108 })
109 if err != nil {
110 t.Fatalf("NewServer() error = %v", err)
@@ -142,7 +141,7 @@ func TestRegisterLeaseRejectsInvalidName(t *testing.T) {
141
142 server, err := NewServer(ServerConfig{
143 PortalURL: "https://portal.example.com",
145 - UDPEnabled: true,
144 + UDPPortCount: 1,
145 })
146 if err != nil {
147 t.Fatalf("NewServer() error = %v", err)
@@ -160,16 +159,14 @@ func TestRegisterLeaseRejectsInvalidName(t *testing.T) {
159 func TestRegisterLeaseBuildsUDPEnabledRuntime(t *testing.T) {
160 t.Parallel()
161
163 - udpPort := reserveUDPPort(t)
162 server, err := NewServer(ServerConfig{
165 - PortalURL: "https://portal.example.com",
166 - UDPEnabled: true,
167 - UDPPortMin: udpPort,
168 - UDPPortMax: udpPort,
163 + PortalURL: "https://portal.example.com",
164 + UDPPortCount: 10,
165 })
166 if err != nil {
167 t.Fatalf("NewServer() error = %v", err)
168 }
169 + server.registry.policy.SetUDPPolicy(true, 0)
170
171 resp, err := server.registerLease(types.RegisterRequest{
172 Name: "demo-udp",
@@ -202,19 +199,3 @@ func TestRegisterLeaseBuildsUDPEnabledRuntime(t *testing.T) {
199 t.Fatal("RegisterResponse.UDPAddr = empty, want public udp address")
200 }
201 }
205 -
206 -func reserveUDPPort(t *testing.T) int {
207 - t.Helper()
208 -
209 - conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0})
210 - if err != nil {
211 - t.Fatalf("ListenUDP() error = %v", err)
212 - }
213 - defer conn.Close()
214 -
215 - addr, ok := conn.LocalAddr().(*net.UDPAddr)
216 - if !ok || addr.Port == 0 {
217 - t.Fatalf("LocalAddr() = %v, want UDP port", conn.LocalAddr())
218 - }
219 - return addr.Port
220 -}
portal/transport/datagram_relay.go
+8
@@ -46,6 +46,14 @@ type PortAllocator struct {
46 }
47
48 func NewPortAllocator(min, max int, grace time.Duration) *PortAllocator {
49 + if min <= 0 || max <= 0 || min > max {
50 + return &PortAllocator{
51 + available: nil,
52 + inUse: make(map[int]string),
53 + reserved: make(map[string]portReservation),
54 + grace: grace,
55 + }
56 + }
57 available := make([]int, 0, max-min+1)
58 for p := min; p <= max; p++ {
59 available = append(available, p)
sdk/api_client.go
+2 -1
@@ -317,7 +317,8 @@ func (a *apiClient) openQUICSession(ctx context.Context, leaseID, reverseToken s
317 MaxIdleTimeout: 60 * time.Second,
318 }
319
320 - conn, err := quic.DialAddr(ctx, utils.EnsurePort(a.baseURL.Host), tlsConf, quicConf)
320 + dialAddr := utils.EnsurePort(a.baseURL.Host)
321 + conn, err := quic.DialAddr(ctx, dialAddr, tlsConf, quicConf)
322 if err != nil {
323 return nil, fmt.Errorf("quic dial: %w", err)
324 }
sdk/listener.go
+2 -2
@@ -58,8 +58,8 @@ type Listener struct {
58 startupStatus listenerStatus
59 leaseID string
60 hostname string
61 - udpAddr string
62 - udpEnabled bool
61 + udpAddr string
62 + udpEnabled bool
63 metadata types.LeaseMetadata
64 stream *transport.ClientStream
65 datagram *transport.ClientDatagram
types/api.go
+15 -4
@@ -67,8 +67,8 @@ type RegisterResponse struct {
67 ConnectURL string `json:"connect_url"`
68 Hostname string `json:"hostname"`
69 Metadata LeaseMetadata `json:"metadata"`
70 - UDPAddr string `json:"udp_addr,omitempty"`
71 - UDPEnabled bool `json:"udp_enabled,omitempty"`
70 + UDPAddr string `json:"udp_addr,omitempty"`
71 + UDPEnabled bool `json:"udp_enabled,omitempty"`
72 }
73
74 type QUICControlMessage struct {
@@ -115,8 +115,9 @@ type AdminAuthStatusResponse struct {
115 }
116
117 type AdminSnapshotResponse struct {
118 - ApprovalMode string `json:"approval_mode"`
119 - Leases []Lease `json:"leases,omitempty"`
118 + ApprovalMode string `json:"approval_mode"`
119 + Leases []Lease `json:"leases,omitempty"`
120 + UDP AdminUDPSettingsResponse `json:"udp"`
121 }
122
123 type AdminApprovalModeRequest struct {
@@ -130,3 +131,13 @@ type AdminApprovalModeResponse struct {
131 type AdminBPSRequest struct {
132 BPS int64 `json:"bps"`
133 }
134 +
135 +type AdminUDPSettingsRequest struct {
136 + Enabled bool `json:"enabled"`
137 + MaxLeases int `json:"max_leases"`
138 +}
139 +
140 +type AdminUDPSettingsResponse struct {
141 + Enabled bool `json:"enabled"`
142 + MaxLeases int `json:"max_leases"`
143 +}
types/error.go
+2
@@ -20,5 +20,7 @@ const (
20 APIErrorCodeSessionCreateFailed = "session_create_failed"
21 APIErrorCodeUnauthorized = "unauthorized"
22 APIErrorCodeUDPPortExhausted = "udp_port_exhausted"
23 + APIErrorCodeUDPDisabled = "udp_disabled"
24 + APIErrorCodeUDPCapacityExceeded = "udp_capacity_exceeded"
25 APIErrorCodeTransportMismatch = "transport_mismatch"
26 )
types/paths.go
+1
@@ -16,6 +16,7 @@ const (
16 PathAdminLogout = "/admin/logout"
17 PathAdminAuthStatus = "/admin/auth/status"
18 PathAdminApproval = "/admin/settings/approval-mode"
19 + PathAdminUDP = "/admin/settings/udp"
20 PathAdminIPsPrefix = "/admin/ips/"
21 PathInstallShell = "/install.sh"
22 PathInstallPowerShell = "/install.ps1"