Files
languard-servers-manager/ARCHITECTURE.md
Khoa (Revenovich) Tran Gia a60b94c20c fix: address santa-loop review findings (round 1)
Update remaining old-name references in body text:
- ARCHITECTURE.md:219 directory layout: languard-server-manager/ → languard-servers-manager/
- IMPLEMENTATION_PLAN.md:405 setup instructions: cd languard-server-manager → cd languard-servers-manager
2026-04-16 14:04:57 +07:00

310 lines
18 KiB
Markdown

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