Port the CLI to Go: awg-control <start|stop|…>
internal/cli reimplements cli/src: start/stop/restart/status with the awg-ctrl health gate, credentials, join/unjoin, peers [drop], monitor install/status/remove and the interactive menu. Same cli.env, pid files, logs and messages. Without arguments or with an operator command the binary acts as the CLI; ctrl/ui/agent stay service roles. - services are started as the same binary (`awg-control ctrl|ui|agent`), detached into their own session, from the same working directories - processes started by the Node CLI (tsx … index.ts / server.ts) are still recognised, so `stop` and `status` work across the switch - after join the fresh restart gets the original environment, so a rejoin picks up the new cli.env instead of the inherited old CORE_* values - install root: AWG_ROOT, else the nearest parent of the binary with cli/ or .env, else /opt/awg-control Checked end to end on stubbed awg: start/status/stop, credentials, join to the panel's own hub (with and without --no-panel), peers listing and drop, unjoin, menu.
This commit is contained in:
@@ -9,14 +9,16 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"github.com/maeneko/forgetting/internal/agent"
|
"github.com/maeneko/forgetting/internal/agent"
|
||||||
|
"github.com/maeneko/forgetting/internal/cli"
|
||||||
"github.com/maeneko/forgetting/internal/ctrl"
|
"github.com/maeneko/forgetting/internal/ctrl"
|
||||||
"github.com/maeneko/forgetting/internal/ui"
|
"github.com/maeneko/forgetting/internal/ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
// Без аргументов или с командой оператора — CLI (меню, start/stop/…), как прежний awg-ctrl.
|
||||||
if len(os.Args) < 2 {
|
if len(os.Args) < 2 {
|
||||||
fmt.Fprintln(os.Stderr, "usage: awg-control <ctrl|ui|agent|start|stop|restart|status|…>")
|
cli.Main(nil)
|
||||||
os.Exit(1)
|
return
|
||||||
}
|
}
|
||||||
switch os.Args[1] {
|
switch os.Args[1] {
|
||||||
case "ctrl":
|
case "ctrl":
|
||||||
@@ -54,7 +56,6 @@ func main() {
|
|||||||
defer stop()
|
defer stop()
|
||||||
a.Run(ctx)
|
a.Run(ctx)
|
||||||
default:
|
default:
|
||||||
fmt.Fprintf(os.Stderr, "awg-control: неизвестная команда %q\n", os.Args[1])
|
cli.Main(os.Args[1:])
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 <start|stop|restart|status|credentials [user|pass]|join <awgjoin://…> [--no-panel]|unjoin|peers [drop <область>]|monitor <install|status|remove>>"
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <awgjoin://…> [--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 <awgjoin://…> [--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 = «<id панели>/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 для продолжения..."))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <install|status|remove>")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user