Files
pzmanager/cmd/pzmanager/main.go
T
mrleo1nidandClaude Opus 5 d0ff64c272 Веб-панель управления сервером 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>
2026-09-03 22:41:11 +03:00

281 lines
7.7 KiB
Go

// Команда 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)
}