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

18 KiB

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)

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.