Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dc8fdae05 | ||
|
|
1e0b0a8d2b | ||
|
|
41c7cb9bb0 | ||
|
|
e25294db16 | ||
|
|
a8d86e0c48 | ||
|
|
3da1135202 |
@@ -54,6 +54,11 @@ jobs:
|
|||||||
# если в настройках инстанса права такого токена урезаны.
|
# если в настройках инстанса права такого токена урезаны.
|
||||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
DEFAULT_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
DEFAULT_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
# Команду установки из описания релиза копируют люди, поэтому в ней
|
||||||
|
# нужен внешний адрес. GITHUB_SERVER_URL для этого не годится: внутри
|
||||||
|
# Actions он указывает на адрес, по которому Gitea видна runner'у, а
|
||||||
|
# это обычно внутренний IP. Переопределяется переменной репозитория.
|
||||||
|
PUBLIC_URL: ${{ vars.PUBLIC_URL }}
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
apt-get update -qq && apt-get install -y -qq jq
|
apt-get update -qq && apt-get install -y -qq jq
|
||||||
@@ -77,8 +82,9 @@ jobs:
|
|||||||
CHANGES="$(git log --no-merges --pretty=format:'- %s')"
|
CHANGES="$(git log --no-merges --pretty=format:'- %s')"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
NOTES="$(printf '## Установка\n\n```bash\ncurl -fsSL %s/%s/raw/branch/main/deploy/get.sh | sudo bash\n```\n\nОбновление — та же команда: конфиг, миры и учётные записи не трогаются.\n\n## Изменения\n\n%s\n' \
|
PUBLIC="${PUBLIC_URL:-https://gitea.hsrv.site}"
|
||||||
"${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}" "${CHANGES}")"
|
NOTES="$(printf '## Установка\n\n```bash\ncurl -fsSL %s/%s/raw/branch/main/deploy/get.sh | sudo bash\n```\n\nОбновление — та же команда или кнопка в панели: конфиг, миры и учётные записи не трогаются.\n\n## Изменения\n\n%s\n' \
|
||||||
|
"${PUBLIC%/}" "${GITHUB_REPOSITORY}" "${CHANGES}")"
|
||||||
|
|
||||||
PAYLOAD="$(jq -n --arg tag "$TAG" --arg body "$NOTES" \
|
PAYLOAD="$(jq -n --arg tag "$TAG" --arg body "$NOTES" \
|
||||||
'{tag_name: $tag, name: $tag, body: $body, draft: false, prerelease: false}')"
|
'{tag_name: $tag, name: $tag, body: $body, draft: false, prerelease: false}')"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# PZ Manager
|
# PZ Manager
|
||||||
|
|
||||||
|
[](https://gitea.hsrv.site/mrleo1nid/pzmanager/releases/latest)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://go.dev/)
|
[](https://go.dev/)
|
||||||
[](https://ubuntu.com/)
|
[](https://ubuntu.com/)
|
||||||
@@ -84,28 +85,31 @@ curl -fsSL https://gitea.hsrv.site/mrleo1nid/pzmanager/raw/branch/main/deploy/ge
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Обновление** — та же команда или кнопка в панели (вкладка «Настройки» →
|
**Обновление** — та же команда или кнопка в панели (вкладка «Настройки» →
|
||||||
«Версия панели»). Конфиг, миры и учётные записи не трогаются, меняется только
|
«Версия панели»). Конфиг, миры и учётные записи не трогаются. Повторный запуск
|
||||||
бинарник.
|
с той же версией службу не трогает вовсе: если бинарник не изменился,
|
||||||
|
установщик не станет ради него ронять игровой сервер.
|
||||||
|
|
||||||
Дальше:
|
Дальше:
|
||||||
|
|
||||||
1. Посмотрите код первичной настройки:
|
1. Откройте панель в браузере и создайте администратора, введя код первичной
|
||||||
|
настройки — установщик печатает его последним сообщением. Если панель
|
||||||
```bash
|
слушает localhost, пробросьте порт:
|
||||||
journalctl -u pzmanager -n 30 --no-pager
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Откройте панель в браузере и создайте администратора, введя этот код.
|
|
||||||
Если панель слушает localhost, пробросьте порт:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh -L 8080:127.0.0.1:8080 пользователь@сервер
|
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` —
|
||||||
после этого станут доступны вкладки «Конфиг сервера», «Песочница» и «Моды».
|
после этого станут доступны вкладки «Конфиг сервера», «Песочница» и «Моды».
|
||||||
|
|
||||||
Порты, которые нужно открыть:
|
Порты, которые нужно открыть:
|
||||||
@@ -131,6 +135,32 @@ make build-linux
|
|||||||
sudo PZ_LISTEN=10.10.1.142:8080 ./deploy/install.sh
|
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` — что
|
Project Zomboid хранит моды в двух строках конфига: `WorkshopItems` — что
|
||||||
@@ -206,9 +236,19 @@ Mod ID, который нужен серверу, лежит внутри сам
|
|||||||
pzserver ALL=(root) NOPASSWD: /usr/local/bin/pzmanager-admin
|
pzserver ALL=(root) NOPASSWD: /usr/local/bin/pzmanager-admin
|
||||||
```
|
```
|
||||||
|
|
||||||
Скрипт качает бинарник нужной архитектуры, сверяет sha256 с `checksums.txt`
|
Скрипт не повторяет логику установки, а забирает из релиза `get.sh` (сверив
|
||||||
релиза, подменяет файл и перезапускает службу. Панель к этим файлам доступа не
|
его sha256) и отдаёт работу ему — поэтому кнопка обновляет всё сразу: бинарник,
|
||||||
имеет — она может только попросить выполнить `update` или `restart`.
|
скрипты установки и systemd-юнит. Панель к этим файлам доступа не имеет, она
|
||||||
|
может только попросить выполнить `update` или `restart`.
|
||||||
|
|
||||||
|
Работа уходит в отдельный разовый юнит (`systemd-run --unit=pzmanager-update`).
|
||||||
|
Иначе обновление не пережило бы само себя: панель вызывает скрипт через sudo,
|
||||||
|
тот живёт в cgroup её юнита, и остановка службы убила бы его на середине.
|
||||||
|
Побочный эффект — вывод идёт не в консоль панели, а в свой журнал:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
journalctl -u pzmanager-update -f
|
||||||
|
```
|
||||||
|
|
||||||
Если правило sudo не установлено (панель поставлена вручную или в системе нет
|
Если правило sudo не установлено (панель поставлена вручную или в системе нет
|
||||||
`sudo`), кнопка не появится, а рядом будет показана команда для терминала:
|
`sudo`), кнопка не появится, а рядом будет показана команда для терминала:
|
||||||
|
|||||||
@@ -131,10 +131,19 @@ func serve(cfg *config.Config, users *auth.Store, secureCookies bool) error {
|
|||||||
// Пока учёток нет, создать первого администратора можно только с этим
|
// Пока учёток нет, создать первого администратора можно только с этим
|
||||||
// кодом — иначе панель захватит любой, кто откроет её первым.
|
// кодом — иначе панель захватит любой, кто откроет её первым.
|
||||||
setupToken := ""
|
setupToken := ""
|
||||||
|
setupTokenFile := filepath.Join(cfg.DataDir, "setup-token")
|
||||||
if users.IsEmpty() {
|
if users.IsEmpty() {
|
||||||
setupToken = randomToken()
|
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"+
|
fmt.Printf("\n Панель ещё не настроена.\n"+
|
||||||
" Откройте её в браузере и введите код первичной настройки:\n\n %s\n\n", setupToken)
|
" Откройте её в браузере и введите код первичной настройки:\n\n %s\n\n", setupToken)
|
||||||
|
} else {
|
||||||
|
// Панель уже настроена: файл мог остаться от прошлой установки.
|
||||||
|
os.Remove(setupTokenFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
apiServer := api.New(api.Options{
|
apiServer := api.New(api.Options{
|
||||||
@@ -148,6 +157,7 @@ func serve(cfg *config.Config, users *auth.Store, secureCookies bool) error {
|
|||||||
Static: static,
|
Static: static,
|
||||||
SecureCookies: secureCookies,
|
SecureCookies: secureCookies,
|
||||||
SetupToken: setupToken,
|
SetupToken: setupToken,
|
||||||
|
SetupTokenFile: setupTokenFile,
|
||||||
Repo: repoURL(),
|
Repo: repoURL(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+41
-9
@@ -7,6 +7,15 @@
|
|||||||
#
|
#
|
||||||
# curl -fsSL .../get.sh | sudo PZ_LISTEN=10.10.1.142:8080 bash
|
# 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_VERSION какой тег ставить (по умолчанию последний релиз)
|
||||||
# PZ_HOME куда ставить (по умолчанию /opt/pzmanager)
|
# PZ_HOME куда ставить (по умолчанию /opt/pzmanager)
|
||||||
@@ -29,6 +38,19 @@ die() { echo "Ошибка: $*" >&2; exit 1; }
|
|||||||
|
|
||||||
[[ $EUID -eq 0 ]] || die "запустите с правами root (sudo)"
|
[[ $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
|
for tool in curl sha256sum; do
|
||||||
command -v "$tool" >/dev/null 2>&1 || die "не найдена утилита $tool"
|
command -v "$tool" >/dev/null 2>&1 || die "не найдена утилита $tool"
|
||||||
done
|
done
|
||||||
@@ -68,10 +90,13 @@ asset_url() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
BIN_NAME="pzmanager-linux-${ARCH}"
|
BIN_NAME="pzmanager-linux-${ARCH}"
|
||||||
BIN_URL="$(asset_url "$BIN_NAME")"
|
if [[ "$NEED_BINARY" == "1" ]]; then
|
||||||
[[ -n "$BIN_URL" ]] || die "в релизе ${TAG} нет файла ${BIN_NAME}"
|
BIN_URL="$(asset_url "$BIN_NAME")"
|
||||||
|
[[ -n "$BIN_URL" ]] || die "в релизе ${TAG} нет файла ${BIN_NAME}"
|
||||||
echo "==> Ставлю ${TAG} (${ARCH})"
|
echo "==> Ставлю ${TAG} (${ARCH})"
|
||||||
|
else
|
||||||
|
echo "==> Беру установщик из ${TAG} для удаления"
|
||||||
|
fi
|
||||||
|
|
||||||
TMP="$(mktemp -d)"
|
TMP="$(mktemp -d)"
|
||||||
trap 'rm -rf "$TMP"' EXIT
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
@@ -91,13 +116,17 @@ download() {
|
|||||||
curl -fsSL -o "$name" "$url" || die "не удалось скачать ${name}"
|
curl -fsSL -o "$name" "$url" || die "не удалось скачать ${name}"
|
||||||
}
|
}
|
||||||
|
|
||||||
download "$BIN_NAME"
|
|
||||||
download install.sh
|
download install.sh
|
||||||
download pzmanager.service
|
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
|
if download pzmanager-admin.sh optional; then
|
||||||
FILES+=(pzmanager-admin.sh)
|
FILES+=(pzmanager-admin.sh)
|
||||||
fi
|
fi
|
||||||
@@ -120,8 +149,11 @@ mv install.sh pzmanager.service deploy/
|
|||||||
if [[ -f pzmanager-admin.sh ]]; then
|
if [[ -f pzmanager-admin.sh ]]; then
|
||||||
mv pzmanager-admin.sh deploy/
|
mv pzmanager-admin.sh deploy/
|
||||||
fi
|
fi
|
||||||
mv "$BIN_NAME" pzmanager
|
chmod +x deploy/install.sh
|
||||||
chmod +x pzmanager deploy/install.sh
|
if [[ "$NEED_BINARY" == "1" ]]; then
|
||||||
|
mv "$BIN_NAME" pzmanager
|
||||||
|
chmod +x pzmanager
|
||||||
|
fi
|
||||||
|
|
||||||
echo "==> Запускаю установщик"
|
echo "==> Запускаю установщик"
|
||||||
BINARY="$TMP/pzmanager" bash deploy/install.sh
|
BINARY="$TMP/pzmanager" bash deploy/install.sh ${ARGS[@]+"${ARGS[@]}"}
|
||||||
|
|||||||
+181
-14
@@ -1,10 +1,12 @@
|
|||||||
#!/usr/bin/env bash
|
#!/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)
|
# PZ_HOME куда ставить всё хозяйство (по умолчанию /opt/pzmanager)
|
||||||
@@ -29,11 +31,96 @@ ADMIN_SRC="${SCRIPT_DIR}/pzmanager-admin.sh"
|
|||||||
TARGET_BINARY=/usr/local/bin/pzmanager
|
TARGET_BINARY=/usr/local/bin/pzmanager
|
||||||
ADMIN_BINARY=/usr/local/bin/pzmanager-admin
|
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)
|
||||||
|
# Справка — это шапка файла: печатаем комментарии со второй строки и
|
||||||
|
# останавливаемся, как только они кончились.
|
||||||
|
awk 'NR > 1 && /^#/ { sub(/^# ?/, ""); print; next } NR > 1 { exit }' "$0"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Неизвестный аргумент: $arg" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
if [[ $EUID -ne 0 ]]; then
|
if [[ $EUID -ne 0 ]]; then
|
||||||
echo "Запустите скрипт от root: sudo $0" >&2
|
echo "Запустите скрипт от root: sudo $0" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
if [[ ! -f "$BINARY" ]]; then
|
||||||
echo "Не найден бинарник $BINARY. Соберите его: make build-linux" >&2
|
echo "Не найден бинарник $BINARY. Соберите его: make build-linux" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -118,17 +205,29 @@ EOF
|
|||||||
chmod 0640 "$CONFIG"
|
chmod 0640 "$CONFIG"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Если ставим ровно то же самое, что уже лежит, службу трогать незачем: её
|
||||||
|
# остановка кладёт и игровой сервер, а повторный запуск установщика — обычное
|
||||||
|
# дело (например, чтобы обновить скрипты).
|
||||||
|
SAME_BINARY=0
|
||||||
|
if [[ "$MODE" == "update" ]] &&
|
||||||
|
[[ "$(sha256sum "$BINARY" | cut -d' ' -f1)" == "$(sha256sum "$TARGET_BINARY" | cut -d' ' -f1)" ]]; then
|
||||||
|
SAME_BINARY=1
|
||||||
|
echo "==> Бинарник не изменился — службу не останавливаю"
|
||||||
|
fi
|
||||||
|
|
||||||
# Останавливаем сервис перед заменой бинарника: менеджер должен корректно
|
# Останавливаем сервис перед заменой бинарника: менеджер должен корректно
|
||||||
# завершить игровой сервер, а не быть подменённым на ходу.
|
# завершить игровой сервер, а не быть подменённым на ходу.
|
||||||
RESTART_AFTER=0
|
RESTART_AFTER=0
|
||||||
if [[ "$MODE" == "update" ]] && systemctl is-active --quiet pzmanager; then
|
if [[ "$MODE" == "update" && "$SAME_BINARY" == "0" ]] && systemctl is-active --quiet pzmanager; then
|
||||||
echo "==> Останавливаю pzmanager (игровой сервер завершится штатно)"
|
echo "==> Останавливаю pzmanager (игровой сервер завершится штатно)"
|
||||||
systemctl stop pzmanager
|
systemctl stop pzmanager
|
||||||
RESTART_AFTER=1
|
RESTART_AFTER=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "==> Копирую бинарник в $TARGET_BINARY"
|
if [[ "$SAME_BINARY" == "0" ]]; then
|
||||||
install -m 0755 "$BINARY" "$TARGET_BINARY"
|
echo "==> Копирую бинарник в $TARGET_BINARY"
|
||||||
|
install -m 0755 "$BINARY" "$TARGET_BINARY"
|
||||||
|
fi
|
||||||
|
|
||||||
# Панель работает от непривилегированного пользователя и не может ни заменить
|
# Панель работает от непривилегированного пользователя и не может ни заменить
|
||||||
# бинарник, ни дёрнуть systemctl. Даём ей ровно одну команду через sudo.
|
# бинарник, ни дёрнуть systemctl. Даём ей ровно одну команду через sudo.
|
||||||
@@ -136,6 +235,8 @@ if [[ -f "$ADMIN_SRC" ]] && command -v sudo >/dev/null 2>&1; then
|
|||||||
echo "==> Ставлю $ADMIN_BINARY и правило sudo"
|
echo "==> Ставлю $ADMIN_BINARY и правило sudo"
|
||||||
sed -e "s|@PZ_REPO@|${PZ_REPO}|" \
|
sed -e "s|@PZ_REPO@|${PZ_REPO}|" \
|
||||||
-e "s|@PZ_BINARY@|${TARGET_BINARY}|" \
|
-e "s|@PZ_BINARY@|${TARGET_BINARY}|" \
|
||||||
|
-e "s|@PZ_HOME@|${PZ_HOME}|" \
|
||||||
|
-e "s|@PZ_USER@|${PZ_USER}|" \
|
||||||
"$ADMIN_SRC" > "$ADMIN_BINARY"
|
"$ADMIN_SRC" > "$ADMIN_BINARY"
|
||||||
chown root:root "$ADMIN_BINARY"
|
chown root:root "$ADMIN_BINARY"
|
||||||
chmod 0755 "$ADMIN_BINARY"
|
chmod 0755 "$ADMIN_BINARY"
|
||||||
@@ -152,6 +253,14 @@ else
|
|||||||
echo "!! $ADMIN_SRC не найден или нет sudo — обновление из панели будет недоступно" >&2
|
echo "!! $ADMIN_SRC не найден или нет sudo — обновление из панели будет недоступно" >&2
|
||||||
fi
|
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-юнит"
|
echo "==> Ставлю systemd-юнит"
|
||||||
sed -e "s|/opt/pzmanager|${PZ_HOME}|g" \
|
sed -e "s|/opt/pzmanager|${PZ_HOME}|g" \
|
||||||
-e "s|User=pzserver|User=${PZ_USER}|" \
|
-e "s|User=pzserver|User=${PZ_USER}|" \
|
||||||
@@ -161,10 +270,25 @@ sed -e "s|/opt/pzmanager|${PZ_HOME}|g" \
|
|||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable --quiet pzmanager
|
systemctl enable --quiet pzmanager
|
||||||
|
|
||||||
|
# wait_active ждёт, пока служба поднимется, и говорит, чем всё кончилось:
|
||||||
|
# молчаливый systemctl start оставлял пользователя гадать, работает ли панель.
|
||||||
|
wait_active() {
|
||||||
|
local i
|
||||||
|
for i in $(seq 1 15); do
|
||||||
|
if systemctl is-active --quiet pzmanager; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
if [[ "$MODE" == "update" ]]; then
|
if [[ "$MODE" == "update" ]]; then
|
||||||
if [[ "$RESTART_AFTER" == "1" ]]; then
|
if [[ "$RESTART_AFTER" == "1" ]]; then
|
||||||
|
echo "==> Запускаю pzmanager"
|
||||||
systemctl start pzmanager
|
systemctl start pzmanager
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo
|
echo
|
||||||
if [[ "$OLD_VERSION" == "$NEW_VERSION" ]]; then
|
if [[ "$OLD_VERSION" == "$NEW_VERSION" ]]; then
|
||||||
echo "Переустановлена версия ${NEW_VERSION}."
|
echo "Переустановлена версия ${NEW_VERSION}."
|
||||||
@@ -172,17 +296,50 @@ if [[ "$MODE" == "update" ]]; then
|
|||||||
echo "Обновлено: ${OLD_VERSION} -> ${NEW_VERSION}."
|
echo "Обновлено: ${OLD_VERSION} -> ${NEW_VERSION}."
|
||||||
fi
|
fi
|
||||||
echo "Конфиг, миры и учётные записи не тронуты."
|
echo "Конфиг, миры и учётные записи не тронуты."
|
||||||
|
|
||||||
LISTEN_NOW="$(grep -E '^listen:' "$CONFIG" | head -1 | awk '{print $2}')"
|
LISTEN_NOW="$(grep -E '^listen:' "$CONFIG" | head -1 | awk '{print $2}')"
|
||||||
echo "Панель слушает ${LISTEN_NOW:-?}."
|
if [[ "$RESTART_AFTER" == "1" ]]; then
|
||||||
|
if wait_active; then
|
||||||
|
echo "Панель работает и слушает ${LISTEN_NOW:-?}."
|
||||||
|
else
|
||||||
|
echo "!! Служба не поднялась. Посмотрите, что случилось:" >&2
|
||||||
|
echo " journalctl -u pzmanager -n 40 --no-pager" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
elif systemctl is-active --quiet pzmanager; then
|
||||||
|
echo "Панель работает и слушает ${LISTEN_NOW:-?}."
|
||||||
|
else
|
||||||
|
# Службу останавливали не мы — значит и поднимать её самовольно не стоит.
|
||||||
|
echo "Служба сейчас остановлена. Запустить: systemctl start pzmanager"
|
||||||
|
fi
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
systemctl start pzmanager
|
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
|
||||||
echo "Готово. Панель запущена и слушает ${PZ_LISTEN}."
|
if wait_active; then
|
||||||
|
echo "Готово. Панель запущена и слушает ${PZ_LISTEN}."
|
||||||
|
else
|
||||||
|
echo "!! Служба не поднялась. Посмотрите, что случилось:" >&2
|
||||||
|
echo " journalctl -u pzmanager -n 40 --no-pager" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
echo
|
echo
|
||||||
if [[ "$PZ_LISTEN" == 127.0.0.1:* || "$PZ_LISTEN" == localhost:* ]]; then
|
if [[ "$PZ_LISTEN" == 127.0.0.1:* || "$PZ_LISTEN" == localhost:* ]]; then
|
||||||
echo "Панель доступна только с самой машины. Пробросьте порт:"
|
echo "Панель доступна только с самой машины. Пробросьте порт:"
|
||||||
@@ -198,8 +355,18 @@ else
|
|||||||
echo " sudo ufw allow ${PORT}/tcp"
|
echo " sudo ufw allow ${PORT}/tcp"
|
||||||
echo
|
echo
|
||||||
fi
|
fi
|
||||||
echo "Код первичной настройки — в журнале:"
|
if [[ -n "$SETUP_TOKEN" ]]; then
|
||||||
echo
|
echo "Код первичной настройки (введите его в браузере):"
|
||||||
echo " journalctl -u pzmanager -n 30 --no-pager"
|
echo
|
||||||
|
echo " ${SETUP_TOKEN}"
|
||||||
|
echo
|
||||||
|
echo "Код одноразовый: после создания администратора он перестаёт работать."
|
||||||
|
else
|
||||||
|
echo "Код первичной настройки не удалось прочитать. Посмотрите журнал:"
|
||||||
|
echo
|
||||||
|
echo " journalctl -u pzmanager -n 30 --no-pager"
|
||||||
|
fi
|
||||||
echo
|
echo
|
||||||
echo "Игровые порты сервера: udp/16261 и udp/16262."
|
echo "Игровые порты сервера: udp/16261 и udp/16262."
|
||||||
|
echo
|
||||||
|
echo "Удалить панель целиком: sudo ${PZ_HOME}/install.sh --uninstall --purge"
|
||||||
|
|||||||
+68
-34
@@ -11,7 +11,11 @@ set -euo pipefail
|
|||||||
|
|
||||||
PZ_REPO="@PZ_REPO@"
|
PZ_REPO="@PZ_REPO@"
|
||||||
PZ_BINARY="@PZ_BINARY@"
|
PZ_BINARY="@PZ_BINARY@"
|
||||||
|
PZ_HOME="@PZ_HOME@"
|
||||||
|
PZ_USER="@PZ_USER@"
|
||||||
PZ_SERVICE="pzmanager"
|
PZ_SERVICE="pzmanager"
|
||||||
|
# Имя разового юнита, в котором идёт обновление.
|
||||||
|
UPDATE_UNIT="pzmanager-update"
|
||||||
|
|
||||||
die() { echo "Ошибка: $*" >&2; exit 1; }
|
die() { echo "Ошибка: $*" >&2; exit 1; }
|
||||||
|
|
||||||
@@ -22,21 +26,57 @@ api_base() {
|
|||||||
printf '%s/api/v1/repos/%s' "$host" "${url#"$host"/}"
|
printf '%s/api/v1/repos/%s' "$host" "${url#"$host"/}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# asset_url ищет в описании релиза ссылку на файл с точным именем.
|
||||||
|
asset_url() {
|
||||||
|
local release="$1" name="$2"
|
||||||
|
printf '%s' "$release" \
|
||||||
|
| grep -o '"browser_download_url" *: *"[^"]*"' \
|
||||||
|
| cut -d'"' -f4 \
|
||||||
|
| while read -r url; do
|
||||||
|
if [[ "${url##*/}" == "$name" ]]; then
|
||||||
|
printf '%s' "$url"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# cmd_update только запускает работу и сразу возвращает управление панели.
|
||||||
|
#
|
||||||
|
# Обновление обязано пережить перезапуск службы: установщик остановит
|
||||||
|
# pzmanager, а вместе с ним systemd убьёт и этот скрипт — панель вызывает его
|
||||||
|
# через sudo, так что он живёт в cgroup её юнита. Поэтому саму работу уносим
|
||||||
|
# в отдельный разовый юнит, который к pzmanager отношения не имеет.
|
||||||
cmd_update() {
|
cmd_update() {
|
||||||
local version="${1:-latest}"
|
local version="${1:-latest}"
|
||||||
local api release tag url name tmp arch
|
# Версия приходит из панели: проверяем до запуска юнита, чтобы не плодить
|
||||||
|
# заведомо провальные.
|
||||||
|
[[ "$version" == "latest" || "$version" =~ ^[A-Za-z0-9._-]+$ ]] \
|
||||||
|
|| die "недопустимая версия: $version"
|
||||||
|
command -v systemd-run >/dev/null 2>&1 \
|
||||||
|
|| die "нет systemd-run — обновите панель командой из README"
|
||||||
|
|
||||||
case "$(uname -m)" in
|
# --collect убирает юнит после завершения, в том числе неуспешного: иначе
|
||||||
x86_64 | amd64) arch=amd64 ;;
|
# следующий запуск упрётся в оставшийся failed-юнит с тем же именем.
|
||||||
aarch64 | arm64) arch=arm64 ;;
|
systemd-run --quiet --collect --unit="$UPDATE_UNIT" \
|
||||||
*) die "архитектура $(uname -m) не поддерживается" ;;
|
--description="Обновление pzmanager" \
|
||||||
esac
|
"$0" do-update "$version" \
|
||||||
|
|| die "не удалось запустить обновление разовым юнитом"
|
||||||
|
|
||||||
|
echo "Обновление запущено в фоне."
|
||||||
|
echo "Ход работы: journalctl -u ${UPDATE_UNIT} -f"
|
||||||
|
}
|
||||||
|
|
||||||
|
# cmd_do_update выполняется уже внутри разового юнита. Обновляет всё сразу:
|
||||||
|
# бинарник, скрипты установки и systemd-юнит, — поэтому просто отдаёт работу
|
||||||
|
# get.sh из релиза вместо того, чтобы повторять его логику.
|
||||||
|
cmd_do_update() {
|
||||||
|
local version="${1:-latest}" api release tag url tmp
|
||||||
|
|
||||||
api="$(api_base)"
|
api="$(api_base)"
|
||||||
if [[ "$version" == "latest" ]]; then
|
if [[ "$version" == "latest" ]]; then
|
||||||
release="$(curl -fsSL "${api}/releases/latest")" || die "не удалось запросить релиз"
|
release="$(curl -fsSL "${api}/releases/latest")" || die "не удалось запросить релиз"
|
||||||
else
|
else
|
||||||
# Тег приходит из панели; всё, кроме безопасных символов, отвергаем.
|
# Версия приходит из панели; всё, кроме безопасных символов, отвергаем.
|
||||||
[[ "$version" =~ ^[A-Za-z0-9._-]+$ ]] || die "недопустимая версия: $version"
|
[[ "$version" =~ ^[A-Za-z0-9._-]+$ ]] || die "недопустимая версия: $version"
|
||||||
release="$(curl -fsSL "${api}/releases/tags/${version}")" || die "релиз ${version} не найден"
|
release="$(curl -fsSL "${api}/releases/tags/${version}")" || die "релиз ${version} не найден"
|
||||||
fi
|
fi
|
||||||
@@ -44,52 +84,46 @@ cmd_update() {
|
|||||||
tag="$(printf '%s' "$release" | grep -o '"tag_name" *: *"[^"]*"' | head -1 | cut -d'"' -f4)"
|
tag="$(printf '%s' "$release" | grep -o '"tag_name" *: *"[^"]*"' | head -1 | cut -d'"' -f4)"
|
||||||
[[ -n "$tag" ]] || die "в ответе нет тега"
|
[[ -n "$tag" ]] || die "в ответе нет тега"
|
||||||
|
|
||||||
name="pzmanager-linux-${arch}"
|
url="$(asset_url "$release" get.sh)"
|
||||||
url="$(printf '%s' "$release" \
|
[[ -n "$url" ]] || die "в релизе ${tag} нет get.sh"
|
||||||
| 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
|
local sums_url
|
||||||
sums_url="$(printf '%s' "$release" \
|
sums_url="$(asset_url "$release" checksums.txt)"
|
||||||
| 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"
|
[[ -n "$sums_url" ]] || die "в релизе ${tag} нет checksums.txt"
|
||||||
|
|
||||||
tmp="$(mktemp -d)"
|
tmp="$(mktemp -d)"
|
||||||
trap 'rm -rf "$tmp"' RETURN
|
trap 'rm -rf "$tmp"' RETURN
|
||||||
|
|
||||||
echo "==> Скачиваю ${tag} (${arch})"
|
echo "==> Беру установщик из ${tag}"
|
||||||
curl -fsSL -o "${tmp}/${name}" "$url" || die "не удалось скачать ${name}"
|
curl -fsSL -o "${tmp}/get.sh" "$url" || die "не удалось скачать get.sh"
|
||||||
curl -fsSL -o "${tmp}/checksums.txt" "$sums_url" || die "не удалось скачать checksums.txt"
|
curl -fsSL -o "${tmp}/checksums.txt" "$sums_url" || die "не удалось скачать checksums.txt"
|
||||||
|
|
||||||
echo "==> Проверяю контрольную сумму"
|
echo "==> Проверяю контрольную сумму"
|
||||||
( cd "$tmp" && grep " ${name}\$" checksums.txt > expected.txt && sha256sum -c expected.txt ) \
|
( cd "$tmp" && awk '$NF == "get.sh"' checksums.txt > expected.txt \
|
||||||
|| die "контрольная сумма не сошлась — файл повреждён или подменён"
|
&& [[ -s expected.txt ]] && sha256sum -c expected.txt ) \
|
||||||
|
|| die "контрольная сумма get.sh не сошлась"
|
||||||
|
|
||||||
echo "==> Заменяю ${PZ_BINARY}"
|
echo "==> Обновляю до ${tag}"
|
||||||
chmod 0755 "${tmp}/${name}"
|
# get.sh скачает бинарник нужной архитектуры и запустит install.sh, который
|
||||||
# Через временный файл рядом и rename: перезаписать работающий бинарник
|
# сам остановит службу, заменит файлы и поднимет её обратно.
|
||||||
# нельзя, а переименовать поверх — можно.
|
PZ_HOME="$PZ_HOME" PZ_USER="$PZ_USER" PZ_REPO="$PZ_REPO" PZ_VERSION="$tag" \
|
||||||
install -m 0755 "${tmp}/${name}" "${PZ_BINARY}.new"
|
bash "${tmp}/get.sh" \
|
||||||
mv -f "${PZ_BINARY}.new" "${PZ_BINARY}"
|
|| die "обновление не удалось"
|
||||||
|
|
||||||
echo "==> Перезапускаю ${PZ_SERVICE}"
|
echo "Обновление до ${tag} завершено"
|
||||||
systemctl restart "$PZ_SERVICE"
|
|
||||||
echo "Обновлено до ${tag}"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case "${1:-}" in
|
case "${1:-}" in
|
||||||
update)
|
update)
|
||||||
cmd_update "${2:-latest}"
|
cmd_update "${2:-latest}"
|
||||||
;;
|
;;
|
||||||
|
do-update)
|
||||||
|
cmd_do_update "${2:-latest}"
|
||||||
|
;;
|
||||||
restart)
|
restart)
|
||||||
echo "==> Перезапускаю ${PZ_SERVICE}"
|
echo "==> Перезапускаю ${PZ_SERVICE}"
|
||||||
systemctl restart "$PZ_SERVICE"
|
# --no-block: команда ставит задание и выходит, не дожидаясь остановки.
|
||||||
|
# Иначе systemd убил бы нас вместе со службой на середине ожидания.
|
||||||
|
systemctl restart --no-block "$PZ_SERVICE"
|
||||||
;;
|
;;
|
||||||
version)
|
version)
|
||||||
"$PZ_BINARY" version
|
"$PZ_BINARY" version
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mrleo1nid/pzmanager/internal/auth"
|
"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())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Администратор создан — код больше не нужен и не должен лежать на диске.
|
||||||
|
if s.setupTokenFile != "" {
|
||||||
|
os.Remove(s.setupTokenFile)
|
||||||
|
}
|
||||||
s.setSessionCookie(w, id, sessionTTL)
|
s.setSessionCookie(w, id, sessionTTL)
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"authenticated": true, "login": creds.Login})
|
writeJSON(w, http.StatusOK, map[string]any{"authenticated": true, "login": creds.Login})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ func (s *Server) handleApplyUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Игровой сервер останавливаем сами: иначе это сделает systemd уже в
|
// Игровой сервер останавливаем сами: иначе это сделает systemd уже в
|
||||||
// момент перезапуска, и ожидание растянется на таймаут остановки.
|
// момент перезапуска, и ожидание растянется на таймаут остановки.
|
||||||
s.stopServerBefore(log, "обновлением")
|
s.stopServerBefore(log, "обновлением")
|
||||||
log("Запускаю обновление, панель перезапустится")
|
log("Запускаю обновление — оно идёт отдельной службой и переживёт перезапуск панели")
|
||||||
|
log("Подробности: journalctl -u pzmanager-update -f")
|
||||||
return s.updater.Apply(ctx, log, body.Version)
|
return s.updater.Apply(ctx, log, body.Version)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -40,8 +40,11 @@ type Server struct {
|
|||||||
// secureCookies включает флаг Secure у cookie сессии (панель за HTTPS).
|
// secureCookies включает флаг Secure у cookie сессии (панель за HTTPS).
|
||||||
secureCookies bool
|
secureCookies bool
|
||||||
// setupToken защищает первичное создание администратора: менеджер печатает
|
// setupToken защищает первичное создание администратора: менеджер печатает
|
||||||
// его в журнал при старте, когда учёток ещё нет.
|
// его при старте, когда учёток ещё нет.
|
||||||
setupToken string
|
setupToken string
|
||||||
|
// setupTokenFile — копия кода на диске, которую показывает установщик.
|
||||||
|
// После создания администратора файл больше не нужен.
|
||||||
|
setupTokenFile string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Options — то, что нужно собрать серверу API.
|
// Options — то, что нужно собрать серверу API.
|
||||||
@@ -57,6 +60,9 @@ type Options struct {
|
|||||||
Static fs.FS
|
Static fs.FS
|
||||||
SecureCookies bool
|
SecureCookies bool
|
||||||
SetupToken string
|
SetupToken string
|
||||||
|
// SetupTokenFile — файл с кодом первичной настройки, удаляется после
|
||||||
|
// создания администратора.
|
||||||
|
SetupTokenFile string
|
||||||
// Repo — репозиторий, из которого панель берёт обновления.
|
// Repo — репозиторий, из которого панель берёт обновления.
|
||||||
Repo string
|
Repo string
|
||||||
}
|
}
|
||||||
@@ -76,6 +82,7 @@ func New(o Options) *Server {
|
|||||||
static: o.Static,
|
static: o.Static,
|
||||||
secureCookies: o.SecureCookies,
|
secureCookies: o.SecureCookies,
|
||||||
setupToken: o.SetupToken,
|
setupToken: o.SetupToken,
|
||||||
|
setupTokenFile: o.SetupTokenFile,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -471,6 +471,7 @@ async function loadServerConfig() {
|
|||||||
|
|
||||||
if (!data.exists) {
|
if (!data.exists) {
|
||||||
$("#config-empty").textContent = data.hint;
|
$("#config-empty").textContent = data.hint;
|
||||||
|
refreshFieldSaveState(SAVE_TARGETS[0]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
serverConfigValues = data.values;
|
serverConfigValues = data.values;
|
||||||
@@ -479,6 +480,7 @@ async function loadServerConfig() {
|
|||||||
container.appendChild(buildConfigField(key, data.values[key], CONFIG_HINTS[key]));
|
container.appendChild(buildConfigField(key, data.values[key], CONFIG_HINTS[key]));
|
||||||
});
|
});
|
||||||
applyFieldFilter("#config-filter", "#config-fields");
|
applyFieldFilter("#config-filter", "#config-fields");
|
||||||
|
refreshFieldSaveState(SAVE_TARGETS[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildConfigField выбирает тип поля по текущему значению: булевы значения
|
// buildConfigField выбирает тип поля по текущему значению: булевы значения
|
||||||
@@ -539,12 +541,35 @@ function collectChanged(containerSelector) {
|
|||||||
return changed;
|
return changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Кнопка «Сохранить» включается, только когда есть что сохранять: на пустой
|
||||||
|
// вкладке (файл ещё не создан игрой) или без правок нажимать её незачем.
|
||||||
|
const SAVE_TARGETS = [
|
||||||
|
{ fields: "#config-fields", button: "#btn-config-save" },
|
||||||
|
{ fields: "#sandbox-fields", button: "#btn-sandbox-save" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function refreshFieldSaveState(target) {
|
||||||
|
const count = Object.keys(collectChanged(target.fields)).length;
|
||||||
|
const button = $(target.button);
|
||||||
|
button.disabled = count === 0;
|
||||||
|
button.textContent = count ? `Сохранить (${count})` : "Сохранить";
|
||||||
|
button.title = count ? `Изменено параметров: ${count}` : "Изменений нет";
|
||||||
|
}
|
||||||
|
|
||||||
|
SAVE_TARGETS.forEach((target) => {
|
||||||
|
// Слушаем на контейнере: поля пересоздаются при каждой загрузке вкладки.
|
||||||
|
["input", "change"].forEach((event) =>
|
||||||
|
$(target.fields).addEventListener(event, () => refreshFieldSaveState(target)));
|
||||||
|
});
|
||||||
|
|
||||||
function markSaved(containerSelector) {
|
function markSaved(containerSelector) {
|
||||||
$$(`${containerSelector} .cfg-field`).forEach((field) => {
|
$$(`${containerSelector} .cfg-field`).forEach((field) => {
|
||||||
const input = field.querySelector("input, select");
|
const input = field.querySelector("input, select");
|
||||||
if (input) input.dataset.original = input.value;
|
if (input) input.dataset.original = input.value;
|
||||||
field.classList.remove("changed");
|
field.classList.remove("changed");
|
||||||
});
|
});
|
||||||
|
const target = SAVE_TARGETS.find((t) => t.fields === containerSelector);
|
||||||
|
if (target) refreshFieldSaveState(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
$("#btn-config-save").addEventListener("click", async () => {
|
$("#btn-config-save").addEventListener("click", async () => {
|
||||||
@@ -584,6 +609,7 @@ async function loadSandbox() {
|
|||||||
|
|
||||||
if (!data.exists) {
|
if (!data.exists) {
|
||||||
$("#sandbox-empty").textContent = data.hint;
|
$("#sandbox-empty").textContent = data.hint;
|
||||||
|
refreshFieldSaveState(SAVE_TARGETS[1]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const container = $("#sandbox-fields");
|
const container = $("#sandbox-fields");
|
||||||
@@ -591,6 +617,7 @@ async function loadSandbox() {
|
|||||||
container.appendChild(buildConfigField(entry.path, entry.value, null));
|
container.appendChild(buildConfigField(entry.path, entry.value, null));
|
||||||
});
|
});
|
||||||
applyFieldFilter("#sandbox-filter", "#sandbox-fields");
|
applyFieldFilter("#sandbox-filter", "#sandbox-fields");
|
||||||
|
refreshFieldSaveState(SAVE_TARGETS[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$("#btn-sandbox-save").addEventListener("click", async () => {
|
$("#btn-sandbox-save").addEventListener("click", async () => {
|
||||||
@@ -616,6 +643,10 @@ $("#btn-sandbox-save").addEventListener("click", async () => {
|
|||||||
// WorkshopItems — что скачать из мастерской, Mods — что включить и в каком
|
// WorkshopItems — что скачать из мастерской, Mods — что включить и в каком
|
||||||
// порядке. Панель показывает их одним списком: строка на мод, галочка =
|
// порядке. Панель показывает их одним списком: строка на мод, галочка =
|
||||||
// присутствие в Mods, порядок строк = порядок в Mods.
|
// присутствие в Mods, порядок строк = порядок в Mods.
|
||||||
|
// Снимок списков на момент загрузки: по нему видно, есть ли несохранённые
|
||||||
|
// правки. Пустая строка означает, что конфига ещё нет и сохранять нечего.
|
||||||
|
let modsSnapshot = "";
|
||||||
|
|
||||||
let mods = {
|
let mods = {
|
||||||
workshop: [], // WorkshopItems
|
workshop: [], // WorkshopItems
|
||||||
enabled: [], // Mods, порядок важен
|
enabled: [], // Mods, порядок важен
|
||||||
@@ -630,6 +661,8 @@ async function loadMods() {
|
|||||||
$("#mods-body").hidden = !data.exists;
|
$("#mods-body").hidden = !data.exists;
|
||||||
if (!data.exists) {
|
if (!data.exists) {
|
||||||
$("#mods-empty").textContent = data.hint;
|
$("#mods-empty").textContent = data.hint;
|
||||||
|
modsSnapshot = "";
|
||||||
|
refreshModsSaveState();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -641,6 +674,7 @@ async function loadMods() {
|
|||||||
details: mods.details,
|
details: mods.details,
|
||||||
};
|
};
|
||||||
$("#map-list").value = mods.maps.join("\n");
|
$("#map-list").value = mods.maps.join("\n");
|
||||||
|
modsSnapshot = modsState();
|
||||||
renderMods();
|
renderMods();
|
||||||
lookupWorkshopTitles().catch(() => {
|
lookupWorkshopTitles().catch(() => {
|
||||||
/* без интернета останутся идентификаторы — это не ошибка панели */
|
/* без интернета останутся идентификаторы — это не ошибка панели */
|
||||||
@@ -711,6 +745,23 @@ function buildModRows() {
|
|||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// modsState сводит списки к строке — так проще всего заметить и переключённую
|
||||||
|
// галочку, и переставленный порядок загрузки.
|
||||||
|
function modsState() {
|
||||||
|
return JSON.stringify({
|
||||||
|
enabled: mods.enabled,
|
||||||
|
workshop: mods.workshop,
|
||||||
|
maps: $("#map-list").value.split("\n").map((line) => line.trim()).filter(Boolean),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshModsSaveState() {
|
||||||
|
const button = $("#btn-mods-save");
|
||||||
|
const changed = modsSnapshot !== "" && modsState() !== modsSnapshot;
|
||||||
|
button.disabled = !changed;
|
||||||
|
button.title = changed ? "Есть несохранённые изменения" : "Изменений нет";
|
||||||
|
}
|
||||||
|
|
||||||
function renderMods() {
|
function renderMods() {
|
||||||
const filter = $("#mod-filter").value.trim().toLowerCase();
|
const filter = $("#mod-filter").value.trim().toLowerCase();
|
||||||
const onlyEnabled = $("#mod-only-enabled").checked;
|
const onlyEnabled = $("#mod-only-enabled").checked;
|
||||||
@@ -725,6 +776,8 @@ function renderMods() {
|
|||||||
$("#mod-summary").textContent =
|
$("#mod-summary").textContent =
|
||||||
`Модов: ${rows.length}, включено: ${mods.enabled.length}, пакетов мастерской: ${mods.workshop.length}`;
|
`Модов: ${rows.length}, включено: ${mods.enabled.length}, пакетов мастерской: ${mods.workshop.length}`;
|
||||||
|
|
||||||
|
refreshModsSaveState();
|
||||||
|
|
||||||
$("#mod-list").innerHTML = visible
|
$("#mod-list").innerHTML = visible
|
||||||
.map((row) => {
|
.map((row) => {
|
||||||
const on = mods.enabled.includes(row.modID);
|
const on = mods.enabled.includes(row.modID);
|
||||||
@@ -857,6 +910,7 @@ $("#mod-list").addEventListener("drop", (event) => {
|
|||||||
renderMods();
|
renderMods();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$("#map-list").addEventListener("input", refreshModsSaveState);
|
||||||
$("#mod-filter").addEventListener("input", renderMods);
|
$("#mod-filter").addEventListener("input", renderMods);
|
||||||
$("#mod-only-enabled").addEventListener("change", renderMods);
|
$("#mod-only-enabled").addEventListener("change", renderMods);
|
||||||
|
|
||||||
@@ -921,6 +975,8 @@ $("#btn-mods-save").addEventListener("click", async () => {
|
|||||||
workshop_items: mods.workshop,
|
workshop_items: mods.workshop,
|
||||||
map: maps,
|
map: maps,
|
||||||
});
|
});
|
||||||
|
modsSnapshot = modsState();
|
||||||
|
refreshModsSaveState();
|
||||||
ok("Список модов сохранён. Перезапустите сервер, чтобы он их подтянул.");
|
ok("Список модов сохранён. Перезапустите сервер, чтобы он их подтянул.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fail(error);
|
fail(error);
|
||||||
@@ -1212,6 +1268,9 @@ $("#btn-update-apply").addEventListener("click", async () => {
|
|||||||
try {
|
try {
|
||||||
await post("/api/update", { version: updateInfo.latest });
|
await post("/api/update", { version: updateInfo.latest });
|
||||||
waitForRestart("Обновление запущено");
|
waitForRestart("Обновление запущено");
|
||||||
|
// Обновление выполняется отдельной службой, и её вывод в консоль панели
|
||||||
|
// не попадает — панель в это время перезапускается.
|
||||||
|
toast("Ход обновления: journalctl -u pzmanager-update -f");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fail(error);
|
fail(error);
|
||||||
}
|
}
|
||||||
@@ -1299,6 +1358,7 @@ async function loadSettings() {
|
|||||||
} else {
|
} else {
|
||||||
input.value = config[key] ?? "";
|
input.value = config[key] ?? "";
|
||||||
}
|
}
|
||||||
|
input.dataset.original = type === "checkbox" ? String(input.checked) : input.value;
|
||||||
wrapper.appendChild(input);
|
wrapper.appendChild(input);
|
||||||
|
|
||||||
if (hint) {
|
if (hint) {
|
||||||
@@ -1315,6 +1375,7 @@ async function loadSettings() {
|
|||||||
pathNote.textContent = `Файл конфигурации: ${config.config_path}`;
|
pathNote.textContent = `Файл конфигурации: ${config.config_path}`;
|
||||||
form.appendChild(pathNote);
|
form.appendChild(pathNote);
|
||||||
|
|
||||||
|
refreshSettingsSaveState();
|
||||||
await loadUsers();
|
await loadUsers();
|
||||||
// Проверка обновления ходит в интернет и может не ответить — она не должна
|
// Проверка обновления ходит в интернет и может не ответить — она не должна
|
||||||
// мешать показу остальных настроек.
|
// мешать показу остальных настроек.
|
||||||
@@ -1323,6 +1384,21 @@ async function loadSettings() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function refreshSettingsSaveState() {
|
||||||
|
const changed = $$("#settings-form input").filter((input) => {
|
||||||
|
const current = input.type === "checkbox" ? String(input.checked) : input.value;
|
||||||
|
return current !== input.dataset.original;
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
const button = $("#btn-settings-save");
|
||||||
|
button.disabled = changed === 0;
|
||||||
|
button.textContent = changed ? `Сохранить настройки (${changed})` : "Сохранить настройки";
|
||||||
|
button.title = changed ? `Изменено полей: ${changed}` : "Изменений нет";
|
||||||
|
}
|
||||||
|
|
||||||
|
["input", "change"].forEach((event) =>
|
||||||
|
$("#settings-form").addEventListener(event, refreshSettingsSaveState));
|
||||||
|
|
||||||
$("#btn-settings-save").addEventListener("click", async () => {
|
$("#btn-settings-save").addEventListener("click", async () => {
|
||||||
const payload = {};
|
const payload = {};
|
||||||
SETTINGS_FIELDS.forEach(([key, , type]) => {
|
SETTINGS_FIELDS.forEach(([key, , type]) => {
|
||||||
|
|||||||
@@ -145,7 +145,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<input type="search" id="config-filter" placeholder="Поиск параметра…" class="input-sm">
|
<input type="search" id="config-filter" placeholder="Поиск параметра…" class="input-sm">
|
||||||
<button class="btn btn-primary" id="btn-config-save">Сохранить</button>
|
<button class="btn btn-primary" id="btn-config-save" disabled>Сохранить</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted" id="config-empty" hidden></p>
|
<p class="muted" id="config-empty" hidden></p>
|
||||||
@@ -161,7 +161,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<input type="search" id="sandbox-filter" placeholder="Поиск параметра…" class="input-sm">
|
<input type="search" id="sandbox-filter" placeholder="Поиск параметра…" class="input-sm">
|
||||||
<button class="btn btn-primary" id="btn-sandbox-save">Сохранить</button>
|
<button class="btn btn-primary" id="btn-sandbox-save" disabled>Сохранить</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted" id="sandbox-empty" hidden></p>
|
<p class="muted" id="sandbox-empty" hidden></p>
|
||||||
@@ -176,7 +176,7 @@
|
|||||||
<p class="muted small">Галочка включает мод, перетаскивание задаёт порядок загрузки.
|
<p class="muted small">Галочка включает мод, перетаскивание задаёт порядок загрузки.
|
||||||
Сервер сам скачивает моды из мастерской при запуске.</p>
|
Сервер сам скачивает моды из мастерской при запуске.</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary" id="btn-mods-save">Сохранить</button>
|
<button class="btn btn-primary" id="btn-mods-save" disabled>Сохранить</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="muted" id="mods-empty" hidden></p>
|
<p class="muted" id="mods-empty" hidden></p>
|
||||||
@@ -290,7 +290,7 @@
|
|||||||
|
|
||||||
<h2>Настройки менеджера</h2>
|
<h2>Настройки менеджера</h2>
|
||||||
<form id="settings-form" class="fields"></form>
|
<form id="settings-form" class="fields"></form>
|
||||||
<button class="btn btn-primary" id="btn-settings-save">Сохранить настройки</button>
|
<button class="btn btn-primary" id="btn-settings-save" disabled>Сохранить настройки</button>
|
||||||
|
|
||||||
<h2>Пользователи панели</h2>
|
<h2>Пользователи панели</h2>
|
||||||
<ul class="user-list" id="user-list"></ul>
|
<ul class="user-list" id="user-list"></ul>
|
||||||
|
|||||||
Reference in New Issue
Block a user