feat: implement bubbletea for tunnel agent WIP

rabbitprincess committed May 1, 2026 at 00:01 UTC 54b4b9512893a61c5863941fb9dee984c75e93ba
17 files changed +1523 -265
cmd/portal-tunnel/README.md
+10 -9
@@ -112,19 +112,23 @@ Flags:
112
113 Runs Portal as a managed long-lived tunnel agent.
114
115 -- `portal agent run` reads the platform default config path and installs or updates the OS service.
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.
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.
119 -- `portal agent status` reads the same local control API and prints tunnel state, relays, public URLs, and recent errors.
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.
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
124 Default paths:
125
126 | OS | Config | Default identity |
127 |----|--------|------------------|
126 -| Linux | `/etc/portal-tunnel/agent/config.toml` | `/var/lib/portal-tunnel/agent/identity.json` |
127 -| macOS | `/Library/Application Support/Portal Tunnel/Agent/config.toml` | `/Library/Application Support/Portal Tunnel/Agent/identity.json` |
128 +| Linux user | `$XDG_CONFIG_HOME/portal-tunnel/agent/config.toml` or `~/.config/portal-tunnel/agent/config.toml` | `$XDG_DATA_HOME/portal-tunnel/agent/identity.json` or `~/.local/share/portal-tunnel/agent/identity.json` |
129 +| Linux root | `/etc/portal-tunnel/agent/config.toml` | `/var/lib/portal-tunnel/agent/identity.json` |
130 +| macOS user | `~/Library/Application Support/Portal Tunnel/Agent/config.toml` | `~/Library/Application Support/Portal Tunnel/Agent/identity.json` |
131 +| macOS root | `/Library/Application Support/Portal Tunnel/Agent/config.toml` | `/Library/Application Support/Portal Tunnel/Agent/identity.json` |
132 | Windows | `%ProgramData%\Portal Tunnel\Agent\config.toml` | `%ProgramData%\Portal Tunnel\Agent\identity.json` |
133
134 Example `config.toml`:
@@ -147,11 +151,8 @@ tags = ["web"]
151 Runtime controls:
152
153 ```text
150 -portal agent status
151 -portal agent reload
152 -portal agent restart web
153 -portal agent relay-add web https://relay2.example.com
154 -portal agent relay-remove web https://relay2.example.com
154 +portal agent run
155 +portal agent dashboard
156 portal agent stop
157 ```
158
cmd/portal-tunnel/agent.go
+35 -172
@@ -2,7 +2,6 @@ package main
2
3 import (
4 "context"
5 - "encoding/json"
5 "errors"
6 "flag"
7 "fmt"
@@ -10,7 +9,6 @@ import (
9 "os"
10 "path/filepath"
11 "strings"
13 - "text/tabwriter"
12 "time"
13
14 "github.com/gosuda/portal-tunnel/v2/cmd/portal-tunnel/agent"
@@ -21,16 +19,12 @@ import (
19
20 func runAgentCommand(args []string) error {
21 return utils.RunCommands(args, os.Stdout, os.Stderr, printAgentUsage, map[string]utils.CommandFunc{
24 - "run": runAgentRunCommand,
25 - "status": runAgentStatusCommand,
26 - "stop": runAgentStopCommand,
27 - "reload": runAgentReloadCommand,
28 - "restart": runAgentRestartCommand,
29 - "add": runAgentRelayAddCommand,
30 - "remove": runAgentRelayRemoveCommand,
22 + "run": runAgentRunCommand,
23 + "dashboard": runAgentDashboardCommand,
24 + "stop": runAgentStopCommand,
25 "help": utils.MakeHelpCommand(printAgentUsage, []utils.HelpTopic{
26 {Name: "run", Usage: printAgentRunUsage},
33 - {Name: "status", Usage: printAgentStatusUsage},
27 + {Name: "dashboard", Usage: printAgentDashboardUsage},
28 {Name: "stop", Usage: printAgentStopUsage},
29 }),
30 })
@@ -90,48 +84,20 @@ func runAgentRunCommand(args []string) error {
84 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
85 defer cancel()
86 if err := service.Install(ctx, def); err != nil {
93 - return fmt.Errorf("install portal agent service: %w", err)
87 + return fmt.Errorf("install portal agent service: %w; use --foreground when the OS service manager is unavailable", err)
88 }
89 if err := service.Start(ctx, cfg.Agent.ServiceName); err != nil {
96 - return fmt.Errorf("start portal agent service: %w", err)
90 + return fmt.Errorf("start portal agent service: %w; use --foreground when the OS service manager is unavailable", err)
91 }
92 status, err := waitAgentStatus(ctx, cfg.Agent.StateDir)
93 if err != nil {
94 return err
95 }
102 - fmt.Fprintf(os.Stdout, "Portal agent running at %s with %d tunnel(s).\n", status.ControlAddr, len(status.Tunnels))
103 - return nil
104 -}
105 -
106 -func runAgentStatusCommand(args []string) error {
107 - var configPath string
108 - var stateDir string
109 - var jsonOutput bool
110 - fs := utils.NewFlagSet("agent status", printAgentStatusUsage)
111 - utils.StringFlag(fs, &configPath, "config", "", "Portal agent TOML config path")
112 - utils.StringFlag(fs, &stateDir, "state-dir", "", "Portal agent state directory")
113 - utils.BoolFlag(fs, &jsonOutput, "json", false, "Print raw JSON status")
114 - if err := utils.ParseFlagSet(fs, args, printAgentStatusUsage); err != nil {
115 - if errors.Is(err, flag.ErrHelp) {
116 - return nil
117 - }
118 - return err
119 - }
120 - if err := utils.RequireNoArgs(fs.Args(), "agent status"); err != nil {
121 - printAgentStatusUsage(os.Stderr)
122 - return err
96 + if agentCLIInteractive() {
97 + return agent.RunDashboard(configPath, cfg.Agent.StateDir)
98 }
99
125 - status, err := agentStatusFromFlags(context.Background(), configPath, stateDir)
126 - if err != nil {
127 - return err
128 - }
129 - if jsonOutput {
130 - enc := json.NewEncoder(os.Stdout)
131 - enc.SetIndent("", " ")
132 - return enc.Encode(status)
133 - }
134 - printAgentStatus(os.Stdout, status)
100 + fmt.Fprintf(os.Stdout, "Portal agent running at %s with %d tunnel(s).\n", status.ControlAddr, len(status.Tunnels))
101 return nil
102 }
103
@@ -167,118 +133,31 @@ func runAgentStopCommand(args []string) error {
133 return nil
134 }
135
170 -func runAgentRestartCommand(args []string) error {
171 - configPath, stateDir, tunnelID, err := parseAgentTunnelCommand("agent restart", args, printAgentUsage)
172 - if err != nil {
173 - if errors.Is(err, flag.ErrHelp) {
174 - return nil
175 - }
176 - return err
177 - }
178 - return withAgentControl(configPath, stateDir, func(ctx context.Context, stateDir string) error {
179 - return agent.RestartTunnel(ctx, stateDir, tunnelID)
180 - })
181 -}
182 -
183 -func runAgentReloadCommand(args []string) error {
184 - var configPath, stateDir string
185 - fs := utils.NewFlagSet("agent reload", printAgentUsage)
136 +func runAgentDashboardCommand(args []string) error {
137 + var configPath string
138 + var stateDir string
139 + fs := utils.NewFlagSet("agent dashboard", printAgentDashboardUsage)
140 utils.StringFlag(fs, &configPath, "config", "", "Portal agent TOML config path")
141 utils.StringFlag(fs, &stateDir, "state-dir", "", "Portal agent state directory")
188 - if err := utils.ParseFlagSet(fs, args, printAgentUsage); err != nil {
189 - if errors.Is(err, flag.ErrHelp) {
190 - return nil
191 - }
192 - return err
193 - }
194 - if err := utils.RequireNoArgs(fs.Args(), "agent reload"); err != nil {
195 - return err
196 - }
197 - return withAgentControl(configPath, stateDir, func(ctx context.Context, stateDir string) error {
198 - return agent.Reload(ctx, stateDir)
199 - })
200 -}
201 -
202 -func runAgentRelayAddCommand(args []string) error {
203 - configPath, stateDir, tunnelID, relayURL, err := parseAgentRelayCommand("agent relay-add", args, printAgentUsage)
204 - if err != nil {
142 + if err := utils.ParseFlagSet(fs, args, printAgentDashboardUsage); err != nil {
143 if errors.Is(err, flag.ErrHelp) {
144 return nil
145 }
146 return err
147 }
210 - return withAgentControl(configPath, stateDir, func(ctx context.Context, stateDir string) error {
211 - return agent.AddRelay(ctx, stateDir, tunnelID, relayURL)
212 - })
213 -}
214 -
215 -func runAgentRelayRemoveCommand(args []string) error {
216 - configPath, stateDir, tunnelID, relayURL, err := parseAgentRelayCommand("agent relay-remove", args, printAgentUsage)
217 - if err != nil {
218 - if errors.Is(err, flag.ErrHelp) {
219 - return nil
220 - }
148 + if err := utils.RequireNoArgs(fs.Args(), "agent dashboard"); err != nil {
149 + printAgentDashboardUsage(os.Stderr)
150 return err
151 }
223 - return withAgentControl(configPath, stateDir, func(ctx context.Context, stateDir string) error {
224 - return agent.RemoveRelay(ctx, stateDir, tunnelID, relayURL)
225 - })
226 -}
152
228 -func parseAgentTunnelCommand(name string, args []string, usage func(io.Writer)) (string, string, string, error) {
229 - var configPath, stateDir string
230 - fs := utils.NewFlagSet(name, usage)
231 - utils.StringFlag(fs, &configPath, "config", "", "Portal agent TOML config path")
232 - utils.StringFlag(fs, &stateDir, "state-dir", "", "Portal agent state directory")
233 - if err := utils.ParseFlagSet(fs, args, usage); err != nil {
234 - if errors.Is(err, flag.ErrHelp) {
235 - return "", "", "", flag.ErrHelp
236 - }
237 - return "", "", "", err
238 - }
239 - if len(fs.Args()) != 1 {
240 - return "", "", "", errors.New(name + " requires tunnel id")
241 - }
242 - return configPath, stateDir, fs.Args()[0], nil
243 -}
244 -
245 -func parseAgentRelayCommand(name string, args []string, usage func(io.Writer)) (string, string, string, string, error) {
246 - var configPath, stateDir string
247 - fs := utils.NewFlagSet(name, usage)
248 - utils.StringFlag(fs, &configPath, "config", "", "Portal agent TOML config path")
249 - utils.StringFlag(fs, &stateDir, "state-dir", "", "Portal agent state directory")
250 - if err := utils.ParseFlagSet(fs, args, usage); err != nil {
251 - if errors.Is(err, flag.ErrHelp) {
252 - return "", "", "", "", flag.ErrHelp
253 - }
254 - return "", "", "", "", err
255 - }
256 - if len(fs.Args()) != 2 {
257 - return "", "", "", "", errors.New(name + " requires tunnel id and relay url")
258 - }
259 - return configPath, stateDir, fs.Args()[0], fs.Args()[1], nil
260 -}
261 -
262 -func withAgentControl(configPath, stateDir string, run func(context.Context, string) error) error {
153 _, resolvedStateDir, err := loadAgentCommandConfig(configPath, stateDir)
154 if err != nil {
155 return err
156 }
267 - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
268 - defer cancel()
269 - if err := run(ctx, resolvedStateDir); err != nil {
270 - return err
271 - }
272 - fmt.Fprintln(os.Stdout, "Accepted.")
273 - return nil
274 -}
275 -
276 -func agentStatusFromFlags(ctx context.Context, configPath, stateDir string) (types.AgentStatusResponse, error) {
277 - _, resolvedStateDir, err := loadAgentCommandConfig(configPath, stateDir)
278 - if err != nil {
279 - return types.AgentStatusResponse{}, err
157 + if strings.TrimSpace(configPath) == "" {
158 + configPath = service.DefaultConfigPath()
159 }
281 - return agent.Status(ctx, resolvedStateDir)
160 + return agent.RunDashboard(configPath, resolvedStateDir)
161 }
162
163 func waitAgentStatus(ctx context.Context, stateDir string) (types.AgentStatusResponse, error) {
@@ -286,7 +165,7 @@ func waitAgentStatus(ctx context.Context, stateDir string) (types.AgentStatusRes
165 defer ticker.Stop()
166 var lastErr error
167 for {
289 - status, err := agentStatusFromFlags(ctx, "", stateDir)
168 + status, err := agent.Status(ctx, stateDir)
169 if err == nil {
170 return status, nil
171 }
@@ -299,6 +178,15 @@ func waitAgentStatus(ctx context.Context, stateDir string) (types.AgentStatusRes
178 }
179 }
180
181 +func agentCLIInteractive() bool {
182 + stdin, err := os.Stdin.Stat()
183 + if err != nil || stdin.Mode()&os.ModeCharDevice == 0 {
184 + return false
185 + }
186 + stdout, err := os.Stdout.Stat()
187 + return err == nil && stdout.Mode()&os.ModeCharDevice != 0
188 +}
189 +
190 func loadAgentCommandConfig(configPath, stateDir string) (agent.Config, string, error) {
191 if stateDir != "" && configPath == "" {
192 cfg := agent.Config{Agent: agent.AgentConfig{StateDir: stateDir, ServiceName: agent.DefaultServiceName}}
@@ -319,42 +207,18 @@ func loadAgentCommandConfig(configPath, stateDir string) (agent.Config, string,
207 return cfg, defaultStateDir, nil
208 }
209
322 -func printAgentStatus(w io.Writer, status types.AgentStatusResponse) {
323 - fmt.Fprintf(w, "Portal agent %s at %s\n", status.ReleaseVersion, status.ControlAddr)
324 - table := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
325 - fmt.Fprintln(table, "TUNNEL\tSTATE\tPUBLIC URLS\tLAST ERROR")
326 - for _, tunnel := range status.Tunnels {
327 - publicURLs := "-"
328 - if len(tunnel.PublicURLs) > 0 {
329 - publicURLs = strings.Join(tunnel.PublicURLs, ",")
330 - }
331 - lastError := tunnel.LastError
332 - if lastError == "" {
333 - lastError = "-"
334 - }
335 - fmt.Fprintf(table, "%s\t%s\t%s\t%s\n", tunnel.ID, tunnel.State, publicURLs, lastError)
336 - }
337 - _ = table.Flush()
338 -}
339 -
210 func printAgentUsage(w io.Writer) {
211 utils.WriteCommandUsage(w,
212 []string{
213 "portal agent run [flags]",
344 - "portal agent status [flags]",
214 + "portal agent dashboard [flags]",
215 "portal agent stop [flags]",
346 - "portal agent reload [flags]",
347 - "portal agent restart [flags] <tunnel-id>",
348 - "portal agent relay-add [flags] <tunnel-id> <relay-url>",
349 - "portal agent relay-remove [flags] <tunnel-id> <relay-url>",
216 },
217 []string{
218 "portal agent run",
219 "portal agent run --config config.toml --foreground",
354 - "portal agent status",
220 + "portal agent dashboard",
221 "portal agent stop",
356 - "portal agent reload",
357 - "portal agent relay-add web https://portal.example.com",
222 },
223 )
224 }
@@ -369,13 +233,12 @@ func printAgentRunUsage(w io.Writer) {
233 )
234 }
235
372 -func printAgentStatusUsage(w io.Writer) {
236 +func printAgentDashboardUsage(w io.Writer) {
237 utils.WriteCommandUsage(w,
374 - []string{"portal agent status [flags]"},
238 + []string{"portal agent dashboard [flags]"},
239 []string{
376 - "portal agent status",
377 - "portal agent status --json",
378 - "portal agent status --config config.toml",
240 + "portal agent dashboard",
241 + "portal agent dashboard --config config.toml",
242 },
243 )
244 }
cmd/portal-tunnel/agent/config.go
+27
@@ -3,6 +3,7 @@ package agent
3 import (
4 "errors"
5 "fmt"
6 + "os"
7 "path/filepath"
8 "strings"
9 "time"
@@ -20,6 +21,7 @@ const (
21 DefaultServiceName = "portal-agent"
22
23 defaultIdentityFilename = "identity.json"
24 + defaultTargetAddr = "127.0.0.1:3000"
25 )
26
27 type Config struct {
@@ -72,6 +74,31 @@ func LoadConfig(path string) (Config, error) {
74 if err != nil {
75 return Config{}, err
76 }
77 + configDir := filepath.Dir(absPath)
78 + if err := os.MkdirAll(configDir, 0o755); err != nil {
79 + return Config{}, fmt.Errorf("create agent config directory %q: %w", configDir, err)
80 + }
81 + if _, err := os.Stat(absPath); err != nil {
82 + if errors.Is(err, os.ErrNotExist) {
83 + data := fmt.Sprintf(`[agent]
84 +state_dir = %q
85 +control_addr = %q
86 +service_name = %q
87 +restart_delay = "5s"
88 +
89 +[[tunnels]]
90 +id = "default"
91 +name = "default"
92 +target = %q
93 +discovery = true
94 +`, service.DefaultDataDir(), DefaultControlAddr, DefaultServiceName, defaultTargetAddr)
95 + if err := os.WriteFile(absPath, []byte(data), 0o644); err != nil {
96 + return Config{}, fmt.Errorf("create default agent config %q: %w", absPath, err)
97 + }
98 + } else {
99 + return Config{}, err
100 + }
101 + }
102
103 k := koanf.New(".")
104 if err := k.Load(file.Provider(absPath), toml.Parser()); err != nil {
cmd/portal-tunnel/agent/control.go
+36 -5
@@ -80,7 +80,7 @@ func (s *controlHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
80 }
81
82 switch action {
83 - case strings.TrimPrefix(types.PathAgentRestartSegment, "/"):
83 + case "restart":
84 if !utils.RequireMethod(w, r, http.MethodPost) {
85 return
86 }
@@ -89,7 +89,7 @@ func (s *controlHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
89 return
90 }
91 utils.WriteAPIData(w, http.StatusAccepted, map[string]bool{"accepted": true})
92 - case strings.TrimPrefix(types.PathAgentRelaysSegment, "/"):
92 + case "relays":
93 switch r.Method {
94 case http.MethodPost:
95 case http.MethodDelete:
@@ -113,6 +113,27 @@ func (s *controlHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
113 return
114 }
115 utils.WriteAPIData(w, http.StatusAccepted, map[string]bool{"accepted": true})
116 + case "multi-hop":
117 + switch r.Method {
118 + case http.MethodPost:
119 + req, ok := utils.DecodeJSONRequest[types.AgentMultiHopRequest](w, r, controlRequestBodyLimit)
120 + if !ok {
121 + return
122 + }
123 + if err := s.manager.SetMultiHop(tunnelID, req.Relays); err != nil {
124 + utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, err.Error())
125 + return
126 + }
127 + case http.MethodDelete:
128 + if err := s.manager.SetMultiHop(tunnelID, nil); err != nil {
129 + utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, err.Error())
130 + return
131 + }
132 + default:
133 + utils.MethodNotAllowedError().Write(w)
134 + return
135 + }
136 + utils.WriteAPIData(w, http.StatusAccepted, map[string]bool{"accepted": true})
137 default:
138 utils.WriteAPIError(w, http.StatusNotFound, types.APIErrorCodeNotFound, "not found")
139 }
@@ -136,20 +157,30 @@ func Reload(ctx context.Context, stateDir string) error {
157 }
158
159 func RestartTunnel(ctx context.Context, stateDir, tunnelID string) error {
139 - path := types.PathAgentTunnelsPrefix + url.PathEscape(tunnelID) + types.PathAgentRestartSegment
160 + path := types.PathAgentTunnelsPrefix + url.PathEscape(tunnelID) + "/restart"
161 return controlRequest(ctx, stateDir, http.MethodPost, path, nil, nil)
162 }
163
164 func AddRelay(ctx context.Context, stateDir, tunnelID, relayURL string) error {
144 - path := types.PathAgentTunnelsPrefix + url.PathEscape(tunnelID) + types.PathAgentRelaysSegment
165 + path := types.PathAgentTunnelsPrefix + url.PathEscape(tunnelID) + "/relays"
166 return controlRequest(ctx, stateDir, http.MethodPost, path, types.AgentRelayRequest{RelayURL: relayURL}, nil)
167 }
168
169 func RemoveRelay(ctx context.Context, stateDir, tunnelID, relayURL string) error {
149 - path := types.PathAgentTunnelsPrefix + url.PathEscape(tunnelID) + types.PathAgentRelaysSegment
170 + path := types.PathAgentTunnelsPrefix + url.PathEscape(tunnelID) + "/relays"
171 return controlRequest(ctx, stateDir, http.MethodDelete, path, types.AgentRelayRequest{RelayURL: relayURL}, nil)
172 }
173
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)
177 +}
178 +
179 +func ClearMultiHop(ctx context.Context, stateDir, tunnelID string) error {
180 + path := types.PathAgentTunnelsPrefix + url.PathEscape(tunnelID) + "/multi-hop"
181 + return controlRequest(ctx, stateDir, http.MethodDelete, path, nil, nil)
182 +}
183 +
184 func controlRequest(ctx context.Context, stateDir, method, path string, payload any, out any) error {
185 stateDir = strings.TrimSpace(stateDir)
186 if stateDir == "" {
cmd/portal-tunnel/agent/dashboard.go new
+1115
@@ -0,0 +1,1115 @@
1 +package agent
2 +
3 +import (
4 + "context"
5 + "fmt"
6 + "slices"
7 + "strings"
8 + "time"
9 +
10 + "github.com/charmbracelet/bubbles/viewport"
11 + tea "github.com/charmbracelet/bubbletea"
12 + "github.com/charmbracelet/lipgloss"
13 +
14 + "github.com/gosuda/portal-tunnel/v2/types"
15 +)
16 +
17 +const agentDashboardRefreshInterval = 2 * time.Second
18 +
19 +type agentDashboardTab int
20 +
21 +const (
22 + agentDashboardRelaysTab agentDashboardTab = iota
23 + agentDashboardMultiHopTab
24 + agentDashboardLogsTab
25 +)
26 +
27 +type agentDashboardClick int
28 +
29 +const (
30 + agentDashboardClickRefresh agentDashboardClick = iota + 1
31 + agentDashboardClickReload
32 + agentDashboardClickRestart
33 + agentDashboardClickQuit
34 + agentDashboardClickTunnel
35 + agentDashboardClickTab
36 + agentDashboardClickRelay
37 + agentDashboardClickAttachRelay
38 + agentDashboardClickDetachRelay
39 + agentDashboardClickAddHop
40 + agentDashboardClickRemoveHop
41 + agentDashboardClickApplyHop
42 + agentDashboardClickClearHop
43 +)
44 +
45 +type agentDashboardModel struct {
46 + configPath string
47 + stateDir string
48 +
49 + status types.AgentStatusResponse
50 + err error
51 + message string
52 +
53 + width int
54 + height int
55 +
56 + selectedTunnel int
57 + selectedRelay int
58 + selectedTunnelID string
59 + selectedRelayURL string
60 + tab agentDashboardTab
61 +
62 + multiHopDraft []string
63 + draftTunnelID string
64 +
65 + logs viewport.Model
66 +}
67 +
68 +type agentDashboardStatusMsg struct {
69 + status types.AgentStatusResponse
70 + err error
71 +}
72 +
73 +type agentDashboardActionMsg struct {
74 + message string
75 + err error
76 +}
77 +
78 +type agentDashboardTickMsg time.Time
79 +
80 +type agentDashboardClickRegion struct {
81 + x0 int
82 + x1 int
83 + y int
84 + action agentDashboardClick
85 + tunnel int
86 + relay int
87 + tab agentDashboardTab
88 +}
89 +
90 +type agentDashboardLayout struct {
91 + lines []string
92 + regions []agentDashboardClickRegion
93 +}
94 +
95 +var (
96 + agentDashboardTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("39"))
97 + agentDashboardSectionStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("81"))
98 + agentDashboardMutedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
99 + agentDashboardSelectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("230")).Background(lipgloss.Color("25"))
100 + agentDashboardButtonStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("230")).Background(lipgloss.Color("238"))
101 + agentDashboardDisabledStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
102 + agentDashboardErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
103 + agentDashboardMessageStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214"))
104 + agentDashboardOKStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("120"))
105 + agentDashboardHelpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244"))
106 + 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"))
109 +)
110 +
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 +
122 +func RunDashboard(configPath, stateDir string) error {
123 + logs := viewport.New(0, 0)
124 + logs.MouseWheelEnabled = true
125 + logs.MouseWheelDelta = 3
126 +
127 + _, err := tea.NewProgram(agentDashboardModel{
128 + configPath: configPath,
129 + stateDir: stateDir,
130 + tab: agentDashboardRelaysTab,
131 + logs: logs,
132 + }, tea.WithAltScreen(), tea.WithMouseCellMotion()).Run()
133 + return err
134 +}
135 +
136 +func (m agentDashboardModel) Init() tea.Cmd {
137 + return tea.Batch(agentDashboardFetchStatus(m.stateDir), agentDashboardTick())
138 +}
139 +
140 +func (m agentDashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
141 + switch msg := msg.(type) {
142 + case tea.WindowSizeMsg:
143 + m.width = msg.Width
144 + m.height = msg.Height
145 + m.syncLogViewport()
146 + return m, nil
147 + case agentDashboardTickMsg:
148 + return m, tea.Batch(agentDashboardFetchStatus(m.stateDir), agentDashboardTick())
149 + case agentDashboardStatusMsg:
150 + m.err = msg.err
151 + if msg.err == nil {
152 + m.status = msg.status
153 + m.message = ""
154 + m.clampSelection()
155 + }
156 + m.syncLogViewport()
157 + return m, nil
158 + case agentDashboardActionMsg:
159 + m.err = msg.err
160 + m.message = msg.message
161 + if msg.err != nil {
162 + 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 = ""
166 + }
167 + return m, agentDashboardFetchStatus(m.stateDir)
168 + case tea.KeyMsg:
169 + return m.updateKeys(msg)
170 + case tea.MouseMsg:
171 + return m.updateMouse(msg)
172 + default:
173 + return m, nil
174 + }
175 +}
176 +
177 +func (m agentDashboardModel) updateKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
178 + switch msg.String() {
179 + case "ctrl+c", "q":
180 + return m, tea.Quit
181 + case "enter":
182 + m.message = "refreshing..."
183 + return m, agentDashboardFetchStatus(m.stateDir)
184 + 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 + }
191 + 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 = ""
197 + }
198 + case "left", "h", "shift+tab":
199 + m.prevTab()
200 + case "right", "l", "tab":
201 + m.nextTab()
202 + }
203 + return m, nil
204 +}
205 +
206 +func (m agentDashboardModel) updateMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
207 + event := tea.MouseEvent(msg)
208 + 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 + }
227 + return m, nil
228 + }
229 + if event.Action != tea.MouseActionPress || event.Button != tea.MouseButtonLeft {
230 + return m, nil
231 + }
232 + for _, region := range m.layout().regions {
233 + if event.Y == region.y && event.X >= region.x0 && event.X < region.x1 {
234 + return m.applyClick(region)
235 + }
236 + }
237 + return m, nil
238 +}
239 +
240 +func (m agentDashboardModel) applyClick(region agentDashboardClickRegion) (tea.Model, tea.Cmd) {
241 + 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
252 + case agentDashboardClickTunnel:
253 + 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 = ""
258 + }
259 + case agentDashboardClickTab:
260 + m.tab = region.tab
261 + m.syncLogViewport()
262 + case agentDashboardClickRelay:
263 + 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
266 + }
267 + case agentDashboardClickAttachRelay:
268 + return m.attachSelectedRelay()
269 + case agentDashboardClickDetachRelay:
270 + return m.detachSelectedRelay()
271 + case agentDashboardClickAddHop:
272 + return m.addSelectedHop()
273 + case agentDashboardClickRemoveHop:
274 + return m.removeSelectedHop()
275 + case agentDashboardClickApplyHop:
276 + return m.applyMultiHop()
277 + case agentDashboardClickClearHop:
278 + return m.clearMultiHop()
279 + }
280 + return m, nil
281 +}
282 +
283 +func (m agentDashboardModel) View() string {
284 + layout := m.layout()
285 + return strings.Join(layout.lines, "\n") + "\n"
286 +}
287 +
288 +func (m *agentDashboardModel) clampSelection() {
289 + if len(m.status.Tunnels) == 0 {
290 + m.selectedTunnel = 0
291 + m.selectedRelay = 0
292 + m.selectedTunnelID = ""
293 + m.selectedRelayURL = ""
294 + return
295 + }
296 + if m.selectedTunnelID != "" {
297 + for i, tunnel := range m.status.Tunnels {
298 + if tunnel.ID == m.selectedTunnelID {
299 + m.selectedTunnel = i
300 + break
301 + }
302 + }
303 + }
304 + if m.selectedTunnel < 0 {
305 + m.selectedTunnel = 0
306 + }
307 + if m.selectedTunnel >= len(m.status.Tunnels) {
308 + m.selectedTunnel = len(m.status.Tunnels) - 1
309 + }
310 + m.selectedTunnelID = m.status.Tunnels[m.selectedTunnel].ID
311 +
312 + relays := m.status.Tunnels[m.selectedTunnel].Relays
313 + if len(relays) == 0 {
314 + m.selectedRelay = 0
315 + m.selectedRelayURL = ""
316 + return
317 + }
318 + if m.selectedRelayURL != "" {
319 + for i, relay := range relays {
320 + if relay.RelayURL == m.selectedRelayURL {
321 + m.selectedRelay = i
322 + break
323 + }
324 + }
325 + }
326 + if m.selectedRelay < 0 {
327 + m.selectedRelay = 0
328 + }
329 + if m.selectedRelay >= len(relays) {
330 + m.selectedRelay = len(relays) - 1
331 + }
332 + m.selectedRelayURL = relays[m.selectedRelay].RelayURL
333 +}
334 +
335 +func (m agentDashboardModel) selectedTunnelStatus() (types.AgentTunnelStatus, bool) {
336 + if m.selectedTunnel < 0 || m.selectedTunnel >= len(m.status.Tunnels) {
337 + return types.AgentTunnelStatus{}, false
338 + }
339 + return m.status.Tunnels[m.selectedTunnel], true
340 +}
341 +
342 +func (m agentDashboardModel) selectedRelayStatus() (types.AgentRelayStatus, bool) {
343 + tunnel, ok := m.selectedTunnelStatus()
344 + if !ok || m.selectedRelay < 0 || m.selectedRelay >= len(tunnel.Relays) {
345 + return types.AgentRelayStatus{}, false
346 + }
347 + return tunnel.Relays[m.selectedRelay], true
348 +}
349 +
350 +func (m *agentDashboardModel) prevTab() {
351 + if m.tab == agentDashboardRelaysTab {
352 + m.tab = agentDashboardLogsTab
353 + } else {
354 + m.tab--
355 + }
356 + m.syncLogViewport()
357 +}
358 +
359 +func (m *agentDashboardModel) nextTab() {
360 + if m.tab == agentDashboardLogsTab {
361 + m.tab = agentDashboardRelaysTab
362 + } else {
363 + m.tab++
364 + }
365 + m.syncLogViewport()
366 +}
367 +
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()
377 + }
378 +}
379 +
380 +func (m agentDashboardModel) restartSelectedTunnel() (tea.Model, tea.Cmd) {
381 + tunnel, ok := m.selectedTunnelStatus()
382 + if !ok {
383 + m.message = "no tunnel selected"
384 + return m, nil
385 + }
386 + m.message = "restarting " + tunnel.ID + "..."
387 + return m, agentDashboardRestart(m.stateDir, tunnel.ID)
388 +}
389 +
390 +func (m agentDashboardModel) attachSelectedRelay() (tea.Model, tea.Cmd) {
391 + tunnel, relay, ok := m.selectedTunnelRelay()
392 + if !ok {
393 + m.message = "select a relay first"
394 + return m, nil
395 + }
396 + if relayDashboardAttached(relay) {
397 + m.message = "relay is already attached"
398 + return m, nil
399 + }
400 + m.message = "attaching relay..."
401 + return m, agentDashboardAddRelay(m.stateDir, tunnel.ID, relay.RelayURL)
402 +}
403 +
404 +func (m agentDashboardModel) detachSelectedRelay() (tea.Model, tea.Cmd) {
405 + tunnel, relay, ok := m.selectedTunnelRelay()
406 + if !ok {
407 + m.message = "select a relay first"
408 + return m, nil
409 + }
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)
416 +}
417 +
418 +func (m agentDashboardModel) addSelectedHop() (tea.Model, tea.Cmd) {
419 + tunnel, relay, ok := m.selectedTunnelRelay()
420 + if !ok {
421 + m.message = "select a relay first"
422 + return m, nil
423 + }
424 + if !relay.SupportsOverlay {
425 + m.message = "selected relay does not support multi-hop"
426 + return m, nil
427 + }
428 + m.ensureMultiHopDraft(tunnel)
429 + if slices.Contains(m.multiHopDraft, relay.RelayURL) {
430 + m.message = "relay is already in the route"
431 + return m, nil
432 + }
433 + m.multiHopDraft = append(m.multiHopDraft, relay.RelayURL)
434 + m.message = "route draft updated"
435 + return m, nil
436 +}
437 +
438 +func (m agentDashboardModel) removeSelectedHop() (tea.Model, tea.Cmd) {
439 + tunnel, relay, ok := m.selectedTunnelRelay()
440 + if !ok {
441 + m.message = "select a relay first"
442 + return m, nil
443 + }
444 + m.ensureMultiHopDraft(tunnel)
445 +
446 + next := m.multiHopDraft[:0]
447 + for _, relayURL := range m.multiHopDraft {
448 + if relayURL != relay.RelayURL {
449 + next = append(next, relayURL)
450 + }
451 + }
452 + if len(next) == len(m.multiHopDraft) {
453 + m.message = "selected relay is not in the route"
454 + return m, nil
455 + }
456 + m.multiHopDraft = next
457 + m.message = "route draft updated"
458 + return m, nil
459 +}
460 +
461 +func (m agentDashboardModel) applyMultiHop() (tea.Model, tea.Cmd) {
462 + tunnel, ok := m.selectedTunnelStatus()
463 + if !ok {
464 + m.message = "no tunnel selected"
465 + return m, nil
466 + }
467 + route := m.displayedMultiHop(tunnel)
468 + if len(route) < 2 {
469 + m.message = "multi-hop requires at least two relays"
470 + return m, nil
471 + }
472 + m.message = "applying route..."
473 + return m, agentDashboardSetMultiHop(m.stateDir, tunnel.ID, route)
474 +}
475 +
476 +func (m agentDashboardModel) clearMultiHop() (tea.Model, tea.Cmd) {
477 + tunnel, ok := m.selectedTunnelStatus()
478 + if !ok {
479 + m.message = "no tunnel selected"
480 + return m, nil
481 + }
482 + 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
496 +}
497 +
498 +func (m *agentDashboardModel) ensureMultiHopDraft(tunnel types.AgentTunnelStatus) {
499 + if m.draftTunnelID == tunnel.ID {
500 + return
501 + }
502 + m.draftTunnelID = tunnel.ID
503 + m.multiHopDraft = append([]string(nil), tunnel.MultiHop...)
504 +}
505 +
506 +func (m agentDashboardModel) displayedMultiHop(tunnel types.AgentTunnelStatus) []string {
507 + if m.draftTunnelID == tunnel.ID {
508 + return append([]string(nil), m.multiHopDraft...)
509 + }
510 + return append([]string(nil), tunnel.MultiHop...)
511 +}
512 +
513 +func agentDashboardFetchStatus(stateDir string) tea.Cmd {
514 + return func() tea.Msg {
515 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
516 + defer cancel()
517 +
518 + status, err := Status(ctx, stateDir)
519 + return agentDashboardStatusMsg{status: status, err: err}
520 + }
521 +}
522 +
523 +func agentDashboardTick() tea.Cmd {
524 + return tea.Tick(agentDashboardRefreshInterval, func(t time.Time) tea.Msg {
525 + return agentDashboardTickMsg(t)
526 + })
527 +}
528 +
529 +func agentDashboardReload(stateDir string) tea.Cmd {
530 + return func() tea.Msg {
531 + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
532 + defer cancel()
533 +
534 + err := Reload(ctx, stateDir)
535 + return agentDashboardActionMsg{message: "reload accepted", err: err}
536 + }
537 +}
538 +
539 +func agentDashboardRestart(stateDir, tunnelID string) tea.Cmd {
540 + return func() tea.Msg {
541 + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
542 + defer cancel()
543 +
544 + err := RestartTunnel(ctx, stateDir, tunnelID)
545 + return agentDashboardActionMsg{message: "restart accepted", err: err}
546 + }
547 +}
548 +
549 +func agentDashboardAddRelay(stateDir, tunnelID, relayURL string) tea.Cmd {
550 + return func() tea.Msg {
551 + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
552 + defer cancel()
553 +
554 + err := AddRelay(ctx, stateDir, tunnelID, relayURL)
555 + return agentDashboardActionMsg{message: "relay attach accepted", err: err}
556 + }
557 +}
558 +
559 +func agentDashboardRemoveRelay(stateDir, tunnelID, relayURL string) tea.Cmd {
560 + return func() tea.Msg {
561 + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
562 + defer cancel()
563 +
564 + err := RemoveRelay(ctx, stateDir, tunnelID, relayURL)
565 + return agentDashboardActionMsg{message: "relay detach accepted", err: err}
566 + }
567 +}
568 +
569 +func agentDashboardSetMultiHop(stateDir, tunnelID string, relayURLs []string) tea.Cmd {
570 + return func() tea.Msg {
571 + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
572 + defer cancel()
573 +
574 + err := SetMultiHop(ctx, stateDir, tunnelID, relayURLs)
575 + return agentDashboardActionMsg{message: "multi-hop applied", err: err}
576 + }
577 +}
578 +
579 +func agentDashboardClearMultiHop(stateDir, tunnelID string) tea.Cmd {
580 + return func() tea.Msg {
581 + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
582 + defer cancel()
583 +
584 + err := ClearMultiHop(ctx, stateDir, tunnelID)
585 + return agentDashboardActionMsg{message: "multi-hop cleared", err: err}
586 + }
587 +}
588 +
589 +func (m agentDashboardModel) layout() agentDashboardLayout {
590 + width := max(m.width, 88)
591 + leftWidth, rightWidth, bodyHeight := agentDashboardPaneSizes(width, m.height)
592 +
593 + var layout agentDashboardLayout
594 + layout.addLine(agentDashboardTitleStyle.Render("Portal Agent") + " " + agentDashboardMutedStyle.Render(agentDashboardSummaryLine(m.status)))
595 + layout.addLine(agentDashboardMutedStyle.Render(strings.Repeat("-", min(width, 120))))
596 +
597 + 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))
602 + layout.addLine("")
603 + layout.addButtons(
604 + agentDashboardButton{label: "Refresh", action: agentDashboardClickRefresh},
605 + agentDashboardButton{label: "Quit", action: agentDashboardClickQuit},
606 + )
607 + return layout
608 + }
609 +
610 + layout.addLine(fmt.Sprintf("Control: %s Tunnels: %d Running: %d Errors: %d Uptime: %s",
611 + 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 + ))
617 + if m.message != "" {
618 + layout.addLine(agentDashboardMessageStyle.Render("Message: " + m.message))
619 + }
620 + if m.err != nil {
621 + layout.addLine(agentDashboardErrorStyle.Render(fmt.Sprintf("Error: %v", m.err)))
622 + }
623 + layout.addButtons(
624 + agentDashboardButton{label: "Refresh", action: agentDashboardClickRefresh},
625 + agentDashboardButton{label: "Reload Config", action: agentDashboardClickReload},
626 + agentDashboardButton{label: "Quit", action: agentDashboardClickQuit},
627 + )
628 + layout.addLine("")
629 +
630 + left := m.renderTunnelsPane(leftWidth, bodyHeight)
631 + right := m.renderTunnelPane(rightWidth, bodyHeight)
632 + 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."))
635 + return layout
636 +}
637 +
638 +func (m agentDashboardModel) renderTunnelsPane(width, height int) agentDashboardPane {
639 + var pane agentDashboardPane
640 + pane.addLine(agentDashboardSectionStyle.Render(agentDashboardFit("Tunnels", width)))
641 + pane.addLine(agentDashboardMutedStyle.Render(agentDashboardFit(fmt.Sprintf("%d managed", len(m.status.Tunnels)), width)))
642 + pane.addLine(agentDashboardMutedStyle.Render(strings.Repeat("-", width)))
643 +
644 + if len(m.status.Tunnels) == 0 {
645 + pane.addLine(agentDashboardMutedStyle.Render(agentDashboardFit("no managed tunnels", width)))
646 + return pane
647 + }
648 +
649 + for i, tunnel := range m.status.Tunnels {
650 + if len(pane.lines) >= height {
651 + pane.addLine(agentDashboardMutedStyle.Render(agentDashboardFit(fmt.Sprintf("+ %d more", len(m.status.Tunnels)-i), width)))
652 + break
653 + }
654 + name := tunnel.ID
655 + if strings.TrimSpace(tunnel.Name) != "" {
656 + name = tunnel.Name
657 + }
658 + line := fmt.Sprintf("%-10s %-16s %s",
659 + truncateDashboardValue(tunnel.State, 10),
660 + truncateDashboardValue(name, 16),
661 + firstOrDash(tunnel.PublicURLs),
662 + )
663 + pane.addClickRow(line, width, agentDashboardTunnelStyle(i == m.selectedTunnel, tunnel.State), agentDashboardClickTunnel, i, -1)
664 + }
665 + return pane
666 +}
667 +
668 +func (m agentDashboardModel) renderTunnelPane(width, height int) agentDashboardPane {
669 + var pane agentDashboardPane
670 + tunnel, ok := m.selectedTunnelStatus()
671 + if !ok {
672 + pane.addLine(agentDashboardSectionStyle.Render(agentDashboardFit("Tunnel", width)))
673 + pane.addLine(agentDashboardMutedStyle.Render(agentDashboardFit("select a managed tunnel", width)))
674 + return pane
675 + }
676 +
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 + }
703 + return pane
704 +}
705 +
706 +func (m agentDashboardModel) renderRelaysTab(pane *agentDashboardPane, width, height int, tunnel types.AgentTunnelStatus) {
707 + 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},
713 + )
714 + if hasRelay {
715 + pane.addLine(agentDashboardMutedStyle.Render(agentDashboardFit("Selected: "+relay.RelayURL+" Public: "+valueOrDash(relay.PublicURL), width)))
716 + }
717 + pane.addLine("")
718 + pane.addLine(agentDashboardMutedStyle.Render(agentDashboardFit(fmt.Sprintf("%-9s %-9s %-9s %-7s %s", "STATE", "ROLE", "CAPS", "RTT", "RELAY"), width)))
719 +
720 + if len(tunnel.Relays) == 0 {
721 + pane.addLine(agentDashboardMutedStyle.Render(agentDashboardFit("no discovered relays", width)))
722 + return
723 + }
724 + for i, relay := range tunnel.Relays {
725 + if len(pane.lines) >= height {
726 + pane.addLine(agentDashboardMutedStyle.Render(agentDashboardFit(fmt.Sprintf("+ %d more", len(tunnel.Relays)-i), width)))
727 + break
728 + }
729 + line := fmt.Sprintf("%-9s %-9s %-9s %-7s %s",
730 + relayDashboardState(relay),
731 + relayDashboardRole(relay),
732 + relayDashboardCaps(relay),
733 + relayDashboardRTT(relay),
734 + relay.RelayURL,
735 + )
736 + pane.addClickRow(line, width, agentDashboardRelayStyle(i == m.selectedRelay, relay), agentDashboardClickRelay, m.selectedTunnel, i)
737 + }
738 +}
739 +
740 +func (m agentDashboardModel) renderMultiHopTab(pane *agentDashboardPane, width, height int, tunnel types.AgentTunnelStatus) {
741 + route := m.displayedMultiHop(tunnel)
742 + relay, hasRelay := m.selectedRelayStatus()
743 + inRoute := hasRelay && slices.Contains(route, relay.RelayURL)
744 + canAdd := hasRelay && relay.SupportsOverlay && !inRoute
745 +
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},
751 + )
752 + if hasRelay {
753 + pane.addLine(agentDashboardMutedStyle.Render(agentDashboardFit("Selected: "+relay.RelayURL+" Public: "+valueOrDash(relay.PublicURL), width)))
754 + }
755 + pane.addLine("")
756 +
757 + routeLabel := "Route: none"
758 + if len(route) > 0 {
759 + routeLabel = "Route: " + strings.Join(route, " -> ")
760 + if m.draftTunnelID == tunnel.ID {
761 + routeLabel += " (draft)"
762 + }
763 + }
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") {
798 + if len(pane.lines) >= height {
799 + break
800 + }
801 + pane.addLine(agentDashboardFit(line, width))
802 + }
803 +}
804 +
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 +
828 +func (l *agentDashboardLayout) addLine(line string) {
829 + l.lines = append(l.lines, line)
830 +}
831 +
832 +func (l *agentDashboardLayout) addButtons(buttons ...agentDashboardButton) {
833 + line, regions := agentDashboardRenderButtons(len(l.lines), 0, buttons...)
834 + l.lines = append(l.lines, line)
835 + l.regions = append(l.regions, regions...)
836 +}
837 +
838 +func (l *agentDashboardLayout) addPanes(left, right agentDashboardPane, leftWidth, gutter int) {
839 + startY := len(l.lines)
840 + height := max(len(left.lines), len(right.lines))
841 + for i := 0; i < height; i++ {
842 + leftLine := ""
843 + if i < len(left.lines) {
844 + leftLine = left.lines[i]
845 + }
846 + rightLine := ""
847 + if i < len(right.lines) {
848 + rightLine = right.lines[i]
849 + }
850 + l.lines = append(l.lines, agentDashboardPadStyled(leftLine, leftWidth)+strings.Repeat(" ", gutter)+rightLine)
851 + }
852 + for _, region := range left.regions {
853 + region.y += startY
854 + l.regions = append(l.regions, region)
855 + }
856 + for _, region := range right.regions {
857 + region.y += startY
858 + region.x0 += leftWidth + gutter
859 + region.x1 += leftWidth + gutter
860 + l.regions = append(l.regions, region)
861 + }
862 +}
863 +
864 +func (p *agentDashboardPane) addLine(line string) {
865 + p.lines = append(p.lines, line)
866 +}
867 +
868 +func (p *agentDashboardPane) addButtons(buttons ...agentDashboardButton) {
869 + line, regions := agentDashboardRenderButtons(len(p.lines), 0, buttons...)
870 + p.lines = append(p.lines, line)
871 + p.regions = append(p.regions, regions...)
872 +}
873 +
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 +
910 +func (p *agentDashboardPane) addClickRow(line string, width int, style lipgloss.Style, action agentDashboardClick, tunnel, relay int) {
911 + plain := agentDashboardFit(line, width)
912 + y := len(p.lines)
913 + p.lines = append(p.lines, style.Width(width).Render(plain))
914 + p.regions = append(p.regions, agentDashboardClickRegion{
915 + x0: 0,
916 + x1: width,
917 + y: y,
918 + action: action,
919 + tunnel: tunnel,
920 + relay: relay,
921 + })
922 +}
923 +
924 +func agentDashboardRenderButtons(y, x int, buttons ...agentDashboardButton) (string, []agentDashboardClickRegion) {
925 + var line strings.Builder
926 + var regions []agentDashboardClickRegion
927 + for i, button := range buttons {
928 + if i > 0 {
929 + line.WriteString(" ")
930 + x++
931 + }
932 + plain := "[ " + button.label + " ]"
933 + style := agentDashboardButtonStyle
934 + if button.disabled {
935 + style = agentDashboardDisabledStyle
936 + } else {
937 + regions = append(regions, agentDashboardClickRegion{
938 + x0: x,
939 + x1: x + lipgloss.Width(plain),
940 + y: y,
941 + action: button.action,
942 + })
943 + }
944 + line.WriteString(style.Render(plain))
945 + x += lipgloss.Width(plain)
946 + }
947 + return line.String(), regions
948 +}
949 +
950 +func agentDashboardPaneSizes(width, height int) (int, int, int) {
951 + if width <= 0 {
952 + width = 104
953 + }
954 + width = max(width, 88)
955 +
956 + leftWidth := width / 3
957 + leftWidth = min(max(leftWidth, 30), 42)
958 + rightWidth := max(42, width-leftWidth-2)
959 +
960 + bodyHeight := height - 8
961 + if height <= 0 {
962 + bodyHeight = 22
963 + }
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
973 +}
974 +
975 +func agentDashboardTunnelStyle(selected bool, state string) lipgloss.Style {
976 + if selected {
977 + return agentDashboardSelectedStyle
978 + }
979 + switch strings.ToLower(strings.TrimSpace(state)) {
980 + case "running":
981 + return agentDashboardOKStyle
982 + case "error":
983 + return agentDashboardErrorStyle
984 + case "starting", "restarting":
985 + return agentDashboardMessageStyle
986 + default:
987 + return lipgloss.NewStyle()
988 + }
989 +}
990 +
991 +func agentDashboardRelayStyle(selected bool, relay types.AgentRelayStatus) lipgloss.Style {
992 + if selected {
993 + return agentDashboardSelectedStyle
994 + }
995 + if relay.Banned {
996 + return agentDashboardErrorStyle
997 + }
998 + if relay.Connected || relay.Active {
999 + return agentDashboardOKStyle
1000 + }
1001 + return agentDashboardMutedStyle
1002 +}
1003 +
1004 +func relayDashboardState(relay types.AgentRelayStatus) string {
1005 + switch {
1006 + case relay.Banned:
1007 + return "removed"
1008 + case relay.Connected:
1009 + return "up"
1010 + case relay.Active:
1011 + return "active"
1012 + case relay.Confirmed:
1013 + return "known"
1014 + case relay.Bootstrap:
1015 + return "seed"
1016 + default:
1017 + return "seen"
1018 + }
1019 +}
1020 +
1021 +func relayDashboardRole(relay types.AgentRelayStatus) string {
1022 + switch {
1023 + case relay.Banned:
1024 + return "blocked"
1025 + case relay.Active || relay.Connected:
1026 + return "attached"
1027 + case relay.Bootstrap:
1028 + return "bootstrap"
1029 + case relay.Confirmed:
1030 + return "known"
1031 + default:
1032 + return "discovered"
1033 + }
1034 +}
1035 +
1036 +func relayDashboardAttached(relay types.AgentRelayStatus) bool {
1037 + return relay.Active || relay.Connected || relay.Bootstrap
1038 +}
1039 +
1040 +func relayDashboardCaps(relay types.AgentRelayStatus) string {
1041 + var caps []string
1042 + if relay.SupportsOverlay {
1043 + caps = append(caps, "hop")
1044 + }
1045 + if relay.SupportsUDP {
1046 + caps = append(caps, "udp")
1047 + }
1048 + if relay.SupportsTCP {
1049 + caps = append(caps, "tcp")
1050 + }
1051 + if len(caps) == 0 {
1052 + return "-"
1053 + }
1054 + return strings.Join(caps, "/")
1055 +}
1056 +
1057 +func relayDashboardRTT(relay types.AgentRelayStatus) string {
1058 + if relay.DiscoveryRTTMillis <= 0 {
1059 + return "-"
1060 + }
1061 + return fmt.Sprintf("%dms", relay.DiscoveryRTTMillis)
1062 +}
1063 +
1064 +func durationSince(t time.Time) string {
1065 + if t.IsZero() {
1066 + return "-"
1067 + }
1068 + return time.Since(t).Round(time.Second).String()
1069 +}
1070 +
1071 +func firstOrDash(values []string) string {
1072 + if len(values) == 0 {
1073 + return "-"
1074 + }
1075 + return valueOrDash(values[0])
1076 +}
1077 +
1078 +func valueOrDash(value string) string {
1079 + value = strings.TrimSpace(value)
1080 + if value == "" {
1081 + return "-"
1082 + }
1083 + return value
1084 +}
1085 +
1086 +func truncateDashboardValue(value string, maxLength int) string {
1087 + value = strings.TrimSpace(value)
1088 + if value == "" {
1089 + return "-"
1090 + }
1091 + return agentDashboardFit(value, maxLength)
1092 +}
1093 +
1094 +func agentDashboardFit(value string, width int) string {
1095 + value = strings.TrimSpace(value)
1096 + if value == "" || width <= 0 {
1097 + return ""
1098 + }
1099 +
1100 + runes := []rune(value)
1101 + if len(runes) <= width {
1102 + return value
1103 + }
1104 + if width == 1 {
1105 + return "~"
1106 + }
1107 + return string(runes[:width-1]) + "~"
1108 +}
1109 +
1110 +func agentDashboardPadStyled(value string, width int) string {
1111 + if lipgloss.Width(value) >= width {
1112 + return value
1113 + }
1114 + return value + strings.Repeat(" ", width-lipgloss.Width(value))
1115 +}
cmd/portal-tunnel/agent/manager.go
+33 -11
@@ -137,6 +137,17 @@ func (m *manager) RemoveRelay(id, relayURL string) error {
137 return tunnel.RemoveRelay(relayURL)
138 }
139
140 +func (m *manager) SetMultiHop(id string, relayURLs []string) error {
141 + id = strings.TrimSpace(id)
142 + m.mu.RLock()
143 + tunnel := m.tunnels[id]
144 + m.mu.RUnlock()
145 + if tunnel == nil {
146 + return fmt.Errorf("unknown tunnel %q", id)
147 + }
148 + return tunnel.SetMultiHop(relayURLs)
149 +}
150 +
151 func (m *manager) Reload(cfg Config) error {
152 m.mu.Lock()
153 rootCtx := m.rootCtx
@@ -350,6 +361,25 @@ func (t *managedTunnel) RemoveRelay(relayURL string) error {
361 return nil
362 }
363
364 +func (t *managedTunnel) SetMultiHop(relayURLs []string) error {
365 + t.mu.RLock()
366 + id := t.cfg.ID
367 + exposure := t.exposure
368 + t.mu.RUnlock()
369 + if exposure == nil {
370 + return fmt.Errorf("tunnel %q is not running", id)
371 + }
372 + if err := exposure.SetMultiHop(relayURLs); err != nil {
373 + return err
374 + }
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})
380 + return nil
381 +}
382 +
383 func (t *managedTunnel) Snapshot() types.AgentTunnelStatus {
384 t.mu.RLock()
385 cfg := t.cfg
@@ -378,17 +408,9 @@ func (t *managedTunnel) Snapshot() types.AgentTunnelStatus {
408 snapshot := exposure.Snapshot()
409 status.TargetAddr = snapshot.TargetAddr
410 status.UDPAddr = snapshot.UDPAddr
381 - for _, relay := range snapshot.Relays {
382 - status.Relays = append(status.Relays, types.AgentRelayStatus{
383 - RelayURL: relay.RelayURL,
384 - Hostname: relay.Hostname,
385 - PublicURL: relay.PublicURL,
386 - UDPAddr: relay.UDPAddr,
387 - TCPAddr: relay.TCPAddr,
388 - ExpiresAt: relay.ExpiresAt,
389 - MultiHop: append([]string(nil), relay.MultiHop...),
390 - Connected: relay.Connected,
391 - })
411 + status.MultiHop = append([]string(nil), snapshot.MultiHop...)
412 + status.Relays = append([]types.AgentRelayStatus(nil), snapshot.Relays...)
413 + for _, relay := range status.Relays {
414 if relay.PublicURL != "" {
415 status.PublicURLs = append(status.PublicURLs, relay.PublicURL)
416 }
cmd/portal-tunnel/agent/service/service.go
+40 -4
@@ -23,9 +23,27 @@ func DefaultConfigPath() string {
23 case "windows":
24 return filepath.Join(windowsProgramDataDir(), "Portal Tunnel", "Agent", defaultConfigFilename)
25 case "darwin":
26 - return filepath.Join(string(filepath.Separator), "Library", "Application Support", "Portal Tunnel", "Agent", defaultConfigFilename)
26 + if os.Geteuid() == 0 {
27 + return filepath.Join(string(filepath.Separator), "Library", "Application Support", "Portal Tunnel", "Agent", defaultConfigFilename)
28 + }
29 + home, err := os.UserHomeDir()
30 + if err != nil {
31 + return filepath.Join(".", "Library", "Application Support", "Portal Tunnel", "Agent", defaultConfigFilename)
32 + }
33 + return filepath.Join(home, "Library", "Application Support", "Portal Tunnel", "Agent", defaultConfigFilename)
34 default:
28 - return filepath.Join(string(filepath.Separator), "etc", "portal-tunnel", "agent", defaultConfigFilename)
35 + if os.Geteuid() == 0 {
36 + return filepath.Join(string(filepath.Separator), "etc", "portal-tunnel", "agent", defaultConfigFilename)
37 + }
38 + configHome := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME"))
39 + if configHome == "" {
40 + home, err := os.UserHomeDir()
41 + if err != nil {
42 + return filepath.Join(".", ".config", "portal-tunnel", "agent", defaultConfigFilename)
43 + }
44 + configHome = filepath.Join(home, ".config")
45 + }
46 + return filepath.Join(configHome, "portal-tunnel", "agent", defaultConfigFilename)
47 }
48 }
49
@@ -34,9 +52,27 @@ func DefaultDataDir() string {
52 case "windows":
53 return filepath.Join(windowsProgramDataDir(), "Portal Tunnel", "Agent")
54 case "darwin":
37 - return filepath.Join(string(filepath.Separator), "Library", "Application Support", "Portal Tunnel", "Agent")
55 + if os.Geteuid() == 0 {
56 + return filepath.Join(string(filepath.Separator), "Library", "Application Support", "Portal Tunnel", "Agent")
57 + }
58 + home, err := os.UserHomeDir()
59 + if err != nil {
60 + return filepath.Join(".", "Library", "Application Support", "Portal Tunnel", "Agent")
61 + }
62 + return filepath.Join(home, "Library", "Application Support", "Portal Tunnel", "Agent")
63 default:
39 - return filepath.Join(string(filepath.Separator), "var", "lib", "portal-tunnel", "agent")
64 + if os.Geteuid() == 0 {
65 + return filepath.Join(string(filepath.Separator), "var", "lib", "portal-tunnel", "agent")
66 + }
67 + dataHome := strings.TrimSpace(os.Getenv("XDG_DATA_HOME"))
68 + if dataHome == "" {
69 + home, err := os.UserHomeDir()
70 + if err != nil {
71 + return filepath.Join(".", ".local", "share", "portal-tunnel", "agent")
72 + }
73 + dataHome = filepath.Join(home, ".local", "share")
74 + }
75 + return filepath.Join(dataHome, "portal-tunnel", "agent")
76 }
77 }
78
cmd/portal-tunnel/agent/service/service_linux.go
+20 -8
@@ -22,12 +22,10 @@ func Install(ctx context.Context, def Definition) error {
22 if err := os.WriteFile(unitPath, []byte(systemdUnit(def)), 0o644); err != nil {
23 return err
24 }
25 - args := systemctlArgs(userMode, "daemon-reload")
26 - if err := exec.CommandContext(ctx, "systemctl", args...).Run(); err != nil {
25 + if err := runSystemctl(ctx, userMode, "daemon-reload"); err != nil {
26 return err
27 }
29 - args = systemctlArgs(userMode, "enable", def.Name+".service")
30 - return exec.CommandContext(ctx, "systemctl", args...).Run()
28 + return runSystemctl(ctx, userMode, "enable", def.Name+".service")
29 }
30
31 func Start(ctx context.Context, name string) error {
@@ -35,8 +33,7 @@ func Start(ctx context.Context, name string) error {
33 if err != nil {
34 return err
35 }
38 - args := systemctlArgs(userMode, "start", name+".service")
39 - return exec.CommandContext(ctx, "systemctl", args...).Run()
36 + return runSystemctl(ctx, userMode, "start", name+".service")
37 }
38
39 func StopDisable(ctx context.Context, name string) error {
@@ -44,8 +41,7 @@ func StopDisable(ctx context.Context, name string) error {
41 if err != nil {
42 return err
43 }
47 - args := systemctlArgs(userMode, "disable", "--now", name+".service")
48 - return exec.CommandContext(ctx, "systemctl", args...).Run()
44 + return runSystemctl(ctx, userMode, "disable", "--now", name+".service")
45 }
46
47 func Run(ctx context.Context, name string, run func(context.Context) error) error {
@@ -70,6 +66,22 @@ func systemctlArgs(userMode bool, args ...string) []string {
66 return args
67 }
68
69 +func runSystemctl(ctx context.Context, userMode bool, args ...string) error {
70 + commandArgs := systemctlArgs(userMode, args...)
71 + output, err := exec.CommandContext(ctx, "systemctl", commandArgs...).CombinedOutput()
72 + if err == nil {
73 + return nil
74 + }
75 + message := "systemctl " + strings.Join(commandArgs, " ")
76 + if detail := strings.TrimSpace(string(output)); detail != "" {
77 + message += ": " + detail
78 + }
79 + if userMode {
80 + message += "; user systemd must be available for managed agent mode"
81 + }
82 + return fmt.Errorf("%s: %w", message, err)
83 +}
84 +
85 func systemdUnit(def Definition) string {
86 parts := append([]string{def.Executable}, def.Args...)
87 for i := range parts {
cmd/portal-tunnel/main.go
+2 -2
@@ -254,7 +254,7 @@ func printRootUsage(w io.Writer) {
254 "portal expose [flags] <target>",
255 "portal expose [flags] --http-route PATH=UPSTREAM [--http-route PATH=UPSTREAM]",
256 "portal agent run [flags]",
257 - "portal agent status [flags]",
257 + "portal agent dashboard [flags]",
258 "portal agent stop [flags]",
259 "portal list [flags]",
260 "portal update [flags]",
@@ -265,7 +265,7 @@ func printRootUsage(w io.Writer) {
265 "portal expose localhost:8080 --name my-app",
266 "portal expose --http-route /api=http://127.0.0.1:3001 --http-route /=http://127.0.0.1:5173 --name my-app",
267 "portal agent run",
268 - "portal agent status",
268 + "portal agent dashboard",
269 "portal agent stop",
270 "portal expose 3000 --udp --udp-addr 127.0.0.1:5353",
271 "portal list",
docs/src/routes/cli-reference/+page.md
+4 -7
@@ -156,11 +156,12 @@ Run a durable local agent that owns multiple tunnels from one config file.
156
157 ```bash
158 portal agent run
159 -portal agent status
159 +portal agent dashboard
160 portal agent stop
161 ```
162
163 -`portal agent run` reads the platform default `config.toml`, installs or updates the OS-managed service, and starts it. Use `--foreground` for local debugging without service registration.
163 +`portal agent run` reads or creates the platform default `config.toml`, installs or updates the OS-managed service, starts it, and opens the dashboard when invoked from an interactive terminal. Use `--foreground` for local debugging without service registration.
164 +Use `portal agent dashboard` to attach to an already running local agent. When using `--foreground`, keep that process running in one terminal and open the dashboard from another.
165
166 **Subcommands:**
167
@@ -168,11 +169,7 @@ portal agent stop
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 |
171 -| `portal agent status [--json]` | Print tunnel, relay, public URL, and error state from the local control API |
172 -| `portal agent reload` | Reload `config.toml` and restart only changed tunnels |
173 -| `portal agent restart <tunnel-id>` | Restart one managed tunnel |
174 -| `portal agent relay-add <tunnel-id> <relay-url>` | Attach a relay to one running tunnel |
175 -| `portal agent relay-remove <tunnel-id> <relay-url>` | Detach a relay from one running tunnel |
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 |
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
+6 -3
@@ -146,13 +146,16 @@ The `portal list` subcommand accepts the following flags:
146 ### `config.toml`
147
148 `portal agent run` reads the platform default `config.toml` and starts one managed process for all declared tunnels. Relative paths are resolved from the config file directory.
149 +If the file is missing, `portal agent run` creates a default config and the agent creates the identity file on first tunnel start.
150
151 Default paths:
152
153 | OS | Config | Default identity |
154 |----|--------|------------------|
154 -| Linux | `/etc/portal-tunnel/agent/config.toml` | `/var/lib/portal-tunnel/agent/identity.json` |
155 -| macOS | `/Library/Application Support/Portal Tunnel/Agent/config.toml` | `/Library/Application Support/Portal Tunnel/Agent/identity.json` |
155 +| Linux user | `$XDG_CONFIG_HOME/portal-tunnel/agent/config.toml` or `~/.config/portal-tunnel/agent/config.toml` | `$XDG_DATA_HOME/portal-tunnel/agent/identity.json` or `~/.local/share/portal-tunnel/agent/identity.json` |
156 +| Linux root | `/etc/portal-tunnel/agent/config.toml` | `/var/lib/portal-tunnel/agent/identity.json` |
157 +| macOS user | `~/Library/Application Support/Portal Tunnel/Agent/config.toml` | `~/Library/Application Support/Portal Tunnel/Agent/identity.json` |
158 +| macOS root | `/Library/Application Support/Portal Tunnel/Agent/config.toml` | `/Library/Application Support/Portal Tunnel/Agent/identity.json` |
159 | Windows | `%ProgramData%\Portal Tunnel\Agent\config.toml` | `%ProgramData%\Portal Tunnel\Agent\identity.json` |
160
161 ```toml
@@ -196,7 +199,7 @@ Tunnel fields mirror `portal expose` flags:
199
200 | Field | Type | Description |
201 |-------|------|-------------|
199 -| `id` | string | Stable tunnel ID used by `portal agent restart`, `relay-add`, and `relay-remove` |
202 +| `id` | string | Stable tunnel ID used by the agent dashboard |
203 | `target` | string | Local TCP target, equivalent to the `portal expose <target>` argument |
204 | `http_routes` | table array | HTTP route mappings; cannot be combined with `target` or `udp` |
205 | `relays` | string array | Explicit relay API URLs |
go.mod
+20
@@ -9,6 +9,9 @@ require (
9 github.com/aws/aws-sdk-go-v2/config v1.32.14
10 github.com/aws/aws-sdk-go-v2/credentials v1.19.14
11 github.com/aws/aws-sdk-go-v2/service/route53 v1.62.5
12 + github.com/charmbracelet/bubbles v1.0.0
13 + github.com/charmbracelet/bubbletea v1.3.10
14 + github.com/charmbracelet/lipgloss v1.1.0
15 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0
16 github.com/go-acme/lego/v4 v4.34.0
17 github.com/go-jose/go-jose/v4 v4.1.4
@@ -45,9 +48,18 @@ require (
48 github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect
49 github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect
50 github.com/aws/smithy-go v1.24.2 // indirect
51 + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
52 github.com/cenkalti/backoff/v5 v5.0.3 // indirect
53 github.com/cespare/xxhash/v2 v2.3.0 // indirect
54 + github.com/charmbracelet/colorprofile v0.4.1 // indirect
55 + github.com/charmbracelet/x/ansi v0.11.6 // indirect
56 + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
57 + github.com/charmbracelet/x/term v0.2.2 // indirect
58 + github.com/clipperhouse/displaywidth v0.9.0 // indirect
59 + github.com/clipperhouse/stringish v0.1.1 // indirect
60 + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
61 github.com/dchest/uniuri v1.2.0 // indirect
62 + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
63 github.com/ethereum/go-ethereum v1.17.1 // indirect
64 github.com/felixge/httpsnoop v1.0.4 // indirect
65 github.com/fsnotify/fsnotify v1.9.0 // indirect
@@ -61,13 +73,21 @@ require (
73 github.com/googleapis/gax-go/v2 v2.21.0 // indirect
74 github.com/holiman/uint256 v1.3.2 // indirect
75 github.com/knadh/koanf/maps v0.1.2 // indirect
76 + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
77 github.com/mattn/go-colorable v0.1.13 // indirect
78 github.com/mattn/go-isatty v0.0.21 // indirect
79 + github.com/mattn/go-localereader v0.0.1 // indirect
80 + github.com/mattn/go-runewidth v0.0.19 // indirect
81 github.com/miekg/dns v1.1.72 // indirect
82 github.com/mitchellh/copystructure v1.2.0 // indirect
83 github.com/mitchellh/reflectwalk v1.0.2 // indirect
84 + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
85 + github.com/muesli/cancelreader v0.2.2 // indirect
86 + github.com/muesli/termenv v0.16.0 // indirect
87 github.com/pelletier/go-toml/v2 v2.2.4 // indirect
88 github.com/relvacode/iso8601 v1.1.1-0.20210511065120-b30b151cc433 // indirect
89 + github.com/rivo/uniseg v0.4.7 // indirect
90 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
91 github.com/ysmood/fetchup v0.2.3 // indirect
92 github.com/ysmood/goob v0.4.0 // indirect
93 github.com/ysmood/got v0.40.0 // indirect
go.sum
+43
@@ -38,10 +38,32 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBU
38 github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw=
39 github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
40 github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
41 +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
42 +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
43 github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
44 github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
45 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
46 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
47 +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
48 +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
49 +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
50 +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
51 +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
52 +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
53 +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
54 +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
55 +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
56 +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
57 +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
58 +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
59 +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
60 +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
61 +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
62 +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
63 +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
64 +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
65 +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
66 +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
67 github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
68 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
69 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -51,6 +73,8 @@ github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK
73 github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
74 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0 h1:HbphB4TFFXpv7MNrT52FGrrgVXF1owhMVTHFZIlnvd4=
75 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0/go.mod h1:DZGJHZMqrU4JJqFAWUS2UO1+lbSKsdiOoYi9Zzey7Fc=
76 +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
77 +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
78 github.com/ethereum/go-ethereum v1.17.1 h1:IjlQDjgxg2uL+GzPRkygGULPMLzcYWncEI7wbaizvho=
79 github.com/ethereum/go-ethereum v1.17.1/go.mod h1:7UWOVHL7K3b8RfVRea022btnzLCaanwHtBuH1jUCH/I=
80 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
@@ -99,18 +123,30 @@ github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP
123 github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA=
124 github.com/knadh/koanf/v2 v2.3.4 h1:fnynNSDlujWE+v83hAp8wKr/cdoxHLO0629SN+U8Urc=
125 github.com/knadh/koanf/v2 v2.3.4/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28=
126 +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
127 +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
128 github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
129 github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
130 github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
131 github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
132 github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
133 github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
134 +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
135 +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
136 +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
137 +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
138 github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
139 github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
140 github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
141 github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
142 github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
143 github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
144 +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
145 +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
146 +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
147 +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
148 +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
149 +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
150 github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
151 github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
152 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -120,6 +156,8 @@ github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SA
156 github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
157 github.com/relvacode/iso8601 v1.1.1-0.20210511065120-b30b151cc433 h1:mLbKGKe5gDGHE8uJLYMmA/fkp/htaXEMl2Hj0k4xfYE=
158 github.com/relvacode/iso8601 v1.1.1-0.20210511065120-b30b151cc433/go.mod h1:FlNp+jz+TXpyRqgmM7tnzHHzBnz776kmAH2h3sZCn0I=
159 +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
160 +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
161 github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
162 github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
163 github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
@@ -127,6 +165,8 @@ github.com/spruceid/siwe-go v0.2.1 h1:BroySys6CyUzeyNppTseEOT/w56xTdOfcmECTI7rnu
165 github.com/spruceid/siwe-go v0.2.1/go.mod h1:MHpHbptGsM3lHth2L8quhZ9ipiwST8zsJH1CjWpeO1k=
166 github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
167 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
168 +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
169 +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
170 github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
171 github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
172 github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ=
@@ -163,6 +203,8 @@ go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
203 go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
204 golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
205 golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
206 +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
207 +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
208 golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
209 golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
210 golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
@@ -171,6 +213,7 @@ golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
213 golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
214 golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
215 golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
216 +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
217 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
218 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
219 golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
portal/discovery/relayset.go
+10
@@ -240,6 +240,16 @@ func (s *RelaySet) AggregateRelays() []RelayState {
240 return policy.SelectAggregate(states)
241 }
242
243 +func (s *RelaySet) AllRelays() []RelayState {
244 + s.mu.RLock()
245 + states := make([]RelayState, 0, len(s.relays))
246 + for _, state := range s.relays {
247 + states = append(states, state)
248 + }
249 + s.mu.RUnlock()
250 + return states
251 +}
252 +
253 func (s *RelaySet) ConfirmedRelays() []RelayState {
254 s.mu.RLock()
255 states := make([]RelayState, 0, len(s.relays))
sdk/expose.go
+93 -30
@@ -200,9 +200,6 @@ func (e *Exposure) AddRelay(relayURL string) error {
200 if err != nil {
201 return err
202 }
203 - if len(e.multiHop) > 0 || e.multiHopDepth > 1 {
204 - return errors.New("runtime relay add/remove is not supported for multi-hop exposures")
205 - }
203 if e.closed() {
204 return net.ErrClosed
205 }
@@ -228,9 +225,6 @@ func (e *Exposure) RemoveRelay(relayURL string) error {
225 if err != nil {
226 return err
227 }
231 - if len(e.multiHop) > 0 || e.multiHopDepth > 1 {
232 - return errors.New("runtime relay add/remove is not supported for multi-hop exposures")
233 - }
228 if e.closed() {
229 return net.ErrClosed
230 }
@@ -246,6 +240,19 @@ func (e *Exposure) RemoveRelay(relayURL string) error {
240 }
241 }
242 e.explicitRelays = nextRelays
243 + if slices.Contains(e.multiHop, relayURL) {
244 + nextMultiHop := make([]string, 0, len(e.multiHop))
245 + for _, existing := range e.multiHop {
246 + if existing != relayURL {
247 + nextMultiHop = append(nextMultiHop, existing)
248 + }
249 + }
250 + if len(nextMultiHop) < 2 {
251 + nextMultiHop = nil
252 + }
253 + e.multiHop = nextMultiHop
254 + e.multiHopDepth = 0
255 + }
256 e.listenerMu.Unlock()
257
258 e.relaySet.BanRelayURL(relayURL)
@@ -253,6 +260,43 @@ func (e *Exposure) RemoveRelay(relayURL string) error {
260 return e.reconcileRelayListeners(false)
261 }
262
263 +func (e *Exposure) SetMultiHop(relayURLs []string) error {
264 + multiHop := make([]string, 0, len(relayURLs))
265 + for _, input := range relayURLs {
266 + relayURL, err := utils.NormalizeRelayURL(input)
267 + if err != nil {
268 + return fmt.Errorf("normalize multi-hop relay url: %w", err)
269 + }
270 + if slices.Contains(multiHop, relayURL) {
271 + return fmt.Errorf("multi-hop relay url repeated: %s", relayURL)
272 + }
273 + multiHop = append(multiHop, relayURL)
274 + }
275 + if len(multiHop) == 1 {
276 + return errors.New("multi-hop requires at least entry and exit relay urls")
277 + }
278 + if len(multiHop) > 0 && (e.udpEnabled || e.tcpEnabled) {
279 + return errors.New("multi-hop currently supports only the default SNI TLS stream transport")
280 + }
281 + if e.closed() {
282 + return net.ErrClosed
283 + }
284 + if e.relaySet == nil {
285 + return errors.New("exposure relay set is not initialized")
286 + }
287 +
288 + for _, relayURL := range multiHop {
289 + e.relaySet.AllowRelayURL(relayURL)
290 + e.relaySet.AddBootstrapRelayURL(relayURL)
291 + }
292 +
293 + e.listenerMu.Lock()
294 + e.multiHop = append([]string(nil), multiHop...)
295 + e.multiHopDepth = 0
296 + e.listenerMu.Unlock()
297 + return e.reconcileRelayListeners(len(multiHop) > 0)
298 +}
299 +
300 func initialRouteCapacity(listenerRelayURLs []string, multiHopDepth int) int {
301 if multiHopDepth > 1 {
302 return 1
@@ -296,25 +340,7 @@ func (e *Exposure) Identity() types.Identity {
340 return e.identity
341 }
342
299 -type ExposureSnapshot struct {
300 - Identity types.Identity `json:"identity"`
301 - TargetAddr string `json:"target_addr,omitempty"`
302 - UDPAddr string `json:"udp_addr,omitempty"`
303 - Relays []ExposureRelaySnapshot `json:"relays,omitempty"`
304 -}
305 -
306 -type ExposureRelaySnapshot struct {
307 - RelayURL string `json:"relay_url"`
308 - Hostname string `json:"hostname,omitempty"`
309 - PublicURL string `json:"public_url,omitempty"`
310 - UDPAddr string `json:"udp_addr,omitempty"`
311 - TCPAddr string `json:"tcp_addr,omitempty"`
312 - ExpiresAt time.Time `json:"expires_at,omitempty"`
313 - MultiHop []string `json:"multi_hop,omitempty"`
314 - Connected bool `json:"connected"`
315 -}
316 -
317 -func (e *Exposure) Snapshot() ExposureSnapshot {
343 +func (e *Exposure) Snapshot() types.AgentTunnelStatus {
344 e.listenerMu.RLock()
345 listeners := make([]*listener, 0, len(e.relayListeners))
346 for _, listener := range e.relayListeners {
@@ -322,17 +348,19 @@ func (e *Exposure) Snapshot() ExposureSnapshot {
348 listeners = append(listeners, listener)
349 }
350 }
351 + multiHop := append([]string(nil), e.multiHop...)
352 e.listenerMu.RUnlock()
353
327 - relays := make([]ExposureRelaySnapshot, 0, len(listeners))
354 + relayByURL := make(map[string]types.AgentRelayStatus, len(listeners))
355 for _, listener := range listeners {
356 relayURL := ""
357 if listener.relayURL != nil {
358 relayURL = listener.relayURL.String()
359 }
333 - snap := ExposureRelaySnapshot{
360 + snap := types.AgentRelayStatus{
361 RelayURL: relayURL,
362 MultiHop: append([]string(nil), listener.multiHop...),
363 + Active: true,
364 }
365 if lease, ok := listener.leaseSnapshot(); ok {
366 snap.Hostname = lease.hostname
@@ -342,16 +370,51 @@ func (e *Exposure) Snapshot() ExposureSnapshot {
370 snap.ExpiresAt = lease.expiresAt
371 snap.Connected = lease.hostname != ""
372 }
373 + if relayURL != "" {
374 + relayByURL[relayURL] = snap
375 + }
376 + }
377 + if e.relaySet != nil {
378 + for _, state := range e.relaySet.AllRelays() {
379 + relayURL := strings.TrimSpace(state.Descriptor.APIHTTPSAddr)
380 + if relayURL == "" {
381 + continue
382 + }
383 + snap := relayByURL[relayURL]
384 + snap.RelayURL = relayURL
385 + snap.Address = state.Descriptor.Address
386 + snap.DescriptorExpiresAt = state.Descriptor.ExpiresAt
387 + snap.LastSeenAt = state.LastSeenAt
388 + if !state.DiscoveryRTTAt.IsZero() {
389 + snap.DiscoveryRTTMillis = state.DiscoveryRTT.Milliseconds()
390 + }
391 + snap.Bootstrap = state.Bootstrap
392 + snap.Confirmed = state.Confirmed
393 + snap.Banned = state.Banned
394 + snap.SupportsOverlay = state.Descriptor.SupportsOverlay
395 + snap.SupportsUDP = state.Descriptor.SupportsUDP
396 + snap.SupportsTCP = state.Descriptor.SupportsTCP
397 + relayByURL[relayURL] = snap
398 + }
399 + }
400 + relays := make([]types.AgentRelayStatus, 0, len(relayByURL))
401 + for _, snap := range relayByURL {
402 relays = append(relays, snap)
403 }
347 - slices.SortFunc(relays, func(a, b ExposureRelaySnapshot) int {
404 + slices.SortFunc(relays, func(a, b types.AgentRelayStatus) int {
405 + if a.Active != b.Active {
406 + if a.Active {
407 + return -1
408 + }
409 + return 1
410 + }
411 return strings.Compare(a.RelayURL, b.RelayURL)
412 })
413
351 - return ExposureSnapshot{
352 - Identity: e.identity,
414 + return types.AgentTunnelStatus{
415 TargetAddr: e.TargetAddr,
416 UDPAddr: e.UDPAddr,
417 + MultiHop: multiHop,
418 Relays: relays,
419 }
420 }
types/agent.go
+24 -8
@@ -27,19 +27,31 @@ type AgentTunnelStatus struct {
27 StartedAt time.Time `json:"started_at,omitempty"`
28 UpdatedAt time.Time `json:"updated_at,omitempty"`
29 Restarts int `json:"restarts,omitempty"`
30 + MultiHop []string `json:"multi_hop,omitempty"`
31 Relays []AgentRelayStatus `json:"relays,omitempty"`
32 PublicURLs []string `json:"public_urls,omitempty"`
33 }
34
35 type AgentRelayStatus struct {
35 - RelayURL string `json:"relay_url"`
36 - Hostname string `json:"hostname,omitempty"`
37 - PublicURL string `json:"public_url,omitempty"`
38 - UDPAddr string `json:"udp_addr,omitempty"`
39 - TCPAddr string `json:"tcp_addr,omitempty"`
40 - ExpiresAt time.Time `json:"expires_at,omitempty"`
41 - MultiHop []string `json:"multi_hop,omitempty"`
42 - Connected bool `json:"connected"`
36 + RelayURL string `json:"relay_url"`
37 + Address string `json:"address,omitempty"`
38 + Hostname string `json:"hostname,omitempty"`
39 + PublicURL string `json:"public_url,omitempty"`
40 + UDPAddr string `json:"udp_addr,omitempty"`
41 + TCPAddr string `json:"tcp_addr,omitempty"`
42 + ExpiresAt time.Time `json:"expires_at,omitempty"`
43 + DescriptorExpiresAt time.Time `json:"descriptor_expires_at,omitempty"`
44 + LastSeenAt time.Time `json:"last_seen_at,omitempty"`
45 + DiscoveryRTTMillis int64 `json:"discovery_rtt_millis,omitempty"`
46 + MultiHop []string `json:"multi_hop,omitempty"`
47 + Active bool `json:"active"`
48 + Bootstrap bool `json:"bootstrap"`
49 + Confirmed bool `json:"confirmed"`
50 + Banned bool `json:"banned"`
51 + SupportsOverlay bool `json:"supports_overlay"`
52 + SupportsUDP bool `json:"supports_udp"`
53 + SupportsTCP bool `json:"supports_tcp"`
54 + Connected bool `json:"connected"`
55 }
56
57 type AgentLogEntry struct {
@@ -52,3 +64,7 @@ type AgentLogEntry struct {
64 type AgentRelayRequest struct {
65 RelayURL string `json:"relay_url"`
66 }
67 +
68 +type AgentMultiHopRequest struct {
69 + Relays []string `json:"relays"`
70 +}
types/paths.go
+5 -6
@@ -24,12 +24,11 @@ const (
24 PathInstallPowerShell = "/install.ps1"
25 PathInstallBinPrefix = "/install/bin/"
26
27 - PathAgentStatus = "/v1/status"
28 - PathAgentShutdown = "/v1/shutdown"
29 - PathAgentReload = "/v1/reload"
30 - PathAgentTunnelsPrefix = "/v1/tunnels/"
31 - PathAgentRelaysSegment = "/relays"
32 - PathAgentRestartSegment = "/restart"
27 + PathAgentPrefix = "/v1/agent"
28 + PathAgentStatus = PathAgentPrefix + "/status"
29 + PathAgentShutdown = PathAgentPrefix + "/shutdown"
30 + PathAgentReload = PathAgentPrefix + "/reload"
31 + PathAgentTunnelsPrefix = PathAgentPrefix + "/tunnels/"
32
33 PathTunnelStatus = "/tunnel/status"
34 PathThumbnailPrefix = "/thumbnail/"