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:
353
API.md
353
API.md
@@ -6,7 +6,7 @@ http://localhost:8000/api
|
|||||||
```
|
```
|
||||||
|
|
||||||
## Authentication
|
## 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>`
|
- 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 }`
|
- JWT payload: `{ "sub": "user_id", "username": "string", "role": "admin|viewer", "exp": timestamp }`
|
||||||
|
|
||||||
@@ -39,11 +39,26 @@ Error response:
|
|||||||
| 400 | Validation error |
|
| 400 | Validation error |
|
||||||
| 401 | Unauthenticated |
|
| 401 | Unauthenticated |
|
||||||
| 403 | Forbidden (insufficient role) |
|
| 403 | Forbidden (insufficient role) |
|
||||||
| 404 | Not found |
|
| 404 | Not found (or capability not supported by adapter) |
|
||||||
| 409 | Conflict (already running, duplicate) |
|
| 409 | Conflict (already running, duplicate) |
|
||||||
| 422 | Unprocessable (Pydantic validation) |
|
| 422 | Unprocessable (Pydantic validation) |
|
||||||
| 500 | Internal server error |
|
| 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
|
## 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
|
## Server Endpoints
|
||||||
|
|
||||||
### GET /servers
|
### 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:**
|
**Response 200:**
|
||||||
```json
|
```json
|
||||||
@@ -117,32 +208,30 @@ List all servers with current status. Supports pagination.
|
|||||||
"id": 1,
|
"id": 1,
|
||||||
"name": "Main Server",
|
"name": "Main Server",
|
||||||
"description": "Primary COOP server",
|
"description": "Primary COOP server",
|
||||||
|
"game_type": "arma3",
|
||||||
"status": "running",
|
"status": "running",
|
||||||
"pid": 12345,
|
"pid": 12345,
|
||||||
"game_port": 2302,
|
"game_port": 2302,
|
||||||
"rcon_port": 2306,
|
"rcon_port": 2306,
|
||||||
"player_count": 15,
|
"player_count": 15,
|
||||||
"max_players": 40,
|
"max_players": 40,
|
||||||
"current_mission": "MyMission.Altis",
|
|
||||||
"uptime_seconds": 3600,
|
|
||||||
"cpu_percent": 34.2,
|
"cpu_percent": 34.2,
|
||||||
"ram_mb": 1850.5,
|
"ram_mb": 1850.5,
|
||||||
"started_at": "2026-04-16T10:00:00Z"
|
"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
|
### POST /servers
|
||||||
Create a new server. Admin only.
|
Create a new server. Admin only. `game_type` determines which adapter handles this server.
|
||||||
|
|
||||||
**Request:**
|
**Request:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"name": "Main Server",
|
"name": "Main Server",
|
||||||
"description": "Primary COOP server",
|
"description": "Primary COOP server",
|
||||||
|
"game_type": "arma3",
|
||||||
"exe_path": "C:/Arma3Server/arma3server_x64.exe",
|
"exe_path": "C:/Arma3Server/arma3server_x64.exe",
|
||||||
"game_port": 2302,
|
"game_port": 2302,
|
||||||
"rcon_port": 2306,
|
"rcon_port": 2306,
|
||||||
@@ -150,7 +239,9 @@ Create a new server. Admin only.
|
|||||||
"max_restarts": 3
|
"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.
|
**Response 201:** Returns full server object including auto-generated credentials.
|
||||||
|
|
||||||
### GET /servers/{server_id}
|
### GET /servers/{server_id}
|
||||||
@@ -163,6 +254,7 @@ Get server detail with full status.
|
|||||||
"data": {
|
"data": {
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"name": "Main Server",
|
"name": "Main Server",
|
||||||
|
"game_type": "arma3",
|
||||||
"status": "running",
|
"status": "running",
|
||||||
"pid": 12345,
|
"pid": 12345,
|
||||||
"game_port": 2302,
|
"game_port": 2302,
|
||||||
@@ -174,8 +266,7 @@ Get server detail with full status.
|
|||||||
"cpu_percent": 34.2,
|
"cpu_percent": 34.2,
|
||||||
"ram_mb": 1850.5,
|
"ram_mb": 1850.5,
|
||||||
"started_at": "2026-04-16T10:00:00Z",
|
"started_at": "2026-04-16T10:00:00Z",
|
||||||
"uptime_seconds": 3600,
|
"uptime_seconds": 3600
|
||||||
"current_mission": "MyMission.Altis"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -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.
|
Delete server (must be stopped first). Admin only. Removes DB rows and `servers/{id}/` directory.
|
||||||
|
|
||||||
### POST /servers/{server_id}/start
|
### 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:**
|
**Response 200:**
|
||||||
```json
|
```json
|
||||||
@@ -196,7 +287,7 @@ Start the server. Admin only.
|
|||||||
**Response 409:** Server already running.
|
**Response 409:** Server already running.
|
||||||
|
|
||||||
### POST /servers/{server_id}/stop
|
### 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):**
|
**Request (optional):**
|
||||||
```json
|
```json
|
||||||
@@ -213,28 +304,46 @@ Force-kill the process immediately. Admin only. Emergency use only.
|
|||||||
|
|
||||||
## Server Config Endpoints
|
## 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 /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:**
|
**Response 200:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"data": {
|
"data": {
|
||||||
"server": { /* server_configs row */ },
|
"server": { /* section JSON */, "_meta": {"config_version": 3, "schema_version": "1.0"} },
|
||||||
"basic": { /* basic_configs row */ },
|
"basic": { /* section JSON */, "_meta": {"config_version": 1, "schema_version": "1.0"} },
|
||||||
"profile": { /* server_profiles row */ },
|
"profile": { /* section JSON */, "_meta": {"config_version": 2, "schema_version": "1.0"} },
|
||||||
"launch": { /* launch_params row */ },
|
"launch": { /* section JSON */, "_meta": {"config_version": 1, "schema_version": "1.0"} },
|
||||||
"rcon": { "rcon_password": "***", "max_ping": 200, "enabled": true }
|
"rcon": { "rcon_password": "***", "max_ping": 200, "enabled": 1, "_meta": {"config_version": 1, "schema_version": "1.0"} }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### PUT /servers/{server_id}/config/server
|
### GET /servers/{server_id}/config/{section}
|
||||||
Update server.cfg settings. Admin only.
|
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
|
```json
|
||||||
{
|
{
|
||||||
"hostname": "Updated Server Name",
|
"hostname": "Updated Server Name",
|
||||||
@@ -242,13 +351,27 @@ Update server.cfg settings. Admin only.
|
|||||||
"battleye": 1,
|
"battleye": 1,
|
||||||
"verify_signatures": 2,
|
"verify_signatures": 2,
|
||||||
"motd_lines": ["Welcome!", "Have fun"],
|
"motd_lines": ["Welcome!", "Have fun"],
|
||||||
"motd_interval": 5.0
|
"motd_interval": 5.0,
|
||||||
|
"config_version": 3
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### PUT /servers/{server_id}/config/basic
|
**Response 409 (Conflict):** Another admin updated this section since you read it.
|
||||||
Update basic.cfg (bandwidth) settings. Admin only.
|
```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
|
```json
|
||||||
{
|
{
|
||||||
"max_bandwidth": 50000000,
|
"max_bandwidth": 50000000,
|
||||||
@@ -256,9 +379,7 @@ Update basic.cfg (bandwidth) settings. Admin only.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### PUT /servers/{server_id}/config/profile
|
**Arma 3 `profile` section example:**
|
||||||
Update difficulty profile. Admin only.
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"third_person_view": 0,
|
"third_person_view": 0,
|
||||||
@@ -269,28 +390,17 @@ Update difficulty profile. Admin only.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### PUT /servers/{server_id}/config/launch
|
**Arma 3 `launch` section example:**
|
||||||
Update launch parameters. Admin only.
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"world": "empty",
|
"world": "empty",
|
||||||
"limit_fps": 50,
|
"limit_fps": 50,
|
||||||
"auto_init": false,
|
"auto_init": false,
|
||||||
"load_mission_to_memory": true,
|
"load_mission_to_memory": true
|
||||||
"bandwidth_alg": 2,
|
|
||||||
"enable_ht": true,
|
|
||||||
"huge_pages": false
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### PUT /servers/{server_id}/config/rcon
|
**Arma 3 `rcon` section example:**
|
||||||
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.
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"rcon_password": "newpassword",
|
"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
|
**Note:** `rcon_port` is stored in the `servers` table, not in the config JSON. The service layer updates both tables as needed.
|
||||||
Returns rendered `server.cfg` as plain text string (for preview in UI). **Admin only** — contains plaintext credentials.
|
|
||||||
|
|
||||||
**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}
|
### 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
|
### GET /servers/{server_id}/missions
|
||||||
List all mission PBOs for a server.
|
List all mission/scenario files for a server.
|
||||||
|
|
||||||
**Response 200:**
|
**Response 200:**
|
||||||
```json
|
```json
|
||||||
@@ -333,10 +459,10 @@ List all mission PBOs for a server.
|
|||||||
```
|
```
|
||||||
|
|
||||||
### POST /servers/{server_id}/missions/upload
|
### 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:**
|
**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:**
|
**Response 201:**
|
||||||
```json
|
```json
|
||||||
@@ -353,10 +479,10 @@ Upload a mission PBO. Admin only. `multipart/form-data`.
|
|||||||
```
|
```
|
||||||
|
|
||||||
### DELETE /servers/{server_id}/missions/{mission_id}
|
### 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 /servers/{server_id}/missions/rotation
|
||||||
Get current mission rotation (ordered list).
|
Get current mission/scenario rotation (ordered list).
|
||||||
|
|
||||||
**Response 200:**
|
**Response 200:**
|
||||||
```json
|
```json
|
||||||
@@ -368,7 +494,7 @@ Get current mission rotation (ordered list).
|
|||||||
"sort_order": 0,
|
"sort_order": 0,
|
||||||
"mission": { "id": 1, "mission_name": "MyMission.Altis" },
|
"mission": { "id": 1, "mission_name": "MyMission.Altis" },
|
||||||
"difficulty": "Regular",
|
"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
|
### GET /mods
|
||||||
List all registered mods (global list).
|
List all registered mods. Optionally filter by game type.
|
||||||
|
|
||||||
|
**Query params:** `?game_type=arma3`
|
||||||
|
|
||||||
### POST /mods
|
### POST /mods
|
||||||
Register a mod folder. Admin only.
|
Register a mod folder. Admin only.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"game_type": "arma3",
|
||||||
"name": "@CBA_A3",
|
"name": "@CBA_A3",
|
||||||
"folder_path": "C:/Arma3Server/@CBA_A3",
|
"folder_path": "C:/Arma3Server/@CBA_A3",
|
||||||
"workshop_id": "450814997",
|
"workshop_id": "450814997",
|
||||||
@@ -452,26 +583,26 @@ Get currently connected players.
|
|||||||
"success": true,
|
"success": true,
|
||||||
"data": [
|
"data": [
|
||||||
{
|
{
|
||||||
"player_num": 1,
|
"slot_id": "1",
|
||||||
"name": "PlayerOne",
|
"name": "PlayerOne",
|
||||||
"guid": "abc123...",
|
"guid": "abc123...",
|
||||||
"ping": 45,
|
"ping": 45,
|
||||||
"verified": true,
|
"game_data": { "verified": true, "steam_uid": "76561198..." },
|
||||||
"joined_at": "2026-04-16T10:15:00Z"
|
"joined_at": "2026-04-16T10:15:00Z"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### POST /servers/{server_id}/players/{player_num}/kick
|
### POST /servers/{server_id}/players/{slot_id}/kick
|
||||||
Kick a player via RCon. Admin only.
|
Kick a player. Admin only. Requires adapter `remote_admin` capability.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "reason": "AFK" }
|
{ "reason": "AFK" }
|
||||||
```
|
```
|
||||||
|
|
||||||
### POST /servers/{server_id}/players/{player_num}/ban
|
### POST /servers/{server_id}/players/{slot_id}/ban
|
||||||
Ban a player via RCon. Admin only.
|
Ban a player. Admin only. Requires adapter `remote_admin` capability.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -495,12 +626,11 @@ List all bans for a server.
|
|||||||
**Query params:** `?active_only=true&limit=50&offset=0`
|
**Query params:** `?active_only=true&limit=50&offset=0`
|
||||||
|
|
||||||
### POST /servers/{server_id}/bans
|
### 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
|
```json
|
||||||
{
|
{
|
||||||
"guid": "abc123...",
|
"guid": "abc123...",
|
||||||
"steam_uid": "76561198...",
|
|
||||||
"name": "PlayerName",
|
"name": "PlayerName",
|
||||||
"reason": "Cheating",
|
"reason": "Cheating",
|
||||||
"duration_minutes": 0
|
"duration_minutes": 0
|
||||||
@@ -508,7 +638,37 @@ Add ban manually. Admin only.
|
|||||||
```
|
```
|
||||||
|
|
||||||
### DELETE /servers/{server_id}/bans/{ban_id}
|
### 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
|
## Event Log Endpoints
|
||||||
|
|
||||||
### GET /servers/{server_id}/events
|
### GET /servers/{server_id}/events
|
||||||
@@ -615,6 +747,7 @@ Overall system status. **Requires authentication** (admin or viewer).
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"running_servers": 2,
|
"running_servers": 2,
|
||||||
"total_servers": 3,
|
"total_servers": 3,
|
||||||
|
"supported_games": ["arma3"],
|
||||||
"uptime_seconds": 86400
|
"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 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
|
### Server → Client Messages
|
||||||
|
|
||||||
#### Status Update
|
#### Status Update
|
||||||
Sent when server status changes (starting → running → stopped, etc.)
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "status",
|
"type": "status",
|
||||||
@@ -662,7 +792,6 @@ Sent when server status changes (starting → running → stopped, etc.)
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### Log Line
|
#### Log Line
|
||||||
Sent for each new RPT log line.
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "log",
|
"type": "log",
|
||||||
@@ -676,14 +805,13 @@ Sent for each new RPT log line.
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### Player List Update
|
#### Player List Update
|
||||||
Sent after each RCon poll (every 10s).
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "players",
|
"type": "players",
|
||||||
"server_id": 1,
|
"server_id": 1,
|
||||||
"data": {
|
"data": {
|
||||||
"players": [
|
"players": [
|
||||||
{ "player_num": 1, "name": "PlayerOne", "ping": 45, "verified": true }
|
{ "slot_id": "1", "name": "PlayerOne", "ping": 45 }
|
||||||
],
|
],
|
||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
@@ -691,7 +819,6 @@ Sent after each RCon poll (every 10s).
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### Metrics Update
|
#### Metrics Update
|
||||||
Sent every 5 seconds.
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "metrics",
|
"type": "metrics",
|
||||||
@@ -706,7 +833,6 @@ Sent every 5 seconds.
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### Server Event
|
#### Server Event
|
||||||
Sent for significant events (crash, restart, etc.)
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "event",
|
"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
|
## Rate Limiting
|
||||||
|
|
||||||
- `POST /auth/login`: 5 attempts per minute per IP. Exceeded returns `429 Too Many Requests`.
|
- `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 |
|
| `UNAUTHORIZED` | Missing or invalid token |
|
||||||
| `FORBIDDEN` | Role insufficient |
|
| `FORBIDDEN` | Role insufficient |
|
||||||
| `NOT_FOUND` | Resource not found |
|
| `NOT_FOUND` | Resource not found |
|
||||||
|
| `CAPABILITY_NOT_SUPPORTED` | Adapter lacks required capability for this endpoint |
|
||||||
| `SERVER_ALREADY_RUNNING` | Start called on running server |
|
| `SERVER_ALREADY_RUNNING` | Start called on running server |
|
||||||
| `SERVER_NOT_RUNNING` | Stop/command on stopped server |
|
| `SERVER_NOT_RUNNING` | Stop/command on stopped server |
|
||||||
| `RCON_UNAVAILABLE` | RCon connection failed |
|
| `REMOTE_ADMIN_UNAVAILABLE` | Remote admin connection failed |
|
||||||
| `INVALID_CONFIG` | Config validation failed |
|
| `INVALID_CONFIG` | Config validation failed (adapter-specific) |
|
||||||
| `EXE_NOT_FOUND` | arma3server.exe not at configured path |
|
| `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 |
|
| `PORT_IN_USE` | Game port already occupied |
|
||||||
| `UPLOAD_FAILED` | Mission file upload error |
|
| `UPLOAD_FAILED` | File upload error |
|
||||||
| `VALIDATION_ERROR` | Pydantic validation failure |
|
| `VALIDATION_ERROR` | Pydantic validation failure |
|
||||||
|
| `GAME_TYPE_NOT_FOUND` | No adapter registered for this game type |
|
||||||
| `INTERNAL_ERROR` | Unexpected server error |
|
| `INTERNAL_ERROR` | Unexpected server error |
|
||||||
| `MOD_IN_USE` | Cannot delete mod — enabled on one or more servers |
|
| `MOD_IN_USE` | Cannot delete mod — enabled on one or more servers |
|
||||||
| `MISSION_IN_ROTATION` | Cannot delete mission — in active rotation |
|
| `MISSION_IN_ROTATION` | Cannot delete mission — in active rotation |
|
||||||
|
|||||||
560
ARCHITECTURE.md
560
ARCHITECTURE.md
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
## Overview
|
## 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 |
|
| 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) |
|
| 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 |
|
| 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 |
|
| Game adapters | **Protocol + Registry** | Each game implements capability protocols; core resolves the adapter at runtime from `server.game_type` |
|
||||||
| Config generation | Python structured builder | Generate server.cfg, basic.cfg, server.Arma3Profile with proper escaping (no f-string injection) |
|
| Scheduling | `APScheduler` (BackgroundScheduler) | Auto-restart, log/metrics cleanup (sync DB ops → BackgroundScheduler, not AsyncIOScheduler) |
|
||||||
| Scheduling | `APScheduler` (BackgroundScheduler) | Auto-restart, mission rotation timers, log/metrics cleanup (sync DB ops → BackgroundScheduler, not AsyncIOScheduler) |
|
|
||||||
| Auth | **JWT** (python-jose) + bcrypt | Secure the API; React stores token in localStorage |
|
| 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 │
|
│ React Frontend (see FRONTEND.md) │
|
||||||
│ Server List │ Server Detail │ Logs │ Players │ Config UI │
|
│ Dashboard │ Server List │ Server Detail │ Logs │ Config UI │
|
||||||
|
│ Game Type Selector │ Adapter-specific Panels │
|
||||||
└────────────────────────┬────────────────────────────────────┘
|
└────────────────────────┬────────────────────────────────────┘
|
||||||
│ HTTP REST + WebSocket
|
│ HTTP REST + WebSocket
|
||||||
▼
|
▼
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
│ FastAPI Application │
|
│ FastAPI Application (Core) │
|
||||||
│ │
|
│ │
|
||||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
||||||
│ │ Auth Router │ │ Server Router│ │ Config Router │ │
|
│ │ 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 │ │
|
│ │ Core Service Layer │ │
|
||||||
│ │ ServerService │ ConfigService │ RConService │ │
|
│ │ ServerService │ ConfigService │ PlayerService │ │
|
||||||
│ │ LogService │ MetricsService│ MissionService │ │
|
│ │ 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 │ │
|
│ │ Thread Pool │ │
|
||||||
│ │ ProcessMonitorThread (per server) │ │
|
│ │ ProcessMonitorThread (per server, core) │ │
|
||||||
│ │ LogTailThread (per server) │ │
|
│ │ LogTailThread (per server, core + adapter parser) │ │
|
||||||
│ │ MetricsCollectorThread (per server) │ │
|
│ │ MetricsCollectorThread (per server, core) │ │
|
||||||
│ │ RConPollerThread (per server) │ │
|
│ │ RemoteAdminPollerThread (per server, core + adapter) │ │
|
||||||
│ │ BroadcastThread (global) │ │
|
│ │ BroadcastThread (global) │ │
|
||||||
│ └─────────────────────────────────────────────────────┘ │
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
│ │
|
│ │
|
||||||
@@ -60,110 +79,213 @@ Languard is a web-based management panel for Arma 3 dedicated servers. It provid
|
|||||||
│ │ Data Access Layer (DAL) │ │
|
│ │ Data Access Layer (DAL) │ │
|
||||||
│ │ ServerRepository │ PlayerRepository │ │
|
│ │ ServerRepository │ PlayerRepository │ │
|
||||||
│ │ LogRepository │ MetricsRepository │ │
|
│ │ LogRepository │ MetricsRepository │ │
|
||||||
|
│ │ ConfigRepository (game_configs table) │ │
|
||||||
│ └─────────────────────────────────────────────────────┘ │
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
│ │
|
│ │
|
||||||
│ ┌───────────────────┐ ┌────────────────────────────────┐ │
|
│ ┌───────────────────┐ ┌────────────────────────────────┐ │
|
||||||
│ │ SQLite (DB) │ │ Filesystem │ │
|
│ │ SQLite (DB) │ │ Filesystem │ │
|
||||||
│ │ languard.db │ │ servers/{id}/server.cfg │ │
|
│ │ languard.db │ │ servers/{id}/ (layout by │ │
|
||||||
│ │ │ │ servers/{id}/basic.cfg │ │
|
│ │ │ │ adapter.get_process_config() │ │
|
||||||
│ │ │ │ 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/ │ │
|
|
||||||
│ └───────────────────┘ └────────────────────────────────┘ │
|
│ └───────────────────┘ └────────────────────────────────┘ │
|
||||||
└─────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────┘
|
||||||
│ subprocess
|
│
|
||||||
▼
|
┌─────────────┴─────────────────┐
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
▼ ▼
|
||||||
│ Arma 3 Server Processes (OS level) │
|
┌──────────────────────┐ ┌──────────────────────────────────┐
|
||||||
│ arma3server_x64.exe (port 2302) │
|
│ Game Adapters │ │ Game Server Processes (OS level) │
|
||||||
│ arma3server_x64.exe (port 2402) │
|
│ │ │ │
|
||||||
│ ... │
|
│ ┌────────────────┐ │ │ (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
|
## Component Responsibilities
|
||||||
|
|
||||||
### FastAPI Routers
|
### FastAPI Routers (Core)
|
||||||
- Validate input (Pydantic models)
|
- Validate input (Pydantic models)
|
||||||
- Call service layer
|
- Resolve adapter from `server.game_type`
|
||||||
|
- Delegate game-specific work to adapter
|
||||||
- Return JSON responses
|
- Return JSON responses
|
||||||
- Handle WebSocket connections
|
- Handle WebSocket connections
|
||||||
|
- Return 404 with clear message if adapter lacks a capability
|
||||||
|
|
||||||
### Service Layer
|
### Core Service Layer
|
||||||
- Orchestrate operations (start server = generate config + launch process + start threads)
|
- Orchestrate operations (start server = resolve adapter + generate config + launch process + start threads)
|
||||||
- No direct DB access — delegates to repositories
|
- No direct DB access — delegates to repositories
|
||||||
- No direct process access — delegates to ProcessManager
|
- No direct process access — delegates to ProcessManager
|
||||||
|
- **No game-specific logic** — delegates to adapter
|
||||||
|
|
||||||
### ProcessManager
|
### ProcessManager (Core)
|
||||||
- Singleton that owns all subprocess handles
|
- Singleton that owns all subprocess handles
|
||||||
- Thread-safe dict: `{server_id: subprocess.Popen}`
|
- Thread-safe dict: `{server_id: subprocess.Popen}`
|
||||||
- `start()` sets `cwd=servers/{server_id}/` so relative config paths resolve correctly
|
- `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()`
|
- 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 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
|
| Thread | Source | Interval | Purpose |
|
||||||
- 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
|
| `ProcessMonitorThread` | Core | 1s | Detect crash / unexpected exit; update DB status; trigger auto-restart |
|
||||||
- **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.
|
| `LogTailThread` | Core + adapter's LogParser | 100ms | Read new lines from log file; parse via adapter; store in DB; push to WS |
|
||||||
- Used by: `RConPollerThread`, `RConService` (for admin commands from UI)
|
| `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
|
### ConfigRepository (Core)
|
||||||
- Takes `ServerConfig` Pydantic model from DB
|
- Manages the generic `game_configs` table
|
||||||
- Renders `server.cfg`, `basic.cfg`, `*.Arma3Profile` using a **structured builder** (NOT f-strings — prevents config injection)
|
- Stores config as JSON blobs keyed by `(server_id, section)`
|
||||||
- Escapes double quotes and newlines in all user-supplied string values
|
- **Validation is delegated to adapter's Pydantic models** — core never inspects config content
|
||||||
- Writes files to `servers/{server_id}/` directory
|
- **Sensitive field encryption**: calls `adapter.get_config_generator().get_sensitive_fields(section)` to identify which JSON keys to encrypt/decrypt via Fernet
|
||||||
- `server.Arma3Profile` written to `servers/{server_id}/server/` (Arma 3 reads from the `-name` subdirectory)
|
- **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
|
### Adapter Exceptions (Standard Error Types)
|
||||||
- Sync reads/writes using SQLAlchemy Core (not ORM — simpler for this use case)
|
|
||||||
- Thread-safe via SQLAlchemy's connection pooling
|
Adapters raise specific exception types so the core can handle errors precisely:
|
||||||
- One `languard.db` file at project root
|
|
||||||
- **PRAGMA busy_timeout=5000** — prevents "database is locked" errors under concurrent thread writes
|
| Exception | When Raised | Core Action |
|
||||||
- Thread-local connections via `get_thread_db()` — one connection per background thread
|
|-----------|------------|-------------|
|
||||||
|
| `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
|
## Data Flow: Start Server
|
||||||
|
|
||||||
```
|
```
|
||||||
Frontend → POST /api/servers/{id}/start
|
Frontend → POST /api/servers/{id}/start
|
||||||
→ ServerService.start(server_id)
|
→ ServerService.start(server_id)
|
||||||
├── Load ServerConfig from DB
|
├── Load server from DB (includes game_type)
|
||||||
├── ConfigGenerator.write_configs(server_id, config)
|
├── adapter = GameAdapterRegistry.get(server.game_type)
|
||||||
│ ├── server.cfg → servers/{id}/server.cfg
|
├── Validate exe against adapter.get_process_config().get_allowed_executables()
|
||||||
│ ├── basic.cfg → servers/{id}/basic.cfg
|
│ (raises ExeNotAllowedError → 400)
|
||||||
│ ├── server.Arma3Profile → servers/{id}/server/server.Arma3Profile
|
├── Check ALL derived ports across ALL running servers
|
||||||
│ └── beserver.cfg → servers/{id}/battleye/beserver.cfg
|
│ (resolve each server's adapter, get port conventions, check full set)
|
||||||
├── ProcessManager.start(server_id, exe_path, args, cwd=servers/{id}/)
|
├── Load config sections from game_configs table
|
||||||
├── DB: update server.status = "starting"
|
├── adapter.get_config_generator().write_configs(server_id, dir, config)
|
||||||
├── Spawn ProcessMonitorThread(server_id)
|
│ ATOMIC: writes to .tmp files first, then os.replace() to final paths
|
||||||
├── Spawn LogTailThread(server_id) — tails servers/{id}/server/arma3server_*.rpt
|
│ On failure: cleans up .tmp files, raises ConfigWriteError
|
||||||
├── Spawn MetricsCollectorThread(server_id)
|
│ Core: sets status='error', returns 500
|
||||||
├── Spawn RConPollerThread(server_id) [after 30s delay for server startup]
|
├── launch_args = adapter.get_config_generator().build_launch_args(config, mods)
|
||||||
└── BroadcastThread pushes status update to WS clients
|
│ 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
|
## Data Flow: Real-time Logs
|
||||||
|
|
||||||
```
|
```
|
||||||
arma3server.exe writes servers/{id}/server/arma3server_*.rpt
|
Game server writes log file (path determined by adapter.get_log_parser().get_log_file_resolver())
|
||||||
→ LogTailThread reads new lines (recursive glob for *.rpt in profile dir)
|
→ LogTailThread reads new lines (core tailing logic, game-agnostic)
|
||||||
→ LogRepository.insert(server_id, line, timestamp)
|
→ adapter.get_log_parser().parse_line(line) → {timestamp, level, message}
|
||||||
→ BroadcastQueue.put({type: "log", server_id, line, timestamp})
|
→ LogRepository.insert(server_id, entry)
|
||||||
|
→ BroadcastQueue.put({type: "log", server_id, entry})
|
||||||
→ BroadcastThread sends to all WS subscribers for this server
|
→ BroadcastThread sends to all WS subscribers for this server
|
||||||
→ React frontend appends to log viewer
|
→ React frontend appends to log viewer
|
||||||
```
|
```
|
||||||
@@ -171,10 +293,10 @@ arma3server.exe writes servers/{id}/server/arma3server_*.rpt
|
|||||||
## Data Flow: Player List
|
## Data Flow: Player List
|
||||||
|
|
||||||
```
|
```
|
||||||
RConPollerThread (every 10s)
|
RemoteAdminPollerThread (every 10s, core thread)
|
||||||
→ RConClient.send("players")
|
→ adapter.get_remote_admin().create_client() → client instance
|
||||||
→ Parse response: [{id, name, guid, ping, verified}]
|
→ client.get_players() → list of player dicts
|
||||||
→ PlayerRepository.upsert_all(server_id, players)
|
→ PlayerService.update_from_remote_admin(server_id, players)
|
||||||
→ BroadcastQueue.put({type: "players", server_id, players})
|
→ BroadcastQueue.put({type: "players", server_id, players})
|
||||||
→ React frontend updates player list
|
→ React frontend updates player list
|
||||||
```
|
```
|
||||||
@@ -189,11 +311,48 @@ RConPollerThread (every 10s)
|
|||||||
- `admin` role: all operations
|
- `admin` role: all operations
|
||||||
- CORS configured to accept only the frontend origin
|
- CORS configured to accept only the frontend origin
|
||||||
- Passwords hashed with **bcrypt** (cost factor 12)
|
- Passwords hashed with **bcrypt** (cost factor 12)
|
||||||
- `serverCommandPassword` and `passwordAdmin` stored encrypted in SQLite (AES-256 via `cryptography` library, key from env)
|
- 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: checks game_port through game_port+4 (game, Steam query, Steam master, Steam auth, RCon) against all existing servers
|
- **Port conflict validation** at server creation and start: uses adapter's `get_port_conventions()` to determine all derived ports
|
||||||
- **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.
|
- **Ban file sync**: adapter's BanManager handles bidirectional sync between DB bans table and game's ban file format
|
||||||
- Generated config files containing passwords (server.cfg, beserver.cfg) have restrictive file permissions (0600 on Unix, restricted ACL on Windows)
|
- 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
|
- 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
|
```env
|
||||||
LANGUARD_SECRET_KEY=<jwt-signing-secret>
|
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_DB_PATH=./languard.db
|
||||||
LANGUARD_SERVERS_DIR=./servers
|
LANGUARD_SERVERS_DIR=./servers
|
||||||
LANGUARD_ARMA_EXE=C:/Arma3Server/arma3server_x64.exe
|
|
||||||
LANGUARD_HOST=0.0.0.0
|
LANGUARD_HOST=0.0.0.0
|
||||||
LANGUARD_PORT=8000
|
LANGUARD_PORT=8000
|
||||||
LANGUARD_CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
LANGUARD_CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||||
LANGUARD_LOG_RETENTION_DAYS=7
|
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
|
│ ├── main.py # FastAPI app factory
|
||||||
│ ├── config.py # Settings from env
|
│ ├── config.py # Settings from env
|
||||||
│ ├── database.py # SQLAlchemy engine + session
|
│ ├── database.py # SQLAlchemy engine + session
|
||||||
│ ├── auth/
|
│ ├── dependencies.py # Auth deps, server lookup
|
||||||
│ │ ├── router.py
|
│ │
|
||||||
│ │ ├── service.py
|
│ ├── core/ # Game-agnostic core
|
||||||
│ │ └── schemas.py
|
│ │ ├── auth/
|
||||||
│ ├── servers/
|
│ │ │ ├── router.py
|
||||||
│ │ ├── router.py # REST endpoints for servers
|
│ │ │ ├── service.py
|
||||||
│ │ ├── service.py # ServerService
|
│ │ │ ├── schemas.py
|
||||||
│ │ ├── process_manager.py # ProcessManager singleton
|
│ │ │ └── utils.py
|
||||||
│ │ ├── config_generator.py # server.cfg / basic.cfg / beserver.cfg writer
|
│ │ ├── servers/
|
||||||
│ │ └── schemas.py # Pydantic schemas
|
│ │ │ ├── router.py # REST endpoints for servers
|
||||||
│ ├── rcon/
|
│ │ │ ├── service.py # ServerService (delegates to adapter)
|
||||||
│ │ ├── client.py # BattlEye RCon UDP client
|
│ │ │ ├── process_manager.py # ProcessManager singleton
|
||||||
│ │ └── service.py # RConService
|
│ │ │ └── schemas.py # Generic Pydantic schemas
|
||||||
│ ├── players/
|
│ │ ├── players/
|
||||||
│ │ ├── router.py
|
│ │ │ ├── router.py
|
||||||
│ │ ├── service.py
|
│ │ │ ├── service.py
|
||||||
│ │ └── schemas.py
|
│ │ │ └── schemas.py
|
||||||
│ ├── missions/
|
│ │ ├── logs/
|
||||||
│ │ ├── router.py
|
│ │ │ ├── router.py
|
||||||
│ │ └── service.py
|
│ │ │ └── service.py
|
||||||
│ ├── mods/
|
│ │ ├── metrics/
|
||||||
│ │ ├── router.py
|
│ │ │ ├── router.py
|
||||||
│ │ └── service.py
|
│ │ │ └── service.py
|
||||||
│ ├── logs/
|
│ │ ├── bans/
|
||||||
│ │ ├── router.py
|
│ │ │ ├── router.py
|
||||||
│ │ └── service.py
|
│ │ │ └── service.py
|
||||||
│ ├── metrics/
|
│ │ ├── events/
|
||||||
│ │ ├── router.py
|
│ │ │ ├── router.py
|
||||||
│ │ └── service.py
|
│ │ │ └── service.py
|
||||||
│ ├── websocket/
|
│ │ ├── websocket/
|
||||||
│ │ ├── router.py # WS connection handler
|
│ │ │ ├── router.py
|
||||||
│ │ ├── manager.py # ConnectionManager (per-server subscriptions)
|
│ │ │ ├── manager.py
|
||||||
│ │ └── broadcaster.py # BroadcastThread + queue
|
│ │ │ └── broadcaster.py
|
||||||
│ ├── threads/
|
│ │ ├── threads/
|
||||||
│ │ ├── process_monitor.py # ProcessMonitorThread
|
│ │ │ ├── base_thread.py
|
||||||
│ │ ├── log_tail.py # LogTailThread
|
│ │ │ ├── process_monitor.py
|
||||||
│ │ ├── metrics_collector.py # MetricsCollectorThread
|
│ │ │ ├── log_tail.py # Generic, takes adapter LogParser
|
||||||
│ │ └── rcon_poller.py # RConPollerThread
|
│ │ │ ├── metrics_collector.py
|
||||||
│ ├── system/
|
│ │ │ ├── remote_admin_poller.py # Generic, takes adapter RemoteAdmin
|
||||||
│ │ └── router.py # GET /system/status, GET /system/health
|
│ │ │ └── thread_registry.py
|
||||||
│ ├── dal/
|
│ │ ├── games/
|
||||||
│ │ ├── server_repository.py
|
│ │ │ └── router.py # /api/games — type discovery, schemas
|
||||||
│ │ ├── config_repository.py
|
│ │ ├── system/
|
||||||
│ │ ├── player_repository.py
|
│ │ │ └── router.py
|
||||||
│ │ ├── log_repository.py
|
│ │ ├── dal/
|
||||||
│ │ ├── metrics_repository.py
|
│ │ │ ├── base_repository.py
|
||||||
│ │ ├── mission_repository.py
|
│ │ │ ├── server_repository.py
|
||||||
│ │ ├── mod_repository.py
|
│ │ │ ├── config_repository.py # game_configs table
|
||||||
│ │ ├── ban_repository.py
|
│ │ │ ├── player_repository.py
|
||||||
│ │ └── event_repository.py
|
│ │ │ ├── log_repository.py
|
||||||
│ └── migrations/
|
│ │ │ ├── metrics_repository.py
|
||||||
│ └── 001_initial_schema.sql
|
│ │ │ ├── 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
|
├── servers/ # Runtime data per server instance
|
||||||
│ └── {server_id}/
|
│ └── {server_id}/ # Layout determined by adapter.get_process_config()
|
||||||
│ ├── server.cfg
|
│ └── (Arma 3: server.cfg, basic.cfg, server/, battleye/, mpmissions/)
|
||||||
│ ├── basic.cfg
|
│
|
||||||
│ ├── server/ # Arma 3 profile dir (matches -name=server)
|
├── frontend/ # React app
|
||||||
│ │ ├── server.Arma3Profile
|
|
||||||
│ │ └── arma3server_*.rpt # Timestamped RPT logs
|
|
||||||
│ ├── battleye/
|
|
||||||
│ │ └── beserver.cfg # BattlEye RCon config (generated on start)
|
|
||||||
│ └── mpmissions/
|
|
||||||
├── frontend/ # React app (separate repo or subfolder)
|
|
||||||
├── requirements.txt
|
├── requirements.txt
|
||||||
├── .env.example
|
├── .env.example
|
||||||
├── ARCHITECTURE.md
|
├── ARCHITECTURE.md
|
||||||
@@ -291,19 +481,67 @@ languard-servers-manager/
|
|||||||
├── API.md
|
├── API.md
|
||||||
├── MODULES.md
|
├── MODULES.md
|
||||||
├── THREADING.md
|
├── THREADING.md
|
||||||
|
├── FRONTEND.md
|
||||||
└── IMPLEMENTATION_PLAN.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
|
## Key Design Decisions
|
||||||
|
|
||||||
| Decision | Choice | Reason |
|
| 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 |
|
| Game-specific logic | **Adapter pattern with Protocol + Registry** | Structural subtyping with mypy enforcement; optional capabilities return None; zero core changes per game |
|
||||||
| ORM vs Core | **SQLAlchemy Core** | Simpler SQL control, less magic for embedded use case |
|
| 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. |
|
||||||
| WebSocket auth | JWT in query param on connect | Browser WS API doesn't support headers; query param `?token=...` |
|
| 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 |
|
| 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 | **Adapter regenerates on each start** | Always fresh from DB; no sync drift; adapter's structured builder prevents config injection |
|
||||||
| 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 |
|
| 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. |
|
||||||
| 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. |
|
| 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 |
|
||||||
762
DATABASE.md
762
DATABASE.md
@@ -9,6 +9,19 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Design Philosophy
|
||||||
|
|
||||||
|
The database uses a **hybrid approach**: core tables are fully normalized (game-agnostic), while game-specific config is stored as JSON blobs in a generic `game_configs` table, validated by adapter Pydantic models at the application layer.
|
||||||
|
|
||||||
|
**Why this works for Languard:**
|
||||||
|
- Config is always read/written as a whole section (nobody queries "find all servers where von_codec_quality > 20")
|
||||||
|
- Each adapter section maps to a Pydantic model, so validation is enforced at the application layer
|
||||||
|
- The JSON is opaque to the core, meaningful only to the adapter that owns it
|
||||||
|
- Adding a new game requires **zero DB migration** — just a new adapter
|
||||||
|
- Core queries across all games work naturally
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Schema
|
## Schema
|
||||||
|
|
||||||
### Table: `users`
|
### Table: `users`
|
||||||
@@ -31,29 +44,28 @@ CREATE TABLE users (
|
|||||||
|
|
||||||
### Table: `servers`
|
### Table: `servers`
|
||||||
|
|
||||||
One row per managed Arma 3 server instance.
|
One row per managed server instance. **Game-agnostic.**
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE servers (
|
CREATE TABLE servers (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NOT NULL, -- display name in UI
|
name TEXT NOT NULL, -- display name in UI
|
||||||
description TEXT,
|
description TEXT,
|
||||||
|
game_type TEXT NOT NULL DEFAULT 'arma3', -- adapter lookup key
|
||||||
status TEXT NOT NULL DEFAULT 'stopped',
|
status TEXT NOT NULL DEFAULT 'stopped',
|
||||||
-- status values: 'stopped' | 'starting' | 'running' | 'stopping' | 'crashed' | 'error'
|
|
||||||
CHECK (status IN ('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
|
-- Process info
|
||||||
pid INTEGER, -- OS process ID when running
|
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
|
started_at TEXT, -- ISO datetime
|
||||||
stopped_at TEXT,
|
stopped_at TEXT,
|
||||||
|
|
||||||
-- Network
|
-- Network (core ports; adapter defines derived port conventions)
|
||||||
game_port INTEGER NOT NULL DEFAULT 2302,
|
game_port INTEGER NOT NULL,
|
||||||
rcon_port INTEGER NOT NULL DEFAULT 2306, -- user-configurable; written to battleye/beserver.cfg
|
rcon_port INTEGER, -- NULL if game has no remote admin
|
||||||
steam_query_port INTEGER GENERATED ALWAYS AS (game_port + 1) VIRTUAL, -- convention, not enforced by engine
|
CHECK (game_port BETWEEN 1024 AND 65535),
|
||||||
|
CHECK (rcon_port IS NULL OR (rcon_port BETWEEN 1024 AND 65535)),
|
||||||
|
|
||||||
-- Auto-management
|
-- Auto-management
|
||||||
auto_restart INTEGER NOT NULL DEFAULT 0, -- 1 = restart on crash
|
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_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_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
|
```sql
|
||||||
CREATE TABLE server_configs (
|
CREATE TABLE game_configs (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
server_id INTEGER NOT NULL UNIQUE REFERENCES servers(id) ON DELETE CASCADE,
|
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
game_type TEXT NOT NULL, -- for validation; matches servers.game_type
|
||||||
-- Basic identity
|
section TEXT NOT NULL, -- e.g. 'server', 'basic', 'profile', 'launch', 'rcon'
|
||||||
hostname TEXT NOT NULL DEFAULT 'My Arma 3 Server',
|
config_json TEXT NOT NULL DEFAULT '{}', -- JSON validated by adapter's Pydantic model
|
||||||
password TEXT, -- join password (encrypted at app layer via Fernet)
|
config_version INTEGER NOT NULL DEFAULT 1, -- optimistic locking version; incremented on each write
|
||||||
password_admin TEXT NOT NULL, -- encrypted (no default — must be set on creation)
|
schema_version TEXT NOT NULL DEFAULT '1.0', -- adapter schema version at time of last write
|
||||||
server_command_password TEXT, -- encrypted
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(server_id, section)
|
||||||
-- 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 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.
|
Section `server` — maps to `server.cfg` parameters:
|
||||||
|
```json
|
||||||
```sql
|
{
|
||||||
CREATE TABLE basic_configs (
|
"hostname": "My Arma 3 Server",
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
"password": "encrypted:...",
|
||||||
server_id INTEGER NOT NULL UNIQUE REFERENCES servers(id) ON DELETE CASCADE,
|
"password_admin": "encrypted:...",
|
||||||
|
"server_command_password": "encrypted:...",
|
||||||
min_bandwidth INTEGER NOT NULL DEFAULT 800000,
|
"max_players": 40,
|
||||||
max_bandwidth INTEGER NOT NULL DEFAULT 25000000,
|
"kick_duplicate": 1,
|
||||||
max_msg_send INTEGER NOT NULL DEFAULT 384, -- default 128; higher = desync risk
|
"persistent": 1,
|
||||||
max_size_guaranteed INTEGER NOT NULL DEFAULT 512,
|
"vote_threshold": 0.33,
|
||||||
max_size_non_guaranteed INTEGER NOT NULL DEFAULT 256,
|
"vote_mission_players": 1,
|
||||||
min_error_to_send REAL NOT NULL DEFAULT 0.003,
|
"vote_timeout": 60,
|
||||||
max_custom_file_size INTEGER NOT NULL DEFAULT 100000,
|
"role_timeout": 90,
|
||||||
|
"briefing_timeout": 60,
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
"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"]
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
Section `basic` — maps to `basic.cfg` parameters:
|
||||||
|
```json
|
||||||
### Table: `server_profiles`
|
{
|
||||||
|
"min_bandwidth": 800000,
|
||||||
Stores `server.Arma3Profile` difficulty settings. One row per server.
|
"max_bandwidth": 25000000,
|
||||||
|
"max_msg_send": 384,
|
||||||
```sql
|
"max_size_guaranteed": 512,
|
||||||
CREATE TABLE server_profiles (
|
"max_size_non_guaranteed": 256,
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
"min_error_to_send": 0.003,
|
||||||
server_id INTEGER NOT NULL UNIQUE REFERENCES servers(id) ON DELETE CASCADE,
|
"max_custom_file_size": 100000
|
||||||
|
}
|
||||||
-- 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 `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.
|
Section `rcon` — BattlEye RCon settings:
|
||||||
|
```json
|
||||||
```sql
|
{
|
||||||
CREATE TABLE launch_params (
|
"rcon_password": "encrypted:...",
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
"max_ping": 200,
|
||||||
server_id INTEGER NOT NULL UNIQUE REFERENCES servers(id) ON DELETE CASCADE,
|
"enabled": 1
|
||||||
|
}
|
||||||
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'))
|
|
||||||
);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Table: `mods`
|
### Table: `mods`
|
||||||
|
|
||||||
Registered mods. Many-to-many with servers.
|
Registered mods. Many-to-many with servers. Scoped by `game_type`.
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE mods (
|
CREATE TABLE mods (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
game_type TEXT NOT NULL, -- scope mods by game type
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
folder_path TEXT NOT NULL UNIQUE, -- absolute or relative path
|
folder_path TEXT NOT NULL,
|
||||||
workshop_id TEXT, -- Steam Workshop ID if applicable
|
workshop_id TEXT, -- Steam Workshop ID if applicable
|
||||||
description TEXT,
|
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 (
|
CREATE TABLE server_mods (
|
||||||
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
mod_id INTEGER NOT NULL REFERENCES mods(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,
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
game_data TEXT DEFAULT '{}', -- JSON for per-server mod overrides
|
||||||
PRIMARY KEY (server_id, mod_id)
|
PRIMARY KEY (server_id, mod_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_server_mods_server ON server_mods(server_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`
|
### Table: `missions`
|
||||||
|
|
||||||
Mission PBO files tracked per server.
|
Mission/scenario files tracked per server.
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE missions (
|
CREATE TABLE missions (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
filename TEXT NOT NULL, -- e.g. "MyMission.Altis.pbo"
|
filename TEXT NOT NULL, -- e.g. "MyMission.Altis.pbo"
|
||||||
mission_name TEXT NOT NULL, -- e.g. "MyMission.Altis"
|
mission_name TEXT NOT NULL, -- parsed by adapter
|
||||||
terrain TEXT NOT NULL, -- e.g. "Altis"
|
terrain TEXT, -- may be NULL for non-Arma games
|
||||||
file_size INTEGER, -- bytes
|
file_size INTEGER, -- bytes
|
||||||
|
game_data TEXT DEFAULT '{}', -- JSON for game-specific mission metadata
|
||||||
uploaded_at TEXT NOT NULL DEFAULT (datetime('now')),
|
uploaded_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
UNIQUE (server_id, filename)
|
UNIQUE (server_id, filename)
|
||||||
);
|
);
|
||||||
@@ -330,11 +311,13 @@ CREATE TABLE missions (
|
|||||||
CREATE INDEX idx_missions_server ON missions(server_id);
|
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`
|
### Table: `mission_rotation`
|
||||||
|
|
||||||
Ordered mission cycle for a server.
|
Ordered mission/scenario cycle for a server.
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE mission_rotation (
|
CREATE TABLE mission_rotation (
|
||||||
@@ -342,40 +325,43 @@ CREATE TABLE mission_rotation (
|
|||||||
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
mission_id INTEGER NOT NULL REFERENCES missions(id) ON DELETE CASCADE,
|
mission_id INTEGER NOT NULL REFERENCES missions(id) ON DELETE CASCADE,
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
difficulty TEXT NOT NULL DEFAULT 'Regular',
|
difficulty TEXT, -- game-specific (NULL for games without difficulty)
|
||||||
CHECK (difficulty IN ('Recruit', 'Regular', 'Veteran', 'Custom')),
|
params_json TEXT NOT NULL DEFAULT '{}', -- mission params as JSON
|
||||||
params_json TEXT NOT NULL DEFAULT '{}', -- mission params override as JSON
|
game_data TEXT DEFAULT '{}', -- adapter-specific rotation metadata
|
||||||
UNIQUE (server_id, sort_order)
|
UNIQUE (server_id, sort_order)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_mission_rotation_server ON mission_rotation(server_id);
|
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`
|
### Table: `players`
|
||||||
|
|
||||||
Currently connected players (live state, refreshed by RConPollerThread).
|
Currently connected players (live state, refreshed by RemoteAdminPollerThread).
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE players (
|
CREATE TABLE players (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
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,
|
name TEXT NOT NULL,
|
||||||
guid TEXT, -- BattlEye GUID
|
guid TEXT, -- Game-specific identifier (BattlEye GUID, Steam ID, etc.)
|
||||||
steam_uid TEXT,
|
|
||||||
ip TEXT,
|
ip TEXT,
|
||||||
ping INTEGER,
|
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')),
|
joined_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
updated_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);
|
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`
|
### Table: `player_history`
|
||||||
@@ -388,15 +374,15 @@ CREATE TABLE player_history (
|
|||||||
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
guid TEXT,
|
guid TEXT,
|
||||||
steam_uid TEXT,
|
|
||||||
ip TEXT,
|
ip TEXT,
|
||||||
|
game_data TEXT DEFAULT '{}', -- JSON for game-specific historical data
|
||||||
joined_at TEXT NOT NULL,
|
joined_at TEXT NOT NULL,
|
||||||
left_at TEXT NOT NULL DEFAULT (datetime('now')),
|
left_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
session_duration_seconds INTEGER
|
session_duration_seconds INTEGER
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_player_history_server ON player_history(server_id);
|
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)
|
### Player History Retention Cleanup (run daily via APScheduler, keep 90 days)
|
||||||
@@ -409,42 +395,35 @@ WHERE left_at < datetime('now', '-90 days');
|
|||||||
|
|
||||||
### Table: `bans`
|
### 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 records. Core concept is game-agnostic; ban file sync is adapter-specific.
|
||||||
|
|
||||||
**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.
|
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE bans (
|
CREATE TABLE bans (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
guid TEXT,
|
guid TEXT,
|
||||||
steam_uid TEXT,
|
|
||||||
name TEXT,
|
name TEXT,
|
||||||
reason TEXT,
|
reason TEXT,
|
||||||
banned_by TEXT, -- admin username
|
banned_by TEXT, -- admin username
|
||||||
banned_at TEXT NOT NULL DEFAULT (datetime('now')),
|
banned_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
expires_at TEXT, -- NULL = permanent
|
expires_at TEXT, -- NULL = permanent
|
||||||
is_active INTEGER NOT NULL DEFAULT 1,
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
game_data TEXT DEFAULT '{}', -- JSON: {steam_uid, ip, etc.}
|
||||||
CHECK (is_active IN (0, 1))
|
CHECK (is_active IN (0, 1))
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_bans_server ON bans(server_id);
|
CREATE INDEX idx_bans_server ON bans(server_id);
|
||||||
CREATE INDEX idx_bans_guid ON bans(guid);
|
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);
|
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`
|
### Table: `logs`
|
||||||
|
|
||||||
Parsed RPT log lines (rolling retention, default 7 days).
|
Parsed log lines (rolling retention, default 7 days).
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE logs (
|
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_server_ts ON logs(server_id, timestamp);
|
||||||
CREATE INDEX idx_logs_level ON logs(level); -- for ?level= filter
|
CREATE INDEX idx_logs_level ON logs(level);
|
||||||
CREATE INDEX idx_logs_created ON logs(created_at); -- for retention cleanup
|
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`
|
### Table: `metrics`
|
||||||
@@ -481,21 +462,24 @@ CREATE TABLE metrics (
|
|||||||
CREATE INDEX idx_metrics_server_ts ON metrics(server_id, timestamp);
|
CREATE INDEX idx_metrics_server_ts ON metrics(server_id, timestamp);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**No changes** — fully game-agnostic.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Table: `server_events`
|
### Table: `server_events`
|
||||||
|
|
||||||
Audit trail of all significant events (start, stop, crash, restart, admin actions).
|
Audit trail of all significant events.
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE server_events (
|
CREATE TABLE server_events (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
event_type TEXT NOT NULL,
|
event_type TEXT NOT NULL,
|
||||||
-- event_type values:
|
-- Core event types:
|
||||||
-- 'started' | 'stopped' | 'crashed' | 'restarted' | 'config_updated'
|
-- 'started' | 'stopped' | 'crashed' | 'restarted' | 'config_updated'
|
||||||
-- 'player_kicked' | 'player_banned' | 'mission_changed' | 'admin_login'
|
-- 'player_kicked' | 'player_banned' | 'admin_login'
|
||||||
-- 'rcon_command' | 'auto_restarted'
|
-- 'auto_restarted' | 'max_restarts_exceeded'
|
||||||
|
-- Adapters may define additional event types
|
||||||
actor TEXT, -- username or 'system'
|
actor TEXT, -- username or 'system'
|
||||||
detail TEXT, -- JSON with event-specific data
|
detail TEXT, -- JSON with event-specific data
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
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);
|
CREATE INDEX idx_events_server ON server_events(server_id, created_at);
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
**No changes** — fully game-agnostic. Adapters can emit additional event types.
|
||||||
|
|
||||||
### 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'))
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -528,12 +497,8 @@ CREATE TABLE rcon_configs (
|
|||||||
```
|
```
|
||||||
users (1) ──────────────────────────────────── (many) server_events.actor
|
users (1) ──────────────────────────────────── (many) server_events.actor
|
||||||
|
|
||||||
servers (1) ──┬── (1) server_configs
|
servers (1) ──┬── (many) game_configs ← JSON sections replace 5 Arma 3 tables
|
||||||
├── (1) basic_configs
|
├── (many) server_mods ──── (many) mods (scoped by game_type)
|
||||||
├── (1) server_profiles
|
|
||||||
├── (1) launch_params
|
|
||||||
├── (1) rcon_configs
|
|
||||||
├── (many) server_mods ──── (many) mods
|
|
||||||
├── (many) missions
|
├── (many) missions
|
||||||
├── (many) mission_rotation → missions
|
├── (many) mission_rotation → missions
|
||||||
├── (many) players
|
├── (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
|
## Maintenance Queries
|
||||||
|
|
||||||
### Log Retention Cleanup (run daily via APScheduler)
|
### Log Retention Cleanup (run daily via APScheduler)
|
||||||
@@ -574,8 +580,8 @@ VACUUM;
|
|||||||
|
|
||||||
## Migration Strategy
|
## Migration Strategy
|
||||||
|
|
||||||
- Migrations are plain `.sql` files in `backend/migrations/`
|
- Migrations are plain `.sql` files in `backend/core/migrations/`
|
||||||
- Naming: `001_initial_schema.sql`, `002_add_bans.sql`, etc.
|
- Naming: `001_initial_schema.sql`, `002_add_game_type.sql`, etc.
|
||||||
- Tracked in a `schema_migrations` table:
|
- Tracked in a `schema_migrations` table:
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE schema_migrations (
|
CREATE TABLE schema_migrations (
|
||||||
@@ -584,3 +590,229 @@ VACUUM;
|
|||||||
);
|
);
|
||||||
```
|
```
|
||||||
- Applied automatically at app startup by `database.py:run_migrations()`
|
- 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
1343
FRONTEND.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,15 +4,61 @@
|
|||||||
|
|
||||||
Before starting, ensure the following are available:
|
Before starting, ensure the following are available:
|
||||||
- Python 3.11+
|
- 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)
|
- Node.js 18+ (for frontend dev server)
|
||||||
- The reference docs: ARCHITECTURE.md, DATABASE.md, API.md, MODULES.md, THREADING.md
|
- The reference docs: ARCHITECTURE.md, DATABASE.md, API.md, MODULES.md, THREADING.md
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 1 — Foundation (Start Here)
|
## Phase 0 — Adapter Framework (New)
|
||||||
|
|
||||||
**Goal:** Running FastAPI server with DB, auth, and basic server CRUD.
|
**Goal:** Build the adapter protocol + registry system before any other code. This is the foundation that makes every subsequent phase modular.
|
||||||
|
|
||||||
|
### Step 0.1 — Adapter protocols, exceptions, and registry
|
||||||
|
|
||||||
|
1. Create `backend/adapters/__init__.py` — auto-imports built-in adapters
|
||||||
|
2. Create `backend/adapters/protocols.py` — all capability Protocol definitions:
|
||||||
|
- `ConfigGenerator` (merged: schema + generation), `ProcessConfig`, `LogParser`
|
||||||
|
- `RemoteAdmin`, `RemoteAdminClient`
|
||||||
|
- `MissionManager`, `ModManager`, `BanManager`
|
||||||
|
- `GameAdapter` (composite protocol with `has_capability()` method)
|
||||||
|
- `ConfigGenerator` includes `get_sections()`, `get_sensitive_fields(section)`, `get_config_version()`
|
||||||
|
- `RemoteAdmin` includes `get_player_data_schema() -> type[BaseModel] | None`
|
||||||
|
- `MissionManager` includes `get_mission_data_schema() -> type[BaseModel] | None`
|
||||||
|
- `ModManager` includes `get_mod_data_schema() -> type[BaseModel] | None`
|
||||||
|
- `BanManager` includes `get_ban_data_schema() -> type[BaseModel] | None`
|
||||||
|
3. Create `backend/adapters/exceptions.py` — typed adapter exceptions:
|
||||||
|
- `AdapterError` (base)
|
||||||
|
- `ConfigWriteError` — atomic write failed (tmp file cleanup done)
|
||||||
|
- `ConfigValidationError` — adapter Pydantic validation failed
|
||||||
|
- `LaunchArgsError` — invalid launch arguments
|
||||||
|
- `RemoteAdminError` — admin protocol communication failed
|
||||||
|
- `ExeNotAllowedError` — executable not in adapter allowlist
|
||||||
|
4. Create `backend/adapters/registry.py` — `GameAdapterRegistry` singleton
|
||||||
|
5. Add `has_capability(name) -> bool` method to `GameAdapter` protocol — core uses explicit capability probes instead of scattered `None` checks
|
||||||
|
6. Write unit tests: register adapter, get adapter, list game types, missing adapter raises error, exceptions are catchable by type, has_capability returns correct bools
|
||||||
|
|
||||||
|
### Step 0.2 — Arma 3 adapter skeleton
|
||||||
|
|
||||||
|
1. Create `backend/adapters/arma3/__init__.py` — exports and registers `ARMA3_ADAPTER`
|
||||||
|
2. Create `backend/adapters/arma3/adapter.py` — `Arma3Adapter` class (all methods return stubs initially)
|
||||||
|
3. Create `backend/adapters/arma3/process_config.py` — `Arma3ProcessConfig` (full implementation)
|
||||||
|
4. Create `backend/adapters/arma3/config_generator.py` — Pydantic models (ServerConfig, BasicConfig, ProfileConfig, LaunchConfig, RConConfig) + `Arma3ConfigGenerator` (schema + generation merged)
|
||||||
|
5. **Third-party adapter loading**: add `languard.adapters` entry_point group to `pyproject.toml`:
|
||||||
|
```toml
|
||||||
|
[project.entry-points."languard.adapters"]
|
||||||
|
arma3 = "adapters.arma3:ARMA3_ADAPTER"
|
||||||
|
```
|
||||||
|
Core scans entry_points at startup via `importlib.metadata` in addition to built-in imports.
|
||||||
|
6. Write unit tests: adapter registers, protocols satisfied, config schema produces valid JSON Schema
|
||||||
|
|
||||||
|
**Test:** Import adapters module → `GameAdapterRegistry.get("arma3")` returns a valid adapter. `GameAdapterRegistry.list_game_types()` returns `[{"game_type": "arma3", "display_name": "Arma 3", ...}]`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — Foundation
|
||||||
|
|
||||||
|
**Goal:** Running FastAPI server with DB, auth, and basic server CRUD using the adapter framework.
|
||||||
|
|
||||||
### Step 1.1 — Project scaffold
|
### Step 1.1 — Project scaffold
|
||||||
|
|
||||||
@@ -22,230 +68,214 @@ cd backend
|
|||||||
python -m venv venv
|
python -m venv venv
|
||||||
venv/Scripts/activate
|
venv/Scripts/activate
|
||||||
pip install fastapi uvicorn[standard] sqlalchemy python-jose[cryptography] passlib[bcrypt] cryptography psutil apscheduler python-multipart slowapi pytest pytest-asyncio httpx
|
pip 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
|
pip freeze > requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
Create:
|
Create:
|
||||||
- `backend/config.py` — Settings class (see MODULES.md)
|
- `backend/config.py` — Settings class
|
||||||
- `backend/main.py` — FastAPI app factory, startup/shutdown hooks
|
- `backend/main.py` — FastAPI app factory, startup/shutdown hooks
|
||||||
- `backend/conftest.py` — pytest fixtures (in-memory SQLite, test client)
|
- `backend/conftest.py` — pytest fixtures (in-memory SQLite, test client)
|
||||||
- `.env.example` — All env vars documented
|
- `.env.example` — All env vars documented
|
||||||
|
|
||||||
### Step 1.2 — Database + Migrations
|
### Step 1.2 — Database + Migrations
|
||||||
|
|
||||||
1. Create `backend/migrations/001_initial_schema.sql` — all tables from DATABASE.md
|
1. Create `backend/core/migrations/001_initial_schema.sql` — all core tables:
|
||||||
- Include all CHECK constraints (role, status, verify_signatures, von_codec_quality, etc.)
|
- `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
|
- Include `PRAGMA busy_timeout=5000` in engine setup
|
||||||
- **Important:** Put `CREATE TABLE IF NOT EXISTS schema_migrations` as the very first
|
2. Create `backend/core/dal/event_repository.py`
|
||||||
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)
|
|
||||||
3. Create `backend/database.py`:
|
3. Create `backend/database.py`:
|
||||||
- `get_engine()` with WAL + FK pragma
|
- `get_engine()` with WAL + FK pragma
|
||||||
- `run_migrations()` — reads and applies `.sql` files from migrations/
|
- `run_migrations()`
|
||||||
- `get_db()` — FastAPI dependency (sync session)
|
- `get_db()` — FastAPI dependency
|
||||||
- `get_thread_db()` — thread-local session factory
|
- `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
|
### Step 1.3 — Auth module
|
||||||
|
|
||||||
1. `backend/auth/utils.py` — `hash_password`, `verify_password`, `create_access_token`, `decode_access_token`
|
1. `backend/core/auth/utils.py` — `hash_password`, `verify_password`, `create_access_token`, `decode_access_token`
|
||||||
2. `backend/auth/schemas.py` — `LoginRequest`, `TokenResponse`, `UserResponse`
|
2. `backend/core/auth/schemas.py` — `LoginRequest`, `TokenResponse`, `UserResponse`
|
||||||
3. `backend/auth/service.py` — `AuthService` (create user, login, list users)
|
3. `backend/core/auth/service.py` — `AuthService`
|
||||||
4. `backend/auth/router.py` — login, me, users CRUD
|
4. `backend/core/auth/router.py` — login, me, users CRUD
|
||||||
5. `backend/dependencies.py` — `get_current_user`, `require_admin`
|
5. `backend/dependencies.py` — `get_current_user`, `require_admin`, `get_adapter_for_server`
|
||||||
6. `main.py` — seed default admin user on first startup if users table empty
|
6. `main.py` — seed default admin user on first startup (random password printed to stdout)
|
||||||
- **Generate a random password** and print it to stdout once (NOT admin/admin)
|
7. Add rate limiting to `POST /auth/login` (5 attempts/minute per IP via slowapi)
|
||||||
- Add rate limiting to `POST /auth/login` (5 attempts/minute per IP via slowapi)
|
|
||||||
- Add input sanitization for all string fields in auth schemas
|
|
||||||
|
|
||||||
**Test:** `POST /api/auth/login` returns JWT. `GET /api/auth/me` with token returns user. Rate limiting returns 429 after 5 failed attempts.
|
**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)
|
### Step 1.4 — Server CRUD (no process management yet)
|
||||||
|
|
||||||
1. `backend/dal/server_repository.py`
|
1. `backend/core/dal/server_repository.py`
|
||||||
2. `backend/dal/config_repository.py`
|
2. `backend/core/dal/config_repository.py` — manages `game_configs` table
|
||||||
3. `backend/servers/schemas.py`
|
3. `backend/core/servers/schemas.py` — `CreateServerRequest` (includes `game_type`)
|
||||||
4. `backend/servers/router.py` — GET, POST, PUT, DELETE /servers and /servers/{id}
|
4. `backend/core/servers/router.py` — GET, POST, PUT, DELETE /servers
|
||||||
5. `backend/servers/service.py` — CRUD methods only (skip start/stop for now)
|
5. `backend/core/servers/service.py` — CRUD methods + `create_server` seeds config sections from adapter defaults
|
||||||
6. `backend/utils/file_utils.py` — `ensure_server_dirs()`, `sanitize_filename()`
|
6. `backend/core/utils/file_utils.py` — `ensure_server_dirs()` (uses adapter's `get_server_dir_layout()`)
|
||||||
7. `backend/utils/port_checker.py` — `is_port_in_use()`, `check_server_ports_available()`
|
7. `backend/core/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
|
- **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`
|
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 to prevent config injection
|
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 DATABASE.md, including mission rotation as `class Missions {}` block
|
3. Write `server.cfg` covering all params from config schema, including mission rotation as `class Missions {}` block
|
||||||
4. Write `basic.cfg`
|
4. Write `basic.cfg`
|
||||||
5. Write `server.Arma3Profile` — **written to `servers/{id}/server/server.Arma3Profile`** (Arma 3 reads from the `-name` subdirectory)
|
5. Write `server.Arma3Profile` — written to `servers/{id}/server/server.Arma3Profile`
|
||||||
6. Write `BESERVER_CFG_TEMPLATE` — **required for BattlEye RCon to work**
|
6. Write `beserver.cfg` — creates `battleye/` directory, writes RCon config
|
||||||
```
|
7. `build_launch_args()` — assembles full CLI arg list including `-bepath=./battleye`
|
||||||
# servers/{id}/battleye/beserver.cfg
|
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)
|
||||||
RConPassword {rcon_password}
|
9. Set file permissions 0600 on config files containing passwords
|
||||||
RConPort {rcon_port}
|
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.
|
||||||
```
|
|
||||||
`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)
|
|
||||||
|
|
||||||
**Test:** `ConfigGenerator.write_all(server_id)` → inspect all generated files for correctness.
|
**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.
|
||||||
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.
|
|
||||||
|
|
||||||
### Step 2.2 — Process Manager
|
### Step 2.2 — Process Manager (core)
|
||||||
|
|
||||||
1. `backend/servers/process_manager.py` — `ProcessManager` singleton
|
1. `backend/core/servers/process_manager.py` — `ProcessManager` singleton (game-agnostic)
|
||||||
2. `start(server_id, exe_path, args, cwd=servers/{id}/)` — subprocess.Popen with cwd set to server instance dir
|
2. `start(server_id, exe_path, args, cwd=servers/{id}/)`
|
||||||
3. `stop(server_id, timeout=30)` — on Windows: `terminate()` = hard kill (no SIGTERM). Graceful shutdown is via RCon `#shutdown` in ServerService.
|
3. `stop(server_id, timeout=30)` — on Windows: `terminate()` = hard kill
|
||||||
4. `kill()`, `is_running()`, `get_pid()`
|
4. `kill()`, `is_running()`, `get_pid()`
|
||||||
5. `recover_on_startup()` — verify PID is alive AND process name matches arma3server (prevents PID reuse)
|
5. `recover_on_startup()` — verify PID is alive AND process name matches adapter allowlist (prevents PID reuse)
|
||||||
6. Wire `ServerService.start()` and `ServerService.stop()`
|
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
|
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`
|
1. `GET /servers/{id}/config` — reads all sections from `game_configs`
|
||||||
2. `PUT /servers/{id}/config/server`
|
2. `GET /servers/{id}/config/{section}` — reads single section, response includes `_meta` with `config_version` and `schema_version`
|
||||||
3. `PUT /servers/{id}/config/basic`
|
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`
|
||||||
4. `PUT /servers/{id}/config/profile`
|
- **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
|
||||||
5. `PUT /servers/{id}/config/launch`
|
- On successful write, increment `config_version` in the row
|
||||||
6. `GET /servers/{id}/config/preview`
|
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.
|
**Goal:** Live monitoring — process crash detection, log tailing, metrics.
|
||||||
|
|
||||||
### Step 3.1 — Thread infrastructure
|
### Step 3.1 — Thread infrastructure
|
||||||
|
|
||||||
1. `backend/threads/base_thread.py` — `BaseServerThread`
|
1. `backend/core/threads/base_thread.py` — `BaseServerThread`
|
||||||
2. `backend/threads/thread_registry.py` — `ThreadRegistry` singleton
|
2. `backend/core/threads/thread_registry.py` — `ThreadRegistry` (adapter-aware)
|
||||||
3. Wire `start_server_threads()` / `stop_server_threads()` into `ServerService.start()` / `ServerService.stop()`
|
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
|
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:** Start server → kill process manually → confirm DB status changes to 'crashed'.
|
||||||
**Test:** Enable auto_restart → kill → confirm server restarts automatically.
|
**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`
|
1. `backend/adapters/arma3/log_parser.py` — `RPTParser` implementing `LogParser` protocol
|
||||||
2. `backend/dal/log_repository.py`
|
2. `backend/core/threads/log_tail.py` — `LogTailThread` (generic, takes adapter's `LogParser`)
|
||||||
3. `backend/threads/log_tail.py`
|
3. `backend/core/dal/log_repository.py`
|
||||||
4. `backend/logs/service.py`
|
4. `backend/core/logs/service.py`
|
||||||
5. `backend/logs/router.py` — `GET /servers/{id}/logs`
|
5. `backend/core/logs/router.py` — `GET /servers/{id}/logs`
|
||||||
|
|
||||||
**Test:** Start server → `GET /api/servers/{id}/logs` returns recent RPT lines.
|
**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`
|
1. `backend/core/metrics/service.py`
|
||||||
2. `backend/dal/metrics_repository.py`
|
2. `backend/core/dal/metrics_repository.py`
|
||||||
3. `backend/threads/metrics_collector.py`
|
3. `backend/core/threads/metrics_collector.py`
|
||||||
4. `backend/metrics/router.py` — `GET /servers/{id}/metrics`
|
4. `backend/core/metrics/router.py` — `GET /servers/{id}/metrics`
|
||||||
|
|
||||||
**Test:** Running server → query metrics endpoint → see CPU/RAM data points.
|
**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:
|
2. Implement BE RCon UDP protocol:
|
||||||
- Packet structure: `'BE'` + CRC32 (little-endian) + type byte + payload
|
- Packet structure: `'BE'` + CRC32 (little-endian) + type byte + payload
|
||||||
- Login: type `0x00`, payload = password
|
- Login: type `0x00`, payload = password
|
||||||
- Command: type `0x01`, payload = sequence byte + command string
|
- Command: type `0x01`, payload = sequence byte + command string
|
||||||
- Keepalive: type `0x02`, payload = empty
|
- 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
|
4. `parse_players_response()` — parse `players` command output
|
||||||
5. Handle unsolicited server messages (type 0x02) — enqueue for event logging
|
5. Handle unsolicited server messages (type 0x02)
|
||||||
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
**Test:** Connect BERConClient to a running server with BattlEye → successfully login → send `players` → receive response.
|
**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`
|
1. `backend/adapters/arma3/rcon_service.py` — `Arma3RConService` implementing `RemoteAdmin` protocol
|
||||||
2. `backend/threads/rcon_poller.py`
|
2. `backend/core/threads/remote_admin_poller.py` — `RemoteAdminPollerThread` (generic, takes adapter's `RemoteAdmin`)
|
||||||
3. `backend/dal/player_repository.py`
|
3. `backend/core/dal/player_repository.py`
|
||||||
4. `backend/players/service.py`
|
4. `backend/core/players/service.py`
|
||||||
5. `backend/players/router.py` — `GET /servers/{id}/players`
|
5. `backend/core/players/router.py` — `GET /servers/{id}/players`
|
||||||
|
|
||||||
**Test:** Players join server → `GET /players` returns them with pings.
|
**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`
|
1. `POST /servers/{id}/players/{slot_id}/kick` — delegates to adapter's `remote_admin.kick_player()`
|
||||||
2. `POST /servers/{id}/players/{num}/ban`
|
2. `POST /servers/{id}/players/{slot_id}/ban` — delegates to adapter's `remote_admin.ban_player()`
|
||||||
3. `POST /servers/{id}/rcon/command`
|
3. `POST /servers/{id}/remote-admin/command` — delegates to adapter's `remote_admin.send_command()`
|
||||||
4. `POST /servers/{id}/rcon/say`
|
4. `POST /servers/{id}/remote-admin/say` — delegates to adapter's `remote_admin.say_all()`
|
||||||
5. `backend/dal/ban_repository.py`
|
5. `backend/core/dal/ban_repository.py`
|
||||||
6. `GET/POST/DELETE /servers/{id}/bans`
|
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.
|
**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
|
## 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
|
### Step 5.1 — Broadcast infrastructure
|
||||||
|
|
||||||
1. `backend/websocket/broadcaster.py` — `BroadcastThread` + `enqueue()`
|
1. `backend/core/websocket/broadcaster.py` — `BroadcastThread` + `enqueue()`
|
||||||
2. `backend/websocket/manager.py` — `ConnectionManager`
|
2. `backend/core/websocket/manager.py` — `ConnectionManager`
|
||||||
3. Store event loop reference in `main.py:on_startup()`:
|
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)
|
|
||||||
```
|
|
||||||
4. Start `BroadcastThread` in `on_startup()`
|
4. Start `BroadcastThread` in `on_startup()`
|
||||||
5. Wire `BroadcastThread.enqueue()` calls into all background threads
|
5. Wire `BroadcastThread.enqueue()` calls into all background threads
|
||||||
|
|
||||||
### Step 5.2 — WebSocket endpoint
|
### Step 5.2 — WebSocket endpoint
|
||||||
|
|
||||||
1. `backend/websocket/router.py`
|
1. `backend/core/websocket/router.py`
|
||||||
2. JWT validation from query param
|
2. JWT validation from query param
|
||||||
3. Subscribe/unsubscribe message handling
|
3. Subscribe/unsubscribe message handling
|
||||||
4. Ping/pong keepalive
|
4. Ping/pong keepalive
|
||||||
@@ -285,30 +308,30 @@ Wire `BroadcastThread.enqueue()` into:
|
|||||||
- `ProcessMonitorThread` → status updates, crash events
|
- `ProcessMonitorThread` → status updates, crash events
|
||||||
- `LogTailThread` → log lines
|
- `LogTailThread` → log lines
|
||||||
- `MetricsCollectorThread` → metrics snapshots
|
- `MetricsCollectorThread` → metrics snapshots
|
||||||
- `RConPollerThread` → player list updates
|
- `RemoteAdminPollerThread` → player list updates
|
||||||
- `ServerService.start/stop` → status transitions
|
- `ServerService.start/stop` → status transitions
|
||||||
|
|
||||||
**Test:** React frontend connects to WS → server starts → see status, logs, metrics all update in real time.
|
**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
|
### Step 6.1 — Missions
|
||||||
|
|
||||||
1. `backend/missions/service.py`
|
1. `backend/adapters/arma3/mission_manager.py` — `Arma3MissionManager` implementing `MissionManager` protocol
|
||||||
2. `backend/missions/router.py`
|
2. `backend/core/missions/router.py` — generic endpoints (delegate to adapter if capability supported)
|
||||||
3. Upload PBO validation (check `.pbo` extension, parse name)
|
3. Upload file validation (extension from adapter's `MissionManager.file_extension`)
|
||||||
4. Mission rotation CRUD
|
4. Mission rotation CRUD
|
||||||
|
|
||||||
**Test:** Upload a `.pbo` → appears in `GET /missions` → set as rotation → start server → mission available.
|
**Test:** Upload a `.pbo` → appears in `GET /missions` → set as rotation → start server → mission available.
|
||||||
|
|
||||||
### Step 6.2 — Mods
|
### Step 6.2 — Mods
|
||||||
|
|
||||||
1. `backend/mods/service.py`
|
1. `backend/adapters/arma3/mod_manager.py` — `Arma3ModManager` implementing `ModManager` protocol
|
||||||
2. `backend/mods/router.py`
|
2. `backend/core/mods/router.py` — generic endpoints (delegate to adapter if capability supported)
|
||||||
3. `build_mod_string()` — assemble `-mod=` and `-serverMod=` args
|
3. `build_mod_args()` — assemble `-mod=` and `-serverMod=` args
|
||||||
4. Wire mod string into `ConfigGenerator.build_launch_args()`
|
4. Wire mod args into `Arma3ConfigGenerator.build_launch_args()`
|
||||||
|
|
||||||
**Test:** Register `@CBA_A3` → enable on server → start → server loads mod.
|
**Test:** Register `@CBA_A3` → enable on server → start → server loads mod.
|
||||||
|
|
||||||
@@ -318,15 +341,12 @@ Wire `BroadcastThread.enqueue()` into:
|
|||||||
|
|
||||||
### Step 7.1 — APScheduler jobs
|
### Step 7.1 — APScheduler jobs
|
||||||
|
|
||||||
Add to `on_startup()`:
|
|
||||||
```python
|
```python
|
||||||
# Use BackgroundScheduler (not AsyncIOScheduler) because cleanup methods
|
|
||||||
# perform sync SQLite operations. AsyncIOScheduler would block the event loop.
|
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
scheduler = BackgroundScheduler()
|
scheduler = BackgroundScheduler()
|
||||||
scheduler.add_job(log_service.cleanup_old_logs, 'cron', hour=3)
|
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(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()
|
scheduler.start()
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -335,60 +355,98 @@ scheduler.start()
|
|||||||
In `on_startup()` → `ProcessManager.recover_on_startup()`:
|
In `on_startup()` → `ProcessManager.recover_on_startup()`:
|
||||||
- Query DB for servers with `status='running'`
|
- Query DB for servers with `status='running'`
|
||||||
- Check if PID still alive (`psutil.pid_exists(pid)`)
|
- 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 alive: re-attach threads (skip process start, just start monitoring threads)
|
||||||
- If dead: mark as `crashed`, clear players
|
- If dead: mark as `crashed`, clear players
|
||||||
|
|
||||||
### Step 7.3 — Events log
|
### 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
|
2. Insert events for: start, stop, crash, kick, ban, config change, mission change
|
||||||
3. `GET /servers/{id}/events` endpoint
|
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`
|
1. Encrypt sensitive DB fields in `game_configs` JSON (passwords, rcon_password)
|
||||||
- `backend/utils/crypto.py` with Fernet
|
- `backend/core/utils/crypto.py` with Fernet
|
||||||
- **Key format:** `LANGUARD_ENCRYPTION_KEY` must be a Fernet base64 key, NOT hex.
|
- `LANGUARD_ENCRYPTION_KEY` must be a Fernet base64 key
|
||||||
Generate with: `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`
|
- **Adapter declares sensitive fields**: `adapter.get_sensitive_fields(section) -> list[str]`
|
||||||
Passing a hex string to `Fernet()` raises `ValueError` at startup.
|
- ConfigRepository handles Fernet encrypt/decrypt transparently: encrypts declared fields on write, decrypts on read
|
||||||
- Encrypt on write, decrypt on read in repositories
|
2. Content-Security-Policy headers for frontend
|
||||||
- **NOTE:** Core security (rate limiting, input sanitization, config escaping, exe path validation) is already in Phases 1-2.
|
3. Penetration testing and security audit
|
||||||
2. Additional penetration testing and security audit
|
|
||||||
3. Content-Security-Policy headers for frontend
|
|
||||||
|
|
||||||
### Step 7.5 — Frontend integration checklist
|
### Step 7.5 — Frontend integration checklist
|
||||||
|
|
||||||
Verify React app can:
|
Verify React app can:
|
||||||
- [ ] Login and store JWT
|
- [ ] Login and store JWT
|
||||||
- [ ] List servers with live status
|
- [ ] See list of supported game types
|
||||||
- [ ] Start/stop server and see status update via WebSocket (no page refresh)
|
- [ ] Create server with game type selection
|
||||||
- [ ] View streaming log output
|
- [ ] List servers with live status (any game type)
|
||||||
- [ ] See player list update every 10s
|
- [ ] Start/stop server and see status update via WebSocket
|
||||||
- [ ] See CPU/RAM charts update every 5s
|
- [ ] View streaming log output (parsed by adapter)
|
||||||
- [ ] Edit all config sections and see preview
|
- [ ] See player list update (via adapter's remote admin)
|
||||||
- [ ] Upload a mission PBO
|
- [ ] See CPU/RAM charts update
|
||||||
- [ ] Kick a player
|
- [ ] Edit config sections (dynamic form from adapter's JSON Schema)
|
||||||
- [ ] Send a message to all players
|
- [ ] 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
|
## Testing Strategy
|
||||||
|
|
||||||
### Unit tests (pytest)
|
### Unit tests (pytest)
|
||||||
- `ConfigGenerator.write_server_cfg()` — compare output against expected string; test config injection prevention
|
- `GameAdapterRegistry` — register, get, list, missing adapter
|
||||||
- `ConfigGenerator._escape_config_string()` — test double-quote and newline escaping
|
- `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
|
- `RPTParser.parse_line()` — test all log formats
|
||||||
- `BERConClient.parse_players_response()` — test with sample output
|
- `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:`)
|
- 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
|
- `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
|
### 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)
|
- WebSocket message delivery (can be automated with httpx test client)
|
||||||
- RCon command round-trip (manual — requires running server with BattlEye)
|
- 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
|
### Load notes
|
||||||
- SQLite with WAL handles concurrent reads from 4 threads per server well
|
- SQLite with WAL handles concurrent reads from 4 threads per server well
|
||||||
@@ -412,7 +470,7 @@ pip install -r requirements.txt
|
|||||||
|
|
||||||
# 3. Environment
|
# 3. Environment
|
||||||
cp .env.example .env
|
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
|
# 4. Run backend
|
||||||
uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||||
@@ -423,23 +481,22 @@ npm install
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Backend auto-creates `languard.db` and seeds an admin user on first run:
|
Backend auto-creates `languard.db`, seeds an admin user on first run, and registers the Arma 3 adapter automatically.
|
||||||
- Username: `admin`
|
|
||||||
- Password: **randomly generated** and printed to stdout once (e.g., `Initial admin password: a7b9c2d4e5f6...`)
|
|
||||||
- Change immediately via `PUT /api/auth/password`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase Summary
|
## Phase Summary
|
||||||
|
|
||||||
| Phase | Deliverable | Est. Complexity |
|
| Phase | Deliverable | Key Change from Single-Game |
|
||||||
|-------|-------------|----------------|
|
|-------|-------------|------------------------------|
|
||||||
| 1 | Foundation (auth + server CRUD) | Low |
|
| 0 | Adapter framework (protocols + exceptions + registry) | **NEW** — foundation for modularity |
|
||||||
| 2 | Process management + config gen | Medium |
|
| 1 | Foundation (auth + server CRUD + game discovery + migration) | Core tables, `game_type` field, `game_configs` JSON, migration from old schema |
|
||||||
| 3 | Background threads (monitor, logs, metrics) | Medium-High |
|
| 2 | Arma 3 adapter: config gen + process mgmt | Config generation in adapter, atomic writes, typed exceptions, optimistic locking |
|
||||||
| 4 | BattlEye RCon (player list, admin cmds) | High |
|
| 3 | Background threads (core + adapter injection) | Generic threads + adapter parsers/clients, per-server lock for RemoteAdmin |
|
||||||
| 5 | WebSocket real-time | Medium |
|
| 4 | Remote admin (Arma 3: BattlEye RCon) | RCon in adapter, generic poller in core |
|
||||||
| 6 | Mission + mod management | Low-Medium |
|
| 5 | WebSocket real-time | No change — fully game-agnostic |
|
||||||
| 7 | Polish, security, recovery | Medium |
|
| 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
1281
MODULES.md
File diff suppressed because it is too large
Load Diff
411
THREADING.md
411
THREADING.md
@@ -8,6 +8,8 @@ The system uses a hybrid concurrency model:
|
|||||||
- **Queue** bridges the thread world → asyncio world for WebSocket broadcasting
|
- **Queue** bridges the thread world → asyncio world for WebSocket broadcasting
|
||||||
- **SQLAlchemy sync sessions** are used in threads (thread-local connections)
|
- **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
|
## Thread Map
|
||||||
@@ -16,7 +18,7 @@ The system uses a hybrid concurrency model:
|
|||||||
Main Process (FastAPI / asyncio event loop)
|
Main Process (FastAPI / asyncio event loop)
|
||||||
│
|
│
|
||||||
├── [uvicorn] HTTP/WS event loop (asyncio)
|
├── [uvicorn] HTTP/WS event loop (asyncio)
|
||||||
│ ├── REST request handlers (async def)
|
│ ├── REST request handlers (async def / plain def)
|
||||||
│ └── WebSocket handlers (async def)
|
│ └── WebSocket handlers (async def)
|
||||||
│
|
│
|
||||||
├── BroadcastThread (daemon thread, 1 global)
|
├── BroadcastThread (daemon thread, 1 global)
|
||||||
@@ -25,14 +27,66 @@ Main Process (FastAPI / asyncio event loop)
|
|||||||
│ → ConnectionManager.broadcast()
|
│ → ConnectionManager.broadcast()
|
||||||
│
|
│
|
||||||
└── Per-running-server thread group (started when server starts, stopped when server stops):
|
└── Per-running-server thread group (started when server starts, stopped when server stops):
|
||||||
├── ProcessMonitorThread (1 per server, 1s interval)
|
├── ProcessMonitorThread (1 per server, 1s interval) — CORE
|
||||||
├── LogTailThread (1 per server, 100ms interval)
|
├── LogTailThread (1 per server, 100ms interval) — CORE + adapter LogParser
|
||||||
├── MetricsCollectorThread (1 per server, 5s interval)
|
├── MetricsCollectorThread (1 per server, 5s interval) — CORE
|
||||||
└── RConPollerThread (1 per server, 10s interval, 30s startup delay)
|
└── RemoteAdminPollerThread (1 per server, 10s interval) — CORE + adapter RemoteAdmin
|
||||||
```
|
```
|
||||||
|
|
||||||
For **N running servers**, there are:
|
For **N running servers**, there are:
|
||||||
- `4*N` background threads + 1 BroadcastThread = `4N+1` background threads total
|
- `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` |
|
| `ConnectionManager._connections` | async, single event loop | `asyncio.Lock` |
|
||||||
| SQLite connections | one connection per thread | Thread-local via `threading.local()` |
|
| 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) |
|
| 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
|
### SQLite Thread Safety
|
||||||
```python
|
```python
|
||||||
@@ -55,7 +140,6 @@ For **N running servers**, there are:
|
|||||||
|
|
||||||
class BaseServerThread(threading.Thread):
|
class BaseServerThread(threading.Thread):
|
||||||
def run(self):
|
def run(self):
|
||||||
# Create thread-local DB connection — single connection per thread
|
|
||||||
engine = get_engine()
|
engine = get_engine()
|
||||||
self._db = engine.connect()
|
self._db = engine.connect()
|
||||||
try:
|
try:
|
||||||
@@ -69,46 +153,49 @@ class BaseServerThread(threading.Thread):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self.name} setup error: {e}")
|
logger.error(f"{self.name} setup error: {e}")
|
||||||
finally:
|
finally:
|
||||||
self.teardown() # always release resources (even on setup failure)
|
self.teardown()
|
||||||
self._db.close() # always close connection
|
self._db.close()
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## BroadcastThread — Asyncio Bridge
|
## 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
|
Background Thread Asyncio Event Loop
|
||||||
───────────────── ──────────────────
|
───────────────── ──────────────────
|
||||||
BroadcastThread.enqueue( uvicorn runs here
|
Any background thread uvicorn runs here
|
||||||
server_id=1,
|
│
|
||||||
|
▼
|
||||||
|
BroadcastThread.enqueue( loop = asyncio.get_running_loop()
|
||||||
|
server_id=1, (stored at app startup)
|
||||||
msg_type='log',
|
msg_type='log',
|
||||||
data={...}
|
data={...}
|
||||||
)
|
)
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
broadcast_queue.put({ loop = asyncio.get_event_loop()
|
broadcast_queue.put({ asyncio.run_coroutine_threadsafe(
|
||||||
'server_id': 1, (stored at app startup)
|
'server_id': 1, connection_manager.broadcast(
|
||||||
'type': 'log',
|
'type': 'log', server_id=1,
|
||||||
'data': {...}
|
'data': {...} message={type, data}
|
||||||
})
|
) ),
|
||||||
│
|
│ loop=self._loop
|
||||||
▼
|
▼ )
|
||||||
BroadcastThread.run() ──────────────────► asyncio.run_coroutine_threadsafe(
|
BroadcastThread.run() ──────────────────►
|
||||||
while True: connection_manager.broadcast(
|
while True:
|
||||||
msg = queue.get() server_id=1,
|
msg = queue.get()
|
||||||
fut = run_coroutine_threadsafe( message={type, data}
|
fut = run_coroutine_threadsafe(
|
||||||
broadcast_coro, ),
|
broadcast_coro,
|
||||||
self._loop loop=self._loop
|
self._loop
|
||||||
) )
|
)
|
||||||
fut.result(timeout=5)
|
fut.result(timeout=5)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Implementation Sketch
|
### Implementation Sketch
|
||||||
```python
|
```python
|
||||||
# broadcaster.py
|
# core/websocket/broadcaster.py
|
||||||
import asyncio
|
import asyncio
|
||||||
import queue
|
import queue
|
||||||
import threading
|
import threading
|
||||||
@@ -130,9 +217,6 @@ class BroadcastThread(threading.Thread):
|
|||||||
try:
|
try:
|
||||||
msg = _broadcast_queue.get(timeout=1.0)
|
msg = _broadcast_queue.get(timeout=1.0)
|
||||||
server_id = msg['server_id']
|
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 = {
|
outgoing = {
|
||||||
'type': msg['type'],
|
'type': msg['type'],
|
||||||
'server_id': server_id,
|
'server_id': server_id,
|
||||||
@@ -145,7 +229,6 @@ class BroadcastThread(threading.Thread):
|
|||||||
try:
|
try:
|
||||||
future.result(timeout=5.0)
|
future.result(timeout=5.0)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
# Don't block the queue — log and continue
|
|
||||||
logger.warning(f"Broadcast timeout for server {server_id} msg type {msg['type']}")
|
logger.warning(f"Broadcast timeout for server {server_id} msg type {msg['type']}")
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
continue
|
continue
|
||||||
@@ -172,6 +255,8 @@ class BroadcastThread(threading.Thread):
|
|||||||
|
|
||||||
## ProcessMonitorThread — Crash Detection & Auto-Restart
|
## ProcessMonitorThread — Crash Detection & Auto-Restart
|
||||||
|
|
||||||
|
**Game-agnostic.** This thread only checks OS-level process status and updates the core `servers` table.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
class ProcessMonitorThread(BaseServerThread):
|
class ProcessMonitorThread(BaseServerThread):
|
||||||
interval = 1.0
|
interval = 1.0
|
||||||
@@ -184,7 +269,6 @@ class ProcessMonitorThread(BaseServerThread):
|
|||||||
|
|
||||||
exit_code = proc.poll()
|
exit_code = proc.poll()
|
||||||
if exit_code is not None:
|
if exit_code is not None:
|
||||||
# Process has exited
|
|
||||||
self._handle_process_exit(exit_code)
|
self._handle_process_exit(exit_code)
|
||||||
self.stop()
|
self.stop()
|
||||||
|
|
||||||
@@ -210,18 +294,13 @@ class ProcessMonitorThread(BaseServerThread):
|
|||||||
'detail': {'exit_code': exit_code}
|
'detail': {'exit_code': exit_code}
|
||||||
})
|
})
|
||||||
|
|
||||||
# Stop other threads for this server. Must NOT be called synchronously
|
# Stop other threads for this server via daemon cleanup thread
|
||||||
# from within this thread's own run() if stop_server_threads() joins threads,
|
# (avoids thread joining itself)
|
||||||
# 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.
|
|
||||||
import threading as _threading
|
import threading as _threading
|
||||||
|
|
||||||
def _cleanup_and_maybe_restart():
|
def _cleanup_and_maybe_restart():
|
||||||
try:
|
try:
|
||||||
ThreadRegistry.get().stop_server_threads(self.server_id)
|
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'):
|
if is_crash and server.get('auto_restart'):
|
||||||
self._schedule_auto_restart(server)
|
self._schedule_auto_restart(server)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -238,10 +317,8 @@ class ProcessMonitorThread(BaseServerThread):
|
|||||||
).start()
|
).start()
|
||||||
|
|
||||||
def _schedule_auto_restart(self, server: dict):
|
def _schedule_auto_restart(self, server: dict):
|
||||||
# IMPORTANT: This method runs in the daemon cleanup thread, NOT the
|
# IMPORTANT: Runs in daemon cleanup thread, NOT ProcessMonitorThread.
|
||||||
# ProcessMonitorThread. Must create its own DB connection — do NOT
|
# Must create its own DB connection.
|
||||||
# use self._db (it belongs to the ProcessMonitorThread's thread context
|
|
||||||
# and may be closed by teardown() already).
|
|
||||||
from database import get_thread_db
|
from database import get_thread_db
|
||||||
db = get_thread_db()
|
db = get_thread_db()
|
||||||
|
|
||||||
@@ -250,7 +327,6 @@ class ProcessMonitorThread(BaseServerThread):
|
|||||||
window = server['restart_window_seconds']
|
window = server['restart_window_seconds']
|
||||||
last_restart = server.get('last_restart_at')
|
last_restart = server.get('last_restart_at')
|
||||||
|
|
||||||
# Reset restart_count if last restart was outside the window
|
|
||||||
if last_restart:
|
if last_restart:
|
||||||
last_dt = datetime.fromisoformat(last_restart)
|
last_dt = datetime.fromisoformat(last_restart)
|
||||||
elapsed = (datetime.utcnow() - last_dt).total_seconds()
|
elapsed = (datetime.utcnow() - last_dt).total_seconds()
|
||||||
@@ -270,7 +346,7 @@ class ProcessMonitorThread(BaseServerThread):
|
|||||||
})
|
})
|
||||||
|
|
||||||
def _auto_restart(self):
|
def _auto_restart(self):
|
||||||
from servers.service import ServerService
|
from core.servers.service import ServerService
|
||||||
try:
|
try:
|
||||||
ServerService().start(self.server_id)
|
ServerService().start(self.server_id)
|
||||||
except Exception as e:
|
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
|
```python
|
||||||
class LogTailThread(BaseServerThread):
|
class LogTailThread(BaseServerThread):
|
||||||
interval = 0.1 # 100ms
|
interval = 0.1 # 100ms
|
||||||
|
|
||||||
def setup(self):
|
def __init__(self, server_id: int, log_parser: "LogParser",
|
||||||
self._file = None
|
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._current_path: Path | None = None
|
||||||
self._last_size: int = 0
|
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:
|
Uses the adapter-provided log_file_resolver to find the current log file.
|
||||||
servers/{id}/server/arma3server_YYYY-MM-DD_HH-MM-SS.rpt
|
Opens it and seeks to end (tail behavior).
|
||||||
|
|
||||||
Use rglob('*.rpt') to search recursively within the server dir.
|
|
||||||
The profile subdirectory is determined by -profiles + -name flags.
|
|
||||||
|
|
||||||
NOTE: Do NOT use os.stat().st_ino for rotation detection — on Windows/NTFS
|
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.
|
st_ino is always 0. Instead, track filename and file size.
|
||||||
Instead, track the filename and file size. If a newer .rpt appears or the
|
|
||||||
current file shrinks (truncated/replaced), reopen.
|
|
||||||
"""
|
"""
|
||||||
rpt_files = list(Path(get_server_dir(self.server_id)).rglob("*.rpt"))
|
server_dir = get_server_dir(self.server_id)
|
||||||
if not rpt_files:
|
log_path = self._log_file_resolver(server_dir)
|
||||||
return # Server hasn't created RPT yet; retry in next tick
|
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:
|
try:
|
||||||
self._file = open(latest, 'r', encoding='utf-8', errors='replace')
|
self._file = open(log_path, 'r', encoding='utf-8', errors='replace')
|
||||||
self._file.seek(0, 2) # seek to end — tail, don't replay old output
|
self._file.seek(0, 2) # seek to end
|
||||||
self._current_path = latest
|
self._current_path = log_path
|
||||||
self._last_size = self._file.tell()
|
self._last_size = self._file.tell()
|
||||||
except OSError:
|
except OSError:
|
||||||
self._file = None
|
self._file = None
|
||||||
|
|
||||||
def tick(self):
|
def tick(self):
|
||||||
if self._file is None:
|
if self._file is None:
|
||||||
self._open_latest_rpt()
|
self._open_latest_log()
|
||||||
return
|
return
|
||||||
|
|
||||||
# Rotation detection: only re-glob every 5 seconds (not every 100ms tick)
|
# Rotation detection: only re-check every 5 seconds
|
||||||
# to avoid excessive filesystem I/O with large mpmissions directories.
|
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
if now - getattr(self, '_last_glob_time', 0) > 5.0:
|
if now - getattr(self, '_last_glob_time', 0) > 5.0:
|
||||||
self._last_glob_time = now
|
self._last_glob_time = now
|
||||||
rpt_files = list(Path(get_server_dir(self.server_id)).rglob("*.rpt"))
|
server_dir = get_server_dir(self.server_id)
|
||||||
if rpt_files:
|
log_path = self._log_file_resolver(server_dir)
|
||||||
latest = max(rpt_files, key=lambda p: p.stat().st_mtime)
|
if log_path is not None and log_path != self._current_path:
|
||||||
if latest != self._current_path:
|
|
||||||
# A new RPT file was created — switch to it
|
|
||||||
self._file.close()
|
self._file.close()
|
||||||
self._open_latest_rpt()
|
self._open_latest_log()
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -344,12 +418,11 @@ class LogTailThread(BaseServerThread):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if current_size < self._last_size:
|
if current_size < self._last_size:
|
||||||
# File shrank — truncated or replaced; reopen
|
|
||||||
self._file.close()
|
self._file.close()
|
||||||
self._open_latest_rpt()
|
self._open_latest_log()
|
||||||
return
|
return
|
||||||
|
|
||||||
# Read new lines
|
# Read new lines and parse using adapter's parser
|
||||||
while True:
|
while True:
|
||||||
line = self._file.readline()
|
line = self._file.readline()
|
||||||
if not line:
|
if not line:
|
||||||
@@ -359,13 +432,13 @@ class LogTailThread(BaseServerThread):
|
|||||||
if not line:
|
if not line:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
entry = RPTParser.parse_line(line)
|
# Adapter parses the line — game-specific format
|
||||||
|
entry = self._parser.parse_line(line)
|
||||||
if entry:
|
if entry:
|
||||||
LogRepository(self._db).insert(self.server_id, entry)
|
LogRepository(self._db).insert(self.server_id, entry)
|
||||||
BroadcastThread.enqueue(self.server_id, 'log', entry)
|
BroadcastThread.enqueue(self.server_id, 'log', entry)
|
||||||
|
|
||||||
def teardown(self):
|
def teardown(self):
|
||||||
"""Close the open RPT file handle when the thread stops."""
|
|
||||||
if self._file is not None:
|
if self._file is not None:
|
||||||
try:
|
try:
|
||||||
self._file.close()
|
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
|
```python
|
||||||
class RConPollerThread(BaseServerThread):
|
class MetricsCollectorThread(BaseServerThread):
|
||||||
interval = 10.0
|
interval = 5.0
|
||||||
STARTUP_DELAY = 30.0 # wait for server to fully initialize
|
|
||||||
_rcon_ready = False # flag: True only after successful setup
|
def tick(self):
|
||||||
|
pid = ProcessManager.get().get_pid(self.server_id)
|
||||||
def setup(self):
|
if pid is None:
|
||||||
# Wait for server to start up before attempting RCon
|
return
|
||||||
if self._stop_event.wait(self.STARTUP_DELAY):
|
|
||||||
self._rcon_ready = False
|
try:
|
||||||
return # stop was requested during wait
|
proc = psutil.Process(pid)
|
||||||
self._rcon = RConService(self.server_id)
|
cpu = proc.cpu_percent(interval=0.5)
|
||||||
self._connected = self._rcon.connect()
|
ram = proc.memory_info().rss / (1024 * 1024) # MB
|
||||||
self._rcon_ready = True
|
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):
|
def tick(self):
|
||||||
if not self._rcon_ready:
|
|
||||||
return # setup() failed or was interrupted
|
|
||||||
if not self._connected:
|
if not self._connected:
|
||||||
self._reconnect_attempts = getattr(self, '_reconnect_attempts', 0) + 1
|
self._reconnect_attempts = getattr(self, '_reconnect_attempts', 0) + 1
|
||||||
delay = min(10 * 2 ** self._reconnect_attempts, 120) # exponential backoff
|
delay = min(10 * 2 ** self._reconnect_attempts, 120) # exponential backoff
|
||||||
if self._reconnect_attempts > 1:
|
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):
|
if self._stop_event.wait(delay):
|
||||||
return
|
return
|
||||||
self._connected = self._rcon.connect()
|
self._connect()
|
||||||
if not self._connected:
|
if not self._connected:
|
||||||
return
|
return
|
||||||
self._reconnect_attempts = 0 # reset on successful connection
|
self._reconnect_attempts = 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
players = self._rcon.get_players()
|
players = self._call(self._client.get_players)
|
||||||
PlayerService(self._db).update_from_rcon(self.server_id, players)
|
PlayerService(self._db).update_from_remote_admin(self.server_id, players)
|
||||||
BroadcastThread.enqueue(self.server_id, 'players', {
|
BroadcastThread.enqueue(self.server_id, 'players', {
|
||||||
'players': [p.dict() for p in players],
|
'players': [p for p in players],
|
||||||
'count': len(players)
|
'count': len(players),
|
||||||
})
|
})
|
||||||
except ConnectionError:
|
except ConnectionError:
|
||||||
self._connected = False
|
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
|
POST /servers/{id}/start
|
||||||
│
|
│
|
||||||
├── ServerService.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
|
│ ├── ProcessManager.start() ← creates subprocess.Popen
|
||||||
│ └── ThreadRegistry.start_server_threads(id)
|
│ └── ThreadRegistry.start_server_threads(id, db)
|
||||||
│ ├── ProcessMonitorThread(id).start()
|
│ ├── ProcessMonitorThread(id) ← core, always
|
||||||
│ ├── LogTailThread(id).start()
|
│ ├── LogTailThread(id, adapter.log_parser) ← core + adapter
|
||||||
│ ├── MetricsCollectorThread(id).start()
|
│ ├── MetricsCollectorThread(id) ← core, always
|
||||||
│ └── RConPollerThread(id).start()
|
│ └── RemoteAdminPollerThread(id, adapter.remote_admin)
|
||||||
|
│ ← core + adapter (if available)
|
||||||
│
|
│
|
||||||
└── BroadcastThread.enqueue(id, 'status', {status: 'starting'})
|
└── 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
|
### Stop Server Flow
|
||||||
```
|
```
|
||||||
POST /servers/{id}/stop
|
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))
|
├── Wait up to 30s for process exit (ProcessManager.stop(timeout=30))
|
||||||
├── If still running: ProcessManager.kill()
|
├── If still running: ProcessManager.kill()
|
||||||
├── ThreadRegistry.stop_server_threads(id)
|
├── ThreadRegistry.stop_server_threads(id)
|
||||||
│ ├── ProcessMonitorThread.stop() (sets _stop_event)
|
│ ├── ProcessMonitorThread.stop()
|
||||||
│ ├── LogTailThread.stop()
|
│ ├── LogTailThread.stop()
|
||||||
│ ├── MetricsCollectorThread.stop()
|
│ ├── MetricsCollectorThread.stop()
|
||||||
│ └── RConPollerThread.stop()
|
│ └── RemoteAdminPollerThread.stop() ← if present
|
||||||
│ └── Thread.join(timeout=5) for each
|
│ └── Thread.join(timeout=5) for each
|
||||||
│
|
│
|
||||||
└── BroadcastThread.enqueue(id, 'status', {status: 'stopped'})
|
└── BroadcastThread.enqueue(id, 'status', {status: 'stopped'})
|
||||||
@@ -496,7 +645,7 @@ class BaseServerThread(threading.Thread):
|
|||||||
self.setup()
|
self.setup()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self.name} setup error: {e}")
|
logger.error(f"{self.name} setup error: {e}")
|
||||||
return # setup failed completely — no partial resources to clean
|
return # setup failed completely
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while not self._stop_event.is_set():
|
while not self._stop_event.is_set():
|
||||||
@@ -504,24 +653,34 @@ class BaseServerThread(threading.Thread):
|
|||||||
self.tick()
|
self.tick()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.on_error(e)
|
self.on_error(e)
|
||||||
# Use wait() instead of sleep() — responds immediately to stop()
|
|
||||||
self._stop_event.wait(self.interval)
|
self._stop_event.wait(self.interval)
|
||||||
finally:
|
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)
|
## WebSocket Connection Manager (asyncio)
|
||||||
|
|
||||||
|
**Game-agnostic.** No changes from single-game design.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# websocket/manager.py
|
# core/websocket/manager.py
|
||||||
class ConnectionManager:
|
class ConnectionManager:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
# server_id → set[WebSocket]
|
|
||||||
# Use set (not list) so .add()/.discard() work correctly.
|
|
||||||
self._connections: dict[str, set[WebSocket]] = defaultdict(set)
|
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._channel_subs: dict[WebSocket, set[str]] = defaultdict(set)
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
@@ -529,8 +688,7 @@ class ConnectionManager:
|
|||||||
await ws.accept()
|
await ws.accept()
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
self._connections[server_id].add(ws)
|
self._connections[server_id].add(ws)
|
||||||
self._channel_subs[ws].add('status') # default channel
|
self._channel_subs[ws].add('status')
|
||||||
# Only add to 'all' bucket if server_id is explicitly 'all'
|
|
||||||
if server_id == 'all':
|
if server_id == 'all':
|
||||||
self._connections['all'].add(ws)
|
self._connections['all'].add(ws)
|
||||||
|
|
||||||
@@ -549,15 +707,12 @@ class ConnectionManager:
|
|||||||
self._channel_subs[ws].difference_update(channels)
|
self._channel_subs[ws].difference_update(channels)
|
||||||
|
|
||||||
async def broadcast(self, server_id: str, message: dict, channel: str = None):
|
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()
|
targets: set[WebSocket] = set()
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
# Collect clients for this server_id + 'all' subscribers
|
|
||||||
server_clients = self._connections.get(server_id, set())
|
server_clients = self._connections.get(server_id, set())
|
||||||
all_clients = self._connections.get('all', set())
|
all_clients = self._connections.get('all', set())
|
||||||
candidates = server_clients | all_clients
|
candidates = server_clients | all_clients
|
||||||
|
|
||||||
# Filter by channel subscription if specified
|
|
||||||
if channel:
|
if channel:
|
||||||
targets = {ws for ws in candidates
|
targets = {ws for ws in candidates
|
||||||
if channel in self._channel_subs.get(ws, set())}
|
if channel in self._channel_subs.get(ws, set())}
|
||||||
@@ -571,7 +726,6 @@ class ConnectionManager:
|
|||||||
except Exception:
|
except Exception:
|
||||||
dead.append(ws)
|
dead.append(ws)
|
||||||
|
|
||||||
# Clean up dead connections
|
|
||||||
if dead:
|
if dead:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
for ws in dead:
|
for ws in dead:
|
||||||
@@ -587,9 +741,9 @@ class ConnectionManager:
|
|||||||
| Thread | Memory Impact | CPU Impact |
|
| Thread | Memory Impact | CPU Impact |
|
||||||
|--------|--------------|-----------|
|
|--------|--------------|-----------|
|
||||||
| ProcessMonitorThread | Minimal (one `os.kill` check) | Negligible |
|
| 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 |
|
| 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 |
|
| BroadcastThread | Queue buffer (max 10000 entries) | Low |
|
||||||
|
|
||||||
### Recommendations
|
### Recommendations
|
||||||
@@ -597,4 +751,5 @@ class ConnectionManager:
|
|||||||
- `broadcast_queue.maxsize=10000` — backpressure; drop on Full (log warning)
|
- `broadcast_queue.maxsize=10000` — backpressure; drop on Full (log warning)
|
||||||
- `LogTailThread` buffers max ~100 lines per tick before writing to DB in batch
|
- `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
|
- `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
|
||||||
Reference in New Issue
Block a user