Files
languard-servers-manager/ARCHITECTURE.md
Tran G. (Revernomad) Khoa 6511353b55 feat: implement full backend + frontend server detail, settings, and create server pages
Backend:
- Complete FastAPI backend with 42+ REST endpoints (auth, servers, config,
  players, bans, missions, mods, games, system)
- Game adapter architecture with Arma 3 as first-class adapter
- WebSocket real-time events for status, metrics, logs, players
- Background thread system (process monitor, metrics, log tail, RCon poller)
- Fernet encryption for sensitive config fields at rest
- JWT auth with admin/viewer roles, bcrypt password hashing
- SQLite with WAL mode, parameterized queries, migration system
- APScheduler cleanup jobs for logs, metrics, events

Frontend:
- Server Detail page with 7 tabs (overview, config, players, bans,
  missions, mods, logs)
- Settings page with password change and admin user management
- Create Server wizard (4-step; known bug: silent validation failure)
- New hooks: useServerDetail, useAuth, useGames
- New components: ServerHeader, ConfigEditor, PlayerTable, BanTable,
  MissionList, ModList, LogViewer, PasswordChange, UserManager
- WebSocket onEvent callback for real-time log accumulation
- 120 unit tests passing (Vitest + React Testing Library)

Docs:
- Added .gitignore, CLAUDE.md, README.md
- Updated FRONTEND.md, ARCHITECTURE.md with current implementation state
- Added .env.example for backend configuration

Known issues:
- Create Server form: "Next" buttons don't validate before advancing,
  causing silent submit failure when fields are invalid
- Config sub-tabs need UX redesign for non-technical users
2026-04-17 11:58:34 +07:00

767 lines
40 KiB
Markdown

# 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) |
| Dashboard | Login | Server List | Server Detail (planned) |
+------------------------------+-------------------------------+
| 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 from server config or defaults to `server_dir/server/*.rpt`.
- **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/<game>/` 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:<base64-token>` 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:///<db_path>` (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 <token>` header from localStorage
- Response interceptor: on 401, clears token and redirects to `/login` (except for `/api/auth/` endpoints)
- Type: `ApiResponse<T> = { success: boolean; data: T; error?: string }`
### WebSocket Hook (`hooks/useWebSocket.ts`)
- Connects to `VITE_WS_URL/ws?token=<jwt>[&server_id=<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
components/
layout/
Sidebar.tsx # Sidebar navigation
servers/
ServerCard.tsx # Server status card with lifecycle buttons
ui/
StatusLed.tsx # Status LED indicator component
hooks/
useServers.ts # TanStack Query hooks for server CRUD + lifecycle
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__/ # 12 unit test files (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.