Веб-панель управления сервером 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,264 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user