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