Files
pzmanager/cmd/pzmanager/main.go
T
mrleo1nidandClaude Opus 5 e25294db16
CI / test (push) Successful in 21s
Release / release (push) Successful in 29s
Код настройки печатает установщик, появилось удаление панели
Менеджер кладёт код первичной настройки в data/setup-token, установщик
дожидается файла и печатает код последним сообщением — за ним больше не
нужно идти в journalctl. Файл удаляется, как только создан администратор,
и не появляется на уже настроенной панели.

Установщик научился снимать панель с машины: --uninstall убирает службу,
бинарники и правило sudo, --purge вдобавок сносит каталог с мирами,
бэкапами и учётками. Данные удаляются только по явному подтверждению, а
каталог сносится, лишь если похож на установку панели: опечатка в PZ_HOME
не должна стоить чужих файлов. Копия установщика теперь лежит в
/opt/pzmanager, так что удалять можно без похода в репозиторий.

get.sh пробрасывает аргументы установщику и в режиме удаления не качает
бинарник. Заодно исправлена подстановка пустого массива аргументов:
"${ARGS[@]:-}" разворачивается в один пустой аргумент, из-за чего обычная
установка спотыкалась бы о него.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 20:03:13 +03:00

325 lines
9.9 KiB
Go

// Команда pzmanager — веб-панель управления сервером Project Zomboid.
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"flag"
"fmt"
"net"
"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"
)
// defaultRepo — откуда панель узнаёт о новых версиях. Меняется переменной
// окружения PZMANAGER_REPO, если репозиторий переехал или это форк.
const defaultRepo = "https://gitea.hsrv.site/mrleo1nid/pzmanager"
// version подставляется при сборке: -ldflags "-X main.version=v1.2.3".
// В сборке из рабочей копии остаётся "dev".
var version = "dev"
const usage = `pzmanager — панель управления сервером Project Zomboid.
Использование:
pzmanager [serve] запустить панель (команда по умолчанию)
pzmanager useradd <логин> создать пользователя панели
pzmanager passwd <логин> сменить пароль
pzmanager users показать список пользователей
pzmanager version показать версию
Флаги:
`
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 command == "version" || command == "--version" {
fmt.Println("pzmanager", version)
return
}
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 := ""
setupTokenFile := filepath.Join(cfg.DataDir, "setup-token")
if users.IsEmpty() {
setupToken = randomToken()
// Код кладём и в файл: установщик читает его оттуда и показывает сразу
// после установки, не заставляя идти в журнал.
if err := os.WriteFile(setupTokenFile, []byte(setupToken+"\n"), 0o600); err != nil {
fmt.Fprintf(os.Stderr, "не удалось записать %s: %v\n", setupTokenFile, err)
}
fmt.Printf("\n Панель ещё не настроена.\n"+
" Откройте её в браузере и введите код первичной настройки:\n\n %s\n\n", setupToken)
} else {
// Панель уже настроена: файл мог остаться от прошлой установки.
os.Remove(setupTokenFile)
}
apiServer := api.New(api.Options{
Version: version,
Config: cfg,
Users: users,
Supervisor: supervisor,
Jobs: jobs,
Backups: backups,
Steam: steam,
Static: static,
SecureCookies: secureCookies,
SetupToken: setupToken,
SetupTokenFile: setupTokenFile,
Repo: repoURL(),
})
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,
}
// Адрес занимаем до автозапуска игрового сервера: если его не поднять
// (IP ещё не назначен, порт занят), лучше упасть сразу, чем оставить
// работающий сервер PZ без панели управления.
listener, err := net.Listen("tcp", cfg.Listen)
if err != nil {
return fmt.Errorf("не удалось занять адрес %s: %w", cfg.Listen, err)
}
fmt.Printf("pzmanager %s слушает http://%s\n", version, cfg.Listen)
if cfg.AutoStart {
if err := supervisor.Start(); err != nil {
supervisor.AppendLog("manager", "Автозапуск не удался: "+err.Error())
}
}
errCh := make(chan error, 1)
go func() {
if err := httpServer.Serve(listener); 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
}
// repoURL возвращает адрес репозитория для проверки обновлений.
func repoURL() string {
if env := os.Getenv("PZMANAGER_REPO"); env != "" {
return env
}
return defaultRepo
}
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)
}