Files
languard-servers-manager/DATABASE.md
Khoa (Revenovich) Tran Gia 2c72e45b5f fix: address santa-loop review findings (round 2)
Stage and commit remaining 4 title renames that were left as
unstaged working-tree changes:
- API.md: Languard Server Manager → Languard Servers Manager
- DATABASE.md: Languard Server Manager → Languard Servers Manager
- MODULES.md: Languard Server Manager → Languard Servers Manager
- THREADING.md: Languard Server Manager → Languard Servers Manager
2026-04-16 14:08:44 +07:00

20 KiB

Languard Servers 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.

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.

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.

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.

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.

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.

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.

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.

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.

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).

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.

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)

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.

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).

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.

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).

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.

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)

DELETE FROM logs
WHERE created_at < datetime('now', '-7 days');

Metrics Retention Cleanup (keep 30 days)

DELETE FROM metrics
WHERE timestamp < datetime('now', '-30 days');

Clear disconnected players on server stop

DELETE FROM players WHERE server_id = ?;

Vacuum (run weekly)

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:
    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()