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
}