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:
1
backend/core/utils/__init__.py
Normal file
1
backend/core/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Core utility modules."""
|
||||
32
backend/core/utils/crypto.py
Normal file
32
backend/core/utils/crypto.py
Normal 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:")
|
||||
65
backend/core/utils/file_utils.py
Normal file
65
backend/core/utils/file_utils.py
Normal 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
|
||||
87
backend/core/utils/port_checker.py
Normal file
87
backend/core/utils/port_checker.py
Normal 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)
|
||||
Reference in New Issue
Block a user