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).