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:
Tran G. (Revernomad) Khoa
2026-04-16 13:54:30 +07:00
commit 473f585391
6 changed files with 3595 additions and 0 deletions

586
DATABASE.md Normal file
View File

@@ -0,0 +1,586 @@
# Languard Server Manager — Database Design
## Engine
- **SQLite** via `SQLAlchemy Core` (sync for all access — routes and threads)
- File: `languard.db` at project root (configurable via `LANGUARD_DB_PATH`)
- WAL mode enabled: `PRAGMA journal_mode=WAL` — allows concurrent reads during writes
- Foreign keys enabled: `PRAGMA foreign_keys=ON`
- Busy timeout: `PRAGMA busy_timeout=5000` — prevents "database is locked" errors under concurrent thread writes
---
## Schema
### Table: `users`
Stores web UI admin accounts.
```sql
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL, -- bcrypt hash
role TEXT NOT NULL DEFAULT 'viewer', -- 'admin' | 'viewer'
CHECK (role IN ('admin', 'viewer')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_login TEXT
);
```
---
### Table: `servers`
One row per managed Arma 3 server instance.
```sql
CREATE TABLE servers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, -- display name in UI
description TEXT,
status TEXT NOT NULL DEFAULT 'stopped',
-- status values: 'stopped' | 'starting' | 'running' | 'stopping' | 'crashed' | 'error'
CHECK (status IN ('stopped', 'starting', 'running', 'stopping', 'crashed', 'error')),
CHECK (game_port BETWEEN 1024 AND 65535),
CHECK (rcon_port BETWEEN 1024 AND 65535),
-- Process info
pid INTEGER, -- OS process ID when running
exe_path TEXT NOT NULL, -- path to arma3server_x64.exe
started_at TEXT, -- ISO datetime
stopped_at TEXT,
-- Network
game_port INTEGER NOT NULL DEFAULT 2302,
rcon_port INTEGER NOT NULL DEFAULT 2306, -- user-configurable; written to battleye/beserver.cfg
steam_query_port INTEGER GENERATED ALWAYS AS (game_port + 1) VIRTUAL, -- convention, not enforced by engine
-- Auto-management
auto_restart INTEGER NOT NULL DEFAULT 0, -- 1 = restart on crash
max_restarts INTEGER NOT NULL DEFAULT 3, -- within restart_window_seconds
restart_window_seconds INTEGER NOT NULL DEFAULT 300,
restart_count INTEGER NOT NULL DEFAULT 0,
last_restart_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_servers_status ON servers(status);
CREATE INDEX idx_servers_game_port ON servers(game_port);
CREATE INDEX idx_servers_rcon_port ON servers(rcon_port);
```
---
### Table: `server_configs`
Stores all parameters for generating `server.cfg`. One row per server.
```sql
CREATE TABLE server_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL UNIQUE REFERENCES servers(id) ON DELETE CASCADE,
-- Basic identity
hostname TEXT NOT NULL DEFAULT 'My Arma 3 Server',
password TEXT, -- join password (encrypted at app layer via Fernet)
password_admin TEXT NOT NULL, -- encrypted (no default — must be set on creation)
server_command_password TEXT, -- encrypted
-- Players
max_players INTEGER NOT NULL DEFAULT 40,
kick_duplicate INTEGER NOT NULL DEFAULT 1,
persistent INTEGER NOT NULL DEFAULT 1,
-- Voting
vote_threshold REAL NOT NULL DEFAULT 0.33,
vote_mission_players INTEGER NOT NULL DEFAULT 1,
vote_timeout INTEGER NOT NULL DEFAULT 60, -- seconds
role_timeout INTEGER NOT NULL DEFAULT 90,
briefing_timeout INTEGER NOT NULL DEFAULT 60,
debriefing_timeout INTEGER NOT NULL DEFAULT 45,
lobby_idle_timeout INTEGER NOT NULL DEFAULT 300,
-- Voice
disable_von INTEGER NOT NULL DEFAULT 0,
von_codec INTEGER NOT NULL DEFAULT 1, -- 1 = OPUS
CHECK (von_codec IN (0, 1)),
von_codec_quality INTEGER NOT NULL DEFAULT 20, -- 1-30
-- Network quality kick thresholds
max_ping INTEGER NOT NULL DEFAULT 250,
max_packet_loss INTEGER NOT NULL DEFAULT 50,
max_desync INTEGER NOT NULL DEFAULT 200,
disconnect_timeout INTEGER NOT NULL DEFAULT 15,
kick_on_ping INTEGER NOT NULL DEFAULT 1,
kick_on_packet_loss INTEGER NOT NULL DEFAULT 1,
kick_on_desync INTEGER NOT NULL DEFAULT 1,
kick_on_timeout INTEGER NOT NULL DEFAULT 1,
-- Security
battleye INTEGER NOT NULL DEFAULT 1,
verify_signatures INTEGER NOT NULL DEFAULT 2, -- 0 | 1 | 2 (1 = check but don't kick)
allowed_file_patching INTEGER NOT NULL DEFAULT 0, -- 0 | 1 | 2
-- Difficulty
forced_difficulty TEXT NOT NULL DEFAULT 'Regular', -- Recruit | Regular | Veteran | Custom
-- Misc
timestamp_format TEXT NOT NULL DEFAULT 'short', -- none | short | full
auto_select_mission INTEGER NOT NULL DEFAULT 0,
random_mission_order INTEGER NOT NULL DEFAULT 0,
missions_to_restart INTEGER NOT NULL DEFAULT 0,
missions_to_shutdown INTEGER NOT NULL DEFAULT 0,
log_file TEXT NOT NULL DEFAULT 'server_console.log',
skip_lobby INTEGER NOT NULL DEFAULT 0,
drawing_in_map INTEGER NOT NULL DEFAULT 1,
upnp INTEGER NOT NULL DEFAULT 0,
loopback INTEGER NOT NULL DEFAULT 0,
statistics_enabled INTEGER NOT NULL DEFAULT 1,
force_rotor_lib INTEGER NOT NULL DEFAULT 0, -- 0=player, 1=AFM, 2=SFM
CHECK (force_rotor_lib IN (0, 1, 2)),
required_build INTEGER NOT NULL DEFAULT 0,
steam_protocol_max_data_size INTEGER NOT NULL DEFAULT 1024,
-- MOTD
motd_lines TEXT NOT NULL DEFAULT '[]', -- JSON array of strings
motd_interval REAL NOT NULL DEFAULT 5.0,
-- Event scripts
on_user_connected TEXT DEFAULT '',
on_user_disconnected TEXT DEFAULT '',
on_unsigned_data TEXT DEFAULT 'kick (_this select 0)',
on_hacked_data TEXT DEFAULT 'kick (_this select 0)',
double_id_detected TEXT DEFAULT '',
-- Headless clients (JSON arrays)
headless_clients TEXT NOT NULL DEFAULT '[]', -- e.g. '["127.0.0.1"]'
local_clients TEXT NOT NULL DEFAULT '[]',
-- Admin UIDs whitelist
admin_uids TEXT NOT NULL DEFAULT '[]', -- JSON array of Steam UIDs
-- File extension whitelists (JSON arrays)
allowed_load_extensions TEXT NOT NULL DEFAULT '["hpp","sqs","sqf","fsm","cpp","paa","txt","xml","inc","ext","sqm","ods","fxy","lip","csv","kb","bik","bikb","html","htm","biedi"]',
allowed_preprocess_extensions TEXT NOT NULL DEFAULT '["hpp","sqs","sqf","fsm","cpp","paa","txt","xml","inc","ext","sqm","ods","fxy","lip","csv","kb","bik","bikb","html","htm","biedi"]',
allowed_html_extensions TEXT NOT NULL DEFAULT '["htm","html","xml","txt"]',
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
CHECK (verify_signatures IN (0, 1, 2)),
CHECK (allowed_file_patching IN (0, 1, 2)),
CHECK (von_codec_quality BETWEEN 1 AND 30),
CHECK (forced_difficulty IN ('Recruit', 'Regular', 'Veteran', 'Custom')),
CHECK (vote_threshold >= 0.0 AND vote_threshold <= 1.0),
CHECK (max_players > 0)
);
```
---
### Table: `basic_configs`
Stores `basic.cfg` (bandwidth) settings. One row per server.
```sql
CREATE TABLE basic_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL UNIQUE REFERENCES servers(id) ON DELETE CASCADE,
min_bandwidth INTEGER NOT NULL DEFAULT 800000,
max_bandwidth INTEGER NOT NULL DEFAULT 25000000,
max_msg_send INTEGER NOT NULL DEFAULT 384, -- default 128; higher = desync risk
max_size_guaranteed INTEGER NOT NULL DEFAULT 512,
max_size_non_guaranteed INTEGER NOT NULL DEFAULT 256,
min_error_to_send REAL NOT NULL DEFAULT 0.003,
max_custom_file_size INTEGER NOT NULL DEFAULT 100000,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```
---
### Table: `server_profiles`
Stores `server.Arma3Profile` difficulty settings. One row per server.
```sql
CREATE TABLE server_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL UNIQUE REFERENCES servers(id) ON DELETE CASCADE,
-- Custom difficulty options (all 0/1 or 0/1/2)
reduced_damage INTEGER NOT NULL DEFAULT 0,
group_indicators INTEGER NOT NULL DEFAULT 0,
friendly_tags INTEGER NOT NULL DEFAULT 0,
enemy_tags INTEGER NOT NULL DEFAULT 0,
detected_mines INTEGER NOT NULL DEFAULT 0,
commands INTEGER NOT NULL DEFAULT 1,
waypoints INTEGER NOT NULL DEFAULT 1,
tactical_ping INTEGER NOT NULL DEFAULT 0,
weapon_info INTEGER NOT NULL DEFAULT 2,
stance_indicator INTEGER NOT NULL DEFAULT 2,
stamina_bar INTEGER NOT NULL DEFAULT 0,
weapon_crosshair INTEGER NOT NULL DEFAULT 0,
vision_aid INTEGER NOT NULL DEFAULT 0,
third_person_view INTEGER NOT NULL DEFAULT 0,
camera_shake INTEGER NOT NULL DEFAULT 1,
score_table INTEGER NOT NULL DEFAULT 1,
death_messages INTEGER NOT NULL DEFAULT 1,
von_id INTEGER NOT NULL DEFAULT 1,
map_content_friendly INTEGER NOT NULL DEFAULT 0,
map_content_enemy INTEGER NOT NULL DEFAULT 0,
map_content_mines INTEGER NOT NULL DEFAULT 0,
auto_report INTEGER NOT NULL DEFAULT 0,
multiple_saves INTEGER NOT NULL DEFAULT 0,
-- AI level
ai_level_preset INTEGER NOT NULL DEFAULT 3, -- 0=Low,1=Normal,2=High,3=Custom
skill_ai REAL NOT NULL DEFAULT 0.5,
precision_ai REAL NOT NULL DEFAULT 0.5,
CHECK (ai_level_preset BETWEEN 0 AND 3),
CHECK (skill_ai BETWEEN 0.0 AND 1.0),
CHECK (precision_ai BETWEEN 0.0 AND 1.0),
CHECK (group_indicators BETWEEN 0 AND 2),
CHECK (weapon_info BETWEEN 0 AND 2),
CHECK (stance_indicator BETWEEN 0 AND 2),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```
---
### Table: `launch_params`
Extra command-line parameters added to the server launch command. One row per server.
```sql
CREATE TABLE launch_params (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL UNIQUE REFERENCES servers(id) ON DELETE CASCADE,
world TEXT NOT NULL DEFAULT 'empty',
extra_params TEXT NOT NULL DEFAULT '', -- raw extra params string
limit_fps INTEGER NOT NULL DEFAULT 50,
auto_init INTEGER NOT NULL DEFAULT 0,
load_mission_to_memory INTEGER NOT NULL DEFAULT 0,
bandwidth_alg INTEGER, -- NULL | 2
CHECK (bandwidth_alg IS NULL OR bandwidth_alg = 2),
enable_ht INTEGER NOT NULL DEFAULT 0,
huge_pages INTEGER NOT NULL DEFAULT 0,
cpu_count INTEGER, -- NULL = auto
ex_threads INTEGER NOT NULL DEFAULT 7,
max_mem INTEGER, -- NULL = auto
no_logs INTEGER NOT NULL DEFAULT 0,
netlog INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```
---
### Table: `mods`
Registered mods. Many-to-many with servers.
```sql
CREATE TABLE mods (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
folder_path TEXT NOT NULL UNIQUE, -- absolute or relative path
workshop_id TEXT, -- Steam Workshop ID if applicable
description TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE server_mods (
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
mod_id INTEGER NOT NULL REFERENCES mods(id) ON DELETE CASCADE,
is_server_mod INTEGER NOT NULL DEFAULT 0, -- 1 = -serverMod (not broadcast to clients)
sort_order INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (server_id, mod_id)
);
CREATE INDEX idx_server_mods_server ON server_mods(server_id);
```
---
### Table: `missions`
Mission PBO files tracked per server.
```sql
CREATE TABLE missions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
filename TEXT NOT NULL, -- e.g. "MyMission.Altis.pbo"
mission_name TEXT NOT NULL, -- e.g. "MyMission.Altis"
terrain TEXT NOT NULL, -- e.g. "Altis"
file_size INTEGER, -- bytes
uploaded_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (server_id, filename)
);
CREATE INDEX idx_missions_server ON missions(server_id);
```
---
### Table: `mission_rotation`
Ordered mission cycle for a server.
```sql
CREATE TABLE mission_rotation (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
mission_id INTEGER NOT NULL REFERENCES missions(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
difficulty TEXT NOT NULL DEFAULT 'Regular',
CHECK (difficulty IN ('Recruit', 'Regular', 'Veteran', 'Custom')),
params_json TEXT NOT NULL DEFAULT '{}', -- mission params override as JSON
UNIQUE (server_id, sort_order)
);
CREATE INDEX idx_mission_rotation_server ON mission_rotation(server_id);
```
---
### Table: `players`
Currently connected players (live state, refreshed by RConPollerThread).
```sql
CREATE TABLE players (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
player_num INTEGER NOT NULL, -- BE player# (slot number)
name TEXT NOT NULL,
guid TEXT, -- BattlEye GUID
steam_uid TEXT,
ip TEXT,
ping INTEGER,
verified INTEGER NOT NULL DEFAULT 0, -- 1 = signature verified
joined_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (server_id, player_num)
);
CREATE INDEX idx_players_server ON players(server_id);
```
---
### Table: `player_history`
Historical record of connections. Inserted when player disconnects.
```sql
CREATE TABLE player_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
name TEXT NOT NULL,
guid TEXT,
steam_uid TEXT,
ip TEXT,
joined_at TEXT NOT NULL,
left_at TEXT NOT NULL DEFAULT (datetime('now')),
session_duration_seconds INTEGER
);
CREATE INDEX idx_player_history_server ON player_history(server_id);
CREATE INDEX idx_player_history_steam ON player_history(steam_uid);
```
### Player History Retention Cleanup (run daily via APScheduler, keep 90 days)
```sql
DELETE FROM player_history
WHERE left_at < datetime('now', '-90 days');
```
---
### Table: `bans`
Local ban records (source of truth for the UI). **Must sync bidirectionally with `battleye/ban.txt`** — BattlEye reads only from ban.txt. On API ban add/delete: also write to ban.txt. On startup: read ban.txt and upsert into DB.
**ban.txt format** (one entry per line):
```
GUID|IP timestamp reason
```
Example: `a1b2c3d4e5f6|192.168.1.1 1713260000 Cheating`
**Sync caveats:** ban.txt does not store `banned_by`, `expires_at`, or `is_active`. Timed bans are represented by a future timestamp (not minutes); permanent bans have timestamp `0`. On startup, `banned_by` is set to `'ban.txt'` for entries read from file. Deactivated bans (`is_active=0`) are not written to ban.txt.
```sql
CREATE TABLE bans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
guid TEXT,
steam_uid TEXT,
name TEXT,
reason TEXT,
banned_by TEXT, -- admin username
banned_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT, -- NULL = permanent
is_active INTEGER NOT NULL DEFAULT 1,
CHECK (is_active IN (0, 1))
);
CREATE INDEX idx_bans_server ON bans(server_id);
CREATE INDEX idx_bans_guid ON bans(guid);
CREATE INDEX idx_bans_steam_uid ON bans(steam_uid);
CREATE INDEX idx_bans_active ON bans(is_active);
```
---
### Table: `logs`
Parsed RPT log lines (rolling retention, default 7 days).
```sql
CREATE TABLE logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
timestamp TEXT NOT NULL,
level TEXT NOT NULL DEFAULT 'info', -- 'info' | 'warning' | 'error'
CHECK (level IN ('info', 'warning', 'error')),
message TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_logs_server_ts ON logs(server_id, timestamp);
CREATE INDEX idx_logs_level ON logs(level); -- for ?level= filter
CREATE INDEX idx_logs_created ON logs(created_at); -- for retention cleanup
```
---
### Table: `metrics`
Time-series CPU/RAM/player count snapshots.
```sql
CREATE TABLE metrics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
cpu_percent REAL,
ram_mb REAL,
player_count INTEGER
);
CREATE INDEX idx_metrics_server_ts ON metrics(server_id, timestamp);
```
---
### Table: `server_events`
Audit trail of all significant events (start, stop, crash, restart, admin actions).
```sql
CREATE TABLE server_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
event_type TEXT NOT NULL,
-- event_type values:
-- 'started' | 'stopped' | 'crashed' | 'restarted' | 'config_updated'
-- 'player_kicked' | 'player_banned' | 'mission_changed' | 'admin_login'
-- 'rcon_command' | 'auto_restarted'
actor TEXT, -- username or 'system'
detail TEXT, -- JSON with event-specific data
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_events_server ON server_events(server_id, created_at);
```
---
### Table: `rcon_configs`
BattlEye RCon credentials per server.
```sql
CREATE TABLE rcon_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL UNIQUE REFERENCES servers(id) ON DELETE CASCADE,
rcon_password TEXT NOT NULL, -- encrypted at app layer
max_ping INTEGER NOT NULL DEFAULT 200,
enabled INTEGER NOT NULL DEFAULT 1,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```
---
## Relationships Diagram
```
users (1) ──────────────────────────────────── (many) server_events.actor
servers (1) ──┬── (1) server_configs
├── (1) basic_configs
├── (1) server_profiles
├── (1) launch_params
├── (1) rcon_configs
├── (many) server_mods ──── (many) mods
├── (many) missions
├── (many) mission_rotation → missions
├── (many) players
├── (many) player_history
├── (many) bans
├── (many) logs
├── (many) metrics
└── (many) server_events
```
---
## Maintenance Queries
### Log Retention Cleanup (run daily via APScheduler)
```sql
DELETE FROM logs
WHERE created_at < datetime('now', '-7 days');
```
### Metrics Retention Cleanup (keep 30 days)
```sql
DELETE FROM metrics
WHERE timestamp < datetime('now', '-30 days');
```
### Clear disconnected players on server stop
```sql
DELETE FROM players WHERE server_id = ?;
```
### Vacuum (run weekly)
```sql
VACUUM;
```
---
## Migration Strategy
- Migrations are plain `.sql` files in `backend/migrations/`
- Naming: `001_initial_schema.sql`, `002_add_bans.sql`, etc.
- Tracked in a `schema_migrations` table:
```sql
CREATE TABLE schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```
- Applied automatically at app startup by `database.py:run_migrations()`