awg-ctrl: update, reinstall and uninstall from the menu

The awg-ctrl menu only started, stopped and changed the panel login; updating
or removing an installation meant finding and running install.sh by hand. The
menu gets "Обновить", "Переустановить" and "Удалить" (7–9), and the same as
`awg-ctrl update | reinstall | uninstall`.

The work stays in install.sh, which now takes ACTION=update|reinstall|uninstall
to skip its own menu. The CLI fetches it — the latest from INSTALLER_URL
(GitHub by default) for update and reinstall, the copy shipped in the release
archive for uninstall, so removal works offline — writes it to a temp file and
execs bash on it in the same terminal. It must not run from /opt/awg-control or
via `bash -c`: kill_related kills every process whose command line holds the
install path, and would kill the installer itself. The CLI passes its original
environment, not the one with cli.env loaded, or the installer would take
UI_PORT, SERVER_NAME and the rest as explicit overrides. The installer deletes
its temp copy on start.

The release archive now includes install.sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013bNGYiDst62wH3ZGTRPFLJ
This commit is contained in:
Claude
2026-10-28 12:50:00 +00:00
parent 1169a0adc7
commit ea07914249
6 changed files with 166 additions and 10 deletions
+9 -2
View File
@@ -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` — без пути | | `UI_PREFIX` | свой секретный путь панели (8–64 символа `A–Z a–z 0–9 _ -`); `off` — без пути |
| `SERVER_NAME` | имя VPN-сервера — без вопроса | | `SERVER_NAME` | имя VPN-сервера — без вопроса |
| `SUB_PORT`, `SUB_TLS`, `NODE_PORT` | порт и HTTPS подписки, порт хаба нод — без вопросов | | `SUB_PORT`, `SUB_TLS`, `NODE_PORT` | порт и HTTPS подписки, порт хаба нод — без вопросов |
| `UNINSTALL` | `y` — удалить, см. ниже | | `ACTION` | `update`, `reinstall` или `uninstall` — действие без меню |
| `UNINSTALL` | `y` — удалить без меню и без подтверждения |
| `AMNEZIA_PPA_SUITE` | серия PPA для Debian (`focal`/`jammy`/`noble`) | | `AMNEZIA_PPA_SUITE` | серия PPA для Debian (`focal`/`jammy`/`noble`) |
| `REPO_BASE`, `REPO_FALLBACK`, `ARCHIVE_URL`, `AWG_LOCAL_TARBALL` | откуда брать архив релиза (своё зеркало, локальный файл) | | `REPO_BASE`, `REPO_FALLBACK`, `ARCHIVE_URL`, `AWG_LOCAL_TARBALL` | откуда брать архив релиза (своё зеркало, локальный файл) |
| `BRAND`, `CHANNEL` | имя продукта в баннерах и панели | | `BRAND`, `CHANNEL` | имя продукта в баннерах и панели |
### Обновление и удаление ### Обновление и удаление
Повторный запуск установщика на сервере с установкой предлагает: Повторный запуск установщика на сервере с установкой — или пункты **7–9** меню `awg-ctrl`, или
команды `awg-ctrl update | reinstall | uninstall` — предлагает:
1. **Обновить** — новая версия, настройки, пользователи и роль сохраняются. Установке без 1. **Обновить** — новая версия, настройки, пользователи и роль сохраняются. Установке без
секретного пути панели он выдаётся, и установщик печатает новый адрес: закладки и адрес секретного пути панели он выдаётся, и установщик печатает новый адрес: закладки и адрес
@@ -461,8 +463,12 @@ awg-ctrl monitor install|status|remove # встроенный Prometheus
awg-ctrl migrate export [файл] # перенос панели (см. «Несколько серверов») awg-ctrl migrate export [файл] # перенос панели (см. «Несколько серверов»)
awg-ctrl migrate import <архив> awg-ctrl migrate import <архив>
awg-ctrl migrate cancel awg-ctrl migrate cancel
awg-ctrl update | reinstall | uninstall # обновить, переустановить, удалить (через install.sh)
``` ```
`update` и `reinstall` скачивают свежий установщик (`INSTALLER_URL` в `cli.env` — своё
зеркало), `uninstall` берёт его копию из установки и работает без сети.
Через systemd (автозапуск на загрузке): Через systemd (автозапуск на загрузке):
```bash ```bash
@@ -485,6 +491,7 @@ systemctl start|stop|restart awg-control
| `TRUST_PROXY` | IP или сети reverse-proxy, кроме loopback: от них верим `X-Forwarded-For` | | `TRUST_PROXY` | IP или сети reverse-proxy, кроме loopback: от них верим `X-Forwarded-For` |
| `METRICS_PEERS` | `on` — метрики по отдельным пирам | | `METRICS_PEERS` | `on` — метрики по отдельным пирам |
| `AUDIT` | `off` — выключить периодический аудит пиров | | `AUDIT` | `off` — выключить периодический аудит пиров |
| `INSTALLER_URL` | откуда `awg-ctrl update`/`reinstall` берут установщик (своё зеркало) |
## Лицензия ## Лицензия
[MIT](LICENSE) © 2026 Ivan Vasilev [MIT](LICENSE) © 2026 Ivan Vasilev
+20 -3
View File
@@ -13,6 +13,12 @@
set -euo pipefail 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, # Цвета — настоящие ESC-символы ($'…'), а не '\033' строкой: так они работают и в echo -e,
# и в приглашениях read -p. ACC — фиолетовый акцент панели (design.md). Без терминала # и в приглашениях read -p. ACC — фиолетовый акцент панели (design.md). Без терминала
# (перенаправили в файл) или с NO_COLOR — без цветов. # (перенаправили в файл) или с NO_COLOR — без цветов.
@@ -821,11 +827,22 @@ echo -e " ${DIM}Лог: $LOGFILE${NC}"
rule rule
# Поверх существующей установки: обновить (настройки, пользователи и роль остаются), # Поверх существующей установки: обновить (настройки, пользователи и роль остаются),
# переустановить с нуля или удалить совсем (без меню — UNINSTALL=y). Спрашиваем первым — # переустановить с нуля или удалить совсем. Спрашиваем первым — от ответа зависит, спрашивать
# от ответа зависит, спрашивать ли роль. # ли роль. Без меню: ACTION=update|reinstall|uninstall (так запускают пункты меню awg-ctrl)
# или UNINSTALL=y — удалить без вопроса-подтверждения.
INSTALL_MODE="fresh" INSTALL_MODE="fresh"
ACTION="${ACTION:-}"
[[ "$ACTION" =~ ^(|update|reinstall|uninstall)$ ]] \
|| fail "ACTION должен быть update, reinstall или uninstall (задано: $ACTION)"
if [[ "${UNINSTALL:-}" =~ ^([Yy]|yes|1)$ ]]; then if [[ "${UNINSTALL:-}" =~ ^([Yy]|yes|1)$ ]]; then
INSTALL_MODE="uninstall" 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 elif [[ -d "$PROJECT" ]]; then
echo echo
warn "Уже установлено в $PROJECT" warn "Уже установлено в $PROJECT"
@@ -1833,7 +1850,7 @@ rule
echo -e " ${YLW}⚠${NC} Если у хостера свой фаервол (панель провайдера, security group) — открой там:" echo -e " ${YLW}⚠${NC} Если у хостера свой фаервол (панель провайдера, security group) — открой там:"
echo -e " ${BLD}${OPEN_PORTS[*]}${NC} ${DIM}иначе клиенты не подключатся, а на сервере всё будет выглядеть исправным${NC}" echo -e " ${BLD}${OPEN_PORTS[*]}${NC} ${DIM}иначе клиенты не подключатся, а на сервере всё будет выглядеть исправным${NC}"
rule 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}awg-ctrl status ${NC} состояние сервисов и адрес панели"
echo -e " ${DIM}systemctl restart awg-control${NC} перезапуск ${DIM}(автозапуск на загрузке включён)${NC}" echo -e " ${DIM}systemctl restart awg-control${NC} перезапуск ${DIM}(автозапуск на загрузке включён)${NC}"
echo echo
+5 -2
View File
@@ -638,10 +638,10 @@ func (c *CLI) confirmYes(q string) bool {
// ── Точка входа ───────────────────────────────────────────────────────────── // ── Точка входа ─────────────────────────────────────────────────────────────
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>>" 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>|update|reinstall|uninstall>"
// Commands — команды оператора; main передаёт сюда всё, что не роль сервиса. // 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 — без имени бинарника). Пусто — интерактивное меню. // Main выполняет команду CLI (args — без имени бинарника). Пусто — интерактивное меню.
func Main(args []string) { func Main(args []string) {
@@ -686,6 +686,9 @@ func Main(args []string) {
c.peersCmd(sub, hasSub, a2, hasA2) c.peersCmd(sub, hasSub, a2, hasA2)
case "monitor": case "monitor":
c.monitorCmd(sub) c.monitorCmd(sub)
case "update", "reinstall", "uninstall":
// Из командной строки — без своего вопроса: установщик сам спросит, что нужно.
c.runInstaller(args[0], false)
default: default:
fmt.Println(usage) fmt.Println(usage)
os.Exit(1) os.Exit(1)
+11 -1
View File
@@ -491,9 +491,13 @@ func (c *CLI) interactiveMenu() {
%s Сменить логин UI %s Сменить логин UI
%s Сменить пароль UI %s Сменить пароль UI
%s %s
%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 { for {
printMenu() printMenu()
@@ -518,6 +522,12 @@ func (c *CLI) interactiveMenu() {
c.setCredentials("user") c.setCredentials("user")
case "6": case "6":
c.setCredentials("pass") c.setCredentials("pass")
case "7":
c.runInstaller("update", true)
case "8":
c.runInstaller("reinstall", true)
case "9":
c.runInstaller("uninstall", true)
case "0": case "0":
os.Exit(0) os.Exit(0)
default: default:
+118
View File
@@ -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
}
+3 -2
View File
@@ -1,6 +1,7 @@
#!/bin/sh #!/bin/sh
# package.sh <версия> — архивы релиза: по одному на архитектуру, awgcontrol-<версия>-linux-<arch>.tar.gz. # package.sh <версия> — архивы релиза: по одному на архитектуру, awgcontrol-<версия>-linux-<arch>.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. # Фронтенд должен быть собран заранее (npm run build в awg-ui). Общий для GitHub и Gitea.
set -eu set -eu
VERSION="$1" 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}" \ CGO_ENABLED=0 GOOS=linux GOARCH="$ARCH" go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" \
-o "$D/awg-control" ./cmd/awg-control -o "$D/awg-control" ./cmd/awg-control
cp -r awg-ui/dist "$D/awg-ui/dist" 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/" tar -czf "awgcontrol-${VERSION}-linux-${ARCH}.tar.gz" "$D/"
rm -rf "$D" rm -rf "$D"
echo "awgcontrol-${VERSION}-linux-${ARCH}.tar.gz" echo "awgcontrol-${VERSION}-linux-${ARCH}.tar.gz"