# Languard Servers Manager — System Architecture ## 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. --- ## Technology Stack | Layer | Technology | Rationale | |-------|-----------|-----------| | Backend framework | **FastAPI** (Python 3.11+) | Async-native, built-in WebSocket, OpenAPI docs auto-generated | | Database | **SQLite** via `SQLAlchemy` (sync) | Zero-config, file-based, sufficient for single-host server manager; all access is synchronous (WAL mode for concurrent reads) | | Process management | `subprocess` + `threading` | Wrap 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) | | 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) | | Auth | **JWT** (python-jose) + bcrypt | Secure the API; React stores token in localStorage | | Frontend | React + TypeScript (external repo) | Connects to this backend's API | --- ## High-Level Architecture ``` ┌─────────────────────────────────────────────────────────────┐ │ React Frontend │ │ Server List │ Server Detail │ Logs │ Players │ Config UI │ └────────────────────────┬────────────────────────────────────┘ │ HTTP REST + WebSocket ▼ ┌─────────────────────────────────────────────────────────────┐ │ FastAPI Application │ │ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ │ │ Auth Router │ │ Server Router│ │ Config Router │ │ │ └──────────────┘ └──────────────┘ └──────────────────┘ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ │ │Mission Router│ │ Mod Router │ │ WS Router │ │ │ └──────────────┘ └──────────────┘ └──────────────────┘ │ │ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ Service Layer │ │ │ │ ServerService │ ConfigService │ RConService │ │ │ │ LogService │ MetricsService│ MissionService │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ Thread Pool │ │ │ │ ProcessMonitorThread (per server) │ │ │ │ LogTailThread (per server) │ │ │ │ MetricsCollectorThread (per server) │ │ │ │ RConPollerThread (per server) │ │ │ │ BroadcastThread (global) │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ Data Access Layer (DAL) │ │ │ │ ServerRepository │ PlayerRepository │ │ │ │ LogRepository │ MetricsRepository │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ │ ┌───────────────────┐ ┌────────────────────────────────┐ │ │ │ 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/ │ │ │ └───────────────────┘ └────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ │ subprocess ▼ ┌─────────────────────────────────────────────────────────────┐ │ Arma 3 Server Processes (OS level) │ │ arma3server_x64.exe (port 2302) │ │ arma3server_x64.exe (port 2402) │ │ ... │ └─────────────────────────────────────────────────────────────┘ ``` --- ## Component Responsibilities ### FastAPI Routers - Validate input (Pydantic models) - Call service layer - Return JSON responses - Handle WebSocket connections ### Service Layer - Orchestrate operations (start server = generate config + launch process + start threads) - No direct DB access — delegates to repositories - No direct process access — delegates to ProcessManager ### ProcessManager - 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 - Provides: `start()`, `stop()`, `restart()`, `is_running()`, `send_command()` ### 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) ### 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) ### 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 --- ## 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 ``` ## 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}) → BroadcastThread sends to all WS subscribers for this server → React frontend appends to log viewer ``` ## Data Flow: Player List ``` RConPollerThread (every 10s) → RConClient.send("players") → Parse response: [{id, name, guid, ping, verified}] → PlayerRepository.upsert_all(server_id, players) → BroadcastQueue.put({type: "players", server_id, players}) → React frontend updates player list ``` --- ## Security Model - All API routes (except `POST /api/auth/login`) require a valid **JWT Bearer token** - JWT contains: `user_id`, `username`, `role` (`admin` | `viewer`) - `viewer` role: read-only (GET endpoints, WebSocket) - `admin` role: all operations - CORS configured to accept only the frontend origin - Passwords hashed with **bcrypt** (cost factor 12) - `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) - Input sanitization on all string fields before config generation — no shell injection or config directive injection --- ## Configuration (Environment Variables) ```env LANGUARD_SECRET_KEY= LANGUARD_ENCRYPTION_KEY= LANGUARD_DB_PATH=./languard.db LANGUARD_SERVERS_DIR=./servers LANGUARD_ARMA_EXE=C:/Arma3Server/arma3server_x64.exe LANGUARD_HOST=0.0.0.0 LANGUARD_PORT=8000 LANGUARD_CORS_ORIGINS=http://localhost:5173,http://localhost:3000 LANGUARD_LOG_RETENTION_DAYS=7 ``` --- ## Directory Layout ``` languard-servers-manager/ ├── backend/ │ ├── 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 ├── 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) ├── requirements.txt ├── .env.example ├── ARCHITECTURE.md ├── DATABASE.md ├── API.md ├── MODULES.md ├── THREADING.md └── IMPLEMENTATION_PLAN.md ``` --- ## 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=...` | | 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. |