Веб-панель управления сервером 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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user