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 == "" {