Files
pzmanager/internal/auth/auth.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

265 lines
7.0 KiB
Go

// Package auth хранит учётные записи панели и живые сессии.
package auth
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
)
// SessionCookie — имя cookie с идентификатором сессии.
const SessionCookie = "pzm_session"
// Ошибки, которые вызывающему коду имеет смысл различать.
var (
ErrBadCredentials = errors.New("неверный логин или пароль")
ErrUserExists = errors.New("пользователь уже существует")
ErrUserNotFound = errors.New("пользователь не найден")
)
// User — учётная запись веб-панели.
type User struct {
Login string `json:"login"`
Hash string `json:"hash"`
CreatedAt time.Time `json:"created_at"`
}
type session struct {
login string
expiresAt time.Time
}
// Store — потокобезопасное хранилище пользователей (файл на диске) и
// сессий (только в памяти: перезапуск менеджера разлогинивает всех).
type Store struct {
path string
ttl time.Duration
mu sync.RWMutex
users map[string]User
sessions map[string]session
}
// NewStore открывает файл с пользователями, создавая пустой при первом запуске.
func NewStore(path string, ttl time.Duration) (*Store, error) {
s := &Store{
path: path,
ttl: ttl,
users: make(map[string]User),
sessions: make(map[string]session),
}
if err := s.load(); err != nil {
return nil, err
}
return s, nil
}
func (s *Store) load() error {
raw, err := os.ReadFile(s.path)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return fmt.Errorf("прочитать %s: %w", s.path, err)
}
var users []User
if err := json.Unmarshal(raw, &users); err != nil {
return fmt.Errorf("разобрать %s: %w", s.path, err)
}
for _, u := range users {
s.users[u.Login] = u
}
return nil
}
// saveLocked пишет пользователей на диск; вызывается под удержанным s.mu.
func (s *Store) saveLocked() error {
users := make([]User, 0, len(s.users))
for _, u := range s.users {
users = append(users, u)
}
raw, err := json.MarshalIndent(users, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
return err
}
tmp := s.path + ".tmp"
// 0600: файл содержит хеши паролей.
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
// IsEmpty сообщает, что учёток нет и панель нужно инициализировать.
func (s *Store) IsEmpty() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.users) == 0
}
// Logins возвращает отсортированный список логинов.
func (s *Store) Logins() []string {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]string, 0, len(s.users))
for login := range s.users {
out = append(out, login)
}
return out
}
// AddUser создаёт учётку. Пароль хешируется bcrypt'ом.
func (s *Store) AddUser(login, password string) error {
login = strings.TrimSpace(login)
if login == "" {
return errors.New("логин не может быть пустым")
}
if len(password) < 8 {
return errors.New("пароль короче 8 символов")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.users[login]; exists {
return ErrUserExists
}
s.users[login] = User{Login: login, Hash: string(hash), CreatedAt: time.Now()}
return s.saveLocked()
}
// SetPassword меняет пароль существующей учётки.
func (s *Store) SetPassword(login, password string) error {
if len(password) < 8 {
return errors.New("пароль короче 8 символов")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
u, ok := s.users[login]
if !ok {
return ErrUserNotFound
}
u.Hash = string(hash)
s.users[login] = u
return s.saveLocked()
}
// DeleteUser удаляет учётку и разлогинивает её сессии.
func (s *Store) DeleteUser(login string) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.users[login]; !ok {
return ErrUserNotFound
}
delete(s.users, login)
for id, sess := range s.sessions {
if sess.login == login {
delete(s.sessions, id)
}
}
return s.saveLocked()
}
// Login проверяет пару логин/пароль и заводит сессию, возвращая её id.
func (s *Store) Login(login, password string) (string, error) {
s.mu.RLock()
u, ok := s.users[login]
s.mu.RUnlock()
if !ok {
// Считаем фиктивный хеш, чтобы по времени ответа нельзя было
// отличить несуществующий логин от неверного пароля.
bcrypt.CompareHashAndPassword([]byte("$2a$10$"+strings.Repeat("x", 53)), []byte(password))
return "", ErrBadCredentials
}
if err := bcrypt.CompareHashAndPassword([]byte(u.Hash), []byte(password)); err != nil {
return "", ErrBadCredentials
}
id, err := randomID()
if err != nil {
return "", err
}
s.mu.Lock()
s.sessions[id] = session{login: login, expiresAt: time.Now().Add(s.ttl)}
s.mu.Unlock()
return id, nil
}
// Logout закрывает конкретную сессию.
func (s *Store) Logout(id string) {
s.mu.Lock()
delete(s.sessions, id)
s.mu.Unlock()
}
// Validate возвращает логин владельца сессии, продлевая её срок жизни.
func (s *Store) Validate(id string) (string, bool) {
if id == "" {
return "", false
}
s.mu.Lock()
defer s.mu.Unlock()
sess, ok := s.sessions[id]
if !ok {
return "", false
}
if time.Now().After(sess.expiresAt) {
delete(s.sessions, id)
return "", false
}
sess.expiresAt = time.Now().Add(s.ttl)
s.sessions[id] = sess
return sess.login, true
}
// GC выкидывает протухшие сессии; вызывается по таймеру.
func (s *Store) GC() {
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
for id, sess := range s.sessions {
if now.After(sess.expiresAt) {
delete(s.sessions, id)
}
}
}
// SessionFromRequest достаёт логин из cookie запроса.
func (s *Store) SessionFromRequest(r *http.Request) (string, bool) {
c, err := r.Cookie(SessionCookie)
if err != nil {
return "", false
}
return s.Validate(c.Value)
}
func randomID() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}