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