feat: add restart command for portal agent and update documentation

Kim committed May 8, 2026 at 11:58 UTC 02a95c0e4d0125983ad61edb7e4abda5c6e1b509
8 files changed +137 -14
cmd/portal-tunnel/README.md
+2
@@ -119,6 +119,7 @@ Runs Portal as a managed long-lived tunnel agent.
119 - The local control API is bound to loopback and authenticated with a token stored in the agent state directory.
120 - `portal agent dashboard` opens the mouse-capable local TUI for tunnel add/delete, per-tunnel relay add/delete/listing, and multi-hop route changes.
121 - `portal agent stop` asks the local agent to shut down, then disables/stops the OS service so intentional shutdown is not immediately restarted.
122 +- `portal agent restart` stops the running agent if present, installs or updates the OS service from the existing config, and starts it again.
123 - If the config file is missing, `portal agent run` creates a default config and the agent creates the identity file on first tunnel start.
124
125 Default paths:
@@ -154,6 +155,7 @@ Runtime controls:
155 portal agent run
156 portal agent dashboard
157 portal agent stop
158 +portal agent restart
159 ```
160
161 Legacy execution compatibility has been removed:
cmd/portal-tunnel/agent.go
+93 -14
@@ -25,10 +25,12 @@ func runAgentCommand(args []string) error {
25 "run": runAgentRunCommand,
26 "dashboard": runAgentDashboardCommand,
27 "stop": runAgentStopCommand,
28 + "restart": runAgentRestartCommand,
29 "help": utils.MakeHelpCommand(printAgentUsage, []utils.HelpTopic{
30 {Name: "run", Usage: printAgentRunUsage},
31 {Name: "dashboard", Usage: printAgentDashboardUsage},
32 {Name: "stop", Usage: printAgentStopUsage},
33 + {Name: "restart", Usage: printAgentRestartUsage},
34 }),
35 })
36 }
@@ -67,18 +69,75 @@ func runAgentRunCommand(args []string) error {
69 return runAgentForeground(configPath, cfg)
70 }
71
70 - executable, err := os.Executable()
72 + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
73 + defer cancel()
74 + status, err := startAgentService(ctx, configPath, cfg)
75 if err != nil {
76 return err
77 }
74 - executable, err = filepath.Abs(executable)
78 +
79 + fmt.Fprintf(os.Stdout, "Portal agent running at %s with %d tunnel(s).\n", status.ControlAddr, len(status.Tunnels))
80 + return nil
81 +}
82 +
83 +func runAgentRestartCommand(args []string) error {
84 + var configPath string
85 + fs := utils.NewFlagSet("agent restart", printAgentRestartUsage)
86 + utils.StringFlag(fs, &configPath, "config", "", "Portal agent TOML config path")
87 + if err := utils.ParseFlagSet(fs, args, printAgentRestartUsage); err != nil {
88 + if errors.Is(err, flag.ErrHelp) {
89 + return nil
90 + }
91 + return err
92 + }
93 + if err := utils.RequireNoArgs(fs.Args(), "agent restart"); err != nil {
94 + printAgentRestartUsage(os.Stderr)
95 + return err
96 + }
97 + if strings.TrimSpace(configPath) == "" {
98 + configPath = service.DefaultConfigPath()
99 + }
100 + cfg, err := agent.LoadExistingConfig(configPath)
101 if err != nil {
102 return err
103 }
78 - configPath, err = filepath.Abs(strings.TrimSpace(configPath))
104 +
105 + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
106 + defer cancel()
107 +
108 + shutdownErr := agent.Shutdown(ctx, cfg.Agent.StateDir)
109 + if err := service.Stop(ctx, cfg.Agent.ServiceName); err != nil {
110 + if shutdownErr != nil {
111 + fmt.Fprintf(os.Stderr, "Warning: existing agent stop failed: %v\n", errors.Join(shutdownErr, err))
112 + } else {
113 + fmt.Fprintf(os.Stderr, "Warning: service manager stop failed: %v\n", err)
114 + }
115 + }
116 + if err := waitAgentStopped(ctx, cfg.Agent.StateDir); err != nil {
117 + return err
118 + }
119 + status, err := startAgentService(ctx, configPath, cfg)
120 if err != nil {
121 return err
122 }
123 +
124 + fmt.Fprintf(os.Stdout, "Portal agent restarted at %s with %d tunnel(s).\n", status.ControlAddr, len(status.Tunnels))
125 + return nil
126 +}
127 +
128 +func startAgentService(ctx context.Context, configPath string, cfg agent.Config) (types.AgentStatusResponse, error) {
129 + configPath, err := filepath.Abs(strings.TrimSpace(configPath))
130 + if err != nil {
131 + return types.AgentStatusResponse{}, err
132 + }
133 + executable, err := os.Executable()
134 + if err != nil {
135 + return types.AgentStatusResponse{}, err
136 + }
137 + executable, err = filepath.Abs(executable)
138 + if err != nil {
139 + return types.AgentStatusResponse{}, err
140 + }
141 def := service.Definition{
142 Name: strings.TrimSpace(cfg.Agent.ServiceName),
143 DisplayName: "Portal Agent",
@@ -87,21 +146,13 @@ func runAgentRunCommand(args []string) error {
146 Args: []string{"agent", "run", "--service", "--config", configPath},
147 WorkingDir: filepath.Dir(configPath),
148 }
90 - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
91 - defer cancel()
149 if err := service.Install(ctx, def); err != nil {
93 - return fmt.Errorf("install portal agent service: %w; use --foreground when the OS service manager is unavailable", err)
150 + return types.AgentStatusResponse{}, fmt.Errorf("install portal agent service: %w; use --foreground when the OS service manager is unavailable", err)
151 }
152 if err := service.Start(ctx, cfg.Agent.ServiceName); err != nil {
96 - return fmt.Errorf("start portal agent service: %w; use --foreground when the OS service manager is unavailable", err)
153 + return types.AgentStatusResponse{}, fmt.Errorf("start portal agent service: %w; use --foreground when the OS service manager is unavailable", err)
154 }
98 - status, err := waitAgentStatus(ctx, cfg.Agent.StateDir)
99 - if err != nil {
100 - return err
101 - }
102 -
103 - fmt.Fprintf(os.Stdout, "Portal agent running at %s with %d tunnel(s).\n", status.ControlAddr, len(status.Tunnels))
104 - return nil
155 + return waitAgentStatus(ctx, cfg.Agent.StateDir)
156 }
157
158 func runAgentForeground(configPath string, cfg agent.Config) error {
@@ -240,6 +291,22 @@ func waitAgentStatus(ctx context.Context, stateDir string) (types.AgentStatusRes
291 }
292 }
293
294 +func waitAgentStopped(ctx context.Context, stateDir string) error {
295 + ticker := time.NewTicker(300 * time.Millisecond)
296 + defer ticker.Stop()
297 + for {
298 + _, err := agent.Status(ctx, stateDir)
299 + if err != nil {
300 + return nil
301 + }
302 + select {
303 + case <-ctx.Done():
304 + return errors.New("wait for portal agent shutdown: agent is still running")
305 + case <-ticker.C:
306 + }
307 + }
308 +}
309 +
310 func waitAgentStatusOrExit(ctx context.Context, stateDir string, errCh <-chan error) error {
311 ticker := time.NewTicker(300 * time.Millisecond)
312 defer ticker.Stop()
@@ -307,12 +374,14 @@ func printAgentUsage(w io.Writer) {
374 "portal agent run [flags]",
375 "portal agent dashboard [flags]",
376 "portal agent stop [flags]",
377 + "portal agent restart [flags]",
378 },
379 []string{
380 "portal agent run",
381 "portal agent run --config config.toml --foreground",
382 "portal agent dashboard",
383 "portal agent stop",
384 + "portal agent restart",
385 },
386 )
387 }
@@ -346,3 +415,13 @@ func printAgentStopUsage(w io.Writer) {
415 },
416 )
417 }
418 +
419 +func printAgentRestartUsage(w io.Writer) {
420 + utils.WriteCommandUsage(w,
421 + []string{"portal agent restart [flags]"},
422 + []string{
423 + "portal agent restart",
424 + "portal agent restart --config config.toml",
425 + },
426 + )
427 +}
cmd/portal-tunnel/agent/service/service_darwin.go
+8
@@ -34,6 +34,14 @@ func Start(ctx context.Context, name string) error {
34 return exec.CommandContext(ctx, "launchctl", "kickstart", "-k", domain+"/"+name).Run()
35 }
36
37 +func Stop(ctx context.Context, name string) error {
38 + plistPath, domain, err := launchdPlistPath(name)
39 + if err != nil {
40 + return err
41 + }
42 + return exec.CommandContext(ctx, "launchctl", "bootout", domain, plistPath).Run()
43 +}
44 +
45 func StopDisable(ctx context.Context, name string) error {
46 plistPath, domain, err := launchdPlistPath(name)
47 if err != nil {
cmd/portal-tunnel/agent/service/service_linux.go
+8
@@ -36,6 +36,14 @@ func Start(ctx context.Context, name string) error {
36 return runSystemctl(ctx, userMode, "start", name+".service")
37 }
38
39 +func Stop(ctx context.Context, name string) error {
40 + _, userMode, err := linuxUnitPath(name)
41 + if err != nil {
42 + return err
43 + }
44 + return runSystemctl(ctx, userMode, "stop", name+".service")
45 +}
46 +
47 func StopDisable(ctx context.Context, name string) error {
48 _, userMode, err := linuxUnitPath(name)
49 if err != nil {
cmd/portal-tunnel/agent/service/service_unsupported.go
+4
@@ -15,6 +15,10 @@ func Start(context.Context, string) error {
15 return errors.New("portal agent service start is not supported on this OS")
16 }
17
18 +func Stop(context.Context, string) error {
19 + return errors.New("portal agent service stop is not supported on this OS")
20 +}
21 +
22 func StopDisable(context.Context, string) error {
23 return errors.New("portal agent service stop is not supported on this OS")
24 }
cmd/portal-tunnel/agent/service/service_windows.go
+18
@@ -73,6 +73,24 @@ func Start(ctx context.Context, name string) error {
73 return waitWindowsService(ctx, s, svc.Running)
74 }
75
76 +func Stop(ctx context.Context, name string) error {
77 + s, err := openService(name)
78 + if err != nil {
79 + if errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) {
80 + return nil
81 + }
82 + return err
83 + }
84 + defer s.Close()
85 +
86 + status, err := s.Query()
87 + if err == nil && status.State != svc.Stopped {
88 + _, _ = s.Control(svc.Stop)
89 + return waitWindowsService(ctx, s, svc.Stopped)
90 + }
91 + return ctx.Err()
92 +}
93 +
94 func StopDisable(ctx context.Context, name string) error {
95 s, err := openService(name)
96 if err != nil {
cmd/portal-tunnel/main.go
+2
@@ -276,6 +276,7 @@ func printRootUsage(w io.Writer) {
276 "portal agent run [flags]",
277 "portal agent dashboard [flags]",
278 "portal agent stop [flags]",
279 + "portal agent restart [flags]",
280 "portal list [flags]",
281 "portal update [flags]",
282 "portal version",
@@ -287,6 +288,7 @@ func printRootUsage(w io.Writer) {
288 "portal agent run",
289 "portal agent dashboard",
290 "portal agent stop",
291 + "portal agent restart",
292 "portal expose 3000 --udp --udp-addr 127.0.0.1:5353",
293 "portal list",
294 "portal update",
docs/src/routes/cli-reference/+page.md
+2
@@ -158,6 +158,7 @@ Run a durable local agent that owns multiple tunnels from one config file.
158 portal agent run
159 portal agent dashboard
160 portal agent stop
161 +portal agent restart
162 ```
163
164 `portal agent run` reads or creates the platform default `config.toml`, installs or updates the OS-managed service, starts it in the background, and exits after the agent is ready. Use `--foreground` for local debugging without service registration.
@@ -171,6 +172,7 @@ Use `portal agent dashboard` to attach to an already running managed agent. With
172 | `portal agent run --config config.toml --foreground` | Run the agent in the current terminal |
173 | `portal agent dashboard` | Open the mouse-capable local TUI for tunnels, relay attach/detach, relay lists, and multi-hop route changes |
174 | `portal agent stop` | Gracefully stop the agent and disable/stop the OS service |
175 +| `portal agent restart` | Stop the current agent if present, install/update the service, and start it again |
176
177 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.
178