feat: restore sdk discovery

Kim committed Apr 14, 2026 at 17:28 UTC cc447d8805adff75706f84b2a3236a97bd885614
8 files changed +68 -54
cmd/demo-app/main.go
+4 -10
@@ -129,12 +129,9 @@ func runUDPCommand(args []string) error {
129 }
130
131 func runTCPDemo(ctx context.Context, cfg demoConfig) error {
132 - relayURLs, err := utils.ResolvePortalRelayURLs(ctx, utils.SplitCSV(cfg.relayURLs), cfg.discovery)
133 - if err != nil {
134 - return fmt.Errorf("resolve relay urls: %w", err)
135 - }
132 exposure, err := sdk.Expose(ctx, sdk.ExposeConfig{
137 - RelayURLs: relayURLs,
133 + RelayURLs: utils.SplitCSV(cfg.relayURLs),
134 + Discovery: cfg.discovery,
135 IdentityPath: cfg.identityPath,
136 IdentityJSON: cfg.identityJSON,
137 Name: cfg.name,
@@ -175,12 +172,9 @@ func runTCPDemo(ctx context.Context, cfg demoConfig) error {
172 }
173
174 func runUDPDemo(ctx context.Context, cfg demoConfig) error {
178 - relayURLs, err := utils.ResolvePortalRelayURLs(ctx, utils.SplitCSV(cfg.relayURLs), cfg.discovery)
179 - if err != nil {
180 - return fmt.Errorf("resolve relay urls: %w", err)
181 - }
175 exposure, err := sdk.Expose(ctx, sdk.ExposeConfig{
183 - RelayURLs: relayURLs,
176 + RelayURLs: utils.SplitCSV(cfg.relayURLs),
177 + Discovery: cfg.discovery,
178 IdentityPath: cfg.identityPath,
179 IdentityJSON: cfg.identityJSON,
180 Name: cfg.name,
cmd/portal-tunnel/main.go
+2 -6
@@ -100,13 +100,9 @@ func runExposeCommand(args []string) error {
100 ctx, stop := utils.SignalContext()
101 defer stop()
102
103 - relayURLs, err := utils.ResolvePortalRelayURLs(ctx, utils.SplitCSV(flags.relayCSV), flags.discovery)
104 - if err != nil {
105 - return fmt.Errorf("resolve relay urls: %w", err)
106 - }
107 -
103 exposure, err := sdk.Expose(ctx, sdk.ExposeConfig{
109 - RelayURLs: relayURLs,
104 + RelayURLs: utils.SplitCSV(flags.relayCSV),
105 + Discovery: flags.discovery,
106 IdentityPath: flags.identityPath,
107 IdentityJSON: flags.identityJSON,
108 Name: flags.name,
portal/discovery/refresher.go
+20 -12
@@ -49,7 +49,7 @@ func NewRefresher(relaySet *RelaySet, overlay OverlayRuntime) *Refresher {
49 }
50 }
51
52 -func (r *Refresher) Refresh(ctx context.Context, self types.RelayDescriptor) error {
52 +func (r *Refresher) Refresh(ctx context.Context, self *types.RelayDescriptor) error {
53 if r.overlay != nil {
54 if err := r.refreshOverlay(ctx); err != nil && ctx.Err() == nil {
55 log.Warn().
@@ -63,7 +63,12 @@ func (r *Refresher) Refresh(ctx context.Context, self types.RelayDescriptor) err
63 if err := r.refreshHTTPS(ctx); err != nil {
64 return err
65 }
66 - return r.announceSelf(ctx, self)
66 + if self != nil {
67 + if err := r.announceSelf(ctx, *self); err != nil {
68 + return err
69 + }
70 + }
71 + return nil
72 }
73
74 func (r *Refresher) announceSelf(ctx context.Context, descriptor types.RelayDescriptor) error {
@@ -71,6 +76,18 @@ func (r *Refresher) announceSelf(ctx context.Context, descriptor types.RelayDesc
76 ProtocolVersion: types.DiscoveryVersion,
77 Descriptor: descriptor,
78 }
79 + httpClient := &http.Client{
80 + Transport: &http.Transport{
81 + TLSClientConfig: &tls.Config{
82 + MinVersion: tls.VersionTLS12,
83 + NextProtos: []string{"http/1.1"},
84 + },
85 + ForceAttemptHTTP2: false,
86 + },
87 + Timeout: defaultRequestTimeout,
88 + }
89 + defer httpClient.CloseIdleConnections()
90 +
91 for _, relayURL := range r.relaySet.BootstrapRelayURLs() {
92 if relayURL == descriptor.APIHTTPSAddr {
93 continue
@@ -83,11 +100,8 @@ func (r *Refresher) announceSelf(ctx context.Context, descriptor types.RelayDesc
100 Msg("relay discovery announce target skipped")
101 continue
102 }
86 - if utils.IsLocalRelayHost(baseURL.Hostname()) {
87 - continue
88 - }
103
90 - if err := utils.HTTPDoAPIPath(ctx, r.httpClient, baseURL, http.MethodPost, types.PathDiscoveryAnnounce, req, nil, nil); err != nil {
104 + if err := utils.HTTPDoAPIPath(ctx, httpClient, baseURL, http.MethodPost, types.PathDiscoveryAnnounce, req, nil, nil); err != nil {
105 if ctx.Err() != nil {
106 return ctx.Err()
107 }
@@ -143,12 +157,6 @@ func (r *Refresher) refreshHTTPS(ctx context.Context) error {
157 }
158 continue
159 }
146 - if utils.IsLocalRelayHost(baseURL.Hostname()) {
147 - log.Info().
148 - Str("relay", relayURL).
149 - Msg("skip loopback relay as discovery source")
150 - continue
151 - }
160
161 startedAt := time.Now()
162 var resp types.DiscoveryResponse
portal/server.go
+1 -1
@@ -605,7 +605,7 @@ func (s *Server) runRelayDiscoveryLoop(ctx context.Context) error {
605 if err != nil {
606 return fmt.Errorf("build relay discovery descriptor: %w", err)
607 }
608 - if err := refresher.Refresh(ctx, self); err != nil {
608 + if err := refresher.Refresh(ctx, &self); err != nil {
609 if ctx.Err() != nil {
610 return nil
611 }
sdk/api_client.go
+1 -3
@@ -42,7 +42,6 @@ type apiClient struct {
42 rawTLSConfig *tls.Config
43 dialTimeout time.Duration
44 requestTimeout time.Duration
45 - rootCAPEM []byte
45 identity types.Identity
46 accessToken string
47 expiresAt time.Time
@@ -69,7 +68,6 @@ func newApiClient(relayURL string, cfg ListenerConfig) (*apiClient, error) {
68 baseURL: baseURL,
69 dialTimeout: dialTimeout,
70 requestTimeout: requestTimeout,
72 - rootCAPEM: append([]byte(nil), cfg.RootCAPEM...),
71 identity: cfg.Identity.Copy(),
72 metadata: cfg.Metadata.Copy(),
73 }, nil
@@ -186,7 +184,7 @@ func (a *apiClient) ensureHTTPClient(ctx context.Context) error {
184 bootstrapCtx, cancel := context.WithTimeout(ctx, defaultDialTimeout+defaultHandshakeTimeout)
185 defer cancel()
186
189 - rawTLSConfig, httpClient, err := utils.NewHTTPTLSClient(bootstrapCtx, a.baseURL, a.rootCAPEM, a.requestTimeout)
187 + rawTLSConfig, httpClient, err := utils.NewHTTPTLSClient(bootstrapCtx, a.baseURL, a.requestTimeout)
188 if err != nil {
189 return err
190 }
sdk/expose.go
+35 -11
@@ -20,12 +20,11 @@ import (
20 )
21
22 // Exposure owns the lifecycle of one or more relay listeners and accepts
23 -// traffic from all of them through one net.Listener. The SDK is a pure
24 -// relay client: it uses only the explicit relay URLs passed in by the
25 -// caller, never gossips its own descriptor into the discovery mesh, and
26 -// never serves the /discovery endpoint. Relay bootstrap expansion (e.g.
27 -// loading a public registry) is the caller's responsibility — resolve the
28 -// final URL list before invoking Expose.
23 +// traffic from all of them through one net.Listener. The SDK is a pure relay
24 +// client: it never gossips its own descriptor into the discovery mesh and
25 +// never serves the /discovery endpoint. When SDK discovery is enabled, the
26 +// exposure resolves registry seed relays and consumes discovery results from
27 +// attached relays.
28 type Exposure struct {
29 cancel context.CancelFunc
30 done <-chan struct{}
@@ -39,7 +38,6 @@ type Exposure struct {
38 banMITM bool
39 maxActiveRelays int
40 metadata types.LeaseMetadata
42 - rootCAPEM []byte
41
42 accepted chan net.Conn
43 datagrams chan types.DatagramFrame
@@ -54,6 +52,7 @@ type Exposure struct {
52
53 type ExposeConfig struct {
54 RelayURLs []string
55 + Discovery bool
56 IdentityPath string
57 IdentityJSON string
58 Name string
@@ -64,7 +63,6 @@ type ExposeConfig struct {
63 BanMITM bool
64 MaxActiveRelays int
65 Metadata types.LeaseMetadata
67 - RootCAPEM []byte
66 }
67
68 // Expose creates relay listeners for the selected relay pool and exposes a
@@ -74,7 +72,10 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
72 if err != nil {
73 return nil, err
74 }
77 - relayURLs := explicitRelayURLs
75 + relayURLs, err := utils.ResolvePortalRelayURLs(ctx, explicitRelayURLs, cfg.Discovery)
76 + if err != nil {
77 + return nil, err
78 + }
79
80 identity, createdIdentity, err := utils.ResolveListenerIdentity(
81 types.Identity{Name: cfg.Name},
@@ -115,7 +116,6 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
116 banMITM: cfg.BanMITM,
117 maxActiveRelays: cfg.MaxActiveRelays,
118 metadata: cfg.Metadata.Copy(),
118 - rootCAPEM: append([]byte(nil), cfg.RootCAPEM...),
119 accepted: make(chan net.Conn, max(len(relayURLs)*defaultReadyTarget*2, 1)),
120 datagrams: make(chan types.DatagramFrame, max(len(relayURLs)*32, 1)),
121 relaySet: discovery.NewRelaySet(relayURLs),
@@ -129,6 +129,10 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
129 }
130 }
131
132 + if cfg.Discovery {
133 + go exposure.runDiscoveryLoop(exposureCtx)
134 + }
135 +
136 go func() {
137 <-exposure.done
138 _ = exposure.Close()
@@ -339,6 +343,27 @@ func (e *Exposure) Close() error {
343 return closeErr
344 }
345
346 +func (e *Exposure) runDiscoveryLoop(ctx context.Context) {
347 + refresher := discovery.NewRefresher(e.relaySet, nil)
348 + ticker := time.NewTicker(discovery.DiscoveryPollInterval)
349 + defer ticker.Stop()
350 +
351 + for {
352 + if err := refresher.Refresh(ctx, nil); err != nil {
353 + return
354 + }
355 + if err := e.reconcileRelayListeners(false); err != nil {
356 + return
357 + }
358 +
359 + select {
360 + case <-ctx.Done():
361 + return
362 + case <-ticker.C:
363 + }
364 + }
365 +}
366 +
367 func (e *Exposure) reconcileRelayListeners(failOnError bool) error {
368 clientState := discovery.ClientState{
369 ActiveRelayURLs: e.ActiveRelayURLs(),
@@ -395,7 +420,6 @@ func (e *Exposure) reconcileRelayListeners(failOnError bool) error {
420 BanMITM: e.banMITM,
421 RetryCount: retryCount,
422 Metadata: e.metadata.Copy(),
398 - RootCAPEM: append([]byte(nil), e.rootCAPEM...),
423 relaySet: e.relaySet,
424 })
425 if err != nil {
sdk/listener.go
-1
@@ -26,7 +26,6 @@ type ListenerConfig struct {
26 TCPEnabled bool
27 BanMITM bool
28 Metadata types.LeaseMetadata
29 - RootCAPEM []byte
29 DialTimeout time.Duration
30 RequestTimeout time.Duration
31 HandshakeTimeout time.Duration
utils/tls.go
+5 -10
@@ -17,7 +17,7 @@ import (
17 "time"
18 )
19
20 -func NewHTTPTLSClient(ctx context.Context, relayURL *url.URL, rootCAPEM []byte, timeout time.Duration) (*tls.Config, *http.Client, error) {
20 +func NewHTTPTLSClient(ctx context.Context, relayURL *url.URL, timeout time.Duration) (*tls.Config, *http.Client, error) {
21 if relayURL == nil {
22 return nil, nil, errors.New("relay url is required")
23 }
@@ -27,19 +27,14 @@ func NewHTTPTLSClient(ctx context.Context, relayURL *url.URL, rootCAPEM []byte,
27 return nil, nil, errors.New("relay hostname is required")
28 }
29
30 - resolvedRootCAPEM := append([]byte(nil), rootCAPEM...)
31 - if len(resolvedRootCAPEM) == 0 && IsLocalRelayHost(serverName) {
32 - fetchedRootCAPEM, err := FetchEndpointCertificateChain(ctx, relayURL.String(), serverName)
30 + var rootCAs *x509.CertPool
31 + if IsLocalRelayHost(serverName) {
32 + rootCAPEM, err := FetchEndpointCertificateChain(ctx, relayURL.String(), serverName)
33 if err != nil {
34 return nil, nil, fmt.Errorf("bootstrap localhost relay trust: %w", err)
35 }
36 - resolvedRootCAPEM = fetchedRootCAPEM
37 - }
38 -
39 - var rootCAs *x509.CertPool
40 - if len(resolvedRootCAPEM) > 0 {
36 rootCAs = x509.NewCertPool()
42 - if !rootCAs.AppendCertsFromPEM(resolvedRootCAPEM) {
37 + if !rootCAs.AppendCertsFromPEM(rootCAPEM) {
38 return nil, nil, errors.New("failed to parse relay root ca")
39 }
40 }