commit d0ff64c27224cc1b39f36f986fa20e5130c56341 Author: mrleo1nid Date: Thu Sep 3 22:41:11 2026 +0300 Веб-панель управления сервером Project Zomboid Менеджер запускает сервер PZ как дочерний процесс и держит его stdin/stdout: отсюда живая консоль без RCON, стриминг логов через SSE и корректная остановка по цепочке quit -> SIGTERM -> SIGKILL для всей группы процессов. Сам менеджер работает под systemd. Что входит: - управление жизненным циклом сервера, автозапуск и автоподъём после падения; - метрики процесса из /proc и состояние хоста, список игроков онлайн; - редакторы servertest.ini и SandboxVars.lua, сохраняющие исходное форматирование файлов; - моды: списки Workshop ID и Mod ID с порядком загрузки, названия из Steam; - бэкапы мира по расписанию и вручную, с ротацией и восстановлением; - установка и обновление сервера через SteamCMD; - вход по логину и паролю, первичная настройка по одноразовому коду. Веб-интерфейс без сборщика, вшит в бинарник через embed. Co-Authored-By: Claude Opus 5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e4d3cdc --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Собранные бинарники +/pzmanager +/pzmanager.exe + +# Локальные данные разработки +/dev/ + +# Редакторы +.idea/ +.vscode/ +*.swp diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..558cddd --- /dev/null +++ b/Makefile @@ -0,0 +1,29 @@ +BINARY := pzmanager +LDFLAGS := -s -w + +.PHONY: build build-linux test vet fmt run clean + +## build: собрать под текущую платформу +build: + go build -ldflags "$(LDFLAGS)" -o $(BINARY) ./cmd/pzmanager + +## build-linux: собрать статичный бинарник для Ubuntu (amd64) +build-linux: + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o $(BINARY) ./cmd/pzmanager + +## test: прогнать тесты +test: + go test ./... + +vet: + go vet ./... + +fmt: + gofmt -w cmd internal web + +## run: запустить панель локально с конфигом в ./dev +run: + go run ./cmd/pzmanager serve --config ./dev/config.yaml --listen 127.0.0.1:8080 + +clean: + rm -f $(BINARY) diff --git a/README.md b/README.md new file mode 100644 index 0000000..7795a4c --- /dev/null +++ b/README.md @@ -0,0 +1,199 @@ +# PZ Manager + +Веб-панель для управления выделенным сервером Project Zomboid на Ubuntu. +Один бинарник без зависимостей: веб-интерфейс вшит внутрь, Node.js и Python на +сервере не нужны. + +## Что умеет + +- **Управление сервером** — запуск, остановка (через команду `quit`, мир + сохраняется), перезапуск, автоподъём после падения, автозапуск вместе с + менеджером. +- **Живая консоль** — вывод сервера в реальном времени и отправка любых команд + (`players`, `save`, `servermsg`, `kickuser`, …). +- **Мониторинг** — состояние, аптайм, CPU и память процесса сервера, свободные + ОЗУ и диск на хосте, список игроков онлайн. +- **Редактор конфигов** — `servertest.ini` и `SandboxVars.lua` прямо в + браузере, с поиском по параметрам и сохранением комментариев в файле. +- **Моды** — списки Workshop ID и Mod ID с порядком загрузки, названия модов + подтягиваются из мастерской Steam. +- **Бэкапы** — ручные и по расписанию, с ротацией, скачиванием и + восстановлением (перед восстановлением автоматически снимается страховочная + копия). +- **Установка и обновление** сервера через SteamCMD прямо из панели. + +## Как это устроено + +Менеджер запускает `start-server.sh` как дочерний процесс и держит его stdin, +stdout и stderr. Отсюда следует всё остальное: команды консоли пишутся прямо в +stdin (RCON не нужен), логи стримятся в браузер через SSE, а остановка идёт по +цепочке `quit` → `SIGTERM` → `SIGKILL` для всей группы процессов. + +Сам менеджер живёт под systemd, который его и перезапускает. + +``` +systemd ──> pzmanager ──> start-server.sh ──> java (GameServer) + │ + ├── HTTP + SSE ──> браузер + └── SteamCMD, бэкапы, конфиги +``` + +## Быстрый старт + +Собрать бинарник (нужен Go 1.24+): + +```bash +make build-linux +``` + +Скопировать `pzmanager` и папку `deploy/` на сервер и запустить установщик: + +```bash +sudo ./deploy/install.sh +``` + +Установщик заведёт пользователя `pzserver`, поставит зависимости (SteamCMD, +JRE, SDL2), положит бинарник в `/usr/local/bin` и включит systemd-юнит. + +Дальше: + +1. Посмотрите код первичной настройки: + + ```bash + journalctl -u pzmanager -n 30 --no-pager + ``` + +2. Откройте панель. По умолчанию она слушает только `127.0.0.1:8080`, так что + зайти на неё можно через SSH-туннель: + + ```bash + ssh -L 8080:127.0.0.1:8080 пользователь@сервер + ``` + + Затем откройте и создайте администратора, введя код + из журнала. + +3. Во вкладке «Обзор» нажмите **Установить / обновить через SteamCMD** и + дождитесь окончания — прогресс виден во вкладке «Консоль». + +4. Запустите сервер. При первом запуске PZ создаст `~/Zomboid/Server/*.ini` — + после этого станут доступны вкладки «Конфиг сервера», «Песочница» и «Моды». + +Не забудьте открыть игровые порты: + +```bash +sudo ufw allow 16261/udp +sudo ufw allow 16262/udp +``` + +## Доступ снаружи + +Панель управляет сервером, поэтому наружу её лучше не выставлять голой. Два +разумных варианта: + +**SSH-туннель (по умолчанию)** — ничего настраивать не нужно, панель слушает +только localhost. + +**HTTPS через reverse-proxy** — если панель нужна с телефона. Настройте nginx с +сертификатом (например, от Let's Encrypt): + +```nginx +server { + listen 443 ssl; + server_name pz.example.com; + + ssl_certificate /etc/letsencrypt/live/pz.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/pz.example.com/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + + # Живая консоль работает через Server-Sent Events. + proxy_buffering off; + proxy_read_timeout 24h; + } +} +``` + +И запустите менеджер с флагом `--secure-cookies`, чтобы cookie сессии +передавалась только по HTTPS (добавьте флаг в `ExecStart` юнита). + +## Команды + +```bash +pzmanager serve # запустить панель (команда по умолчанию) +pzmanager useradd <логин> # создать пользователя панели +pzmanager passwd <логин> # сменить пароль +pzmanager users # список пользователей +``` + +Флаги: `--config <путь>`, `--listen <адрес>`, `--secure-cookies`. + +## Конфигурация + +Файл `~/.config/pzmanager/config.yaml` создаётся при первом запуске. Всё, что в +нём есть, правится и через вкладку «Настройки». + +```yaml +listen: 127.0.0.1:8080 # адрес веб-панели +data_dir: /home/pzserver/.local/share/pzmanager +server_dir: /home/pzserver/pzserver # куда SteamCMD ставит сервер +zomboid_dir: /home/pzserver/Zomboid # миры, конфиги и логи игры +steamcmd_path: "" # пусто — найти в PATH или скачать +server_name: servertest # servertest -> servertest.ini +admin_password: "" # пароль админа игрового сервера +java_memory: 4g # -Xmx для JVM +extra_args: [] # доп. аргументы start-server.sh +autostart: false # поднимать сервер вместе с менеджером +autorestart: true # поднимать сервер после падения +stop_timeout: 1m30s # сколько ждать после quit до SIGTERM +log_buffer_lines: 5000 # строк консоли в памяти +backup: + dir: /home/pzserver/pzbackups + schedule: 6h # 0 — отключить автобэкапы + keep: 20 # сколько архивов хранить + stop_server: false # останавливать сервер на время бэкапа +``` + +Изменения `listen` применяются после `systemctl restart pzmanager`; остальные — +при следующем запуске игрового сервера. + +## Бэкапы + +В архив попадают `~/Zomboid/Saves` и `~/Zomboid/Server` (миры и конфиги). Имя +файла — `pz-ГГГГММДД-ЧЧММСС[-пометка].tar.gz`. + +Бэкап на живом сервере технически корректен, но снимок может отставать от +состояния мира в памяти. Если хочется гарантированной согласованности — +включите `backup.stop_server` (игроков на время бэкапа выкинет) или отправляйте +`save` в консоль перед ручным бэкапом. + +Восстановление требует остановленного сервера. Перед распаковкой менеджер сам +делает страховочную копию с пометкой `pre-restore`. + +## Безопасность + +- Пароли пользователей панели хранятся в виде bcrypt-хешей в + `data_dir/users.json` (права 0600). +- Сессии живут только в памяти: перезапуск менеджера разлогинивает всех. +- Первого администратора можно создать только с одноразовым кодом из журнала. +- Изменяющие запросы требуют собственный заголовок — это защита от CSRF. +- Панель не выполняет произвольные команды хоста: всё, что она умеет, — это + управление процессом сервера, SteamCMD и файлами в своих директориях. + +Тем не менее доступ к панели равносилен доступу к игровому серверу, так что +пароль стоит выбрать не короче того, что вы поставили бы на SSH. + +## Разработка + +```bash +make test # тесты +make vet # go vet +make run # локальный запуск с конфигом в ./dev +``` + +Веб-интерфейс — обычные HTML, CSS и JS в `web/static`, без сборщика; при +`go build` они вшиваются в бинарник через `embed`. diff --git a/cmd/pzmanager/main.go b/cmd/pzmanager/main.go new file mode 100644 index 0000000..4a4eded --- /dev/null +++ b/cmd/pzmanager/main.go @@ -0,0 +1,280 @@ +// Команда pzmanager — веб-панель управления сервером Project Zomboid. +package main + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "flag" + "fmt" + "net/http" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" + + "golang.org/x/term" + + "github.com/mrleo1nid/pzmanager/internal/api" + "github.com/mrleo1nid/pzmanager/internal/auth" + "github.com/mrleo1nid/pzmanager/internal/backup" + "github.com/mrleo1nid/pzmanager/internal/config" + "github.com/mrleo1nid/pzmanager/internal/job" + "github.com/mrleo1nid/pzmanager/internal/pzserver" + "github.com/mrleo1nid/pzmanager/internal/steamcmd" + "github.com/mrleo1nid/pzmanager/web" +) + +const usage = `pzmanager — панель управления сервером Project Zomboid. + +Использование: + pzmanager [serve] запустить панель (команда по умолчанию) + pzmanager useradd <логин> создать пользователя панели + pzmanager passwd <логин> сменить пароль + pzmanager users показать список пользователей + +Флаги: +` + +func main() { + fs := flag.NewFlagSet("pzmanager", flag.ExitOnError) + configPath := fs.String("config", defaultConfigPath(), "путь к config.yaml") + listen := fs.String("listen", "", "адрес прослушивания (перекрывает конфиг)") + secureCookies := fs.Bool("secure-cookies", false, + "ставить флаг Secure у cookie сессии (панель работает по HTTPS)") + fs.Usage = func() { + fmt.Fprint(os.Stderr, usage) + fs.PrintDefaults() + } + + command := "serve" + args := os.Args[1:] + if len(args) > 0 && !strings.HasPrefix(args[0], "-") { + command, args = args[0], args[1:] + } + if err := fs.Parse(args); err != nil { + os.Exit(2) + } + + cfg, err := config.Load(*configPath) + if err != nil { + fatal(err) + } + if *listen != "" { + cfg.Listen = *listen + } + if err := cfg.EnsureDirs(); err != nil { + fatal(err) + } + + users, err := auth.NewStore(filepath.Join(cfg.DataDir, "users.json"), 12*time.Hour) + if err != nil { + fatal(err) + } + + switch command { + case "serve": + if err := serve(cfg, users, *secureCookies); err != nil { + fatal(err) + } + case "useradd": + if err := cmdUserAdd(users, fs.Args()); err != nil { + fatal(err) + } + case "passwd": + if err := cmdPasswd(users, fs.Args()); err != nil { + fatal(err) + } + case "users": + for _, login := range users.Logins() { + fmt.Println(login) + } + default: + fmt.Fprintf(os.Stderr, "неизвестная команда %q\n\n", command) + fs.Usage() + os.Exit(2) + } +} + +func serve(cfg *config.Config, users *auth.Store, secureCookies bool) error { + static, err := web.Static() + if err != nil { + return err + } + + supervisor := pzserver.New(cfg) + defer supervisor.Close() + + jobs := job.New(supervisor.AppendLog) + backups := backup.New(cfg) + steam := &steamcmd.Client{Path: cfg.SteamCMDPath, InstallRoot: cfg.DataDir} + + // Пока учёток нет, создать первого администратора можно только с этим + // кодом — иначе панель захватит любой, кто откроет её первым. + setupToken := "" + if users.IsEmpty() { + setupToken = randomToken() + fmt.Printf("\n Панель ещё не настроена.\n"+ + " Откройте её в браузере и введите код первичной настройки:\n\n %s\n\n", setupToken) + } + + apiServer := api.New(api.Options{ + Config: cfg, + Users: users, + Supervisor: supervisor, + Jobs: jobs, + Backups: backups, + Steam: steam, + Static: static, + SecureCookies: secureCookies, + SetupToken: setupToken, + }) + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + api.StartBackupScheduler(ctx, apiServer) + go sessionGC(ctx, users) + + httpServer := &http.Server{ + Addr: cfg.Listen, + Handler: apiServer.Handler(), + // Записи без таймаута: SSE-поток логов живёт, пока открыта вкладка. + ReadHeaderTimeout: 10 * time.Second, + IdleTimeout: 120 * time.Second, + } + + if cfg.AutoStart { + if err := supervisor.Start(); err != nil { + supervisor.AppendLog("manager", "Автозапуск не удался: "+err.Error()) + } + } + + errCh := make(chan error, 1) + go func() { + fmt.Printf("pzmanager слушает http://%s\n", cfg.Listen) + if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- err + } + }() + + select { + case err := <-errCh: + return err + case <-ctx.Done(): + } + + fmt.Println("\nЗавершение работы…") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := httpServer.Shutdown(shutdownCtx); err != nil { + fmt.Fprintln(os.Stderr, "HTTP-сервер закрыт принудительно:", err) + } + + // Игровой сервер останавливаем последним: это самая долгая часть, зато + // мир гарантированно сохранён. + if supervisor.State() != pzserver.StateStopped { + fmt.Println("Останавливаю игровой сервер (это может занять пару минут)…") + if err := supervisor.Stop(); err != nil && !errors.Is(err, pzserver.ErrNotRunning) { + return err + } + } + return nil +} + +// sessionGC периодически чистит протухшие сессии. +func sessionGC(ctx context.Context, users *auth.Store) { + ticker := time.NewTicker(30 * time.Minute) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + users.GC() + } + } +} + +func cmdUserAdd(users *auth.Store, args []string) error { + if len(args) != 1 { + return errors.New("укажите логин: pzmanager useradd <логин>") + } + password, err := readPassword("Пароль: ") + if err != nil { + return err + } + repeat, err := readPassword("Ещё раз: ") + if err != nil { + return err + } + if password != repeat { + return errors.New("пароли не совпадают") + } + if err := users.AddUser(args[0], password); err != nil { + return err + } + fmt.Println("Пользователь создан:", args[0]) + return nil +} + +func cmdPasswd(users *auth.Store, args []string) error { + if len(args) != 1 { + return errors.New("укажите логин: pzmanager passwd <логин>") + } + password, err := readPassword("Новый пароль: ") + if err != nil { + return err + } + if err := users.SetPassword(args[0], password); err != nil { + return err + } + fmt.Println("Пароль изменён:", args[0]) + return nil +} + +// readPassword читает пароль без эха, если запущено в терминале. +func readPassword(prompt string) (string, error) { + fmt.Print(prompt) + fd := int(syscall.Stdin) + if !term.IsTerminal(fd) { + var line string + _, err := fmt.Scanln(&line) + return line, err + } + raw, err := term.ReadPassword(fd) + fmt.Println() + if err != nil { + return "", err + } + return string(raw), nil +} + +func randomToken() string { + buf := make([]byte, 12) + if _, err := rand.Read(buf); err != nil { + // Практически недостижимо; лучше упасть, чем выдать предсказуемый код. + fatal(err) + } + return hex.EncodeToString(buf) +} + +// defaultConfigPath — ~/.config/pzmanager/config.yaml. +func defaultConfigPath() string { + if env := os.Getenv("PZMANAGER_CONFIG"); env != "" { + return env + } + dir, err := os.UserConfigDir() + if err != nil { + return "config.yaml" + } + return filepath.Join(dir, "pzmanager", "config.yaml") +} + +func fatal(err error) { + fmt.Fprintln(os.Stderr, "pzmanager:", err) + os.Exit(1) +} diff --git a/deploy/install.sh b/deploy/install.sh new file mode 100644 index 0000000..fa2440b --- /dev/null +++ b/deploy/install.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Установка pzmanager на Ubuntu. Запускать от root: +# sudo ./deploy/install.sh +# +# Скрипт: заводит системного пользователя, кладёт бинарник в /usr/local/bin, +# ставит systemd-юнит и зависимости, нужные серверу Project Zomboid. +set -euo pipefail + +PZ_USER="${PZ_USER:-pzserver}" +PZ_HOME="${PZ_HOME:-/home/${PZ_USER}}" +BINARY="${BINARY:-./pzmanager}" +UNIT_SRC="$(dirname "$0")/pzmanager.service" + +if [[ $EUID -ne 0 ]]; then + echo "Запустите скрипт от root: sudo $0" >&2 + exit 1 +fi + +if [[ ! -f "$BINARY" ]]; then + echo "Не найден бинарник $BINARY. Соберите его: make build-linux" >&2 + exit 1 +fi + +echo "==> Устанавливаю системные зависимости" +apt-get update -qq +# lib32gcc-s1 нужен SteamCMD, остальное — самому серверу PZ. +apt-get install -y --no-install-recommends \ + ca-certificates curl lib32gcc-s1 libsdl2-2.0-0 openjdk-17-jre-headless + +if ! id "$PZ_USER" &>/dev/null; then + echo "==> Создаю пользователя $PZ_USER" + adduser --system --group --home "$PZ_HOME" --shell /bin/bash "$PZ_USER" +fi + +echo "==> Копирую бинарник в /usr/local/bin/pzmanager" +install -m 0755 "$BINARY" /usr/local/bin/pzmanager + +echo "==> Готовлю директории" +install -d -o "$PZ_USER" -g "$PZ_USER" -m 0755 \ + "$PZ_HOME/.config/pzmanager" \ + "$PZ_HOME/.local/share/pzmanager" \ + "$PZ_HOME/pzserver" \ + "$PZ_HOME/pzbackups" + +echo "==> Ставлю systemd-юнит" +sed -e "s|/home/pzserver|${PZ_HOME}|g" \ + -e "s|User=pzserver|User=${PZ_USER}|" \ + -e "s|Group=pzserver|Group=${PZ_USER}|" \ + "$UNIT_SRC" > /etc/systemd/system/pzmanager.service + +systemctl daemon-reload +systemctl enable --now pzmanager + +echo +echo "Готово. Панель запущена." +echo "Код первичной настройки смотрите в журнале:" +echo +echo " journalctl -u pzmanager -n 30 --no-pager" +echo +echo "По умолчанию панель слушает 127.0.0.1:8080." +echo "Откройте её через SSH-туннель:" +echo +echo " ssh -L 8080:127.0.0.1:8080 <пользователь>@<сервер>" +echo +echo "Не забудьте открыть игровые порты: udp/16261 и udp/16262." diff --git a/deploy/pzmanager.service b/deploy/pzmanager.service new file mode 100644 index 0000000..fc66ff3 --- /dev/null +++ b/deploy/pzmanager.service @@ -0,0 +1,32 @@ +[Unit] +Description=PZ Manager — веб-панель сервера Project Zomboid +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=pzserver +Group=pzserver +WorkingDirectory=/home/pzserver +ExecStart=/usr/local/bin/pzmanager serve --config /home/pzserver/.config/pzmanager/config.yaml +Restart=on-failure +RestartSec=5 + +# Менеджер сам останавливает игровой сервер по SIGTERM: сначала отправляет +# команду quit (мир сохраняется), потом добивает процесс. Дадим ему время. +KillSignal=SIGTERM +KillMode=mixed +TimeoutStopSec=300 + +# Панель пишет только в свои директории. +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full +ProtectHome=read-only +ReadWritePaths=/home/pzserver + +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bcf411a --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module github.com/mrleo1nid/pzmanager + +go 1.24 + +require ( + golang.org/x/crypto v0.31.0 + golang.org/x/sys v0.28.0 + golang.org/x/term v0.27.0 + gopkg.in/yaml.v3 v3.0.1 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..31dab60 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/handlers_auth.go b/internal/api/handlers_auth.go new file mode 100644 index 0000000..27c200c --- /dev/null +++ b/internal/api/handlers_auth.go @@ -0,0 +1,130 @@ +package api + +import ( + "crypto/subtle" + "errors" + "net/http" + "time" + + "github.com/mrleo1nid/pzmanager/internal/auth" +) + +// sessionTTL — сколько живёт сессия без активности. +const sessionTTL = 12 * time.Hour + +type credentials struct { + Login string `json:"login"` + Password string `json:"password"` + // Token нужен только при первичной настройке. + Token string `json:"token,omitempty"` +} + +// handleSession сообщает фронтенду, вошёл ли пользователь и нужна ли +// первоначальная настройка (когда учёток ещё нет). +func (s *Server) handleSession(w http.ResponseWriter, r *http.Request) { + login, ok := s.users.SessionFromRequest(r) + writeJSON(w, http.StatusOK, map[string]any{ + "authenticated": ok, + "login": login, + "setup_needed": s.users.IsEmpty(), + }) +} + +// handleSetup создаёт первого администратора. Доступен, только пока учёток нет. +func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) { + if !s.users.IsEmpty() { + writeError(w, http.StatusConflict, "панель уже настроена") + return + } + var creds credentials + if !decodeJSON(w, r, &creds) { + return + } + // Токен печатается в журнал менеджера при старте: без него первым + // администратором стал бы любой, кто успел открыть страницу. + if s.setupToken == "" || subtle.ConstantTimeCompare([]byte(creds.Token), []byte(s.setupToken)) != 1 { + writeError(w, http.StatusForbidden, "неверный код первичной настройки") + return + } + if err := s.users.AddUser(creds.Login, creds.Password); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + id, err := s.users.Login(creds.Login, creds.Password) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + s.setSessionCookie(w, id, sessionTTL) + writeJSON(w, http.StatusOK, map[string]any{"authenticated": true, "login": creds.Login}) +} + +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + var creds credentials + if !decodeJSON(w, r, &creds) { + return + } + id, err := s.users.Login(creds.Login, creds.Password) + if err != nil { + if errors.Is(err, auth.ErrBadCredentials) { + writeError(w, http.StatusUnauthorized, err.Error()) + return + } + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + s.setSessionCookie(w, id, sessionTTL) + writeJSON(w, http.StatusOK, map[string]any{"authenticated": true, "login": creds.Login}) +} + +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + if c, err := r.Cookie(auth.SessionCookie); err == nil { + s.users.Logout(c.Value) + } + s.clearSessionCookie(w) + writeJSON(w, http.StatusOK, map[string]any{"authenticated": false}) +} + +func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{"users": s.users.Logins()}) +} + +func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) { + var creds credentials + if !decodeJSON(w, r, &creds) { + return + } + if err := s.users.AddUser(creds.Login, creds.Password); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, http.StatusCreated, map[string]any{"login": creds.Login}) +} + +func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) { + login := r.PathValue("login") + current, _ := s.users.SessionFromRequest(r) + if login == current { + writeError(w, http.StatusBadRequest, "нельзя удалить учётную запись, под которой вы вошли") + return + } + if err := s.users.DeleteUser(login); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"deleted": login}) +} + +func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) { + var body struct { + Password string `json:"password"` + } + if !decodeJSON(w, r, &body) { + return + } + if err := s.users.SetPassword(r.PathValue("login"), body.Password); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} diff --git a/internal/api/handlers_config.go b/internal/api/handlers_config.go new file mode 100644 index 0000000..9d20a14 --- /dev/null +++ b/internal/api/handlers_config.go @@ -0,0 +1,208 @@ +package api + +import ( + "net/http" + "os" + "strconv" + "time" + + "github.com/mrleo1nid/pzmanager/internal/config" + "github.com/mrleo1nid/pzmanager/internal/pzconfig" +) + +// handleGetServerConfig отдаёт содержимое servertest.ini. Файл появляется +// только после первого запуска сервера, поэтому его отсутствие — не ошибка. +func (s *Server) handleGetServerConfig(w http.ResponseWriter, r *http.Request) { + path := s.cfg.ServerConfigPath() + ini, err := pzconfig.LoadINI(path) + if os.IsNotExist(err) { + writeJSON(w, http.StatusOK, map[string]any{ + "exists": false, + "path": path, + "hint": "Файл создаётся при первом запуске сервера", + }) + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "exists": true, + "path": path, + "keys": ini.Keys(), + "values": ini.Map(), + }) +} + +// handlePutServerConfig правит только присланные ключи, остальные строки файла +// (включая комментарии) остаются нетронутыми. +func (s *Server) handlePutServerConfig(w http.ResponseWriter, r *http.Request) { + var body struct { + Values map[string]string `json:"values"` + } + if !decodeJSON(w, r, &body) { + return + } + path := s.cfg.ServerConfigPath() + ini, err := pzconfig.LoadINI(path) + if err != nil { + writeError(w, http.StatusBadRequest, "конфиг сервера недоступен: "+err.Error()) + return + } + for key, value := range body.Values { + ini.Set(key, value) + } + if err := pzconfig.SaveINI(path, ini); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + s.sup.AppendLog("manager", "Конфиг сервера сохранён (изменений: "+strconv.Itoa(len(body.Values))+"). Изменения вступят в силу после перезапуска.") + writeJSON(w, http.StatusOK, map[string]any{"saved": len(body.Values)}) +} + +func (s *Server) handleGetSandbox(w http.ResponseWriter, r *http.Request) { + path := s.cfg.SandboxConfigPath() + sb, err := pzconfig.LoadSandbox(path) + if os.IsNotExist(err) { + writeJSON(w, http.StatusOK, map[string]any{ + "exists": false, + "path": path, + "hint": "Файл создаётся при первом запуске сервера", + }) + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "exists": true, + "path": path, + "entries": sb.Entries(), + }) +} + +func (s *Server) handlePutSandbox(w http.ResponseWriter, r *http.Request) { + var body struct { + Values map[string]string `json:"values"` + } + if !decodeJSON(w, r, &body) { + return + } + path := s.cfg.SandboxConfigPath() + sb, err := pzconfig.LoadSandbox(path) + if err != nil { + writeError(w, http.StatusBadRequest, "настройки песочницы недоступны: "+err.Error()) + return + } + for key, value := range body.Values { + if err := sb.Set(key, value); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + } + if err := pzconfig.SaveSandbox(path, sb); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + s.sup.AppendLog("manager", "Настройки песочницы сохранены. Часть параметров применяется только к новому миру.") + writeJSON(w, http.StatusOK, map[string]any{"saved": len(body.Values)}) +} + +// managerConfigDTO — вид конфига менеджера, удобный для формы в браузере: +// длительности в секундах, пароль наружу не отдаётся. +type managerConfigDTO struct { + Listen string `json:"listen"` + ServerDir string `json:"server_dir"` + ZomboidDir string `json:"zomboid_dir"` + SteamCMDPath string `json:"steamcmd_path"` + ServerName string `json:"server_name"` + JavaMemory string `json:"java_memory"` + ExtraArgs []string `json:"extra_args"` + AutoStart bool `json:"autostart"` + AutoRestart bool `json:"autorestart"` + StopTimeoutSec int64 `json:"stop_timeout_sec"` + LogBufferLines int `json:"log_buffer_lines"` + BackupDir string `json:"backup_dir"` + BackupScheduleMin int64 `json:"backup_schedule_min"` + BackupKeep int `json:"backup_keep"` + BackupStopServer bool `json:"backup_stop_server"` + HasAdminPassword bool `json:"has_admin_password"` + AdminPassword string `json:"admin_password,omitempty"` + ConfigPath string `json:"config_path"` + RequiresRestartMsg string `json:"requires_restart_msg,omitempty"` +} + +func (s *Server) handleGetManagerConfig(w http.ResponseWriter, r *http.Request) { + c := s.cfg + writeJSON(w, http.StatusOK, managerConfigDTO{ + Listen: c.Listen, + ServerDir: c.ServerDir, + ZomboidDir: c.ZomboidDir, + SteamCMDPath: c.SteamCMDPath, + ServerName: c.ServerName, + JavaMemory: c.JavaMemory, + ExtraArgs: c.ExtraArgs, + AutoStart: c.AutoStart, + AutoRestart: c.AutoRestart, + StopTimeoutSec: c.StopTimeout.Seconds(), + LogBufferLines: c.LogBufferLines, + BackupDir: c.Backup.Dir, + BackupScheduleMin: int64(c.Backup.Schedule.Duration().Minutes()), + BackupKeep: c.Backup.Keep, + BackupStopServer: c.Backup.StopServer, + HasAdminPassword: c.AdminPassword != "", + ConfigPath: c.Path(), + }) +} + +// handlePutManagerConfig сохраняет настройки менеджера. Пустой admin_password +// означает "оставить прежний", иначе его нельзя было бы не потерять при +// каждом сохранении формы. +func (s *Server) handlePutManagerConfig(w http.ResponseWriter, r *http.Request) { + var dto managerConfigDTO + if !decodeJSON(w, r, &dto) { + return + } + + c := s.cfg + c.Listen = dto.Listen + c.ServerDir = dto.ServerDir + c.ZomboidDir = dto.ZomboidDir + c.SteamCMDPath = dto.SteamCMDPath + c.ServerName = dto.ServerName + c.JavaMemory = dto.JavaMemory + // Пустой список аргументов от формы означает "не трогать": ExtraArgs + // правятся только через файл конфигурации. + if dto.ExtraArgs != nil { + c.ExtraArgs = dto.ExtraArgs + } + c.AutoStart = dto.AutoStart + c.AutoRestart = dto.AutoRestart + c.LogBufferLines = dto.LogBufferLines + c.Backup.Dir = dto.BackupDir + c.Backup.Keep = dto.BackupKeep + c.Backup.StopServer = dto.BackupStopServer + if dto.StopTimeoutSec > 0 { + c.StopTimeout = config.Duration(time.Duration(dto.StopTimeoutSec) * time.Second) + } + c.Backup.Schedule = config.Duration(time.Duration(dto.BackupScheduleMin) * time.Minute) + if dto.AdminPassword != "" { + c.AdminPassword = dto.AdminPassword + } + + if err := c.Save(); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if err := c.EnsureDirs(); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + s.sup.AppendLog("manager", "Настройки менеджера сохранены") + writeJSON(w, http.StatusOK, map[string]any{ + "saved": true, + "note": "Адрес прослушивания применится после перезапуска pzmanager", + }) +} diff --git a/internal/api/handlers_mods.go b/internal/api/handlers_mods.go new file mode 100644 index 0000000..2de92e1 --- /dev/null +++ b/internal/api/handlers_mods.go @@ -0,0 +1,84 @@ +package api + +import ( + "net/http" + "os" + + "github.com/mrleo1nid/pzmanager/internal/pzconfig" +) + +// handleGetMods отдаёт списки модов из servertest.ini. Названия модов не +// запрашиваются здесь: поход в Steam медленный, для него есть отдельный +// эндпоинт lookup. +func (s *Server) handleGetMods(w http.ResponseWriter, r *http.Request) { + path := s.cfg.ServerConfigPath() + ini, err := pzconfig.LoadINI(path) + if os.IsNotExist(err) { + writeJSON(w, http.StatusOK, map[string]any{ + "exists": false, + "path": path, + "hint": "Файл конфигурации появится после первого запуска сервера", + }) + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + mods, workshop := ini.Mods() + writeJSON(w, http.StatusOK, map[string]any{ + "exists": true, + "path": path, + "mods": mods, + "workshop_items": workshop, + "map": pzconfig.SplitList(ini.Get(pzconfig.KeyMap)), + }) +} + +func (s *Server) handlePutMods(w http.ResponseWriter, r *http.Request) { + var body struct { + Mods []string `json:"mods"` + WorkshopItems []string `json:"workshop_items"` + Map []string `json:"map"` + } + if !decodeJSON(w, r, &body) { + return + } + + path := s.cfg.ServerConfigPath() + ini, err := pzconfig.LoadINI(path) + if err != nil { + writeError(w, http.StatusBadRequest, "конфиг сервера недоступен: "+err.Error()) + return + } + ini.SetMods(body.Mods, body.WorkshopItems) + if len(body.Map) > 0 { + ini.Set(pzconfig.KeyMap, pzconfig.JoinList(body.Map)) + } + if err := pzconfig.SaveINI(path, ini); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + s.sup.AppendLog("manager", "Список модов обновлён. Сервер докачает моды из мастерской при следующем запуске.") + writeJSON(w, http.StatusOK, map[string]any{"saved": true}) +} + +// handleLookupMods спрашивает у Steam названия модов по Workshop ID. +func (s *Server) handleLookupMods(w http.ResponseWriter, r *http.Request) { + var body struct { + IDs []string `json:"ids"` + } + if !decodeJSON(w, r, &body) { + return + } + if len(body.IDs) == 0 { + writeJSON(w, http.StatusOK, map[string]any{"items": []pzconfig.WorkshopItem{}}) + return + } + if len(body.IDs) > 200 { + writeError(w, http.StatusBadRequest, "слишком много идентификаторов за один запрос") + return + } + items := s.workshop.Details(r.Context(), body.IDs) + writeJSON(w, http.StatusOK, map[string]any{"items": items}) +} diff --git a/internal/api/handlers_server.go b/internal/api/handlers_server.go new file mode 100644 index 0000000..46b1b4b --- /dev/null +++ b/internal/api/handlers_server.go @@ -0,0 +1,138 @@ +package api + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/mrleo1nid/pzmanager/internal/pzserver" +) + +func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "server": s.sup.Status(), + "task": s.jobs.Status(), + }) +} + +func (s *Server) handleStart(w http.ResponseWriter, r *http.Request) { + if err := s.sup.Start(); err != nil { + writeOpError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"state": s.sup.State()}) +} + +// handleStop останавливает сервер в фоне: корректное завершение занимает до +// нескольких минут, и держать HTTP-запрос всё это время незачем — панель +// видит прогресс по состоянию и логам. +func (s *Server) handleStop(w http.ResponseWriter, r *http.Request) { + if s.sup.State() == pzserver.StateStopped { + writeOpError(w, pzserver.ErrNotRunning) + return + } + go func() { + if err := s.sup.Stop(); err != nil && !errors.Is(err, pzserver.ErrNotRunning) { + s.sup.AppendLog("manager", "Остановка не удалась: "+err.Error()) + } + }() + writeJSON(w, http.StatusAccepted, map[string]any{"state": pzserver.StateStopping}) +} + +func (s *Server) handleRestart(w http.ResponseWriter, r *http.Request) { + if !s.sup.Installed() { + writeOpError(w, pzserver.ErrNotInstalled) + return + } + go func() { + if err := s.sup.Restart(); err != nil { + s.sup.AppendLog("manager", "Перезапуск не удался: "+err.Error()) + } + }() + writeJSON(w, http.StatusAccepted, map[string]any{"state": s.sup.State()}) +} + +func (s *Server) handleConsole(w http.ResponseWriter, r *http.Request) { + var body struct { + Command string `json:"command"` + } + if !decodeJSON(w, r, &body) { + return + } + if err := s.sup.Send(body.Command); err != nil { + writeOpError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"sent": body.Command}) +} + +// handleLogs отдаёт историю консоли начиная с указанного номера строки. +func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) { + since, _ := strconv.ParseInt(r.URL.Query().Get("since"), 10, 64) + writeJSON(w, http.StatusOK, map[string]any{"lines": s.sup.Lines(since)}) +} + +// handleLogStream — живая консоль через Server-Sent Events. SSE выбран вместо +// вебсокетов: поток односторонний, а переподключение браузер берёт на себя. +func (s *Server) handleLogStream(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + writeError(w, http.StatusInternalServerError, "потоковая отдача не поддерживается") + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + // Отключаем буферизацию в nginx, иначе строки придут пачкой. + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + + lines, unsubscribe := s.sup.Subscribe() + defer unsubscribe() + + // Сначала отдаём хвост истории, чтобы вкладка не открывалась пустой. + since, _ := strconv.ParseInt(r.URL.Query().Get("since"), 10, 64) + for _, line := range s.sup.Lines(since) { + if !writeSSE(w, line) { + return + } + } + flusher.Flush() + + // Пинг не даёт прокси закрыть простаивающее соединение. + ping := time.NewTicker(25 * time.Second) + defer ping.Stop() + + for { + select { + case <-r.Context().Done(): + return + case line, ok := <-lines: + if !ok { + return + } + if !writeSSE(w, line) { + return + } + flusher.Flush() + case <-ping.C: + if _, err := fmt.Fprint(w, ": ping\n\n"); err != nil { + return + } + flusher.Flush() + } + } +} + +func writeSSE(w http.ResponseWriter, line pzserver.LogLine) bool { + payload, err := json.Marshal(line) + if err != nil { + return true + } + _, err = fmt.Fprintf(w, "data: %s\n\n", payload) + return err == nil +} diff --git a/internal/api/handlers_tasks.go b/internal/api/handlers_tasks.go new file mode 100644 index 0000000..4d2c8be --- /dev/null +++ b/internal/api/handlers_tasks.go @@ -0,0 +1,212 @@ +package api + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "time" + + "github.com/mrleo1nid/pzmanager/internal/job" + "github.com/mrleo1nid/pzmanager/internal/pzconfig" + "github.com/mrleo1nid/pzmanager/internal/pzserver" +) + +func (s *Server) handleTaskStatus(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, s.jobs.Status()) +} + +func (s *Server) handleTaskCancel(w http.ResponseWriter, r *http.Request) { + if !s.jobs.Busy() { + writeError(w, http.StatusConflict, "нет активной задачи") + return + } + s.jobs.Cancel() + writeJSON(w, http.StatusOK, map[string]any{"cancelled": true}) +} + +// handleInstall ставит или обновляет серверные файлы через SteamCMD. Обновлять +// файлы под работающим сервером нельзя: SteamCMD перезапишет то, что открыто +// JVM, и сервер упадёт непредсказуемо. +func (s *Server) handleInstall(w http.ResponseWriter, r *http.Request) { + if s.sup.State() != pzserver.StateStopped { + writeError(w, http.StatusConflict, "сначала остановите игровой сервер") + return + } + + name := "Установка/обновление сервера через SteamCMD" + err := s.jobs.Start(name, func(ctx context.Context, log func(string)) error { + if err := s.steam.InstallOrUpdate(ctx, log, s.cfg.ServerDir); err != nil { + return err + } + if err := s.applyJavaMemory(log); err != nil { + log("Не удалось выставить объём памяти JVM: " + err.Error()) + } + return nil + }) + if err != nil { + writeOpError(w, err) + return + } + writeJSON(w, http.StatusAccepted, map[string]any{"task": name}) +} + +func (s *Server) handleListBackups(w http.ResponseWriter, r *http.Request) { + archives, err := s.backups.List() + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"backups": archives, "dir": s.backups.Dir()}) +} + +func (s *Server) handleCreateBackup(w http.ResponseWriter, r *http.Request) { + var body struct { + Note string `json:"note"` + } + if r.ContentLength > 0 && !decodeJSON(w, r, &body) { + return + } + + name := "Резервное копирование мира" + err := s.jobs.Start(name, func(ctx context.Context, log func(string)) error { + // Опциональная остановка сервера даёт согласованный снимок: PZ + // сбрасывает мир на диск только при сохранении. + restart := false + if s.cfg.Backup.StopServer && s.sup.State() != pzserver.StateStopped { + log("Останавливаю сервер перед бэкапом") + if err := s.sup.Stop(); err != nil && !errors.Is(err, pzserver.ErrNotRunning) { + return err + } + restart = true + } + + _, err := s.backups.Create(ctx, log, body.Note) + + if restart { + log("Поднимаю сервер обратно") + if startErr := s.sup.Start(); startErr != nil { + log("Не удалось запустить сервер: " + startErr.Error()) + } + } + return err + }) + if err != nil { + writeOpError(w, err) + return + } + writeJSON(w, http.StatusAccepted, map[string]any{"task": name}) +} + +func (s *Server) handleDeleteBackup(w http.ResponseWriter, r *http.Request) { + if err := s.backups.Delete(r.PathValue("name")); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"deleted": r.PathValue("name")}) +} + +func (s *Server) handleDownloadBackup(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + path, err := s.backups.Path(name) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + f, err := os.Open(path) + if err != nil { + writeError(w, http.StatusNotFound, "архив не найден") + return + } + defer f.Close() + info, err := f.Stat() + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + w.Header().Set("Content-Type", "application/gzip") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name)) + http.ServeContent(w, r, name, info.ModTime(), f) +} + +// handleRestoreBackup разворачивает архив поверх текущего мира. Сервер должен +// быть остановлен — иначе он держит файлы мира открытыми и перезапишет их +// своим состоянием при следующем сохранении. +func (s *Server) handleRestoreBackup(w http.ResponseWriter, r *http.Request) { + if s.sup.State() != pzserver.StateStopped { + writeError(w, http.StatusConflict, "сначала остановите игровой сервер") + return + } + name := r.PathValue("name") + if _, err := s.backups.Path(name); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + taskName := "Восстановление из архива " + name + err := s.jobs.Start(taskName, func(ctx context.Context, log func(string)) error { + // Перед перезаписью снимаем страховочную копию текущего состояния: + // восстановление не туда — самая дорогая ошибка в этой панели. + log("Делаю страховочный бэкап текущего мира") + if _, err := s.backups.Create(ctx, log, "pre-restore"); err != nil { + return fmt.Errorf("страховочный бэкап не создан, восстановление отменено: %w", err) + } + return s.backups.Restore(ctx, log, name) + }) + if err != nil { + writeOpError(w, err) + return + } + writeJSON(w, http.StatusAccepted, map[string]any{"task": taskName}) +} + +// StartBackupScheduler запускает периодические бэкапы согласно конфигу. +// Интервал перечитывается на каждом тике, поэтому смена расписания в панели +// действует без перезапуска менеджера. +func StartBackupScheduler(ctx context.Context, s *Server) { + go func() { + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + last := time.Now() + + for { + select { + case <-ctx.Done(): + return + case now := <-ticker.C: + interval := s.cfg.Backup.Schedule.Duration() + if interval <= 0 || now.Sub(last) < interval { + continue + } + last = now + if s.jobs.Busy() { + s.sup.AppendLog("manager", "Плановый бэкап пропущен: выполняется другая задача") + continue + } + err := s.jobs.Start("Плановое резервное копирование", + func(ctx context.Context, log func(string)) error { + _, err := s.backups.Create(ctx, log, "auto") + return err + }) + if err != nil && !errors.Is(err, job.ErrBusy) { + s.sup.AppendLog("manager", "Плановый бэкап не запущен: "+err.Error()) + } + } + } + }() +} + +// applyJavaMemory переносит лимит памяти из конфига менеджера в файл запуска +// сервера. Вызывается после установки: SteamCMD перезаписывает этот файл. +func (s *Server) applyJavaMemory(log func(string)) error { + if s.cfg.JavaMemory == "" { + return nil + } + if err := pzconfig.SetJavaMemory(s.cfg.ServerDir, s.cfg.JavaMemory); err != nil { + return err + } + log("Лимит памяти JVM выставлен: " + s.cfg.JavaMemory) + return nil +} diff --git a/internal/api/server.go b/internal/api/server.go new file mode 100644 index 0000000..566ff89 --- /dev/null +++ b/internal/api/server.go @@ -0,0 +1,233 @@ +// Package api отдаёт REST-эндпоинты панели и её статические файлы. +package api + +import ( + "encoding/json" + "errors" + "io/fs" + "log" + "net/http" + "strings" + "time" + + "github.com/mrleo1nid/pzmanager/internal/auth" + "github.com/mrleo1nid/pzmanager/internal/backup" + "github.com/mrleo1nid/pzmanager/internal/config" + "github.com/mrleo1nid/pzmanager/internal/job" + "github.com/mrleo1nid/pzmanager/internal/pzconfig" + "github.com/mrleo1nid/pzmanager/internal/pzserver" + "github.com/mrleo1nid/pzmanager/internal/steamcmd" +) + +// csrfHeader — заголовок, который обязан прислать клиент при изменяющих +// запросах. Браузер не поставит его в кросс-сайтовом простом запросе, поэтому +// он служит защитой от CSRF без отдельных токенов. +const csrfHeader = "X-PZM-Request" + +// Server связывает HTTP-слой со всеми подсистемами менеджера. +type Server struct { + cfg *config.Config + users *auth.Store + sup *pzserver.Supervisor + jobs *job.Manager + backups *backup.Manager + steam *steamcmd.Client + workshop *pzconfig.WorkshopClient + static fs.FS + // secureCookies включает флаг Secure у cookie сессии (панель за HTTPS). + secureCookies bool + // setupToken защищает первичное создание администратора: менеджер печатает + // его в журнал при старте, когда учёток ещё нет. + setupToken string +} + +// Options — то, что нужно собрать серверу API. +type Options struct { + Config *config.Config + Users *auth.Store + Supervisor *pzserver.Supervisor + Jobs *job.Manager + Backups *backup.Manager + Steam *steamcmd.Client + Static fs.FS + SecureCookies bool + SetupToken string +} + +// New собирает HTTP-обработчик панели. +func New(o Options) *Server { + return &Server{ + cfg: o.Config, + users: o.Users, + sup: o.Supervisor, + jobs: o.Jobs, + backups: o.Backups, + steam: o.Steam, + workshop: pzconfig.NewWorkshopClient(), + static: o.Static, + secureCookies: o.SecureCookies, + setupToken: o.SetupToken, + } +} + +// Handler возвращает роутер со всеми маршрутами панели. +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + + // Публичные эндпоинты: без них нельзя войти. + mux.HandleFunc("GET /api/session", s.handleSession) + mux.HandleFunc("POST /api/login", s.handleLogin) + mux.HandleFunc("POST /api/logout", s.handleLogout) + mux.HandleFunc("POST /api/setup", s.handleSetup) + + // Всё остальное — только для вошедших. + protected := map[string]http.HandlerFunc{ + "GET /api/status": s.handleStatus, + "POST /api/server/start": s.handleStart, + "POST /api/server/stop": s.handleStop, + "POST /api/server/restart": s.handleRestart, + "POST /api/console": s.handleConsole, + "GET /api/logs": s.handleLogs, + "GET /api/logs/stream": s.handleLogStream, + "GET /api/config/server": s.handleGetServerConfig, + "PUT /api/config/server": s.handlePutServerConfig, + "GET /api/config/sandbox": s.handleGetSandbox, + "PUT /api/config/sandbox": s.handlePutSandbox, + "GET /api/config/manager": s.handleGetManagerConfig, + "PUT /api/config/manager": s.handlePutManagerConfig, + "GET /api/mods": s.handleGetMods, + "PUT /api/mods": s.handlePutMods, + "POST /api/mods/lookup": s.handleLookupMods, + "GET /api/backups": s.handleListBackups, + "POST /api/backups": s.handleCreateBackup, + "DELETE /api/backups/{name}": s.handleDeleteBackup, + "GET /api/backups/{name}/download": s.handleDownloadBackup, + "POST /api/backups/{name}/restore": s.handleRestoreBackup, + "POST /api/install": s.handleInstall, + "GET /api/task": s.handleTaskStatus, + "POST /api/task/cancel": s.handleTaskCancel, + "GET /api/users": s.handleListUsers, + "POST /api/users": s.handleCreateUser, + "DELETE /api/users/{login}": s.handleDeleteUser, + "POST /api/users/{login}/password": s.handleChangePassword, + } + for pattern, handler := range protected { + mux.Handle(pattern, s.requireAuth(handler)) + } + + mux.Handle("GET /", s.staticHandler()) + return s.withCommonHeaders(mux) +} + +// staticHandler отдаёт SPA: неизвестные пути возвращают index.html, чтобы +// работали ссылки вида /mods при перезагрузке страницы. +func (s *Server) staticHandler() http.Handler { + files := http.FileServer(http.FS(s.static)) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/") + if path == "" { + path = "index.html" + } + if _, err := fs.Stat(s.static, path); err != nil { + r = r.Clone(r.Context()) + r.URL.Path = "/" + } + files.ServeHTTP(w, r) + }) +} + +// withCommonHeaders ставит заголовки безопасности и проверяет CSRF. +func (s *Server) withCommonHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Referrer-Policy", "no-referrer") + // Панель полностью самодостаточна: внешние скрипты ей не нужны. + w.Header().Set("Content-Security-Policy", + "default-src 'self'; img-src 'self' data: https://steamuserimages-a.akamaihd.net "+ + "https://images.steamusercontent.com https://community.cloudflare.steamstatic.com; "+ + "style-src 'self'; script-src 'self'; connect-src 'self'") + + if r.Method != http.MethodGet && r.Method != http.MethodHead && + strings.HasPrefix(r.URL.Path, "/api/") && r.Header.Get(csrfHeader) == "" { + writeError(w, http.StatusForbidden, "запрос без заголовка "+csrfHeader) + return + } + next.ServeHTTP(w, r) + }) +} + +// requireAuth пропускает дальше только запросы с живой сессией. +func (s *Server) requireAuth(next http.HandlerFunc) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, ok := s.users.SessionFromRequest(r); !ok { + writeError(w, http.StatusUnauthorized, "требуется вход") + return + } + next(w, r) + }) +} + +// setSessionCookie кладёт cookie сессии. +func (s *Server) setSessionCookie(w http.ResponseWriter, id string, ttl time.Duration) { + http.SetCookie(w, &http.Cookie{ + Name: auth.SessionCookie, + Value: id, + Path: "/", + HttpOnly: true, + Secure: s.secureCookies, + SameSite: http.SameSiteStrictMode, + MaxAge: int(ttl.Seconds()), + }) +} + +func (s *Server) clearSessionCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: auth.SessionCookie, + Value: "", + Path: "/", + HttpOnly: true, + Secure: s.secureCookies, + SameSite: http.SameSiteStrictMode, + MaxAge: -1, + }) +} + +// writeJSON отправляет значение как JSON. +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(v); err != nil { + log.Printf("api: не удалось записать ответ: %v", err) + } +} + +// writeError отправляет ошибку в том же формате, что и остальные ответы. +func writeError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, map[string]string{"error": message}) +} + +// writeOpError переводит ошибку операции в подходящий HTTP-код. +func writeOpError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, pzserver.ErrAlreadyRunning), errors.Is(err, pzserver.ErrNotRunning), + errors.Is(err, job.ErrBusy): + writeError(w, http.StatusConflict, err.Error()) + case errors.Is(err, pzserver.ErrNotInstalled): + writeError(w, http.StatusPreconditionFailed, err.Error()) + default: + writeError(w, http.StatusInternalServerError, err.Error()) + } +} + +// decodeJSON читает тело запроса, ограничивая его размер. +func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool { + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + if err := dec.Decode(dst); err != nil { + writeError(w, http.StatusBadRequest, "некорректный JSON: "+err.Error()) + return false + } + return true +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..0c255c0 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,264 @@ +// Package auth хранит учётные записи панели и живые сессии. +package auth + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "golang.org/x/crypto/bcrypt" +) + +// SessionCookie — имя cookie с идентификатором сессии. +const SessionCookie = "pzm_session" + +// Ошибки, которые вызывающему коду имеет смысл различать. +var ( + ErrBadCredentials = errors.New("неверный логин или пароль") + ErrUserExists = errors.New("пользователь уже существует") + ErrUserNotFound = errors.New("пользователь не найден") +) + +// User — учётная запись веб-панели. +type User struct { + Login string `json:"login"` + Hash string `json:"hash"` + CreatedAt time.Time `json:"created_at"` +} + +type session struct { + login string + expiresAt time.Time +} + +// Store — потокобезопасное хранилище пользователей (файл на диске) и +// сессий (только в памяти: перезапуск менеджера разлогинивает всех). +type Store struct { + path string + ttl time.Duration + + mu sync.RWMutex + users map[string]User + sessions map[string]session +} + +// NewStore открывает файл с пользователями, создавая пустой при первом запуске. +func NewStore(path string, ttl time.Duration) (*Store, error) { + s := &Store{ + path: path, + ttl: ttl, + users: make(map[string]User), + sessions: make(map[string]session), + } + if err := s.load(); err != nil { + return nil, err + } + return s, nil +} + +func (s *Store) load() error { + raw, err := os.ReadFile(s.path) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return fmt.Errorf("прочитать %s: %w", s.path, err) + } + var users []User + if err := json.Unmarshal(raw, &users); err != nil { + return fmt.Errorf("разобрать %s: %w", s.path, err) + } + for _, u := range users { + s.users[u.Login] = u + } + return nil +} + +// saveLocked пишет пользователей на диск; вызывается под удержанным s.mu. +func (s *Store) saveLocked() error { + users := make([]User, 0, len(s.users)) + for _, u := range s.users { + users = append(users, u) + } + raw, err := json.MarshalIndent(users, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { + return err + } + tmp := s.path + ".tmp" + // 0600: файл содержит хеши паролей. + if err := os.WriteFile(tmp, raw, 0o600); err != nil { + return err + } + return os.Rename(tmp, s.path) +} + +// IsEmpty сообщает, что учёток нет и панель нужно инициализировать. +func (s *Store) IsEmpty() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return len(s.users) == 0 +} + +// Logins возвращает отсортированный список логинов. +func (s *Store) Logins() []string { + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]string, 0, len(s.users)) + for login := range s.users { + out = append(out, login) + } + return out +} + +// AddUser создаёт учётку. Пароль хешируется bcrypt'ом. +func (s *Store) AddUser(login, password string) error { + login = strings.TrimSpace(login) + if login == "" { + return errors.New("логин не может быть пустым") + } + if len(password) < 8 { + return errors.New("пароль короче 8 символов") + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + + s.mu.Lock() + defer s.mu.Unlock() + if _, exists := s.users[login]; exists { + return ErrUserExists + } + s.users[login] = User{Login: login, Hash: string(hash), CreatedAt: time.Now()} + return s.saveLocked() +} + +// SetPassword меняет пароль существующей учётки. +func (s *Store) SetPassword(login, password string) error { + if len(password) < 8 { + return errors.New("пароль короче 8 символов") + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + + s.mu.Lock() + defer s.mu.Unlock() + u, ok := s.users[login] + if !ok { + return ErrUserNotFound + } + u.Hash = string(hash) + s.users[login] = u + return s.saveLocked() +} + +// DeleteUser удаляет учётку и разлогинивает её сессии. +func (s *Store) DeleteUser(login string) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.users[login]; !ok { + return ErrUserNotFound + } + delete(s.users, login) + for id, sess := range s.sessions { + if sess.login == login { + delete(s.sessions, id) + } + } + return s.saveLocked() +} + +// Login проверяет пару логин/пароль и заводит сессию, возвращая её id. +func (s *Store) Login(login, password string) (string, error) { + s.mu.RLock() + u, ok := s.users[login] + s.mu.RUnlock() + + if !ok { + // Считаем фиктивный хеш, чтобы по времени ответа нельзя было + // отличить несуществующий логин от неверного пароля. + bcrypt.CompareHashAndPassword([]byte("$2a$10$"+strings.Repeat("x", 53)), []byte(password)) + return "", ErrBadCredentials + } + if err := bcrypt.CompareHashAndPassword([]byte(u.Hash), []byte(password)); err != nil { + return "", ErrBadCredentials + } + + id, err := randomID() + if err != nil { + return "", err + } + s.mu.Lock() + s.sessions[id] = session{login: login, expiresAt: time.Now().Add(s.ttl)} + s.mu.Unlock() + return id, nil +} + +// Logout закрывает конкретную сессию. +func (s *Store) Logout(id string) { + s.mu.Lock() + delete(s.sessions, id) + s.mu.Unlock() +} + +// Validate возвращает логин владельца сессии, продлевая её срок жизни. +func (s *Store) Validate(id string) (string, bool) { + if id == "" { + return "", false + } + s.mu.Lock() + defer s.mu.Unlock() + sess, ok := s.sessions[id] + if !ok { + return "", false + } + if time.Now().After(sess.expiresAt) { + delete(s.sessions, id) + return "", false + } + sess.expiresAt = time.Now().Add(s.ttl) + s.sessions[id] = sess + return sess.login, true +} + +// GC выкидывает протухшие сессии; вызывается по таймеру. +func (s *Store) GC() { + now := time.Now() + s.mu.Lock() + defer s.mu.Unlock() + for id, sess := range s.sessions { + if now.After(sess.expiresAt) { + delete(s.sessions, id) + } + } +} + +// SessionFromRequest достаёт логин из cookie запроса. +func (s *Store) SessionFromRequest(r *http.Request) (string, bool) { + c, err := r.Cookie(SessionCookie) + if err != nil { + return "", false + } + return s.Validate(c.Value) +} + +func randomID() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} diff --git a/internal/backup/backup.go b/internal/backup/backup.go new file mode 100644 index 0000000..f605d9f --- /dev/null +++ b/internal/backup/backup.go @@ -0,0 +1,347 @@ +// Package backup архивирует миры и конфиги Project Zomboid и восстанавливает +// их обратно. +package backup + +import ( + "archive/tar" + "compress/gzip" + "context" + "fmt" + "io" + "os" + "path" + "path/filepath" + "regexp" + "sort" + "strings" + "time" + + "github.com/mrleo1nid/pzmanager/internal/config" +) + +// Archive — один файл бэкапа на диске. +type Archive struct { + Name string `json:"name"` + SizeBytes int64 `json:"size_bytes"` + CreatedAt int64 `json:"created_at"` +} + +// nameRe ограничивает имена архивов, которые принимает API: имя приходит от +// клиента и используется как путь. +var nameRe = regexp.MustCompile(`^pz-[0-9]{8}-[0-9]{6}(-[a-zA-Z0-9_-]+)?\.tar\.gz$`) + +// Manager владеет директорией с бэкапами. +type Manager struct { + cfg *config.Config +} + +// New создаёт менеджер бэкапов. +func New(cfg *config.Config) *Manager { return &Manager{cfg: cfg} } + +// Dir возвращает директорию с архивами. +func (m *Manager) Dir() string { return m.cfg.Backup.Dir } + +// List возвращает архивы, свежие в начале списка. +func (m *Manager) List() ([]Archive, error) { + entries, err := os.ReadDir(m.cfg.Backup.Dir) + if os.IsNotExist(err) { + return []Archive{}, nil + } + if err != nil { + return nil, err + } + + out := make([]Archive, 0, len(entries)) + for _, e := range entries { + if e.IsDir() || !nameRe.MatchString(e.Name()) { + continue + } + info, err := e.Info() + if err != nil { + continue + } + out = append(out, Archive{ + Name: e.Name(), + SizeBytes: info.Size(), + CreatedAt: info.ModTime().Unix(), + }) + } + sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt > out[j].CreatedAt }) + return out, nil +} + +// Path возвращает полный путь к архиву, проверив имя. +func (m *Manager) Path(name string) (string, error) { + if !nameRe.MatchString(name) { + return "", fmt.Errorf("недопустимое имя архива: %q", name) + } + return filepath.Join(m.cfg.Backup.Dir, name), nil +} + +// Create собирает архив из Saves и серверных конфигов. Суффикс note попадает в +// имя файла, чтобы ручные бэкапы было легко отличить. +func (m *Manager) Create(ctx context.Context, log func(string), note string) (Archive, error) { + if err := os.MkdirAll(m.cfg.Backup.Dir, 0o755); err != nil { + return Archive{}, err + } + name := fmt.Sprintf("pz-%s", time.Now().Format("20060102-150405")) + if note = sanitizeNote(note); note != "" { + name += "-" + note + } + name += ".tar.gz" + target := filepath.Join(m.cfg.Backup.Dir, name) + + log("Создаю архив " + name) + // Пишем во временный файл: недописанный архив не должен попасть в список. + tmp := target + ".part" + f, err := os.Create(tmp) + if err != nil { + return Archive{}, err + } + + gz := gzip.NewWriter(f) + tw := tar.NewWriter(gz) + + var written int64 + sources := []string{m.cfg.SavesDir(), filepath.Join(m.cfg.ZomboidDir, "Server")} + for _, src := range sources { + if _, err := os.Stat(src); err != nil { + log("Пропускаю " + src + " — нет такой директории") + continue + } + n, err := addTree(ctx, tw, m.cfg.ZomboidDir, src, log) + written += n + if err != nil { + tw.Close() + gz.Close() + f.Close() + os.Remove(tmp) + return Archive{}, err + } + } + + if err := tw.Close(); err != nil { + gz.Close() + f.Close() + os.Remove(tmp) + return Archive{}, err + } + if err := gz.Close(); err != nil { + f.Close() + os.Remove(tmp) + return Archive{}, err + } + if err := f.Close(); err != nil { + os.Remove(tmp) + return Archive{}, err + } + if err := os.Rename(tmp, target); err != nil { + return Archive{}, err + } + + info, err := os.Stat(target) + if err != nil { + return Archive{}, err + } + log(fmt.Sprintf("Архив готов: %s (%s, файлов: %d)", name, humanSize(info.Size()), written)) + + if err := m.Prune(m.cfg.Backup.Keep, log); err != nil { + log("Ротация не удалась: " + err.Error()) + } + return Archive{Name: name, SizeBytes: info.Size(), CreatedAt: info.ModTime().Unix()}, nil +} + +// addTree кладёт директорию в архив, сохраняя пути относительно root. +func addTree(ctx context.Context, tw *tar.Writer, root, dir string, log func(string)) (int64, error) { + var count int64 + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + // Файл мог исчезнуть, пока идёт обход, — это не повод падать. + log("Пропускаю " + path + ": " + err.Error()) + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + if !info.Mode().IsRegular() && !info.IsDir() { + return nil + } + + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + hdr, err := tar.FileInfoHeader(info, "") + if err != nil { + return err + } + hdr.Name = filepath.ToSlash(rel) + if info.IsDir() { + hdr.Name += "/" + } + if err := tw.WriteHeader(hdr); err != nil { + return err + } + if info.IsDir() { + return nil + } + + f, err := os.Open(path) + if err != nil { + // Блокировка файла сервером не должна ронять весь бэкап. + log("Не прочитан " + rel + ": " + err.Error()) + return nil + } + defer f.Close() + if _, err := io.Copy(tw, f); err != nil { + return err + } + count++ + return nil + }) + return count, err +} + +// Restore распаковывает архив поверх ~/Zomboid. Сервер должен быть остановлен — +// это проверяет вызывающий код. +func (m *Manager) Restore(ctx context.Context, log func(string), name string) error { + path, err := m.Path(name) + if err != nil { + return err + } + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + + gz, err := gzip.NewReader(f) + if err != nil { + return fmt.Errorf("архив повреждён: %w", err) + } + defer gz.Close() + + log("Восстанавливаю " + name + " в " + m.cfg.ZomboidDir) + tr := tar.NewReader(gz) + var restored int + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + target, err := safeJoin(m.cfg.ZomboidDir, hdr.Name) + if err != nil { + return err + } + + switch hdr.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(target, 0o755); err != nil { + return err + } + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(hdr.Mode)) + if err != nil { + return err + } + if _, err := io.Copy(out, tr); err != nil { + out.Close() + return err + } + out.Close() + restored++ + } + } + log(fmt.Sprintf("Восстановлено файлов: %d", restored)) + return nil +} + +// Delete удаляет архив. +func (m *Manager) Delete(name string) error { + path, err := m.Path(name) + if err != nil { + return err + } + return os.Remove(path) +} + +// Prune оставляет только keep самых свежих архивов (keep <= 0 — не трогать). +func (m *Manager) Prune(keep int, log func(string)) error { + if keep <= 0 { + return nil + } + archives, err := m.List() + if err != nil { + return err + } + for _, a := range archives[min(keep, len(archives)):] { + if err := m.Delete(a.Name); err != nil { + return err + } + log("Удалён старый архив " + a.Name) + } + return nil +} + +// safeJoin не даёт архиву с путями вида ../.. вылезти за пределы root. +// Сегменты проверяются до нормализации: path.Clean схлопнул бы "a/../../b" в +// безобидный на вид путь и скрыл попытку выхода. Такие пути отвергаются, а не +// подгоняются под корень — они означают либо испорченный, либо специально +// собранный архив. +func safeJoin(root, name string) (string, error) { + slashed := filepath.ToSlash(name) + if path.IsAbs(slashed) { + return "", fmt.Errorf("абсолютный путь в архиве: %s", name) + } + for _, segment := range strings.Split(slashed, "/") { + if segment == ".." { + return "", fmt.Errorf("небезопасный путь в архиве: %s", name) + } + } + return filepath.Join(root, filepath.FromSlash(path.Clean(slashed))), nil +} + +// sanitizeNote оставляет от пометки только безопасные для имени файла символы. +func sanitizeNote(note string) string { + var b strings.Builder + for _, r := range note { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_': + b.WriteRune(r) + case r == ' ': + b.WriteRune('_') + } + if b.Len() >= 32 { + break + } + } + return strings.Trim(b.String(), "_-") +} + +func humanSize(n int64) string { + const unit = 1024 + if n < unit { + return fmt.Sprintf("%d Б", n) + } + prefixes := []string{"К", "М", "Г", "Т"} + div, exp := int64(unit), 0 + for size := n / unit; size >= unit && exp < len(prefixes)-1; size /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %sБ", float64(n)/float64(div), prefixes[exp]) +} diff --git a/internal/backup/backup_test.go b/internal/backup/backup_test.go new file mode 100644 index 0000000..c775bdd --- /dev/null +++ b/internal/backup/backup_test.go @@ -0,0 +1,141 @@ +package backup + +import ( + "archive/tar" + "compress/gzip" + "context" + "os" + "path/filepath" + "testing" + + "github.com/mrleo1nid/pzmanager/internal/config" +) + +func TestSafeJoinRejectsTraversal(t *testing.T) { + root := filepath.Join(os.TempDir(), "pzm-root") + + for _, name := range []string{"../evil", "../../etc/passwd", "Saves/../../evil"} { + if _, err := safeJoin(root, name); err == nil { + t.Errorf("путь %q принят, ожидался отказ", name) + } + } + if _, err := safeJoin(root, "Saves/Multiplayer/map.bin"); err != nil { + t.Errorf("обычный путь отклонён: %v", err) + } +} + +func TestSanitizeNote(t *testing.T) { + cases := map[string]string{ + "перед вайпом": "", + "before wipe": "before_wipe", + "v1.2/../x": "v12x", + "": "", + "__trim-me__": "trim-me", + } + for input, want := range cases { + if got := sanitizeNote(input); got != want { + t.Errorf("sanitizeNote(%q) = %q, ожидалось %q", input, got, want) + } + } +} + +func TestNameValidation(t *testing.T) { + m := New(&config.Config{}) + if _, err := m.Path("../../etc/passwd"); err == nil { + t.Error("имя с переходом по каталогам принято") + } + if _, err := m.Path("pz-20250903-181500-auto.tar.gz"); err != nil { + t.Errorf("корректное имя отклонено: %v", err) + } +} + +// TestCreateAndRestore проверяет полный цикл: архивируем дерево, портим файл, +// восстанавливаем и убеждаемся, что содержимое вернулось. +func TestCreateAndRestore(t *testing.T) { + root := t.TempDir() + zomboid := filepath.Join(root, "Zomboid") + savePath := filepath.Join(zomboid, "Saves", "Multiplayer", "world.bin") + + if err := os.MkdirAll(filepath.Dir(savePath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(savePath, []byte("исходный мир"), 0o644); err != nil { + t.Fatal(err) + } + + cfg := &config.Config{ZomboidDir: zomboid} + cfg.Backup.Dir = filepath.Join(root, "backups") + m := New(cfg) + + archive, err := m.Create(context.Background(), func(string) {}, "test") + if err != nil { + t.Fatalf("Create: %v", err) + } + if archive.SizeBytes == 0 { + t.Error("архив пустой") + } + + if err := os.WriteFile(savePath, []byte("испорчено"), 0o644); err != nil { + t.Fatal(err) + } + if err := m.Restore(context.Background(), func(string) {}, archive.Name); err != nil { + t.Fatalf("Restore: %v", err) + } + + restored, err := os.ReadFile(savePath) + if err != nil { + t.Fatal(err) + } + if string(restored) != "исходный мир" { + t.Errorf("после восстановления содержимое = %q", restored) + } +} + +func TestPruneKeepsNewest(t *testing.T) { + root := t.TempDir() + cfg := &config.Config{ZomboidDir: filepath.Join(root, "Zomboid")} + cfg.Backup.Dir = filepath.Join(root, "backups") + m := New(cfg) + + if err := os.MkdirAll(cfg.Backup.Dir, 0o755); err != nil { + t.Fatal(err) + } + names := []string{ + "pz-20250101-000000.tar.gz", + "pz-20250102-000000.tar.gz", + "pz-20250103-000000.tar.gz", + } + for _, name := range names { + writeEmptyArchive(t, filepath.Join(cfg.Backup.Dir, name)) + } + + if err := m.Prune(2, func(string) {}); err != nil { + t.Fatal(err) + } + left, err := m.List() + if err != nil { + t.Fatal(err) + } + if len(left) != 2 { + t.Fatalf("осталось %d архивов, ожидалось 2: %v", len(left), left) + } +} + +// writeEmptyArchive создаёт валидный пустой tar.gz — List проверяет имя, а не +// содержимое, но пусть файлы будут настоящими. +func writeEmptyArchive(t *testing.T, path string) { + t.Helper() + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + defer f.Close() + gz := gzip.NewWriter(f) + tw := tar.NewWriter(gz) + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..03a6c8d --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,201 @@ +// Package config загружает и сохраняет настройки самого менеджера. +package config + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "gopkg.in/yaml.v3" +) + +// Config — всё, что менеджеру нужно знать о хосте и о сервере PZ. +type Config struct { + // Listen — адрес веб-панели. Для доступа только через SSH-туннель + // используйте 127.0.0.1:8080. + Listen string `yaml:"listen"` + + // DataDir — рабочая директория менеджера (пользователи, сессии, бэкапы). + DataDir string `yaml:"data_dir"` + + // ServerDir — куда SteamCMD ставит серверные файлы PZ. + ServerDir string `yaml:"server_dir"` + + // ZomboidDir — ~/Zomboid: сохранения, конфиги, логи. + ZomboidDir string `yaml:"zomboid_dir"` + + // SteamCMDPath — путь к бинарю steamcmd.sh (пустой = ищем в PATH). + SteamCMDPath string `yaml:"steamcmd_path"` + + // ServerName — имя набора конфигов PZ (servertest -> servertest.ini). + ServerName string `yaml:"server_name"` + + // AdminPassword — пароль админа игрового сервера, нужен при первом + // запуске PZ. Хранится здесь, чтобы не вводить его в консоли. + AdminPassword string `yaml:"admin_password"` + + // JavaMemory — -Xmx для JVM сервера, например "8g". + JavaMemory string `yaml:"java_memory"` + + // ExtraArgs — дополнительные аргументы запуска сервера. + ExtraArgs []string `yaml:"extra_args"` + + // AutoStart — поднимать игровой сервер сразу при старте менеджера. + AutoStart bool `yaml:"autostart"` + + // AutoRestart — перезапускать сервер, если он упал сам. + AutoRestart bool `yaml:"autorestart"` + + // StopTimeout — сколько ждать корректного завершения после "quit". + StopTimeout Duration `yaml:"stop_timeout"` + + // LogBufferLines — сколько строк консоли держать в памяти для веб-панели. + LogBufferLines int `yaml:"log_buffer_lines"` + + // Backup — настройки резервного копирования. + Backup BackupConfig `yaml:"backup"` + + path string +} + +// BackupConfig описывает расписание и ротацию бэкапов мира. +type BackupConfig struct { + // Dir — куда складывать архивы. + Dir string `yaml:"dir"` + // Schedule — интервал автобэкапа; 0 отключает автоматику. + Schedule Duration `yaml:"schedule"` + // Keep — сколько последних архивов хранить (0 = не удалять). + Keep int `yaml:"keep"` + // StopServer — останавливать сервер на время бэкапа. Безопаснее, но + // игроков выкидывает; по умолчанию выключено. + StopServer bool `yaml:"stop_server"` +} + +// Default возвращает конфиг с разумными значениями для Ubuntu. +func Default() *Config { + home, _ := os.UserHomeDir() + return &Config{ + Listen: "127.0.0.1:8080", + DataDir: filepath.Join(home, ".local", "share", "pzmanager"), + ServerDir: filepath.Join(home, "pzserver"), + ZomboidDir: filepath.Join(home, "Zomboid"), + ServerName: "servertest", + JavaMemory: "4g", + AutoRestart: true, + StopTimeout: Duration(90 * time.Second), + LogBufferLines: 5000, + Backup: BackupConfig{ + Dir: filepath.Join(home, "pzbackups"), + Schedule: Duration(6 * time.Hour), + Keep: 20, + }, + } +} + +// Load читает конфиг с диска, дополняя пропущенные поля значениями по +// умолчанию. Если файла нет — создаёт его. +func Load(path string) (*Config, error) { + cfg := Default() + cfg.path = path + + raw, err := os.ReadFile(path) + if os.IsNotExist(err) { + if err := cfg.Save(); err != nil { + return nil, fmt.Errorf("создать конфиг %s: %w", path, err) + } + return cfg, nil + } + if err != nil { + return nil, fmt.Errorf("прочитать конфиг %s: %w", path, err) + } + if err := yaml.Unmarshal(raw, cfg); err != nil { + return nil, fmt.Errorf("разобрать конфиг %s: %w", path, err) + } + cfg.applyFallbacks() + return cfg, nil +} + +// applyFallbacks подставляет умолчания вместо пустых значений: конфиг, +// написанный руками, редко заполнен целиком. +func (c *Config) applyFallbacks() { + d := Default() + if c.Listen == "" { + c.Listen = d.Listen + } + if c.DataDir == "" { + c.DataDir = d.DataDir + } + if c.ServerDir == "" { + c.ServerDir = d.ServerDir + } + if c.ZomboidDir == "" { + c.ZomboidDir = d.ZomboidDir + } + if c.ServerName == "" { + c.ServerName = d.ServerName + } + if c.JavaMemory == "" { + c.JavaMemory = d.JavaMemory + } + if c.StopTimeout <= 0 { + c.StopTimeout = d.StopTimeout + } + if c.LogBufferLines <= 0 { + c.LogBufferLines = d.LogBufferLines + } + if c.Backup.Dir == "" { + c.Backup.Dir = d.Backup.Dir + } +} + +// Save записывает конфиг атомарно: сначала во временный файл рядом, затем +// переименование, чтобы падение посреди записи не оставило огрызок. +func (c *Config) Save() error { + if c.path == "" { + return fmt.Errorf("путь к конфигу неизвестен") + } + if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil { + return err + } + raw, err := yaml.Marshal(c) + if err != nil { + return err + } + tmp := c.path + ".tmp" + if err := os.WriteFile(tmp, raw, 0o600); err != nil { + return err + } + return os.Rename(tmp, c.path) +} + +// Path возвращает путь, откуда конфиг был загружен. +func (c *Config) Path() string { return c.path } + +// SetPath задаёт файл для последующего Save. +func (c *Config) SetPath(p string) { c.path = p } + +// EnsureDirs создаёт директории, без которых менеджер не сможет работать. +func (c *Config) EnsureDirs() error { + for _, dir := range []string{c.DataDir, c.ServerDir, c.Backup.Dir} { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("создать %s: %w", dir, err) + } + } + return nil +} + +// ServerConfigPath — путь к servertest.ini (или другому имени сервера). +func (c *Config) ServerConfigPath() string { + return filepath.Join(c.ZomboidDir, "Server", c.ServerName+".ini") +} + +// SandboxConfigPath — путь к _SandboxVars.lua. +func (c *Config) SandboxConfigPath() string { + return filepath.Join(c.ZomboidDir, "Server", c.ServerName+"_SandboxVars.lua") +} + +// SavesDir — директория с мирами (то, что бэкапим). +func (c *Config) SavesDir() string { + return filepath.Join(c.ZomboidDir, "Saves") +} diff --git a/internal/config/duration.go b/internal/config/duration.go new file mode 100644 index 0000000..96eaec0 --- /dev/null +++ b/internal/config/duration.go @@ -0,0 +1,45 @@ +package config + +import ( + "fmt" + "time" + + "gopkg.in/yaml.v3" +) + +// Duration — time.Duration, который в YAML выглядит как "90s" или "6h", а не +// как число наносекунд: конфиг правят руками. +type Duration time.Duration + +// Duration возвращает значение в виде time.Duration. +func (d Duration) Duration() time.Duration { return time.Duration(d) } + +// Seconds возвращает длительность в секундах — удобно для JSON-ответов API. +func (d Duration) Seconds() int64 { return int64(time.Duration(d).Seconds()) } + +// String печатает длительность в том же виде, в каком она пишется в конфиге. +func (d Duration) String() string { return time.Duration(d).String() } + +// MarshalYAML пишет длительность строкой. +func (d Duration) MarshalYAML() (any, error) { return time.Duration(d).String(), nil } + +// UnmarshalYAML принимает и строку ("90s"), и число — число трактуется как +// секунды, потому что так его естественнее всего написать руками. +func (d *Duration) UnmarshalYAML(node *yaml.Node) error { + var asString string + if err := node.Decode(&asString); err == nil { + parsed, err := time.ParseDuration(asString) + if err != nil { + return fmt.Errorf("не разобрана длительность %q: %w", asString, err) + } + *d = Duration(parsed) + return nil + } + + var asSeconds int64 + if err := node.Decode(&asSeconds); err != nil { + return fmt.Errorf("длительность должна быть строкой вида 90s или числом секунд") + } + *d = Duration(time.Duration(asSeconds) * time.Second) + return nil +} diff --git a/internal/job/job.go b/internal/job/job.go new file mode 100644 index 0000000..af8f304 --- /dev/null +++ b/internal/job/job.go @@ -0,0 +1,98 @@ +// Package job выполняет длинные операции (установка сервера, обновление модов, +// бэкап) по одной за раз, чтобы они не мешали друг другу. +package job + +import ( + "context" + "errors" + "sync" + "time" +) + +// ErrBusy возвращается, когда другая задача ещё выполняется. +var ErrBusy = errors.New("уже выполняется другая задача") + +// Status — что менеджер задач показывает веб-панели. +type Status struct { + Name string `json:"name"` + Running bool `json:"running"` + StartedAt int64 `json:"started_at"` + FinishedAt int64 `json:"finished_at"` + Error string `json:"error"` +} + +// Func — тело задачи. Логирование идёт через log, отмена — через ctx. +type Func func(ctx context.Context, log func(string)) error + +// Manager допускает только одну активную задачу. +type Manager struct { + logf func(stream, text string) + + mu sync.Mutex + status Status + cancel context.CancelFunc +} + +// New создаёт менеджер задач; logf получает весь вывод задач. +func New(logf func(stream, text string)) *Manager { + return &Manager{logf: logf} +} + +// Start запускает задачу в фоне. Если что-то уже выполняется — ErrBusy. +func (m *Manager) Start(name string, fn Func) error { + m.mu.Lock() + if m.status.Running { + m.mu.Unlock() + return ErrBusy + } + ctx, cancel := context.WithCancel(context.Background()) + m.cancel = cancel + m.status = Status{Name: name, Running: true, StartedAt: time.Now().Unix()} + m.mu.Unlock() + + m.logf("task", "▶ "+name) + go func() { + defer cancel() + err := fn(ctx, func(line string) { m.logf("task", line) }) + + m.mu.Lock() + m.status.Running = false + m.status.FinishedAt = time.Now().Unix() + if err != nil { + m.status.Error = err.Error() + } + m.mu.Unlock() + + if err != nil { + m.logf("task", "✖ "+name+": "+err.Error()) + return + } + m.logf("task", "✔ "+name+" — готово") + }() + return nil +} + +// Cancel просит текущую задачу завершиться. +func (m *Manager) Cancel() { + m.mu.Lock() + cancel := m.cancel + running := m.status.Running + m.mu.Unlock() + if running && cancel != nil { + cancel() + } +} + +// Status отдаёт состояние последней (или текущей) задачи. +func (m *Manager) Status() Status { + m.mu.Lock() + defer m.mu.Unlock() + return m.status +} + +// Busy сообщает, занят ли менеджер. +func (m *Manager) Busy() bool { + m.mu.Lock() + defer m.mu.Unlock() + return m.status.Running +} diff --git a/internal/pzconfig/ini.go b/internal/pzconfig/ini.go new file mode 100644 index 0000000..2af1695 --- /dev/null +++ b/internal/pzconfig/ini.go @@ -0,0 +1,151 @@ +// Package pzconfig читает и правит конфиги Project Zomboid: servertest.ini и +// SandboxVars.lua. +package pzconfig + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" +) + +// iniLine — одна строка файла. Пустой Key означает комментарий или пустую +// строку, которую нужно сохранить как есть. +type iniLine struct { + Key string + Value string + Raw string +} + +// INI — конфиг PZ в виде плоских key=value. Разбор сохраняет исходный порядок +// строк и комментарии, чтобы правка одного параметра не переписывала файл. +type INI struct { + lines []iniLine + index map[string]int +} + +// ParseINI разбирает содержимое конфига. +func ParseINI(data []byte) *INI { + ini := &INI{index: make(map[string]int)} + text := strings.ReplaceAll(string(data), "\r\n", "\n") + for _, raw := range strings.Split(text, "\n") { + trimmed := strings.TrimSpace(raw) + key, value, found := strings.Cut(raw, "=") + if !found || trimmed == "" || strings.HasPrefix(trimmed, "#") { + ini.lines = append(ini.lines, iniLine{Raw: raw}) + continue + } + key = strings.TrimSpace(key) + ini.index[key] = len(ini.lines) + ini.lines = append(ini.lines, iniLine{Key: key, Value: strings.TrimSpace(value)}) + } + return ini +} + +// LoadINI читает конфиг с диска. +func LoadINI(path string) (*INI, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + return ParseINI(data), nil +} + +// Get возвращает значение параметра. +func (i *INI) Get(key string) string { + idx, ok := i.index[key] + if !ok { + return "" + } + return i.lines[idx].Value +} + +// Has сообщает, есть ли такой параметр в файле. +func (i *INI) Has(key string) bool { + _, ok := i.index[key] + return ok +} + +// Set меняет значение, добавляя параметр в конец, если его ещё нет. +func (i *INI) Set(key, value string) { + // Перевод строки в значении сломал бы формат файла. + value = strings.NewReplacer("\n", " ", "\r", " ").Replace(value) + if idx, ok := i.index[key]; ok { + i.lines[idx].Value = value + return + } + i.index[key] = len(i.lines) + i.lines = append(i.lines, iniLine{Key: key, Value: value}) +} + +// Keys возвращает имена параметров в порядке их появления в файле. +func (i *INI) Keys() []string { + out := make([]string, 0, len(i.index)) + for _, l := range i.lines { + if l.Key != "" { + out = append(out, l.Key) + } + } + return out +} + +// Map отдаёт все параметры в виде словаря. +func (i *INI) Map() map[string]string { + out := make(map[string]string, len(i.index)) + for _, l := range i.lines { + if l.Key != "" { + out[l.Key] = l.Value + } + } + return out +} + +// Render собирает файл обратно в текст. +func (i *INI) Render() []byte { + var buf bytes.Buffer + for n, l := range i.lines { + if l.Key == "" { + buf.WriteString(l.Raw) + } else { + buf.WriteString(l.Key) + buf.WriteByte('=') + buf.WriteString(l.Value) + } + if n < len(i.lines)-1 { + buf.WriteByte('\n') + } + } + return buf.Bytes() +} + +// SaveINI записывает конфиг атомарно, предварительно сняв копию прежней +// версии рядом (.bak): испорченный конфиг остановит сервер. +func SaveINI(path string, ini *INI) error { + if err := backupFile(path); err != nil { + return err + } + return writeAtomic(path, ini.Render(), 0o644) +} + +func backupFile(path string) error { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + return os.WriteFile(path+".bak", data, 0o644) +} + +func writeAtomic(path string, data []byte, mode os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, mode); err != nil { + return fmt.Errorf("записать %s: %w", tmp, err) + } + return os.Rename(tmp, path) +} diff --git a/internal/pzconfig/java.go b/internal/pzconfig/java.go new file mode 100644 index 0000000..1e80719 --- /dev/null +++ b/internal/pzconfig/java.go @@ -0,0 +1,62 @@ +package pzconfig + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +// javaLauncherFile — файл, из которого сервер берёт аргументы JVM. +const javaLauncherFile = "ProjectZomboid64.json" + +var xmxRe = regexp.MustCompile(`^-Xm[sx]`) + +// SetJavaMemory прописывает лимит памяти JVM в ProjectZomboid64.json. Именно +// оттуда start-server.sh берёт -Xmx, поэтому менять размер памяти в аргументах +// запуска бесполезно. +func SetJavaMemory(serverDir, memory string) error { + memory = strings.TrimSpace(memory) + if memory == "" { + return nil + } + if !regexp.MustCompile(`^[0-9]+[kKmMgG]$`).MatchString(memory) { + return fmt.Errorf("объём памяти должен выглядеть как 4g или 8192m, получено %q", memory) + } + + path := filepath.Join(serverDir, javaLauncherFile) + raw, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("прочитать %s: %w", path, err) + } + + // Разбираем в map, чтобы не потерять поля, о которых мы не знаем. + var doc map[string]any + if err := json.Unmarshal(raw, &doc); err != nil { + return fmt.Errorf("разобрать %s: %w", path, err) + } + + args, _ := doc["vmArgs"].([]any) + updated := make([]any, 0, len(args)+2) + for _, a := range args { + if str, ok := a.(string); ok && xmxRe.MatchString(str) { + continue + } + updated = append(updated, a) + } + // -Xms равным -Xmx: JVM сразу забирает память и не тратит время на рост + // кучи в момент, когда на сервере много игроков. + updated = append(updated, "-Xms"+memory, "-Xmx"+memory) + doc["vmArgs"] = updated + + out, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return err + } + if err := backupFile(path); err != nil { + return err + } + return writeAtomic(path, out, 0o644) +} diff --git a/internal/pzconfig/mods.go b/internal/pzconfig/mods.go new file mode 100644 index 0000000..414d332 --- /dev/null +++ b/internal/pzconfig/mods.go @@ -0,0 +1,52 @@ +package pzconfig + +import "strings" + +// Ключи servertest.ini, в которых живут моды. +const ( + KeyMods = "Mods" + KeyWorkshopItems = "WorkshopItems" + KeyMap = "Map" +) + +// SplitList разбирает список PZ вида "a;b;c" в срез без пустых элементов. +func SplitList(value string) []string { + if strings.TrimSpace(value) == "" { + return []string{} + } + parts := strings.Split(value, ";") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} + +// JoinList собирает список обратно в формат PZ, убирая дубликаты и сохраняя +// порядок: он важен, моды грузятся именно в нём. +func JoinList(items []string) string { + seen := make(map[string]bool, len(items)) + out := make([]string, 0, len(items)) + for _, item := range items { + item = strings.TrimSpace(item) + if item == "" || seen[item] { + continue + } + seen[item] = true + out = append(out, item) + } + return strings.Join(out, ";") +} + +// Mods возвращает текущие списки модов сервера. +func (i *INI) Mods() (modIDs, workshopIDs []string) { + return SplitList(i.Get(KeyMods)), SplitList(i.Get(KeyWorkshopItems)) +} + +// SetMods записывает списки модов обратно в конфиг. +func (i *INI) SetMods(modIDs, workshopIDs []string) { + i.Set(KeyMods, JoinList(modIDs)) + i.Set(KeyWorkshopItems, JoinList(workshopIDs)) +} diff --git a/internal/pzconfig/pzconfig_test.go b/internal/pzconfig/pzconfig_test.go new file mode 100644 index 0000000..ec8cc88 --- /dev/null +++ b/internal/pzconfig/pzconfig_test.go @@ -0,0 +1,156 @@ +package pzconfig + +import ( + "strings" + "testing" +) + +const sampleINI = `# Comment line +PublicName=My Server +Public=true + +MaxPlayers=32 +Mods=modA;modB +WorkshopItems=111;222 +` + +func TestINIPreservesLayout(t *testing.T) { + ini := ParseINI([]byte(sampleINI)) + + if got := ini.Get("PublicName"); got != "My Server" { + t.Fatalf("PublicName = %q, ожидалось %q", got, "My Server") + } + // Правка одного ключа не должна трогать комментарии и пустые строки. + ini.Set("MaxPlayers", "64") + + got := string(ini.Render()) + if !strings.HasPrefix(got, "# Comment line\n") { + t.Errorf("комментарий потерян:\n%s", got) + } + if !strings.Contains(got, "\n\nMaxPlayers=64\n") { + t.Errorf("пустая строка или новое значение потеряны:\n%s", got) + } + if strings.Contains(got, "MaxPlayers=32") { + t.Errorf("старое значение осталось:\n%s", got) + } +} + +func TestINIAddsMissingKey(t *testing.T) { + ini := ParseINI([]byte("A=1")) + ini.Set("B", "2") + if got := ini.Get("B"); got != "2" { + t.Fatalf("B = %q", got) + } + if got := string(ini.Render()); got != "A=1\nB=2" { + t.Fatalf("render = %q", got) + } +} + +func TestINIRejectsNewlineInValue(t *testing.T) { + ini := ParseINI([]byte("A=1")) + ini.Set("A", "нет\nпереносам") + if strings.Contains(string(ini.Render()), "\nп") { + t.Fatalf("перенос строки попал в значение: %q", ini.Get("A")) + } +} + +func TestModsRoundTrip(t *testing.T) { + ini := ParseINI([]byte(sampleINI)) + mods, workshop := ini.Mods() + if len(mods) != 2 || mods[0] != "modA" { + t.Fatalf("mods = %v", mods) + } + if len(workshop) != 2 || workshop[1] != "222" { + t.Fatalf("workshop = %v", workshop) + } + + // Дубликаты отбрасываются, порядок сохраняется. + ini.SetMods([]string{"modB", "modA", "modB"}, []string{"222"}) + if got := ini.Get(KeyMods); got != "modB;modA" { + t.Fatalf("Mods = %q", got) + } + if got := ini.Get(KeyWorkshopItems); got != "222" { + t.Fatalf("WorkshopItems = %q", got) + } +} + +func TestSplitListIgnoresBlanks(t *testing.T) { + if got := SplitList(" a ; ; b;"); len(got) != 2 || got[0] != "a" || got[1] != "b" { + t.Fatalf("SplitList = %v", got) + } + if got := SplitList(" "); len(got) != 0 { + t.Fatalf("пустая строка дала %v", got) + } +} + +const sampleSandbox = `SandboxVars = { + VERSION = 5, + Zombies = 3, + Speed = 2, + ZombieLore = { + Speed = 2, + Strength = 2, + }, + WaterShutModifier = 14, +} +` + +func TestSandboxParsesNestedPaths(t *testing.T) { + sb := ParseSandbox([]byte(sampleSandbox)) + + if v, ok := sb.Get("Zombies"); !ok || v != "3" { + t.Fatalf("Zombies = %q (найдено: %v)", v, ok) + } + // Одноимённые ключи на разных уровнях не должны перетирать друг друга. + if v, ok := sb.Get("ZombieLore.Speed"); !ok || v != "2" { + t.Fatalf("ZombieLore.Speed = %q (найдено: %v)", v, ok) + } + if v, ok := sb.Get("Speed"); !ok || v != "2" { + t.Fatalf("Speed верхнего уровня = %q (найдено: %v)", v, ok) + } + if v, ok := sb.Get("WaterShutModifier"); !ok || v != "14" { + t.Fatalf("WaterShutModifier после закрытия таблицы = %q (найдено: %v)", v, ok) + } +} + +func TestSandboxSetKeepsFormatting(t *testing.T) { + sb := ParseSandbox([]byte(sampleSandbox)) + if err := sb.Set("ZombieLore.Speed", "1"); err != nil { + t.Fatal(err) + } + got := string(sb.Render()) + + if !strings.Contains(got, " Speed = 1,") { + t.Errorf("отступ или запятая потеряны:\n%s", got) + } + if !strings.Contains(got, " Speed = 2,") { + t.Errorf("параметр верхнего уровня изменён по ошибке:\n%s", got) + } + if !strings.HasSuffix(got, "}\n") { + t.Errorf("хвост файла изменён:\n%s", got) + } +} + +func TestSandboxRejectsUnknownAndBrokenValues(t *testing.T) { + sb := ParseSandbox([]byte(sampleSandbox)) + if err := sb.Set("НетТакого", "1"); err == nil { + t.Error("ожидалась ошибка для неизвестного параметра") + } + if err := sb.Set("Zombies", "{ 1, 2 }"); err == nil { + t.Error("ожидалась ошибка для значения с фигурными скобками") + } +} + +func TestSandboxEntriesFollowFileOrder(t *testing.T) { + entries := ParseSandbox([]byte(sampleSandbox)).Entries() + want := []string{"VERSION", "Zombies", "Speed", "ZombieLore.Speed", "ZombieLore.Strength", "WaterShutModifier"} + + if len(entries) != len(want) { + t.Fatalf("получено %d параметров, ожидалось %d: %v", len(entries), len(want), entries) + } + for i, path := range want { + if entries[i].Path != path { + t.Errorf("entries[%d] = %q, ожидалось %q", i, entries[i].Path, path) + } + } +} diff --git a/internal/pzconfig/sandbox.go b/internal/pzconfig/sandbox.go new file mode 100644 index 0000000..25be2c3 --- /dev/null +++ b/internal/pzconfig/sandbox.go @@ -0,0 +1,158 @@ +package pzconfig + +import ( + "bytes" + "fmt" + "os" + "regexp" + "strings" +) + +// SandboxEntry — один параметр песочницы. Path включает вложенные таблицы +// (например ZombieLore.Speed), Value хранится как в файле. +type SandboxEntry struct { + Path string `json:"path"` + Value string `json:"value"` +} + +// Sandbox — разбор _SandboxVars.lua. Файл генерируется игрой и имеет +// предсказуемый вид (одна пара ключ-значение на строку), поэтому вместо +// полноценного интерпретатора Lua достаточно построчного разбора: он +// гарантирует, что при сохранении файл останется байт-в-байт прежним, кроме +// изменённых значений. +type Sandbox struct { + lines []string + // index сопоставляет путь параметра с номером строки. + index map[string]int +} + +var ( + sandboxAssignRe = regexp.MustCompile(`^(\s*)([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+?)(,?)\s*$`) + sandboxCloseRe = regexp.MustCompile(`^\s*\}`) +) + +// ParseSandbox разбирает содержимое SandboxVars.lua. +func ParseSandbox(data []byte) *Sandbox { + s := &Sandbox{index: make(map[string]int)} + text := strings.ReplaceAll(string(data), "\r\n", "\n") + s.lines = strings.Split(text, "\n") + + var stack []string + for n, line := range s.lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "--") { + continue + } + if sandboxCloseRe.MatchString(line) { + if len(stack) > 0 { + stack = stack[:len(stack)-1] + } + continue + } + m := sandboxAssignRe.FindStringSubmatch(line) + if m == nil { + continue + } + name, value := m[2], strings.TrimSpace(m[3]) + if strings.HasPrefix(value, "{") { + // Начало вложенной таблицы: сам SandboxVars = { тоже сюда попадёт, + // но его имя в путях не нужно. + if name != "SandboxVars" { + stack = append(stack, name) + } else { + stack = stack[:0] + } + continue + } + path := name + if len(stack) > 0 { + path = strings.Join(stack, ".") + "." + name + } + s.index[path] = n + } + return s +} + +// LoadSandbox читает файл песочницы с диска. +func LoadSandbox(path string) (*Sandbox, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + return ParseSandbox(data), nil +} + +// Entries возвращает все параметры в порядке следования в файле. +func (s *Sandbox) Entries() []SandboxEntry { + out := make([]SandboxEntry, 0, len(s.index)) + // Обходим строки, а не карту: порядок в файле осмысленный. + byLine := make(map[int]string, len(s.index)) + for path, line := range s.index { + byLine[line] = path + } + for n := range s.lines { + path, ok := byLine[n] + if !ok { + continue + } + out = append(out, SandboxEntry{Path: path, Value: s.valueAt(n)}) + } + return out +} + +func (s *Sandbox) valueAt(line int) string { + m := sandboxAssignRe.FindStringSubmatch(s.lines[line]) + if m == nil { + return "" + } + return strings.TrimSpace(m[3]) +} + +// Get возвращает значение параметра по пути. +func (s *Sandbox) Get(path string) (string, bool) { + n, ok := s.index[path] + if !ok { + return "", false + } + return s.valueAt(n), true +} + +// Set меняет значение существующего параметра. Новые параметры не добавляются: +// игра всё равно игнорирует незнакомые ключи. +func (s *Sandbox) Set(path, value string) error { + n, ok := s.index[path] + if !ok { + return fmt.Errorf("параметр %q не найден в SandboxVars", path) + } + value = strings.TrimSpace(value) + if strings.ContainsAny(value, "\n\r{}") { + return fmt.Errorf("недопустимое значение для %q", path) + } + m := sandboxAssignRe.FindStringSubmatch(s.lines[n]) + if m == nil { + return fmt.Errorf("не удалось разобрать строку параметра %q", path) + } + indent, name, comma := m[1], m[2], m[4] + s.lines[n] = fmt.Sprintf("%s%s = %s%s", indent, name, value, comma) + return nil +} + +// Render собирает файл обратно. +func (s *Sandbox) Render() []byte { + var buf bytes.Buffer + for n, line := range s.lines { + buf.WriteString(line) + if n < len(s.lines)-1 { + buf.WriteByte('\n') + } + } + return buf.Bytes() +} + +// SaveSandbox пишет файл песочницы, сохранив предыдущую версию в .bak. +func SaveSandbox(path string, s *Sandbox) error { + if err := backupFile(path); err != nil { + return err + } + return writeAtomic(path, s.Render(), 0o644) +} diff --git a/internal/pzconfig/workshop.go b/internal/pzconfig/workshop.go new file mode 100644 index 0000000..e7995e9 --- /dev/null +++ b/internal/pzconfig/workshop.go @@ -0,0 +1,153 @@ +package pzconfig + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" +) + +// workshopAPI — публичный метод Steam, ключ доступа не требуется. +const workshopAPI = "https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/" + +// WorkshopItem — то, что панель показывает про мод из мастерской. +type WorkshopItem struct { + ID string `json:"id"` + Title string `json:"title"` + Preview string `json:"preview"` + Description string `json:"description"` + Error string `json:"error,omitempty"` +} + +// WorkshopClient запрашивает названия модов у Steam и кеширует их: список +// модов на странице обновляется часто, а названия не меняются. +type WorkshopClient struct { + HTTP *http.Client + + mu sync.Mutex + cache map[string]cachedItem +} + +type cachedItem struct { + item WorkshopItem + fetched time.Time +} + +// cacheTTL — как долго доверять закешированному названию мода. +const cacheTTL = 12 * time.Hour + +// NewWorkshopClient создаёт клиент со своим HTTP-таймаутом. +func NewWorkshopClient() *WorkshopClient { + return &WorkshopClient{ + HTTP: &http.Client{Timeout: 15 * time.Second}, + cache: make(map[string]cachedItem), + } +} + +// Details запрашивает описание модов по их Workshop ID. Если Steam недоступен, +// возвращает записи с заполненным полем Error — панель всё равно покажет +// список, просто без названий. +func (c *WorkshopClient) Details(ctx context.Context, ids []string) []WorkshopItem { + out := make([]WorkshopItem, 0, len(ids)) + missing := make([]string, 0, len(ids)) + + c.mu.Lock() + for _, id := range ids { + if entry, ok := c.cache[id]; ok && time.Since(entry.fetched) < cacheTTL { + out = append(out, entry.item) + continue + } + missing = append(missing, id) + } + c.mu.Unlock() + + if len(missing) > 0 { + fetched, err := c.fetch(ctx, missing) + if err != nil { + for _, id := range missing { + out = append(out, WorkshopItem{ID: id, Error: err.Error()}) + } + } else { + c.mu.Lock() + for _, item := range fetched { + c.cache[item.ID] = cachedItem{item: item, fetched: time.Now()} + } + c.mu.Unlock() + out = append(out, fetched...) + } + } + + // Возвращаем в том же порядке, в каком просили. + byID := make(map[string]WorkshopItem, len(out)) + for _, item := range out { + byID[item.ID] = item + } + ordered := make([]WorkshopItem, 0, len(ids)) + for _, id := range ids { + if item, ok := byID[id]; ok { + ordered = append(ordered, item) + continue + } + ordered = append(ordered, WorkshopItem{ID: id, Error: "нет данных"}) + } + return ordered +} + +func (c *WorkshopClient) fetch(ctx context.Context, ids []string) ([]WorkshopItem, error) { + form := url.Values{} + form.Set("itemcount", strconv.Itoa(len(ids))) + for n, id := range ids { + form.Set(fmt.Sprintf("publishedfileids[%d]", n), id) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, workshopAPI, + strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, fmt.Errorf("Steam недоступен: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("Steam ответил %s", resp.Status) + } + + var payload struct { + Response struct { + PublishedFileDetails []struct { + PublishedFileID string `json:"publishedfileid"` + Title string `json:"title"` + Description string `json:"description"` + PreviewURL string `json:"preview_url"` + Result int `json:"result"` + } `json:"publishedfiledetails"` + } `json:"response"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return nil, fmt.Errorf("ответ Steam не разобран: %w", err) + } + + items := make([]WorkshopItem, 0, len(payload.Response.PublishedFileDetails)) + for _, d := range payload.Response.PublishedFileDetails { + item := WorkshopItem{ID: d.PublishedFileID, Title: d.Title, Preview: d.PreviewURL} + // result == 1 означает "найдено"; всё остальное — удалён или скрыт. + if d.Result != 1 { + item.Error = "мод не найден в мастерской" + } + if len(d.Description) > 400 { + d.Description = d.Description[:400] + "…" + } + item.Description = d.Description + items = append(items, item) + } + return items, nil +} diff --git a/internal/pzserver/logbuf.go b/internal/pzserver/logbuf.go new file mode 100644 index 0000000..0289582 --- /dev/null +++ b/internal/pzserver/logbuf.go @@ -0,0 +1,91 @@ +package pzserver + +import "sync" + +// LogLine — одна строка консоли сервера с монотонным номером, по которому +// веб-клиент понимает, что он пропустил, и откуда догружать. +type LogLine struct { + Seq int64 `json:"seq"` + Time int64 `json:"time"` // Unix-миллисекунды + Stream string `json:"stream"` + Text string `json:"text"` +} + +// logBuffer — кольцевой буфер последних строк консоли плюс рассылка их +// живым подписчикам (SSE-соединениям веб-панели). +type logBuffer struct { + mu sync.RWMutex + lines []LogLine + capacity int + nextSeq int64 + subs map[int]chan LogLine + nextSub int +} + +func newLogBuffer(capacity int) *logBuffer { + return &logBuffer{ + lines: make([]LogLine, 0, capacity), + capacity: capacity, + nextSeq: 1, + subs: make(map[int]chan LogLine), + } +} + +// append кладёт строку в буфер и рассылает подписчикам. +func (b *logBuffer) append(line LogLine) { + b.mu.Lock() + line.Seq = b.nextSeq + b.nextSeq++ + if len(b.lines) == b.capacity { + copy(b.lines, b.lines[1:]) + b.lines = b.lines[:len(b.lines)-1] + } + b.lines = append(b.lines, line) + + subs := make([]chan LogLine, 0, len(b.subs)) + for _, ch := range b.subs { + subs = append(subs, ch) + } + b.mu.Unlock() + + for _, ch := range subs { + // Неблокирующая отправка: подписчик, который не успевает читать, + // теряет строки, но не тормозит вывод сервера. + select { + case ch <- line: + default: + } + } +} + +// since возвращает строки с номером больше seq (seq<=0 — весь буфер). +func (b *logBuffer) since(seq int64) []LogLine { + b.mu.RLock() + defer b.mu.RUnlock() + out := make([]LogLine, 0, len(b.lines)) + for _, l := range b.lines { + if l.Seq > seq { + out = append(out, l) + } + } + return out +} + +// subscribe открывает канал живых строк и функцию отписки. +func (b *logBuffer) subscribe(bufferSize int) (<-chan LogLine, func()) { + ch := make(chan LogLine, bufferSize) + b.mu.Lock() + id := b.nextSub + b.nextSub++ + b.subs[id] = ch + b.mu.Unlock() + + return ch, func() { + b.mu.Lock() + if existing, ok := b.subs[id]; ok { + delete(b.subs, id) + close(existing) + } + b.mu.Unlock() + } +} diff --git a/internal/pzserver/proc_unix.go b/internal/pzserver/proc_unix.go new file mode 100644 index 0000000..cbff10d --- /dev/null +++ b/internal/pzserver/proc_unix.go @@ -0,0 +1,33 @@ +//go:build !windows + +package pzserver + +import ( + "os/exec" + "syscall" +) + +// setProcessGroup просит ядро выделить процессу собственную группу: сервер PZ +// это скрипт-обёртка, порождающая JVM, и убивать их нужно вместе. +func setProcessGroup(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} +} + +// processGroupID возвращает pgid запущенного процесса. +func processGroupID(pid int) int { + pgid, err := syscall.Getpgid(pid) + if err != nil { + return pid + } + return pgid +} + +// signalGroup шлёт сигнал всей группе (отрицательный pid = группа). +func signalGroup(pgid int, sig syscall.Signal) error { + return syscall.Kill(-pgid, sig) +} + +var ( + sigTerm = syscall.SIGTERM + sigKill = syscall.SIGKILL +) diff --git a/internal/pzserver/proc_windows.go b/internal/pzserver/proc_windows.go new file mode 100644 index 0000000..9aa88b1 --- /dev/null +++ b/internal/pzserver/proc_windows.go @@ -0,0 +1,28 @@ +//go:build windows + +package pzserver + +import ( + "os/exec" + "syscall" +) + +// На Windows групп процессов в POSIX-смысле нет. Менеджер рассчитан на +// Ubuntu; эти заглушки нужны лишь для сборки и отладки под Windows. +func setProcessGroup(cmd *exec.Cmd) {} + +func processGroupID(pid int) int { return pid } + +func signalGroup(pgid int, sig syscall.Signal) error { + p, err := syscall.OpenProcess(syscall.PROCESS_TERMINATE, false, uint32(pgid)) + if err != nil { + return err + } + defer syscall.CloseHandle(p) + return syscall.TerminateProcess(p, 1) +} + +var ( + sigTerm = syscall.Signal(15) + sigKill = syscall.Signal(9) +) diff --git a/internal/pzserver/supervisor.go b/internal/pzserver/supervisor.go new file mode 100644 index 0000000..f0faa40 --- /dev/null +++ b/internal/pzserver/supervisor.go @@ -0,0 +1,459 @@ +// Package pzserver запускает игровой сервер Project Zomboid как дочерний +// процесс, держит его консоль и следит за его состоянием. +package pzserver + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/mrleo1nid/pzmanager/internal/config" + "github.com/mrleo1nid/pzmanager/internal/sysinfo" +) + +// State — фаза жизненного цикла игрового сервера. +type State string + +const ( + StateStopped State = "stopped" + StateStarting State = "starting" + StateRunning State = "running" + StateStopping State = "stopping" +) + +// Ошибки, на которые HTTP-слой отвечает осмысленным кодом. +var ( + ErrAlreadyRunning = errors.New("сервер уже запущен") + ErrNotRunning = errors.New("сервер не запущен") + ErrNotInstalled = errors.New("серверные файлы не найдены — сначала выполните установку") +) + +// playersPollInterval — как часто менеджер спрашивает у сервера список игроков. +const playersPollInterval = 30 * time.Second + +// restartDelay — пауза перед автоподъёмом упавшего сервера. +const restartDelay = 10 * time.Second + +// Status — снимок состояния для веб-панели. +type Status struct { + State State `json:"state"` + Installed bool `json:"installed"` + PID int `json:"pid"` + UptimeSec int64 `json:"uptime_sec"` + Players []string `json:"players"` + PlayersAt int64 `json:"players_at"` + LastExit string `json:"last_exit"` + AutoRestart bool `json:"auto_restart"` + Proc sysinfo.ProcStats `json:"proc"` + Host sysinfo.HostStats `json:"host"` +} + +// Supervisor владеет процессом сервера: запускает, кормит командами консоли, +// собирает вывод и по желанию поднимает после падения. +type Supervisor struct { + cfg *config.Config + log *logBuffer + + mu sync.Mutex + state State + cmd *exec.Cmd + stdin io.WriteCloser + pgid int + startedAt time.Time + lastExit string + players []string + playersAt time.Time + // intentionalStop отличает остановку по кнопке от падения: во втором + // случае включается автоперезапуск. + intentionalStop bool + exited chan struct{} + + // Захват ответа на команду players: сервер печатает заголовок и список + // строк вида -Nick. + capturing bool + captured []string + autoQuery bool + + sampler *sysinfo.Sampler + stopOnce sync.Once + quit chan struct{} +} + +// New создаёт супервизор поверх конфига. Конфиг читается при каждом запуске, +// поэтому изменения настроек подхватываются следующим стартом сервера. +func New(cfg *config.Config) *Supervisor { + s := &Supervisor{ + cfg: cfg, + log: newLogBuffer(cfg.LogBufferLines), + state: StateStopped, + sampler: sysinfo.NewSampler(), + quit: make(chan struct{}), + } + go s.pollPlayers() + return s +} + +// Close останавливает фоновые задачи супервизора. +func (s *Supervisor) Close() { + s.stopOnce.Do(func() { close(s.quit) }) +} + +// Installed сообщает, лежат ли на диске файлы сервера. +func (s *Supervisor) Installed() bool { + _, err := os.Stat(s.startScript()) + return err == nil +} + +func (s *Supervisor) startScript() string { + return filepath.Join(s.cfg.ServerDir, "start-server.sh") +} + +// AppendLog добавляет строку в консоль панели — им пользуются задачи +// установки и бэкапа, чтобы их вывод был виден там же, где логи сервера. +func (s *Supervisor) AppendLog(stream, text string) { + s.log.append(LogLine{Time: time.Now().UnixMilli(), Stream: stream, Text: text}) +} + +// Lines возвращает историю консоли начиная с номера seq. +func (s *Supervisor) Lines(seq int64) []LogLine { return s.log.since(seq) } + +// Subscribe отдаёт канал новых строк консоли и функцию отписки. +func (s *Supervisor) Subscribe() (<-chan LogLine, func()) { return s.log.subscribe(256) } + +// State возвращает текущую фазу без остальных метрик. +func (s *Supervisor) State() State { + s.mu.Lock() + defer s.mu.Unlock() + return s.state +} + +// Status собирает полный снимок состояния вместе с метриками процесса и хоста. +func (s *Supervisor) Status() Status { + s.mu.Lock() + st := Status{ + State: s.state, + Players: append([]string{}, s.players...), + LastExit: s.lastExit, + AutoRestart: s.cfg.AutoRestart, + } + if !s.playersAt.IsZero() { + st.PlayersAt = s.playersAt.Unix() + } + pgid := s.pgid + if s.cmd != nil && s.cmd.Process != nil { + st.PID = s.cmd.Process.Pid + } + if !s.startedAt.IsZero() && s.state != StateStopped { + st.UptimeSec = int64(time.Since(s.startedAt).Seconds()) + } + s.mu.Unlock() + + st.Installed = s.Installed() + if st.State == StateRunning || st.State == StateStarting { + st.Proc = s.sampler.Sample(pgid) + } else { + s.sampler.Reset() + } + st.Host = sysinfo.Host(s.cfg.ZomboidDir) + return st +} + +// Start поднимает игровой сервер. Возвращает ошибку, если он уже работает +// или файлы сервера ещё не установлены. +func (s *Supervisor) Start() error { + s.mu.Lock() + defer s.mu.Unlock() + return s.startLocked() +} + +func (s *Supervisor) startLocked() error { + if s.state != StateStopped { + return ErrAlreadyRunning + } + script := s.startScript() + if _, err := os.Stat(script); err != nil { + return ErrNotInstalled + } + // Права на исполнение теряются, например, после распаковки из архива. + if err := os.Chmod(script, 0o755); err != nil { + return fmt.Errorf("chmod %s: %w", script, err) + } + + args := []string{"-servername", s.cfg.ServerName} + if s.cfg.AdminPassword != "" { + args = append(args, "-adminpassword", s.cfg.AdminPassword) + } + args = append(args, s.cfg.ExtraArgs...) + + cmd := exec.Command(script, args...) + cmd.Dir = s.cfg.ServerDir + // PZ ищет сохранения относительно HOME, а нативные библиотеки — рядом с + // собой; обе переменные задаём явно, чтобы не зависеть от окружения + // systemd-юнита. + cmd.Env = append(os.Environ(), + "HOME="+filepath.Dir(s.cfg.ZomboidDir), + "LD_LIBRARY_PATH="+s.cfg.ServerDir+"/linux64:"+s.cfg.ServerDir+"/natives:"+os.Getenv("LD_LIBRARY_PATH"), + ) + setProcessGroup(cmd) + + stdin, err := cmd.StdinPipe() + if err != nil { + return fmt.Errorf("stdin: %w", err) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("stdout: %w", err) + } + stderr, err := cmd.StderrPipe() + if err != nil { + return fmt.Errorf("stderr: %w", err) + } + if err := cmd.Start(); err != nil { + return fmt.Errorf("запустить %s: %w", script, err) + } + + s.cmd = cmd + s.stdin = stdin + s.pgid = processGroupID(cmd.Process.Pid) + s.state = StateStarting + s.startedAt = time.Now() + s.lastExit = "" + s.intentionalStop = false + s.players = nil + s.exited = make(chan struct{}) + s.sampler.Reset() + + s.AppendLog("manager", fmt.Sprintf("Запуск сервера: %s %s (pid %d)", + script, strings.Join(args, " "), cmd.Process.Pid)) + + go s.readPipe(stdout, "stdout") + go s.readPipe(stderr, "stderr") + go s.wait(cmd, s.exited) + return nil +} + +// readPipe построчно читает вывод сервера. Буфер увеличен: PZ иногда печатает +// длинные стек-трейсы одной строкой. +func (s *Supervisor) readPipe(r io.Reader, stream string) { + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + s.handleLine(stream, scanner.Text()) + } +} + +// handleLine разбирает строку вывода: ловит момент готовности сервера и +// ответы на команду players, остальное складывает в консоль. +func (s *Supervisor) handleLine(stream, text string) { + trimmed := strings.TrimRight(text, "\r") + + s.mu.Lock() + hide := false + + switch { + case s.capturing: + if stripped := strings.TrimSpace(trimmed); strings.HasPrefix(stripped, "-") { + s.captured = append(s.captured, strings.TrimPrefix(stripped, "-")) + s.players = append([]string{}, s.captured...) + hide = s.autoQuery + } else { + s.capturing = false + s.autoQuery = false + } + case strings.Contains(trimmed, "Players connected"): + s.capturing = true + s.captured = nil + s.players = nil + s.playersAt = time.Now() + hide = s.autoQuery + } + + if s.state == StateStarting && isServerReady(trimmed) { + s.state = StateRunning + } + s.mu.Unlock() + + // Автоопрос игроков не должен засорять консоль — команду отправили мы, + // а не пользователь. + if !hide { + s.AppendLog(stream, trimmed) + } +} + +// isServerReady распознаёт строку, которой PZ сообщает о готовности принимать +// игроков. +func isServerReady(line string) bool { + return strings.Contains(line, "SERVER STARTED") || + strings.Contains(line, "Server Steam ID") +} + +// wait дожидается завершения процесса и решает, поднимать ли его заново. +func (s *Supervisor) wait(cmd *exec.Cmd, exited chan struct{}) { + err := cmd.Wait() + close(exited) + + s.mu.Lock() + wasIntentional := s.intentionalStop + s.state = StateStopped + s.cmd = nil + s.stdin = nil + s.pgid = 0 + s.players = nil + s.capturing = false + if err != nil { + s.lastExit = err.Error() + } else { + s.lastExit = "штатное завершение" + } + reason := s.lastExit + autoRestart := s.cfg.AutoRestart && !wasIntentional + s.mu.Unlock() + + s.AppendLog("manager", "Сервер остановлен: "+reason) + if !autoRestart { + return + } + + s.AppendLog("manager", fmt.Sprintf("Автоперезапуск через %s", restartDelay)) + select { + case <-time.After(restartDelay): + case <-s.quit: + return + } + s.mu.Lock() + err = s.startLocked() + s.mu.Unlock() + if err != nil && !errors.Is(err, ErrAlreadyRunning) { + s.AppendLog("manager", "Автоперезапуск не удался: "+err.Error()) + } +} + +// Send отправляет команду в консоль сервера. +func (s *Supervisor) Send(command string) error { + command = strings.TrimSpace(command) + if command == "" { + return errors.New("пустая команда") + } + s.mu.Lock() + stdin, state := s.stdin, s.state + s.mu.Unlock() + + if stdin == nil || state == StateStopped { + return ErrNotRunning + } + s.AppendLog("console", "> "+command) + if _, err := io.WriteString(stdin, command+"\n"); err != nil { + return fmt.Errorf("запись в консоль: %w", err) + } + return nil +} + +// sendQuiet отправляет служебную команду, не показывая её в консоли панели. +func (s *Supervisor) sendQuiet(command string) error { + s.mu.Lock() + stdin := s.stdin + s.mu.Unlock() + if stdin == nil { + return ErrNotRunning + } + _, err := io.WriteString(stdin, command+"\n") + return err +} + +// Stop корректно останавливает сервер: сначала команда quit (она сохраняет +// мир), затем SIGTERM группе процессов и в крайнем случае SIGKILL. +func (s *Supervisor) Stop() error { + s.mu.Lock() + if s.state == StateStopped || s.cmd == nil { + s.mu.Unlock() + return ErrNotRunning + } + s.state = StateStopping + s.intentionalStop = true + exited, pgid := s.exited, s.pgid + s.mu.Unlock() + + s.AppendLog("manager", "Останавливаю сервер командой quit...") + if err := s.sendQuiet("quit"); err != nil { + s.AppendLog("manager", "Не удалось отправить quit: "+err.Error()) + } + + select { + case <-exited: + return nil + case <-time.After(s.cfg.StopTimeout.Duration()): + } + + s.AppendLog("manager", "Сервер не ответил на quit — отправляю SIGTERM") + if err := signalGroup(pgid, sigTerm); err != nil { + s.AppendLog("manager", "SIGTERM не доставлен: "+err.Error()) + } + select { + case <-exited: + return nil + case <-time.After(30 * time.Second): + } + + s.AppendLog("manager", "Сервер завис — отправляю SIGKILL") + if err := signalGroup(pgid, sigKill); err != nil { + return fmt.Errorf("SIGKILL: %w", err) + } + <-exited + return nil +} + +// Restart останавливает сервер и поднимает его заново. +func (s *Supervisor) Restart() error { + if s.State() != StateStopped { + if err := s.Stop(); err != nil && !errors.Is(err, ErrNotRunning) { + return err + } + // После Stop горутина wait ещё должна снять состояние. + s.WaitStopped(15 * time.Second) + } + return s.Start() +} + +// WaitStopped ждёт, пока состояние станет stopped, но не дольше timeout. +// Возвращает true, если сервер действительно остановился. +func (s *Supervisor) WaitStopped(timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if s.State() == StateStopped { + return true + } + time.Sleep(100 * time.Millisecond) + } + return s.State() == StateStopped +} + +// pollPlayers периодически спрашивает у работающего сервера, кто онлайн. +func (s *Supervisor) pollPlayers() { + ticker := time.NewTicker(playersPollInterval) + defer ticker.Stop() + for { + select { + case <-s.quit: + return + case <-ticker.C: + if s.State() != StateRunning { + continue + } + s.mu.Lock() + s.autoQuery = true + s.mu.Unlock() + if err := s.sendQuiet("players"); err != nil { + s.mu.Lock() + s.autoQuery = false + s.mu.Unlock() + } + } + } +} diff --git a/internal/steamcmd/steamcmd.go b/internal/steamcmd/steamcmd.go new file mode 100644 index 0000000..b30ac09 --- /dev/null +++ b/internal/steamcmd/steamcmd.go @@ -0,0 +1,207 @@ +// Package steamcmd ставит и обновляет серверные файлы Project Zomboid. +package steamcmd + +import ( + "archive/tar" + "bufio" + "compress/gzip" + "context" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path" + "path/filepath" + "strings" + "time" +) + +// AppID выделенного сервера Project Zomboid в Steam. +const AppID = "380870" + +// downloadURL — официальная сборка SteamCMD для Linux. +const downloadURL = "https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz" + +// Client знает, где лежит steamcmd и куда ставить сервер. +type Client struct { + // Path — путь к steamcmd.sh. Пустой означает "найти или скачать". + Path string + // InstallRoot — куда положить SteamCMD, если его нет в системе. + InstallRoot string +} + +// Resolve находит рабочий steamcmd: сначала явно заданный путь, потом PATH, +// потом ранее скачанная копия. Если ничего нет — возвращает пустую строку. +func (c *Client) Resolve() string { + if c.Path != "" { + if _, err := os.Stat(c.Path); err == nil { + return c.Path + } + } + if p, err := exec.LookPath("steamcmd"); err == nil { + return p + } + local := filepath.Join(c.InstallRoot, "steamcmd", "steamcmd.sh") + if _, err := os.Stat(local); err == nil { + return local + } + return "" +} + +// Ensure возвращает путь к steamcmd, при необходимости скачивая его. +func (c *Client) Ensure(ctx context.Context, log func(string)) (string, error) { + if p := c.Resolve(); p != "" { + log("SteamCMD найден: " + p) + return p, nil + } + + dir := filepath.Join(c.InstallRoot, "steamcmd") + log("SteamCMD не найден, скачиваю в " + dir) + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", err + } + if err := downloadAndExtract(ctx, downloadURL, dir); err != nil { + return "", fmt.Errorf("скачать SteamCMD: %w", err) + } + + script := filepath.Join(dir, "steamcmd.sh") + if err := os.Chmod(script, 0o755); err != nil { + return "", err + } + // Бинарь, который дёргает скрипт, тоже должен быть исполняемым. + if bin := filepath.Join(dir, "linux32", "steamcmd"); fileExists(bin) { + _ = os.Chmod(bin, 0o755) + } + log("SteamCMD установлен: " + script) + return script, nil +} + +// InstallOrUpdate ставит или обновляет серверные файлы в targetDir. Вывод +// SteamCMD построчно уходит в log. +func (c *Client) InstallOrUpdate(ctx context.Context, log func(string), targetDir string) error { + script, err := c.Ensure(ctx, log) + if err != nil { + return err + } + if err := os.MkdirAll(targetDir, 0o755); err != nil { + return err + } + + // validate сверяет контрольные суммы: чуть дольше, но лечит битые файлы + // после падения диска или прерванной загрузки. + args := []string{ + "+force_install_dir", targetDir, + "+login", "anonymous", + "+app_update", AppID, "validate", + "+quit", + } + log("Запускаю: " + script + " " + strings.Join(args, " ")) + + cmd := exec.CommandContext(ctx, script, args...) + cmd.Dir = filepath.Dir(script) + return runStreaming(cmd, log) +} + +// runStreaming выполняет команду, отдавая её вывод построчно в log. +func runStreaming(cmd *exec.Cmd, log func(string)) error { + stdout, err := cmd.StdoutPipe() + if err != nil { + return err + } + // Один канал на оба потока: SteamCMD пишет ошибки вперемешку с прогрессом, + // и разделять их в консоли панели незачем. + cmd.Stderr = cmd.Stdout + if err := cmd.Start(); err != nil { + return err + } + + sc := bufio.NewScanner(stdout) + sc.Buffer(make([]byte, 0, 32*1024), 512*1024) + for sc.Scan() { + // SteamCMD рисует прогресс через \r; разворачиваем в отдельные строки. + for _, part := range strings.Split(sc.Text(), "\r") { + if part = strings.TrimSpace(part); part != "" { + log(part) + } + } + } + return cmd.Wait() +} + +// downloadAndExtract скачивает tar.gz и распаковывает его в dest. +func downloadAndExtract(ctx context.Context, url, dest string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + client := &http.Client{Timeout: 10 * time.Minute} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %s", resp.Status) + } + + gz, err := gzip.NewReader(resp.Body) + if err != nil { + return err + } + defer gz.Close() + + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if err == io.EOF { + return nil + } + if err != nil { + return err + } + target, err := safeJoin(dest, hdr.Name) + if err != nil { + return err + } + switch hdr.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(target, 0o755); err != nil { + return err + } + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + f, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(hdr.Mode)) + if err != nil { + return err + } + if _, err := io.Copy(f, tr); err != nil { + f.Close() + return err + } + f.Close() + } + } +} + +// safeJoin защищает от архивов с путями вида ../../etc/passwd. Сегменты +// проверяются до path.Clean: тот схлопнул бы выход за корень и скрыл его. +func safeJoin(root, name string) (string, error) { + slashed := filepath.ToSlash(name) + if path.IsAbs(slashed) { + return "", fmt.Errorf("абсолютный путь в архиве: %s", name) + } + for _, segment := range strings.Split(slashed, "/") { + if segment == ".." { + return "", fmt.Errorf("небезопасный путь в архиве: %s", name) + } + } + return filepath.Join(root, filepath.FromSlash(path.Clean(slashed))), nil +} + +func fileExists(p string) bool { + _, err := os.Stat(p) + return err == nil +} diff --git a/internal/sysinfo/sysinfo.go b/internal/sysinfo/sysinfo.go new file mode 100644 index 0000000..2854695 --- /dev/null +++ b/internal/sysinfo/sysinfo.go @@ -0,0 +1,42 @@ +// Package sysinfo снимает метрики хоста и группы процессов игрового сервера. +package sysinfo + +import "sync" + +// ProcStats — потребление ресурсов одной группой процессов. +type ProcStats struct { + // CPUPercent — доля CPU за интервал между двумя снимками; 100% = одно ядро. + CPUPercent float64 `json:"cpu_percent"` + // RSSBytes — резидентная память всех процессов группы. + RSSBytes int64 `json:"rss_bytes"` + // Threads — суммарное число потоков. + Threads int `json:"threads"` +} + +// HostStats — общее состояние машины. +type HostStats struct { + MemTotalBytes int64 `json:"mem_total_bytes"` + MemAvailableBytes int64 `json:"mem_available_bytes"` + Load1 float64 `json:"load1"` + CPUCount int `json:"cpu_count"` + DiskTotalBytes int64 `json:"disk_total_bytes"` + DiskFreeBytes int64 `json:"disk_free_bytes"` +} + +// Sampler считает CPU по разнице между вызовами, поэтому хранит прошлый снимок. +type Sampler struct { + mu sync.Mutex + lastCPU float64 // накопленное CPU-время группы, секунды + lastWall float64 // момент снятия, секунды монотонных часов +} + +// NewSampler создаёт счётчик метрик для одной группы процессов. +func NewSampler() *Sampler { return &Sampler{} } + +// Reset забывает предыдущий снимок — вызывается при перезапуске сервера, +// иначе первый замер нового процесса посчитается от чужой базы. +func (s *Sampler) Reset() { + s.mu.Lock() + s.lastCPU, s.lastWall = 0, 0 + s.mu.Unlock() +} diff --git a/internal/sysinfo/sysinfo_linux.go b/internal/sysinfo/sysinfo_linux.go new file mode 100644 index 0000000..1f78829 --- /dev/null +++ b/internal/sysinfo/sysinfo_linux.go @@ -0,0 +1,156 @@ +//go:build linux + +package sysinfo + +import ( + "os" + "path/filepath" + "strconv" + "strings" + + "golang.org/x/sys/unix" +) + +var pageSize = int64(os.Getpagesize()) + +// clockTicks — USER_HZ. На всех практически встречающихся Linux-сборках 100. +const clockTicks = 100.0 + +// Sample обходит /proc и суммирует статистику всех процессов, входящих в +// группу pgid: сервер PZ — это скрипт-обёртка плюс порождённая им JVM. +func (s *Sampler) Sample(pgid int) ProcStats { + var ( + stats ProcStats + cpuSecs float64 + ) + if pgid <= 0 { + s.Reset() + return stats + } + + entries, err := os.ReadDir("/proc") + if err != nil { + return stats + } + for _, e := range entries { + if !e.IsDir() { + continue + } + pid, err := strconv.Atoi(e.Name()) + if err != nil { + continue + } + utime, stime, threads, group, ok := readProcStat(pid) + if !ok || group != pgid { + continue + } + cpuSecs += (utime + stime) / clockTicks + stats.Threads += threads + stats.RSSBytes += readRSS(pid) + } + + now := uptimeSeconds() + s.mu.Lock() + if s.lastWall > 0 && now > s.lastWall { + stats.CPUPercent = (cpuSecs - s.lastCPU) / (now - s.lastWall) * 100 + if stats.CPUPercent < 0 { + stats.CPUPercent = 0 + } + } + s.lastCPU, s.lastWall = cpuSecs, now + s.mu.Unlock() + return stats +} + +// readProcStat разбирает /proc//stat. Имя процесса заключено в скобки и +// может содержать пробелы, поэтому режем строку после последней ')'. +func readProcStat(pid int) (utime, stime float64, threads, pgrp int, ok bool) { + raw, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "stat")) + if err != nil { + return 0, 0, 0, 0, false + } + line := string(raw) + idx := strings.LastIndex(line, ")") + if idx < 0 || idx+2 >= len(line) { + return 0, 0, 0, 0, false + } + // Поля после comm: state(3) ppid(4) pgrp(5) ... utime(14) stime(15) ... num_threads(20). + fields := strings.Fields(line[idx+2:]) + if len(fields) < 18 { + return 0, 0, 0, 0, false + } + pgrp, _ = strconv.Atoi(fields[2]) + utime, _ = strconv.ParseFloat(fields[11], 64) + stime, _ = strconv.ParseFloat(fields[12], 64) + threads, _ = strconv.Atoi(fields[17]) + return utime, stime, threads, pgrp, true +} + +// readRSS берёт резидентные страницы из /proc//statm (второе поле). +func readRSS(pid int) int64 { + raw, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "statm")) + if err != nil { + return 0 + } + fields := strings.Fields(string(raw)) + if len(fields) < 2 { + return 0 + } + pages, _ := strconv.ParseInt(fields[1], 10, 64) + return pages * pageSize +} + +func uptimeSeconds() float64 { + raw, err := os.ReadFile("/proc/uptime") + if err != nil { + return 0 + } + fields := strings.Fields(string(raw)) + if len(fields) == 0 { + return 0 + } + v, _ := strconv.ParseFloat(fields[0], 64) + return v +} + +// Host собирает состояние машины; diskPath задаёт раздел для расчёта места. +func Host(diskPath string) HostStats { + h := HostStats{CPUCount: cpuCount()} + + if raw, err := os.ReadFile("/proc/meminfo"); err == nil { + for _, line := range strings.Split(string(raw), "\n") { + key, value, found := strings.Cut(line, ":") + if !found { + continue + } + kb, _ := strconv.ParseInt(strings.Fields(strings.TrimSpace(value))[0], 10, 64) + switch key { + case "MemTotal": + h.MemTotalBytes = kb * 1024 + case "MemAvailable": + h.MemAvailableBytes = kb * 1024 + } + } + } + if raw, err := os.ReadFile("/proc/loadavg"); err == nil { + if fields := strings.Fields(string(raw)); len(fields) > 0 { + h.Load1, _ = strconv.ParseFloat(fields[0], 64) + } + } + if diskPath != "" { + var st unix.Statfs_t + if err := unix.Statfs(diskPath, &st); err == nil { + h.DiskTotalBytes = int64(st.Blocks) * int64(st.Bsize) + h.DiskFreeBytes = int64(st.Bavail) * int64(st.Bsize) + } + } + return h +} + +func cpuCount() int { + raw, err := os.ReadFile("/proc/cpuinfo") + if err != nil { + return 0 + } + return strings.Count(string(raw), "processor\t:") +} diff --git a/internal/sysinfo/sysinfo_other.go b/internal/sysinfo/sysinfo_other.go new file mode 100644 index 0000000..c24644f --- /dev/null +++ b/internal/sysinfo/sysinfo_other.go @@ -0,0 +1,14 @@ +//go:build !linux + +package sysinfo + +import "runtime" + +// Sample на не-Linux платформах не читает /proc: менеджер рассчитан на +// Ubuntu, а сборка под другие ОС нужна только для разработки. +func (s *Sampler) Sample(pgid int) ProcStats { return ProcStats{} } + +// Host отдаёт лишь число ядер — остальное платформозависимо. +func Host(diskPath string) HostStats { + return HostStats{CPUCount: runtime.NumCPU()} +} diff --git a/web/static/app.js b/web/static/app.js new file mode 100644 index 0000000..f9cb06f --- /dev/null +++ b/web/static/app.js @@ -0,0 +1,919 @@ +"use strict"; + +// --------------------------------------------------------------------------- +// Обращения к API +// --------------------------------------------------------------------------- + +// Заголовок обязателен для всех изменяющих запросов: сервер использует его как +// защиту от CSRF вместо отдельных токенов. +const CSRF_HEADER = "X-PZM-Request"; + +async function api(method, path, body) { + const options = { + method, + headers: { [CSRF_HEADER]: "1" }, + credentials: "same-origin", + }; + if (body !== undefined) { + options.headers["Content-Type"] = "application/json"; + options.body = JSON.stringify(body); + } + + const response = await fetch(path, options); + if (response.status === 401) { + showAuth(false); + throw new Error("Сессия истекла, войдите заново"); + } + + const text = await response.text(); + const data = text ? JSON.parse(text) : {}; + if (!response.ok) { + throw new Error(data.error || `Ошибка ${response.status}`); + } + return data; +} + +const get = (path) => api("GET", path); +const post = (path, body) => api("POST", path, body); +const put = (path, body) => api("PUT", path, body); +const del = (path) => api("DELETE", path); + +// --------------------------------------------------------------------------- +// Мелкие утилиты +// --------------------------------------------------------------------------- + +const $ = (selector) => document.querySelector(selector); +const $$ = (selector) => Array.from(document.querySelectorAll(selector)); + +function toast(message, kind = "") { + const el = document.createElement("div"); + el.className = `toast ${kind}`; + el.textContent = message; + $("#toasts").appendChild(el); + setTimeout(() => el.remove(), kind === "err" ? 7000 : 4000); +} + +const ok = (message) => toast(message, "ok"); +const fail = (error) => toast(error.message || String(error), "err"); + +function formatBytes(bytes) { + if (!bytes) return "—"; + const units = ["Б", "КБ", "МБ", "ГБ", "ТБ"]; + let value = bytes; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit++; + } + return `${value.toFixed(value < 10 && unit > 0 ? 1 : 0)} ${units[unit]}`; +} + +function formatUptime(seconds) { + if (!seconds) return ""; + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const parts = []; + if (days) parts.push(`${days} д`); + if (hours) parts.push(`${hours} ч`); + parts.push(`${minutes} мин`); + return "Работает: " + parts.join(" "); +} + +function formatDate(unixSeconds) { + return new Date(unixSeconds * 1000).toLocaleString("ru-RU"); +} + +// Экранирование не нужно там, где мы пишем в textContent, но пригодится для +// заголовков модов, которые приходят из Steam. +function escapeHTML(value) { + const div = document.createElement("div"); + div.textContent = value ?? ""; + return div.innerHTML; +} + +const STATE_LABELS = { + stopped: "Остановлен", + starting: "Запускается", + running: "Работает", + stopping: "Останавливается", +}; + +// --------------------------------------------------------------------------- +// Вход и первичная настройка +// --------------------------------------------------------------------------- + +let setupMode = false; + +function showAuth(needSetup) { + setupMode = needSetup; + $("#app").classList.add("hidden"); + $("#auth-screen").classList.remove("hidden"); + $("#token-field").hidden = !needSetup; + $("#auth-subtitle").textContent = needSetup + ? "Создайте учётную запись администратора" + : "Управление сервером Project Zomboid"; + $("#auth-submit").textContent = needSetup ? "Создать администратора" : "Войти"; + $("#auth-form").querySelector("[name=password]").autocomplete = needSetup + ? "new-password" + : "current-password"; + stopPolling(); +} + +function showApp(login) { + $("#auth-screen").classList.add("hidden"); + $("#app").classList.remove("hidden"); + $("#current-user").textContent = login; + startPolling(); + openConsoleStream(); + loadTab(currentTab); +} + +$("#auth-form").addEventListener("submit", async (event) => { + event.preventDefault(); + const form = new FormData(event.target); + const payload = { + login: form.get("login").trim(), + password: form.get("password"), + }; + if (setupMode) payload.token = (form.get("token") || "").trim(); + + const errorBox = $("#auth-error"); + errorBox.hidden = true; + try { + const result = await post(setupMode ? "/api/setup" : "/api/login", payload); + event.target.reset(); + showApp(result.login); + } catch (error) { + errorBox.textContent = error.message; + errorBox.hidden = false; + } +}); + +$("#btn-logout").addEventListener("click", async () => { + try { + await post("/api/logout"); + } catch (error) { + /* даже при ошибке уводим на экран входа */ + } + closeConsoleStream(); + showAuth(false); +}); + +async function checkSession() { + const session = await get("/api/session"); + if (session.authenticated) { + showApp(session.login); + } else { + showAuth(session.setup_needed); + } +} + +// --------------------------------------------------------------------------- +// Вкладки +// --------------------------------------------------------------------------- + +let currentTab = "overview"; + +$("#tabs").addEventListener("click", (event) => { + const tab = event.target.closest(".tab"); + if (!tab) return; + currentTab = tab.dataset.tab; + $$(".tab").forEach((t) => t.classList.toggle("active", t === tab)); + $$(".panel").forEach((p) => p.classList.toggle("active", p.dataset.panel === currentTab)); + loadTab(currentTab); +}); + +// loadTab подгружает данные вкладки при переходе на неё: конфиги и списки +// модов меняются редко, держать их в опросе статуса незачем. +function loadTab(name) { + const loaders = { + config: loadServerConfig, + sandbox: loadSandbox, + mods: loadMods, + backups: loadBackups, + settings: loadSettings, + }; + if (loaders[name]) loaders[name]().catch(fail); +} + +// --------------------------------------------------------------------------- +// Статус и управление сервером +// --------------------------------------------------------------------------- + +let pollTimer = null; +let lastTaskName = ""; + +function startPolling() { + stopPolling(); + refreshStatus(); + pollTimer = setInterval(refreshStatus, 2000); +} + +function stopPolling() { + if (pollTimer) clearInterval(pollTimer); + pollTimer = null; +} + +async function refreshStatus() { + let data; + try { + data = await get("/api/status"); + } catch (error) { + $("#status-text").textContent = "Нет связи с менеджером"; + $("#status-dot").className = "dot"; + return; + } + renderStatus(data.server); + renderTask(data.task); +} + +function renderStatus(status) { + const label = STATE_LABELS[status.state] || status.state; + $("#status-text").textContent = label; + $("#status-dot").className = `dot ${status.state}`; + + $("#ov-state").textContent = label; + $("#ov-uptime").textContent = formatUptime(status.uptime_sec); + $("#ov-pid").textContent = status.pid ? `PID ${status.pid}` : ""; + $("#ov-lastexit").textContent = + status.state === "stopped" && status.last_exit ? `Последнее завершение: ${status.last_exit}` : ""; + + const players = status.players || []; + $("#ov-players-count").textContent = players.length; + $("#ov-players").innerHTML = players.map((name) => `
  • ${escapeHTML(name)}
  • `).join(""); + + $("#ov-cpu").textContent = status.proc.cpu_percent ? `${status.proc.cpu_percent.toFixed(0)} %` : "—"; + $("#ov-rss").textContent = formatBytes(status.proc.rss_bytes); + $("#ov-threads").textContent = status.proc.threads || "—"; + + const host = status.host || {}; + $("#ov-mem").textContent = host.mem_total_bytes + ? `${formatBytes(host.mem_available_bytes)} из ${formatBytes(host.mem_total_bytes)}` + : "—"; + $("#ov-load").textContent = host.cpu_count + ? `${(host.load1 || 0).toFixed(2)} на ${host.cpu_count} ядер` + : (host.load1 || 0).toFixed(2); + $("#ov-disk").textContent = host.disk_total_bytes + ? `${formatBytes(host.disk_free_bytes)} из ${formatBytes(host.disk_total_bytes)}` + : "—"; + + $("#install-state").textContent = status.installed + ? "Серверные файлы установлены." + : "Серверные файлы не найдены — установите их перед первым запуском."; + + const running = status.state !== "stopped"; + $("#btn-start").disabled = running || !status.installed; + $("#btn-stop").disabled = !running; + $("#btn-restart").disabled = !status.installed; + $("#btn-install").disabled = running; +} + +function renderTask(task) { + const banner = $("#task-banner"); + banner.hidden = !task || !task.running; + if (task && task.running) { + $("#task-name").textContent = task.name; + lastTaskName = task.name; + } else if (task && task.name && task.name === lastTaskName) { + // Сообщаем об исходе один раз — на следующем опросе имя уже совпадать не будет. + lastTaskName = ""; + if (task.error) { + toast(`${task.name}: ${task.error}`, "err"); + } else { + ok(`${task.name} — готово`); + loadTab(currentTab); + } + } +} + +async function serverAction(path, confirmText) { + if (confirmText && !confirm(confirmText)) return; + try { + await post(path); + refreshStatus(); + } catch (error) { + fail(error); + } +} + +$("#btn-start").addEventListener("click", () => serverAction("/api/server/start")); +$("#btn-stop").addEventListener("click", () => + serverAction("/api/server/stop", "Остановить игровой сервер? Игроки будут отключены.")); +$("#btn-restart").addEventListener("click", () => + serverAction("/api/server/restart", "Перезапустить сервер? Игроки будут отключены.")); + +$("#btn-install").addEventListener("click", async () => { + if (!confirm("Запустить установку/обновление серверных файлов через SteamCMD?")) return; + try { + await post("/api/install"); + ok("Задача запущена — следите за вкладкой «Консоль»"); + refreshStatus(); + } catch (error) { + fail(error); + } +}); + +$("#btn-task-cancel").addEventListener("click", async () => { + try { + await post("/api/task/cancel"); + } catch (error) { + fail(error); + } +}); + +// --------------------------------------------------------------------------- +// Консоль +// --------------------------------------------------------------------------- + +let eventSource = null; +let lastSeq = 0; +const MAX_CONSOLE_LINES = 3000; + +function openConsoleStream() { + closeConsoleStream(); + eventSource = new EventSource(`/api/logs/stream?since=${lastSeq}`); + eventSource.onmessage = (event) => appendConsoleLine(JSON.parse(event.data)); + eventSource.onerror = () => { + // Браузер переподключается сам; сообщать об этом каждый раз не нужно. + }; +} + +function closeConsoleStream() { + if (eventSource) eventSource.close(); + eventSource = null; +} + +function appendConsoleLine(line) { + if (line.seq <= lastSeq) return; + lastSeq = line.seq; + + const output = $("#console-output"); + const filter = $("#console-filter").value.trim().toLowerCase(); + + const el = document.createElement("div"); + el.className = `line-${line.stream}`; + el.textContent = line.text; + el.dataset.text = line.text.toLowerCase(); + if (filter && !el.dataset.text.includes(filter)) el.hidden = true; + output.appendChild(el); + + while (output.childElementCount > MAX_CONSOLE_LINES) { + output.removeChild(output.firstElementChild); + } + if ($("#console-autoscroll").checked) { + output.scrollTop = output.scrollHeight; + } +} + +$("#console-filter").addEventListener("input", (event) => { + const filter = event.target.value.trim().toLowerCase(); + Array.from($("#console-output").children).forEach((line) => { + line.hidden = filter && !line.dataset.text.includes(filter); + }); +}); + +$("#btn-console-clear").addEventListener("click", () => { + // Чистим только экран: история остаётся в буфере менеджера. + $("#console-output").innerHTML = ""; +}); + +// История отправленных команд: та же логика, что и в обычном терминале. +const commandHistory = []; +let historyIndex = 0; + +$("#console-form").addEventListener("submit", async (event) => { + event.preventDefault(); + const input = $("#console-command"); + const command = input.value.trim(); + if (!command) return; + try { + await post("/api/console", { command }); + if (commandHistory[commandHistory.length - 1] !== command) commandHistory.push(command); + historyIndex = commandHistory.length; + input.value = ""; + } catch (error) { + fail(error); + } +}); + +$("#console-command").addEventListener("keydown", (event) => { + if (event.key === "Enter") { + // Отправляем явно: полагаться на неявную отправку формы по Enter не стоит, + // она зависит от браузера. + event.preventDefault(); + $("#console-form").requestSubmit(); + return; + } + if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return; + + event.preventDefault(); + historyIndex += event.key === "ArrowUp" ? -1 : 1; + historyIndex = Math.max(0, Math.min(historyIndex, commandHistory.length)); + event.target.value = commandHistory[historyIndex] ?? ""; +}); + +// --------------------------------------------------------------------------- +// Конфиг сервера +// --------------------------------------------------------------------------- + +// Подсказки к самым востребованным параметрам: остальные показываются как есть. +const CONFIG_HINTS = { + PublicName: "Название сервера в списке серверов", + PublicDescription: "Описание сервера", + Public: "Показывать сервер в публичном списке (true/false)", + Password: "Пароль для входа на сервер (пусто — без пароля)", + MaxPlayers: "Максимум игроков", + DefaultPort: "Игровой порт (UDP), по умолчанию 16261", + UDPPort: "Второй игровой порт, по умолчанию 16262", + PVP: "Разрешить бой между игроками", + PauseEmpty: "Ставить мир на паузу, когда никого нет онлайн", + GlobalChat: "Общий чат для всего сервера", + Open: "Пускать игроков без записи в белом списке", + ServerWelcomeMessage: "Приветственное сообщение", + SaveWorldEveryMinutes: "Как часто сохранять мир, минут", + BackupsOnStart: "Делать бэкап силами самой игры при старте", + RCONPort: "Порт RCON", + RCONPassword: "Пароль RCON", + AdminSafehouse: "Разрешить админам входить в чужие убежища", + SafetySystem: "Система защиты от PVP-урона", + DisplayUserName: "Показывать ники над персонажами", + SpawnPoint: "Точка появления новых игроков (x,y,z)", + Mods: "Список модов — редактируется во вкладке «Моды»", + WorkshopItems: "Моды мастерской — редактируется во вкладке «Моды»", + Map: "Карты — редактируется во вкладке «Моды»", +}; + +const BOOLEAN_VALUES = new Set(["true", "false"]); + +let serverConfigValues = {}; + +async function loadServerConfig() { + const data = await get("/api/config/server"); + $("#config-path").textContent = data.path; + $("#config-empty").hidden = data.exists; + $("#config-fields").innerHTML = ""; + + if (!data.exists) { + $("#config-empty").textContent = data.hint; + return; + } + serverConfigValues = data.values; + const container = $("#config-fields"); + data.keys.forEach((key) => { + container.appendChild(buildConfigField(key, data.values[key], CONFIG_HINTS[key])); + }); + applyFieldFilter("#config-filter", "#config-fields"); +} + +// buildConfigField выбирает тип поля по текущему значению: булевы значения +// удобнее переключать списком, числа — числовым полем. +function buildConfigField(key, value, hint) { + const wrapper = document.createElement("div"); + wrapper.className = "cfg-field"; + wrapper.dataset.key = key.toLowerCase(); + wrapper.dataset.hint = (hint || "").toLowerCase(); + + const label = document.createElement("label"); + label.textContent = hint || key; + wrapper.appendChild(label); + + let input; + if (BOOLEAN_VALUES.has(value.toLowerCase())) { + input = document.createElement("select"); + ["true", "false"].forEach((option) => { + const el = document.createElement("option"); + el.value = option; + el.textContent = option === "true" ? "да" : "нет"; + input.appendChild(el); + }); + input.value = value.toLowerCase(); + } else if (/^-?\d+$/.test(value) && value.length < 10) { + input = document.createElement("input"); + input.type = "number"; + input.value = value; + } else { + input = document.createElement("input"); + input.type = key.toLowerCase().includes("password") ? "password" : "text"; + input.value = value; + } + input.dataset.original = value; + input.name = key; + input.addEventListener("input", () => { + wrapper.classList.toggle("changed", input.value !== input.dataset.original); + }); + wrapper.appendChild(input); + + const keyLabel = document.createElement("div"); + keyLabel.className = "key"; + keyLabel.textContent = key; + wrapper.appendChild(keyLabel); + return wrapper; +} + +// collectChanged собирает только изменённые поля: отправлять весь файл целиком +// рискованно, если его правили ещё и вручную. +function collectChanged(containerSelector) { + const changed = {}; + $$(`${containerSelector} .cfg-field`).forEach((field) => { + const input = field.querySelector("input, select"); + if (input && input.value !== input.dataset.original) { + changed[input.name] = input.value; + } + }); + return changed; +} + +function markSaved(containerSelector) { + $$(`${containerSelector} .cfg-field`).forEach((field) => { + const input = field.querySelector("input, select"); + if (input) input.dataset.original = input.value; + field.classList.remove("changed"); + }); +} + +$("#btn-config-save").addEventListener("click", async () => { + const values = collectChanged("#config-fields"); + if (!Object.keys(values).length) { + toast("Нет изменений"); + return; + } + try { + await put("/api/config/server", { values }); + markSaved("#config-fields"); + ok("Конфиг сохранён. Изменения применятся после перезапуска сервера."); + } catch (error) { + fail(error); + } +}); + +function applyFieldFilter(inputSelector, containerSelector) { + const filter = $(inputSelector).value.trim().toLowerCase(); + $$(`${containerSelector} .cfg-field`).forEach((field) => { + field.hidden = filter && !field.dataset.key.includes(filter) && !field.dataset.hint.includes(filter); + }); +} + +$("#config-filter").addEventListener("input", () => applyFieldFilter("#config-filter", "#config-fields")); +$("#sandbox-filter").addEventListener("input", () => applyFieldFilter("#sandbox-filter", "#sandbox-fields")); + +// --------------------------------------------------------------------------- +// Песочница +// --------------------------------------------------------------------------- + +async function loadSandbox() { + const data = await get("/api/config/sandbox"); + $("#sandbox-path").textContent = data.path; + $("#sandbox-empty").hidden = data.exists; + $("#sandbox-fields").innerHTML = ""; + + if (!data.exists) { + $("#sandbox-empty").textContent = data.hint; + return; + } + const container = $("#sandbox-fields"); + data.entries.forEach((entry) => { + container.appendChild(buildConfigField(entry.path, entry.value, null)); + }); + applyFieldFilter("#sandbox-filter", "#sandbox-fields"); +} + +$("#btn-sandbox-save").addEventListener("click", async () => { + const values = collectChanged("#sandbox-fields"); + if (!Object.keys(values).length) { + toast("Нет изменений"); + return; + } + try { + await put("/api/config/sandbox", { values }); + markSaved("#sandbox-fields"); + ok("Песочница сохранена. Часть параметров действует только на новый мир."); + } catch (error) { + fail(error); + } +}); + +// --------------------------------------------------------------------------- +// Моды +// --------------------------------------------------------------------------- + +let mods = { workshop: [], modIDs: [], maps: [] }; + +async function loadMods() { + const data = await get("/api/mods"); + $("#mods-empty").hidden = data.exists; + $("#mods-body").hidden = !data.exists; + if (!data.exists) { + $("#mods-empty").textContent = data.hint; + return; + } + + mods = { + workshop: data.workshop_items || [], + modIDs: data.mods || [], + maps: data.map || [], + }; + $("#map-list").value = mods.maps.join("\n"); + renderMods(); + lookupWorkshopTitles().catch(() => { + /* без интернета останутся одни идентификаторы — это не ошибка панели */ + }); +} + +function renderMods(details = {}) { + $("#workshop-list").innerHTML = mods.workshop + .map((id, index) => { + const info = details[id] || {}; + const title = info.error ? `${escapeHTML(info.error)}` : escapeHTML(info.title || "…"); + const preview = info.preview + ? `` + : ""; + return `
  • + ${preview} +
    ${title}${escapeHTML(id)}
    + + + +
  • `; + }) + .join(""); + + $("#modid-list").innerHTML = mods.modIDs + .map( + (id, index) => `
  • +
    ${escapeHTML(id)}
    + + + +
  • ` + ) + .join(""); +} + +async function lookupWorkshopTitles() { + if (!mods.workshop.length) return; + const data = await post("/api/mods/lookup", { ids: mods.workshop }); + const details = {}; + (data.items || []).forEach((item) => { + details[item.id] = item; + }); + renderMods(details); +} + +// Обе кнопки — перемещение и удаление — обрабатываем одним слушателем на списке. +$("#mods-body").addEventListener("click", (event) => { + const button = event.target.closest("button[data-move], button[data-remove]"); + if (!button) return; + + const index = Number(button.dataset.index); + const list = (button.dataset.move || button.dataset.remove) === "workshop" ? mods.workshop : mods.modIDs; + + if (button.dataset.move) { + const target = index + Number(button.dataset.dir); + if (target < 0 || target >= list.length) return; + [list[index], list[target]] = [list[target], list[index]]; + } else { + list.splice(index, 1); + } + renderMods(); + lookupWorkshopTitles().catch(() => {}); +}); + +$("#mod-add-form").addEventListener("submit", (event) => { + event.preventDefault(); + const workshopID = $("#mod-workshop-id").value.trim(); + const modID = $("#mod-mod-id").value.trim(); + + if (workshopID && !/^\d+$/.test(workshopID)) { + toast("Workshop ID — это число из ссылки на мод", "err"); + return; + } + if (!workshopID && !modID) return; + + if (workshopID && !mods.workshop.includes(workshopID)) mods.workshop.push(workshopID); + if (modID && !mods.modIDs.includes(modID)) mods.modIDs.push(modID); + + $("#mod-workshop-id").value = ""; + $("#mod-mod-id").value = ""; + renderMods(); + lookupWorkshopTitles().catch(() => {}); +}); + +$("#btn-mods-save").addEventListener("click", async () => { + const maps = $("#map-list").value.split("\n").map((line) => line.trim()).filter(Boolean); + try { + await put("/api/mods", { + mods: mods.modIDs, + workshop_items: mods.workshop, + map: maps, + }); + ok("Список модов сохранён. Перезапустите сервер, чтобы он их подтянул."); + } catch (error) { + fail(error); + } +}); + +// --------------------------------------------------------------------------- +// Бэкапы +// --------------------------------------------------------------------------- + +async function loadBackups() { + const data = await get("/api/backups"); + $("#backup-dir").textContent = data.dir; + + const rows = (data.backups || []).map( + (archive) => ` + ${escapeHTML(archive.name)} + ${formatBytes(archive.size_bytes)} + ${formatDate(archive.created_at)} + + Скачать + + + + ` + ); + $("#backup-rows").innerHTML = rows.join("") || + `Пока ни одного архива`; +} + +$("#backup-form").addEventListener("submit", async (event) => { + event.preventDefault(); + try { + await post("/api/backups", { note: $("#backup-note").value.trim() }); + $("#backup-note").value = ""; + ok("Бэкап запущен — прогресс во вкладке «Консоль»"); + } catch (error) { + fail(error); + } +}); + +$("#backup-rows").addEventListener("click", async (event) => { + const restore = event.target.closest("button[data-restore]"); + const remove = event.target.closest("button[data-delete]"); + + try { + if (restore) { + const name = restore.dataset.restore; + if (!confirm(`Восстановить мир из ${name}? Текущий мир будет перезаписан.\n` + + `Перед восстановлением менеджер сам сделает страховочный бэкап.`)) return; + await post(`/api/backups/${encodeURIComponent(name)}/restore`); + ok("Восстановление запущено"); + } else if (remove) { + const name = remove.dataset.delete; + if (!confirm(`Удалить архив ${name}? Это действие необратимо.`)) return; + await del(`/api/backups/${encodeURIComponent(name)}`); + ok("Архив удалён"); + loadBackups(); + } + } catch (error) { + fail(error); + } +}); + +// --------------------------------------------------------------------------- +// Настройки менеджера и пользователи +// --------------------------------------------------------------------------- + +// Описание формы настроек: ключ в API, подпись, тип и пояснение. +const SETTINGS_FIELDS = [ + ["listen", "Адрес веб-панели", "text", "Например 127.0.0.1:8080. Применится после перезапуска pzmanager"], + ["server_dir", "Директория сервера", "text", "Куда SteamCMD ставит файлы Project Zomboid"], + ["zomboid_dir", "Директория данных (~/Zomboid)", "text", "Здесь лежат миры, конфиги и логи игры"], + ["steamcmd_path", "Путь к steamcmd.sh", "text", "Пусто — найти в PATH или скачать автоматически"], + ["server_name", "Имя конфигурации сервера", "text", "servertest → servertest.ini"], + ["java_memory", "Память JVM", "text", "Например 8g. Применяется при установке/обновлении"], + ["admin_password", "Пароль админа игры", "password", "Оставьте пустым, чтобы не менять"], + ["autostart", "Запускать сервер вместе с менеджером", "checkbox", ""], + ["autorestart", "Поднимать сервер после падения", "checkbox", ""], + ["stop_timeout_sec", "Таймаут остановки, сек", "number", "Сколько ждать после команды quit до SIGTERM"], + ["log_buffer_lines", "Строк консоли в памяти", "number", ""], + ["backup_dir", "Директория бэкапов", "text", ""], + ["backup_schedule_min", "Автобэкап, минут", "number", "0 — отключить автоматические бэкапы"], + ["backup_keep", "Хранить архивов", "number", "0 — не удалять старые"], + ["backup_stop_server", "Останавливать сервер на время бэкапа", "checkbox", + "Даёт согласованный снимок, но выкидывает игроков"], +]; + +async function loadSettings() { + const config = await get("/api/config/manager"); + const form = $("#settings-form"); + form.innerHTML = ""; + + SETTINGS_FIELDS.forEach(([key, label, type, hint]) => { + const wrapper = document.createElement("div"); + wrapper.className = "cfg-field"; + + const labelEl = document.createElement("label"); + labelEl.textContent = label; + wrapper.appendChild(labelEl); + + const input = document.createElement("input"); + input.type = type; + input.name = key; + if (type === "checkbox") { + input.checked = Boolean(config[key]); + } else if (key === "admin_password") { + input.placeholder = config.has_admin_password ? "пароль задан" : "не задан"; + } else { + input.value = config[key] ?? ""; + } + wrapper.appendChild(input); + + if (hint) { + const hintEl = document.createElement("div"); + hintEl.className = "hint"; + hintEl.textContent = hint; + wrapper.appendChild(hintEl); + } + form.appendChild(wrapper); + }); + + const pathNote = document.createElement("p"); + pathNote.className = "muted small"; + pathNote.textContent = `Файл конфигурации: ${config.config_path}`; + form.appendChild(pathNote); + + await loadUsers(); +} + +$("#btn-settings-save").addEventListener("click", async () => { + const payload = {}; + SETTINGS_FIELDS.forEach(([key, , type]) => { + const input = $(`#settings-form [name="${key}"]`); + if (!input) return; + if (type === "checkbox") { + payload[key] = input.checked; + } else if (type === "number") { + payload[key] = Number(input.value || 0); + } else { + payload[key] = input.value.trim(); + } + }); + // Пустой пароль означает «не менять» — не отправляем его вовсе. + if (!payload.admin_password) delete payload.admin_password; + try { + const result = await put("/api/config/manager", payload); + ok(result.note || "Настройки сохранены"); + loadSettings(); + } catch (error) { + fail(error); + } +}); + +async function loadUsers() { + const data = await get("/api/users"); + $("#user-list").innerHTML = (data.users || []) + .map( + (login) => `
  • + ${escapeHTML(login)} + + +
  • ` + ) + .join(""); +} + +$("#user-list").addEventListener("click", async (event) => { + const passwd = event.target.closest("button[data-passwd]"); + const remove = event.target.closest("button[data-deluser]"); + + try { + if (passwd) { + const password = prompt(`Новый пароль для ${passwd.dataset.passwd} (минимум 8 символов):`); + if (!password) return; + await post(`/api/users/${encodeURIComponent(passwd.dataset.passwd)}/password`, { password }); + ok("Пароль изменён"); + } else if (remove) { + if (!confirm(`Удалить пользователя ${remove.dataset.deluser}?`)) return; + await del(`/api/users/${encodeURIComponent(remove.dataset.deluser)}`); + ok("Пользователь удалён"); + loadUsers(); + } + } catch (error) { + fail(error); + } +}); + +$("#user-add-form").addEventListener("submit", async (event) => { + event.preventDefault(); + try { + await post("/api/users", { + login: $("#new-user-login").value.trim(), + password: $("#new-user-password").value, + }); + $("#new-user-login").value = ""; + $("#new-user-password").value = ""; + ok("Пользователь добавлен"); + loadUsers(); + } catch (error) { + fail(error); + } +}); + +// --------------------------------------------------------------------------- + +checkSession().catch((error) => { + fail(error); + showAuth(false); +}); diff --git a/web/static/index.html b/web/static/index.html new file mode 100644 index 0000000..6bde546 --- /dev/null +++ b/web/static/index.html @@ -0,0 +1,237 @@ + + + + + + PZ Manager + + + + + + + + + + + +
    + + + diff --git a/web/static/style.css b/web/static/style.css new file mode 100644 index 0000000..6f741bc --- /dev/null +++ b/web/static/style.css @@ -0,0 +1,384 @@ +/* Палитра панели: тёмная, с зелёным акцентом «в тему» Project Zomboid. */ +:root { + --bg: #14171c; + --bg-soft: #1b1f26; + --bg-card: #20252e; + --border: #2c333f; + --text: #e6e9ef; + --muted: #98a2b3; + --accent: #6bbf59; + --accent-dark: #4f9440; + --danger: #e05c5c; + --warn: #e0a95c; + --info: #5c9ce0; + --radius: 10px; + --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font: 14px/1.5 var(--font); +} + +.hidden { display: none !important; } +/* Атрибут hidden обязан побеждать display из правил ниже: иначе элементы с + display: flex/block остаются на экране. */ +[hidden] { display: none !important; } +.muted { color: var(--muted); } +.small { font-size: 12px; } +.error { color: var(--danger); } +.spacer { flex: 1; } + +h1, h2, h3 { margin: 0 0 12px; font-weight: 600; } +h2 { font-size: 17px; } +h3 { font-size: 14px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; } + +code { + font-family: var(--mono); + background: var(--bg-soft); + padding: 1px 5px; + border-radius: 4px; + font-size: 12px; +} + +/* ---------- Экран входа ---------- */ + +.auth-screen { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; +} + +.auth-card { + width: 100%; + max-width: 380px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 28px; +} + +.auth-title { font-size: 22px; text-align: center; } +.auth-subtitle { text-align: center; color: var(--muted); margin: 0 0 22px; } + +/* ---------- Каркас ---------- */ + +.app { min-height: 100vh; display: flex; flex-direction: column; } + +/* Кнопки управления и статус сервера должны оставаться под рукой на длинных + вкладках — конфиг и моды прокручиваются далеко вниз. */ +.topbar { + position: sticky; + top: 0; + z-index: 20; + display: flex; + align-items: center; + gap: 12px; + padding: 12px 20px; + background: var(--bg-soft); + border-bottom: 1px solid var(--border); + flex-wrap: wrap; +} + +.brand { font-weight: 600; font-size: 16px; } +.user { color: var(--muted); font-size: 13px; } + +.status-pill { + display: inline-flex; + align-items: center; + gap: 8px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 999px; + padding: 5px 14px; + font-size: 13px; +} + +.dot { + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--muted); +} +.dot.running { background: var(--accent); box-shadow: 0 0 8px var(--accent); } +.dot.starting, .dot.stopping { background: var(--warn); animation: pulse 1.2s infinite; } +.dot.stopped { background: #5b636f; } + +@keyframes pulse { 50% { opacity: .35; } } + +.controls { display: flex; gap: 8px; } + +.tabs { + position: sticky; + top: 53px; + z-index: 19; + display: flex; + gap: 2px; + padding: 0 20px; + background: var(--bg-soft); + border-bottom: 1px solid var(--border); + overflow-x: auto; +} + +.tab { + background: none; + border: none; + border-bottom: 2px solid transparent; + color: var(--muted); + padding: 11px 14px; + cursor: pointer; + font: inherit; + white-space: nowrap; +} +.tab:hover { color: var(--text); } +.tab.active { color: var(--text); border-bottom-color: var(--accent); } + +.content { padding: 20px; max-width: 1100px; width: 100%; margin: 0 auto; flex: 1; } + +.panel { display: none; } +.panel.active { display: block; } + +.panel-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + margin-bottom: 16px; + flex-wrap: wrap; +} +.panel-head > div:last-child { display: flex; gap: 8px; align-items: center; } + +/* ---------- Кнопки и поля ---------- */ + +.btn { + background: var(--bg-card); + border: 1px solid var(--border); + color: var(--text); + padding: 8px 14px; + border-radius: 8px; + cursor: pointer; + font: inherit; + transition: background .15s, border-color .15s; +} +.btn:hover:not(:disabled) { background: #2a303a; } +.btn:disabled { opacity: .45; cursor: not-allowed; } + +.btn-primary { background: var(--accent-dark); border-color: var(--accent-dark); } +.btn-primary:hover:not(:disabled) { background: var(--accent); border-color: var(--accent); } +.btn-start { border-color: var(--accent-dark); color: var(--accent); } +.btn-stop { border-color: #6a3c3c; color: var(--danger); } +.btn-danger { border-color: #6a3c3c; color: var(--danger); } +.btn-ghost { background: transparent; } +.btn-sm { padding: 5px 10px; font-size: 13px; } +.btn-block { width: 100%; margin-top: 8px; } + +input[type=text], input[type=password], input[type=search], input[type=number], textarea, select { + background: var(--bg); + border: 1px solid var(--border); + color: var(--text); + padding: 8px 10px; + border-radius: 8px; + font: inherit; + width: 100%; +} +input:focus, textarea:focus, select:focus { outline: none; border-color: var(--accent-dark); } +/* Селектор с тегом нужен, чтобы перебить width из правила input[type=...] + выше: у него специфичность выше, чем у одного класса. */ +input.input-sm, .input-sm { width: auto; padding: 6px 10px; font-size: 13px; } + +.field { display: block; margin-bottom: 14px; } +.field > span { display: block; margin-bottom: 5px; font-size: 13px; color: var(--muted); } +.field small { display: block; margin-top: 4px; color: var(--muted); font-size: 12px; } + +.checkbox { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-size: 13px; } +.checkbox input { width: auto; } + +/* ---------- Карточки обзора ---------- */ + +.cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 14px; + margin-bottom: 18px; +} + +.card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px; +} + +.stat { font-size: 24px; font-weight: 600; margin-bottom: 4px; } + +.metric { + display: flex; + justify-content: space-between; + padding: 4px 0; + border-bottom: 1px solid var(--border); +} +.metric:last-child { border-bottom: none; } +.metric span { color: var(--muted); } + +.player-list { list-style: none; margin: 6px 0 0; padding: 0; max-height: 120px; overflow-y: auto; } +.player-list li { padding: 3px 0; border-bottom: 1px solid var(--border); font-size: 13px; } + +/* ---------- Консоль ---------- */ + +.console-toolbar { display: flex; gap: 10px; align-items: center; margin-bottom: 10px; flex-wrap: wrap; } +.console-toolbar #console-filter { flex: 1 1 200px; max-width: 320px; } + +.console { + background: #0f1216; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 12px; + height: 60vh; + min-height: 320px; + overflow-y: auto; + font-family: var(--mono); + font-size: 12.5px; + line-height: 1.55; + margin: 0; + white-space: pre-wrap; + word-break: break-word; +} + +.console .line-manager { color: var(--info); } +.console .line-task { color: var(--warn); } +.console .line-console { color: var(--accent); } +.console .line-stderr { color: #e08c8c; } + +.console-input { display: flex; gap: 8px; margin-top: 10px; } +.console-input input { font-family: var(--mono); } + +/* ---------- Формы конфигов ---------- */ + +.fields { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 12px; +} + +.cfg-field { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 12px; +} +.cfg-field.changed { border-color: var(--accent-dark); } +.cfg-field label { display: block; font-size: 13px; margin-bottom: 6px; } +.cfg-field .hint { color: var(--muted); font-size: 12px; margin-top: 5px; } +.cfg-field .key { color: var(--muted); font-family: var(--mono); font-size: 11px; } + +/* ---------- Моды ---------- */ + +.mod-add { display: flex; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; } +.mod-add input { flex: 1; min-width: 180px; } + +.mod-list { list-style: none; padding: 0; margin: 0 0 18px; } + +.mod-list li { + display: flex; + align-items: center; + gap: 10px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + padding: 9px 12px; + margin-bottom: 6px; +} +.mod-list .mod-title { flex: 1; min-width: 0; } +.mod-list .mod-title b { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.mod-list .mod-title small { color: var(--muted); font-family: var(--mono); font-size: 11px; } +.mod-list img { width: 40px; height: 40px; object-fit: cover; border-radius: 6px; background: var(--bg); } + +/* ---------- Таблицы ---------- */ + +.table { width: 100%; border-collapse: collapse; } +.table th { + text-align: left; + color: var(--muted); + font-weight: 500; + font-size: 12px; + text-transform: uppercase; + padding: 8px; + border-bottom: 1px solid var(--border); +} +.table td { padding: 10px 8px; border-bottom: 1px solid var(--border); } +.table td:last-child { text-align: right; white-space: nowrap; } + +.user-list { list-style: none; padding: 0; margin: 0 0 12px; } +.user-list li { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 12px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + margin-bottom: 6px; +} +.user-list li span { flex: 1; } + +/* ---------- Задача и уведомления ---------- */ + +.task-banner { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 20px; + background: #2a2618; + border-bottom: 1px solid #4a4020; + color: var(--warn); + font-size: 13px; +} + +.spinner { + width: 13px; + height: 13px; + border: 2px solid rgba(224, 169, 92, .3); + border-top-color: var(--warn); + border-radius: 50%; + animation: spin .8s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } + +.toasts { + position: fixed; + right: 16px; + bottom: 16px; + display: flex; + flex-direction: column; + gap: 8px; + z-index: 50; + max-width: 380px; +} + +.toast { + background: var(--bg-card); + border: 1px solid var(--border); + border-left: 3px solid var(--info); + border-radius: 8px; + padding: 11px 14px; + font-size: 13px; + animation: slide-in .2s ease-out; +} +.toast.ok { border-left-color: var(--accent); } +.toast.err { border-left-color: var(--danger); } + +@keyframes slide-in { from { opacity: 0; transform: translateX(16px); } } + +@media (max-width: 640px) { + .topbar { gap: 8px; } + .controls { width: 100%; } + .controls .btn { flex: 1; } + .content { padding: 14px; } +} diff --git a/web/web.go b/web/web.go new file mode 100644 index 0000000..a079375 --- /dev/null +++ b/web/web.go @@ -0,0 +1,15 @@ +// Package web содержит статические файлы панели, вшитые в бинарник. +package web + +import ( + "embed" + "io/fs" +) + +//go:embed static +var files embed.FS + +// Static возвращает файловую систему с содержимым web/static. +func Static() (fs.FS, error) { + return fs.Sub(files, "static") +}