feat: add tunnel update functionality and metadata handling
Kim committed
May 14, 2026 at 15:20 UTC
70ab46f58558831a9df06f8233f437866b2e1551
14 files changed
+974
-253
cmd/demo-app/main.go
+2
-2
@@ -133,9 +133,9 @@ func runTCPDemo(ctx context.Context, cfg demoConfig) error {
133
exposure, err := sdk.Expose(ctx, sdk.ExposeConfig{
134
RelayURLs: utils.SplitCSV(cfg.relayURLs),
135
Discovery: cfg.discovery,
136
+ Identity: types.Identity{Name: cfg.name},
137
IdentityPath: cfg.identityPath,
138
IdentityJSON: cfg.identityJSON,
138
- Name: cfg.name,
139
BanMITM: cfg.banMITM,
140
MaxActiveRelays: cfg.maxActiveRelays,
141
Metadata: types.LeaseMetadata{
@@ -176,9 +176,9 @@ func runUDPDemo(ctx context.Context, cfg demoConfig) error {
176
exposure, err := sdk.Expose(ctx, sdk.ExposeConfig{
177
RelayURLs: utils.SplitCSV(cfg.relayURLs),
178
Discovery: cfg.discovery,
179
+ Identity: types.Identity{Name: cfg.name},
180
IdentityPath: cfg.identityPath,
181
IdentityJSON: cfg.identityJSON,
181
- Name: cfg.name,
182
UDPEnabled: true,
183
BanMITM: cfg.banMITM,
184
MaxActiveRelays: cfg.maxActiveRelays,
cmd/portal-tunnel/README.md
+2
-1
@@ -225,7 +225,8 @@ Useful commands:
225
the OS service, starts it in the background, and exits after the agent is ready.
226
- `portal agent run --config config.toml --foreground` runs the agent in the
227
current terminal and opens the dashboard when the terminal is interactive.
228
-- `portal agent dashboard` attaches to a running agent and opens the local TUI.
228
+- `portal agent dashboard` attaches to a running agent and opens the local TUI
229
+ for tunnels, relays, multi-hop routes, and editable tunnel settings.
230
- `portal agent stop` asks the local agent to shut down, then disables or stops
231
the OS service so intentional shutdown is not immediately restarted.
232
- `portal agent restart` stops the running agent if present, installs or updates
cmd/portal-tunnel/agent/control.go
+22
-5
@@ -88,11 +88,23 @@ func (s *controlHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
88
return
89
}
90
if !ok {
91
- if !utils.RequireMethod(w, r, http.MethodDelete) {
92
- return
93
- }
94
- if err := s.manager.DeleteTunnel(tunnelID); err != nil {
95
- utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, err.Error())
91
+ switch r.Method {
92
+ case http.MethodDelete:
93
+ if err := s.manager.DeleteTunnel(tunnelID); err != nil {
94
+ utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, err.Error())
95
+ return
96
+ }
97
+ case http.MethodPatch:
98
+ req, ok := utils.DecodeJSONRequest[types.AgentTunnelUpdateRequest](w, r, controlRequestBodyLimit)
99
+ if !ok {
100
+ return
101
+ }
102
+ if err := s.manager.UpdateTunnel(tunnelID, req); err != nil {
103
+ utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, err.Error())
104
+ return
105
+ }
106
+ default:
107
+ utils.MethodNotAllowedError().Write(w)
108
return
109
}
110
utils.WriteAPIData(w, http.StatusAccepted, map[string]bool{"accepted": true})
@@ -304,6 +316,11 @@ func SetMultiHop(ctx context.Context, stateDir, tunnelID string, relayURLs []str
316
return controlRequest(ctx, stateDir, http.MethodPost, path, types.AgentMultiHopRequest{Relays: relayURLs}, nil)
317
}
318
319
+func UpdateTunnel(ctx context.Context, stateDir, tunnelID string, req types.AgentTunnelUpdateRequest) error {
320
+ path := types.PathAgentTunnelsPrefix + url.PathEscape(tunnelID)
321
+ return controlRequest(ctx, stateDir, http.MethodPatch, path, req, nil)
322
+}
323
+
324
func controlRequest(ctx context.Context, stateDir, method, path string, payload any, out any) error {
325
stateDir = strings.TrimSpace(stateDir)
326
if stateDir == "" {
cmd/portal-tunnel/agent/dashboard.go
+541
-77
@@ -7,6 +7,7 @@ import (
7
"os/exec"
8
"runtime"
9
"slices"
10
+ "strconv"
11
"strings"
12
"time"
13
@@ -20,7 +21,7 @@ import (
21
22
const (
23
agentDashboardPollInterval = 2 * time.Second
23
- agentDashboardMinRelayRows = 1
24
+ agentDashboardMinRelayRows = 5
25
agentDashboardTunnelInputMaxWidth = 80
26
)
27
@@ -28,16 +29,40 @@ type agentDashboardAction int
29
30
const (
31
agentDashboardActionSelectTunnel agentDashboardAction = iota + 1
32
+ agentDashboardActionSelectPane
33
agentDashboardActionAddTunnel
34
+ agentDashboardActionCancelAddTunnel
35
agentDashboardActionDeleteTunnel
36
agentDashboardActionConnectRelay
37
agentDashboardActionDisconnectRelay
38
agentDashboardActionAddHop
39
agentDashboardActionApplyHop
40
agentDashboardActionClearHop
41
+ agentDashboardActionApplySettings
42
+ agentDashboardActionFocusSettingsField
43
agentDashboardActionOpenTunnelURL
44
)
45
46
+type agentDashboardPane int
47
+
48
+const (
49
+ agentDashboardPaneTunnels agentDashboardPane = iota
50
+ agentDashboardPaneRelays
51
+ agentDashboardPaneSettings
52
+ agentDashboardPaneMultiHop
53
+ agentDashboardPaneCount
54
+)
55
+
56
+const (
57
+ agentDashboardSettingsFieldMaxActiveRelays = iota
58
+ agentDashboardSettingsFieldDescription
59
+ agentDashboardSettingsFieldTags
60
+ agentDashboardSettingsFieldOwner
61
+ agentDashboardSettingsFieldThumbnail
62
+ agentDashboardSettingsFieldHide
63
+ agentDashboardSettingsFieldCount
64
+)
65
+
66
type agentDashboardModel struct {
67
configPath string
68
stateDir string
@@ -50,11 +75,22 @@ type agentDashboardModel struct {
75
76
selectedTunnelID string
77
selectedRelayURL string
78
+ activePane agentDashboardPane
79
80
routeDraft []string
81
draftTunnelID string
82
57
- input textinput.Model
83
+ addingTunnel bool
84
+ input textinput.Model
85
+
86
+ settingsEditTunnelID string
87
+ settingsFocus int
88
+ settingsMaxRelays textinput.Model
89
+ metadataDescription textinput.Model
90
+ metadataTags textinput.Model
91
+ metadataOwner textinput.Model
92
+ metadataThumbnail textinput.Model
93
+ metadataHide textinput.Model
94
}
95
96
type agentDashboardStatusMsg struct {
@@ -75,6 +111,8 @@ type agentDashboardRegion struct {
111
action agentDashboardAction
112
tunnel string
113
relay string
114
+ field int
115
+ pane agentDashboardPane
116
}
117
118
type agentDashboardButton struct {
@@ -101,22 +139,41 @@ var (
139
)
140
141
func RunDashboard(configPath, stateDir string) error {
142
+ input := newAgentDashboardTextInput()
143
+ input.Prompt = "Name port: "
144
+ input.Placeholder = "myname 3000"
145
+ input.Width = agentDashboardTunnelInputMaxWidth
146
+
147
+ model := agentDashboardModel{
148
+ configPath: configPath,
149
+ stateDir: stateDir,
150
+ input: input,
151
+ settingsMaxRelays: newAgentDashboardInlineInput("3"),
152
+ metadataDescription: newAgentDashboardInlineInput("description"),
153
+ metadataTags: newAgentDashboardInlineInput("api,staging"),
154
+ metadataOwner: newAgentDashboardInlineInput("owner"),
155
+ metadataThumbnail: newAgentDashboardInlineInput("https://..."),
156
+ metadataHide: newAgentDashboardInlineInput("true or false"),
157
+ }
158
+ model.resizeInputs(0)
159
+
160
+ _, err := tea.NewProgram(model, tea.WithAltScreen(), tea.WithMouseCellMotion()).Run()
161
+ return err
162
+}
163
+
164
+func newAgentDashboardTextInput() textinput.Model {
165
input := textinput.New()
166
input.CharLimit = 512
106
- input.Prompt = "New tunnel: "
107
- input.Placeholder = "name target (Examples: myname 3000)"
167
input.PromptStyle = agentDashboardSectionStyle
168
input.TextStyle = agentDashboardInputStyle
169
input.PlaceholderStyle = agentDashboardMutedStyle
111
- input.Width = agentDashboardTunnelInputMaxWidth
112
- _ = input.Focus()
170
+ return input
171
+}
172
114
- _, err := tea.NewProgram(agentDashboardModel{
115
- configPath: configPath,
116
- stateDir: stateDir,
117
- input: input,
118
- }, tea.WithAltScreen(), tea.WithMouseCellMotion()).Run()
119
- return err
173
+func newAgentDashboardInlineInput(placeholder string) textinput.Model {
174
+ input := newAgentDashboardTextInput()
175
+ input.Placeholder = placeholder
176
+ return input
177
}
178
179
func (m agentDashboardModel) Init() tea.Cmd {
@@ -128,9 +185,7 @@ func (m agentDashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
185
case tea.WindowSizeMsg:
186
m.width = msg.Width
187
m.height = msg.Height
131
- availableWidth := msg.Width - lipgloss.Width(m.input.Prompt)
132
- clearWidth := lipgloss.Width(m.input.Prompt) + lipgloss.Width(m.input.Placeholder)
133
- m.input.Width = max(clearWidth, min(agentDashboardTunnelInputMaxWidth, availableWidth))
188
+ m.resizeInputs(msg.Width)
189
return m, nil
190
case agentDashboardTickMsg:
191
return m, tea.Batch(agentDashboardFetchStatus(m.stateDir), agentDashboardTick())
@@ -142,6 +197,7 @@ func (m agentDashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
197
m.configPath = msg.status.ConfigPath
198
}
199
m.clampSelection()
200
+ m.ensureSelectedSettingsDraft()
201
}
202
return m, nil
203
case agentDashboardActionMsg:
@@ -161,57 +217,119 @@ func (m agentDashboardModel) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
217
case "ctrl+c":
218
return m, tea.Quit
219
case "esc":
164
- m.input.Reset()
220
m.err = nil
221
+ if m.activePane == agentDashboardPaneTunnels {
222
+ if m.addingTunnel {
223
+ m.cancelTunnelInput()
224
+ } else {
225
+ m.input.Reset()
226
+ }
227
+ } else {
228
+ m.setActivePane(agentDashboardPaneTunnels)
229
+ }
230
+ return m, nil
231
+ case "left":
232
+ m.setActivePane(m.activePane - 1)
233
+ return m, nil
234
+ case "right":
235
+ m.setActivePane(m.activePane + 1)
236
+ return m, nil
237
+ }
238
+
239
+ switch m.activePane {
240
+ case agentDashboardPaneTunnels:
241
+ return m.updateTunnelKeys(msg)
242
+ case agentDashboardPaneRelays:
243
+ return m.updateRelayKeys(msg)
244
+ case agentDashboardPaneSettings:
245
+ return m.updateSettingsKeys(msg)
246
+ case agentDashboardPaneMultiHop:
247
+ return m.updateMultiHopKeys(msg)
248
+ default:
249
+ m.setActivePane(agentDashboardPaneTunnels)
250
return m, nil
251
+ }
252
+}
253
+
254
+func (m agentDashboardModel) updateTunnelKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
255
+ switch msg.String() {
256
case "up":
168
- if m.selectedTunnelHasRelays() {
169
- m.selectRelayOffset(-1)
170
- return m, nil
171
- }
257
m.selectTunnelOffset(-1)
258
return m, nil
259
case "down":
175
- if m.selectedTunnelHasRelays() {
176
- m.selectRelayOffset(1)
177
- return m, nil
178
- }
260
m.selectTunnelOffset(1)
261
return m, nil
262
+ case "delete":
263
+ if !m.addingTunnel {
264
+ return m.deleteTunnel("")
265
+ }
266
case "enter":
182
- if strings.TrimSpace(m.input.Value()) != "" {
267
+ if m.addingTunnel {
268
return m.addTunnelFromInput()
269
}
185
- return m.runAction(agentDashboardActionConnectRelay, "", "")
270
+ }
271
+ if !m.addingTunnel {
272
+ return m, nil
273
}
274
var cmd tea.Cmd
275
m.input, cmd = m.input.Update(msg)
276
return m, cmd
277
}
278
279
+func (m agentDashboardModel) updateRelayKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
280
+ switch msg.String() {
281
+ case "up":
282
+ m.selectRelayOffset(-1)
283
+ case "down":
284
+ m.selectRelayOffset(1)
285
+ case "enter", "c":
286
+ return m.connectSelectedRelay()
287
+ case "d", "delete":
288
+ return m.disconnectSelectedRelay()
289
+ case "o":
290
+ return m.openRelayTunnelURL("", "")
291
+ }
292
+ return m, nil
293
+}
294
+
295
+func (m agentDashboardModel) updateMultiHopKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
296
+ switch msg.String() {
297
+ case "up":
298
+ m.selectRelayOffset(-1)
299
+ case "down":
300
+ m.selectRelayOffset(1)
301
+ case "enter", "a":
302
+ return m.addSelectedHop()
303
+ case "p":
304
+ return m.applyRoute()
305
+ case "c", "delete":
306
+ return m.clearRoute()
307
+ }
308
+ return m, nil
309
+}
310
+
311
func (m agentDashboardModel) updateMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
312
event := tea.MouseEvent(msg)
313
switch event.Button {
314
case tea.MouseButtonWheelUp:
196
- if m.selectedTunnelHasRelays() {
197
- m.selectRelayOffset(-1)
198
- return m, nil
199
- }
200
- m.selectTunnelOffset(-1)
201
- return m, nil
315
+ return m.scrollActivePane(-1)
316
case tea.MouseButtonWheelDown:
203
- if m.selectedTunnelHasRelays() {
204
- m.selectRelayOffset(1)
205
- return m, nil
206
- }
207
- m.selectTunnelOffset(1)
208
- return m, nil
317
+ return m.scrollActivePane(1)
318
}
319
if event.Action != tea.MouseActionPress || event.Button != tea.MouseButtonLeft {
320
return m, nil
321
}
322
for _, region := range m.layout().regions {
323
if event.Y == region.y && event.X >= region.x0 && event.X < region.x1 {
324
+ if region.action == agentDashboardActionSelectPane {
325
+ m.setActivePane(region.pane)
326
+ return m, nil
327
+ }
328
+ if region.action == agentDashboardActionFocusSettingsField {
329
+ m.setActivePane(agentDashboardPaneSettings)
330
+ m.focusSettingsField(region.field)
331
+ return m, nil
332
+ }
333
return m.runAction(region.action, region.tunnel, region.relay)
334
}
335
}
@@ -225,7 +343,9 @@ func (m agentDashboardModel) runAction(action agentDashboardAction, tunnelID, re
343
m.selectTunnel(tunnelID)
344
}
345
case agentDashboardActionAddTunnel:
228
- return m.addTunnelFromInput()
346
+ return m.startOrAddTunnel()
347
+ case agentDashboardActionCancelAddTunnel:
348
+ m.cancelTunnelInput()
349
case agentDashboardActionDeleteTunnel:
350
return m.deleteTunnel(tunnelID)
351
case agentDashboardActionConnectRelay:
@@ -238,6 +358,8 @@ func (m agentDashboardModel) runAction(action agentDashboardAction, tunnelID, re
358
return m.applyRoute()
359
case agentDashboardActionClearHop:
360
return m.clearRoute()
361
+ case agentDashboardActionApplySettings:
362
+ return m.applySettingsEdit()
363
case agentDashboardActionOpenTunnelURL:
364
return m.openRelayTunnelURL(tunnelID, relayURL)
365
}
@@ -293,6 +415,7 @@ func (m *agentDashboardModel) selectTunnelIndex(index int) {
415
if len(m.status.Tunnels[index].Relays) > 0 {
416
m.selectedRelayURL = m.status.Tunnels[index].Relays[0].RelayURL
417
}
418
+ m.loadSettingsDraft(m.status.Tunnels[index])
419
}
420
421
func (m *agentDashboardModel) selectTunnelOffset(delta int) {
@@ -317,12 +440,48 @@ func (m *agentDashboardModel) selectRelayOffset(delta int) {
440
m.selectRelay(tunnel.Relays[next].RelayURL)
441
}
442
443
+func (m *agentDashboardModel) setActivePane(pane agentDashboardPane) {
444
+ if pane < 0 {
445
+ pane = agentDashboardPaneCount - 1
446
+ }
447
+ if pane >= agentDashboardPaneCount {
448
+ pane = 0
449
+ }
450
+ m.activePane = pane
451
+ m.input.Blur()
452
+ m.blurSettingsInputs()
453
+ switch pane {
454
+ case agentDashboardPaneTunnels:
455
+ if m.addingTunnel {
456
+ _ = m.input.Focus()
457
+ }
458
+ case agentDashboardPaneSettings:
459
+ m.ensureSelectedSettingsDraft()
460
+ if input := m.focusedSettingsInput(); input != nil {
461
+ _ = input.Focus()
462
+ }
463
+ }
464
+}
465
+
466
+func (m agentDashboardModel) scrollActivePane(delta int) (tea.Model, tea.Cmd) {
467
+ switch m.activePane {
468
+ case agentDashboardPaneTunnels:
469
+ m.selectTunnelOffset(delta)
470
+ case agentDashboardPaneRelays, agentDashboardPaneMultiHop:
471
+ m.selectRelayOffset(delta)
472
+ case agentDashboardPaneSettings:
473
+ m.focusSettingsField(m.settingsFocus + delta)
474
+ }
475
+ return m, nil
476
+}
477
+
478
func (m *agentDashboardModel) clampSelection() {
479
if len(m.status.Tunnels) == 0 {
480
m.selectedTunnelID = ""
481
m.selectedRelayURL = ""
482
m.routeDraft = nil
483
m.draftTunnelID = ""
484
+ m.clearSettingsDraft()
485
return
486
}
487
@@ -333,6 +492,7 @@ func (m *agentDashboardModel) clampSelection() {
492
m.draftTunnelID = ""
493
}
494
m.selectedTunnelID = tunnelID
495
+ m.ensureSettingsDraft(m.status.Tunnels[tunnelIndex])
496
497
relays := m.status.Tunnels[tunnelIndex].Relays
498
if len(relays) == 0 {
@@ -390,19 +550,163 @@ func (m agentDashboardModel) selectedTunnelRelay() (types.AgentTunnelStatus, typ
550
return tunnel, relay, true
551
}
552
393
-func (m agentDashboardModel) selectedTunnelHasRelays() bool {
553
+func (m agentDashboardModel) updateSettingsKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
554
+ switch msg.String() {
555
+ case "tab", "down":
556
+ m.focusSettingsField(m.settingsFocus + 1)
557
+ return m, nil
558
+ case "shift+tab", "up":
559
+ m.focusSettingsField(m.settingsFocus - 1)
560
+ return m, nil
561
+ case "enter":
562
+ return m.applySettingsEdit()
563
+ }
564
+
565
+ input := m.focusedSettingsInput()
566
+ if input == nil {
567
+ return m, nil
568
+ }
569
+ var cmd tea.Cmd
570
+ *input, cmd = input.Update(msg)
571
+ return m, cmd
572
+}
573
+
574
+func (m *agentDashboardModel) focusSettingsField(field int) {
575
+ if field < 0 {
576
+ field = agentDashboardSettingsFieldCount - 1
577
+ }
578
+ if field >= agentDashboardSettingsFieldCount {
579
+ field = 0
580
+ }
581
+ m.input.Blur()
582
+ m.settingsFocus = field
583
+ for _, input := range []*textinput.Model{
584
+ &m.settingsMaxRelays,
585
+ &m.metadataDescription,
586
+ &m.metadataTags,
587
+ &m.metadataOwner,
588
+ &m.metadataThumbnail,
589
+ &m.metadataHide,
590
+ } {
591
+ input.Blur()
592
+ }
593
+ if input := m.focusedSettingsInput(); input != nil {
594
+ _ = input.Focus()
595
+ }
596
+}
597
+
598
+func (m *agentDashboardModel) focusedSettingsInput() *textinput.Model {
599
+ switch m.settingsFocus {
600
+ case agentDashboardSettingsFieldMaxActiveRelays:
601
+ return &m.settingsMaxRelays
602
+ case agentDashboardSettingsFieldDescription:
603
+ return &m.metadataDescription
604
+ case agentDashboardSettingsFieldTags:
605
+ return &m.metadataTags
606
+ case agentDashboardSettingsFieldOwner:
607
+ return &m.metadataOwner
608
+ case agentDashboardSettingsFieldThumbnail:
609
+ return &m.metadataThumbnail
610
+ case agentDashboardSettingsFieldHide:
611
+ return &m.metadataHide
612
+ default:
613
+ return nil
614
+ }
615
+}
616
+
617
+func (m *agentDashboardModel) blurSettingsInputs() {
618
+ for _, input := range []*textinput.Model{
619
+ &m.settingsMaxRelays,
620
+ &m.metadataDescription,
621
+ &m.metadataTags,
622
+ &m.metadataOwner,
623
+ &m.metadataThumbnail,
624
+ &m.metadataHide,
625
+ } {
626
+ input.Blur()
627
+ }
628
+}
629
+
630
+func (m *agentDashboardModel) clearSettingsDraft() {
631
+ m.settingsEditTunnelID = ""
632
+ for _, input := range []*textinput.Model{
633
+ &m.settingsMaxRelays,
634
+ &m.metadataDescription,
635
+ &m.metadataTags,
636
+ &m.metadataOwner,
637
+ &m.metadataThumbnail,
638
+ &m.metadataHide,
639
+ } {
640
+ input.Reset()
641
+ }
642
+ m.blurSettingsInputs()
643
+ if m.activePane == agentDashboardPaneTunnels && m.addingTunnel {
644
+ _ = m.input.Focus()
645
+ }
646
+}
647
+
648
+func (m *agentDashboardModel) resizeInputs(width int) {
649
+ if width <= 0 {
650
+ width = 88
651
+ }
652
+ availableWidth := width - lipgloss.Width(m.input.Prompt)
653
+ m.input.Width = max(1, min(agentDashboardTunnelInputMaxWidth, availableWidth))
654
+
655
+ settingsWidth := max(1, min(agentDashboardTunnelInputMaxWidth, width-13))
656
+ for _, input := range []*textinput.Model{
657
+ &m.settingsMaxRelays,
658
+ &m.metadataDescription,
659
+ &m.metadataTags,
660
+ &m.metadataOwner,
661
+ &m.metadataThumbnail,
662
+ &m.metadataHide,
663
+ } {
664
+ input.Width = settingsWidth
665
+ }
666
+}
667
+
668
+func (m *agentDashboardModel) ensureSelectedSettingsDraft() {
669
tunnel, ok := m.selectedTunnelStatus()
395
- return ok && len(tunnel.Relays) > 0
670
+ if !ok {
671
+ return
672
+ }
673
+ m.ensureSettingsDraft(tunnel)
674
+}
675
+
676
+func (m *agentDashboardModel) ensureSettingsDraft(tunnel types.AgentTunnelStatus) {
677
+ if m.settingsEditTunnelID == tunnel.ID {
678
+ return
679
+ }
680
+ m.loadSettingsDraft(tunnel)
681
+}
682
+
683
+func (m *agentDashboardModel) loadSettingsDraft(tunnel types.AgentTunnelStatus) {
684
+ metadata := tunnel.Metadata
685
+ m.settingsEditTunnelID = tunnel.ID
686
+ m.settingsMaxRelays.SetValue(strconv.Itoa(tunnel.MaxActiveRelays))
687
+ m.metadataDescription.SetValue(strings.TrimSpace(metadata.Description))
688
+ m.metadataTags.SetValue(strings.Join(metadata.Tags, ","))
689
+ m.metadataOwner.SetValue(strings.TrimSpace(metadata.Owner))
690
+ m.metadataThumbnail.SetValue(strings.TrimSpace(metadata.Thumbnail))
691
+ m.metadataHide.SetValue(strconv.FormatBool(metadata.Hide))
692
+ m.settingsMaxRelays.CursorEnd()
693
+ m.metadataDescription.CursorEnd()
694
+ m.metadataTags.CursorEnd()
695
+ m.metadataOwner.CursorEnd()
696
+ m.metadataThumbnail.CursorEnd()
697
+ m.metadataHide.CursorEnd()
698
}
699
700
func (m agentDashboardModel) addTunnelFromInput() (tea.Model, tea.Cmd) {
701
value := strings.TrimSpace(m.input.Value())
702
if value == "" {
703
+ m.addingTunnel = true
704
+ _ = m.input.Focus()
705
return m, nil
706
}
707
fields := strings.Fields(value)
708
if len(fields) < 2 {
405
- m.err = fmt.Errorf("use: name target")
709
+ m.err = fmt.Errorf("use: name port")
710
return m, nil
711
}
712
name := strings.Join(fields[:len(fields)-1], " ")
@@ -419,6 +723,8 @@ func (m agentDashboardModel) addTunnelFromInput() (tea.Model, tea.Cmd) {
723
724
m.err = nil
725
m.input.Reset()
726
+ m.addingTunnel = false
727
+ m.input.Blur()
728
return m, agentDashboardRun(func(ctx context.Context) error {
729
return AddTunnel(ctx, m.stateDir, types.AgentTunnelRequest{
730
Name: name,
@@ -427,6 +733,64 @@ func (m agentDashboardModel) addTunnelFromInput() (tea.Model, tea.Cmd) {
733
})
734
}
735
736
+func (m agentDashboardModel) startOrAddTunnel() (tea.Model, tea.Cmd) {
737
+ if !m.addingTunnel {
738
+ m.addingTunnel = true
739
+ m.setActivePane(agentDashboardPaneTunnels)
740
+ _ = m.input.Focus()
741
+ return m, nil
742
+ }
743
+ return m.addTunnelFromInput()
744
+}
745
+
746
+func (m *agentDashboardModel) cancelTunnelInput() {
747
+ m.addingTunnel = false
748
+ m.input.Reset()
749
+ m.input.Blur()
750
+}
751
+
752
+func (m agentDashboardModel) applySettingsEdit() (tea.Model, tea.Cmd) {
753
+ tunnel, ok := m.selectedTunnelStatus()
754
+ if !ok {
755
+ return m, nil
756
+ }
757
+ maxActiveRelays, err := strconv.Atoi(strings.TrimSpace(m.settingsMaxRelays.Value()))
758
+ if err != nil || maxActiveRelays <= 0 {
759
+ m.err = fmt.Errorf("max active relays must be a positive integer")
760
+ return m, nil
761
+ }
762
+
763
+ hideRaw := strings.TrimSpace(m.metadataHide.Value())
764
+ if hideRaw == "" {
765
+ hideRaw = "false"
766
+ }
767
+ hide, err := strconv.ParseBool(hideRaw)
768
+ if err != nil {
769
+ m.err = fmt.Errorf("metadata hidden must be true or false")
770
+ return m, nil
771
+ }
772
+
773
+ description := strings.TrimSpace(m.metadataDescription.Value())
774
+ tags := utils.SplitCSV(m.metadataTags.Value())
775
+ owner := strings.TrimSpace(m.metadataOwner.Value())
776
+ thumbnail := strings.TrimSpace(m.metadataThumbnail.Value())
777
+ metadata := types.AgentMetadataRequest{
778
+ Description: &description,
779
+ Tags: &tags,
780
+ Owner: &owner,
781
+ Thumbnail: &thumbnail,
782
+ Hide: &hide,
783
+ }
784
+ req := types.AgentTunnelUpdateRequest{
785
+ MaxActiveRelays: &maxActiveRelays,
786
+ Metadata: &metadata,
787
+ }
788
+ m.err = nil
789
+ return m, agentDashboardRun(func(ctx context.Context) error {
790
+ return UpdateTunnel(ctx, m.stateDir, tunnel.ID, req)
791
+ })
792
+}
793
+
794
func (m agentDashboardModel) deleteTunnel(tunnelID string) (tea.Model, tea.Cmd) {
795
tunnelID = strings.TrimSpace(tunnelID)
796
if tunnelID == "" {
@@ -602,7 +966,7 @@ func (m agentDashboardModel) layout() agentDashboardView {
966
bodyHeight = max(1, m.height-len(layout.lines))
967
}
968
605
- tunnels := m.renderTunnelsSection(width)
969
+ tunnels := m.renderTunnelsSection(width, m.tunnelsSectionHeight(bodyHeight))
970
layout.addView(tunnels)
971
layout.addLine("")
972
if m.height > 0 {
@@ -613,14 +977,42 @@ func (m agentDashboardModel) layout() agentDashboardView {
977
return layout
978
}
979
616
-func (m agentDashboardModel) renderTunnelsSection(width int) agentDashboardView {
980
+func (m agentDashboardModel) tunnelsSectionHeight(bodyHeight int) int {
981
+ if bodyHeight <= 0 {
982
+ return bodyHeight
983
+ }
984
+ if _, ok := m.selectedTunnelStatus(); !ok {
985
+ return bodyHeight
986
+ }
987
+ minRelaySectionHeight := agentDashboardMinRelayRows + 3
988
+ if bodyHeight <= minRelaySectionHeight {
989
+ return 1
990
+ }
991
+ return max(1, bodyHeight-minRelaySectionHeight-1)
992
+}
993
+
994
+func (m agentDashboardModel) renderTunnelsSection(width, height int) agentDashboardView {
995
var pane agentDashboardView
618
- pane.addStyled(width, agentDashboardSectionStyle, "Tunnels")
619
- pane.addButtons(width,
620
- agentDashboardButton{label: "Add Tunnel", action: agentDashboardActionAddTunnel, disabled: strings.TrimSpace(m.input.Value()) == ""},
996
+ pane.addSectionTitle(width, agentDashboardPaneTunnels, "Tunnels", m.activePane == agentDashboardPaneTunnels)
997
+ addLabel := "Add Tunnel"
998
+ addDisabled := false
999
+ if m.addingTunnel {
1000
+ addLabel = "Create"
1001
+ addDisabled = strings.TrimSpace(m.input.Value()) == ""
1002
+ }
1003
+ buttons := []agentDashboardButton{
1004
+ {label: addLabel, action: agentDashboardActionAddTunnel, disabled: addDisabled},
1005
+ }
1006
+ if m.addingTunnel {
1007
+ buttons = append(buttons, agentDashboardButton{label: "Cancel", action: agentDashboardActionCancelAddTunnel})
1008
+ }
1009
+ buttons = append(buttons,
1010
agentDashboardButton{label: "Delete", action: agentDashboardActionDeleteTunnel, disabled: len(m.status.Tunnels) == 0},
1011
)
623
- pane.addLine(m.input.View())
1012
+ pane.addButtons(width, buttons...)
1013
+ if m.addingTunnel {
1014
+ pane.addLine(m.input.View())
1015
+ }
1016
tunnelRowWidth := agentDashboardTunnelTableWidth(width, m.status.Tunnels)
1017
pane.addLine(agentDashboardMutedStyle.Render(agentDashboardTunnelRow(tunnelRowWidth, "STATUS", "TARGET", "TUNNEL")))
1018
@@ -633,14 +1025,21 @@ func (m agentDashboardModel) renderTunnelsSection(width int) agentDashboardView
1025
if selectedTunnelID == "" && len(m.status.Tunnels) > 0 {
1026
selectedTunnelID = m.status.Tunnels[0].ID
1027
}
636
- for _, tunnel := range m.status.Tunnels {
637
- pane.addTunnelRow(width, tunnelRowWidth, tunnel, tunnel.ID == selectedTunnelID)
1028
+ maxRows := len(m.status.Tunnels)
1029
+ if height > 0 {
1030
+ maxRows = max(1, height-len(pane.lines)-2)
1031
+ }
1032
+ selectedIndex := m.selectedTunnelIndex()
1033
+ start, end := agentDashboardRelayWindow(selectedIndex, len(m.status.Tunnels), maxRows)
1034
+ rowWidth := tunnelRowWidth
1035
+ for i := start; i < end; i++ {
1036
+ tunnel := m.status.Tunnels[i]
1037
+ pane.addTunnelRow(width, rowWidth, tunnel, tunnel.ID == selectedTunnelID)
1038
}
1039
1040
if tunnel, ok := m.selectedTunnelStatus(); ok && strings.TrimSpace(tunnel.LastError) != "" {
1041
pane.addStyled(width, agentDashboardErrorStyle, "Error: "+tunnel.LastError)
1042
}
643
- pane.addLine(agentDashboardMutedStyle.Render(strings.Repeat("-", width)))
1043
return pane
1044
}
1045
@@ -648,7 +1047,7 @@ func (m agentDashboardModel) renderTunnelPane(width, height int) agentDashboardV
1047
var pane agentDashboardView
1048
tunnel, ok := m.selectedTunnelStatus()
1049
if !ok {
651
- pane.addStyled(width, agentDashboardSectionStyle, "Relays")
1050
+ pane.addSectionTitle(width, agentDashboardPaneRelays, "Relays", m.activePane == agentDashboardPaneRelays)
1051
pane.addStyled(width, agentDashboardMutedStyle, "select a tunnel")
1052
pane.clip(height)
1053
return pane
@@ -657,6 +1056,8 @@ func (m agentDashboardModel) renderTunnelPane(width, height int) agentDashboardV
1056
relayLimit := m.relayRowsForHeight(tunnel, height)
1057
m.renderRelaysSection(&pane, width, relayLimit, tunnel)
1058
pane.addLine("")
1059
+ m.renderSettingsSection(&pane, width, max(1, height-len(pane.lines)), tunnel)
1060
+ pane.addLine("")
1061
m.renderRouteSection(&pane, width, max(1, height-len(pane.lines)), tunnel)
1062
pane.clip(height)
1063
return pane
@@ -667,8 +1068,9 @@ func (m agentDashboardModel) relayRowsForHeight(tunnel types.AgentTunnelStatus,
1068
return 0
1069
}
1070
routeRows := len(m.displayedRoute(tunnel))
670
- routeReserve := min(max(4, routeRows+3), 8)
671
- relayRows := height - routeReserve - 4
1071
+ routeReserve := min(max(5, routeRows+4), 9)
1072
+ settingsReserve := 9
1073
+ relayRows := height - routeReserve - settingsReserve - 4
1074
if relayRows < agentDashboardMinRelayRows {
1075
relayRows = min(agentDashboardMinRelayRows, len(tunnel.Relays))
1076
}
@@ -680,7 +1082,7 @@ func (m agentDashboardModel) renderRelaysSection(pane *agentDashboardView, width
1082
connectDisabled := !hasRelay || relay.Banned || relayDashboardActive(tunnel, relay)
1083
disconnectDisabled := !hasRelay || relay.Banned || !relayDashboardActive(tunnel, relay) || slices.Contains(m.displayedRoute(tunnel), relay.RelayURL)
1084
683
- pane.addStyled(width, agentDashboardSectionStyle, "Relays")
1085
+ pane.addSectionTitle(width, agentDashboardPaneRelays, "Relays", m.activePane == agentDashboardPaneRelays)
1086
pane.addButtons(width,
1087
agentDashboardButton{label: "Connect", action: agentDashboardActionConnectRelay, disabled: connectDisabled},
1088
agentDashboardButton{label: "Disconnect", action: agentDashboardActionDisconnectRelay, disabled: disconnectDisabled},
@@ -698,9 +1100,6 @@ func (m agentDashboardModel) renderRelaysSection(pane *agentDashboardView, width
1100
selectedRelayIndex := m.selectedRelayIndex(tunnel)
1101
start, end := agentDashboardRelayWindow(selectedRelayIndex, len(tunnel.Relays), maxRows)
1102
rowWidth := width
701
- if len(tunnel.Relays) > maxRows && width > 1 {
702
- rowWidth = width - 1
703
- }
1103
for i := start; i < end; i++ {
1104
relay := tunnel.Relays[i]
1105
line := agentDashboardRelayRow(rowWidth,
@@ -708,13 +1107,47 @@ func (m agentDashboardModel) renderRelaysSection(pane *agentDashboardView, width
1107
relayDashboardFeatures(relay),
1108
relayDashboardURL(relay),
1109
)
711
- if rowWidth < width {
712
- line = agentDashboardFit(line, rowWidth) + agentDashboardScrollCell(i-start, start, len(tunnel.Relays), end-start)
713
- }
1110
pane.addClickRow(line, width, agentDashboardRelayStyle(relay.RelayURL == selectedRelayURL, tunnel, relay), agentDashboardActionOpenTunnelURL, tunnel.ID, relay.RelayURL)
1111
}
1112
}
1113
1114
+func (m agentDashboardModel) renderSettingsSection(pane *agentDashboardView, width, height int, tunnel types.AgentTunnelStatus) {
1115
+ if height <= 0 {
1116
+ return
1117
+ }
1118
+ startLine := len(pane.lines)
1119
+ pane.addSectionTitle(width, agentDashboardPaneSettings, "Settings", m.activePane == agentDashboardPaneSettings)
1120
+ pane.addButtons(width,
1121
+ agentDashboardButton{label: "Apply", action: agentDashboardActionApplySettings, disabled: m.settingsEditTunnelID != tunnel.ID},
1122
+ )
1123
+ if len(pane.lines)-startLine >= height {
1124
+ return
1125
+ }
1126
+
1127
+ m.renderSettingsInputRows(pane, width, height, startLine)
1128
+}
1129
+
1130
+func (m agentDashboardModel) renderSettingsInputRows(pane *agentDashboardView, width, height, startLine int) {
1131
+ rows := []struct {
1132
+ label string
1133
+ input textinput.Model
1134
+ field int
1135
+ }{
1136
+ {label: "Max Relays", input: m.settingsMaxRelays, field: agentDashboardSettingsFieldMaxActiveRelays},
1137
+ {label: "Description", input: m.metadataDescription, field: agentDashboardSettingsFieldDescription},
1138
+ {label: "Tags", input: m.metadataTags, field: agentDashboardSettingsFieldTags},
1139
+ {label: "Owner", input: m.metadataOwner, field: agentDashboardSettingsFieldOwner},
1140
+ {label: "Thumbnail", input: m.metadataThumbnail, field: agentDashboardSettingsFieldThumbnail},
1141
+ {label: "Hidden", input: m.metadataHide, field: agentDashboardSettingsFieldHide},
1142
+ }
1143
+ for _, row := range rows {
1144
+ if len(pane.lines)-startLine >= height {
1145
+ return
1146
+ }
1147
+ pane.addSettingsInputRow(width, row.label, row.input, row.field, m.settingsFocus == row.field)
1148
+ }
1149
+}
1150
+
1151
func (m agentDashboardModel) renderRouteSection(pane *agentDashboardView, width, height int, tunnel types.AgentTunnelStatus) {
1152
if height <= 0 {
1153
return
@@ -725,13 +1158,19 @@ func (m agentDashboardModel) renderRouteSection(pane *agentDashboardView, width,
1158
canAdd := hasRelay && relay.SupportsOverlay && !inRoute
1159
1160
startLine := len(pane.lines)
728
- pane.addStyled(width, agentDashboardSectionStyle, "Multi-hop")
1161
+ pane.addSectionTitle(width, agentDashboardPaneMultiHop, "Multi-hop", m.activePane == agentDashboardPaneMultiHop)
1162
pane.addButtons(width,
1163
agentDashboardButton{label: "Add Hop", action: agentDashboardActionAddHop, disabled: !canAdd},
1164
agentDashboardButton{label: "Apply", action: agentDashboardActionApplyHop, disabled: len(route) < 2},
1165
agentDashboardButton{label: "Clear", action: agentDashboardActionClearHop, disabled: len(route) == 0},
1166
)
1167
1168
+ if hasRelay {
1169
+ pane.addText(width, "Selected relay: "+relayDashboardURL(relay))
1170
+ } else {
1171
+ pane.addStyled(width, agentDashboardMutedStyle, "no relays")
1172
+ }
1173
+
1174
routeLabel := "Multi-hop:"
1175
if m.draftTunnelID == tunnel.ID {
1176
routeLabel += " draft"
@@ -760,6 +1199,26 @@ func (v *agentDashboardView) addStyled(width int, style lipgloss.Style, text str
1199
v.addLine(style.Render(agentDashboardFit(text, width)))
1200
}
1201
1202
+func (v *agentDashboardView) addSectionTitle(width int, pane agentDashboardPane, title string, active bool) {
1203
+ if width <= 0 {
1204
+ width = 1
1205
+ }
1206
+ style := agentDashboardSectionStyle
1207
+ if active {
1208
+ style = agentDashboardSelectedStyle
1209
+ }
1210
+ line := agentDashboardFit(title, width)
1211
+ y := len(v.lines)
1212
+ v.lines = append(v.lines, style.Render(line))
1213
+ v.regions = append(v.regions, agentDashboardRegion{
1214
+ x0: 0,
1215
+ x1: min(lipgloss.Width(line), width),
1216
+ y: y,
1217
+ action: agentDashboardActionSelectPane,
1218
+ pane: pane,
1219
+ })
1220
+}
1221
+
1222
func (v *agentDashboardView) addButtons(width int, buttons ...agentDashboardButton) {
1223
lines, regions := agentDashboardRenderButtons(width, len(v.lines), 0, buttons...)
1224
v.lines = append(v.lines, lines...)
@@ -807,6 +1266,26 @@ func (v *agentDashboardView) addClickRow(line string, width int, style lipgloss.
1266
})
1267
}
1268
1269
+func (v *agentDashboardView) addSettingsInputRow(width int, label string, input textinput.Model, field int, focused bool) {
1270
+ if width <= 0 {
1271
+ width = 1
1272
+ }
1273
+ labelStyle := agentDashboardMutedStyle
1274
+ if focused {
1275
+ labelStyle = agentDashboardInputStyle
1276
+ }
1277
+ labelText := agentDashboardCell(label+":", 12)
1278
+ y := len(v.lines)
1279
+ v.lines = append(v.lines, labelStyle.Render(labelText)+" "+input.View())
1280
+ v.regions = append(v.regions, agentDashboardRegion{
1281
+ x0: 0,
1282
+ x1: width,
1283
+ y: y,
1284
+ action: agentDashboardActionFocusSettingsField,
1285
+ field: field,
1286
+ })
1287
+}
1288
+
1289
func (v *agentDashboardView) clip(height int) {
1290
if height <= 0 || len(v.lines) <= height {
1291
return
@@ -1016,21 +1495,6 @@ func agentDashboardRelayWindow(selected, total, rows int) (int, int) {
1495
return start, start + rows
1496
}
1497
1019
-func agentDashboardScrollCell(row, start, total, visible int) string {
1020
- if total <= visible || visible <= 0 {
1021
- return " "
1022
- }
1023
- thumbSize := max(1, visible*visible/total)
1024
- thumbStart := 0
1025
- if total > visible {
1026
- thumbStart = start * (visible - thumbSize) / (total - visible)
1027
- }
1028
- if row >= thumbStart && row < thumbStart+thumbSize {
1029
- return "#"
1030
- }
1031
- return "|"
1032
-}
1033
-
1498
func agentDashboardRelayRow(width int, mode, features, displayURL string) string {
1499
if width < 28 {
1500
return agentDashboardFit(mode+" "+displayURL, width)
cmd/portal-tunnel/agent/manager.go
+109
-22
@@ -155,6 +155,51 @@ func (m *manager) SetMultiHop(id string, relayURLs []string) error {
155
return tunnel.SetMultiHop(multiHop)
156
}
157
158
+func (m *manager) UpdateTunnel(id string, req types.AgentTunnelUpdateRequest) error {
159
+ if req.Empty() {
160
+ return errors.New("tunnel update requires at least one field")
161
+ }
162
+ updateMetadata := req.Metadata != nil && !req.Metadata.Empty()
163
+ updateMaxActiveRelays := req.MaxActiveRelays != nil
164
+ if err := m.updateTunnelConfig(id, func(tunnel *TunnelConfig) error {
165
+ if req.MaxActiveRelays != nil {
166
+ if *req.MaxActiveRelays <= 0 {
167
+ return errors.New("max_active_relays must be a positive integer")
168
+ }
169
+ tunnel.MaxActiveRelays = *req.MaxActiveRelays
170
+ }
171
+ if req.Metadata != nil {
172
+ if req.Metadata.Description != nil {
173
+ tunnel.Description = strings.TrimSpace(*req.Metadata.Description)
174
+ }
175
+ if req.Metadata.Owner != nil {
176
+ tunnel.Owner = strings.TrimSpace(*req.Metadata.Owner)
177
+ }
178
+ if req.Metadata.Thumbnail != nil {
179
+ tunnel.Thumbnail = strings.TrimSpace(*req.Metadata.Thumbnail)
180
+ }
181
+ if req.Metadata.Tags != nil {
182
+ tunnel.Tags = normalizeAgentMetadataTags(*req.Metadata.Tags)
183
+ }
184
+ if req.Metadata.Hide != nil {
185
+ tunnel.Hide = *req.Metadata.Hide
186
+ }
187
+ }
188
+ return nil
189
+ }); err != nil {
190
+ return err
191
+ }
192
+
193
+ id = strings.TrimSpace(id)
194
+ m.mu.RLock()
195
+ tunnel := m.tunnels[id]
196
+ m.mu.RUnlock()
197
+ if tunnel == nil {
198
+ return fmt.Errorf("tunnel %q not found", id)
199
+ }
200
+ return tunnel.UpdateSettings(updateMetadata, updateMaxActiveRelays)
201
+}
202
+
203
func (m *manager) AddTunnel(req types.AgentTunnelRequest) error {
204
m.configMu.Lock()
205
defer m.configMu.Unlock()
@@ -472,6 +517,24 @@ func (t *managedTunnel) SetMultiHop(relayURLs []string) error {
517
return exposure.SetMultiHop(relayURLs)
518
}
519
520
+func (t *managedTunnel) UpdateSettings(updateMetadata, updateMaxActiveRelays bool) error {
521
+ t.mu.RLock()
522
+ cfg := t.cfg
523
+ exposure := t.exposure
524
+ t.mu.RUnlock()
525
+ if exposure == nil {
526
+ return nil
527
+ }
528
+ var err error
529
+ if updateMetadata {
530
+ err = errors.Join(err, exposure.UpdateMetadata(metadataFromTunnelConfig(cfg)))
531
+ }
532
+ if updateMaxActiveRelays {
533
+ err = errors.Join(err, exposure.UpdateMaxActiveRelays(cfg.MaxActiveRelays))
534
+ }
535
+ return err
536
+}
537
+
538
func (t *managedTunnel) Snapshot() types.AgentTunnelStatus {
539
t.mu.RLock()
540
cfg := t.cfg
@@ -501,12 +564,14 @@ func (t *managedTunnel) Snapshot() types.AgentTunnelStatus {
564
}
565
566
status := types.AgentTunnelStatus{
504
- ID: cfg.ID,
505
- Name: cfg.Name,
506
- State: state,
507
- TargetAddr: cfg.TargetAddr,
508
- LastError: lastError,
509
- MultiHop: append([]string(nil), cfg.MultiHop...),
567
+ ID: cfg.ID,
568
+ Name: cfg.Name,
569
+ State: state,
570
+ TargetAddr: cfg.TargetAddr,
571
+ LastError: lastError,
572
+ MaxActiveRelays: cfg.MaxActiveRelays,
573
+ Metadata: metadataFromTunnelConfig(cfg),
574
+ MultiHop: append([]string(nil), cfg.MultiHop...),
575
}
576
if exposure == nil {
577
if strings.TrimSpace(runtime.Address) != "" {
@@ -525,10 +590,11 @@ func (t *managedTunnel) Snapshot() types.AgentTunnelStatus {
590
t.mu.Lock()
591
if t.exposure == exposure {
592
t.runtime = types.AgentTunnelStatus{
528
- Address: snapshot.Address,
529
- TargetAddr: snapshot.TargetAddr,
530
- MultiHop: append([]string(nil), snapshot.MultiHop...),
531
- Relays: append([]types.AgentRelayStatus(nil), snapshot.Relays...),
593
+ Address: snapshot.Address,
594
+ TargetAddr: snapshot.TargetAddr,
595
+ MaxActiveRelays: snapshot.MaxActiveRelays,
596
+ MultiHop: append([]string(nil), snapshot.MultiHop...),
597
+ Relays: append([]types.AgentRelayStatus(nil), snapshot.Relays...),
598
}
599
}
600
t.mu.Unlock()
@@ -580,9 +646,9 @@ func (t *managedTunnel) runOnce(ctx context.Context) error {
646
exposure, err := sdk.Expose(ctx, sdk.ExposeConfig{
647
RelayURLs: append([]string(nil), cfg.RelayURLs...),
648
Discovery: discovery,
649
+ Identity: types.Identity{Name: cfg.Name},
650
IdentityPath: cfg.IdentityPath,
651
IdentityJSON: cfg.IdentityJSON,
585
- Name: cfg.Name,
652
TargetAddr: cfg.TargetAddr,
653
UDPAddr: cfg.UDPAddr,
654
UDPEnabled: cfg.UDPEnabled,
@@ -591,13 +657,7 @@ func (t *managedTunnel) runOnce(ctx context.Context) error {
657
MultiHopDepth: cfg.MultiHopDepth,
658
BanMITM: banMITM,
659
MaxActiveRelays: cfg.MaxActiveRelays,
594
- Metadata: types.LeaseMetadata{
595
- Description: cfg.Description,
596
- Tags: append([]string(nil), cfg.Tags...),
597
- Owner: cfg.Owner,
598
- Thumbnail: cfg.Thumbnail,
599
- Hide: cfg.Hide,
600
- },
660
+ Metadata: metadataFromTunnelConfig(cfg),
661
})
662
if err != nil {
663
return err
@@ -606,10 +666,11 @@ func (t *managedTunnel) runOnce(ctx context.Context) error {
666
t.mu.Lock()
667
t.exposure = exposure
668
t.runtime = types.AgentTunnelStatus{
609
- Address: snapshot.Address,
610
- TargetAddr: snapshot.TargetAddr,
611
- MultiHop: append([]string(nil), snapshot.MultiHop...),
612
- Relays: append([]types.AgentRelayStatus(nil), snapshot.Relays...),
669
+ Address: snapshot.Address,
670
+ TargetAddr: snapshot.TargetAddr,
671
+ MaxActiveRelays: snapshot.MaxActiveRelays,
672
+ MultiHop: append([]string(nil), snapshot.MultiHop...),
673
+ Relays: append([]types.AgentRelayStatus(nil), snapshot.Relays...),
674
}
675
t.lastError = ""
676
t.mu.Unlock()
@@ -633,3 +694,29 @@ func (t *managedTunnel) runOnce(ctx context.Context) error {
694
}
695
return err
696
}
697
+
698
+func metadataFromTunnelConfig(cfg TunnelConfig) types.LeaseMetadata {
699
+ return types.LeaseMetadata{
700
+ Description: strings.TrimSpace(cfg.Description),
701
+ Tags: normalizeAgentMetadataTags(cfg.Tags),
702
+ Owner: strings.TrimSpace(cfg.Owner),
703
+ Thumbnail: strings.TrimSpace(cfg.Thumbnail),
704
+ Hide: cfg.Hide,
705
+ }
706
+}
707
+
708
+func normalizeAgentMetadataTags(tags []string) []string {
709
+ if len(tags) == 0 {
710
+ return nil
711
+ }
712
+ out := make([]string, 0, len(tags))
713
+ for _, tag := range tags {
714
+ if tag = strings.TrimSpace(tag); tag != "" {
715
+ out = append(out, tag)
716
+ }
717
+ }
718
+ if len(out) == 0 {
719
+ return nil
720
+ }
721
+ return out
722
+}
cmd/portal-tunnel/main.go
+1
-1
@@ -142,9 +142,9 @@ func runExposeCommand(args []string) error {
142
exposure, err := sdk.Expose(ctx, sdk.ExposeConfig{
143
RelayURLs: utils.SplitCSV(flags.relayCSV),
144
Discovery: flags.discovery,
145
+ Identity: types.Identity{Name: flags.name},
146
IdentityPath: flags.identityPath,
147
IdentityJSON: flags.identityJSON,
147
- Name: flags.name,
148
TargetAddr: flags.targetAddr,
149
UDPAddr: flags.udpAddr,
150
UDPEnabled: flags.udp,
docs/src/routes/cli-reference/+page.md
+1
-1
@@ -201,7 +201,7 @@ portal agent restart
201
|---------|-------------|
202
| `portal agent run` | Install or update and start the managed agent service |
203
| `portal agent run --config config.toml --foreground` | Run the agent in the current terminal |
204
-| `portal agent dashboard` | Open the local TUI for tunnels, relays, and multi-hop routes |
204
+| `portal agent dashboard` | Open the local TUI for tunnels, relays, multi-hop routes, and settings |
205
| `portal agent stop` | Gracefully stop the agent and disable or stop the OS service |
206
| `portal agent restart` | Stop the current agent if present, install or update the service, and start it again |
207
portal/multihop_local_test.go
+1
-1
@@ -266,7 +266,7 @@ func (c *localRelayCluster) relayURLs() []string {
266
func (c *localRelayCluster) exposeMultiHop(t *testing.T, ctx context.Context, name string) *sdk.Exposure {
267
t.Helper()
268
exposure, err := sdk.Expose(ctx, sdk.ExposeConfig{
269
- Name: name,
269
+ Identity: types.Identity{Name: name},
270
TargetAddr: "127.0.0.1:1",
271
MultiHop: c.relayURLs(),
272
BanMITM: false,
sdk/api_client.go
+7
-3
@@ -113,7 +113,7 @@ func (l *listener) buildHopRoutes(hopPath []types.RelayDescriptor, publicHostnam
113
route.RouteHostname = routeHostname
114
route.HostnameHash = utils.HostnameHash(publicHostname)
115
route.ECHConfigList = bytes.Clone(echConfigList)
116
- route.Metadata = l.metadata.Copy()
116
+ route.Metadata = l.metadataSnapshot()
117
route.Metadata.Hide = true
118
} else {
119
route.MatchToken = previousHopToken
@@ -194,7 +194,7 @@ func (l *listener) registerLease(ctx context.Context, ttl time.Duration, udpEnab
194
195
registerReq := types.RegisterChallengeRequest{
196
Identity: registerIdentity,
197
- Metadata: l.metadata,
197
+ Metadata: l.metadataSnapshot(),
198
TTL: int(ttl / time.Second),
199
UDPEnabled: udpEnabled,
200
TCPEnabled: tcpEnabled,
@@ -243,7 +243,7 @@ func (l *listener) registerLease(ctx context.Context, ttl time.Duration, udpEnab
243
244
func (l *listener) renewRegisteredLease(ctx context.Context, ttl time.Duration, accessToken string) (types.RenewResponse, error) {
245
var resp types.RenewResponse
246
- req := newRenewRequest(ttl, accessToken, utils.ResolvePublicIP(ctx), l.metadata)
246
+ req := newRenewRequest(ttl, accessToken, utils.ResolvePublicIP(ctx), l.metadataSnapshot())
247
if err := utils.HTTPDoAPIPath(ctx, l.httpClient, l.relayURL, http.MethodPost, types.PathSDKRenew, req, nil, &resp); err != nil {
248
return types.RenewResponse{}, err
249
}
@@ -285,6 +285,10 @@ func (l *listener) registerHopRoutes(ctx context.Context, expiresAt time.Time, r
285
}
286
route.ForwardRelay = desc
287
route.FirstSeenAt = expiresAt.Add(-30 * time.Second)
288
+ if i == 0 {
289
+ route.Metadata = l.metadataSnapshot()
290
+ route.Metadata.Hide = true
291
+ }
292
route, err := auth.SignHopRoute(http.MethodPost, route, authority, expiresAt)
293
if err != nil {
294
return "", 0, err
sdk/expose.go
+183
-116
@@ -27,23 +27,14 @@ type Exposure struct {
27
cancel context.CancelFunc
28
done <-chan struct{}
29
30
- identity types.Identity
31
- explicitRelays []string
32
- TargetAddr string
33
- UDPAddr string
34
- udpEnabled bool
35
- tcpEnabled bool
36
- multiHop []string
37
- multiHopDepth int
38
- banMITM bool
39
- maxActiveRelays int
40
- metadata types.LeaseMetadata
30
+ cfgMu sync.RWMutex
31
+ cfg ExposeConfig
32
33
accepted chan net.Conn
34
datagrams chan types.DatagramFrame
35
36
relaySet *discovery.RelaySet
46
- listenerMu sync.RWMutex
37
+ mu sync.RWMutex
38
relayListeners map[string]*listener
39
40
closeOnce sync.Once
@@ -54,24 +45,55 @@ type ExposeConfig struct {
45
RelayURLs []string
46
Discovery bool
47
57
- IdentityPath string
58
- IdentityJSON string
59
- Name string
60
- TargetAddr string
61
- UDPAddr string
62
- UDPEnabled bool
63
- TCPEnabled bool
64
- // MultiHop is the caller-selected ordered relay URL path. The first URL is
65
- // the public entry relay and the last URL is the exit relay the SDK registers with.
66
- MultiHop []string
67
- // MultiHopDepth selects one automatic multi-hop route when >= 2. Values 0
68
- // and 1 keep the automatic route selector in single-hop relay pool mode.
48
+ Identity types.Identity
49
+ IdentityPath string
50
+ IdentityJSON string
51
+ TargetAddr string
52
+ UDPAddr string
53
+ UDPEnabled bool
54
+ TCPEnabled bool
55
+ MultiHop []string
56
MultiHopDepth int
57
BanMITM bool
58
MaxActiveRelays int
59
Metadata types.LeaseMetadata
60
}
61
62
+func (cfg ExposeConfig) clone() ExposeConfig {
63
+ cfg.RelayURLs = append([]string(nil), cfg.RelayURLs...)
64
+ cfg.Identity = cfg.Identity.Copy()
65
+ cfg.MultiHop = append([]string(nil), cfg.MultiHop...)
66
+ cfg.Metadata = cfg.Metadata.Copy()
67
+ return cfg
68
+}
69
+
70
+func (e *Exposure) config() ExposeConfig {
71
+ if e == nil {
72
+ return ExposeConfig{}
73
+ }
74
+ e.cfgMu.RLock()
75
+ defer e.cfgMu.RUnlock()
76
+ return e.cfg.clone()
77
+}
78
+
79
+func (e *Exposure) updateCfg(update func(*ExposeConfig) error) error {
80
+ if e == nil {
81
+ return errors.New("exposure config is not initialized")
82
+ }
83
+ e.cfgMu.Lock()
84
+ defer e.cfgMu.Unlock()
85
+ return update(&e.cfg)
86
+}
87
+
88
+func (e *Exposure) metadata() types.LeaseMetadata {
89
+ if e == nil {
90
+ return types.LeaseMetadata{}
91
+ }
92
+ e.cfgMu.RLock()
93
+ defer e.cfgMu.RUnlock()
94
+ return e.cfg.Metadata.Copy()
95
+}
96
+
97
// Expose creates relay listeners for the selected relay pool and exposes a
98
// dynamic listener hub for accepting traffic from all of them.
99
func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
@@ -122,7 +144,7 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
144
}
145
146
listenerIdentity, createdIdentity, err := identity.ResolveListenerIdentity(
125
- types.Identity{Name: cfg.Name},
147
+ cfg.Identity.Copy(),
148
cfg.TargetAddr,
149
cfg.IdentityPath,
150
cfg.IdentityJSON,
@@ -147,25 +169,23 @@ func Expose(ctx context.Context, cfg ExposeConfig) (*Exposure, error) {
169
return nil, fmt.Errorf("invalid --udp-addr value %q: %w", cfg.UDPAddr, err)
170
}
171
}
172
+ runtimeCfg := cfg.clone()
173
+ runtimeCfg.RelayURLs = append([]string(nil), explicitRelayURLs...)
174
+ runtimeCfg.Identity = listenerIdentity.Copy()
175
+ runtimeCfg.TargetAddr = targetAddr
176
+ runtimeCfg.UDPAddr = udpAddr
177
+ runtimeCfg.MultiHop = append([]string(nil), multiHop...)
178
+ runtimeCfg.Metadata = cfg.Metadata.Copy()
179
+
180
exposureCtx, cancel := context.WithCancel(ctx)
181
exposure := &Exposure{
152
- cancel: cancel,
153
- done: exposureCtx.Done(),
154
- identity: listenerIdentity,
155
- explicitRelays: explicitRelayURLs,
156
- TargetAddr: targetAddr,
157
- UDPAddr: udpAddr,
158
- udpEnabled: cfg.UDPEnabled,
159
- tcpEnabled: cfg.TCPEnabled,
160
- multiHop: multiHop,
161
- multiHopDepth: cfg.MultiHopDepth,
162
- banMITM: cfg.BanMITM,
163
- maxActiveRelays: cfg.MaxActiveRelays,
164
- metadata: cfg.Metadata,
165
- accepted: make(chan net.Conn, max(initialRouteCapacity(listenerRelayURLs, cfg.MultiHopDepth)*defaultReadyTarget*2, 1)),
166
- datagrams: make(chan types.DatagramFrame, max(initialRouteCapacity(listenerRelayURLs, cfg.MultiHopDepth)*32, 1)),
167
- relaySet: discovery.NewRelaySet(relaySetURLs),
168
- relayListeners: make(map[string]*listener, initialRouteCapacity(listenerRelayURLs, cfg.MultiHopDepth)),
182
+ cancel: cancel,
183
+ done: exposureCtx.Done(),
184
+ cfg: runtimeCfg.clone(),
185
+ accepted: make(chan net.Conn, max(initialRouteCapacity(listenerRelayURLs, cfg.MultiHopDepth)*defaultReadyTarget*2, 1)),
186
+ datagrams: make(chan types.DatagramFrame, max(initialRouteCapacity(listenerRelayURLs, cfg.MultiHopDepth)*32, 1)),
187
+ relaySet: discovery.NewRelaySet(relaySetURLs),
188
+ relayListeners: make(map[string]*listener, initialRouteCapacity(listenerRelayURLs, cfg.MultiHopDepth)),
189
}
190
191
if cfg.Discovery || len(multiHop) > 0 || cfg.MultiHopDepth > 1 {
@@ -209,11 +229,14 @@ func (e *Exposure) AddRelay(relayURL string) error {
229
return errors.New("exposure relay set is not initialized")
230
}
231
212
- e.listenerMu.Lock()
213
- if !slices.Contains(e.explicitRelays, relayURL) {
214
- e.explicitRelays = append(append([]string(nil), e.explicitRelays...), relayURL)
232
+ if err := e.updateCfg(func(cfg *ExposeConfig) error {
233
+ if !slices.Contains(cfg.RelayURLs, relayURL) {
234
+ cfg.RelayURLs = append(append([]string(nil), cfg.RelayURLs...), relayURL)
235
+ }
236
+ return nil
237
+ }); err != nil {
238
+ return err
239
}
216
- e.listenerMu.Unlock()
240
241
e.relaySet.AllowRelayURL(relayURL)
242
e.relaySet.AddBootstrapRelayURL(relayURL)
@@ -234,19 +257,21 @@ func (e *Exposure) RemoveRelay(relayURL string) error {
257
return errors.New("exposure relay set is not initialized")
258
}
259
237
- e.listenerMu.Lock()
238
- if slices.Contains(e.multiHop, relayURL) {
239
- e.listenerMu.Unlock()
240
- return errors.New("relay is part of the multi-hop route; clear multi-hop first")
241
- }
242
- nextRelays := make([]string, 0, len(e.explicitRelays))
243
- for _, existing := range e.explicitRelays {
244
- if existing != relayURL {
245
- nextRelays = append(nextRelays, existing)
260
+ if err := e.updateCfg(func(cfg *ExposeConfig) error {
261
+ if slices.Contains(cfg.MultiHop, relayURL) {
262
+ return errors.New("relay is part of the multi-hop route; clear multi-hop first")
263
+ }
264
+ nextRelays := make([]string, 0, len(cfg.RelayURLs))
265
+ for _, existing := range cfg.RelayURLs {
266
+ if existing != relayURL {
267
+ nextRelays = append(nextRelays, existing)
268
+ }
269
}
270
+ cfg.RelayURLs = nextRelays
271
+ return nil
272
+ }); err != nil {
273
+ return err
274
}
248
- e.explicitRelays = nextRelays
249
- e.listenerMu.Unlock()
275
276
e.relaySet.DeactivateRelayURL(relayURL)
277
e.relaySet.RemoveBootstrapRelayURL(relayURL)
@@ -268,7 +293,8 @@ func (e *Exposure) SetMultiHop(relayURLs []string) error {
293
if len(multiHop) == 1 {
294
return errors.New("multi-hop requires at least entry and exit relay urls")
295
}
271
- if len(multiHop) > 0 && (e.udpEnabled || e.tcpEnabled) {
296
+ cfg := e.config()
297
+ if len(multiHop) > 0 && (cfg.UDPEnabled || cfg.TCPEnabled) {
298
return errors.New("multi-hop currently supports only the default SNI TLS stream transport")
299
}
300
if e.closed() {
@@ -283,10 +309,49 @@ func (e *Exposure) SetMultiHop(relayURLs []string) error {
309
e.relaySet.AddBootstrapRelayURL(relayURL)
310
}
311
286
- e.listenerMu.Lock()
287
- e.multiHop = append([]string(nil), multiHop...)
288
- e.multiHopDepth = 0
289
- e.listenerMu.Unlock()
312
+ if err := e.updateCfg(func(cfg *ExposeConfig) error {
313
+ cfg.MultiHop = append([]string(nil), multiHop...)
314
+ cfg.MultiHopDepth = 0
315
+ return nil
316
+ }); err != nil {
317
+ return err
318
+ }
319
+ return e.reconcileRelayListeners(false)
320
+}
321
+
322
+func (e *Exposure) UpdateMetadata(metadata types.LeaseMetadata) error {
323
+ if e.closed() {
324
+ return net.ErrClosed
325
+ }
326
+
327
+ if err := e.updateCfg(func(cfg *ExposeConfig) error {
328
+ cfg.Metadata = metadata.Copy()
329
+ return nil
330
+ }); err != nil {
331
+ return err
332
+ }
333
+ return nil
334
+}
335
+
336
+func (e *Exposure) UpdateMaxActiveRelays(maxActiveRelays int) error {
337
+ if maxActiveRelays <= 0 {
338
+ return errors.New("max_active_relays must be a positive integer")
339
+ }
340
+ if e.closed() {
341
+ return net.ErrClosed
342
+ }
343
+
344
+ changed := false
345
+ if err := e.updateCfg(func(cfg *ExposeConfig) error {
346
+ changed = cfg.MaxActiveRelays != maxActiveRelays
347
+ cfg.MaxActiveRelays = maxActiveRelays
348
+ return nil
349
+ }); err != nil {
350
+ return err
351
+ }
352
+ if !changed {
353
+ return nil
354
+ }
355
return e.reconcileRelayListeners(false)
356
}
357
@@ -298,8 +363,8 @@ func initialRouteCapacity(listenerRelayURLs []string, multiHopDepth int) int {
363
}
364
365
func (e *Exposure) ActiveRelayURLs() []string {
301
- e.listenerMu.RLock()
302
- defer e.listenerMu.RUnlock()
366
+ e.mu.RLock()
367
+ defer e.mu.RUnlock()
368
relayURLs := make([]string, 0, len(e.relayListeners))
369
for relayURL := range e.relayListeners {
370
relayURLs = append(relayURLs, relayURL)
@@ -318,10 +383,11 @@ func (e *Exposure) closed() bool {
383
}
384
385
func (e *Exposure) Addr() net.Addr {
321
- if e.identity.Address == "" {
386
+ identity := e.config().Identity
387
+ if identity.Address == "" {
388
return exposureAddr("portal:exposure")
389
}
324
- return exposureAddr("portal:" + e.identity.Address)
390
+ return exposureAddr("portal:" + identity.Address)
391
}
392
393
type exposureAddr string
@@ -329,21 +395,20 @@ type exposureAddr string
395
func (a exposureAddr) Network() string { return "portal" }
396
func (a exposureAddr) String() string { return string(a) }
397
332
-func (e *Exposure) Identity() types.Identity {
333
- return e.identity
398
+func (e *Exposure) Config() ExposeConfig {
399
+ return e.config()
400
}
401
402
func (e *Exposure) Snapshot() types.AgentTunnelStatus {
337
- e.listenerMu.RLock()
403
+ cfg := e.config()
404
+ e.mu.RLock()
405
listeners := make([]*listener, 0, len(e.relayListeners))
406
for _, listener := range e.relayListeners {
407
if listener != nil {
408
listeners = append(listeners, listener)
409
}
410
}
344
- multiHop := append([]string(nil), e.multiHop...)
345
- explicitRelays := append([]string(nil), e.explicitRelays...)
346
- e.listenerMu.RUnlock()
411
+ e.mu.RUnlock()
412
413
relayByURL := make(map[string]types.AgentRelayStatus, len(listeners))
414
for _, listener := range listeners {
@@ -353,7 +418,7 @@ func (e *Exposure) Snapshot() types.AgentTunnelStatus {
418
}
419
snap := types.AgentRelayStatus{
420
RelayURL: relayURL,
356
- Explicit: slices.Contains(explicitRelays, relayURL),
421
+ Explicit: slices.Contains(cfg.RelayURLs, relayURL),
422
Connecting: true,
423
}
424
if lease, ok := listener.leaseSnapshot(); ok {
@@ -372,7 +437,7 @@ func (e *Exposure) Snapshot() types.AgentTunnelStatus {
437
}
438
snap := relayByURL[relayURL]
439
snap.RelayURL = relayURL
375
- snap.Explicit = slices.Contains(explicitRelays, relayURL)
440
+ snap.Explicit = slices.Contains(cfg.RelayURLs, relayURL)
441
snap.Bootstrap = state.Bootstrap
442
snap.Banned = state.Banned
443
snap.SupportsOverlay = relay.SupportsOverlay
@@ -404,15 +469,17 @@ func (e *Exposure) Snapshot() types.AgentTunnelStatus {
469
})
470
471
return types.AgentTunnelStatus{
407
- Address: e.identity.Address,
408
- TargetAddr: e.TargetAddr,
409
- MultiHop: multiHop,
410
- Relays: relays,
472
+ Address: cfg.Identity.Address,
473
+ TargetAddr: cfg.TargetAddr,
474
+ MaxActiveRelays: cfg.MaxActiveRelays,
475
+ Metadata: cfg.Metadata,
476
+ MultiHop: cfg.MultiHop,
477
+ Relays: relays,
478
}
479
}
480
481
func (e *Exposure) AcceptDatagram() (types.DatagramFrame, error) {
415
- if !e.udpEnabled {
482
+ if !e.config().UDPEnabled {
483
return types.DatagramFrame{}, net.ErrClosed
484
}
485
@@ -425,13 +492,13 @@ func (e *Exposure) AcceptDatagram() (types.DatagramFrame, error) {
492
}
493
494
func (e *Exposure) SendDatagram(frame types.DatagramFrame) error {
428
- if !e.udpEnabled {
495
+ if !e.config().UDPEnabled {
496
return net.ErrClosed
497
}
498
432
- e.listenerMu.RLock()
499
+ e.mu.RLock()
500
listener := e.relayListeners[frame.RelayURL]
434
- e.listenerMu.RUnlock()
501
+ e.mu.RUnlock()
502
if listener == nil {
503
return net.ErrClosed
504
}
@@ -439,7 +506,7 @@ func (e *Exposure) SendDatagram(frame types.DatagramFrame) error {
506
}
507
508
func (e *Exposure) WaitDatagramReady(ctx context.Context) ([]string, error) {
442
- if !e.udpEnabled {
509
+ if !e.config().UDPEnabled {
510
return nil, errors.New("exposure does not have udp enabled")
511
}
512
@@ -447,7 +514,7 @@ func (e *Exposure) WaitDatagramReady(ctx context.Context) ([]string, error) {
514
defer ticker.Stop()
515
516
for {
450
- e.listenerMu.RLock()
517
+ e.mu.RLock()
518
addrs := make([]string, 0, len(e.relayListeners))
519
seen := make(map[string]struct{})
520
resolvedWithoutDatagram := true
@@ -467,7 +534,7 @@ func (e *Exposure) WaitDatagramReady(ctx context.Context) ([]string, error) {
534
resolvedWithoutDatagram = false
535
}
536
}
470
- e.listenerMu.RUnlock()
537
+ e.mu.RUnlock()
538
if len(addrs) > 0 {
539
return addrs, nil
540
}
@@ -499,9 +566,9 @@ func (e *Exposure) RunHTTP(ctx context.Context, handler http.Handler, localAddr
566
handler = http.NotFoundHandler()
567
}
568
502
- e.listenerMu.RLock()
569
+ e.mu.RLock()
570
hasRelayListeners := len(e.relayListeners) > 0
504
- e.listenerMu.RUnlock()
571
+ e.mu.RUnlock()
572
573
if hasRelayListeners {
574
return RunHTTP(ctx, e, handler, localAddr)
@@ -588,10 +655,10 @@ func (e *Exposure) Close() error {
655
e.cancel()
656
}
657
591
- e.listenerMu.Lock()
658
+ e.mu.Lock()
659
relayListeners := e.relayListeners
660
e.relayListeners = make(map[string]*listener)
594
- e.listenerMu.Unlock()
661
+ e.mu.Unlock()
662
663
relayURLs := make([]string, 0, len(relayListeners))
664
for relayURL, listener := range relayListeners {
@@ -640,28 +707,28 @@ func (e *Exposure) reconcileRelayListeners(failOnError bool) error {
707
var multiHop []string
708
var listenerRelayURLs []string
709
643
- e.listenerMu.Lock()
644
- multiHop = append([]string(nil), e.multiHop...)
645
- explicitRelays := append([]string(nil), e.explicitRelays...)
710
+ cfg := e.config()
711
+ e.mu.Lock()
712
+ multiHop = append([]string(nil), cfg.MultiHop...)
713
if len(multiHop) > 0 {
714
listenerRelayURLs = []string{multiHop[len(multiHop)-1]}
648
- } else if e.multiHopDepth > 1 {
715
+ } else if cfg.MultiHopDepth > 1 {
716
multiHop = e.relaySet.PriorityMultiHop(discovery.ClientState{
650
- MultiHopDepth: e.multiHopDepth,
651
- LocalAddress: e.identity.Address,
717
+ MultiHopDepth: cfg.MultiHopDepth,
718
+ LocalAddress: cfg.Identity.Address,
719
})
653
- if len(multiHop) < e.multiHopDepth {
654
- e.listenerMu.Unlock()
655
- return fmt.Errorf("multi-hop-depth %d requires %d overlay relay candidates, got %d", e.multiHopDepth, e.multiHopDepth, len(multiHop))
720
+ if len(multiHop) < cfg.MultiHopDepth {
721
+ e.mu.Unlock()
722
+ return fmt.Errorf("multi-hop-depth %d requires %d overlay relay candidates, got %d", cfg.MultiHopDepth, cfg.MultiHopDepth, len(multiHop))
723
}
724
listenerRelayURLs = []string{multiHop[len(multiHop)-1]}
725
} else {
726
listenerRelayURLs = e.relaySet.PriorityRelays(discovery.ClientState{
660
- ExplicitRelayURLs: explicitRelays,
661
- MaxActiveRelays: e.maxActiveRelays,
662
- RequireUDP: e.udpEnabled,
663
- RequireTCP: e.tcpEnabled,
664
- LocalAddress: e.identity.Address,
727
+ ExplicitRelayURLs: cfg.RelayURLs,
728
+ MaxActiveRelays: cfg.MaxActiveRelays,
729
+ RequireUDP: cfg.UDPEnabled,
730
+ RequireTCP: cfg.TCPEnabled,
731
+ LocalAddress: cfg.Identity.Address,
732
})
733
}
734
staleRelayListeners := make(map[string]*listener)
@@ -686,7 +753,7 @@ func (e *Exposure) reconcileRelayListeners(failOnError bool) error {
753
}
754
missingRelayURLs = append(missingRelayURLs, relayURL)
755
}
689
- e.listenerMu.Unlock()
756
+ e.mu.Unlock()
757
if len(removedRelayURLs) > 1 {
758
slices.Sort(removedRelayURLs)
759
}
@@ -706,17 +773,17 @@ func (e *Exposure) reconcileRelayListeners(failOnError bool) error {
773
listenerMultiHop = append([]string(nil), multiHop...)
774
}
775
retryCount := 10
709
- if len(listenerMultiHop) > 0 || slices.Contains(explicitRelays, relayURL) {
776
+ if len(listenerMultiHop) > 0 || slices.Contains(cfg.RelayURLs, relayURL) {
777
retryCount = 0
778
}
779
listener, err := newListener(context.Background(), relayURL, listenerConfig{
713
- Identity: e.identity,
714
- UDPEnabled: e.udpEnabled,
715
- TCPEnabled: e.tcpEnabled,
780
+ Identity: cfg.Identity.Copy(),
781
+ UDPEnabled: cfg.UDPEnabled,
782
+ TCPEnabled: cfg.TCPEnabled,
783
+ BanMITM: cfg.BanMITM,
784
+ Metadata: e.metadata,
785
MultiHop: listenerMultiHop,
717
- BanMITM: e.banMITM,
786
RetryCount: retryCount,
719
- Metadata: e.metadata,
787
relaySet: e.relaySet,
788
})
789
if err != nil {
@@ -734,14 +801,14 @@ func (e *Exposure) reconcileRelayListeners(failOnError bool) error {
801
default:
802
}
803
737
- e.listenerMu.Lock()
804
+ e.mu.Lock()
805
if _, exists := e.relayListeners[relayURL]; exists {
739
- e.listenerMu.Unlock()
806
+ e.mu.Unlock()
807
_ = listener.Close()
808
continue
809
}
810
e.relayListeners[relayURL] = listener
744
- e.listenerMu.Unlock()
811
+ e.mu.Unlock()
812
addedRelayURLs = append(addedRelayURLs, relayURL)
813
814
go e.runListenerAcceptLoop(listener)
@@ -766,7 +833,7 @@ func (e *Exposure) runListenerAcceptLoop(listener *listener) {
833
if listener.relayURL != nil {
834
relayURL = listener.relayURL.String()
835
}
769
- if e.udpEnabled {
836
+ if listener.udpEnabled {
837
go func() {
838
for {
839
frame, err := listener.acceptDatagram()
@@ -796,11 +863,11 @@ func (e *Exposure) runListenerAcceptLoop(listener *listener) {
863
}()
864
}
865
defer func() {
799
- e.listenerMu.Lock()
866
+ e.mu.Lock()
867
if current, ok := e.relayListeners[relayURL]; ok && current == listener {
868
delete(e.relayListeners, relayURL)
869
}
803
- e.listenerMu.Unlock()
870
+ e.mu.Unlock()
871
}()
872
873
for {
sdk/expose_test.go
+54
-9
@@ -5,6 +5,7 @@ import (
5
"testing"
6
7
"github.com/gosuda/portal-tunnel/v2/portal/discovery"
8
+ "github.com/gosuda/portal-tunnel/v2/types"
9
)
10
11
func mustRelaySet(t *testing.T, relayURLs ...string) *discovery.RelaySet {
@@ -12,6 +13,50 @@ func mustRelaySet(t *testing.T, relayURLs ...string) *discovery.RelaySet {
13
return discovery.NewRelaySet(relayURLs)
14
}
15
16
+func TestExposureConfigSnapshotsDoNotShareMutableState(t *testing.T) {
17
+ exposure := &Exposure{
18
+ cfg: ExposeConfig{
19
+ RelayURLs: []string{"https://relay-a.example"},
20
+ Identity: types.Identity{
21
+ Name: "svc",
22
+ Address: "portal-address",
23
+ },
24
+ Metadata: types.LeaseMetadata{
25
+ Tags: []string{"initial"},
26
+ },
27
+ },
28
+ }
29
+
30
+ snapshot := exposure.Config()
31
+ snapshot.RelayURLs[0] = "https://mutated.example"
32
+ snapshot.Metadata.Tags[0] = "mutated"
33
+
34
+ next := exposure.Config()
35
+ if got := next.RelayURLs[0]; got != "https://relay-a.example" {
36
+ t.Fatalf("RelayURLs[0] = %q, want original relay", got)
37
+ }
38
+ if got := next.Metadata.Tags[0]; got != "initial" {
39
+ t.Fatalf("Metadata.Tags[0] = %q, want original tag", got)
40
+ }
41
+
42
+ if err := exposure.updateCfg(func(cfg *ExposeConfig) error {
43
+ cfg.MaxActiveRelays = 2
44
+ cfg.Metadata = types.LeaseMetadata{Tags: []string{"updated"}}
45
+ return nil
46
+ }); err != nil {
47
+ t.Fatalf("Update() error = %v", err)
48
+ }
49
+
50
+ metadata := exposure.metadata()
51
+ metadata.Tags[0] = "mutated"
52
+ if got := exposure.metadata().Tags[0]; got != "updated" {
53
+ t.Fatalf("MetadataSnapshot().Tags[0] = %q, want updated", got)
54
+ }
55
+ if got := exposure.Config().MaxActiveRelays; got != 2 {
56
+ t.Fatalf("MaxActiveRelays = %d, want 2", got)
57
+ }
58
+}
59
+
60
func TestExposureReconcileRemovesBannedRelayFromActiveSet(t *testing.T) {
61
const (
62
relayA = "https://relay-a.example"
@@ -28,7 +73,7 @@ func TestExposureReconcileRemovesBannedRelayFromActiveSet(t *testing.T) {
73
}
74
75
exposure := &Exposure{
31
- explicitRelays: []string{relayA, relayB},
76
+ cfg: ExposeConfig{RelayURLs: []string{relayA, relayB}},
77
relaySet: mustRelaySet(t, relayA, relayB),
78
relayListeners: make(map[string]*listener, 2),
79
}
@@ -59,9 +104,9 @@ func TestExposureReconcileRemovesBannedRelayFromActiveSet(t *testing.T) {
104
t.Fatalf("ActiveRelayURLs() = %v, want [%q]", got, relayB)
105
}
106
62
- exposure.listenerMu.RLock()
107
+ exposure.mu.RLock()
108
_, listenerExists := exposure.relayListeners[relayA]
64
- exposure.listenerMu.RUnlock()
109
+ exposure.mu.RUnlock()
110
if listenerExists {
111
t.Fatal("banned relay listener still exists in exposure.listeners")
112
}
@@ -84,7 +129,7 @@ func TestExposureReconcileRemovesStaleListener(t *testing.T) {
129
130
relayAClosed := make(chan struct{})
131
exposure := &Exposure{
87
- explicitRelays: []string{relayA, relayB},
132
+ cfg: ExposeConfig{RelayURLs: []string{relayA, relayB}},
133
relaySet: mustRelaySet(t, relayA, relayB),
134
relayListeners: make(map[string]*listener, 2),
135
}
@@ -111,10 +156,10 @@ func TestExposureReconcileRemovesStaleListener(t *testing.T) {
156
}
157
158
knownRelayURLs := exposure.ActiveRelayURLs()
114
- exposure.listenerMu.RLock()
159
+ exposure.mu.RLock()
160
_, relayAExists := exposure.relayListeners[relayA]
161
_, relayBExists := exposure.relayListeners[relayB]
117
- exposure.listenerMu.RUnlock()
162
+ exposure.mu.RUnlock()
163
if len(knownRelayURLs) != 1 || knownRelayURLs[0] != relayB {
164
t.Fatalf("knownRelayURLs = %v, want [%q]", knownRelayURLs, relayB)
165
}
@@ -136,7 +181,7 @@ func TestExposureRemoveRelayStopsRunningListener(t *testing.T) {
181
182
relayAClosed := make(chan struct{})
183
exposure := &Exposure{
139
- explicitRelays: []string{relayA},
184
+ cfg: ExposeConfig{RelayURLs: []string{relayA}},
185
relaySet: mustRelaySet(t, relayA),
186
relayListeners: make(map[string]*listener, 1),
187
}
@@ -158,8 +203,8 @@ func TestExposureRemoveRelayStopsRunningListener(t *testing.T) {
203
if got := exposure.ActiveRelayURLs(); len(got) != 0 {
204
t.Fatalf("ActiveRelayURLs() = %v, want empty", got)
205
}
161
- if len(exposure.explicitRelays) != 0 {
162
- t.Fatalf("explicitRelays = %v, want empty", exposure.explicitRelays)
206
+ if got := exposure.Config().RelayURLs; len(got) != 0 {
207
+ t.Fatalf("RelayURLs = %v, want empty", got)
208
}
209
if got := exposure.relaySet.PriorityRelays(discovery.ClientState{}); len(got) != 0 {
210
t.Fatalf("PriorityRelays() = %v, want empty", got)
sdk/listener.go
+11
-4
@@ -32,7 +32,7 @@ type listenerConfig struct {
32
UDPEnabled bool
33
TCPEnabled bool
34
BanMITM bool
35
- Metadata types.LeaseMetadata
35
+ Metadata func() types.LeaseMetadata
36
DialTimeout time.Duration
37
RequestTimeout time.Duration
38
HandshakeTimeout time.Duration
@@ -53,8 +53,8 @@ type listener struct {
53
closeOnce sync.Once
54
55
relayURL *url.URL
56
+ metadata func() types.LeaseMetadata
57
identity types.Identity
57
- metadata types.LeaseMetadata
58
relaySet *discovery.RelaySet
59
multiHop []string
60
udpEnabled bool
@@ -105,10 +105,10 @@ func newListener(ctx context.Context, relayURL string, cfg listenerConfig) (*lis
105
cancel: cancel,
106
doneCh: listenerCtx.Done(),
107
relayURL: relayurl,
108
- identity: cfg.Identity,
108
metadata: cfg.Metadata,
109
+ identity: cfg.Identity.Copy(),
110
relaySet: cfg.relaySet,
111
- multiHop: cfg.MultiHop,
111
+ multiHop: append([]string(nil), cfg.MultiHop...),
112
udpEnabled: cfg.UDPEnabled,
113
tcpEnabled: cfg.TCPEnabled,
114
dialTimeout: dialTimeout,
@@ -135,6 +135,13 @@ func newListener(ctx context.Context, relayURL string, cfg listenerConfig) (*lis
135
return l, nil
136
}
137
138
+func (l *listener) metadataSnapshot() types.LeaseMetadata {
139
+ if l.metadata == nil {
140
+ return types.LeaseMetadata{}
141
+ }
142
+ return l.metadata()
143
+}
144
+
145
func (l *listener) run(ctx context.Context) {
146
var retries int
147
sdk/proxy.go
+4
-3
@@ -22,9 +22,10 @@ func ProxyExposure(ctx context.Context, exposure *Exposure) error {
22
return errors.New("no relay URLs provided")
23
}
24
25
- identity := exposure.Identity()
26
- tcpTarget := exposure.TargetAddr
27
- udpTarget := exposure.UDPAddr
25
+ cfg := exposure.Config()
26
+ identity := cfg.Identity
27
+ tcpTarget := cfg.TargetAddr
28
+ udpTarget := cfg.UDPAddr
29
udpEnabled := udpTarget != ""
30
31
log.Info().
types/agent.go
+36
-8
@@ -8,14 +8,16 @@ type AgentStatusResponse struct {
8
}
9
10
type AgentTunnelStatus struct {
11
- ID string `json:"id"`
12
- Name string `json:"name,omitempty"`
13
- Address string `json:"address,omitempty"`
14
- State string `json:"state"`
15
- TargetAddr string `json:"target_addr,omitempty"`
16
- LastError string `json:"last_error,omitempty"`
17
- MultiHop []string `json:"multi_hop,omitempty"`
18
- Relays []AgentRelayStatus `json:"relays,omitempty"`
11
+ ID string `json:"id"`
12
+ Name string `json:"name,omitempty"`
13
+ Address string `json:"address,omitempty"`
14
+ State string `json:"state"`
15
+ TargetAddr string `json:"target_addr,omitempty"`
16
+ LastError string `json:"last_error,omitempty"`
17
+ MaxActiveRelays int `json:"max_active_relays,omitempty"`
18
+ Metadata LeaseMetadata `json:"metadata,omitempty"`
19
+ MultiHop []string `json:"multi_hop,omitempty"`
20
+ Relays []AgentRelayStatus `json:"relays,omitempty"`
21
}
22
23
type AgentRelayStatus struct {
@@ -44,3 +46,29 @@ type AgentRelayRequest struct {
46
type AgentMultiHopRequest struct {
47
Relays []string `json:"relays"`
48
}
49
+
50
+type AgentTunnelUpdateRequest struct {
51
+ MaxActiveRelays *int `json:"max_active_relays,omitempty"`
52
+ Metadata *AgentMetadataRequest `json:"metadata,omitempty"`
53
+}
54
+
55
+func (r AgentTunnelUpdateRequest) Empty() bool {
56
+ return r.MaxActiveRelays == nil &&
57
+ (r.Metadata == nil || r.Metadata.Empty())
58
+}
59
+
60
+type AgentMetadataRequest struct {
61
+ Description *string `json:"description,omitempty"`
62
+ Owner *string `json:"owner,omitempty"`
63
+ Thumbnail *string `json:"thumbnail,omitempty"`
64
+ Tags *[]string `json:"tags,omitempty"`
65
+ Hide *bool `json:"hide,omitempty"`
66
+}
67
+
68
+func (r AgentMetadataRequest) Empty() bool {
69
+ return r.Description == nil &&
70
+ r.Owner == nil &&
71
+ r.Thumbnail == nil &&
72
+ r.Tags == nil &&
73
+ r.Hide == nil
74
+}