refact: tidy flags

Kim committed Apr 3, 2026 at 15:44 UTC 440cea4f22c63147cdde03b752a8841db2b87e3e
25 files changed +531 -206
.env.example
+11 -16
@@ -9,32 +9,27 @@ WIREGUARD_PRIVATE_KEY=
9 # Listener ports
10 API_PORT=4017
11 SNI_PORT=443
12 -# UDP transport (0 = disabled). Set count > 0 to enable QUIC tunnel + allocate UDP ports starting from 50000.
13 -# e.g., UDP_PORT_COUNT=10 → ports 50000-50009. Also requires enabling UDP in the admin panel.
14 -UDP_PORT_COUNT=0
15 -# Raw TCP port transport (0 = disabled). Set count > 0 to allocate TCP ports starting from 40000
16 -# for non-TLS services (e.g., Minecraft, game servers). Also requires enabling TCP port in the admin panel.
17 -# e.g., TCP_PORT_COUNT=10 → ports 40000-40009.
18 -TCP_PORT_COUNT=0
12 +# Set when enabling public UDP or raw TCP lease ports.
13 +MIN_PORT=0
14 +MAX_PORT=0
15 +UDP_ENABLED=false
16 +TCP_ENABLED=false
17
18 # TLS/ACME and keyless materials
19 KEYLESS_DIR=/portal-certs
22 -# Leave empty to use manual fullchain.pem/privatekey.pem from KEYLESS_DIR.
23 -# Set this when Portal should manage ACME DNS-01/renewal and/or ENS gasless DNSSEC/TXT automation.
20 +
21 # Supported managed values: cloudflare, gcloud, route53
25 -ACME_DNS_PROVIDER=gcloud
26 -# Optional ENS gasless DNS import automation. When enabled, Portal uses ACME_DNS_PROVIDER
27 -# for DNSSEC and ENS TXT automation, even when certificate files are managed manually.
22 +ACME_DNS_PROVIDER=cloudflare
23
24 # Cloudflare API token (required when ACME_DNS_PROVIDER=cloudflare)
25 CLOUDFLARE_TOKEN=
26
32 -# Google Cloud DNS settings.
27 +# Google Cloud DNS settings. (required when ACME_DNS_PROVIDER=gcloud)
28 GCP_PROJECT_ID=
29 GCP_MANAGED_ZONE=
30 GOOGLE_APPLICATION_CREDENTIALS=
31
37 -# Route53 settings (use static credentials or ambient AWS credentials)
32 +# Route53 settings (required when ACME_DNS_PROVIDER=route53)
33 AWS_ACCESS_KEY_ID=
34 AWS_SECRET_ACCESS_KEY=
35 AWS_SESSION_TOKEN=
@@ -43,9 +38,9 @@ AWS_DEFAULT_REGION=
38 AWS_HOSTED_ZONE_ID=
39 # Required only when ACME_DNS_PROVIDER=route53 and ENS_GASLESS_ENABLED=true and no ACTIVE KSK already exists.
40 AWS_DNSSEC_KMS_KEY_ARN=
46 -# Optional Route53 key-signing key name override. Defaults to portal_ksk.
47 -DNSSEC_KSK_NAME=
41
42 +# ENS gasless DNS import automation. When enabled, Portal uses ACME_DNS_PROVIDER
43 +# for DNSSEC and ENS TXT automation, even when certificate files are managed manually.
44 ENS_GASLESS_ENABLED=false
45
46 # Admin/auth configuration
cmd/portal-tunnel/README.md
+1 -1
@@ -127,5 +127,5 @@ Legacy execution compatibility has been removed:
127 - Tenant TLS is provisioned automatically through the relay keyless signer. The SDK fetches the relay certificate chain and uses `/v1/sign` for remote signing.
128 - `portal expose` enables MITM strict enforcement by default. Use `--ban-mitm=false` to keep warning-only behavior when the TLS self-probe suspects relay termination.
129 - When the local service is unreachable, the tunnel returns an HTTP 503 page.
130 -- `--tcp` allocates a dedicated TCP port (starting from 40000) on the relay. The relay bridges raw TCP connections to the local target without TLS. Requires `TCP_PORT_COUNT > 0` on the relay and TCP port enabled in the admin panel.
130 +- `--tcp` allocates a dedicated TCP port within the relay's configured `MIN_PORT-MAX_PORT` range. The relay bridges raw TCP connections to the local target without TLS. Requires `TCP_ENABLED=true`, a valid `MIN_PORT/MAX_PORT` range, and TCP port enabled in the admin panel.
131 - `--http-route` mode is HTTP-only and cannot be combined with `--udp`.
cmd/relay-server/main.go
+17 -12
@@ -34,8 +34,10 @@ type relayServerConfig struct {
34 PortalURL string
35 APIPort int
36 SNIPort int
37 - UDPPortCount int
38 - TCPPortCount int
37 + MinPort int
38 + MaxPort int
39 + UDPEnabled bool
40 + TCPEnabled bool
41 LandingPageEnabled bool
42 Bootstraps string
43 DiscoveryEnabled bool
@@ -59,7 +61,6 @@ type relayServerConfig struct {
61 AWSRegion string
62 AWSHostedZoneID string
63 AWSDNSSECKMSKeyARN string
62 - DNSSECKSKName string
64 }
65
66 func runServeCommand(args []string) error {
@@ -69,8 +70,10 @@ func runServeCommand(args []string) error {
70 utils.StringFlagEnv(fs, &cfg.PortalURL, "portal-url", "https://localhost:4017", "portal base URL", "PORTAL_URL")
71 utils.IntFlagEnv(fs, &cfg.APIPort, "api-port", 4017, utils.ParsePortNumber, "Admin/API server port", "API_PORT")
72 utils.IntFlagEnv(fs, &cfg.SNIPort, "sni-port", 443, utils.ParsePortNumber, "TCP SNI router port number", "SNI_PORT")
72 - utils.IntFlagEnv(fs, &cfg.UDPPortCount, "udp-port-count", 0, utils.ParseNonNegativeInt, "Number of UDP ports to allocate for leases, starting at port 50000 (0=disabled)", "UDP_PORT_COUNT")
73 - utils.IntFlagEnv(fs, &cfg.TCPPortCount, "tcp-port-count", 0, utils.ParseNonNegativeInt, "Number of TCP ports to allocate for raw TCP leases, starting at port 40000 (0=disabled)", "TCP_PORT_COUNT")
73 + utils.IntFlagEnv(fs, &cfg.MinPort, "min-port", 0, utils.ParseOptionalPortNumber, "inclusive minimum lease port shared by UDP and raw TCP transports (0=disabled)", "MIN_PORT")
74 + utils.IntFlagEnv(fs, &cfg.MaxPort, "max-port", 0, utils.ParseOptionalPortNumber, "inclusive maximum lease port shared by UDP and raw TCP transports (0=disabled)", "MAX_PORT")
75 + utils.BoolFlagEnv(fs, &cfg.UDPEnabled, "udp-enabled", false, "enable UDP relay transport; requires a valid --min-port/--max-port range", "UDP_ENABLED")
76 + utils.BoolFlagEnv(fs, &cfg.TCPEnabled, "tcp-enabled", false, "enable raw TCP port transport; requires a valid --min-port/--max-port range", "TCP_ENABLED")
77 utils.BoolFlagEnv(fs, &cfg.LandingPageEnabled, "landing-page-enabled", false, "enable landing page by default when no admin setting has been saved yet", "LANDING_PAGE_ENABLED")
78 utils.StringFlagEnv(fs, &cfg.Bootstraps, "bootstraps", "", "additional bootstrap relay API URLs used for discovery expansion", "BOOTSTRAPS")
79 utils.BoolFlagEnv(fs, &cfg.DiscoveryEnabled, "discovery", false, "serve relay discovery endpoints and poll discovery peers", "DISCOVERY")
@@ -95,7 +98,6 @@ func runServeCommand(args []string) error {
98 utils.StringFlagEnv(fs, &cfg.AWSRegion, "aws-region", "", "AWS region for Route53 and Route53-backed DNS-01; defaults to us-east-1 when unset", "AWS_REGION", "AWS_DEFAULT_REGION")
99 utils.StringFlagEnv(fs, &cfg.AWSHostedZoneID, "aws-hosted-zone-id", "", "explicit Route53 hosted zone ID override", "AWS_HOSTED_ZONE_ID")
100 utils.StringFlagEnv(fs, &cfg.AWSDNSSECKMSKeyARN, "aws-dnssec-kms-key-arn", "", "AWS KMS key ARN used to create a Route53 DNSSEC key-signing key when needed", "AWS_DNSSEC_KMS_KEY_ARN")
98 - utils.StringFlagEnv(fs, &cfg.DNSSECKSKName, "dnssec-ksk-name", "", "optional key-signing key name override for Route53 DNSSEC automation", "DNSSEC_KSK_NAME")
101
102 if err := utils.ParseFlagSet(fs, args, printRootUsage); err != nil {
103 if errors.Is(err, flag.ErrHelp) {
@@ -113,13 +115,15 @@ func runServeCommand(args []string) error {
115 Str("portal_url", cfg.PortalURL).
116 Str("identity_path", cfg.IdentityPath).
117 Str("admin_settings_path", cfg.AdminSettingsPath).
118 + Int("min_port", cfg.MinPort).
119 + Int("max_port", cfg.MaxPort).
120 Bool("landing_page_enabled", cfg.LandingPageEnabled).
121 Bool("discovery_enabled", cfg.DiscoveryEnabled).
122 Str("acme_dns_provider", cfg.ACMEDNSProvider).
123 Bool("ens_gasless_enabled", cfg.ENSGaslessEnabled).
124 Bool("wireguard_enabled", strings.TrimSpace(cfg.WireGuardPrivateKey) != "").
121 - Bool("udp_enabled", cfg.UDPPortCount > 0).
122 - Bool("tcp_port_enabled", cfg.TCPPortCount > 0).
125 + Bool("udp_enabled", cfg.UDPEnabled).
126 + Bool("tcp_enabled", cfg.TCPEnabled).
127 Msg("configured relay server")
128
129 ctx, stop := utils.SignalContext()
@@ -154,15 +158,16 @@ func runServer(ctx context.Context, cfg relayServerConfig) error {
158 AWSRegion: cfg.AWSRegion,
159 AWSHostedZoneID: cfg.AWSHostedZoneID,
160 AWSKMSKeyARN: cfg.AWSDNSSECKMSKeyARN,
157 - DNSSECKSKName: cfg.DNSSECKSKName,
161 },
162 APIPort: cfg.APIPort,
163 SNIPort: cfg.SNIPort,
164 TrustedProxyCIDRs: cfg.TrustedProxyCIDRs,
165 TrustProxyHeaders: cfg.TrustProxyHeaders,
166 DiscoveryEnabled: cfg.DiscoveryEnabled,
164 - UDPPortCount: cfg.UDPPortCount,
165 - TCPPortCount: cfg.TCPPortCount,
167 + MinPort: cfg.MinPort,
168 + MaxPort: cfg.MaxPort,
169 + UDPEnabled: cfg.UDPEnabled,
170 + TCPEnabled: cfg.TCPEnabled,
171 })
172 if err != nil {
173 return fmt.Errorf("create relay server: %w", err)
@@ -211,7 +216,7 @@ func printRootUsage(w io.Writer) {
216 "relay-server",
217 "relay-server serve",
218 "relay-server --portal-url https://portal.example.com",
214 - "relay-server --discovery --udp-port-count 100",
219 + "relay-server --discovery --udp-enabled --min-port 40000 --max-port 40099",
220 "relay-server --landing-page-enabled",
221 "relay-server help",
222 },
docker-compose.yml
+8 -9
@@ -8,12 +8,11 @@ services:
8 ports:
9 - "${API_PORT:-4017}:${API_PORT:-4017}"
10 - "${SNI_PORT:-443}:${SNI_PORT:-443}"
11 + # Uncomment for UDP backhaul, public UDP lease ports, and raw TCP lease ports as needed.
12 # - "${DISCOVERY_PORT:-51820}:${DISCOVERY_PORT:-51820}/udp"
12 - # Uncomment below when enabling UDP transport (UDP_PORT_COUNT > 0):
13 # - "${SNI_PORT:-443}:${SNI_PORT:-443}/udp"
14 - # - "50000-50009:50000-50009/udp" # adjust range to match UDP_PORT_COUNT
15 - # Uncomment below when enabling raw TCP port transport (TCP_PORT_COUNT > 0):
16 - # - "40000-40009:40000-40009" # adjust range to match TCP_PORT_COUNT
14 + # - "${MIN_PORT:-40000}-${MAX_PORT:-40009}:${MIN_PORT:-40000}-${MAX_PORT:-40009}/udp"
15 + # - "${MIN_PORT:-40000}-${MAX_PORT:-40009}:${MIN_PORT:-40000}-${MAX_PORT:-40009}"
16 environment:
17 # Public routing, discovery, and relay identity persistence
18 PORTAL_URL: ${PORTAL_URL:-https://localhost:${API_PORT:-4017}}
@@ -28,10 +27,11 @@ services:
27 API_PORT: ${API_PORT:-4017}
28 SNI_PORT: ${SNI_PORT:-443}
29
31 - # UDP transport (0 = disabled, set count > 0 to enable QUIC tunnel + UDP ports)
32 - UDP_PORT_COUNT: ${UDP_PORT_COUNT:-0}
33 - # Raw TCP port transport (0 = disabled, set count > 0 to allocate TCP ports for non-TLS services)
34 - TCP_PORT_COUNT: ${TCP_PORT_COUNT:-0}
30 + # Shared lease port range.
31 + MIN_PORT: ${MIN_PORT:-40000}
32 + MAX_PORT: ${MAX_PORT:-40010}
33 + UDP_ENABLED: ${UDP_ENABLED:-false}
34 + TCP_ENABLED: ${TCP_ENABLED:-false}
35
36 # Admin/auth configuration
37 ADMIN_SECRET_KEY: ${ADMIN_SECRET_KEY:-}
@@ -54,7 +54,6 @@ services:
54 AWS_DEFAULT_REGION: ${AWS_DEFAULT_REGION:-}
55 AWS_HOSTED_ZONE_ID: ${AWS_HOSTED_ZONE_ID:-}
56 AWS_DNSSEC_KMS_KEY_ARN: ${AWS_DNSSEC_KMS_KEY_ARN:-}
57 - DNSSEC_KSK_NAME: ${DNSSEC_KSK_NAME:-}
57 volumes:
58 - ./.portal-certs:${KEYLESS_DIR:-/portal-certs}
59 # Uncomment when using a Google Cloud service account file for gcloud automation.
docs/architecture.md
+15 -14
@@ -15,13 +15,13 @@ Stream client
15 -> Local service
16
17 TCP port client
18 - -> Relay lease TCP port (40000+ by default)
18 + -> Relay lease TCP port (within configured MIN_PORT-MAX_PORT)
19 -> Claimed reverse session (raw TCP, no TLS)
20 -> SDK / portal-tunnel
21 -> Local service
22
23 UDP client
24 - -> Relay lease UDP port (50000+ by default)
24 + -> Relay lease UDP port (within configured MIN_PORT-MAX_PORT)
25 -> Internal QUIC tunnel
26 -> SDK / portal-tunnel
27 -> Local UDP service
@@ -55,8 +55,8 @@ UDP client
55 - SNI wildcard matching is one level only. `*.parent.example.com` matches `foo.parent.example.com`, not deeper labels.
56 - Reverse TCP marker bytes remain protocol state:
57 - `0x00` = idle keepalive
58 + - `0x01` = raw TCP activation (non-TLS port routing)
59 - `0x02` = TLS passthrough activation
59 - - `0x03` = raw TCP activation (non-TLS port routing)
60 - `/sdk/connect` remains HTTP/1.1 only.
61
62 ### JSON and Shared Contract
@@ -94,7 +94,7 @@ Portal has three distinct network roles:
94 - hijacked into a long-lived raw TCP session
95 - starts idle in the per-lease stream ready queue, then becomes the tenant data path when claimed
96 - **Internal datagram tunnel**
97 - - QUIC to the relay URL host:port with ALPN `portal-tunnel`
97 + - QUIC to the relay URL host plus the relay-advertised `sni_port` from `POST /sdk/register` with ALPN `portal-tunnel`
98 - authenticated by a first-stream control message carrying `access_token`
99 - carries relay-to-SDK/tunnel datagram traffic only
100
@@ -118,7 +118,8 @@ That distinction matters because `/sdk/connect` stops being ordinary HTTP once h
118 - `transport.RelayStream`: per-lease ready queue for reverse stream sessions
119 - `transport.RelayTCPPort`: per-lease TCP listener on an allocated port; bridges incoming connections to reverse sessions using raw TCP (no TLS)
120 - `transport.RelayDatagram`: per-lease raw UDP socket plus QUIC DATAGRAM bridge runtime
121 -- `transport.PortAllocator`: count-based port allocator with sticky name-based reservation and grace period (shared by UDP and TCP port transport)
121 +- `transport.PortAllocator`: range-based port allocator with sticky name-based reservation and grace period
122 +- UDP and raw TCP allocate independently from the same inclusive `MIN_PORT-MAX_PORT` range, so the same numeric port may exist on both protocols
123 - `transport.datagramSession`: internal QUIC DATAGRAM bind/send/receive primitive shared by relay and SDK datagram runtimes
124 - `acme`: Cloudflare/Google Cloud DNS/Route53-backed root/wildcard A-record sync + certificate provisioning/renewal for the relay root host and wildcard
125 - `keyless`: admin/API TLS attach helpers and tenant-side signer integration
@@ -199,16 +200,16 @@ Result: this is a detect-only signal by default. It raises the cost of adaptive
200 ### TCP Port Transport (non-TLS)
201
202 1. SDK/tunnel requests a register challenge with `tcp_enabled=true`, signs the returned SIWE message, and then completes `POST /sdk/register`.
202 -2. Relay validates that the TCP port plane is enabled (server has `TCP_PORT_COUNT > 0` and admin has enabled TCP port), allocates a TCP port via `PortAllocator`, and creates a `transport.RelayTCPPort` for the lease.
203 +2. Relay validates that the TCP port plane is enabled (server has `TCP_ENABLED=true`, a valid `MIN_PORT/MAX_PORT` range, and admin has enabled TCP port), allocates a TCP port via `PortAllocator`, and creates a `transport.RelayTCPPort` for the lease.
204 3. Registration response includes `tcp_addr` (public TCP endpoint, e.g., `hostname:40001`).
205 4. `RelayTCPPort` starts a TCP listener on the allocated port.
206 5. An external TCP client connects to `tcp_addr`.
206 -6. `RelayTCPPort.acceptLoop` accepts the connection and claims a reverse session from the lease `RelayStream` using `ClaimRaw` (writes `0x03` marker instead of `0x02`).
207 -7. SDK-side `ClientStream.runSession` receives `0x03`, calls `activateRaw` which passes the raw connection directly without TLS handshake.
207 +6. `RelayTCPPort.acceptLoop` accepts the connection and claims a reverse session from the lease `RelayStream` using `ClaimRaw` (writes `0x01` marker instead of `0x02`).
208 +7. SDK-side `ClientStream.runSession` receives `0x01`, calls `activateRaw` which passes the raw connection directly without TLS handshake.
209 8. `RelayTCPPort.bridgeConns` copies data bidirectionally between the external client and the reverse session.
210
211 ```text
211 -Client --TCP--> [:40000+ Relay] --raw TCP--> [RelayTCPPort] --ClaimRaw--> [reverse session] --0x03--> [ClientStream] --> Local Service
212 +Client --TCP--> [:MIN_PORT-MAX_PORT Relay] --raw TCP--> [RelayTCPPort] --ClaimRaw--> [reverse session] --0x01--> [ClientStream] --> Local Service
213 <--bidirectional bridge--
214 ```
215
@@ -217,8 +218,8 @@ Result: the relay allocates a dedicated TCP port per lease and bridges raw TCP w
218 ### UDP/QUIC Datagram Transport
219
220 1. SDK/tunnel requests a register challenge with `udp_enabled=true`, signs the returned SIWE message, and then completes `POST /sdk/register`.
220 -2. Relay validates that the datagram plane is enabled (server has `UDP_PORT_COUNT > 0` and admin has enabled UDP), allocates a UDP port via `PortAllocator`, and creates a `transport.RelayDatagram` for the lease.
221 -3. Registration response includes `udp_addr` (public UDP endpoint) and `access_token`. There is no separate `quic_addr`; the SDK dials QUIC to the relay URL host:port.
221 +2. Relay validates that the datagram plane is enabled (server has `UDP_ENABLED=true`, a valid `MIN_PORT/MAX_PORT` range, and admin has enabled UDP), allocates a UDP port via `PortAllocator`, and creates a `transport.RelayDatagram` for the lease.
222 +3. Registration response includes `udp_addr` (public UDP endpoint), `access_token`, and `sni_port`. The SDK dials QUIC to the relay URL host on that `sni_port`.
223 4. SDK `transport.ClientDatagram` opens a QUIC connection with ALPN `portal-tunnel` and QUIC DATAGRAM support enabled.
224 5. Authentication: SDK sends `{access_token}` JSON on the first QUIC stream; the relay validates that lease access token before calling `RelayDatagram.Register(conn)`.
225 6. External UDP client sends a packet to `udp_addr` -> `RelayDatagram.readLoop` -> `TouchFlow` (assigns flow ID) -> `SendDatagram` -> QUIC DATAGRAM frame.
@@ -226,7 +227,7 @@ Result: the relay allocates a dedicated TCP port per lease and bridges raw TCP w
227 8. Return path: local response -> `Exposure.SendDatagram()` -> `Listener.SendDatagram()` -> `ClientDatagram.Send()` -> QUIC DATAGRAM -> `RelayDatagram.dispatch()` -> `conn.WriteToUDP` to the original client.
228
229 ```text
229 -Client --UDP--> [:50000+ Relay] --DATAGRAM--> [RelayDatagram] --QUIC--> [ClientDatagram] --UDP--> Local Service
230 +Client --UDP--> [:MIN_PORT-MAX_PORT Relay] --DATAGRAM--> [RelayDatagram] --QUIC--> [ClientDatagram] --UDP--> Local Service
231 <--QUIC DATAGRAM return path--
232 ```
233
@@ -292,11 +293,11 @@ Wire format (`types/transport.go`): `[flowID uvarint][payload bytes]`
293 - `identity`
294 - `iat`, `nbf`, `exp`
295 - `jti`
295 -- UDP registration requires two conditions: server must have `UDP_PORT_COUNT > 0` and admin must enable UDP in the admin panel
296 +- UDP registration requires three conditions: server must have `UDP_ENABLED=true`, a valid `MIN_PORT/MAX_PORT` range, and admin must enable UDP in the admin panel
297 - `APIErrorCodeUDPDisabled` (HTTP 403) when UDP is disabled by admin policy
298 - `APIErrorCodeUDPCapacityExceeded` (HTTP 503) when the admin-configured max UDP lease limit is reached
299 - `APIErrorCodeUDPPortExhausted` (HTTP 503) when the UDP port pool is exhausted
299 -- TCP port registration requires two conditions: server must have `TCP_PORT_COUNT > 0` and admin must enable TCP port in the admin panel
300 +- TCP port registration requires three conditions: server must have `TCP_ENABLED=true`, a valid `MIN_PORT/MAX_PORT` range, and admin must enable TCP port in the admin panel
301 - `APIErrorCodeTCPPortDisabled` (HTTP 403) when TCP port is disabled by admin policy
302 - `APIErrorCodeTCPPortCapacityExceeded` (HTTP 503) when the admin-configured max TCP port lease limit is reached
303 - `APIErrorCodeTCPPortExhausted` (HTTP 503) when the TCP port pool is exhausted
docs/deployment.md
+57 -41
@@ -14,8 +14,10 @@ You need:
14 - `443/tcp`
15 - `4017/tcp`
16 - optional for UDP transport:
17 - - `4017/udp`
18 - - `50000+/udp` (see section 5)
17 + - `SNI_PORT/udp`
18 + - `MIN_PORT-MAX_PORT/udp` (see section 5)
19 + - optional for raw TCP port transport:
20 + - `MIN_PORT-MAX_PORT/tcp` (see section 5)
21
22 ## 2. Certificate and DNS Mode
23
@@ -120,7 +122,6 @@ Equivalent relay flags:
122 When `ENS_GASLESS_ENABLED=true` and `ACME_DNS_PROVIDER=route53` and the hosted zone does not already have an active Route53 key-signing key (KSK), also provide:
123
124 - `AWS_DNSSEC_KMS_KEY_ARN`
123 -- optional `DNSSEC_KSK_NAME`
125
126 ### 3.4 Google Cloud DNS setup
127
@@ -266,8 +267,6 @@ AWS_REGION=us-east-1
267 AWS_HOSTED_ZONE_ID=Z1234567890ABC
268 # Required only for ENS gasless automation when no ACTIVE KSK already exists.
269 AWS_DNSSEC_KMS_KEY_ARN=arn:aws:kms:...
269 -# Optional override
270 -DNSSEC_KSK_NAME=portal_ksk
270 ENS_GASLESS_ENABLED=false
271 ```
272
@@ -334,57 +333,71 @@ Then start the stack:
333 docker compose up -d
334 ```
335
337 -## 5. Optional UDP Setup
336 +## 5. Optional UDP and Raw TCP Port Setup
337
339 -UDP transport is disabled by default.
338 +UDP transport and raw TCP port transport are disabled by default.
339
341 -### 5.1 Open UDP ports on your VM or host
340 +### 5.1 Open transport ports on your VM or host
341
343 -Open these UDP ports in your cloud security group or firewall:
342 +Open these ports in your cloud security group or firewall:
343
345 -- `4017/udp`
346 -- the lease port range starting at `50000`, for example `50000-50009/udp`
344 +- `SNI_PORT/udp`
345 +- `MIN_PORT-MAX_PORT/udp` when UDP transport is enabled
346 +- `MIN_PORT-MAX_PORT/tcp` when raw TCP port transport is enabled
347
348 -UFW example for 10 UDP ports:
348 +Example with `MIN_PORT=40000` and `MAX_PORT=40009`:
349
350 ```bash
351 -sudo ufw allow 4017/udp
352 -sudo ufw allow 50000:50009/udp
351 +sudo ufw allow 443/udp
352 +sudo ufw allow 40000:40009/udp
353 +sudo ufw allow 40000:40009/tcp
354 ```
355
355 -### 5.2 Expose UDP ports in Docker
356 +### 5.2 Expose transport ports in Docker
357
357 -If you use `network_mode: host`, the container uses host UDP ports directly.
358 +If you use `network_mode: host`, the container uses host transport ports directly.
359
360 If you use bridge networking, map the ports explicitly in `docker-compose.yaml`:
361
362 ```yaml
363 ports:
363 - - "4017:4017/udp"
364 - - "50000-50009:50000-50009/udp"
364 + - "443:443/udp"
365 + - "40000-40009:40000-40009/udp"
366 + - "40000-40009:40000-40009"
367 ```
368
367 -### 5.3 Configure `UDP_PORT_COUNT`
369 +Map `SNI_PORT/udp` on the host to the relay's UDP QUIC listener port in the container.
370 +UDP and raw TCP use the same numeric lease range independently, so when both transports are enabled you publish the same `MIN_PORT-MAX_PORT` range once for UDP and once for TCP.
371 +
372 +### 5.3 Configure Relay Transport Ports
373
369 -Set `UDP_PORT_COUNT` in `.env` to the number of UDP leases you want to support.
374 +Set the shared lease range in `.env`, then enable the transports you want.
375
376 Example:
377
378 ```bash
374 -UDP_PORT_COUNT=10
379 +MIN_PORT=40000
380 +MAX_PORT=40009
381 +UDP_ENABLED=true
382 +TCP_ENABLED=true
383 ```
384
377 -That allocates lease UDP ports `50000-50009`.
385 +That allocates lease ports `40000-40009` for both UDP and raw TCP. The protocols are independent, so the same numeric port may be used on both transports at the same time.
386 +The SDK datagram backhaul always uses the relay `SNI_PORT`, even if `PORTAL_URL` uses `:4017` for the API.
387
388 | Variable | Default | Description |
389 |---|---|---|
381 -| `UDP_PORT_COUNT` | `0` | Number of UDP ports to allocate, starting at port 50000 |
390 +| `MIN_PORT` | `0` | Inclusive minimum lease port shared by UDP and raw TCP (`0` disables the range) |
391 +| `MAX_PORT` | `0` | Inclusive maximum lease port shared by UDP and raw TCP (`0` disables the range) |
392 +| `UDP_ENABLED` | `false` | Enable UDP relay transport |
393 +| `TCP_ENABLED` | `false` | Enable raw TCP port transport |
394 +| `SNI_PORT` | `443` | Public TCP SNI port and QUIC UDP port for relay ingress |
395
383 -### 5.4 Enable UDP in the admin panel
396 +### 5.4 Enable transports in the admin panel
397
385 -After the relay starts, open `/admin`, enable UDP transport, and optionally set a max concurrent UDP lease limit.
398 +After the relay starts, open `/admin`, enable UDP transport and/or TCP port transport, and set any lease limits you want to enforce.
399
387 -### 4.5 Optional Linux UDP buffer tuning
400 +### 5.5 Optional Linux UDP buffer tuning
401
402 For better QUIC performance on Linux:
403
@@ -395,11 +408,11 @@ sudo sysctl -w net.core.wmem_max=7500000
408
409 To persist this across reboots, add the values to `/etc/sysctl.conf` or a file in `/etc/sysctl.d/`.
410
398 -## 5. Auto-Update
411 +## 6. Auto-Update
412
413 Automatically redeploy when a new `ghcr.io/gosuda/portal:latest` image is pushed.
414
402 -### 5.1 Deploy script
415 +### 6.1 Deploy script
416
417 Create `deploy_portal.sh` in your project directory:
418
@@ -413,7 +426,7 @@ docker compose pull
426 docker compose up -d
427 ```
428
416 -### 5.2 Watcher script
429 +### 6.2 Watcher script
430
431 The repository includes `watch_and_deploy.sh`, which polls the remote image digest and runs the deploy script on change.
432
@@ -425,7 +438,7 @@ Environment variables:
438 | `DEPLOY_SCRIPT` | `deploy_portal.sh` | Path to deploy script |
439 | `DIGEST_FILE` | `.portal_image_digest` | File storing the last known digest |
440
428 -### 5.3 Register as systemd service
441 +### 6.3 Register as systemd service
442
443 Set `WorkingDirectory` and `ExecStart` to the directory where `watch_and_deploy.sh` and `deploy_portal.sh` are located:
444
@@ -461,7 +474,7 @@ Adjust `User` to match your environment. Ensure the user belongs to the `docker`
474 sudo usermod -aG docker opc
475 ```
476
464 -### 5.4 Verify and monitor
477 +### 6.4 Verify and monitor
478
479 ```bash
480 sudo systemctl status portal-watcher
@@ -469,33 +482,36 @@ sudo journalctl -u portal-watcher -f
482 sudo journalctl -u portal-watcher --since today
483 ```
484
472 -## 6. Troubleshooting
485 +## 7. Troubleshooting
486
474 -### 6.1 Ports blocked
487 +### 7.1 Ports blocked
488
489 Required inbound ports:
490
491 - `443/tcp`
492 - `4017/tcp`
493 - optional for UDP:
481 - - `4017/udp`
482 - - `50000+/udp` matching `UDP_PORT_COUNT`
494 + - `SNI_PORT/udp`
495 + - `MIN_PORT-MAX_PORT/udp`
496 +- optional for raw TCP:
497 + - `MIN_PORT-MAX_PORT/tcp`
498
484 -UFW example with 10 UDP ports:
499 +UFW example with `MIN_PORT=40000` and `MAX_PORT=40009`:
500
501 ```bash
502 sudo ufw allow 443/tcp
503 sudo ufw allow 4017/tcp
489 -sudo ufw allow 4017/udp
490 -sudo ufw allow 50000:50009/udp
504 +sudo ufw allow 443/udp
505 +sudo ufw allow 40000:40009/udp
506 +sudo ufw allow 40000:40009/tcp
507 sudo ufw status
508 ```
509
494 -### 6.2 QUIC UDP buffer warnings
510 +### 7.2 QUIC UDP buffer warnings
511
496 -If relay logs show `failed to sufficiently increase receive buffer size`, apply the sysctl settings from section 4.5.
512 +If relay logs show `failed to sufficiently increase receive buffer size`, apply the sysctl settings from section 5.5.
513
498 -### 6.3 Docker DNS resolution fails
514 +### 7.3 Docker DNS resolution fails
515
516 If logs show `discover bootstraps failed`, `sync dns records`, or `lookup <host> on 127.0.0.11:53: write: operation not permitted`, Docker is usually using the wrong host resolver config.
517
docs/examples/nginx-proxy-multi-service/.env.example
+37 -22
@@ -1,40 +1,55 @@
1 # Portal + nginx multi-service deployment configuration
2 # Copy this file to .env and fill in the values.
3
4 -# Public routing (no port; nginx handles :443 externally)
4 +# Public routing, discovery, and relay identity persistence
5 PORTAL_URL=https://portal.example.com
6 +BOOTSTRAPS=
7 +DISCOVERY=true
8 +IDENTITY_PATH=/portal-certs/identity.json
9 +WIREGUARD_ENDPOINT=
10 +WIREGUARD_PRIVATE_KEY=
11
7 -# Internal listener ports
8 -# API_PORT uses default 4017.
9 -# SNI_PORT is 4443 to avoid conflict with nginx on 443.
12 +# Listener ports
13 API_PORT=4017
14 SNI_PORT=4443
15 +# Set when enabling public UDP or raw TCP lease ports.
16 +MIN_PORT=0
17 +MAX_PORT=0
18 +UDP_ENABLED=false
19 +TCP_ENABLED=false
20
13 -# ACME DNS provider (cloudflare, gcloud, or route53)
14 -ACME_DNS_PROVIDER=cloudflare
15 -
16 -# Relay identity persistence
17 -IDENTITY_PATH=/portal-certs/identity.json
18 -
19 -# UDP transport (0 = disabled). Allocates ports starting from 50000.
20 -# e.g., count=10 -> ports 50000-50009. Also requires enabling UDP in the admin panel.
21 -UDP_PORT_COUNT=0
21 +# TLS/ACME and keyless materials
22 +KEYLESS_DIR=/portal-certs
23
23 -# Admin secret for the /admin UI
24 -ADMIN_SECRET_KEY=
24 +# Supported managed values: cloudflare, gcloud, route53
25 +ACME_DNS_PROVIDER=cloudflare
26
26 -# Cloudflare API token (Zone:Read + DNS:Edit) for portal ACME cert issuance
27 +# Cloudflare API token (required when ACME_DNS_PROVIDER=cloudflare)
28 CLOUDFLARE_TOKEN=
29
29 -# Google Cloud DNS settings.
30 -# Use ADC via GOOGLE_APPLICATION_CREDENTIALS, workload identity, or an attached service account.
30 +# Google Cloud DNS settings. (required when ACME_DNS_PROVIDER=gcloud)
31 GCP_PROJECT_ID=
32 GCP_MANAGED_ZONE=
33 GOOGLE_APPLICATION_CREDENTIALS=
34
35 -# Portal state directories
36 -KEYLESS_DIR=/portal-certs
37 -
38 -# Trust forwarded headers from nginx (required behind reverse proxy)
35 +# Route53 settings (required when ACME_DNS_PROVIDER=route53)
36 +AWS_ACCESS_KEY_ID=
37 +AWS_SECRET_ACCESS_KEY=
38 +AWS_SESSION_TOKEN=
39 +AWS_REGION=
40 +AWS_DEFAULT_REGION=
41 +AWS_HOSTED_ZONE_ID=
42 +# Required only when ACME_DNS_PROVIDER=route53 and ENS_GASLESS_ENABLED=true and no ACTIVE KSK already exists.
43 +AWS_DNSSEC_KMS_KEY_ARN=
44 +
45 +# ENS gasless DNS import automation. When enabled, Portal uses ACME_DNS_PROVIDER
46 +# for DNSSEC and ENS TXT automation, even when certificate files are managed manually.
47 +ENS_GASLESS_ENABLED=false
48 +
49 +# Admin/auth configuration
50 +ADMIN_SECRET_KEY=
51 +LANDING_PAGE_ENABLED=false
52 +# Enable when the relay is behind nginx/ingress/load balancers and should trust forwarded client IP headers.
53 +# Optionally restrict which proxy source ranges may supply those headers; leave empty for default private/loopback proxy ranges.
54 TRUST_PROXY_HEADERS=true
55 TRUSTED_PROXY_CIDRS=127.0.0.0/8
docs/examples/nginx-proxy-multi-service/docker-compose.yaml
+14 -6
@@ -55,7 +55,8 @@ services:
55 # TCP (4017, 4443) is reached by nginx via host.docker.internal.
56 # SNI_PORT is 4443 to avoid conflicting with nginx on 443.
57 # If you enable relay-peer discovery overlay, expose DISCOVERY_PORT/udp as well.
58 - # If you enable UDP (UDP_PORT_COUNT > 0), expose SNI_PORT/udp and 50000+/udp as well.
58 + # If you enable UDP, expose SNI_PORT/udp and MIN_PORT-MAX_PORT/udp as well.
59 + # If you enable raw TCP transport, expose MIN_PORT-MAX_PORT/tcp as well.
60 portal:
61 image: ghcr.io/gosuda/portal:latest
62 container_name: portal
@@ -63,20 +64,28 @@ services:
64 - "${API_PORT:-4017}:${API_PORT:-4017}/tcp"
65 - "${SNI_PORT:-4443}:${SNI_PORT:-4443}/tcp"
66 - "${DISCOVERY_PORT:-51820}:${DISCOVERY_PORT:-51820}/udp"
66 - # Uncomment below when enabling UDP transport (host 443/udp is free — nginx only uses 443/tcp):
67 - # - "443:${SNI_PORT:-4443}/udp"
68 - # - "50000-50009:50000-50009/udp" # adjust range to match UDP_PORT_COUNT
67 + # Uncomment below when enabling UDP transport (host SNI_PORT/udp is free — nginx only uses 443/tcp):
68 + # - "${SNI_PORT:-4443}:${SNI_PORT:-4443}/udp"
69 + # - "${MIN_PORT:-40000}-${MAX_PORT:-40009}:${MIN_PORT:-40000}-${MAX_PORT:-40009}/udp"
70 + # Uncomment below when enabling raw TCP port transport:
71 + # - "${MIN_PORT:-40000}-${MAX_PORT:-40009}:${MIN_PORT:-40000}-${MAX_PORT:-40009}"
72 stop_grace_period: 30s
73 environment:
74 PORTAL_URL: ${PORTAL_URL:-https://portal.example.com}
75 + BOOTSTRAPS: ${BOOTSTRAPS:-}
76 + DISCOVERY: ${DISCOVERY:-true}
77 API_PORT: ${API_PORT:-4017}
78 SNI_PORT: ${SNI_PORT:-4443}
79 IDENTITY_PATH: ${IDENTITY_PATH:-/portal-certs/identity.json}
80 WIREGUARD_PRIVATE_KEY: ${WIREGUARD_PRIVATE_KEY:-}
81 DISCOVERY_PORT: ${DISCOVERY_PORT:-51820}
82 WIREGUARD_ENDPOINT: ${WIREGUARD_ENDPOINT:-}
78 - UDP_PORT_COUNT: ${UDP_PORT_COUNT:-0}
83 + MIN_PORT: ${MIN_PORT:-0}
84 + MAX_PORT: ${MAX_PORT:-0}
85 + UDP_ENABLED: ${UDP_ENABLED:-false}
86 + TCP_ENABLED: ${TCP_ENABLED:-false}
87 ADMIN_SECRET_KEY: ${ADMIN_SECRET_KEY:-}
88 + LANDING_PAGE_ENABLED: ${LANDING_PAGE_ENABLED:-false}
89 TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-true}
90 TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-127.0.0.0/8}
91 KEYLESS_DIR: ${KEYLESS_DIR:-/portal-certs}
@@ -93,7 +102,6 @@ services:
102 AWS_DEFAULT_REGION: ${AWS_DEFAULT_REGION:-}
103 AWS_HOSTED_ZONE_ID: ${AWS_HOSTED_ZONE_ID:-}
104 AWS_DNSSEC_KMS_KEY_ARN: ${AWS_DNSSEC_KMS_KEY_ARN:-}
96 - DNSSEC_KSK_NAME: ${DNSSEC_KSK_NAME:-}
105 volumes:
106 - ./.portal-certs:${KEYLESS_DIR:-/portal-certs}
107 # Uncomment when using a Google Cloud service account file for gcloud automation.
docs/examples/nginx-proxy/.env.example
+38 -21
@@ -1,38 +1,55 @@
1 # Portal + nginx reverse proxy configuration
2 # Copy this file to .env and fill in the values.
3
4 -# Public routing (no port; nginx handles :443 externally)
4 +# Public routing, discovery, and relay identity persistence
5 PORTAL_URL=https://portal.example.com
6 +BOOTSTRAPS=
7 +DISCOVERY=true
8 +IDENTITY_PATH=/portal-certs/identity.json
9 +WIREGUARD_ENDPOINT=
10 +WIREGUARD_PRIVATE_KEY=
11
7 -# Listener ports bound directly on the host via host networking
12 +# Listener ports
13 API_PORT=4017
9 -SNI_PORT=443
14 +SNI_PORT=4443
15 +# Set when enabling public UDP or raw TCP lease ports.
16 +MIN_PORT=0
17 +MAX_PORT=0
18 +UDP_ENABLED=false
19 +TCP_ENABLED=false
20 +
21 +# TLS/ACME and keyless materials
22 +KEYLESS_DIR=/portal-certs
23
11 -# ACME DNS provider (cloudflare, gcloud, or route53)
24 +# Supported managed values: cloudflare, gcloud, route53
25 ACME_DNS_PROVIDER=cloudflare
26
14 -# Relay identity persistence
15 -IDENTITY_PATH=/portal-certs/identity.json
16 -
17 -# UDP transport (0 = disabled). Allocates ports starting from 50000.
18 -# e.g., count=10 -> ports 50000-50009. Also requires enabling UDP in the admin panel.
19 -UDP_PORT_COUNT=0
20 -
21 -# Admin secret for the /admin UI
22 -ADMIN_SECRET_KEY=
23 -
24 -# Cloudflare API token (Zone:Read + DNS:Edit) for portal ACME cert issuance
27 +# Cloudflare API token (required when ACME_DNS_PROVIDER=cloudflare)
28 CLOUDFLARE_TOKEN=
29
27 -# Google Cloud DNS settings.
28 -# Use ADC via GOOGLE_APPLICATION_CREDENTIALS, workload identity, or an attached service account.
30 +# Google Cloud DNS settings. (required when ACME_DNS_PROVIDER=gcloud)
31 GCP_PROJECT_ID=
32 GCP_MANAGED_ZONE=
33 GOOGLE_APPLICATION_CREDENTIALS=
34
33 -# Portal state directories
34 -KEYLESS_DIR=/portal-certs
35 -
36 -# Trust forwarded headers from nginx (required behind reverse proxy)
35 +# Route53 settings (required when ACME_DNS_PROVIDER=route53)
36 +AWS_ACCESS_KEY_ID=
37 +AWS_SECRET_ACCESS_KEY=
38 +AWS_SESSION_TOKEN=
39 +AWS_REGION=
40 +AWS_DEFAULT_REGION=
41 +AWS_HOSTED_ZONE_ID=
42 +# Required only when ACME_DNS_PROVIDER=route53 and ENS_GASLESS_ENABLED=true and no ACTIVE KSK already exists.
43 +AWS_DNSSEC_KMS_KEY_ARN=
44 +
45 +# ENS gasless DNS import automation. When enabled, Portal uses ACME_DNS_PROVIDER
46 +# for DNSSEC and ENS TXT automation, even when certificate files are managed manually.
47 +ENS_GASLESS_ENABLED=false
48 +
49 +# Admin/auth configuration
50 +ADMIN_SECRET_KEY=
51 +LANDING_PAGE_ENABLED=false
52 +# Enable when the relay is behind nginx/ingress/load balancers and should trust forwarded client IP headers.
53 +# Optionally restrict which proxy source ranges may supply those headers; leave empty for default private/loopback proxy ranges.
54 TRUST_PROXY_HEADERS=true
55 TRUSTED_PROXY_CIDRS=127.0.0.0/8
docs/examples/nginx-proxy/docker-compose.yaml
+20 -11
@@ -5,11 +5,13 @@
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:4443/udp (QUIC tunnel listener — shares SNI_PORT, only if UDP_PORT_COUNT > 0)
9 -# portal:50000+/udp (per-lease UDP relay ports — only if UDP_PORT_COUNT > 0)
8 +# portal:4443/udp (QUIC tunnel listener — shares SNI_PORT, only if UDP_ENABLED=true and a valid MIN_PORT/MAX_PORT range is configured)
9 +# portal:MIN_PORT-MAX_PORT/udp (per-lease UDP relay ports — only if UDP_ENABLED=true)
10 +# portal:MIN_PORT-MAX_PORT/tcp (per-lease raw TCP ports — only if TCP_ENABLED=true)
11 #
11 -# If you enable UDP transport (UDP_PORT_COUNT > 0), expose SNI_PORT/udp and
12 -# the allocated UDP port range (50000+) in the portal service ports section.
12 +# If you enable UDP transport, expose SNI_PORT/udp and the shared lease
13 +# range as MIN_PORT-MAX_PORT/udp. If you enable raw TCP transport, expose the same
14 +# MIN_PORT-MAX_PORT range again over TCP.
15 #
16 # Prerequisites:
17 # 1. Copy .env.example to .env and set all required values.
@@ -46,7 +48,8 @@ services:
48 # TCP (4017, 4443) is reached by nginx via 127.0.0.1.
49 # SNI_PORT is set to 4443 to avoid conflicting with nginx on port 443.
50 # If you enable relay-peer discovery overlay, expose DISCOVERY_PORT/udp as well.
49 - # If you enable UDP (UDP_PORT_COUNT > 0), expose SNI_PORT/udp and 50000+/udp as well.
51 + # If you enable UDP, expose SNI_PORT/udp and MIN_PORT-MAX_PORT/udp as well.
52 + # If you enable raw TCP transport, expose MIN_PORT-MAX_PORT/tcp as well.
53 portal:
54 image: ghcr.io/gosuda/portal:latest
55 container_name: portal
@@ -54,12 +57,16 @@ services:
57 - "${API_PORT:-4017}:${API_PORT:-4017}/tcp"
58 - "${SNI_PORT:-4443}:${SNI_PORT:-4443}/tcp"
59 - "${DISCOVERY_PORT:-51820}:${DISCOVERY_PORT:-51820}/udp"
57 - # Uncomment below when enabling UDP transport (host 443/udp is free — nginx only uses 443/tcp):
58 - # - "443:${SNI_PORT:-4443}/udp"
59 - # - "50000-50009:50000-50009/udp" # adjust range to match UDP_PORT_COUNT
60 + # Uncomment below when enabling UDP transport (host SNI_PORT/udp is free — nginx only uses 443/tcp):
61 + # - "${SNI_PORT:-4443}:${SNI_PORT:-4443}/udp"
62 + # - "${MIN_PORT:-40000}-${MAX_PORT:-40009}:${MIN_PORT:-40000}-${MAX_PORT:-40009}/udp"
63 + # Uncomment below when enabling raw TCP port transport:
64 + # - "${MIN_PORT:-40000}-${MAX_PORT:-40009}:${MIN_PORT:-40000}-${MAX_PORT:-40009}"
65 stop_grace_period: 30s
66 environment:
67 PORTAL_URL: ${PORTAL_URL:-https://portal.example.com}
68 + BOOTSTRAPS: ${BOOTSTRAPS:-}
69 + DISCOVERY: ${DISCOVERY:-true}
70
71 # Listener ports (bound directly on host via host networking).
72 API_PORT: ${API_PORT:-4017}
@@ -70,10 +77,13 @@ services:
77 DISCOVERY_PORT: ${DISCOVERY_PORT:-51820}
78 WIREGUARD_ENDPOINT: ${WIREGUARD_ENDPOINT:-}
79
73 - # UDP transport (0 = disabled, set count to enable).
74 - UDP_PORT_COUNT: ${UDP_PORT_COUNT:-0}
80 + MIN_PORT: ${MIN_PORT:-0}
81 + MAX_PORT: ${MAX_PORT:-0}
82 + UDP_ENABLED: ${UDP_ENABLED:-false}
83 + TCP_ENABLED: ${TCP_ENABLED:-false}
84
85 ADMIN_SECRET_KEY: ${ADMIN_SECRET_KEY:-}
86 + LANDING_PAGE_ENABLED: ${LANDING_PAGE_ENABLED:-false}
87 TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-true}
88 TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-127.0.0.0/8}
89
@@ -91,7 +101,6 @@ services:
101 AWS_DEFAULT_REGION: ${AWS_DEFAULT_REGION:-}
102 AWS_HOSTED_ZONE_ID: ${AWS_HOSTED_ZONE_ID:-}
103 AWS_DNSSEC_KMS_KEY_ARN: ${AWS_DNSSEC_KMS_KEY_ARN:-}
94 - DNSSEC_KSK_NAME: ${DNSSEC_KSK_NAME:-}
104 volumes:
105 - ./.portal-certs:${KEYLESS_DIR:-/portal-certs}
106 # Uncomment when using a Google Cloud service account file for gcloud automation.
portal/acme/acme.go
-2
@@ -54,7 +54,6 @@ type Config struct {
54 AWSRegion string
55 AWSHostedZoneID string
56 AWSKMSKeyARN string
57 - DNSSECKSKName string
57 }
58
59 type Manager struct {
@@ -89,7 +88,6 @@ func NewManager(cfg Config) (*Manager, error) {
88 cfg.AWSRegion = strings.TrimSpace(cfg.AWSRegion)
89 cfg.AWSHostedZoneID = strings.TrimSpace(cfg.AWSHostedZoneID)
90 cfg.AWSKMSKeyARN = strings.TrimSpace(cfg.AWSKMSKeyARN)
92 - cfg.DNSSECKSKName = strings.TrimSpace(cfg.DNSSECKSKName)
91 if cfg.ENSGaslessEnabled {
92 if cfg.ENSGaslessAddress == "" {
93 return nil, errors.New("ens gasless address is required when ens gasless import is enabled")
portal/acme/provider.go
-1
@@ -49,7 +49,6 @@ func NewDNSProvider(providerType string, cfg Config) (DNSProvider, error) {
49 Region: cfg.AWSRegion,
50 HostedZoneID: cfg.AWSHostedZoneID,
51 KMSKeyARN: cfg.AWSKMSKeyARN,
52 - DNSSECKSKName: cfg.DNSSECKSKName,
52 }), nil
53 default:
54 return nil, fmt.Errorf("unsupported acme dns provider: %q", providerType)
portal/acme/route53/provider.go
+1 -6
@@ -32,7 +32,6 @@ type Config struct {
32 Region string
33 HostedZoneID string
34 KMSKeyARN string
35 - DNSSECKSKName string
35 }
36
37 type Provider struct {
@@ -48,7 +47,6 @@ func New(cfg Config) *Provider {
47 Region: strings.TrimSpace(cfg.Region),
48 HostedZoneID: normalizeZoneID(cfg.HostedZoneID),
49 KMSKeyARN: strings.TrimSpace(cfg.KMSKeyARN),
51 - DNSSECKSKName: strings.TrimSpace(cfg.DNSSECKSKName),
50 },
51 }
52 }
@@ -552,10 +550,7 @@ func ensureActiveKeySigningKey(ctx context.Context, client *awsroute53.Client, h
550 if client == nil {
551 return errors.New("route53 client is nil")
552 }
555 - kskName := strings.TrimSpace(cfg.DNSSECKSKName)
556 - if kskName == "" {
557 - kskName = defaultDNSSECKSKName
558 - }
553 + kskName := defaultDNSSECKSKName
554
555 if existing, ok := keySigningKeyByName(keys, kskName); ok {
556 if strings.EqualFold(strings.TrimSpace(aws.ToString(existing.Status)), "ACTIVE") {
portal/api_server.go
+7 -8
@@ -158,10 +158,8 @@ func (s *Server) handleRelayDiscovery(w http.ResponseWriter, r *http.Request) {
158 ExpiresAt: now.Add(2 * types.DiscoveryPollInterval),
159 APIHTTPSAddr: s.cfg.PortalURL,
160 IngressTLSAddr: ingressAddr,
161 - SupportsTLS: true,
162 - SupportsUDP: s.cfg.UDPPortCount > 0,
163 - SupportsTCP: true,
164 - SupportsRawTCP: s.cfg.TCPPortCount > 0,
161 + SupportsUDP: s.cfg.UDPEnabled && s.quicTunnel != nil,
162 + SupportsTCP: s.cfg.TCPEnabled,
163 SupportsOverlayPeer: supportsOverlayPeer,
164 WireGuardPublicKey: s.wgConfig.PublicKey,
165 WireGuardEndpoint: s.wgConfig.Endpoint,
@@ -282,11 +280,11 @@ func (s *Server) handleRegisterChallenge(w http.ResponseWriter, r *http.Request)
280 Path: types.PathSDKRegister,
281 }).String()
282
285 - if req.UDPEnabled && (s.cfg.UDPPortCount <= 0 || s.group != nil && s.quicTunnel == nil) {
283 + if req.UDPEnabled && (!s.cfg.UDPEnabled || s.group != nil && s.quicTunnel == nil) {
284 utils.WriteAPIError(w, http.StatusServiceUnavailable, types.APIErrorCodeFeatureUnavailable, errFeatureUnavailable.Error())
285 return
286 }
289 - if req.TCPEnabled && s.cfg.TCPPortCount <= 0 {
287 + if req.TCPEnabled && !s.cfg.TCPEnabled {
288 utils.WriteAPIError(w, http.StatusServiceUnavailable, types.APIErrorCodeFeatureUnavailable, errFeatureUnavailable.Error())
289 return
290 }
@@ -561,7 +559,7 @@ func (s *Server) registerLease(req types.RegisterChallengeRequest, clientIP, rep
559 }
560
561 if req.UDPEnabled {
564 - if s.cfg.UDPPortCount <= 0 || s.group != nil && s.quicTunnel == nil {
562 + if !s.cfg.UDPEnabled || s.group != nil && s.quicTunnel == nil {
563 return types.RegisterResponse{}, errFeatureUnavailable
564 }
565 if !s.registry.policy.IsUDPEnabled() {
@@ -572,7 +570,7 @@ func (s *Server) registerLease(req types.RegisterChallengeRequest, clientIP, rep
570 }
571 }
572 if req.TCPEnabled {
575 - if s.cfg.TCPPortCount <= 0 {
573 + if !s.cfg.TCPEnabled {
574 return types.RegisterResponse{}, errFeatureUnavailable
575 }
576 if !s.registry.policy.IsTCPPortEnabled() {
@@ -655,6 +653,7 @@ func (s *Server) registerLease(req types.RegisterChallengeRequest, clientIP, rep
653 TCPEnabled: record.TCPEnabled,
654 }
655 if record.datagram != nil {
656 + resp.SNIPort = s.cfg.SNIPort
657 resp.UDPAddr = fmt.Sprintf("%s:%d", s.identity.Name, record.datagram.UDPPort())
658 }
659 if record.tcpPort != nil {
portal/server.go
+34 -18
@@ -34,8 +34,6 @@ const (
34 defaultReadyQueueLimit = 8
35 defaultClientHelloWait = 2 * time.Second
36 defaultControlBodyLimit = 4 << 20
37 - defaultUDPPortBase = 50000
38 - defaultTCPPortBase = 40000
37 defaultWGRecoveryFailures = 3
38 )
39
@@ -54,12 +52,13 @@ type ServerConfig struct {
52 SNIPort int
53 APIListenAddr string
54 SNIListenAddr string
57 - QUICListenAddr string
55 TrustedProxyCIDRs string
56 TrustProxyHeaders bool
57 DiscoveryEnabled bool
61 - UDPPortCount int
62 - TCPPortCount int
58 + MinPort int
59 + MaxPort int
60 + UDPEnabled bool
61 + TCPEnabled bool
62 }
63
64 type Server struct {
@@ -93,7 +92,6 @@ func NewServer(cfg ServerConfig) (*Server, error) {
92 if rootHost == "" {
93 return nil, errors.New("root host is required")
94 }
96 - cfg.QUICListenAddr = utils.StringOrDefault(cfg.QUICListenAddr, cfg.SNIListenAddr)
95 trustedProxyCIDRs, err := utils.ParseCIDRs(cfg.TrustedProxyCIDRs)
96 if err != nil {
97 return nil, fmt.Errorf("parse trusted proxy cidrs: %w", err)
@@ -129,10 +127,26 @@ func NewServer(cfg ServerConfig) (*Server, error) {
127 Msg("generated wireguard private key; set WIREGUARD_PRIVATE_KEY to preserve relay identity")
128 }
129
130 + transportEnabled := cfg.UDPEnabled || cfg.TCPEnabled
131 + hasPortRange := cfg.MinPort > 0 && cfg.MaxPort > 0
132 + if transportEnabled {
133 + switch {
134 + case !hasPortRange:
135 + return nil, errors.New("udp and tcp relay transport require a valid min port and max port range")
136 + case cfg.MinPort > 65535 || cfg.MaxPort > 65535:
137 + return nil, errors.New("min port and max port must be between 1 and 65535")
138 + case cfg.MinPort > cfg.MaxPort:
139 + return nil, errors.New("min port must be less than or equal to max port")
140 + }
141 + }
142 +
143 + cfg.UDPEnabled = cfg.UDPEnabled && hasPortRange
144 + cfg.TCPEnabled = cfg.TCPEnabled && hasPortRange
145 +
146 portMin, portMax := 0, 0
133 - if cfg.UDPPortCount > 0 {
134 - portMin = defaultUDPPortBase
135 - portMax = defaultUDPPortBase + cfg.UDPPortCount - 1
147 + if cfg.UDPEnabled {
148 + portMin = cfg.MinPort
149 + portMax = cfg.MaxPort
150 }
151
152 identity, generatedIdentity, err := utils.LoadOrCreateIdentity(cfg.IdentityPath, types.Identity{Name: rootHost})
@@ -147,14 +161,14 @@ func NewServer(cfg ServerConfig) (*Server, error) {
161 }
162
163 tcpPortMin, tcpPortMax := 0, 0
150 - if cfg.TCPPortCount > 0 {
151 - tcpPortMin = defaultTCPPortBase
152 - tcpPortMax = defaultTCPPortBase + cfg.TCPPortCount - 1
164 + if cfg.TCPEnabled {
165 + tcpPortMin = cfg.MinPort
166 + tcpPortMax = cfg.MaxPort
167 }
168
169 policy := policy.NewRuntime()
156 - policy.SetUDPPolicy(cfg.UDPPortCount > 0, 0)
157 - policy.SetTCPPortPolicy(cfg.TCPPortCount > 0, 0)
170 + policy.SetUDPPolicy(cfg.UDPEnabled, 0)
171 + policy.SetTCPPortPolicy(cfg.TCPEnabled, 0)
172 registry := newLeaseRegistry(policy)
173 ports := transport.NewPortAllocator(portMin, portMax, 5*time.Minute)
174 tcpPorts := transport.NewPortAllocator(tcpPortMin, tcpPortMax, 5*time.Minute)
@@ -245,7 +259,7 @@ func (s *Server) Start(ctx context.Context, apiMux *http.ServeMux) error {
259 }
260 s.acmeManager.Start(serverCtx)
261
248 - if s.cfg.UDPPortCount > 0 {
262 + if s.cfg.UDPEnabled {
263 if err := s.startQUICTunnelListener(apiTLS); err != nil {
264 log.Warn().Err(err).Msg("quic tunnel listener disabled")
265 }
@@ -262,10 +276,12 @@ func (s *Server) Start(ctx context.Context, apiMux *http.ServeMux) error {
276 Str("sni_addr", s.sniListener.Addr().String()).
277 Str("root_host", s.identity.Name).
278 Str("acme_dns_provider", s.cfg.ACME.DNSProvider).
279 + Int("min_port", s.cfg.MinPort).
280 + Int("max_port", s.cfg.MaxPort).
281 Bool("discovery_enabled", s.cfg.DiscoveryEnabled).
282 Bool("wireguard_enabled", s.wgConfig.PrivateKey != "").
267 - Bool("udp_enabled", s.cfg.UDPPortCount > 0).
268 - Bool("tcp_port_enabled", s.cfg.TCPPortCount > 0)
283 + Bool("udp_enabled", s.cfg.UDPEnabled).
284 + Bool("tcp_enabled", s.cfg.TCPEnabled)
285 if s.quicTunnel != nil {
286 logEvent = logEvent.Str("internal_quic_tunnel_addr", s.quicTunnel.Addr().String())
287 }
@@ -565,7 +581,7 @@ func (s *Server) startQUICTunnelListener(apiTLS keyless.TLSMaterialConfig) error
581 MaxIncomingStreams: 16,
582 }
583
568 - listener, err := quic.ListenAddr(s.cfg.QUICListenAddr, tlsConf, quicConf)
584 + listener, err := quic.ListenAddr(s.cfg.SNIListenAddr, tlsConf, quicConf)
585 if err != nil {
586 return fmt.Errorf("listen quic: %w", err)
587 }
portal/server_test.go
+168 -7
@@ -56,7 +56,6 @@ func mustRelayDescriptor(t *testing.T, relayURL string) types.RelayDescriptor {
56 WireGuardPublicKey: wireGuardPublicKey,
57 WireGuardEndpoint: net.JoinHostPort(utils.PortalRootHost(relayURL), "51820"),
58 OverlayIPv4: overlayIPv4,
59 - SupportsTLS: true,
59 SupportsOverlayPeer: true,
60 })
61 if err != nil {
@@ -146,7 +145,9 @@ func TestServerStartInitializesLocalACMEAndSigner(t *testing.T) {
145 ACME: acme.Config{KeyDir: t.TempDir()},
146 APIListenAddr: "127.0.0.1:0",
147 SNIListenAddr: "127.0.0.1:0",
149 - UDPPortCount: 1,
148 + MinPort: 40000,
149 + MaxPort: 40000,
150 + UDPEnabled: true,
151 })
152 if err != nil {
153 t.Fatalf("NewServer() error = %v", err)
@@ -201,6 +202,143 @@ func TestServerStartInitializesLocalACMEAndSigner(t *testing.T) {
202 }
203 }
204
205 +func TestServerStartDomainReportsCompatibilityInfo(t *testing.T) {
206 + t.Parallel()
207 +
208 + server, err := NewServer(ServerConfig{
209 + PortalURL: "https://localhost:4017",
210 + IdentityPath: tempIdentityPath(t),
211 + ACME: acme.Config{KeyDir: t.TempDir()},
212 + SNIPort: 4443,
213 + APIListenAddr: "127.0.0.1:0",
214 + SNIListenAddr: "127.0.0.1:0",
215 + })
216 + if err != nil {
217 + t.Fatalf("NewServer() error = %v", err)
218 + }
219 +
220 + ctx, cancel := context.WithCancel(context.Background())
221 + defer cancel()
222 +
223 + if err := server.Start(ctx, nil); err != nil {
224 + t.Fatalf("Start() error = %v", err)
225 + }
226 +
227 + client := &http.Client{
228 + Transport: &http.Transport{
229 + TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
230 + },
231 + }
232 + t.Cleanup(func() {
233 + client.CloseIdleConnections()
234 + cancel()
235 + if err := server.Wait(); err != nil {
236 + t.Fatalf("Wait() error = %v", err)
237 + }
238 + })
239 +
240 + resp, err := client.Get("https://" + utils.HostPortOrLoopback(server.apiListener.Addr().String()) + types.PathSDKDomain)
241 + if err != nil {
242 + t.Fatalf("GET /sdk/domain error = %v", err)
243 + }
244 + defer resp.Body.Close()
245 +
246 + if resp.StatusCode != http.StatusOK {
247 + t.Fatalf("GET /sdk/domain status = %d, want %d", resp.StatusCode, http.StatusOK)
248 + }
249 +
250 + body, err := io.ReadAll(resp.Body)
251 + if err != nil {
252 + t.Fatalf("read /sdk/domain response: %v", err)
253 + }
254 +
255 + var envelope types.APIEnvelope[types.DomainResponse]
256 + if err := json.Unmarshal(body, &envelope); err != nil {
257 + t.Fatalf("decode /sdk/domain response: %v", err)
258 + }
259 + if !envelope.OK {
260 + t.Fatalf("GET /sdk/domain response = %+v, want ok=true", envelope)
261 + }
262 + if envelope.Data.ProtocolVersion != types.ProtocolVersion {
263 + t.Fatalf("DomainResponse.ProtocolVersion = %q, want %q", envelope.Data.ProtocolVersion, types.ProtocolVersion)
264 + }
265 + if envelope.Data.ReleaseVersion != types.ReleaseVersion {
266 + t.Fatalf("DomainResponse.ReleaseVersion = %q, want %q", envelope.Data.ReleaseVersion, types.ReleaseVersion)
267 + }
268 +}
269 +
270 +func TestRegisterLeaseIncludesSNIPort(t *testing.T) {
271 + t.Parallel()
272 +
273 + server, err := NewServer(ServerConfig{
274 + PortalURL: "https://portal.example.com:4017",
275 + IdentityPath: tempIdentityPath(t),
276 + SNIPort: 4443,
277 + MinPort: 40000,
278 + MaxPort: 40009,
279 + UDPEnabled: true,
280 + })
281 + if err != nil {
282 + t.Fatalf("NewServer() error = %v", err)
283 + }
284 +
285 + resp, err := server.registerLease(types.RegisterChallengeRequest{
286 + Identity: types.Identity{
287 + Name: "demo-sni",
288 + Address: server.identity.Address,
289 + },
290 + UDPEnabled: true,
291 + }, "203.0.113.10", "")
292 + if err != nil {
293 + t.Fatalf("registerLease() error = %v", err)
294 + }
295 + t.Cleanup(func() {
296 + if record, err := server.registry.Find(resp.Identity); err == nil {
297 + record.Close()
298 + }
299 + })
300 +
301 + if resp.SNIPort != 4443 {
302 + t.Fatalf("RegisterResponse.SNIPort = %d, want %d", resp.SNIPort, 4443)
303 + }
304 +}
305 +
306 +func TestRegisterLeaseOmitsSNIPortWithoutUDP(t *testing.T) {
307 + t.Parallel()
308 +
309 + server, err := NewServer(ServerConfig{
310 + PortalURL: "https://portal.example.com:4017",
311 + IdentityPath: tempIdentityPath(t),
312 + SNIPort: 4443,
313 + MinPort: 40000,
314 + MaxPort: 40009,
315 + TCPEnabled: true,
316 + })
317 + if err != nil {
318 + t.Fatalf("NewServer() error = %v", err)
319 + }
320 +
321 + resp, err := server.registerLease(types.RegisterChallengeRequest{
322 + Identity: types.Identity{
323 + Name: "demo-tcp",
324 + Address: server.identity.Address,
325 + },
326 + TCPEnabled: true,
327 + }, "203.0.113.10", "")
328 + if err != nil {
329 + t.Fatalf("registerLease() error = %v", err)
330 + }
331 + t.Cleanup(func() {
332 + if record, err := server.registry.Find(resp.Identity); err == nil {
333 + record.Close()
334 + }
335 + })
336 +
337 + if resp.SNIPort != 0 {
338 + t.Fatalf("RegisterResponse.SNIPort = %d, want 0 without udp", resp.SNIPort)
339 + }
340 +}
341 +
342 func TestServerStartUsesManualCertificateWithoutACMEProvider(t *testing.T) {
343 t.Parallel()
344
@@ -320,7 +458,9 @@ func TestServerStartRejectsMismatchedACMEBaseDomain(t *testing.T) {
458 ACME: acme.Config{BaseDomain: "other.example.com", KeyDir: t.TempDir()},
459 APIListenAddr: "127.0.0.1:0",
460 SNIListenAddr: "127.0.0.1:0",
323 - UDPPortCount: 1,
461 + MinPort: 40000,
462 + MaxPort: 40000,
463 + UDPEnabled: true,
464 })
465 if err != nil {
466 t.Fatalf("NewServer() error = %v", err)
@@ -392,13 +532,32 @@ func TestNewServerIgnoresDiscoveryPortWithoutWireGuardKey(t *testing.T) {
532 }
533 }
534
535 +func TestNewServerRejectsInvalidSharedPortRange(t *testing.T) {
536 + t.Parallel()
537 +
538 + _, err := NewServer(ServerConfig{
539 + PortalURL: "https://portal.example.com",
540 + IdentityPath: tempIdentityPath(t),
541 + MinPort: 40010,
542 + MaxPort: 40000,
543 + })
544 + if err == nil {
545 + t.Fatal("NewServer() error = nil, want invalid range error")
546 + }
547 + if !strings.Contains(err.Error(), "min port must be less than or equal to max port") {
548 + t.Fatalf("NewServer() error = %v, want invalid range error", err)
549 + }
550 +}
551 +
552 func TestRegisterLeaseDerivesFixedHostnameFromName(t *testing.T) {
553 t.Parallel()
554
555 server, err := NewServer(ServerConfig{
556 PortalURL: "https://portal.example.com",
557 IdentityPath: tempIdentityPath(t),
401 - UDPPortCount: 1,
558 + MinPort: 40000,
559 + MaxPort: 40000,
560 + UDPEnabled: true,
561 })
562 if err != nil {
563 t.Fatalf("NewServer() error = %v", err)
@@ -438,7 +597,9 @@ func TestRegisterLeaseBuildsUDPEnabledRuntime(t *testing.T) {
597 server, err := NewServer(ServerConfig{
598 PortalURL: "https://portal.example.com",
599 IdentityPath: tempIdentityPath(t),
441 - UDPPortCount: 10,
600 + MinPort: 40000,
601 + MaxPort: 40009,
602 + UDPEnabled: true,
603 })
604 if err != nil {
605 t.Fatalf("NewServer() error = %v", err)
@@ -471,8 +632,8 @@ func TestRegisterLeaseBuildsUDPEnabledRuntime(t *testing.T) {
632 if record.datagram == nil {
633 t.Fatal("datagram = nil, want datagram runtime")
634 }
474 - if got := record.datagram.UDPPort(); got == 0 {
475 - t.Fatal("UDPPort() = 0, want allocated port")
635 + if got := record.datagram.UDPPort(); got < 40000 || got > 40009 {
636 + t.Fatalf("UDPPort() = %d, want port within %d-%d", got, 40000, 40009)
637 }
638 if resp.UDPAddr == "" {
639 t.Fatal("RegisterResponse.UDPAddr = empty, want public udp address")
portal/transport/datagram_relay.go
+2 -2
@@ -21,7 +21,7 @@ const (
21 defaultFlowCleanupInterval = 30 * time.Second
22 )
23
24 -var ErrPortExhausted = errors.New("no udp ports available")
24 +var ErrPortExhausted = errors.New("no ports available")
25
26 type flowReplyFunc func([]byte) error
27
@@ -36,7 +36,7 @@ type portReservation struct {
36 expiresAt time.Time
37 }
38
39 -// PortAllocator manages a pool of UDP ports for dynamic per-lease allocation.
39 +// PortAllocator manages a pool of ports for dynamic per-lease allocation.
40 type PortAllocator struct {
41 available []int
42 inUse map[int]string
portal/transport/stream_client.go
+1 -1
@@ -133,7 +133,7 @@ func (s *ClientStream) runSession(
133 onReady()
134 }
135 return true, nil
136 - case types.MarkerRawTCPStart:
136 + case types.MarkerRawStart:
137 if err := s.activateRaw(ctx, conn); err != nil {
138 _ = conn.Close()
139 return true, err
portal/transport/stream_relay.go
+1 -1
@@ -70,7 +70,7 @@ func (b *RelayStream) Claim(ctx context.Context) (net.Conn, error) {
70 }
71
72 func (b *RelayStream) ClaimRaw(ctx context.Context) (net.Conn, error) {
73 - return b.claimWithMarker(ctx, types.MarkerRawTCPStart)
73 + return b.claimWithMarker(ctx, types.MarkerRawStart)
74 }
75
76 func (b *RelayStream) claimWithMarker(ctx context.Context, marker byte) (net.Conn, error) {
sdk/api_client.go
+23 -1
@@ -48,6 +48,7 @@ type apiClient struct {
48 accessToken string
49 metadata types.LeaseMetadata
50 resolvedPublicIP string
51 + sniPort int
52 }
53
54 func newApiClient(relayURL string, cfg ListenerConfig) (*apiClient, error) {
@@ -126,8 +127,18 @@ func (a *apiClient) registerLease(ctx context.Context, ttl time.Duration, udpEna
127 return types.RegisterResponse{}, errors.New("relay returned mismatched lease identity")
128 }
129 resp.Identity = registeredIdentity
130 +
131 + sniPort := 0
132 + if udpEnabled {
133 + if resp.SNIPort <= 0 {
134 + return types.RegisterResponse{}, errors.New("relay did not return sni port for udp transport")
135 + }
136 + sniPort = resp.SNIPort
137 + }
138 +
139 a.mu.Lock()
140 a.accessToken = resp.AccessToken
141 + a.sniPort = sniPort
142 a.mu.Unlock()
143 return resp, nil
144 }
@@ -316,7 +327,18 @@ func (a *apiClient) openQUICSession(ctx context.Context, accessToken string) (*q
327 MaxIdleTimeout: 60 * time.Second,
328 }
329
319 - dialAddr := utils.EnsurePort(a.baseURL.Host)
330 + a.mu.RLock()
331 + sniPort := a.sniPort
332 + a.mu.RUnlock()
333 +
334 + if sniPort <= 0 {
335 + return nil, errors.New("sni port is not available")
336 + }
337 + host := strings.TrimSpace(a.baseURL.Hostname())
338 + if host == "" {
339 + host = strings.TrimSpace(a.baseURL.Host)
340 + }
341 + dialAddr := net.JoinHostPort(host, fmt.Sprintf("%d", sniPort))
342 conn, err := quic.DialAddr(ctx, dialAddr, tlsConf, quicConf)
343 if err != nil {
344 return nil, fmt.Errorf("quic dial: %w", err)
sdk/sdk_test.go
+60
@@ -317,6 +317,66 @@ func TestExposeGeneratesAddressWithoutPrivateKey(t *testing.T) {
317 }
318 }
319
320 +func TestAPIClientRegisterLeaseRequiresSNIPortForUDP(t *testing.T) {
321 + privateKey := strings.Repeat("33", 32)
322 + identity, err := utils.ResolveSecp256k1Identity(privateKey)
323 + if err != nil {
324 + t.Fatalf("ResolveSecp256k1Identity() error = %v", err)
325 + }
326 + identity.Name = "demo-udp"
327 +
328 + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
329 + switch r.URL.Path {
330 + case types.PathSDKDomain:
331 + writeSDKTestEnvelope(w, http.StatusOK, types.APIEnvelope[types.DomainResponse]{
332 + OK: true,
333 + Data: types.DomainResponse{
334 + ProtocolVersion: types.ProtocolVersion,
335 + },
336 + })
337 + case types.PathSDKRegisterChallenge:
338 + var challengeReq types.RegisterChallengeRequest
339 + if err := json.NewDecoder(r.Body).Decode(&challengeReq); err != nil {
340 + t.Fatalf("decode register challenge request: %v", err)
341 + }
342 + writeSDKTestEnvelope(w, http.StatusCreated, types.APIEnvelope[types.RegisterChallengeResponse]{
343 + OK: true,
344 + Data: types.RegisterChallengeResponse{
345 + ChallengeID: "challenge-udp",
346 + ExpiresAt: time.Now().Add(time.Minute).UTC(),
347 + SIWEMessage: mustSDKTestSIWEMessage(t, r, challengeReq.Identity.Address, "challenge-udp"),
348 + },
349 + })
350 + case types.PathSDKRegister:
351 + writeSDKTestEnvelope(w, http.StatusCreated, types.APIEnvelope[types.RegisterResponse]{
352 + OK: true,
353 + Data: types.RegisterResponse{
354 + Identity: types.Identity{Name: identity.Name, Address: identity.Address},
355 + Hostname: "127.0.0.1",
356 + AccessToken: "jwt-register-udp",
357 + UDPEnabled: true,
358 + },
359 + })
360 + default:
361 + http.NotFound(w, r)
362 + }
363 + }))
364 + defer server.Close()
365 +
366 + api, err := newApiClient(server.URL, ListenerConfig{Identity: identity})
367 + if err != nil {
368 + t.Fatalf("newApiClient() error = %v", err)
369 + }
370 +
371 + _, err = api.registerLease(context.Background(), 30*time.Second, true, false)
372 + if err == nil {
373 + t.Fatal("registerLease() error = nil, want missing sni port error")
374 + }
375 + if !strings.Contains(err.Error(), "sni port") {
376 + t.Fatalf("registerLease() error = %v, want missing sni port error", err)
377 + }
378 +}
379 +
380 func mustSDKTestSIWEMessage(t *testing.T, r *http.Request, address, challengeID string) string {
381 t.Helper()
382
types/api.go
+1
@@ -81,6 +81,7 @@ type RegisterResponse struct {
81 ExpiresAt time.Time `json:"expires_at"`
82 Hostname string `json:"hostname"`
83 AccessToken string `json:"access_token"`
84 + SNIPort int `json:"sni_port,omitempty"`
85 UDPAddr string `json:"udp_addr,omitempty"`
86 UDPEnabled bool `json:"udp_enabled,omitempty"`
87 TCPAddr string `json:"tcp_addr,omitempty"`
types/identity.go
+1 -3
@@ -92,10 +92,8 @@ type RelayDescriptor struct {
92 OverlayIPv4 string `json:"overlay_ipv4,omitempty"`
93 OverlayCIDRs []string `json:"overlay_cidrs,omitempty"`
94
95 - SupportsTLS bool `json:"supports_tls,omitempty"`
95 SupportsUDP bool `json:"supports_udp,omitempty"`
97 - SupportsTCP bool `json:"supports_tcp,omitempty"` // Deprecated: always true. Use SupportsRawTCP instead.
98 - SupportsRawTCP bool `json:"supports_raw_tcp,omitempty"`
96 + SupportsTCP bool `json:"supports_tcp,omitempty"`
97 SupportsOverlayPeer bool `json:"supports_overlay_peer,omitempty"`
98 }
99
types/types.go
+3 -3
@@ -1,12 +1,12 @@
1 package types
2
3 const (
4 - ReleaseVersion = "v2.1.1"
5 - ProtocolVersion = "4"
4 + ReleaseVersion = "v2.1.2"
5 + ProtocolVersion = "5"
6 PortalRelayRegistryURL = "https://raw.githubusercontent.com/gosuda/portal/main/registry.json"
7
8 HeaderAccessToken = "X-Portal-Access-Token"
9 MarkerKeepalive = byte(0x00)
10 + MarkerRawStart = byte(0x01)
11 MarkerTLSStart = byte(0x02)
11 - MarkerRawTCPStart = byte(0x03)
12 )
utils/cmd.go
+11
@@ -81,6 +81,17 @@ func ParsePortNumber(raw string, fallback int) int {
81 return port
82 }
83
84 +func ParseOptionalPortNumber(raw string, fallback int) int {
85 + raw = strings.TrimSpace(raw)
86 + if raw == "" {
87 + return fallback
88 + }
89 + if raw == "0" {
90 + return 0
91 + }
92 + return ParsePortNumber(raw, fallback)
93 +}
94 +
95 func ParseNonNegativeInt(raw string, fallback int) int {
96 raw = strings.TrimSpace(raw)
97 if raw == "" {