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 {