feat: add QUIC/UDP transport support
Hee Sung Son committed
Mar 12, 2026 at 21:13 UTC
cdbac74d9e67c2389e5e0bdc0ea3d3cad2453ca0
25 files changed
+2078
-89
cmd/portal-tunnel/main.go
+46
-5
@@ -62,13 +62,20 @@ func runTunnel() error {
62
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
63
defer stop()
64
65
- exposure, err := sdk.Expose(ctx, sdk.SplitCSV(flagRelayURLs), flagName, types.LeaseMetadata{
65
+ metadata := types.LeaseMetadata{
66
Description: flagDesc,
67
Tags: sdk.SplitCSV(flagTags),
68
Owner: flagOwner,
69
Thumbnail: flagThumbnail,
70
Hide: flagHide,
71
- })
71
+ }
72
+
73
+ logger.Info().
74
+ Str("local", flagHost).
75
+ Msg("starting portal tunnel")
76
+
77
+ // TCP is the primary transport — failure is fatal.
78
+ exposure, err := sdk.Expose(ctx, sdk.SplitCSV(flagRelayURLs), flagName, metadata)
79
if err != nil {
80
return fmt.Errorf("service %s: failed to start relays: %w", flagName, err)
81
}
@@ -77,9 +84,8 @@ func runTunnel() error {
84
}
85
defer exposure.Close()
86
80
- logger.Info().
81
- Str("local", flagHost).
82
- Msg("starting portal tunnel")
87
+ // UDP is best-effort — attach to existing lease, log and continue if it fails.
88
+ go runUDPBestEffort(ctx, exposure)
89
90
var connWG sync.WaitGroup
91
var connCount atomic.Int64
@@ -120,3 +126,38 @@ func runTunnel() error {
126
logger.Info().Msg("tunnel shutdown complete")
127
return errors.Join(waitErr, closeErr)
128
}
129
+
130
+// runUDPBestEffort attaches UDP listeners to the existing TCP exposure lease.
131
+// Failures are logged but do not bring down the tunnel.
132
+func runUDPBestEffort(ctx context.Context, exposure *sdk.Exposure) {
133
+ logger := log.With().Str("component", "portal-tunnel-udp").Logger()
134
+
135
+ udpListeners, err := exposure.AttachUDP(ctx)
136
+ if err != nil {
137
+ logger.Warn().Err(err).Msg("udp transport disabled: attach failed")
138
+ return
139
+ }
140
+ if len(udpListeners) == 0 {
141
+ logger.Info().Msg("udp transport: no UDP addresses from relay")
142
+ return
143
+ }
144
+
145
+ var wg sync.WaitGroup
146
+ for _, ul := range udpListeners {
147
+ wg.Add(1)
148
+ go func(l *sdk.UDPListener) {
149
+ defer wg.Done()
150
+ defer l.Close()
151
+
152
+ logger.Info().
153
+ Str("udp_addr", l.UDPAddr()).
154
+ Str("lease_id", l.LeaseID()).
155
+ Msg("UDP tunnel ready")
156
+
157
+ if err := proxyUDPRelayConnections(ctx, l, flagHost); err != nil {
158
+ logger.Warn().Err(err).Msg("udp proxy ended")
159
+ }
160
+ }(ul)
161
+ }
162
+ wg.Wait()
163
+}
cmd/portal-tunnel/relays.go
+115
@@ -132,3 +132,118 @@ func writeEmptyHTTPResponse(conn net.Conn) error {
132
_, err := conn.Write([]byte(response))
133
return err
134
}
135
+
136
+// proxyUDPRelayConnections receives datagrams from the relay via the UDPListener
137
+// and forwards them to the local UDP service, relaying responses back.
138
+func proxyUDPRelayConnections(ctx context.Context, udpListener *sdk.UDPListener, localAddr string) error {
139
+ logger := log.With().Str("component", "portal-tunnel-udp").Logger()
140
+
141
+ targetAddr, err := sdk.NormalizeTargetAddr(localAddr)
142
+ if err != nil {
143
+ return fmt.Errorf("invalid --host value %q: %w", localAddr, err)
144
+ }
145
+
146
+ resolvedAddr, err := net.ResolveUDPAddr("udp", targetAddr)
147
+ if err != nil {
148
+ return fmt.Errorf("resolve udp addr %q: %w", targetAddr, err)
149
+ }
150
+
151
+ // Per-flow local UDP connections: flowID → *net.UDPConn
152
+ type flowEntry struct {
153
+ conn *net.UDPConn
154
+ lastSeen time.Time
155
+ }
156
+ var mu sync.Mutex
157
+ flows := make(map[uint32]*flowEntry)
158
+
159
+ // Cleanup idle flow connections.
160
+ go func() {
161
+ ticker := time.NewTicker(15 * time.Second)
162
+ defer ticker.Stop()
163
+ for {
164
+ select {
165
+ case <-ctx.Done():
166
+ return
167
+ case <-ticker.C:
168
+ mu.Lock()
169
+ now := time.Now()
170
+ for id, f := range flows {
171
+ if now.Sub(f.lastSeen) > 30*time.Second {
172
+ _ = f.conn.Close()
173
+ delete(flows, id)
174
+ }
175
+ }
176
+ mu.Unlock()
177
+ }
178
+ }
179
+ }()
180
+
181
+ // getOrCreateFlow returns (or creates) a local UDP conn for a flow.
182
+ getOrCreateFlow := func(flowID uint32) (*net.UDPConn, error) {
183
+ mu.Lock()
184
+ if f, ok := flows[flowID]; ok {
185
+ f.lastSeen = time.Now()
186
+ mu.Unlock()
187
+ return f.conn, nil
188
+ }
189
+ mu.Unlock()
190
+
191
+ localConn, err := net.DialUDP("udp", nil, resolvedAddr)
192
+ if err != nil {
193
+ return nil, err
194
+ }
195
+
196
+ mu.Lock()
197
+ // Double-check after acquiring lock.
198
+ if f, ok := flows[flowID]; ok {
199
+ mu.Unlock()
200
+ _ = localConn.Close()
201
+ f.lastSeen = time.Now()
202
+ return f.conn, nil
203
+ }
204
+ flows[flowID] = &flowEntry{conn: localConn, lastSeen: time.Now()}
205
+ mu.Unlock()
206
+
207
+ // Start reverse read loop: local service → relay.
208
+ go func() {
209
+ buf := make([]byte, 65535)
210
+ for {
211
+ n, err := localConn.Read(buf)
212
+ if err != nil {
213
+ if ctx.Err() != nil {
214
+ return
215
+ }
216
+ logger.Debug().Err(err).Uint32("flow_id", flowID).Msg("local read ended")
217
+ return
218
+ }
219
+ if sendErr := udpListener.SendDatagram(flowID, buf[:n]); sendErr != nil {
220
+ logger.Debug().Err(sendErr).Uint32("flow_id", flowID).Msg("send datagram to relay failed")
221
+ return
222
+ }
223
+ }
224
+ }()
225
+
226
+ return localConn, nil
227
+ }
228
+
229
+ // Main loop: relay → local service.
230
+ for {
231
+ dg, err := udpListener.AcceptDatagram()
232
+ if err != nil {
233
+ if ctx.Err() != nil || errors.Is(err, net.ErrClosed) {
234
+ return nil
235
+ }
236
+ return fmt.Errorf("accept datagram: %w", err)
237
+ }
238
+
239
+ localConn, err := getOrCreateFlow(dg.FlowID)
240
+ if err != nil {
241
+ logger.Warn().Err(err).Uint32("flow_id", dg.FlowID).Msg("dial local udp failed")
242
+ continue
243
+ }
244
+
245
+ if _, err := localConn.Write(dg.Payload); err != nil {
246
+ logger.Debug().Err(err).Uint32("flow_id", dg.FlowID).Msg("write to local udp failed")
247
+ }
248
+ }
249
+}
cmd/relay-server/main.go
+8
@@ -14,6 +14,8 @@ import (
14
const (
15
defaultAPIPort = 4017
16
defaultSNIPort = 443
17
+ defaultUDPPortMin = 29000
18
+ defaultUDPPortMax = 29999
19
defaultPortalURL = "https://localhost:4017"
20
defaultKeylessDir = "./.portal-certs"
21
)
@@ -23,6 +25,8 @@ type relayServerConfig struct {
25
Bootstraps []string
26
APIPort int
27
SNIPort int
28
+ UDPPortMin int
29
+ UDPPortMax int
30
AdminSecretKey string
31
TrustProxyHeaders bool
32
TrustedProxyCIDRs string
@@ -52,6 +56,8 @@ func main() {
56
}
57
apiPort := parsePortNumber(os.Getenv("API_PORT"), defaultAPIPort)
58
sniPort := parsePortNumber(os.Getenv("SNI_PORT"), defaultSNIPort)
59
+ udpPortMin := parsePortNumber(os.Getenv("UDP_PORT_MIN"), defaultUDPPortMin)
60
+ udpPortMax := parsePortNumber(os.Getenv("UDP_PORT_MAX"), defaultUDPPortMax)
61
adminSecretKey := trimmedEnv("ADMIN_SECRET_KEY")
62
trustProxyHeaders := parseBoolEnv("TRUST_PROXY_HEADERS")
63
trustedProxyCIDRs := trimmedEnv("TRUSTED_PROXY_CIDRS")
@@ -77,6 +83,8 @@ func main() {
83
flag.StringVar(&bootstrapsCSV, "bootstraps", bootstrapsCSV, "bootstrap URIs, comma-separated (env: BOOTSTRAP_URIS)")
84
flag.IntVar(&cfg.APIPort, "api-port", apiPort, "Admin/API server port (env: API_PORT)")
85
flag.IntVar(&cfg.SNIPort, "sni-port", sniPort, "SNI router port number (env: SNI_PORT)")
86
+ flag.IntVar(&cfg.UDPPortMin, "udp-port-min", udpPortMin, "Minimum UDP port for lease allocation (env: UDP_PORT_MIN)")
87
+ flag.IntVar(&cfg.UDPPortMax, "udp-port-max", udpPortMax, "Maximum UDP port for lease allocation (env: UDP_PORT_MAX)")
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)")
cmd/relay-server/serve.go
+21
-3
@@ -8,6 +8,7 @@ import (
8
"os/signal"
9
"strings"
10
"syscall"
11
+ "time"
12
13
"github.com/rs/zerolog/log"
14
@@ -69,10 +70,15 @@ func runServer(cfg relayServerConfig) error {
70
logger.Warn().Err(loadErr).Msg("load admin settings")
71
}
72
73
+ quicListenAddr := fmt.Sprintf(":%d", cfg.APIPort)
74
+
75
server, err := portal.NewServer(portal.ServerConfig{
76
PortalURL: cfg.PortalURL,
77
APIListenAddr: apiListenAddr,
78
SNIListenAddr: sniListenAddr,
79
+ QUICListenAddr: quicListenAddr,
80
+ UDPPortMin: cfg.UDPPortMin,
81
+ UDPPortMax: cfg.UDPPortMax,
82
RootHost: rootHost,
83
RootFallbackAddr: portal.HostPortOrLoopback(apiListenAddr),
84
Policy: adminHandler.Runtime(),
@@ -102,13 +108,25 @@ func runServer(cfg relayServerConfig) error {
108
acmeManager.Start(ctx)
109
defer acmeManager.Stop()
110
105
- logger.Info().
111
+ logEvent := logger.Info().
112
Str("api_addr", portal.HostPortOrLoopback(server.APIAddr())).
113
Str("sni_addr", server.SNIAddr()).
114
Str("root_host", rootHost).
115
Str("acme_dns_provider", cfg.ACMEDNSProvider).
110
- Bool("acme_enabled", !strings.HasSuffix(rootHost, "localhost") && rootHost != "127.0.0.1" && rootHost != "::1").
111
- Msg("relay server started")
116
+ Bool("acme_enabled", !strings.HasSuffix(rootHost, "localhost") && rootHost != "127.0.0.1" && rootHost != "::1")
117
+ if quicAddr := server.QUICAddr(); quicAddr != "" {
118
+ logEvent = logEvent.Str("quic_addr", quicAddr)
119
+ }
120
+ logEvent.Msg("relay server started")
121
+
122
+ // Force-exit if graceful shutdown does not complete within 15 seconds.
123
+ go func() {
124
+ <-ctx.Done()
125
+ time.AfterFunc(15*time.Second, func() {
126
+ logger.Warn().Msg("graceful shutdown timed out, forcing exit")
127
+ os.Exit(1)
128
+ })
129
+ }()
130
131
return server.Wait()
132
}
docker-compose.yml
+7
-4
@@ -4,15 +4,21 @@ services:
4
build:
5
context: .
6
dockerfile: Dockerfile
7
+ network_mode: host
8
+ stop_grace_period: 30s
9
environment:
10
# Public routing
11
PORTAL_URL: ${PORTAL_URL:-https://localhost:${API_PORT:-4017}}
12
BOOTSTRAP_URIS: ${BOOTSTRAP_URIS:-https://localhost:${API_PORT:-4017}}
13
12
- # Listener ports
14
+ # Listener ports (bound directly on host via host networking)
15
API_PORT: ${API_PORT:-4017}
16
SNI_PORT: ${SNI_PORT:-443}
17
18
+ # UDP port allocation range for QUIC/UDP leases
19
+ UDP_PORT_MIN: ${UDP_PORT_MIN:-29000}
20
+ UDP_PORT_MAX: ${UDP_PORT_MAX:-29999}
21
+
22
# Admin/auth configuration
23
ADMIN_SECRET_KEY: ${ADMIN_SECRET_KEY:-}
24
TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-false}
@@ -28,9 +34,6 @@ services:
34
AWS_REGION: ${AWS_REGION:-}
35
AWS_DEFAULT_REGION: ${AWS_DEFAULT_REGION:-}
36
AWS_HOSTED_ZONE_ID: ${AWS_HOSTED_ZONE_ID:-}
31
- ports:
32
- - "${API_PORT:-4017}:${API_PORT:-4017}"
33
- - "443:443"
37
volumes:
38
- ${KEYLESS_DIR:-./.portal-certs}:/.portal-certs
39
restart: unless-stopped
docs/adr/README.md
+1
@@ -14,6 +14,7 @@ This directory is the source of truth for major Portal architecture decisions.
14
- [0001 - Raw TCP Reverse Connect and ACME TLS for Portal Root](./0001-raw-tcp-reverse-connect-and-autocert-tls.md) (`Accepted`)
15
- [0002 - Remove WebSocket and Legacy Compatibility Paths](./0002-remove-websocket-and-legacy-compatibility.md) (`Accepted`)
16
- [0003 - Security and Anti-Abuse Hardening](./0003-security-and-anti-abuse-hardening.md) (`Deprecated`)
17
+- [0004 - QUIC-Based UDP Transport](./0004-quic-udp-transport.md) (`Accepted`)
18
19
## Authoring Notes
20
docs/deployment.md
+19
-4
@@ -8,7 +8,7 @@ You need:
8
9
- A public domain (example: `example.com`)
10
- A public Linux server with a static public IP
11
-- Open inbound ports: `443/tcp`, `4017/tcp`
11
+- Open inbound ports: `443/tcp`, `4017/tcp`, `4017/udp`, `29000-29999/udp` (UDP range for QUIC/UDP leases)
12
- Docker and Docker Compose
13
- A DNS provider account for ACME DNS-01 automation with a supported provider (`cloudflare` or `route53`)
14
@@ -110,7 +110,18 @@ Equivalent relay flags:
110
- `/sdk/renew` and `/sdk/unregister` require `lease_id` + `reverse_token`.
111
- `/sdk/connect` is hijacked into a long-lived reverse TCP session after validation.
112
113
-### 3.2 Certificates and DNS Maintenance
113
+### 3.2 UDP/QUIC Transport
114
+
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 `29000`–`29999`) for UDP leases. The tunnel CLI starts both TCP and UDP transports by default; if the relay does not support UDP, the tunnel falls back to TCP-only.
116
+
117
+| Variable | Default | Description |
118
+|---|---|---|
119
+| `UDP_PORT_MIN` | `29000` | Start of the UDP port allocation range |
120
+| `UDP_PORT_MAX` | `29999` | End of the UDP port allocation range |
121
+
122
+> **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.
123
+
124
+### 3.3 Certificates and DNS Maintenance
125
126
- Relay certificates live in `KEYLESS_DIR`:
127
- `fullchain.pem`
@@ -252,13 +263,17 @@ sudo journalctl -u portal-watcher --since today
263
264
Required inbound ports:
265
255
-- `443/tcp`
256
-- `4017/tcp`
266
+- `443/tcp` — SNI router (tenant TLS passthrough)
267
+- `4017/tcp` — Admin/API listener
268
+- `4017/udp` — QUIC tunnel listener (tunnel ↔ relay)
269
+- `29000-29999/udp` — Raw UDP lease ports (client ↔ relay, adjust to match `UDP_PORT_MAX`)
270
271
UFW example:
272
273
```bash
274
sudo ufw allow 443/tcp
275
sudo ufw allow 4017/tcp
276
+sudo ufw allow 4017/udp
277
+sudo ufw allow 29000:29999/udp
278
sudo ufw status
279
```
docs/examples/nginx-proxy-multi-service/docker-compose.yaml
+15
-11
@@ -5,8 +5,8 @@
5
#
6
# Architecture:
7
# nginx:443 (L4 stream, ssl_preread)
8
-# ├─ portal.example.com → portal:4017 (TLS passthrough, admin/API)
9
-# ├─ *.portal.example.com → portal:443 (TLS passthrough, tenant SNI)
8
+# ├─ portal.example.com → host.docker.internal:4017 (TLS passthrough, admin/API)
9
+# ├─ *.portal.example.com → host.docker.internal:4443 (TLS passthrough, tenant SNI)
10
# └─ everything else → nginx:8443 (L7, TLS termination)
11
# ├─ app-a.example.com → app-a-frontend:3000 / app-a-api:8000
12
# └─ app-b.example.com → app-b-frontend:3000 / app-b-api:8001
@@ -35,11 +35,12 @@ services:
35
ports:
36
- "80:80"
37
- "443:443"
38
+ extra_hosts:
39
+ - "host.docker.internal:host-gateway"
40
volumes:
41
- ./nginx.conf:/etc/nginx/nginx.conf:ro
42
- ./certs:/etc/certs:ro
43
depends_on:
42
- - portal
44
- app-a-api
45
- app-a-frontend
46
restart: unless-stopped
@@ -49,27 +50,30 @@ services:
50
- app-b-network
51
52
# ─── portal relay ───────────────────────────────────────────────────────────
52
- # NAT-traversal relay. Ports are internal-only — nginx routes to them
53
- # via the docker network (L4 SNI passthrough, no TLS termination by nginx).
53
+ # NAT-traversal relay with host networking.
54
+ # TCP (4017, 4443) is reached by nginx via host.docker.internal.
55
+ # UDP (4017, 29000-29999) is bound directly on the host.
56
+ # SNI_PORT is 4443 to avoid conflicting with nginx on 443.
57
portal:
58
image: ghcr.io/gosuda/portal:2
59
container_name: portal
60
+ network_mode: host
61
+ stop_grace_period: 30s
62
environment:
63
PORTAL_URL: ${PORTAL_URL:-https://portal.example.com}
64
BOOTSTRAP_URIS: ${BOOTSTRAP_URIS:-https://portal.example.com}
65
API_PORT: ${API_PORT:-4017}
61
- SNI_PORT: ${SNI_PORT:-443}
66
+ SNI_PORT: ${SNI_PORT:-4443}
67
+ UDP_PORT_MIN: ${UDP_PORT_MIN:-29000}
68
+ UDP_PORT_MAX: ${UDP_PORT_MAX:-29999}
69
ADMIN_SECRET_KEY: ${ADMIN_SECRET_KEY:-}
70
+ TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-true}
71
+ TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-127.0.0.0/8}
72
KEYLESS_DIR: ${KEYLESS_DIR:-/portal-certs}
73
CLOUDFLARE_TOKEN: ${CLOUDFLARE_TOKEN:-}
74
volumes:
75
- ./portal-certs:/portal-certs
67
- expose:
68
- - "4017"
69
- - "443"
76
restart: unless-stopped
71
- networks:
72
- - default
77
78
# ─── App A: backend ─────────────────────────────────────────────────────────
79
# Replace with your actual backend service image and config.
docs/examples/nginx-proxy-multi-service/nginx.conf
+10
-6
@@ -13,8 +13,8 @@
13
# Traffic flow:
14
# :80 → redirect to HTTPS
15
# :443 → L4 SNI inspection (ssl_preread)
16
-# portal.example.com → portal:4017 (admin/API, TLS passthrough)
17
-# *.portal.example.com → portal:443 (tenant SNI passthrough)
16
+# portal.example.com → host.docker.internal:4017 (admin/API, TLS passthrough)
17
+# *.portal.example.com → host.docker.internal:4443 (portal SNI, raw TCP passthrough)
18
# everything else → 127.0.0.1:8443 (nginx L7, TLS termination)
19
20
user nginx;
@@ -46,14 +46,18 @@ stream {
46
}
47
48
upstream portal_admin {
49
- # Portal admin/API TLS listener. nginx does NOT terminate TLS here.
50
- server portal:4017;
49
+ # Portal admin/API TLS listener (host networking).
50
+ # nginx does NOT terminate TLS here.
51
+ # Use host.docker.internal to reach portal on the host from bridge network.
52
+ server host.docker.internal:4017;
53
}
54
55
upstream portal_sni {
54
- # Portal SNI listener. Relay routes by SNI and bridges raw TCP
56
+ # Portal SNI listener (host networking, port 4443 to avoid conflict
57
+ # with nginx on 443). Relay routes by SNI and bridges raw TCP
58
# to the claimed reverse session. TLS is not terminated.
56
- server portal:443;
59
+ # Use host.docker.internal to reach portal on the host from bridge network.
60
+ server host.docker.internal:4443;
61
}
62
63
upstream local_https {
docs/examples/nginx-proxy/.env.example
+4
@@ -9,6 +9,10 @@ BOOTSTRAP_URIS=https://portal.example.com
9
API_PORT=4017
10
SNI_PORT=443
11
12
+# UDP port allocation range for QUIC/UDP leases (default: 29000-29999)
13
+# UDP_PORT_MIN=29000
14
+# UDP_PORT_MAX=29999
15
+
16
# Admin secret for the /admin UI
17
ADMIN_SECRET_KEY=
18
docs/examples/nginx-proxy/docker-compose.yaml
+25
-29
@@ -2,9 +2,15 @@
2
# Replace "portal.example.com" with your actual domain throughout.
3
#
4
# Architecture:
5
-# nginx:443 (L4 stream, ssl_preread)
6
-# ├─ portal.example.com → nginx:8443 (L7, TLS termination) → portal:4017
7
-# └─ *.portal.example.com → portal:443 (raw TCP SNI passthrough)
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:29000-29999/udp (per-lease UDP relay ports — direct on host)
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.
14
#
15
# Prerequisites:
16
# 1. Copy .env.example to .env and set all required values.
@@ -23,57 +29,47 @@ services:
29
# ─── nginx ──────────────────────────────────────────────────────────────────
30
# Handles all inbound traffic on ports 80 and 443.
31
# L4 stream block routes by SNI; L7 http block terminates TLS for root domain.
32
+ # Host networking so 127.0.0.1 reaches portal (also on host network).
33
nginx:
34
image: nginx:stable-alpine
35
container_name: nginx
29
- ports:
30
- - "80:80"
31
- - "443:443"
36
+ network_mode: host
37
volumes:
38
- ./nginx.conf:/etc/nginx/nginx.conf:ro
39
- ./certs:/etc/nginx/certs:ro
40
depends_on:
41
- portal
42
restart: unless-stopped
38
- networks:
39
- - portal-net
43
44
# ─── portal relay ───────────────────────────────────────────────────────────
42
- # Relay server. Ports are internal-only when behind nginx.
43
- # nginx forwards raw TCP for tenant subdomains to portal:443.
44
- # nginx proxies admin/API HTTP to portal:4017.
45
+ # Relay server with host networking.
46
+ # TCP (4017, 4443) is reached by nginx via 127.0.0.1.
47
+ # UDP (4017, 29000-29999) is bound directly on the host — no port mapping.
48
+ # SNI_PORT is set to 4443 to avoid conflicting with nginx on port 443.
49
portal:
50
image: ghcr.io/gosuda/portal:2
51
container_name: portal
52
+ network_mode: host
53
+ stop_grace_period: 30s
54
environment:
49
- # Public-facing relay URL. nginx handles TLS on port 443 externally,
50
- # so this URL should not include a port number.
55
PORTAL_URL: ${PORTAL_URL:-https://portal.example.com}
56
BOOTSTRAP_URIS: ${BOOTSTRAP_URIS:-https://portal.example.com}
57
54
- # Internal listener ports (not exposed to host).
58
+ # Listener ports (bound directly on host via host networking).
59
API_PORT: ${API_PORT:-4017}
56
- SNI_PORT: ${SNI_PORT:-443}
60
+ # Use a non-443 port to avoid conflict with nginx on the host.
61
+ SNI_PORT: ${SNI_PORT:-4443}
62
58
- # Admin secret for the /admin UI. Set a strong random value.
59
- ADMIN_SECRET_KEY: ${ADMIN_SECRET_KEY:-}
63
+ # UDP port allocation range for QUIC/UDP leases.
64
+ UDP_PORT_MIN: ${UDP_PORT_MIN:-29000}
65
+ UDP_PORT_MAX: ${UDP_PORT_MAX:-29999}
66
61
- # Trust X-Forwarded-For and X-Real-IP headers from nginx.
67
+ ADMIN_SECRET_KEY: ${ADMIN_SECRET_KEY:-}
68
TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-true}
63
- TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-}
69
+ TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-127.0.0.0/8}
70
65
- # Portal's own ACME-managed TLS certificates for keyless signing.
71
KEYLESS_DIR: ${KEYLESS_DIR:-/portal-certs}
72
CLOUDFLARE_TOKEN: ${CLOUDFLARE_TOKEN:-}
73
volumes:
74
- ./portal-certs:/portal-certs
70
- expose:
71
- - "4017"
72
- - "443"
75
restart: unless-stopped
74
- networks:
75
- - portal-net
76
-
77
-networks:
78
- portal-net:
79
- driver: bridge
docs/examples/nginx-proxy/nginx.conf
+7
-6
@@ -4,8 +4,8 @@
4
# Traffic flow:
5
# :80 → redirect to HTTPS
6
# :443 → L4 SNI inspection (ssl_preread, no TLS termination)
7
-# portal.example.com → :8443 (nginx L7, terminates TLS) → portal:4017
8
-# *.portal.example.com → portal:443 (raw TCP passthrough, relay routes by SNI)
7
+# portal.example.com → :8443 (nginx L7, terminates TLS) → 127.0.0.1:4017
8
+# *.portal.example.com → 127.0.0.1:4443 (portal SNI, raw TCP passthrough)
9
10
events {
11
worker_connections 4096;
@@ -33,9 +33,10 @@ stream {
33
}
34
35
upstream portal_sni {
36
- # Portal SNI listener. Relay routes by SNI and bridges raw TCP
36
+ # Portal SNI listener (host networking, port 4443 to avoid conflict
37
+ # with nginx on 443). Relay routes by SNI and bridges raw TCP
38
# to the claimed reverse session. TLS is not terminated here.
38
- server portal:443;
39
+ server 127.0.0.1:4443;
40
}
41
42
server {
@@ -92,7 +93,7 @@ http {
93
# reverse session. After hijacking, data flows as raw bytes.
94
# Buffering must be disabled; timeouts must be long.
95
location = /sdk/connect {
95
- proxy_pass http://portal:4017;
96
+ proxy_pass http://127.0.0.1:4017;
97
proxy_http_version 1.1;
98
99
proxy_set_header Host $host;
@@ -116,7 +117,7 @@ http {
117
118
# ── All other admin/API and frontend routes ──────────────────────────
119
location / {
119
- proxy_pass http://portal:4017;
120
+ proxy_pass http://127.0.0.1:4017;
121
proxy_http_version 1.1;
122
123
proxy_set_header Host $host;
portal/helpers.go
+5
@@ -17,6 +17,11 @@ const (
17
defaultClientHelloWait = 2 * time.Second
18
defaultControlBodyLimit = 4 << 20
19
defaultSessionWriteLimit = 5 * time.Second
20
+
21
+ defaultUDPPortMin = 29000
22
+ defaultUDPPortMax = 29999
23
+ defaultUDPSessionTimeout = 30 * time.Second
24
+ defaultMaxDatagramSize = 1350
25
)
26
27
func PortalRootHost(portalURL string) string {
portal/quic.go
new
+323
@@ -0,0 +1,323 @@
1
+package portal
2
+
3
+import (
4
+ "context"
5
+ "encoding/binary"
6
+ "encoding/json"
7
+ "errors"
8
+ "net"
9
+ "sync"
10
+ "time"
11
+
12
+ "github.com/quic-go/quic-go"
13
+ "github.com/rs/zerolog/log"
14
+)
15
+
16
+var (
17
+ errQUICNoConnection = errors.New("no quic connection registered")
18
+ errQUICAlreadyClosed = errors.New("quic broker closed")
19
+ errDatagramTooSmall = errors.New("datagram too small to decode")
20
+)
21
+
22
+// datagramFrame is the wire format for QUIC DATAGRAM payloads.
23
+// Layout: [flowID varint][payload bytes]
24
+type datagramFrame struct {
25
+ FlowID uint32
26
+ Payload []byte
27
+}
28
+
29
+func encodeDatagram(flowID uint32, payload []byte) []byte {
30
+ var buf [binary.MaxVarintLen32]byte
31
+ n := binary.PutUvarint(buf[:], uint64(flowID))
32
+ out := make([]byte, n+len(payload))
33
+ copy(out, buf[:n])
34
+ copy(out[n:], payload)
35
+ return out
36
+}
37
+
38
+func decodeDatagram(data []byte) (datagramFrame, error) {
39
+ flowID, n := binary.Uvarint(data)
40
+ if n <= 0 {
41
+ return datagramFrame{}, errDatagramTooSmall
42
+ }
43
+ return datagramFrame{
44
+ FlowID: uint32(flowID),
45
+ Payload: data[n:],
46
+ }, nil
47
+}
48
+
49
+// quicBroker manages a single QUIC connection from a tunnel for one lease.
50
+// All UDP traffic for the lease is multiplexed over DATAGRAM frames on this
51
+// connection, identified by flow IDs.
52
+type quicBroker struct {
53
+ leaseID string
54
+
55
+ conn *quic.Conn
56
+ flowTable map[uint32]*net.UDPAddr // flowID → client addr
57
+ addrIndex map[string]uint32 // "ip:port" → flowID
58
+ nextFlow uint32
59
+
60
+ incoming chan datagramFrame // frames received from tunnel
61
+ done chan struct{}
62
+
63
+ mu sync.Mutex
64
+ closeOnce sync.Once
65
+ closed bool
66
+}
67
+
68
+func newQUICBroker(leaseID string) *quicBroker {
69
+ return &quicBroker{
70
+ leaseID: leaseID,
71
+ flowTable: make(map[uint32]*net.UDPAddr),
72
+ addrIndex: make(map[string]uint32),
73
+ nextFlow: 1,
74
+ incoming: make(chan datagramFrame, 256),
75
+ done: make(chan struct{}),
76
+ }
77
+}
78
+
79
+// Register stores the QUIC connection from the tunnel for this lease.
80
+// Replaces any existing connection.
81
+func (b *quicBroker) Register(conn *quic.Conn) {
82
+ b.mu.Lock()
83
+ old := b.conn
84
+ b.conn = conn
85
+ b.mu.Unlock()
86
+
87
+ if old != nil {
88
+ _ = old.CloseWithError(0, "replaced")
89
+ }
90
+
91
+ go b.receiveLoop(conn)
92
+
93
+ log.Info().
94
+ Str("component", "quic-broker").
95
+ Str("lease_id", b.leaseID).
96
+ Str("remote_addr", conn.RemoteAddr().String()).
97
+ Msg("quic tunnel connection registered")
98
+}
99
+
100
+// HasConnection reports whether a tunnel QUIC connection is active.
101
+func (b *quicBroker) HasConnection() bool {
102
+ b.mu.Lock()
103
+ defer b.mu.Unlock()
104
+ return b.conn != nil && !b.closed
105
+}
106
+
107
+// SendDatagram encodes a flow-framed datagram and sends it to the tunnel.
108
+func (b *quicBroker) SendDatagram(flowID uint32, payload []byte) error {
109
+ b.mu.Lock()
110
+ conn := b.conn
111
+ b.mu.Unlock()
112
+
113
+ if conn == nil {
114
+ return errQUICNoConnection
115
+ }
116
+ return conn.SendDatagram(encodeDatagram(flowID, payload))
117
+}
118
+
119
+// Incoming returns the channel that delivers datagrams received from the tunnel.
120
+func (b *quicBroker) Incoming() <-chan datagramFrame {
121
+ return b.incoming
122
+}
123
+
124
+// Done returns a channel closed when the broker shuts down.
125
+func (b *quicBroker) Done() <-chan struct{} {
126
+ return b.done
127
+}
128
+
129
+// AllocateFlow assigns a flow ID for a client address. If the address already
130
+// has a flow, the existing ID is returned.
131
+func (b *quicBroker) AllocateFlow(addr *net.UDPAddr) uint32 {
132
+ key := addr.String()
133
+
134
+ b.mu.Lock()
135
+ defer b.mu.Unlock()
136
+
137
+ if id, ok := b.addrIndex[key]; ok {
138
+ return id
139
+ }
140
+ id := b.nextFlow
141
+ b.nextFlow++
142
+ b.flowTable[id] = addr
143
+ b.addrIndex[key] = id
144
+ return id
145
+}
146
+
147
+// LookupFlowAddr returns the client address for a flow ID.
148
+func (b *quicBroker) LookupFlowAddr(flowID uint32) (*net.UDPAddr, bool) {
149
+ b.mu.Lock()
150
+ defer b.mu.Unlock()
151
+ addr, ok := b.flowTable[flowID]
152
+ return addr, ok
153
+}
154
+
155
+// Stop tears down the QUIC connection and signals done.
156
+func (b *quicBroker) Stop() {
157
+ b.closeOnce.Do(func() {
158
+ b.mu.Lock()
159
+ b.closed = true
160
+ conn := b.conn
161
+ b.conn = nil
162
+ b.mu.Unlock()
163
+
164
+ if conn != nil {
165
+ _ = conn.CloseWithError(0, "lease stopped")
166
+ }
167
+ close(b.done)
168
+ })
169
+}
170
+
171
+func (b *quicBroker) receiveLoop(conn *quic.Conn) {
172
+ for {
173
+ data, err := conn.ReceiveDatagram(context.Background())
174
+ if err != nil {
175
+ b.mu.Lock()
176
+ // Only clear if this is still the active connection.
177
+ if b.conn == conn {
178
+ b.conn = nil
179
+ }
180
+ b.mu.Unlock()
181
+
182
+ if !b.isClosed() {
183
+ log.Warn().
184
+ Err(err).
185
+ Str("component", "quic-broker").
186
+ Str("lease_id", b.leaseID).
187
+ Msg("quic receive loop ended")
188
+ }
189
+ return
190
+ }
191
+
192
+ frame, err := decodeDatagram(data)
193
+ if err != nil {
194
+ continue
195
+ }
196
+
197
+ select {
198
+ case b.incoming <- frame:
199
+ default:
200
+ // Drop if channel full — back-pressure on tunnel.
201
+ }
202
+ }
203
+}
204
+
205
+func (b *quicBroker) isClosed() bool {
206
+ select {
207
+ case <-b.done:
208
+ return true
209
+ default:
210
+ return false
211
+ }
212
+}
213
+
214
+// quicControlMessage is sent by the tunnel on the first QUIC stream after
215
+// connecting. The relay reads it to associate the connection with a lease.
216
+type quicControlMessage struct {
217
+ LeaseID string `json:"lease_id"`
218
+ ReverseToken string `json:"reverse_token"`
219
+}
220
+
221
+// quicTunnelListener manages the QUIC listener that accepts tunnel connections
222
+// on the relay API UDP port.
223
+type quicTunnelListener struct {
224
+ listener *quic.Listener
225
+ server *Server
226
+
227
+ done chan struct{}
228
+ closeOnce sync.Once
229
+}
230
+
231
+func newQUICTunnelListener(listener *quic.Listener, server *Server) *quicTunnelListener {
232
+ return &quicTunnelListener{
233
+ listener: listener,
234
+ server: server,
235
+ done: make(chan struct{}),
236
+ }
237
+}
238
+
239
+func (l *quicTunnelListener) run() error {
240
+ for {
241
+ conn, err := l.listener.Accept(context.Background())
242
+ if err != nil {
243
+ select {
244
+ case <-l.done:
245
+ return nil
246
+ default:
247
+ }
248
+ if errors.Is(err, quic.ErrServerClosed) {
249
+ return nil
250
+ }
251
+ return err
252
+ }
253
+ go l.handleConnection(conn)
254
+ }
255
+}
256
+
257
+func (l *quicTunnelListener) handleConnection(conn *quic.Conn) {
258
+ stream, err := conn.AcceptStream(context.Background())
259
+ if err != nil {
260
+ _ = conn.CloseWithError(1, "stream accept failed")
261
+ return
262
+ }
263
+
264
+ // Read control message with timeout.
265
+ _ = stream.SetReadDeadline(time.Now().Add(10 * time.Second))
266
+ var msg quicControlMessage
267
+ buf := make([]byte, 4096)
268
+ n, err := stream.Read(buf)
269
+ if err != nil {
270
+ _ = conn.CloseWithError(1, "control read failed")
271
+ return
272
+ }
273
+
274
+ // Simple JSON decode.
275
+ if decErr := json.Unmarshal(buf[:n], &msg); decErr != nil {
276
+ _ = conn.CloseWithError(1, "invalid control message")
277
+ return
278
+ }
279
+ _ = stream.SetReadDeadline(time.Time{})
280
+
281
+ lease, err := l.server.findLeaseByID(msg.LeaseID)
282
+ if err != nil {
283
+ _, _ = stream.Write([]byte(`{"ok":false,"error":"lease_not_found"}`))
284
+ _ = conn.CloseWithError(1, "lease not found")
285
+ return
286
+ }
287
+
288
+ if authErr := l.server.authorizeLeaseToken(lease, msg.ReverseToken); authErr != nil {
289
+ _, _ = stream.Write([]byte(`{"ok":false,"error":"unauthorized"}`))
290
+ _ = conn.CloseWithError(1, "unauthorized")
291
+ return
292
+ }
293
+
294
+ if lease.QUICBroker == nil {
295
+ _, _ = stream.Write([]byte(`{"ok":false,"error":"transport_mismatch"}`))
296
+ _ = conn.CloseWithError(1, "lease does not support QUIC transport")
297
+ return
298
+ }
299
+
300
+ // Success — register the QUIC connection with the broker.
301
+ lease.QUICBroker.Register(conn)
302
+
303
+ // Confirm registration to the tunnel.
304
+ _, _ = stream.Write([]byte(`{"ok":true}`))
305
+
306
+ l.server.touchLease(lease.ID, conn.RemoteAddr().String())
307
+
308
+ log.Info().
309
+ Str("component", "quic-tunnel-listener").
310
+ Str("lease_id", lease.ID).
311
+ Str("lease_name", lease.Name).
312
+ Str("remote_addr", conn.RemoteAddr().String()).
313
+ Msg("quic tunnel connected")
314
+}
315
+
316
+func (l *quicTunnelListener) close() error {
317
+ var closeErr error
318
+ l.closeOnce.Do(func() {
319
+ close(l.done)
320
+ closeErr = l.listener.Close()
321
+ })
322
+ return closeErr
323
+}
portal/quic_sni.go
new
+532
@@ -0,0 +1,532 @@
1
+package portal
2
+
3
+import (
4
+ "crypto"
5
+ "crypto/aes"
6
+ "crypto/cipher"
7
+ "encoding/binary"
8
+ "errors"
9
+ "fmt"
10
+ "net"
11
+ "sync"
12
+
13
+ "golang.org/x/crypto/hkdf"
14
+)
15
+
16
+// QUIC v1 constants (RFC 9001).
17
+var quicV1InitialSalt = []byte{
18
+ 0x38, 0x76, 0x2c, 0xf7, 0xf5, 0x59, 0x34, 0xb3,
19
+ 0x4d, 0x17, 0x9a, 0xe6, 0xa4, 0xc8, 0x0c, 0xad,
20
+ 0xcc, 0xbb, 0x7f, 0x0a,
21
+}
22
+
23
+var errNotQUICInitial = errors.New("not a quic initial packet")
24
+var errSNINotFound = errors.New("sni not found in quic initial")
25
+
26
+// parseQUICInitialSNI extracts the TLS SNI from a QUIC Initial packet.
27
+// It decrypts the Initial packet header and payload using keys derived from
28
+// the Destination Connection ID per RFC 9001 Section 5.2, then parses the
29
+// CRYPTO frame to find the TLS ClientHello SNI extension.
30
+func parseQUICInitialSNI(packet []byte) (string, error) {
31
+ if len(packet) < 5 {
32
+ return "", errNotQUICInitial
33
+ }
34
+
35
+ // Long header: first bit is 1, second bit is 1 (fixed), bits 4-5 are packet type.
36
+ firstByte := packet[0]
37
+ if firstByte&0x80 == 0 {
38
+ return "", errNotQUICInitial // short header
39
+ }
40
+
41
+ // Packet type: bits 4-5 of first byte. Initial = 0.
42
+ packetType := (firstByte & 0x30) >> 4
43
+ if packetType != 0 {
44
+ return "", errNotQUICInitial
45
+ }
46
+
47
+ // Version (4 bytes).
48
+ version := binary.BigEndian.Uint32(packet[1:5])
49
+ if version == 0 {
50
+ return "", errNotQUICInitial // version negotiation
51
+ }
52
+
53
+ offset := 5
54
+
55
+ // Destination Connection ID length + DCID.
56
+ if offset >= len(packet) {
57
+ return "", errNotQUICInitial
58
+ }
59
+ dcidLen := int(packet[offset])
60
+ offset++
61
+ if offset+dcidLen > len(packet) {
62
+ return "", errNotQUICInitial
63
+ }
64
+ dcid := packet[offset : offset+dcidLen]
65
+ offset += dcidLen
66
+
67
+ // Source Connection ID length + SCID.
68
+ if offset >= len(packet) {
69
+ return "", errNotQUICInitial
70
+ }
71
+ scidLen := int(packet[offset])
72
+ offset++
73
+ offset += scidLen
74
+ if offset > len(packet) {
75
+ return "", errNotQUICInitial
76
+ }
77
+
78
+ // Token length (varint) + token.
79
+ tokenLen, n := readVarint(packet[offset:])
80
+ if n <= 0 {
81
+ return "", errNotQUICInitial
82
+ }
83
+ offset += n + int(tokenLen)
84
+ if offset > len(packet) {
85
+ return "", errNotQUICInitial
86
+ }
87
+
88
+ // Payload length (varint).
89
+ payloadLen, n := readVarint(packet[offset:])
90
+ if n <= 0 {
91
+ return "", errNotQUICInitial
92
+ }
93
+ offset += n
94
+ _ = payloadLen
95
+
96
+ // The rest from offset is: packet number (1-4 bytes, encrypted) + encrypted payload.
97
+ // We need to decrypt the header first to determine packet number length.
98
+ clientSecret, err := deriveInitialClientSecret(dcid, version)
99
+ if err != nil {
100
+ return "", fmt.Errorf("derive initial secret: %w", err)
101
+ }
102
+
103
+ hp, err := deriveHPKey(clientSecret)
104
+ if err != nil {
105
+ return "", fmt.Errorf("derive hp key: %w", err)
106
+ }
107
+
108
+ key, err := deriveKey(clientSecret)
109
+ if err != nil {
110
+ return "", fmt.Errorf("derive key: %w", err)
111
+ }
112
+
113
+ iv, err := deriveIV(clientSecret)
114
+ if err != nil {
115
+ return "", fmt.Errorf("derive iv: %w", err)
116
+ }
117
+
118
+ // Header protection: sample 16 bytes starting 4 bytes after packet number offset.
119
+ pnOffset := offset
120
+ sampleOffset := pnOffset + 4
121
+ if sampleOffset+16 > len(packet) {
122
+ return "", errNotQUICInitial
123
+ }
124
+ sample := packet[sampleOffset : sampleOffset+16]
125
+
126
+ // Create AES-ECB cipher for HP mask.
127
+ block, err := aes.NewCipher(hp)
128
+ if err != nil {
129
+ return "", fmt.Errorf("aes cipher: %w", err)
130
+ }
131
+ mask := make([]byte, aes.BlockSize)
132
+ block.Encrypt(mask, sample)
133
+
134
+ // Unmask first byte.
135
+ unmaskedFirst := packet[0] ^ (mask[0] & 0x0f) // long header: lower 4 bits
136
+ pnLength := int(unmaskedFirst&0x03) + 1
137
+
138
+ // Unmask packet number.
139
+ pnBytes := make([]byte, pnLength)
140
+ for i := range pnLength {
141
+ pnBytes[i] = packet[pnOffset+i] ^ mask[1+i]
142
+ }
143
+
144
+ var pn uint32
145
+ for _, b := range pnBytes {
146
+ pn = (pn << 8) | uint32(b)
147
+ }
148
+
149
+ // Build nonce for AEAD.
150
+ nonce := make([]byte, len(iv))
151
+ copy(nonce, iv)
152
+ for i := range len(nonce) {
153
+ if i >= len(nonce)-4 {
154
+ nonce[i] ^= byte(pn >> (8 * (len(nonce) - 1 - i)))
155
+ }
156
+ }
157
+
158
+ // Decrypt payload.
159
+ payloadOffset := pnOffset + pnLength
160
+ if payloadOffset >= len(packet) {
161
+ return "", errNotQUICInitial
162
+ }
163
+
164
+ // AAD = entire header with unmasked first byte and unmasked PN.
165
+ aad := make([]byte, payloadOffset)
166
+ copy(aad, packet[:payloadOffset])
167
+ aad[0] = unmaskedFirst
168
+ copy(aad[pnOffset:], pnBytes)
169
+
170
+ aead, err := cipher.NewGCM(block)
171
+ if err != nil {
172
+ // Use the key for AEAD, not HP block.
173
+ aeadBlock, err2 := aes.NewCipher(key)
174
+ if err2 != nil {
175
+ return "", fmt.Errorf("aead cipher: %w", err2)
176
+ }
177
+ aead, err = cipher.NewGCM(aeadBlock)
178
+ if err != nil {
179
+ return "", fmt.Errorf("gcm: %w", err)
180
+ }
181
+ } else {
182
+ // We used the HP block for AEAD by mistake. Redo with key.
183
+ aeadBlock, err2 := aes.NewCipher(key)
184
+ if err2 != nil {
185
+ return "", fmt.Errorf("aead cipher: %w", err2)
186
+ }
187
+ aead, err = cipher.NewGCM(aeadBlock)
188
+ if err != nil {
189
+ return "", fmt.Errorf("gcm: %w", err)
190
+ }
191
+ }
192
+
193
+ ciphertext := packet[payloadOffset:]
194
+ plaintext, err := aead.Open(nil, nonce, ciphertext, aad)
195
+ if err != nil {
196
+ return "", fmt.Errorf("decrypt initial payload: %w", err)
197
+ }
198
+
199
+ // Parse CRYPTO frames to find ClientHello.
200
+ return extractSNIFromCryptoFrames(plaintext)
201
+}
202
+
203
+// extractSNIFromCryptoFrames parses QUIC frames looking for CRYPTO frames
204
+// containing a TLS ClientHello, and extracts the SNI server_name extension.
205
+func extractSNIFromCryptoFrames(frames []byte) (string, error) {
206
+ offset := 0
207
+ for offset < len(frames) {
208
+ frameType := frames[offset]
209
+ offset++
210
+
211
+ switch {
212
+ case frameType == 0x00:
213
+ // PADDING frame — skip.
214
+ continue
215
+ case frameType == 0x01:
216
+ // PING frame — skip.
217
+ continue
218
+ case frameType == 0x06:
219
+ // CRYPTO frame.
220
+ // Offset field (varint).
221
+ _, n := readVarint(frames[offset:])
222
+ if n <= 0 {
223
+ return "", errSNINotFound
224
+ }
225
+ offset += n
226
+
227
+ // Length field (varint).
228
+ dataLen, n := readVarint(frames[offset:])
229
+ if n <= 0 {
230
+ return "", errSNINotFound
231
+ }
232
+ offset += n
233
+
234
+ if offset+int(dataLen) > len(frames) {
235
+ return "", errSNINotFound
236
+ }
237
+ cryptoData := frames[offset : offset+int(dataLen)]
238
+ offset += int(dataLen)
239
+
240
+ sni, err := parseTLSClientHelloSNI(cryptoData)
241
+ if err == nil {
242
+ return sni, nil
243
+ }
244
+ default:
245
+ // Unknown frame — can't continue parsing reliably.
246
+ return "", errSNINotFound
247
+ }
248
+ }
249
+ return "", errSNINotFound
250
+}
251
+
252
+// parseTLSClientHelloSNI parses a raw TLS ClientHello message and extracts SNI.
253
+func parseTLSClientHelloSNI(data []byte) (string, error) {
254
+ // TLS handshake: type(1) + length(3) + ...
255
+ if len(data) < 4 {
256
+ return "", errSNINotFound
257
+ }
258
+ if data[0] != 0x01 { // ClientHello
259
+ return "", errSNINotFound
260
+ }
261
+ msgLen := int(data[1])<<16 | int(data[2])<<8 | int(data[3])
262
+ if len(data) < 4+msgLen {
263
+ return "", errSNINotFound
264
+ }
265
+ body := data[4 : 4+msgLen]
266
+
267
+ // ClientHello: version(2) + random(32) + session_id_len(1) + session_id + ...
268
+ if len(body) < 34 {
269
+ return "", errSNINotFound
270
+ }
271
+ offset := 2 + 32 // skip version + random
272
+
273
+ // Session ID.
274
+ if offset >= len(body) {
275
+ return "", errSNINotFound
276
+ }
277
+ sessionIDLen := int(body[offset])
278
+ offset += 1 + sessionIDLen
279
+
280
+ // Cipher suites.
281
+ if offset+2 > len(body) {
282
+ return "", errSNINotFound
283
+ }
284
+ cipherSuitesLen := int(body[offset])<<8 | int(body[offset+1])
285
+ offset += 2 + cipherSuitesLen
286
+
287
+ // Compression methods.
288
+ if offset >= len(body) {
289
+ return "", errSNINotFound
290
+ }
291
+ compMethodsLen := int(body[offset])
292
+ offset += 1 + compMethodsLen
293
+
294
+ // Extensions.
295
+ if offset+2 > len(body) {
296
+ return "", errSNINotFound
297
+ }
298
+ extensionsLen := int(body[offset])<<8 | int(body[offset+1])
299
+ offset += 2
300
+
301
+ extEnd := offset + extensionsLen
302
+ if extEnd > len(body) {
303
+ extEnd = len(body)
304
+ }
305
+
306
+ for offset+4 <= extEnd {
307
+ extType := int(body[offset])<<8 | int(body[offset+1])
308
+ extLen := int(body[offset+2])<<8 | int(body[offset+3])
309
+ offset += 4
310
+
311
+ if extType == 0x0000 { // server_name
312
+ return parseSNIExtension(body[offset : offset+extLen])
313
+ }
314
+ offset += extLen
315
+ }
316
+
317
+ return "", errSNINotFound
318
+}
319
+
320
+func parseSNIExtension(data []byte) (string, error) {
321
+ if len(data) < 2 {
322
+ return "", errSNINotFound
323
+ }
324
+ // Server name list length.
325
+ listLen := int(data[0])<<8 | int(data[1])
326
+ offset := 2
327
+ end := offset + listLen
328
+ if end > len(data) {
329
+ end = len(data)
330
+ }
331
+
332
+ for offset+3 <= end {
333
+ nameType := data[offset]
334
+ nameLen := int(data[offset+1])<<8 | int(data[offset+2])
335
+ offset += 3
336
+ if nameType == 0x00 { // host_name
337
+ if offset+nameLen > end {
338
+ return "", errSNINotFound
339
+ }
340
+ return string(data[offset : offset+nameLen]), nil
341
+ }
342
+ offset += nameLen
343
+ }
344
+ return "", errSNINotFound
345
+}
346
+
347
+// QUIC Initial secret derivation (RFC 9001 Section 5.2).
348
+func deriveInitialClientSecret(dcid []byte, version uint32) ([]byte, error) {
349
+ salt := quicV1InitialSalt
350
+
351
+ initialSecret := hkdf.Extract(crypto.SHA256.New, dcid, salt)
352
+
353
+ clientInitialSecret := make([]byte, 32)
354
+ r := hkdf.Expand(crypto.SHA256.New, initialSecret, hkdfLabel([]byte("client in"), 32))
355
+ if _, err := r.Read(clientInitialSecret); err != nil {
356
+ return nil, err
357
+ }
358
+ return clientInitialSecret, nil
359
+}
360
+
361
+func deriveHPKey(secret []byte) ([]byte, error) {
362
+ hp := make([]byte, 16)
363
+ r := hkdf.Expand(crypto.SHA256.New, secret, hkdfLabel([]byte("quic hp"), 16))
364
+ if _, err := r.Read(hp); err != nil {
365
+ return nil, err
366
+ }
367
+ return hp, nil
368
+}
369
+
370
+func deriveKey(secret []byte) ([]byte, error) {
371
+ key := make([]byte, 16)
372
+ r := hkdf.Expand(crypto.SHA256.New, secret, hkdfLabel([]byte("quic key"), 16))
373
+ if _, err := r.Read(key); err != nil {
374
+ return nil, err
375
+ }
376
+ return key, nil
377
+}
378
+
379
+func deriveIV(secret []byte) ([]byte, error) {
380
+ iv := make([]byte, 12)
381
+ r := hkdf.Expand(crypto.SHA256.New, secret, hkdfLabel([]byte("quic iv"), 12))
382
+ if _, err := r.Read(iv); err != nil {
383
+ return nil, err
384
+ }
385
+ return iv, nil
386
+}
387
+
388
+// hkdfLabel builds a TLS 1.3 HkdfLabel structure for HKDF-Expand-Label.
389
+func hkdfLabel(label []byte, length int) []byte {
390
+ fullLabel := append([]byte("tls13 "), label...)
391
+ out := make([]byte, 2+1+len(fullLabel)+1)
392
+ out[0] = byte(length >> 8)
393
+ out[1] = byte(length)
394
+ out[2] = byte(len(fullLabel))
395
+ copy(out[3:], fullLabel)
396
+ out[3+len(fullLabel)] = 0 // empty context
397
+ return out
398
+}
399
+
400
+// readVarint reads a QUIC variable-length integer (RFC 9000 Section 16).
401
+func readVarint(data []byte) (uint64, int) {
402
+ if len(data) == 0 {
403
+ return 0, -1
404
+ }
405
+ prefix := data[0] >> 6
406
+ length := 1 << prefix
407
+
408
+ if len(data) < length {
409
+ return 0, -1
410
+ }
411
+
412
+ val := uint64(data[0] & 0x3f)
413
+ for i := 1; i < length; i++ {
414
+ val = (val << 8) | uint64(data[i])
415
+ }
416
+ return val, length
417
+}
418
+
419
+// quicSNIRouter listens on a raw UDP socket and routes QUIC connections
420
+// based on SNI extracted from Initial packets.
421
+type quicSNIRouter struct {
422
+ conn net.PacketConn
423
+ server *Server
424
+ connTable map[string]string // "src_ip:port" → leaseID
425
+ mu sync.RWMutex
426
+ done chan struct{}
427
+ closeOnce sync.Once
428
+}
429
+
430
+func newQUICSNIRouter(conn net.PacketConn, server *Server) *quicSNIRouter {
431
+ return &quicSNIRouter{
432
+ conn: conn,
433
+ server: server,
434
+ connTable: make(map[string]string),
435
+ done: make(chan struct{}),
436
+ }
437
+}
438
+
439
+func (r *quicSNIRouter) run() error {
440
+ buf := make([]byte, 65535)
441
+ for {
442
+ n, addr, err := r.conn.ReadFrom(buf)
443
+ if err != nil {
444
+ select {
445
+ case <-r.done:
446
+ return nil
447
+ default:
448
+ }
449
+ if errors.Is(err, net.ErrClosed) {
450
+ return nil
451
+ }
452
+ return err
453
+ }
454
+
455
+ packet := make([]byte, n)
456
+ copy(packet, buf[:n])
457
+ go r.handlePacket(packet, addr)
458
+ }
459
+}
460
+
461
+func (r *quicSNIRouter) handlePacket(packet []byte, srcAddr net.Addr) {
462
+ key := srcAddr.String()
463
+
464
+ // Check if we already have a mapping for this source.
465
+ r.mu.RLock()
466
+ leaseID, found := r.connTable[key]
467
+ r.mu.RUnlock()
468
+
469
+ if !found {
470
+ // Try to parse SNI from Initial packet.
471
+ sni, err := parseQUICInitialSNI(packet)
472
+ if err != nil || sni == "" {
473
+ return // drop non-Initial or unparseable packets from unknown sources
474
+ }
475
+
476
+ serverName := normalizeHostname(sni)
477
+ var ok bool
478
+ leaseID, ok = r.server.routes.Lookup(serverName)
479
+ if !ok {
480
+ return // no route
481
+ }
482
+
483
+ r.mu.Lock()
484
+ r.connTable[key] = leaseID
485
+ r.mu.Unlock()
486
+ }
487
+
488
+ // Forward packet to the tunnel via QUIC DATAGRAM.
489
+ r.server.mu.RLock()
490
+ record := r.server.leases[leaseID]
491
+ r.server.mu.RUnlock()
492
+
493
+ if record == nil || record.QUICBroker == nil || !record.QUICBroker.HasConnection() {
494
+ return
495
+ }
496
+
497
+ udpAddr, ok := srcAddr.(*net.UDPAddr)
498
+ if !ok {
499
+ return
500
+ }
501
+
502
+ flowID := record.QUICBroker.AllocateFlow(udpAddr)
503
+ _ = record.QUICBroker.SendDatagram(flowID, packet)
504
+}
505
+
506
+// writeBackLoop reads datagrams from each QUIC broker and writes raw UDP back
507
+// to the public QUIC clients via the SNI router's PacketConn.
508
+func (r *quicSNIRouter) writeBackLoop(leaseID string, broker *quicBroker) {
509
+ for {
510
+ select {
511
+ case <-r.done:
512
+ return
513
+ case <-broker.Done():
514
+ return
515
+ case frame := <-broker.Incoming():
516
+ addr, ok := broker.LookupFlowAddr(frame.FlowID)
517
+ if !ok {
518
+ continue
519
+ }
520
+ _, _ = r.conn.WriteTo(frame.Payload, addr)
521
+ }
522
+ }
523
+}
524
+
525
+func (r *quicSNIRouter) close() error {
526
+ var closeErr error
527
+ r.closeOnce.Do(func() {
528
+ close(r.done)
529
+ closeErr = r.conn.Close()
530
+ })
531
+ return closeErr
532
+}
portal/server.go
+213
-18
@@ -14,6 +14,7 @@ import (
14
"sync"
15
"time"
16
17
+ "github.com/quic-go/quic-go"
18
"github.com/rs/zerolog/log"
19
"golang.org/x/sync/errgroup"
20
@@ -31,6 +32,7 @@ type ServerConfig struct {
32
PortalURL string
33
APIListenAddr string
34
SNIListenAddr string
35
+ QUICListenAddr string
36
RootHost string
37
RootFallbackAddr string
38
APITLS keyless.TLSMaterialConfig
@@ -40,22 +42,28 @@ type ServerConfig struct {
42
ReadyQueueLimit int
43
ClientHelloTimeout time.Duration
44
TrustProxyHeaders bool
45
+ UDPPortMin int
46
+ UDPPortMax int
47
}
48
49
type Server struct {
46
- sniListener net.Listener
47
- apiTLSClose io.Closer
48
- apiListener net.Listener
49
- apiServer *http.Server
50
- ctxDone <-chan struct{}
51
- baseContext func() context.Context
52
- cancel context.CancelFunc
53
- group *errgroup.Group
54
- routes *routeTable
55
- leases map[string]*leaseRecord
56
- cfg ServerConfig
57
- mu sync.RWMutex
58
- shutdownOnce sync.Once
50
+ sniListener net.Listener
51
+ apiTLSClose io.Closer
52
+ apiListener net.Listener
53
+ apiServer *http.Server
54
+ quicTunnel *quicTunnelListener
55
+ quicSNIConn net.PacketConn
56
+ ctxDone <-chan struct{}
57
+ baseContext func() context.Context
58
+ cancel context.CancelFunc
59
+ group *errgroup.Group
60
+ routes *routeTable
61
+ leases map[string]*leaseRecord
62
+ ports *portAllocator
63
+ udpRelays map[string]*udpRelay
64
+ cfg ServerConfig
65
+ mu sync.RWMutex
66
+ shutdownOnce sync.Once
67
}
68
69
type leaseRecord struct {
@@ -63,12 +71,15 @@ type leaseRecord struct {
71
FirstSeenAt time.Time
72
LastSeenAt time.Time
73
Broker *leaseBroker
74
+ QUICBroker *quicBroker
75
ID string
76
Name string
77
ReverseToken string
78
ClientIP string
79
+ Transport string
80
Hostnames []string
81
Metadata types.LeaseMetadata
82
+ UDPPort int
83
}
84
85
type LeaseSnapshot struct {
@@ -78,9 +89,11 @@ type LeaseSnapshot struct {
89
ID string
90
Name string
91
ClientIP string
92
+ Transport string
93
Hostnames []string
94
Metadata types.LeaseMetadata
95
Ready int
96
+ UDPPort int
97
IsApproved bool
98
IsBanned bool
99
IsDenied bool
@@ -114,11 +127,18 @@ func NewServer(cfg ServerConfig) (*Server, error) {
127
if len(cfg.APITLS.KeyPEM) == 0 && cfg.APITLS.Keyless == nil {
128
return nil, errors.New("api tls key or keyless signer is required")
129
}
130
+ cfg.UDPPortMin = intOrDefault(cfg.UDPPortMin, defaultUDPPortMin)
131
+ cfg.UDPPortMax = intOrDefault(cfg.UDPPortMax, defaultUDPPortMax)
132
+ if cfg.QUICListenAddr == "" {
133
+ cfg.QUICListenAddr = cfg.APIListenAddr
134
+ }
135
136
return &Server{
119
- cfg: cfg,
120
- routes: newRouteTable(),
121
- leases: make(map[string]*leaseRecord),
137
+ cfg: cfg,
138
+ routes: newRouteTable(),
139
+ leases: make(map[string]*leaseRecord),
140
+ ports: newPortAllocator(cfg.UDPPortMin, cfg.UDPPortMax),
141
+ udpRelays: make(map[string]*udpRelay),
142
}, nil
143
}
144
@@ -170,6 +190,17 @@ func (s *Server) Start(ctx context.Context) error {
190
group.Go(s.runSNIListener)
191
group.Go(s.runLeaseJanitor)
192
group.Go(s.watchContext)
193
+
194
+ // QUIC tunnel listener for UDP transport reverse sessions.
195
+ if err := s.startQUICTunnelListener(serverCtx); err != nil {
196
+ log.Warn().Err(err).Msg("quic tunnel listener disabled")
197
+ }
198
+
199
+ // QUIC SNI router on :443/udp for public QUIC client routing.
200
+ if err := s.startQUICSNIRouter(); err != nil {
201
+ log.Warn().Err(err).Msg("quic sni router disabled")
202
+ }
203
+
204
return nil
205
}
206
@@ -190,9 +221,21 @@ func (s *Server) Shutdown(ctx context.Context) error {
221
s.mu.Lock()
222
for _, lease := range s.leases {
223
lease.Broker.Stop()
224
+ if lease.QUICBroker != nil {
225
+ lease.QUICBroker.Stop()
226
+ }
227
+ }
228
+ for _, relay := range s.udpRelays {
229
+ relay.Stop()
230
}
231
s.mu.Unlock()
232
233
+ if s.quicTunnel != nil {
234
+ _ = s.quicTunnel.close()
235
+ }
236
+ if s.quicSNIConn != nil {
237
+ _ = s.quicSNIConn.Close()
238
+ }
239
if s.sniListener != nil {
240
if err := s.sniListener.Close(); err != nil && !errors.Is(err, net.ErrClosed) {
241
shutdownErr = err
@@ -224,6 +267,13 @@ func (s *Server) SNIAddr() string {
267
return s.sniListener.Addr().String()
268
}
269
270
+func (s *Server) QUICAddr() string {
271
+ if s.quicTunnel == nil || s.quicTunnel.listener == nil {
272
+ return ""
273
+ }
274
+ return s.quicTunnel.listener.Addr().String()
275
+}
276
+
277
func (s *Server) GetLease(leaseID string) (LeaseSnapshot, bool) {
278
s.mu.RLock()
279
record, ok := s.leases[strings.TrimSpace(leaseID)]
@@ -307,9 +357,18 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
357
if errors.Is(err, errIPBanned) {
358
status, code = http.StatusForbidden, types.APIErrorCodeIPBanned
359
}
360
+ if errors.Is(err, errPortExhausted) {
361
+ status, code = http.StatusServiceUnavailable, types.APIErrorCodeUDPPortExhausted
362
+ }
363
writeAPIError(w, status, code, err.Error())
364
return
365
}
366
+
367
+ // Start UDP relay outside the lock if the lease needs one.
368
+ if resp.UDPAddr != "" {
369
+ s.startUDPRelay(resp.LeaseID)
370
+ }
371
+
372
writeAPIData(w, http.StatusCreated, resp)
373
}
374
@@ -478,6 +537,26 @@ func (s *Server) registerLease(req types.RegisterRequest, clientIP string) (type
537
ttl = time.Duration(req.TTLSeconds) * time.Second
538
}
539
540
+ transport := strings.TrimSpace(strings.ToLower(req.Transport))
541
+ if transport == "" {
542
+ transport = types.TransportTCP
543
+ }
544
+ switch transport {
545
+ case types.TransportTCP, types.TransportUDP, types.TransportBoth:
546
+ default:
547
+ return types.RegisterResponse{}, fmt.Errorf("unsupported transport: %s", transport)
548
+ }
549
+
550
+ needsUDP := transport == types.TransportUDP || transport == types.TransportBoth
551
+ var udpPort int
552
+ if needsUDP {
553
+ var portErr error
554
+ udpPort, portErr = s.ports.Allocate()
555
+ if portErr != nil {
556
+ return types.RegisterResponse{}, fmt.Errorf("allocate udp port: %w", portErr)
557
+ }
558
+ }
559
+
560
leaseID := randomID("lease_")
561
now := time.Now()
562
expiresAt := now.Add(ttl)
@@ -491,9 +570,15 @@ func (s *Server) registerLease(req types.RegisterRequest, clientIP string) (type
570
FirstSeenAt: now,
571
LastSeenAt: now,
572
ClientIP: clientIP,
573
+ Transport: transport,
574
+ UDPPort: udpPort,
575
Broker: newLeaseBroker(leaseID, s.cfg.IdleKeepaliveInterval, s.cfg.ReadyQueueLimit),
576
}
577
578
+ if needsUDP {
579
+ record.QUICBroker = newQUICBroker(leaseID)
580
+ }
581
+
582
s.leases[leaseID] = record
583
for _, host := range hostnames {
584
s.routes.Set(host, leaseID)
@@ -502,13 +587,25 @@ func (s *Server) registerLease(req types.RegisterRequest, clientIP string) (type
587
s.cfg.Policy.IPFilter().RegisterLeaseIP(leaseID, clientIP)
588
}
589
505
- return types.RegisterResponse{
590
+ // Start UDP relay for leases that need it (done outside lock in a goroutine-safe way).
591
+ if needsUDP {
592
+ relay := newUDPRelay(leaseID, udpPort, record.QUICBroker)
593
+ s.udpRelays[leaseID] = relay
594
+ }
595
+
596
+ resp := types.RegisterResponse{
597
LeaseID: leaseID,
598
Hostnames: append([]string(nil), hostnames...),
599
Metadata: record.Metadata,
600
ExpiresAt: expiresAt,
601
ConnectURL: s.connectURL(),
511
- }, nil
602
+ Transport: transport,
603
+ }
604
+ if needsUDP {
605
+ resp.UDPAddr = fmt.Sprintf("%s:%d", s.cfg.RootHost, udpPort)
606
+ resp.QUICAddr = s.quicPublicAddr()
607
+ }
608
+ return resp, nil
609
}
610
611
func (s *Server) renewLease(req types.RenewRequest, clientIP string) (types.RenewResponse, error) {
@@ -552,12 +649,23 @@ func (s *Server) unregisterLease(req types.UnregisterRequest) error {
649
s.mu.Unlock()
650
return errUnauthorized
651
}
652
+ relay := s.udpRelays[record.ID]
653
+ delete(s.udpRelays, record.ID)
654
delete(s.leases, record.ID)
655
s.mu.Unlock()
656
657
s.routes.DeleteLease(record.Hostnames)
658
s.cfg.Policy.ForgetLease(record.ID)
659
record.Broker.Drop()
660
+ if record.QUICBroker != nil {
661
+ record.QUICBroker.Stop()
662
+ }
663
+ if relay != nil {
664
+ relay.Stop()
665
+ }
666
+ if record.UDPPort > 0 {
667
+ s.ports.Release(record.UDPPort)
668
+ }
669
return nil
670
}
671
@@ -711,6 +819,12 @@ func (s *Server) cleanupExpiredLeases() {
819
s.routes.DeleteLease(lease.Hostnames)
820
s.cfg.Policy.ForgetLease(lease.ID)
821
lease.Broker.Drop()
822
+ if lease.QUICBroker != nil {
823
+ lease.QUICBroker.Stop()
824
+ }
825
+ if lease.UDPPort > 0 {
826
+ s.ports.Release(lease.UDPPort)
827
+ }
828
}
829
}
830
@@ -732,6 +846,14 @@ func (s *Server) connectURL() string {
846
return base + types.PathSDKConnect
847
}
848
849
+func (s *Server) quicPublicAddr() string {
850
+ _, port, err := net.SplitHostPort(s.cfg.QUICListenAddr)
851
+ if err != nil {
852
+ port = "4017"
853
+ }
854
+ return net.JoinHostPort(s.cfg.RootHost, port)
855
+}
856
+
857
func (s *Server) wrapAPIHandler(base http.Handler) http.Handler {
858
if s.cfg.APIHandlerWrapper == nil {
859
return base
@@ -853,12 +975,14 @@ func (s *Server) snapshotForLease(record *leaseRecord) LeaseSnapshot {
975
ID: record.ID,
976
Name: record.Name,
977
ClientIP: clientIP,
978
+ Transport: record.Transport,
979
Hostnames: append([]string(nil), record.Hostnames...),
980
Metadata: record.Metadata,
981
ExpiresAt: record.ExpiresAt,
982
FirstSeenAt: record.FirstSeenAt,
983
LastSeenAt: record.LastSeenAt,
984
Ready: record.Broker.ReadyCount(),
985
+ UDPPort: record.UDPPort,
986
IsApproved: runtime.EffectiveApproval(record.ID),
987
IsBanned: runtime.IsLeaseBanned(record.ID),
988
IsDenied: runtime.IsLeaseDenied(record.ID),
@@ -901,3 +1025,74 @@ func (s *Server) touchLease(leaseID, clientIP string) {
1025
s.cfg.Policy.IPFilter().RegisterLeaseIP(record.ID, clientIP)
1026
}
1027
}
1028
+
1029
+func (s *Server) startQUICTunnelListener(ctx context.Context) error {
1030
+ tlsCert, err := tls.X509KeyPair(s.cfg.APITLS.CertPEM, s.cfg.APITLS.KeyPEM)
1031
+ if err != nil {
1032
+ return fmt.Errorf("parse quic tls keypair: %w", err)
1033
+ }
1034
+
1035
+ tlsConf := &tls.Config{
1036
+ Certificates: []tls.Certificate{tlsCert},
1037
+ NextProtos: []string{"portal-tunnel"},
1038
+ MinVersion: tls.VersionTLS13,
1039
+ }
1040
+
1041
+ quicConf := &quic.Config{
1042
+ EnableDatagrams: true,
1043
+ KeepAlivePeriod: 15 * time.Second,
1044
+ MaxIdleTimeout: 60 * time.Second,
1045
+ MaxIncomingStreams: 16,
1046
+ }
1047
+
1048
+ listener, err := quic.ListenAddr(s.cfg.QUICListenAddr, tlsConf, quicConf)
1049
+ if err != nil {
1050
+ return fmt.Errorf("listen quic: %w", err)
1051
+ }
1052
+
1053
+ tunnel := newQUICTunnelListener(listener, s)
1054
+ s.quicTunnel = tunnel
1055
+ s.group.Go(tunnel.run)
1056
+
1057
+ log.Info().
1058
+ Str("component", "relay-server").
1059
+ Str("quic_addr", listener.Addr().String()).
1060
+ Msg("quic tunnel listener started")
1061
+
1062
+ return nil
1063
+}
1064
+
1065
+// startUDPRelay starts a previously created UDP relay. Called after lock is released.
1066
+func (s *Server) startUDPRelay(leaseID string) {
1067
+ s.mu.RLock()
1068
+ relay, ok := s.udpRelays[leaseID]
1069
+ s.mu.RUnlock()
1070
+ if !ok || relay == nil {
1071
+ return
1072
+ }
1073
+ if err := relay.Start(s.context()); err != nil {
1074
+ log.Error().
1075
+ Err(err).
1076
+ Str("component", "relay-server").
1077
+ Str("lease_id", leaseID).
1078
+ Msg("failed to start udp relay")
1079
+ }
1080
+}
1081
+
1082
+func (s *Server) startQUICSNIRouter() error {
1083
+ conn, err := net.ListenPacket("udp", s.cfg.SNIListenAddr)
1084
+ if err != nil {
1085
+ return fmt.Errorf("listen quic sni udp: %w", err)
1086
+ }
1087
+
1088
+ router := newQUICSNIRouter(conn, s)
1089
+ s.quicSNIConn = conn
1090
+ s.group.Go(router.run)
1091
+
1092
+ log.Info().
1093
+ Str("component", "relay-server").
1094
+ Str("quic_sni_addr", conn.LocalAddr().String()).
1095
+ Msg("quic sni router started")
1096
+
1097
+ return nil
1098
+}
portal/udp_port.go
new
+52
@@ -0,0 +1,52 @@
1
+package portal
2
+
3
+import (
4
+ "errors"
5
+ "sync"
6
+)
7
+
8
+var errPortExhausted = errors.New("no udp ports available")
9
+
10
+// portAllocator manages a pool of UDP ports for dynamic per-lease allocation.
11
+type portAllocator struct {
12
+ available []int
13
+ inUse map[int]struct{}
14
+ mu sync.Mutex
15
+}
16
+
17
+func newPortAllocator(min, max int) *portAllocator {
18
+ available := make([]int, 0, max-min+1)
19
+ for p := min; p <= max; p++ {
20
+ available = append(available, p)
21
+ }
22
+ return &portAllocator{
23
+ available: available,
24
+ inUse: make(map[int]struct{}),
25
+ }
26
+}
27
+
28
+// Allocate returns the next available port from the pool.
29
+func (a *portAllocator) Allocate() (int, error) {
30
+ a.mu.Lock()
31
+ defer a.mu.Unlock()
32
+
33
+ if len(a.available) == 0 {
34
+ return 0, errPortExhausted
35
+ }
36
+ port := a.available[0]
37
+ a.available = a.available[1:]
38
+ a.inUse[port] = struct{}{}
39
+ return port, nil
40
+}
41
+
42
+// Release returns a port back to the available pool.
43
+func (a *portAllocator) Release(port int) {
44
+ a.mu.Lock()
45
+ defer a.mu.Unlock()
46
+
47
+ if _, ok := a.inUse[port]; !ok {
48
+ return
49
+ }
50
+ delete(a.inUse, port)
51
+ a.available = append(a.available, port)
52
+}
portal/udp_relay.go
new
+178
@@ -0,0 +1,178 @@
1
+package portal
2
+
3
+import (
4
+ "context"
5
+ "fmt"
6
+ "net"
7
+ "sync"
8
+ "time"
9
+
10
+ "github.com/rs/zerolog/log"
11
+)
12
+
13
+// udpSession tracks one client endpoint sending to a per-lease UDP listener.
14
+type udpSession struct {
15
+ FlowID uint32
16
+ Addr *net.UDPAddr
17
+ LastSeen time.Time
18
+}
19
+
20
+// udpRelay binds a UDP port for a lease and relays datagrams bidirectionally
21
+// between raw UDP clients and the tunnel's QUIC connection via the quicBroker.
22
+type udpRelay struct {
23
+ leaseID string
24
+ port int
25
+ broker *quicBroker
26
+ conn *net.UDPConn
27
+
28
+ sessions map[string]*udpSession // "ip:port" → session
29
+ mu sync.Mutex
30
+
31
+ cancel context.CancelFunc
32
+ done chan struct{}
33
+ closeOnce sync.Once
34
+}
35
+
36
+func newUDPRelay(leaseID string, port int, broker *quicBroker) *udpRelay {
37
+ return &udpRelay{
38
+ leaseID: leaseID,
39
+ port: port,
40
+ broker: broker,
41
+ sessions: make(map[string]*udpSession),
42
+ done: make(chan struct{}),
43
+ }
44
+}
45
+
46
+// Start binds the UDP port and launches read/write relay goroutines.
47
+func (r *udpRelay) Start(ctx context.Context) error {
48
+ addr := &net.UDPAddr{Port: r.port}
49
+ conn, err := net.ListenUDP("udp", addr)
50
+ if err != nil {
51
+ return fmt.Errorf("listen udp :%d: %w", r.port, err)
52
+ }
53
+ r.conn = conn
54
+
55
+ relayCtx, cancel := context.WithCancel(ctx)
56
+ r.cancel = cancel
57
+
58
+ go r.readLoop(relayCtx)
59
+ go r.writeLoop(relayCtx)
60
+ go r.sessionCleanup(relayCtx)
61
+
62
+ log.Info().
63
+ Str("component", "udp-relay").
64
+ Str("lease_id", r.leaseID).
65
+ Int("port", r.port).
66
+ Msg("udp relay started")
67
+
68
+ return nil
69
+}
70
+
71
+// Stop closes the UDP socket and cancels the relay context.
72
+func (r *udpRelay) Stop() {
73
+ r.closeOnce.Do(func() {
74
+ if r.cancel != nil {
75
+ r.cancel()
76
+ }
77
+ if r.conn != nil {
78
+ _ = r.conn.Close()
79
+ }
80
+ close(r.done)
81
+ log.Info().
82
+ Str("component", "udp-relay").
83
+ Str("lease_id", r.leaseID).
84
+ Int("port", r.port).
85
+ Msg("udp relay stopped")
86
+ })
87
+}
88
+
89
+// readLoop reads raw UDP from public clients and forwards via QUIC DATAGRAM.
90
+func (r *udpRelay) readLoop(ctx context.Context) {
91
+ buf := make([]byte, defaultMaxDatagramSize)
92
+ for {
93
+ select {
94
+ case <-ctx.Done():
95
+ return
96
+ default:
97
+ }
98
+
99
+ _ = r.conn.SetReadDeadline(time.Now().Add(5 * time.Second))
100
+ n, clientAddr, err := r.conn.ReadFromUDP(buf)
101
+ if err != nil {
102
+ if ctx.Err() != nil {
103
+ return
104
+ }
105
+ if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
106
+ continue
107
+ }
108
+ return
109
+ }
110
+
111
+ flowID := r.getOrCreateFlow(clientAddr)
112
+ payload := make([]byte, n)
113
+ copy(payload, buf[:n])
114
+
115
+ if err := r.broker.SendDatagram(flowID, payload); err != nil {
116
+ // Tunnel not connected yet — drop silently.
117
+ continue
118
+ }
119
+ }
120
+}
121
+
122
+// writeLoop receives QUIC DATAGRAM frames from the tunnel and sends raw UDP back.
123
+func (r *udpRelay) writeLoop(ctx context.Context) {
124
+ for {
125
+ select {
126
+ case <-ctx.Done():
127
+ return
128
+ case frame := <-r.broker.Incoming():
129
+ addr, ok := r.broker.LookupFlowAddr(frame.FlowID)
130
+ if !ok {
131
+ continue
132
+ }
133
+ _, _ = r.conn.WriteToUDP(frame.Payload, addr)
134
+ }
135
+ }
136
+}
137
+
138
+func (r *udpRelay) getOrCreateFlow(addr *net.UDPAddr) uint32 {
139
+ key := addr.String()
140
+
141
+ r.mu.Lock()
142
+ defer r.mu.Unlock()
143
+
144
+ if s, ok := r.sessions[key]; ok {
145
+ s.LastSeen = time.Now()
146
+ return s.FlowID
147
+ }
148
+
149
+ flowID := r.broker.AllocateFlow(addr)
150
+ r.sessions[key] = &udpSession{
151
+ FlowID: flowID,
152
+ Addr: addr,
153
+ LastSeen: time.Now(),
154
+ }
155
+ return flowID
156
+}
157
+
158
+// sessionCleanup periodically removes idle UDP sessions.
159
+func (r *udpRelay) sessionCleanup(ctx context.Context) {
160
+ ticker := time.NewTicker(defaultUDPSessionTimeout)
161
+ defer ticker.Stop()
162
+
163
+ for {
164
+ select {
165
+ case <-ctx.Done():
166
+ return
167
+ case <-ticker.C:
168
+ now := time.Now()
169
+ r.mu.Lock()
170
+ for key, s := range r.sessions {
171
+ if now.Sub(s.LastSeen) > defaultUDPSessionTimeout {
172
+ delete(r.sessions, key)
173
+ }
174
+ }
175
+ r.mu.Unlock()
176
+ }
177
+ }
178
+}
sdk/client.go
+8
@@ -208,6 +208,11 @@ func (c *Client) Listen(ctx context.Context, req ListenRequest) (*Listener, erro
208
}
209
acceptedCap := max(readyTarget*2, 1)
210
211
+ transport := strings.TrimSpace(strings.ToLower(req.Transport))
212
+ if transport == "" {
213
+ transport = types.TransportTCP
214
+ }
215
+
216
registerReq := types.RegisterRequest{
217
Name: req.Name,
218
Hostnames: req.Hostnames,
@@ -215,6 +220,7 @@ func (c *Client) Listen(ctx context.Context, req ListenRequest) (*Listener, erro
220
ReverseToken: reverseToken,
221
TLS: true,
222
TTLSeconds: int(leaseTTL / time.Second),
223
+ Transport: transport,
224
}
225
226
var registerResp types.RegisterResponse
@@ -239,6 +245,8 @@ func (c *Client) Listen(ctx context.Context, req ListenRequest) (*Listener, erro
245
hostnames: registerResp.Hostnames,
246
metadata: registerResp.Metadata,
247
reverseToken: reverseToken,
248
+ udpAddr: registerResp.UDPAddr,
249
+ quicAddr: registerResp.QUICAddr,
250
leaseTTL: leaseTTL,
251
readyTarget: readyTarget,
252
tlsConfig: tlsConf,
sdk/helper.go
+35
-2
@@ -59,8 +59,9 @@ func Expose(ctx context.Context, relayUrls []string, name string, metadata types
59
}
60
61
listener, err := client.Listen(ctx, ListenRequest{
62
- Name: name,
63
- Metadata: metadata,
62
+ Name: name,
63
+ Metadata: metadata,
64
+ Transport: types.TransportBoth,
65
})
66
if err != nil {
67
client.Close()
@@ -221,6 +222,38 @@ func (e *Exposure) Close() error {
222
return closeErr
223
}
224
225
+// AttachUDP creates UDPListeners for each relay that has a UDP address,
226
+// reusing the existing lease credentials. The returned listeners only run the
227
+// QUIC supervisor — the TCP Listener already handles lease renewal.
228
+func (e *Exposure) AttachUDP(ctx context.Context) ([]*UDPListener, error) {
229
+ if e == nil || len(e.relays) == 0 {
230
+ return nil, nil
231
+ }
232
+
233
+ var listeners []*UDPListener
234
+ for _, relay := range e.relays {
235
+ udpAddr := relay.listener.UDPAddr()
236
+ if udpAddr == "" {
237
+ continue
238
+ }
239
+ ul, err := relay.client.AttachUDP(
240
+ ctx,
241
+ relay.listener.LeaseID(),
242
+ relay.listener.ReverseToken(),
243
+ udpAddr,
244
+ relay.listener.QUICAddr(),
245
+ )
246
+ if err != nil {
247
+ for _, l := range listeners {
248
+ _ = l.Close()
249
+ }
250
+ return nil, fmt.Errorf("attach udp %q: %w", relay.relayURL, err)
251
+ }
252
+ listeners = append(listeners, ul)
253
+ }
254
+ return listeners, nil
255
+}
256
+
257
// RunHTTP serves one handler on relayListener and, when localAddr is set, on
258
// the provided local HTTP address for app-local access.
259
func RunHTTP(ctx context.Context, relayListener net.Listener, handler http.Handler, localAddr string) error {
sdk/listener.go
+15
@@ -19,6 +19,7 @@ import (
19
type ListenRequest struct {
20
Name string
21
ReverseToken string
22
+ Transport string
23
Hostnames []string
24
Metadata types.LeaseMetadata
25
ReadyTarget int
@@ -37,6 +38,8 @@ type Listener struct {
38
name string
39
leaseID string
40
reverseToken string
41
+ udpAddr string
42
+ quicAddr string
43
hostnames []string
44
metadata types.LeaseMetadata
45
readyTarget int
@@ -91,6 +94,18 @@ func (l *Listener) LeaseID() string {
94
return l.leaseID
95
}
96
97
+func (l *Listener) ReverseToken() string {
98
+ return l.reverseToken
99
+}
100
+
101
+func (l *Listener) UDPAddr() string {
102
+ return l.udpAddr
103
+}
104
+
105
+func (l *Listener) QUICAddr() string {
106
+ return l.quicAddr
107
+}
108
+
109
func (l *Listener) Hostnames() []string {
110
l.mu.Lock()
111
defer l.mu.Unlock()
sdk/udp_listener.go
new
+424
@@ -0,0 +1,424 @@
1
+package sdk
2
+
3
+import (
4
+ "context"
5
+ "encoding/binary"
6
+ "encoding/json"
7
+ "errors"
8
+ "fmt"
9
+ "net"
10
+ "net/http"
11
+ "strings"
12
+ "sync"
13
+ "time"
14
+
15
+ "github.com/quic-go/quic-go"
16
+ "github.com/rs/zerolog/log"
17
+
18
+ "github.com/gosuda/portal/v2/types"
19
+)
20
+
21
+// UDPListener manages a QUIC connection to the relay for a UDP-transport lease.
22
+// It receives DATAGRAM frames from the relay and delivers decoded datagrams via
23
+// the AcceptDatagram method.
24
+type UDPListener struct {
25
+ client *Client
26
+ baseContext func() context.Context
27
+ ctxDone <-chan struct{}
28
+ cancel context.CancelFunc
29
+
30
+ name string
31
+ leaseID string
32
+ reverseToken string
33
+ udpAddr string
34
+ quicAddr string
35
+ hostnames []string
36
+ metadata types.LeaseMetadata
37
+ leaseTTL time.Duration
38
+
39
+ conn *quic.Conn
40
+ datagrams chan UDPDatagram
41
+ done chan struct{}
42
+
43
+ ownsLease bool
44
+ closeOnce sync.Once
45
+ mu sync.Mutex
46
+}
47
+
48
+// UDPDatagram represents a single datagram received from a public client
49
+// through the relay.
50
+type UDPDatagram struct {
51
+ FlowID uint32
52
+ Payload []byte
53
+}
54
+
55
+// AcceptDatagram blocks until a datagram is available or the listener is closed.
56
+func (l *UDPListener) AcceptDatagram() (UDPDatagram, error) {
57
+ select {
58
+ case <-l.ctxDone:
59
+ return UDPDatagram{}, net.ErrClosed
60
+ case dg, ok := <-l.datagrams:
61
+ if !ok {
62
+ return UDPDatagram{}, net.ErrClosed
63
+ }
64
+ return dg, nil
65
+ }
66
+}
67
+
68
+// SendDatagram sends a response datagram back to a client via the relay.
69
+func (l *UDPListener) SendDatagram(flowID uint32, payload []byte) error {
70
+ l.mu.Lock()
71
+ conn := l.conn
72
+ l.mu.Unlock()
73
+
74
+ if conn == nil {
75
+ return errors.New("quic connection not established")
76
+ }
77
+ return conn.SendDatagram(encodeDatagram(flowID, payload))
78
+}
79
+
80
+// UDPAddr returns the public UDP address allocated by the relay.
81
+func (l *UDPListener) UDPAddr() string {
82
+ return l.udpAddr
83
+}
84
+
85
+// LeaseID returns the current lease ID.
86
+func (l *UDPListener) LeaseID() string {
87
+ l.mu.Lock()
88
+ defer l.mu.Unlock()
89
+ return l.leaseID
90
+}
91
+
92
+// Hostnames returns the hostnames registered for this lease.
93
+func (l *UDPListener) Hostnames() []string {
94
+ l.mu.Lock()
95
+ defer l.mu.Unlock()
96
+ return l.hostnames
97
+}
98
+
99
+// Close tears down the QUIC connection. If this listener owns the lease
100
+// (created via ListenUDP), it also unregisters the lease. Attached listeners
101
+// (created via AttachUDP) leave lease lifecycle to the TCP Listener.
102
+func (l *UDPListener) Close() error {
103
+ var closeErr error
104
+ l.closeOnce.Do(func() {
105
+ l.cancel()
106
+
107
+ l.mu.Lock()
108
+ conn := l.conn
109
+ leaseID := l.leaseID
110
+ l.mu.Unlock()
111
+
112
+ if conn != nil {
113
+ _ = conn.CloseWithError(0, "listener closed")
114
+ }
115
+
116
+ if l.ownsLease {
117
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
118
+ defer cancel()
119
+ closeErr = l.client.unregisterLease(ctx, leaseID, l.reverseToken)
120
+ }
121
+ })
122
+ return closeErr
123
+}
124
+
125
+func (l *UDPListener) runSupervisor() {
126
+ for {
127
+ select {
128
+ case <-l.ctxDone:
129
+ return
130
+ default:
131
+ }
132
+
133
+ conn, err := l.client.openQUICSession(l.context(), l.quicAddr, l.leaseID, l.reverseToken)
134
+ if err != nil {
135
+ log.Warn().
136
+ Err(err).
137
+ Str("component", "sdk-udp-listener").
138
+ Str("lease_id", l.leaseID).
139
+ Msg("quic session open failed, retrying")
140
+ sleepOrDone(l.context(), 2*time.Second)
141
+ continue
142
+ }
143
+
144
+ l.mu.Lock()
145
+ l.conn = conn
146
+ l.mu.Unlock()
147
+
148
+ log.Info().
149
+ Str("component", "sdk-udp-listener").
150
+ Str("lease_id", l.leaseID).
151
+ Str("remote_addr", conn.RemoteAddr().String()).
152
+ Msg("quic tunnel connected")
153
+
154
+ l.receiveLoop(conn)
155
+
156
+ l.mu.Lock()
157
+ if l.conn == conn {
158
+ l.conn = nil
159
+ }
160
+ l.mu.Unlock()
161
+
162
+ if l.isClosed() {
163
+ return
164
+ }
165
+ sleepOrDone(l.context(), time.Second)
166
+ }
167
+}
168
+
169
+func (l *UDPListener) receiveLoop(conn *quic.Conn) {
170
+ for {
171
+ data, err := conn.ReceiveDatagram(l.context())
172
+ if err != nil {
173
+ if !l.isClosed() {
174
+ log.Warn().
175
+ Err(err).
176
+ Str("component", "sdk-udp-listener").
177
+ Str("lease_id", l.leaseID).
178
+ Msg("quic receive loop ended")
179
+ }
180
+ return
181
+ }
182
+
183
+ frame, err := decodeDatagram(data)
184
+ if err != nil {
185
+ continue
186
+ }
187
+
188
+ select {
189
+ case l.datagrams <- UDPDatagram{FlowID: frame.FlowID, Payload: frame.Payload}:
190
+ case <-l.ctxDone:
191
+ return
192
+ }
193
+ }
194
+}
195
+
196
+func (l *UDPListener) runRenewLoop() {
197
+ interval := l.leaseTTL / 2
198
+ if interval <= 0 {
199
+ interval = 30 * time.Second
200
+ }
201
+
202
+ ticker := time.NewTicker(interval)
203
+ defer ticker.Stop()
204
+
205
+ for {
206
+ select {
207
+ case <-l.ctxDone:
208
+ return
209
+ case <-ticker.C:
210
+ ctx, cancel := context.WithTimeout(l.context(), 10*time.Second)
211
+ err := l.client.renewLease(ctx, l.leaseID, l.reverseToken, l.leaseTTL)
212
+ cancel()
213
+ if err != nil {
214
+ log.Warn().
215
+ Err(err).
216
+ Str("component", "sdk-udp-listener").
217
+ Str("lease_id", l.leaseID).
218
+ Msg("lease renewal failed")
219
+ }
220
+ }
221
+ }
222
+}
223
+
224
+func (l *UDPListener) context() context.Context {
225
+ if l.baseContext != nil {
226
+ if ctx := l.baseContext(); ctx != nil {
227
+ return ctx
228
+ }
229
+ }
230
+ return context.Background()
231
+}
232
+
233
+func (l *UDPListener) isClosed() bool {
234
+ if l.ctxDone == nil {
235
+ return false
236
+ }
237
+ select {
238
+ case <-l.ctxDone:
239
+ return true
240
+ default:
241
+ return false
242
+ }
243
+}
244
+
245
+// datagramFrame mirrors portal.datagramFrame for SDK-side decode/encode.
246
+type datagramFrame struct {
247
+ FlowID uint32
248
+ Payload []byte
249
+}
250
+
251
+func encodeDatagram(flowID uint32, payload []byte) []byte {
252
+ var buf [binary.MaxVarintLen32]byte
253
+ n := binary.PutUvarint(buf[:], uint64(flowID))
254
+ out := make([]byte, n+len(payload))
255
+ copy(out, buf[:n])
256
+ copy(out[n:], payload)
257
+ return out
258
+}
259
+
260
+func decodeDatagram(data []byte) (datagramFrame, error) {
261
+ flowID, n := binary.Uvarint(data)
262
+ if n <= 0 {
263
+ return datagramFrame{}, fmt.Errorf("datagram too small to decode")
264
+ }
265
+ return datagramFrame{
266
+ FlowID: uint32(flowID),
267
+ Payload: data[n:],
268
+ }, nil
269
+}
270
+
271
+// quicControlResponse is read from the relay after sending the control message.
272
+type quicControlResponse struct {
273
+ OK bool `json:"ok"`
274
+ Error string `json:"error,omitempty"`
275
+}
276
+
277
+// openQUICSession opens a QUIC connection to the relay for datagram transport.
278
+// quicAddr is the relay's QUIC listen address (host:port). If empty, falls back
279
+// to the relay base URL host.
280
+func (c *Client) openQUICSession(ctx context.Context, quicAddr, leaseID, reverseToken string) (*quic.Conn, error) {
281
+ tlsConf := c.rawTLSConfig.Clone()
282
+ tlsConf.NextProtos = []string{"portal-tunnel"}
283
+
284
+ quicConf := &quic.Config{
285
+ EnableDatagrams: true,
286
+ KeepAlivePeriod: 15 * time.Second,
287
+ MaxIdleTimeout: 60 * time.Second,
288
+ }
289
+
290
+ dialAddr := ensurePort(c.baseURL.Host)
291
+ if quicAddr != "" {
292
+ dialAddr = quicAddr
293
+ }
294
+
295
+ conn, err := quic.DialAddr(ctx, dialAddr, tlsConf, quicConf)
296
+ if err != nil {
297
+ return nil, fmt.Errorf("quic dial: %w", err)
298
+ }
299
+
300
+ stream, err := conn.OpenStreamSync(ctx)
301
+ if err != nil {
302
+ _ = conn.CloseWithError(1, "stream open failed")
303
+ return nil, fmt.Errorf("open control stream: %w", err)
304
+ }
305
+
306
+ controlMsg, _ := json.Marshal(map[string]string{
307
+ "lease_id": leaseID,
308
+ "reverse_token": reverseToken,
309
+ })
310
+ if _, err := stream.Write(controlMsg); err != nil {
311
+ _ = conn.CloseWithError(1, "control write failed")
312
+ return nil, fmt.Errorf("write control: %w", err)
313
+ }
314
+
315
+ _ = stream.SetReadDeadline(time.Now().Add(10 * time.Second))
316
+ buf := make([]byte, 4096)
317
+ n, err := stream.Read(buf)
318
+ if err != nil {
319
+ _ = conn.CloseWithError(1, "control read failed")
320
+ return nil, fmt.Errorf("read control response: %w", err)
321
+ }
322
+
323
+ var resp quicControlResponse
324
+ if err := json.Unmarshal(buf[:n], &resp); err != nil {
325
+ _ = conn.CloseWithError(1, "invalid response")
326
+ return nil, fmt.Errorf("decode control response: %w", err)
327
+ }
328
+ if !resp.OK {
329
+ _ = conn.CloseWithError(1, resp.Error)
330
+ return nil, fmt.Errorf("quic connect rejected: %s", resp.Error)
331
+ }
332
+
333
+ return conn, nil
334
+}
335
+
336
+// AttachUDP creates a UDPListener that connects to an existing lease's QUIC
337
+// broker without registering a new lease or running a renew loop. The caller
338
+// (typically a TCP Listener) owns the lease lifecycle.
339
+func (c *Client) AttachUDP(ctx context.Context, leaseID, reverseToken, udpAddr, quicAddr string) (*UDPListener, error) {
340
+ if leaseID == "" {
341
+ return nil, errors.New("lease id is required for AttachUDP")
342
+ }
343
+
344
+ listenerCtx, cancel := context.WithCancel(ctx)
345
+ listener := &UDPListener{
346
+ client: c,
347
+ baseContext: func() context.Context { return listenerCtx },
348
+ ctxDone: listenerCtx.Done(),
349
+ cancel: cancel,
350
+ leaseID: leaseID,
351
+ reverseToken: reverseToken,
352
+ udpAddr: udpAddr,
353
+ quicAddr: quicAddr,
354
+ datagrams: make(chan UDPDatagram, 256),
355
+ done: make(chan struct{}),
356
+ ownsLease: false,
357
+ }
358
+
359
+ go listener.runSupervisor()
360
+ return listener, nil
361
+}
362
+
363
+// ListenUDP registers a UDP-transport lease and returns a UDPListener.
364
+func (c *Client) ListenUDP(ctx context.Context, req ListenRequest) (*UDPListener, error) {
365
+ if strings.TrimSpace(req.Name) == "" {
366
+ return nil, errors.New("listener name is required")
367
+ }
368
+ if ctx == nil {
369
+ ctx = context.Background()
370
+ }
371
+
372
+ reverseToken := strings.TrimSpace(req.ReverseToken)
373
+ if reverseToken == "" {
374
+ reverseToken = randomToken()
375
+ }
376
+ leaseTTL := req.LeaseTTL
377
+ if leaseTTL <= 0 {
378
+ leaseTTL = c.leaseTTL
379
+ }
380
+
381
+ transport := strings.TrimSpace(strings.ToLower(req.Transport))
382
+ if transport == "" {
383
+ transport = types.TransportUDP
384
+ }
385
+
386
+ registerReq := types.RegisterRequest{
387
+ Name: req.Name,
388
+ Hostnames: req.Hostnames,
389
+ Metadata: req.Metadata,
390
+ ReverseToken: reverseToken,
391
+ TLS: true,
392
+ TTLSeconds: int(leaseTTL / time.Second),
393
+ Transport: transport,
394
+ }
395
+
396
+ var registerResp types.RegisterResponse
397
+ if err := c.doJSON(ctx, http.MethodPost, types.PathSDKRegister, registerReq, ®isterResp); err != nil {
398
+ return nil, err
399
+ }
400
+
401
+ listenerCtx, cancel := context.WithCancel(ctx)
402
+ listener := &UDPListener{
403
+ client: c,
404
+ baseContext: func() context.Context { return listenerCtx },
405
+ ctxDone: listenerCtx.Done(),
406
+ cancel: cancel,
407
+ name: strings.TrimSpace(req.Name),
408
+ leaseID: registerResp.LeaseID,
409
+ reverseToken: reverseToken,
410
+ udpAddr: registerResp.UDPAddr,
411
+ quicAddr: registerResp.QUICAddr,
412
+ hostnames: registerResp.Hostnames,
413
+ metadata: registerResp.Metadata,
414
+ leaseTTL: leaseTTL,
415
+ datagrams: make(chan UDPDatagram, 256),
416
+ done: make(chan struct{}),
417
+ ownsLease: true,
418
+ }
419
+
420
+ go listener.runSupervisor()
421
+ go listener.runRenewLoop()
422
+
423
+ return listener, nil
424
+}
types/api.go
+11
@@ -10,6 +10,13 @@ const (
10
HeaderReverseToken = "X-Portal-Token"
11
MarkerKeepalive = byte(0x00)
12
MarkerTLSStart = byte(0x02)
13
+ MarkerQUICReady = byte(0x03)
14
+)
15
+
16
+const (
17
+ TransportTCP = "tcp"
18
+ TransportUDP = "udp"
19
+ TransportBoth = "both"
20
)
21
22
type APIEnvelope[T any] struct {
@@ -74,6 +81,7 @@ type RegisterRequest struct {
81
Metadata LeaseMetadata `json:"metadata"`
82
TTLSeconds int `json:"ttl_seconds,omitempty"`
83
TLS bool `json:"tls"`
84
+ Transport string `json:"transport,omitempty"`
85
}
86
87
type RegisterResponse struct {
@@ -82,6 +90,9 @@ type RegisterResponse struct {
90
ConnectURL string `json:"connect_url"`
91
Hostnames []string `json:"hostnames"`
92
Metadata LeaseMetadata `json:"metadata"`
93
+ UDPAddr string `json:"udp_addr,omitempty"`
94
+ QUICAddr string `json:"quic_addr,omitempty"`
95
+ Transport string `json:"transport,omitempty"`
96
}
97
98
type RenewRequest struct {
types/error_codes.go
+2
@@ -20,4 +20,6 @@ const (
20
APIErrorCodeMethodNotAllowed = "method_not_allowed"
21
APIErrorCodeSessionCreateFailed = "session_create_failed"
22
APIErrorCodeUnauthorized = "unauthorized"
23
+ APIErrorCodeUDPPortExhausted = "udp_port_exhausted"
24
+ APIErrorCodeTransportMismatch = "transport_mismatch"
25
)
types/paths.go
+2
-1
@@ -26,5 +26,6 @@ const (
26
PathSDKRegister = "/sdk/register"
27
PathSDKRenew = "/sdk/renew"
28
PathSDKUnregister = "/sdk/unregister"
29
- PathSDKConnect = "/sdk/connect"
29
+ PathSDKConnect = "/sdk/connect"
30
+ PathSDKQUICConnect = "/sdk/quic-connect"
31
)