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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user