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"