The «Перенос» tab moves a panel + VPN server to a panel-only server without users noticing: the new panel issues a one-time awgmove:// code, the old one sends its ui.db, subscription keys and hub certificate encrypted with it, and becomes node #0 of the new panel. Server id 0 is kept, so config servers[] is byte-identical. The old server relays its former ports (awg-relay, PROXY v1 header trusted only from RELAY_FROM), so sen:// links, nodes and the API keep working. The same is available from the CLI: migrate export/import/cancel, rehome. Nodes now learn the panel's address: the hub sends it in ready, the agent stores it in CORE_ENDPOINT, dials it first and confirms with core_ok. The new panel shows when the old server can be turned off: devices that got the new address, nodes that confirmed it, master keys never opened. «Серверы для всех» on the master keys tab adds or removes servers on every live key in one go, with a dry run that warns about API keys that would lose sight of some keys, and sets the servers new keys get.
651 lines
21 KiB
Go
651 lines
21 KiB
Go
// 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/envfile"
|
||
"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", "")},
|
||
// IP ретрансляторов (старый сервер после переноса): им — доверие к заголовку PROXY.
|
||
{"RELAY_FROM", env("RELAY_FROM", "")},
|
||
// Когда панель приняла перенос: с этого момента считаются клиенты, узнавшие новый адрес.
|
||
{"MOVED_IN_AT", env("MOVED_IN_AT", "")},
|
||
// Вкладка «Перенос» пишет сюда порты, PANEL_MOVED и RELAY_FROM.
|
||
{"CLI_ENV_FILE", cliEnv},
|
||
// Часовой аудит пиров на всех серверах — только у 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_ENDPOINT", env("CORE_ENDPOINT", "")},
|
||
{"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"),
|
||
},
|
||
// Ретранслятор на сервере, с которого переехала панель: прежние порты → новая панель.
|
||
"relay": {
|
||
name: "relay", label: "awg-relay", cwd: c.root,
|
||
env: [][2]string{{"RELAY", env("RELAY", "")}},
|
||
pidFile: "/tmp/awg-relay.pid", logFile: filepath.Join(c.logs, "awg-relay.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"}
|
||
}
|
||
// Панель переехала (`migrate export`): здесь её больше не запускаем — вторая копия ui.db
|
||
// разошлась бы с перенесённой.
|
||
if c.panelMoved() {
|
||
c.all = without(c.all, "ui")
|
||
}
|
||
if os.Getenv("RELAY") != "" {
|
||
c.all = append(c.all, "relay")
|
||
}
|
||
return c
|
||
}
|
||
|
||
func (c *CLI) panelMoved() bool { return strings.ToLower(os.Getenv("PANEL_MOVED")) == "on" }
|
||
|
||
func without(s []string, v string) []string {
|
||
var out []string
|
||
for _, x := range s {
|
||
if x != v {
|
||
out = append(out, x)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
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
|
||
}
|
||
if s.legacy == "" {
|
||
return false
|
||
}
|
||
// 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
|
||
}
|
||
// Ретранслятор от awg-ctrl не зависит: подписка и панель через него не должны ждать VPN.
|
||
if contains(c.all, "relay") {
|
||
c.start("relay")
|
||
}
|
||
c.start("awgctrl")
|
||
var rest, labels []string
|
||
for _, n := range c.all {
|
||
if n != "awgctrl" && n != "relay" {
|
||
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 { return envfile.Update(content, key, value) }
|
||
|
||
func removeEnvVar(content, key string) string { return envfile.Remove(content, key) }
|
||
|
||
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|rehome <хост[:порт]>|peers [drop <область>]|monitor <install|status|remove>|migrate <export [файл]|import <файл>|cancel>>"
|
||
|
||
// Commands — команды оператора; main передаёт сюда всё, что не роль сервиса.
|
||
var Commands = []string{"start", "stop", "restart", "status", "credentials", "join", "unjoin", "rehome", "peers", "monitor", "migrate"}
|
||
|
||
// 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 "rehome":
|
||
c.rehome(sub, hasSub)
|
||
case "migrate":
|
||
c.migrateCmd(sub, a2, hasA2)
|
||
case "peers":
|
||
c.peersCmd(sub, hasSub, a2, hasA2)
|
||
case "monitor":
|
||
c.monitorCmd(sub)
|
||
default:
|
||
fmt.Println(usage)
|
||
os.Exit(1)
|
||
}
|
||
}
|