diff --git a/API.md b/API.md index e01a906..a8e3b34 100644 --- a/API.md +++ b/API.md @@ -6,7 +6,7 @@ http://localhost:8000/api ``` ## Authentication -- All endpoints except `POST /auth/login` require: `Authorization: Bearer ` +- All endpoints except `POST /auth/login` and `GET /system/health` require: `Authorization: Bearer ` - WebSocket: pass token as query param: `ws://localhost:8000/ws/{server_id}?token=` - JWT payload: `{ "sub": "user_id", "username": "string", "role": "admin|viewer", "exp": timestamp }` @@ -39,11 +39,26 @@ Error response: | 400 | Validation error | | 401 | Unauthenticated | | 403 | Forbidden (insufficient role) | -| 404 | Not found | +| 404 | Not found (or capability not supported by adapter) | | 409 | Conflict (already running, duplicate) | | 422 | Unprocessable (Pydantic validation) | | 500 | Internal server error | +## Capability-Based Routing + +Some endpoints depend on the server's game adapter supporting a specific capability. If the adapter does not support the capability, the endpoint returns **404** with a clear message: + +```json +{ + "success": false, + "data": null, + "error": { + "code": "CAPABILITY_NOT_SUPPORTED", + "message": "Missions not supported for game type 'rust'" + } +} +``` + --- ## Auth Endpoints @@ -101,12 +116,88 @@ Delete user. Admin only. --- +## Game Type Discovery Endpoints + +### GET /games +List all registered game types and their capabilities. + +**Response 200:** +```json +{ + "success": true, + "data": [ + { + "game_type": "arma3", + "display_name": "Arma 3", + "version": "1.0.0", + "capabilities": ["config_generator", "process_config", + "log_parser", "remote_admin", "mission_manager", "mod_manager", "ban_manager"] + } + ] +} +``` + +### GET /games/{game_type} +Get details for a specific game type. + +**Response 200:** +```json +{ + "success": true, + "data": { + "game_type": "arma3", + "display_name": "Arma 3", + "version": "1.0.0", + "capabilities": ["config_generator", "process_config", + "log_parser", "remote_admin", "mission_manager", "mod_manager", "ban_manager"], + "config_sections": ["server", "basic", "profile", "launch", "rcon"], + "allowed_executables": ["arma3server_x64.exe", "arma3server.exe"] + } +} +``` + +### GET /games/{game_type}/config-schema +Returns JSON Schema for each config section defined by the adapter. Used by frontend to build dynamic config forms. + +**Response 200:** +```json +{ + "success": true, + "data": { + "server": { /* JSON Schema for server.cfg params */ }, + "basic": { /* JSON Schema for basic.cfg params */ }, + "profile": { /* JSON Schema for Arma3Profile params */ }, + "launch": { /* JSON Schema for launch params */ }, + "rcon": { /* JSON Schema for RCon params */ } + } +} +``` + +### GET /games/{game_type}/defaults +Default config values for new server creation. + +**Response 200:** +```json +{ + "success": true, + "data": { + "server": { "hostname": "My Arma 3 Server", "max_players": 40, ... }, + "basic": { "min_bandwidth": 800000, ... }, + "profile": { "reduced_damage": 0, ... }, + "launch": { "world": "empty", "limit_fps": 50, ... }, + "rcon": { "max_ping": 200, "enabled": 1 } + } +} +``` + +--- + ## Server Endpoints ### GET /servers -List all servers with current status. Supports pagination. +List all servers with current status. Supports filtering by game type. -**Query params:** `?limit=50&offset=0` +**Query params:** `?limit=50&offset=0&game_type=arma3` **Response 200:** ```json @@ -117,32 +208,30 @@ List all servers with current status. Supports pagination. "id": 1, "name": "Main Server", "description": "Primary COOP server", + "game_type": "arma3", "status": "running", "pid": 12345, "game_port": 2302, "rcon_port": 2306, "player_count": 15, "max_players": 40, - "current_mission": "MyMission.Altis", - "uptime_seconds": 3600, "cpu_percent": 34.2, "ram_mb": 1850.5, "started_at": "2026-04-16T10:00:00Z" - // current_mission: computed from RCon 'players' response or mission_rotation + server status - // uptime_seconds: computed as (now - started_at) in the service layer } ] } ``` ### POST /servers -Create a new server. Admin only. +Create a new server. Admin only. `game_type` determines which adapter handles this server. **Request:** ```json { "name": "Main Server", "description": "Primary COOP server", + "game_type": "arma3", "exe_path": "C:/Arma3Server/arma3server_x64.exe", "game_port": 2302, "rcon_port": 2306, @@ -150,7 +239,9 @@ Create a new server. Admin only. "max_restarts": 3 } ``` -**Note:** `password_admin` is auto-generated if not provided in the request. The generated value is returned in the response (shown once — not stored in plaintext API responses after creation). `rcon_password` is also auto-generated if not provided. + +The adapter provides default config values for all sections. Auto-generated credentials (e.g., `password_admin`, `rcon_password` for Arma 3) are returned in the response and not stored in plaintext. + **Response 201:** Returns full server object including auto-generated credentials. ### GET /servers/{server_id} @@ -163,6 +254,7 @@ Get server detail with full status. "data": { "id": 1, "name": "Main Server", + "game_type": "arma3", "status": "running", "pid": 12345, "game_port": 2302, @@ -174,8 +266,7 @@ Get server detail with full status. "cpu_percent": 34.2, "ram_mb": 1850.5, "started_at": "2026-04-16T10:00:00Z", - "uptime_seconds": 3600, - "current_mission": "MyMission.Altis" + "uptime_seconds": 3600 } } ``` @@ -187,7 +278,7 @@ Update server metadata (name, description, exe_path, ports). Admin only. Delete server (must be stopped first). Admin only. Removes DB rows and `servers/{id}/` directory. ### POST /servers/{server_id}/start -Start the server. Admin only. +Start the server. Admin only. Core resolves the adapter and delegates config generation + launch arg building. **Response 200:** ```json @@ -196,7 +287,7 @@ Start the server. Admin only. **Response 409:** Server already running. ### POST /servers/{server_id}/stop -Graceful stop (send `#shutdown` via RCon, then kill after 30s). Admin only. +Graceful stop (sends shutdown via adapter's RemoteAdmin, then force-kill after 30s). Admin only. **Request (optional):** ```json @@ -213,28 +304,46 @@ Force-kill the process immediately. Admin only. Emergency use only. ## Server Config Endpoints +Config sections are defined by the server's game adapter. The core `game_configs` table stores them as JSON. The adapter's Pydantic models validate input. + ### GET /servers/{server_id}/config -Get all config sections combined. +Get all config sections combined. Each section includes its `config_version` for optimistic locking. **Response 200:** ```json { "success": true, "data": { - "server": { /* server_configs row */ }, - "basic": { /* basic_configs row */ }, - "profile": { /* server_profiles row */ }, - "launch": { /* launch_params row */ }, - "rcon": { "rcon_password": "***", "max_ping": 200, "enabled": true } + "server": { /* section JSON */, "_meta": {"config_version": 3, "schema_version": "1.0"} }, + "basic": { /* section JSON */, "_meta": {"config_version": 1, "schema_version": "1.0"} }, + "profile": { /* section JSON */, "_meta": {"config_version": 2, "schema_version": "1.0"} }, + "launch": { /* section JSON */, "_meta": {"config_version": 1, "schema_version": "1.0"} }, + "rcon": { "rcon_password": "***", "max_ping": 200, "enabled": 1, "_meta": {"config_version": 1, "schema_version": "1.0"} } } } ``` -### PUT /servers/{server_id}/config/server -Update server.cfg settings. Admin only. +### GET /servers/{server_id}/config/{section} +Get a single config section. Section names are defined by the adapter (e.g., `server`, `basic`, `profile`, `launch`, `rcon` for Arma 3). -**Request:** Partial object matching `server_configs` columns (snake_case). Any omitted field keeps current value. +**Response 200:** +```json +{ + "success": true, + "data": { + "hostname": "My Server", + "max_players": 40, + "_meta": { "config_version": 3, "schema_version": "1.0" } + } +} +``` +### PUT /servers/{server_id}/config/{section} +Update a config section. Admin only. Validated against the adapter's Pydantic model for that section. Sensitive fields (passwords) are encrypted before storage. **Optimistic locking** — client must send `config_version` from their last read. + +**Request:** Partial object matching the adapter's section schema. Any omitted field keeps current value. Must include `config_version` for conflict detection. + +**Arma 3 `server` section example:** ```json { "hostname": "Updated Server Name", @@ -242,13 +351,27 @@ Update server.cfg settings. Admin only. "battleye": 1, "verify_signatures": 2, "motd_lines": ["Welcome!", "Have fun"], - "motd_interval": 5.0 + "motd_interval": 5.0, + "config_version": 3 } ``` -### PUT /servers/{server_id}/config/basic -Update basic.cfg (bandwidth) settings. Admin only. +**Response 409 (Conflict):** Another admin updated this section since you read it. +```json +{ + "success": false, + "data": { + "current_config": { /* latest values */ }, + "current_version": 5 + }, + "error": { + "code": "CONFIG_VERSION_CONFLICT", + "message": "Config section 'server' was modified by another user. Re-read and merge your changes." + } +} +``` +**Arma 3 `basic` section example:** ```json { "max_bandwidth": 50000000, @@ -256,9 +379,7 @@ Update basic.cfg (bandwidth) settings. Admin only. } ``` -### PUT /servers/{server_id}/config/profile -Update difficulty profile. Admin only. - +**Arma 3 `profile` section example:** ```json { "third_person_view": 0, @@ -269,28 +390,17 @@ Update difficulty profile. Admin only. } ``` -### PUT /servers/{server_id}/config/launch -Update launch parameters. Admin only. - +**Arma 3 `launch` section example:** ```json { "world": "empty", "limit_fps": 50, "auto_init": false, - "load_mission_to_memory": true, - "bandwidth_alg": 2, - "enable_ht": true, - "huge_pages": false + "load_mission_to_memory": true } ``` -### PUT /servers/{server_id}/config/rcon -Update BattlEye RCon settings. Admin only. -Regenerates `battleye/beserver.cfg` immediately. **Note:** BattlEye reads beserver.cfg only at server startup — RCon config changes require a server restart to take effect. The updated config file is ready for the next start. - -**Note on `rcon_port`:** This field is stored in the `servers` table (not `rcon_configs`). -The service layer updates both tables as needed. Include only fields you want to change. - +**Arma 3 `rcon` section example:** ```json { "rcon_password": "newpassword", @@ -300,20 +410,36 @@ The service layer updates both tables as needed. Include only fields you want to } ``` -### GET /servers/{server_id}/config/preview -Returns rendered `server.cfg` as plain text string (for preview in UI). **Admin only** — contains plaintext credentials. +**Note:** `rcon_port` is stored in the `servers` table, not in the config JSON. The service layer updates both tables as needed. -**Response 200:** `Content-Type: text/plain` +### GET /servers/{server_id}/config/preview +Returns rendered config for preview in UI. **Admin only** — may contain plaintext credentials. Returns a dict of `{label: rendered_content}` — labels are filenames for file-based configs, variable names for env-var configs, or argument names for CLI configs. + +**Response 200:** +```json +{ + "success": true, + "data": { + "server.cfg": "// Generated server.cfg\nhostname = \"My Server\";\n...", + "basic.cfg": "// Generated basic.cfg\n...", + "server.Arma3Profile": "// Generated profile\n..." + } +} +``` + +(Non-file games would return e.g. `{"SERVER_NAME": "My Server", "MAX_PLAYERS": "40"}` for env-var configs.) ### GET /servers/{server_id}/config/download/{filename} -Download generated config file. Filename must be one of: `server.cfg` | `basic.cfg` | `server.Arma3Profile` (whitelist-validated, no path traversal). **Admin only** — config files contain plaintext passwords. +Download generated config file. Filename must be in adapter's allowlist (whitelist-validated, no path traversal). **Admin only**. --- -## Mission Endpoints +## Mission Endpoints (Capability: `mission_manager`) + +Returns **404** if adapter does not support `mission_manager`. ### GET /servers/{server_id}/missions -List all mission PBOs for a server. +List all mission/scenario files for a server. **Response 200:** ```json @@ -333,10 +459,10 @@ List all mission PBOs for a server. ``` ### POST /servers/{server_id}/missions/upload -Upload a mission PBO. Admin only. `multipart/form-data`. +Upload a mission/scenario file. Admin only. `multipart/form-data`. File extension validated by adapter's `MissionManager.file_extension`. **Form fields:** -- `file`: the `.pbo` file (filename is sanitized with `os.path.basename()` to prevent path traversal; only `.pbo` extension allowed) +- `file`: the mission file (filename sanitized; only adapter-allowed extensions accepted) **Response 201:** ```json @@ -353,10 +479,10 @@ Upload a mission PBO. Admin only. `multipart/form-data`. ``` ### DELETE /servers/{server_id}/missions/{mission_id} -Delete a mission PBO (removes file from disk). Admin only. +Delete a mission file (removes file from disk). Admin only. ### GET /servers/{server_id}/missions/rotation -Get current mission rotation (ordered list). +Get current mission/scenario rotation (ordered list). **Response 200:** ```json @@ -368,7 +494,7 @@ Get current mission rotation (ordered list). "sort_order": 0, "mission": { "id": 1, "mission_name": "MyMission.Altis" }, "difficulty": "Regular", - "params": { "RespawnDelay": 15 } + "params_json": { "RespawnDelay": 15 } } ] } @@ -388,16 +514,21 @@ Replace the entire mission rotation. Admin only. --- -## Mod Endpoints +## Mod Endpoints (Capability: `mod_manager`) + +Returns **404** if adapter does not support `mod_manager`. ### GET /mods -List all registered mods (global list). +List all registered mods. Optionally filter by game type. + +**Query params:** `?game_type=arma3` ### POST /mods Register a mod folder. Admin only. ```json { + "game_type": "arma3", "name": "@CBA_A3", "folder_path": "C:/Arma3Server/@CBA_A3", "workshop_id": "450814997", @@ -452,26 +583,26 @@ Get currently connected players. "success": true, "data": [ { - "player_num": 1, + "slot_id": "1", "name": "PlayerOne", "guid": "abc123...", "ping": 45, - "verified": true, + "game_data": { "verified": true, "steam_uid": "76561198..." }, "joined_at": "2026-04-16T10:15:00Z" } ] } ``` -### POST /servers/{server_id}/players/{player_num}/kick -Kick a player via RCon. Admin only. +### POST /servers/{server_id}/players/{slot_id}/kick +Kick a player. Admin only. Requires adapter `remote_admin` capability. ```json { "reason": "AFK" } ``` -### POST /servers/{server_id}/players/{player_num}/ban -Ban a player via RCon. Admin only. +### POST /servers/{server_id}/players/{slot_id}/ban +Ban a player. Admin only. Requires adapter `remote_admin` capability. ```json { @@ -495,12 +626,11 @@ List all bans for a server. **Query params:** `?active_only=true&limit=50&offset=0` ### POST /servers/{server_id}/bans -Add ban manually. Admin only. +Add ban manually. Admin only. If adapter has `ban_manager`, also syncs to the game's ban file. ```json { "guid": "abc123...", - "steam_uid": "76561198...", "name": "PlayerName", "reason": "Cheating", "duration_minutes": 0 @@ -508,7 +638,37 @@ Add ban manually. Admin only. ``` ### DELETE /servers/{server_id}/bans/{ban_id} -Remove a ban. Admin only. +Remove a ban. Admin only. If adapter has `ban_manager`, also removes from the game's ban file. + +--- + +## Remote Admin Endpoints (Capability: `remote_admin`) + +Returns **404** if adapter does not support `remote_admin`. + +### POST /servers/{server_id}/remote-admin/command +Send raw remote admin command. Admin only. + +```json +{ "command": "#restart" } +``` + +**Arma 3 available commands:** +- `#restart` — Restart mission +- `#reassign` — Restart with roles unassigned +- `#missions` — Open mission selection +- `#lock` / `#unlock` — Lock/unlock server +- `#mission NAME.TERRAIN [difficulty]` — Load specific mission +- `#shutdown` — Shut down server +- `#monitor N` — Toggle performance monitoring +- `say -1 MESSAGE` — Message all players + +### POST /servers/{server_id}/remote-admin/say +Broadcast a message to all players. Admin only. + +```json +{ "message": "Server restarting in 5 minutes!" } +``` --- @@ -566,34 +726,6 @@ Get time-series metrics. --- -## RCon Endpoints - -### POST /servers/{server_id}/rcon/command -Send raw RCon/admin command. Admin only. - -```json -{ "command": "#restart" } -``` - -**Available commands:** -- `#restart` — Restart mission -- `#reassign` — Restart with roles unassigned -- `#missions` — Open mission selection -- `#lock` / `#unlock` — Lock/unlock server -- `#mission NAME.TERRAIN [difficulty]` — Load specific mission -- `#shutdown` — Shut down server -- `#monitor N` — Toggle performance monitoring -- `say -1 MESSAGE` — Message all players - -### POST /servers/{server_id}/rcon/say -Broadcast a message to all players. Admin only. - -```json -{ "message": "Server restarting in 5 minutes!" } -``` - ---- - ## Event Log Endpoints ### GET /servers/{server_id}/events @@ -615,6 +747,7 @@ Overall system status. **Requires authentication** (admin or viewer). "version": "1.0.0", "running_servers": 2, "total_servers": 3, + "supported_games": ["arma3"], "uptime_seconds": 86400 } } @@ -643,12 +776,9 @@ Use `server_id = "all"` to subscribe to events from all servers. **Channel subscription**: The `ConnectionManager` tracks per-connection channel subscriptions. Only messages matching subscribed channels are delivered. Default subscriptions on connect: `["status"]`. -**Channel names match message types exactly:** `status`, `log`, `players`, `metrics`, `event`. Subscribe with channel names matching the `type` field in server→client messages. - ### Server → Client Messages #### Status Update -Sent when server status changes (starting → running → stopped, etc.) ```json { "type": "status", @@ -662,7 +792,6 @@ Sent when server status changes (starting → running → stopped, etc.) ``` #### Log Line -Sent for each new RPT log line. ```json { "type": "log", @@ -676,14 +805,13 @@ Sent for each new RPT log line. ``` #### Player List Update -Sent after each RCon poll (every 10s). ```json { "type": "players", "server_id": 1, "data": { "players": [ - { "player_num": 1, "name": "PlayerOne", "ping": 45, "verified": true } + { "slot_id": "1", "name": "PlayerOne", "ping": 45 } ], "count": 1 } @@ -691,7 +819,6 @@ Sent after each RCon poll (every 10s). ``` #### Metrics Update -Sent every 5 seconds. ```json { "type": "metrics", @@ -706,7 +833,6 @@ Sent every 5 seconds. ``` #### Server Event -Sent for significant events (crash, restart, etc.) ```json { "type": "event", @@ -726,6 +852,19 @@ Sent for significant events (crash, restart, etc.) --- +## Adapter-Specific Routes + +Adapters may register additional routes under `/api/servers/{server_id}/game/{game_type}/...` for features that have no generic counterpart. + +**Arma 3 example:** +``` +POST /api/servers/{server_id}/game/arma3/battleye/reload +``` + +These routes are registered at app startup by iterating over all registered adapters. + +--- + ## Rate Limiting - `POST /auth/login`: 5 attempts per minute per IP. Exceeded returns `429 Too Many Requests`. @@ -741,15 +880,19 @@ Sent for significant events (crash, restart, etc.) | `UNAUTHORIZED` | Missing or invalid token | | `FORBIDDEN` | Role insufficient | | `NOT_FOUND` | Resource not found | +| `CAPABILITY_NOT_SUPPORTED` | Adapter lacks required capability for this endpoint | | `SERVER_ALREADY_RUNNING` | Start called on running server | | `SERVER_NOT_RUNNING` | Stop/command on stopped server | -| `RCON_UNAVAILABLE` | RCon connection failed | -| `INVALID_CONFIG` | Config validation failed | -| `EXE_NOT_FOUND` | arma3server.exe not at configured path | +| `REMOTE_ADMIN_UNAVAILABLE` | Remote admin connection failed | +| `INVALID_CONFIG` | Config validation failed (adapter-specific) | +| `CONFIG_WRITE_ERROR` | Config file write failed (disk, permissions) | +| `CONFIG_VERSION_CONFLICT` | Optimistic locking conflict on config update | +| `EXE_NOT_ALLOWED` | Executable not in adapter's allowlist | | `PORT_IN_USE` | Game port already occupied | -| `UPLOAD_FAILED` | Mission file upload error | +| `UPLOAD_FAILED` | File upload error | | `VALIDATION_ERROR` | Pydantic validation failure | +| `GAME_TYPE_NOT_FOUND` | No adapter registered for this game type | | `INTERNAL_ERROR` | Unexpected server error | | `MOD_IN_USE` | Cannot delete mod — enabled on one or more servers | | `MISSION_IN_ROTATION` | Cannot delete mission — in active rotation | -| `RATE_LIMITED` | Too many requests | +| `RATE_LIMITED` | Too many requests | \ No newline at end of file diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7bf1ac6..737b81a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -2,7 +2,9 @@ ## Overview -Languard is a web-based management panel for Arma 3 dedicated servers. It provides a Python backend that manages one or more `arma3server_x64.exe` processes, exposes a REST + WebSocket API to a React frontend, and persists all state in SQLite. +Languard is a **multi-game** web-based management panel for dedicated game servers. It uses a **game adapter architecture** where a game-agnostic core handles server lifecycle, monitoring, and real-time communication, while game-specific behavior (config formats, remote admin protocols, log parsing, mission/mod handling) is encapsulated in pluggable adapters. + +**Arma 3** is the first-class, built-in adapter. Adding a new game server type requires only a new adapter package — no core code changes. --- @@ -12,47 +14,64 @@ Languard is a web-based management panel for Arma 3 dedicated servers. It provid |-------|-----------|-----------| | Backend framework | **FastAPI** (Python 3.11+) | Async-native, built-in WebSocket, OpenAPI docs auto-generated | | Database | **SQLite** via `SQLAlchemy` (sync) | Zero-config, file-based, sufficient for single-host server manager; all access is synchronous (WAL mode for concurrent reads) | -| Process management | `subprocess` + `threading` | Wrap arma3server.exe, watch stdout/stderr, check exit codes; **cwd** set to server instance dir for relative paths; on Windows `terminate()` is a hard kill (no SIGTERM) | +| Process management | `subprocess` + `threading` | Wrap server executables, watch stdout/stderr, check exit codes; **cwd** set to server instance dir for relative paths; on Windows `terminate()` is a hard kill (no SIGTERM) | | Real-time comms | **WebSocket** (FastAPI) | Push log lines, player lists, server status to React | -| RCon client | Custom UDP client | BattlEye RCon protocol for in-game admin commands | -| Config generation | Python structured builder | Generate server.cfg, basic.cfg, server.Arma3Profile with proper escaping (no f-string injection) | -| Scheduling | `APScheduler` (BackgroundScheduler) | Auto-restart, mission rotation timers, log/metrics cleanup (sync DB ops → BackgroundScheduler, not AsyncIOScheduler) | +| Game adapters | **Protocol + Registry** | Each game implements capability protocols; core resolves the adapter at runtime from `server.game_type` | +| Scheduling | `APScheduler` (BackgroundScheduler) | Auto-restart, log/metrics cleanup (sync DB ops → BackgroundScheduler, not AsyncIOScheduler) | | Auth | **JWT** (python-jose) + bcrypt | Secure the API; React stores token in localStorage | -| Frontend | React + TypeScript (external repo) | Connects to this backend's API | +| Frontend | React + TypeScript + Vite + Tailwind | See FRONTEND.md for full design system, component architecture, and adapter-aware UI patterns | --- -## High-Level Architecture +## Architecture Overview ``` ┌─────────────────────────────────────────────────────────────┐ -│ React Frontend │ -│ Server List │ Server Detail │ Logs │ Players │ Config UI │ +│ React Frontend (see FRONTEND.md) │ +│ Dashboard │ Server List │ Server Detail │ Logs │ Config UI │ +│ Game Type Selector │ Adapter-specific Panels │ └────────────────────────┬────────────────────────────────────┘ │ HTTP REST + WebSocket ▼ ┌─────────────────────────────────────────────────────────────┐ -│ FastAPI Application │ +│ FastAPI Application (Core) │ │ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ │ │ Auth Router │ │ Server Router│ │ Config Router │ │ │ └──────────────┘ └──────────────┘ └──────────────────┘ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ -│ │Mission Router│ │ Mod Router │ │ WS Router │ │ +│ │ Player Router│ │ Log Router │ │ WS Router │ │ +│ └──────────────┘ └──────────────┘ └──────────────────┘ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ +│ │ Metric Router│ │ Event Router │ │ Games Router │ │ │ └──────────────┘ └──────────────┘ └──────────────────┘ │ │ │ │ ┌─────────────────────────────────────────────────────┐ │ -│ │ Service Layer │ │ -│ │ ServerService │ ConfigService │ RConService │ │ -│ │ LogService │ MetricsService│ MissionService │ │ +│ │ Core Service Layer │ │ +│ │ ServerService │ ConfigService │ PlayerService │ │ +│ │ LogService │ MetricsService│ EventService │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Game Adapter Registry │ │ +│ │ GameAdapterRegistry.get(game_type) → GameAdapter │ │ +│ │ │ │ +│ │ Delegates to: │ │ +│ │ • ConfigGenerator → adapter.get_config_generator()│ │ +│ │ • ProcessConfig → adapter.get_process_config() │ │ +│ │ • LogParser → adapter.get_log_parser() │ │ +│ │ • RemoteAdmin → adapter.get_remote_admin() │ │ +│ │ • MissionManager → adapter.get_mission_manager() │ │ +│ │ • ModManager → adapter.get_mod_manager() │ │ +│ │ • BanManager → adapter.get_ban_manager() │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ Thread Pool │ │ -│ │ ProcessMonitorThread (per server) │ │ -│ │ LogTailThread (per server) │ │ -│ │ MetricsCollectorThread (per server) │ │ -│ │ RConPollerThread (per server) │ │ +│ │ ProcessMonitorThread (per server, core) │ │ +│ │ LogTailThread (per server, core + adapter parser) │ │ +│ │ MetricsCollectorThread (per server, core) │ │ +│ │ RemoteAdminPollerThread (per server, core + adapter) │ │ │ │ BroadcastThread (global) │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ @@ -60,110 +79,213 @@ Languard is a web-based management panel for Arma 3 dedicated servers. It provid │ │ Data Access Layer (DAL) │ │ │ │ ServerRepository │ PlayerRepository │ │ │ │ LogRepository │ MetricsRepository │ │ +│ │ ConfigRepository (game_configs table) │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ │ ┌───────────────────┐ ┌────────────────────────────────┐ │ │ │ SQLite (DB) │ │ Filesystem │ │ -│ │ languard.db │ │ servers/{id}/server.cfg │ │ -│ │ │ │ servers/{id}/basic.cfg │ │ -│ │ │ │ servers/{id}/server/ │ │ ← profile dir (Arma3 -name=server) -│ │ │ │ server.Arma3Profile │ │ ← profile settings -│ │ │ │ arma3server_*.rpt │ │ ← RPT logs (tailable) -│ │ │ │ servers/{id}/battleye/ │ │ -│ │ │ │ beserver.cfg │ │ ← RCon config -│ │ │ │ servers/{id}/mpmissions/ │ │ +│ │ languard.db │ │ servers/{id}/ (layout by │ │ +│ │ │ │ adapter.get_process_config() │ │ │ └───────────────────┘ └────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ - │ subprocess - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ Arma 3 Server Processes (OS level) │ -│ arma3server_x64.exe (port 2302) │ -│ arma3server_x64.exe (port 2402) │ -│ ... │ -└─────────────────────────────────────────────────────────────┘ + │ + ┌─────────────┴─────────────────┐ + ▼ ▼ +┌──────────────────────┐ ┌──────────────────────────────────┐ +│ Game Adapters │ │ Game Server Processes (OS level) │ +│ │ │ │ +│ ┌────────────────┐ │ │ (Any game executable) │ +│ │ Arma 3 │ │ │ Started via adapter's │ +│ │ adapter │ │ │ build_launch_args() │ +│ │ │ │ │ │ +│ │ • ConfigGen │ │ └──────────────────────────────────┘ +│ │ • ProcessConfig│ │ +│ │ • RPTParser │ │ +│ │ • BERConClient │ │ +│ │ • MissionMgr │ │ +│ │ • ModMgr │ │ +│ │ • BanMgr │ │ +│ └────────────────┘ │ +│ │ +│ ┌────────────────┐ │ +│ │ (Future Game) │ │ +│ │ adapter │ │ +│ └────────────────┘ │ +└──────────────────────┘ +``` + +--- + +## Game Adapter Architecture + +### Core Principle: Composition Over Inheritance + +Each game adapter is a **composition of capability objects** implementing well-defined `Protocol` interfaces. Not every game supports every capability — adapters return `None` for unsupported features, and the core gracefully degrades. + +### Capability Protocols + +| Protocol | Purpose | Required? | +|----------|---------|-----------| +| `ConfigGenerator` | Define config schema + Pydantic models, write config files, build launch args | **Yes** | +| `ProcessConfig` | Exe allowlist, port conventions, directory layout | **Yes** | +| `LogParser` | Parse game-specific log lines, find log files | **Yes** | +| `RemoteAdmin` | Factory for RCon/Telnet/HTTP admin clients | No | +| `MissionManager` | Mission file format and rotation config | No | +| `ModManager` | Mod folder convention and CLI args | No | +| `BanManager` | Ban file sync between DB and game's ban file | No | + +### Adapter Registry + +```python +# adapters/registry.py +class GameAdapterRegistry: + _adapters: dict[str, GameAdapter] = {} + + @classmethod + def register(cls, adapter: GameAdapter) -> None: ... + + @classmethod + def get(cls, game_type: str) -> GameAdapter: ... + + @classmethod + def all(cls) -> list[GameAdapter]: ... + + @classmethod + def list_game_types(cls) -> list[dict]: ... +``` + +Adapters auto-register at import time. The core never imports adapter internals — it only resolves through the registry. + +### How Core Delegates to Adapter + +Every server has a `game_type` column. When core code needs game-specific behavior, it: + +1. Reads `server.game_type` from DB +2. Resolves `adapter = GameAdapterRegistry.get(game_type)` +3. Calls the appropriate adapter method + +**Example — Server start flow:** +```python +def start(self, server_id: int) -> dict: + server = ServerRepository(db).get_by_id(server_id) + adapter = GameAdapterRegistry.get(server["game_type"]) + process_config = adapter.get_process_config() + + # Core validation (game-agnostic) + exe_basename = Path(server["exe_path"]).name + if exe_basename not in process_config.get_allowed_executables(): + raise ValueError(f"Executable not allowed: {exe_basename}") + + # Adapter generates config files + config_gen = adapter.get_config_generator() + config_gen.write_configs(server_id, server_dir, config_sections) + + # Adapter builds launch args + launch_args = config_gen.build_launch_args(config_sections, mod_args) + + # Core launches process (game-agnostic) + pid = ProcessManager.get().start(server_id, exe_path, launch_args, cwd=server_dir) + + # Core starts threads (delegates to adapter for parsers/clients) + ThreadRegistry.start_server_threads(server_id, db) ``` --- ## Component Responsibilities -### FastAPI Routers +### FastAPI Routers (Core) - Validate input (Pydantic models) -- Call service layer +- Resolve adapter from `server.game_type` +- Delegate game-specific work to adapter - Return JSON responses - Handle WebSocket connections +- Return 404 with clear message if adapter lacks a capability -### Service Layer -- Orchestrate operations (start server = generate config + launch process + start threads) +### Core Service Layer +- Orchestrate operations (start server = resolve adapter + generate config + launch process + start threads) - No direct DB access — delegates to repositories - No direct process access — delegates to ProcessManager +- **No game-specific logic** — delegates to adapter -### ProcessManager +### ProcessManager (Core) - Singleton that owns all subprocess handles - Thread-safe dict: `{server_id: subprocess.Popen}` - `start()` sets `cwd=servers/{server_id}/` so relative config paths resolve correctly -- On Windows: `terminate()` = `TerminateProcess` (hard kill), no graceful SIGTERM — graceful shutdown must go through RCon `#shutdown` first +- On Windows: `terminate()` = `TerminateProcess` (hard kill, no SIGTERM) — graceful shutdown must go through adapter's RemoteAdmin - Provides: `start()`, `stop()`, `restart()`, `is_running()`, `send_command()` +- **Exe validation is delegated to adapter's ProcessConfig** — core no longer has a hardcoded allowlist ### Thread Pool (per running server) -| Thread | Interval | Purpose | -|--------|----------|---------| -| `ProcessMonitorThread` | 1s | Detect crash / unexpected exit; update DB status; trigger auto-restart | -| `LogTailThread` | 100ms | Read new lines from .rpt file; store in DB; push to WS clients | -| `MetricsCollectorThread` | 5s | Collect CPU%, RAM MB for the process via psutil; write to DB | -| `RConPollerThread` | 10s | Query connected players via BattlEye RCon; update DB player table | -| `BroadcastThread` | event-driven | Consume from internal queue; push JSON to all subscribed WS clients | -### RCon Client -- UDP socket to BattlEye RCon port (configured in `beserver.cfg` inside the server's `battleye/` directory) -- Implements BE RCon protocol: login, keepalive, send command, parse response -- **Request multiplexer**: tracks pending requests by sequence byte, routes responses to the correct caller via `threading.Event` per request. Prevents response misrouting when RConPollerThread and API-request RConService calls share the same UDP socket. -- Used by: `RConPollerThread`, `RConService` (for admin commands from UI) +| Thread | Source | Interval | Purpose | +|--------|--------|----------|---------| +| `ProcessMonitorThread` | Core | 1s | Detect crash / unexpected exit; update DB status; trigger auto-restart | +| `LogTailThread` | Core + adapter's LogParser | 100ms | Read new lines from log file; parse via adapter; store in DB; push to WS | +| `MetricsCollectorThread` | Core | 5s | Collect CPU%, RAM MB via psutil; write to DB | +| `RemoteAdminPollerThread` | Core + adapter's RemoteAdmin | 10s | Query players via adapter's admin client; update DB player table | +| `BroadcastThread` | Core | event-driven | Consume from internal queue; push JSON to all subscribed WS clients | -### Config Generator -- Takes `ServerConfig` Pydantic model from DB -- Renders `server.cfg`, `basic.cfg`, `*.Arma3Profile` using a **structured builder** (NOT f-strings — prevents config injection) -- Escapes double quotes and newlines in all user-supplied string values -- Writes files to `servers/{server_id}/` directory -- `server.Arma3Profile` written to `servers/{server_id}/server/` (Arma 3 reads from the `-name` subdirectory) +### ConfigRepository (Core) +- Manages the generic `game_configs` table +- Stores config as JSON blobs keyed by `(server_id, section)` +- **Validation is delegated to adapter's Pydantic models** — core never inspects config content +- **Sensitive field encryption**: calls `adapter.get_config_generator().get_sensitive_fields(section)` to identify which JSON keys to encrypt/decrypt via Fernet +- **Optimistic locking**: each row includes `config_version` (integer). On PUT, client sends the version they read. If version mismatch, return 409 Conflict. +- Provides: `get_section()`, `get_all_sections()`, `upsert_section()`, `delete_sections()` -### SQLite DAL -- Sync reads/writes using SQLAlchemy Core (not ORM — simpler for this use case) -- Thread-safe via SQLAlchemy's connection pooling -- One `languard.db` file at project root -- **PRAGMA busy_timeout=5000** — prevents "database is locked" errors under concurrent thread writes -- Thread-local connections via `get_thread_db()` — one connection per background thread +### Adapter Exceptions (Standard Error Types) + +Adapters raise specific exception types so the core can handle errors precisely: + +| Exception | When Raised | Core Action | +|-----------|------------|-------------| +| `ConfigWriteError` | File write fails (disk full, permissions) | Set server status='error', return 500 with detail | +| `ConfigValidationError` | Config values violate adapter constraints | Return 400 with field-level errors | +| `LaunchArgsError` | build_launch_args() fails (missing mod, bad path) | Set server status='error', return 400 | +| `RemoteAdminError` | Remote admin connection/command fails | Log warning, return 503 with detail | +| `ExeNotAllowedError` | Executable not in adapter's allowlist | Return 400 with allowed list | --- ## Data Flow: Start Server ``` -Frontend → POST /api/servers/{id}/start - → ServerService.start(server_id) - ├── Load ServerConfig from DB - ├── ConfigGenerator.write_configs(server_id, config) - │ ├── server.cfg → servers/{id}/server.cfg - │ ├── basic.cfg → servers/{id}/basic.cfg - │ ├── server.Arma3Profile → servers/{id}/server/server.Arma3Profile - │ └── beserver.cfg → servers/{id}/battleye/beserver.cfg - ├── ProcessManager.start(server_id, exe_path, args, cwd=servers/{id}/) - ├── DB: update server.status = "starting" - ├── Spawn ProcessMonitorThread(server_id) - ├── Spawn LogTailThread(server_id) — tails servers/{id}/server/arma3server_*.rpt - ├── Spawn MetricsCollectorThread(server_id) - ├── Spawn RConPollerThread(server_id) [after 30s delay for server startup] - └── BroadcastThread pushes status update to WS clients +Frontend → POST /api/servers/{id}/start + → ServerService.start(server_id) + ├── Load server from DB (includes game_type) + ├── adapter = GameAdapterRegistry.get(server.game_type) + ├── Validate exe against adapter.get_process_config().get_allowed_executables() + │ (raises ExeNotAllowedError → 400) + ├── Check ALL derived ports across ALL running servers + │ (resolve each server's adapter, get port conventions, check full set) + ├── Load config sections from game_configs table + ├── adapter.get_config_generator().write_configs(server_id, dir, config) + │ ATOMIC: writes to .tmp files first, then os.replace() to final paths + │ On failure: cleans up .tmp files, raises ConfigWriteError + │ Core: sets status='error', returns 500 + ├── launch_args = adapter.get_config_generator().build_launch_args(config, mods) + │ On failure: raises LaunchArgsError → 400 + ├── ProcessManager.start(server_id, exe_path, launch_args, cwd=dir) + ├── DB: update server.status = "starting" + ├── ThreadRegistry.start_server_threads(server_id, db) + │ ├── ProcessMonitorThread (core, always) + │ ├── LogTailThread(server_id, adapter.get_log_parser()) + │ ├── MetricsCollectorThread (core, always) + │ └── RemoteAdminPollerThread(server_id, adapter.get_remote_admin()) + │ (only if adapter has remote_admin capability) + │ Core wraps client with threading.Lock for thread safety + └── BroadcastThread pushes status update to WS clients ``` ## Data Flow: Real-time Logs ``` -arma3server.exe writes servers/{id}/server/arma3server_*.rpt - → LogTailThread reads new lines (recursive glob for *.rpt in profile dir) - → LogRepository.insert(server_id, line, timestamp) - → BroadcastQueue.put({type: "log", server_id, line, timestamp}) +Game server writes log file (path determined by adapter.get_log_parser().get_log_file_resolver()) + → LogTailThread reads new lines (core tailing logic, game-agnostic) + → adapter.get_log_parser().parse_line(line) → {timestamp, level, message} + → LogRepository.insert(server_id, entry) + → BroadcastQueue.put({type: "log", server_id, entry}) → BroadcastThread sends to all WS subscribers for this server → React frontend appends to log viewer ``` @@ -171,10 +293,10 @@ arma3server.exe writes servers/{id}/server/arma3server_*.rpt ## Data Flow: Player List ``` -RConPollerThread (every 10s) - → RConClient.send("players") - → Parse response: [{id, name, guid, ping, verified}] - → PlayerRepository.upsert_all(server_id, players) +RemoteAdminPollerThread (every 10s, core thread) + → adapter.get_remote_admin().create_client() → client instance + → client.get_players() → list of player dicts + → PlayerService.update_from_remote_admin(server_id, players) → BroadcastQueue.put({type: "players", server_id, players}) → React frontend updates player list ``` @@ -189,11 +311,48 @@ RConPollerThread (every 10s) - `admin` role: all operations - CORS configured to accept only the frontend origin - Passwords hashed with **bcrypt** (cost factor 12) -- `serverCommandPassword` and `passwordAdmin` stored encrypted in SQLite (AES-256 via `cryptography` library, key from env) -- **Port conflict validation** at server creation and start: checks game_port through game_port+4 (game, Steam query, Steam master, Steam auth, RCon) against all existing servers -- **ban.txt sync**: bans table is source of truth for UI; on ban add/delete via API, also write to `battleye/ban.txt`; on startup, read `ban.txt` and upsert into DB. Without this sync, DB-only bans are not enforced by BattlEye. -- Generated config files containing passwords (server.cfg, beserver.cfg) have restrictive file permissions (0600 on Unix, restricted ACL on Windows) +- Sensitive config fields (passwords, RCon passwords) stored encrypted in `game_configs` JSON (AES-256 via Fernet, key from env) +- **Port conflict validation** at server creation and start: uses adapter's `get_port_conventions()` to determine all derived ports +- **Ban file sync**: adapter's BanManager handles bidirectional sync between DB bans table and game's ban file format +- Generated config files containing passwords have restrictive file permissions (0600 on Unix, restricted ACL on Windows) - Input sanitization on all string fields before config generation — no shell injection or config directive injection +- **Exe validation**: core checks against adapter's `get_allowed_executables()` — prevents executing arbitrary binaries + +--- + +## Core vs Adapter Responsibility Boundary + +| Concern | Owner | Rationale | +|---------|-------|-----------| +| Server CRUD (name, description, status, game_type, ports) | **Core** | Universal concept | +| Process lifecycle (subprocess start, stop, kill) | **Core** | `subprocess.Popen` is game-agnostic | +| Config schema definition + file generation | **Adapter** | Schema, validation models, file formats, and launch args are all game-specific — unified in ConfigGenerator | +| Config CRUD in DB | **Core** | `game_configs` table is generic; adapter validates JSON | +| Process monitoring (crash detection, auto-restart) | **Core** | OS-level, game-agnostic | +| System metrics (CPU, RAM) | **Core** | psutil is game-agnostic | +| Log file tailing mechanics | **Core** | Tail -f is universal | +| Log line parsing | **Adapter** | RPT vs. server.log vs. custom JSON | +| Log file discovery | **Adapter** | Different naming conventions per game | +| Log storage and querying | **Core** | `logs` table is game-agnostic | +| Remote admin protocol | **Adapter** | BattlEye UDP vs. Source RCON vs. none | +| Remote admin polling | **Core** | Thread and interval logic are generic | +| Player identification | **Adapter** | GUID, Steam ID, UUID — game-specific | +| Player storage and history | **Core** | Generic table with `game_data` JSON | +| Ban concept | **Core** | Universal | +| Ban file sync | **Adapter** | `battleye/ban.txt` vs. `banned-players.json` vs. none | +| Mission/scenario file format | **Adapter** | PBO vs. PK3 vs. directory | +| Mission rotation config | **Adapter** | Game-specific format | +| Mission storage in DB | **Core** | Generic filename/metadata | +| Mod folder convention | **Adapter** | `@mod_folder` vs. `mods/` vs. `plugins/` | +| Mod CLI argument building | **Adapter** | `-mod=` vs. `+moddir` vs. none | +| Mod storage in DB | **Core** | Generic mod registration | +| Exe name allowlist | **Adapter** | Game-specific binary names | +| Port convention (derived ports) | **Adapter** | Arma 3: game+1/+2/+3 for Steam; varies per game | +| Profile directory convention | **Adapter** | Arma 3: `-name=server` subdirectory; varies | +| WebSocket real-time | **Core** | Transport is game-agnostic | +| Auth and user management | **Core** | No game dependency | +| Event/audit trail | **Core** | Generic; adapter can define additional event types | +| Scheduled cleanup | **Core** | Cron jobs are game-agnostic | --- @@ -201,14 +360,20 @@ RConPollerThread (every 10s) ```env LANGUARD_SECRET_KEY= -LANGUARD_ENCRYPTION_KEY= +LANGUARD_ENCRYPTION_KEY= LANGUARD_DB_PATH=./languard.db LANGUARD_SERVERS_DIR=./servers -LANGUARD_ARMA_EXE=C:/Arma3Server/arma3server_x64.exe LANGUARD_HOST=0.0.0.0 LANGUARD_PORT=8000 LANGUARD_CORS_ORIGINS=http://localhost:5173,http://localhost:3000 LANGUARD_LOG_RETENTION_DAYS=7 +LANGUARD_METRICS_RETENTION_DAYS=30 +LANGUARD_PLAYER_HISTORY_RETENTION_DAYS=90 + +# Game-specific defaults (adapter may use these) +LANGUARD_ARMA3_EXE=C:/Arma3Server/arma3server_x64.exe +# LANGUARD_MINECRAFT_JAR=C:/minecraft/server.jar +# LANGUARD_RUST_EXE=C:/RustDedicated/RustDedicated.exe ``` --- @@ -221,69 +386,94 @@ languard-servers-manager/ │ ├── main.py # FastAPI app factory │ ├── config.py # Settings from env │ ├── database.py # SQLAlchemy engine + session -│ ├── auth/ -│ │ ├── router.py -│ │ ├── service.py -│ │ └── schemas.py -│ ├── servers/ -│ │ ├── router.py # REST endpoints for servers -│ │ ├── service.py # ServerService -│ │ ├── process_manager.py # ProcessManager singleton -│ │ ├── config_generator.py # server.cfg / basic.cfg / beserver.cfg writer -│ │ └── schemas.py # Pydantic schemas -│ ├── rcon/ -│ │ ├── client.py # BattlEye RCon UDP client -│ │ └── service.py # RConService -│ ├── players/ -│ │ ├── router.py -│ │ ├── service.py -│ │ └── schemas.py -│ ├── missions/ -│ │ ├── router.py -│ │ └── service.py -│ ├── mods/ -│ │ ├── router.py -│ │ └── service.py -│ ├── logs/ -│ │ ├── router.py -│ │ └── service.py -│ ├── metrics/ -│ │ ├── router.py -│ │ └── service.py -│ ├── websocket/ -│ │ ├── router.py # WS connection handler -│ │ ├── manager.py # ConnectionManager (per-server subscriptions) -│ │ └── broadcaster.py # BroadcastThread + queue -│ ├── threads/ -│ │ ├── process_monitor.py # ProcessMonitorThread -│ │ ├── log_tail.py # LogTailThread -│ │ ├── metrics_collector.py # MetricsCollectorThread -│ │ └── rcon_poller.py # RConPollerThread -│ ├── system/ -│ │ └── router.py # GET /system/status, GET /system/health -│ ├── dal/ -│ │ ├── server_repository.py -│ │ ├── config_repository.py -│ │ ├── player_repository.py -│ │ ├── log_repository.py -│ │ ├── metrics_repository.py -│ │ ├── mission_repository.py -│ │ ├── mod_repository.py -│ │ ├── ban_repository.py -│ │ └── event_repository.py -│ └── migrations/ -│ └── 001_initial_schema.sql +│ ├── dependencies.py # Auth deps, server lookup +│ │ +│ ├── core/ # Game-agnostic core +│ │ ├── auth/ +│ │ │ ├── router.py +│ │ │ ├── service.py +│ │ │ ├── schemas.py +│ │ │ └── utils.py +│ │ ├── servers/ +│ │ │ ├── router.py # REST endpoints for servers +│ │ │ ├── service.py # ServerService (delegates to adapter) +│ │ │ ├── process_manager.py # ProcessManager singleton +│ │ │ └── schemas.py # Generic Pydantic schemas +│ │ ├── players/ +│ │ │ ├── router.py +│ │ │ ├── service.py +│ │ │ └── schemas.py +│ │ ├── logs/ +│ │ │ ├── router.py +│ │ │ └── service.py +│ │ ├── metrics/ +│ │ │ ├── router.py +│ │ │ └── service.py +│ │ ├── bans/ +│ │ │ ├── router.py +│ │ │ └── service.py +│ │ ├── events/ +│ │ │ ├── router.py +│ │ │ └── service.py +│ │ ├── websocket/ +│ │ │ ├── router.py +│ │ │ ├── manager.py +│ │ │ └── broadcaster.py +│ │ ├── threads/ +│ │ │ ├── base_thread.py +│ │ │ ├── process_monitor.py +│ │ │ ├── log_tail.py # Generic, takes adapter LogParser +│ │ │ ├── metrics_collector.py +│ │ │ ├── remote_admin_poller.py # Generic, takes adapter RemoteAdmin +│ │ │ └── thread_registry.py +│ │ ├── games/ +│ │ │ └── router.py # /api/games — type discovery, schemas +│ │ ├── system/ +│ │ │ └── router.py +│ │ ├── dal/ +│ │ │ ├── base_repository.py +│ │ │ ├── server_repository.py +│ │ │ ├── config_repository.py # game_configs table +│ │ │ ├── player_repository.py +│ │ │ ├── log_repository.py +│ │ │ ├── metrics_repository.py +│ │ │ ├── mission_repository.py +│ │ │ ├── mod_repository.py +│ │ │ ├── ban_repository.py +│ │ │ └── event_repository.py +│ │ ├── migrations/ +│ │ │ ├── runner.py +│ │ │ └── 001_initial_schema.sql +│ │ └── utils/ +│ │ ├── crypto.py +│ │ ├── file_utils.py +│ │ └── port_checker.py +│ │ +│ └── adapters/ # Game-specific adapters +│ ├── __init__.py +│ ├── registry.py # GameAdapterRegistry +│ ├── protocols.py # All capability Protocol definitions +│ │ +│ └── arma3/ # Arma 3 adapter (built-in) +│ ├── __init__.py # Exports ARMA3_ADAPTER, registers on import +│ ├── adapter.py # Arma3Adapter class +│ ├── config_generator.py # Pydantic models + server.cfg, basic.cfg, Arma3Profile, beserver.cfg +│ ├── rcon_client.py # BERConClient (BattlEye UDP protocol) +│ ├── rcon_service.py # Wraps BERConClient for RemoteAdmin protocol +│ ├── log_parser.py # RPTParser +│ ├── mission_manager.py # PBO upload, mission rotation config +│ ├── mod_manager.py # @mod_folder convention, -mod=/-serverMod= +│ ├── process_config.py # Exe allowlist, port conventions, profile dir +│ ├── ban_manager.py # battleye/ban.txt bidirectional sync +│ ├── schemas.py # Arma 3 specific request/response models +│ └── migrations/ +│ └── 001_arma3_metadata.sql # Arma 3 specific tables (if any) +│ ├── servers/ # Runtime data per server instance -│ └── {server_id}/ -│ ├── server.cfg -│ ├── basic.cfg -│ ├── server/ # Arma 3 profile dir (matches -name=server) -│ │ ├── server.Arma3Profile -│ │ └── arma3server_*.rpt # Timestamped RPT logs -│ ├── battleye/ -│ │ └── beserver.cfg # BattlEye RCon config (generated on start) -│ └── mpmissions/ -├── frontend/ # React app (separate repo or subfolder) +│ └── {server_id}/ # Layout determined by adapter.get_process_config() +│ └── (Arma 3: server.cfg, basic.cfg, server/, battleye/, mpmissions/) +│ +├── frontend/ # React app ├── requirements.txt ├── .env.example ├── ARCHITECTURE.md @@ -291,19 +481,67 @@ languard-servers-manager/ ├── API.md ├── MODULES.md ├── THREADING.md +├── FRONTEND.md └── IMPLEMENTATION_PLAN.md ``` --- +## Adding a New Game Adapter + +To add support for a new game, create an adapter package: + +``` +adapters// +├── __init__.py # Export adapter, register in registry +├── adapter.py # Implement GameAdapter protocol +├── config_generator.py # Pydantic models + write game config files +├── log_parser.py # Parse game log format +├── process_config.py # Exe allowlist, port conventions +├── ... # Optional: rcon_client, mission_manager, mod_manager, ban_manager +└── migrations/ # Optional: game-specific DB extensions +``` + +Steps: +1. Implement the required protocols: `ConfigGenerator`, `ProcessConfig`, `LogParser` +2. Implement optional protocols as needed: `RemoteAdmin`, `MissionManager`, `ModManager`, `BanManager` +3. Create the `GameAdapter` class composing all capabilities +4. Register the adapter — either: + - **Built-in**: register in `adapters/__init__.py` via import + - **Third-party**: register via setuptools entry_point in `pyproject.toml`: + ```toml + [project.entry-points."languard.adapters"] + mygame = "mygame_adapter:MYGAME_ADAPTER" + ``` + Core scans `languard.adapters` entry_point group at startup and auto-registers. +5. No core code changes, no DB migrations required + +--- + ## Key Design Decisions | Decision | Choice | Reason | |----------|--------|--------| -| Sync vs async DB | **Sync SQLAlchemy only** | All DB access is synchronous; background threads are non-async; `get_thread_db()` provides thread-local connections; no aiosqlite dependency | -| ORM vs Core | **SQLAlchemy Core** | Simpler SQL control, less magic for embedded use case | -| WebSocket auth | JWT in query param on connect | Browser WS API doesn't support headers; query param `?token=...` | +| Game-specific logic | **Adapter pattern with Protocol + Registry** | Structural subtyping with mypy enforcement; optional capabilities return None; zero core changes per game | +| Capability probe | **has_capability() on GameAdapter** | Instead of scattered None checks, GameAdapter.has_capability(name) returns bool. Core code uses this to check support before calling get methods. Cleaner than `if adapter.get_remote_admin() is not None:` everywhere. | +| Protocol granularity | **7 protocols (ConfigGenerator merged from ConfigSchema+ConfigGenerator)** | ConfigSchema and ConfigGenerator always co-occur (no game has schema without generation). Merged into single ConfigGenerator with schema + generation methods. ProcessConfig kept separate — may evolve independently. | +| Config storage | **Hybrid: core normalized + game_configs JSON** | Core tables stay clean; config is always whole-read/write; adapter Pydantic models validate; zero migration per new game | +| Sync vs async DB | **Sync SQLAlchemy only** | All DB access is synchronous; background threads are non-async; no aiosqlite dependency | +| WebSocket auth | JWT in query param on connect | Browser WS API doesn't support headers | | Process ownership | **ProcessManager singleton** | Single source of truth; prevents duplicate launches | -| Log storage | **DB + rolling file** | DB for fast queries/streaming; raw .rpt preserved on disk | -| Config files | **Regenerate on each start** | Always fresh from DB; no sync drift between DB and filesystem; **structured builder** (not f-strings) prevents config injection | -| RCon port convention | **User-configurable** | BattlEye RCon port is set in `beserver.cfg` (inside `battleye/` dir). Default suggestion: game port + 4 (e.g., 2302 → 2306). Must not conflict with game (2302), Steam query (2303), VON (2304), or Steam auth (2305) ports. **Note:** RCon config changes require server restart — BattlEye reads beserver.cfg only at startup. | +| Config files | **Adapter regenerates on each start** | Always fresh from DB; no sync drift; adapter's structured builder prevents config injection | +| Config write failure | **Atomic write + rollback** | Adapter writes to temp files first, then atomic rename. On failure, temp files are cleaned up — original files remain untouched. Server start never proceeds with partial config. | +| Sensitive field encryption | **Adapter declares via get_sensitive_fields()** | ConfigGenerator protocol returns list of JSON keys per section that need Fernet encryption. Core's ConfigRepository handles encrypt/decrypt transparently. | +| Adapter schema versioning | **config_version in game_configs row** | Each config section row stores a version string. On adapter update, if version differs, adapter provides a migration function. | +| Adapter error communication | **Typed adapter exceptions** | Adapters raise specific exception types (ConfigWriteError, ConfigValidationError, LaunchArgsError, RemoteAdminError). Core catches specifically and sets appropriate DB status + returns clear API errors. | +| Remote admin thread safety | **Core wraps with lock** | Core wraps RemoteAdminClient calls with a threading.Lock. Adapter clients don't need to be thread-safe. One lock per server — API requests and poller thread share safely. | +| Third-party adapter loading | **Setuptools entry_points** | Third-party adapters register via `languard.adapters` entry_point group. Core scans entry_points at startup and auto-registers. Built-in adapters registered on import. | +| Port conflict detection | **Full cross-game check** | When checking ports for a new/starting server, query ALL running servers, resolve each adapter, get port conventions for that game, and check the full derived port set. | +| Config preview | **Dict of label → content** | preview_config() returns {label: rendered_content}. File-based games use filename as label; env-var games use variable name; CLI games use argument name. Frontend renders all as labeled text blocks. | +| Ban file sync timing | **Immediate + startup** | On every ban add/delete via API, adapter's BanManager syncs to file immediately. On startup, adapter reads ban file and upserts into DB. Ensures consistency. | +| Config concurrency | **Optimistic locking** | game_configs rows include config_version (integer). On PUT, client sends the version they read. If version mismatch, return 409 Conflict. Frontend re-reads and merges. | +| game_data JSON schema | **Adapter declares via get_game_data_schema()** | Each capability protocol (MissionManager, ModManager, etc.) optionally returns a Pydantic model for the game_data JSON. Core validates on write. | +| Log storage | **DB + rolling file** | DB for fast queries/streaming; raw logs preserved on disk | +| Player identification | **slot_id (string) + game_data JSON** | Flexible across games; Arma 3 uses int slot, others may use UUID | +| Route URLs | **Game-agnostic with adapter delegation** | Frontend doesn't need game-type-specific URLs; 404 if adapter lacks capability | +| API route registration | **Core defines all routes; adapter dispatch at request time** | Simpler than dynamic route mounting; clear 404 for unsupported features | \ No newline at end of file diff --git a/DATABASE.md b/DATABASE.md index 2fde55c..3127434 100644 --- a/DATABASE.md +++ b/DATABASE.md @@ -9,6 +9,19 @@ --- +## Design Philosophy + +The database uses a **hybrid approach**: core tables are fully normalized (game-agnostic), while game-specific config is stored as JSON blobs in a generic `game_configs` table, validated by adapter Pydantic models at the application layer. + +**Why this works for Languard:** +- Config is always read/written as a whole section (nobody queries "find all servers where von_codec_quality > 20") +- Each adapter section maps to a Pydantic model, so validation is enforced at the application layer +- The JSON is opaque to the core, meaningful only to the adapter that owns it +- Adding a new game requires **zero DB migration** — just a new adapter +- Core queries across all games work naturally + +--- + ## Schema ### Table: `users` @@ -31,29 +44,28 @@ CREATE TABLE users ( ### Table: `servers` -One row per managed Arma 3 server instance. +One row per managed server instance. **Game-agnostic.** ```sql CREATE TABLE servers ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, -- display name in UI description TEXT, + game_type TEXT NOT NULL DEFAULT 'arma3', -- adapter lookup key status TEXT NOT NULL DEFAULT 'stopped', - -- status values: 'stopped' | 'starting' | 'running' | 'stopping' | 'crashed' | 'error' CHECK (status IN ('stopped', 'starting', 'running', 'stopping', 'crashed', 'error')), - CHECK (game_port BETWEEN 1024 AND 65535), - CHECK (rcon_port BETWEEN 1024 AND 65535), -- Process info pid INTEGER, -- OS process ID when running - exe_path TEXT NOT NULL, -- path to arma3server_x64.exe + exe_path TEXT NOT NULL, -- path to server executable started_at TEXT, -- ISO datetime stopped_at TEXT, - -- Network - game_port INTEGER NOT NULL DEFAULT 2302, - rcon_port INTEGER NOT NULL DEFAULT 2306, -- user-configurable; written to battleye/beserver.cfg - steam_query_port INTEGER GENERATED ALWAYS AS (game_port + 1) VIRTUAL, -- convention, not enforced by engine + -- Network (core ports; adapter defines derived port conventions) + game_port INTEGER NOT NULL, + rcon_port INTEGER, -- NULL if game has no remote admin + CHECK (game_port BETWEEN 1024 AND 65535), + CHECK (rcon_port IS NULL OR (rcon_port BETWEEN 1024 AND 65535)), -- Auto-management auto_restart INTEGER NOT NULL DEFAULT 0, -- 1 = restart on crash @@ -67,262 +79,231 @@ CREATE TABLE servers ( ); CREATE INDEX idx_servers_status ON servers(status); +CREATE INDEX idx_servers_game_type ON servers(game_type); CREATE INDEX idx_servers_game_port ON servers(game_port); -CREATE INDEX idx_servers_rcon_port ON servers(rcon_port); ``` +**Key changes from single-game design:** +- Added `game_type` column (defaults to `'arma3'` for backward compatibility) +- Removed Arma 3-specific defaults (`DEFAULT 2302`, `DEFAULT 2306`) — port defaults are now adapter-provided +- Removed `steam_query_port` virtual column (Arma 3 convention moved to adapter) +- `rcon_port` is now nullable (some games have no remote admin) + --- -### Table: `server_configs` +### Table: `game_configs` -Stores all parameters for generating `server.cfg`. One row per server. +Stores all game-specific configuration as JSON blobs, keyed by section. **Replaces** the previous `server_configs`, `basic_configs`, `server_profiles`, `launch_params`, and `rcon_configs` tables. ```sql -CREATE TABLE server_configs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - server_id INTEGER NOT NULL UNIQUE REFERENCES servers(id) ON DELETE CASCADE, - - -- Basic identity - hostname TEXT NOT NULL DEFAULT 'My Arma 3 Server', - password TEXT, -- join password (encrypted at app layer via Fernet) - password_admin TEXT NOT NULL, -- encrypted (no default — must be set on creation) - server_command_password TEXT, -- encrypted - - -- Players - max_players INTEGER NOT NULL DEFAULT 40, - kick_duplicate INTEGER NOT NULL DEFAULT 1, - persistent INTEGER NOT NULL DEFAULT 1, - - -- Voting - vote_threshold REAL NOT NULL DEFAULT 0.33, - vote_mission_players INTEGER NOT NULL DEFAULT 1, - vote_timeout INTEGER NOT NULL DEFAULT 60, -- seconds - role_timeout INTEGER NOT NULL DEFAULT 90, - briefing_timeout INTEGER NOT NULL DEFAULT 60, - debriefing_timeout INTEGER NOT NULL DEFAULT 45, - lobby_idle_timeout INTEGER NOT NULL DEFAULT 300, - - -- Voice - disable_von INTEGER NOT NULL DEFAULT 0, - von_codec INTEGER NOT NULL DEFAULT 1, -- 1 = OPUS - CHECK (von_codec IN (0, 1)), - von_codec_quality INTEGER NOT NULL DEFAULT 20, -- 1-30 - - -- Network quality kick thresholds - max_ping INTEGER NOT NULL DEFAULT 250, - max_packet_loss INTEGER NOT NULL DEFAULT 50, - max_desync INTEGER NOT NULL DEFAULT 200, - disconnect_timeout INTEGER NOT NULL DEFAULT 15, - kick_on_ping INTEGER NOT NULL DEFAULT 1, - kick_on_packet_loss INTEGER NOT NULL DEFAULT 1, - kick_on_desync INTEGER NOT NULL DEFAULT 1, - kick_on_timeout INTEGER NOT NULL DEFAULT 1, - - -- Security - battleye INTEGER NOT NULL DEFAULT 1, - verify_signatures INTEGER NOT NULL DEFAULT 2, -- 0 | 1 | 2 (1 = check but don't kick) - allowed_file_patching INTEGER NOT NULL DEFAULT 0, -- 0 | 1 | 2 - - -- Difficulty - forced_difficulty TEXT NOT NULL DEFAULT 'Regular', -- Recruit | Regular | Veteran | Custom - - -- Misc - timestamp_format TEXT NOT NULL DEFAULT 'short', -- none | short | full - auto_select_mission INTEGER NOT NULL DEFAULT 0, - random_mission_order INTEGER NOT NULL DEFAULT 0, - missions_to_restart INTEGER NOT NULL DEFAULT 0, - missions_to_shutdown INTEGER NOT NULL DEFAULT 0, - log_file TEXT NOT NULL DEFAULT 'server_console.log', - skip_lobby INTEGER NOT NULL DEFAULT 0, - drawing_in_map INTEGER NOT NULL DEFAULT 1, - upnp INTEGER NOT NULL DEFAULT 0, - loopback INTEGER NOT NULL DEFAULT 0, - statistics_enabled INTEGER NOT NULL DEFAULT 1, - force_rotor_lib INTEGER NOT NULL DEFAULT 0, -- 0=player, 1=AFM, 2=SFM - CHECK (force_rotor_lib IN (0, 1, 2)), - required_build INTEGER NOT NULL DEFAULT 0, - steam_protocol_max_data_size INTEGER NOT NULL DEFAULT 1024, - - -- MOTD - motd_lines TEXT NOT NULL DEFAULT '[]', -- JSON array of strings - motd_interval REAL NOT NULL DEFAULT 5.0, - - -- Event scripts - on_user_connected TEXT DEFAULT '', - on_user_disconnected TEXT DEFAULT '', - on_unsigned_data TEXT DEFAULT 'kick (_this select 0)', - on_hacked_data TEXT DEFAULT 'kick (_this select 0)', - double_id_detected TEXT DEFAULT '', - - -- Headless clients (JSON arrays) - headless_clients TEXT NOT NULL DEFAULT '[]', -- e.g. '["127.0.0.1"]' - local_clients TEXT NOT NULL DEFAULT '[]', - - -- Admin UIDs whitelist - admin_uids TEXT NOT NULL DEFAULT '[]', -- JSON array of Steam UIDs - - -- File extension whitelists (JSON arrays) - allowed_load_extensions TEXT NOT NULL DEFAULT '["hpp","sqs","sqf","fsm","cpp","paa","txt","xml","inc","ext","sqm","ods","fxy","lip","csv","kb","bik","bikb","html","htm","biedi"]', - allowed_preprocess_extensions TEXT NOT NULL DEFAULT '["hpp","sqs","sqf","fsm","cpp","paa","txt","xml","inc","ext","sqm","ods","fxy","lip","csv","kb","bik","bikb","html","htm","biedi"]', - allowed_html_extensions TEXT NOT NULL DEFAULT '["htm","html","xml","txt"]', - - updated_at TEXT NOT NULL DEFAULT (datetime('now')), - - CHECK (verify_signatures IN (0, 1, 2)), - CHECK (allowed_file_patching IN (0, 1, 2)), - CHECK (von_codec_quality BETWEEN 1 AND 30), - CHECK (forced_difficulty IN ('Recruit', 'Regular', 'Veteran', 'Custom')), - CHECK (vote_threshold >= 0.0 AND vote_threshold <= 1.0), - CHECK (max_players > 0) +CREATE TABLE game_configs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + game_type TEXT NOT NULL, -- for validation; matches servers.game_type + section TEXT NOT NULL, -- e.g. 'server', 'basic', 'profile', 'launch', 'rcon' + config_json TEXT NOT NULL DEFAULT '{}', -- JSON validated by adapter's Pydantic model + config_version INTEGER NOT NULL DEFAULT 1, -- optimistic locking version; incremented on each write + schema_version TEXT NOT NULL DEFAULT '1.0', -- adapter schema version at time of last write + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(server_id, section) ); + +CREATE INDEX idx_game_configs_server ON game_configs(server_id); +CREATE INDEX idx_game_configs_type_section ON game_configs(game_type, section); ``` ---- +**How it works:** +- Each adapter defines config sections via `ConfigGenerator.get_sections()` → `{section_name: PydanticModelClass}` +- Core reads/writes JSON blobs; adapter validates them on write and parses them on read +- Arma 3 has 5 sections: `server`, `basic`, `profile`, `launch`, `rcon` +- Another game might have 2 sections or 8 — no migration needed +- Sensitive fields within JSON (passwords, rcon_password) are encrypted at the application layer via Fernet before storage -### Table: `basic_configs` +**Arma 3 config sections and their JSON structures:** -Stores `basic.cfg` (bandwidth) settings. One row per server. - -```sql -CREATE TABLE basic_configs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - server_id INTEGER NOT NULL UNIQUE REFERENCES servers(id) ON DELETE CASCADE, - - min_bandwidth INTEGER NOT NULL DEFAULT 800000, - max_bandwidth INTEGER NOT NULL DEFAULT 25000000, - max_msg_send INTEGER NOT NULL DEFAULT 384, -- default 128; higher = desync risk - max_size_guaranteed INTEGER NOT NULL DEFAULT 512, - max_size_non_guaranteed INTEGER NOT NULL DEFAULT 256, - min_error_to_send REAL NOT NULL DEFAULT 0.003, - max_custom_file_size INTEGER NOT NULL DEFAULT 100000, - - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); +Section `server` — maps to `server.cfg` parameters: +```json +{ + "hostname": "My Arma 3 Server", + "password": "encrypted:...", + "password_admin": "encrypted:...", + "server_command_password": "encrypted:...", + "max_players": 40, + "kick_duplicate": 1, + "persistent": 1, + "vote_threshold": 0.33, + "vote_mission_players": 1, + "vote_timeout": 60, + "role_timeout": 90, + "briefing_timeout": 60, + "debriefing_timeout": 45, + "lobby_idle_timeout": 300, + "disable_von": 0, + "von_codec": 1, + "von_codec_quality": 20, + "max_ping": 250, + "max_packet_loss": 50, + "max_desync": 200, + "disconnect_timeout": 15, + "kick_on_ping": 1, + "kick_on_packet_loss": 1, + "kick_on_desync": 1, + "kick_on_timeout": 1, + "battleye": 1, + "verify_signatures": 2, + "allowed_file_patching": 0, + "forced_difficulty": "Regular", + "timestamp_format": "short", + "auto_select_mission": 0, + "random_mission_order": 0, + "missions_to_restart": 0, + "missions_to_shutdown": 0, + "log_file": "server_console.log", + "skip_lobby": 0, + "drawing_in_map": 1, + "upnp": 0, + "loopback": 0, + "statistics_enabled": 1, + "force_rotor_lib": 0, + "required_build": 0, + "steam_protocol_max_data_size": 1024, + "motd_lines": ["Welcome!", "Have fun"], + "motd_interval": 5.0, + "on_user_connected": "", + "on_user_disconnected": "", + "on_unsigned_data": "kick (_this select 0)", + "on_hacked_data": "kick (_this select 0)", + "double_id_detected": "", + "headless_clients": [], + "local_clients": [], + "admin_uids": [], + "allowed_load_extensions": ["hpp","sqs","sqf","fsm","cpp","paa","txt","xml","inc","ext","sqm","ods","fxy","lip","csv","kb","bik","bikb","html","htm","biedi"], + "allowed_preprocess_extensions": ["hpp","sqs","sqf","fsm","cpp","paa","txt","xml","inc","ext","sqm","ods","fxy","lip","csv","kb","bik","bikb","html","htm","biedi"], + "allowed_html_extensions": ["htm","html","xml","txt"] +} ``` ---- - -### Table: `server_profiles` - -Stores `server.Arma3Profile` difficulty settings. One row per server. - -```sql -CREATE TABLE server_profiles ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - server_id INTEGER NOT NULL UNIQUE REFERENCES servers(id) ON DELETE CASCADE, - - -- Custom difficulty options (all 0/1 or 0/1/2) - reduced_damage INTEGER NOT NULL DEFAULT 0, - group_indicators INTEGER NOT NULL DEFAULT 0, - friendly_tags INTEGER NOT NULL DEFAULT 0, - enemy_tags INTEGER NOT NULL DEFAULT 0, - detected_mines INTEGER NOT NULL DEFAULT 0, - commands INTEGER NOT NULL DEFAULT 1, - waypoints INTEGER NOT NULL DEFAULT 1, - tactical_ping INTEGER NOT NULL DEFAULT 0, - weapon_info INTEGER NOT NULL DEFAULT 2, - stance_indicator INTEGER NOT NULL DEFAULT 2, - stamina_bar INTEGER NOT NULL DEFAULT 0, - weapon_crosshair INTEGER NOT NULL DEFAULT 0, - vision_aid INTEGER NOT NULL DEFAULT 0, - third_person_view INTEGER NOT NULL DEFAULT 0, - camera_shake INTEGER NOT NULL DEFAULT 1, - score_table INTEGER NOT NULL DEFAULT 1, - death_messages INTEGER NOT NULL DEFAULT 1, - von_id INTEGER NOT NULL DEFAULT 1, - map_content_friendly INTEGER NOT NULL DEFAULT 0, - map_content_enemy INTEGER NOT NULL DEFAULT 0, - map_content_mines INTEGER NOT NULL DEFAULT 0, - auto_report INTEGER NOT NULL DEFAULT 0, - multiple_saves INTEGER NOT NULL DEFAULT 0, - - -- AI level - ai_level_preset INTEGER NOT NULL DEFAULT 3, -- 0=Low,1=Normal,2=High,3=Custom - skill_ai REAL NOT NULL DEFAULT 0.5, - precision_ai REAL NOT NULL DEFAULT 0.5, - - CHECK (ai_level_preset BETWEEN 0 AND 3), - CHECK (skill_ai BETWEEN 0.0 AND 1.0), - CHECK (precision_ai BETWEEN 0.0 AND 1.0), - CHECK (group_indicators BETWEEN 0 AND 2), - CHECK (weapon_info BETWEEN 0 AND 2), - CHECK (stance_indicator BETWEEN 0 AND 2), - - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); +Section `basic` — maps to `basic.cfg` parameters: +```json +{ + "min_bandwidth": 800000, + "max_bandwidth": 25000000, + "max_msg_send": 384, + "max_size_guaranteed": 512, + "max_size_non_guaranteed": 256, + "min_error_to_send": 0.003, + "max_custom_file_size": 100000 +} ``` ---- +Section `profile` — maps to `server.Arma3Profile` difficulty: +```json +{ + "reduced_damage": 0, + "group_indicators": 0, + "friendly_tags": 0, + "enemy_tags": 0, + "detected_mines": 0, + "commands": 1, + "waypoints": 1, + "tactical_ping": 0, + "weapon_info": 2, + "stance_indicator": 2, + "stamina_bar": 0, + "weapon_crosshair": 0, + "vision_aid": 0, + "third_person_view": 0, + "camera_shake": 1, + "score_table": 1, + "death_messages": 1, + "von_id": 1, + "map_content_friendly": 0, + "map_content_enemy": 0, + "map_content_mines": 0, + "auto_report": 0, + "multiple_saves": 0, + "ai_level_preset": 3, + "skill_ai": 0.5, + "precision_ai": 0.5 +} +``` -### Table: `launch_params` +Section `launch` — maps to CLI launch parameters: +```json +{ + "world": "empty", + "extra_params": "", + "limit_fps": 50, + "auto_init": 0, + "load_mission_to_memory": 0, + "bandwidth_alg": null, + "enable_ht": 0, + "huge_pages": 0, + "cpu_count": null, + "ex_threads": 7, + "max_mem": null, + "no_logs": 0, + "netlog": 0 +} +``` -Extra command-line parameters added to the server launch command. One row per server. - -```sql -CREATE TABLE launch_params ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - server_id INTEGER NOT NULL UNIQUE REFERENCES servers(id) ON DELETE CASCADE, - - world TEXT NOT NULL DEFAULT 'empty', - extra_params TEXT NOT NULL DEFAULT '', -- raw extra params string - limit_fps INTEGER NOT NULL DEFAULT 50, - auto_init INTEGER NOT NULL DEFAULT 0, - load_mission_to_memory INTEGER NOT NULL DEFAULT 0, - bandwidth_alg INTEGER, -- NULL | 2 - CHECK (bandwidth_alg IS NULL OR bandwidth_alg = 2), - enable_ht INTEGER NOT NULL DEFAULT 0, - huge_pages INTEGER NOT NULL DEFAULT 0, - cpu_count INTEGER, -- NULL = auto - ex_threads INTEGER NOT NULL DEFAULT 7, - max_mem INTEGER, -- NULL = auto - no_logs INTEGER NOT NULL DEFAULT 0, - netlog INTEGER NOT NULL DEFAULT 0, - - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); +Section `rcon` — BattlEye RCon settings: +```json +{ + "rcon_password": "encrypted:...", + "max_ping": 200, + "enabled": 1 +} ``` --- ### Table: `mods` -Registered mods. Many-to-many with servers. +Registered mods. Many-to-many with servers. Scoped by `game_type`. ```sql CREATE TABLE mods ( id INTEGER PRIMARY KEY AUTOINCREMENT, + game_type TEXT NOT NULL, -- scope mods by game type name TEXT NOT NULL, - folder_path TEXT NOT NULL UNIQUE, -- absolute or relative path - workshop_id TEXT, -- Steam Workshop ID if applicable + folder_path TEXT NOT NULL, + workshop_id TEXT, -- Steam Workshop ID if applicable description TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')) + game_data TEXT DEFAULT '{}', -- JSON for game-specific mod metadata + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (game_type, folder_path) ); CREATE TABLE server_mods ( server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE, mod_id INTEGER NOT NULL REFERENCES mods(id) ON DELETE CASCADE, - is_server_mod INTEGER NOT NULL DEFAULT 0, -- 1 = -serverMod (not broadcast to clients) + is_server_mod INTEGER NOT NULL DEFAULT 0, -- 1 = server-side only (not broadcast to clients) sort_order INTEGER NOT NULL DEFAULT 0, + game_data TEXT DEFAULT '{}', -- JSON for per-server mod overrides PRIMARY KEY (server_id, mod_id) ); CREATE INDEX idx_server_mods_server ON server_mods(server_id); ``` +**Key changes:** Added `game_type` to `mods` (scopes the mod registry per game). Added `game_data` JSON columns for game-specific metadata that doesn't fit the common schema. + --- ### Table: `missions` -Mission PBO files tracked per server. +Mission/scenario files tracked per server. ```sql CREATE TABLE missions ( id INTEGER PRIMARY KEY AUTOINCREMENT, server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE, filename TEXT NOT NULL, -- e.g. "MyMission.Altis.pbo" - mission_name TEXT NOT NULL, -- e.g. "MyMission.Altis" - terrain TEXT NOT NULL, -- e.g. "Altis" + mission_name TEXT NOT NULL, -- parsed by adapter + terrain TEXT, -- may be NULL for non-Arma games file_size INTEGER, -- bytes + game_data TEXT DEFAULT '{}', -- JSON for game-specific mission metadata uploaded_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE (server_id, filename) ); @@ -330,11 +311,13 @@ CREATE TABLE missions ( CREATE INDEX idx_missions_server ON missions(server_id); ``` +**Key changes:** `terrain` is now nullable (not all games use the mission/terrain naming convention). Added `game_data` for adapter-specific metadata. + --- ### Table: `mission_rotation` -Ordered mission cycle for a server. +Ordered mission/scenario cycle for a server. ```sql CREATE TABLE mission_rotation ( @@ -342,40 +325,43 @@ CREATE TABLE mission_rotation ( server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE, mission_id INTEGER NOT NULL REFERENCES missions(id) ON DELETE CASCADE, sort_order INTEGER NOT NULL DEFAULT 0, - difficulty TEXT NOT NULL DEFAULT 'Regular', - CHECK (difficulty IN ('Recruit', 'Regular', 'Veteran', 'Custom')), - params_json TEXT NOT NULL DEFAULT '{}', -- mission params override as JSON + difficulty TEXT, -- game-specific (NULL for games without difficulty) + params_json TEXT NOT NULL DEFAULT '{}', -- mission params as JSON + game_data TEXT DEFAULT '{}', -- adapter-specific rotation metadata UNIQUE (server_id, sort_order) ); CREATE INDEX idx_mission_rotation_server ON mission_rotation(server_id); ``` +**Key changes:** `difficulty` is now nullable. Added `game_data` for adapter-specific rotation config. + --- ### Table: `players` -Currently connected players (live state, refreshed by RConPollerThread). +Currently connected players (live state, refreshed by RemoteAdminPollerThread). ```sql CREATE TABLE players ( id INTEGER PRIMARY KEY AUTOINCREMENT, server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE, - player_num INTEGER NOT NULL, -- BE player# (slot number) + slot_id TEXT NOT NULL, -- Game-specific slot identifier (was player_num int) name TEXT NOT NULL, - guid TEXT, -- BattlEye GUID - steam_uid TEXT, + guid TEXT, -- Game-specific identifier (BattlEye GUID, Steam ID, etc.) ip TEXT, ping INTEGER, - verified INTEGER NOT NULL DEFAULT 0, -- 1 = signature verified + game_data TEXT DEFAULT '{}', -- JSON: {verified, steam_uid, etc.} for Arma 3 joined_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), - UNIQUE (server_id, player_num) + UNIQUE (server_id, slot_id) ); CREATE INDEX idx_players_server ON players(server_id); ``` +**Key changes:** `player_num` (INTEGER) renamed to `slot_id` (TEXT) for flexibility across games. `steam_uid` moved into `game_data` JSON (Arma 3 specific). `verified` moved into `game_data` JSON. + --- ### Table: `player_history` @@ -388,15 +374,15 @@ CREATE TABLE player_history ( server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE, name TEXT NOT NULL, guid TEXT, - steam_uid TEXT, ip TEXT, + game_data TEXT DEFAULT '{}', -- JSON for game-specific historical data joined_at TEXT NOT NULL, left_at TEXT NOT NULL DEFAULT (datetime('now')), session_duration_seconds INTEGER ); CREATE INDEX idx_player_history_server ON player_history(server_id); -CREATE INDEX idx_player_history_steam ON player_history(steam_uid); +CREATE INDEX idx_player_history_guid ON player_history(guid); ``` ### Player History Retention Cleanup (run daily via APScheduler, keep 90 days) @@ -409,42 +395,35 @@ WHERE left_at < datetime('now', '-90 days'); ### Table: `bans` -Local ban records (source of truth for the UI). **Must sync bidirectionally with `battleye/ban.txt`** — BattlEye reads only from ban.txt. On API ban add/delete: also write to ban.txt. On startup: read ban.txt and upsert into DB. - -**ban.txt format** (one entry per line): -``` -GUID|IP timestamp reason -``` -Example: `a1b2c3d4e5f6|192.168.1.1 1713260000 Cheating` - -**Sync caveats:** ban.txt does not store `banned_by`, `expires_at`, or `is_active`. Timed bans are represented by a future timestamp (not minutes); permanent bans have timestamp `0`. On startup, `banned_by` is set to `'ban.txt'` for entries read from file. Deactivated bans (`is_active=0`) are not written to ban.txt. +Ban records. Core concept is game-agnostic; ban file sync is adapter-specific. ```sql CREATE TABLE bans ( id INTEGER PRIMARY KEY AUTOINCREMENT, server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE, guid TEXT, - steam_uid TEXT, name TEXT, reason TEXT, banned_by TEXT, -- admin username banned_at TEXT NOT NULL DEFAULT (datetime('now')), expires_at TEXT, -- NULL = permanent is_active INTEGER NOT NULL DEFAULT 1, + game_data TEXT DEFAULT '{}', -- JSON: {steam_uid, ip, etc.} CHECK (is_active IN (0, 1)) ); CREATE INDEX idx_bans_server ON bans(server_id); CREATE INDEX idx_bans_guid ON bans(guid); -CREATE INDEX idx_bans_steam_uid ON bans(steam_uid); CREATE INDEX idx_bans_active ON bans(is_active); ``` +**Key changes:** Removed `steam_uid` as a dedicated column (moved to `game_data`). Added `game_data` JSON. Ban file sync (e.g., `battleye/ban.txt`) is handled by the adapter's `BanManager`. + --- ### Table: `logs` -Parsed RPT log lines (rolling retention, default 7 days). +Parsed log lines (rolling retention, default 7 days). ```sql CREATE TABLE logs ( @@ -458,10 +437,12 @@ CREATE TABLE logs ( ); CREATE INDEX idx_logs_server_ts ON logs(server_id, timestamp); -CREATE INDEX idx_logs_level ON logs(level); -- for ?level= filter -CREATE INDEX idx_logs_created ON logs(created_at); -- for retention cleanup +CREATE INDEX idx_logs_level ON logs(level); +CREATE INDEX idx_logs_created ON logs(created_at); ``` +**No changes** — this table is fully game-agnostic. The adapter's `LogParser` converts game-specific log format into the standard `{timestamp, level, message}` tuple. + --- ### Table: `metrics` @@ -481,21 +462,24 @@ CREATE TABLE metrics ( CREATE INDEX idx_metrics_server_ts ON metrics(server_id, timestamp); ``` +**No changes** — fully game-agnostic. + --- ### Table: `server_events` -Audit trail of all significant events (start, stop, crash, restart, admin actions). +Audit trail of all significant events. ```sql CREATE TABLE server_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE, event_type TEXT NOT NULL, - -- event_type values: + -- Core event types: -- 'started' | 'stopped' | 'crashed' | 'restarted' | 'config_updated' - -- 'player_kicked' | 'player_banned' | 'mission_changed' | 'admin_login' - -- 'rcon_command' | 'auto_restarted' + -- 'player_kicked' | 'player_banned' | 'admin_login' + -- 'auto_restarted' | 'max_restarts_exceeded' + -- Adapters may define additional event types actor TEXT, -- username or 'system' detail TEXT, -- JSON with event-specific data created_at TEXT NOT NULL DEFAULT (datetime('now')) @@ -504,22 +488,7 @@ CREATE TABLE server_events ( CREATE INDEX idx_events_server ON server_events(server_id, created_at); ``` ---- - -### Table: `rcon_configs` - -BattlEye RCon credentials per server. - -```sql -CREATE TABLE rcon_configs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - server_id INTEGER NOT NULL UNIQUE REFERENCES servers(id) ON DELETE CASCADE, - rcon_password TEXT NOT NULL, -- encrypted at app layer - max_ping INTEGER NOT NULL DEFAULT 200, - enabled INTEGER NOT NULL DEFAULT 1, - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); -``` +**No changes** — fully game-agnostic. Adapters can emit additional event types. --- @@ -528,12 +497,8 @@ CREATE TABLE rcon_configs ( ``` users (1) ──────────────────────────────────── (many) server_events.actor -servers (1) ──┬── (1) server_configs - ├── (1) basic_configs - ├── (1) server_profiles - ├── (1) launch_params - ├── (1) rcon_configs - ├── (many) server_mods ──── (many) mods +servers (1) ──┬── (many) game_configs ← JSON sections replace 5 Arma 3 tables + ├── (many) server_mods ──── (many) mods (scoped by game_type) ├── (many) missions ├── (many) mission_rotation → missions ├── (many) players @@ -546,6 +511,47 @@ servers (1) ──┬── (1) server_configs --- +## Encryption Strategy + +Sensitive fields stored in `game_configs.config_json` are encrypted at the application layer before JSON serialization. The adapter declares which fields are sensitive via `ConfigGenerator.get_sensitive_fields(section) -> list[str]`. + +**How it works:** +1. On write: Core calls `adapter.get_config_generator().get_sensitive_fields(section)` to get a list of JSON keys +2. ConfigRepository replaces those values with `"encrypted:" + Fernet.encrypt(value)` tokens +3. On read: ConfigRepository detects `"encrypted:"` prefix and decrypts +4. The adapter's Pydantic model sees plaintext — encryption is transparent to the adapter + +**Arma 3 encrypted fields (declared by Arma3ConfigGenerator):** +- Section `server`: `password`, `password_admin`, `server_command_password` +- Section `rcon`: `rcon_password` + +Encryption uses Fernet (AES-256) with `LANGUARD_ENCRYPTION_KEY`. + +## Optimistic Locking for Config Updates + +The `config_version` column in `game_configs` prevents lost updates when two admins edit simultaneously: + +1. Client reads config section → gets `config_version: 3` +2. Client sends PUT with `config_version: 3` in request body +3. Server checks: `WHERE server_id = ? AND section = ? AND config_version = 3` +4. If match: increment version, write new JSON, return 200 +5. If no match (version changed): return **409 Conflict** with current config for client-side merge + +## game_data JSON Schema + +The `game_data` columns on `players`, `missions`, `mods`, and `bans` are validated by adapter-provided Pydantic models. Each capability protocol optionally provides a schema: + +| Table | game_data column | Protocol Method | Arma 3 Example | +|-------|-----------------|-----------------|-----------------| +| `players` | `game_data` | `RemoteAdmin.get_player_data_schema()` | `{verified: bool, steam_uid: str}` | +| `missions` | `game_data` | `MissionManager.get_mission_data_schema()` | `{terrain: str}` | +| `mods` | `game_data` | `ModManager.get_mod_data_schema()` | `{}` (empty for Arma 3) | +| `bans` | `game_data` | `BanManager.get_ban_data_schema()` | `{steam_uid: str, ip: str}` | + +If an adapter doesn't define a game_data schema, the field accepts any JSON object (no validation). + +--- + ## Maintenance Queries ### Log Retention Cleanup (run daily via APScheduler) @@ -574,8 +580,8 @@ VACUUM; ## Migration Strategy -- Migrations are plain `.sql` files in `backend/migrations/` -- Naming: `001_initial_schema.sql`, `002_add_bans.sql`, etc. +- Migrations are plain `.sql` files in `backend/core/migrations/` +- Naming: `001_initial_schema.sql`, `002_add_game_type.sql`, etc. - Tracked in a `schema_migrations` table: ```sql CREATE TABLE schema_migrations ( @@ -584,3 +590,229 @@ VACUUM; ); ``` - Applied automatically at app startup by `database.py:run_migrations()` +- **Adapter-specific migrations** go in `adapters//migrations/` and are applied by the adapter's initialization (if the adapter needs extra tables beyond `game_configs`) + +--- + +## Migration from Single-Game Schema + +For existing deployments with the old Arma 3-specific schema: + +### SQL Migration (002_multi_game_adapter.sql) + +```sql +-- 1. Add game_type to servers +ALTER TABLE servers ADD COLUMN game_type TEXT NOT NULL DEFAULT 'arma3'; + +-- 2. Create game_configs table +CREATE TABLE game_configs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + game_type TEXT NOT NULL, + section TEXT NOT NULL, + config_json TEXT NOT NULL DEFAULT '{}', + config_version INTEGER NOT NULL DEFAULT 1, + schema_version TEXT NOT NULL DEFAULT '1.0', + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(server_id, section) +); + +-- 3. Add game_data columns +ALTER TABLE players ADD COLUMN game_data TEXT DEFAULT '{}'; +ALTER TABLE missions ADD COLUMN game_data TEXT DEFAULT '{}'; +ALTER TABLE mods ADD COLUMN game_type TEXT NOT NULL DEFAULT 'arma3'; +ALTER TABLE mods ADD COLUMN game_data TEXT DEFAULT '{}'; +ALTER TABLE server_mods ADD COLUMN game_data TEXT DEFAULT '{}'; +ALTER TABLE bans ADD COLUMN game_data TEXT DEFAULT '{}'; +ALTER TABLE mission_rotation ADD COLUMN game_data TEXT DEFAULT '{}'; + +-- 4. Migrate player_num → slot_id (SQLite 3.25.0+) +ALTER TABLE players RENAME COLUMN player_num TO slot_id; +-- Change slot_id type: SQLite doesn't support ALTER COLUMN TYPE. +-- Workaround: create new table, copy data, drop old, rename. +-- See Python migration script below. + +-- 5. Drop old Arma 3-specific tables (after Python migration confirms data copied) +-- DROP TABLE server_configs; +-- DROP TABLE basic_configs; +-- DROP TABLE server_profiles; +-- DROP TABLE launch_params; +-- DROP TABLE rcon_configs; +``` + +### Python Migration Script (002_migrate_arma3_config.py) + +This script reads old Arma 3-specific tables, serializes each row to JSON, and inserts into `game_configs`. It runs AFTER the SQL migration creates the new table structure. + +```python +"""Migrate Arma 3 config data from normalized tables to game_configs JSON. + +Run after 002_multi_game_adapter.sql. This script: +1. Reads each old table row by row +2. Converts column values to JSON (handling type conversions) +3. Inserts into game_configs with proper section names +4. Migrates player data (player_num int → slot_id text, steam_uid → game_data) +5. Verifies row counts match before dropping old tables + +Type conversion rules: + - INTEGER 0/1 → JSON boolean false/true (for boolean-like fields) + - TEXT containing JSON arrays (motd_lines, headless_clients, etc.) → parsed JSON arrays + - NULL values → omitted from JSON (Pydantic fills defaults on read) + - Encrypted fields (password, password_admin, rcon_password) → copied as-is (already encrypted) + +Transaction: all inserts in a single transaction. On failure, rollback — old tables untouched. +""" + +import json +import sqlite3 + +COLUMN_TYPE_MAP = { + # server_configs → section 'server' + 'server_configs': { + 'section': 'server', + 'boolean_fields': [ + 'kick_duplicate', 'persistent', 'disable_von', 'battleye', + 'kick_on_ping', 'kick_on_packet_loss', 'kick_on_desync', 'kick_on_timeout', + 'auto_select_mission', 'random_mission_order', 'skip_lobby', + 'drawing_in_map', 'upnp', 'loopback', 'statistics_enabled', + ], + 'json_array_fields': [ + 'motd_lines', 'headless_clients', 'local_clients', 'admin_uids', + 'allowed_load_extensions', 'allowed_preprocess_extensions', + 'allowed_html_extensions', + ], + 'encrypted_fields': ['password', 'password_admin', 'server_command_password'], + }, + # basic_configs → section 'basic' + 'basic_configs': { + 'section': 'basic', + 'boolean_fields': [], + 'json_array_fields': [], + 'encrypted_fields': [], + }, + # server_profiles → section 'profile' + 'server_profiles': { + 'section': 'profile', + 'boolean_fields': [ + 'reduced_damage', 'camera_shake', 'score_table', 'death_messages', + 'von_id', 'auto_report', 'multiple_saves', 'stamina_bar', + 'weapon_crosshair', 'vision_aid', 'third_person_view', + 'map_content_friendly', 'map_content_enemy', 'map_content_mines', + ], + 'json_array_fields': [], + 'encrypted_fields': [], + }, + # launch_params → section 'launch' + 'launch_params': { + 'section': 'launch', + 'boolean_fields': [ + 'auto_init', 'load_mission_to_memory', 'enable_ht', + 'huge_pages', 'no_logs', 'netlog', + ], + 'json_array_fields': [], + 'encrypted_fields': [], + }, + # rcon_configs → section 'rcon' + 'rcon_configs': { + 'section': 'rcon', + 'boolean_fields': ['enabled'], + 'json_array_fields': [], + 'encrypted_fields': ['rcon_password'], + }, +} + +def migrate_config_table(db: sqlite3.Connection, table: str, mapping: dict): + """Read rows from old table, serialize to JSON, insert into game_configs.""" + rows = db.execute(f"SELECT * FROM {table}").fetchall() + columns = [desc[0] for desc in db.execute(f"SELECT * FROM {table} LIMIT 0").description] + + for row in rows: + row_dict = dict(zip(columns, row)) + server_id = row_dict.pop('id', None) or row_dict.pop('server_id', None) + + config = {} + for col, value in row_dict.items(): + if value is None: + continue # omit NULLs; Pydantic fills defaults + if col in mapping.get('boolean_fields', []): + config[col] = bool(value) + elif col in mapping.get('json_array_fields', []): + config[col] = json.loads(value) if isinstance(value, str) else value + elif col in mapping.get('encrypted_fields', []): + config[col] = value # already encrypted, copy as-is + elif col in ('updated_at', 'created_at', 'id', 'server_id'): + continue # skip metadata columns + else: + config[col] = value + + db.execute(""" + INSERT INTO game_configs (server_id, game_type, section, config_json, schema_version) + VALUES (?, 'arma3', ?, ?, '1.0') + """, (server_id, mapping['section'], json.dumps(config))) + + +def migrate_player_data(db: sqlite3.Connection): + """Convert player_num (int) to slot_id (text), move steam_uid to game_data.""" + # Create new players table with slot_id as TEXT + db.execute(""" + CREATE TABLE players_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + server_id INTEGER NOT NULL, + slot_id TEXT NOT NULL, + name TEXT NOT NULL, + guid TEXT, + ip TEXT, + ping INTEGER, + game_data TEXT DEFAULT '{}', + joined_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (server_id, slot_id) + ) + """) + rows = db.execute("SELECT * FROM players").fetchall() + columns = [desc[0] for desc in db.execute("SELECT * FROM players LIMIT 0").description] + for row in rows: + d = dict(zip(columns, row)) + game_data = {} + if d.get('steam_uid'): + game_data['steam_uid'] = d['steam_uid'] + if d.get('verified') is not None: + game_data['verified'] = bool(d['verified']) + db.execute(""" + INSERT INTO players_new (id, server_id, slot_id, name, guid, ip, ping, + game_data, joined_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + d['id'], d['server_id'], str(d['player_num']), + d['name'], d.get('guid'), d.get('ip'), d.get('ping'), + json.dumps(game_data), d.get('joined_at'), d.get('updated_at'), + )) + db.execute("DROP TABLE players") + db.execute("ALTER TABLE players_new RENAME TO players") + db.execute("CREATE INDEX idx_players_server ON players(server_id)") + + +def run_migration(db_path: str): + db = sqlite3.connect(db_path) + try: + db.execute("BEGIN TRANSACTION") + for table, mapping in COLUMN_TYPE_MAP.items(): + count_before = db.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] + migrate_config_table(db, table, mapping) + count_after = db.execute( + "SELECT COUNT(*) FROM game_configs WHERE game_type='arma3' AND section=?", + (mapping['section'],) + ).fetchone()[0] + assert count_after == count_before, ( + f"Row count mismatch for {table}: {count_before} → {count_after}" + ) + migrate_player_data(db) + db.execute("COMMIT") + print("Migration successful. Old tables can now be dropped.") + except Exception as e: + db.execute("ROLLBACK") + print(f"Migration FAILED (rolled back): {e}") + raise + finally: + db.close() +``` \ No newline at end of file diff --git a/FRONTEND.md b/FRONTEND.md new file mode 100644 index 0000000..2f9200b --- /dev/null +++ b/FRONTEND.md @@ -0,0 +1,1343 @@ +# Languard Servers Manager — Frontend Design + +## Purpose + +A real-time game server management dashboard that gives admins instant visibility and control over their dedicated servers. The interface must feel like a mission control center — dense with live data, fast to react, and unambiguous in its state signals. + +**Audience:** Server administrators managing game servers. Technical, task-oriented, frequently under time pressure (server crashed, player is cheating, need to restart now). + +**Emotional tone:** Confident, precise, operational. Not playful, not corporate, not minimal-for-the-sake-of-it. + +**Visual direction:** **Dark neumorphic command center** — near-black surfaces with soft extruded/inset shadows creating tactile depth, amber and orange accents cutting through like instrument panel lights, monospaced data glowing against dark backgrounds. The aesthetic of a physical control panel — buttons you can feel, displays that look back-lit, surfaces that have real mass. + +**One thing the user should remember:** "I can see exactly what's happening and act on it immediately." + +--- + +## Technology Stack + +| Layer | Technology | Rationale | +|-------|-----------|-----------| +| Framework | **React 18** + **TypeScript 5** | Ecosystem, type safety, team familiarity | +| Build | **Vite 5** | Fast HMR, native ESM, minimal config | +| Routing | **React Router v6** | Standard, nested layouts | +| State (server) | **TanStack Query v5** | Server state cache, background refetch, optimistic updates | +| State (client) | **Zustand** | Minimal boilerplate, no providers, good for WS state | +| Forms | **React Hook Form** + **Zod** | Adapter-driven dynamic form generation from JSON Schema | +| Styling | **Tailwind CSS v3** + CSS variables | Utility-first with design tokens for theming | +| Charts | **Recharts** | Lightweight, responsive, sufficient for CPU/RAM/player time series | +| Icons | **Lucide React** | Consistent stroke style, tree-shakeable | +| HTTP | **Ky** (fetch wrapper) | Hooks, retry, typed responses | +| WS | Native WebSocket + custom hook | Minimal abstraction over browser API | +| Code quality | **ESLint** + **Prettier** + **tsc --noEmit** | Lint, format, type-check | + +No UI component library (shadcn, MUI, etc.). Every component is purpose-built to the design system below. + +--- + +## Design Tokens + +### Color + +Black, white, dark yellow, and orange. The palette mirrors a military-grade instrument panel — near-black surfaces, white text for readability, amber/yellow for data highlights and warnings, orange for primary actions and live indicators. + +```css +:root { + /* ── Surfaces ────────────────────────────────────────────── + Dark neumorphism: surfaces are all near-black but subtly + differentiated by lightness. Neumorphic shadows (below) + create the illusion of physical depth — raised panels, + sunken inputs, extruded buttons. + */ + --color-base: #0d0d0d; /* deepest — page background */ + --color-surface: #141414; /* panels, cards */ + --color-elevated:#1a1a1a; /* raised panels, modals */ + --color-hover: #1f1f1f; /* hover state on interactive surfaces */ + + /* ── Neumorphic shadow source ────────────────────────────── + Neumorphism requires two opposing shadows on the same + surface: a lighter shadow (simulating top-left light) + and a darker shadow (simulating bottom-right depth). + The source colors are derived from the surface itself. + */ + --neu-light: #1e1e1e; /* light shadow — 4-5% above surface */ + --neu-dark: #0a0a0a; /* dark shadow — 4-5% below surface */ + + /* ── Text ───────────────────────────────────────────────── */ + --color-text: #f5f5f5; /* primary — near-white */ + --color-text-dim: #999999; /* secondary — timestamps, meta */ + --color-text-muted: #555555; /* disabled, placeholders */ + + /* ── Accent — dark yellow / orange ──────────────────────── */ + --color-accent: #d4940a; /* dark yellow — primary actions */ + --color-accent-hover: #e5a61c; /* lighter on hover */ + --color-accent-dim: #8b6210; /* muted accent for backgrounds */ + --color-orange: #d45e0a; /* orange — secondary accent */ + --color-orange-hover: #e56e1c; /* lighter on hover */ + --color-orange-dim: #8b3e0a; /* muted orange for backgrounds */ + + /* ── Status ─────────────────────────────────────────────── */ + --color-running: #d4940a; /* amber glow — server is live */ + --color-stopped: #555555; /* gray — idle */ + --color-starting: #d4940a; /* amber — transitioning (pulsing) */ + --color-crashed: #cc3333; /* red — needs attention */ + --color-error: #cc3333; /* red — error states */ + + /* ── Danger ─────────────────────────────────────────────── */ + --color-danger: #cc3333; /* red — destructive actions */ + --color-danger-hover: #dd4444; + + /* ── Glow ──────────────────────────────────────────────── + Status indicators use a subtle glow (box-shadow) to + simulate back-lit LEDs on a dark panel. + */ + --glow-amber: 0 0 8px 2px oklch(72% 0.15 80 / 0.4); + --glow-red: 0 0 8px 2px oklch(55% 0.20 25 / 0.4); + --glow-orange: 0 0 8px 2px oklch(65% 0.17 50 / 0.35); + + /* ── Overlays ──────────────────────────────────────────── */ + --color-overlay: oklch(8% 0 0 / 0.85); /* modal backdrop */ +} +``` + +**Color rules:** +- **Black is the only background.** No white backgrounds anywhere. White is text only. +- **Amber (dark yellow) is the primary accent** — used for: primary buttons, active tabs, selected items, the "running" status dot, data highlight values. +- **Orange is the secondary accent** — used for: warning states, important metrics, secondary CTAs. +- **Red is reserved for danger** — crashed, error, destructive actions. Never decorative. +- **Gray is the neutral** — stopped status, disabled states, borders. +- No purple. No blue. No decorative gradients. +- Status dots glow via `box-shadow` — like back-lit LEDs on a physical panel. + +### Neumorphic Surface Treatment + +Dark neumorphism creates the illusion of physical depth through opposing light/dark shadows on near-black surfaces. Every interactive element signals its affordance through shadow direction: + +```css +/* ── Raised (extruded) ──────────────────────────────────── + Buttons, cards, metric tiles — things that sit above the surface. + Light shadow top-left, dark shadow bottom-right. +*/ +.neu-raised { + background: var(--color-surface); + border-radius: var(--radius-md); + box-shadow: + 4px 4px 8px var(--neu-dark), + -4px -4px 8px var(--neu-light); +} + +/* ── Inset (sunken) ──────────────────────────────────────── + Input fields, log viewer, search bars — things that go into the surface. + Dark shadow top-left, light shadow bottom-right. +*/ +.neu-inset { + background: var(--color-base); + border-radius: var(--radius-sm); + box-shadow: + inset 3px 3px 6px var(--neu-dark), + inset -3px -3px 6px var(--neu-light); +} + +/* ── Flat (flush) ────────────────────────────────────────── + Modal surfaces, overlays — flat on the surface, no shadow play. +*/ +.neu-flat { + background: var(--color-elevated); + border-radius: var(--radius-lg); + box-shadow: + 0 8px 32px oklch(0% 0 0 / 0.5); +} + +/* ── Pressed ─────────────────────────────────────────────── + Active/pressed button state — flips to inset. +*/ +.neu-raised:active { + box-shadow: + inset 3px 3px 6px var(--neu-dark), + inset -3px -3px 6px var(--neu-light); +} + +/* ── Accent raised ───────────────────────────────────────── + Primary action buttons with amber/orange fill. + Neumorphic shadows on a colored surface. +*/ +.neu-raised-accent { + background: var(--color-accent); + color: #0d0d0d; + border-radius: var(--radius-md); + box-shadow: + 4px 4px 8px var(--neu-dark), + -4px -4px 8px var(--neu-light), + 0 0 12px oklch(72% 0.15 80 / 0.2); /* subtle amber glow */ +} + +/* ── Status LED ──────────────────────────────────────────── + Status indicator dots with glow. Looks like a back-lit LED. +*/ +.led-running { + background: var(--color-running); + border-radius: var(--radius-full); + box-shadow: var(--glow-amber); +} +.led-crashed { + background: var(--color-crashed); + border-radius: var(--radius-full); + box-shadow: var(--glow-red); +} +``` + +**Neumorphism rules for this project:** +- **Shadow intensity is proportional to interaction.** Buttons get full shadows. Decorative cards get lighter shadows. Flat info panels get none. +- **Never use borders with neumorphism** — shadows define edges, not lines. The only exception is focus rings for accessibility. +- **Inset for input, raised for output.** Form fields, log viewers, and search bars are sunken. Metric tiles, buttons, and cards are raised. +- **Pressed = inset.** When a raised button is clicked, it flips to inset shadows for tactile feedback. +- **Subtle, not extreme.** Shadow offsets are 3-4px, blur 6-8px. This is not the exaggerated neumorphism of 2020 — it's understated depth that makes the interface feel like physical hardware. +- **Glow replaces color fill for status.** Running servers don't just get a colored dot — they get a dot that glows, like a real LED indicator on a server rack. + +### Typography + +```css +:root { + /* Display — for page titles */ + --font-display: "Space Grotesk", sans-serif; + + /* Body — for most UI text */ + --font-body: "Inter", sans-serif; + + /* Mono — for logs, code, ports, PIDs, timestamps */ + --font-mono: "JetBrains Mono", monospace; + + /* Scale */ + --text-xs: 0.75rem; /* 12px — badges, tags */ + --text-sm: 0.8125rem; /* 13px — table cells, meta */ + --text-base: 0.875rem; /* 14px — body, form labels */ + --text-lg: 1rem; /* 16px — section headings */ + --text-xl: 1.25rem; /* 20px — page titles */ + --text-2xl: 1.5rem; /* 24px — hero metrics */ + --text-3xl: 2rem; /* 32px — big status numbers */ + + /* Weight */ + --weight-regular: 400; + --weight-medium: 500; + --weight-semibold: 600; + + /* Line height */ + --leading-tight: 1.25; + --leading-normal: 1.5; + --leading-relaxed: 1.75; /* for log blocks */ +} +``` + +**Rules:** +- All data values (ports, PIDs, player counts, IPs) use `--font-mono`. +- Log viewer is entirely monospaced. +- Page titles use `--font-display`. Everything else uses `--font-body`. +- Never use font weight alone to differentiate — combine with size or color. + +### Spacing + +```css +:root { + --space-1: 0.25rem; /* 4px — tight internal gaps */ + --space-2: 0.5rem; /* 8px — form field spacing */ + --space-3: 0.75rem; /* 12px — compact padding */ + --space-4: 1rem; /* 16px — standard padding */ + --space-5: 1.5rem; /* 24px — section gaps */ + --space-6: 2rem; /* 32px — page margins */ + --space-8: 3rem; /* 48px — major separations */ +} +``` + +**Rhythm:** 4px base unit. All spacing is a multiple of 4px. No arbitrary padding values. + +### Borders, Shadows, Radii + +```css +:root { + /* Radii — consistent, not excessive */ + --radius-sm: 6px; /* inputs, badges, small buttons */ + --radius-md: 10px; /* cards, panels */ + --radius-lg: 14px; /* modals, overlays */ + --radius-full: 9999px; /* status LEDs, pills */ + + /* Neumorphism handles edge definition — borders are rare. + Only use borders for: focus rings, table row separators, + and explicit dividers between sections. */ + --border-subtle: 1px solid #222222; + --border-focus: 2px solid var(--color-accent); +} +``` + +**Note on neumorphic shadows + Tailwind:** The neumorphic shadow classes above cannot be expressed as single Tailwind utilities. Use CSS custom classes (`.neu-raised`, `.neu-inset`, etc.) defined in `globals.css` and reference them via `@apply` or direct class names in components. Tailwind utilities handle everything else (spacing, layout, typography, color). + +### Motion + +```css +:root { + --duration-fast: 100ms; /* hover states, toggles */ + --duration-normal: 200ms; /* panel transitions, modals */ + --duration-slow: 400ms; /* page transitions */ + + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); + --ease-in-out: cubic-bezier(0.45, 0, 0.55, 1); +} +``` + +**Rules:** +- Motion is for state transitions, not decoration. +- Status changes (stopped→starting→running) use `--duration-normal` + `--ease-out`. +- No scroll-triggered animations. No parallax. No loading spinners with decorative motion. +- Log streaming has zero animation — lines appear instantly. +- Modals slide in from bottom or fade in. Never bounce, never spring. + +--- + +## Layout Architecture + +### Shell + +``` +┌─────────────────────────────────────────────────────────┐ +│ LOGO Languard [user] [settings] │ ← 48px header +├────────┬────────────────────────────────────────────────┤ +│ │ │ +│ NAV │ CONTENT AREA │ +│ │ │ +│ ┌────┐ │ ┌──────────────────────────────────────────┐ │ +│ │📊 │ │ │ │ │ +│ ├────┤ │ │ │ │ +│ │🖥️ │ │ │ Page Content │ │ +│ ├────┤ │ │ │ │ +│ │📋 │ │ │ │ │ +│ ├────┤ │ │ │ │ +│ │⚙️ │ │ │ │ │ +│ ├────┤ │ └──────────────────────────────────────────┘ │ +│ │🔧 │ │ │ +│ └────┘ │ │ +│ 56px │ │ +├────────┴────────────────────────────────────────────────┤ +│ Status bar: connected servers / WS status / version │ ← 28px footer +└─────────────────────────────────────────────────────────┘ +``` + +- **Sidebar:** 56px collapsed (icons only), expands to 200px on hover/click. Icon + label for each section. +- **Header:** App name left, user dropdown right. Dark, fixed. +- **Footer:** Always visible — shows WebSocket connection status (connected/reconnecting/disconnected), number of running servers, app version. Critical for trust. +- **Content:** Scrollable main area. Min width 1024px. + +### Navigation Structure + +| Route | Icon | Label | Access | +|-------|------|-------|--------| +| `/` | LayoutDashboard | Dashboard | All | +| `/servers` | Server | Servers | All | +| `/servers/:id` | — | (Server Detail) | All | +| `/servers/:id/config` | — | (Server Config) | Admin | +| `/missions` | — | (via server detail) | Admin | +| `/mods` | PuzzlePiece | Mods | All (view), Admin (edit) | +| `/bans` | ShieldOff | Bans | All (view), Admin (edit) | +| `/users` | Users | Users | Admin | +| `/settings` | Settings | Settings | Admin | + +### Responsive Breakpoints + +| Breakpoint | Layout | Sidebar | +|-----------|--------|---------| +| ≥1440px | Full layout | Expanded by default | +| 1024–1439px | Full layout | Collapsed by default | +| <1024px | **Not supported** | — | + +This is a server management tool, not a consumer app. Mobile is not a target. The minimum viewport is 1024px wide. Below that, show a "Please use a desktop browser" message. + +--- + +## Page Designs + +### 1. Login + +Single centered card on dark background. No illustration, no hero, no marketing copy. The card itself is `neu-raised` — it appears to float above the base surface. + +``` +┌─────────────────────────────────┐ +│ │ +│ Languard │ ← display font, white +│ Server Manager │ ← dim, monospaced? +│ │ +│ ┌─────────────────────────┐ │ +│ │ Username │ │ ← neu-inset input +│ └─────────────────────────┘ │ +│ ┌─────────────────────────┐ │ +│ │ Password │ │ ← neu-inset input +│ └─────────────────────────┘ │ +│ │ +│ ╔═══════════════════════╗ │ ← neu-raised-accent +│ ║ Sign In ║ │ (amber fill) +│ ╚═══════════════════════╝ │ +│ │ +│ Error message area (red glow) │ +│ │ +└─────────────────────────────────┘ +``` + +- No "remember me", no "forgot password" (single-host tool, admin manages users via CLI or initial setup). +- Failed login shows inline error with rate-limit countdown after 5 attempts. +- JWT stored in `localStorage`. On token expiry, redirect to login with a toast "Session expired". + +### 2. Dashboard + +A command-center overview. Not a marketing dashboard — dense, data-first. Neumorphic raised cards for metrics, inset panels for lists. + +``` +┌────────────────────────────────────────────────────────┐ +│ Dashboard [Refresh] │ +├────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │(raised) │ │(raised) │ │(raised) │ │(raised) │ │ +│ │ 3 │ │ 2 │ │ 15 │ │ 34% │ │ +│ │ Total │ │ Running │ │ Players │ │ Avg CPU │ │ +│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ SERVER STATUS (raised panel) │ │ +│ │ │ │ +│ │ 🟡 Main Server Arma 3 15/40 34%CPU │ │ ← amber glow LED +│ │ 🟡 Altis COOP Arma 3 0/32 12%CPU │ │ +│ │ ⚫ Test Server Arma 3 — — │ │ ← gray, no glow +│ │ │ │ +│ └──────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ RECENT EVENTS (inset panel) │ │ +│ │ │ │ +│ │ 10:05 PlayerOne kicked (AFK) Main Server │ │ +│ │ 10:02 Server started Altis COOP │ │ +│ │ 09:58 Crashed (exit 1) 🔴 Test Server │ │ ← red glow LED +│ │ 09:45 Ban added: Cheater42 Main Server │ │ +│ │ │ │ +│ └──────────────────────────────────────────────────┘ │ +│ │ +└────────────────────────────────────────────────────────┘ +``` + +- **Summary cards:** Neumorphic raised (`neu-raised`). Large amber number on top, white label below. Monospaced numbers. Running count glows amber. Crashed count glows red (if > 0). +- **Server status list:** Clickable rows → navigate to server detail. Status dot is an LED with glow (`led-running`). Player count in `mono`. CPU is color-coded: <50% white, 50-80% amber, >80% orange-red. +- **Recent events:** Inset panel (`neu-inset`). Last 10 events across all servers. Crash events show red LED. + +### 3. Server List + +Full CRUD list with filtering and bulk overview. + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Servers [+ New Server] [Game: All ▾] │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Name │ Game │ Status │ Players │ CPU │ ⚙ │ │ +│ ├───────────────┼────────┼─────────┼─────────┼─────┼───┤ │ +│ │ Main Server │ Arma 3 │ 🟡 Run │ 15/40 │ 34% │ ⋮ │ │ ← amber LED +│ │ Altis COOP │ Arma 3 │ 🟡 Run │ 0/32 │ 12% │ ⋮ │ │ +│ │ Test Server │ Arma 3 │ ⚫ Stop │ — │ — │ ⋮ │ │ ← gray, no glow +│ └───────────────┴────────┴─────────┴─────────┴─────┴───┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +- **Game filter dropdown:** Populated from `GET /games`. Inset input style (`neu-inset`). +- **[+ New Server]:** Raised accent button (`neu-raised-accent`). Amber fill, dark text. +- **Row actions (⋮ menu):** Start/Stop/Restart/Kill, Edit, Delete. Actions are context-aware — "Start" is disabled when running, "Stop" is disabled when stopped. +- **Sort:** Click column headers. Default sort: status (running first), then name. +- **Status column:** LED dot + short label. Amber glow = running, gray = stopped, red glow = crashed. + +### 4. New Server Dialog + +Modal overlay. Multi-step for clarity, not wizardry. + +``` +┌──────────────────────────────────────┐ +│ New Server │ +│ │ +│ ── Step 1: Game Type ────────── │ +│ │ +│ ┌────────┐ ┌────────┐ │ +│ │ Arma 3 │ │ + Add │ ← greyed │ +│ │ ★ │ │ more │ if no │ +│ └────────┘ └────────┘ adapters │ +│ │ +│ ── Step 2: Details ──────────── │ +│ │ +│ Server Name [ ]│ +│ Description [ ]│ +│ Executable [/path/to/exe ]│ +│ Game Port [2302 ]│ +│ RCon Port [2306 ]│ +│ │ +│ ── Step 3: Config ────────────── │ +│ │ +│ (Pre-filled from adapter defaults) │ +│ Hostname [My Arma 3 Server ]│ +│ Max Players [40 ]│ +│ Admin Password [•••••• ]│ +│ ... │ +│ │ +│ [Cancel] [Create Server] │ +│ │ +└──────────────────────────────────────┘ +``` + +- **Game type selector:** Visual cards, not a dropdown. Each shows game name + icon. Only registered game types appear (from `GET /games`). +- **Config sections:** Dynamically rendered from `GET /games/{type}/config-schema`. Each section becomes a collapsible group. Fields generated from JSON Schema types (string → text input, integer → number input, boolean → toggle, enum → dropdown). +- **Sensitive fields:** Password inputs with show/hide toggle. Pre-generated values shown once in a dismissible callout after creation. +- **Port auto-fill:** Game port defaults from adapter's `get_default_game_port()`. RCon port from `get_default_rcon_port()`. User can override. + +### 5. Server Detail + +The primary operating surface. Tabbed layout with real-time data. + +``` +┌─────────────────────────────────────────────────────────────┐ +│ ← Servers │ Main Server │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ 🟡 RUNNING Arma 3 PID: 12345 Uptime: 2h 15m │ ← amber LED + glow +│ │ +│ [Stop] [Restart] [Kill ⚠] │ ← action bar +│ │ +│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ +│ │(neu-raised)│ │(neu-raised)│ │(neu-raised)│ │ +│ │ 15 │ │ 34.2% │ │ 1850 MB │ │ +│ │ Players │ │ CPU │ │ RAM │ │ +│ └────────────┘ └────────────┘ └────────────┘ │ +│ │ +│ [Overview] [Logs] [Players] [Config] [Missions] [Mods] │ +│ ═════════ │ +│ │ +│ ── Tab Content Area ── │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +**Action bar:** +- `[Stop]` and `[Restart]` are `neu-raised` (default surface style). +- `[Kill ⚠]` is `neu-raised` with red text (not a red button — danger signals through color, not surface). +- Buttons press to `neu-inset` on click (tactile feedback). +- Action bar is fixed at top of content — always accessible. + +**Metric cards:** +- Neumorphic raised (`neu-raised`). Updated in real-time via WebSocket (`type: "metrics"`). +- Player count shows `current/max` in mono font, accent color when > 0. +- CPU color-coded: <50% white, 50-80% amber, >80% orange. + +**Tabs:** Rendered/hidden based on adapter capabilities: + +| Tab | Condition | Source | +|-----|-----------|--------| +| Overview | Always | Server detail + recent events | +| Logs | Always | WebSocket `type: "log"` + `GET /servers/{id}/logs` | +| Players | `has_capability("remote_admin")` | WebSocket `type: "players"` + `GET /servers/{id}/players` | +| Config | Always (admin only) | `GET /servers/{id}/config` | +| Missions | `has_capability("mission_manager")` | `GET /servers/{id}/missions` | +| Mods | `has_capability("mod_manager")` | `GET /servers/{id}/mods` + `GET /mods` | + +Tabs that the adapter doesn't support are simply not rendered. No disabled tabs, no "coming soon" badges. + +### 5a. Server Detail — Logs Tab + +``` +┌─────────────────────────────────────────────────────────────┐ +│ [Level: All ▾] [Search...] [Clear Logs ⚠] │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ 10:05:23 INFO BattlEye Server: Initialized (v1.240) │ +│ 10:05:24 INFO Player PlayerOne connected │ +│ 10:05:30 WARN High ping detected: PlayerTwo (450ms) │ +│ 10:06:01 ERROR BattlEye: RCon connection timeout │ +│ 10:06:15 INFO Player PlayerOne disconnected │ +│ │ +│ ── streaming ── │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +- **Entirely monospaced** (`--font-mono`). This is a log terminal, not a chat. +- **Container uses `neu-inset`** — the log area looks like a sunken display screen. +- **Level colors:** INFO = dim white, WARN = amber, ERROR = red. Color on the level tag only, not the whole line. +- **Streaming:** New lines prepend from top (newest-first) or append to bottom (oldest-first) — user toggle. Default: newest-first. +- **Virtualized list** for performance. Logs can grow to tens of thousands of lines. +- **Search** is client-side filter on loaded logs + server-side `?search=` for historical. +- **No auto-scroll lock** — if user scrolls up, stop auto-scroll. Resume when scrolled to bottom. + +### 5b. Server Detail — Players Tab + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Players (15/40) [Say All] │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────┬──────────┬────────┬──────┬──────────────────┐│ +│ │ Slot │ Name │ GUID │ Ping │ Actions ││ +│ ├─────────┼──────────┼────────┼──────┼──────────────────┤│ +│ │ 1 │ PlayerOne│ abc... │ 45ms │ [Kick] [Ban] ││ +│ │ 2 │ PlayerTwo│ def... │ 450ms│ [Kick] [Ban] ││ +│ │ ... │ │ │ │ ││ +│ └─────────┴──────────┴────────┴──────┴──────────────────┘│ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +- **Real-time:** Table updates via WebSocket `type: "players"`. No manual refresh. +- **GUID** column shows truncated GUID (click to copy full). +- **Ping** column color-coded: <100 green, 100-300 default, >300 amber, >500 red. +- **Actions:** Kick opens a small popover for reason input. Ban opens a popover with reason + duration. +- **Say All button:** Opens a message input. Sends `POST /servers/{id}/remote-admin/say`. +- **Viewer role:** Sees the table, no action buttons. + +### 5c. Server Detail — Config Tab + +The most complex UI surface. Dynamic form generation from adapter's JSON Schema. + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Config [Preview Config] [Download ▾] │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ ▸ Server (modified*) │ │ +│ │ ▾ Basic (neu-raised panel) │ │ +│ │ ┌───────────────────────────────────────────────┐ │ │ +│ │ │ Hostname ╔═══════════════════════════════╗ │ │ │ ← neu-inset input +│ │ │ ║ My Arma 3 Server ║ │ │ │ +│ │ │ ╚═══════════════════════════════╝ │ │ │ +│ │ │ Max Players ╔════════════╗ Password ╔════╗ │ │ │ +│ │ │ ║ 40 ║ ║••••║ │ │ │ +│ │ │ ╚════════════╝ [👁] ╚════╝ │ │ │ +│ │ │ BattlEye [● On ] Verify Sig [2 ▾] │ │ │ ← toggle = raised +│ │ └───────────────────────────────────────────────┘ │ │ +│ │ ▸ Profile │ │ +│ │ ▸ Launch │ │ +│ │ ▸ RCon │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ [Reset Section] ╔═══════════════╗ │ +│ ║ Save Changes ║ ← neu-raised-accent │ +│ ╚══════════════╝ (amber fill) │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +**Dynamic form generation:** +- Config sections are collapsible panels, one per adapter section. +- Each section's fields are rendered from `GET /games/{type}/config-schema` (JSON Schema). +- JSON Schema → form field mapping: + - `type: "string"` → text input (or textarea if `format: "multiline"`) + - `type: "integer"` → number input (with min/max from schema) + - `type: "number"` → number input (step=0.1) + - `type: "boolean"` → toggle switch + - `enum: [...]` → dropdown select + - `type: "array"` → repeatable field group (for motd_lines, etc.) + - Sensitive fields (from `get_sensitive_fields()`) → password input with show/hide +- Field descriptions from JSON Schema `description` → tooltip on hover. + +**Optimistic locking:** +- Each section stores its `config_version` from the last read. +- On save (`PUT /servers/{id}/config/{section}`), sends the version. +- On 409 Conflict: shows a diff dialog with "Your changes" vs "Current server values", with options to override or merge. + +**Dirty state:** +- Unsaved changes show `(modified*)` on the section header. +- Navigation away from dirty form triggers an unsaved-changes dialog. +- `Reset Section` reverts to the last saved state. + +**Preview:** +- `Preview Config` opens a modal with rendered config from `GET /servers/{id}/config/preview`. +- Each entry in the `label→content` dict is shown as a labeled code block. Monospaced. +- `Download ▾` gives individual file downloads from `GET /servers/{id}/config/download/{filename}`. + +### 5d. Server Detail — Missions Tab + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Missions [Upload .pbo] │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ── Active Rotation ────────────────────────────────────── │ +│ 1. MyMission.Altis (Regular) [↑] [↓] [✕] │ +│ 2. ZeusOps.Altis (Veteran) [↑] [↓] [✕] │ +│ │ +│ ── Available Missions ─────────────────────────────────── │ +│ ┌──────────────────────┬──────────┬────────┬──────────┐ │ +│ │ Filename │ Terrain │ Size │ Actions │ │ +│ ├──────────────────────┼──────────┼────────┼──────────┤ │ +│ │ MyMission.Altis.pbo │ Altis │ 100 KB │ [+ Add] │ │ +│ │ ZeusOps.Altis.pbo │ Altis │ 50 KB │ In rot. │ │ +│ │ Training.Stratis.pbo│ Stratis │ 25 KB │ [+ Add] │ │ +│ └──────────────────────┴──────────┴────────┴──────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +- Only shown if `has_capability("mission_manager")`. +- **Upload:** Drag-and-drop zone + file picker. Extension validated from adapter's `MissionManager.file_extension`. +- **Rotation:** Ordered list. Reorder via drag-and-drop or arrow buttons. Difficulty dropdown per entry. +- **Add to rotation:** Button on each available mission. Moves it to rotation. +- **Remove from rotation:** Removes from rotation, mission stays on disk. + +### 5e. Server Detail — Mods Tab + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Mods [Register Mod] │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ── Active Mods ───────────────────────────────────────── │ +│ ┌──────────┬─────────────────────┬────────────┬────────┐ │ +│ │ Type │ Mod │ Workshop ID │ Remove │ │ +│ ├──────────┼─────────────────────┼────────────┼────────┤ │ +│ │ Client │ @CBA_A3 │ 450814997 │ [✕] │ │ +│ │ Server │ @ACE_server │ — │ [✕] │ │ +│ └──────────┴─────────────────────┴────────────┴────────┘ │ +│ │ +│ ── Available Mods ─────────────────────────────────────── │ +│ ┌─────────────────────┬────────────┬──────────────────┐ │ +│ │ @CBA_A3 │ 450814997 │ [+ Enable] │ │ +│ │ @ACE │ 463289743 │ [+ Enable] │ │ +│ └─────────────────────┴────────────┴──────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +- Only shown if `has_capability("mod_manager")`. +- **Client vs Server mod:** Toggle on each mod assignment. Determines `-mod=` vs `-serverMod=` in Arma 3. +- **Sort order:** Drag-and-drop reordering within each type. Affects load order. +- **Register Mod:** Opens a form to add a new mod folder path + metadata. + +### 6. Bans Page + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Bans [Server: All ▾] │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────┬──────────┬───────────────┬────────┬──────────┐│ +│ │ Server │ Name │ GUID │ Reason │ Expires ││ +│ ├──────────┼──────────┼───────────────┼────────┼──────────┤│ +│ │ Main │ Cheater42│ abc123... │ Hacking│ Perm ││ +│ │ Altis │ Troll99 │ def456... │ Grief │ 2h left ││ +│ └──────────┴──────────┴───────────────┴────────┴──────────┘│ +│ │ +│ [+ Add Ban Manually] │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +- Cross-server view by default (all servers). Filterable by server. +- **Add Ban Manually:** Opens a form with GUID/Name/Reason/Duration/Server selector. +- **Unban:** Confirmation dialog → `DELETE /servers/{id}/bans/{ban_id}`. +- Adapter's `BanManager` syncs to game ban file automatically (no extra UI needed). + +### 7. Users Page (Admin Only) + +Simple CRUD table: username, role, created date. Add/delete users. Change role. No inline password editing (use `/auth/password`). + +### 8. Settings Page (Admin Only) + +- Change own password +- System info (version, uptime, supported games) +- API key management (future) + +--- + +## Component Architecture + +### Directory Structure + +``` +frontend/ +├── index.html +├── vite.config.ts +├── tsconfig.json +├── package.json +├── tailwind.config.ts +├── postcss.config.js +│ +├── public/ +│ └── favicon.svg +│ +└── src/ + ├── main.tsx # Mount point + ├── App.tsx # Router + providers + ├── vite-env.d.ts + │ + ├── styles/ + │ ├── globals.css # CSS variables, resets, base styles + │ └── fonts.css # @font-face declarations + │ + ├── api/ + │ ├── client.ts # Ky instance with base URL + auth interceptor + │ ├── auth.ts # Login, me, users + │ ├── servers.ts # Server CRUD, start/stop/kill + │ ├── config.ts # Config CRUD, preview, download + │ ├── players.ts # Players, kick, ban + │ ├── mods.ts # Mod registration, server mods + │ ├── missions.ts # Missions, upload, rotation + │ ├── bans.ts # Bans CRUD + │ ├── games.ts # Game type discovery, schemas, defaults + │ ├── logs.ts # Log queries + │ ├── metrics.ts # Metrics queries + │ └── events.ts # Event log queries + │ + ├── hooks/ + │ ├── useWebSocket.ts # Connection management, reconnection, channel sub + │ ├── useAuth.ts # JWT state, login/logout, role check + │ ├── useServerStatus.ts # WS-driven status for a server (or all) + │ ├── useServerLogs.ts # WS-driven log stream + │ ├── useServerPlayers.ts # WS-driven player list + │ ├── useServerMetrics.ts # WS-driven metrics + │ ├── useConfigForm.ts # Dynamic form from JSON Schema + optimistic locking + │ ├── useCapability.ts # Check adapter.has_capability() + │ └── useConfirm.ts # Confirmation dialog hook + │ + ├── stores/ + │ ├── authStore.ts # Zustand: token, user, role + │ └── wsStore.ts # Zustand: connection status, reconnect count + │ + ├── components/ + │ ├── ui/ # Primitives (no business logic) + │ │ ├── Button.tsx # neu-raised / neu-raised-accent / neu-raised-danger + │ │ ├── Input.tsx # neu-inset + │ │ ├── Select.tsx # neu-inset + custom dropdown (neu-raised) + │ │ ├── Toggle.tsx # neu-raised (on) / neu-inset (off) with amber LED + │ │ ├── Badge.tsx + │ │ ├── Modal.tsx # neu-flat (drop shadow, no neumorphic play) + │ │ ├── Toast.tsx + │ │ ├── Tooltip.tsx + │ │ ├── ConfirmDialog.tsx + │ │ ├── EmptyState.tsx + │ │ ├── LoadingBar.tsx # amber bar, inset track + │ │ ├── CodeBlock.tsx # neu-inset + │ │ └── StatusLed.tsx # LED dot with glow (amber/red/gray) + │ │ + │ ├── layout/ + │ │ ├── AppShell.tsx # Header + sidebar + footer + │ │ ├── Sidebar.tsx + │ │ ├── Header.tsx + │ │ └── StatusBar.tsx # Footer: WS status, server count + │ │ + │ ├── server/ + │ │ ├── ServerCard.tsx # Dashboard summary card (neu-raised) + │ │ ├── ServerList.tsx # Table view (rows inside neu-raised container) + │ │ ├── ServerStatusDot.tsx # Status LED with glow + │ │ ├── ServerActionBar.tsx # Start/Stop/Restart/Kill buttons + │ │ ├── ServerMetricCard.tsx # Player/CPU/RAM card (neu-raised, amber numbers) + │ │ └── NewServerDialog.tsx # Multi-step creation modal (neu-flat) + │ │ + │ ├── config/ + │ │ ├── ConfigSection.tsx # Collapsible config panel (neu-raised) + │ │ ├── ConfigForm.tsx # Dynamic form from JSON Schema (neu-inset inputs) + │ │ ├── ConfigField.tsx # Single field renderer + │ │ ├── ConfigPreview.tsx # Modal with rendered config (neu-inset code blocks) + │ │ └── ConflictDialog.tsx # Optimistic locking 409 handler + │ │ + │ ├── players/ + │ │ ├── PlayerTable.tsx + │ │ ├── KickPopover.tsx + │ │ ├── BanPopover.tsx + │ │ └── SayAllDialog.tsx + │ │ + │ ├── logs/ + │ │ ├── LogViewer.tsx # Virtualized log stream + │ │ └── LogLine.tsx + │ │ + │ ├── missions/ + │ │ ├── MissionTable.tsx + │ │ ├── MissionUpload.tsx + │ │ ├── RotationList.tsx + │ │ └── RotationEntry.tsx + │ │ + │ ├── mods/ + │ │ ├── ModTable.tsx + │ │ ├── ServerModList.tsx + │ │ └── ModRegistrationDialog.tsx + │ │ + │ ├── bans/ + │ │ ├── BanTable.tsx + │ │ └── BanFormDialog.tsx + │ │ + │ └── charts/ + │ ├── MetricsChart.tsx # CPU + RAM time series + │ └── PlayerCountChart.tsx + │ + ├── pages/ + │ ├── LoginPage.tsx + │ ├── DashboardPage.tsx + │ ├── ServerListPage.tsx + │ ├── ServerDetailPage.tsx + │ ├── ModsPage.tsx + │ ├── BansPage.tsx + │ ├── UsersPage.tsx + │ └── SettingsPage.tsx + │ + └── lib/ + ├── jsonSchemaToFields.ts # JSON Schema → form field descriptors + ├── formatUptime.ts # Seconds → "2h 15m" + ├── formatBytes.ts # Bytes → human readable + ├── timeAgo.ts # Timestamp → "5 minutes ago" + └── cn.ts # clsx + tailwind-merge utility +``` + +### Key Component Contracts + +**`ConfigForm`** — the hardest component. Must handle: +- Dynamic rendering from JSON Schema (any game, any section) +- Sensitive field masking +- Dirty state tracking +- Optimistic locking (send `config_version` on save) +- 409 conflict resolution (show diff, allow override/merge) +- Validation errors from adapter (field-level, from Pydantic) + +**`LogViewer`** — performance-critical: +- Virtualized rendering (react-window or similar) +- Newest-first or oldest-first toggle +- Level filter, text search +- Auto-scroll with manual-override detection +- Streams via WebSocket, paginated fallback via HTTP + +**`ServerDetailPage`** — orchestrator: +- Resolves adapter from `server.game_type` +- Checks `has_capability()` to show/hide tabs +- Manages WS subscriptions for the server +- Handles status transitions in real-time (start → starting → running) + +--- + +## State Management Strategy + +### Server State (TanStack Query) + +All API data uses TanStack Query with appropriate stale times: + +| Query | staleTime | refetchInterval | +|-------|----------|-----------------| +| Server list | 30s | Background 30s (fallback for WS) | +| Server detail | 15s | — | +| Config sections | 5min | — (manual save) | +| Players | 0s | WS-driven | +| Logs | 0s | WS-driven | +| Metrics | 0s | WS-driven | +| Missions | 5min | — | +| Mods | 5min | — | +| Bans | 2min | — | +| Game types | 30min | — (rarely changes) | +| Config schema | 30min | — (tied to adapter version) | + +**Optimistic updates** on: +- Server start/stop → immediately update status in cache, rollback on error +- Player kick/ban → immediately remove from player list cache +- Config save → immediately update config cache, rollback on 409 or error + +### Client State (Zustand) + +Only two stores — keep it minimal: + +**`authStore`:** +```typescript +interface AuthState { + token: string | null; + user: { id: number; username: string; role: "admin" | "viewer" } | null; + setAuth: (token: string, user: User) => void; + logout: () => void; + isAdmin: () => boolean; +} +``` + +**`wsStore`:** +```typescript +interface WsState { + status: "connected" | "reconnecting" | "disconnected"; + reconnectAttempts: number; + lastEventAt: number | null; +} +``` + +### URL State + +Persisted in URL query params: +- Server list filters (`game_type`, `sort`) +- Log viewer filters (`level`, `search`) +- Ban list filters (`server`, `active_only`) +- Metrics chart time range (`from`, `to`, `resolution`) + +--- + +## WebSocket Integration + +### Connection Lifecycle + +``` +App Mount + │ + ├── Connect to ws://localhost:8000/ws/all?token= + │ ├── On open: wsStore.status = "connected" + │ ├── On close: wsStore.status = "disconnected" + │ │ └── Auto-reconnect with exponential backoff (1s → 2s → 4s → ... → 30s) + │ ├── On error: wsStore.status = "reconnecting" + │ └── On message: dispatch to handlers + │ + ├── Subscribe to: ["status", "event"] + │ + └── On server detail navigation: + └── Subscribe to: ["logs", "players", "metrics", "status", "event"] + for that specific server_id +``` + +### Message Dispatch + +```typescript +// hooks/useWebSocket.ts +function handleWsMessage(msg: WsMessage) { + switch (msg.type) { + case "status": + queryClient.setQueryData(["servers", msg.server_id], (old) => ({ + ...old, status: msg.data.status, pid: msg.data.pid, started_at: msg.data.started_at, + })); + break; + + case "log": + // Prepend to log cache (newest-first) + queryClient.setQueryData(["servers", msg.server_id, "logs"], (old) => ({ + ...old, logs: [msg.data, ...old.logs], + })); + break; + + case "players": + queryClient.setQueryData(["servers", msg.server_id, "players"], msg.data.players); + break; + + case "metrics": + queryClient.setQueryData(["servers", msg.server_id, "metrics"], (old) => ({ + ...old, current: msg.data, + })); + break; + + case "event": + // Prepend to events cache + show toast for critical events + if (msg.data.event_type === "crashed") { + toast.error(`Server ${msg.server_id} crashed`); + } + break; + } +} +``` + +### Reconnection UX + +- **StatusBar** shows connection state at all times: green dot = connected, amber spinner = reconnecting, red X = disconnected. +- During reconnection, all WS-driven data shows its last known value with a subtle "last updated X seconds ago" indicator. +- On reconnect, TanStack Query invalidates all server data to get fresh state. +- If JWT expires during WS connection, server closes the socket. Client detects 4xx on reconnect → redirect to login. + +--- + +## Adapter-Aware UI Patterns + +The frontend never hardcodes game-specific logic. It queries adapter metadata and renders accordingly. + +### Capability Check Pattern + +```typescript +// hooks/useCapability.ts +function useCapability(serverId: number) { + const { data: server } = useServerDetail(serverId); + const { data: gameType } = useGameType(server?.game_type); + + return { + hasMissions: gameType?.capabilities.includes("mission_manager") ?? false, + hasMods: gameType?.capabilities.includes("mod_manager") ?? false, + hasRemoteAdmin: gameType?.capabilities.includes("remote_admin") ?? false, + hasBanManager: gameType?.capabilities.includes("ban_manager") ?? false, + }; +} + +// Usage in ServerDetailPage: +const { hasMissions, hasMods, hasRemoteAdmin } = useCapability(serverId); +// Only render tabs that the adapter supports +``` + +### Dynamic Config Form Pattern + +```typescript +// lib/jsonSchemaToFields.ts +interface FieldDescriptor { + name: string; + label: string; + type: "text" | "number" | "boolean" | "select" | "textarea" | "password" | "array"; + default?: unknown; + min?: number; + max?: number; + step?: number; + enum?: string[]; + description?: string; + isSensitive?: boolean; +} + +function jsonSchemaToFields( + schema: JsonSchema, + sensitiveFields: string[] +): FieldDescriptor[] { + // Walk schema.properties, map each to a FieldDescriptor + // Mark fields in sensitiveFields as type: "password" +} +``` + +### Game Type Card Pattern + +```typescript +// Used in NewServerDialog and Dashboard + setSelectedGame("arma3")} +/> +``` + +Future adapters register themselves; the card list auto-populates from `GET /games`. + +--- + +## Error Handling UX + +| Scenario | UI Response | +|----------|-------------| +| API 401 | Redirect to login with "Session expired" toast | +| API 403 | "You don't have permission" inline message | +| API 404 | Empty state component with "Not found" | +| API 409 (config conflict) | ConflictDialog with diff + override/merge | +| API 422 (validation) | Field-level red highlights + error messages | +| API 429 (rate limit) | "Too many requests, try again in X seconds" toast | +| API 500 | "Server error" toast with retry button | +| WS disconnect | StatusBar indicator + stale data with timestamp | +| WS reconnect | Automatic; no user action needed | +| Server crashed | Toast notification + status dot turns red | + +**No `alert()` calls.** All feedback uses toast (transient) or inline (persistent) patterns. + +--- + +## Performance Budget + +| Metric | Target | +|--------|--------| +| First Contentful Paint | < 1.5s | +| Time to Interactive | < 3s | +| Bundle size (gzipped) | < 250KB JS | +| CSS size (gzipped) | < 40KB | +| Log viewer render (1000 lines) | < 16ms per frame | +| WebSocket message processing | < 5ms per message | + +**Techniques:** +- Vite code splitting per route (lazy `React.lazy` + `Suspense`) +- Virtual list for log viewer (react-window) +- TanStack Query deduplication (same query key = same request) +- Tailwind purge for minimal CSS +- Fonts: preload critical weights only (`Inter` 400/500/600, `JetBrains Mono` 400) +- No icon font — Lucide is tree-shaken SVGs + +--- + +## Accessibility + +- **Focus management:** Modals trap focus. Close on Escape. Return focus to trigger. +- **Keyboard navigation:** All interactive elements reachable via Tab. Action bar buttons have shortcuts (S=Start, X=Stop, R=Restart). +- **Color is not the only status indicator:** Status LEDs are paired with text labels. Logs use prefix tags (INFO, WARN, ERROR), not just color. +- **Contrast:** All text meets WCAG AA against its surface (4.5:1 minimum for body text). +- **Reduced motion:** Respect `prefers-reduced-motion` — disable transitions when active. +- **ARIA:** Live regions for WS status changes. `aria-label` on icon-only buttons. + +--- + +## Toast System + +Transient notifications. Stack in bottom-right. + +| Type | Use | Duration | +|------|-----|----------| +| Success | Config saved, server started, mod enabled | 3s | +| Error | API errors, WS disconnect, validation failures | 7s (or dismiss) | +| Warning | Rate limited, high CPU, approaching memory limit | 5s | +| Info | Ban synced to file, mission uploaded | 3s | + +Max 3 visible at once. Oldest dismissed automatically. + +--- + +## Loading States + +- **Route transitions:** Suspense boundary with a minimal loading bar at the top of the content area (not a full-page spinner). +- **Data loading:** Skeleton placeholders matching the layout shape of the loaded content. No spinners. +- **Actions:** Button shows inline spinner for duration of request. Disabled during request. +- **Initial load:** Dashboard shows skeleton cards → real data populates in place. + +--- + +## Empty States + +Every list/table has a designed empty state: + +| Context | Empty State | +|---------|------------| +| No servers | "No servers yet" + [Create Server] button | +| No players | "No players connected" (dimmed, no action needed) | +| No missions | "No missions uploaded" + [Upload Mission] button | +| No mods registered | "No mods registered" + [Register Mod] button | +| No bans | "No active bans" (this is good news — no action needed) | +| No logs | "No log entries yet — server may not have started" | + +--- + +## Build & Dev Commands + +```bash +# Development +npm run dev # Vite dev server on :5173 + +# Production build +npm run build # tsc + vite build +npm run preview # Preview production build locally + +# Quality +npm run lint # ESLint +npm run format # Prettier +npm run typecheck # tsc --noEmit +``` + +### globals.css (neumorphic classes) + +The neumorphic shadow classes live in `src/styles/globals.css` and are referenced by Tailwind components via `@apply` or direct class names. They cannot be expressed as Tailwind utilities (multi-shadow syntax): + +```css +/* src/styles/globals.css — neumorphic primitives */ + +.neu-raised { + background: var(--color-surface); + border: none; + border-radius: var(--radius-md); + box-shadow: + 4px 4px 8px var(--neu-dark), + -4px -4px 8px var(--neu-light); + transition: box-shadow var(--duration-fast) var(--ease-out); +} + +.neu-raised:active { + box-shadow: + inset 3px 3px 6px var(--neu-dark), + inset -3px -3px 6px var(--neu-light); +} + +.neu-inset { + background: var(--color-base); + border: none; + border-radius: var(--radius-sm); + box-shadow: + inset 3px 3px 6px var(--neu-dark), + inset -3px -3px 6px var(--neu-light); +} + +.neu-flat { + background: var(--color-elevated); + border: none; + border-radius: var(--radius-lg); + box-shadow: 0 8px 32px oklch(0% 0 0 / 0.5); +} + +.neu-raised-accent { + background: var(--color-accent); + color: #0d0d0d; + border: none; + border-radius: var(--radius-md); + box-shadow: + 4px 4px 8px var(--neu-dark), + -4px -4px 8px var(--neu-light), + 0 0 12px oklch(72% 0.15 80 / 0.2); + transition: box-shadow var(--duration-fast) var(--ease-out); +} + +.neu-raised-accent:hover { + background: var(--color-accent-hover); +} + +.neu-raised-accent:active { + box-shadow: + inset 3px 3px 6px var(--neu-dark), + inset -3px -3px 6px var(--neu-light); +} + +/* LED status indicators */ +.led { + width: 8px; + height: 8px; + border-radius: 9999px; + display: inline-block; +} + +.led-running { + composes: led; + background: var(--color-running); + box-shadow: var(--glow-amber); +} + +.led-crashed { + composes: led; + background: var(--color-crashed); + box-shadow: var(--glow-red); +} + +.led-stopped { + composes: led; + background: var(--color-stopped); + box-shadow: none; +} +``` + +### Vite Proxy (dev only) + +```typescript +// vite.config.ts +export default defineConfig({ + server: { + proxy: { + '/api': 'http://localhost:8000', + '/ws': { + target: 'ws://localhost:8000', + ws: true, + }, + }, + }, +}); +``` + +--- + +## Frontend ↔ Backend Contract Summary + +| Frontend Action | API Call | WS Channel | +|----------------|----------|------------| +| View server list | `GET /servers` | — | +| View server detail | `GET /servers/{id}` | Subscribe to server | +| Start server | `POST /servers/{id}/start` | status | +| Stop server | `POST /servers/{id}/stop` | status | +| View live logs | `GET /servers/{id}/logs` (initial) | log | +| View player list | `GET /servers/{id}/players` (initial) | players | +| View metrics | `GET /servers/{id}/metrics` (initial) | metrics | +| Edit config | `GET/PUT /servers/{id}/config/{section}` | — | +| Preview config | `GET /servers/{id}/config/preview` | — | +| Upload mission | `POST /servers/{id}/missions/upload` | — | +| Manage rotation | `GET/PUT /servers/{id}/missions/rotation` | — | +| Enable mods | `PUT /servers/{id}/mods` | — | +| Kick player | `POST /servers/{id}/players/{slot}/kick` | players | +| Ban player | `POST /servers/{id}/players/{slot}/ban` | players | +| View events | `GET /servers/{id}/events` | event | +| Check capabilities | `GET /games/{type}` | — | +| Get config schema | `GET /games/{type}/config-schema` | — | \ No newline at end of file diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 5b5fdfc..e601902 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -4,15 +4,61 @@ Before starting, ensure the following are available: - Python 3.11+ -- A working Arma 3 dedicated server installation (for testing) +- A working Arma 3 dedicated server installation (for testing the first adapter) - Node.js 18+ (for frontend dev server) - The reference docs: ARCHITECTURE.md, DATABASE.md, API.md, MODULES.md, THREADING.md --- -## Phase 1 — Foundation (Start Here) +## Phase 0 — Adapter Framework (New) -**Goal:** Running FastAPI server with DB, auth, and basic server CRUD. +**Goal:** Build the adapter protocol + registry system before any other code. This is the foundation that makes every subsequent phase modular. + +### Step 0.1 — Adapter protocols, exceptions, and registry + +1. Create `backend/adapters/__init__.py` — auto-imports built-in adapters +2. Create `backend/adapters/protocols.py` — all capability Protocol definitions: + - `ConfigGenerator` (merged: schema + generation), `ProcessConfig`, `LogParser` + - `RemoteAdmin`, `RemoteAdminClient` + - `MissionManager`, `ModManager`, `BanManager` + - `GameAdapter` (composite protocol with `has_capability()` method) + - `ConfigGenerator` includes `get_sections()`, `get_sensitive_fields(section)`, `get_config_version()` + - `RemoteAdmin` includes `get_player_data_schema() -> type[BaseModel] | None` + - `MissionManager` includes `get_mission_data_schema() -> type[BaseModel] | None` + - `ModManager` includes `get_mod_data_schema() -> type[BaseModel] | None` + - `BanManager` includes `get_ban_data_schema() -> type[BaseModel] | None` +3. Create `backend/adapters/exceptions.py` — typed adapter exceptions: + - `AdapterError` (base) + - `ConfigWriteError` — atomic write failed (tmp file cleanup done) + - `ConfigValidationError` — adapter Pydantic validation failed + - `LaunchArgsError` — invalid launch arguments + - `RemoteAdminError` — admin protocol communication failed + - `ExeNotAllowedError` — executable not in adapter allowlist +4. Create `backend/adapters/registry.py` — `GameAdapterRegistry` singleton +5. Add `has_capability(name) -> bool` method to `GameAdapter` protocol — core uses explicit capability probes instead of scattered `None` checks +6. Write unit tests: register adapter, get adapter, list game types, missing adapter raises error, exceptions are catchable by type, has_capability returns correct bools + +### Step 0.2 — Arma 3 adapter skeleton + +1. Create `backend/adapters/arma3/__init__.py` — exports and registers `ARMA3_ADAPTER` +2. Create `backend/adapters/arma3/adapter.py` — `Arma3Adapter` class (all methods return stubs initially) +3. Create `backend/adapters/arma3/process_config.py` — `Arma3ProcessConfig` (full implementation) +4. Create `backend/adapters/arma3/config_generator.py` — Pydantic models (ServerConfig, BasicConfig, ProfileConfig, LaunchConfig, RConConfig) + `Arma3ConfigGenerator` (schema + generation merged) +5. **Third-party adapter loading**: add `languard.adapters` entry_point group to `pyproject.toml`: + ```toml + [project.entry-points."languard.adapters"] + arma3 = "adapters.arma3:ARMA3_ADAPTER" + ``` + Core scans entry_points at startup via `importlib.metadata` in addition to built-in imports. +6. Write unit tests: adapter registers, protocols satisfied, config schema produces valid JSON Schema + +**Test:** Import adapters module → `GameAdapterRegistry.get("arma3")` returns a valid adapter. `GameAdapterRegistry.list_game_types()` returns `[{"game_type": "arma3", "display_name": "Arma 3", ...}]`. + +--- + +## Phase 1 — Foundation + +**Goal:** Running FastAPI server with DB, auth, and basic server CRUD using the adapter framework. ### Step 1.1 — Project scaffold @@ -22,230 +68,214 @@ cd backend python -m venv venv venv/Scripts/activate pip install fastapi uvicorn[standard] sqlalchemy python-jose[cryptography] passlib[bcrypt] cryptography psutil apscheduler python-multipart slowapi pytest pytest-asyncio httpx -# uvloop (faster event loop) is Linux/macOS only — skip on Windows: -# pip install uvloop # only on Linux/macOS pip freeze > requirements.txt ``` Create: -- `backend/config.py` — Settings class (see MODULES.md) +- `backend/config.py` — Settings class - `backend/main.py` — FastAPI app factory, startup/shutdown hooks - `backend/conftest.py` — pytest fixtures (in-memory SQLite, test client) - `.env.example` — All env vars documented ### Step 1.2 — Database + Migrations -1. Create `backend/migrations/001_initial_schema.sql` — all tables from DATABASE.md - - Include all CHECK constraints (role, status, verify_signatures, von_codec_quality, etc.) +1. Create `backend/core/migrations/001_initial_schema.sql` — all core tables: + - `schema_migrations`, `users`, `servers` (with `game_type`), `game_configs` + - `mods` (with `game_type`, `game_data`), `server_mods` + - `missions`, `mission_rotation` (with `game_data`) + - `players` (with `slot_id` TEXT, `game_data`), `player_history` + - `bans` (with `game_data`), `logs`, `metrics`, `server_events` + - Include all CHECK constraints and indexes - Include `PRAGMA busy_timeout=5000` in engine setup - - **Important:** Put `CREATE TABLE IF NOT EXISTS schema_migrations` as the very first - statement — the migration runner queries this table before it can track anything. -2. Create `backend/dal/event_repository.py` — `ServerEventRepository` (needed by Phase 3 threads) +2. Create `backend/core/dal/event_repository.py` 3. Create `backend/database.py`: - `get_engine()` with WAL + FK pragma - - `run_migrations()` — reads and applies `.sql` files from migrations/ - - `get_db()` — FastAPI dependency (sync session) + - `run_migrations()` + - `get_db()` — FastAPI dependency - `get_thread_db()` — thread-local session factory -3. Call `run_migrations()` in `main.py:on_startup()` +4. Call `run_migrations()` in `main.py:on_startup()` -**Test:** Start app, confirm `languard.db` created with all tables. Run `pytest` with in-memory SQLite to verify schema creates cleanly. +**Test:** Start app, confirm `languard.db` created with all tables. Run `pytest` with in-memory SQLite. ### Step 1.3 — Auth module -1. `backend/auth/utils.py` — `hash_password`, `verify_password`, `create_access_token`, `decode_access_token` -2. `backend/auth/schemas.py` — `LoginRequest`, `TokenResponse`, `UserResponse` -3. `backend/auth/service.py` — `AuthService` (create user, login, list users) -4. `backend/auth/router.py` — login, me, users CRUD -5. `backend/dependencies.py` — `get_current_user`, `require_admin` -6. `main.py` — seed default admin user on first startup if users table empty - - **Generate a random password** and print it to stdout once (NOT admin/admin) - - Add rate limiting to `POST /auth/login` (5 attempts/minute per IP via slowapi) - - Add input sanitization for all string fields in auth schemas +1. `backend/core/auth/utils.py` — `hash_password`, `verify_password`, `create_access_token`, `decode_access_token` +2. `backend/core/auth/schemas.py` — `LoginRequest`, `TokenResponse`, `UserResponse` +3. `backend/core/auth/service.py` — `AuthService` +4. `backend/core/auth/router.py` — login, me, users CRUD +5. `backend/dependencies.py` — `get_current_user`, `require_admin`, `get_adapter_for_server` +6. `main.py` — seed default admin user on first startup (random password printed to stdout) +7. Add rate limiting to `POST /auth/login` (5 attempts/minute per IP via slowapi) **Test:** `POST /api/auth/login` returns JWT. `GET /api/auth/me` with token returns user. Rate limiting returns 429 after 5 failed attempts. ### Step 1.4 — Server CRUD (no process management yet) -1. `backend/dal/server_repository.py` -2. `backend/dal/config_repository.py` -3. `backend/servers/schemas.py` -4. `backend/servers/router.py` — GET, POST, PUT, DELETE /servers and /servers/{id} -5. `backend/servers/service.py` — CRUD methods only (skip start/stop for now) -6. `backend/utils/file_utils.py` — `ensure_server_dirs()`, `sanitize_filename()` -7. `backend/utils/port_checker.py` — `is_port_in_use()`, `check_server_ports_available()` -8. Port validation on create/start: check game_port through game_port+4 +1. `backend/core/dal/server_repository.py` +2. `backend/core/dal/config_repository.py` — manages `game_configs` table +3. `backend/core/servers/schemas.py` — `CreateServerRequest` (includes `game_type`) +4. `backend/core/servers/router.py` — GET, POST, PUT, DELETE /servers +5. `backend/core/servers/service.py` — CRUD methods + `create_server` seeds config sections from adapter defaults +6. `backend/core/utils/file_utils.py` — `ensure_server_dirs()` (uses adapter's `get_server_dir_layout()`) +7. `backend/core/utils/port_checker.py` — `is_port_in_use()`, `check_server_ports_available()` + - **Full cross-game port checking**: query ALL running servers, resolve each adapter, get port conventions for each, check the full derived port set + - Example: Arma 3 uses game port + 1 (Steam query), BattlEye RCon port; another game may use different conventions — all checked -**Test:** Create server via API, confirm DB row + directory created. +**Test:** Create server via API with `game_type: "arma3"` → confirm DB row + `game_configs` rows + directory created. Create a second server with a port that conflicts with derived ports of the first → confirm 409 error. + +### Step 1.5 — Game type discovery endpoints + +1. `backend/core/games/router.py` — `GET /games`, `GET /games/{type}`, `GET /games/{type}/config-schema`, `GET /games/{type}/defaults` + +**Test:** `GET /api/games` returns `[{"game_type": "arma3", ...}]`. `GET /api/games/arma3/config-schema` returns JSON Schema for all 5 Arma 3 config sections. + +### Step 1.6 — Migration script for existing Arma 3 data + +If upgrading from the single-game schema, create a migration script: + +1. Create `backend/core/migrations/002_migrate_arma3_config.py` +2. Column type map: `max_players` INT→JSON `maxPlayers`, `hostname` TEXT→JSON `hostname`, etc. +3. `migrate_config_table()`: read old Arma 3 config table rows → build `game_configs` JSON blobs → insert into new table → delete old rows +4. `migrate_player_data()`: convert `player_num` INTEGER → `slot_id` TEXT +5. Transaction + rollback: all migration runs inside a single DB transaction; on failure, full rollback +6. Row count verification: after migration, assert row counts match between old and new tables +7. Idempotent: safe to run multiple times (checks if migration already applied) + +**Test:** Create test DB with old single-game schema + sample data → run migration script → verify all data in new tables → verify old tables dropped. --- -## Phase 2 — Process Management +## Phase 2 — Arma 3 Adapter Implementation -**Goal:** Start/stop actual `arma3server.exe` processes. +**Goal:** Complete the Arma 3 adapter with config generation and process management. This phase proves the adapter architecture works end-to-end with the primary game. -### Step 2.1 — Config Generator +### Step 2.1 — Config Generator (Arma 3 adapter) -1. `backend/servers/config_generator.py` -2. **Use a structured builder** (NOT f-strings) — escape double quotes and newlines in all user-supplied string values to prevent config injection -3. Write `server.cfg` covering all params from DATABASE.md, including mission rotation as `class Missions {}` block +1. `backend/adapters/arma3/config_generator.py` — `Arma3ConfigGenerator` +2. **Use a structured builder** (NOT f-strings) — escape double quotes and newlines in all user-supplied string values +3. Write `server.cfg` covering all params from config schema, including mission rotation as `class Missions {}` block 4. Write `basic.cfg` -5. Write `server.Arma3Profile` — **written to `servers/{id}/server/server.Arma3Profile`** (Arma 3 reads from the `-name` subdirectory) -6. Write `BESERVER_CFG_TEMPLATE` — **required for BattlEye RCon to work** - ``` - # servers/{id}/battleye/beserver.cfg - RConPassword {rcon_password} - RConPort {rcon_port} - ``` - `write_beserver_cfg()` must create the `battleye/` directory and write this file. - Without it BattlEye will not open an RCon port regardless of launch parameters. -7. `build_launch_args()` — assembles full CLI arg list - - Include `-bepath=./battleye` to point BE at the generated config (relative to cwd) - - Include `-profiles=./` and `-name=server` for profile directory - - All relative paths resolve against `cwd=servers/{id}/` set in ProcessManager -8. Set file permissions 0600 on config files containing passwords (server.cfg, beserver.cfg) +5. Write `server.Arma3Profile` — written to `servers/{id}/server/server.Arma3Profile` +6. Write `beserver.cfg` — creates `battleye/` directory, writes RCon config +7. `build_launch_args()` — assembles full CLI arg list including `-bepath=./battleye` +8. `preview_config()` — renders all files without writing to disk, returns `dict[str, str]` of label→content (filenames for file-based, variable names for env-var, argument names for CLI) +9. Set file permissions 0600 on config files containing passwords +10. **Atomic write pattern**: all config files written to `.tmp` files first, then `os.replace()` for atomic rename. On any write failure, all `.tmp` files are cleaned up and original files remain untouched. Raises `ConfigWriteError` on failure. -**Test:** `ConfigGenerator.write_all(server_id)` → inspect all generated files for correctness. -Verify `servers/{id}/battleye/beserver.cfg` exists with the correct RCon password. -Verify `servers/{id}/server/server.Arma3Profile` exists. -Test config injection prevention: set hostname to `X"; passwordAdmin = "pwned"; //` — verify generated server.cfg does NOT contain the injected directive. -Validate generated `server.cfg` manually by running the server with it. +**Test:** `Arma3ConfigGenerator.write_configs(server_id, dir, config)` → inspect all generated files. Test config injection prevention: set hostname to `X"; passwordAdmin = "pwned"; //` — verify generated server.cfg does NOT contain the injected directive. Test atomic write: mock `os.replace()` to raise OSError → confirm `.tmp` files are cleaned up and original files are untouched. -### Step 2.2 — Process Manager +### Step 2.2 — Process Manager (core) -1. `backend/servers/process_manager.py` — `ProcessManager` singleton -2. `start(server_id, exe_path, args, cwd=servers/{id}/)` — subprocess.Popen with cwd set to server instance dir -3. `stop(server_id, timeout=30)` — on Windows: `terminate()` = hard kill (no SIGTERM). Graceful shutdown is via RCon `#shutdown` in ServerService. +1. `backend/core/servers/process_manager.py` — `ProcessManager` singleton (game-agnostic) +2. `start(server_id, exe_path, args, cwd=servers/{id}/)` +3. `stop(server_id, timeout=30)` — on Windows: `terminate()` = hard kill 4. `kill()`, `is_running()`, `get_pid()` -5. `recover_on_startup()` — verify PID is alive AND process name matches arma3server (prevents PID reuse) -6. Wire `ServerService.start()` and `ServerService.stop()` +5. `recover_on_startup()` — verify PID is alive AND process name matches adapter allowlist (prevents PID reuse) +6. Wire `ServerService.start()` and `ServerService.stop()` — both delegate to adapter for exe validation and config generation 7. Add `POST /servers/{id}/start`, `POST /servers/{id}/stop`, `POST /servers/{id}/kill` endpoints +8. **Typed exception handling in start flow**: catch and map adapter exceptions to HTTP responses: + - `ConfigWriteError` → 500 (atomic write failed, tmp cleaned) + - `ConfigValidationError` → 422 (invalid config values) + - `LaunchArgsError` → 400 (invalid launch arguments) + - `ExeNotAllowedError` → 403 (executable not in adapter allowlist) -**Test:** Start a server via API → confirm process appears in Task Manager. Stop it → confirm process ends. +**Test:** Start a server via API → confirm process appears in Task Manager. Stop it → confirm process ends. Test error paths: set invalid exe path → confirm 403 ExeNotAllowedError response. -### Step 2.3 — Config endpoints +### Step 2.3 — Config endpoints (core + adapter validation) -1. `GET /servers/{id}/config` -2. `PUT /servers/{id}/config/server` -3. `PUT /servers/{id}/config/basic` -4. `PUT /servers/{id}/config/profile` -5. `PUT /servers/{id}/config/launch` -6. `GET /servers/{id}/config/preview` +1. `GET /servers/{id}/config` — reads all sections from `game_configs` +2. `GET /servers/{id}/config/{section}` — reads single section, response includes `_meta` with `config_version` and `schema_version` +3. `PUT /servers/{id}/config/{section}` — validates against adapter's Pydantic model, encrypts sensitive fields via `adapter.get_sensitive_fields(section)`, stores in `game_configs` + - **Optimistic locking**: client must send `config_version` in request body; if it doesn't match the current row's `config_version`, return 409 Conflict with `CONFIG_VERSION_CONFLICT` error code + - On successful write, increment `config_version` in the row +4. `GET /servers/{id}/config/preview` — delegates to adapter's `preview_config()`, returns `dict[str, str]` of label→content +5. `GET /servers/{id}/config/download/{filename}` — filename validated against adapter allowlist -**Test:** Update hostname via API → regenerate and start server → confirm new hostname appears in server browser. +**Test:** Update hostname via API → regenerate and start server → confirm new hostname appears in server browser. Test optimistic locking: two concurrent PUT requests with same config_version → one succeeds (200), one fails (409 Conflict). --- -## Phase 3 — Background Threads +## Phase 3 — Background Threads (Core + Adapter) **Goal:** Live monitoring — process crash detection, log tailing, metrics. ### Step 3.1 — Thread infrastructure -1. `backend/threads/base_thread.py` — `BaseServerThread` -2. `backend/threads/thread_registry.py` — `ThreadRegistry` singleton +1. `backend/core/threads/base_thread.py` — `BaseServerThread` +2. `backend/core/threads/thread_registry.py` — `ThreadRegistry` (adapter-aware) 3. Wire `start_server_threads()` / `stop_server_threads()` into `ServerService.start()` / `ServerService.stop()` -### Step 3.2 — Process Monitor Thread +### Step 3.2 — Process Monitor Thread (core) -1. `backend/threads/process_monitor.py` +1. `backend/core/threads/process_monitor.py` 2. Crash detection + status update in DB -3. Auto-restart with exponential backoff +3. Auto-restart with exponential backoff (daemon cleanup thread pattern) **Test:** Start server → kill process manually → confirm DB status changes to 'crashed'. **Test:** Enable auto_restart → kill → confirm server restarts automatically. -### Step 3.3 — Log Tail Thread +### Step 3.3 — Log Parser (Arma 3 adapter) + Log Tail Thread (core) -1. `backend/logs/parser.py` — `RPTParser` -2. `backend/dal/log_repository.py` -3. `backend/threads/log_tail.py` -4. `backend/logs/service.py` -5. `backend/logs/router.py` — `GET /servers/{id}/logs` +1. `backend/adapters/arma3/log_parser.py` — `RPTParser` implementing `LogParser` protocol +2. `backend/core/threads/log_tail.py` — `LogTailThread` (generic, takes adapter's `LogParser`) +3. `backend/core/dal/log_repository.py` +4. `backend/core/logs/service.py` +5. `backend/core/logs/router.py` — `GET /servers/{id}/logs` **Test:** Start server → `GET /api/servers/{id}/logs` returns recent RPT lines. -### Step 3.4 — Metrics Collector Thread +### Step 3.4 — Metrics Collector Thread (core) -1. `backend/metrics/service.py` -2. `backend/dal/metrics_repository.py` -3. `backend/threads/metrics_collector.py` -4. `backend/metrics/router.py` — `GET /servers/{id}/metrics` +1. `backend/core/metrics/service.py` +2. `backend/core/dal/metrics_repository.py` +3. `backend/core/threads/metrics_collector.py` +4. `backend/core/metrics/router.py` — `GET /servers/{id}/metrics` **Test:** Running server → query metrics endpoint → see CPU/RAM data points. --- -## Phase 4 — BattlEye RCon +## Phase 4 — Remote Admin (Arma 3: BattlEye RCon) -**Goal:** Real-time player list, in-game admin commands. +**Goal:** Real-time player list, in-game admin commands via the adapter's RemoteAdmin protocol. -### Step 4.1 — RCon Client +### Step 4.1 — RCon Client (Arma 3 adapter) -1. `backend/rcon/client.py` — `BERConClient` +1. `backend/adapters/arma3/rcon_client.py` — `BERConClient` 2. Implement BE RCon UDP protocol: - Packet structure: `'BE'` + CRC32 (little-endian) + type byte + payload - Login: type `0x00`, payload = password - Command: type `0x01`, payload = sequence byte + command string - Keepalive: type `0x02`, payload = empty -3. **Request multiplexer**: track pending requests by sequence byte, route responses to correct caller via `threading.Event` per request. Background receiver thread reads all incoming packets. +3. **Request multiplexer**: track pending requests by sequence byte, route responses to correct caller via `threading.Event` per request 4. `parse_players_response()` — parse `players` command output -5. Handle unsolicited server messages (type 0x02) — enqueue for event logging - -BattlEye RCon packet format reference: -``` -Login packet (client → server): - 42 45 # 'BE' - [CRC32 LE] # checksum of bytes after CRC - FF # packet type prefix - 00 # login type - [password] # ASCII password - -Command packet: - 42 45 - [CRC32 LE] - FF - 01 - [seq byte] # 0x00-0xFF, wraps around - [command] # ASCII command string - -Command response (server → client): - 42 45 - [CRC32 LE] - FF - 01 # 0x01 = command response (same type byte as outgoing command) - [seq byte] - [response] # ASCII response text - -Server-pushed message (server → client, unsolicited): - 42 45 - [CRC32 LE] - FF - 02 # 0x02 = server message (chat events, kill events, etc.) - [seq byte] - [message] # ASCII message text -``` +5. Handle unsolicited server messages (type 0x02) **Test:** Connect BERConClient to a running server with BattlEye → successfully login → send `players` → receive response. -### Step 4.2 — RCon Service + Poller Thread +### Step 4.2 — RCon Service (Arma 3 adapter) + Remote Admin Poller Thread (core) -1. `backend/rcon/service.py` — `RConService` -2. `backend/threads/rcon_poller.py` -3. `backend/dal/player_repository.py` -4. `backend/players/service.py` -5. `backend/players/router.py` — `GET /servers/{id}/players` +1. `backend/adapters/arma3/rcon_service.py` — `Arma3RConService` implementing `RemoteAdmin` protocol +2. `backend/core/threads/remote_admin_poller.py` — `RemoteAdminPollerThread` (generic, takes adapter's `RemoteAdmin`) +3. `backend/core/dal/player_repository.py` +4. `backend/core/players/service.py` +5. `backend/core/players/router.py` — `GET /servers/{id}/players` **Test:** Players join server → `GET /players` returns them with pings. -### Step 4.3 — Admin Actions via RCon +### Step 4.3 — Admin Actions via Remote Admin -1. `POST /servers/{id}/players/{num}/kick` -2. `POST /servers/{id}/players/{num}/ban` -3. `POST /servers/{id}/rcon/command` -4. `POST /servers/{id}/rcon/say` -5. `backend/dal/ban_repository.py` +1. `POST /servers/{id}/players/{slot_id}/kick` — delegates to adapter's `remote_admin.kick_player()` +2. `POST /servers/{id}/players/{slot_id}/ban` — delegates to adapter's `remote_admin.ban_player()` +3. `POST /servers/{id}/remote-admin/command` — delegates to adapter's `remote_admin.send_command()` +4. `POST /servers/{id}/remote-admin/say` — delegates to adapter's `remote_admin.say_all()` +5. `backend/core/dal/ban_repository.py` 6. `GET/POST/DELETE /servers/{id}/bans` -7. **ban.txt bidirectional sync**: on ban add/delete via API, write to `battleye/ban.txt`; on startup, read `ban.txt` and upsert into DB + +### Step 4.4 — Ban Manager (Arma 3 adapter) + +1. `backend/adapters/arma3/ban_manager.py` — `Arma3BanManager` implementing `BanManager` protocol +2. **ban.txt bidirectional sync**: on ban add/delete via API, also write to `battleye/ban.txt`; on startup, read `ban.txt` and upsert into DB **Test:** Kick a player via API → confirm player disconnected from server. @@ -253,26 +283,19 @@ Server-pushed message (server → client, unsolicited): ## Phase 5 — WebSocket Real-Time -**Goal:** Live updates to React frontend without polling. +**Goal:** Live updates to React frontend without polling. **Fully game-agnostic.** ### Step 5.1 — Broadcast infrastructure -1. `backend/websocket/broadcaster.py` — `BroadcastThread` + `enqueue()` -2. `backend/websocket/manager.py` — `ConnectionManager` -3. Store event loop reference in `main.py:on_startup()`: - ```python - import asyncio - # on_startup() runs inside the asyncio event loop — use get_running_loop(), - # not get_event_loop() (deprecated in Python 3.10+ from async context). - _event_loop = asyncio.get_running_loop() - broadcaster.init(_event_loop, connection_manager) - ``` +1. `backend/core/websocket/broadcaster.py` — `BroadcastThread` + `enqueue()` +2. `backend/core/websocket/manager.py` — `ConnectionManager` +3. Store event loop reference in `main.py:on_startup()` 4. Start `BroadcastThread` in `on_startup()` 5. Wire `BroadcastThread.enqueue()` calls into all background threads ### Step 5.2 — WebSocket endpoint -1. `backend/websocket/router.py` +1. `backend/core/websocket/router.py` 2. JWT validation from query param 3. Subscribe/unsubscribe message handling 4. Ping/pong keepalive @@ -285,30 +308,30 @@ Wire `BroadcastThread.enqueue()` into: - `ProcessMonitorThread` → status updates, crash events - `LogTailThread` → log lines - `MetricsCollectorThread` → metrics snapshots -- `RConPollerThread` → player list updates +- `RemoteAdminPollerThread` → player list updates - `ServerService.start/stop` → status transitions **Test:** React frontend connects to WS → server starts → see status, logs, metrics all update in real time. --- -## Phase 6 — Mission & Mod Management +## Phase 6 — Mission & Mod Management (Arma 3 Adapter) ### Step 6.1 — Missions -1. `backend/missions/service.py` -2. `backend/missions/router.py` -3. Upload PBO validation (check `.pbo` extension, parse name) +1. `backend/adapters/arma3/mission_manager.py` — `Arma3MissionManager` implementing `MissionManager` protocol +2. `backend/core/missions/router.py` — generic endpoints (delegate to adapter if capability supported) +3. Upload file validation (extension from adapter's `MissionManager.file_extension`) 4. Mission rotation CRUD **Test:** Upload a `.pbo` → appears in `GET /missions` → set as rotation → start server → mission available. ### Step 6.2 — Mods -1. `backend/mods/service.py` -2. `backend/mods/router.py` -3. `build_mod_string()` — assemble `-mod=` and `-serverMod=` args -4. Wire mod string into `ConfigGenerator.build_launch_args()` +1. `backend/adapters/arma3/mod_manager.py` — `Arma3ModManager` implementing `ModManager` protocol +2. `backend/core/mods/router.py` — generic endpoints (delegate to adapter if capability supported) +3. `build_mod_args()` — assemble `-mod=` and `-serverMod=` args +4. Wire mod args into `Arma3ConfigGenerator.build_launch_args()` **Test:** Register `@CBA_A3` → enable on server → start → server loads mod. @@ -318,15 +341,12 @@ Wire `BroadcastThread.enqueue()` into: ### Step 7.1 — APScheduler jobs -Add to `on_startup()`: ```python -# Use BackgroundScheduler (not AsyncIOScheduler) because cleanup methods -# perform sync SQLite operations. AsyncIOScheduler would block the event loop. from apscheduler.schedulers.background import BackgroundScheduler scheduler = BackgroundScheduler() scheduler.add_job(log_service.cleanup_old_logs, 'cron', hour=3) scheduler.add_job(metrics_service.cleanup_old_metrics, 'cron', hour=3, minute=30) -scheduler.add_job(player_service.cleanup_old_history, 'cron', hour=4) # 90-day retention +scheduler.add_job(player_service.cleanup_old_history, 'cron', hour=4) scheduler.start() ``` @@ -335,60 +355,98 @@ scheduler.start() In `on_startup()` → `ProcessManager.recover_on_startup()`: - Query DB for servers with `status='running'` - Check if PID still alive (`psutil.pid_exists(pid)`) +- Validate process name against adapter's `get_allowed_executables()` - If alive: re-attach threads (skip process start, just start monitoring threads) - If dead: mark as `crashed`, clear players ### Step 7.3 — Events log -1. `backend/dal/event_repository.py` +1. `backend/core/dal/event_repository.py` 2. Insert events for: start, stop, crash, kick, ban, config change, mission change 3. `GET /servers/{id}/events` endpoint -### Step 7.4 — Security hardening (additional layers) +### Step 7.4 — Security hardening -1. Encrypt sensitive DB fields: `password`, `password_admin`, `rcon_password` - - `backend/utils/crypto.py` with Fernet - - **Key format:** `LANGUARD_ENCRYPTION_KEY` must be a Fernet base64 key, NOT hex. - Generate with: `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"` - Passing a hex string to `Fernet()` raises `ValueError` at startup. - - Encrypt on write, decrypt on read in repositories - - **NOTE:** Core security (rate limiting, input sanitization, config escaping, exe path validation) is already in Phases 1-2. -2. Additional penetration testing and security audit -3. Content-Security-Policy headers for frontend +1. Encrypt sensitive DB fields in `game_configs` JSON (passwords, rcon_password) + - `backend/core/utils/crypto.py` with Fernet + - `LANGUARD_ENCRYPTION_KEY` must be a Fernet base64 key + - **Adapter declares sensitive fields**: `adapter.get_sensitive_fields(section) -> list[str]` + - ConfigRepository handles Fernet encrypt/decrypt transparently: encrypts declared fields on write, decrypts on read +2. Content-Security-Policy headers for frontend +3. Penetration testing and security audit ### Step 7.5 — Frontend integration checklist Verify React app can: - [ ] Login and store JWT -- [ ] List servers with live status -- [ ] Start/stop server and see status update via WebSocket (no page refresh) -- [ ] View streaming log output -- [ ] See player list update every 10s -- [ ] See CPU/RAM charts update every 5s -- [ ] Edit all config sections and see preview -- [ ] Upload a mission PBO -- [ ] Kick a player -- [ ] Send a message to all players +- [ ] See list of supported game types +- [ ] Create server with game type selection +- [ ] List servers with live status (any game type) +- [ ] Start/stop server and see status update via WebSocket +- [ ] View streaming log output (parsed by adapter) +- [ ] See player list update (via adapter's remote admin) +- [ ] See CPU/RAM charts update +- [ ] Edit config sections (dynamic form from adapter's JSON Schema) +- [ ] Upload a mission file (if adapter supports missions) +- [ ] Manage mods (if adapter supports mods) +- [ ] Kick/ban a player (if adapter supports remote admin) +- [ ] Send a message to all players (if adapter supports remote admin) + +--- + +## Phase 8 — Second Adapter (Validation) + +**Goal:** Prove the architecture works by adding a second game adapter. This validates that new games require zero core changes. + +### Choose a second game (examples): +- **Minecraft Java Edition** — Has RCON (Source protocol), server.properties config, JAR executable, world/ directory, plugins/ mods +- **Rust** — Has RCON (websocket-based), server.cfg, RustDedicated.exe, oxide/mods +- **Valheim** — Has no RCON, start_server.sh config, valheim_server.exe, mods via BepInEx + +### Steps for a new adapter: + +1. Create `backend/adapters//` directory (built-in) or separate Python package (third-party) +2. Implement required protocols: `ConfigGenerator` (schema + generation), `ProcessConfig`, `LogParser` +3. Implement optional protocols as needed: `RemoteAdmin`, `MissionManager`, `ModManager`, `BanManager` +4. Create adapter class implementing `GameAdapter` +5. Register adapter: + - **Built-in**: add to `backend/adapters//__init__.py` and auto-import in `adapters/__init__.py` + - **Third-party**: add `languard.adapters` entry_point in `pyproject.toml`: + ```toml + [project.entry-points."languard.adapters"] + mygame = "my_package.adapters:MYGAME_ADAPTER" + ``` + Core discovers these via `importlib.metadata` at startup. +6. **No core code changes needed** +7. **No DB migrations needed** +8. Test: create a server with the new game_type, start it, monitor it --- ## Testing Strategy ### Unit tests (pytest) -- `ConfigGenerator.write_server_cfg()` — compare output against expected string; test config injection prevention -- `ConfigGenerator._escape_config_string()` — test double-quote and newline escaping +- `GameAdapterRegistry` — register, get, list, missing adapter +- `Arma3ConfigGenerator` — Pydantic model validation for each section (merged schema + generation) +- `Arma3ConfigGenerator.write_server_cfg()` — compare output against expected string; test config injection prevention +- `Arma3ConfigGenerator._escape_config_string()` — test double-quote and newline escaping - `RPTParser.parse_line()` — test all log formats - `BERConClient.parse_players_response()` — test with sample output -- `AuthService.login()` — correct password / wrong password / rate limiting +- `AuthService.login()` — correct/wrong password / rate limiting - Repository methods — use in-memory SQLite (`:memory:`) -- `check_server_ports_available()` — test derived port validation +- `check_server_ports_available()` — test derived port validation (via adapter conventions) - `sanitize_filename()` — test path traversal prevention -- In-memory SQLite setup in `conftest.py` — shared fixture for all repository tests +- Protocol conformance — verify Arma3Adapter satisfies all GameAdapter protocol methods ### Integration tests -- Full start/stop cycle with a real arma3server.exe (manual — requires licensed Arma 3 installation, not in CI) +- Full start/stop cycle with a real arma3server.exe (manual — requires licensed Arma 3) - WebSocket message delivery (can be automated with httpx test client) - RCon command round-trip (manual — requires running server with BattlEye) +- Adapter resolution: create server with game_type, verify correct adapter is used throughout + +### Adapter contract tests +- Template test suite that any new adapter should pass +- Tests: ConfigGenerator produces valid sections and valid config files, ProcessConfig returns allowed executables, LogParser parses sample lines ### Load notes - SQLite with WAL handles concurrent reads from 4 threads per server well @@ -412,7 +470,7 @@ pip install -r requirements.txt # 3. Environment cp .env.example .env -# Edit .env: set LANGUARD_ARMA_EXE to your arma3server_x64.exe path +# Edit .env: set game-specific paths (LANGUARD_ARMA3_DEFAULT_EXE, etc.) # 4. Run backend uvicorn main:app --reload --host 0.0.0.0 --port 8000 @@ -423,23 +481,22 @@ npm install npm run dev ``` -Backend auto-creates `languard.db` and seeds an admin user on first run: -- Username: `admin` -- Password: **randomly generated** and printed to stdout once (e.g., `Initial admin password: a7b9c2d4e5f6...`) -- Change immediately via `PUT /api/auth/password` +Backend auto-creates `languard.db`, seeds an admin user on first run, and registers the Arma 3 adapter automatically. --- ## Phase Summary -| Phase | Deliverable | Est. Complexity | -|-------|-------------|----------------| -| 1 | Foundation (auth + server CRUD) | Low | -| 2 | Process management + config gen | Medium | -| 3 | Background threads (monitor, logs, metrics) | Medium-High | -| 4 | BattlEye RCon (player list, admin cmds) | High | -| 5 | WebSocket real-time | Medium | -| 6 | Mission + mod management | Low-Medium | -| 7 | Polish, security, recovery | Medium | +| Phase | Deliverable | Key Change from Single-Game | +|-------|-------------|------------------------------| +| 0 | Adapter framework (protocols + exceptions + registry) | **NEW** — foundation for modularity | +| 1 | Foundation (auth + server CRUD + game discovery + migration) | Core tables, `game_type` field, `game_configs` JSON, migration from old schema | +| 2 | Arma 3 adapter: config gen + process mgmt | Config generation in adapter, atomic writes, typed exceptions, optimistic locking | +| 3 | Background threads (core + adapter injection) | Generic threads + adapter parsers/clients, per-server lock for RemoteAdmin | +| 4 | Remote admin (Arma 3: BattlEye RCon) | RCon in adapter, generic poller in core | +| 5 | WebSocket real-time | No change — fully game-agnostic | +| 6 | Mission + mod management (Arma 3 adapter) | In adapter, generic endpoints in core | +| 7 | Polish, security, recovery | Adapter-declared sensitive fields, Fernet encryption | +| 8 | Second game adapter | **NEW** — validates zero core changes, entry_points for third-party | -Implement phases in order — each phase builds on the previous and is independently testable. +Implement phases in order — each phase builds on the previous and is independently testable. Phase 0 must come first as it defines the contract that all subsequent code depends on. \ No newline at end of file diff --git a/MODULES.md b/MODULES.md index 09771aa..e494358 100644 --- a/MODULES.md +++ b/MODULES.md @@ -9,96 +9,115 @@ backend/ ├── database.py ├── dependencies.py │ -├── auth/ +├── core/ # Game-agnostic core │ ├── __init__.py -│ ├── router.py -│ ├── service.py -│ ├── schemas.py -│ └── utils.py +│ │ +│ ├── auth/ +│ │ ├── __init__.py +│ │ ├── router.py +│ │ ├── service.py +│ │ ├── schemas.py +│ │ └── utils.py +│ │ +│ ├── servers/ +│ │ ├── __init__.py +│ │ ├── router.py # Generic routes, delegate to adapter +│ │ ├── service.py # ServerService delegates game work to adapter +│ │ ├── process_manager.py # ProcessManager singleton (game-agnostic) +│ │ └── schemas.py # Generic server schemas +│ │ +│ ├── players/ +│ │ ├── __init__.py +│ │ ├── router.py +│ │ ├── service.py +│ │ └── schemas.py +│ │ +│ ├── logs/ +│ │ ├── __init__.py +│ │ ├── router.py +│ │ └── service.py +│ │ +│ ├── metrics/ +│ │ ├── __init__.py +│ │ ├── router.py +│ │ └── service.py +│ │ +│ ├── bans/ +│ │ ├── __init__.py +│ │ ├── router.py +│ │ └── service.py +│ │ +│ ├── events/ +│ │ ├── __init__.py +│ │ ├── router.py +│ │ └── service.py +│ │ +│ ├── websocket/ +│ │ ├── __init__.py +│ │ ├── router.py +│ │ ├── manager.py +│ │ └── broadcaster.py +│ │ +│ ├── threads/ +│ │ ├── __init__.py +│ │ ├── base_thread.py +│ │ ├── process_monitor.py # Core (game-agnostic) +│ │ ├── log_tail.py # Core, takes adapter LogParser +│ │ ├── metrics_collector.py # Core (game-agnostic) +│ │ ├── remote_admin_poller.py # Core, takes adapter RemoteAdmin +│ │ └── thread_registry.py # Builds threads from adapter capabilities +│ │ +│ ├── games/ +│ │ ├── __init__.py +│ │ └── router.py # /api/games — type discovery, schemas +│ │ +│ ├── system/ +│ │ ├── __init__.py +│ │ └── router.py +│ │ +│ ├── dal/ +│ │ ├── __init__.py +│ │ ├── base_repository.py +│ │ ├── server_repository.py +│ │ ├── config_repository.py # game_configs table +│ │ ├── player_repository.py +│ │ ├── log_repository.py +│ │ ├── metrics_repository.py +│ │ ├── mission_repository.py +│ │ ├── mod_repository.py +│ │ ├── ban_repository.py +│ │ └── event_repository.py +│ │ +│ ├── migrations/ +│ │ ├── runner.py +│ │ └── 001_initial_schema.sql +│ │ +│ └── utils/ +│ ├── __init__.py +│ ├── crypto.py +│ ├── file_utils.py +│ └── port_checker.py │ -├── servers/ -│ ├── __init__.py -│ ├── router.py -│ ├── service.py -│ ├── schemas.py -│ ├── process_manager.py -│ └── config_generator.py -│ -├── rcon/ -│ ├── __init__.py -│ ├── client.py -│ └── service.py -│ -├── missions/ -│ ├── __init__.py -│ ├── router.py -│ ├── service.py -│ └── schemas.py -│ -├── mods/ -│ ├── __init__.py -│ ├── router.py -│ ├── service.py -│ └── schemas.py -│ -├── players/ -│ ├── __init__.py -│ ├── router.py -│ ├── service.py -│ └── schemas.py -│ -├── logs/ -│ ├── __init__.py -│ ├── router.py -│ ├── service.py -│ └── parser.py -│ -├── metrics/ -│ ├── __init__.py -│ ├── router.py -│ └── service.py -│ -├── websocket/ -│ ├── __init__.py -│ ├── router.py -│ ├── manager.py -│ └── broadcaster.py -│ -├── threads/ -│ ├── __init__.py -│ ├── base_thread.py -│ ├── process_monitor.py -│ ├── log_tail.py -│ ├── metrics_collector.py -│ ├── rcon_poller.py -│ └── thread_registry.py -│ -├── system/ -│ ├── __init__.py -│ └── router.py -│ -├── dal/ -│ ├── __init__.py -│ ├── base_repository.py -│ ├── server_repository.py -│ ├── config_repository.py -│ ├── player_repository.py -│ ├── log_repository.py -│ ├── metrics_repository.py -│ ├── mission_repository.py -│ ├── mod_repository.py -│ ├── ban_repository.py -│ └── event_repository.py -│ -├── migrations/ -│ ├── runner.py -│ └── 001_initial_schema.sql -│ -└── utils/ - ├── __init__.py - ├── crypto.py - ├── file_utils.py - └── port_checker.py +└── adapters/ # Game-specific adapters + ├── __init__.py # Auto-registers built-in adapters + entry_points + ├── registry.py # GameAdapterRegistry singleton + ├── protocols.py # All capability Protocol definitions + ├── exceptions.py # Typed adapter exceptions (ConfigWriteError, etc.) + │ + └── arma3/ # Arma 3 adapter (built-in, first-class) + ├── __init__.py # Exports ARMA3_ADAPTER, registers on import + ├── adapter.py # Arma3Adapter class + ├── config_generator.py # Pydantic models + server.cfg, basic.cfg, Arma3Profile, beserver.cfg (merged schema + generation) + ├── rcon_client.py # BERConClient (BattlEye UDP protocol) + ├── rcon_service.py # Wraps BERConClient for RemoteAdmin protocol + ├── log_parser.py # RPTParser + ├── mission_manager.py # PBO upload, mission rotation config + ├── mod_manager.py # @mod_folder convention, -mod=/-serverMod= + ├── process_config.py # Exe allowlist, port conventions, profile dir + ├── ban_manager.py # battleye/ban.txt bidirectional sync + ├── schemas.py # Arma 3 specific request/response models + └── migrations/ + └── 001_arma3_metadata.sql # Arma 3 specific tables (if any) ``` --- @@ -111,22 +130,19 @@ Entry point. Creates and configures the FastAPI application. ```python # Responsibilities: # - Create FastAPI app instance -# - Register all routers with prefix /api +# - Register all core routers with prefix /api +# - Register adapter-provided additional routers # - Configure CORS middleware # - Add JWT auth middleware # - Register startup/shutdown event handlers: -# startup: run DB migrations, init ProcessManager, restore running servers +# startup: run DB migrations, init ProcessManager, auto-register adapters, +# restore running servers # shutdown: gracefully stop all BroadcastThread, close DB -# - Mount static files if serving frontend -# - NOTE: Route handlers that perform blocking I/O (subprocess, file writes, -# socket checks) MUST be declared as plain `def` (not `async def`). -# FastAPI automatically runs plain-def handlers in a thread pool, -# preventing event loop blocking. Only truly async operations -# (WebSocket send, async library calls) should use `async def`. +# - Auto-register built-in adapters (arma3) on import Key functions: create_app() -> FastAPI - on_startup() # DB migrations, recover state + on_startup() # DB migrations, register adapters, recover state on_shutdown() # Clean up threads, close connections ``` @@ -141,7 +157,6 @@ class Settings(BaseSettings): encryption_key: str # Fernet base64 key (NOT hex) db_path: str = "./languard.db" servers_dir: str = "./servers" - arma_exe: str = "C:/Arma3Server/arma3server_x64.exe" host: str = "0.0.0.0" port: int = 8000 cors_origins: list[str] = ["http://localhost:5173"] @@ -151,13 +166,18 @@ class Settings(BaseSettings): jwt_expire_hours: int = 24 login_rate_limit: str = "5/minute" # per IP + # Game-specific defaults (used by adapters, not core) + arma3_default_exe: str = "C:/Arma3Server/arma3server_x64.exe" + settings = Settings() # singleton ``` +**Key change:** Removed `arma_exe` as a core setting. Game-specific paths are now namespaced per adapter. + --- ### `database.py` -Database engine setup and session management. +Database engine setup and session management. **No game-specific logic.** ```python # Responsibilities: @@ -165,13 +185,6 @@ Database engine setup and session management. # - Provide get_db() dependency for FastAPI routes (sync session) # - Provide get_thread_db() for background threads (thread-local sessions) # - run_migrations(): apply pending .sql migration files at startup -# - Migration rollback: if a migration fails, the schema_migrations table -# is NOT updated; re-running applies only unapplied migrations (idempotent) - -# Pragma setup: -# PRAGMA journal_mode=WAL -# PRAGMA foreign_keys=ON -# PRAGMA busy_timeout=5000 # 5s wait before "database is locked" error Key functions: get_engine() -> Engine @@ -183,58 +196,24 @@ Key functions: --- ### `dependencies.py` -Reusable FastAPI dependencies. +Reusable FastAPI dependencies. **No game-specific logic.** ```python -# Responsibilities: -# - get_current_user(token) -> User (JWT validation) -# - require_admin(user) -> User (role check) -# - get_server_or_404(server_id, db) -> ServerRow - Key functions: get_current_user(credentials: HTTPAuthorizationCredentials) -> User require_admin(user: User = Depends(get_current_user)) -> User get_server_or_404(server_id: int, db: Connection) -> dict + get_adapter_for_server(server_id: int, db: Connection) -> GameAdapter + # Convenience: loads server, resolves adapter from game_type ``` --- -### `auth/` +### Core: `servers/` -**`router.py`** — FastAPI router for auth endpoints. -- `POST /auth/login` -- `POST /auth/logout` -- `GET /auth/me` -- `PUT /auth/password` -- `GET /auth/users` (admin) -- `POST /auth/users` (admin) -- `DELETE /auth/users/{user_id}` (admin) - -**`service.py`** — `AuthService` -```python -class AuthService: - def login(username, password) -> TokenResponse - def create_user(username, password, role) -> User - def change_password(user_id, current_pw, new_pw) -> bool - def list_users() -> list[User] - def delete_user(user_id) -> bool -``` - -**`utils.py`** -```python -def hash_password(password: str) -> str # bcrypt -def verify_password(plain, hashed) -> bool -def create_access_token(data: dict) -> str # JWT sign -def decode_access_token(token: str) -> dict # JWT verify -``` - ---- - -### `servers/` - -**`router.py`** — All server CRUD + lifecycle endpoints. -- `GET /servers` -- `POST /servers` +**`router.py`** — Game-agnostic server endpoints. Delegates to adapter for game-specific operations. +- `GET /servers` (supports `?game_type=` filter) +- `POST /servers` (requires `game_type` in body) - `GET /servers/{id}` - `PUT /servers/{id}` - `DELETE /servers/{id}` @@ -243,38 +222,40 @@ def decode_access_token(token: str) -> dict # JWT verify - `POST /servers/{id}/restart` - `POST /servers/{id}/kill` - `GET /servers/{id}/config` -- `PUT /servers/{id}/config/server` -- `PUT /servers/{id}/config/basic` -- `PUT /servers/{id}/config/profile` -- `PUT /servers/{id}/config/launch` -- `PUT /servers/{id}/config/rcon` +- `GET /servers/{id}/config/{section}` +- `PUT /servers/{id}/config/{section}` - `GET /servers/{id}/config/preview` - `GET /servers/{id}/config/download/{filename}` **`service.py`** — `ServerService` ```python class ServerService: - def list_servers() -> list[ServerSummary] + def list_servers(game_type: str | None = None) -> list[ServerSummary] def get_server(server_id) -> ServerDetail def create_server(data: CreateServerRequest) -> Server + # 1. Validate game_type is registered + # 2. Get adapter defaults for config sections + # 3. Create server row + game_configs rows + # 4. Create server directory (layout from adapter.process_config) def update_server(server_id, data) -> Server def delete_server(server_id) -> bool def start(server_id) -> StatusResponse - # 1. Load config from DB - # 2. Validate exe_path exists and basename matches allowlist - # (arma3server_x64.exe, arma3server.exe) — prevents executing arbitrary binaries - # 3. Check ALL derived ports not in use (game_port through game_port+3 + rcon_port) - # 4. ConfigGenerator.write_all(server_id) - # — if write fails: DB status='error', return error (no process launch) - # 5. Build launch args - # 6. ProcessManager.start(server_id, exe, args, cwd=servers/{id}/) - # 7. DB: status = 'starting' - # 8. ThreadRegistry.start_server_threads(server_id) - # 9. Broadcast status update + # 1. Load server from DB (includes game_type) + # 2. adapter = GameAdapterRegistry.get(server.game_type) + # 3. Validate exe against adapter.process_config.get_allowed_executables() + # 4. Check ports via adapter.process_config.get_port_conventions() + # 5. Read config sections from game_configs table + # 6. adapter.config_generator.write_configs(server_id, dir, config) + # 7. launch_args = adapter.config_generator.build_launch_args(config, mods) + # 8. ProcessManager.start(server_id, exe, args, cwd=dir) + # 9. DB: status = 'starting' + # 10. ThreadRegistry.start_server_threads(server_id, db) + # 11. Broadcast status update def stop(server_id, force=False) -> StatusResponse - # 1. If not force: RConService.send_command('#shutdown') + # 1. If not force and adapter has remote_admin: + # adapter.remote_admin.shutdown() # 2. Wait up to 30s for process exit # 3. If still running: ProcessManager.kill(server_id) # 4. DB: status = 'stopped', pid = NULL @@ -284,19 +265,17 @@ class ServerService: def restart(server_id) -> StatusResponse - def update_config_server(server_id, data) -> ServerConfig - def update_config_basic(server_id, data) -> BasicConfig - def update_config_profile(server_id, data) -> ServerProfile - def update_config_launch(server_id, data) -> LaunchParams - def update_config_rcon(server_id, data) -> RConConfig - # Updates rcon_configs row (rcon_password, max_ping, enabled) via ConfigRepository. - # If data includes rcon_port, also updates servers.rcon_port via ServerRepository — - # rcon_port lives in the servers table, not rcon_configs. - # Regenerates battleye/beserver.cfg immediately after saving. - def get_config_preview(server_id) -> str + def get_config(server_id) -> dict # all sections + def get_config_section(server_id, section) -> dict + def update_config_section(server_id, section, data) -> dict + # Validates data against adapter.config_generator.get_sections()[section] + # Encrypts sensitive fields + # Stores in game_configs table + def get_config_preview(server_id) -> dict[str, str] + # Returns {filename: rendered_content} from adapter.config_generator.preview_config() ``` -**`process_manager.py`** — `ProcessManager` singleton +**`process_manager.py`** — `ProcessManager` singleton **(game-agnostic)** ```python class ProcessManager: _instance = None @@ -307,328 +286,67 @@ class ProcessManager: def get() -> ProcessManager def start(server_id, exe_path, args: list[str], cwd: str) -> int # returns PID - # subprocess.Popen([exe_path, *args], cwd=cwd, stdout=PIPE, stderr=STDOUT) - # cwd = servers/{server_id}/ so relative config paths resolve correctly - def stop(server_id, timeout=30) -> bool - # On Windows: subprocess.terminate() = TerminateProcess (hard kill, no SIGTERM) - # Graceful shutdown is handled by ServerService via RCon #shutdown first. - # This method is the forceful fallback: terminate() → wait(timeout) - def kill(server_id) -> bool - # terminate() immediately (hard kill on Windows) - def is_running(server_id) -> bool - def get_pid(server_id) -> int | None - def get_process(server_id) -> subprocess.Popen | None - - def list_running() -> list[int] # list of server_ids - + def list_running() -> list[int] def recover_on_startup(db) - # At app startup: query DB for servers with status='running' - # Check if pid still alive AND verify process name matches arma3server + # Checks PID still alive AND process name matches adapter allowlist # (prevents PID reuse by unrelated processes) - # If alive: re-attach monitoring threads (skip process start) - # If dead or wrong process: mark crashed, clear players -``` - -**`config_generator.py`** — `ConfigGenerator` -```python -class ConfigGenerator: - def write_all(server_id: int, db: Connection) -> None - # Writes server.cfg, basic.cfg, server.Arma3Profile, battleye/beserver.cfg - # Creates directories if they don't exist - # Sets restrictive file permissions on files containing passwords: - # Unix: chmod 0600 - # Windows: use icacls to grant only the service account read/write access - # Raises IOError if write fails — caller must handle (set DB status='error') - - def write_server_cfg(server_id, config: dict, path: Path) -> None - # Uses structured builder — NOT f-strings or string.Template - # Escapes double quotes in all string values (replace " with \"") - # Validates no newline injection in string fields - # Renders mission rotation as class Missions { class Mission1 { ... }; }; - - def write_basic_cfg(server_id, config: dict, path: Path) -> None - - def write_arma3profile(server_id, profile: dict, path: Path) -> None - # Writes to servers/{id}/server/server.Arma3Profile (profile subdirectory) - - def write_beserver_cfg(server_id, rcon_config: dict, path: Path) -> None - # Generates servers/{id}/battleye/beserver.cfg - # Content: "RConPassword \nRConPort \n" - # Without this file BattlEye will not open an RCon port. - - def build_launch_args(server_id, config: dict, launch: dict, mod_string: str) -> list[str] - # Returns list of command-line arguments for arma3server_x64.exe - # e.g. ['-port=2302', '-config=server.cfg', '-cfg=basic.cfg', - # '-profiles=./', '-name=server', '-world=empty', - # '-mod=@CBA;@ACE', '-serverMod=@ACE_server', - # '-bepath=./battleye', - # '-limitFPS=50', '-autoInit', '-loadMissionToMemory', ...] - # NOTE: -profiles is relative to cwd (which is set to servers/{id}/) - # -bepath is required for BattlEye to find beserver.cfg - - def _escape_config_string(value: str) -> str - # Escapes backslashes FIRST, then double quotes and newlines for safe Arma 3 config interpolation. - # Order matters: backslash → \\, then " → \", then newline → \\n - # If backslashes are not escaped first, input "test\\" produces "test\\" - # which Arma 3 reads as an escaped backslash + unescaped closing quote = injection. - value = value.replace('\\', '\\\\') # backslash FIRST - value = value.replace('"', '\\"') # then double-quote - value = value.replace('\n', '\\n') # then newline - value = value.replace('\r', '') # strip carriage returns - value = value.replace('\t', ' ') # tabs → spaces - return value - - def _render_mission_class(rotation: list[dict]) -> str - # Renders the class Missions {} block for server.cfg - # class Missions { class Mission1 { template="..."; difficulty="..."; }; ... }; ``` --- -### `rcon/` +### Core: `players/` -**`client.py`** — `BERConClient` -```python -class BERConClient: - """ - Implements BattlEye RCon protocol over UDP. - Packet type bytes: - Client → Server: 0xFF 0x00 [password] → login - Client → Server: 0xFF 0x01 [seq] [command] → send command - Client → Server: 0xFF 0x02 → keepalive (empty payload) - Server → Client: 0xFF 0x00 [0x00|0x01] → login response (0x01=ok) - Server → Client: 0xFF 0x01 [seq] [response] → command response - Server → Client: 0xFF 0x02 [seq] [message] → unsolicited server message (chat/kill events) - Note: 0x01 is the type byte for BOTH outgoing commands AND incoming command responses. - """ - def __init__(host: str, port: int, password: str) - - # Request multiplexer: prevents response misrouting when - # RConPollerThread and API-request RConService share the same socket. - _pending_requests: dict[int, threading.Event] = {} # seq → Event - _responses: dict[int, str] = {} # seq → response - _seq_counter: int = 0 - _lock: threading.Lock - - def connect() -> bool - def disconnect() - def login() -> bool - def send_command(command: str, timeout: float = 5.0) -> str | None - # Sends command with sequence number, creates Event, waits for response - # Routes response to correct caller by matching sequence byte - def keepalive() # send empty packet every 30s - def is_connected() -> bool - - # Background receiver thread: - def _receiver_loop() - # Reads all incoming UDP packets - # For type 0x01 (command response): sets Event + stores response for matching seq - # For type 0x02 (server message): enqueues for processing (player events, chat) - - def parse_players_response(response: str) -> list[PlayerInfo] - # Parse output of 'players' command - # Format: "Players on server:\n[#] [IP:Port] [Ping] [GUID] [Name]\n..." -``` - -**`service.py`** — `RConService` -```python -class RConService: - def __init__(server_id: int) - - def send_command(command: str) -> str | None - # Gets or creates BERConClient, sends command, returns response - - def kick_player(player_num: int, reason: str = "") -> bool - - def ban_player(player_num: int, duration_minutes: int, reason: str) -> bool - - def unban(guid: str) -> bool - - def say_all(message: str) -> bool - - def get_players() -> list[PlayerInfo] - - def send_mission_command(mission_name: str) -> bool - - def shutdown() -> bool - - def restart() -> bool - - def lock() -> bool - - def unlock() -> bool -``` - ---- - -### `missions/` - -**`service.py`** — `MissionService` -```python -class MissionService: - def list_missions(server_id) -> list[Mission] - - def upload_mission(server_id, filename: str, file_data: bytes) -> Mission - # 1. Validate .pbo extension - # 2. Parse mission_name and terrain from filename - # 3. Write to servers/{server_id}/mpmissions/{filename} - # 4. Insert into missions table - # 5. Return Mission object - - def delete_mission(server_id, mission_id) -> bool - # 1. Check not in active rotation - # 2. Delete file from disk - # 3. Delete from DB - - def get_rotation(server_id) -> list[RotationEntry] - - def update_rotation(server_id, rotation: list[RotationEntry]) -> bool - # 1. Delete existing rotation rows - # 2. Insert new ordered list - # 3. Trigger config regeneration -``` - ---- - -### `mods/` - -**`service.py`** — `ModService` -```python -class ModService: - def list_all() -> list[Mod] - def register_mod(name, folder_path, workshop_id, description) -> Mod - # Validates folder exists - def delete_mod(mod_id) -> bool - # Check not in use by any server - def get_server_mods(server_id) -> list[ServerMod] - def update_server_mods(server_id, mods: list) -> bool - # Replaces server_mods rows, regenerates mod string - def build_mod_string(server_id) -> tuple[str, str] - # Returns (-mod=..., -serverMod=...) strings -``` - ---- - -### `players/` +**`router.py`** — Generic player endpoints. +- `GET /servers/{id}/players` +- `POST /servers/{id}/players/{slot_id}/kick` (requires `remote_admin`) +- `POST /servers/{id}/players/{slot_id}/ban` (requires `remote_admin`) +- `GET /servers/{id}/players/history` **`service.py`** — `PlayerService` ```python class PlayerService: def get_current_players(server_id) -> list[Player] - def kick(server_id, player_num, reason) -> bool - # RConService.kick_player() + log event - def ban(server_id, player_num, duration_minutes, reason) -> bool - # RConService.ban_player() + insert into bans table + def kick(server_id, slot_id, reason) -> bool + # Resolves adapter; if adapter has remote_admin: + # adapter.remote_admin.kick_player(identifier, reason) + def ban(server_id, slot_id, duration_minutes, reason) -> bool def get_history(server_id, limit, offset, search) -> PaginatedResult - def update_from_rcon(server_id, rcon_players: list) -> None - # Upsert players table; detect disconnections; insert player_history rows + def update_from_remote_admin(server_id, players: list) -> None + # Upserts players table; detects disconnections; inserts player_history rows ``` --- -### `logs/` +### Core: `logs/` -**`parser.py`** — `RPTParser` -```python -class RPTParser: - # Parses Arma 3 RPT log format - # Example line: "10:05:23 BattlEye Server: Initialized (v1.240)" - # With timestamp format "short": "10:05:23" - # With timestamp format "full": "2026/04/16, 10:05:23" - - def parse_line(line: str) -> LogEntry | None - # Returns: {timestamp, level, message} - # level detection: 'error' if 'Error' in msg, 'warning' if 'Warning', else 'info' - - def parse_timestamp(raw: str) -> datetime -``` +**`router.py`** — `GET /servers/{id}/logs`, `DELETE /servers/{id}/logs` **`service.py`** — `LogService` ```python class LogService: def query(server_id, limit, offset, level, since, search) -> PaginatedLogs - def clear(server_id) -> int # returns deleted count - def get_rpt_path(server_id) -> Path | None - # Delegates to file_utils.get_rpt_path() — globs for latest timestamped .rpt + def clear(server_id) -> int + def get_log_path(server_id) -> Path | None + # Resolves adapter's log_parser.get_log_file_resolver() def cleanup_old_logs() # called by APScheduler ``` --- -### `metrics/` +### Core: `metrics/`, `bans/`, `events/` -**`service.py`** — `MetricsService` -```python -class MetricsService: - def query(server_id, from_dt, to_dt, resolution) -> list[MetricPoint] - # Aggregates by resolution ('1m', '5m', '1h') - def insert(server_id, cpu, ram, player_count) -> None - def cleanup_old_metrics() # called by APScheduler - def get_latest(server_id) -> MetricPoint | None -``` +Same pattern as above — game-agnostic services using core tables. --- -### `websocket/` +### Core: `threads/` -**`manager.py`** — `ConnectionManager` -```python -class ConnectionManager: - """ - Manages active WebSocket connections grouped by server_id. - 'all' is a special server_id that receives events for all servers. - """ - _connections: dict[str, set[WebSocket]] - _lock: asyncio.Lock - - async def connect(ws: WebSocket, server_id: str, channels: list[str]) - async def disconnect(ws: WebSocket, server_id: str) - async def broadcast(server_id: str, message: dict) - # Sends to all connections subscribed to server_id + 'all' - async def send_personal(ws: WebSocket, message: dict) -``` - -**`broadcaster.py`** — `BroadcastThread` -```python -class BroadcastThread(threading.Thread): - """ - Runs in background thread. - Reads from a queue (put by background threads). - Posts messages to asyncio event loop via run_coroutine_threadsafe(). - """ - _queue: queue.Queue - _loop: asyncio.AbstractEventLoop - _manager: ConnectionManager - _running: bool - - def run() # main loop: get from queue, schedule broadcast coroutine - - @staticmethod - def enqueue(server_id: int, msg_type: str, data: dict) - # Thread-safe: called from any background thread -``` - -**`router.py`** — WebSocket endpoint -```python -@router.websocket("/ws/{server_id}") -async def websocket_endpoint(ws: WebSocket, server_id: str, token: str = Query(...)): - # 1. Validate JWT token from query param - # 2. Accept WebSocket connection - # 3. Register with ConnectionManager - # 4. Loop: receive messages (ping/subscribe/unsubscribe) - # 5. On disconnect: deregister from ConnectionManager -``` - ---- - -### `threads/` - -**`base_thread.py`** — `BaseServerThread` +**`base_thread.py`** — `BaseServerThread` **(game-agnostic)** ```python class BaseServerThread(threading.Thread): def __init__(server_id: int, interval: float) @@ -641,10 +359,9 @@ class BaseServerThread(threading.Thread): def tick() # override for per-interval work (uses self._db) def teardown() # override for cleanup (close files, sockets) def on_error(e: Exception) # default: log, continue - # if same error repeats 5x in a row: escalate + self.stop() ``` -**`process_monitor.py`** — `ProcessMonitorThread` +**`process_monitor.py`** — `ProcessMonitorThread` **(game-agnostic)** ```python class ProcessMonitorThread(BaseServerThread): interval = 1.0 # seconds @@ -657,35 +374,37 @@ class ProcessMonitorThread(BaseServerThread): # c. Clear players from DB # d. Broadcast: {type: 'status', status: 'crashed'} # e. Insert server_events: {event_type: 'crashed', exit_code} - # f. If auto_restart enabled and restart_count < max_restarts: - # DB: increment restart_count - # Schedule restart after 10s (threading.Timer) + # f. If auto_restart: schedule restart with exponential backoff # g. self.stop() ``` -**`log_tail.py`** — `LogTailThread` +**`log_tail.py`** — `LogTailThread` **(core thread + adapter parser)** ```python class LogTailThread(BaseServerThread): interval = 0.1 # 100ms + def __init__(self, server_id: int, log_parser: "LogParser", + log_file_resolver: Callable): + super().__init__(server_id, self.interval) + self._parser = log_parser + self._log_file_resolver = log_file_resolver + def setup(): - # Find .rpt file path + # Find log file using adapter's resolver # Open file, seek to end (tail behavior) - self._file = open(rpt_path, 'r', encoding='utf-8', errors='replace') - self._file.seek(0, 2) # seek to end def tick(): - # 1. Read all new lines from self._file + # 1. Read all new lines from log file # 2. For each line: - # a. RPTParser.parse_line(line) -> LogEntry + # a. self._parser.parse_line(line) -> LogEntry # b. LogRepository.insert(server_id, entry) # c. BroadcastThread.enqueue(server_id, 'log', entry) - def on_rpt_rotate(): - # Close and reopen if file was rotated (new server start) + def teardown(): + # Close the open log file handle ``` -**`metrics_collector.py`** — `MetricsCollectorThread` +**`metrics_collector.py`** — `MetricsCollectorThread` **(game-agnostic)** ```python class MetricsCollectorThread(BaseServerThread): interval = 5.0 # seconds @@ -699,184 +418,555 @@ class MetricsCollectorThread(BaseServerThread): # 6. BroadcastThread.enqueue(server_id, 'metrics', {cpu, ram, player_count}) ``` -**`rcon_poller.py`** — `RConPollerThread` +**`remote_admin_poller.py`** — `RemoteAdminPollerThread` **(core thread + adapter client)** ```python -class RConPollerThread(BaseServerThread): - interval = 10.0 # seconds - startup_delay = 30.0 # wait 30s after server start before first poll - _rcon_ready = False # flag: set True only after successful setup +class RemoteAdminPollerThread(BaseServerThread): + interval = 10.0 + STARTUP_DELAY = 30.0 + + def __init__(self, server_id: int, + remote_admin_factory: Callable[[], "RemoteAdminClient"]): + super().__init__(server_id, self.interval) + self._client_factory = remote_admin_factory + self._client: RemoteAdminClient | None = None def setup(): - # Use _stop_event.wait() instead of time.sleep() so the thread - # can be interrupted immediately during shutdown - if self._stop_event.wait(self.startup_delay): - self._rcon_ready = False - return # stop was requested during startup delay - self._rcon = RConService(self.server_id) - self._rcon_ready = True + # Wait for server startup (using _stop_event.wait instead of sleep) + # Create client via factory def tick(): - if not self._rcon_ready: - return # setup() failed or was interrupted — skip tick - # 1. RConService.get_players() -> list[PlayerInfo] - # 2. PlayerService.update_from_rcon(server_id, players) - # 3. BroadcastThread.enqueue(server_id, 'players', {players, count}) - # 4. RConClient.keepalive() if needed + if not self._client: + return + try: + players = self._client.get_players() + PlayerService(self._db).update_from_remote_admin(self.server_id, players) + BroadcastThread.enqueue(self.server_id, "players", { + "players": [p.dict() for p in players], + "count": len(players), + }) + except ConnectionError: + self._client = None # Will reconnect on next tick ``` -**`thread_registry.py`** — `ThreadRegistry` +**`thread_registry.py`** — `ThreadRegistry` **(adapter-aware)** ```python class ThreadRegistry: - """ - Singleton. Manages all background threads per server. - """ - _threads: dict[int, dict[str, BaseServerThread]] - _lock: threading.Lock + _threads: dict[int, dict[str, BaseServerThread]] = {} + _lock = threading.Lock() @classmethod - def get() -> ThreadRegistry + def start_server_threads(cls, server_id: int, db: Connection) -> None: + server = ServerRepository(db).get_by_id(server_id) + adapter = GameAdapterRegistry.get(server["game_type"]) - def start_server_threads(server_id: int) -> None - # Instantiates and starts: - # ProcessMonitorThread, LogTailThread, - # MetricsCollectorThread, RConPollerThread + threads: dict[str, BaseServerThread] = {} - def stop_server_threads(server_id: int) -> None - # Calls stop() on each thread; joins with timeout + # Core threads (always present) + threads["process_monitor"] = ProcessMonitorThread(server_id) + threads["metrics_collector"] = MetricsCollectorThread(server_id) - def get_thread(server_id, thread_type: str) -> BaseServerThread | None + # Adapter-provided log parser → generic LogTailThread + log_parser = adapter.get_log_parser() + threads["log_tail"] = LogTailThread( + server_id, + parser=log_parser, + log_file_resolver=log_parser.get_log_file_resolver(server_id), + ) - def list_active(server_id) -> list[str] # thread names + # Adapter-provided remote admin → generic RemoteAdminPollerThread + remote_admin = adapter.get_remote_admin() + if remote_admin is not None: + threads["remote_admin_poller"] = RemoteAdminPollerThread( + server_id, + remote_admin_factory=lambda: remote_admin.create_client( + host="127.0.0.1", + port=server["rcon_port"], + password=_get_remote_admin_password(server_id, db), + ), + ) - def stop_all() -> None # on app shutdown + # Adapter-declared custom threads + for thread_factory in adapter.get_custom_thread_factories(): + thread = thread_factory(server_id, db) + threads[thread.name_key] = thread + + with cls._lock: + cls._threads[server_id] = threads + + for thread in threads.values(): + thread.start() ``` --- -### `dal/` +### Core: `games/` -**`base_repository.py`** +**`router.py`** — Game type discovery endpoints. ```python -class BaseRepository: - def __init__(db: Connection) - - def execute(sql: str, params: tuple = ()) -> CursorResult - def fetchone(sql: str, params: tuple = ()) -> dict | None - def fetchall(sql: str, params: tuple = ()) -> list[dict] - def insert(table: str, data: dict) -> int # returns last_insert_rowid - def update(table: str, data: dict, where: str, params: tuple) -> int - def delete(table: str, where: str, params: tuple) -> int - def row_to_dict(row) -> dict +# GET /games → list all registered game types + capabilities +# GET /games/{type} → details for a specific game type +# GET /games/{type}/config-schema → JSON Schema for each config section +# GET /games/{type}/defaults → default config values ``` -**`server_repository.py`** -```python -class ServerRepository(BaseRepository): - def get_all() -> list[dict] - def get_by_id(server_id) -> dict | None - def create(data: dict) -> int - def update_status(server_id, status, pid=None, started_at=None) -> None - def update(server_id, data: dict) -> None - def delete(server_id) -> None - def get_running() -> list[dict] # for startup recovery - def increment_restart_count(server_id) -> None - def reset_restart_count(server_id) -> None -``` +--- -**`event_repository.py`** -```python -class ServerEventRepository(BaseRepository): - def insert(server_id: int, event_type: str, actor: str, detail: dict) -> int - def get_events(server_id: int, limit: int, offset: int, event_type: str | None) -> list[dict] - def get_recent(server_id: int, limit: int = 20) -> list[dict] -``` +### Core: `dal/` -**`config_repository.py`** +**`config_repository.py`** — Manages the `game_configs` table. ```python class ConfigRepository(BaseRepository): - def get_server_config(server_id) -> dict | None - def upsert_server_config(server_id, data: dict) -> None - def get_basic_config(server_id) -> dict | None - def upsert_basic_config(server_id, data: dict) -> None - def get_profile(server_id) -> dict | None - def upsert_profile(server_id, data: dict) -> None - def get_launch_params(server_id) -> dict | None - def upsert_launch_params(server_id, data: dict) -> None - def get_rcon_config(server_id) -> dict | None - def upsert_rcon_config(server_id, data: dict) -> None - def get_full_config(server_id) -> dict # all sections combined + def get_section(server_id: int, section: str) -> dict | None + def get_all_sections(server_id: int) -> dict[str, dict] + def upsert_section(server_id: int, game_type: str, section: str, + config_json: str) -> None + def delete_sections(server_id: int) -> None # cascade on server delete +``` + +All other repositories remain game-agnostic, using core tables. + +--- + +### Adapters: `exceptions.py` + +Typed adapter exceptions that core catches specifically: + +```python +class AdapterError(Exception): + """Base for all adapter errors.""" + pass + +class ConfigWriteError(AdapterError): + """File write failed (disk full, permissions).""" + def __init__(self, path: str, detail: str): ... + +class ConfigValidationError(AdapterError): + """Config values violate adapter constraints.""" + def __init__(self, section: str, errors: list[dict]): ... + +class LaunchArgsError(AdapterError): + """build_launch_args() failed (missing mod, bad path).""" + def __init__(self, detail: str): ... + +class RemoteAdminError(AdapterError): + """Remote admin connection/command failed.""" + def __init__(self, detail: str, recoverable: bool = True): ... + +class ExeNotAllowedError(AdapterError): + """Executable not in adapter's allowlist.""" + def __init__(self, exe: str, allowed: list[str]): ... ``` --- -### `system/` +### Adapters: `protocols.py` + +Defines all capability protocols. See ARCHITECTURE.md for the full protocol definitions. -**`router.py`** — System-level endpoints (no auth required for health check). ```python -# GET /system/status → running_servers, total_servers, uptime, version -# GET /system/health → 200 OK if app is alive (for load balancer / Docker healthcheck) +@runtime_checkable +class ConfigGenerator(Protocol): + """Merged protocol: config schema definition + file generation + launch args. + ConfigSchema and ConfigGenerator were merged because they always co-occur — + no game defines config schema without also generating config files.""" + game_type: str + def get_sections(self) -> dict[str, type[BaseModel]]: ... + def get_defaults(self, section: str) -> dict[str, Any]: ... + def get_sensitive_fields(self, section: str) -> list[str]: + """Return JSON keys that need Fernet encryption for this section. + Core's ConfigRepository handles encrypt/decrypt transparently.""" + ... + def get_config_version(self) -> str: + """Current adapter schema version. Stored in game_configs.schema_version.""" + ... + def write_configs(self, server_id: int, server_dir: Path, + config_sections: dict[str, dict]) -> list[Path]: ... + def build_launch_args(self, config_sections: dict[str, dict], + mod_args: list[str] | None = None) -> list[str]: ... + def preview_config(self, server_id: int, server_dir: Path, + config_sections: dict[str, dict]) -> dict[str, str]: ... -@router.get("/system/status") -async def system_status() -> APIResponse: - # Returns: {version, running_servers, total_servers, uptime_seconds} +@runtime_checkable +class RemoteAdminClient(Protocol): + def send_command(self, command: str, timeout: float = 5.0) -> str | None: ... + def get_players(self) -> list[dict]: ... + def kick_player(self, identifier: str, reason: str = "") -> bool: ... + def ban_player(self, identifier: str, duration_minutes: int, reason: str) -> bool: ... + def say_all(self, message: str) -> bool: ... + def shutdown(self) -> bool: ... + def keepalive(self) -> None: ... + def disconnect(self) -> None: ... -@router.get("/system/health") -async def health_check() -> dict: - # Returns: {"status": "ok"} +@runtime_checkable +class RemoteAdmin(Protocol): + def create_client(self, host: str, port: int, password: str) -> RemoteAdminClient: ... + def get_startup_delay(self) -> float: ... + def get_poll_interval(self) -> float: ... + def get_player_data_schema(self) -> type[BaseModel] | None: + """Pydantic model for players.game_data JSON. Return None for no validation.""" + ... + +# NOTE on thread safety: RemoteAdminClient instances are shared between +# RemoteAdminPollerThread and API request handlers. Core wraps all +# RemoteAdminClient method calls with a per-server threading.Lock to +# ensure thread safety. Adapters do NOT need to implement thread-safe clients. + +@runtime_checkable +class LogParser(Protocol): + def parse_line(self, line: str) -> dict | None: ... + def get_log_file_resolver(self, server_id: int) -> "LogFileResolver": ... + +@runtime_checkable +class MissionManager(Protocol): + file_extension: str + def parse_mission_filename(self, filename: str) -> dict: ... + def get_rotation_config(self, rotation_entries: list[dict]) -> str: ... + def get_missions_dir(self, server_dir: Path) -> Path: ... + def get_mission_data_schema(self) -> type[BaseModel] | None: + """Pydantic model for missions.game_data JSON. Return None for no validation.""" + ... + +@runtime_checkable +class ModManager(Protocol): + def get_mod_folder_pattern(self) -> str: ... + def build_mod_args(self, server_mods: list[dict]) -> list[str]: ... + def validate_mod_folder(self, path: Path) -> bool: ... + def get_mod_data_schema(self) -> type[BaseModel] | None: + """Pydantic model for mods.game_data JSON. Return None for no validation.""" + ... + +@runtime_checkable +class ProcessConfig(Protocol): + def get_allowed_executables(self) -> list[str]: ... + def get_port_conventions(self, game_port: int) -> dict[str, int]: ... + def get_default_game_port(self) -> int: ... + def get_default_rcon_port(self, game_port: int) -> int | None: ... + def get_server_dir_layout(self) -> list[str]: ... + +@runtime_checkable +class BanManager(Protocol): + def get_ban_file_path(self, server_dir: Path) -> Path: ... + def sync_bans_to_file(self, bans: list[dict], ban_file: Path) -> None: ... + def read_bans_from_file(self, ban_file: Path) -> list[dict]: ... + def get_ban_data_schema(self) -> type[BaseModel] | None: + """Pydantic model for bans.game_data JSON. Return None for no validation.""" + ... + +@runtime_checkable +class GameAdapter(Protocol): + game_type: str + display_name: str + version: str + def get_config_generator(self) -> ConfigGenerator: ... + def get_process_config(self) -> ProcessConfig: ... + def get_log_parser(self) -> LogParser: ... + def get_remote_admin(self) -> RemoteAdmin | None: ... + def get_mission_manager(self) -> MissionManager | None: ... + def get_mod_manager(self) -> ModManager | None: ... + def get_ban_manager(self) -> BanManager | None: ... + def get_additional_routers(self) -> list[APIRouter]: ... + def get_custom_thread_factories(self) -> list[Callable]: ... + def has_capability(self, name: str) -> bool: ... ``` --- -### `utils/` +### Adapters: `registry.py` -**`crypto.py`** ```python -# AES-256 field encryption for sensitive values (passwords, RCon pw) -# Uses cryptography.fernet.Fernet +class GameAdapterRegistry: + _adapters: dict[str, GameAdapter] = {} -def encrypt(plaintext: str) -> str -def decrypt(ciphertext: str) -> str -def get_fernet() -> Fernet # from settings.encryption_key + @classmethod + def register(cls, adapter: GameAdapter) -> None: ... + + @classmethod + def get(cls, game_type: str) -> GameAdapter: ... + + @classmethod + def all(cls) -> list[GameAdapter]: ... + + @classmethod + def list_game_types(cls) -> list[dict]: ... ``` -**`file_utils.py`** +--- + +### Adapters: `arma3/` + +**`adapter.py`** — `Arma3Adapter` ```python -def ensure_server_dirs(server_id: int) -> None - # Creates servers/{id}/, servers/{id}/server/ (profile dir), - # servers/{id}/mpmissions/, servers/{id}/battleye/ +class Arma3Adapter: + game_type = "arma3" + display_name = "Arma 3" + version = "1.0.0" + + def get_config_generator(self) -> ConfigGenerator: + return Arma3ConfigGenerator() # includes schema + generation + + def get_process_config(self) -> ProcessConfig: + return Arma3ProcessConfig() + + def get_log_parser(self) -> LogParser: + return RPTParser() + + def get_remote_admin(self) -> RemoteAdmin | None: + return Arma3RConService() + + def get_mission_manager(self) -> MissionManager | None: + return Arma3MissionManager() + + def get_mod_manager(self) -> ModManager | None: + return Arma3ModManager() + + def get_ban_manager(self) -> BanManager | None: + return Arma3BanManager() + + def has_capability(self, name: str) -> bool: + """Explicit capability probe — core uses this instead of scattered None checks.""" + return name in ( + "config_generator", "process_config", + "log_parser", "remote_admin", "mission_manager", + "mod_manager", "ban_manager", + ) + + def get_additional_routers(self): + return [] # Arma 3 has no extra routes beyond generic set + + def get_custom_thread_factories(self): + return [] + +ARMA3_ADAPTER = Arma3Adapter() +``` + +**`config_generator.py`** — `Arma3ConfigGenerator` (merged schema + generation) +```python +# --- Pydantic models (formerly in config_schema.py) --- +class ServerConfig(BaseModel): + hostname: str = "My Arma 3 Server" + password: str | None = None + password_admin: str # must be set on creation + server_command_password: str | None = None + max_players: int = Field(default=40, gt=0) + # ... all server.cfg parameters ... + +class BasicConfig(BaseModel): + min_bandwidth: int = Field(default=800000, gt=0) + max_bandwidth: int = Field(default=25000000, gt=0) + # ... all basic.cfg parameters ... + +class ProfileConfig(BaseModel): + reduced_damage: int = Field(default=0, ge=0, le=1) + third_person_view: int = Field(default=0, ge=0, le=1) + # ... all Arma3Profile parameters ... + +class LaunchConfig(BaseModel): + world: str = "empty" + limit_fps: int = Field(default=50, gt=0) + # ... all launch parameters ... + +class RConConfig(BaseModel): + rcon_password: str + max_ping: int = Field(default=200, gt=0) + enabled: int = Field(default=1, ge=0, le=1) + +# --- ConfigGenerator implementation (schema + generation in one class) --- +class Arma3ConfigGenerator: + game_type = "arma3" + + # Schema methods (formerly Arma3ConfigSchema) + def get_sections(self) -> dict[str, type[BaseModel]]: + return { + "server": ServerConfig, + "basic": BasicConfig, + "profile": ProfileConfig, + "launch": LaunchConfig, + "rcon": RConConfig, + } + def get_defaults(self, section: str) -> dict: ... + def get_sensitive_fields(self, section: str) -> list[str]: + return { + "server": ["password", "password_admin", "server_command_password"], + "rcon": ["rcon_password"], + }.get(section, []) + def get_config_version(self) -> str: + return "1.0.0" + + # Generation methods + def write_configs(self, server_id, server_dir, config_sections) -> list[Path]: + # Writes server.cfg, basic.cfg, server.Arma3Profile, beserver.cfg + # Creates directories if they don't exist + # Sets restrictive file permissions on files containing passwords + # Uses structured builder — NOT f-strings — prevents config injection + # ATOMIC: writes to .tmp files first, then os.replace() + + def write_server_cfg(server_id, config, path): ... + def write_basic_cfg(server_id, config, path): ... + def write_arma3profile(server_id, profile, path): ... + # Writes to servers/{id}/server/server.Arma3Profile + def write_beserver_cfg(server_id, rcon_config, path): ... + # Generates servers/{id}/battleye/beserver.cfg + + def build_launch_args(self, config_sections, mod_args=None) -> list[str]: + # Returns CLI args for arma3server_x64.exe + # e.g. ['-port=2302', '-config=server.cfg', '-cfg=basic.cfg', + # '-profiles=./', '-name=server', '-world=empty', + # '-mod=@CBA;@ACE', '-serverMod=@ACE_server', + # '-bepath=./battleye', '-limitFPS=50', ...] + + def preview_config(self, server_id, server_dir, config_sections) -> dict[str, str]: + # Returns {filename: rendered_content} without writing to disk + + def _escape_config_string(value: str) -> str: + # Escapes backslashes FIRST, then double quotes and newlines + # Order matters: \\ → \\\\, then " → \\", then \n → \\n +``` + +**`rcon_client.py`** — `BERConClient` +```python +class BERConClient: + """Implements BattlEye RCon protocol over UDP.""" + def __init__(host, port, password): ... + _pending_requests: dict[int, threading.Event] = {} + _responses: dict[int, str] = {} + + def connect() -> bool + def disconnect() + def login() -> bool + def send_command(command, timeout=5.0) -> str | None + def keepalive() + def is_connected() -> bool + + def _receiver_loop() # background thread + def parse_players_response(response) -> list[dict] +``` + +**`rcon_service.py`** — `Arma3RConService` (implements `RemoteAdmin` protocol) +```python +class Arma3RConService: + def create_client(self, host, port, password) -> RemoteAdminClient: + client = BERConClient(host, port, password) + client.connect() + return client + + def get_startup_delay(self) -> float: return 30.0 + def get_poll_interval(self) -> float: return 10.0 +``` + +**`log_parser.py`** — `RPTParser` +```python +class RPTParser: + def parse_line(self, line: str) -> dict | None: + # Returns: {timestamp, level, message} + # level detection: 'error' if 'Error', 'warning' if 'Warning', else 'info' + + def parse_timestamp(raw: str) -> datetime + + def get_log_file_resolver(self, server_id: int) -> LogFileResolver: + # Returns a callable that finds the latest .rpt file + # Arma 3 writes: servers/{id}/server/arma3server_YYYY-MM-DD_HH-MM-SS.rpt +``` + +**`process_config.py`** — `Arma3ProcessConfig` +```python +class Arma3ProcessConfig: + def get_allowed_executables(self) -> list[str]: + return ["arma3server_x64.exe", "arma3server.exe"] + + def get_port_conventions(self, game_port: int) -> dict[str, int]: + return { + "game": game_port, + "steam_query": game_port + 1, + "von": game_port + 2, + "steam_auth": game_port + 3, + } + # rcon_port is separate (user-configurable, defaults to game_port+4) + + def get_default_game_port(self) -> int: return 2302 + + def get_default_rcon_port(self, game_port: int) -> int | None: + return game_port + 4 + + def get_server_dir_layout(self) -> list[str]: + return ["server", "battleye", "mpmissions"] +``` + +**`mission_manager.py`** — `Arma3MissionManager` +```python +class Arma3MissionManager: + file_extension = ".pbo" + + def parse_mission_filename(self, filename: str) -> dict: + # Extract mission_name and terrain from PBO filename + # e.g. "MyMission.Altis.pbo" → {mission_name: "MyMission.Altis", terrain: "Altis"} + + def get_rotation_config(self, rotation_entries) -> str: + # Renders class Missions {} block for server.cfg + # class Missions { class Mission1 { template="..."; difficulty="..."; }; }; + + def get_missions_dir(self, server_dir: Path) -> Path: + return server_dir / "mpmissions" +``` + +**`mod_manager.py`** — `Arma3ModManager` +```python +class Arma3ModManager: + def get_mod_folder_pattern(self) -> str: return "@*" + + def build_mod_args(self, server_mods: list[dict]) -> list[str]: + # Build -mod= and -serverMod= CLI args + # e.g. ["-mod=@CBA;@ACE", "-serverMod=@ACE_server"] + + def validate_mod_folder(self, path: Path) -> bool: + return path.name.startswith("@") +``` + +**`ban_manager.py`** — `Arma3BanManager` +```python +class Arma3BanManager: + def get_ban_file_path(self, server_dir: Path) -> Path: + return server_dir / "battleye" / "ban.txt" + + def sync_bans_to_file(self, bans: list[dict], ban_file: Path) -> None: + # Write active bans to battleye/ban.txt + # Format: GUID|IP timestamp reason + + def read_bans_from_file(self, ban_file: Path) -> list[dict]: + # Read battleye/ban.txt and parse entries +``` + +--- + +### Core: `utils/` + +**`file_utils.py`** — Game-agnostic file operations. +```python +def ensure_server_dirs(server_id: int, layout: list[str] | None = None) -> None + # Creates servers/{id}/ plus subdirectories from adapter.get_process_config().get_server_dir_layout() def get_server_dir(server_id: int) -> Path -def get_profile_dir(server_id: int) -> Path - # Returns servers/{id}/server/ — Arma 3 profile dir (matches -name=server) -def get_missions_dir(server_id: int) -> Path -def get_rpt_path(server_id: int) -> Path | None - # Arma 3 creates timestamped RPT files in the profile dir: - # servers/{id}/server/arma3server_YYYY-MM-DD_HH-MM-SS.rpt - # Uses rglob('*.rpt') to search recursively within profile dir. - # Returns the most-recently-modified one. - # Returns None if no .rpt file exists yet (server still starting up). def safe_delete_file(path: Path) -> bool def sanitize_filename(filename: str) -> str - # Returns Path(filename).name — prevents path traversal on both Unix and Windows - # os.path.basename() on Windows does NOT strip forward slashes; - # Path.name handles both separators correctly. ``` -**`port_checker.py`** +**`port_checker.py`** — Game-agnostic port checking. ```python def is_port_in_use(port: int, host: str = "0.0.0.0") -> bool - # socket.connect check -def check_server_ports_available(game_port: int, rcon_port: int | None = None, host: str = "0.0.0.0") -> list[int] - # Checks ALL ports: game_port, game_port+1 (Steam query), - # game_port+2 (VON), game_port+3 (Steam auth), - # plus the actual rcon_port (user-configurable, defaults to game_port+4) - # If rcon_port is None, defaults to game_port+4 - # If rcon_port is None, defaults to game_port+4 +def check_server_ports_available(game_port: int, rcon_port: int | None = None, + host: str = "0.0.0.0", + port_conventions: dict[str, int] | None = None) -> list[int] + # If port_conventions provided (from adapter), checks all derived ports # Returns list of ports that are in use (empty = all available) def find_available_port(start: int = 2302, step: int = 100) -> int - # Find next available game port (checking all 5 derived ports per candidate) +``` + +**`crypto.py`** — Game-agnostic encryption. +```python +def encrypt(plaintext: str) -> str +def decrypt(ciphertext: str) -> str +def get_fernet() -> Fernet ``` --- @@ -893,8 +983,7 @@ python-jose[cryptography]==3.3.0 # JWT passlib[bcrypt]==1.7.4 # password hashing cryptography==42.0.5 # field-level encryption (Fernet) psutil==5.9.8 # process metrics -apscheduler==3.10.4 # scheduled jobs (log/metrics/player_history cleanup) +apscheduler==3.10.4 # scheduled jobs python-multipart==0.0.9 # file upload support slowapi==0.1.9 # rate limiting middleware -uvloop==0.19.0; sys_platform != "win32" # faster event loop (Linux/macOS only — skip on Windows) -``` +``` \ No newline at end of file diff --git a/THREADING.md b/THREADING.md index 4ead46d..850a0ac 100644 --- a/THREADING.md +++ b/THREADING.md @@ -8,6 +8,8 @@ The system uses a hybrid concurrency model: - **Queue** bridges the thread world → asyncio world for WebSocket broadcasting - **SQLAlchemy sync sessions** are used in threads (thread-local connections) +The key change for multi-game support: **core threads are game-agnostic** and receive game-specific behavior (log parsers, remote admin clients) via dependency injection from the adapter. + --- ## Thread Map @@ -16,7 +18,7 @@ The system uses a hybrid concurrency model: Main Process (FastAPI / asyncio event loop) │ ├── [uvicorn] HTTP/WS event loop (asyncio) -│ ├── REST request handlers (async def) +│ ├── REST request handlers (async def / plain def) │ └── WebSocket handlers (async def) │ ├── BroadcastThread (daemon thread, 1 global) @@ -25,14 +27,66 @@ Main Process (FastAPI / asyncio event loop) │ → ConnectionManager.broadcast() │ └── Per-running-server thread group (started when server starts, stopped when server stops): - ├── ProcessMonitorThread (1 per server, 1s interval) - ├── LogTailThread (1 per server, 100ms interval) - ├── MetricsCollectorThread (1 per server, 5s interval) - └── RConPollerThread (1 per server, 10s interval, 30s startup delay) + ├── ProcessMonitorThread (1 per server, 1s interval) — CORE + ├── LogTailThread (1 per server, 100ms interval) — CORE + adapter LogParser + ├── MetricsCollectorThread (1 per server, 5s interval) — CORE + └── RemoteAdminPollerThread (1 per server, 10s interval) — CORE + adapter RemoteAdmin ``` For **N running servers**, there are: - `4*N` background threads + 1 BroadcastThread = `4N+1` background threads total +- (If adapter has no `remote_admin`, RemoteAdminPollerThread is skipped → `3*N+1`) + +--- + +## Adapter Injection into Threads + +The `ThreadRegistry` resolves the adapter at thread creation time and injects game-specific components into the generic core threads: + +```python +class ThreadRegistry: + @classmethod + def start_server_threads(cls, server_id: int, db: Connection) -> None: + server = ServerRepository(db).get_by_id(server_id) + adapter = GameAdapterRegistry.get(server["game_type"]) + + threads: dict[str, BaseServerThread] = {} + + # Core threads — always present + threads["process_monitor"] = ProcessMonitorThread(server_id) + threads["metrics_collector"] = MetricsCollectorThread(server_id) + + # Core thread with adapter's log parser injected + log_parser = adapter.get_log_parser() + threads["log_tail"] = LogTailThread( + server_id, + parser=log_parser, + log_file_resolver=log_parser.get_log_file_resolver(server_id), + ) + + # Core thread with adapter's remote admin injected (if supported) + remote_admin = adapter.get_remote_admin() + if remote_admin is not None: + threads["remote_admin_poller"] = RemoteAdminPollerThread( + server_id, + remote_admin_factory=lambda: remote_admin.create_client( + host="127.0.0.1", + port=server["rcon_port"], + password=_get_remote_admin_password(server_id, db), + ), + ) + + # Adapter-declared custom threads (for game-specific background work) + for thread_factory in adapter.get_custom_thread_factories(): + thread = thread_factory(server_id, db) + threads[thread.name_key] = thread + + with cls._lock: + cls._threads[server_id] = threads + + for thread in threads.values(): + thread.start() +``` --- @@ -46,6 +100,37 @@ For **N running servers**, there are: | `ConnectionManager._connections` | async, single event loop | `asyncio.Lock` | | SQLite connections | one connection per thread | Thread-local via `threading.local()` | | Config files on disk | write on start, read-only during run | No lock needed (regenerated before start) | +| Adapter objects | read-only after registration | No lock needed (registered once at startup) | +| RemoteAdminClient calls | called from RemoteAdminPollerThread only | **Core wraps with per-server `threading.Lock`** (see below) | + +### RemoteAdminClient Thread Safety + +Adapters do NOT need to make their `RemoteAdminClient` implementations thread-safe. The core wraps every RemoteAdminClient call with a **per-server `threading.Lock`** so only one call executes at a time against a given server's admin client. + +```python +# In RemoteAdminPollerThread +class RemoteAdminPollerThread(BaseServerThread): + def __init__(self, server_id: int, + remote_admin_factory: Callable[[], "RemoteAdminClient"]): + super().__init__(server_id, self.interval) + self._client_factory = remote_admin_factory + self._client: RemoteAdminClient | None = None + self._connected = False + self._call_lock = threading.Lock() # per-server lock + + def _call(self, method, *args, **kwargs): + """All RemoteAdminClient calls go through this to serialize access.""" + with self._call_lock: + return method(*args, **kwargs) + + # In tick(), replace direct self._client.get_players() with: + # players = self._call(self._client.get_players) +``` + +This means: +- Adapter authors write simple, non-thread-safe clients +- Core guarantees no concurrent calls to the same client +- Different servers' clients can call concurrently (different locks) ### SQLite Thread Safety ```python @@ -55,7 +140,6 @@ For **N running servers**, there are: class BaseServerThread(threading.Thread): def run(self): - # Create thread-local DB connection — single connection per thread engine = get_engine() self._db = engine.connect() try: @@ -69,46 +153,49 @@ class BaseServerThread(threading.Thread): except Exception as e: logger.error(f"{self.name} setup error: {e}") finally: - self.teardown() # always release resources (even on setup failure) - self._db.close() # always close connection + self.teardown() + self._db.close() ``` --- ## BroadcastThread — Asyncio Bridge -This is the critical bridge between background threads and the asyncio WebSocket layer. +This is the critical bridge between background threads and the asyncio WebSocket layer. **Game-agnostic.** ``` Background Thread Asyncio Event Loop ───────────────── ────────────────── -BroadcastThread.enqueue( uvicorn runs here - server_id=1, +Any background thread uvicorn runs here + │ + ▼ +BroadcastThread.enqueue( loop = asyncio.get_running_loop() + server_id=1, (stored at app startup) msg_type='log', data={...} ) │ ▼ -broadcast_queue.put({ loop = asyncio.get_event_loop() - 'server_id': 1, (stored at app startup) - 'type': 'log', - 'data': {...} -}) - │ - ▼ -BroadcastThread.run() ──────────────────► asyncio.run_coroutine_threadsafe( - while True: connection_manager.broadcast( - msg = queue.get() server_id=1, - fut = run_coroutine_threadsafe( message={type, data} - broadcast_coro, ), - self._loop loop=self._loop - ) ) +broadcast_queue.put({ asyncio.run_coroutine_threadsafe( + 'server_id': 1, connection_manager.broadcast( + 'type': 'log', server_id=1, + 'data': {...} message={type, data} +) ), + │ loop=self._loop + ▼ ) +BroadcastThread.run() ──────────────────► + while True: + msg = queue.get() + fut = run_coroutine_threadsafe( + broadcast_coro, + self._loop + ) fut.result(timeout=5) ``` ### Implementation Sketch ```python -# broadcaster.py +# core/websocket/broadcaster.py import asyncio import queue import threading @@ -130,9 +217,6 @@ class BroadcastThread(threading.Thread): try: msg = _broadcast_queue.get(timeout=1.0) server_id = msg['server_id'] - # Build the outgoing WebSocket message envelope. - # Include server_id so clients subscribed to 'all' can identify the source. - # API contract: {type, server_id, data} outgoing = { 'type': msg['type'], 'server_id': server_id, @@ -145,7 +229,6 @@ class BroadcastThread(threading.Thread): try: future.result(timeout=5.0) except TimeoutError: - # Don't block the queue — log and continue logger.warning(f"Broadcast timeout for server {server_id} msg type {msg['type']}") except queue.Empty: continue @@ -172,6 +255,8 @@ class BroadcastThread(threading.Thread): ## ProcessMonitorThread — Crash Detection & Auto-Restart +**Game-agnostic.** This thread only checks OS-level process status and updates the core `servers` table. + ```python class ProcessMonitorThread(BaseServerThread): interval = 1.0 @@ -184,7 +269,6 @@ class ProcessMonitorThread(BaseServerThread): exit_code = proc.poll() if exit_code is not None: - # Process has exited self._handle_process_exit(exit_code) self.stop() @@ -210,18 +294,13 @@ class ProcessMonitorThread(BaseServerThread): 'detail': {'exit_code': exit_code} }) - # Stop other threads for this server. Must NOT be called synchronously - # from within this thread's own run() if stop_server_threads() joins threads, - # as a thread cannot join itself. Use a daemon thread to do the cleanup - # after this thread's run() returns naturally. - # IMPORTANT: The auto-restart Timer must be started AFTER thread cleanup - # completes. The cleanup daemon thread starts the restart timer when done. + # Stop other threads for this server via daemon cleanup thread + # (avoids thread joining itself) import threading as _threading def _cleanup_and_maybe_restart(): try: ThreadRegistry.get().stop_server_threads(self.server_id) - # Only schedule restart after threads are fully cleaned up if is_crash and server.get('auto_restart'): self._schedule_auto_restart(server) except Exception as e: @@ -238,10 +317,8 @@ class ProcessMonitorThread(BaseServerThread): ).start() def _schedule_auto_restart(self, server: dict): - # IMPORTANT: This method runs in the daemon cleanup thread, NOT the - # ProcessMonitorThread. Must create its own DB connection — do NOT - # use self._db (it belongs to the ProcessMonitorThread's thread context - # and may be closed by teardown() already). + # IMPORTANT: Runs in daemon cleanup thread, NOT ProcessMonitorThread. + # Must create its own DB connection. from database import get_thread_db db = get_thread_db() @@ -250,7 +327,6 @@ class ProcessMonitorThread(BaseServerThread): window = server['restart_window_seconds'] last_restart = server.get('last_restart_at') - # Reset restart_count if last restart was outside the window if last_restart: last_dt = datetime.fromisoformat(last_restart) elapsed = (datetime.utcnow() - last_dt).total_seconds() @@ -270,7 +346,7 @@ class ProcessMonitorThread(BaseServerThread): }) def _auto_restart(self): - from servers.service import ServerService + from core.servers.service import ServerService try: ServerService().start(self.server_id) except Exception as e: @@ -279,63 +355,61 @@ class ProcessMonitorThread(BaseServerThread): --- -## LogTailThread — RPT File Tailing +## LogTailThread — Generic File Tailing with Adapter Parser -The Arma 3 RPT file grows while the server runs. This thread tails it like `tail -f`. +**Core thread** that takes an adapter-provided `LogParser` for game-specific log line parsing and file discovery. ```python class LogTailThread(BaseServerThread): interval = 0.1 # 100ms - def setup(self): - self._file = None + def __init__(self, server_id: int, log_parser: "LogParser", + log_file_resolver: Callable[[Path], Path | None]): + super().__init__(server_id, self.interval) + self._parser = log_parser + self._log_file_resolver = log_file_resolver + self._file: TextIO | None = None self._current_path: Path | None = None self._last_size: int = 0 - self._open_latest_rpt() - def _open_latest_rpt(self): + def setup(self): + self._open_latest_log() + + def _open_latest_log(self): """ - Arma 3 writes timestamped RPT files in the profile subdirectory: - servers/{id}/server/arma3server_YYYY-MM-DD_HH-MM-SS.rpt - - Use rglob('*.rpt') to search recursively within the server dir. - The profile subdirectory is determined by -profiles + -name flags. + Uses the adapter-provided log_file_resolver to find the current log file. + Opens it and seeks to end (tail behavior). NOTE: Do NOT use os.stat().st_ino for rotation detection — on Windows/NTFS - st_ino is always 0, making inode comparison completely non-functional. - Instead, track the filename and file size. If a newer .rpt appears or the - current file shrinks (truncated/replaced), reopen. + st_ino is always 0. Instead, track filename and file size. """ - rpt_files = list(Path(get_server_dir(self.server_id)).rglob("*.rpt")) - if not rpt_files: - return # Server hasn't created RPT yet; retry in next tick + server_dir = get_server_dir(self.server_id) + log_path = self._log_file_resolver(server_dir) + if log_path is None: + return # Server hasn't created log yet; retry in next tick - latest = max(rpt_files, key=lambda p: p.stat().st_mtime) try: - self._file = open(latest, 'r', encoding='utf-8', errors='replace') - self._file.seek(0, 2) # seek to end — tail, don't replay old output - self._current_path = latest + self._file = open(log_path, 'r', encoding='utf-8', errors='replace') + self._file.seek(0, 2) # seek to end + self._current_path = log_path self._last_size = self._file.tell() except OSError: self._file = None def tick(self): if self._file is None: - self._open_latest_rpt() + self._open_latest_log() return - # Rotation detection: only re-glob every 5 seconds (not every 100ms tick) - # to avoid excessive filesystem I/O with large mpmissions directories. + # Rotation detection: only re-check every 5 seconds now = time.monotonic() if now - getattr(self, '_last_glob_time', 0) > 5.0: self._last_glob_time = now - rpt_files = list(Path(get_server_dir(self.server_id)).rglob("*.rpt")) - if rpt_files: - latest = max(rpt_files, key=lambda p: p.stat().st_mtime) - if latest != self._current_path: - # A new RPT file was created — switch to it + server_dir = get_server_dir(self.server_id) + log_path = self._log_file_resolver(server_dir) + if log_path is not None and log_path != self._current_path: self._file.close() - self._open_latest_rpt() + self._open_latest_log() return try: @@ -344,12 +418,11 @@ class LogTailThread(BaseServerThread): return if current_size < self._last_size: - # File shrank — truncated or replaced; reopen self._file.close() - self._open_latest_rpt() + self._open_latest_log() return - # Read new lines + # Read new lines and parse using adapter's parser while True: line = self._file.readline() if not line: @@ -359,13 +432,13 @@ class LogTailThread(BaseServerThread): if not line: continue - entry = RPTParser.parse_line(line) + # Adapter parses the line — game-specific format + entry = self._parser.parse_line(line) if entry: LogRepository(self._db).insert(self.server_id, entry) BroadcastThread.enqueue(self.server_id, 'log', entry) def teardown(self): - """Close the open RPT file handle when the thread stops.""" if self._file is not None: try: self._file.close() @@ -376,48 +449,108 @@ class LogTailThread(BaseServerThread): --- -## RConPollerThread — Player List Synchronization +## MetricsCollectorThread — Game-Agnostic Resource Monitoring + +**Fully game-agnostic.** Uses psutil to monitor any process. ```python -class RConPollerThread(BaseServerThread): - interval = 10.0 - STARTUP_DELAY = 30.0 # wait for server to fully initialize - _rcon_ready = False # flag: True only after successful setup - - def setup(self): - # Wait for server to start up before attempting RCon - if self._stop_event.wait(self.STARTUP_DELAY): - self._rcon_ready = False - return # stop was requested during wait - self._rcon = RConService(self.server_id) - self._connected = self._rcon.connect() - self._rcon_ready = True +class MetricsCollectorThread(BaseServerThread): + interval = 5.0 + + def tick(self): + pid = ProcessManager.get().get_pid(self.server_id) + if pid is None: + return + + try: + proc = psutil.Process(pid) + cpu = proc.cpu_percent(interval=0.5) + ram = proc.memory_info().rss / (1024 * 1024) # MB + except (psutil.NoSuchProcess, psutil.AccessDenied): + return + + player_count = PlayerRepository(self._db).count(self.server_id) + + MetricsRepository(self._db).insert(self.server_id, cpu, ram, player_count) + BroadcastThread.enqueue(self.server_id, 'metrics', { + 'cpu_percent': cpu, + 'ram_mb': ram, + 'player_count': player_count, + }) +``` + +--- + +## RemoteAdminPollerThread — Generic Polling with Adapter Client + +**Core thread** that takes an adapter-provided `RemoteAdmin` factory for game-specific admin protocol communication. Skipped entirely if adapter has no `remote_admin` capability. + +```python +class RemoteAdminPollerThread(BaseServerThread): + interval = 10.0 + STARTUP_DELAY = 30.0 + + def __init__(self, server_id: int, + remote_admin_factory: Callable[[], "RemoteAdminClient"]): + super().__init__(server_id, self.interval) + self._client_factory = remote_admin_factory + self._client: RemoteAdminClient | None = None + self._connected = False + + def setup(self): + # Wait for server to start up before attempting connection + # Uses _stop_event.wait() instead of time.sleep() for immediate shutdown + startup_delay = self._get_startup_delay() + if self._stop_event.wait(startup_delay): + return # stop was requested during wait + self._connect() + + def _get_startup_delay(self) -> float: + # Default delay; adapter may override via RemoteAdmin.get_startup_delay() + return self.STARTUP_DELAY + + def _connect(self): + try: + self._client = self._client_factory() + self._connected = True + except Exception as e: + logger.warning(f"Remote admin connection failed for server {self.server_id}: {e}") + self._connected = False def tick(self): - if not self._rcon_ready: - return # setup() failed or was interrupted if not self._connected: self._reconnect_attempts = getattr(self, '_reconnect_attempts', 0) + 1 delay = min(10 * 2 ** self._reconnect_attempts, 120) # exponential backoff if self._reconnect_attempts > 1: - logger.info(f"RCon reconnect attempt {self._reconnect_attempts} for server {self.server_id} (next in {delay}s)") + logger.info(f"Remote admin reconnect attempt {self._reconnect_attempts} for server {self.server_id}") if self._stop_event.wait(delay): return - self._connected = self._rcon.connect() + self._connect() if not self._connected: return - self._reconnect_attempts = 0 # reset on successful connection + self._reconnect_attempts = 0 try: - players = self._rcon.get_players() - PlayerService(self._db).update_from_rcon(self.server_id, players) + players = self._call(self._client.get_players) + PlayerService(self._db).update_from_remote_admin(self.server_id, players) BroadcastThread.enqueue(self.server_id, 'players', { - 'players': [p.dict() for p in players], - 'count': len(players) + 'players': [p for p in players], + 'count': len(players), }) except ConnectionError: self._connected = False - logger.warning(f"RCon connection lost for server {self.server_id}") + logger.warning(f"Remote admin connection lost for server {self.server_id}") + except RemoteAdminError as e: + logger.error(f"Remote admin adapter error for server {self.server_id}: {e}") + self._connected = False + + def teardown(self): + if self._client is not None: + try: + self._client.disconnect() + except Exception: + pass + self._client = None ``` --- @@ -429,29 +562,45 @@ class RConPollerThread(BaseServerThread): POST /servers/{id}/start │ ├── ServerService.start() - │ ├── ConfigGenerator.write_all() + │ ├── adapter = GameAdapterRegistry.get(server.game_type) + │ ├── check_server_ports_available(server_id) + │ │ └── For ALL running servers, resolve each adapter, + │ │ get port conventions, check full derived port set + │ │ (cross-game: Arma 3 game+steam query + other games' ports) + │ ├── adapter.config_generator.write_configs() + │ │ └── Atomic write: write to .tmp files first, then os.replace() + │ │ On failure: .tmp files cleaned up, originals untouched + │ ├── launch_args = adapter.config_generator.build_launch_args() │ ├── ProcessManager.start() ← creates subprocess.Popen - │ └── ThreadRegistry.start_server_threads(id) - │ ├── ProcessMonitorThread(id).start() - │ ├── LogTailThread(id).start() - │ ├── MetricsCollectorThread(id).start() - │ └── RConPollerThread(id).start() + │ └── ThreadRegistry.start_server_threads(id, db) + │ ├── ProcessMonitorThread(id) ← core, always + │ ├── LogTailThread(id, adapter.log_parser) ← core + adapter + │ ├── MetricsCollectorThread(id) ← core, always + │ └── RemoteAdminPollerThread(id, adapter.remote_admin) + │ ← core + adapter (if available) │ └── BroadcastThread.enqueue(id, 'status', {status: 'starting'}) + +Error paths on start: + ├── ConfigWriteError → rollback .tmp files, return 500 to client + ├── ConfigValidationError → return 422 with validation details + ├── LaunchArgsError → return 400 with invalid arg info + ├── ExeNotAllowedError → return 403 with executable name + └── PortInUseError → return 409 with conflicting port info ``` ### Stop Server Flow ``` POST /servers/{id}/stop │ - ├── RConService.shutdown() ← sends #shutdown via RCon + ├── adapter.remote_admin.shutdown() ← if adapter has remote_admin ├── Wait up to 30s for process exit (ProcessManager.stop(timeout=30)) ├── If still running: ProcessManager.kill() ├── ThreadRegistry.stop_server_threads(id) - │ ├── ProcessMonitorThread.stop() (sets _stop_event) + │ ├── ProcessMonitorThread.stop() │ ├── LogTailThread.stop() │ ├── MetricsCollectorThread.stop() - │ └── RConPollerThread.stop() + │ └── RemoteAdminPollerThread.stop() ← if present │ └── Thread.join(timeout=5) for each │ └── BroadcastThread.enqueue(id, 'status', {status: 'stopped'}) @@ -496,7 +645,7 @@ class BaseServerThread(threading.Thread): self.setup() except Exception as e: logger.error(f"{self.name} setup error: {e}") - return # setup failed completely — no partial resources to clean + return # setup failed completely try: while not self._stop_event.is_set(): @@ -504,24 +653,34 @@ class BaseServerThread(threading.Thread): self.tick() except Exception as e: self.on_error(e) - # Use wait() instead of sleep() — responds immediately to stop() self._stop_event.wait(self.interval) finally: - self.teardown() # always runs; subclasses close files/sockets here + self.teardown() + + def on_error(self, error: Exception): + """Default error handler. Adapter exceptions are typed for specific handling.""" + if isinstance(error, RemoteAdminError): + logger.error(f"{self.name} remote admin error: {error}") + # RemoteAdminPollerThread overrides to set _connected = False + elif isinstance(error, ConfigWriteError): + logger.critical(f"{self.name} config write error (atomic write failed): {error}") + elif isinstance(error, ConfigValidationError): + logger.error(f"{self.name} config validation error: {error}") + else: + logger.error(f"{self.name} unhandled error: {error}") ``` --- ## WebSocket Connection Manager (asyncio) +**Game-agnostic.** No changes from single-game design. + ```python -# websocket/manager.py +# core/websocket/manager.py class ConnectionManager: def __init__(self): - # server_id → set[WebSocket] - # Use set (not list) so .add()/.discard() work correctly. self._connections: dict[str, set[WebSocket]] = defaultdict(set) - # Per-connection channel subscriptions: ws → set[str] self._channel_subs: dict[WebSocket, set[str]] = defaultdict(set) self._lock = asyncio.Lock() @@ -529,8 +688,7 @@ class ConnectionManager: await ws.accept() async with self._lock: self._connections[server_id].add(ws) - self._channel_subs[ws].add('status') # default channel - # Only add to 'all' bucket if server_id is explicitly 'all' + self._channel_subs[ws].add('status') if server_id == 'all': self._connections['all'].add(ws) @@ -549,15 +707,12 @@ class ConnectionManager: self._channel_subs[ws].difference_update(channels) async def broadcast(self, server_id: str, message: dict, channel: str = None): - """Send to all clients subscribed to server_id AND the message's channel.""" targets: set[WebSocket] = set() async with self._lock: - # Collect clients for this server_id + 'all' subscribers server_clients = self._connections.get(server_id, set()) all_clients = self._connections.get('all', set()) candidates = server_clients | all_clients - # Filter by channel subscription if specified if channel: targets = {ws for ws in candidates if channel in self._channel_subs.get(ws, set())} @@ -571,7 +726,6 @@ class ConnectionManager: except Exception: dead.append(ws) - # Clean up dead connections if dead: async with self._lock: for ws in dead: @@ -587,9 +741,9 @@ class ConnectionManager: | Thread | Memory Impact | CPU Impact | |--------|--------------|-----------| | ProcessMonitorThread | Minimal (one `os.kill` check) | Negligible | -| LogTailThread | Buffer for unread log lines | Low (file I/O) | +| LogTailThread | Buffer for unread log lines | Low (file I/O + adapter parsing) | | MetricsCollectorThread | psutil subprocess scan | Low-Medium | -| RConPollerThread | UDP socket + response buffer | Low | +| RemoteAdminPollerThread | Adapter client socket + buffer | Low (varies by adapter protocol) | | BroadcastThread | Queue buffer (max 10000 entries) | Low | ### Recommendations @@ -597,4 +751,5 @@ class ConnectionManager: - `broadcast_queue.maxsize=10000` — backpressure; drop on Full (log warning) - `LogTailThread` buffers max ~100 lines per tick before writing to DB in batch - `MetricsCollectorThread` uses `psutil.Process.cpu_percent(interval=0.5)` — blocks 500ms, acceptable at 5s interval -- For N=10 servers: 41 background threads — well within Python's thread limits +- For N=10 servers: 31-41 background threads — well within Python's thread limits +- Games without remote admin skip the RemoteAdminPollerThread entirely \ No newline at end of file