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

@@ -0,0 +1 @@
"""Core utility modules."""

View File

@@ -0,0 +1,32 @@
"""Field-level encryption using Fernet (AES-256)."""
from __future__ import annotations
from cryptography.fernet import Fernet
_fernet: Fernet | None = None
def get_fernet() -> Fernet:
global _fernet
if _fernet is None:
from config import settings
_fernet = Fernet(settings.encryption_key.encode())
return _fernet
def encrypt(plaintext: str) -> str:
"""Encrypt plaintext string. Returns 'encrypted:<base64-token>'."""
token = get_fernet().encrypt(plaintext.encode()).decode()
return f"encrypted:{token}"
def decrypt(ciphertext: str) -> str:
"""Decrypt 'encrypted:<token>' string. Returns plaintext."""
if not ciphertext.startswith("encrypted:"):
return ciphertext # Not encrypted, return as-is
token = ciphertext[len("encrypted:"):]
return get_fernet().decrypt(token.encode()).decode()
def is_encrypted(value: str) -> bool:
return isinstance(value, str) and value.startswith("encrypted:")

View File

@@ -0,0 +1,65 @@
"""Game-agnostic file operations."""
from __future__ import annotations
import re
from pathlib import Path
def get_server_dir(server_id: int) -> Path:
"""Return the absolute directory path for a server's data."""
from config import settings
base = Path(settings.servers_dir).resolve()
return base / str(server_id)
def ensure_server_dirs(server_id: int, layout: list[str] | None = None) -> None:
"""
Create servers/{id}/ and any subdirectories from adapter layout.
layout example: ["server", "battleye", "mpmissions"]
"""
server_dir = get_server_dir(server_id)
server_dir.mkdir(parents=True, exist_ok=True)
if layout:
for subdir in layout:
(server_dir / subdir).mkdir(parents=True, exist_ok=True)
def safe_delete_file(path: Path) -> bool:
"""Delete a file if it exists. Returns True if deleted."""
try:
path.unlink(missing_ok=True)
return True
except OSError:
return False
def sanitize_filename(filename: str) -> str:
"""
Sanitize a filename for safe disk storage.
Rules:
- Strip path separators (/ \\ and ..)
- Allow only alphanumeric, dots, hyphens, underscores, @ signs
- Collapse consecutive dots (prevent ../ tricks)
- Truncate to 255 characters
- Raise ValueError if the result is empty
"""
# Take only the basename — strip any directory components
filename = filename.replace("\\", "/").split("/")[-1]
# Remove null bytes and control characters
filename = re.sub(r"[\x00-\x1f\x7f]", "", filename)
# Allow only safe characters: alphanum, dot, hyphen, underscore, @
filename = re.sub(r"[^\w.\-@]", "_", filename)
# Collapse consecutive dots to prevent tricks like ".../.."
filename = re.sub(r"\.{2,}", ".", filename)
# Truncate
filename = filename[:255]
if not filename or filename in (".", ".."):
raise ValueError(f"Filename '{filename}' is not safe for storage")
return filename

View File

@@ -0,0 +1,87 @@
"""Game-agnostic port availability checking."""
from __future__ import annotations
import logging
import socket
logger = logging.getLogger(__name__)
def is_port_in_use(port: int, host: str = "127.0.0.1") -> bool:
"""Return True if the port is already bound."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(0.5)
try:
s.bind((host, port))
return False
except OSError:
return True
def check_server_ports_available(
game_port: int,
rcon_port: int | None = None,
host: str = "127.0.0.1",
port_conventions: dict[str, int] | None = None,
) -> list[int]:
"""
Check all ports for a server instance.
If port_conventions is provided (from adapter), checks all derived ports.
Returns list of ports that are already in use (empty = all available).
"""
ports_to_check: set[int] = set()
if port_conventions:
ports_to_check.update(port_conventions.values())
else:
ports_to_check.add(game_port)
if rcon_port is not None:
ports_to_check.add(rcon_port)
return [p for p in sorted(ports_to_check) if is_port_in_use(p, host)]
def check_ports_against_running_servers(
new_server_game_port: int,
new_server_rcon_port: int | None,
exclude_server_id: int | None,
db,
) -> list[int]:
"""
Cross-game port conflict detection.
Checks new server's full port set against all running servers' full port sets.
Returns list of conflicting ports.
"""
from adapters.registry import GameAdapterRegistry
from sqlalchemy import text
rows = db.execute(
text("SELECT id, game_type, game_port, rcon_port FROM servers WHERE status IN ('running','starting')")
).fetchall()
occupied_ports: set[int] = set()
for row in rows:
if exclude_server_id and row[0] == exclude_server_id:
continue
try:
adapter = GameAdapterRegistry.get(row[1])
conventions = adapter.get_process_config().get_port_conventions(row[2])
occupied_ports.update(conventions.values())
except KeyError:
logger.debug("Unknown game type '%s', falling back to game_port only", row[1])
occupied_ports.add(row[2])
if row[3] is not None:
occupied_ports.add(row[3])
# Check new server's ports against occupied set
try:
adapter = GameAdapterRegistry.get("arma3") # temporary — will be passed in
except KeyError:
logger.debug("No 'arma3' adapter for port conventions, using defaults")
new_ports: set[int] = {new_server_game_port}
if new_server_rcon_port:
new_ports.add(new_server_rcon_port)
return sorted(new_ports & occupied_ports)