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, &registerResp); 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 )