Веб-панель управления сервером 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
+130
View File
@@ -0,0 +1,130 @@
package api
import (
"crypto/subtle"
"errors"
"net/http"
"time"
"github.com/mrleo1nid/pzmanager/internal/auth"
)
// sessionTTL — сколько живёт сессия без активности.
const sessionTTL = 12 * time.Hour
type credentials struct {
Login string `json:"login"`
Password string `json:"password"`
// Token нужен только при первичной настройке.
Token string `json:"token,omitempty"`
}
// handleSession сообщает фронтенду, вошёл ли пользователь и нужна ли
// первоначальная настройка (когда учёток ещё нет).
func (s *Server) handleSession(w http.ResponseWriter, r *http.Request) {
login, ok := s.users.SessionFromRequest(r)
writeJSON(w, http.StatusOK, map[string]any{
"authenticated": ok,
"login": login,
"setup_needed": s.users.IsEmpty(),
})
}
// handleSetup создаёт первого администратора. Доступен, только пока учёток нет.
func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
if !s.users.IsEmpty() {
writeError(w, http.StatusConflict, "панель уже настроена")
return
}
var creds credentials
if !decodeJSON(w, r, &creds) {
return
}
// Токен печатается в журнал менеджера при старте: без него первым
// администратором стал бы любой, кто успел открыть страницу.
if s.setupToken == "" || subtle.ConstantTimeCompare([]byte(creds.Token), []byte(s.setupToken)) != 1 {
writeError(w, http.StatusForbidden, "неверный код первичной настройки")
return
}
if err := s.users.AddUser(creds.Login, creds.Password); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
id, err := s.users.Login(creds.Login, creds.Password)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.setSessionCookie(w, id, sessionTTL)
writeJSON(w, http.StatusOK, map[string]any{"authenticated": true, "login": creds.Login})
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
var creds credentials
if !decodeJSON(w, r, &creds) {
return
}
id, err := s.users.Login(creds.Login, creds.Password)
if err != nil {
if errors.Is(err, auth.ErrBadCredentials) {
writeError(w, http.StatusUnauthorized, err.Error())
return
}
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.setSessionCookie(w, id, sessionTTL)
writeJSON(w, http.StatusOK, map[string]any{"authenticated": true, "login": creds.Login})
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(auth.SessionCookie); err == nil {
s.users.Logout(c.Value)
}
s.clearSessionCookie(w)
writeJSON(w, http.StatusOK, map[string]any{"authenticated": false})
}
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"users": s.users.Logins()})
}
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
var creds credentials
if !decodeJSON(w, r, &creds) {
return
}
if err := s.users.AddUser(creds.Login, creds.Password); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, map[string]any{"login": creds.Login})
}
func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
login := r.PathValue("login")
current, _ := s.users.SessionFromRequest(r)
if login == current {
writeError(w, http.StatusBadRequest, "нельзя удалить учётную запись, под которой вы вошли")
return
}
if err := s.users.DeleteUser(login); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"deleted": login})
}
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
var body struct {
Password string `json:"password"`
}
if !decodeJSON(w, r, &body) {
return
}
if err := s.users.SetPassword(r.PathValue("login"), body.Password); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
+208
View File
@@ -0,0 +1,208 @@
package api
import (
"net/http"
"os"
"strconv"
"time"
"github.com/mrleo1nid/pzmanager/internal/config"
"github.com/mrleo1nid/pzmanager/internal/pzconfig"
)
// handleGetServerConfig отдаёт содержимое servertest.ini. Файл появляется
// только после первого запуска сервера, поэтому его отсутствие — не ошибка.
func (s *Server) handleGetServerConfig(w http.ResponseWriter, r *http.Request) {
path := s.cfg.ServerConfigPath()
ini, err := pzconfig.LoadINI(path)
if os.IsNotExist(err) {
writeJSON(w, http.StatusOK, map[string]any{
"exists": false,
"path": path,
"hint": "Файл создаётся при первом запуске сервера",
})
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"exists": true,
"path": path,
"keys": ini.Keys(),
"values": ini.Map(),
})
}
// handlePutServerConfig правит только присланные ключи, остальные строки файла
// (включая комментарии) остаются нетронутыми.
func (s *Server) handlePutServerConfig(w http.ResponseWriter, r *http.Request) {
var body struct {
Values map[string]string `json:"values"`
}
if !decodeJSON(w, r, &body) {
return
}
path := s.cfg.ServerConfigPath()
ini, err := pzconfig.LoadINI(path)
if err != nil {
writeError(w, http.StatusBadRequest, "конфиг сервера недоступен: "+err.Error())
return
}
for key, value := range body.Values {
ini.Set(key, value)
}
if err := pzconfig.SaveINI(path, ini); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.sup.AppendLog("manager", "Конфиг сервера сохранён (изменений: "+strconv.Itoa(len(body.Values))+"). Изменения вступят в силу после перезапуска.")
writeJSON(w, http.StatusOK, map[string]any{"saved": len(body.Values)})
}
func (s *Server) handleGetSandbox(w http.ResponseWriter, r *http.Request) {
path := s.cfg.SandboxConfigPath()
sb, err := pzconfig.LoadSandbox(path)
if os.IsNotExist(err) {
writeJSON(w, http.StatusOK, map[string]any{
"exists": false,
"path": path,
"hint": "Файл создаётся при первом запуске сервера",
})
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"exists": true,
"path": path,
"entries": sb.Entries(),
})
}
func (s *Server) handlePutSandbox(w http.ResponseWriter, r *http.Request) {
var body struct {
Values map[string]string `json:"values"`
}
if !decodeJSON(w, r, &body) {
return
}
path := s.cfg.SandboxConfigPath()
sb, err := pzconfig.LoadSandbox(path)
if err != nil {
writeError(w, http.StatusBadRequest, "настройки песочницы недоступны: "+err.Error())
return
}
for key, value := range body.Values {
if err := sb.Set(key, value); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
}
if err := pzconfig.SaveSandbox(path, sb); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.sup.AppendLog("manager", "Настройки песочницы сохранены. Часть параметров применяется только к новому миру.")
writeJSON(w, http.StatusOK, map[string]any{"saved": len(body.Values)})
}
// managerConfigDTO — вид конфига менеджера, удобный для формы в браузере:
// длительности в секундах, пароль наружу не отдаётся.
type managerConfigDTO struct {
Listen string `json:"listen"`
ServerDir string `json:"server_dir"`
ZomboidDir string `json:"zomboid_dir"`
SteamCMDPath string `json:"steamcmd_path"`
ServerName string `json:"server_name"`
JavaMemory string `json:"java_memory"`
ExtraArgs []string `json:"extra_args"`
AutoStart bool `json:"autostart"`
AutoRestart bool `json:"autorestart"`
StopTimeoutSec int64 `json:"stop_timeout_sec"`
LogBufferLines int `json:"log_buffer_lines"`
BackupDir string `json:"backup_dir"`
BackupScheduleMin int64 `json:"backup_schedule_min"`
BackupKeep int `json:"backup_keep"`
BackupStopServer bool `json:"backup_stop_server"`
HasAdminPassword bool `json:"has_admin_password"`
AdminPassword string `json:"admin_password,omitempty"`
ConfigPath string `json:"config_path"`
RequiresRestartMsg string `json:"requires_restart_msg,omitempty"`
}
func (s *Server) handleGetManagerConfig(w http.ResponseWriter, r *http.Request) {
c := s.cfg
writeJSON(w, http.StatusOK, managerConfigDTO{
Listen: c.Listen,
ServerDir: c.ServerDir,
ZomboidDir: c.ZomboidDir,
SteamCMDPath: c.SteamCMDPath,
ServerName: c.ServerName,
JavaMemory: c.JavaMemory,
ExtraArgs: c.ExtraArgs,
AutoStart: c.AutoStart,
AutoRestart: c.AutoRestart,
StopTimeoutSec: c.StopTimeout.Seconds(),
LogBufferLines: c.LogBufferLines,
BackupDir: c.Backup.Dir,
BackupScheduleMin: int64(c.Backup.Schedule.Duration().Minutes()),
BackupKeep: c.Backup.Keep,
BackupStopServer: c.Backup.StopServer,
HasAdminPassword: c.AdminPassword != "",
ConfigPath: c.Path(),
})
}
// handlePutManagerConfig сохраняет настройки менеджера. Пустой admin_password
// означает "оставить прежний", иначе его нельзя было бы не потерять при
// каждом сохранении формы.
func (s *Server) handlePutManagerConfig(w http.ResponseWriter, r *http.Request) {
var dto managerConfigDTO
if !decodeJSON(w, r, &dto) {
return
}
c := s.cfg
c.Listen = dto.Listen
c.ServerDir = dto.ServerDir
c.ZomboidDir = dto.ZomboidDir
c.SteamCMDPath = dto.SteamCMDPath
c.ServerName = dto.ServerName
c.JavaMemory = dto.JavaMemory
// Пустой список аргументов от формы означает "не трогать": ExtraArgs
// правятся только через файл конфигурации.
if dto.ExtraArgs != nil {
c.ExtraArgs = dto.ExtraArgs
}
c.AutoStart = dto.AutoStart
c.AutoRestart = dto.AutoRestart
c.LogBufferLines = dto.LogBufferLines
c.Backup.Dir = dto.BackupDir
c.Backup.Keep = dto.BackupKeep
c.Backup.StopServer = dto.BackupStopServer
if dto.StopTimeoutSec > 0 {
c.StopTimeout = config.Duration(time.Duration(dto.StopTimeoutSec) * time.Second)
}
c.Backup.Schedule = config.Duration(time.Duration(dto.BackupScheduleMin) * time.Minute)
if dto.AdminPassword != "" {
c.AdminPassword = dto.AdminPassword
}
if err := c.Save(); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if err := c.EnsureDirs(); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.sup.AppendLog("manager", "Настройки менеджера сохранены")
writeJSON(w, http.StatusOK, map[string]any{
"saved": true,
"note": "Адрес прослушивания применится после перезапуска pzmanager",
})
}
+84
View File
@@ -0,0 +1,84 @@
package api
import (
"net/http"
"os"
"github.com/mrleo1nid/pzmanager/internal/pzconfig"
)
// handleGetMods отдаёт списки модов из servertest.ini. Названия модов не
// запрашиваются здесь: поход в Steam медленный, для него есть отдельный
// эндпоинт lookup.
func (s *Server) handleGetMods(w http.ResponseWriter, r *http.Request) {
path := s.cfg.ServerConfigPath()
ini, err := pzconfig.LoadINI(path)
if os.IsNotExist(err) {
writeJSON(w, http.StatusOK, map[string]any{
"exists": false,
"path": path,
"hint": "Файл конфигурации появится после первого запуска сервера",
})
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
mods, workshop := ini.Mods()
writeJSON(w, http.StatusOK, map[string]any{
"exists": true,
"path": path,
"mods": mods,
"workshop_items": workshop,
"map": pzconfig.SplitList(ini.Get(pzconfig.KeyMap)),
})
}
func (s *Server) handlePutMods(w http.ResponseWriter, r *http.Request) {
var body struct {
Mods []string `json:"mods"`
WorkshopItems []string `json:"workshop_items"`
Map []string `json:"map"`
}
if !decodeJSON(w, r, &body) {
return
}
path := s.cfg.ServerConfigPath()
ini, err := pzconfig.LoadINI(path)
if err != nil {
writeError(w, http.StatusBadRequest, "конфиг сервера недоступен: "+err.Error())
return
}
ini.SetMods(body.Mods, body.WorkshopItems)
if len(body.Map) > 0 {
ini.Set(pzconfig.KeyMap, pzconfig.JoinList(body.Map))
}
if err := pzconfig.SaveINI(path, ini); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.sup.AppendLog("manager", "Список модов обновлён. Сервер докачает моды из мастерской при следующем запуске.")
writeJSON(w, http.StatusOK, map[string]any{"saved": true})
}
// handleLookupMods спрашивает у Steam названия модов по Workshop ID.
func (s *Server) handleLookupMods(w http.ResponseWriter, r *http.Request) {
var body struct {
IDs []string `json:"ids"`
}
if !decodeJSON(w, r, &body) {
return
}
if len(body.IDs) == 0 {
writeJSON(w, http.StatusOK, map[string]any{"items": []pzconfig.WorkshopItem{}})
return
}
if len(body.IDs) > 200 {
writeError(w, http.StatusBadRequest, "слишком много идентификаторов за один запрос")
return
}
items := s.workshop.Details(r.Context(), body.IDs)
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
+138
View File
@@ -0,0 +1,138 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"time"
"github.com/mrleo1nid/pzmanager/internal/pzserver"
)
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"server": s.sup.Status(),
"task": s.jobs.Status(),
})
}
func (s *Server) handleStart(w http.ResponseWriter, r *http.Request) {
if err := s.sup.Start(); err != nil {
writeOpError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"state": s.sup.State()})
}
// handleStop останавливает сервер в фоне: корректное завершение занимает до
// нескольких минут, и держать HTTP-запрос всё это время незачем — панель
// видит прогресс по состоянию и логам.
func (s *Server) handleStop(w http.ResponseWriter, r *http.Request) {
if s.sup.State() == pzserver.StateStopped {
writeOpError(w, pzserver.ErrNotRunning)
return
}
go func() {
if err := s.sup.Stop(); err != nil && !errors.Is(err, pzserver.ErrNotRunning) {
s.sup.AppendLog("manager", "Остановка не удалась: "+err.Error())
}
}()
writeJSON(w, http.StatusAccepted, map[string]any{"state": pzserver.StateStopping})
}
func (s *Server) handleRestart(w http.ResponseWriter, r *http.Request) {
if !s.sup.Installed() {
writeOpError(w, pzserver.ErrNotInstalled)
return
}
go func() {
if err := s.sup.Restart(); err != nil {
s.sup.AppendLog("manager", "Перезапуск не удался: "+err.Error())
}
}()
writeJSON(w, http.StatusAccepted, map[string]any{"state": s.sup.State()})
}
func (s *Server) handleConsole(w http.ResponseWriter, r *http.Request) {
var body struct {
Command string `json:"command"`
}
if !decodeJSON(w, r, &body) {
return
}
if err := s.sup.Send(body.Command); err != nil {
writeOpError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"sent": body.Command})
}
// handleLogs отдаёт историю консоли начиная с указанного номера строки.
func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) {
since, _ := strconv.ParseInt(r.URL.Query().Get("since"), 10, 64)
writeJSON(w, http.StatusOK, map[string]any{"lines": s.sup.Lines(since)})
}
// handleLogStream — живая консоль через Server-Sent Events. SSE выбран вместо
// вебсокетов: поток односторонний, а переподключение браузер берёт на себя.
func (s *Server) handleLogStream(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
writeError(w, http.StatusInternalServerError, "потоковая отдача не поддерживается")
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
// Отключаем буферизацию в nginx, иначе строки придут пачкой.
w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
lines, unsubscribe := s.sup.Subscribe()
defer unsubscribe()
// Сначала отдаём хвост истории, чтобы вкладка не открывалась пустой.
since, _ := strconv.ParseInt(r.URL.Query().Get("since"), 10, 64)
for _, line := range s.sup.Lines(since) {
if !writeSSE(w, line) {
return
}
}
flusher.Flush()
// Пинг не даёт прокси закрыть простаивающее соединение.
ping := time.NewTicker(25 * time.Second)
defer ping.Stop()
for {
select {
case <-r.Context().Done():
return
case line, ok := <-lines:
if !ok {
return
}
if !writeSSE(w, line) {
return
}
flusher.Flush()
case <-ping.C:
if _, err := fmt.Fprint(w, ": ping\n\n"); err != nil {
return
}
flusher.Flush()
}
}
}
func writeSSE(w http.ResponseWriter, line pzserver.LogLine) bool {
payload, err := json.Marshal(line)
if err != nil {
return true
}
_, err = fmt.Fprintf(w, "data: %s\n\n", payload)
return err == nil
}
+212
View File
@@ -0,0 +1,212 @@
package api
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"time"
"github.com/mrleo1nid/pzmanager/internal/job"
"github.com/mrleo1nid/pzmanager/internal/pzconfig"
"github.com/mrleo1nid/pzmanager/internal/pzserver"
)
func (s *Server) handleTaskStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.jobs.Status())
}
func (s *Server) handleTaskCancel(w http.ResponseWriter, r *http.Request) {
if !s.jobs.Busy() {
writeError(w, http.StatusConflict, "нет активной задачи")
return
}
s.jobs.Cancel()
writeJSON(w, http.StatusOK, map[string]any{"cancelled": true})
}
// handleInstall ставит или обновляет серверные файлы через SteamCMD. Обновлять
// файлы под работающим сервером нельзя: SteamCMD перезапишет то, что открыто
// JVM, и сервер упадёт непредсказуемо.
func (s *Server) handleInstall(w http.ResponseWriter, r *http.Request) {
if s.sup.State() != pzserver.StateStopped {
writeError(w, http.StatusConflict, "сначала остановите игровой сервер")
return
}
name := "Установка/обновление сервера через SteamCMD"
err := s.jobs.Start(name, func(ctx context.Context, log func(string)) error {
if err := s.steam.InstallOrUpdate(ctx, log, s.cfg.ServerDir); err != nil {
return err
}
if err := s.applyJavaMemory(log); err != nil {
log("Не удалось выставить объём памяти JVM: " + err.Error())
}
return nil
})
if err != nil {
writeOpError(w, err)
return
}
writeJSON(w, http.StatusAccepted, map[string]any{"task": name})
}
func (s *Server) handleListBackups(w http.ResponseWriter, r *http.Request) {
archives, err := s.backups.List()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"backups": archives, "dir": s.backups.Dir()})
}
func (s *Server) handleCreateBackup(w http.ResponseWriter, r *http.Request) {
var body struct {
Note string `json:"note"`
}
if r.ContentLength > 0 && !decodeJSON(w, r, &body) {
return
}
name := "Резервное копирование мира"
err := s.jobs.Start(name, func(ctx context.Context, log func(string)) error {
// Опциональная остановка сервера даёт согласованный снимок: PZ
// сбрасывает мир на диск только при сохранении.
restart := false
if s.cfg.Backup.StopServer && s.sup.State() != pzserver.StateStopped {
log("Останавливаю сервер перед бэкапом")
if err := s.sup.Stop(); err != nil && !errors.Is(err, pzserver.ErrNotRunning) {
return err
}
restart = true
}
_, err := s.backups.Create(ctx, log, body.Note)
if restart {
log("Поднимаю сервер обратно")
if startErr := s.sup.Start(); startErr != nil {
log("Не удалось запустить сервер: " + startErr.Error())
}
}
return err
})
if err != nil {
writeOpError(w, err)
return
}
writeJSON(w, http.StatusAccepted, map[string]any{"task": name})
}
func (s *Server) handleDeleteBackup(w http.ResponseWriter, r *http.Request) {
if err := s.backups.Delete(r.PathValue("name")); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"deleted": r.PathValue("name")})
}
func (s *Server) handleDownloadBackup(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
path, err := s.backups.Path(name)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
f, err := os.Open(path)
if err != nil {
writeError(w, http.StatusNotFound, "архив не найден")
return
}
defer f.Close()
info, err := f.Stat()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
w.Header().Set("Content-Type", "application/gzip")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name))
http.ServeContent(w, r, name, info.ModTime(), f)
}
// handleRestoreBackup разворачивает архив поверх текущего мира. Сервер должен
// быть остановлен — иначе он держит файлы мира открытыми и перезапишет их
// своим состоянием при следующем сохранении.
func (s *Server) handleRestoreBackup(w http.ResponseWriter, r *http.Request) {
if s.sup.State() != pzserver.StateStopped {
writeError(w, http.StatusConflict, "сначала остановите игровой сервер")
return
}
name := r.PathValue("name")
if _, err := s.backups.Path(name); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
taskName := "Восстановление из архива " + name
err := s.jobs.Start(taskName, func(ctx context.Context, log func(string)) error {
// Перед перезаписью снимаем страховочную копию текущего состояния:
// восстановление не туда — самая дорогая ошибка в этой панели.
log("Делаю страховочный бэкап текущего мира")
if _, err := s.backups.Create(ctx, log, "pre-restore"); err != nil {
return fmt.Errorf("страховочный бэкап не создан, восстановление отменено: %w", err)
}
return s.backups.Restore(ctx, log, name)
})
if err != nil {
writeOpError(w, err)
return
}
writeJSON(w, http.StatusAccepted, map[string]any{"task": taskName})
}
// StartBackupScheduler запускает периодические бэкапы согласно конфигу.
// Интервал перечитывается на каждом тике, поэтому смена расписания в панели
// действует без перезапуска менеджера.
func StartBackupScheduler(ctx context.Context, s *Server) {
go func() {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
last := time.Now()
for {
select {
case <-ctx.Done():
return
case now := <-ticker.C:
interval := s.cfg.Backup.Schedule.Duration()
if interval <= 0 || now.Sub(last) < interval {
continue
}
last = now
if s.jobs.Busy() {
s.sup.AppendLog("manager", "Плановый бэкап пропущен: выполняется другая задача")
continue
}
err := s.jobs.Start("Плановое резервное копирование",
func(ctx context.Context, log func(string)) error {
_, err := s.backups.Create(ctx, log, "auto")
return err
})
if err != nil && !errors.Is(err, job.ErrBusy) {
s.sup.AppendLog("manager", "Плановый бэкап не запущен: "+err.Error())
}
}
}
}()
}
// applyJavaMemory переносит лимит памяти из конфига менеджера в файл запуска
// сервера. Вызывается после установки: SteamCMD перезаписывает этот файл.
func (s *Server) applyJavaMemory(log func(string)) error {
if s.cfg.JavaMemory == "" {
return nil
}
if err := pzconfig.SetJavaMemory(s.cfg.ServerDir, s.cfg.JavaMemory); err != nil {
return err
}
log("Лимит памяти JVM выставлен: " + s.cfg.JavaMemory)
return nil
}
+233
View File
@@ -0,0 +1,233 @@
// Package api отдаёт REST-эндпоинты панели и её статические файлы.
package api
import (
"encoding/json"
"errors"
"io/fs"
"log"
"net/http"
"strings"
"time"
"github.com/mrleo1nid/pzmanager/internal/auth"
"github.com/mrleo1nid/pzmanager/internal/backup"
"github.com/mrleo1nid/pzmanager/internal/config"
"github.com/mrleo1nid/pzmanager/internal/job"
"github.com/mrleo1nid/pzmanager/internal/pzconfig"
"github.com/mrleo1nid/pzmanager/internal/pzserver"
"github.com/mrleo1nid/pzmanager/internal/steamcmd"
)
// csrfHeader — заголовок, который обязан прислать клиент при изменяющих
// запросах. Браузер не поставит его в кросс-сайтовом простом запросе, поэтому
// он служит защитой от CSRF без отдельных токенов.
const csrfHeader = "X-PZM-Request"
// Server связывает HTTP-слой со всеми подсистемами менеджера.
type Server struct {
cfg *config.Config
users *auth.Store
sup *pzserver.Supervisor
jobs *job.Manager
backups *backup.Manager
steam *steamcmd.Client
workshop *pzconfig.WorkshopClient
static fs.FS
// secureCookies включает флаг Secure у cookie сессии (панель за HTTPS).
secureCookies bool
// setupToken защищает первичное создание администратора: менеджер печатает
// его в журнал при старте, когда учёток ещё нет.
setupToken string
}
// Options — то, что нужно собрать серверу API.
type Options struct {
Config *config.Config
Users *auth.Store
Supervisor *pzserver.Supervisor
Jobs *job.Manager
Backups *backup.Manager
Steam *steamcmd.Client
Static fs.FS
SecureCookies bool
SetupToken string
}
// New собирает HTTP-обработчик панели.
func New(o Options) *Server {
return &Server{
cfg: o.Config,
users: o.Users,
sup: o.Supervisor,
jobs: o.Jobs,
backups: o.Backups,
steam: o.Steam,
workshop: pzconfig.NewWorkshopClient(),
static: o.Static,
secureCookies: o.SecureCookies,
setupToken: o.SetupToken,
}
}
// Handler возвращает роутер со всеми маршрутами панели.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
// Публичные эндпоинты: без них нельзя войти.
mux.HandleFunc("GET /api/session", s.handleSession)
mux.HandleFunc("POST /api/login", s.handleLogin)
mux.HandleFunc("POST /api/logout", s.handleLogout)
mux.HandleFunc("POST /api/setup", s.handleSetup)
// Всё остальное — только для вошедших.
protected := map[string]http.HandlerFunc{
"GET /api/status": s.handleStatus,
"POST /api/server/start": s.handleStart,
"POST /api/server/stop": s.handleStop,
"POST /api/server/restart": s.handleRestart,
"POST /api/console": s.handleConsole,
"GET /api/logs": s.handleLogs,
"GET /api/logs/stream": s.handleLogStream,
"GET /api/config/server": s.handleGetServerConfig,
"PUT /api/config/server": s.handlePutServerConfig,
"GET /api/config/sandbox": s.handleGetSandbox,
"PUT /api/config/sandbox": s.handlePutSandbox,
"GET /api/config/manager": s.handleGetManagerConfig,
"PUT /api/config/manager": s.handlePutManagerConfig,
"GET /api/mods": s.handleGetMods,
"PUT /api/mods": s.handlePutMods,
"POST /api/mods/lookup": s.handleLookupMods,
"GET /api/backups": s.handleListBackups,
"POST /api/backups": s.handleCreateBackup,
"DELETE /api/backups/{name}": s.handleDeleteBackup,
"GET /api/backups/{name}/download": s.handleDownloadBackup,
"POST /api/backups/{name}/restore": s.handleRestoreBackup,
"POST /api/install": s.handleInstall,
"GET /api/task": s.handleTaskStatus,
"POST /api/task/cancel": s.handleTaskCancel,
"GET /api/users": s.handleListUsers,
"POST /api/users": s.handleCreateUser,
"DELETE /api/users/{login}": s.handleDeleteUser,
"POST /api/users/{login}/password": s.handleChangePassword,
}
for pattern, handler := range protected {
mux.Handle(pattern, s.requireAuth(handler))
}
mux.Handle("GET /", s.staticHandler())
return s.withCommonHeaders(mux)
}
// staticHandler отдаёт SPA: неизвестные пути возвращают index.html, чтобы
// работали ссылки вида /mods при перезагрузке страницы.
func (s *Server) staticHandler() http.Handler {
files := http.FileServer(http.FS(s.static))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/")
if path == "" {
path = "index.html"
}
if _, err := fs.Stat(s.static, path); err != nil {
r = r.Clone(r.Context())
r.URL.Path = "/"
}
files.ServeHTTP(w, r)
})
}
// withCommonHeaders ставит заголовки безопасности и проверяет CSRF.
func (s *Server) withCommonHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "no-referrer")
// Панель полностью самодостаточна: внешние скрипты ей не нужны.
w.Header().Set("Content-Security-Policy",
"default-src 'self'; img-src 'self' data: https://steamuserimages-a.akamaihd.net "+
"https://images.steamusercontent.com https://community.cloudflare.steamstatic.com; "+
"style-src 'self'; script-src 'self'; connect-src 'self'")
if r.Method != http.MethodGet && r.Method != http.MethodHead &&
strings.HasPrefix(r.URL.Path, "/api/") && r.Header.Get(csrfHeader) == "" {
writeError(w, http.StatusForbidden, "запрос без заголовка "+csrfHeader)
return
}
next.ServeHTTP(w, r)
})
}
// requireAuth пропускает дальше только запросы с живой сессией.
func (s *Server) requireAuth(next http.HandlerFunc) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, ok := s.users.SessionFromRequest(r); !ok {
writeError(w, http.StatusUnauthorized, "требуется вход")
return
}
next(w, r)
})
}
// setSessionCookie кладёт cookie сессии.
func (s *Server) setSessionCookie(w http.ResponseWriter, id string, ttl time.Duration) {
http.SetCookie(w, &http.Cookie{
Name: auth.SessionCookie,
Value: id,
Path: "/",
HttpOnly: true,
Secure: s.secureCookies,
SameSite: http.SameSiteStrictMode,
MaxAge: int(ttl.Seconds()),
})
}
func (s *Server) clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: auth.SessionCookie,
Value: "",
Path: "/",
HttpOnly: true,
Secure: s.secureCookies,
SameSite: http.SameSiteStrictMode,
MaxAge: -1,
})
}
// writeJSON отправляет значение как JSON.
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
log.Printf("api: не удалось записать ответ: %v", err)
}
}
// writeError отправляет ошибку в том же формате, что и остальные ответы.
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
// writeOpError переводит ошибку операции в подходящий HTTP-код.
func writeOpError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, pzserver.ErrAlreadyRunning), errors.Is(err, pzserver.ErrNotRunning),
errors.Is(err, job.ErrBusy):
writeError(w, http.StatusConflict, err.Error())
case errors.Is(err, pzserver.ErrNotInstalled):
writeError(w, http.StatusPreconditionFailed, err.Error())
default:
writeError(w, http.StatusInternalServerError, err.Error())
}
}
// decodeJSON читает тело запроса, ограничивая его размер.
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
writeError(w, http.StatusBadRequest, "некорректный JSON: "+err.Error())
return false
}
return true
}