fix bootstrap logics

Kim committed Mar 24, 2026 at 16:01 UTC 1367b4ba46efe638805d2988a6de8a1e4dc1eec7
14 files changed +167 -129
.env.example
+3 -1
@@ -1,5 +1,7 @@
1 -# Public routing
1 +# Public routing and discovery
2 PORTAL_URL=https://localhost:4017
3 +BOOTSTRAPS=https://localhost:4017
4 +DISCOVERY=true
5
6 # Listener ports
7 API_PORT=4017
README.md
+16 -4
@@ -46,10 +46,22 @@ For deployment to a public domain, see [docs/deployment.md](docs/deployment.md).
46
47 ### Expose Local Service via Tunnel
48
49 -1. Run your local service.
50 -2. Open the Portal relay site.
51 -3. Click `Add your server` button.
52 -4. Use the generated command to connect your local service.
49 +For a local relay started with `docker compose up`:
50 +
51 +```bash
52 +curl -ksSL https://localhost:4017/install.sh | bash
53 +portal expose 3000 --relays https://localhost:4017
54 +```
55 +
56 +```powershell
57 +$ProgressPreference = 'SilentlyContinue'
58 +irm https://localhost:4017/install.ps1 | iex
59 +portal expose 3000 --relays https://localhost:4017
60 +```
61 +
62 +Replace `https://localhost:4017` with your relay URL when using a hosted relay.
63 +The relay landing page also generates the exact install command for the current relay.
64 +For CLI usage and install details, see [cmd/portal-tunnel/README.md](cmd/portal-tunnel/README.md).
65
66 ### Use the Go SDK (Advanced)
67
cmd/demo-app/main.go
+21 -21
@@ -31,23 +31,23 @@ func main() {
31 }
32
33 type demoConfig struct {
34 - relayURLs string
35 - defaultRelays bool
36 - addr string
37 - name string
38 - desc string
39 - tags string
40 - owner string
41 - hide bool
42 - thumbnail string
34 + relayURLs string
35 + discovery bool
36 + addr string
37 + name string
38 + desc string
39 + tags string
40 + owner string
41 + hide bool
42 + thumbnail string
43 }
44
45 func runTCPCommand(args []string) error {
46 cfg := demoConfig{}
47
48 fs := utils.NewFlagSet("demo-app", printTCPUsage)
49 - utils.StringFlagEnv(fs, &cfg.relayURLs, "relays", "https://localhost:4017", "additional relay API URLs (comma-separated; scheme omitted defaults to https; merged with public registry relays unless --default-relays=false is set)", "RELAYS")
50 - utils.BoolFlagEnv(fs, &cfg.defaultRelays, "default-relays", true, "include public registry relays", "DEFAULT_RELAYS")
49 + utils.StringFlagEnv(fs, &cfg.relayURLs, "relays", "https://gosunuts.xyz", "additional relay API URLs (comma-separated; scheme omitted defaults to https; merged with public registry relays when discovery is enabled)", "RELAYS")
50 + utils.BoolFlagEnv(fs, &cfg.discovery, "discovery", true, "include public registry relays and enable discovery", "DISCOVERY")
51 utils.StringFlag(fs, &cfg.addr, "addr", "127.0.0.1:8092", "local demo HTTP listen address (host:port or URL; disable if empty)")
52 utils.StringFlag(fs, &cfg.name, "name", "demo-app", "public hostname prefix (single DNS label)")
53 utils.StringFlag(fs, &cfg.desc, "description", "Portal demo connectivity app", "lease description")
@@ -77,8 +77,8 @@ func runUDPCommand(args []string) error {
77 cfg := demoConfig{}
78 fs := utils.NewFlagSet("demo-app-udp", printUDPUsage)
79
80 - utils.StringFlagEnv(fs, &cfg.relayURLs, "relays", "https://localhost:4017", "additional relay API URLs (comma-separated; scheme omitted defaults to https; merged with public registry relays unless --default-relays=false is set)", "RELAYS")
81 - utils.BoolFlagEnv(fs, &cfg.defaultRelays, "default-relays", false, "include public registry relays", "DEFAULT_RELAYS")
80 + utils.StringFlagEnv(fs, &cfg.relayURLs, "relays", "https://localhost:4017", "additional relay API URLs (comma-separated; scheme omitted defaults to https; merged with public registry relays when discovery is enabled)", "RELAYS")
81 + utils.BoolFlagEnv(fs, &cfg.discovery, "discovery", true, "include public registry relays and enable discovery", "DISCOVERY")
82 utils.StringFlag(fs, &cfg.name, "name", "demo-udp", "public hostname prefix (single DNS label)")
83 utils.StringFlag(fs, &cfg.desc, "description", "Portal demo UDP echo service", "lease description")
84 utils.StringFlag(fs, &cfg.tags, "tags", "demo,udp,echo", "comma-separated lease tags")
@@ -131,9 +131,9 @@ func runHelpCommand(args []string) error {
131
132 func runTCPDemo(ctx context.Context, cfg demoConfig) error {
133 exposure, err := sdk.Expose(ctx, sdk.ExposeConfig{
134 - RelayURLs: utils.SplitCSV(cfg.relayURLs),
135 - DefaultRelayEnabled: cfg.defaultRelays,
136 - Name: cfg.name,
134 + RelayURLs: utils.SplitCSV(cfg.relayURLs),
135 + Name: cfg.name,
136 + Discovery: cfg.discovery,
137 Metadata: types.LeaseMetadata{
138 Description: cfg.desc,
139 Tags: utils.SplitCSV(cfg.tags),
@@ -170,10 +170,10 @@ func runTCPDemo(ctx context.Context, cfg demoConfig) error {
170
171 func runUDPDemo(ctx context.Context, cfg demoConfig) error {
172 exposure, err := sdk.Expose(ctx, sdk.ExposeConfig{
173 - RelayURLs: utils.SplitCSV(cfg.relayURLs),
174 - DefaultRelayEnabled: cfg.defaultRelays,
175 - Name: cfg.name,
176 - UDPEnabled: true,
173 + RelayURLs: utils.SplitCSV(cfg.relayURLs),
174 + Name: cfg.name,
175 + UDPEnabled: true,
176 + Discovery: cfg.discovery,
177 Metadata: types.LeaseMetadata{
178 Description: cfg.desc,
179 Tags: utils.SplitCSV(cfg.tags),
@@ -273,7 +273,7 @@ func printUDPUsage(w io.Writer) {
273 []string{
274 "demo-app udp",
275 "demo-app udp --name my-udp-demo",
276 - "demo-app udp --default-relays=true",
276 + "demo-app udp --discovery=true",
277 },
278 )
279 }
cmd/portal-tunnel/README.md
+5 -5
@@ -36,13 +36,13 @@ portal expose localhost:8080 \
36 - Bare ports resolve to `127.0.0.1:<port>`.
37 - `--name` is optional. When omitted, the CLI generates a name for that run.
38 - `--relays` sets the relay API URLs for that run.
39 -- `--default-relays=false` disables the public registry list for that run.
39 +- `--discovery=false` disables the public registry seed list and the discovery expansion loop for that run.
40
41 Flags:
42
43 ```text
44 --relays Portal relay API URLs (comma-separated, https only)
45 ---default-relays Include public registry relays
45 +--discovery Include public registry relays and discover additional relay bootstraps
46 --name Public hostname prefix (single DNS label); auto-generated when omitted
47 --description Service description metadata
48 --tags Service tags metadata (comma-separated)
@@ -54,7 +54,7 @@ Flags:
54 ### `portal list [flags]`
55
56 - Prints the relay URLs that the CLI will use for the current invocation.
57 -- `--relays` and `--default-relays=false` follow the same semantics as `portal expose`.
57 +- `--relays` adds explicit relay URLs, and `--default-relays=false` disables the public registry list for the current listing run.
58
59 Legacy execution compatibility has been removed:
60
@@ -67,7 +67,7 @@ Legacy execution compatibility has been removed:
67 - `install.sh` installs the downloaded binary as `portal`.
68 - `install.ps1` installs `portal.exe` for the current Windows user and updates the user `PATH`.
69 - The installer does not write a config file.
70 -- `portal expose 3000` still works after install because default relays are enabled.
70 +- `portal expose 3000` still works after install because discovery is enabled by default.
71 - Use `--relays https://portal.example.com` only when you want to target a specific relay explicitly.
72
73 ## Notes
@@ -77,7 +77,7 @@ Legacy execution compatibility has been removed:
77 - The tunnel consumes one aggregate SDK listener, so the CLI no longer manages per-relay listener loops itself.
78 - Relay startup and reconnect failures are retried independently in the background. A relay that is down does not stop healthy relays from continuing to serve traffic.
79 - The tunnel starts once relay URLs pass local validation. Remote compatibility checks, lease registration, and reconnects continue in the background until each relay becomes ready.
80 -- The configured relay list is either `public registry + --relays values` or, with `--default-relays=false`, just the explicit relay URLs. Published public URLs appear only for relays that have registered successfully.
80 +- With discovery enabled, the configured relay list starts with `public registry + --relays values` and can expand through relay discovery. With `--discovery=false`, only the explicit relay URLs are used. Published public URLs appear only for relays that have registered successfully.
81 - SDK callers that do not set `ListenerConfig.RetryCount` use infinite retry semantics for each relay.
82 - Tenant TLS is provisioned automatically through the relay keyless signer. The SDK fetches the relay certificate chain and uses `/v1/sign` for remote signing.
83 - When the local service is unreachable, the tunnel returns an HTTP 503 page.
cmd/portal-tunnel/main.go
+21 -24
@@ -32,19 +32,18 @@ func main() {
32 }
33
34 type exposeFlags struct {
35 - relayCSV string
36 - defaultRelays bool
37 - discoveryEnabled bool
38 - privateKey string
39 - name string
40 - desc string
41 - tags string
42 - owner string
43 - thumbnail string
44 - hide bool
45 - targetAddr string
46 - udp bool
47 - udpAddr string
35 + relayCSV string
36 + discovery bool
37 + privateKey string
38 + name string
39 + desc string
40 + tags string
41 + owner string
42 + thumbnail string
43 + hide bool
44 + targetAddr string
45 + udp bool
46 + udpAddr string
47 }
48
49 func runExposeCommand(args []string) error {
@@ -52,8 +51,7 @@ func runExposeCommand(args []string) error {
51 fs := utils.NewFlagSet("expose", printExposeUsage)
52
53 utils.StringFlag(fs, &flags.relayCSV, "relays", "", "Additional Portal relay server API URLs (comma-separated; scheme omitted defaults to https)")
55 - utils.BoolFlag(fs, &flags.defaultRelays, "default-relays", true, "Include public registry relays")
56 - utils.BoolFlag(fs, &flags.discoveryEnabled, "discovery", false, "Advertise known relay URLs and discover additional relay bootstraps")
54 + utils.BoolFlag(fs, &flags.discovery, "discovery", true, "Include public registry relays and discover additional relay bootstraps")
55 utils.StringFlag(fs, &flags.privateKey, "private-key", "", "Owner private key used to derive a discovery address")
56 utils.StringFlag(fs, &flags.name, "name", "", "Public hostname prefix (single DNS label); auto-generated when omitted")
57 utils.StringFlag(fs, &flags.desc, "description", "", "Service description metadata")
@@ -92,13 +90,12 @@ func runExposeCommand(args []string) error {
90 defer stop()
91
92 exposure, err := sdk.Expose(ctx, sdk.ExposeConfig{
95 - RelayURLs: utils.SplitCSV(flags.relayCSV),
96 - DefaultRelayEnabled: flags.defaultRelays,
97 - Name: flags.name,
98 - TargetAddr: flags.targetAddr,
99 - UDPAddr: flags.udpAddr,
100 - UDPEnabled: flags.udp,
101 - Discovery: flags.discoveryEnabled,
93 + RelayURLs: utils.SplitCSV(flags.relayCSV),
94 + Name: flags.name,
95 + TargetAddr: flags.targetAddr,
96 + UDPAddr: flags.udpAddr,
97 + UDPEnabled: flags.udp,
98 + Discovery: flags.discovery,
99 Metadata: types.LeaseMetadata{
100 Description: flags.desc,
101 Tags: utils.SplitCSV(flags.tags),
@@ -142,7 +139,7 @@ func runListCommand(args []string) error {
139
140 relayInputs := utils.SplitCSV(flags.relayCSV)
141
145 - relayURLs, err := sdk.ResolveRelayURLs(ctx, relayInputs, flags.defaultRelays)
142 + relayURLs, err := utils.ResolvePortalRelayURLs(ctx, relayInputs, flags.defaultRelays)
143 if err != nil {
144 return fmt.Errorf("resolve relay urls: %w", err)
145 }
@@ -255,7 +252,7 @@ func printExposeUsage(w io.Writer) {
252 "portal expose 3000",
253 "portal expose localhost:8080 --name my-app",
254 "portal expose 3000 --udp --udp-addr 127.0.0.1:5353",
258 - "portal expose 3000 --relays https://portal.example.com --default-relays=false",
255 + "portal expose 3000 --relays https://portal.example.com --discovery=false",
256 },
257 )
258 }
cmd/portal-tunnel/relays.go
+2 -2
@@ -79,7 +79,7 @@ func proxyExposure(ctx context.Context, exposure *sdk.Exposure, serviceName stri
79 log.Error().Err(udpErr).Msg("udp proxy exited with error")
80 }
81 if closeErr != nil {
82 - log.Error().Err(closeErr).Msg("relay shutdown failed")
82 + log.Warn().Err(closeErr).Msg("relay shutdown completed with cleanup errors")
83 }
84
85 if ctx.Err() != nil {
@@ -126,7 +126,7 @@ func proxyRelayConnections(ctx context.Context, exposure *sdk.Exposure, localAdd
126 go func(connID int64, relayConn net.Conn) {
127 defer connWG.Done()
128 if err := proxyConnection(ctx, localAddr, relayConn); err != nil {
129 - log.Error().Err(err).Int64("conn_id", connID).Msg("proxy connection failed")
129 + log.Debug().Err(err).Int64("conn_id", connID).Msg("proxy connection closed with an I/O error")
130 }
131 log.Info().Int64("conn_id", connID).Msg("proxy connection closed")
132 }(connID, relayConn)
cmd/relay-server/main.go
+7 -2
@@ -63,7 +63,7 @@ func runServeCommand(args []string) error {
63 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")
64 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")
65 utils.StringFlagEnv(fs, &cfg.Bootstraps, "bootstraps", "", "additional bootstrap relay API URLs used for discovery expansion", "BOOTSTRAPS")
66 - utils.BoolFlagEnv(fs, &cfg.DiscoveryEnabled, "discovery", false, "serve relay discovery endpoints and poll discovery peers", "DISCOVERY_ENABLED")
66 + utils.BoolFlagEnv(fs, &cfg.DiscoveryEnabled, "discovery", false, "serve relay discovery endpoints and poll discovery peers", "DISCOVERY")
67 utils.StringFlagEnv(fs, &cfg.OwnerPrivateKey, "owner-private-key", "", "relay owner private key used to derive a discovery address", "OWNER_PRIVATE_KEY")
68 utils.StringFlagEnv(fs, &cfg.AdminSecretKey, "admin-secret-key", "", "admin auth secret", "ADMIN_SECRET_KEY")
69 utils.BoolFlagEnv(fs, &cfg.TrustProxyHeaders, "trust-proxy-headers", false, "trust X-Forwarded-* and X-Real-IP headers from trusted proxies", "TRUST_PROXY_HEADERS")
@@ -106,10 +106,15 @@ func runServeCommand(args []string) error {
106 }
107
108 func runServer(ctx context.Context, cfg relayServerConfig) error {
109 + bootstraps, err := utils.ResolvePortalRelayURLs(ctx, utils.SplitCSV(cfg.Bootstraps), cfg.DiscoveryEnabled)
110 + if err != nil {
111 + return fmt.Errorf("resolve discovery bootstraps: %w", err)
112 + }
113 +
114 server, err := portal.NewServer(portal.ServerConfig{
115 PortalURL: cfg.PortalURL,
116 OwnerPrivateKey: cfg.OwnerPrivateKey,
112 - Bootstraps: []string{cfg.Bootstraps},
117 + Bootstraps: bootstraps,
118 ACME: acme.Config{
119 KeyDir: cfg.KeylessDir,
120 DNSProvider: cfg.ACMEDNSProvider,
docker-compose.yml
+3 -1
@@ -12,8 +12,10 @@ services:
12 # - "${SNI_PORT:-443}:${SNI_PORT:-443}/udp"
13 # - "50000-50009:50000-50009/udp" # adjust range to match UDP_PORT_COUNT
14 environment:
15 - # Public routing
15 + # Public routing and discovery
16 PORTAL_URL: ${PORTAL_URL:-https://localhost:${API_PORT:-4017}}
17 + BOOTSTRAPS: ${BOOTSTRAPS:-}
18 + DISCOVERY: ${DISCOVERY:-true}
19
20 # Listener ports (published to the host below)
21 API_PORT: ${API_PORT:-4017}
docs/architecture.md
+2 -2
@@ -115,13 +115,13 @@ That distinction matters because `/sdk/connect` stops being ordinary HTTP once h
115
116 ### SDK (`sdk/`)
117
118 -- `ExposeConfig.DefaultRelayEnabled`: when true, `Expose` fetches the default Portal relay registry, merges it with explicit relay inputs, and normalizes the result
118 +- `ExposeConfig.Discovery`: when true, `Expose` fetches the default Portal relay registry, merges it with explicit relay inputs, normalizes the result, and runs the relay discovery loop
119 - Entry points can opt out of registry defaults and call `utils.NormalizeRelayURLs` directly when they need explicit relay inputs only
120 - `Listener`: validates one relay URL locally, then starts relay compatibility checks, lease registration, reverse session maintenance, and lease renewal in the background until ready
121 - `api_client.go`: internal relay client for control-plane requests, reverse session dialing, and internal QUIC tunnel setup
122 - `ListenerConfig.RetryCount <= 0` means retry forever; positive values close the listener after the retry budget is exhausted
123 - `NewListener` callers provide explicit normalized relay URLs
124 -- Default exposure flow is `Expose{DefaultRelayEnabled: true} -> PublicURLs -> http.Server.Serve(exposure)`, with an opt-out path for explicit relay inputs only
124 +- Default exposure flow is `Expose{Discovery: true} -> PublicURLs -> http.Server.Serve(exposure)`, with an opt-out path for explicit relay inputs only
125 - `expose.go`: optional `RunHTTP` helper for serving one handler on both a local HTTP port and the relay listener
126 - `Expose` keeps one listener per configured relay URL. Relay startup and reconnect failures are retried independently per relay, and successful relays remain available while failed relays keep retrying in the background
127 - `Exposure.RelayURLs()` returns the configured normalized relay URLs, while `Exposure.PublicURLs()` returns only relays that are currently registered and ready
docs/deployment.md
+2
@@ -178,6 +178,8 @@ Navigate to `/admin`, toggle UDP transport to "Enabled", and optionally set a ma
178
179 ```bash
180 PORTAL_URL=https://example.com
181 +BOOTSTRAPS=
182 +DISCOVERY=true
183 SNI_PORT=443
184 ADMIN_SECRET_KEY=your-admin-secret
185 KEYLESS_DIR=./.portal-certs
portal/transport/datagram_client.go
+4 -4
@@ -51,11 +51,11 @@ func (d *ClientDatagram) RunLoop(
51
52 conn, err := open(ctx, state)
53 if err != nil {
54 - log.Warn().
54 + log.Info().
55 Err(err).
56 Str("component", "sdk-datagram-plane").
57 Str("lease_id", state.LeaseID).
58 - Msg("quic session open failed, retrying")
58 + Msg("quic datagram plane unavailable; retrying")
59 if !utils.SleepOrDone(ctx, 2*time.Second) {
60 d.session.Stop("listener context closed")
61 return
@@ -74,11 +74,11 @@ func (d *ClientDatagram) RunLoop(
74 if ctx.Err() != nil {
75 return
76 }
77 - log.Warn().
77 + log.Info().
78 Err(err).
79 Str("component", "sdk-datagram-plane").
80 Str("lease_id", state.LeaseID).
81 - Msg("quic session bind failed")
81 + Msg("quic datagram plane did not bind cleanly; retrying")
82 if !utils.SleepOrDone(ctx, time.Second) {
83 return
84 }
sdk/expose.go
+27 -61
@@ -2,7 +2,6 @@ package sdk
2
3 import (
4 "context"
5 - "encoding/json"
5 "errors"
6 "fmt"
7 "net"
@@ -49,23 +48,22 @@ type Exposure struct {
48 }
49
50 type ExposeConfig struct {
52 - RelayURLs []string
53 - DefaultRelayEnabled bool
54 - Name string
55 - TargetAddr string
56 - UDPAddr string
57 - ReverseToken string
58 - UDPEnabled bool
59 - Discovery bool
60 - Metadata types.LeaseMetadata
61 - OwnerPrivateKey string
62 - RootCAPEM []byte
51 + RelayURLs []string
52 + Name string
53 + TargetAddr string
54 + UDPAddr string
55 + ReverseToken string
56 + UDPEnabled bool
57 + Discovery bool
58 + Metadata types.LeaseMetadata
59 + OwnerPrivateKey string
60 + RootCAPEM []byte
61 }
62
63 // Expose creates relay listeners for each normalized relay URL and exposes a
64 // dynamic listener hub for accepting traffic from all of them.
65 func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
68 - relayURLs, err := ResolveRelayURLs(ctx, cfg.RelayURLs, cfg.DefaultRelayEnabled)
66 + relayURLs, err := utils.ResolvePortalRelayURLs(ctx, cfg.RelayURLs, cfg.Discovery)
67 if err != nil {
68 return nil, err
69 }
@@ -134,50 +132,6 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
132 return exposure, nil
133 }
134
137 -const defaultDiscoveryInterval = 30 * time.Second
138 -
139 -func ResolveRelayURLs(ctx context.Context, explicit []string, includeDefaults bool) ([]string, error) {
140 - explicit, err := utils.NormalizeRelayURLs(explicit...)
141 - if err != nil {
142 - return nil, err
143 - }
144 - if !includeDefaults {
145 - return explicit, nil
146 - }
147 -
148 - req, err := http.NewRequestWithContext(ctx, http.MethodGet, types.PortalRelayRegistryURL, nil)
149 - if err != nil {
150 - return explicit, nil
151 - }
152 -
153 - client := &http.Client{Timeout: defaultRequestTimeout}
154 - resp, err := client.Do(req)
155 - if err != nil {
156 - return explicit, nil
157 - }
158 - defer resp.Body.Close()
159 -
160 - if resp.StatusCode != http.StatusOK {
161 - return explicit, nil
162 - }
163 -
164 - var registry struct {
165 - Relays []string `json:"relays"`
166 - }
167 - if err := json.NewDecoder(resp.Body).Decode(&registry); err != nil {
168 - return explicit, nil
169 - }
170 -
171 - defaults, err := utils.NormalizeRelayURLs(registry.Relays...)
172 - if err != nil {
173 - return explicit, nil
174 - }
175 - if len(defaults) == 0 {
176 - return explicit, nil
177 - }
178 - return utils.MergeRelayURLs(defaults, nil, explicit)
179 -}
180 -
135 func (e *Exposure) KnownRelayURLs() []string {
136 e.mu.RLock()
137 defer e.mu.RUnlock()
@@ -801,9 +755,12 @@ func (e *Exposure) monitorStartupCounts() {
755 }
756 }
757
758 +const defaultDiscoveryInterval = 30 * time.Second
759 +
760 func (e *Exposure) runDiscoveryLoop(ctx context.Context) {
761 ticker := time.NewTicker(defaultDiscoveryInterval)
762 defer ticker.Stop()
763 + discoveryFailed := false
764
765 for {
766 peers := e.KnownRelayURLs()
@@ -811,6 +768,12 @@ func (e *Exposure) runDiscoveryLoop(ctx context.Context) {
768 relayURLs, err := discovery.DiscoverBootstraps(ctx, peers, types.DiscoverRequest{}, e.rootCAPEM)
769 switch {
770 case err == nil:
771 + if discoveryFailed {
772 + log.Info().
773 + Int("peer_count", len(peers)).
774 + Msg("relay discovery recovered")
775 + }
776 + discoveryFailed = false
777 added, err := e.applyRelayURLs(relayURLs, false)
778 if err != nil {
779 log.Warn().
@@ -828,10 +791,13 @@ func (e *Exposure) runDiscoveryLoop(ctx context.Context) {
791 case ctx.Err() != nil:
792 return
793 default:
831 - log.Warn().
832 - Err(err).
833 - Int("relay_count", len(peers)).
834 - Msg("discover relay urls failed")
794 + if !discoveryFailed {
795 + log.Debug().
796 + Err(err).
797 + Int("relay_count", len(peers)).
798 + Msg("discover relay urls failed")
799 + }
800 + discoveryFailed = true
801 }
802 }
803
sdk/listener.go
+2 -2
@@ -113,11 +113,11 @@ func NewListener(ctx context.Context, relayURL string, cfg ListenerConfig) (*Lis
113 l.stream = transport.NewClientStream(readyTarget, handshakeTimeout)
114 if cfg.UDPEnabled {
115 l.datagram = transport.NewClientDatagram(func(err error) {
116 - log.Warn().
116 + log.Info().
117 Err(err).
118 Str("component", "sdk-datagram-plane").
119 Str("lease_id", l.LeaseID()).
120 - Msg("quic receive loop ended")
120 + Msg("quic datagram plane disconnected; waiting to reconnect")
121 })
122 }
123
utils/registry.go new
+52
@@ -0,0 +1,52 @@
1 +package utils
2 +
3 +import (
4 + "context"
5 + "encoding/json"
6 + "net/http"
7 + "time"
8 +
9 + "github.com/gosuda/portal/v2/types"
10 +)
11 +
12 +func ResolvePortalRelayURLs(ctx context.Context, explicit []string, includeDefaults bool) ([]string, error) {
13 + explicit, err := NormalizeRelayURLs(explicit...)
14 + if err != nil {
15 + return nil, err
16 + }
17 + if !includeDefaults {
18 + return explicit, nil
19 + }
20 +
21 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, types.PortalRelayRegistryURL, nil)
22 + if err != nil {
23 + return explicit, nil
24 + }
25 +
26 + client := &http.Client{Timeout: 5 * time.Second}
27 + resp, err := client.Do(req)
28 + if err != nil {
29 + return explicit, nil
30 + }
31 + defer resp.Body.Close()
32 +
33 + if resp.StatusCode != http.StatusOK {
34 + return explicit, nil
35 + }
36 +
37 + var registry struct {
38 + Relays []string `json:"relays"`
39 + }
40 + if err := json.NewDecoder(resp.Body).Decode(&registry); err != nil {
41 + return explicit, nil
42 + }
43 +
44 + defaults, err := NormalizeRelayURLs(registry.Relays...)
45 + if err != nil {
46 + return explicit, nil
47 + }
48 + if len(defaults) == 0 {
49 + return explicit, nil
50 + }
51 + return MergeRelayURLs(defaults, nil, explicit)
52 +}