Менеджер запускает сервер 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>
265 lines
7.0 KiB
Go
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
|
|
}
|