Refactor tunnel management and configuration handling
rabbitprincess committed
May 2, 2026 at 20:27 UTC
dfbe76d104f0184e7bd64d84e28343d8ab11aa40
16 files changed
+1113
-616
cmd/portal-tunnel/README.md
+3
-3
@@ -113,11 +113,11 @@ Flags:
113
Runs Portal as a managed long-lived tunnel agent.
114
115
- `portal agent run` reads the platform default config path, installs or updates the OS service, and opens the dashboard when run from an interactive terminal.
116
-- `portal agent run --config config.toml --foreground` runs the agent in the current terminal without installing a service.
117
-- `portal agent dashboard` only attaches to a running agent. When using `--foreground`, keep that process running in one terminal and open the dashboard from another.
116
+- `portal agent run --config config.toml --foreground` runs the agent in the current terminal and opens the dashboard when the terminal is interactive.
117
+- `portal agent dashboard` attaches to an already running agent.
118
- The service process owns multiple tunnel definitions from one `config.toml`.
119
- The local control API is bound to loopback and authenticated with a token stored in the agent state directory.
120
-- `portal agent dashboard` opens the mouse-capable local TUI for tunnel state, discovered relays, public URLs, logs, reload, restart, relay attach/detach, and multi-hop route changes.
120
+- `portal agent dashboard` opens the mouse-capable local TUI for tunnel add/delete, per-tunnel relay add/delete/listing, and multi-hop route changes.
121
- `portal agent stop` asks the local agent to shut down, then disables/stops the OS service so intentional shutdown is not immediately restarted.
122
- If the config file is missing, `portal agent run` creates a default config and the agent creates the identity file on first tunnel start.
123
cmd/portal-tunnel/agent.go
+93
-1
@@ -11,6 +11,9 @@ import (
11
"strings"
12
"time"
13
14
+ "github.com/rs/zerolog"
15
+ "github.com/rs/zerolog/log"
16
+
17
"github.com/gosuda/portal-tunnel/v2/cmd/portal-tunnel/agent"
18
"github.com/gosuda/portal-tunnel/v2/cmd/portal-tunnel/agent/service"
19
"github.com/gosuda/portal-tunnel/v2/types"
@@ -53,13 +56,16 @@ func runAgentRunCommand(args []string) error {
56
if err != nil {
57
return err
58
}
56
- if serviceMode || foreground {
59
+ if serviceMode {
60
ctx, stop := utils.SignalContext()
61
defer stop()
62
return service.Run(ctx, cfg.Agent.ServiceName, func(ctx context.Context) error {
63
return agent.Run(ctx, cfg)
64
})
65
}
66
+ if foreground {
67
+ return runAgentForeground(configPath, cfg)
68
+ }
69
70
executable, err := os.Executable()
71
if err != nil {
@@ -101,6 +107,60 @@ func runAgentRunCommand(args []string) error {
107
return nil
108
}
109
110
+func runAgentForeground(configPath string, cfg agent.Config) error {
111
+ ctx, stop := utils.SignalContext()
112
+ defer stop()
113
+
114
+ if !agentCLIInteractive() {
115
+ return agent.Run(ctx, cfg)
116
+ }
117
+
118
+ resolvedConfigPath, err := filepath.Abs(strings.TrimSpace(configPath))
119
+ if err != nil {
120
+ return err
121
+ }
122
+
123
+ restoreLogs := suppressTerminalLogs()
124
+ defer restoreLogs()
125
+
126
+ errCh := make(chan error, 1)
127
+ go func() {
128
+ errCh <- agent.Run(ctx, cfg)
129
+ }()
130
+
131
+ readyCtx, readyCancel := context.WithTimeout(ctx, 15*time.Second)
132
+ status, err := waitAgentStatusOrExit(readyCtx, cfg.Agent.StateDir, errCh)
133
+ readyCancel()
134
+ if err != nil {
135
+ stop()
136
+ return err
137
+ }
138
+
139
+ dashboardErr := agent.RunDashboard(resolvedConfigPath, cfg.Agent.StateDir)
140
+ stop()
141
+ runErr := <-errCh
142
+ if errors.Is(runErr, context.Canceled) {
143
+ runErr = nil
144
+ }
145
+ if dashboardErr != nil {
146
+ return dashboardErr
147
+ }
148
+ if runErr != nil {
149
+ return runErr
150
+ }
151
+
152
+ fmt.Fprintf(os.Stdout, "Portal agent stopped after managing %d tunnel(s).\n", len(status.Tunnels))
153
+ return nil
154
+}
155
+
156
+func suppressTerminalLogs() func() {
157
+ previous := log.Logger
158
+ log.Logger = zerolog.New(io.Discard)
159
+ return func() {
160
+ log.Logger = previous
161
+ }
162
+}
163
+
164
func runAgentStopCommand(args []string) error {
165
var configPath string
166
var stateDir string
@@ -178,6 +238,38 @@ func waitAgentStatus(ctx context.Context, stateDir string) (types.AgentStatusRes
238
}
239
}
240
241
+func waitAgentStatusOrExit(ctx context.Context, stateDir string, errCh <-chan error) (types.AgentStatusResponse, error) {
242
+ ticker := time.NewTicker(300 * time.Millisecond)
243
+ defer ticker.Stop()
244
+ var lastErr error
245
+ for {
246
+ select {
247
+ case err := <-errCh:
248
+ if err == nil {
249
+ err = errors.New("portal agent stopped before dashboard was ready")
250
+ }
251
+ return types.AgentStatusResponse{}, err
252
+ default:
253
+ }
254
+
255
+ status, err := agent.Status(ctx, stateDir)
256
+ if err == nil {
257
+ return status, nil
258
+ }
259
+ lastErr = err
260
+ select {
261
+ case err := <-errCh:
262
+ if err == nil {
263
+ err = errors.New("portal agent stopped before dashboard was ready")
264
+ }
265
+ return types.AgentStatusResponse{}, err
266
+ case <-ctx.Done():
267
+ return types.AgentStatusResponse{}, fmt.Errorf("wait for portal agent status: %w", lastErr)
268
+ case <-ticker.C:
269
+ }
270
+ }
271
+}
272
+
273
func agentCLIInteractive() bool {
274
stdin, err := os.Stdin.Stat()
275
if err != nil || stdin.Mode()&os.ModeCharDevice == 0 {
cmd/portal-tunnel/agent/config.go
+167
-12
@@ -6,7 +6,6 @@ import (
6
"os"
7
"path/filepath"
8
"strings"
9
- "time"
9
10
"github.com/knadh/koanf/parsers/toml/v2"
11
"github.com/knadh/koanf/providers/file"
@@ -31,10 +30,9 @@ type Config struct {
30
}
31
32
type AgentConfig struct {
34
- StateDir string `koanf:"state_dir"`
35
- ControlAddr string `koanf:"control_addr"`
36
- ServiceName string `koanf:"service_name"`
37
- RestartDelay string `koanf:"restart_delay"`
33
+ StateDir string `koanf:"state_dir"`
34
+ ControlAddr string `koanf:"control_addr"`
35
+ ServiceName string `koanf:"service_name"`
36
}
37
38
type TunnelConfig struct {
@@ -84,7 +82,6 @@ func LoadConfig(path string) (Config, error) {
82
state_dir = %q
83
control_addr = %q
84
service_name = %q
87
-restart_delay = "5s"
85
86
[[tunnels]]
87
id = "default"
@@ -116,6 +113,170 @@ discovery = true
113
return cfg, cfg.Validate()
114
}
115
116
+func loadConfigDocument(path string) (Config, string, os.FileMode, error) {
117
+ path = strings.TrimSpace(path)
118
+ if path == "" {
119
+ path = service.DefaultConfigPath()
120
+ }
121
+ absPath, err := filepath.Abs(path)
122
+ if err != nil {
123
+ return Config{}, "", 0, err
124
+ }
125
+ if _, err := os.Stat(absPath); err != nil {
126
+ if errors.Is(err, os.ErrNotExist) {
127
+ if _, err := LoadConfig(absPath); err != nil {
128
+ return Config{}, "", 0, err
129
+ }
130
+ } else {
131
+ return Config{}, "", 0, err
132
+ }
133
+ }
134
+ info, err := os.Stat(absPath)
135
+ if err != nil {
136
+ return Config{}, "", 0, err
137
+ }
138
+ data, err := os.ReadFile(absPath)
139
+ if err != nil {
140
+ return Config{}, "", 0, err
141
+ }
142
+
143
+ var cfg Config
144
+ if strings.TrimSpace(string(data)) != "" {
145
+ k := koanf.New(".")
146
+ if err := k.Load(file.Provider(absPath), toml.Parser()); err != nil {
147
+ return Config{}, "", 0, err
148
+ }
149
+ if err := k.Unmarshal("", &cfg); err != nil {
150
+ return Config{}, "", 0, err
151
+ }
152
+ }
153
+ cfg.sourcePath = absPath
154
+ return cfg, absPath, info.Mode().Perm(), nil
155
+}
156
+
157
+func writeConfigDocument(path string, mode os.FileMode, cfg Config) error {
158
+ data, err := toml.Parser().Marshal(configDocumentMap(cfg))
159
+ if err != nil {
160
+ return err
161
+ }
162
+ if mode == 0 {
163
+ mode = 0o644
164
+ }
165
+ return os.WriteFile(path, data, mode)
166
+}
167
+
168
+func configDocumentMap(cfg Config) map[string]any {
169
+ return map[string]any{
170
+ "agent": agentConfigDocumentMap(cfg.Agent),
171
+ "tunnels": tunnelConfigDocumentMaps(cfg.Tunnels),
172
+ }
173
+}
174
+
175
+func agentConfigDocumentMap(cfg AgentConfig) map[string]any {
176
+ out := make(map[string]any)
177
+ addStringDocumentField(out, "state_dir", cfg.StateDir)
178
+ addStringDocumentField(out, "control_addr", cfg.ControlAddr)
179
+ addStringDocumentField(out, "service_name", cfg.ServiceName)
180
+ return out
181
+}
182
+
183
+func tunnelConfigDocumentMaps(tunnels []TunnelConfig) []map[string]any {
184
+ out := make([]map[string]any, 0, len(tunnels))
185
+ for _, tunnel := range tunnels {
186
+ out = append(out, tunnelConfigDocumentMap(tunnel))
187
+ }
188
+ return out
189
+}
190
+
191
+func tunnelConfigDocumentMap(cfg TunnelConfig) map[string]any {
192
+ out := make(map[string]any)
193
+ addStringDocumentField(out, "id", cfg.ID)
194
+ addStringDocumentField(out, "name", cfg.Name)
195
+ addStringDocumentField(out, "target", cfg.TargetAddr)
196
+ if len(cfg.HTTPRoutes) > 0 {
197
+ routes := make([]map[string]any, 0, len(cfg.HTTPRoutes))
198
+ for _, route := range cfg.HTTPRoutes {
199
+ routeMap := make(map[string]any)
200
+ addStringDocumentField(routeMap, "prefix", route.Prefix)
201
+ addStringDocumentField(routeMap, "upstream", route.Upstream)
202
+ routes = append(routes, routeMap)
203
+ }
204
+ out["http_routes"] = routes
205
+ }
206
+ addStringSliceDocumentField(out, "relays", cfg.RelayURLs)
207
+ if cfg.Discovery != nil {
208
+ out["discovery"] = *cfg.Discovery
209
+ }
210
+ addStringDocumentField(out, "identity_path", cfg.IdentityPath)
211
+ addStringDocumentField(out, "identity_json", cfg.IdentityJSON)
212
+ if cfg.UDPEnabled {
213
+ out["udp"] = cfg.UDPEnabled
214
+ }
215
+ addStringDocumentField(out, "udp_addr", cfg.UDPAddr)
216
+ if cfg.TCPEnabled {
217
+ out["tcp"] = cfg.TCPEnabled
218
+ }
219
+ addStringSliceDocumentField(out, "multi_hop", cfg.MultiHop)
220
+ if cfg.MultiHopDepth != 0 {
221
+ out["multi_hop_depth"] = cfg.MultiHopDepth
222
+ }
223
+ if cfg.BanMITM != nil {
224
+ out["ban_mitm"] = *cfg.BanMITM
225
+ }
226
+ if cfg.MaxActiveRelays != 0 {
227
+ out["max_active_relays"] = cfg.MaxActiveRelays
228
+ }
229
+ addStringDocumentField(out, "description", cfg.Description)
230
+ addStringSliceDocumentField(out, "tags", cfg.Tags)
231
+ addStringDocumentField(out, "owner", cfg.Owner)
232
+ addStringDocumentField(out, "thumbnail", cfg.Thumbnail)
233
+ if cfg.Hide {
234
+ out["hide"] = cfg.Hide
235
+ }
236
+ return out
237
+}
238
+
239
+func addStringDocumentField(out map[string]any, key, value string) {
240
+ if strings.TrimSpace(value) != "" {
241
+ out[key] = value
242
+ }
243
+}
244
+
245
+func addStringSliceDocumentField(out map[string]any, key string, value []string) {
246
+ if len(value) > 0 {
247
+ out[key] = append([]string(nil), value...)
248
+ }
249
+}
250
+
251
+func validateConfigDocument(path string, cfg Config) error {
252
+ next := cloneConfig(cfg)
253
+ if err := next.ApplyDefaults(path); err != nil {
254
+ return err
255
+ }
256
+ return next.Validate()
257
+}
258
+
259
+func cloneConfig(cfg Config) Config {
260
+ next := cfg
261
+ next.Tunnels = append([]TunnelConfig(nil), cfg.Tunnels...)
262
+ for i := range next.Tunnels {
263
+ tunnel := &next.Tunnels[i]
264
+ tunnel.HTTPRoutes = append([]HTTPRouteConfig(nil), tunnel.HTTPRoutes...)
265
+ tunnel.RelayURLs = append([]string(nil), tunnel.RelayURLs...)
266
+ tunnel.MultiHop = append([]string(nil), tunnel.MultiHop...)
267
+ tunnel.Tags = append([]string(nil), tunnel.Tags...)
268
+ if tunnel.Discovery != nil {
269
+ value := *tunnel.Discovery
270
+ tunnel.Discovery = &value
271
+ }
272
+ if tunnel.BanMITM != nil {
273
+ value := *tunnel.BanMITM
274
+ tunnel.BanMITM = &value
275
+ }
276
+ }
277
+ return next
278
+}
279
+
280
func (cfg *Config) ApplyDefaults(configPath string) error {
281
configDir := "."
282
if absConfig, err := filepath.Abs(strings.TrimSpace(configPath)); err == nil {
@@ -133,9 +294,6 @@ func (cfg *Config) ApplyDefaults(configPath string) error {
294
if strings.TrimSpace(cfg.Agent.ServiceName) == "" {
295
cfg.Agent.ServiceName = DefaultServiceName
296
}
136
- if strings.TrimSpace(cfg.Agent.RestartDelay) == "" {
137
- cfg.Agent.RestartDelay = "5s"
138
- }
297
298
for i := range cfg.Tunnels {
299
t := &cfg.Tunnels[i]
@@ -184,9 +342,6 @@ func (cfg Config) Validate() error {
342
if strings.TrimSpace(cfg.Agent.ControlAddr) == "" {
343
return errors.New("agent.control_addr is required")
344
}
187
- if _, err := time.ParseDuration(cfg.Agent.RestartDelay); err != nil {
188
- return fmt.Errorf("agent.restart_delay: %w", err)
189
- }
345
if len(cfg.Tunnels) == 0 {
346
return errors.New("at least one tunnel is required")
347
}
cmd/portal-tunnel/agent/control.go
+32
-19
@@ -29,7 +29,6 @@ type controlHandler struct {
29
manager *manager
30
token string
31
shutdown func()
32
- reload func() error
32
}
33
34
func (s *controlHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -53,15 +52,15 @@ func (s *controlHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
52
if s.shutdown != nil {
53
go s.shutdown()
54
}
56
- case r.URL.Path == types.PathAgentReload:
55
+ case r.URL.Path == types.PathAgentTunnels:
56
if !utils.RequireMethod(w, r, http.MethodPost) {
57
return
58
}
60
- if s.reload == nil {
61
- utils.WriteAPIError(w, http.StatusNotImplemented, types.APIErrorCodeFeatureUnavailable, "reload is not configured")
59
+ req, ok := utils.DecodeJSONRequest[types.AgentTunnelRequest](w, r, controlRequestBodyLimit)
60
+ if !ok {
61
return
62
}
64
- if err := s.reload(); err != nil {
63
+ if err := s.manager.AddTunnel(req); err != nil {
64
utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, err.Error())
65
return
66
}
@@ -75,17 +74,28 @@ func (s *controlHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
74
return
75
}
76
if !ok {
78
- utils.WriteAPIError(w, http.StatusNotFound, types.APIErrorCodeNotFound, "not found")
77
+ if !utils.RequireMethod(w, r, http.MethodDelete) {
78
+ return
79
+ }
80
+ if err := s.manager.DeleteTunnel(tunnelID); err != nil {
81
+ utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, err.Error())
82
+ return
83
+ }
84
+ utils.WriteAPIData(w, http.StatusAccepted, map[string]bool{"accepted": true})
85
return
86
}
87
88
switch action {
83
- case "restart":
89
+ case "relays/seed":
90
if !utils.RequireMethod(w, r, http.MethodPost) {
91
return
92
}
87
- if err := s.manager.RestartTunnel(tunnelID); err != nil {
88
- utils.WriteAPIError(w, http.StatusNotFound, types.APIErrorCodeNotFound, err.Error())
93
+ req, ok := utils.DecodeJSONRequest[types.AgentRelayRequest](w, r, controlRequestBodyLimit)
94
+ if !ok {
95
+ return
96
+ }
97
+ if err := s.manager.SeedRelay(tunnelID, req.RelayURL); err != nil {
98
+ utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, err.Error())
99
return
100
}
101
utils.WriteAPIData(w, http.StatusAccepted, map[string]bool{"accepted": true})
@@ -152,13 +162,13 @@ func Shutdown(ctx context.Context, stateDir string) error {
162
return controlRequest(ctx, stateDir, http.MethodPost, types.PathAgentShutdown, nil, nil)
163
}
164
155
-func Reload(ctx context.Context, stateDir string) error {
156
- return controlRequest(ctx, stateDir, http.MethodPost, types.PathAgentReload, nil, nil)
165
+func AddTunnel(ctx context.Context, stateDir string, req types.AgentTunnelRequest) error {
166
+ return controlRequest(ctx, stateDir, http.MethodPost, types.PathAgentTunnels, req, nil)
167
}
168
159
-func RestartTunnel(ctx context.Context, stateDir, tunnelID string) error {
160
- path := types.PathAgentTunnelsPrefix + url.PathEscape(tunnelID) + "/restart"
161
- return controlRequest(ctx, stateDir, http.MethodPost, path, nil, nil)
169
+func DeleteTunnel(ctx context.Context, stateDir, tunnelID string) error {
170
+ path := types.PathAgentTunnelsPrefix + url.PathEscape(tunnelID)
171
+ return controlRequest(ctx, stateDir, http.MethodDelete, path, nil, nil)
172
}
173
174
func AddRelay(ctx context.Context, stateDir, tunnelID, relayURL string) error {
@@ -171,14 +181,17 @@ func RemoveRelay(ctx context.Context, stateDir, tunnelID, relayURL string) error
181
return controlRequest(ctx, stateDir, http.MethodDelete, path, types.AgentRelayRequest{RelayURL: relayURL}, nil)
182
}
183
174
-func SetMultiHop(ctx context.Context, stateDir, tunnelID string, relayURLs []string) error {
175
- path := types.PathAgentTunnelsPrefix + url.PathEscape(tunnelID) + "/multi-hop"
176
- return controlRequest(ctx, stateDir, http.MethodPost, path, types.AgentMultiHopRequest{Relays: relayURLs}, nil)
184
+func SeedRelay(ctx context.Context, stateDir, tunnelID, relayURL string) error {
185
+ path := types.PathAgentTunnelsPrefix + url.PathEscape(tunnelID) + "/relays/seed"
186
+ return controlRequest(ctx, stateDir, http.MethodPost, path, types.AgentRelayRequest{RelayURL: relayURL}, nil)
187
}
188
179
-func ClearMultiHop(ctx context.Context, stateDir, tunnelID string) error {
189
+func SetMultiHop(ctx context.Context, stateDir, tunnelID string, relayURLs []string) error {
190
path := types.PathAgentTunnelsPrefix + url.PathEscape(tunnelID) + "/multi-hop"
181
- return controlRequest(ctx, stateDir, http.MethodDelete, path, nil, nil)
191
+ if relayURLs == nil {
192
+ return controlRequest(ctx, stateDir, http.MethodDelete, path, nil, nil)
193
+ }
194
+ return controlRequest(ctx, stateDir, http.MethodPost, path, types.AgentMultiHopRequest{Relays: relayURLs}, nil)
195
}
196
197
func controlRequest(ctx context.Context, stateDir, method, path string, payload any, out any) error {
cmd/portal-tunnel/agent/dashboard.go
+517
-377
@@ -4,38 +4,38 @@ import (
4
"context"
5
"fmt"
6
"slices"
7
+ "strconv"
8
"strings"
9
"time"
10
10
- "github.com/charmbracelet/bubbles/viewport"
11
+ "github.com/charmbracelet/bubbles/textinput"
12
tea "github.com/charmbracelet/bubbletea"
13
"github.com/charmbracelet/lipgloss"
14
15
"github.com/gosuda/portal-tunnel/v2/types"
16
)
17
17
-const agentDashboardRefreshInterval = 2 * time.Second
18
+const agentDashboardPollInterval = 2 * time.Second
19
19
-type agentDashboardTab int
20
+type agentDashboardMode int
21
22
const (
22
- agentDashboardRelaysTab agentDashboardTab = iota
23
- agentDashboardMultiHopTab
24
- agentDashboardLogsTab
23
+ agentDashboardNormalMode agentDashboardMode = iota
24
+ agentDashboardAddTunnelMode
25
+ agentDashboardAddRelayMode
26
)
27
28
type agentDashboardClick int
29
30
const (
30
- agentDashboardClickRefresh agentDashboardClick = iota + 1
31
- agentDashboardClickReload
32
- agentDashboardClickRestart
33
- agentDashboardClickQuit
34
- agentDashboardClickTunnel
35
- agentDashboardClickTab
31
+ agentDashboardClickTunnel agentDashboardClick = iota + 1
32
agentDashboardClickRelay
33
+ agentDashboardClickAddTunnel
34
+ agentDashboardClickDeleteTunnel
35
+ agentDashboardClickAddRelay
36
+ agentDashboardClickDeleteRelay
37
agentDashboardClickAttachRelay
38
- agentDashboardClickDetachRelay
38
+ agentDashboardClickSeedRelay
39
agentDashboardClickAddHop
40
agentDashboardClickRemoveHop
41
agentDashboardClickApplyHop
@@ -57,12 +57,12 @@ type agentDashboardModel struct {
57
selectedRelay int
58
selectedTunnelID string
59
selectedRelayURL string
60
- tab agentDashboardTab
60
61
multiHopDraft []string
62
draftTunnelID string
63
65
- logs viewport.Model
64
+ mode agentDashboardMode
65
+ input textinput.Model
66
}
67
68
type agentDashboardStatusMsg struct {
@@ -84,7 +84,6 @@ type agentDashboardClickRegion struct {
84
action agentDashboardClick
85
tunnel int
86
relay int
87
- tab agentDashboardTab
87
}
88
89
type agentDashboardLayout struct {
@@ -92,6 +91,17 @@ type agentDashboardLayout struct {
91
regions []agentDashboardClickRegion
92
}
93
94
+type agentDashboardButton struct {
95
+ label string
96
+ action agentDashboardClick
97
+ disabled bool
98
+}
99
+
100
+type agentDashboardPane struct {
101
+ lines []string
102
+ regions []agentDashboardClickRegion
103
+}
104
+
105
var (
106
agentDashboardTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("39"))
107
agentDashboardSectionStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("81"))
@@ -102,33 +112,19 @@ var (
112
agentDashboardErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
113
agentDashboardMessageStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214"))
114
agentDashboardOKStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("120"))
105
- agentDashboardHelpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244"))
115
agentDashboardInputStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("120"))
107
- agentDashboardTabStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244"))
108
- agentDashboardActiveTab = lipgloss.NewStyle().Foreground(lipgloss.Color("230")).Background(lipgloss.Color("31"))
116
)
117
111
-type agentDashboardButton struct {
112
- label string
113
- action agentDashboardClick
114
- disabled bool
115
-}
116
-
117
-type agentDashboardPane struct {
118
- lines []string
119
- regions []agentDashboardClickRegion
120
-}
121
-
118
func RunDashboard(configPath, stateDir string) error {
123
- logs := viewport.New(0, 0)
124
- logs.MouseWheelEnabled = true
125
- logs.MouseWheelDelta = 3
119
+ input := textinput.New()
120
+ input.CharLimit = 512
121
+ input.Prompt = "> "
122
+ input.Width = 72
123
124
_, err := tea.NewProgram(agentDashboardModel{
125
configPath: configPath,
126
stateDir: stateDir,
130
- tab: agentDashboardRelaysTab,
131
- logs: logs,
127
+ input: input,
128
}, tea.WithAltScreen(), tea.WithMouseCellMotion()).Run()
129
return err
130
}
@@ -142,7 +138,7 @@ func (m agentDashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
138
case tea.WindowSizeMsg:
139
m.width = msg.Width
140
m.height = msg.Height
145
- m.syncLogViewport()
141
+ m.input.Width = max(1, min(88, msg.Width-8))
142
return m, nil
143
case agentDashboardTickMsg:
144
return m, tea.Batch(agentDashboardFetchStatus(m.stateDir), agentDashboardTick())
@@ -150,19 +146,19 @@ func (m agentDashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
146
m.err = msg.err
147
if msg.err == nil {
148
m.status = msg.status
153
- m.message = ""
149
m.clampSelection()
150
}
156
- m.syncLogViewport()
151
return m, nil
152
case agentDashboardActionMsg:
153
m.err = msg.err
160
- m.message = msg.message
154
if msg.err != nil {
155
m.message = msg.err.Error()
163
- } else if msg.message == "multi-hop applied" || msg.message == "multi-hop cleared" {
164
- m.multiHopDraft = nil
165
- m.draftTunnelID = ""
156
+ } else {
157
+ m.message = msg.message
158
+ if msg.message == "multi-hop applied" || msg.message == "multi-hop cleared" {
159
+ m.multiHopDraft = nil
160
+ m.draftTunnelID = ""
161
+ }
162
}
163
return m, agentDashboardFetchStatus(m.stateDir)
164
case tea.KeyMsg:
@@ -175,30 +171,53 @@ func (m agentDashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
171
}
172
173
func (m agentDashboardModel) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
174
+ if m.mode != agentDashboardNormalMode {
175
+ switch msg.String() {
176
+ case "ctrl+c":
177
+ return m, tea.Quit
178
+ case "esc":
179
+ m.cancelInput()
180
+ return m, nil
181
+ case "enter":
182
+ return m.submitInput()
183
+ default:
184
+ var cmd tea.Cmd
185
+ m.input, cmd = m.input.Update(msg)
186
+ return m, cmd
187
+ }
188
+ }
189
+
190
switch msg.String() {
179
- case "ctrl+c", "q":
191
+ case "ctrl+c":
192
return m, tea.Quit
181
- case "enter":
182
- m.message = "refreshing..."
183
- return m, agentDashboardFetchStatus(m.stateDir)
193
case "up", "k":
185
- if m.selectedTunnel > 0 {
186
- m.selectedTunnel--
187
- m.selectedTunnelID = m.status.Tunnels[m.selectedTunnel].ID
188
- m.selectedRelay = 0
189
- m.selectedRelayURL = ""
190
- }
194
+ m.selectPreviousTunnel()
195
case "down", "j":
192
- if m.selectedTunnel+1 < len(m.status.Tunnels) {
193
- m.selectedTunnel++
194
- m.selectedTunnelID = m.status.Tunnels[m.selectedTunnel].ID
195
- m.selectedRelay = 0
196
- m.selectedRelayURL = ""
196
+ m.selectNextTunnel()
197
+ case "left", "h":
198
+ m.selectPreviousRelay()
199
+ case "right", "l":
200
+ m.selectNextRelay()
201
+ case "n":
202
+ return m.startInput(agentDashboardAddTunnelMode, "New tunnel: ", "name port")
203
+ case "x":
204
+ return m.deleteSelectedTunnel()
205
+ case "a":
206
+ if _, ok := m.selectedTunnelStatus(); !ok {
207
+ m.message = "select a tunnel first"
208
+ return m, nil
209
}
198
- case "left", "h", "shift+tab":
199
- m.prevTab()
200
- case "right", "l", "tab":
201
- m.nextTab()
210
+ return m.startInput(agentDashboardAddRelayMode, "Add relay: ", "https://relay.example.com")
211
+ case "d":
212
+ return m.deleteSelectedRelay()
213
+ case "m":
214
+ return m.addSelectedHop()
215
+ case "u":
216
+ return m.removeSelectedHop()
217
+ case "p":
218
+ return m.applyMultiHop()
219
+ case "c":
220
+ return m.clearMultiHop()
221
}
222
return m, nil
223
}
@@ -206,24 +225,11 @@ func (m agentDashboardModel) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
225
func (m agentDashboardModel) updateMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
226
event := tea.MouseEvent(msg)
227
switch event.Button {
209
- case tea.MouseButtonWheelUp, tea.MouseButtonWheelDown:
210
- if m.tab == agentDashboardLogsTab {
211
- var cmd tea.Cmd
212
- m.logs, cmd = m.logs.Update(msg)
213
- return m, cmd
214
- }
215
- if event.Button == tea.MouseButtonWheelUp && m.selectedTunnel > 0 {
216
- m.selectedTunnel--
217
- m.selectedTunnelID = m.status.Tunnels[m.selectedTunnel].ID
218
- m.selectedRelay = 0
219
- m.selectedRelayURL = ""
220
- }
221
- if event.Button == tea.MouseButtonWheelDown && m.selectedTunnel+1 < len(m.status.Tunnels) {
222
- m.selectedTunnel++
223
- m.selectedTunnelID = m.status.Tunnels[m.selectedTunnel].ID
224
- m.selectedRelay = 0
225
- m.selectedRelayURL = ""
226
- }
228
+ case tea.MouseButtonWheelUp:
229
+ m.selectPreviousTunnel()
230
+ return m, nil
231
+ case tea.MouseButtonWheelDown:
232
+ m.selectNextTunnel()
233
return m, nil
234
}
235
if event.Action != tea.MouseActionPress || event.Button != tea.MouseButtonLeft {
@@ -239,35 +245,30 @@ func (m agentDashboardModel) updateMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd)
245
246
func (m agentDashboardModel) applyClick(region agentDashboardClickRegion) (tea.Model, tea.Cmd) {
247
switch region.action {
242
- case agentDashboardClickRefresh:
243
- m.message = "refreshing..."
244
- return m, agentDashboardFetchStatus(m.stateDir)
245
- case agentDashboardClickReload:
246
- m.message = "reloading config..."
247
- return m, agentDashboardReload(m.stateDir)
248
- case agentDashboardClickRestart:
249
- return m.restartSelectedTunnel()
250
- case agentDashboardClickQuit:
251
- return m, tea.Quit
248
case agentDashboardClickTunnel:
249
if region.tunnel >= 0 && region.tunnel < len(m.status.Tunnels) {
254
- m.selectedTunnel = region.tunnel
255
- m.selectedTunnelID = m.status.Tunnels[region.tunnel].ID
256
- m.selectedRelay = 0
257
- m.selectedRelayURL = ""
250
+ m.selectTunnel(region.tunnel)
251
}
259
- case agentDashboardClickTab:
260
- m.tab = region.tab
261
- m.syncLogViewport()
252
case agentDashboardClickRelay:
253
if tunnel, ok := m.selectedTunnelStatus(); ok && region.relay >= 0 && region.relay < len(tunnel.Relays) {
264
- m.selectedRelay = region.relay
265
- m.selectedRelayURL = tunnel.Relays[region.relay].RelayURL
254
+ m.selectRelay(region.relay, tunnel.Relays[region.relay].RelayURL)
255
}
256
+ case agentDashboardClickAddTunnel:
257
+ return m.startInput(agentDashboardAddTunnelMode, "New tunnel: ", "name port")
258
+ case agentDashboardClickDeleteTunnel:
259
+ return m.deleteSelectedTunnel()
260
+ case agentDashboardClickAddRelay:
261
+ if _, ok := m.selectedTunnelStatus(); !ok {
262
+ m.message = "select a tunnel first"
263
+ return m, nil
264
+ }
265
+ return m.startInput(agentDashboardAddRelayMode, "Add relay: ", "https://relay.example.com")
266
+ case agentDashboardClickDeleteRelay:
267
+ return m.deleteSelectedRelay()
268
case agentDashboardClickAttachRelay:
269
return m.attachSelectedRelay()
269
- case agentDashboardClickDetachRelay:
270
- return m.detachSelectedRelay()
270
+ case agentDashboardClickSeedRelay:
271
+ return m.seedSelectedRelay()
272
case agentDashboardClickAddHop:
273
return m.addSelectedHop()
274
case agentDashboardClickRemoveHop:
@@ -282,7 +283,62 @@ func (m agentDashboardModel) applyClick(region agentDashboardClickRegion) (tea.M
283
284
func (m agentDashboardModel) View() string {
285
layout := m.layout()
285
- return strings.Join(layout.lines, "\n") + "\n"
286
+ lines := layout.lines
287
+ if m.height > 0 {
288
+ if len(lines) > m.height {
289
+ lines = lines[:m.height]
290
+ }
291
+ for len(lines) < m.height {
292
+ lines = append(lines, "")
293
+ }
294
+ }
295
+ return strings.Join(lines, "\n")
296
+}
297
+
298
+func (m *agentDashboardModel) selectTunnel(index int) {
299
+ if index < 0 || index >= len(m.status.Tunnels) {
300
+ return
301
+ }
302
+ m.selectedTunnel = index
303
+ m.selectedTunnelID = m.status.Tunnels[index].ID
304
+ m.selectedRelay = 0
305
+ m.selectedRelayURL = ""
306
+ if len(m.status.Tunnels[index].Relays) > 0 {
307
+ m.selectedRelayURL = m.status.Tunnels[index].Relays[0].RelayURL
308
+ }
309
+}
310
+
311
+func (m *agentDashboardModel) selectPreviousTunnel() {
312
+ if m.selectedTunnel > 0 {
313
+ m.selectTunnel(m.selectedTunnel - 1)
314
+ }
315
+}
316
+
317
+func (m *agentDashboardModel) selectNextTunnel() {
318
+ if m.selectedTunnel+1 < len(m.status.Tunnels) {
319
+ m.selectTunnel(m.selectedTunnel + 1)
320
+ }
321
+}
322
+
323
+func (m *agentDashboardModel) selectRelay(index int, relayURL string) {
324
+ m.selectedRelay = index
325
+ m.selectedRelayURL = relayURL
326
+}
327
+
328
+func (m *agentDashboardModel) selectPreviousRelay() {
329
+ tunnel, ok := m.selectedTunnelStatus()
330
+ if !ok || len(tunnel.Relays) == 0 || m.selectedRelay <= 0 {
331
+ return
332
+ }
333
+ m.selectRelay(m.selectedRelay-1, tunnel.Relays[m.selectedRelay-1].RelayURL)
334
+}
335
+
336
+func (m *agentDashboardModel) selectNextRelay() {
337
+ tunnel, ok := m.selectedTunnelStatus()
338
+ if !ok || m.selectedRelay+1 >= len(tunnel.Relays) {
339
+ return
340
+ }
341
+ m.selectRelay(m.selectedRelay+1, tunnel.Relays[m.selectedRelay+1].RelayURL)
342
}
343
344
func (m *agentDashboardModel) clampSelection() {
@@ -293,13 +349,19 @@ func (m *agentDashboardModel) clampSelection() {
349
m.selectedRelayURL = ""
350
return
351
}
352
+
353
if m.selectedTunnelID != "" {
354
+ found := false
355
for i, tunnel := range m.status.Tunnels {
356
if tunnel.ID == m.selectedTunnelID {
357
m.selectedTunnel = i
358
+ found = true
359
break
360
}
361
}
362
+ if !found {
363
+ m.selectedTunnel = 0
364
+ }
365
}
366
if m.selectedTunnel < 0 {
367
m.selectedTunnel = 0
@@ -316,12 +378,17 @@ func (m *agentDashboardModel) clampSelection() {
378
return
379
}
380
if m.selectedRelayURL != "" {
381
+ found := false
382
for i, relay := range relays {
383
if relay.RelayURL == m.selectedRelayURL {
384
m.selectedRelay = i
385
+ found = true
386
break
387
}
388
}
389
+ if !found {
390
+ m.selectedRelay = 0
391
+ }
392
}
393
if m.selectedRelay < 0 {
394
m.selectedRelay = 0
@@ -347,44 +414,99 @@ func (m agentDashboardModel) selectedRelayStatus() (types.AgentRelayStatus, bool
414
return tunnel.Relays[m.selectedRelay], true
415
}
416
350
-func (m *agentDashboardModel) prevTab() {
351
- if m.tab == agentDashboardRelaysTab {
352
- m.tab = agentDashboardLogsTab
353
- } else {
354
- m.tab--
417
+func (m agentDashboardModel) selectedTunnelRelay() (types.AgentTunnelStatus, types.AgentRelayStatus, bool) {
418
+ tunnel, ok := m.selectedTunnelStatus()
419
+ if !ok {
420
+ return types.AgentTunnelStatus{}, types.AgentRelayStatus{}, false
421
}
356
- m.syncLogViewport()
357
-}
358
-
359
-func (m *agentDashboardModel) nextTab() {
360
- if m.tab == agentDashboardLogsTab {
361
- m.tab = agentDashboardRelaysTab
362
- } else {
363
- m.tab++
422
+ relay, ok := m.selectedRelayStatus()
423
+ if !ok {
424
+ return types.AgentTunnelStatus{}, types.AgentRelayStatus{}, false
425
}
365
- m.syncLogViewport()
426
+ return tunnel, relay, true
427
}
428
368
-func (m *agentDashboardModel) syncLogViewport() {
369
- _, rightWidth, bodyHeight := agentDashboardPaneSizes(m.width, m.height)
370
- m.logs.Width = rightWidth
371
- m.logs.Height = max(4, bodyHeight-8)
372
-
373
- wasAtBottom := m.logs.AtBottom()
374
- m.logs.SetContent(m.logContent())
375
- if wasAtBottom {
376
- m.logs.GotoBottom()
429
+func (m agentDashboardModel) startInput(mode agentDashboardMode, prompt, placeholder string) (tea.Model, tea.Cmd) {
430
+ m.mode = mode
431
+ m.input.Reset()
432
+ m.input.Prompt = prompt
433
+ m.input.Placeholder = placeholder
434
+ m.input.PromptStyle = agentDashboardSectionStyle
435
+ m.input.TextStyle = agentDashboardInputStyle
436
+ m.input.PlaceholderStyle = agentDashboardMutedStyle
437
+ m.input.Width = max(1, min(88, m.width-8))
438
+ m.message = ""
439
+ return m, tea.Batch(m.input.Focus(), textinput.Blink)
440
+}
441
+
442
+func (m *agentDashboardModel) cancelInput() {
443
+ m.mode = agentDashboardNormalMode
444
+ m.input.Blur()
445
+ m.input.Reset()
446
+ m.message = "input canceled"
447
+}
448
+
449
+func (m agentDashboardModel) submitInput() (tea.Model, tea.Cmd) {
450
+ mode := m.mode
451
+ value := strings.TrimSpace(m.input.Value())
452
+ m.mode = agentDashboardNormalMode
453
+ m.input.Blur()
454
+ m.input.Reset()
455
+
456
+ switch mode {
457
+ case agentDashboardAddTunnelMode:
458
+ fields := strings.Fields(value)
459
+ if len(fields) < 2 {
460
+ m.message = "use: name port"
461
+ return m, nil
462
+ }
463
+ name := strings.Join(fields[:len(fields)-1], " ")
464
+ port := strings.TrimPrefix(fields[len(fields)-1], ":")
465
+ portNumber, err := strconv.Atoi(port)
466
+ if err != nil || portNumber < 1 || portNumber > 65535 {
467
+ m.message = "port must be 1-65535"
468
+ return m, nil
469
+ }
470
+ m.message = "adding tunnel..."
471
+ return m, agentDashboardAddTunnel(m.stateDir, name, "127.0.0.1:"+port)
472
+ case agentDashboardAddRelayMode:
473
+ if value == "" {
474
+ m.message = "relay url is required"
475
+ return m, nil
476
+ }
477
+ tunnel, ok := m.selectedTunnelStatus()
478
+ if !ok {
479
+ m.message = "select a tunnel first"
480
+ return m, nil
481
+ }
482
+ m.message = "adding relay..."
483
+ return m, agentDashboardAddRelay(m.stateDir, tunnel.ID, value)
484
}
485
+ return m, nil
486
}
487
380
-func (m agentDashboardModel) restartSelectedTunnel() (tea.Model, tea.Cmd) {
488
+func (m agentDashboardModel) deleteSelectedTunnel() (tea.Model, tea.Cmd) {
489
tunnel, ok := m.selectedTunnelStatus()
490
if !ok {
491
m.message = "no tunnel selected"
492
return m, nil
493
}
386
- m.message = "restarting " + tunnel.ID + "..."
387
- return m, agentDashboardRestart(m.stateDir, tunnel.ID)
494
+ if len(m.status.Tunnels) <= 1 {
495
+ m.message = "cannot delete the last tunnel"
496
+ return m, nil
497
+ }
498
+ m.message = "deleting " + tunnel.ID + "..."
499
+ return m, agentDashboardDeleteTunnel(m.stateDir, tunnel.ID)
500
+}
501
+
502
+func (m agentDashboardModel) deleteSelectedRelay() (tea.Model, tea.Cmd) {
503
+ tunnel, relay, ok := m.selectedTunnelRelay()
504
+ if !ok {
505
+ m.message = "select a relay first"
506
+ return m, nil
507
+ }
508
+ m.message = "deleting relay..."
509
+ return m, agentDashboardRemoveRelay(m.stateDir, tunnel.ID, relay.RelayURL)
510
}
511
512
func (m agentDashboardModel) attachSelectedRelay() (tea.Model, tea.Cmd) {
@@ -401,18 +523,14 @@ func (m agentDashboardModel) attachSelectedRelay() (tea.Model, tea.Cmd) {
523
return m, agentDashboardAddRelay(m.stateDir, tunnel.ID, relay.RelayURL)
524
}
525
404
-func (m agentDashboardModel) detachSelectedRelay() (tea.Model, tea.Cmd) {
526
+func (m agentDashboardModel) seedSelectedRelay() (tea.Model, tea.Cmd) {
527
tunnel, relay, ok := m.selectedTunnelRelay()
528
if !ok {
529
m.message = "select a relay first"
530
return m, nil
531
}
410
- if !relayDashboardAttached(relay) {
411
- m.message = "relay is not attached"
412
- return m, nil
413
- }
414
- m.message = "detaching relay..."
415
- return m, agentDashboardRemoveRelay(m.stateDir, tunnel.ID, relay.RelayURL)
532
+ m.message = "switching relay to seed..."
533
+ return m, agentDashboardSeedRelay(m.stateDir, tunnel.ID, relay.RelayURL)
534
}
535
536
func (m agentDashboardModel) addSelectedHop() (tea.Model, tea.Cmd) {
@@ -480,19 +598,7 @@ func (m agentDashboardModel) clearMultiHop() (tea.Model, tea.Cmd) {
598
return m, nil
599
}
600
m.message = "clearing route..."
483
- return m, agentDashboardClearMultiHop(m.stateDir, tunnel.ID)
484
-}
485
-
486
-func (m agentDashboardModel) selectedTunnelRelay() (types.AgentTunnelStatus, types.AgentRelayStatus, bool) {
487
- tunnel, ok := m.selectedTunnelStatus()
488
- if !ok {
489
- return types.AgentTunnelStatus{}, types.AgentRelayStatus{}, false
490
- }
491
- relay, ok := m.selectedRelayStatus()
492
- if !ok {
493
- return types.AgentTunnelStatus{}, types.AgentRelayStatus{}, false
494
- }
495
- return tunnel, relay, true
601
+ return m, agentDashboardSetMultiHop(m.stateDir, tunnel.ID, nil)
602
}
603
604
func (m *agentDashboardModel) ensureMultiHopDraft(tunnel types.AgentTunnelStatus) {
@@ -521,133 +627,169 @@ func agentDashboardFetchStatus(stateDir string) tea.Cmd {
627
}
628
629
func agentDashboardTick() tea.Cmd {
524
- return tea.Tick(agentDashboardRefreshInterval, func(t time.Time) tea.Msg {
630
+ return tea.Tick(agentDashboardPollInterval, func(t time.Time) tea.Msg {
631
return agentDashboardTickMsg(t)
632
})
633
}
634
529
-func agentDashboardReload(stateDir string) tea.Cmd {
635
+func agentDashboardAddRelay(stateDir, tunnelID, relayURL string) tea.Cmd {
636
return func() tea.Msg {
637
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
638
defer cancel()
639
534
- err := Reload(ctx, stateDir)
535
- return agentDashboardActionMsg{message: "reload accepted", err: err}
640
+ err := AddRelay(ctx, stateDir, tunnelID, relayURL)
641
+ return agentDashboardActionMsg{message: "relay added", err: err}
642
}
643
}
644
539
-func agentDashboardRestart(stateDir, tunnelID string) tea.Cmd {
645
+func agentDashboardRemoveRelay(stateDir, tunnelID, relayURL string) tea.Cmd {
646
return func() tea.Msg {
647
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
648
defer cancel()
649
544
- err := RestartTunnel(ctx, stateDir, tunnelID)
545
- return agentDashboardActionMsg{message: "restart accepted", err: err}
650
+ err := RemoveRelay(ctx, stateDir, tunnelID, relayURL)
651
+ return agentDashboardActionMsg{message: "relay deleted", err: err}
652
}
653
}
654
549
-func agentDashboardAddRelay(stateDir, tunnelID, relayURL string) tea.Cmd {
655
+func agentDashboardSeedRelay(stateDir, tunnelID, relayURL string) tea.Cmd {
656
return func() tea.Msg {
657
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
658
defer cancel()
659
554
- err := AddRelay(ctx, stateDir, tunnelID, relayURL)
555
- return agentDashboardActionMsg{message: "relay attach accepted", err: err}
660
+ err := SeedRelay(ctx, stateDir, tunnelID, relayURL)
661
+ return agentDashboardActionMsg{message: "relay switched to seed", err: err}
662
}
663
}
664
559
-func agentDashboardRemoveRelay(stateDir, tunnelID, relayURL string) tea.Cmd {
665
+func agentDashboardSetMultiHop(stateDir, tunnelID string, relayURLs []string) tea.Cmd {
666
return func() tea.Msg {
667
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
668
defer cancel()
669
564
- err := RemoveRelay(ctx, stateDir, tunnelID, relayURL)
565
- return agentDashboardActionMsg{message: "relay detach accepted", err: err}
670
+ err := SetMultiHop(ctx, stateDir, tunnelID, relayURLs)
671
+ message := "multi-hop applied"
672
+ if relayURLs == nil {
673
+ message = "multi-hop cleared"
674
+ }
675
+ return agentDashboardActionMsg{message: message, err: err}
676
}
677
}
678
569
-func agentDashboardSetMultiHop(stateDir, tunnelID string, relayURLs []string) tea.Cmd {
679
+func agentDashboardAddTunnel(stateDir, name, target string) tea.Cmd {
680
return func() tea.Msg {
681
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
682
defer cancel()
683
574
- err := SetMultiHop(ctx, stateDir, tunnelID, relayURLs)
575
- return agentDashboardActionMsg{message: "multi-hop applied", err: err}
684
+ err := AddTunnel(ctx, stateDir, types.AgentTunnelRequest{
685
+ Name: name,
686
+ TargetAddr: target,
687
+ })
688
+ return agentDashboardActionMsg{message: "tunnel added", err: err}
689
}
690
}
691
579
-func agentDashboardClearMultiHop(stateDir, tunnelID string) tea.Cmd {
692
+func agentDashboardDeleteTunnel(stateDir, id string) tea.Cmd {
693
return func() tea.Msg {
694
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
695
defer cancel()
696
584
- err := ClearMultiHop(ctx, stateDir, tunnelID)
585
- return agentDashboardActionMsg{message: "multi-hop cleared", err: err}
697
+ err := DeleteTunnel(ctx, stateDir, id)
698
+ return agentDashboardActionMsg{message: "tunnel deleted", err: err}
699
}
700
}
701
702
func (m agentDashboardModel) layout() agentDashboardLayout {
590
- width := max(m.width, 88)
703
+ width := m.width
704
+ if width <= 0 {
705
+ width = 88
706
+ }
707
leftWidth, rightWidth, bodyHeight := agentDashboardPaneSizes(width, m.height)
708
709
var layout agentDashboardLayout
594
- layout.addLine(agentDashboardTitleStyle.Render("Portal Agent") + " " + agentDashboardMutedStyle.Render(agentDashboardSummaryLine(m.status)))
710
+ layout.addLine(agentDashboardTitleStyle.Render(agentDashboardFit("Portal Agent "+types.ReleaseVersion, width)))
711
layout.addLine(agentDashboardMutedStyle.Render(strings.Repeat("-", min(width, 120))))
712
713
if m.err != nil && m.status.ControlAddr == "" {
598
- layout.addLine(agentDashboardErrorStyle.Render(fmt.Sprintf("Agent unavailable: %v", m.err)))
599
- layout.addLine("")
600
- layout.addLine("Start managed service: " + agentDashboardInputStyle.Render("portal agent run --config "+m.configPath))
601
- layout.addLine("No service manager: " + agentDashboardInputStyle.Render("portal agent run --foreground --config "+m.configPath))
714
+ layout.addLine(agentDashboardErrorStyle.Render(agentDashboardFit(fmt.Sprintf("Agent unavailable: %v", m.err), width)))
715
layout.addLine("")
603
- layout.addButtons(
604
- agentDashboardButton{label: "Refresh", action: agentDashboardClickRefresh},
605
- agentDashboardButton{label: "Quit", action: agentDashboardClickQuit},
606
- )
716
+ layout.addLine(agentDashboardFit("Start managed service: portal agent run --config "+m.configPath, width))
717
+ layout.addLine(agentDashboardFit("No service manager: portal agent run --foreground --config "+m.configPath, width))
718
return layout
719
}
720
610
- layout.addLine(fmt.Sprintf("Control: %s Tunnels: %d Running: %d Errors: %d Uptime: %s",
721
+ tunnelCount := len(m.status.Tunnels)
722
+ runningCount := 0
723
+ errorCount := 0
724
+ for _, tunnel := range m.status.Tunnels {
725
+ switch tunnel.State {
726
+ case tunnelStateRunning:
727
+ runningCount++
728
+ case tunnelStateError:
729
+ errorCount++
730
+ }
731
+ }
732
+ layout.addLine(agentDashboardFit(fmt.Sprintf("Control: %s Tunnels: %d Running: %d Errors: %d",
733
valueOrDash(m.status.ControlAddr),
612
- m.status.Summary.TunnelCount,
613
- m.status.Summary.RunningCount,
614
- m.status.Summary.ErrorCount,
615
- durationSince(m.status.StartedAt),
616
- ))
734
+ tunnelCount,
735
+ runningCount,
736
+ errorCount,
737
+ ), width))
738
if m.message != "" {
618
- layout.addLine(agentDashboardMessageStyle.Render("Message: " + m.message))
739
+ layout.addLine(agentDashboardMessageStyle.Render(agentDashboardFit("Message: "+m.message, width)))
740
}
741
if m.err != nil {
621
- layout.addLine(agentDashboardErrorStyle.Render(fmt.Sprintf("Error: %v", m.err)))
742
+ layout.addLine(agentDashboardErrorStyle.Render(agentDashboardFit(fmt.Sprintf("Error: %v", m.err), width)))
743
+ }
744
+ if m.mode != agentDashboardNormalMode {
745
+ layout.addLine(m.input.View())
746
}
623
- layout.addButtons(
624
- agentDashboardButton{label: "Refresh", action: agentDashboardClickRefresh},
625
- agentDashboardButton{label: "Reload Config", action: agentDashboardClickReload},
626
- agentDashboardButton{label: "Quit", action: agentDashboardClickQuit},
627
- )
747
layout.addLine("")
748
+ if m.height > 0 {
749
+ bodyHeight = max(1, m.height-len(layout.lines))
750
+ }
751
752
left := m.renderTunnelsPane(leftWidth, bodyHeight)
753
right := m.renderTunnelPane(rightWidth, bodyHeight)
754
layout.addPanes(left, right, leftWidth, 2)
633
- layout.addLine("")
634
- layout.addLine(agentDashboardHelpStyle.Render("Mouse: select tunnels, relays, tabs, and action buttons. Keyboard fallback: arrows, tab, enter, q."))
755
return layout
756
}
757
758
func (m agentDashboardModel) renderTunnelsPane(width, height int) agentDashboardPane {
759
var pane agentDashboardPane
760
pane.addLine(agentDashboardSectionStyle.Render(agentDashboardFit("Tunnels", width)))
761
+ pane.addButtons(width,
762
+ agentDashboardButton{label: "Add Tunnel", action: agentDashboardClickAddTunnel},
763
+ agentDashboardButton{label: "Delete Tunnel", action: agentDashboardClickDeleteTunnel, disabled: len(m.status.Tunnels) <= 1},
764
+ )
765
pane.addLine(agentDashboardMutedStyle.Render(agentDashboardFit(fmt.Sprintf("%d managed", len(m.status.Tunnels)), width)))
766
pane.addLine(agentDashboardMutedStyle.Render(strings.Repeat("-", width)))
767
768
if len(m.status.Tunnels) == 0 {
769
pane.addLine(agentDashboardMutedStyle.Render(agentDashboardFit("no managed tunnels", width)))
770
+ pane.clip(height)
771
return pane
772
}
773
774
+ detailLines := make([]string, 0, 5)
775
+ if tunnel, ok := m.selectedTunnelStatus(); ok {
776
+ detailLines = append(detailLines,
777
+ "",
778
+ agentDashboardSectionStyle.Render(agentDashboardFit("Selected Tunnel", width)),
779
+ agentDashboardFit("State: "+valueOrDash(tunnel.State), width),
780
+ agentDashboardFit("Target: "+valueOrDash(tunnel.TargetAddr), width),
781
+ agentDashboardFit("Public: "+firstOrDash(tunnel.PublicURLs), width),
782
+ )
783
+ if strings.TrimSpace(tunnel.LastError) != "" {
784
+ detailLines = append(detailLines, agentDashboardErrorStyle.Render(agentDashboardFit("Error: "+tunnel.LastError, width)))
785
+ }
786
+ }
787
+ listLimit := height - len(pane.lines) - len(detailLines)
788
+ if listLimit < 3 {
789
+ listLimit = height - len(pane.lines)
790
+ }
791
for i, tunnel := range m.status.Tunnels {
650
- if len(pane.lines) >= height {
792
+ if i >= listLimit || len(pane.lines) >= height {
793
pane.addLine(agentDashboardMutedStyle.Render(agentDashboardFit(fmt.Sprintf("+ %d more", len(m.status.Tunnels)-i), width)))
794
break
795
}
@@ -655,13 +797,17 @@ func (m agentDashboardModel) renderTunnelsPane(width, height int) agentDashboard
797
if strings.TrimSpace(tunnel.Name) != "" {
798
name = tunnel.Name
799
}
658
- line := fmt.Sprintf("%-10s %-16s %s",
659
- truncateDashboardValue(tunnel.State, 10),
660
- truncateDashboardValue(name, 16),
661
- firstOrDash(tunnel.PublicURLs),
662
- )
800
+ nameWidth := max(8, width-11)
801
+ line := fmt.Sprintf("%-10s %s", truncateDashboardValue(tunnel.State, 10), agentDashboardFit(name, nameWidth))
802
pane.addClickRow(line, width, agentDashboardTunnelStyle(i == m.selectedTunnel, tunnel.State), agentDashboardClickTunnel, i, -1)
803
}
804
+ for _, line := range detailLines {
805
+ if len(pane.lines) >= height {
806
+ break
807
+ }
808
+ pane.addLine(line)
809
+ }
810
+ pane.clip(height)
811
return pane
812
}
813
@@ -669,64 +815,49 @@ func (m agentDashboardModel) renderTunnelPane(width, height int) agentDashboardP
815
var pane agentDashboardPane
816
tunnel, ok := m.selectedTunnelStatus()
817
if !ok {
672
- pane.addLine(agentDashboardSectionStyle.Render(agentDashboardFit("Tunnel", width)))
818
+ pane.addLine(agentDashboardSectionStyle.Render(agentDashboardFit("Relays", width)))
819
pane.addLine(agentDashboardMutedStyle.Render(agentDashboardFit("select a managed tunnel", width)))
820
+ pane.clip(height)
821
return pane
822
}
823
677
- title := tunnel.ID
678
- if strings.TrimSpace(tunnel.Name) != "" {
679
- title = tunnel.Name + " (" + tunnel.ID + ")"
680
- }
681
- pane.addLine(agentDashboardSectionStyle.Render(agentDashboardFit(title, width)))
682
- pane.addLine(agentDashboardFit(fmt.Sprintf("State: %s Target: %s Restarts: %d",
683
- valueOrDash(tunnel.State),
684
- valueOrDash(tunnel.TargetAddr),
685
- tunnel.Restarts,
686
- ), width))
687
- pane.addLine(agentDashboardFit("Public: "+firstOrDash(tunnel.PublicURLs), width))
688
- if strings.TrimSpace(tunnel.LastError) != "" {
689
- pane.addLine(agentDashboardErrorStyle.Render(agentDashboardFit("Error: "+tunnel.LastError, width)))
690
- }
691
- pane.addButtons(agentDashboardButton{label: "Restart Tunnel", action: agentDashboardClickRestart})
692
- pane.addTabs(m.tab)
693
- pane.addLine(agentDashboardMutedStyle.Render(strings.Repeat("-", width)))
694
-
695
- switch m.tab {
696
- case agentDashboardMultiHopTab:
697
- m.renderMultiHopTab(&pane, width, height, tunnel)
698
- case agentDashboardLogsTab:
699
- m.renderLogsTab(&pane, width, height)
700
- default:
701
- m.renderRelaysTab(&pane, width, height, tunnel)
702
- }
824
+ relayLimit := max(4, (height-len(pane.lines)-6)/2)
825
+ m.renderRelaysSection(&pane, width, relayLimit, tunnel)
826
+ pane.addLine("")
827
+ m.renderMultiHopSection(&pane, width, height, tunnel)
828
+ pane.clip(height)
829
return pane
830
}
831
706
-func (m agentDashboardModel) renderRelaysTab(pane *agentDashboardPane, width, height int, tunnel types.AgentTunnelStatus) {
832
+func (m agentDashboardModel) renderRelaysSection(pane *agentDashboardPane, width, maxRows int, tunnel types.AgentTunnelStatus) {
833
relay, hasRelay := m.selectedRelayStatus()
708
- attachDisabled := !hasRelay || relayDashboardAttached(relay)
709
- detachDisabled := !hasRelay || !relayDashboardAttached(relay)
710
- pane.addButtons(
711
- agentDashboardButton{label: "Attach Relay", action: agentDashboardClickAttachRelay, disabled: attachDisabled},
712
- agentDashboardButton{label: "Detach Relay", action: agentDashboardClickDetachRelay, disabled: detachDisabled},
834
+ attached := hasRelay && relayDashboardAttached(relay)
835
+ attachDisabled := !hasRelay || attached || relay.Banned
836
+ seedDisabled := !hasRelay || relay.Banned || (!attached && relay.Bootstrap)
837
+ deleteDisabled := !hasRelay
838
+
839
+ pane.addLine(agentDashboardSectionStyle.Render(agentDashboardFit("Relays", width)))
840
+ pane.addButtons(width,
841
+ agentDashboardButton{label: "Attach", action: agentDashboardClickAttachRelay, disabled: attachDisabled},
842
+ agentDashboardButton{label: "Seed", action: agentDashboardClickSeedRelay, disabled: seedDisabled},
843
+ agentDashboardButton{label: "Add URL", action: agentDashboardClickAddRelay},
844
+ agentDashboardButton{label: "Remove", action: agentDashboardClickDeleteRelay, disabled: deleteDisabled},
845
)
846
if hasRelay {
715
- pane.addLine(agentDashboardMutedStyle.Render(agentDashboardFit("Selected: "+relay.RelayURL+" Public: "+valueOrDash(relay.PublicURL), width)))
847
+ pane.addLine(agentDashboardMutedStyle.Render(agentDashboardFit("Selected: "+relay.RelayURL, width)))
848
}
717
- pane.addLine("")
718
- pane.addLine(agentDashboardMutedStyle.Render(agentDashboardFit(fmt.Sprintf("%-9s %-9s %-9s %-7s %s", "STATE", "ROLE", "CAPS", "RTT", "RELAY"), width)))
849
+ pane.addLine(agentDashboardMutedStyle.Render(agentDashboardRelayRow(width, "STATE", "ROLE", "CAPS", "RTT", "RELAY")))
850
851
if len(tunnel.Relays) == 0 {
721
- pane.addLine(agentDashboardMutedStyle.Render(agentDashboardFit("no discovered relays", width)))
852
+ pane.addLine(agentDashboardMutedStyle.Render(agentDashboardFit("no relays", width)))
853
return
854
}
855
for i, relay := range tunnel.Relays {
725
- if len(pane.lines) >= height {
856
+ if i >= maxRows {
857
pane.addLine(agentDashboardMutedStyle.Render(agentDashboardFit(fmt.Sprintf("+ %d more", len(tunnel.Relays)-i), width)))
858
break
859
}
729
- line := fmt.Sprintf("%-9s %-9s %-9s %-7s %s",
860
+ line := agentDashboardRelayRow(width,
861
relayDashboardState(relay),
862
relayDashboardRole(relay),
863
relayDashboardCaps(relay),
@@ -737,22 +868,19 @@ func (m agentDashboardModel) renderRelaysTab(pane *agentDashboardPane, width, he
868
}
869
}
870
740
-func (m agentDashboardModel) renderMultiHopTab(pane *agentDashboardPane, width, height int, tunnel types.AgentTunnelStatus) {
871
+func (m agentDashboardModel) renderMultiHopSection(pane *agentDashboardPane, width, height int, tunnel types.AgentTunnelStatus) {
872
route := m.displayedMultiHop(tunnel)
873
relay, hasRelay := m.selectedRelayStatus()
874
inRoute := hasRelay && slices.Contains(route, relay.RelayURL)
875
canAdd := hasRelay && relay.SupportsOverlay && !inRoute
876
746
- pane.addButtons(
747
- agentDashboardButton{label: "Add to Route", action: agentDashboardClickAddHop, disabled: !canAdd},
748
- agentDashboardButton{label: "Remove from Route", action: agentDashboardClickRemoveHop, disabled: !inRoute},
749
- agentDashboardButton{label: "Apply Route", action: agentDashboardClickApplyHop, disabled: len(route) < 2},
750
- agentDashboardButton{label: "Clear Route", action: agentDashboardClickClearHop, disabled: len(route) == 0},
877
+ pane.addLine(agentDashboardSectionStyle.Render(agentDashboardFit("Multi-hop", width)))
878
+ pane.addButtons(width,
879
+ agentDashboardButton{label: "Add Hop", action: agentDashboardClickAddHop, disabled: !canAdd},
880
+ agentDashboardButton{label: "Remove Hop", action: agentDashboardClickRemoveHop, disabled: !inRoute},
881
+ agentDashboardButton{label: "Apply", action: agentDashboardClickApplyHop, disabled: len(route) < 2},
882
+ agentDashboardButton{label: "Clear", action: agentDashboardClickClearHop, disabled: len(route) == 0},
883
)
752
- if hasRelay {
753
- pane.addLine(agentDashboardMutedStyle.Render(agentDashboardFit("Selected: "+relay.RelayURL+" Public: "+valueOrDash(relay.PublicURL), width)))
754
- }
755
- pane.addLine("")
884
885
routeLabel := "Route: none"
886
if len(route) > 0 {
@@ -761,77 +889,21 @@ func (m agentDashboardModel) renderMultiHopTab(pane *agentDashboardPane, width,
889
routeLabel += " (draft)"
890
}
891
}
764
- pane.addLine(agentDashboardFit(routeLabel, width))
765
- pane.addLine(agentDashboardMutedStyle.Render(agentDashboardFit("Select discovered relays below, then add/remove them from the route.", width)))
766
- pane.addLine("")
767
- pane.addLine(agentDashboardMutedStyle.Render(agentDashboardFit(fmt.Sprintf("%-9s %-9s %-9s %-7s %s", "STATE", "ROLE", "CAPS", "RTT", "RELAY"), width)))
768
-
769
- if len(tunnel.Relays) == 0 {
770
- pane.addLine(agentDashboardMutedStyle.Render(agentDashboardFit("no discovered relays", width)))
771
- return
772
- }
773
- for i, relay := range tunnel.Relays {
774
- if len(pane.lines) >= height {
775
- pane.addLine(agentDashboardMutedStyle.Render(agentDashboardFit(fmt.Sprintf("+ %d more", len(tunnel.Relays)-i), width)))
776
- break
777
- }
778
- line := fmt.Sprintf("%-9s %-9s %-9s %-7s %s",
779
- relayDashboardState(relay),
780
- relayDashboardRole(relay),
781
- relayDashboardCaps(relay),
782
- relayDashboardRTT(relay),
783
- relay.RelayURL,
784
- )
785
- pane.addClickRow(line, width, agentDashboardRelayStyle(i == m.selectedRelay, relay), agentDashboardClickRelay, m.selectedTunnel, i)
786
- }
787
-}
788
-
789
-func (m agentDashboardModel) renderLogsTab(pane *agentDashboardPane, width, height int) {
790
- pane.addLine(agentDashboardMutedStyle.Render(agentDashboardFit("Recent logs", width)))
791
-
792
- logs := m.logs
793
- logs.Width = width
794
- logs.Height = max(4, height-len(pane.lines))
795
- logs.SetContent(m.logContent())
796
-
797
- for _, line := range strings.Split(logs.View(), "\n") {
892
+ for _, line := range agentDashboardWrap(routeLabel, width) {
893
if len(pane.lines) >= height {
799
- break
894
+ return
895
}
896
pane.addLine(agentDashboardFit(line, width))
897
}
898
}
899
805
-func (m agentDashboardModel) logContent() string {
806
- if len(m.status.Logs) == 0 {
807
- return "no recent logs"
808
- }
809
-
810
- width := m.logs.Width
811
- if width <= 0 {
812
- _, width, _ = agentDashboardPaneSizes(m.width, m.height)
813
- }
814
-
815
- lines := make([]string, 0, len(m.status.Logs))
816
- for _, entry := range m.status.Logs {
817
- line := fmt.Sprintf("%s %-5s %-14s %s",
818
- entry.Time.Local().Format("15:04:05"),
819
- strings.ToUpper(entry.Level),
820
- truncateDashboardValue(valueOrDash(entry.TunnelID), 14),
821
- entry.Message,
822
- )
823
- lines = append(lines, agentDashboardFit(line, width))
824
- }
825
- return strings.Join(lines, "\n")
826
-}
827
-
900
func (l *agentDashboardLayout) addLine(line string) {
901
l.lines = append(l.lines, line)
902
}
903
832
-func (l *agentDashboardLayout) addButtons(buttons ...agentDashboardButton) {
833
- line, regions := agentDashboardRenderButtons(len(l.lines), 0, buttons...)
834
- l.lines = append(l.lines, line)
904
+func (l *agentDashboardLayout) addButtons(width int, buttons ...agentDashboardButton) {
905
+ lines, regions := agentDashboardRenderButtons(width, len(l.lines), 0, buttons...)
906
+ l.lines = append(l.lines, lines...)
907
l.regions = append(l.regions, regions...)
908
}
909
@@ -865,48 +937,12 @@ func (p *agentDashboardPane) addLine(line string) {
937
p.lines = append(p.lines, line)
938
}
939
868
-func (p *agentDashboardPane) addButtons(buttons ...agentDashboardButton) {
869
- line, regions := agentDashboardRenderButtons(len(p.lines), 0, buttons...)
870
- p.lines = append(p.lines, line)
940
+func (p *agentDashboardPane) addButtons(width int, buttons ...agentDashboardButton) {
941
+ lines, regions := agentDashboardRenderButtons(width, len(p.lines), 0, buttons...)
942
+ p.lines = append(p.lines, lines...)
943
p.regions = append(p.regions, regions...)
944
}
945
874
-func (p *agentDashboardPane) addTabs(active agentDashboardTab) {
875
- y := len(p.lines)
876
- x := 0
877
- var b strings.Builder
878
- tabs := []struct {
879
- label string
880
- tab agentDashboardTab
881
- }{
882
- {label: "Relays", tab: agentDashboardRelaysTab},
883
- {label: "Multi-Hop", tab: agentDashboardMultiHopTab},
884
- {label: "Logs", tab: agentDashboardLogsTab},
885
- }
886
-
887
- for i, tab := range tabs {
888
- if i > 0 {
889
- b.WriteString(" ")
890
- x++
891
- }
892
- plain := "[ " + tab.label + " ]"
893
- style := agentDashboardTabStyle
894
- if tab.tab == active {
895
- style = agentDashboardActiveTab
896
- }
897
- p.regions = append(p.regions, agentDashboardClickRegion{
898
- x0: x,
899
- x1: x + lipgloss.Width(plain),
900
- y: y,
901
- action: agentDashboardClickTab,
902
- tab: tab.tab,
903
- })
904
- b.WriteString(style.Render(plain))
905
- x += lipgloss.Width(plain)
906
- }
907
- p.lines = append(p.lines, b.String())
908
-}
909
-
946
func (p *agentDashboardPane) addClickRow(line string, width int, style lipgloss.Style, action agentDashboardClick, tunnel, relay int) {
947
plain := agentDashboardFit(line, width)
948
y := len(p.lines)
@@ -921,55 +957,101 @@ func (p *agentDashboardPane) addClickRow(line string, width int, style lipgloss.
957
})
958
}
959
924
-func agentDashboardRenderButtons(y, x int, buttons ...agentDashboardButton) (string, []agentDashboardClickRegion) {
960
+func (p *agentDashboardPane) clip(height int) {
961
+ if height <= 0 || len(p.lines) <= height {
962
+ return
963
+ }
964
+ p.lines = p.lines[:height]
965
+ regions := p.regions[:0]
966
+ for _, region := range p.regions {
967
+ if region.y < height {
968
+ regions = append(regions, region)
969
+ }
970
+ }
971
+ p.regions = regions
972
+}
973
+
974
+func agentDashboardRenderButtons(width, y, x int, buttons ...agentDashboardButton) ([]string, []agentDashboardClickRegion) {
975
+ if width <= 0 {
976
+ width = 1
977
+ }
978
var line strings.Builder
979
+ var lines []string
980
var regions []agentDashboardClickRegion
981
+ lineY := y
982
+ lineX := x
983
for i, button := range buttons {
928
- if i > 0 {
984
+ plain := "[ " + button.label + " ]"
985
+ if lipgloss.Width(plain) > width {
986
+ plain = agentDashboardFit(plain, width)
987
+ }
988
+ plainWidth := lipgloss.Width(plain)
989
+ space := 0
990
+ if i > 0 && line.Len() > 0 {
991
+ space = 1
992
+ }
993
+ if line.Len() > 0 && lineX+space+plainWidth > width {
994
+ lines = append(lines, line.String())
995
+ line.Reset()
996
+ lineY++
997
+ lineX = x
998
+ space = 0
999
+ }
1000
+ if space > 0 {
1001
line.WriteString(" ")
930
- x++
1002
+ lineX++
1003
}
932
- plain := "[ " + button.label + " ]"
1004
style := agentDashboardButtonStyle
1005
if button.disabled {
1006
style = agentDashboardDisabledStyle
1007
} else {
1008
regions = append(regions, agentDashboardClickRegion{
938
- x0: x,
939
- x1: x + lipgloss.Width(plain),
940
- y: y,
1009
+ x0: lineX,
1010
+ x1: min(lineX+plainWidth, width),
1011
+ y: lineY,
1012
action: button.action,
1013
})
1014
}
1015
line.WriteString(style.Render(plain))
945
- x += lipgloss.Width(plain)
1016
+ lineX += plainWidth
1017
+ }
1018
+ if line.Len() > 0 || len(lines) == 0 {
1019
+ lines = append(lines, line.String())
1020
}
947
- return line.String(), regions
1021
+ return lines, regions
1022
}
1023
1024
func agentDashboardPaneSizes(width, height int) (int, int, int) {
1025
if width <= 0 {
1026
width = 104
1027
}
954
- width = max(width, 88)
1028
+
1029
+ gutter := 2
1030
+ if width < 84 {
1031
+ leftWidth := min(max(width/2, 1), 40)
1032
+ if width >= 48 {
1033
+ leftWidth = max(leftWidth, 24)
1034
+ }
1035
+ rightWidth := width - leftWidth - gutter
1036
+ if rightWidth < 1 {
1037
+ rightWidth = 1
1038
+ leftWidth = max(1, width-gutter-rightWidth)
1039
+ }
1040
+ return leftWidth, rightWidth, defaultDashboardBodyHeight(height)
1041
+ }
1042
1043
leftWidth := width / 3
957
- leftWidth = min(max(leftWidth, 30), 42)
958
- rightWidth := max(42, width-leftWidth-2)
1044
+ leftWidth = min(max(leftWidth, 40), 56)
1045
+ rightWidth := max(1, width-leftWidth-gutter)
1046
+ return leftWidth, rightWidth, defaultDashboardBodyHeight(height)
1047
+}
1048
1049
+func defaultDashboardBodyHeight(height int) int {
1050
bodyHeight := height - 8
1051
if height <= 0 {
1052
bodyHeight = 22
1053
}
964
- bodyHeight = max(bodyHeight, 14)
965
- return leftWidth, rightWidth, bodyHeight
966
-}
967
-
968
-func agentDashboardSummaryLine(status types.AgentStatusResponse) string {
969
- if strings.TrimSpace(status.ReleaseVersion) == "" {
970
- return ""
971
- }
972
- return "v" + status.ReleaseVersion
1054
+ return max(bodyHeight, 1)
1055
}
1056
1057
func agentDashboardTunnelStyle(selected bool, state string) lipgloss.Style {
@@ -981,7 +1063,7 @@ func agentDashboardTunnelStyle(selected bool, state string) lipgloss.Style {
1063
return agentDashboardOKStyle
1064
case "error":
1065
return agentDashboardErrorStyle
984
- case "starting", "restarting":
1066
+ case "starting":
1067
return agentDashboardMessageStyle
1068
default:
1069
return lipgloss.NewStyle()
@@ -1034,7 +1116,7 @@ func relayDashboardRole(relay types.AgentRelayStatus) string {
1116
}
1117
1118
func relayDashboardAttached(relay types.AgentRelayStatus) bool {
1037
- return relay.Active || relay.Connected || relay.Bootstrap
1119
+ return relay.Active || relay.Connected
1120
}
1121
1122
func relayDashboardCaps(relay types.AgentRelayStatus) string {
@@ -1061,11 +1143,35 @@ func relayDashboardRTT(relay types.AgentRelayStatus) string {
1143
return fmt.Sprintf("%dms", relay.DiscoveryRTTMillis)
1144
}
1145
1064
-func durationSince(t time.Time) string {
1065
- if t.IsZero() {
1066
- return "-"
1146
+func agentDashboardRelayRow(width int, state, role, caps, rtt, relayURL string) string {
1147
+ if width < 28 {
1148
+ return agentDashboardFit(state+" "+relayURL, width)
1149
}
1068
- return time.Since(t).Round(time.Second).String()
1150
+ if width < 48 {
1151
+ stateW := 7
1152
+ return agentDashboardCell(state, stateW) + " " + agentDashboardFit(relayURL, width-stateW-1)
1153
+ }
1154
+ if width < 64 {
1155
+ stateW := 7
1156
+ roleW := 9
1157
+ rttW := 6
1158
+ relayW := max(1, width-stateW-roleW-rttW-3)
1159
+ return agentDashboardCell(state, stateW) + " " +
1160
+ agentDashboardCell(role, roleW) + " " +
1161
+ agentDashboardCell(rtt, rttW) + " " +
1162
+ agentDashboardFit(relayURL, relayW)
1163
+ }
1164
+
1165
+ stateW := 8
1166
+ roleW := 9
1167
+ capsW := 8
1168
+ rttW := 6
1169
+ relayW := max(1, width-stateW-roleW-capsW-rttW-4)
1170
+ return agentDashboardCell(state, stateW) + " " +
1171
+ agentDashboardCell(role, roleW) + " " +
1172
+ agentDashboardCell(caps, capsW) + " " +
1173
+ agentDashboardCell(rtt, rttW) + " " +
1174
+ agentDashboardFit(relayURL, relayW)
1175
}
1176
1177
func firstOrDash(values []string) string {
@@ -1096,15 +1202,49 @@ func agentDashboardFit(value string, width int) string {
1202
if value == "" || width <= 0 {
1203
return ""
1204
}
1099
-
1100
- runes := []rune(value)
1101
- if len(runes) <= width {
1205
+ if lipgloss.Width(value) <= width {
1206
return value
1207
}
1208
if width == 1 {
1209
return "~"
1210
}
1107
- return string(runes[:width-1]) + "~"
1211
+ var out strings.Builder
1212
+ used := 0
1213
+ for _, r := range value {
1214
+ cellWidth := lipgloss.Width(string(r))
1215
+ if used+cellWidth > width-1 {
1216
+ break
1217
+ }
1218
+ out.WriteRune(r)
1219
+ used += cellWidth
1220
+ }
1221
+ return out.String() + "~"
1222
+}
1223
+
1224
+func agentDashboardCell(value string, width int) string {
1225
+ value = agentDashboardFit(value, width)
1226
+ if lipgloss.Width(value) >= width {
1227
+ return value
1228
+ }
1229
+ return value + strings.Repeat(" ", width-lipgloss.Width(value))
1230
+}
1231
+
1232
+func agentDashboardWrap(value string, width int) []string {
1233
+ value = strings.TrimSpace(value)
1234
+ if value == "" {
1235
+ return nil
1236
+ }
1237
+ var lines []string
1238
+ remaining := value
1239
+ for len([]rune(remaining)) > width && width > 1 {
1240
+ lines = append(lines, agentDashboardFit(remaining, width))
1241
+ runes := []rune(remaining)
1242
+ remaining = string(runes[min(width-1, len(runes)):])
1243
+ }
1244
+ if strings.TrimSpace(remaining) != "" {
1245
+ lines = append(lines, remaining)
1246
+ }
1247
+ return lines
1248
}
1249
1250
func agentDashboardPadStyled(value string, width int) string {
cmd/portal-tunnel/agent/manager.go
+200
-159
@@ -4,11 +4,12 @@ import (
4
"context"
5
"errors"
6
"fmt"
7
+ "os"
8
"reflect"
9
"slices"
10
"strings"
11
"sync"
11
- "time"
12
+ "unicode"
13
14
"github.com/rs/zerolog/log"
15
@@ -17,35 +18,31 @@ import (
18
)
19
20
const (
20
- tunnelStateStarting = "starting"
21
- tunnelStateRunning = "running"
22
- tunnelStateRestarting = "restarting"
23
- tunnelStateStopped = "stopped"
24
- tunnelStateError = "error"
21
+ tunnelStateStarting = "starting"
22
+ tunnelStateRunning = "running"
23
+ tunnelStateStopped = "stopped"
24
+ tunnelStateError = "error"
25
)
26
27
type manager struct {
28
- startedAt time.Time
28
controlAddr string
29
30
+ configMu sync.Mutex
31
+
32
mu sync.RWMutex
33
+ cfg Config
34
tunnels map[string]*managedTunnel
33
- logs []types.AgentLogEntry
35
rootCtx context.Context
36
}
37
38
func newManager(cfg Config, controlAddr string) *manager {
38
- restartDelay, _ := time.ParseDuration(cfg.Agent.RestartDelay)
39
- if restartDelay <= 0 {
40
- restartDelay = 5 * time.Second
41
- }
39
manager := &manager{
43
- startedAt: time.Now().UTC(),
40
controlAddr: controlAddr,
41
+ cfg: cfg,
42
tunnels: make(map[string]*managedTunnel, len(cfg.Tunnels)),
43
}
44
for _, tunnelCfg := range cfg.Tunnels {
48
- manager.tunnels[tunnelCfg.ID] = newTunnel(tunnelCfg, restartDelay, manager.appendLog)
45
+ manager.tunnels[tunnelCfg.ID] = newTunnel(tunnelCfg)
46
}
47
return manager
48
}
@@ -103,7 +100,7 @@ func (m *manager) Stop(ctx context.Context) error {
100
}
101
}
102
106
-func (m *manager) RestartTunnel(id string) error {
103
+func (m *manager) AddRelay(id, relayURL string) error {
104
id = strings.TrimSpace(id)
105
m.mu.RLock()
106
tunnel := m.tunnels[id]
@@ -111,11 +108,10 @@ func (m *manager) RestartTunnel(id string) error {
108
if tunnel == nil {
109
return fmt.Errorf("unknown tunnel %q", id)
110
}
114
- tunnel.Restart()
115
- return nil
111
+ return tunnel.AddRelay(relayURL)
112
}
113
118
-func (m *manager) AddRelay(id, relayURL string) error {
114
+func (m *manager) RemoveRelay(id, relayURL string) error {
115
id = strings.TrimSpace(id)
116
m.mu.RLock()
117
tunnel := m.tunnels[id]
@@ -123,10 +119,10 @@ func (m *manager) AddRelay(id, relayURL string) error {
119
if tunnel == nil {
120
return fmt.Errorf("unknown tunnel %q", id)
121
}
126
- return tunnel.AddRelay(relayURL)
122
+ return tunnel.RemoveRelay(relayURL)
123
}
124
129
-func (m *manager) RemoveRelay(id, relayURL string) error {
125
+func (m *manager) SeedRelay(id, relayURL string) error {
126
id = strings.TrimSpace(id)
127
m.mu.RLock()
128
tunnel := m.tunnels[id]
@@ -134,7 +130,7 @@ func (m *manager) RemoveRelay(id, relayURL string) error {
130
if tunnel == nil {
131
return fmt.Errorf("unknown tunnel %q", id)
132
}
137
- return tunnel.RemoveRelay(relayURL)
133
+ return tunnel.SeedRelay(relayURL)
134
}
135
136
func (m *manager) SetMultiHop(id string, relayURLs []string) error {
@@ -148,20 +144,157 @@ func (m *manager) SetMultiHop(id string, relayURLs []string) error {
144
return tunnel.SetMultiHop(relayURLs)
145
}
146
151
-func (m *manager) Reload(cfg Config) error {
147
+func (m *manager) AddTunnel(req types.AgentTunnelRequest) error {
148
+ m.configMu.Lock()
149
+ defer m.configMu.Unlock()
150
+
151
+ cfg, path, mode, err := m.loadConfigDocument()
152
+ if err != nil {
153
+ return err
154
+ }
155
+ m.preserveCurrentIdentityPaths(&cfg)
156
+ id := strings.TrimSpace(req.ID)
157
+ name := strings.TrimSpace(req.Name)
158
+ if id == "" {
159
+ id = agentTunnelID(name)
160
+ }
161
+ if id == "" {
162
+ return errors.New("tunnel name is required")
163
+ }
164
+ if strings.ContainsAny(id, " \t\r\n/") {
165
+ return errors.New("tunnel id cannot contain whitespace or slash")
166
+ }
167
+ target := strings.TrimSpace(req.TargetAddr)
168
+ if target == "" {
169
+ target = defaultTargetAddr
170
+ }
171
+ if name == "" {
172
+ name = id
173
+ }
174
+ discovery := true
175
+ tunnelCfg := TunnelConfig{
176
+ ID: id,
177
+ Name: name,
178
+ TargetAddr: target,
179
+ RelayURLs: append([]string(nil), req.RelayURLs...),
180
+ Discovery: &discovery,
181
+ }
182
+ for _, tunnel := range cfg.Tunnels {
183
+ if tunnel.ID == tunnelCfg.ID {
184
+ return fmt.Errorf("tunnel %q already exists", tunnelCfg.ID)
185
+ }
186
+ }
187
+ cfg.Tunnels = append(cfg.Tunnels, tunnelCfg)
188
+ return m.writeConfigAndApply(path, mode, cfg)
189
+}
190
+
191
+func agentTunnelID(name string) string {
192
+ name = strings.ToLower(strings.TrimSpace(name))
193
+ var out strings.Builder
194
+ dash := false
195
+ for _, r := range name {
196
+ if r == '/' || unicode.IsSpace(r) {
197
+ if out.Len() > 0 && !dash {
198
+ out.WriteByte('-')
199
+ dash = true
200
+ }
201
+ continue
202
+ }
203
+ if r < 0x20 {
204
+ continue
205
+ }
206
+ out.WriteRune(r)
207
+ dash = false
208
+ }
209
+ return strings.Trim(out.String(), "-")
210
+}
211
+
212
+func (m *manager) DeleteTunnel(id string) error {
213
+ m.configMu.Lock()
214
+ defer m.configMu.Unlock()
215
+
216
+ id = strings.TrimSpace(id)
217
+ if id == "" {
218
+ return errors.New("tunnel id is required")
219
+ }
220
+ cfg, path, mode, err := m.loadConfigDocument()
221
+ if err != nil {
222
+ return err
223
+ }
224
+ m.preserveCurrentIdentityPaths(&cfg)
225
+ if len(cfg.Tunnels) <= 1 {
226
+ return errors.New("cannot delete the last tunnel")
227
+ }
228
+
229
+ next := cfg.Tunnels[:0]
230
+ found := false
231
+ for _, tunnel := range cfg.Tunnels {
232
+ if tunnel.ID == id {
233
+ found = true
234
+ continue
235
+ }
236
+ next = append(next, tunnel)
237
+ }
238
+ if !found {
239
+ return fmt.Errorf("tunnel %q not found", id)
240
+ }
241
+ cfg.Tunnels = next
242
+ return m.writeConfigAndApply(path, mode, cfg)
243
+}
244
+
245
+func (m *manager) loadConfigDocument() (Config, string, os.FileMode, error) {
246
+ m.mu.RLock()
247
+ configPath := m.cfg.sourcePath
248
+ m.mu.RUnlock()
249
+ return loadConfigDocument(configPath)
250
+}
251
+
252
+func (m *manager) preserveCurrentIdentityPaths(cfg *Config) {
253
+ m.mu.RLock()
254
+ identityPathByID := make(map[string]string, len(m.cfg.Tunnels))
255
+ for _, tunnel := range m.cfg.Tunnels {
256
+ if strings.TrimSpace(tunnel.IdentityPath) != "" {
257
+ identityPathByID[tunnel.ID] = tunnel.IdentityPath
258
+ }
259
+ }
260
+ m.mu.RUnlock()
261
+
262
+ for i := range cfg.Tunnels {
263
+ tunnel := &cfg.Tunnels[i]
264
+ if strings.TrimSpace(tunnel.IdentityPath) != "" {
265
+ continue
266
+ }
267
+ if identityPath := identityPathByID[tunnel.ID]; identityPath != "" {
268
+ tunnel.IdentityPath = identityPath
269
+ }
270
+ }
271
+}
272
+
273
+func (m *manager) writeConfigAndApply(path string, mode os.FileMode, cfg Config) error {
274
+ if err := validateConfigDocument(path, cfg); err != nil {
275
+ return err
276
+ }
277
+ if err := writeConfigDocument(path, mode, cfg); err != nil {
278
+ return err
279
+ }
280
+ next, err := LoadConfig(path)
281
+ if err != nil {
282
+ return err
283
+ }
284
+ return m.ApplyConfig(next)
285
+}
286
+
287
+func (m *manager) ApplyConfig(cfg Config) error {
288
m.mu.Lock()
289
+ m.cfg = cfg
290
rootCtx := m.rootCtx
154
- restartDelay, _ := time.ParseDuration(cfg.Agent.RestartDelay)
155
- if restartDelay <= 0 {
156
- restartDelay = 5 * time.Second
157
- }
291
next := make(map[string]TunnelConfig, len(cfg.Tunnels))
292
for _, tunnelCfg := range cfg.Tunnels {
293
next[tunnelCfg.ID] = tunnelCfg
294
}
295
toStop := make([]*managedTunnel, 0)
296
toStart := make([]*managedTunnel, 0)
164
- toRestart := make([]*managedTunnel, 0)
297
+ toUpdate := make([]*managedTunnel, 0)
298
for id, tunnel := range m.tunnels {
299
tunnelCfg, ok := next[id]
300
if !ok {
@@ -170,34 +303,29 @@ func (m *manager) Reload(cfg Config) error {
303
continue
304
}
305
tunnel.mu.Lock()
173
- tunnel.restartDelay = restartDelay
306
if !reflect.DeepEqual(tunnel.cfg, tunnelCfg) {
307
tunnel.cfg = tunnelCfg
176
- tunnel.updatedAt = time.Now().UTC()
177
- toRestart = append(toRestart, tunnel)
308
+ toUpdate = append(toUpdate, tunnel)
309
}
310
tunnel.mu.Unlock()
311
delete(next, id)
312
}
313
for _, tunnelCfg := range next {
183
- tunnel := newTunnel(tunnelCfg, restartDelay, m.appendLog)
314
+ tunnel := newTunnel(tunnelCfg)
315
m.tunnels[tunnelCfg.ID] = tunnel
316
toStart = append(toStart, tunnel)
317
}
318
m.mu.Unlock()
319
189
- for _, tunnel := range toStop {
320
+ for _, tunnel := range append(toStop, toUpdate...) {
321
_ = tunnel.Stop(context.Background())
322
}
323
if rootCtx == nil {
324
rootCtx = context.Background()
325
}
195
- for _, tunnel := range toStart {
326
+ for _, tunnel := range append(toStart, toUpdate...) {
327
tunnel.Start(rootCtx)
328
}
198
- for _, tunnel := range toRestart {
199
- tunnel.Restart()
200
- }
329
return nil
330
}
331
@@ -207,51 +335,25 @@ func (m *manager) Snapshot() types.AgentStatusResponse {
335
for _, tunnel := range m.tunnels {
336
tunnels = append(tunnels, tunnel)
337
}
210
- logs := append([]types.AgentLogEntry(nil), m.logs...)
338
m.mu.RUnlock()
339
340
statuses := make([]types.AgentTunnelStatus, 0, len(tunnels))
214
- summary := types.AgentMetricsSummary{TunnelCount: len(tunnels)}
341
for _, tunnel := range tunnels {
216
- status := tunnel.Snapshot()
217
- switch status.State {
218
- case tunnelStateRunning:
219
- summary.RunningCount++
220
- case tunnelStateError:
221
- summary.ErrorCount++
222
- }
223
- statuses = append(statuses, status)
342
+ statuses = append(statuses, tunnel.Snapshot())
343
}
344
slices.SortFunc(statuses, func(a, b types.AgentTunnelStatus) int {
345
return strings.Compare(a.ID, b.ID)
346
})
347
348
return types.AgentStatusResponse{
230
- ReleaseVersion: types.ReleaseVersion,
231
- StartedAt: m.startedAt,
232
- ControlAddr: m.controlAddr,
233
- Tunnels: statuses,
234
- Logs: logs,
235
- Summary: summary,
236
- }
237
-}
238
-
239
-func (m *manager) appendLog(entry types.AgentLogEntry) {
240
- entry.Time = time.Now().UTC()
241
- m.mu.Lock()
242
- defer m.mu.Unlock()
243
- m.logs = append(m.logs, entry)
244
- if len(m.logs) > 200 {
245
- copy(m.logs, m.logs[len(m.logs)-200:])
246
- m.logs = m.logs[:200]
349
+ ControlAddr: m.controlAddr,
350
+ Tunnels: statuses,
351
}
352
}
353
354
type managedTunnel struct {
251
- mu sync.RWMutex
252
- cfg TunnelConfig
253
- restartDelay time.Duration
254
- appendLog func(types.AgentLogEntry)
355
+ mu sync.RWMutex
356
+ cfg TunnelConfig
357
358
stopCancel context.CancelFunc
359
runCancel context.CancelFunc
@@ -260,19 +362,12 @@ type managedTunnel struct {
362
363
state string
364
lastError string
263
- startedAt time.Time
264
- updatedAt time.Time
265
- restarts int
365
}
366
268
-func newTunnel(cfg TunnelConfig, restartDelay time.Duration, appendLog func(types.AgentLogEntry)) *managedTunnel {
269
- now := time.Now().UTC()
367
+func newTunnel(cfg TunnelConfig) *managedTunnel {
368
return &managedTunnel{
271
- cfg: cfg,
272
- restartDelay: restartDelay,
273
- appendLog: appendLog,
274
- state: tunnelStateStopped,
275
- updatedAt: now,
369
+ cfg: cfg,
370
+ state: tunnelStateStopped,
371
}
372
}
373
@@ -321,16 +416,6 @@ func (t *managedTunnel) Stop(ctx context.Context) error {
416
}
417
}
418
324
-func (t *managedTunnel) Restart() {
325
- t.mu.Lock()
326
- if t.runCancel != nil {
327
- t.state = tunnelStateRestarting
328
- t.updatedAt = time.Now().UTC()
329
- t.runCancel()
330
- }
331
- t.mu.Unlock()
332
-}
333
-
419
func (t *managedTunnel) AddRelay(relayURL string) error {
420
t.mu.RLock()
421
id := t.cfg.ID
@@ -342,7 +427,6 @@ func (t *managedTunnel) AddRelay(relayURL string) error {
427
if err := exposure.AddRelay(relayURL); err != nil {
428
return err
429
}
345
- t.appendLog(types.AgentLogEntry{TunnelID: id, Level: "info", Message: "relay added"})
430
return nil
431
}
432
@@ -357,7 +441,20 @@ func (t *managedTunnel) RemoveRelay(relayURL string) error {
441
if err := exposure.RemoveRelay(relayURL); err != nil {
442
return err
443
}
360
- t.appendLog(types.AgentLogEntry{TunnelID: id, Level: "info", Message: "relay removed"})
444
+ return nil
445
+}
446
+
447
+func (t *managedTunnel) SeedRelay(relayURL string) error {
448
+ t.mu.RLock()
449
+ id := t.cfg.ID
450
+ exposure := t.exposure
451
+ t.mu.RUnlock()
452
+ if exposure == nil {
453
+ return fmt.Errorf("tunnel %q is not running", id)
454
+ }
455
+ if err := exposure.SeedRelay(relayURL); err != nil {
456
+ return err
457
+ }
458
return nil
459
}
460
@@ -372,11 +469,6 @@ func (t *managedTunnel) SetMultiHop(relayURLs []string) error {
469
if err := exposure.SetMultiHop(relayURLs); err != nil {
470
return err
471
}
375
- message := "multi-hop cleared"
376
- if len(relayURLs) > 0 {
377
- message = "multi-hop updated"
378
- }
379
- t.appendLog(types.AgentLogEntry{TunnelID: id, Level: "info", Message: message})
472
return nil
473
}
474
@@ -385,9 +477,6 @@ func (t *managedTunnel) Snapshot() types.AgentTunnelStatus {
477
cfg := t.cfg
478
state := t.state
479
lastError := t.lastError
388
- startedAt := t.startedAt
389
- updatedAt := t.updatedAt
390
- restarts := t.restarts
480
exposure := t.exposure
481
t.mu.RUnlock()
482
@@ -398,9 +487,6 @@ func (t *managedTunnel) Snapshot() types.AgentTunnelStatus {
487
TargetAddr: cfg.TargetAddr,
488
UDPAddr: cfg.UDPAddr,
489
LastError: lastError,
401
- StartedAt: startedAt,
402
- UpdatedAt: updatedAt,
403
- Restarts: restarts,
490
}
491
if exposure == nil {
492
return status
@@ -419,64 +505,24 @@ func (t *managedTunnel) Snapshot() types.AgentTunnelStatus {
505
}
506
507
func (t *managedTunnel) runLoop(ctx context.Context) {
422
- stop := func() {
423
- t.mu.Lock()
424
- t.state = tunnelStateStopped
425
- t.updatedAt = time.Now().UTC()
426
- t.exposure = nil
427
- tunnelID := t.cfg.ID
428
- t.mu.Unlock()
429
- t.appendLog(types.AgentLogEntry{TunnelID: tunnelID, Level: "info", Message: "tunnel stopped"})
430
- }
508
+ runCtx, runCancel := context.WithCancel(ctx)
509
+ t.mu.Lock()
510
+ t.runCancel = runCancel
511
+ t.mu.Unlock()
512
432
- for {
433
- if ctx.Err() != nil {
434
- stop()
435
- return
436
- }
437
- runCtx, runCancel := context.WithCancel(ctx)
438
- t.mu.Lock()
439
- t.runCancel = runCancel
440
- t.mu.Unlock()
441
- err := t.runOnce(runCtx)
442
- t.mu.Lock()
443
- t.runCancel = nil
444
- t.exposure = nil
445
- t.mu.Unlock()
446
- if ctx.Err() != nil {
447
- stop()
448
- return
449
- }
513
+ err := t.runOnce(runCtx)
514
451
- level := "error"
452
- t.mu.Lock()
453
- delay := t.restartDelay
454
- message := fmt.Sprintf("tunnel stopped; restarting in %s", delay)
455
- t.restarts++
515
+ t.mu.Lock()
516
+ t.runCancel = nil
517
+ t.exposure = nil
518
+ t.lastError = ""
519
+ if ctx.Err() != nil || errors.Is(err, context.Canceled) || err == nil {
520
+ t.state = tunnelStateStopped
521
+ } else {
522
t.state = tunnelStateError
457
- t.lastError = ""
458
- if errors.Is(err, context.Canceled) {
459
- t.state = tunnelStateRestarting
460
- delay = 100 * time.Millisecond
461
- level = "info"
462
- message = "tunnel restarting"
463
- } else if err != nil {
464
- t.lastError = err.Error()
465
- }
466
- t.updatedAt = time.Now().UTC()
467
- tunnelID := t.cfg.ID
468
- t.mu.Unlock()
469
-
470
- t.appendLog(types.AgentLogEntry{TunnelID: tunnelID, Level: level, Message: message})
471
- timer := time.NewTimer(delay)
472
- select {
473
- case <-ctx.Done():
474
- timer.Stop()
475
- stop()
476
- return
477
- case <-timer.C:
478
- }
523
+ t.lastError = err.Error()
524
}
525
+ t.mu.Unlock()
526
}
527
528
func (t *managedTunnel) runOnce(ctx context.Context) error {
@@ -484,7 +530,6 @@ func (t *managedTunnel) runOnce(ctx context.Context) error {
530
cfg := t.cfg
531
t.state = tunnelStateStarting
532
t.lastError = ""
487
- t.updatedAt = time.Now().UTC()
533
t.mu.Unlock()
534
535
discovery := true
@@ -524,12 +569,8 @@ func (t *managedTunnel) runOnce(ctx context.Context) error {
569
t.exposure = exposure
570
t.state = tunnelStateRunning
571
t.lastError = ""
527
- t.startedAt = time.Now().UTC()
528
- t.updatedAt = t.startedAt
529
- tunnelID := t.cfg.ID
572
t.mu.Unlock()
573
532
- t.appendLog(types.AgentLogEntry{TunnelID: tunnelID, Level: "info", Message: "tunnel started"})
574
defer exposure.Close()
575
576
if len(cfg.HTTPRoutes) > 0 {
cmd/portal-tunnel/agent/run.go
-12
@@ -30,17 +30,6 @@ func Run(ctx context.Context, cfg Config) error {
30
defer cancel()
31
32
manager := newManager(cfg, "")
33
- reload := func() error {
34
- if cfg.sourcePath == "" {
35
- return errors.New("config path is not available")
36
- }
37
- next, err := LoadConfig(cfg.sourcePath)
38
- if err != nil {
39
- return err
40
- }
41
- cfg = next
42
- return manager.Reload(next)
43
- }
33
controlAddr := strings.TrimSpace(cfg.Agent.ControlAddr)
34
if controlAddr == "" {
35
return errors.New("control address is required")
@@ -69,7 +58,6 @@ func Run(ctx context.Context, cfg Config) error {
58
manager: manager,
59
token: token,
60
shutdown: cancel,
72
- reload: reload,
61
},
62
ReadHeaderTimeout: 5 * time.Second,
63
}
docs/src/routes/cli-reference/+page.md
+1
-1
@@ -169,7 +169,7 @@ Use `portal agent dashboard` to attach to an already running local agent. When u
169
|---------|-------------|
170
| `portal agent run` | Install/update and start the managed agent service |
171
| `portal agent run --config config.toml --foreground` | Run the agent in the current terminal |
172
-| `portal agent dashboard` | Open the mouse-capable local TUI for status, discovered relays, logs, reload, restart, relay attach/detach, and multi-hop route changes |
172
+| `portal agent dashboard` | Open the mouse-capable local TUI for tunnels, relay attach/detach, relay lists, and multi-hop route changes |
173
| `portal agent stop` | Gracefully stop the agent and disable/stop the OS service |
174
175
The local control API binds only to loopback and uses a token in the agent state directory. See [Configuration Reference](/configuration#configtoml) for the `config.toml` format.
docs/src/routes/configuration/+page.md
-2
@@ -162,7 +162,6 @@ Default paths:
162
[agent]
163
control_addr = "127.0.0.1:4018"
164
service_name = "portal-agent"
165
-restart_delay = "5s"
165
166
[[tunnels]]
167
id = "web"
@@ -193,7 +192,6 @@ Agent fields:
192
| `state_dir` | Platform default state directory | Stores the local control endpoint token and runtime state |
193
| `control_addr` | `127.0.0.1:4018` | Loopback-only local control API address |
194
| `service_name` | `portal-agent` | OS service name |
196
-| `restart_delay` | `5s` | Delay before restarting a failed tunnel |
195
196
Tunnel fields mirror `portal expose` flags:
197
go.mod
+1
@@ -37,6 +37,7 @@ require (
37
cloud.google.com/go/auth v0.20.0 // indirect
38
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
39
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect
40
+ github.com/atotto/clipboard v0.1.4 // indirect
41
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect
42
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
43
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
go.sum
+2
@@ -8,6 +8,8 @@ github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608
8
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
9
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
10
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
11
+github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
12
+github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
13
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
14
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
15
github.com/aws/aws-sdk-go-v2/config v1.32.14 h1:opVIRo/ZbbI8OIqSOKmpFaY7IwfFUOCCXBsUpJOwDdI=
portal/discovery/mols.go
+6
@@ -320,6 +320,9 @@ func (p MOLSRelayPolicy) SelectPriority(states []RelayState, clientState ClientS
320
explicit = append(explicit, relayURL)
321
continue
322
}
323
+ if slices.Contains(clientState.SuppressedRelayURLs, relayURL) {
324
+ continue
325
+ }
326
327
if state.hasObservedDescriptor() {
328
if !state.Descriptor.ExpiresAt.After(now) {
@@ -362,6 +365,9 @@ func (p MOLSRelayPolicy) SelectMultiHop(states []RelayState, clientState ClientS
365
now := time.Now().UTC()
366
autoPool := make([]RelayState, 0, len(selected))
367
for _, state := range selected {
368
+ if slices.Contains(clientState.SuppressedRelayURLs, state.Descriptor.APIHTTPSAddr) {
369
+ continue
370
+ }
371
if clientState.RequireUDP && state.hasObservedDescriptor() && !state.Descriptor.SupportsUDP {
372
continue
373
}
portal/discovery/relaystate.go
+3
@@ -59,6 +59,9 @@ func (state RelayState) hasObservedDescriptor() bool {
59
60
type ClientState struct {
61
ExplicitRelayURLs []string
62
+ // SuppressedRelayURLs are discovery seeds that must not be auto-selected
63
+ // as active relays unless they are also explicit.
64
+ SuppressedRelayURLs []string
65
// MaxActiveRelays caps auto-selected relays. Zero or negative values use
66
// the policy default of 3.
67
MaxActiveRelays int
sdk/expose.go
+80
-9
@@ -27,6 +27,7 @@ type Exposure struct {
27
28
identity types.Identity
29
explicitRelays []string
30
+ seedOnlyRelays []string
31
TargetAddr string
32
UDPAddr string
33
udpEnabled bool
@@ -208,6 +209,13 @@ func (e *Exposure) AddRelay(relayURL string) error {
209
}
210
211
e.listenerMu.Lock()
212
+ nextSeedOnlyRelays := make([]string, 0, len(e.seedOnlyRelays))
213
+ for _, existing := range e.seedOnlyRelays {
214
+ if existing != relayURL {
215
+ nextSeedOnlyRelays = append(nextSeedOnlyRelays, existing)
216
+ }
217
+ }
218
+ e.seedOnlyRelays = nextSeedOnlyRelays
219
if !slices.Contains(e.explicitRelays, relayURL) {
220
e.explicitRelays = append(append([]string(nil), e.explicitRelays...), relayURL)
221
}
@@ -240,6 +248,13 @@ func (e *Exposure) RemoveRelay(relayURL string) error {
248
}
249
}
250
e.explicitRelays = nextRelays
251
+ nextSeedOnlyRelays := make([]string, 0, len(e.seedOnlyRelays))
252
+ for _, existing := range e.seedOnlyRelays {
253
+ if existing != relayURL {
254
+ nextSeedOnlyRelays = append(nextSeedOnlyRelays, existing)
255
+ }
256
+ }
257
+ e.seedOnlyRelays = nextSeedOnlyRelays
258
if slices.Contains(e.multiHop, relayURL) {
259
nextMultiHop := make([]string, 0, len(e.multiHop))
260
for _, existing := range e.multiHop {
@@ -260,6 +275,51 @@ func (e *Exposure) RemoveRelay(relayURL string) error {
275
return e.reconcileRelayListeners(false)
276
}
277
278
+// SeedRelay keeps a relay as a discovery seed while removing it from the
279
+// active relay pool for this exposure.
280
+func (e *Exposure) SeedRelay(relayURL string) error {
281
+ relayURL, err := utils.NormalizeRelayURL(relayURL)
282
+ if err != nil {
283
+ return err
284
+ }
285
+ if e.closed() {
286
+ return net.ErrClosed
287
+ }
288
+ if e.relaySet == nil {
289
+ return errors.New("exposure relay set is not initialized")
290
+ }
291
+
292
+ e.listenerMu.Lock()
293
+ nextRelays := make([]string, 0, len(e.explicitRelays))
294
+ for _, existing := range e.explicitRelays {
295
+ if existing != relayURL {
296
+ nextRelays = append(nextRelays, existing)
297
+ }
298
+ }
299
+ e.explicitRelays = nextRelays
300
+ if !slices.Contains(e.seedOnlyRelays, relayURL) {
301
+ e.seedOnlyRelays = append(append([]string(nil), e.seedOnlyRelays...), relayURL)
302
+ }
303
+ if slices.Contains(e.multiHop, relayURL) {
304
+ nextMultiHop := make([]string, 0, len(e.multiHop))
305
+ for _, existing := range e.multiHop {
306
+ if existing != relayURL {
307
+ nextMultiHop = append(nextMultiHop, existing)
308
+ }
309
+ }
310
+ if len(nextMultiHop) < 2 {
311
+ nextMultiHop = nil
312
+ }
313
+ e.multiHop = nextMultiHop
314
+ e.multiHopDepth = 0
315
+ }
316
+ e.listenerMu.Unlock()
317
+
318
+ e.relaySet.AllowRelayURL(relayURL)
319
+ e.relaySet.AddBootstrapRelayURL(relayURL)
320
+ return e.reconcileRelayListeners(false)
321
+}
322
+
323
func (e *Exposure) SetMultiHop(relayURLs []string) error {
324
multiHop := make([]string, 0, len(relayURLs))
325
for _, input := range relayURLs {
@@ -291,6 +351,13 @@ func (e *Exposure) SetMultiHop(relayURLs []string) error {
351
}
352
353
e.listenerMu.Lock()
354
+ nextSeedOnlyRelays := make([]string, 0, len(e.seedOnlyRelays))
355
+ for _, existing := range e.seedOnlyRelays {
356
+ if !slices.Contains(multiHop, existing) {
357
+ nextSeedOnlyRelays = append(nextSeedOnlyRelays, existing)
358
+ }
359
+ }
360
+ e.seedOnlyRelays = nextSeedOnlyRelays
361
e.multiHop = append([]string(nil), multiHop...)
362
e.multiHopDepth = 0
363
e.listenerMu.Unlock()
@@ -630,17 +697,20 @@ func (e *Exposure) runDiscoveryLoop(ctx context.Context) {
697
}
698
699
func (e *Exposure) reconcileRelayListeners(failOnError bool) error {
633
- multiHop := e.multiHop
700
+ var multiHop []string
701
var listenerRelayURLs []string
702
703
e.listenerMu.Lock()
637
- explicitRelays := e.explicitRelays
704
+ multiHop = append([]string(nil), e.multiHop...)
705
+ explicitRelays := append([]string(nil), e.explicitRelays...)
706
+ seedOnlyRelays := append([]string(nil), e.seedOnlyRelays...)
707
if len(multiHop) > 0 {
708
listenerRelayURLs = []string{multiHop[len(multiHop)-1]}
709
} else if e.multiHopDepth > 1 {
710
multiHop = e.relaySet.PriorityMultiHop(discovery.ClientState{
642
- MultiHopDepth: e.multiHopDepth,
643
- LocalAddress: e.identity.Address,
711
+ SuppressedRelayURLs: seedOnlyRelays,
712
+ MultiHopDepth: e.multiHopDepth,
713
+ LocalAddress: e.identity.Address,
714
})
715
if len(multiHop) < e.multiHopDepth {
716
e.listenerMu.Unlock()
@@ -649,11 +719,12 @@ func (e *Exposure) reconcileRelayListeners(failOnError bool) error {
719
listenerRelayURLs = []string{multiHop[len(multiHop)-1]}
720
} else {
721
listenerRelayURLs = e.relaySet.PriorityRelays(discovery.ClientState{
652
- ExplicitRelayURLs: explicitRelays,
653
- MaxActiveRelays: e.maxActiveRelays,
654
- RequireUDP: e.udpEnabled,
655
- RequireTCP: e.tcpEnabled,
656
- LocalAddress: e.identity.Address,
722
+ ExplicitRelayURLs: explicitRelays,
723
+ SuppressedRelayURLs: seedOnlyRelays,
724
+ MaxActiveRelays: e.maxActiveRelays,
725
+ RequireUDP: e.udpEnabled,
726
+ RequireTCP: e.tcpEnabled,
727
+ LocalAddress: e.identity.Address,
728
})
729
}
730
types/agent.go
+7
-20
@@ -3,18 +3,8 @@ package types
3
import "time"
4
5
type AgentStatusResponse struct {
6
- ReleaseVersion string `json:"release_version"`
7
- StartedAt time.Time `json:"started_at"`
8
- ControlAddr string `json:"control_addr"`
9
- Tunnels []AgentTunnelStatus `json:"tunnels,omitempty"`
10
- Logs []AgentLogEntry `json:"logs,omitempty"`
11
- Summary AgentMetricsSummary `json:"summary"`
12
-}
13
-
14
-type AgentMetricsSummary struct {
15
- TunnelCount int `json:"tunnel_count"`
16
- RunningCount int `json:"running_count"`
17
- ErrorCount int `json:"error_count"`
6
+ ControlAddr string `json:"control_addr"`
7
+ Tunnels []AgentTunnelStatus `json:"tunnels,omitempty"`
8
}
9
10
type AgentTunnelStatus struct {
@@ -24,9 +14,6 @@ type AgentTunnelStatus struct {
14
TargetAddr string `json:"target_addr,omitempty"`
15
UDPAddr string `json:"udp_addr,omitempty"`
16
LastError string `json:"last_error,omitempty"`
27
- StartedAt time.Time `json:"started_at,omitempty"`
28
- UpdatedAt time.Time `json:"updated_at,omitempty"`
29
- Restarts int `json:"restarts,omitempty"`
17
MultiHop []string `json:"multi_hop,omitempty"`
18
Relays []AgentRelayStatus `json:"relays,omitempty"`
19
PublicURLs []string `json:"public_urls,omitempty"`
@@ -54,11 +41,11 @@ type AgentRelayStatus struct {
41
Connected bool `json:"connected"`
42
}
43
57
-type AgentLogEntry struct {
58
- Time time.Time `json:"time"`
59
- TunnelID string `json:"tunnel_id,omitempty"`
60
- Level string `json:"level"`
61
- Message string `json:"message"`
44
+type AgentTunnelRequest struct {
45
+ ID string `json:"id"`
46
+ Name string `json:"name,omitempty"`
47
+ TargetAddr string `json:"target_addr,omitempty"`
48
+ RelayURLs []string `json:"relays,omitempty"`
49
}
50
51
type AgentRelayRequest struct {
types/paths.go
+1
-1
@@ -27,7 +27,7 @@ const (
27
PathAgentPrefix = "/v1/agent"
28
PathAgentStatus = PathAgentPrefix + "/status"
29
PathAgentShutdown = PathAgentPrefix + "/shutdown"
30
- PathAgentReload = PathAgentPrefix + "/reload"
30
+ PathAgentTunnels = PathAgentPrefix + "/tunnels"
31
PathAgentTunnelsPrefix = PathAgentPrefix + "/tunnels/"
32
33
PathTunnelStatus = "/tunnel/status"