From c0797be74505ccfb80b06677d95599b8c4f80823 Mon Sep 17 00:00:00 2001 From: mrleo1nid Date: Fri, 4 Sep 2026 19:37:53 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D0=B8=20?= =?UTF-8?q?=D0=B8=D0=B7=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D1=84=D0=B5=D0=B9?= =?UTF-8?q?=D1=81=D0=B0=20=D0=B8=20=D1=80=D0=B5=D0=B6=D0=B8=D0=BC=20=D0=BE?= =?UTF-8?q?=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B2?= =?UTF-8?q?=20=D1=83=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D1=89=D0=B8=D0=BA?= =?UTF-8?q?=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Установщик стал идемпотентным: на уже настроенной машине он определяет установленную версию, останавливает службу, меняет бинарник и поднимает её обратно, не трогая конфиг, миры и учётные записи. Зависимости при обновлении не переустанавливаются — это лишние минуты apt. Кнопки «Обновить» и «Перезапустить панель» появились на вкладке «Настройки» вместе с текущей версией и описанием нового релиза. Панель работает от непривилегированного пользователя и не может ни заменить свой бинарник в /usr/local/bin, ни дёрнуть systemctl. Вместо того чтобы расширять её права, установщик кладёт root-скрипт pzmanager-admin и разрешает сервисному пользователю через sudoers ровно эту одну команду. Скрипт сверяет sha256 скачанного бинарника с checksums.txt релиза. Если правила sudo нет, кнопки не будет — панель покажет команду для терминала. Перед обновлением и перезапуском игровой сервер останавливается штатным quit: иначе его погасил бы systemd уже в момент рестарта, и ожидание растянулось бы на таймаут остановки. Co-Authored-By: Claude Opus 5 --- .gitea/workflows/release.yml | 6 +- README.md | 37 ++++- cmd/pzmanager/main.go | 13 ++ deploy/get.sh | 26 +++- deploy/install.sh | 109 ++++++++++++-- deploy/pzmanager-admin.sh | 101 +++++++++++++ internal/api/handlers_update.go | 80 ++++++++++ internal/api/server.go | 8 + internal/updater/updater.go | 243 +++++++++++++++++++++++++++++++ internal/updater/updater_test.go | 59 ++++++++ web/static/app.js | 122 +++++++++++++++- web/static/index.html | 20 +++ web/static/style.css | 34 +++++ 13 files changed, 833 insertions(+), 25 deletions(-) create mode 100644 deploy/pzmanager-admin.sh create mode 100644 internal/api/handlers_update.go create mode 100644 internal/updater/updater.go create mode 100644 internal/updater/updater_test.go diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 09f0458..3e67cc5 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -40,10 +40,12 @@ jobs: # Скрипты установки кладём в релиз: тогда get.sh ставит версию # целиком из одного источника и не зависит от состояния ветки. - cp deploy/install.sh deploy/pzmanager.service deploy/get.sh dist/ + cp deploy/install.sh deploy/pzmanager.service dist/ + cp deploy/get.sh deploy/pzmanager-admin.sh dist/ cd dist - sha256sum pzmanager-linux-* install.sh pzmanager.service get.sh > checksums.txt + sha256sum pzmanager-linux-* install.sh pzmanager.service \ + get.sh pzmanager-admin.sh > checksums.txt cat checksums.txt - name: Опубликовать релиз diff --git a/README.md b/README.md index 20bfc22..fe8909b 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ ротацией, скачиванием и восстановлением (перед восстановлением автоматически снимается страховочная копия). - **Установка и обновление** сервера через SteamCMD прямо из панели. +- **Обновление самой панели** — кнопкой из интерфейса или одной командой в + терминале. ## Как это устроено @@ -81,8 +83,9 @@ curl -fsSL https://gitea.hsrv.site/mrleo1nid/pzmanager/raw/branch/main/deploy/ge └── backups/ архивы ``` -**Обновление** — та же команда: конфиг, миры и учётные записи не трогаются, -меняется только бинарник. +**Обновление** — та же команда или кнопка в панели (вкладка «Настройки» → +«Версия панели»). Конфиг, миры и учётные записи не трогаются, меняется только +бинарник. Дальше: @@ -187,6 +190,36 @@ Mod ID, который нужен серверу, лежит внутри сам `server_name`, `admin_password` и `java_memory` превращаются в первый профиль при первом запуске. +## Обновление + +Панель показывает свою версию и следит за релизами в репозитории: вкладка +«Настройки» → «Версия панели». Если вышла новая версия, там появляется кнопка +**«Обновить до …»**. + +Обновление из интерфейса устроено так. Сама панель работает от +непривилегированного пользователя и не может ни заменить свой бинарник в +`/usr/local/bin`, ни дёрнуть `systemctl`. Поэтому установщик кладёт рядом +маленький root-скрипт `/usr/local/bin/pzmanager-admin` и разрешает сервисному +пользователю ровно его одну команду: + +``` +pzserver ALL=(root) NOPASSWD: /usr/local/bin/pzmanager-admin +``` + +Скрипт качает бинарник нужной архитектуры, сверяет sha256 с `checksums.txt` +релиза, подменяет файл и перезапускает службу. Панель к этим файлам доступа не +имеет — она может только попросить выполнить `update` или `restart`. + +Если правило sudo не установлено (панель поставлена вручную или в системе нет +`sudo`), кнопка не появится, а рядом будет показана команда для терминала: + +```bash +curl -fsSL https://gitea.hsrv.site/mrleo1nid/pzmanager/raw/branch/main/deploy/get.sh | sudo bash +``` + +Обновление и перезапуск панели останавливают игровой сервер — панель делает +это сама, штатной командой `quit`, чтобы мир успел сохраниться. + ## Доступ снаружи Панель управляет сервером, поэтому наружу её лучше не выставлять голой. Два diff --git a/cmd/pzmanager/main.go b/cmd/pzmanager/main.go index 68a5f50..33a0ef1 100644 --- a/cmd/pzmanager/main.go +++ b/cmd/pzmanager/main.go @@ -29,6 +29,10 @@ import ( "github.com/mrleo1nid/pzmanager/web" ) +// defaultRepo — откуда панель узнаёт о новых версиях. Меняется переменной +// окружения PZMANAGER_REPO, если репозиторий переехал или это форк. +const defaultRepo = "https://gitea.hsrv.site/mrleo1nid/pzmanager" + // version подставляется при сборке: -ldflags "-X main.version=v1.2.3". // В сборке из рабочей копии остаётся "dev". var version = "dev" @@ -144,6 +148,7 @@ func serve(cfg *config.Config, users *auth.Store, secureCookies bool) error { Static: static, SecureCookies: secureCookies, SetupToken: setupToken, + Repo: repoURL(), }) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) @@ -274,6 +279,14 @@ func readPassword(prompt string) (string, error) { return string(raw), nil } +// repoURL возвращает адрес репозитория для проверки обновлений. +func repoURL() string { + if env := os.Getenv("PZMANAGER_REPO"); env != "" { + return env + } + return defaultRepo +} + func randomToken() string { buf := make([]byte, 12) if _, err := rand.Read(buf); err != nil { diff --git a/deploy/get.sh b/deploy/get.sh index f7f0b66..75c462b 100644 --- a/deploy/get.sh +++ b/deploy/get.sh @@ -81,7 +81,11 @@ download() { local name="$1" url required="${2:-required}" url="$(asset_url "$name")" if [[ -z "$url" ]]; then - [[ "$required" == "optional" ]] && return 1 + # Явный if, а не "&& return": при set -e короткая форма для обязательного + # файла вернула бы 1 и уронила скрипт молча, не дойдя до die. + if [[ "$required" == "optional" ]]; then + return 1 + fi die "в релизе ${TAG} нет файла ${name}" fi curl -fsSL -o "$name" "$url" || die "не удалось скачать ${name}" @@ -91,15 +95,31 @@ download "$BIN_NAME" download install.sh download pzmanager.service +# Скрипт привилегированных действий появился не сразу: со старым релизом +# ставим без него, панель просто не покажет кнопку обновления. +FILES=("$BIN_NAME" install.sh pzmanager.service) +if download pzmanager-admin.sh optional; then + FILES+=(pzmanager-admin.sh) +fi + # Контрольные суммы обязательны: бинарник ставится с правами root. download checksums.txt echo "==> Проверяю контрольные суммы" -grep -E " (${BIN_NAME}|install\.sh|pzmanager\.service)\$" checksums.txt > expected.txt \ - || die "в checksums.txt нет нужных строк" +: > expected.txt +for name in "${FILES[@]}"; do + # Сравниваем имя целиком через awk: в grep точка из "install.sh" совпала бы + # с любым символом и подтянула чужую строку. + awk -v name="$name" '$NF == name' checksums.txt >> expected.txt + grep -q -F -- "$name" expected.txt \ + || die "в checksums.txt нет строки для ${name}" +done sha256sum -c expected.txt || die "контрольные суммы не сошлись — файлы повреждены" mkdir -p deploy mv install.sh pzmanager.service deploy/ +if [[ -f pzmanager-admin.sh ]]; then + mv pzmanager-admin.sh deploy/ +fi mv "$BIN_NAME" pzmanager chmod +x pzmanager deploy/install.sh diff --git a/deploy/install.sh b/deploy/install.sh index 9849efc..22f51fe 100644 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -1,12 +1,16 @@ #!/usr/bin/env bash -# Установка pzmanager на чистый Ubuntu/Debian. Запускать от root: +# Установка и обновление pzmanager на Ubuntu/Debian. Запускать от root: # # sudo ./deploy/install.sh # +# Скрипт идемпотентен: на уже настроенной машине он обновляет бинарник и +# юнит, не трогая конфиг, миры и учётные записи панели. +# # Переменные окружения: # PZ_HOME куда ставить всё хозяйство (по умолчанию /opt/pzmanager) # PZ_USER системный пользователь (по умолчанию pzserver) -# PZ_LISTEN адрес веб-панели (по умолчанию 127.0.0.1:8080) +# PZ_LISTEN адрес веб-панели (только при первой установке) +# PZ_REPO репозиторий для обновлений (по умолчанию этот) # BINARY путь к собранному бинарнику (по умолчанию ./pzmanager) # # Пример — панель доступна в локальной сети по адресу машины: @@ -16,8 +20,14 @@ set -euo pipefail PZ_USER="${PZ_USER:-pzserver}" PZ_HOME="${PZ_HOME:-/opt/pzmanager}" PZ_LISTEN="${PZ_LISTEN:-127.0.0.1:8080}" +PZ_REPO="${PZ_REPO:-https://gitea.hsrv.site/mrleo1nid/pzmanager}" BINARY="${BINARY:-./pzmanager}" -UNIT_SRC="$(dirname "$0")/pzmanager.service" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +UNIT_SRC="${SCRIPT_DIR}/pzmanager.service" +ADMIN_SRC="${SCRIPT_DIR}/pzmanager-admin.sh" +TARGET_BINARY=/usr/local/bin/pzmanager +ADMIN_BINARY=/usr/local/bin/pzmanager-admin if [[ $EUID -ne 0 ]]; then echo "Запустите скрипт от root: sudo $0" >&2 @@ -29,21 +39,37 @@ if [[ ! -f "$BINARY" ]]; then exit 1 fi -echo "==> Устанавливаю системные зависимости" -apt-get update -qq -# lib32gcc-s1 нужен SteamCMD, остальное — самому серверу PZ. Свою JRE сервер -# приносит с собой, системная нужна как запасной вариант. -apt-get install -y --no-install-recommends \ - ca-certificates curl lib32gcc-s1 libsdl2-2.0-0 openjdk-17-jre-headless +# Определяем, что это: первая установка или обновление. +OLD_VERSION="" +if [[ -x "$TARGET_BINARY" ]]; then + OLD_VERSION="$("$TARGET_BINARY" version 2>/dev/null | awk '{print $2}' || true)" +fi +NEW_VERSION="$("$BINARY" version 2>/dev/null | awk '{print $2}' || echo "?")" + +if [[ -n "$OLD_VERSION" ]]; then + echo "==> Обновление: установлено ${OLD_VERSION}, ставим ${NEW_VERSION}" + MODE=update +else + echo "==> Первая установка версии ${NEW_VERSION}" + MODE=install +fi + +# Зависимости ставим только при первой установке: на обновлении это лишние +# минуты ожидания apt. +if [[ "$MODE" == "install" || "${FORCE_DEPS:-0}" == "1" ]]; then + echo "==> Устанавливаю системные зависимости" + apt-get update -qq + # lib32gcc-s1 нужен SteamCMD, остальное — самому серверу PZ. Свою JRE сервер + # приносит с собой, системная нужна как запасной вариант. + apt-get install -y --no-install-recommends \ + ca-certificates curl sudo lib32gcc-s1 libsdl2-2.0-0 openjdk-17-jre-headless +fi if ! id "$PZ_USER" &>/dev/null; then echo "==> Создаю пользователя $PZ_USER" adduser --system --group --home "$PZ_HOME" --shell /usr/sbin/nologin "$PZ_USER" fi -echo "==> Копирую бинарник в /usr/local/bin/pzmanager" -install -m 0755 "$BINARY" /usr/local/bin/pzmanager - echo "==> Готовлю директории в $PZ_HOME" install -d -o "$PZ_USER" -g "$PZ_USER" -m 0755 \ "$PZ_HOME" \ @@ -52,8 +78,8 @@ install -d -o "$PZ_USER" -g "$PZ_USER" -m 0755 \ "$PZ_HOME/Zomboid" \ "$PZ_HOME/backups" -# Конфиг создаётся только при первой установке: обновление не должно -# затирать настройки, которые уже поправили руками или через панель. +# Конфиг создаётся только при первой установке: обновление не должно затирать +# настройки, которые уже поправили руками или через панель. CONFIG="$PZ_HOME/config.yaml" if [[ -f "$CONFIG" ]]; then echo "==> Конфиг $CONFIG уже есть — оставляю как есть" @@ -92,6 +118,40 @@ EOF chmod 0640 "$CONFIG" fi +# Останавливаем сервис перед заменой бинарника: менеджер должен корректно +# завершить игровой сервер, а не быть подменённым на ходу. +RESTART_AFTER=0 +if [[ "$MODE" == "update" ]] && systemctl is-active --quiet pzmanager; then + echo "==> Останавливаю pzmanager (игровой сервер завершится штатно)" + systemctl stop pzmanager + RESTART_AFTER=1 +fi + +echo "==> Копирую бинарник в $TARGET_BINARY" +install -m 0755 "$BINARY" "$TARGET_BINARY" + +# Панель работает от непривилегированного пользователя и не может ни заменить +# бинарник, ни дёрнуть systemctl. Даём ей ровно одну команду через sudo. +if [[ -f "$ADMIN_SRC" ]] && command -v sudo >/dev/null 2>&1; then + echo "==> Ставлю $ADMIN_BINARY и правило sudo" + sed -e "s|@PZ_REPO@|${PZ_REPO}|" \ + -e "s|@PZ_BINARY@|${TARGET_BINARY}|" \ + "$ADMIN_SRC" > "$ADMIN_BINARY" + chown root:root "$ADMIN_BINARY" + chmod 0755 "$ADMIN_BINARY" + + printf '%s ALL=(root) NOPASSWD: %s\n' "$PZ_USER" "$ADMIN_BINARY" \ + > /etc/sudoers.d/pzmanager + chmod 0440 /etc/sudoers.d/pzmanager + # Битый файл в sudoers.d ломает sudo целиком, поэтому проверяем и откатываем. + if ! visudo -cf /etc/sudoers.d/pzmanager >/dev/null; then + rm -f /etc/sudoers.d/pzmanager + echo "!! Правило sudo отвергнуто, кнопки обновления в панели не будет" >&2 + fi +else + echo "!! $ADMIN_SRC не найден или нет sudo — обновление из панели будет недоступно" >&2 +fi + echo "==> Ставлю systemd-юнит" sed -e "s|/opt/pzmanager|${PZ_HOME}|g" \ -e "s|User=pzserver|User=${PZ_USER}|" \ @@ -99,8 +159,25 @@ sed -e "s|/opt/pzmanager|${PZ_HOME}|g" \ "$UNIT_SRC" > /etc/systemd/system/pzmanager.service systemctl daemon-reload -systemctl enable --now pzmanager +systemctl enable --quiet pzmanager +if [[ "$MODE" == "update" ]]; then + if [[ "$RESTART_AFTER" == "1" ]]; then + systemctl start pzmanager + fi + echo + if [[ "$OLD_VERSION" == "$NEW_VERSION" ]]; then + echo "Переустановлена версия ${NEW_VERSION}." + else + echo "Обновлено: ${OLD_VERSION} -> ${NEW_VERSION}." + fi + echo "Конфиг, миры и учётные записи не тронуты." + LISTEN_NOW="$(grep -E '^listen:' "$CONFIG" | head -1 | awk '{print $2}')" + echo "Панель слушает ${LISTEN_NOW:-?}." + exit 0 +fi + +systemctl start pzmanager # Дадим сервису секунду подняться, чтобы код настройки успел попасть в журнал. sleep 2 @@ -123,6 +200,6 @@ else fi echo "Код первичной настройки — в журнале:" echo -echo " journalctl -u pzmanager -n 30 --no-pager | grep -A2 'код первичной'" +echo " journalctl -u pzmanager -n 30 --no-pager" echo echo "Игровые порты сервера: udp/16261 и udp/16262." diff --git a/deploy/pzmanager-admin.sh b/deploy/pzmanager-admin.sh new file mode 100644 index 0000000..fb1f9a7 --- /dev/null +++ b/deploy/pzmanager-admin.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# Действия, которые панель не может выполнить сама: она работает от +# непривилегированного пользователя, а бинарник и systemd принадлежат root. +# +# Скрипт ставится установщиком в /usr/local/bin/pzmanager-admin (root:root) и +# разрешён сервисному пользователю в /etc/sudoers.d/pzmanager — ровно эта +# команда и ничего больше. Аргументы проверяются здесь: sudo пропускает любые. +# +# Значения ниже подставляет install.sh при установке. +set -euo pipefail + +PZ_REPO="@PZ_REPO@" +PZ_BINARY="@PZ_BINARY@" +PZ_SERVICE="pzmanager" + +die() { echo "Ошибка: $*" >&2; exit 1; } + +api_base() { + local url="${PZ_REPO%/}" + url="${url%.git}" + local host="${url%%://*}://$(printf '%s' "${url#*://}" | cut -d/ -f1)" + printf '%s/api/v1/repos/%s' "$host" "${url#"$host"/}" +} + +cmd_update() { + local version="${1:-latest}" + local api release tag url name tmp arch + + case "$(uname -m)" in + x86_64 | amd64) arch=amd64 ;; + aarch64 | arm64) arch=arm64 ;; + *) die "архитектура $(uname -m) не поддерживается" ;; + esac + + api="$(api_base)" + if [[ "$version" == "latest" ]]; then + release="$(curl -fsSL "${api}/releases/latest")" || die "не удалось запросить релиз" + else + # Тег приходит из панели; всё, кроме безопасных символов, отвергаем. + [[ "$version" =~ ^[A-Za-z0-9._-]+$ ]] || die "недопустимая версия: $version" + release="$(curl -fsSL "${api}/releases/tags/${version}")" || die "релиз ${version} не найден" + fi + + tag="$(printf '%s' "$release" | grep -o '"tag_name" *: *"[^"]*"' | head -1 | cut -d'"' -f4)" + [[ -n "$tag" ]] || die "в ответе нет тега" + + name="pzmanager-linux-${arch}" + url="$(printf '%s' "$release" \ + | grep -o '"browser_download_url" *: *"[^"]*"' | cut -d'"' -f4 \ + | while read -r candidate; do + [[ "${candidate##*/}" == "$name" ]] && { printf '%s' "$candidate"; break; } + done)" + [[ -n "$url" ]] || die "в релизе ${tag} нет файла ${name}" + + local sums_url + sums_url="$(printf '%s' "$release" \ + | grep -o '"browser_download_url" *: *"[^"]*"' | cut -d'"' -f4 \ + | while read -r candidate; do + [[ "${candidate##*/}" == "checksums.txt" ]] && { printf '%s' "$candidate"; break; } + done)" + [[ -n "$sums_url" ]] || die "в релизе ${tag} нет checksums.txt" + + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + + echo "==> Скачиваю ${tag} (${arch})" + curl -fsSL -o "${tmp}/${name}" "$url" || die "не удалось скачать ${name}" + curl -fsSL -o "${tmp}/checksums.txt" "$sums_url" || die "не удалось скачать checksums.txt" + + echo "==> Проверяю контрольную сумму" + ( cd "$tmp" && grep " ${name}\$" checksums.txt > expected.txt && sha256sum -c expected.txt ) \ + || die "контрольная сумма не сошлась — файл повреждён или подменён" + + echo "==> Заменяю ${PZ_BINARY}" + chmod 0755 "${tmp}/${name}" + # Через временный файл рядом и rename: перезаписать работающий бинарник + # нельзя, а переименовать поверх — можно. + install -m 0755 "${tmp}/${name}" "${PZ_BINARY}.new" + mv -f "${PZ_BINARY}.new" "${PZ_BINARY}" + + echo "==> Перезапускаю ${PZ_SERVICE}" + systemctl restart "$PZ_SERVICE" + echo "Обновлено до ${tag}" +} + +case "${1:-}" in + update) + cmd_update "${2:-latest}" + ;; + restart) + echo "==> Перезапускаю ${PZ_SERVICE}" + systemctl restart "$PZ_SERVICE" + ;; + version) + "$PZ_BINARY" version + ;; + *) + echo "Использование: pzmanager-admin update [версия] | restart | version" >&2 + exit 2 + ;; +esac diff --git a/internal/api/handlers_update.go b/internal/api/handlers_update.go new file mode 100644 index 0000000..fe183b5 --- /dev/null +++ b/internal/api/handlers_update.go @@ -0,0 +1,80 @@ +package api + +import ( + "context" + "errors" + "net/http" + "time" + + "github.com/mrleo1nid/pzmanager/internal/pzserver" + "github.com/mrleo1nid/pzmanager/internal/updater" +) + +// handleCheckUpdate спрашивает у репозитория последнюю версию. +func (s *Server) handleCheckUpdate(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, s.updater.Check(r.Context())) +} + +// handleApplyUpdate качает новую версию и перезапускает панель. Ответ уходит +// раньше, чем работа заканчивается: обновление убивает текущий процесс. +func (s *Server) handleApplyUpdate(w http.ResponseWriter, r *http.Request) { + var body struct { + Version string `json:"version"` + } + if r.ContentLength > 0 && !decodeJSON(w, r, &body) { + return + } + + if !s.updater.CanApply(r.Context()) { + writeError(w, http.StatusPreconditionFailed, updater.ErrNoAdmin.Error()) + return + } + + name := "Обновление панели" + err := s.jobs.Start(name, func(ctx context.Context, log func(string)) error { + // Игровой сервер останавливаем сами: иначе это сделает systemd уже в + // момент перезапуска, и ожидание растянется на таймаут остановки. + s.stopServerBefore(log, "обновлением") + log("Запускаю обновление, панель перезапустится") + return s.updater.Apply(ctx, log, body.Version) + }) + if err != nil { + writeOpError(w, err) + return + } + writeJSON(w, http.StatusAccepted, map[string]any{"task": name}) +} + +// handleRestartManager перезапускает саму панель. +func (s *Server) handleRestartManager(w http.ResponseWriter, r *http.Request) { + if !s.updater.CanApply(r.Context()) { + writeError(w, http.StatusPreconditionFailed, updater.ErrNoAdmin.Error()) + return + } + + name := "Перезапуск панели" + err := s.jobs.Start(name, func(ctx context.Context, log func(string)) error { + s.stopServerBefore(log, "перезапуском") + log("Перезапускаю панель") + return s.updater.Restart(ctx, log) + }) + if err != nil { + writeOpError(w, err) + return + } + writeJSON(w, http.StatusAccepted, map[string]any{"task": name}) +} + +// stopServerBefore корректно гасит игровой сервер перед тем, как панель уйдёт +// в перезапуск: мир сохраняется, а systemd не ждёт таймаут остановки. +func (s *Server) stopServerBefore(log func(string), what string) { + if s.sup.State() == pzserver.StateStopped { + return + } + log("Останавливаю игровой сервер перед " + what) + if err := s.sup.Stop(); err != nil && !errors.Is(err, pzserver.ErrNotRunning) { + log("Остановить сервер не удалось: " + err.Error()) + return + } + s.sup.WaitStopped(30 * time.Second) +} diff --git a/internal/api/server.go b/internal/api/server.go index ed6293a..14075ab 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -17,6 +17,7 @@ import ( "github.com/mrleo1nid/pzmanager/internal/pzconfig" "github.com/mrleo1nid/pzmanager/internal/pzserver" "github.com/mrleo1nid/pzmanager/internal/steamcmd" + "github.com/mrleo1nid/pzmanager/internal/updater" ) // csrfHeader — заголовок, который обязан прислать клиент при изменяющих @@ -34,6 +35,7 @@ type Server struct { backups *backup.Manager steam *steamcmd.Client workshop *pzconfig.WorkshopClient + updater *updater.Updater static fs.FS // secureCookies включает флаг Secure у cookie сессии (панель за HTTPS). secureCookies bool @@ -55,6 +57,8 @@ type Options struct { Static fs.FS SecureCookies bool SetupToken string + // Repo — репозиторий, из которого панель берёт обновления. + Repo string } // New собирает HTTP-обработчик панели. @@ -68,6 +72,7 @@ func New(o Options) *Server { backups: o.Backups, steam: o.Steam, workshop: pzconfig.NewWorkshopClient(), + updater: updater.New(o.Repo, o.Version), static: o.Static, secureCookies: o.SecureCookies, setupToken: o.SetupToken, @@ -114,6 +119,9 @@ func (s *Server) Handler() http.Handler { "GET /api/backups/{name}/download": s.handleDownloadBackup, "POST /api/backups/{name}/restore": s.handleRestoreBackup, "POST /api/install": s.handleInstall, + "GET /api/update": s.handleCheckUpdate, + "POST /api/update": s.handleApplyUpdate, + "POST /api/manager/restart": s.handleRestartManager, "GET /api/task": s.handleTaskStatus, "POST /api/task/cancel": s.handleTaskCancel, "GET /api/users": s.handleListUsers, diff --git a/internal/updater/updater.go b/internal/updater/updater.go new file mode 100644 index 0000000..0d7fad1 --- /dev/null +++ b/internal/updater/updater.go @@ -0,0 +1,243 @@ +// Package updater проверяет наличие новой версии панели в Gitea и запускает +// обновление. +// +// Сама панель работает от непривилегированного пользователя и не может ни +// заменить свой бинарник в /usr/local/bin, ни дёрнуть systemctl. Поэтому +// привилегированные действия вынесены в отдельный root-скрипт +// pzmanager-admin, разрешённый сервисному пользователю через sudoers; здесь +// только его вызов. +package updater + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "os/exec" + "strconv" + "strings" + "time" +) + +// adminCommand — имя root-скрипта, который ставит установщик. +const adminCommand = "pzmanager-admin" + +// ErrNoAdmin возвращается, когда обновление из панели недоступно. +var ErrNoAdmin = errors.New("обновление из панели недоступно: нет pzmanager-admin или правила sudo") + +// Info — то, что панель показывает в разделе обновления. +type Info struct { + Current string `json:"current"` + Latest string `json:"latest"` + Notes string `json:"notes"` + Published int64 `json:"published"` + URL string `json:"url"` + // Available — есть ли версия новее установленной. + Available bool `json:"available"` + // CanApply — сможет ли панель обновиться сама. + CanApply bool `json:"can_apply"` + // ManualCommand — что выполнить в терминале, если сама не может. + ManualCommand string `json:"manual_command"` + // Error — почему не удалось узнать последнюю версию. + Error string `json:"error,omitempty"` +} + +// Updater знает, где искать релизы и как позвать привилегированный скрипт. +type Updater struct { + // Repo — адрес репозитория вида https://gitea.example/owner/repo. + Repo string + // Version — версия текущей сборки. + Version string + HTTP *http.Client +} + +// New создаёт обновлятор. +func New(repo, version string) *Updater { + return &Updater{ + Repo: strings.TrimSuffix(strings.TrimSuffix(repo, "/"), ".git"), + Version: version, + HTTP: &http.Client{Timeout: 15 * time.Second}, + } +} + +// apiBase превращает адрес репозитория в базовый адрес API Gitea. +func (u *Updater) apiBase() (string, error) { + scheme, rest, found := strings.Cut(u.Repo, "://") + if !found { + return "", fmt.Errorf("не разобран адрес репозитория %q", u.Repo) + } + host, path, found := strings.Cut(rest, "/") + if !found || path == "" { + return "", fmt.Errorf("в адресе %q нет владельца и репозитория", u.Repo) + } + return fmt.Sprintf("%s://%s/api/v1/repos/%s", scheme, host, path), nil +} + +// Check спрашивает у Gitea последний релиз и сравнивает его с текущей версией. +func (u *Updater) Check(ctx context.Context) Info { + info := Info{ + Current: u.Version, + CanApply: u.CanApply(ctx), + ManualCommand: u.manualCommand(), + } + + api, err := u.apiBase() + if err != nil { + info.Error = err.Error() + return info + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, api+"/releases/latest", nil) + if err != nil { + info.Error = err.Error() + return info + } + resp, err := u.HTTP.Do(req) + if err != nil { + info.Error = "не удалось связаться с " + u.Repo + return info + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + info.Error = "в репозитории ещё нет релизов" + return info + } + if resp.StatusCode != http.StatusOK { + info.Error = "репозиторий ответил " + resp.Status + return info + } + + var release struct { + TagName string `json:"tag_name"` + Body string `json:"body"` + HTMLURL string `json:"html_url"` + PublishedAt time.Time `json:"published_at"` + } + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + info.Error = "ответ репозитория не разобран: " + err.Error() + return info + } + + info.Latest = release.TagName + info.Notes = release.Body + info.URL = release.HTMLURL + if !release.PublishedAt.IsZero() { + info.Published = release.PublishedAt.Unix() + } + info.Available = IsNewer(u.Version, release.TagName) + return info +} + +// CanApply проверяет, что привилегированный скрипт на месте и sudo пускает к +// нему без пароля. Проверяем вызовом: наличие файла ещё не значит, что +// правило sudoers действует. +func (u *Updater) CanApply(ctx context.Context) bool { + if _, err := exec.LookPath("sudo"); err != nil { + return false + } + if _, err := exec.LookPath(adminCommand); err != nil { + return false + } + + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + return exec.CommandContext(ctx, "sudo", "-n", adminCommand, "version").Run() == nil +} + +func (u *Updater) manualCommand() string { + return "curl -fsSL " + u.Repo + "/raw/branch/main/deploy/get.sh | sudo bash" +} + +// Apply обновляет панель до указанной версии (пустая строка — до последней). +// Скрипт перезапускает сервис, поэтому вызов обычно не возвращается: процесс +// получает SIGTERM в середине работы. +func (u *Updater) Apply(ctx context.Context, log func(string), version string) error { + if !u.CanApply(ctx) { + return ErrNoAdmin + } + args := []string{"-n", adminCommand, "update"} + if version != "" { + args = append(args, version) + } + return u.run(ctx, log, args) +} + +// Restart перезапускает сервис панели. +func (u *Updater) Restart(ctx context.Context, log func(string)) error { + if !u.CanApply(ctx) { + return ErrNoAdmin + } + return u.run(ctx, log, []string{"-n", adminCommand, "restart"}) +} + +// run выполняет sudo-команду, отдавая её вывод построчно в log. +func (u *Updater) run(ctx context.Context, log func(string), args []string) error { + cmd := exec.CommandContext(ctx, "sudo", args...) + stdout, err := cmd.StdoutPipe() + if err != nil { + return err + } + cmd.Stderr = cmd.Stdout + if err := cmd.Start(); err != nil { + return err + } + + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + log(scanner.Text()) + } + return cmd.Wait() +} + +// IsNewer сравнивает версии вида v1.2.3. Сборка без версии ("dev") считается +// всегда устаревшей: понять, что в ней собрано, всё равно нельзя. +func IsNewer(current, latest string) bool { + if latest == "" { + return false + } + if current == "" || current == "dev" { + return true + } + if current == latest { + return false + } + + cur, curOK := parseVersion(current) + lat, latOK := parseVersion(latest) + if !curOK || !latOK { + // Нестандартные версии сравнивать бессмысленно; считаем, что новее + // то, что отличается от текущего. + return current != latest + } + for i := range cur { + if cur[i] != lat[i] { + return lat[i] > cur[i] + } + } + return false +} + +// parseVersion разбирает "v1.2.3" (и "1.2") в три числа. +func parseVersion(v string) ([3]int, bool) { + var out [3]int + v = strings.TrimPrefix(strings.TrimSpace(v), "v") + // Суффиксы вида -rc1 в сравнении чисел не участвуют. + if idx := strings.IndexAny(v, "-+"); idx != -1 { + v = v[:idx] + } + parts := strings.Split(v, ".") + if len(parts) == 0 || len(parts) > 3 { + return out, false + } + for i, part := range parts { + n, err := strconv.Atoi(part) + if err != nil { + return out, false + } + out[i] = n + } + return out, true +} diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go new file mode 100644 index 0000000..ae43f6b --- /dev/null +++ b/internal/updater/updater_test.go @@ -0,0 +1,59 @@ +package updater + +import "testing" + +func TestIsNewer(t *testing.T) { + cases := []struct { + current, latest string + want bool + }{ + {"v1.0.0", "v1.0.1", true}, + {"v1.0.0", "v1.1.0", true}, + {"v1.0.0", "v2.0.0", true}, + {"v1.2.3", "v1.2.3", false}, + {"v1.2.3", "v1.2.2", false}, + {"v2.0.0", "v1.9.9", false}, + // Сборка из рабочей копии не знает, что в ней, — предлагаем релиз. + {"dev", "v1.0.0", true}, + {"", "v1.0.0", true}, + // Без релизов обновляться некуда. + {"v1.0.0", "", false}, + // Разное число компонентов. + {"v1.0", "v1.0.1", true}, + {"1.0.0", "v1.0.0", false}, + // Предрелизные суффиксы в сравнении чисел не участвуют. + {"v1.0.0-rc1", "v1.0.0", false}, + {"v1.0.0", "v1.0.1-rc1", true}, + } + + for _, c := range cases { + if got := IsNewer(c.current, c.latest); got != c.want { + t.Errorf("IsNewer(%q, %q) = %v, ожидалось %v", c.current, c.latest, got, c.want) + } + } +} + +func TestAPIBase(t *testing.T) { + cases := map[string]string{ + "https://gitea.hsrv.site/mrleo1nid/pzmanager": "https://gitea.hsrv.site/api/v1/repos/mrleo1nid/pzmanager", + "https://gitea.hsrv.site/mrleo1nid/pzmanager.git": "https://gitea.hsrv.site/api/v1/repos/mrleo1nid/pzmanager", + "https://gitea.hsrv.site/mrleo1nid/pzmanager/": "https://gitea.hsrv.site/api/v1/repos/mrleo1nid/pzmanager", + "http://192.168.0.5:3000/team/pzmanager": "http://192.168.0.5:3000/api/v1/repos/team/pzmanager", + } + for repo, want := range cases { + got, err := New(repo, "dev").apiBase() + if err != nil { + t.Errorf("apiBase(%q): %v", repo, err) + continue + } + if got != want { + t.Errorf("apiBase(%q) = %q, ожидалось %q", repo, got, want) + } + } + + for _, bad := range []string{"gitea.hsrv.site/owner/repo", "https://gitea.hsrv.site", ""} { + if _, err := New(bad, "dev").apiBase(); err == nil { + t.Errorf("адрес %q принят, ожидалась ошибка", bad) + } + } +} diff --git a/web/static/app.js b/web/static/app.js index 0283b2c..eeb7e6b 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -1143,6 +1143,120 @@ $("#backup-rows").addEventListener("click", async (event) => { } }); +// --------------------------------------------------------------------------- +// Версия панели и обновление +// --------------------------------------------------------------------------- + +let updateInfo = null; + +async function loadUpdateInfo() { + const info = await get("/api/update"); + updateInfo = info; + + $("#update-current").textContent = info.current || "?"; + + const state = $("#update-state"); + const applyBtn = $("#btn-update-apply"); + const manual = $("#update-manual"); + + applyBtn.hidden = !info.available; + manual.hidden = true; + + if (info.error) { + state.className = "muted small"; + state.textContent = `Не удалось проверить обновления: ${info.error}`; + } else if (info.available) { + state.className = "small update-available"; + state.textContent = `Доступна версия ${info.latest}`; + applyBtn.textContent = `Обновить до ${info.latest}`; + // Панель обновляется сама, только если установщик прописал ей права на + // это. Иначе показываем команду для терминала. + if (!info.can_apply) { + applyBtn.hidden = true; + manual.hidden = false; + manual.textContent = `Обновить можно командой: ${info.manual_command}`; + } + } else { + state.className = "muted small"; + state.textContent = info.latest + ? `Установлена последняя версия (${info.latest})` + : "Сведений о релизах нет"; + } + + const notes = $("#update-notes"); + notes.hidden = !(info.available && info.notes); + if (!notes.hidden) $("#update-notes-body").textContent = info.notes; + + $("#btn-manager-restart").disabled = !info.can_apply; + $("#btn-manager-restart").title = info.can_apply + ? "Перезапустить службу панели" + : "Недоступно: панель не может управлять своей службой"; +} + +$("#btn-update-check").addEventListener("click", async () => { + const button = $("#btn-update-check"); + button.disabled = true; + try { + await loadUpdateInfo(); + ok(updateInfo.available ? `Есть новая версия: ${updateInfo.latest}` : "Обновлений нет"); + } catch (error) { + fail(error); + } finally { + button.disabled = false; + } +}); + +$("#btn-update-apply").addEventListener("click", async () => { + if (!confirm(`Обновить панель до ${updateInfo.latest}?\n\n` + + `Игровой сервер будет остановлен, панель перезапустится.`)) return; + try { + await post("/api/update", { version: updateInfo.latest }); + waitForRestart("Обновление запущено"); + } catch (error) { + fail(error); + } +}); + +$("#btn-manager-restart").addEventListener("click", async () => { + if (!confirm("Перезапустить панель?\n\nИгровой сервер будет остановлен.")) return; + try { + await post("/api/manager/restart"); + waitForRestart("Панель перезапускается"); + } catch (error) { + fail(error); + } +}); + +// waitForRestart дожидается, пока служба поднимется заново, и перезагружает +// страницу: во время перезапуска панель недоступна, и обычные опросы статуса +// будут только сыпать ошибками. +function waitForRestart(message) { + ok(message + " — жду, пока панель поднимется"); + stopPolling(); + closeConsoleStream(); + + const deadline = Date.now() + 5 * 60 * 1000; + const state = $("#update-state"); + state.className = "small"; + state.textContent = message + "…"; + + const probe = setInterval(async () => { + if (Date.now() > deadline) { + clearInterval(probe); + state.textContent = "Панель не поднялась за пять минут — проверьте journalctl -u pzmanager"; + return; + } + try { + const response = await fetch("/api/session", { credentials: "same-origin" }); + if (!response.ok) return; + clearInterval(probe); + location.reload(); + } catch (error) { + // Служба ещё перезапускается — это ожидаемо. + } + }, 3000); +} + // --------------------------------------------------------------------------- // Настройки менеджера и пользователи // --------------------------------------------------------------------------- @@ -1198,11 +1312,15 @@ async function loadSettings() { const pathNote = document.createElement("p"); pathNote.className = "muted small"; - pathNote.textContent = - `PZ Manager ${config.version || "?"} · файл конфигурации: ${config.config_path}`; + pathNote.textContent = `Файл конфигурации: ${config.config_path}`; form.appendChild(pathNote); await loadUsers(); + // Проверка обновления ходит в интернет и может не ответить — она не должна + // мешать показу остальных настроек. + loadUpdateInfo().catch((error) => { + $("#update-state").textContent = "Не удалось проверить обновления: " + error.message; + }); } $("#btn-settings-save").addEventListener("click", async () => { diff --git a/web/static/index.html b/web/static/index.html index 57fd75b..a34c709 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -268,6 +268,26 @@
+

Версия панели

+
+
+
+
+

Проверяю обновления…

+
+
+ + + +
+
+ + +
+

Настройки менеджера

diff --git a/web/static/style.css b/web/static/style.css index f156ab6..b1d7a43 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -444,3 +444,37 @@ input.input-sm, .input-sm { width: auto; padding: 6px 10px; font-size: 13px; } text-transform: none; letter-spacing: 0; } + +/* ---------- Обновление панели ---------- */ + +.update-card { margin-bottom: 22px; } + +.update-row { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; +} +.update-row > div:first-child { flex: 1; min-width: 200px; } +.update-row .stat { font-size: 20px; margin: 0; } + +.update-actions { display: flex; gap: 8px; flex-wrap: wrap; } + +.update-card details { margin-top: 12px; } +.update-card summary { cursor: pointer; color: var(--muted); font-size: 13px; } + +.release-notes { + margin: 10px 0 0; + padding: 12px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + max-height: 260px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + font-size: 12.5px; + line-height: 1.5; +} + +.update-available { color: var(--accent); }