Refactor tunnel management and exposure handling

rabbitprincess committed May 2, 2026 at 21:54 UTC eb61d95536ae89458452095fb98fc20a05e37645
7 files changed +420 -637
cmd/portal-tunnel/agent.go
+8 -8
@@ -129,7 +129,7 @@ func runAgentForeground(configPath string, cfg agent.Config) error {
129 }()
130
131 readyCtx, readyCancel := context.WithTimeout(ctx, 15*time.Second)
132 - status, err := waitAgentStatusOrExit(readyCtx, cfg.Agent.StateDir, errCh)
132 + err = waitAgentStatusOrExit(readyCtx, cfg.Agent.StateDir, errCh)
133 readyCancel()
134 if err != nil {
135 stop()
@@ -149,7 +149,7 @@ func runAgentForeground(configPath string, cfg agent.Config) error {
149 return runErr
150 }
151
152 - fmt.Fprintf(os.Stdout, "Portal agent stopped after managing %d tunnel(s).\n", len(status.Tunnels))
152 + fmt.Fprintln(os.Stdout, "Portal agent stopped.")
153 return nil
154 }
155
@@ -238,7 +238,7 @@ func waitAgentStatus(ctx context.Context, stateDir string) (types.AgentStatusRes
238 }
239 }
240
241 -func waitAgentStatusOrExit(ctx context.Context, stateDir string, errCh <-chan error) (types.AgentStatusResponse, error) {
241 +func waitAgentStatusOrExit(ctx context.Context, stateDir string, errCh <-chan error) error {
242 ticker := time.NewTicker(300 * time.Millisecond)
243 defer ticker.Stop()
244 var lastErr error
@@ -248,13 +248,13 @@ func waitAgentStatusOrExit(ctx context.Context, stateDir string, errCh <-chan er
248 if err == nil {
249 err = errors.New("portal agent stopped before dashboard was ready")
250 }
251 - return types.AgentStatusResponse{}, err
251 + return err
252 default:
253 }
254
255 - status, err := agent.Status(ctx, stateDir)
255 + _, err := agent.Status(ctx, stateDir)
256 if err == nil {
257 - return status, nil
257 + return nil
258 }
259 lastErr = err
260 select {
@@ -262,9 +262,9 @@ func waitAgentStatusOrExit(ctx context.Context, stateDir string, errCh <-chan er
262 if err == nil {
263 err = errors.New("portal agent stopped before dashboard was ready")
264 }
265 - return types.AgentStatusResponse{}, err
265 + return err
266 case <-ctx.Done():
267 - return types.AgentStatusResponse{}, fmt.Errorf("wait for portal agent status: %w", lastErr)
267 + return fmt.Errorf("wait for portal agent status: %w", lastErr)
268 case <-ticker.C:
269 }
270 }
cmd/portal-tunnel/agent/control.go
+2 -4
@@ -19,10 +19,8 @@ const (
19 )
20
21 type endpoint struct {
22 - ControlAddr string `json:"control_addr"`
23 - Token string `json:"token"`
24 - PID int `json:"pid"`
25 - UpdatedAt time.Time `json:"updated_at"`
22 + ControlAddr string `json:"control_addr"`
23 + Token string `json:"token"`
24 }
25
26 type controlHandler struct {
cmd/portal-tunnel/agent/dashboard.go
+308 -451
@@ -15,7 +15,10 @@ import (
15 "github.com/gosuda/portal-tunnel/v2/types"
16 )
17
18 -const agentDashboardPollInterval = 2 * time.Second
18 +const (
19 + agentDashboardPollInterval = 2 * time.Second
20 + agentDashboardMinListRows = 10
21 +)
22
23 type agentDashboardMode int
24
@@ -25,40 +28,37 @@ const (
28 agentDashboardAddRelayMode
29 )
30
28 -type agentDashboardClick int
31 +type agentDashboardAction int
32
33 const (
31 - agentDashboardClickTunnel agentDashboardClick = iota + 1
32 - agentDashboardClickRelay
33 - agentDashboardClickAddTunnel
34 - agentDashboardClickDeleteTunnel
35 - agentDashboardClickAddRelay
36 - agentDashboardClickDeleteRelay
37 - agentDashboardClickAttachRelay
38 - agentDashboardClickSeedRelay
39 - agentDashboardClickAddHop
40 - agentDashboardClickRemoveHop
41 - agentDashboardClickApplyHop
42 - agentDashboardClickClearHop
34 + agentDashboardActionSelectTunnel agentDashboardAction = iota + 1
35 + agentDashboardActionSelectRelay
36 + agentDashboardActionAddTunnel
37 + agentDashboardActionDeleteTunnel
38 + agentDashboardActionAddRelay
39 + agentDashboardActionDeleteRelay
40 + agentDashboardActionAttachRelay
41 + agentDashboardActionDetachRelay
42 + agentDashboardActionAddHop
43 + agentDashboardActionRemoveHop
44 + agentDashboardActionApplyHop
45 + agentDashboardActionClearHop
46 )
47
48 type agentDashboardModel struct {
49 configPath string
50 stateDir string
51
49 - status types.AgentStatusResponse
50 - err error
51 - message string
52 + status types.AgentStatusResponse
53 + err error
54
55 width int
56 height int
57
56 - selectedTunnel int
57 - selectedRelay int
58 selectedTunnelID string
59 selectedRelayURL string
60
61 - multiHopDraft []string
61 + routeDraft []string
62 draftTunnelID string
63
64 mode agentDashboardMode
@@ -71,35 +71,29 @@ type agentDashboardStatusMsg struct {
71 }
72
73 type agentDashboardActionMsg struct {
74 - message string
75 - err error
74 + err error
75 }
76
78 -type agentDashboardTickMsg time.Time
77 +type agentDashboardTickMsg struct{}
78
80 -type agentDashboardClickRegion struct {
79 +type agentDashboardRegion struct {
80 x0 int
81 x1 int
82 y int
84 - action agentDashboardClick
85 - tunnel int
86 - relay int
87 -}
88 -
89 -type agentDashboardLayout struct {
90 - lines []string
91 - regions []agentDashboardClickRegion
83 + action agentDashboardAction
84 + tunnel string
85 + relay string
86 }
87
88 type agentDashboardButton struct {
89 label string
96 - action agentDashboardClick
90 + action agentDashboardAction
91 disabled bool
92 }
93
100 -type agentDashboardPane struct {
94 +type agentDashboardView struct {
95 lines []string
102 - regions []agentDashboardClickRegion
96 + regions []agentDashboardRegion
97 }
98
99 var (
@@ -110,7 +104,6 @@ var (
104 agentDashboardButtonStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("230")).Background(lipgloss.Color("238"))
105 agentDashboardDisabledStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
106 agentDashboardErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
113 - agentDashboardMessageStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214"))
107 agentDashboardOKStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("120"))
108 agentDashboardInputStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("120"))
109 )
@@ -151,15 +144,6 @@ func (m agentDashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
144 return m, nil
145 case agentDashboardActionMsg:
146 m.err = msg.err
154 - if msg.err != nil {
155 - m.message = msg.err.Error()
156 - } else {
157 - m.message = msg.message
158 - if msg.message == "multi-hop applied" || msg.message == "multi-hop cleared" {
159 - m.multiHopDraft = nil
160 - m.draftTunnelID = ""
161 - }
162 - }
147 return m, agentDashboardFetchStatus(m.stateDir)
148 case tea.KeyMsg:
149 return m.updateKeys(msg)
@@ -191,33 +175,29 @@ func (m agentDashboardModel) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
175 case "ctrl+c":
176 return m, tea.Quit
177 case "up", "k":
194 - m.selectPreviousTunnel()
178 + m.selectTunnelOffset(-1)
179 case "down", "j":
196 - m.selectNextTunnel()
180 + m.selectTunnelOffset(1)
181 case "left", "h":
198 - m.selectPreviousRelay()
182 + m.selectRelayOffset(-1)
183 case "right", "l":
200 - m.selectNextRelay()
184 + m.selectRelayOffset(1)
185 case "n":
202 - return m.startInput(agentDashboardAddTunnelMode, "New tunnel: ", "name port")
186 + return m.runAction(agentDashboardActionAddTunnel, "", "")
187 case "x":
204 - return m.deleteSelectedTunnel()
188 + return m.runAction(agentDashboardActionDeleteTunnel, "", "")
189 case "a":
206 - if _, ok := m.selectedTunnelStatus(); !ok {
207 - m.message = "select a tunnel first"
208 - return m, nil
209 - }
210 - return m.startInput(agentDashboardAddRelayMode, "Add relay: ", "https://relay.example.com")
190 + return m.runAction(agentDashboardActionAddRelay, "", "")
191 case "d":
212 - return m.deleteSelectedRelay()
192 + return m.runAction(agentDashboardActionDeleteRelay, "", "")
193 case "m":
214 - return m.addSelectedHop()
194 + return m.runAction(agentDashboardActionAddHop, "", "")
195 case "u":
216 - return m.removeSelectedHop()
196 + return m.runAction(agentDashboardActionRemoveHop, "", "")
197 case "p":
218 - return m.applyMultiHop()
198 + return m.runAction(agentDashboardActionApplyHop, "", "")
199 case "c":
220 - return m.clearMultiHop()
200 + return m.runAction(agentDashboardActionClearHop, "", "")
201 }
202 return m, nil
203 }
@@ -226,10 +206,10 @@ func (m agentDashboardModel) updateMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd)
206 event := tea.MouseEvent(msg)
207 switch event.Button {
208 case tea.MouseButtonWheelUp:
229 - m.selectPreviousTunnel()
209 + m.selectTunnelOffset(-1)
210 return m, nil
211 case tea.MouseButtonWheelDown:
232 - m.selectNextTunnel()
212 + m.selectTunnelOffset(1)
213 return m, nil
214 }
215 if event.Action != tea.MouseActionPress || event.Button != tea.MouseButtonLeft {
@@ -237,46 +217,48 @@ func (m agentDashboardModel) updateMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd)
217 }
218 for _, region := range m.layout().regions {
219 if event.Y == region.y && event.X >= region.x0 && event.X < region.x1 {
240 - return m.applyClick(region)
220 + return m.runAction(region.action, region.tunnel, region.relay)
221 }
222 }
223 return m, nil
224 }
225
246 -func (m agentDashboardModel) applyClick(region agentDashboardClickRegion) (tea.Model, tea.Cmd) {
247 - switch region.action {
248 - case agentDashboardClickTunnel:
249 - if region.tunnel >= 0 && region.tunnel < len(m.status.Tunnels) {
250 - m.selectTunnel(region.tunnel)
226 +func (m agentDashboardModel) runAction(action agentDashboardAction, tunnelID, relayURL string) (tea.Model, tea.Cmd) {
227 + switch action {
228 + case agentDashboardActionSelectTunnel:
229 + if tunnelID != "" {
230 + m.selectTunnel(tunnelID)
231 + }
232 + case agentDashboardActionSelectRelay:
233 + if tunnelID != "" {
234 + m.selectTunnel(tunnelID)
235 }
252 - case agentDashboardClickRelay:
253 - if tunnel, ok := m.selectedTunnelStatus(); ok && region.relay >= 0 && region.relay < len(tunnel.Relays) {
254 - m.selectRelay(region.relay, tunnel.Relays[region.relay].RelayURL)
236 + if relayURL != "" {
237 + m.selectRelay(relayURL)
238 }
256 - case agentDashboardClickAddTunnel:
239 + case agentDashboardActionAddTunnel:
240 return m.startInput(agentDashboardAddTunnelMode, "New tunnel: ", "name port")
258 - case agentDashboardClickDeleteTunnel:
241 + case agentDashboardActionDeleteTunnel:
242 return m.deleteSelectedTunnel()
260 - case agentDashboardClickAddRelay:
243 + case agentDashboardActionAddRelay:
244 if _, ok := m.selectedTunnelStatus(); !ok {
262 - m.message = "select a tunnel first"
245 return m, nil
246 }
247 return m.startInput(agentDashboardAddRelayMode, "Add relay: ", "https://relay.example.com")
266 - case agentDashboardClickDeleteRelay:
248 + case agentDashboardActionDeleteRelay:
249 return m.deleteSelectedRelay()
268 - case agentDashboardClickAttachRelay:
250 + case agentDashboardActionAttachRelay:
251 return m.attachSelectedRelay()
270 - case agentDashboardClickSeedRelay:
271 - return m.seedSelectedRelay()
272 - case agentDashboardClickAddHop:
252 + case agentDashboardActionDetachRelay:
253 + return m.detachSelectedRelay()
254 + case agentDashboardActionAddHop:
255 return m.addSelectedHop()
274 - case agentDashboardClickRemoveHop:
256 + case agentDashboardActionRemoveHop:
257 return m.removeSelectedHop()
276 - case agentDashboardClickApplyHop:
277 - return m.applyMultiHop()
278 - case agentDashboardClickClearHop:
279 - return m.clearMultiHop()
258 + case agentDashboardActionApplyHop:
259 + return m.applyRoute()
260 + case agentDashboardActionClearHop:
261 + return m.clearRoute()
262 }
263 return m, nil
264 }
@@ -295,123 +277,112 @@ func (m agentDashboardModel) View() string {
277 return strings.Join(lines, "\n")
278 }
279
298 -func (m *agentDashboardModel) selectTunnel(index int) {
280 +func (m agentDashboardModel) selectedTunnelIndex() int {
281 + if len(m.status.Tunnels) == 0 {
282 + return -1
283 + }
284 + for i, tunnel := range m.status.Tunnels {
285 + if tunnel.ID == m.selectedTunnelID {
286 + return i
287 + }
288 + }
289 + return 0
290 +}
291 +
292 +func (m *agentDashboardModel) selectTunnel(id string) {
293 + for i, tunnel := range m.status.Tunnels {
294 + if tunnel.ID == id {
295 + m.selectTunnelIndex(i)
296 + return
297 + }
298 + }
299 +}
300 +
301 +func (m *agentDashboardModel) selectTunnelIndex(index int) {
302 if index < 0 || index >= len(m.status.Tunnels) {
303 return
304 }
302 - m.selectedTunnel = index
305 m.selectedTunnelID = m.status.Tunnels[index].ID
304 - m.selectedRelay = 0
306 m.selectedRelayURL = ""
307 if len(m.status.Tunnels[index].Relays) > 0 {
308 m.selectedRelayURL = m.status.Tunnels[index].Relays[0].RelayURL
309 }
310 }
311
311 -func (m *agentDashboardModel) selectPreviousTunnel() {
312 - if m.selectedTunnel > 0 {
313 - m.selectTunnel(m.selectedTunnel - 1)
314 - }
315 -}
316 -
317 -func (m *agentDashboardModel) selectNextTunnel() {
318 - if m.selectedTunnel+1 < len(m.status.Tunnels) {
319 - m.selectTunnel(m.selectedTunnel + 1)
312 +func (m *agentDashboardModel) selectTunnelOffset(delta int) {
313 + index := m.selectedTunnelIndex()
314 + next := index + delta
315 + if next >= 0 && next < len(m.status.Tunnels) {
316 + m.selectTunnelIndex(next)
317 }
318 }
319
323 -func (m *agentDashboardModel) selectRelay(index int, relayURL string) {
324 - m.selectedRelay = index
320 +func (m *agentDashboardModel) selectRelay(relayURL string) {
321 m.selectedRelayURL = relayURL
322 }
323
328 -func (m *agentDashboardModel) selectPreviousRelay() {
324 +func (m *agentDashboardModel) selectRelayOffset(delta int) {
325 tunnel, ok := m.selectedTunnelStatus()
330 - if !ok || len(tunnel.Relays) == 0 || m.selectedRelay <= 0 {
326 + index := m.selectedRelayIndex(tunnel)
327 + next := index + delta
328 + if !ok || next < 0 || next >= len(tunnel.Relays) {
329 return
330 }
333 - m.selectRelay(m.selectedRelay-1, tunnel.Relays[m.selectedRelay-1].RelayURL)
334 -}
335 -
336 -func (m *agentDashboardModel) selectNextRelay() {
337 - tunnel, ok := m.selectedTunnelStatus()
338 - if !ok || m.selectedRelay+1 >= len(tunnel.Relays) {
339 - return
340 - }
341 - m.selectRelay(m.selectedRelay+1, tunnel.Relays[m.selectedRelay+1].RelayURL)
331 + m.selectRelay(tunnel.Relays[next].RelayURL)
332 }
333
334 func (m *agentDashboardModel) clampSelection() {
335 if len(m.status.Tunnels) == 0 {
346 - m.selectedTunnel = 0
347 - m.selectedRelay = 0
336 m.selectedTunnelID = ""
337 m.selectedRelayURL = ""
338 return
339 }
340
353 - if m.selectedTunnelID != "" {
354 - found := false
355 - for i, tunnel := range m.status.Tunnels {
356 - if tunnel.ID == m.selectedTunnelID {
357 - m.selectedTunnel = i
358 - found = true
359 - break
360 - }
361 - }
362 - if !found {
363 - m.selectedTunnel = 0
364 - }
365 - }
366 - if m.selectedTunnel < 0 {
367 - m.selectedTunnel = 0
368 - }
369 - if m.selectedTunnel >= len(m.status.Tunnels) {
370 - m.selectedTunnel = len(m.status.Tunnels) - 1
371 - }
372 - m.selectedTunnelID = m.status.Tunnels[m.selectedTunnel].ID
341 + tunnelIndex := m.selectedTunnelIndex()
342 + m.selectedTunnelID = m.status.Tunnels[tunnelIndex].ID
343
374 - relays := m.status.Tunnels[m.selectedTunnel].Relays
344 + relays := m.status.Tunnels[tunnelIndex].Relays
345 if len(relays) == 0 {
376 - m.selectedRelay = 0
346 m.selectedRelayURL = ""
347 return
348 }
349 if m.selectedRelayURL != "" {
381 - found := false
382 - for i, relay := range relays {
350 + for _, relay := range relays {
351 if relay.RelayURL == m.selectedRelayURL {
384 - m.selectedRelay = i
385 - found = true
386 - break
352 + return
353 }
354 }
389 - if !found {
390 - m.selectedRelay = 0
391 - }
392 - }
393 - if m.selectedRelay < 0 {
394 - m.selectedRelay = 0
395 - }
396 - if m.selectedRelay >= len(relays) {
397 - m.selectedRelay = len(relays) - 1
355 }
399 - m.selectedRelayURL = relays[m.selectedRelay].RelayURL
356 + m.selectedRelayURL = relays[0].RelayURL
357 }
358
359 func (m agentDashboardModel) selectedTunnelStatus() (types.AgentTunnelStatus, bool) {
403 - if m.selectedTunnel < 0 || m.selectedTunnel >= len(m.status.Tunnels) {
360 + index := m.selectedTunnelIndex()
361 + if index < 0 {
362 return types.AgentTunnelStatus{}, false
363 }
406 - return m.status.Tunnels[m.selectedTunnel], true
364 + return m.status.Tunnels[index], true
365 +}
366 +
367 +func (m agentDashboardModel) selectedRelayIndex(tunnel types.AgentTunnelStatus) int {
368 + if len(tunnel.Relays) == 0 {
369 + return -1
370 + }
371 + for i, relay := range tunnel.Relays {
372 + if relay.RelayURL == m.selectedRelayURL {
373 + return i
374 + }
375 + }
376 + return 0
377 }
378
379 func (m agentDashboardModel) selectedRelayStatus() (types.AgentRelayStatus, bool) {
380 tunnel, ok := m.selectedTunnelStatus()
411 - if !ok || m.selectedRelay < 0 || m.selectedRelay >= len(tunnel.Relays) {
381 + index := m.selectedRelayIndex(tunnel)
382 + if !ok || index < 0 {
383 return types.AgentRelayStatus{}, false
384 }
414 - return tunnel.Relays[m.selectedRelay], true
385 + return tunnel.Relays[index], true
386 }
387
388 func (m agentDashboardModel) selectedTunnelRelay() (types.AgentTunnelStatus, types.AgentRelayStatus, bool) {
@@ -435,7 +406,6 @@ func (m agentDashboardModel) startInput(mode agentDashboardMode, prompt, placeho
406 m.input.TextStyle = agentDashboardInputStyle
407 m.input.PlaceholderStyle = agentDashboardMutedStyle
408 m.input.Width = max(1, min(88, m.width-8))
438 - m.message = ""
409 return m, tea.Batch(m.input.Focus(), textinput.Blink)
410 }
411
@@ -443,7 +413,6 @@ func (m *agentDashboardModel) cancelInput() {
413 m.mode = agentDashboardNormalMode
414 m.input.Blur()
415 m.input.Reset()
446 - m.message = "input canceled"
416 }
417
418 func (m agentDashboardModel) submitInput() (tea.Model, tea.Cmd) {
@@ -457,30 +426,31 @@ func (m agentDashboardModel) submitInput() (tea.Model, tea.Cmd) {
426 case agentDashboardAddTunnelMode:
427 fields := strings.Fields(value)
428 if len(fields) < 2 {
460 - m.message = "use: name port"
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 {
467 - m.message = "port must be 1-65535"
435 return m, nil
436 }
470 - m.message = "adding tunnel..."
471 - return m, agentDashboardAddTunnel(m.stateDir, name, "127.0.0.1:"+port)
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 + })
442 + })
443 case agentDashboardAddRelayMode:
444 if value == "" {
474 - m.message = "relay url is required"
445 return m, nil
446 }
447 tunnel, ok := m.selectedTunnelStatus()
448 if !ok {
479 - m.message = "select a tunnel first"
449 return m, nil
450 }
482 - m.message = "adding relay..."
483 - return m, agentDashboardAddRelay(m.stateDir, tunnel.ID, value)
451 + return m, agentDashboardRun(func(ctx context.Context) error {
452 + return AddRelay(ctx, m.stateDir, tunnel.ID, value)
453 + })
454 }
455 return m, nil
456 }
@@ -488,130 +458,124 @@ func (m agentDashboardModel) submitInput() (tea.Model, tea.Cmd) {
458 func (m agentDashboardModel) deleteSelectedTunnel() (tea.Model, tea.Cmd) {
459 tunnel, ok := m.selectedTunnelStatus()
460 if !ok {
491 - m.message = "no tunnel selected"
461 return m, nil
462 }
463 if len(m.status.Tunnels) <= 1 {
495 - m.message = "cannot delete the last tunnel"
464 return m, nil
465 }
498 - m.message = "deleting " + tunnel.ID + "..."
499 - return m, agentDashboardDeleteTunnel(m.stateDir, tunnel.ID)
466 + return m, agentDashboardRun(func(ctx context.Context) error {
467 + return DeleteTunnel(ctx, m.stateDir, tunnel.ID)
468 + })
469 }
470
471 func (m agentDashboardModel) deleteSelectedRelay() (tea.Model, tea.Cmd) {
472 tunnel, relay, ok := m.selectedTunnelRelay()
473 if !ok {
505 - m.message = "select a relay first"
474 return m, nil
475 }
508 - m.message = "deleting relay..."
509 - return m, agentDashboardRemoveRelay(m.stateDir, tunnel.ID, relay.RelayURL)
476 + return m, agentDashboardRun(func(ctx context.Context) error {
477 + return RemoveRelay(ctx, m.stateDir, tunnel.ID, relay.RelayURL)
478 + })
479 }
480
481 func (m agentDashboardModel) attachSelectedRelay() (tea.Model, tea.Cmd) {
482 tunnel, relay, ok := m.selectedTunnelRelay()
483 if !ok {
515 - m.message = "select a relay first"
484 return m, nil
485 }
518 - if relayDashboardAttached(relay) {
519 - m.message = "relay is already attached"
486 + if relayDashboardInUse(relay) {
487 return m, nil
488 }
522 - m.message = "attaching relay..."
523 - return m, agentDashboardAddRelay(m.stateDir, tunnel.ID, relay.RelayURL)
489 + return m, agentDashboardRun(func(ctx context.Context) error {
490 + return AddRelay(ctx, m.stateDir, tunnel.ID, relay.RelayURL)
491 + })
492 }
493
526 -func (m agentDashboardModel) seedSelectedRelay() (tea.Model, tea.Cmd) {
494 +func (m agentDashboardModel) detachSelectedRelay() (tea.Model, tea.Cmd) {
495 tunnel, relay, ok := m.selectedTunnelRelay()
496 if !ok {
529 - m.message = "select a relay first"
497 return m, nil
498 }
532 - m.message = "switching relay to seed..."
533 - return m, agentDashboardSeedRelay(m.stateDir, tunnel.ID, relay.RelayURL)
499 + return m, agentDashboardRun(func(ctx context.Context) error {
500 + return SeedRelay(ctx, m.stateDir, tunnel.ID, relay.RelayURL)
501 + })
502 }
503
504 func (m agentDashboardModel) addSelectedHop() (tea.Model, tea.Cmd) {
505 tunnel, relay, ok := m.selectedTunnelRelay()
506 if !ok {
539 - m.message = "select a relay first"
507 return m, nil
508 }
509 if !relay.SupportsOverlay {
543 - m.message = "selected relay does not support multi-hop"
510 return m, nil
511 }
546 - m.ensureMultiHopDraft(tunnel)
547 - if slices.Contains(m.multiHopDraft, relay.RelayURL) {
548 - m.message = "relay is already in the route"
512 + m.ensureRouteDraft(tunnel)
513 + if slices.Contains(m.routeDraft, relay.RelayURL) {
514 return m, nil
515 }
551 - m.multiHopDraft = append(m.multiHopDraft, relay.RelayURL)
552 - m.message = "route draft updated"
516 + m.routeDraft = append(m.routeDraft, relay.RelayURL)
517 return m, nil
518 }
519
520 func (m agentDashboardModel) removeSelectedHop() (tea.Model, tea.Cmd) {
521 tunnel, relay, ok := m.selectedTunnelRelay()
522 if !ok {
559 - m.message = "select a relay first"
523 return m, nil
524 }
562 - m.ensureMultiHopDraft(tunnel)
525 + m.ensureRouteDraft(tunnel)
526
564 - next := m.multiHopDraft[:0]
565 - for _, relayURL := range m.multiHopDraft {
527 + next := m.routeDraft[:0]
528 + for _, relayURL := range m.routeDraft {
529 if relayURL != relay.RelayURL {
530 next = append(next, relayURL)
531 }
532 }
570 - if len(next) == len(m.multiHopDraft) {
571 - m.message = "selected relay is not in the route"
533 + if len(next) == len(m.routeDraft) {
534 return m, nil
535 }
574 - m.multiHopDraft = next
575 - m.message = "route draft updated"
536 + m.routeDraft = next
537 return m, nil
538 }
539
579 -func (m agentDashboardModel) applyMultiHop() (tea.Model, tea.Cmd) {
540 +func (m agentDashboardModel) applyRoute() (tea.Model, tea.Cmd) {
541 tunnel, ok := m.selectedTunnelStatus()
542 if !ok {
582 - m.message = "no tunnel selected"
543 return m, nil
544 }
585 - route := m.displayedMultiHop(tunnel)
545 + route := m.displayedRoute(tunnel)
546 if len(route) < 2 {
587 - m.message = "multi-hop requires at least two relays"
547 return m, nil
548 }
590 - m.message = "applying route..."
591 - return m, agentDashboardSetMultiHop(m.stateDir, tunnel.ID, route)
549 + m.routeDraft = nil
550 + m.draftTunnelID = ""
551 + return m, agentDashboardRun(func(ctx context.Context) error {
552 + return SetMultiHop(ctx, m.stateDir, tunnel.ID, route)
553 + })
554 }
555
594 -func (m agentDashboardModel) clearMultiHop() (tea.Model, tea.Cmd) {
556 +func (m agentDashboardModel) clearRoute() (tea.Model, tea.Cmd) {
557 tunnel, ok := m.selectedTunnelStatus()
558 if !ok {
597 - m.message = "no tunnel selected"
559 return m, nil
560 }
600 - m.message = "clearing route..."
601 - return m, agentDashboardSetMultiHop(m.stateDir, tunnel.ID, nil)
561 + m.routeDraft = nil
562 + m.draftTunnelID = ""
563 + return m, agentDashboardRun(func(ctx context.Context) error {
564 + return SetMultiHop(ctx, m.stateDir, tunnel.ID, nil)
565 + })
566 }
567
604 -func (m *agentDashboardModel) ensureMultiHopDraft(tunnel types.AgentTunnelStatus) {
568 +func (m *agentDashboardModel) ensureRouteDraft(tunnel types.AgentTunnelStatus) {
569 if m.draftTunnelID == tunnel.ID {
570 return
571 }
572 m.draftTunnelID = tunnel.ID
609 - m.multiHopDraft = append([]string(nil), tunnel.MultiHop...)
573 + m.routeDraft = append([]string(nil), tunnel.MultiHop...)
574 }
575
612 -func (m agentDashboardModel) displayedMultiHop(tunnel types.AgentTunnelStatus) []string {
576 +func (m agentDashboardModel) displayedRoute(tunnel types.AgentTunnelStatus) []string {
577 if m.draftTunnelID == tunnel.ID {
614 - return append([]string(nil), m.multiHopDraft...)
578 + return append([]string(nil), m.routeDraft...)
579 }
580 return append([]string(nil), tunnel.MultiHop...)
581 }
@@ -628,118 +592,40 @@ func agentDashboardFetchStatus(stateDir string) tea.Cmd {
592
593 func agentDashboardTick() tea.Cmd {
594 return tea.Tick(agentDashboardPollInterval, func(t time.Time) tea.Msg {
631 - return agentDashboardTickMsg(t)
595 + return agentDashboardTickMsg{}
596 })
597 }
598
635 -func agentDashboardAddRelay(stateDir, tunnelID, relayURL string) tea.Cmd {
599 +func agentDashboardRun(run func(context.Context) error) tea.Cmd {
600 return func() tea.Msg {
601 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
602 defer cancel()
603
640 - err := AddRelay(ctx, stateDir, tunnelID, relayURL)
641 - return agentDashboardActionMsg{message: "relay added", err: err}
604 + return agentDashboardActionMsg{err: run(ctx)}
605 }
606 }
607
645 -func agentDashboardRemoveRelay(stateDir, tunnelID, relayURL string) tea.Cmd {
646 - return func() tea.Msg {
647 - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
648 - defer cancel()
649 -
650 - err := RemoveRelay(ctx, stateDir, tunnelID, relayURL)
651 - return agentDashboardActionMsg{message: "relay deleted", err: err}
652 - }
653 -}
654 -
655 -func agentDashboardSeedRelay(stateDir, tunnelID, relayURL string) tea.Cmd {
656 - return func() tea.Msg {
657 - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
658 - defer cancel()
659 -
660 - err := SeedRelay(ctx, stateDir, tunnelID, relayURL)
661 - return agentDashboardActionMsg{message: "relay switched to seed", err: err}
662 - }
663 -}
664 -
665 -func agentDashboardSetMultiHop(stateDir, tunnelID string, relayURLs []string) tea.Cmd {
666 - return func() tea.Msg {
667 - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
668 - defer cancel()
669 -
670 - err := SetMultiHop(ctx, stateDir, tunnelID, relayURLs)
671 - message := "multi-hop applied"
672 - if relayURLs == nil {
673 - message = "multi-hop cleared"
674 - }
675 - return agentDashboardActionMsg{message: message, err: err}
676 - }
677 -}
678 -
679 -func agentDashboardAddTunnel(stateDir, name, target string) tea.Cmd {
680 - return func() tea.Msg {
681 - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
682 - defer cancel()
683 -
684 - err := AddTunnel(ctx, stateDir, types.AgentTunnelRequest{
685 - Name: name,
686 - TargetAddr: target,
687 - })
688 - return agentDashboardActionMsg{message: "tunnel added", err: err}
689 - }
690 -}
691 -
692 -func agentDashboardDeleteTunnel(stateDir, id string) tea.Cmd {
693 - return func() tea.Msg {
694 - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
695 - defer cancel()
696 -
697 - err := DeleteTunnel(ctx, stateDir, id)
698 - return agentDashboardActionMsg{message: "tunnel deleted", err: err}
699 - }
700 -}
701 -
702 -func (m agentDashboardModel) layout() agentDashboardLayout {
608 +func (m agentDashboardModel) layout() agentDashboardView {
609 width := m.width
610 if width <= 0 {
611 width = 88
612 }
707 - leftWidth, rightWidth, bodyHeight := agentDashboardPaneSizes(width, m.height)
613 + leftWidth, rightWidth, bodyHeight := agentDashboardSizes(width, m.height)
614
709 - var layout agentDashboardLayout
710 - layout.addLine(agentDashboardTitleStyle.Render(agentDashboardFit("Portal Agent "+types.ReleaseVersion, width)))
615 + var layout agentDashboardView
616 + layout.addStyled(width, agentDashboardTitleStyle, "Portal Agent "+types.ReleaseVersion)
617 layout.addLine(agentDashboardMutedStyle.Render(strings.Repeat("-", min(width, 120))))
618
619 if m.err != nil && m.status.ControlAddr == "" {
714 - layout.addLine(agentDashboardErrorStyle.Render(agentDashboardFit(fmt.Sprintf("Agent unavailable: %v", m.err), width)))
620 + layout.addStyled(width, agentDashboardErrorStyle, fmt.Sprintf("Agent unavailable: %v", m.err))
621 layout.addLine("")
716 - layout.addLine(agentDashboardFit("Start managed service: portal agent run --config "+m.configPath, width))
717 - layout.addLine(agentDashboardFit("No service manager: portal agent run --foreground --config "+m.configPath, width))
622 + layout.addText(width, "Start managed service: portal agent run --config "+m.configPath)
623 + layout.addText(width, "No service manager: portal agent run --foreground --config "+m.configPath)
624 return layout
625 }
626
721 - tunnelCount := len(m.status.Tunnels)
722 - runningCount := 0
723 - errorCount := 0
724 - for _, tunnel := range m.status.Tunnels {
725 - switch tunnel.State {
726 - case tunnelStateRunning:
727 - runningCount++
728 - case tunnelStateError:
729 - errorCount++
730 - }
731 - }
732 - layout.addLine(agentDashboardFit(fmt.Sprintf("Control: %s Tunnels: %d Running: %d Errors: %d",
733 - valueOrDash(m.status.ControlAddr),
734 - tunnelCount,
735 - runningCount,
736 - errorCount,
737 - ), width))
738 - if m.message != "" {
739 - layout.addLine(agentDashboardMessageStyle.Render(agentDashboardFit("Message: "+m.message, width)))
740 - }
627 if m.err != nil {
742 - layout.addLine(agentDashboardErrorStyle.Render(agentDashboardFit(fmt.Sprintf("Error: %v", m.err), width)))
628 + layout.addStyled(width, agentDashboardErrorStyle, fmt.Sprintf("Error: %v", m.err))
629 }
630 if m.mode != agentDashboardNormalMode {
631 layout.addLine(m.input.View())
@@ -755,18 +641,18 @@ func (m agentDashboardModel) layout() agentDashboardLayout {
641 return layout
642 }
643
758 -func (m agentDashboardModel) renderTunnelsPane(width, height int) agentDashboardPane {
759 - var pane agentDashboardPane
760 - pane.addLine(agentDashboardSectionStyle.Render(agentDashboardFit("Tunnels", width)))
644 +func (m agentDashboardModel) renderTunnelsPane(width, height int) agentDashboardView {
645 + var pane agentDashboardView
646 + pane.addStyled(width, agentDashboardSectionStyle, "Tunnels")
647 pane.addButtons(width,
762 - agentDashboardButton{label: "Add Tunnel", action: agentDashboardClickAddTunnel},
763 - agentDashboardButton{label: "Delete Tunnel", action: agentDashboardClickDeleteTunnel, disabled: len(m.status.Tunnels) <= 1},
648 + agentDashboardButton{label: "Add Tunnel", action: agentDashboardActionAddTunnel},
649 + agentDashboardButton{label: "Delete Tunnel", action: agentDashboardActionDeleteTunnel, disabled: len(m.status.Tunnels) <= 1},
650 )
765 - pane.addLine(agentDashboardMutedStyle.Render(agentDashboardFit(fmt.Sprintf("%d managed", len(m.status.Tunnels)), width)))
651 + pane.addStyled(width, agentDashboardMutedStyle, fmt.Sprintf("%d managed", len(m.status.Tunnels)))
652 pane.addLine(agentDashboardMutedStyle.Render(strings.Repeat("-", width)))
653
654 if len(m.status.Tunnels) == 0 {
769 - pane.addLine(agentDashboardMutedStyle.Render(agentDashboardFit("no managed tunnels", width)))
655 + pane.addStyled(width, agentDashboardMutedStyle, "no managed tunnels")
656 pane.clip(height)
657 return pane
658 }
@@ -778,19 +664,20 @@ func (m agentDashboardModel) renderTunnelsPane(width, height int) agentDashboard
664 agentDashboardSectionStyle.Render(agentDashboardFit("Selected Tunnel", width)),
665 agentDashboardFit("State: "+valueOrDash(tunnel.State), width),
666 agentDashboardFit("Target: "+valueOrDash(tunnel.TargetAddr), width),
781 - agentDashboardFit("Public: "+firstOrDash(tunnel.PublicURLs), width),
667 + agentDashboardFit("Public: "+tunnelPublicURL(tunnel), width),
668 )
669 if strings.TrimSpace(tunnel.LastError) != "" {
670 detailLines = append(detailLines, agentDashboardErrorStyle.Render(agentDashboardFit("Error: "+tunnel.LastError, width)))
671 }
672 }
787 - listLimit := height - len(pane.lines) - len(detailLines)
788 - if listLimit < 3 {
789 - listLimit = height - len(pane.lines)
673 + listLimit := max(agentDashboardMinListRows, height-len(pane.lines)-len(detailLines))
674 + selectedTunnelID := m.selectedTunnelID
675 + if selectedTunnelID == "" && len(m.status.Tunnels) > 0 {
676 + selectedTunnelID = m.status.Tunnels[0].ID
677 }
678 for i, tunnel := range m.status.Tunnels {
679 if i >= listLimit || len(pane.lines) >= height {
793 - pane.addLine(agentDashboardMutedStyle.Render(agentDashboardFit(fmt.Sprintf("+ %d more", len(m.status.Tunnels)-i), width)))
680 + pane.addStyled(width, agentDashboardMutedStyle, fmt.Sprintf("+ %d more", len(m.status.Tunnels)-i))
681 break
682 }
683 name := tunnel.ID
@@ -799,7 +686,7 @@ func (m agentDashboardModel) renderTunnelsPane(width, height int) agentDashboard
686 }
687 nameWidth := max(8, width-11)
688 line := fmt.Sprintf("%-10s %s", truncateDashboardValue(tunnel.State, 10), agentDashboardFit(name, nameWidth))
802 - pane.addClickRow(line, width, agentDashboardTunnelStyle(i == m.selectedTunnel, tunnel.State), agentDashboardClickTunnel, i, -1)
689 + pane.addClickRow(line, width, agentDashboardTunnelStyle(tunnel.ID == selectedTunnelID, tunnel.State), agentDashboardActionSelectTunnel, tunnel.ID, "")
690 }
691 for _, line := range detailLines {
692 if len(pane.lines) >= height {
@@ -811,104 +698,116 @@ func (m agentDashboardModel) renderTunnelsPane(width, height int) agentDashboard
698 return pane
699 }
700
814 -func (m agentDashboardModel) renderTunnelPane(width, height int) agentDashboardPane {
815 - var pane agentDashboardPane
701 +func (m agentDashboardModel) renderTunnelPane(width, height int) agentDashboardView {
702 + var pane agentDashboardView
703 tunnel, ok := m.selectedTunnelStatus()
704 if !ok {
818 - pane.addLine(agentDashboardSectionStyle.Render(agentDashboardFit("Relays", width)))
819 - pane.addLine(agentDashboardMutedStyle.Render(agentDashboardFit("select a managed tunnel", width)))
705 + pane.addStyled(width, agentDashboardSectionStyle, "Relays")
706 + pane.addStyled(width, agentDashboardMutedStyle, "select a managed tunnel")
707 pane.clip(height)
708 return pane
709 }
710
824 - relayLimit := max(4, (height-len(pane.lines)-6)/2)
711 + relayLimit := max(agentDashboardMinListRows, (height-len(pane.lines)-6)/2)
712 m.renderRelaysSection(&pane, width, relayLimit, tunnel)
713 pane.addLine("")
827 - m.renderMultiHopSection(&pane, width, height, tunnel)
714 + m.renderRouteSection(&pane, width, height, tunnel)
715 pane.clip(height)
716 return pane
717 }
718
832 -func (m agentDashboardModel) renderRelaysSection(pane *agentDashboardPane, width, maxRows int, tunnel types.AgentTunnelStatus) {
719 +func (m agentDashboardModel) renderRelaysSection(pane *agentDashboardView, width, maxRows int, tunnel types.AgentTunnelStatus) {
720 relay, hasRelay := m.selectedRelayStatus()
834 - attached := hasRelay && relayDashboardAttached(relay)
835 - attachDisabled := !hasRelay || attached || relay.Banned
836 - seedDisabled := !hasRelay || relay.Banned || (!attached && relay.Bootstrap)
721 + inUse := hasRelay && relayDashboardInUse(relay)
722 + attachDisabled := !hasRelay || inUse || relay.Connecting || relay.Banned
723 + detachDisabled := !hasRelay || relay.Banned || (!inUse && !relay.Connecting && relay.Bootstrap)
724 deleteDisabled := !hasRelay
725
839 - pane.addLine(agentDashboardSectionStyle.Render(agentDashboardFit("Relays", width)))
726 + pane.addStyled(width, agentDashboardSectionStyle, "Relays")
727 pane.addButtons(width,
841 - agentDashboardButton{label: "Attach", action: agentDashboardClickAttachRelay, disabled: attachDisabled},
842 - agentDashboardButton{label: "Seed", action: agentDashboardClickSeedRelay, disabled: seedDisabled},
843 - agentDashboardButton{label: "Add URL", action: agentDashboardClickAddRelay},
844 - agentDashboardButton{label: "Remove", action: agentDashboardClickDeleteRelay, disabled: deleteDisabled},
728 + agentDashboardButton{label: "Attach", action: agentDashboardActionAttachRelay, disabled: attachDisabled},
729 + agentDashboardButton{label: "Detach", action: agentDashboardActionDetachRelay, disabled: detachDisabled},
730 + agentDashboardButton{label: "Add URL", action: agentDashboardActionAddRelay},
731 + agentDashboardButton{label: "Remove", action: agentDashboardActionDeleteRelay, disabled: deleteDisabled},
732 )
733 if hasRelay {
847 - pane.addLine(agentDashboardMutedStyle.Render(agentDashboardFit("Selected: "+relay.RelayURL, width)))
734 + pane.addStyled(width, agentDashboardMutedStyle, "Selected: "+relay.RelayURL)
735 }
849 - pane.addLine(agentDashboardMutedStyle.Render(agentDashboardRelayRow(width, "STATE", "ROLE", "CAPS", "RTT", "RELAY")))
736 + pane.addLine(agentDashboardMutedStyle.Render(agentDashboardRelayRow(width, "STATE", "ROLE", "FEATURES", "RELAY")))
737
738 if len(tunnel.Relays) == 0 {
852 - pane.addLine(agentDashboardMutedStyle.Render(agentDashboardFit("no relays", width)))
739 + pane.addStyled(width, agentDashboardMutedStyle, "no relays")
740 return
741 }
742 + selectedRelayURL := m.selectedRelayURL
743 + if selectedRelayURL == "" && len(tunnel.Relays) > 0 {
744 + selectedRelayURL = tunnel.Relays[0].RelayURL
745 + }
746 for i, relay := range tunnel.Relays {
747 if i >= maxRows {
857 - pane.addLine(agentDashboardMutedStyle.Render(agentDashboardFit(fmt.Sprintf("+ %d more", len(tunnel.Relays)-i), width)))
748 + pane.addStyled(width, agentDashboardMutedStyle, fmt.Sprintf("+ %d more", len(tunnel.Relays)-i))
749 break
750 }
751 line := agentDashboardRelayRow(width,
752 relayDashboardState(relay),
753 relayDashboardRole(relay),
863 - relayDashboardCaps(relay),
864 - relayDashboardRTT(relay),
754 + relayDashboardFeatures(relay),
755 relay.RelayURL,
756 )
867 - pane.addClickRow(line, width, agentDashboardRelayStyle(i == m.selectedRelay, relay), agentDashboardClickRelay, m.selectedTunnel, i)
757 + pane.addClickRow(line, width, agentDashboardRelayStyle(relay.RelayURL == selectedRelayURL, relay), agentDashboardActionSelectRelay, tunnel.ID, relay.RelayURL)
758 }
759 }
760
871 -func (m agentDashboardModel) renderMultiHopSection(pane *agentDashboardPane, width, height int, tunnel types.AgentTunnelStatus) {
872 - route := m.displayedMultiHop(tunnel)
761 +func (m agentDashboardModel) renderRouteSection(pane *agentDashboardView, width, height int, tunnel types.AgentTunnelStatus) {
762 + route := m.displayedRoute(tunnel)
763 relay, hasRelay := m.selectedRelayStatus()
764 inRoute := hasRelay && slices.Contains(route, relay.RelayURL)
765 canAdd := hasRelay && relay.SupportsOverlay && !inRoute
766
877 - pane.addLine(agentDashboardSectionStyle.Render(agentDashboardFit("Multi-hop", width)))
767 + pane.addStyled(width, agentDashboardSectionStyle, "Route")
768 pane.addButtons(width,
879 - agentDashboardButton{label: "Add Hop", action: agentDashboardClickAddHop, disabled: !canAdd},
880 - agentDashboardButton{label: "Remove Hop", action: agentDashboardClickRemoveHop, disabled: !inRoute},
881 - agentDashboardButton{label: "Apply", action: agentDashboardClickApplyHop, disabled: len(route) < 2},
882 - agentDashboardButton{label: "Clear", action: agentDashboardClickClearHop, disabled: len(route) == 0},
769 + agentDashboardButton{label: "Add Hop", action: agentDashboardActionAddHop, disabled: !canAdd},
770 + agentDashboardButton{label: "Remove Hop", action: agentDashboardActionRemoveHop, disabled: !inRoute},
771 + agentDashboardButton{label: "Apply", action: agentDashboardActionApplyHop, disabled: len(route) < 2},
772 + agentDashboardButton{label: "Clear", action: agentDashboardActionClearHop, disabled: len(route) == 0},
773 )
774
885 - routeLabel := "Route: none"
886 - if len(route) > 0 {
887 - routeLabel = "Route: " + strings.Join(route, " -> ")
888 - if m.draftTunnelID == tunnel.ID {
889 - routeLabel += " (draft)"
890 - }
775 + routeLabel := "Route:"
776 + if m.draftTunnelID == tunnel.ID {
777 + routeLabel += " draft"
778 + }
779 + if len(route) == 0 {
780 + routeLabel = "Route: none"
781 }
892 - for _, line := range agentDashboardWrap(routeLabel, width) {
782 + pane.addText(width, routeLabel)
783 + for i, relayURL := range route {
784 if len(pane.lines) >= height {
785 return
786 }
896 - pane.addLine(agentDashboardFit(line, width))
787 + pane.addText(width, fmt.Sprintf("%d. %s", i+1, relayURL))
788 }
789 }
790
900 -func (l *agentDashboardLayout) addLine(line string) {
901 - l.lines = append(l.lines, line)
791 +func (v *agentDashboardView) addLine(line string) {
792 + v.lines = append(v.lines, line)
793 }
794
904 -func (l *agentDashboardLayout) addButtons(width int, buttons ...agentDashboardButton) {
905 - lines, regions := agentDashboardRenderButtons(width, len(l.lines), 0, buttons...)
906 - l.lines = append(l.lines, lines...)
907 - l.regions = append(l.regions, regions...)
795 +func (v *agentDashboardView) addText(width int, text string) {
796 + v.addLine(agentDashboardFit(text, width))
797 }
798
910 -func (l *agentDashboardLayout) addPanes(left, right agentDashboardPane, leftWidth, gutter int) {
911 - startY := len(l.lines)
799 +func (v *agentDashboardView) addStyled(width int, style lipgloss.Style, text string) {
800 + v.addLine(style.Render(agentDashboardFit(text, width)))
801 +}
802 +
803 +func (v *agentDashboardView) addButtons(width int, buttons ...agentDashboardButton) {
804 + lines, regions := agentDashboardRenderButtons(width, len(v.lines), 0, buttons...)
805 + v.lines = append(v.lines, lines...)
806 + v.regions = append(v.regions, regions...)
807 +}
808 +
809 +func (v *agentDashboardView) addPanes(left, right agentDashboardView, leftWidth, gutter int) {
810 + startY := len(v.lines)
811 height := max(len(left.lines), len(right.lines))
812 for i := 0; i < height; i++ {
813 leftLine := ""
@@ -919,35 +818,25 @@ func (l *agentDashboardLayout) addPanes(left, right agentDashboardPane, leftWidt
818 if i < len(right.lines) {
819 rightLine = right.lines[i]
820 }
922 - l.lines = append(l.lines, agentDashboardPadStyled(leftLine, leftWidth)+strings.Repeat(" ", gutter)+rightLine)
821 + v.lines = append(v.lines, agentDashboardPadStyled(leftLine, leftWidth)+strings.Repeat(" ", gutter)+rightLine)
822 }
823 for _, region := range left.regions {
824 region.y += startY
926 - l.regions = append(l.regions, region)
825 + v.regions = append(v.regions, region)
826 }
827 for _, region := range right.regions {
828 region.y += startY
829 region.x0 += leftWidth + gutter
830 region.x1 += leftWidth + gutter
932 - l.regions = append(l.regions, region)
831 + v.regions = append(v.regions, region)
832 }
833 }
834
936 -func (p *agentDashboardPane) addLine(line string) {
937 - p.lines = append(p.lines, line)
938 -}
939 -
940 -func (p *agentDashboardPane) addButtons(width int, buttons ...agentDashboardButton) {
941 - lines, regions := agentDashboardRenderButtons(width, len(p.lines), 0, buttons...)
942 - p.lines = append(p.lines, lines...)
943 - p.regions = append(p.regions, regions...)
944 -}
945 -
946 -func (p *agentDashboardPane) addClickRow(line string, width int, style lipgloss.Style, action agentDashboardClick, tunnel, relay int) {
835 +func (v *agentDashboardView) addClickRow(line string, width int, style lipgloss.Style, action agentDashboardAction, tunnel, relay string) {
836 plain := agentDashboardFit(line, width)
948 - y := len(p.lines)
949 - p.lines = append(p.lines, style.Width(width).Render(plain))
950 - p.regions = append(p.regions, agentDashboardClickRegion{
837 + y := len(v.lines)
838 + v.lines = append(v.lines, style.Width(width).Render(plain))
839 + v.regions = append(v.regions, agentDashboardRegion{
840 x0: 0,
841 x1: width,
842 y: y,
@@ -957,27 +846,27 @@ func (p *agentDashboardPane) addClickRow(line string, width int, style lipgloss.
846 })
847 }
848
960 -func (p *agentDashboardPane) clip(height int) {
961 - if height <= 0 || len(p.lines) <= height {
849 +func (v *agentDashboardView) clip(height int) {
850 + if height <= 0 || len(v.lines) <= height {
851 return
852 }
964 - p.lines = p.lines[:height]
965 - regions := p.regions[:0]
966 - for _, region := range p.regions {
853 + v.lines = v.lines[:height]
854 + regions := v.regions[:0]
855 + for _, region := range v.regions {
856 if region.y < height {
857 regions = append(regions, region)
858 }
859 }
971 - p.regions = regions
860 + v.regions = regions
861 }
862
974 -func agentDashboardRenderButtons(width, y, x int, buttons ...agentDashboardButton) ([]string, []agentDashboardClickRegion) {
863 +func agentDashboardRenderButtons(width, y, x int, buttons ...agentDashboardButton) ([]string, []agentDashboardRegion) {
864 if width <= 0 {
865 width = 1
866 }
867 var line strings.Builder
868 var lines []string
980 - var regions []agentDashboardClickRegion
869 + var regions []agentDashboardRegion
870 lineY := y
871 lineX := x
872 for i, button := range buttons {
@@ -1005,7 +894,7 @@ func agentDashboardRenderButtons(width, y, x int, buttons ...agentDashboardButto
894 if button.disabled {
895 style = agentDashboardDisabledStyle
896 } else {
1008 - regions = append(regions, agentDashboardClickRegion{
897 + regions = append(regions, agentDashboardRegion{
898 x0: lineX,
899 x1: min(lineX+plainWidth, width),
900 y: lineY,
@@ -1021,7 +910,7 @@ func agentDashboardRenderButtons(width, y, x int, buttons ...agentDashboardButto
910 return lines, regions
911 }
912
1024 -func agentDashboardPaneSizes(width, height int) (int, int, int) {
913 +func agentDashboardSizes(width, height int) (int, int, int) {
914 if width <= 0 {
915 width = 104
916 }
@@ -1064,7 +953,7 @@ func agentDashboardTunnelStyle(selected bool, state string) lipgloss.Style {
953 case "error":
954 return agentDashboardErrorStyle
955 case "starting":
1067 - return agentDashboardMessageStyle
956 + return agentDashboardMutedStyle
957 default:
958 return lipgloss.NewStyle()
959 }
@@ -1077,7 +966,7 @@ func agentDashboardRelayStyle(selected bool, relay types.AgentRelayStatus) lipgl
966 if relay.Banned {
967 return agentDashboardErrorStyle
968 }
1080 - if relay.Connected || relay.Active {
969 + if relayDashboardInUse(relay) {
970 return agentDashboardOKStyle
971 }
972 return agentDashboardMutedStyle
@@ -1086,17 +975,15 @@ func agentDashboardRelayStyle(selected bool, relay types.AgentRelayStatus) lipgl
975 func relayDashboardState(relay types.AgentRelayStatus) string {
976 switch {
977 case relay.Banned:
1089 - return "removed"
1090 - case relay.Connected:
1091 - return "up"
1092 - case relay.Active:
1093 - return "active"
1094 - case relay.Confirmed:
1095 - return "known"
978 + return "blocked"
979 + case relay.PublicURL != "":
980 + return "ready"
981 + case relay.Connecting:
982 + return "trying"
983 case relay.Bootstrap:
984 return "seed"
985 default:
1099 - return "seen"
986 + return "known"
987 }
988 }
989
@@ -1104,46 +991,39 @@ func relayDashboardRole(relay types.AgentRelayStatus) string {
991 switch {
992 case relay.Banned:
993 return "blocked"
1107 - case relay.Active || relay.Connected:
994 + case relayDashboardInUse(relay):
995 return "attached"
996 + case relay.Connecting:
997 + return "trying"
998 case relay.Bootstrap:
1110 - return "bootstrap"
1111 - case relay.Confirmed:
1112 - return "known"
999 + return "seed"
1000 default:
1114 - return "discovered"
1001 + return "candidate"
1002 }
1003 }
1004
1118 -func relayDashboardAttached(relay types.AgentRelayStatus) bool {
1119 - return relay.Active || relay.Connected
1005 +func relayDashboardInUse(relay types.AgentRelayStatus) bool {
1006 + return relay.PublicURL != ""
1007 }
1008
1122 -func relayDashboardCaps(relay types.AgentRelayStatus) string {
1123 - var caps []string
1009 +func relayDashboardFeatures(relay types.AgentRelayStatus) string {
1010 + var features []string
1011 if relay.SupportsOverlay {
1125 - caps = append(caps, "hop")
1012 + features = append(features, "hop")
1013 }
1014 if relay.SupportsUDP {
1128 - caps = append(caps, "udp")
1015 + features = append(features, "udp")
1016 }
1017 if relay.SupportsTCP {
1131 - caps = append(caps, "tcp")
1018 + features = append(features, "tcp")
1019 }
1133 - if len(caps) == 0 {
1020 + if len(features) == 0 {
1021 return "-"
1022 }
1136 - return strings.Join(caps, "/")
1023 + return strings.Join(features, ",")
1024 }
1025
1139 -func relayDashboardRTT(relay types.AgentRelayStatus) string {
1140 - if relay.DiscoveryRTTMillis <= 0 {
1141 - return "-"
1142 - }
1143 - return fmt.Sprintf("%dms", relay.DiscoveryRTTMillis)
1144 -}
1145 -
1146 -func agentDashboardRelayRow(width int, state, role, caps, rtt, relayURL string) string {
1026 +func agentDashboardRelayRow(width int, state, role, features, relayURL string) string {
1027 if width < 28 {
1028 return agentDashboardFit(state+" "+relayURL, width)
1029 }
@@ -1151,34 +1031,29 @@ func agentDashboardRelayRow(width int, state, role, caps, rtt, relayURL string)
1031 stateW := 7
1032 return agentDashboardCell(state, stateW) + " " + agentDashboardFit(relayURL, width-stateW-1)
1033 }
1154 - if width < 64 {
1155 - stateW := 7
1156 - roleW := 9
1157 - rttW := 6
1158 - relayW := max(1, width-stateW-roleW-rttW-3)
1034 + stateW := 8
1035 + roleW := 9
1036 + if width < 68 {
1037 + relayW := max(1, width-stateW-roleW-2)
1038 return agentDashboardCell(state, stateW) + " " +
1039 agentDashboardCell(role, roleW) + " " +
1161 - agentDashboardCell(rtt, rttW) + " " +
1040 agentDashboardFit(relayURL, relayW)
1041 }
1164 -
1165 - stateW := 8
1166 - roleW := 9
1167 - capsW := 8
1168 - rttW := 6
1169 - relayW := max(1, width-stateW-roleW-capsW-rttW-4)
1042 + featuresW := 11
1043 + relayW := max(1, width-stateW-roleW-featuresW-3)
1044 return agentDashboardCell(state, stateW) + " " +
1045 agentDashboardCell(role, roleW) + " " +
1172 - agentDashboardCell(caps, capsW) + " " +
1173 - agentDashboardCell(rtt, rttW) + " " +
1046 + agentDashboardCell(features, featuresW) + " " +
1047 agentDashboardFit(relayURL, relayW)
1048 }
1049
1177 -func firstOrDash(values []string) string {
1178 - if len(values) == 0 {
1179 - return "-"
1050 +func tunnelPublicURL(tunnel types.AgentTunnelStatus) string {
1051 + for _, relay := range tunnel.Relays {
1052 + if strings.TrimSpace(relay.PublicURL) != "" {
1053 + return relay.PublicURL
1054 + }
1055 }
1181 - return valueOrDash(values[0])
1056 + return "-"
1057 }
1058
1059 func valueOrDash(value string) string {
@@ -1229,24 +1104,6 @@ func agentDashboardCell(value string, width int) string {
1104 return value + strings.Repeat(" ", width-lipgloss.Width(value))
1105 }
1106
1232 -func agentDashboardWrap(value string, width int) []string {
1233 - value = strings.TrimSpace(value)
1234 - if value == "" {
1235 - return nil
1236 - }
1237 - var lines []string
1238 - remaining := value
1239 - for len([]rune(remaining)) > width && width > 1 {
1240 - lines = append(lines, agentDashboardFit(remaining, width))
1241 - runes := []rune(remaining)
1242 - remaining = string(runes[min(width-1, len(runes)):])
1243 - }
1244 - if strings.TrimSpace(remaining) != "" {
1245 - lines = append(lines, remaining)
1246 - }
1247 - return lines
1248 -}
1249 -
1107 func agentDashboardPadStyled(value string, width int) string {
1108 if lipgloss.Width(value) >= width {
1109 return value
cmd/portal-tunnel/agent/manager.go
+59 -125
@@ -17,13 +17,6 @@ import (
17 "github.com/gosuda/portal-tunnel/v2/types"
18 )
19
20 -const (
21 - tunnelStateStarting = "starting"
22 - tunnelStateRunning = "running"
23 - tunnelStateStopped = "stopped"
24 - tunnelStateError = "error"
25 -)
26 -
20 type manager struct {
21 controlAddr string
22
@@ -101,47 +94,53 @@ func (m *manager) Stop(ctx context.Context) error {
94 }
95
96 func (m *manager) AddRelay(id, relayURL string) error {
104 - id = strings.TrimSpace(id)
105 - m.mu.RLock()
106 - tunnel := m.tunnels[id]
107 - m.mu.RUnlock()
108 - if tunnel == nil {
109 - return fmt.Errorf("unknown tunnel %q", id)
97 + exposure, err := m.runningExposure(id)
98 + if err != nil {
99 + return err
100 }
111 - return tunnel.AddRelay(relayURL)
101 + return exposure.AddRelay(relayURL)
102 }
103
104 func (m *manager) RemoveRelay(id, relayURL string) error {
115 - id = strings.TrimSpace(id)
116 - m.mu.RLock()
117 - tunnel := m.tunnels[id]
118 - m.mu.RUnlock()
119 - if tunnel == nil {
120 - return fmt.Errorf("unknown tunnel %q", id)
105 + exposure, err := m.runningExposure(id)
106 + if err != nil {
107 + return err
108 }
122 - return tunnel.RemoveRelay(relayURL)
109 + return exposure.RemoveRelay(relayURL)
110 }
111
112 func (m *manager) SeedRelay(id, relayURL string) error {
126 - id = strings.TrimSpace(id)
127 - m.mu.RLock()
128 - tunnel := m.tunnels[id]
129 - m.mu.RUnlock()
130 - if tunnel == nil {
131 - return fmt.Errorf("unknown tunnel %q", id)
113 + exposure, err := m.runningExposure(id)
114 + if err != nil {
115 + return err
116 }
133 - return tunnel.SeedRelay(relayURL)
117 + return exposure.SeedRelay(relayURL)
118 }
119
120 func (m *manager) SetMultiHop(id string, relayURLs []string) error {
121 + exposure, err := m.runningExposure(id)
122 + if err != nil {
123 + return err
124 + }
125 + return exposure.SetMultiHop(relayURLs)
126 +}
127 +
128 +func (m *manager) runningExposure(id string) (*sdk.Exposure, error) {
129 id = strings.TrimSpace(id)
130 m.mu.RLock()
131 tunnel := m.tunnels[id]
132 m.mu.RUnlock()
133 if tunnel == nil {
142 - return fmt.Errorf("unknown tunnel %q", id)
134 + return nil, fmt.Errorf("unknown tunnel %q", id)
135 + }
136 + tunnel.mu.RLock()
137 + tunnelID := tunnel.cfg.ID
138 + exposure := tunnel.exposure
139 + tunnel.mu.RUnlock()
140 + if exposure == nil {
141 + return nil, fmt.Errorf("tunnel %q is not running", tunnelID)
142 }
144 - return tunnel.SetMultiHop(relayURLs)
143 + return exposure, nil
144 }
145
146 func (m *manager) AddTunnel(req types.AgentTunnelRequest) error {
@@ -355,20 +354,14 @@ type managedTunnel struct {
354 mu sync.RWMutex
355 cfg TunnelConfig
356
358 - stopCancel context.CancelFunc
359 - runCancel context.CancelFunc
360 - done chan struct{}
361 - exposure *sdk.Exposure
362 -
363 - state string
357 + cancel context.CancelFunc
358 + done chan struct{}
359 + exposure *sdk.Exposure
360 lastError string
361 }
362
363 func newTunnel(cfg TunnelConfig) *managedTunnel {
368 - return &managedTunnel{
369 - cfg: cfg,
370 - state: tunnelStateStopped,
371 - }
364 + return &managedTunnel{cfg: cfg}
365 }
366
367 func (t *managedTunnel) Start(parent context.Context) {
@@ -378,7 +371,7 @@ func (t *managedTunnel) Start(parent context.Context) {
371 return
372 }
373 ctx, cancel := context.WithCancel(parent)
381 - t.stopCancel = cancel
374 + t.cancel = cancel
375 t.done = make(chan struct{})
376 done := t.done
377 t.mu.Unlock()
@@ -391,17 +384,12 @@ func (t *managedTunnel) Start(parent context.Context) {
384
385 func (t *managedTunnel) Stop(ctx context.Context) error {
386 t.mu.Lock()
394 - stopCancel := t.stopCancel
395 - runCancel := t.runCancel
387 + cancel := t.cancel
388 done := t.done
397 - t.stopCancel = nil
398 - t.runCancel = nil
389 + t.cancel = nil
390 t.done = nil
400 - if runCancel != nil {
401 - runCancel()
402 - }
403 - if stopCancel != nil {
404 - stopCancel()
391 + if cancel != nil {
392 + cancel()
393 }
394 t.mu.Unlock()
395
@@ -416,76 +404,38 @@ func (t *managedTunnel) Stop(ctx context.Context) error {
404 }
405 }
406
419 -func (t *managedTunnel) AddRelay(relayURL string) error {
420 - t.mu.RLock()
421 - id := t.cfg.ID
422 - exposure := t.exposure
423 - t.mu.RUnlock()
424 - if exposure == nil {
425 - return fmt.Errorf("tunnel %q is not running", id)
426 - }
427 - if err := exposure.AddRelay(relayURL); err != nil {
428 - return err
429 - }
430 - return nil
431 -}
432 -
433 -func (t *managedTunnel) RemoveRelay(relayURL string) error {
407 +func (t *managedTunnel) Snapshot() types.AgentTunnelStatus {
408 t.mu.RLock()
435 - id := t.cfg.ID
409 + cfg := t.cfg
410 + lastError := t.lastError
411 exposure := t.exposure
412 + done := t.done
413 t.mu.RUnlock()
438 - if exposure == nil {
439 - return fmt.Errorf("tunnel %q is not running", id)
440 - }
441 - if err := exposure.RemoveRelay(relayURL); err != nil {
442 - return err
443 - }
444 - return nil
445 -}
414
447 -func (t *managedTunnel) SeedRelay(relayURL string) error {
448 - t.mu.RLock()
449 - id := t.cfg.ID
450 - exposure := t.exposure
451 - t.mu.RUnlock()
452 - if exposure == nil {
453 - return fmt.Errorf("tunnel %q is not running", id)
454 - }
455 - if err := exposure.SeedRelay(relayURL); err != nil {
456 - return err
415 + running := false
416 + if done != nil {
417 + select {
418 + case <-done:
419 + default:
420 + running = true
421 + }
422 }
458 - return nil
459 -}
423
461 -func (t *managedTunnel) SetMultiHop(relayURLs []string) error {
462 - t.mu.RLock()
463 - id := t.cfg.ID
464 - exposure := t.exposure
465 - t.mu.RUnlock()
466 - if exposure == nil {
467 - return fmt.Errorf("tunnel %q is not running", id)
468 - }
469 - if err := exposure.SetMultiHop(relayURLs); err != nil {
470 - return err
424 + state := "stopped"
425 + switch {
426 + case lastError != "":
427 + state = "error"
428 + case exposure != nil:
429 + state = "running"
430 + case running:
431 + state = "starting"
432 }
472 - return nil
473 -}
474 -
475 -func (t *managedTunnel) Snapshot() types.AgentTunnelStatus {
476 - t.mu.RLock()
477 - cfg := t.cfg
478 - state := t.state
479 - lastError := t.lastError
480 - exposure := t.exposure
481 - t.mu.RUnlock()
433
434 status := types.AgentTunnelStatus{
435 ID: cfg.ID,
436 Name: cfg.Name,
437 State: state,
438 TargetAddr: cfg.TargetAddr,
488 - UDPAddr: cfg.UDPAddr,
439 LastError: lastError,
440 }
441 if exposure == nil {
@@ -493,33 +443,19 @@ func (t *managedTunnel) Snapshot() types.AgentTunnelStatus {
443 }
444 snapshot := exposure.Snapshot()
445 status.TargetAddr = snapshot.TargetAddr
496 - status.UDPAddr = snapshot.UDPAddr
446 status.MultiHop = append([]string(nil), snapshot.MultiHop...)
447 status.Relays = append([]types.AgentRelayStatus(nil), snapshot.Relays...)
499 - for _, relay := range status.Relays {
500 - if relay.PublicURL != "" {
501 - status.PublicURLs = append(status.PublicURLs, relay.PublicURL)
502 - }
503 - }
448 return status
449 }
450
451 func (t *managedTunnel) runLoop(ctx context.Context) {
508 - runCtx, runCancel := context.WithCancel(ctx)
509 - t.mu.Lock()
510 - t.runCancel = runCancel
511 - t.mu.Unlock()
512 -
513 - err := t.runOnce(runCtx)
452 + err := t.runOnce(ctx)
453
454 t.mu.Lock()
516 - t.runCancel = nil
455 t.exposure = nil
518 - t.lastError = ""
456 if ctx.Err() != nil || errors.Is(err, context.Canceled) || err == nil {
520 - t.state = tunnelStateStopped
457 + t.lastError = ""
458 } else {
522 - t.state = tunnelStateError
459 t.lastError = err.Error()
460 }
461 t.mu.Unlock()
@@ -528,7 +464,6 @@ func (t *managedTunnel) runLoop(ctx context.Context) {
464 func (t *managedTunnel) runOnce(ctx context.Context) error {
465 t.mu.Lock()
466 cfg := t.cfg
531 - t.state = tunnelStateStarting
467 t.lastError = ""
468 t.mu.Unlock()
469
@@ -567,7 +502,6 @@ func (t *managedTunnel) runOnce(ctx context.Context) error {
502 }
503 t.mu.Lock()
504 t.exposure = exposure
570 - t.state = tunnelStateRunning
505 t.lastError = ""
506 t.mu.Unlock()
507
cmd/portal-tunnel/agent/run.go
-2
@@ -67,8 +67,6 @@ func Run(ctx context.Context, cfg Config) error {
67 if err := utils.WriteJSONFile(filepath.Join(endpointStateDir, endpointFilename), endpoint{
68 ControlAddr: listenAddr,
69 Token: token,
70 - PID: os.Getpid(),
71 - UpdatedAt: time.Now().UTC(),
70 }, 0o600); err != nil {
71 _ = listener.Close()
72 _ = control.Shutdown(context.Background())
sdk/expose.go
+35 -24
@@ -361,7 +361,7 @@ func (e *Exposure) SetMultiHop(relayURLs []string) error {
361 e.multiHop = append([]string(nil), multiHop...)
362 e.multiHopDepth = 0
363 e.listenerMu.Unlock()
364 - return e.reconcileRelayListeners(len(multiHop) > 0)
364 + return e.reconcileRelayListeners(false)
365 }
366
367 func initialRouteCapacity(listenerRelayURLs []string, multiHopDepth int) int {
@@ -425,17 +425,11 @@ func (e *Exposure) Snapshot() types.AgentTunnelStatus {
425 relayURL = listener.relayURL.String()
426 }
427 snap := types.AgentRelayStatus{
428 - RelayURL: relayURL,
429 - MultiHop: append([]string(nil), listener.multiHop...),
430 - Active: true,
428 + RelayURL: relayURL,
429 + Connecting: true,
430 }
431 if lease, ok := listener.leaseSnapshot(); ok {
433 - snap.Hostname = lease.hostname
432 snap.PublicURL = listener.publicURLForLease(lease)
435 - snap.UDPAddr = lease.udpAddr
436 - snap.TCPAddr = lease.tcpAddr
437 - snap.ExpiresAt = lease.expiresAt
438 - snap.Connected = lease.hostname != ""
433 }
434 if relayURL != "" {
435 relayByURL[relayURL] = snap
@@ -449,14 +443,7 @@ func (e *Exposure) Snapshot() types.AgentTunnelStatus {
443 }
444 snap := relayByURL[relayURL]
445 snap.RelayURL = relayURL
452 - snap.Address = state.Descriptor.Address
453 - snap.DescriptorExpiresAt = state.Descriptor.ExpiresAt
454 - snap.LastSeenAt = state.LastSeenAt
455 - if !state.DiscoveryRTTAt.IsZero() {
456 - snap.DiscoveryRTTMillis = state.DiscoveryRTT.Milliseconds()
457 - }
446 snap.Bootstrap = state.Bootstrap
459 - snap.Confirmed = state.Confirmed
447 snap.Banned = state.Banned
448 snap.SupportsOverlay = state.Descriptor.SupportsOverlay
449 snap.SupportsUDP = state.Descriptor.SupportsUDP
@@ -469,8 +456,16 @@ func (e *Exposure) Snapshot() types.AgentTunnelStatus {
456 relays = append(relays, snap)
457 }
458 slices.SortFunc(relays, func(a, b types.AgentRelayStatus) int {
472 - if a.Active != b.Active {
473 - if a.Active {
459 + aReady := a.PublicURL != ""
460 + bReady := b.PublicURL != ""
461 + if aReady != bReady {
462 + if aReady {
463 + return -1
464 + }
465 + return 1
466 + }
467 + if a.Connecting != b.Connecting {
468 + if a.Connecting {
469 return -1
470 }
471 return 1
@@ -480,7 +475,6 @@ func (e *Exposure) Snapshot() types.AgentTunnelStatus {
475
476 return types.AgentTunnelStatus{
477 TargetAddr: e.TargetAddr,
483 - UDPAddr: e.UDPAddr,
478 MultiHop: multiHop,
479 Relays: relays,
480 }
@@ -705,7 +699,17 @@ func (e *Exposure) reconcileRelayListeners(failOnError bool) error {
699 explicitRelays := append([]string(nil), e.explicitRelays...)
700 seedOnlyRelays := append([]string(nil), e.seedOnlyRelays...)
701 if len(multiHop) > 0 {
708 - listenerRelayURLs = []string{multiHop[len(multiHop)-1]}
702 + listenerRelayURLs = e.relaySet.PriorityRelays(discovery.ClientState{
703 + ExplicitRelayURLs: explicitRelays,
704 + SuppressedRelayURLs: seedOnlyRelays,
705 + MaxActiveRelays: e.maxActiveRelays,
706 + RequireUDP: e.udpEnabled,
707 + RequireTCP: e.tcpEnabled,
708 + LocalAddress: e.identity.Address,
709 + })
710 + if exitRelayURL := multiHop[len(multiHop)-1]; !slices.Contains(listenerRelayURLs, exitRelayURL) {
711 + listenerRelayURLs = append(listenerRelayURLs, exitRelayURL)
712 + }
713 } else if e.multiHopDepth > 1 {
714 multiHop = e.relaySet.PriorityMultiHop(discovery.ClientState{
715 SuppressedRelayURLs: seedOnlyRelays,
@@ -727,11 +731,14 @@ func (e *Exposure) reconcileRelayListeners(failOnError bool) error {
731 LocalAddress: e.identity.Address,
732 })
733 }
730 -
734 staleRelayListeners := make(map[string]*listener)
735 removedRelayURLs := make([]string, 0)
736 for relayURL, listener := range e.relayListeners {
734 - if slices.Contains(listenerRelayURLs, relayURL) && slices.Equal(listener.multiHop, multiHop) {
737 + wantMultiHop := []string(nil)
738 + if len(multiHop) > 0 && relayURL == multiHop[len(multiHop)-1] {
739 + wantMultiHop = multiHop
740 + }
741 + if slices.Contains(listenerRelayURLs, relayURL) && slices.Equal(listener.multiHop, wantMultiHop) {
742 continue
743 }
744 staleRelayListeners[relayURL] = listener
@@ -761,15 +768,19 @@ func (e *Exposure) reconcileRelayListeners(failOnError bool) error {
768 }
769 }
770 for _, relayURL := range missingRelayURLs {
771 + listenerMultiHop := []string(nil)
772 + if len(multiHop) > 0 && relayURL == multiHop[len(multiHop)-1] {
773 + listenerMultiHop = append([]string(nil), multiHop...)
774 + }
775 retryCount := 10
765 - if len(multiHop) > 0 || slices.Contains(explicitRelays, relayURL) {
776 + if len(listenerMultiHop) > 0 || slices.Contains(explicitRelays, relayURL) {
777 retryCount = 0
778 }
779 listener, err := newListener(context.Background(), relayURL, listenerConfig{
780 Identity: e.identity,
781 UDPEnabled: e.udpEnabled,
782 TCPEnabled: e.tcpEnabled,
772 - MultiHop: multiHop,
783 + MultiHop: listenerMultiHop,
784 BanMITM: e.banMITM,
785 RetryCount: retryCount,
786 Metadata: e.metadata,
types/agent.go
+8 -23
@@ -1,7 +1,5 @@
1 package types
2
3 -import "time"
4 -
3 type AgentStatusResponse struct {
4 ControlAddr string `json:"control_addr"`
5 Tunnels []AgentTunnelStatus `json:"tunnels,omitempty"`
@@ -12,33 +10,20 @@ type AgentTunnelStatus struct {
10 Name string `json:"name,omitempty"`
11 State string `json:"state"`
12 TargetAddr string `json:"target_addr,omitempty"`
15 - UDPAddr string `json:"udp_addr,omitempty"`
13 LastError string `json:"last_error,omitempty"`
14 MultiHop []string `json:"multi_hop,omitempty"`
15 Relays []AgentRelayStatus `json:"relays,omitempty"`
19 - PublicURLs []string `json:"public_urls,omitempty"`
16 }
17
18 type AgentRelayStatus struct {
23 - RelayURL string `json:"relay_url"`
24 - Address string `json:"address,omitempty"`
25 - Hostname string `json:"hostname,omitempty"`
26 - PublicURL string `json:"public_url,omitempty"`
27 - UDPAddr string `json:"udp_addr,omitempty"`
28 - TCPAddr string `json:"tcp_addr,omitempty"`
29 - ExpiresAt time.Time `json:"expires_at,omitempty"`
30 - DescriptorExpiresAt time.Time `json:"descriptor_expires_at,omitempty"`
31 - LastSeenAt time.Time `json:"last_seen_at,omitempty"`
32 - DiscoveryRTTMillis int64 `json:"discovery_rtt_millis,omitempty"`
33 - MultiHop []string `json:"multi_hop,omitempty"`
34 - Active bool `json:"active"`
35 - Bootstrap bool `json:"bootstrap"`
36 - Confirmed bool `json:"confirmed"`
37 - Banned bool `json:"banned"`
38 - SupportsOverlay bool `json:"supports_overlay"`
39 - SupportsUDP bool `json:"supports_udp"`
40 - SupportsTCP bool `json:"supports_tcp"`
41 - Connected bool `json:"connected"`
19 + RelayURL string `json:"relay_url"`
20 + PublicURL string `json:"public_url,omitempty"`
21 + Connecting bool `json:"connecting"`
22 + Bootstrap bool `json:"bootstrap"`
23 + Banned bool `json:"banned"`
24 + SupportsOverlay bool `json:"supports_overlay"`
25 + SupportsUDP bool `json:"supports_udp"`
26 + SupportsTCP bool `json:"supports_tcp"`
27 }
28
29 type AgentTunnelRequest struct {