diff --git a/cmd/awg-control/main.go b/cmd/awg-control/main.go index 401eebf..66876ab 100644 --- a/cmd/awg-control/main.go +++ b/cmd/awg-control/main.go @@ -9,14 +9,16 @@ import ( "syscall" "github.com/maeneko/forgetting/internal/agent" + "github.com/maeneko/forgetting/internal/cli" "github.com/maeneko/forgetting/internal/ctrl" "github.com/maeneko/forgetting/internal/ui" ) func main() { + // Без аргументов или с командой оператора — CLI (меню, start/stop/…), как прежний awg-ctrl. if len(os.Args) < 2 { - fmt.Fprintln(os.Stderr, "usage: awg-control ") - os.Exit(1) + cli.Main(nil) + return } switch os.Args[1] { case "ctrl": @@ -54,7 +56,6 @@ func main() { defer stop() a.Run(ctx) default: - fmt.Fprintf(os.Stderr, "awg-control: неизвестная команда %q\n", os.Args[1]) - os.Exit(1) + cli.Main(os.Args[1:]) } } diff --git a/internal/cli/cli.go b/internal/cli/cli.go new file mode 100644 index 0000000..7a0d264 --- /dev/null +++ b/internal/cli/cli.go @@ -0,0 +1,619 @@ +// Package cli — менеджер процессов Forgetting (порт cli/src): start/stop/restart/status, +// логин и пароль панели, join/unjoin ноды, пиры подписки, встроенный Prometheus и +// интерактивное меню. Сервисы — тот же бинарник с ролью: `awg-control ctrl|ui|agent`. +package cli + +import ( + "bufio" + "errors" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "syscall" + "time" + + "github.com/maeneko/forgetting/internal/jsjson" +) + +// ── Цвета ─────────────────────────────────────────────────────────────────── + +const ( + cReset = "\x1b[0m" + cBold = "\x1b[1m" + cDim = "\x1b[2m" + cGreen = "\x1b[32m" + cGrey = "\x1b[90m" + cBlue = "\x1b[34m" +) + +func bold(s string) string { return cBold + s + cReset } +func green(s string) string { return cGreen + s + cReset } +func grey(s string) string { return cGrey + s + cReset } +func dim(s string) string { return cDim + s + cReset } + +func errorf(f string, a ...any) { fmt.Fprintf(os.Stderr, f+"\n", a...) } + +// ── Окружение ─────────────────────────────────────────────────────────────── + +// findRoot — корень установки: AWG_ROOT, иначе ближайший предок каталога бинарника, где есть +// cli/ или .env, иначе /opt/awg-control. +func findRoot() string { + if r := os.Getenv("AWG_ROOT"); r != "" { + return r + } + if exe, err := os.Executable(); err == nil { + if real, err := filepath.EvalSymlinks(exe); err == nil { + exe = real + } + for d := filepath.Dir(exe); d != "/" && d != "."; d = filepath.Dir(d) { + if exists(filepath.Join(d, "cli")) || exists(filepath.Join(d, ".env")) { + return d + } + } + } + return "/opt/awg-control" +} + +func exists(p string) bool { + _, err := os.Stat(p) + return err == nil +} + +// loadEnvFile — уже заданное окружение не перетираем (??= в Node), поэтому порядок = +// приоритет: реальное окружение → cli.env → .env в корне проекта. +func loadEnvFile(file string) { + raw, err := os.ReadFile(file) + if err != nil { + return + } + for _, line := range strings.Split(string(raw), "\n") { + t := strings.TrimSpace(line) + if t == "" || strings.HasPrefix(t, "#") { + continue + } + i := strings.Index(t, "=") + if i < 0 { + continue + } + k := strings.TrimSpace(t[:i]) + if _, set := os.LookupEnv(k); !set { + os.Setenv(k, strings.TrimSpace(t[i+1:])) + } + } +} + +func env(k, def string) string { + if v, ok := os.LookupEnv(k); ok { + return v + } + return def +} + +// envOr — `process.env.X || def`. +func envOr(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} + +type service struct { + name string // ctrl | ui | agent — роль бинарника + label string + cwd string + legacy string // entry Node-версии: её процессы тоже узнаём (переход с TS) + env [][2]string + pidFile string + logFile string +} + +type CLI struct { + root string + logs string + envFile string + origEnv []string // окружение до чтения cli.env и .env — для свежего перезапуска + exe string + + brand, channel, version string + + svc map[string]*service + all []string + isNode bool + + in *bufio.Reader +} + +func New() *CLI { + c := &CLI{root: findRoot(), origEnv: os.Environ(), in: bufio.NewReader(os.Stdin)} + c.exe, _ = os.Executable() + c.logs = filepath.Join(c.root, "logs") + os.MkdirAll(c.logs, 0o755) + c.envFile = filepath.Join(c.root, "cli", "cli.env") + loadEnvFile(c.envFile) // машинные настройки + loadEnvFile(filepath.Join(c.root, ".env")) // имя панели и версия + + c.brand = envOr("BRAND", "Forgetting") + c.channel = envOr("CHANNEL", "Beta") + c.version = envOr("VERSION", "") + + cliEnv := c.envFile + coreHost := os.Getenv("CORE_HOST") + audit := "off" + if coreHost == "" { + audit = env("AUDIT", "on") + } + c.svc = map[string]*service{ + "awgctrl": { + name: "ctrl", label: "awg-ctrl", cwd: filepath.Join(c.root, "awg-ctrl"), legacy: "index.ts", + env: [][2]string{ + {"PORT", env("AWGCTRL_PORT", "3005")}, + {"SERVER_IP", env("SERVER_IP", "")}, + {"SERVER_PORT", env("SERVER_PORT", "47619")}, + {"SERVER_NAME", env("SERVER_NAME", "VPN")}, + // Публичный ключ внутренней авторизации (awg-ctrl проверяет им токены awg-ui). + {"INTERNAL_AUTH_PUB_FILE", env("INTERNAL_AUTH_PUB_FILE", "/etc/amnezia/amneziawg/internal_auth_public.key")}, + }, + pidFile: "/tmp/awg-ctrl.pid", logFile: filepath.Join(c.logs, "awg-ctrl.log"), + }, + "ui": { + name: "ui", label: "awg-ui", cwd: filepath.Join(c.root, "awg-ui"), legacy: "server.ts", + env: [][2]string{ + {"PORT", env("UI_PORT", "8080")}, + {"AWGCTRL_PORT", env("AWGCTRL_PORT", "3005")}, + {"INTERNAL_AUTH_KEY_FILE", env("INTERNAL_AUTH_KEY_FILE", "/etc/amnezia/amneziawg/internal_auth_private.key")}, + {"BRAND", c.brand}, {"CHANNEL", c.channel}, {"VERSION", c.version}, + {"UI_USER", env("UI_USER", "admin")}, + {"UI_PASS", env("UI_PASS", "")}, + {"JWT_SECRET", env("JWT_SECRET", "")}, + // ui.db в каталоге данных AWG (вне проекта) — переживает переустановку. + {"UI_DB_FILE", env("UI_DB_FILE", "/etc/amnezia/amneziawg/ui.db")}, + // Подписка sen://: без SUB_PORT или ключа подписи awg-ui подписку не поднимает. + {"SUB_PORT", env("SUB_PORT", "")}, + {"SUB_TLS", env("SUB_TLS", "off")}, + {"SUB_SIGN_KEY_FILE", env("SUB_SIGN_KEY_FILE", "/etc/amnezia/amneziawg/sub_sign.key")}, + {"SUB_TLS_KEY_FILE", env("SUB_TLS_KEY_FILE", "/etc/amnezia/amneziawg/sub_tls.key")}, + {"SUB_TLS_CERT_FILE", env("SUB_TLS_CERT_FILE", "/etc/amnezia/amneziawg/sub_tls.crt")}, + {"SUB_HOST", env("SUB_HOST", "")}, + // Приём нод (core): без NODE_PORT хаб выключен. LOCAL_NODE=off — core без своего VPN. + {"NODE_PORT", env("NODE_PORT", "")}, + // Часовой аудит пиров на всех серверах — только у core. + {"AUDIT", audit}, + {"METRICS_PEERS", env("METRICS_PEERS", "off")}, + {"LOCAL_NODE", env("LOCAL_NODE", "on")}, + {"NODE_TLS_KEY_FILE", env("NODE_TLS_KEY_FILE", "/etc/amnezia/amneziawg/node_tls.key")}, + {"NODE_TLS_CERT_FILE", env("NODE_TLS_CERT_FILE", "/etc/amnezia/amneziawg/node_tls.crt")}, + }, + pidFile: "/tmp/awg-ui.pid", logFile: filepath.Join(c.logs, "awg-ui.log"), + }, + // Агент ноды: сам звонит в core и исполняет запросы на локальном awg-ctrl. + "agent": { + name: "agent", label: "awg-agent", cwd: filepath.Join(c.root, "awg-agent"), legacy: "index.ts", + env: [][2]string{ + {"CORE_HOST", env("CORE_HOST", "")}, + {"CORE_PORT", env("CORE_PORT", "")}, + {"CORE_PIN", env("CORE_PIN", "")}, + {"NODE_ID", env("NODE_ID", "")}, + {"NODE_KEY_FILE", env("NODE_KEY_FILE", "/etc/amnezia/amneziawg/node.key")}, + {"JOIN_SECRET", env("JOIN_SECRET", "")}, + {"CLI_ENV_FILE", cliEnv}, + {"AWGCTRL_PORT", env("AWGCTRL_PORT", "3005")}, + {"INTERNAL_AUTH_KEY_FILE", env("INTERNAL_AUTH_KEY_FILE", "/etc/amnezia/amneziawg/internal_auth_private.key")}, + }, + pidFile: "/tmp/awg-agent.pid", logFile: filepath.Join(c.logs, "awg-agent.log"), + }, + } + + // Роль установки. node — VPN-сервер без панели (есть CORE_HOST, его пишет `join`); core с + // LOCAL_NODE=off — панель без своего VPN; иначе — обычная установка. Нода со своей панелью + // (join без --no-panel) держит и awg-ui: обе панели ходят в один awg-ctrl. + c.isNode = coreHost != "" + noLocal := strings.ToLower(env("LOCAL_NODE", "on")) == "off" + nodePanel := c.isNode && os.Getenv("UI_PASS") != "" && strings.ToLower(env("NODE_PANEL", "on")) != "off" + switch { + case c.isNode && nodePanel: + c.all = []string{"awgctrl", "ui", "agent"} + case c.isNode: + c.all = []string{"awgctrl", "agent"} + case noLocal: + c.all = []string{"ui"} + default: + c.all = []string{"awgctrl", "ui"} + } + return c +} + +func (c *CLI) envOf(name string) string { + for _, kv := range c.svc[name].env { + if kv[0] == "PORT" { + return kv[1] + } + } + return "" +} + +func (c *CLI) svcEnv(name, key string) string { + for _, kv := range c.svc[name].env { + if kv[0] == key { + return kv[1] + } + } + return "" +} + +// ── Процессы ──────────────────────────────────────────────────────────────── + +func (c *CLI) readPid(name string) int { + raw, err := os.ReadFile(c.svc[name].pidFile) + if err != nil { + return 0 + } + n, err := strconv.Atoi(firstInt(strings.TrimSpace(string(raw)))) + if err != nil { + return 0 + } + return n +} + +var reLeadInt = regexp.MustCompile(`^[+-]?\d+`) + +// firstInt — parseInt: ведущее целое, остальное игнорируется. +func firstInt(s string) string { return reLeadInt.FindString(s) } + +// isAlive — EPERM: процесс есть, просто не наш (считаем живым). +func isAlive(pid int) bool { + if pid <= 0 { + return false + } + err := syscall.Kill(pid, 0) + return err == nil || errors.Is(err, syscall.EPERM) +} + +// pidMatches — защита от переиспользования PID: сверяем cmdline с сервисом. Узнаём и Go-сервис +// (`awg-control <роль>`), и процесс Node-версии (entry-файл в cmdline). Без procfs проверить нечем. +func (c *CLI) pidMatches(name string, pid int) bool { + raw, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid)) + if err != nil { + return true + } + args := strings.Split(strings.TrimRight(string(raw), "\x00"), "\x00") + s := c.svc[name] + if len(args) >= 2 && filepath.Base(args[0]) == filepath.Base(c.exe) && args[1] == s.name { + return true + } + // tsx запускается из node_modules сервиса: путь различает index.ts awg-ctrl и awg-agent. + return strings.Contains(string(raw), s.legacy) && strings.Contains(string(raw), filepath.Base(s.cwd)) +} + +func (c *CLI) isRunning(name string) bool { + pid := c.readPid(name) + return pid != 0 && isAlive(pid) && c.pidMatches(name, pid) +} + +func (c *CLI) start(name string) { + s := c.svc[name] + if c.isRunning(name) { + fmt.Printf(" %s: already running pid=%d\n", s.label, c.readPid(name)) + return + } + if name == "awgctrl" { + if f := c.svcEnv("awgctrl", "INTERNAL_AUTH_PUB_FILE"); !exists(f) { + errorf(" awgctrl: публичный ключ внутренней авторизации не найден: %s", f) + os.Exit(1) + } + } + logF, err := os.OpenFile(s.logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + errorf(" %s: не удалось запустить — %v", s.label, err) + return + } + defer logF.Close() // дескриптор унаследован ребёнком — родителю не нужен + cmd := exec.Command(c.exe, s.name) + cmd.Dir = s.cwd + if !exists(s.cwd) { + cmd.Dir = c.root + } + cmd.Env = os.Environ() + for _, kv := range s.env { + cmd.Env = append(cmd.Env, kv[0]+"="+kv[1]) + } + cmd.Stdout, cmd.Stderr = logF, logF + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} // detached: переживает CLI + if err := cmd.Start(); err != nil { + errorf(" %s: не удалось запустить — %v", s.label, err) + os.Remove(s.pidFile) + return + } + go cmd.Wait() // пока CLI жив (меню), не оставляем зомби + os.WriteFile(s.pidFile, []byte(strconv.Itoa(cmd.Process.Pid)), 0o644) + fmt.Printf(" %s %s: started pid=%d\n", green("▶"), s.label, cmd.Process.Pid) +} + +func (c *CLI) stop(name string) { + s := c.svc[name] + pid := c.readPid(name) + if pid == 0 || !isAlive(pid) || !c.pidMatches(name, pid) { + fmt.Printf(" %s: not running\n", s.label) + os.Remove(s.pidFile) + return + } + syscall.Kill(pid, syscall.SIGTERM) + // Ждём мягкого завершения до 5 с, иначе добиваем SIGKILL. + deadline := time.Now().Add(5 * time.Second) + for isAlive(pid) && !isZombie(pid) && time.Now().Before(deadline) { + time.Sleep(100 * time.Millisecond) + } + if isAlive(pid) && !isZombie(pid) { + syscall.Kill(pid, syscall.SIGKILL) + time.Sleep(200 * time.Millisecond) + } + os.Remove(s.pidFile) + fmt.Printf(" %s %s: stopped pid=%d\n", grey("■"), s.label, pid) +} + +// isZombie — процесс завершился, но его ещё не забрал родитель (наш же Wait в меню). +func isZombie(pid int) bool { + raw, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) + if err != nil { + return false + } + s := string(raw) + if i := strings.LastIndex(s, ")"); i >= 0 && i+2 < len(s) { + return s[i+2] == 'Z' + } + return false +} + +// fetchHealth — GET /health и разбор JSON. Любая ошибка — nil. +func fetchHealth(url string) any { + cl := &http.Client{Timeout: time.Second, Transport: &http.Transport{Proxy: nil}} + resp, err := cl.Get(url) + if err != nil { + return nil + } + defer resp.Body.Close() + var b strings.Builder + buf := make([]byte, 4096) + for { + n, err := resp.Body.Read(buf) + b.WriteString(string(buf[:n])) + if err != nil { + break + } + } + v, err := jsjson.Decode([]byte(b.String())) + if err != nil { + return nil + } + return v +} + +func get(v any, keys ...string) any { + for _, k := range keys { + o, ok := v.(jsjson.Object) + if !ok { + return nil + } + v = o.Get2(k) + } + return v +} + +func strOr(v any, def string) string { + if s, ok := v.(string); ok && s != "" { + return s + } + return def +} + +func (c *CLI) serviceLine(name string, menu bool) string { + running := c.isRunning(name) + dot, info := grey("○"), grey("stopped") + if running { + dot = green("●") + if menu { + info = green("running") + dim(fmt.Sprintf(" pid %d", c.readPid(name))) + } else { + info = green("running") + fmt.Sprintf(" pid %d", c.readPid(name)) + } + } + return fmt.Sprintf(" %s %-12s %s", dot, c.svc[name].label, info) +} + +func (c *CLI) status() { + fmt.Println() + for _, n := range c.all { + fmt.Println(c.serviceLine(n, false)) + } + if c.isRunning("awgctrl") { + h := fetchHealth("http://127.0.0.1:" + c.envOf("awgctrl") + "/health") + if gen, ok := get(h, "gen").(string); ok && gen != "" { + g := "2.0" + if gen == "3.1" { + g = "3.1" + } + fmt.Println(" " + dim(fmt.Sprintf("AmneziaWG %s · модуль %s · tools %s", g, strOr(get(h, "awg", "module"), "?"), strOr(get(h, "awg", "tools"), "?")))) + } + } + fmt.Println() +} + +// probe — любой HTTP-ответ (даже 401/503) значит, что порт слушается. +func probe(url string) bool { + cl := &http.Client{Timeout: time.Second, Transport: &http.Transport{Proxy: nil}} + resp, err := cl.Get(url) + if err != nil { + return false + } + resp.Body.Close() + return true +} + +// waitForHealth — ждём, пока awg-ctrl начнёт отвечать; умер — сразу выходим. +func (c *CLI) waitForHealth() bool { + url := "http://127.0.0.1:" + c.envOf("awgctrl") + "/health" + for range 10 { + if !c.isRunning("awgctrl") { + return false + } + if probe(url) { + return true + } + time.Sleep(300 * time.Millisecond) + } + return false +} + +func (c *CLI) startAll() { + // Панель без своего VPN: awg-ctrl рядом нет, health-gate не нужен. + if !contains(c.all, "awgctrl") { + for _, n := range c.all { + c.start(n) + } + return + } + c.start("awgctrl") + var rest, labels []string + for _, n := range c.all { + if n != "awgctrl" { + rest = append(rest, n) + labels = append(labels, c.svc[n].label) + } + } + if !c.isRunning("awgctrl") || !c.waitForHealth() { + errorf(" %s awg-ctrl не отвечает — %s не запущен (см. %s)", grey("■"), strings.Join(labels, ", "), c.svc["awgctrl"].logFile) + return + } + for _, n := range rest { + c.start(n) + } +} + +func (c *CLI) stopAll() { + for _, n := range c.all { + c.stop(n) + } +} + +func (c *CLI) restartAll() { + c.stopAll() + c.startAll() +} + +func contains(s []string, v string) bool { + for _, x := range s { + if x == v { + return true + } + } + return false +} + +// ── cli.env ───────────────────────────────────────────────────────────────── + +func updateEnvVar(content, key, value string) string { + re := regexp.MustCompile(`(?m)^` + regexp.QuoteMeta(key) + `=.*$`) + if loc := re.FindStringIndex(content); loc != nil { + return content[:loc[0]] + key + "=" + value + content[loc[1]:] + } + return strings.TrimRightFunc(content, func(r rune) bool { return r == ' ' || r == '\n' || r == '\t' || r == '\r' }) + "\n" + key + "=" + value + "\n" +} + +func removeEnvVar(content, key string) string { + var kept []string + for _, l := range strings.Split(content, "\n") { + if !strings.HasPrefix(l, key+"=") { + kept = append(kept, l) + } + } + return strings.Join(kept, "\n") +} + +func (c *CLI) readEnvFile() string { + raw, _ := os.ReadFile(c.envFile) + return string(raw) +} + +// writeEnvFile — новый файл с правами 0600 (в нём пароль панели); у существующего права не трогаем. +func (c *CLI) writeEnvFile(content string) error { + return os.WriteFile(c.envFile, []byte(content), 0o600) +} + +// restartFresh — роль читается из окружения при старте процесса, поэтому после смены cli.env +// перезапуск — свежим процессом с исходным окружением (без значений из старого cli.env). +func (c *CLI) restartFresh() { + cmd := exec.Command(c.exe, "restart") + cmd.Env = c.origEnv + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr + cmd.Run() +} + +// ── Ввод ──────────────────────────────────────────────────────────────────── + +func (c *CLI) ask(prompt string) string { + fmt.Print(prompt) + line, _ := c.in.ReadString('\n') + return strings.TrimRight(line, "\r\n") +} + +var reYes = regexp.MustCompile(`(?i)^y`) + +func (c *CLI) confirmYes(q string) bool { + return reYes.MatchString(strings.TrimSpace(c.ask(q + " [y/N] "))) +} + +// ── Точка входа ───────────────────────────────────────────────────────────── + +const usage = "Usage: cli [--no-panel]|unjoin|peers [drop <область>]|monitor >" + +// Commands — команды оператора; main передаёт сюда всё, что не роль сервиса. +var Commands = []string{"start", "stop", "restart", "status", "credentials", "join", "unjoin", "peers", "monitor"} + +// Main выполняет команду CLI (args — без имени бинарника). Пусто — интерактивное меню. +func Main(args []string) { + c := New() + if len(args) == 0 { + c.interactiveMenu() + return + } + arg := func(i int) (string, bool) { + if i < len(args) { + return args[i], true + } + return "", false + } + sub, hasSub := arg(1) + a2, hasA2 := arg(2) + switch args[0] { + case "start": + c.startAll() + case "stop": + c.stopAll() + case "restart": + c.restartAll() + case "status": + c.status() + case "credentials": + switch sub { + case "user", "pass": + c.setCredentials(sub) + default: + c.setCredentials("") + } + case "join": + c.joinCore(sub, hasSub, a2, hasA2) + case "unjoin": + c.unjoinCore() + case "peers": + c.peersCmd(sub, hasSub, a2, hasA2) + case "monitor": + c.monitorCmd(sub) + default: + fmt.Println(usage) + os.Exit(1) + } +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go new file mode 100644 index 0000000..9397c07 --- /dev/null +++ b/internal/cli/cli_test.go @@ -0,0 +1,44 @@ +package cli + +import "testing" + +func TestUpdateEnvVar(t *testing.T) { + for _, c := range []struct{ in, key, val, want string }{ + {"", "A", "1", "\nA=1\n"}, + {"A=0\nB=2\n", "A", "1", "A=1\nB=2\n"}, + {"B=2\n\n\n", "A", "1", "B=2\nA=1\n"}, + {"XA=0\nA=0\n", "A", "$&1", "XA=0\nA=$&1\n"}, + {"A=0\nA=9\n", "A", "1", "A=1\nA=9\n"}, // как String.replace без g — только первое + } { + if got := updateEnvVar(c.in, c.key, c.val); got != c.want { + t.Errorf("updateEnvVar(%q, %s, %s) = %q, want %q", c.in, c.key, c.val, got, c.want) + } + } + if got := removeEnvVar("A=1\nJOIN_SECRET=x\nB=2\n", "JOIN_SECRET"); got != "A=1\nB=2\n" { + t.Errorf("removeEnvVar: %q", got) + } +} + +func TestAreaOf(t *testing.T) { + for owner, want := range map[string]string{ + "abcdef123456/m1/d2": "abcdef123456", + "m1/d2": "legacy", + "alice": "", + "ABCDEF123456/m1/d2": "", + "abc/m1/d2": "", + "": "", + } { + if got := areaOf(owner); got != want { + t.Errorf("areaOf(%q) = %q, want %q", owner, got, want) + } + } +} + +func TestPeersWord(t *testing.T) { + for n, want := range map[int]string{0: "пиров", 1: "пир", 2: "пира", 4: "пира", 5: "пиров", 11: "пиров", 12: "пиров", + 21: "пир", 22: "пира", 101: "пир", 111: "пиров", 114: "пиров", 1004: "пира"} { + if got := peersWord(n); got != want { + t.Errorf("peersWord(%d) = %s, want %s", n, got, want) + } + } +} diff --git a/internal/cli/cmds.go b/internal/cli/cmds.go new file mode 100644 index 0000000..f99f1c1 --- /dev/null +++ b/internal/cli/cmds.go @@ -0,0 +1,486 @@ +package cli + +import ( + "bytes" + "crypto/rand" + "encoding/base64" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/maeneko/forgetting/internal/iauth" + "github.com/maeneko/forgetting/internal/jsjson" +) + +var b64url = base64.RawURLEncoding + +// fromB64url — Buffer.from(s, "base64url"): нестрого, как в Node. +func fromB64url(s string) []byte { + s = strings.NewReplacer("+", "-", "/", "_").Replace(strings.TrimRight(s, "=")) + if len(s)%4 == 1 { + s = s[:len(s)-1] + } + b, _ := b64url.DecodeString(s) + return b +} + +// ── join / unjoin ─────────────────────────────────────────────────────────── + +var ( + reJoinHost = regexp.MustCompile(`^[A-Za-z0-9.:_-]{1,253}$`) + reJoinPin = regexp.MustCompile(`^[A-Za-z0-9_-]{43}$`) + reJoinSec = regexp.MustCompile(`^[A-Za-z0-9_-]{22}$`) +) + +func isPosInt(v any, max float64) (int64, bool) { + f, ok := v.(float64) + return int64(f), ok && f == float64(int64(f)) && f > 0 && f < max +} + +// joinCore — `join [--no-panel]`: подключить эту установку к главной панели как +// ноду. Рядом запускается агент; awg-ctrl и пользователи остаются как были. Своя панель +// продолжает работать, если она здесь есть, — с --no-panel она останавливается. +func (c *CLI) joinCore(link string, hasLink bool, flag string, hasFlag bool) { + if !hasLink || !strings.HasPrefix(link, "awgjoin://") || hasFlag && flag != "--no-panel" { + errorf(" Usage: awg-ctrl join [--no-panel] (ссылку даёт панель: «Добавить сервер»)") + os.Exit(1) + } + v, err := jsjson.Decode(fromB64url(link[len("awgjoin://"):])) + j, isObj := v.(jsjson.Object) + if err != nil || !isObj { + errorf(" Ссылка повреждена") + os.Exit(1) + } + h, hok := j.Get2("h").(string) + pin, pinok := j.Get2("pin").(string) + sec, sok := j.Get2("s").(string) + port, pok := isPosInt(j.Get2("p"), 65536) + id, iok := isPosInt(j.Get2("n"), 9007199254740992) + if j.Get2("v") != 1.0 || !hok || !reJoinHost.MatchString(h) || !pok || !pinok || !reJoinPin.MatchString(pin) || + !iok || !sok || !reJoinSec.MatchString(sec) { + errorf(" Ссылка неверного формата или от другой версии панели") + os.Exit(1) + } + if c.isNode && !c.confirmYes(fmt.Sprintf(" Эта нода уже подключена к %s. Перепривязать?", os.Getenv("CORE_HOST"))) { + return + } + + hasPanel := os.Getenv("UI_PASS") != "" + keepPanel := !hasFlag && hasPanel + content := c.readEnvFile() + np := "off" + if keepPanel { + np = "on" + } + for _, kv := range [][2]string{{"CORE_HOST", h}, {"CORE_PORT", fmt.Sprint(port)}, {"CORE_PIN", pin}, {"NODE_ID", fmt.Sprint(id)}, + {"JOIN_SECRET", sec}, {"NODE_PANEL", np}} { + content = updateEnvVar(content, kv[0], kv[1]) + } + c.writeEnvFile(content) + + // Панель останавливается, а пиры её устройств остаются на awg-ctrl — управлять ими больше + // некому. Решает оператор; спрашиваем до остановки, снимаем после (иначе панель вернёт их). + dropPeers := !keepPanel && hasPanel && c.askDropPanelPeers() + if !keepPanel { + c.stop("ui") + } + if dropPeers { + c.dropArea(c.localPanelID()) + } + tail := "" + if keepPanel { + tail = " · своя панель продолжает работать" + } else if hasPanel { + tail = " · своя панель остановлена" + } + fmt.Printf("\n %s Настроено: core %s:%d, нода #%d%s\n", green("✓"), h, port, id, tail) + c.restartFresh() +} + +func (c *CLI) unjoinCore() { + if !c.isNode { + fmt.Println(" Эта установка не подключена к core") + return + } + c.stop("agent") + content := c.readEnvFile() + for _, k := range []string{"CORE_HOST", "CORE_PORT", "CORE_PIN", "NODE_ID", "JOIN_SECRET", "NODE_PANEL"} { + content = removeEnvVar(content, k) + } + c.writeEnvFile(content) + fmt.Printf(" %s Нода отвязана. Удалите её в панели (сервер) и запустите: awg-ctrl restart\n", green("✓")) + // Нода, поставленная install.sh с ролью «нода», панели не имеет — без пароля в неё не войти. + if os.Getenv("UI_PASS") == "" { + fmt.Println(" " + dim("Панель на этой машине не настроена: задайте логин и пароль — awg-ctrl credentials")) + } +} + +// ── Пиры подписки (sen://) на локальном awg-ctrl ──────────────────────────── +// Пира ставит панель, owner = «/m<ключ>/d<устройство>»: id — область панели. Пиры +// без области («m1/d2») выданы до областей. Здесь — ручной рычаг для оператора: панель +// выключена или core пропал насовсем. По времени ничего не удаляется. + +var ( + reArea = regexp.MustCompile(`^[a-z0-9]{8,16}$`) + reLegacyOwner = regexp.MustCompile(`^m\d+/d\d+$`) +) + +func (c *CLI) localPanelID() string { + raw, err := os.ReadFile(filepath.Join(c.root, "awg-ui", "panel.id")) + if err != nil { + return "" + } + if id := strings.TrimSpace(string(raw)); reArea.MatchString(id) { + return id + } + return "" +} + +type ctrlResp struct { + status int + data any +} + +// ctrlCall — запрос к локальному awg-ctrl с тем же короткоживущим токеном, что у панели и агента. +func (c *CLI) ctrlCall(method, path string, body any) (*ctrlResp, error) { + key, err := iauth.LoadPrivate(c.svcEnv("ui", "INTERNAL_AUTH_KEY_FILE")) + if err != nil { + return nil, err + } + var rd io.Reader + if body != nil { + rd = bytes.NewReader(jsjson.MustMarshal(body)) + } + req, err := http.NewRequest(method, "http://127.0.0.1:"+c.envOf("awgctrl")+path, rd) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+iauth.Mint(key, "awg-cli", time.Now())) + req.Header.Set("Content-Type", "application/json") + cl := &http.Client{Timeout: 30 * time.Second, Transport: &http.Transport{Proxy: nil}} + resp, err := cl.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + data, err := jsjson.Decode(raw) + if err != nil { + data = nil + } + return &ctrlResp{resp.StatusCode, data}, nil +} + +type ctrlPeer struct { + pub, ip, owner string + online bool +} + +func ctrlError(r *ctrlResp) error { + if e, ok := get(r.data, "error").(string); ok { + return fmt.Errorf("%s", e) + } + return fmt.Errorf("awg-ctrl ответил %d", r.status) +} + +func (c *CLI) listPeers() ([]ctrlPeer, error) { + r, err := c.ctrlCall("GET", "/api/peers", nil) + if err != nil { + return nil, err + } + if r.status != 200 { + return nil, ctrlError(r) + } + var out []ctrlPeer + list, _ := get(r.data, "peers").([]any) + for _, p := range list { + on, _ := get(p, "online").(bool) + out = append(out, ctrlPeer{pub: strOr(get(p, "pub_key"), ""), ip: strOr(get(p, "ip"), ""), owner: strOr(get(p, "owner"), ""), online: on}) + } + return out, nil +} + +// areaOf — область пира: id панели, «legacy» (до областей) или "" (чужой формат — не наш). +func areaOf(owner string) string { + head := strings.SplitN(owner, "/", 2)[0] + if strings.Contains(owner, "/") && reArea.MatchString(head) { + return head + } + if reLegacyOwner.MatchString(owner) { + return "legacy" + } + return "" +} + +func peersWord(n int) string { + switch { + case n%10 == 1 && n%100 != 11: + return "пир" + case n%10 >= 2 && n%10 <= 4 && !(n%100 >= 12 && n%100 <= 14): + return "пира" + } + return "пиров" +} + +func (c *CLI) askDropPanelPeers() bool { + own := c.localPanelID() + if own == "" { + return false + } + peers, err := c.listPeers() + if err != nil { + fmt.Fprintln(os.Stderr, " "+dim("Не удалось посмотреть пиров подписки: "+err.Error())) + return false + } + n := 0 + for _, p := range peers { + if areaOf(p.owner) == own { + n++ + } + } + if n == 0 { + return false + } + switch strings.ToLower(os.Getenv("NODE_PEERS")) { + case "keep": + return false + case "drop": + return true + } + fmt.Printf("\n На этой панели %d %s подписки (устройства мастер-ключей). Без панели ими некому управлять:\n", n, peersWord(n)) + fmt.Println(" 1) Оставить — продолжат работать, отозвать их можно будет только после unjoin") + fmt.Println(" 2) Отключить — снять их с VPN (ключи останутся в ui.db и вернутся после unjoin)") + return strings.TrimSpace(c.ask(" Выбор [1]: ")) == "2" +} + +// dropArea — снять всю область: для панели — пустым списком через sync, для legacy — по одному. +func (c *CLI) dropArea(area string) { + if area == "legacy" { + peers, err := c.listPeers() + if err != nil { + errorf(" Не удалось снять пиров: %v", err) + return + } + n := 0 + for _, p := range peers { + if areaOf(p.owner) != "legacy" { + continue + } + if _, err := c.ctrlCall("DELETE", "/api/peers/"+b64url.EncodeToString(stdB64(p.pub)), nil); err != nil { + errorf(" Не удалось снять пиров: %v", err) + return + } + n++ + } + fmt.Printf(" %s Снято пиров без области: %d\n", green("✓"), n) + return + } + r, err := c.ctrlCall("PUT", "/api/peers/sync", jsjson.Object{{Key: "owner", Val: area}, {Key: "keep", Val: []any{}}}) + if err == nil && r.status != 200 { + err = ctrlError(r) + } + if err != nil { + errorf(" Не удалось снять пиров: %v", err) + return + } + fmt.Printf(" %s Снято пиров области %s: %s\n", green("✓"), area, jsString(get(r.data, "removed"))) +} + +func stdB64(s string) []byte { + b, err := base64.StdEncoding.DecodeString(s) + if err != nil { + return fromB64url(s) + } + return b +} + +// jsString — `${v}` для вывода числа или строки из ответа. +func jsString(v any) string { + switch x := v.(type) { + case nil: + return "undefined" + case string: + return x + case float64: + return jsjson.Number(x) + } + return string(jsjson.MustMarshal(v)) +} + +func (c *CLI) peersCmd(sub string, hasSub bool, arg string, hasArg bool) { + own := c.localPanelID() + peers, err := c.listPeers() + if err != nil { + errorf(" awg-ctrl недоступен: %v", err) + os.Exit(1) + } + var order []string + areas := map[string][]ctrlPeer{} + for _, p := range peers { + if a := areaOf(p.owner); a != "" { + if _, seen := areas[a]; !seen { + order = append(order, a) + } + areas[a] = append(areas[a], p) + } + } + + if !hasSub { + if len(areas) == 0 { + fmt.Println(" Пиров подписки (sen://) на этом сервере нет") + return + } + fmt.Print("\n Пиры подписки (sen://) на этом сервере:\n\n") + for _, a := range order { + list := areas[a] + mark := "" + if a == own { + mark = "эта панель" + } else if a == "legacy" { + mark = "без области (выданы до областей)" + } + online := 0 + for _, p := range list { + if p.online { + online++ + } + } + fmt.Printf(" %s %4d %-5s онлайн %d %s\n", bold(fmt.Sprintf("%-16s", a)), len(list), peersWord(len(list)), online, dim(mark)) + } + fmt.Printf("\n %s\n", dim("Снять область целиком: awg-ctrl peers drop <область>")) + return + } + if sub != "drop" || !hasArg || arg == "" || !(arg == "legacy" || reArea.MatchString(arg)) { + errorf(" Usage: awg-ctrl peers [drop <область|legacy>]") + os.Exit(1) + } + n := len(areas[arg]) + if n == 0 { + fmt.Printf(" В области %s пиров нет\n", arg) + return + } + // Панель, которая ведёт эту область, вернёт пиров при следующем сведении — снимать бесполезно. + if arg == own && c.isRunning("ui") { + errorf(" Эту область ведёт панель на этой машине — удаляйте мастер-ключи или устройства в ней") + os.Exit(1) + } + fmt.Println(" " + dim("Если панель этой области ещё ведёт сервер, она вернёт пиров при следующем сведении —")) + fmt.Println(" " + dim("сперва удалите сервер в ней (или мастер-ключи).")) + if !c.confirmYes(fmt.Sprintf(" Снять %d %s области %s? Их устройства потеряют доступ к этому серверу.", n, peersWord(n), arg)) { + return + } + c.dropArea(arg) +} + +// ── Логин и пароль панели ─────────────────────────────────────────────────── + +func (c *CLI) setCredentials(field string) { + var user, pass *string + if field == "" || field == "user" { + cur := env("UI_USER", "admin") + u := strings.TrimSpace(c.ask(fmt.Sprintf("\n Логин [%s]: ", cur))) + if u == "" { + u = cur + } + user = &u + } + if field == "" || field == "pass" { + b := make([]byte, 9) + rand.Read(b) + generated := b64url.EncodeToString(b)[:12] + p := strings.TrimSpace(c.ask(fmt.Sprintf(" Пароль [%s]: ", generated))) + if p == "" { + p = generated + } + pass = &p + } + content := c.readEnvFile() + if user != nil { + content = updateEnvVar(content, "UI_USER", *user) + os.Setenv("UI_USER", *user) + } + if pass != nil { + content = updateEnvVar(content, "UI_PASS", *pass) + os.Setenv("UI_PASS", *pass) + } + c.writeEnvFile(content) + if user != nil { + fmt.Printf("\n %s Логин: %s\n", green("✓"), *user) + } + if pass != nil { + fmt.Printf(" %s Пароль: %s\n", green("✓"), *pass) + } + fmt.Printf("\n %s\n", dim("Перезапусти UI чтобы изменения вступили в силу.")) +} + +// ── Интерактивное меню ────────────────────────────────────────────────────── + +func clearScreen() { + if st, err := os.Stdout.Stat(); err == nil && st.Mode()&os.ModeCharDevice != 0 { + fmt.Print("\x1b[1;1H\x1b[0J") + } +} + +func (c *CLI) interactiveMenu() { + printMenu := func() { + clearScreen() + var parts []string + for _, p := range []string{c.brand, c.channel, c.version} { + if p != "" { + parts = append(parts, p) + } + } + fmt.Printf("\n%s\n\n", bold(cBlue+"── "+strings.Join(parts, " ")+" ──"+cReset)) + for _, n := range c.all { + fmt.Println(c.serviceLine(n, true)) + } + sep := dim("──────────────") + fmt.Printf(` + %s Запустить + %s Остановить + %s Перезапустить + %s Статус + %s + %s Сменить логин UI + %s Сменить пароль UI + %s + %s Выход + +`, bold("1"), bold("2"), bold("3"), bold("4"), sep, bold("5"), bold("6"), sep, bold("0")) + } + for { + printMenu() + fmt.Print(" › ") + choice, err := c.in.ReadString('\n') + if err != nil && choice == "" { + fmt.Println() + return // stdin закрыт + } + choice = strings.TrimSpace(choice) + fmt.Println() + switch choice { + case "1": + c.startAll() + case "2": + c.stopAll() + case "3": + c.restartAll() + case "4": + c.status() + case "5": + c.setCredentials("user") + case "6": + c.setCredentials("pass") + case "0": + os.Exit(0) + default: + fmt.Println(grey(" Неверный выбор")) + } + c.ask("\n " + dim("Enter для продолжения...")) + } +} diff --git a/internal/cli/monitor.go b/internal/cli/monitor.go new file mode 100644 index 0000000..da11bbb --- /dev/null +++ b/internal/cli/monitor.go @@ -0,0 +1,265 @@ +package cli + +// `awg-ctrl monitor install|status|remove` — встроенный Prometheus для вкладки «Монитор» (порт +// cli/src/monitor.ts). Работает от root (ставит пакет, пишет в /etc): панель сама пакетов не +// ставит, она только просит (через awg-ctrl) или показывает эту команду. Всё слушает +// 127.0.0.1: наружу Prometheus не торчит, а панель читает его по HTTP на той же машине. + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "regexp" + "strconv" + "strings" + "time" +) + +const ( + awgDir = "/etc/amnezia/amneziawg" + promDir = "/etc/prometheus" + promConf = promDir + "/prometheus.yml" + promToken = promDir + "/forgetting.token" + monitorHashFile = awgDir + "/monitor_token.sha256" // хэш локального токена: его читает awg-ui + promDefaults = "/etc/default/prometheus" + promMarker = "# managed by forgetting" + promListen = "127.0.0.1:9090" + promRetention = "15d" +) + +func mlog(s string) { fmt.Println(" " + s) } + +func mfail(s string) { + fmt.Fprintf(os.Stderr, "\n ✗ %s\n\n", s) + os.Exit(1) +} + +type shResult struct { + ok bool + output string +} + +// sh — команда без stdin; quiet — вывод забираем себе, иначе он идёт в терминал. +func sh(quiet bool, name string, args ...string) shResult { + cmd := exec.Command(name, args...) + cmd.Env = append(os.Environ(), "DEBIAN_FRONTEND=noninteractive") + if quiet { + out, err := cmd.CombinedOutput() + return shResult{err == nil, string(out)} + } + cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + return shResult{cmd.Run() == nil, ""} +} + +var ( + reOSID = regexp.MustCompile(`(?m)^ID=(.*)$`) + reOSIDLike = regexp.MustCompile(`(?m)^ID_LIKE=(.*)$`) + reDebian = regexp.MustCompile(`(?i)debian|ubuntu`) + rePromVer = regexp.MustCompile(`version (\d+)\.(\d+)`) + reArgsLine = regexp.MustCompile(`(?m)^ARGS=.*$`) +) + +func osFamily() string { + raw, err := os.ReadFile("/etc/os-release") + if err != nil { + return "" + } + val := func(re *regexp.Regexp) string { + if m := re.FindStringSubmatch(string(raw)); m != nil { + return strings.ReplaceAll(m[1], `"`, "") + } + return "" + } + return val(reOSID) + " " + val(reOSIDLike) +} + +func promReady() bool { + cl := &http.Client{Timeout: 1500 * time.Millisecond, Transport: &http.Transport{Proxy: nil}} + resp, err := cl.Get("http://" + promListen + "/-/ready") + if err != nil { + return false + } + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + return resp.StatusCode == 200 +} + +func promInstalled() bool { return sh(true, "dpkg", "-s", "prometheus").ok } +func promActive() bool { return sh(true, "systemctl", "is-active", "--quiet", "prometheus").ok } +func promManaged() bool { + raw, err := os.ReadFile(promConf) + return err == nil && strings.Contains(string(raw), promMarker) +} + +// authBlock — новые схемы (authorization.credentials_file) есть с 2.26; в 3.x bearer_token_file убран. +func authBlock() string { + r := sh(true, "prometheus", "--version") + maj, min := 2, 0 + if m := rePromVer.FindStringSubmatch(r.output); m != nil { + maj, _ = strconv.Atoi(m[1]) + min, _ = strconv.Atoi(m[2]) + } + if maj > 2 || maj == 2 && min >= 26 { + return " authorization:\n credentials_file: " + promToken + "\n" + } + return " bearer_token_file: " + promToken + "\n" +} + +// backupStamp — new Date().toISOString() с «:» и «.» → «-». +func backupStamp() string { + return strings.NewReplacer(":", "-", ".", "-").Replace(time.Now().UTC().Format("2006-01-02T15:04:05.000Z")) +} + +func copyFile(src, dst string) { + if raw, err := os.ReadFile(src); err == nil { + os.WriteFile(dst, raw, 0o644) + } +} + +func writePromConfig(uiPort string) { + if exists(promConf) { + copyFile(promConf, promConf+".bak-"+backupStamp()) + } + os.WriteFile(promConf, []byte(promMarker+` +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: forgetting + scheme: http + metrics_path: /metrics +`+authBlock()+` static_configs: + - targets: ['127.0.0.1:`+uiPort+`'] +`), 0o644) +} + +// writePromToken — токен для самого Prometheus. Файл читает пользователь prometheus, в панели лежит только хэш. +func writePromToken() { + b := make([]byte, 24) + rand.Read(b) + token := "awgm_" + b64url.EncodeToString(b) + os.WriteFile(promToken, []byte(token), 0o640) + sh(true, "chown", "root:prometheus", promToken) + sum := sha256.Sum256([]byte(token)) + os.WriteFile(monitorHashFile, []byte(hex.EncodeToString(sum[:])+"\n"), 0o644) +} + +func setPromArgs() { + line := `ARGS="--web.listen-address=` + promListen + ` --storage.tsdb.retention.time=` + promRetention + `"` + raw, _ := os.ReadFile(promDefaults) + text := string(raw) + if text != "" { + copyFile(promDefaults, promDefaults+".bak-"+backupStamp()) + } + if loc := reArgsLine.FindStringIndex(text); loc != nil { + text = text[:loc[0]] + line + text[loc[1]:] + } else { + text = strings.TrimSuffix(text, "\n") + "\n" + line + "\n" + } + os.WriteFile(promDefaults, []byte(text), 0o644) +} + +func (c *CLI) monitorInstall() { + if os.Getuid() != 0 { + mfail("Нужен root: sudo awg-ctrl monitor install") + } + if !reDebian.MatchString(osFamily()) { + mfail("Автоустановка поддерживает только Debian и Ubuntu. На другом дистрибутиве поставьте Prometheus сами и подключите его кнопкой в панели (токен метрик).") + } + uiPort := os.Getenv("UI_PORT") + if uiPort == "" { + mfail("На этой машине нет панели (UI_PORT не задан): Prometheus скрейпит метрики панели.") + } + // Чужой Prometheus не трогаем: перезаписать его prometheus.yml значит сломать чужой мониторинг. + if promInstalled() && exists(promConf) && !promManaged() { + mfail("Prometheus уже установлен и настроен не нами (" + promConf + "). Подключите его кнопкой «Подключить Prometheus» во вкладке «Монитор»: она выдаст токен и готовый конфиг.") + } + + mlog("1/4 установка пакета prometheus") + if !promInstalled() { + if !sh(false, "apt-get", "update", "-qq").ok { + mfail("apt-get update завершился ошибкой") + } + if !sh(false, "apt-get", "install", "-y", "--no-install-recommends", "prometheus").ok { + mfail("apt-get install prometheus завершился ошибкой") + } + } else { + mlog("уже установлен") + } + + mlog("2/4 токен и конфиг") + writePromToken() + writePromConfig(uiPort) + + mlog("3/4 слушаем только " + promListen + ", хранение " + promRetention) + setPromArgs() + + mlog("4/4 запуск") + sh(true, "systemctl", "enable", "prometheus") + if !sh(false, "systemctl", "restart", "prometheus").ok { + mfail("prometheus не запустился: journalctl -u prometheus") + } + for range 30 { + if promReady() { + fmt.Printf("\n ✓ Prometheus работает на %s, метрики панели скрейпятся каждые 15 с.\n Во вкладке «Монитор» графики появятся через минуту-две.\n\n", promListen) + return + } + time.Sleep(time.Second) + } + mfail("Prometheus не ответил за 30 с: journalctl -u prometheus") +} + +func yesNo(b bool, yes, no string) string { + if b { + return yes + } + return no +} + +func monitorStatus() { + ok := promReady() + fmt.Println(" пакет: " + yesNo(promInstalled(), "установлен", "нет")) + fmt.Println(" сервис: " + yesNo(promActive(), "работает", "остановлен")) + fmt.Println(" наш конфиг: " + yesNo(promManaged(), "да", "нет")) + fmt.Println(" готов: " + yesNo(ok, "да, "+promListen, "нет")) +} + +func monitorRemove() { + if os.Getuid() != 0 { + mfail("Нужен root: sudo awg-ctrl monitor remove") + } + // Чужой Prometheus не удаляем: у него могут быть свои данные и задачи, которых мы не ставили. + if promInstalled() && exists(promConf) && !promManaged() { + mfail("Prometheus настроен не нами (" + promConf + "), поэтому панель его не удаляет. Если он не нужен: sudo apt purge prometheus") + } + mlog("1/3 остановка сервиса") + sh(true, "systemctl", "disable", "--now", "prometheus") + mlog("2/3 токен") + os.Remove(promToken) + os.Remove(monitorHashFile) + mlog("3/3 удаление пакета и накопленных данных") + if promInstalled() && !sh(false, "apt-get", "purge", "-y", "prometheus").ok { + mfail("apt-get purge prometheus завершился ошибкой") + } + fmt.Print("\n ✓ Prometheus удалён вместе с накопленными графиками. Панель продолжает отдавать /metrics.\n\n") +} + +func (c *CLI) monitorCmd(sub string) { + switch sub { + case "install": + c.monitorInstall() + case "status": + monitorStatus() + case "remove": + monitorRemove() + default: + fmt.Println("Usage: awg-ctrl monitor ") + os.Exit(1) + } +}