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:
Tran G. (Revernomad) Khoa
2026-04-17 11:58:34 +07:00
parent 620429c9b8
commit 6511353b55
119 changed files with 13752 additions and 5000 deletions

View File

View 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)

View 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"})

View 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,
})

View 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})

View 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()

View 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})

View 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"}

View 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)