Веб-панель управления сервером Project Zomboid

Менеджер запускает сервер PZ как дочерний процесс и держит его stdin/stdout:
отсюда живая консоль без RCON, стриминг логов через SSE и корректная
остановка по цепочке quit -> SIGTERM -> SIGKILL для всей группы процессов.
Сам менеджер работает под systemd.

Что входит:
- управление жизненным циклом сервера, автозапуск и автоподъём после падения;
- метрики процесса из /proc и состояние хоста, список игроков онлайн;
- редакторы servertest.ini и SandboxVars.lua, сохраняющие исходное
  форматирование файлов;
- моды: списки Workshop ID и Mod ID с порядком загрузки, названия из Steam;
- бэкапы мира по расписанию и вручную, с ротацией и восстановлением;
- установка и обновление сервера через SteamCMD;
- вход по логину и паролю, первичная настройка по одноразовому коду.

Веб-интерфейс без сборщика, вшит в бинарник через embed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 22:41:11 +03:00
co-authored by Claude Opus 5
commit d0ff64c272
38 changed files with 6054 additions and 0 deletions
+212
View File
@@ -0,0 +1,212 @@
package api
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"time"
"github.com/mrleo1nid/pzmanager/internal/job"
"github.com/mrleo1nid/pzmanager/internal/pzconfig"
"github.com/mrleo1nid/pzmanager/internal/pzserver"
)
func (s *Server) handleTaskStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.jobs.Status())
}
func (s *Server) handleTaskCancel(w http.ResponseWriter, r *http.Request) {
if !s.jobs.Busy() {
writeError(w, http.StatusConflict, "нет активной задачи")
return
}
s.jobs.Cancel()
writeJSON(w, http.StatusOK, map[string]any{"cancelled": true})
}
// handleInstall ставит или обновляет серверные файлы через SteamCMD. Обновлять
// файлы под работающим сервером нельзя: SteamCMD перезапишет то, что открыто
// JVM, и сервер упадёт непредсказуемо.
func (s *Server) handleInstall(w http.ResponseWriter, r *http.Request) {
if s.sup.State() != pzserver.StateStopped {
writeError(w, http.StatusConflict, "сначала остановите игровой сервер")
return
}
name := "Установка/обновление сервера через SteamCMD"
err := s.jobs.Start(name, func(ctx context.Context, log func(string)) error {
if err := s.steam.InstallOrUpdate(ctx, log, s.cfg.ServerDir); err != nil {
return err
}
if err := s.applyJavaMemory(log); err != nil {
log("Не удалось выставить объём памяти JVM: " + err.Error())
}
return nil
})
if err != nil {
writeOpError(w, err)
return
}
writeJSON(w, http.StatusAccepted, map[string]any{"task": name})
}
func (s *Server) handleListBackups(w http.ResponseWriter, r *http.Request) {
archives, err := s.backups.List()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"backups": archives, "dir": s.backups.Dir()})
}
func (s *Server) handleCreateBackup(w http.ResponseWriter, r *http.Request) {
var body struct {
Note string `json:"note"`
}
if r.ContentLength > 0 && !decodeJSON(w, r, &body) {
return
}
name := "Резервное копирование мира"
err := s.jobs.Start(name, func(ctx context.Context, log func(string)) error {
// Опциональная остановка сервера даёт согласованный снимок: PZ
// сбрасывает мир на диск только при сохранении.
restart := false
if s.cfg.Backup.StopServer && s.sup.State() != pzserver.StateStopped {
log("Останавливаю сервер перед бэкапом")
if err := s.sup.Stop(); err != nil && !errors.Is(err, pzserver.ErrNotRunning) {
return err
}
restart = true
}
_, err := s.backups.Create(ctx, log, body.Note)
if restart {
log("Поднимаю сервер обратно")
if startErr := s.sup.Start(); startErr != nil {
log("Не удалось запустить сервер: " + startErr.Error())
}
}
return err
})
if err != nil {
writeOpError(w, err)
return
}
writeJSON(w, http.StatusAccepted, map[string]any{"task": name})
}
func (s *Server) handleDeleteBackup(w http.ResponseWriter, r *http.Request) {
if err := s.backups.Delete(r.PathValue("name")); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"deleted": r.PathValue("name")})
}
func (s *Server) handleDownloadBackup(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
path, err := s.backups.Path(name)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
f, err := os.Open(path)
if err != nil {
writeError(w, http.StatusNotFound, "архив не найден")
return
}
defer f.Close()
info, err := f.Stat()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
w.Header().Set("Content-Type", "application/gzip")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name))
http.ServeContent(w, r, name, info.ModTime(), f)
}
// handleRestoreBackup разворачивает архив поверх текущего мира. Сервер должен
// быть остановлен — иначе он держит файлы мира открытыми и перезапишет их
// своим состоянием при следующем сохранении.
func (s *Server) handleRestoreBackup(w http.ResponseWriter, r *http.Request) {
if s.sup.State() != pzserver.StateStopped {
writeError(w, http.StatusConflict, "сначала остановите игровой сервер")
return
}
name := r.PathValue("name")
if _, err := s.backups.Path(name); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
taskName := "Восстановление из архива " + name
err := s.jobs.Start(taskName, func(ctx context.Context, log func(string)) error {
// Перед перезаписью снимаем страховочную копию текущего состояния:
// восстановление не туда — самая дорогая ошибка в этой панели.
log("Делаю страховочный бэкап текущего мира")
if _, err := s.backups.Create(ctx, log, "pre-restore"); err != nil {
return fmt.Errorf("страховочный бэкап не создан, восстановление отменено: %w", err)
}
return s.backups.Restore(ctx, log, name)
})
if err != nil {
writeOpError(w, err)
return
}
writeJSON(w, http.StatusAccepted, map[string]any{"task": taskName})
}
// StartBackupScheduler запускает периодические бэкапы согласно конфигу.
// Интервал перечитывается на каждом тике, поэтому смена расписания в панели
// действует без перезапуска менеджера.
func StartBackupScheduler(ctx context.Context, s *Server) {
go func() {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
last := time.Now()
for {
select {
case <-ctx.Done():
return
case now := <-ticker.C:
interval := s.cfg.Backup.Schedule.Duration()
if interval <= 0 || now.Sub(last) < interval {
continue
}
last = now
if s.jobs.Busy() {
s.sup.AppendLog("manager", "Плановый бэкап пропущен: выполняется другая задача")
continue
}
err := s.jobs.Start("Плановое резервное копирование",
func(ctx context.Context, log func(string)) error {
_, err := s.backups.Create(ctx, log, "auto")
return err
})
if err != nil && !errors.Is(err, job.ErrBusy) {
s.sup.AppendLog("manager", "Плановый бэкап не запущен: "+err.Error())
}
}
}
}()
}
// applyJavaMemory переносит лимит памяти из конфига менеджера в файл запуска
// сервера. Вызывается после установки: SteamCMD перезаписывает этот файл.
func (s *Server) applyJavaMemory(log func(string)) error {
if s.cfg.JavaMemory == "" {
return nil
}
if err := pzconfig.SetJavaMemory(s.cfg.ServerDir, s.cfg.JavaMemory); err != nil {
return err
}
log("Лимит памяти JVM выставлен: " + s.cfg.JavaMemory)
return nil
}