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