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 +}