# Languard Servers Manager — System Architecture ## Overview Languard is a **multi-game** web-based management panel for dedicated game servers. It uses a **game adapter architecture** where a game-agnostic core handles server lifecycle, monitoring, and real-time communication, while game-specific behavior (config formats, remote admin protocols, log parsing, mission/mod handling) is encapsulated in pluggable adapters. **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. --- ## 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 server executables, watch stdout/stderr, check exit codes; **cwd** set to server instance dir for relative paths; on Windows `terminate()` is a hard kill (no SIGTERM) | | Real-time comms | **WebSocket** (FastAPI) | Push log lines, player lists, server status to React | | Game adapters | **Protocol + Registry** | Each game implements capability protocols; core resolves the adapter at runtime from `server.game_type` | | Scheduling | `APScheduler` (BackgroundScheduler) | Auto-restart, log/metrics cleanup (sync DB ops → BackgroundScheduler, not AsyncIOScheduler) | | Auth | **JWT** (python-jose) + bcrypt | Secure the API; React stores token in localStorage | | Frontend | React + TypeScript + Vite + Tailwind | See FRONTEND.md for full design system, component architecture, and adapter-aware UI patterns | --- ## Architecture Overview ``` ┌─────────────────────────────────────────────────────────────┐ │ React Frontend (see FRONTEND.md) │ │ Dashboard │ Server List │ Server Detail │ Logs │ Config UI │ │ Game Type Selector │ Adapter-specific Panels │ └────────────────────────┬────────────────────────────────────┘ │ HTTP REST + WebSocket ▼ ┌─────────────────────────────────────────────────────────────┐ │ FastAPI Application (Core) │ │ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ │ │ Auth Router │ │ Server Router│ │ Config Router │ │ │ └──────────────┘ └──────────────┘ └──────────────────┘ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ │ │ Player Router│ │ Log Router │ │ WS Router │ │ │ └──────────────┘ └──────────────┘ └──────────────────┘ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ │ │ Metric Router│ │ Event Router │ │ Games Router │ │ │ └──────────────┘ └──────────────┘ └──────────────────┘ │ │ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ Core Service Layer │ │ │ │ ServerService │ ConfigService │ PlayerService │ │ │ │ LogService │ MetricsService│ EventService │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ Game Adapter Registry │ │ │ │ GameAdapterRegistry.get(game_type) → GameAdapter │ │ │ │ │ │ │ │ Delegates to: │ │ │ │ • ConfigGenerator → adapter.get_config_generator()│ │ │ │ • ProcessConfig → adapter.get_process_config() │ │ │ │ • LogParser → adapter.get_log_parser() │ │ │ │ • RemoteAdmin → adapter.get_remote_admin() │ │ │ │ • MissionManager → adapter.get_mission_manager() │ │ │ │ • ModManager → adapter.get_mod_manager() │ │ │ │ • BanManager → adapter.get_ban_manager() │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ Thread Pool │ │ │ │ ProcessMonitorThread (per server, core) │ │ │ │ LogTailThread (per server, core + adapter parser) │ │ │ │ MetricsCollectorThread (per server, core) │ │ │ │ RemoteAdminPollerThread (per server, core + adapter) │ │ │ │ BroadcastThread (global) │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ Data Access Layer (DAL) │ │ │ │ ServerRepository │ PlayerRepository │ │ │ │ LogRepository │ MetricsRepository │ │ │ │ ConfigRepository (game_configs table) │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ │ ┌───────────────────┐ ┌────────────────────────────────┐ │ │ │ SQLite (DB) │ │ Filesystem │ │ │ │ languard.db │ │ servers/{id}/ (layout by │ │ │ │ │ │ adapter.get_process_config() │ │ │ └───────────────────┘ └────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ │ ┌─────────────┴─────────────────┐ ▼ ▼ ┌──────────────────────┐ ┌──────────────────────────────────┐ │ Game Adapters │ │ Game Server Processes (OS level) │ │ │ │ │ │ ┌────────────────┐ │ │ (Any game executable) │ │ │ Arma 3 │ │ │ Started via adapter's │ │ │ adapter │ │ │ build_launch_args() │ │ │ │ │ │ │ │ │ • ConfigGen │ │ └──────────────────────────────────┘ │ │ • ProcessConfig│ │ │ │ • RPTParser │ │ │ │ • BERConClient │ │ │ │ • MissionMgr │ │ │ │ • ModMgr │ │ │ │ • BanMgr │ │ │ └────────────────┘ │ │ │ │ ┌────────────────┐ │ │ │ (Future Game) │ │ │ │ adapter │ │ │ └────────────────┘ │ └──────────────────────┘ ``` --- ## 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 (Core) - Validate input (Pydantic models) - Resolve adapter from `server.game_type` - Delegate game-specific work to adapter - Return JSON responses - Handle WebSocket connections - Return 404 with clear message if adapter lacks a capability ### 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 (Core) - Singleton that owns all subprocess handles - Thread-safe dict: `{server_id: subprocess.Popen}` - **Per-server operation lock** (`_operation_locks: dict[int, threading.Lock]`) — serializes start/stop/restart for the same server. Prevents race conditions when two admins hit "start" simultaneously or when start+stop overlap. Each server gets its own lock; different servers operate independently. - `start()` sets `cwd=servers/{server_id}/` so relative config paths resolve correctly - On Windows: `terminate()` = `TerminateProcess` (hard kill, no SIGTERM) — graceful shutdown must go through adapter's RemoteAdmin - Provides: `start()`, `stop()`, `restart()`, `is_running()`, `send_command()` - **Exe validation is delegated to adapter's ProcessConfig** — core no longer has a hardcoded allowlist ### Thread Pool (per running server) | 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 | ### ConfigRepository (Core) - Manages the generic `game_configs` table - Stores config as JSON blobs keyed by `(server_id, section)` - **Validation is delegated to adapter's Pydantic models** — core never inspects config content - **Sensitive field encryption**: calls `adapter.get_config_generator().get_sensitive_fields(section)` to identify which JSON keys to encrypt/decrypt via Fernet - **Optimistic locking**: each row includes `config_version` (integer). On PUT, client sends the version they read. If version mismatch, return 409 Conflict. - **Schema migration**: on read, if `schema_version` differs from `adapter.get_config_version()`, core calls `adapter.migrate_config(old_version, config_json)`. On success, updates the row with migrated JSON and new `schema_version`. On `ConfigMigrationError`, keeps original config and logs a warning. - Provides: `get_section()`, `get_all_sections()`, `upsert_section()`, `delete_sections()` ### 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 | | `ConfigMigrationError` | `migrate_config()` fails to transform old schema | Keep original config, log warning, server runs with old schema | --- ## Data Flow: Start Server ``` 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 ``` 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 ``` ## Data Flow: Player List ``` 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 ``` --- ## 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) - 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 | --- ## Configuration (Environment Variables) ```env LANGUARD_SECRET_KEY= LANGUARD_ENCRYPTION_KEY= LANGUARD_DB_PATH=./languard.db LANGUARD_SERVERS_DIR=./servers LANGUARD_HOST=0.0.0.0 LANGUARD_PORT=8000 LANGUARD_CORS_ORIGINS=http://localhost:5173,http://localhost:3000 LANGUARD_LOG_RETENTION_DAYS=7 LANGUARD_METRICS_RETENTION_DAYS=30 LANGUARD_PLAYER_HISTORY_RETENTION_DAYS=90 # 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 ``` --- ## Directory Layout ``` languard-servers-manager/ ├── backend/ │ ├── main.py # FastAPI app factory │ ├── config.py # Settings from env │ ├── database.py # SQLAlchemy engine + session │ ├── dependencies.py # Auth deps, server lookup │ │ │ ├── core/ # Game-agnostic core │ │ ├── auth/ │ │ │ ├── router.py │ │ │ ├── service.py │ │ │ ├── schemas.py │ │ │ └── utils.py │ │ ├── servers/ │ │ │ ├── router.py # REST endpoints for servers │ │ │ ├── service.py # ServerService (delegates to adapter) │ │ │ ├── process_manager.py # ProcessManager singleton │ │ │ └── schemas.py # Generic Pydantic schemas │ │ ├── players/ │ │ │ ├── router.py │ │ │ ├── service.py │ │ │ └── schemas.py │ │ ├── logs/ │ │ │ ├── router.py │ │ │ └── service.py │ │ ├── metrics/ │ │ │ ├── router.py │ │ │ └── service.py │ │ ├── bans/ │ │ │ ├── router.py │ │ │ └── service.py │ │ ├── events/ │ │ │ ├── router.py │ │ │ └── service.py │ │ ├── websocket/ │ │ │ ├── router.py │ │ │ ├── manager.py │ │ │ └── broadcaster.py │ │ ├── threads/ │ │ │ ├── base_thread.py │ │ │ ├── process_monitor.py │ │ │ ├── log_tail.py # Generic, takes adapter LogParser │ │ │ ├── metrics_collector.py │ │ │ ├── remote_admin_poller.py # Generic, takes adapter RemoteAdmin │ │ │ └── thread_registry.py │ │ ├── games/ │ │ │ └── router.py # /api/games — type discovery, schemas │ │ ├── system/ │ │ │ └── router.py │ │ ├── dal/ │ │ │ ├── base_repository.py │ │ │ ├── server_repository.py │ │ │ ├── config_repository.py # game_configs table │ │ │ ├── player_repository.py │ │ │ ├── log_repository.py │ │ │ ├── metrics_repository.py │ │ │ ├── mission_repository.py │ │ │ ├── mod_repository.py │ │ │ ├── ban_repository.py │ │ │ └── event_repository.py │ │ ├── migrations/ │ │ │ ├── runner.py │ │ │ └── 001_initial_schema.sql │ │ └── utils/ │ │ ├── crypto.py │ │ ├── file_utils.py │ │ └── port_checker.py │ │ │ └── adapters/ # Game-specific adapters │ ├── __init__.py │ ├── registry.py # GameAdapterRegistry │ ├── protocols.py # All capability Protocol definitions │ │ │ └── arma3/ # Arma 3 adapter (built-in) │ ├── __init__.py # Exports ARMA3_ADAPTER, registers on import │ ├── adapter.py # Arma3Adapter class │ ├── config_generator.py # Pydantic models + server.cfg, basic.cfg, Arma3Profile, beserver.cfg │ ├── rcon_client.py # BERConClient (BattlEye UDP protocol) │ ├── rcon_service.py # Wraps BERConClient for RemoteAdmin protocol │ ├── log_parser.py # RPTParser │ ├── mission_manager.py # PBO upload, mission rotation config │ ├── mod_manager.py # @mod_folder convention, -mod=/-serverMod= │ ├── process_config.py # Exe allowlist, port conventions, profile dir │ ├── ban_manager.py # battleye/ban.txt bidirectional sync │ ├── schemas.py # Arma 3 specific request/response models │ └── migrations/ │ └── 001_arma3_metadata.sql # Arma 3 specific tables (if any) │ ├── servers/ # Runtime data per server instance │ └── {server_id}/ # Layout determined by adapter.get_process_config() │ └── (Arma 3: server.cfg, basic.cfg, server/, battleye/, mpmissions/) │ ├── frontend/ # React app ├── requirements.txt ├── .env.example ├── ARCHITECTURE.md ├── DATABASE.md ├── API.md ├── MODULES.md ├── THREADING.md ├── FRONTEND.md └── IMPLEMENTATION_PLAN.md ``` --- ## Adding a New Game Adapter To add support for a new game, create an adapter package: ``` adapters// ├── __init__.py # Export adapter, register in registry ├── adapter.py # Implement GameAdapter protocol ├── config_generator.py # Pydantic models + write game config files ├── log_parser.py # Parse game log format ├── process_config.py # Exe allowlist, port conventions ├── ... # Optional: rcon_client, mission_manager, mod_manager, ban_manager └── migrations/ # Optional: game-specific DB extensions ``` 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 | |----------|--------|--------| | Game-specific logic | **Adapter pattern with Protocol + Registry** | Structural subtyping with mypy enforcement; optional capabilities return None; zero core changes per game | | Capability probe | **has_capability() on GameAdapter** | Instead of scattered None checks, GameAdapter.has_capability(name) returns bool. Core code uses this to check support before calling get methods. Cleaner than `if adapter.get_remote_admin() is not None:` everywhere. | | Protocol granularity | **7 protocols (ConfigGenerator merged from ConfigSchema+ConfigGenerator)** | ConfigSchema and ConfigGenerator always co-occur (no game has schema without generation). Merged into single ConfigGenerator with schema + generation methods. ProcessConfig kept separate — may evolve independently. | | Config storage | **Hybrid: core normalized + game_configs JSON** | Core tables stay clean; config is always whole-read/write; adapter Pydantic models validate; zero migration per new game | | Sync vs async DB | **Sync SQLAlchemy only** | All DB access is synchronous; background threads are non-async; no aiosqlite dependency | | WebSocket auth | JWT in query param on connect | Browser WS API doesn't support headers | | Process ownership | **ProcessManager singleton** | Single source of truth; prevents duplicate launches | | Server operation safety | **Per-server operation lock** | ProcessManager holds a lock per server_id that serializes start/stop/restart. Two concurrent start requests for the same server: the second waits for the first to complete, then sees the server is already running. Different servers are independent (no cross-server locking). | | Config files | **Adapter regenerates on each start** | Always fresh from DB; no sync drift; adapter's structured builder prevents config injection | | Config write failure | **Atomic write + rollback** | Adapter writes to temp files first, then atomic rename. On failure, temp files are cleaned up — original files remain untouched. Server start never proceeds with partial config. | | Sensitive field encryption | **Adapter declares via get_sensitive_fields()** | ConfigGenerator protocol returns list of JSON keys per section that need Fernet encryption. Core's ConfigRepository handles encrypt/decrypt transparently. | | Adapter schema versioning | **config_version in game_configs row** | Each config section row stores a version string. On adapter update, if version differs, core calls `adapter.migrate_config(old_version, config_json)` which returns the migrated dict. On migration failure (ConfigMigrationError), core keeps the original config and logs a warning — the server can still run with the old schema. | | Adapter error communication | **Typed adapter exceptions** | Adapters raise specific exception types (ConfigWriteError, ConfigValidationError, LaunchArgsError, RemoteAdminError). Core catches specifically and sets appropriate DB status + returns clear API errors. | | Remote admin thread safety | **Core wraps with lock** | Core wraps RemoteAdminClient calls with a threading.Lock. Adapter clients don't need to be thread-safe. One lock per server — API requests and poller thread share safely. | | Third-party adapter loading | **Setuptools entry_points** | Third-party adapters register via `languard.adapters` entry_point group. Core scans entry_points at startup and auto-registers. Built-in adapters registered on import. | | Port conflict detection | **Full cross-game check** | When checking ports for a new/starting server, query ALL running servers, resolve each adapter, get port conventions for that game, and check the full derived port set. | | Config preview | **Dict of label → content** | preview_config() returns {label: rendered_content}. File-based games use filename as label; env-var games use variable name; CLI games use argument name. Frontend renders all as labeled text blocks. | | Ban file sync timing | **Immediate + startup** | On every ban add/delete via API, adapter's BanManager syncs to file immediately. On startup, adapter reads ban file and upserts into DB. Ensures consistency. | | Config concurrency | **Optimistic locking** | game_configs rows include config_version (integer). On PUT, client sends the version they read. If version mismatch, return 409 Conflict. Frontend re-reads and merges. | | game_data JSON schema | **Adapter declares via get_game_data_schema()** | Each capability protocol (MissionManager, ModManager, etc.) optionally returns a Pydantic model for the game_data JSON. Core validates on write. | | Log storage | **DB + rolling file** | DB for fast queries/streaming; raw logs preserved on disk | | Player identification | **slot_id (string) + game_data JSON** | Flexible across games; Arma 3 uses int slot, others may use UUID | | Route URLs | **Game-agnostic with adapter delegation** | Frontend doesn't need game-type-specific URLs; 404 if adapter lacks capability | | API route registration | **Core defines all routes; adapter dispatch at request time** | Simpler than dynamic route mounting; clear 404 for unsupported features |