Веб-панель управления сервером 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:
+11
@@ -0,0 +1,11 @@
|
||||
# Собранные бинарники
|
||||
/pzmanager
|
||||
/pzmanager.exe
|
||||
|
||||
# Локальные данные разработки
|
||||
/dev/
|
||||
|
||||
# Редакторы
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
@@ -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)
|
||||
@@ -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`.
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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."
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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:")
|
||||
}
|
||||
@@ -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()}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user