Веб-панель управления сервером 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
}
+264
View File
@@ -0,0 +1,264 @@
// Package auth хранит учётные записи панели и живые сессии.
package auth
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
)
// SessionCookie — имя cookie с идентификатором сессии.
const SessionCookie = "pzm_session"
// Ошибки, которые вызывающему коду имеет смысл различать.
var (
ErrBadCredentials = errors.New("неверный логин или пароль")
ErrUserExists = errors.New("пользователь уже существует")
ErrUserNotFound = errors.New("пользователь не найден")
)
// User — учётная запись веб-панели.
type User struct {
Login string `json:"login"`
Hash string `json:"hash"`
CreatedAt time.Time `json:"created_at"`
}
type session struct {
login string
expiresAt time.Time
}
// Store — потокобезопасное хранилище пользователей (файл на диске) и
// сессий (только в памяти: перезапуск менеджера разлогинивает всех).
type Store struct {
path string
ttl time.Duration
mu sync.RWMutex
users map[string]User
sessions map[string]session
}
// NewStore открывает файл с пользователями, создавая пустой при первом запуске.
func NewStore(path string, ttl time.Duration) (*Store, error) {
s := &Store{
path: path,
ttl: ttl,
users: make(map[string]User),
sessions: make(map[string]session),
}
if err := s.load(); err != nil {
return nil, err
}
return s, nil
}
func (s *Store) load() error {
raw, err := os.ReadFile(s.path)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return fmt.Errorf("прочитать %s: %w", s.path, err)
}
var users []User
if err := json.Unmarshal(raw, &users); err != nil {
return fmt.Errorf("разобрать %s: %w", s.path, err)
}
for _, u := range users {
s.users[u.Login] = u
}
return nil
}
// saveLocked пишет пользователей на диск; вызывается под удержанным s.mu.
func (s *Store) saveLocked() error {
users := make([]User, 0, len(s.users))
for _, u := range s.users {
users = append(users, u)
}
raw, err := json.MarshalIndent(users, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
return err
}
tmp := s.path + ".tmp"
// 0600: файл содержит хеши паролей.
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
// IsEmpty сообщает, что учёток нет и панель нужно инициализировать.
func (s *Store) IsEmpty() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.users) == 0
}
// Logins возвращает отсортированный список логинов.
func (s *Store) Logins() []string {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]string, 0, len(s.users))
for login := range s.users {
out = append(out, login)
}
return out
}
// AddUser создаёт учётку. Пароль хешируется bcrypt'ом.
func (s *Store) AddUser(login, password string) error {
login = strings.TrimSpace(login)
if login == "" {
return errors.New("логин не может быть пустым")
}
if len(password) < 8 {
return errors.New("пароль короче 8 символов")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.users[login]; exists {
return ErrUserExists
}
s.users[login] = User{Login: login, Hash: string(hash), CreatedAt: time.Now()}
return s.saveLocked()
}
// SetPassword меняет пароль существующей учётки.
func (s *Store) SetPassword(login, password string) error {
if len(password) < 8 {
return errors.New("пароль короче 8 символов")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
u, ok := s.users[login]
if !ok {
return ErrUserNotFound
}
u.Hash = string(hash)
s.users[login] = u
return s.saveLocked()
}
// DeleteUser удаляет учётку и разлогинивает её сессии.
func (s *Store) DeleteUser(login string) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.users[login]; !ok {
return ErrUserNotFound
}
delete(s.users, login)
for id, sess := range s.sessions {
if sess.login == login {
delete(s.sessions, id)
}
}
return s.saveLocked()
}
// Login проверяет пару логин/пароль и заводит сессию, возвращая её id.
func (s *Store) Login(login, password string) (string, error) {
s.mu.RLock()
u, ok := s.users[login]
s.mu.RUnlock()
if !ok {
// Считаем фиктивный хеш, чтобы по времени ответа нельзя было
// отличить несуществующий логин от неверного пароля.
bcrypt.CompareHashAndPassword([]byte("$2a$10$"+strings.Repeat("x", 53)), []byte(password))
return "", ErrBadCredentials
}
if err := bcrypt.CompareHashAndPassword([]byte(u.Hash), []byte(password)); err != nil {
return "", ErrBadCredentials
}
id, err := randomID()
if err != nil {
return "", err
}
s.mu.Lock()
s.sessions[id] = session{login: login, expiresAt: time.Now().Add(s.ttl)}
s.mu.Unlock()
return id, nil
}
// Logout закрывает конкретную сессию.
func (s *Store) Logout(id string) {
s.mu.Lock()
delete(s.sessions, id)
s.mu.Unlock()
}
// Validate возвращает логин владельца сессии, продлевая её срок жизни.
func (s *Store) Validate(id string) (string, bool) {
if id == "" {
return "", false
}
s.mu.Lock()
defer s.mu.Unlock()
sess, ok := s.sessions[id]
if !ok {
return "", false
}
if time.Now().After(sess.expiresAt) {
delete(s.sessions, id)
return "", false
}
sess.expiresAt = time.Now().Add(s.ttl)
s.sessions[id] = sess
return sess.login, true
}
// GC выкидывает протухшие сессии; вызывается по таймеру.
func (s *Store) GC() {
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
for id, sess := range s.sessions {
if now.After(sess.expiresAt) {
delete(s.sessions, id)
}
}
}
// SessionFromRequest достаёт логин из cookie запроса.
func (s *Store) SessionFromRequest(r *http.Request) (string, bool) {
c, err := r.Cookie(SessionCookie)
if err != nil {
return "", false
}
return s.Validate(c.Value)
}
func randomID() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
+347
View File
@@ -0,0 +1,347 @@
// Package backup архивирует миры и конфиги Project Zomboid и восстанавливает
// их обратно.
package backup
import (
"archive/tar"
"compress/gzip"
"context"
"fmt"
"io"
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"github.com/mrleo1nid/pzmanager/internal/config"
)
// Archive — один файл бэкапа на диске.
type Archive struct {
Name string `json:"name"`
SizeBytes int64 `json:"size_bytes"`
CreatedAt int64 `json:"created_at"`
}
// nameRe ограничивает имена архивов, которые принимает API: имя приходит от
// клиента и используется как путь.
var nameRe = regexp.MustCompile(`^pz-[0-9]{8}-[0-9]{6}(-[a-zA-Z0-9_-]+)?\.tar\.gz$`)
// Manager владеет директорией с бэкапами.
type Manager struct {
cfg *config.Config
}
// New создаёт менеджер бэкапов.
func New(cfg *config.Config) *Manager { return &Manager{cfg: cfg} }
// Dir возвращает директорию с архивами.
func (m *Manager) Dir() string { return m.cfg.Backup.Dir }
// List возвращает архивы, свежие в начале списка.
func (m *Manager) List() ([]Archive, error) {
entries, err := os.ReadDir(m.cfg.Backup.Dir)
if os.IsNotExist(err) {
return []Archive{}, nil
}
if err != nil {
return nil, err
}
out := make([]Archive, 0, len(entries))
for _, e := range entries {
if e.IsDir() || !nameRe.MatchString(e.Name()) {
continue
}
info, err := e.Info()
if err != nil {
continue
}
out = append(out, Archive{
Name: e.Name(),
SizeBytes: info.Size(),
CreatedAt: info.ModTime().Unix(),
})
}
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt > out[j].CreatedAt })
return out, nil
}
// Path возвращает полный путь к архиву, проверив имя.
func (m *Manager) Path(name string) (string, error) {
if !nameRe.MatchString(name) {
return "", fmt.Errorf("недопустимое имя архива: %q", name)
}
return filepath.Join(m.cfg.Backup.Dir, name), nil
}
// Create собирает архив из Saves и серверных конфигов. Суффикс note попадает в
// имя файла, чтобы ручные бэкапы было легко отличить.
func (m *Manager) Create(ctx context.Context, log func(string), note string) (Archive, error) {
if err := os.MkdirAll(m.cfg.Backup.Dir, 0o755); err != nil {
return Archive{}, err
}
name := fmt.Sprintf("pz-%s", time.Now().Format("20060102-150405"))
if note = sanitizeNote(note); note != "" {
name += "-" + note
}
name += ".tar.gz"
target := filepath.Join(m.cfg.Backup.Dir, name)
log("Создаю архив " + name)
// Пишем во временный файл: недописанный архив не должен попасть в список.
tmp := target + ".part"
f, err := os.Create(tmp)
if err != nil {
return Archive{}, err
}
gz := gzip.NewWriter(f)
tw := tar.NewWriter(gz)
var written int64
sources := []string{m.cfg.SavesDir(), filepath.Join(m.cfg.ZomboidDir, "Server")}
for _, src := range sources {
if _, err := os.Stat(src); err != nil {
log("Пропускаю " + src + " — нет такой директории")
continue
}
n, err := addTree(ctx, tw, m.cfg.ZomboidDir, src, log)
written += n
if err != nil {
tw.Close()
gz.Close()
f.Close()
os.Remove(tmp)
return Archive{}, err
}
}
if err := tw.Close(); err != nil {
gz.Close()
f.Close()
os.Remove(tmp)
return Archive{}, err
}
if err := gz.Close(); err != nil {
f.Close()
os.Remove(tmp)
return Archive{}, err
}
if err := f.Close(); err != nil {
os.Remove(tmp)
return Archive{}, err
}
if err := os.Rename(tmp, target); err != nil {
return Archive{}, err
}
info, err := os.Stat(target)
if err != nil {
return Archive{}, err
}
log(fmt.Sprintf("Архив готов: %s (%s, файлов: %d)", name, humanSize(info.Size()), written))
if err := m.Prune(m.cfg.Backup.Keep, log); err != nil {
log("Ротация не удалась: " + err.Error())
}
return Archive{Name: name, SizeBytes: info.Size(), CreatedAt: info.ModTime().Unix()}, nil
}
// addTree кладёт директорию в архив, сохраняя пути относительно root.
func addTree(ctx context.Context, tw *tar.Writer, root, dir string, log func(string)) (int64, error) {
var count int64
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
// Файл мог исчезнуть, пока идёт обход, — это не повод падать.
log("Пропускаю " + path + ": " + err.Error())
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if !info.Mode().IsRegular() && !info.IsDir() {
return nil
}
rel, err := filepath.Rel(root, path)
if err != nil {
return err
}
hdr, err := tar.FileInfoHeader(info, "")
if err != nil {
return err
}
hdr.Name = filepath.ToSlash(rel)
if info.IsDir() {
hdr.Name += "/"
}
if err := tw.WriteHeader(hdr); err != nil {
return err
}
if info.IsDir() {
return nil
}
f, err := os.Open(path)
if err != nil {
// Блокировка файла сервером не должна ронять весь бэкап.
log("Не прочитан " + rel + ": " + err.Error())
return nil
}
defer f.Close()
if _, err := io.Copy(tw, f); err != nil {
return err
}
count++
return nil
})
return count, err
}
// Restore распаковывает архив поверх ~/Zomboid. Сервер должен быть остановлен —
// это проверяет вызывающий код.
func (m *Manager) Restore(ctx context.Context, log func(string), name string) error {
path, err := m.Path(name)
if err != nil {
return err
}
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
return fmt.Errorf("архив повреждён: %w", err)
}
defer gz.Close()
log("Восстанавливаю " + name + " в " + m.cfg.ZomboidDir)
tr := tar.NewReader(gz)
var restored int
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return err
}
target, err := safeJoin(m.cfg.ZomboidDir, hdr.Name)
if err != nil {
return err
}
switch hdr.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(hdr.Mode))
if err != nil {
return err
}
if _, err := io.Copy(out, tr); err != nil {
out.Close()
return err
}
out.Close()
restored++
}
}
log(fmt.Sprintf("Восстановлено файлов: %d", restored))
return nil
}
// Delete удаляет архив.
func (m *Manager) Delete(name string) error {
path, err := m.Path(name)
if err != nil {
return err
}
return os.Remove(path)
}
// Prune оставляет только keep самых свежих архивов (keep <= 0 — не трогать).
func (m *Manager) Prune(keep int, log func(string)) error {
if keep <= 0 {
return nil
}
archives, err := m.List()
if err != nil {
return err
}
for _, a := range archives[min(keep, len(archives)):] {
if err := m.Delete(a.Name); err != nil {
return err
}
log("Удалён старый архив " + a.Name)
}
return nil
}
// safeJoin не даёт архиву с путями вида ../.. вылезти за пределы root.
// Сегменты проверяются до нормализации: path.Clean схлопнул бы "a/../../b" в
// безобидный на вид путь и скрыл попытку выхода. Такие пути отвергаются, а не
// подгоняются под корень — они означают либо испорченный, либо специально
// собранный архив.
func safeJoin(root, name string) (string, error) {
slashed := filepath.ToSlash(name)
if path.IsAbs(slashed) {
return "", fmt.Errorf("абсолютный путь в архиве: %s", name)
}
for _, segment := range strings.Split(slashed, "/") {
if segment == ".." {
return "", fmt.Errorf("небезопасный путь в архиве: %s", name)
}
}
return filepath.Join(root, filepath.FromSlash(path.Clean(slashed))), nil
}
// sanitizeNote оставляет от пометки только безопасные для имени файла символы.
func sanitizeNote(note string) string {
var b strings.Builder
for _, r := range note {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
b.WriteRune(r)
case r == ' ':
b.WriteRune('_')
}
if b.Len() >= 32 {
break
}
}
return strings.Trim(b.String(), "_-")
}
func humanSize(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d Б", n)
}
prefixes := []string{"К", "М", "Г", "Т"}
div, exp := int64(unit), 0
for size := n / unit; size >= unit && exp < len(prefixes)-1; size /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %sБ", float64(n)/float64(div), prefixes[exp])
}
+141
View File
@@ -0,0 +1,141 @@
package backup
import (
"archive/tar"
"compress/gzip"
"context"
"os"
"path/filepath"
"testing"
"github.com/mrleo1nid/pzmanager/internal/config"
)
func TestSafeJoinRejectsTraversal(t *testing.T) {
root := filepath.Join(os.TempDir(), "pzm-root")
for _, name := range []string{"../evil", "../../etc/passwd", "Saves/../../evil"} {
if _, err := safeJoin(root, name); err == nil {
t.Errorf("путь %q принят, ожидался отказ", name)
}
}
if _, err := safeJoin(root, "Saves/Multiplayer/map.bin"); err != nil {
t.Errorf("обычный путь отклонён: %v", err)
}
}
func TestSanitizeNote(t *testing.T) {
cases := map[string]string{
"перед вайпом": "",
"before wipe": "before_wipe",
"v1.2/../x": "v12x",
"": "",
"__trim-me__": "trim-me",
}
for input, want := range cases {
if got := sanitizeNote(input); got != want {
t.Errorf("sanitizeNote(%q) = %q, ожидалось %q", input, got, want)
}
}
}
func TestNameValidation(t *testing.T) {
m := New(&config.Config{})
if _, err := m.Path("../../etc/passwd"); err == nil {
t.Error("имя с переходом по каталогам принято")
}
if _, err := m.Path("pz-20250903-181500-auto.tar.gz"); err != nil {
t.Errorf("корректное имя отклонено: %v", err)
}
}
// TestCreateAndRestore проверяет полный цикл: архивируем дерево, портим файл,
// восстанавливаем и убеждаемся, что содержимое вернулось.
func TestCreateAndRestore(t *testing.T) {
root := t.TempDir()
zomboid := filepath.Join(root, "Zomboid")
savePath := filepath.Join(zomboid, "Saves", "Multiplayer", "world.bin")
if err := os.MkdirAll(filepath.Dir(savePath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(savePath, []byte("исходный мир"), 0o644); err != nil {
t.Fatal(err)
}
cfg := &config.Config{ZomboidDir: zomboid}
cfg.Backup.Dir = filepath.Join(root, "backups")
m := New(cfg)
archive, err := m.Create(context.Background(), func(string) {}, "test")
if err != nil {
t.Fatalf("Create: %v", err)
}
if archive.SizeBytes == 0 {
t.Error("архив пустой")
}
if err := os.WriteFile(savePath, []byte("испорчено"), 0o644); err != nil {
t.Fatal(err)
}
if err := m.Restore(context.Background(), func(string) {}, archive.Name); err != nil {
t.Fatalf("Restore: %v", err)
}
restored, err := os.ReadFile(savePath)
if err != nil {
t.Fatal(err)
}
if string(restored) != "исходный мир" {
t.Errorf("после восстановления содержимое = %q", restored)
}
}
func TestPruneKeepsNewest(t *testing.T) {
root := t.TempDir()
cfg := &config.Config{ZomboidDir: filepath.Join(root, "Zomboid")}
cfg.Backup.Dir = filepath.Join(root, "backups")
m := New(cfg)
if err := os.MkdirAll(cfg.Backup.Dir, 0o755); err != nil {
t.Fatal(err)
}
names := []string{
"pz-20250101-000000.tar.gz",
"pz-20250102-000000.tar.gz",
"pz-20250103-000000.tar.gz",
}
for _, name := range names {
writeEmptyArchive(t, filepath.Join(cfg.Backup.Dir, name))
}
if err := m.Prune(2, func(string) {}); err != nil {
t.Fatal(err)
}
left, err := m.List()
if err != nil {
t.Fatal(err)
}
if len(left) != 2 {
t.Fatalf("осталось %d архивов, ожидалось 2: %v", len(left), left)
}
}
// writeEmptyArchive создаёт валидный пустой tar.gz — List проверяет имя, а не
// содержимое, но пусть файлы будут настоящими.
func writeEmptyArchive(t *testing.T, path string) {
t.Helper()
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
gz := gzip.NewWriter(f)
tw := tar.NewWriter(gz)
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := gz.Close(); err != nil {
t.Fatal(err)
}
}
+201
View File
@@ -0,0 +1,201 @@
// Package config загружает и сохраняет настройки самого менеджера.
package config
import (
"fmt"
"os"
"path/filepath"
"time"
"gopkg.in/yaml.v3"
)
// Config — всё, что менеджеру нужно знать о хосте и о сервере PZ.
type Config struct {
// Listen — адрес веб-панели. Для доступа только через SSH-туннель
// используйте 127.0.0.1:8080.
Listen string `yaml:"listen"`
// DataDir — рабочая директория менеджера (пользователи, сессии, бэкапы).
DataDir string `yaml:"data_dir"`
// ServerDir — куда SteamCMD ставит серверные файлы PZ.
ServerDir string `yaml:"server_dir"`
// ZomboidDir — ~/Zomboid: сохранения, конфиги, логи.
ZomboidDir string `yaml:"zomboid_dir"`
// SteamCMDPath — путь к бинарю steamcmd.sh (пустой = ищем в PATH).
SteamCMDPath string `yaml:"steamcmd_path"`
// ServerName — имя набора конфигов PZ (servertest -> servertest.ini).
ServerName string `yaml:"server_name"`
// AdminPassword — пароль админа игрового сервера, нужен при первом
// запуске PZ. Хранится здесь, чтобы не вводить его в консоли.
AdminPassword string `yaml:"admin_password"`
// JavaMemory — -Xmx для JVM сервера, например "8g".
JavaMemory string `yaml:"java_memory"`
// ExtraArgs — дополнительные аргументы запуска сервера.
ExtraArgs []string `yaml:"extra_args"`
// AutoStart — поднимать игровой сервер сразу при старте менеджера.
AutoStart bool `yaml:"autostart"`
// AutoRestart — перезапускать сервер, если он упал сам.
AutoRestart bool `yaml:"autorestart"`
// StopTimeout — сколько ждать корректного завершения после "quit".
StopTimeout Duration `yaml:"stop_timeout"`
// LogBufferLines — сколько строк консоли держать в памяти для веб-панели.
LogBufferLines int `yaml:"log_buffer_lines"`
// Backup — настройки резервного копирования.
Backup BackupConfig `yaml:"backup"`
path string
}
// BackupConfig описывает расписание и ротацию бэкапов мира.
type BackupConfig struct {
// Dir — куда складывать архивы.
Dir string `yaml:"dir"`
// Schedule — интервал автобэкапа; 0 отключает автоматику.
Schedule Duration `yaml:"schedule"`
// Keep — сколько последних архивов хранить (0 = не удалять).
Keep int `yaml:"keep"`
// StopServer — останавливать сервер на время бэкапа. Безопаснее, но
// игроков выкидывает; по умолчанию выключено.
StopServer bool `yaml:"stop_server"`
}
// Default возвращает конфиг с разумными значениями для Ubuntu.
func Default() *Config {
home, _ := os.UserHomeDir()
return &Config{
Listen: "127.0.0.1:8080",
DataDir: filepath.Join(home, ".local", "share", "pzmanager"),
ServerDir: filepath.Join(home, "pzserver"),
ZomboidDir: filepath.Join(home, "Zomboid"),
ServerName: "servertest",
JavaMemory: "4g",
AutoRestart: true,
StopTimeout: Duration(90 * time.Second),
LogBufferLines: 5000,
Backup: BackupConfig{
Dir: filepath.Join(home, "pzbackups"),
Schedule: Duration(6 * time.Hour),
Keep: 20,
},
}
}
// Load читает конфиг с диска, дополняя пропущенные поля значениями по
// умолчанию. Если файла нет — создаёт его.
func Load(path string) (*Config, error) {
cfg := Default()
cfg.path = path
raw, err := os.ReadFile(path)
if os.IsNotExist(err) {
if err := cfg.Save(); err != nil {
return nil, fmt.Errorf("создать конфиг %s: %w", path, err)
}
return cfg, nil
}
if err != nil {
return nil, fmt.Errorf("прочитать конфиг %s: %w", path, err)
}
if err := yaml.Unmarshal(raw, cfg); err != nil {
return nil, fmt.Errorf("разобрать конфиг %s: %w", path, err)
}
cfg.applyFallbacks()
return cfg, nil
}
// applyFallbacks подставляет умолчания вместо пустых значений: конфиг,
// написанный руками, редко заполнен целиком.
func (c *Config) applyFallbacks() {
d := Default()
if c.Listen == "" {
c.Listen = d.Listen
}
if c.DataDir == "" {
c.DataDir = d.DataDir
}
if c.ServerDir == "" {
c.ServerDir = d.ServerDir
}
if c.ZomboidDir == "" {
c.ZomboidDir = d.ZomboidDir
}
if c.ServerName == "" {
c.ServerName = d.ServerName
}
if c.JavaMemory == "" {
c.JavaMemory = d.JavaMemory
}
if c.StopTimeout <= 0 {
c.StopTimeout = d.StopTimeout
}
if c.LogBufferLines <= 0 {
c.LogBufferLines = d.LogBufferLines
}
if c.Backup.Dir == "" {
c.Backup.Dir = d.Backup.Dir
}
}
// Save записывает конфиг атомарно: сначала во временный файл рядом, затем
// переименование, чтобы падение посреди записи не оставило огрызок.
func (c *Config) Save() error {
if c.path == "" {
return fmt.Errorf("путь к конфигу неизвестен")
}
if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil {
return err
}
raw, err := yaml.Marshal(c)
if err != nil {
return err
}
tmp := c.path + ".tmp"
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
return err
}
return os.Rename(tmp, c.path)
}
// Path возвращает путь, откуда конфиг был загружен.
func (c *Config) Path() string { return c.path }
// SetPath задаёт файл для последующего Save.
func (c *Config) SetPath(p string) { c.path = p }
// EnsureDirs создаёт директории, без которых менеджер не сможет работать.
func (c *Config) EnsureDirs() error {
for _, dir := range []string{c.DataDir, c.ServerDir, c.Backup.Dir} {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("создать %s: %w", dir, err)
}
}
return nil
}
// ServerConfigPath — путь к servertest.ini (или другому имени сервера).
func (c *Config) ServerConfigPath() string {
return filepath.Join(c.ZomboidDir, "Server", c.ServerName+".ini")
}
// SandboxConfigPath — путь к <name>_SandboxVars.lua.
func (c *Config) SandboxConfigPath() string {
return filepath.Join(c.ZomboidDir, "Server", c.ServerName+"_SandboxVars.lua")
}
// SavesDir — директория с мирами (то, что бэкапим).
func (c *Config) SavesDir() string {
return filepath.Join(c.ZomboidDir, "Saves")
}
+45
View File
@@ -0,0 +1,45 @@
package config
import (
"fmt"
"time"
"gopkg.in/yaml.v3"
)
// Duration — time.Duration, который в YAML выглядит как "90s" или "6h", а не
// как число наносекунд: конфиг правят руками.
type Duration time.Duration
// Duration возвращает значение в виде time.Duration.
func (d Duration) Duration() time.Duration { return time.Duration(d) }
// Seconds возвращает длительность в секундах — удобно для JSON-ответов API.
func (d Duration) Seconds() int64 { return int64(time.Duration(d).Seconds()) }
// String печатает длительность в том же виде, в каком она пишется в конфиге.
func (d Duration) String() string { return time.Duration(d).String() }
// MarshalYAML пишет длительность строкой.
func (d Duration) MarshalYAML() (any, error) { return time.Duration(d).String(), nil }
// UnmarshalYAML принимает и строку ("90s"), и число — число трактуется как
// секунды, потому что так его естественнее всего написать руками.
func (d *Duration) UnmarshalYAML(node *yaml.Node) error {
var asString string
if err := node.Decode(&asString); err == nil {
parsed, err := time.ParseDuration(asString)
if err != nil {
return fmt.Errorf("не разобрана длительность %q: %w", asString, err)
}
*d = Duration(parsed)
return nil
}
var asSeconds int64
if err := node.Decode(&asSeconds); err != nil {
return fmt.Errorf("длительность должна быть строкой вида 90s или числом секунд")
}
*d = Duration(time.Duration(asSeconds) * time.Second)
return nil
}
+98
View File
@@ -0,0 +1,98 @@
// Package job выполняет длинные операции (установка сервера, обновление модов,
// бэкап) по одной за раз, чтобы они не мешали друг другу.
package job
import (
"context"
"errors"
"sync"
"time"
)
// ErrBusy возвращается, когда другая задача ещё выполняется.
var ErrBusy = errors.New("уже выполняется другая задача")
// Status — что менеджер задач показывает веб-панели.
type Status struct {
Name string `json:"name"`
Running bool `json:"running"`
StartedAt int64 `json:"started_at"`
FinishedAt int64 `json:"finished_at"`
Error string `json:"error"`
}
// Func — тело задачи. Логирование идёт через log, отмена — через ctx.
type Func func(ctx context.Context, log func(string)) error
// Manager допускает только одну активную задачу.
type Manager struct {
logf func(stream, text string)
mu sync.Mutex
status Status
cancel context.CancelFunc
}
// New создаёт менеджер задач; logf получает весь вывод задач.
func New(logf func(stream, text string)) *Manager {
return &Manager{logf: logf}
}
// Start запускает задачу в фоне. Если что-то уже выполняется — ErrBusy.
func (m *Manager) Start(name string, fn Func) error {
m.mu.Lock()
if m.status.Running {
m.mu.Unlock()
return ErrBusy
}
ctx, cancel := context.WithCancel(context.Background())
m.cancel = cancel
m.status = Status{Name: name, Running: true, StartedAt: time.Now().Unix()}
m.mu.Unlock()
m.logf("task", "▶ "+name)
go func() {
defer cancel()
err := fn(ctx, func(line string) { m.logf("task", line) })
m.mu.Lock()
m.status.Running = false
m.status.FinishedAt = time.Now().Unix()
if err != nil {
m.status.Error = err.Error()
}
m.mu.Unlock()
if err != nil {
m.logf("task", "✖ "+name+": "+err.Error())
return
}
m.logf("task", "✔ "+name+" — готово")
}()
return nil
}
// Cancel просит текущую задачу завершиться.
func (m *Manager) Cancel() {
m.mu.Lock()
cancel := m.cancel
running := m.status.Running
m.mu.Unlock()
if running && cancel != nil {
cancel()
}
}
// Status отдаёт состояние последней (или текущей) задачи.
func (m *Manager) Status() Status {
m.mu.Lock()
defer m.mu.Unlock()
return m.status
}
// Busy сообщает, занят ли менеджер.
func (m *Manager) Busy() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.status.Running
}
+151
View File
@@ -0,0 +1,151 @@
// Package pzconfig читает и правит конфиги Project Zomboid: servertest.ini и
// SandboxVars.lua.
package pzconfig
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
)
// iniLine — одна строка файла. Пустой Key означает комментарий или пустую
// строку, которую нужно сохранить как есть.
type iniLine struct {
Key string
Value string
Raw string
}
// INI — конфиг PZ в виде плоских key=value. Разбор сохраняет исходный порядок
// строк и комментарии, чтобы правка одного параметра не переписывала файл.
type INI struct {
lines []iniLine
index map[string]int
}
// ParseINI разбирает содержимое конфига.
func ParseINI(data []byte) *INI {
ini := &INI{index: make(map[string]int)}
text := strings.ReplaceAll(string(data), "\r\n", "\n")
for _, raw := range strings.Split(text, "\n") {
trimmed := strings.TrimSpace(raw)
key, value, found := strings.Cut(raw, "=")
if !found || trimmed == "" || strings.HasPrefix(trimmed, "#") {
ini.lines = append(ini.lines, iniLine{Raw: raw})
continue
}
key = strings.TrimSpace(key)
ini.index[key] = len(ini.lines)
ini.lines = append(ini.lines, iniLine{Key: key, Value: strings.TrimSpace(value)})
}
return ini
}
// LoadINI читает конфиг с диска.
func LoadINI(path string) (*INI, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return ParseINI(data), nil
}
// Get возвращает значение параметра.
func (i *INI) Get(key string) string {
idx, ok := i.index[key]
if !ok {
return ""
}
return i.lines[idx].Value
}
// Has сообщает, есть ли такой параметр в файле.
func (i *INI) Has(key string) bool {
_, ok := i.index[key]
return ok
}
// Set меняет значение, добавляя параметр в конец, если его ещё нет.
func (i *INI) Set(key, value string) {
// Перевод строки в значении сломал бы формат файла.
value = strings.NewReplacer("\n", " ", "\r", " ").Replace(value)
if idx, ok := i.index[key]; ok {
i.lines[idx].Value = value
return
}
i.index[key] = len(i.lines)
i.lines = append(i.lines, iniLine{Key: key, Value: value})
}
// Keys возвращает имена параметров в порядке их появления в файле.
func (i *INI) Keys() []string {
out := make([]string, 0, len(i.index))
for _, l := range i.lines {
if l.Key != "" {
out = append(out, l.Key)
}
}
return out
}
// Map отдаёт все параметры в виде словаря.
func (i *INI) Map() map[string]string {
out := make(map[string]string, len(i.index))
for _, l := range i.lines {
if l.Key != "" {
out[l.Key] = l.Value
}
}
return out
}
// Render собирает файл обратно в текст.
func (i *INI) Render() []byte {
var buf bytes.Buffer
for n, l := range i.lines {
if l.Key == "" {
buf.WriteString(l.Raw)
} else {
buf.WriteString(l.Key)
buf.WriteByte('=')
buf.WriteString(l.Value)
}
if n < len(i.lines)-1 {
buf.WriteByte('\n')
}
}
return buf.Bytes()
}
// SaveINI записывает конфиг атомарно, предварительно сняв копию прежней
// версии рядом (.bak): испорченный конфиг остановит сервер.
func SaveINI(path string, ini *INI) error {
if err := backupFile(path); err != nil {
return err
}
return writeAtomic(path, ini.Render(), 0o644)
}
func backupFile(path string) error {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
return os.WriteFile(path+".bak", data, 0o644)
}
func writeAtomic(path string, data []byte, mode os.FileMode) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, mode); err != nil {
return fmt.Errorf("записать %s: %w", tmp, err)
}
return os.Rename(tmp, path)
}
+62
View File
@@ -0,0 +1,62 @@
package pzconfig
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
// javaLauncherFile — файл, из которого сервер берёт аргументы JVM.
const javaLauncherFile = "ProjectZomboid64.json"
var xmxRe = regexp.MustCompile(`^-Xm[sx]`)
// SetJavaMemory прописывает лимит памяти JVM в ProjectZomboid64.json. Именно
// оттуда start-server.sh берёт -Xmx, поэтому менять размер памяти в аргументах
// запуска бесполезно.
func SetJavaMemory(serverDir, memory string) error {
memory = strings.TrimSpace(memory)
if memory == "" {
return nil
}
if !regexp.MustCompile(`^[0-9]+[kKmMgG]$`).MatchString(memory) {
return fmt.Errorf("объём памяти должен выглядеть как 4g или 8192m, получено %q", memory)
}
path := filepath.Join(serverDir, javaLauncherFile)
raw, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("прочитать %s: %w", path, err)
}
// Разбираем в map, чтобы не потерять поля, о которых мы не знаем.
var doc map[string]any
if err := json.Unmarshal(raw, &doc); err != nil {
return fmt.Errorf("разобрать %s: %w", path, err)
}
args, _ := doc["vmArgs"].([]any)
updated := make([]any, 0, len(args)+2)
for _, a := range args {
if str, ok := a.(string); ok && xmxRe.MatchString(str) {
continue
}
updated = append(updated, a)
}
// -Xms равным -Xmx: JVM сразу забирает память и не тратит время на рост
// кучи в момент, когда на сервере много игроков.
updated = append(updated, "-Xms"+memory, "-Xmx"+memory)
doc["vmArgs"] = updated
out, err := json.MarshalIndent(doc, "", " ")
if err != nil {
return err
}
if err := backupFile(path); err != nil {
return err
}
return writeAtomic(path, out, 0o644)
}
+52
View File
@@ -0,0 +1,52 @@
package pzconfig
import "strings"
// Ключи servertest.ini, в которых живут моды.
const (
KeyMods = "Mods"
KeyWorkshopItems = "WorkshopItems"
KeyMap = "Map"
)
// SplitList разбирает список PZ вида "a;b;c" в срез без пустых элементов.
func SplitList(value string) []string {
if strings.TrimSpace(value) == "" {
return []string{}
}
parts := strings.Split(value, ";")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
// JoinList собирает список обратно в формат PZ, убирая дубликаты и сохраняя
// порядок: он важен, моды грузятся именно в нём.
func JoinList(items []string) string {
seen := make(map[string]bool, len(items))
out := make([]string, 0, len(items))
for _, item := range items {
item = strings.TrimSpace(item)
if item == "" || seen[item] {
continue
}
seen[item] = true
out = append(out, item)
}
return strings.Join(out, ";")
}
// Mods возвращает текущие списки модов сервера.
func (i *INI) Mods() (modIDs, workshopIDs []string) {
return SplitList(i.Get(KeyMods)), SplitList(i.Get(KeyWorkshopItems))
}
// SetMods записывает списки модов обратно в конфиг.
func (i *INI) SetMods(modIDs, workshopIDs []string) {
i.Set(KeyMods, JoinList(modIDs))
i.Set(KeyWorkshopItems, JoinList(workshopIDs))
}
+156
View File
@@ -0,0 +1,156 @@
package pzconfig
import (
"strings"
"testing"
)
const sampleINI = `# Comment line
PublicName=My Server
Public=true
MaxPlayers=32
Mods=modA;modB
WorkshopItems=111;222
`
func TestINIPreservesLayout(t *testing.T) {
ini := ParseINI([]byte(sampleINI))
if got := ini.Get("PublicName"); got != "My Server" {
t.Fatalf("PublicName = %q, ожидалось %q", got, "My Server")
}
// Правка одного ключа не должна трогать комментарии и пустые строки.
ini.Set("MaxPlayers", "64")
got := string(ini.Render())
if !strings.HasPrefix(got, "# Comment line\n") {
t.Errorf("комментарий потерян:\n%s", got)
}
if !strings.Contains(got, "\n\nMaxPlayers=64\n") {
t.Errorf("пустая строка или новое значение потеряны:\n%s", got)
}
if strings.Contains(got, "MaxPlayers=32") {
t.Errorf("старое значение осталось:\n%s", got)
}
}
func TestINIAddsMissingKey(t *testing.T) {
ini := ParseINI([]byte("A=1"))
ini.Set("B", "2")
if got := ini.Get("B"); got != "2" {
t.Fatalf("B = %q", got)
}
if got := string(ini.Render()); got != "A=1\nB=2" {
t.Fatalf("render = %q", got)
}
}
func TestINIRejectsNewlineInValue(t *testing.T) {
ini := ParseINI([]byte("A=1"))
ini.Set("A", "нет\nпереносам")
if strings.Contains(string(ini.Render()), "\nп") {
t.Fatalf("перенос строки попал в значение: %q", ini.Get("A"))
}
}
func TestModsRoundTrip(t *testing.T) {
ini := ParseINI([]byte(sampleINI))
mods, workshop := ini.Mods()
if len(mods) != 2 || mods[0] != "modA" {
t.Fatalf("mods = %v", mods)
}
if len(workshop) != 2 || workshop[1] != "222" {
t.Fatalf("workshop = %v", workshop)
}
// Дубликаты отбрасываются, порядок сохраняется.
ini.SetMods([]string{"modB", "modA", "modB"}, []string{"222"})
if got := ini.Get(KeyMods); got != "modB;modA" {
t.Fatalf("Mods = %q", got)
}
if got := ini.Get(KeyWorkshopItems); got != "222" {
t.Fatalf("WorkshopItems = %q", got)
}
}
func TestSplitListIgnoresBlanks(t *testing.T) {
if got := SplitList(" a ; ; b;"); len(got) != 2 || got[0] != "a" || got[1] != "b" {
t.Fatalf("SplitList = %v", got)
}
if got := SplitList(" "); len(got) != 0 {
t.Fatalf("пустая строка дала %v", got)
}
}
const sampleSandbox = `SandboxVars = {
VERSION = 5,
Zombies = 3,
Speed = 2,
ZombieLore = {
Speed = 2,
Strength = 2,
},
WaterShutModifier = 14,
}
`
func TestSandboxParsesNestedPaths(t *testing.T) {
sb := ParseSandbox([]byte(sampleSandbox))
if v, ok := sb.Get("Zombies"); !ok || v != "3" {
t.Fatalf("Zombies = %q (найдено: %v)", v, ok)
}
// Одноимённые ключи на разных уровнях не должны перетирать друг друга.
if v, ok := sb.Get("ZombieLore.Speed"); !ok || v != "2" {
t.Fatalf("ZombieLore.Speed = %q (найдено: %v)", v, ok)
}
if v, ok := sb.Get("Speed"); !ok || v != "2" {
t.Fatalf("Speed верхнего уровня = %q (найдено: %v)", v, ok)
}
if v, ok := sb.Get("WaterShutModifier"); !ok || v != "14" {
t.Fatalf("WaterShutModifier после закрытия таблицы = %q (найдено: %v)", v, ok)
}
}
func TestSandboxSetKeepsFormatting(t *testing.T) {
sb := ParseSandbox([]byte(sampleSandbox))
if err := sb.Set("ZombieLore.Speed", "1"); err != nil {
t.Fatal(err)
}
got := string(sb.Render())
if !strings.Contains(got, " Speed = 1,") {
t.Errorf("отступ или запятая потеряны:\n%s", got)
}
if !strings.Contains(got, " Speed = 2,") {
t.Errorf("параметр верхнего уровня изменён по ошибке:\n%s", got)
}
if !strings.HasSuffix(got, "}\n") {
t.Errorf("хвост файла изменён:\n%s", got)
}
}
func TestSandboxRejectsUnknownAndBrokenValues(t *testing.T) {
sb := ParseSandbox([]byte(sampleSandbox))
if err := sb.Set("НетТакого", "1"); err == nil {
t.Error("ожидалась ошибка для неизвестного параметра")
}
if err := sb.Set("Zombies", "{ 1, 2 }"); err == nil {
t.Error("ожидалась ошибка для значения с фигурными скобками")
}
}
func TestSandboxEntriesFollowFileOrder(t *testing.T) {
entries := ParseSandbox([]byte(sampleSandbox)).Entries()
want := []string{"VERSION", "Zombies", "Speed", "ZombieLore.Speed", "ZombieLore.Strength", "WaterShutModifier"}
if len(entries) != len(want) {
t.Fatalf("получено %d параметров, ожидалось %d: %v", len(entries), len(want), entries)
}
for i, path := range want {
if entries[i].Path != path {
t.Errorf("entries[%d] = %q, ожидалось %q", i, entries[i].Path, path)
}
}
}
+158
View File
@@ -0,0 +1,158 @@
package pzconfig
import (
"bytes"
"fmt"
"os"
"regexp"
"strings"
)
// SandboxEntry — один параметр песочницы. Path включает вложенные таблицы
// (например ZombieLore.Speed), Value хранится как в файле.
type SandboxEntry struct {
Path string `json:"path"`
Value string `json:"value"`
}
// Sandbox — разбор <server>_SandboxVars.lua. Файл генерируется игрой и имеет
// предсказуемый вид (одна пара ключ-значение на строку), поэтому вместо
// полноценного интерпретатора Lua достаточно построчного разбора: он
// гарантирует, что при сохранении файл останется байт-в-байт прежним, кроме
// изменённых значений.
type Sandbox struct {
lines []string
// index сопоставляет путь параметра с номером строки.
index map[string]int
}
var (
sandboxAssignRe = regexp.MustCompile(`^(\s*)([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+?)(,?)\s*$`)
sandboxCloseRe = regexp.MustCompile(`^\s*\}`)
)
// ParseSandbox разбирает содержимое SandboxVars.lua.
func ParseSandbox(data []byte) *Sandbox {
s := &Sandbox{index: make(map[string]int)}
text := strings.ReplaceAll(string(data), "\r\n", "\n")
s.lines = strings.Split(text, "\n")
var stack []string
for n, line := range s.lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "--") {
continue
}
if sandboxCloseRe.MatchString(line) {
if len(stack) > 0 {
stack = stack[:len(stack)-1]
}
continue
}
m := sandboxAssignRe.FindStringSubmatch(line)
if m == nil {
continue
}
name, value := m[2], strings.TrimSpace(m[3])
if strings.HasPrefix(value, "{") {
// Начало вложенной таблицы: сам SandboxVars = { тоже сюда попадёт,
// но его имя в путях не нужно.
if name != "SandboxVars" {
stack = append(stack, name)
} else {
stack = stack[:0]
}
continue
}
path := name
if len(stack) > 0 {
path = strings.Join(stack, ".") + "." + name
}
s.index[path] = n
}
return s
}
// LoadSandbox читает файл песочницы с диска.
func LoadSandbox(path string) (*Sandbox, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return ParseSandbox(data), nil
}
// Entries возвращает все параметры в порядке следования в файле.
func (s *Sandbox) Entries() []SandboxEntry {
out := make([]SandboxEntry, 0, len(s.index))
// Обходим строки, а не карту: порядок в файле осмысленный.
byLine := make(map[int]string, len(s.index))
for path, line := range s.index {
byLine[line] = path
}
for n := range s.lines {
path, ok := byLine[n]
if !ok {
continue
}
out = append(out, SandboxEntry{Path: path, Value: s.valueAt(n)})
}
return out
}
func (s *Sandbox) valueAt(line int) string {
m := sandboxAssignRe.FindStringSubmatch(s.lines[line])
if m == nil {
return ""
}
return strings.TrimSpace(m[3])
}
// Get возвращает значение параметра по пути.
func (s *Sandbox) Get(path string) (string, bool) {
n, ok := s.index[path]
if !ok {
return "", false
}
return s.valueAt(n), true
}
// Set меняет значение существующего параметра. Новые параметры не добавляются:
// игра всё равно игнорирует незнакомые ключи.
func (s *Sandbox) Set(path, value string) error {
n, ok := s.index[path]
if !ok {
return fmt.Errorf("параметр %q не найден в SandboxVars", path)
}
value = strings.TrimSpace(value)
if strings.ContainsAny(value, "\n\r{}") {
return fmt.Errorf("недопустимое значение для %q", path)
}
m := sandboxAssignRe.FindStringSubmatch(s.lines[n])
if m == nil {
return fmt.Errorf("не удалось разобрать строку параметра %q", path)
}
indent, name, comma := m[1], m[2], m[4]
s.lines[n] = fmt.Sprintf("%s%s = %s%s", indent, name, value, comma)
return nil
}
// Render собирает файл обратно.
func (s *Sandbox) Render() []byte {
var buf bytes.Buffer
for n, line := range s.lines {
buf.WriteString(line)
if n < len(s.lines)-1 {
buf.WriteByte('\n')
}
}
return buf.Bytes()
}
// SaveSandbox пишет файл песочницы, сохранив предыдущую версию в .bak.
func SaveSandbox(path string, s *Sandbox) error {
if err := backupFile(path); err != nil {
return err
}
return writeAtomic(path, s.Render(), 0o644)
}
+153
View File
@@ -0,0 +1,153 @@
package pzconfig
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
)
// workshopAPI — публичный метод Steam, ключ доступа не требуется.
const workshopAPI = "https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/"
// WorkshopItem — то, что панель показывает про мод из мастерской.
type WorkshopItem struct {
ID string `json:"id"`
Title string `json:"title"`
Preview string `json:"preview"`
Description string `json:"description"`
Error string `json:"error,omitempty"`
}
// WorkshopClient запрашивает названия модов у Steam и кеширует их: список
// модов на странице обновляется часто, а названия не меняются.
type WorkshopClient struct {
HTTP *http.Client
mu sync.Mutex
cache map[string]cachedItem
}
type cachedItem struct {
item WorkshopItem
fetched time.Time
}
// cacheTTL — как долго доверять закешированному названию мода.
const cacheTTL = 12 * time.Hour
// NewWorkshopClient создаёт клиент со своим HTTP-таймаутом.
func NewWorkshopClient() *WorkshopClient {
return &WorkshopClient{
HTTP: &http.Client{Timeout: 15 * time.Second},
cache: make(map[string]cachedItem),
}
}
// Details запрашивает описание модов по их Workshop ID. Если Steam недоступен,
// возвращает записи с заполненным полем Error — панель всё равно покажет
// список, просто без названий.
func (c *WorkshopClient) Details(ctx context.Context, ids []string) []WorkshopItem {
out := make([]WorkshopItem, 0, len(ids))
missing := make([]string, 0, len(ids))
c.mu.Lock()
for _, id := range ids {
if entry, ok := c.cache[id]; ok && time.Since(entry.fetched) < cacheTTL {
out = append(out, entry.item)
continue
}
missing = append(missing, id)
}
c.mu.Unlock()
if len(missing) > 0 {
fetched, err := c.fetch(ctx, missing)
if err != nil {
for _, id := range missing {
out = append(out, WorkshopItem{ID: id, Error: err.Error()})
}
} else {
c.mu.Lock()
for _, item := range fetched {
c.cache[item.ID] = cachedItem{item: item, fetched: time.Now()}
}
c.mu.Unlock()
out = append(out, fetched...)
}
}
// Возвращаем в том же порядке, в каком просили.
byID := make(map[string]WorkshopItem, len(out))
for _, item := range out {
byID[item.ID] = item
}
ordered := make([]WorkshopItem, 0, len(ids))
for _, id := range ids {
if item, ok := byID[id]; ok {
ordered = append(ordered, item)
continue
}
ordered = append(ordered, WorkshopItem{ID: id, Error: "нет данных"})
}
return ordered
}
func (c *WorkshopClient) fetch(ctx context.Context, ids []string) ([]WorkshopItem, error) {
form := url.Values{}
form.Set("itemcount", strconv.Itoa(len(ids)))
for n, id := range ids {
form.Set(fmt.Sprintf("publishedfileids[%d]", n), id)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, workshopAPI,
strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("Steam недоступен: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Steam ответил %s", resp.Status)
}
var payload struct {
Response struct {
PublishedFileDetails []struct {
PublishedFileID string `json:"publishedfileid"`
Title string `json:"title"`
Description string `json:"description"`
PreviewURL string `json:"preview_url"`
Result int `json:"result"`
} `json:"publishedfiledetails"`
} `json:"response"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return nil, fmt.Errorf("ответ Steam не разобран: %w", err)
}
items := make([]WorkshopItem, 0, len(payload.Response.PublishedFileDetails))
for _, d := range payload.Response.PublishedFileDetails {
item := WorkshopItem{ID: d.PublishedFileID, Title: d.Title, Preview: d.PreviewURL}
// result == 1 означает "найдено"; всё остальное — удалён или скрыт.
if d.Result != 1 {
item.Error = "мод не найден в мастерской"
}
if len(d.Description) > 400 {
d.Description = d.Description[:400] + "…"
}
item.Description = d.Description
items = append(items, item)
}
return items, nil
}
+91
View File
@@ -0,0 +1,91 @@
package pzserver
import "sync"
// LogLine — одна строка консоли сервера с монотонным номером, по которому
// веб-клиент понимает, что он пропустил, и откуда догружать.
type LogLine struct {
Seq int64 `json:"seq"`
Time int64 `json:"time"` // Unix-миллисекунды
Stream string `json:"stream"`
Text string `json:"text"`
}
// logBuffer — кольцевой буфер последних строк консоли плюс рассылка их
// живым подписчикам (SSE-соединениям веб-панели).
type logBuffer struct {
mu sync.RWMutex
lines []LogLine
capacity int
nextSeq int64
subs map[int]chan LogLine
nextSub int
}
func newLogBuffer(capacity int) *logBuffer {
return &logBuffer{
lines: make([]LogLine, 0, capacity),
capacity: capacity,
nextSeq: 1,
subs: make(map[int]chan LogLine),
}
}
// append кладёт строку в буфер и рассылает подписчикам.
func (b *logBuffer) append(line LogLine) {
b.mu.Lock()
line.Seq = b.nextSeq
b.nextSeq++
if len(b.lines) == b.capacity {
copy(b.lines, b.lines[1:])
b.lines = b.lines[:len(b.lines)-1]
}
b.lines = append(b.lines, line)
subs := make([]chan LogLine, 0, len(b.subs))
for _, ch := range b.subs {
subs = append(subs, ch)
}
b.mu.Unlock()
for _, ch := range subs {
// Неблокирующая отправка: подписчик, который не успевает читать,
// теряет строки, но не тормозит вывод сервера.
select {
case ch <- line:
default:
}
}
}
// since возвращает строки с номером больше seq (seq<=0 — весь буфер).
func (b *logBuffer) since(seq int64) []LogLine {
b.mu.RLock()
defer b.mu.RUnlock()
out := make([]LogLine, 0, len(b.lines))
for _, l := range b.lines {
if l.Seq > seq {
out = append(out, l)
}
}
return out
}
// subscribe открывает канал живых строк и функцию отписки.
func (b *logBuffer) subscribe(bufferSize int) (<-chan LogLine, func()) {
ch := make(chan LogLine, bufferSize)
b.mu.Lock()
id := b.nextSub
b.nextSub++
b.subs[id] = ch
b.mu.Unlock()
return ch, func() {
b.mu.Lock()
if existing, ok := b.subs[id]; ok {
delete(b.subs, id)
close(existing)
}
b.mu.Unlock()
}
}
+33
View File
@@ -0,0 +1,33 @@
//go:build !windows
package pzserver
import (
"os/exec"
"syscall"
)
// setProcessGroup просит ядро выделить процессу собственную группу: сервер PZ
// это скрипт-обёртка, порождающая JVM, и убивать их нужно вместе.
func setProcessGroup(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
}
// processGroupID возвращает pgid запущенного процесса.
func processGroupID(pid int) int {
pgid, err := syscall.Getpgid(pid)
if err != nil {
return pid
}
return pgid
}
// signalGroup шлёт сигнал всей группе (отрицательный pid = группа).
func signalGroup(pgid int, sig syscall.Signal) error {
return syscall.Kill(-pgid, sig)
}
var (
sigTerm = syscall.SIGTERM
sigKill = syscall.SIGKILL
)
+28
View File
@@ -0,0 +1,28 @@
//go:build windows
package pzserver
import (
"os/exec"
"syscall"
)
// На Windows групп процессов в POSIX-смысле нет. Менеджер рассчитан на
// Ubuntu; эти заглушки нужны лишь для сборки и отладки под Windows.
func setProcessGroup(cmd *exec.Cmd) {}
func processGroupID(pid int) int { return pid }
func signalGroup(pgid int, sig syscall.Signal) error {
p, err := syscall.OpenProcess(syscall.PROCESS_TERMINATE, false, uint32(pgid))
if err != nil {
return err
}
defer syscall.CloseHandle(p)
return syscall.TerminateProcess(p, 1)
}
var (
sigTerm = syscall.Signal(15)
sigKill = syscall.Signal(9)
)
+459
View File
@@ -0,0 +1,459 @@
// Package pzserver запускает игровой сервер Project Zomboid как дочерний
// процесс, держит его консоль и следит за его состоянием.
package pzserver
import (
"bufio"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"github.com/mrleo1nid/pzmanager/internal/config"
"github.com/mrleo1nid/pzmanager/internal/sysinfo"
)
// State — фаза жизненного цикла игрового сервера.
type State string
const (
StateStopped State = "stopped"
StateStarting State = "starting"
StateRunning State = "running"
StateStopping State = "stopping"
)
// Ошибки, на которые HTTP-слой отвечает осмысленным кодом.
var (
ErrAlreadyRunning = errors.New("сервер уже запущен")
ErrNotRunning = errors.New("сервер не запущен")
ErrNotInstalled = errors.New("серверные файлы не найдены — сначала выполните установку")
)
// playersPollInterval — как часто менеджер спрашивает у сервера список игроков.
const playersPollInterval = 30 * time.Second
// restartDelay — пауза перед автоподъёмом упавшего сервера.
const restartDelay = 10 * time.Second
// Status — снимок состояния для веб-панели.
type Status struct {
State State `json:"state"`
Installed bool `json:"installed"`
PID int `json:"pid"`
UptimeSec int64 `json:"uptime_sec"`
Players []string `json:"players"`
PlayersAt int64 `json:"players_at"`
LastExit string `json:"last_exit"`
AutoRestart bool `json:"auto_restart"`
Proc sysinfo.ProcStats `json:"proc"`
Host sysinfo.HostStats `json:"host"`
}
// Supervisor владеет процессом сервера: запускает, кормит командами консоли,
// собирает вывод и по желанию поднимает после падения.
type Supervisor struct {
cfg *config.Config
log *logBuffer
mu sync.Mutex
state State
cmd *exec.Cmd
stdin io.WriteCloser
pgid int
startedAt time.Time
lastExit string
players []string
playersAt time.Time
// intentionalStop отличает остановку по кнопке от падения: во втором
// случае включается автоперезапуск.
intentionalStop bool
exited chan struct{}
// Захват ответа на команду players: сервер печатает заголовок и список
// строк вида -Nick.
capturing bool
captured []string
autoQuery bool
sampler *sysinfo.Sampler
stopOnce sync.Once
quit chan struct{}
}
// New создаёт супервизор поверх конфига. Конфиг читается при каждом запуске,
// поэтому изменения настроек подхватываются следующим стартом сервера.
func New(cfg *config.Config) *Supervisor {
s := &Supervisor{
cfg: cfg,
log: newLogBuffer(cfg.LogBufferLines),
state: StateStopped,
sampler: sysinfo.NewSampler(),
quit: make(chan struct{}),
}
go s.pollPlayers()
return s
}
// Close останавливает фоновые задачи супервизора.
func (s *Supervisor) Close() {
s.stopOnce.Do(func() { close(s.quit) })
}
// Installed сообщает, лежат ли на диске файлы сервера.
func (s *Supervisor) Installed() bool {
_, err := os.Stat(s.startScript())
return err == nil
}
func (s *Supervisor) startScript() string {
return filepath.Join(s.cfg.ServerDir, "start-server.sh")
}
// AppendLog добавляет строку в консоль панели — им пользуются задачи
// установки и бэкапа, чтобы их вывод был виден там же, где логи сервера.
func (s *Supervisor) AppendLog(stream, text string) {
s.log.append(LogLine{Time: time.Now().UnixMilli(), Stream: stream, Text: text})
}
// Lines возвращает историю консоли начиная с номера seq.
func (s *Supervisor) Lines(seq int64) []LogLine { return s.log.since(seq) }
// Subscribe отдаёт канал новых строк консоли и функцию отписки.
func (s *Supervisor) Subscribe() (<-chan LogLine, func()) { return s.log.subscribe(256) }
// State возвращает текущую фазу без остальных метрик.
func (s *Supervisor) State() State {
s.mu.Lock()
defer s.mu.Unlock()
return s.state
}
// Status собирает полный снимок состояния вместе с метриками процесса и хоста.
func (s *Supervisor) Status() Status {
s.mu.Lock()
st := Status{
State: s.state,
Players: append([]string{}, s.players...),
LastExit: s.lastExit,
AutoRestart: s.cfg.AutoRestart,
}
if !s.playersAt.IsZero() {
st.PlayersAt = s.playersAt.Unix()
}
pgid := s.pgid
if s.cmd != nil && s.cmd.Process != nil {
st.PID = s.cmd.Process.Pid
}
if !s.startedAt.IsZero() && s.state != StateStopped {
st.UptimeSec = int64(time.Since(s.startedAt).Seconds())
}
s.mu.Unlock()
st.Installed = s.Installed()
if st.State == StateRunning || st.State == StateStarting {
st.Proc = s.sampler.Sample(pgid)
} else {
s.sampler.Reset()
}
st.Host = sysinfo.Host(s.cfg.ZomboidDir)
return st
}
// Start поднимает игровой сервер. Возвращает ошибку, если он уже работает
// или файлы сервера ещё не установлены.
func (s *Supervisor) Start() error {
s.mu.Lock()
defer s.mu.Unlock()
return s.startLocked()
}
func (s *Supervisor) startLocked() error {
if s.state != StateStopped {
return ErrAlreadyRunning
}
script := s.startScript()
if _, err := os.Stat(script); err != nil {
return ErrNotInstalled
}
// Права на исполнение теряются, например, после распаковки из архива.
if err := os.Chmod(script, 0o755); err != nil {
return fmt.Errorf("chmod %s: %w", script, err)
}
args := []string{"-servername", s.cfg.ServerName}
if s.cfg.AdminPassword != "" {
args = append(args, "-adminpassword", s.cfg.AdminPassword)
}
args = append(args, s.cfg.ExtraArgs...)
cmd := exec.Command(script, args...)
cmd.Dir = s.cfg.ServerDir
// PZ ищет сохранения относительно HOME, а нативные библиотеки — рядом с
// собой; обе переменные задаём явно, чтобы не зависеть от окружения
// systemd-юнита.
cmd.Env = append(os.Environ(),
"HOME="+filepath.Dir(s.cfg.ZomboidDir),
"LD_LIBRARY_PATH="+s.cfg.ServerDir+"/linux64:"+s.cfg.ServerDir+"/natives:"+os.Getenv("LD_LIBRARY_PATH"),
)
setProcessGroup(cmd)
stdin, err := cmd.StdinPipe()
if err != nil {
return fmt.Errorf("stdin: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("stdout: %w", err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
return fmt.Errorf("stderr: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("запустить %s: %w", script, err)
}
s.cmd = cmd
s.stdin = stdin
s.pgid = processGroupID(cmd.Process.Pid)
s.state = StateStarting
s.startedAt = time.Now()
s.lastExit = ""
s.intentionalStop = false
s.players = nil
s.exited = make(chan struct{})
s.sampler.Reset()
s.AppendLog("manager", fmt.Sprintf("Запуск сервера: %s %s (pid %d)",
script, strings.Join(args, " "), cmd.Process.Pid))
go s.readPipe(stdout, "stdout")
go s.readPipe(stderr, "stderr")
go s.wait(cmd, s.exited)
return nil
}
// readPipe построчно читает вывод сервера. Буфер увеличен: PZ иногда печатает
// длинные стек-трейсы одной строкой.
func (s *Supervisor) readPipe(r io.Reader, stream string) {
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
s.handleLine(stream, scanner.Text())
}
}
// handleLine разбирает строку вывода: ловит момент готовности сервера и
// ответы на команду players, остальное складывает в консоль.
func (s *Supervisor) handleLine(stream, text string) {
trimmed := strings.TrimRight(text, "\r")
s.mu.Lock()
hide := false
switch {
case s.capturing:
if stripped := strings.TrimSpace(trimmed); strings.HasPrefix(stripped, "-") {
s.captured = append(s.captured, strings.TrimPrefix(stripped, "-"))
s.players = append([]string{}, s.captured...)
hide = s.autoQuery
} else {
s.capturing = false
s.autoQuery = false
}
case strings.Contains(trimmed, "Players connected"):
s.capturing = true
s.captured = nil
s.players = nil
s.playersAt = time.Now()
hide = s.autoQuery
}
if s.state == StateStarting && isServerReady(trimmed) {
s.state = StateRunning
}
s.mu.Unlock()
// Автоопрос игроков не должен засорять консоль — команду отправили мы,
// а не пользователь.
if !hide {
s.AppendLog(stream, trimmed)
}
}
// isServerReady распознаёт строку, которой PZ сообщает о готовности принимать
// игроков.
func isServerReady(line string) bool {
return strings.Contains(line, "SERVER STARTED") ||
strings.Contains(line, "Server Steam ID")
}
// wait дожидается завершения процесса и решает, поднимать ли его заново.
func (s *Supervisor) wait(cmd *exec.Cmd, exited chan struct{}) {
err := cmd.Wait()
close(exited)
s.mu.Lock()
wasIntentional := s.intentionalStop
s.state = StateStopped
s.cmd = nil
s.stdin = nil
s.pgid = 0
s.players = nil
s.capturing = false
if err != nil {
s.lastExit = err.Error()
} else {
s.lastExit = "штатное завершение"
}
reason := s.lastExit
autoRestart := s.cfg.AutoRestart && !wasIntentional
s.mu.Unlock()
s.AppendLog("manager", "Сервер остановлен: "+reason)
if !autoRestart {
return
}
s.AppendLog("manager", fmt.Sprintf("Автоперезапуск через %s", restartDelay))
select {
case <-time.After(restartDelay):
case <-s.quit:
return
}
s.mu.Lock()
err = s.startLocked()
s.mu.Unlock()
if err != nil && !errors.Is(err, ErrAlreadyRunning) {
s.AppendLog("manager", "Автоперезапуск не удался: "+err.Error())
}
}
// Send отправляет команду в консоль сервера.
func (s *Supervisor) Send(command string) error {
command = strings.TrimSpace(command)
if command == "" {
return errors.New("пустая команда")
}
s.mu.Lock()
stdin, state := s.stdin, s.state
s.mu.Unlock()
if stdin == nil || state == StateStopped {
return ErrNotRunning
}
s.AppendLog("console", "> "+command)
if _, err := io.WriteString(stdin, command+"\n"); err != nil {
return fmt.Errorf("запись в консоль: %w", err)
}
return nil
}
// sendQuiet отправляет служебную команду, не показывая её в консоли панели.
func (s *Supervisor) sendQuiet(command string) error {
s.mu.Lock()
stdin := s.stdin
s.mu.Unlock()
if stdin == nil {
return ErrNotRunning
}
_, err := io.WriteString(stdin, command+"\n")
return err
}
// Stop корректно останавливает сервер: сначала команда quit (она сохраняет
// мир), затем SIGTERM группе процессов и в крайнем случае SIGKILL.
func (s *Supervisor) Stop() error {
s.mu.Lock()
if s.state == StateStopped || s.cmd == nil {
s.mu.Unlock()
return ErrNotRunning
}
s.state = StateStopping
s.intentionalStop = true
exited, pgid := s.exited, s.pgid
s.mu.Unlock()
s.AppendLog("manager", "Останавливаю сервер командой quit...")
if err := s.sendQuiet("quit"); err != nil {
s.AppendLog("manager", "Не удалось отправить quit: "+err.Error())
}
select {
case <-exited:
return nil
case <-time.After(s.cfg.StopTimeout.Duration()):
}
s.AppendLog("manager", "Сервер не ответил на quit — отправляю SIGTERM")
if err := signalGroup(pgid, sigTerm); err != nil {
s.AppendLog("manager", "SIGTERM не доставлен: "+err.Error())
}
select {
case <-exited:
return nil
case <-time.After(30 * time.Second):
}
s.AppendLog("manager", "Сервер завис — отправляю SIGKILL")
if err := signalGroup(pgid, sigKill); err != nil {
return fmt.Errorf("SIGKILL: %w", err)
}
<-exited
return nil
}
// Restart останавливает сервер и поднимает его заново.
func (s *Supervisor) Restart() error {
if s.State() != StateStopped {
if err := s.Stop(); err != nil && !errors.Is(err, ErrNotRunning) {
return err
}
// После Stop горутина wait ещё должна снять состояние.
s.WaitStopped(15 * time.Second)
}
return s.Start()
}
// WaitStopped ждёт, пока состояние станет stopped, но не дольше timeout.
// Возвращает true, если сервер действительно остановился.
func (s *Supervisor) WaitStopped(timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if s.State() == StateStopped {
return true
}
time.Sleep(100 * time.Millisecond)
}
return s.State() == StateStopped
}
// pollPlayers периодически спрашивает у работающего сервера, кто онлайн.
func (s *Supervisor) pollPlayers() {
ticker := time.NewTicker(playersPollInterval)
defer ticker.Stop()
for {
select {
case <-s.quit:
return
case <-ticker.C:
if s.State() != StateRunning {
continue
}
s.mu.Lock()
s.autoQuery = true
s.mu.Unlock()
if err := s.sendQuiet("players"); err != nil {
s.mu.Lock()
s.autoQuery = false
s.mu.Unlock()
}
}
}
}
+207
View File
@@ -0,0 +1,207 @@
// Package steamcmd ставит и обновляет серверные файлы Project Zomboid.
package steamcmd
import (
"archive/tar"
"bufio"
"compress/gzip"
"context"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path"
"path/filepath"
"strings"
"time"
)
// AppID выделенного сервера Project Zomboid в Steam.
const AppID = "380870"
// downloadURL — официальная сборка SteamCMD для Linux.
const downloadURL = "https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz"
// Client знает, где лежит steamcmd и куда ставить сервер.
type Client struct {
// Path — путь к steamcmd.sh. Пустой означает "найти или скачать".
Path string
// InstallRoot — куда положить SteamCMD, если его нет в системе.
InstallRoot string
}
// Resolve находит рабочий steamcmd: сначала явно заданный путь, потом PATH,
// потом ранее скачанная копия. Если ничего нет — возвращает пустую строку.
func (c *Client) Resolve() string {
if c.Path != "" {
if _, err := os.Stat(c.Path); err == nil {
return c.Path
}
}
if p, err := exec.LookPath("steamcmd"); err == nil {
return p
}
local := filepath.Join(c.InstallRoot, "steamcmd", "steamcmd.sh")
if _, err := os.Stat(local); err == nil {
return local
}
return ""
}
// Ensure возвращает путь к steamcmd, при необходимости скачивая его.
func (c *Client) Ensure(ctx context.Context, log func(string)) (string, error) {
if p := c.Resolve(); p != "" {
log("SteamCMD найден: " + p)
return p, nil
}
dir := filepath.Join(c.InstallRoot, "steamcmd")
log("SteamCMD не найден, скачиваю в " + dir)
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
if err := downloadAndExtract(ctx, downloadURL, dir); err != nil {
return "", fmt.Errorf("скачать SteamCMD: %w", err)
}
script := filepath.Join(dir, "steamcmd.sh")
if err := os.Chmod(script, 0o755); err != nil {
return "", err
}
// Бинарь, который дёргает скрипт, тоже должен быть исполняемым.
if bin := filepath.Join(dir, "linux32", "steamcmd"); fileExists(bin) {
_ = os.Chmod(bin, 0o755)
}
log("SteamCMD установлен: " + script)
return script, nil
}
// InstallOrUpdate ставит или обновляет серверные файлы в targetDir. Вывод
// SteamCMD построчно уходит в log.
func (c *Client) InstallOrUpdate(ctx context.Context, log func(string), targetDir string) error {
script, err := c.Ensure(ctx, log)
if err != nil {
return err
}
if err := os.MkdirAll(targetDir, 0o755); err != nil {
return err
}
// validate сверяет контрольные суммы: чуть дольше, но лечит битые файлы
// после падения диска или прерванной загрузки.
args := []string{
"+force_install_dir", targetDir,
"+login", "anonymous",
"+app_update", AppID, "validate",
"+quit",
}
log("Запускаю: " + script + " " + strings.Join(args, " "))
cmd := exec.CommandContext(ctx, script, args...)
cmd.Dir = filepath.Dir(script)
return runStreaming(cmd, log)
}
// runStreaming выполняет команду, отдавая её вывод построчно в log.
func runStreaming(cmd *exec.Cmd, log func(string)) error {
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
// Один канал на оба потока: SteamCMD пишет ошибки вперемешку с прогрессом,
// и разделять их в консоли панели незачем.
cmd.Stderr = cmd.Stdout
if err := cmd.Start(); err != nil {
return err
}
sc := bufio.NewScanner(stdout)
sc.Buffer(make([]byte, 0, 32*1024), 512*1024)
for sc.Scan() {
// SteamCMD рисует прогресс через \r; разворачиваем в отдельные строки.
for _, part := range strings.Split(sc.Text(), "\r") {
if part = strings.TrimSpace(part); part != "" {
log(part)
}
}
}
return cmd.Wait()
}
// downloadAndExtract скачивает tar.gz и распаковывает его в dest.
func downloadAndExtract(ctx context.Context, url, dest string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
client := &http.Client{Timeout: 10 * time.Minute}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %s", resp.Status)
}
gz, err := gzip.NewReader(resp.Body)
if err != nil {
return err
}
defer gz.Close()
tr := tar.NewReader(gz)
for {
hdr, err := tr.Next()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
target, err := safeJoin(dest, hdr.Name)
if err != nil {
return err
}
switch hdr.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
f, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(hdr.Mode))
if err != nil {
return err
}
if _, err := io.Copy(f, tr); err != nil {
f.Close()
return err
}
f.Close()
}
}
}
// safeJoin защищает от архивов с путями вида ../../etc/passwd. Сегменты
// проверяются до path.Clean: тот схлопнул бы выход за корень и скрыл его.
func safeJoin(root, name string) (string, error) {
slashed := filepath.ToSlash(name)
if path.IsAbs(slashed) {
return "", fmt.Errorf("абсолютный путь в архиве: %s", name)
}
for _, segment := range strings.Split(slashed, "/") {
if segment == ".." {
return "", fmt.Errorf("небезопасный путь в архиве: %s", name)
}
}
return filepath.Join(root, filepath.FromSlash(path.Clean(slashed))), nil
}
func fileExists(p string) bool {
_, err := os.Stat(p)
return err == nil
}
+42
View File
@@ -0,0 +1,42 @@
// Package sysinfo снимает метрики хоста и группы процессов игрового сервера.
package sysinfo
import "sync"
// ProcStats — потребление ресурсов одной группой процессов.
type ProcStats struct {
// CPUPercent — доля CPU за интервал между двумя снимками; 100% = одно ядро.
CPUPercent float64 `json:"cpu_percent"`
// RSSBytes — резидентная память всех процессов группы.
RSSBytes int64 `json:"rss_bytes"`
// Threads — суммарное число потоков.
Threads int `json:"threads"`
}
// HostStats — общее состояние машины.
type HostStats struct {
MemTotalBytes int64 `json:"mem_total_bytes"`
MemAvailableBytes int64 `json:"mem_available_bytes"`
Load1 float64 `json:"load1"`
CPUCount int `json:"cpu_count"`
DiskTotalBytes int64 `json:"disk_total_bytes"`
DiskFreeBytes int64 `json:"disk_free_bytes"`
}
// Sampler считает CPU по разнице между вызовами, поэтому хранит прошлый снимок.
type Sampler struct {
mu sync.Mutex
lastCPU float64 // накопленное CPU-время группы, секунды
lastWall float64 // момент снятия, секунды монотонных часов
}
// NewSampler создаёт счётчик метрик для одной группы процессов.
func NewSampler() *Sampler { return &Sampler{} }
// Reset забывает предыдущий снимок — вызывается при перезапуске сервера,
// иначе первый замер нового процесса посчитается от чужой базы.
func (s *Sampler) Reset() {
s.mu.Lock()
s.lastCPU, s.lastWall = 0, 0
s.mu.Unlock()
}
+156
View File
@@ -0,0 +1,156 @@
//go:build linux
package sysinfo
import (
"os"
"path/filepath"
"strconv"
"strings"
"golang.org/x/sys/unix"
)
var pageSize = int64(os.Getpagesize())
// clockTicks — USER_HZ. На всех практически встречающихся Linux-сборках 100.
const clockTicks = 100.0
// Sample обходит /proc и суммирует статистику всех процессов, входящих в
// группу pgid: сервер PZ — это скрипт-обёртка плюс порождённая им JVM.
func (s *Sampler) Sample(pgid int) ProcStats {
var (
stats ProcStats
cpuSecs float64
)
if pgid <= 0 {
s.Reset()
return stats
}
entries, err := os.ReadDir("/proc")
if err != nil {
return stats
}
for _, e := range entries {
if !e.IsDir() {
continue
}
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
utime, stime, threads, group, ok := readProcStat(pid)
if !ok || group != pgid {
continue
}
cpuSecs += (utime + stime) / clockTicks
stats.Threads += threads
stats.RSSBytes += readRSS(pid)
}
now := uptimeSeconds()
s.mu.Lock()
if s.lastWall > 0 && now > s.lastWall {
stats.CPUPercent = (cpuSecs - s.lastCPU) / (now - s.lastWall) * 100
if stats.CPUPercent < 0 {
stats.CPUPercent = 0
}
}
s.lastCPU, s.lastWall = cpuSecs, now
s.mu.Unlock()
return stats
}
// readProcStat разбирает /proc/<pid>/stat. Имя процесса заключено в скобки и
// может содержать пробелы, поэтому режем строку после последней ')'.
func readProcStat(pid int) (utime, stime float64, threads, pgrp int, ok bool) {
raw, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "stat"))
if err != nil {
return 0, 0, 0, 0, false
}
line := string(raw)
idx := strings.LastIndex(line, ")")
if idx < 0 || idx+2 >= len(line) {
return 0, 0, 0, 0, false
}
// Поля после comm: state(3) ppid(4) pgrp(5) ... utime(14) stime(15) ... num_threads(20).
fields := strings.Fields(line[idx+2:])
if len(fields) < 18 {
return 0, 0, 0, 0, false
}
pgrp, _ = strconv.Atoi(fields[2])
utime, _ = strconv.ParseFloat(fields[11], 64)
stime, _ = strconv.ParseFloat(fields[12], 64)
threads, _ = strconv.Atoi(fields[17])
return utime, stime, threads, pgrp, true
}
// readRSS берёт резидентные страницы из /proc/<pid>/statm (второе поле).
func readRSS(pid int) int64 {
raw, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "statm"))
if err != nil {
return 0
}
fields := strings.Fields(string(raw))
if len(fields) < 2 {
return 0
}
pages, _ := strconv.ParseInt(fields[1], 10, 64)
return pages * pageSize
}
func uptimeSeconds() float64 {
raw, err := os.ReadFile("/proc/uptime")
if err != nil {
return 0
}
fields := strings.Fields(string(raw))
if len(fields) == 0 {
return 0
}
v, _ := strconv.ParseFloat(fields[0], 64)
return v
}
// Host собирает состояние машины; diskPath задаёт раздел для расчёта места.
func Host(diskPath string) HostStats {
h := HostStats{CPUCount: cpuCount()}
if raw, err := os.ReadFile("/proc/meminfo"); err == nil {
for _, line := range strings.Split(string(raw), "\n") {
key, value, found := strings.Cut(line, ":")
if !found {
continue
}
kb, _ := strconv.ParseInt(strings.Fields(strings.TrimSpace(value))[0], 10, 64)
switch key {
case "MemTotal":
h.MemTotalBytes = kb * 1024
case "MemAvailable":
h.MemAvailableBytes = kb * 1024
}
}
}
if raw, err := os.ReadFile("/proc/loadavg"); err == nil {
if fields := strings.Fields(string(raw)); len(fields) > 0 {
h.Load1, _ = strconv.ParseFloat(fields[0], 64)
}
}
if diskPath != "" {
var st unix.Statfs_t
if err := unix.Statfs(diskPath, &st); err == nil {
h.DiskTotalBytes = int64(st.Blocks) * int64(st.Bsize)
h.DiskFreeBytes = int64(st.Bavail) * int64(st.Bsize)
}
}
return h
}
func cpuCount() int {
raw, err := os.ReadFile("/proc/cpuinfo")
if err != nil {
return 0
}
return strings.Count(string(raw), "processor\t:")
}
+14
View File
@@ -0,0 +1,14 @@
//go:build !linux
package sysinfo
import "runtime"
// Sample на не-Linux платформах не читает /proc: менеджер рассчитан на
// Ubuntu, а сборка под другие ОС нужна только для разработки.
func (s *Sampler) Sample(pgid int) ProcStats { return ProcStats{} }
// Host отдаёт лишь число ядер — остальное платформозависимо.
func Host(diskPath string) HostStats {
return HostStats{CPUCount: runtime.NumCPU()}
}