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"