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