package cli import ( "context" "errors" "os/exec" "regexp" "strings" "time" "pxmon/internal/cluster" ) type observerCommandOptions struct { AllowShellEscape bool StatsAutoOnce bool BlockBotRun bool StripANSI bool EmbeddedConsole bool } var ansiEscapeRE = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`) func stripANSI(s string) string { if s == "" { return s } return ansiEscapeRE.ReplaceAllString(s, "") } func runObserverScopedCommand(svc *cluster.Service, configPath, line string, opts observerCommandOptions) (string, int) { out, code := runObserverScopedCommandInner(svc, configPath, line, opts) if opts.StripANSI { out = stripANSI(out) } return out, code } func runObserverScopedCommandInner(svc *cluster.Service, configPath, line string, opts observerCommandOptions) (string, int) { line = strings.TrimSpace(line) if line == "" { return "empty command", 2 } if strings.HasPrefix(line, "!") { if !opts.AllowShellEscape { return "shell escape is disabled for this channel", 2 } cmdline := strings.TrimSpace(strings.TrimPrefix(line, "!")) if cmdline == "" { return "usage: !", 2 } cmd := exec.Command("/bin/sh", "-lc", cmdline) out, err := cmd.CombinedOutput() if err != nil { var exitErr *exec.ExitError if errors.As(err, &exitErr) { return string(out), exitErr.ExitCode() } if len(out) == 0 { return err.Error(), 1 } return string(out), 1 } return string(out), 0 } args, err := parseShellArgs(line) if err != nil || len(args) == 0 { if err != nil { return err.Error(), 2 } return "empty command", 2 } norm := normalizeObserverConsoleArgs(args) if len(norm) == 0 { return "empty command", 2 } if opts.BlockBotRun && len(norm) >= 3 && strings.EqualFold(norm[0], "bot") && strings.EqualFold(norm[1], "telegram") && strings.EqualFold(norm[2], "run") { return "running `bot telegram run` from bot channel is blocked", 2 } if opts.StatsAutoOnce && len(norm) >= 2 && strings.EqualFold(norm[0], "cluster") && strings.EqualFold(norm[1], "stats") && !hasArg(norm[2:], "--once") { norm = append(norm, "--once") } if handled, out, code := runPluginArgs(svc, norm); handled { return out, code } return runObserverCommand(configPath, norm, opts.EmbeddedConsole) } func runPluginArgs(svc *cluster.Service, args []string) (bool, string, int) { if len(args) == 0 || svc == nil { return false, "", 0 } tool := strings.ToLower(strings.TrimSpace(args[0])) switch tool { case "kvm", "lxc", "lxd", "bird", "frr": default: return false, "", 0 } selector, rest, err := parseClusterSelectorArg(args[1:]) if err != nil { return true, err.Error(), 2 } action := "" params := []string{} if len(rest) > 0 { action = strings.ToLower(strings.TrimSpace(rest[0])) params = rest[1:] } if action == "" { switch tool { case "kvm", "lxc", "lxd": action = "list" default: action = "status" } } if tool == "kvm" && action == "top" { for _, p := range params { if strings.EqualFold(strings.TrimSpace(p), "--live") || strings.EqualFold(strings.TrimSpace(p), "-L") { return true, "kvm top --live is removed; use `kvm top` for allocated VM specs", 2 } } } ctx, cancel := context.WithTimeout(context.Background(), pluginActionTimeout(tool, action, false)) defer cancel() out, execErr := svc.RunPluginAction(ctx, selector, tool, action, params) if execErr != nil { return true, execErr.Error(), 1 } if strings.TrimSpace(out) == "" { out = "ok" } return true, out, 0 } func pluginActionTimeout(tool, action string, live bool) time.Duration { tool = strings.ToLower(strings.TrimSpace(tool)) action = strings.ToLower(strings.TrimSpace(action)) switch tool { case "kvm": switch action { case "top": if live { return 2 * time.Minute } return 90 * time.Second case "net-top", "net": if live { return 90 * time.Second } return 60 * time.Second default: return 45 * time.Second } case "lxc", "lxd": if action == "top" || action == "net-top" || action == "net" { return 45 * time.Second } } if live { return 35 * time.Second } return 25 * time.Second }