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/"