Files
pxmon/internal/cli/command_exec.go
T
2026-06-16 21:52:10 +04:00

183 lines
4.1 KiB
Go

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: !<shell command>", 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
}