feat: multi-game adapter revamp, council protocol merge, and frontend design doc

- Revamp architecture for modular game server support (Arma 3 first, extensible)
- Merge ConfigSchema into ConfigGenerator per council decision (8→7 protocols)
- Add has_capability() method to GameAdapter protocol for explicit capability probing
- Add FRONTEND.md: production-grade dark neumorphism design with amber/orange palette
- Update all docs (ARCHITECTURE, MODULES, DATABASE, API, IMPLEMENTATION_PLAN, THREADING)
  to reflect protocol merge and multi-game adapter patterns
This commit is contained in:
Tran G. (Revernomad) Khoa
2026-04-16 17:05:04 +07:00
parent 2c72e45b5f
commit 624d7594e2
7 changed files with 3723 additions and 1466 deletions

353
API.md
View File

@@ -6,7 +6,7 @@ http://localhost:8000/api
```
## Authentication
- All endpoints except `POST /auth/login` require: `Authorization: Bearer <JWT>`
- All endpoints except `POST /auth/login` and `GET /system/health` require: `Authorization: Bearer <JWT>`
- WebSocket: pass token as query param: `ws://localhost:8000/ws/{server_id}?token=<JWT>`
- JWT payload: `{ "sub": "user_id", "username": "string", "role": "admin|viewer", "exp": timestamp }`
@@ -39,11 +39,26 @@ Error response:
| 400 | Validation error |
| 401 | Unauthenticated |
| 403 | Forbidden (insufficient role) |
| 404 | Not found |
| 404 | Not found (or capability not supported by adapter) |
| 409 | Conflict (already running, duplicate) |
| 422 | Unprocessable (Pydantic validation) |
| 500 | Internal server error |
## Capability-Based Routing
Some endpoints depend on the server's game adapter supporting a specific capability. If the adapter does not support the capability, the endpoint returns **404** with a clear message:
```json
{
"success": false,
"data": null,
"error": {
"code": "CAPABILITY_NOT_SUPPORTED",
"message": "Missions not supported for game type 'rust'"
}
}
```
---
## Auth Endpoints
@@ -101,12 +116,88 @@ Delete user. Admin only.
---
## Game Type Discovery Endpoints
### GET /games
List all registered game types and their capabilities.
**Response 200:**
```json
{
"success": true,
"data": [
{
"game_type": "arma3",
"display_name": "Arma 3",
"version": "1.0.0",
"capabilities": ["config_generator", "process_config",
"log_parser", "remote_admin", "mission_manager", "mod_manager", "ban_manager"]
}
]
}
```
### GET /games/{game_type}
Get details for a specific game type.
**Response 200:**
```json
{
"success": true,
"data": {
"game_type": "arma3",
"display_name": "Arma 3",
"version": "1.0.0",
"capabilities": ["config_generator", "process_config",
"log_parser", "remote_admin", "mission_manager", "mod_manager", "ban_manager"],
"config_sections": ["server", "basic", "profile", "launch", "rcon"],
"allowed_executables": ["arma3server_x64.exe", "arma3server.exe"]
}
}
```
### GET /games/{game_type}/config-schema
Returns JSON Schema for each config section defined by the adapter. Used by frontend to build dynamic config forms.
**Response 200:**
```json
{
"success": true,
"data": {
"server": { /* JSON Schema for server.cfg params */ },
"basic": { /* JSON Schema for basic.cfg params */ },
"profile": { /* JSON Schema for Arma3Profile params */ },
"launch": { /* JSON Schema for launch params */ },
"rcon": { /* JSON Schema for RCon params */ }
}
}
```
### GET /games/{game_type}/defaults
Default config values for new server creation.
**Response 200:**
```json
{
"success": true,
"data": {
"server": { "hostname": "My Arma 3 Server", "max_players": 40, ... },
"basic": { "min_bandwidth": 800000, ... },
"profile": { "reduced_damage": 0, ... },
"launch": { "world": "empty", "limit_fps": 50, ... },
"rcon": { "max_ping": 200, "enabled": 1 }
}
}
```
---
## Server Endpoints
### GET /servers
List all servers with current status. Supports pagination.
List all servers with current status. Supports filtering by game type.
**Query params:** `?limit=50&offset=0`
**Query params:** `?limit=50&offset=0&game_type=arma3`
**Response 200:**
```json
@@ -117,32 +208,30 @@ List all servers with current status. Supports pagination.
"id": 1,
"name": "Main Server",
"description": "Primary COOP server",
"game_type": "arma3",
"status": "running",
"pid": 12345,
"game_port": 2302,
"rcon_port": 2306,
"player_count": 15,
"max_players": 40,
"current_mission": "MyMission.Altis",
"uptime_seconds": 3600,
"cpu_percent": 34.2,
"ram_mb": 1850.5,
"started_at": "2026-04-16T10:00:00Z"
// current_mission: computed from RCon 'players' response or mission_rotation + server status
// uptime_seconds: computed as (now - started_at) in the service layer
}
]
}
```
### POST /servers
Create a new server. Admin only.
Create a new server. Admin only. `game_type` determines which adapter handles this server.
**Request:**
```json
{
"name": "Main Server",
"description": "Primary COOP server",
"game_type": "arma3",
"exe_path": "C:/Arma3Server/arma3server_x64.exe",
"game_port": 2302,
"rcon_port": 2306,
@@ -150,7 +239,9 @@ Create a new server. Admin only.
"max_restarts": 3
}
```
**Note:** `password_admin` is auto-generated if not provided in the request. The generated value is returned in the response (shown once — not stored in plaintext API responses after creation). `rcon_password` is also auto-generated if not provided.
The adapter provides default config values for all sections. Auto-generated credentials (e.g., `password_admin`, `rcon_password` for Arma 3) are returned in the response and not stored in plaintext.
**Response 201:** Returns full server object including auto-generated credentials.
### GET /servers/{server_id}
@@ -163,6 +254,7 @@ Get server detail with full status.
"data": {
"id": 1,
"name": "Main Server",
"game_type": "arma3",
"status": "running",
"pid": 12345,
"game_port": 2302,
@@ -174,8 +266,7 @@ Get server detail with full status.
"cpu_percent": 34.2,
"ram_mb": 1850.5,
"started_at": "2026-04-16T10:00:00Z",
"uptime_seconds": 3600,
"current_mission": "MyMission.Altis"
"uptime_seconds": 3600
}
}
```
@@ -187,7 +278,7 @@ Update server metadata (name, description, exe_path, ports). Admin only.
Delete server (must be stopped first). Admin only. Removes DB rows and `servers/{id}/` directory.
### POST /servers/{server_id}/start
Start the server. Admin only.
Start the server. Admin only. Core resolves the adapter and delegates config generation + launch arg building.
**Response 200:**
```json
@@ -196,7 +287,7 @@ Start the server. Admin only.
**Response 409:** Server already running.
### POST /servers/{server_id}/stop
Graceful stop (send `#shutdown` via RCon, then kill after 30s). Admin only.
Graceful stop (sends shutdown via adapter's RemoteAdmin, then force-kill after 30s). Admin only.
**Request (optional):**
```json
@@ -213,28 +304,46 @@ Force-kill the process immediately. Admin only. Emergency use only.
## Server Config Endpoints
Config sections are defined by the server's game adapter. The core `game_configs` table stores them as JSON. The adapter's Pydantic models validate input.
### GET /servers/{server_id}/config
Get all config sections combined.
Get all config sections combined. Each section includes its `config_version` for optimistic locking.
**Response 200:**
```json
{
"success": true,
"data": {
"server": { /* server_configs row */ },
"basic": { /* basic_configs row */ },
"profile": { /* server_profiles row */ },
"launch": { /* launch_params row */ },
"rcon": { "rcon_password": "***", "max_ping": 200, "enabled": true }
"server": { /* section JSON */, "_meta": {"config_version": 3, "schema_version": "1.0"} },
"basic": { /* section JSON */, "_meta": {"config_version": 1, "schema_version": "1.0"} },
"profile": { /* section JSON */, "_meta": {"config_version": 2, "schema_version": "1.0"} },
"launch": { /* section JSON */, "_meta": {"config_version": 1, "schema_version": "1.0"} },
"rcon": { "rcon_password": "***", "max_ping": 200, "enabled": 1, "_meta": {"config_version": 1, "schema_version": "1.0"} }
}
}
```
### PUT /servers/{server_id}/config/server
Update server.cfg settings. Admin only.
### GET /servers/{server_id}/config/{section}
Get a single config section. Section names are defined by the adapter (e.g., `server`, `basic`, `profile`, `launch`, `rcon` for Arma 3).
**Request:** Partial object matching `server_configs` columns (snake_case). Any omitted field keeps current value.
**Response 200:**
```json
{
"success": true,
"data": {
"hostname": "My Server",
"max_players": 40,
"_meta": { "config_version": 3, "schema_version": "1.0" }
}
}
```
### PUT /servers/{server_id}/config/{section}
Update a config section. Admin only. Validated against the adapter's Pydantic model for that section. Sensitive fields (passwords) are encrypted before storage. **Optimistic locking** — client must send `config_version` from their last read.
**Request:** Partial object matching the adapter's section schema. Any omitted field keeps current value. Must include `config_version` for conflict detection.
**Arma 3 `server` section example:**
```json
{
"hostname": "Updated Server Name",
@@ -242,13 +351,27 @@ Update server.cfg settings. Admin only.
"battleye": 1,
"verify_signatures": 2,
"motd_lines": ["Welcome!", "Have fun"],
"motd_interval": 5.0
"motd_interval": 5.0,
"config_version": 3
}
```
### PUT /servers/{server_id}/config/basic
Update basic.cfg (bandwidth) settings. Admin only.
**Response 409 (Conflict):** Another admin updated this section since you read it.
```json
{
"success": false,
"data": {
"current_config": { /* latest values */ },
"current_version": 5
},
"error": {
"code": "CONFIG_VERSION_CONFLICT",
"message": "Config section 'server' was modified by another user. Re-read and merge your changes."
}
}
```
**Arma 3 `basic` section example:**
```json
{
"max_bandwidth": 50000000,
@@ -256,9 +379,7 @@ Update basic.cfg (bandwidth) settings. Admin only.
}
```
### PUT /servers/{server_id}/config/profile
Update difficulty profile. Admin only.
**Arma 3 `profile` section example:**
```json
{
"third_person_view": 0,
@@ -269,28 +390,17 @@ Update difficulty profile. Admin only.
}
```
### PUT /servers/{server_id}/config/launch
Update launch parameters. Admin only.
**Arma 3 `launch` section example:**
```json
{
"world": "empty",
"limit_fps": 50,
"auto_init": false,
"load_mission_to_memory": true,
"bandwidth_alg": 2,
"enable_ht": true,
"huge_pages": false
"load_mission_to_memory": true
}
```
### PUT /servers/{server_id}/config/rcon
Update BattlEye RCon settings. Admin only.
Regenerates `battleye/beserver.cfg` immediately. **Note:** BattlEye reads beserver.cfg only at server startup — RCon config changes require a server restart to take effect. The updated config file is ready for the next start.
**Note on `rcon_port`:** This field is stored in the `servers` table (not `rcon_configs`).
The service layer updates both tables as needed. Include only fields you want to change.
**Arma 3 `rcon` section example:**
```json
{
"rcon_password": "newpassword",
@@ -300,20 +410,36 @@ The service layer updates both tables as needed. Include only fields you want to
}
```
### GET /servers/{server_id}/config/preview
Returns rendered `server.cfg` as plain text string (for preview in UI). **Admin only** — contains plaintext credentials.
**Note:** `rcon_port` is stored in the `servers` table, not in the config JSON. The service layer updates both tables as needed.
**Response 200:** `Content-Type: text/plain`
### GET /servers/{server_id}/config/preview
Returns rendered config for preview in UI. **Admin only** — may contain plaintext credentials. Returns a dict of `{label: rendered_content}` — labels are filenames for file-based configs, variable names for env-var configs, or argument names for CLI configs.
**Response 200:**
```json
{
"success": true,
"data": {
"server.cfg": "// Generated server.cfg\nhostname = \"My Server\";\n...",
"basic.cfg": "// Generated basic.cfg\n...",
"server.Arma3Profile": "// Generated profile\n..."
}
}
```
(Non-file games would return e.g. `{"SERVER_NAME": "My Server", "MAX_PLAYERS": "40"}` for env-var configs.)
### GET /servers/{server_id}/config/download/{filename}
Download generated config file. Filename must be one of: `server.cfg` | `basic.cfg` | `server.Arma3Profile` (whitelist-validated, no path traversal). **Admin only** — config files contain plaintext passwords.
Download generated config file. Filename must be in adapter's allowlist (whitelist-validated, no path traversal). **Admin only**.
---
## Mission Endpoints
## Mission Endpoints (Capability: `mission_manager`)
Returns **404** if adapter does not support `mission_manager`.
### GET /servers/{server_id}/missions
List all mission PBOs for a server.
List all mission/scenario files for a server.
**Response 200:**
```json
@@ -333,10 +459,10 @@ List all mission PBOs for a server.
```
### POST /servers/{server_id}/missions/upload
Upload a mission PBO. Admin only. `multipart/form-data`.
Upload a mission/scenario file. Admin only. `multipart/form-data`. File extension validated by adapter's `MissionManager.file_extension`.
**Form fields:**
- `file`: the `.pbo` file (filename is sanitized with `os.path.basename()` to prevent path traversal; only `.pbo` extension allowed)
- `file`: the mission file (filename sanitized; only adapter-allowed extensions accepted)
**Response 201:**
```json
@@ -353,10 +479,10 @@ Upload a mission PBO. Admin only. `multipart/form-data`.
```
### DELETE /servers/{server_id}/missions/{mission_id}
Delete a mission PBO (removes file from disk). Admin only.
Delete a mission file (removes file from disk). Admin only.
### GET /servers/{server_id}/missions/rotation
Get current mission rotation (ordered list).
Get current mission/scenario rotation (ordered list).
**Response 200:**
```json
@@ -368,7 +494,7 @@ Get current mission rotation (ordered list).
"sort_order": 0,
"mission": { "id": 1, "mission_name": "MyMission.Altis" },
"difficulty": "Regular",
"params": { "RespawnDelay": 15 }
"params_json": { "RespawnDelay": 15 }
}
]
}
@@ -388,16 +514,21 @@ Replace the entire mission rotation. Admin only.
---
## Mod Endpoints
## Mod Endpoints (Capability: `mod_manager`)
Returns **404** if adapter does not support `mod_manager`.
### GET /mods
List all registered mods (global list).
List all registered mods. Optionally filter by game type.
**Query params:** `?game_type=arma3`
### POST /mods
Register a mod folder. Admin only.
```json
{
"game_type": "arma3",
"name": "@CBA_A3",
"folder_path": "C:/Arma3Server/@CBA_A3",
"workshop_id": "450814997",
@@ -452,26 +583,26 @@ Get currently connected players.
"success": true,
"data": [
{
"player_num": 1,
"slot_id": "1",
"name": "PlayerOne",
"guid": "abc123...",
"ping": 45,
"verified": true,
"game_data": { "verified": true, "steam_uid": "76561198..." },
"joined_at": "2026-04-16T10:15:00Z"
}
]
}
```
### POST /servers/{server_id}/players/{player_num}/kick
Kick a player via RCon. Admin only.
### POST /servers/{server_id}/players/{slot_id}/kick
Kick a player. Admin only. Requires adapter `remote_admin` capability.
```json
{ "reason": "AFK" }
```
### POST /servers/{server_id}/players/{player_num}/ban
Ban a player via RCon. Admin only.
### POST /servers/{server_id}/players/{slot_id}/ban
Ban a player. Admin only. Requires adapter `remote_admin` capability.
```json
{
@@ -495,12 +626,11 @@ List all bans for a server.
**Query params:** `?active_only=true&limit=50&offset=0`
### POST /servers/{server_id}/bans
Add ban manually. Admin only.
Add ban manually. Admin only. If adapter has `ban_manager`, also syncs to the game's ban file.
```json
{
"guid": "abc123...",
"steam_uid": "76561198...",
"name": "PlayerName",
"reason": "Cheating",
"duration_minutes": 0
@@ -508,7 +638,37 @@ Add ban manually. Admin only.
```
### DELETE /servers/{server_id}/bans/{ban_id}
Remove a ban. Admin only.
Remove a ban. Admin only. If adapter has `ban_manager`, also removes from the game's ban file.
---
## Remote Admin Endpoints (Capability: `remote_admin`)
Returns **404** if adapter does not support `remote_admin`.
### POST /servers/{server_id}/remote-admin/command
Send raw remote admin command. Admin only.
```json
{ "command": "#restart" }
```
**Arma 3 available commands:**
- `#restart` — Restart mission
- `#reassign` — Restart with roles unassigned
- `#missions` — Open mission selection
- `#lock` / `#unlock` — Lock/unlock server
- `#mission NAME.TERRAIN [difficulty]` — Load specific mission
- `#shutdown` — Shut down server
- `#monitor N` — Toggle performance monitoring
- `say -1 MESSAGE` — Message all players
### POST /servers/{server_id}/remote-admin/say
Broadcast a message to all players. Admin only.
```json
{ "message": "Server restarting in 5 minutes!" }
```
---
@@ -566,34 +726,6 @@ Get time-series metrics.
---
## RCon Endpoints
### POST /servers/{server_id}/rcon/command
Send raw RCon/admin command. Admin only.
```json
{ "command": "#restart" }
```
**Available commands:**
- `#restart` — Restart mission
- `#reassign` — Restart with roles unassigned
- `#missions` — Open mission selection
- `#lock` / `#unlock` — Lock/unlock server
- `#mission NAME.TERRAIN [difficulty]` — Load specific mission
- `#shutdown` — Shut down server
- `#monitor N` — Toggle performance monitoring
- `say -1 MESSAGE` — Message all players
### POST /servers/{server_id}/rcon/say
Broadcast a message to all players. Admin only.
```json
{ "message": "Server restarting in 5 minutes!" }
```
---
## Event Log Endpoints
### GET /servers/{server_id}/events
@@ -615,6 +747,7 @@ Overall system status. **Requires authentication** (admin or viewer).
"version": "1.0.0",
"running_servers": 2,
"total_servers": 3,
"supported_games": ["arma3"],
"uptime_seconds": 86400
}
}
@@ -643,12 +776,9 @@ Use `server_id = "all"` to subscribe to events from all servers.
**Channel subscription**: The `ConnectionManager` tracks per-connection channel subscriptions. Only messages matching subscribed channels are delivered. Default subscriptions on connect: `["status"]`.
**Channel names match message types exactly:** `status`, `log`, `players`, `metrics`, `event`. Subscribe with channel names matching the `type` field in server→client messages.
### Server → Client Messages
#### Status Update
Sent when server status changes (starting → running → stopped, etc.)
```json
{
"type": "status",
@@ -662,7 +792,6 @@ Sent when server status changes (starting → running → stopped, etc.)
```
#### Log Line
Sent for each new RPT log line.
```json
{
"type": "log",
@@ -676,14 +805,13 @@ Sent for each new RPT log line.
```
#### Player List Update
Sent after each RCon poll (every 10s).
```json
{
"type": "players",
"server_id": 1,
"data": {
"players": [
{ "player_num": 1, "name": "PlayerOne", "ping": 45, "verified": true }
{ "slot_id": "1", "name": "PlayerOne", "ping": 45 }
],
"count": 1
}
@@ -691,7 +819,6 @@ Sent after each RCon poll (every 10s).
```
#### Metrics Update
Sent every 5 seconds.
```json
{
"type": "metrics",
@@ -706,7 +833,6 @@ Sent every 5 seconds.
```
#### Server Event
Sent for significant events (crash, restart, etc.)
```json
{
"type": "event",
@@ -726,6 +852,19 @@ Sent for significant events (crash, restart, etc.)
---
## Adapter-Specific Routes
Adapters may register additional routes under `/api/servers/{server_id}/game/{game_type}/...` for features that have no generic counterpart.
**Arma 3 example:**
```
POST /api/servers/{server_id}/game/arma3/battleye/reload
```
These routes are registered at app startup by iterating over all registered adapters.
---
## Rate Limiting
- `POST /auth/login`: 5 attempts per minute per IP. Exceeded returns `429 Too Many Requests`.
@@ -741,14 +880,18 @@ Sent for significant events (crash, restart, etc.)
| `UNAUTHORIZED` | Missing or invalid token |
| `FORBIDDEN` | Role insufficient |
| `NOT_FOUND` | Resource not found |
| `CAPABILITY_NOT_SUPPORTED` | Adapter lacks required capability for this endpoint |
| `SERVER_ALREADY_RUNNING` | Start called on running server |
| `SERVER_NOT_RUNNING` | Stop/command on stopped server |
| `RCON_UNAVAILABLE` | RCon connection failed |
| `INVALID_CONFIG` | Config validation failed |
| `EXE_NOT_FOUND` | arma3server.exe not at configured path |
| `REMOTE_ADMIN_UNAVAILABLE` | Remote admin connection failed |
| `INVALID_CONFIG` | Config validation failed (adapter-specific) |
| `CONFIG_WRITE_ERROR` | Config file write failed (disk, permissions) |
| `CONFIG_VERSION_CONFLICT` | Optimistic locking conflict on config update |
| `EXE_NOT_ALLOWED` | Executable not in adapter's allowlist |
| `PORT_IN_USE` | Game port already occupied |
| `UPLOAD_FAILED` | Mission file upload error |
| `UPLOAD_FAILED` | File upload error |
| `VALIDATION_ERROR` | Pydantic validation failure |
| `GAME_TYPE_NOT_FOUND` | No adapter registered for this game type |
| `INTERNAL_ERROR` | Unexpected server error |
| `MOD_IN_USE` | Cannot delete mod — enabled on one or more servers |
| `MISSION_IN_ROTATION` | Cannot delete mission — in active rotation |

View File

@@ -2,7 +2,9 @@
## Overview
Languard is a web-based management panel for Arma 3 dedicated servers. It provides a Python backend that manages one or more `arma3server_x64.exe` processes, exposes a REST + WebSocket API to a React frontend, and persists all state in SQLite.
Languard is a **multi-game** web-based management panel for dedicated game servers. It uses a **game adapter architecture** where a game-agnostic core handles server lifecycle, monitoring, and real-time communication, while game-specific behavior (config formats, remote admin protocols, log parsing, mission/mod handling) is encapsulated in pluggable adapters.
**Arma 3** is the first-class, built-in adapter. Adding a new game server type requires only a new adapter package — no core code changes.
---
@@ -12,47 +14,64 @@ Languard is a web-based management panel for Arma 3 dedicated servers. It provid
|-------|-----------|-----------|
| Backend framework | **FastAPI** (Python 3.11+) | Async-native, built-in WebSocket, OpenAPI docs auto-generated |
| Database | **SQLite** via `SQLAlchemy` (sync) | Zero-config, file-based, sufficient for single-host server manager; all access is synchronous (WAL mode for concurrent reads) |
| Process management | `subprocess` + `threading` | Wrap arma3server.exe, watch stdout/stderr, check exit codes; **cwd** set to server instance dir for relative paths; on Windows `terminate()` is a hard kill (no SIGTERM) |
| Process management | `subprocess` + `threading` | Wrap server executables, watch stdout/stderr, check exit codes; **cwd** set to server instance dir for relative paths; on Windows `terminate()` is a hard kill (no SIGTERM) |
| Real-time comms | **WebSocket** (FastAPI) | Push log lines, player lists, server status to React |
| RCon client | Custom UDP client | BattlEye RCon protocol for in-game admin commands |
| Config generation | Python structured builder | Generate server.cfg, basic.cfg, server.Arma3Profile with proper escaping (no f-string injection) |
| Scheduling | `APScheduler` (BackgroundScheduler) | Auto-restart, mission rotation timers, log/metrics cleanup (sync DB ops → BackgroundScheduler, not AsyncIOScheduler) |
| Game adapters | **Protocol + Registry** | Each game implements capability protocols; core resolves the adapter at runtime from `server.game_type` |
| Scheduling | `APScheduler` (BackgroundScheduler) | Auto-restart, log/metrics cleanup (sync DB ops → BackgroundScheduler, not AsyncIOScheduler) |
| Auth | **JWT** (python-jose) + bcrypt | Secure the API; React stores token in localStorage |
| Frontend | React + TypeScript (external repo) | Connects to this backend's API |
| Frontend | React + TypeScript + Vite + Tailwind | See FRONTEND.md for full design system, component architecture, and adapter-aware UI patterns |
---
## High-Level Architecture
## Architecture Overview
```
┌─────────────────────────────────────────────────────────────┐
│ React Frontend
│ Server List │ Server Detail │ Logs │ Players │ Config UI
│ React Frontend (see FRONTEND.md)
Dashboard │ Server List │ Server Detail │ Logs │ Config UI │
│ Game Type Selector │ Adapter-specific Panels │
└────────────────────────┬────────────────────────────────────┘
│ HTTP REST + WebSocket
┌─────────────────────────────────────────────────────────────┐
│ FastAPI Application
│ FastAPI Application (Core)
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Auth Router │ │ Server Router│ │ Config Router │ │
│ └──────────────┘ └──────────────┘ └──────────────────┘ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │Mission Router│ │ Mod Router │ │ WS Router │ │
│ │ Player Router│ │ Log Router │ │ WS Router │ │
│ └──────────────┘ └──────────────┘ └──────────────────┘ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Metric Router│ │ Event Router │ │ Games Router │ │
│ └──────────────┘ └──────────────┘ └──────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Service Layer │ │
│ │ ServerService │ ConfigService │ RConService │ │
│ │ LogService │ MetricsService│ MissionService │ │
│ │ Core Service Layer │ │
│ │ ServerService │ ConfigService │ PlayerService │ │
│ │ LogService │ MetricsService│ EventService │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Game Adapter Registry │ │
│ │ GameAdapterRegistry.get(game_type) → GameAdapter │ │
│ │ │ │
│ │ Delegates to: │ │
│ │ • ConfigGenerator → adapter.get_config_generator()│ │
│ │ • ProcessConfig → adapter.get_process_config() │ │
│ │ • LogParser → adapter.get_log_parser() │ │
│ │ • RemoteAdmin → adapter.get_remote_admin() │ │
│ │ • MissionManager → adapter.get_mission_manager() │ │
│ │ • ModManager → adapter.get_mod_manager() │ │
│ │ • BanManager → adapter.get_ban_manager() │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Thread Pool │ │
│ │ ProcessMonitorThread (per server) │ │
│ │ LogTailThread (per server) │ │
│ │ MetricsCollectorThread (per server) │ │
│ │ RConPollerThread (per server) │ │
│ │ ProcessMonitorThread (per server, core) │ │
│ │ LogTailThread (per server, core + adapter parser) │ │
│ │ MetricsCollectorThread (per server, core) │ │
│ │ RemoteAdminPollerThread (per server, core + adapter) │ │
│ │ BroadcastThread (global) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
@@ -60,110 +79,213 @@ Languard is a web-based management panel for Arma 3 dedicated servers. It provid
│ │ Data Access Layer (DAL) │ │
│ │ ServerRepository │ PlayerRepository │ │
│ │ LogRepository │ MetricsRepository │ │
│ │ ConfigRepository (game_configs table) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────┐ ┌────────────────────────────────┐ │
│ │ SQLite (DB) │ │ Filesystem │ │
│ │ languard.db │ │ servers/{id}/server.cfg │ │
│ │ │ │ servers/{id}/basic.cfg │ │
│ │ │ │ servers/{id}/server/ │ │ ← profile dir (Arma3 -name=server)
│ │ │ │ server.Arma3Profile │ │ ← profile settings
│ │ │ │ arma3server_*.rpt │ │ ← RPT logs (tailable)
│ │ │ │ servers/{id}/battleye/ │ │
│ │ │ │ beserver.cfg │ │ ← RCon config
│ │ │ │ servers/{id}/mpmissions/ │ │
│ │ languard.db │ │ servers/{id}/ (layout by │ │
│ │ │ │ adapter.get_process_config() │ │
│ └───────────────────┘ └────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
subprocess
┌─────────────────────────────────────────────────────────────┐
│ Arma 3 Server Processes (OS level) │
arma3server_x64.exe (port 2302)
arma3server_x64.exe (port 2402)
...
└─────────────────────────────────────────────────────────────┘
┌─────────────┴─────────────────┐
▼ ▼
┌──────────────────────┐ ┌──────────────────────────────────┐
Game Adapters Game Server Processes (OS level)
│ │
┌────────────────┐ │ (Any game executable)
│ │ Arma 3 │ │ │ Started via adapter's │
│ │ adapter │ │ │ build_launch_args() │
│ │ │ │ │ │
│ │ • ConfigGen │ │ └──────────────────────────────────┘
│ │ • ProcessConfig│ │
│ │ • RPTParser │ │
│ │ • BERConClient │ │
│ │ • MissionMgr │ │
│ │ • ModMgr │ │
│ │ • BanMgr │ │
│ └────────────────┘ │
│ │
│ ┌────────────────┐ │
│ │ (Future Game) │ │
│ │ adapter │ │
│ └────────────────┘ │
└──────────────────────┘
```
---
## Game Adapter Architecture
### Core Principle: Composition Over Inheritance
Each game adapter is a **composition of capability objects** implementing well-defined `Protocol` interfaces. Not every game supports every capability — adapters return `None` for unsupported features, and the core gracefully degrades.
### Capability Protocols
| Protocol | Purpose | Required? |
|----------|---------|-----------|
| `ConfigGenerator` | Define config schema + Pydantic models, write config files, build launch args | **Yes** |
| `ProcessConfig` | Exe allowlist, port conventions, directory layout | **Yes** |
| `LogParser` | Parse game-specific log lines, find log files | **Yes** |
| `RemoteAdmin` | Factory for RCon/Telnet/HTTP admin clients | No |
| `MissionManager` | Mission file format and rotation config | No |
| `ModManager` | Mod folder convention and CLI args | No |
| `BanManager` | Ban file sync between DB and game's ban file | No |
### Adapter Registry
```python
# adapters/registry.py
class GameAdapterRegistry:
_adapters: dict[str, GameAdapter] = {}
@classmethod
def register(cls, adapter: GameAdapter) -> None: ...
@classmethod
def get(cls, game_type: str) -> GameAdapter: ...
@classmethod
def all(cls) -> list[GameAdapter]: ...
@classmethod
def list_game_types(cls) -> list[dict]: ...
```
Adapters auto-register at import time. The core never imports adapter internals — it only resolves through the registry.
### How Core Delegates to Adapter
Every server has a `game_type` column. When core code needs game-specific behavior, it:
1. Reads `server.game_type` from DB
2. Resolves `adapter = GameAdapterRegistry.get(game_type)`
3. Calls the appropriate adapter method
**Example — Server start flow:**
```python
def start(self, server_id: int) -> dict:
server = ServerRepository(db).get_by_id(server_id)
adapter = GameAdapterRegistry.get(server["game_type"])
process_config = adapter.get_process_config()
# Core validation (game-agnostic)
exe_basename = Path(server["exe_path"]).name
if exe_basename not in process_config.get_allowed_executables():
raise ValueError(f"Executable not allowed: {exe_basename}")
# Adapter generates config files
config_gen = adapter.get_config_generator()
config_gen.write_configs(server_id, server_dir, config_sections)
# Adapter builds launch args
launch_args = config_gen.build_launch_args(config_sections, mod_args)
# Core launches process (game-agnostic)
pid = ProcessManager.get().start(server_id, exe_path, launch_args, cwd=server_dir)
# Core starts threads (delegates to adapter for parsers/clients)
ThreadRegistry.start_server_threads(server_id, db)
```
---
## Component Responsibilities
### FastAPI Routers
### FastAPI Routers (Core)
- Validate input (Pydantic models)
- Call service layer
- Resolve adapter from `server.game_type`
- Delegate game-specific work to adapter
- Return JSON responses
- Handle WebSocket connections
- Return 404 with clear message if adapter lacks a capability
### Service Layer
- Orchestrate operations (start server = generate config + launch process + start threads)
### Core Service Layer
- Orchestrate operations (start server = resolve adapter + generate config + launch process + start threads)
- No direct DB access — delegates to repositories
- No direct process access — delegates to ProcessManager
- **No game-specific logic** — delegates to adapter
### ProcessManager
### ProcessManager (Core)
- Singleton that owns all subprocess handles
- Thread-safe dict: `{server_id: subprocess.Popen}`
- `start()` sets `cwd=servers/{server_id}/` so relative config paths resolve correctly
- On Windows: `terminate()` = `TerminateProcess` (hard kill), no graceful SIGTERM — graceful shutdown must go through RCon `#shutdown` first
- On Windows: `terminate()` = `TerminateProcess` (hard kill, no SIGTERM) — graceful shutdown must go through adapter's RemoteAdmin
- Provides: `start()`, `stop()`, `restart()`, `is_running()`, `send_command()`
- **Exe validation is delegated to adapter's ProcessConfig** — core no longer has a hardcoded allowlist
### Thread Pool (per running server)
| Thread | Interval | Purpose |
|--------|----------|---------|
| `ProcessMonitorThread` | 1s | Detect crash / unexpected exit; update DB status; trigger auto-restart |
| `LogTailThread` | 100ms | Read new lines from .rpt file; store in DB; push to WS clients |
| `MetricsCollectorThread` | 5s | Collect CPU%, RAM MB for the process via psutil; write to DB |
| `RConPollerThread` | 10s | Query connected players via BattlEye RCon; update DB player table |
| `BroadcastThread` | event-driven | Consume from internal queue; push JSON to all subscribed WS clients |
### RCon Client
- UDP socket to BattlEye RCon port (configured in `beserver.cfg` inside the server's `battleye/` directory)
- Implements BE RCon protocol: login, keepalive, send command, parse response
- **Request multiplexer**: tracks pending requests by sequence byte, routes responses to the correct caller via `threading.Event` per request. Prevents response misrouting when RConPollerThread and API-request RConService calls share the same UDP socket.
- Used by: `RConPollerThread`, `RConService` (for admin commands from UI)
| Thread | Source | Interval | Purpose |
|--------|--------|----------|---------|
| `ProcessMonitorThread` | Core | 1s | Detect crash / unexpected exit; update DB status; trigger auto-restart |
| `LogTailThread` | Core + adapter's LogParser | 100ms | Read new lines from log file; parse via adapter; store in DB; push to WS |
| `MetricsCollectorThread` | Core | 5s | Collect CPU%, RAM MB via psutil; write to DB |
| `RemoteAdminPollerThread` | Core + adapter's RemoteAdmin | 10s | Query players via adapter's admin client; update DB player table |
| `BroadcastThread` | Core | event-driven | Consume from internal queue; push JSON to all subscribed WS clients |
### Config Generator
- Takes `ServerConfig` Pydantic model from DB
- Renders `server.cfg`, `basic.cfg`, `*.Arma3Profile` using a **structured builder** (NOT f-strings — prevents config injection)
- Escapes double quotes and newlines in all user-supplied string values
- Writes files to `servers/{server_id}/` directory
- `server.Arma3Profile` written to `servers/{server_id}/server/` (Arma 3 reads from the `-name` subdirectory)
### ConfigRepository (Core)
- Manages the generic `game_configs` table
- Stores config as JSON blobs keyed by `(server_id, section)`
- **Validation is delegated to adapter's Pydantic models** — core never inspects config content
- **Sensitive field encryption**: calls `adapter.get_config_generator().get_sensitive_fields(section)` to identify which JSON keys to encrypt/decrypt via Fernet
- **Optimistic locking**: each row includes `config_version` (integer). On PUT, client sends the version they read. If version mismatch, return 409 Conflict.
- Provides: `get_section()`, `get_all_sections()`, `upsert_section()`, `delete_sections()`
### SQLite DAL
- Sync reads/writes using SQLAlchemy Core (not ORM — simpler for this use case)
- Thread-safe via SQLAlchemy's connection pooling
- One `languard.db` file at project root
- **PRAGMA busy_timeout=5000** — prevents "database is locked" errors under concurrent thread writes
- Thread-local connections via `get_thread_db()` — one connection per background thread
### Adapter Exceptions (Standard Error Types)
Adapters raise specific exception types so the core can handle errors precisely:
| Exception | When Raised | Core Action |
|-----------|------------|-------------|
| `ConfigWriteError` | File write fails (disk full, permissions) | Set server status='error', return 500 with detail |
| `ConfigValidationError` | Config values violate adapter constraints | Return 400 with field-level errors |
| `LaunchArgsError` | build_launch_args() fails (missing mod, bad path) | Set server status='error', return 400 |
| `RemoteAdminError` | Remote admin connection/command fails | Log warning, return 503 with detail |
| `ExeNotAllowedError` | Executable not in adapter's allowlist | Return 400 with allowed list |
---
## Data Flow: Start Server
```
Frontend POST /api/servers/{id}/start
ServerService.start(server_id)
├── Load ServerConfig from DB
├── ConfigGenerator.write_configs(server_id, config)
│ ├── server.cfg → servers/{id}/server.cfg
├── basic.cfg → servers/{id}/basic.cfg
│ ├── server.Arma3Profile → servers/{id}/server/server.Arma3Profile
└── beserver.cfg → servers/{id}/battleye/beserver.cfg
├── ProcessManager.start(server_id, exe_path, args, cwd=servers/{id}/)
├── DB: update server.status = "starting"
├── Spawn ProcessMonitorThread(server_id)
├── Spawn LogTailThread(server_id) — tails servers/{id}/server/arma3server_*.rpt
├── Spawn MetricsCollectorThread(server_id)
├── Spawn RConPollerThread(server_id) [after 30s delay for server startup]
└── BroadcastThread pushes status update to WS clients
Frontend POST /api/servers/{id}/start
ServerService.start(server_id)
├── Load server from DB (includes game_type)
├── adapter = GameAdapterRegistry.get(server.game_type)
├── Validate exe against adapter.get_process_config().get_allowed_executables()
(raises ExeNotAllowedError → 400)
├── Check ALL derived ports across ALL running servers
(resolve each server's adapter, get port conventions, check full set)
├── Load config sections from game_configs table
├── adapter.get_config_generator().write_configs(server_id, dir, config)
│ ATOMIC: writes to .tmp files first, then os.replace() to final paths
│ On failure: cleans up .tmp files, raises ConfigWriteError
│ Core: sets status='error', returns 500
├── launch_args = adapter.get_config_generator().build_launch_args(config, mods)
│ On failure: raises LaunchArgsError → 400
├── ProcessManager.start(server_id, exe_path, launch_args, cwd=dir)
├── DB: update server.status = "starting"
├── ThreadRegistry.start_server_threads(server_id, db)
│ ├── ProcessMonitorThread (core, always)
│ ├── LogTailThread(server_id, adapter.get_log_parser())
│ ├── MetricsCollectorThread (core, always)
│ └── RemoteAdminPollerThread(server_id, adapter.get_remote_admin())
│ (only if adapter has remote_admin capability)
│ Core wraps client with threading.Lock for thread safety
└── BroadcastThread pushes status update to WS clients
```
## Data Flow: Real-time Logs
```
arma3server.exe writes servers/{id}/server/arma3server_*.rpt
→ LogTailThread reads new lines (recursive glob for *.rpt in profile dir)
LogRepository.insert(server_id, line, timestamp)
BroadcastQueue.put({type: "log", server_id, line, timestamp})
Game server writes log file (path determined by adapter.get_log_parser().get_log_file_resolver())
→ LogTailThread reads new lines (core tailing logic, game-agnostic)
adapter.get_log_parser().parse_line(line) → {timestamp, level, message}
LogRepository.insert(server_id, entry)
→ BroadcastQueue.put({type: "log", server_id, entry})
→ BroadcastThread sends to all WS subscribers for this server
→ React frontend appends to log viewer
```
@@ -171,10 +293,10 @@ arma3server.exe writes servers/{id}/server/arma3server_*.rpt
## Data Flow: Player List
```
RConPollerThread (every 10s)
RConClient.send("players")
→ Parse response: [{id, name, guid, ping, verified}]
→ PlayerRepository.upsert_all(server_id, players)
RemoteAdminPollerThread (every 10s, core thread)
adapter.get_remote_admin().create_client() → client instance
→ client.get_players() → list of player dicts
→ PlayerService.update_from_remote_admin(server_id, players)
→ BroadcastQueue.put({type: "players", server_id, players})
→ React frontend updates player list
```
@@ -189,11 +311,48 @@ RConPollerThread (every 10s)
- `admin` role: all operations
- CORS configured to accept only the frontend origin
- Passwords hashed with **bcrypt** (cost factor 12)
- `serverCommandPassword` and `passwordAdmin` stored encrypted in SQLite (AES-256 via `cryptography` library, key from env)
- **Port conflict validation** at server creation and start: checks game_port through game_port+4 (game, Steam query, Steam master, Steam auth, RCon) against all existing servers
- **ban.txt sync**: bans table is source of truth for UI; on ban add/delete via API, also write to `battleye/ban.txt`; on startup, read `ban.txt` and upsert into DB. Without this sync, DB-only bans are not enforced by BattlEye.
- Generated config files containing passwords (server.cfg, beserver.cfg) have restrictive file permissions (0600 on Unix, restricted ACL on Windows)
- Sensitive config fields (passwords, RCon passwords) stored encrypted in `game_configs` JSON (AES-256 via Fernet, key from env)
- **Port conflict validation** at server creation and start: uses adapter's `get_port_conventions()` to determine all derived ports
- **Ban file sync**: adapter's BanManager handles bidirectional sync between DB bans table and game's ban file format
- Generated config files containing passwords have restrictive file permissions (0600 on Unix, restricted ACL on Windows)
- Input sanitization on all string fields before config generation — no shell injection or config directive injection
- **Exe validation**: core checks against adapter's `get_allowed_executables()` — prevents executing arbitrary binaries
---
## Core vs Adapter Responsibility Boundary
| Concern | Owner | Rationale |
|---------|-------|-----------|
| Server CRUD (name, description, status, game_type, ports) | **Core** | Universal concept |
| Process lifecycle (subprocess start, stop, kill) | **Core** | `subprocess.Popen` is game-agnostic |
| Config schema definition + file generation | **Adapter** | Schema, validation models, file formats, and launch args are all game-specific — unified in ConfigGenerator |
| Config CRUD in DB | **Core** | `game_configs` table is generic; adapter validates JSON |
| Process monitoring (crash detection, auto-restart) | **Core** | OS-level, game-agnostic |
| System metrics (CPU, RAM) | **Core** | psutil is game-agnostic |
| Log file tailing mechanics | **Core** | Tail -f is universal |
| Log line parsing | **Adapter** | RPT vs. server.log vs. custom JSON |
| Log file discovery | **Adapter** | Different naming conventions per game |
| Log storage and querying | **Core** | `logs` table is game-agnostic |
| Remote admin protocol | **Adapter** | BattlEye UDP vs. Source RCON vs. none |
| Remote admin polling | **Core** | Thread and interval logic are generic |
| Player identification | **Adapter** | GUID, Steam ID, UUID — game-specific |
| Player storage and history | **Core** | Generic table with `game_data` JSON |
| Ban concept | **Core** | Universal |
| Ban file sync | **Adapter** | `battleye/ban.txt` vs. `banned-players.json` vs. none |
| Mission/scenario file format | **Adapter** | PBO vs. PK3 vs. directory |
| Mission rotation config | **Adapter** | Game-specific format |
| Mission storage in DB | **Core** | Generic filename/metadata |
| Mod folder convention | **Adapter** | `@mod_folder` vs. `mods/` vs. `plugins/` |
| Mod CLI argument building | **Adapter** | `-mod=` vs. `+moddir` vs. none |
| Mod storage in DB | **Core** | Generic mod registration |
| Exe name allowlist | **Adapter** | Game-specific binary names |
| Port convention (derived ports) | **Adapter** | Arma 3: game+1/+2/+3 for Steam; varies per game |
| Profile directory convention | **Adapter** | Arma 3: `-name=server` subdirectory; varies |
| WebSocket real-time | **Core** | Transport is game-agnostic |
| Auth and user management | **Core** | No game dependency |
| Event/audit trail | **Core** | Generic; adapter can define additional event types |
| Scheduled cleanup | **Core** | Cron jobs are game-agnostic |
---
@@ -201,14 +360,20 @@ RConPollerThread (every 10s)
```env
LANGUARD_SECRET_KEY=<jwt-signing-secret>
LANGUARD_ENCRYPTION_KEY=<Fernet-base64-key — generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())">
LANGUARD_ENCRYPTION_KEY=<Fernet-base64-key>
LANGUARD_DB_PATH=./languard.db
LANGUARD_SERVERS_DIR=./servers
LANGUARD_ARMA_EXE=C:/Arma3Server/arma3server_x64.exe
LANGUARD_HOST=0.0.0.0
LANGUARD_PORT=8000
LANGUARD_CORS_ORIGINS=http://localhost:5173,http://localhost:3000
LANGUARD_LOG_RETENTION_DAYS=7
LANGUARD_METRICS_RETENTION_DAYS=30
LANGUARD_PLAYER_HISTORY_RETENTION_DAYS=90
# Game-specific defaults (adapter may use these)
LANGUARD_ARMA3_EXE=C:/Arma3Server/arma3server_x64.exe
# LANGUARD_MINECRAFT_JAR=C:/minecraft/server.jar
# LANGUARD_RUST_EXE=C:/RustDedicated/RustDedicated.exe
```
---
@@ -221,69 +386,94 @@ languard-servers-manager/
│ ├── main.py # FastAPI app factory
│ ├── config.py # Settings from env
│ ├── database.py # SQLAlchemy engine + session
│ ├── auth/
│ │ ├── router.py
│ ├── service.py
│ │ ── schemas.py
├── servers/
│ │ ├── router.py # REST endpoints for servers
│ │ ├── service.py # ServerService
│ │ ├── process_manager.py # ProcessManager singleton
│ │ ├── config_generator.py # server.cfg / basic.cfg / beserver.cfg writer
│ │ └── schemas.py # Pydantic schemas
├── rcon/
│ │ ├── client.py # BattlEye RCon UDP client
│ │ └── service.py # RConService
│ ├── players/
│ │ ├── router.py
│ │ ├── service.py
│ │ └── schemas.py
│ ├── missions/
│ │ ├── router.py
│ │ └── service.py
│ ├── mods/
│ │ ├── router.py
│ │ └── service.py
│ ├── logs/
│ │ ├── router.py
│ │ └── service.py
│ ├── metrics/
│ │ ├── router.py
│ │ └── service.py
│ ├── websocket/
│ │ ├── router.py # WS connection handler
│ │ ├── manager.py # ConnectionManager (per-server subscriptions)
│ │ └── broadcaster.py # BroadcastThread + queue
│ ├── threads/
│ │ ├── process_monitor.py # ProcessMonitorThread
│ │ ├── log_tail.py # LogTailThread
│ │ ├── metrics_collector.py # MetricsCollectorThread
│ │ └── rcon_poller.py # RConPollerThread
├── system/
│ │ └── router.py # GET /system/status, GET /system/health
│ ├── dal/
│ │ ├── server_repository.py
│ │ ├── config_repository.py
│ │ ├── player_repository.py
│ │ ├── log_repository.py
│ │ ├── metrics_repository.py
│ │ ├── mission_repository.py
│ │ ├── mod_repository.py
│ │ ├── ban_repository.py
│ │ └── event_repository.py
└── migrations/
└── 001_initial_schema.sql
│ ├── dependencies.py # Auth deps, server lookup
│ │
├── core/ # Game-agnostic core
│ │ ── auth/
│ │ ├── router.py
│ │ │ ├── service.py
│ │ ├── schemas.py
│ │ │ └── utils.py
│ │ ├── servers/
│ │ │ ├── router.py # REST endpoints for servers
│ │ ├── service.py # ServerService (delegates to adapter)
│ │ │ ├── process_manager.py # ProcessManager singleton
│ │ └── schemas.py # Generic Pydantic schemas
│ ├── players/
│ │ ├── router.py
│ │ ├── service.py
│ │ └── schemas.py
│ ├── logs/
│ │ ├── router.py
│ │ └── service.py
│ ├── metrics/
│ │ ├── router.py
│ │ └── service.py
│ ├── bans/
│ │ ├── router.py
│ │ └── service.py
│ ├── events/
│ │ ├── router.py
│ │ └── service.py
│ ├── websocket/
│ │ ├── router.py
│ │ ├── manager.py
│ │ └── broadcaster.py
│ ├── threads/
│ │ │ ├── base_thread.py
│ │ │ ├── process_monitor.py
│ │ │ ├── log_tail.py # Generic, takes adapter LogParser
│ │ │ ├── metrics_collector.py
│ │ ├── remote_admin_poller.py # Generic, takes adapter RemoteAdmin
│ │ │ └── thread_registry.py
│ ├── games/
│ │ │ └── router.py # /api/games — type discovery, schemas
│ │ ├── system/
│ │ │ └── router.py
│ │ ├── dal/
│ │ │ ├── base_repository.py
│ │ │ ├── server_repository.py
│ │ │ ├── config_repository.py # game_configs table
│ │ │ ├── player_repository.py
│ │ │ ├── log_repository.py
│ │ ├── metrics_repository.py
│ ├── mission_repository.py
│ │ │ ├── mod_repository.py
│ │ │ ├── ban_repository.py
│ │ │ └── event_repository.py
│ │ ├── migrations/
│ │ │ ├── runner.py
│ │ │ └── 001_initial_schema.sql
│ │ └── utils/
│ │ ├── crypto.py
│ │ ├── file_utils.py
│ │ └── port_checker.py
│ │
│ └── adapters/ # Game-specific adapters
│ ├── __init__.py
│ ├── registry.py # GameAdapterRegistry
│ ├── protocols.py # All capability Protocol definitions
│ │
│ └── arma3/ # Arma 3 adapter (built-in)
│ ├── __init__.py # Exports ARMA3_ADAPTER, registers on import
│ ├── adapter.py # Arma3Adapter class
│ ├── config_generator.py # Pydantic models + server.cfg, basic.cfg, Arma3Profile, beserver.cfg
│ ├── rcon_client.py # BERConClient (BattlEye UDP protocol)
│ ├── rcon_service.py # Wraps BERConClient for RemoteAdmin protocol
│ ├── log_parser.py # RPTParser
│ ├── mission_manager.py # PBO upload, mission rotation config
│ ├── mod_manager.py # @mod_folder convention, -mod=/-serverMod=
│ ├── process_config.py # Exe allowlist, port conventions, profile dir
│ ├── ban_manager.py # battleye/ban.txt bidirectional sync
│ ├── schemas.py # Arma 3 specific request/response models
│ └── migrations/
│ └── 001_arma3_metadata.sql # Arma 3 specific tables (if any)
├── servers/ # Runtime data per server instance
│ └── {server_id}/
── server.cfg
├── basic.cfg
│ ├── server/ # Arma 3 profile dir (matches -name=server)
│ │ ├── server.Arma3Profile
│ │ └── arma3server_*.rpt # Timestamped RPT logs
│ ├── battleye/
│ │ └── beserver.cfg # BattlEye RCon config (generated on start)
│ └── mpmissions/
├── frontend/ # React app (separate repo or subfolder)
│ └── {server_id}/ # Layout determined by adapter.get_process_config()
── (Arma 3: server.cfg, basic.cfg, server/, battleye/, mpmissions/)
├── frontend/ # React app
├── requirements.txt
├── .env.example
├── ARCHITECTURE.md
@@ -291,19 +481,67 @@ languard-servers-manager/
├── API.md
├── MODULES.md
├── THREADING.md
├── FRONTEND.md
└── IMPLEMENTATION_PLAN.md
```
---
## Adding a New Game Adapter
To add support for a new game, create an adapter package:
```
adapters/<game_type>/
├── __init__.py # Export adapter, register in registry
├── adapter.py # Implement GameAdapter protocol
├── config_generator.py # Pydantic models + write game config files
├── log_parser.py # Parse game log format
├── process_config.py # Exe allowlist, port conventions
├── ... # Optional: rcon_client, mission_manager, mod_manager, ban_manager
└── migrations/ # Optional: game-specific DB extensions
```
Steps:
1. Implement the required protocols: `ConfigGenerator`, `ProcessConfig`, `LogParser`
2. Implement optional protocols as needed: `RemoteAdmin`, `MissionManager`, `ModManager`, `BanManager`
3. Create the `GameAdapter` class composing all capabilities
4. Register the adapter — either:
- **Built-in**: register in `adapters/__init__.py` via import
- **Third-party**: register via setuptools entry_point in `pyproject.toml`:
```toml
[project.entry-points."languard.adapters"]
mygame = "mygame_adapter:MYGAME_ADAPTER"
```
Core scans `languard.adapters` entry_point group at startup and auto-registers.
5. No core code changes, no DB migrations required
---
## Key Design Decisions
| Decision | Choice | Reason |
|----------|--------|--------|
| Sync vs async DB | **Sync SQLAlchemy only** | All DB access is synchronous; background threads are non-async; `get_thread_db()` provides thread-local connections; no aiosqlite dependency |
| ORM vs Core | **SQLAlchemy Core** | Simpler SQL control, less magic for embedded use case |
| WebSocket auth | JWT in query param on connect | Browser WS API doesn't support headers; query param `?token=...` |
| Game-specific logic | **Adapter pattern with Protocol + Registry** | Structural subtyping with mypy enforcement; optional capabilities return None; zero core changes per game |
| Capability probe | **has_capability() on GameAdapter** | Instead of scattered None checks, GameAdapter.has_capability(name) returns bool. Core code uses this to check support before calling get methods. Cleaner than `if adapter.get_remote_admin() is not None:` everywhere. |
| Protocol granularity | **7 protocols (ConfigGenerator merged from ConfigSchema+ConfigGenerator)** | ConfigSchema and ConfigGenerator always co-occur (no game has schema without generation). Merged into single ConfigGenerator with schema + generation methods. ProcessConfig kept separate — may evolve independently. |
| Config storage | **Hybrid: core normalized + game_configs JSON** | Core tables stay clean; config is always whole-read/write; adapter Pydantic models validate; zero migration per new game |
| Sync vs async DB | **Sync SQLAlchemy only** | All DB access is synchronous; background threads are non-async; no aiosqlite dependency |
| WebSocket auth | JWT in query param on connect | Browser WS API doesn't support headers |
| Process ownership | **ProcessManager singleton** | Single source of truth; prevents duplicate launches |
| Log storage | **DB + rolling file** | DB for fast queries/streaming; raw .rpt preserved on disk |
| Config files | **Regenerate on each start** | Always fresh from DB; no sync drift between DB and filesystem; **structured builder** (not f-strings) prevents config injection |
| RCon port convention | **User-configurable** | BattlEye RCon port is set in `beserver.cfg` (inside `battleye/` dir). Default suggestion: game port + 4 (e.g., 2302 → 2306). Must not conflict with game (2302), Steam query (2303), VON (2304), or Steam auth (2305) ports. **Note:** RCon config changes require server restart — BattlEye reads beserver.cfg only at startup. |
| Config files | **Adapter regenerates on each start** | Always fresh from DB; no sync drift; adapter's structured builder prevents config injection |
| Config write failure | **Atomic write + rollback** | Adapter writes to temp files first, then atomic rename. On failure, temp files are cleaned up — original files remain untouched. Server start never proceeds with partial config. |
| Sensitive field encryption | **Adapter declares via get_sensitive_fields()** | ConfigGenerator protocol returns list of JSON keys per section that need Fernet encryption. Core's ConfigRepository handles encrypt/decrypt transparently. |
| Adapter schema versioning | **config_version in game_configs row** | Each config section row stores a version string. On adapter update, if version differs, adapter provides a migration function. |
| Adapter error communication | **Typed adapter exceptions** | Adapters raise specific exception types (ConfigWriteError, ConfigValidationError, LaunchArgsError, RemoteAdminError). Core catches specifically and sets appropriate DB status + returns clear API errors. |
| Remote admin thread safety | **Core wraps with lock** | Core wraps RemoteAdminClient calls with a threading.Lock. Adapter clients don't need to be thread-safe. One lock per server — API requests and poller thread share safely. |
| Third-party adapter loading | **Setuptools entry_points** | Third-party adapters register via `languard.adapters` entry_point group. Core scans entry_points at startup and auto-registers. Built-in adapters registered on import. |
| Port conflict detection | **Full cross-game check** | When checking ports for a new/starting server, query ALL running servers, resolve each adapter, get port conventions for that game, and check the full derived port set. |
| Config preview | **Dict of label → content** | preview_config() returns {label: rendered_content}. File-based games use filename as label; env-var games use variable name; CLI games use argument name. Frontend renders all as labeled text blocks. |
| Ban file sync timing | **Immediate + startup** | On every ban add/delete via API, adapter's BanManager syncs to file immediately. On startup, adapter reads ban file and upserts into DB. Ensures consistency. |
| Config concurrency | **Optimistic locking** | game_configs rows include config_version (integer). On PUT, client sends the version they read. If version mismatch, return 409 Conflict. Frontend re-reads and merges. |
| game_data JSON schema | **Adapter declares via get_game_data_schema()** | Each capability protocol (MissionManager, ModManager, etc.) optionally returns a Pydantic model for the game_data JSON. Core validates on write. |
| Log storage | **DB + rolling file** | DB for fast queries/streaming; raw logs preserved on disk |
| Player identification | **slot_id (string) + game_data JSON** | Flexible across games; Arma 3 uses int slot, others may use UUID |
| Route URLs | **Game-agnostic with adapter delegation** | Frontend doesn't need game-type-specific URLs; 404 if adapter lacks capability |
| API route registration | **Core defines all routes; adapter dispatch at request time** | Simpler than dynamic route mounting; clear 404 for unsupported features |

View File

@@ -9,6 +9,19 @@
---
## Design Philosophy
The database uses a **hybrid approach**: core tables are fully normalized (game-agnostic), while game-specific config is stored as JSON blobs in a generic `game_configs` table, validated by adapter Pydantic models at the application layer.
**Why this works for Languard:**
- Config is always read/written as a whole section (nobody queries "find all servers where von_codec_quality > 20")
- Each adapter section maps to a Pydantic model, so validation is enforced at the application layer
- The JSON is opaque to the core, meaningful only to the adapter that owns it
- Adding a new game requires **zero DB migration** — just a new adapter
- Core queries across all games work naturally
---
## Schema
### Table: `users`
@@ -31,29 +44,28 @@ CREATE TABLE users (
### Table: `servers`
One row per managed Arma 3 server instance.
One row per managed server instance. **Game-agnostic.**
```sql
CREATE TABLE servers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, -- display name in UI
description TEXT,
game_type TEXT NOT NULL DEFAULT 'arma3', -- adapter lookup key
status TEXT NOT NULL DEFAULT 'stopped',
-- status values: 'stopped' | 'starting' | 'running' | 'stopping' | 'crashed' | 'error'
CHECK (status IN ('stopped', 'starting', 'running', 'stopping', 'crashed', 'error')),
CHECK (game_port BETWEEN 1024 AND 65535),
CHECK (rcon_port BETWEEN 1024 AND 65535),
-- Process info
pid INTEGER, -- OS process ID when running
exe_path TEXT NOT NULL, -- path to arma3server_x64.exe
exe_path TEXT NOT NULL, -- path to server executable
started_at TEXT, -- ISO datetime
stopped_at TEXT,
-- Network
game_port INTEGER NOT NULL DEFAULT 2302,
rcon_port INTEGER NOT NULL DEFAULT 2306, -- user-configurable; written to battleye/beserver.cfg
steam_query_port INTEGER GENERATED ALWAYS AS (game_port + 1) VIRTUAL, -- convention, not enforced by engine
-- Network (core ports; adapter defines derived port conventions)
game_port INTEGER NOT NULL,
rcon_port INTEGER, -- NULL if game has no remote admin
CHECK (game_port BETWEEN 1024 AND 65535),
CHECK (rcon_port IS NULL OR (rcon_port BETWEEN 1024 AND 65535)),
-- Auto-management
auto_restart INTEGER NOT NULL DEFAULT 0, -- 1 = restart on crash
@@ -67,262 +79,231 @@ CREATE TABLE servers (
);
CREATE INDEX idx_servers_status ON servers(status);
CREATE INDEX idx_servers_game_type ON servers(game_type);
CREATE INDEX idx_servers_game_port ON servers(game_port);
CREATE INDEX idx_servers_rcon_port ON servers(rcon_port);
```
**Key changes from single-game design:**
- Added `game_type` column (defaults to `'arma3'` for backward compatibility)
- Removed Arma 3-specific defaults (`DEFAULT 2302`, `DEFAULT 2306`) — port defaults are now adapter-provided
- Removed `steam_query_port` virtual column (Arma 3 convention moved to adapter)
- `rcon_port` is now nullable (some games have no remote admin)
---
### Table: `server_configs`
### Table: `game_configs`
Stores all parameters for generating `server.cfg`. One row per server.
Stores all game-specific configuration as JSON blobs, keyed by section. **Replaces** the previous `server_configs`, `basic_configs`, `server_profiles`, `launch_params`, and `rcon_configs` tables.
```sql
CREATE TABLE server_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL UNIQUE REFERENCES servers(id) ON DELETE CASCADE,
-- Basic identity
hostname TEXT NOT NULL DEFAULT 'My Arma 3 Server',
password TEXT, -- join password (encrypted at app layer via Fernet)
password_admin TEXT NOT NULL, -- encrypted (no default — must be set on creation)
server_command_password TEXT, -- encrypted
-- Players
max_players INTEGER NOT NULL DEFAULT 40,
kick_duplicate INTEGER NOT NULL DEFAULT 1,
persistent INTEGER NOT NULL DEFAULT 1,
-- Voting
vote_threshold REAL NOT NULL DEFAULT 0.33,
vote_mission_players INTEGER NOT NULL DEFAULT 1,
vote_timeout INTEGER NOT NULL DEFAULT 60, -- seconds
role_timeout INTEGER NOT NULL DEFAULT 90,
briefing_timeout INTEGER NOT NULL DEFAULT 60,
debriefing_timeout INTEGER NOT NULL DEFAULT 45,
lobby_idle_timeout INTEGER NOT NULL DEFAULT 300,
-- Voice
disable_von INTEGER NOT NULL DEFAULT 0,
von_codec INTEGER NOT NULL DEFAULT 1, -- 1 = OPUS
CHECK (von_codec IN (0, 1)),
von_codec_quality INTEGER NOT NULL DEFAULT 20, -- 1-30
-- Network quality kick thresholds
max_ping INTEGER NOT NULL DEFAULT 250,
max_packet_loss INTEGER NOT NULL DEFAULT 50,
max_desync INTEGER NOT NULL DEFAULT 200,
disconnect_timeout INTEGER NOT NULL DEFAULT 15,
kick_on_ping INTEGER NOT NULL DEFAULT 1,
kick_on_packet_loss INTEGER NOT NULL DEFAULT 1,
kick_on_desync INTEGER NOT NULL DEFAULT 1,
kick_on_timeout INTEGER NOT NULL DEFAULT 1,
-- Security
battleye INTEGER NOT NULL DEFAULT 1,
verify_signatures INTEGER NOT NULL DEFAULT 2, -- 0 | 1 | 2 (1 = check but don't kick)
allowed_file_patching INTEGER NOT NULL DEFAULT 0, -- 0 | 1 | 2
-- Difficulty
forced_difficulty TEXT NOT NULL DEFAULT 'Regular', -- Recruit | Regular | Veteran | Custom
-- Misc
timestamp_format TEXT NOT NULL DEFAULT 'short', -- none | short | full
auto_select_mission INTEGER NOT NULL DEFAULT 0,
random_mission_order INTEGER NOT NULL DEFAULT 0,
missions_to_restart INTEGER NOT NULL DEFAULT 0,
missions_to_shutdown INTEGER NOT NULL DEFAULT 0,
log_file TEXT NOT NULL DEFAULT 'server_console.log',
skip_lobby INTEGER NOT NULL DEFAULT 0,
drawing_in_map INTEGER NOT NULL DEFAULT 1,
upnp INTEGER NOT NULL DEFAULT 0,
loopback INTEGER NOT NULL DEFAULT 0,
statistics_enabled INTEGER NOT NULL DEFAULT 1,
force_rotor_lib INTEGER NOT NULL DEFAULT 0, -- 0=player, 1=AFM, 2=SFM
CHECK (force_rotor_lib IN (0, 1, 2)),
required_build INTEGER NOT NULL DEFAULT 0,
steam_protocol_max_data_size INTEGER NOT NULL DEFAULT 1024,
-- MOTD
motd_lines TEXT NOT NULL DEFAULT '[]', -- JSON array of strings
motd_interval REAL NOT NULL DEFAULT 5.0,
-- Event scripts
on_user_connected TEXT DEFAULT '',
on_user_disconnected TEXT DEFAULT '',
on_unsigned_data TEXT DEFAULT 'kick (_this select 0)',
on_hacked_data TEXT DEFAULT 'kick (_this select 0)',
double_id_detected TEXT DEFAULT '',
-- Headless clients (JSON arrays)
headless_clients TEXT NOT NULL DEFAULT '[]', -- e.g. '["127.0.0.1"]'
local_clients TEXT NOT NULL DEFAULT '[]',
-- Admin UIDs whitelist
admin_uids TEXT NOT NULL DEFAULT '[]', -- JSON array of Steam UIDs
-- File extension whitelists (JSON arrays)
allowed_load_extensions TEXT NOT NULL DEFAULT '["hpp","sqs","sqf","fsm","cpp","paa","txt","xml","inc","ext","sqm","ods","fxy","lip","csv","kb","bik","bikb","html","htm","biedi"]',
allowed_preprocess_extensions TEXT NOT NULL DEFAULT '["hpp","sqs","sqf","fsm","cpp","paa","txt","xml","inc","ext","sqm","ods","fxy","lip","csv","kb","bik","bikb","html","htm","biedi"]',
allowed_html_extensions TEXT NOT NULL DEFAULT '["htm","html","xml","txt"]',
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
CHECK (verify_signatures IN (0, 1, 2)),
CHECK (allowed_file_patching IN (0, 1, 2)),
CHECK (von_codec_quality BETWEEN 1 AND 30),
CHECK (forced_difficulty IN ('Recruit', 'Regular', 'Veteran', 'Custom')),
CHECK (vote_threshold >= 0.0 AND vote_threshold <= 1.0),
CHECK (max_players > 0)
CREATE TABLE game_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
game_type TEXT NOT NULL, -- for validation; matches servers.game_type
section TEXT NOT NULL, -- e.g. 'server', 'basic', 'profile', 'launch', 'rcon'
config_json TEXT NOT NULL DEFAULT '{}', -- JSON validated by adapter's Pydantic model
config_version INTEGER NOT NULL DEFAULT 1, -- optimistic locking version; incremented on each write
schema_version TEXT NOT NULL DEFAULT '1.0', -- adapter schema version at time of last write
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(server_id, section)
);
CREATE INDEX idx_game_configs_server ON game_configs(server_id);
CREATE INDEX idx_game_configs_type_section ON game_configs(game_type, section);
```
---
**How it works:**
- Each adapter defines config sections via `ConfigGenerator.get_sections()``{section_name: PydanticModelClass}`
- Core reads/writes JSON blobs; adapter validates them on write and parses them on read
- Arma 3 has 5 sections: `server`, `basic`, `profile`, `launch`, `rcon`
- Another game might have 2 sections or 8 — no migration needed
- Sensitive fields within JSON (passwords, rcon_password) are encrypted at the application layer via Fernet before storage
### Table: `basic_configs`
**Arma 3 config sections and their JSON structures:**
Stores `basic.cfg` (bandwidth) settings. One row per server.
```sql
CREATE TABLE basic_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL UNIQUE REFERENCES servers(id) ON DELETE CASCADE,
min_bandwidth INTEGER NOT NULL DEFAULT 800000,
max_bandwidth INTEGER NOT NULL DEFAULT 25000000,
max_msg_send INTEGER NOT NULL DEFAULT 384, -- default 128; higher = desync risk
max_size_guaranteed INTEGER NOT NULL DEFAULT 512,
max_size_non_guaranteed INTEGER NOT NULL DEFAULT 256,
min_error_to_send REAL NOT NULL DEFAULT 0.003,
max_custom_file_size INTEGER NOT NULL DEFAULT 100000,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
Section `server` — maps to `server.cfg` parameters:
```json
{
"hostname": "My Arma 3 Server",
"password": "encrypted:...",
"password_admin": "encrypted:...",
"server_command_password": "encrypted:...",
"max_players": 40,
"kick_duplicate": 1,
"persistent": 1,
"vote_threshold": 0.33,
"vote_mission_players": 1,
"vote_timeout": 60,
"role_timeout": 90,
"briefing_timeout": 60,
"debriefing_timeout": 45,
"lobby_idle_timeout": 300,
"disable_von": 0,
"von_codec": 1,
"von_codec_quality": 20,
"max_ping": 250,
"max_packet_loss": 50,
"max_desync": 200,
"disconnect_timeout": 15,
"kick_on_ping": 1,
"kick_on_packet_loss": 1,
"kick_on_desync": 1,
"kick_on_timeout": 1,
"battleye": 1,
"verify_signatures": 2,
"allowed_file_patching": 0,
"forced_difficulty": "Regular",
"timestamp_format": "short",
"auto_select_mission": 0,
"random_mission_order": 0,
"missions_to_restart": 0,
"missions_to_shutdown": 0,
"log_file": "server_console.log",
"skip_lobby": 0,
"drawing_in_map": 1,
"upnp": 0,
"loopback": 0,
"statistics_enabled": 1,
"force_rotor_lib": 0,
"required_build": 0,
"steam_protocol_max_data_size": 1024,
"motd_lines": ["Welcome!", "Have fun"],
"motd_interval": 5.0,
"on_user_connected": "",
"on_user_disconnected": "",
"on_unsigned_data": "kick (_this select 0)",
"on_hacked_data": "kick (_this select 0)",
"double_id_detected": "",
"headless_clients": [],
"local_clients": [],
"admin_uids": [],
"allowed_load_extensions": ["hpp","sqs","sqf","fsm","cpp","paa","txt","xml","inc","ext","sqm","ods","fxy","lip","csv","kb","bik","bikb","html","htm","biedi"],
"allowed_preprocess_extensions": ["hpp","sqs","sqf","fsm","cpp","paa","txt","xml","inc","ext","sqm","ods","fxy","lip","csv","kb","bik","bikb","html","htm","biedi"],
"allowed_html_extensions": ["htm","html","xml","txt"]
}
```
---
### Table: `server_profiles`
Stores `server.Arma3Profile` difficulty settings. One row per server.
```sql
CREATE TABLE server_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL UNIQUE REFERENCES servers(id) ON DELETE CASCADE,
-- Custom difficulty options (all 0/1 or 0/1/2)
reduced_damage INTEGER NOT NULL DEFAULT 0,
group_indicators INTEGER NOT NULL DEFAULT 0,
friendly_tags INTEGER NOT NULL DEFAULT 0,
enemy_tags INTEGER NOT NULL DEFAULT 0,
detected_mines INTEGER NOT NULL DEFAULT 0,
commands INTEGER NOT NULL DEFAULT 1,
waypoints INTEGER NOT NULL DEFAULT 1,
tactical_ping INTEGER NOT NULL DEFAULT 0,
weapon_info INTEGER NOT NULL DEFAULT 2,
stance_indicator INTEGER NOT NULL DEFAULT 2,
stamina_bar INTEGER NOT NULL DEFAULT 0,
weapon_crosshair INTEGER NOT NULL DEFAULT 0,
vision_aid INTEGER NOT NULL DEFAULT 0,
third_person_view INTEGER NOT NULL DEFAULT 0,
camera_shake INTEGER NOT NULL DEFAULT 1,
score_table INTEGER NOT NULL DEFAULT 1,
death_messages INTEGER NOT NULL DEFAULT 1,
von_id INTEGER NOT NULL DEFAULT 1,
map_content_friendly INTEGER NOT NULL DEFAULT 0,
map_content_enemy INTEGER NOT NULL DEFAULT 0,
map_content_mines INTEGER NOT NULL DEFAULT 0,
auto_report INTEGER NOT NULL DEFAULT 0,
multiple_saves INTEGER NOT NULL DEFAULT 0,
-- AI level
ai_level_preset INTEGER NOT NULL DEFAULT 3, -- 0=Low,1=Normal,2=High,3=Custom
skill_ai REAL NOT NULL DEFAULT 0.5,
precision_ai REAL NOT NULL DEFAULT 0.5,
CHECK (ai_level_preset BETWEEN 0 AND 3),
CHECK (skill_ai BETWEEN 0.0 AND 1.0),
CHECK (precision_ai BETWEEN 0.0 AND 1.0),
CHECK (group_indicators BETWEEN 0 AND 2),
CHECK (weapon_info BETWEEN 0 AND 2),
CHECK (stance_indicator BETWEEN 0 AND 2),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
Section `basic` — maps to `basic.cfg` parameters:
```json
{
"min_bandwidth": 800000,
"max_bandwidth": 25000000,
"max_msg_send": 384,
"max_size_guaranteed": 512,
"max_size_non_guaranteed": 256,
"min_error_to_send": 0.003,
"max_custom_file_size": 100000
}
```
---
Section `profile` — maps to `server.Arma3Profile` difficulty:
```json
{
"reduced_damage": 0,
"group_indicators": 0,
"friendly_tags": 0,
"enemy_tags": 0,
"detected_mines": 0,
"commands": 1,
"waypoints": 1,
"tactical_ping": 0,
"weapon_info": 2,
"stance_indicator": 2,
"stamina_bar": 0,
"weapon_crosshair": 0,
"vision_aid": 0,
"third_person_view": 0,
"camera_shake": 1,
"score_table": 1,
"death_messages": 1,
"von_id": 1,
"map_content_friendly": 0,
"map_content_enemy": 0,
"map_content_mines": 0,
"auto_report": 0,
"multiple_saves": 0,
"ai_level_preset": 3,
"skill_ai": 0.5,
"precision_ai": 0.5
}
```
### Table: `launch_params`
Section `launch` — maps to CLI launch parameters:
```json
{
"world": "empty",
"extra_params": "",
"limit_fps": 50,
"auto_init": 0,
"load_mission_to_memory": 0,
"bandwidth_alg": null,
"enable_ht": 0,
"huge_pages": 0,
"cpu_count": null,
"ex_threads": 7,
"max_mem": null,
"no_logs": 0,
"netlog": 0
}
```
Extra command-line parameters added to the server launch command. One row per server.
```sql
CREATE TABLE launch_params (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL UNIQUE REFERENCES servers(id) ON DELETE CASCADE,
world TEXT NOT NULL DEFAULT 'empty',
extra_params TEXT NOT NULL DEFAULT '', -- raw extra params string
limit_fps INTEGER NOT NULL DEFAULT 50,
auto_init INTEGER NOT NULL DEFAULT 0,
load_mission_to_memory INTEGER NOT NULL DEFAULT 0,
bandwidth_alg INTEGER, -- NULL | 2
CHECK (bandwidth_alg IS NULL OR bandwidth_alg = 2),
enable_ht INTEGER NOT NULL DEFAULT 0,
huge_pages INTEGER NOT NULL DEFAULT 0,
cpu_count INTEGER, -- NULL = auto
ex_threads INTEGER NOT NULL DEFAULT 7,
max_mem INTEGER, -- NULL = auto
no_logs INTEGER NOT NULL DEFAULT 0,
netlog INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
Section `rcon` — BattlEye RCon settings:
```json
{
"rcon_password": "encrypted:...",
"max_ping": 200,
"enabled": 1
}
```
---
### Table: `mods`
Registered mods. Many-to-many with servers.
Registered mods. Many-to-many with servers. Scoped by `game_type`.
```sql
CREATE TABLE mods (
id INTEGER PRIMARY KEY AUTOINCREMENT,
game_type TEXT NOT NULL, -- scope mods by game type
name TEXT NOT NULL,
folder_path TEXT NOT NULL UNIQUE, -- absolute or relative path
workshop_id TEXT, -- Steam Workshop ID if applicable
folder_path TEXT NOT NULL,
workshop_id TEXT, -- Steam Workshop ID if applicable
description TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
game_data TEXT DEFAULT '{}', -- JSON for game-specific mod metadata
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (game_type, folder_path)
);
CREATE TABLE server_mods (
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
mod_id INTEGER NOT NULL REFERENCES mods(id) ON DELETE CASCADE,
is_server_mod INTEGER NOT NULL DEFAULT 0, -- 1 = -serverMod (not broadcast to clients)
is_server_mod INTEGER NOT NULL DEFAULT 0, -- 1 = server-side only (not broadcast to clients)
sort_order INTEGER NOT NULL DEFAULT 0,
game_data TEXT DEFAULT '{}', -- JSON for per-server mod overrides
PRIMARY KEY (server_id, mod_id)
);
CREATE INDEX idx_server_mods_server ON server_mods(server_id);
```
**Key changes:** Added `game_type` to `mods` (scopes the mod registry per game). Added `game_data` JSON columns for game-specific metadata that doesn't fit the common schema.
---
### Table: `missions`
Mission PBO files tracked per server.
Mission/scenario files tracked per server.
```sql
CREATE TABLE missions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
filename TEXT NOT NULL, -- e.g. "MyMission.Altis.pbo"
mission_name TEXT NOT NULL, -- e.g. "MyMission.Altis"
terrain TEXT NOT NULL, -- e.g. "Altis"
mission_name TEXT NOT NULL, -- parsed by adapter
terrain TEXT, -- may be NULL for non-Arma games
file_size INTEGER, -- bytes
game_data TEXT DEFAULT '{}', -- JSON for game-specific mission metadata
uploaded_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (server_id, filename)
);
@@ -330,11 +311,13 @@ CREATE TABLE missions (
CREATE INDEX idx_missions_server ON missions(server_id);
```
**Key changes:** `terrain` is now nullable (not all games use the mission/terrain naming convention). Added `game_data` for adapter-specific metadata.
---
### Table: `mission_rotation`
Ordered mission cycle for a server.
Ordered mission/scenario cycle for a server.
```sql
CREATE TABLE mission_rotation (
@@ -342,40 +325,43 @@ CREATE TABLE mission_rotation (
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
mission_id INTEGER NOT NULL REFERENCES missions(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
difficulty TEXT NOT NULL DEFAULT 'Regular',
CHECK (difficulty IN ('Recruit', 'Regular', 'Veteran', 'Custom')),
params_json TEXT NOT NULL DEFAULT '{}', -- mission params override as JSON
difficulty TEXT, -- game-specific (NULL for games without difficulty)
params_json TEXT NOT NULL DEFAULT '{}', -- mission params as JSON
game_data TEXT DEFAULT '{}', -- adapter-specific rotation metadata
UNIQUE (server_id, sort_order)
);
CREATE INDEX idx_mission_rotation_server ON mission_rotation(server_id);
```
**Key changes:** `difficulty` is now nullable. Added `game_data` for adapter-specific rotation config.
---
### Table: `players`
Currently connected players (live state, refreshed by RConPollerThread).
Currently connected players (live state, refreshed by RemoteAdminPollerThread).
```sql
CREATE TABLE players (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
player_num INTEGER NOT NULL, -- BE player# (slot number)
slot_id TEXT NOT NULL, -- Game-specific slot identifier (was player_num int)
name TEXT NOT NULL,
guid TEXT, -- BattlEye GUID
steam_uid TEXT,
guid TEXT, -- Game-specific identifier (BattlEye GUID, Steam ID, etc.)
ip TEXT,
ping INTEGER,
verified INTEGER NOT NULL DEFAULT 0, -- 1 = signature verified
game_data TEXT DEFAULT '{}', -- JSON: {verified, steam_uid, etc.} for Arma 3
joined_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (server_id, player_num)
UNIQUE (server_id, slot_id)
);
CREATE INDEX idx_players_server ON players(server_id);
```
**Key changes:** `player_num` (INTEGER) renamed to `slot_id` (TEXT) for flexibility across games. `steam_uid` moved into `game_data` JSON (Arma 3 specific). `verified` moved into `game_data` JSON.
---
### Table: `player_history`
@@ -388,15 +374,15 @@ CREATE TABLE player_history (
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
name TEXT NOT NULL,
guid TEXT,
steam_uid TEXT,
ip TEXT,
game_data TEXT DEFAULT '{}', -- JSON for game-specific historical data
joined_at TEXT NOT NULL,
left_at TEXT NOT NULL DEFAULT (datetime('now')),
session_duration_seconds INTEGER
);
CREATE INDEX idx_player_history_server ON player_history(server_id);
CREATE INDEX idx_player_history_steam ON player_history(steam_uid);
CREATE INDEX idx_player_history_guid ON player_history(guid);
```
### Player History Retention Cleanup (run daily via APScheduler, keep 90 days)
@@ -409,42 +395,35 @@ WHERE left_at < datetime('now', '-90 days');
### Table: `bans`
Local ban records (source of truth for the UI). **Must sync bidirectionally with `battleye/ban.txt`** — BattlEye reads only from ban.txt. On API ban add/delete: also write to ban.txt. On startup: read ban.txt and upsert into DB.
**ban.txt format** (one entry per line):
```
GUID|IP timestamp reason
```
Example: `a1b2c3d4e5f6|192.168.1.1 1713260000 Cheating`
**Sync caveats:** ban.txt does not store `banned_by`, `expires_at`, or `is_active`. Timed bans are represented by a future timestamp (not minutes); permanent bans have timestamp `0`. On startup, `banned_by` is set to `'ban.txt'` for entries read from file. Deactivated bans (`is_active=0`) are not written to ban.txt.
Ban records. Core concept is game-agnostic; ban file sync is adapter-specific.
```sql
CREATE TABLE bans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
guid TEXT,
steam_uid TEXT,
name TEXT,
reason TEXT,
banned_by TEXT, -- admin username
banned_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT, -- NULL = permanent
is_active INTEGER NOT NULL DEFAULT 1,
game_data TEXT DEFAULT '{}', -- JSON: {steam_uid, ip, etc.}
CHECK (is_active IN (0, 1))
);
CREATE INDEX idx_bans_server ON bans(server_id);
CREATE INDEX idx_bans_guid ON bans(guid);
CREATE INDEX idx_bans_steam_uid ON bans(steam_uid);
CREATE INDEX idx_bans_active ON bans(is_active);
```
**Key changes:** Removed `steam_uid` as a dedicated column (moved to `game_data`). Added `game_data` JSON. Ban file sync (e.g., `battleye/ban.txt`) is handled by the adapter's `BanManager`.
---
### Table: `logs`
Parsed RPT log lines (rolling retention, default 7 days).
Parsed log lines (rolling retention, default 7 days).
```sql
CREATE TABLE logs (
@@ -458,10 +437,12 @@ CREATE TABLE logs (
);
CREATE INDEX idx_logs_server_ts ON logs(server_id, timestamp);
CREATE INDEX idx_logs_level ON logs(level); -- for ?level= filter
CREATE INDEX idx_logs_created ON logs(created_at); -- for retention cleanup
CREATE INDEX idx_logs_level ON logs(level);
CREATE INDEX idx_logs_created ON logs(created_at);
```
**No changes** — this table is fully game-agnostic. The adapter's `LogParser` converts game-specific log format into the standard `{timestamp, level, message}` tuple.
---
### Table: `metrics`
@@ -481,21 +462,24 @@ CREATE TABLE metrics (
CREATE INDEX idx_metrics_server_ts ON metrics(server_id, timestamp);
```
**No changes** — fully game-agnostic.
---
### Table: `server_events`
Audit trail of all significant events (start, stop, crash, restart, admin actions).
Audit trail of all significant events.
```sql
CREATE TABLE server_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
event_type TEXT NOT NULL,
-- event_type values:
-- Core event types:
-- 'started' | 'stopped' | 'crashed' | 'restarted' | 'config_updated'
-- 'player_kicked' | 'player_banned' | 'mission_changed' | 'admin_login'
-- 'rcon_command' | 'auto_restarted'
-- 'player_kicked' | 'player_banned' | 'admin_login'
-- 'auto_restarted' | 'max_restarts_exceeded'
-- Adapters may define additional event types
actor TEXT, -- username or 'system'
detail TEXT, -- JSON with event-specific data
created_at TEXT NOT NULL DEFAULT (datetime('now'))
@@ -504,22 +488,7 @@ CREATE TABLE server_events (
CREATE INDEX idx_events_server ON server_events(server_id, created_at);
```
---
### Table: `rcon_configs`
BattlEye RCon credentials per server.
```sql
CREATE TABLE rcon_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL UNIQUE REFERENCES servers(id) ON DELETE CASCADE,
rcon_password TEXT NOT NULL, -- encrypted at app layer
max_ping INTEGER NOT NULL DEFAULT 200,
enabled INTEGER NOT NULL DEFAULT 1,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```
**No changes** — fully game-agnostic. Adapters can emit additional event types.
---
@@ -528,12 +497,8 @@ CREATE TABLE rcon_configs (
```
users (1) ──────────────────────────────────── (many) server_events.actor
servers (1) ──┬── (1) server_configs
├── (1) basic_configs
├── (1) server_profiles
├── (1) launch_params
├── (1) rcon_configs
├── (many) server_mods ──── (many) mods
servers (1) ──┬── (many) game_configs ← JSON sections replace 5 Arma 3 tables
├── (many) server_mods ──── (many) mods (scoped by game_type)
├── (many) missions
├── (many) mission_rotation → missions
├── (many) players
@@ -546,6 +511,47 @@ servers (1) ──┬── (1) server_configs
---
## Encryption Strategy
Sensitive fields stored in `game_configs.config_json` are encrypted at the application layer before JSON serialization. The adapter declares which fields are sensitive via `ConfigGenerator.get_sensitive_fields(section) -> list[str]`.
**How it works:**
1. On write: Core calls `adapter.get_config_generator().get_sensitive_fields(section)` to get a list of JSON keys
2. ConfigRepository replaces those values with `"encrypted:" + Fernet.encrypt(value)` tokens
3. On read: ConfigRepository detects `"encrypted:"` prefix and decrypts
4. The adapter's Pydantic model sees plaintext — encryption is transparent to the adapter
**Arma 3 encrypted fields (declared by Arma3ConfigGenerator):**
- Section `server`: `password`, `password_admin`, `server_command_password`
- Section `rcon`: `rcon_password`
Encryption uses Fernet (AES-256) with `LANGUARD_ENCRYPTION_KEY`.
## Optimistic Locking for Config Updates
The `config_version` column in `game_configs` prevents lost updates when two admins edit simultaneously:
1. Client reads config section → gets `config_version: 3`
2. Client sends PUT with `config_version: 3` in request body
3. Server checks: `WHERE server_id = ? AND section = ? AND config_version = 3`
4. If match: increment version, write new JSON, return 200
5. If no match (version changed): return **409 Conflict** with current config for client-side merge
## game_data JSON Schema
The `game_data` columns on `players`, `missions`, `mods`, and `bans` are validated by adapter-provided Pydantic models. Each capability protocol optionally provides a schema:
| Table | game_data column | Protocol Method | Arma 3 Example |
|-------|-----------------|-----------------|-----------------|
| `players` | `game_data` | `RemoteAdmin.get_player_data_schema()` | `{verified: bool, steam_uid: str}` |
| `missions` | `game_data` | `MissionManager.get_mission_data_schema()` | `{terrain: str}` |
| `mods` | `game_data` | `ModManager.get_mod_data_schema()` | `{}` (empty for Arma 3) |
| `bans` | `game_data` | `BanManager.get_ban_data_schema()` | `{steam_uid: str, ip: str}` |
If an adapter doesn't define a game_data schema, the field accepts any JSON object (no validation).
---
## Maintenance Queries
### Log Retention Cleanup (run daily via APScheduler)
@@ -574,8 +580,8 @@ VACUUM;
## Migration Strategy
- Migrations are plain `.sql` files in `backend/migrations/`
- Naming: `001_initial_schema.sql`, `002_add_bans.sql`, etc.
- Migrations are plain `.sql` files in `backend/core/migrations/`
- Naming: `001_initial_schema.sql`, `002_add_game_type.sql`, etc.
- Tracked in a `schema_migrations` table:
```sql
CREATE TABLE schema_migrations (
@@ -584,3 +590,229 @@ VACUUM;
);
```
- Applied automatically at app startup by `database.py:run_migrations()`
- **Adapter-specific migrations** go in `adapters/<game_type>/migrations/` and are applied by the adapter's initialization (if the adapter needs extra tables beyond `game_configs`)
---
## Migration from Single-Game Schema
For existing deployments with the old Arma 3-specific schema:
### SQL Migration (002_multi_game_adapter.sql)
```sql
-- 1. Add game_type to servers
ALTER TABLE servers ADD COLUMN game_type TEXT NOT NULL DEFAULT 'arma3';
-- 2. Create game_configs table
CREATE TABLE game_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
game_type TEXT NOT NULL,
section TEXT NOT NULL,
config_json TEXT NOT NULL DEFAULT '{}',
config_version INTEGER NOT NULL DEFAULT 1,
schema_version TEXT NOT NULL DEFAULT '1.0',
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(server_id, section)
);
-- 3. Add game_data columns
ALTER TABLE players ADD COLUMN game_data TEXT DEFAULT '{}';
ALTER TABLE missions ADD COLUMN game_data TEXT DEFAULT '{}';
ALTER TABLE mods ADD COLUMN game_type TEXT NOT NULL DEFAULT 'arma3';
ALTER TABLE mods ADD COLUMN game_data TEXT DEFAULT '{}';
ALTER TABLE server_mods ADD COLUMN game_data TEXT DEFAULT '{}';
ALTER TABLE bans ADD COLUMN game_data TEXT DEFAULT '{}';
ALTER TABLE mission_rotation ADD COLUMN game_data TEXT DEFAULT '{}';
-- 4. Migrate player_num → slot_id (SQLite 3.25.0+)
ALTER TABLE players RENAME COLUMN player_num TO slot_id;
-- Change slot_id type: SQLite doesn't support ALTER COLUMN TYPE.
-- Workaround: create new table, copy data, drop old, rename.
-- See Python migration script below.
-- 5. Drop old Arma 3-specific tables (after Python migration confirms data copied)
-- DROP TABLE server_configs;
-- DROP TABLE basic_configs;
-- DROP TABLE server_profiles;
-- DROP TABLE launch_params;
-- DROP TABLE rcon_configs;
```
### Python Migration Script (002_migrate_arma3_config.py)
This script reads old Arma 3-specific tables, serializes each row to JSON, and inserts into `game_configs`. It runs AFTER the SQL migration creates the new table structure.
```python
"""Migrate Arma 3 config data from normalized tables to game_configs JSON.
Run after 002_multi_game_adapter.sql. This script:
1. Reads each old table row by row
2. Converts column values to JSON (handling type conversions)
3. Inserts into game_configs with proper section names
4. Migrates player data (player_num int → slot_id text, steam_uid → game_data)
5. Verifies row counts match before dropping old tables
Type conversion rules:
- INTEGER 0/1 → JSON boolean false/true (for boolean-like fields)
- TEXT containing JSON arrays (motd_lines, headless_clients, etc.) → parsed JSON arrays
- NULL values → omitted from JSON (Pydantic fills defaults on read)
- Encrypted fields (password, password_admin, rcon_password) → copied as-is (already encrypted)
Transaction: all inserts in a single transaction. On failure, rollback — old tables untouched.
"""
import json
import sqlite3
COLUMN_TYPE_MAP = {
# server_configs → section 'server'
'server_configs': {
'section': 'server',
'boolean_fields': [
'kick_duplicate', 'persistent', 'disable_von', 'battleye',
'kick_on_ping', 'kick_on_packet_loss', 'kick_on_desync', 'kick_on_timeout',
'auto_select_mission', 'random_mission_order', 'skip_lobby',
'drawing_in_map', 'upnp', 'loopback', 'statistics_enabled',
],
'json_array_fields': [
'motd_lines', 'headless_clients', 'local_clients', 'admin_uids',
'allowed_load_extensions', 'allowed_preprocess_extensions',
'allowed_html_extensions',
],
'encrypted_fields': ['password', 'password_admin', 'server_command_password'],
},
# basic_configs → section 'basic'
'basic_configs': {
'section': 'basic',
'boolean_fields': [],
'json_array_fields': [],
'encrypted_fields': [],
},
# server_profiles → section 'profile'
'server_profiles': {
'section': 'profile',
'boolean_fields': [
'reduced_damage', 'camera_shake', 'score_table', 'death_messages',
'von_id', 'auto_report', 'multiple_saves', 'stamina_bar',
'weapon_crosshair', 'vision_aid', 'third_person_view',
'map_content_friendly', 'map_content_enemy', 'map_content_mines',
],
'json_array_fields': [],
'encrypted_fields': [],
},
# launch_params → section 'launch'
'launch_params': {
'section': 'launch',
'boolean_fields': [
'auto_init', 'load_mission_to_memory', 'enable_ht',
'huge_pages', 'no_logs', 'netlog',
],
'json_array_fields': [],
'encrypted_fields': [],
},
# rcon_configs → section 'rcon'
'rcon_configs': {
'section': 'rcon',
'boolean_fields': ['enabled'],
'json_array_fields': [],
'encrypted_fields': ['rcon_password'],
},
}
def migrate_config_table(db: sqlite3.Connection, table: str, mapping: dict):
"""Read rows from old table, serialize to JSON, insert into game_configs."""
rows = db.execute(f"SELECT * FROM {table}").fetchall()
columns = [desc[0] for desc in db.execute(f"SELECT * FROM {table} LIMIT 0").description]
for row in rows:
row_dict = dict(zip(columns, row))
server_id = row_dict.pop('id', None) or row_dict.pop('server_id', None)
config = {}
for col, value in row_dict.items():
if value is None:
continue # omit NULLs; Pydantic fills defaults
if col in mapping.get('boolean_fields', []):
config[col] = bool(value)
elif col in mapping.get('json_array_fields', []):
config[col] = json.loads(value) if isinstance(value, str) else value
elif col in mapping.get('encrypted_fields', []):
config[col] = value # already encrypted, copy as-is
elif col in ('updated_at', 'created_at', 'id', 'server_id'):
continue # skip metadata columns
else:
config[col] = value
db.execute("""
INSERT INTO game_configs (server_id, game_type, section, config_json, schema_version)
VALUES (?, 'arma3', ?, ?, '1.0')
""", (server_id, mapping['section'], json.dumps(config)))
def migrate_player_data(db: sqlite3.Connection):
"""Convert player_num (int) to slot_id (text), move steam_uid to game_data."""
# Create new players table with slot_id as TEXT
db.execute("""
CREATE TABLE players_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL,
slot_id TEXT NOT NULL,
name TEXT NOT NULL,
guid TEXT,
ip TEXT,
ping INTEGER,
game_data TEXT DEFAULT '{}',
joined_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (server_id, slot_id)
)
""")
rows = db.execute("SELECT * FROM players").fetchall()
columns = [desc[0] for desc in db.execute("SELECT * FROM players LIMIT 0").description]
for row in rows:
d = dict(zip(columns, row))
game_data = {}
if d.get('steam_uid'):
game_data['steam_uid'] = d['steam_uid']
if d.get('verified') is not None:
game_data['verified'] = bool(d['verified'])
db.execute("""
INSERT INTO players_new (id, server_id, slot_id, name, guid, ip, ping,
game_data, joined_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
d['id'], d['server_id'], str(d['player_num']),
d['name'], d.get('guid'), d.get('ip'), d.get('ping'),
json.dumps(game_data), d.get('joined_at'), d.get('updated_at'),
))
db.execute("DROP TABLE players")
db.execute("ALTER TABLE players_new RENAME TO players")
db.execute("CREATE INDEX idx_players_server ON players(server_id)")
def run_migration(db_path: str):
db = sqlite3.connect(db_path)
try:
db.execute("BEGIN TRANSACTION")
for table, mapping in COLUMN_TYPE_MAP.items():
count_before = db.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
migrate_config_table(db, table, mapping)
count_after = db.execute(
"SELECT COUNT(*) FROM game_configs WHERE game_type='arma3' AND section=?",
(mapping['section'],)
).fetchone()[0]
assert count_after == count_before, (
f"Row count mismatch for {table}: {count_before} → {count_after}"
)
migrate_player_data(db)
db.execute("COMMIT")
print("Migration successful. Old tables can now be dropped.")
except Exception as e:
db.execute("ROLLBACK")
print(f"Migration FAILED (rolled back): {e}")
raise
finally:
db.close()
```

1343
FRONTEND.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -4,15 +4,61 @@
Before starting, ensure the following are available:
- Python 3.11+
- A working Arma 3 dedicated server installation (for testing)
- A working Arma 3 dedicated server installation (for testing the first adapter)
- Node.js 18+ (for frontend dev server)
- The reference docs: ARCHITECTURE.md, DATABASE.md, API.md, MODULES.md, THREADING.md
---
## Phase 1Foundation (Start Here)
## Phase 0Adapter Framework (New)
**Goal:** Running FastAPI server with DB, auth, and basic server CRUD.
**Goal:** Build the adapter protocol + registry system before any other code. This is the foundation that makes every subsequent phase modular.
### Step 0.1 — Adapter protocols, exceptions, and registry
1. Create `backend/adapters/__init__.py` — auto-imports built-in adapters
2. Create `backend/adapters/protocols.py` — all capability Protocol definitions:
- `ConfigGenerator` (merged: schema + generation), `ProcessConfig`, `LogParser`
- `RemoteAdmin`, `RemoteAdminClient`
- `MissionManager`, `ModManager`, `BanManager`
- `GameAdapter` (composite protocol with `has_capability()` method)
- `ConfigGenerator` includes `get_sections()`, `get_sensitive_fields(section)`, `get_config_version()`
- `RemoteAdmin` includes `get_player_data_schema() -> type[BaseModel] | None`
- `MissionManager` includes `get_mission_data_schema() -> type[BaseModel] | None`
- `ModManager` includes `get_mod_data_schema() -> type[BaseModel] | None`
- `BanManager` includes `get_ban_data_schema() -> type[BaseModel] | None`
3. Create `backend/adapters/exceptions.py` — typed adapter exceptions:
- `AdapterError` (base)
- `ConfigWriteError` — atomic write failed (tmp file cleanup done)
- `ConfigValidationError` — adapter Pydantic validation failed
- `LaunchArgsError` — invalid launch arguments
- `RemoteAdminError` — admin protocol communication failed
- `ExeNotAllowedError` — executable not in adapter allowlist
4. Create `backend/adapters/registry.py``GameAdapterRegistry` singleton
5. Add `has_capability(name) -> bool` method to `GameAdapter` protocol — core uses explicit capability probes instead of scattered `None` checks
6. Write unit tests: register adapter, get adapter, list game types, missing adapter raises error, exceptions are catchable by type, has_capability returns correct bools
### Step 0.2 — Arma 3 adapter skeleton
1. Create `backend/adapters/arma3/__init__.py` — exports and registers `ARMA3_ADAPTER`
2. Create `backend/adapters/arma3/adapter.py``Arma3Adapter` class (all methods return stubs initially)
3. Create `backend/adapters/arma3/process_config.py``Arma3ProcessConfig` (full implementation)
4. Create `backend/adapters/arma3/config_generator.py` — Pydantic models (ServerConfig, BasicConfig, ProfileConfig, LaunchConfig, RConConfig) + `Arma3ConfigGenerator` (schema + generation merged)
5. **Third-party adapter loading**: add `languard.adapters` entry_point group to `pyproject.toml`:
```toml
[project.entry-points."languard.adapters"]
arma3 = "adapters.arma3:ARMA3_ADAPTER"
```
Core scans entry_points at startup via `importlib.metadata` in addition to built-in imports.
6. Write unit tests: adapter registers, protocols satisfied, config schema produces valid JSON Schema
**Test:** Import adapters module → `GameAdapterRegistry.get("arma3")` returns a valid adapter. `GameAdapterRegistry.list_game_types()` returns `[{"game_type": "arma3", "display_name": "Arma 3", ...}]`.
---
## Phase 1 — Foundation
**Goal:** Running FastAPI server with DB, auth, and basic server CRUD using the adapter framework.
### Step 1.1 — Project scaffold
@@ -22,230 +68,214 @@ cd backend
python -m venv venv
venv/Scripts/activate
pip install fastapi uvicorn[standard] sqlalchemy python-jose[cryptography] passlib[bcrypt] cryptography psutil apscheduler python-multipart slowapi pytest pytest-asyncio httpx
# uvloop (faster event loop) is Linux/macOS only — skip on Windows:
# pip install uvloop # only on Linux/macOS
pip freeze > requirements.txt
```
Create:
- `backend/config.py` — Settings class (see MODULES.md)
- `backend/config.py` — Settings class
- `backend/main.py` — FastAPI app factory, startup/shutdown hooks
- `backend/conftest.py` — pytest fixtures (in-memory SQLite, test client)
- `.env.example` — All env vars documented
### Step 1.2 — Database + Migrations
1. Create `backend/migrations/001_initial_schema.sql` — all tables from DATABASE.md
- Include all CHECK constraints (role, status, verify_signatures, von_codec_quality, etc.)
1. Create `backend/core/migrations/001_initial_schema.sql` — all core tables:
- `schema_migrations`, `users`, `servers` (with `game_type`), `game_configs`
- `mods` (with `game_type`, `game_data`), `server_mods`
- `missions`, `mission_rotation` (with `game_data`)
- `players` (with `slot_id` TEXT, `game_data`), `player_history`
- `bans` (with `game_data`), `logs`, `metrics`, `server_events`
- Include all CHECK constraints and indexes
- Include `PRAGMA busy_timeout=5000` in engine setup
- **Important:** Put `CREATE TABLE IF NOT EXISTS schema_migrations` as the very first
statement — the migration runner queries this table before it can track anything.
2. Create `backend/dal/event_repository.py``ServerEventRepository` (needed by Phase 3 threads)
2. Create `backend/core/dal/event_repository.py`
3. Create `backend/database.py`:
- `get_engine()` with WAL + FK pragma
- `run_migrations()` — reads and applies `.sql` files from migrations/
- `get_db()` — FastAPI dependency (sync session)
- `run_migrations()`
- `get_db()` — FastAPI dependency
- `get_thread_db()` — thread-local session factory
3. Call `run_migrations()` in `main.py:on_startup()`
4. Call `run_migrations()` in `main.py:on_startup()`
**Test:** Start app, confirm `languard.db` created with all tables. Run `pytest` with in-memory SQLite to verify schema creates cleanly.
**Test:** Start app, confirm `languard.db` created with all tables. Run `pytest` with in-memory SQLite.
### Step 1.3 — Auth module
1. `backend/auth/utils.py``hash_password`, `verify_password`, `create_access_token`, `decode_access_token`
2. `backend/auth/schemas.py``LoginRequest`, `TokenResponse`, `UserResponse`
3. `backend/auth/service.py``AuthService` (create user, login, list users)
4. `backend/auth/router.py` — login, me, users CRUD
5. `backend/dependencies.py``get_current_user`, `require_admin`
6. `main.py` — seed default admin user on first startup if users table empty
- **Generate a random password** and print it to stdout once (NOT admin/admin)
- Add rate limiting to `POST /auth/login` (5 attempts/minute per IP via slowapi)
- Add input sanitization for all string fields in auth schemas
1. `backend/core/auth/utils.py` — `hash_password`, `verify_password`, `create_access_token`, `decode_access_token`
2. `backend/core/auth/schemas.py` — `LoginRequest`, `TokenResponse`, `UserResponse`
3. `backend/core/auth/service.py` — `AuthService`
4. `backend/core/auth/router.py` — login, me, users CRUD
5. `backend/dependencies.py` — `get_current_user`, `require_admin`, `get_adapter_for_server`
6. `main.py` — seed default admin user on first startup (random password printed to stdout)
7. Add rate limiting to `POST /auth/login` (5 attempts/minute per IP via slowapi)
**Test:** `POST /api/auth/login` returns JWT. `GET /api/auth/me` with token returns user. Rate limiting returns 429 after 5 failed attempts.
### Step 1.4 — Server CRUD (no process management yet)
1. `backend/dal/server_repository.py`
2. `backend/dal/config_repository.py`
3. `backend/servers/schemas.py`
4. `backend/servers/router.py` — GET, POST, PUT, DELETE /servers and /servers/{id}
5. `backend/servers/service.py` — CRUD methods only (skip start/stop for now)
6. `backend/utils/file_utils.py``ensure_server_dirs()`, `sanitize_filename()`
7. `backend/utils/port_checker.py``is_port_in_use()`, `check_server_ports_available()`
8. Port validation on create/start: check game_port through game_port+4
1. `backend/core/dal/server_repository.py`
2. `backend/core/dal/config_repository.py` — manages `game_configs` table
3. `backend/core/servers/schemas.py` — `CreateServerRequest` (includes `game_type`)
4. `backend/core/servers/router.py` — GET, POST, PUT, DELETE /servers
5. `backend/core/servers/service.py` — CRUD methods + `create_server` seeds config sections from adapter defaults
6. `backend/core/utils/file_utils.py` — `ensure_server_dirs()` (uses adapter's `get_server_dir_layout()`)
7. `backend/core/utils/port_checker.py` — `is_port_in_use()`, `check_server_ports_available()`
- **Full cross-game port checking**: query ALL running servers, resolve each adapter, get port conventions for each, check the full derived port set
- Example: Arma 3 uses game port + 1 (Steam query), BattlEye RCon port; another game may use different conventions — all checked
**Test:** Create server via API, confirm DB row + directory created.
**Test:** Create server via API with `game_type: "arma3"` → confirm DB row + `game_configs` rows + directory created. Create a second server with a port that conflicts with derived ports of the first → confirm 409 error.
### Step 1.5 — Game type discovery endpoints
1. `backend/core/games/router.py` — `GET /games`, `GET /games/{type}`, `GET /games/{type}/config-schema`, `GET /games/{type}/defaults`
**Test:** `GET /api/games` returns `[{"game_type": "arma3", ...}]`. `GET /api/games/arma3/config-schema` returns JSON Schema for all 5 Arma 3 config sections.
### Step 1.6 — Migration script for existing Arma 3 data
If upgrading from the single-game schema, create a migration script:
1. Create `backend/core/migrations/002_migrate_arma3_config.py`
2. Column type map: `max_players` INT→JSON `maxPlayers`, `hostname` TEXT→JSON `hostname`, etc.
3. `migrate_config_table()`: read old Arma 3 config table rows → build `game_configs` JSON blobs → insert into new table → delete old rows
4. `migrate_player_data()`: convert `player_num` INTEGER → `slot_id` TEXT
5. Transaction + rollback: all migration runs inside a single DB transaction; on failure, full rollback
6. Row count verification: after migration, assert row counts match between old and new tables
7. Idempotent: safe to run multiple times (checks if migration already applied)
**Test:** Create test DB with old single-game schema + sample data → run migration script → verify all data in new tables → verify old tables dropped.
---
## Phase 2 — Process Management
## Phase 2 — Arma 3 Adapter Implementation
**Goal:** Start/stop actual `arma3server.exe` processes.
**Goal:** Complete the Arma 3 adapter with config generation and process management. This phase proves the adapter architecture works end-to-end with the primary game.
### Step 2.1 — Config Generator
### Step 2.1 — Config Generator (Arma 3 adapter)
1. `backend/servers/config_generator.py`
2. **Use a structured builder** (NOT f-strings) — escape double quotes and newlines in all user-supplied string values to prevent config injection
3. Write `server.cfg` covering all params from DATABASE.md, including mission rotation as `class Missions {}` block
1. `backend/adapters/arma3/config_generator.py` — `Arma3ConfigGenerator`
2. **Use a structured builder** (NOT f-strings) — escape double quotes and newlines in all user-supplied string values
3. Write `server.cfg` covering all params from config schema, including mission rotation as `class Missions {}` block
4. Write `basic.cfg`
5. Write `server.Arma3Profile`**written to `servers/{id}/server/server.Arma3Profile`** (Arma 3 reads from the `-name` subdirectory)
6. Write `BESERVER_CFG_TEMPLATE`**required for BattlEye RCon to work**
```
# servers/{id}/battleye/beserver.cfg
RConPassword {rcon_password}
RConPort {rcon_port}
```
`write_beserver_cfg()` must create the `battleye/` directory and write this file.
Without it BattlEye will not open an RCon port regardless of launch parameters.
7. `build_launch_args()` — assembles full CLI arg list
- Include `-bepath=./battleye` to point BE at the generated config (relative to cwd)
- Include `-profiles=./` and `-name=server` for profile directory
- All relative paths resolve against `cwd=servers/{id}/` set in ProcessManager
8. Set file permissions 0600 on config files containing passwords (server.cfg, beserver.cfg)
5. Write `server.Arma3Profile` — written to `servers/{id}/server/server.Arma3Profile`
6. Write `beserver.cfg` — creates `battleye/` directory, writes RCon config
7. `build_launch_args()` — assembles full CLI arg list including `-bepath=./battleye`
8. `preview_config()` — renders all files without writing to disk, returns `dict[str, str]` of label→content (filenames for file-based, variable names for env-var, argument names for CLI)
9. Set file permissions 0600 on config files containing passwords
10. **Atomic write pattern**: all config files written to `.tmp` files first, then `os.replace()` for atomic rename. On any write failure, all `.tmp` files are cleaned up and original files remain untouched. Raises `ConfigWriteError` on failure.
**Test:** `ConfigGenerator.write_all(server_id)` → inspect all generated files for correctness.
Verify `servers/{id}/battleye/beserver.cfg` exists with the correct RCon password.
Verify `servers/{id}/server/server.Arma3Profile` exists.
Test config injection prevention: set hostname to `X"; passwordAdmin = "pwned"; //` — verify generated server.cfg does NOT contain the injected directive.
Validate generated `server.cfg` manually by running the server with it.
**Test:** `Arma3ConfigGenerator.write_configs(server_id, dir, config)` → inspect all generated files. Test config injection prevention: set hostname to `X"; passwordAdmin = "pwned"; //` — verify generated server.cfg does NOT contain the injected directive. Test atomic write: mock `os.replace()` to raise OSError → confirm `.tmp` files are cleaned up and original files are untouched.
### Step 2.2 — Process Manager
### Step 2.2 — Process Manager (core)
1. `backend/servers/process_manager.py` — `ProcessManager` singleton
2. `start(server_id, exe_path, args, cwd=servers/{id}/)` — subprocess.Popen with cwd set to server instance dir
3. `stop(server_id, timeout=30)` — on Windows: `terminate()` = hard kill (no SIGTERM). Graceful shutdown is via RCon `#shutdown` in ServerService.
1. `backend/core/servers/process_manager.py` — `ProcessManager` singleton (game-agnostic)
2. `start(server_id, exe_path, args, cwd=servers/{id}/)`
3. `stop(server_id, timeout=30)` — on Windows: `terminate()` = hard kill
4. `kill()`, `is_running()`, `get_pid()`
5. `recover_on_startup()` — verify PID is alive AND process name matches arma3server (prevents PID reuse)
6. Wire `ServerService.start()` and `ServerService.stop()`
5. `recover_on_startup()` — verify PID is alive AND process name matches adapter allowlist (prevents PID reuse)
6. Wire `ServerService.start()` and `ServerService.stop()` — both delegate to adapter for exe validation and config generation
7. Add `POST /servers/{id}/start`, `POST /servers/{id}/stop`, `POST /servers/{id}/kill` endpoints
8. **Typed exception handling in start flow**: catch and map adapter exceptions to HTTP responses:
- `ConfigWriteError` → 500 (atomic write failed, tmp cleaned)
- `ConfigValidationError` → 422 (invalid config values)
- `LaunchArgsError` → 400 (invalid launch arguments)
- `ExeNotAllowedError` → 403 (executable not in adapter allowlist)
**Test:** Start a server via API → confirm process appears in Task Manager. Stop it → confirm process ends.
**Test:** Start a server via API → confirm process appears in Task Manager. Stop it → confirm process ends. Test error paths: set invalid exe path → confirm 403 ExeNotAllowedError response.
### Step 2.3 — Config endpoints
### Step 2.3 — Config endpoints (core + adapter validation)
1. `GET /servers/{id}/config`
2. `PUT /servers/{id}/config/server`
3. `PUT /servers/{id}/config/basic`
4. `PUT /servers/{id}/config/profile`
5. `PUT /servers/{id}/config/launch`
6. `GET /servers/{id}/config/preview`
1. `GET /servers/{id}/config` — reads all sections from `game_configs`
2. `GET /servers/{id}/config/{section}` — reads single section, response includes `_meta` with `config_version` and `schema_version`
3. `PUT /servers/{id}/config/{section}` — validates against adapter's Pydantic model, encrypts sensitive fields via `adapter.get_sensitive_fields(section)`, stores in `game_configs`
- **Optimistic locking**: client must send `config_version` in request body; if it doesn't match the current row's `config_version`, return 409 Conflict with `CONFIG_VERSION_CONFLICT` error code
- On successful write, increment `config_version` in the row
4. `GET /servers/{id}/config/preview` — delegates to adapter's `preview_config()`, returns `dict[str, str]` of label→content
5. `GET /servers/{id}/config/download/{filename}` — filename validated against adapter allowlist
**Test:** Update hostname via API → regenerate and start server → confirm new hostname appears in server browser.
**Test:** Update hostname via API → regenerate and start server → confirm new hostname appears in server browser. Test optimistic locking: two concurrent PUT requests with same config_version → one succeeds (200), one fails (409 Conflict).
---
## Phase 3 — Background Threads
## Phase 3 — Background Threads (Core + Adapter)
**Goal:** Live monitoring — process crash detection, log tailing, metrics.
### Step 3.1 — Thread infrastructure
1. `backend/threads/base_thread.py` — `BaseServerThread`
2. `backend/threads/thread_registry.py` — `ThreadRegistry` singleton
1. `backend/core/threads/base_thread.py` — `BaseServerThread`
2. `backend/core/threads/thread_registry.py` — `ThreadRegistry` (adapter-aware)
3. Wire `start_server_threads()` / `stop_server_threads()` into `ServerService.start()` / `ServerService.stop()`
### Step 3.2 — Process Monitor Thread
### Step 3.2 — Process Monitor Thread (core)
1. `backend/threads/process_monitor.py`
1. `backend/core/threads/process_monitor.py`
2. Crash detection + status update in DB
3. Auto-restart with exponential backoff
3. Auto-restart with exponential backoff (daemon cleanup thread pattern)
**Test:** Start server → kill process manually → confirm DB status changes to 'crashed'.
**Test:** Enable auto_restart → kill → confirm server restarts automatically.
### Step 3.3 — Log Tail Thread
### Step 3.3 — Log Parser (Arma 3 adapter) + Log Tail Thread (core)
1. `backend/logs/parser.py` — `RPTParser`
2. `backend/dal/log_repository.py`
3. `backend/threads/log_tail.py`
4. `backend/logs/service.py`
5. `backend/logs/router.py` — `GET /servers/{id}/logs`
1. `backend/adapters/arma3/log_parser.py` — `RPTParser` implementing `LogParser` protocol
2. `backend/core/threads/log_tail.py` — `LogTailThread` (generic, takes adapter's `LogParser`)
3. `backend/core/dal/log_repository.py`
4. `backend/core/logs/service.py`
5. `backend/core/logs/router.py` — `GET /servers/{id}/logs`
**Test:** Start server → `GET /api/servers/{id}/logs` returns recent RPT lines.
### Step 3.4 — Metrics Collector Thread
### Step 3.4 — Metrics Collector Thread (core)
1. `backend/metrics/service.py`
2. `backend/dal/metrics_repository.py`
3. `backend/threads/metrics_collector.py`
4. `backend/metrics/router.py` — `GET /servers/{id}/metrics`
1. `backend/core/metrics/service.py`
2. `backend/core/dal/metrics_repository.py`
3. `backend/core/threads/metrics_collector.py`
4. `backend/core/metrics/router.py` — `GET /servers/{id}/metrics`
**Test:** Running server → query metrics endpoint → see CPU/RAM data points.
---
## Phase 4 — BattlEye RCon
## Phase 4 — Remote Admin (Arma 3: BattlEye RCon)
**Goal:** Real-time player list, in-game admin commands.
**Goal:** Real-time player list, in-game admin commands via the adapter's RemoteAdmin protocol.
### Step 4.1 — RCon Client
### Step 4.1 — RCon Client (Arma 3 adapter)
1. `backend/rcon/client.py` — `BERConClient`
1. `backend/adapters/arma3/rcon_client.py` — `BERConClient`
2. Implement BE RCon UDP protocol:
- Packet structure: `'BE'` + CRC32 (little-endian) + type byte + payload
- Login: type `0x00`, payload = password
- Command: type `0x01`, payload = sequence byte + command string
- Keepalive: type `0x02`, payload = empty
3. **Request multiplexer**: track pending requests by sequence byte, route responses to correct caller via `threading.Event` per request. Background receiver thread reads all incoming packets.
3. **Request multiplexer**: track pending requests by sequence byte, route responses to correct caller via `threading.Event` per request
4. `parse_players_response()` — parse `players` command output
5. Handle unsolicited server messages (type 0x02) — enqueue for event logging
BattlEye RCon packet format reference:
```
Login packet (client → server):
42 45 # 'BE'
[CRC32 LE] # checksum of bytes after CRC
FF # packet type prefix
00 # login type
[password] # ASCII password
Command packet:
42 45
[CRC32 LE]
FF
01
[seq byte] # 0x00-0xFF, wraps around
[command] # ASCII command string
Command response (server → client):
42 45
[CRC32 LE]
FF
01 # 0x01 = command response (same type byte as outgoing command)
[seq byte]
[response] # ASCII response text
Server-pushed message (server → client, unsolicited):
42 45
[CRC32 LE]
FF
02 # 0x02 = server message (chat events, kill events, etc.)
[seq byte]
[message] # ASCII message text
```
5. Handle unsolicited server messages (type 0x02)
**Test:** Connect BERConClient to a running server with BattlEye → successfully login → send `players` → receive response.
### Step 4.2 — RCon Service + Poller Thread
### Step 4.2 — RCon Service (Arma 3 adapter) + Remote Admin Poller Thread (core)
1. `backend/rcon/service.py` — `RConService`
2. `backend/threads/rcon_poller.py`
3. `backend/dal/player_repository.py`
4. `backend/players/service.py`
5. `backend/players/router.py` — `GET /servers/{id}/players`
1. `backend/adapters/arma3/rcon_service.py` — `Arma3RConService` implementing `RemoteAdmin` protocol
2. `backend/core/threads/remote_admin_poller.py` — `RemoteAdminPollerThread` (generic, takes adapter's `RemoteAdmin`)
3. `backend/core/dal/player_repository.py`
4. `backend/core/players/service.py`
5. `backend/core/players/router.py` — `GET /servers/{id}/players`
**Test:** Players join server → `GET /players` returns them with pings.
### Step 4.3 — Admin Actions via RCon
### Step 4.3 — Admin Actions via Remote Admin
1. `POST /servers/{id}/players/{num}/kick`
2. `POST /servers/{id}/players/{num}/ban`
3. `POST /servers/{id}/rcon/command`
4. `POST /servers/{id}/rcon/say`
5. `backend/dal/ban_repository.py`
1. `POST /servers/{id}/players/{slot_id}/kick` — delegates to adapter's `remote_admin.kick_player()`
2. `POST /servers/{id}/players/{slot_id}/ban` — delegates to adapter's `remote_admin.ban_player()`
3. `POST /servers/{id}/remote-admin/command` — delegates to adapter's `remote_admin.send_command()`
4. `POST /servers/{id}/remote-admin/say` — delegates to adapter's `remote_admin.say_all()`
5. `backend/core/dal/ban_repository.py`
6. `GET/POST/DELETE /servers/{id}/bans`
7. **ban.txt bidirectional sync**: on ban add/delete via API, write to `battleye/ban.txt`; on startup, read `ban.txt` and upsert into DB
### Step 4.4 — Ban Manager (Arma 3 adapter)
1. `backend/adapters/arma3/ban_manager.py` — `Arma3BanManager` implementing `BanManager` protocol
2. **ban.txt bidirectional sync**: on ban add/delete via API, also write to `battleye/ban.txt`; on startup, read `ban.txt` and upsert into DB
**Test:** Kick a player via API → confirm player disconnected from server.
@@ -253,26 +283,19 @@ Server-pushed message (server → client, unsolicited):
## Phase 5 — WebSocket Real-Time
**Goal:** Live updates to React frontend without polling.
**Goal:** Live updates to React frontend without polling. **Fully game-agnostic.**
### Step 5.1 — Broadcast infrastructure
1. `backend/websocket/broadcaster.py` — `BroadcastThread` + `enqueue()`
2. `backend/websocket/manager.py` — `ConnectionManager`
3. Store event loop reference in `main.py:on_startup()`:
```python
import asyncio
# on_startup() runs inside the asyncio event loop — use get_running_loop(),
# not get_event_loop() (deprecated in Python 3.10+ from async context).
_event_loop = asyncio.get_running_loop()
broadcaster.init(_event_loop, connection_manager)
```
1. `backend/core/websocket/broadcaster.py` — `BroadcastThread` + `enqueue()`
2. `backend/core/websocket/manager.py` — `ConnectionManager`
3. Store event loop reference in `main.py:on_startup()`
4. Start `BroadcastThread` in `on_startup()`
5. Wire `BroadcastThread.enqueue()` calls into all background threads
### Step 5.2 — WebSocket endpoint
1. `backend/websocket/router.py`
1. `backend/core/websocket/router.py`
2. JWT validation from query param
3. Subscribe/unsubscribe message handling
4. Ping/pong keepalive
@@ -285,30 +308,30 @@ Wire `BroadcastThread.enqueue()` into:
- `ProcessMonitorThread` → status updates, crash events
- `LogTailThread` → log lines
- `MetricsCollectorThread` → metrics snapshots
- `RConPollerThread` → player list updates
- `RemoteAdminPollerThread` → player list updates
- `ServerService.start/stop` → status transitions
**Test:** React frontend connects to WS → server starts → see status, logs, metrics all update in real time.
---
## Phase 6 — Mission & Mod Management
## Phase 6 — Mission & Mod Management (Arma 3 Adapter)
### Step 6.1 — Missions
1. `backend/missions/service.py`
2. `backend/missions/router.py`
3. Upload PBO validation (check `.pbo` extension, parse name)
1. `backend/adapters/arma3/mission_manager.py` — `Arma3MissionManager` implementing `MissionManager` protocol
2. `backend/core/missions/router.py` — generic endpoints (delegate to adapter if capability supported)
3. Upload file validation (extension from adapter's `MissionManager.file_extension`)
4. Mission rotation CRUD
**Test:** Upload a `.pbo` → appears in `GET /missions` → set as rotation → start server → mission available.
### Step 6.2 — Mods
1. `backend/mods/service.py`
2. `backend/mods/router.py`
3. `build_mod_string()` — assemble `-mod=` and `-serverMod=` args
4. Wire mod string into `ConfigGenerator.build_launch_args()`
1. `backend/adapters/arma3/mod_manager.py` — `Arma3ModManager` implementing `ModManager` protocol
2. `backend/core/mods/router.py` — generic endpoints (delegate to adapter if capability supported)
3. `build_mod_args()` — assemble `-mod=` and `-serverMod=` args
4. Wire mod args into `Arma3ConfigGenerator.build_launch_args()`
**Test:** Register `@CBA_A3` → enable on server → start → server loads mod.
@@ -318,15 +341,12 @@ Wire `BroadcastThread.enqueue()` into:
### Step 7.1 — APScheduler jobs
Add to `on_startup()`:
```python
# Use BackgroundScheduler (not AsyncIOScheduler) because cleanup methods
# perform sync SQLite operations. AsyncIOScheduler would block the event loop.
from apscheduler.schedulers.background import BackgroundScheduler
scheduler = BackgroundScheduler()
scheduler.add_job(log_service.cleanup_old_logs, 'cron', hour=3)
scheduler.add_job(metrics_service.cleanup_old_metrics, 'cron', hour=3, minute=30)
scheduler.add_job(player_service.cleanup_old_history, 'cron', hour=4) # 90-day retention
scheduler.add_job(player_service.cleanup_old_history, 'cron', hour=4)
scheduler.start()
```
@@ -335,60 +355,98 @@ scheduler.start()
In `on_startup()` → `ProcessManager.recover_on_startup()`:
- Query DB for servers with `status='running'`
- Check if PID still alive (`psutil.pid_exists(pid)`)
- Validate process name against adapter's `get_allowed_executables()`
- If alive: re-attach threads (skip process start, just start monitoring threads)
- If dead: mark as `crashed`, clear players
### Step 7.3 — Events log
1. `backend/dal/event_repository.py`
1. `backend/core/dal/event_repository.py`
2. Insert events for: start, stop, crash, kick, ban, config change, mission change
3. `GET /servers/{id}/events` endpoint
### Step 7.4 — Security hardening (additional layers)
### Step 7.4 — Security hardening
1. Encrypt sensitive DB fields: `password`, `password_admin`, `rcon_password`
- `backend/utils/crypto.py` with Fernet
- **Key format:** `LANGUARD_ENCRYPTION_KEY` must be a Fernet base64 key, NOT hex.
Generate with: `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`
Passing a hex string to `Fernet()` raises `ValueError` at startup.
- Encrypt on write, decrypt on read in repositories
- **NOTE:** Core security (rate limiting, input sanitization, config escaping, exe path validation) is already in Phases 1-2.
2. Additional penetration testing and security audit
3. Content-Security-Policy headers for frontend
1. Encrypt sensitive DB fields in `game_configs` JSON (passwords, rcon_password)
- `backend/core/utils/crypto.py` with Fernet
- `LANGUARD_ENCRYPTION_KEY` must be a Fernet base64 key
- **Adapter declares sensitive fields**: `adapter.get_sensitive_fields(section) -> list[str]`
- ConfigRepository handles Fernet encrypt/decrypt transparently: encrypts declared fields on write, decrypts on read
2. Content-Security-Policy headers for frontend
3. Penetration testing and security audit
### Step 7.5 — Frontend integration checklist
Verify React app can:
- [ ] Login and store JWT
- [ ] List servers with live status
- [ ] Start/stop server and see status update via WebSocket (no page refresh)
- [ ] View streaming log output
- [ ] See player list update every 10s
- [ ] See CPU/RAM charts update every 5s
- [ ] Edit all config sections and see preview
- [ ] Upload a mission PBO
- [ ] Kick a player
- [ ] Send a message to all players
- [ ] See list of supported game types
- [ ] Create server with game type selection
- [ ] List servers with live status (any game type)
- [ ] Start/stop server and see status update via WebSocket
- [ ] View streaming log output (parsed by adapter)
- [ ] See player list update (via adapter's remote admin)
- [ ] See CPU/RAM charts update
- [ ] Edit config sections (dynamic form from adapter's JSON Schema)
- [ ] Upload a mission file (if adapter supports missions)
- [ ] Manage mods (if adapter supports mods)
- [ ] Kick/ban a player (if adapter supports remote admin)
- [ ] Send a message to all players (if adapter supports remote admin)
---
## Phase 8 — Second Adapter (Validation)
**Goal:** Prove the architecture works by adding a second game adapter. This validates that new games require zero core changes.
### Choose a second game (examples):
- **Minecraft Java Edition** — Has RCON (Source protocol), server.properties config, JAR executable, world/ directory, plugins/ mods
- **Rust** — Has RCON (websocket-based), server.cfg, RustDedicated.exe, oxide/mods
- **Valheim** — Has no RCON, start_server.sh config, valheim_server.exe, mods via BepInEx
### Steps for a new adapter:
1. Create `backend/adapters/<game_type>/` 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/<game_type>/__init__.py` and auto-import in `adapters/__init__.py`
- **Third-party**: add `languard.adapters` entry_point in `pyproject.toml`:
```toml
[project.entry-points."languard.adapters"]
mygame = "my_package.adapters:MYGAME_ADAPTER"
```
Core discovers these via `importlib.metadata` at startup.
6. **No core code changes needed**
7. **No DB migrations needed**
8. Test: create a server with the new game_type, start it, monitor it
---
## Testing Strategy
### Unit tests (pytest)
- `ConfigGenerator.write_server_cfg()` — compare output against expected string; test config injection prevention
- `ConfigGenerator._escape_config_string()` — test double-quote and newline escaping
- `GameAdapterRegistry` — register, get, list, missing adapter
- `Arma3ConfigGenerator` — Pydantic model validation for each section (merged schema + generation)
- `Arma3ConfigGenerator.write_server_cfg()` — compare output against expected string; test config injection prevention
- `Arma3ConfigGenerator._escape_config_string()` — test double-quote and newline escaping
- `RPTParser.parse_line()` — test all log formats
- `BERConClient.parse_players_response()` — test with sample output
- `AuthService.login()` — correct password / wrong password / rate limiting
- `AuthService.login()` — correct/wrong password / rate limiting
- Repository methods — use in-memory SQLite (`:memory:`)
- `check_server_ports_available()` — test derived port validation
- `check_server_ports_available()` — test derived port validation (via adapter conventions)
- `sanitize_filename()` — test path traversal prevention
- In-memory SQLite setup in `conftest.py` — shared fixture for all repository tests
- Protocol conformance — verify Arma3Adapter satisfies all GameAdapter protocol methods
### Integration tests
- Full start/stop cycle with a real arma3server.exe (manual — requires licensed Arma 3 installation, not in CI)
- Full start/stop cycle with a real arma3server.exe (manual — requires licensed Arma 3)
- WebSocket message delivery (can be automated with httpx test client)
- RCon command round-trip (manual — requires running server with BattlEye)
- Adapter resolution: create server with game_type, verify correct adapter is used throughout
### Adapter contract tests
- Template test suite that any new adapter should pass
- Tests: ConfigGenerator produces valid sections and valid config files, ProcessConfig returns allowed executables, LogParser parses sample lines
### Load notes
- SQLite with WAL handles concurrent reads from 4 threads per server well
@@ -412,7 +470,7 @@ pip install -r requirements.txt
# 3. Environment
cp .env.example .env
# Edit .env: set LANGUARD_ARMA_EXE to your arma3server_x64.exe path
# Edit .env: set game-specific paths (LANGUARD_ARMA3_DEFAULT_EXE, etc.)
# 4. Run backend
uvicorn main:app --reload --host 0.0.0.0 --port 8000
@@ -423,23 +481,22 @@ npm install
npm run dev
```
Backend auto-creates `languard.db` and seeds an admin user on first run:
- Username: `admin`
- Password: **randomly generated** and printed to stdout once (e.g., `Initial admin password: a7b9c2d4e5f6...`)
- Change immediately via `PUT /api/auth/password`
Backend auto-creates `languard.db`, seeds an admin user on first run, and registers the Arma 3 adapter automatically.
---
## Phase Summary
| Phase | Deliverable | Est. Complexity |
|-------|-------------|----------------|
| 1 | Foundation (auth + server CRUD) | Low |
| 2 | Process management + config gen | Medium |
| 3 | Background threads (monitor, logs, metrics) | Medium-High |
| 4 | BattlEye RCon (player list, admin cmds) | High |
| 5 | WebSocket real-time | Medium |
| 6 | Mission + mod management | Low-Medium |
| 7 | Polish, security, recovery | Medium |
| Phase | Deliverable | Key Change from Single-Game |
|-------|-------------|------------------------------|
| 0 | Adapter framework (protocols + exceptions + registry) | **NEW** — foundation for modularity |
| 1 | Foundation (auth + server CRUD + game discovery + migration) | Core tables, `game_type` field, `game_configs` JSON, migration from old schema |
| 2 | Arma 3 adapter: config gen + process mgmt | Config generation in adapter, atomic writes, typed exceptions, optimistic locking |
| 3 | Background threads (core + adapter injection) | Generic threads + adapter parsers/clients, per-server lock for RemoteAdmin |
| 4 | Remote admin (Arma 3: BattlEye RCon) | RCon in adapter, generic poller in core |
| 5 | WebSocket real-time | No change — fully game-agnostic |
| 6 | Mission + mod management (Arma 3 adapter) | In adapter, generic endpoints in core |
| 7 | Polish, security, recovery | Adapter-declared sensitive fields, Fernet encryption |
| 8 | Second game adapter | **NEW** — validates zero core changes, entry_points for third-party |
Implement phases in order — each phase builds on the previous and is independently testable.
Implement phases in order — each phase builds on the previous and is independently testable. Phase 0 must come first as it defines the contract that all subsequent code depends on.

1281
MODULES.md

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,8 @@ The system uses a hybrid concurrency model:
- **Queue** bridges the thread world → asyncio world for WebSocket broadcasting
- **SQLAlchemy sync sessions** are used in threads (thread-local connections)
The key change for multi-game support: **core threads are game-agnostic** and receive game-specific behavior (log parsers, remote admin clients) via dependency injection from the adapter.
---
## Thread Map
@@ -16,7 +18,7 @@ The system uses a hybrid concurrency model:
Main Process (FastAPI / asyncio event loop)
├── [uvicorn] HTTP/WS event loop (asyncio)
│ ├── REST request handlers (async def)
│ ├── REST request handlers (async def / plain def)
│ └── WebSocket handlers (async def)
├── BroadcastThread (daemon thread, 1 global)
@@ -25,14 +27,66 @@ Main Process (FastAPI / asyncio event loop)
│ → ConnectionManager.broadcast()
└── Per-running-server thread group (started when server starts, stopped when server stops):
├── ProcessMonitorThread (1 per server, 1s interval)
├── LogTailThread (1 per server, 100ms interval)
├── MetricsCollectorThread (1 per server, 5s interval)
└── RConPollerThread (1 per server, 10s interval, 30s startup delay)
├── ProcessMonitorThread (1 per server, 1s interval) — CORE
├── LogTailThread (1 per server, 100ms interval) — CORE + adapter LogParser
├── MetricsCollectorThread (1 per server, 5s interval) — CORE
└── RemoteAdminPollerThread (1 per server, 10s interval) — CORE + adapter RemoteAdmin
```
For **N running servers**, there are:
- `4*N` background threads + 1 BroadcastThread = `4N+1` background threads total
- (If adapter has no `remote_admin`, RemoteAdminPollerThread is skipped → `3*N+1`)
---
## Adapter Injection into Threads
The `ThreadRegistry` resolves the adapter at thread creation time and injects game-specific components into the generic core threads:
```python
class ThreadRegistry:
@classmethod
def start_server_threads(cls, server_id: int, db: Connection) -> None:
server = ServerRepository(db).get_by_id(server_id)
adapter = GameAdapterRegistry.get(server["game_type"])
threads: dict[str, BaseServerThread] = {}
# Core threads — always present
threads["process_monitor"] = ProcessMonitorThread(server_id)
threads["metrics_collector"] = MetricsCollectorThread(server_id)
# Core thread with adapter's log parser injected
log_parser = adapter.get_log_parser()
threads["log_tail"] = LogTailThread(
server_id,
parser=log_parser,
log_file_resolver=log_parser.get_log_file_resolver(server_id),
)
# Core thread with adapter's remote admin injected (if supported)
remote_admin = adapter.get_remote_admin()
if remote_admin is not None:
threads["remote_admin_poller"] = RemoteAdminPollerThread(
server_id,
remote_admin_factory=lambda: remote_admin.create_client(
host="127.0.0.1",
port=server["rcon_port"],
password=_get_remote_admin_password(server_id, db),
),
)
# Adapter-declared custom threads (for game-specific background work)
for thread_factory in adapter.get_custom_thread_factories():
thread = thread_factory(server_id, db)
threads[thread.name_key] = thread
with cls._lock:
cls._threads[server_id] = threads
for thread in threads.values():
thread.start()
```
---
@@ -46,6 +100,37 @@ For **N running servers**, there are:
| `ConnectionManager._connections` | async, single event loop | `asyncio.Lock` |
| SQLite connections | one connection per thread | Thread-local via `threading.local()` |
| Config files on disk | write on start, read-only during run | No lock needed (regenerated before start) |
| Adapter objects | read-only after registration | No lock needed (registered once at startup) |
| RemoteAdminClient calls | called from RemoteAdminPollerThread only | **Core wraps with per-server `threading.Lock`** (see below) |
### RemoteAdminClient Thread Safety
Adapters do NOT need to make their `RemoteAdminClient` implementations thread-safe. The core wraps every RemoteAdminClient call with a **per-server `threading.Lock`** so only one call executes at a time against a given server's admin client.
```python
# In RemoteAdminPollerThread
class RemoteAdminPollerThread(BaseServerThread):
def __init__(self, server_id: int,
remote_admin_factory: Callable[[], "RemoteAdminClient"]):
super().__init__(server_id, self.interval)
self._client_factory = remote_admin_factory
self._client: RemoteAdminClient | None = None
self._connected = False
self._call_lock = threading.Lock() # per-server lock
def _call(self, method, *args, **kwargs):
"""All RemoteAdminClient calls go through this to serialize access."""
with self._call_lock:
return method(*args, **kwargs)
# In tick(), replace direct self._client.get_players() with:
# players = self._call(self._client.get_players)
```
This means:
- Adapter authors write simple, non-thread-safe clients
- Core guarantees no concurrent calls to the same client
- Different servers' clients can call concurrently (different locks)
### SQLite Thread Safety
```python
@@ -55,7 +140,6 @@ For **N running servers**, there are:
class BaseServerThread(threading.Thread):
def run(self):
# Create thread-local DB connection — single connection per thread
engine = get_engine()
self._db = engine.connect()
try:
@@ -69,46 +153,49 @@ class BaseServerThread(threading.Thread):
except Exception as e:
logger.error(f"{self.name} setup error: {e}")
finally:
self.teardown() # always release resources (even on setup failure)
self._db.close() # always close connection
self.teardown()
self._db.close()
```
---
## BroadcastThread — Asyncio Bridge
This is the critical bridge between background threads and the asyncio WebSocket layer.
This is the critical bridge between background threads and the asyncio WebSocket layer. **Game-agnostic.**
```
Background Thread Asyncio Event Loop
───────────────── ──────────────────
BroadcastThread.enqueue( uvicorn runs here
server_id=1,
Any background thread uvicorn runs here
BroadcastThread.enqueue( loop = asyncio.get_running_loop()
server_id=1, (stored at app startup)
msg_type='log',
data={...}
)
broadcast_queue.put({ loop = asyncio.get_event_loop()
'server_id': 1, (stored at app startup)
'type': 'log',
'data': {...}
})
BroadcastThread.run() ──────────────────► asyncio.run_coroutine_threadsafe(
while True: connection_manager.broadcast(
msg = queue.get() server_id=1,
fut = run_coroutine_threadsafe( message={type, data}
broadcast_coro, ),
self._loop loop=self._loop
) )
broadcast_queue.put({ asyncio.run_coroutine_threadsafe(
'server_id': 1, connection_manager.broadcast(
'type': 'log', server_id=1,
'data': {...} message={type, data}
) ),
loop=self._loop
)
BroadcastThread.run() ──────────────────►
while True:
msg = queue.get()
fut = run_coroutine_threadsafe(
broadcast_coro,
self._loop
)
fut.result(timeout=5)
```
### Implementation Sketch
```python
# broadcaster.py
# core/websocket/broadcaster.py
import asyncio
import queue
import threading
@@ -130,9 +217,6 @@ class BroadcastThread(threading.Thread):
try:
msg = _broadcast_queue.get(timeout=1.0)
server_id = msg['server_id']
# Build the outgoing WebSocket message envelope.
# Include server_id so clients subscribed to 'all' can identify the source.
# API contract: {type, server_id, data}
outgoing = {
'type': msg['type'],
'server_id': server_id,
@@ -145,7 +229,6 @@ class BroadcastThread(threading.Thread):
try:
future.result(timeout=5.0)
except TimeoutError:
# Don't block the queue — log and continue
logger.warning(f"Broadcast timeout for server {server_id} msg type {msg['type']}")
except queue.Empty:
continue
@@ -172,6 +255,8 @@ class BroadcastThread(threading.Thread):
## ProcessMonitorThread — Crash Detection & Auto-Restart
**Game-agnostic.** This thread only checks OS-level process status and updates the core `servers` table.
```python
class ProcessMonitorThread(BaseServerThread):
interval = 1.0
@@ -184,7 +269,6 @@ class ProcessMonitorThread(BaseServerThread):
exit_code = proc.poll()
if exit_code is not None:
# Process has exited
self._handle_process_exit(exit_code)
self.stop()
@@ -210,18 +294,13 @@ class ProcessMonitorThread(BaseServerThread):
'detail': {'exit_code': exit_code}
})
# Stop other threads for this server. Must NOT be called synchronously
# from within this thread's own run() if stop_server_threads() joins threads,
# as a thread cannot join itself. Use a daemon thread to do the cleanup
# after this thread's run() returns naturally.
# IMPORTANT: The auto-restart Timer must be started AFTER thread cleanup
# completes. The cleanup daemon thread starts the restart timer when done.
# Stop other threads for this server via daemon cleanup thread
# (avoids thread joining itself)
import threading as _threading
def _cleanup_and_maybe_restart():
try:
ThreadRegistry.get().stop_server_threads(self.server_id)
# Only schedule restart after threads are fully cleaned up
if is_crash and server.get('auto_restart'):
self._schedule_auto_restart(server)
except Exception as e:
@@ -238,10 +317,8 @@ class ProcessMonitorThread(BaseServerThread):
).start()
def _schedule_auto_restart(self, server: dict):
# IMPORTANT: This method runs in the daemon cleanup thread, NOT the
# ProcessMonitorThread. Must create its own DB connection — do NOT
# use self._db (it belongs to the ProcessMonitorThread's thread context
# and may be closed by teardown() already).
# IMPORTANT: Runs in daemon cleanup thread, NOT ProcessMonitorThread.
# Must create its own DB connection.
from database import get_thread_db
db = get_thread_db()
@@ -250,7 +327,6 @@ class ProcessMonitorThread(BaseServerThread):
window = server['restart_window_seconds']
last_restart = server.get('last_restart_at')
# Reset restart_count if last restart was outside the window
if last_restart:
last_dt = datetime.fromisoformat(last_restart)
elapsed = (datetime.utcnow() - last_dt).total_seconds()
@@ -270,7 +346,7 @@ class ProcessMonitorThread(BaseServerThread):
})
def _auto_restart(self):
from servers.service import ServerService
from core.servers.service import ServerService
try:
ServerService().start(self.server_id)
except Exception as e:
@@ -279,63 +355,61 @@ class ProcessMonitorThread(BaseServerThread):
---
## LogTailThread — RPT File Tailing
## LogTailThread — Generic File Tailing with Adapter Parser
The Arma 3 RPT file grows while the server runs. This thread tails it like `tail -f`.
**Core thread** that takes an adapter-provided `LogParser` for game-specific log line parsing and file discovery.
```python
class LogTailThread(BaseServerThread):
interval = 0.1 # 100ms
def setup(self):
self._file = None
def __init__(self, server_id: int, log_parser: "LogParser",
log_file_resolver: Callable[[Path], Path | None]):
super().__init__(server_id, self.interval)
self._parser = log_parser
self._log_file_resolver = log_file_resolver
self._file: TextIO | None = None
self._current_path: Path | None = None
self._last_size: int = 0
self._open_latest_rpt()
def _open_latest_rpt(self):
def setup(self):
self._open_latest_log()
def _open_latest_log(self):
"""
Arma 3 writes timestamped RPT files in the profile subdirectory:
servers/{id}/server/arma3server_YYYY-MM-DD_HH-MM-SS.rpt
Use rglob('*.rpt') to search recursively within the server dir.
The profile subdirectory is determined by -profiles + -name flags.
Uses the adapter-provided log_file_resolver to find the current log file.
Opens it and seeks to end (tail behavior).
NOTE: Do NOT use os.stat().st_ino for rotation detection — on Windows/NTFS
st_ino is always 0, making inode comparison completely non-functional.
Instead, track the filename and file size. If a newer .rpt appears or the
current file shrinks (truncated/replaced), reopen.
st_ino is always 0. Instead, track filename and file size.
"""
rpt_files = list(Path(get_server_dir(self.server_id)).rglob("*.rpt"))
if not rpt_files:
return # Server hasn't created RPT yet; retry in next tick
server_dir = get_server_dir(self.server_id)
log_path = self._log_file_resolver(server_dir)
if log_path is None:
return # Server hasn't created log yet; retry in next tick
latest = max(rpt_files, key=lambda p: p.stat().st_mtime)
try:
self._file = open(latest, 'r', encoding='utf-8', errors='replace')
self._file.seek(0, 2) # seek to end — tail, don't replay old output
self._current_path = latest
self._file = open(log_path, 'r', encoding='utf-8', errors='replace')
self._file.seek(0, 2) # seek to end
self._current_path = log_path
self._last_size = self._file.tell()
except OSError:
self._file = None
def tick(self):
if self._file is None:
self._open_latest_rpt()
self._open_latest_log()
return
# Rotation detection: only re-glob every 5 seconds (not every 100ms tick)
# to avoid excessive filesystem I/O with large mpmissions directories.
# Rotation detection: only re-check every 5 seconds
now = time.monotonic()
if now - getattr(self, '_last_glob_time', 0) > 5.0:
self._last_glob_time = now
rpt_files = list(Path(get_server_dir(self.server_id)).rglob("*.rpt"))
if rpt_files:
latest = max(rpt_files, key=lambda p: p.stat().st_mtime)
if latest != self._current_path:
# A new RPT file was created — switch to it
server_dir = get_server_dir(self.server_id)
log_path = self._log_file_resolver(server_dir)
if log_path is not None and log_path != self._current_path:
self._file.close()
self._open_latest_rpt()
self._open_latest_log()
return
try:
@@ -344,12 +418,11 @@ class LogTailThread(BaseServerThread):
return
if current_size < self._last_size:
# File shrank — truncated or replaced; reopen
self._file.close()
self._open_latest_rpt()
self._open_latest_log()
return
# Read new lines
# Read new lines and parse using adapter's parser
while True:
line = self._file.readline()
if not line:
@@ -359,13 +432,13 @@ class LogTailThread(BaseServerThread):
if not line:
continue
entry = RPTParser.parse_line(line)
# Adapter parses the line — game-specific format
entry = self._parser.parse_line(line)
if entry:
LogRepository(self._db).insert(self.server_id, entry)
BroadcastThread.enqueue(self.server_id, 'log', entry)
def teardown(self):
"""Close the open RPT file handle when the thread stops."""
if self._file is not None:
try:
self._file.close()
@@ -376,48 +449,108 @@ class LogTailThread(BaseServerThread):
---
## RConPollerThread — Player List Synchronization
## MetricsCollectorThread — Game-Agnostic Resource Monitoring
**Fully game-agnostic.** Uses psutil to monitor any process.
```python
class RConPollerThread(BaseServerThread):
interval = 10.0
STARTUP_DELAY = 30.0 # wait for server to fully initialize
_rcon_ready = False # flag: True only after successful setup
def setup(self):
# Wait for server to start up before attempting RCon
if self._stop_event.wait(self.STARTUP_DELAY):
self._rcon_ready = False
return # stop was requested during wait
self._rcon = RConService(self.server_id)
self._connected = self._rcon.connect()
self._rcon_ready = True
class MetricsCollectorThread(BaseServerThread):
interval = 5.0
def tick(self):
pid = ProcessManager.get().get_pid(self.server_id)
if pid is None:
return
try:
proc = psutil.Process(pid)
cpu = proc.cpu_percent(interval=0.5)
ram = proc.memory_info().rss / (1024 * 1024) # MB
except (psutil.NoSuchProcess, psutil.AccessDenied):
return
player_count = PlayerRepository(self._db).count(self.server_id)
MetricsRepository(self._db).insert(self.server_id, cpu, ram, player_count)
BroadcastThread.enqueue(self.server_id, 'metrics', {
'cpu_percent': cpu,
'ram_mb': ram,
'player_count': player_count,
})
```
---
## RemoteAdminPollerThread — Generic Polling with Adapter Client
**Core thread** that takes an adapter-provided `RemoteAdmin` factory for game-specific admin protocol communication. Skipped entirely if adapter has no `remote_admin` capability.
```python
class RemoteAdminPollerThread(BaseServerThread):
interval = 10.0
STARTUP_DELAY = 30.0
def __init__(self, server_id: int,
remote_admin_factory: Callable[[], "RemoteAdminClient"]):
super().__init__(server_id, self.interval)
self._client_factory = remote_admin_factory
self._client: RemoteAdminClient | None = None
self._connected = False
def setup(self):
# Wait for server to start up before attempting connection
# Uses _stop_event.wait() instead of time.sleep() for immediate shutdown
startup_delay = self._get_startup_delay()
if self._stop_event.wait(startup_delay):
return # stop was requested during wait
self._connect()
def _get_startup_delay(self) -> float:
# Default delay; adapter may override via RemoteAdmin.get_startup_delay()
return self.STARTUP_DELAY
def _connect(self):
try:
self._client = self._client_factory()
self._connected = True
except Exception as e:
logger.warning(f"Remote admin connection failed for server {self.server_id}: {e}")
self._connected = False
def tick(self):
if not self._rcon_ready:
return # setup() failed or was interrupted
if not self._connected:
self._reconnect_attempts = getattr(self, '_reconnect_attempts', 0) + 1
delay = min(10 * 2 ** self._reconnect_attempts, 120) # exponential backoff
if self._reconnect_attempts > 1:
logger.info(f"RCon reconnect attempt {self._reconnect_attempts} for server {self.server_id} (next in {delay}s)")
logger.info(f"Remote admin reconnect attempt {self._reconnect_attempts} for server {self.server_id}")
if self._stop_event.wait(delay):
return
self._connected = self._rcon.connect()
self._connect()
if not self._connected:
return
self._reconnect_attempts = 0 # reset on successful connection
self._reconnect_attempts = 0
try:
players = self._rcon.get_players()
PlayerService(self._db).update_from_rcon(self.server_id, players)
players = self._call(self._client.get_players)
PlayerService(self._db).update_from_remote_admin(self.server_id, players)
BroadcastThread.enqueue(self.server_id, 'players', {
'players': [p.dict() for p in players],
'count': len(players)
'players': [p for p in players],
'count': len(players),
})
except ConnectionError:
self._connected = False
logger.warning(f"RCon connection lost for server {self.server_id}")
logger.warning(f"Remote admin connection lost for server {self.server_id}")
except RemoteAdminError as e:
logger.error(f"Remote admin adapter error for server {self.server_id}: {e}")
self._connected = False
def teardown(self):
if self._client is not None:
try:
self._client.disconnect()
except Exception:
pass
self._client = None
```
---
@@ -429,29 +562,45 @@ class RConPollerThread(BaseServerThread):
POST /servers/{id}/start
├── ServerService.start()
│ ├── ConfigGenerator.write_all()
│ ├── adapter = GameAdapterRegistry.get(server.game_type)
│ ├── check_server_ports_available(server_id)
│ │ └── For ALL running servers, resolve each adapter,
│ │ get port conventions, check full derived port set
│ │ (cross-game: Arma 3 game+steam query + other games' ports)
│ ├── adapter.config_generator.write_configs()
│ │ └── Atomic write: write to .tmp files first, then os.replace()
│ │ On failure: .tmp files cleaned up, originals untouched
│ ├── launch_args = adapter.config_generator.build_launch_args()
│ ├── ProcessManager.start() ← creates subprocess.Popen
│ └── ThreadRegistry.start_server_threads(id)
│ ├── ProcessMonitorThread(id).start()
│ ├── LogTailThread(id).start()
│ ├── MetricsCollectorThread(id).start()
│ └── RConPollerThread(id).start()
│ └── ThreadRegistry.start_server_threads(id, db)
│ ├── ProcessMonitorThread(id) ← core, always
│ ├── LogTailThread(id, adapter.log_parser) ← core + adapter
│ ├── MetricsCollectorThread(id) ← core, always
│ └── RemoteAdminPollerThread(id, adapter.remote_admin)
│ ← core + adapter (if available)
└── BroadcastThread.enqueue(id, 'status', {status: 'starting'})
Error paths on start:
├── ConfigWriteError → rollback .tmp files, return 500 to client
├── ConfigValidationError → return 422 with validation details
├── LaunchArgsError → return 400 with invalid arg info
├── ExeNotAllowedError → return 403 with executable name
└── PortInUseError → return 409 with conflicting port info
```
### Stop Server Flow
```
POST /servers/{id}/stop
├── RConService.shutdown() ← sends #shutdown via RCon
├── adapter.remote_admin.shutdown() ← if adapter has remote_admin
├── Wait up to 30s for process exit (ProcessManager.stop(timeout=30))
├── If still running: ProcessManager.kill()
├── ThreadRegistry.stop_server_threads(id)
│ ├── ProcessMonitorThread.stop() (sets _stop_event)
│ ├── ProcessMonitorThread.stop()
│ ├── LogTailThread.stop()
│ ├── MetricsCollectorThread.stop()
│ └── RConPollerThread.stop()
│ └── RemoteAdminPollerThread.stop() ← if present
│ └── Thread.join(timeout=5) for each
└── BroadcastThread.enqueue(id, 'status', {status: 'stopped'})
@@ -496,7 +645,7 @@ class BaseServerThread(threading.Thread):
self.setup()
except Exception as e:
logger.error(f"{self.name} setup error: {e}")
return # setup failed completely — no partial resources to clean
return # setup failed completely
try:
while not self._stop_event.is_set():
@@ -504,24 +653,34 @@ class BaseServerThread(threading.Thread):
self.tick()
except Exception as e:
self.on_error(e)
# Use wait() instead of sleep() — responds immediately to stop()
self._stop_event.wait(self.interval)
finally:
self.teardown() # always runs; subclasses close files/sockets here
self.teardown()
def on_error(self, error: Exception):
"""Default error handler. Adapter exceptions are typed for specific handling."""
if isinstance(error, RemoteAdminError):
logger.error(f"{self.name} remote admin error: {error}")
# RemoteAdminPollerThread overrides to set _connected = False
elif isinstance(error, ConfigWriteError):
logger.critical(f"{self.name} config write error (atomic write failed): {error}")
elif isinstance(error, ConfigValidationError):
logger.error(f"{self.name} config validation error: {error}")
else:
logger.error(f"{self.name} unhandled error: {error}")
```
---
## WebSocket Connection Manager (asyncio)
**Game-agnostic.** No changes from single-game design.
```python
# websocket/manager.py
# core/websocket/manager.py
class ConnectionManager:
def __init__(self):
# server_id → set[WebSocket]
# Use set (not list) so .add()/.discard() work correctly.
self._connections: dict[str, set[WebSocket]] = defaultdict(set)
# Per-connection channel subscriptions: ws → set[str]
self._channel_subs: dict[WebSocket, set[str]] = defaultdict(set)
self._lock = asyncio.Lock()
@@ -529,8 +688,7 @@ class ConnectionManager:
await ws.accept()
async with self._lock:
self._connections[server_id].add(ws)
self._channel_subs[ws].add('status') # default channel
# Only add to 'all' bucket if server_id is explicitly 'all'
self._channel_subs[ws].add('status')
if server_id == 'all':
self._connections['all'].add(ws)
@@ -549,15 +707,12 @@ class ConnectionManager:
self._channel_subs[ws].difference_update(channels)
async def broadcast(self, server_id: str, message: dict, channel: str = None):
"""Send to all clients subscribed to server_id AND the message's channel."""
targets: set[WebSocket] = set()
async with self._lock:
# Collect clients for this server_id + 'all' subscribers
server_clients = self._connections.get(server_id, set())
all_clients = self._connections.get('all', set())
candidates = server_clients | all_clients
# Filter by channel subscription if specified
if channel:
targets = {ws for ws in candidates
if channel in self._channel_subs.get(ws, set())}
@@ -571,7 +726,6 @@ class ConnectionManager:
except Exception:
dead.append(ws)
# Clean up dead connections
if dead:
async with self._lock:
for ws in dead:
@@ -587,9 +741,9 @@ class ConnectionManager:
| Thread | Memory Impact | CPU Impact |
|--------|--------------|-----------|
| ProcessMonitorThread | Minimal (one `os.kill` check) | Negligible |
| LogTailThread | Buffer for unread log lines | Low (file I/O) |
| LogTailThread | Buffer for unread log lines | Low (file I/O + adapter parsing) |
| MetricsCollectorThread | psutil subprocess scan | Low-Medium |
| RConPollerThread | UDP socket + response buffer | Low |
| RemoteAdminPollerThread | Adapter client socket + buffer | Low (varies by adapter protocol) |
| BroadcastThread | Queue buffer (max 10000 entries) | Low |
### Recommendations
@@ -597,4 +751,5 @@ class ConnectionManager:
- `broadcast_queue.maxsize=10000` — backpressure; drop on Full (log warning)
- `LogTailThread` buffers max ~100 lines per tick before writing to DB in batch
- `MetricsCollectorThread` uses `psutil.Process.cpu_percent(interval=0.5)` — blocks 500ms, acceptable at 5s interval
- For N=10 servers: 41 background threads — well within Python's thread limits
- For N=10 servers: 31-41 background threads — well within Python's thread limits
- Games without remote admin skip the RemoteAdminPollerThread entirely