Веб-панель управления сервером 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 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 22:41:11 +03:00
co-authored by Claude Opus 5
commit d0ff64c272
38 changed files with 6054 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
# Собранные бинарники
/pzmanager
/pzmanager.exe
# Локальные данные разработки
/dev/
# Редакторы
.idea/
.vscode/
*.swp
+29
View File
@@ -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)
+199
View File
@@ -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 пользователь@сервер
```
Затем откройте <http://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`.
+280
View File
@@ -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)
}
+65
View File
@@ -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."
+32
View File
@@ -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
+10
View File
@@ -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
)
+10
View File
@@ -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=
+130
View File
@@ -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})
}
+208
View File
@@ -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",
})
}
+84
View File
@@ -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})
}
+138
View File
@@ -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
}
+212
View File
@@ -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
}
+233
View File
@@ -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
}
+264
View File
@@ -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
}
+347
View File
@@ -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])
}
+141
View File
@@ -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)
}
}
+201
View File
@@ -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 — путь к <name>_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")
}
+45
View File
@@ -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
}
+98
View File
@@ -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
}
+151
View File
@@ -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)
}
+62
View File
@@ -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)
}
+52
View File
@@ -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))
}
+156
View File
@@ -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)
}
}
}
+158
View File
@@ -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 — разбор <server>_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)
}
+153
View File
@@ -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
}
+91
View File
@@ -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()
}
}
+33
View File
@@ -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
)
+28
View File
@@ -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)
)
+459
View File
@@ -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()
}
}
}
}
+207
View File
@@ -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
}
+42
View File
@@ -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()
}
+156
View File
@@ -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/<pid>/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/<pid>/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:")
}
+14
View File
@@ -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()}
}
+919
View File
@@ -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) => `<li>${escapeHTML(name)}</li>`).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 ? `<span class="error">${escapeHTML(info.error)}</span>` : escapeHTML(info.title || "…");
const preview = info.preview
? `<img src="${escapeHTML(info.preview)}" alt="">`
: "";
return `<li>
${preview}
<div class="mod-title"><b>${title}</b><small>${escapeHTML(id)}</small></div>
<button class="btn btn-sm btn-ghost" data-move="workshop" data-index="${index}" data-dir="-1">↑</button>
<button class="btn btn-sm btn-ghost" data-move="workshop" data-index="${index}" data-dir="1">↓</button>
<button class="btn btn-sm btn-danger" data-remove="workshop" data-index="${index}">Убрать</button>
</li>`;
})
.join("");
$("#modid-list").innerHTML = mods.modIDs
.map(
(id, index) => `<li>
<div class="mod-title"><b>${escapeHTML(id)}</b></div>
<button class="btn btn-sm btn-ghost" data-move="mod" data-index="${index}" data-dir="-1">↑</button>
<button class="btn btn-sm btn-ghost" data-move="mod" data-index="${index}" data-dir="1">↓</button>
<button class="btn btn-sm btn-danger" data-remove="mod" data-index="${index}">Убрать</button>
</li>`
)
.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) => `<tr>
<td>${escapeHTML(archive.name)}</td>
<td>${formatBytes(archive.size_bytes)}</td>
<td>${formatDate(archive.created_at)}</td>
<td>
<a class="btn btn-sm btn-ghost" href="/api/backups/${encodeURIComponent(archive.name)}/download">Скачать</a>
<button class="btn btn-sm" data-restore="${escapeHTML(archive.name)}">Восстановить</button>
<button class="btn btn-sm btn-danger" data-delete="${escapeHTML(archive.name)}">Удалить</button>
</td>
</tr>`
);
$("#backup-rows").innerHTML = rows.join("") ||
`<tr><td colspan="4" class="muted">Пока ни одного архива</td></tr>`;
}
$("#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) => `<li>
<span>${escapeHTML(login)}</span>
<button class="btn btn-sm" data-passwd="${escapeHTML(login)}">Сменить пароль</button>
<button class="btn btn-sm btn-danger" data-deluser="${escapeHTML(login)}">Удалить</button>
</li>`
)
.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);
});
+237
View File
@@ -0,0 +1,237 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PZ Manager</title>
<link rel="stylesheet" href="/style.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text y='26' font-size='26'>🧟</text></svg>">
</head>
<body>
<!-- Экран входа: он же первичная настройка, когда учёток ещё нет. -->
<div id="auth-screen" class="auth-screen hidden">
<form id="auth-form" class="auth-card">
<h1 class="auth-title">🧟 PZ Manager</h1>
<p id="auth-subtitle" class="auth-subtitle">Управление сервером Project Zomboid</p>
<label class="field" id="token-field" hidden>
<span>Код первичной настройки</span>
<input type="text" name="token" autocomplete="off" spellcheck="false">
<small>Код напечатан в журнале менеджера при запуске: <code>journalctl -u pzmanager</code></small>
</label>
<label class="field">
<span>Логин</span>
<input type="text" name="login" autocomplete="username" required>
</label>
<label class="field">
<span>Пароль</span>
<input type="password" name="password" autocomplete="current-password" required>
</label>
<p id="auth-error" class="error" hidden></p>
<button type="submit" class="btn btn-primary btn-block" id="auth-submit">Войти</button>
</form>
</div>
<!-- Основное приложение -->
<div id="app" class="app hidden">
<header class="topbar">
<div class="brand">🧟 <span>PZ Manager</span></div>
<div class="status-pill" id="status-pill">
<span class="dot" id="status-dot"></span>
<span id="status-text"></span>
</div>
<div class="controls">
<button class="btn btn-start" id="btn-start">Запустить</button>
<button class="btn btn-stop" id="btn-stop">Остановить</button>
<button class="btn" id="btn-restart">Перезапустить</button>
</div>
<div class="spacer"></div>
<span class="user" id="current-user"></span>
<button class="btn btn-ghost" id="btn-logout">Выйти</button>
</header>
<div id="task-banner" class="task-banner" hidden>
<span class="spinner"></span>
<span id="task-name"></span>
<button class="btn btn-ghost btn-sm" id="btn-task-cancel">Отменить</button>
</div>
<nav class="tabs" id="tabs">
<button class="tab active" data-tab="overview">Обзор</button>
<button class="tab" data-tab="console">Консоль</button>
<button class="tab" data-tab="config">Конфиг сервера</button>
<button class="tab" data-tab="sandbox">Песочница</button>
<button class="tab" data-tab="mods">Моды</button>
<button class="tab" data-tab="backups">Бэкапы</button>
<button class="tab" data-tab="settings">Настройки</button>
</nav>
<main class="content">
<!-- Обзор -->
<section class="panel active" data-panel="overview">
<div class="cards">
<div class="card">
<h3>Состояние</h3>
<div class="stat" id="ov-state"></div>
<div class="muted" id="ov-uptime"></div>
<div class="muted" id="ov-pid"></div>
<div class="muted error" id="ov-lastexit"></div>
</div>
<div class="card">
<h3>Игроки онлайн</h3>
<div class="stat" id="ov-players-count">0</div>
<ul class="player-list" id="ov-players"></ul>
</div>
<div class="card">
<h3>Процесс сервера</h3>
<div class="metric"><span>CPU</span><b id="ov-cpu"></b></div>
<div class="metric"><span>Память</span><b id="ov-rss"></b></div>
<div class="metric"><span>Потоки</span><b id="ov-threads"></b></div>
</div>
<div class="card">
<h3>Хост</h3>
<div class="metric"><span>ОЗУ свободно</span><b id="ov-mem"></b></div>
<div class="metric"><span>Load average</span><b id="ov-load"></b></div>
<div class="metric"><span>Диск свободно</span><b id="ov-disk"></b></div>
</div>
</div>
<div class="card" id="install-card">
<h3>Серверные файлы</h3>
<p id="install-state" class="muted"></p>
<button class="btn btn-primary" id="btn-install">Установить / обновить через SteamCMD</button>
<p class="muted small">Сервер должен быть остановлен. Обновление занимает несколько минут — прогресс виден во вкладке «Консоль».</p>
</div>
</section>
<!-- Консоль -->
<section class="panel" data-panel="console">
<div class="console-toolbar">
<label class="checkbox"><input type="checkbox" id="console-autoscroll" checked> Прокручивать за выводом</label>
<input type="search" id="console-filter" placeholder="Фильтр по строке…" class="input-sm">
<button class="btn btn-ghost btn-sm" id="btn-console-clear">Очистить экран</button>
</div>
<pre id="console-output" class="console"></pre>
<form class="console-input" id="console-form">
<input type="text" id="console-command" placeholder="Команда сервера, например: players, save, quit"
autocomplete="off" spellcheck="false">
<button class="btn btn-primary" type="submit">Отправить</button>
</form>
<p class="muted small">Полезные команды: <code>players</code>, <code>save</code>, <code>servermsg "текст"</code>,
<code>kickuser "ник"</code>, <code>banuser "ник"</code>, <code>checkModsNeedUpdate</code>.</p>
</section>
<!-- Конфиг сервера -->
<section class="panel" data-panel="config">
<div class="panel-head">
<div>
<h2>Конфигурация сервера</h2>
<p class="muted small" id="config-path"></p>
</div>
<div>
<input type="search" id="config-filter" placeholder="Поиск параметра…" class="input-sm">
<button class="btn btn-primary" id="btn-config-save">Сохранить</button>
</div>
</div>
<p class="muted" id="config-empty" hidden></p>
<div id="config-fields" class="fields"></div>
</section>
<!-- Песочница -->
<section class="panel" data-panel="sandbox">
<div class="panel-head">
<div>
<h2>Настройки песочницы</h2>
<p class="muted small" id="sandbox-path"></p>
</div>
<div>
<input type="search" id="sandbox-filter" placeholder="Поиск параметра…" class="input-sm">
<button class="btn btn-primary" id="btn-sandbox-save">Сохранить</button>
</div>
</div>
<p class="muted" id="sandbox-empty" hidden></p>
<div id="sandbox-fields" class="fields"></div>
</section>
<!-- Моды -->
<section class="panel" data-panel="mods">
<div class="panel-head">
<div>
<h2>Моды</h2>
<p class="muted small">Сервер сам скачивает моды из мастерской при запуске.</p>
</div>
<button class="btn btn-primary" id="btn-mods-save">Сохранить</button>
</div>
<p class="muted" id="mods-empty" hidden></p>
<div id="mods-body">
<form class="mod-add" id="mod-add-form">
<input type="text" id="mod-workshop-id" placeholder="Workshop ID, например 2392709985" inputmode="numeric">
<input type="text" id="mod-mod-id" placeholder="Mod ID (из описания мода)">
<button class="btn" type="submit">Добавить</button>
</form>
<p class="muted small">Workshop ID — число из ссылки на мод. Mod ID автор указывает в описании; у одного мода
мастерской их может быть несколько.</p>
<h3>Моды из мастерской</h3>
<ul class="mod-list" id="workshop-list"></ul>
<h3>Порядок загрузки (Mods)</h3>
<ul class="mod-list" id="modid-list"></ul>
<h3>Карты (Map)</h3>
<textarea id="map-list" rows="3" spellcheck="false"
placeholder="Например: Muldraugh, KY"></textarea>
<p class="muted small">Каждая карта с новой строки. Первой должна идти карта мода, последней — базовая.</p>
</div>
</section>
<!-- Бэкапы -->
<section class="panel" data-panel="backups">
<div class="panel-head">
<div>
<h2>Резервные копии</h2>
<p class="muted small" id="backup-dir"></p>
</div>
<form id="backup-form">
<input type="text" id="backup-note" placeholder="Пометка (необязательно)" class="input-sm">
<button class="btn btn-primary" type="submit">Создать бэкап</button>
</form>
</div>
<table class="table">
<thead><tr><th>Архив</th><th>Размер</th><th>Создан</th><th></th></tr></thead>
<tbody id="backup-rows"></tbody>
</table>
</section>
<!-- Настройки -->
<section class="panel" data-panel="settings">
<h2>Настройки менеджера</h2>
<form id="settings-form" class="fields"></form>
<button class="btn btn-primary" id="btn-settings-save">Сохранить настройки</button>
<h2>Пользователи панели</h2>
<ul class="user-list" id="user-list"></ul>
<form id="user-add-form" class="mod-add">
<input type="text" id="new-user-login" placeholder="Логин" autocomplete="off">
<input type="password" id="new-user-password" placeholder="Пароль (минимум 8 символов)" autocomplete="new-password">
<button class="btn" type="submit">Добавить пользователя</button>
</form>
</section>
</main>
</div>
<div id="toasts" class="toasts"></div>
<script src="/app.js"></script>
</body>
</html>
+384
View File
@@ -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; }
}
+15
View File
@@ -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")
}