Веб-панель управления сервером 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,280 @@
|
||||
// Команда pzmanager — веб-панель управления сервером Project Zomboid.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"golang.org/x/term"
|
||||
|
||||
"github.com/mrleo1nid/pzmanager/internal/api"
|
||||
"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/pzserver"
|
||||
"github.com/mrleo1nid/pzmanager/internal/steamcmd"
|
||||
"github.com/mrleo1nid/pzmanager/web"
|
||||
)
|
||||
|
||||
const usage = `pzmanager — панель управления сервером Project Zomboid.
|
||||
|
||||
Использование:
|
||||
pzmanager [serve] запустить панель (команда по умолчанию)
|
||||
pzmanager useradd <логин> создать пользователя панели
|
||||
pzmanager passwd <логин> сменить пароль
|
||||
pzmanager users показать список пользователей
|
||||
|
||||
Флаги:
|
||||
`
|
||||
|
||||
func main() {
|
||||
fs := flag.NewFlagSet("pzmanager", flag.ExitOnError)
|
||||
configPath := fs.String("config", defaultConfigPath(), "путь к config.yaml")
|
||||
listen := fs.String("listen", "", "адрес прослушивания (перекрывает конфиг)")
|
||||
secureCookies := fs.Bool("secure-cookies", false,
|
||||
"ставить флаг Secure у cookie сессии (панель работает по HTTPS)")
|
||||
fs.Usage = func() {
|
||||
fmt.Fprint(os.Stderr, usage)
|
||||
fs.PrintDefaults()
|
||||
}
|
||||
|
||||
command := "serve"
|
||||
args := os.Args[1:]
|
||||
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
|
||||
command, args = args[0], args[1:]
|
||||
}
|
||||
if err := fs.Parse(args); err != nil {
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
cfg, err := config.Load(*configPath)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
if *listen != "" {
|
||||
cfg.Listen = *listen
|
||||
}
|
||||
if err := cfg.EnsureDirs(); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
|
||||
users, err := auth.NewStore(filepath.Join(cfg.DataDir, "users.json"), 12*time.Hour)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
|
||||
switch command {
|
||||
case "serve":
|
||||
if err := serve(cfg, users, *secureCookies); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
case "useradd":
|
||||
if err := cmdUserAdd(users, fs.Args()); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
case "passwd":
|
||||
if err := cmdPasswd(users, fs.Args()); err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
case "users":
|
||||
for _, login := range users.Logins() {
|
||||
fmt.Println(login)
|
||||
}
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "неизвестная команда %q\n\n", command)
|
||||
fs.Usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
func serve(cfg *config.Config, users *auth.Store, secureCookies bool) error {
|
||||
static, err := web.Static()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
supervisor := pzserver.New(cfg)
|
||||
defer supervisor.Close()
|
||||
|
||||
jobs := job.New(supervisor.AppendLog)
|
||||
backups := backup.New(cfg)
|
||||
steam := &steamcmd.Client{Path: cfg.SteamCMDPath, InstallRoot: cfg.DataDir}
|
||||
|
||||
// Пока учёток нет, создать первого администратора можно только с этим
|
||||
// кодом — иначе панель захватит любой, кто откроет её первым.
|
||||
setupToken := ""
|
||||
if users.IsEmpty() {
|
||||
setupToken = randomToken()
|
||||
fmt.Printf("\n Панель ещё не настроена.\n"+
|
||||
" Откройте её в браузере и введите код первичной настройки:\n\n %s\n\n", setupToken)
|
||||
}
|
||||
|
||||
apiServer := api.New(api.Options{
|
||||
Config: cfg,
|
||||
Users: users,
|
||||
Supervisor: supervisor,
|
||||
Jobs: jobs,
|
||||
Backups: backups,
|
||||
Steam: steam,
|
||||
Static: static,
|
||||
SecureCookies: secureCookies,
|
||||
SetupToken: setupToken,
|
||||
})
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
api.StartBackupScheduler(ctx, apiServer)
|
||||
go sessionGC(ctx, users)
|
||||
|
||||
httpServer := &http.Server{
|
||||
Addr: cfg.Listen,
|
||||
Handler: apiServer.Handler(),
|
||||
// Записи без таймаута: SSE-поток логов живёт, пока открыта вкладка.
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
if cfg.AutoStart {
|
||||
if err := supervisor.Start(); err != nil {
|
||||
supervisor.AppendLog("manager", "Автозапуск не удался: "+err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
fmt.Printf("pzmanager слушает http://%s\n", cfg.Listen)
|
||||
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
errCh <- err
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
}
|
||||
|
||||
fmt.Println("\nЗавершение работы…")
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := httpServer.Shutdown(shutdownCtx); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "HTTP-сервер закрыт принудительно:", err)
|
||||
}
|
||||
|
||||
// Игровой сервер останавливаем последним: это самая долгая часть, зато
|
||||
// мир гарантированно сохранён.
|
||||
if supervisor.State() != pzserver.StateStopped {
|
||||
fmt.Println("Останавливаю игровой сервер (это может занять пару минут)…")
|
||||
if err := supervisor.Stop(); err != nil && !errors.Is(err, pzserver.ErrNotRunning) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sessionGC периодически чистит протухшие сессии.
|
||||
func sessionGC(ctx context.Context, users *auth.Store) {
|
||||
ticker := time.NewTicker(30 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
users.GC()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cmdUserAdd(users *auth.Store, args []string) error {
|
||||
if len(args) != 1 {
|
||||
return errors.New("укажите логин: pzmanager useradd <логин>")
|
||||
}
|
||||
password, err := readPassword("Пароль: ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
repeat, err := readPassword("Ещё раз: ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if password != repeat {
|
||||
return errors.New("пароли не совпадают")
|
||||
}
|
||||
if err := users.AddUser(args[0], password); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("Пользователь создан:", args[0])
|
||||
return nil
|
||||
}
|
||||
|
||||
func cmdPasswd(users *auth.Store, args []string) error {
|
||||
if len(args) != 1 {
|
||||
return errors.New("укажите логин: pzmanager passwd <логин>")
|
||||
}
|
||||
password, err := readPassword("Новый пароль: ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := users.SetPassword(args[0], password); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("Пароль изменён:", args[0])
|
||||
return nil
|
||||
}
|
||||
|
||||
// readPassword читает пароль без эха, если запущено в терминале.
|
||||
func readPassword(prompt string) (string, error) {
|
||||
fmt.Print(prompt)
|
||||
fd := int(syscall.Stdin)
|
||||
if !term.IsTerminal(fd) {
|
||||
var line string
|
||||
_, err := fmt.Scanln(&line)
|
||||
return line, err
|
||||
}
|
||||
raw, err := term.ReadPassword(fd)
|
||||
fmt.Println()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
func randomToken() string {
|
||||
buf := make([]byte, 12)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
// Практически недостижимо; лучше упасть, чем выдать предсказуемый код.
|
||||
fatal(err)
|
||||
}
|
||||
return hex.EncodeToString(buf)
|
||||
}
|
||||
|
||||
// defaultConfigPath — ~/.config/pzmanager/config.yaml.
|
||||
func defaultConfigPath() string {
|
||||
if env := os.Getenv("PZMANAGER_CONFIG"); env != "" {
|
||||
return env
|
||||
}
|
||||
dir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "config.yaml"
|
||||
}
|
||||
return filepath.Join(dir, "pzmanager", "config.yaml")
|
||||
}
|
||||
|
||||
func fatal(err error) {
|
||||
fmt.Fprintln(os.Stderr, "pzmanager:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
Reference in New Issue
Block a user