Веб-панель управления сервером 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,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