Refactor tunnel relay management and exposure handling

Kim committed May 8, 2026 at 19:24 UTC 1d081f19616dc94ede6d6ece7936fa87ff8cfdfb
7 files changed +538 -429
cmd/portal-tunnel/agent/config.go
-4
@@ -295,10 +295,6 @@ func (cfg Config) Validate() error {
295 if err := validateAgentPathComponent("agent.service_name", cfg.Agent.ServiceName); err != nil {
296 return err
297 }
298 - if len(cfg.Tunnels) == 0 {
299 - return errors.New("at least one tunnel is required")
300 - }
301 -
298 seen := make(map[string]struct{}, len(cfg.Tunnels))
299 for _, tunnel := range cfg.Tunnels {
300 if err := tunnel.Validate(); err != nil {
cmd/portal-tunnel/agent/control.go
+4 -4
@@ -101,9 +101,9 @@ func (s *controlHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
101 }
102 var err error
103 if r.Method == http.MethodPost {
104 - err = s.manager.AddRelay(tunnelID, req.RelayURL)
104 + err = s.manager.ConnectRelay(tunnelID, req.RelayURL)
105 } else {
106 - err = s.manager.RemoveRelay(tunnelID, req.RelayURL)
106 + err = s.manager.DisconnectRelay(tunnelID, req.RelayURL)
107 }
108 if err != nil {
109 utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, err.Error())
@@ -158,12 +158,12 @@ func DeleteTunnel(ctx context.Context, stateDir, tunnelID string) error {
158 return controlRequest(ctx, stateDir, http.MethodDelete, path, nil, nil)
159 }
160
161 -func AddRelay(ctx context.Context, stateDir, tunnelID, relayURL string) error {
161 +func ConnectRelay(ctx context.Context, stateDir, tunnelID, relayURL string) error {
162 path := types.PathAgentTunnelsPrefix + url.PathEscape(tunnelID) + "/relays"
163 return controlRequest(ctx, stateDir, http.MethodPost, path, types.AgentRelayRequest{RelayURL: relayURL}, nil)
164 }
165
166 -func RemoveRelay(ctx context.Context, stateDir, tunnelID, relayURL string) error {
166 +func DisconnectRelay(ctx context.Context, stateDir, tunnelID, relayURL string) error {
167 path := types.PathAgentTunnelsPrefix + url.PathEscape(tunnelID) + "/relays"
168 return controlRequest(ctx, stateDir, http.MethodDelete, path, types.AgentRelayRequest{RelayURL: relayURL}, nil)
169 }
cmd/portal-tunnel/agent/dashboard.go
+400 -360
@@ -3,8 +3,10 @@ package agent
3 import (
4 "context"
5 "fmt"
6 + "net/url"
7 + "os/exec"
8 + "runtime"
9 "slices"
7 - "strconv"
10 "strings"
11 "time"
12
@@ -13,35 +15,26 @@ import (
15 "github.com/charmbracelet/lipgloss"
16
17 "github.com/gosuda/portal-tunnel/v2/types"
18 + "github.com/gosuda/portal-tunnel/v2/utils"
19 )
20
21 const (
22 agentDashboardPollInterval = 2 * time.Second
20 - agentDashboardMinListRows = 10
21 -)
22 -
23 -type agentDashboardMode int
24 -
25 -const (
26 - agentDashboardNormalMode agentDashboardMode = iota
27 - agentDashboardAddTunnelMode
28 - agentDashboardAddRelayMode
23 + agentDashboardMinRelayRows = 1
24 )
25
26 type agentDashboardAction int
27
28 const (
29 agentDashboardActionSelectTunnel agentDashboardAction = iota + 1
35 - agentDashboardActionSelectRelay
30 agentDashboardActionAddTunnel
31 agentDashboardActionDeleteTunnel
38 - agentDashboardActionAddRelay
39 - agentDashboardActionDeleteRelay
40 - agentDashboardActionAttachRelay
32 + agentDashboardActionConnectRelay
33 + agentDashboardActionDisconnectRelay
34 agentDashboardActionAddHop
42 - agentDashboardActionRemoveHop
35 agentDashboardActionApplyHop
36 agentDashboardActionClearHop
37 + agentDashboardActionOpenTunnelURL
38 )
39
40 type agentDashboardModel struct {
@@ -60,7 +53,6 @@ type agentDashboardModel struct {
53 routeDraft []string
54 draftTunnelID string
55
63 - mode agentDashboardMode
56 input textinput.Model
57 }
58
@@ -110,8 +102,12 @@ var (
102 func RunDashboard(configPath, stateDir string) error {
103 input := textinput.New()
104 input.CharLimit = 512
113 - input.Prompt = "> "
114 - input.Width = 72
105 + input.Prompt = ""
106 + input.Placeholder = "name port"
107 + input.TextStyle = agentDashboardInputStyle
108 + input.PlaceholderStyle = agentDashboardMutedStyle
109 + input.Width = 32
110 + _ = input.Focus()
111
112 _, err := tea.NewProgram(agentDashboardModel{
113 configPath: configPath,
@@ -122,7 +118,7 @@ func RunDashboard(configPath, stateDir string) error {
118 }
119
120 func (m agentDashboardModel) Init() tea.Cmd {
125 - return tea.Batch(agentDashboardFetchStatus(m.stateDir), agentDashboardTick())
121 + return tea.Batch(agentDashboardFetchStatus(m.stateDir), agentDashboardTick(), textinput.Blink)
122 }
123
124 func (m agentDashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -130,7 +126,8 @@ func (m agentDashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
126 case tea.WindowSizeMsg:
127 m.width = msg.Width
128 m.height = msg.Height
133 - m.input.Width = max(1, min(88, msg.Width-8))
129 + buttonsWidth := lipgloss.Width("[ Add Tunnel ]") + lipgloss.Width("[ Delete ]") + 2
130 + m.input.Width = max(1, min(32, msg.Width-buttonsWidth))
131 return m, nil
132 case agentDashboardTickMsg:
133 return m, tea.Batch(agentDashboardFetchStatus(m.stateDir), agentDashboardTick())
@@ -157,60 +154,53 @@ func (m agentDashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
154 }
155
156 func (m agentDashboardModel) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
160 - if m.mode != agentDashboardNormalMode {
161 - switch msg.String() {
162 - case "ctrl+c":
163 - return m, tea.Quit
164 - case "esc":
165 - m.cancelInput()
166 - return m, nil
167 - case "enter":
168 - return m.submitInput()
169 - default:
170 - var cmd tea.Cmd
171 - m.input, cmd = m.input.Update(msg)
172 - return m, cmd
173 - }
174 - }
175 -
157 switch msg.String() {
158 case "ctrl+c":
159 return m, tea.Quit
179 - case "up", "k":
160 + case "esc":
161 + m.input.Reset()
162 + m.err = nil
163 + return m, nil
164 + case "up":
165 + if m.selectedTunnelHasRelays() {
166 + m.selectRelayOffset(-1)
167 + return m, nil
168 + }
169 m.selectTunnelOffset(-1)
181 - case "down", "j":
170 + return m, nil
171 + case "down":
172 + if m.selectedTunnelHasRelays() {
173 + m.selectRelayOffset(1)
174 + return m, nil
175 + }
176 m.selectTunnelOffset(1)
183 - case "left", "h":
184 - m.selectRelayOffset(-1)
185 - case "right", "l":
186 - m.selectRelayOffset(1)
187 - case "n":
188 - return m.runAction(agentDashboardActionAddTunnel, "", "")
189 - case "x":
190 - return m.runAction(agentDashboardActionDeleteTunnel, "", "")
191 - case "a":
192 - return m.runAction(agentDashboardActionAddRelay, "", "")
193 - case "d":
194 - return m.runAction(agentDashboardActionDeleteRelay, "", "")
195 - case "m":
196 - return m.runAction(agentDashboardActionAddHop, "", "")
197 - case "u":
198 - return m.runAction(agentDashboardActionRemoveHop, "", "")
199 - case "p":
200 - return m.runAction(agentDashboardActionApplyHop, "", "")
201 - case "c":
202 - return m.runAction(agentDashboardActionClearHop, "", "")
177 + return m, nil
178 + case "enter":
179 + if strings.TrimSpace(m.input.Value()) != "" {
180 + return m.addTunnelFromInput()
181 + }
182 + return m.runAction(agentDashboardActionConnectRelay, "", "")
183 }
204 - return m, nil
184 + var cmd tea.Cmd
185 + m.input, cmd = m.input.Update(msg)
186 + return m, cmd
187 }
188
189 func (m agentDashboardModel) updateMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
190 event := tea.MouseEvent(msg)
191 switch event.Button {
192 case tea.MouseButtonWheelUp:
193 + if m.selectedTunnelHasRelays() {
194 + m.selectRelayOffset(-1)
195 + return m, nil
196 + }
197 m.selectTunnelOffset(-1)
198 return m, nil
199 case tea.MouseButtonWheelDown:
200 + if m.selectedTunnelHasRelays() {
201 + m.selectRelayOffset(1)
202 + return m, nil
203 + }
204 m.selectTunnelOffset(1)
205 return m, nil
206 }
@@ -231,34 +221,22 @@ func (m agentDashboardModel) runAction(action agentDashboardAction, tunnelID, re
221 if tunnelID != "" {
222 m.selectTunnel(tunnelID)
223 }
234 - case agentDashboardActionSelectRelay:
235 - if tunnelID != "" {
236 - m.selectTunnel(tunnelID)
237 - }
238 - if relayURL != "" {
239 - m.selectRelay(relayURL)
240 - }
224 case agentDashboardActionAddTunnel:
242 - return m.startInput(agentDashboardAddTunnelMode, "New tunnel: ", "name port")
225 + return m.addTunnelFromInput()
226 case agentDashboardActionDeleteTunnel:
244 - return m.deleteSelectedTunnel()
245 - case agentDashboardActionAddRelay:
246 - if _, ok := m.selectedTunnelStatus(); !ok {
247 - return m, nil
248 - }
249 - return m.startInput(agentDashboardAddRelayMode, "Add relay: ", "https://relay.example.com")
250 - case agentDashboardActionDeleteRelay:
251 - return m.deleteSelectedRelay()
252 - case agentDashboardActionAttachRelay:
253 - return m.attachSelectedRelay()
227 + return m.deleteTunnel(tunnelID)
228 + case agentDashboardActionConnectRelay:
229 + return m.connectSelectedRelay()
230 + case agentDashboardActionDisconnectRelay:
231 + return m.disconnectSelectedRelay()
232 case agentDashboardActionAddHop:
233 return m.addSelectedHop()
256 - case agentDashboardActionRemoveHop:
257 - return m.removeSelectedHop()
234 case agentDashboardActionApplyHop:
235 return m.applyRoute()
236 case agentDashboardActionClearHop:
237 return m.clearRoute()
238 + case agentDashboardActionOpenTunnelURL:
239 + return m.openRelayTunnelURL(tunnelID, relayURL)
240 }
241 return m, nil
242 }
@@ -302,7 +280,12 @@ func (m *agentDashboardModel) selectTunnelIndex(index int) {
280 if index < 0 || index >= len(m.status.Tunnels) {
281 return
282 }
305 - m.selectedTunnelID = m.status.Tunnels[index].ID
283 + tunnelID := m.status.Tunnels[index].ID
284 + if m.selectedTunnelID != tunnelID {
285 + m.routeDraft = nil
286 + m.draftTunnelID = ""
287 + }
288 + m.selectedTunnelID = tunnelID
289 m.selectedRelayURL = ""
290 if len(m.status.Tunnels[index].Relays) > 0 {
291 m.selectedRelayURL = m.status.Tunnels[index].Relays[0].RelayURL
@@ -335,11 +318,18 @@ func (m *agentDashboardModel) clampSelection() {
318 if len(m.status.Tunnels) == 0 {
319 m.selectedTunnelID = ""
320 m.selectedRelayURL = ""
321 + m.routeDraft = nil
322 + m.draftTunnelID = ""
323 return
324 }
325
326 tunnelIndex := m.selectedTunnelIndex()
342 - m.selectedTunnelID = m.status.Tunnels[tunnelIndex].ID
327 + tunnelID := m.status.Tunnels[tunnelIndex].ID
328 + if m.selectedTunnelID != tunnelID {
329 + m.routeDraft = nil
330 + m.draftTunnelID = ""
331 + }
332 + m.selectedTunnelID = tunnelID
333
334 relays := m.status.Tunnels[tunnelIndex].Relays
335 if len(relays) == 0 {
@@ -397,97 +387,96 @@ func (m agentDashboardModel) selectedTunnelRelay() (types.AgentTunnelStatus, typ
387 return tunnel, relay, true
388 }
389
400 -func (m agentDashboardModel) startInput(mode agentDashboardMode, prompt, placeholder string) (tea.Model, tea.Cmd) {
401 - m.mode = mode
402 - m.input.Reset()
403 - m.input.Prompt = prompt
404 - m.input.Placeholder = placeholder
405 - m.input.PromptStyle = agentDashboardSectionStyle
406 - m.input.TextStyle = agentDashboardInputStyle
407 - m.input.PlaceholderStyle = agentDashboardMutedStyle
408 - m.input.Width = max(1, min(88, m.width-8))
409 - return m, tea.Batch(m.input.Focus(), textinput.Blink)
410 -}
411 -
412 -func (m *agentDashboardModel) cancelInput() {
413 - m.mode = agentDashboardNormalMode
414 - m.input.Blur()
415 - m.input.Reset()
390 +func (m agentDashboardModel) selectedTunnelHasRelays() bool {
391 + tunnel, ok := m.selectedTunnelStatus()
392 + return ok && len(tunnel.Relays) > 0
393 }
394
418 -func (m agentDashboardModel) submitInput() (tea.Model, tea.Cmd) {
419 - mode := m.mode
395 +func (m agentDashboardModel) addTunnelFromInput() (tea.Model, tea.Cmd) {
396 value := strings.TrimSpace(m.input.Value())
421 - m.mode = agentDashboardNormalMode
422 - m.input.Blur()
423 - m.input.Reset()
397 + if value == "" {
398 + return m, nil
399 + }
400 + fields := strings.Fields(value)
401 + if len(fields) < 2 {
402 + m.err = fmt.Errorf("use: name port")
403 + return m, nil
404 + }
405 + name := strings.Join(fields[:len(fields)-1], " ")
406 + if agentTunnelID(name) == "" {
407 + m.err = fmt.Errorf("tunnel name is required")
408 + return m, nil
409 + }
410 + targetInput := fields[len(fields)-1]
411 + target, err := utils.NormalizeLoopbackTarget(targetInput)
412 + if err != nil || target == "" {
413 + m.err = fmt.Errorf("invalid target %q", targetInput)
414 + return m, nil
415 + }
416
425 - switch mode {
426 - case agentDashboardAddTunnelMode:
427 - fields := strings.Fields(value)
428 - if len(fields) < 2 {
429 - return m, nil
430 - }
431 - name := strings.Join(fields[:len(fields)-1], " ")
432 - port := strings.TrimPrefix(fields[len(fields)-1], ":")
433 - portNumber, err := strconv.Atoi(port)
434 - if err != nil || portNumber < 1 || portNumber > 65535 {
435 - return m, nil
436 - }
437 - return m, agentDashboardRun(func(ctx context.Context) error {
438 - return AddTunnel(ctx, m.stateDir, types.AgentTunnelRequest{
439 - Name: name,
440 - TargetAddr: "127.0.0.1:" + port,
441 - })
417 + m.err = nil
418 + m.input.Reset()
419 + return m, agentDashboardRun(func(ctx context.Context) error {
420 + return AddTunnel(ctx, m.stateDir, types.AgentTunnelRequest{
421 + Name: name,
422 + TargetAddr: target,
423 })
443 - case agentDashboardAddRelayMode:
444 - if value == "" {
445 - return m, nil
446 - }
424 + })
425 +}
426 +
427 +func (m agentDashboardModel) deleteTunnel(tunnelID string) (tea.Model, tea.Cmd) {
428 + tunnelID = strings.TrimSpace(tunnelID)
429 + if tunnelID == "" {
430 tunnel, ok := m.selectedTunnelStatus()
431 if !ok {
432 return m, nil
433 }
451 - return m, agentDashboardRun(func(ctx context.Context) error {
452 - return AddRelay(ctx, m.stateDir, tunnel.ID, value)
453 - })
434 + tunnelID = tunnel.ID
435 }
455 - return m, nil
436 + return m, agentDashboardRun(func(ctx context.Context) error {
437 + return DeleteTunnel(ctx, m.stateDir, tunnelID)
438 + })
439 }
440
458 -func (m agentDashboardModel) deleteSelectedTunnel() (tea.Model, tea.Cmd) {
459 - tunnel, ok := m.selectedTunnelStatus()
441 +func (m agentDashboardModel) connectSelectedRelay() (tea.Model, tea.Cmd) {
442 + tunnel, relay, ok := m.selectedTunnelRelay()
443 if !ok {
444 return m, nil
445 }
463 - if len(m.status.Tunnels) <= 1 {
446 + if relay.Banned || relayDashboardActive(tunnel, relay) {
447 return m, nil
448 }
449 return m, agentDashboardRun(func(ctx context.Context) error {
467 - return DeleteTunnel(ctx, m.stateDir, tunnel.ID)
450 + return ConnectRelay(ctx, m.stateDir, tunnel.ID, relay.RelayURL)
451 })
452 }
453
471 -func (m agentDashboardModel) deleteSelectedRelay() (tea.Model, tea.Cmd) {
454 +func (m agentDashboardModel) disconnectSelectedRelay() (tea.Model, tea.Cmd) {
455 tunnel, relay, ok := m.selectedTunnelRelay()
456 if !ok {
457 return m, nil
458 }
459 + if relay.Banned || !relayDashboardActive(tunnel, relay) || slices.Contains(m.displayedRoute(tunnel), relay.RelayURL) {
460 + return m, nil
461 + }
462 return m, agentDashboardRun(func(ctx context.Context) error {
477 - return RemoveRelay(ctx, m.stateDir, tunnel.ID, relay.RelayURL)
463 + return DisconnectRelay(ctx, m.stateDir, tunnel.ID, relay.RelayURL)
464 })
465 }
466
481 -func (m agentDashboardModel) attachSelectedRelay() (tea.Model, tea.Cmd) {
482 - tunnel, relay, ok := m.selectedTunnelRelay()
483 - if !ok {
484 - return m, nil
467 +func (m agentDashboardModel) openRelayTunnelURL(tunnelID, relayURL string) (tea.Model, tea.Cmd) {
468 + if tunnelID != "" {
469 + m.selectTunnel(tunnelID)
470 }
486 - if relay.Explicit {
471 + if relayURL != "" {
472 + m.selectRelay(relayURL)
473 + }
474 + _, relay, ok := m.selectedTunnelRelay()
475 + if !ok || strings.TrimSpace(relay.PublicURL) == "" {
476 return m, nil
477 }
489 - return m, agentDashboardRun(func(ctx context.Context) error {
490 - return AddRelay(ctx, m.stateDir, tunnel.ID, relay.RelayURL)
478 + return m, agentDashboardRun(func(context.Context) error {
479 + return openDashboardURL(relay.PublicURL)
480 })
481 }
482
@@ -507,26 +496,6 @@ func (m agentDashboardModel) addSelectedHop() (tea.Model, tea.Cmd) {
496 return m, nil
497 }
498
510 -func (m agentDashboardModel) removeSelectedHop() (tea.Model, tea.Cmd) {
511 - tunnel, relay, ok := m.selectedTunnelRelay()
512 - if !ok {
513 - return m, nil
514 - }
515 - m.ensureRouteDraft(tunnel)
516 -
517 - next := m.routeDraft[:0]
518 - for _, relayURL := range m.routeDraft {
519 - if relayURL != relay.RelayURL {
520 - next = append(next, relayURL)
521 - }
522 - }
523 - if len(next) == len(m.routeDraft) {
524 - return m, nil
525 - }
526 - m.routeDraft = next
527 - return m, nil
528 -}
529 -
499 func (m agentDashboardModel) applyRoute() (tea.Model, tea.Cmd) {
500 tunnel, ok := m.selectedTunnelStatus()
501 if !ok {
@@ -600,10 +569,18 @@ func (m agentDashboardModel) layout() agentDashboardView {
569 if width <= 0 {
570 width = 88
571 }
603 - leftWidth, rightWidth, bodyHeight := agentDashboardSizes(width, m.height)
572 + bodyHeight := defaultDashboardBodyHeight(m.height)
573
574 var layout agentDashboardView
575 layout.addStyled(width, agentDashboardTitleStyle, "Portal Agent "+types.ReleaseVersion)
576 + if configPath := strings.TrimSpace(m.status.ConfigPath); configPath != "" {
577 + layout.addStyled(width, agentDashboardMutedStyle, agentDashboardFit("Config: "+configPath, width))
578 + } else if configPath := strings.TrimSpace(m.configPath); configPath != "" {
579 + layout.addStyled(width, agentDashboardMutedStyle, agentDashboardFit("Config: "+configPath, width))
580 + }
581 + if controlAddr := strings.TrimSpace(m.status.ControlAddr); controlAddr != "" {
582 + layout.addStyled(width, agentDashboardMutedStyle, agentDashboardFit("Control: "+controlAddr, width))
583 + }
584 layout.addLine(agentDashboardMutedStyle.Render(strings.Repeat("-", min(width, 120))))
585
586 if m.err != nil && m.status.ControlAddr == "" {
@@ -617,80 +594,49 @@ func (m agentDashboardModel) layout() agentDashboardView {
594 if m.err != nil {
595 layout.addStyled(width, agentDashboardErrorStyle, fmt.Sprintf("Error: %v", m.err))
596 }
620 - if m.mode != agentDashboardNormalMode {
621 - layout.addLine(m.input.View())
622 - }
623 - if strings.TrimSpace(m.status.ConfigPath) != "" {
624 - layout.addStyled(width, agentDashboardMutedStyle, agentDashboardFit("Config: "+m.status.ConfigPath, width))
625 - }
626 - if strings.TrimSpace(m.status.ControlAddr) != "" {
627 - layout.addStyled(width, agentDashboardMutedStyle, agentDashboardFit("Control: "+m.status.ControlAddr, width))
628 - }
597 layout.addLine("")
598 if m.height > 0 {
599 bodyHeight = max(1, m.height-len(layout.lines))
600 }
601
634 - left := m.renderTunnelsPane(leftWidth, bodyHeight)
635 - right := m.renderTunnelPane(rightWidth, bodyHeight)
636 - layout.addPanes(left, right, leftWidth, 2)
602 + tunnels := m.renderTunnelsSection(width)
603 + layout.addView(tunnels)
604 + layout.addLine("")
605 + if m.height > 0 {
606 + bodyHeight = max(1, m.height-len(layout.lines))
607 + }
608 + body := m.renderTunnelPane(width, bodyHeight)
609 + layout.addView(body)
610 return layout
611 }
612
640 -func (m agentDashboardModel) renderTunnelsPane(width, height int) agentDashboardView {
613 +func (m agentDashboardModel) renderTunnelsSection(width int) agentDashboardView {
614 var pane agentDashboardView
615 pane.addStyled(width, agentDashboardSectionStyle, "Tunnels")
643 - pane.addButtons(width,
644 - agentDashboardButton{label: "Add Tunnel", action: agentDashboardActionAddTunnel},
645 - agentDashboardButton{label: "Delete Tunnel", action: agentDashboardActionDeleteTunnel, disabled: len(m.status.Tunnels) <= 1},
616 + pane.addInputButton(width, m.input.View(),
617 + agentDashboardButton{label: "Add Tunnel", action: agentDashboardActionAddTunnel, disabled: strings.TrimSpace(m.input.Value()) == ""},
618 + agentDashboardButton{label: "Delete", action: agentDashboardActionDeleteTunnel, disabled: len(m.status.Tunnels) == 0},
619 )
647 - pane.addStyled(width, agentDashboardMutedStyle, fmt.Sprintf("%d managed", len(m.status.Tunnels)))
648 - pane.addLine(agentDashboardMutedStyle.Render(strings.Repeat("-", width)))
620 + tunnelRowWidth := agentDashboardTunnelTableWidth(width, m.status.Tunnels)
621 + pane.addLine(agentDashboardMutedStyle.Render(agentDashboardTunnelRow(tunnelRowWidth, "STATUS", "TARGET", "TUNNEL")))
622
623 if len(m.status.Tunnels) == 0 {
651 - pane.addStyled(width, agentDashboardMutedStyle, "no managed tunnels")
652 - pane.clip(height)
624 + pane.addStyled(width, agentDashboardMutedStyle, "no tunnels")
625 return pane
626 }
627
656 - detailLines := make([]string, 0, 5)
657 - if tunnel, ok := m.selectedTunnelStatus(); ok {
658 - detailLines = append(detailLines,
659 - "",
660 - agentDashboardSectionStyle.Render(agentDashboardFit("Selected Tunnel", width)),
661 - agentDashboardFit("State: "+valueOrDash(tunnel.State), width),
662 - agentDashboardFit("Target: "+valueOrDash(tunnel.TargetAddr), width),
663 - agentDashboardFit("Public: "+tunnelPublicURL(tunnel), width),
664 - )
665 - if strings.TrimSpace(tunnel.LastError) != "" {
666 - detailLines = append(detailLines, agentDashboardErrorStyle.Render(agentDashboardFit("Error: "+tunnel.LastError, width)))
667 - }
668 - }
669 - listLimit := max(agentDashboardMinListRows, height-len(pane.lines)-len(detailLines))
628 selectedTunnelID := m.selectedTunnelID
629 if selectedTunnelID == "" && len(m.status.Tunnels) > 0 {
630 selectedTunnelID = m.status.Tunnels[0].ID
631 }
674 - for i, tunnel := range m.status.Tunnels {
675 - if i >= listLimit || len(pane.lines) >= height {
676 - pane.addStyled(width, agentDashboardMutedStyle, fmt.Sprintf("+ %d more", len(m.status.Tunnels)-i))
677 - break
678 - }
679 - name := tunnel.ID
680 - if strings.TrimSpace(tunnel.Name) != "" {
681 - name = tunnel.Name
682 - }
683 - nameWidth := max(8, width-11)
684 - line := fmt.Sprintf("%-10s %s", truncateDashboardValue(tunnel.State, 10), agentDashboardFit(name, nameWidth))
685 - pane.addClickRow(line, width, agentDashboardTunnelStyle(tunnel.ID == selectedTunnelID, tunnel.State), agentDashboardActionSelectTunnel, tunnel.ID, "")
632 + for _, tunnel := range m.status.Tunnels {
633 + pane.addTunnelRow(width, tunnelRowWidth, tunnel, tunnel.ID == selectedTunnelID)
634 }
687 - for _, line := range detailLines {
688 - if len(pane.lines) >= height {
689 - break
690 - }
691 - pane.addLine(line)
635 +
636 + if tunnel, ok := m.selectedTunnelStatus(); ok && strings.TrimSpace(tunnel.LastError) != "" {
637 + pane.addStyled(width, agentDashboardErrorStyle, "Error: "+tunnel.LastError)
638 }
693 - pane.clip(height)
639 + pane.addLine(agentDashboardMutedStyle.Render(strings.Repeat("-", width)))
640 return pane
641 }
642
@@ -699,34 +645,43 @@ func (m agentDashboardModel) renderTunnelPane(width, height int) agentDashboardV
645 tunnel, ok := m.selectedTunnelStatus()
646 if !ok {
647 pane.addStyled(width, agentDashboardSectionStyle, "Relays")
702 - pane.addStyled(width, agentDashboardMutedStyle, "select a managed tunnel")
648 + pane.addStyled(width, agentDashboardMutedStyle, "select a tunnel")
649 pane.clip(height)
650 return pane
651 }
652
707 - relayLimit := max(agentDashboardMinListRows, (height-len(pane.lines)-6)/2)
653 + relayLimit := m.relayRowsForHeight(tunnel, height)
654 m.renderRelaysSection(&pane, width, relayLimit, tunnel)
655 pane.addLine("")
710 - m.renderRouteSection(&pane, width, height, tunnel)
656 + m.renderRouteSection(&pane, width, max(1, height-len(pane.lines)), tunnel)
657 pane.clip(height)
658 return pane
659 }
660
661 +func (m agentDashboardModel) relayRowsForHeight(tunnel types.AgentTunnelStatus, height int) int {
662 + if len(tunnel.Relays) == 0 {
663 + return 0
664 + }
665 + routeRows := len(m.displayedRoute(tunnel))
666 + routeReserve := min(max(4, routeRows+3), 8)
667 + relayRows := height - routeReserve - 4
668 + if relayRows < agentDashboardMinRelayRows {
669 + relayRows = min(agentDashboardMinRelayRows, len(tunnel.Relays))
670 + }
671 + return min(relayRows, len(tunnel.Relays))
672 +}
673 +
674 func (m agentDashboardModel) renderRelaysSection(pane *agentDashboardView, width, maxRows int, tunnel types.AgentTunnelStatus) {
675 relay, hasRelay := m.selectedRelayStatus()
717 - attachDisabled := !hasRelay || relay.Explicit || relay.Banned
718 - deleteDisabled := !hasRelay || !relay.Explicit
676 + connectDisabled := !hasRelay || relay.Banned || relayDashboardActive(tunnel, relay)
677 + disconnectDisabled := !hasRelay || relay.Banned || !relayDashboardActive(tunnel, relay) || slices.Contains(m.displayedRoute(tunnel), relay.RelayURL)
678
679 pane.addStyled(width, agentDashboardSectionStyle, "Relays")
680 pane.addButtons(width,
722 - agentDashboardButton{label: "Attach", action: agentDashboardActionAttachRelay, disabled: attachDisabled},
723 - agentDashboardButton{label: "Add URL", action: agentDashboardActionAddRelay},
724 - agentDashboardButton{label: "Remove", action: agentDashboardActionDeleteRelay, disabled: deleteDisabled},
681 + agentDashboardButton{label: "Connect", action: agentDashboardActionConnectRelay, disabled: connectDisabled},
682 + agentDashboardButton{label: "Disconnect", action: agentDashboardActionDisconnectRelay, disabled: disconnectDisabled},
683 )
726 - if hasRelay {
727 - pane.addStyled(width, agentDashboardMutedStyle, "Selected: "+relay.RelayURL)
728 - }
729 - pane.addLine(agentDashboardMutedStyle.Render(agentDashboardRelayRow(width, "STATE", "ROLE", "FEATURES", "RELAY")))
684 + pane.addLine(agentDashboardMutedStyle.Render(agentDashboardRelayRow(width, "STATUS", "FEATURES", "TUNNEL URL")))
685
686 if len(tunnel.Relays) == 0 {
687 pane.addStyled(width, agentDashboardMutedStyle, "no relays")
@@ -736,45 +691,53 @@ func (m agentDashboardModel) renderRelaysSection(pane *agentDashboardView, width
691 if selectedRelayURL == "" && len(tunnel.Relays) > 0 {
692 selectedRelayURL = tunnel.Relays[0].RelayURL
693 }
739 - for i, relay := range tunnel.Relays {
740 - if i >= maxRows {
741 - pane.addStyled(width, agentDashboardMutedStyle, fmt.Sprintf("+ %d more", len(tunnel.Relays)-i))
742 - break
743 - }
744 - line := agentDashboardRelayRow(width,
745 - relayDashboardState(relay),
746 - relayDashboardRole(relay),
694 + selectedRelayIndex := m.selectedRelayIndex(tunnel)
695 + start, end := agentDashboardRelayWindow(selectedRelayIndex, len(tunnel.Relays), maxRows)
696 + rowWidth := width
697 + if len(tunnel.Relays) > maxRows && width > 1 {
698 + rowWidth = width - 1
699 + }
700 + for i := start; i < end; i++ {
701 + relay := tunnel.Relays[i]
702 + line := agentDashboardRelayRow(rowWidth,
703 + m.relayDashboardMode(tunnel, relay),
704 relayDashboardFeatures(relay),
748 - relay.RelayURL,
705 + relayDashboardURL(relay),
706 )
750 - pane.addClickRow(line, width, agentDashboardRelayStyle(relay.RelayURL == selectedRelayURL, relay), agentDashboardActionSelectRelay, tunnel.ID, relay.RelayURL)
707 + if rowWidth < width {
708 + line = agentDashboardFit(line, rowWidth) + agentDashboardScrollCell(i-start, start, len(tunnel.Relays), end-start)
709 + }
710 + pane.addClickRow(line, width, agentDashboardRelayStyle(relay.RelayURL == selectedRelayURL, tunnel, relay), agentDashboardActionOpenTunnelURL, tunnel.ID, relay.RelayURL)
711 }
712 }
713
714 func (m agentDashboardModel) renderRouteSection(pane *agentDashboardView, width, height int, tunnel types.AgentTunnelStatus) {
715 + if height <= 0 {
716 + return
717 + }
718 route := m.displayedRoute(tunnel)
719 relay, hasRelay := m.selectedRelayStatus()
720 inRoute := hasRelay && slices.Contains(route, relay.RelayURL)
721 canAdd := hasRelay && relay.SupportsOverlay && !inRoute
722
760 - pane.addStyled(width, agentDashboardSectionStyle, "Route")
723 + startLine := len(pane.lines)
724 + pane.addStyled(width, agentDashboardSectionStyle, "Multi-hop")
725 pane.addButtons(width,
726 agentDashboardButton{label: "Add Hop", action: agentDashboardActionAddHop, disabled: !canAdd},
763 - agentDashboardButton{label: "Remove Hop", action: agentDashboardActionRemoveHop, disabled: !inRoute},
727 agentDashboardButton{label: "Apply", action: agentDashboardActionApplyHop, disabled: len(route) < 2},
728 agentDashboardButton{label: "Clear", action: agentDashboardActionClearHop, disabled: len(route) == 0},
729 )
730
768 - routeLabel := "Route:"
731 + routeLabel := "Multi-hop:"
732 if m.draftTunnelID == tunnel.ID {
733 routeLabel += " draft"
734 }
735 if len(route) == 0 {
773 - routeLabel = "Route: none"
736 + routeLabel = "Multi-hop: none"
737 }
738 pane.addText(width, routeLabel)
739 for i, relayURL := range route {
777 - if len(pane.lines) >= height {
740 + if len(pane.lines)-startLine >= height {
741 return
742 }
743 pane.addText(width, fmt.Sprintf("%d. %s", i+1, relayURL))
@@ -799,30 +762,68 @@ func (v *agentDashboardView) addButtons(width int, buttons ...agentDashboardButt
762 v.regions = append(v.regions, regions...)
763 }
764
802 -func (v *agentDashboardView) addPanes(left, right agentDashboardView, leftWidth, gutter int) {
803 - startY := len(v.lines)
804 - height := max(len(left.lines), len(right.lines))
805 - for i := 0; i < height; i++ {
806 - leftLine := ""
807 - if i < len(left.lines) {
808 - leftLine = left.lines[i]
765 +func (v *agentDashboardView) addInputButton(width int, input string, buttons ...agentDashboardButton) {
766 + if width <= 0 {
767 + width = 1
768 + }
769 + line := agentDashboardFit(input, width)
770 + lineWidth := lipgloss.Width(line)
771 + y := len(v.lines)
772 + for _, button := range buttons {
773 + if lineWidth >= width {
774 + break
775 }
810 - rightLine := ""
811 - if i < len(right.lines) {
812 - rightLine = right.lines[i]
776 + if lineWidth > 0 {
777 + line += " "
778 + lineWidth++
779 }
814 - v.lines = append(v.lines, agentDashboardPadStyled(leftLine, leftWidth)+strings.Repeat(" ", gutter)+rightLine)
780 + buttonText := agentDashboardFit("[ "+button.label+" ]", width-lineWidth)
781 + buttonWidth := lipgloss.Width(buttonText)
782 + if buttonWidth == 0 {
783 + break
784 + }
785 + buttonStyle := agentDashboardButtonStyle
786 + if button.disabled {
787 + buttonStyle = agentDashboardDisabledStyle
788 + } else {
789 + v.regions = append(v.regions, agentDashboardRegion{
790 + x0: lineWidth,
791 + x1: lineWidth + buttonWidth,
792 + y: y,
793 + action: button.action,
794 + })
795 + }
796 + line += buttonStyle.Render(buttonText)
797 + lineWidth += buttonWidth
798 }
816 - for _, region := range left.regions {
799 + v.lines = append(v.lines, line)
800 +}
801 +
802 +func (v *agentDashboardView) addView(child agentDashboardView) {
803 + startY := len(v.lines)
804 + v.lines = append(v.lines, child.lines...)
805 + for _, region := range child.regions {
806 region.y += startY
807 v.regions = append(v.regions, region)
808 }
820 - for _, region := range right.regions {
821 - region.y += startY
822 - region.x0 += leftWidth + gutter
823 - region.x1 += leftWidth + gutter
824 - v.regions = append(v.regions, region)
809 +}
810 +
811 +func (v *agentDashboardView) addTunnelRow(width, rowWidth int, tunnel types.AgentTunnelStatus, selected bool) {
812 + if width <= 0 {
813 + width = 1
814 }
815 + rowWidth = min(rowWidth, max(1, width))
816 + line := agentDashboardTunnelRow(rowWidth, tunnel.State, tunnel.TargetAddr, tunnelDashboardName(tunnel))
817 + style := agentDashboardTunnelStyle(selected, tunnel.State)
818 + y := len(v.lines)
819 + v.lines = append(v.lines, style.Width(rowWidth).Render(agentDashboardFit(line, rowWidth)))
820 + v.regions = append(v.regions, agentDashboardRegion{
821 + x0: 0,
822 + x1: rowWidth,
823 + y: y,
824 + action: agentDashboardActionSelectTunnel,
825 + tunnel: tunnel.ID,
826 + })
827 }
828
829 func (v *agentDashboardView) addClickRow(line string, width int, style lipgloss.Style, action agentDashboardAction, tunnel, relay string) {
@@ -903,31 +904,6 @@ func agentDashboardRenderButtons(width, y, x int, buttons ...agentDashboardButto
904 return lines, regions
905 }
906
906 -func agentDashboardSizes(width, height int) (int, int, int) {
907 - if width <= 0 {
908 - width = 104
909 - }
910 -
911 - gutter := 2
912 - if width < 84 {
913 - leftWidth := min(max(width/2, 1), 40)
914 - if width >= 48 {
915 - leftWidth = max(leftWidth, 24)
916 - }
917 - rightWidth := width - leftWidth - gutter
918 - if rightWidth < 1 {
919 - rightWidth = 1
920 - leftWidth = max(1, width-gutter-rightWidth)
921 - }
922 - return leftWidth, rightWidth, defaultDashboardBodyHeight(height)
923 - }
924 -
925 - leftWidth := width / 3
926 - leftWidth = min(max(leftWidth, 40), 56)
927 - rightWidth := max(1, width-leftWidth-gutter)
928 - return leftWidth, rightWidth, defaultDashboardBodyHeight(height)
929 -}
930 -
907 func defaultDashboardBodyHeight(height int) int {
908 bodyHeight := height - 8
909 if height <= 0 {
@@ -945,66 +921,36 @@ func agentDashboardTunnelStyle(selected bool, state string) lipgloss.Style {
921 return agentDashboardOKStyle
922 case "error":
923 return agentDashboardErrorStyle
948 - case "starting":
949 - return agentDashboardMutedStyle
924 default:
951 - return lipgloss.NewStyle()
925 + return agentDashboardMutedStyle
926 }
927 }
928
955 -func agentDashboardRelayStyle(selected bool, relay types.AgentRelayStatus) lipgloss.Style {
929 +func agentDashboardRelayStyle(selected bool, tunnel types.AgentTunnelStatus, relay types.AgentRelayStatus) lipgloss.Style {
930 if selected {
931 return agentDashboardSelectedStyle
932 }
933 if relay.Banned {
934 return agentDashboardErrorStyle
935 }
962 - if relayDashboardInUse(relay) {
936 + if relayDashboardConnected(tunnel, relay) {
937 return agentDashboardOKStyle
938 }
939 return agentDashboardMutedStyle
940 }
941
968 -func relayDashboardState(relay types.AgentRelayStatus) string {
969 - switch {
970 - case relay.Banned:
971 - return "blocked"
972 - case relay.PublicURL != "":
973 - return "ready"
974 - case relay.Connecting:
975 - return "trying"
976 - case relay.Bootstrap:
977 - return "seed"
978 - default:
979 - return "known"
980 - }
981 -}
982 -
983 -func relayDashboardRole(relay types.AgentRelayStatus) string {
984 - switch {
985 - case relay.Banned:
986 - return "blocked"
987 - case relay.Explicit:
988 - return "pinned"
989 - case relayDashboardInUse(relay):
990 - return "attached"
991 - case relay.Connecting:
992 - return "trying"
993 - case relay.Bootstrap:
994 - return "seed"
995 - default:
996 - return "candidate"
997 - }
942 +func relayDashboardActive(tunnel types.AgentTunnelStatus, relay types.AgentRelayStatus) bool {
943 + return relayDashboardConnected(tunnel, relay) || relay.Connecting
944 }
945
1000 -func relayDashboardInUse(relay types.AgentRelayStatus) bool {
1001 - return relay.PublicURL != ""
946 +func relayDashboardConnected(tunnel types.AgentTunnelStatus, relay types.AgentRelayStatus) bool {
947 + return relay.PublicURL != "" || slices.Contains(tunnel.MultiHop, relay.RelayURL)
948 }
949
950 func relayDashboardFeatures(relay types.AgentRelayStatus) string {
951 var features []string
952 if relay.SupportsOverlay {
1007 - features = append(features, "hop")
953 + features = append(features, "overlay")
954 }
955 if relay.SupportsUDP {
956 features = append(features, "udp")
@@ -1018,53 +964,154 @@ func relayDashboardFeatures(relay types.AgentRelayStatus) string {
964 return strings.Join(features, ",")
965 }
966
1021 -func agentDashboardRelayRow(width int, state, role, features, relayURL string) string {
1022 - if width < 28 {
1023 - return agentDashboardFit(state+" "+relayURL, width)
967 +func relayDashboardURL(relay types.AgentRelayStatus) string {
968 + if publicURL := strings.TrimSpace(relay.PublicURL); publicURL != "" {
969 + return publicURL
970 }
1025 - if width < 48 {
1026 - stateW := 7
1027 - return agentDashboardCell(state, stateW) + " " + agentDashboardFit(relayURL, width-stateW-1)
971 + return relay.RelayURL
972 +}
973 +
974 +func (m agentDashboardModel) relayDashboardMode(tunnel types.AgentTunnelStatus, relay types.AgentRelayStatus) string {
975 + var modes []string
976 + if relay.PublicURL != "" || relay.Connecting {
977 + modes = append(modes, "direct")
978 + }
979 + for i, relayURL := range tunnel.MultiHop {
980 + if relayURL != relay.RelayURL {
981 + continue
982 + }
983 + if i == 0 {
984 + modes = append(modes, "hop-entry")
985 + } else {
986 + modes = append(modes, "hop-relay")
987 + }
988 + break
989 }
1029 - stateW := 8
1030 - roleW := 9
1031 - if width < 68 {
1032 - relayW := max(1, width-stateW-roleW-2)
990 + if len(modes) > 0 {
991 + return strings.Join(modes, ",")
992 + }
993 + return "-"
994 +}
995 +
996 +func tunnelDashboardName(tunnel types.AgentTunnelStatus) string {
997 + if strings.TrimSpace(tunnel.Name) != "" {
998 + return tunnel.Name
999 + }
1000 + return tunnel.ID
1001 +}
1002 +
1003 +func agentDashboardTunnelTableWidth(width int, tunnels []types.AgentTunnelStatus) int {
1004 + tableWidth := 56
1005 + for _, tunnel := range tunnels {
1006 + nameWidth := max(lipgloss.Width(tunnelDashboardName(tunnel)), lipgloss.Width("TUNNEL"))
1007 + tableWidth = max(tableWidth, 11+1+22+1+nameWidth)
1008 + }
1009 + return max(1, min(tableWidth, width))
1010 +}
1011 +
1012 +func agentDashboardTunnelRow(width int, state, target, name string) string {
1013 + if width < 28 {
1014 + return agentDashboardFit(state+" "+name, width)
1015 + }
1016 + if width < 56 {
1017 + stateW := 11
1018 return agentDashboardCell(state, stateW) + " " +
1034 - agentDashboardCell(role, roleW) + " " +
1035 - agentDashboardFit(relayURL, relayW)
1019 + agentDashboardFit(name, width-stateW-1)
1020 }
1037 - featuresW := 11
1038 - relayW := max(1, width-stateW-roleW-featuresW-3)
1021 + stateW := 11
1022 + targetW := 22
1023 + nameW := max(1, width-stateW-targetW-2)
1024 return agentDashboardCell(state, stateW) + " " +
1040 - agentDashboardCell(role, roleW) + " " +
1041 - agentDashboardCell(features, featuresW) + " " +
1042 - agentDashboardFit(relayURL, relayW)
1025 + agentDashboardCell(target, targetW) + " " +
1026 + agentDashboardFit(name, nameW)
1027 }
1028
1045 -func tunnelPublicURL(tunnel types.AgentTunnelStatus) string {
1046 - for _, relay := range tunnel.Relays {
1047 - if strings.TrimSpace(relay.PublicURL) != "" {
1048 - return relay.PublicURL
1049 - }
1029 +func agentDashboardRelayWindow(selected, total, rows int) (int, int) {
1030 + if total <= 0 || rows <= 0 {
1031 + return 0, 0
1032 }
1051 - return "-"
1033 + if rows >= total {
1034 + return 0, total
1035 + }
1036 + if selected < 0 {
1037 + selected = 0
1038 + }
1039 + if selected >= total {
1040 + selected = total - 1
1041 + }
1042 + start := selected - rows/2
1043 + if start < 0 {
1044 + start = 0
1045 + }
1046 + if start+rows > total {
1047 + start = total - rows
1048 + }
1049 + return start, start + rows
1050 }
1051
1054 -func valueOrDash(value string) string {
1055 - value = strings.TrimSpace(value)
1056 - if value == "" {
1057 - return "-"
1052 +func agentDashboardScrollCell(row, start, total, visible int) string {
1053 + if total <= visible || visible <= 0 {
1054 + return " "
1055 + }
1056 + thumbSize := max(1, visible*visible/total)
1057 + thumbStart := 0
1058 + if total > visible {
1059 + thumbStart = start * (visible - thumbSize) / (total - visible)
1060 }
1059 - return value
1061 + if row >= thumbStart && row < thumbStart+thumbSize {
1062 + return "#"
1063 + }
1064 + return "|"
1065 }
1066
1062 -func truncateDashboardValue(value string, maxLength int) string {
1063 - value = strings.TrimSpace(value)
1064 - if value == "" {
1065 - return "-"
1067 +func agentDashboardRelayRow(width int, mode, features, displayURL string) string {
1068 + if width < 28 {
1069 + return agentDashboardFit(mode+" "+displayURL, width)
1070 }
1067 - return agentDashboardFit(value, maxLength)
1071 + if width < 56 {
1072 + modeW := 16
1073 + return agentDashboardCell(mode, modeW) + " " +
1074 + agentDashboardFit(displayURL, width-modeW-1)
1075 + }
1076 + modeW := 16
1077 + featuresW := 15
1078 + relayW := max(1, width-modeW-featuresW-2)
1079 + return agentDashboardCell(mode, modeW) + " " +
1080 + agentDashboardCell(features, featuresW) + " " +
1081 + agentDashboardFit(displayURL, relayW)
1082 +}
1083 +
1084 +func openDashboardURL(rawURL string) error {
1085 + rawURL = strings.TrimSpace(rawURL)
1086 + parsed, err := url.Parse(rawURL)
1087 + if err != nil {
1088 + return err
1089 + }
1090 + switch strings.ToLower(parsed.Scheme) {
1091 + case "http", "https":
1092 + default:
1093 + return fmt.Errorf("unsupported url scheme %q", parsed.Scheme)
1094 + }
1095 + if strings.TrimSpace(parsed.Host) == "" {
1096 + return fmt.Errorf("url host is required")
1097 + }
1098 +
1099 + var cmd *exec.Cmd
1100 + switch runtime.GOOS {
1101 + case "windows":
1102 + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", rawURL)
1103 + case "darwin":
1104 + cmd = exec.Command("open", rawURL)
1105 + default:
1106 + cmd = exec.Command("xdg-open", rawURL)
1107 + }
1108 + if err := cmd.Start(); err != nil {
1109 + return err
1110 + }
1111 + go func() {
1112 + _ = cmd.Wait()
1113 + }()
1114 + return nil
1115 }
1116
1117 func agentDashboardFit(value string, width int) string {
@@ -1098,10 +1145,3 @@ func agentDashboardCell(value string, width int) string {
1145 }
1146 return value + strings.Repeat(" ", width-lipgloss.Width(value))
1147 }
1101 -
1102 -func agentDashboardPadStyled(value string, width int) string {
1103 - if lipgloss.Width(value) >= width {
1104 - return value
1105 - }
1106 - return value + strings.Repeat(" ", width-lipgloss.Width(value))
1107 -}
cmd/portal-tunnel/agent/manager.go
+107 -44
@@ -96,37 +96,38 @@ func (m *manager) Stop(ctx context.Context) error {
96 }
97 }
98
99 -func (m *manager) AddRelay(id, relayURL string) error {
100 - relayURL, err := utils.NormalizeRelayURL(relayURL)
101 - if err != nil {
99 +func (m *manager) ConnectRelay(id, relayURL string) error {
100 + id = strings.TrimSpace(id)
101 + if err := validateAgentPathComponent("tunnel id", id); err != nil {
102 return err
103 }
104 - return m.updateTunnelConfig(id, func(tunnel *TunnelConfig) error {
105 - relayURLs, err := utils.MergeRelayURLs(tunnel.RelayURLs, nil, []string{relayURL})
106 - if err != nil {
107 - return err
108 - }
109 - tunnel.RelayURLs = relayURLs
110 - return nil
111 - })
104 +
105 + m.mu.RLock()
106 + tunnel := m.tunnels[id]
107 + m.mu.RUnlock()
108 + if tunnel == nil {
109 + return fmt.Errorf("tunnel %q not found", id)
110 + }
111 + return tunnel.ConnectRelay(relayURL)
112 }
113
114 -func (m *manager) RemoveRelay(id, relayURL string) error {
115 - relayURL, err := utils.NormalizeRelayURL(relayURL)
116 - if err != nil {
114 +func (m *manager) DisconnectRelay(id, relayURL string) error {
115 + id = strings.TrimSpace(id)
116 + if err := validateAgentPathComponent("tunnel id", id); err != nil {
117 return err
118 }
119 - return m.updateTunnelConfig(id, func(tunnel *TunnelConfig) error {
120 - relayURLs, err := utils.NormalizeRelayURLs(tunnel.RelayURLs...)
121 - if err != nil {
122 - return err
123 - }
124 - tunnel.RelayURLs = utils.RemoveRelayURL(relayURLs, relayURL)
125 - return removeRelayFromTunnelRoute(tunnel, relayURL)
126 - })
119 +
120 + m.mu.RLock()
121 + tunnel := m.tunnels[id]
122 + m.mu.RUnlock()
123 + if tunnel == nil {
124 + return fmt.Errorf("tunnel %q not found", id)
125 + }
126 + return tunnel.DisconnectRelay(relayURL)
127 }
128
129 func (m *manager) SetMultiHop(id string, relayURLs []string) error {
130 + id = strings.TrimSpace(id)
131 multiHop, err := utils.NormalizeRelayURLs(relayURLs...)
132 if err != nil {
133 return fmt.Errorf("normalize multi-hop relay url: %w", err)
@@ -137,11 +138,21 @@ func (m *manager) SetMultiHop(id string, relayURLs []string) error {
138 if len(multiHop) == 1 {
139 return errors.New("multi-hop requires at least entry and exit relay urls")
140 }
140 - return m.updateTunnelConfig(id, func(tunnel *TunnelConfig) error {
141 + if err := m.updateTunnelConfig(id, func(tunnel *TunnelConfig) error {
142 tunnel.MultiHop = append([]string(nil), multiHop...)
143 tunnel.MultiHopDepth = 0
144 return nil
144 - })
145 + }); err != nil {
146 + return err
147 + }
148 +
149 + m.mu.RLock()
150 + tunnel := m.tunnels[id]
151 + m.mu.RUnlock()
152 + if tunnel == nil {
153 + return fmt.Errorf("tunnel %q not found", id)
154 + }
155 + return tunnel.SetMultiHop(multiHop)
156 }
157
158 func (m *manager) AddTunnel(req types.AgentTunnelRequest) error {
@@ -231,27 +242,26 @@ func (m *manager) updateTunnelConfig(id string, update func(*TunnelConfig) error
242 if reflect.DeepEqual(before, cfg.Tunnels[index]) {
243 return nil
244 }
234 - return m.writeConfigAndApply(path, mode, cfg)
235 -}
236 -
237 -func removeRelayFromTunnelRoute(tunnel *TunnelConfig, relayURL string) error {
238 - if len(tunnel.MultiHop) == 0 {
239 - return nil
245 + cfg.sourcePath = path
246 + if err := cfg.ApplyDefaults(path); err != nil {
247 + return err
248 }
241 - multiHop, err := utils.NormalizeRelayURLs(tunnel.MultiHop...)
242 - if err != nil {
243 - return fmt.Errorf("normalize multi-hop relay url: %w", err)
249 + if err := cfg.Validate(); err != nil {
250 + return err
251 }
245 - nextMultiHop := utils.RemoveRelayURL(multiHop, relayURL)
246 - if len(nextMultiHop) == len(multiHop) {
247 - tunnel.MultiHop = nextMultiHop
248 - return nil
252 + if err := writeConfigDocument(path, mode, cfg); err != nil {
253 + return err
254 }
250 - if len(nextMultiHop) < 2 {
251 - nextMultiHop = nil
255 +
256 + nextTunnelCfg := cfg.Tunnels[index]
257 + m.mu.Lock()
258 + m.cfg = cfg
259 + if tunnel := m.tunnels[id]; tunnel != nil {
260 + tunnel.mu.Lock()
261 + tunnel.cfg = nextTunnelCfg
262 + tunnel.mu.Unlock()
263 }
253 - tunnel.MultiHop = nextMultiHop
254 - tunnel.MultiHopDepth = 0
264 + m.mu.Unlock()
265 return nil
266 }
267
@@ -267,9 +277,6 @@ func (m *manager) DeleteTunnel(id string) error {
277 if err != nil {
278 return err
279 }
270 - if len(cfg.Tunnels) <= 1 {
271 - return errors.New("cannot delete the last tunnel")
272 - }
280
281 index := slices.IndexFunc(cfg.Tunnels, func(tunnel TunnelConfig) bool { return tunnel.ID == id })
282 if index < 0 {
@@ -388,6 +395,7 @@ type managedTunnel struct {
395 done chan struct{}
396 exposure *sdk.Exposure
397 lastError string
398 + runtime types.AgentTunnelStatus
399 }
400
401 func newTunnel(cfg TunnelConfig) *managedTunnel {
@@ -434,12 +442,43 @@ func (t *managedTunnel) Stop(ctx context.Context) error {
442 }
443 }
444
445 +func (t *managedTunnel) ConnectRelay(relayURL string) error {
446 + t.mu.RLock()
447 + exposure := t.exposure
448 + t.mu.RUnlock()
449 + if exposure == nil {
450 + return nil
451 + }
452 + return exposure.AddRelay(relayURL)
453 +}
454 +
455 +func (t *managedTunnel) DisconnectRelay(relayURL string) error {
456 + t.mu.RLock()
457 + exposure := t.exposure
458 + t.mu.RUnlock()
459 + if exposure == nil {
460 + return nil
461 + }
462 + return exposure.RemoveRelay(relayURL)
463 +}
464 +
465 +func (t *managedTunnel) SetMultiHop(relayURLs []string) error {
466 + t.mu.RLock()
467 + exposure := t.exposure
468 + t.mu.RUnlock()
469 + if exposure == nil {
470 + return nil
471 + }
472 + return exposure.SetMultiHop(relayURLs)
473 +}
474 +
475 func (t *managedTunnel) Snapshot() types.AgentTunnelStatus {
476 t.mu.RLock()
477 cfg := t.cfg
478 lastError := t.lastError
479 exposure := t.exposure
480 done := t.done
481 + runtime := t.runtime
482 t.mu.RUnlock()
483
484 running := false
@@ -467,11 +506,29 @@ func (t *managedTunnel) Snapshot() types.AgentTunnelStatus {
506 State: state,
507 TargetAddr: cfg.TargetAddr,
508 LastError: lastError,
509 + MultiHop: append([]string(nil), cfg.MultiHop...),
510 }
511 if exposure == nil {
512 + if strings.TrimSpace(runtime.TargetAddr) != "" {
513 + status.TargetAddr = runtime.TargetAddr
514 + }
515 + if cfg.MultiHopDepth > 1 && len(runtime.MultiHop) > 0 {
516 + status.MultiHop = append([]string(nil), runtime.MultiHop...)
517 + }
518 + status.Relays = append([]types.AgentRelayStatus(nil), runtime.Relays...)
519 return status
520 }
521 snapshot := exposure.Snapshot()
522 + t.mu.Lock()
523 + if t.exposure == exposure {
524 + t.runtime = types.AgentTunnelStatus{
525 + TargetAddr: snapshot.TargetAddr,
526 + MultiHop: append([]string(nil), snapshot.MultiHop...),
527 + Relays: append([]types.AgentRelayStatus(nil), snapshot.Relays...),
528 + }
529 + }
530 + t.mu.Unlock()
531 +
532 status.TargetAddr = snapshot.TargetAddr
533 status.MultiHop = append([]string(nil), snapshot.MultiHop...)
534 status.Relays = append([]types.AgentRelayStatus(nil), snapshot.Relays...)
@@ -540,8 +597,14 @@ func (t *managedTunnel) runOnce(ctx context.Context) error {
597 if err != nil {
598 return err
599 }
600 + snapshot := exposure.Snapshot()
601 t.mu.Lock()
602 t.exposure = exposure
603 + t.runtime = types.AgentTunnelStatus{
604 + TargetAddr: snapshot.TargetAddr,
605 + MultiHop: append([]string(nil), snapshot.MultiHop...),
606 + Relays: append([]types.AgentRelayStatus(nil), snapshot.Relays...),
607 + }
608 t.lastError = ""
609 t.mu.Unlock()
610
portal/discovery/relayset.go
+15
@@ -484,6 +484,21 @@ func (s *RelaySet) UnconfirmRelayURL(relayURL string) {
484 s.relays[relayURL] = state
485 }
486
487 +// DeactivateRelayURL drops a relay out of active selection while keeping its
488 +// discovered descriptor as a candidate.
489 +func (s *RelaySet) DeactivateRelayURL(relayURL string) {
490 + s.mu.Lock()
491 + defer s.mu.Unlock()
492 +
493 + state, ok := s.relays[relayURL]
494 + if !ok {
495 + return
496 + }
497 + state = s.policy.OnUnconfirmed(state)
498 + state.suppressActiveUntil = time.Now().Add(defaultDirectRecoveryBackoff)
499 + s.relays[relayURL] = state
500 +}
501 +
502 func (s *RelaySet) ApplyRelayDiscoveryResponse(targetURL string, resp types.DiscoveryResponse, now time.Time) (relaySetChanged bool, err error) {
503 if now.IsZero() {
504 now = time.Now().UTC()
sdk/expose.go
+7 -16
@@ -219,8 +219,8 @@ func (e *Exposure) AddRelay(relayURL string) error {
219 return e.reconcileRelayListeners(true)
220 }
221
222 -// RemoveRelay detaches a relay from the running exposure and suppresses
223 -// auto-selection for that relay until it is added again.
222 +// RemoveRelay detaches a relay from the running exposure and lets it fall back
223 +// to the discovered candidate pool.
224 func (e *Exposure) RemoveRelay(relayURL string) error {
225 relayURL, err := utils.NormalizeRelayURL(relayURL)
226 if err != nil {
@@ -234,6 +234,10 @@ func (e *Exposure) RemoveRelay(relayURL string) error {
234 }
235
236 e.listenerMu.Lock()
237 + if slices.Contains(e.multiHop, relayURL) {
238 + e.listenerMu.Unlock()
239 + return errors.New("relay is part of the multi-hop route; clear multi-hop first")
240 + }
241 nextRelays := make([]string, 0, len(e.explicitRelays))
242 for _, existing := range e.explicitRelays {
243 if existing != relayURL {
@@ -241,22 +245,9 @@ func (e *Exposure) RemoveRelay(relayURL string) error {
245 }
246 }
247 e.explicitRelays = nextRelays
244 - if slices.Contains(e.multiHop, relayURL) {
245 - nextMultiHop := make([]string, 0, len(e.multiHop))
246 - for _, existing := range e.multiHop {
247 - if existing != relayURL {
248 - nextMultiHop = append(nextMultiHop, existing)
249 - }
250 - }
251 - if len(nextMultiHop) < 2 {
252 - nextMultiHop = nil
253 - }
254 - e.multiHop = nextMultiHop
255 - e.multiHopDepth = 0
256 - }
248 e.listenerMu.Unlock()
249
259 - e.relaySet.BanRelayURL(relayURL)
250 + e.relaySet.DeactivateRelayURL(relayURL)
251 e.relaySet.RemoveBootstrapRelayURL(relayURL)
252 return e.reconcileRelayListeners(false)
253 }
sdk/expose_test.go
+5 -1
@@ -161,7 +161,11 @@ func TestExposureRemoveRelayStopsRunningListener(t *testing.T) {
161 if len(exposure.explicitRelays) != 0 {
162 t.Fatalf("explicitRelays = %v, want empty", exposure.explicitRelays)
163 }
164 - if got := exposure.relaySet.PriorityRelays(discovery.ClientState{ExplicitRelayURLs: []string{relayA}}); len(got) != 0 {
164 + if got := exposure.relaySet.PriorityRelays(discovery.ClientState{}); len(got) != 0 {
165 t.Fatalf("PriorityRelays() = %v, want empty", got)
166 }
167 + relays := exposure.relaySet.AllRelays()
168 + if len(relays) != 1 || relays[0].Descriptor.APIHTTPSAddr != relayA || relays[0].Banned {
169 + t.Fatalf("AllRelays() = %+v, want unbanned candidate %q", relays, relayA)
170 + }
171 }