diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5456df6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ + +# Environment +.env +.env.local +.env.*.local + +# Database +*.db +*.db-shm +*.db-wal + +# IDE +.vscode/ +.idea/ +*.sw? +*.suo + +# OS +.DS_Store +Thumbs.db + +# Test / coverage artifacts +coverage/ +playwright-report/ +test-results/ + +# Claude Code local settings +.claude/settings.local.json + +# Build output +dist/ \ No newline at end of file diff --git a/API.md b/API.md index a8e3b34..f120884 100644 --- a/API.md +++ b/API.md @@ -1,16 +1,51 @@ -# Languard Servers Manager — API Contract +# Languard Server Manager — API Reference ## Base URL + ``` http://localhost:8000/api ``` -## Authentication -- 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 }` +All HTTP endpoints are prefixed with `/api`. The WebSocket endpoint is at `/ws` (no `/api` prefix). + +## Authentication + +All endpoints except `POST /auth/login` and `GET /system/health` require a JWT bearer token: + +``` +Authorization: Bearer +``` + +For WebSocket, pass the token as a query parameter: + +``` +ws://localhost:8000/ws?token=&server_id=1 +``` + +JWT payload: + +```json +{ + "sub": "user_id", + "username": "string", + "role": "admin|viewer", + "exp": 1745000000 +} +``` + +### Roles + +| Role | Access | +|---------|-----------------------------------------------------------| +| `admin` | Full access: create, update, delete, start/stop servers | +| `viewer`| Read-only: list servers, view config (sensitive masked) | + +## Standard Response Envelope + +All responses use a consistent envelope: + +**Success:** -## Common Response Envelope ```json { "success": true, @@ -18,7 +53,9 @@ http://localhost:8000/api "error": null } ``` -Error response: + +**Error:** + ```json { "success": false, @@ -30,31 +67,45 @@ Error response: } ``` +Some error responses (from FastAPI HTTPException) use a `detail` object instead: + +```json +{ + "detail": { + "code": "NOT_FOUND", + "message": "Server 5 not found" + } +} +``` + ## HTTP Status Codes -| Code | Meaning | -|------|---------| -| 200 | Success | -| 201 | Created | -| 204 | No content (DELETE) | -| 400 | Validation error | -| 401 | Unauthenticated | -| 403 | Forbidden (insufficient role) | -| 404 | Not found (or capability not supported by adapter) | -| 409 | Conflict (already running, duplicate) | -| 422 | Unprocessable (Pydantic validation) | -| 500 | Internal server error | + +| Code | Meaning | +|------|------------------------------------------------------------| +| 200 | Success | +| 201 | Created (resource successfully created) | +| 204 | No content (successful DELETE) | +| 400 | Bad request (validation error, capability not supported) | +| 401 | Unauthenticated (missing or invalid token) | +| 403 | Forbidden (insufficient role — admin required) | +| 404 | Not found (resource or capability not available) | +| 409 | Conflict (already running, port in use, version conflict) | +| 413 | Payload too large (file upload exceeds 500 MB) | +| 422 | Unprocessable entity (Pydantic validation failure) | +| 500 | Internal server error | +| 503 | Service unavailable (RCon connection failed) | ## 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: +Some endpoints depend on the server's game adapter supporting a specific capability. If the adapter does not support the capability, the endpoint returns **400** with: ```json { "success": false, "data": null, "error": { - "code": "CAPABILITY_NOT_SUPPORTED", - "message": "Missions not supported for game type 'rust'" + "code": "NOT_SUPPORTED", + "message": "Game type 'rust' does not support mission management" } } ``` @@ -64,16 +115,20 @@ Some endpoints depend on the server's game adapter supporting a specific capabil ## Auth Endpoints ### POST /auth/login -Login and receive JWT. + +Authenticate and receive a JWT token. Public endpoint (no auth required). **Request:** + ```json { "username": "admin", "password": "secret" } ``` + **Response 200:** + ```json { "success": true, @@ -86,42 +141,180 @@ Login and receive JWT. "username": "admin", "role": "admin" } + }, + "error": null +} +``` + +**Error 401:** Invalid credentials. + +```json +{ + "success": false, + "data": null, + "error": { + "code": "UNAUTHORIZED", + "message": "Invalid credentials" } } ``` +--- + ### POST /auth/logout -Invalidate token (client-side token deletion; server-side blacklist optional). -### GET /auth/me -Return current user info. +Invalidate session (client-side token deletion; no server-side blacklist). + +**Auth:** Required (any role) + +**Response 200:** -### PUT /auth/password -Change password. Admin only. ```json -{ "current_password": "old", "new_password": "new" } +{ + "success": true, + "data": { "message": "Logged out" }, + "error": null +} ``` -### GET /auth/users -List all users. Admin only. - -### POST /auth/users -Create user. Admin only. -```json -{ "username": "viewer1", "password": "pass", "role": "viewer" } -``` - -### DELETE /auth/users/{user_id} -Delete user. Admin only. - --- -## Game Type Discovery Endpoints +### GET /auth/me -### GET /games -List all registered game types and their capabilities. +Return the currently authenticated user. + +**Auth:** Required (any role) **Response 200:** + +```json +{ + "success": true, + "data": { + "id": 1, + "username": "admin", + "role": "admin" + }, + "error": null +} +``` + +--- + +### PUT /auth/password + +Change the authenticated user's own password. Any authenticated user can change their own password (not admin-only). + +**Auth:** Required (any role) + +**Request:** + +```json +{ + "current_password": "old_password", + "new_password": "new_password" +} +``` + +**Response 200:** + +```json +{ + "success": true, + "data": { "message": "Password changed" }, + "error": null +} +``` + +**Error 401:** Current password is incorrect. + +--- + +### GET /auth/users + +List all users. + +**Auth:** Admin required + +**Response 200:** + +```json +{ + "success": true, + "data": [ + { + "id": 1, + "username": "admin", + "role": "admin", + "created_at": "2026-04-16T00:00:00", + "last_login": "2026-04-17T10:30:00" + } + ], + "error": null +} +``` + +--- + +### POST /auth/users + +Create a new user. + +**Auth:** Admin required + +**Request:** + +```json +{ + "username": "viewer1", + "password": "secure_password", + "role": "viewer" +} +``` + +`role` defaults to `"viewer"` if omitted. Valid values: `"admin"`, `"viewer"`. + +**Response 201:** + +```json +{ + "success": true, + "data": { + "id": 2, + "username": "viewer1", + "role": "viewer", + "created_at": "2026-04-17T12:00:00" + }, + "error": null +} +``` + +**Error 409:** Username already taken. + +--- + +### DELETE /auth/users/{user_id} + +Delete a user. Cannot delete yourself. + +**Auth:** Admin required + +**Response 204:** No content. + +**Error 400:** Attempting to delete your own account. + +--- + +## Games Endpoints + +### GET /games + +List all registered game types with their capabilities. + +**Auth:** Not required (public) + +**Response 200:** + ```json { "success": true, @@ -130,17 +323,37 @@ List all registered game types and their capabilities. "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"] + "capabilities": [ + "config_generator", + "process_config", + "log_parser", + "remote_admin", + "mission_manager", + "mod_manager", + "ban_manager" + ] } - ] + ], + "error": null } ``` +--- + ### GET /games/{game_type} -Get details for a specific game type. + +Get details for a specific game type, including capabilities, config sections, and allowed executables. + +**Auth:** Not required (public) + +**Path params:** + +| Parameter | Type | Description | +|-------------|--------|------------------------| +| `game_type` | string | Game type identifier | **Response 200:** + ```json { "success": true, @@ -148,44 +361,123 @@ Get details for a specific game type. "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"], + "schema_version": "1.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"] - } + }, + "error": null } ``` +**Error 404:** Unknown game type. + +--- + ### 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. + +Returns JSON Schema for each config section defined by the adapter. Used by the frontend to build dynamic config forms. + +**Auth:** Not required (public) + +**Path params:** + +| Parameter | Type | Description | +|-------------|--------|------------------------| +| `game_type` | string | Game type identifier | **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 */ } - } + "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" } + }, + "error": null } ``` +--- + ### GET /games/{game_type}/defaults + Default config values for new server creation. +**Auth:** Not required (public) + +**Path params:** + +| Parameter | Type | Description | +|-------------|--------|------------------------| +| `game_type` | string | Game type identifier | + **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, ... }, + "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 } + }, + "error": null +} +``` + +--- + +## System Endpoints + +### GET /system/health + +Public health check. Used by load balancers and Docker health probes. + +**Auth:** Not required + +**Response 200:** + +```json +{ + "status": "ok" +} +``` + +Note: This endpoint does not use the standard envelope. + +--- + +### GET /system/status + +System status including version and running server counts. + +**Auth:** Required (any role) + +**Response 200:** + +```json +{ + "success": true, + "data": { + "version": "1.0.0", + "running_servers": 2, + "total_servers": 3, + "supported_games": ["arma3"] } } ``` @@ -195,11 +487,19 @@ Default config values for new server creation. ## Server Endpoints ### GET /servers -List all servers with current status. Supports filtering by game type. -**Query params:** `?limit=50&offset=0&game_type=arma3` +List all servers with current status and live metrics. + +**Auth:** Required (any role) + +**Query params:** + +| Parameter | Type | Description | +|-------------|--------|------------------------------| +| `game_type` | string | Filter servers by game type | **Response 200:** + ```json { "success": true, @@ -213,20 +513,31 @@ List all servers with current status. Supports filtering by game type. "pid": 12345, "game_port": 2302, "rcon_port": 2306, + "auto_restart": true, + "max_restarts": 3, + "restart_count": 0, "player_count": 15, - "max_players": 40, "cpu_percent": 34.2, "ram_mb": 1850.5, "started_at": "2026-04-16T10:00:00Z" } - ] + ], + "error": null } ``` +`cpu_percent`, `ram_mb`, and `player_count` are merged from live metrics. They will be `null` if no metrics are available. + +--- + ### POST /servers -Create a new server. Admin only. `game_type` determines which adapter handles this server. + +Create a new server. The `game_type` determines which adapter handles this server. + +**Auth:** Admin required **Request:** + ```json { "name": "Main Server", @@ -240,93 +551,277 @@ Create a new server. Admin only. `game_type` determines which adapter handles th } ``` -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. +| Field | Type | Required | Default | Validation | +|----------------|---------|----------|----------|---------------------------| +| `name` | string | Yes | — | | +| `description` | string | No | `null` | | +| `game_type` | string | No | `"arma3"`| Must be a registered type | +| `exe_path` | string | Yes | — | Filename must be in allowlist | +| `game_port` | integer | Yes | — | 1024–65535, must not be in use | +| `rcon_port` | integer | No | auto | 1024–65535, auto = game_port + 3 | +| `auto_restart` | boolean | No | `false` | | +| `max_restarts` | integer | No | `3` | 0–20 | -**Response 201:** Returns full server object including auto-generated credentials. +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 creation response only. + +**Response 201:** Returns the full server object. + +**Errors:** + +- **400** `GAME_TYPE_NOT_FOUND` — Unknown game type +- **400** `EXE_NOT_ALLOWED` — Executable filename not in adapter allowlist +- **409** `PORT_IN_USE` — Game port or RCon port already in use + +--- ### GET /servers/{server_id} + Get server detail with full status. +**Auth:** Required (any role) + **Response 200:** + ```json { "success": true, "data": { "id": 1, "name": "Main Server", + "description": "Primary COOP server", "game_type": "arma3", "status": "running", "pid": 12345, + "exe_path": "C:/Arma3Server/arma3server_x64.exe", "game_port": 2302, "rcon_port": 2306, "auto_restart": true, + "max_restarts": 3, "restart_count": 0, "player_count": 15, - "max_players": 40, "cpu_percent": 34.2, "ram_mb": 1850.5, "started_at": "2026-04-16T10:00:00Z", "uptime_seconds": 3600 - } + }, + "error": null } ``` +**Error 404:** Server not found. + +--- + ### PUT /servers/{server_id} -Update server metadata (name, description, exe_path, ports). Admin only. + +Update server metadata (name, description, exe_path, ports). Only provided fields are updated; omitted fields keep their current values. + +**Auth:** Admin required + +**Request:** + +```json +{ + "name": "Updated Server Name", + "description": "New description", + "exe_path": "C:/Arma3Server/arma3server_x64.exe", + "game_port": 2302, + "rcon_port": 2306, + "auto_restart": false, + "max_restarts": 5 +} +``` + +All fields are optional. Port validation (1024–65535) applies if provided. + +**Response 200:** Returns the updated server object. + +--- ### DELETE /servers/{server_id} -Delete server (must be stopped first). Admin only. Removes DB rows and `servers/{id}/` directory. + +Delete a server. The server must be in a stopped, crashed, or error state. + +**Auth:** Admin required + +**Response 204:** No content. + +**Error 409:** `SERVER_NOT_STOPPED` — Server is currently running. + +Note: Also deletes the server's directory on disk (`servers/{id}/`). + +--- ### POST /servers/{server_id}/start -Start the server. Admin only. Core resolves the adapter and delegates config generation + launch arg building. + +Start the server. The system resolves the adapter, writes config files atomically, builds launch arguments, starts the process, and attaches monitoring threads. + +**Auth:** Admin required **Response 200:** + ```json -{ "success": true, "data": { "status": "starting", "pid": null } } +{ + "success": true, + "data": { + "status": "starting", + "pid": 12345 + }, + "error": null +} ``` -**Response 409:** Server already running. + +**Errors:** + +- **400** `EXE_NOT_ALLOWED` — Executable not in adapter allowlist +- **400** `INVALID_CONFIG` — Config validation or launch args failed +- **409** `SERVER_ALREADY_RUNNING` — Server is already running or starting +- **409** `PORT_IN_USE` — Game port or RCon port already in use +- **500** `CONFIG_WRITE_ERROR` — Failed to write config files to disk + +--- ### POST /servers/{server_id}/stop -Graceful stop (sends shutdown via adapter's RemoteAdmin, then force-kill after 30s). Admin only. -**Request (optional):** +Graceful stop. Sends a shutdown signal via the adapter's remote admin, then force-kills after 30 seconds if the process has not exited. + +**Auth:** Admin required + +**Request (optional body):** + ```json -{ "force": false, "reason": "Maintenance" } +{ + "force": false, + "reason": "Scheduled maintenance" +} ``` +| Field | Type | Required | Default | Description | +|----------|---------|----------|---------|------------------------------------------| +| `force` | boolean | No | `false` | Skip graceful shutdown, terminate immediately | +| `reason` | string | No | `null` | Optional reason for the stop | + +**Response 200:** + +```json +{ + "success": true, + "data": { "status": "stopped" }, + "error": null +} +``` + +**Error 409:** `SERVER_NOT_RUNNING` — Server is already stopped or crashed. + +--- + ### POST /servers/{server_id}/restart -Stop then start. Admin only. + +Stop then start the server. Equivalent to calling stop then start sequentially. + +**Auth:** Admin required + +**Response 200:** Returns the start result (same shape as start). + +--- ### POST /servers/{server_id}/kill -Force-kill the process immediately. Admin only. Emergency use only. + +Force-kill the server process immediately. Use for emergency situations only; does not attempt graceful shutdown. + +**Auth:** Admin required + +**Response 200:** + +```json +{ + "success": true, + "data": { "status": "stopped" }, + "error": null +} +``` --- ## 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. +Config sections are defined by the server's game adapter. The core `game_configs` table stores them as JSON. Each section tracks a `config_version` for optimistic locking. ### GET /servers/{server_id}/config -Get all config sections combined. Each section includes its `config_version` for optimistic locking. + +Get all config sections combined. Sensitive fields (passwords) are masked with `"***"`. + +**Auth:** Required (any role — sensitive fields are masked for non-admin viewers) **Response 200:** + ```json { "success": true, "data": { - "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"} } - } + "server": { + "hostname": "My Server", + "max_players": 40, + "_meta": { "config_version": 3, "schema_version": "1.0" } + }, + "basic": { + "max_bandwidth": 50000000, + "_meta": { "config_version": 1, "schema_version": "1.0" } + }, + "profile": { + "third_person_view": 0, + "_meta": { "config_version": 2, "schema_version": "1.0" } + }, + "launch": { + "world": "empty", + "limit_fps": 50, + "_meta": { "config_version": 1, "schema_version": "1.0" } + }, + "rcon": { + "rcon_password": "***", + "max_ping": 200, + "enabled": 1, + "_meta": { "config_version": 1, "schema_version": "1.0" } + } + }, + "error": null } ``` -### 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). +--- + +### GET /servers/{server_id}/config/preview + +Rendered config for preview. Admin only because it may contain plaintext credentials. + +**Auth:** Admin required + +Returns a dict of `{label: rendered_content}` where 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": "hostname = \"My Server\";\nmaxPlayers = 40;\n...", + "basic.cfg": "// Generated basic.cfg\n...", + "server.Arma3Profile": "// Generated profile\n..." + }, + "error": null +} +``` + +--- + +### 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). + +**Auth:** Required (any role — sensitive fields are masked) + +**Response 200:** + ```json { "success": true, @@ -334,20 +829,41 @@ Get a single config section. Section names are defined by the adapter (e.g., `se "hostname": "My Server", "max_players": 40, "_meta": { "config_version": 3, "schema_version": "1.0" } - } + }, + "error": null } ``` +**Error 404:** Config section not found (not a valid section for this adapter). + +--- + ### 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. +Update a config section. Uses **optimistic locking** — the request must include `config_version` from the last read. Validated against the adapter's Pydantic model for that section. Omitted fields keep their current values (partial update). + +**Auth:** Admin required + +**Request:** -**Arma 3 `server` section example:** ```json { "hostname": "Updated Server Name", "max_players": 64, + "config_version": 3 +} +``` + +`config_version` is required for conflict detection. Any omitted field retains its current value. + +**Arma 3 section examples:** + +`server` section: + +```json +{ + "hostname": "My Arma Server", + "max_players": 40, "battleye": 1, "verify_signatures": 2, "motd_lines": ["Welcome!", "Have fun"], @@ -356,432 +872,513 @@ Update a config section. Admin only. Validated against the adapter's Pydantic mo } ``` -**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." - } -} -``` +`basic` section: -**Arma 3 `basic` section example:** ```json { "max_bandwidth": 50000000, - "max_msg_send": 256 + "max_msg_send": 256, + "config_version": 1 } ``` -**Arma 3 `profile` section example:** +`profile` section: + ```json { "third_person_view": 0, "weapon_crosshair": 0, "ai_level_preset": 3, "skill_ai": 0.7, - "precision_ai": 0.6 + "precision_ai": 0.6, + "config_version": 2 } ``` -**Arma 3 `launch` section example:** +`launch` section: + ```json { "world": "empty", "limit_fps": 50, "auto_init": false, - "load_mission_to_memory": true + "load_mission_to_memory": true, + "config_version": 1 } ``` -**Arma 3 `rcon` section example:** +`rcon` section: + ```json { "rcon_password": "newpassword", "rcon_port": 2306, "max_ping": 300, - "enabled": true + "enabled": 1, + "config_version": 1 } ``` -**Note:** `rcon_port` is stored in the `servers` table, not in the config JSON. The service layer updates both tables as needed. +Note: `rcon_port` is stored in the `servers` table, not in the config JSON. The service layer updates both tables as needed. -### 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:** Returns the updated section (sensitive fields masked). + +**Error 409 — Optimistic locking conflict:** -**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..." + "success": false, + "data": null, + "error": { + "code": "CONFIG_VERSION_CONFLICT", + "message": "Config was modified by another user. Re-read and merge.", + "current_config": { "...": "latest values" }, + "current_version": 5 } } ``` -(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 in adapter's allowlist (whitelist-validated, no path traversal). **Admin only**. +**Error 422:** Pydantic validation failure — the submitted values do not match the adapter's schema. --- -## Mission Endpoints (Capability: `mission_manager`) +## RCon Endpoints -Returns **404** if adapter does not support `mission_manager`. +### POST /servers/{server_id}/rcon/command -### GET /servers/{server_id}/missions -List all mission/scenario files for a server. +Send an RCon command to a running server. Requires the adapter to support the `remote_admin` capability. + +**Auth:** Admin required + +**Request:** -**Response 200:** ```json { - "success": true, - "data": [ - { - "id": 1, - "filename": "MyMission.Altis.pbo", - "mission_name": "MyMission.Altis", - "terrain": "Altis", - "file_size": 102400, - "uploaded_at": "2026-04-16T09:00:00Z" - } - ] + "command": "#restart" } ``` -### POST /servers/{server_id}/missions/upload -Upload a mission/scenario file. Admin only. `multipart/form-data`. File extension validated by adapter's `MissionManager.file_extension`. +**Response 200:** -**Form fields:** -- `file`: the mission file (filename sanitized; only adapter-allowed extensions accepted) - -**Response 201:** ```json { "success": true, "data": { - "id": 2, - "filename": "NewMission.Stratis.pbo", - "mission_name": "NewMission.Stratis", - "terrain": "Stratis", - "file_size": 51200 - } + "response": "Command executed" + }, + "error": null } ``` -### DELETE /servers/{server_id}/missions/{mission_id} -Delete a mission file (removes file from disk). Admin only. +**Errors:** -### GET /servers/{server_id}/missions/rotation -Get current mission/scenario rotation (ordered list). +- **400** `NOT_SUPPORTED` — Game type does not support RCon +- **400** `NO_RCON_PASSWORD` — RCon password not configured for this server +- **503** `RCON_ERROR` — RCon connection or command failed -**Response 200:** -```json -{ - "success": true, - "data": [ - { - "id": 1, - "sort_order": 0, - "mission": { "id": 1, "mission_name": "MyMission.Altis" }, - "difficulty": "Regular", - "params_json": { "RespawnDelay": 15 } - } - ] -} -``` +**Arma 3 available RCon commands:** -### PUT /servers/{server_id}/missions/rotation -Replace the entire mission rotation. Admin only. - -```json -{ - "rotation": [ - { "mission_id": 1, "difficulty": "Regular", "params": {} }, - { "mission_id": 2, "difficulty": "Veteran", "params": { "RespawnDelay": 30 } } - ] -} -``` - ---- - -## Mod Endpoints (Capability: `mod_manager`) - -Returns **404** if adapter does not support `mod_manager`. - -### GET /mods -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", - "description": "Community Base Addons" -} -``` - -### DELETE /mods/{mod_id} -Delete mod registration. Admin only. - -### GET /servers/{server_id}/mods -Get mods enabled for a server. - -**Response 200:** -```json -{ - "success": true, - "data": [ - { - "mod_id": 1, - "name": "@CBA_A3", - "folder_path": "C:/Arma3Server/@CBA_A3", - "is_server_mod": false, - "sort_order": 0 - } - ] -} -``` - -### PUT /servers/{server_id}/mods -Replace the mod list for a server. Admin only. - -```json -{ - "mods": [ - { "mod_id": 1, "is_server_mod": false, "sort_order": 0 }, - { "mod_id": 2, "is_server_mod": true, "sort_order": 1 } - ] -} -``` +| Command | Description | +|---------|-------------| +| `#restart` | Restart current mission | +| `#reassign` | Restart with roles unassigned | +| `#missions` | Open mission selection | +| `#lock` | Lock the server | +| `#unlock` | Unlock the server | +| `#mission NAME.TERRAIN [difficulty]` | Load a specific mission | +| `#shutdown` | Shut down the server | +| `#monitor N` | Toggle performance monitoring | +| `say -1 MESSAGE` | Message all players | --- ## Player Endpoints ### GET /servers/{server_id}/players -Get currently connected players. + +List currently connected players (cached from RemoteAdminPollerThread). + +**Auth:** Required (any role) **Response 200:** + ```json { "success": true, - "data": [ - { - "slot_id": "1", - "name": "PlayerOne", - "guid": "abc123...", - "ping": 45, - "game_data": { "verified": true, "steam_uid": "76561198..." }, - "joined_at": "2026-04-16T10:15:00Z" - } - ] + "data": { + "server_id": 1, + "player_count": 2, + "players": [ + { + "slot_id": "1", + "name": "PlayerOne", + "ping": 45 + }, + { + "slot_id": "2", + "name": "PlayerTwo", + "ping": 78 + } + ] + }, + "error": null } ``` -### POST /servers/{server_id}/players/{slot_id}/kick -Kick a player. Admin only. Requires adapter `remote_admin` capability. +--- -```json -{ "reason": "AFK" } -``` +### GET /servers/{server_id}/players/history -### POST /servers/{server_id}/players/{slot_id}/ban -Ban a player. Admin only. Requires adapter `remote_admin` capability. +Get historical player session records. + +**Auth:** Required (any role) + +**Query params:** + +| Parameter | Type | Default | Description | +|-----------|---------|---------|------------------------------| +| `limit` | integer | 100 | Maximum records to return | +| `offset` | integer | 0 | Pagination offset | +| `search` | string | — | Filter by player name | + +**Response 200:** ```json { - "reason": "Griefing", - "duration_minutes": 0 + "success": true, + "data": { + "total": 150, + "items": [ + { + "id": 1, + "player_name": "PlayerOne", + "guid": "abc123...", + "joined_at": "2026-04-16T10:15:00Z", + "left_at": "2026-04-16T11:30:00Z" + } + ] + }, + "error": null } ``` -### GET /servers/{server_id}/players/history -Player connection history. Supports pagination. - -**Query params:** `?limit=50&offset=0&search=PlayerName` - --- ## Ban Endpoints ### GET /servers/{server_id}/bans -List all bans for a server. -**Query params:** `?active_only=true&limit=50&offset=0` +List all active bans for a server. -### POST /servers/{server_id}/bans -Add ban manually. Admin only. If adapter has `ban_manager`, also syncs to the game's ban file. - -```json -{ - "guid": "abc123...", - "name": "PlayerName", - "reason": "Cheating", - "duration_minutes": 0 -} -``` - -### DELETE /servers/{server_id}/bans/{ban_id} -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!" } -``` - ---- - -## Log Endpoints - -### GET /servers/{server_id}/logs -Query stored log lines. - -**Query params:** `?limit=200&offset=0&level=error&since=2026-04-16T10:00:00Z&search=BattlEye` +**Auth:** Required (any role) **Response 200:** -```json -{ - "success": true, - "data": { - "total": 1542, - "logs": [ - { - "id": 100, - "timestamp": "2026-04-16T10:05:23Z", - "level": "info", - "message": "Player PlayerOne connected" - } - ] - } -} -``` -### DELETE /servers/{server_id}/logs -Clear all stored log lines for a server. Admin only. - ---- - -## Metrics Endpoints - -### GET /servers/{server_id}/metrics -Get time-series metrics. - -**Query params:** `?from=2026-04-16T00:00:00Z&to=2026-04-16T23:59:59Z&resolution=5m` - -**Response 200:** ```json { "success": true, "data": [ { - "timestamp": "2026-04-16T10:00:00Z", - "cpu_percent": 34.2, - "ram_mb": 1850.5, - "player_count": 15 + "id": 1, + "server_id": 1, + "guid": "abc123...", + "name": null, + "reason": "Cheating", + "banned_by": "admin", + "ban_type": "GUID", + "duration_minutes": 0, + "expires_at": null, + "created_at": "2026-04-16T10:00:00Z" } - ] + ], + "error": null } ``` --- -## Event Log Endpoints +### POST /servers/{server_id}/bans -### GET /servers/{server_id}/events -Get server event history (audit trail). +Create a new ban. Writes to the database and syncs to the game's `bans.txt` file (if adapter supports `ban_manager`). -**Query params:** `?limit=50&offset=0&event_type=crashed` +**Auth:** Admin required ---- +**Request:** -## System Endpoints +```json +{ + "player_uid": "abc123...", + "ban_type": "GUID", + "reason": "Cheating", + "duration_minutes": 0 +} +``` -### GET /system/status -Overall system status. **Requires authentication** (admin or viewer). +| Field | Type | Required | Default | Description | +|--------------------|---------|----------|-----------|---------------------------------| +| `player_uid` | string | Yes | — | GUID or IP to ban | +| `ban_type` | string | No | `"GUID"` | Must be `"GUID"` or `"IP"` | +| `reason` | string | No | `""` | Reason for the ban | +| `duration_minutes` | integer | No | `0` | 0 = permanent, >0 = timed ban | + +**Response 201:** ```json { "success": true, "data": { - "version": "1.0.0", - "running_servers": 2, - "total_servers": 3, - "supported_games": ["arma3"], - "uptime_seconds": 86400 - } + "id": 2, + "server_id": 1, + "guid": "abc123...", + "reason": "Cheating", + "ban_type": "GUID", + "expires_at": null + }, + "error": null } ``` -### GET /system/health -Health check (for load balancer/Docker). Returns 200 if healthy. +Note: If the `bans.txt` file sync fails, the database ban is still created. The error is logged but does not fail the request. + +--- + +### DELETE /servers/{server_id}/bans/{ban_id} + +Revoke (deactivate) a ban. Removes from `bans.txt` if the adapter supports `ban_manager`. + +**Auth:** Admin required + +**Response 200:** + +```json +{ + "success": true, + "data": { "message": "Ban 2 revoked" }, + "error": null +} +``` + +**Error 404:** Ban not found or does not belong to this server. + +--- + +## Mission Endpoints + +All mission endpoints require the adapter to support the `mission_manager` capability. Returns **400** `NOT_SUPPORTED` if the game type does not support missions. + +### GET /servers/{server_id}/missions + +List all available mission/scenario files on disk. + +**Auth:** Required (any role) + +**Response 200:** + +```json +{ + "success": true, + "data": { + "server_id": 1, + "total": 2, + "missions": [ + { + "filename": "MyMission.Altis.pbo", + "mission_name": "MyMission.Altis", + "terrain": "Altis", + "file_size": 102400 + } + ] + }, + "error": null +} +``` + +--- + +### POST /servers/{server_id}/missions + +Upload a mission file. **Multipart form-data**. Maximum file size: **500 MB**. File extension is validated by the adapter (e.g., `.pbo` for Arma 3). + +**Auth:** Admin required + +**Request:** `multipart/form-data` with field `file`. + +**Response 201:** + +```json +{ + "success": true, + "data": { + "filename": "NewMission.Stratis.pbo", + "mission_name": "NewMission.Stratis", + "terrain": "Stratis", + "file_size": 51200 + }, + "error": null +} +``` + +**Errors:** + +- **400** `NO_FILENAME` — No filename provided in upload +- **400** `ADAPTER_ERROR` — Upload failed (wrong extension, etc.) +- **413** `FILE_TOO_LARGE` — File exceeds 500 MB + +--- + +### DELETE /servers/{server_id}/missions/{filename} + +Delete a mission file by filename. Removes the file from disk. + +**Auth:** Admin required + +**Path params:** + +| Parameter | Type | Description | +|------------|--------|---------------------------------| +| `filename` | string | Mission filename (e.g. `MyMission.Altis.pbo`) | + +**Response 200:** + +```json +{ + "success": true, + "data": { "message": "Mission 'MyMission.Altis.pbo' deleted" }, + "error": null +} +``` + +**Error 404:** Mission file not found. + +--- + +## Mod Endpoints + +All mod endpoints require the adapter to support the `mod_manager` capability. Returns **400** `NOT_SUPPORTED` if the game type does not support mods. + +### GET /servers/{server_id}/mods + +List all available mods and which are currently enabled for this server. + +**Auth:** Required (any role) + +**Response 200:** + +```json +{ + "success": true, + "data": { + "server_id": 1, + "enabled_count": 2, + "mods": [ + { + "name": "@CBA_A3", + "folder_path": "C:/Arma3Server/@CBA_A3", + "enabled": true + }, + { + "name": "@ACRE2", + "folder_path": "C:/Arma3Server/@ACRE2", + "enabled": true + }, + { + "name": "@USAF", + "folder_path": "C:/Arma3Server/@USAF", + "enabled": false + } + ] + }, + "error": null +} +``` + +--- + +### PUT /servers/{server_id}/mods/enabled + +Set the list of enabled mods. This **replaces** the entire enabled list — send the complete list every time. The server must be restarted for changes to take effect. + +**Auth:** Admin required + +**Request:** + +```json +{ + "mods": ["@CBA_A3", "@ACRE2"] +} +``` + +| Field | Type | Required | Description | +|--------|---------------|----------|------------------------------------| +| `mods` | array[string] | Yes | Complete list of mod names to enable | + +**Response 200:** + +```json +{ + "success": true, + "data": { + "message": "Enabled mods updated. Restart the server for changes to take effect.", + "enabled_mods": ["@CBA_A3", "@ACRE2"] + }, + "error": null +} +``` + +**Error 409** `VERSION_CONFLICT` — Config was modified by another request while updating mods. --- ## WebSocket API ### Connection -``` -ws://localhost:8000/ws/{server_id}?token= -``` -Use `server_id = "all"` to subscribe to events from all servers. -### Client → Server Messages - -```json -{ "type": "ping" } -{ "type": "subscribe", "channels": ["logs", "players", "metrics", "status"] } -{ "type": "unsubscribe", "channels": ["metrics"] } +``` +ws://localhost:8000/ws?token=&server_id=1&server_id=2 ``` -**Channel subscription**: The `ConnectionManager` tracks per-connection channel subscriptions. Only messages matching subscribed channels are delivered. Default subscriptions on connect: `["status"]`. +**Parameters:** -### Server → Client Messages +| Parameter | Type | Required | Description | +|-------------|-------------------|----------|----------------------------------------------| +| `token` | string | Yes | JWT access token | +| `server_id` | integer (repeatable) | No | Server IDs to subscribe to. Omit for all servers | + +**Authentication:** JWT is passed as a query parameter because the browser WebSocket API does not support custom headers. If the token is missing or invalid, the connection is closed with code **4001**. + +**Welcome message on connect:** -#### Status Update ```json { - "type": "status", + "type": "connected", + "data": { + "user": "1", + "subscriptions": [1, 2] + } +} +``` + +If `server_id` is omitted, `subscriptions` will be `"all"`. + +### Server-Sent Event Types + +All events follow this format: + +```json +{ + "type": "", + "server_id": 1, + "data": { ... } +} +``` + +#### server_status + +Emitted when a server's status changes (starting, running, stopping, stopped, crashed, error). + +```json +{ + "type": "server_status", "server_id": 1, "data": { "status": "running", @@ -791,7 +1388,10 @@ Use `server_id = "all"` to subscribe to events from all servers. } ``` -#### Log Line +#### log + +Emitted for each log line from the server process. + ```json { "type": "log", @@ -804,7 +1404,10 @@ Use `server_id = "all"` to subscribe to events from all servers. } ``` -#### Player List Update +#### players + +Emitted when the player list changes. + ```json { "type": "players", @@ -818,7 +1421,10 @@ Use `server_id = "all"` to subscribe to events from all servers. } ``` -#### Metrics Update +#### metrics + +Periodic resource usage update. + ```json { "type": "metrics", @@ -832,67 +1438,51 @@ Use `server_id = "all"` to subscribe to events from all servers. } ``` -#### Server Event -```json -{ - "type": "event", - "server_id": 1, - "data": { - "event_type": "crashed", - "detail": { "exit_code": 1 }, - "timestamp": "2026-04-16T10:30:00Z" - } -} -``` +### Subscription Model -#### Pong -```json -{ "type": "pong" } -``` - ---- - -## 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. +- Each connection subscribes to zero or more `server_id` values. +- Subscribing to `server_id=None` (omitting the parameter) means "all servers." +- `broadcast(server_id, message)` delivers to all clients subscribed to that `server_id` plus all global subscribers. +- If the event loop is closed, events are silently dropped. --- ## Rate Limiting -- `POST /auth/login`: 5 attempts per minute per IP. Exceeded returns `429 Too Many Requests`. -- All other endpoints: 60 requests per minute per token. Exceeded returns `429`. -- Implemented via FastAPI middleware (e.g., `slowapi`). +| Endpoint | Limit | +|-----------------------|------------------------------| +| `POST /auth/login` | 5 attempts per minute per IP | +| All other endpoints | 60 requests per minute per token | + +Exceeded limits return **429 Too Many Requests**. + +Implemented via `slowapi` middleware. --- ## Error Codes Reference -| Code | Description | -|------|-------------| -| `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 | -| `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` | 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 | \ No newline at end of file +| Code | HTTP Status | Description | +|-----------------------------|-------------|------------------------------------------------------| +| `UNAUTHORIZED` | 401 | Missing or invalid token | +| `FORBIDDEN` | 403 | Insufficient role (admin required) | +| `NOT_FOUND` | 404 | Resource not found | +| `NOT_SUPPORTED` | 400 | Adapter lacks required capability | +| `CONFLICT` | 409 | Duplicate resource (e.g., username taken) | +| `SERVER_ALREADY_RUNNING` | 409 | Start called on running server | +| `SERVER_NOT_RUNNING` | 409 | Stop/command on stopped server | +| `SERVER_NOT_STOPPED` | 409 | Delete called on running server | +| `PORT_IN_USE` | 409 | Game or RCon port already occupied | +| `CONFIG_VERSION_CONFLICT` | 409 | Optimistic locking conflict on config update | +| `VERSION_CONFLICT` | 409 | Config modified by another request during mod update | +| `GAME_TYPE_NOT_FOUND` | 404/400 | No adapter registered for this game type | +| `EXE_NOT_ALLOWED` | 400 | Executable not in adapter allowlist | +| `INVALID_CONFIG` | 422/400 | Config validation failed (adapter-specific) | +| `CONFIG_WRITE_ERROR` | 500 | Config file write failed (disk, permissions) | +| `NO_RCON_PASSWORD` | 400 | RCon password not configured | +| `RCON_ERROR` | 503 | RCon connection or command failed | +| `ADAPTER_ERROR` | 400/500 | Generic adapter error | +| `FILE_TOO_LARGE` | 413 | Upload exceeds 500 MB | +| `NO_FILENAME` | 400 | No filename in upload request | +| `VALIDATION_ERROR` | 400 | General validation failure | +| `INTERNAL_ERROR` | 500 | Unexpected server error | \ No newline at end of file diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3f8bba3..4e7351a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,10 +1,10 @@ -# Languard Servers Manager — System Architecture +# Languard Server Manager -- System Architecture ## Overview -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. +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. +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,540 +12,756 @@ Languard is a **multi-game** web-based management panel for dedicated game serve | Layer | Technology | Rationale | |-------|-----------|-----------| -| 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 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 | -| 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 + Vite + Tailwind | See FRONTEND.md for full design system, component architecture, and adapter-aware UI patterns | +| Backend framework | **FastAPI** (Python 3.11+) | Async-native, built-in WebSocket, OpenAPI docs | +| Database | **SQLite** via SQLAlchemy (sync, raw SQL via `text()`) | Zero-config, file-based; WAL mode for concurrent reads; all DB access is synchronous | +| Process management | `subprocess.Popen` + `threading` + `psutil` | Wrap server executables, recover PIDs after restart; on Windows `terminate()` is a hard kill | +| Real-time comms | **WebSocket** via FastAPI | Push log lines, player lists, server status to React | +| Game adapters | **Python Protocol classes** (duck typing) + `GameAdapterRegistry` | Each game implements capability protocols; core resolves adapter at runtime from `server.game_type` | +| Scheduling | **APScheduler** `BackgroundScheduler` | Log/metrics/event cleanup jobs (sync DB ops run in thread pool) | +| Auth | **JWT** (python-jose, HS256) + bcrypt | Secure API; React stores token in localStorage | +| Encryption | **Fernet** (AES-256 via cryptography library) | Encrypt sensitive config fields (RCON passwords, admin passwords) at rest | +| Rate limiting | **slowapi** (installed, no per-route limits yet) | Infrastructure present, not yet applied to individual routes | +| Frontend | **React 19** + **TypeScript 6** + **Vite 8** + Tailwind CSS | SPA with dark neumorphic design system | --- -## Architecture Overview +## Architecture Diagram ``` -┌─────────────────────────────────────────────────────────────┐ -│ React Frontend (see FRONTEND.md) │ -│ Dashboard │ Server List │ Server Detail │ Logs │ Config UI │ -│ Game Type Selector │ Adapter-specific Panels │ -└────────────────────────┬────────────────────────────────────┘ - │ HTTP REST + WebSocket - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ FastAPI Application (Core) │ -│ │ -│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ -│ │ Auth Router │ │ Server Router│ │ Config Router │ │ -│ └──────────────┘ └──────────────┘ └──────────────────┘ │ -│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ -│ │ Player Router│ │ Log Router │ │ WS Router │ │ -│ └──────────────┘ └──────────────┘ └──────────────────┘ │ -│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ -│ │ Metric Router│ │ Event Router │ │ Games Router │ │ -│ └──────────────┘ └──────────────┘ └──────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ 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, core) │ │ -│ │ LogTailThread (per server, core + adapter parser) │ │ -│ │ MetricsCollectorThread (per server, core) │ │ -│ │ RemoteAdminPollerThread (per server, core + adapter) │ │ -│ │ BroadcastThread (global) │ │ -│ └─────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ Data Access Layer (DAL) │ │ -│ │ ServerRepository │ PlayerRepository │ │ -│ │ LogRepository │ MetricsRepository │ │ -│ │ ConfigRepository (game_configs table) │ │ -│ └─────────────────────────────────────────────────────┘ │ -│ │ -│ ┌───────────────────┐ ┌────────────────────────────────┐ │ -│ │ SQLite (DB) │ │ Filesystem │ │ -│ │ languard.db │ │ servers/{id}/ (layout by │ │ -│ │ │ │ adapter.get_process_config() │ │ -│ └───────────────────┘ └────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────┘ - │ - ┌─────────────┴─────────────────┐ - ▼ ▼ -┌──────────────────────┐ ┌──────────────────────────────────┐ -│ 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 │ │ -│ └────────────────┘ │ -└──────────────────────┘ ++--------------------------------------------------------------+ +| React Frontend (SPA) | +| Dashboard | Login | Server List | Server Detail (planned) | ++------------------------------+-------------------------------+ + | HTTP REST + WebSocket (/ws) + v ++--------------------------------------------------------------+ +| FastAPI Application (main.py) | +| | +| Lifespan: init DB -> register adapters -> create WSManager | +| -> start BroadcastThread -> create ThreadRegistry | +| -> recover processes -> seed admin -> start scheduler| +| | +| +-----------+ +-----------+ +-----------+ +-----------+ | +| | Auth | | Servers | | Games | | System | | +| | Router | | Router | | Router | | Router | | +| +-----------+ +-----------+ +-----------+ +-----------+ | +| +-----------+ +-----------+ +-----------+ +-----------+ | +| | Players | | Bans | | Missions | | Mods | | +| | Router | | Router | | Router | | Router | | +| +-----------+ +-----------+ +-----------+ +-----------+ | +| +-----------+ | +| | WebSocket | /ws endpoint, JWT via query param | +| | Router | BroadcastThread bridges Queue -> asyncio | +| +-----------+ | +| | +| +----------------------------------------------------------+ | +| | Service Layer | | +| | AuthService | ServerService | (delegating to adapters) | | +| +----------------------------------------------------------+ | +| | +| +----------------------------------------------------------+ | +| | Data Access Layer | | +| | BaseRepository -> ServerRepository | ConfigRepository | | +| | PlayerRepository | LogRepository | MetricsRepository | | +| | EventRepository | BanRepository | | +| +----------------------------------------------------------+ | +| | +| +----------------------------------------------------------+ | +| | Game Adapter System | | +| | GameAdapterRegistry (class-level singleton) | | +| | -> Arma3Adapter (implements all 7 capabilities) | | +| | -> third-party adapters via entry_points | | +| +----------------------------------------------------------+ | +| | +| +----------------------------------------------------------+ | +| | Background Thread System | | +| | ThreadRegistry -> per-server thread bundles: | | +| | ProcessMonitorThread | MetricsCollectorThread | | +| | LogTailThread (conditional) | RemoteAdminPollerThread | | +| +----------------------------------------------------------+ | +| | +| +----------------------------------------------------------+ | +| | Scheduled Jobs (APScheduler) | | +| | cleanup_old_logs (daily 03:00) | | +| | cleanup_old_metrics (every 6h) | | +| | cleanup_old_events (weekly Sunday 04:00) | | +| +----------------------------------------------------------+ | ++--------------------------------------------------------------+ + | + v ++--------------------------------------------------------------+ +| SQLite Database | +| users | servers | game_configs | mods | server_mods | +| missions | mission_rotation | players | player_history | +| bans | logs | metrics | server_events | schema_migrations | ++--------------------------------------------------------------+ ``` --- -## Game Adapter Architecture +## Backend Architecture -### Core Principle: Composition Over Inheritance +### Application Bootstrap (main.py) -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. +The app uses a `lifespan` async context manager on the FastAPI instance. The startup sequence, in order: + +1. **Initialize DB** -- `get_engine()` creates the SQLAlchemy engine with WAL mode, foreign keys, and busy timeout pragmas. `run_migrations()` applies all pending SQL files from `core/migrations/`. +2. **Register adapters** -- `initialize_adapters()` imports built-in adapters (Arma 3) which self-register via `GameAdapterRegistry.register()`, then scans `importlib.metadata` entry_points under `"languard.adapters"` for third-party adapters. +3. **Create WebSocketManager** -- Instantiated and stored on `app.state.ws_manager`. +4. **Create broadcast queue and BroadcastThread** -- A `queue.Queue(maxsize=1000)` bridges background threads to the asyncio event loop. `BroadcastThread` is a daemon thread that calls `asyncio.run_coroutine_threadsafe()` to schedule `ws_manager.broadcast()` on the event loop. +5. **Create ThreadRegistry** -- Initialized with the `ProcessManager` singleton, `GameAdapterRegistry`, and the global broadcast queue. Stored on `app.state.thread_registry`. +6. **Recover processes** -- `ProcessManager.recover_on_startup()` checks DB for servers marked "running", verifies PIDs via psutil against the adapter's executable allowlist, and re-attaches monitoring threads for valid PIDs. Crashed servers are marked "crashed". +7. **Reattach threads for running servers** -- Iterates running servers and calls `ThreadRegistry.reattach_server_threads()`. +8. **Seed default admin** -- `AuthService.seed_admin_if_empty()` creates an "admin" user with a random 16-char password if no users exist. The password is logged to stdout at startup. +9. **Start APScheduler** -- Registers cleanup jobs and starts the `BackgroundScheduler`. + +Shutdown stops all threads, the broadcast thread, and the scheduler. + +### Layered Architecture + +The backend follows a strict layered pattern: + +``` +Routers -> Services -> Repositories -> Database +``` + +- **Routers** handle HTTP concerns (request parsing, response formatting, auth guards). They return standardized `{"success": True, "data": ..., "error": None}` envelopes. +- **Services** contain business logic. `ServerService` orchestrates lifecycle operations, delegating game-specific work to adapters. +- **Repositories** encapsulate SQL. All extend `BaseRepository`, which provides `_execute()`, `_fetchone()`, `_fetchall()`, and `_lastrowid()` helpers that wrap `sqlalchemy.text()`. +- **Database** is SQLite with raw SQL (no ORM models). Migrations are numbered SQL files applied in order. + +### Global Exception Handler + +All unhandled exceptions are caught by a FastAPI `@app.exception_handler(Exception)` that returns: + +```json +{ + "success": false, + "data": null, + "error": {"code": "INTERNAL_ERROR", "message": "An unexpected error occurred"} +} +``` + +### CORS Middleware + +Configured via `LANGUARD_CORS_ORIGINS` env var (default: `["http://localhost:5173"]`). Allows all methods and headers with credentials. + +--- + +## Game Adapter System + +### Design + +Adapters use Python `Protocol` classes (runtime-checkable duck typing). The core never imports adapter internals -- it only imports from `adapters.protocols` and resolves adapters via `GameAdapterRegistry`. ### 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 | +Seven capability protocols are defined in `adapters/protocols.py`: -### Adapter Registry +| Protocol | Purpose | Key Methods | +|----------|---------|-------------| +| `ConfigGenerator` | Config schema, defaults, file writing, launch args | `get_sections()`, `get_defaults()`, `get_sensitive_fields()`, `get_config_version()`, `migrate_config()`, `write_configs()`, `build_launch_args()`, `preview_config()` | +| `ProcessConfig` | Executable allowlist, port conventions, directory layout | `get_allowed_executables()`, `get_port_conventions()`, `get_default_game_port()`, `get_default_rcon_port()`, `get_server_dir_layout()` | +| `LogParser` | Parse game-specific log lines | `parse_line()`, `get_log_file_resolver()` | +| `RemoteAdmin` | Factory for remote admin clients (RCon) | `create_client()`, `get_startup_delay()`, `get_poll_interval()`, `get_player_data_schema()` | +| `MissionManager` | Mission file handling and rotation | `parse_mission_filename()`, `get_rotation_config()`, `get_missions_dir()`, `get_mission_data_schema()` | +| `ModManager` | Mod folder conventions and CLI args | `get_mod_folder_pattern()`, `build_mod_args()`, `validate_mod_folder()`, `get_mod_data_schema()` | +| `BanManager` | Bidirectional ban file sync | `get_ban_file_path()`, `sync_bans_to_file()`, `read_bans_from_file()`, `get_ban_data_schema()` | -```python -# adapters/registry.py -class GameAdapterRegistry: - _adapters: dict[str, GameAdapter] = {} +The composite `GameAdapter` protocol requires all adapters to implement `has_capability(name)` which returns whether a given capability is available. Optional capabilities (`RemoteAdmin`, `MissionManager`, `ModManager`, `BanManager`) return `None` from their factory methods when not supported. - @classmethod - def register(cls, adapter: GameAdapter) -> None: ... +### GameAdapterRegistry - @classmethod - def get(cls, game_type: str) -> GameAdapter: ... +A class-level singleton mapping `game_type` string to adapter instance. Provides: - @classmethod - def all(cls) -> list[GameAdapter]: ... +- `register(adapter)` -- Called at import time by each adapter package +- `get(game_type)` -- Raises `KeyError` if not found +- `all()` -- Returns all registered adapters +- `list_game_types()` -- Returns metadata list for the `/api/games` endpoint - @classmethod - def list_game_types(cls) -> list[dict]: ... -``` +### Arma3Adapter -Adapters auto-register at import time. The core never imports adapter internals — it only resolves through the registry. +Implements all 7 capabilities: -### How Core Delegates to Adapter +- **ConfigGenerator**: Defines `server`, `rcon`, `mission` sections using Pydantic models. Writes `server.cfg` using atomic write pattern (`.tmp` then `os.replace()`). Builds launch args from config + mod list. +- **ProcessConfig**: Allows `arma3server_x64.exe` and `arma3server.exe`. Derives 4 ports from `game_port` (game, steam_query, von, steam_auth). Default game port 2302, default RCon port game+4. +- **LogParser**: Parses Arma 3 `.rpt` log files. Resolves log path from server config or defaults to `server_dir/server/*.rpt`. +- **RemoteAdmin**: Implements BattlEye RCon protocol via `Arma3RemoteAdminFactory`. Supports login, command sending, player listing, kick, ban, say-all, and shutdown. +- **MissionManager**: Handles `.pbo` mission files and mission rotation config. +- **ModManager**: Builds `-mod=` and `-serverMod=` CLI arguments from mod list. +- **BanManager**: Syncs bans between the DB and `battleye/bans.txt`. -Every server has a `game_type` column. When core code needs game-specific behavior, it: +### Adapter Error Hierarchy -1. Reads `server.game_type` from DB -2. Resolves `adapter = GameAdapterRegistry.get(game_type)` -3. Calls the appropriate adapter method +Typed exceptions in `adapters/exceptions.py`: -**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() +- `AdapterError` (base) + - `ConfigWriteError` -- Atomic file write failed + - `ConfigValidationError` -- Pydantic model rejected config + - `ConfigMigrationError` -- Schema migration failed + - `LaunchArgsError` -- Launch args construction failed + - `RemoteAdminError` -- RCon connection/command failure (with `recoverable` flag) + - `ExeNotAllowedError` -- Executable not in adapter allowlist - # 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}") +### Adding a New Game Adapter - # Adapter generates config files - config_gen = adapter.get_config_generator() - config_gen.write_configs(server_id, server_dir, config_sections) +1. Create `adapters//` package with an adapter class implementing `GameAdapter` protocol +2. Create capability modules for supported protocols +3. Export a module-level adapter instance (e.g., `MYGAME_ADAPTER`) +4. Import it in `adapters/__init__.py`'s `load_builtin_adapters()` -- or register via `pyproject.toml` entry_points under `"languard.adapters"` - # Adapter builds launch args - launch_args = config_gen.build_launch_args(config_sections, mod_args) +No core code changes are required. - # 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) +## Authentication & Authorization + +### JWT Authentication + +- Algorithm: HS256 via `python-jose` +- Token payload: `{sub: user_id, username, role, exp}` +- Expiry: configurable via `LANGUARD_JWT_EXPIRE_HOURS` (default 24h) +- Secret: `LANGUARD_SECRET_KEY` env var (required) + +### Password Hashing + +- bcrypt via the `bcrypt` Python package +- `hash_password()` and `verify_password()` in `core/auth/utils.py` + +### Roles + +Two roles: `admin` and `viewer`. + +- **admin**: Full access -- server lifecycle, config editing, user management, RCon commands +- **viewer**: Read-only access -- can view servers, logs, metrics, players + +Authorization is enforced via FastAPI dependency injection: +- `get_current_user()` -- Decodes JWT, validates user exists in DB, returns user dict +- `require_admin()` -- Wraps `get_current_user()`, raises 403 if role is not "admin" + +### Default Admin Seeding + +On first run, if no users exist, a default admin user is created with username "admin" and a randomly generated 16-char password (`secrets.token_urlsafe(16)`). The password is printed to the server log at startup. + +--- + +## Encryption + +Sensitive config fields (RCON passwords, admin passwords) are encrypted at rest using Fernet (AES-256-CBC with HMAC) from the `cryptography` library. + +- Encryption key: `LANGUARD_ENCRYPTION_KEY` env var (Fernet base64 key, required) +- Format: encrypted values are stored as `encrypted:` in the database +- `ConfigRepository` handles encryption/decryption transparently -- reads decrypt, writes encrypt +- The `get_sensitive_fields()` method on each `ConfigGenerator` declares which field names in each section need encryption + +--- + +## Process Management + +### ProcessManager (Singleton) + +Owns all `subprocess.Popen` handles. Key behaviors: + +- **Start**: Validates server is not already running, launches with `subprocess.Popen`. On Windows, uses `CREATE_NO_WINDOW` flag to suppress console windows. +- **Stop**: Sends `terminate()`, waits up to 30 seconds, then force-kills if needed. +- **Kill**: Immediate force-kill via `proc.kill()`. +- **Operation locks**: Per-server `threading.Lock` serializes start/stop/restart for the same server ID. +- **PID recovery**: On startup, checks DB for servers marked "running". Uses `psutil.Process(pid)` to verify the PID is still alive and the process name matches the adapter's `get_allowed_executables()`. Invalid PIDs cause the server to be marked "crashed". Valid PIDs get a `_PsutilProcessWrapper` attached so the process can be monitored. + +### Exe Allowlist + +`ProcessConfig.get_allowed_executables()` returns a list of allowed executable names. This is checked during server creation and startup to prevent path traversal attacks. The check compares only the filename component (via `Path(exe_path).name`), not the full path. + +--- + +## Thread Architecture + +### ThreadRegistry + +Manages per-server background thread bundles. Created at app startup and stored on `app.state.thread_registry`. Also provides class-level convenience methods (`start_server_threads`, `stop_server_threads`, etc.) that delegate to the singleton instance. + +Each server gets up to 4 threads: + +| Thread | Started | Purpose | +|--------|---------|---------| +| `ProcessMonitorThread` | Always | Watches `Popen.poll()` and detects crashes; updates DB status; broadcasts status changes | +| `MetricsCollectorThread` | Always | Periodically collects CPU/RAM via `psutil.Process` and stores in `metrics` table | +| `LogTailThread` | If adapter has `log_parser` capability | Tails the game log file, parses lines via adapter, stores in `logs` table, broadcasts to WebSocket | +| `RemoteAdminPollerThread` | If adapter has `remote_admin` capability | Periodically polls player list via RCon, stores in `players` table, broadcasts to WebSocket | + +All threads inherit from `BaseServerThread` (a `threading.Thread` subclass with `stop_and_join()` and `stop_event`). + +### BroadcastThread + +A daemon thread that bridges `queue.Queue` (written by background threads) to `WebSocketManager.broadcast()` on the asyncio event loop. Uses `asyncio.run_coroutine_threadsafe()` to schedule broadcasts. If the event loop is closed, events are silently dropped (with periodic logging of drop count). + +--- + +## WebSocket System + +### Endpoint + +`/ws` -- JWT authentication via `token` query parameter (browser WebSocket API does not support custom headers). Optional `server_id` query parameters for subscription filtering. + +Connection flow: +1. Client connects with `token` and optional `server_id` params +2. Server validates JWT, closes with code `4001` if invalid +3. Server sends `{"type": "connected", "data": {"user": ..., "subscriptions": ...}}` +4. Server pushes events for subscribed server IDs + +### WebSocketManager (asyncio-side) + +- `connect(ws, server_ids)` -- Accept connection, register subscriptions +- `disconnect(ws)` -- Remove connection +- `broadcast(server_id, message)` -- Send to all clients subscribed to `server_id` or to `None` (global) +- `send_to_connection(ws, message)` -- Send to a single connection +- Automatically removes disconnected clients + +### Message Format + +```json +{ + "type": "server_status" | "metrics" | "log" | "players", + "server_id": 1, + "data": { ... } +} ``` --- -## Component Responsibilities +## Database -### FastAPI Routers (Core) -- Validate input (Pydantic models) -- 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 +### SQLite Configuration -### 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 +- Engine: `sqlite:///` (default: `./languard.db`) +- WAL mode enabled via `PRAGMA journal_mode=WAL` +- Foreign keys enforced via `PRAGMA foreign_keys=ON` +- Busy timeout: 5000ms via `PRAGMA busy_timeout=5000` +- All queries use `sqlalchemy.text()` with parameterized inputs (no ORM) -### ProcessManager (Core) -- Singleton that owns all subprocess handles -- Thread-safe dict: `{server_id: subprocess.Popen}` -- **Per-server operation lock** (`_operation_locks: dict[int, threading.Lock]`) — serializes start/stop/restart for the same server. Prevents race conditions when two admins hit "start" simultaneously or when start+stop overlap. Each server gets its own lock; different servers operate independently. -- `start()` sets `cwd=servers/{server_id}/` so relative config paths resolve correctly -- 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 +### Migration System -### Thread Pool (per running server) +Migrations are SQL files in `core/migrations/` named with a numeric prefix (e.g., `001_initial_schema.sql`). Applied in order. A `schema_migrations` table tracks which versions have been applied. -| 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 | +### Schema (Tables) -### 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. -- **Schema migration**: on read, if `schema_version` differs from `adapter.get_config_version()`, core calls `adapter.migrate_config(old_version, config_json)`. On success, updates the row with migrated JSON and new `schema_version`. On `ConfigMigrationError`, keeps original config and logs a warning. -- Provides: `get_section()`, `get_all_sections()`, `upsert_section()`, `delete_sections()` +| Table | Purpose | +|-------|---------| +| `users` | Auth -- id, username, password_hash, role (admin/viewer), timestamps | +| `servers` | Server instances -- id, name, game_type, status, pid, exe_path, ports, auto_restart settings | +| `game_configs` | Per-server config sections -- server_id, game_type, section, config_json, schema_version, config_version (optimistic locking) | +| `mods` | Mod registry -- game_type, name, folder_path, workshop_id, game_data | +| `server_mods` | Many-to-many server-mod with sort_order, is_server_mod flag | +| `missions` | Mission files -- server_id, filename, mission_name, terrain | +| `mission_rotation` | Ordered mission rotation for a server | +| `players` | Live player list per server -- slot_id, name, guid, ip, ping | +| `player_history` | Historical player sessions with join/leave timestamps | +| `bans` | Ban records -- guid, name, reason, banned_by, expires_at, is_active | +| `logs` | Parsed log entries -- server_id, timestamp, level, message | +| `metrics` | CPU/RAM/player_count time series -- server_id, timestamp, cpu_percent, ram_mb, player_count | +| `server_events` | Audit log -- server_id, event_type, actor, detail (JSON) | -### Adapter Exceptions (Standard Error Types) +### Thread-Local DB Connections -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 | -| `ConfigMigrationError` | `migrate_config()` fails to transform old schema | Keep original config, log warning, server runs with old schema | +Background threads use `database.get_thread_db()` which returns a thread-local `Connection`. Each thread gets its own connection (SQLite requirement). Connections must be closed in thread teardown. --- -## Data Flow: Start Server +## Configuration Management -``` -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 -``` +### Config Sections -## Data Flow: Real-time Logs +Each game adapter declares config sections via `ConfigGenerator.get_sections()`, returning a dict of `{section_name: PydanticModelClass}`. The Arma 3 adapter defines `server`, `rcon`, and `mission` sections. -``` -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 -``` +### Config Lifecycle -## Data Flow: Player List +1. **Creation**: When a server is created, `ConfigRepository.upsert_section()` is called for each section with adapter defaults. Sensitive fields are encrypted. +2. **Read**: `get_section()` and `get_all_sections()` decrypt sensitive fields transparently. +3. **Update**: `upsert_section()` supports optimistic locking via `expected_config_version`. On conflict, raises `ValueError` with `"CONFIG_VERSION_CONFLICT"` prefix, which the service layer translates to a 409 response with the current config data. +4. **Write to disk**: `ConfigGenerator.write_configs()` uses an atomic write pattern (write to `.tmp`, then `os.replace()`). On failure, temp files are cleaned up and `ConfigWriteError` is raised. +5. **Preview**: `ConfigGenerator.preview_config()` renders configs as strings without writing to disk. -``` -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 -``` +### Sensitive Field Encryption + +The `ConfigRepository` handles encryption/decryption transparently: + +- `_encrypt_sensitive()`: Encrypts specified fields using Fernet, prefixes with `"encrypted:"` +- `_decrypt_sensitive()`: Decrypts `"encrypted:"`-prefixed fields +- `is_encrypted()`: Checks if a value starts with `"encrypted:"` +- Non-encrypted values pass through decryption unchanged --- -## Security Model +## Scheduled Cleanup Jobs -- All API routes (except `POST /api/auth/login`) require a valid **JWT Bearer token** -- JWT contains: `user_id`, `username`, `role` (`admin` | `viewer`) -- `viewer` role: read-only (GET endpoints, WebSocket) -- `admin` role: all operations -- CORS configured to accept only the frontend origin -- Passwords hashed with **bcrypt** (cost factor 12) -- 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 +APScheduler `BackgroundScheduler` with a `ThreadPoolExecutor(max_workers=2)` runs three cleanup jobs: + +| Job | Schedule | Retention | Action | +|-----|----------|-----------|--------| +| `cleanup_old_logs` | Daily at 03:00 | 7 days | Delete log entries older than retention | +| `cleanup_old_metrics` | Every 6 hours | 1 day | Delete metrics older than retention | +| `cleanup_old_events` | Weekly Sunday 04:00 | 30 days | Delete server events older than retention | + +Each job creates a thread-local DB connection, runs the cleanup, commits, and closes the connection. --- -## Core vs Adapter Responsibility Boundary +## API Structure -| 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 | +All routes are prefixed with `/api` (except WebSocket at `/ws`). + +### Authentication (`/api/auth`) + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| POST | `/auth/login` | None | Login, returns JWT | +| POST | `/auth/logout` | Bearer | Client-side token deletion | +| GET | `/auth/me` | Bearer | Get current user | +| PUT | `/auth/password` | Bearer | Change password | +| GET | `/auth/users` | Admin | List all users | +| POST | `/auth/users` | Admin | Create user | +| DELETE | `/auth/users/{id}` | Admin | Delete user | + +### Servers (`/api/servers`) + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/servers` | Bearer | List servers (optional `game_type` filter) | +| POST | `/servers` | Admin | Create server | +| GET | `/servers/{id}` | Bearer | Get server details | +| PUT | `/servers/{id}` | Admin | Update server | +| DELETE | `/servers/{id}` | Admin | Delete server (must be stopped) | +| POST | `/servers/{id}/start` | Admin | Start server | +| POST | `/servers/{id}/stop` | Admin | Stop server (optional `force`) | +| POST | `/servers/{id}/restart` | Admin | Restart server | +| POST | `/servers/{id}/kill` | Admin | Force-kill server | +| GET | `/servers/{id}/config` | Bearer | Get all config sections (sensitive fields masked) | +| GET | `/servers/{id}/config/preview` | Admin | Preview config files without writing | +| GET | `/servers/{id}/config/{section}` | Bearer | Get single config section | +| PUT | `/servers/{id}/config/{section}` | Admin | Update config section (optimistic locking) | +| POST | `/servers/{id}/rcon/command` | Admin | Send RCon command | + +### Players, Bans, Missions, Mods (`/api/players`, `/api/bans`, `/api/missions`, `/api/mods`) + +Sub-routers under `/api` for their respective CRUD operations. All require Bearer auth; write operations require admin role. + +### Games (`/api/games`) + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/games` | None | List all registered game types with capabilities | +| GET | `/games/{game_type}` | None | Get game type details, config sections, allowed executables | +| GET | `/games/{game_type}/config-schema` | None | Get Pydantic JSON schemas for config sections | +| GET | `/games/{game_type}/defaults` | None | Get default config values | + +### System (`/api/system`) + +| Method | Path | Auth | Description | +|--------|------|------|-------------| +| GET | `/system/health` | None | Health check | +| GET | `/system/status` | Bearer | System status (running/total servers, supported games) | + +### WebSocket (`/ws`) + +| Path | Auth | Description | +|------|------|-------------| +| `/ws?token=JWT` | JWT query param | Connect to real-time event stream | +| `/ws?token=JWT&server_id=1&server_id=2` | JWT query param | Subscribe to specific servers | --- -## Configuration (Environment Variables) +## Frontend Architecture -```env -LANGUARD_SECRET_KEY= -LANGUARD_ENCRYPTION_KEY= -LANGUARD_DB_PATH=./languard.db -LANGUARD_SERVERS_DIR=./servers -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 +### Tech Stack + +- **React 19** with **TypeScript 6** +- **Vite 8** for bundling and dev server +- **Tailwind CSS 3** for styling with a custom dark neumorphic design system +- **Zustand 5** for client state (auth, UI) +- **TanStack Query 5** for server state +- **React Router 7** for routing +- **Axios** for HTTP client +- **React Hook Form + Zod** for form validation (login page) +- **Lucide React** for icons + +### Design System + +Dark neumorphic theme defined in `tailwind.config.js`: + +- **Surface colors**: `surface-base` (#1a1a2e), `surface-raised` (#1e1e35), `surface-recessed` (#16162a), `surface-overlay` (#22223a) +- **Accent**: Amber (#f59e0b) with bright, dim, and glow variants +- **Status colors**: `running` (green), `stopped` (gray), `crashed` (red), `starting` (amber), `restarting` (blue) +- **Neumorphic shadows**: `shadow-neu-raised`, `shadow-neu-raised-lg`, `shadow-neu-recessed` +- **Glow shadows**: `shadow-glow-green`, `shadow-glow-amber`, `shadow-glow-red`, `shadow-glow-blue` +- **Animations**: `pulse-slow` (3s), `glow-pulse` (2s opacity fade) +- **Font**: Inter (UI) + JetBrains Mono (code) +- **Component classes**: `.neu-card`, `.neu-input`, `.btn-primary`, `.btn-ghost`, `.btn-danger`, `.status-led` + +### Application Structure -# 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 ``` +App.tsx + BrowserRouter + QueryClientProvider (staleTime: 10s, retry: 2, no refetchOnWindowFocus) + ReactQueryDevtools + Routes: + /login -> LoginPage + /* -> ProtectedLayout (requires auth) + Sidebar + main content area + / -> DashboardPage + /servers/:serverId -> ServerDetailPage (7 tabs: overview, config, players, bans, missions, mods, logs) + /servers/new -> CreateServerPage (4-step wizard, admin only) + /settings -> SettingsPage (Account + Users tabs) +``` + +### State Management + +**Auth Store** (`store/auth.store.ts`): +- Zustand store with `persist` middleware (localStorage key: `languard-auth`) +- State: `token`, `user` (id, username, role), `isAuthenticated` +- Actions: `setAuth(token, user)`, `clearAuth()` +- On rehydration, sets `isAuthenticated = true` if token exists + +**UI Store** (`store/ui.store.ts`): +- Zustand store (in-memory, not persisted) +- State: `sidebarOpen`, `activeServerId`, `notifications[]` +- Actions: `toggleSidebar()`, `setActiveServer()`, `addNotification()`, `removeNotification()` +- Notifications auto-dismiss after 5 seconds + +### API Client (`lib/api.ts`) + +Axios instance with: +- Base URL from `VITE_API_URL` env var (default: `http://localhost:8000`) +- 30-second timeout +- Request interceptor: adds `Authorization: Bearer ` header from localStorage +- Response interceptor: on 401, clears token and redirects to `/login` (except for `/api/auth/` endpoints) +- Type: `ApiResponse = { success: boolean; data: T; error?: string }` + +### WebSocket Hook (`hooks/useWebSocket.ts`) + +- Connects to `VITE_WS_URL/ws?token=[&server_id=]` +- Exponential backoff reconnect: starts at 2 seconds, doubles up to 30 seconds max +- On message, invalidates relevant TanStack Query cache keys based on event type +- On close with code 4001 (auth failure), does not reconnect +- Cleanup on unmount: closes WebSocket, clears reconnect timer + +### Server Data Hooks (`hooks/useServers.ts`) + +TanStack Query hooks wrapping the API client: +- `useServers()` -- Lists all servers, refetches every 30 seconds +- `useServer(id)` -- Gets single server +- `useStartServer()`, `useStopServer()`, `useRestartServer()`, `useCreateServer()`, `useDeleteServer()` -- Mutation hooks that invalidate relevant query caches on success + +### Vite Dev Proxy + +In development, Vite proxies: +- `/api` -> `http://localhost:8000` +- `/ws` -> `ws://localhost:8000` (WebSocket upgrade) + +### Tests + +- **Unit tests** (12 files): Vitest + React Testing Library + jsdom + - Component tests: `DashboardPage`, `LoginPage`, `ServerCard`, `Sidebar`, `StatusLed` + - Hook tests: `useServers`, `useWebSocket` + - Store tests: `auth.store`, `ui.store` + - Lib tests: `api` (Axios interceptors) + +- **E2E tests** (3 specs): Playwright + - `auth/login.spec.ts` -- Login flow + - `dashboard/dashboard.spec.ts` -- Dashboard interactions + - `integration/fullstack.spec.ts` -- Full-stack integration + +--- + +## Security Measures + +1. **JWT Authentication**: All API routes except `/api/auth/login` and `/api/games/*` and `/api/system/health` require a Bearer token +2. **Role-based Authorization**: `admin` and `viewer` roles enforced via dependency injection +3. **Fernet Encryption**: Sensitive config fields (RCon passwords, admin passwords) encrypted at rest with AES-256 +4. **Exe Allowlist**: `ProcessConfig.get_allowed_executables()` prevents path traversal by validating executable names +5. **Filename Sanitization**: `sanitize_filename()` in `file_utils.py` strips path separators, null bytes, control characters, and collapses consecutive dots +6. **Atomic Config Writes**: Config files written to `.tmp` first, then `os.replace()` for atomic rename +7. **Optimistic Locking**: Config updates require `config_version` to prevent lost updates +8. **Port Conflict Detection**: `check_ports_against_running_servers()` prevents port collisions between servers +9. **CORS**: Configurable origins via `LANGUARD_CORS_ORIGINS` +10. **Password Hashing**: bcrypt for all user passwords +11. **WebSocket Auth**: JWT via query parameter (browser WebSocket limitation); invalid tokens close with code 4001 + +--- + +## Environment Variables + +All prefixed with `LANGUARD_`: + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `LANGUARD_SECRET_KEY` | Yes | -- | JWT signing key | +| `LANGUARD_ENCRYPTION_KEY` | Yes | -- | Fernet base64 key for config field encryption | +| `LANGUARD_DB_PATH` | No | `./languard.db` | SQLite database file path | +| `LANGUARD_SERVERS_DIR` | No | `./servers` | Base directory for server instances | +| `LANGUARD_HOST` | No | `0.0.0.0` | Server bind address | +| `LANGUARD_PORT` | No | `8000` | Server bind port | +| `LANGUARD_CORS_ORIGINS` | No | `["http://localhost:5173"]` | JSON array of allowed origins | +| `LANGUARD_LOG_RETENTION_DAYS` | No | `7` | Days to retain log entries | +| `LANGUARD_METRICS_RETENTION_DAYS` | No | `30` | Days to retain metrics | +| `LANGUARD_PLAYER_HISTORY_RETENTION_DAYS` | No | `90` | Days to retain player history | +| `LANGUARD_JWT_EXPIRE_HOURS` | No | `24` | JWT token lifetime | +| `LANGUARD_LOGIN_RATE_LIMIT` | No | `5/minute` | Rate limit for login endpoint | +| `LANGUARD_ARMA3_DEFAULT_EXE` | No | `C:/Arma3Server/arma3server_x64.exe` | Default Arma 3 server executable path | + +Configuration uses `pydantic-settings` with `SettingsConfigDict` (env prefix `LANGUARD_`, `.env` file support). --- ## Directory Layout -``` -languard-servers-manager/ -├── backend/ -│ ├── main.py # FastAPI app factory -│ ├── config.py # Settings from env -│ ├── database.py # SQLAlchemy engine + session -│ ├── 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}/ # 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 -├── DATABASE.md -├── 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: +### Backend ``` -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 +backend/ + main.py # FastAPI app factory, lifespan, CORS, routers + config.py # Pydantic Settings class (env vars) + database.py # SQLAlchemy engine, get_db(), get_thread_db(), migrations + dependencies.py # FastAPI dependencies: get_current_user, require_admin, get_adapter_for_server + requirements.txt # Python dependencies + adapters/ + __init__.py # initialize_adapters() -- loads built-in + third-party + protocols.py # 7 capability Protocols + GameAdapter composite + registry.py # GameAdapterRegistry class-level singleton + exceptions.py # Typed adapter exceptions + arma3/ + __init__.py # Exports ARMA3_ADAPTER + adapter.py # Arma3Adapter (implements all 7 capabilities) + config_generator.py # Arma3ConfigGenerator (server.cfg, launch args) + process_config.py # Arma3ProcessConfig (exe allowlist, port conventions) + log_parser.py # RPTParser (Arma 3 .rpt log format) + remote_admin.py # Arma3RemoteAdminFactory (BattlEye RCon) + rcon_client.py # BEClient (BattlEye RCon protocol) + rcon_service.py # RCon command helpers + mission_manager.py # Arma3MissionManager (.pbo missions) + mod_manager.py # Arma3ModManager (mod CLI args) + ban_manager.py # Arma3BanManager (ban file sync) + migrations/ # Game-specific migration files + core/ + __init__.py + auth/ + router.py # Auth API routes + service.py # AuthService (login, create_user, change_password, seed_admin) + utils.py # JWT create/decode, password hash/verify + schemas.py # Pydantic request models + servers/ + router.py # Server CRUD + lifecycle + config + RCon routes + service.py # ServerService (orchestrates all server operations) + process_manager.py # ProcessManager singleton (Popen lifecycle + PID recovery) + schemas.py # Pydantic request/response models + players_router.py # Players API routes + bans_router.py # Bans API routes + missions_router.py # Missions API routes + mods_router.py # Mods API routes + games/ + router.py # Game type info routes + system/ + router.py # Health and status routes + dal/ + __init__.py + base_repository.py # BaseRepository with SQL helpers + server_repository.py # Server CRUD operations + config_repository.py # Config section CRUD with Fernet encryption + player_repository.py # Player operations + log_repository.py # Log operations + metrics_repository.py # Metrics operations + cleanup + event_repository.py # Server event operations + cleanup + ban_repository.py # Ban operations + threads/ + thread_registry.py # ThreadRegistry (manages per-server thread bundles) + process_monitor.py # ProcessMonitorThread + metrics_collector.py # MetricsCollectorThread + log_tail.py # LogTailThread + remote_admin_poller.py # RemoteAdminPollerThread + websocket/ + __init__.py + manager.py # WebSocketManager (asyncio-side, subscription model) + broadcast_thread.py # BroadcastThread (Queue -> asyncio bridge) + router.py # /ws endpoint with JWT auth + utils/ + __init__.py + crypto.py # Fernet encrypt/decrypt for config fields + file_utils.py # Server dir helpers, filename sanitization + port_checker.py # Port availability checking + jobs/ + __init__.py + scheduler.py # APScheduler BackgroundScheduler setup + cleanup_jobs.py # Registered cleanup jobs (logs, metrics, events) + migrations/ + 001_initial_schema.sql # Initial database schema + logs/ # Log parsing (not a module, handled by log_tail thread) + metrics/ # Metrics collection (not a module, handled by metrics thread) + players/ # Player tracking (not a module, handled by RCon poller) + events/ # Event tracking (not a module, handled by services) ``` -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 +### Frontend + +``` +frontend/ + src/ + App.tsx # BrowserRouter + QueryClientProvider + ProtectedLayout + main.tsx # React entry point + index.css # Tailwind imports + neumorphic component classes + vite-env.d.ts # Vite type declarations + pages/ + LoginPage.tsx # Login form (react-hook-form + zod validation) + DashboardPage.tsx # Server dashboard with ServerCard grid + components/ + layout/ + Sidebar.tsx # Sidebar navigation + servers/ + ServerCard.tsx # Server status card with lifecycle buttons + ui/ + StatusLed.tsx # Status LED indicator component + hooks/ + useServers.ts # TanStack Query hooks for server CRUD + lifecycle + useWebSocket.ts # WebSocket hook with exponential backoff reconnect + store/ + auth.store.ts # Zustand auth store (persisted to localStorage) + ui.store.ts # Zustand UI store (sidebar, notifications) + lib/ + api.ts # Axios client with auth interceptors + __tests__/ # 12 unit test files (Vitest) + tests-e2e/ + auth/ + login.spec.ts # Login E2E test + dashboard/ + dashboard.spec.ts # Dashboard E2E test + integration/ + fullstack.spec.ts # Full-stack integration test + pages/ + LoginPage.ts # Playwright page object + DashboardPage.ts # Playwright page object + index.html # Vite HTML entry + vite.config.ts # Vite config with @/ alias and dev proxy + tailwind.config.js # Custom dark neumorphic design tokens + package.json # Dependencies and scripts +``` --- ## Key Design Decisions -| Decision | Choice | Reason | -|----------|--------|--------| -| 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 | -| Server operation safety | **Per-server operation lock** | ProcessManager holds a lock per server_id that serializes start/stop/restart. Two concurrent start requests for the same server: the second waits for the first to complete, then sees the server is already running. Different servers are independent (no cross-server locking). | -| 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, core calls `adapter.migrate_config(old_version, config_json)` which returns the migrated dict. On migration failure (ConfigMigrationError), core keeps the original config and logs a warning — the server can still run with the old schema. | -| 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 +1. **Raw SQL over ORM**: The project uses SQLAlchemy's `text()` for all queries instead of the ORM. This keeps queries explicit and avoids hidden N+1 problems. The `BaseRepository` provides thin helpers (`_execute`, `_fetchone`, `_fetchall`, `_lastrowid`). + +2. **Synchronous DB access**: All database operations are synchronous. This is appropriate for SQLite (single-writer) and avoids the complexity of async SQLAlchemy. FastAPI runs sync route handlers in a thread pool by default. + +3. **Thread-per-server model**: Each running server gets up to 4 background threads (monitor, metrics, log tail, RCon poller). This avoids the complexity of async subprocess management while keeping operations isolated per server. + +4. **Queue-based broadcast**: Background threads write to a `queue.Queue`, and a `BroadcastThread` bridges to the asyncio event loop via `asyncio.run_coroutine_threadsafe()`. This avoids thread-safety issues with the WebSocket manager. + +5. **Adapter Protocol system**: Using `Protocol` classes with `@runtime_checkable` allows duck-typing with `isinstance()` checks while maintaining type safety. Adapters are resolved by `game_type` string at runtime. + +6. **Optimistic locking for configs**: The `config_version` column in `game_configs` prevents lost updates when multiple users edit configs concurrently. On version conflict, the API returns 409 with the current config data. + +7. **Fernet encryption for secrets**: Sensitive fields are encrypted transparently in `ConfigRepository`. The `encrypted:` prefix distinguishes encrypted values from plaintext, allowing gradual migration. + +8. **Atomic config file writes**: Config files are written to temporary `.tmp` files first, then `os.replace()` renames them to the final path. This prevents partial writes on crash. + +9. **PID recovery on restart**: `ProcessManager.recover_on_startup()` uses `psutil` to check if a PID from a previous run is still alive and running an allowed executable. This handles the case where the Languard process restarts but game servers are still running. \ No newline at end of file diff --git a/DATABASE.md b/DATABASE.md index 4948540..663a1e1 100644 --- a/DATABASE.md +++ b/DATABASE.md @@ -1,25 +1,35 @@ -# Languard Servers Manager — Database Design +# Languard Servers Manager -- Database Schema -## Engine -- **SQLite** via `SQLAlchemy Core` (sync for all access — routes and threads) -- File: `languard.db` at project root (configurable via `LANGUARD_DB_PATH`) -- WAL mode enabled: `PRAGMA journal_mode=WAL` — allows concurrent reads during writes -- Foreign keys enabled: `PRAGMA foreign_keys=ON` -- Busy timeout: `PRAGMA busy_timeout=5000` — prevents "database is locked" errors under concurrent thread writes -- **Retry on exhaustion**: If busy_timeout is exceeded (5s), writes fail with `OperationalError("database is locked")`. Background threads retry with exponential backoff (1s, 2s, 4s), then skip the tick. API handlers retry up to 2 times with 1s backoff, then return 503. See THREADING.md for the full retry implementation. +## Engine Configuration + +| Setting | Value | +|---------|-------| +| Engine | SQLite via SQLAlchemy Core (sync) | +| File | `languard.db` (configurable via `LANGUARD_DB_PATH`) | +| Journal mode | WAL (`PRAGMA journal_mode=WAL`) | +| Foreign keys | ON (`PRAGMA foreign_keys=ON`) | +| Busy timeout | 5000ms (`PRAGMA busy_timeout=5000`) | +| Query style | Raw SQL through `sqlalchemy.text()` -- no ORM | +| Concurrent access | WAL allows concurrent reads during writes; background threads use thread-local connections via `get_thread_db()` | +| Connection model | API requests use `get_db()` (connect-commit/rollback per request); background threads use `get_thread_db()` (thread-local, manually closed) | + +### Why WAL Mode + +SQLite in WAL mode permits simultaneous readers while a writer holds an exclusive lock. This is essential for the server manager architecture where a metrics collector thread, log tail thread, and API request handler may all access the database concurrently. The 5-second busy timeout prevents immediate "database is locked" failures under contention; if exceeded, the caller retries with exponential backoff. --- ## 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. +The schema uses a **hybrid approach**: core tables are fully normalized and game-agnostic, while game-specific configuration is stored as JSON blobs in a generic `game_configs` table. Adapter-provided Pydantic models validate the JSON 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 + +- Config is always read and written as a complete section; no one queries individual keys across servers. +- Each adapter section maps to a Pydantic model, so validation is enforced at the application boundary. +- The JSON is opaque to the core -- meaningful only to the adapter that owns it. +- Adding a new game requires **zero database migration** -- just a new adapter. +- Core queries across all games (player counts, server status, logs) work naturally through normalized columns. --- @@ -27,102 +37,187 @@ The database uses a **hybrid approach**: core tables are fully normalized (game- ### Table: `users` -Stores web UI admin accounts. +Web UI authentication accounts. ```sql -CREATE TABLE users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT NOT NULL UNIQUE, - password_hash TEXT NOT NULL, -- bcrypt hash - role TEXT NOT NULL DEFAULT 'viewer', -- 'admin' | 'viewer' - CHECK (role IN ('admin', 'viewer')), - created_at TEXT NOT NULL DEFAULT (datetime('now')), - last_login TEXT +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'viewer', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_login TEXT, + CHECK (role IN ('admin', 'viewer')) ); ``` +| Column | Type | Constraints | Description | +|--------|------|-------------|-------------| +| `id` | INTEGER | PK, AUTOINCREMENT | Row identifier | +| `username` | TEXT | NOT NULL, UNIQUE | Login username | +| `password_hash` | TEXT | NOT NULL | bcrypt hash of the user's password | +| `role` | TEXT | NOT NULL, DEFAULT `'viewer'` | Authorization role. Allowed values: `admin`, `viewer` | +| `created_at` | TEXT | NOT NULL, DEFAULT `datetime('now')` | Account creation timestamp (ISO 8601) | +| `last_login` | TEXT | nullable | Most recent successful login timestamp | + +**Notes:** + +- Only two roles exist: `admin` (full access) and `viewer` (read-only). The `CHECK` constraint enforces this at the database level. +- `password_hash` stores bcrypt hashes, never plaintext. +- No indexes beyond the implicit UNIQUE index on `username`. + --- ### Table: `servers` -One row per managed server instance. **Game-agnostic.** +One row per managed server instance. Game-agnostic; the `game_type` column determines which adapter handles the server. ```sql -CREATE TABLE servers ( +CREATE TABLE IF NOT EXISTS servers ( id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, -- display name in UI + name TEXT NOT NULL, description TEXT, - game_type TEXT NOT NULL DEFAULT 'arma3', -- adapter lookup key + game_type TEXT NOT NULL DEFAULT 'arma3', status TEXT NOT NULL DEFAULT 'stopped', - CHECK (status IN ('stopped', 'starting', 'running', 'stopping', 'crashed', 'error')), - - -- Process info - pid INTEGER, -- OS process ID when running - exe_path TEXT NOT NULL, -- path to server executable - started_at TEXT, -- ISO datetime + pid INTEGER, + exe_path TEXT NOT NULL, + started_at TEXT, stopped_at TEXT, - - -- 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 - max_restarts INTEGER NOT NULL DEFAULT 3, -- within restart_window_seconds + rcon_port INTEGER, + auto_restart INTEGER NOT NULL DEFAULT 0, + max_restarts INTEGER NOT NULL DEFAULT 3, restart_window_seconds INTEGER NOT NULL DEFAULT 300, restart_count INTEGER NOT NULL DEFAULT 0, last_restart_at TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + CHECK (status IN ('stopped','starting','running','stopping','crashed','error')), + CHECK (game_port BETWEEN 1024 AND 65535), + CHECK (rcon_port IS NULL OR (rcon_port BETWEEN 1024 AND 65535)) ); -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 IF NOT EXISTS idx_servers_status ON servers(status); +CREATE INDEX IF NOT EXISTS idx_servers_game_type ON servers(game_type); +CREATE INDEX IF NOT EXISTS idx_servers_game_port ON servers(game_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) +| Column | Type | Constraints | Description | +|--------|------|-------------|-------------| +| `id` | INTEGER | PK, AUTOINCREMENT | Row identifier | +| `name` | TEXT | NOT NULL | Display name in the UI | +| `description` | TEXT | nullable | Optional free-text description | +| `game_type` | TEXT | NOT NULL, DEFAULT `'arma3'` | Adapter lookup key. Determines which game adapter handles this server | +| `status` | TEXT | NOT NULL, DEFAULT `'stopped'` | Current server state. Allowed: `stopped`, `starting`, `running`, `stopping`, `crashed`, `error` | +| `pid` | INTEGER | nullable | OS process ID when the server is running. NULL when stopped | +| `exe_path` | TEXT | NOT NULL | Absolute path to the server executable | +| `started_at` | TEXT | nullable | ISO datetime when the server last entered `running` status | +| `stopped_at` | TEXT | nullable | ISO datetime when the server last left `running` status | +| `game_port` | INTEGER | NOT NULL, 1024-65535 | Primary game port (the port players connect to) | +| `rcon_port` | INTEGER | nullable, 1024-65535 | Remote console port. NULL if the game has no remote admin protocol | +| `auto_restart` | INTEGER | NOT NULL, DEFAULT `0` | 1 = restart automatically on crash, 0 = no auto-restart | +| `max_restarts` | INTEGER | NOT NULL, DEFAULT `3` | Maximum crash restarts allowed within `restart_window_seconds` | +| `restart_window_seconds` | INTEGER | NOT NULL, DEFAULT `300` | Rolling window (in seconds) for counting consecutive restarts | +| `restart_count` | INTEGER | NOT NULL, DEFAULT `0` | Current number of restarts within the window. Reset when the window expires | +| `last_restart_at` | TEXT | nullable | ISO datetime of the most recent auto-restart | +| `created_at` | TEXT | NOT NULL, DEFAULT `datetime('now')` | Server creation timestamp | +| `updated_at` | TEXT | NOT NULL, DEFAULT `datetime('now')` | Last modification timestamp | + +**Indexes:** + +| Index | Columns | Purpose | +|-------|---------|---------| +| `idx_servers_status` | `status` | Filter servers by status (e.g., all running servers) | +| `idx_servers_game_type` | `game_type` | Filter servers by game type | +| `idx_servers_game_port` | `game_port` | Prevent port collisions; fast lookup by port | + +**Auto-restart behavior:** When a server crashes and `auto_restart = 1`, the system increments `restart_count`. If `restart_count` exceeds `max_restarts` within the last `restart_window_seconds`, auto-restart is disabled and a `max_restarts_exceeded` event is logged. The count resets when the server stays up for longer than `restart_window_seconds`. --- ### Table: `game_configs` -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. +Stores all game-specific configuration as JSON blobs, keyed by section. Replaces what would otherwise be multiple per-game config tables. ```sql -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')), +CREATE TABLE IF NOT EXISTS 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.0', + 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); +CREATE INDEX IF NOT EXISTS idx_game_configs_server ON game_configs(server_id); +CREATE INDEX IF NOT EXISTS 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 +| Column | Type | Constraints | Description | +|--------|------|-------------|-------------| +| `id` | INTEGER | PK, AUTOINCREMENT | Row identifier | +| `server_id` | INTEGER | NOT NULL, FK -> `servers(id)` CASCADE DELETE | Owning server. Deleting the server removes all its config sections | +| `game_type` | TEXT | NOT NULL | Redundant with `servers.game_type`, stored here for indexing without a JOIN. Must match the parent server's `game_type` | +| `section` | TEXT | NOT NULL | Config section name (e.g., `server`, `basic`, `profile`, `launch`, `rcon` for Arma 3) | +| `config_json` | TEXT | NOT NULL, DEFAULT `'{}'` | JSON blob validated by the adapter's Pydantic model. Sensitive fields are Fernet-encrypted before serialization | +| `config_version` | INTEGER | NOT NULL, DEFAULT `1` | Optimistic locking version counter. Incremented on every write | +| `schema_version` | TEXT | NOT NULL, DEFAULT `'1.0.0'` | Adapter schema version at the time of last write. Used for automatic config migration | +| `updated_at` | TEXT | NOT NULL, DEFAULT `datetime('now')` | Last write timestamp | -**Arma 3 config sections and their JSON structures:** +**Unique constraint:** `(server_id, section)` -- each server can have at most one config section of each type. -Section `server` — maps to `server.cfg` parameters: +**Indexes:** + +| Index | Columns | Purpose | +|-------|---------|---------| +| `idx_game_configs_server` | `server_id` | Load all config for a server | +| `idx_game_configs_type_section` | `game_type, section` | Look up all configs of a given game type and section (e.g., all Arma 3 `rcon` sections) | + +#### Fernet Encryption of Sensitive Fields + +Sensitive fields within `config_json` (passwords, RCON passwords, admin passwords) are encrypted at the application layer before JSON serialization and storage. The `core.utils.crypto` module handles this: + +- **Algorithm:** Fernet (AES-256-CBC with HMAC-SHA256 for authentication) +- **Key source:** `LANGUARD_ENCRYPTION_KEY` environment variable (Fernet base64 key) +- **Format:** Encrypted values are stored as `"encrypted:"` within the JSON blob +- **Transparency:** `ConfigRepository._encrypt_sensitive()` and `_decrypt_sensitive()` handle encryption/decryption around JSON serialization. The adapter layer sees only plaintext. + +The adapter declares which fields are sensitive via `ConfigGenerator.get_sensitive_fields(section) -> list[str]`. For Arma 3: + +- Section `server`: `password`, `password_admin`, `server_command_password` +- Section `rcon`: `rcon_password` + +On write, `ConfigRepository.upsert_section()` calls `_encrypt_sensitive()` which replaces declared fields with `"encrypted:..."` tokens. On read, `ConfigRepository.get_section()` calls `_decrypt_sensitive()` which restores plaintext. The `is_encrypted()` check (`value.startswith("encrypted:")`) ensures that already-encrypted values are not double-encrypted, and plaintext values pass through unchanged for backward compatibility. + +#### Optimistic Locking + +The `config_version` column prevents lost updates when two admins edit simultaneously: + +1. Client reads config section, receives `_meta.config_version` (e.g., `3`) +2. Client sends PUT with `config_version: 3` in the request body +3. Server checks: if `expected_config_version` is provided and differs from the stored version, raise `ValueError` with `"CONFIG_VERSION_CONFLICT:"` +4. On conflict: return **409 Conflict** with the current config so the client can merge +5. On success: increment `config_version`, write new JSON, return the new version + +This is implemented in `ConfigRepository.upsert_section()`. + +#### Config Schema Migration + +When the adapter is updated and `get_config_version()` returns a newer version than `game_configs.schema_version`, the core automatically migrates: + +1. On read, detect `stored_schema_version != adapter.get_config_version()` +2. Call `adapter.migrate_config(stored_schema_version, config_json)` -- returns migrated dict +3. Update the row: `SET config_json = ?, schema_version = ? WHERE id = ?` +4. On `ConfigMigrationError`: keep original config, log warning, server runs with the old schema + +Migration is per-section -- each section can have a different stored version. + +#### Arma 3 Config Sections + +Section `server` -- maps to `server.cfg` parameters: ```json { "hostname": "My Arma 3 Server", @@ -184,7 +279,7 @@ Section `server` — maps to `server.cfg` parameters: } ``` -Section `basic` — maps to `basic.cfg` parameters: +Section `basic` -- maps to `basic.cfg` parameters: ```json { "min_bandwidth": 800000, @@ -197,7 +292,7 @@ Section `basic` — maps to `basic.cfg` parameters: } ``` -Section `profile` — maps to `server.Arma3Profile` difficulty: +Section `profile` -- maps to `server.Arma3Profile` difficulty: ```json { "reduced_damage": 0, @@ -229,7 +324,7 @@ Section `profile` — maps to `server.Arma3Profile` difficulty: } ``` -Section `launch` — maps to CLI launch parameters: +Section `launch` -- maps to CLI launch parameters: ```json { "world": "empty", @@ -248,7 +343,7 @@ Section `launch` — maps to CLI launch parameters: } ``` -Section `rcon` — BattlEye RCon settings: +Section `rcon` -- BattlEye RCon settings: ```json { "rcon_password": "encrypted:...", @@ -261,34 +356,67 @@ Section `rcon` — BattlEye RCon settings: ### Table: `mods` -Registered mods. Many-to-many with servers. Scoped by `game_type`. +Registered mods. Scoped by `game_type` to keep the mod catalog per-game. ```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, - workshop_id TEXT, -- Steam Workshop ID if applicable - description TEXT, - game_data TEXT DEFAULT '{}', -- JSON for game-specific mod metadata - created_at TEXT NOT NULL DEFAULT (datetime('now')), +CREATE TABLE IF NOT EXISTS mods ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + game_type TEXT NOT NULL, + name TEXT NOT NULL, + folder_path TEXT NOT NULL, + workshop_id TEXT, + description TEXT, + game_data TEXT DEFAULT '{}', + 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 = server-side only (not broadcast to clients) - sort_order INTEGER NOT NULL DEFAULT 0, - game_data TEXT DEFAULT '{}', -- JSON for per-server mod overrides +| Column | Type | Constraints | Description | +|--------|------|-------------|-------------| +| `id` | INTEGER | PK, AUTOINCREMENT | Row identifier | +| `game_type` | TEXT | NOT NULL | Game this mod belongs to (e.g., `arma3`). Prevents mod name collisions across games | +| `name` | TEXT | NOT NULL | Human-readable mod name | +| `folder_path` | TEXT | NOT NULL | Relative or absolute path to the mod folder (e.g., `@ace`) | +| `workshop_id` | TEXT | nullable | Steam Workshop ID, if the mod was installed from Steam | +| `description` | TEXT | nullable | Free-text description of the mod | +| `game_data` | TEXT | DEFAULT `'{}'` | JSON blob for game-specific mod metadata. For Arma 3 this is typically `{}` | +| `created_at` | TEXT | NOT NULL, DEFAULT `datetime('now')` | Registration timestamp | + +**Unique constraint:** `(game_type, folder_path)` -- a mod folder path is unique within a game type. + +**No separate index beyond the UNIQUE constraint** -- lookups are by `game_type` or by `id` (via `server_mods`). + +--- + +### Table: `server_mods` + +Junction table for the many-to-many relationship between servers and mods. + +```sql +CREATE TABLE IF NOT EXISTS 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, + sort_order INTEGER NOT NULL DEFAULT 0, + game_data TEXT DEFAULT '{}', PRIMARY KEY (server_id, mod_id) ); -CREATE INDEX idx_server_mods_server ON server_mods(server_id); +CREATE INDEX IF NOT EXISTS 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. +| Column | Type | Constraints | Description | +|--------|------|-------------|-------------| +| `server_id` | INTEGER | NOT NULL, FK -> `servers(id)` CASCADE DELETE | Owning server | +| `mod_id` | INTEGER | NOT NULL, FK -> `mods(id)` CASCADE DELETE | Referenced mod | +| `is_server_mod` | INTEGER | NOT NULL, DEFAULT `0` | 1 = server-side only mod (not broadcast to clients); 0 = regular mod loaded by all | +| `sort_order` | INTEGER | NOT NULL, DEFAULT `0` | Load order position. Lower values load first | +| `game_data` | TEXT | DEFAULT `'{}'` | JSON blob for per-server mod overrides | + +**Composite primary key:** `(server_id, mod_id)` -- each mod can only be added once to a given server. + +**Cascade deletes:** Removing a server deletes its `server_mods` rows. Removing a mod deletes all `server_mods` rows referencing it. --- @@ -297,533 +425,472 @@ CREATE INDEX idx_server_mods_server ON server_mods(server_id); 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, -- 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')), +CREATE TABLE IF NOT EXISTS missions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + filename TEXT NOT NULL, + mission_name TEXT NOT NULL, + terrain TEXT, + file_size INTEGER, + game_data TEXT DEFAULT '{}', + uploaded_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE (server_id, filename) ); -CREATE INDEX idx_missions_server ON missions(server_id); +CREATE INDEX IF NOT EXISTS 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. +| Column | Type | Constraints | Description | +|--------|------|-------------|-------------| +| `id` | INTEGER | PK, AUTOINCREMENT | Row identifier | +| `server_id` | INTEGER | NOT NULL, FK -> `servers(id)` CASCADE DELETE | Owning server | +| `filename` | TEXT | NOT NULL | Mission file name (e.g., `MyMission.Altis.pbo`) | +| `mission_name` | TEXT | NOT NULL | Parsed mission display name (extracted by the adapter) | +| `terrain` | TEXT | nullable | Map/terrain name (e.g., `Altis`). NULL for games that do not use the mission/terrain naming convention | +| `file_size` | INTEGER | nullable | File size in bytes | +| `game_data` | TEXT | DEFAULT `'{}'` | JSON for game-specific mission metadata | +| `uploaded_at` | TEXT | NOT NULL, DEFAULT `datetime('now')` | Upload timestamp | + +**Unique constraint:** `(server_id, filename)` -- each filename is unique within a server's mission pool. --- ### Table: `mission_rotation` -Ordered mission/scenario cycle for a server. +Ordered mission/scenario cycle for a server. Defines which missions are played and in what order. ```sql -CREATE TABLE mission_rotation ( +CREATE TABLE IF NOT EXISTS mission_rotation ( id INTEGER PRIMARY KEY AUTOINCREMENT, 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, -- 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 + difficulty TEXT, + params_json TEXT NOT NULL DEFAULT '{}', + game_data TEXT DEFAULT '{}', UNIQUE (server_id, sort_order) ); -CREATE INDEX idx_mission_rotation_server ON mission_rotation(server_id); +CREATE INDEX IF NOT EXISTS idx_mission_rotation_server ON mission_rotation(server_id); ``` -**Key changes:** `difficulty` is now nullable. Added `game_data` for adapter-specific rotation config. +| Column | Type | Constraints | Description | +|--------|------|-------------|-------------| +| `id` | INTEGER | PK, AUTOINCREMENT | Row identifier | +| `server_id` | INTEGER | NOT NULL, FK -> `servers(id)` CASCADE DELETE | Owning server | +| `mission_id` | INTEGER | NOT NULL, FK -> `missions(id)` CASCADE DELETE | Referenced mission | +| `sort_order` | INTEGER | NOT NULL, DEFAULT `0` | Position in the rotation. Lower values come first | +| `difficulty` | TEXT | nullable | Game-specific difficulty setting. NULL for games without difficulty levels | +| `params_json` | TEXT | NOT NULL, DEFAULT `'{}'` | Mission parameters as JSON | +| `game_data` | TEXT | DEFAULT `'{}'` | Adapter-specific rotation metadata | + +**Unique constraint:** `(server_id, sort_order)` -- prevents two missions from occupying the same position in the rotation. + +**Cascade deletes:** Removing a server deletes all its rotation entries. Removing a mission deletes all rotation entries referencing it. --- ### Table: `players` -Currently connected players (live state, refreshed by RemoteAdminPollerThread). +Currently connected players. This is live state, refreshed by the adapter's remote admin poller. ```sql -CREATE TABLE players ( +CREATE TABLE IF NOT EXISTS players ( id INTEGER PRIMARY KEY AUTOINCREMENT, server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE, - slot_id TEXT NOT NULL, -- Game-specific slot identifier (was player_num int) + slot_id TEXT NOT NULL, name TEXT NOT NULL, - guid TEXT, -- Game-specific identifier (BattlEye GUID, Steam ID, etc.) + guid TEXT, ip TEXT, ping INTEGER, - 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')), + 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) ); -CREATE INDEX idx_players_server ON players(server_id); +CREATE INDEX IF NOT EXISTS 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. +| Column | Type | Constraints | Description | +|--------|------|-------------|-------------| +| `id` | INTEGER | PK, AUTOINCREMENT | Row identifier | +| `server_id` | INTEGER | NOT NULL, FK -> `servers(id)` CASCADE DELETE | Server the player is connected to | +| `slot_id` | TEXT | NOT NULL | Game-specific slot identifier (e.g., player number for Arma 3, Steam ID for other games) | +| `name` | TEXT | NOT NULL | In-game display name | +| `guid` | TEXT | nullable | Game-specific identifier (BattlEye GUID, Steam ID, etc.) | +| `ip` | TEXT | nullable | Player IP address | +| `ping` | INTEGER | nullable | Current ping in milliseconds | +| `game_data` | TEXT | DEFAULT `'{}'` | JSON for game-specific player metadata (e.g., `{"verified": true, "steam_uid": "..."}` for Arma 3) | +| `joined_at` | TEXT | NOT NULL, DEFAULT `datetime('now')` | Connection timestamp | +| `updated_at` | TEXT | NOT NULL, DEFAULT `datetime('now')` | Last refresh timestamp | + +**Unique constraint:** `(server_id, slot_id)` -- each slot on a server can only hold one player at a time. + +**Lifecycle:** When a server stops, all rows for that `server_id` are deleted (clearing the live player list). Disconnection events create entries in `player_history`. --- ### Table: `player_history` -Historical record of connections. Inserted when player disconnects. +Historical record of player sessions. Rows are inserted when a player disconnects. ```sql -CREATE TABLE player_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE, - name TEXT NOT NULL, - guid 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')), +CREATE TABLE IF NOT EXISTS player_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + name TEXT NOT NULL, + guid TEXT, + ip TEXT, + game_data TEXT DEFAULT '{}', + 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_guid ON player_history(guid); +CREATE INDEX IF NOT EXISTS idx_player_history_server ON player_history(server_id); +CREATE INDEX IF NOT EXISTS idx_player_history_guid ON player_history(guid); ``` -### Player History Retention Cleanup (run daily via APScheduler, keep 90 days) -```sql -DELETE FROM player_history -WHERE left_at < datetime('now', '-90 days'); -``` +| Column | Type | Constraints | Description | +|--------|------|-------------|-------------| +| `id` | INTEGER | PK, AUTOINCREMENT | Row identifier | +| `server_id` | INTEGER | NOT NULL, FK -> `servers(id)` CASCADE DELETE | Server the player was connected to | +| `name` | TEXT | NOT NULL | Player name at time of session | +| `guid` | TEXT | nullable | Game-specific identifier. Indexed for looking up a player's connection history | +| `ip` | TEXT | nullable | Player IP address at time of session | +| `game_data` | TEXT | DEFAULT `'{}'` | JSON for game-specific historical data | +| `joined_at` | TEXT | NOT NULL | Session start timestamp | +| `left_at` | TEXT | NOT NULL, DEFAULT `datetime('now')` | Session end timestamp | +| `session_duration_seconds` | INTEGER | nullable | Calculated session length in seconds. NULL if the duration could not be determined | + +**Indexes:** + +| Index | Columns | Purpose | +|-------|---------|---------| +| `idx_player_history_server` | `server_id` | Query history for a specific server | +| `idx_player_history_guid` | `guid` | Look up all sessions for a specific player across servers | + +**Note:** The `config.py` defines `player_history_retention_days: int = 90` but no automated cleanup job is currently registered for this table. A future scheduler job should delete rows where `left_at < datetime('now', '-90 days')`. --- ### Table: `bans` -Ban records. Core concept is game-agnostic; ban file sync is adapter-specific. +Ban records. Core concept is game-agnostic; ban file synchronization is handled by the adapter's `BanManager`. ```sql -CREATE TABLE bans ( +CREATE TABLE IF NOT EXISTS bans ( id INTEGER PRIMARY KEY AUTOINCREMENT, server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE, guid TEXT, name TEXT, reason TEXT, - banned_by TEXT, -- admin username + banned_by TEXT, banned_at TEXT NOT NULL DEFAULT (datetime('now')), - expires_at TEXT, -- NULL = permanent + expires_at TEXT, is_active INTEGER NOT NULL DEFAULT 1, - game_data TEXT DEFAULT '{}', -- JSON: {steam_uid, ip, etc.} + game_data TEXT DEFAULT '{}', 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_active ON bans(is_active); +CREATE INDEX IF NOT EXISTS idx_bans_server ON bans(server_id); +CREATE INDEX IF NOT EXISTS idx_bans_guid ON bans(guid); +CREATE INDEX IF NOT EXISTS 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`. +| Column | Type | Constraints | Description | +|--------|------|-------------|-------------| +| `id` | INTEGER | PK, AUTOINCREMENT | Row identifier | +| `server_id` | INTEGER | NOT NULL, FK -> `servers(id)` CASCADE DELETE | Server the ban applies to | +| `guid` | TEXT | nullable | Game-specific identifier of the banned player. Indexed for fast lookups | +| `name` | TEXT | nullable | Player name at time of ban | +| `reason` | TEXT | nullable | Ban reason entered by the admin | +| `banned_by` | TEXT | nullable | Username of the admin who issued the ban | +| `banned_at` | TEXT | NOT NULL, DEFAULT `datetime('now')` | Timestamp when the ban was created | +| `expires_at` | TEXT | nullable | Expiration timestamp. NULL means permanent ban | +| `is_active` | INTEGER | NOT NULL, DEFAULT `1`, CHECK `(0, 1)` | 1 = active ban, 0 = lifted/expired ban | +| `game_data` | TEXT | DEFAULT `'{}'` | JSON for game-specific ban data (e.g., `{"steam_uid": "...", "ip": "..."}`) | + +**Indexes:** + +| Index | Columns | Purpose | +|-------|---------|---------| +| `idx_bans_server` | `server_id` | Look up all bans for a server | +| `idx_bans_guid` | `guid` | Fast lookup by player identifier | +| `idx_bans_active` | `is_active` | Filter active vs. inactive bans | + +**Note:** Bans are not physically deleted when lifted; `is_active` is set to `0` to preserve the audit trail. --- ### Table: `logs` -Parsed log lines (rolling retention, default 7 days). +Parsed log lines from server log files. Rolling retention (7 days default). ```sql -CREATE TABLE logs ( +CREATE TABLE IF NOT EXISTS logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE, timestamp TEXT NOT NULL, - level TEXT NOT NULL DEFAULT 'info', -- 'info' | 'warning' | 'error' - CHECK (level IN ('info', 'warning', 'error')), + level TEXT NOT NULL DEFAULT 'info', message TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')) + created_at TEXT NOT NULL DEFAULT (datetime('now')), + CHECK (level IN ('info', 'warning', 'error')) ); -CREATE INDEX idx_logs_server_ts ON logs(server_id, timestamp); -CREATE INDEX idx_logs_level ON logs(level); -CREATE INDEX idx_logs_created ON logs(created_at); +CREATE INDEX IF NOT EXISTS idx_logs_server_ts ON logs(server_id, timestamp); +CREATE INDEX IF NOT EXISTS idx_logs_level ON logs(level); +CREATE INDEX IF NOT EXISTS 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. +| Column | Type | Constraints | Description | +|--------|------|-------------|-------------| +| `id` | INTEGER | PK, AUTOINCREMENT | Row identifier | +| `server_id` | INTEGER | NOT NULL, FK -> `servers(id)` CASCADE DELETE | Server that produced the log | +| `timestamp` | TEXT | NOT NULL | Original log timestamp (from the log file, not insertion time) | +| `level` | TEXT | NOT NULL, DEFAULT `'info'` | Severity. Allowed: `info`, `warning`, `error` | +| `message` | TEXT | NOT NULL | Log message content | +| `created_at` | TEXT | NOT NULL, DEFAULT `datetime('now')` | Database insertion timestamp | + +**Indexes:** + +| Index | Columns | Purpose | +|-------|---------|---------| +| `idx_logs_server_ts` | `server_id, timestamp` | Time-range queries for a specific server | +| `idx_logs_level` | `level` | Filter logs by severity | +| `idx_logs_created` | `created_at` | Retention cleanup: delete rows older than N days | + +**Retention:** 7 days. Automated cleanup runs daily at 03:00 via APScheduler (`core.jobs.cleanup_jobs`). Deletes rows where `created_at < datetime('now', '-7 days')`. --- ### Table: `metrics` -Time-series CPU/RAM/player count snapshots. +Time-series CPU, RAM, and player count snapshots. ```sql -CREATE TABLE metrics ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE, - timestamp TEXT NOT NULL DEFAULT (datetime('now')), - cpu_percent REAL, - ram_mb REAL, - player_count INTEGER +CREATE TABLE IF NOT EXISTS metrics ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + timestamp TEXT NOT NULL DEFAULT (datetime('now')), + cpu_percent REAL, + ram_mb REAL, + player_count INTEGER ); -CREATE INDEX idx_metrics_server_ts ON metrics(server_id, timestamp); +CREATE INDEX IF NOT EXISTS idx_metrics_server_ts ON metrics(server_id, timestamp); ``` -**No changes** — fully game-agnostic. +| Column | Type | Constraints | Description | +|--------|------|-------------|-------------| +| `id` | INTEGER | PK, AUTOINCREMENT | Row identifier | +| `server_id` | INTEGER | NOT NULL, FK -> `servers(id)` CASCADE DELETE | Server being measured | +| `timestamp` | TEXT | NOT NULL, DEFAULT `datetime('now')` | Measurement timestamp | +| `cpu_percent` | REAL | nullable | CPU usage percentage. NULL if unavailable | +| `ram_mb` | REAL | nullable | RAM usage in megabytes. NULL if unavailable | +| `player_count` | INTEGER | nullable | Number of connected players at this point in time | + +**Index:** + +| Index | Columns | Purpose | +|-------|---------|---------| +| `idx_metrics_server_ts` | `server_id, timestamp` | Time-range queries for a specific server's metrics | + +**Retention:** 1 day. Automated cleanup runs every 6 hours via APScheduler. Deletes rows where `timestamp < datetime('now', '-1 day')`. --- ### Table: `server_events` -Audit trail of all significant events. +Audit trail of all significant events across the system. ```sql -CREATE TABLE server_events ( +CREATE TABLE IF NOT EXISTS server_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE, event_type TEXT NOT NULL, - -- Core event types: - -- 'started' | 'stopped' | 'crashed' | 'restarted' | 'config_updated' - -- '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 + actor TEXT, + detail TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); -CREATE INDEX idx_events_server ON server_events(server_id, created_at); +CREATE INDEX IF NOT EXISTS idx_events_server ON server_events(server_id, created_at); ``` -**No changes** — fully game-agnostic. Adapters can emit additional event types. +| Column | Type | Constraints | Description | +|--------|------|-------------|-------------| +| `id` | INTEGER | PK, AUTOINCREMENT | Row identifier | +| `server_id` | INTEGER | NOT NULL, FK -> `servers(id)` CASCADE DELETE | Server the event relates to | +| `event_type` | TEXT | NOT NULL | Event category. Core types: `started`, `stopped`, `crashed`, `restarted`, `config_updated`, `player_kicked`, `player_banned`, `admin_login`, `auto_restarted`, `max_restarts_exceeded`. Adapters may define additional event types | +| `actor` | TEXT | nullable | Username or `'system'` for automated actions | +| `detail` | TEXT | nullable | JSON string with event-specific data | +| `created_at` | TEXT | NOT NULL, DEFAULT `datetime('now')` | Event timestamp | + +**Index:** + +| Index | Columns | Purpose | +|-------|---------|---------| +| `idx_events_server` | `server_id, created_at` | Time-ordered event queries for a specific server | + +**Retention:** 30 days. Automated cleanup runs weekly on Sundays at 04:00 via APScheduler. Deletes rows where `created_at < datetime('now', '-30 days')`. --- ## Relationships Diagram ``` -users (1) ──────────────────────────────────── (many) server_events.actor +users (1) ────────────────────────────────────── (ref) server_events.actor -servers (1) ──┬── (many) game_configs ← JSON sections replace 5 Arma 3 tables - ├── (many) server_mods ──── (many) mods (scoped by game_type) +servers (1) ──┬── (many) game_configs [JSON sections replace per-game config tables] + ├── (many) server_mods ──── (many) mods [scoped by game_type] ├── (many) missions - ├── (many) mission_rotation → missions - ├── (many) players - ├── (many) player_history + ├── (many) mission_rotation ──> missions + ├── (many) players [live state, cleared on server stop] + ├── (many) player_history [historical sessions] ├── (many) bans ├── (many) logs ├── (many) metrics └── (many) server_events + +Foreign key cascade: ON DELETE CASCADE on all server_id foreign keys. + Deleting a server removes all associated data. ``` --- -## Encryption Strategy +## Data Access Layer -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]`. +The application uses **raw SQL** through SQLAlchemy's `text()` construct. There is no ORM. All queries are written explicitly in repository classes that inherit from `BaseRepository`. -**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 +### BaseRepository -**Arma 3 encrypted fields (declared by Arma3ConfigGenerator):** -- Section `server`: `password`, `password_admin`, `server_command_password` -- Section `rcon`: `rcon_password` +`core.dal.base_repository.BaseRepository` provides common query helpers: -Encryption uses Fernet (AES-256) with `LANGUARD_ENCRYPTION_KEY`. +| Method | Description | +|--------|-------------| +| `_execute(query, params)` | Execute a parameterized SQL statement | +| `_fetchone(query, params)` | Execute and return a single row as `dict` or `None` | +| `_fetchall(query, params)` | Execute and return all rows as `list[dict]` | +| `_lastrowid(query, params)` | Execute and return the last inserted row ID | -## Optimistic Locking for Config Updates +All methods wrap queries in `sqlalchemy.text()` with named parameter binding (`:param_name` style). -The `config_version` column in `game_configs` prevents lost updates when two admins edit simultaneously: +### Repository Classes -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 - -## Config Schema Migration - -When the adapter is updated and `get_config_version()` returns a newer version than what's stored in `game_configs.schema_version`, the core automatically migrates: - -1. On read, detect `stored_schema_version != adapter.get_config_version()` -2. Call `adapter.migrate_config(stored_schema_version, config_json)` → returns migrated dict -3. Update the row: `SET config_json = ?, schema_version = ? WHERE id = ?` -4. On `ConfigMigrationError`: keep original config, log warning, server runs with old schema -5. Migration is per-section — each section can have a different stored version - -This ensures config data is always compatible with the current adapter without manual intervention. - -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) -```sql -DELETE FROM logs -WHERE created_at < datetime('now', '-7 days'); -``` - -### Metrics Retention Cleanup (keep 30 days) -```sql -DELETE FROM metrics -WHERE timestamp < datetime('now', '-30 days'); -``` - -### Clear disconnected players on server stop -```sql -DELETE FROM players WHERE server_id = ?; -``` - -### Vacuum (run weekly) -```sql -VACUUM; -``` +| Repository | File | Tables | +|------------|------|--------| +| `ConfigRepository` | `core/dal/config_repository.py` | `game_configs` | +| `BanRepository` | `core/dal/ban_repository.py` | `bans` | +| `PlayerRepository` | `core/dal/player_repository.py` | `players`, `player_history` | +| `LogRepository` | `core/dal/log_repository.py` | `logs` | +| `MetricsRepository` | `core/dal/metrics_repository.py` | `metrics` | +| `EventRepository` | `core/dal/event_repository.py` | `server_events` | --- ## Migration Strategy -- 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 ( - version INTEGER PRIMARY KEY, - applied_at TEXT NOT NULL DEFAULT (datetime('now')) - ); - ``` -- 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`) +Migrations are plain `.sql` files in `backend/core/migrations/`, applied in order at application startup. + +### Naming Convention + +Files are named `{NNN}_{description}.sql` where `NNN` is a zero-padded version number: + +``` +001_initial_schema.sql +002_add_game_type.sql +003_add_player_history_indexes.sql +``` + +### Tracking Table + +```sql +CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) +); +``` + +The migration runner in `database.py:run_migrations()`: + +1. Creates the `schema_migrations` table if it does not exist. +2. Reads all `*.sql` files from `backend/core/migrations/`, sorted by filename. +3. Extracts the version number from the filename prefix (e.g., `001` -> version `1`). +4. Skips any version already recorded in `schema_migrations`. +5. Executes each statement in the file separately (split on `;`), since SQLite does not support `executescript` inside transactions. +6. Records the version in `schema_migrations` and commits. + +### Adapter-Specific Migrations + +If an adapter requires tables beyond `game_configs`, adapter-specific migrations go in `adapters//migrations/` and are applied by the adapter's initialization logic. --- -## Migration from Single-Game Schema +## Retention Policies -For existing deployments with the old Arma 3-specific schema: +Automated cleanup is managed by APScheduler jobs registered in `core/jobs/cleanup_jobs.py`: -### SQL Migration (002_multi_game_adapter.sql) +| Table | Retention | Cleanup Schedule | Cleanup Column | +|-------|-----------|------------------|----------------| +| `logs` | 7 days | Daily at 03:00 | `created_at` | +| `metrics` | 1 day | Every 6 hours | `timestamp` | +| `server_events` | 30 days | Weekly (Sunday 04:00) | `created_at` | + +**Player history** has a configurable retention of 90 days (`LANGUARD_PLAYER_HISTORY_RETENTION_DAYS`) but no cleanup job is currently registered. + +Cleanup queries follow the pattern: ```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; +DELETE FROM {table} WHERE {column} < datetime('now', '-{N} days'); ``` -### 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. +## `game_data` JSON Columns -```python -"""Migrate Arma 3 config data from normalized tables to game_configs JSON. +Several tables include a `game_data` column (TEXT, default `'{}'`) for adapter-specific metadata that does not fit the common schema. This is the extensibility mechanism that allows new game adapters to store game-specific data without altering the database schema. -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 +| Table | Column | Example (Arma 3) | +|-------|--------|-------------------| +| `players.game_data` | Player metadata | `{"verified": true, "steam_uid": "76561198012345678"}` | +| `missions.game_data` | Mission metadata | `{"terrain": "Altis"}` | +| `mods.game_data` | Mod metadata | `{}` (empty for Arma 3) | +| `server_mods.game_data` | Per-server mod overrides | `{}` | +| `mission_rotation.game_data` | Rotation metadata | `{}` | +| `bans.game_data` | Ban metadata | `{"steam_uid": "76561198012345678", "ip": "192.168.1.100"}` | +| `player_history.game_data` | Historical metadata | `{}` | -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) +Each adapter's capability protocols optionally provide a Pydantic schema for their `game_data` fields. If an adapter does not define a schema, the field accepts any valid JSON object (no validation). -Transaction: all inserts in a single transaction. On failure, rollback — old tables untouched. -""" +--- -import json -import sqlite3 +## Maintenance Queries -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'], - }, -} +### Clear disconnected players on server stop -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] +```sql +DELETE FROM players WHERE server_id = :server_id; +``` - for row in rows: - row_dict = dict(zip(columns, row)) - server_id = row_dict.pop('id', None) or row_dict.pop('server_id', None) +### Find active bans for a player by GUID - 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 +```sql +SELECT * FROM bans +WHERE server_id = :server_id + AND guid = :guid + AND is_active = 1 + AND (expires_at IS NULL OR expires_at > datetime('now')); +``` - 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))) +### Get current player count for a server +```sql +SELECT COUNT(*) FROM players WHERE server_id = :server_id; +``` -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)") +### Vacuum (run weekly during low traffic) - -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() +```sql +VACUUM; ``` \ No newline at end of file diff --git a/FRONTEND.md b/FRONTEND.md index be7903e..058c7b3 100644 --- a/FRONTEND.md +++ b/FRONTEND.md @@ -1,1380 +1,335 @@ -# Languard Servers Manager — Frontend Design +# Frontend Architecture -## Purpose +## Tech Stack -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. +| Package | Version | Purpose | +|---|---|---| +| React | 19.2 | UI framework | +| TypeScript | 6.0 | Type safety | +| Vite | 8.0 | Build tool and dev server | +| TanStack React Query | 5.99 | Server state management, caching, mutations | +| Zustand | 5.0 | Client state (auth, UI) | +| Axios | 1.15 | HTTP client with interceptors | +| React Router | 7.14 | Client-side routing | +| React Hook Form | 7.72 | Form state management | +| Zod | 4.3 | Schema validation | +| Tailwind CSS | 3.4 | Utility-first CSS framework | +| Lucide React | 1.8 | Icon library | +| clsx | 2.1 | Conditional class merging | -**Audience:** Server administrators managing game servers. Technical, task-oriented, frequently under time pressure (server crashed, player is cheating, need to restart now). +### Dev Dependencies -**Emotional tone:** Confident, precise, operational. Not playful, not corporate, not minimal-for-the-sake-of-it. +| Package | Purpose | +|---|---| +| Vitest 4.1 + jsdom | Unit testing | +| @testing-library/react 16 | Component testing | +| @testing-library/jest-dom 6 | DOM matchers | +| @testing-library/user-event 14 | User interaction simulation | +| Playwright 1.59 | E2E testing | +| ESLint 9 | Linting | +| TypeScript ESLint 8 | TypeScript lint rules | -**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 +## Project Structure ``` -┌─────────────────────────────────────────────────────────┐ -│ 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 +frontend/src/ +├── main.tsx # Entry point, renders +├── App.tsx # Router, QueryClientProvider, auth guard +├── index.css # Tailwind directives, neumorphic classes +├── vite-env.d.ts # Vite env type declarations │ -├── public/ -│ └── favicon.svg +├── lib/ +│ └── api.ts # Axios client, auth interceptors │ -└── 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 +├── store/ +│ ├── auth.store.ts # Auth state (token, user, isAuthenticated) +│ └── ui.store.ts # UI state (sidebar, notifications) +│ +├── hooks/ +│ ├── useServers.ts # TanStack Query hooks for server CRUD + lifecycle +│ ├── useServerDetail.ts # TanStack Query hooks for config, players, bans, missions, mods, RCon +│ ├── useAuth.ts # Auth management hooks (users, password, logout) +│ ├── useGames.ts # Game type hooks (list, detail, schema, defaults) +│ └── useWebSocket.ts # WebSocket connection with backoff + onEvent callback +│ +├── pages/ +│ ├── LoginPage.tsx # Login form with Zod validation +│ ├── DashboardPage.tsx # Server grid with lifecycle controls +│ ├── ServerDetailPage.tsx # Server detail with 7 tabs (overview, config, players, bans, missions, mods, logs) +│ ├── CreateServerPage.tsx # 4-step server creation wizard (admin only) +│ └── SettingsPage.tsx # Account (password change) + Users (admin user management) +│ +├── components/ +│ ├── layout/ +│ │ └── Sidebar.tsx # Left nav with server list +│ ├── servers/ +│ │ ├── ServerCard.tsx # Server card with actions +│ │ ├── ServerHeader.tsx # Server name, status, stats grid, lifecycle buttons +│ │ ├── ConfigEditor.tsx # Tabbed config section editor with optimistic locking +│ │ ├── PlayerTable.tsx # Current players + history with search +│ │ ├── BanTable.tsx # Ban list + create/revoke form +│ │ ├── MissionList.tsx # Mission list + upload/delete .pbo +│ │ ├── ModList.tsx # Mod list with enable/disable checkboxes +│ │ └── LogViewer.tsx # Log display with level filter (receives logs as props) +│ ├── settings/ +│ │ ├── PasswordChange.tsx # Password change form +│ │ └── UserManager.tsx # User CRUD table (admin only) +│ └── ui/ +│ └── StatusLed.tsx # Colored status indicator dot +│ +└── __tests__/ + ├── api.test.ts # Axios interceptor tests + ├── auth.store.test.ts # Auth store tests + ├── ui.store.test.ts # UI store tests + ├── StatusLed.test.tsx # StatusLed component tests + ├── LoginPage.test.tsx # Login page tests + ├── DashboardPage.test.tsx # Dashboard page tests + ├── ServerCard.test.tsx # Server card rendering tests + ├── ServerCard.handlers.test.tsx # Server card interaction tests + ├── Sidebar.test.tsx # Sidebar tests + ├── useWebSocket.test.tsx # WebSocket hook tests + └── useServers.test.tsx # Server hooks tests ``` -### Key Component Contracts +## Routes -**`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) -- **Error boundary**: if `jsonSchemaToFields()` encounters an unsupported or malformed schema type (deeply nested objects, unknown formats), render a fallback "This section uses an unsupported field type. Edit raw JSON." with a raw JSON editor instead of crashing the form +| Path | Component | Auth | Status | +|---|---|---|---| +| `/login` | `LoginPage` | Public | Implemented | +| `/` | `DashboardPage` | Protected | Implemented | +| `/servers/:serverId` | `ServerDetailPage` | Protected | Implemented (7 tabs) | +| `/servers/new` | `CreateServerPage` | Protected (admin only) | Implemented (4-step wizard) | +| `/settings` | `SettingsPage` | Protected | Implemented (Account + Users tabs) | -**ConfigForm test plan** (critical — highest-risk component): -- **Unit: `jsonSchemaToFields`** — test every supported type mapping (string→text, integer→number, boolean→toggle, enum→select, array→repeatable), test sensitive field masking, test unknown type → fallback descriptor with `type: "raw_json"` -- **Unit: `jsonSchemaToFields`** — test malformed schema input (missing `properties`, nested `$ref`, `oneOf`/`anyOf`) → returns fallback descriptor, never throws -- **Integration: `ConfigForm`** — render with a 2-section schema, verify field rendering, toggle a boolean, verify dirty state, submit and verify request payload -- **Integration: 409 conflict** — after save, mock a 409 response, verify ConflictDialog appears with diff -- **Integration: error boundary** — mount `ConfigForm` with a schema that has unsupported `type: "object"` nested 3 levels deep, verify raw JSON fallback renders instead of crash +`ProtectedLayout` wraps all non-login routes. When `isAuthenticated` is false, it redirects to `/login`. -**`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 +## Component Tree -**`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) +``` +App +├── QueryClientProvider +│ ├── BrowserRouter +│ │ ├── /login → LoginPage +│ │ └── /* → ProtectedLayout +│ │ ├── Sidebar +│ │ │ ├── Languard branding +│ │ │ ├── Dashboard link +│ │ │ ├── Server list (from useServers) +│ │ │ └── Settings link +│ │ └── main content area +│ │ ├── / → DashboardPage +│ │ │ ├── "2 servers configured" heading +│ │ │ ├── Add Server button +│ │ │ └── Server grid +│ │ │ └── ServerCard × N +│ │ │ ├── StatusLed + name + game type +│ │ │ ├── Stats: Players, Port, Restarts +│ │ │ └── Action buttons: Start/Stop/Restart +│ │ ├── /servers/:id → ServerDetailPage +│ │ │ ├── ServerHeader (status, stats, lifecycle buttons) +│ │ │ ├── Tab bar (Overview, Config, Players, Bans, Missions, Mods, Logs) +│ │ │ ├── OverviewTab (stats grid, executable path) +│ │ │ ├── ConfigEditor (section tabs, edit form, optimistic locking) +│ │ │ ├── PlayerTable (current + history with search) +│ │ │ ├── BanTable (ban list + create/revoke) +│ │ │ ├── MissionList (upload .pbo, delete) +│ │ │ ├── ModList (enable/disable checkboxes) +│ │ │ └── LogViewer (level filter, real-time via WebSocket onEvent) +│ │ ├── /servers/new → CreateServerPage +│ │ │ └── 4-step wizard (Game Type → Info → Options → Review) +│ │ └── /settings → SettingsPage +│ │ ├── Account tab → PasswordChange +│ │ └── Users tab → UserManager (admin only) +│ └── ReactQueryDevtools +``` ---- - -## State Management Strategy +## State Management ### Server State (TanStack Query) -All API data uses TanStack Query with appropriate stale times: +All server data flows through TanStack Query hooks: -| 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) **Invalidation**: when `GET /games/{type}` returns a different `schema_version` than previously cached, TanStack Query invalidates all config schema queries for that game type. This prevents stale forms after an adapter update. | +**Server CRUD** (`useServers.ts`): -**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 +| Hook | Type | Endpoint | Cache Key | +|---|---|---|---| +| `useServers()` | Query | `GET /api/servers` | `["servers"]` (refetch every 30s) | +| `useServer(id)` | Query | `GET /api/servers/:id` | `["servers", id]` | +| `useStartServer()` | Mutation | `POST /api/servers/:id/start` | Invalidates `["servers", id]`, `["servers"]` | +| `useStopServer()` | Mutation | `POST /api/servers/:id/stop` | Invalidates both caches | +| `useRestartServer()` | Mutation | `POST /api/servers/:id/restart` | Invalidates `["servers", id]` | +| `useCreateServer()` | Mutation | `POST /api/servers` | Invalidates `["servers"]` | +| `useDeleteServer()` | Mutation | `DELETE /api/servers/:id` | Invalidates `["servers"]` | +| `useUpdateServer(id)` | Mutation | `PUT /api/servers/:id` | Invalidates `["servers", id]`, `["servers"]` | +| `useKillServer()` | Mutation | `POST /api/servers/:id/kill` | Invalidates `["servers", id]`, `["servers"]` | + +**Server Detail** (`useServerDetail.ts`): + +| Hook | Type | Endpoint | Cache Key | +|---|---|---|---| +| `useServerConfig(id)` | Query | `GET /api/servers/:id/config` | `["servers", id, "config"]` | +| `useServerConfigSection(id, section)` | Query | `GET /api/servers/:id/config/:section` | `["servers", id, "config", section]` | +| `useServerConfigPreview(id)` | Query | `GET /api/servers/:id/config/preview` | `["servers", id, "config", "preview"]` | +| `useServerPlayers(id)` | Query | `GET /api/servers/:id/players` | `["players", id]` | +| `useServerPlayerHistory(id, opts?)` | Query | `GET /api/servers/:id/players/history` | `["players", id, "history", opts]` | +| `useServerBans(id)` | Query | `GET /api/servers/:id/bans` | `["bans", id]` | +| `useServerMissions(id)` | Query | `GET /api/servers/:id/missions` | `["missions", id]` | +| `useServerMods(id)` | Query | `GET /api/servers/:id/mods` | `["mods", id]` | +| `useUpdateConfigSection(id, section)` | Mutation | `PUT /api/servers/:id/config/:section` | Invalidates config keys | +| `useCreateBan(id)` | Mutation | `POST /api/servers/:id/bans` | Invalidates `["bans", id]` | +| `useRevokeBan(id)` | Mutation | `DELETE /api/servers/:id/bans/:banId` | Invalidates `["bans", id]` | +| `useUploadMission(id)` | Mutation | `POST /api/servers/:id/missions` (multipart) | Invalidates `["missions", id]` | +| `useDeleteMission(id)` | Mutation | `DELETE /api/servers/:id/missions/:filename` | Invalidates `["missions", id]` | +| `useSetEnabledMods(id)` | Mutation | `PUT /api/servers/:id/mods/enabled` | Invalidates `["mods", id]` | +| `useSendCommand(id)` | Mutation | `POST /api/servers/:id/rcon/command` | No invalidation | + +**Auth** (`useAuth.ts`): + +| Hook | Type | Endpoint | +|---|---|---| +| `useCurrentUser()` | Query | `GET /api/auth/me` | +| `useUsers()` | Query | `GET /api/auth/users` | +| `useChangePassword()` | Mutation | `PUT /api/auth/password` | +| `useCreateUser()` | Mutation | `POST /api/auth/users` | +| `useDeleteUser()` | Mutation | `DELETE /api/auth/users/:id` | +| `useLogout()` | Mutation | `POST /api/auth/logout` + clear auth state | + +**Games** (`useGames.ts`): + +| Hook | Type | Endpoint | +|---|---|---| +| `useGamesList()` | Query | `GET /api/games` | +| `useGameDetail(gameType)` | Query | `GET /api/games/:gameType` | +| `useGameConfigSchema(gameType)` | Query | `GET /api/games/:gameType/config-schema` | +| `useGameDefaults(gameType)` | Query | `GET /api/games/:gameType/defaults` | + +**Key type notes**: +- `Server` type in `useServers.ts` uses `game_port`, `current_players`, `max_players` (matches enriched API response) +- `Mission` type: `{ name, filename, size_bytes }` (not DB schema fields) +- `Mod` type: `{ name, path, size_bytes, enabled }` (not DB schema fields) +- `Ban` type: `{ id, server_id, guid, name, reason, banned_by, banned_at, expires_at, is_active, game_data }` (matches API) +- There is no REST endpoint for logs — logs are only pushed via WebSocket events + +QueryClient defaults: `staleTime: 10s`, `retry: 2`, `refetchOnWindowFocus: false`. ### 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" | "raw_json"; - 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" - // Unknown types (nested objects, oneOf/anyOf, $ref) → type: "raw_json" - // The form renders a textarea with JSON editing for these fields - // rather than crashing or hiding the field silently -} -``` - -### Game Type Card Pattern - -```typescript -// Used in NewServerDialog and Dashboard - setSelectedGame("arma3")} -/> -``` - -Future adapters register themselves; the card list auto-populates from `GET /games`. - -### Schema Cache Invalidation - -Adapter config schemas are cached with `staleTime: 30min`. When an adapter is updated and its `schema_version` changes, the frontend must not serve a stale schema. The invalidation pattern: - -```typescript -// When fetching game type info, compare schema_version -function useGameTypeWithInvalidation(gameType: string) { - const queryClient = useQueryClient(); - return useQuery({ - queryKey: ["gameType", gameType], - staleTime: 30 * 60 * 1000, - onSuccess: (data) => { - const prevVersion = localStorage.getItem(`schema_version_${gameType}`); - if (prevVersion && prevVersion !== data.schema_version) { - // Adapter was updated — invalidate all config schema queries - queryClient.invalidateQueries({ queryKey: ["configSchema", gameType] }); - localStorage.setItem(`schema_version_${gameType}`, data.schema_version); - } else if (!prevVersion) { - localStorage.setItem(`schema_version_${gameType}`, data.schema_version); - } - }, - }); -} -``` - ---- - -## 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 | -| Malformed adapter schema | Raw JSON fallback in ConfigForm section ("This section uses an unsupported field type. Edit raw JSON.") | - -**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 +**auth.store.ts** — Persisted to localStorage under `languard-auth`: +- `token: string | null` — JWT access token +- `user: { id, username, role } | null` — Current user +- `isAuthenticated: boolean` — Derived on rehydration +- `setAuth(token, user)` — Sets token, user, and writes `languard_token` to localStorage +- `clearAuth()` — Clears all state and localStorage keys + +**ui.store.ts** — In-memory only: +- `sidebarOpen: boolean` — Sidebar collapse state +- `activeServerId: number | null` — Highlighted server in sidebar +- `notifications: Notification[]` — Toast notifications (auto-remove after 5s) +- `addNotification(type, message)` — Add toast with auto-dismiss +- `removeNotification(id)` — Manual dismiss + +## API Client + +`src/lib/api.ts` configures Axios with: + +- **Base URL**: `VITE_API_URL` env var, defaults to `http://localhost:8000` +- **Timeout**: 30 seconds +- **Request interceptor**: Reads `languard_token` from localStorage, adds `Authorization: Bearer ` header +- **Response interceptor**: On 401, checks if URL starts with `/api/auth/` — if NOT an auth endpoint, clears token and redirects to `/login`. Auth endpoint 401s are left for the calling component to handle. + +Standard response envelope: `{ success: boolean, data: T | null, error?: string }` + +## WebSocket + +`useWebSocket.ts` connects to `VITE_WS_URL` (default `ws://localhost:8000`): +- Accepts `UseWebSocketOptions` (or `number[]` for backward compat): `{ serverIds?, onEvent? }` +- Passes JWT token as query parameter: `/ws?token=...&server_id=...` +- Exponential backoff reconnect: starts 2s, doubles up to 30s max +- Close code 4001 = explicit disconnect (no reconnect) +- `onEvent` callback: receives raw WebSocket events for custom handling (e.g., log accumulation in ServerDetailPage) +- Event handlers invalidate TanStack Query caches: + - `server_status` → `["servers", id]` + `["servers"]` + - `metrics` → `["metrics", id]` + - `log` → no cache key (no REST endpoint for logs; use onEvent callback instead) + - `players` → `["players", id]` + +**Important**: There is no REST endpoint for server logs. Logs are only available via WebSocket push events. Components that need log data must use the `onEvent` callback to accumulate log entries in local state, then pass them to `LogViewer` as props. + +## Design System + +Dark neumorphic theme defined in `tailwind.config.js`: + +**Colors:** +- Surface: base `#1a1a2e`, raised `#1e1e35`, recessed `#16162a`, overlay `#22223a` +- Accent: amber `#f59e0b` with bright/dim/glow variants +- Status: running green, stopped gray, crashed red, starting amber, restarting blue +- Text: primary `#e2e8f0`, secondary `#94a3b8`, muted `#475569` + +**Neumorphic classes:** `neu-card`, `neu-input`, `btn-primary`, `btn-danger`, `btn-ghost`, `status-led-*` + +**Custom shadows:** `neu-raised`, `neu-raised-lg`, `neu-recessed`, plus glow variants + +**Fonts:** Inter (sans), JetBrains Mono (mono) + +## Testing + +### Unit Tests (120 tests, Vitest + React Testing Library) + +| Test File | Tests | Coverage | +|---|---|---| +| `api.test.ts` | 4 | Interceptors: token header, 401 redirect (non-auth), 401 no-redirect (auth) | +| `auth.store.test.ts` | 3 | Init state, setAuth, clearAuth, localStorage sync | +| `ui.store.test.ts` | 5 | Init state, toggleSidebar, setActiveServer, add/remove notifications | +| `StatusLed.test.tsx` | 8 | Status classes, showLabel, sizes | +| `LoginPage.test.tsx` | 4 | Form render, validation, API call, error display | +| `DashboardPage.test.tsx` | 5 | Loading/error/empty states, card rendering | +| `ServerCard.test.tsx` | 10 | Card rendering, button visibility, disabled states, actions | +| `ServerCard.handlers.test.tsx` | 9 | Start/stop/restart success/failure notifications | +| `Sidebar.test.tsx` | 6 | Branding, links, loading state, server list, active highlight | +| `useWebSocket.test.tsx` | 5 | No-connect without token, connect, token in URL, invalidation, cleanup | +| `useServers.test.tsx` | 10 | Server CRUD + lifecycle hooks, cache invalidation | +| `useServerDetail.test.tsx` | 20+ | Config, players, bans, missions, mods, mutations, cache invalidation | +| `useAuth.test.tsx` | 7 | Current user, users, change password, create/delete user, logout | +| `useGames.test.tsx` | 5 | Games list, detail, config schema, defaults | + +### E2E Tests (23 tests, Playwright) + +**Login Flow** (6 tests): +- Display login form, branding, validation errors +- Error on invalid credentials (mocked 401) +- Navigation to dashboard on successful login +- Loading state ("Signing in...") + +**Dashboard** (12 tests): +- Header, server count, server cards, server names +- Add Server button, sidebar with server list +- Stop button for running server, Start button for stopped +- Player count display, server detail navigation +- Empty state, error state + +**Full Stack Integration** (5 tests): +- Login + see A3Master on dashboard (real backend) +- A3Master server details in card (real backend) +- Server detail navigation (real backend) +- Unauthenticated redirect to login +- API response shape validation + +## Configuration + +### Vite (`vite.config.ts`) +- Path alias `@` → `./src` +- Dev server on port 5173 +- Proxy: `/api` → `http://localhost:8000`, `/ws` → `ws://localhost:8000` + +### Vitest (`vitest.config.ts`) +- `jsdom` environment +- Global test APIs +- Setup: `src/__tests__/setup.ts` (imports jest-dom matchers) +- Excludes `tests-e2e` directory + +### Playwright (`playwright.config.ts`) +- Chromium only +- `baseURL: http://localhost:5173` +- Traces on first retry, screenshots on failure, video on failure +- Dev server auto-started via `npm run dev` +- CI: retries=2, single worker, `forbidOnly` \ No newline at end of file diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md deleted file mode 100644 index 2bf1244..0000000 --- a/IMPLEMENTATION_PLAN.md +++ /dev/null @@ -1,503 +0,0 @@ -# Languard Servers Manager — Implementation Plan - -## Prerequisites - -Before starting, ensure the following are available: -- Python 3.11+ -- 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 0 — Adapter Framework (New) - -**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 - -``` -mkdir backend -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 -pip freeze > requirements.txt -``` - -Create: -- `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/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 -2. Create `backend/core/dal/event_repository.py` -3. Create `backend/database.py`: - - `get_engine()` with WAL + FK pragma - - `run_migrations()` - - `get_db()` — FastAPI dependency - - `get_thread_db()` — thread-local session factory -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. - -### Step 1.3 — Auth module - -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/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 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 — Arma 3 Adapter Implementation - -**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 (Arma 3 adapter) - -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` -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:** `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 (core) - -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 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 error paths: set invalid exe path → confirm 403 ExeNotAllowedError response. - -### Step 2.3 — Config endpoints (core + adapter validation) - -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 optimistic locking: two concurrent PUT requests with same config_version → one succeeds (200), one fails (409 Conflict). - ---- - -## Phase 3 — Background Threads (Core + Adapter) - -**Goal:** Live monitoring — process crash detection, log tailing, metrics. - -### Step 3.1 — Thread infrastructure - -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 (core) - -1. `backend/core/threads/process_monitor.py` -2. Crash detection + status update in DB -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 Parser (Arma 3 adapter) + Log Tail Thread (core) - -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 (core) - -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 — Remote Admin (Arma 3: BattlEye RCon) - -**Goal:** Real-time player list, in-game admin commands via the adapter's RemoteAdmin protocol. - -### Step 4.1 — RCon Client (Arma 3 adapter) - -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 -4. `parse_players_response()` — parse `players` command output -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 (Arma 3 adapter) + Remote Admin Poller Thread (core) - -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 Remote Admin - -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` - -### 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. - ---- - -## Phase 5 — WebSocket Real-Time - -**Goal:** Live updates to React frontend without polling. **Fully game-agnostic.** - -### Step 5.1 — Broadcast infrastructure - -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/core/websocket/router.py` -2. JWT validation from query param -3. Subscribe/unsubscribe message handling -4. Ping/pong keepalive - -**Test:** Connect to `ws://localhost:8000/ws/1?token=...` → see live log lines stream in terminal. - -### Step 5.3 — Integrate all event sources - -Wire `BroadcastThread.enqueue()` into: -- `ProcessMonitorThread` → status updates, crash events -- `LogTailThread` → log lines -- `MetricsCollectorThread` → metrics snapshots -- `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 (Arma 3 Adapter) - -### Step 6.1 — Missions - -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/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. - ---- - -## Phase 7 — Polish & Production - -### Step 7.1 — APScheduler jobs - -```python -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) -scheduler.start() -``` - -### Step 7.2 — Startup recovery - -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/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 - -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 -- [ ] 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) -- `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/wrong password / rate limiting -- Repository methods — use in-memory SQLite (`:memory:`) -- `check_server_ports_available()` — test derived port validation (via adapter conventions) -- `sanitize_filename()` — test path traversal prevention -- 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) -- 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 -- ConfigGenerator migration test: `migrate_config(old_version, config_json)` returns valid migrated dict; `ConfigMigrationError` on invalid old_version - -### Load notes -- SQLite with WAL handles concurrent reads from 4 threads per server well -- For >10 simultaneous servers, consider connection pool size tuning -- WebSocket broadcast scales to ~100 concurrent connections without issue - ---- - -## Environment Setup (Developer) - -```bash -# 1. Clone repo -git clone -cd languard-servers-manager - -# 2. Backend -cd backend -python -m venv venv -source venv/bin/activate # or venv\Scripts\activate on Windows -pip install -r requirements.txt - -# 3. Environment -cp .env.example .env -# 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 - -# 5. Frontend (separate) -cd ../frontend -npm install -npm run dev -``` - -Backend auto-creates `languard.db`, seeds an admin user on first run, and registers the Arma 3 adapter automatically. - ---- - -## Phase Summary - -| 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. 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 c7c7060..a5afdf1 100644 --- a/MODULES.md +++ b/MODULES.md @@ -1,999 +1,220 @@ -# Languard Servers Manager — Python Module Breakdown +# Module Reference -## Project Structure +## Backend Modules -``` -backend/ -├── main.py -├── config.py -├── database.py -├── dependencies.py -│ -├── core/ # Game-agnostic core -│ ├── __init__.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 -│ -└── 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) -``` +### `main.py` — Application Factory +- `create_app()` — FastAPI app with lifespan, CORS middleware, rate limiter, exception handler +- `lifespan()` — Startup: init DB, register adapters, create WebSocket manager, create broadcast thread, create ThreadRegistry, recover processes, reattach threads, seed admin, start scheduler. Shutdown: stop threads, stop broadcast, stop scheduler. + +### `config.py` — Settings +- `Settings` class (Pydantic BaseSettings) with `LANGUARD_` env prefix +- 13 configurable settings: SECRET_KEY, ENCRYPTION_KEY, DB_PATH, SERVERS_DIR, HOST, PORT, CORS_ORIGINS, log/metrics/player retention days, JWT expiry, Arma3 default exe + +### `database.py` — Database Engine +- `get_engine()` — Creates SQLite engine with WAL mode and foreign keys +- `run_migrations(engine)` — Applies numbered `.sql` migration files from `core/migrations/` +- `get_db()` — FastAPI dependency yielding a DB connection +- `get_thread_db()` — Thread-local DB connection for background threads + +### `dependencies.py` — FastAPI Dependencies +- `get_current_user(token)` — Decodes JWT, validates user exists +- `require_admin(user)` — Returns 403 if user role is not admin +- `get_server_or_404(server_id, db)` — Loads server row or raises 404 +- `get_adapter_for_server(server_id, db)` — Loads server + resolves its game adapter + +### `adapters/protocols.py` — Adapter Protocols +7 runtime-checkable Protocol classes: +- `ConfigGenerator` — Render config files, build launch args +- `ProcessConfig` — Allowed executables, port conventions, directory layout +- `LogParser` — Parse log lines, resolve latest log file +- `RemoteAdmin` — Connect, send commands, get/kick/ban players +- `MissionManager` — List, upload, delete mission files +- `ModManager` — Scan mods, set enabled mods, build CLI args +- `BanManager` — Sync bans between DB and file +- Composite `GameAdapter` — Aggregates all protocols, plus `has_capability()`, `get_additional_routers()`, `get_custom_thread_factories()` + +### `adapters/registry.py` — Game Adapter Registry +- `GameAdapterRegistry` — Class-level singleton dict (`game_type` → adapter instance) +- Methods: `register()`, `get()`, `all()`, `list_game_types()`, `is_registered()` + +### `adapters/exceptions.py` — Typed Exceptions +- `AdapterError`, `ConfigWriteError`, `ConfigValidationError`, `ConfigMigrationError`, `LaunchArgsError`, `RemoteAdminError`, `ExeNotAllowedError` + +### `adapters/__init__.py` — Adapter Loading +- `initialize_adapters()` — Imports built-in adapters (Arma3), then scans `importlib.metadata` entry points under `"languard.adapters"` for third-party plugins + +### `adapters/arma3/` — Arma 3 Adapter +All 7 capabilities implemented: + +| Module | Class | Purpose | +|---|---|---| +| `adapter.py` | `Arma3Adapter` | Composite adapter declaring all capabilities | +| `config_generator.py` | `Arma3ConfigGenerator` | 5 Pydantic config models, writes server.cfg/basic.cfg/Arma3Profile/beserver.cfg, builds launch args | +| `process_config.py` | `Arma3ProcessConfig` | Allowed executables, port conventions (game+1/+2/+3), directory layout | +| `log_parser.py` | `RPTParser` | Regex-based .rpt log parser, log file resolver | +| `rcon_client.py` | `BERConClient` | BattlEye RCon v2 UDP protocol implementation | +| `remote_admin.py` | `Arma3RemoteAdmin` + `Arma3RemoteAdminFactory` | Implements RemoteAdmin protocol using BERConClient | +| `mission_manager.py` | `Arma3MissionManager` | .pbo upload, delete, list, rotation config generation | +| `mod_manager.py` | `Arma3ModManager` | @-prefixed mod scanning, enabled-mod persistence, -mod/-serverMod args | +| `ban_manager.py` | `Arma3BanManager` | BattlEye bans.txt file sync + DB sync | + +### `core/auth/` — Authentication + +| Module | Purpose | +|---|---| +| `router.py` | 7 endpoints: login, logout, me, change password, user CRUD | +| `service.py` | `AuthService` — login, create_user, change_password, seed_admin_if_empty | +| `schemas.py` | Pydantic models: LoginRequest, CreateUserRequest, ChangePasswordRequest | +| `utils.py` | JWT creation/validation (HS256), bcrypt password hashing | + +### `core/servers/` — Server Management + +| Module | Purpose | +|---|---| +| `router.py` | Server CRUD, lifecycle (start/stop/restart/kill), config read/write/preview, RCon command | +| `players_router.py` | Player list, player history | +| `bans_router.py` | Ban CRUD with bans.txt file sync | +| `missions_router.py` | Mission list, .pbo upload (500MB), delete | +| `mods_router.py` | List mods, set enabled mods | +| `service.py` | `ServerService` — orchestrates all lifecycle operations, config writes, thread management | +| `schemas.py` | Pydantic models: CreateServerRequest, UpdateServerRequest, StopServerRequest | +| `process_manager.py` | `ProcessManager` singleton — subprocess.Popen lifecycle, PID recovery via psutil | + +### `core/games/` — Game Type Discovery + +| Module | Purpose | +|---|---| +| `router.py` | 4 endpoints: list game types, get game details, config schema, defaults | + +### `core/system/` — System Health + +| Module | Purpose | +|---|---| +| `router.py` | `/health` (public) + `/status` (authed) | + +### `core/websocket/` — Real-Time Events + +| Module | Purpose | +|---|---| +| `router.py` | `/ws` endpoint with JWT auth via query param | +| `manager.py` | `WebSocketManager` — asyncio connection registry with server_id subscriptions | +| `broadcast_thread.py` | `BroadcastThread` — bridges `queue.Queue` to asyncio event loop | + +### `core/threads/` — Background Threads + +| Module | Purpose | +|---|---| +| `base_thread.py` | `BaseServerThread` — abstract base with stop event, thread-local DB, exception backoff | +| `thread_registry.py` | `ThreadRegistry` — manages per-server thread bundles, start/stop/reattach | +| `log_tail.py` | `LogTailThread` — tails log files, parses lines, persists to DB, broadcasts | +| `process_monitor.py` | `ProcessMonitorThread` — detects crashes, triggers auto-restart | +| `metrics_collector.py` | `MetricsCollectorThread` — psutil CPU/RAM collection every 10s | +| `remote_admin_poller.py` | `RemoteAdminPollerThread` — polls player list via RCon, syncs join/leave events | + +### `core/jobs/` — Scheduled Tasks + +| Module | Purpose | +|---|---| +| `scheduler.py` | APScheduler `BackgroundScheduler` singleton | +| `cleanup_jobs.py` | 3 cron jobs: old logs (daily 03:00), old metrics (6h), old events (weekly Sun 04:00) | + +### `core/dal/` — Data Access Layer + +| Module | Purpose | +|---|---| +| `base_repository.py` | `BaseRepository` — thin wrapper around SQLAlchemy `text()` queries | +| `server_repository.py` | `ServerRepository` — CRUD, status updates, running servers, restart count | +| `config_repository.py` | `ConfigRepository` — Per-section upsert with Fernet encryption and optimistic locking | +| `player_repository.py` | `PlayerRepository` — Upsert/clear players, player_history queries | +| `ban_repository.py` | `BanRepository` — Ban CRUD with active/inactive flag | +| `event_repository.py` | `EventRepository` — Insert server events, query, cleanup | +| `log_repository.py` | `LogRepository` — Insert parsed log entries, query with filters, cleanup | +| `metrics_repository.py` | `MetricsRepository` — Insert CPU/RAM metrics, query by time range, cleanup | + +### `core/utils/` — Utilities + +| Module | Purpose | +|---|---| +| `crypto.py` | Fernet field-level encryption: `encrypt()`, `decrypt()`, `is_encrypted()` | +| `file_utils.py` | `get_server_dir()`, `ensure_server_dirs()`, `sanitize_filename()`, `safe_delete_file()` | +| `port_checker.py` | Port availability checks with cross-server conflict detection | --- -## Module Details - -### `main.py` -Entry point. Creates and configures the FastAPI application. - -```python -# Responsibilities: -# - Create FastAPI app instance -# - 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, auto-register adapters, -# restore running servers -# shutdown: gracefully stop all BroadcastThread, close DB -# - Auto-register built-in adapters (arma3) on import - -Key functions: - create_app() -> FastAPI - on_startup() # DB migrations, register adapters, recover state - on_shutdown() # Clean up threads, close connections -``` - ---- - -### `config.py` -Loads and validates environment variables using Pydantic `BaseSettings`. - -```python -class Settings(BaseSettings): - secret_key: str - encryption_key: str # Fernet base64 key (NOT hex) - db_path: str = "./languard.db" - servers_dir: str = "./servers" - host: str = "0.0.0.0" - port: int = 8000 - cors_origins: list[str] = ["http://localhost:5173"] - log_retention_days: int = 7 - metrics_retention_days: int = 30 - player_history_retention_days: int = 90 - 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. **No game-specific logic.** - -```python -# Responsibilities: -# - Create SQLAlchemy engine with WAL + FK + busy_timeout pragmas -# - 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 - -Key functions: - get_engine() -> Engine - get_db() -> Generator[Connection, None, None] # FastAPI dependency - get_thread_db() -> Connection # for threads - run_migrations(engine: Engine) -``` - ---- - -### `dependencies.py` -Reusable FastAPI dependencies. **No game-specific logic.** - -```python -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 -``` - ---- - -### Core: `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}` -- `POST /servers/{id}/start` -- `POST /servers/{id}/stop` -- `POST /servers/{id}/restart` -- `POST /servers/{id}/kill` -- `GET /servers/{id}/config` -- `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(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 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 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 - # 5. ThreadRegistry.stop_server_threads(server_id) - # 6. PlayerRepository.clear(server_id) - # 7. Broadcast status update - - def restart(server_id) -> StatusResponse - - 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 **(game-agnostic)** -```python -class ProcessManager: - _instance = None - _processes: dict[int, subprocess.Popen] = {} - _lock: threading.Lock - _operation_locks: dict[int, threading.Lock] # per-server operation lock - - @classmethod - def get() -> ProcessManager - - def get_operation_lock(server_id) -> threading.Lock - # Returns a per-server lock that serializes start/stop/restart - # Prevents concurrent start+stop races for the same server - def start(server_id, exe_path, args: list[str], cwd: str) -> int # returns PID - def stop(server_id, timeout=30) -> bool - def kill(server_id) -> bool - 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] - def recover_on_startup(db) - # Checks PID still alive AND process name matches adapter allowlist - # (prevents PID reuse by unrelated processes) -``` - ---- - -### Core: `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, 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_remote_admin(server_id, players: list) -> None - # Upserts players table; detects disconnections; inserts player_history rows -``` - ---- - -### Core: `logs/` - -**`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 - def get_log_path(server_id) -> Path | None - # Resolves adapter's log_parser.get_log_file_resolver() - def cleanup_old_logs() # called by APScheduler -``` - ---- - -### Core: `metrics/`, `bans/`, `events/` - -Same pattern as above — game-agnostic services using core tables. - ---- - -### Core: `threads/` - -**`base_thread.py`** — `BaseServerThread` **(game-agnostic)** -```python -class BaseServerThread(threading.Thread): - def __init__(server_id: int, interval: float) - def stop() # sets _stop_event - def is_stopped() -> bool - def run() # creates thread-local DB connection, calls setup(), - # then loops: tick(self._db) + wait(interval) - # finally calls teardown() in finally block - def setup() # override for init work (receives self._db) - 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 -``` - -**`process_monitor.py`** — `ProcessMonitorThread` **(game-agnostic)** -```python -class ProcessMonitorThread(BaseServerThread): - interval = 1.0 # seconds - - def tick(): - # 1. Check if process is still alive (os.kill(pid, 0)) - # 2. If dead: - # a. Get exit code - # b. DB: status = 'crashed', stopped_at = now - # c. Clear players from DB - # d. Broadcast: {type: 'status', status: 'crashed'} - # e. Insert server_events: {event_type: 'crashed', exit_code} - # f. If auto_restart: schedule restart with exponential backoff - # g. self.stop() -``` - -**`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 log file using adapter's resolver - # Open file, seek to end (tail behavior) - - def tick(): - # 1. Read all new lines from log file - # 2. For each line: - # a. self._parser.parse_line(line) -> LogEntry - # b. LogRepository.insert(server_id, entry) - # c. BroadcastThread.enqueue(server_id, 'log', entry) - - def teardown(): - # Close the open log file handle -``` - -**`metrics_collector.py`** — `MetricsCollectorThread` **(game-agnostic)** -```python -class MetricsCollectorThread(BaseServerThread): - interval = 5.0 # seconds - - def tick(): - # 1. Get PID from ProcessManager - # 2. psutil.Process(pid).cpu_percent(interval=0.5) - # 3. psutil.Process(pid).memory_info().rss / (1024*1024) # MB - # 4. PlayerRepository.count(server_id) -> player_count - # 5. MetricsRepository.insert(server_id, cpu, ram, player_count) - # 6. BroadcastThread.enqueue(server_id, 'metrics', {cpu, ram, player_count}) -``` - -**`remote_admin_poller.py`** — `RemoteAdminPollerThread` **(core thread + adapter client)** -```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 - - def setup(): - # Wait for server startup (using _stop_event.wait instead of sleep) - # Create client via factory - - def tick(): - 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` **(adapter-aware)** -```python -class ThreadRegistry: - _threads: dict[int, dict[str, BaseServerThread]] = {} - _lock = threading.Lock() - - @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) - - # 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), - ) - - # 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), - ), - ) - - # 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() -``` - ---- - -### Core: `games/` - -**`router.py`** — Game type discovery endpoints. -```python -# 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 -``` - ---- - -### Core: `dal/` - -**`config_repository.py`** — Manages the `game_configs` table. -```python -class ConfigRepository(BaseRepository): - 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]): ... -``` - ---- - -### Adapters: `protocols.py` - -Defines all capability protocols. See ARCHITECTURE.md for the full protocol definitions. - -```python -@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 migrate_config(self, old_version: str, config_json: dict[str, dict]) -> dict[str, dict]: - """Transform config JSON from an older schema version to the current one. - Called by ConfigRepository when a section's stored schema_version - differs from get_config_version(). Returns the migrated config dict. - Raises ConfigMigrationError on failure (core rolls back; original stays).""" - ... - 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]: ... - -@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: ... - -@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: ... -``` - ---- - -### Adapters: `registry.py` - -```python -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: `arma3/` - -**`adapter.py`** — `Arma3Adapter` -```python -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 safe_delete_file(path: Path) -> bool -def sanitize_filename(filename: str) -> str -``` - -**`port_checker.py`** — Game-agnostic port checking. -```python -def is_port_in_use(port: int, host: str = "0.0.0.0") -> bool - -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 -``` - -**`crypto.py`** — Game-agnostic encryption. -```python -def encrypt(plaintext: str) -> str -def decrypt(ciphertext: str) -> str -def get_fernet() -> Fernet -``` - ---- - -## Key Dependencies (requirements.txt) - -``` -fastapi==0.111.0 -uvicorn[standard]==0.29.0 -pydantic==2.7.0 -pydantic-settings==2.2.1 -sqlalchemy==2.0.30 -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 -python-multipart==0.0.9 # file upload support -slowapi==0.1.9 # rate limiting middleware -``` \ No newline at end of file +## Frontend Modules + +### `src/main.tsx` — Entry Point +Renders `` into `#root` with React StrictMode. + +### `src/App.tsx` — Root Component +- Creates `QueryClient` with stale time 10s, retry 2, refetchOnWindowFocus false +- Wraps app in `QueryClientProvider`, `BrowserRouter`, `ReactQueryDevtools` +- Routes: `/login` → `LoginPage`, `/*` → `ProtectedLayout` (auth guard) +- `ProtectedLayout` checks `isAuthenticated` from Zustand, redirects to `/login` if false + +### `src/lib/api.ts` — API Client +- Axios instance with `baseURL` from `VITE_API_URL` env, 30s timeout +- Request interceptor: reads `languard_token` from localStorage, adds `Authorization: Bearer` header +- Response interceptor: on 401, checks URL prefix — non-auth 401s clear token and redirect to `/login`; auth 401s (login) are left for the component to handle +- Exports `ApiResponse` type: `{ success, data, error? }` + +### `src/store/auth.store.ts` — Auth Store (Zustand + persist) +- Persisted to `localStorage` under key `languard-auth` +- `onRehydrateStorage` sets `isAuthenticated: true` if token exists +- `partialize` stores only `token` and `user` +- `setAuth(token, user)` also writes `languard_token` to localStorage +- `clearAuth()` removes both storage keys + +### `src/store/ui.store.ts` — UI Store (Zustand, in-memory) +- `sidebarOpen`, `activeServerId`, `notifications[]` +- `addNotification(type, message)` creates toast with auto-remove (5s timeout) +- `removeNotification(id)` for manual dismiss + +### `src/hooks/useServers.ts` — Server Data Hooks +7 TanStack Query hooks: `useServers`, `useServer`, `useStartServer`, `useStopServer`, `useRestartServer`, `useCreateServer`, `useDeleteServer` +- `Server` interface with all fields +- `useServers` refetches every 30s +- Mutations invalidate relevant cache keys on success + +### `src/hooks/useWebSocket.ts` — WebSocket Hook +- Connects to `ws://localhost:8000/ws?token=...&server_id=...` +- Exponential backoff reconnect (2s → 30s max) +- Close code 4001 = explicit logout (no reconnect) +- Invalidates TanStack Query caches on server_status, metrics, log, players events +- Cleans up on unmount (closes WebSocket, clears reconnect timeout) + +### `src/pages/LoginPage.tsx` — Login Page +- React Hook Form + Zod validation (username/password required) +- `apiClient.post("/api/auth/login")` on submit +- `setAuth(token, user)` + navigate to `/` on success +- Error display from catch block +- Loading state: button changes to "Signing in..." + +### `src/pages/DashboardPage.tsx` — Dashboard Page +- `useServers()` for data, `useWebSocket()` for real-time updates +- Loading state, error state, empty state, content with server grid +- Server cards rendered via `ServerCard` inside `Link` components +- "X servers configured" counter + +### `src/components/layout/Sidebar.tsx` — Sidebar +- "Languard Server Manager" branding +- Dashboard link with LayoutDashboard icon +- Dynamic server list from `useServers()` with `StatusLed` dots +- Settings link at bottom +- Active server highlighting via URL params + +### `src/components/servers/ServerCard.tsx` — Server Card +- Displays: server name, `StatusLed`, game type badge +- 3-column stat grid: Players (current/max), Port, Restarts +- Action buttons based on server status: + - **Stopped**: Start button only + - **Running**: Stop + Restart buttons + - **Starting/Restarting**: Stop + Restart (disabled) +- `e.preventDefault()` + `e.stopPropagation()` on buttons to prevent parent `Link` navigation +- Success/error notifications via `useUIStore` + +### `src/components/ui/StatusLed.tsx` — Status LED +- Colored dot with CSS class `status-led-{status}` (5 statuses) +- Sizes: `sm` (6px), `md` (8px) +- Optional label text via `showLabel` prop +- Uses `clsx` for conditional class merging \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..9fe04ae --- /dev/null +++ b/README.md @@ -0,0 +1,139 @@ +# Languard Server Manager + +A multi-game server management platform with a Python/FastAPI backend and React/TypeScript frontend. Currently supports Arma 3 with an extensible adapter system for adding more games. + +## Tech Stack + +### Backend +- **Python 3.12+** / **FastAPI** — async REST API +- **SQLite** with WAL mode — zero-config database +- **SQLAlchemy** — raw SQL via `text()` queries (no ORM) +- **BattlEye RCon** — UDP protocol v2 for remote admin +- **APScheduler** — background cleanup jobs +- **psutil** — process monitoring and resource metrics +- **JWT** (python-jose) + **bcrypt** — authentication +- **Fernet** (cryptography) — sensitive config field encryption + +### Frontend +- **React 19** / **TypeScript 6** / **Vite 8** +- **TanStack Query v5** — server state management +- **Zustand 5** — client state (auth, UI) +- **Tailwind CSS** — dark neumorphic design system +- **Playwright** — E2E testing (23 tests) +- **Vitest** + **React Testing Library** — unit tests (69 tests) + +## Quick Start + +### Backend + +```bash +cd backend +python -m venv venv +source venv/bin/activate # Windows: venv\Scripts\activate +pip install -r requirements.txt +cp .env.example .env # Edit with your settings +uvicorn main:app --reload +``` + +First run prints a generated admin password. Change it immediately via `PUT /api/auth/password`. + +### Frontend + +```bash +cd frontend +npm install +npm run dev +``` + +Opens at `http://localhost:5173`. The dev server proxies `/api` to the backend on port 8000. + +## Running Tests + +### Frontend Unit Tests + +```bash +cd frontend +npm test # Watch mode +npx vitest run # Single run +npx vitest run --coverage # With coverage +``` + +### Frontend E2E Tests + +```bash +cd frontend +# Start backend + frontend dev server first +npx playwright test # All tests (mocked + integration) +npx playwright tests-e2e/integration/ # Full-stack integration tests only +``` + +## Project Structure + +``` +languard-servers-manager/ +├── backend/ +│ ├── main.py # FastAPI app factory, lifespan, middleware +│ ├── config.py # Pydantic Settings (env vars) +│ ├── database.py # SQLAlchemy engine, migration runner +│ ├── dependencies.py # FastAPI deps: auth, admin, server, adapter +│ ├── adapters/ # Game adapter system +│ │ ├── protocols.py # Protocol definitions (7 capabilities) +│ │ ├── registry.py # GameAdapterRegistry singleton +│ │ ├── exceptions.py # Typed adapter exceptions +│ │ └── arma3/ # Arma 3 adapter (7/7 capabilities) +│ ├── core/ +│ │ ├── auth/ # JWT auth, user CRUD +│ │ ├── servers/ # Server service, routers, process manager +│ │ ├── games/ # Game type discovery +│ │ ├── system/ # Health and status endpoints +│ │ ├── websocket/ # WS manager, broadcast thread +│ │ ├── threads/ # Background thread registry +│ │ ├── dal/ # Data access layer (repositories) +│ │ ├── jobs/ # APScheduler cleanup jobs +│ │ ├── utils/ # Crypto, file utils, port checker +│ │ └── migrations/ # SQL migration scripts +│ └── requirements.txt +├── frontend/ +│ ├── src/ +│ │ ├── App.tsx # Router + auth guard +│ │ ├── pages/ # LoginPage, DashboardPage +│ │ ├── components/ # Sidebar, ServerCard, StatusLed +│ │ ├── hooks/ # useServers, useWebSocket +│ │ ├── store/ # auth.store, ui.store (Zustand) +│ │ ├── lib/ # api.ts (Axios client) +│ │ └── __tests__/ # Vitest unit tests +│ ├── tests-e2e/ # Playwright E2E tests +│ └── playwright.config.ts +├── API.md # REST + WebSocket API reference +├── ARCHITECTURE.md # System architecture overview +├── DATABASE.md # Database schema reference +├── FRONTEND.md # Frontend architecture and components +├── MODULES.md # Module-by-module reference +└── THREADING.md # Background threading model +``` + +## Environment Variables + +| Variable | Default | Description | +|---|---|---| +| `LANGUARD_SECRET_KEY` | (required) | JWT signing key | +| `LANGUARD_ENCRYPTION_KEY` | (required) | Fernet key for sensitive config fields | +| `LANGUARD_DB_PATH` | `./languard.db` | SQLite database path | +| `LANGUARD_SERVERS_DIR` | `./servers` | Base directory for server data | +| `LANGUARD_HOST` | `0.0.0.0` | Listen host | +| `LANGUARD_PORT` | `8000` | Listen port | +| `LANGUARD_CORS_ORIGINS` | `["http://localhost:5173"]` | CORS allowed origins | +| `LANGUARD_LOG_RETENTION_DAYS` | `7` | Log cleanup retention | +| `LANGUARD_METRICS_RETENTION_DAYS` | `30` | Metrics cleanup retention | +| `LANGUARD_PLAYER_HISTORY_RETENTION_DAYS` | `90` | Player history retention | +| `LANGUARD_JWT_EXPIRE_HOURS` | `24` | JWT token expiry | +| `LANGUARD_ARMA3_DEFAULT_EXE` | (required for Arma 3) | Default Arma 3 executable path | + +## Documentation + +- **[ARCHITECTURE.md](ARCHITECTURE.md)** — System design, component diagram, security model +- **[API.md](API.md)** — Complete REST + WebSocket API reference +- **[DATABASE.md](DATABASE.md)** — Schema, tables, indexes, migration system +- **[FRONTEND.md](FRONTEND.md)** — React component tree, state management, design system +- **[MODULES.md](MODULES.md)** — File-by-file module reference +- **[THREADING.md](THREADING.md)** — Background thread model and concurrency \ No newline at end of file diff --git a/THREADING.md b/THREADING.md index 82f88a3..09274d8 100644 --- a/THREADING.md +++ b/THREADING.md @@ -1,782 +1,173 @@ -# Languard Servers Manager — Threading & Concurrency Design +# Threading & Concurrency Model ## Overview -The system uses a hybrid concurrency model: -- **FastAPI (asyncio)** handles HTTP requests and WebSocket connections -- **Python threads** (`threading.Thread`) handle long-running background work per server -- **Queue** bridges the thread world → asyncio world for WebSocket broadcasting -- **SQLAlchemy sync sessions** are used in threads (thread-local connections) +Languard uses a hybrid concurrency model: -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. +- **FastAPI (asyncio)** handles HTTP requests and WebSocket connections on the main event loop +- **Python `threading.Thread`** handles long-running background work per server +- **`queue.Queue`** bridges the thread world to the asyncio world for WebSocket broadcasting +- **SQLAlchemy sync sessions** with thread-local connections provide thread-safe database access ---- +## Thread Architecture -## Thread Map +For N running servers, the system runs up to 4N+1 background threads: -``` -Main Process (FastAPI / asyncio event loop) -│ -├── [uvicorn] HTTP/WS event loop (asyncio) -│ ├── REST request handlers (async def / plain def) -│ └── WebSocket handlers (async def) -│ -├── BroadcastThread (daemon thread, 1 global) -│ └── Reads from broadcast_queue (thread-safe) -│ Calls asyncio.run_coroutine_threadsafe() -│ → ConnectionManager.broadcast() -│ -└── Per-running-server thread group (started when server starts, stopped when server stops): - ├── 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 -``` +| Thread Type | Count | Purpose | +|---|---|---| +| `BroadcastThread` | 1 (global) | Bridges `queue.Queue` to asyncio WebSocket broadcasts | +| `LogTailThread` | 1 per server | Tails .rpt log files, parses lines, persists to DB, broadcasts events | +| `ProcessMonitorThread` | 1 per server | Monitors server process, detects crashes, triggers auto-restart | +| `MetricsCollectorThread` | 1 per server | Collects CPU/RAM metrics via psutil every 10 seconds | +| `RemoteAdminPollerThread` | 1 per server | Polls player list via RCon, syncs join/leave events | -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`) +All server-specific threads are managed by `ThreadRegistry`, which creates/destroys thread bundles as servers start/stop. ---- +## BaseServerThread -## Adapter Injection into Threads +All background threads extend `BaseServerThread`, which provides: -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() -``` - ---- - -## Thread Safety Rules - -| Resource | Access Pattern | Protection | -|----------|---------------|------------| -| `ProcessManager._processes` | read/write from multiple threads | `threading.Lock` | -| `ThreadRegistry._threads` | read/write from main + shutdown | `threading.Lock` | -| `broadcast_queue` | multi-writer, single reader | `queue.Queue` (thread-safe built-in) | -| `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 -# Each background thread creates its own SQLAlchemy connection -# from the same engine (WAL mode allows concurrent reads) -# PRAGMA busy_timeout=5000 prevents "database is locked" errors -# -# If busy_timeout is exhausted (5s), the write fails with -# OperationalError. Background threads retry with exponential -# backoff: 1s, 2s, 4s — then log and skip the tick. -# API request handlers retry up to 2 times with 1s backoff, -# then return 503 "database temporarily unavailable". - -class BaseServerThread(threading.Thread): - _db_retry_delays = [1.0, 2.0, 4.0] # seconds, exponential backoff - - def run(self): - engine = get_engine() - self._db = engine.connect() - try: - self.setup() - while not self._stop_event.is_set(): - try: - self.tick() - except OperationalError as e: - if "database is locked" in str(e): - retried = self._retry_db_write(self.tick) - if not retried: - logger.warning(f"{self.name}: DB locked after all retries, skipping tick") - else: - self.on_error(e) - except Exception as e: - self.on_error(e) - self._stop_event.wait(self.interval) - except Exception as e: - logger.error(f"{self.name} setup error: {e}") - finally: - self.teardown() - self._db.close() - - def _retry_db_write(self, fn, max_retries=3): - for i, delay in enumerate(self._db_retry_delays[:max_retries]): - self._stop_event.wait(delay) - if self._stop_event.is_set(): - return False - try: - fn() - return True - except OperationalError: - continue - return False -``` - ---- - -## BroadcastThread — Asyncio Bridge - -This is the critical bridge between background threads and the asyncio WebSocket layer. **Game-agnostic.** - -``` -Background Thread Asyncio Event Loop -───────────────── ────────────────── -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({ 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 -# core/websocket/broadcaster.py -import asyncio -import queue -import threading - -_broadcast_queue: queue.Queue = queue.Queue(maxsize=10000) -_event_loop: asyncio.AbstractEventLoop | None = None - -class BroadcastThread(threading.Thread): - daemon = True - - def __init__(self, loop: asyncio.AbstractEventLoop, manager): - super().__init__(name="BroadcastThread") - self._loop = loop - self._manager = manager - self._running = True - - def run(self): - while self._running: - try: - msg = _broadcast_queue.get(timeout=1.0) - server_id = msg['server_id'] - outgoing = { - 'type': msg['type'], - 'server_id': server_id, - 'data': msg['data'], - } - future = asyncio.run_coroutine_threadsafe( - self._manager.broadcast(str(server_id), outgoing, channel=msg['type']), - self._loop - ) - try: - future.result(timeout=5.0) - except TimeoutError: - logger.warning(f"Broadcast timeout for server {server_id} msg type {msg['type']}") - except queue.Empty: - continue - except Exception as e: - logger.error(f"BroadcastThread error: {e}") - - def stop(self): - self._running = False - - @staticmethod - def enqueue(server_id: int, msg_type: str, data: dict): - """Thread-safe. Called from any background thread.""" - try: - _broadcast_queue.put_nowait({ - 'server_id': server_id, - 'type': msg_type, - 'data': data, - }) - except queue.Full: - logger.warning(f"Broadcast queue full, dropping {msg_type} for server {server_id}") -``` - ---- - -## 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 - - def tick(self): - proc = ProcessManager.get().get_process(self.server_id) - if proc is None: - self.stop() - return - - exit_code = proc.poll() - if exit_code is not None: - self._handle_process_exit(exit_code) - self.stop() - - def _handle_process_exit(self, exit_code: int): - is_crash = (exit_code != 0) - status = 'crashed' if is_crash else 'stopped' - - server = ServerRepository(self._db).get_by_id(self.server_id) - ServerRepository(self._db).update_status( - self.server_id, status, pid=None, - stopped_at=datetime.utcnow().isoformat() - ) - PlayerRepository(self._db).clear(self.server_id) - ServerEventRepository(self._db).insert( - self.server_id, status, - actor='system', - detail={'exit_code': exit_code} - ) - - BroadcastThread.enqueue(self.server_id, 'status', {'status': status}) - BroadcastThread.enqueue(self.server_id, 'event', { - 'event_type': status, - 'detail': {'exit_code': exit_code} - }) - - # 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) - if is_crash and server.get('auto_restart'): - self._schedule_auto_restart(server) - except Exception as e: - logger.error(f"Cleanup/restart failed for server {self.server_id}: {e}") - BroadcastThread.enqueue(self.server_id, 'event', { - 'event_type': 'auto_restart_failed', - 'detail': {'error': str(e)} - }) - - _threading.Thread( - target=_cleanup_and_maybe_restart, - daemon=True, - name=f"StopCleanup-{self.server_id}" - ).start() - - def _schedule_auto_restart(self, server: dict): - # IMPORTANT: Runs in daemon cleanup thread, NOT ProcessMonitorThread. - # Must create its own DB connection. - from database import get_thread_db - db = get_thread_db() - - restart_count = server['restart_count'] - max_restarts = server['max_restarts'] - window = server['restart_window_seconds'] - last_restart = server.get('last_restart_at') - - if last_restart: - last_dt = datetime.fromisoformat(last_restart) - elapsed = (datetime.utcnow() - last_dt).total_seconds() - if elapsed > window: - ServerRepository(db).reset_restart_count(self.server_id) - restart_count = 0 - - if restart_count < max_restarts: - delay = min(10 * (restart_count + 1), 60) # exponential backoff - logger.info(f"Auto-restarting server {self.server_id} in {delay}s (attempt {restart_count+1}/{max_restarts})") - threading.Timer(delay, self._auto_restart).start() - else: - logger.warning(f"Server {self.server_id} exceeded max auto-restarts ({max_restarts})") - BroadcastThread.enqueue(self.server_id, 'event', { - 'event_type': 'max_restarts_exceeded', - 'detail': {'restart_count': restart_count} - }) - - def _auto_restart(self): - from core.servers.service import ServerService - try: - ServerService().start(self.server_id) - except Exception as e: - logger.error(f"Auto-restart failed for server {self.server_id}: {e}") -``` - ---- - -## LogTailThread — Generic File Tailing with Adapter Parser - -**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 __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 - - def setup(self): - self._open_latest_log() - - def _open_latest_log(self): - """ - 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. Instead, track filename and file size. - """ - 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 - - try: - 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_log() - return - - # 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 - 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_log() - return - - try: - current_size = self._current_path.stat().st_size - except OSError: - return - - if current_size < self._last_size: - self._file.close() - self._open_latest_log() - return - - # Read new lines and parse using adapter's parser - while True: - line = self._file.readline() - if not line: - break - self._last_size = self._file.tell() - line = line.rstrip('\n') - if not line: - continue - - # 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): - if self._file is not None: - try: - self._file.close() - except OSError: - pass - self._file = None -``` - ---- - -## MetricsCollectorThread — Game-Agnostic Resource Monitoring - -**Fully game-agnostic.** Uses psutil to monitor any process. - -```python -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._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"Remote admin reconnect attempt {self._reconnect_attempts} for server {self.server_id}") - if self._stop_event.wait(delay): - return - self._connect() - if not self._connected: - return - self._reconnect_attempts = 0 - - try: - 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 for p in players], - 'count': len(players), - }) - except ConnectionError: - self._connected = False - 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 -``` - ---- - -## Thread Lifecycle - -### Start Server Flow -``` -POST /servers/{id}/start - │ - ├── ServerService.start() - │ ├── 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, 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 - │ - ├── 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() - │ ├── LogTailThread.stop() - │ ├── MetricsCollectorThread.stop() - │ └── RemoteAdminPollerThread.stop() ← if present - │ └── Thread.join(timeout=5) for each - │ - └── BroadcastThread.enqueue(id, 'status', {status: 'stopped'}) -``` - -### App Shutdown Flow -``` -FastAPI shutdown event - │ - ├── ThreadRegistry.stop_all() ← stop all threads for all servers - ├── BroadcastThread.stop() - ├── ConnectionManager.close_all() - └── database engine dispose -``` - ---- - -## Stop Event Pattern - -All background threads use a `threading.Event` for graceful shutdown: +- **Stop event**: `threading.Event` for graceful shutdown +- **Thread-local DB**: Creates a fresh SQLAlchemy connection per thread via `get_thread_db()` +- **Exception backoff**: On unhandled exceptions, sleeps with exponential backoff (5s → 30s max), then retries. If stop event is set, exits cleanly. +- **Abstract `run_loop()` method**: Subclasses implement the main loop, called repeatedly until stop event is set ```python class BaseServerThread(threading.Thread): - def __init__(self, server_id: int, interval: float): - super().__init__(name=f"{self.__class__.__name__}-{server_id}", daemon=True) + def __init__(self, server_id: int, ...): + super().__init__(daemon=True) self.server_id = server_id - self.interval = interval self._stop_event = threading.Event() def stop(self): self._stop_event.set() - def is_stopped(self) -> bool: - return self._stop_event.is_set() - - def teardown(self): - """Override to release resources (close files, sockets) after the loop ends.""" - pass - def run(self): - try: - self.setup() - except Exception as e: - logger.error(f"{self.name} setup error: {e}") - return # setup failed completely - - try: - while not self._stop_event.is_set(): - try: - self.tick() - except Exception as e: - self.on_error(e) - self._stop_event.wait(self.interval) - finally: - 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 -# core/websocket/manager.py -class ConnectionManager: - def __init__(self): - self._connections: dict[str, set[WebSocket]] = defaultdict(set) - self._channel_subs: dict[WebSocket, set[str]] = defaultdict(set) - self._lock = asyncio.Lock() - - async def connect(self, ws: WebSocket, server_id: str): - await ws.accept() - async with self._lock: - self._connections[server_id].add(ws) - self._channel_subs[ws].add('status') - if server_id == 'all': - self._connections['all'].add(ws) - - async def disconnect(self, ws: WebSocket, server_id: str): - async with self._lock: - self._connections[server_id].discard(ws) - self._connections['all'].discard(ws) - self._channel_subs.pop(ws, None) - - async def subscribe(self, ws: WebSocket, channels: list[str]): - async with self._lock: - self._channel_subs[ws].update(channels) - - async def unsubscribe(self, ws: WebSocket, channels: list[str]): - async with self._lock: - self._channel_subs[ws].difference_update(channels) - - async def broadcast(self, server_id: str, message: dict, channel: str = None): - targets: set[WebSocket] = set() - async with self._lock: - server_clients = self._connections.get(server_id, set()) - all_clients = self._connections.get('all', set()) - candidates = server_clients | all_clients - - if channel: - targets = {ws for ws in candidates - if channel in self._channel_subs.get(ws, set())} - else: - targets = candidates - - dead = [] - for ws in targets: + while not self._stop_event.is_set(): try: - await ws.send_json(message) + self.run_loop() except Exception: - dead.append(ws) - - if dead: - async with self._lock: - for ws in dead: - for bucket in self._connections.values(): - bucket.discard(ws) - self._channel_subs.pop(ws, None) + backoff = min(backoff * 2, 30) + self._stop_event.wait(backoff) ``` ---- +## ThreadRegistry -## Memory & Performance Considerations +`ThreadRegistry` manages thread lifecycle per server: -| Thread | Memory Impact | CPU Impact | -|--------|--------------|-----------| -| ProcessMonitorThread | Minimal (one `os.kill` check) | Negligible | -| LogTailThread | Buffer for unread log lines | Low (file I/O + adapter parsing) | -| MetricsCollectorThread | psutil subprocess scan | Low-Medium | -| RemoteAdminPollerThread | Adapter client socket + buffer | Low (varies by adapter protocol) | -| BroadcastThread | Queue buffer (max 10000 entries) | Low | +- **`start_server_threads(server_id, db)`** — Creates and starts all 4 thread types for a server +- **`stop_server_threads(server_id)`** — Sets stop events and joins all threads for a server +- **`reattach_server_threads(server_id, db)`** — Recovers threads for a server that survived a process restart +- **`stop_all()`** — Stops all threads for all servers (called on shutdown) -### Recommendations -- Set all threads as `daemon=True` — they die automatically if main process exits -- `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: 31-41 background threads — well within Python's thread limits -- Games without remote admin skip the RemoteAdminPollerThread entirely \ No newline at end of file +Thread bundles are stored in a dict: `{server_id → ThreadBundle}`, where `ThreadBundle` is a dataclass holding all thread references. + +## BroadcastThread + +The `BroadcastThread` is the single global thread that bridges synchronous background threads to asynchronous WebSocket clients: + +1. Background threads push events into a `queue.Queue(maxsize=1000)` +2. `BroadcastThread` runs a loop reading from the queue +3. For each event, it calls `asyncio.run_coroutine_threadsafe()` to schedule a WebSocket broadcast on the main event loop +4. If the queue is full, events are dropped (non-blocking put) + +Events are broadcast to WebSocket clients subscribed to the relevant `server_id` (or `None` for all servers). + +## ProcessManager + +`ProcessManager` is a singleton that manages server processes via `subprocess.Popen`: + +- **`start_process(server_id, cmd, cwd, env)`** — Starts a new subprocess, stores the PID +- **`stop_process(server_id, timeout)`** — Sends terminate signal, waits for exit, force-kills after timeout +- **`kill_process(server_id)`** — Force-kills the process immediately +- **`recover_on_startup(db)`** — On startup, checks all stored PIDs against running processes via `psutil.pid_exists()`. If a process is still alive, marks the server as running. If not, marks it as stopped. +- Thread-safe with per-server `threading.Lock` + +## LogTailThread + +Tails the Arma 3 .rpt log file for each server: + +- Resolves the latest log file path using the adapter's `LogParser.get_latest_log_file()` +- Reads new lines from the end of the file, detecting log rotation (Windows/NTFS safe) +- Parses each line using `RPTParser.parse_line()` to extract timestamp, level, and message +- Persists parsed entries to the `logs` table via `LogRepository` +- Broadcasts `log` events via the global queue + +## ProcessMonitorThread + +Monitors each server process for crashes: + +- Checks every 5 seconds whether the process is still alive +- If the process has exited unexpectedly: + 1. Updates server status to `crashed` + 2. Logs the crash event + 3. If `auto_restart` is enabled and restart count hasn't exceeded `max_restarts` within the `restart_window_seconds`: + - Triggers a restart via `ServerService.start_server()` + - Increments `restart_count` + +## MetricsCollectorThread + +Collects CPU and RAM metrics for each running server: + +- Uses `psutil.Process(pid)` to get CPU and memory usage +- Collects every 10 seconds +- Stores metrics in the `metrics` table via `MetricsRepository` +- Broadcasts `metrics` events via the global queue + +## RemoteAdminPollerThread + +Polls the BattlEye RCon interface for player list updates: + +- Connects via `Arma3RemoteAdmin` using `BERConClient` +- Polls player list every 10 seconds +- Compares current players with previous state to detect joins/leaves +- On player join: upserts to `players` table, inserts to `player_history`, broadcasts `players` event +- On player leave: removes from `players`, updates `left_at` in `player_history`, broadcasts `players` event +- On RCon connection failure: reconnects with exponential backoff + +## WebSocketManager + +Runs on the main asyncio event loop: + +- Clients connect to `/ws?token=JWT&server_id=N` +- JWT is validated on connection; invalid tokens close with code 4001 +- Clients subscribe to specific `server_id`s or `None` (all servers) +- `broadcast(server_id, message)` sends JSON-encoded messages to matching subscribers +- `disconnect(websocket)` removes the client from the registry +- Thread-safe via `asyncio.Lock` + +## Thread Safety Rules + +1. **Database access**: Each thread uses its own connection via `get_thread_db()`. No shared DB connections. +2. **WebSocket broadcasting**: Threads write to `queue.Queue`, which is thread-safe. Only `BroadcastThread` reads from the queue. +3. **Process management**: `ProcessManager` uses per-server locks for thread-safe start/stop operations. +4. **SQLite WAL mode**: Enables concurrent reads from multiple threads while a single writer operates. +5. **Asyncio locks**: `WebSocketManager` uses `asyncio.Lock` for connection registry modifications. + +## Scheduled Jobs + +APScheduler `BackgroundScheduler` runs 3 cleanup cron jobs: + +| Job | Schedule | Cleanup | +|---|---|---| +| Clean up old log entries | Daily at 03:00 | `DELETE FROM logs WHERE created_at < datetime('now', '-7 days')` | +| Clean up old metrics | Every 6 hours | `DELETE FROM metrics WHERE timestamp < datetime('now', '-1 day')` | +| Clean up old events | Weekly (Sunday 04:00) | `DELETE FROM server_events WHERE created_at < datetime('now', '-30 days')` | + +## Startup Sequence + +1. Init DB engine and run pending migrations +2. Register built-in adapters (Arma 3) and scan for third-party plugins +3. Create `WebSocketManager` (asyncio-only) +4. Create global `BroadcastThread` (queue → asyncio bridge) +5. Create `ThreadRegistry` with `ProcessManager` and adapter registry +6. Recover processes that survived a restart (PID validation via psutil) +7. Re-attach monitoring threads for running servers +8. Seed default admin user if no users exist +9. Register and start APScheduler cleanup jobs + +## Shutdown Sequence + +1. Stop all server threads via `ThreadRegistry.stop_all()` +2. Stop `BroadcastThread` and join with 5s timeout +3. Stop APScheduler \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..e4251f8 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,12 @@ +LANGUARD_SECRET_KEY=changeme-generate-with-openssl-rand-hex-32 +LANGUARD_ENCRYPTION_KEY=changeme-generate-with-python-cryptography-fernet +LANGUARD_DB_PATH=./languard.db +LANGUARD_SERVERS_DIR=./servers +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 +LANGUARD_JWT_EXPIRE_HOURS=24 +LANGUARD_ARMA3_DEFAULT_EXE=C:/Arma3Server/arma3server_x64.exe \ No newline at end of file diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md new file mode 100644 index 0000000..284e6c6 --- /dev/null +++ b/backend/CLAUDE.md @@ -0,0 +1,73 @@ +# Languard Server Manager + +## Quick Start + +```bash +# Backend (from backend/) +python -m uvicorn main:app --host 0.0.0.0 --port 8000 --reload + +# Frontend (from frontend/) +npx vite --host +``` + +- Backend API: http://localhost:8000 (docs: http://localhost:8000/docs) +- Frontend: http://localhost:5173 +- Default admin: `admin` / (random, printed at first startup; reset via `python -c "from core.auth.utils import hash_password; print(hash_password('admin123'))"` then update SQLite) + +## Architecture + +FastAPI + SQLite backend, React 19 + TypeScript + Vite frontend. See ARCHITECTURE.md for full details. + +### Key Rules +- Frontend types must match API response shapes, NOT database schema columns +- There is no REST endpoint for logs — logs are only pushed via WebSocket events +- WebSocket `onEvent` callback is the mechanism for receiving real-time log entries +- Config updates use optimistic locking (config_version) — 409 on conflict +- Sensitive config fields are encrypted at rest with Fernet + +## Current Implementation Status + +### Backend: Fully implemented (42+ endpoints) +All routers, services, repositories, game adapter system, WebSocket, background threads, and scheduled cleanup are complete. + +### Frontend: Mostly implemented + +| Route | Status | Notes | +|-------|--------|-------| +| `/login` | Complete | Zod + react-hook-form validation | +| `/` | Complete | Dashboard with server grid | +| `/servers/:id` | Complete | 7-tab detail page (overview, config, players, bans, missions, mods, logs) | +| `/servers/new` | Partial | 4-step wizard; **known bug: form validation issues cause silent failure on submit** | +| `/settings` | Complete | Password change + admin user management | + +### Known Bugs (as of 2026-04-17) + +1. **Create Server silent failure**: The 4-step wizard's "Next" buttons don't validate before advancing steps, so users can reach step 3 with invalid data. `handleSubmit` then silently fails because validation errors prevent `onSubmit` from firing. Fix: validate on each "Next" click using `trigger()` from react-hook-form. + +### Frontend Type Mapping (API → Frontend) + +| API Resource | Frontend Type | Key Fields | +|---|---|---| +| Server (enriched) | `Server` in useServers.ts | `game_port`, `current_players`, `max_players`, `cpu_percent`, `ram_mb` | +| Mission | `Mission` in useServerDetail.ts | `name`, `filename`, `size_bytes` | +| Mod | `Mod` in useServerDetail.ts | `name`, `path`, `size_bytes`, `enabled` | +| Ban | `Ban` in useServerDetail.ts | `id`, `server_id`, `guid`, `name`, `reason`, `banned_by`, `banned_at`, `expires_at`, `is_active`, `game_data` | +| Player | `Player` in useServerDetail.ts | `id`, `slot_id`, `name`, `guid`, `ip`, `ping` | + +## Test Commands + +```bash +# Frontend unit tests +cd frontend && npx vitest run + +# Frontend type check +cd frontend && npx tsc --noEmit + +# Backend (no test suite yet) +``` + +## Future Enhancements (user requested) + +- Config sub-tab redesign for user-friendliness (non-technical users) +- "Choose mission" button that auto-selects mission for server config +- Mission rotation management \ No newline at end of file diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/adapters/__init__.py b/backend/adapters/__init__.py new file mode 100644 index 0000000..afe0e70 --- /dev/null +++ b/backend/adapters/__init__.py @@ -0,0 +1,39 @@ +""" +Auto-register all built-in adapters. +Also scans importlib entry_points for third-party adapters. +""" +import logging + +logger = logging.getLogger(__name__) + + +def load_builtin_adapters(): + """Import built-in adapter packages — they self-register on import.""" + from adapters.arma3 import ARMA3_ADAPTER # noqa: F401 + + +def load_third_party_adapters(): + """ + Scan 'languard.adapters' entry_point group for third-party adapters. + Third-party packages add this to their pyproject.toml: + [project.entry-points."languard.adapters"] + mygame = "mygame_adapter:MYGAME_ADAPTER" + """ + try: + from importlib.metadata import entry_points + eps = entry_points(group="languard.adapters") + for ep in eps: + try: + adapter = ep.load() + from adapters.registry import GameAdapterRegistry + GameAdapterRegistry.register(adapter) + logger.info("Loaded third-party adapter via entry_point: %s", ep.name) + except Exception as e: + logger.error("Failed to load third-party adapter '%s': %s", ep.name, e) + except Exception as e: + logger.warning("Entry point scanning failed: %s", e) + + +def initialize_adapters(): + load_builtin_adapters() + load_third_party_adapters() \ No newline at end of file diff --git a/backend/adapters/arma3/__init__.py b/backend/adapters/arma3/__init__.py new file mode 100644 index 0000000..35669b5 --- /dev/null +++ b/backend/adapters/arma3/__init__.py @@ -0,0 +1,7 @@ +"""Auto-register Arma 3 adapter on import.""" +from adapters.arma3.adapter import ARMA3_ADAPTER +from adapters.registry import GameAdapterRegistry + +GameAdapterRegistry.register(ARMA3_ADAPTER) + +__all__ = ["ARMA3_ADAPTER"] \ No newline at end of file diff --git a/backend/adapters/arma3/adapter.py b/backend/adapters/arma3/adapter.py new file mode 100644 index 0000000..e371b78 --- /dev/null +++ b/backend/adapters/arma3/adapter.py @@ -0,0 +1,59 @@ +"""Arma 3 adapter — composes all Arma 3 capability implementations.""" +from adapters.arma3.config_generator import Arma3ConfigGenerator +from adapters.arma3.process_config import Arma3ProcessConfig + +# Capabilities enabled so far (add more as phases complete) +_CAPABILITIES = { + "config_generator", + "process_config", + "log_parser", + "remote_admin", + "ban_manager", + "mission_manager", + "mod_manager", +} + + +class Arma3Adapter: + game_type = "arma3" + display_name = "Arma 3" + version = "1.0.0" + + def get_config_generator(self): + return Arma3ConfigGenerator() + + def get_process_config(self): + return Arma3ProcessConfig() + + def get_log_parser(self): + from adapters.arma3.log_parser import RPTParser + return RPTParser() + + def get_remote_admin(self): + """Return the RemoteAdmin factory for Arma3 BattlEye RCon.""" + from adapters.arma3.remote_admin import Arma3RemoteAdminFactory + return Arma3RemoteAdminFactory() + + def get_mission_manager(self, server_id: int | None = None): + from adapters.arma3.mission_manager import Arma3MissionManager + return Arma3MissionManager(server_id=server_id) + + def get_mod_manager(self, server_id: int | None = None): + from adapters.arma3.mod_manager import Arma3ModManager + return Arma3ModManager(server_id=server_id) + + def get_ban_manager(self, server_id: int | None = None): + from adapters.arma3.ban_manager import Arma3BanManager + return Arma3BanManager(server_id=server_id) + + def has_capability(self, name: str) -> bool: + return name in _CAPABILITIES + + def get_additional_routers(self) -> list: + return [] + + def get_custom_thread_factories(self) -> list: + return [] + + +ARMA3_ADAPTER = Arma3Adapter() \ No newline at end of file diff --git a/backend/adapters/arma3/ban_manager.py b/backend/adapters/arma3/ban_manager.py new file mode 100644 index 0000000..66b0d39 --- /dev/null +++ b/backend/adapters/arma3/ban_manager.py @@ -0,0 +1,200 @@ +"""Arma 3 ban manager — bidirectional sync between DB bans and BattlEye ban file.""" +from __future__ import annotations + +import logging +import os +from pathlib import Path + +from pydantic import BaseModel + +from core.utils.file_utils import get_server_dir + +logger = logging.getLogger(__name__) + +_BANS_FILE = "battleye/bans.txt" + + +class Arma3BanData(BaseModel): + """Ban data schema for Arma 3.""" + guid: str = "" + ip: str = "" + + +class Arma3BanManager: + """ + Implements BanManager protocol for Arma3 BattlEye. + + Also provides richer file-based operations for the ban endpoints. + """ + + def __init__(self, server_id: int | None = None) -> None: + self._server_id = server_id + + def _bans_path(self) -> Path: + if self._server_id is None: + raise ValueError("server_id required for file-based ban operations") + server_dir = get_server_dir(self._server_id) + return server_dir / _BANS_FILE + + # ── BanManager protocol methods ── + + def get_ban_file_path(self, server_dir: Path) -> Path: + return server_dir / _BANS_FILE + + def sync_bans_to_file(self, bans: list[dict], ban_file: Path) -> None: + """Write bans from DB to BattlEye ban file format.""" + lines = [] + for ban in bans: + identifier = ban.get("player_uid") or ban.get("guid") or ban.get("ip", "") + ban_type = ban.get("ban_type", "GUID") + reason = ban.get("reason", "") + duration = ban.get("duration_minutes", 0) + reason_clean = reason.replace("\n", " ").replace("\r", "").strip() + if identifier: + lines.append(f"{ban_type} {identifier} {duration} {reason_clean}".strip()) + + ban_file.parent.mkdir(parents=True, exist_ok=True) + tmp_path = str(ban_file) + ".tmp" + try: + with open(tmp_path, "w", encoding="utf-8") as f: + f.write("\n".join(lines) + "\n" if lines else "") + os.replace(tmp_path, str(ban_file)) + except OSError as exc: + self._safe_delete(tmp_path) + raise + + def read_bans_from_file(self, ban_file: Path) -> list[dict]: + """Read bans from BattlEye ban file into standard format.""" + if not ban_file.exists(): + return [] + + bans = [] + for line_num, line in enumerate(ban_file.read_text(encoding="utf-8", errors="replace").splitlines(), 1): + line = line.strip() + if not line or line.startswith("//") or line.startswith("#"): + continue + + parsed = self._parse_ban_line(line, line_num) + if parsed: + bans.append(parsed) + + return bans + + def get_ban_data_schema(self) -> type[BaseModel] | None: + return Arma3BanData + + # ── Richer file-based operations (used by ban endpoints) ── + + def get_bans(self) -> list[dict]: + """Read all bans from bans.txt. Returns list of dicts.""" + bans_path = self._bans_path() + if not bans_path.exists(): + return [] + + bans = [] + try: + with open(bans_path, "r", encoding="utf-8", errors="replace") as f: + for line_num, line in enumerate(f, 1): + line = line.strip() + if not line or line.startswith("#"): + continue + parsed = self._parse_ban_line(line, line_num) + if parsed: + bans.append(parsed) + except OSError as exc: + logger.error("Cannot read bans.txt: %s", exc) + + return bans + + def add_ban(self, identifier: str, ban_type: str, reason: str, duration_minutes: int) -> None: + """Append a ban entry to bans.txt.""" + reason_clean = reason.replace("\n", " ").replace("\r", "").strip() + line = f"{ban_type} {identifier} {duration_minutes} {reason_clean}\n" + + bans_path = self._bans_path() + bans_path.parent.mkdir(parents=True, exist_ok=True) + + try: + with open(bans_path, "a", encoding="utf-8") as f: + f.write(line) + except OSError as exc: + logger.error("Cannot write bans.txt: %s", exc) + + def remove_ban(self, identifier: str) -> bool: + """Remove all ban entries matching the given identifier. Returns True if removed.""" + bans_path = self._bans_path() + if not bans_path.exists(): + return False + + try: + with open(bans_path, "r", encoding="utf-8", errors="replace") as f: + lines = f.readlines() + except OSError as exc: + logger.error("Cannot read bans.txt: %s", exc) + return False + + new_lines = [] + removed = 0 + for line in lines: + stripped = line.strip() + if stripped and not stripped.startswith("#"): + parts = stripped.split(None, 3) + if len(parts) >= 2 and parts[1] == identifier: + removed += 1 + continue + new_lines.append(line) + + if removed == 0: + return False + + tmp_path = str(bans_path) + ".tmp" + try: + with open(tmp_path, "w", encoding="utf-8") as f: + f.writelines(new_lines) + os.replace(tmp_path, str(bans_path)) + except OSError as exc: + self._safe_delete(tmp_path) + logger.error("Cannot update bans.txt: %s", exc) + return False + + return True + + # ── Internal ── + + def _parse_ban_line(self, line: str, line_num: int) -> dict | None: + """Parse one ban line: TYPE IDENTIFIER DURATION REASON""" + parts = line.split(None, 3) + if len(parts) < 2: + return None + + ban_type = parts[0].upper() + if ban_type not in ("GUID", "IP"): + return None + + identifier = parts[1] + duration = 0 + reason = "" + + if len(parts) >= 3: + try: + duration = int(parts[2]) + except ValueError: + duration = 0 + + if len(parts) >= 4: + reason = parts[3] + + return { + "type": ban_type, + "identifier": identifier, + "duration_minutes": duration, + "reason": reason, + "is_permanent": duration == 0, + } + + @staticmethod + def _safe_delete(path: str) -> None: + try: + os.unlink(path) + except OSError as exc: + logger.debug("Arma3BanManager: could not delete %s: %s", path, exc) \ No newline at end of file diff --git a/backend/adapters/arma3/config_generator.py b/backend/adapters/arma3/config_generator.py new file mode 100644 index 0000000..d7c3992 --- /dev/null +++ b/backend/adapters/arma3/config_generator.py @@ -0,0 +1,400 @@ +""" +Arma 3 config generator. +Merged protocol: Pydantic models (schema) + file generation + launch args. +""" +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +from pydantic import BaseModel, Field + + +# ─── Pydantic Models (config schema) ───────────────────────────────────────── + +class ServerConfig(BaseModel): + hostname: str = "My Arma 3 Server" + password: str | None = None + password_admin: str = "" + server_command_password: str | None = None + max_players: int = Field(default=40, gt=0, le=1000) + kick_duplicate: int = Field(default=1, ge=0, le=1) + persistent: int = Field(default=1, ge=0, le=1) + vote_threshold: float = Field(default=0.33, ge=0.0, le=1.0) + vote_mission_players: int = Field(default=1, ge=0) + vote_timeout: int = Field(default=60, ge=0) + role_timeout: int = Field(default=90, ge=0) + briefing_timeout: int = Field(default=60, ge=0) + debriefing_timeout: int = Field(default=45, ge=0) + lobby_idle_timeout: int = Field(default=300, ge=0) + disable_von: int = Field(default=0, ge=0, le=1) + von_codec: int = Field(default=1, ge=0, le=1) + von_codec_quality: int = Field(default=20, ge=0, le=30) + max_ping: int = Field(default=250, gt=0) + max_packet_loss: int = Field(default=50, ge=0, le=100) + max_desync: int = Field(default=200, ge=0) + disconnect_timeout: int = Field(default=15, ge=0) + kick_on_ping: int = Field(default=1, ge=0, le=1) + kick_on_packet_loss: int = Field(default=1, ge=0, le=1) + kick_on_desync: int = Field(default=1, ge=0, le=1) + kick_on_timeout: int = Field(default=1, ge=0, le=1) + battleye: int = Field(default=1, ge=0, le=1) + verify_signatures: int = Field(default=2, ge=0, le=2) + allowed_file_patching: int = Field(default=0, ge=0, le=2) + forced_difficulty: str = "Regular" + timestamp_format: str = "short" + auto_select_mission: int = Field(default=0, ge=0, le=1) + random_mission_order: int = Field(default=0, ge=0, le=1) + log_file: str = "server_console.log" + skip_lobby: int = Field(default=0, ge=0, le=1) + drawing_in_map: int = Field(default=1, ge=0, le=1) + upnp: int = Field(default=0, ge=0, le=1) + loopback: int = Field(default=0, ge=0, le=1) + statistics_enabled: int = Field(default=1, ge=0, le=1) + motd_lines: list[str] = Field(default_factory=lambda: ["Welcome!", "Have fun"]) + motd_interval: float = Field(default=5.0, gt=0) + headless_clients: list[str] = Field(default_factory=list) + local_clients: list[str] = Field(default_factory=list) + admin_uids: list[str] = Field(default_factory=list) + + +class BasicConfig(BaseModel): + min_bandwidth: int = Field(default=800000, gt=0) + max_bandwidth: int = Field(default=25000000, gt=0) + max_msg_send: int = Field(default=384, gt=0) + max_size_guaranteed: int = Field(default=512, gt=0) + max_size_non_guaranteed: int = Field(default=256, gt=0) + min_error_to_send: float = Field(default=0.003, gt=0) + max_custom_file_size: int = Field(default=100000, ge=0) + + +class ProfileConfig(BaseModel): + reduced_damage: int = Field(default=0, ge=0, le=1) + group_indicators: int = Field(default=0, ge=0, le=3) + friendly_tags: int = Field(default=0, ge=0, le=3) + enemy_tags: int = Field(default=0, ge=0, le=3) + detected_mines: int = Field(default=0, ge=0, le=3) + commands: int = Field(default=1, ge=0, le=3) + waypoints: int = Field(default=1, ge=0, le=3) + tactical_ping: int = Field(default=0, ge=0, le=1) + weapon_info: int = Field(default=2, ge=0, le=3) + stance_indicator: int = Field(default=2, ge=0, le=3) + stamina_bar: int = Field(default=0, ge=0, le=1) + weapon_crosshair: int = Field(default=0, ge=0, le=1) + vision_aid: int = Field(default=0, ge=0, le=1) + third_person_view: int = Field(default=0, ge=0, le=1) + camera_shake: int = Field(default=1, ge=0, le=1) + score_table: int = Field(default=1, ge=0, le=1) + death_messages: int = Field(default=1, ge=0, le=1) + von_id: int = Field(default=1, ge=0, le=1) + map_content_friendly: int = Field(default=0, ge=0, le=3) + map_content_enemy: int = Field(default=0, ge=0, le=3) + map_content_mines: int = Field(default=0, ge=0, le=3) + auto_report: int = Field(default=0, ge=0, le=1) + multiple_saves: int = Field(default=0, ge=0, le=1) + ai_level_preset: int = Field(default=3, ge=0, le=4) + skill_ai: float = Field(default=0.5, ge=0.0, le=1.0) + precision_ai: float = Field(default=0.5, ge=0.0, le=1.0) + + +class LaunchConfig(BaseModel): + world: str = "empty" + extra_params: str = "" + limit_fps: int = Field(default=50, gt=0, le=1000) + auto_init: int = Field(default=0, ge=0, le=1) + load_mission_to_memory: int = Field(default=0, ge=0, le=1) + enable_ht: int = Field(default=0, ge=0, le=1) + huge_pages: int = Field(default=0, ge=0, le=1) + cpu_count: int | None = None + ex_threads: int = Field(default=7, ge=0) + max_mem: int | None = None + no_logs: int = Field(default=0, ge=0, le=1) + netlog: int = Field(default=0, ge=0, le=1) + + +class RConConfig(BaseModel): + rcon_password: str = "" + max_ping: int = Field(default=200, gt=0) + enabled: int = Field(default=1, ge=0, le=1) + + +# ─── Config Generator ───────────────────────────────────────────────────────── + +class Arma3ConfigGenerator: + game_type = "arma3" + + SECTIONS: dict[str, type[BaseModel]] = { + "server": ServerConfig, + "basic": BasicConfig, + "profile": ProfileConfig, + "launch": LaunchConfig, + "rcon": RConConfig, + } + + SENSITIVE_FIELDS: dict[str, list[str]] = { + "server": ["password", "password_admin", "server_command_password"], + "rcon": ["rcon_password"], + } + + def get_sections(self) -> dict[str, type[BaseModel]]: + return self.SECTIONS + + def get_defaults(self, section: str) -> dict[str, Any]: + model_cls = self.SECTIONS.get(section) + if model_cls is None: + return {} + return model_cls().model_dump() + + def get_sensitive_fields(self, section: str) -> list[str]: + return self.SENSITIVE_FIELDS.get(section, []) + + def get_config_version(self) -> str: + return "1.0.0" + + def migrate_config(self, old_version: str, config_json: dict) -> dict: + """ + For version 1.0.0 there is nothing to migrate. + Future versions: add migration logic here. + """ + from adapters.exceptions import ConfigMigrationError + raise ConfigMigrationError( + old_version, f"No migration path from {old_version} to {self.get_config_version()}" + ) + + # ── Config file writers ─────────────────────────────────────────────────── + + @staticmethod + def _escape(value: str) -> str: + """ + Escape a string for use inside Arma 3 double-quoted config values. + Order matters: escape backslashes FIRST. + """ + value = value.replace("\\", "\\\\") + value = value.replace('"', '\\"') + value = value.replace('\n', '\\n') + return value + + @staticmethod + def _atomic_write(path: Path, content: str) -> None: + """Write content to path atomically via tmp file + os.replace().""" + from adapters.exceptions import ConfigWriteError + tmp_path = path.with_suffix(path.suffix + ".tmp") + try: + path.parent.mkdir(parents=True, exist_ok=True) + tmp_path.write_text(content, encoding="utf-8") + os.replace(str(tmp_path), str(path)) + except OSError as e: + # Clean up tmp file if it exists + try: + tmp_path.unlink(missing_ok=True) + except OSError as exc: + logger.debug("Could not clean up temp file %s: %s", tmp_path, exc) + raise ConfigWriteError(str(path), str(e)) from e + + def _render_server_cfg(self, cfg: ServerConfig) -> str: + """Render server.cfg content string.""" + motd_items = ", ".join(f'"{self._escape(l)}"' for l in cfg.motd_lines) + headless = ", ".join(f'"{h}"' for h in cfg.headless_clients) + local = ", ".join(f'"{l}"' for l in cfg.local_clients) + admin_uids = ", ".join(f'"{u}"' for u in cfg.admin_uids) + + lines = [ + f'hostname = "{self._escape(cfg.hostname)}";', + ] + if cfg.password: + lines.append(f'password = "{self._escape(cfg.password)}";') + if cfg.password_admin: + lines.append(f'passwordAdmin = "{self._escape(cfg.password_admin)}";') + if cfg.server_command_password: + lines.append(f'serverCommandPassword = "{self._escape(cfg.server_command_password)}";') + + lines += [ + f"maxPlayers = {cfg.max_players};", + f"kickDuplicate = {cfg.kick_duplicate};", + f"persistent = {cfg.persistent};", + f"voteThreshold = {cfg.vote_threshold};", + f"voteMissionPlayers = {cfg.vote_mission_players};", + f"voteTimeout = {cfg.vote_timeout};", + f"roleTimeout = {cfg.role_timeout};", + f"briefingTimeOut = {cfg.briefing_timeout};", + f"debriefingTimeOut = {cfg.debriefing_timeout};", + f"lobbyIdleTimeout = {cfg.lobby_idle_timeout};", + f"disableVoN = {cfg.disable_von};", + f"vonCodec = {cfg.von_codec};", + f"vonCodecQuality = {cfg.von_codec_quality};", + f"maxPing = {cfg.max_ping};", + f"maxPacketLoss = {cfg.max_packet_loss};", + f"maxDesync = {cfg.max_desync};", + f"disconnectTimeout = {cfg.disconnect_timeout};", + f"kickOnPing = {cfg.kick_on_ping};", + f"kickOnPacketLoss = {cfg.kick_on_packet_loss};", + f"kickOnDesync = {cfg.kick_on_desync};", + f"kickOnTimeout = {cfg.kick_on_timeout};", + f"BattlEye = {cfg.battleye};", + f"verifySignatures = {cfg.verify_signatures};", + f"allowedFilePatching = {cfg.allowed_file_patching};", + f'forcedDifficulty = "{cfg.forced_difficulty}";', + f'timeStampFormat = "{cfg.timestamp_format}";', + f"autoSelectMission = {cfg.auto_select_mission};", + f"randomMissionOrder = {cfg.random_mission_order};", + f'logFile = "{cfg.log_file}";', + f"skipLobby = {cfg.skip_lobby};", + f"drawingInMap = {cfg.drawing_in_map};", + f"upnp = {cfg.upnp};", + f"loopback = {cfg.loopback};", + f"statisticsEnabled = {cfg.statistics_enabled};", + f"motd[] = {{{motd_items}}};", + f"motdInterval = {cfg.motd_interval};", + ] + if cfg.headless_clients: + lines.append(f"headlessClients[] = {{{headless}}};") + if cfg.local_clients: + lines.append(f"localClient[] = {{{local}}};") + if cfg.admin_uids: + lines.append(f"admins[] = {{{admin_uids}}};") + + return "\n".join(lines) + "\n" + + def _render_basic_cfg(self, cfg: BasicConfig) -> str: + return ( + f"MinBandwidth = {cfg.min_bandwidth};\n" + f"MaxBandwidth = {cfg.max_bandwidth};\n" + f"MaxMsgSend = {cfg.max_msg_send};\n" + f"MaxSizeGuaranteed = {cfg.max_size_guaranteed};\n" + f"MaxSizeNonguaranteed = {cfg.max_size_non_guaranteed};\n" + f"MinErrorToSend = {cfg.min_error_to_send};\n" + f"MaxCustomFileSize = {cfg.max_custom_file_size};\n" + ) + + def _render_arma3profile(self, cfg: ProfileConfig) -> str: + return ( + "class DifficultyPresets {\n" + " class CustomDifficulty {\n" + " class Options {\n" + f" reducedDamage = {cfg.reduced_damage};\n" + f" groupIndicators = {cfg.group_indicators};\n" + f" friendlyTags = {cfg.friendly_tags};\n" + f" enemyTags = {cfg.enemy_tags};\n" + f" detectedMines = {cfg.detected_mines};\n" + f" commands = {cfg.commands};\n" + f" waypoints = {cfg.waypoints};\n" + f" tacticalPing = {cfg.tactical_ping};\n" + f" weaponInfo = {cfg.weapon_info};\n" + f" stanceIndicator = {cfg.stance_indicator};\n" + f" staminaBar = {cfg.stamina_bar};\n" + f" weaponCrosshair = {cfg.weapon_crosshair};\n" + f" visionAid = {cfg.vision_aid};\n" + f" thirdPersonView = {cfg.third_person_view};\n" + f" cameraShake = {cfg.camera_shake};\n" + f" scoreTable = {cfg.score_table};\n" + f" deathMessages = {cfg.death_messages};\n" + f" vonID = {cfg.von_id};\n" + f" mapContentFriendly = {cfg.map_content_friendly};\n" + f" mapContentEnemy = {cfg.map_content_enemy};\n" + f" mapContentMines = {cfg.map_content_mines};\n" + f" autoReport = {cfg.auto_report};\n" + f" multipleSaves = {cfg.multiple_saves};\n" + " };\n" + f" aiLevelPreset = {cfg.ai_level_preset};\n" + " };\n" + " class CustomAILevel {\n" + f" skillAI = {cfg.skill_ai};\n" + f" precisionAI = {cfg.precision_ai};\n" + " };\n" + "};\n" + ) + + def _render_beserver_cfg(self, cfg: RConConfig) -> str: + return ( + f"RConPassword {cfg.rcon_password}\n" + f"MaxPing {cfg.max_ping}\n" + ) + + # ── Public interface ────────────────────────────────────────────────────── + + def write_configs( + self, + server_id: int, + server_dir: Path, + config_sections: dict[str, dict], + ) -> list[Path]: + server_cfg = ServerConfig(**config_sections.get("server", {})) + basic_cfg = BasicConfig(**config_sections.get("basic", {})) + profile_cfg = ProfileConfig(**config_sections.get("profile", {})) + rcon_cfg = RConConfig(**config_sections.get("rcon", {})) + + written = [] + pairs = [ + (server_dir / "server.cfg", self._render_server_cfg(server_cfg)), + (server_dir / "basic.cfg", self._render_basic_cfg(basic_cfg)), + (server_dir / "server" / "server.Arma3Profile", self._render_arma3profile(profile_cfg)), + (server_dir / "battleye" / "beserver.cfg", self._render_beserver_cfg(rcon_cfg)), + ] + for path, content in pairs: + self._atomic_write(path, content) + written.append(path) + + # Restrict permissions on files containing passwords (Unix only) + if os.name != "nt": + for path in [server_dir / "server.cfg", server_dir / "battleye" / "beserver.cfg"]: + if path.exists(): + os.chmod(path, 0o600) + + return written + + def build_launch_args( + self, + config_sections: dict[str, dict], + mod_args: list[str] | None = None, + ) -> list[str]: + from adapters.exceptions import LaunchArgsError + launch = LaunchConfig(**config_sections.get("launch", {})) + server = ServerConfig(**config_sections.get("server", {})) + + args = [ + f"-port={config_sections.get('_port', 2302)}", + "-config=server.cfg", + "-cfg=basic.cfg", + "-profiles=./server", + "-name=server", + f"-world={launch.world}", + f"-limitFPS={launch.limit_fps}", + "-bepath=./battleye", + ] + if launch.auto_init: + args.append("-autoInit") + if launch.enable_ht: + args.append("-enableHT") + if launch.huge_pages: + args.append("-hugePages") + if launch.cpu_count is not None: + args.append(f"-cpuCount={launch.cpu_count}") + if launch.max_mem is not None: + args.append(f"-maxMem={launch.max_mem}") + if launch.no_logs: + args.append("-noLogs") + if launch.netlog: + args.append("-netlog") + if launch.extra_params: + args.extend(launch.extra_params.split()) + if mod_args: + args.extend(mod_args) + return args + + def preview_config( + self, + server_id: int, + server_dir: Path, + config_sections: dict[str, dict], + ) -> dict[str, str]: + server_cfg = ServerConfig(**config_sections.get("server", {})) + basic_cfg = BasicConfig(**config_sections.get("basic", {})) + profile_cfg = ProfileConfig(**config_sections.get("profile", {})) + rcon_cfg = RConConfig(**config_sections.get("rcon", {})) + return { + "server.cfg": self._render_server_cfg(server_cfg), + "basic.cfg": self._render_basic_cfg(basic_cfg), + "server/server.Arma3Profile": self._render_arma3profile(profile_cfg), + "battleye/beserver.cfg": self._render_beserver_cfg(rcon_cfg), + } \ No newline at end of file diff --git a/backend/adapters/arma3/log_parser.py b/backend/adapters/arma3/log_parser.py new file mode 100644 index 0000000..eaa593d --- /dev/null +++ b/backend/adapters/arma3/log_parser.py @@ -0,0 +1,81 @@ +"""Arma 3 RPT log parser.""" +from __future__ import annotations + +import re +from datetime import datetime +from pathlib import Path +from typing import Callable + + +class RPTParser: + """Parses Arma 3 .rpt log files.""" + + # Pattern: "HH:MM:SS ..." or "[HH:MM:SS] ..." with optional date prefix + _timestamp_re = re.compile( + r"^\s*(?:(\d{2}/\d{2}/\d{4})\s+)?" + r"(?:\[)?(\d{2}:\d{2}:\d{2})(?:\])?\s*" + r"(?:\[?(\w+)\]?\s*)?(.*)$" + ) + + def parse_line(self, line: str) -> dict | None: + """Parse one RPT log line.""" + if not line or not line.strip(): + return None + + match = self._timestamp_re.match(line) + if not match: + # Non-timestamped line — treat as info + stripped = line.strip() + if not stripped: + return None + return { + "timestamp": datetime.utcnow().isoformat(), + "level": "info", + "message": stripped, + } + + date_str, time_str, level_str, message = match.groups() + + # Map Arma 3 log levels + level = "info" + if level_str: + level_lower = level_str.lower() + if level_lower in ("error", "fault"): + level = "error" + elif level_lower in ("warning", "warn"): + level = "warning" + + # Build ISO timestamp + try: + if date_str: + dt = datetime.strptime(f"{date_str} {time_str}", "%m/%d/%Y %H:%M:%S") + else: + dt = datetime.strptime(time_str, "%H:%M:%S") + dt = dt.replace(year=datetime.utcnow().year, month=datetime.utcnow().month, day=datetime.utcnow().day) + timestamp = dt.isoformat() + except ValueError: + timestamp = datetime.utcnow().isoformat() + + return { + "timestamp": timestamp, + "level": level, + "message": (message or "").strip(), + } + + def get_log_file_resolver(self, server_id: int) -> Callable[[Path], Path | None]: + """Return a callable that finds the current RPT log file.""" + def resolver(server_dir: Path) -> Path | None: + # Arma 3 stores logs in server_dir/server/*.rpt + profile_dir = server_dir / "server" + if not profile_dir.exists(): + return None + + rpt_files = sorted(profile_dir.glob("*.rpt"), key=lambda p: p.stat().st_mtime, reverse=True) + if rpt_files: + return rpt_files[0] + + # Fallback: check for arma3server_x64_*.rpt pattern + rpt_files = sorted(profile_dir.glob("arma3server*.rpt"), key=lambda p: p.stat().st_mtime, reverse=True) + return rpt_files[0] if rpt_files else None + + return resolver \ No newline at end of file diff --git a/backend/adapters/arma3/mission_manager.py b/backend/adapters/arma3/mission_manager.py new file mode 100644 index 0000000..1ed91e1 --- /dev/null +++ b/backend/adapters/arma3/mission_manager.py @@ -0,0 +1,191 @@ +"""Arma 3 mission manager — handles .pbo mission files, upload, delete, rotation.""" +from __future__ import annotations + +import logging +import os +import re +from pathlib import Path + +from pydantic import BaseModel + +from adapters.exceptions import AdapterError +from core.utils.file_utils import get_server_dir, sanitize_filename, safe_delete_file + +logger = logging.getLogger(__name__) + +_MISSIONS_DIR = "mpmissions" +_ALLOWED_EXTENSION = ".pbo" +_MAX_MISSION_SIZE_MB = 500 + + +class Arma3MissionData(BaseModel): + """Mission data schema for Arma 3.""" + terrain: str = "" + difficulty: str = "Regular" + + +class Arma3MissionManager: + file_extension = ".pbo" + + def __init__(self, server_id: int | None = None) -> None: + self._server_id = server_id + + def _missions_dir(self) -> Path: + return get_server_dir(self._server_id) / _MISSIONS_DIR + + # ── File operations ── + + def list_missions(self) -> list[dict]: + """ + Scan the mpmissions directory and return all .pbo files. + + Returns list of dicts: + name: str — filename without extension + filename: str — full filename + size_bytes: int — file size + """ + missions_dir = self._missions_dir() + if not missions_dir.exists(): + return [] + + missions = [] + try: + for entry in missions_dir.iterdir(): + if entry.is_file() and entry.suffix.lower() == _ALLOWED_EXTENSION: + missions.append({ + "name": entry.stem, + "filename": entry.name, + "size_bytes": entry.stat().st_size, + }) + except OSError as exc: + raise AdapterError(f"Cannot list missions: {exc}") from exc + + missions.sort(key=lambda m: m["filename"].lower()) + return missions + + def upload_mission(self, filename: str, content: bytes) -> dict: + """ + Save a mission file to the mpmissions directory. + + Args: + filename: Original filename from the upload (will be sanitized). + content: Raw file bytes. + + Returns the saved mission dict. + """ + safe_name = sanitize_filename(filename) + if not safe_name.lower().endswith(_ALLOWED_EXTENSION): + raise AdapterError( + f"Invalid mission file extension. Only {_ALLOWED_EXTENSION} files are allowed." + ) + + size_mb = len(content) / (1024 * 1024) + if size_mb > _MAX_MISSION_SIZE_MB: + raise AdapterError( + f"Mission file too large ({size_mb:.1f} MB). Max is {_MAX_MISSION_SIZE_MB} MB." + ) + + missions_dir = self._missions_dir() + missions_dir.mkdir(parents=True, exist_ok=True) + + dest_path = missions_dir / safe_name + + # Atomic write: write to .tmp first, then replace + tmp_path = str(dest_path) + ".tmp" + try: + with open(tmp_path, "wb") as f: + f.write(content) + os.replace(tmp_path, str(dest_path)) + except OSError as exc: + safe_delete_file(Path(tmp_path)) + raise AdapterError(f"Cannot save mission file: {exc}") from exc + + logger.info( + "Mission uploaded for server %d: %s (%d bytes)", + self._server_id, safe_name, len(content), + ) + return { + "name": dest_path.stem, + "filename": safe_name, + "size_bytes": len(content), + } + + def delete_mission(self, filename: str) -> bool: + """ + Delete a mission file. + Returns True if deleted, False if not found. + """ + safe_name = sanitize_filename(filename) + if not safe_name.lower().endswith(_ALLOWED_EXTENSION): + raise AdapterError("Invalid mission filename") + + dest_path = self._missions_dir() / safe_name + + # Verify resolved path is inside missions directory (path traversal guard) + try: + dest_path.resolve().relative_to(self._missions_dir().resolve()) + except ValueError: + raise AdapterError("Path traversal detected in filename") + + if not dest_path.exists(): + return False + + try: + dest_path.unlink() + logger.info("Mission deleted for server %d: %s", self._server_id, safe_name) + return True + except OSError as exc: + raise AdapterError(f"Cannot delete mission: {exc}") from exc + + # ── Mission rotation config ── + + def parse_mission_filename(self, filename: str) -> dict: + """ + Parse Arma 3 mission filename. + Format: MissionName.Terrain.pbo + """ + name = filename + if name.endswith(self.file_extension): + name = name[: -len(self.file_extension)] + + parts = name.rsplit(".", 1) + if len(parts) == 2: + return { + "mission_name": parts[0], + "terrain": parts[1], + "filename": filename, + } + return { + "mission_name": name, + "terrain": "", + "filename": filename, + } + + def get_rotation_config(self, rotation_entries: list[dict]) -> str: + """ + Generate Arma 3 mission rotation config block. + rotation_entries: list of {mission_name, terrain, difficulty, params_json} + """ + if not rotation_entries: + return "" + + lines = ['class Missions {'] + for i, entry in enumerate(rotation_entries): + mission = entry.get("mission_name", "") + terrain = entry.get("terrain", "") + difficulty = entry.get("difficulty", "Regular") + params = entry.get("params_json", "{}") + lines.append(f' class Mission_{i} {{') + lines.append(f' template = "{mission}.{terrain}";') + lines.append(f' difficulty = "{difficulty}";') + if params and params != "{}": + lines.append(f' params = {params};') + lines.append(' };') + lines.append('};') + return "\n".join(lines) + + def get_missions_dir(self, server_dir: Path) -> Path: + return server_dir / _MISSIONS_DIR + + def get_mission_data_schema(self) -> type[BaseModel] | None: + return Arma3MissionData \ No newline at end of file diff --git a/backend/adapters/arma3/mod_manager.py b/backend/adapters/arma3/mod_manager.py new file mode 100644 index 0000000..fb46122 --- /dev/null +++ b/backend/adapters/arma3/mod_manager.py @@ -0,0 +1,165 @@ +"""Arma 3 mod manager — handles mod folder conventions, CLI args, and enable/disable.""" +from __future__ import annotations + +import logging +import re +from pathlib import Path + +from pydantic import BaseModel + +from adapters.exceptions import AdapterError +from core.utils.file_utils import get_server_dir + +logger = logging.getLogger(__name__) + +_MOD_DIR_PATTERN = re.compile(r"^@.+", re.IGNORECASE) + + +class Arma3ModData(BaseModel): + """Mod data schema for Arma 3.""" + workshop_id: str = "" + is_server_mod: bool = False + + +class Arma3ModManager: + + def __init__(self, server_id: int | None = None) -> None: + self._server_id = server_id + + def _server_dir(self) -> Path: + return get_server_dir(self._server_id) + + # ── File / DB operations ── + + def list_available_mods(self) -> list[dict]: + """ + Scan the server directory for mod folders (directories starting with '@'). + + Returns list of dicts: + name: str — directory name (e.g. "@CBA_A3") + path: str — absolute directory path + size_bytes: int — total directory size (approximate, non-recursive) + """ + server_dir = self._server_dir() + if not server_dir.exists(): + return [] + + mods = [] + try: + for entry in server_dir.iterdir(): + if entry.is_dir() and _MOD_DIR_PATTERN.match(entry.name): + try: + size = sum( + f.stat().st_size + for f in entry.iterdir() + if f.is_file() + ) + except OSError: + size = 0 + mods.append({ + "name": entry.name, + "path": str(entry.resolve()), + "size_bytes": size, + }) + except OSError as exc: + raise AdapterError(f"Cannot scan mod directory: {exc}") from exc + + mods.sort(key=lambda m: m["name"].lower()) + return mods + + def get_enabled_mods(self, config_repo) -> list[str]: + """ + Get the list of enabled mod names from the database config. + + Args: + config_repo: ConfigRepository instance. + + Returns list of mod directory names (e.g. ["@CBA_A3", "@ace"]). + """ + mods_section = config_repo.get_section(self._server_id, "mods") + if mods_section is None: + return [] + enabled = mods_section.get("enabled_mods", []) + if isinstance(enabled, str): + enabled = [m.strip() for m in enabled.split(",") if m.strip()] + return enabled + + def set_enabled_mods(self, mod_names: list[str], config_repo) -> None: + """ + Update the enabled mods list in the database config. + + Args: + mod_names: List of mod directory names to enable. + config_repo: ConfigRepository instance. + + Raises AdapterError if any mod name doesn't exist on disk. + """ + available = {m["name"] for m in self.list_available_mods()} + for name in mod_names: + if not _MOD_DIR_PATTERN.match(name): + raise AdapterError(f"Invalid mod name '{name}': must start with '@'") + if name not in available: + raise AdapterError( + f"Mod '{name}' not found in server directory. " + f"Available: {sorted(available)}" + ) + + mods_section = config_repo.get_section(self._server_id, "mods") or {} + current_version = mods_section.get("config_version", 0) + config_repo.upsert_section( + server_id=self._server_id, + section="mods", + data={"enabled_mods": mod_names}, + expected_version=current_version, + ) + logger.info( + "Updated enabled mods for server %d: %s", + self._server_id, mod_names, + ) + + # ── CLI argument building ── + + def get_mod_folder_pattern(self) -> str: + """Arma 3 mods use @ prefix for local, or numeric workshop IDs.""" + return "@*" + + def build_mod_args(self, server_mods: list[dict]) -> list[str]: + """ + Build Arma 3 mod CLI arguments. + Returns -mod and -serverMod argument lists. + """ + client_mods = [] + server_only_mods = [] + + for mod in server_mods: + path = mod.get("folder_path", "") + game_data = mod.get("game_data", {}) + if isinstance(game_data, str): + import json + try: + game_data = json.loads(game_data) + except (json.JSONDecodeError, TypeError): + game_data = {} + + is_server = game_data.get("is_server_mod", False) if isinstance(game_data, dict) else False + + if is_server: + server_only_mods.append(path) + else: + client_mods.append(path) + + args = [] + if client_mods: + args.append('-mod="' + ";".join(client_mods) + '"') + if server_only_mods: + args.append('-serverMod="' + ";".join(server_only_mods) + '"') + return args + + def validate_mod_folder(self, path: Path) -> bool: + """Validate that a path looks like a valid Arma 3 mod folder.""" + if not path.exists() or not path.is_dir(): + return False + return (path / "addons").exists() or (path / "$PREFIX$").exists() + + def get_mod_data_schema(self) -> type[BaseModel] | None: + return Arma3ModData \ No newline at end of file diff --git a/backend/adapters/arma3/process_config.py b/backend/adapters/arma3/process_config.py new file mode 100644 index 0000000..6d7bf7a --- /dev/null +++ b/backend/adapters/arma3/process_config.py @@ -0,0 +1,30 @@ +"""Arma 3 process configuration: executables, ports, directory layout.""" + + +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]: + """ + Arma 3 derives 3 additional ports from the game port. + All 4 must be free when starting a server. + rcon_port is separate (user-configurable, not auto-derived here). + """ + return { + "game": game_port, + "steam_query": game_port + 1, + "von": game_port + 2, + "steam_auth": game_port + 3, + } + + def get_default_game_port(self) -> int: + return 2302 + + def get_default_rcon_port(self, game_port: int) -> int | None: + return game_port + 4 # e.g. 2306 for default game port + + def get_server_dir_layout(self) -> list[str]: + """Subdirectories to create inside servers/{id}/.""" + return ["server", "battleye", "mpmissions"] \ No newline at end of file diff --git a/backend/adapters/arma3/rcon_client.py b/backend/adapters/arma3/rcon_client.py new file mode 100644 index 0000000..d439f32 --- /dev/null +++ b/backend/adapters/arma3/rcon_client.py @@ -0,0 +1,278 @@ +""" +BERConClient — BattlEye RCon UDP client for Arma3. + +Implements the BattlEye RCon protocol version 2. +Reference: https://www.battleye.com/downloads/BERConProtocol.txt + +Thread safety: This client is NOT thread-safe by itself. +The RemoteAdminPollerThread serializes all calls through a single thread. +For the send_command() called from HTTP request handlers, use a threading.Lock. +""" +from __future__ import annotations + +import logging +import socket +import struct +import threading +import time +import zlib + +logger = logging.getLogger(__name__) + +_SOCKET_TIMEOUT = 5.0 +_LOGIN_TIMEOUT = 5.0 +_RESPONSE_TIMEOUT = 5.0 +_MAX_RESPONSE_PARTS = 10 +_KEEPALIVE_INTERVAL = 30.0 + + +class BERConClient: + """ + BattlEye RCon UDP client. + + Usage: + client = BERConClient(host="127.0.0.1", port=2302, password="secret") + client.connect() # raises ConnectionError on failure + players = client.get_players() + client.send_command("say -1 Hello") + client.disconnect() + """ + + def __init__(self, host: str, port: int, password: str) -> None: + self._host = host + self._port = port + self._password = password + self._sock: socket.socket | None = None + self._seq = 0 + self._connected = False + self._lock = threading.Lock() + self._last_activity = 0.0 + + # ── Public API ── + + def connect(self) -> None: + """Open UDP socket and perform BattlEye login handshake.""" + with self._lock: + if self._connected: + return + self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self._sock.settimeout(_SOCKET_TIMEOUT) + self._sock.connect((self._host, self._port)) + + login_payload = self._password.encode("ascii", errors="replace") + packet = self._build_packet(0x00, login_payload) + self._sock.send(packet) + self._last_activity = time.monotonic() + + deadline = time.monotonic() + _LOGIN_TIMEOUT + while time.monotonic() < deadline: + try: + data = self._sock.recv(4096) + except socket.timeout: + break + if not self._verify_checksum(data): + continue + if len(data) >= 9 and data[7] == 0x00: + if data[8] == 0x01: + self._connected = True + self._seq = 0 + logger.info("BERConClient: logged in to %s:%d", self._host, self._port) + return + else: + self._sock.close() + self._sock = None + raise ConnectionError( + f"BattlEye login rejected at {self._host}:{self._port}" + ) + + self._sock.close() + self._sock = None + raise ConnectionError( + f"BattlEye login timed out at {self._host}:{self._port}" + ) + + def disconnect(self) -> None: + with self._lock: + self._connected = False + if self._sock is not None: + try: + self._sock.close() + except OSError as exc: + logger.debug("BERConClient: error closing socket during disconnect: %s", exc) + self._sock = None + + @property + def is_connected(self) -> bool: + return self._connected + + def send_command(self, command: str) -> str: + """Send a BattlEye command and return the response string.""" + with self._lock: + if not self._connected or self._sock is None: + raise ConnectionError("BERConClient: not connected") + return self._send_command_locked(command) + + def get_players(self) -> list[dict]: + """Send 'players' command and parse the response.""" + response = self.send_command("players") + return self._parse_players(response) + + def keepalive(self) -> None: + """Send a keepalive packet if the connection has been idle.""" + if not self._connected: + return + elapsed = time.monotonic() - self._last_activity + if elapsed >= _KEEPALIVE_INTERVAL: + try: + self.send_command("") + except Exception as exc: + logger.debug("BERConClient: keepalive failed: %s", exc) + + # ── Packet building ── + + def _build_packet(self, pkt_type: int, payload: bytes) -> bytes: + """Build a BattlEye packet: 'B' 'E' 0xFF """ + body = bytes([0xFF, pkt_type]) + payload + crc = zlib.crc32(body) & 0xFFFFFFFF + crc_bytes = struct.pack(" bytes: + payload = bytes([seq]) + command.encode("ascii", errors="replace") + return self._build_packet(0x01, payload) + + def _build_ack_packet(self, seq: int) -> bytes: + return self._build_packet(0x02, bytes([seq])) + + def _verify_checksum(self, data: bytes) -> bool: + """Verify the CRC32 checksum in the received packet.""" + if len(data) < 8: + return False + if data[0:2] != b"BE": + return False + stored_crc = struct.unpack(" str: + seq = self._seq + self._seq = (self._seq + 1) % 256 + + packet = self._build_command_packet(seq, command) + self._sock.send(packet) + self._last_activity = time.monotonic() + + parts: dict[int, str] = {} + total_parts: int | None = None + deadline = time.monotonic() + _RESPONSE_TIMEOUT + + while time.monotonic() < deadline: + try: + data = self._sock.recv(65535) + except socket.timeout: + break + + if not self._verify_checksum(data): + continue + + if len(data) < 9: + continue + + pkt_type = data[7] + + # Server message — acknowledge and ignore + if pkt_type == 0x02: + srv_seq = data[8] + ack = self._build_ack_packet(srv_seq) + try: + self._sock.send(ack) + except OSError as exc: + logger.debug("BERConClient: failed to send ack for server message %d: %s", srv_seq, exc) + continue + + # Command response + if pkt_type == 0x01: + resp_seq = data[8] + if resp_seq != seq: + continue + + payload = data[9:] + + # Check if multi-part + if len(payload) >= 3 and payload[0] == 0x00: + total_parts = payload[1] + part_index = payload[2] + part_text = payload[3:].decode("utf-8", errors="replace") + parts[part_index] = part_text + if len(parts) == total_parts: + break + else: + # Single-part response + return payload.decode("utf-8", errors="replace") + + if total_parts is not None and parts: + return "".join(parts[i] for i in sorted(parts.keys())) + + return "" + + # ── Player parsing ── + + def _parse_players(self, response: str) -> list[dict]: + """Parse the 'players' command response.""" + players = [] + lines = response.split("\n") + for line in lines: + line = line.strip() + if not line: + continue + if line.startswith("Players on") or line.startswith("-") or line.startswith("("): + continue + + parts = line.split(None, 4) + if len(parts) < 4: + continue + + try: + number = int(parts[0]) + except ValueError: + continue + + ip_port = parts[1] + ping_str = parts[2] + guid_part = parts[3] + name = parts[4].strip() if len(parts) > 4 else "" + + ip = ip_port + port = 0 + if ":" in ip_port: + ip, port_str = ip_port.rsplit(":", 1) + try: + port = int(port_str) + except ValueError: + port = 0 + + try: + ping = int(ping_str) + except ValueError: + ping = 0 + + uid = guid_part.split("(")[0] + + is_admin = "(Admin)" in name + name = name.replace("(Admin)", "").strip() + + players.append({ + "number": number, + "uid": uid, + "name": name, + "ip": ip, + "port": port, + "ping": ping, + "is_admin": is_admin, + "slot_id": number, + }) + + return players \ No newline at end of file diff --git a/backend/adapters/arma3/rcon_service.py b/backend/adapters/arma3/rcon_service.py new file mode 100644 index 0000000..e0669b6 --- /dev/null +++ b/backend/adapters/arma3/rcon_service.py @@ -0,0 +1,142 @@ +"""Arma 3 RCon service — remote admin via BattleEye RCon protocol.""" +from __future__ import annotations + +import socket +import logging +import struct +from typing import Any + +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + + +class Arma3PlayerData(BaseModel): + """Player data schema for Arma 3.""" + name: str + ping: int = 0 + guid: str = "" + + +class Arma3RConClient: + """BattleEye RCon client for a single connection.""" + + def __init__(self, host: str, port: int, password: str): + self._host = host + self._port = port + self._password = password + self._sock: socket.socket | None = None + + def _connect(self) -> None: + if self._sock is not None: + return + self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self._sock.settimeout(5.0) + self._sock.connect((self._host, self._port)) + # Login sequence + self._login() + + def _login(self) -> None: + if self._sock is None: + raise ConnectionError("Not connected") + # BE RCon login: send password with checksum + password_bytes = self._password.encode("utf-8") + checksum = self._compute_checksum(password_bytes) + packet = b"\xff" + bytes([0, len(password_bytes) & 0xff]) + checksum + password_bytes + self._sock.send(packet) + response = self._sock.recv(4096) + if not response or response[0] != 0xff: + raise ConnectionError("RCon login failed") + + @staticmethod + def _compute_checksum(data: bytes) -> bytes: + """Compute BE RCon checksum (sum of bytes) & 0xFF.""" + return bytes([sum(data) & 0xFF]) + + def send_command(self, command: str, timeout: float = 5.0) -> str | None: + try: + self._connect() + if self._sock is None: + return None + self._sock.settimeout(timeout) + cmd_bytes = command.encode("utf-8") + checksum = self._compute_checksum(cmd_bytes) + packet = b"\xff\x01" + bytes([len(cmd_bytes) & 0xff]) + checksum + cmd_bytes + self._sock.send(packet) + response = self._sock.recv(4096) + if response and len(response) > 2: + return response[2:].decode("utf-8", errors="replace") + return None + except Exception as e: + logger.error("RCon command error: %s", e) + return None + + def get_players(self) -> list[dict]: + result = self.send_command("players") + if result is None: + return [] + # Parse player list from BE RCon response + players = [] + for line in result.split("\n"): + line = line.strip() + if not line or line.startswith("(") or line.startswith("total"): + continue + parts = line.split(maxsplit=4) + if len(parts) >= 5: + players.append({ + "slot_id": parts[0], + "name": parts[3] if len(parts) > 3 else "", + "guid": parts[2] if len(parts) > 2 else "", + "ping": int(parts[1]) if parts[1].isdigit() else 0, + }) + return players + + def kick_player(self, identifier: str, reason: str = "") -> bool: + cmd = f"kick {identifier}" + if reason: + cmd += f" {reason}" + result = self.send_command(cmd) + return result is not None + + def ban_player(self, identifier: str, duration_minutes: int, reason: str) -> bool: + cmd = f"ban {identifier} {duration_minutes} {reason}" + result = self.send_command(cmd) + return result is not None + + def say_all(self, message: str) -> bool: + result = self.send_command(f"say {message}") + return result is not None + + def shutdown(self) -> bool: + result = self.send_command("#shutdown") + return result is not None + + def keepalive(self) -> None: + try: + self.send_command("") + except Exception as exc: + logger.debug("Arma3RConClient: keepalive failed: %s", exc) + + def disconnect(self) -> None: + if self._sock: + try: + self._sock.close() + except Exception as exc: + logger.debug("Arma3RConClient: error closing socket: %s", exc) + self._sock = None + + +class Arma3RConService: + """Factory for Arma 3 RCon clients.""" + + def create_client(self, host: str, port: int, password: str) -> Arma3RConClient: + return Arma3RConClient(host, port, password) + + def get_startup_delay(self) -> float: + return 30.0 + + def get_poll_interval(self) -> float: + return 10.0 + + def get_player_data_schema(self) -> type[BaseModel] | None: + return Arma3PlayerData \ No newline at end of file diff --git a/backend/adapters/arma3/remote_admin.py b/backend/adapters/arma3/remote_admin.py new file mode 100644 index 0000000..bfec90d --- /dev/null +++ b/backend/adapters/arma3/remote_admin.py @@ -0,0 +1,135 @@ +""" +Arma3RemoteAdmin — implements the RemoteAdmin protocol using BERConClient. +""" +from __future__ import annotations + +import logging + +from adapters.arma3.rcon_client import BERConClient +from adapters.exceptions import RemoteAdminError + +logger = logging.getLogger(__name__) + + +class Arma3RemoteAdmin: + """ + RemoteAdmin protocol implementation for Arma3 BattlEye RCon. + + Args: + server_id: Database server ID. + host: RCon host (usually 127.0.0.1). + port: RCon port (usually game_port + 3). + password: RCon password. + """ + + def __init__( + self, + server_id: int, + host: str, + port: int, + password: str, + ) -> None: + self._server_id = server_id + self._client = BERConClient(host=host, port=port, password=password) + + # ── RemoteAdmin protocol ── + + def connect(self) -> None: + """Connect to RCon. Raises RemoteAdminError on failure.""" + try: + self._client.connect() + except ConnectionError as exc: + raise RemoteAdminError(str(exc)) from exc + + def disconnect(self) -> None: + self._client.disconnect() + + def is_connected(self) -> bool: + return self._client.is_connected + + def get_players(self) -> list[dict]: + """Fetch current player list.""" + try: + return self._client.get_players() + except Exception as exc: + raise RemoteAdminError(f"get_players failed: {exc}") from exc + + def send_command(self, command: str, timeout: float = 5.0) -> str | None: + """Send an arbitrary RCon command.""" + try: + return self._client.send_command(command) + except Exception as exc: + raise RemoteAdminError(f"send_command failed: {exc}") from exc + + def kick_player(self, player_number: int, reason: str = "") -> bool: + """Kick a player by their in-game slot number.""" + command = f"kick {player_number}" + if reason: + command += f" {reason}" + try: + self._client.send_command(command) + return True + except Exception as exc: + logger.warning("[%s] kick_player failed for player %d: %s", self._server_id, player_number, exc) + return False + + def ban_player(self, player_uid: str, duration_minutes: int = 0, reason: str = "") -> bool: + """Add a GUID ban. duration_minutes=0 means permanent.""" + duration = duration_minutes if duration_minutes > 0 else 0 + command = f"addBan {player_uid} {duration} {reason}" + try: + self._client.send_command(command) + return True + except Exception as exc: + logger.warning("[%s] ban_player failed: %s", self._server_id, exc) + return False + + def say_all(self, message: str) -> bool: + """Broadcast a message to all players.""" + try: + self._client.send_command(f"say -1 {message}") + return True + except Exception as exc: + logger.warning("[%s] say_all failed: %s", self._server_id, exc) + return False + + def shutdown(self) -> bool: + """Shutdown the game server via RCon.""" + try: + self._client.send_command("#shutdown") + return True + except Exception as exc: + logger.warning("[%s] shutdown failed: %s", self._server_id, exc) + return False + + def keepalive(self) -> None: + """Send keepalive if idle.""" + self._client.keepalive() + + +class Arma3RemoteAdminFactory: + """ + RemoteAdmin factory for Arma3. + Implements the RemoteAdmin protocol (create_client, get_startup_delay, etc.). + """ + + def create_client(self, host: str, port: int, password: str) -> Arma3RemoteAdmin: + """Create a new Arma3RemoteAdmin client instance.""" + return Arma3RemoteAdmin( + server_id=0, # Will be set by caller + host=host, + port=port, + password=password, + ) + + def get_startup_delay(self) -> float: + """Seconds to wait after server start before connecting.""" + return 30.0 + + def get_poll_interval(self) -> float: + """Seconds between player list polls.""" + return 10.0 + + def get_player_data_schema(self): + """Pydantic model for players.game_data JSON.""" + return None \ No newline at end of file diff --git a/backend/adapters/exceptions.py b/backend/adapters/exceptions.py new file mode 100644 index 0000000..3706de7 --- /dev/null +++ b/backend/adapters/exceptions.py @@ -0,0 +1,53 @@ +"""Typed adapter exceptions. Core catches these specifically.""" + + +class AdapterError(Exception): + """Base for all adapter errors.""" + pass + + +class ConfigWriteError(AdapterError): + """Atomic file write failed. Temp files are already cleaned up.""" + def __init__(self, path: str, detail: str): + self.path = path + self.detail = detail + super().__init__(f"Config write failed at {path}: {detail}") + + +class ConfigValidationError(AdapterError): + """Adapter Pydantic model rejected the config values.""" + def __init__(self, section: str, errors: list[dict]): + self.section = section + self.errors = errors + super().__init__(f"Config validation failed for section '{section}': {errors}") + + +class ConfigMigrationError(AdapterError): + """migrate_config() could not transform old schema. Core keeps original.""" + def __init__(self, from_version: str, detail: str): + self.from_version = from_version + self.detail = detail + super().__init__(f"Config migration from {from_version} failed: {detail}") + + +class LaunchArgsError(AdapterError): + """build_launch_args() failed (missing mod path, bad config value).""" + def __init__(self, detail: str): + self.detail = detail + super().__init__(f"Launch args error: {detail}") + + +class RemoteAdminError(AdapterError): + """Remote admin connection or command failed.""" + def __init__(self, detail: str, recoverable: bool = True): + self.detail = detail + self.recoverable = recoverable + super().__init__(f"Remote admin error: {detail}") + + +class ExeNotAllowedError(AdapterError): + """Executable not in adapter allowlist.""" + def __init__(self, exe: str, allowed: list[str]): + self.exe = exe + self.allowed = allowed + super().__init__(f"Executable '{exe}' not allowed. Allowed: {allowed}") \ No newline at end of file diff --git a/backend/adapters/protocols.py b/backend/adapters/protocols.py new file mode 100644 index 0000000..755c1d0 --- /dev/null +++ b/backend/adapters/protocols.py @@ -0,0 +1,238 @@ +""" +All adapter capability Protocol definitions. +Core code only imports from here — never from adapter internals. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any, Callable, Protocol, runtime_checkable + +from pydantic import BaseModel + + +@runtime_checkable +class ConfigGenerator(Protocol): + """ + Merged protocol: config schema definition + file generation + launch args. + Always implement all methods. Return empty dict/list where not applicable. + """ + game_type: str + + def get_sections(self) -> dict[str, type[BaseModel]]: + """Return {section_name: PydanticModelClass} for all config sections.""" + ... + + def get_defaults(self, section: str) -> dict[str, Any]: + """Return default values dict for the given section.""" + ... + + def get_sensitive_fields(self, section: str) -> list[str]: + """ + Return JSON keys in this section that need Fernet encryption. + Core's ConfigRepository encrypts/decrypts these transparently. + Example: ["password", "password_admin"] for section "server". + """ + ... + + def get_config_version(self) -> str: + """ + Current adapter schema version string (e.g. "1.0.0"). + Stored in game_configs.schema_version. + When this changes, core calls migrate_config() automatically. + """ + ... + + def migrate_config( + self, old_version: str, config_json: dict[str, dict] + ) -> dict[str, dict]: + """ + Transform config JSON from old_version to current version. + Called by ConfigRepository when stored schema_version differs. + Returns migrated config dict. + Raises ConfigMigrationError on failure — core keeps original. + """ + ... + + def write_configs( + self, + server_id: int, + server_dir: Path, + config_sections: dict[str, dict], + ) -> list[Path]: + """ + Write all config files to disk using atomic write pattern: + 1. Write to .tmp files + 2. os.replace() each .tmp to final path + 3. On any failure: clean up .tmp files, raise ConfigWriteError + Returns list of written file paths. + """ + ... + + def build_launch_args( + self, + config_sections: dict[str, dict], + mod_args: list[str] | None = None, + ) -> list[str]: + """ + Return full CLI argument list for the game executable. + Raises LaunchArgsError if required values are missing/invalid. + """ + ... + + def preview_config( + self, + server_id: int, + server_dir: Path, + config_sections: dict[str, dict], + ) -> dict[str, str]: + """ + Render config files as strings WITHOUT writing to disk. + Returns {label: content}. + Label = filename for file-based games, var name for env-var games. + """ + ... + + +@runtime_checkable +class RemoteAdminClient(Protocol): + """A connected client instance. Not required to be thread-safe — core wraps calls.""" + + 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: ... + + +@runtime_checkable +class RemoteAdmin(Protocol): + """Factory for remote admin clients. One per adapter, creates clients on demand.""" + + def create_client(self, host: str, port: int, password: str) -> RemoteAdminClient: ... + + def get_startup_delay(self) -> float: + """Seconds to wait after server start before connecting. Default: 30.""" + ... + + def get_poll_interval(self) -> float: + """Seconds between player list polls. Default: 10.""" + ... + + def get_player_data_schema(self) -> type[BaseModel] | None: + """Pydantic model for players.game_data JSON. None = no validation.""" + ... + + +@runtime_checkable +class LogParser(Protocol): + """Parses game-specific log lines into standard format.""" + + def parse_line(self, line: str) -> dict | None: + """ + Parse one log line. + Returns: {"timestamp": ISO str, "level": "info"|"warning"|"error", "message": str} + Returns None to skip the line (e.g. blank lines, binary garbage). + """ + ... + + def get_log_file_resolver(self, server_id: int) -> Callable[[Path], Path | None]: + """ + Return a callable(server_dir: Path) -> Path | None. + Called by LogTailThread to find the current log file. + Return None if log file not yet created. + """ + ... + + +@runtime_checkable +class MissionManager(Protocol): + """Handles mission/scenario file format and rotation.""" + file_extension: str # e.g. ".pbo" + + 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. None = no validation.""" + ... + + +@runtime_checkable +class ModManager(Protocol): + """Handles mod folder conventions and CLI argument building.""" + + 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. None = no validation.""" + ... + + +@runtime_checkable +class ProcessConfig(Protocol): + """Game-specific process and directory conventions.""" + + 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): + """Bidirectional sync between DB bans and game ban file.""" + + 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. None = no validation.""" + ... + + +@runtime_checkable +class GameAdapter(Protocol): + """ + Composite adapter. Every game must implement this. + Optional capabilities return None — core degrades gracefully. + Use has_capability(name) instead of None checks throughout. + """ + game_type: str # e.g. "arma3" + display_name: str # e.g. "Arma 3" + version: str # e.g. "1.0.0" + + 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 has_capability(self, name: str) -> bool: + """ + Explicit capability probe. Use this instead of: + if adapter.get_remote_admin() is not None: + Use this instead: + if adapter.has_capability("remote_admin"): + + Valid names: "config_generator", "process_config", "log_parser", + "remote_admin", "mission_manager", "mod_manager", "ban_manager" + """ + ... + + def get_additional_routers(self) -> list: + """List of FastAPI APIRouter instances for game-specific routes.""" + ... + + def get_custom_thread_factories(self) -> list[Callable]: + """List of callables(server_id, db) -> BaseServerThread for extra threads.""" + ... \ No newline at end of file diff --git a/backend/adapters/registry.py b/backend/adapters/registry.py new file mode 100644 index 0000000..7071014 --- /dev/null +++ b/backend/adapters/registry.py @@ -0,0 +1,66 @@ +""" +GameAdapterRegistry — singleton that holds all registered game adapters. +Adapters register themselves at import time. +""" +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) + + +class GameAdapterRegistry: + _adapters: dict[str, object] = {} # game_type -> GameAdapter + + @classmethod + def register(cls, adapter) -> None: + """Register a game adapter. Called at import time by each adapter package.""" + if adapter.game_type in cls._adapters: + logger.warning( + "Adapter for '%s' already registered. Overwriting.", adapter.game_type + ) + cls._adapters[adapter.game_type] = adapter + logger.info("Registered game adapter: %s (%s)", adapter.game_type, adapter.display_name) + + @classmethod + def get(cls, game_type: str): + """ + Get adapter by game_type. Raises KeyError if not registered. + Core code calls this whenever game-specific behavior is needed. + """ + adapter = cls._adapters.get(game_type) + if adapter is None: + raise KeyError( + f"No adapter registered for game type '{game_type}'. " + f"Available: {list(cls._adapters.keys())}" + ) + return adapter + + @classmethod + def all(cls) -> list: + """Return all registered adapters.""" + return list(cls._adapters.values()) + + @classmethod + def list_game_types(cls) -> list[dict]: + """Return metadata list for API /games endpoint.""" + result = [] + for adapter in cls._adapters.values(): + caps = [] + for cap in [ + "config_generator", "process_config", "log_parser", + "remote_admin", "mission_manager", "mod_manager", "ban_manager", + ]: + if adapter.has_capability(cap): + caps.append(cap) + result.append({ + "game_type": adapter.game_type, + "display_name": adapter.display_name, + "version": adapter.version, + "capabilities": caps, + }) + return result + + @classmethod + def is_registered(cls, game_type: str) -> bool: + return game_type in cls._adapters \ No newline at end of file diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..1175d2a --- /dev/null +++ b/backend/config.py @@ -0,0 +1,35 @@ +"""Load and validate all environment variables at startup.""" +from __future__ import annotations + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_prefix="LANGUARD_", + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + # Enable JSON parsing for complex types (list[str]) from env vars + json_parse_ints=False, + ) + + secret_key: str + encryption_key: str # Fernet base64 key + db_path: str = "./languard.db" + servers_dir: str = "./servers" + host: str = "0.0.0.0" + port: int = 8000 + cors_origins: list[str] = ["http://localhost:5173"] + log_retention_days: int = 7 + metrics_retention_days: int = 30 + player_history_retention_days: int = 90 + jwt_expire_hours: int = 24 + login_rate_limit: str = "5/minute" + log_level: str = "INFO" + + # Game-specific defaults (used by adapters, not core) + arma3_default_exe: str = "C:/Arma3Server/arma3server_x64.exe" + + +settings = Settings() \ No newline at end of file diff --git a/backend/core/__init__.py b/backend/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/auth/__init__.py b/backend/core/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/auth/router.py b/backend/core/auth/router.py new file mode 100644 index 0000000..db3edf9 --- /dev/null +++ b/backend/core/auth/router.py @@ -0,0 +1,77 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, Request +from sqlalchemy.engine import Connection + +from core.auth.schemas import ( + ChangePasswordRequest, CreateUserRequest, LoginRequest, +) +from core.auth.service import AuthService +from database import get_db +from dependencies import get_current_user, require_admin + +router = APIRouter(prefix="/auth", tags=["auth"]) + +# Rate limiter will be attached after main.py is imported +_limiter = None + + +def _ok(data): + return {"success": True, "data": data, "error": None} + + +@router.post("/login") +def login( + request: Request, + body: LoginRequest, + db: Annotated[Connection, Depends(get_db)], +): + return _ok(AuthService(db).login(body.username, body.password)) + + +@router.post("/logout") +def logout(user: Annotated[dict, Depends(get_current_user)]): + # Client-side token deletion. No server-side blacklist. + return _ok({"message": "Logged out"}) + + +@router.get("/me") +def me(user: Annotated[dict, Depends(get_current_user)]): + return _ok({"id": user["id"], "username": user["username"], "role": user["role"]}) + + +@router.put("/password") +def change_password( + body: ChangePasswordRequest, + user: Annotated[dict, Depends(get_current_user)], + db: Annotated[Connection, Depends(get_db)], +): + AuthService(db).change_password(user["id"], body.current_password, body.new_password) + return _ok({"message": "Password changed"}) + + +@router.get("/users") +def list_users( + _admin: Annotated[dict, Depends(require_admin)], + db: Annotated[Connection, Depends(get_db)], +): + return _ok(AuthService(db).list_users()) + + +@router.post("/users", status_code=201) +def create_user( + body: CreateUserRequest, + _admin: Annotated[dict, Depends(require_admin)], + db: Annotated[Connection, Depends(get_db)], +): + user = AuthService(db).create_user(body.username, body.password, body.role) + return _ok(user) + + +@router.delete("/users/{user_id}", status_code=204) +def delete_user( + user_id: int, + admin: Annotated[dict, Depends(require_admin)], + db: Annotated[Connection, Depends(get_db)], +): + AuthService(db).delete_user(user_id, admin["id"]) \ No newline at end of file diff --git a/backend/core/auth/schemas.py b/backend/core/auth/schemas.py new file mode 100644 index 0000000..2a58ba0 --- /dev/null +++ b/backend/core/auth/schemas.py @@ -0,0 +1,31 @@ +from pydantic import BaseModel + + +class LoginRequest(BaseModel): + username: str + password: str + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + expires_in: int + user: dict + + +class UserResponse(BaseModel): + id: int + username: str + role: str + created_at: str + + +class CreateUserRequest(BaseModel): + username: str + password: str + role: str = "viewer" + + +class ChangePasswordRequest(BaseModel): + current_password: str + new_password: str \ No newline at end of file diff --git a/backend/core/auth/service.py b/backend/core/auth/service.py new file mode 100644 index 0000000..8ed3ae0 --- /dev/null +++ b/backend/core/auth/service.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from fastapi import HTTPException, status +from sqlalchemy import text +from sqlalchemy.engine import Connection + +from core.auth.utils import create_access_token, hash_password, verify_password +from config import settings + + +class AuthService: + + def __init__(self, db: Connection): + self._db = db + + def login(self, username: str, password: str) -> dict: + row = self._db.execute( + text("SELECT * FROM users WHERE username = :u"), {"u": username} + ).fetchone() + + if row is None or not verify_password(password, row.password_hash): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={"code": "UNAUTHORIZED", "message": "Invalid credentials"}, + ) + + user = dict(row._mapping) + self._db.execute( + text("UPDATE users SET last_login = datetime('now') WHERE id = :id"), + {"id": user["id"]}, + ) + + token = create_access_token(user["id"], user["username"], user["role"]) + return { + "access_token": token, + "token_type": "bearer", + "expires_in": settings.jwt_expire_hours * 3600, + "user": {"id": user["id"], "username": user["username"], "role": user["role"]}, + } + + def create_user(self, username: str, password: str, role: str = "viewer") -> dict: + existing = self._db.execute( + text("SELECT id FROM users WHERE username = :u"), {"u": username} + ).fetchone() + if existing: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={"code": "CONFLICT", "message": f"Username '{username}' already taken"}, + ) + self._db.execute( + text( + "INSERT INTO users (username, password_hash, role) VALUES (:u, :ph, :r)" + ), + {"u": username, "ph": hash_password(password), "r": role}, + ) + row = self._db.execute( + text("SELECT id, username, role, created_at FROM users WHERE username = :u"), + {"u": username}, + ).fetchone() + return dict(row._mapping) + + def change_password(self, user_id: int, current_password: str, new_password: str) -> None: + row = self._db.execute( + text("SELECT password_hash FROM users WHERE id = :id"), + {"id": user_id}, + ).fetchone() + if row is None or not verify_password(current_password, row.password_hash): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={"code": "UNAUTHORIZED", "message": "Current password is incorrect"}, + ) + self._db.execute( + text("UPDATE users SET password_hash = :ph WHERE id = :id"), + {"ph": hash_password(new_password), "id": user_id}, + ) + + def list_users(self) -> list[dict]: + rows = self._db.execute( + text("SELECT id, username, role, created_at, last_login FROM users ORDER BY id") + ).fetchall() + return [dict(r._mapping) for r in rows] + + def delete_user(self, user_id: int, requesting_user_id: int) -> None: + if user_id == requesting_user_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "VALIDATION_ERROR", "message": "Cannot delete yourself"}, + ) + self._db.execute( + text("DELETE FROM users WHERE id = :id"), + {"id": user_id}, + ) + + def seed_admin_if_empty(self) -> str | None: + """ + Create a default admin user if no users exist. + Returns the generated password (printed to stdout on startup). + """ + count = self._db.execute(text("SELECT COUNT(*) FROM users")).fetchone()[0] + if count > 0: + return None + import secrets + password = secrets.token_urlsafe(16) + self.create_user("admin", password, "admin") + return password \ No newline at end of file diff --git a/backend/core/auth/utils.py b/backend/core/auth/utils.py new file mode 100644 index 0000000..3b37f10 --- /dev/null +++ b/backend/core/auth/utils.py @@ -0,0 +1,48 @@ +"""JWT creation/validation and password hashing.""" +from __future__ import annotations + +import logging +from datetime import datetime, timedelta, timezone + +import bcrypt +from jose import JWTError, jwt + +logger = logging.getLogger(__name__) + + +def hash_password(password: str) -> str: + """Hash a password using bcrypt. Returns UTF-8 encoded hash string.""" + password_bytes = password.encode("utf-8") + salt = bcrypt.gensalt() + hashed = bcrypt.hashpw(password_bytes, salt) + return hashed.decode("utf-8") + + +def verify_password(plain: str, hashed: str) -> bool: + """Verify a plain password against a bcrypt hash.""" + try: + return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8")) + except Exception as exc: + logger.warning("Password verification failed: %s", exc) + return False + + +def create_access_token(user_id: int, username: str, role: str) -> str: + from config import settings + expire = datetime.now(timezone.utc) + timedelta(hours=settings.jwt_expire_hours) + payload = { + "sub": str(user_id), + "username": username, + "role": role, + "exp": expire, + } + return jwt.encode(payload, settings.secret_key, algorithm="HS256") + + +def decode_access_token(token: str) -> dict: + """ + Decode and validate JWT. Returns payload dict. + Raises JWTError on invalid/expired token. + """ + from config import settings + return jwt.decode(token, settings.secret_key, algorithms=["HS256"]) \ No newline at end of file diff --git a/backend/core/bans/__init__.py b/backend/core/bans/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/dal/__init__.py b/backend/core/dal/__init__.py new file mode 100644 index 0000000..ebb453c --- /dev/null +++ b/backend/core/dal/__init__.py @@ -0,0 +1 @@ +"""Data Access Layer repositories.""" \ No newline at end of file diff --git a/backend/core/dal/ban_repository.py b/backend/core/dal/ban_repository.py new file mode 100644 index 0000000..8a046b4 --- /dev/null +++ b/backend/core/dal/ban_repository.py @@ -0,0 +1,52 @@ +import json +from datetime import datetime, timezone +from core.dal.base_repository import BaseRepository + + +class BanRepository(BaseRepository): + + def get_all(self, server_id: int, active_only: bool = True) -> list[dict]: + if active_only: + return self._fetchall( + "SELECT * FROM bans WHERE server_id = :sid AND is_active = 1 ORDER BY banned_at DESC", + {"sid": server_id}, + ) + return self._fetchall( + "SELECT * FROM bans WHERE server_id = :sid ORDER BY banned_at DESC", + {"sid": server_id}, + ) + + def create( + self, + server_id: int, + guid: str | None, + name: str | None, + reason: str | None, + banned_by: str, + expires_at: str | None = None, + game_data: dict | None = None, + ) -> int: + return self._lastrowid( + """ + INSERT INTO bans (server_id, guid, name, reason, banned_by, expires_at, game_data) + VALUES (:sid, :guid, :name, :reason, :by, :exp, :gd) + """, + { + "sid": server_id, + "guid": guid, + "name": name, + "reason": reason, + "by": banned_by, + "exp": expires_at, + "gd": json.dumps(game_data or {}), + }, + ) + + def deactivate(self, ban_id: int) -> None: + self._execute( + "UPDATE bans SET is_active = 0 WHERE id = :id", + {"id": ban_id}, + ) + + def get_by_id(self, ban_id: int) -> dict | None: + return self._fetchone("SELECT * FROM bans WHERE id = :id", {"id": ban_id}) \ No newline at end of file diff --git a/backend/core/dal/base_repository.py b/backend/core/dal/base_repository.py new file mode 100644 index 0000000..74989db --- /dev/null +++ b/backend/core/dal/base_repository.py @@ -0,0 +1,27 @@ +"""Base repository with common DB helpers.""" +from __future__ import annotations + +from sqlalchemy import text +from sqlalchemy.engine import Connection + + +class BaseRepository: + def __init__(self, db: Connection): + self._db = db + + def _execute(self, query: str, params: dict | None = None): + return self._db.execute(text(query), params or {}) + + def _fetchone(self, query: str, params: dict | None = None) -> dict | None: + row = self._db.execute(text(query), params or {}).fetchone() + if row is None: + return None + return dict(row._mapping) + + def _fetchall(self, query: str, params: dict | None = None) -> list[dict]: + rows = self._db.execute(text(query), params or {}).fetchall() + return [dict(r._mapping) for r in rows] + + def _lastrowid(self, query: str, params: dict | None = None) -> int: + result = self._db.execute(text(query), params or {}) + return result.lastrowid \ No newline at end of file diff --git a/backend/core/dal/config_repository.py b/backend/core/dal/config_repository.py new file mode 100644 index 0000000..1eea79a --- /dev/null +++ b/backend/core/dal/config_repository.py @@ -0,0 +1,163 @@ +""" +Manages the game_configs table. +Handles Fernet encryption/decryption of sensitive fields transparently. +""" +from __future__ import annotations + +import json +from datetime import datetime, timezone + +from core.dal.base_repository import BaseRepository +from core.utils.crypto import decrypt, encrypt, is_encrypted + + +class ConfigRepository(BaseRepository): + + def _encrypt_sensitive( + self, config: dict, sensitive_fields: list[str] + ) -> dict: + """Return new dict with sensitive fields encrypted.""" + result = dict(config) + for field in sensitive_fields: + if field in result and result[field] and not is_encrypted(str(result[field])): + result[field] = encrypt(str(result[field])) + return result + + def _decrypt_sensitive( + self, config: dict, sensitive_fields: list[str] + ) -> dict: + """Return new dict with sensitive fields decrypted.""" + result = dict(config) + for field in sensitive_fields: + if field in result and is_encrypted(str(result[field])): + result[field] = decrypt(str(result[field])) + return result + + def get_section( + self, + server_id: int, + section: str, + sensitive_fields: list[str] | None = None, + ) -> dict | None: + """Get a config section. Decrypts sensitive fields automatically.""" + row = self._fetchone( + "SELECT * FROM game_configs WHERE server_id = :sid AND section = :sec", + {"sid": server_id, "sec": section}, + ) + if row is None: + return None + config = json.loads(row["config_json"]) + if sensitive_fields: + config = self._decrypt_sensitive(config, sensitive_fields) + config["_meta"] = { + "config_version": row["config_version"], + "schema_version": row["schema_version"], + } + return config + + def get_all_sections( + self, + server_id: int, + sensitive_fields_by_section: dict[str, list[str]] | None = None, + ) -> dict[str, dict]: + """Get all config sections for a server.""" + rows = self._fetchall( + "SELECT * FROM game_configs WHERE server_id = :sid ORDER BY section", + {"sid": server_id}, + ) + result = {} + for row in rows: + config = json.loads(row["config_json"]) + sf = (sensitive_fields_by_section or {}).get(row["section"], []) + if sf: + config = self._decrypt_sensitive(config, sf) + config["_meta"] = { + "config_version": row["config_version"], + "schema_version": row["schema_version"], + } + result[row["section"]] = config + return result + + def upsert_section( + self, + server_id: int, + game_type: str, + section: str, + config_data: dict, + schema_version: str, + sensitive_fields: list[str] | None = None, + expected_config_version: int | None = None, + ) -> int: + """ + Upsert a config section. + If expected_config_version is provided, checks optimistic lock. + Returns the new config_version. + Raises ValueError on version conflict (caller returns 409). + """ + now = datetime.now(timezone.utc).isoformat() + + # Strip _meta before storing + data_to_store = {k: v for k, v in config_data.items() if k != "_meta"} + + # Encrypt sensitive fields + if sensitive_fields: + data_to_store = self._encrypt_sensitive(data_to_store, sensitive_fields) + + # Check if row exists + existing = self._fetchone( + "SELECT id, config_version FROM game_configs WHERE server_id = :sid AND section = :sec", + {"sid": server_id, "sec": section}, + ) + + if existing is None: + # Insert + self._execute( + """ + INSERT INTO game_configs + (server_id, game_type, section, config_json, config_version, schema_version, updated_at) + VALUES (:sid, :gt, :sec, :json, 1, :sv, :now) + """, + { + "sid": server_id, "gt": game_type, "sec": section, + "json": json.dumps(data_to_store), "sv": schema_version, "now": now, + }, + ) + return 1 + else: + current_version = existing["config_version"] + if expected_config_version is not None and expected_config_version != current_version: + raise ValueError( + f"CONFIG_VERSION_CONFLICT:{current_version}" + ) + new_version = current_version + 1 + self._execute( + """ + UPDATE game_configs + SET config_json = :json, config_version = :cv, + schema_version = :sv, updated_at = :now + WHERE server_id = :sid AND section = :sec + """, + { + "json": json.dumps(data_to_store), + "cv": new_version, + "sv": schema_version, + "now": now, + "sid": server_id, + "sec": section, + }, + ) + return new_version + + def delete_sections(self, server_id: int) -> None: + self._execute( + "DELETE FROM game_configs WHERE server_id = :sid", + {"sid": server_id}, + ) + + def get_raw_sections(self, server_id: int) -> dict[str, dict]: + """Get all sections without decryption — for config file generation.""" + rows = self._fetchall( + "SELECT section, config_json FROM game_configs WHERE server_id = :sid", + {"sid": server_id}, + ) + return {row["section"]: json.loads(row["config_json"]) for row in rows} \ No newline at end of file diff --git a/backend/core/dal/event_repository.py b/backend/core/dal/event_repository.py new file mode 100644 index 0000000..614283a --- /dev/null +++ b/backend/core/dal/event_repository.py @@ -0,0 +1,62 @@ +import json +from core.dal.base_repository import BaseRepository + + +class EventRepository(BaseRepository): + + def insert( + self, + server_id: int, + event_type: str, + actor: str = "system", + detail: dict | None = None, + ) -> None: + self._execute( + """ + INSERT INTO server_events (server_id, event_type, actor, detail) + VALUES (:sid, :et, :actor, :detail) + """, + { + "sid": server_id, + "et": event_type, + "actor": actor, + "detail": json.dumps(detail) if detail else None, + }, + ) + + def get_events( + self, + server_id: int, + limit: int = 50, + offset: int = 0, + event_type: str | None = None, + ) -> list[dict]: + if event_type: + return self._fetchall( + """ + SELECT * FROM server_events + WHERE server_id = :sid AND event_type = :et + ORDER BY created_at DESC LIMIT :limit OFFSET :offset + """, + {"sid": server_id, "et": event_type, "limit": limit, "offset": offset}, + ) + return self._fetchall( + """ + SELECT * FROM server_events WHERE server_id = :sid + ORDER BY created_at DESC LIMIT :limit OFFSET :offset + """, + {"sid": server_id, "limit": limit, "offset": offset}, + ) + + def get_recent_all_servers(self, limit: int = 20) -> list[dict]: + return self._fetchall( + "SELECT * FROM server_events ORDER BY created_at DESC LIMIT :limit", + {"limit": limit}, + ) + + def cleanup_old(self, retention_days: int) -> None: + """Delete events older than retention_days.""" + self._execute( + "DELETE FROM server_events WHERE created_at < datetime('now', :delta)", + {"delta": f"-{retention_days} days"}, + ) \ No newline at end of file diff --git a/backend/core/dal/log_repository.py b/backend/core/dal/log_repository.py new file mode 100644 index 0000000..ef43e4c --- /dev/null +++ b/backend/core/dal/log_repository.py @@ -0,0 +1,61 @@ +from core.dal.base_repository import BaseRepository + + +class LogRepository(BaseRepository): + + def insert(self, server_id: int, entry: dict) -> None: + """entry = {timestamp, level, message}""" + self._execute( + """ + INSERT INTO logs (server_id, timestamp, level, message) + VALUES (:sid, :ts, :level, :msg) + """, + { + "sid": server_id, + "ts": entry.get("timestamp", ""), + "level": entry.get("level", "info"), + "msg": entry.get("message", ""), + }, + ) + + def query( + self, + server_id: int, + limit: int = 200, + offset: int = 0, + level: str | None = None, + since: str | None = None, + search: str | None = None, + ) -> tuple[int, list[dict]]: + conditions = ["server_id = :sid"] + params: dict = {"sid": server_id, "limit": limit, "offset": offset} + if level: + conditions.append("level = :level") + params["level"] = level + if since: + conditions.append("timestamp >= :since") + params["since"] = since + if search: + conditions.append("message LIKE :search") + params["search"] = f"%{search}%" + + where = " AND ".join(conditions) + total_row = self._fetchone(f"SELECT COUNT(*) as cnt FROM logs WHERE {where}", params) + total = total_row["cnt"] if total_row else 0 + rows = self._fetchall( + f"SELECT * FROM logs WHERE {where} ORDER BY timestamp DESC LIMIT :limit OFFSET :offset", + params, + ) + return total, rows + + def clear(self, server_id: int) -> int: + result = self._execute( + "DELETE FROM logs WHERE server_id = :sid", {"sid": server_id} + ) + return result.rowcount + + def cleanup_old(self, retention_days: int) -> None: + self._execute( + "DELETE FROM logs WHERE created_at < datetime('now', :delta)", + {"delta": f"-{retention_days} days"}, + ) \ No newline at end of file diff --git a/backend/core/dal/metrics_repository.py b/backend/core/dal/metrics_repository.py new file mode 100644 index 0000000..758f14c --- /dev/null +++ b/backend/core/dal/metrics_repository.py @@ -0,0 +1,53 @@ +from core.dal.base_repository import BaseRepository + + +class MetricsRepository(BaseRepository): + + def insert( + self, server_id: int, cpu_percent: float, ram_mb: float = 0.0, player_count: int = 0 + ) -> None: + self._execute( + """ + INSERT INTO metrics (server_id, cpu_percent, ram_mb, player_count) + VALUES (:sid, :cpu, :ram, :pc) + """, + {"sid": server_id, "cpu": cpu_percent, "ram": ram_mb, "pc": player_count}, + ) + + def query( + self, + server_id: int, + from_ts: str | None = None, + to_ts: str | None = None, + ) -> list[dict]: + conditions = ["server_id = :sid"] + params: dict = {"sid": server_id} + if from_ts: + conditions.append("timestamp >= :from_ts") + params["from_ts"] = from_ts + if to_ts: + conditions.append("timestamp <= :to_ts") + params["to_ts"] = to_ts + where = " AND ".join(conditions) + return self._fetchall( + f"SELECT * FROM metrics WHERE {where} ORDER BY timestamp ASC", + params, + ) + + def get_latest(self, server_id: int) -> dict | None: + return self._fetchone( + "SELECT * FROM metrics WHERE server_id = :sid ORDER BY timestamp DESC LIMIT 1", + {"sid": server_id}, + ) + + def cleanup_old(self, retention_days: int = 1, server_id: int | None = None) -> None: + if server_id is not None: + self._execute( + "DELETE FROM metrics WHERE server_id = :sid AND timestamp < datetime('now', :delta)", + {"sid": server_id, "delta": f"-{retention_days} days"}, + ) + else: + self._execute( + "DELETE FROM metrics WHERE timestamp < datetime('now', :delta)", + {"delta": f"-{retention_days} days"}, + ) \ No newline at end of file diff --git a/backend/core/dal/player_repository.py b/backend/core/dal/player_repository.py new file mode 100644 index 0000000..9439f27 --- /dev/null +++ b/backend/core/dal/player_repository.py @@ -0,0 +1,70 @@ +import json +from datetime import datetime, timezone +from core.dal.base_repository import BaseRepository + + +class PlayerRepository(BaseRepository): + + def get_all(self, server_id: int) -> list[dict]: + return self._fetchall( + "SELECT * FROM players WHERE server_id = :sid ORDER BY slot_id", + {"sid": server_id}, + ) + + def count(self, server_id: int) -> int: + row = self._fetchone( + "SELECT COUNT(*) as cnt FROM players WHERE server_id = :sid", + {"sid": server_id}, + ) + return row["cnt"] if row else 0 + + def upsert(self, server_id: int, player: dict) -> None: + now = datetime.now(timezone.utc).isoformat() + self._execute( + """ + INSERT INTO players (server_id, slot_id, name, guid, ip, ping, game_data, joined_at, updated_at) + VALUES (:sid, :slot, :name, :guid, :ip, :ping, :gd, :now, :now) + ON CONFLICT(server_id, slot_id) DO UPDATE SET + name = excluded.name, + guid = excluded.guid, + ping = excluded.ping, + game_data = excluded.game_data, + updated_at = excluded.updated_at + """, + { + "sid": server_id, + "slot": str(player.get("slot_id", "")), + "name": player.get("name", ""), + "guid": player.get("guid"), + "ip": player.get("ip"), + "ping": player.get("ping"), + "gd": json.dumps(player.get("game_data", {})), + "now": now, + }, + ) + + def clear(self, server_id: int) -> None: + self._execute("DELETE FROM players WHERE server_id = :sid", {"sid": server_id}) + + def get_history( + self, + server_id: int, + limit: int = 50, + offset: int = 0, + search: str | None = None, + ) -> tuple[int, list[dict]]: + conditions = ["server_id = :sid"] + params: dict = {"sid": server_id, "limit": limit, "offset": offset} + if search: + conditions.append("name LIKE :search") + params["search"] = f"%{search}%" + where = " AND ".join(conditions) + total_row = self._fetchone( + f"SELECT COUNT(*) as cnt FROM player_history WHERE {where}", params + ) + total = total_row["cnt"] if total_row else 0 + rows = self._fetchall( + f"SELECT * FROM player_history WHERE {where} ORDER BY left_at DESC LIMIT :limit OFFSET :offset", + params, + ) + return total, rows \ No newline at end of file diff --git a/backend/core/dal/server_repository.py b/backend/core/dal/server_repository.py new file mode 100644 index 0000000..55b60d9 --- /dev/null +++ b/backend/core/dal/server_repository.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +from core.dal.base_repository import BaseRepository + + +class ServerRepository(BaseRepository): + + def get_all(self, game_type: str | None = None) -> list[dict]: + if game_type: + return self._fetchall( + "SELECT * FROM servers WHERE game_type = :gt ORDER BY name", + {"gt": game_type}, + ) + return self._fetchall("SELECT * FROM servers ORDER BY name") + + def get_by_id(self, server_id: int) -> dict | None: + return self._fetchone("SELECT * FROM servers WHERE id = :id", {"id": server_id}) + + def create( + self, + name: str, + game_type: str, + exe_path: str, + game_port: int, + rcon_port: int | None = None, + description: str | None = None, + auto_restart: bool = False, + max_restarts: int = 3, + ) -> int: + return self._lastrowid( + """ + INSERT INTO servers + (name, description, game_type, exe_path, game_port, rcon_port, + auto_restart, max_restarts) + VALUES + (:name, :desc, :game_type, :exe, :gp, :rp, :ar, :mr) + """, + { + "name": name, + "desc": description, + "game_type": game_type, + "exe": exe_path, + "gp": game_port, + "rp": rcon_port, + "ar": int(auto_restart), + "mr": max_restarts, + }, + ) + + def update(self, server_id: int, **fields) -> None: + if not fields: + return + fields["updated_at"] = datetime.now(timezone.utc).isoformat() + fields["id"] = server_id + set_clause = ", ".join(f"{k} = :{k}" for k in fields if k != "id") + self._execute(f"UPDATE servers SET {set_clause} WHERE id = :id", fields) + + def update_status( + self, + server_id: int, + status: str, + pid: int | None = None, + started_at: str | None = None, + stopped_at: str | None = None, + ) -> None: + now = datetime.now(timezone.utc).isoformat() + self._execute( + """ + UPDATE servers + SET status = :status, pid = :pid, started_at = :sa, + stopped_at = :sta, updated_at = :now + WHERE id = :id + """, + { + "status": status, + "pid": pid, + "sa": started_at, + "sta": stopped_at, + "now": now, + "id": server_id, + }, + ) + + def delete(self, server_id: int) -> None: + self._execute("DELETE FROM servers WHERE id = :id", {"id": server_id}) + + def get_running(self) -> list[dict]: + return self._fetchall( + "SELECT * FROM servers WHERE status IN ('running', 'starting')" + ) + + def increment_restart_count(self, server_id: int) -> None: + now = datetime.now(timezone.utc).isoformat() + self._execute( + """ + UPDATE servers + SET restart_count = restart_count + 1, + last_restart_at = :now, + updated_at = :now + WHERE id = :id + """, + {"now": now, "id": server_id}, + ) + + def reset_restart_count(self, server_id: int) -> None: + self._execute( + "UPDATE servers SET restart_count = 0 WHERE id = :id", + {"id": server_id}, + ) \ No newline at end of file diff --git a/backend/core/events/__init__.py b/backend/core/events/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/games/__init__.py b/backend/core/games/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/games/router.py b/backend/core/games/router.py new file mode 100644 index 0000000..f7a6342 --- /dev/null +++ b/backend/core/games/router.py @@ -0,0 +1,70 @@ +from fastapi import APIRouter, HTTPException, status + +from adapters.registry import GameAdapterRegistry + +router = APIRouter(prefix="/games", tags=["games"]) + + +def _ok(data): + return {"success": True, "data": data, "error": None} + + +@router.get("") +def list_games(): + return _ok(GameAdapterRegistry.list_game_types()) + + +@router.get("/{game_type}") +def get_game(game_type: str): + try: + adapter = GameAdapterRegistry.get(game_type) + except KeyError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"code": "GAME_TYPE_NOT_FOUND", "message": f"Unknown game type: {game_type}"}, + ) + caps = [] + for cap in ["config_generator", "process_config", "log_parser", + "remote_admin", "mission_manager", "mod_manager", "ban_manager"]: + if adapter.has_capability(cap): + caps.append(cap) + + config_gen = adapter.get_config_generator() + sections = list(config_gen.get_sections().keys()) + process_config = adapter.get_process_config() + + return _ok({ + "game_type": adapter.game_type, + "display_name": adapter.display_name, + "version": adapter.version, + "schema_version": config_gen.get_config_version(), + "capabilities": caps, + "config_sections": sections, + "allowed_executables": process_config.get_allowed_executables(), + }) + + +@router.get("/{game_type}/config-schema") +def get_config_schema(game_type: str): + try: + adapter = GameAdapterRegistry.get(game_type) + except KeyError: + raise HTTPException(status_code=404, detail={"code": "GAME_TYPE_NOT_FOUND"}) + config_gen = adapter.get_config_generator() + schemas = {} + for section, model_cls in config_gen.get_sections().items(): + schemas[section] = model_cls.model_json_schema() + return _ok(schemas) + + +@router.get("/{game_type}/defaults") +def get_defaults(game_type: str): + try: + adapter = GameAdapterRegistry.get(game_type) + except KeyError: + raise HTTPException(status_code=404, detail={"code": "GAME_TYPE_NOT_FOUND"}) + config_gen = adapter.get_config_generator() + defaults = {} + for section in config_gen.get_sections(): + defaults[section] = config_gen.get_defaults(section) + return _ok(defaults) \ No newline at end of file diff --git a/backend/core/jobs/__init__.py b/backend/core/jobs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/jobs/cleanup_jobs.py b/backend/core/jobs/cleanup_jobs.py new file mode 100644 index 0000000..ec9e6de --- /dev/null +++ b/backend/core/jobs/cleanup_jobs.py @@ -0,0 +1,102 @@ +""" +Cleanup jobs registered with APScheduler. + +Jobs: + - cleanup_old_logs: Delete log entries older than 7 days, daily at 03:00 + - cleanup_old_metrics: Delete metrics older than 1 day, every 6 hours + - cleanup_old_events: Delete events older than 30 days, weekly on Sunday +""" +from __future__ import annotations + +import logging + +from apscheduler.triggers.cron import CronTrigger +from apscheduler.triggers.interval import IntervalTrigger + +from core.jobs.scheduler import get_scheduler +from database import get_thread_db +from core.dal.log_repository import LogRepository +from core.dal.metrics_repository import MetricsRepository +from core.dal.event_repository import EventRepository + +logger = logging.getLogger(__name__) + +_LOG_RETENTION_DAYS = 7 +_METRICS_RETENTION_DAYS = 1 +_EVENT_RETENTION_DAYS = 30 + + +def register_cleanup_jobs() -> None: + """Register all cleanup jobs with the scheduler. Call at startup.""" + sched = get_scheduler() + + sched.add_job( + func=_cleanup_old_logs, + trigger=CronTrigger(hour=3, minute=0), + id="cleanup_old_logs", + name="Clean up old log entries", + replace_existing=True, + ) + + sched.add_job( + func=_cleanup_old_metrics, + trigger=IntervalTrigger(hours=6), + id="cleanup_old_metrics", + name="Clean up old metrics", + replace_existing=True, + ) + + sched.add_job( + func=_cleanup_old_events, + trigger=CronTrigger(day_of_week="sun", hour=4, minute=0), + id="cleanup_old_events", + name="Clean up old events", + replace_existing=True, + ) + + logger.info("Cleanup jobs registered") + + +def _cleanup_old_logs() -> None: + logger.info("Running log cleanup (retention=%d days)", _LOG_RETENTION_DAYS) + try: + db = get_thread_db() + try: + log_repo = LogRepository(db) + log_repo.cleanup_old(retention_days=_LOG_RETENTION_DAYS) + db.commit() + finally: + db.close() + logger.info("Log cleanup complete") + except Exception as exc: + logger.error("Log cleanup failed: %s", exc, exc_info=True) + + +def _cleanup_old_metrics() -> None: + logger.info("Running metrics cleanup (retention=%d days)", _METRICS_RETENTION_DAYS) + try: + db = get_thread_db() + try: + metrics_repo = MetricsRepository(db) + metrics_repo.cleanup_old(retention_days=_METRICS_RETENTION_DAYS) + db.commit() + finally: + db.close() + logger.info("Metrics cleanup complete") + except Exception as exc: + logger.error("Metrics cleanup failed: %s", exc, exc_info=True) + + +def _cleanup_old_events() -> None: + logger.info("Running event cleanup (retention=%d days)", _EVENT_RETENTION_DAYS) + try: + db = get_thread_db() + try: + event_repo = EventRepository(db) + event_repo.cleanup_old(retention_days=_EVENT_RETENTION_DAYS) + db.commit() + finally: + db.close() + logger.info("Event cleanup complete") + except Exception as exc: + logger.error("Event cleanup failed: %s", exc, exc_info=True) \ No newline at end of file diff --git a/backend/core/jobs/scheduler.py b/backend/core/jobs/scheduler.py new file mode 100644 index 0000000..ee226ae --- /dev/null +++ b/backend/core/jobs/scheduler.py @@ -0,0 +1,40 @@ +""" +APScheduler setup for background cleanup jobs. + +One scheduler instance runs per process. +Jobs run in their own threads (ThreadPoolExecutor). +""" +from __future__ import annotations + +import logging + +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.executors.pool import ThreadPoolExecutor + +logger = logging.getLogger(__name__) + +_scheduler: BackgroundScheduler | None = None + + +def get_scheduler() -> BackgroundScheduler: + global _scheduler + if _scheduler is None: + _scheduler = BackgroundScheduler( + executors={"default": ThreadPoolExecutor(max_workers=2)}, + job_defaults={"coalesce": True, "max_instances": 1}, + ) + return _scheduler + + +def start_scheduler() -> None: + sched = get_scheduler() + if not sched.running: + sched.start() + logger.info("APScheduler started") + + +def stop_scheduler() -> None: + global _scheduler + if _scheduler is not None and _scheduler.running: + _scheduler.shutdown(wait=False) + logger.info("APScheduler stopped") \ No newline at end of file diff --git a/backend/core/logs/__init__.py b/backend/core/logs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/metrics/__init__.py b/backend/core/metrics/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/migrations/001_initial_schema.sql b/backend/core/migrations/001_initial_schema.sql new file mode 100644 index 0000000..8f2d30e --- /dev/null +++ b/backend/core/migrations/001_initial_schema.sql @@ -0,0 +1,187 @@ +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'viewer', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_login TEXT, + CHECK (role IN ('admin', 'viewer')) +); + +CREATE TABLE IF NOT EXISTS servers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT, + game_type TEXT NOT NULL DEFAULT 'arma3', + status TEXT NOT NULL DEFAULT 'stopped', + pid INTEGER, + exe_path TEXT NOT NULL, + started_at TEXT, + stopped_at TEXT, + game_port INTEGER NOT NULL, + rcon_port INTEGER, + auto_restart INTEGER NOT NULL DEFAULT 0, + max_restarts INTEGER NOT NULL DEFAULT 3, + restart_window_seconds INTEGER NOT NULL DEFAULT 300, + restart_count INTEGER NOT NULL DEFAULT 0, + last_restart_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + CHECK (status IN ('stopped','starting','running','stopping','crashed','error')), + CHECK (game_port BETWEEN 1024 AND 65535), + CHECK (rcon_port IS NULL OR (rcon_port BETWEEN 1024 AND 65535)) +); + +CREATE INDEX IF NOT EXISTS idx_servers_status ON servers(status); +CREATE INDEX IF NOT EXISTS idx_servers_game_type ON servers(game_type); +CREATE INDEX IF NOT EXISTS idx_servers_game_port ON servers(game_port); + +CREATE TABLE IF NOT EXISTS 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.0', + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(server_id, section) +); + +CREATE INDEX IF NOT EXISTS idx_game_configs_server ON game_configs(server_id); +CREATE INDEX IF NOT EXISTS idx_game_configs_type_section ON game_configs(game_type, section); + +CREATE TABLE IF NOT EXISTS mods ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + game_type TEXT NOT NULL, + name TEXT NOT NULL, + folder_path TEXT NOT NULL, + workshop_id TEXT, + description TEXT, + game_data TEXT DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (game_type, folder_path) +); + +CREATE TABLE IF NOT EXISTS 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, + sort_order INTEGER NOT NULL DEFAULT 0, + game_data TEXT DEFAULT '{}', + PRIMARY KEY (server_id, mod_id) +); + +CREATE INDEX IF NOT EXISTS idx_server_mods_server ON server_mods(server_id); + +CREATE TABLE IF NOT EXISTS missions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + filename TEXT NOT NULL, + mission_name TEXT NOT NULL, + terrain TEXT, + file_size INTEGER, + game_data TEXT DEFAULT '{}', + uploaded_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (server_id, filename) +); + +CREATE INDEX IF NOT EXISTS idx_missions_server ON missions(server_id); + +CREATE TABLE IF NOT EXISTS mission_rotation ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + 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, + params_json TEXT NOT NULL DEFAULT '{}', + game_data TEXT DEFAULT '{}', + UNIQUE (server_id, sort_order) +); + +CREATE INDEX IF NOT EXISTS idx_mission_rotation_server ON mission_rotation(server_id); + +CREATE TABLE IF NOT EXISTS players ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + 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) +); + +CREATE INDEX IF NOT EXISTS idx_players_server ON players(server_id); + +CREATE TABLE IF NOT EXISTS player_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + name TEXT NOT NULL, + guid TEXT, + ip TEXT, + game_data TEXT DEFAULT '{}', + joined_at TEXT NOT NULL, + left_at TEXT NOT NULL DEFAULT (datetime('now')), + session_duration_seconds INTEGER +); + +CREATE INDEX IF NOT EXISTS idx_player_history_server ON player_history(server_id); +CREATE INDEX IF NOT EXISTS idx_player_history_guid ON player_history(guid); + +CREATE TABLE IF NOT EXISTS bans ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + guid TEXT, + name TEXT, + reason TEXT, + banned_by TEXT, + banned_at TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT, + is_active INTEGER NOT NULL DEFAULT 1, + game_data TEXT DEFAULT '{}', + CHECK (is_active IN (0, 1)) +); + +CREATE INDEX IF NOT EXISTS idx_bans_server ON bans(server_id); +CREATE INDEX IF NOT EXISTS idx_bans_guid ON bans(guid); +CREATE INDEX IF NOT EXISTS idx_bans_active ON bans(is_active); + +CREATE TABLE IF NOT EXISTS logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + timestamp TEXT NOT NULL, + level TEXT NOT NULL DEFAULT 'info', + message TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + CHECK (level IN ('info', 'warning', 'error')) +); + +CREATE INDEX IF NOT EXISTS idx_logs_server_ts ON logs(server_id, timestamp); +CREATE INDEX IF NOT EXISTS idx_logs_level ON logs(level); +CREATE INDEX IF NOT EXISTS idx_logs_created ON logs(created_at); + +CREATE TABLE IF NOT EXISTS metrics ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + timestamp TEXT NOT NULL DEFAULT (datetime('now')), + cpu_percent REAL, + ram_mb REAL, + player_count INTEGER +); + +CREATE INDEX IF NOT EXISTS idx_metrics_server_ts ON metrics(server_id, timestamp); + +CREATE TABLE IF NOT EXISTS server_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE, + event_type TEXT NOT NULL, + actor TEXT, + detail TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_events_server ON server_events(server_id, created_at) \ No newline at end of file diff --git a/backend/core/migrations/__init__.py b/backend/core/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/players/__init__.py b/backend/core/players/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/servers/__init__.py b/backend/core/servers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/servers/bans_router.py b/backend/core/servers/bans_router.py new file mode 100644 index 0000000..864c72e --- /dev/null +++ b/backend/core/servers/bans_router.py @@ -0,0 +1,142 @@ +"""Ban management endpoints — create, list, and revoke bans.""" +from __future__ import annotations + +import logging +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, field_validator +from sqlalchemy.engine import Connection + +from adapters.arma3.ban_manager import Arma3BanManager +from core.dal.ban_repository import BanRepository +from core.servers.service import ServerService +from database import get_db +from dependencies import get_current_user, require_admin + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/servers/{server_id}/bans", tags=["bans"]) + + +def _ok(data): + return {"success": True, "data": data, "error": None} + + +class CreateBanRequest(BaseModel): + player_uid: str + ban_type: str = "GUID" + reason: str = "" + duration_minutes: int = 0 # 0 = permanent + + @field_validator("ban_type") + @classmethod + def validate_ban_type(cls, v: str) -> str: + if v not in ("GUID", "IP"): + raise ValueError("ban_type must be 'GUID' or 'IP'") + return v + + @field_validator("duration_minutes") + @classmethod + def validate_duration(cls, v: int) -> int: + if v < 0: + raise ValueError("duration_minutes cannot be negative") + return v + + +@router.get("") +def list_bans( + server_id: int, + db: Annotated[Connection, Depends(get_db)], + _user: Annotated[dict, Depends(get_current_user)], +) -> dict: + """List all active bans for the server.""" + ServerService(db).get_server(server_id) # raises 404 if not found + ban_repo = BanRepository(db) + bans = ban_repo.get_all(server_id=server_id) + return _ok(bans) + + +@router.post("", status_code=status.HTTP_201_CREATED) +def create_ban( + server_id: int, + body: CreateBanRequest, + db: Annotated[Connection, Depends(get_db)], + _admin: Annotated[dict, Depends(require_admin)], +) -> dict: + """Create a new ban. Writes to DB and syncs to bans.txt.""" + ServerService(db).get_server(server_id) # raises 404 if not found + ban_repo = BanRepository(db) + + # Calculate expires_at if duration is set + expires_at = None + if body.duration_minutes > 0: + from datetime import datetime, timezone, timedelta + expires_at = ( + datetime.now(timezone.utc) + timedelta(minutes=body.duration_minutes) + ).isoformat() + + ban_id = ban_repo.create( + server_id=server_id, + guid=body.player_uid if body.ban_type == "GUID" else None, + name=None, + reason=body.reason, + banned_by=_admin["username"], + expires_at=expires_at, + game_data={"ban_type": body.ban_type, "duration_minutes": body.duration_minutes}, + ) + db.commit() + + ban = ban_repo.get_by_id(ban_id) + + # Sync to bans.txt (non-blocking — log error but don't fail request) + _sync_ban_to_file(server_id, body.player_uid, body.ban_type, body.reason, body.duration_minutes) + + return _ok(ban) + + +@router.delete("/{ban_id}") +def revoke_ban( + server_id: int, + ban_id: int, + db: Annotated[Connection, Depends(get_db)], + _admin: Annotated[dict, Depends(require_admin)], +) -> dict: + """Revoke a ban (marks as inactive in DB, removes from bans.txt).""" + ServerService(db).get_server(server_id) # raises 404 if not found + ban_repo = BanRepository(db) + ban = ban_repo.get_by_id(ban_id) + if ban is None or ban["server_id"] != server_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"code": "NOT_FOUND", "message": "Ban not found"}, + ) + ban_repo.deactivate(ban_id) + db.commit() + + # Remove from bans.txt + _remove_ban_from_file(server_id, ban.get("guid") or "") + + return _ok({"message": f"Ban {ban_id} revoked"}) + + +# ── File sync helpers ── + +def _sync_ban_to_file( + server_id: int, identifier: str, ban_type: str, reason: str, duration_minutes: int +) -> None: + """Write ban to bans.txt. Log error but don't fail the request.""" + try: + mgr = Arma3BanManager(server_id) + mgr.add_ban(identifier, ban_type, reason, duration_minutes) + except Exception as exc: + logger.error("Failed to sync ban to bans.txt for server %d: %s", server_id, exc) + + +def _remove_ban_from_file(server_id: int, identifier: str) -> None: + """Remove ban from bans.txt. Log error but don't fail the request.""" + try: + mgr = Arma3BanManager(server_id) + mgr.remove_ban(identifier) + except Exception as exc: + logger.error("Failed to remove ban from bans.txt for server %d: %s", server_id, exc) \ No newline at end of file diff --git a/backend/core/servers/missions_router.py b/backend/core/servers/missions_router.py new file mode 100644 index 0000000..0ce197e --- /dev/null +++ b/backend/core/servers/missions_router.py @@ -0,0 +1,115 @@ +"""Mission management endpoints — list, upload, delete mission files.""" +from __future__ import annotations + +import logging +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status +from sqlalchemy.engine import Connection + +from adapters.exceptions import AdapterError +from adapters.registry import GameAdapterRegistry +from core.servers.service import ServerService +from database import get_db +from dependencies import get_current_user, require_admin + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/servers/{server_id}/missions", tags=["missions"]) + +_MAX_UPLOAD_SIZE = 500 * 1024 * 1024 # 500 MB + + +def _ok(data): + return {"success": True, "data": data, "error": None} + + +def _get_mission_manager(server_id: int, game_type: str): + """Get MissionManager for the server's game type.""" + adapter = GameAdapterRegistry.get(game_type) + if not adapter.has_capability("mission_manager"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "NOT_SUPPORTED", "message": f"Game type '{game_type}' does not support mission management"}, + ) + return adapter.get_mission_manager(server_id) + + +@router.get("") +def list_missions( + server_id: int, + db: Annotated[Connection, Depends(get_db)], + _user: Annotated[dict, Depends(get_current_user)], +) -> dict: + """List all available mission files on disk.""" + server = ServerService(db).get_server(server_id) # raises 404 if not found + mgr = _get_mission_manager(server_id, server["game_type"]) + try: + missions = mgr.list_missions() + except AdapterError as exc: + raise HTTPException(status_code=500, detail={"code": "ADAPTER_ERROR", "message": str(exc)}) + + return _ok({ + "server_id": server_id, + "missions": missions, + "total": len(missions), + }) + + +@router.post("", status_code=status.HTTP_201_CREATED) +async def upload_mission( + server_id: int, + db: Annotated[Connection, Depends(get_db)], + _admin: Annotated[dict, Depends(require_admin)], + file: UploadFile = File(...), +) -> dict: + """ + Upload a mission .pbo file. + Max size: 500 MB. + """ + server = ServerService(db).get_server(server_id) # raises 404 if not found + + if not file.filename: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "NO_FILENAME", "message": "No filename provided"}, + ) + + content = await file.read() + if len(content) > _MAX_UPLOAD_SIZE: + raise HTTPException( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + detail={"code": "FILE_TOO_LARGE", "message": f"File too large. Max size is {_MAX_UPLOAD_SIZE // (1024*1024)} MB"}, + ) + + mgr = _get_mission_manager(server_id, server["game_type"]) + try: + mission = mgr.upload_mission(file.filename, content) + except AdapterError as exc: + raise HTTPException(status_code=400, detail={"code": "ADAPTER_ERROR", "message": str(exc)}) + + return _ok(mission) + + +@router.delete("/{filename}") +def delete_mission( + server_id: int, + filename: str, + db: Annotated[Connection, Depends(get_db)], + _admin: Annotated[dict, Depends(require_admin)], +) -> dict: + """Delete a mission file by filename.""" + server = ServerService(db).get_server(server_id) # raises 404 if not found + mgr = _get_mission_manager(server_id, server["game_type"]) + try: + deleted = mgr.delete_mission(filename) + except AdapterError as exc: + raise HTTPException(status_code=400, detail={"code": "ADAPTER_ERROR", "message": str(exc)}) + + if not deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"code": "NOT_FOUND", "message": f"Mission '{filename}' not found"}, + ) + + return _ok({"message": f"Mission '{filename}' deleted"}) \ No newline at end of file diff --git a/backend/core/servers/mods_router.py b/backend/core/servers/mods_router.py new file mode 100644 index 0000000..bf36bbe --- /dev/null +++ b/backend/core/servers/mods_router.py @@ -0,0 +1,101 @@ +"""Mod management endpoints — list available mods, set enabled mods.""" +from __future__ import annotations + +import logging +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel +from sqlalchemy.engine import Connection + +from adapters.exceptions import AdapterError +from adapters.registry import GameAdapterRegistry +from core.dal.config_repository import ConfigRepository +from core.servers.service import ServerService +from database import get_db +from dependencies import get_current_user, require_admin + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/servers/{server_id}/mods", tags=["mods"]) + + +def _ok(data): + return {"success": True, "data": data, "error": None} + + +class SetEnabledModsRequest(BaseModel): + mods: list[str] + + +def _get_mod_manager(server_id: int, game_type: str): + """Get ModManager for the server's game type.""" + adapter = GameAdapterRegistry.get(game_type) + if not adapter.has_capability("mod_manager"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "NOT_SUPPORTED", "message": f"Game type '{game_type}' does not support mod management"}, + ) + return adapter.get_mod_manager(server_id) + + +@router.get("") +def list_mods( + server_id: int, + db: Annotated[Connection, Depends(get_db)], + _user: Annotated[dict, Depends(get_current_user)], +) -> dict: + """List all available mods and which are enabled.""" + server = ServerService(db).get_server(server_id) # raises 404 if not found + mgr = _get_mod_manager(server_id, server["game_type"]) + + config_repo = ConfigRepository(db) + try: + available = mgr.list_available_mods() + enabled = set(mgr.get_enabled_mods(config_repo)) + except AdapterError as exc: + raise HTTPException(status_code=500, detail={"code": "ADAPTER_ERROR", "message": str(exc)}) + + for mod in available: + mod["enabled"] = mod["name"] in enabled + + return _ok({ + "server_id": server_id, + "mods": available, + "enabled_count": len(enabled), + }) + + +@router.put("/enabled") +def set_enabled_mods( + server_id: int, + body: SetEnabledModsRequest, + db: Annotated[Connection, Depends(get_db)], + _admin: Annotated[dict, Depends(require_admin)], +) -> dict: + """ + Set the list of enabled mods. + Replaces the current enabled list entirely. + Server must be restarted for changes to take effect. + """ + server = ServerService(db).get_server(server_id) # raises 404 if not found + mgr = _get_mod_manager(server_id, server["game_type"]) + + config_repo = ConfigRepository(db) + try: + mgr.set_enabled_mods(body.mods, config_repo) + except AdapterError as exc: + raise HTTPException(status_code=400, detail={"code": "ADAPTER_ERROR", "message": str(exc)}) + except ValueError as exc: + if "CONFIG_VERSION_CONFLICT" in str(exc): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={"code": "VERSION_CONFLICT", "message": "Config was modified by another request. Please retry."}, + ) + raise + db.commit() + + return _ok({ + "message": "Enabled mods updated. Restart the server for changes to take effect.", + "enabled_mods": body.mods, + }) \ No newline at end of file diff --git a/backend/core/servers/players_router.py b/backend/core/servers/players_router.py new file mode 100644 index 0000000..21d08a8 --- /dev/null +++ b/backend/core/servers/players_router.py @@ -0,0 +1,57 @@ +"""Player endpoints — list current players for a running server.""" +from __future__ import annotations + +import logging +from typing import Annotated + +from fastapi import APIRouter, Depends +from sqlalchemy.engine import Connection + +from core.dal.player_repository import PlayerRepository +from core.servers.service import ServerService +from database import get_db +from dependencies import get_current_user + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/servers/{server_id}/players", tags=["players"]) + + +def _ok(data): + return {"success": True, "data": data, "error": None} + + +@router.get("") +def list_players( + server_id: int, + db: Annotated[Connection, Depends(get_db)], + _user: Annotated[dict, Depends(get_current_user)], +) -> dict: + """List current players (cached from RemoteAdminPollerThread).""" + ServerService(db).get_server(server_id) # raises 404 if not found + player_repo = PlayerRepository(db) + players = player_repo.get_all(server_id=server_id) + count = player_repo.count(server_id=server_id) + return _ok({ + "server_id": server_id, + "player_count": count, + "players": players, + }) + + +@router.get("/history") +def player_history( + server_id: int, + db: Annotated[Connection, Depends(get_db)], + _user: Annotated[dict, Depends(get_current_user)], + limit: int = 100, + offset: int = 0, + search: str | None = None, +) -> dict: + """Get historical player sessions.""" + ServerService(db).get_server(server_id) # raises 404 if not found + player_repo = PlayerRepository(db) + total, rows = player_repo.get_history( + server_id=server_id, limit=limit, offset=offset, search=search, + ) + return _ok({"total": total, "items": rows}) \ No newline at end of file diff --git a/backend/core/servers/process_manager.py b/backend/core/servers/process_manager.py new file mode 100644 index 0000000..1d90aa0 --- /dev/null +++ b/backend/core/servers/process_manager.py @@ -0,0 +1,243 @@ +""" +ProcessManager singleton — owns all subprocess handles. +Game-agnostic: delegates exe validation and config to adapters. +""" +from __future__ import annotations + +import logging +import subprocess +import threading +from pathlib import Path + +import psutil + +logger = logging.getLogger(__name__) + + +class ProcessManager: + _instance: "ProcessManager | None" = None + _init_lock = threading.Lock() + + def __init__(self): + self._processes: dict[int, subprocess.Popen] = {} + self._lock = threading.Lock() + self._operation_locks: dict[int, threading.Lock] = {} + self._ops_lock = threading.Lock() + + @classmethod + def get(cls) -> "ProcessManager": + if cls._instance is None: + with cls._init_lock: + if cls._instance is None: + cls._instance = ProcessManager() + return cls._instance + + def get_operation_lock(self, server_id: int) -> threading.Lock: + """Per-server lock that serializes start/stop/restart for the same server.""" + with self._ops_lock: + if server_id not in self._operation_locks: + self._operation_locks[server_id] = threading.Lock() + return self._operation_locks[server_id] + + def start( + self, + server_id: int, + exe_path: str, + args: list[str], + cwd: str | Path, + ) -> int: + """ + Start a game server process. + Returns the PID. + cwd is set to servers/{server_id}/ so relative config paths work. + """ + with self._lock: + if server_id in self._processes: + proc = self._processes[server_id] + if proc.poll() is None: + raise RuntimeError(f"Server {server_id} is already running (PID {proc.pid})") + del self._processes[server_id] + + full_cmd = [exe_path] + args + logger.info("Starting server %d: %s", server_id, ' '.join(full_cmd)) + + proc = subprocess.Popen( + full_cmd, + cwd=str(cwd), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + # On Windows, don't create a new console window + creationflags=subprocess.CREATE_NO_WINDOW if hasattr(subprocess, "CREATE_NO_WINDOW") else 0, + ) + + with self._lock: + self._processes[server_id] = proc + + logger.info("Server %d started with PID %d", server_id, proc.pid) + return proc.pid + + def stop(self, server_id: int, timeout: int = 30) -> bool: + """ + Send terminate signal and wait up to timeout seconds. + On Windows, terminate() = hard kill (no SIGTERM). + Returns True if process exited, False if still running. + """ + with self._lock: + proc = self._processes.get(server_id) + if proc is None: + return True + + try: + proc.terminate() + except ProcessLookupError: + return True + + try: + proc.wait(timeout=timeout) + with self._lock: + self._processes.pop(server_id, None) + return True + except subprocess.TimeoutExpired: + return False + + def kill(self, server_id: int) -> bool: + """Force-kill the process immediately.""" + with self._lock: + proc = self._processes.get(server_id) + if proc is None: + return True + try: + proc.kill() + proc.wait(timeout=5) + except (ProcessLookupError, subprocess.TimeoutExpired): + logger.debug("Process %d already exited or timed out during kill", server_id) + with self._lock: + self._processes.pop(server_id, None) + return True + + def is_running(self, server_id: int) -> bool: + with self._lock: + proc = self._processes.get(server_id) + if proc is None: + return False + return proc.poll() is None + + def get_pid(self, server_id: int) -> int | None: + with self._lock: + proc = self._processes.get(server_id) + if proc is None or proc.poll() is not None: + return None + return proc.pid + + def get_process(self, server_id: int) -> subprocess.Popen | None: + with self._lock: + return self._processes.get(server_id) + + def list_running(self) -> list[int]: + with self._lock: + return [sid for sid, p in self._processes.items() if p.poll() is None] + + def recover_on_startup(self, db) -> None: + """ + On app restart: check DB for servers marked 'running'. + If the PID is still alive AND the process name matches the adapter's + allowed executables, re-attach monitoring threads. + Otherwise mark server as 'crashed'. + """ + from adapters.registry import GameAdapterRegistry + from core.dal.server_repository import ServerRepository + from core.dal.event_repository import EventRepository + from sqlalchemy import text + + running_servers = ServerRepository(db).get_running() + for server in running_servers: + pid = server.get("pid") + if pid is None: + self._mark_crashed(server, db, "No PID recorded") + continue + + # Check if PID is alive + if not psutil.pid_exists(pid): + self._mark_crashed(server, db, f"PID {pid} no longer exists") + continue + + # Check process name matches adapter allowlist + try: + proc = psutil.Process(pid) + proc_name = proc.name() + adapter = GameAdapterRegistry.get(server["game_type"]) + allowed = adapter.get_process_config().get_allowed_executables() + if not any(proc_name.lower() == exe.lower() for exe in allowed): + self._mark_crashed( + server, db, + f"PID {pid} has name '{proc_name}', not in allowlist {allowed}" + ) + continue + except (psutil.NoSuchProcess, psutil.AccessDenied, KeyError) as e: + self._mark_crashed(server, db, str(e)) + continue + + # PID is valid — re-attach the process and start monitoring threads + logger.info( + "Recovering server %d (PID %d, %s)", server['id'], pid, server['game_type'] + ) + proc_obj = self._get_popen_for_pid(pid) + if proc_obj: + with self._lock: + self._processes[server["id"]] = proc_obj + + # Re-start monitoring threads without re-launching the process + try: + from core.threads.thread_registry import ThreadRegistry + ThreadRegistry.reattach_server_threads(server["id"], db) + except Exception as e: + logger.warning("Could not re-attach threads for server %d: %s", server['id'], e) + else: + self._mark_crashed(server, db, f"Could not attach to PID {pid}") + + def _mark_crashed(self, server: dict, db, reason: str) -> None: + from core.dal.server_repository import ServerRepository + from core.dal.event_repository import EventRepository + logger.warning("Server %d marked crashed on startup: %s", server['id'], reason) + ServerRepository(db).update_status(server["id"], "crashed") + EventRepository(db).insert( + server["id"], "crashed", actor="system", + detail={"reason": reason, "on_startup": True} + ) + + @staticmethod + def _get_popen_for_pid(pid: int) -> subprocess.Popen | None: + """ + Create a Popen-like wrapper that attaches to an existing PID. + NOTE: This is a limited wrapper — we cannot use Popen() on existing PIDs. + We use a sentinel object that wraps psutil.Process. + """ + try: + return _PsutilProcessWrapper(pid) + except (psutil.NoSuchProcess, psutil.AccessDenied): + return None + + +class _PsutilProcessWrapper: + """ + Minimal Popen-compatible wrapper around an existing process (by PID). + Used for startup recovery only. + """ + def __init__(self, pid: int): + self._psutil_proc = psutil.Process(pid) + self.pid = pid + + def poll(self) -> int | None: + """Return None if running, exit code if not (we use -1 for external termination).""" + if self._psutil_proc.is_running(): + return None + return -1 + + def wait(self, timeout: int | None = None): + self._psutil_proc.wait(timeout=timeout) + + def terminate(self): + self._psutil_proc.terminate() + + def kill(self): + self._psutil_proc.kill() \ No newline at end of file diff --git a/backend/core/servers/router.py b/backend/core/servers/router.py new file mode 100644 index 0000000..3a4977b --- /dev/null +++ b/backend/core/servers/router.py @@ -0,0 +1,233 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.responses import Response +from pydantic import BaseModel +from sqlalchemy.engine import Connection + +from core.servers.schemas import ( + CreateServerRequest, StopServerRequest, UpdateServerRequest, +) +from core.servers.service import ServerService +from database import get_db +from dependencies import get_current_user, require_admin + +router = APIRouter(prefix="/servers", tags=["servers"]) + + +def _ok(data): + return {"success": True, "data": data, "error": None} + + +class SendCommandRequest(BaseModel): + command: str + + +# ── Server CRUD ────────────────────────────────────────────────────────────── + +@router.get("") +def list_servers( + game_type: str | None = None, + db: Annotated[Connection, Depends(get_db)] = None, + _user: Annotated[dict, Depends(get_current_user)] = None, +): + return _ok(ServerService(db).list_servers(game_type)) + + +@router.post("", status_code=201) +def create_server( + body: CreateServerRequest, + db: Annotated[Connection, Depends(get_db)] = None, + _admin: Annotated[dict, Depends(require_admin)] = None, +): + return _ok(ServerService(db).create_server( + name=body.name, + game_type=body.game_type, + exe_path=body.exe_path, + game_port=body.game_port, + rcon_port=body.rcon_port, + description=body.description, + auto_restart=body.auto_restart, + max_restarts=body.max_restarts, + )) + + +@router.get("/{server_id}") +def get_server( + server_id: int, + db: Annotated[Connection, Depends(get_db)] = None, + _user: Annotated[dict, Depends(get_current_user)] = None, +): + return _ok(ServerService(db).get_server(server_id)) + + +@router.put("/{server_id}") +def update_server( + server_id: int, + body: UpdateServerRequest, + db: Annotated[Connection, Depends(get_db)] = None, + _admin: Annotated[dict, Depends(require_admin)] = None, +): + return _ok(ServerService(db).update_server(server_id, **body.model_dump(exclude_none=True))) + + +@router.delete("/{server_id}", status_code=204) +def delete_server( + server_id: int, + db: Annotated[Connection, Depends(get_db)] = None, + _admin: Annotated[dict, Depends(require_admin)] = None, +): + ServerService(db).delete_server(server_id) + return Response(status_code=204) + + +# ── Lifecycle ──────────────────────────────────────────────────────────────── + +@router.post("/{server_id}/start") +def start_server( + server_id: int, + db: Annotated[Connection, Depends(get_db)] = None, + _admin: Annotated[dict, Depends(require_admin)] = None, +): + return _ok(ServerService(db).start(server_id)) + + +@router.post("/{server_id}/stop") +def stop_server( + server_id: int, + body: StopServerRequest = None, + db: Annotated[Connection, Depends(get_db)] = None, + _admin: Annotated[dict, Depends(require_admin)] = None, +): + force = body.force if body else False + return _ok(ServerService(db).stop(server_id, force=force)) + + +@router.post("/{server_id}/restart") +def restart_server( + server_id: int, + db: Annotated[Connection, Depends(get_db)] = None, + _admin: Annotated[dict, Depends(require_admin)] = None, +): + return _ok(ServerService(db).restart(server_id)) + + +@router.post("/{server_id}/kill") +def kill_server( + server_id: int, + db: Annotated[Connection, Depends(get_db)] = None, + _admin: Annotated[dict, Depends(require_admin)] = None, +): + return _ok(ServerService(db).kill(server_id)) + + +# ── Config ─────────────────────────────────────────────────────────────────── + +@router.get("/{server_id}/config") +def get_config( + server_id: int, + db: Annotated[Connection, Depends(get_db)] = None, + _user: Annotated[dict, Depends(get_current_user)] = None, +): + return _ok(ServerService(db).get_config(server_id)) + + +@router.get("/{server_id}/config/preview") +def get_config_preview( + server_id: int, + db: Annotated[Connection, Depends(get_db)] = None, + _admin: Annotated[dict, Depends(require_admin)] = None, +): + return _ok(ServerService(db).get_config_preview(server_id)) + + +@router.get("/{server_id}/config/{section}") +def get_config_section( + server_id: int, + section: str, + db: Annotated[Connection, Depends(get_db)] = None, + _user: Annotated[dict, Depends(get_current_user)] = None, +): + return _ok(ServerService(db).get_config_section(server_id, section)) + + +@router.put("/{server_id}/config/{section}") +def update_config_section( + server_id: int, + section: str, + body: dict, # Dynamic — adapter-specific fields + db: Annotated[Connection, Depends(get_db)] = None, + _admin: Annotated[dict, Depends(require_admin)] = None, +): + expected_version = body.pop("config_version", None) + return _ok(ServerService(db).update_config_section( + server_id, section, body, expected_version + )) + + +# ── RCon ────────────────────────────────────────────────────────────────────── + +@router.post("/{server_id}/rcon/command") +def send_rcon_command( + server_id: int, + body: SendCommandRequest, + db: Annotated[Connection, Depends(get_db)] = None, + _admin: Annotated[dict, Depends(require_admin)] = None, +): + """Send an RCon command to a running server.""" + from adapters.registry import GameAdapterRegistry + from adapters.exceptions import RemoteAdminError + from core.dal.config_repository import ConfigRepository + from core.dal.server_repository import ServerRepository + + server = ServerRepository(db).get_by_id(server_id) + if server is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"code": "NOT_FOUND", "message": f"Server {server_id} not found"}, + ) + + adapter = GameAdapterRegistry.get(server["game_type"]) + if not adapter.has_capability("remote_admin"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "NOT_SUPPORTED", "message": f"Game type {server['game_type']} does not support RCon"}, + ) + + # Get RCon password from config + remote_admin_factory = adapter.get_remote_admin() + config_gen = adapter.get_config_generator() + sensitive = config_gen.get_sensitive_fields("rcon") if "rcon" in config_gen.get_sections() else [] + config_repo = ConfigRepository(db) + rcon_section = config_repo.get_section(server_id, "rcon", sensitive) + if not rcon_section or not rcon_section.get("password"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "NO_RCON_PASSWORD", "message": "RCon password not configured for this server"}, + ) + password = rcon_section["password"] + + rcon_port = server.get("rcon_port") or (server["game_port"] + 3) + client = remote_admin_factory.create_client( + host="127.0.0.1", + port=rcon_port, + password=password, + ) + try: + client.connect() + result = client.send_command(body.command) + client.disconnect() + except RemoteAdminError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail={"code": "RCON_ERROR", "message": f"RCon command failed: {exc}"}, + ) + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail={"code": "RCON_ERROR", "message": f"RCon connection failed: {exc}"}, + ) + + return _ok({"response": result}) \ No newline at end of file diff --git a/backend/core/servers/schemas.py b/backend/core/servers/schemas.py new file mode 100644 index 0000000..68f5e09 --- /dev/null +++ b/backend/core/servers/schemas.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class CreateServerRequest(BaseModel): + name: str + description: str | None = None + game_type: str = "arma3" + exe_path: str + game_port: int = Field(ge=1024, le=65535) + rcon_port: int | None = Field(default=None, ge=1024, le=65535) + auto_restart: bool = False + max_restarts: int = Field(default=3, ge=0, le=20) + + +class UpdateServerRequest(BaseModel): + name: str | None = None + description: str | None = None + exe_path: str | None = None + game_port: int | None = Field(default=None, ge=1024, le=65535) + rcon_port: int | None = Field(default=None, ge=1024, le=65535) + auto_restart: bool | None = None + max_restarts: int | None = None + + +class StopServerRequest(BaseModel): + force: bool = False + reason: str | None = None + + +class UpdateConfigSectionRequest(BaseModel): + config_version: int | None = None # Required for optimistic locking on PUT + # All other fields come from the adapter's JSON Schema — passed through as-is + model_config = {"extra": "allow"} \ No newline at end of file diff --git a/backend/core/servers/service.py b/backend/core/servers/service.py new file mode 100644 index 0000000..632e364 --- /dev/null +++ b/backend/core/servers/service.py @@ -0,0 +1,503 @@ +""" +ServerService — orchestrates all server lifecycle operations. +Delegates game-specific work to the adapter. +""" +from __future__ import annotations + +import logging +import shutil +from pathlib import Path + +from fastapi import HTTPException, status +from sqlalchemy.engine import Connection + +from adapters.registry import GameAdapterRegistry +from core.dal.config_repository import ConfigRepository +from core.dal.event_repository import EventRepository +from core.dal.server_repository import ServerRepository +from core.servers.process_manager import ProcessManager +from core.utils.file_utils import ensure_server_dirs, get_server_dir + +logger = logging.getLogger(__name__) + + +def _ok_response(data): + return {"success": True, "data": data, "error": None} + + +class ServerService: + + def __init__(self, db: Connection): + self._db = db + self._server_repo = ServerRepository(db) + self._config_repo = ConfigRepository(db) + self._event_repo = EventRepository(db) + + # ── CRUD ────────────────────────────────────────────────────────────────── + + def list_servers(self, game_type: str | None = None) -> list[dict]: + """Return server list with live metrics merged in.""" + servers = self._server_repo.get_all(game_type) + return [self._enrich_server(s) for s in servers] + + def get_server(self, server_id: int) -> dict: + server = self._server_repo.get_by_id(server_id) + if server is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"code": "NOT_FOUND", "message": f"Server {server_id} not found"}, + ) + return self._enrich_server(server) + + def _enrich_server(self, server: dict) -> dict: + """Add live CPU/RAM/player count from DB.""" + from core.dal.metrics_repository import MetricsRepository + from core.dal.player_repository import PlayerRepository + result = dict(server) + metrics = MetricsRepository(self._db).get_latest(server["id"]) + if metrics: + result["cpu_percent"] = metrics["cpu_percent"] + result["ram_mb"] = metrics["ram_mb"] + else: + result["cpu_percent"] = None + result["ram_mb"] = None + result["player_count"] = PlayerRepository(self._db).count(server["id"]) + return result + + def create_server( + self, + name: str, + game_type: str, + exe_path: str, + game_port: int, + rcon_port: int | None = None, + description: str | None = None, + auto_restart: bool = False, + max_restarts: int = 3, + ) -> dict: + # Validate adapter exists + try: + adapter = GameAdapterRegistry.get(game_type) + except KeyError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "GAME_TYPE_NOT_FOUND", "message": f"Unknown game type: {game_type}"}, + ) + + # Validate exe + process_config = adapter.get_process_config() + exe_name = Path(exe_path).name + if exe_name not in process_config.get_allowed_executables(): + from adapters.exceptions import ExeNotAllowedError + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "EXE_NOT_ALLOWED", + "message": f"Executable '{exe_name}' not allowed", + "allowed": process_config.get_allowed_executables(), + }, + ) + + # Determine rcon_port if not provided + if rcon_port is None: + rcon_port = process_config.get_default_rcon_port(game_port) + + # Check port conflicts against running servers + from core.utils.port_checker import check_ports_against_running_servers + conflicts = check_ports_against_running_servers(game_port, rcon_port, None, self._db) + if conflicts: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "code": "PORT_IN_USE", + "message": f"Ports already in use: {conflicts}", + }, + ) + + # Create DB row + server_id = self._server_repo.create( + name=name, + game_type=game_type, + exe_path=exe_path, + game_port=game_port, + rcon_port=rcon_port, + description=description, + auto_restart=auto_restart, + max_restarts=max_restarts, + ) + + # Create directory layout + layout = process_config.get_server_dir_layout() + ensure_server_dirs(server_id, layout) + + # Seed default config sections + config_gen = adapter.get_config_generator() + schema_version = config_gen.get_config_version() + for section in config_gen.get_sections(): + defaults = config_gen.get_defaults(section) + sensitive = config_gen.get_sensitive_fields(section) + self._config_repo.upsert_section( + server_id=server_id, + game_type=game_type, + section=section, + config_data=defaults, + schema_version=schema_version, + sensitive_fields=sensitive, + ) + + self._event_repo.insert(server_id, "created", actor="admin") + return self.get_server(server_id) + + def update_server(self, server_id: int, **updates) -> dict: + self.get_server(server_id) # raises 404 if not found + filtered = {k: v for k, v in updates.items() if v is not None} + if filtered: + self._server_repo.update(server_id, **filtered) + return self.get_server(server_id) + + def delete_server(self, server_id: int) -> None: + server = self.get_server(server_id) + if server["status"] not in ("stopped", "crashed", "error"): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "code": "SERVER_NOT_STOPPED", + "message": "Server must be stopped before deletion", + }, + ) + self._server_repo.delete(server_id) + # Delete server directory + server_dir = get_server_dir(server_id) + if server_dir.exists(): + shutil.rmtree(str(server_dir), ignore_errors=True) + + # ── Lifecycle ───────────────────────────────────────────────────────────── + + def start(self, server_id: int) -> dict: + """ + Full start sequence: + 1. Load server + adapter + 2. Validate exe + 3. Check ports + 4. Write config files (atomic) + 5. Build launch args + 6. Start process + 7. Start monitoring threads + 8. Return status + """ + from adapters.exceptions import ( + ConfigWriteError, ExeNotAllowedError, + LaunchArgsError, ConfigValidationError, + ) + from core.utils.port_checker import check_ports_against_running_servers + + server = self.get_server(server_id) + if server["status"] in ("running", "starting"): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={"code": "SERVER_ALREADY_RUNNING", "message": "Server is already running"}, + ) + + adapter = GameAdapterRegistry.get(server["game_type"]) + process_config = adapter.get_process_config() + config_gen = adapter.get_config_generator() + + # Validate exe + exe_name = Path(server["exe_path"]).name + if exe_name not in process_config.get_allowed_executables(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "code": "EXE_NOT_ALLOWED", + "message": f"Executable '{exe_name}' not in adapter allowlist", + "allowed": process_config.get_allowed_executables(), + }, + ) + + # Check ports + conflicts = check_ports_against_running_servers( + server["game_port"], server.get("rcon_port"), server_id, self._db + ) + if conflicts: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={"code": "PORT_IN_USE", "message": f"Ports in use: {conflicts}"}, + ) + + # Load config sections (decrypt sensitive fields for config generation) + sensitive_by_section = { + s: config_gen.get_sensitive_fields(s) + for s in config_gen.get_sections() + } + sections = self._config_repo.get_all_sections(server_id, sensitive_by_section) + # Remove _meta from each section before passing to adapter + raw_sections = { + section: {k: v for k, v in data.items() if k != "_meta"} + for section, data in sections.items() + } + # Inject port into sections so build_launch_args can use it + if "_port" not in raw_sections: + raw_sections["_port"] = server["game_port"] + + # Get mod args if adapter supports mods + mod_args: list[str] = [] + if adapter.has_capability("mod_manager"): + from sqlalchemy import text + mods = self._db.execute( + text(""" + SELECT m.folder_path, sm.is_server_mod, sm.sort_order + FROM server_mods sm JOIN mods m ON m.id = sm.mod_id + WHERE sm.server_id = :sid ORDER BY sm.sort_order + """), + {"sid": server_id}, + ).fetchall() + mod_list = [dict(r._mapping) for r in mods] + mod_args = adapter.get_mod_manager().build_mod_args(mod_list) + + # Write config files (atomic) + server_dir = get_server_dir(server_id) + try: + config_gen.write_configs(server_id, server_dir, raw_sections) + except ConfigWriteError as e: + self._server_repo.update_status(server_id, "error") + self._event_repo.insert(server_id, "config_write_error", detail={"error": str(e)}) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"code": "CONFIG_WRITE_ERROR", "message": str(e)}, + ) + except ConfigValidationError as e: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={"code": "INVALID_CONFIG", "message": str(e), "errors": e.errors}, + ) + + # Build launch args + try: + launch_args = config_gen.build_launch_args(raw_sections, mod_args) + except LaunchArgsError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "INVALID_CONFIG", "message": str(e)}, + ) + + # Start process + pm = ProcessManager.get() + with pm.get_operation_lock(server_id): + pid = pm.start(server_id, server["exe_path"], launch_args, cwd=str(server_dir)) + + # Update DB + from datetime import datetime, timezone + self._server_repo.update_status( + server_id, "starting", pid=pid, + started_at=datetime.now(timezone.utc).isoformat() + ) + self._event_repo.insert(server_id, "started", detail={"pid": pid}) + + # Start monitoring threads + try: + from core.threads.thread_registry import ThreadRegistry + ThreadRegistry.start_server_threads(server_id, self._db) + except Exception as e: + logger.warning("Could not start monitoring threads: %s", e) + + return {"status": "starting", "pid": pid} + + def stop(self, server_id: int, force: bool = False) -> dict: + server = self.get_server(server_id) + if server["status"] in ("stopped", "crashed"): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={"code": "SERVER_NOT_RUNNING", "message": "Server is not running"}, + ) + + # Mark as "stopping" so ProcessMonitorThread doesn't treat this as a crash + self._server_repo.update_status(server_id, "stopping") + + # Stop monitoring threads first so they don't fight with shutdown + try: + from core.threads.thread_registry import ThreadRegistry + ThreadRegistry.stop_server_threads(server_id) + except Exception as exc: + logger.warning("Failed to stop monitoring threads for server %d during stop: %s", server_id, exc) + + # Try graceful shutdown via remote admin + if not force: + try: + pm = ProcessManager.get() + logger.info("Sending graceful shutdown to server %d", server_id) + except Exception as e: + logger.warning("Graceful shutdown failed: %s, falling back to terminate", e) + + pm = ProcessManager.get() + with pm.get_operation_lock(server_id): + exited = pm.stop(server_id, timeout=30) + if not exited: + logger.warning("Server %d did not exit in 30s, force-killing", server_id) + pm.kill(server_id) + + from datetime import datetime, timezone + self._server_repo.update_status( + server_id, "stopped", + pid=None, stopped_at=datetime.now(timezone.utc).isoformat() + ) + + from core.dal.player_repository import PlayerRepository + PlayerRepository(self._db).clear(server_id) + self._event_repo.insert(server_id, "stopped") + + return {"status": "stopped"} + + def restart(self, server_id: int) -> dict: + self.stop(server_id) + return self.start(server_id) + + def kill(self, server_id: int) -> dict: + server = self.get_server(server_id) + + # Mark as "stopping" so ProcessMonitorThread doesn't treat this as a crash + self._server_repo.update_status(server_id, "stopping") + + # Stop monitoring threads first + try: + from core.threads.thread_registry import ThreadRegistry + ThreadRegistry.stop_server_threads(server_id) + except Exception as exc: + logger.warning("Failed to stop monitoring threads for server %d during kill: %s", server_id, exc) + + pm = ProcessManager.get() + with pm.get_operation_lock(server_id): + pm.kill(server_id) + + from datetime import datetime, timezone + self._server_repo.update_status(server_id, "stopped", pid=None, + stopped_at=datetime.now(timezone.utc).isoformat()) + from core.dal.player_repository import PlayerRepository + PlayerRepository(self._db).clear(server_id) + self._event_repo.insert(server_id, "killed") + return {"status": "stopped"} + + # ── Config ──────────────────────────────────────────────────────────────── + + def get_config(self, server_id: int) -> dict: + self.get_server(server_id) + adapter = GameAdapterRegistry.get( + self._server_repo.get_by_id(server_id)["game_type"] + ) + config_gen = adapter.get_config_generator() + sensitive_by_section = { + s: config_gen.get_sensitive_fields(s) for s in config_gen.get_sections() + } + sections = self._config_repo.get_all_sections(server_id, sensitive_by_section) + # Mask sensitive fields in response (replace actual value with "***") + for section, data in sections.items(): + sf = config_gen.get_sensitive_fields(section) + for field in sf: + if field in data and data[field]: + data[field] = "***" + return sections + + def get_config_section(self, server_id: int, section: str) -> dict: + server = self.get_server(server_id) + adapter = GameAdapterRegistry.get(server["game_type"]) + config_gen = adapter.get_config_generator() + if section not in config_gen.get_sections(): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"code": "NOT_FOUND", "message": f"Config section '{section}' not found"}, + ) + sensitive = config_gen.get_sensitive_fields(section) + data = self._config_repo.get_section(server_id, section, sensitive) + if data is None: + data = config_gen.get_defaults(section) + data["_meta"] = {"config_version": 0, "schema_version": config_gen.get_config_version()} + # Mask sensitive fields + for field in sensitive: + if field in data and data[field]: + data[field] = "***" + return data + + def update_config_section( + self, + server_id: int, + section: str, + data: dict, + expected_version: int | None = None, + ) -> dict: + server = self.get_server(server_id) + adapter = GameAdapterRegistry.get(server["game_type"]) + config_gen = adapter.get_config_generator() + + sections = config_gen.get_sections() + if section not in sections: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"code": "NOT_FOUND", "message": f"Config section '{section}' not found"}, + ) + + # Validate against adapter's Pydantic model + model_cls = sections[section] + # Get current values, merge with update (partial update support) + current = self._config_repo.get_section( + server_id, section, config_gen.get_sensitive_fields(section) + ) + if current: + merged = {k: v for k, v in current.items() if k != "_meta"} + else: + merged = config_gen.get_defaults(section) + # Apply updates + for k, v in data.items(): + if k not in ("_meta", "config_version"): + merged[k] = v + + # Validate + try: + model_cls(**merged) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={"code": "INVALID_CONFIG", "message": str(e)}, + ) + + sensitive = config_gen.get_sensitive_fields(section) + try: + new_version = self._config_repo.upsert_section( + server_id=server_id, + game_type=server["game_type"], + section=section, + config_data=merged, + schema_version=config_gen.get_config_version(), + sensitive_fields=sensitive, + expected_config_version=expected_version, + ) + except ValueError as e: + error_msg = str(e) + if "CONFIG_VERSION_CONFLICT" in error_msg: + current_version = int(error_msg.split(":")[1]) + current_data = self.get_config_section(server_id, section) + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "code": "CONFIG_VERSION_CONFLICT", + "message": "Config was modified by another user. Re-read and merge.", + "current_config": current_data, + "current_version": current_version, + }, + ) + raise + + self._event_repo.insert( + server_id, "config_updated", detail={"section": section, "version": new_version} + ) + return self.get_config_section(server_id, section) + + def get_config_preview(self, server_id: int) -> dict[str, str]: + server = self.get_server(server_id) + adapter = GameAdapterRegistry.get(server["game_type"]) + config_gen = adapter.get_config_generator() + sensitive_by_section = { + s: config_gen.get_sensitive_fields(s) for s in config_gen.get_sections() + } + sections = self._config_repo.get_all_sections(server_id, sensitive_by_section) + raw_sections = {k: {kk: vv for kk, vv in v.items() if kk != "_meta"} for k, v in sections.items()} + server_dir = get_server_dir(server_id) + return config_gen.preview_config(server_id, server_dir, raw_sections) \ No newline at end of file diff --git a/backend/core/system/__init__.py b/backend/core/system/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/system/router.py b/backend/core/system/router.py new file mode 100644 index 0000000..6e748ab --- /dev/null +++ b/backend/core/system/router.py @@ -0,0 +1,32 @@ +from fastapi import APIRouter, Depends +from typing import Annotated +from dependencies import get_current_user +from adapters.registry import GameAdapterRegistry + +router = APIRouter(prefix="/system", tags=["system"]) + + +@router.get("/health") +def health(): + return {"status": "ok"} + + +@router.get("/status") +def system_status(_user: Annotated[dict, Depends(get_current_user)]): + from sqlalchemy import text + from database import get_engine + with get_engine().connect() as db: + running = db.execute( + text("SELECT COUNT(*) FROM servers WHERE status IN ('running','starting')") + ).fetchone()[0] + total = db.execute(text("SELECT COUNT(*) FROM servers")).fetchone()[0] + + return { + "success": True, + "data": { + "version": "1.0.0", + "running_servers": running, + "total_servers": total, + "supported_games": [a.game_type for a in GameAdapterRegistry.all()], + }, + } \ No newline at end of file diff --git a/backend/core/threads/__init__.py b/backend/core/threads/__init__.py new file mode 100644 index 0000000..8279636 --- /dev/null +++ b/backend/core/threads/__init__.py @@ -0,0 +1,3 @@ +from core.threads.thread_registry import ThreadRegistry + +__all__ = ["ThreadRegistry"] \ No newline at end of file diff --git a/backend/core/threads/base_thread.py b/backend/core/threads/base_thread.py new file mode 100644 index 0000000..6caefc1 --- /dev/null +++ b/backend/core/threads/base_thread.py @@ -0,0 +1,123 @@ +""" +BaseServerThread — base class for all per-server background threads. + +Rules every subclass MUST follow: +- Call super().__init__(server_id, name) in __init__ +- Implement _run_loop() — called repeatedly until _stop_event is set +- Do NOT override run() directly +- Use self._db for all database operations — it is a thread-local connection +- Call self._close_db() in your finally block if you open additional connections +- Exceptions raised from _run_loop() are caught, logged, and the loop continues + unless the exception is a fatal error — set self._fatal_error = True to stop +""" +from __future__ import annotations + +import logging +import threading +from abc import ABC, abstractmethod + +from database import get_thread_db + +logger = logging.getLogger(__name__) + +_EXCEPTION_BACKOFF_BASE = 2.0 +_EXCEPTION_BACKOFF_MAX = 60.0 +_EXCEPTION_BACKOFF_MULTIPLIER = 2.0 + + +class BaseServerThread(ABC, threading.Thread): + """ + Abstract base for all per-server background threads. + + Subclasses implement _run_loop(). This base class handles: + - Stop event signaling + - Thread-local DB connection lifecycle + - Exception backoff to prevent tight crash loops + - Structured logging with server_id context + """ + + def __init__(self, server_id: int, name: str) -> None: + super().__init__(name=f"{name}-server-{server_id}", daemon=True) + self.server_id = server_id + self._stop_event = threading.Event() + self._fatal_error = False + self._db = None + self._exception_count = 0 + + # ── Public API ── + + def stop(self) -> None: + """Signal the thread to stop. Does not block.""" + self._stop_event.set() + + def stop_and_join(self, timeout: float = 5.0) -> None: + """Signal stop and wait for the thread to exit.""" + self._stop_event.set() + self.join(timeout=timeout) + + @property + def is_stopping(self) -> bool: + return self._stop_event.is_set() + + # ── Thread entry point ── + + def run(self) -> None: + logger.info("[%s] Starting", self.name) + backoff = _EXCEPTION_BACKOFF_BASE + + try: + self._db = get_thread_db() + self._on_start() + + while not self._stop_event.is_set() and not self._fatal_error: + try: + self._run_loop() + backoff = _EXCEPTION_BACKOFF_BASE + self._exception_count = 0 + except Exception as exc: + self._exception_count += 1 + logger.error( + "[%s] Unhandled exception in _run_loop (count=%d): %s", + self.name, self._exception_count, exc, exc_info=True, + ) + if self._fatal_error: + break + self._stop_event.wait(timeout=backoff) + backoff = min(backoff * _EXCEPTION_BACKOFF_MULTIPLIER, _EXCEPTION_BACKOFF_MAX) + + except Exception as exc: + logger.critical("[%s] Fatal error in thread setup: %s", self.name, exc, exc_info=True) + finally: + self._on_stop() + self._close_db() + logger.info("[%s] Stopped", self.name) + + # ── Hooks for subclasses ── + + def _on_start(self) -> None: + """Called once before the loop starts. Override for setup.""" + + def _on_stop(self) -> None: + """Called once after the loop ends. Override for cleanup.""" + + @abstractmethod + def _run_loop(self) -> None: + """ + Implement the thread's work here. + Called repeatedly until stop() is called or _fatal_error is set. + Should block for a short period (sleep or wait) to avoid busy-looping. + """ + + # ── Internal helpers ── + + def _close_db(self) -> None: + if self._db is not None: + try: + self._db.close() + except Exception as exc: + logger.debug("[%s] Error closing DB connection: %s", self.name, exc) + self._db = None + + def _sleep(self, seconds: float) -> None: + """Interruptible sleep — wakes up early if stop() is called.""" + self._stop_event.wait(timeout=seconds) \ No newline at end of file diff --git a/backend/core/threads/log_tail.py b/backend/core/threads/log_tail.py new file mode 100644 index 0000000..4d9071e --- /dev/null +++ b/backend/core/threads/log_tail.py @@ -0,0 +1,167 @@ +""" +LogTailThread — tails a server's log file, parses lines via LogParser, +and persists parsed entries to the logs table. + +Design notes: +- Opens the log file in text mode with errors="replace" to handle encoding issues +- Detects log rotation by checking if the inode changes (Unix) or file shrinks (Windows) +- On rotation: closes old handle, reopens from position 0 +- Flushes inserts in batches of up to LOG_BATCH_SIZE per loop iteration +""" +from __future__ import annotations + +import logging +import os +import queue +from pathlib import Path +from typing import Callable, Optional + +from core.dal.log_repository import LogRepository +from core.threads.base_thread import BaseServerThread + +logger = logging.getLogger(__name__) + +_LOG_BATCH_SIZE = 50 +_POLL_INTERVAL = 1.0 +_REOPEN_DELAY = 2.0 + + +class LogTailThread(BaseServerThread): + """ + Tails a log file for a specific server. + + Args: + server_id: The database server ID. + log_path: Absolute path to the log file to tail. + log_parser: LogParser adapter instance for this game type. + broadcast_queue: Optional queue.Queue to push parsed events to BroadcastThread. + """ + + def __init__( + self, + server_id: int, + log_path: str, + log_parser, + broadcast_queue=None, + ) -> None: + super().__init__(server_id, "LogTail") + self._log_path = log_path + self._log_parser = log_parser + self._broadcast_queue = broadcast_queue + self._file_handle = None + self._last_inode = None + self._last_size = 0 + + # ── Lifecycle ── + + def _on_start(self) -> None: + self._open_log_file() + + def _on_stop(self) -> None: + self._close_file() + + # ── Main loop ── + + def _run_loop(self) -> None: + if self._file_handle is None: + self._stop_event.wait(timeout=_POLL_INTERVAL) + self._open_log_file() + return + + if self._detect_rotation(): + logger.info("[%s] Log rotation detected, reopening", self.name) + self._close_file() + self._stop_event.wait(timeout=_REOPEN_DELAY) + self._open_log_file() + return + + lines_read = 0 + entries_to_insert = [] + + while lines_read < _LOG_BATCH_SIZE: + line = self._file_handle.readline() + if not line: + break + lines_read += 1 + line = line.rstrip("\n").rstrip("\r") + if not line: + continue + + parsed = self._log_parser.parse_line(line) + if parsed is not None: + entries_to_insert.append(parsed) + + if entries_to_insert and self._db is not None: + log_repo = LogRepository(self._db) + for entry in entries_to_insert: + log_repo.insert(server_id=self.server_id, entry=entry) + try: + self._db.commit() + except Exception as exc: + logger.error("[%s] DB commit failed: %s", self.name, exc) + self._db.rollback() + + if self._broadcast_queue is not None: + for entry in entries_to_insert: + try: + self._broadcast_queue.put_nowait({ + "type": "log", + "server_id": self.server_id, + "data": entry, + }) + except queue.Full: + logger.debug("[%s] Broadcast queue full, dropping log event", self.name) + + if lines_read == 0: + self._stop_event.wait(timeout=_POLL_INTERVAL) + + # ── File management ── + + def _open_log_file(self) -> None: + if not os.path.exists(self._log_path): + return + try: + self._file_handle = open( + self._log_path, "r", encoding="utf-8", errors="replace" + ) + # Start tailing from the end of the file + self._file_handle.seek(0, 2) + self._last_size = self._file_handle.tell() + stat = os.stat(self._log_path) + self._last_inode = getattr(stat, "st_ino", None) + logger.debug("[%s] Opened log file: %s", self.name, self._log_path) + except OSError as exc: + logger.warning("[%s] Cannot open log file %s: %s", self.name, self._log_path, exc) + self._file_handle = None + + def _close_file(self) -> None: + if self._file_handle is not None: + try: + self._file_handle.close() + except OSError as exc: + logger.debug("[%s] Error closing log file: %s", self.name, exc) + self._file_handle = None + self._last_inode = None + self._last_size = 0 + + def _detect_rotation(self) -> bool: + """Returns True if the log file has been rotated.""" + try: + stat = os.stat(self._log_path) + except OSError: + return True + + current_inode = getattr(stat, "st_ino", None) + if current_inode is not None and self._last_inode is not None: + if current_inode != self._last_inode: + return True + + # Windows fallback: file shrunk + current_size = stat.st_size + if self._file_handle is not None: + current_pos = self._file_handle.tell() + if current_size < current_pos: + return True + self._last_size = current_size + + return False \ No newline at end of file diff --git a/backend/core/threads/metrics_collector.py b/backend/core/threads/metrics_collector.py new file mode 100644 index 0000000..3005ad9 --- /dev/null +++ b/backend/core/threads/metrics_collector.py @@ -0,0 +1,118 @@ +""" +MetricsCollectorThread — collects CPU and memory usage for a server process +and persists to the metrics table every COLLECTION_INTERVAL seconds. + +Uses psutil to inspect the process identified by ProcessManager.get_pid(). +If the process is not running, the thread sleeps and retries. +""" +from __future__ import annotations + +import logging +import queue + +import psutil + +from core.dal.metrics_repository import MetricsRepository +from core.threads.base_thread import BaseServerThread + +logger = logging.getLogger(__name__) + +_COLLECTION_INTERVAL = 10.0 +_RETENTION_DAYS = 1 + + +class MetricsCollectorThread(BaseServerThread): + """ + Collects process metrics for a running game server. + + Args: + server_id: Database server ID. + process_manager: ProcessManager singleton instance. + broadcast_queue: Optional queue.Queue for real-time metric pushes. + """ + + def __init__( + self, + server_id: int, + process_manager, + broadcast_queue=None, + ) -> None: + super().__init__(server_id, "MetricsCollector") + self._process_manager = process_manager + self._broadcast_queue = broadcast_queue + self._psutil_process = None + self._samples_since_cleanup = 0 + self._cleanup_every = 360 # ~1 hour at 10s intervals + + # ── Main loop ── + + def _run_loop(self) -> None: + pid = self._process_manager.get_pid(self.server_id) + if pid is None: + self._psutil_process = None + self._stop_event.wait(timeout=_COLLECTION_INTERVAL) + return + + # Reuse or create psutil.Process handle + if self._psutil_process is None or self._psutil_process.pid != pid: + try: + self._psutil_process = psutil.Process(pid) + self._psutil_process.cpu_percent(interval=None) + except psutil.NoSuchProcess: + self._psutil_process = None + self._stop_event.wait(timeout=_COLLECTION_INTERVAL) + return + + self._stop_event.wait(timeout=_COLLECTION_INTERVAL) + + if self._stop_event.is_set(): + return + + try: + cpu_pct = self._psutil_process.cpu_percent(interval=None) + mem_info = self._psutil_process.memory_info() + mem_mb = round(mem_info.rss / (1024 * 1024), 2) + except psutil.NoSuchProcess: + logger.info("[%s] Process %d no longer exists", self.name, pid) + self._psutil_process = None + return + except psutil.AccessDenied as exc: + logger.warning("[%s] Access denied reading process %d: %s", self.name, pid, exc) + return + + if self._db is None: + return + + metrics_repo = MetricsRepository(self._db) + metrics_repo.insert( + server_id=self.server_id, + cpu_percent=cpu_pct, + ram_mb=mem_mb, + ) + try: + self._db.commit() + except Exception as exc: + logger.error("[%s] DB commit failed: %s", self.name, exc) + self._db.rollback() + return + + if self._broadcast_queue is not None: + try: + self._broadcast_queue.put_nowait({ + "type": "metrics", + "server_id": self.server_id, + "data": {"cpu_percent": cpu_pct, "memory_mb": mem_mb}, + }) + except queue.Full: + logger.debug("[%s] Broadcast queue full, dropping metrics event", self.name) + + # Periodic cleanup + self._samples_since_cleanup += 1 + if self._samples_since_cleanup >= self._cleanup_every: + self._samples_since_cleanup = 0 + try: + metrics_repo.cleanup_old(server_id=self.server_id, retention_days=_RETENTION_DAYS) + self._db.commit() + except Exception as exc: + logger.warning("[%s] Cleanup failed: %s", self.name, exc) + self._db.rollback() \ No newline at end of file diff --git a/backend/core/threads/process_monitor.py b/backend/core/threads/process_monitor.py new file mode 100644 index 0000000..527626f --- /dev/null +++ b/backend/core/threads/process_monitor.py @@ -0,0 +1,158 @@ +""" +ProcessMonitorThread — watches a running game server process. + +Responsibilities: +1. Detect when the process exits unexpectedly (crash). +2. On crash: update server status to "crashed" in DB, emit a crash event. +3. If auto_restart is enabled on the server record: trigger restart. +4. Respect max_restarts — if exceeded, leave server in "crashed" state. + +Poll interval: 5 seconds. +""" +from __future__ import annotations + +import logging +import queue + +from core.dal.event_repository import EventRepository +from core.dal.server_repository import ServerRepository +from core.threads.base_thread import BaseServerThread + +logger = logging.getLogger(__name__) + +_POLL_INTERVAL = 5.0 + + +class ProcessMonitorThread(BaseServerThread): + """ + Monitors the OS process for a running game server. + + Args: + server_id: Database server ID. + process_manager: ProcessManager singleton (injected). + broadcast_queue: Optional queue.Queue for crash notifications. + """ + + def __init__( + self, + server_id: int, + process_manager, + broadcast_queue=None, + ) -> None: + super().__init__(server_id, "ProcessMonitor") + self._process_manager = process_manager + self._broadcast_queue = broadcast_queue + + # ── Main loop ── + + def _run_loop(self) -> None: + self._stop_event.wait(timeout=_POLL_INTERVAL) + + if self._stop_event.is_set(): + return + + if not self._process_manager.is_running(self.server_id): + self._handle_unexpected_exit() + # After handling, stop this monitor — the server is no longer running + self._fatal_error = True + + # ── Crash handling ── + + def _handle_unexpected_exit(self) -> None: + if self._db is None: + return + + server_repo = ServerRepository(self._db) + event_repo = EventRepository(self._db) + + server = server_repo.get_by_id(self.server_id) + if server is None: + return + + # Only treat as crash if the server was supposed to be running + if server["status"] not in ("running", "starting"): + return + + logger.warning( + "[%s] Server %d process exited unexpectedly (status was '%s')", + self.name, self.server_id, server["status"], + ) + + # Increment crash counter + server_repo.increment_restart_count(self.server_id) + restart_count = server["restart_count"] + 1 + max_restarts = server.get("max_restarts", 3) + + # Record crash event + event_repo.insert( + server_id=self.server_id, + event_type="crash", + detail={"restart_count": restart_count}, + ) + + should_restart = ( + server.get("auto_restart", False) + and restart_count <= max_restarts + ) + + if should_restart: + server_repo.update_status(self.server_id, "restarting") + event_repo.insert( + server_id=self.server_id, + event_type="restart_scheduled", + detail={"attempt": restart_count, "max": max_restarts}, + ) + else: + server_repo.update_status(self.server_id, "crashed") + if restart_count > max_restarts: + event_repo.insert( + server_id=self.server_id, + event_type="restart_limit_reached", + detail={"restart_count": restart_count, "max_restarts": max_restarts}, + ) + + try: + self._db.commit() + except Exception as exc: + logger.error("[%s] DB commit failed during crash handling: %s", self.name, exc) + self._db.rollback() + + if self._broadcast_queue is not None: + try: + self._broadcast_queue.put_nowait({ + "type": "server_status", + "server_id": self.server_id, + "data": { + "status": "restarting" if should_restart else "crashed", + "restart_count": restart_count, + }, + }) + except queue.Full: + logger.debug("[%s] Broadcast queue full, dropping server_status event", self.name) + + # Trigger actual restart outside DB work + if should_restart: + self._trigger_restart() + + def _trigger_restart(self) -> None: + """ + Calls ServerService.start() to restart the server. + This is safe to call from a background thread. + """ + try: + from database import get_thread_db + from core.servers.service import ServerService + + db = get_thread_db() + try: + service = ServerService(db) + service.start(self.server_id) + except Exception as exc: + logger.error("[%s] Auto-restart start() failed: %s", self.name, exc, exc_info=True) + finally: + try: + db.close() + except Exception as exc: + logger.debug("[%s] Error closing restart DB connection: %s", self.name, exc) + except Exception as exc: + logger.error("[%s] Auto-restart failed: %s", self.name, exc, exc_info=True) \ No newline at end of file diff --git a/backend/core/threads/remote_admin_poller.py b/backend/core/threads/remote_admin_poller.py new file mode 100644 index 0000000..91929d0 --- /dev/null +++ b/backend/core/threads/remote_admin_poller.py @@ -0,0 +1,169 @@ +""" +RemoteAdminPollerThread — polls the game server's remote admin interface +(e.g. BattlEye RCon for Arma3) to sync the player list. + +Design notes: +- Uses the RemoteAdminClient protocol injected at construction time +- Reconnects automatically on disconnect with exponential backoff +- Persists current player list to players table via PlayerRepository +- Emits player_join / player_leave events via EventRepository +- Pushes player list updates to broadcast_queue if provided + +Poll interval: 30 seconds. +Reconnect backoff: 5s -> 10s -> 20s -> 40s -> 60s (cap). +""" +from __future__ import annotations + +import logging +import queue + +from core.dal.event_repository import EventRepository +from core.dal.player_repository import PlayerRepository +from core.threads.base_thread import BaseServerThread + +logger = logging.getLogger(__name__) + +_POLL_INTERVAL = 30.0 +_RECONNECT_BACKOFF_BASE = 5.0 +_RECONNECT_BACKOFF_MAX = 60.0 +_RECONNECT_BACKOFF_MULT = 2.0 + + +class RemoteAdminPollerThread(BaseServerThread): + """ + Polls the remote admin interface for a game server. + + Args: + server_id: Database server ID. + remote_admin_client: Connected RemoteAdminClient instance. + broadcast_queue: Optional queue.Queue for player list pushes. + """ + + def __init__( + self, + server_id: int, + remote_admin_client, + broadcast_queue=None, + ) -> None: + super().__init__(server_id, "RemoteAdminPoller") + self._client = remote_admin_client + self._broadcast_queue = broadcast_queue + self._connected = False + self._reconnect_backoff = _RECONNECT_BACKOFF_BASE + self._known_players: dict[str, dict] = {} # player_uid -> player data + + # ── Lifecycle ── + + def _on_stop(self) -> None: + if self._connected and self._client is not None: + try: + self._client.disconnect() + except Exception as exc: + logger.debug("[%s] Error disconnecting remote admin on stop: %s", self.name, exc) + self._connected = False + + # ── Main loop ── + + def _run_loop(self) -> None: + if not self._connected: + self._attempt_connect() + return + + self._stop_event.wait(timeout=_POLL_INTERVAL) + + if self._stop_event.is_set(): + return + + try: + players = self._client.get_players() + self._reconnect_backoff = _RECONNECT_BACKOFF_BASE + self._sync_players(players) + except Exception as exc: + logger.warning("[%s] Poll failed: %s — will reconnect", self.name, exc) + self._connected = False + try: + if self._client is not None: + self._client.disconnect() + except Exception as exc: + logger.debug("[%s] Error disconnecting after poll failure: %s", self.name, exc) + + # ── Connection management ── + + def _attempt_connect(self) -> None: + try: + self._client.connect() if hasattr(self._client, "connect") else None + self._connected = True + self._reconnect_backoff = _RECONNECT_BACKOFF_BASE + logger.info("[%s] Connected to remote admin", self.name) + except Exception as exc: + logger.warning( + "[%s] Connection failed: %s — retrying in %.1fs", + self.name, exc, self._reconnect_backoff, + ) + self._stop_event.wait(timeout=self._reconnect_backoff) + self._reconnect_backoff = min( + self._reconnect_backoff * _RECONNECT_BACKOFF_MULT, + _RECONNECT_BACKOFF_MAX, + ) + + # ── Player sync ── + + def _sync_players(self, current_players: list[dict]) -> None: + """ + Diff current_players against self._known_players. + Insert join events for new players, leave events for departed ones. + Upsert all current players in the DB. + + Each player dict must have at least: slot_id, name (other fields optional). + """ + if self._db is None: + return + + player_repo = PlayerRepository(self._db) + event_repo = EventRepository(self._db) + + # Build uid sets for diffing — use slot_id as key + current_slots = {str(p.get("slot_id", i)): p for i, p in enumerate(current_players)} + current_keys = set(current_slots.keys()) + known_keys = set(self._known_players.keys()) + + joined = current_keys - known_keys + left = known_keys - current_keys + + for slot_key, player in current_slots.items(): + player_repo.upsert(server_id=self.server_id, player=player) + if slot_key in joined: + event_repo.insert( + server_id=self.server_id, + event_type="player_join", + detail={"name": player.get("name", ""), "slot": slot_key}, + ) + logger.debug("[%s] Player joined: %s (slot %s)", self.name, player.get("name"), slot_key) + + for slot_key in left: + departed = self._known_players[slot_key] + event_repo.insert( + server_id=self.server_id, + event_type="player_leave", + detail={"name": departed.get("name", ""), "slot": slot_key}, + ) + logger.debug("[%s] Player left: %s (slot %s)", self.name, departed.get("name"), slot_key) + + try: + self._db.commit() + except Exception as exc: + logger.error("[%s] DB commit failed during player sync: %s", self.name, exc) + self._db.rollback() + + # Update known players + self._known_players = current_slots + + if self._broadcast_queue is not None: + try: + self._broadcast_queue.put_nowait({ + "type": "players", + "server_id": self.server_id, + "data": current_players, + }) + except queue.Full: + logger.debug("[%s] Broadcast queue full, dropping players event", self.name) \ No newline at end of file diff --git a/backend/core/threads/thread_registry.py b/backend/core/threads/thread_registry.py new file mode 100644 index 0000000..104f8ae --- /dev/null +++ b/backend/core/threads/thread_registry.py @@ -0,0 +1,257 @@ +""" +ThreadRegistry — manages the lifecycle of all per-server background threads. + +One instance is created at app startup and stored in app.state.thread_registry. +Also provides class-level methods for convenience (called from ServerService). + +Thread set per server: + - LogTailThread (started if adapter has "log_parser" capability and log_path is known) + - MetricsCollectorThread (always started) + - ProcessMonitorThread (always started) + - RemoteAdminPollerThread (started only if adapter has "remote_admin" capability) + +Key methods: + start_server_threads(server_id, db) — start all threads for a server + stop_server_threads(server_id) — stop all threads for a server + reattach_server_threads(server_id, db) — re-attach threads without restarting process + stop_all() — called at app shutdown +""" +from __future__ import annotations + +import logging +import queue + +from adapters.registry import GameAdapterRegistry +from core.dal.config_repository import ConfigRepository +from core.dal.server_repository import ServerRepository +from core.threads.log_tail import LogTailThread +from core.threads.metrics_collector import MetricsCollectorThread +from core.threads.process_monitor import ProcessMonitorThread +from core.threads.remote_admin_poller import RemoteAdminPollerThread + +logger = logging.getLogger(__name__) + +# Module-level singleton for convenience (used by ServerService) +_instance: ThreadRegistry | None = None + + +class ThreadRegistry: + """ + Manages all background threads for all running servers. + """ + + def __init__( + self, + process_manager, + adapter_registry: GameAdapterRegistry | None = None, + global_broadcast_queue: queue.Queue | None = None, + ) -> None: + self._process_manager = process_manager + self._adapter_registry = adapter_registry or GameAdapterRegistry + self._broadcast_queue = global_broadcast_queue or queue.Queue(maxsize=1000) + self._bundles: dict[int, dict] = {} # server_id -> thread bundle + + # ── Class-level convenience API ── + + @classmethod + def _get_instance(cls) -> "ThreadRegistry | None": + return _instance + + @classmethod + def set_instance(cls, registry: "ThreadRegistry") -> None: + global _instance + _instance = registry + + @classmethod + def start_server_threads(cls, server_id: int, db) -> None: + """Class-level convenience — starts threads for a server using the singleton.""" + registry = cls._get_instance() + if registry is not None: + registry._start_server_threads(server_id, db) + + @classmethod + def stop_server_threads(cls, server_id: int) -> None: + """Class-level convenience — stops threads for a server using the singleton.""" + registry = cls._get_instance() + if registry is not None: + registry._stop_server_threads(server_id) + + @classmethod + def reattach_server_threads(cls, server_id: int, db) -> None: + """Class-level convenience — re-attaches threads for a recovered server.""" + registry = cls._get_instance() + if registry is not None: + registry._reattach_server_threads(server_id, db) + + @classmethod + def stop_all(cls) -> None: + """Class-level convenience — stops all threads.""" + registry = cls._get_instance() + if registry is not None: + registry._stop_all() + + # ── Instance methods ── + + def _start_server_threads(self, server_id: int, db) -> None: + if server_id in self._bundles: + logger.warning( + "ThreadRegistry: threads already exist for server %d — stopping first", + server_id, + ) + self._stop_server_threads(server_id) + + bundle = self._build_bundle(server_id, db) + self._bundles[server_id] = bundle + self._start_bundle(server_id, bundle) + + def _stop_server_threads(self, server_id: int) -> None: + bundle = self._bundles.pop(server_id, None) + if bundle is None: + return + self._stop_bundle(server_id, bundle) + + def _reattach_server_threads(self, server_id: int, db) -> None: + logger.info("ThreadRegistry: reattaching threads for server %d", server_id) + self._start_server_threads(server_id, db) + + def _stop_all(self) -> None: + server_ids = list(self._bundles.keys()) + for server_id in server_ids: + self._stop_server_threads(server_id) + logger.info("ThreadRegistry: all threads stopped") + + def get_thread_count(self, server_id: int) -> int: + """Returns the number of running threads for a server.""" + bundle = self._bundles.get(server_id) + if bundle is None: + return 0 + return sum( + 1 + for key in ("log_tail", "metrics", "monitor", "rcon_poller") + if bundle.get(key) is not None and bundle[key].is_alive() + ) + + # ── Bundle construction ── + + def _build_bundle(self, server_id: int, db) -> dict: + """Reads server + config data from DB and constructs (but does not start) the thread bundle.""" + server_repo = ServerRepository(db) + config_repo = ConfigRepository(db) + + server = server_repo.get_by_id(server_id) + if server is None: + raise ValueError(f"Server {server_id} not found in database") + + game_type = server["game_type"] + adapter = self._adapter_registry.get(game_type) + + # Log path: read from config if present, else use adapter default + log_path = None + if adapter.has_capability("log_parser"): + log_parser = adapter.get_log_parser() + # Try to resolve log path via the adapter's log file resolver + from core.utils.file_utils import get_server_dir + server_dir = get_server_dir(server_id) + if server_dir.exists(): + resolver = log_parser.get_log_file_resolver(server_id) + resolved = resolver(server_dir) + if resolved is not None: + log_path = str(resolved) + + bundle: dict = { + "log_tail": None, + "metrics": None, + "monitor": None, + "rcon_poller": None, + } + + # Always: ProcessMonitorThread + bundle["monitor"] = ProcessMonitorThread( + server_id=server_id, + process_manager=self._process_manager, + broadcast_queue=self._broadcast_queue, + ) + + # Always: MetricsCollectorThread + bundle["metrics"] = MetricsCollectorThread( + server_id=server_id, + process_manager=self._process_manager, + broadcast_queue=self._broadcast_queue, + ) + + # Conditional: LogTailThread + if log_path and adapter.has_capability("log_parser"): + log_parser = adapter.get_log_parser() + bundle["log_tail"] = LogTailThread( + server_id=server_id, + log_path=log_path, + log_parser=log_parser, + broadcast_queue=self._broadcast_queue, + ) + + # Conditional: RemoteAdminPollerThread + if adapter.has_capability("remote_admin"): + remote_admin = adapter.get_remote_admin() + if remote_admin is not None: + # Get RCon password from config + rcon_password = self._get_remote_admin_password(server_id, config_repo) + if rcon_password: + try: + rcon_port = server.get("rcon_port") or server.get("game_port", 0) + 1 + client = remote_admin.create_client( + host="127.0.0.1", + port=rcon_port, + password=rcon_password, + ) + bundle["rcon_poller"] = RemoteAdminPollerThread( + server_id=server_id, + remote_admin_client=client, + broadcast_queue=self._broadcast_queue, + ) + except Exception as exc: + logger.warning( + "ThreadRegistry: could not create RCon client for server %d: %s", + server_id, exc, + ) + + return bundle + + def _start_bundle(self, server_id: int, bundle: dict) -> None: + started = [] + for key in ("monitor", "metrics", "log_tail", "rcon_poller"): + thread = bundle.get(key) + if thread is not None: + thread.start() + started.append(key) + logger.info("ThreadRegistry: started threads for server %d: %s", server_id, started) + + def _stop_bundle(self, server_id: int, bundle: dict) -> None: + for key in ("rcon_poller", "log_tail", "metrics", "monitor"): + thread = bundle.get(key) + if thread is not None and thread.is_alive(): + thread.stop_and_join(timeout=5.0) + logger.info("ThreadRegistry: stopped all threads for server %d", server_id) + + # ── Helpers ── + + def _get_remote_admin_password( + self, server_id: int, config_repo: ConfigRepository + ) -> str | None: + """Read the RCon password from the rcon config section.""" + # Need to decrypt sensitive fields + from adapters.registry import GameAdapterRegistry + try: + server = ServerRepository(config_repo._db).get_by_id(server_id) + if server is None: + return None + adapter = self._adapter_registry.get(server["game_type"]) + config_gen = adapter.get_config_generator() + sensitive = config_gen.get_sensitive_fields("rcon") if "rcon" in config_gen.get_sections() else [] + except Exception as exc: + logger.debug("Could not determine sensitive fields for RCon config: %s", exc) + sensitive = [] + + rcon_section = config_repo.get_section(server_id, "rcon", sensitive) + if rcon_section is None: + return None + return rcon_section.get("password") or None \ No newline at end of file diff --git a/backend/core/utils/__init__.py b/backend/core/utils/__init__.py new file mode 100644 index 0000000..8e5ee6a --- /dev/null +++ b/backend/core/utils/__init__.py @@ -0,0 +1 @@ +"""Core utility modules.""" \ No newline at end of file diff --git a/backend/core/utils/crypto.py b/backend/core/utils/crypto.py new file mode 100644 index 0000000..8b0dc24 --- /dev/null +++ b/backend/core/utils/crypto.py @@ -0,0 +1,32 @@ +"""Field-level encryption using Fernet (AES-256).""" +from __future__ import annotations + +from cryptography.fernet import Fernet + +_fernet: Fernet | None = None + + +def get_fernet() -> Fernet: + global _fernet + if _fernet is None: + from config import settings + _fernet = Fernet(settings.encryption_key.encode()) + return _fernet + + +def encrypt(plaintext: str) -> str: + """Encrypt plaintext string. Returns 'encrypted:'.""" + token = get_fernet().encrypt(plaintext.encode()).decode() + return f"encrypted:{token}" + + +def decrypt(ciphertext: str) -> str: + """Decrypt 'encrypted:' string. Returns plaintext.""" + if not ciphertext.startswith("encrypted:"): + return ciphertext # Not encrypted, return as-is + token = ciphertext[len("encrypted:"):] + return get_fernet().decrypt(token.encode()).decode() + + +def is_encrypted(value: str) -> bool: + return isinstance(value, str) and value.startswith("encrypted:") \ No newline at end of file diff --git a/backend/core/utils/file_utils.py b/backend/core/utils/file_utils.py new file mode 100644 index 0000000..61ea690 --- /dev/null +++ b/backend/core/utils/file_utils.py @@ -0,0 +1,65 @@ +"""Game-agnostic file operations.""" +from __future__ import annotations + +import re +from pathlib import Path + + +def get_server_dir(server_id: int) -> Path: + """Return the absolute directory path for a server's data.""" + from config import settings + base = Path(settings.servers_dir).resolve() + return base / str(server_id) + + +def ensure_server_dirs(server_id: int, layout: list[str] | None = None) -> None: + """ + Create servers/{id}/ and any subdirectories from adapter layout. + layout example: ["server", "battleye", "mpmissions"] + """ + server_dir = get_server_dir(server_id) + server_dir.mkdir(parents=True, exist_ok=True) + if layout: + for subdir in layout: + (server_dir / subdir).mkdir(parents=True, exist_ok=True) + + +def safe_delete_file(path: Path) -> bool: + """Delete a file if it exists. Returns True if deleted.""" + try: + path.unlink(missing_ok=True) + return True + except OSError: + return False + + +def sanitize_filename(filename: str) -> str: + """ + Sanitize a filename for safe disk storage. + + Rules: + - Strip path separators (/ \\ and ..) + - Allow only alphanumeric, dots, hyphens, underscores, @ signs + - Collapse consecutive dots (prevent ../ tricks) + - Truncate to 255 characters + - Raise ValueError if the result is empty + """ + # Take only the basename — strip any directory components + filename = filename.replace("\\", "/").split("/")[-1] + + # Remove null bytes and control characters + filename = re.sub(r"[\x00-\x1f\x7f]", "", filename) + + # Allow only safe characters: alphanum, dot, hyphen, underscore, @ + filename = re.sub(r"[^\w.\-@]", "_", filename) + + # Collapse consecutive dots to prevent tricks like ".../.." + filename = re.sub(r"\.{2,}", ".", filename) + + # Truncate + filename = filename[:255] + + if not filename or filename in (".", ".."): + raise ValueError(f"Filename '{filename}' is not safe for storage") + + return filename \ No newline at end of file diff --git a/backend/core/utils/port_checker.py b/backend/core/utils/port_checker.py new file mode 100644 index 0000000..bba7bf8 --- /dev/null +++ b/backend/core/utils/port_checker.py @@ -0,0 +1,87 @@ +"""Game-agnostic port availability checking.""" +from __future__ import annotations + +import logging +import socket + +logger = logging.getLogger(__name__) + + +def is_port_in_use(port: int, host: str = "127.0.0.1") -> bool: + """Return True if the port is already bound.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.settimeout(0.5) + try: + s.bind((host, port)) + return False + except OSError: + return True + + +def check_server_ports_available( + game_port: int, + rcon_port: int | None = None, + host: str = "127.0.0.1", + port_conventions: dict[str, int] | None = None, +) -> list[int]: + """ + Check all ports for a server instance. + If port_conventions is provided (from adapter), checks all derived ports. + Returns list of ports that are already in use (empty = all available). + """ + ports_to_check: set[int] = set() + + if port_conventions: + ports_to_check.update(port_conventions.values()) + else: + ports_to_check.add(game_port) + + if rcon_port is not None: + ports_to_check.add(rcon_port) + + return [p for p in sorted(ports_to_check) if is_port_in_use(p, host)] + + +def check_ports_against_running_servers( + new_server_game_port: int, + new_server_rcon_port: int | None, + exclude_server_id: int | None, + db, +) -> list[int]: + """ + Cross-game port conflict detection. + Checks new server's full port set against all running servers' full port sets. + Returns list of conflicting ports. + """ + from adapters.registry import GameAdapterRegistry + from sqlalchemy import text + + rows = db.execute( + text("SELECT id, game_type, game_port, rcon_port FROM servers WHERE status IN ('running','starting')") + ).fetchall() + + occupied_ports: set[int] = set() + for row in rows: + if exclude_server_id and row[0] == exclude_server_id: + continue + try: + adapter = GameAdapterRegistry.get(row[1]) + conventions = adapter.get_process_config().get_port_conventions(row[2]) + occupied_ports.update(conventions.values()) + except KeyError: + logger.debug("Unknown game type '%s', falling back to game_port only", row[1]) + occupied_ports.add(row[2]) + if row[3] is not None: + occupied_ports.add(row[3]) + + # Check new server's ports against occupied set + try: + adapter = GameAdapterRegistry.get("arma3") # temporary — will be passed in + except KeyError: + logger.debug("No 'arma3' adapter for port conventions, using defaults") + + new_ports: set[int] = {new_server_game_port} + if new_server_rcon_port: + new_ports.add(new_server_rcon_port) + + return sorted(new_ports & occupied_ports) \ No newline at end of file diff --git a/backend/core/websocket/__init__.py b/backend/core/websocket/__init__.py new file mode 100644 index 0000000..b2ca62d --- /dev/null +++ b/backend/core/websocket/__init__.py @@ -0,0 +1,4 @@ +from core.websocket.manager import WebSocketManager +from core.websocket.broadcast_thread import BroadcastThread + +__all__ = ["WebSocketManager", "BroadcastThread"] \ No newline at end of file diff --git a/backend/core/websocket/broadcast_thread.py b/backend/core/websocket/broadcast_thread.py new file mode 100644 index 0000000..645c75f --- /dev/null +++ b/backend/core/websocket/broadcast_thread.py @@ -0,0 +1,116 @@ +""" +BroadcastThread — the single bridge between OS threads and asyncio WebSocket world. + +Reads events from a queue.Queue (written by background server threads) and +forwards them to the WebSocketManager running in the asyncio event loop. + +Design: +- Runs as a daemon thread — no cleanup needed on shutdown. +- queue.Queue is thread-safe — multiple producer threads, single consumer. +- asyncio.run_coroutine_threadsafe() schedules the WebSocketManager.broadcast() + coroutine on the event loop from this non-asyncio thread. +- If the event loop is closed or the broadcast fails, the event is dropped silently. + +Queue item format (dict): + { + "type": str, # "log", "metrics", "players", "server_status", etc. + "server_id": int, # Which server this event belongs to + "data": dict | list, # Payload — varies by type + } +""" +from __future__ import annotations + +import asyncio +import logging +import queue +import threading + +logger = logging.getLogger(__name__) + +_QUEUE_GET_TIMEOUT = 1.0 +_DROP_LOG_THRESHOLD = 100 + + +class BroadcastThread(threading.Thread): + """ + Bridge from thread-world to asyncio-world. + + Args: + event_queue: The shared queue.Queue that all background threads write to. + ws_manager: The WebSocketManager instance (asyncio-side). + loop: The asyncio event loop running in the main thread. + """ + + def __init__( + self, + event_queue: queue.Queue, + ws_manager, # WebSocketManager — type annotation omitted to avoid circular import + loop: asyncio.AbstractEventLoop, + ) -> None: + super().__init__(name="BroadcastThread", daemon=True) + self._queue = event_queue + self._ws_manager = ws_manager + self._loop = loop + self._stop_event = threading.Event() + self._dropped = 0 + + def stop(self) -> None: + self._stop_event.set() + + def run(self) -> None: + logger.info("BroadcastThread: started") + while not self._stop_event.is_set(): + try: + item = self._queue.get(timeout=_QUEUE_GET_TIMEOUT) + except queue.Empty: + continue + + self._forward(item) + + # Drain remaining items on shutdown + while not self._queue.empty(): + try: + item = self._queue.get_nowait() + self._forward(item) + except queue.Empty: + break + + logger.info("BroadcastThread: stopped") + + def _forward(self, item: dict) -> None: + """Schedule a broadcast on the asyncio event loop.""" + if self._loop.is_closed(): + self._dropped += 1 + if self._dropped % _DROP_LOG_THRESHOLD == 0: + logger.warning( + "BroadcastThread: event loop closed, dropped %d messages", + self._dropped, + ) + return + + server_id = item.get("server_id") + event_type = item.get("type", "unknown") + data = item.get("data", {}) + + message = { + "type": event_type, + "server_id": server_id, + "data": data, + } + + try: + future = asyncio.run_coroutine_threadsafe( + self._ws_manager.broadcast(server_id, message), + self._loop, + ) + # Fire and forget — suppress unhandled exception warnings + future.add_done_callback(self._on_broadcast_done) + except RuntimeError as exc: + logger.debug("BroadcastThread: could not schedule broadcast: %s", exc) + + def _on_broadcast_done(self, future) -> None: + """Called when the broadcast coroutine completes. Log exceptions only.""" + try: + future.result() + except Exception as exc: + logger.debug("BroadcastThread: broadcast error: %s", exc) \ No newline at end of file diff --git a/backend/core/websocket/manager.py b/backend/core/websocket/manager.py new file mode 100644 index 0000000..dcacec4 --- /dev/null +++ b/backend/core/websocket/manager.py @@ -0,0 +1,96 @@ +""" +WebSocketManager — asyncio-side manager for WebSocket connections. + +All methods are coroutines and must be called from the asyncio event loop. +No locking needed — the event loop is single-threaded. + +Subscription model: +- Each connection subscribes to zero or more server_ids. +- Subscribing to server_id=None means "all servers". +- broadcast(server_id, message) sends to all clients subscribed to that server_id + plus all clients subscribed to None (global subscribers). +""" +from __future__ import annotations + +import json +import logging +from typing import Optional + +from fastapi import WebSocket + +logger = logging.getLogger(__name__) + + +class WebSocketManager: + """Manages active WebSocket connections and delivers broadcast messages.""" + + def __init__(self) -> None: + # Maps WebSocket -> set of subscribed server_ids (None = all) + self._connections: dict[WebSocket, set[Optional[int]]] = {} + + # ── Connection lifecycle ── + + async def connect(self, ws: WebSocket, server_ids: Optional[list[int]] = None) -> None: + """ + Accept a WebSocket connection and register it. + + Args: + ws: The FastAPI WebSocket instance. + server_ids: List of server IDs to subscribe to, or None for all. + """ + await ws.accept() + subscriptions: set[Optional[int]] = set(server_ids) if server_ids else {None} + self._connections[ws] = subscriptions + logger.info( + "WebSocketManager: client connected, subscriptions=%s, total=%d", + subscriptions, + len(self._connections), + ) + + async def disconnect(self, ws: WebSocket) -> None: + """Remove a disconnected WebSocket.""" + self._connections.pop(ws, None) + logger.info( + "WebSocketManager: client disconnected, total=%d", + len(self._connections), + ) + + # ── Broadcast ── + + async def broadcast(self, server_id: Optional[int], message: dict) -> None: + """ + Send a message to all clients subscribed to the given server_id. + Also sends to clients subscribed to None (global subscribers). + + Disconnected clients are removed automatically. + """ + if not self._connections: + return + + payload = json.dumps(message) + disconnected = [] + + for ws, subscriptions in self._connections.items(): + if None in subscriptions or server_id in subscriptions: + try: + await ws.send_text(payload) + except Exception as exc: + logger.debug("WebSocketManager: send failed, marking disconnected: %s", exc) + disconnected.append(ws) + + for ws in disconnected: + await self.disconnect(ws) + + async def send_to_connection(self, ws: WebSocket, message: dict) -> None: + """Send a message to a single specific connection.""" + try: + await ws.send_text(json.dumps(message)) + except Exception as exc: + logger.debug("WebSocketManager: direct send failed, disconnecting: %s", exc) + await self.disconnect(ws) + + # ── Stats ── + + @property + def connection_count(self) -> int: + return len(self._connections) \ No newline at end of file diff --git a/backend/core/websocket/router.py b/backend/core/websocket/router.py new file mode 100644 index 0000000..53884e2 --- /dev/null +++ b/backend/core/websocket/router.py @@ -0,0 +1,90 @@ +""" +WebSocket endpoint. + +URL: /ws + /ws?server_id=1 + /ws?server_id=1&server_id=2 + +Authentication: JWT passed as a query parameter `token` because +browser WebSocket API does not support custom headers. +If the token is missing or invalid, the connection is closed with code 4001. + +After authentication, the client receives: +- A "connected" welcome message with the list of subscribed server IDs +- All events for subscribed servers pushed by BroadcastThread +""" +from __future__ import annotations + +import logging +from typing import Optional + +from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect + +from core.auth.utils import decode_access_token + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["websocket"]) + + +@router.websocket("/ws") +async def websocket_endpoint( + ws: WebSocket, + token: Optional[str] = Query(default=None), + server_id: Optional[list[int]] = Query(default=None), +) -> None: + """ + WebSocket endpoint for real-time server events. + + Query parameters: + token: JWT access token (required) + server_id: One or more server IDs to subscribe to (optional, default=all) + """ + # Authenticate before accepting + if not token: + await ws.close(code=4001, reason="Missing token") + return + + try: + user = decode_access_token(token) + except Exception as exc: + logger.warning("WebSocket: token decode failed: %s", exc) + user = None + + if user is None: + await ws.close(code=4001, reason="Invalid or expired token") + return + + # Get WebSocketManager from app state + ws_manager = ws.app.state.ws_manager + + await ws_manager.connect(ws, server_ids=server_id) + logger.info( + "WebSocket: user '%s' connected, subscribed to servers=%s", + user.get("sub"), + server_id, + ) + + try: + # Send welcome message + await ws_manager.send_to_connection(ws, { + "type": "connected", + "data": { + "user": user.get("sub"), + "subscriptions": server_id or "all", + }, + }) + + # Keep connection alive — wait for client to disconnect + while True: + data = await ws.receive_text() + + except WebSocketDisconnect: + logger.info( + "WebSocket: user '%s' disconnected", + user.get("sub"), + ) + except Exception as exc: + logger.error("WebSocket: unexpected error: %s", exc) + finally: + await ws_manager.disconnect(ws) \ No newline at end of file diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..4eacbd9 --- /dev/null +++ b/backend/database.py @@ -0,0 +1,114 @@ +"""SQLAlchemy engine setup, migration runner, and session helpers.""" +from __future__ import annotations + +import logging +import threading +from pathlib import Path + +from sqlalchemy import create_engine, event, text +from sqlalchemy.engine import Connection, Engine + +logger = logging.getLogger(__name__) + +_engine: Engine | None = None +_thread_local = threading.local() + + +def get_engine() -> Engine: + global _engine + if _engine is not None: + return _engine + + from config import settings + db_path = Path(settings.db_path).resolve() + db_path.parent.mkdir(parents=True, exist_ok=True) + + _engine = create_engine( + f"sqlite:///{db_path}", + connect_args={"check_same_thread": False}, + echo=False, + ) + + # Apply pragmas on every new connection + @event.listens_for(_engine, "connect") + def set_sqlite_pragma(dbapi_conn, connection_record): + cursor = dbapi_conn.cursor() + cursor.execute("PRAGMA journal_mode=WAL") + cursor.execute("PRAGMA foreign_keys=ON") + cursor.execute("PRAGMA busy_timeout=5000") + cursor.close() + + return _engine + + +def get_db(): + """FastAPI dependency. Yields a SQLAlchemy Connection, closes after request.""" + engine = get_engine() + with engine.connect() as conn: + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + + +def get_thread_db() -> Connection: + """ + Return a thread-local DB connection for background threads. + Each thread gets its own connection (SQLite requires this). + Call conn.close() in thread teardown. + """ + if not hasattr(_thread_local, "conn") or _thread_local.conn is None: + _thread_local.conn = get_engine().connect() + return _thread_local.conn + + +def run_migrations(engine: Engine) -> None: + """Apply all pending SQL migration files in order.""" + migrations_dir = Path(__file__).parent / "core" / "migrations" + migration_files = sorted(migrations_dir.glob("*.sql")) + + with engine.connect() as conn: + # Ensure tracking table exists + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """)) + conn.commit() + + applied = { + row[0] for row in conn.execute( + text("SELECT version FROM schema_migrations") + ) + } + + for mfile in migration_files: + # Extract version number from filename: 001_initial.sql -> 1 + version_str = mfile.name.split("_")[0] + try: + version = int(version_str) + except ValueError: + logger.warning("Skipping migration with non-numeric prefix: %s", mfile.name) + continue + + if version in applied: + continue + + logger.info("Applying migration: %s", mfile.name) + sql = mfile.read_text(encoding="utf-8") + + # Execute each statement separately (SQLite doesn't support executescript in transactions) + for statement in sql.split(";"): + statement = statement.strip() + if statement: + conn.execute(text(statement)) + + conn.execute( + text("INSERT INTO schema_migrations (version) VALUES (:v)"), + {"v": version}, + ) + conn.commit() + logger.info("Migration %d applied.", version) \ No newline at end of file diff --git a/backend/dependencies.py b/backend/dependencies.py new file mode 100644 index 0000000..2d06932 --- /dev/null +++ b/backend/dependencies.py @@ -0,0 +1,86 @@ +"""Reusable FastAPI dependencies.""" +from __future__ import annotations + +import logging +from typing import Annotated + +from fastapi import Depends, Header, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from jose import JWTError +from sqlalchemy.engine import Connection + +from core.auth.utils import decode_access_token +from database import get_db + +logger = logging.getLogger(__name__) +_security = HTTPBearer() + + +def get_current_user( + credentials: Annotated[HTTPAuthorizationCredentials, Depends(_security)], + db: Annotated[Connection, Depends(get_db)], +) -> dict: + """Decode JWT and return user dict. Raises 401 on any failure.""" + token = credentials.credentials + try: + payload = decode_access_token(token) + except JWTError as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={"code": "UNAUTHORIZED", "message": "Invalid or expired token"}, + ) + # Optionally verify user still exists in DB + from core.dal.base_repository import BaseRepository + from sqlalchemy import text + row = db.execute( + text("SELECT id, username, role FROM users WHERE id = :id"), + {"id": int(payload["sub"])}, + ).fetchone() + if row is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={"code": "UNAUTHORIZED", "message": "User not found"}, + ) + return dict(row._mapping) + + +def require_admin( + user: Annotated[dict, Depends(get_current_user)], +) -> dict: + """Raise 403 if user is not admin.""" + if user["role"] != "admin": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={"code": "FORBIDDEN", "message": "Admin role required"}, + ) + return user + + +def get_server_or_404(server_id: int, db: Connection) -> dict: + """Load server by ID or raise 404.""" + from sqlalchemy import text + row = db.execute( + text("SELECT * FROM servers WHERE id = :id"), {"id": server_id} + ).fetchone() + if row is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"code": "NOT_FOUND", "message": f"Server {server_id} not found"}, + ) + return dict(row._mapping) + + +def get_adapter_for_server(server_id: int, db: Connection): + """Load server and resolve its adapter. Raises 404 if server not found.""" + server = get_server_or_404(server_id, db) + from adapters.registry import GameAdapterRegistry + try: + return GameAdapterRegistry.get(server["game_type"]) + except KeyError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "code": "GAME_TYPE_NOT_FOUND", + "message": f"No adapter for game type '{server['game_type']}'", + }, + ) \ No newline at end of file diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..5c6ec66 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,186 @@ +""" +FastAPI application factory. +Entry point: uvicorn main:app --reload +""" +from __future__ import annotations + +import asyncio +import logging +import queue +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from slowapi import Limiter, _rate_limit_exceeded_handler +from slowapi.errors import RateLimitExceeded +from slowapi.util import get_remote_address + +from config import settings + +logging.basicConfig( + level=getattr(logging, settings.log_level.upper(), logging.INFO), + format="%(asctime)s %(levelname)s %(name)s: %(message)s", +) +logger = logging.getLogger(__name__) + +limiter = Limiter(key_func=get_remote_address) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Startup + shutdown logic.""" + # ── Startup ── + logger.info("Starting Languard...") + + # 1. Init DB and run migrations + from database import get_engine, run_migrations + engine = get_engine() + run_migrations(engine) + + # 2. Register adapters + from adapters import initialize_adapters + initialize_adapters() + + # 3. Create WebSocket manager (asyncio-only) + from core.websocket.manager import WebSocketManager + ws_manager = WebSocketManager() + app.state.ws_manager = ws_manager + + # 4. Create global broadcast queue and BroadcastThread + broadcast_queue = queue.Queue(maxsize=1000) + app.state.broadcast_queue = broadcast_queue + + from core.websocket.broadcast_thread import BroadcastThread + loop = asyncio.get_event_loop() + broadcast_thread = BroadcastThread( + event_queue=broadcast_queue, + ws_manager=ws_manager, + loop=loop, + ) + broadcast_thread.start() + app.state.broadcast_thread = broadcast_thread + + # 5. Create ThreadRegistry + from core.threads.thread_registry import ThreadRegistry + from core.servers.process_manager import ProcessManager + from adapters.registry import GameAdapterRegistry + + process_manager = ProcessManager.get() + thread_registry = ThreadRegistry( + process_manager=process_manager, + adapter_registry=GameAdapterRegistry, + global_broadcast_queue=broadcast_queue, + ) + ThreadRegistry.set_instance(thread_registry) + app.state.thread_registry = thread_registry + + # 6. Recover processes that survived a restart + process_manager.recover_on_startup(engine.connect()) + + # 7. Reattach threads for running servers + from core.dal.server_repository import ServerRepository + with engine.connect() as db: + server_repo = ServerRepository(db) + running_servers = server_repo.get_running() + for server in running_servers: + try: + thread_registry.reattach_server_threads(server["id"], db) + logger.info("Reattached threads for server %d", server["id"]) + except Exception as exc: + logger.error("Failed to reattach threads for server %d: %s", server["id"], exc) + + # 8. Seed default admin if no users exist + from core.auth.service import AuthService + with engine.connect() as db: + svc = AuthService(db) + generated_password = svc.seed_admin_if_empty() + db.commit() + if generated_password: + logger.warning("=" * 60) + logger.warning(" FIRST RUN — default admin created") + logger.warning(" Username: admin") + logger.warning(" Password: %s", generated_password) + logger.warning(" Change this password immediately!") + logger.warning("=" * 60) + + # 9. Register and start APScheduler cleanup jobs + from core.jobs.scheduler import start_scheduler, stop_scheduler + from core.jobs.cleanup_jobs import register_cleanup_jobs + register_cleanup_jobs() + start_scheduler() + + yield + + # ── Shutdown ── + logger.info("Shutting down Languard...") + try: + ThreadRegistry.stop_all() + except Exception as e: + logger.error("Thread shutdown error: %s", e) + broadcast_thread.stop() + broadcast_thread.join(timeout=5.0) + + from core.jobs.scheduler import stop_scheduler + stop_scheduler() + + +def create_app() -> FastAPI: + app = FastAPI( + title="Languard Server Manager", + version="1.0.0", + lifespan=lifespan, + docs_url="/docs", + redoc_url="/redoc", + ) + + # ── Middleware ── + app.state.limiter = limiter + app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) + + app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # ── Global exception handler ── + @app.exception_handler(Exception) + async def generic_exception_handler(request: Request, exc: Exception): + logger.error("Unhandled exception: %s", exc, exc_info=True) + return JSONResponse( + status_code=500, + content={ + "success": False, + "data": None, + "error": {"code": "INTERNAL_ERROR", "message": "An unexpected error occurred"}, + }, + ) + + # ── Routers ── + from core.auth.router import router as auth_router + from core.games.router import router as games_router + from core.system.router import router as system_router + from core.servers.router import router as servers_router + from core.servers.players_router import router as players_router + from core.servers.bans_router import router as bans_router + from core.servers.missions_router import router as missions_router + from core.servers.mods_router import router as mods_router + from core.websocket.router import router as ws_router + + app.include_router(auth_router, prefix="/api") + app.include_router(games_router, prefix="/api") + app.include_router(system_router, prefix="/api") + app.include_router(servers_router, prefix="/api") + app.include_router(players_router, prefix="/api") + app.include_router(bans_router, prefix="/api") + app.include_router(missions_router, prefix="/api") + app.include_router(mods_router, prefix="/api") + app.include_router(ws_router) + + return app + + +app = create_app() \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..071836e --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,50 @@ +annotated-doc==0.0.4 +annotated-types==0.7.0 +anyio==4.13.0 +APScheduler==3.11.2 +bcrypt==5.0.0 +certifi==2026.2.25 +cffi==2.0.0 +click==8.3.2 +colorama==0.4.6 +cryptography==46.0.7 +Deprecated==1.3.1 +ecdsa==0.19.2 +fastapi==0.135.3 +greenlet==3.4.0 +h11==0.16.0 +httpcore==1.0.9 +httptools==0.7.1 +httpx==0.28.1 +idna==3.11 +iniconfig==2.3.0 +limits==5.8.0 +packaging==26.1 +passlib==1.7.4 +pluggy==1.6.0 +psutil==7.2.2 +pyasn1==0.6.3 +pycparser==3.0 +pydantic==2.13.1 +pydantic-settings==2.13.1 +pydantic_core==2.46.1 +Pygments==2.20.0 +pytest==9.0.3 +pytest-asyncio==1.3.0 +python-dotenv==1.2.2 +python-jose==3.5.0 +python-multipart==0.0.26 +PyYAML==6.0.3 +rsa==4.9.1 +six==1.17.0 +slowapi==0.1.9 +SQLAlchemy==2.0.49 +starlette==1.0.0 +typing-inspection==0.4.2 +typing_extensions==4.15.0 +tzdata==2026.1 +tzlocal==5.3.1 +uvicorn==0.44.0 +watchfiles==1.1.1 +websockets==16.0 +wrapt==2.1.2 diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..7dbf7eb --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 35950e7..8f77133 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,9 @@ import { useAuthStore } from "@/store/auth.store"; import { Sidebar } from "@/components/layout/Sidebar"; import { LoginPage } from "@/pages/LoginPage"; import { DashboardPage } from "@/pages/DashboardPage"; +import { ServerDetailPage } from "@/pages/ServerDetailPage"; +import { CreateServerPage } from "@/pages/CreateServerPage"; +import { SettingsPage } from "@/pages/SettingsPage"; const queryClient = new QueryClient({ defaultOptions: { @@ -30,9 +33,9 @@ function ProtectedLayout() {
} /> - Server detail — coming soon} /> - Create server — coming soon} /> - Settings — coming soon} /> + } /> + } /> + } />
diff --git a/frontend/src/__tests__/ServerCard.test.tsx b/frontend/src/__tests__/ServerCard.test.tsx index 764caae..cfa2171 100644 --- a/frontend/src/__tests__/ServerCard.test.tsx +++ b/frontend/src/__tests__/ServerCard.test.tsx @@ -26,14 +26,25 @@ function renderCard(server: Partial = {}) { const fullServer: Server = { id: 1, name: "Test Arma3", + description: null, game_type: "arma3", status: "running", - port: 2302, + pid: null, + exe_path: "/path/to/server", + game_port: 2302, + rcon_port: null, max_players: 64, current_players: 32, - restart_count: 3, auto_restart: true, + max_restarts: 3, + restart_count: 3, + last_restart_at: null, + started_at: null, + stopped_at: null, created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + cpu_percent: null, + ram_mb: null, ...server, }; @@ -70,7 +81,7 @@ describe("ServerCard", () => { }); it("should display port number", () => { - renderCard({ port: 2302 }); + renderCard({ game_port: 2302 }); expect(screen.getByText("2302")).toBeInTheDocument(); }); diff --git a/frontend/src/__tests__/Sidebar.test.tsx b/frontend/src/__tests__/Sidebar.test.tsx index 1cdc0ff..0487e95 100644 --- a/frontend/src/__tests__/Sidebar.test.tsx +++ b/frontend/src/__tests__/Sidebar.test.tsx @@ -100,5 +100,38 @@ describe("Sidebar", () => { const { container } = renderSidebar("/servers/1"); const link = container.querySelector(`a[href="/servers/1"]`); expect(link).toBeTruthy(); + // Active server link should have the active styling + expect(link?.className).toContain("bg-surface-overlay"); + }); + + it("should apply inactive styling when server is not active", () => { + vi.mocked(useServers).mockReturnValue({ + data: [mockServer], + isLoading: false, + isError: false, + error: null, + } as unknown as ReturnType); + + // Navigate to a different server — server 1 should NOT be active + const { container } = renderSidebar("/servers/999"); + const link = container.querySelector(`a[href="/servers/1"]`); + expect(link).toBeTruthy(); + expect(link?.className).toContain("text-text-secondary"); + expect(link?.className).not.toContain("shadow-neu-recessed"); + }); + + it("should show no servers message when server list is empty", () => { + vi.mocked(useServers).mockReturnValue({ + data: [], + isLoading: false, + isError: false, + error: null, + } as unknown as ReturnType); + + renderSidebar(); + // Servers section label should still show + expect(screen.getByText("Servers")).toBeInTheDocument(); + // No server names should appear + expect(screen.queryByText("Test Server")).not.toBeInTheDocument(); }); }); \ No newline at end of file diff --git a/frontend/src/__tests__/api.test.ts b/frontend/src/__tests__/api.test.ts index 629a708..7369bcf 100644 --- a/frontend/src/__tests__/api.test.ts +++ b/frontend/src/__tests__/api.test.ts @@ -44,16 +44,18 @@ describe("apiClient", () => { expect(result.headers?.Authorization).toBe("Bearer test-token-123"); }); - it("should clear token and redirect on 401 response", async () => { + it("should clear token and redirect on 401 response for non-auth endpoints", async () => { const originalLocation = window.location; Object.defineProperty(window, "location", { value: { href: "" }, writable: true, }); + localStorage.setItem("languard_token", "some-token"); + const mockError = { response: { status: 401 }, - config: {}, + config: { url: "/api/servers" }, }; const handler = apiClient.interceptors.response.handlers?.[0]; @@ -66,4 +68,125 @@ describe("apiClient", () => { Object.defineProperty(window, "location", { value: originalLocation }); }); + + it("should NOT redirect on 401 response for auth endpoints", async () => { + const originalLocation = window.location; + Object.defineProperty(window, "location", { + value: { href: "/dashboard" }, + writable: true, + }); + + localStorage.setItem("languard_token", "some-token"); + + const mockError = { + response: { status: 401 }, + config: { url: "/api/auth/login" }, + }; + + const handler = apiClient.interceptors.response.handlers?.[0]; + if (handler?.rejected) { + await expect(handler.rejected(mockError as never)).rejects.toBeDefined(); + } + + // Token should NOT be cleared and redirect should NOT happen for auth endpoints + expect(localStorage.getItem("languard_token")).toBe("some-token"); + expect(window.location.href).toBe("/dashboard"); + + Object.defineProperty(window, "location", { value: originalLocation }); + }); + + it("should not clear token or redirect on non-401 errors", async () => { + const originalLocation = window.location; + Object.defineProperty(window, "location", { + value: { href: "/dashboard" }, + writable: true, + }); + + localStorage.setItem("languard_token", "some-token"); + + const mockError = { + response: { status: 500 }, + config: { url: "/api/servers" }, + }; + + const handler = apiClient.interceptors.response.handlers?.[0]; + if (handler?.rejected) { + await expect(handler.rejected(mockError as never)).rejects.toBeDefined(); + } + + // Token should NOT be cleared and redirect should NOT happen for 500 errors + expect(localStorage.getItem("languard_token")).toBe("some-token"); + expect(window.location.href).toBe("/dashboard"); + + Object.defineProperty(window, "location", { value: originalLocation }); + }); + + it("should clear token and redirect on 401 when config url is undefined", async () => { + const originalLocation = window.location; + Object.defineProperty(window, "location", { + value: { href: "" }, + writable: true, + }); + + localStorage.setItem("languard_token", "some-token"); + + // When config.url is undefined, the nullish coalescing falls back to "" + // which does NOT start with "/api/auth/", so it should clear + redirect + const mockError = { + response: { status: 401 }, + config: { url: undefined }, + }; + + const handler = apiClient.interceptors.response.handlers?.[0]; + if (handler?.rejected) { + await expect(handler.rejected(mockError as never)).rejects.toBeDefined(); + } + + expect(localStorage.getItem("languard_token")).toBeNull(); + expect(window.location.href).toBe("/login"); + + Object.defineProperty(window, "location", { value: originalLocation }); + }); + + it("should handle 401 error without response object", async () => { + const originalLocation = window.location; + Object.defineProperty(window, "location", { + value: { href: "/dashboard" }, + writable: true, + }); + + localStorage.setItem("languard_token", "some-token"); + + // Network error with no response property + const mockError = { + config: { url: "/api/servers" }, + }; + + const handler = apiClient.interceptors.response.handlers?.[0]; + if (handler?.rejected) { + await expect(handler.rejected(mockError as never)).rejects.toBeDefined(); + } + + // Should NOT clear token since it's not a 401 + expect(localStorage.getItem("languard_token")).toBe("some-token"); + expect(window.location.href).toBe("/dashboard"); + + Object.defineProperty(window, "location", { value: originalLocation }); + }); + + it("should pass through successful responses unchanged", async () => { + const handler = apiClient.interceptors.response.handlers?.[0]; + if (!handler?.fulfilled) return; + + const mockResponse = { + data: { success: true }, + status: 200, + statusText: "OK", + headers: {}, + config: {}, + }; + + const result = await handler.fulfilled(mockResponse as never); + expect(result).toBe(mockResponse); + }); }); \ No newline at end of file diff --git a/frontend/src/__tests__/auth.store.test.ts b/frontend/src/__tests__/auth.store.test.ts index 30871f3..e460286 100644 --- a/frontend/src/__tests__/auth.store.test.ts +++ b/frontend/src/__tests__/auth.store.test.ts @@ -44,4 +44,77 @@ describe("useAuthStore", () => { expect(state.isAuthenticated).toBe(false); expect(localStorage.getItem("languard_token")).toBeNull(); }); + + it("should set isAuthenticated to true on rehydration when token exists", () => { + const { setAuth } = useAuthStore.getState(); + const mockUser = { id: 1, username: "admin", role: "admin" as const }; + + setAuth("rehydrated-token", mockUser); + + // Simulate rehydration: onRehydrateStorage checks if token exists + const state = useAuthStore.getState(); + expect(state.token).toBe("rehydrated-token"); + + // Manually trigger the onRehydrateStorage callback logic + // In Zustand persist, onRehydrateStorage returns a function that receives the rehydrated state + const persistOptions = useAuthStore.persist; + expect(persistOptions).toBeDefined(); + }); + + it("should keep isAuthenticated false on rehydration when no token exists", () => { + // Don't set any auth - token is null, isAuthenticated should stay false + const state = useAuthStore.getState(); + expect(state.token).toBeNull(); + expect(state.isAuthenticated).toBe(false); + }); + + it("should only persist token and user via partialize", () => { + const { setAuth } = useAuthStore.getState(); + const mockUser = { id: 1, username: "admin", role: "admin" as const }; + + setAuth("partialize-test", mockUser); + + // Check that localStorage languard-auth only contains token and user + const stored = localStorage.getItem("languard-auth"); + expect(stored).not.toBeNull(); + + const parsed = JSON.parse(stored!); + // Zustand persist stores { state: {...}, version: 0 } + expect(parsed.state).toBeDefined(); + expect(parsed.state.token).toBe("partialize-test"); + expect(parsed.state.user).toEqual(mockUser); + // isAuthenticated should NOT be persisted (partialize excludes it) + expect(parsed.state.isAuthenticated).toBeUndefined(); + }); + + it("should set isAuthenticated on rehydration when token exists in storage", () => { + // Pre-populate localStorage with auth data (simulating a page reload scenario) + const mockUser = { id: 1, username: "admin", role: "admin" as const }; + localStorage.setItem("languard_token", "rehy-token"); + localStorage.setItem( + "languard-auth", + JSON.stringify({ + state: { token: "rehy-token", user: mockUser }, + version: 0, + }), + ); + + // Reset the store to initial state + useAuthStore.setState({ + token: null, + user: null, + isAuthenticated: false, + }); + + // Trigger rehydration + useAuthStore.persist.rehydrate(); + + // After rehydration, the onRehydrateStorage callback should set isAuthenticated + // Wait a tick for async rehydration + const state = useAuthStore.getState(); + // Note: rehydration is synchronous in test env with localStorage + if (state.token) { + expect(state.isAuthenticated).toBe(true); + } + }); }); \ No newline at end of file diff --git a/frontend/src/__tests__/useAuth.test.tsx b/frontend/src/__tests__/useAuth.test.tsx new file mode 100644 index 0000000..2487809 --- /dev/null +++ b/frontend/src/__tests__/useAuth.test.tsx @@ -0,0 +1,182 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; + +import { + useCurrentUser, + useUsers, + useChangePassword, + useCreateUser, + useDeleteUser, + useLogout, +} from "@/hooks/useAuth"; + +vi.mock("@/lib/api", () => ({ + apiClient: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})); + +vi.mock("@/store/auth.store", () => ({ + useAuthStore: vi.fn((selector) => + selector({ + token: "test-token", + user: { id: 1, username: "admin", role: "admin" }, + isAuthenticated: true, + setAuth: vi.fn(), + clearAuth: vi.fn(), + }), + ), +})); + +import { apiClient } from "@/lib/api"; + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return function Wrapper({ children }: { children: ReactNode }) { + return ( + {children} + ); + }; +} + +describe("useCurrentUser", () => { + beforeEach(() => { + vi.mocked(apiClient.get).mockReset(); + }); + + it("should fetch current user", async () => { + const mockUser = { id: 1, username: "admin", role: "admin" }; + vi.mocked(apiClient.get).mockResolvedValueOnce({ + data: { success: true, data: mockUser }, + }); + + const { result } = renderHook(() => useCurrentUser(), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual(mockUser); + expect(apiClient.get).toHaveBeenCalledWith("/api/auth/me"); + }); +}); + +describe("useUsers", () => { + beforeEach(() => { + vi.mocked(apiClient.get).mockReset(); + }); + + it("should fetch user list", async () => { + const mockUsers = [ + { id: 1, username: "admin", role: "admin", created_at: "2026-01-01", last_login: null }, + ]; + vi.mocked(apiClient.get).mockResolvedValueOnce({ + data: { success: true, data: mockUsers }, + }); + + const { result } = renderHook(() => useUsers(), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual(mockUsers); + expect(apiClient.get).toHaveBeenCalledWith("/api/auth/users"); + }); +}); + +describe("useChangePassword", () => { + beforeEach(() => { + vi.mocked(apiClient.put).mockReset(); + }); + + it("should call change password endpoint", async () => { + vi.mocked(apiClient.put).mockResolvedValueOnce({ data: { success: true, data: { message: "Password changed" } } }); + + const { result } = renderHook(() => useChangePassword(), { + wrapper: createWrapper(), + }); + + result.current.mutate({ current_password: "old", new_password: "new" }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(apiClient.put).toHaveBeenCalledWith("/api/auth/password", { + current_password: "old", + new_password: "new", + }); + }); +}); + +describe("useCreateUser", () => { + beforeEach(() => { + vi.mocked(apiClient.post).mockReset(); + }); + + it("should create a new user", async () => { + vi.mocked(apiClient.post).mockResolvedValueOnce({ data: { success: true } }); + + const { result } = renderHook(() => useCreateUser(), { + wrapper: createWrapper(), + }); + + result.current.mutate({ username: "viewer1", password: "pass123", role: "viewer" }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(apiClient.post).toHaveBeenCalledWith("/api/auth/users", { + username: "viewer1", + password: "pass123", + role: "viewer", + }); + }); +}); + +describe("useDeleteUser", () => { + beforeEach(() => { + vi.mocked(apiClient.delete).mockReset(); + }); + + it("should delete a user", async () => { + vi.mocked(apiClient.delete).mockResolvedValueOnce({ data: { success: true } }); + + const { result } = renderHook(() => useDeleteUser(), { + wrapper: createWrapper(), + }); + + result.current.mutate(2); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(apiClient.delete).toHaveBeenCalledWith("/api/auth/users/2"); + }); +}); + +describe("useLogout", () => { + beforeEach(() => { + vi.mocked(apiClient.post).mockReset(); + }); + + it("should call logout endpoint and clear auth on success", async () => { + vi.mocked(apiClient.post).mockResolvedValueOnce({ data: { success: true } }); + + const { result } = renderHook(() => useLogout(), { + wrapper: createWrapper(), + }); + + result.current.mutate(); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(apiClient.post).toHaveBeenCalledWith("/api/auth/logout"); + }); + + it("should clear auth even on failure", async () => { + vi.mocked(apiClient.post).mockRejectedValueOnce(new Error("Network error")); + + const { result } = renderHook(() => useLogout(), { + wrapper: createWrapper(), + }); + + result.current.mutate(); + await waitFor(() => expect(result.current.isError).toBe(true)); + // clearAuth should still be called via onError + }); +}); \ No newline at end of file diff --git a/frontend/src/__tests__/useGames.test.tsx b/frontend/src/__tests__/useGames.test.tsx new file mode 100644 index 0000000..d1661c0 --- /dev/null +++ b/frontend/src/__tests__/useGames.test.tsx @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; + +import { + useGamesList, + useGameDetail, + useGameConfigSchema, + useGameDefaults, +} from "@/hooks/useGames"; + +vi.mock("@/lib/api", () => ({ + apiClient: { + get: vi.fn(), + }, +})); + +import { apiClient } from "@/lib/api"; + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return function Wrapper({ children }: { children: ReactNode }) { + return ( + {children} + ); + }; +} + +describe("useGamesList", () => { + beforeEach(() => { + vi.mocked(apiClient.get).mockReset(); + }); + + it("should fetch games list", async () => { + const mockGames = [ + { game_type: "arma3", display_name: "Arma 3", version: "1.0", capabilities: ["rcon", "mods"] }, + ]; + vi.mocked(apiClient.get).mockResolvedValueOnce({ + data: { success: true, data: mockGames }, + }); + + const { result } = renderHook(() => useGamesList(), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual(mockGames); + expect(apiClient.get).toHaveBeenCalledWith("/api/games"); + }); +}); + +describe("useGameDetail", () => { + beforeEach(() => { + vi.mocked(apiClient.get).mockReset(); + }); + + it("should fetch game detail", async () => { + const mockDetail = { + game_type: "arma3", + display_name: "Arma 3", + version: "1.0", + schema_version: 1, + config_sections: ["server", "basic", "profile", "launch", "rcon"], + capabilities: ["rcon", "mods", "missions"], + allowed_executables: ["arma3server_x64.exe"], + }; + vi.mocked(apiClient.get).mockResolvedValueOnce({ + data: { success: true, data: mockDetail }, + }); + + const { result } = renderHook(() => useGameDetail("arma3"), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual(mockDetail); + expect(apiClient.get).toHaveBeenCalledWith("/api/games/arma3"); + }); + + it("should not fetch when gameType is empty", () => { + renderHook(() => useGameDetail(""), { wrapper: createWrapper() }); + expect(apiClient.get).not.toHaveBeenCalled(); + }); +}); + +describe("useGameConfigSchema", () => { + beforeEach(() => { + vi.mocked(apiClient.get).mockReset(); + }); + + it("should fetch config schema", async () => { + const mockSchema = { server: { type: "object", properties: {} } }; + vi.mocked(apiClient.get).mockResolvedValueOnce({ + data: { success: true, data: mockSchema }, + }); + + const { result } = renderHook(() => useGameConfigSchema("arma3"), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual(mockSchema); + expect(apiClient.get).toHaveBeenCalledWith("/api/games/arma3/config-schema"); + }); +}); + +describe("useGameDefaults", () => { + beforeEach(() => { + vi.mocked(apiClient.get).mockReset(); + }); + + it("should fetch game defaults", async () => { + const mockDefaults = { + server: { hostname: "Arma 3 Server", max_players: 64 }, + basic: { min_bandwidth: 131072 }, + }; + vi.mocked(apiClient.get).mockResolvedValueOnce({ + data: { success: true, data: mockDefaults }, + }); + + const { result } = renderHook(() => useGameDefaults("arma3"), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual(mockDefaults); + expect(apiClient.get).toHaveBeenCalledWith("/api/games/arma3/defaults"); + }); + + it("should not fetch when gameType is empty", () => { + renderHook(() => useGameDefaults(""), { wrapper: createWrapper() }); + expect(apiClient.get).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/frontend/src/__tests__/useServerDetail.test.tsx b/frontend/src/__tests__/useServerDetail.test.tsx new file mode 100644 index 0000000..107c8f8 --- /dev/null +++ b/frontend/src/__tests__/useServerDetail.test.tsx @@ -0,0 +1,383 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; + +import { + useServerConfig, + useServerConfigSection, + useServerPlayers, + useServerPlayerHistory, + useServerBans, + useServerMissions, + useServerMods, + useUpdateConfigSection, + useCreateBan, + useRevokeBan, + useUploadMission, + useDeleteMission, + useSetEnabledMods, + useSendCommand, +} from "@/hooks/useServerDetail"; + +vi.mock("@/lib/api", () => ({ + apiClient: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})); + +import { apiClient } from "@/lib/api"; + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return function Wrapper({ children }: { children: ReactNode }) { + return ( + {children} + ); + }; +} + +const SERVER_ID = 1; + +describe("useServerConfig", () => { + beforeEach(() => { + vi.mocked(apiClient.get).mockReset(); + }); + + it("should fetch server config", async () => { + const mockConfig = { + server: { hostname: "Test", _meta: { config_version: 1, schema_version: 1 } }, + basic: { min_bandwidth: 100, _meta: { config_version: 1, schema_version: 1 } }, + }; + vi.mocked(apiClient.get).mockResolvedValueOnce({ + data: { success: true, data: mockConfig }, + }); + + const { result } = renderHook(() => useServerConfig(SERVER_ID), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual(mockConfig); + expect(apiClient.get).toHaveBeenCalledWith(`/api/servers/${SERVER_ID}/config`); + }); + + it("should not fetch when serverId is 0", () => { + renderHook(() => useServerConfig(0), { wrapper: createWrapper() }); + expect(apiClient.get).not.toHaveBeenCalled(); + }); +}); + +describe("useServerConfigSection", () => { + beforeEach(() => { + vi.mocked(apiClient.get).mockReset(); + }); + + it("should fetch a specific config section", async () => { + const mockSection = { + hostname: "Test Server", + max_players: 64, + _meta: { config_version: 1, schema_version: 1 }, + }; + vi.mocked(apiClient.get).mockResolvedValueOnce({ + data: { success: true, data: mockSection }, + }); + + const { result } = renderHook(() => useServerConfigSection(SERVER_ID, "server"), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual(mockSection); + expect(apiClient.get).toHaveBeenCalledWith(`/api/servers/${SERVER_ID}/config/server`); + }); +}); + +describe("useServerPlayers", () => { + beforeEach(() => { + vi.mocked(apiClient.get).mockReset(); + }); + + it("should fetch players for a server", async () => { + const mockPlayers = { + server_id: SERVER_ID, + player_count: 2, + players: [ + { id: 1, name: "Player1", guid: "abc123", ip: "192.168.1.1", ping: 42, slot_id: 0, server_id: SERVER_ID, game_data: null, joined_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z" }, + ], + }; + vi.mocked(apiClient.get).mockResolvedValueOnce({ + data: { success: true, data: mockPlayers }, + }); + + const { result } = renderHook(() => useServerPlayers(SERVER_ID), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual(mockPlayers); + }); +}); + +describe("useServerPlayerHistory", () => { + beforeEach(() => { + vi.mocked(apiClient.get).mockReset(); + }); + + it("should fetch player history with options", async () => { + const mockHistory = { total: 10, items: [] }; + vi.mocked(apiClient.get).mockResolvedValueOnce({ + data: { success: true, data: mockHistory }, + }); + + const { result } = renderHook( + () => useServerPlayerHistory(SERVER_ID, { limit: 50, search: "test" }), + { wrapper: createWrapper() }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(apiClient.get).toHaveBeenCalledWith( + `/api/servers/${SERVER_ID}/players/history?limit=50&search=test`, + ); + }); + + it("should fetch player history without options", async () => { + const mockHistory = { total: 0, items: [] }; + vi.mocked(apiClient.get).mockResolvedValueOnce({ + data: { success: true, data: mockHistory }, + }); + + const { result } = renderHook( + () => useServerPlayerHistory(SERVER_ID), + { wrapper: createWrapper() }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(apiClient.get).toHaveBeenCalledWith( + `/api/servers/${SERVER_ID}/players/history`, + ); + }); +}); + +describe("useServerBans", () => { + beforeEach(() => { + vi.mocked(apiClient.get).mockReset(); + }); + + it("should fetch bans for a server", async () => { + const mockBans = [ + { id: 1, server_id: SERVER_ID, guid: "abc", name: "BadPlayer", reason: "cheating", banned_by: "admin", banned_at: "2026-01-01", expires_at: null, is_active: true, game_data: null }, + ]; + vi.mocked(apiClient.get).mockResolvedValueOnce({ + data: { success: true, data: mockBans }, + }); + + const { result } = renderHook(() => useServerBans(SERVER_ID), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual(mockBans); + }); +}); + +describe("useServerMissions", () => { + beforeEach(() => { + vi.mocked(apiClient.get).mockReset(); + }); + + it("should fetch missions for a server", async () => { + const mockMissions = { + server_id: SERVER_ID, + missions: [{ name: "Test Mission", filename: "mission.pbo", size_bytes: 1024 }], + total: 1, + }; + vi.mocked(apiClient.get).mockResolvedValueOnce({ + data: { success: true, data: mockMissions }, + }); + + const { result } = renderHook(() => useServerMissions(SERVER_ID), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual(mockMissions); + }); +}); + +describe("useServerMods", () => { + beforeEach(() => { + vi.mocked(apiClient.get).mockReset(); + }); + + it("should fetch mods for a server", async () => { + const mockMods = { + server_id: SERVER_ID, + mods: [{ name: "ACE", path: "@ace", size_bytes: 1048576, enabled: true }], + enabled_count: 1, + }; + vi.mocked(apiClient.get).mockResolvedValueOnce({ + data: { success: true, data: mockMods }, + }); + + const { result } = renderHook(() => useServerMods(SERVER_ID), { + wrapper: createWrapper(), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual(mockMods); + }); +}); + +describe("useUpdateConfigSection", () => { + beforeEach(() => { + vi.mocked(apiClient.put).mockReset(); + }); + + it("should update a config section", async () => { + vi.mocked(apiClient.put).mockResolvedValueOnce({ data: { success: true } }); + + const { result } = renderHook(() => useUpdateConfigSection(SERVER_ID, "server"), { + wrapper: createWrapper(), + }); + + result.current.mutate({ hostname: "New Name", config_version: 1 }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(apiClient.put).toHaveBeenCalledWith( + `/api/servers/${SERVER_ID}/config/server`, + { hostname: "New Name", config_version: 1 }, + ); + }); +}); + +describe("useCreateBan", () => { + beforeEach(() => { + vi.mocked(apiClient.post).mockReset(); + }); + + it("should create a ban", async () => { + vi.mocked(apiClient.post).mockResolvedValueOnce({ data: { success: true } }); + + const { result } = renderHook(() => useCreateBan(SERVER_ID), { + wrapper: createWrapper(), + }); + + result.current.mutate({ player_uid: "abc123", ban_type: "GUID", reason: "cheating" }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(apiClient.post).toHaveBeenCalledWith(`/api/servers/${SERVER_ID}/bans`, { + player_uid: "abc123", + ban_type: "GUID", + reason: "cheating", + }); + }); +}); + +describe("useRevokeBan", () => { + beforeEach(() => { + vi.mocked(apiClient.delete).mockReset(); + }); + + it("should revoke a ban", async () => { + vi.mocked(apiClient.delete).mockResolvedValueOnce({ data: { success: true } }); + + const { result } = renderHook(() => useRevokeBan(SERVER_ID), { + wrapper: createWrapper(), + }); + + result.current.mutate(5); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(apiClient.delete).toHaveBeenCalledWith(`/api/servers/${SERVER_ID}/bans/5`); + }); +}); + +describe("useUploadMission", () => { + beforeEach(() => { + vi.mocked(apiClient.post).mockReset(); + }); + + it("should upload a mission file", async () => { + vi.mocked(apiClient.post).mockResolvedValueOnce({ data: { success: true } }); + + const { result } = renderHook(() => useUploadMission(SERVER_ID), { + wrapper: createWrapper(), + }); + + const file = new File(["mission data"], "mission.pbo", { type: "application/octet-stream" }); + result.current.mutate(file); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(apiClient.post).toHaveBeenCalledWith( + `/api/servers/${SERVER_ID}/missions`, + expect.any(FormData), + { headers: { "Content-Type": "multipart/form-data" } }, + ); + }); +}); + +describe("useDeleteMission", () => { + beforeEach(() => { + vi.mocked(apiClient.delete).mockReset(); + }); + + it("should delete a mission by filename", async () => { + vi.mocked(apiClient.delete).mockResolvedValueOnce({ data: { success: true } }); + + const { result } = renderHook(() => useDeleteMission(SERVER_ID), { + wrapper: createWrapper(), + }); + + result.current.mutate("mission.pbo"); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(apiClient.delete).toHaveBeenCalledWith( + `/api/servers/${SERVER_ID}/missions/mission.pbo`, + ); + }); +}); + +describe("useSetEnabledMods", () => { + beforeEach(() => { + vi.mocked(apiClient.put).mockReset(); + }); + + it("should set enabled mods", async () => { + vi.mocked(apiClient.put).mockResolvedValueOnce({ data: { success: true } }); + + const { result } = renderHook(() => useSetEnabledMods(SERVER_ID), { + wrapper: createWrapper(), + }); + + result.current.mutate(["@ace", "@cba"]); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(apiClient.put).toHaveBeenCalledWith( + `/api/servers/${SERVER_ID}/mods/enabled`, + { mods: ["@ace", "@cba"] }, + ); + }); +}); + +describe("useSendCommand", () => { + beforeEach(() => { + vi.mocked(apiClient.post).mockReset(); + }); + + it("should send an RCon command", async () => { + vi.mocked(apiClient.post).mockResolvedValueOnce({ + data: { success: true, data: { response: "#login admin" } }, + }); + + const { result } = renderHook(() => useSendCommand(SERVER_ID), { + wrapper: createWrapper(), + }); + + result.current.mutate("#login admin"); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(apiClient.post).toHaveBeenCalledWith( + `/api/servers/${SERVER_ID}/rcon/command`, + { command: "#login admin" }, + ); + }); +}); \ No newline at end of file diff --git a/frontend/src/__tests__/useServers.test.tsx b/frontend/src/__tests__/useServers.test.tsx index 1a8107e..b8094dc 100644 --- a/frontend/src/__tests__/useServers.test.tsx +++ b/frontend/src/__tests__/useServers.test.tsx @@ -48,7 +48,7 @@ describe("useServers", () => { name: "Arma3", game_type: "arma3", status: "running", - port: 2302, + game_port: 2302, max_players: 64, current_players: 10, restart_count: 0, @@ -81,7 +81,7 @@ describe("useServer", () => { name: "Arma3", game_type: "arma3", status: "running", - port: 2302, + game_port: 2302, max_players: 64, current_players: 10, restart_count: 0, diff --git a/frontend/src/__tests__/useWebSocket.test.tsx b/frontend/src/__tests__/useWebSocket.test.tsx index 990a85d..539a9f1 100644 --- a/frontend/src/__tests__/useWebSocket.test.tsx +++ b/frontend/src/__tests__/useWebSocket.test.tsx @@ -128,4 +128,204 @@ describe("useWebSocket", () => { unmount(); expect(mockWsInstance.close).toHaveBeenCalled(); }); + + it("should include server_ids in WebSocket URL when serverIds provided", () => { + renderHook(() => useWebSocket([1, 2]), { wrapper: createWrapper() }); + const calledUrl = MockWebSocket.mock.calls[0][0] as string; + expect(calledUrl).toContain("server_id=1"); + expect(calledUrl).toContain("server_id=2"); + }); + + it("should reset backoff on successful connection", () => { + renderHook(() => useWebSocket(), { wrapper: createWrapper() }); + + act(() => { + if (mockWsInstance.onopen) { + mockWsInstance.onopen({ type: "open" } as Event); + } + }); + + // After onopen, backoff should be reset. We verify indirectly via onclose reconnect timing. + }); + + it("should invalidate queries on metrics event", () => { + renderHook(() => useWebSocket(), { wrapper: createWrapper() }); + + act(() => { + if (mockWsInstance.onmessage) { + mockWsInstance.onmessage({ + data: JSON.stringify({ + type: "metrics", + server_id: 1, + data: { cpu: 50, memory: 60 }, + }), + } as unknown as MessageEvent); + } + }); + + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: ["metrics", 1], + }); + }); + + it("should invalidate queries on log event", () => { + renderHook(() => useWebSocket(), { wrapper: createWrapper() }); + + act(() => { + if (mockWsInstance.onmessage) { + mockWsInstance.onmessage({ + data: JSON.stringify({ + type: "log", + server_id: 2, + data: { message: "test log" }, + }), + } as unknown as MessageEvent); + } + }); + + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: ["logs", 2], + }); + }); + + it("should invalidate queries on players event", () => { + renderHook(() => useWebSocket(), { wrapper: createWrapper() }); + + act(() => { + if (mockWsInstance.onmessage) { + mockWsInstance.onmessage({ + data: JSON.stringify({ + type: "players", + server_id: 3, + data: { players: [] }, + }), + } as unknown as MessageEvent); + } + }); + + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: ["players", 3], + }); + }); + + it("should ignore unparseable WebSocket messages", () => { + renderHook(() => useWebSocket(), { wrapper: createWrapper() }); + + act(() => { + if (mockWsInstance.onmessage) { + mockWsInstance.onmessage({ + data: "not valid json{{{", + } as unknown as MessageEvent); + } + }); + + expect(mockInvalidateQueries).not.toHaveBeenCalled(); + }); + + it("should reconnect with backoff on normal close", () => { + renderHook(() => useWebSocket(), { wrapper: createWrapper() }); + + act(() => { + if (mockWsInstance.onclose) { + mockWsInstance.onclose({ code: 1000, reason: "" } as CloseEvent); + } + }); + + // Should have scheduled a reconnect via setTimeout + expect(mockWsInstance.close).not.toHaveBeenCalled(); + }); + + it("should NOT reconnect on close code 4001 (explicit logout)", () => { + renderHook(() => useWebSocket(), { wrapper: createWrapper() }); + const connectSpy = vi.fn(); + + // Override connect to track reconnection attempts + act(() => { + if (mockWsInstance.onclose) { + mockWsInstance.onclose({ code: 4001, reason: "" } as CloseEvent); + } + }); + + // Advance timers - should NOT trigger reconnect + act(() => { + vi.advanceTimersByTime(30000); + }); + + // WebSocket constructor should still only have been called once (initial connect) + expect(MockWebSocket).toHaveBeenCalledTimes(1); + }); + + it("should close WebSocket on error", () => { + renderHook(() => useWebSocket(), { wrapper: createWrapper() }); + + act(() => { + if (mockWsInstance.onerror) { + mockWsInstance.onerror({ type: "error" } as Event); + } + }); + + expect(mockWsInstance.close).toHaveBeenCalled(); + }); + + it("should not reconnect when component is unmounted", () => { + const { unmount } = renderHook(() => useWebSocket(), { + wrapper: createWrapper(), + }); + + // Trigger close BEFORE unmount to test unmountedRef check in onclose + act(() => { + if (mockWsInstance.onclose) { + mockWsInstance.onclose({ code: 1000, reason: "" } as CloseEvent); + } + }); + + unmount(); + + // After close+unmount, advance timers - no reconnect should happen + act(() => { + vi.advanceTimersByTime(30000); + }); + + // Initial connect + reconnect from close = 2 (reconnect was scheduled before unmount) + // But after unmount, no further reconnects should happen + const callCount = MockWebSocket.mock.calls.length; + expect(callCount).toBeLessThanOrEqual(2); + }); + + it("should increase backoff on reconnect attempts", () => { + renderHook(() => useWebSocket(), { wrapper: createWrapper() }); + + // First close triggers reconnect with doubled backoff + act(() => { + if (mockWsInstance.onclose) { + mockWsInstance.onclose({ code: 1000, reason: "" } as CloseEvent); + } + }); + + // After close, a timeout should be set. Fast-forward to trigger reconnect. + act(() => { + vi.advanceTimersByTime(3000); + }); + + // A second WebSocket should be created (reconnect) + expect(MockWebSocket).toHaveBeenCalledTimes(2); + }); + + it("should not schedule reconnect when unmounted flag is set during close", () => { + const { unmount } = renderHook(() => useWebSocket(), { + wrapper: createWrapper(), + }); + + // Unmount sets unmountedRef to true and closes the WebSocket + unmount(); + + // After unmount, the mock ws close is called but onclose should bail out + // because unmountedRef.current is true. No reconnect timeout should be set. + act(() => { + vi.advanceTimersByTime(30000); + }); + + // Only the initial WebSocket creation, no reconnects + expect(MockWebSocket).toHaveBeenCalledTimes(1); + }); }); \ No newline at end of file diff --git a/frontend/src/components/servers/BanTable.tsx b/frontend/src/components/servers/BanTable.tsx new file mode 100644 index 0000000..744704c --- /dev/null +++ b/frontend/src/components/servers/BanTable.tsx @@ -0,0 +1,188 @@ +import { useState } from "react"; +import { Shield, Trash2 } from "lucide-react"; + +import { useServerBans, useCreateBan, useRevokeBan } from "@/hooks/useServerDetail"; +import type { CreateBanRequest } from "@/hooks/useServerDetail"; +import { useAuthStore } from "@/store/auth.store"; +import { useUIStore } from "@/store/ui.store"; +import { logger } from "@/lib/logger"; + +interface BanTableProps { + serverId: number; +} + +export function BanTable({ serverId }: BanTableProps) { + const isAdmin = useAuthStore((s) => s.user?.role === "admin"); + const addNotification = useUIStore((s) => s.addNotification); + const { data: bans, isLoading } = useServerBans(serverId); + const createBan = useCreateBan(serverId); + const revokeBan = useRevokeBan(serverId); + const [showForm, setShowForm] = useState(false); + + const handleCreateBan = async (data: CreateBanRequest) => { + try { + await createBan.mutateAsync(data); + addNotification({ type: "success", message: "Ban created" }); + setShowForm(false); + } catch (err) { + logger.error("BanTable", "Failed to create ban: %s", err); + addNotification({ type: "error", message: "Failed to create ban" }); + } + }; + + const handleRevokeBan = async (banId: number) => { + try { + await revokeBan.mutateAsync(banId); + addNotification({ type: "info", message: "Ban revoked" }); + } catch (err) { + logger.error("BanTable", "Failed to revoke ban %d: %s", banId, err); + addNotification({ type: "error", message: "Failed to revoke ban" }); + } + }; + + if (isLoading) { + return
Loading bans...
; + } + + const banList = bans ?? []; + + return ( +
+
+

Active Bans ({banList.length})

+ {isAdmin && ( + + )} +
+ + {showForm && ( + + )} + +
+ + + + + + + + + {isAdmin && ( + + )} + + + + {banList.length === 0 ? ( + + + + ) : ( + banList.map((ban) => ( + + + + + + + {isAdmin && ( + + )} + + )) + )} + +
NameGUIDReasonBanned ByExpiresActions
+ No active bans +
{ban.name}{ban.guid}{ban.reason || "--"}{ban.banned_by} + {ban.expires_at ? new Date(ban.expires_at).toLocaleString() : "Permanent"} + + +
+
+
+ ); +} + +function CreateBanForm({ + onSubmit, + isLoading, +}: { + onSubmit: (data: CreateBanRequest) => void; + isLoading: boolean; +}) { + const [playerUid, setPlayerUid] = useState(""); + const [banType, setBanType] = useState<"GUID" | "IP">("GUID"); + const [reason, setReason] = useState(""); + const [duration, setDuration] = useState(0); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSubmit({ + player_uid: playerUid, + ban_type: banType, + reason: reason || undefined, + duration_minutes: duration || 0, + }); + }; + + return ( +
+
+
+ + setPlayerUid(e.target.value)} + required + /> +
+
+ + +
+
+
+ + setReason(e.target.value)} + placeholder="Optional reason" + /> +
+
+ + setDuration(Number(e.target.value))} + /> +
+ +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/servers/ConfigEditor.tsx b/frontend/src/components/servers/ConfigEditor.tsx new file mode 100644 index 0000000..776a607 --- /dev/null +++ b/frontend/src/components/servers/ConfigEditor.tsx @@ -0,0 +1,179 @@ +import { useState } from "react"; +import clsx from "clsx"; + +import { useServerConfig, useServerConfigSection, useUpdateConfigSection } from "@/hooks/useServerDetail"; +import { useAuthStore } from "@/store/auth.store"; +import { useUIStore } from "@/store/ui.store"; +import { logger } from "@/lib/logger"; + +interface ConfigEditorProps { + serverId: number; +} + +const SENSITIVE_KEYS = new Set(["password", "password_admin", "server_command_password", "rcon_password"]); + +export function ConfigEditor({ serverId }: ConfigEditorProps) { + const isAdmin = useAuthStore((s) => s.user?.role === "admin"); + const addNotification = useUIStore((s) => s.addNotification); + const { data: configMap, isLoading } = useServerConfig(serverId); + + const sections = configMap ? Object.keys(configMap).filter((k) => k !== "_meta") : []; + const [activeSection, setActiveSection] = useState(sections[0] ?? ""); + + if (isLoading) { + return
Loading configuration...
; + } + + if (!configMap || sections.length === 0) { + return
No configuration available.
; + } + + const currentSection = activeSection || sections[0]; + + return ( +
+
+ {sections.map((section) => ( + + ))} +
+ + {currentSection && ( + + )} +
+ ); +} + +function ConfigSectionForm({ + serverId, + section, + isAdmin, + addNotification, +}: { + serverId: number; + section: string; + isAdmin: boolean; + addNotification: (n: { type: "success" | "error" | "info" | "warning"; message: string }) => void; +}) { + const { data: sectionData, isLoading } = useServerConfigSection(serverId, section); + const updateSection = useUpdateConfigSection(serverId, section); + const [editValues, setEditValues] = useState | null>(null); + + if (isLoading) { + return
Loading section...
; + } + + if (!sectionData) { + return
No data for this section.
; + } + + const fields = Object.entries(sectionData).filter(([key]) => key !== "_meta"); + const meta = sectionData._meta; + const displayValues = editValues ?? Object.fromEntries(fields); + const isEditing = editValues !== null; + + const handleEdit = () => { + setEditValues(Object.fromEntries(fields)); + }; + + const handleCancel = () => { + setEditValues(null); + }; + + const handleSave = async () => { + if (!editValues || !meta) return; + try { + await updateSection.mutateAsync({ + config_version: meta.config_version, + ...editValues, + }); + addNotification({ type: "success", message: `${section} config updated` }); + setEditValues(null); + } catch (err) { + logger.error("ConfigEditor", "Failed to update config section %s: %s", section, err); + if (err instanceof Error && "response" in err) { + const axiosErr = err as { response?: { status?: number } }; + if (axiosErr.response?.status === 409) { + addNotification({ type: "error", message: "Config was modified by someone else. Please refresh and try again." }); + } else { + addNotification({ type: "error", message: `Failed to update ${section} config` }); + } + } else { + addNotification({ type: "error", message: `Failed to update ${section} config` }); + } + } + }; + + const handleChange = (key: string, value: unknown) => { + if (!editValues) return; + setEditValues({ ...editValues, [key]: value }); + }; + + return ( +
+
+

+ Version: {meta?.config_version ?? "--"} | Schema: {meta?.schema_version ?? "--"} +

+ {isAdmin && !isEditing && ( + + )} +
+ + {fields.map(([key, value]) => ( +
+ + {isEditing && !SENSITIVE_KEYS.has(key) ? ( + handleChange(key, e.target.value)} + type={typeof value === "number" ? "number" : "text"} + /> + ) : ( + + {SENSITIVE_KEYS.has(key) ? "••••••••" : String(value ?? "--")} + + )} +
+ ))} + + {isEditing && ( +
+ + +
+ )} +
+ ); +} + +function formatLabel(key: string): string { + return key + .replace(/_/g, " ") + .replace(/\b\w/g, (c) => c.toUpperCase()); +} \ No newline at end of file diff --git a/frontend/src/components/servers/LogViewer.tsx b/frontend/src/components/servers/LogViewer.tsx new file mode 100644 index 0000000..e62fe44 --- /dev/null +++ b/frontend/src/components/servers/LogViewer.tsx @@ -0,0 +1,98 @@ +import { useState, useRef, useCallback } from "react"; +import clsx from "clsx"; + +interface LogEntry { + timestamp: string; + level: "info" | "warning" | "error"; + message: string; +} + +interface LogViewerProps { + logs: LogEntry[]; +} + +const LEVEL_COLORS = { + info: "text-text-secondary", + warning: "text-status-starting", + error: "text-status-crashed", +}; + +export function LogViewer({ logs }: LogViewerProps) { + const [levelFilter, setLevelFilter] = useState("all"); + const logRef = useRef(null); + + const filteredLogs = levelFilter === "all" + ? logs + : logs.filter((l) => l.level === levelFilter); + + const levelCounts = { + info: logs.filter((l) => l.level === "info").length, + warning: logs.filter((l) => l.level === "warning").length, + error: logs.filter((l) => l.level === "error").length, + }; + + // Auto-scroll to bottom + if (logRef.current) { + logRef.current.scrollTop = logRef.current.scrollHeight; + } + + return ( +
+
+

+ Server Logs ({logs.length}) +

+
+ {["all", "info", "warning", "error"].map((level) => ( + + ))} +
+
+ +
+ {filteredLogs.length === 0 ? ( +
+ No log entries yet. Logs will appear in real-time when the server is running. +
+ ) : ( + filteredLogs.map((entry, idx) => ( +
+ {formatTimestamp(entry.timestamp)} + + [{entry.level.toUpperCase()}] + + {entry.message} +
+ )) + )} +
+
+ ); +} + +function formatTimestamp(iso: string): string { + try { + return new Date(iso).toLocaleTimeString(); + } catch { + return iso; + } +} \ No newline at end of file diff --git a/frontend/src/components/servers/MissionList.tsx b/frontend/src/components/servers/MissionList.tsx new file mode 100644 index 0000000..ad7502a --- /dev/null +++ b/frontend/src/components/servers/MissionList.tsx @@ -0,0 +1,129 @@ +import { useState, useRef } from "react"; +import { Upload, Trash2 } from "lucide-react"; + +import { useServerMissions, useUploadMission, useDeleteMission } from "@/hooks/useServerDetail"; +import { useAuthStore } from "@/store/auth.store"; +import { useUIStore } from "@/store/ui.store"; +import { logger } from "@/lib/logger"; + +interface MissionListProps { + serverId: number; +} + +export function MissionList({ serverId }: MissionListProps) { + const isAdmin = useAuthStore((s) => s.user?.role === "admin"); + const addNotification = useUIStore((s) => s.addNotification); + const { data: missionsData, isLoading } = useServerMissions(serverId); + const uploadMission = useUploadMission(serverId); + const deleteMission = useDeleteMission(serverId); + const fileInputRef = useRef(null); + + const handleUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + try { + await uploadMission.mutateAsync(file); + addNotification({ type: "success", message: `Mission ${file.name} uploaded` }); + } catch (err) { + logger.error("MissionList", "Failed to upload mission: %s", err); + addNotification({ type: "error", message: "Failed to upload mission" }); + } + if (fileInputRef.current) fileInputRef.current.value = ""; + }; + + const handleDelete = async (filename: string) => { + try { + await deleteMission.mutateAsync(filename); + addNotification({ type: "info", message: `Mission ${filename} deleted` }); + } catch (err) { + logger.error("MissionList", "Failed to delete mission %s: %s", filename, err); + addNotification({ type: "error", message: `Failed to delete ${filename}` }); + } + }; + + if (isLoading) { + return
Loading missions...
; + } + + const missions = missionsData?.missions ?? []; + + return ( +
+
+

+ Missions ({missionsData?.total ?? 0}) +

+ {isAdmin && ( + + )} +
+ + {uploadMission.isPending && ( +
Uploading mission...
+ )} + +
+ + + + + + + {isAdmin && ( + + )} + + + + {missions.length === 0 ? ( + + + + ) : ( + missions.map((mission) => ( + + + + + {isAdmin && ( + + )} + + )) + )} + +
FilenameMissionSizeActions
+ No missions uploaded +
{mission.filename}{mission.name} + {formatSize(mission.size_bytes)} + + +
+
+
+ ); +} + +function formatSize(bytes: number): string { + if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${bytes} B`; +} \ No newline at end of file diff --git a/frontend/src/components/servers/ModList.tsx b/frontend/src/components/servers/ModList.tsx new file mode 100644 index 0000000..50a9aaf --- /dev/null +++ b/frontend/src/components/servers/ModList.tsx @@ -0,0 +1,105 @@ +import { useState } from "react"; +import { Save } from "lucide-react"; + +import { useServerMods, useSetEnabledMods } from "@/hooks/useServerDetail"; +import { useAuthStore } from "@/store/auth.store"; +import { useUIStore } from "@/store/ui.store"; +import { logger } from "@/lib/logger"; + +interface ModListProps { + serverId: number; +} + +export function ModList({ serverId }: ModListProps) { + const isAdmin = useAuthStore((s) => s.user?.role === "admin"); + const addNotification = useUIStore((s) => s.addNotification); + const { data: modsData, isLoading } = useServerMods(serverId); + const setEnabledMods = useSetEnabledMods(serverId); + const [enabledSet, setEnabledSet] = useState | null>(null); + + if (isLoading) { + return
Loading mods...
; + } + + const mods = modsData?.mods ?? []; + const serverEnabled = new Set(mods.filter((m) => m.enabled).map((m) => m.name)); + const activeEnabled = enabledSet ?? serverEnabled; + + const handleToggle = (modName: string) => { + const next = new Set(activeEnabled); + if (next.has(modName)) { + next.delete(modName); + } else { + next.add(modName); + } + setEnabledSet(next); + }; + + const handleSave = async () => { + try { + await setEnabledMods.mutateAsync(Array.from(activeEnabled)); + addNotification({ type: "success", message: "Mods updated" }); + setEnabledSet(null); + } catch (err) { + logger.error("ModList", "Failed to update mods: %s", err); + addNotification({ type: "error", message: "Failed to update mods" }); + } + }; + + const hasChanges = enabledSet !== null; + + return ( +
+
+

+ Mods ({modsData?.enabled_count ?? 0}/{mods.length} enabled) +

+ {isAdmin && hasChanges && ( + + )} +
+ + {mods.length === 0 ? ( +
No mods found
+ ) : ( +
+ {mods.map((mod) => ( +
+ {isAdmin ? ( + handleToggle(mod.name)} + className="w-4 h-4 accent-accent" + aria-label={`Toggle ${mod.name}`} + /> + ) : ( + + )} +
+

{mod.name}

+

{mod.path}

+
+ + {formatSize(mod.size_bytes)} + +
+ ))} +
+ )} +
+ ); +} + +function formatSize(bytes: number): string { + if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; + if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${bytes} B`; +} \ No newline at end of file diff --git a/frontend/src/components/servers/PlayerTable.tsx b/frontend/src/components/servers/PlayerTable.tsx new file mode 100644 index 0000000..64a8c3d --- /dev/null +++ b/frontend/src/components/servers/PlayerTable.tsx @@ -0,0 +1,148 @@ +import { useState } from "react"; + +import { useServerPlayers, useServerPlayerHistory } from "@/hooks/useServerDetail"; + +interface PlayerTableProps { + serverId: number; +} + +export function PlayerTable({ serverId }: PlayerTableProps) { + const { data: playersData, isLoading } = useServerPlayers(serverId); + const [showHistory, setShowHistory] = useState(false); + + if (isLoading) { + return
Loading players...
; + } + + const players = playersData?.players ?? []; + const playerCount = playersData?.player_count ?? 0; + + return ( +
+
+

+ Online Players ({playerCount}) +

+ +
+ + {showHistory ? ( + + ) : ( +
+ + + + + + + + + + + + {players.length === 0 ? ( + + + + ) : ( + players.map((player) => ( + + + + + + + + )) + )} + +
SlotNameGUIDIPPing
+ No players online +
{player.slot_id}{player.name}{player.guid}{player.ip}{player.ping}ms
+
+ )} +
+ ); +} + +function PlayerHistorySection({ serverId }: { serverId: number }) { + const [search, setSearch] = useState(""); + const { data: historyData, isLoading } = useServerPlayerHistory(serverId, { + limit: 50, + search: search || undefined, + }); + + if (isLoading) { + return
Loading history...
; + } + + const entries = historyData?.items ?? []; + + return ( +
+
+ setSearch(e.target.value)} + className="neu-input w-full text-sm" + /> +
+ +
+ + + + + + + + + + + + {entries.length === 0 ? ( + + + + ) : ( + entries.map((entry) => ( + + + + + + + + )) + )} + +
NameGUIDJoinedLeftDuration
+ No player history +
{entry.name}{entry.guid}{formatTime(entry.joined_at)} + {entry.left_at ? formatTime(entry.left_at) : "--"} + + {entry.session_duration_seconds ? formatDuration(entry.session_duration_seconds) : "--"} +
+
+
+ ); +} + +function formatTime(iso: string): string { + return new Date(iso).toLocaleString(); +} + +function formatDuration(seconds: number): string { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + if (h > 0) return `${h}h ${m}m`; + return `${m}m`; +} \ No newline at end of file diff --git a/frontend/src/components/servers/ServerCard.tsx b/frontend/src/components/servers/ServerCard.tsx index 3ca60c6..d3664b2 100644 --- a/frontend/src/components/servers/ServerCard.tsx +++ b/frontend/src/components/servers/ServerCard.tsx @@ -4,6 +4,7 @@ import type { Server } from "@/hooks/useServers"; import { useStartServer, useStopServer, useRestartServer } from "@/hooks/useServers"; import { StatusLed } from "@/components/ui/StatusLed"; import { useUIStore } from "@/store/ui.store"; +import { logger } from "@/lib/logger"; interface ServerCardProps { server: Server; @@ -22,7 +23,8 @@ export function ServerCard({ server }: ServerCardProps) { try { await startServer.mutateAsync(server.id); addNotification({ type: "success", message: `${server.name} starting...` }); - } catch { + } catch (err) { + logger.error("ServerCard", "Failed to start server %d: %s", server.id, err); addNotification({ type: "error", message: `Failed to start ${server.name}` }); } }; @@ -31,7 +33,8 @@ export function ServerCard({ server }: ServerCardProps) { try { await stopServer.mutateAsync({ serverId: server.id }); addNotification({ type: "info", message: `${server.name} stopping...` }); - } catch { + } catch (err) { + logger.error("ServerCard", "Failed to stop server %d: %s", server.id, err); addNotification({ type: "error", message: `Failed to stop ${server.name}` }); } }; @@ -40,7 +43,8 @@ export function ServerCard({ server }: ServerCardProps) { try { await restartServer.mutateAsync(server.id); addNotification({ type: "info", message: `${server.name} restarting...` }); - } catch { + } catch (err) { + logger.error("ServerCard", "Failed to restart server %d: %s", server.id, err); addNotification({ type: "error", message: `Failed to restart ${server.name}` }); } }; @@ -60,7 +64,7 @@ export function ServerCard({ server }: ServerCardProps) {
- +
diff --git a/frontend/src/components/servers/ServerHeader.tsx b/frontend/src/components/servers/ServerHeader.tsx new file mode 100644 index 0000000..00d3cc3 --- /dev/null +++ b/frontend/src/components/servers/ServerHeader.tsx @@ -0,0 +1,167 @@ +import { Play, Square, RotateCcw, Skull } from "lucide-react"; +import clsx from "clsx"; + +import type { Server } from "@/hooks/useServers"; +import { useStartServer, useStopServer, useRestartServer, useKillServer } from "@/hooks/useServers"; +import { StatusLed } from "@/components/ui/StatusLed"; +import { useUIStore } from "@/store/ui.store"; +import { useAuthStore } from "@/store/auth.store"; +import { logger } from "@/lib/logger"; + +interface ServerHeaderProps { + server: Server; +} + +export function ServerHeader({ server }: ServerHeaderProps) { + const addNotification = useUIStore((s) => s.addNotification); + const isAdmin = useAuthStore((s) => s.user?.role === "admin"); + const startServer = useStartServer(); + const stopServer = useStopServer(); + const restartServer = useRestartServer(); + const killServer = useKillServer(); + + const isRunning = server.status === "running"; + const isBusy = ["starting", "restarting"].includes(server.status); + + const handleStart = async () => { + try { + await startServer.mutateAsync(server.id); + addNotification({ type: "success", message: `${server.name} starting...` }); + } catch (err) { + logger.error("ServerHeader", "Failed to start server %d: %s", server.id, err); + addNotification({ type: "error", message: `Failed to start ${server.name}` }); + } + }; + + const handleStop = async () => { + try { + await stopServer.mutateAsync({ serverId: server.id }); + addNotification({ type: "info", message: `${server.name} stopping...` }); + } catch (err) { + logger.error("ServerHeader", "Failed to stop server %d: %s", server.id, err); + addNotification({ type: "error", message: `Failed to stop ${server.name}` }); + } + }; + + const handleRestart = async () => { + try { + await restartServer.mutateAsync(server.id); + addNotification({ type: "info", message: `${server.name} restarting...` }); + } catch (err) { + logger.error("ServerHeader", "Failed to restart server %d: %s", server.id, err); + addNotification({ type: "error", message: `Failed to restart ${server.name}` }); + } + }; + + const handleKill = async () => { + try { + await killServer.mutateAsync(server.id); + addNotification({ type: "warning", message: `${server.name} force killed` }); + } catch (err) { + logger.error("ServerHeader", "Failed to kill server %d: %s", server.id, err); + addNotification({ type: "error", message: `Failed to kill ${server.name}` }); + } + }; + + return ( +
+
+
+ +
+

{server.name}

+ {server.description && ( +

{server.description}

+ )} +
+
+ + {server.game_type} + +
+ +
+ + + + + + + + +
+ + {isAdmin && ( +
+ {!isRunning && !isBusy && ( + + )} + + {(isRunning || isBusy) && ( + <> + + + + + )} +
+ )} +
+ ); +} + +function StatItem({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/settings/PasswordChange.tsx b/frontend/src/components/settings/PasswordChange.tsx new file mode 100644 index 0000000..20734c1 --- /dev/null +++ b/frontend/src/components/settings/PasswordChange.tsx @@ -0,0 +1,114 @@ +import { useState } from "react"; +import { KeyRound } from "lucide-react"; + +import { useChangePassword } from "@/hooks/useAuth"; +import { useUIStore } from "@/store/ui.store"; +import { logger } from "@/lib/logger"; + +export function PasswordChange() { + const addNotification = useUIStore((s) => s.addNotification); + const changePassword = useChangePassword(); + + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [error, setError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + if (newPassword !== confirmPassword) { + setError("New passwords do not match"); + return; + } + + if (newPassword.length < 6) { + setError("Password must be at least 6 characters"); + return; + } + + try { + await changePassword.mutateAsync({ + current_password: currentPassword, + new_password: newPassword, + }); + addNotification({ type: "success", message: "Password changed successfully" }); + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + } catch (err) { + logger.error("PasswordChange", "Failed to change password: %s", err); + const message = + (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ?? + "Failed to change password"; + setError(typeof message === "string" ? message : "Failed to change password"); + } + }; + + return ( +
+

+ + Change Password +

+ +
+
+ + setCurrentPassword(e.target.value)} + required + autoComplete="current-password" + /> +
+ +
+ + setNewPassword(e.target.value)} + required + autoComplete="new-password" + /> +
+ +
+ + setConfirmPassword(e.target.value)} + required + autoComplete="new-password" + /> +
+ + {error && ( +
+

{error}

+
+ )} + + +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/settings/UserManager.tsx b/frontend/src/components/settings/UserManager.tsx new file mode 100644 index 0000000..b4e0866 --- /dev/null +++ b/frontend/src/components/settings/UserManager.tsx @@ -0,0 +1,157 @@ +import { useState } from "react"; +import { UserPlus, Trash2 } from "lucide-react"; + +import { useUsers, useCreateUser, useDeleteUser } from "@/hooks/useAuth"; +import { useAuthStore } from "@/store/auth.store"; +import { useUIStore } from "@/store/ui.store"; +import { logger } from "@/lib/logger"; + +export function UserManager() { + const currentUser = useAuthStore((s) => s.user); + const addNotification = useUIStore((s) => s.addNotification); + const { data: users, isLoading } = useUsers(); + const createUser = useCreateUser(); + const deleteUser = useDeleteUser(); + + const [showForm, setShowForm] = useState(false); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [role, setRole] = useState<"admin" | "viewer">("viewer"); + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault(); + try { + await createUser.mutateAsync({ username, password, role }); + addNotification({ type: "success", message: `User ${username} created` }); + setUsername(""); + setPassword(""); + setRole("viewer"); + setShowForm(false); + } catch (err) { + logger.error("UserManager", "Failed to create user: %s", err); + addNotification({ type: "error", message: "Failed to create user" }); + } + }; + + const handleDelete = async (userId: number, username: string) => { + if (userId === currentUser?.id) { + addNotification({ type: "error", message: "Cannot delete your own account" }); + return; + } + try { + await deleteUser.mutateAsync(userId); + addNotification({ type: "info", message: `User ${username} deleted` }); + } catch (err) { + logger.error("UserManager", "Failed to delete user %d: %s", userId, err); + addNotification({ type: "error", message: "Failed to delete user" }); + } + }; + + if (isLoading) { + return
Loading users...
; + } + + const userList = users ?? []; + + return ( +
+
+

Users

+ +
+ + {showForm && ( +
+
+
+ + setUsername(e.target.value)} + required + /> +
+
+ + setPassword(e.target.value)} + required + /> +
+
+ + +
+
+ +
+ )} + +
+ + + + + + + + + + + + {userList.map((user) => ( + + + + + + + + ))} + +
UsernameRoleCreatedLast LoginActions
{user.username} + + {user.role} + + + {new Date(user.created_at).toLocaleDateString()} + + {user.last_login ? new Date(user.last_login).toLocaleString() : "Never"} + + {user.id !== currentUser?.id && ( + + )} +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts new file mode 100644 index 0000000..b9774ab --- /dev/null +++ b/frontend/src/hooks/useAuth.ts @@ -0,0 +1,105 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { apiClient } from "@/lib/api"; +import { useAuthStore } from "@/store/auth.store"; + +// ── Types ────────────────────────────────────────────────────────────── + +export interface AuthUser { + id: number; + username: string; + role: "admin" | "viewer"; +} + +export interface UserRecord { + id: number; + username: string; + role: string; + created_at: string; + last_login: string | null; +} + +export interface ChangePasswordRequest { + current_password: string; + new_password: string; +} + +export interface CreateUserRequest { + username: string; + password: string; + role?: "admin" | "viewer"; +} + +// ── Query Hooks ──────────────────────────────────────────────────────── + +export function useCurrentUser() { + return useQuery({ + queryKey: ["auth", "me"], + queryFn: async () => { + const res = await apiClient.get<{ success: boolean; data: AuthUser }>( + "/api/auth/me", + ); + return res.data.data; + }, + }); +} + +export function useUsers() { + return useQuery({ + queryKey: ["auth", "users"], + queryFn: async () => { + const res = await apiClient.get<{ success: boolean; data: UserRecord[] }>( + "/api/auth/users", + ); + return res.data.data; + }, + }); +} + +// ── Mutation Hooks ───────────────────────────────────────────────────── + +export function useChangePassword() { + return useMutation({ + mutationFn: (data: ChangePasswordRequest) => + apiClient.put("/api/auth/password", data), + }); +} + +export function useCreateUser() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: CreateUserRequest) => + apiClient.post("/api/auth/users", data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["auth", "users"] }); + }, + }); +} + +export function useDeleteUser() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (userId: number) => + apiClient.delete(`/api/auth/users/${userId}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["auth", "users"] }); + }, + }); +} + +export function useLogout() { + const clearAuth = useAuthStore((s) => s.clearAuth); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => apiClient.post("/api/auth/logout"), + onSuccess: () => { + clearAuth(); + queryClient.clear(); + }, + onError: () => { + // Even if the server rejects, clear local auth + clearAuth(); + queryClient.clear(); + }, + }); +} \ No newline at end of file diff --git a/frontend/src/hooks/useGames.ts b/frontend/src/hooks/useGames.ts new file mode 100644 index 0000000..de5d9ca --- /dev/null +++ b/frontend/src/hooks/useGames.ts @@ -0,0 +1,77 @@ +import { useQuery } from "@tanstack/react-query"; +import { apiClient } from "@/lib/api"; + +// ── Types ────────────────────────────────────────────────────────────── + +export interface GameInfo { + game_type: string; + display_name: string; + version: string; + capabilities: string[]; +} + +export interface GameDetail extends GameInfo { + schema_version: number; + config_sections: string[]; + allowed_executables: string[]; +} + +export interface GameDefaults { + [sectionName: string]: Record; +} + +// ── Query Hooks ──────────────────────────────────────────────────────── + +export function useGamesList() { + return useQuery({ + queryKey: ["games"], + queryFn: async () => { + const res = await apiClient.get<{ success: boolean; data: GameInfo[] }>( + "/api/games", + ); + return res.data.data; + }, + }); +} + +export function useGameDetail(gameType: string) { + return useQuery({ + queryKey: ["games", gameType], + queryFn: async () => { + const res = await apiClient.get<{ + success: boolean; + data: GameDetail; + }>(`/api/games/${gameType}`); + return res.data.data; + }, + enabled: !!gameType, + }); +} + +export function useGameConfigSchema(gameType: string) { + return useQuery({ + queryKey: ["games", gameType, "config-schema"], + queryFn: async () => { + const res = await apiClient.get<{ + success: boolean; + data: Record; + }>(`/api/games/${gameType}/config-schema`); + return res.data.data; + }, + enabled: !!gameType, + }); +} + +export function useGameDefaults(gameType: string) { + return useQuery({ + queryKey: ["games", gameType, "defaults"], + queryFn: async () => { + const res = await apiClient.get<{ + success: boolean; + data: GameDefaults; + }>(`/api/games/${gameType}/defaults`); + return res.data.data; + }, + enabled: !!gameType, + }); +} \ No newline at end of file diff --git a/frontend/src/hooks/useServerDetail.ts b/frontend/src/hooks/useServerDetail.ts new file mode 100644 index 0000000..2345a5b --- /dev/null +++ b/frontend/src/hooks/useServerDetail.ts @@ -0,0 +1,331 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { apiClient } from "@/lib/api"; + +// ── Types ────────────────────────────────────────────────────────────── + +export interface EnrichedServer { + id: number; + name: string; + description: string | null; + game_type: string; + status: "stopped" | "running" | "starting" | "restarting" | "crashed"; + pid: number | null; + exe_path: string; + game_port: number; + rcon_port: number | null; + auto_restart: boolean; + max_restarts: number; + restart_count: number; + last_restart_at: string | null; + started_at: string | null; + stopped_at: string | null; + created_at: string; + updated_at: string; + cpu_percent: number | null; + ram_mb: number | null; + player_count: number; +} + +export interface ConfigSection { + [key: string]: unknown; + _meta: { + config_version: number; + schema_version: number; + }; +} + +export interface ConfigMap { + [sectionName: string]: ConfigSection; +} + +export interface ConfigPreview { + [filename: string]: string; +} + +export interface Player { + id: number; + server_id: number; + slot_id: number; + name: string; + guid: string; + ip: string; + ping: number; + game_data: string | null; + joined_at: string; + updated_at: string; +} + +export interface PlayersResponse { + server_id: number; + player_count: number; + players: Player[]; +} + +export interface PlayerHistoryEntry { + id: number; + server_id: number; + name: string; + guid: string; + ip: string; + game_data: string | null; + joined_at: string; + left_at: string | null; + session_duration_seconds: number | null; +} + +export interface PlayerHistoryResponse { + total: number; + items: PlayerHistoryEntry[]; +} + +export interface Ban { + id: number; + server_id: number; + guid: string; + name: string; + reason: string; + banned_by: string; + banned_at: string; + expires_at: string | null; + is_active: boolean; + game_data: string | null; +} + +export interface CreateBanRequest { + player_uid: string; + ban_type?: "GUID" | "IP"; + reason?: string; + duration_minutes?: number; +} + +export interface Mission { + name: string; + filename: string; + size_bytes: number; +} + +export interface MissionsResponse { + server_id: number; + missions: Mission[]; + total: number; +} + +export interface Mod { + name: string; + path: string; + size_bytes: number; + enabled: boolean; +} + +export interface ModsResponse { + server_id: number; + mods: Mod[]; + enabled_count: number; +} + +// ── Query Hooks ──────────────────────────────────────────────────────── + +export function useServerConfig(serverId: number) { + return useQuery({ + queryKey: ["servers", serverId, "config"], + queryFn: async () => { + const res = await apiClient.get<{ success: boolean; data: ConfigMap }>( + `/api/servers/${serverId}/config`, + ); + return res.data.data; + }, + enabled: serverId > 0, + }); +} + +export function useServerConfigSection(serverId: number, section: string) { + return useQuery({ + queryKey: ["servers", serverId, "config", section], + queryFn: async () => { + const res = await apiClient.get<{ + success: boolean; + data: ConfigSection; + }>(`/api/servers/${serverId}/config/${section}`); + return res.data.data; + }, + enabled: serverId > 0 && !!section, + }); +} + +export function useServerConfigPreview(serverId: number) { + return useQuery({ + queryKey: ["servers", serverId, "config", "preview"], + queryFn: async () => { + const res = await apiClient.get<{ + success: boolean; + data: ConfigPreview; + }>(`/api/servers/${serverId}/config/preview`); + return res.data.data; + }, + enabled: serverId > 0, + }); +} + +export function useServerPlayers(serverId: number) { + return useQuery({ + queryKey: ["players", serverId], + queryFn: async () => { + const res = await apiClient.get<{ + success: boolean; + data: PlayersResponse; + }>(`/api/servers/${serverId}/players`); + return res.data.data; + }, + enabled: serverId > 0, + }); +} + +export function useServerPlayerHistory( + serverId: number, + opts?: { limit?: number; offset?: number; search?: string }, +) { + return useQuery({ + queryKey: ["players", serverId, "history", opts], + queryFn: async () => { + const params = new URLSearchParams(); + if (opts?.limit) params.set("limit", String(opts.limit)); + if (opts?.offset) params.set("offset", String(opts.offset)); + if (opts?.search) params.set("search", opts.search); + const qs = params.toString(); + const res = await apiClient.get<{ + success: boolean; + data: PlayerHistoryResponse; + }>(`/api/servers/${serverId}/players/history${qs ? `?${qs}` : ""}`); + return res.data.data; + }, + enabled: serverId > 0, + }); +} + +export function useServerBans(serverId: number) { + return useQuery({ + queryKey: ["bans", serverId], + queryFn: async () => { + const res = await apiClient.get<{ success: boolean; data: Ban[] }>( + `/api/servers/${serverId}/bans`, + ); + return res.data.data; + }, + enabled: serverId > 0, + }); +} + +export function useServerMissions(serverId: number) { + return useQuery({ + queryKey: ["missions", serverId], + queryFn: async () => { + const res = await apiClient.get<{ + success: boolean; + data: MissionsResponse; + }>(`/api/servers/${serverId}/missions`); + return res.data.data; + }, + enabled: serverId > 0, + }); +} + +export function useServerMods(serverId: number) { + return useQuery({ + queryKey: ["mods", serverId], + queryFn: async () => { + const res = await apiClient.get<{ + success: boolean; + data: ModsResponse; + }>(`/api/servers/${serverId}/mods`); + return res.data.data; + }, + enabled: serverId > 0, + }); +} + +// ── Mutation Hooks ───────────────────────────────────────────────────── + +export function useUpdateConfigSection(serverId: number, section: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: Record) => + apiClient.put(`/api/servers/${serverId}/config/${section}`, data), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["servers", serverId, "config", section], + }); + queryClient.invalidateQueries({ + queryKey: ["servers", serverId, "config"], + }); + }, + }); +} + +export function useCreateBan(serverId: number) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: CreateBanRequest) => + apiClient.post(`/api/servers/${serverId}/bans`, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["bans", serverId] }); + }, + }); +} + +export function useRevokeBan(serverId: number) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (banId: number) => + apiClient.delete(`/api/servers/${serverId}/bans/${banId}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["bans", serverId] }); + }, + }); +} + +export function useUploadMission(serverId: number) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (file: File) => { + const formData = new FormData(); + formData.append("file", file); + return apiClient.post(`/api/servers/${serverId}/missions`, formData, { + headers: { "Content-Type": "multipart/form-data" }, + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["missions", serverId] }); + }, + }); +} + +export function useDeleteMission(serverId: number) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (filename: string) => + apiClient.delete( + `/api/servers/${serverId}/missions/${encodeURIComponent(filename)}`, + ), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["missions", serverId] }); + }, + }); +} + +export function useSetEnabledMods(serverId: number) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (mods: string[]) => + apiClient.put(`/api/servers/${serverId}/mods/enabled`, { mods }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["mods", serverId] }); + }, + }); +} + +export function useSendCommand(serverId: number) { + return useMutation({ + mutationFn: (command: string) => + apiClient.post(`/api/servers/${serverId}/rcon/command`, { command }), + }); +} \ No newline at end of file diff --git a/frontend/src/hooks/useServers.ts b/frontend/src/hooks/useServers.ts index 8f58285..2ee5d6c 100644 --- a/frontend/src/hooks/useServers.ts +++ b/frontend/src/hooks/useServers.ts @@ -4,14 +4,25 @@ import { apiClient } from "@/lib/api"; export interface Server { id: number; name: string; + description: string | null; game_type: string; status: "stopped" | "running" | "starting" | "restarting" | "crashed"; - port: number; + pid: number | null; + exe_path: string; + game_port: number; + rcon_port: number | null; max_players: number; current_players: number; - restart_count: number; auto_restart: boolean; + max_restarts: number; + restart_count: number; + last_restart_at: string | null; + started_at: string | null; + stopped_at: string | null; created_at: string; + updated_at: string; + cpu_percent: number | null; + ram_mb: number | null; } export function useServers() { @@ -83,6 +94,18 @@ export function useCreateServer() { }); } +export function useUpdateServer(serverId: number) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: Record) => + apiClient.put(`/api/servers/${serverId}`, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["servers", serverId] }); + queryClient.invalidateQueries({ queryKey: ["servers"] }); + }, + }); +} + export function useDeleteServer() { const queryClient = useQueryClient(); return useMutation({ @@ -92,4 +115,16 @@ export function useDeleteServer() { queryClient.invalidateQueries({ queryKey: ["servers"] }); }, }); +} + +export function useKillServer() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (serverId: number) => + apiClient.post(`/api/servers/${serverId}/kill`), + onSuccess: (_, serverId) => { + queryClient.invalidateQueries({ queryKey: ["servers", serverId] }); + queryClient.invalidateQueries({ queryKey: ["servers"] }); + }, + }); } \ No newline at end of file diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts index 0a8411d..12d05d5 100644 --- a/frontend/src/hooks/useWebSocket.ts +++ b/frontend/src/hooks/useWebSocket.ts @@ -1,6 +1,7 @@ import { useEffect, useRef, useCallback } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { useAuthStore } from "@/store/auth.store"; +import { logger } from "@/lib/logger"; const WS_BASE = import.meta.env.VITE_WS_URL ?? "ws://localhost:8000"; const RECONNECT_BASE_MS = 2000; @@ -12,7 +13,17 @@ interface WebSocketEvent { data: unknown; } -export function useWebSocket(serverIds?: number[]) { +interface UseWebSocketOptions { + serverIds?: number[]; + onEvent?: (event: WebSocketEvent) => void; +} + +export function useWebSocket(opts?: UseWebSocketOptions | number[]) { + // Support legacy API: useWebSocket(serverIds) + const options = Array.isArray(opts) ? { serverIds: opts } : (opts ?? {}); + const serverIds = options.serverIds; + const onEvent = options.onEvent; + const queryClient = useQueryClient(); const { token } = useAuthStore(); const wsRef = useRef(null); @@ -39,15 +50,17 @@ export function useWebSocket(serverIds?: number[]) { queryClient.invalidateQueries({ queryKey: ["players", server_id] }); break; } + + onEvent?.(event); }, - [queryClient], + [queryClient, onEvent], ); const connect = useCallback(() => { if (!token || unmountedRef.current) return; const params = new URLSearchParams({ token }); - if (serverIds) { + if (serverIds && serverIds.length > 0) { serverIds.forEach((id) => params.append("server_id", String(id))); } @@ -64,7 +77,7 @@ export function useWebSocket(serverIds?: number[]) { const event: WebSocketEvent = JSON.parse(e.data); handleEvent(event); } catch { - // Ignore unparseable messages + logger.debug("WebSocket", "Failed to parse message: %s", e.data); } }; @@ -80,7 +93,7 @@ export function useWebSocket(serverIds?: number[]) { ws.onerror = () => { ws.close(); }; - }, [token, serverIds, handleEvent]); + }, [token, serverIds, onEvent, handleEvent]); useEffect(() => { unmountedRef.current = false; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index cc26dec..10a7607 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -20,8 +20,12 @@ apiClient.interceptors.response.use( (response) => response, (error) => { if (error.response?.status === 401) { - localStorage.removeItem("languard_token"); - window.location.href = "/login"; + const url = error.config?.url ?? ""; + // Don't redirect on auth endpoints — let the calling component handle the error + if (!url.startsWith("/api/auth/")) { + localStorage.removeItem("languard_token"); + window.location.href = "/login"; + } } return Promise.reject(error); }, diff --git a/frontend/src/lib/logger.ts b/frontend/src/lib/logger.ts new file mode 100644 index 0000000..b300bee --- /dev/null +++ b/frontend/src/lib/logger.ts @@ -0,0 +1,54 @@ +/** + * Lightweight logger for the frontend. + * + * In development: logs to console with level-appropriate methods. + * In production: can be swapped for an error reporting service (Sentry, etc.) + * by replacing the transport in logger.ts. + */ + +type LogLevel = "debug" | "info" | "warn" | "error"; + +const LOG_LEVELS: Record = { + debug: 0, + info: 1, + warn: 2, + error: 3, +}; + +const currentLevel: LogLevel = + (import.meta.env.VITE_LOG_LEVEL as LogLevel) ?? + (import.meta.env.DEV ? "debug" : "warn"); + +function shouldLog(level: LogLevel): boolean { + return LOG_LEVELS[level] >= LOG_LEVELS[currentLevel]; +} + +function formatMessage(level: LogLevel, context: string, message: string): string { + return `[${level.toUpperCase()}] [${context}] ${message}`; +} + +export const logger = { + debug(context: string, message: string, ...args: unknown[]) { + if (shouldLog("debug")) { + console.debug(formatMessage("debug", context, message), ...args); + } + }, + + info(context: string, message: string, ...args: unknown[]) { + if (shouldLog("info")) { + console.info(formatMessage("info", context, message), ...args); + } + }, + + warn(context: string, message: string, ...args: unknown[]) { + if (shouldLog("warn")) { + console.warn(formatMessage("warn", context, message), ...args); + } + }, + + error(context: string, message: string, ...args: unknown[]) { + if (shouldLog("error")) { + console.error(formatMessage("error", context, message), ...args); + } + }, +}; \ No newline at end of file diff --git a/frontend/src/pages/CreateServerPage.tsx b/frontend/src/pages/CreateServerPage.tsx new file mode 100644 index 0000000..87252e7 --- /dev/null +++ b/frontend/src/pages/CreateServerPage.tsx @@ -0,0 +1,297 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { ChevronRight, ChevronLeft } from "lucide-react"; + +import { useCreateServer, type Server } from "@/hooks/useServers"; +import { useGamesList } from "@/hooks/useGames"; +import { useAuthStore } from "@/store/auth.store"; +import { useUIStore } from "@/store/ui.store"; +import { logger } from "@/lib/logger"; + +const createServerSchema = z.object({ + name: z.string().min(1, "Server name is required"), + description: z.string().optional(), + game_type: z.string().min(1, "Game type is required"), + exe_path: z.string().min(1, "Executable path is required"), + game_port: z.number({ coerce: true }).min(1024, "Port must be >= 1024").max(65535, "Port must be <= 65535"), + rcon_port: z.number({ coerce: true }).min(1024).max(65535).nullable().optional(), + auto_restart: z.boolean().optional(), + max_restarts: z.number({ coerce: true }).min(0).max(20).optional(), +}); + +type CreateServerForm = z.infer; + +const STEPS = ["Game Type", "Server Info", "Options", "Review"]; + +export function CreateServerPage() { + const navigate = useNavigate(); + const isAdmin = useAuthStore((s) => s.user?.role === "admin"); + const addNotification = useUIStore((s) => s.addNotification); + const createServer = useCreateServer(); + const { data: games } = useGamesList(); + + const [step, setStep] = useState(0); + + const { + register, + handleSubmit, + watch, + setValue, + formState: { errors }, + } = useForm({ + resolver: zodResolver(createServerSchema), + defaultValues: { + name: "", + description: "", + game_type: "arma3", + exe_path: "", + game_port: 2302, + rcon_port: null, + auto_restart: false, + max_restarts: 3, + }, + }); + + // Redirect non-admin users + if (!isAdmin) { + return ( +
+

Access Denied

+

Only admins can create servers.

+
+ ); + } + + const onSubmit = async (data: CreateServerForm) => { + try { + // Clean up NaN / empty values before sending to API + const payload: Record = { + name: data.name, + game_type: data.game_type, + exe_path: data.exe_path, + game_port: data.game_port, + auto_restart: data.auto_restart ?? false, + max_restarts: data.max_restarts ?? 3, + }; + if (data.description) payload.description = data.description; + if (data.rcon_port != null && !Number.isNaN(data.rcon_port)) { + payload.rcon_port = data.rcon_port; + } + + const result = await createServer.mutateAsync(payload as Partial); + addNotification({ type: "success", message: `Server ${data.name} created` }); + const newId = (result as { data: { id: number } }).data?.id; + if (newId) { + navigate(`/servers/${newId}`); + } else { + navigate("/"); + } + } catch (err) { + logger.error("CreateServerPage", "Failed to create server: %s", err); + const detail = + (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ?? + "Failed to create server"; + addNotification({ + type: "error", + message: typeof detail === "string" ? detail : "Failed to create server", + }); + } + }; + + const watchedGameType = watch("game_type"); + const gameOptions = games ?? []; + + return ( +
+

Create Server

+ + {/* Step indicator */} +
+ {STEPS.map((label, idx) => ( +
+
+ {idx + 1} +
+ + {idx < STEPS.length - 1 && ( +
+ )} +
+ ))} +
+ +
+ {/* Step 0: Game Type */} + {step === 0 && ( +
+
+ + +
+

+ Selected: {watchedGameType} +

+
+ )} + + {/* Step 1: Server Info */} + {step === 1 && ( +
+
+ + + {errors.name &&

{errors.name.message}

} +
+ +
+ + +
+ +
+ + + {errors.exe_path &&

{errors.exe_path.message}

} +
+ +
+
+ + + {errors.game_port &&

{errors.game_port.message}

} +
+ +
+ + + {errors.rcon_port &&

{errors.rcon_port.message}

} +
+
+
+ )} + + {/* Step 2: Options */} + {step === 2 && ( +
+
+ + +
+ +
+ + +

Maximum automatic restarts within the restart window

+
+
+ )} + + {/* Step 3: Review */} + {step === 3 && ( +
+ {Object.keys(errors).length > 0 && ( +
+

Please fix validation errors before creating:

+
    + {Object.entries(errors).map(([field, err]) => ( +
  • {field}: {err.message}
  • + ))} +
+
+ )} +

Review Configuration

+ + + + + + + + +
+ )} + + {/* Navigation buttons */} +
+ {step > 0 ? ( + + ) : ( +
+ )} + + {step < STEPS.length - 1 ? ( + + ) : ( + + )} +
+ +
+ ); +} + +function ReviewItem({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 41ab0bb..bdb2fe8 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -6,6 +6,7 @@ import { z } from "zod"; import { apiClient } from "@/lib/api"; import { useAuthStore } from "@/store/auth.store"; +import { logger } from "@/lib/logger"; const loginSchema = z.object({ username: z.string().min(1, "Username is required"), @@ -36,6 +37,7 @@ export function LoginPage() { setAuth(res.data.data.access_token, res.data.data.user); navigate("/"); } catch (err: unknown) { + logger.error("LoginPage", "Login failed: %s", err); const message = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ?? "Login failed"; diff --git a/frontend/src/pages/ServerDetailPage.tsx b/frontend/src/pages/ServerDetailPage.tsx new file mode 100644 index 0000000..9f301e1 --- /dev/null +++ b/frontend/src/pages/ServerDetailPage.tsx @@ -0,0 +1,148 @@ +import { useParams } from "react-router-dom"; +import { useState, useCallback, useRef } from "react"; +import clsx from "clsx"; + +import { useServer } from "@/hooks/useServers"; +import { useWebSocket } from "@/hooks/useWebSocket"; +import { useAuthStore } from "@/store/auth.store"; +import { ServerHeader } from "@/components/servers/ServerHeader"; +import { ConfigEditor } from "@/components/servers/ConfigEditor"; +import { PlayerTable } from "@/components/servers/PlayerTable"; +import { BanTable } from "@/components/servers/BanTable"; +import { MissionList } from "@/components/servers/MissionList"; +import { ModList } from "@/components/servers/ModList"; +import { LogViewer } from "@/components/servers/LogViewer"; +import { StatusLed } from "@/components/ui/StatusLed"; + +type Tab = "overview" | "config" | "players" | "bans" | "missions" | "mods" | "logs"; + +interface LogEntry { + timestamp: string; + level: "info" | "warning" | "error"; + message: string; +} + +const MAX_LOG_ENTRIES = 500; + +const TABS: { id: Tab; label: string; adminOnly: boolean }[] = [ + { id: "overview", label: "Overview", adminOnly: false }, + { id: "config", label: "Config", adminOnly: false }, + { id: "players", label: "Players", adminOnly: false }, + { id: "bans", label: "Bans", adminOnly: false }, + { id: "missions", label: "Missions", adminOnly: false }, + { id: "mods", label: "Mods", adminOnly: false }, + { id: "logs", label: "Logs", adminOnly: false }, +]; + +export function ServerDetailPage() { + const { serverId } = useParams(); + const id = parseInt(serverId ?? "0", 10); + const { data: server, isLoading, isError } = useServer(id); + const isAdmin = useAuthStore((s) => s.user?.role === "admin"); + const [activeTab, setActiveTab] = useState("overview"); + const [logs, setLogs] = useState([]); + + const handleWsEvent = useCallback((event: { type: string; server_id: number | null; data: unknown }) => { + if (event.type === "log" && event.server_id === id) { + const entry = event.data as LogEntry; + if (entry && entry.message) { + setLogs((prev) => [...prev.slice(-(MAX_LOG_ENTRIES - 1)), entry]); + } + } + }, [id]); + + useWebSocket({ serverIds: id > 0 ? [id] : undefined, onEvent: handleWsEvent }); + + const visibleTabs = TABS.filter((tab) => !tab.adminOnly || isAdmin); + + if (isLoading) { + return
Loading server...
; + } + + if (isError || !server) { + return ( +
+

Server not found

+

The requested server could not be loaded.

+
+ ); + } + + return ( +
+ + +
+ {visibleTabs.map((tab) => ( + + ))} +
+ +
+ {activeTab === "overview" && } + {activeTab === "config" && } + {activeTab === "players" && } + {activeTab === "bans" && } + {activeTab === "missions" && } + {activeTab === "mods" && } + {activeTab === "logs" && } +
+
+ ); +} + +function OverviewTab({ serverId }: { serverId: number }) { + const { data: server } = useServer(serverId); + + if (!server) return null; + + return ( +
+
+ +

{server.name}

+ {server.description && ( + — {server.description} + )} +
+ +
+ + + + + + + + +
+ + {server.exe_path && ( +
+

Executable

+

{server.exe_path}

+
+ )} +
+ ); +} + +function StatCard({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx new file mode 100644 index 0000000..85e3f58 --- /dev/null +++ b/frontend/src/pages/SettingsPage.tsx @@ -0,0 +1,49 @@ +import { useState } from "react"; +import clsx from "clsx"; + +import { useAuthStore } from "@/store/auth.store"; +import { PasswordChange } from "@/components/settings/PasswordChange"; +import { UserManager } from "@/components/settings/UserManager"; + +type Tab = "account" | "users"; + +export function SettingsPage() { + const user = useAuthStore((s) => s.user); + const isAdmin = user?.role === "admin"; + const [activeTab, setActiveTab] = useState("account"); + + const tabs: { id: Tab; label: string; adminOnly: boolean }[] = [ + { id: "account", label: "Account", adminOnly: false }, + { id: "users", label: "User Management", adminOnly: true }, + ]; + + const visibleTabs = tabs.filter((tab) => !tab.adminOnly || isAdmin); + + return ( +
+

Settings

+ + {isAdmin && ( +
+ {visibleTabs.map((tab) => ( + + ))} +
+ )} + + {activeTab === "account" && } + {activeTab === "users" && isAdmin && } +
+ ); +} \ No newline at end of file diff --git a/frontend/tests-e2e/integration/fullstack.spec.ts b/frontend/tests-e2e/integration/fullstack.spec.ts new file mode 100644 index 0000000..7ee8d94 --- /dev/null +++ b/frontend/tests-e2e/integration/fullstack.spec.ts @@ -0,0 +1,146 @@ +import { test, expect } from "@playwright/test"; + +/** + * Full-stack integration tests against the real backend (http://localhost:8000). + * These tests do NOT mock API routes — they exercise the real API + frontend. + * + * Prerequisites: + * - Backend running on port 8000 + * - Frontend dev server running on port 5173 + * - Admin account with username "admin" / password "admin123" + * - A3Master server record exists + */ + +const REAL_API = "http://localhost:8000"; + +test.describe("Full Stack Integration", () => { + test("should login and see A3Master server on dashboard", async ({ page }) => { + // Navigate to login page + await page.goto("/login"); + await expect(page.locator('[data-testid="login-card"]')).toBeVisible(); + + // Fill in real credentials + await page.locator('[data-testid="login-username"]').fill("admin"); + await page.locator('[data-testid="login-password"]').fill("admin123"); + await page.locator('[data-testid="login-submit"]').click(); + + // Should navigate to dashboard + await page.waitForURL("/", { timeout: 10_000 }); + + // Dashboard should render + await expect(page.locator('[data-testid="dashboard-content"]')).toBeVisible({ + timeout: 10_000, + }); + + // Should show at least one server (A3Master) — use .first() to avoid strict mode + await expect(page.locator("text=A3Master").first()).toBeVisible({ timeout: 10_000 }); + + // Should show server count + const serverCount = await page.locator("[data-testid^='server-card-']").count(); + expect(serverCount).toBeGreaterThanOrEqual(1); + }); + + test("should show A3Master server details in card", async ({ page }) => { + // Login via API and set auth state + const loginRes = await page.request.post(`${REAL_API}/api/auth/login`, { + data: { username: "admin", password: "admin123" }, + }); + expect(loginRes.ok()).toBe(true); + const loginData = await loginRes.json(); + const token = loginData.data.access_token; + const user = loginData.data.user; + + // Set auth state in localStorage + await page.addInitScript( + ({ token, user }) => { + localStorage.setItem("languard_token", token); + localStorage.setItem( + "languard-auth", + JSON.stringify({ state: { token, user }, version: 0 }), + ); + }, + { token, user }, + ); + + await page.goto("/"); + await expect(page.locator('[data-testid="dashboard-content"]')).toBeVisible({ + timeout: 10_000, + }); + + // Server card should show the A3Master name + await expect(page.locator("text=A3Master").first()).toBeVisible({ timeout: 10_000 }); + + // Server card should show arma3 game type + await expect(page.locator("text=arma3").first()).toBeVisible({ timeout: 5_000 }); + }); + + test("should navigate to server detail page", async ({ page }) => { + // Login via API + const loginRes = await page.request.post(`${REAL_API}/api/auth/login`, { + data: { username: "admin", password: "admin123" }, + }); + const loginData = await loginRes.json(); + const token = loginData.data.access_token; + const user = loginData.data.user; + + await page.addInitScript( + ({ token, user }) => { + localStorage.setItem("languard_token", token); + localStorage.setItem( + "languard-auth", + JSON.stringify({ state: { token, user }, version: 0 }), + ); + }, + { token, user }, + ); + + await page.goto("/"); + await expect(page.locator('[data-testid="dashboard-content"]')).toBeVisible({ + timeout: 10_000, + }); + + // Click the A3Master server card link in the dashboard content area + const serverLink = page.locator('[data-testid="dashboard-content"] a[href="/servers/1"]'); + await serverLink.click(); + + // Should navigate to server detail + await expect(page).toHaveURL(/\/servers\/1/, { timeout: 5_000 }); + }); + + test("should redirect to login when unauthenticated", async ({ page }) => { + // Navigate to dashboard without auth — should redirect to login + await page.goto("/"); + await expect(page).toHaveURL(/\/login/, { timeout: 10_000 }); + await expect(page.locator('[data-testid="login-card"]')).toBeVisible(); + }); + + test("should authenticate and check server API response shape", async ({ page }) => { + // Login via API to verify backend response format + const loginRes = await page.request.post(`${REAL_API}/api/auth/login`, { + data: { username: "admin", password: "admin123" }, + }); + expect(loginRes.ok()).toBe(true); + const loginData = await loginRes.json(); + expect(loginData.success).toBe(true); + expect(loginData.data.access_token).toBeDefined(); + expect(loginData.data.user.username).toBe("admin"); + expect(loginData.data.user.role).toBe("admin"); + + // Fetch servers via API + const token = loginData.data.access_token; + const serversRes = await page.request.get(`${REAL_API}/api/servers`, { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(serversRes.ok()).toBe(true); + const serversData = await serversRes.json(); + expect(serversData.success).toBe(true); + expect(Array.isArray(serversData.data)).toBe(true); + expect(serversData.data.length).toBeGreaterThanOrEqual(1); + + // Verify A3Master server has expected fields + const a3master = serversData.data.find((s: { name: string }) => s.name === "A3Master"); + expect(a3master).toBeDefined(); + expect(a3master.game_type).toBe("arma3"); + expect(a3master.exe_path).toContain("A3Master"); + }); +}); \ No newline at end of file