main
go 439 lines 11.9 KB
Raw
1 package main
2
3 import (
4 "context"
5 "errors"
6 "flag"
7 "fmt"
8 "io"
9 "os"
10 "path/filepath"
11 "strings"
12 "time"
13
14 "github.com/rs/zerolog"
15 "github.com/rs/zerolog/log"
16
17 "github.com/gosuda/portal-tunnel/v2/cmd/portal-tunnel/agent"
18 "github.com/gosuda/portal-tunnel/v2/cmd/portal-tunnel/agent/service"
19 "github.com/gosuda/portal-tunnel/v2/types"
20 "github.com/gosuda/portal-tunnel/v2/utils"
21 )
22
23 func runAgentCommand(args []string) error {
24 return utils.RunCommands(args, os.Stdout, os.Stderr, printAgentUsage, map[string]utils.CommandFunc{
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 }
37
38 func runAgentRunCommand(args []string) error {
39 var configPath string
40 var serviceMode bool
41 var foreground bool
42 fs := utils.NewFlagSet("agent run", printAgentRunUsage)
43 utils.StringFlag(fs, &configPath, "config", service.DefaultConfigPath(), "Portal agent TOML config path")
44 utils.BoolFlag(fs, &serviceMode, "service", false, "Run the foreground service process")
45 utils.BoolFlag(fs, &foreground, "foreground", false, "Run in the current process without installing the OS service")
46 if err := utils.ParseFlagSet(fs, args, printAgentRunUsage); err != nil {
47 if errors.Is(err, flag.ErrHelp) {
48 return nil
49 }
50 return err
51 }
52 if err := utils.RequireNoArgs(fs.Args(), "agent run"); err != nil {
53 printAgentRunUsage(os.Stderr)
54 return err
55 }
56
57 cfg, err := agent.LoadExistingConfig(configPath)
58 if err != nil {
59 return err
60 }
61 if serviceMode {
62 ctx, stop := utils.SignalContext()
63 defer stop()
64 return service.Run(ctx, cfg.Agent.ServiceName, func(ctx context.Context) error {
65 return agent.Run(ctx, cfg)
66 })
67 }
68 if foreground {
69 return runAgentForeground(configPath, cfg)
70 }
71
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 }
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 }
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",
144 Description: "Manages Portal tunnel definitions and relay membership.",
145 Executable: executable,
146 Args: []string{"agent", "run", "--service", "--config", configPath},
147 WorkingDir: filepath.Dir(configPath),
148 }
149 if err := service.Install(ctx, def); err != nil {
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 {
153 return types.AgentStatusResponse{}, fmt.Errorf("start portal agent service: %w; use --foreground when the OS service manager is unavailable", err)
154 }
155 return waitAgentStatus(ctx, cfg.Agent.StateDir)
156 }
157
158 func runAgentForeground(configPath string, cfg agent.Config) error {
159 ctx, stop := utils.SignalContext()
160 defer stop()
161
162 if !agentCLIInteractive() {
163 return agent.Run(ctx, cfg)
164 }
165
166 resolvedConfigPath, err := filepath.Abs(strings.TrimSpace(configPath))
167 if err != nil {
168 return err
169 }
170
171 restoreLogs := suppressTerminalLogs()
172 defer restoreLogs()
173
174 errCh := make(chan error, 1)
175 go func() {
176 errCh <- agent.Run(ctx, cfg)
177 }()
178
179 readyCtx, readyCancel := context.WithTimeout(ctx, 15*time.Second)
180 err = waitAgentStatusOrExit(readyCtx, cfg.Agent.StateDir, errCh)
181 readyCancel()
182 if err != nil {
183 stop()
184 return err
185 }
186
187 dashboardErr := agent.RunDashboard(resolvedConfigPath, cfg.Agent.StateDir)
188 stop()
189 runErr := <-errCh
190 if errors.Is(runErr, context.Canceled) {
191 runErr = nil
192 }
193 if dashboardErr != nil {
194 return dashboardErr
195 }
196 if runErr != nil {
197 return runErr
198 }
199
200 fmt.Fprintln(os.Stdout, "Portal agent stopped.")
201 return nil
202 }
203
204 func suppressTerminalLogs() func() {
205 previous := log.Logger
206 log.Logger = zerolog.New(io.Discard)
207 return func() {
208 log.Logger = previous
209 }
210 }
211
212 func runAgentStopCommand(args []string) error {
213 var configPath string
214 var stateDir string
215 fs := utils.NewFlagSet("agent stop", printAgentStopUsage)
216 utils.StringFlag(fs, &configPath, "config", "", "Portal agent TOML config path")
217 utils.StringFlag(fs, &stateDir, "state-dir", "", "Portal agent state directory")
218 if err := utils.ParseFlagSet(fs, args, printAgentStopUsage); err != nil {
219 if errors.Is(err, flag.ErrHelp) {
220 return nil
221 }
222 return err
223 }
224 if err := utils.RequireNoArgs(fs.Args(), "agent stop"); err != nil {
225 printAgentStopUsage(os.Stderr)
226 return err
227 }
228
229 configPath = strings.TrimSpace(configPath)
230 stateDir = strings.TrimSpace(stateDir)
231 cfg := agent.Config{Agent: agent.AgentConfig{ServiceName: agent.DefaultServiceName}}
232 if configPath != "" || stateDir == "" {
233 if configPath == "" {
234 configPath = service.DefaultConfigPath()
235 }
236 var err error
237 cfg, err = agent.LoadExistingConfig(configPath)
238 if err != nil {
239 return err
240 }
241 }
242 if stateDir != "" {
243 cfg.Agent.StateDir = stateDir
244 }
245 ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
246 defer cancel()
247
248 shutdownErr := agent.Shutdown(ctx, cfg.Agent.StateDir)
249 serviceErr := service.StopDisable(ctx, cfg.Agent.ServiceName)
250 if errors.Is(shutdownErr, agent.ErrNotRunning) {
251 if serviceErr != nil {
252 fmt.Fprintf(os.Stderr, "Warning: agent is not running, but service manager cleanup failed: %v\n", serviceErr)
253 }
254 fmt.Fprintln(os.Stdout, "Portal agent is not running.")
255 return nil
256 }
257 if shutdownErr != nil {
258 return fmt.Errorf("stop portal agent: %w", errors.Join(shutdownErr, serviceErr))
259 }
260 if err := waitAgentStopped(ctx, cfg.Agent.StateDir); err != nil {
261 return fmt.Errorf("stop portal agent: %w", errors.Join(err, serviceErr))
262 }
263 if serviceErr != nil {
264 fmt.Fprintf(os.Stderr, "Warning: agent stopped, but service manager cleanup failed: %v\n", serviceErr)
265 }
266 fmt.Fprintln(os.Stdout, "Portal agent stopped.")
267 return nil
268 }
269
270 func runAgentDashboardCommand(args []string) error {
271 var configPath string
272 var stateDir string
273 fs := utils.NewFlagSet("agent dashboard", printAgentDashboardUsage)
274 utils.StringFlag(fs, &configPath, "config", "", "Portal agent TOML config path")
275 utils.StringFlag(fs, &stateDir, "state-dir", "", "Portal agent state directory")
276 if err := utils.ParseFlagSet(fs, args, printAgentDashboardUsage); err != nil {
277 if errors.Is(err, flag.ErrHelp) {
278 return nil
279 }
280 return err
281 }
282 if err := utils.RequireNoArgs(fs.Args(), "agent dashboard"); err != nil {
283 printAgentDashboardUsage(os.Stderr)
284 return err
285 }
286
287 configPath = strings.TrimSpace(configPath)
288 stateDir = strings.TrimSpace(stateDir)
289 if configPath == "" {
290 configPath = service.DefaultConfigPath()
291 }
292 if stateDir == "" {
293 if _, err := os.Stat(configPath); err == nil {
294 cfg, err := agent.LoadExistingConfig(configPath)
295 if err != nil {
296 return err
297 }
298 stateDir = cfg.Agent.StateDir
299 } else if errors.Is(err, os.ErrNotExist) {
300 stateDir = service.DefaultDataDir()
301 } else {
302 return err
303 }
304 }
305 return agent.RunDashboard(configPath, stateDir)
306 }
307
308 func waitAgentStatus(ctx context.Context, stateDir string) (types.AgentStatusResponse, error) {
309 ticker := time.NewTicker(300 * time.Millisecond)
310 defer ticker.Stop()
311 var lastErr error
312 for {
313 status, err := agent.Status(ctx, stateDir)
314 if err == nil {
315 return status, nil
316 }
317 lastErr = err
318 select {
319 case <-ctx.Done():
320 return types.AgentStatusResponse{}, fmt.Errorf("wait for portal agent status: %w", lastErr)
321 case <-ticker.C:
322 }
323 }
324 }
325
326 func waitAgentStopped(ctx context.Context, stateDir string) error {
327 ticker := time.NewTicker(300 * time.Millisecond)
328 defer ticker.Stop()
329 for {
330 _, err := agent.Status(ctx, stateDir)
331 if err != nil {
332 return nil
333 }
334 select {
335 case <-ctx.Done():
336 return errors.New("wait for portal agent shutdown: agent is still running")
337 case <-ticker.C:
338 }
339 }
340 }
341
342 func waitAgentStatusOrExit(ctx context.Context, stateDir string, errCh <-chan error) error {
343 ticker := time.NewTicker(300 * time.Millisecond)
344 defer ticker.Stop()
345 var lastErr error
346 for {
347 select {
348 case err := <-errCh:
349 if err == nil {
350 err = errors.New("portal agent stopped before dashboard was ready")
351 }
352 return err
353 default:
354 }
355
356 _, err := agent.Status(ctx, stateDir)
357 if err == nil {
358 return nil
359 }
360 lastErr = err
361 select {
362 case err := <-errCh:
363 if err == nil {
364 err = errors.New("portal agent stopped before dashboard was ready")
365 }
366 return err
367 case <-ctx.Done():
368 return fmt.Errorf("wait for portal agent status: %w", lastErr)
369 case <-ticker.C:
370 }
371 }
372 }
373
374 func agentCLIInteractive() bool {
375 stdin, err := os.Stdin.Stat()
376 if err != nil || stdin.Mode()&os.ModeCharDevice == 0 {
377 return false
378 }
379 stdout, err := os.Stdout.Stat()
380 return err == nil && stdout.Mode()&os.ModeCharDevice != 0
381 }
382
383 func printAgentUsage(w io.Writer) {
384 utils.WriteCommandUsage(w,
385 []string{
386 "portal agent run [flags]",
387 "portal agent dashboard [flags]",
388 "portal agent stop [flags]",
389 "portal agent restart [flags]",
390 },
391 []string{
392 "portal agent run",
393 "portal agent run --config config.toml --foreground",
394 "portal agent dashboard",
395 "portal agent stop",
396 "portal agent restart",
397 },
398 )
399 }
400
401 func printAgentRunUsage(w io.Writer) {
402 utils.WriteCommandUsage(w,
403 []string{"portal agent run [flags]"},
404 []string{
405 "portal agent run",
406 "portal agent run --config config.toml --foreground",
407 },
408 )
409 }
410
411 func printAgentDashboardUsage(w io.Writer) {
412 utils.WriteCommandUsage(w,
413 []string{"portal agent dashboard [flags]"},
414 []string{
415 "portal agent dashboard",
416 "portal agent dashboard --config config.toml",
417 },
418 )
419 }
420
421 func printAgentStopUsage(w io.Writer) {
422 utils.WriteCommandUsage(w,
423 []string{"portal agent stop [flags]"},
424 []string{
425 "portal agent stop",
426 "portal agent stop --config config.toml",
427 },
428 )
429 }
430
431 func printAgentRestartUsage(w io.Writer) {
432 utils.WriteCommandUsage(w,
433 []string{"portal agent restart [flags]"},
434 []string{
435 "portal agent restart",
436 "portal agent restart --config config.toml",
437 },
438 )
439 }