diff --git a/README.md b/README.md index c5db2cd..4f6d868 100644 --- a/README.md +++ b/README.md @@ -119,14 +119,16 @@ sudo bash -c "$(curl -Ls https://raw.githubusercontent.com/maeneko/forgetting/ma | `UI_PREFIX` | свой секретный путь панели (8–64 символа `A–Z a–z 0–9 _ -`); `off` — без пути | | `SERVER_NAME` | имя VPN-сервера — без вопроса | | `SUB_PORT`, `SUB_TLS`, `NODE_PORT` | порт и HTTPS подписки, порт хаба нод — без вопросов | -| `UNINSTALL` | `y` — удалить, см. ниже | +| `ACTION` | `update`, `reinstall` или `uninstall` — действие без меню | +| `UNINSTALL` | `y` — удалить без меню и без подтверждения | | `AMNEZIA_PPA_SUITE` | серия PPA для Debian (`focal`/`jammy`/`noble`) | | `REPO_BASE`, `REPO_FALLBACK`, `ARCHIVE_URL`, `AWG_LOCAL_TARBALL` | откуда брать архив релиза (своё зеркало, локальный файл) | | `BRAND`, `CHANNEL` | имя продукта в баннерах и панели | ### Обновление и удаление -Повторный запуск установщика на сервере с установкой предлагает: +Повторный запуск установщика на сервере с установкой — или пункты **7–9** меню `awg-ctrl`, или +команды `awg-ctrl update | reinstall | uninstall` — предлагает: 1. **Обновить** — новая версия, настройки, пользователи и роль сохраняются. Установке без секретного пути панели он выдаётся, и установщик печатает новый адрес: закладки и адрес @@ -461,8 +463,12 @@ awg-ctrl monitor install|status|remove # встроенный Prometheus awg-ctrl migrate export [файл] # перенос панели (см. «Несколько серверов») awg-ctrl migrate import <архив> awg-ctrl migrate cancel +awg-ctrl update | reinstall | uninstall # обновить, переустановить, удалить (через install.sh) ``` +`update` и `reinstall` скачивают свежий установщик (`INSTALLER_URL` в `cli.env` — своё +зеркало), `uninstall` берёт его копию из установки и работает без сети. + Через systemd (автозапуск на загрузке): ```bash @@ -485,6 +491,7 @@ systemctl start|stop|restart awg-control | `TRUST_PROXY` | IP или сети reverse-proxy, кроме loopback: от них верим `X-Forwarded-For` | | `METRICS_PEERS` | `on` — метрики по отдельным пирам | | `AUDIT` | `off` — выключить периодический аудит пиров | +| `INSTALLER_URL` | откуда `awg-ctrl update`/`reinstall` берут установщик (своё зеркало) | ## Лицензия [MIT](LICENSE) © 2026 Ivan Vasilev diff --git a/install.sh b/install.sh index a8884f6..4f6be13 100644 --- a/install.sh +++ b/install.sh @@ -13,6 +13,12 @@ set -euo pipefail +# Запущен из меню awg-ctrl — с временной копии (internal/cli/installer.go): убираем её сразу, +# bash дочитает скрипт из уже открытого файла. +case "${BASH_SOURCE[0]:-}" in + */awg-install-[0-9]*.sh) rm -f -- "${BASH_SOURCE[0]}" ;; +esac + # Цвета — настоящие ESC-символы ($'…'), а не '\033' строкой: так они работают и в echo -e, # и в приглашениях read -p. ACC — фиолетовый акцент панели (design.md). Без терминала # (перенаправили в файл) или с NO_COLOR — без цветов. @@ -821,11 +827,22 @@ echo -e " ${DIM}Лог: $LOGFILE${NC}" rule # Поверх существующей установки: обновить (настройки, пользователи и роль остаются), -# переустановить с нуля или удалить совсем (без меню — UNINSTALL=y). Спрашиваем первым — -# от ответа зависит, спрашивать ли роль. +# переустановить с нуля или удалить совсем. Спрашиваем первым — от ответа зависит, спрашивать +# ли роль. Без меню: ACTION=update|reinstall|uninstall (так запускают пункты меню awg-ctrl) +# или UNINSTALL=y — удалить без вопроса-подтверждения. INSTALL_MODE="fresh" +ACTION="${ACTION:-}" +[[ "$ACTION" =~ ^(|update|reinstall|uninstall)$ ]] \ + || fail "ACTION должен быть update, reinstall или uninstall (задано: $ACTION)" if [[ "${UNINSTALL:-}" =~ ^([Yy]|yes|1)$ ]]; then INSTALL_MODE="uninstall" +elif [[ -n "$ACTION" ]]; then + [[ -d "$PROJECT" || "$ACTION" == "uninstall" ]] || fail "Обновлять нечего: в $PROJECT ничего не установлено" + case "$ACTION" in + update) INSTALL_MODE="update" ;; + reinstall) INSTALL_MODE="fresh" ;; + uninstall) INSTALL_MODE="uninstall" ;; + esac elif [[ -d "$PROJECT" ]]; then echo warn "Уже установлено в $PROJECT" @@ -1833,7 +1850,7 @@ rule echo -e " ${YLW}⚠${NC} Если у хостера свой фаервол (панель провайдера, security group) — открой там:" echo -e " ${BLD}${OPEN_PORTS[*]}${NC} ${DIM}иначе клиенты не подключатся, а на сервере всё будет выглядеть исправным${NC}" rule -echo -e " ${DIM}awg-ctrl ${NC} меню: запуск, статус, логин и пароль, join/unjoin" +echo -e " ${DIM}awg-ctrl ${NC} меню: запуск, статус, логин и пароль, обновление и удаление" echo -e " ${DIM}awg-ctrl status ${NC} состояние сервисов и адрес панели" echo -e " ${DIM}systemctl restart awg-control${NC} перезапуск ${DIM}(автозапуск на загрузке включён)${NC}" echo diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 1c70246..82acbe1 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -638,10 +638,10 @@ func (c *CLI) confirmYes(q string) bool { // ── Точка входа ───────────────────────────────────────────────────────────── -const usage = "Usage: cli [--no-panel]|unjoin|rehome <хост[:порт]>|peers [drop <область>]|monitor |migrate |cancel>>" +const usage = "Usage: cli [--no-panel]|unjoin|rehome <хост[:порт]>|peers [drop <область>]|monitor |migrate |cancel>|update|reinstall|uninstall>" // Commands — команды оператора; main передаёт сюда всё, что не роль сервиса. -var Commands = []string{"start", "stop", "restart", "status", "credentials", "join", "unjoin", "rehome", "peers", "monitor", "migrate"} +var Commands = []string{"start", "stop", "restart", "status", "credentials", "join", "unjoin", "rehome", "peers", "monitor", "migrate", "update", "reinstall", "uninstall"} // Main выполняет команду CLI (args — без имени бинарника). Пусто — интерактивное меню. func Main(args []string) { @@ -686,6 +686,9 @@ func Main(args []string) { c.peersCmd(sub, hasSub, a2, hasA2) case "monitor": c.monitorCmd(sub) + case "update", "reinstall", "uninstall": + // Из командной строки — без своего вопроса: установщик сам спросит, что нужно. + c.runInstaller(args[0], false) default: fmt.Println(usage) os.Exit(1) diff --git a/internal/cli/cmds.go b/internal/cli/cmds.go index 79d9f25..24bac25 100644 --- a/internal/cli/cmds.go +++ b/internal/cli/cmds.go @@ -491,9 +491,13 @@ func (c *CLI) interactiveMenu() { %s Сменить логин UI %s Сменить пароль UI %s + %s Обновить + %s Переустановить + %s Удалить + %s %s Выход -`, bold("1"), bold("2"), bold("3"), bold("4"), sep, bold("5"), bold("6"), sep, bold("0")) +`, bold("1"), bold("2"), bold("3"), bold("4"), sep, bold("5"), bold("6"), sep, bold("7"), bold("8"), bold("9"), sep, bold("0")) } for { printMenu() @@ -518,6 +522,12 @@ func (c *CLI) interactiveMenu() { c.setCredentials("user") case "6": c.setCredentials("pass") + case "7": + c.runInstaller("update", true) + case "8": + c.runInstaller("reinstall", true) + case "9": + c.runInstaller("uninstall", true) case "0": os.Exit(0) default: diff --git a/internal/cli/installer.go b/internal/cli/installer.go new file mode 100644 index 0000000..37e9d9e --- /dev/null +++ b/internal/cli/installer.go @@ -0,0 +1,118 @@ +package cli + +// Обновить, переустановить, удалить — из меню awg-ctrl и командами `awg-ctrl update|reinstall| +// uninstall`. Работу делает install.sh (ACTION=… — без его меню): CLI находит скрипт, кладёт во +// временный файл и передаёт ему управление (exec), чтобы диалог шёл в том же терминале. +// +// Скрипт — только из /tmp: install.sh перед заменой файлов убивает процессы, в командной строке +// которых есть путь установки (kill_related). Запущенный из /opt/awg-control/install.sh — или +// текстом через `bash -c` — он убил бы сам себя. + +import ( + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "time" +) + +// Откуда брать свежий установщик; INSTALLER_URL в cli.env — своё зеркало. +const defaultInstallerURL = "https://raw.githubusercontent.com/maeneko/forgetting/main/install.sh" + +var installerActions = map[string]struct{ title, what string }{ + "update": {"Обновить", "новая версия; настройки, пользователи и роль сохранятся"}, + "reinstall": {"Переустановить", "с нуля; данные можно будет сохранить"}, + "uninstall": {"Удалить", "полностью: сервисы, данные, правила фаервола; копию данных предложит сохранить"}, +} + +// fetchInstaller — текст install.sh: для обновления и переустановки — свежий из сети (иначе +// поставилась бы та же версия), для удаления — копия из установки (работает и без сети). +// Копия — запасной путь и для переустановки. +func (c *CLI) fetchInstaller(action string) ([]byte, string, error) { + local := filepath.Join(c.root, "install.sh") + readLocal := func() ([]byte, string, error) { + b, err := os.ReadFile(local) + return b, local, err + } + if action == "uninstall" { + if b, src, err := readLocal(); err == nil { + return b, src, nil + } + } + url := env("INSTALLER_URL", defaultInstallerURL) + cl := &http.Client{Timeout: 30 * time.Second} + resp, err := cl.Get(url) + if err == nil { + defer resp.Body.Close() + if resp.StatusCode == 200 { + b, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if err == nil && strings.HasPrefix(string(b), "#!") { + return b, url, nil + } + } + err = fmt.Errorf("%s: HTTP %d", url, resp.StatusCode) + } + if action == "reinstall" { + if b, src, lerr := readLocal(); lerr == nil { + fmt.Printf(" %s\n", dim("Установщик не скачался ("+err.Error()+") — берём копию из установки, версия та же")) + return b, src, nil + } + } + return nil, "", err +} + +// runInstaller — подтверждение и exec install.sh с ACTION. Возвращается только при отказе или +// ошибке: после exec процесс CLI заменён установщиком. +func (c *CLI) runInstaller(action string, confirm bool) { + a, ok := installerActions[action] + if !ok { + errorf(" Неизвестное действие: %s", action) + return + } + if os.Geteuid() != 0 { + errorf(" Нужен root: sudo awg-ctrl %s", action) + return + } + if confirm && !c.confirmYes(fmt.Sprintf(" %s — %s. Продолжить?", a.title, a.what)) { + fmt.Println(" Отменено") + return + } + script, src, err := c.fetchInstaller(action) + if err != nil { + errorf(" Не удалось получить установщик: %v", err) + errorf(" %s", dim("Запустите его вручную (README, «Установка») или задайте INSTALLER_URL в "+c.envFile)) + return + } + f, err := os.CreateTemp("", "awg-install-*.sh") + if err != nil { + errorf(" %v", err) + return + } + f.Write(script) + f.Close() + fmt.Printf(" %s\n\n", dim("Установщик: "+src)) + bash := "/bin/bash" + if p, err := exec.LookPath("bash"); err == nil { + bash = p + } + // Исходное окружение, без cli.env: иначе UI_PORT, SERVER_NAME и прочие сохранённые значения + // установщик принял бы за явно заданные параметры. + environ := append(withoutKey(c.origEnv, "ACTION"), "ACTION="+action) + if err := syscall.Exec(bash, []string{"bash", f.Name()}, environ); err != nil { + errorf(" Не удалось запустить установщик: %v", err) + } +} + +func withoutKey(environ []string, key string) []string { + out := make([]string, 0, len(environ)+1) + for _, kv := range environ { + if !strings.HasPrefix(kv, key+"=") { + out = append(out, kv) + } + } + return out +} diff --git a/tools/release/package.sh b/tools/release/package.sh index a99eb34..3af0593 100755 --- a/tools/release/package.sh +++ b/tools/release/package.sh @@ -1,6 +1,7 @@ #!/bin/sh # package.sh <версия> — архивы релиза: по одному на архитектуру, awgcontrol-<версия>-linux-.tar.gz. -# Внутри: awg-control (статический Go-бинарник: сервисы и CLI), awg-ui/dist (фронтенд), .env. +# Внутри: awg-control (статический Go-бинарник: сервисы и CLI), awg-ui/dist (фронтенд), .env и +# install.sh (им `awg-ctrl uninstall` удаляет установку без сети). # Фронтенд должен быть собран заранее (npm run build в awg-ui). Общий для GitHub и Gitea. set -eu VERSION="$1" @@ -11,7 +12,7 @@ for ARCH in amd64 arm64; do CGO_ENABLED=0 GOOS=linux GOARCH="$ARCH" go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" \ -o "$D/awg-control" ./cmd/awg-control cp -r awg-ui/dist "$D/awg-ui/dist" - cp .env "$D/" + cp .env install.sh "$D/" tar -czf "awgcontrol-${VERSION}-linux-${ARCH}.tar.gz" "$D/" rm -rf "$D" echo "awgcontrol-${VERSION}-linux-${ARCH}.tar.gz"