feat: implement full backend + frontend server detail, settings, and create server pages
Backend: - Complete FastAPI backend with 42+ REST endpoints (auth, servers, config, players, bans, missions, mods, games, system) - Game adapter architecture with Arma 3 as first-class adapter - WebSocket real-time events for status, metrics, logs, players - Background thread system (process monitor, metrics, log tail, RCon poller) - Fernet encryption for sensitive config fields at rest - JWT auth with admin/viewer roles, bcrypt password hashing - SQLite with WAL mode, parameterized queries, migration system - APScheduler cleanup jobs for logs, metrics, events Frontend: - Server Detail page with 7 tabs (overview, config, players, bans, missions, mods, logs) - Settings page with password change and admin user management - Create Server wizard (4-step; known bug: silent validation failure) - New hooks: useServerDetail, useAuth, useGames - New components: ServerHeader, ConfigEditor, PlayerTable, BanTable, MissionList, ModList, LogViewer, PasswordChange, UserManager - WebSocket onEvent callback for real-time log accumulation - 120 unit tests passing (Vitest + React Testing Library) Docs: - Added .gitignore, CLAUDE.md, README.md - Updated FRONTEND.md, ARCHITECTURE.md with current implementation state - Added .env.example for backend configuration Known issues: - Create Server form: "Next" buttons don't validate before advancing, causing silent submit failure when fields are invalid - Config sub-tabs need UX redesign for non-technical users
This commit is contained in:
0
backend/core/servers/__init__.py
Normal file
0
backend/core/servers/__init__.py
Normal file
142
backend/core/servers/bans_router.py
Normal file
142
backend/core/servers/bans_router.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""Ban management endpoints — create, list, and revoke bans."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, field_validator
|
||||
from sqlalchemy.engine import Connection
|
||||
|
||||
from adapters.arma3.ban_manager import Arma3BanManager
|
||||
from core.dal.ban_repository import BanRepository
|
||||
from core.servers.service import ServerService
|
||||
from database import get_db
|
||||
from dependencies import get_current_user, require_admin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/servers/{server_id}/bans", tags=["bans"])
|
||||
|
||||
|
||||
def _ok(data):
|
||||
return {"success": True, "data": data, "error": None}
|
||||
|
||||
|
||||
class CreateBanRequest(BaseModel):
|
||||
player_uid: str
|
||||
ban_type: str = "GUID"
|
||||
reason: str = ""
|
||||
duration_minutes: int = 0 # 0 = permanent
|
||||
|
||||
@field_validator("ban_type")
|
||||
@classmethod
|
||||
def validate_ban_type(cls, v: str) -> str:
|
||||
if v not in ("GUID", "IP"):
|
||||
raise ValueError("ban_type must be 'GUID' or 'IP'")
|
||||
return v
|
||||
|
||||
@field_validator("duration_minutes")
|
||||
@classmethod
|
||||
def validate_duration(cls, v: int) -> int:
|
||||
if v < 0:
|
||||
raise ValueError("duration_minutes cannot be negative")
|
||||
return v
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_bans(
|
||||
server_id: int,
|
||||
db: Annotated[Connection, Depends(get_db)],
|
||||
_user: Annotated[dict, Depends(get_current_user)],
|
||||
) -> dict:
|
||||
"""List all active bans for the server."""
|
||||
ServerService(db).get_server(server_id) # raises 404 if not found
|
||||
ban_repo = BanRepository(db)
|
||||
bans = ban_repo.get_all(server_id=server_id)
|
||||
return _ok(bans)
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
def create_ban(
|
||||
server_id: int,
|
||||
body: CreateBanRequest,
|
||||
db: Annotated[Connection, Depends(get_db)],
|
||||
_admin: Annotated[dict, Depends(require_admin)],
|
||||
) -> dict:
|
||||
"""Create a new ban. Writes to DB and syncs to bans.txt."""
|
||||
ServerService(db).get_server(server_id) # raises 404 if not found
|
||||
ban_repo = BanRepository(db)
|
||||
|
||||
# Calculate expires_at if duration is set
|
||||
expires_at = None
|
||||
if body.duration_minutes > 0:
|
||||
from datetime import datetime, timezone, timedelta
|
||||
expires_at = (
|
||||
datetime.now(timezone.utc) + timedelta(minutes=body.duration_minutes)
|
||||
).isoformat()
|
||||
|
||||
ban_id = ban_repo.create(
|
||||
server_id=server_id,
|
||||
guid=body.player_uid if body.ban_type == "GUID" else None,
|
||||
name=None,
|
||||
reason=body.reason,
|
||||
banned_by=_admin["username"],
|
||||
expires_at=expires_at,
|
||||
game_data={"ban_type": body.ban_type, "duration_minutes": body.duration_minutes},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
ban = ban_repo.get_by_id(ban_id)
|
||||
|
||||
# Sync to bans.txt (non-blocking — log error but don't fail request)
|
||||
_sync_ban_to_file(server_id, body.player_uid, body.ban_type, body.reason, body.duration_minutes)
|
||||
|
||||
return _ok(ban)
|
||||
|
||||
|
||||
@router.delete("/{ban_id}")
|
||||
def revoke_ban(
|
||||
server_id: int,
|
||||
ban_id: int,
|
||||
db: Annotated[Connection, Depends(get_db)],
|
||||
_admin: Annotated[dict, Depends(require_admin)],
|
||||
) -> dict:
|
||||
"""Revoke a ban (marks as inactive in DB, removes from bans.txt)."""
|
||||
ServerService(db).get_server(server_id) # raises 404 if not found
|
||||
ban_repo = BanRepository(db)
|
||||
ban = ban_repo.get_by_id(ban_id)
|
||||
if ban is None or ban["server_id"] != server_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"code": "NOT_FOUND", "message": "Ban not found"},
|
||||
)
|
||||
ban_repo.deactivate(ban_id)
|
||||
db.commit()
|
||||
|
||||
# Remove from bans.txt
|
||||
_remove_ban_from_file(server_id, ban.get("guid") or "")
|
||||
|
||||
return _ok({"message": f"Ban {ban_id} revoked"})
|
||||
|
||||
|
||||
# ── File sync helpers ──
|
||||
|
||||
def _sync_ban_to_file(
|
||||
server_id: int, identifier: str, ban_type: str, reason: str, duration_minutes: int
|
||||
) -> None:
|
||||
"""Write ban to bans.txt. Log error but don't fail the request."""
|
||||
try:
|
||||
mgr = Arma3BanManager(server_id)
|
||||
mgr.add_ban(identifier, ban_type, reason, duration_minutes)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to sync ban to bans.txt for server %d: %s", server_id, exc)
|
||||
|
||||
|
||||
def _remove_ban_from_file(server_id: int, identifier: str) -> None:
|
||||
"""Remove ban from bans.txt. Log error but don't fail the request."""
|
||||
try:
|
||||
mgr = Arma3BanManager(server_id)
|
||||
mgr.remove_ban(identifier)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to remove ban from bans.txt for server %d: %s", server_id, exc)
|
||||
115
backend/core/servers/missions_router.py
Normal file
115
backend/core/servers/missions_router.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""Mission management endpoints — list, upload, delete mission files."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status
|
||||
from sqlalchemy.engine import Connection
|
||||
|
||||
from adapters.exceptions import AdapterError
|
||||
from adapters.registry import GameAdapterRegistry
|
||||
from core.servers.service import ServerService
|
||||
from database import get_db
|
||||
from dependencies import get_current_user, require_admin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/servers/{server_id}/missions", tags=["missions"])
|
||||
|
||||
_MAX_UPLOAD_SIZE = 500 * 1024 * 1024 # 500 MB
|
||||
|
||||
|
||||
def _ok(data):
|
||||
return {"success": True, "data": data, "error": None}
|
||||
|
||||
|
||||
def _get_mission_manager(server_id: int, game_type: str):
|
||||
"""Get MissionManager for the server's game type."""
|
||||
adapter = GameAdapterRegistry.get(game_type)
|
||||
if not adapter.has_capability("mission_manager"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"code": "NOT_SUPPORTED", "message": f"Game type '{game_type}' does not support mission management"},
|
||||
)
|
||||
return adapter.get_mission_manager(server_id)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_missions(
|
||||
server_id: int,
|
||||
db: Annotated[Connection, Depends(get_db)],
|
||||
_user: Annotated[dict, Depends(get_current_user)],
|
||||
) -> dict:
|
||||
"""List all available mission files on disk."""
|
||||
server = ServerService(db).get_server(server_id) # raises 404 if not found
|
||||
mgr = _get_mission_manager(server_id, server["game_type"])
|
||||
try:
|
||||
missions = mgr.list_missions()
|
||||
except AdapterError as exc:
|
||||
raise HTTPException(status_code=500, detail={"code": "ADAPTER_ERROR", "message": str(exc)})
|
||||
|
||||
return _ok({
|
||||
"server_id": server_id,
|
||||
"missions": missions,
|
||||
"total": len(missions),
|
||||
})
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def upload_mission(
|
||||
server_id: int,
|
||||
db: Annotated[Connection, Depends(get_db)],
|
||||
_admin: Annotated[dict, Depends(require_admin)],
|
||||
file: UploadFile = File(...),
|
||||
) -> dict:
|
||||
"""
|
||||
Upload a mission .pbo file.
|
||||
Max size: 500 MB.
|
||||
"""
|
||||
server = ServerService(db).get_server(server_id) # raises 404 if not found
|
||||
|
||||
if not file.filename:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"code": "NO_FILENAME", "message": "No filename provided"},
|
||||
)
|
||||
|
||||
content = await file.read()
|
||||
if len(content) > _MAX_UPLOAD_SIZE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail={"code": "FILE_TOO_LARGE", "message": f"File too large. Max size is {_MAX_UPLOAD_SIZE // (1024*1024)} MB"},
|
||||
)
|
||||
|
||||
mgr = _get_mission_manager(server_id, server["game_type"])
|
||||
try:
|
||||
mission = mgr.upload_mission(file.filename, content)
|
||||
except AdapterError as exc:
|
||||
raise HTTPException(status_code=400, detail={"code": "ADAPTER_ERROR", "message": str(exc)})
|
||||
|
||||
return _ok(mission)
|
||||
|
||||
|
||||
@router.delete("/{filename}")
|
||||
def delete_mission(
|
||||
server_id: int,
|
||||
filename: str,
|
||||
db: Annotated[Connection, Depends(get_db)],
|
||||
_admin: Annotated[dict, Depends(require_admin)],
|
||||
) -> dict:
|
||||
"""Delete a mission file by filename."""
|
||||
server = ServerService(db).get_server(server_id) # raises 404 if not found
|
||||
mgr = _get_mission_manager(server_id, server["game_type"])
|
||||
try:
|
||||
deleted = mgr.delete_mission(filename)
|
||||
except AdapterError as exc:
|
||||
raise HTTPException(status_code=400, detail={"code": "ADAPTER_ERROR", "message": str(exc)})
|
||||
|
||||
if not deleted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"code": "NOT_FOUND", "message": f"Mission '{filename}' not found"},
|
||||
)
|
||||
|
||||
return _ok({"message": f"Mission '{filename}' deleted"})
|
||||
101
backend/core/servers/mods_router.py
Normal file
101
backend/core/servers/mods_router.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""Mod management endpoints — list available mods, set enabled mods."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.engine import Connection
|
||||
|
||||
from adapters.exceptions import AdapterError
|
||||
from adapters.registry import GameAdapterRegistry
|
||||
from core.dal.config_repository import ConfigRepository
|
||||
from core.servers.service import ServerService
|
||||
from database import get_db
|
||||
from dependencies import get_current_user, require_admin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/servers/{server_id}/mods", tags=["mods"])
|
||||
|
||||
|
||||
def _ok(data):
|
||||
return {"success": True, "data": data, "error": None}
|
||||
|
||||
|
||||
class SetEnabledModsRequest(BaseModel):
|
||||
mods: list[str]
|
||||
|
||||
|
||||
def _get_mod_manager(server_id: int, game_type: str):
|
||||
"""Get ModManager for the server's game type."""
|
||||
adapter = GameAdapterRegistry.get(game_type)
|
||||
if not adapter.has_capability("mod_manager"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"code": "NOT_SUPPORTED", "message": f"Game type '{game_type}' does not support mod management"},
|
||||
)
|
||||
return adapter.get_mod_manager(server_id)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_mods(
|
||||
server_id: int,
|
||||
db: Annotated[Connection, Depends(get_db)],
|
||||
_user: Annotated[dict, Depends(get_current_user)],
|
||||
) -> dict:
|
||||
"""List all available mods and which are enabled."""
|
||||
server = ServerService(db).get_server(server_id) # raises 404 if not found
|
||||
mgr = _get_mod_manager(server_id, server["game_type"])
|
||||
|
||||
config_repo = ConfigRepository(db)
|
||||
try:
|
||||
available = mgr.list_available_mods()
|
||||
enabled = set(mgr.get_enabled_mods(config_repo))
|
||||
except AdapterError as exc:
|
||||
raise HTTPException(status_code=500, detail={"code": "ADAPTER_ERROR", "message": str(exc)})
|
||||
|
||||
for mod in available:
|
||||
mod["enabled"] = mod["name"] in enabled
|
||||
|
||||
return _ok({
|
||||
"server_id": server_id,
|
||||
"mods": available,
|
||||
"enabled_count": len(enabled),
|
||||
})
|
||||
|
||||
|
||||
@router.put("/enabled")
|
||||
def set_enabled_mods(
|
||||
server_id: int,
|
||||
body: SetEnabledModsRequest,
|
||||
db: Annotated[Connection, Depends(get_db)],
|
||||
_admin: Annotated[dict, Depends(require_admin)],
|
||||
) -> dict:
|
||||
"""
|
||||
Set the list of enabled mods.
|
||||
Replaces the current enabled list entirely.
|
||||
Server must be restarted for changes to take effect.
|
||||
"""
|
||||
server = ServerService(db).get_server(server_id) # raises 404 if not found
|
||||
mgr = _get_mod_manager(server_id, server["game_type"])
|
||||
|
||||
config_repo = ConfigRepository(db)
|
||||
try:
|
||||
mgr.set_enabled_mods(body.mods, config_repo)
|
||||
except AdapterError as exc:
|
||||
raise HTTPException(status_code=400, detail={"code": "ADAPTER_ERROR", "message": str(exc)})
|
||||
except ValueError as exc:
|
||||
if "CONFIG_VERSION_CONFLICT" in str(exc):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={"code": "VERSION_CONFLICT", "message": "Config was modified by another request. Please retry."},
|
||||
)
|
||||
raise
|
||||
db.commit()
|
||||
|
||||
return _ok({
|
||||
"message": "Enabled mods updated. Restart the server for changes to take effect.",
|
||||
"enabled_mods": body.mods,
|
||||
})
|
||||
57
backend/core/servers/players_router.py
Normal file
57
backend/core/servers/players_router.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Player endpoints — list current players for a running server."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.engine import Connection
|
||||
|
||||
from core.dal.player_repository import PlayerRepository
|
||||
from core.servers.service import ServerService
|
||||
from database import get_db
|
||||
from dependencies import get_current_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/servers/{server_id}/players", tags=["players"])
|
||||
|
||||
|
||||
def _ok(data):
|
||||
return {"success": True, "data": data, "error": None}
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_players(
|
||||
server_id: int,
|
||||
db: Annotated[Connection, Depends(get_db)],
|
||||
_user: Annotated[dict, Depends(get_current_user)],
|
||||
) -> dict:
|
||||
"""List current players (cached from RemoteAdminPollerThread)."""
|
||||
ServerService(db).get_server(server_id) # raises 404 if not found
|
||||
player_repo = PlayerRepository(db)
|
||||
players = player_repo.get_all(server_id=server_id)
|
||||
count = player_repo.count(server_id=server_id)
|
||||
return _ok({
|
||||
"server_id": server_id,
|
||||
"player_count": count,
|
||||
"players": players,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
def player_history(
|
||||
server_id: int,
|
||||
db: Annotated[Connection, Depends(get_db)],
|
||||
_user: Annotated[dict, Depends(get_current_user)],
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
search: str | None = None,
|
||||
) -> dict:
|
||||
"""Get historical player sessions."""
|
||||
ServerService(db).get_server(server_id) # raises 404 if not found
|
||||
player_repo = PlayerRepository(db)
|
||||
total, rows = player_repo.get_history(
|
||||
server_id=server_id, limit=limit, offset=offset, search=search,
|
||||
)
|
||||
return _ok({"total": total, "items": rows})
|
||||
243
backend/core/servers/process_manager.py
Normal file
243
backend/core/servers/process_manager.py
Normal file
@@ -0,0 +1,243 @@
|
||||
"""
|
||||
ProcessManager singleton — owns all subprocess handles.
|
||||
Game-agnostic: delegates exe validation and config to adapters.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import psutil
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProcessManager:
|
||||
_instance: "ProcessManager | None" = None
|
||||
_init_lock = threading.Lock()
|
||||
|
||||
def __init__(self):
|
||||
self._processes: dict[int, subprocess.Popen] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._operation_locks: dict[int, threading.Lock] = {}
|
||||
self._ops_lock = threading.Lock()
|
||||
|
||||
@classmethod
|
||||
def get(cls) -> "ProcessManager":
|
||||
if cls._instance is None:
|
||||
with cls._init_lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = ProcessManager()
|
||||
return cls._instance
|
||||
|
||||
def get_operation_lock(self, server_id: int) -> threading.Lock:
|
||||
"""Per-server lock that serializes start/stop/restart for the same server."""
|
||||
with self._ops_lock:
|
||||
if server_id not in self._operation_locks:
|
||||
self._operation_locks[server_id] = threading.Lock()
|
||||
return self._operation_locks[server_id]
|
||||
|
||||
def start(
|
||||
self,
|
||||
server_id: int,
|
||||
exe_path: str,
|
||||
args: list[str],
|
||||
cwd: str | Path,
|
||||
) -> int:
|
||||
"""
|
||||
Start a game server process.
|
||||
Returns the PID.
|
||||
cwd is set to servers/{server_id}/ so relative config paths work.
|
||||
"""
|
||||
with self._lock:
|
||||
if server_id in self._processes:
|
||||
proc = self._processes[server_id]
|
||||
if proc.poll() is None:
|
||||
raise RuntimeError(f"Server {server_id} is already running (PID {proc.pid})")
|
||||
del self._processes[server_id]
|
||||
|
||||
full_cmd = [exe_path] + args
|
||||
logger.info("Starting server %d: %s", server_id, ' '.join(full_cmd))
|
||||
|
||||
proc = subprocess.Popen(
|
||||
full_cmd,
|
||||
cwd=str(cwd),
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
# On Windows, don't create a new console window
|
||||
creationflags=subprocess.CREATE_NO_WINDOW if hasattr(subprocess, "CREATE_NO_WINDOW") else 0,
|
||||
)
|
||||
|
||||
with self._lock:
|
||||
self._processes[server_id] = proc
|
||||
|
||||
logger.info("Server %d started with PID %d", server_id, proc.pid)
|
||||
return proc.pid
|
||||
|
||||
def stop(self, server_id: int, timeout: int = 30) -> bool:
|
||||
"""
|
||||
Send terminate signal and wait up to timeout seconds.
|
||||
On Windows, terminate() = hard kill (no SIGTERM).
|
||||
Returns True if process exited, False if still running.
|
||||
"""
|
||||
with self._lock:
|
||||
proc = self._processes.get(server_id)
|
||||
if proc is None:
|
||||
return True
|
||||
|
||||
try:
|
||||
proc.terminate()
|
||||
except ProcessLookupError:
|
||||
return True
|
||||
|
||||
try:
|
||||
proc.wait(timeout=timeout)
|
||||
with self._lock:
|
||||
self._processes.pop(server_id, None)
|
||||
return True
|
||||
except subprocess.TimeoutExpired:
|
||||
return False
|
||||
|
||||
def kill(self, server_id: int) -> bool:
|
||||
"""Force-kill the process immediately."""
|
||||
with self._lock:
|
||||
proc = self._processes.get(server_id)
|
||||
if proc is None:
|
||||
return True
|
||||
try:
|
||||
proc.kill()
|
||||
proc.wait(timeout=5)
|
||||
except (ProcessLookupError, subprocess.TimeoutExpired):
|
||||
logger.debug("Process %d already exited or timed out during kill", server_id)
|
||||
with self._lock:
|
||||
self._processes.pop(server_id, None)
|
||||
return True
|
||||
|
||||
def is_running(self, server_id: int) -> bool:
|
||||
with self._lock:
|
||||
proc = self._processes.get(server_id)
|
||||
if proc is None:
|
||||
return False
|
||||
return proc.poll() is None
|
||||
|
||||
def get_pid(self, server_id: int) -> int | None:
|
||||
with self._lock:
|
||||
proc = self._processes.get(server_id)
|
||||
if proc is None or proc.poll() is not None:
|
||||
return None
|
||||
return proc.pid
|
||||
|
||||
def get_process(self, server_id: int) -> subprocess.Popen | None:
|
||||
with self._lock:
|
||||
return self._processes.get(server_id)
|
||||
|
||||
def list_running(self) -> list[int]:
|
||||
with self._lock:
|
||||
return [sid for sid, p in self._processes.items() if p.poll() is None]
|
||||
|
||||
def recover_on_startup(self, db) -> None:
|
||||
"""
|
||||
On app restart: check DB for servers marked 'running'.
|
||||
If the PID is still alive AND the process name matches the adapter's
|
||||
allowed executables, re-attach monitoring threads.
|
||||
Otherwise mark server as 'crashed'.
|
||||
"""
|
||||
from adapters.registry import GameAdapterRegistry
|
||||
from core.dal.server_repository import ServerRepository
|
||||
from core.dal.event_repository import EventRepository
|
||||
from sqlalchemy import text
|
||||
|
||||
running_servers = ServerRepository(db).get_running()
|
||||
for server in running_servers:
|
||||
pid = server.get("pid")
|
||||
if pid is None:
|
||||
self._mark_crashed(server, db, "No PID recorded")
|
||||
continue
|
||||
|
||||
# Check if PID is alive
|
||||
if not psutil.pid_exists(pid):
|
||||
self._mark_crashed(server, db, f"PID {pid} no longer exists")
|
||||
continue
|
||||
|
||||
# Check process name matches adapter allowlist
|
||||
try:
|
||||
proc = psutil.Process(pid)
|
||||
proc_name = proc.name()
|
||||
adapter = GameAdapterRegistry.get(server["game_type"])
|
||||
allowed = adapter.get_process_config().get_allowed_executables()
|
||||
if not any(proc_name.lower() == exe.lower() for exe in allowed):
|
||||
self._mark_crashed(
|
||||
server, db,
|
||||
f"PID {pid} has name '{proc_name}', not in allowlist {allowed}"
|
||||
)
|
||||
continue
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, KeyError) as e:
|
||||
self._mark_crashed(server, db, str(e))
|
||||
continue
|
||||
|
||||
# PID is valid — re-attach the process and start monitoring threads
|
||||
logger.info(
|
||||
"Recovering server %d (PID %d, %s)", server['id'], pid, server['game_type']
|
||||
)
|
||||
proc_obj = self._get_popen_for_pid(pid)
|
||||
if proc_obj:
|
||||
with self._lock:
|
||||
self._processes[server["id"]] = proc_obj
|
||||
|
||||
# Re-start monitoring threads without re-launching the process
|
||||
try:
|
||||
from core.threads.thread_registry import ThreadRegistry
|
||||
ThreadRegistry.reattach_server_threads(server["id"], db)
|
||||
except Exception as e:
|
||||
logger.warning("Could not re-attach threads for server %d: %s", server['id'], e)
|
||||
else:
|
||||
self._mark_crashed(server, db, f"Could not attach to PID {pid}")
|
||||
|
||||
def _mark_crashed(self, server: dict, db, reason: str) -> None:
|
||||
from core.dal.server_repository import ServerRepository
|
||||
from core.dal.event_repository import EventRepository
|
||||
logger.warning("Server %d marked crashed on startup: %s", server['id'], reason)
|
||||
ServerRepository(db).update_status(server["id"], "crashed")
|
||||
EventRepository(db).insert(
|
||||
server["id"], "crashed", actor="system",
|
||||
detail={"reason": reason, "on_startup": True}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_popen_for_pid(pid: int) -> subprocess.Popen | None:
|
||||
"""
|
||||
Create a Popen-like wrapper that attaches to an existing PID.
|
||||
NOTE: This is a limited wrapper — we cannot use Popen() on existing PIDs.
|
||||
We use a sentinel object that wraps psutil.Process.
|
||||
"""
|
||||
try:
|
||||
return _PsutilProcessWrapper(pid)
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
return None
|
||||
|
||||
|
||||
class _PsutilProcessWrapper:
|
||||
"""
|
||||
Minimal Popen-compatible wrapper around an existing process (by PID).
|
||||
Used for startup recovery only.
|
||||
"""
|
||||
def __init__(self, pid: int):
|
||||
self._psutil_proc = psutil.Process(pid)
|
||||
self.pid = pid
|
||||
|
||||
def poll(self) -> int | None:
|
||||
"""Return None if running, exit code if not (we use -1 for external termination)."""
|
||||
if self._psutil_proc.is_running():
|
||||
return None
|
||||
return -1
|
||||
|
||||
def wait(self, timeout: int | None = None):
|
||||
self._psutil_proc.wait(timeout=timeout)
|
||||
|
||||
def terminate(self):
|
||||
self._psutil_proc.terminate()
|
||||
|
||||
def kill(self):
|
||||
self._psutil_proc.kill()
|
||||
233
backend/core/servers/router.py
Normal file
233
backend/core/servers/router.py
Normal file
@@ -0,0 +1,233 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.engine import Connection
|
||||
|
||||
from core.servers.schemas import (
|
||||
CreateServerRequest, StopServerRequest, UpdateServerRequest,
|
||||
)
|
||||
from core.servers.service import ServerService
|
||||
from database import get_db
|
||||
from dependencies import get_current_user, require_admin
|
||||
|
||||
router = APIRouter(prefix="/servers", tags=["servers"])
|
||||
|
||||
|
||||
def _ok(data):
|
||||
return {"success": True, "data": data, "error": None}
|
||||
|
||||
|
||||
class SendCommandRequest(BaseModel):
|
||||
command: str
|
||||
|
||||
|
||||
# ── Server CRUD ──────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("")
|
||||
def list_servers(
|
||||
game_type: str | None = None,
|
||||
db: Annotated[Connection, Depends(get_db)] = None,
|
||||
_user: Annotated[dict, Depends(get_current_user)] = None,
|
||||
):
|
||||
return _ok(ServerService(db).list_servers(game_type))
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
def create_server(
|
||||
body: CreateServerRequest,
|
||||
db: Annotated[Connection, Depends(get_db)] = None,
|
||||
_admin: Annotated[dict, Depends(require_admin)] = None,
|
||||
):
|
||||
return _ok(ServerService(db).create_server(
|
||||
name=body.name,
|
||||
game_type=body.game_type,
|
||||
exe_path=body.exe_path,
|
||||
game_port=body.game_port,
|
||||
rcon_port=body.rcon_port,
|
||||
description=body.description,
|
||||
auto_restart=body.auto_restart,
|
||||
max_restarts=body.max_restarts,
|
||||
))
|
||||
|
||||
|
||||
@router.get("/{server_id}")
|
||||
def get_server(
|
||||
server_id: int,
|
||||
db: Annotated[Connection, Depends(get_db)] = None,
|
||||
_user: Annotated[dict, Depends(get_current_user)] = None,
|
||||
):
|
||||
return _ok(ServerService(db).get_server(server_id))
|
||||
|
||||
|
||||
@router.put("/{server_id}")
|
||||
def update_server(
|
||||
server_id: int,
|
||||
body: UpdateServerRequest,
|
||||
db: Annotated[Connection, Depends(get_db)] = None,
|
||||
_admin: Annotated[dict, Depends(require_admin)] = None,
|
||||
):
|
||||
return _ok(ServerService(db).update_server(server_id, **body.model_dump(exclude_none=True)))
|
||||
|
||||
|
||||
@router.delete("/{server_id}", status_code=204)
|
||||
def delete_server(
|
||||
server_id: int,
|
||||
db: Annotated[Connection, Depends(get_db)] = None,
|
||||
_admin: Annotated[dict, Depends(require_admin)] = None,
|
||||
):
|
||||
ServerService(db).delete_server(server_id)
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
# ── Lifecycle ────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/{server_id}/start")
|
||||
def start_server(
|
||||
server_id: int,
|
||||
db: Annotated[Connection, Depends(get_db)] = None,
|
||||
_admin: Annotated[dict, Depends(require_admin)] = None,
|
||||
):
|
||||
return _ok(ServerService(db).start(server_id))
|
||||
|
||||
|
||||
@router.post("/{server_id}/stop")
|
||||
def stop_server(
|
||||
server_id: int,
|
||||
body: StopServerRequest = None,
|
||||
db: Annotated[Connection, Depends(get_db)] = None,
|
||||
_admin: Annotated[dict, Depends(require_admin)] = None,
|
||||
):
|
||||
force = body.force if body else False
|
||||
return _ok(ServerService(db).stop(server_id, force=force))
|
||||
|
||||
|
||||
@router.post("/{server_id}/restart")
|
||||
def restart_server(
|
||||
server_id: int,
|
||||
db: Annotated[Connection, Depends(get_db)] = None,
|
||||
_admin: Annotated[dict, Depends(require_admin)] = None,
|
||||
):
|
||||
return _ok(ServerService(db).restart(server_id))
|
||||
|
||||
|
||||
@router.post("/{server_id}/kill")
|
||||
def kill_server(
|
||||
server_id: int,
|
||||
db: Annotated[Connection, Depends(get_db)] = None,
|
||||
_admin: Annotated[dict, Depends(require_admin)] = None,
|
||||
):
|
||||
return _ok(ServerService(db).kill(server_id))
|
||||
|
||||
|
||||
# ── Config ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/{server_id}/config")
|
||||
def get_config(
|
||||
server_id: int,
|
||||
db: Annotated[Connection, Depends(get_db)] = None,
|
||||
_user: Annotated[dict, Depends(get_current_user)] = None,
|
||||
):
|
||||
return _ok(ServerService(db).get_config(server_id))
|
||||
|
||||
|
||||
@router.get("/{server_id}/config/preview")
|
||||
def get_config_preview(
|
||||
server_id: int,
|
||||
db: Annotated[Connection, Depends(get_db)] = None,
|
||||
_admin: Annotated[dict, Depends(require_admin)] = None,
|
||||
):
|
||||
return _ok(ServerService(db).get_config_preview(server_id))
|
||||
|
||||
|
||||
@router.get("/{server_id}/config/{section}")
|
||||
def get_config_section(
|
||||
server_id: int,
|
||||
section: str,
|
||||
db: Annotated[Connection, Depends(get_db)] = None,
|
||||
_user: Annotated[dict, Depends(get_current_user)] = None,
|
||||
):
|
||||
return _ok(ServerService(db).get_config_section(server_id, section))
|
||||
|
||||
|
||||
@router.put("/{server_id}/config/{section}")
|
||||
def update_config_section(
|
||||
server_id: int,
|
||||
section: str,
|
||||
body: dict, # Dynamic — adapter-specific fields
|
||||
db: Annotated[Connection, Depends(get_db)] = None,
|
||||
_admin: Annotated[dict, Depends(require_admin)] = None,
|
||||
):
|
||||
expected_version = body.pop("config_version", None)
|
||||
return _ok(ServerService(db).update_config_section(
|
||||
server_id, section, body, expected_version
|
||||
))
|
||||
|
||||
|
||||
# ── RCon ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/{server_id}/rcon/command")
|
||||
def send_rcon_command(
|
||||
server_id: int,
|
||||
body: SendCommandRequest,
|
||||
db: Annotated[Connection, Depends(get_db)] = None,
|
||||
_admin: Annotated[dict, Depends(require_admin)] = None,
|
||||
):
|
||||
"""Send an RCon command to a running server."""
|
||||
from adapters.registry import GameAdapterRegistry
|
||||
from adapters.exceptions import RemoteAdminError
|
||||
from core.dal.config_repository import ConfigRepository
|
||||
from core.dal.server_repository import ServerRepository
|
||||
|
||||
server = ServerRepository(db).get_by_id(server_id)
|
||||
if server is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"code": "NOT_FOUND", "message": f"Server {server_id} not found"},
|
||||
)
|
||||
|
||||
adapter = GameAdapterRegistry.get(server["game_type"])
|
||||
if not adapter.has_capability("remote_admin"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"code": "NOT_SUPPORTED", "message": f"Game type {server['game_type']} does not support RCon"},
|
||||
)
|
||||
|
||||
# Get RCon password from config
|
||||
remote_admin_factory = adapter.get_remote_admin()
|
||||
config_gen = adapter.get_config_generator()
|
||||
sensitive = config_gen.get_sensitive_fields("rcon") if "rcon" in config_gen.get_sections() else []
|
||||
config_repo = ConfigRepository(db)
|
||||
rcon_section = config_repo.get_section(server_id, "rcon", sensitive)
|
||||
if not rcon_section or not rcon_section.get("password"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"code": "NO_RCON_PASSWORD", "message": "RCon password not configured for this server"},
|
||||
)
|
||||
password = rcon_section["password"]
|
||||
|
||||
rcon_port = server.get("rcon_port") or (server["game_port"] + 3)
|
||||
client = remote_admin_factory.create_client(
|
||||
host="127.0.0.1",
|
||||
port=rcon_port,
|
||||
password=password,
|
||||
)
|
||||
try:
|
||||
client.connect()
|
||||
result = client.send_command(body.command)
|
||||
client.disconnect()
|
||||
except RemoteAdminError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail={"code": "RCON_ERROR", "message": f"RCon command failed: {exc}"},
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail={"code": "RCON_ERROR", "message": f"RCon connection failed: {exc}"},
|
||||
)
|
||||
|
||||
return _ok({"response": result})
|
||||
35
backend/core/servers/schemas.py
Normal file
35
backend/core/servers/schemas.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CreateServerRequest(BaseModel):
|
||||
name: str
|
||||
description: str | None = None
|
||||
game_type: str = "arma3"
|
||||
exe_path: str
|
||||
game_port: int = Field(ge=1024, le=65535)
|
||||
rcon_port: int | None = Field(default=None, ge=1024, le=65535)
|
||||
auto_restart: bool = False
|
||||
max_restarts: int = Field(default=3, ge=0, le=20)
|
||||
|
||||
|
||||
class UpdateServerRequest(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
exe_path: str | None = None
|
||||
game_port: int | None = Field(default=None, ge=1024, le=65535)
|
||||
rcon_port: int | None = Field(default=None, ge=1024, le=65535)
|
||||
auto_restart: bool | None = None
|
||||
max_restarts: int | None = None
|
||||
|
||||
|
||||
class StopServerRequest(BaseModel):
|
||||
force: bool = False
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class UpdateConfigSectionRequest(BaseModel):
|
||||
config_version: int | None = None # Required for optimistic locking on PUT
|
||||
# All other fields come from the adapter's JSON Schema — passed through as-is
|
||||
model_config = {"extra": "allow"}
|
||||
503
backend/core/servers/service.py
Normal file
503
backend/core/servers/service.py
Normal file
@@ -0,0 +1,503 @@
|
||||
"""
|
||||
ServerService — orchestrates all server lifecycle operations.
|
||||
Delegates game-specific work to the adapter.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.engine import Connection
|
||||
|
||||
from adapters.registry import GameAdapterRegistry
|
||||
from core.dal.config_repository import ConfigRepository
|
||||
from core.dal.event_repository import EventRepository
|
||||
from core.dal.server_repository import ServerRepository
|
||||
from core.servers.process_manager import ProcessManager
|
||||
from core.utils.file_utils import ensure_server_dirs, get_server_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ok_response(data):
|
||||
return {"success": True, "data": data, "error": None}
|
||||
|
||||
|
||||
class ServerService:
|
||||
|
||||
def __init__(self, db: Connection):
|
||||
self._db = db
|
||||
self._server_repo = ServerRepository(db)
|
||||
self._config_repo = ConfigRepository(db)
|
||||
self._event_repo = EventRepository(db)
|
||||
|
||||
# ── CRUD ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def list_servers(self, game_type: str | None = None) -> list[dict]:
|
||||
"""Return server list with live metrics merged in."""
|
||||
servers = self._server_repo.get_all(game_type)
|
||||
return [self._enrich_server(s) for s in servers]
|
||||
|
||||
def get_server(self, server_id: int) -> dict:
|
||||
server = self._server_repo.get_by_id(server_id)
|
||||
if server is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"code": "NOT_FOUND", "message": f"Server {server_id} not found"},
|
||||
)
|
||||
return self._enrich_server(server)
|
||||
|
||||
def _enrich_server(self, server: dict) -> dict:
|
||||
"""Add live CPU/RAM/player count from DB."""
|
||||
from core.dal.metrics_repository import MetricsRepository
|
||||
from core.dal.player_repository import PlayerRepository
|
||||
result = dict(server)
|
||||
metrics = MetricsRepository(self._db).get_latest(server["id"])
|
||||
if metrics:
|
||||
result["cpu_percent"] = metrics["cpu_percent"]
|
||||
result["ram_mb"] = metrics["ram_mb"]
|
||||
else:
|
||||
result["cpu_percent"] = None
|
||||
result["ram_mb"] = None
|
||||
result["player_count"] = PlayerRepository(self._db).count(server["id"])
|
||||
return result
|
||||
|
||||
def create_server(
|
||||
self,
|
||||
name: str,
|
||||
game_type: str,
|
||||
exe_path: str,
|
||||
game_port: int,
|
||||
rcon_port: int | None = None,
|
||||
description: str | None = None,
|
||||
auto_restart: bool = False,
|
||||
max_restarts: int = 3,
|
||||
) -> dict:
|
||||
# Validate adapter exists
|
||||
try:
|
||||
adapter = GameAdapterRegistry.get(game_type)
|
||||
except KeyError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"code": "GAME_TYPE_NOT_FOUND", "message": f"Unknown game type: {game_type}"},
|
||||
)
|
||||
|
||||
# Validate exe
|
||||
process_config = adapter.get_process_config()
|
||||
exe_name = Path(exe_path).name
|
||||
if exe_name not in process_config.get_allowed_executables():
|
||||
from adapters.exceptions import ExeNotAllowedError
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"code": "EXE_NOT_ALLOWED",
|
||||
"message": f"Executable '{exe_name}' not allowed",
|
||||
"allowed": process_config.get_allowed_executables(),
|
||||
},
|
||||
)
|
||||
|
||||
# Determine rcon_port if not provided
|
||||
if rcon_port is None:
|
||||
rcon_port = process_config.get_default_rcon_port(game_port)
|
||||
|
||||
# Check port conflicts against running servers
|
||||
from core.utils.port_checker import check_ports_against_running_servers
|
||||
conflicts = check_ports_against_running_servers(game_port, rcon_port, None, self._db)
|
||||
if conflicts:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={
|
||||
"code": "PORT_IN_USE",
|
||||
"message": f"Ports already in use: {conflicts}",
|
||||
},
|
||||
)
|
||||
|
||||
# Create DB row
|
||||
server_id = self._server_repo.create(
|
||||
name=name,
|
||||
game_type=game_type,
|
||||
exe_path=exe_path,
|
||||
game_port=game_port,
|
||||
rcon_port=rcon_port,
|
||||
description=description,
|
||||
auto_restart=auto_restart,
|
||||
max_restarts=max_restarts,
|
||||
)
|
||||
|
||||
# Create directory layout
|
||||
layout = process_config.get_server_dir_layout()
|
||||
ensure_server_dirs(server_id, layout)
|
||||
|
||||
# Seed default config sections
|
||||
config_gen = adapter.get_config_generator()
|
||||
schema_version = config_gen.get_config_version()
|
||||
for section in config_gen.get_sections():
|
||||
defaults = config_gen.get_defaults(section)
|
||||
sensitive = config_gen.get_sensitive_fields(section)
|
||||
self._config_repo.upsert_section(
|
||||
server_id=server_id,
|
||||
game_type=game_type,
|
||||
section=section,
|
||||
config_data=defaults,
|
||||
schema_version=schema_version,
|
||||
sensitive_fields=sensitive,
|
||||
)
|
||||
|
||||
self._event_repo.insert(server_id, "created", actor="admin")
|
||||
return self.get_server(server_id)
|
||||
|
||||
def update_server(self, server_id: int, **updates) -> dict:
|
||||
self.get_server(server_id) # raises 404 if not found
|
||||
filtered = {k: v for k, v in updates.items() if v is not None}
|
||||
if filtered:
|
||||
self._server_repo.update(server_id, **filtered)
|
||||
return self.get_server(server_id)
|
||||
|
||||
def delete_server(self, server_id: int) -> None:
|
||||
server = self.get_server(server_id)
|
||||
if server["status"] not in ("stopped", "crashed", "error"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={
|
||||
"code": "SERVER_NOT_STOPPED",
|
||||
"message": "Server must be stopped before deletion",
|
||||
},
|
||||
)
|
||||
self._server_repo.delete(server_id)
|
||||
# Delete server directory
|
||||
server_dir = get_server_dir(server_id)
|
||||
if server_dir.exists():
|
||||
shutil.rmtree(str(server_dir), ignore_errors=True)
|
||||
|
||||
# ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||
|
||||
def start(self, server_id: int) -> dict:
|
||||
"""
|
||||
Full start sequence:
|
||||
1. Load server + adapter
|
||||
2. Validate exe
|
||||
3. Check ports
|
||||
4. Write config files (atomic)
|
||||
5. Build launch args
|
||||
6. Start process
|
||||
7. Start monitoring threads
|
||||
8. Return status
|
||||
"""
|
||||
from adapters.exceptions import (
|
||||
ConfigWriteError, ExeNotAllowedError,
|
||||
LaunchArgsError, ConfigValidationError,
|
||||
)
|
||||
from core.utils.port_checker import check_ports_against_running_servers
|
||||
|
||||
server = self.get_server(server_id)
|
||||
if server["status"] in ("running", "starting"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={"code": "SERVER_ALREADY_RUNNING", "message": "Server is already running"},
|
||||
)
|
||||
|
||||
adapter = GameAdapterRegistry.get(server["game_type"])
|
||||
process_config = adapter.get_process_config()
|
||||
config_gen = adapter.get_config_generator()
|
||||
|
||||
# Validate exe
|
||||
exe_name = Path(server["exe_path"]).name
|
||||
if exe_name not in process_config.get_allowed_executables():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"code": "EXE_NOT_ALLOWED",
|
||||
"message": f"Executable '{exe_name}' not in adapter allowlist",
|
||||
"allowed": process_config.get_allowed_executables(),
|
||||
},
|
||||
)
|
||||
|
||||
# Check ports
|
||||
conflicts = check_ports_against_running_servers(
|
||||
server["game_port"], server.get("rcon_port"), server_id, self._db
|
||||
)
|
||||
if conflicts:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={"code": "PORT_IN_USE", "message": f"Ports in use: {conflicts}"},
|
||||
)
|
||||
|
||||
# Load config sections (decrypt sensitive fields for config generation)
|
||||
sensitive_by_section = {
|
||||
s: config_gen.get_sensitive_fields(s)
|
||||
for s in config_gen.get_sections()
|
||||
}
|
||||
sections = self._config_repo.get_all_sections(server_id, sensitive_by_section)
|
||||
# Remove _meta from each section before passing to adapter
|
||||
raw_sections = {
|
||||
section: {k: v for k, v in data.items() if k != "_meta"}
|
||||
for section, data in sections.items()
|
||||
}
|
||||
# Inject port into sections so build_launch_args can use it
|
||||
if "_port" not in raw_sections:
|
||||
raw_sections["_port"] = server["game_port"]
|
||||
|
||||
# Get mod args if adapter supports mods
|
||||
mod_args: list[str] = []
|
||||
if adapter.has_capability("mod_manager"):
|
||||
from sqlalchemy import text
|
||||
mods = self._db.execute(
|
||||
text("""
|
||||
SELECT m.folder_path, sm.is_server_mod, sm.sort_order
|
||||
FROM server_mods sm JOIN mods m ON m.id = sm.mod_id
|
||||
WHERE sm.server_id = :sid ORDER BY sm.sort_order
|
||||
"""),
|
||||
{"sid": server_id},
|
||||
).fetchall()
|
||||
mod_list = [dict(r._mapping) for r in mods]
|
||||
mod_args = adapter.get_mod_manager().build_mod_args(mod_list)
|
||||
|
||||
# Write config files (atomic)
|
||||
server_dir = get_server_dir(server_id)
|
||||
try:
|
||||
config_gen.write_configs(server_id, server_dir, raw_sections)
|
||||
except ConfigWriteError as e:
|
||||
self._server_repo.update_status(server_id, "error")
|
||||
self._event_repo.insert(server_id, "config_write_error", detail={"error": str(e)})
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={"code": "CONFIG_WRITE_ERROR", "message": str(e)},
|
||||
)
|
||||
except ConfigValidationError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail={"code": "INVALID_CONFIG", "message": str(e), "errors": e.errors},
|
||||
)
|
||||
|
||||
# Build launch args
|
||||
try:
|
||||
launch_args = config_gen.build_launch_args(raw_sections, mod_args)
|
||||
except LaunchArgsError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"code": "INVALID_CONFIG", "message": str(e)},
|
||||
)
|
||||
|
||||
# Start process
|
||||
pm = ProcessManager.get()
|
||||
with pm.get_operation_lock(server_id):
|
||||
pid = pm.start(server_id, server["exe_path"], launch_args, cwd=str(server_dir))
|
||||
|
||||
# Update DB
|
||||
from datetime import datetime, timezone
|
||||
self._server_repo.update_status(
|
||||
server_id, "starting", pid=pid,
|
||||
started_at=datetime.now(timezone.utc).isoformat()
|
||||
)
|
||||
self._event_repo.insert(server_id, "started", detail={"pid": pid})
|
||||
|
||||
# Start monitoring threads
|
||||
try:
|
||||
from core.threads.thread_registry import ThreadRegistry
|
||||
ThreadRegistry.start_server_threads(server_id, self._db)
|
||||
except Exception as e:
|
||||
logger.warning("Could not start monitoring threads: %s", e)
|
||||
|
||||
return {"status": "starting", "pid": pid}
|
||||
|
||||
def stop(self, server_id: int, force: bool = False) -> dict:
|
||||
server = self.get_server(server_id)
|
||||
if server["status"] in ("stopped", "crashed"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={"code": "SERVER_NOT_RUNNING", "message": "Server is not running"},
|
||||
)
|
||||
|
||||
# Mark as "stopping" so ProcessMonitorThread doesn't treat this as a crash
|
||||
self._server_repo.update_status(server_id, "stopping")
|
||||
|
||||
# Stop monitoring threads first so they don't fight with shutdown
|
||||
try:
|
||||
from core.threads.thread_registry import ThreadRegistry
|
||||
ThreadRegistry.stop_server_threads(server_id)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to stop monitoring threads for server %d during stop: %s", server_id, exc)
|
||||
|
||||
# Try graceful shutdown via remote admin
|
||||
if not force:
|
||||
try:
|
||||
pm = ProcessManager.get()
|
||||
logger.info("Sending graceful shutdown to server %d", server_id)
|
||||
except Exception as e:
|
||||
logger.warning("Graceful shutdown failed: %s, falling back to terminate", e)
|
||||
|
||||
pm = ProcessManager.get()
|
||||
with pm.get_operation_lock(server_id):
|
||||
exited = pm.stop(server_id, timeout=30)
|
||||
if not exited:
|
||||
logger.warning("Server %d did not exit in 30s, force-killing", server_id)
|
||||
pm.kill(server_id)
|
||||
|
||||
from datetime import datetime, timezone
|
||||
self._server_repo.update_status(
|
||||
server_id, "stopped",
|
||||
pid=None, stopped_at=datetime.now(timezone.utc).isoformat()
|
||||
)
|
||||
|
||||
from core.dal.player_repository import PlayerRepository
|
||||
PlayerRepository(self._db).clear(server_id)
|
||||
self._event_repo.insert(server_id, "stopped")
|
||||
|
||||
return {"status": "stopped"}
|
||||
|
||||
def restart(self, server_id: int) -> dict:
|
||||
self.stop(server_id)
|
||||
return self.start(server_id)
|
||||
|
||||
def kill(self, server_id: int) -> dict:
|
||||
server = self.get_server(server_id)
|
||||
|
||||
# Mark as "stopping" so ProcessMonitorThread doesn't treat this as a crash
|
||||
self._server_repo.update_status(server_id, "stopping")
|
||||
|
||||
# Stop monitoring threads first
|
||||
try:
|
||||
from core.threads.thread_registry import ThreadRegistry
|
||||
ThreadRegistry.stop_server_threads(server_id)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to stop monitoring threads for server %d during kill: %s", server_id, exc)
|
||||
|
||||
pm = ProcessManager.get()
|
||||
with pm.get_operation_lock(server_id):
|
||||
pm.kill(server_id)
|
||||
|
||||
from datetime import datetime, timezone
|
||||
self._server_repo.update_status(server_id, "stopped", pid=None,
|
||||
stopped_at=datetime.now(timezone.utc).isoformat())
|
||||
from core.dal.player_repository import PlayerRepository
|
||||
PlayerRepository(self._db).clear(server_id)
|
||||
self._event_repo.insert(server_id, "killed")
|
||||
return {"status": "stopped"}
|
||||
|
||||
# ── Config ────────────────────────────────────────────────────────────────
|
||||
|
||||
def get_config(self, server_id: int) -> dict:
|
||||
self.get_server(server_id)
|
||||
adapter = GameAdapterRegistry.get(
|
||||
self._server_repo.get_by_id(server_id)["game_type"]
|
||||
)
|
||||
config_gen = adapter.get_config_generator()
|
||||
sensitive_by_section = {
|
||||
s: config_gen.get_sensitive_fields(s) for s in config_gen.get_sections()
|
||||
}
|
||||
sections = self._config_repo.get_all_sections(server_id, sensitive_by_section)
|
||||
# Mask sensitive fields in response (replace actual value with "***")
|
||||
for section, data in sections.items():
|
||||
sf = config_gen.get_sensitive_fields(section)
|
||||
for field in sf:
|
||||
if field in data and data[field]:
|
||||
data[field] = "***"
|
||||
return sections
|
||||
|
||||
def get_config_section(self, server_id: int, section: str) -> dict:
|
||||
server = self.get_server(server_id)
|
||||
adapter = GameAdapterRegistry.get(server["game_type"])
|
||||
config_gen = adapter.get_config_generator()
|
||||
if section not in config_gen.get_sections():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"code": "NOT_FOUND", "message": f"Config section '{section}' not found"},
|
||||
)
|
||||
sensitive = config_gen.get_sensitive_fields(section)
|
||||
data = self._config_repo.get_section(server_id, section, sensitive)
|
||||
if data is None:
|
||||
data = config_gen.get_defaults(section)
|
||||
data["_meta"] = {"config_version": 0, "schema_version": config_gen.get_config_version()}
|
||||
# Mask sensitive fields
|
||||
for field in sensitive:
|
||||
if field in data and data[field]:
|
||||
data[field] = "***"
|
||||
return data
|
||||
|
||||
def update_config_section(
|
||||
self,
|
||||
server_id: int,
|
||||
section: str,
|
||||
data: dict,
|
||||
expected_version: int | None = None,
|
||||
) -> dict:
|
||||
server = self.get_server(server_id)
|
||||
adapter = GameAdapterRegistry.get(server["game_type"])
|
||||
config_gen = adapter.get_config_generator()
|
||||
|
||||
sections = config_gen.get_sections()
|
||||
if section not in sections:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"code": "NOT_FOUND", "message": f"Config section '{section}' not found"},
|
||||
)
|
||||
|
||||
# Validate against adapter's Pydantic model
|
||||
model_cls = sections[section]
|
||||
# Get current values, merge with update (partial update support)
|
||||
current = self._config_repo.get_section(
|
||||
server_id, section, config_gen.get_sensitive_fields(section)
|
||||
)
|
||||
if current:
|
||||
merged = {k: v for k, v in current.items() if k != "_meta"}
|
||||
else:
|
||||
merged = config_gen.get_defaults(section)
|
||||
# Apply updates
|
||||
for k, v in data.items():
|
||||
if k not in ("_meta", "config_version"):
|
||||
merged[k] = v
|
||||
|
||||
# Validate
|
||||
try:
|
||||
model_cls(**merged)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail={"code": "INVALID_CONFIG", "message": str(e)},
|
||||
)
|
||||
|
||||
sensitive = config_gen.get_sensitive_fields(section)
|
||||
try:
|
||||
new_version = self._config_repo.upsert_section(
|
||||
server_id=server_id,
|
||||
game_type=server["game_type"],
|
||||
section=section,
|
||||
config_data=merged,
|
||||
schema_version=config_gen.get_config_version(),
|
||||
sensitive_fields=sensitive,
|
||||
expected_config_version=expected_version,
|
||||
)
|
||||
except ValueError as e:
|
||||
error_msg = str(e)
|
||||
if "CONFIG_VERSION_CONFLICT" in error_msg:
|
||||
current_version = int(error_msg.split(":")[1])
|
||||
current_data = self.get_config_section(server_id, section)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={
|
||||
"code": "CONFIG_VERSION_CONFLICT",
|
||||
"message": "Config was modified by another user. Re-read and merge.",
|
||||
"current_config": current_data,
|
||||
"current_version": current_version,
|
||||
},
|
||||
)
|
||||
raise
|
||||
|
||||
self._event_repo.insert(
|
||||
server_id, "config_updated", detail={"section": section, "version": new_version}
|
||||
)
|
||||
return self.get_config_section(server_id, section)
|
||||
|
||||
def get_config_preview(self, server_id: int) -> dict[str, str]:
|
||||
server = self.get_server(server_id)
|
||||
adapter = GameAdapterRegistry.get(server["game_type"])
|
||||
config_gen = adapter.get_config_generator()
|
||||
sensitive_by_section = {
|
||||
s: config_gen.get_sensitive_fields(s) for s in config_gen.get_sections()
|
||||
}
|
||||
sections = self._config_repo.get_all_sections(server_id, sensitive_by_section)
|
||||
raw_sections = {k: {kk: vv for kk, vv in v.items() if kk != "_meta"} for k, v in sections.items()}
|
||||
server_dir = get_server_dir(server_id)
|
||||
return config_gen.preview_config(server_id, server_dir, raw_sections)
|
||||
Reference in New Issue
Block a user