# Languard Server 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 | | Database | **SQLite** via SQLAlchemy (sync, raw SQL via `text()`) | Zero-config, file-based; WAL mode for concurrent reads; all DB access is synchronous | | Process management | `subprocess.Popen` + `threading` + `psutil` | Wrap server executables, recover PIDs after restart; on Windows `terminate()` is a hard kill | | Real-time comms | **WebSocket** via FastAPI | Push log lines, player lists, server status to React | | Game adapters | **Python Protocol classes** (duck typing) + `GameAdapterRegistry` | Each game implements capability protocols; core resolves adapter at runtime from `server.game_type` | | Scheduling | **APScheduler** `BackgroundScheduler` | Log/metrics/event cleanup jobs (sync DB ops run in thread pool) | | Auth | **JWT** (python-jose, HS256) + bcrypt | Secure API; React stores token in localStorage | | Encryption | **Fernet** (AES-256 via cryptography library) | Encrypt sensitive config fields (RCON passwords, admin passwords) at rest | | Rate limiting | **slowapi** (installed, no per-route limits yet) | Infrastructure present, not yet applied to individual routes | | Frontend | **React 19** + **TypeScript 6** + **Vite 8** + Tailwind CSS | SPA with dark neumorphic design system | --- ## Architecture Diagram ``` +--------------------------------------------------------------+ | React Frontend (SPA) | | Login | Dashboard | Server Detail (7 tabs) | Create Server | | Settings | (all routes complete) | +------------------------------+-------------------------------+ | HTTP REST + WebSocket (/ws) v +--------------------------------------------------------------+ | FastAPI Application (main.py) | | | | Lifespan: init DB -> register adapters -> create WSManager | | -> start BroadcastThread -> create ThreadRegistry | | -> recover processes -> seed admin -> start scheduler| | | | +-----------+ +-----------+ +-----------+ +-----------+ | | | Auth | | Servers | | Games | | System | | | | Router | | Router | | Router | | Router | | | +-----------+ +-----------+ +-----------+ +-----------+ | | +-----------+ +-----------+ +-----------+ +-----------+ | | | Players | | Bans | | Missions | | Mods | | | | Router | | Router | | Router | | Router | | | +-----------+ +-----------+ +-----------+ +-----------+ | | +-----------+ | | | WebSocket | /ws endpoint, JWT via query param | | | Router | BroadcastThread bridges Queue -> asyncio | | +-----------+ | | | | +----------------------------------------------------------+ | | | Service Layer | | | | AuthService | ServerService | (delegating to adapters) | | | +----------------------------------------------------------+ | | | | +----------------------------------------------------------+ | | | Data Access Layer | | | | BaseRepository -> ServerRepository | ConfigRepository | | | | PlayerRepository | LogRepository | MetricsRepository | | | | EventRepository | BanRepository | | | +----------------------------------------------------------+ | | | | +----------------------------------------------------------+ | | | Game Adapter System | | | | GameAdapterRegistry (class-level singleton) | | | | -> Arma3Adapter (implements all 7 capabilities) | | | | -> third-party adapters via entry_points | | | +----------------------------------------------------------+ | | | | +----------------------------------------------------------+ | | | Background Thread System | | | | ThreadRegistry -> per-server thread bundles: | | | | ProcessMonitorThread | MetricsCollectorThread | | | | LogTailThread (conditional) | RemoteAdminPollerThread | | | +----------------------------------------------------------+ | | | | +----------------------------------------------------------+ | | | Scheduled Jobs (APScheduler) | | | | cleanup_old_logs (daily 03:00) | | | | cleanup_old_metrics (every 6h) | | | | cleanup_old_events (weekly Sunday 04:00) | | | +----------------------------------------------------------+ | +--------------------------------------------------------------+ | v +--------------------------------------------------------------+ | SQLite Database | | users | servers | game_configs | mods | server_mods | | missions | mission_rotation | players | player_history | | bans | logs | metrics | server_events | schema_migrations | +--------------------------------------------------------------+ ``` --- ## Backend Architecture ### Application Bootstrap (main.py) The app uses a `lifespan` async context manager on the FastAPI instance. The startup sequence, in order: 1. **Initialize DB** -- `get_engine()` creates the SQLAlchemy engine with WAL mode, foreign keys, and busy timeout pragmas. `run_migrations()` applies all pending SQL files from `core/migrations/`. 2. **Register adapters** -- `initialize_adapters()` imports built-in adapters (Arma 3) which self-register via `GameAdapterRegistry.register()`, then scans `importlib.metadata` entry_points under `"languard.adapters"` for third-party adapters. 3. **Create WebSocketManager** -- Instantiated and stored on `app.state.ws_manager`. 4. **Create broadcast queue and BroadcastThread** -- A `queue.Queue(maxsize=1000)` bridges background threads to the asyncio event loop. `BroadcastThread` is a daemon thread that calls `asyncio.run_coroutine_threadsafe()` to schedule `ws_manager.broadcast()` on the event loop. 5. **Create ThreadRegistry** -- Initialized with the `ProcessManager` singleton, `GameAdapterRegistry`, and the global broadcast queue. Stored on `app.state.thread_registry`. 6. **Recover processes** -- `ProcessManager.recover_on_startup()` checks DB for servers marked "running", verifies PIDs via psutil against the adapter's executable allowlist, and re-attaches monitoring threads for valid PIDs. Crashed servers are marked "crashed". 7. **Reattach threads for running servers** -- Iterates running servers and calls `ThreadRegistry.reattach_server_threads()`. 8. **Seed default admin** -- `AuthService.seed_admin_if_empty()` creates an "admin" user with a random 16-char password if no users exist. The password is logged to stdout at startup. 9. **Start APScheduler** -- Registers cleanup jobs and starts the `BackgroundScheduler`. Shutdown stops all threads, the broadcast thread, and the scheduler. ### Layered Architecture The backend follows a strict layered pattern: ``` Routers -> Services -> Repositories -> Database ``` - **Routers** handle HTTP concerns (request parsing, response formatting, auth guards). They return standardized `{"success": True, "data": ..., "error": None}` envelopes. - **Services** contain business logic. `ServerService` orchestrates lifecycle operations, delegating game-specific work to adapters. - **Repositories** encapsulate SQL. All extend `BaseRepository`, which provides `_execute()`, `_fetchone()`, `_fetchall()`, and `_lastrowid()` helpers that wrap `sqlalchemy.text()`. - **Database** is SQLite with raw SQL (no ORM models). Migrations are numbered SQL files applied in order. ### Global Exception Handler All unhandled exceptions are caught by a FastAPI `@app.exception_handler(Exception)` that returns: ```json { "success": false, "data": null, "error": {"code": "INTERNAL_ERROR", "message": "An unexpected error occurred"} } ``` ### CORS Middleware Configured via `LANGUARD_CORS_ORIGINS` env var (default: `["http://localhost:5173"]`). Allows all methods and headers with credentials. --- ## Game Adapter System ### Design Adapters use Python `Protocol` classes (runtime-checkable duck typing). The core never imports adapter internals -- it only imports from `adapters.protocols` and resolves adapters via `GameAdapterRegistry`. ### Capability Protocols Seven capability protocols are defined in `adapters/protocols.py`: | Protocol | Purpose | Key Methods | |----------|---------|-------------| | `ConfigGenerator` | Config schema, defaults, file writing, launch args | `get_sections()`, `get_defaults()`, `get_sensitive_fields()`, `get_config_version()`, `migrate_config()`, `write_configs()`, `build_launch_args()`, `preview_config()` | | `ProcessConfig` | Executable allowlist, port conventions, directory layout | `get_allowed_executables()`, `get_port_conventions()`, `get_default_game_port()`, `get_default_rcon_port()`, `get_server_dir_layout()` | | `LogParser` | Parse game-specific log lines | `parse_line()`, `get_log_file_resolver()` | | `RemoteAdmin` | Factory for remote admin clients (RCon) | `create_client()`, `get_startup_delay()`, `get_poll_interval()`, `get_player_data_schema()` | | `MissionManager` | Mission file handling and rotation | `parse_mission_filename()`, `get_rotation_config()`, `get_missions_dir()`, `get_mission_data_schema()` | | `ModManager` | Mod folder conventions and CLI args | `get_mod_folder_pattern()`, `build_mod_args()`, `validate_mod_folder()`, `get_mod_data_schema()` | | `BanManager` | Bidirectional ban file sync | `get_ban_file_path()`, `sync_bans_to_file()`, `read_bans_from_file()`, `get_ban_data_schema()` | The composite `GameAdapter` protocol requires all adapters to implement `has_capability(name)` which returns whether a given capability is available. Optional capabilities (`RemoteAdmin`, `MissionManager`, `ModManager`, `BanManager`) return `None` from their factory methods when not supported. ### GameAdapterRegistry A class-level singleton mapping `game_type` string to adapter instance. Provides: - `register(adapter)` -- Called at import time by each adapter package - `get(game_type)` -- Raises `KeyError` if not found - `all()` -- Returns all registered adapters - `list_game_types()` -- Returns metadata list for the `/api/games` endpoint ### Arma3Adapter Implements all 7 capabilities: - **ConfigGenerator**: Defines `server`, `rcon`, `mission` sections using Pydantic models. Writes `server.cfg` using atomic write pattern (`.tmp` then `os.replace()`). Builds launch args from config + mod list. - **ProcessConfig**: Allows `arma3server_x64.exe` and `arma3server.exe`. Derives 4 ports from `game_port` (game, steam_query, von, steam_auth). Default game port 2302, default RCon port game+4. - **LogParser**: Parses Arma 3 `.rpt` log files. Resolves log path using `Path(server["exe_path"]).parent / "server"` — Arma 3 writes .rpt files next to its executable, not in the languard server data directory. - **RemoteAdmin**: Implements BattlEye RCon protocol via `Arma3RemoteAdminFactory`. Supports login, command sending, player listing, kick, ban, say-all, and shutdown. - **MissionManager**: Handles `.pbo` mission files and mission rotation config. - **ModManager**: Builds `-mod=` and `-serverMod=` CLI arguments from mod list. - **BanManager**: Syncs bans between the DB and `battleye/bans.txt`. ### Adapter Error Hierarchy Typed exceptions in `adapters/exceptions.py`: - `AdapterError` (base) - `ConfigWriteError` -- Atomic file write failed - `ConfigValidationError` -- Pydantic model rejected config - `ConfigMigrationError` -- Schema migration failed - `LaunchArgsError` -- Launch args construction failed - `RemoteAdminError` -- RCon connection/command failure (with `recoverable` flag) - `ExeNotAllowedError` -- Executable not in adapter allowlist ### Adding a New Game Adapter 1. Create `adapters//` package with an adapter class implementing `GameAdapter` protocol 2. Create capability modules for supported protocols 3. Export a module-level adapter instance (e.g., `MYGAME_ADAPTER`) 4. Import it in `adapters/__init__.py`'s `load_builtin_adapters()` -- or register via `pyproject.toml` entry_points under `"languard.adapters"` No core code changes are required. --- ## Authentication & Authorization ### JWT Authentication - Algorithm: HS256 via `python-jose` - Token payload: `{sub: user_id, username, role, exp}` - Expiry: configurable via `LANGUARD_JWT_EXPIRE_HOURS` (default 24h) - Secret: `LANGUARD_SECRET_KEY` env var (required) ### Password Hashing - bcrypt via the `bcrypt` Python package - `hash_password()` and `verify_password()` in `core/auth/utils.py` ### Roles Two roles: `admin` and `viewer`. - **admin**: Full access -- server lifecycle, config editing, user management, RCon commands - **viewer**: Read-only access -- can view servers, logs, metrics, players Authorization is enforced via FastAPI dependency injection: - `get_current_user()` -- Decodes JWT, validates user exists in DB, returns user dict - `require_admin()` -- Wraps `get_current_user()`, raises 403 if role is not "admin" ### Default Admin Seeding On first run, if no users exist, a default admin user is created with username "admin" and a randomly generated 16-char password (`secrets.token_urlsafe(16)`). The password is printed to the server log at startup. --- ## Encryption Sensitive config fields (RCON passwords, admin passwords) are encrypted at rest using Fernet (AES-256-CBC with HMAC) from the `cryptography` library. - Encryption key: `LANGUARD_ENCRYPTION_KEY` env var (Fernet base64 key, required) - Format: encrypted values are stored as `encrypted:` in the database - `ConfigRepository` handles encryption/decryption transparently -- reads decrypt, writes encrypt - The `get_sensitive_fields()` method on each `ConfigGenerator` declares which field names in each section need encryption --- ## Process Management ### ProcessManager (Singleton) Owns all `subprocess.Popen` handles. Key behaviors: - **Start**: Validates server is not already running, launches with `subprocess.Popen`. On Windows, uses `CREATE_NO_WINDOW` flag to suppress console windows. - **Stop**: Sends `terminate()`, waits up to 30 seconds, then force-kills if needed. - **Kill**: Immediate force-kill via `proc.kill()`. - **Operation locks**: Per-server `threading.Lock` serializes start/stop/restart for the same server ID. - **PID recovery**: On startup, checks DB for servers marked "running". Uses `psutil.Process(pid)` to verify the PID is still alive and the process name matches the adapter's `get_allowed_executables()`. Invalid PIDs cause the server to be marked "crashed". Valid PIDs get a `_PsutilProcessWrapper` attached so the process can be monitored. ### Exe Allowlist `ProcessConfig.get_allowed_executables()` returns a list of allowed executable names. This is checked during server creation and startup to prevent path traversal attacks. The check compares only the filename component (via `Path(exe_path).name`), not the full path. --- ## Thread Architecture ### ThreadRegistry Manages per-server background thread bundles. Created at app startup and stored on `app.state.thread_registry`. Also provides class-level convenience methods (`start_server_threads`, `stop_server_threads`, etc.) that delegate to the singleton instance. Each server gets up to 4 threads: | Thread | Started | Purpose | |--------|---------|---------| | `ProcessMonitorThread` | Always | Watches `Popen.poll()` and detects crashes; updates DB status; broadcasts status changes | | `MetricsCollectorThread` | Always | Periodically collects CPU/RAM via `psutil.Process` and stores in `metrics` table | | `LogTailThread` | If adapter has `log_parser` capability | Tails the game log file, parses lines via adapter, stores in `logs` table, broadcasts to WebSocket | | `RemoteAdminPollerThread` | If adapter has `remote_admin` capability | Periodically polls player list via RCon, stores in `players` table, broadcasts to WebSocket | All threads inherit from `BaseServerThread` (a `threading.Thread` subclass with `stop_and_join()` and `stop_event`). ### BroadcastThread A daemon thread that bridges `queue.Queue` (written by background threads) to `WebSocketManager.broadcast()` on the asyncio event loop. Uses `asyncio.run_coroutine_threadsafe()` to schedule broadcasts. If the event loop is closed, events are silently dropped (with periodic logging of drop count). --- ## WebSocket System ### Endpoint `/ws` -- JWT authentication via `token` query parameter (browser WebSocket API does not support custom headers). Optional `server_id` query parameters for subscription filtering. Connection flow: 1. Client connects with `token` and optional `server_id` params 2. Server validates JWT, closes with code `4001` if invalid 3. Server sends `{"type": "connected", "data": {"user": ..., "subscriptions": ...}}` 4. Server pushes events for subscribed server IDs ### WebSocketManager (asyncio-side) - `connect(ws, server_ids)` -- Accept connection, register subscriptions - `disconnect(ws)` -- Remove connection - `broadcast(server_id, message)` -- Send to all clients subscribed to `server_id` or to `None` (global) - `send_to_connection(ws, message)` -- Send to a single connection - Automatically removes disconnected clients ### Message Format ```json { "type": "server_status" | "metrics" | "log" | "players", "server_id": 1, "data": { ... } } ``` --- ## Database ### SQLite Configuration - Engine: `sqlite:///` (default: `./languard.db`) - WAL mode enabled via `PRAGMA journal_mode=WAL` - Foreign keys enforced via `PRAGMA foreign_keys=ON` - Busy timeout: 5000ms via `PRAGMA busy_timeout=5000` - All queries use `sqlalchemy.text()` with parameterized inputs (no ORM) ### Migration System Migrations are SQL files in `core/migrations/` named with a numeric prefix (e.g., `001_initial_schema.sql`). Applied in order. A `schema_migrations` table tracks which versions have been applied. ### Schema (Tables) | Table | Purpose | |-------|---------| | `users` | Auth -- id, username, password_hash, role (admin/viewer), timestamps | | `servers` | Server instances -- id, name, game_type, status, pid, exe_path, ports, auto_restart settings | | `game_configs` | Per-server config sections -- server_id, game_type, section, config_json, schema_version, config_version (optimistic locking) | | `mods` | Mod registry -- game_type, name, folder_path, workshop_id, game_data | | `server_mods` | Many-to-many server-mod with sort_order, is_server_mod flag | | `missions` | Mission files -- server_id, filename, mission_name, terrain | | `mission_rotation` | Ordered mission rotation for a server | | `players` | Live player list per server -- slot_id, name, guid, ip, ping | | `player_history` | Historical player sessions with join/leave timestamps | | `bans` | Ban records -- guid, name, reason, banned_by, expires_at, is_active | | `logs` | Parsed log entries -- server_id, timestamp, level, message | | `metrics` | CPU/RAM/player_count time series -- server_id, timestamp, cpu_percent, ram_mb, player_count | | `server_events` | Audit log -- server_id, event_type, actor, detail (JSON) | ### Thread-Local DB Connections Background threads use `database.get_thread_db()` which returns a thread-local `Connection`. Each thread gets its own connection (SQLite requirement). Connections must be closed in thread teardown. --- ## Configuration Management ### Config Sections Each game adapter declares config sections via `ConfigGenerator.get_sections()`, returning a dict of `{section_name: PydanticModelClass}`. The Arma 3 adapter defines `server`, `rcon`, and `mission` sections. ### Config Lifecycle 1. **Creation**: When a server is created, `ConfigRepository.upsert_section()` is called for each section with adapter defaults. Sensitive fields are encrypted. 2. **Read**: `get_section()` and `get_all_sections()` decrypt sensitive fields transparently. 3. **Update**: `upsert_section()` supports optimistic locking via `expected_config_version`. On conflict, raises `ValueError` with `"CONFIG_VERSION_CONFLICT"` prefix, which the service layer translates to a 409 response with the current config data. 4. **Write to disk**: `ConfigGenerator.write_configs()` uses an atomic write pattern (write to `.tmp`, then `os.replace()`). On failure, temp files are cleaned up and `ConfigWriteError` is raised. 5. **Preview**: `ConfigGenerator.preview_config()` renders configs as strings without writing to disk. ### Sensitive Field Encryption The `ConfigRepository` handles encryption/decryption transparently: - `_encrypt_sensitive()`: Encrypts specified fields using Fernet, prefixes with `"encrypted:"` - `_decrypt_sensitive()`: Decrypts `"encrypted:"`-prefixed fields - `is_encrypted()`: Checks if a value starts with `"encrypted:"` - Non-encrypted values pass through decryption unchanged --- ## Scheduled Cleanup Jobs APScheduler `BackgroundScheduler` with a `ThreadPoolExecutor(max_workers=2)` runs three cleanup jobs: | Job | Schedule | Retention | Action | |-----|----------|-----------|--------| | `cleanup_old_logs` | Daily at 03:00 | 7 days | Delete log entries older than retention | | `cleanup_old_metrics` | Every 6 hours | 1 day | Delete metrics older than retention | | `cleanup_old_events` | Weekly Sunday 04:00 | 30 days | Delete server events older than retention | Each job creates a thread-local DB connection, runs the cleanup, commits, and closes the connection. --- ## API Structure All routes are prefixed with `/api` (except WebSocket at `/ws`). ### Authentication (`/api/auth`) | Method | Path | Auth | Description | |--------|------|------|-------------| | POST | `/auth/login` | None | Login, returns JWT | | POST | `/auth/logout` | Bearer | Client-side token deletion | | GET | `/auth/me` | Bearer | Get current user | | PUT | `/auth/password` | Bearer | Change password | | GET | `/auth/users` | Admin | List all users | | POST | `/auth/users` | Admin | Create user | | DELETE | `/auth/users/{id}` | Admin | Delete user | ### Servers (`/api/servers`) | Method | Path | Auth | Description | |--------|------|------|-------------| | GET | `/servers` | Bearer | List servers (optional `game_type` filter) | | POST | `/servers` | Admin | Create server | | GET | `/servers/{id}` | Bearer | Get server details | | PUT | `/servers/{id}` | Admin | Update server | | DELETE | `/servers/{id}` | Admin | Delete server (must be stopped) | | POST | `/servers/{id}/start` | Admin | Start server | | POST | `/servers/{id}/stop` | Admin | Stop server (optional `force`) | | POST | `/servers/{id}/restart` | Admin | Restart server | | POST | `/servers/{id}/kill` | Admin | Force-kill server | | GET | `/servers/{id}/config` | Bearer | Get all config sections (sensitive fields masked) | | GET | `/servers/{id}/config/preview` | Admin | Preview config files without writing | | GET | `/servers/{id}/config/{section}` | Bearer | Get single config section | | PUT | `/servers/{id}/config/{section}` | Admin | Update config section (optimistic locking) | | POST | `/servers/{id}/rcon/command` | Admin | Send RCon command | ### Players, Bans, Missions, Mods (`/api/players`, `/api/bans`, `/api/missions`, `/api/mods`) Sub-routers under `/api` for their respective CRUD operations. All require Bearer auth; write operations require admin role. ### Games (`/api/games`) | Method | Path | Auth | Description | |--------|------|------|-------------| | GET | `/games` | None | List all registered game types with capabilities | | GET | `/games/{game_type}` | None | Get game type details, config sections, allowed executables | | GET | `/games/{game_type}/config-schema` | None | Get Pydantic JSON schemas for config sections | | GET | `/games/{game_type}/defaults` | None | Get default config values | ### System (`/api/system`) | Method | Path | Auth | Description | |--------|------|------|-------------| | GET | `/system/health` | None | Health check | | GET | `/system/status` | Bearer | System status (running/total servers, supported games) | ### WebSocket (`/ws`) | Path | Auth | Description | |------|------|-------------| | `/ws?token=JWT` | JWT query param | Connect to real-time event stream | | `/ws?token=JWT&server_id=1&server_id=2` | JWT query param | Subscribe to specific servers | --- ## Frontend Architecture ### Tech Stack - **React 19** with **TypeScript 6** - **Vite 8** for bundling and dev server - **Tailwind CSS 3** for styling with a custom dark neumorphic design system - **Zustand 5** for client state (auth, UI) - **TanStack Query 5** for server state - **React Router 7** for routing - **Axios** for HTTP client - **React Hook Form + Zod** for form validation (login page) - **Lucide React** for icons ### Design System Dark neumorphic theme defined in `tailwind.config.js`: - **Surface colors**: `surface-base` (#1a1a2e), `surface-raised` (#1e1e35), `surface-recessed` (#16162a), `surface-overlay` (#22223a) - **Accent**: Amber (#f59e0b) with bright, dim, and glow variants - **Status colors**: `running` (green), `stopped` (gray), `crashed` (red), `starting` (amber), `restarting` (blue) - **Neumorphic shadows**: `shadow-neu-raised`, `shadow-neu-raised-lg`, `shadow-neu-recessed` - **Glow shadows**: `shadow-glow-green`, `shadow-glow-amber`, `shadow-glow-red`, `shadow-glow-blue` - **Animations**: `pulse-slow` (3s), `glow-pulse` (2s opacity fade) - **Font**: Inter (UI) + JetBrains Mono (code) - **Component classes**: `.neu-card`, `.neu-input`, `.btn-primary`, `.btn-ghost`, `.btn-danger`, `.status-led` ### Application Structure ``` App.tsx BrowserRouter QueryClientProvider (staleTime: 10s, retry: 2, no refetchOnWindowFocus) ReactQueryDevtools Routes: /login -> LoginPage /* -> ProtectedLayout (requires auth) Sidebar + main content area / -> DashboardPage /servers/:serverId -> ServerDetailPage (7 tabs: overview, config, players, bans, missions, mods, logs) /servers/new -> CreateServerPage (4-step wizard, admin only) /settings -> SettingsPage (Account + Users tabs) ``` ### State Management **Auth Store** (`store/auth.store.ts`): - Zustand store with `persist` middleware (localStorage key: `languard-auth`) - State: `token`, `user` (id, username, role), `isAuthenticated` - Actions: `setAuth(token, user)`, `clearAuth()` - On rehydration, sets `isAuthenticated = true` if token exists **UI Store** (`store/ui.store.ts`): - Zustand store (in-memory, not persisted) - State: `sidebarOpen`, `activeServerId`, `notifications[]` - Actions: `toggleSidebar()`, `setActiveServer()`, `addNotification()`, `removeNotification()` - Notifications auto-dismiss after 5 seconds ### API Client (`lib/api.ts`) Axios instance with: - Base URL from `VITE_API_URL` env var (default: `http://localhost:8000`) - 30-second timeout - Request interceptor: adds `Authorization: Bearer ` header from localStorage - Response interceptor: on 401, clears token and redirects to `/login` (except for `/api/auth/` endpoints) - Type: `ApiResponse = { success: boolean; data: T; error?: string }` ### WebSocket Hook (`hooks/useWebSocket.ts`) - Connects to `VITE_WS_URL/ws?token=[&server_id=]` - Exponential backoff reconnect: starts at 2 seconds, doubles up to 30 seconds max - On message, invalidates relevant TanStack Query cache keys based on event type - On close with code 4001 (auth failure), does not reconnect - Cleanup on unmount: closes WebSocket, clears reconnect timer ### Server Data Hooks (`hooks/useServers.ts`) TanStack Query hooks wrapping the API client: - `useServers()` -- Lists all servers, refetches every 30 seconds - `useServer(id)` -- Gets single server - `useStartServer()`, `useStopServer()`, `useRestartServer()`, `useCreateServer()`, `useDeleteServer()` -- Mutation hooks that invalidate relevant query caches on success ### Vite Dev Proxy In development, Vite proxies: - `/api` -> `http://localhost:8000` - `/ws` -> `ws://localhost:8000` (WebSocket upgrade) ### Tests - **Unit tests** (12 files): Vitest + React Testing Library + jsdom - Component tests: `DashboardPage`, `LoginPage`, `ServerCard`, `Sidebar`, `StatusLed` - Hook tests: `useServers`, `useWebSocket` - Store tests: `auth.store`, `ui.store` - Lib tests: `api` (Axios interceptors) - **E2E tests** (3 specs): Playwright - `auth/login.spec.ts` -- Login flow - `dashboard/dashboard.spec.ts` -- Dashboard interactions - `integration/fullstack.spec.ts` -- Full-stack integration --- ## Security Measures 1. **JWT Authentication**: All API routes except `/api/auth/login` and `/api/games/*` and `/api/system/health` require a Bearer token 2. **Role-based Authorization**: `admin` and `viewer` roles enforced via dependency injection 3. **Fernet Encryption**: Sensitive config fields (RCon passwords, admin passwords) encrypted at rest with AES-256 4. **Exe Allowlist**: `ProcessConfig.get_allowed_executables()` prevents path traversal by validating executable names 5. **Filename Sanitization**: `sanitize_filename()` in `file_utils.py` strips path separators, null bytes, control characters, and collapses consecutive dots 6. **Atomic Config Writes**: Config files written to `.tmp` first, then `os.replace()` for atomic rename 7. **Optimistic Locking**: Config updates require `config_version` to prevent lost updates 8. **Port Conflict Detection**: `check_ports_against_running_servers()` prevents port collisions between servers 9. **CORS**: Configurable origins via `LANGUARD_CORS_ORIGINS` 10. **Password Hashing**: bcrypt for all user passwords 11. **WebSocket Auth**: JWT via query parameter (browser WebSocket limitation); invalid tokens close with code 4001 --- ## Environment Variables All prefixed with `LANGUARD_`: | Variable | Required | Default | Description | |----------|----------|---------|-------------| | `LANGUARD_SECRET_KEY` | Yes | -- | JWT signing key | | `LANGUARD_ENCRYPTION_KEY` | Yes | -- | Fernet base64 key for config field encryption | | `LANGUARD_DB_PATH` | No | `./languard.db` | SQLite database file path | | `LANGUARD_SERVERS_DIR` | No | `./servers` | Base directory for server instances | | `LANGUARD_HOST` | No | `0.0.0.0` | Server bind address | | `LANGUARD_PORT` | No | `8000` | Server bind port | | `LANGUARD_CORS_ORIGINS` | No | `["http://localhost:5173"]` | JSON array of allowed origins | | `LANGUARD_LOG_RETENTION_DAYS` | No | `7` | Days to retain log entries | | `LANGUARD_METRICS_RETENTION_DAYS` | No | `30` | Days to retain metrics | | `LANGUARD_PLAYER_HISTORY_RETENTION_DAYS` | No | `90` | Days to retain player history | | `LANGUARD_JWT_EXPIRE_HOURS` | No | `24` | JWT token lifetime | | `LANGUARD_LOGIN_RATE_LIMIT` | No | `5/minute` | Rate limit for login endpoint | | `LANGUARD_ARMA3_DEFAULT_EXE` | No | `C:/Arma3Server/arma3server_x64.exe` | Default Arma 3 server executable path | Configuration uses `pydantic-settings` with `SettingsConfigDict` (env prefix `LANGUARD_`, `.env` file support). --- ## Directory Layout ### Backend ``` backend/ main.py # FastAPI app factory, lifespan, CORS, routers config.py # Pydantic Settings class (env vars) database.py # SQLAlchemy engine, get_db(), get_thread_db(), migrations dependencies.py # FastAPI dependencies: get_current_user, require_admin, get_adapter_for_server requirements.txt # Python dependencies adapters/ __init__.py # initialize_adapters() -- loads built-in + third-party protocols.py # 7 capability Protocols + GameAdapter composite registry.py # GameAdapterRegistry class-level singleton exceptions.py # Typed adapter exceptions arma3/ __init__.py # Exports ARMA3_ADAPTER adapter.py # Arma3Adapter (implements all 7 capabilities) config_generator.py # Arma3ConfigGenerator (server.cfg, launch args) process_config.py # Arma3ProcessConfig (exe allowlist, port conventions) log_parser.py # RPTParser (Arma 3 .rpt log format) remote_admin.py # Arma3RemoteAdminFactory (BattlEye RCon) rcon_client.py # BEClient (BattlEye RCon protocol) rcon_service.py # RCon command helpers mission_manager.py # Arma3MissionManager (.pbo missions) mod_manager.py # Arma3ModManager (mod CLI args) ban_manager.py # Arma3BanManager (ban file sync) migrations/ # Game-specific migration files core/ __init__.py auth/ router.py # Auth API routes service.py # AuthService (login, create_user, change_password, seed_admin) utils.py # JWT create/decode, password hash/verify schemas.py # Pydantic request models servers/ router.py # Server CRUD + lifecycle + config + RCon routes service.py # ServerService (orchestrates all server operations) process_manager.py # ProcessManager singleton (Popen lifecycle + PID recovery) schemas.py # Pydantic request/response models players_router.py # Players API routes bans_router.py # Bans API routes missions_router.py # Missions API routes mods_router.py # Mods API routes games/ router.py # Game type info routes system/ router.py # Health and status routes dal/ __init__.py base_repository.py # BaseRepository with SQL helpers server_repository.py # Server CRUD operations config_repository.py # Config section CRUD with Fernet encryption player_repository.py # Player operations log_repository.py # Log operations metrics_repository.py # Metrics operations + cleanup event_repository.py # Server event operations + cleanup ban_repository.py # Ban operations threads/ thread_registry.py # ThreadRegistry (manages per-server thread bundles) process_monitor.py # ProcessMonitorThread metrics_collector.py # MetricsCollectorThread log_tail.py # LogTailThread remote_admin_poller.py # RemoteAdminPollerThread websocket/ __init__.py manager.py # WebSocketManager (asyncio-side, subscription model) broadcast_thread.py # BroadcastThread (Queue -> asyncio bridge) router.py # /ws endpoint with JWT auth utils/ __init__.py crypto.py # Fernet encrypt/decrypt for config fields file_utils.py # Server dir helpers, filename sanitization port_checker.py # Port availability checking jobs/ __init__.py scheduler.py # APScheduler BackgroundScheduler setup cleanup_jobs.py # Registered cleanup jobs (logs, metrics, events) migrations/ 001_initial_schema.sql # Initial database schema logs/ # Log parsing (not a module, handled by log_tail thread) metrics/ # Metrics collection (not a module, handled by metrics thread) players/ # Player tracking (not a module, handled by RCon poller) events/ # Event tracking (not a module, handled by services) ``` ### Frontend ``` frontend/ src/ App.tsx # BrowserRouter + QueryClientProvider + ProtectedLayout main.tsx # React entry point index.css # Tailwind imports + neumorphic component classes vite-env.d.ts # Vite type declarations pages/ LoginPage.tsx # Login form (react-hook-form + zod validation) DashboardPage.tsx # Server dashboard with ServerCard grid ServerDetailPage.tsx # 7-tab detail page (overview, config, players, bans, missions, mods, logs) CreateServerPage.tsx # 4-step wizard (admin only) SettingsPage.tsx # Password change + admin user management components/ layout/ Sidebar.tsx # Sidebar navigation servers/ ServerCard.tsx # Server status card with lifecycle buttons ServerHeader.tsx # Server name, status, stats, lifecycle buttons ConfigEditor.tsx # Tabbed config editor with optimistic locking PlayerTable.tsx # Current players + history with search BanTable.tsx # Ban list + create/revoke form MissionList.tsx # Mission list + .pbo upload/delete ModList.tsx # Mod list with enable/disable checkboxes LogViewer.tsx # Log display with level filter (props-driven) settings/ PasswordChange.tsx # Password change form UserManager.tsx # User CRUD table (admin only) ui/ StatusLed.tsx # Status LED indicator component hooks/ useServers.ts # TanStack Query hooks for server CRUD + lifecycle useServerDetail.ts # Config, players, bans, missions, mods, RCon hooks useAuth.ts # Auth management hooks (users, password, logout) useGames.ts # Game type hooks (list, detail, schema, defaults) useWebSocket.ts # WebSocket hook with exponential backoff reconnect store/ auth.store.ts # Zustand auth store (persisted to localStorage) ui.store.ts # Zustand UI store (sidebar, notifications) lib/ api.ts # Axios client with auth interceptors __tests__/ # 14 unit test files (~120 tests, Vitest) tests-e2e/ auth/ login.spec.ts # Login E2E test dashboard/ dashboard.spec.ts # Dashboard E2E test integration/ fullstack.spec.ts # Full-stack integration test pages/ LoginPage.ts # Playwright page object DashboardPage.ts # Playwright page object index.html # Vite HTML entry vite.config.ts # Vite config with @/ alias and dev proxy tailwind.config.js # Custom dark neumorphic design tokens package.json # Dependencies and scripts ``` --- ## Key Design Decisions 1. **Raw SQL over ORM**: The project uses SQLAlchemy's `text()` for all queries instead of the ORM. This keeps queries explicit and avoids hidden N+1 problems. The `BaseRepository` provides thin helpers (`_execute`, `_fetchone`, `_fetchall`, `_lastrowid`). 2. **Synchronous DB access**: All database operations are synchronous. This is appropriate for SQLite (single-writer) and avoids the complexity of async SQLAlchemy. FastAPI runs sync route handlers in a thread pool by default. 3. **Thread-per-server model**: Each running server gets up to 4 background threads (monitor, metrics, log tail, RCon poller). This avoids the complexity of async subprocess management while keeping operations isolated per server. 4. **Queue-based broadcast**: Background threads write to a `queue.Queue`, and a `BroadcastThread` bridges to the asyncio event loop via `asyncio.run_coroutine_threadsafe()`. This avoids thread-safety issues with the WebSocket manager. 5. **Adapter Protocol system**: Using `Protocol` classes with `@runtime_checkable` allows duck-typing with `isinstance()` checks while maintaining type safety. Adapters are resolved by `game_type` string at runtime. 6. **Optimistic locking for configs**: The `config_version` column in `game_configs` prevents lost updates when multiple users edit configs concurrently. On version conflict, the API returns 409 with the current config data. 7. **Fernet encryption for secrets**: Sensitive fields are encrypted transparently in `ConfigRepository`. The `encrypted:` prefix distinguishes encrypted values from plaintext, allowing gradual migration. 8. **Atomic config file writes**: Config files are written to temporary `.tmp` files first, then `os.replace()` renames them to the final path. This prevents partial writes on crash. 9. **PID recovery on restart**: `ProcessManager.recover_on_startup()` uses `psutil` to check if a PID from a previous run is still alive and running an allowed executable. This handles the case where the Languard process restarts but game servers are still running. 10. **Game-relative log file discovery**: For Arma 3, log files (.rpt) are written by the game next to its executable (`{exe_path_parent}/server/*.rpt`), not in Languard's data directory. `LogTailThread` resolves the log path from `Path(server["exe_path"]).parent`, not from `get_server_dir(server_id)`. This respects the game's native file layout.