feat: initial system design documents for Languard Server Manager
Complete backend design for an Arma 3 dedicated server management panel: - ARCHITECTURE.md: System architecture, tech stack, component responsibilities, data flows - DATABASE.md: SQLite schema with WAL mode, CHECK constraints, 16+ tables - API.md: REST + WebSocket API contract with auth, CRUD, and real-time channels - MODULES.md: Python module breakdown with class definitions and dependencies - THREADING.md: Concurrency model with thread safety, auto-restart, and WS bridge - IMPLEMENTATION_PLAN.md: 7-phase implementation plan with security from Phase 1 Key design decisions: - Sync SQLAlchemy only (no aiosqlite), thread-local DB connections - Structured config builder (not f-strings) preventing config injection - RCon request multiplexer for concurrent UDP access - BackgroundScheduler for sync DB cleanup jobs - ban.txt bidirectional sync with documented field mapping - Auto-restart sequenced after thread cleanup Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
309
ARCHITECTURE.md
Normal file
309
ARCHITECTURE.md
Normal file
@@ -0,0 +1,309 @@
|
||||
# Languard Server Manager — System Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
Languard is a web-based management panel for Arma 3 dedicated servers. It provides a Python backend that manages one or more `arma3server_x64.exe` processes, exposes a REST + WebSocket API to a React frontend, and persists all state in SQLite.
|
||||
|
||||
---
|
||||
|
||||
## Technology Stack
|
||||
|
||||
| Layer | Technology | Rationale |
|
||||
|-------|-----------|-----------|
|
||||
| Backend framework | **FastAPI** (Python 3.11+) | Async-native, built-in WebSocket, OpenAPI docs auto-generated |
|
||||
| Database | **SQLite** via `SQLAlchemy` (sync) | Zero-config, file-based, sufficient for single-host server manager; all access is synchronous (WAL mode for concurrent reads) |
|
||||
| Process management | `subprocess` + `threading` | Wrap arma3server.exe, watch stdout/stderr, check exit codes; **cwd** set to server instance dir for relative paths; on Windows `terminate()` is a hard kill (no SIGTERM) |
|
||||
| Real-time comms | **WebSocket** (FastAPI) | Push log lines, player lists, server status to React |
|
||||
| RCon client | Custom UDP client | BattlEye RCon protocol for in-game admin commands |
|
||||
| Config generation | Python structured builder | Generate server.cfg, basic.cfg, server.Arma3Profile with proper escaping (no f-string injection) |
|
||||
| Scheduling | `APScheduler` (BackgroundScheduler) | Auto-restart, mission rotation timers, log/metrics cleanup (sync DB ops → BackgroundScheduler, not AsyncIOScheduler) |
|
||||
| Auth | **JWT** (python-jose) + bcrypt | Secure the API; React stores token in localStorage |
|
||||
| Frontend | React + TypeScript (external repo) | Connects to this backend's API |
|
||||
|
||||
---
|
||||
|
||||
## High-Level Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ React Frontend │
|
||||
│ Server List │ Server Detail │ Logs │ Players │ Config UI │
|
||||
└────────────────────────┬────────────────────────────────────┘
|
||||
│ HTTP REST + WebSocket
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ FastAPI Application │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
||||
│ │ Auth Router │ │ Server Router│ │ Config Router │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────────┘ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
|
||||
│ │Mission Router│ │ Mod Router │ │ WS Router │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Service Layer │ │
|
||||
│ │ ServerService │ ConfigService │ RConService │ │
|
||||
│ │ LogService │ MetricsService│ MissionService │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Thread Pool │ │
|
||||
│ │ ProcessMonitorThread (per server) │ │
|
||||
│ │ LogTailThread (per server) │ │
|
||||
│ │ MetricsCollectorThread (per server) │ │
|
||||
│ │ RConPollerThread (per server) │ │
|
||||
│ │ BroadcastThread (global) │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Data Access Layer (DAL) │ │
|
||||
│ │ ServerRepository │ PlayerRepository │ │
|
||||
│ │ LogRepository │ MetricsRepository │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────┐ ┌────────────────────────────────┐ │
|
||||
│ │ SQLite (DB) │ │ Filesystem │ │
|
||||
│ │ languard.db │ │ servers/{id}/server.cfg │ │
|
||||
│ │ │ │ servers/{id}/basic.cfg │ │
|
||||
│ │ │ │ servers/{id}/server/ │ │ ← profile dir (Arma3 -name=server)
|
||||
│ │ │ │ server.Arma3Profile │ │ ← profile settings
|
||||
│ │ │ │ arma3server_*.rpt │ │ ← RPT logs (tailable)
|
||||
│ │ │ │ servers/{id}/battleye/ │ │
|
||||
│ │ │ │ beserver.cfg │ │ ← RCon config
|
||||
│ │ │ │ servers/{id}/mpmissions/ │ │
|
||||
│ └───────────────────┘ └────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│ subprocess
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Arma 3 Server Processes (OS level) │
|
||||
│ arma3server_x64.exe (port 2302) │
|
||||
│ arma3server_x64.exe (port 2402) │
|
||||
│ ... │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Component Responsibilities
|
||||
|
||||
### FastAPI Routers
|
||||
- Validate input (Pydantic models)
|
||||
- Call service layer
|
||||
- Return JSON responses
|
||||
- Handle WebSocket connections
|
||||
|
||||
### Service Layer
|
||||
- Orchestrate operations (start server = generate config + launch process + start threads)
|
||||
- No direct DB access — delegates to repositories
|
||||
- No direct process access — delegates to ProcessManager
|
||||
|
||||
### ProcessManager
|
||||
- Singleton that owns all subprocess handles
|
||||
- Thread-safe dict: `{server_id: subprocess.Popen}`
|
||||
- `start()` sets `cwd=servers/{server_id}/` so relative config paths resolve correctly
|
||||
- On Windows: `terminate()` = `TerminateProcess` (hard kill), no graceful SIGTERM — graceful shutdown must go through RCon `#shutdown` first
|
||||
- Provides: `start()`, `stop()`, `restart()`, `is_running()`, `send_command()`
|
||||
|
||||
### Thread Pool (per running server)
|
||||
| Thread | Interval | Purpose |
|
||||
|--------|----------|---------|
|
||||
| `ProcessMonitorThread` | 1s | Detect crash / unexpected exit; update DB status; trigger auto-restart |
|
||||
| `LogTailThread` | 100ms | Read new lines from .rpt file; store in DB; push to WS clients |
|
||||
| `MetricsCollectorThread` | 5s | Collect CPU%, RAM MB for the process via psutil; write to DB |
|
||||
| `RConPollerThread` | 10s | Query connected players via BattlEye RCon; update DB player table |
|
||||
| `BroadcastThread` | event-driven | Consume from internal queue; push JSON to all subscribed WS clients |
|
||||
|
||||
### RCon Client
|
||||
- UDP socket to BattlEye RCon port (configured in `beserver.cfg` inside the server's `battleye/` directory)
|
||||
- Implements BE RCon protocol: login, keepalive, send command, parse response
|
||||
- **Request multiplexer**: tracks pending requests by sequence byte, routes responses to the correct caller via `threading.Event` per request. Prevents response misrouting when RConPollerThread and API-request RConService calls share the same UDP socket.
|
||||
- Used by: `RConPollerThread`, `RConService` (for admin commands from UI)
|
||||
|
||||
### Config Generator
|
||||
- Takes `ServerConfig` Pydantic model from DB
|
||||
- Renders `server.cfg`, `basic.cfg`, `*.Arma3Profile` using a **structured builder** (NOT f-strings — prevents config injection)
|
||||
- Escapes double quotes and newlines in all user-supplied string values
|
||||
- Writes files to `servers/{server_id}/` directory
|
||||
- `server.Arma3Profile` written to `servers/{server_id}/server/` (Arma 3 reads from the `-name` subdirectory)
|
||||
|
||||
### SQLite DAL
|
||||
- Sync reads/writes using SQLAlchemy Core (not ORM — simpler for this use case)
|
||||
- Thread-safe via SQLAlchemy's connection pooling
|
||||
- One `languard.db` file at project root
|
||||
- **PRAGMA busy_timeout=5000** — prevents "database is locked" errors under concurrent thread writes
|
||||
- Thread-local connections via `get_thread_db()` — one connection per background thread
|
||||
|
||||
---
|
||||
|
||||
## Data Flow: Start Server
|
||||
|
||||
```
|
||||
Frontend → POST /api/servers/{id}/start
|
||||
→ ServerService.start(server_id)
|
||||
├── Load ServerConfig from DB
|
||||
├── ConfigGenerator.write_configs(server_id, config)
|
||||
│ ├── server.cfg → servers/{id}/server.cfg
|
||||
│ ├── basic.cfg → servers/{id}/basic.cfg
|
||||
│ ├── server.Arma3Profile → servers/{id}/server/server.Arma3Profile
|
||||
│ └── beserver.cfg → servers/{id}/battleye/beserver.cfg
|
||||
├── ProcessManager.start(server_id, exe_path, args, cwd=servers/{id}/)
|
||||
├── DB: update server.status = "starting"
|
||||
├── Spawn ProcessMonitorThread(server_id)
|
||||
├── Spawn LogTailThread(server_id) — tails servers/{id}/server/arma3server_*.rpt
|
||||
├── Spawn MetricsCollectorThread(server_id)
|
||||
├── Spawn RConPollerThread(server_id) [after 30s delay for server startup]
|
||||
└── BroadcastThread pushes status update to WS clients
|
||||
```
|
||||
|
||||
## Data Flow: Real-time Logs
|
||||
|
||||
```
|
||||
arma3server.exe writes servers/{id}/server/arma3server_*.rpt
|
||||
→ LogTailThread reads new lines (recursive glob for *.rpt in profile dir)
|
||||
→ LogRepository.insert(server_id, line, timestamp)
|
||||
→ BroadcastQueue.put({type: "log", server_id, line, timestamp})
|
||||
→ BroadcastThread sends to all WS subscribers for this server
|
||||
→ React frontend appends to log viewer
|
||||
```
|
||||
|
||||
## Data Flow: Player List
|
||||
|
||||
```
|
||||
RConPollerThread (every 10s)
|
||||
→ RConClient.send("players")
|
||||
→ Parse response: [{id, name, guid, ping, verified}]
|
||||
→ PlayerRepository.upsert_all(server_id, players)
|
||||
→ BroadcastQueue.put({type: "players", server_id, players})
|
||||
→ React frontend updates player list
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Model
|
||||
|
||||
- All API routes (except `POST /api/auth/login`) require a valid **JWT Bearer token**
|
||||
- JWT contains: `user_id`, `username`, `role` (`admin` | `viewer`)
|
||||
- `viewer` role: read-only (GET endpoints, WebSocket)
|
||||
- `admin` role: all operations
|
||||
- CORS configured to accept only the frontend origin
|
||||
- Passwords hashed with **bcrypt** (cost factor 12)
|
||||
- `serverCommandPassword` and `passwordAdmin` stored encrypted in SQLite (AES-256 via `cryptography` library, key from env)
|
||||
- **Port conflict validation** at server creation and start: checks game_port through game_port+4 (game, Steam query, Steam master, Steam auth, RCon) against all existing servers
|
||||
- **ban.txt sync**: bans table is source of truth for UI; on ban add/delete via API, also write to `battleye/ban.txt`; on startup, read `ban.txt` and upsert into DB. Without this sync, DB-only bans are not enforced by BattlEye.
|
||||
- Generated config files containing passwords (server.cfg, beserver.cfg) have restrictive file permissions (0600 on Unix, restricted ACL on Windows)
|
||||
- Input sanitization on all string fields before config generation — no shell injection or config directive injection
|
||||
|
||||
---
|
||||
|
||||
## Configuration (Environment Variables)
|
||||
|
||||
```env
|
||||
LANGUARD_SECRET_KEY=<jwt-signing-secret>
|
||||
LANGUARD_ENCRYPTION_KEY=<Fernet-base64-key — generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())">
|
||||
LANGUARD_DB_PATH=./languard.db
|
||||
LANGUARD_SERVERS_DIR=./servers
|
||||
LANGUARD_ARMA_EXE=C:/Arma3Server/arma3server_x64.exe
|
||||
LANGUARD_HOST=0.0.0.0
|
||||
LANGUARD_PORT=8000
|
||||
LANGUARD_CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||
LANGUARD_LOG_RETENTION_DAYS=7
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Directory Layout
|
||||
|
||||
```
|
||||
languard-server-manager/
|
||||
├── backend/
|
||||
│ ├── main.py # FastAPI app factory
|
||||
│ ├── config.py # Settings from env
|
||||
│ ├── database.py # SQLAlchemy engine + session
|
||||
│ ├── auth/
|
||||
│ │ ├── router.py
|
||||
│ │ ├── service.py
|
||||
│ │ └── schemas.py
|
||||
│ ├── servers/
|
||||
│ │ ├── router.py # REST endpoints for servers
|
||||
│ │ ├── service.py # ServerService
|
||||
│ │ ├── process_manager.py # ProcessManager singleton
|
||||
│ │ ├── config_generator.py # server.cfg / basic.cfg / beserver.cfg writer
|
||||
│ │ └── schemas.py # Pydantic schemas
|
||||
│ ├── rcon/
|
||||
│ │ ├── client.py # BattlEye RCon UDP client
|
||||
│ │ └── service.py # RConService
|
||||
│ ├── players/
|
||||
│ │ ├── router.py
|
||||
│ │ ├── service.py
|
||||
│ │ └── schemas.py
|
||||
│ ├── missions/
|
||||
│ │ ├── router.py
|
||||
│ │ └── service.py
|
||||
│ ├── mods/
|
||||
│ │ ├── router.py
|
||||
│ │ └── service.py
|
||||
│ ├── logs/
|
||||
│ │ ├── router.py
|
||||
│ │ └── service.py
|
||||
│ ├── metrics/
|
||||
│ │ ├── router.py
|
||||
│ │ └── service.py
|
||||
│ ├── websocket/
|
||||
│ │ ├── router.py # WS connection handler
|
||||
│ │ ├── manager.py # ConnectionManager (per-server subscriptions)
|
||||
│ │ └── broadcaster.py # BroadcastThread + queue
|
||||
│ ├── threads/
|
||||
│ │ ├── process_monitor.py # ProcessMonitorThread
|
||||
│ │ ├── log_tail.py # LogTailThread
|
||||
│ │ ├── metrics_collector.py # MetricsCollectorThread
|
||||
│ │ └── rcon_poller.py # RConPollerThread
|
||||
│ ├── system/
|
||||
│ │ └── router.py # GET /system/status, GET /system/health
|
||||
│ ├── dal/
|
||||
│ │ ├── server_repository.py
|
||||
│ │ ├── config_repository.py
|
||||
│ │ ├── player_repository.py
|
||||
│ │ ├── log_repository.py
|
||||
│ │ ├── metrics_repository.py
|
||||
│ │ ├── mission_repository.py
|
||||
│ │ ├── mod_repository.py
|
||||
│ │ ├── ban_repository.py
|
||||
│ │ └── event_repository.py
|
||||
│ └── migrations/
|
||||
│ └── 001_initial_schema.sql
|
||||
├── servers/ # Runtime data per server instance
|
||||
│ └── {server_id}/
|
||||
│ ├── server.cfg
|
||||
│ ├── basic.cfg
|
||||
│ ├── server/ # Arma 3 profile dir (matches -name=server)
|
||||
│ │ ├── server.Arma3Profile
|
||||
│ │ └── arma3server_*.rpt # Timestamped RPT logs
|
||||
│ ├── battleye/
|
||||
│ │ └── beserver.cfg # BattlEye RCon config (generated on start)
|
||||
│ └── mpmissions/
|
||||
├── frontend/ # React app (separate repo or subfolder)
|
||||
├── requirements.txt
|
||||
├── .env.example
|
||||
├── ARCHITECTURE.md
|
||||
├── DATABASE.md
|
||||
├── API.md
|
||||
├── MODULES.md
|
||||
├── THREADING.md
|
||||
└── IMPLEMENTATION_PLAN.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
| Decision | Choice | Reason |
|
||||
|----------|--------|--------|
|
||||
| Sync vs async DB | **Sync SQLAlchemy only** | All DB access is synchronous; background threads are non-async; `get_thread_db()` provides thread-local connections; no aiosqlite dependency |
|
||||
| ORM vs Core | **SQLAlchemy Core** | Simpler SQL control, less magic for embedded use case |
|
||||
| WebSocket auth | JWT in query param on connect | Browser WS API doesn't support headers; query param `?token=...` |
|
||||
| Process ownership | **ProcessManager singleton** | Single source of truth; prevents duplicate launches |
|
||||
| Log storage | **DB + rolling file** | DB for fast queries/streaming; raw .rpt preserved on disk |
|
||||
| Config files | **Regenerate on each start** | Always fresh from DB; no sync drift between DB and filesystem; **structured builder** (not f-strings) prevents config injection |
|
||||
| RCon port convention | **User-configurable** | BattlEye RCon port is set in `beserver.cfg` (inside `battleye/` dir). Default suggestion: game port + 4 (e.g., 2302 → 2306). Must not conflict with game (2302), Steam query (2303), VON (2304), or Steam auth (2305) ports. **Note:** RCon config changes require server restart — BattlEye reads beserver.cfg only at startup. |
|
||||
Reference in New Issue
Block a user