feat: enhance relay management with explicit relay handling and drop logic
Kim committed
May 19, 2026 at 22:35 UTC
96a1241aa862bf44eae9ebed8bb2ae7668f5d9bf
7 files changed
+195
-28
cmd/portal-tunnel/agent/dashboard.go
+70
-7
@@ -974,11 +974,12 @@ func (m agentDashboardModel) openRelayTunnelURL(tunnelID, relayURL string) (tea.
974
m.selectRelay(relayURL)
975
}
976
_, relay, ok := m.selectedTunnelRelay()
977
- if !ok || strings.TrimSpace(relay.PublicURL) == "" {
977
+ publicURL := relayDashboardPublicURL(relay.PublicURL)
978
+ if !ok || publicURL == "" {
979
return m, nil
980
}
981
return m, agentDashboardRun(func(context.Context) error {
981
- return openDashboardURL(relay.PublicURL)
982
+ return openDashboardURL(publicURL)
983
})
984
}
985
@@ -1688,10 +1689,49 @@ func relayDashboardVersion(relay types.AgentRelayStatus) string {
1689
}
1690
1691
func relayDashboardURL(relay types.AgentRelayStatus) string {
1691
- if publicURL := strings.TrimSpace(relay.PublicURL); publicURL != "" {
1692
+ if publicURL := relayDashboardPublicURL(relay.PublicURL); publicURL != "" {
1693
return publicURL
1694
}
1694
- return relay.RelayURL
1695
+ return relayDashboardRelayLabel(relay.RelayURL)
1696
+}
1697
+
1698
+func relayDashboardPublicURL(rawURL string) string {
1699
+ rawURL = strings.TrimSpace(rawURL)
1700
+ if rawURL == "" {
1701
+ return ""
1702
+ }
1703
+ parsed, err := url.Parse(rawURL)
1704
+ if err != nil || parsed.Scheme == "" || parsed.Host == "" {
1705
+ return rawURL
1706
+ }
1707
+ host := parsed.Hostname()
1708
+ if host == "" {
1709
+ host = parsed.Host
1710
+ }
1711
+ if strings.Contains(host, ":") && !strings.HasPrefix(host, "[") {
1712
+ host = "[" + host + "]"
1713
+ }
1714
+ parsed.Host = host
1715
+ parsed.User = nil
1716
+ parsed.RawQuery = ""
1717
+ parsed.Fragment = ""
1718
+ return parsed.String()
1719
+}
1720
+
1721
+func relayDashboardRelayLabel(rawURL string) string {
1722
+ rawURL = strings.TrimSpace(rawURL)
1723
+ if rawURL == "" {
1724
+ return "-"
1725
+ }
1726
+ parsed, err := url.Parse(rawURL)
1727
+ if err != nil || parsed.Host == "" {
1728
+ return rawURL
1729
+ }
1730
+ host := strings.TrimSpace(parsed.Hostname())
1731
+ if host == "" {
1732
+ return strings.TrimSpace(parsed.Host)
1733
+ }
1734
+ return host
1735
}
1736
1737
func (m agentDashboardModel) relayDashboardMode(tunnel types.AgentTunnelStatus, relay types.AgentRelayStatus) string {
@@ -1778,12 +1818,14 @@ func agentDashboardRelayWindow(selected, total, rows int) (int, int) {
1818
1819
func agentDashboardRelayRow(width int, mode, version, features, displayURL string) string {
1820
if width < 28 {
1781
- return agentDashboardFit(mode+" "+displayURL, width)
1821
+ modeW := lipgloss.Width(mode)
1822
+ urlW := max(1, width-modeW-1)
1823
+ return agentDashboardFit(mode+" "+agentDashboardURLCell(displayURL, urlW), width)
1824
}
1825
if width < 56 {
1826
modeW := 13
1827
return agentDashboardCell(mode, modeW) + " " +
1786
- agentDashboardFit(displayURL, width-modeW-1)
1828
+ agentDashboardURLCell(displayURL, width-modeW-1)
1829
}
1830
modeW := 13
1831
versionW := 8
@@ -1792,7 +1834,28 @@ func agentDashboardRelayRow(width int, mode, version, features, displayURL strin
1834
return agentDashboardCell(mode, modeW) + " " +
1835
agentDashboardCell(version, versionW) + " " +
1836
agentDashboardCell(features, featuresW) + " " +
1795
- agentDashboardFit(displayURL, relayW)
1837
+ agentDashboardURLCell(displayURL, relayW)
1838
+}
1839
+
1840
+func agentDashboardURLCell(value string, width int) string {
1841
+ value = strings.TrimSpace(value)
1842
+ if value == "" || width <= 0 {
1843
+ return ""
1844
+ }
1845
+ if lipgloss.Width(value) <= width {
1846
+ return value
1847
+ }
1848
+ parsed, err := url.Parse(value)
1849
+ if err == nil && parsed.Scheme != "" && parsed.Host != "" {
1850
+ host := strings.TrimSpace(parsed.Hostname())
1851
+ if host == "" {
1852
+ host = strings.TrimSpace(parsed.Host)
1853
+ }
1854
+ if host != "" {
1855
+ return agentDashboardFit("open "+host, width)
1856
+ }
1857
+ }
1858
+ return agentDashboardFit(value, width)
1859
}
1860
1861
func openDashboardURL(rawURL string) error {
portal/discovery/relayset.go
+34
@@ -398,6 +398,25 @@ func (s *RelaySet) PlanRoutes(explicitPath []string, routeState RouteState) ([]R
398
}
399
400
states := s.currentRelayStates(time.Now().UTC())
401
+ if len(routeState.ExplicitRelayURLs) > 0 {
402
+ seen := make(map[string]struct{}, len(states))
403
+ for _, state := range states {
404
+ if relayURL := strings.TrimSpace(state.Descriptor.APIHTTPSAddr); relayURL != "" {
405
+ seen[relayURL] = struct{}{}
406
+ }
407
+ }
408
+ for _, relayURL := range routeState.ExplicitRelayURLs {
409
+ relayURL = strings.TrimSpace(relayURL)
410
+ if relayURL == "" {
411
+ continue
412
+ }
413
+ if _, ok := seen[relayURL]; ok {
414
+ continue
415
+ }
416
+ states = append(states, newRelayState(relayURL))
417
+ seen[relayURL] = struct{}{}
418
+ }
419
+ }
420
421
if routeState.MultiHopDepth > 1 {
422
path := SelectMultiHop(states, routeState)
@@ -608,6 +627,21 @@ func (s *RelaySet) BanRelayURL(relayURL string) {
627
s.relays[relayURL] = state
628
}
629
630
+func (s *RelaySet) DropRelayURLFromActivePool(relayURL string) {
631
+ s.mu.Lock()
632
+ defer s.mu.Unlock()
633
+
634
+ now := time.Now().UTC()
635
+ s.clearExpiredPoolBansLocked(now)
636
+ state, ok := s.relays[relayURL]
637
+ if !ok || state.Banned {
638
+ return
639
+ }
640
+ state.Confirmed = false
641
+ state.suppressActiveUntil = now.Add(activeDropTTL)
642
+ s.relays[relayURL] = state
643
+}
644
+
645
func (s *RelaySet) AllowRelayURL(relayURL string) {
646
s.mu.Lock()
647
defer s.mu.Unlock()
portal/discovery/relayset_test.go
+21
@@ -472,3 +472,24 @@ func TestPlanRoutesExplicitPathReturnsSingleRouteToExit(t *testing.T) {
472
t.Fatalf("MultiHop() = %v, want [%q %q %q]", path, entry, mid, exit)
473
}
474
}
475
+
476
+func TestPlanRoutesIncludesExplicitRelayMissingFromSet(t *testing.T) {
477
+ const relayURL = "https://relay-explicit.example"
478
+
479
+ routes, err := NewRelaySet(nil).PlanRoutes(nil, RouteState{
480
+ ExplicitRelayURLs: []string{relayURL},
481
+ })
482
+ if err != nil {
483
+ t.Fatalf("PlanRoutes() error = %v", err)
484
+ }
485
+ if len(routes) != 1 {
486
+ t.Fatalf("len(routes) = %d, want 1", len(routes))
487
+ }
488
+ route := routes[0]
489
+ if !route.Explicit() {
490
+ t.Fatal("route.Explicit() = false, want true")
491
+ }
492
+ if got := route.ListenerRelayURL(); got != relayURL {
493
+ t.Fatalf("ListenerRelayURL() = %q, want %q", got, relayURL)
494
+ }
495
+}
portal/discovery/relaystate.go
+1
@@ -12,6 +12,7 @@ const (
12
DiscoveryDescriptorTTL = 5 * time.Minute
13
defaultDirectRecoveryBackoff = 1 * time.Minute
14
maxDirectRecoveryBackoff = 5 * time.Minute
15
+ activeDropTTL = 72 * time.Hour
16
relayPoolBanTTL = 72 * time.Hour
17
18
// MaxAnnouncedRelays is the hard ceiling on the number of relay entries
sdk/expose.go
-21
@@ -829,32 +829,11 @@ func (e *Exposure) runListenerAcceptLoop(listener *listener) {
829
}()
830
}
831
defer func() {
832
- removed := false
832
e.mu.Lock()
833
if current, ok := e.relayListeners[relayURL]; ok && current == listener {
834
delete(e.relayListeners, relayURL)
836
- removed = true
835
}
836
e.mu.Unlock()
839
- if !removed || e.closed() {
840
- return
841
- }
842
-
843
- removedExplicit := false
844
- if e.cfg != nil {
845
- _, removedExplicit = e.cfg.UpdateIf(func(cfg ExposeConfig) (ExposeConfig, bool) {
846
- if !slices.Contains(cfg.RelayURLs, relayURL) {
847
- return cfg, false
848
- }
849
- cfg.RelayURLs = utils.RemoveRelayURL(cfg.RelayURLs, relayURL)
850
- return cfg, true
851
- })
852
- }
853
-
854
- if removedExplicit && e.relaySet != nil {
855
- e.relaySet.DeactivateRelayURL(relayURL)
856
- e.relaySet.RemoveBootstrapRelayURL(relayURL)
857
- }
837
}()
838
839
for {
sdk/expose_test.go
+68
@@ -1,6 +1,8 @@
1
package sdk
2
3
import (
4
+ "context"
5
+ "errors"
6
"net/url"
7
"testing"
8
@@ -216,3 +218,69 @@ func TestExposureRemoveRelayStopsRunningListener(t *testing.T) {
218
t.Fatalf("AllRelays() = %+v, want unbanned candidate %q", relays, relayA)
219
}
220
}
221
+
222
+func TestExposureListenerSelfExitKeepsExplicitRelayConfigured(t *testing.T) {
223
+ const relayA = "https://relay-a.example"
224
+
225
+ relayAURL, err := url.Parse(relayA)
226
+ if err != nil {
227
+ t.Fatalf("url.Parse(relayA) error = %v", err)
228
+ }
229
+
230
+ l := &listener{relayURL: relayAURL}
231
+ exposure := &Exposure{
232
+ cfg: utils.NewSnapshot(ExposeConfig{RelayURLs: []string{relayA}}, ExposeConfig.snapshot),
233
+ relaySet: mustRelaySet(t, relayA),
234
+ relayListeners: map[string]*listener{relayA: l},
235
+ done: make(chan struct{}),
236
+ }
237
+
238
+ exposure.runListenerAcceptLoop(l)
239
+
240
+ if got := exposure.ActiveRelayURLs(); len(got) != 0 {
241
+ t.Fatalf("ActiveRelayURLs() = %v, want empty", got)
242
+ }
243
+ if got := exposure.Config().RelayURLs; len(got) != 1 || got[0] != relayA {
244
+ t.Fatalf("RelayURLs = %v, want [%q]", got, relayA)
245
+ }
246
+ if got := exposure.relaySet.BootstrapRelayURLs(); len(got) != 1 || got[0] != relayA {
247
+ t.Fatalf("BootstrapRelayURLs() = %v, want [%q]", got, relayA)
248
+ }
249
+}
250
+
251
+func TestListenerRetryBudgetDropsAutoSelectedRelayWithoutPoolBan(t *testing.T) {
252
+ const relayA = "https://relay-a.example"
253
+
254
+ relayAURL, err := url.Parse(relayA)
255
+ if err != nil {
256
+ t.Fatalf("url.Parse(relayA) error = %v", err)
257
+ }
258
+
259
+ relaySet := mustRelaySet(t, relayA)
260
+ listener := &listener{
261
+ relayURL: relayAURL,
262
+ route: discovery.NewRoute([]string{relayA}, false),
263
+ relaySet: relaySet,
264
+ retryCount: 1,
265
+ }
266
+
267
+ if listener.waitRetry(context.Background(), "lease registration", errors.New("boom"), 2, 0) {
268
+ t.Fatal("waitRetry() = true after retry budget was exhausted")
269
+ }
270
+
271
+ routes, err := relaySet.PlanRoutes(nil, discovery.RouteState{})
272
+ if err != nil {
273
+ t.Fatalf("PlanRoutes() error = %v", err)
274
+ }
275
+ if len(routes) != 0 {
276
+ t.Fatalf("PlanRoutes() = %v, want no active routes", routes)
277
+ }
278
+
279
+ relays := relaySet.AllRelays()
280
+ if len(relays) != 1 || relays[0].Banned || relays[0].Descriptor.APIHTTPSAddr != relayA {
281
+ t.Fatalf("AllRelays() = %+v, want relay retained outside active pool", relays)
282
+ }
283
+ if got := relaySet.BootstrapRelayURLs(); len(got) != 1 || got[0] != relayA {
284
+ t.Fatalf("BootstrapRelayURLs() = %v, want [%q]", got, relayA)
285
+ }
286
+}
sdk/listener.go
+1
@@ -892,6 +892,7 @@ func (l *listener) waitRetry(ctx context.Context, operation string, err error, r
892
if l.relaySet != nil && relayURL != "" {
893
l.relaySet.UnconfirmRelayURL(relayURL)
894
l.relaySet.RecordActiveFailure(relayURL, 1)
895
+ l.relaySet.DropRelayURLFromActivePool(relayURL)
896
}
897
logger.Error().
898
Err(err).