diff --git a/README.md b/README.md index c8ce6a9..5b829c5 100644 --- a/README.md +++ b/README.md @@ -90,23 +90,25 @@ curl -fsSL https://gitea.hsrv.site/mrleo1nid/pzmanager/raw/branch/main/deploy/ge Дальше: -1. Посмотрите код первичной настройки: - - ```bash - journalctl -u pzmanager -n 30 --no-pager - ``` - -2. Откройте панель в браузере и создайте администратора, введя этот код. - Если панель слушает localhost, пробросьте порт: +1. Откройте панель в браузере и создайте администратора, введя код первичной + настройки — установщик печатает его последним сообщением. Если панель + слушает localhost, пробросьте порт: ```bash ssh -L 8080:127.0.0.1:8080 пользователь@сервер ``` -3. Во вкладке «Обзор» нажмите **Установить / обновить через SteamCMD** и + Код одноразовый и перестаёт работать сразу после создания администратора. + Если вывод установщика потерялся, код есть в журнале: + + ```bash + journalctl -u pzmanager -n 30 --no-pager + ``` + +2. Во вкладке «Обзор» нажмите **Установить / обновить через SteamCMD** и дождитесь окончания — прогресс виден во вкладке «Консоль». -4. Запустите сервер. При первом запуске PZ создаст `Zomboid/Server/*.ini` — +3. Запустите сервер. При первом запуске PZ создаст `Zomboid/Server/*.ini` — после этого станут доступны вкладки «Конфиг сервера», «Песочница» и «Моды». Порты, которые нужно открыть: @@ -132,6 +134,32 @@ make build-linux sudo PZ_LISTEN=10.10.1.142:8080 ./deploy/install.sh ``` +### Удаление + +Убрать панель, оставив миры и настройки: + +```bash +sudo /opt/pzmanager/install.sh --uninstall +``` + +Удалить всё вместе с мирами, бэкапами и учётными записями: + +```bash +sudo /opt/pzmanager/install.sh --uninstall --purge +``` + +`--purge` спрашивает подтверждение; в неинтерактивном запуске нужен ещё и +`--yes`. Каталог сносится, только если похож на установку панели — иначе +опечатка в `PZ_HOME` стоила бы чужих файлов. + +Установщик кладёт свою копию в `/opt/pzmanager/install.sh`, так что для +удаления ничего скачивать не нужно. Если копии нет, те же аргументы принимает +и установочная команда: + +```bash +curl -fsSL https://gitea.hsrv.site/mrleo1nid/pzmanager/raw/branch/main/deploy/get.sh | sudo bash -s -- --uninstall --purge +``` + ## Моды Project Zomboid хранит моды в двух строках конфига: `WorkshopItems` — что diff --git a/cmd/pzmanager/main.go b/cmd/pzmanager/main.go index 33a0ef1..718ca28 100644 --- a/cmd/pzmanager/main.go +++ b/cmd/pzmanager/main.go @@ -131,24 +131,34 @@ func serve(cfg *config.Config, users *auth.Store, secureCookies bool) error { // Пока учёток нет, создать первого администратора можно только с этим // кодом — иначе панель захватит любой, кто откроет её первым. setupToken := "" + setupTokenFile := filepath.Join(cfg.DataDir, "setup-token") if users.IsEmpty() { setupToken = randomToken() + // Код кладём и в файл: установщик читает его оттуда и показывает сразу + // после установки, не заставляя идти в журнал. + if err := os.WriteFile(setupTokenFile, []byte(setupToken+"\n"), 0o600); err != nil { + fmt.Fprintf(os.Stderr, "не удалось записать %s: %v\n", setupTokenFile, err) + } fmt.Printf("\n Панель ещё не настроена.\n"+ " Откройте её в браузере и введите код первичной настройки:\n\n %s\n\n", setupToken) + } else { + // Панель уже настроена: файл мог остаться от прошлой установки. + os.Remove(setupTokenFile) } apiServer := api.New(api.Options{ - Version: version, - Config: cfg, - Users: users, - Supervisor: supervisor, - Jobs: jobs, - Backups: backups, - Steam: steam, - Static: static, - SecureCookies: secureCookies, - SetupToken: setupToken, - Repo: repoURL(), + Version: version, + Config: cfg, + Users: users, + Supervisor: supervisor, + Jobs: jobs, + Backups: backups, + Steam: steam, + Static: static, + SecureCookies: secureCookies, + SetupToken: setupToken, + SetupTokenFile: setupTokenFile, + Repo: repoURL(), }) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) diff --git a/deploy/get.sh b/deploy/get.sh index 75c462b..cf1312e 100644 --- a/deploy/get.sh +++ b/deploy/get.sh @@ -7,6 +7,15 @@ # # curl -fsSL .../get.sh | sudo PZ_LISTEN=10.10.1.142:8080 bash # +# Удаление (аргументы уходят установщику как есть): +# +# curl -fsSL .../get.sh | sudo bash -s -- --uninstall +# curl -fsSL .../get.sh | sudo bash -s -- --uninstall --purge +# +# Если панель уже установлена, то же самое умеет её локальная копия: +# +# sudo /opt/pzmanager/install.sh --uninstall --purge +# # Переменные окружения: # PZ_VERSION какой тег ставить (по умолчанию последний релиз) # PZ_HOME куда ставить (по умолчанию /opt/pzmanager) @@ -29,6 +38,19 @@ die() { echo "Ошибка: $*" >&2; exit 1; } [[ $EUID -eq 0 ]] || die "запустите с правами root (sudo)" +# Аргументы уходят установщику. Для удаления бинарник качать незачем — это +# лишние восемь мегабайт ради скрипта, который его же и сотрёт. +ARGS=("$@") +NEED_BINARY=1 +# Идиома ${ARGS[@]+...} нужна из-за set -u: обычная "${ARGS[@]:-}" на пустом +# массиве разворачивается в один пустой аргумент, и установщик спотыкается о +# него при обычной установке. +for arg in ${ARGS[@]+"${ARGS[@]}"}; do + case "$arg" in + --uninstall | --remove | --purge) NEED_BINARY=0 ;; + esac +done + for tool in curl sha256sum; do command -v "$tool" >/dev/null 2>&1 || die "не найдена утилита $tool" done @@ -68,10 +90,13 @@ asset_url() { } BIN_NAME="pzmanager-linux-${ARCH}" -BIN_URL="$(asset_url "$BIN_NAME")" -[[ -n "$BIN_URL" ]] || die "в релизе ${TAG} нет файла ${BIN_NAME}" - -echo "==> Ставлю ${TAG} (${ARCH})" +if [[ "$NEED_BINARY" == "1" ]]; then + BIN_URL="$(asset_url "$BIN_NAME")" + [[ -n "$BIN_URL" ]] || die "в релизе ${TAG} нет файла ${BIN_NAME}" + echo "==> Ставлю ${TAG} (${ARCH})" +else + echo "==> Беру установщик из ${TAG} для удаления" +fi TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT @@ -91,13 +116,17 @@ download() { curl -fsSL -o "$name" "$url" || die "не удалось скачать ${name}" } -download "$BIN_NAME" download install.sh download pzmanager.service +FILES=(install.sh pzmanager.service) + +if [[ "$NEED_BINARY" == "1" ]]; then + download "$BIN_NAME" + FILES+=("$BIN_NAME") +fi # Скрипт привилегированных действий появился не сразу: со старым релизом # ставим без него, панель просто не покажет кнопку обновления. -FILES=("$BIN_NAME" install.sh pzmanager.service) if download pzmanager-admin.sh optional; then FILES+=(pzmanager-admin.sh) fi @@ -120,8 +149,11 @@ 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 +chmod +x deploy/install.sh +if [[ "$NEED_BINARY" == "1" ]]; then + mv "$BIN_NAME" pzmanager + chmod +x pzmanager +fi echo "==> Запускаю установщик" -BINARY="$TMP/pzmanager" bash deploy/install.sh +BINARY="$TMP/pzmanager" bash deploy/install.sh ${ARGS[@]+"${ARGS[@]}"} diff --git a/deploy/install.sh b/deploy/install.sh index 22f51fe..d813f3d 100644 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -1,10 +1,12 @@ #!/usr/bin/env bash -# Установка и обновление pzmanager на Ubuntu/Debian. Запускать от root: +# Установка, обновление и удаление pzmanager на Ubuntu/Debian. От root: # -# sudo ./deploy/install.sh +# sudo ./deploy/install.sh установить или обновить +# sudo ./deploy/install.sh --uninstall убрать панель, данные оставить +# sudo ./deploy/install.sh --uninstall --purge убрать вместе с мирами # -# Скрипт идемпотентен: на уже настроенной машине он обновляет бинарник и -# юнит, не трогая конфиг, миры и учётные записи панели. +# Установка идемпотентна: на уже настроенной машине обновляются бинарник и +# юнит, а конфиг, миры и учётные записи панели остаются нетронутыми. # # Переменные окружения: # PZ_HOME куда ставить всё хозяйство (по умолчанию /opt/pzmanager) @@ -29,11 +31,94 @@ ADMIN_SRC="${SCRIPT_DIR}/pzmanager-admin.sh" TARGET_BINARY=/usr/local/bin/pzmanager ADMIN_BINARY=/usr/local/bin/pzmanager-admin +UNINSTALL=0 +PURGE=0 +for arg in "$@"; do + case "$arg" in + --uninstall | --remove) UNINSTALL=1 ;; + --purge) UNINSTALL=1; PURGE=1 ;; + --yes | -y) PZ_ASSUME_YES=1 ;; + -h | --help) + sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) + echo "Неизвестный аргумент: $arg" >&2 + exit 2 + ;; + esac +done + if [[ $EUID -ne 0 ]]; then echo "Запустите скрипт от root: sudo $0" >&2 exit 1 fi +# uninstall снимает панель с машины. Данные (миры, бэкапы, учётки) удаляются +# только по --purge: потерять сохранения из-за неудачно набранной команды +# гораздо обиднее, чем лишний раз убрать каталог руками. +uninstall() { + echo "==> Удаляю pzmanager" + + if systemctl list-unit-files pzmanager.service &>/dev/null && + systemctl is-active --quiet pzmanager; then + echo "==> Останавливаю службу (игровой сервер завершится штатно, это небыстро)" + systemctl stop pzmanager || true + fi + systemctl disable --quiet pzmanager 2>/dev/null || true + rm -f /etc/systemd/system/pzmanager.service + systemctl daemon-reload + systemctl reset-failed pzmanager 2>/dev/null || true + + rm -f /usr/local/bin/pzmanager /usr/local/bin/pzmanager-admin /etc/sudoers.d/pzmanager + echo "Служба, бинарники и правило sudo удалены." + + if [[ "$PURGE" != "1" ]]; then + echo + echo "Данные остались в ${PZ_HOME} (миры, бэкапы, конфиг, учётные записи)." + echo "Удалить и их: sudo $0 --uninstall --purge" + return + fi + + # Защита от опечатки в PZ_HOME: сносим только то, что похоже на нашу + # установку, и никогда — корень или домашние каталоги. + local home_clean="${PZ_HOME%/}" + if [[ -z "$home_clean" || "$home_clean" == "/" || "$home_clean" != /* ]]; then + echo "!! PZ_HOME=${PZ_HOME} не похож на каталог установки — не трогаю" >&2 + return + fi + if [[ ! -e "$home_clean/config.yaml" && ! -d "$home_clean/data" ]]; then + echo "!! В ${home_clean} нет ни config.yaml, ни data/ — не похоже на установку pzmanager," >&2 + echo " поэтому каталог оставлен нетронутым." >&2 + return + fi + + if [[ "${PZ_ASSUME_YES:-0}" != "1" ]]; then + echo + echo "Будет безвозвратно удалено: ${home_clean}" + echo "Это миры, бэкапы, конфиг и учётные записи панели." + if [[ -t 0 ]]; then + read -r -p "Введите YES для подтверждения: " answer + [[ "$answer" == "YES" ]] || { echo "Отменено."; return; } + else + echo "Запуск неинтерактивный — повторите с флагом --yes, если уверены." >&2 + return + fi + fi + + rm -rf "$home_clean" + if id "$PZ_USER" &>/dev/null; then + deluser --system "$PZ_USER" &>/dev/null || userdel "$PZ_USER" &>/dev/null || true + echo "Пользователь ${PZ_USER} удалён." + fi + echo "Удалено полностью: ${home_clean}" +} + +if [[ "$UNINSTALL" == "1" ]]; then + uninstall + exit 0 +fi + if [[ ! -f "$BINARY" ]]; then echo "Не найден бинарник $BINARY. Соберите его: make build-linux" >&2 exit 1 @@ -152,6 +237,14 @@ else echo "!! $ADMIN_SRC не найден или нет sudo — обновление из панели будет недоступно" >&2 fi +# Кладём сам установщик рядом с данными: удалить или переустановить панель +# можно будет одной локальной командой, без похода в репозиторий. +install -m 0755 "$0" "$PZ_HOME/install.sh" +if [[ -f "$ADMIN_SRC" ]]; then + install -m 0755 "$ADMIN_SRC" "$PZ_HOME/pzmanager-admin.sh" +fi +install -m 0644 "$UNIT_SRC" "$PZ_HOME/pzmanager.service" + echo "==> Ставлю systemd-юнит" sed -e "s|/opt/pzmanager|${PZ_HOME}|g" \ -e "s|User=pzserver|User=${PZ_USER}|" \ @@ -178,8 +271,21 @@ if [[ "$MODE" == "update" ]]; then fi systemctl start pzmanager -# Дадим сервису секунду подняться, чтобы код настройки успел попасть в журнал. -sleep 2 + +# Панель кладёт код первичной настройки в файл, как только поднимется. Ждём +# его, чтобы показать код прямо здесь: иначе за ним пришлось бы идти в журнал. +SETUP_TOKEN="" +TOKEN_FILE="$PZ_HOME/data/setup-token" +for _ in $(seq 1 20); do + if [[ -s "$TOKEN_FILE" ]]; then + SETUP_TOKEN="$(head -1 "$TOKEN_FILE")" + break + fi + if ! systemctl is-active --quiet pzmanager; then + break + fi + sleep 1 +done echo echo "Готово. Панель запущена и слушает ${PZ_LISTEN}." @@ -198,8 +304,18 @@ else echo " sudo ufw allow ${PORT}/tcp" echo fi -echo "Код первичной настройки — в журнале:" -echo -echo " journalctl -u pzmanager -n 30 --no-pager" +if [[ -n "$SETUP_TOKEN" ]]; then + echo "Код первичной настройки (введите его в браузере):" + echo + echo " ${SETUP_TOKEN}" + echo + echo "Код одноразовый: после создания администратора он перестаёт работать." +else + echo "Код первичной настройки не удалось прочитать. Посмотрите журнал:" + echo + echo " journalctl -u pzmanager -n 30 --no-pager" +fi echo echo "Игровые порты сервера: udp/16261 и udp/16262." +echo +echo "Удалить панель целиком: sudo ${PZ_HOME}/install.sh --uninstall --purge" diff --git a/internal/api/handlers_auth.go b/internal/api/handlers_auth.go index 27c200c..abe7e87 100644 --- a/internal/api/handlers_auth.go +++ b/internal/api/handlers_auth.go @@ -4,6 +4,7 @@ import ( "crypto/subtle" "errors" "net/http" + "os" "time" "github.com/mrleo1nid/pzmanager/internal/auth" @@ -55,6 +56,10 @@ func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, err.Error()) return } + // Администратор создан — код больше не нужен и не должен лежать на диске. + if s.setupTokenFile != "" { + os.Remove(s.setupTokenFile) + } s.setSessionCookie(w, id, sessionTTL) writeJSON(w, http.StatusOK, map[string]any{"authenticated": true, "login": creds.Login}) } diff --git a/internal/api/server.go b/internal/api/server.go index 14075ab..2565ea6 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -40,8 +40,11 @@ type Server struct { // secureCookies включает флаг Secure у cookie сессии (панель за HTTPS). secureCookies bool // setupToken защищает первичное создание администратора: менеджер печатает - // его в журнал при старте, когда учёток ещё нет. + // его при старте, когда учёток ещё нет. setupToken string + // setupTokenFile — копия кода на диске, которую показывает установщик. + // После создания администратора файл больше не нужен. + setupTokenFile string } // Options — то, что нужно собрать серверу API. @@ -57,6 +60,9 @@ type Options struct { Static fs.FS SecureCookies bool SetupToken string + // SetupTokenFile — файл с кодом первичной настройки, удаляется после + // создания администратора. + SetupTokenFile string // Repo — репозиторий, из которого панель берёт обновления. Repo string } @@ -64,18 +70,19 @@ type Options struct { // New собирает HTTP-обработчик панели. func New(o Options) *Server { return &Server{ - version: o.Version, - cfg: o.Config, - users: o.Users, - sup: o.Supervisor, - jobs: o.Jobs, - 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, + version: o.Version, + cfg: o.Config, + users: o.Users, + sup: o.Supervisor, + jobs: o.Jobs, + 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, + setupTokenFile: o.SetupTokenFile, } }