Compare commits

...

27 Commits

Author SHA1 Message Date
Tran G. (Revernomad) Khoa
6e9a37ef00 fix: use absolute paths in Arma 3 launch args to survive cwd change
Arma 3 changes its own working directory to the exe folder on startup,
so relative paths like -config=server.cfg resolved against A3Master/
instead of the server data dir. Configs were never found, and profile/
battleye dirs pointed at the wrong location (confirmed via RPT location
in A3Master/server/ instead of the data dir).

build_launch_args() now accepts an optional server_dir: Path argument.
When provided, all four path args (-config, -cfg, -profiles, -bepath)
use absolute paths. Service passes server_dir at the call site.
2026-04-20 11:13:18 +07:00
Tran G. (Revernomad) Khoa
d45345a094 feat: fix mods tab, add client/server split, and scaffold server dirs
Mods tab bug fixes:
- mod_manager: fix wrong kwargs in set_enabled_mods, fix scan dir to use
  mods/ subdir instead of server root, migrate old string-list format to
  dict format on read
- service: replace dead server_mods SQL JOIN with get_enabled_mods()
  call through the mod_manager capability; pass is_server_mod to
  build_mod_args
- mods_router: accept list[EnabledModEntry] objects (name + is_server_mod)
  instead of bare strings

Client/server mod split:
- Mods now stored as list[{"name": str, "is_server_mod": bool}]; old
  string-list format auto-migrated on read
- is_server_mod=true routes to -serverMod= arg; false to -mod= arg
- ModList UI: amber Client/Server badge in selected pane; toggle button
  in split-pane selector

Directory scaffold:
- process_config: adds "mods" to dir layout; provides get_dir_readme()
  with per-directory README.txt content
- file_utils: ensure_server_dirs() gains readme_provider kwarg; writes
  README.txt idempotently if absent
- service.create_server: passes readme_provider via hasattr probe
- main.py startup: backfills all existing servers with correct subdirs
  and README files (idempotent)

Docs: API.md and FRONTEND.md updated for new mod schema and types
Test __init__.py files added for pytest discovery
2026-04-20 10:54:56 +07:00
Tran G. (Revernomad) Khoa
fa95587567 docs: expand README quick start with full dev/debug setup
- Step-by-step backend setup: venv, secret key generation (openssl +
  Fernet), .env configuration with annotated minimum values
- VS Code launch.json snippets for backend (debugpy/uvicorn) and
  frontend (Chrome with source maps)
- Vite proxy detail (/api → :8000, /ws → ws://:8000)
- Backend pytest commands alongside existing frontend test section
- Playwright headed/UI mode instructions for E2E debugging
- Updated unit test count to 173; removed stale E2E count
- CLAUDE.md quick start trimmed to point at README for full setup
2026-04-20 10:53:14 +07:00
Tran G. (Revernomad) Khoa
64b35a7aaf feat: basic/advanced config split with profile section gate
- FieldSchema gains optional `advanced` boolean flag
- ConfigEditor reads schema at top level and passes sectionSchema as prop
- ConfigSectionForm filters out advanced fields by default; "Show advanced"
  toggle reveals them without entering edit mode
- Profile (Difficulty) section shows an inline banner when
  forced_difficulty is not "Custom", guiding users to the right setting
- All 173 frontend tests pass; tsc clean
2026-04-20 10:49:08 +07:00
Tran G. (Revernomad) Khoa
03ea623536 test: add RED test for config schema advanced flags (TDD checkpoint)
Adds test_config_schema.py verifying every visible field has an
explicit 'advanced' bool, basic fields are advanced=False, and
sampled advanced fields are advanced=True.
2026-04-20 10:48:59 +07:00
Tran G. (Revernomad) Khoa
3025c2021c feat: per-mission params, default config values, and mods bug docs
- Add per-mission params to rotation (MissionRotationItem.params); falls
  back to default_mission_params, then omits entirely if both empty
- Add key-value widget to ConfigEditor for default_mission_params field
- Add MissionParamsEditor component for editing param key/value/type rows
- Bump config schema to 1.1.0 with migration from 1.0.0
- Add normalize_section() to Protocol and ArmaConfigGenerator for
  read-time backfill of old stored rows
- Set Arma3 BasicConfig and ProfileConfig defaults from basic.cfg /
  Administrator.Arma3Profile
- Document 3 known Mods tab bugs in CLAUDE.md for next-session fix
2026-04-19 19:28:46 +07:00
Tran G. (Revernomad) Khoa
bf09a6ed1c fix: fix Arma 3 log discovery and improve config editor UX
- Fix logfiles_router and thread_registry to resolve .rpt log files
  from Path(server["exe_path"]).parent/server/ instead of the languard
  data dir, which never contained log files — log list and live tail
  both now work correctly
- Rewrite get_ui_schema() in config_generator to cover all ~80 fields
  across all 5 sections (server/basic/profile/launch/rcon) with proper
  toggle/select/number/password/tag-list/hidden widgets and labels;
  missions field is hidden (managed by Missions tab)
- Add formatSelectDisplay() to ConfigEditor so select fields show
  descriptive text (e.g. "0 - Never") instead of raw numbers in view mode
- Add ToggleDisplay for boolean fields (Enabled/Disabled with indicator dot)
- Add section tab labels and descriptions to ConfigEditor
- Add MissionList UX hints and dynamic Add/In Rotation button labels
- Add "hidden" to FieldSchema widget union type
- Update API.md, ARCHITECTURE.md, CLAUDE.md, FRONTEND.md, MODULES.md,
  THREADING.md to document log path fix and schema coverage
2026-04-18 15:56:04 +07:00
Tran G. (Revernomad) Khoa
b7d670a91c test: add E2E server-detail tests and fill coverage gaps to 83.9%
- Add Playwright E2E for all 5 UX phases (Config/Missions/Mods/Players/Logs)
  with ServerDetailPage POM and fully mocked API routes
- Add logger.test.ts: dynamic module re-import pattern for level-gating tests
- Add useUpdateServer + useKillServer tests to useServers.test.tsx
- Add CreateServerPage edge cases: non-admin gate, API error handling, step 2 render
- Add auth.store rehydration and null-branch coverage tests
- Update FRONTEND.md, MODULES.md, API.md, README.md to reflect current state
  (167 unit tests, 38 E2E tests, 9 useServers hooks, all UX phases implemented)
2026-04-18 10:24:03 +07:00
Tran G. (Revernomad) Khoa
8bac29fb68 docs: mark phases 3-5 complete, update API/FRONTEND/MODULES/CLAUDE.md 2026-04-17 20:50:19 +07:00
Tran G. (Revernomad) Khoa
5a62d21def feat: implement phases 3-5 of Arma 3 UX enhancement plan
Phase 3 - Mod display names + split-pane selector:
- Parse mod.cpp/meta.cpp for display_name and workshop_id
- Rewrite ModList as two-pane available/selected interface

Phase 4 - Player kick/ban from Players tab:
- Add get_by_slot() to PlayerRepository
- Add get_rcon_client() class method to ThreadRegistry
- Add /players/{slot_id}/kick and /ban endpoints
- Rewrite PlayerTable with kick/ban modals and ban presets

Phase 5 - Historical log file browser:
- Add list_log_files() and get_log_file_path() to RPTParser
- Add logfiles_router with GET/download/DELETE endpoints
- Update LogViewer with collapsible log files section (download + delete)
2026-04-17 20:47:37 +07:00
Tran G. (Revernomad) Khoa
fe3bd81cae docs: update API.md, FRONTEND.md, MODULES.md, CLAUDE.md for Phase 1 and 2 completion
- API.md: add GET /config/schema endpoint docs; add GET|PUT /missions/rotation endpoints;
  fix mission response shape (name/filename/size_bytes/terrain); mark Phase 1+2 as done
- FRONTEND.md: add TagListEditor, useServerConfigSchema, useServerMissionRotation,
  useUpdateMissionRotation; update Mission/Mod type notes; remove planned hooks now live
- MODULES.md: update config_generator and missions_router descriptions
- CLAUDE.md: mark Phase 1 and 2 as Done
2026-04-17 20:35:39 +07:00
Tran G. (Revernomad) Khoa
4aae08420b feat: Phase 2 — Mission rotation management + multi-file upload
- Backend: add terrain field to Arma3MissionManager.list_missions()
- Backend: add missions field to ServerConfig Pydantic model
- Backend: add GET /missions/rotation and PUT /missions/rotation endpoints
- Frontend: Mission type gains terrain field; new MissionRotationEntry type
- Frontend: useServerMissionRotation and useUpdateMissionRotation hooks
- Frontend: useUploadMission updated to accept File[] with sequential upload
- Frontend: MissionList redesigned with Available Missions + Mission Rotation sections
- Frontend: per-file upload progress tracking, terrain badges, difficulty select
- Tests: 5 new tests; fixed existing useUploadMission test for File[] API; 141 pass
2026-04-17 20:33:04 +07:00
Tran G. (Revernomad) Khoa
dedf082491 feat: Phase 1 — Config UI Schema system with per-field widget routing
- Backend: add Arma3ConfigGenerator.get_ui_schema() with widget hints per field
- Backend: add ServerService.get_config_schema() and GET /config/schema endpoint
- Frontend: add FieldSchema/ConfigSchema types + useServerConfigSchema hook
- Frontend: new TagListEditor component for dynamic string-list editing
- Frontend: ConfigEditor now routes each field to correct widget (text/number/password/textarea/select/toggle/tag-list)
- Frontend: password fields have show/hide toggle; toggles render as checkbox; tag-list uses TagListEditor
- Tests: 8 new tests covering hook and TagListEditor; all 136 tests green
2026-04-17 20:27:06 +07:00
Tran G. (Revernomad) Khoa
e71dd9a600 docs: update root .md files to reflect current frontend state and planned UX enhancements
- ARCHITECTURE.md: fix diagram (Server Detail was "planned", now complete), expand
  frontend directory listing with all 5 pages, all server components, all hooks
- FRONTEND.md: note planned terrain/display_name/workshop_id fields on Mission/Mod
  types; add planned hooks table for Phases 1-5 of UX enhancement plan
- MODULES.md: annotate PlayerRepository with get_by_slot() and ThreadRegistry with
  get_rcon_client() as planned additions in Phase 4
- API.md: add "Upcoming Endpoints" section documenting all planned routes for
  Phases 1-5 of the UX enhancement plan
- README.md: update unit test count (69 → ~120), update frontend structure comment
  to list all current pages/components/hooks
2026-04-17 15:50:33 +07:00
Tran G. (Revernomad) Khoa
a688bdfdf9 docs: finalise Arma 3 UX enhancement plan and update project docs
- .claude/plan/arma3-ux-enhancement.md: full plan review pass
  - Add Progress Tracker table for session handoff
  - Fix Phase 1 field names to match ServerConfig model (password_admin,
    battleye, disable_von)
  - Fix Phase 2 rotation endpoints to use ServerService(db) inline pattern
  - Fix Phase 4 router/service: add get_by_slot() to PlayerRepository,
    add get_rcon_client() to ThreadRegistry, fix BanRepository.create()
    signature (expires_at not duration_minutes), correct router pattern
  - Fix Phase 6: already implemented, mark as SKIP
  - Fix CSS class names: btn-secondary→btn-ghost, input-base→neu-input
  - Add 19 implementation decisions from Q&A session to Coding Conventions
- CLAUDE.md: update status table, type mapping table, add plan summary
  and new endpoint list, add key implementation gotchas section
- frontend/README.md: replace Vite boilerplate with project README
- frontend/tests-e2e: E2E test improvements from previous session
  (mock-based login error test, full dashboard mock coverage)
2026-04-17 15:45:34 +07:00
Khoa (Revenovich) Tran Gia
34cc1fd008 docs: update plan file to reference local docs/ instead of external path 2026-04-17 14:57:30 +07:00
Khoa (Revenovich) Tran Gia
4ba199dd62 docs: add arma-server-web-admin analysis reference docs
Brings in ANALYSIS.md, HOW_IT_WORKS.md, and CHERRY_PICK.md generated from
deep analysis of the arma-server-web-admin benchmark project. These docs
inform the Arma 3 UX enhancement plan (.claude/plan/arma3-ux-enhancement.md)
and provide context for implementing agents without needing to re-read the
source project.
2026-04-17 14:55:59 +07:00
Khoa (Revenovich) Tran Gia
5d009d50d1 docs: add Arma 3 UX enhancement implementation plan
Cross-references arma-server-web-admin benchmark, classifies cherry-pick
candidates (must-have/good-to-have/optional), and provides a 6-phase
implementation plan covering config UI schema, mission rotation, mod display
names, player kick/ban, log file browser, and server card quick actions.
Plan is self-contained — no need to re-read the benchmark project to execute.
2026-04-17 14:53:37 +07:00
Khoa (Revenovich) Tran Gia
bbfb044b5d fix: validate per-step fields before advancing Create Server wizard
- Add STEP_FIELDS constant mapping each step to its required fields
- Extract trigger() from useForm and call it on Next click
- Only advance to next step when trigger() returns true, blocking
  silent failures where invalid data could reach the Review step
- Add CreateServerPage.test.tsx with 8 tests covering step navigation,
  validation blocking, happy path, and submit mutation
- Update CLAUDE.md: mark /servers/new Complete, remove resolved bug
- Mark implementation plan as completed
2026-04-17 13:23:21 +07:00
Tran G. (Revernomad) Khoa
947f4773e7 chore: move CLAUDE.md to repo root 2026-04-17 12:02:19 +07:00
Tran G. (Revernomad) Khoa
6511353b55 feat: implement full backend + frontend server detail, settings, and create server pages
Backend:
- Complete FastAPI backend with 42+ REST endpoints (auth, servers, config,
  players, bans, missions, mods, games, system)
- Game adapter architecture with Arma 3 as first-class adapter
- WebSocket real-time events for status, metrics, logs, players
- Background thread system (process monitor, metrics, log tail, RCon poller)
- Fernet encryption for sensitive config fields at rest
- JWT auth with admin/viewer roles, bcrypt password hashing
- SQLite with WAL mode, parameterized queries, migration system
- APScheduler cleanup jobs for logs, metrics, events

Frontend:
- Server Detail page with 7 tabs (overview, config, players, bans,
  missions, mods, logs)
- Settings page with password change and admin user management
- Create Server wizard (4-step; known bug: silent validation failure)
- New hooks: useServerDetail, useAuth, useGames
- New components: ServerHeader, ConfigEditor, PlayerTable, BanTable,
  MissionList, ModList, LogViewer, PasswordChange, UserManager
- WebSocket onEvent callback for real-time log accumulation
- 120 unit tests passing (Vitest + React Testing Library)

Docs:
- Added .gitignore, CLAUDE.md, README.md
- Updated FRONTEND.md, ARCHITECTURE.md with current implementation state
- Added .env.example for backend configuration

Known issues:
- Create Server form: "Next" buttons don't validate before advancing,
  causing silent submit failure when fields are invalid
- Config sub-tabs need UX redesign for non-technical users
2026-04-17 11:58:34 +07:00
Tran G. (Revernomad) Khoa
620429c9b8 feat: add Playwright E2E testing setup with POM and testids
- Install @playwright/test and Chromium browser
- Create playwright.config.ts with dev server integration
- Add data-testid attributes to LoginPage, DashboardPage, ServerCard, Sidebar
- Exclude tests-e2e from vitest config
- Create Page Object Models: LoginPage, DashboardPage
- Add 18 E2E tests: 6 login flow, 12 dashboard (happy, empty, error states)
- Add test:e2e and test:e2e:ui scripts to package.json
2026-04-17 00:01:11 +07:00
Tran G. (Revernomad) Khoa
88424675b5 feat: implement frontend with TDD (Part 8)
- Scaffold Vite + React 19 + TypeScript strict project
- Neumorphic dark design system (Tailwind v3, amber/orange LED accents)
- Zustand stores for auth (persist) and UI state (notifications, sidebar)
- TanStack Query v5 hooks for server CRUD operations
- WebSocket hook with reconnection backoff and query invalidation
- Components: StatusLed, Sidebar, ServerCard, LoginPage, DashboardPage
- Protected routing with auth guard
- Axios client with JWT interceptor and 401 redirect
- 68 tests across 11 test files (89% statement coverage, 90% function coverage)
- TDD workflow: RED validated, GREEN achieved, coverage verified
2026-04-16 23:53:25 +07:00
Tran G. (Revernomad) Khoa
b17d199301 fix: address design review ACT NOW items (6 risk gaps)
- Add migrate_config() to ConfigGenerator protocol for schema version upgrades
- Add per-server operation lock to ProcessManager to prevent start/stop races
- Add busy_timeout retry/backoff strategy (exponential: 1s, 2s, 4s) for DB lock exhaustion
- Add ConfigForm testing strategy and error boundary for malformed schemas
- Add schema cache invalidation on adapter version change
- Add ConfigMigrationError to typed adapter exceptions
2026-04-16 17:29:19 +07:00
Tran G. (Revernomad) Khoa
624d7594e2 feat: multi-game adapter revamp, council protocol merge, and frontend design doc
- Revamp architecture for modular game server support (Arma 3 first, extensible)
- Merge ConfigSchema into ConfigGenerator per council decision (8→7 protocols)
- Add has_capability() method to GameAdapter protocol for explicit capability probing
- Add FRONTEND.md: production-grade dark neumorphism design with amber/orange palette
- Update all docs (ARCHITECTURE, MODULES, DATABASE, API, IMPLEMENTATION_PLAN, THREADING)
  to reflect protocol merge and multi-game adapter patterns
2026-04-16 17:05:04 +07:00
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
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
176 changed files with 28308 additions and 3049 deletions

View File

@@ -0,0 +1,966 @@
# Plan: Arma 3 Adapter UX Enhancement
**Status:** APPROVED — Ready to implement
**Branch:** main
**Estimated effort:** ~20h total (5 active phases)
---
## Progress Tracker
> **IMPLEMENTING AGENT:** Update this section at the start and end of each session. Mark each phase `[x]` when ALL its checklist items pass. This is the only reliable way for the next session to know where to pick up.
| Phase | Status | Last session note |
|-------|--------|------------------|
| 1 — Config UI Schema | `[x] done` | TagListEditor, useServerConfigSchema, ConfigEditor widget routing, backend get_ui_schema + endpoint |
| 2 — Mission Rotation | `[x] done` | terrain in list_missions, rotation GET/PUT endpoints, MissionRotationEntry type, useServerMissionRotation/useUpdateMissionRotation hooks, multi-file upload, MissionList redesigned with Available + Rotation sections |
| 3 — Mod Display Names + Split Pane | `[x] done` | _parse_mod_cpp/_parse_meta_cpp in mod_manager, ModList split-pane redesign |
| 4 — Player Kick/Ban | `[x] done` | get_by_slot in PlayerRepository, get_rcon_client in ThreadRegistry, kick/ban endpoints, PlayerTable modals |
| 5 — Log File Browser | `[x] done` | list_log_files/get_log_file_path in RPTParser, logfiles_router (GET/download/DELETE), LogViewer file browser section |
**How to resume:** Read this table first. Find the first phase that is not `[x] done`. Read only that phase section — do not re-read earlier phases. Run `cd frontend && npx tsc --noEmit` to confirm the build is clean before making any changes.
---
## Background & Context
### What was analyzed
`arma-server-web-admin` (Node.js/Express + Backbone.js) was deep-analyzed as a UX benchmark. Reference documentation is in `docs/` (this repo):
- `docs/ANALYSIS.md` — feature inventory, tech stack, directory structure
- `docs/HOW_IT_WORKS.md` — internal flows (server start/stop, mission upload, mod discovery, Socket.IO bridge)
- `docs/CHERRY_PICK.md` — adapter candidates with file paths and adapter strategies
**You do NOT need to read the original arma-server-web-admin source.** All relevant patterns are documented in `docs/` and self-contained below.
### Problem statement
languard-servers-manager has a **complete backend** (42+ endpoints, Arma3ConfigGenerator, Arma3MissionManager, Arma3ModManager, Arma3BanManager, Arma3RemoteAdmin) but the **frontend has gaps** that make daily Arma 3 server administration painful:
| Problem | Root cause |
|---------|-----------|
| All config fields render as text boxes | ConfigEditor is generic; no per-field widget hints |
| Can't build a mission rotation | Backend config supports `missions[]` array; no UI |
| Mods show `@CBA_A3` not "Community Base Addons" | `mod.cpp` not parsed; no `display_name` field |
| Can't kick a player from the UI | `Arma3RemoteAdmin.kick_player()` exists; endpoint missing |
| Can't browse/download historical log files | Only real-time WebSocket stream; no file browser |
| Must navigate to detail page to start/stop | No quick-action buttons on ServerCard |
---
## Cross-Reference: Cherry-Pick Classification
### MUST HAVE
| Feature | Gap in languard |
|---------|----------------|
| Config field UI widgets (textarea/dropdown/toggle/tag-list) | Generic `<input>` for everything |
| Kick player from Players tab | `Arma3RemoteAdmin.kick_player(slot_id, reason)` exists but no HTTP endpoint |
| Mission rotation table + per-mission difficulty | Backend: `Arma3MissionManager.get_rotation_config()` exists. Frontend: nothing |
| Multi-file mission upload | `useUploadMission` accepts single `File` only |
### GOOD TO HAVE
| Feature | Gap |
|---------|-----|
| Mod display names (mod.cpp parsing) | `list_available_mods()` returns path only, no `display_name` |
| Split-pane mod assignment UI | Flat checkbox list |
| Server card start/stop quick actions | Must navigate to ServerDetailPage |
| Log file browser + download | WebSocket stream only |
| Admin UIDs tag list in config | Hidden in raw JSON config editor |
| Ban from player list quick action | User must go to Bans tab manually |
### OPTIONAL
| Feature | Note |
|---------|------|
| Steam Workshop mission download | Requires external `steamcmd` binary |
| Mod Steam Workshop ID (meta.cpp) | Informational only |
| Log viewer level filter | Pure client-side |
| Headless client count in UI | Niche advanced feature |
---
## Current Codebase — Critical File Inventory
### Backend (FastAPI + Python)
```
backend/
├── adapters/arma3/
│ ├── adapter.py # Arma3Adapter — registers capabilities, returns sub-managers
│ ├── config_generator.py # Arma3ConfigGenerator
│ │ SECTIONS: ServerSection, BasicSection, ProfileSection,
│ │ LaunchSection, RconSection
│ │ KEY FIELDS IN ServerSection:
│ │ hostname (str), max_players (int), password (str),
│ │ admin_password (str), motd_lines (list[str]),
│ │ forced_difficulty (str), battle_eye (bool), von (bool),
│ │ admin_uids (list[str])
│ │ KEY FIELDS IN LaunchSection:
│ │ additional_args (list[str])
│ │ METHODS: get_sections(), write_configs(), build_launch_args(),
│ │ get_sensitive_fields(), get_defaults()
│ │ ADD: get_ui_schema() -> dict
│ ├── mission_manager.py # Arma3MissionManager
│ │ list_missions() -> [{name, filename, size_bytes}]
│ │ parse_mission_filename(fn) -> {mission_name, terrain, filename}
│ │ get_rotation_config(entries) -> Arma3 missions config block string
│ │ UPDATE: list_missions() add terrain field
│ ├── mod_manager.py # Arma3ModManager
│ │ list_available_mods() -> [{name, path, size_bytes, enabled}]
│ │ get_enabled_mods(), set_enabled_mods(), build_mod_args()
│ │ UPDATE: add display_name, workshop_id to list_available_mods()
│ ├── log_parser.py # RPTParser
│ │ parse_line(), get_log_file_resolver()
│ │ ADD: list_log_files(server_dir), get_log_file_path(server_dir, filename)
│ ├── remote_admin.py # Arma3RemoteAdmin (DO NOT MODIFY — stable)
│ │ kick_player(slot_id, reason) -> bool ← USE THIS
│ │ ban_player(uid, duration_minutes, reason) -> bool
│ │ get_players() -> list[dict]
│ └── ban_manager.py # Arma3BanManager — bans.txt sync (stable)
├── core/servers/
│ ├── router.py # Main server endpoints
│ │ ADD: GET /api/servers/{id}/config/schema
│ ├── service.py # ServerService
│ │ ADD: get_config_schema(), kick_player(), ban_from_player()
│ ├── missions_router.py # GET/POST/DELETE /api/servers/{id}/missions/*
│ │ ADD: GET /api/servers/{id}/missions/rotation
│ │ ADD: PUT /api/servers/{id}/missions/rotation
│ ├── players_router.py # GET /api/servers/{id}/players
│ │ ADD: POST /api/servers/{id}/players/{slot_id}/kick
│ │ ADD: POST /api/servers/{id}/players/{slot_id}/ban
│ └── [NEW] logfiles_router.py
│ GET /api/servers/{id}/logfiles
│ GET /api/servers/{id}/logfiles/{filename}/download
│ DELETE /api/servers/{id}/logfiles/{filename}
└── main.py # ADD: include_router(logfiles_router)
```
### Frontend (React 19 + TypeScript + TanStack Query)
```
frontend/src/
├── hooks/
│ └── useServerDetail.ts # All query/mutation hooks for server detail
│ ADD: useServerConfigSchema()
│ ADD: useServerMissionRotation(), useUpdateMissionRotation()
│ ADD: useKickPlayer(), useBanPlayer()
│ ADD: useServerLogFiles(), useDeleteLogFile()
│ UPDATE: useUploadMission(File[]) — was single File
│ UPDATE: Mod type — add display_name, workshop_id
│ UPDATE: Mission type — add terrain field
├── components/servers/
│ ├── ConfigEditor.tsx # UPDATE: consume schema, render per-widget type
│ ├── MissionList.tsx # REDESIGN: Available section + Rotation section
│ ├── ModList.tsx # REDESIGN: split pane (Available vs Selected)
│ ├── PlayerTable.tsx # UPDATE: add Actions column (Kick/Ban buttons, admin only)
│ ├── LogViewer.tsx # UPDATE: level filter + Log Files browser section
│ └── ServerCard.tsx # UPDATE: Start/Stop quick-action buttons
└── components/ui/
└── [NEW] TagListEditor.tsx # Dynamic string-list editor (reused in ConfigEditor)
```
---
## Execution Order
| Phase | Description | Priority | Est. |
|-------|-------------|----------|------|
| 1 | Config UI Schema (widget hints per field) | MUST | ~4h |
| 4 | Player Kick/Ban | MUST | ~3h |
| 2 | Mission Rotation + Multi-file upload | MUST | ~5h |
| 3 | Mod Display Names + Split Pane | GOOD | ~4h |
| 5 | Log File Browser + Level Filter | GOOD | ~3h |
| 6 | Server Card Quick Actions | ~~GOOD~~ | **DONE** |
---
## Phase 1 — Config UI Schema System (MUST HAVE, ~4h)
**Goal:** Each Arma 3 config field renders with the right UI widget instead of a generic text box.
### 1.1 `backend/adapters/arma3/config_generator.py` — add `get_ui_schema()`
Add this method to `Arma3ConfigGenerator`:
```python
def get_ui_schema(self) -> dict:
return {
"server": {
# Field names MUST match Arma3ConfigGenerator.ServerConfig exactly
"hostname": {"widget": "text", "label": "Server Hostname"},
"max_players": {"widget": "number", "label": "Max Players", "min": 1, "max": 1000},
"password": {"widget": "password", "label": "Player Password"},
"password_admin": {"widget": "password", "label": "Admin Password"}, # NOT admin_password
"motd_lines": {"widget": "textarea", "label": "Message of the Day (one line per row)"},
"forced_difficulty": {"widget": "select", "label": "Difficulty Preset",
"options": ["Recruit", "Regular", "Veteran", "Custom"]},
"battleye": {"widget": "toggle", "label": "BattleEye Anti-Cheat"}, # NOT battle_eye
"disable_von": {"widget": "toggle", "label": "Disable Voice over Net (VoN)"}, # NOT von — and it's inverted
"verify_signatures": {"widget": "number", "label": "Verify Signatures (0=off, 1=on, 2=strict)", "min": 0, "max": 2},
"persistent": {"widget": "toggle", "label": "Persistent (keep running when empty)"},
"admin_uids": {"widget": "tag-list", "label": "Admin Steam UIDs",
"placeholder": "76561198000000000"},
},
"basic": {
"max_packet_size": {"widget": "number", "label": "Max Packet Size"},
"max_custom_file_size": {"widget": "number", "label": "Max Custom File Size (bytes)"},
},
"launch": {
"additional_args": {"widget": "tag-list", "label": "Additional Startup Parameters",
"placeholder": "-limitFPS=100"},
},
"rcon": {
"password": {"widget": "password", "label": "RCon Password"},
"port": {"widget": "number", "label": "RCon Port"},
},
}
```
### 1.2 `backend/core/servers/service.py` — add `get_config_schema()`
Follow the existing pattern: `ServerService.__init__` already stores `self._server_repo` and `self._config_repo`. No `db` param needed:
```python
def get_config_schema(self, server_id: int) -> dict:
server = self.get_server(server_id) # raises 404 if not found, uses self._server_repo
adapter = GameAdapterRegistry.get(server["game_type"])
config_gen = adapter.get_config_generator()
if hasattr(config_gen, "get_ui_schema"):
return config_gen.get_ui_schema()
return {}
```
### 1.3 `backend/core/servers/router.py` — new endpoint
Add after existing config routes, following the `ServerService(db)` inline pattern:
```python
@router.get("/{server_id}/config/schema")
def get_config_schema(
server_id: int,
db: Annotated[Connection, Depends(get_db)],
_user: Annotated[dict, Depends(get_current_user)],
) -> dict:
schema = ServerService(db).get_config_schema(server_id)
return {"success": True, "data": schema, "error": None}
```
### 1.4 `frontend/src/hooks/useServerDetail.ts` — add schema types + hook
```typescript
export interface FieldSchema {
widget: "text" | "number" | "password" | "textarea" | "select" | "toggle" | "tag-list";
label?: string;
placeholder?: string;
min?: number;
max?: number;
options?: string[];
}
export interface ConfigSchema {
[section: string]: { [field: string]: FieldSchema };
}
export function useServerConfigSchema(serverId: number) {
return useQuery({
queryKey: ["servers", serverId, "config", "schema"],
queryFn: async () => {
const res = await apiClient.get<{ success: boolean; data: ConfigSchema }>(
`/api/servers/${serverId}/config/schema`,
);
return res.data.data;
},
enabled: serverId > 0,
});
}
```
### 1.5 `frontend/src/components/ui/TagListEditor.tsx` — NEW component
```typescript
interface TagListEditorProps {
value: string[];
onChange: (v: string[]) => void;
placeholder?: string;
disabled?: boolean;
}
export function TagListEditor({ value, onChange, placeholder, disabled }: TagListEditorProps) {
const update = (idx: number, val: string) =>
onChange(value.map((v, i) => (i === idx ? val : v)));
const remove = (idx: number) => onChange(value.filter((_, i) => i !== idx));
const add = () => onChange([...value, ""]);
return (
<div className="space-y-1">
{value.map((item, idx) => (
<div key={idx} className="flex gap-2">
<input
className="flex-1 neu-input"
value={item}
placeholder={placeholder}
disabled={disabled}
onChange={(e) => update(idx, e.target.value)}
/>
<button
type="button"
onClick={() => remove(idx)}
disabled={disabled}
className="btn-ghost text-status-crashed px-2"
>
</button>
</div>
))}
<button type="button" onClick={add} disabled={disabled} className="btn-ghost text-sm">
+ Add
</button>
</div>
);
}
```
### 1.6 `frontend/src/components/servers/ConfigEditor.tsx` — consume schema
- Import `useServerConfigSchema` and `TagListEditor`
- Call `useServerConfigSchema(serverId)` alongside existing config queries
- For each field in a config section, look up `schema?.[sectionName]?.[fieldName]`
- Render based on `widget`:
- `"text"``<input type="text" />`
- `"number"``<input type="number" min={} max={} />`
- `"password"``<input type="password" />`
- `"textarea"``<textarea rows={4} />`
- `"select"``<select>` with `schema.options.map(o => <option>)`
- `"toggle"``<input type="checkbox" />` (styled toggle)
- `"tag-list"``<TagListEditor value={} onChange={} placeholder={} />`
- fallback → `<input type="text" />`
- Use `schema.label` as the field label; fall back to field name (capitalized) if absent
---
## Phase 2 — Mission Rotation Management (MUST HAVE, ~5h)
**Goal:** Users build/reorder a mission rotation with per-mission difficulty. Multi-file upload.
### 2.1 `backend/adapters/arma3/mission_manager.py` — add `terrain` to `list_missions()`
`parse_mission_filename()` already extracts `terrain`. Ensure `list_missions()` includes it:
```python
# Current return per mission:
{"name": mission_name, "filename": filename, "size_bytes": size}
# Updated return:
{"name": mission_name, "filename": filename, "size_bytes": size, "terrain": terrain}
```
### 2.2 `backend/core/servers/missions_router.py` — add rotation endpoints
Add Pydantic schemas at top of file:
```python
class MissionRotationEntry(BaseModel):
name: str
difficulty: str = ""
class MissionRotationUpdate(BaseModel):
missions: list[MissionRotationEntry]
config_version: int
```
Add endpoints — follow the `ServerService(db)` inline pattern used by all existing routers:
```python
from typing import Annotated
from sqlalchemy.engine import Connection
from core.servers.service import ServerService
@router.get("/rotation") # prefix already includes /{server_id}/missions
def get_mission_rotation(
server_id: int,
db: Annotated[Connection, Depends(get_db)],
_user: Annotated[dict, Depends(get_current_user)],
) -> dict:
# "server" section is always seeded on create — never None for existing server
config = ServerService(db).get_config_section(server_id, "server")
missions = config.get("missions", [])
return {"success": True, "data": {"missions": missions}, "error": None}
@router.put("/rotation")
def update_mission_rotation(
server_id: int,
body: MissionRotationUpdate,
db: Annotated[Connection, Depends(get_db)],
_admin: Annotated[dict, Depends(require_admin)],
) -> dict:
# ServerService.update_config_section() handles load-merge-upsert + 409 on conflict
updated = ServerService(db).update_config_section(
server_id=server_id,
section="server",
data={"missions": [e.model_dump() for e in body.missions]},
expected_version=body.config_version,
)
return {"success": True, "data": {"missions": updated.get("missions", [])}, "error": None}
```
### 2.3 `frontend/src/hooks/useServerDetail.ts` — rotation hooks + updated types
Update `Mission` type:
```typescript
export interface Mission {
name: string;
filename: string;
size_bytes: number;
terrain: string; // new
}
```
Add rotation types and hooks:
```typescript
export interface MissionRotationEntry {
name: string;
difficulty: string;
}
export function useServerMissionRotation(serverId: number) {
return useQuery({
queryKey: ["missions", serverId, "rotation"],
queryFn: async () => {
const res = await apiClient.get<{
success: boolean; data: { missions: MissionRotationEntry[] }
}>(`/api/servers/${serverId}/missions/rotation`);
return res.data.data.missions;
},
enabled: serverId > 0,
});
}
export function useUpdateMissionRotation(serverId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { missions: MissionRotationEntry[]; config_version: number }) =>
apiClient.put(`/api/servers/${serverId}/missions/rotation`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["missions", serverId, "rotation"] });
// Invalidate server config section too — missions are stored inside it
queryClient.invalidateQueries({ queryKey: ["servers", serverId, "config", "server"] });
},
});
}
```
Update `useUploadMission` to accept `File[]`:
```typescript
export function useUploadMission(serverId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (files: File[]) => {
for (const file of files) {
const formData = new FormData();
formData.append("file", file);
await apiClient.post(`/api/servers/${serverId}/missions`, formData, {
headers: { "Content-Type": "multipart/form-data" },
});
}
},
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: ["missions", serverId] }),
});
}
```
### 2.4 `frontend/src/components/servers/MissionList.tsx` — redesign
Difficulty constant (define once, reuse here and in Phase 1 schema):
```typescript
const DIFFICULTY_OPTIONS = ["", "Recruit", "Regular", "Veteran", "Custom"];
```
Component layout — two sections:
**Section A: Available Missions**
- Table: Mission Name | Terrain badge | Size | Actions
- Actions per row: "Add to Rotation" button + Delete button
- Upload zone: `<input type="file" multiple accept=".pbo" />` → calls `useUploadMission(files)`
- Show per-file upload progress (filename + spinner/✓)
**Section B: Mission Rotation**
- Table: # | Mission Name | Terrain | Difficulty | Remove
- Row: index, name, terrain badge, `<select>` with `DIFFICULTY_OPTIONS`, remove button
- "Save Rotation" → `useUpdateMissionRotation({ missions: rotation, config_version })`
- "Clear Rotation" button → sets rotation to `[]`
State:
- `rotation: MissionRotationEntry[]` — local state, synced from query on load
- `uploadProgress: { filename: string; done: boolean }[]` — per-file status (sequential uploads)
**config_version source:** Call `useServerConfigSection(serverId, "server")` inside `MissionList`. Read `sectionData._meta.config_version` and pass it as `config_version` when calling `useUpdateMissionRotation`. This hook already exists in `useServerDetail.ts`.
---
## Phase 3 — Mod Display Names + Split Pane (GOOD TO HAVE, ~4h)
**Goal:** Parse `mod.cpp` for human-readable names. Redesign mod UI as split pane.
### 3.1 `backend/adapters/arma3/mod_manager.py` — add display_name + workshop_id
Add as **module-level functions** (not class methods — pure `Path → str | None`, no state needed, easier to test):
```python
import re
def _parse_mod_cpp(mod_dir: Path) -> str | None:
mod_cpp = mod_dir / "mod.cpp"
if not mod_cpp.exists():
return None
text = mod_cpp.read_text(errors="ignore")
m = re.search(r'name\s*=\s*"([^"]+)"', text, re.IGNORECASE)
return m.group(1) if m else None
def _parse_meta_cpp(mod_dir: Path) -> str | None:
meta_cpp = mod_dir / "meta.cpp"
if not meta_cpp.exists():
return None
text = meta_cpp.read_text(errors="ignore")
m = re.search(r'publishedid\s*=\s*(\d+)', text, re.IGNORECASE)
return m.group(1) if m else None
```
Update each mod dict in `list_available_mods()`:
```python
{
"name": str(rel_path),
"path": str(mod_dir),
"size_bytes": size,
"enabled": name in enabled_set,
"display_name": _parse_mod_cpp(mod_dir), # new — None if no mod.cpp
"workshop_id": _parse_meta_cpp(mod_dir), # new — None if no meta.cpp
}
```
### 3.2 `frontend/src/hooks/useServerDetail.ts` — update Mod type
```typescript
export interface Mod {
name: string;
path: string;
size_bytes: number;
enabled: boolean;
display_name: string | null; // new
workshop_id: string | null; // new
}
```
### 3.3 `frontend/src/components/servers/ModList.tsx` — split pane redesign
CSS grid 2-column (50% each). Each pane:
- Header: "Available (N)" / "Selected (N)"
- Search `<input>` for client-side filter
- Scrollable list of mod rows
Each mod row:
- Primary text: `display_name ?? name`
- Secondary text (small, muted): `name` (folder path)
- Optional badge: "Workshop" if `workshop_id !== null`
- File size (e.g., "1.2 GB")
- Click → moves to other pane (immutable state update)
State:
- `available: Mod[]` — mods with `enabled === false`
- `selected: Mod[]` — mods with `enabled === true`
- Initialized from query; mutations update local state only until "Apply"
Bottom of component:
- "Apply Selection" button → `useSetEnabledMods(selected.map(m => m.name))`
- Shows confirmation: "N mods selected. Server restart required for changes to take effect."
---
## Phase 4 — Player Kick + Ban (MUST HAVE, ~3h)
**Goal:** Admin can kick or ban from the Players tab via RCon without using raw RCon console.
### 4.1 `backend/core/servers/players_router.py` — new action endpoints
Add imports at top of file:
```python
from pydantic import BaseModel
from dependencies import require_admin
```
Add Pydantic schemas:
```python
class KickRequest(BaseModel):
reason: str = "Kicked by admin"
class BanFromPlayerRequest(BaseModel):
reason: str = "Banned by admin"
duration_minutes: int | None = None # None = permanent
```
Add endpoints — follow existing `ServerService(db)` inline pattern:
```python
@router.post("/{slot_id}/kick") # prefix already is /servers/{server_id}/players
def kick_player(
server_id: int, slot_id: int,
body: KickRequest,
db: Annotated[Connection, Depends(get_db)],
_admin: Annotated[dict, Depends(require_admin)],
) -> dict:
ServerService(db).kick_player(server_id, slot_id, body.reason)
return {"success": True, "data": {"message": f"Player {slot_id} kicked"}, "error": None}
@router.post("/{slot_id}/ban")
def ban_player_from_list(
server_id: int, slot_id: int,
body: BanFromPlayerRequest,
db: Annotated[Connection, Depends(get_db)],
admin: Annotated[dict, Depends(require_admin)],
) -> dict:
ban = ServerService(db).ban_from_player(
server_id, slot_id, body.reason, body.duration_minutes,
banned_by=admin["username"],
)
return {"success": True, "data": ban, "error": None}
```
### 4.2 `backend/core/dal/player_repository.py` — add `get_by_slot()`
`get_by_slot()` does not exist yet. Add it. Note: slot_id is stored as a string in the DB (see `upsert()` which calls `str(player.get("slot_id", ""))`):
```python
def get_by_slot(self, server_id: int, slot_id: int) -> dict | None:
return self._fetchone(
"SELECT * FROM players WHERE server_id = :sid AND slot_id = :slot",
{"sid": server_id, "slot": str(slot_id)}, # cast to str — stored as string in DB
)
```
### 4.2 `backend/core/threads/thread_registry.py` — add `get_rcon_client()`
`ThreadRegistry.get_remote_admin()` does not exist. Add this class method. The RCon client lives on `bundle["rcon_poller"]._client` (verified from `RemoteAdminPollerThread.__init__`):
```python
@classmethod
def get_rcon_client(cls, server_id: int):
"""Return the live Arma3RemoteAdmin client for a running server, or None."""
registry = cls._get_instance()
if registry is None:
return None
bundle = registry._bundles.get(server_id)
if bundle is None:
return None
poller = bundle.get("rcon_poller")
if poller is None or not poller.is_alive():
return None
return getattr(poller, "_client", None)
```
### 4.2 `backend/core/servers/service.py` — new service methods
```python
def kick_player(self, server_id: int, slot_id: int, reason: str) -> None:
from core.threads.thread_registry import ThreadRegistry
ra = ThreadRegistry.get_rcon_client(server_id) # use get_rcon_client, NOT get_remote_admin
if not ra or not ra.is_connected():
raise HTTPException(status.HTTP_400_BAD_REQUEST,
detail={"code": "RCON_NOT_CONNECTED", "message": "RCon not connected — server must be running"})
success = ra.kick_player(int(slot_id), reason) # kick_player takes int, slot stored as str
if not success:
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={"code": "KICK_FAILED", "message": "Kick command failed"})
def ban_from_player(
self, server_id: int, slot_id: int,
reason: str, duration_minutes: int | None,
banned_by: str, # pass admin["username"] from router — service never accepts User objects
) -> dict:
from datetime import datetime, timezone, timedelta
from core.dal.player_repository import PlayerRepository
from core.dal.ban_repository import BanRepository
player = PlayerRepository(self._db).get_by_slot(server_id, int(slot_id))
if not player:
raise HTTPException(status.HTTP_404_NOT_FOUND,
detail={"code": "NOT_FOUND", "message": "Player not found"})
# Convert duration_minutes → expires_at ISO string (None = permanent)
expires_at = None
if duration_minutes is not None and duration_minutes > 0:
expires_at = (datetime.now(timezone.utc) + timedelta(minutes=duration_minutes)).isoformat()
from core.threads.thread_registry import ThreadRegistry
ra = ThreadRegistry.get_rcon_client(server_id)
if ra and ra.is_connected():
ra.ban_player(player["guid"], duration_minutes or 0, reason)
ban_repo = BanRepository(self._db)
ban_id = ban_repo.create(
server_id=server_id, guid=player["guid"], name=player["name"],
reason=reason, banned_by=banned_by,
expires_at=expires_at, # BanRepository.create() takes expires_at, NOT duration_minutes
)
return dict(ban_repo.get_by_id(ban_id)) # create() returns int id, get_by_id() returns dict
```
### 4.3 `frontend/src/hooks/useServerDetail.ts` — kick/ban mutations
```typescript
export function useKickPlayer(serverId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ slotId, reason }: { slotId: number; reason: string }) =>
apiClient.post(`/api/servers/${serverId}/players/${slotId}/kick`, { reason }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["players", serverId] }),
});
}
export function useBanPlayer(serverId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
slotId, reason, durationMinutes,
}: { slotId: number; reason: string; durationMinutes?: number }) =>
apiClient.post(`/api/servers/${serverId}/players/${slotId}/ban`, {
reason,
duration_minutes: durationMinutes ?? null,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["players", serverId] });
queryClient.invalidateQueries({ queryKey: ["bans", serverId] });
},
});
}
```
### 4.4 `frontend/src/components/servers/PlayerTable.tsx` — add Actions column
- Import `useKickPlayer`, `useBanPlayer`, `useAuthStore`
- Check `isAdmin = useAuthStore(s => s.user?.role === "admin")`
- Add "Actions" column at far right, visible only when `isAdmin`
- **Kick flow:** "Kick" button → inline reason input on that row → "Confirm" → `kickPlayer({ slotId: player.slot_id, reason })`
- **Ban flow:** "Ban" button → small modal/dialog with reason textarea + optional duration (minutes) → "Confirm Ban" → `banPlayer({ slotId, reason, durationMinutes })`
- Disable both buttons when `server.status !== "running"` with tooltip "Server must be running"
---
## Phase 5 — Log File Browser (GOOD TO HAVE, ~3h)
**Goal:** Browse, download, and delete historical `.rpt` log files. Filter live log by level.
### 5.1 `backend/adapters/arma3/log_parser.py` — add file listing
> **Log path:** Arma 3 writes `.rpt` files to `C:\Users\<username>\AppData\Local\Arma 3` by default on Windows. The log directory should be configurable. Use a `log_dir` setting from server config (fall back to `server_dir / "logs"` if not set). The implementation below uses `server_dir / "logs"` as the convention for this app; the `ARMA3_LOG_DIR` env var or a config field can override it per-server.
```python
def list_log_files(self, server_dir: Path) -> list[dict]:
import os
log_dir = Path(os.environ.get("ARMA3_LOG_DIR", str(server_dir / "logs")))
if not log_dir.exists():
return []
files = sorted(log_dir.glob("*.rpt"), key=lambda f: f.stat().st_mtime, reverse=True)
return [
{
"filename": f.name,
"size_bytes": f.stat().st_size,
"modified_at": f.stat().st_mtime, # Unix timestamp float
}
for f in files
]
def get_log_file_path(self, server_dir: Path, filename: str) -> Path:
"""Returns absolute path with path-traversal protection."""
log_dir = (server_dir / "server").resolve()
path = (log_dir / filename).resolve()
if not str(path).startswith(str(log_dir)):
raise ValueError("Path traversal detected")
if not path.exists():
raise FileNotFoundError(filename)
return path
```
### 5.2 `backend/core/servers/logfiles_router.py` — NEW file
> **Note:** `adapter.get_log_parser()` already exists on `Arma3Adapter` (returns `RPTParser()`). No adapter changes needed — just call `adapter.get_log_parser()` directly.
```python
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from pathlib import Path
router = APIRouter(prefix="/api/servers", tags=["logfiles"])
@router.get("/{server_id}/logfiles")
async def list_log_files(
server_id: int, db=Depends(get_db), user=Depends(get_current_user)
):
server = server_repo.get_by_id(server_id, db)
adapter = adapter_registry.get(server.game_type)
log_parser = adapter.get_log_parser()
server_dir = Path(settings.SERVERS_DIR) / str(server_id)
return {"success": True, "data": log_parser.list_log_files(server_dir)}
@router.get("/{server_id}/logfiles/{filename}/download")
async def download_log_file(
server_id: int, filename: str, db=Depends(get_db), user=Depends(get_current_user)
):
server = server_repo.get_by_id(server_id, db)
adapter = adapter_registry.get(server.game_type)
log_parser = adapter.get_log_parser()
server_dir = Path(settings.SERVERS_DIR) / str(server_id)
try:
path = log_parser.get_log_file_path(server_dir, filename)
except (ValueError, FileNotFoundError) as e:
raise HTTPException(404, str(e))
return FileResponse(path, filename=filename, media_type="text/plain")
@router.delete("/{server_id}/logfiles/{filename}")
async def delete_log_file(
server_id: int, filename: str, db=Depends(get_db), user=Depends(require_admin)
):
server = server_repo.get_by_id(server_id, db)
adapter = adapter_registry.get(server.game_type)
log_parser = adapter.get_log_parser()
server_dir = Path(settings.SERVERS_DIR) / str(server_id)
try:
path = log_parser.get_log_file_path(server_dir, filename)
path.unlink()
except (ValueError, FileNotFoundError) as e:
raise HTTPException(404, str(e))
return {"success": True, "data": {"message": f"{filename} deleted"}}
```
### 5.3 `backend/main.py` — register router
```python
from backend.core.servers.logfiles_router import router as logfiles_router
app.include_router(logfiles_router)
```
### 5.4 `frontend/src/hooks/useServerDetail.ts` — log file hooks
```typescript
export interface LogFile {
filename: string;
size_bytes: number;
modified_at: number; // Unix timestamp float
}
export function useServerLogFiles(serverId: number) {
return useQuery({
queryKey: ["servers", serverId, "logfiles"],
queryFn: async () => {
const res = await apiClient.get<{ success: boolean; data: LogFile[] }>(
`/api/servers/${serverId}/logfiles`,
);
return res.data.data;
},
enabled: serverId > 0,
refetchInterval: 30_000,
});
}
export function useDeleteLogFile(serverId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (filename: string) =>
apiClient.delete(`/api/servers/${serverId}/logfiles/${encodeURIComponent(filename)}`),
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: ["servers", serverId, "logfiles"] }),
});
}
```
### 5.5 `frontend/src/components/servers/LogViewer.tsx` — extend
**Add level filter** at top of component:
```typescript
const [levelFilter, setLevelFilter] = useState<"all" | "info" | "warning" | "error">("all");
const filtered = levelFilter === "all" ? logs : logs.filter(l => l.level === levelFilter);
// Render filtered instead of logs
```
Filter buttons: `[All] [Info] [Warning] [Error]` — active button uses `bg-accent` class.
**Add Log Files section** below live stream (collapsible):
- Uses `useServerLogFiles(serverId)` and `useDeleteLogFile(serverId)`
- Table: Filename | Size | Modified | Actions (Download, Delete)
- Download: open `/api/servers/{id}/logfiles/{name}/download` in new tab (with auth token in header via apiClient blob request → object URL → anchor click)
- Delete: confirm prompt before calling mutation
---
## Phase 6 — Server Card Quick Actions ~~(GOOD TO HAVE, ~1h)~~ **ALREADY IMPLEMENTED — SKIP**
**Verified:** `frontend/src/components/servers/ServerCard.tsx` already has full Start/Stop/Restart quick-action buttons (lines 71105), including `e.preventDefault()` + `e.stopPropagation()`, pending states, lucide icons (`Play`, `Square`, `RotateCcw`), and error notifications via `useUIStore`. Nothing to do here.
---
## Coding Conventions (for implementing agent)
1. **apiClient** — All API calls use `apiClient` from `@/lib/api` (axios instance with JWT interceptor). Never use `fetch` directly.
2. **Mutations** — Always follow `useMutation` from TanStack Query. On success, call `queryClient.invalidateQueries({ queryKey: [...] })`.
3. **Admin guard** — Check `useAuthStore(s => s.user?.role === "admin")` to show/hide admin-only controls. Never hide entire sections — hide only action buttons/columns.
4. **Optimistic locking** — Config PUT endpoints require `config_version` in the body (from `_meta.config_version` in the fetched config). A 409 response = conflict; display an error message to user.
5. **CSS classes** — Existing utility classes: `neu-card`, `btn-primary`, `btn-ghost`, `btn-danger`, `neu-input`, `text-text-primary`, `text-text-muted`, `text-status-crashed`, `bg-accent`, `bg-surface-recessed`, `shadow-neu-recessed`, `shadow-neu-raised`. `btn-secondary` and `input-base` do NOT exist — use `btn-ghost` and `neu-input` respectively. Do NOT add new CSS files.
6. **Test file location**`frontend/src/__tests__/`. Mock hooks with `vi.mock("@/hooks/...")`. Follow `CreateServerPage.test.tsx` and existing test patterns.
7. **Do NOT modify**`backend/adapters/arma3/remote_admin.py` (RCon client is stable), `backend/core/websocket/` (WS manager is stable), `backend/core/auth/` (auth is stable).
8. **Immutability** — Never mutate state directly. Use spread (`[...arr]`, `{...obj}`) for all state updates.
9. **Missing config fields** — If a field exists in `get_ui_schema()` but is absent from the current section data, render it with an empty/default value (not hidden).
10. **Boolean config fields** — Send as string `"true"` / `"false"`. The backend converts to actual Python bool.
11. **Password fields** — Render as editable `<input type="password" />` in edit mode with a show/hide toggle button. Toggle state is component-local (resets to hidden on navigation/reload).
12. **Multi-file upload** — Sequential, one file at a time. Show per-file `{ filename, done }` progress.
13. **Responsive layout** — Split-pane components (mods, potentially others) stack vertically on small screens using `flex-col` below `md:` breakpoint (`md:flex-row`).
14. **Mod folder names** — Show `display_name` when available; fall back to `name` (the raw folder path, e.g. `@CBA_A3`) as-is without stripping `@`.
15. **Kick/Ban buttons when offline** — Both buttons always visible for admins, disabled with `title="Server must be running"` tooltip when `server.status !== "running"`.
16. **Kick UX** — Use a modal dialog (same pattern as Ban) for consistency. Do not use inline row expansion.
17. **Ban duration** — Both presets (1h / 24h / 7d / Permanent) AND a free-text minutes input. Permanent = send `duration_minutes: null`.
18. **Log file download** — Blob fetch via `apiClient``URL.createObjectURL()` → programmatic anchor click. Never open in new tab (auth header not sent by browser for new-tab navigations).
19. **Delete confirmations** — Use a small modal dialog component, not `window.confirm()` (blocks browser events).
---
## Testing Checklist
Run after each phase:
```bash
cd frontend && npx vitest run
cd frontend && npx tsc --noEmit
```
### Phase 1 (Config Schema)
- [ ] Config tab: `forced_difficulty` renders as `<select>`, not `<input>`
- [ ] `motd_lines` renders as `<textarea>`
- [ ] `battle_eye` renders as toggle checkbox
- [ ] `admin_uids` renders as TagListEditor; add/remove a UID; save; confirm persisted
### Phase 4 (Kick/Ban)
- [ ] With running server + connected player: click Kick, enter reason, confirm; player disconnects
- [ ] Click Ban, enter reason + duration; entry appears in Bans tab
### Phase 2 (Mission Rotation)
- [ ] Upload 3 `.pbo` files via multi-select; all appear in Available Missions
- [ ] Add all to rotation, set different difficulties; Save; GET config shows `missions` array
- [ ] Remove a mission from rotation; Save; confirms removed
### Phase 3 (Mod Display Names)
- [ ] Mod with `mod.cpp` shows display name instead of folder name
- [ ] Click mod in Available pane → moves to Selected; click Apply → `enabled = true` in response
- [ ] Search filter narrows visible mods in each pane
### Phase 6 (Quick Actions) — ALREADY IMPLEMENTED, no tests needed
### Phase 5 (Log Files)
- [ ] After server runs ≥1 min: Log Files section shows `.rpt` file
- [ ] Download → file downloads; Delete → file removed and list refreshes
- [ ] Click "Error" filter → only error-level lines in live stream

View File

@@ -0,0 +1,146 @@
# Implementation Plan: Fix Create Server Submit Button
> **Status: IMPLEMENTED** — All 3 code changes applied, 8 tests added, 128/128 passing.
> Implemented: 2026-04-17
## Task Type
- [x] Frontend only
## Problem Analysis
**Root Cause** (`frontend/src/pages/CreateServerPage.tsx:266-273`):
The "Next" button advances steps unconditionally:
```tsx
onClick={() => setStep(step + 1)}
```
Users can proceed through steps 0→1→2→3 with invalid data in steps 02. On step 3, `handleSubmit(onSubmit)` runs Zod validation across the whole schema — if any required field (name, exe_path, game_port, game_type) is empty or malformed, validation fails and `onSubmit` is **never called**. There is no error shown because RHF simply blocks submission silently when in an invalid state.
The step 3 error block (lines 229-237) only shows `errors` that RHF knows about — but RHF only populates `errors` after a submit attempt, not after just navigating to a step. So the Review step also appears clean even when data is bad.
## Technical Solution
Extract `trigger` from `useForm` and call it per-step before advancing.
`trigger(fields)` runs Zod validation for the given fields only, populates `formState.errors`,
and returns `true` if all pass. Only advance to the next step when it returns `true`.
### Per-step field mapping
| Step | Label | Fields to validate |
|------|-------|-------------------|
| 0 | Game Type | `game_type` |
| 1 | Server Info | `name`, `exe_path`, `game_port`, `rcon_port` |
| 2 | Options | `max_restarts` |
| 3 | Review | (submit — no additional trigger needed) |
## Implementation Steps
### Step 1 — Extract `trigger` from `useForm`
**File:** `frontend/src/pages/CreateServerPage.tsx:38-56`
Change:
```tsx
const {
register,
handleSubmit,
watch,
setValue,
formState: { errors },
} = useForm<CreateServerForm>({ ... });
```
To:
```tsx
const {
register,
handleSubmit,
watch,
setValue,
trigger,
formState: { errors },
} = useForm<CreateServerForm>({ ... });
```
### Step 2 — Add per-step field mapping constant
Add after `const STEPS = ["Game Type", "Server Info", "Options", "Review"];`:
```tsx
const STEP_FIELDS: Array<(keyof CreateServerForm)[]> = [
["game_type"], // step 0
["name", "exe_path", "game_port", "rcon_port"], // step 1
["max_restarts"], // step 2
[], // step 3 (review — submit handles it)
];
```
### Step 3 — Replace Next button onClick with validated handler
**File:** `frontend/src/pages/CreateServerPage.tsx:266-274`
Change:
```tsx
<button
type="button"
onClick={() => setStep(step + 1)}
className="btn-primary flex items-center gap-1.5"
>
Next
<ChevronRight size={16} />
</button>
```
To:
```tsx
<button
type="button"
onClick={async () => {
const valid = await trigger(STEP_FIELDS[step]);
if (valid) setStep(step + 1);
}}
className="btn-primary flex items-center gap-1.5"
>
Next
<ChevronRight size={16} />
</button>
```
### Step 4 — Add CreateServerPage unit tests
**File:** `frontend/src/__tests__/CreateServerPage.test.tsx` (new file)
Tests to cover:
1. **Renders step 0 by default** — Game Type selector visible
2. **Next on step 0 advances to step 1** — server info fields visible
3. **Next on step 1 with empty name blocks advance** — stays on step 1, error message shown
4. **Next on step 1 with empty exe_path blocks advance** — stays on step 1, error shown
5. **Next on step 1 with valid data advances to step 2** — options fields visible
6. **Back button on step 1 returns to step 0**
7. **Full happy path reaches Review step** — review table visible
8. **Submit fires createServer mutation on valid data**
Test setup: mock `useCreateServer`, `useGamesList`, `useAuthStore` (admin), `useUIStore`.
## Key Files
| File | Operation | Description |
|------|-----------|-------------|
| `frontend/src/pages/CreateServerPage.tsx:38-56` | Modify | Add `trigger` to useForm destructure |
| `frontend/src/pages/CreateServerPage.tsx:27` | Modify | Add `STEP_FIELDS` constant after `STEPS` |
| `frontend/src/pages/CreateServerPage.tsx:266-274` | Modify | Replace Next onClick with async trigger-guarded handler |
| `frontend/src/__tests__/CreateServerPage.test.tsx` | Create | Unit tests for wizard validation |
## Risks and Mitigation
| Risk | Mitigation |
|------|------------|
| `trigger` for `rcon_port` (nullable optional) might block valid empty input | `rcon_port` schema is `.nullable().optional()` so empty/null passes — no issue |
| `max_restarts` has `valueAsNumber` — NaN on empty input | Schema has `.min(0).max(20).optional()` with coerce; default is 3 so this won't be empty in practice |
| Async onClick on a `type="button"` | Safe — button is not `type="submit"`, no double-submit risk |
## SESSION_ID
- CODEX_SESSION: N/A (codeagent-wrapper not available)
- GEMINI_SESSION: N/A (codeagent-wrapper not available)

37
.gitignore vendored Normal file
View File

@@ -0,0 +1,37 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
# Environment
.env
.env.local
.env.*.local
# Database
*.db
*.db-shm
*.db-wal
# IDE
.vscode/
.idea/
*.sw?
*.suo
# OS
.DS_Store
Thumbs.db
# Test / coverage artifacts
coverage/
playwright-report/
test-results/
# Claude Code local settings
.claude/settings.local.json
# Build output
dist/

1910
API.md

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

107
CLAUDE.md Normal file
View File

@@ -0,0 +1,107 @@
# Languard Server Manager
## Quick Start
```bash
# Backend (from backend/, venv must be active)
uvicorn main:app --host 0.0.0.0 --port 8000 --reload
# Frontend (from frontend/)
npm run dev
```
- Backend API: http://localhost:8000 (docs: http://localhost:8000/docs)
- Frontend: http://localhost:5173 (Vite proxies /api and /ws to :8000)
- Default admin: `admin` / (random, printed at first startup)
- Full setup instructions (secrets, venv, debug configs): see README.md
## Architecture
FastAPI + SQLite backend, React 19 + TypeScript + Vite frontend. See ARCHITECTURE.md for full details.
### Key Rules
- Frontend types must match API response shapes, NOT database schema columns
- There is no REST endpoint for logs — logs are only pushed via WebSocket events
- WebSocket `onEvent` callback is the mechanism for receiving real-time log entries
- Config updates use optimistic locking (config_version) — 409 on conflict
- Sensitive config fields are encrypted at rest with Fernet
## Current Implementation Status
### Backend: Fully implemented (42+ endpoints)
All routers, services, repositories, game adapter system, WebSocket, background threads, and scheduled cleanup are complete.
### Frontend: Fully implemented (baseline)
| Route | Status | Notes |
|-------|--------|-------|
| `/login` | Complete | Zod + react-hook-form validation |
| `/` | Complete | Dashboard with server grid + Start/Stop/Restart quick actions |
| `/servers/:id` | Complete | 7-tab detail page (overview, config, players, bans, missions, mods, logs) |
| `/servers/new` | Complete | 4-step wizard with per-step validation via `trigger()` |
| `/settings` | Complete | Password change + admin user management |
### Frontend Type Mapping (API → Frontend)
| API Resource | Frontend Type | Key Fields |
|---|---|---|
| Server (enriched) | `Server` in useServers.ts | `game_port`, `current_players`, `max_players`, `cpu_percent`, `ram_mb` |
| Mission | `Mission` in useServerDetail.ts | `name`, `filename`, `size_bytes`, `terrain` |
| Mod | `Mod` in useServerDetail.ts | `name`, `path`, `size_bytes`, `enabled`, `display_name`, `workshop_id` |
| Ban | `Ban` in useServerDetail.ts | `id`, `server_id`, `guid`, `name`, `reason`, `banned_by`, `banned_at`, `expires_at`, `is_active`, `game_data` |
| Player | `Player` in useServerDetail.ts | `id`, `slot_id`, `name`, `guid`, `ip`, `ping` |
| LogFile | `LogFile` in useServerDetail.ts | `filename`, `size_bytes`, `modified_at` |
### UX Enhancement Plan — ALL PHASES COMPLETE
**Plan file:** `.claude/plan/arma3-ux-enhancement.md`
| Phase | Feature | Status |
|-------|---------|--------|
| 1 | Config field UI widgets (textarea/toggle/select/tag-list per field) | **Done** |
| 2 | Mission rotation table + multi-file upload | **Done** |
| 3 | Mod display names (mod.cpp) + split-pane selector | **Done** |
| 4 | Player Kick/Ban from Players tab via RCon | **Done** |
| 5 | Historical log file browser + live log level filter | **Done** |
**Endpoints added:**
- `GET /api/servers/{id}/config/schema` — per-field widget hints
- `GET|PUT /api/servers/{id}/missions/rotation` — mission rotation with optimistic locking
- `POST /api/servers/{id}/players/{slot_id}/kick` — kick via RCon
- `POST /api/servers/{id}/players/{slot_id}/ban` — ban via RCon + DB record
- `GET /api/servers/{id}/logfiles` — list `.rpt` log files
- `GET /api/servers/{id}/logfiles/{filename}/download` — download log file
- `DELETE /api/servers/{id}/logfiles/{filename}` — delete log file
## Test Commands
```bash
# Frontend unit tests
cd frontend && npx vitest run
# Frontend type check
cd frontend && npx tsc --noEmit
# Backend (no test suite yet)
```
## Key Implementation Notes
- `BanRepository.create()` takes `expires_at` (ISO string), not `duration_minutes` — convert in service
- `slot_id` is stored as a string in the `players` table — cast with `str(slot_id)` in queries
- Config field names in `ServerConfig` Pydantic model: `password_admin` (not `admin_password`), `battleye` (not `battle_eye`), `disable_von` (not `von`)
- **Arma 3 log files** are located at `{exe_path_parent}/server/*.rpt` (next to the .exe), NOT in languard's `servers/{id}/` data directory. Code that finds log files must use `Path(server["exe_path"]).parent` to resolve the log directory.
- Config UI schema now covers all ~80 Arma 3 fields across 5 sections (server, basic, profile, launch, rcon) with per-field widget hints (text, toggle, select, number, password, tag-list, hidden, textarea, key-value). The `missions` field in the server section is marked `hidden` because mission rotation is managed via the dedicated Missions tab.
- **Arma 3 per-mission params**: `ServerConfig.missions` is now `list[MissionRotationItem]` (adds optional `params: dict`). A new `default_mission_params` field holds server-wide defaults. Config version bumped to `"1.1.0"`. `_render_server_cfg()` now emits a `class Missions { ... }` block when the rotation is non-empty; `class Params` inside each mission uses per-mission params → global defaults → omit (in that priority order). The `MissionRotationEntry.params` is edited per-row in the Missions tab via `MissionParamsEditor`; `default_mission_params` is edited in the Config tab via the `key-value` widget.
- **Config version migration**: `migrate_config("1.0.0", ...)` backfills `params: {}` on each existing rotation entry and adds `default_mission_params: {}`. `normalize_section()` does the same on reads for stored rows that pre-date the migration run.
## Mods Tab — Implementation Notes
- Mods go in `{server_data_dir}/{server_id}/mods/@ModName` (e.g. `D:/ImContainer/Arma3Server/1/mods/@CBA_A3/`)
- Enabled mods config schema: `{"enabled_mods": [{"name": "@CBA_A3", "is_server_mod": false}]}`
- Old string-list format is auto-migrated to the dict format on read
- `is_server_mod: true``-serverMod=` arg; `false``-mod=` arg
- `list_available_mods()` scans `{server_dir}/mods/` for `@*` directories
- `set_enabled_mods()` stores the new dict format; validates names against disk
- Server start reads mods from `game_configs` via `config_repo`, NOT from the dead `server_mods` table
- Directory scaffold: all 4 Arma3 subdirs (`server/`, `battleye/`, `mpmissions/`, `mods/`) are created on server create and backfilled on startup; each gets a `README.txt` if not already present

File diff suppressed because it is too large Load Diff

359
FRONTEND.md Normal file
View File

@@ -0,0 +1,359 @@
# Frontend Architecture
## Tech Stack
| Package | Version | Purpose |
|---|---|---|
| React | 19.2 | UI framework |
| TypeScript | 6.0 | Type safety |
| Vite | 8.0 | Build tool and dev server |
| TanStack React Query | 5.99 | Server state management, caching, mutations |
| Zustand | 5.0 | Client state (auth, UI) |
| Axios | 1.15 | HTTP client with interceptors |
| React Router | 7.14 | Client-side routing |
| React Hook Form | 7.72 | Form state management |
| Zod | 4.3 | Schema validation |
| Tailwind CSS | 3.4 | Utility-first CSS framework |
| Lucide React | 1.8 | Icon library |
| clsx | 2.1 | Conditional class merging |
### Dev Dependencies
| Package | Purpose |
|---|---|
| Vitest 4.1 + jsdom | Unit testing |
| @testing-library/react 16 | Component testing |
| @testing-library/jest-dom 6 | DOM matchers |
| @testing-library/user-event 14 | User interaction simulation |
| Playwright 1.59 | E2E testing |
| ESLint 9 | Linting |
| TypeScript ESLint 8 | TypeScript lint rules |
## Project Structure
```
frontend/src/
├── main.tsx # Entry point, renders <App />
├── App.tsx # Router, QueryClientProvider, auth guard
├── index.css # Tailwind directives, neumorphic classes
├── vite-env.d.ts # Vite env type declarations
├── lib/
│ └── api.ts # Axios client, auth interceptors
├── store/
│ ├── auth.store.ts # Auth state (token, user, isAuthenticated)
│ └── ui.store.ts # UI state (sidebar, notifications)
├── hooks/
│ ├── useServers.ts # TanStack Query hooks for server CRUD + lifecycle
│ ├── useServerDetail.ts # TanStack Query hooks for config, players, bans, missions, mods, RCon
│ ├── useAuth.ts # Auth management hooks (users, password, logout)
│ ├── useGames.ts # Game type hooks (list, detail, schema, defaults)
│ └── useWebSocket.ts # WebSocket connection with backoff + onEvent callback
├── pages/
│ ├── LoginPage.tsx # Login form with Zod validation
│ ├── DashboardPage.tsx # Server grid with lifecycle controls
│ ├── ServerDetailPage.tsx # Server detail with 7 tabs (overview, config, players, bans, missions, mods, logs)
│ ├── CreateServerPage.tsx # 4-step server creation wizard (admin only)
│ └── SettingsPage.tsx # Account (password change) + Users (admin user management)
├── components/
│ ├── layout/
│ │ └── Sidebar.tsx # Left nav with server list
│ ├── servers/
│ │ ├── ServerCard.tsx # Server card with actions
│ │ ├── ServerHeader.tsx # Server name, status, stats grid, lifecycle buttons
│ │ ├── ConfigEditor.tsx # Tabbed config section editor; per-field widgets via useServerConfigSchema
│ │ ├── PlayerTable.tsx # Current players + history with search
│ │ ├── BanTable.tsx # Ban list + create/revoke form
│ │ ├── MissionList.tsx # Available missions + Mission Rotation sections; multi-file upload
│ │ ├── ModList.tsx # Split-pane mod selector (Available vs Selected); Apply Selection button
│ │ └── LogViewer.tsx # Log display with level filter + collapsible Log Files browser (download/delete)
│ ├── settings/
│ │ ├── PasswordChange.tsx # Password change form
│ │ └── UserManager.tsx # User CRUD table (admin only)
│ └── ui/
│ ├── StatusLed.tsx # Colored status indicator dot
│ └── TagListEditor.tsx # Dynamic string-list editor (add/remove items)
└── __tests__/
├── api.test.ts # Axios interceptor tests
├── auth.store.test.ts # Auth store tests
├── ui.store.test.ts # UI store tests
├── logger.test.ts # Logger level-filtering tests
├── StatusLed.test.tsx # StatusLed component tests
├── LoginPage.test.tsx # Login page tests
├── DashboardPage.test.tsx # Dashboard page tests
├── ServerCard.test.tsx # Server card rendering tests
├── ServerCard.handlers.test.tsx # Server card interaction tests
├── Sidebar.test.tsx # Sidebar tests
├── useWebSocket.test.tsx # WebSocket hook tests
├── useServers.test.tsx # Server hooks tests (useServers, useServer, lifecycle, useUpdateServer, useKillServer)
├── useServerDetail.test.tsx # Server detail hooks (config, players, bans, missions, mods, RCon, logfiles)
├── useAuth.test.tsx # Auth hooks tests
├── useGames.test.tsx # Games hooks tests
└── CreateServerPage.test.tsx # Create server wizard (steps, validation, submit, edge cases)
```
## Routes
| Path | Component | Auth | Status |
|---|---|---|---|
| `/login` | `LoginPage` | Public | Implemented |
| `/` | `DashboardPage` | Protected | Implemented |
| `/servers/:serverId` | `ServerDetailPage` | Protected | Implemented (7 tabs) |
| `/servers/new` | `CreateServerPage` | Protected (admin only) | Implemented (4-step wizard) |
| `/settings` | `SettingsPage` | Protected | Implemented (Account + Users tabs) |
`ProtectedLayout` wraps all non-login routes. When `isAuthenticated` is false, it redirects to `/login`.
## Component Tree
```
App
├── QueryClientProvider
│ ├── BrowserRouter
│ │ ├── /login → LoginPage
│ │ └── /* → ProtectedLayout
│ │ ├── Sidebar
│ │ │ ├── Languard branding
│ │ │ ├── Dashboard link
│ │ │ ├── Server list (from useServers)
│ │ │ └── Settings link
│ │ └── main content area
│ │ ├── / → DashboardPage
│ │ │ ├── "2 servers configured" heading
│ │ │ ├── Add Server button
│ │ │ └── Server grid
│ │ │ └── ServerCard × N
│ │ │ ├── StatusLed + name + game type
│ │ │ ├── Stats: Players, Port, Restarts
│ │ │ └── Action buttons: Start/Stop/Restart
│ │ ├── /servers/:id → ServerDetailPage
│ │ │ ├── ServerHeader (status, stats, lifecycle buttons)
│ │ │ ├── Tab bar (Overview, Config, Players, Bans, Missions, Mods, Logs)
│ │ │ ├── OverviewTab (stats grid, executable path)
│ │ │ ├── ConfigEditor (section tabs, per-field widgets from schema, optimistic locking)
│ │ │ ├── PlayerTable (current + history with search)
│ │ │ ├── BanTable (ban list + create/revoke)
│ │ │ ├── MissionList (Available + Rotation sections, multi-file upload)
│ │ │ ├── ModList (split-pane: Available | Selected; Apply Selection)
│ │ │ └── LogViewer (level filter, real-time stream + Log Files browser)
│ │ ├── /servers/new → CreateServerPage
│ │ │ └── 4-step wizard (Game Type → Info → Options → Review)
│ │ └── /settings → SettingsPage
│ │ ├── Account tab → PasswordChange
│ │ └── Users tab → UserManager (admin only)
│ └── ReactQueryDevtools
```
## State Management
### Server State (TanStack Query)
All server data flows through TanStack Query hooks:
**Server CRUD** (`useServers.ts`):
| Hook | Type | Endpoint | Cache Key |
|---|---|---|---|
| `useServers()` | Query | `GET /api/servers` | `["servers"]` (refetch every 30s) |
| `useServer(id)` | Query | `GET /api/servers/:id` | `["servers", id]` |
| `useStartServer()` | Mutation | `POST /api/servers/:id/start` | Invalidates `["servers", id]`, `["servers"]` |
| `useStopServer()` | Mutation | `POST /api/servers/:id/stop` | Invalidates both caches |
| `useRestartServer()` | Mutation | `POST /api/servers/:id/restart` | Invalidates `["servers", id]` |
| `useCreateServer()` | Mutation | `POST /api/servers` | Invalidates `["servers"]` |
| `useDeleteServer()` | Mutation | `DELETE /api/servers/:id` | Invalidates `["servers"]` |
| `useUpdateServer(id)` | Mutation | `PUT /api/servers/:id` | Invalidates `["servers", id]`, `["servers"]` |
| `useKillServer()` | Mutation | `POST /api/servers/:id/kill` | Invalidates `["servers", id]`, `["servers"]` |
**Server Detail** (`useServerDetail.ts`):
| Hook | Type | Endpoint | Cache Key |
|---|---|---|---|
| `useServerConfig(id)` | Query | `GET /api/servers/:id/config` | `["servers", id, "config"]` |
| `useServerConfigSection(id, section)` | Query | `GET /api/servers/:id/config/:section` | `["servers", id, "config", section]` |
| `useServerConfigSchema(id)` | Query | `GET /api/servers/:id/config/schema` (per-field widget hints for ~80 fields: text, toggle, select, number, password, tag-list, hidden, textarea) | `["servers", id, "config", "schema"]` |
| `useServerConfigPreview(id)` | Query | `GET /api/servers/:id/config/preview` | `["servers", id, "config", "preview"]` |
| `useServerPlayers(id)` | Query | `GET /api/servers/:id/players` | `["players", id]` |
| `useServerPlayerHistory(id, opts?)` | Query | `GET /api/servers/:id/players/history` | `["players", id, "history", opts]` |
| `useServerBans(id)` | Query | `GET /api/servers/:id/bans` | `["bans", id]` |
| `useServerMissions(id)` | Query | `GET /api/servers/:id/missions` | `["missions", id]` |
| `useServerMissionRotation(id)` | Query | `GET /api/servers/:id/missions/rotation` | `["missions", id, "rotation"]` |
| `useServerMods(id)` | Query | `GET /api/servers/:id/mods` | `["mods", id]` |
| `useUpdateConfigSection(id, section)` | Mutation | `PUT /api/servers/:id/config/:section` | Invalidates config keys |
| `useCreateBan(id)` | Mutation | `POST /api/servers/:id/bans` | Invalidates `["bans", id]` |
| `useRevokeBan(id)` | Mutation | `DELETE /api/servers/:id/bans/:banId` | Invalidates `["bans", id]` |
| `useUploadMission(id)` | Mutation | `POST /api/servers/:id/missions` (multipart, `File[]`) | Invalidates `["missions", id]` |
| `useUpdateMissionRotation(id)` | Mutation | `PUT /api/servers/:id/missions/rotation` | Invalidates rotation + server config |
| `useDeleteMission(id)` | Mutation | `DELETE /api/servers/:id/missions/:filename` | Invalidates `["missions", id]` |
| `useSetEnabledMods(id)` | Mutation | `PUT /api/servers/:id/mods/enabled` body: `EnabledModEntry[]` | Invalidates `["mods", id]` |
| `useSendCommand(id)` | Mutation | `POST /api/servers/:id/rcon/command` | No invalidation |
| `useKickPlayer(id)` | Mutation | `POST /api/servers/:id/players/:slot_id/kick` | Invalidates `["players", id]` |
| `useBanPlayer(id)` | Mutation | `POST /api/servers/:id/players/:slot_id/ban` | Invalidates players + bans |
| `useServerLogFiles(id)` | Query | `GET /api/servers/:id/logfiles` | `["servers", id, "logfiles"]` (refetch 30s) |
| `useDeleteLogFile(id)` | Mutation | `DELETE /api/servers/:id/logfiles/:filename` | Invalidates logfiles |
**Auth** (`useAuth.ts`):
| Hook | Type | Endpoint |
|---|---|---|
| `useCurrentUser()` | Query | `GET /api/auth/me` |
| `useUsers()` | Query | `GET /api/auth/users` |
| `useChangePassword()` | Mutation | `PUT /api/auth/password` |
| `useCreateUser()` | Mutation | `POST /api/auth/users` |
| `useDeleteUser()` | Mutation | `DELETE /api/auth/users/:id` |
| `useLogout()` | Mutation | `POST /api/auth/logout` + clear auth state |
**Games** (`useGames.ts`):
| Hook | Type | Endpoint |
|---|---|---|
| `useGamesList()` | Query | `GET /api/games` |
| `useGameDetail(gameType)` | Query | `GET /api/games/:gameType` |
| `useGameConfigSchema(gameType)` | Query | `GET /api/games/:gameType/config-schema` |
| `useGameDefaults(gameType)` | Query | `GET /api/games/:gameType/defaults` |
**Key type notes**:
- `Server` type in `useServers.ts` uses `game_port`, `current_players`, `max_players` (matches enriched API response)
- `Mission` type: `{ name, filename, size_bytes, terrain }` — terrain parsed from filename
- `Mod` type: `{ name, path, size_bytes, enabled, is_server_mod, display_name, workshop_id }``display_name`/`workshop_id` from mod.cpp/meta.cpp; `is_server_mod` controls `-serverMod=` vs `-mod=`
- `EnabledModEntry` type: `{ name: string, is_server_mod: boolean }` — used as `useSetEnabledMods` mutation input
- `Ban` type: `{ id, server_id, guid, name, reason, banned_by, banned_at, expires_at, is_active, game_data }` (matches API)
- There is no REST endpoint for logs — logs are only pushed via WebSocket events
QueryClient defaults: `staleTime: 10s`, `retry: 2`, `refetchOnWindowFocus: false`.
### Client State (Zustand)
**auth.store.ts** — Persisted to localStorage under `languard-auth`:
- `token: string | null` — JWT access token
- `user: { id, username, role } | null` — Current user
- `isAuthenticated: boolean` — Derived on rehydration
- `setAuth(token, user)` — Sets token, user, and writes `languard_token` to localStorage
- `clearAuth()` — Clears all state and localStorage keys
**ui.store.ts** — In-memory only:
- `sidebarOpen: boolean` — Sidebar collapse state
- `activeServerId: number | null` — Highlighted server in sidebar
- `notifications: Notification[]` — Toast notifications (auto-remove after 5s)
- `addNotification(type, message)` — Add toast with auto-dismiss
- `removeNotification(id)` — Manual dismiss
## API Client
`src/lib/api.ts` configures Axios with:
- **Base URL**: `VITE_API_URL` env var, defaults to `http://localhost:8000`
- **Timeout**: 30 seconds
- **Request interceptor**: Reads `languard_token` from localStorage, adds `Authorization: Bearer <token>` header
- **Response interceptor**: On 401, checks if URL starts with `/api/auth/` — if NOT an auth endpoint, clears token and redirects to `/login`. Auth endpoint 401s are left for the calling component to handle.
Standard response envelope: `{ success: boolean, data: T | null, error?: string }`
## WebSocket
`useWebSocket.ts` connects to `VITE_WS_URL` (default `ws://localhost:8000`):
- Accepts `UseWebSocketOptions` (or `number[]` for backward compat): `{ serverIds?, onEvent? }`
- Passes JWT token as query parameter: `/ws?token=...&server_id=...`
- Exponential backoff reconnect: starts 2s, doubles up to 30s max
- Close code 4001 = explicit disconnect (no reconnect)
- `onEvent` callback: receives raw WebSocket events for custom handling (e.g., log accumulation in ServerDetailPage)
- Event handlers invalidate TanStack Query caches:
- `server_status``["servers", id]` + `["servers"]`
- `metrics``["metrics", id]`
- `log` → no cache key (no REST endpoint for logs; use onEvent callback instead)
- `players``["players", id]`
**Important**: There is no REST endpoint for server logs. Logs are only available via WebSocket push events. Components that need log data must use the `onEvent` callback to accumulate log entries in local state, then pass them to `LogViewer` as props.
## Design System
Dark neumorphic theme defined in `tailwind.config.js`:
**Colors:**
- Surface: base `#1a1a2e`, raised `#1e1e35`, recessed `#16162a`, overlay `#22223a`
- Accent: amber `#f59e0b` with bright/dim/glow variants
- Status: running green, stopped gray, crashed red, starting amber, restarting blue
- Text: primary `#e2e8f0`, secondary `#94a3b8`, muted `#475569`
**Neumorphic classes:** `neu-card`, `neu-input`, `btn-primary`, `btn-danger`, `btn-ghost`, `status-led-*`
**Custom shadows:** `neu-raised`, `neu-raised-lg`, `neu-recessed`, plus glow variants
**Fonts:** Inter (sans), JetBrains Mono (mono)
## Testing
### Unit Tests (167 tests, Vitest + React Testing Library)
| Test File | Tests | Coverage |
|---|---|---|
| `api.test.ts` | 4 | Interceptors: token header, 401 redirect (non-auth), 401 no-redirect (auth) |
| `auth.store.test.ts` | 8 | Init state, setAuth, clearAuth, localStorage sync, rehydration, partialize |
| `ui.store.test.ts` | 5 | Init state, toggleSidebar, setActiveServer, add/remove notifications |
| `logger.test.ts` | 10 | All 4 log methods, level filtering (debug/warn/error), message format |
| `StatusLed.test.tsx` | 8 | Status classes, showLabel, sizes |
| `LoginPage.test.tsx` | 4 | Form render, validation, API call, error display |
| `DashboardPage.test.tsx` | 5 | Loading/error/empty states, card rendering |
| `ServerCard.test.tsx` | 10 | Card rendering, button visibility, disabled states, actions |
| `ServerCard.handlers.test.tsx` | 9 | Start/stop/restart success/failure notifications |
| `Sidebar.test.tsx` | 6 | Branding, links, loading state, server list, active highlight |
| `useWebSocket.test.tsx` | 5 | No-connect without token, connect, token in URL, invalidation, cleanup |
| `useServers.test.tsx` | 12 | Server CRUD + lifecycle hooks, useUpdateServer, useKillServer |
| `useServerDetail.test.tsx` | 20+ | Config, players, bans, missions, mods, mutations, logfiles, cache invalidation |
| `useAuth.test.tsx` | 7 | Current user, users, change password, create/delete user, logout |
| `useGames.test.tsx` | 5 | Games list, detail, config schema, defaults |
| `CreateServerPage.test.tsx` | 14 | All 4 wizard steps, validation, submit, non-admin gate, API error handling |
### E2E Tests (38 tests, Playwright)
**Login Flow** (6 tests):
- Display login form, branding, validation errors
- Error on invalid credentials (mocked 401)
- Navigation to dashboard on successful login
- Loading state ("Signing in...")
**Dashboard** (12 tests):
- Header, server count, server cards, server names
- Add Server button, sidebar with server list
- Stop button for running server, Start button for stopped
- Player count display, server detail navigation
- Empty state, error state
**Server Detail — 5 UX phases** (15 tests, fully mocked):
- Overview: server name/status, all 6 tabs visible
- Config: field labels rendered (Hostname, BattlEye)
- Missions: mission names, terrain names, Upload button
- Mods: display names, enabled/disabled state
- Players: player list, ping values, Kick buttons
- Logs: collapsible Log Files section, Download buttons, live log viewer area
**Full Stack Integration** (5 tests):
- Login + see A3Master on dashboard (real backend)
- A3Master server details in card (real backend)
- Server detail navigation (real backend)
- Unauthenticated redirect to login
- API response shape validation
## Configuration
### Vite (`vite.config.ts`)
- Path alias `@``./src`
- Dev server on port 5173
- Proxy: `/api``http://localhost:8000`, `/ws``ws://localhost:8000`
### Vitest (`vitest.config.ts`)
- `jsdom` environment
- Global test APIs
- Setup: `src/__tests__/setup.ts` (imports jest-dom matchers)
- Excludes `tests-e2e` directory
### Playwright (`playwright.config.ts`)
- Chromium only
- `baseURL: http://localhost:5173`
- Traces on first retry, screenshots on failure, video on failure
- Dev server auto-started via `npm run dev`
- CI: retries=2, single worker, `forbidOnly`

View File

@@ -1,445 +0,0 @@
# Languard Server Manager — Implementation Plan
## Prerequisites
Before starting, ensure the following are available:
- Python 3.11+
- A working Arma 3 dedicated server installation (for testing)
- Node.js 18+ (for frontend dev server)
- The reference docs: ARCHITECTURE.md, DATABASE.md, API.md, MODULES.md, THREADING.md
---
## Phase 1 — Foundation (Start Here)
**Goal:** Running FastAPI server with DB, auth, and basic server CRUD.
### Step 1.1 — Project scaffold
```
mkdir backend
cd backend
python -m venv venv
venv/Scripts/activate
pip install fastapi uvicorn[standard] sqlalchemy python-jose[cryptography] passlib[bcrypt] cryptography psutil apscheduler python-multipart slowapi pytest pytest-asyncio httpx
# uvloop (faster event loop) is Linux/macOS only — skip on Windows:
# pip install uvloop # only on Linux/macOS
pip freeze > requirements.txt
```
Create:
- `backend/config.py` — Settings class (see MODULES.md)
- `backend/main.py` — FastAPI app factory, startup/shutdown hooks
- `backend/conftest.py` — pytest fixtures (in-memory SQLite, test client)
- `.env.example` — All env vars documented
### Step 1.2 — Database + Migrations
1. Create `backend/migrations/001_initial_schema.sql` — all tables from DATABASE.md
- Include all CHECK constraints (role, status, verify_signatures, von_codec_quality, etc.)
- Include `PRAGMA busy_timeout=5000` in engine setup
- **Important:** Put `CREATE TABLE IF NOT EXISTS schema_migrations` as the very first
statement — the migration runner queries this table before it can track anything.
2. Create `backend/dal/event_repository.py``ServerEventRepository` (needed by Phase 3 threads)
3. Create `backend/database.py`:
- `get_engine()` with WAL + FK pragma
- `run_migrations()` — reads and applies `.sql` files from migrations/
- `get_db()` — FastAPI dependency (sync session)
- `get_thread_db()` — thread-local session factory
3. Call `run_migrations()` in `main.py:on_startup()`
**Test:** Start app, confirm `languard.db` created with all tables. Run `pytest` with in-memory SQLite to verify schema creates cleanly.
### Step 1.3 — Auth module
1. `backend/auth/utils.py``hash_password`, `verify_password`, `create_access_token`, `decode_access_token`
2. `backend/auth/schemas.py``LoginRequest`, `TokenResponse`, `UserResponse`
3. `backend/auth/service.py``AuthService` (create user, login, list users)
4. `backend/auth/router.py` — login, me, users CRUD
5. `backend/dependencies.py``get_current_user`, `require_admin`
6. `main.py` — seed default admin user on first startup if users table empty
- **Generate a random password** and print it to stdout once (NOT admin/admin)
- Add rate limiting to `POST /auth/login` (5 attempts/minute per IP via slowapi)
- Add input sanitization for all string fields in auth schemas
**Test:** `POST /api/auth/login` returns JWT. `GET /api/auth/me` with token returns user. Rate limiting returns 429 after 5 failed attempts.
### Step 1.4 — Server CRUD (no process management yet)
1. `backend/dal/server_repository.py`
2. `backend/dal/config_repository.py`
3. `backend/servers/schemas.py`
4. `backend/servers/router.py` — GET, POST, PUT, DELETE /servers and /servers/{id}
5. `backend/servers/service.py` — CRUD methods only (skip start/stop for now)
6. `backend/utils/file_utils.py``ensure_server_dirs()`, `sanitize_filename()`
7. `backend/utils/port_checker.py``is_port_in_use()`, `check_server_ports_available()`
8. Port validation on create/start: check game_port through game_port+4
**Test:** Create server via API, confirm DB row + directory created.
---
## Phase 2 — Process Management
**Goal:** Start/stop actual `arma3server.exe` processes.
### Step 2.1 — Config Generator
1. `backend/servers/config_generator.py`
2. **Use a structured builder** (NOT f-strings) — escape double quotes and newlines in all user-supplied string values to prevent config injection
3. Write `server.cfg` covering all params from DATABASE.md, including mission rotation as `class Missions {}` block
4. Write `basic.cfg`
5. Write `server.Arma3Profile`**written to `servers/{id}/server/server.Arma3Profile`** (Arma 3 reads from the `-name` subdirectory)
6. Write `BESERVER_CFG_TEMPLATE`**required for BattlEye RCon to work**
```
# servers/{id}/battleye/beserver.cfg
RConPassword {rcon_password}
RConPort {rcon_port}
```
`write_beserver_cfg()` must create the `battleye/` directory and write this file.
Without it BattlEye will not open an RCon port regardless of launch parameters.
7. `build_launch_args()` — assembles full CLI arg list
- Include `-bepath=./battleye` to point BE at the generated config (relative to cwd)
- Include `-profiles=./` and `-name=server` for profile directory
- All relative paths resolve against `cwd=servers/{id}/` set in ProcessManager
8. Set file permissions 0600 on config files containing passwords (server.cfg, beserver.cfg)
**Test:** `ConfigGenerator.write_all(server_id)` → inspect all generated files for correctness.
Verify `servers/{id}/battleye/beserver.cfg` exists with the correct RCon password.
Verify `servers/{id}/server/server.Arma3Profile` exists.
Test config injection prevention: set hostname to `X"; passwordAdmin = "pwned"; //` — verify generated server.cfg does NOT contain the injected directive.
Validate generated `server.cfg` manually by running the server with it.
### Step 2.2 — Process Manager
1. `backend/servers/process_manager.py` — `ProcessManager` singleton
2. `start(server_id, exe_path, args, cwd=servers/{id}/)` — subprocess.Popen with cwd set to server instance dir
3. `stop(server_id, timeout=30)` — on Windows: `terminate()` = hard kill (no SIGTERM). Graceful shutdown is via RCon `#shutdown` in ServerService.
4. `kill()`, `is_running()`, `get_pid()`
5. `recover_on_startup()` — verify PID is alive AND process name matches arma3server (prevents PID reuse)
6. Wire `ServerService.start()` and `ServerService.stop()`
7. Add `POST /servers/{id}/start`, `POST /servers/{id}/stop`, `POST /servers/{id}/kill` endpoints
**Test:** Start a server via API → confirm process appears in Task Manager. Stop it → confirm process ends.
### Step 2.3 — Config endpoints
1. `GET /servers/{id}/config`
2. `PUT /servers/{id}/config/server`
3. `PUT /servers/{id}/config/basic`
4. `PUT /servers/{id}/config/profile`
5. `PUT /servers/{id}/config/launch`
6. `GET /servers/{id}/config/preview`
**Test:** Update hostname via API → regenerate and start server → confirm new hostname appears in server browser.
---
## Phase 3 — Background Threads
**Goal:** Live monitoring — process crash detection, log tailing, metrics.
### Step 3.1 — Thread infrastructure
1. `backend/threads/base_thread.py` — `BaseServerThread`
2. `backend/threads/thread_registry.py` — `ThreadRegistry` singleton
3. Wire `start_server_threads()` / `stop_server_threads()` into `ServerService.start()` / `ServerService.stop()`
### Step 3.2 — Process Monitor Thread
1. `backend/threads/process_monitor.py`
2. Crash detection + status update in DB
3. Auto-restart with exponential backoff
**Test:** Start server → kill process manually → confirm DB status changes to 'crashed'.
**Test:** Enable auto_restart → kill → confirm server restarts automatically.
### Step 3.3 — Log Tail Thread
1. `backend/logs/parser.py` — `RPTParser`
2. `backend/dal/log_repository.py`
3. `backend/threads/log_tail.py`
4. `backend/logs/service.py`
5. `backend/logs/router.py` — `GET /servers/{id}/logs`
**Test:** Start server → `GET /api/servers/{id}/logs` returns recent RPT lines.
### Step 3.4 — Metrics Collector Thread
1. `backend/metrics/service.py`
2. `backend/dal/metrics_repository.py`
3. `backend/threads/metrics_collector.py`
4. `backend/metrics/router.py` — `GET /servers/{id}/metrics`
**Test:** Running server → query metrics endpoint → see CPU/RAM data points.
---
## Phase 4 — BattlEye RCon
**Goal:** Real-time player list, in-game admin commands.
### Step 4.1 — RCon Client
1. `backend/rcon/client.py` — `BERConClient`
2. Implement BE RCon UDP protocol:
- Packet structure: `'BE'` + CRC32 (little-endian) + type byte + payload
- Login: type `0x00`, payload = password
- Command: type `0x01`, payload = sequence byte + command string
- Keepalive: type `0x02`, payload = empty
3. **Request multiplexer**: track pending requests by sequence byte, route responses to correct caller via `threading.Event` per request. Background receiver thread reads all incoming packets.
4. `parse_players_response()` — parse `players` command output
5. Handle unsolicited server messages (type 0x02) — enqueue for event logging
BattlEye RCon packet format reference:
```
Login packet (client → server):
42 45 # 'BE'
[CRC32 LE] # checksum of bytes after CRC
FF # packet type prefix
00 # login type
[password] # ASCII password
Command packet:
42 45
[CRC32 LE]
FF
01
[seq byte] # 0x00-0xFF, wraps around
[command] # ASCII command string
Command response (server → client):
42 45
[CRC32 LE]
FF
01 # 0x01 = command response (same type byte as outgoing command)
[seq byte]
[response] # ASCII response text
Server-pushed message (server → client, unsolicited):
42 45
[CRC32 LE]
FF
02 # 0x02 = server message (chat events, kill events, etc.)
[seq byte]
[message] # ASCII message text
```
**Test:** Connect BERConClient to a running server with BattlEye → successfully login → send `players` → receive response.
### Step 4.2 — RCon Service + Poller Thread
1. `backend/rcon/service.py` — `RConService`
2. `backend/threads/rcon_poller.py`
3. `backend/dal/player_repository.py`
4. `backend/players/service.py`
5. `backend/players/router.py` — `GET /servers/{id}/players`
**Test:** Players join server → `GET /players` returns them with pings.
### Step 4.3 — Admin Actions via RCon
1. `POST /servers/{id}/players/{num}/kick`
2. `POST /servers/{id}/players/{num}/ban`
3. `POST /servers/{id}/rcon/command`
4. `POST /servers/{id}/rcon/say`
5. `backend/dal/ban_repository.py`
6. `GET/POST/DELETE /servers/{id}/bans`
7. **ban.txt bidirectional sync**: on ban add/delete via API, write to `battleye/ban.txt`; on startup, read `ban.txt` and upsert into DB
**Test:** Kick a player via API → confirm player disconnected from server.
---
## Phase 5 — WebSocket Real-Time
**Goal:** Live updates to React frontend without polling.
### Step 5.1 — Broadcast infrastructure
1. `backend/websocket/broadcaster.py` — `BroadcastThread` + `enqueue()`
2. `backend/websocket/manager.py` — `ConnectionManager`
3. Store event loop reference in `main.py:on_startup()`:
```python
import asyncio
# on_startup() runs inside the asyncio event loop — use get_running_loop(),
# not get_event_loop() (deprecated in Python 3.10+ from async context).
_event_loop = asyncio.get_running_loop()
broadcaster.init(_event_loop, connection_manager)
```
4. Start `BroadcastThread` in `on_startup()`
5. Wire `BroadcastThread.enqueue()` calls into all background threads
### Step 5.2 — WebSocket endpoint
1. `backend/websocket/router.py`
2. JWT validation from query param
3. Subscribe/unsubscribe message handling
4. Ping/pong keepalive
**Test:** Connect to `ws://localhost:8000/ws/1?token=...` → see live log lines stream in terminal.
### Step 5.3 — Integrate all event sources
Wire `BroadcastThread.enqueue()` into:
- `ProcessMonitorThread` → status updates, crash events
- `LogTailThread` → log lines
- `MetricsCollectorThread` → metrics snapshots
- `RConPollerThread` → player list updates
- `ServerService.start/stop` → status transitions
**Test:** React frontend connects to WS → server starts → see status, logs, metrics all update in real time.
---
## Phase 6 — Mission & Mod Management
### Step 6.1 — Missions
1. `backend/missions/service.py`
2. `backend/missions/router.py`
3. Upload PBO validation (check `.pbo` extension, parse name)
4. Mission rotation CRUD
**Test:** Upload a `.pbo` → appears in `GET /missions` → set as rotation → start server → mission available.
### Step 6.2 — Mods
1. `backend/mods/service.py`
2. `backend/mods/router.py`
3. `build_mod_string()` — assemble `-mod=` and `-serverMod=` args
4. Wire mod string into `ConfigGenerator.build_launch_args()`
**Test:** Register `@CBA_A3` → enable on server → start → server loads mod.
---
## Phase 7 — Polish & Production
### Step 7.1 — APScheduler jobs
Add to `on_startup()`:
```python
# Use BackgroundScheduler (not AsyncIOScheduler) because cleanup methods
# perform sync SQLite operations. AsyncIOScheduler would block the event loop.
from apscheduler.schedulers.background import BackgroundScheduler
scheduler = BackgroundScheduler()
scheduler.add_job(log_service.cleanup_old_logs, 'cron', hour=3)
scheduler.add_job(metrics_service.cleanup_old_metrics, 'cron', hour=3, minute=30)
scheduler.add_job(player_service.cleanup_old_history, 'cron', hour=4) # 90-day retention
scheduler.start()
```
### Step 7.2 — Startup recovery
In `on_startup()` → `ProcessManager.recover_on_startup()`:
- Query DB for servers with `status='running'`
- Check if PID still alive (`psutil.pid_exists(pid)`)
- If alive: re-attach threads (skip process start, just start monitoring threads)
- If dead: mark as `crashed`, clear players
### Step 7.3 — Events log
1. `backend/dal/event_repository.py`
2. Insert events for: start, stop, crash, kick, ban, config change, mission change
3. `GET /servers/{id}/events` endpoint
### Step 7.4 — Security hardening (additional layers)
1. Encrypt sensitive DB fields: `password`, `password_admin`, `rcon_password`
- `backend/utils/crypto.py` with Fernet
- **Key format:** `LANGUARD_ENCRYPTION_KEY` must be a Fernet base64 key, NOT hex.
Generate with: `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`
Passing a hex string to `Fernet()` raises `ValueError` at startup.
- Encrypt on write, decrypt on read in repositories
- **NOTE:** Core security (rate limiting, input sanitization, config escaping, exe path validation) is already in Phases 1-2.
2. Additional penetration testing and security audit
3. Content-Security-Policy headers for frontend
### Step 7.5 — Frontend integration checklist
Verify React app can:
- [ ] Login and store JWT
- [ ] List servers with live status
- [ ] Start/stop server and see status update via WebSocket (no page refresh)
- [ ] View streaming log output
- [ ] See player list update every 10s
- [ ] See CPU/RAM charts update every 5s
- [ ] Edit all config sections and see preview
- [ ] Upload a mission PBO
- [ ] Kick a player
- [ ] Send a message to all players
---
## Testing Strategy
### Unit tests (pytest)
- `ConfigGenerator.write_server_cfg()` — compare output against expected string; test config injection prevention
- `ConfigGenerator._escape_config_string()` — test double-quote and newline escaping
- `RPTParser.parse_line()` — test all log formats
- `BERConClient.parse_players_response()` — test with sample output
- `AuthService.login()` — correct password / wrong password / rate limiting
- Repository methods — use in-memory SQLite (`:memory:`)
- `check_server_ports_available()` — test derived port validation
- `sanitize_filename()` — test path traversal prevention
- In-memory SQLite setup in `conftest.py` — shared fixture for all repository tests
### Integration tests
- Full start/stop cycle with a real arma3server.exe (manual — requires licensed Arma 3 installation, not in CI)
- WebSocket message delivery (can be automated with httpx test client)
- RCon command round-trip (manual — requires running server with BattlEye)
### Load notes
- SQLite with WAL handles concurrent reads from 4 threads per server well
- For >10 simultaneous servers, consider connection pool size tuning
- WebSocket broadcast scales to ~100 concurrent connections without issue
---
## Environment Setup (Developer)
```bash
# 1. Clone repo
git clone <repo>
cd languard-server-manager
# 2. Backend
cd backend
python -m venv venv
source venv/bin/activate # or venv\Scripts\activate on Windows
pip install -r requirements.txt
# 3. Environment
cp .env.example .env
# Edit .env: set LANGUARD_ARMA_EXE to your arma3server_x64.exe path
# 4. Run backend
uvicorn main:app --reload --host 0.0.0.0 --port 8000
# 5. Frontend (separate)
cd ../frontend
npm install
npm run dev
```
Backend auto-creates `languard.db` and seeds an admin user on first run:
- Username: `admin`
- Password: **randomly generated** and printed to stdout once (e.g., `Initial admin password: a7b9c2d4e5f6...`)
- Change immediately via `PUT /api/auth/password`
---
## Phase Summary
| Phase | Deliverable | Est. Complexity |
|-------|-------------|----------------|
| 1 | Foundation (auth + server CRUD) | Low |
| 2 | Process management + config gen | Medium |
| 3 | Background threads (monitor, logs, metrics) | Medium-High |
| 4 | BattlEye RCon (player list, admin cmds) | High |
| 5 | WebSocket real-time | Medium |
| 6 | Mission + mod management | Low-Medium |
| 7 | Polish, security, recovery | Medium |
Implement phases in order — each phase builds on the previous and is independently testable.

1111
MODULES.md

File diff suppressed because it is too large Load Diff

228
README.md Normal file
View File

@@ -0,0 +1,228 @@
# Languard Server Manager
A multi-game server management platform with a Python/FastAPI backend and React/TypeScript frontend. Currently supports Arma 3 with an extensible adapter system for adding more games.
## Tech Stack
### Backend
- **Python 3.12+** / **FastAPI** — async REST API
- **SQLite** with WAL mode — zero-config database
- **SQLAlchemy** — raw SQL via `text()` queries (no ORM)
- **BattlEye RCon** — UDP protocol v2 for remote admin
- **APScheduler** — background cleanup jobs
- **psutil** — process monitoring and resource metrics
- **JWT** (python-jose) + **bcrypt** — authentication
- **Fernet** (cryptography) — sensitive config field encryption
### Frontend
- **React 19** / **TypeScript 6** / **Vite 8**
- **TanStack Query v5** — server state management
- **Zustand 5** — client state (auth, UI)
- **Tailwind CSS** — dark neumorphic design system
- **Playwright** — E2E testing
- **Vitest** + **React Testing Library** — unit tests (173 tests)
## Quick Start
### 1 — Backend setup
```bash
cd backend
# Create and activate a virtual environment
python -m venv venv
source venv/bin/activate # macOS / Linux
# venv\Scripts\activate # Windows (cmd)
# venv\Scripts\Activate.ps1 # Windows (PowerShell)
pip install -r requirements.txt
```
**Generate required secrets** (one-time):
```bash
# Secret key (JWT signing)
openssl rand -hex 32
# Fernet encryption key (sensitive config fields at rest)
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
```
Copy `.env.example` to `.env` and fill in the two keys:
```bash
cp .env.example .env # then open .env in your editor
```
```ini
# .env — minimum required values
LANGUARD_SECRET_KEY=<output of openssl command>
LANGUARD_ENCRYPTION_KEY=<output of Fernet command>
LANGUARD_ARMA3_DEFAULT_EXE=C:/path/to/arma3server_x64.exe
```
**Start the backend** (development — auto-reload on file changes):
```bash
uvicorn main:app --host 0.0.0.0 --port 8000 --reload
```
First run prints a randomly-generated admin password to the console. Log in and change it immediately via Settings → Change Password (or `PUT /api/auth/password`).
- API root: `http://localhost:8000`
- Interactive docs: `http://localhost:8000/docs`
**Debug in VS Code:** add this `launch.json` configuration:
```json
{
"name": "Backend — uvicorn",
"type": "debugpy",
"request": "launch",
"module": "uvicorn",
"args": ["main:app", "--host", "0.0.0.0", "--port", "8000"],
"cwd": "${workspaceFolder}/backend",
"env": { "PYTHONDONTWRITEBYTECODE": "1" },
"jinja": true,
"justMyCode": false
}
```
---
### 2 — Frontend setup
```bash
cd frontend
npm install
npm run dev
```
The Vite dev server starts at `http://localhost:5173` and automatically proxies:
- `/api/*``http://localhost:8000` (REST)
- `/ws/*``ws://localhost:8000` (WebSocket)
**Debug in VS Code:** install the [JavaScript Debugger](https://marketplace.visualstudio.com/items?itemName=ms-vscode.js-debug) extension (bundled by default), then add:
```json
{
"name": "Frontend — Vite (Chrome)",
"type": "chrome",
"request": "launch",
"url": "http://localhost:5173",
"webRoot": "${workspaceFolder}/frontend/src",
"sourceMapPathOverrides": {
"/@fs/*": "${workspaceFolder}/frontend/*"
}
}
```
Start the Vite dev server first (`npm run dev`), then launch this config to attach Chrome DevTools with source-map support.
---
## Running Tests
### Backend
```bash
cd backend
source venv/bin/activate # (if not already active)
pytest # all tests
pytest tests/adapters/arma3/ -v # adapter tests only
pytest --tb=short -q # quiet output
```
### Frontend unit tests
```bash
cd frontend
npm test # single run (CI-friendly)
npm run test:watch # watch mode during development
npx vitest run --coverage # with coverage report
```
### Frontend E2E tests (Playwright)
Start the backend and the Vite dev server first, then:
```bash
cd frontend
npm run test:e2e # all E2E tests (headless)
npm run test:e2e:ui # Playwright UI mode (interactive, great for debugging)
npx playwright test --headed # watch tests run in an actual browser
```
Integration tests (require a live backend) live in `tests-e2e/integration/`. All other tests use API mocks and run without a backend.
## Project Structure
```
languard-servers-manager/
├── backend/
│ ├── main.py # FastAPI app factory, lifespan, middleware
│ ├── config.py # Pydantic Settings (env vars)
│ ├── database.py # SQLAlchemy engine, migration runner
│ ├── dependencies.py # FastAPI deps: auth, admin, server, adapter
│ ├── adapters/ # Game adapter system
│ │ ├── protocols.py # Protocol definitions (7 capabilities)
│ │ ├── registry.py # GameAdapterRegistry singleton
│ │ ├── exceptions.py # Typed adapter exceptions
│ │ └── arma3/ # Arma 3 adapter (7/7 capabilities)
│ ├── core/
│ │ ├── auth/ # JWT auth, user CRUD
│ │ ├── servers/ # Server service, routers, process manager
│ │ ├── games/ # Game type discovery
│ │ ├── system/ # Health and status endpoints
│ │ ├── websocket/ # WS manager, broadcast thread
│ │ ├── threads/ # Background thread registry
│ │ ├── dal/ # Data access layer (repositories)
│ │ ├── jobs/ # APScheduler cleanup jobs
│ │ ├── utils/ # Crypto, file utils, port checker
│ │ └── migrations/ # SQL migration scripts
│ └── requirements.txt
├── frontend/
│ ├── src/
│ │ ├── App.tsx # Router + auth guard
│ │ ├── pages/ # LoginPage, DashboardPage, ServerDetailPage, CreateServerPage, SettingsPage
│ │ ├── components/ # Sidebar, ServerCard, ConfigEditor, PlayerTable, BanTable, MissionList, ModList, LogViewer, StatusLed
│ │ ├── hooks/ # useServers, useServerDetail, useAuth, useGames, useWebSocket
│ │ ├── store/ # auth.store, ui.store (Zustand)
│ │ ├── lib/ # api.ts (Axios client)
│ │ └── __tests__/ # Vitest unit tests (173 tests)
│ ├── tests-e2e/ # Playwright E2E tests
│ └── playwright.config.ts
├── API.md # REST + WebSocket API reference
├── ARCHITECTURE.md # System architecture overview
├── DATABASE.md # Database schema reference
├── FRONTEND.md # Frontend architecture and components
├── MODULES.md # Module-by-module reference
└── THREADING.md # Background threading model
```
## Environment Variables
| Variable | Default | Description |
|---|---|---|
| `LANGUARD_SECRET_KEY` | (required) | JWT signing key |
| `LANGUARD_ENCRYPTION_KEY` | (required) | Fernet key for sensitive config fields |
| `LANGUARD_DB_PATH` | `./languard.db` | SQLite database path |
| `LANGUARD_SERVERS_DIR` | `./servers` | Base directory for server data |
| `LANGUARD_HOST` | `0.0.0.0` | Listen host |
| `LANGUARD_PORT` | `8000` | Listen port |
| `LANGUARD_CORS_ORIGINS` | `["http://localhost:5173"]` | CORS allowed origins |
| `LANGUARD_LOG_RETENTION_DAYS` | `7` | Log cleanup retention |
| `LANGUARD_METRICS_RETENTION_DAYS` | `30` | Metrics cleanup retention |
| `LANGUARD_PLAYER_HISTORY_RETENTION_DAYS` | `90` | Player history retention |
| `LANGUARD_JWT_EXPIRE_HOURS` | `24` | JWT token expiry |
| `LANGUARD_ARMA3_DEFAULT_EXE` | (required for Arma 3) | Default Arma 3 executable path |
## Documentation
- **[ARCHITECTURE.md](ARCHITECTURE.md)** — System design, component diagram, security model
- **[API.md](API.md)** — Complete REST + WebSocket API reference
- **[DATABASE.md](DATABASE.md)** — Schema, tables, indexes, migration system
- **[FRONTEND.md](FRONTEND.md)** — React component tree, state management, design system
- **[MODULES.md](MODULES.md)** — File-by-file module reference
- **[THREADING.md](THREADING.md)** — Background thread model and concurrency

View File

@@ -1,600 +1,173 @@
# Languard Server Manager — Threading & Concurrency Design # Threading & Concurrency Model
## Overview ## Overview
The system uses a hybrid concurrency model: Languard uses a hybrid concurrency model:
- **FastAPI (asyncio)** handles HTTP requests and WebSocket connections
- **Python threads** (`threading.Thread`) handle long-running background work per server
- **Queue** bridges the thread world → asyncio world for WebSocket broadcasting
- **SQLAlchemy sync sessions** are used in threads (thread-local connections)
--- - **FastAPI (asyncio)** handles HTTP requests and WebSocket connections on the main event loop
- **Python `threading.Thread`** handles long-running background work per server
- **`queue.Queue`** bridges the thread world to the asyncio world for WebSocket broadcasting
- **SQLAlchemy sync sessions** with thread-local connections provide thread-safe database access
## Thread Map ## Thread Architecture
``` For N running servers, the system runs up to 4N+1 background threads:
Main Process (FastAPI / asyncio event loop)
├── [uvicorn] HTTP/WS event loop (asyncio)
│ ├── REST request handlers (async def)
│ └── WebSocket handlers (async def)
├── BroadcastThread (daemon thread, 1 global)
│ └── Reads from broadcast_queue (thread-safe)
│ Calls asyncio.run_coroutine_threadsafe()
│ → ConnectionManager.broadcast()
└── Per-running-server thread group (started when server starts, stopped when server stops):
├── ProcessMonitorThread (1 per server, 1s interval)
├── LogTailThread (1 per server, 100ms interval)
├── MetricsCollectorThread (1 per server, 5s interval)
└── RConPollerThread (1 per server, 10s interval, 30s startup delay)
```
For **N running servers**, there are: | Thread Type | Count | Purpose |
- `4*N` background threads + 1 BroadcastThread = `4N+1` background threads total |---|---|---|
| `BroadcastThread` | 1 (global) | Bridges `queue.Queue` to asyncio WebSocket broadcasts |
| `LogTailThread` | 1 per server | Tails .rpt log files, parses lines, persists to DB, broadcasts events |
| `ProcessMonitorThread` | 1 per server | Monitors server process, detects crashes, triggers auto-restart |
| `MetricsCollectorThread` | 1 per server | Collects CPU/RAM metrics via psutil every 10 seconds |
| `RemoteAdminPollerThread` | 1 per server | Polls player list via RCon, syncs join/leave events |
--- All server-specific threads are managed by `ThreadRegistry`, which creates/destroys thread bundles as servers start/stop.
## Thread Safety Rules ## BaseServerThread
| Resource | Access Pattern | Protection | All background threads extend `BaseServerThread`, which provides:
|----------|---------------|------------|
| `ProcessManager._processes` | read/write from multiple threads | `threading.Lock` |
| `ThreadRegistry._threads` | read/write from main + shutdown | `threading.Lock` |
| `broadcast_queue` | multi-writer, single reader | `queue.Queue` (thread-safe built-in) |
| `ConnectionManager._connections` | async, single event loop | `asyncio.Lock` |
| SQLite connections | one connection per thread | Thread-local via `threading.local()` |
| Config files on disk | write on start, read-only during run | No lock needed (regenerated before start) |
### SQLite Thread Safety - **Stop event**: `threading.Event` for graceful shutdown
```python - **Thread-local DB**: Creates a fresh SQLAlchemy connection per thread via `get_thread_db()`
# Each background thread creates its own SQLAlchemy connection - **Exception backoff**: On unhandled exceptions, sleeps with exponential backoff (5s → 30s max), then retries. If stop event is set, exits cleanly.
# from the same engine (WAL mode allows concurrent reads) - **Abstract `run_loop()` method**: Subclasses implement the main loop, called repeatedly until stop event is set
# PRAGMA busy_timeout=5000 prevents "database is locked" errors
class BaseServerThread(threading.Thread):
def run(self):
# Create thread-local DB connection — single connection per thread
engine = get_engine()
self._db = engine.connect()
try:
self.setup()
while not self._stop_event.is_set():
try:
self.tick()
except Exception as e:
self.on_error(e)
self._stop_event.wait(self.interval)
except Exception as e:
logger.error(f"{self.name} setup error: {e}")
finally:
self.teardown() # always release resources (even on setup failure)
self._db.close() # always close connection
```
---
## BroadcastThread — Asyncio Bridge
This is the critical bridge between background threads and the asyncio WebSocket layer.
```
Background Thread Asyncio Event Loop
───────────────── ──────────────────
BroadcastThread.enqueue( uvicorn runs here
server_id=1,
msg_type='log',
data={...}
)
broadcast_queue.put({ loop = asyncio.get_event_loop()
'server_id': 1, (stored at app startup)
'type': 'log',
'data': {...}
})
BroadcastThread.run() ──────────────────► asyncio.run_coroutine_threadsafe(
while True: connection_manager.broadcast(
msg = queue.get() server_id=1,
fut = run_coroutine_threadsafe( message={type, data}
broadcast_coro, ),
self._loop loop=self._loop
) )
fut.result(timeout=5)
```
### Implementation Sketch
```python
# broadcaster.py
import asyncio
import queue
import threading
_broadcast_queue: queue.Queue = queue.Queue(maxsize=10000)
_event_loop: asyncio.AbstractEventLoop | None = None
class BroadcastThread(threading.Thread):
daemon = True
def __init__(self, loop: asyncio.AbstractEventLoop, manager):
super().__init__(name="BroadcastThread")
self._loop = loop
self._manager = manager
self._running = True
def run(self):
while self._running:
try:
msg = _broadcast_queue.get(timeout=1.0)
server_id = msg['server_id']
# Build the outgoing WebSocket message envelope.
# Include server_id so clients subscribed to 'all' can identify the source.
# API contract: {type, server_id, data}
outgoing = {
'type': msg['type'],
'server_id': server_id,
'data': msg['data'],
}
future = asyncio.run_coroutine_threadsafe(
self._manager.broadcast(str(server_id), outgoing, channel=msg['type']),
self._loop
)
try:
future.result(timeout=5.0)
except TimeoutError:
# Don't block the queue — log and continue
logger.warning(f"Broadcast timeout for server {server_id} msg type {msg['type']}")
except queue.Empty:
continue
except Exception as e:
logger.error(f"BroadcastThread error: {e}")
def stop(self):
self._running = False
@staticmethod
def enqueue(server_id: int, msg_type: str, data: dict):
"""Thread-safe. Called from any background thread."""
try:
_broadcast_queue.put_nowait({
'server_id': server_id,
'type': msg_type,
'data': data,
})
except queue.Full:
logger.warning(f"Broadcast queue full, dropping {msg_type} for server {server_id}")
```
---
## ProcessMonitorThread — Crash Detection & Auto-Restart
```python
class ProcessMonitorThread(BaseServerThread):
interval = 1.0
def tick(self):
proc = ProcessManager.get().get_process(self.server_id)
if proc is None:
self.stop()
return
exit_code = proc.poll()
if exit_code is not None:
# Process has exited
self._handle_process_exit(exit_code)
self.stop()
def _handle_process_exit(self, exit_code: int):
is_crash = (exit_code != 0)
status = 'crashed' if is_crash else 'stopped'
server = ServerRepository(self._db).get_by_id(self.server_id)
ServerRepository(self._db).update_status(
self.server_id, status, pid=None,
stopped_at=datetime.utcnow().isoformat()
)
PlayerRepository(self._db).clear(self.server_id)
ServerEventRepository(self._db).insert(
self.server_id, status,
actor='system',
detail={'exit_code': exit_code}
)
BroadcastThread.enqueue(self.server_id, 'status', {'status': status})
BroadcastThread.enqueue(self.server_id, 'event', {
'event_type': status,
'detail': {'exit_code': exit_code}
})
# Stop other threads for this server. Must NOT be called synchronously
# from within this thread's own run() if stop_server_threads() joins threads,
# as a thread cannot join itself. Use a daemon thread to do the cleanup
# after this thread's run() returns naturally.
# IMPORTANT: The auto-restart Timer must be started AFTER thread cleanup
# completes. The cleanup daemon thread starts the restart timer when done.
import threading as _threading
def _cleanup_and_maybe_restart():
try:
ThreadRegistry.get().stop_server_threads(self.server_id)
# Only schedule restart after threads are fully cleaned up
if is_crash and server.get('auto_restart'):
self._schedule_auto_restart(server)
except Exception as e:
logger.error(f"Cleanup/restart failed for server {self.server_id}: {e}")
BroadcastThread.enqueue(self.server_id, 'event', {
'event_type': 'auto_restart_failed',
'detail': {'error': str(e)}
})
_threading.Thread(
target=_cleanup_and_maybe_restart,
daemon=True,
name=f"StopCleanup-{self.server_id}"
).start()
def _schedule_auto_restart(self, server: dict):
# IMPORTANT: This method runs in the daemon cleanup thread, NOT the
# ProcessMonitorThread. Must create its own DB connection — do NOT
# use self._db (it belongs to the ProcessMonitorThread's thread context
# and may be closed by teardown() already).
from database import get_thread_db
db = get_thread_db()
restart_count = server['restart_count']
max_restarts = server['max_restarts']
window = server['restart_window_seconds']
last_restart = server.get('last_restart_at')
# Reset restart_count if last restart was outside the window
if last_restart:
last_dt = datetime.fromisoformat(last_restart)
elapsed = (datetime.utcnow() - last_dt).total_seconds()
if elapsed > window:
ServerRepository(db).reset_restart_count(self.server_id)
restart_count = 0
if restart_count < max_restarts:
delay = min(10 * (restart_count + 1), 60) # exponential backoff
logger.info(f"Auto-restarting server {self.server_id} in {delay}s (attempt {restart_count+1}/{max_restarts})")
threading.Timer(delay, self._auto_restart).start()
else:
logger.warning(f"Server {self.server_id} exceeded max auto-restarts ({max_restarts})")
BroadcastThread.enqueue(self.server_id, 'event', {
'event_type': 'max_restarts_exceeded',
'detail': {'restart_count': restart_count}
})
def _auto_restart(self):
from servers.service import ServerService
try:
ServerService().start(self.server_id)
except Exception as e:
logger.error(f"Auto-restart failed for server {self.server_id}: {e}")
```
---
## LogTailThread — RPT File Tailing
The Arma 3 RPT file grows while the server runs. This thread tails it like `tail -f`.
```python
class LogTailThread(BaseServerThread):
interval = 0.1 # 100ms
def setup(self):
self._file = None
self._current_path: Path | None = None
self._last_size: int = 0
self._open_latest_rpt()
def _open_latest_rpt(self):
"""
Arma 3 writes timestamped RPT files in the profile subdirectory:
servers/{id}/server/arma3server_YYYY-MM-DD_HH-MM-SS.rpt
Use rglob('*.rpt') to search recursively within the server dir.
The profile subdirectory is determined by -profiles + -name flags.
NOTE: Do NOT use os.stat().st_ino for rotation detection — on Windows/NTFS
st_ino is always 0, making inode comparison completely non-functional.
Instead, track the filename and file size. If a newer .rpt appears or the
current file shrinks (truncated/replaced), reopen.
"""
rpt_files = list(Path(get_server_dir(self.server_id)).rglob("*.rpt"))
if not rpt_files:
return # Server hasn't created RPT yet; retry in next tick
latest = max(rpt_files, key=lambda p: p.stat().st_mtime)
try:
self._file = open(latest, 'r', encoding='utf-8', errors='replace')
self._file.seek(0, 2) # seek to end — tail, don't replay old output
self._current_path = latest
self._last_size = self._file.tell()
except OSError:
self._file = None
def tick(self):
if self._file is None:
self._open_latest_rpt()
return
# Rotation detection: only re-glob every 5 seconds (not every 100ms tick)
# to avoid excessive filesystem I/O with large mpmissions directories.
now = time.monotonic()
if now - getattr(self, '_last_glob_time', 0) > 5.0:
self._last_glob_time = now
rpt_files = list(Path(get_server_dir(self.server_id)).rglob("*.rpt"))
if rpt_files:
latest = max(rpt_files, key=lambda p: p.stat().st_mtime)
if latest != self._current_path:
# A new RPT file was created — switch to it
self._file.close()
self._open_latest_rpt()
return
try:
current_size = self._current_path.stat().st_size
except OSError:
return
if current_size < self._last_size:
# File shrank — truncated or replaced; reopen
self._file.close()
self._open_latest_rpt()
return
# Read new lines
while True:
line = self._file.readline()
if not line:
break
self._last_size = self._file.tell()
line = line.rstrip('\n')
if not line:
continue
entry = RPTParser.parse_line(line)
if entry:
LogRepository(self._db).insert(self.server_id, entry)
BroadcastThread.enqueue(self.server_id, 'log', entry)
def teardown(self):
"""Close the open RPT file handle when the thread stops."""
if self._file is not None:
try:
self._file.close()
except OSError:
pass
self._file = None
```
---
## RConPollerThread — Player List Synchronization
```python
class RConPollerThread(BaseServerThread):
interval = 10.0
STARTUP_DELAY = 30.0 # wait for server to fully initialize
_rcon_ready = False # flag: True only after successful setup
def setup(self):
# Wait for server to start up before attempting RCon
if self._stop_event.wait(self.STARTUP_DELAY):
self._rcon_ready = False
return # stop was requested during wait
self._rcon = RConService(self.server_id)
self._connected = self._rcon.connect()
self._rcon_ready = True
def tick(self):
if not self._rcon_ready:
return # setup() failed or was interrupted
if not self._connected:
self._reconnect_attempts = getattr(self, '_reconnect_attempts', 0) + 1
delay = min(10 * 2 ** self._reconnect_attempts, 120) # exponential backoff
if self._reconnect_attempts > 1:
logger.info(f"RCon reconnect attempt {self._reconnect_attempts} for server {self.server_id} (next in {delay}s)")
if self._stop_event.wait(delay):
return
self._connected = self._rcon.connect()
if not self._connected:
return
self._reconnect_attempts = 0 # reset on successful connection
try:
players = self._rcon.get_players()
PlayerService(self._db).update_from_rcon(self.server_id, players)
BroadcastThread.enqueue(self.server_id, 'players', {
'players': [p.dict() for p in players],
'count': len(players)
})
except ConnectionError:
self._connected = False
logger.warning(f"RCon connection lost for server {self.server_id}")
```
---
## Thread Lifecycle
### Start Server Flow
```
POST /servers/{id}/start
├── ServerService.start()
│ ├── ConfigGenerator.write_all()
│ ├── ProcessManager.start() ← creates subprocess.Popen
│ └── ThreadRegistry.start_server_threads(id)
│ ├── ProcessMonitorThread(id).start()
│ ├── LogTailThread(id).start()
│ ├── MetricsCollectorThread(id).start()
│ └── RConPollerThread(id).start()
└── BroadcastThread.enqueue(id, 'status', {status: 'starting'})
```
### Stop Server Flow
```
POST /servers/{id}/stop
├── RConService.shutdown() ← sends #shutdown via RCon
├── Wait up to 30s for process exit (ProcessManager.stop(timeout=30))
├── If still running: ProcessManager.kill()
├── ThreadRegistry.stop_server_threads(id)
│ ├── ProcessMonitorThread.stop() (sets _stop_event)
│ ├── LogTailThread.stop()
│ ├── MetricsCollectorThread.stop()
│ └── RConPollerThread.stop()
│ └── Thread.join(timeout=5) for each
└── BroadcastThread.enqueue(id, 'status', {status: 'stopped'})
```
### App Shutdown Flow
```
FastAPI shutdown event
├── ThreadRegistry.stop_all() ← stop all threads for all servers
├── BroadcastThread.stop()
├── ConnectionManager.close_all()
└── database engine dispose
```
---
## Stop Event Pattern
All background threads use a `threading.Event` for graceful shutdown:
```python ```python
class BaseServerThread(threading.Thread): class BaseServerThread(threading.Thread):
def __init__(self, server_id: int, interval: float): def __init__(self, server_id: int, ...):
super().__init__(name=f"{self.__class__.__name__}-{server_id}", daemon=True) super().__init__(daemon=True)
self.server_id = server_id self.server_id = server_id
self.interval = interval
self._stop_event = threading.Event() self._stop_event = threading.Event()
def stop(self): def stop(self):
self._stop_event.set() self._stop_event.set()
def is_stopped(self) -> bool:
return self._stop_event.is_set()
def teardown(self):
"""Override to release resources (close files, sockets) after the loop ends."""
pass
def run(self): def run(self):
try:
self.setup()
except Exception as e:
logger.error(f"{self.name} setup error: {e}")
return # setup failed completely — no partial resources to clean
try:
while not self._stop_event.is_set(): while not self._stop_event.is_set():
try: try:
self.tick() self.run_loop()
except Exception as e:
self.on_error(e)
# Use wait() instead of sleep() — responds immediately to stop()
self._stop_event.wait(self.interval)
finally:
self.teardown() # always runs; subclasses close files/sockets here
```
---
## WebSocket Connection Manager (asyncio)
```python
# websocket/manager.py
class ConnectionManager:
def __init__(self):
# server_id → set[WebSocket]
# Use set (not list) so .add()/.discard() work correctly.
self._connections: dict[str, set[WebSocket]] = defaultdict(set)
# Per-connection channel subscriptions: ws → set[str]
self._channel_subs: dict[WebSocket, set[str]] = defaultdict(set)
self._lock = asyncio.Lock()
async def connect(self, ws: WebSocket, server_id: str):
await ws.accept()
async with self._lock:
self._connections[server_id].add(ws)
self._channel_subs[ws].add('status') # default channel
# Only add to 'all' bucket if server_id is explicitly 'all'
if server_id == 'all':
self._connections['all'].add(ws)
async def disconnect(self, ws: WebSocket, server_id: str):
async with self._lock:
self._connections[server_id].discard(ws)
self._connections['all'].discard(ws)
self._channel_subs.pop(ws, None)
async def subscribe(self, ws: WebSocket, channels: list[str]):
async with self._lock:
self._channel_subs[ws].update(channels)
async def unsubscribe(self, ws: WebSocket, channels: list[str]):
async with self._lock:
self._channel_subs[ws].difference_update(channels)
async def broadcast(self, server_id: str, message: dict, channel: str = None):
"""Send to all clients subscribed to server_id AND the message's channel."""
targets: set[WebSocket] = set()
async with self._lock:
# Collect clients for this server_id + 'all' subscribers
server_clients = self._connections.get(server_id, set())
all_clients = self._connections.get('all', set())
candidates = server_clients | all_clients
# Filter by channel subscription if specified
if channel:
targets = {ws for ws in candidates
if channel in self._channel_subs.get(ws, set())}
else:
targets = candidates
dead = []
for ws in targets:
try:
await ws.send_json(message)
except Exception: except Exception:
dead.append(ws) backoff = min(backoff * 2, 30)
self._stop_event.wait(backoff)
# Clean up dead connections
if dead:
async with self._lock:
for ws in dead:
for bucket in self._connections.values():
bucket.discard(ws)
self._channel_subs.pop(ws, None)
``` ```
--- ## ThreadRegistry
## Memory & Performance Considerations `ThreadRegistry` manages thread lifecycle per server:
| Thread | Memory Impact | CPU Impact | - **`start_server_threads(server_id, db)`** — Creates and starts all 4 thread types for a server
|--------|--------------|-----------| - **`stop_server_threads(server_id)`** — Sets stop events and joins all threads for a server
| ProcessMonitorThread | Minimal (one `os.kill` check) | Negligible | - **`reattach_server_threads(server_id, db)`** — Recovers threads for a server that survived a process restart
| LogTailThread | Buffer for unread log lines | Low (file I/O) | - **`stop_all()`** — Stops all threads for all servers (called on shutdown)
| MetricsCollectorThread | psutil subprocess scan | Low-Medium |
| RConPollerThread | UDP socket + response buffer | Low |
| BroadcastThread | Queue buffer (max 10000 entries) | Low |
### Recommendations Thread bundles are stored in a dict: `{server_id → ThreadBundle}`, where `ThreadBundle` is a dataclass holding all thread references.
- Set all threads as `daemon=True` — they die automatically if main process exits
- `broadcast_queue.maxsize=10000` — backpressure; drop on Full (log warning) ## BroadcastThread
- `LogTailThread` buffers max ~100 lines per tick before writing to DB in batch
- `MetricsCollectorThread` uses `psutil.Process.cpu_percent(interval=0.5)` — blocks 500ms, acceptable at 5s interval The `BroadcastThread` is the single global thread that bridges synchronous background threads to asynchronous WebSocket clients:
- For N=10 servers: 41 background threads — well within Python's thread limits
1. Background threads push events into a `queue.Queue(maxsize=1000)`
2. `BroadcastThread` runs a loop reading from the queue
3. For each event, it calls `asyncio.run_coroutine_threadsafe()` to schedule a WebSocket broadcast on the main event loop
4. If the queue is full, events are dropped (non-blocking put)
Events are broadcast to WebSocket clients subscribed to the relevant `server_id` (or `None` for all servers).
## ProcessManager
`ProcessManager` is a singleton that manages server processes via `subprocess.Popen`:
- **`start_process(server_id, cmd, cwd, env)`** — Starts a new subprocess, stores the PID
- **`stop_process(server_id, timeout)`** — Sends terminate signal, waits for exit, force-kills after timeout
- **`kill_process(server_id)`** — Force-kills the process immediately
- **`recover_on_startup(db)`** — On startup, checks all stored PIDs against running processes via `psutil.pid_exists()`. If a process is still alive, marks the server as running. If not, marks it as stopped.
- Thread-safe with per-server `threading.Lock`
## LogTailThread
Tails the Arma 3 .rpt log file for each server:
- Resolves the latest log file path using `Path(server["exe_path"]).parent / "server"` — Arma 3 writes .rpt files next to its executable, not in the languard server data directory
- Reads new lines from the end of the file, detecting log rotation (Windows/NTFS safe)
- Parses each line using `RPTParser.parse_line()` to extract timestamp, level, and message
- Persists parsed entries to the `logs` table via `LogRepository`
- Broadcasts `log` events via the global queue
## ProcessMonitorThread
Monitors each server process for crashes:
- Checks every 5 seconds whether the process is still alive
- If the process has exited unexpectedly:
1. Updates server status to `crashed`
2. Logs the crash event
3. If `auto_restart` is enabled and restart count hasn't exceeded `max_restarts` within the `restart_window_seconds`:
- Triggers a restart via `ServerService.start_server()`
- Increments `restart_count`
## MetricsCollectorThread
Collects CPU and RAM metrics for each running server:
- Uses `psutil.Process(pid)` to get CPU and memory usage
- Collects every 10 seconds
- Stores metrics in the `metrics` table via `MetricsRepository`
- Broadcasts `metrics` events via the global queue
## RemoteAdminPollerThread
Polls the BattlEye RCon interface for player list updates:
- Connects via `Arma3RemoteAdmin` using `BERConClient`
- Polls player list every 10 seconds
- Compares current players with previous state to detect joins/leaves
- On player join: upserts to `players` table, inserts to `player_history`, broadcasts `players` event
- On player leave: removes from `players`, updates `left_at` in `player_history`, broadcasts `players` event
- On RCon connection failure: reconnects with exponential backoff
## WebSocketManager
Runs on the main asyncio event loop:
- Clients connect to `/ws?token=JWT&server_id=N`
- JWT is validated on connection; invalid tokens close with code 4001
- Clients subscribe to specific `server_id`s or `None` (all servers)
- `broadcast(server_id, message)` sends JSON-encoded messages to matching subscribers
- `disconnect(websocket)` removes the client from the registry
- Thread-safe via `asyncio.Lock`
## Thread Safety Rules
1. **Database access**: Each thread uses its own connection via `get_thread_db()`. No shared DB connections.
2. **WebSocket broadcasting**: Threads write to `queue.Queue`, which is thread-safe. Only `BroadcastThread` reads from the queue.
3. **Process management**: `ProcessManager` uses per-server locks for thread-safe start/stop operations.
4. **SQLite WAL mode**: Enables concurrent reads from multiple threads while a single writer operates.
5. **Asyncio locks**: `WebSocketManager` uses `asyncio.Lock` for connection registry modifications.
## Scheduled Jobs
APScheduler `BackgroundScheduler` runs 3 cleanup cron jobs:
| Job | Schedule | Cleanup |
|---|---|---|
| Clean up old log entries | Daily at 03:00 | `DELETE FROM logs WHERE created_at < datetime('now', '-7 days')` |
| Clean up old metrics | Every 6 hours | `DELETE FROM metrics WHERE timestamp < datetime('now', '-1 day')` |
| Clean up old events | Weekly (Sunday 04:00) | `DELETE FROM server_events WHERE created_at < datetime('now', '-30 days')` |
## Startup Sequence
1. Init DB engine and run pending migrations
2. Register built-in adapters (Arma 3) and scan for third-party plugins
3. Create `WebSocketManager` (asyncio-only)
4. Create global `BroadcastThread` (queue → asyncio bridge)
5. Create `ThreadRegistry` with `ProcessManager` and adapter registry
6. Recover processes that survived a restart (PID validation via psutil)
7. Re-attach monitoring threads for running servers
8. Seed default admin user if no users exist
9. Register and start APScheduler cleanup jobs
## Shutdown Sequence
1. Stop all server threads via `ThreadRegistry.stop_all()`
2. Stop `BroadcastThread` and join with 5s timeout
3. Stop APScheduler

12
backend/.env.example Normal file
View File

@@ -0,0 +1,12 @@
LANGUARD_SECRET_KEY=changeme-generate-with-openssl-rand-hex-32
LANGUARD_ENCRYPTION_KEY=changeme-generate-with-python-cryptography-fernet
LANGUARD_DB_PATH=./languard.db
LANGUARD_SERVERS_DIR=./servers
LANGUARD_HOST=0.0.0.0
LANGUARD_PORT=8000
LANGUARD_CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
LANGUARD_LOG_RETENTION_DAYS=7
LANGUARD_METRICS_RETENTION_DAYS=30
LANGUARD_PLAYER_HISTORY_RETENTION_DAYS=90
LANGUARD_JWT_EXPIRE_HOURS=24
LANGUARD_ARMA3_DEFAULT_EXE=C:/Arma3Server/arma3server_x64.exe

0
backend/__init__.py Normal file
View File

View File

@@ -0,0 +1,39 @@
"""
Auto-register all built-in adapters.
Also scans importlib entry_points for third-party adapters.
"""
import logging
logger = logging.getLogger(__name__)
def load_builtin_adapters():
"""Import built-in adapter packages — they self-register on import."""
from adapters.arma3 import ARMA3_ADAPTER # noqa: F401
def load_third_party_adapters():
"""
Scan 'languard.adapters' entry_point group for third-party adapters.
Third-party packages add this to their pyproject.toml:
[project.entry-points."languard.adapters"]
mygame = "mygame_adapter:MYGAME_ADAPTER"
"""
try:
from importlib.metadata import entry_points
eps = entry_points(group="languard.adapters")
for ep in eps:
try:
adapter = ep.load()
from adapters.registry import GameAdapterRegistry
GameAdapterRegistry.register(adapter)
logger.info("Loaded third-party adapter via entry_point: %s", ep.name)
except Exception as e:
logger.error("Failed to load third-party adapter '%s': %s", ep.name, e)
except Exception as e:
logger.warning("Entry point scanning failed: %s", e)
def initialize_adapters():
load_builtin_adapters()
load_third_party_adapters()

View File

@@ -0,0 +1,7 @@
"""Auto-register Arma 3 adapter on import."""
from adapters.arma3.adapter import ARMA3_ADAPTER
from adapters.registry import GameAdapterRegistry
GameAdapterRegistry.register(ARMA3_ADAPTER)
__all__ = ["ARMA3_ADAPTER"]

View File

@@ -0,0 +1,59 @@
"""Arma 3 adapter — composes all Arma 3 capability implementations."""
from adapters.arma3.config_generator import Arma3ConfigGenerator
from adapters.arma3.process_config import Arma3ProcessConfig
# Capabilities enabled so far (add more as phases complete)
_CAPABILITIES = {
"config_generator",
"process_config",
"log_parser",
"remote_admin",
"ban_manager",
"mission_manager",
"mod_manager",
}
class Arma3Adapter:
game_type = "arma3"
display_name = "Arma 3"
version = "1.0.0"
def get_config_generator(self):
return Arma3ConfigGenerator()
def get_process_config(self):
return Arma3ProcessConfig()
def get_log_parser(self):
from adapters.arma3.log_parser import RPTParser
return RPTParser()
def get_remote_admin(self):
"""Return the RemoteAdmin factory for Arma3 BattlEye RCon."""
from adapters.arma3.remote_admin import Arma3RemoteAdminFactory
return Arma3RemoteAdminFactory()
def get_mission_manager(self, server_id: int | None = None):
from adapters.arma3.mission_manager import Arma3MissionManager
return Arma3MissionManager(server_id=server_id)
def get_mod_manager(self, server_id: int | None = None):
from adapters.arma3.mod_manager import Arma3ModManager
return Arma3ModManager(server_id=server_id)
def get_ban_manager(self, server_id: int | None = None):
from adapters.arma3.ban_manager import Arma3BanManager
return Arma3BanManager(server_id=server_id)
def has_capability(self, name: str) -> bool:
return name in _CAPABILITIES
def get_additional_routers(self) -> list:
return []
def get_custom_thread_factories(self) -> list:
return []
ARMA3_ADAPTER = Arma3Adapter()

View File

@@ -0,0 +1,200 @@
"""Arma 3 ban manager — bidirectional sync between DB bans and BattlEye ban file."""
from __future__ import annotations
import logging
import os
from pathlib import Path
from pydantic import BaseModel
from core.utils.file_utils import get_server_dir
logger = logging.getLogger(__name__)
_BANS_FILE = "battleye/bans.txt"
class Arma3BanData(BaseModel):
"""Ban data schema for Arma 3."""
guid: str = ""
ip: str = ""
class Arma3BanManager:
"""
Implements BanManager protocol for Arma3 BattlEye.
Also provides richer file-based operations for the ban endpoints.
"""
def __init__(self, server_id: int | None = None) -> None:
self._server_id = server_id
def _bans_path(self) -> Path:
if self._server_id is None:
raise ValueError("server_id required for file-based ban operations")
server_dir = get_server_dir(self._server_id)
return server_dir / _BANS_FILE
# ── BanManager protocol methods ──
def get_ban_file_path(self, server_dir: Path) -> Path:
return server_dir / _BANS_FILE
def sync_bans_to_file(self, bans: list[dict], ban_file: Path) -> None:
"""Write bans from DB to BattlEye ban file format."""
lines = []
for ban in bans:
identifier = ban.get("player_uid") or ban.get("guid") or ban.get("ip", "")
ban_type = ban.get("ban_type", "GUID")
reason = ban.get("reason", "")
duration = ban.get("duration_minutes", 0)
reason_clean = reason.replace("\n", " ").replace("\r", "").strip()
if identifier:
lines.append(f"{ban_type} {identifier} {duration} {reason_clean}".strip())
ban_file.parent.mkdir(parents=True, exist_ok=True)
tmp_path = str(ban_file) + ".tmp"
try:
with open(tmp_path, "w", encoding="utf-8") as f:
f.write("\n".join(lines) + "\n" if lines else "")
os.replace(tmp_path, str(ban_file))
except OSError as exc:
self._safe_delete(tmp_path)
raise
def read_bans_from_file(self, ban_file: Path) -> list[dict]:
"""Read bans from BattlEye ban file into standard format."""
if not ban_file.exists():
return []
bans = []
for line_num, line in enumerate(ban_file.read_text(encoding="utf-8", errors="replace").splitlines(), 1):
line = line.strip()
if not line or line.startswith("//") or line.startswith("#"):
continue
parsed = self._parse_ban_line(line, line_num)
if parsed:
bans.append(parsed)
return bans
def get_ban_data_schema(self) -> type[BaseModel] | None:
return Arma3BanData
# ── Richer file-based operations (used by ban endpoints) ──
def get_bans(self) -> list[dict]:
"""Read all bans from bans.txt. Returns list of dicts."""
bans_path = self._bans_path()
if not bans_path.exists():
return []
bans = []
try:
with open(bans_path, "r", encoding="utf-8", errors="replace") as f:
for line_num, line in enumerate(f, 1):
line = line.strip()
if not line or line.startswith("#"):
continue
parsed = self._parse_ban_line(line, line_num)
if parsed:
bans.append(parsed)
except OSError as exc:
logger.error("Cannot read bans.txt: %s", exc)
return bans
def add_ban(self, identifier: str, ban_type: str, reason: str, duration_minutes: int) -> None:
"""Append a ban entry to bans.txt."""
reason_clean = reason.replace("\n", " ").replace("\r", "").strip()
line = f"{ban_type} {identifier} {duration_minutes} {reason_clean}\n"
bans_path = self._bans_path()
bans_path.parent.mkdir(parents=True, exist_ok=True)
try:
with open(bans_path, "a", encoding="utf-8") as f:
f.write(line)
except OSError as exc:
logger.error("Cannot write bans.txt: %s", exc)
def remove_ban(self, identifier: str) -> bool:
"""Remove all ban entries matching the given identifier. Returns True if removed."""
bans_path = self._bans_path()
if not bans_path.exists():
return False
try:
with open(bans_path, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()
except OSError as exc:
logger.error("Cannot read bans.txt: %s", exc)
return False
new_lines = []
removed = 0
for line in lines:
stripped = line.strip()
if stripped and not stripped.startswith("#"):
parts = stripped.split(None, 3)
if len(parts) >= 2 and parts[1] == identifier:
removed += 1
continue
new_lines.append(line)
if removed == 0:
return False
tmp_path = str(bans_path) + ".tmp"
try:
with open(tmp_path, "w", encoding="utf-8") as f:
f.writelines(new_lines)
os.replace(tmp_path, str(bans_path))
except OSError as exc:
self._safe_delete(tmp_path)
logger.error("Cannot update bans.txt: %s", exc)
return False
return True
# ── Internal ──
def _parse_ban_line(self, line: str, line_num: int) -> dict | None:
"""Parse one ban line: TYPE IDENTIFIER DURATION REASON"""
parts = line.split(None, 3)
if len(parts) < 2:
return None
ban_type = parts[0].upper()
if ban_type not in ("GUID", "IP"):
return None
identifier = parts[1]
duration = 0
reason = ""
if len(parts) >= 3:
try:
duration = int(parts[2])
except ValueError:
duration = 0
if len(parts) >= 4:
reason = parts[3]
return {
"type": ban_type,
"identifier": identifier,
"duration_minutes": duration,
"reason": reason,
"is_permanent": duration == 0,
}
@staticmethod
def _safe_delete(path: str) -> None:
try:
os.unlink(path)
except OSError as exc:
logger.debug("Arma3BanManager: could not delete %s: %s", path, exc)

View File

@@ -0,0 +1,616 @@
"""
Arma 3 config generator.
Merged protocol: Pydantic models (schema) + file generation + launch args.
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any, Union
from pydantic import BaseModel, Field
MissionParamValue = Union[int, float, str, bool]
# ─── Pydantic Models (config schema) ─────────────────────────────────────────
class MissionRotationItem(BaseModel):
name: str
difficulty: str = ""
params: dict[str, MissionParamValue] = Field(default_factory=dict)
class ServerConfig(BaseModel):
hostname: str = "My Arma 3 Server"
password: str | None = None
password_admin: str = ""
server_command_password: str | None = None
max_players: int = Field(default=40, gt=0, le=1000)
kick_duplicate: int = Field(default=1, ge=0, le=1)
persistent: int = Field(default=1, ge=0, le=1)
vote_threshold: float = Field(default=0.33, ge=0.0, le=1.0)
vote_mission_players: int = Field(default=1, ge=0)
vote_timeout: int = Field(default=60, ge=0)
role_timeout: int = Field(default=90, ge=0)
briefing_timeout: int = Field(default=60, ge=0)
debriefing_timeout: int = Field(default=45, ge=0)
lobby_idle_timeout: int = Field(default=300, ge=0)
disable_von: int = Field(default=0, ge=0, le=1)
von_codec: int = Field(default=1, ge=0, le=1)
von_codec_quality: int = Field(default=20, ge=0, le=30)
max_ping: int = Field(default=250, gt=0)
max_packet_loss: int = Field(default=50, ge=0, le=100)
max_desync: int = Field(default=200, ge=0)
disconnect_timeout: int = Field(default=15, ge=0)
kick_on_ping: int = Field(default=1, ge=0, le=1)
kick_on_packet_loss: int = Field(default=1, ge=0, le=1)
kick_on_desync: int = Field(default=1, ge=0, le=1)
kick_on_timeout: int = Field(default=1, ge=0, le=1)
battleye: int = Field(default=1, ge=0, le=1)
verify_signatures: int = Field(default=2, ge=0, le=2)
allowed_file_patching: int = Field(default=0, ge=0, le=2)
forced_difficulty: str = "Regular"
timestamp_format: str = "short"
auto_select_mission: int = Field(default=0, ge=0, le=1)
random_mission_order: int = Field(default=0, ge=0, le=1)
log_file: str = "server_console.log"
skip_lobby: int = Field(default=0, ge=0, le=1)
drawing_in_map: int = Field(default=1, ge=0, le=1)
upnp: int = Field(default=0, ge=0, le=1)
loopback: int = Field(default=0, ge=0, le=1)
statistics_enabled: int = Field(default=1, ge=0, le=1)
motd_lines: list[str] = Field(default_factory=lambda: ["Welcome!", "Have fun"])
motd_interval: float = Field(default=5.0, gt=0)
headless_clients: list[str] = Field(default_factory=list)
local_clients: list[str] = Field(default_factory=list)
admin_uids: list[str] = Field(default_factory=list)
missions: list[MissionRotationItem] = Field(default_factory=list)
default_mission_params: dict[str, MissionParamValue] = Field(default_factory=dict)
class BasicConfig(BaseModel):
min_bandwidth: int = Field(default=131072, gt=0)
max_bandwidth: int = Field(default=10000000000, gt=0)
max_msg_send: int = Field(default=128, gt=0)
max_size_guaranteed: int = Field(default=512, gt=0)
max_size_non_guaranteed: int = Field(default=256, gt=0)
min_error_to_send: float = Field(default=0.001, gt=0)
max_custom_file_size: int = Field(default=0, ge=0)
class ProfileConfig(BaseModel):
reduced_damage: int = Field(default=0, ge=0, le=1)
group_indicators: int = Field(default=0, ge=0, le=3)
friendly_tags: int = Field(default=0, ge=0, le=3)
enemy_tags: int = Field(default=0, ge=0, le=3)
detected_mines: int = Field(default=0, ge=0, le=3)
commands: int = Field(default=1, ge=0, le=3)
waypoints: int = Field(default=0, ge=0, le=3)
tactical_ping: int = Field(default=0, ge=0, le=1)
weapon_info: int = Field(default=2, ge=0, le=3)
stance_indicator: int = Field(default=2, ge=0, le=3)
stamina_bar: int = Field(default=2, ge=0, le=2)
weapon_crosshair: int = Field(default=0, ge=0, le=1)
vision_aid: int = Field(default=0, ge=0, le=1)
third_person_view: int = Field(default=0, ge=0, le=1)
camera_shake: int = Field(default=1, ge=0, le=1)
score_table: int = Field(default=0, ge=0, le=1)
death_messages: int = Field(default=1, ge=0, le=1)
von_id: int = Field(default=1, ge=0, le=1)
map_content_friendly: int = Field(default=0, ge=0, le=3)
map_content_enemy: int = Field(default=0, ge=0, le=3)
map_content_mines: int = Field(default=0, ge=0, le=3)
auto_report: int = Field(default=0, ge=0, le=1)
multiple_saves: int = Field(default=0, ge=0, le=1)
ai_level_preset: int = Field(default=3, ge=0, le=4)
skill_ai: float = Field(default=1.0, ge=0.0, le=1.0)
precision_ai: float = Field(default=0.2, ge=0.0, le=1.0)
class LaunchConfig(BaseModel):
world: str = "empty"
extra_params: str = ""
limit_fps: int = Field(default=50, gt=0, le=1000)
auto_init: int = Field(default=0, ge=0, le=1)
load_mission_to_memory: int = Field(default=0, ge=0, le=1)
enable_ht: int = Field(default=0, ge=0, le=1)
huge_pages: int = Field(default=0, ge=0, le=1)
cpu_count: int | None = None
ex_threads: int = Field(default=7, ge=0)
max_mem: int | None = None
no_logs: int = Field(default=0, ge=0, le=1)
netlog: int = Field(default=0, ge=0, le=1)
class RConConfig(BaseModel):
rcon_password: str = ""
max_ping: int = Field(default=200, gt=0)
enabled: int = Field(default=1, ge=0, le=1)
# ─── Config Generator ─────────────────────────────────────────────────────────
class Arma3ConfigGenerator:
game_type = "arma3"
SECTIONS: dict[str, type[BaseModel]] = {
"server": ServerConfig,
"basic": BasicConfig,
"profile": ProfileConfig,
"launch": LaunchConfig,
"rcon": RConConfig,
}
SENSITIVE_FIELDS: dict[str, list[str]] = {
"server": ["password", "password_admin", "server_command_password"],
"rcon": ["rcon_password"],
}
def get_sections(self) -> dict[str, type[BaseModel]]:
return self.SECTIONS
def get_defaults(self, section: str) -> dict[str, Any]:
model_cls = self.SECTIONS.get(section)
if model_cls is None:
return {}
return model_cls().model_dump()
def get_sensitive_fields(self, section: str) -> list[str]:
return self.SENSITIVE_FIELDS.get(section, [])
def get_config_version(self) -> str:
return "1.1.0"
def migrate_config(self, old_version: str, config_json: dict) -> dict:
from adapters.exceptions import ConfigMigrationError
if old_version == "1.0.0":
server = config_json.get("server", {})
for m in server.get("missions", []):
if isinstance(m, dict):
m.setdefault("params", {})
server.setdefault("default_mission_params", {})
return config_json
raise ConfigMigrationError(
old_version, f"No migration path from {old_version} to {self.get_config_version()}"
)
def normalize_section(self, section: str, data: dict) -> dict:
"""Backfill new optional fields on server section for pre-1.1.0 stored data."""
if section == "server":
for m in data.get("missions", []):
if isinstance(m, dict):
m.setdefault("params", {})
data.setdefault("default_mission_params", {})
return data
# ── Config file writers ───────────────────────────────────────────────────
def _render_param_value(self, val: MissionParamValue) -> str:
if isinstance(val, bool):
return "1" if val else "0"
if isinstance(val, (int, float)):
return str(val)
return f'"{self._escape(str(val))}"'
def _render_missions_block(self, cfg: ServerConfig) -> str:
"""Render the class Missions { ... } block for server.cfg.
Per-mission params take priority; falls back to default_mission_params;
if both are empty the class Params block is omitted entirely.
"""
if not cfg.missions:
return ""
lines = ["class Missions {"]
for idx, entry in enumerate(cfg.missions):
effective = entry.params if entry.params else cfg.default_mission_params
lines.append(f" class Mission_{idx} {{")
lines.append(f' template = "{self._escape(entry.name)}";')
if entry.difficulty:
lines.append(f' difficulty = "{self._escape(entry.difficulty)}";')
if effective:
lines.append(" class Params {")
for key, val in effective.items():
lines.append(f" {key} = {self._render_param_value(val)};")
lines.append(" };")
lines.append(" };")
lines.append("};")
return "\n".join(lines) + "\n"
@staticmethod
def _escape(value: str) -> str:
"""
Escape a string for use inside Arma 3 double-quoted config values.
Order matters: escape backslashes FIRST.
"""
value = value.replace("\\", "\\\\")
value = value.replace('"', '\\"')
value = value.replace('\n', '\\n')
return value
@staticmethod
def _atomic_write(path: Path, content: str) -> None:
"""Write content to path atomically via tmp file + os.replace()."""
from adapters.exceptions import ConfigWriteError
tmp_path = path.with_suffix(path.suffix + ".tmp")
try:
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path.write_text(content, encoding="utf-8")
os.replace(str(tmp_path), str(path))
except OSError as e:
# Clean up tmp file if it exists
try:
tmp_path.unlink(missing_ok=True)
except OSError as exc:
logger.debug("Could not clean up temp file %s: %s", tmp_path, exc)
raise ConfigWriteError(str(path), str(e)) from e
def _render_server_cfg(self, cfg: ServerConfig) -> str:
"""Render server.cfg content string."""
motd_items = ", ".join(f'"{self._escape(l)}"' for l in cfg.motd_lines)
headless = ", ".join(f'"{h}"' for h in cfg.headless_clients)
local = ", ".join(f'"{l}"' for l in cfg.local_clients)
admin_uids = ", ".join(f'"{u}"' for u in cfg.admin_uids)
lines = [
f'hostname = "{self._escape(cfg.hostname)}";',
]
if cfg.password:
lines.append(f'password = "{self._escape(cfg.password)}";')
if cfg.password_admin:
lines.append(f'passwordAdmin = "{self._escape(cfg.password_admin)}";')
if cfg.server_command_password:
lines.append(f'serverCommandPassword = "{self._escape(cfg.server_command_password)}";')
lines += [
f"maxPlayers = {cfg.max_players};",
f"kickDuplicate = {cfg.kick_duplicate};",
f"persistent = {cfg.persistent};",
f"voteThreshold = {cfg.vote_threshold};",
f"voteMissionPlayers = {cfg.vote_mission_players};",
f"voteTimeout = {cfg.vote_timeout};",
f"roleTimeout = {cfg.role_timeout};",
f"briefingTimeOut = {cfg.briefing_timeout};",
f"debriefingTimeOut = {cfg.debriefing_timeout};",
f"lobbyIdleTimeout = {cfg.lobby_idle_timeout};",
f"disableVoN = {cfg.disable_von};",
f"vonCodec = {cfg.von_codec};",
f"vonCodecQuality = {cfg.von_codec_quality};",
f"maxPing = {cfg.max_ping};",
f"maxPacketLoss = {cfg.max_packet_loss};",
f"maxDesync = {cfg.max_desync};",
f"disconnectTimeout = {cfg.disconnect_timeout};",
f"kickOnPing = {cfg.kick_on_ping};",
f"kickOnPacketLoss = {cfg.kick_on_packet_loss};",
f"kickOnDesync = {cfg.kick_on_desync};",
f"kickOnTimeout = {cfg.kick_on_timeout};",
f"BattlEye = {cfg.battleye};",
f"verifySignatures = {cfg.verify_signatures};",
f"allowedFilePatching = {cfg.allowed_file_patching};",
f'forcedDifficulty = "{cfg.forced_difficulty}";',
f'timeStampFormat = "{cfg.timestamp_format}";',
f"autoSelectMission = {cfg.auto_select_mission};",
f"randomMissionOrder = {cfg.random_mission_order};",
f'logFile = "{cfg.log_file}";',
f"skipLobby = {cfg.skip_lobby};",
f"drawingInMap = {cfg.drawing_in_map};",
f"upnp = {cfg.upnp};",
f"loopback = {cfg.loopback};",
f"statisticsEnabled = {cfg.statistics_enabled};",
f"motd[] = {{{motd_items}}};",
f"motdInterval = {cfg.motd_interval};",
]
if cfg.headless_clients:
lines.append(f"headlessClients[] = {{{headless}}};")
if cfg.local_clients:
lines.append(f"localClient[] = {{{local}}};")
if cfg.admin_uids:
lines.append(f"admins[] = {{{admin_uids}}};")
return "\n".join(lines) + "\n" + self._render_missions_block(cfg)
def _render_basic_cfg(self, cfg: BasicConfig) -> str:
return (
f"MinBandwidth = {cfg.min_bandwidth};\n"
f"MaxBandwidth = {cfg.max_bandwidth};\n"
f"MaxMsgSend = {cfg.max_msg_send};\n"
f"MaxSizeGuaranteed = {cfg.max_size_guaranteed};\n"
f"MaxSizeNonguaranteed = {cfg.max_size_non_guaranteed};\n"
f"MinErrorToSend = {cfg.min_error_to_send};\n"
f"MaxCustomFileSize = {cfg.max_custom_file_size};\n"
)
def _render_arma3profile(self, cfg: ProfileConfig) -> str:
return (
"class DifficultyPresets {\n"
" class CustomDifficulty {\n"
" class Options {\n"
f" reducedDamage = {cfg.reduced_damage};\n"
f" groupIndicators = {cfg.group_indicators};\n"
f" friendlyTags = {cfg.friendly_tags};\n"
f" enemyTags = {cfg.enemy_tags};\n"
f" detectedMines = {cfg.detected_mines};\n"
f" commands = {cfg.commands};\n"
f" waypoints = {cfg.waypoints};\n"
f" tacticalPing = {cfg.tactical_ping};\n"
f" weaponInfo = {cfg.weapon_info};\n"
f" stanceIndicator = {cfg.stance_indicator};\n"
f" staminaBar = {cfg.stamina_bar};\n"
f" weaponCrosshair = {cfg.weapon_crosshair};\n"
f" visionAid = {cfg.vision_aid};\n"
f" thirdPersonView = {cfg.third_person_view};\n"
f" cameraShake = {cfg.camera_shake};\n"
f" scoreTable = {cfg.score_table};\n"
f" deathMessages = {cfg.death_messages};\n"
f" vonID = {cfg.von_id};\n"
f" mapContentFriendly = {cfg.map_content_friendly};\n"
f" mapContentEnemy = {cfg.map_content_enemy};\n"
f" mapContentMines = {cfg.map_content_mines};\n"
f" autoReport = {cfg.auto_report};\n"
f" multipleSaves = {cfg.multiple_saves};\n"
" };\n"
f" aiLevelPreset = {cfg.ai_level_preset};\n"
" };\n"
" class CustomAILevel {\n"
f" skillAI = {cfg.skill_ai};\n"
f" precisionAI = {cfg.precision_ai};\n"
" };\n"
"};\n"
)
def _render_beserver_cfg(self, cfg: RConConfig) -> str:
return (
f"RConPassword {cfg.rcon_password}\n"
f"MaxPing {cfg.max_ping}\n"
)
# ── Public interface ──────────────────────────────────────────────────────
def write_configs(
self,
server_id: int,
server_dir: Path,
config_sections: dict[str, dict],
) -> list[Path]:
server_cfg = ServerConfig(**config_sections.get("server", {}))
basic_cfg = BasicConfig(**config_sections.get("basic", {}))
profile_cfg = ProfileConfig(**config_sections.get("profile", {}))
rcon_cfg = RConConfig(**config_sections.get("rcon", {}))
written = []
pairs = [
(server_dir / "server.cfg", self._render_server_cfg(server_cfg)),
(server_dir / "basic.cfg", self._render_basic_cfg(basic_cfg)),
(server_dir / "server" / "server.Arma3Profile", self._render_arma3profile(profile_cfg)),
(server_dir / "battleye" / "beserver.cfg", self._render_beserver_cfg(rcon_cfg)),
]
for path, content in pairs:
self._atomic_write(path, content)
written.append(path)
# Restrict permissions on files containing passwords (Unix only)
if os.name != "nt":
for path in [server_dir / "server.cfg", server_dir / "battleye" / "beserver.cfg"]:
if path.exists():
os.chmod(path, 0o600)
return written
def build_launch_args(
self,
config_sections: dict[str, dict],
mod_args: list[str] | None = None,
server_dir: Path | None = None,
) -> list[str]:
from adapters.exceptions import LaunchArgsError
launch = LaunchConfig(**config_sections.get("launch", {}))
server = ServerConfig(**config_sections.get("server", {}))
# Arma 3 changes its own cwd to the exe directory at startup, so relative
# paths in launch args resolve against the exe dir, not server_dir.
# Use absolute paths when server_dir is provided so configs are always found.
if server_dir is not None:
d = Path(server_dir)
config_arg = f"-config={d / 'server.cfg'}"
cfg_arg = f"-cfg={d / 'basic.cfg'}"
profiles_arg = f"-profiles={d / 'server'}"
bepath_arg = f"-bepath={d / 'battleye'}"
else:
config_arg = "-config=server.cfg"
cfg_arg = "-cfg=basic.cfg"
profiles_arg = "-profiles=./server"
bepath_arg = "-bepath=./battleye"
args = [
f"-port={config_sections.get('_port', 2302)}",
config_arg,
cfg_arg,
profiles_arg,
"-name=server",
f"-world={launch.world}",
f"-limitFPS={launch.limit_fps}",
bepath_arg,
]
if launch.auto_init:
args.append("-autoInit")
if launch.enable_ht:
args.append("-enableHT")
if launch.huge_pages:
args.append("-hugePages")
if launch.cpu_count is not None:
args.append(f"-cpuCount={launch.cpu_count}")
if launch.max_mem is not None:
args.append(f"-maxMem={launch.max_mem}")
if launch.no_logs:
args.append("-noLogs")
if launch.netlog:
args.append("-netlog")
if launch.extra_params:
args.extend(launch.extra_params.split())
if mod_args:
args.extend(mod_args)
return args
def get_ui_schema(self) -> dict:
B, A = False, True # basic / advanced shorthand
return {
"server": {
# Identity — basic
"hostname": {"widget": "text", "label": "Server Name", "advanced": B},
"max_players": {"widget": "number", "label": "Max Players", "min": 1, "max": 1000, "advanced": B},
"password": {"widget": "password", "label": "Join Password", "advanced": B},
"password_admin": {"widget": "password", "label": "Admin Password", "advanced": B},
"server_command_password": {"widget": "password", "label": "Server Command Password", "advanced": A},
# Message of the Day — basic
"motd_lines": {"widget": "textarea", "label": "Message of the Day (one line per row)", "advanced": B},
"motd_interval": {"widget": "number", "label": "MOTD Interval (sec)", "min": 1, "advanced": B},
# Mission / Rotation — basic
"forced_difficulty": {"widget": "select", "label": "Forced Difficulty",
"options": ["Recruit", "Regular", "Veteran", "Custom"], "advanced": B},
"auto_select_mission": {"widget": "toggle", "label": "Auto-Select Mission", "advanced": B},
"random_mission_order": {"widget": "toggle", "label": "Random Mission Order", "advanced": B},
# Behaviour — mixed
"persistent": {"widget": "toggle", "label": "Persistent (keep running when empty)", "advanced": B},
"kick_duplicate": {"widget": "toggle", "label": "Kick Duplicate Connections", "advanced": A},
"skip_lobby": {"widget": "toggle", "label": "Skip Lobby (go straight to briefing)", "advanced": B},
"drawing_in_map": {"widget": "toggle", "label": "Allow Drawing in Map", "advanced": B},
# Security — basic
"battleye": {"widget": "toggle", "label": "BattlEye Anti-Cheat", "advanced": B},
"verify_signatures": {"widget": "select", "label": "Verify Addon Signatures",
"options": ["0 - Off", "1 - Kick unsigned", "2 - Strict (kick mismatched)"], "advanced": B},
"allowed_file_patching": {"widget": "select", "label": "Allow File Patching",
"options": ["0 - Nobody", "1 - Lobby only", "2 - Everyone"], "advanced": B},
# Voice — basic
"disable_von": {"widget": "toggle", "label": "Disable Voice-over-Network (VoN)", "advanced": B},
"von_codec": {"widget": "toggle", "label": "Use Opus VoN Codec", "advanced": B},
"von_codec_quality": {"widget": "number", "label": "VoN Codec Quality (030)", "min": 0, "max": 30, "advanced": A},
# Network / Kick thresholds — advanced
"kick_on_ping": {"widget": "toggle", "label": "Kick on High Ping", "advanced": A},
"kick_on_packet_loss": {"widget": "toggle", "label": "Kick on High Packet Loss", "advanced": A},
"kick_on_desync": {"widget": "toggle", "label": "Kick on High Desync", "advanced": A},
"kick_on_timeout": {"widget": "toggle", "label": "Kick on Timeout", "advanced": A},
"max_ping": {"widget": "number", "label": "Max Ping (ms)", "min": 1, "advanced": A},
"max_packet_loss": {"widget": "number", "label": "Max Packet Loss (%)", "min": 0, "max": 100, "advanced": A},
"max_desync": {"widget": "number", "label": "Max Desync", "min": 0, "advanced": A},
"disconnect_timeout": {"widget": "number", "label": "Disconnect Timeout (sec)", "min": 0, "advanced": A},
# Voting — advanced
"vote_threshold": {"widget": "number", "label": "Vote Threshold (0.01.0)", "min": 0, "max": 1, "advanced": A},
"vote_mission_players": {"widget": "number", "label": "Min Players to Start Vote", "min": 0, "advanced": A},
"vote_timeout": {"widget": "number", "label": "Vote Timeout (sec)", "min": 0, "advanced": A},
# Timeouts — advanced
"role_timeout": {"widget": "number", "label": "Role Selection Timeout (sec)", "min": 0, "advanced": A},
"briefing_timeout": {"widget": "number", "label": "Briefing Timeout (sec)", "min": 0, "advanced": A},
"debriefing_timeout": {"widget": "number", "label": "Debriefing Timeout (sec)", "min": 0, "advanced": A},
"lobby_idle_timeout": {"widget": "number", "label": "Lobby Idle Timeout (sec)", "min": 0, "advanced": A},
# Misc — advanced
"statistics_enabled": {"widget": "toggle", "label": "Enable Steam Statistics", "advanced": A},
"upnp": {"widget": "toggle", "label": "Enable UPnP", "advanced": A},
"loopback": {"widget": "toggle", "label": "Loopback Mode (LAN only)", "advanced": A},
"timestamp_format": {"widget": "select", "label": "Log Timestamp Format",
"options": ["none", "short", "full"], "advanced": A},
"log_file": {"widget": "text", "label": "Log File Name", "advanced": A},
# Admin / Headless — advanced
"admin_uids": {"widget": "tag-list", "label": "Admin Steam UIDs",
"placeholder": "76561198000000000", "advanced": A},
"headless_clients": {"widget": "tag-list", "label": "Headless Client IPs",
"placeholder": "127.0.0.1", "advanced": A},
"local_clients": {"widget": "tag-list", "label": "Local Client IPs",
"placeholder": "127.0.0.1", "advanced": A},
# missions managed by the Missions tab — hidden here
"missions": {"widget": "hidden"},
# default params — advanced
"default_mission_params": {"widget": "key-value", "label": "Default Mission Parameters",
"help": "Applied to all missions without custom params.", "advanced": A},
},
"basic": {
# All network tuning fields are advanced
"min_bandwidth": {"widget": "number", "label": "Min Bandwidth (bps)", "min": 1, "advanced": A},
"max_bandwidth": {"widget": "number", "label": "Max Bandwidth (bps)", "min": 1, "advanced": A},
"max_msg_send": {"widget": "number", "label": "Max Messages Sent per Frame", "min": 1, "advanced": A},
"max_size_guaranteed": {"widget": "number", "label": "Max Guaranteed Packet Size (bytes)", "min": 1, "advanced": A},
"max_size_non_guaranteed": {"widget": "number", "label": "Max Non-Guaranteed Packet Size (bytes)", "min": 1, "advanced": A},
"min_error_to_send": {"widget": "number", "label": "Min Error to Send", "advanced": A},
"max_custom_file_size": {"widget": "number", "label": "Max Custom File Size (bytes)", "min": 0, "advanced": A},
},
"profile": {
# Basic difficulty options
"reduced_damage": {"widget": "toggle", "label": "Reduced Damage", "advanced": A},
"group_indicators": {"widget": "select", "label": "Group Indicators",
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"], "advanced": B},
"friendly_tags": {"widget": "select", "label": "Friendly Name Tags",
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"], "advanced": B},
"enemy_tags": {"widget": "select", "label": "Enemy Name Tags",
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"], "advanced": B},
"detected_mines": {"widget": "select", "label": "Detected Mines",
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"], "advanced": A},
"commands": {"widget": "select", "label": "Map Commands",
"options": ["0 - Never", "1 - High command", "2 - Fade out", "3 - Always"], "advanced": B},
"waypoints": {"widget": "select", "label": "Waypoints",
"options": ["0 - Never", "1 - Known positions", "2 - Fade out", "3 - Always"], "advanced": B},
"tactical_ping": {"widget": "toggle", "label": "Tactical Ping", "advanced": A},
"weapon_info": {"widget": "select", "label": "Weapon Info",
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"], "advanced": B},
"stance_indicator": {"widget": "select", "label": "Stance Indicator",
"options": ["0 - Never", "1 - Experimental", "2 - Always", "3 - Always (soldier)"], "advanced": B},
"stamina_bar": {"widget": "select", "label": "Stamina Bar",
"options": ["0 - Never", "1 - Low stamina only", "2 - Always"], "advanced": A},
"weapon_crosshair": {"widget": "toggle", "label": "Weapon Crosshair", "advanced": A},
"vision_aid": {"widget": "toggle", "label": "Vision Aid", "advanced": A},
"third_person_view": {"widget": "toggle", "label": "Third Person View", "advanced": A},
"camera_shake": {"widget": "toggle", "label": "Camera Shake", "advanced": A},
"score_table": {"widget": "toggle", "label": "Show Score Table", "advanced": A},
"death_messages": {"widget": "toggle", "label": "Death Messages", "advanced": A},
"von_id": {"widget": "toggle", "label": "Show VoN Speaker ID", "advanced": A},
"map_content_friendly": {"widget": "select", "label": "Map — Friendly Units",
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"], "advanced": A},
"map_content_enemy": {"widget": "select", "label": "Map — Enemy Units",
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"], "advanced": A},
"map_content_mines": {"widget": "select", "label": "Map — Mines",
"options": ["0 - Never", "1 - Limited distance", "2 - Fade out", "3 - Always"], "advanced": A},
"auto_report": {"widget": "toggle", "label": "Auto Report (automatic radio reports)", "advanced": A},
"multiple_saves": {"widget": "toggle", "label": "Multiple Saves", "advanced": A},
"ai_level_preset": {"widget": "select", "label": "AI Level Preset",
"options": ["0 - Low", "1 - Normal", "2 - High", "3 - Custom", "4 - Ultra"], "advanced": B},
"skill_ai": {"widget": "number", "label": "AI Skill (0.01.0)", "min": 0, "max": 1, "advanced": B},
"precision_ai": {"widget": "number", "label": "AI Precision / Accuracy (0.01.0)", "min": 0, "max": 1, "advanced": B},
},
"launch": {
# All launch/startup fields are advanced
"world": {"widget": "text", "label": "Default World (map name)", "advanced": A},
"limit_fps": {"widget": "number", "label": "FPS Limit", "min": 1, "max": 1000, "advanced": A},
"cpu_count": {"widget": "number", "label": "CPU Core Count (0 = auto)", "min": 0, "advanced": A},
"ex_threads": {"widget": "number", "label": "Extra Thread Count", "min": 0, "advanced": A},
"max_mem": {"widget": "number", "label": "Max RAM (MB, 0 = auto)", "min": 0, "advanced": A},
"auto_init": {"widget": "toggle", "label": "Auto-Init (skip mission select)", "advanced": A},
"load_mission_to_memory": {"widget": "toggle", "label": "Load Mission to Memory", "advanced": A},
"enable_ht": {"widget": "toggle", "label": "Enable HyperThreading", "advanced": A},
"huge_pages": {"widget": "toggle", "label": "Enable Huge Pages (performance)", "advanced": A},
"no_logs": {"widget": "toggle", "label": "Disable Server Logging", "advanced": A},
"netlog": {"widget": "toggle", "label": "Enable Network Log", "advanced": A},
"extra_params": {"widget": "tag-list", "label": "Additional Startup Parameters",
"placeholder": "-filePatching", "advanced": A},
},
"rcon": {
"rcon_password": {"widget": "password", "label": "RCon Password", "advanced": B},
"max_ping": {"widget": "number", "label": "Max Ping for RCon (ms)", "min": 1, "advanced": A},
"enabled": {"widget": "toggle", "label": "Enable RCon", "advanced": B},
},
}
def preview_config(
self,
server_id: int,
server_dir: Path,
config_sections: dict[str, dict],
) -> dict[str, str]:
server_cfg = ServerConfig(**config_sections.get("server", {}))
basic_cfg = BasicConfig(**config_sections.get("basic", {}))
profile_cfg = ProfileConfig(**config_sections.get("profile", {}))
rcon_cfg = RConConfig(**config_sections.get("rcon", {}))
return {
"server.cfg": self._render_server_cfg(server_cfg),
"basic.cfg": self._render_basic_cfg(basic_cfg),
"server/server.Arma3Profile": self._render_arma3profile(profile_cfg),
"battleye/beserver.cfg": self._render_beserver_cfg(rcon_cfg),
}

View File

@@ -0,0 +1,111 @@
"""Arma 3 RPT log parser."""
from __future__ import annotations
import re
from datetime import datetime
from pathlib import Path
from typing import Callable
class RPTParser:
"""Parses Arma 3 .rpt log files."""
# Pattern: "HH:MM:SS ..." or "[HH:MM:SS] ..." with optional date prefix
_timestamp_re = re.compile(
r"^\s*(?:(\d{2}/\d{2}/\d{4})\s+)?"
r"(?:\[)?(\d{2}:\d{2}:\d{2})(?:\])?\s*"
r"(?:\[?(\w+)\]?\s*)?(.*)$"
)
def parse_line(self, line: str) -> dict | None:
"""Parse one RPT log line."""
if not line or not line.strip():
return None
match = self._timestamp_re.match(line)
if not match:
# Non-timestamped line — treat as info
stripped = line.strip()
if not stripped:
return None
return {
"timestamp": datetime.utcnow().isoformat(),
"level": "info",
"message": stripped,
}
date_str, time_str, level_str, message = match.groups()
# Map Arma 3 log levels
level = "info"
if level_str:
level_lower = level_str.lower()
if level_lower in ("error", "fault"):
level = "error"
elif level_lower in ("warning", "warn"):
level = "warning"
# Build ISO timestamp
try:
if date_str:
dt = datetime.strptime(f"{date_str} {time_str}", "%m/%d/%Y %H:%M:%S")
else:
dt = datetime.strptime(time_str, "%H:%M:%S")
dt = dt.replace(year=datetime.utcnow().year, month=datetime.utcnow().month, day=datetime.utcnow().day)
timestamp = dt.isoformat()
except ValueError:
timestamp = datetime.utcnow().isoformat()
return {
"timestamp": timestamp,
"level": level,
"message": (message or "").strip(),
}
def list_log_files(self, server_dir: Path) -> list[dict]:
"""Return all .rpt log files in server_dir/server/, newest first."""
profile_dir = server_dir / "server"
if not profile_dir.exists():
return []
files = []
for p in profile_dir.glob("*.rpt"):
try:
stat = p.stat()
files.append({
"filename": p.name,
"size_bytes": stat.st_size,
"modified_at": stat.st_mtime,
})
except OSError:
pass
files.sort(key=lambda f: f["modified_at"], reverse=True)
return files
def get_log_file_path(self, server_dir: Path, filename: str) -> Path | None:
"""Return the Path for a specific log file, or None if not found / path traversal attempt."""
import os
profile_dir = server_dir / "server"
target = (profile_dir / filename).resolve()
if not str(target).startswith(str(profile_dir.resolve())):
return None
if not target.exists() or target.suffix != ".rpt":
return None
return target
def get_log_file_resolver(self, server_id: int) -> Callable[[Path], Path | None]:
"""Return a callable that finds the current RPT log file."""
def resolver(server_dir: Path) -> Path | None:
# Arma 3 stores logs in server_dir/server/*.rpt
profile_dir = server_dir / "server"
if not profile_dir.exists():
return None
rpt_files = sorted(profile_dir.glob("*.rpt"), key=lambda p: p.stat().st_mtime, reverse=True)
if rpt_files:
return rpt_files[0]
# Fallback: check for arma3server_x64_*.rpt pattern
rpt_files = sorted(profile_dir.glob("arma3server*.rpt"), key=lambda p: p.stat().st_mtime, reverse=True)
return rpt_files[0] if rpt_files else None
return resolver

View File

@@ -0,0 +1,193 @@
"""Arma 3 mission manager — handles .pbo mission files, upload, delete, rotation."""
from __future__ import annotations
import logging
import os
import re
from pathlib import Path
from pydantic import BaseModel
from adapters.exceptions import AdapterError
from core.utils.file_utils import get_server_dir, sanitize_filename, safe_delete_file
logger = logging.getLogger(__name__)
_MISSIONS_DIR = "mpmissions"
_ALLOWED_EXTENSION = ".pbo"
_MAX_MISSION_SIZE_MB = 500
class Arma3MissionData(BaseModel):
"""Mission data schema for Arma 3."""
terrain: str = ""
difficulty: str = "Regular"
class Arma3MissionManager:
file_extension = ".pbo"
def __init__(self, server_id: int | None = None) -> None:
self._server_id = server_id
def _missions_dir(self) -> Path:
return get_server_dir(self._server_id) / _MISSIONS_DIR
# ── File operations ──
def list_missions(self) -> list[dict]:
"""
Scan the mpmissions directory and return all .pbo files.
Returns list of dicts:
name: str — filename without extension
filename: str — full filename
size_bytes: int — file size
"""
missions_dir = self._missions_dir()
if not missions_dir.exists():
return []
missions = []
try:
for entry in missions_dir.iterdir():
if entry.is_file() and entry.suffix.lower() == _ALLOWED_EXTENSION:
parsed = self.parse_mission_filename(entry.name)
missions.append({
"name": entry.stem,
"filename": entry.name,
"size_bytes": entry.stat().st_size,
"terrain": parsed["terrain"],
})
except OSError as exc:
raise AdapterError(f"Cannot list missions: {exc}") from exc
missions.sort(key=lambda m: m["filename"].lower())
return missions
def upload_mission(self, filename: str, content: bytes) -> dict:
"""
Save a mission file to the mpmissions directory.
Args:
filename: Original filename from the upload (will be sanitized).
content: Raw file bytes.
Returns the saved mission dict.
"""
safe_name = sanitize_filename(filename)
if not safe_name.lower().endswith(_ALLOWED_EXTENSION):
raise AdapterError(
f"Invalid mission file extension. Only {_ALLOWED_EXTENSION} files are allowed."
)
size_mb = len(content) / (1024 * 1024)
if size_mb > _MAX_MISSION_SIZE_MB:
raise AdapterError(
f"Mission file too large ({size_mb:.1f} MB). Max is {_MAX_MISSION_SIZE_MB} MB."
)
missions_dir = self._missions_dir()
missions_dir.mkdir(parents=True, exist_ok=True)
dest_path = missions_dir / safe_name
# Atomic write: write to .tmp first, then replace
tmp_path = str(dest_path) + ".tmp"
try:
with open(tmp_path, "wb") as f:
f.write(content)
os.replace(tmp_path, str(dest_path))
except OSError as exc:
safe_delete_file(Path(tmp_path))
raise AdapterError(f"Cannot save mission file: {exc}") from exc
logger.info(
"Mission uploaded for server %d: %s (%d bytes)",
self._server_id, safe_name, len(content),
)
return {
"name": dest_path.stem,
"filename": safe_name,
"size_bytes": len(content),
}
def delete_mission(self, filename: str) -> bool:
"""
Delete a mission file.
Returns True if deleted, False if not found.
"""
safe_name = sanitize_filename(filename)
if not safe_name.lower().endswith(_ALLOWED_EXTENSION):
raise AdapterError("Invalid mission filename")
dest_path = self._missions_dir() / safe_name
# Verify resolved path is inside missions directory (path traversal guard)
try:
dest_path.resolve().relative_to(self._missions_dir().resolve())
except ValueError:
raise AdapterError("Path traversal detected in filename")
if not dest_path.exists():
return False
try:
dest_path.unlink()
logger.info("Mission deleted for server %d: %s", self._server_id, safe_name)
return True
except OSError as exc:
raise AdapterError(f"Cannot delete mission: {exc}") from exc
# ── Mission rotation config ──
def parse_mission_filename(self, filename: str) -> dict:
"""
Parse Arma 3 mission filename.
Format: MissionName.Terrain.pbo
"""
name = filename
if name.endswith(self.file_extension):
name = name[: -len(self.file_extension)]
parts = name.rsplit(".", 1)
if len(parts) == 2:
return {
"mission_name": parts[0],
"terrain": parts[1],
"filename": filename,
}
return {
"mission_name": name,
"terrain": "",
"filename": filename,
}
def get_rotation_config(self, rotation_entries: list[dict]) -> str:
"""
Generate Arma 3 mission rotation config block.
rotation_entries: list of {mission_name, terrain, difficulty, params_json}
"""
if not rotation_entries:
return ""
lines = ['class Missions {']
for i, entry in enumerate(rotation_entries):
mission = entry.get("mission_name", "")
terrain = entry.get("terrain", "")
difficulty = entry.get("difficulty", "Regular")
params = entry.get("params_json", "{}")
lines.append(f' class Mission_{i} {{')
lines.append(f' template = "{mission}.{terrain}";')
lines.append(f' difficulty = "{difficulty}";')
if params and params != "{}":
lines.append(f' params = {params};')
lines.append(' };')
lines.append('};')
return "\n".join(lines)
def get_missions_dir(self, server_dir: Path) -> Path:
return server_dir / _MISSIONS_DIR
def get_mission_data_schema(self) -> type[BaseModel] | None:
return Arma3MissionData

View File

@@ -0,0 +1,193 @@
"""Arma 3 mod manager — handles mod folder conventions, CLI args, and enable/disable."""
from __future__ import annotations
import logging
import re
from pathlib import Path
from pydantic import BaseModel
from adapters.exceptions import AdapterError
from core.utils.file_utils import get_server_dir
logger = logging.getLogger(__name__)
_MOD_DIR_PATTERN = re.compile(r"^@.+", re.IGNORECASE)
def _parse_mod_cpp(mod_dir: Path) -> str | None:
mod_cpp = mod_dir / "mod.cpp"
if not mod_cpp.exists():
return None
text = mod_cpp.read_text(errors="ignore")
m = re.search(r'name\s*=\s*"([^"]+)"', text, re.IGNORECASE)
return m.group(1) if m else None
def _parse_meta_cpp(mod_dir: Path) -> str | None:
meta_cpp = mod_dir / "meta.cpp"
if not meta_cpp.exists():
return None
text = meta_cpp.read_text(errors="ignore")
m = re.search(r'publishedid\s*=\s*(\d+)', text, re.IGNORECASE)
return m.group(1) if m else None
class Arma3ModData(BaseModel):
"""Mod data schema for Arma 3."""
workshop_id: str = ""
is_server_mod: bool = False
class Arma3ModManager:
def __init__(self, server_id: int | None = None) -> None:
self._server_id = server_id
def _server_dir(self) -> Path:
return get_server_dir(self._server_id)
def _mods_dir(self) -> Path:
return get_server_dir(self._server_id) / "mods"
# ── File / DB operations ──
def list_available_mods(self) -> list[dict]:
"""
Scan the server's mods/ subdirectory for mod folders (directories starting with '@').
Returns list of dicts:
name: str — directory name (e.g. "@CBA_A3")
path: str — absolute directory path
size_bytes: int — total directory size (approximate, non-recursive)
"""
mods_dir = self._mods_dir()
if not mods_dir.exists():
return []
mods = []
try:
for entry in mods_dir.iterdir():
if entry.is_dir() and _MOD_DIR_PATTERN.match(entry.name):
try:
size = sum(
f.stat().st_size
for f in entry.iterdir()
if f.is_file()
)
except OSError:
size = 0
mods.append({
"name": entry.name,
"path": str(entry.resolve()),
"size_bytes": size,
"display_name": _parse_mod_cpp(entry),
"workshop_id": _parse_meta_cpp(entry),
})
except OSError as exc:
raise AdapterError(f"Cannot scan mod directory: {exc}") from exc
mods.sort(key=lambda m: m["name"].lower())
return mods
def get_enabled_mods(self, config_repo) -> list[dict]:
"""
Get the list of enabled mods from the database config.
Returns list of dicts: [{"name": "@CBA_A3", "is_server_mod": False}, ...]
Handles migration from old string-list format automatically.
"""
mods_section = config_repo.get_section(self._server_id, "mods")
if mods_section is None:
return []
raw = mods_section.get("enabled_mods", [])
result = []
for item in raw:
if isinstance(item, str):
result.append({"name": item, "is_server_mod": False})
elif isinstance(item, dict):
result.append({"name": item.get("name", ""), "is_server_mod": bool(item.get("is_server_mod", False))})
return result
def set_enabled_mods(self, mod_entries: list[dict], config_repo) -> None:
"""
Update the enabled mods list in the database config.
Args:
mod_entries: List of dicts with "name" (str) and "is_server_mod" (bool).
config_repo: ConfigRepository instance.
Raises AdapterError if any mod name is invalid or not found on disk.
"""
available = {m["name"] for m in self.list_available_mods()}
for entry in mod_entries:
name = entry.get("name", "")
if not _MOD_DIR_PATTERN.match(name):
raise AdapterError(f"Invalid mod name '{name}': must start with '@'")
if name not in available:
raise AdapterError(
f"Mod '{name}' not found in mods directory. "
f"Available: {sorted(available)}"
)
mods_section = config_repo.get_section(self._server_id, "mods") or {}
current_version = mods_section.get("_meta", {}).get("config_version")
config_repo.upsert_section(
server_id=self._server_id,
game_type="arma3",
section="mods",
config_data={"enabled_mods": mod_entries},
schema_version="1.0.0",
expected_config_version=current_version,
)
logger.info(
"Updated enabled mods for server %d: %s",
self._server_id, [e["name"] for e in mod_entries],
)
# ── CLI argument building ──
def get_mod_folder_pattern(self) -> str:
"""Arma 3 mods use @ prefix for local, or numeric workshop IDs."""
return "@*"
def build_mod_args(self, server_mods: list[dict]) -> list[str]:
"""
Build Arma 3 mod CLI arguments.
Returns -mod and -serverMod argument lists.
"""
client_mods = []
server_only_mods = []
for mod in server_mods:
path = mod.get("folder_path", "")
game_data = mod.get("game_data", {})
if isinstance(game_data, str):
import json
try:
game_data = json.loads(game_data)
except (json.JSONDecodeError, TypeError):
game_data = {}
is_server = game_data.get("is_server_mod", False) if isinstance(game_data, dict) else False
if is_server:
server_only_mods.append(path)
else:
client_mods.append(path)
args = []
if client_mods:
args.append('-mod="' + ";".join(client_mods) + '"')
if server_only_mods:
args.append('-serverMod="' + ";".join(server_only_mods) + '"')
return args
def validate_mod_folder(self, path: Path) -> bool:
"""Validate that a path looks like a valid Arma 3 mod folder."""
if not path.exists() or not path.is_dir():
return False
return (path / "addons").exists() or (path / "$PREFIX$").exists()
def get_mod_data_schema(self) -> type[BaseModel] | None:
return Arma3ModData

View File

@@ -0,0 +1,76 @@
"""Arma 3 process configuration: executables, ports, directory layout."""
class Arma3ProcessConfig:
def get_allowed_executables(self) -> list[str]:
return ["arma3server_x64.exe", "arma3server.exe"]
def get_port_conventions(self, game_port: int) -> dict[str, int]:
"""
Arma 3 derives 3 additional ports from the game port.
All 4 must be free when starting a server.
rcon_port is separate (user-configurable, not auto-derived here).
"""
return {
"game": game_port,
"steam_query": game_port + 1,
"von": game_port + 2,
"steam_auth": game_port + 3,
}
def get_default_game_port(self) -> int:
return 2302
def get_default_rcon_port(self, game_port: int) -> int | None:
return game_port + 4 # e.g. 2306 for default game port
def get_server_dir_layout(self) -> list[str]:
"""Subdirectories to create inside servers/{id}/."""
return ["server", "battleye", "mpmissions", "mods"]
_DIR_READMES: dict[str, str] = {
"server": (
"Arma 3 Server — Log Directory\n"
"==============================\n\n"
"Arma 3 writes RPT log files here (e.g. arma3server_2024-01-01_12-00-00.rpt).\n"
"These are viewable in Languard's Logs tab.\n\n"
"Do NOT place files here manually."
),
"battleye": (
"BattlEye Anti-Cheat\n"
"===================\n\n"
"BattlEye configuration and GUID ban list files live here.\n"
"Managed automatically by Arma 3 and Languard.\n\n"
"Do NOT modify these files manually unless you know what you are doing."
),
"mpmissions": (
"Mission Files\n"
"=============\n\n"
"Place Arma 3 mission files (.pbo) here to make them available for the server.\n"
"Once placed here they will appear in Languard's Missions tab.\n\n"
"Example: Wasteland_A3.Altis.pbo"
),
"mods": (
"Mods\n"
"====\n\n"
"Place Arma 3 mod folders here. Each mod folder must start with '@'.\n\n"
"Example layout:\n"
" mods/\n"
" @CBA_A3/\n"
" addons/\n"
" @ACE/\n"
" addons/\n\n"
"After placing mods here:\n"
" 1. Go to the Mods tab in Languard.\n"
" 2. Select the mods you want to enable.\n"
" 3. Toggle 'Server-only' for mods that should use -serverMod= (e.g. task force radio server plugin).\n"
" 4. Click 'Apply Selection'.\n"
" 5. Restart the server for changes to take effect.\n\n"
"Mods with a mod.cpp file will display their friendly name in the UI.\n"
"Workshop mods with meta.cpp will show their Workshop ID."
),
}
def get_dir_readme(self, dir_name: str) -> str | None:
return self._DIR_READMES.get(dir_name)

View File

@@ -0,0 +1,278 @@
"""
BERConClient — BattlEye RCon UDP client for Arma3.
Implements the BattlEye RCon protocol version 2.
Reference: https://www.battleye.com/downloads/BERConProtocol.txt
Thread safety: This client is NOT thread-safe by itself.
The RemoteAdminPollerThread serializes all calls through a single thread.
For the send_command() called from HTTP request handlers, use a threading.Lock.
"""
from __future__ import annotations
import logging
import socket
import struct
import threading
import time
import zlib
logger = logging.getLogger(__name__)
_SOCKET_TIMEOUT = 5.0
_LOGIN_TIMEOUT = 5.0
_RESPONSE_TIMEOUT = 5.0
_MAX_RESPONSE_PARTS = 10
_KEEPALIVE_INTERVAL = 30.0
class BERConClient:
"""
BattlEye RCon UDP client.
Usage:
client = BERConClient(host="127.0.0.1", port=2302, password="secret")
client.connect() # raises ConnectionError on failure
players = client.get_players()
client.send_command("say -1 Hello")
client.disconnect()
"""
def __init__(self, host: str, port: int, password: str) -> None:
self._host = host
self._port = port
self._password = password
self._sock: socket.socket | None = None
self._seq = 0
self._connected = False
self._lock = threading.Lock()
self._last_activity = 0.0
# ── Public API ──
def connect(self) -> None:
"""Open UDP socket and perform BattlEye login handshake."""
with self._lock:
if self._connected:
return
self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self._sock.settimeout(_SOCKET_TIMEOUT)
self._sock.connect((self._host, self._port))
login_payload = self._password.encode("ascii", errors="replace")
packet = self._build_packet(0x00, login_payload)
self._sock.send(packet)
self._last_activity = time.monotonic()
deadline = time.monotonic() + _LOGIN_TIMEOUT
while time.monotonic() < deadline:
try:
data = self._sock.recv(4096)
except socket.timeout:
break
if not self._verify_checksum(data):
continue
if len(data) >= 9 and data[7] == 0x00:
if data[8] == 0x01:
self._connected = True
self._seq = 0
logger.info("BERConClient: logged in to %s:%d", self._host, self._port)
return
else:
self._sock.close()
self._sock = None
raise ConnectionError(
f"BattlEye login rejected at {self._host}:{self._port}"
)
self._sock.close()
self._sock = None
raise ConnectionError(
f"BattlEye login timed out at {self._host}:{self._port}"
)
def disconnect(self) -> None:
with self._lock:
self._connected = False
if self._sock is not None:
try:
self._sock.close()
except OSError as exc:
logger.debug("BERConClient: error closing socket during disconnect: %s", exc)
self._sock = None
@property
def is_connected(self) -> bool:
return self._connected
def send_command(self, command: str) -> str:
"""Send a BattlEye command and return the response string."""
with self._lock:
if not self._connected or self._sock is None:
raise ConnectionError("BERConClient: not connected")
return self._send_command_locked(command)
def get_players(self) -> list[dict]:
"""Send 'players' command and parse the response."""
response = self.send_command("players")
return self._parse_players(response)
def keepalive(self) -> None:
"""Send a keepalive packet if the connection has been idle."""
if not self._connected:
return
elapsed = time.monotonic() - self._last_activity
if elapsed >= _KEEPALIVE_INTERVAL:
try:
self.send_command("")
except Exception as exc:
logger.debug("BERConClient: keepalive failed: %s", exc)
# ── Packet building ──
def _build_packet(self, pkt_type: int, payload: bytes) -> bytes:
"""Build a BattlEye packet: 'B' 'E' <crc32 LE> 0xFF <type> <payload>"""
body = bytes([0xFF, pkt_type]) + payload
crc = zlib.crc32(body) & 0xFFFFFFFF
crc_bytes = struct.pack("<I", crc)
return b"BE" + crc_bytes + body
def _build_command_packet(self, seq: int, command: str) -> bytes:
payload = bytes([seq]) + command.encode("ascii", errors="replace")
return self._build_packet(0x01, payload)
def _build_ack_packet(self, seq: int) -> bytes:
return self._build_packet(0x02, bytes([seq]))
def _verify_checksum(self, data: bytes) -> bool:
"""Verify the CRC32 checksum in the received packet."""
if len(data) < 8:
return False
if data[0:2] != b"BE":
return False
stored_crc = struct.unpack("<I", data[2:6])[0]
body = data[6:]
computed_crc = zlib.crc32(body) & 0xFFFFFFFF
return stored_crc == computed_crc
# ── Command send (must be called with self._lock held) ──
def _send_command_locked(self, command: str) -> str:
seq = self._seq
self._seq = (self._seq + 1) % 256
packet = self._build_command_packet(seq, command)
self._sock.send(packet)
self._last_activity = time.monotonic()
parts: dict[int, str] = {}
total_parts: int | None = None
deadline = time.monotonic() + _RESPONSE_TIMEOUT
while time.monotonic() < deadline:
try:
data = self._sock.recv(65535)
except socket.timeout:
break
if not self._verify_checksum(data):
continue
if len(data) < 9:
continue
pkt_type = data[7]
# Server message — acknowledge and ignore
if pkt_type == 0x02:
srv_seq = data[8]
ack = self._build_ack_packet(srv_seq)
try:
self._sock.send(ack)
except OSError as exc:
logger.debug("BERConClient: failed to send ack for server message %d: %s", srv_seq, exc)
continue
# Command response
if pkt_type == 0x01:
resp_seq = data[8]
if resp_seq != seq:
continue
payload = data[9:]
# Check if multi-part
if len(payload) >= 3 and payload[0] == 0x00:
total_parts = payload[1]
part_index = payload[2]
part_text = payload[3:].decode("utf-8", errors="replace")
parts[part_index] = part_text
if len(parts) == total_parts:
break
else:
# Single-part response
return payload.decode("utf-8", errors="replace")
if total_parts is not None and parts:
return "".join(parts[i] for i in sorted(parts.keys()))
return ""
# ── Player parsing ──
def _parse_players(self, response: str) -> list[dict]:
"""Parse the 'players' command response."""
players = []
lines = response.split("\n")
for line in lines:
line = line.strip()
if not line:
continue
if line.startswith("Players on") or line.startswith("-") or line.startswith("("):
continue
parts = line.split(None, 4)
if len(parts) < 4:
continue
try:
number = int(parts[0])
except ValueError:
continue
ip_port = parts[1]
ping_str = parts[2]
guid_part = parts[3]
name = parts[4].strip() if len(parts) > 4 else ""
ip = ip_port
port = 0
if ":" in ip_port:
ip, port_str = ip_port.rsplit(":", 1)
try:
port = int(port_str)
except ValueError:
port = 0
try:
ping = int(ping_str)
except ValueError:
ping = 0
uid = guid_part.split("(")[0]
is_admin = "(Admin)" in name
name = name.replace("(Admin)", "").strip()
players.append({
"number": number,
"uid": uid,
"name": name,
"ip": ip,
"port": port,
"ping": ping,
"is_admin": is_admin,
"slot_id": number,
})
return players

View File

@@ -0,0 +1,142 @@
"""Arma 3 RCon service — remote admin via BattleEye RCon protocol."""
from __future__ import annotations
import socket
import logging
import struct
from typing import Any
from pydantic import BaseModel
logger = logging.getLogger(__name__)
class Arma3PlayerData(BaseModel):
"""Player data schema for Arma 3."""
name: str
ping: int = 0
guid: str = ""
class Arma3RConClient:
"""BattleEye RCon client for a single connection."""
def __init__(self, host: str, port: int, password: str):
self._host = host
self._port = port
self._password = password
self._sock: socket.socket | None = None
def _connect(self) -> None:
if self._sock is not None:
return
self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self._sock.settimeout(5.0)
self._sock.connect((self._host, self._port))
# Login sequence
self._login()
def _login(self) -> None:
if self._sock is None:
raise ConnectionError("Not connected")
# BE RCon login: send password with checksum
password_bytes = self._password.encode("utf-8")
checksum = self._compute_checksum(password_bytes)
packet = b"\xff" + bytes([0, len(password_bytes) & 0xff]) + checksum + password_bytes
self._sock.send(packet)
response = self._sock.recv(4096)
if not response or response[0] != 0xff:
raise ConnectionError("RCon login failed")
@staticmethod
def _compute_checksum(data: bytes) -> bytes:
"""Compute BE RCon checksum (sum of bytes) & 0xFF."""
return bytes([sum(data) & 0xFF])
def send_command(self, command: str, timeout: float = 5.0) -> str | None:
try:
self._connect()
if self._sock is None:
return None
self._sock.settimeout(timeout)
cmd_bytes = command.encode("utf-8")
checksum = self._compute_checksum(cmd_bytes)
packet = b"\xff\x01" + bytes([len(cmd_bytes) & 0xff]) + checksum + cmd_bytes
self._sock.send(packet)
response = self._sock.recv(4096)
if response and len(response) > 2:
return response[2:].decode("utf-8", errors="replace")
return None
except Exception as e:
logger.error("RCon command error: %s", e)
return None
def get_players(self) -> list[dict]:
result = self.send_command("players")
if result is None:
return []
# Parse player list from BE RCon response
players = []
for line in result.split("\n"):
line = line.strip()
if not line or line.startswith("(") or line.startswith("total"):
continue
parts = line.split(maxsplit=4)
if len(parts) >= 5:
players.append({
"slot_id": parts[0],
"name": parts[3] if len(parts) > 3 else "",
"guid": parts[2] if len(parts) > 2 else "",
"ping": int(parts[1]) if parts[1].isdigit() else 0,
})
return players
def kick_player(self, identifier: str, reason: str = "") -> bool:
cmd = f"kick {identifier}"
if reason:
cmd += f" {reason}"
result = self.send_command(cmd)
return result is not None
def ban_player(self, identifier: str, duration_minutes: int, reason: str) -> bool:
cmd = f"ban {identifier} {duration_minutes} {reason}"
result = self.send_command(cmd)
return result is not None
def say_all(self, message: str) -> bool:
result = self.send_command(f"say {message}")
return result is not None
def shutdown(self) -> bool:
result = self.send_command("#shutdown")
return result is not None
def keepalive(self) -> None:
try:
self.send_command("")
except Exception as exc:
logger.debug("Arma3RConClient: keepalive failed: %s", exc)
def disconnect(self) -> None:
if self._sock:
try:
self._sock.close()
except Exception as exc:
logger.debug("Arma3RConClient: error closing socket: %s", exc)
self._sock = None
class Arma3RConService:
"""Factory for Arma 3 RCon clients."""
def create_client(self, host: str, port: int, password: str) -> Arma3RConClient:
return Arma3RConClient(host, port, password)
def get_startup_delay(self) -> float:
return 30.0
def get_poll_interval(self) -> float:
return 10.0
def get_player_data_schema(self) -> type[BaseModel] | None:
return Arma3PlayerData

View File

@@ -0,0 +1,135 @@
"""
Arma3RemoteAdmin — implements the RemoteAdmin protocol using BERConClient.
"""
from __future__ import annotations
import logging
from adapters.arma3.rcon_client import BERConClient
from adapters.exceptions import RemoteAdminError
logger = logging.getLogger(__name__)
class Arma3RemoteAdmin:
"""
RemoteAdmin protocol implementation for Arma3 BattlEye RCon.
Args:
server_id: Database server ID.
host: RCon host (usually 127.0.0.1).
port: RCon port (usually game_port + 3).
password: RCon password.
"""
def __init__(
self,
server_id: int,
host: str,
port: int,
password: str,
) -> None:
self._server_id = server_id
self._client = BERConClient(host=host, port=port, password=password)
# ── RemoteAdmin protocol ──
def connect(self) -> None:
"""Connect to RCon. Raises RemoteAdminError on failure."""
try:
self._client.connect()
except ConnectionError as exc:
raise RemoteAdminError(str(exc)) from exc
def disconnect(self) -> None:
self._client.disconnect()
def is_connected(self) -> bool:
return self._client.is_connected
def get_players(self) -> list[dict]:
"""Fetch current player list."""
try:
return self._client.get_players()
except Exception as exc:
raise RemoteAdminError(f"get_players failed: {exc}") from exc
def send_command(self, command: str, timeout: float = 5.0) -> str | None:
"""Send an arbitrary RCon command."""
try:
return self._client.send_command(command)
except Exception as exc:
raise RemoteAdminError(f"send_command failed: {exc}") from exc
def kick_player(self, player_number: int, reason: str = "") -> bool:
"""Kick a player by their in-game slot number."""
command = f"kick {player_number}"
if reason:
command += f" {reason}"
try:
self._client.send_command(command)
return True
except Exception as exc:
logger.warning("[%s] kick_player failed for player %d: %s", self._server_id, player_number, exc)
return False
def ban_player(self, player_uid: str, duration_minutes: int = 0, reason: str = "") -> bool:
"""Add a GUID ban. duration_minutes=0 means permanent."""
duration = duration_minutes if duration_minutes > 0 else 0
command = f"addBan {player_uid} {duration} {reason}"
try:
self._client.send_command(command)
return True
except Exception as exc:
logger.warning("[%s] ban_player failed: %s", self._server_id, exc)
return False
def say_all(self, message: str) -> bool:
"""Broadcast a message to all players."""
try:
self._client.send_command(f"say -1 {message}")
return True
except Exception as exc:
logger.warning("[%s] say_all failed: %s", self._server_id, exc)
return False
def shutdown(self) -> bool:
"""Shutdown the game server via RCon."""
try:
self._client.send_command("#shutdown")
return True
except Exception as exc:
logger.warning("[%s] shutdown failed: %s", self._server_id, exc)
return False
def keepalive(self) -> None:
"""Send keepalive if idle."""
self._client.keepalive()
class Arma3RemoteAdminFactory:
"""
RemoteAdmin factory for Arma3.
Implements the RemoteAdmin protocol (create_client, get_startup_delay, etc.).
"""
def create_client(self, host: str, port: int, password: str) -> Arma3RemoteAdmin:
"""Create a new Arma3RemoteAdmin client instance."""
return Arma3RemoteAdmin(
server_id=0, # Will be set by caller
host=host,
port=port,
password=password,
)
def get_startup_delay(self) -> float:
"""Seconds to wait after server start before connecting."""
return 30.0
def get_poll_interval(self) -> float:
"""Seconds between player list polls."""
return 10.0
def get_player_data_schema(self):
"""Pydantic model for players.game_data JSON."""
return None

View File

@@ -0,0 +1,53 @@
"""Typed adapter exceptions. Core catches these specifically."""
class AdapterError(Exception):
"""Base for all adapter errors."""
pass
class ConfigWriteError(AdapterError):
"""Atomic file write failed. Temp files are already cleaned up."""
def __init__(self, path: str, detail: str):
self.path = path
self.detail = detail
super().__init__(f"Config write failed at {path}: {detail}")
class ConfigValidationError(AdapterError):
"""Adapter Pydantic model rejected the config values."""
def __init__(self, section: str, errors: list[dict]):
self.section = section
self.errors = errors
super().__init__(f"Config validation failed for section '{section}': {errors}")
class ConfigMigrationError(AdapterError):
"""migrate_config() could not transform old schema. Core keeps original."""
def __init__(self, from_version: str, detail: str):
self.from_version = from_version
self.detail = detail
super().__init__(f"Config migration from {from_version} failed: {detail}")
class LaunchArgsError(AdapterError):
"""build_launch_args() failed (missing mod path, bad config value)."""
def __init__(self, detail: str):
self.detail = detail
super().__init__(f"Launch args error: {detail}")
class RemoteAdminError(AdapterError):
"""Remote admin connection or command failed."""
def __init__(self, detail: str, recoverable: bool = True):
self.detail = detail
self.recoverable = recoverable
super().__init__(f"Remote admin error: {detail}")
class ExeNotAllowedError(AdapterError):
"""Executable not in adapter allowlist."""
def __init__(self, exe: str, allowed: list[str]):
self.exe = exe
self.allowed = allowed
super().__init__(f"Executable '{exe}' not allowed. Allowed: {allowed}")

View File

@@ -0,0 +1,246 @@
"""
All adapter capability Protocol definitions.
Core code only imports from here — never from adapter internals.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Callable, Protocol, runtime_checkable
from pydantic import BaseModel
@runtime_checkable
class ConfigGenerator(Protocol):
"""
Merged protocol: config schema definition + file generation + launch args.
Always implement all methods. Return empty dict/list where not applicable.
"""
game_type: str
def get_sections(self) -> dict[str, type[BaseModel]]:
"""Return {section_name: PydanticModelClass} for all config sections."""
...
def get_defaults(self, section: str) -> dict[str, Any]:
"""Return default values dict for the given section."""
...
def get_sensitive_fields(self, section: str) -> list[str]:
"""
Return JSON keys in this section that need Fernet encryption.
Core's ConfigRepository encrypts/decrypts these transparently.
Example: ["password", "password_admin"] for section "server".
"""
...
def get_config_version(self) -> str:
"""
Current adapter schema version string (e.g. "1.0.0").
Stored in game_configs.schema_version.
When this changes, core calls migrate_config() automatically.
"""
...
def migrate_config(
self, old_version: str, config_json: dict[str, dict]
) -> dict[str, dict]:
"""
Transform config JSON from old_version to current version.
Called by ConfigRepository when stored schema_version differs.
Returns migrated config dict.
Raises ConfigMigrationError on failure — core keeps original.
"""
...
def write_configs(
self,
server_id: int,
server_dir: Path,
config_sections: dict[str, dict],
) -> list[Path]:
"""
Write all config files to disk using atomic write pattern:
1. Write to .tmp files
2. os.replace() each .tmp to final path
3. On any failure: clean up .tmp files, raise ConfigWriteError
Returns list of written file paths.
"""
...
def build_launch_args(
self,
config_sections: dict[str, dict],
mod_args: list[str] | None = None,
) -> list[str]:
"""
Return full CLI argument list for the game executable.
Raises LaunchArgsError if required values are missing/invalid.
"""
...
def preview_config(
self,
server_id: int,
server_dir: Path,
config_sections: dict[str, dict],
) -> dict[str, str]:
"""
Render config files as strings WITHOUT writing to disk.
Returns {label: content}.
Label = filename for file-based games, var name for env-var games.
"""
...
def normalize_section(self, section: str, data: dict) -> dict:
"""
Optional: backfill / migrate a stored section dict before returning it to callers.
Called by service.get_config_section() via hasattr guard.
Default: return data unchanged. Implement to add new optional fields with defaults.
"""
return data
@runtime_checkable
class RemoteAdminClient(Protocol):
"""A connected client instance. Not required to be thread-safe — core wraps calls."""
def send_command(self, command: str, timeout: float = 5.0) -> str | None: ...
def get_players(self) -> list[dict]: ...
def kick_player(self, identifier: str, reason: str = "") -> bool: ...
def ban_player(self, identifier: str, duration_minutes: int, reason: str) -> bool: ...
def say_all(self, message: str) -> bool: ...
def shutdown(self) -> bool: ...
def keepalive(self) -> None: ...
def disconnect(self) -> None: ...
@runtime_checkable
class RemoteAdmin(Protocol):
"""Factory for remote admin clients. One per adapter, creates clients on demand."""
def create_client(self, host: str, port: int, password: str) -> RemoteAdminClient: ...
def get_startup_delay(self) -> float:
"""Seconds to wait after server start before connecting. Default: 30."""
...
def get_poll_interval(self) -> float:
"""Seconds between player list polls. Default: 10."""
...
def get_player_data_schema(self) -> type[BaseModel] | None:
"""Pydantic model for players.game_data JSON. None = no validation."""
...
@runtime_checkable
class LogParser(Protocol):
"""Parses game-specific log lines into standard format."""
def parse_line(self, line: str) -> dict | None:
"""
Parse one log line.
Returns: {"timestamp": ISO str, "level": "info"|"warning"|"error", "message": str}
Returns None to skip the line (e.g. blank lines, binary garbage).
"""
...
def get_log_file_resolver(self, server_id: int) -> Callable[[Path], Path | None]:
"""
Return a callable(server_dir: Path) -> Path | None.
Called by LogTailThread to find the current log file.
Return None if log file not yet created.
"""
...
@runtime_checkable
class MissionManager(Protocol):
"""Handles mission/scenario file format and rotation."""
file_extension: str # e.g. ".pbo"
def parse_mission_filename(self, filename: str) -> dict: ...
def get_rotation_config(self, rotation_entries: list[dict]) -> str: ...
def get_missions_dir(self, server_dir: Path) -> Path: ...
def get_mission_data_schema(self) -> type[BaseModel] | None:
"""Pydantic model for missions.game_data. None = no validation."""
...
@runtime_checkable
class ModManager(Protocol):
"""Handles mod folder conventions and CLI argument building."""
def get_mod_folder_pattern(self) -> str: ...
def build_mod_args(self, server_mods: list[dict]) -> list[str]: ...
def validate_mod_folder(self, path: Path) -> bool: ...
def get_mod_data_schema(self) -> type[BaseModel] | None:
"""Pydantic model for mods.game_data. None = no validation."""
...
@runtime_checkable
class ProcessConfig(Protocol):
"""Game-specific process and directory conventions."""
def get_allowed_executables(self) -> list[str]: ...
def get_port_conventions(self, game_port: int) -> dict[str, int]: ...
def get_default_game_port(self) -> int: ...
def get_default_rcon_port(self, game_port: int) -> int | None: ...
def get_server_dir_layout(self) -> list[str]: ...
@runtime_checkable
class BanManager(Protocol):
"""Bidirectional sync between DB bans and game ban file."""
def get_ban_file_path(self, server_dir: Path) -> Path: ...
def sync_bans_to_file(self, bans: list[dict], ban_file: Path) -> None: ...
def read_bans_from_file(self, ban_file: Path) -> list[dict]: ...
def get_ban_data_schema(self) -> type[BaseModel] | None:
"""Pydantic model for bans.game_data. None = no validation."""
...
@runtime_checkable
class GameAdapter(Protocol):
"""
Composite adapter. Every game must implement this.
Optional capabilities return None — core degrades gracefully.
Use has_capability(name) instead of None checks throughout.
"""
game_type: str # e.g. "arma3"
display_name: str # e.g. "Arma 3"
version: str # e.g. "1.0.0"
def get_config_generator(self) -> ConfigGenerator: ...
def get_process_config(self) -> ProcessConfig: ...
def get_log_parser(self) -> LogParser: ...
def get_remote_admin(self) -> RemoteAdmin | None: ...
def get_mission_manager(self) -> MissionManager | None: ...
def get_mod_manager(self) -> ModManager | None: ...
def get_ban_manager(self) -> BanManager | None: ...
def has_capability(self, name: str) -> bool:
"""
Explicit capability probe. Use this instead of:
if adapter.get_remote_admin() is not None:
Use this instead:
if adapter.has_capability("remote_admin"):
Valid names: "config_generator", "process_config", "log_parser",
"remote_admin", "mission_manager", "mod_manager", "ban_manager"
"""
...
def get_additional_routers(self) -> list:
"""List of FastAPI APIRouter instances for game-specific routes."""
...
def get_custom_thread_factories(self) -> list[Callable]:
"""List of callables(server_id, db) -> BaseServerThread for extra threads."""
...

View File

@@ -0,0 +1,66 @@
"""
GameAdapterRegistry — singleton that holds all registered game adapters.
Adapters register themselves at import time.
"""
from __future__ import annotations
import logging
logger = logging.getLogger(__name__)
class GameAdapterRegistry:
_adapters: dict[str, object] = {} # game_type -> GameAdapter
@classmethod
def register(cls, adapter) -> None:
"""Register a game adapter. Called at import time by each adapter package."""
if adapter.game_type in cls._adapters:
logger.warning(
"Adapter for '%s' already registered. Overwriting.", adapter.game_type
)
cls._adapters[adapter.game_type] = adapter
logger.info("Registered game adapter: %s (%s)", adapter.game_type, adapter.display_name)
@classmethod
def get(cls, game_type: str):
"""
Get adapter by game_type. Raises KeyError if not registered.
Core code calls this whenever game-specific behavior is needed.
"""
adapter = cls._adapters.get(game_type)
if adapter is None:
raise KeyError(
f"No adapter registered for game type '{game_type}'. "
f"Available: {list(cls._adapters.keys())}"
)
return adapter
@classmethod
def all(cls) -> list:
"""Return all registered adapters."""
return list(cls._adapters.values())
@classmethod
def list_game_types(cls) -> list[dict]:
"""Return metadata list for API /games endpoint."""
result = []
for adapter in cls._adapters.values():
caps = []
for cap in [
"config_generator", "process_config", "log_parser",
"remote_admin", "mission_manager", "mod_manager", "ban_manager",
]:
if adapter.has_capability(cap):
caps.append(cap)
result.append({
"game_type": adapter.game_type,
"display_name": adapter.display_name,
"version": adapter.version,
"capabilities": caps,
})
return result
@classmethod
def is_registered(cls, game_type: str) -> bool:
return game_type in cls._adapters

35
backend/config.py Normal file
View File

@@ -0,0 +1,35 @@
"""Load and validate all environment variables at startup."""
from __future__ import annotations
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="LANGUARD_",
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
# Enable JSON parsing for complex types (list[str]) from env vars
json_parse_ints=False,
)
secret_key: str
encryption_key: str # Fernet base64 key
db_path: str = "./languard.db"
servers_dir: str = "./servers"
host: str = "0.0.0.0"
port: int = 8000
cors_origins: list[str] = ["http://localhost:5173"]
log_retention_days: int = 7
metrics_retention_days: int = 30
player_history_retention_days: int = 90
jwt_expire_hours: int = 24
login_rate_limit: str = "5/minute"
log_level: str = "INFO"
# Game-specific defaults (used by adapters, not core)
arma3_default_exe: str = "C:/Arma3Server/arma3server_x64.exe"
settings = Settings()

0
backend/core/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1,77 @@
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from sqlalchemy.engine import Connection
from core.auth.schemas import (
ChangePasswordRequest, CreateUserRequest, LoginRequest,
)
from core.auth.service import AuthService
from database import get_db
from dependencies import get_current_user, require_admin
router = APIRouter(prefix="/auth", tags=["auth"])
# Rate limiter will be attached after main.py is imported
_limiter = None
def _ok(data):
return {"success": True, "data": data, "error": None}
@router.post("/login")
def login(
request: Request,
body: LoginRequest,
db: Annotated[Connection, Depends(get_db)],
):
return _ok(AuthService(db).login(body.username, body.password))
@router.post("/logout")
def logout(user: Annotated[dict, Depends(get_current_user)]):
# Client-side token deletion. No server-side blacklist.
return _ok({"message": "Logged out"})
@router.get("/me")
def me(user: Annotated[dict, Depends(get_current_user)]):
return _ok({"id": user["id"], "username": user["username"], "role": user["role"]})
@router.put("/password")
def change_password(
body: ChangePasswordRequest,
user: Annotated[dict, Depends(get_current_user)],
db: Annotated[Connection, Depends(get_db)],
):
AuthService(db).change_password(user["id"], body.current_password, body.new_password)
return _ok({"message": "Password changed"})
@router.get("/users")
def list_users(
_admin: Annotated[dict, Depends(require_admin)],
db: Annotated[Connection, Depends(get_db)],
):
return _ok(AuthService(db).list_users())
@router.post("/users", status_code=201)
def create_user(
body: CreateUserRequest,
_admin: Annotated[dict, Depends(require_admin)],
db: Annotated[Connection, Depends(get_db)],
):
user = AuthService(db).create_user(body.username, body.password, body.role)
return _ok(user)
@router.delete("/users/{user_id}", status_code=204)
def delete_user(
user_id: int,
admin: Annotated[dict, Depends(require_admin)],
db: Annotated[Connection, Depends(get_db)],
):
AuthService(db).delete_user(user_id, admin["id"])

View File

@@ -0,0 +1,31 @@
from pydantic import BaseModel
class LoginRequest(BaseModel):
username: str
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
expires_in: int
user: dict
class UserResponse(BaseModel):
id: int
username: str
role: str
created_at: str
class CreateUserRequest(BaseModel):
username: str
password: str
role: str = "viewer"
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str

View File

@@ -0,0 +1,105 @@
from __future__ import annotations
from fastapi import HTTPException, status
from sqlalchemy import text
from sqlalchemy.engine import Connection
from core.auth.utils import create_access_token, hash_password, verify_password
from config import settings
class AuthService:
def __init__(self, db: Connection):
self._db = db
def login(self, username: str, password: str) -> dict:
row = self._db.execute(
text("SELECT * FROM users WHERE username = :u"), {"u": username}
).fetchone()
if row is None or not verify_password(password, row.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "UNAUTHORIZED", "message": "Invalid credentials"},
)
user = dict(row._mapping)
self._db.execute(
text("UPDATE users SET last_login = datetime('now') WHERE id = :id"),
{"id": user["id"]},
)
token = create_access_token(user["id"], user["username"], user["role"])
return {
"access_token": token,
"token_type": "bearer",
"expires_in": settings.jwt_expire_hours * 3600,
"user": {"id": user["id"], "username": user["username"], "role": user["role"]},
}
def create_user(self, username: str, password: str, role: str = "viewer") -> dict:
existing = self._db.execute(
text("SELECT id FROM users WHERE username = :u"), {"u": username}
).fetchone()
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={"code": "CONFLICT", "message": f"Username '{username}' already taken"},
)
self._db.execute(
text(
"INSERT INTO users (username, password_hash, role) VALUES (:u, :ph, :r)"
),
{"u": username, "ph": hash_password(password), "r": role},
)
row = self._db.execute(
text("SELECT id, username, role, created_at FROM users WHERE username = :u"),
{"u": username},
).fetchone()
return dict(row._mapping)
def change_password(self, user_id: int, current_password: str, new_password: str) -> None:
row = self._db.execute(
text("SELECT password_hash FROM users WHERE id = :id"),
{"id": user_id},
).fetchone()
if row is None or not verify_password(current_password, row.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "UNAUTHORIZED", "message": "Current password is incorrect"},
)
self._db.execute(
text("UPDATE users SET password_hash = :ph WHERE id = :id"),
{"ph": hash_password(new_password), "id": user_id},
)
def list_users(self) -> list[dict]:
rows = self._db.execute(
text("SELECT id, username, role, created_at, last_login FROM users ORDER BY id")
).fetchall()
return [dict(r._mapping) for r in rows]
def delete_user(self, user_id: int, requesting_user_id: int) -> None:
if user_id == requesting_user_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"code": "VALIDATION_ERROR", "message": "Cannot delete yourself"},
)
self._db.execute(
text("DELETE FROM users WHERE id = :id"),
{"id": user_id},
)
def seed_admin_if_empty(self) -> str | None:
"""
Create a default admin user if no users exist.
Returns the generated password (printed to stdout on startup).
"""
count = self._db.execute(text("SELECT COUNT(*) FROM users")).fetchone()[0]
if count > 0:
return None
import secrets
password = secrets.token_urlsafe(16)
self.create_user("admin", password, "admin")
return password

View File

@@ -0,0 +1,48 @@
"""JWT creation/validation and password hashing."""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
import bcrypt
from jose import JWTError, jwt
logger = logging.getLogger(__name__)
def hash_password(password: str) -> str:
"""Hash a password using bcrypt. Returns UTF-8 encoded hash string."""
password_bytes = password.encode("utf-8")
salt = bcrypt.gensalt()
hashed = bcrypt.hashpw(password_bytes, salt)
return hashed.decode("utf-8")
def verify_password(plain: str, hashed: str) -> bool:
"""Verify a plain password against a bcrypt hash."""
try:
return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
except Exception as exc:
logger.warning("Password verification failed: %s", exc)
return False
def create_access_token(user_id: int, username: str, role: str) -> str:
from config import settings
expire = datetime.now(timezone.utc) + timedelta(hours=settings.jwt_expire_hours)
payload = {
"sub": str(user_id),
"username": username,
"role": role,
"exp": expire,
}
return jwt.encode(payload, settings.secret_key, algorithm="HS256")
def decode_access_token(token: str) -> dict:
"""
Decode and validate JWT. Returns payload dict.
Raises JWTError on invalid/expired token.
"""
from config import settings
return jwt.decode(token, settings.secret_key, algorithms=["HS256"])

View File

View File

@@ -0,0 +1 @@
"""Data Access Layer repositories."""

View File

@@ -0,0 +1,52 @@
import json
from datetime import datetime, timezone
from core.dal.base_repository import BaseRepository
class BanRepository(BaseRepository):
def get_all(self, server_id: int, active_only: bool = True) -> list[dict]:
if active_only:
return self._fetchall(
"SELECT * FROM bans WHERE server_id = :sid AND is_active = 1 ORDER BY banned_at DESC",
{"sid": server_id},
)
return self._fetchall(
"SELECT * FROM bans WHERE server_id = :sid ORDER BY banned_at DESC",
{"sid": server_id},
)
def create(
self,
server_id: int,
guid: str | None,
name: str | None,
reason: str | None,
banned_by: str,
expires_at: str | None = None,
game_data: dict | None = None,
) -> int:
return self._lastrowid(
"""
INSERT INTO bans (server_id, guid, name, reason, banned_by, expires_at, game_data)
VALUES (:sid, :guid, :name, :reason, :by, :exp, :gd)
""",
{
"sid": server_id,
"guid": guid,
"name": name,
"reason": reason,
"by": banned_by,
"exp": expires_at,
"gd": json.dumps(game_data or {}),
},
)
def deactivate(self, ban_id: int) -> None:
self._execute(
"UPDATE bans SET is_active = 0 WHERE id = :id",
{"id": ban_id},
)
def get_by_id(self, ban_id: int) -> dict | None:
return self._fetchone("SELECT * FROM bans WHERE id = :id", {"id": ban_id})

View File

@@ -0,0 +1,27 @@
"""Base repository with common DB helpers."""
from __future__ import annotations
from sqlalchemy import text
from sqlalchemy.engine import Connection
class BaseRepository:
def __init__(self, db: Connection):
self._db = db
def _execute(self, query: str, params: dict | None = None):
return self._db.execute(text(query), params or {})
def _fetchone(self, query: str, params: dict | None = None) -> dict | None:
row = self._db.execute(text(query), params or {}).fetchone()
if row is None:
return None
return dict(row._mapping)
def _fetchall(self, query: str, params: dict | None = None) -> list[dict]:
rows = self._db.execute(text(query), params or {}).fetchall()
return [dict(r._mapping) for r in rows]
def _lastrowid(self, query: str, params: dict | None = None) -> int:
result = self._db.execute(text(query), params or {})
return result.lastrowid

View File

@@ -0,0 +1,163 @@
"""
Manages the game_configs table.
Handles Fernet encryption/decryption of sensitive fields transparently.
"""
from __future__ import annotations
import json
from datetime import datetime, timezone
from core.dal.base_repository import BaseRepository
from core.utils.crypto import decrypt, encrypt, is_encrypted
class ConfigRepository(BaseRepository):
def _encrypt_sensitive(
self, config: dict, sensitive_fields: list[str]
) -> dict:
"""Return new dict with sensitive fields encrypted."""
result = dict(config)
for field in sensitive_fields:
if field in result and result[field] and not is_encrypted(str(result[field])):
result[field] = encrypt(str(result[field]))
return result
def _decrypt_sensitive(
self, config: dict, sensitive_fields: list[str]
) -> dict:
"""Return new dict with sensitive fields decrypted."""
result = dict(config)
for field in sensitive_fields:
if field in result and is_encrypted(str(result[field])):
result[field] = decrypt(str(result[field]))
return result
def get_section(
self,
server_id: int,
section: str,
sensitive_fields: list[str] | None = None,
) -> dict | None:
"""Get a config section. Decrypts sensitive fields automatically."""
row = self._fetchone(
"SELECT * FROM game_configs WHERE server_id = :sid AND section = :sec",
{"sid": server_id, "sec": section},
)
if row is None:
return None
config = json.loads(row["config_json"])
if sensitive_fields:
config = self._decrypt_sensitive(config, sensitive_fields)
config["_meta"] = {
"config_version": row["config_version"],
"schema_version": row["schema_version"],
}
return config
def get_all_sections(
self,
server_id: int,
sensitive_fields_by_section: dict[str, list[str]] | None = None,
) -> dict[str, dict]:
"""Get all config sections for a server."""
rows = self._fetchall(
"SELECT * FROM game_configs WHERE server_id = :sid ORDER BY section",
{"sid": server_id},
)
result = {}
for row in rows:
config = json.loads(row["config_json"])
sf = (sensitive_fields_by_section or {}).get(row["section"], [])
if sf:
config = self._decrypt_sensitive(config, sf)
config["_meta"] = {
"config_version": row["config_version"],
"schema_version": row["schema_version"],
}
result[row["section"]] = config
return result
def upsert_section(
self,
server_id: int,
game_type: str,
section: str,
config_data: dict,
schema_version: str,
sensitive_fields: list[str] | None = None,
expected_config_version: int | None = None,
) -> int:
"""
Upsert a config section.
If expected_config_version is provided, checks optimistic lock.
Returns the new config_version.
Raises ValueError on version conflict (caller returns 409).
"""
now = datetime.now(timezone.utc).isoformat()
# Strip _meta before storing
data_to_store = {k: v for k, v in config_data.items() if k != "_meta"}
# Encrypt sensitive fields
if sensitive_fields:
data_to_store = self._encrypt_sensitive(data_to_store, sensitive_fields)
# Check if row exists
existing = self._fetchone(
"SELECT id, config_version FROM game_configs WHERE server_id = :sid AND section = :sec",
{"sid": server_id, "sec": section},
)
if existing is None:
# Insert
self._execute(
"""
INSERT INTO game_configs
(server_id, game_type, section, config_json, config_version, schema_version, updated_at)
VALUES (:sid, :gt, :sec, :json, 1, :sv, :now)
""",
{
"sid": server_id, "gt": game_type, "sec": section,
"json": json.dumps(data_to_store), "sv": schema_version, "now": now,
},
)
return 1
else:
current_version = existing["config_version"]
if expected_config_version is not None and expected_config_version != current_version:
raise ValueError(
f"CONFIG_VERSION_CONFLICT:{current_version}"
)
new_version = current_version + 1
self._execute(
"""
UPDATE game_configs
SET config_json = :json, config_version = :cv,
schema_version = :sv, updated_at = :now
WHERE server_id = :sid AND section = :sec
""",
{
"json": json.dumps(data_to_store),
"cv": new_version,
"sv": schema_version,
"now": now,
"sid": server_id,
"sec": section,
},
)
return new_version
def delete_sections(self, server_id: int) -> None:
self._execute(
"DELETE FROM game_configs WHERE server_id = :sid",
{"sid": server_id},
)
def get_raw_sections(self, server_id: int) -> dict[str, dict]:
"""Get all sections without decryption — for config file generation."""
rows = self._fetchall(
"SELECT section, config_json FROM game_configs WHERE server_id = :sid",
{"sid": server_id},
)
return {row["section"]: json.loads(row["config_json"]) for row in rows}

View File

@@ -0,0 +1,62 @@
import json
from core.dal.base_repository import BaseRepository
class EventRepository(BaseRepository):
def insert(
self,
server_id: int,
event_type: str,
actor: str = "system",
detail: dict | None = None,
) -> None:
self._execute(
"""
INSERT INTO server_events (server_id, event_type, actor, detail)
VALUES (:sid, :et, :actor, :detail)
""",
{
"sid": server_id,
"et": event_type,
"actor": actor,
"detail": json.dumps(detail) if detail else None,
},
)
def get_events(
self,
server_id: int,
limit: int = 50,
offset: int = 0,
event_type: str | None = None,
) -> list[dict]:
if event_type:
return self._fetchall(
"""
SELECT * FROM server_events
WHERE server_id = :sid AND event_type = :et
ORDER BY created_at DESC LIMIT :limit OFFSET :offset
""",
{"sid": server_id, "et": event_type, "limit": limit, "offset": offset},
)
return self._fetchall(
"""
SELECT * FROM server_events WHERE server_id = :sid
ORDER BY created_at DESC LIMIT :limit OFFSET :offset
""",
{"sid": server_id, "limit": limit, "offset": offset},
)
def get_recent_all_servers(self, limit: int = 20) -> list[dict]:
return self._fetchall(
"SELECT * FROM server_events ORDER BY created_at DESC LIMIT :limit",
{"limit": limit},
)
def cleanup_old(self, retention_days: int) -> None:
"""Delete events older than retention_days."""
self._execute(
"DELETE FROM server_events WHERE created_at < datetime('now', :delta)",
{"delta": f"-{retention_days} days"},
)

View File

@@ -0,0 +1,61 @@
from core.dal.base_repository import BaseRepository
class LogRepository(BaseRepository):
def insert(self, server_id: int, entry: dict) -> None:
"""entry = {timestamp, level, message}"""
self._execute(
"""
INSERT INTO logs (server_id, timestamp, level, message)
VALUES (:sid, :ts, :level, :msg)
""",
{
"sid": server_id,
"ts": entry.get("timestamp", ""),
"level": entry.get("level", "info"),
"msg": entry.get("message", ""),
},
)
def query(
self,
server_id: int,
limit: int = 200,
offset: int = 0,
level: str | None = None,
since: str | None = None,
search: str | None = None,
) -> tuple[int, list[dict]]:
conditions = ["server_id = :sid"]
params: dict = {"sid": server_id, "limit": limit, "offset": offset}
if level:
conditions.append("level = :level")
params["level"] = level
if since:
conditions.append("timestamp >= :since")
params["since"] = since
if search:
conditions.append("message LIKE :search")
params["search"] = f"%{search}%"
where = " AND ".join(conditions)
total_row = self._fetchone(f"SELECT COUNT(*) as cnt FROM logs WHERE {where}", params)
total = total_row["cnt"] if total_row else 0
rows = self._fetchall(
f"SELECT * FROM logs WHERE {where} ORDER BY timestamp DESC LIMIT :limit OFFSET :offset",
params,
)
return total, rows
def clear(self, server_id: int) -> int:
result = self._execute(
"DELETE FROM logs WHERE server_id = :sid", {"sid": server_id}
)
return result.rowcount
def cleanup_old(self, retention_days: int) -> None:
self._execute(
"DELETE FROM logs WHERE created_at < datetime('now', :delta)",
{"delta": f"-{retention_days} days"},
)

View File

@@ -0,0 +1,53 @@
from core.dal.base_repository import BaseRepository
class MetricsRepository(BaseRepository):
def insert(
self, server_id: int, cpu_percent: float, ram_mb: float = 0.0, player_count: int = 0
) -> None:
self._execute(
"""
INSERT INTO metrics (server_id, cpu_percent, ram_mb, player_count)
VALUES (:sid, :cpu, :ram, :pc)
""",
{"sid": server_id, "cpu": cpu_percent, "ram": ram_mb, "pc": player_count},
)
def query(
self,
server_id: int,
from_ts: str | None = None,
to_ts: str | None = None,
) -> list[dict]:
conditions = ["server_id = :sid"]
params: dict = {"sid": server_id}
if from_ts:
conditions.append("timestamp >= :from_ts")
params["from_ts"] = from_ts
if to_ts:
conditions.append("timestamp <= :to_ts")
params["to_ts"] = to_ts
where = " AND ".join(conditions)
return self._fetchall(
f"SELECT * FROM metrics WHERE {where} ORDER BY timestamp ASC",
params,
)
def get_latest(self, server_id: int) -> dict | None:
return self._fetchone(
"SELECT * FROM metrics WHERE server_id = :sid ORDER BY timestamp DESC LIMIT 1",
{"sid": server_id},
)
def cleanup_old(self, retention_days: int = 1, server_id: int | None = None) -> None:
if server_id is not None:
self._execute(
"DELETE FROM metrics WHERE server_id = :sid AND timestamp < datetime('now', :delta)",
{"sid": server_id, "delta": f"-{retention_days} days"},
)
else:
self._execute(
"DELETE FROM metrics WHERE timestamp < datetime('now', :delta)",
{"delta": f"-{retention_days} days"},
)

View File

@@ -0,0 +1,76 @@
import json
from datetime import datetime, timezone
from core.dal.base_repository import BaseRepository
class PlayerRepository(BaseRepository):
def get_all(self, server_id: int) -> list[dict]:
return self._fetchall(
"SELECT * FROM players WHERE server_id = :sid ORDER BY slot_id",
{"sid": server_id},
)
def count(self, server_id: int) -> int:
row = self._fetchone(
"SELECT COUNT(*) as cnt FROM players WHERE server_id = :sid",
{"sid": server_id},
)
return row["cnt"] if row else 0
def upsert(self, server_id: int, player: dict) -> None:
now = datetime.now(timezone.utc).isoformat()
self._execute(
"""
INSERT INTO players (server_id, slot_id, name, guid, ip, ping, game_data, joined_at, updated_at)
VALUES (:sid, :slot, :name, :guid, :ip, :ping, :gd, :now, :now)
ON CONFLICT(server_id, slot_id) DO UPDATE SET
name = excluded.name,
guid = excluded.guid,
ping = excluded.ping,
game_data = excluded.game_data,
updated_at = excluded.updated_at
""",
{
"sid": server_id,
"slot": str(player.get("slot_id", "")),
"name": player.get("name", ""),
"guid": player.get("guid"),
"ip": player.get("ip"),
"ping": player.get("ping"),
"gd": json.dumps(player.get("game_data", {})),
"now": now,
},
)
def get_by_slot(self, server_id: int, slot_id: int) -> dict | None:
return self._fetchone(
"SELECT * FROM players WHERE server_id = :sid AND slot_id = :slot",
{"sid": server_id, "slot": str(slot_id)},
)
def clear(self, server_id: int) -> None:
self._execute("DELETE FROM players WHERE server_id = :sid", {"sid": server_id})
def get_history(
self,
server_id: int,
limit: int = 50,
offset: int = 0,
search: str | None = None,
) -> tuple[int, list[dict]]:
conditions = ["server_id = :sid"]
params: dict = {"sid": server_id, "limit": limit, "offset": offset}
if search:
conditions.append("name LIKE :search")
params["search"] = f"%{search}%"
where = " AND ".join(conditions)
total_row = self._fetchone(
f"SELECT COUNT(*) as cnt FROM player_history WHERE {where}", params
)
total = total_row["cnt"] if total_row else 0
rows = self._fetchall(
f"SELECT * FROM player_history WHERE {where} ORDER BY left_at DESC LIMIT :limit OFFSET :offset",
params,
)
return total, rows

View File

@@ -0,0 +1,111 @@
from __future__ import annotations
from datetime import datetime, timezone
from core.dal.base_repository import BaseRepository
class ServerRepository(BaseRepository):
def get_all(self, game_type: str | None = None) -> list[dict]:
if game_type:
return self._fetchall(
"SELECT * FROM servers WHERE game_type = :gt ORDER BY name",
{"gt": game_type},
)
return self._fetchall("SELECT * FROM servers ORDER BY name")
def get_by_id(self, server_id: int) -> dict | None:
return self._fetchone("SELECT * FROM servers WHERE id = :id", {"id": server_id})
def create(
self,
name: str,
game_type: str,
exe_path: str,
game_port: int,
rcon_port: int | None = None,
description: str | None = None,
auto_restart: bool = False,
max_restarts: int = 3,
) -> int:
return self._lastrowid(
"""
INSERT INTO servers
(name, description, game_type, exe_path, game_port, rcon_port,
auto_restart, max_restarts)
VALUES
(:name, :desc, :game_type, :exe, :gp, :rp, :ar, :mr)
""",
{
"name": name,
"desc": description,
"game_type": game_type,
"exe": exe_path,
"gp": game_port,
"rp": rcon_port,
"ar": int(auto_restart),
"mr": max_restarts,
},
)
def update(self, server_id: int, **fields) -> None:
if not fields:
return
fields["updated_at"] = datetime.now(timezone.utc).isoformat()
fields["id"] = server_id
set_clause = ", ".join(f"{k} = :{k}" for k in fields if k != "id")
self._execute(f"UPDATE servers SET {set_clause} WHERE id = :id", fields)
def update_status(
self,
server_id: int,
status: str,
pid: int | None = None,
started_at: str | None = None,
stopped_at: str | None = None,
) -> None:
now = datetime.now(timezone.utc).isoformat()
self._execute(
"""
UPDATE servers
SET status = :status, pid = :pid, started_at = :sa,
stopped_at = :sta, updated_at = :now
WHERE id = :id
""",
{
"status": status,
"pid": pid,
"sa": started_at,
"sta": stopped_at,
"now": now,
"id": server_id,
},
)
def delete(self, server_id: int) -> None:
self._execute("DELETE FROM servers WHERE id = :id", {"id": server_id})
def get_running(self) -> list[dict]:
return self._fetchall(
"SELECT * FROM servers WHERE status IN ('running', 'starting')"
)
def increment_restart_count(self, server_id: int) -> None:
now = datetime.now(timezone.utc).isoformat()
self._execute(
"""
UPDATE servers
SET restart_count = restart_count + 1,
last_restart_at = :now,
updated_at = :now
WHERE id = :id
""",
{"now": now, "id": server_id},
)
def reset_restart_count(self, server_id: int) -> None:
self._execute(
"UPDATE servers SET restart_count = 0 WHERE id = :id",
{"id": server_id},
)

View File

View File

View File

@@ -0,0 +1,70 @@
from fastapi import APIRouter, HTTPException, status
from adapters.registry import GameAdapterRegistry
router = APIRouter(prefix="/games", tags=["games"])
def _ok(data):
return {"success": True, "data": data, "error": None}
@router.get("")
def list_games():
return _ok(GameAdapterRegistry.list_game_types())
@router.get("/{game_type}")
def get_game(game_type: str):
try:
adapter = GameAdapterRegistry.get(game_type)
except KeyError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"code": "GAME_TYPE_NOT_FOUND", "message": f"Unknown game type: {game_type}"},
)
caps = []
for cap in ["config_generator", "process_config", "log_parser",
"remote_admin", "mission_manager", "mod_manager", "ban_manager"]:
if adapter.has_capability(cap):
caps.append(cap)
config_gen = adapter.get_config_generator()
sections = list(config_gen.get_sections().keys())
process_config = adapter.get_process_config()
return _ok({
"game_type": adapter.game_type,
"display_name": adapter.display_name,
"version": adapter.version,
"schema_version": config_gen.get_config_version(),
"capabilities": caps,
"config_sections": sections,
"allowed_executables": process_config.get_allowed_executables(),
})
@router.get("/{game_type}/config-schema")
def get_config_schema(game_type: str):
try:
adapter = GameAdapterRegistry.get(game_type)
except KeyError:
raise HTTPException(status_code=404, detail={"code": "GAME_TYPE_NOT_FOUND"})
config_gen = adapter.get_config_generator()
schemas = {}
for section, model_cls in config_gen.get_sections().items():
schemas[section] = model_cls.model_json_schema()
return _ok(schemas)
@router.get("/{game_type}/defaults")
def get_defaults(game_type: str):
try:
adapter = GameAdapterRegistry.get(game_type)
except KeyError:
raise HTTPException(status_code=404, detail={"code": "GAME_TYPE_NOT_FOUND"})
config_gen = adapter.get_config_generator()
defaults = {}
for section in config_gen.get_sections():
defaults[section] = config_gen.get_defaults(section)
return _ok(defaults)

View File

View File

@@ -0,0 +1,102 @@
"""
Cleanup jobs registered with APScheduler.
Jobs:
- cleanup_old_logs: Delete log entries older than 7 days, daily at 03:00
- cleanup_old_metrics: Delete metrics older than 1 day, every 6 hours
- cleanup_old_events: Delete events older than 30 days, weekly on Sunday
"""
from __future__ import annotations
import logging
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.interval import IntervalTrigger
from core.jobs.scheduler import get_scheduler
from database import get_thread_db
from core.dal.log_repository import LogRepository
from core.dal.metrics_repository import MetricsRepository
from core.dal.event_repository import EventRepository
logger = logging.getLogger(__name__)
_LOG_RETENTION_DAYS = 7
_METRICS_RETENTION_DAYS = 1
_EVENT_RETENTION_DAYS = 30
def register_cleanup_jobs() -> None:
"""Register all cleanup jobs with the scheduler. Call at startup."""
sched = get_scheduler()
sched.add_job(
func=_cleanup_old_logs,
trigger=CronTrigger(hour=3, minute=0),
id="cleanup_old_logs",
name="Clean up old log entries",
replace_existing=True,
)
sched.add_job(
func=_cleanup_old_metrics,
trigger=IntervalTrigger(hours=6),
id="cleanup_old_metrics",
name="Clean up old metrics",
replace_existing=True,
)
sched.add_job(
func=_cleanup_old_events,
trigger=CronTrigger(day_of_week="sun", hour=4, minute=0),
id="cleanup_old_events",
name="Clean up old events",
replace_existing=True,
)
logger.info("Cleanup jobs registered")
def _cleanup_old_logs() -> None:
logger.info("Running log cleanup (retention=%d days)", _LOG_RETENTION_DAYS)
try:
db = get_thread_db()
try:
log_repo = LogRepository(db)
log_repo.cleanup_old(retention_days=_LOG_RETENTION_DAYS)
db.commit()
finally:
db.close()
logger.info("Log cleanup complete")
except Exception as exc:
logger.error("Log cleanup failed: %s", exc, exc_info=True)
def _cleanup_old_metrics() -> None:
logger.info("Running metrics cleanup (retention=%d days)", _METRICS_RETENTION_DAYS)
try:
db = get_thread_db()
try:
metrics_repo = MetricsRepository(db)
metrics_repo.cleanup_old(retention_days=_METRICS_RETENTION_DAYS)
db.commit()
finally:
db.close()
logger.info("Metrics cleanup complete")
except Exception as exc:
logger.error("Metrics cleanup failed: %s", exc, exc_info=True)
def _cleanup_old_events() -> None:
logger.info("Running event cleanup (retention=%d days)", _EVENT_RETENTION_DAYS)
try:
db = get_thread_db()
try:
event_repo = EventRepository(db)
event_repo.cleanup_old(retention_days=_EVENT_RETENTION_DAYS)
db.commit()
finally:
db.close()
logger.info("Event cleanup complete")
except Exception as exc:
logger.error("Event cleanup failed: %s", exc, exc_info=True)

View File

@@ -0,0 +1,40 @@
"""
APScheduler setup for background cleanup jobs.
One scheduler instance runs per process.
Jobs run in their own threads (ThreadPoolExecutor).
"""
from __future__ import annotations
import logging
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.executors.pool import ThreadPoolExecutor
logger = logging.getLogger(__name__)
_scheduler: BackgroundScheduler | None = None
def get_scheduler() -> BackgroundScheduler:
global _scheduler
if _scheduler is None:
_scheduler = BackgroundScheduler(
executors={"default": ThreadPoolExecutor(max_workers=2)},
job_defaults={"coalesce": True, "max_instances": 1},
)
return _scheduler
def start_scheduler() -> None:
sched = get_scheduler()
if not sched.running:
sched.start()
logger.info("APScheduler started")
def stop_scheduler() -> None:
global _scheduler
if _scheduler is not None and _scheduler.running:
_scheduler.shutdown(wait=False)
logger.info("APScheduler stopped")

View File

View File

View File

@@ -0,0 +1,187 @@
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'viewer',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_login TEXT,
CHECK (role IN ('admin', 'viewer'))
);
CREATE TABLE IF NOT EXISTS servers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
game_type TEXT NOT NULL DEFAULT 'arma3',
status TEXT NOT NULL DEFAULT 'stopped',
pid INTEGER,
exe_path TEXT NOT NULL,
started_at TEXT,
stopped_at TEXT,
game_port INTEGER NOT NULL,
rcon_port INTEGER,
auto_restart INTEGER NOT NULL DEFAULT 0,
max_restarts INTEGER NOT NULL DEFAULT 3,
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')),
CHECK (status IN ('stopped','starting','running','stopping','crashed','error')),
CHECK (game_port BETWEEN 1024 AND 65535),
CHECK (rcon_port IS NULL OR (rcon_port BETWEEN 1024 AND 65535))
);
CREATE INDEX IF NOT EXISTS idx_servers_status ON servers(status);
CREATE INDEX IF NOT EXISTS idx_servers_game_type ON servers(game_type);
CREATE INDEX IF NOT EXISTS idx_servers_game_port ON servers(game_port);
CREATE TABLE IF NOT EXISTS game_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
game_type TEXT NOT NULL,
section TEXT NOT NULL,
config_json TEXT NOT NULL DEFAULT '{}',
config_version INTEGER NOT NULL DEFAULT 1,
schema_version TEXT NOT NULL DEFAULT '1.0.0',
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(server_id, section)
);
CREATE INDEX IF NOT EXISTS idx_game_configs_server ON game_configs(server_id);
CREATE INDEX IF NOT EXISTS idx_game_configs_type_section ON game_configs(game_type, section);
CREATE TABLE IF NOT EXISTS mods (
id INTEGER PRIMARY KEY AUTOINCREMENT,
game_type TEXT NOT NULL,
name TEXT NOT NULL,
folder_path TEXT NOT NULL,
workshop_id TEXT,
description TEXT,
game_data TEXT DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (game_type, folder_path)
);
CREATE TABLE IF NOT EXISTS 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,
sort_order INTEGER NOT NULL DEFAULT 0,
game_data TEXT DEFAULT '{}',
PRIMARY KEY (server_id, mod_id)
);
CREATE INDEX IF NOT EXISTS idx_server_mods_server ON server_mods(server_id);
CREATE TABLE IF NOT EXISTS missions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
mission_name TEXT NOT NULL,
terrain TEXT,
file_size INTEGER,
game_data TEXT DEFAULT '{}',
uploaded_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (server_id, filename)
);
CREATE INDEX IF NOT EXISTS idx_missions_server ON missions(server_id);
CREATE TABLE IF NOT EXISTS 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,
params_json TEXT NOT NULL DEFAULT '{}',
game_data TEXT DEFAULT '{}',
UNIQUE (server_id, sort_order)
);
CREATE INDEX IF NOT EXISTS idx_mission_rotation_server ON mission_rotation(server_id);
CREATE TABLE IF NOT EXISTS players (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
slot_id TEXT NOT NULL,
name TEXT NOT NULL,
guid TEXT,
ip TEXT,
ping INTEGER,
game_data TEXT DEFAULT '{}',
joined_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (server_id, slot_id)
);
CREATE INDEX IF NOT EXISTS idx_players_server ON players(server_id);
CREATE TABLE IF NOT EXISTS player_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
name TEXT NOT NULL,
guid TEXT,
ip TEXT,
game_data TEXT DEFAULT '{}',
joined_at TEXT NOT NULL,
left_at TEXT NOT NULL DEFAULT (datetime('now')),
session_duration_seconds INTEGER
);
CREATE INDEX IF NOT EXISTS idx_player_history_server ON player_history(server_id);
CREATE INDEX IF NOT EXISTS idx_player_history_guid ON player_history(guid);
CREATE TABLE IF NOT EXISTS bans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
guid TEXT,
name TEXT,
reason TEXT,
banned_by TEXT,
banned_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
game_data TEXT DEFAULT '{}',
CHECK (is_active IN (0, 1))
);
CREATE INDEX IF NOT EXISTS idx_bans_server ON bans(server_id);
CREATE INDEX IF NOT EXISTS idx_bans_guid ON bans(guid);
CREATE INDEX IF NOT EXISTS idx_bans_active ON bans(is_active);
CREATE TABLE IF NOT EXISTS 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',
message TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
CHECK (level IN ('info', 'warning', 'error'))
);
CREATE INDEX IF NOT EXISTS idx_logs_server_ts ON logs(server_id, timestamp);
CREATE INDEX IF NOT EXISTS idx_logs_level ON logs(level);
CREATE INDEX IF NOT EXISTS idx_logs_created ON logs(created_at);
CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS idx_metrics_server_ts ON metrics(server_id, timestamp);
CREATE TABLE IF NOT EXISTS server_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
event_type TEXT NOT NULL,
actor TEXT,
detail TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_events_server ON server_events(server_id, created_at)

View File

View File

View File

View File

@@ -0,0 +1,142 @@
"""Ban management endpoints — create, list, and revoke bans."""
from __future__ import annotations
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, field_validator
from sqlalchemy.engine import Connection
from adapters.arma3.ban_manager import Arma3BanManager
from core.dal.ban_repository import BanRepository
from core.servers.service import ServerService
from database import get_db
from dependencies import get_current_user, require_admin
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/servers/{server_id}/bans", tags=["bans"])
def _ok(data):
return {"success": True, "data": data, "error": None}
class CreateBanRequest(BaseModel):
player_uid: str
ban_type: str = "GUID"
reason: str = ""
duration_minutes: int = 0 # 0 = permanent
@field_validator("ban_type")
@classmethod
def validate_ban_type(cls, v: str) -> str:
if v not in ("GUID", "IP"):
raise ValueError("ban_type must be 'GUID' or 'IP'")
return v
@field_validator("duration_minutes")
@classmethod
def validate_duration(cls, v: int) -> int:
if v < 0:
raise ValueError("duration_minutes cannot be negative")
return v
@router.get("")
def list_bans(
server_id: int,
db: Annotated[Connection, Depends(get_db)],
_user: Annotated[dict, Depends(get_current_user)],
) -> dict:
"""List all active bans for the server."""
ServerService(db).get_server(server_id) # raises 404 if not found
ban_repo = BanRepository(db)
bans = ban_repo.get_all(server_id=server_id)
return _ok(bans)
@router.post("", status_code=status.HTTP_201_CREATED)
def create_ban(
server_id: int,
body: CreateBanRequest,
db: Annotated[Connection, Depends(get_db)],
_admin: Annotated[dict, Depends(require_admin)],
) -> dict:
"""Create a new ban. Writes to DB and syncs to bans.txt."""
ServerService(db).get_server(server_id) # raises 404 if not found
ban_repo = BanRepository(db)
# Calculate expires_at if duration is set
expires_at = None
if body.duration_minutes > 0:
from datetime import datetime, timezone, timedelta
expires_at = (
datetime.now(timezone.utc) + timedelta(minutes=body.duration_minutes)
).isoformat()
ban_id = ban_repo.create(
server_id=server_id,
guid=body.player_uid if body.ban_type == "GUID" else None,
name=None,
reason=body.reason,
banned_by=_admin["username"],
expires_at=expires_at,
game_data={"ban_type": body.ban_type, "duration_minutes": body.duration_minutes},
)
db.commit()
ban = ban_repo.get_by_id(ban_id)
# Sync to bans.txt (non-blocking — log error but don't fail request)
_sync_ban_to_file(server_id, body.player_uid, body.ban_type, body.reason, body.duration_minutes)
return _ok(ban)
@router.delete("/{ban_id}")
def revoke_ban(
server_id: int,
ban_id: int,
db: Annotated[Connection, Depends(get_db)],
_admin: Annotated[dict, Depends(require_admin)],
) -> dict:
"""Revoke a ban (marks as inactive in DB, removes from bans.txt)."""
ServerService(db).get_server(server_id) # raises 404 if not found
ban_repo = BanRepository(db)
ban = ban_repo.get_by_id(ban_id)
if ban is None or ban["server_id"] != server_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"code": "NOT_FOUND", "message": "Ban not found"},
)
ban_repo.deactivate(ban_id)
db.commit()
# Remove from bans.txt
_remove_ban_from_file(server_id, ban.get("guid") or "")
return _ok({"message": f"Ban {ban_id} revoked"})
# ── File sync helpers ──
def _sync_ban_to_file(
server_id: int, identifier: str, ban_type: str, reason: str, duration_minutes: int
) -> None:
"""Write ban to bans.txt. Log error but don't fail the request."""
try:
mgr = Arma3BanManager(server_id)
mgr.add_ban(identifier, ban_type, reason, duration_minutes)
except Exception as exc:
logger.error("Failed to sync ban to bans.txt for server %d: %s", server_id, exc)
def _remove_ban_from_file(server_id: int, identifier: str) -> None:
"""Remove ban from bans.txt. Log error but don't fail the request."""
try:
mgr = Arma3BanManager(server_id)
mgr.remove_ban(identifier)
except Exception as exc:
logger.error("Failed to remove ban from bans.txt for server %d: %s", server_id, exc)

View File

@@ -0,0 +1,82 @@
"""Log file endpoints — list, download, and delete historical RPT log files."""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from sqlalchemy.engine import Connection
from adapters.registry import GameAdapterRegistry
from core.dal.server_repository import ServerRepository
from database import get_db
from dependencies import get_current_user, require_admin
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/servers/{server_id}/logfiles", tags=["logfiles"])
def _ok(data):
return {"success": True, "data": data, "error": None}
def _get_rpt_parser(server_id: int, db: Connection):
server = ServerRepository(db).get_by_id(server_id)
if server is None:
raise HTTPException(status_code=404, detail="Server not found")
adapter = GameAdapterRegistry.get(server["game_type"])
if not adapter.has_capability("log_parser"):
raise HTTPException(status_code=404, detail="Server does not support log files")
# RPT files live next to the server exe (e.g. A3Master/server/*.rpt)
exe_dir = Path(server["exe_path"]).parent
return adapter.get_log_parser(), exe_dir
@router.get("")
def list_log_files(
server_id: int,
db: Annotated[Connection, Depends(get_db)],
_user: Annotated[dict, Depends(get_current_user)],
) -> dict:
parser, server_dir = _get_rpt_parser(server_id, db)
files = parser.list_log_files(server_dir)
return _ok(files)
@router.get("/{filename}/download")
def download_log_file(
server_id: int,
filename: str,
db: Annotated[Connection, Depends(get_db)],
_user: Annotated[dict, Depends(get_current_user)],
):
parser, server_dir = _get_rpt_parser(server_id, db)
path = parser.get_log_file_path(server_dir, filename)
if path is None:
raise HTTPException(status_code=404, detail="Log file not found")
return FileResponse(
path=str(path),
filename=filename,
media_type="text/plain",
)
@router.delete("/{filename}")
def delete_log_file(
server_id: int,
filename: str,
db: Annotated[Connection, Depends(get_db)],
_admin: Annotated[dict, Depends(require_admin)],
) -> dict:
parser, server_dir = _get_rpt_parser(server_id, db)
path = parser.get_log_file_path(server_dir, filename)
if path is None:
raise HTTPException(status_code=404, detail="Log file not found")
try:
path.unlink()
except OSError as exc:
raise HTTPException(status_code=500, detail=f"Could not delete file: {exc}") from exc
return _ok({"message": f"{filename} deleted"})

View File

@@ -0,0 +1,156 @@
"""Mission management endpoints — list, upload, delete mission files."""
from __future__ import annotations
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status
from pydantic import BaseModel, Field
from sqlalchemy.engine import Connection
from adapters.exceptions import AdapterError
from adapters.registry import GameAdapterRegistry
from core.servers.service import ServerService
from database import get_db
from dependencies import get_current_user, require_admin
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/servers/{server_id}/missions", tags=["missions"])
_MAX_UPLOAD_SIZE = 500 * 1024 * 1024 # 500 MB
class MissionRotationEntry(BaseModel):
name: str
difficulty: str = ""
params: dict[str, int | float | str | bool] = Field(default_factory=dict)
class MissionRotationUpdate(BaseModel):
missions: list[MissionRotationEntry]
config_version: int
def _ok(data):
return {"success": True, "data": data, "error": None}
def _get_mission_manager(server_id: int, game_type: str):
"""Get MissionManager for the server's game type."""
adapter = GameAdapterRegistry.get(game_type)
if not adapter.has_capability("mission_manager"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"code": "NOT_SUPPORTED", "message": f"Game type '{game_type}' does not support mission management"},
)
return adapter.get_mission_manager(server_id)
@router.get("/rotation")
def get_mission_rotation(
server_id: int,
db: Annotated[Connection, Depends(get_db)],
_user: Annotated[dict, Depends(get_current_user)],
) -> dict:
"""Get the current mission rotation from the server config."""
config = ServerService(db).get_config_section(server_id, "server")
missions = config.get("missions", [])
return _ok({"missions": missions})
@router.put("/rotation")
def update_mission_rotation(
server_id: int,
body: MissionRotationUpdate,
db: Annotated[Connection, Depends(get_db)],
_admin: Annotated[dict, Depends(require_admin)],
) -> dict:
"""Replace the mission rotation in the server config."""
updated = ServerService(db).update_config_section(
server_id=server_id,
section="server",
data={"missions": [e.model_dump() for e in body.missions]},
expected_version=body.config_version,
)
return _ok({"missions": updated.get("missions", [])})
@router.get("")
def list_missions(
server_id: int,
db: Annotated[Connection, Depends(get_db)],
_user: Annotated[dict, Depends(get_current_user)],
) -> dict:
"""List all available mission files on disk."""
server = ServerService(db).get_server(server_id) # raises 404 if not found
mgr = _get_mission_manager(server_id, server["game_type"])
try:
missions = mgr.list_missions()
except AdapterError as exc:
raise HTTPException(status_code=500, detail={"code": "ADAPTER_ERROR", "message": str(exc)})
return _ok({
"server_id": server_id,
"missions": missions,
"total": len(missions),
})
@router.post("", status_code=status.HTTP_201_CREATED)
async def upload_mission(
server_id: int,
db: Annotated[Connection, Depends(get_db)],
_admin: Annotated[dict, Depends(require_admin)],
file: UploadFile = File(...),
) -> dict:
"""
Upload a mission .pbo file.
Max size: 500 MB.
"""
server = ServerService(db).get_server(server_id) # raises 404 if not found
if not file.filename:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"code": "NO_FILENAME", "message": "No filename provided"},
)
content = await file.read()
if len(content) > _MAX_UPLOAD_SIZE:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail={"code": "FILE_TOO_LARGE", "message": f"File too large. Max size is {_MAX_UPLOAD_SIZE // (1024*1024)} MB"},
)
mgr = _get_mission_manager(server_id, server["game_type"])
try:
mission = mgr.upload_mission(file.filename, content)
except AdapterError as exc:
raise HTTPException(status_code=400, detail={"code": "ADAPTER_ERROR", "message": str(exc)})
return _ok(mission)
@router.delete("/{filename}")
def delete_mission(
server_id: int,
filename: str,
db: Annotated[Connection, Depends(get_db)],
_admin: Annotated[dict, Depends(require_admin)],
) -> dict:
"""Delete a mission file by filename."""
server = ServerService(db).get_server(server_id) # raises 404 if not found
mgr = _get_mission_manager(server_id, server["game_type"])
try:
deleted = mgr.delete_mission(filename)
except AdapterError as exc:
raise HTTPException(status_code=400, detail={"code": "ADAPTER_ERROR", "message": str(exc)})
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"code": "NOT_FOUND", "message": f"Mission '{filename}' not found"},
)
return _ok({"message": f"Mission '{filename}' deleted"})

View File

@@ -0,0 +1,109 @@
"""Mod management endpoints — list available mods, set enabled mods."""
from __future__ import annotations
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.engine import Connection
from adapters.exceptions import AdapterError
from adapters.registry import GameAdapterRegistry
from core.dal.config_repository import ConfigRepository
from core.servers.service import ServerService
from database import get_db
from dependencies import get_current_user, require_admin
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/servers/{server_id}/mods", tags=["mods"])
def _ok(data):
return {"success": True, "data": data, "error": None}
class EnabledModEntry(BaseModel):
name: str
is_server_mod: bool = False
class SetEnabledModsRequest(BaseModel):
mods: list[EnabledModEntry]
def _get_mod_manager(server_id: int, game_type: str):
"""Get ModManager for the server's game type."""
adapter = GameAdapterRegistry.get(game_type)
if not adapter.has_capability("mod_manager"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"code": "NOT_SUPPORTED", "message": f"Game type '{game_type}' does not support mod management"},
)
return adapter.get_mod_manager(server_id)
@router.get("")
def list_mods(
server_id: int,
db: Annotated[Connection, Depends(get_db)],
_user: Annotated[dict, Depends(get_current_user)],
) -> dict:
"""List all available mods and which are enabled."""
server = ServerService(db).get_server(server_id) # raises 404 if not found
mgr = _get_mod_manager(server_id, server["game_type"])
config_repo = ConfigRepository(db)
try:
available = mgr.list_available_mods()
enabled_mods = mgr.get_enabled_mods(config_repo)
except AdapterError as exc:
raise HTTPException(status_code=500, detail={"code": "ADAPTER_ERROR", "message": str(exc)})
enabled_map = {m["name"]: m for m in enabled_mods}
for mod in available:
entry = enabled_map.get(mod["name"])
mod["enabled"] = entry is not None
mod["is_server_mod"] = entry["is_server_mod"] if entry else False
return _ok({
"server_id": server_id,
"mods": available,
"enabled_count": len(enabled),
})
@router.put("/enabled")
def set_enabled_mods(
server_id: int,
body: SetEnabledModsRequest,
db: Annotated[Connection, Depends(get_db)],
_admin: Annotated[dict, Depends(require_admin)],
) -> dict:
"""
Set the list of enabled mods.
Replaces the current enabled list entirely.
Server must be restarted for changes to take effect.
"""
server = ServerService(db).get_server(server_id) # raises 404 if not found
mgr = _get_mod_manager(server_id, server["game_type"])
config_repo = ConfigRepository(db)
try:
mgr.set_enabled_mods([m.model_dump() for m in body.mods], config_repo)
except AdapterError as exc:
raise HTTPException(status_code=400, detail={"code": "ADAPTER_ERROR", "message": str(exc)})
except ValueError as exc:
if "CONFIG_VERSION_CONFLICT" in str(exc):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={"code": "VERSION_CONFLICT", "message": "Config was modified by another request. Please retry."},
)
raise
db.commit()
return _ok({
"message": "Enabled mods updated. Restart the server for changes to take effect.",
"enabled_mods": [m.model_dump() for m in body.mods],
})

View File

@@ -0,0 +1,94 @@
"""Player endpoints — list current players for a running server."""
from __future__ import annotations
import logging
from typing import Annotated
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.engine import Connection
from core.dal.player_repository import PlayerRepository
from core.servers.service import ServerService
from database import get_db
from dependencies import get_current_user, require_admin
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/servers/{server_id}/players", tags=["players"])
class KickRequest(BaseModel):
reason: str = "Kicked by admin"
class BanFromPlayerRequest(BaseModel):
reason: str = "Banned by admin"
duration_minutes: int | None = None
def _ok(data):
return {"success": True, "data": data, "error": None}
@router.get("")
def list_players(
server_id: int,
db: Annotated[Connection, Depends(get_db)],
_user: Annotated[dict, Depends(get_current_user)],
) -> dict:
"""List current players (cached from RemoteAdminPollerThread)."""
ServerService(db).get_server(server_id) # raises 404 if not found
player_repo = PlayerRepository(db)
players = player_repo.get_all(server_id=server_id)
count = player_repo.count(server_id=server_id)
return _ok({
"server_id": server_id,
"player_count": count,
"players": players,
})
@router.get("/history")
def player_history(
server_id: int,
db: Annotated[Connection, Depends(get_db)],
_user: Annotated[dict, Depends(get_current_user)],
limit: int = 100,
offset: int = 0,
search: str | None = None,
) -> dict:
"""Get historical player sessions."""
ServerService(db).get_server(server_id) # raises 404 if not found
player_repo = PlayerRepository(db)
total, rows = player_repo.get_history(
server_id=server_id, limit=limit, offset=offset, search=search,
)
return _ok({"total": total, "items": rows})
@router.post("/{slot_id}/kick")
def kick_player(
server_id: int,
slot_id: int,
body: KickRequest,
db: Annotated[Connection, Depends(get_db)],
_admin: Annotated[dict, Depends(require_admin)],
) -> dict:
ServerService(db).kick_player(server_id, slot_id, body.reason)
return _ok({"message": f"Player {slot_id} kicked"})
@router.post("/{slot_id}/ban")
def ban_player_from_list(
server_id: int,
slot_id: int,
body: BanFromPlayerRequest,
db: Annotated[Connection, Depends(get_db)],
admin: Annotated[dict, Depends(require_admin)],
) -> dict:
ban = ServerService(db).ban_from_player(
server_id, slot_id, body.reason, body.duration_minutes,
banned_by=admin["username"],
)
return _ok(ban)

View File

@@ -0,0 +1,243 @@
"""
ProcessManager singleton — owns all subprocess handles.
Game-agnostic: delegates exe validation and config to adapters.
"""
from __future__ import annotations
import logging
import subprocess
import threading
from pathlib import Path
import psutil
logger = logging.getLogger(__name__)
class ProcessManager:
_instance: "ProcessManager | None" = None
_init_lock = threading.Lock()
def __init__(self):
self._processes: dict[int, subprocess.Popen] = {}
self._lock = threading.Lock()
self._operation_locks: dict[int, threading.Lock] = {}
self._ops_lock = threading.Lock()
@classmethod
def get(cls) -> "ProcessManager":
if cls._instance is None:
with cls._init_lock:
if cls._instance is None:
cls._instance = ProcessManager()
return cls._instance
def get_operation_lock(self, server_id: int) -> threading.Lock:
"""Per-server lock that serializes start/stop/restart for the same server."""
with self._ops_lock:
if server_id not in self._operation_locks:
self._operation_locks[server_id] = threading.Lock()
return self._operation_locks[server_id]
def start(
self,
server_id: int,
exe_path: str,
args: list[str],
cwd: str | Path,
) -> int:
"""
Start a game server process.
Returns the PID.
cwd is set to servers/{server_id}/ so relative config paths work.
"""
with self._lock:
if server_id in self._processes:
proc = self._processes[server_id]
if proc.poll() is None:
raise RuntimeError(f"Server {server_id} is already running (PID {proc.pid})")
del self._processes[server_id]
full_cmd = [exe_path] + args
logger.info("Starting server %d: %s", server_id, ' '.join(full_cmd))
proc = subprocess.Popen(
full_cmd,
cwd=str(cwd),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
# On Windows, don't create a new console window
creationflags=subprocess.CREATE_NO_WINDOW if hasattr(subprocess, "CREATE_NO_WINDOW") else 0,
)
with self._lock:
self._processes[server_id] = proc
logger.info("Server %d started with PID %d", server_id, proc.pid)
return proc.pid
def stop(self, server_id: int, timeout: int = 30) -> bool:
"""
Send terminate signal and wait up to timeout seconds.
On Windows, terminate() = hard kill (no SIGTERM).
Returns True if process exited, False if still running.
"""
with self._lock:
proc = self._processes.get(server_id)
if proc is None:
return True
try:
proc.terminate()
except ProcessLookupError:
return True
try:
proc.wait(timeout=timeout)
with self._lock:
self._processes.pop(server_id, None)
return True
except subprocess.TimeoutExpired:
return False
def kill(self, server_id: int) -> bool:
"""Force-kill the process immediately."""
with self._lock:
proc = self._processes.get(server_id)
if proc is None:
return True
try:
proc.kill()
proc.wait(timeout=5)
except (ProcessLookupError, subprocess.TimeoutExpired):
logger.debug("Process %d already exited or timed out during kill", server_id)
with self._lock:
self._processes.pop(server_id, None)
return True
def is_running(self, server_id: int) -> bool:
with self._lock:
proc = self._processes.get(server_id)
if proc is None:
return False
return proc.poll() is None
def get_pid(self, server_id: int) -> int | None:
with self._lock:
proc = self._processes.get(server_id)
if proc is None or proc.poll() is not None:
return None
return proc.pid
def get_process(self, server_id: int) -> subprocess.Popen | None:
with self._lock:
return self._processes.get(server_id)
def list_running(self) -> list[int]:
with self._lock:
return [sid for sid, p in self._processes.items() if p.poll() is None]
def recover_on_startup(self, db) -> None:
"""
On app restart: check DB for servers marked 'running'.
If the PID is still alive AND the process name matches the adapter's
allowed executables, re-attach monitoring threads.
Otherwise mark server as 'crashed'.
"""
from adapters.registry import GameAdapterRegistry
from core.dal.server_repository import ServerRepository
from core.dal.event_repository import EventRepository
from sqlalchemy import text
running_servers = ServerRepository(db).get_running()
for server in running_servers:
pid = server.get("pid")
if pid is None:
self._mark_crashed(server, db, "No PID recorded")
continue
# Check if PID is alive
if not psutil.pid_exists(pid):
self._mark_crashed(server, db, f"PID {pid} no longer exists")
continue
# Check process name matches adapter allowlist
try:
proc = psutil.Process(pid)
proc_name = proc.name()
adapter = GameAdapterRegistry.get(server["game_type"])
allowed = adapter.get_process_config().get_allowed_executables()
if not any(proc_name.lower() == exe.lower() for exe in allowed):
self._mark_crashed(
server, db,
f"PID {pid} has name '{proc_name}', not in allowlist {allowed}"
)
continue
except (psutil.NoSuchProcess, psutil.AccessDenied, KeyError) as e:
self._mark_crashed(server, db, str(e))
continue
# PID is valid — re-attach the process and start monitoring threads
logger.info(
"Recovering server %d (PID %d, %s)", server['id'], pid, server['game_type']
)
proc_obj = self._get_popen_for_pid(pid)
if proc_obj:
with self._lock:
self._processes[server["id"]] = proc_obj
# Re-start monitoring threads without re-launching the process
try:
from core.threads.thread_registry import ThreadRegistry
ThreadRegistry.reattach_server_threads(server["id"], db)
except Exception as e:
logger.warning("Could not re-attach threads for server %d: %s", server['id'], e)
else:
self._mark_crashed(server, db, f"Could not attach to PID {pid}")
def _mark_crashed(self, server: dict, db, reason: str) -> None:
from core.dal.server_repository import ServerRepository
from core.dal.event_repository import EventRepository
logger.warning("Server %d marked crashed on startup: %s", server['id'], reason)
ServerRepository(db).update_status(server["id"], "crashed")
EventRepository(db).insert(
server["id"], "crashed", actor="system",
detail={"reason": reason, "on_startup": True}
)
@staticmethod
def _get_popen_for_pid(pid: int) -> subprocess.Popen | None:
"""
Create a Popen-like wrapper that attaches to an existing PID.
NOTE: This is a limited wrapper — we cannot use Popen() on existing PIDs.
We use a sentinel object that wraps psutil.Process.
"""
try:
return _PsutilProcessWrapper(pid)
except (psutil.NoSuchProcess, psutil.AccessDenied):
return None
class _PsutilProcessWrapper:
"""
Minimal Popen-compatible wrapper around an existing process (by PID).
Used for startup recovery only.
"""
def __init__(self, pid: int):
self._psutil_proc = psutil.Process(pid)
self.pid = pid
def poll(self) -> int | None:
"""Return None if running, exit code if not (we use -1 for external termination)."""
if self._psutil_proc.is_running():
return None
return -1
def wait(self, timeout: int | None = None):
self._psutil_proc.wait(timeout=timeout)
def terminate(self):
self._psutil_proc.terminate()
def kill(self):
self._psutil_proc.kill()

View File

@@ -0,0 +1,242 @@
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import Response
from pydantic import BaseModel
from sqlalchemy.engine import Connection
from core.servers.schemas import (
CreateServerRequest, StopServerRequest, UpdateServerRequest,
)
from core.servers.service import ServerService
from database import get_db
from dependencies import get_current_user, require_admin
router = APIRouter(prefix="/servers", tags=["servers"])
def _ok(data):
return {"success": True, "data": data, "error": None}
class SendCommandRequest(BaseModel):
command: str
# ── Server CRUD ──────────────────────────────────────────────────────────────
@router.get("")
def list_servers(
game_type: str | None = None,
db: Annotated[Connection, Depends(get_db)] = None,
_user: Annotated[dict, Depends(get_current_user)] = None,
):
return _ok(ServerService(db).list_servers(game_type))
@router.post("", status_code=201)
def create_server(
body: CreateServerRequest,
db: Annotated[Connection, Depends(get_db)] = None,
_admin: Annotated[dict, Depends(require_admin)] = None,
):
return _ok(ServerService(db).create_server(
name=body.name,
game_type=body.game_type,
exe_path=body.exe_path,
game_port=body.game_port,
rcon_port=body.rcon_port,
description=body.description,
auto_restart=body.auto_restart,
max_restarts=body.max_restarts,
))
@router.get("/{server_id}")
def get_server(
server_id: int,
db: Annotated[Connection, Depends(get_db)] = None,
_user: Annotated[dict, Depends(get_current_user)] = None,
):
return _ok(ServerService(db).get_server(server_id))
@router.put("/{server_id}")
def update_server(
server_id: int,
body: UpdateServerRequest,
db: Annotated[Connection, Depends(get_db)] = None,
_admin: Annotated[dict, Depends(require_admin)] = None,
):
return _ok(ServerService(db).update_server(server_id, **body.model_dump(exclude_none=True)))
@router.delete("/{server_id}", status_code=204)
def delete_server(
server_id: int,
db: Annotated[Connection, Depends(get_db)] = None,
_admin: Annotated[dict, Depends(require_admin)] = None,
):
ServerService(db).delete_server(server_id)
return Response(status_code=204)
# ── Lifecycle ────────────────────────────────────────────────────────────────
@router.post("/{server_id}/start")
def start_server(
server_id: int,
db: Annotated[Connection, Depends(get_db)] = None,
_admin: Annotated[dict, Depends(require_admin)] = None,
):
return _ok(ServerService(db).start(server_id))
@router.post("/{server_id}/stop")
def stop_server(
server_id: int,
body: StopServerRequest = None,
db: Annotated[Connection, Depends(get_db)] = None,
_admin: Annotated[dict, Depends(require_admin)] = None,
):
force = body.force if body else False
return _ok(ServerService(db).stop(server_id, force=force))
@router.post("/{server_id}/restart")
def restart_server(
server_id: int,
db: Annotated[Connection, Depends(get_db)] = None,
_admin: Annotated[dict, Depends(require_admin)] = None,
):
return _ok(ServerService(db).restart(server_id))
@router.post("/{server_id}/kill")
def kill_server(
server_id: int,
db: Annotated[Connection, Depends(get_db)] = None,
_admin: Annotated[dict, Depends(require_admin)] = None,
):
return _ok(ServerService(db).kill(server_id))
# ── Config ───────────────────────────────────────────────────────────────────
@router.get("/{server_id}/config")
def get_config(
server_id: int,
db: Annotated[Connection, Depends(get_db)] = None,
_user: Annotated[dict, Depends(get_current_user)] = None,
):
return _ok(ServerService(db).get_config(server_id))
@router.get("/{server_id}/config/schema")
def get_config_schema(
server_id: int,
db: Annotated[Connection, Depends(get_db)] = None,
_user: Annotated[dict, Depends(get_current_user)] = None,
):
return _ok(ServerService(db).get_config_schema(server_id))
@router.get("/{server_id}/config/preview")
def get_config_preview(
server_id: int,
db: Annotated[Connection, Depends(get_db)] = None,
_admin: Annotated[dict, Depends(require_admin)] = None,
):
return _ok(ServerService(db).get_config_preview(server_id))
@router.get("/{server_id}/config/{section}")
def get_config_section(
server_id: int,
section: str,
db: Annotated[Connection, Depends(get_db)] = None,
_user: Annotated[dict, Depends(get_current_user)] = None,
):
return _ok(ServerService(db).get_config_section(server_id, section))
@router.put("/{server_id}/config/{section}")
def update_config_section(
server_id: int,
section: str,
body: dict, # Dynamic — adapter-specific fields
db: Annotated[Connection, Depends(get_db)] = None,
_admin: Annotated[dict, Depends(require_admin)] = None,
):
expected_version = body.pop("config_version", None)
return _ok(ServerService(db).update_config_section(
server_id, section, body, expected_version
))
# ── RCon ──────────────────────────────────────────────────────────────────────
@router.post("/{server_id}/rcon/command")
def send_rcon_command(
server_id: int,
body: SendCommandRequest,
db: Annotated[Connection, Depends(get_db)] = None,
_admin: Annotated[dict, Depends(require_admin)] = None,
):
"""Send an RCon command to a running server."""
from adapters.registry import GameAdapterRegistry
from adapters.exceptions import RemoteAdminError
from core.dal.config_repository import ConfigRepository
from core.dal.server_repository import ServerRepository
server = ServerRepository(db).get_by_id(server_id)
if server is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"code": "NOT_FOUND", "message": f"Server {server_id} not found"},
)
adapter = GameAdapterRegistry.get(server["game_type"])
if not adapter.has_capability("remote_admin"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"code": "NOT_SUPPORTED", "message": f"Game type {server['game_type']} does not support RCon"},
)
# Get RCon password from config
remote_admin_factory = adapter.get_remote_admin()
config_gen = adapter.get_config_generator()
sensitive = config_gen.get_sensitive_fields("rcon") if "rcon" in config_gen.get_sections() else []
config_repo = ConfigRepository(db)
rcon_section = config_repo.get_section(server_id, "rcon", sensitive)
if not rcon_section or not rcon_section.get("password"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"code": "NO_RCON_PASSWORD", "message": "RCon password not configured for this server"},
)
password = rcon_section["password"]
rcon_port = server.get("rcon_port") or (server["game_port"] + 3)
client = remote_admin_factory.create_client(
host="127.0.0.1",
port=rcon_port,
password=password,
)
try:
client.connect()
result = client.send_command(body.command)
client.disconnect()
except RemoteAdminError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={"code": "RCON_ERROR", "message": f"RCon command failed: {exc}"},
)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={"code": "RCON_ERROR", "message": f"RCon connection failed: {exc}"},
)
return _ok({"response": result})

View File

@@ -0,0 +1,35 @@
from __future__ import annotations
from pydantic import BaseModel, Field
class CreateServerRequest(BaseModel):
name: str
description: str | None = None
game_type: str = "arma3"
exe_path: str
game_port: int = Field(ge=1024, le=65535)
rcon_port: int | None = Field(default=None, ge=1024, le=65535)
auto_restart: bool = False
max_restarts: int = Field(default=3, ge=0, le=20)
class UpdateServerRequest(BaseModel):
name: str | None = None
description: str | None = None
exe_path: str | None = None
game_port: int | None = Field(default=None, ge=1024, le=65535)
rcon_port: int | None = Field(default=None, ge=1024, le=65535)
auto_restart: bool | None = None
max_restarts: int | None = None
class StopServerRequest(BaseModel):
force: bool = False
reason: str | None = None
class UpdateConfigSectionRequest(BaseModel):
config_version: int | None = None # Required for optimistic locking on PUT
# All other fields come from the adapter's JSON Schema — passed through as-is
model_config = {"extra": "allow"}

View File

@@ -0,0 +1,564 @@
"""
ServerService — orchestrates all server lifecycle operations.
Delegates game-specific work to the adapter.
"""
from __future__ import annotations
import logging
import shutil
from pathlib import Path
from fastapi import HTTPException, status
from sqlalchemy.engine import Connection
from adapters.registry import GameAdapterRegistry
from core.dal.config_repository import ConfigRepository
from core.dal.event_repository import EventRepository
from core.dal.server_repository import ServerRepository
from core.servers.process_manager import ProcessManager
from core.utils.file_utils import ensure_server_dirs, get_server_dir
logger = logging.getLogger(__name__)
def _ok_response(data):
return {"success": True, "data": data, "error": None}
class ServerService:
def __init__(self, db: Connection):
self._db = db
self._server_repo = ServerRepository(db)
self._config_repo = ConfigRepository(db)
self._event_repo = EventRepository(db)
# ── CRUD ──────────────────────────────────────────────────────────────────
def list_servers(self, game_type: str | None = None) -> list[dict]:
"""Return server list with live metrics merged in."""
servers = self._server_repo.get_all(game_type)
return [self._enrich_server(s) for s in servers]
def get_server(self, server_id: int) -> dict:
server = self._server_repo.get_by_id(server_id)
if server is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"code": "NOT_FOUND", "message": f"Server {server_id} not found"},
)
return self._enrich_server(server)
def _enrich_server(self, server: dict) -> dict:
"""Add live CPU/RAM/player count from DB."""
from core.dal.metrics_repository import MetricsRepository
from core.dal.player_repository import PlayerRepository
result = dict(server)
metrics = MetricsRepository(self._db).get_latest(server["id"])
if metrics:
result["cpu_percent"] = metrics["cpu_percent"]
result["ram_mb"] = metrics["ram_mb"]
else:
result["cpu_percent"] = None
result["ram_mb"] = None
result["player_count"] = PlayerRepository(self._db).count(server["id"])
return result
def create_server(
self,
name: str,
game_type: str,
exe_path: str,
game_port: int,
rcon_port: int | None = None,
description: str | None = None,
auto_restart: bool = False,
max_restarts: int = 3,
) -> dict:
# Validate adapter exists
try:
adapter = GameAdapterRegistry.get(game_type)
except KeyError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"code": "GAME_TYPE_NOT_FOUND", "message": f"Unknown game type: {game_type}"},
)
# Validate exe
process_config = adapter.get_process_config()
exe_name = Path(exe_path).name
if exe_name not in process_config.get_allowed_executables():
from adapters.exceptions import ExeNotAllowedError
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": "EXE_NOT_ALLOWED",
"message": f"Executable '{exe_name}' not allowed",
"allowed": process_config.get_allowed_executables(),
},
)
# Determine rcon_port if not provided
if rcon_port is None:
rcon_port = process_config.get_default_rcon_port(game_port)
# Check port conflicts against running servers
from core.utils.port_checker import check_ports_against_running_servers
conflicts = check_ports_against_running_servers(game_port, rcon_port, None, self._db)
if conflicts:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"code": "PORT_IN_USE",
"message": f"Ports already in use: {conflicts}",
},
)
# Create DB row
server_id = self._server_repo.create(
name=name,
game_type=game_type,
exe_path=exe_path,
game_port=game_port,
rcon_port=rcon_port,
description=description,
auto_restart=auto_restart,
max_restarts=max_restarts,
)
# Create directory layout with per-directory README files
layout = process_config.get_server_dir_layout()
readme_fn = getattr(process_config, "get_dir_readme", None)
ensure_server_dirs(server_id, layout, readme_provider=readme_fn)
# Seed default config sections
config_gen = adapter.get_config_generator()
schema_version = config_gen.get_config_version()
for section in config_gen.get_sections():
defaults = config_gen.get_defaults(section)
sensitive = config_gen.get_sensitive_fields(section)
self._config_repo.upsert_section(
server_id=server_id,
game_type=game_type,
section=section,
config_data=defaults,
schema_version=schema_version,
sensitive_fields=sensitive,
)
self._event_repo.insert(server_id, "created", actor="admin")
return self.get_server(server_id)
def update_server(self, server_id: int, **updates) -> dict:
self.get_server(server_id) # raises 404 if not found
filtered = {k: v for k, v in updates.items() if v is not None}
if filtered:
self._server_repo.update(server_id, **filtered)
return self.get_server(server_id)
def delete_server(self, server_id: int) -> None:
server = self.get_server(server_id)
if server["status"] not in ("stopped", "crashed", "error"):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"code": "SERVER_NOT_STOPPED",
"message": "Server must be stopped before deletion",
},
)
self._server_repo.delete(server_id)
# Delete server directory
server_dir = get_server_dir(server_id)
if server_dir.exists():
shutil.rmtree(str(server_dir), ignore_errors=True)
# ── Lifecycle ─────────────────────────────────────────────────────────────
def start(self, server_id: int) -> dict:
"""
Full start sequence:
1. Load server + adapter
2. Validate exe
3. Check ports
4. Write config files (atomic)
5. Build launch args
6. Start process
7. Start monitoring threads
8. Return status
"""
from adapters.exceptions import (
ConfigWriteError, ExeNotAllowedError,
LaunchArgsError, ConfigValidationError,
)
from core.utils.port_checker import check_ports_against_running_servers
server = self.get_server(server_id)
if server["status"] in ("running", "starting"):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={"code": "SERVER_ALREADY_RUNNING", "message": "Server is already running"},
)
adapter = GameAdapterRegistry.get(server["game_type"])
process_config = adapter.get_process_config()
config_gen = adapter.get_config_generator()
# Validate exe
exe_name = Path(server["exe_path"]).name
if exe_name not in process_config.get_allowed_executables():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": "EXE_NOT_ALLOWED",
"message": f"Executable '{exe_name}' not in adapter allowlist",
"allowed": process_config.get_allowed_executables(),
},
)
# Check ports
conflicts = check_ports_against_running_servers(
server["game_port"], server.get("rcon_port"), server_id, self._db
)
if conflicts:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={"code": "PORT_IN_USE", "message": f"Ports in use: {conflicts}"},
)
# Load config sections (decrypt sensitive fields for config generation)
sensitive_by_section = {
s: config_gen.get_sensitive_fields(s)
for s in config_gen.get_sections()
}
sections = self._config_repo.get_all_sections(server_id, sensitive_by_section)
# Remove _meta from each section before passing to adapter
raw_sections = {
section: {k: v for k, v in data.items() if k != "_meta"}
for section, data in sections.items()
}
# Inject port into sections so build_launch_args can use it
if "_port" not in raw_sections:
raw_sections["_port"] = server["game_port"]
# Get mod args if adapter supports mods
mod_args: list[str] = []
if adapter.has_capability("mod_manager"):
mod_mgr = adapter.get_mod_manager(server_id)
enabled_mods = mod_mgr.get_enabled_mods(self._config_repo)
server_dir = get_server_dir(server_id)
mod_list = [
{
"folder_path": str(server_dir / "mods" / m["name"]),
"game_data": {"is_server_mod": m.get("is_server_mod", False)},
}
for m in enabled_mods
]
mod_args = mod_mgr.build_mod_args(mod_list)
# Write config files (atomic)
server_dir = get_server_dir(server_id)
try:
config_gen.write_configs(server_id, server_dir, raw_sections)
except ConfigWriteError as e:
self._server_repo.update_status(server_id, "error")
self._event_repo.insert(server_id, "config_write_error", detail={"error": str(e)})
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={"code": "CONFIG_WRITE_ERROR", "message": str(e)},
)
except ConfigValidationError as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={"code": "INVALID_CONFIG", "message": str(e), "errors": e.errors},
)
# Build launch args
try:
launch_args = config_gen.build_launch_args(raw_sections, mod_args, server_dir=server_dir)
except LaunchArgsError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"code": "INVALID_CONFIG", "message": str(e)},
)
# Start process
pm = ProcessManager.get()
with pm.get_operation_lock(server_id):
pid = pm.start(server_id, server["exe_path"], launch_args, cwd=str(server_dir))
# Update DB
from datetime import datetime, timezone
self._server_repo.update_status(
server_id, "starting", pid=pid,
started_at=datetime.now(timezone.utc).isoformat()
)
self._event_repo.insert(server_id, "started", detail={"pid": pid})
# Start monitoring threads
try:
from core.threads.thread_registry import ThreadRegistry
ThreadRegistry.start_server_threads(server_id, self._db)
except Exception as e:
logger.warning("Could not start monitoring threads: %s", e)
return {"status": "starting", "pid": pid}
def stop(self, server_id: int, force: bool = False) -> dict:
server = self.get_server(server_id)
if server["status"] in ("stopped", "crashed"):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={"code": "SERVER_NOT_RUNNING", "message": "Server is not running"},
)
# Mark as "stopping" so ProcessMonitorThread doesn't treat this as a crash
self._server_repo.update_status(server_id, "stopping")
# Stop monitoring threads first so they don't fight with shutdown
try:
from core.threads.thread_registry import ThreadRegistry
ThreadRegistry.stop_server_threads(server_id)
except Exception as exc:
logger.warning("Failed to stop monitoring threads for server %d during stop: %s", server_id, exc)
# Try graceful shutdown via remote admin
if not force:
try:
pm = ProcessManager.get()
logger.info("Sending graceful shutdown to server %d", server_id)
except Exception as e:
logger.warning("Graceful shutdown failed: %s, falling back to terminate", e)
pm = ProcessManager.get()
with pm.get_operation_lock(server_id):
exited = pm.stop(server_id, timeout=30)
if not exited:
logger.warning("Server %d did not exit in 30s, force-killing", server_id)
pm.kill(server_id)
from datetime import datetime, timezone
self._server_repo.update_status(
server_id, "stopped",
pid=None, stopped_at=datetime.now(timezone.utc).isoformat()
)
from core.dal.player_repository import PlayerRepository
PlayerRepository(self._db).clear(server_id)
self._event_repo.insert(server_id, "stopped")
return {"status": "stopped"}
def restart(self, server_id: int) -> dict:
self.stop(server_id)
return self.start(server_id)
def kill(self, server_id: int) -> dict:
server = self.get_server(server_id)
# Mark as "stopping" so ProcessMonitorThread doesn't treat this as a crash
self._server_repo.update_status(server_id, "stopping")
# Stop monitoring threads first
try:
from core.threads.thread_registry import ThreadRegistry
ThreadRegistry.stop_server_threads(server_id)
except Exception as exc:
logger.warning("Failed to stop monitoring threads for server %d during kill: %s", server_id, exc)
pm = ProcessManager.get()
with pm.get_operation_lock(server_id):
pm.kill(server_id)
from datetime import datetime, timezone
self._server_repo.update_status(server_id, "stopped", pid=None,
stopped_at=datetime.now(timezone.utc).isoformat())
from core.dal.player_repository import PlayerRepository
PlayerRepository(self._db).clear(server_id)
self._event_repo.insert(server_id, "killed")
return {"status": "stopped"}
# ── Config ────────────────────────────────────────────────────────────────
def get_config(self, server_id: int) -> dict:
self.get_server(server_id)
adapter = GameAdapterRegistry.get(
self._server_repo.get_by_id(server_id)["game_type"]
)
config_gen = adapter.get_config_generator()
sensitive_by_section = {
s: config_gen.get_sensitive_fields(s) for s in config_gen.get_sections()
}
sections = self._config_repo.get_all_sections(server_id, sensitive_by_section)
# Mask sensitive fields in response (replace actual value with "***")
for section, data in sections.items():
sf = config_gen.get_sensitive_fields(section)
for field in sf:
if field in data and data[field]:
data[field] = "***"
return sections
def kick_player(self, server_id: int, slot_id: int, reason: str) -> None:
from core.threads.thread_registry import ThreadRegistry
ra = ThreadRegistry.get_rcon_client(server_id)
if not ra or not ra.is_connected():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"code": "RCON_NOT_CONNECTED", "message": "RCon not connected — server must be running"},
)
success = ra.kick_player(int(slot_id), reason)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={"code": "KICK_FAILED", "message": "Kick command failed"},
)
def ban_from_player(
self,
server_id: int,
slot_id: int,
reason: str,
duration_minutes: int | None,
banned_by: str,
) -> dict:
from datetime import datetime, timezone, timedelta
from core.dal.player_repository import PlayerRepository
from core.dal.ban_repository import BanRepository
player = PlayerRepository(self._db).get_by_slot(server_id, slot_id)
if not player:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"code": "NOT_FOUND", "message": "Player not found"},
)
expires_at = None
if duration_minutes is not None and duration_minutes > 0:
expires_at = (datetime.now(timezone.utc) + timedelta(minutes=duration_minutes)).isoformat()
from core.threads.thread_registry import ThreadRegistry
ra = ThreadRegistry.get_rcon_client(server_id)
if ra and ra.is_connected():
ra.ban_player(player["guid"], duration_minutes or 0, reason)
ban_repo = BanRepository(self._db)
ban_id = ban_repo.create(
server_id=server_id,
guid=player["guid"],
name=player["name"],
reason=reason,
banned_by=banned_by,
expires_at=expires_at,
)
return dict(ban_repo.get_by_id(ban_id))
def get_config_schema(self, server_id: int) -> dict:
server = self.get_server(server_id)
adapter = GameAdapterRegistry.get(server["game_type"])
config_gen = adapter.get_config_generator()
if hasattr(config_gen, "get_ui_schema"):
return config_gen.get_ui_schema()
return {}
def get_config_section(self, server_id: int, section: str) -> dict:
server = self.get_server(server_id)
adapter = GameAdapterRegistry.get(server["game_type"])
config_gen = adapter.get_config_generator()
if section not in config_gen.get_sections():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"code": "NOT_FOUND", "message": f"Config section '{section}' not found"},
)
sensitive = config_gen.get_sensitive_fields(section)
data = self._config_repo.get_section(server_id, section, sensitive)
if data is None:
data = config_gen.get_defaults(section)
data["_meta"] = {"config_version": 0, "schema_version": config_gen.get_config_version()}
if hasattr(config_gen, "normalize_section"):
data = config_gen.normalize_section(section, data)
# Mask sensitive fields
for field in sensitive:
if field in data and data[field]:
data[field] = "***"
return data
def update_config_section(
self,
server_id: int,
section: str,
data: dict,
expected_version: int | None = None,
) -> dict:
server = self.get_server(server_id)
adapter = GameAdapterRegistry.get(server["game_type"])
config_gen = adapter.get_config_generator()
sections = config_gen.get_sections()
if section not in sections:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"code": "NOT_FOUND", "message": f"Config section '{section}' not found"},
)
# Validate against adapter's Pydantic model
model_cls = sections[section]
# Get current values, merge with update (partial update support)
current = self._config_repo.get_section(
server_id, section, config_gen.get_sensitive_fields(section)
)
if current:
merged = {k: v for k, v in current.items() if k != "_meta"}
else:
merged = config_gen.get_defaults(section)
# Apply updates
for k, v in data.items():
if k not in ("_meta", "config_version"):
merged[k] = v
# Validate
try:
model_cls(**merged)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={"code": "INVALID_CONFIG", "message": str(e)},
)
sensitive = config_gen.get_sensitive_fields(section)
try:
new_version = self._config_repo.upsert_section(
server_id=server_id,
game_type=server["game_type"],
section=section,
config_data=merged,
schema_version=config_gen.get_config_version(),
sensitive_fields=sensitive,
expected_config_version=expected_version,
)
except ValueError as e:
error_msg = str(e)
if "CONFIG_VERSION_CONFLICT" in error_msg:
current_version = int(error_msg.split(":")[1])
current_data = self.get_config_section(server_id, section)
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"code": "CONFIG_VERSION_CONFLICT",
"message": "Config was modified by another user. Re-read and merge.",
"current_config": current_data,
"current_version": current_version,
},
)
raise
self._event_repo.insert(
server_id, "config_updated", detail={"section": section, "version": new_version}
)
return self.get_config_section(server_id, section)
def get_config_preview(self, server_id: int) -> dict[str, str]:
server = self.get_server(server_id)
adapter = GameAdapterRegistry.get(server["game_type"])
config_gen = adapter.get_config_generator()
sensitive_by_section = {
s: config_gen.get_sensitive_fields(s) for s in config_gen.get_sections()
}
sections = self._config_repo.get_all_sections(server_id, sensitive_by_section)
raw_sections = {k: {kk: vv for kk, vv in v.items() if kk != "_meta"} for k, v in sections.items()}
server_dir = get_server_dir(server_id)
return config_gen.preview_config(server_id, server_dir, raw_sections)

View File

View File

@@ -0,0 +1,32 @@
from fastapi import APIRouter, Depends
from typing import Annotated
from dependencies import get_current_user
from adapters.registry import GameAdapterRegistry
router = APIRouter(prefix="/system", tags=["system"])
@router.get("/health")
def health():
return {"status": "ok"}
@router.get("/status")
def system_status(_user: Annotated[dict, Depends(get_current_user)]):
from sqlalchemy import text
from database import get_engine
with get_engine().connect() as db:
running = db.execute(
text("SELECT COUNT(*) FROM servers WHERE status IN ('running','starting')")
).fetchone()[0]
total = db.execute(text("SELECT COUNT(*) FROM servers")).fetchone()[0]
return {
"success": True,
"data": {
"version": "1.0.0",
"running_servers": running,
"total_servers": total,
"supported_games": [a.game_type for a in GameAdapterRegistry.all()],
},
}

View File

@@ -0,0 +1,3 @@
from core.threads.thread_registry import ThreadRegistry
__all__ = ["ThreadRegistry"]

View File

@@ -0,0 +1,123 @@
"""
BaseServerThread — base class for all per-server background threads.
Rules every subclass MUST follow:
- Call super().__init__(server_id, name) in __init__
- Implement _run_loop() — called repeatedly until _stop_event is set
- Do NOT override run() directly
- Use self._db for all database operations — it is a thread-local connection
- Call self._close_db() in your finally block if you open additional connections
- Exceptions raised from _run_loop() are caught, logged, and the loop continues
unless the exception is a fatal error — set self._fatal_error = True to stop
"""
from __future__ import annotations
import logging
import threading
from abc import ABC, abstractmethod
from database import get_thread_db
logger = logging.getLogger(__name__)
_EXCEPTION_BACKOFF_BASE = 2.0
_EXCEPTION_BACKOFF_MAX = 60.0
_EXCEPTION_BACKOFF_MULTIPLIER = 2.0
class BaseServerThread(ABC, threading.Thread):
"""
Abstract base for all per-server background threads.
Subclasses implement _run_loop(). This base class handles:
- Stop event signaling
- Thread-local DB connection lifecycle
- Exception backoff to prevent tight crash loops
- Structured logging with server_id context
"""
def __init__(self, server_id: int, name: str) -> None:
super().__init__(name=f"{name}-server-{server_id}", daemon=True)
self.server_id = server_id
self._stop_event = threading.Event()
self._fatal_error = False
self._db = None
self._exception_count = 0
# ── Public API ──
def stop(self) -> None:
"""Signal the thread to stop. Does not block."""
self._stop_event.set()
def stop_and_join(self, timeout: float = 5.0) -> None:
"""Signal stop and wait for the thread to exit."""
self._stop_event.set()
self.join(timeout=timeout)
@property
def is_stopping(self) -> bool:
return self._stop_event.is_set()
# ── Thread entry point ──
def run(self) -> None:
logger.info("[%s] Starting", self.name)
backoff = _EXCEPTION_BACKOFF_BASE
try:
self._db = get_thread_db()
self._on_start()
while not self._stop_event.is_set() and not self._fatal_error:
try:
self._run_loop()
backoff = _EXCEPTION_BACKOFF_BASE
self._exception_count = 0
except Exception as exc:
self._exception_count += 1
logger.error(
"[%s] Unhandled exception in _run_loop (count=%d): %s",
self.name, self._exception_count, exc, exc_info=True,
)
if self._fatal_error:
break
self._stop_event.wait(timeout=backoff)
backoff = min(backoff * _EXCEPTION_BACKOFF_MULTIPLIER, _EXCEPTION_BACKOFF_MAX)
except Exception as exc:
logger.critical("[%s] Fatal error in thread setup: %s", self.name, exc, exc_info=True)
finally:
self._on_stop()
self._close_db()
logger.info("[%s] Stopped", self.name)
# ── Hooks for subclasses ──
def _on_start(self) -> None:
"""Called once before the loop starts. Override for setup."""
def _on_stop(self) -> None:
"""Called once after the loop ends. Override for cleanup."""
@abstractmethod
def _run_loop(self) -> None:
"""
Implement the thread's work here.
Called repeatedly until stop() is called or _fatal_error is set.
Should block for a short period (sleep or wait) to avoid busy-looping.
"""
# ── Internal helpers ──
def _close_db(self) -> None:
if self._db is not None:
try:
self._db.close()
except Exception as exc:
logger.debug("[%s] Error closing DB connection: %s", self.name, exc)
self._db = None
def _sleep(self, seconds: float) -> None:
"""Interruptible sleep — wakes up early if stop() is called."""
self._stop_event.wait(timeout=seconds)

View File

@@ -0,0 +1,167 @@
"""
LogTailThread — tails a server's log file, parses lines via LogParser,
and persists parsed entries to the logs table.
Design notes:
- Opens the log file in text mode with errors="replace" to handle encoding issues
- Detects log rotation by checking if the inode changes (Unix) or file shrinks (Windows)
- On rotation: closes old handle, reopens from position 0
- Flushes inserts in batches of up to LOG_BATCH_SIZE per loop iteration
"""
from __future__ import annotations
import logging
import os
import queue
from pathlib import Path
from typing import Callable, Optional
from core.dal.log_repository import LogRepository
from core.threads.base_thread import BaseServerThread
logger = logging.getLogger(__name__)
_LOG_BATCH_SIZE = 50
_POLL_INTERVAL = 1.0
_REOPEN_DELAY = 2.0
class LogTailThread(BaseServerThread):
"""
Tails a log file for a specific server.
Args:
server_id: The database server ID.
log_path: Absolute path to the log file to tail.
log_parser: LogParser adapter instance for this game type.
broadcast_queue: Optional queue.Queue to push parsed events to BroadcastThread.
"""
def __init__(
self,
server_id: int,
log_path: str,
log_parser,
broadcast_queue=None,
) -> None:
super().__init__(server_id, "LogTail")
self._log_path = log_path
self._log_parser = log_parser
self._broadcast_queue = broadcast_queue
self._file_handle = None
self._last_inode = None
self._last_size = 0
# ── Lifecycle ──
def _on_start(self) -> None:
self._open_log_file()
def _on_stop(self) -> None:
self._close_file()
# ── Main loop ──
def _run_loop(self) -> None:
if self._file_handle is None:
self._stop_event.wait(timeout=_POLL_INTERVAL)
self._open_log_file()
return
if self._detect_rotation():
logger.info("[%s] Log rotation detected, reopening", self.name)
self._close_file()
self._stop_event.wait(timeout=_REOPEN_DELAY)
self._open_log_file()
return
lines_read = 0
entries_to_insert = []
while lines_read < _LOG_BATCH_SIZE:
line = self._file_handle.readline()
if not line:
break
lines_read += 1
line = line.rstrip("\n").rstrip("\r")
if not line:
continue
parsed = self._log_parser.parse_line(line)
if parsed is not None:
entries_to_insert.append(parsed)
if entries_to_insert and self._db is not None:
log_repo = LogRepository(self._db)
for entry in entries_to_insert:
log_repo.insert(server_id=self.server_id, entry=entry)
try:
self._db.commit()
except Exception as exc:
logger.error("[%s] DB commit failed: %s", self.name, exc)
self._db.rollback()
if self._broadcast_queue is not None:
for entry in entries_to_insert:
try:
self._broadcast_queue.put_nowait({
"type": "log",
"server_id": self.server_id,
"data": entry,
})
except queue.Full:
logger.debug("[%s] Broadcast queue full, dropping log event", self.name)
if lines_read == 0:
self._stop_event.wait(timeout=_POLL_INTERVAL)
# ── File management ──
def _open_log_file(self) -> None:
if not os.path.exists(self._log_path):
return
try:
self._file_handle = open(
self._log_path, "r", encoding="utf-8", errors="replace"
)
# Start tailing from the end of the file
self._file_handle.seek(0, 2)
self._last_size = self._file_handle.tell()
stat = os.stat(self._log_path)
self._last_inode = getattr(stat, "st_ino", None)
logger.debug("[%s] Opened log file: %s", self.name, self._log_path)
except OSError as exc:
logger.warning("[%s] Cannot open log file %s: %s", self.name, self._log_path, exc)
self._file_handle = None
def _close_file(self) -> None:
if self._file_handle is not None:
try:
self._file_handle.close()
except OSError as exc:
logger.debug("[%s] Error closing log file: %s", self.name, exc)
self._file_handle = None
self._last_inode = None
self._last_size = 0
def _detect_rotation(self) -> bool:
"""Returns True if the log file has been rotated."""
try:
stat = os.stat(self._log_path)
except OSError:
return True
current_inode = getattr(stat, "st_ino", None)
if current_inode is not None and self._last_inode is not None:
if current_inode != self._last_inode:
return True
# Windows fallback: file shrunk
current_size = stat.st_size
if self._file_handle is not None:
current_pos = self._file_handle.tell()
if current_size < current_pos:
return True
self._last_size = current_size
return False

View File

@@ -0,0 +1,118 @@
"""
MetricsCollectorThread — collects CPU and memory usage for a server process
and persists to the metrics table every COLLECTION_INTERVAL seconds.
Uses psutil to inspect the process identified by ProcessManager.get_pid().
If the process is not running, the thread sleeps and retries.
"""
from __future__ import annotations
import logging
import queue
import psutil
from core.dal.metrics_repository import MetricsRepository
from core.threads.base_thread import BaseServerThread
logger = logging.getLogger(__name__)
_COLLECTION_INTERVAL = 10.0
_RETENTION_DAYS = 1
class MetricsCollectorThread(BaseServerThread):
"""
Collects process metrics for a running game server.
Args:
server_id: Database server ID.
process_manager: ProcessManager singleton instance.
broadcast_queue: Optional queue.Queue for real-time metric pushes.
"""
def __init__(
self,
server_id: int,
process_manager,
broadcast_queue=None,
) -> None:
super().__init__(server_id, "MetricsCollector")
self._process_manager = process_manager
self._broadcast_queue = broadcast_queue
self._psutil_process = None
self._samples_since_cleanup = 0
self._cleanup_every = 360 # ~1 hour at 10s intervals
# ── Main loop ──
def _run_loop(self) -> None:
pid = self._process_manager.get_pid(self.server_id)
if pid is None:
self._psutil_process = None
self._stop_event.wait(timeout=_COLLECTION_INTERVAL)
return
# Reuse or create psutil.Process handle
if self._psutil_process is None or self._psutil_process.pid != pid:
try:
self._psutil_process = psutil.Process(pid)
self._psutil_process.cpu_percent(interval=None)
except psutil.NoSuchProcess:
self._psutil_process = None
self._stop_event.wait(timeout=_COLLECTION_INTERVAL)
return
self._stop_event.wait(timeout=_COLLECTION_INTERVAL)
if self._stop_event.is_set():
return
try:
cpu_pct = self._psutil_process.cpu_percent(interval=None)
mem_info = self._psutil_process.memory_info()
mem_mb = round(mem_info.rss / (1024 * 1024), 2)
except psutil.NoSuchProcess:
logger.info("[%s] Process %d no longer exists", self.name, pid)
self._psutil_process = None
return
except psutil.AccessDenied as exc:
logger.warning("[%s] Access denied reading process %d: %s", self.name, pid, exc)
return
if self._db is None:
return
metrics_repo = MetricsRepository(self._db)
metrics_repo.insert(
server_id=self.server_id,
cpu_percent=cpu_pct,
ram_mb=mem_mb,
)
try:
self._db.commit()
except Exception as exc:
logger.error("[%s] DB commit failed: %s", self.name, exc)
self._db.rollback()
return
if self._broadcast_queue is not None:
try:
self._broadcast_queue.put_nowait({
"type": "metrics",
"server_id": self.server_id,
"data": {"cpu_percent": cpu_pct, "memory_mb": mem_mb},
})
except queue.Full:
logger.debug("[%s] Broadcast queue full, dropping metrics event", self.name)
# Periodic cleanup
self._samples_since_cleanup += 1
if self._samples_since_cleanup >= self._cleanup_every:
self._samples_since_cleanup = 0
try:
metrics_repo.cleanup_old(server_id=self.server_id, retention_days=_RETENTION_DAYS)
self._db.commit()
except Exception as exc:
logger.warning("[%s] Cleanup failed: %s", self.name, exc)
self._db.rollback()

View File

@@ -0,0 +1,158 @@
"""
ProcessMonitorThread — watches a running game server process.
Responsibilities:
1. Detect when the process exits unexpectedly (crash).
2. On crash: update server status to "crashed" in DB, emit a crash event.
3. If auto_restart is enabled on the server record: trigger restart.
4. Respect max_restarts — if exceeded, leave server in "crashed" state.
Poll interval: 5 seconds.
"""
from __future__ import annotations
import logging
import queue
from core.dal.event_repository import EventRepository
from core.dal.server_repository import ServerRepository
from core.threads.base_thread import BaseServerThread
logger = logging.getLogger(__name__)
_POLL_INTERVAL = 5.0
class ProcessMonitorThread(BaseServerThread):
"""
Monitors the OS process for a running game server.
Args:
server_id: Database server ID.
process_manager: ProcessManager singleton (injected).
broadcast_queue: Optional queue.Queue for crash notifications.
"""
def __init__(
self,
server_id: int,
process_manager,
broadcast_queue=None,
) -> None:
super().__init__(server_id, "ProcessMonitor")
self._process_manager = process_manager
self._broadcast_queue = broadcast_queue
# ── Main loop ──
def _run_loop(self) -> None:
self._stop_event.wait(timeout=_POLL_INTERVAL)
if self._stop_event.is_set():
return
if not self._process_manager.is_running(self.server_id):
self._handle_unexpected_exit()
# After handling, stop this monitor — the server is no longer running
self._fatal_error = True
# ── Crash handling ──
def _handle_unexpected_exit(self) -> None:
if self._db is None:
return
server_repo = ServerRepository(self._db)
event_repo = EventRepository(self._db)
server = server_repo.get_by_id(self.server_id)
if server is None:
return
# Only treat as crash if the server was supposed to be running
if server["status"] not in ("running", "starting"):
return
logger.warning(
"[%s] Server %d process exited unexpectedly (status was '%s')",
self.name, self.server_id, server["status"],
)
# Increment crash counter
server_repo.increment_restart_count(self.server_id)
restart_count = server["restart_count"] + 1
max_restarts = server.get("max_restarts", 3)
# Record crash event
event_repo.insert(
server_id=self.server_id,
event_type="crash",
detail={"restart_count": restart_count},
)
should_restart = (
server.get("auto_restart", False)
and restart_count <= max_restarts
)
if should_restart:
server_repo.update_status(self.server_id, "restarting")
event_repo.insert(
server_id=self.server_id,
event_type="restart_scheduled",
detail={"attempt": restart_count, "max": max_restarts},
)
else:
server_repo.update_status(self.server_id, "crashed")
if restart_count > max_restarts:
event_repo.insert(
server_id=self.server_id,
event_type="restart_limit_reached",
detail={"restart_count": restart_count, "max_restarts": max_restarts},
)
try:
self._db.commit()
except Exception as exc:
logger.error("[%s] DB commit failed during crash handling: %s", self.name, exc)
self._db.rollback()
if self._broadcast_queue is not None:
try:
self._broadcast_queue.put_nowait({
"type": "server_status",
"server_id": self.server_id,
"data": {
"status": "restarting" if should_restart else "crashed",
"restart_count": restart_count,
},
})
except queue.Full:
logger.debug("[%s] Broadcast queue full, dropping server_status event", self.name)
# Trigger actual restart outside DB work
if should_restart:
self._trigger_restart()
def _trigger_restart(self) -> None:
"""
Calls ServerService.start() to restart the server.
This is safe to call from a background thread.
"""
try:
from database import get_thread_db
from core.servers.service import ServerService
db = get_thread_db()
try:
service = ServerService(db)
service.start(self.server_id)
except Exception as exc:
logger.error("[%s] Auto-restart start() failed: %s", self.name, exc, exc_info=True)
finally:
try:
db.close()
except Exception as exc:
logger.debug("[%s] Error closing restart DB connection: %s", self.name, exc)
except Exception as exc:
logger.error("[%s] Auto-restart failed: %s", self.name, exc, exc_info=True)

View File

@@ -0,0 +1,169 @@
"""
RemoteAdminPollerThread — polls the game server's remote admin interface
(e.g. BattlEye RCon for Arma3) to sync the player list.
Design notes:
- Uses the RemoteAdminClient protocol injected at construction time
- Reconnects automatically on disconnect with exponential backoff
- Persists current player list to players table via PlayerRepository
- Emits player_join / player_leave events via EventRepository
- Pushes player list updates to broadcast_queue if provided
Poll interval: 30 seconds.
Reconnect backoff: 5s -> 10s -> 20s -> 40s -> 60s (cap).
"""
from __future__ import annotations
import logging
import queue
from core.dal.event_repository import EventRepository
from core.dal.player_repository import PlayerRepository
from core.threads.base_thread import BaseServerThread
logger = logging.getLogger(__name__)
_POLL_INTERVAL = 30.0
_RECONNECT_BACKOFF_BASE = 5.0
_RECONNECT_BACKOFF_MAX = 60.0
_RECONNECT_BACKOFF_MULT = 2.0
class RemoteAdminPollerThread(BaseServerThread):
"""
Polls the remote admin interface for a game server.
Args:
server_id: Database server ID.
remote_admin_client: Connected RemoteAdminClient instance.
broadcast_queue: Optional queue.Queue for player list pushes.
"""
def __init__(
self,
server_id: int,
remote_admin_client,
broadcast_queue=None,
) -> None:
super().__init__(server_id, "RemoteAdminPoller")
self._client = remote_admin_client
self._broadcast_queue = broadcast_queue
self._connected = False
self._reconnect_backoff = _RECONNECT_BACKOFF_BASE
self._known_players: dict[str, dict] = {} # player_uid -> player data
# ── Lifecycle ──
def _on_stop(self) -> None:
if self._connected and self._client is not None:
try:
self._client.disconnect()
except Exception as exc:
logger.debug("[%s] Error disconnecting remote admin on stop: %s", self.name, exc)
self._connected = False
# ── Main loop ──
def _run_loop(self) -> None:
if not self._connected:
self._attempt_connect()
return
self._stop_event.wait(timeout=_POLL_INTERVAL)
if self._stop_event.is_set():
return
try:
players = self._client.get_players()
self._reconnect_backoff = _RECONNECT_BACKOFF_BASE
self._sync_players(players)
except Exception as exc:
logger.warning("[%s] Poll failed: %s — will reconnect", self.name, exc)
self._connected = False
try:
if self._client is not None:
self._client.disconnect()
except Exception as exc:
logger.debug("[%s] Error disconnecting after poll failure: %s", self.name, exc)
# ── Connection management ──
def _attempt_connect(self) -> None:
try:
self._client.connect() if hasattr(self._client, "connect") else None
self._connected = True
self._reconnect_backoff = _RECONNECT_BACKOFF_BASE
logger.info("[%s] Connected to remote admin", self.name)
except Exception as exc:
logger.warning(
"[%s] Connection failed: %s — retrying in %.1fs",
self.name, exc, self._reconnect_backoff,
)
self._stop_event.wait(timeout=self._reconnect_backoff)
self._reconnect_backoff = min(
self._reconnect_backoff * _RECONNECT_BACKOFF_MULT,
_RECONNECT_BACKOFF_MAX,
)
# ── Player sync ──
def _sync_players(self, current_players: list[dict]) -> None:
"""
Diff current_players against self._known_players.
Insert join events for new players, leave events for departed ones.
Upsert all current players in the DB.
Each player dict must have at least: slot_id, name (other fields optional).
"""
if self._db is None:
return
player_repo = PlayerRepository(self._db)
event_repo = EventRepository(self._db)
# Build uid sets for diffing — use slot_id as key
current_slots = {str(p.get("slot_id", i)): p for i, p in enumerate(current_players)}
current_keys = set(current_slots.keys())
known_keys = set(self._known_players.keys())
joined = current_keys - known_keys
left = known_keys - current_keys
for slot_key, player in current_slots.items():
player_repo.upsert(server_id=self.server_id, player=player)
if slot_key in joined:
event_repo.insert(
server_id=self.server_id,
event_type="player_join",
detail={"name": player.get("name", ""), "slot": slot_key},
)
logger.debug("[%s] Player joined: %s (slot %s)", self.name, player.get("name"), slot_key)
for slot_key in left:
departed = self._known_players[slot_key]
event_repo.insert(
server_id=self.server_id,
event_type="player_leave",
detail={"name": departed.get("name", ""), "slot": slot_key},
)
logger.debug("[%s] Player left: %s (slot %s)", self.name, departed.get("name"), slot_key)
try:
self._db.commit()
except Exception as exc:
logger.error("[%s] DB commit failed during player sync: %s", self.name, exc)
self._db.rollback()
# Update known players
self._known_players = current_slots
if self._broadcast_queue is not None:
try:
self._broadcast_queue.put_nowait({
"type": "players",
"server_id": self.server_id,
"data": current_players,
})
except queue.Full:
logger.debug("[%s] Broadcast queue full, dropping players event", self.name)

View File

@@ -0,0 +1,269 @@
"""
ThreadRegistry — manages the lifecycle of all per-server background threads.
One instance is created at app startup and stored in app.state.thread_registry.
Also provides class-level methods for convenience (called from ServerService).
Thread set per server:
- LogTailThread (started if adapter has "log_parser" capability and log_path is known)
- MetricsCollectorThread (always started)
- ProcessMonitorThread (always started)
- RemoteAdminPollerThread (started only if adapter has "remote_admin" capability)
Key methods:
start_server_threads(server_id, db) — start all threads for a server
stop_server_threads(server_id) — stop all threads for a server
reattach_server_threads(server_id, db) — re-attach threads without restarting process
stop_all() — called at app shutdown
"""
from __future__ import annotations
import logging
import queue
from adapters.registry import GameAdapterRegistry
from core.dal.config_repository import ConfigRepository
from core.dal.server_repository import ServerRepository
from core.threads.log_tail import LogTailThread
from core.threads.metrics_collector import MetricsCollectorThread
from core.threads.process_monitor import ProcessMonitorThread
from core.threads.remote_admin_poller import RemoteAdminPollerThread
logger = logging.getLogger(__name__)
# Module-level singleton for convenience (used by ServerService)
_instance: ThreadRegistry | None = None
class ThreadRegistry:
"""
Manages all background threads for all running servers.
"""
def __init__(
self,
process_manager,
adapter_registry: GameAdapterRegistry | None = None,
global_broadcast_queue: queue.Queue | None = None,
) -> None:
self._process_manager = process_manager
self._adapter_registry = adapter_registry or GameAdapterRegistry
self._broadcast_queue = global_broadcast_queue or queue.Queue(maxsize=1000)
self._bundles: dict[int, dict] = {} # server_id -> thread bundle
# ── Class-level convenience API ──
@classmethod
def _get_instance(cls) -> "ThreadRegistry | None":
return _instance
@classmethod
def set_instance(cls, registry: "ThreadRegistry") -> None:
global _instance
_instance = registry
@classmethod
def start_server_threads(cls, server_id: int, db) -> None:
"""Class-level convenience — starts threads for a server using the singleton."""
registry = cls._get_instance()
if registry is not None:
registry._start_server_threads(server_id, db)
@classmethod
def stop_server_threads(cls, server_id: int) -> None:
"""Class-level convenience — stops threads for a server using the singleton."""
registry = cls._get_instance()
if registry is not None:
registry._stop_server_threads(server_id)
@classmethod
def reattach_server_threads(cls, server_id: int, db) -> None:
"""Class-level convenience — re-attaches threads for a recovered server."""
registry = cls._get_instance()
if registry is not None:
registry._reattach_server_threads(server_id, db)
@classmethod
def stop_all(cls) -> None:
"""Class-level convenience — stops all threads."""
registry = cls._get_instance()
if registry is not None:
registry._stop_all()
@classmethod
def get_rcon_client(cls, server_id: int):
"""Return the live Arma3RemoteAdmin client for a running server, or None."""
registry = cls._get_instance()
if registry is None:
return None
bundle = registry._bundles.get(server_id)
if bundle is None:
return None
poller = bundle.get("rcon_poller")
if poller is None or not poller.is_alive():
return None
return getattr(poller, "_client", None)
# ── Instance methods ──
def _start_server_threads(self, server_id: int, db) -> None:
if server_id in self._bundles:
logger.warning(
"ThreadRegistry: threads already exist for server %d — stopping first",
server_id,
)
self._stop_server_threads(server_id)
bundle = self._build_bundle(server_id, db)
self._bundles[server_id] = bundle
self._start_bundle(server_id, bundle)
def _stop_server_threads(self, server_id: int) -> None:
bundle = self._bundles.pop(server_id, None)
if bundle is None:
return
self._stop_bundle(server_id, bundle)
def _reattach_server_threads(self, server_id: int, db) -> None:
logger.info("ThreadRegistry: reattaching threads for server %d", server_id)
self._start_server_threads(server_id, db)
def _stop_all(self) -> None:
server_ids = list(self._bundles.keys())
for server_id in server_ids:
self._stop_server_threads(server_id)
logger.info("ThreadRegistry: all threads stopped")
def get_thread_count(self, server_id: int) -> int:
"""Returns the number of running threads for a server."""
bundle = self._bundles.get(server_id)
if bundle is None:
return 0
return sum(
1
for key in ("log_tail", "metrics", "monitor", "rcon_poller")
if bundle.get(key) is not None and bundle[key].is_alive()
)
# ── Bundle construction ──
def _build_bundle(self, server_id: int, db) -> dict:
"""Reads server + config data from DB and constructs (but does not start) the thread bundle."""
server_repo = ServerRepository(db)
config_repo = ConfigRepository(db)
server = server_repo.get_by_id(server_id)
if server is None:
raise ValueError(f"Server {server_id} not found in database")
game_type = server["game_type"]
adapter = self._adapter_registry.get(game_type)
# Log path: RPT files live next to the server exe, not in the languard data dir
log_path = None
if adapter.has_capability("log_parser"):
log_parser = adapter.get_log_parser()
from pathlib import Path
exe_dir = Path(server["exe_path"]).parent
resolver = log_parser.get_log_file_resolver(server_id)
resolved = resolver(exe_dir)
if resolved is not None:
log_path = str(resolved)
bundle: dict = {
"log_tail": None,
"metrics": None,
"monitor": None,
"rcon_poller": None,
}
# Always: ProcessMonitorThread
bundle["monitor"] = ProcessMonitorThread(
server_id=server_id,
process_manager=self._process_manager,
broadcast_queue=self._broadcast_queue,
)
# Always: MetricsCollectorThread
bundle["metrics"] = MetricsCollectorThread(
server_id=server_id,
process_manager=self._process_manager,
broadcast_queue=self._broadcast_queue,
)
# Conditional: LogTailThread
if log_path and adapter.has_capability("log_parser"):
log_parser = adapter.get_log_parser()
bundle["log_tail"] = LogTailThread(
server_id=server_id,
log_path=log_path,
log_parser=log_parser,
broadcast_queue=self._broadcast_queue,
)
# Conditional: RemoteAdminPollerThread
if adapter.has_capability("remote_admin"):
remote_admin = adapter.get_remote_admin()
if remote_admin is not None:
# Get RCon password from config
rcon_password = self._get_remote_admin_password(server_id, config_repo)
if rcon_password:
try:
rcon_port = server.get("rcon_port") or server.get("game_port", 0) + 1
client = remote_admin.create_client(
host="127.0.0.1",
port=rcon_port,
password=rcon_password,
)
bundle["rcon_poller"] = RemoteAdminPollerThread(
server_id=server_id,
remote_admin_client=client,
broadcast_queue=self._broadcast_queue,
)
except Exception as exc:
logger.warning(
"ThreadRegistry: could not create RCon client for server %d: %s",
server_id, exc,
)
return bundle
def _start_bundle(self, server_id: int, bundle: dict) -> None:
started = []
for key in ("monitor", "metrics", "log_tail", "rcon_poller"):
thread = bundle.get(key)
if thread is not None:
thread.start()
started.append(key)
logger.info("ThreadRegistry: started threads for server %d: %s", server_id, started)
def _stop_bundle(self, server_id: int, bundle: dict) -> None:
for key in ("rcon_poller", "log_tail", "metrics", "monitor"):
thread = bundle.get(key)
if thread is not None and thread.is_alive():
thread.stop_and_join(timeout=5.0)
logger.info("ThreadRegistry: stopped all threads for server %d", server_id)
# ── Helpers ──
def _get_remote_admin_password(
self, server_id: int, config_repo: ConfigRepository
) -> str | None:
"""Read the RCon password from the rcon config section."""
# Need to decrypt sensitive fields
from adapters.registry import GameAdapterRegistry
try:
server = ServerRepository(config_repo._db).get_by_id(server_id)
if server is None:
return None
adapter = self._adapter_registry.get(server["game_type"])
config_gen = adapter.get_config_generator()
sensitive = config_gen.get_sensitive_fields("rcon") if "rcon" in config_gen.get_sections() else []
except Exception as exc:
logger.debug("Could not determine sensitive fields for RCon config: %s", exc)
sensitive = []
rcon_section = config_repo.get_section(server_id, "rcon", sensitive)
if rcon_section is None:
return None
return rcon_section.get("password") or None

View File

@@ -0,0 +1 @@
"""Core utility modules."""

View File

@@ -0,0 +1,32 @@
"""Field-level encryption using Fernet (AES-256)."""
from __future__ import annotations
from cryptography.fernet import Fernet
_fernet: Fernet | None = None
def get_fernet() -> Fernet:
global _fernet
if _fernet is None:
from config import settings
_fernet = Fernet(settings.encryption_key.encode())
return _fernet
def encrypt(plaintext: str) -> str:
"""Encrypt plaintext string. Returns 'encrypted:<base64-token>'."""
token = get_fernet().encrypt(plaintext.encode()).decode()
return f"encrypted:{token}"
def decrypt(ciphertext: str) -> str:
"""Decrypt 'encrypted:<token>' string. Returns plaintext."""
if not ciphertext.startswith("encrypted:"):
return ciphertext # Not encrypted, return as-is
token = ciphertext[len("encrypted:"):]
return get_fernet().decrypt(token.encode()).decode()
def is_encrypted(value: str) -> bool:
return isinstance(value, str) and value.startswith("encrypted:")

View File

@@ -0,0 +1,77 @@
"""Game-agnostic file operations."""
from __future__ import annotations
import re
from pathlib import Path
from typing import Callable
def get_server_dir(server_id: int) -> Path:
"""Return the absolute directory path for a server's data."""
from config import settings
base = Path(settings.servers_dir).resolve()
return base / str(server_id)
def ensure_server_dirs(
server_id: int,
layout: list[str] | None = None,
readme_provider: Callable[[str], str | None] | None = None,
) -> None:
"""
Create servers/{id}/ and any subdirectories from adapter layout.
If readme_provider is given, writes README.txt into each subdir (skips if file already exists).
"""
server_dir = get_server_dir(server_id)
server_dir.mkdir(parents=True, exist_ok=True)
if layout:
for subdir in layout:
subdir_path = server_dir / subdir
subdir_path.mkdir(parents=True, exist_ok=True)
if readme_provider:
content = readme_provider(subdir)
if content:
readme_path = subdir_path / "README.txt"
if not readme_path.exists():
readme_path.write_text(content, encoding="utf-8")
def safe_delete_file(path: Path) -> bool:
"""Delete a file if it exists. Returns True if deleted."""
try:
path.unlink(missing_ok=True)
return True
except OSError:
return False
def sanitize_filename(filename: str) -> str:
"""
Sanitize a filename for safe disk storage.
Rules:
- Strip path separators (/ \\ and ..)
- Allow only alphanumeric, dots, hyphens, underscores, @ signs
- Collapse consecutive dots (prevent ../ tricks)
- Truncate to 255 characters
- Raise ValueError if the result is empty
"""
# Take only the basename — strip any directory components
filename = filename.replace("\\", "/").split("/")[-1]
# Remove null bytes and control characters
filename = re.sub(r"[\x00-\x1f\x7f]", "", filename)
# Allow only safe characters: alphanum, dot, hyphen, underscore, @
filename = re.sub(r"[^\w.\-@]", "_", filename)
# Collapse consecutive dots to prevent tricks like ".../.."
filename = re.sub(r"\.{2,}", ".", filename)
# Truncate
filename = filename[:255]
if not filename or filename in (".", ".."):
raise ValueError(f"Filename '{filename}' is not safe for storage")
return filename

View File

@@ -0,0 +1,87 @@
"""Game-agnostic port availability checking."""
from __future__ import annotations
import logging
import socket
logger = logging.getLogger(__name__)
def is_port_in_use(port: int, host: str = "127.0.0.1") -> bool:
"""Return True if the port is already bound."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(0.5)
try:
s.bind((host, port))
return False
except OSError:
return True
def check_server_ports_available(
game_port: int,
rcon_port: int | None = None,
host: str = "127.0.0.1",
port_conventions: dict[str, int] | None = None,
) -> list[int]:
"""
Check all ports for a server instance.
If port_conventions is provided (from adapter), checks all derived ports.
Returns list of ports that are already in use (empty = all available).
"""
ports_to_check: set[int] = set()
if port_conventions:
ports_to_check.update(port_conventions.values())
else:
ports_to_check.add(game_port)
if rcon_port is not None:
ports_to_check.add(rcon_port)
return [p for p in sorted(ports_to_check) if is_port_in_use(p, host)]
def check_ports_against_running_servers(
new_server_game_port: int,
new_server_rcon_port: int | None,
exclude_server_id: int | None,
db,
) -> list[int]:
"""
Cross-game port conflict detection.
Checks new server's full port set against all running servers' full port sets.
Returns list of conflicting ports.
"""
from adapters.registry import GameAdapterRegistry
from sqlalchemy import text
rows = db.execute(
text("SELECT id, game_type, game_port, rcon_port FROM servers WHERE status IN ('running','starting')")
).fetchall()
occupied_ports: set[int] = set()
for row in rows:
if exclude_server_id and row[0] == exclude_server_id:
continue
try:
adapter = GameAdapterRegistry.get(row[1])
conventions = adapter.get_process_config().get_port_conventions(row[2])
occupied_ports.update(conventions.values())
except KeyError:
logger.debug("Unknown game type '%s', falling back to game_port only", row[1])
occupied_ports.add(row[2])
if row[3] is not None:
occupied_ports.add(row[3])
# Check new server's ports against occupied set
try:
adapter = GameAdapterRegistry.get("arma3") # temporary — will be passed in
except KeyError:
logger.debug("No 'arma3' adapter for port conventions, using defaults")
new_ports: set[int] = {new_server_game_port}
if new_server_rcon_port:
new_ports.add(new_server_rcon_port)
return sorted(new_ports & occupied_ports)

View File

@@ -0,0 +1,4 @@
from core.websocket.manager import WebSocketManager
from core.websocket.broadcast_thread import BroadcastThread
__all__ = ["WebSocketManager", "BroadcastThread"]

View File

@@ -0,0 +1,116 @@
"""
BroadcastThread — the single bridge between OS threads and asyncio WebSocket world.
Reads events from a queue.Queue (written by background server threads) and
forwards them to the WebSocketManager running in the asyncio event loop.
Design:
- Runs as a daemon thread — no cleanup needed on shutdown.
- queue.Queue is thread-safe — multiple producer threads, single consumer.
- asyncio.run_coroutine_threadsafe() schedules the WebSocketManager.broadcast()
coroutine on the event loop from this non-asyncio thread.
- If the event loop is closed or the broadcast fails, the event is dropped silently.
Queue item format (dict):
{
"type": str, # "log", "metrics", "players", "server_status", etc.
"server_id": int, # Which server this event belongs to
"data": dict | list, # Payload — varies by type
}
"""
from __future__ import annotations
import asyncio
import logging
import queue
import threading
logger = logging.getLogger(__name__)
_QUEUE_GET_TIMEOUT = 1.0
_DROP_LOG_THRESHOLD = 100
class BroadcastThread(threading.Thread):
"""
Bridge from thread-world to asyncio-world.
Args:
event_queue: The shared queue.Queue that all background threads write to.
ws_manager: The WebSocketManager instance (asyncio-side).
loop: The asyncio event loop running in the main thread.
"""
def __init__(
self,
event_queue: queue.Queue,
ws_manager, # WebSocketManager — type annotation omitted to avoid circular import
loop: asyncio.AbstractEventLoop,
) -> None:
super().__init__(name="BroadcastThread", daemon=True)
self._queue = event_queue
self._ws_manager = ws_manager
self._loop = loop
self._stop_event = threading.Event()
self._dropped = 0
def stop(self) -> None:
self._stop_event.set()
def run(self) -> None:
logger.info("BroadcastThread: started")
while not self._stop_event.is_set():
try:
item = self._queue.get(timeout=_QUEUE_GET_TIMEOUT)
except queue.Empty:
continue
self._forward(item)
# Drain remaining items on shutdown
while not self._queue.empty():
try:
item = self._queue.get_nowait()
self._forward(item)
except queue.Empty:
break
logger.info("BroadcastThread: stopped")
def _forward(self, item: dict) -> None:
"""Schedule a broadcast on the asyncio event loop."""
if self._loop.is_closed():
self._dropped += 1
if self._dropped % _DROP_LOG_THRESHOLD == 0:
logger.warning(
"BroadcastThread: event loop closed, dropped %d messages",
self._dropped,
)
return
server_id = item.get("server_id")
event_type = item.get("type", "unknown")
data = item.get("data", {})
message = {
"type": event_type,
"server_id": server_id,
"data": data,
}
try:
future = asyncio.run_coroutine_threadsafe(
self._ws_manager.broadcast(server_id, message),
self._loop,
)
# Fire and forget — suppress unhandled exception warnings
future.add_done_callback(self._on_broadcast_done)
except RuntimeError as exc:
logger.debug("BroadcastThread: could not schedule broadcast: %s", exc)
def _on_broadcast_done(self, future) -> None:
"""Called when the broadcast coroutine completes. Log exceptions only."""
try:
future.result()
except Exception as exc:
logger.debug("BroadcastThread: broadcast error: %s", exc)

View File

@@ -0,0 +1,96 @@
"""
WebSocketManager — asyncio-side manager for WebSocket connections.
All methods are coroutines and must be called from the asyncio event loop.
No locking needed — the event loop is single-threaded.
Subscription model:
- Each connection subscribes to zero or more server_ids.
- Subscribing to server_id=None means "all servers".
- broadcast(server_id, message) sends to all clients subscribed to that server_id
plus all clients subscribed to None (global subscribers).
"""
from __future__ import annotations
import json
import logging
from typing import Optional
from fastapi import WebSocket
logger = logging.getLogger(__name__)
class WebSocketManager:
"""Manages active WebSocket connections and delivers broadcast messages."""
def __init__(self) -> None:
# Maps WebSocket -> set of subscribed server_ids (None = all)
self._connections: dict[WebSocket, set[Optional[int]]] = {}
# ── Connection lifecycle ──
async def connect(self, ws: WebSocket, server_ids: Optional[list[int]] = None) -> None:
"""
Accept a WebSocket connection and register it.
Args:
ws: The FastAPI WebSocket instance.
server_ids: List of server IDs to subscribe to, or None for all.
"""
await ws.accept()
subscriptions: set[Optional[int]] = set(server_ids) if server_ids else {None}
self._connections[ws] = subscriptions
logger.info(
"WebSocketManager: client connected, subscriptions=%s, total=%d",
subscriptions,
len(self._connections),
)
async def disconnect(self, ws: WebSocket) -> None:
"""Remove a disconnected WebSocket."""
self._connections.pop(ws, None)
logger.info(
"WebSocketManager: client disconnected, total=%d",
len(self._connections),
)
# ── Broadcast ──
async def broadcast(self, server_id: Optional[int], message: dict) -> None:
"""
Send a message to all clients subscribed to the given server_id.
Also sends to clients subscribed to None (global subscribers).
Disconnected clients are removed automatically.
"""
if not self._connections:
return
payload = json.dumps(message)
disconnected = []
for ws, subscriptions in self._connections.items():
if None in subscriptions or server_id in subscriptions:
try:
await ws.send_text(payload)
except Exception as exc:
logger.debug("WebSocketManager: send failed, marking disconnected: %s", exc)
disconnected.append(ws)
for ws in disconnected:
await self.disconnect(ws)
async def send_to_connection(self, ws: WebSocket, message: dict) -> None:
"""Send a message to a single specific connection."""
try:
await ws.send_text(json.dumps(message))
except Exception as exc:
logger.debug("WebSocketManager: direct send failed, disconnecting: %s", exc)
await self.disconnect(ws)
# ── Stats ──
@property
def connection_count(self) -> int:
return len(self._connections)

View File

@@ -0,0 +1,90 @@
"""
WebSocket endpoint.
URL: /ws
/ws?server_id=1
/ws?server_id=1&server_id=2
Authentication: JWT passed as a query parameter `token` because
browser WebSocket API does not support custom headers.
If the token is missing or invalid, the connection is closed with code 4001.
After authentication, the client receives:
- A "connected" welcome message with the list of subscribed server IDs
- All events for subscribed servers pushed by BroadcastThread
"""
from __future__ import annotations
import logging
from typing import Optional
from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
from core.auth.utils import decode_access_token
logger = logging.getLogger(__name__)
router = APIRouter(tags=["websocket"])
@router.websocket("/ws")
async def websocket_endpoint(
ws: WebSocket,
token: Optional[str] = Query(default=None),
server_id: Optional[list[int]] = Query(default=None),
) -> None:
"""
WebSocket endpoint for real-time server events.
Query parameters:
token: JWT access token (required)
server_id: One or more server IDs to subscribe to (optional, default=all)
"""
# Authenticate before accepting
if not token:
await ws.close(code=4001, reason="Missing token")
return
try:
user = decode_access_token(token)
except Exception as exc:
logger.warning("WebSocket: token decode failed: %s", exc)
user = None
if user is None:
await ws.close(code=4001, reason="Invalid or expired token")
return
# Get WebSocketManager from app state
ws_manager = ws.app.state.ws_manager
await ws_manager.connect(ws, server_ids=server_id)
logger.info(
"WebSocket: user '%s' connected, subscribed to servers=%s",
user.get("sub"),
server_id,
)
try:
# Send welcome message
await ws_manager.send_to_connection(ws, {
"type": "connected",
"data": {
"user": user.get("sub"),
"subscriptions": server_id or "all",
},
})
# Keep connection alive — wait for client to disconnect
while True:
data = await ws.receive_text()
except WebSocketDisconnect:
logger.info(
"WebSocket: user '%s' disconnected",
user.get("sub"),
)
except Exception as exc:
logger.error("WebSocket: unexpected error: %s", exc)
finally:
await ws_manager.disconnect(ws)

114
backend/database.py Normal file
View File

@@ -0,0 +1,114 @@
"""SQLAlchemy engine setup, migration runner, and session helpers."""
from __future__ import annotations
import logging
import threading
from pathlib import Path
from sqlalchemy import create_engine, event, text
from sqlalchemy.engine import Connection, Engine
logger = logging.getLogger(__name__)
_engine: Engine | None = None
_thread_local = threading.local()
def get_engine() -> Engine:
global _engine
if _engine is not None:
return _engine
from config import settings
db_path = Path(settings.db_path).resolve()
db_path.parent.mkdir(parents=True, exist_ok=True)
_engine = create_engine(
f"sqlite:///{db_path}",
connect_args={"check_same_thread": False},
echo=False,
)
# Apply pragmas on every new connection
@event.listens_for(_engine, "connect")
def set_sqlite_pragma(dbapi_conn, connection_record):
cursor = dbapi_conn.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA foreign_keys=ON")
cursor.execute("PRAGMA busy_timeout=5000")
cursor.close()
return _engine
def get_db():
"""FastAPI dependency. Yields a SQLAlchemy Connection, closes after request."""
engine = get_engine()
with engine.connect() as conn:
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
def get_thread_db() -> Connection:
"""
Return a thread-local DB connection for background threads.
Each thread gets its own connection (SQLite requires this).
Call conn.close() in thread teardown.
"""
if not hasattr(_thread_local, "conn") or _thread_local.conn is None:
_thread_local.conn = get_engine().connect()
return _thread_local.conn
def run_migrations(engine: Engine) -> None:
"""Apply all pending SQL migration files in order."""
migrations_dir = Path(__file__).parent / "core" / "migrations"
migration_files = sorted(migrations_dir.glob("*.sql"))
with engine.connect() as conn:
# Ensure tracking table exists
conn.execute(text("""
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
)
"""))
conn.commit()
applied = {
row[0] for row in conn.execute(
text("SELECT version FROM schema_migrations")
)
}
for mfile in migration_files:
# Extract version number from filename: 001_initial.sql -> 1
version_str = mfile.name.split("_")[0]
try:
version = int(version_str)
except ValueError:
logger.warning("Skipping migration with non-numeric prefix: %s", mfile.name)
continue
if version in applied:
continue
logger.info("Applying migration: %s", mfile.name)
sql = mfile.read_text(encoding="utf-8")
# Execute each statement separately (SQLite doesn't support executescript in transactions)
for statement in sql.split(";"):
statement = statement.strip()
if statement:
conn.execute(text(statement))
conn.execute(
text("INSERT INTO schema_migrations (version) VALUES (:v)"),
{"v": version},
)
conn.commit()
logger.info("Migration %d applied.", version)

86
backend/dependencies.py Normal file
View File

@@ -0,0 +1,86 @@
"""Reusable FastAPI dependencies."""
from __future__ import annotations
import logging
from typing import Annotated
from fastapi import Depends, Header, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError
from sqlalchemy.engine import Connection
from core.auth.utils import decode_access_token
from database import get_db
logger = logging.getLogger(__name__)
_security = HTTPBearer()
def get_current_user(
credentials: Annotated[HTTPAuthorizationCredentials, Depends(_security)],
db: Annotated[Connection, Depends(get_db)],
) -> dict:
"""Decode JWT and return user dict. Raises 401 on any failure."""
token = credentials.credentials
try:
payload = decode_access_token(token)
except JWTError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "UNAUTHORIZED", "message": "Invalid or expired token"},
)
# Optionally verify user still exists in DB
from core.dal.base_repository import BaseRepository
from sqlalchemy import text
row = db.execute(
text("SELECT id, username, role FROM users WHERE id = :id"),
{"id": int(payload["sub"])},
).fetchone()
if row is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"code": "UNAUTHORIZED", "message": "User not found"},
)
return dict(row._mapping)
def require_admin(
user: Annotated[dict, Depends(get_current_user)],
) -> dict:
"""Raise 403 if user is not admin."""
if user["role"] != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"code": "FORBIDDEN", "message": "Admin role required"},
)
return user
def get_server_or_404(server_id: int, db: Connection) -> dict:
"""Load server by ID or raise 404."""
from sqlalchemy import text
row = db.execute(
text("SELECT * FROM servers WHERE id = :id"), {"id": server_id}
).fetchone()
if row is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"code": "NOT_FOUND", "message": f"Server {server_id} not found"},
)
return dict(row._mapping)
def get_adapter_for_server(server_id: int, db: Connection):
"""Load server and resolve its adapter. Raises 404 if server not found."""
server = get_server_or_404(server_id, db)
from adapters.registry import GameAdapterRegistry
try:
return GameAdapterRegistry.get(server["game_type"])
except KeyError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={
"code": "GAME_TYPE_NOT_FOUND",
"message": f"No adapter for game type '{server['game_type']}'",
},
)

205
backend/main.py Normal file
View File

@@ -0,0 +1,205 @@
"""
FastAPI application factory.
Entry point: uvicorn main:app --reload
"""
from __future__ import annotations
import asyncio
import logging
import queue
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address
from config import settings
logging.basicConfig(
level=getattr(logging, settings.log_level.upper(), logging.INFO),
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
limiter = Limiter(key_func=get_remote_address)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Startup + shutdown logic."""
# ── Startup ──
logger.info("Starting Languard...")
# 1. Init DB and run migrations
from database import get_engine, run_migrations
engine = get_engine()
run_migrations(engine)
# 2. Register adapters
from adapters import initialize_adapters
initialize_adapters()
# 3. Create WebSocket manager (asyncio-only)
from core.websocket.manager import WebSocketManager
ws_manager = WebSocketManager()
app.state.ws_manager = ws_manager
# 4. Create global broadcast queue and BroadcastThread
broadcast_queue = queue.Queue(maxsize=1000)
app.state.broadcast_queue = broadcast_queue
from core.websocket.broadcast_thread import BroadcastThread
loop = asyncio.get_event_loop()
broadcast_thread = BroadcastThread(
event_queue=broadcast_queue,
ws_manager=ws_manager,
loop=loop,
)
broadcast_thread.start()
app.state.broadcast_thread = broadcast_thread
# 5. Create ThreadRegistry
from core.threads.thread_registry import ThreadRegistry
from core.servers.process_manager import ProcessManager
from adapters.registry import GameAdapterRegistry
process_manager = ProcessManager.get()
thread_registry = ThreadRegistry(
process_manager=process_manager,
adapter_registry=GameAdapterRegistry,
global_broadcast_queue=broadcast_queue,
)
ThreadRegistry.set_instance(thread_registry)
app.state.thread_registry = thread_registry
# 6. Recover processes that survived a restart
process_manager.recover_on_startup(engine.connect())
# 7. Reattach threads for running servers
from core.dal.server_repository import ServerRepository
with engine.connect() as db:
server_repo = ServerRepository(db)
running_servers = server_repo.get_running()
for server in running_servers:
try:
thread_registry.reattach_server_threads(server["id"], db)
logger.info("Reattached threads for server %d", server["id"])
except Exception as exc:
logger.error("Failed to reattach threads for server %d: %s", server["id"], exc)
# 8. Backfill server directory scaffold for existing servers (idempotent)
from core.dal.server_repository import ServerRepository as _ServerRepo
from core.utils.file_utils import ensure_server_dirs as _ensure_dirs
from adapters.registry import GameAdapterRegistry as _Registry
with engine.connect() as db:
for server in _ServerRepo(db).get_all():
try:
_adapter = _Registry.get(server["game_type"])
_pc = _adapter.get_process_config()
_ensure_dirs(
server["id"],
_pc.get_server_dir_layout(),
readme_provider=getattr(_pc, "get_dir_readme", None),
)
except Exception as exc:
logger.warning("Dir scaffold failed for server %d: %s", server["id"], exc)
# 9. Seed default admin if no users exist
from core.auth.service import AuthService
with engine.connect() as db:
svc = AuthService(db)
generated_password = svc.seed_admin_if_empty()
db.commit()
if generated_password:
logger.warning("=" * 60)
logger.warning(" FIRST RUN — default admin created")
logger.warning(" Username: admin")
logger.warning(" Password: %s", generated_password)
logger.warning(" Change this password immediately!")
logger.warning("=" * 60)
# 10. Register and start APScheduler cleanup jobs
from core.jobs.scheduler import start_scheduler, stop_scheduler
from core.jobs.cleanup_jobs import register_cleanup_jobs
register_cleanup_jobs()
start_scheduler()
yield
# ── Shutdown ──
logger.info("Shutting down Languard...")
try:
ThreadRegistry.stop_all()
except Exception as e:
logger.error("Thread shutdown error: %s", e)
broadcast_thread.stop()
broadcast_thread.join(timeout=5.0)
from core.jobs.scheduler import stop_scheduler
stop_scheduler()
def create_app() -> FastAPI:
app = FastAPI(
title="Languard Server Manager",
version="1.0.0",
lifespan=lifespan,
docs_url="/docs",
redoc_url="/redoc",
)
# ── Middleware ──
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ── Global exception handler ──
@app.exception_handler(Exception)
async def generic_exception_handler(request: Request, exc: Exception):
logger.error("Unhandled exception: %s", exc, exc_info=True)
return JSONResponse(
status_code=500,
content={
"success": False,
"data": None,
"error": {"code": "INTERNAL_ERROR", "message": "An unexpected error occurred"},
},
)
# ── Routers ──
from core.auth.router import router as auth_router
from core.games.router import router as games_router
from core.system.router import router as system_router
from core.servers.router import router as servers_router
from core.servers.players_router import router as players_router
from core.servers.bans_router import router as bans_router
from core.servers.missions_router import router as missions_router
from core.servers.mods_router import router as mods_router
from core.servers.logfiles_router import router as logfiles_router
from core.websocket.router import router as ws_router
app.include_router(auth_router, prefix="/api")
app.include_router(games_router, prefix="/api")
app.include_router(system_router, prefix="/api")
app.include_router(servers_router, prefix="/api")
app.include_router(players_router, prefix="/api")
app.include_router(bans_router, prefix="/api")
app.include_router(missions_router, prefix="/api")
app.include_router(mods_router, prefix="/api")
app.include_router(logfiles_router, prefix="/api")
app.include_router(ws_router)
return app
app = create_app()

50
backend/requirements.txt Normal file
View File

@@ -0,0 +1,50 @@
annotated-doc==0.0.4
annotated-types==0.7.0
anyio==4.13.0
APScheduler==3.11.2
bcrypt==5.0.0
certifi==2026.2.25
cffi==2.0.0
click==8.3.2
colorama==0.4.6
cryptography==46.0.7
Deprecated==1.3.1
ecdsa==0.19.2
fastapi==0.135.3
greenlet==3.4.0
h11==0.16.0
httpcore==1.0.9
httptools==0.7.1
httpx==0.28.1
idna==3.11
iniconfig==2.3.0
limits==5.8.0
packaging==26.1
passlib==1.7.4
pluggy==1.6.0
psutil==7.2.2
pyasn1==0.6.3
pycparser==3.0
pydantic==2.13.1
pydantic-settings==2.13.1
pydantic_core==2.46.1
Pygments==2.20.0
pytest==9.0.3
pytest-asyncio==1.3.0
python-dotenv==1.2.2
python-jose==3.5.0
python-multipart==0.0.26
PyYAML==6.0.3
rsa==4.9.1
six==1.17.0
slowapi==0.1.9
SQLAlchemy==2.0.49
starlette==1.0.0
typing-inspection==0.4.2
typing_extensions==4.15.0
tzdata==2026.1
tzlocal==5.3.1
uvicorn==0.44.0
watchfiles==1.1.1
websockets==16.0
wrapt==2.1.2

View File

View File

View File

View File

@@ -0,0 +1,89 @@
"""Tests for Arma3ConfigGenerator.get_ui_schema() — advanced flag completeness."""
import pytest
from adapters.arma3.config_generator import Arma3ConfigGenerator
BASIC_FIELDS = {
"server": {
"hostname", "max_players", "password", "password_admin",
"motd_lines", "motd_interval",
"forced_difficulty", "auto_select_mission", "random_mission_order",
"persistent", "skip_lobby", "drawing_in_map",
"battleye", "verify_signatures", "allowed_file_patching",
"disable_von", "von_codec",
},
"profile": {
"group_indicators", "friendly_tags", "enemy_tags",
"commands", "waypoints", "weapon_info", "stance_indicator",
"ai_level_preset", "skill_ai", "precision_ai",
},
"rcon": {"rcon_password", "enabled"},
}
ADVANCED_SAMPLES = {
"server": {
"server_command_password", "kick_duplicate", "vote_threshold",
"max_ping", "max_packet_loss", "disconnect_timeout",
"kick_on_ping", "log_file", "upnp", "loopback",
"admin_uids", "headless_clients", "local_clients",
"default_mission_params",
},
"basic": {
"min_bandwidth", "max_bandwidth", "max_msg_send",
"max_size_guaranteed", "min_error_to_send",
},
"profile": {
"reduced_damage", "tactical_ping", "weapon_crosshair",
"vision_aid", "third_person_view", "score_table",
"death_messages", "von_id",
},
"launch": {
"world", "limit_fps", "cpu_count", "max_mem",
"enable_ht", "huge_pages", "no_logs", "netlog",
},
"rcon": {"max_ping"},
}
@pytest.fixture
def schema():
return Arma3ConfigGenerator().get_ui_schema()
def test_every_visible_field_has_advanced_key(schema):
"""Every non-hidden field must carry an explicit `advanced` bool."""
for section, fields in schema.items():
for field, entry in fields.items():
if entry.get("widget") == "hidden":
continue
assert "advanced" in entry, (
f"section={section!r} field={field!r} is missing 'advanced' key"
)
def test_basic_fields_are_not_advanced(schema):
"""Confirmed basic fields must have advanced=False."""
for section, field_names in BASIC_FIELDS.items():
for field in field_names:
entry = schema[section][field]
assert entry["advanced"] is False, (
f"section={section!r} field={field!r} should be basic (advanced=False)"
)
def test_advanced_samples_are_marked_advanced(schema):
"""Sampled advanced fields must have advanced=True."""
for section, field_names in ADVANCED_SAMPLES.items():
for field in field_names:
entry = schema[section][field]
assert entry["advanced"] is True, (
f"section={section!r} field={field!r} should be advanced (advanced=True)"
)
def test_hidden_fields_excluded_from_advanced_requirement(schema):
"""Hidden fields (e.g. missions) are exempt from the advanced check."""
for section, fields in schema.items():
for field, entry in fields.items():
if entry.get("widget") == "hidden":
# No advanced key required — just confirm widget is hidden
assert entry["widget"] == "hidden"

155
docs/ANALYSIS.md Normal file
View File

@@ -0,0 +1,155 @@
# Arma Server Web Admin — Full Analysis
## Project Overview
**Arma Server Web Admin** is a Node.js/Express web application providing a browser-based administration panel for managing one or more Arma game server instances (Arma 1/2/2OA/3, CWA, OFP).
It consolidates all day-to-day server management tasks into a single UI:
- Launch and stop game server processes
- Monitor live player counts, current mission, and server state
- Upload and rotate missions (.pbo files, including Steam Workshop)
- Discover and assign mods per server
- Browse and download server log files
- Configure every server setting (ports, passwords, difficulty, mods, MOTD, etc.)
- Deploy headless clients automatically alongside the server process
---
## Technology Stack
| Layer | Technology |
|-------|-----------|
| HTTP server | Node.js + Express.js |
| Real-time | Socket.IO 2.x |
| Game process | arma-server (Node wrapper) |
| Game query | Gamedig |
| Steam Workshop | steam-workshop |
| File helpers | fs.extra, glob, multer |
| Async control | async 2.x |
| Frontend SPA | Backbone.js + Marionette.js |
| Templating | Underscore.js |
| UI framework | Bootstrap 3 |
| Build | Webpack v1 |
| Auth | express-basic-auth (optional) |
---
## Directory Structure
```
arma-server-web-admin/
├── app.js # Entry point: Express + Socket.IO + route wiring
├── config.js.example # All configuration options with defaults
├── config.docker.js # Docker-specific config overrides
├── webpack.config.js # Frontend bundling
├── package.json
├── lib/ # Backend business logic
│ ├── manager.js # Multi-server lifecycle manager (EventEmitter)
│ ├── server.js # Single server wrapper (start/stop/query/persist)
│ ├── missions.js # Mission file discovery, upload, Workshop download
│ ├── logs.js # Log file discovery, cleanup, platform paths
│ ├── settings.js # Config accessor (public subset for client)
│ ├── setup-basic-auth.js # Optional HTTP Basic Auth middleware
│ └── mods/
│ ├── index.js # Mod discovery + parallel metadata resolution
│ ├── folderSize.js # Recursive folder size calculator
│ ├── modFile.js # Parses mod.cpp for display name
│ └── steamMeta.js # Parses meta.cpp for Steam Workshop ID
├── routes/ # Express routers (each returns a router factory)
│ ├── servers.js # /api/servers/* (CRUD + start/stop)
│ ├── missions.js # /api/missions/* (list/upload/download/delete)
│ ├── mods.js # /api/mods/* (list/delete)
│ ├── logs.js # /api/logs/* (list/view/download/delete)
│ └── settings.js # /api/settings (GET public settings)
└── public/ # Single-page frontend (built by Webpack)
├── index.html
├── css/styles.css
└── js/
├── app.js # RequireJS bootstrap + Socket.IO init
└── app/
├── router.js # Backbone Router (5 routes)
├── models/ # Backbone Models
├── collections/ # Backbone Collections
└── views/ # Marionette views
```
---
## Feature Inventory
### Server Lifecycle
- Create / edit / delete server definitions persisted to `servers.json`
- Start / stop server processes (Windows `.exe`, Linux binary, Wine)
- Auto-start servers on application launch (`auto_start` flag)
- Process ID tracking
- 5-second polling via Gamedig for live state (players, mission, status)
### Configuration
- Title, port, max players
- Player password, admin password
- Message of the Day (MOTD, multi-line)
- BattleEye, VoN, signature verification, file patching, persistent mode
- Difficulty override (Recruit / Regular / Veteran / Custom)
- Per-server additional `server.cfg` text (freeform)
- Per-server startup parameters (e.g. `-limitFPS=100`)
- Per-server mod selection
- Per-server mission rotation with per-mission difficulty
### Mission Management
- List `.pbo` files from `mpmissions/`
- Upload up to 64 `.pbo` files at once (multipart)
- Download from Steam Workshop by ID
- Download mission files to browser
- Delete mission files
- View name, world name, file size, timestamps
### Mod Management
- Auto-discover mods via glob patterns (`@*`, `csla`, `ef`, etc.)
- Extract display name from `mod.cpp`
- Extract Steam Workshop ID from `meta.cpp`
- Calculate folder sizes recursively (symlink-aware)
- Delete mod folders
- Assign mods per server (split-pane UI)
### Log Management
- Discover `.rpt` log files from platform-appropriate paths:
- **Windows:** `AppData\Local\[Game]\`
- **Linux:** `[game_path]/logs/`
- **Wine:** `.wine/drive_c/users/.../Application Data/[Game]/`
- View log contents inline
- Download logs
- Delete logs
- Auto-cleanup: retain only the 20 newest log files
### Headless Clients
- Configure number of headless clients per server
- Auto-launch when server starts, auto-kill when server stops
- Connect to `127.0.0.1:[server_port]`
### Real-Time Updates
- Socket.IO push for: server state, mission list, mod list, settings
- All connected clients receive updates simultaneously
### Authentication
- Optional HTTP Basic Auth (single or multiple users)
- Credentials in `config.js`; no database
### Platform Support
- Windows, Linux, Wine, Docker
---
## Data Persistence
| Data | Storage |
|------|---------|
| Server definitions | `servers.json` (plain JSON, in-memory on load) |
| Mission files | `[game_path]/mpmissions/` (filesystem) |
| Mod files | `[game_path]/[mod_dirs]/` (filesystem) |
| Log files | Platform-specific log directory |
| App config | `config.js` (static, not written at runtime) |
No database. All runtime state lives in the `Manager` class and is flushed to `servers.json` on every mutation.

247
docs/CHERRY_PICK.md Normal file
View File

@@ -0,0 +1,247 @@
# Cherry-Pick Candidates for languard-servers-manager
This document lists modules and functions from `arma-server-web-admin` that are worth adapting into `languard-servers-manager`. Each entry explains what the code does, why it is valuable, and a concrete adapter strategy.
Target project: `E:\TestScript\languard-servers-manager` (FastAPI + React stack).
---
## 1. Manager Pattern — Multi-Server Lifecycle Registry
**Source:** `lib/manager.js`
**Key functions:** `load()`, `save()`, `addServer()`, `removeServer()`, `getServer()`, `getServers()`
**What it does:**
Maintains an in-memory registry of Server instances (both as an ordered array and a hash for O(1) lookup), flushes state to `servers.json` on every mutation, and emits change events so the Socket.IO layer can broadcast diffs.
**Why it is valuable:**
languard already has a similar concept (servers stored in SQLite), but the EventEmitter pattern that automatically triggers broadcasts on every mutation is clean and decoupled. The dual array+hash storage pattern is also worth copying for cache performance.
**Adapter strategy:**
```
Create: backend/core/servers/manager.py
- ServerManager class with in-memory cache (dict + list)
- on_change callback / asyncio.Event instead of EventEmitter
- load() reads from SQLite via existing ServerRepository
- save() writes back through repository
- Emit WebSocket broadcasts via FastAPI WebSocket manager on every mutation
```
---
## 2. Process Lifecycle — Start / Stop / Kill with Graceful Fallback
**Source:** `lib/server.js``start()`, `stop()` methods
**What it does:**
Spawns a child process for the game server, captures its PID, starts a status-poll interval, pipes stdout/stderr to a log file, and on stop sends SIGTERM with a 5-second SIGKILL fallback. Headless clients are launched/killed alongside the main process.
**Why it is valuable:**
languard's backend already launches processes, but the graceful stop with timed SIGKILL fallback and the headless client co-lifecycle are patterns not yet present.
**Adapter strategy:**
```
Adapt: backend/core/servers/process_manager.py
- async def start_server(config) -> asyncio.subprocess.Process
- async def stop_server(proc, timeout=5) -> SIGTERM then SIGKILL
- Capture PID, store in DB server record
- Pipe stdout/stderr to dated log file (matches existing log path logic)
```
---
## 3. Status Polling — Gamedig-style Periodic Query
**Source:** `lib/server.js``queryStatus()` + `setInterval` pattern
**What it does:**
Every 5 seconds, queries the running game server via the game's UDP query protocol (Gamedig). Stores the response (`players`, `mission`, `state`) in the server's in-memory state and emits it to connected clients.
**Why it is valuable:**
languard's frontend relies on WebSocket events pushed from process stdout. A periodic external query would give accurate player counts and current mission data independent of log output.
**Adapter strategy:**
```
Create: backend/core/servers/status_poller.py
- asyncio.Task per running server (cancel on stop)
- Use python-a2s or opengsq-python to query UDP game port
- QueryAdapter interface: arma3, dayz, etc. as subclasses
- On result: update server record in DB, push via WebSocket manager
- Poll interval configurable (default 5 s)
```
---
## 4. Mod Discovery — Parallel Metadata Resolution
**Source:** `lib/mods/index.js`, `lib/mods/modFile.js`, `lib/mods/steamMeta.js`, `lib/mods/folderSize.js`
**What it does:**
Finds all mod directories via glob, then for each mod concurrently: parses `mod.cpp` for display name, parses `meta.cpp` for Steam Workshop ID, and recursively calculates folder size with symlink-aware deduplication.
**Why it is valuable:**
languard's mod tab lists paths but does not extract Steam IDs, human-readable names, or folder sizes. This pattern handles all three in parallel efficiently.
**Adapter strategy:**
```
Create: backend/core/mods/scanner.py
- async def scan_mods(game_path: str) -> list[ModMeta]
- Use pathlib.glob for discovery
- asyncio.gather for parallel metadata:
parse_mod_file(path) -> ModFileAdapter (reads mod.cpp)
parse_steam_meta(path) -> SteamMetaAdapter (reads meta.cpp)
folder_size(path) -> recursive os.scandir sum
- ModMeta dataclass: { path, name, steam_id, size_bytes }
```
---
## 5. Log File Management — Platform Paths + Auto-Cleanup
**Source:** `lib/logs.js``logsPath()`, `logFiles()`, `cleanupOldLogFiles()`
**What it does:**
Resolves the game log directory based on platform (windows / linux / wine). Lists `.rpt` files with metadata, auto-deletes the oldest files beyond a configurable retention limit (default 20).
**Why it is valuable:**
languard streams real-time logs via WebSocket but has no endpoint to browse historical `.rpt` files on disk. Discovery and cleanup logic is directly reusable.
**Adapter strategy:**
```
Adapt: backend/core/logs/log_manager.py
- def logs_path(platform: str, game_path: str) -> Path
- def list_logs(logs_dir: Path) -> list[LogMeta] (stat each .rpt)
- def cleanup_old_logs(logs_dir: Path, keep: int = 20)
- Expose via:
GET /api/servers/{id}/logfiles
GET /api/servers/{id}/logfiles/{name}/download
DELETE /api/servers/{id}/logfiles/{name}
```
---
## 6. Mission Multi-File Upload + Filename Parsing
**Source:** `lib/missions.js``updateMissions()` + `routes/missions.js` upload handler
**What it does:**
Accepts multipart upload of up to 64 `.pbo` files simultaneously (parallel move with limit 8), scans the `mpmissions/` directory, and extracts `{ name, world }` from the Arma filename convention `missionname.worldname.pbo`.
**Why it is valuable:**
languard's missions tab lists files but does not support drag-and-drop multi-file upload or Steam Workshop download. The `.pbo` filename parsing convention is Arma-specific and worth encoding explicitly.
**Adapter strategy:**
```
Adapt: backend/routers/missions.py
- POST /api/servers/{id}/missions -> UploadFile[] via FastAPI
- Validate .pbo extension server-side (reject others)
- asyncio.gather with semaphore (limit 8): move to mpmissions/
- Parse filename: "name.world.pbo" -> { name, world }
- Trigger rescan -> return updated list
- Optional: POST /api/missions/workshop { id } -> delegate to steamcmd
```
---
## 7. EventEmitter → WebSocket Broadcast Bridge
**Source:** `app.js` lines 4966 (event bridge block)
**What it does:**
Listens on EventEmitter events from Manager, Missions, and Mods, then calls `io.emit()` to broadcast to all connected Socket.IO clients. New connections receive a full state snapshot immediately on connect.
**Why it is valuable:**
languard's WebSocket layer pushes real-time log lines but does not broadcast server state changes to all connected tabs/clients simultaneously. The "snapshot on connect + push diffs on change" pattern is directly applicable.
**Adapter strategy:**
```
Adapt: backend/core/websocket/broadcast_manager.py
- WebSocketManager class (already partially exists in languard)
- Add: publish(event: str, payload) -> broadcast_all()
- On new connection: send snapshot of all servers, missions, mods
- On server state change: publish("servers", get_all_servers())
- Same for missions and mods events
```
---
## 8. Mission Rotation Table — Per-Mission Difficulty
**Source:** `public/js/app/views/servers/missions/rotation/` (list + item views)
**What it does:**
Renders a table of missions in the server's active rotation. Each row has a difficulty dropdown. Rows can be added from the discovered mission list or removed individually. The full rotation array saves with the server config.
**Why it is valuable:**
languard's missions tab shows available missions but has no drag-to-add rotation table with per-mission difficulty. This is a key Arma workflow.
**Adapter strategy:**
```
Adapt: frontend/src/pages/ServerDetailPage.tsx (Missions tab)
- MissionRotationTable component
- Row type: { name: string, difficulty: '' | 'Recruit' | 'Regular' | 'Veteran' }
- Add row: select from available missions dropdown
- Remove row: delete button per row
- Save: PUT /api/servers/{id} { missions: [...] }
- Backend: MissionRotationItem Pydantic schema as array field on Server
```
---
## 9. Mod Assignment UI — Split-Pane Available / Selected
**Source:** `public/js/app/views/servers/mods/` (available list + selected list views)
**What it does:**
Two side-by-side lists: all discovered mods on the left, server-assigned mods on the right. Clicking a mod moves it between lists. Each list has a search filter. Selection saves when the settings form submits.
**Why it is valuable:**
languard's mods tab uses a flat checkbox list. The split-pane pattern with instant visual feedback is significantly more usable when mod counts are large (50+).
**Adapter strategy:**
```
Adapt: frontend/src/pages/ServerDetailPage.tsx (Mods tab)
- <AvailableModsList> and <SelectedModsList> side by side
- Shared state: selectedMods: string[] (mod paths)
- On click: immutable transfer between lists
- Search input on each list (client-side filter)
- On save: PUT /api/servers/{id} { mods: selectedMods }
```
---
## 10. Startup Parameter Editor — Dynamic String List
**Source:** `public/js/app/views/servers/parameters/` (list + item views)
**What it does:**
Renders a dynamic list of startup parameter inputs (e.g., `-limitFPS=100`). Rows can be added or removed. The list persists with the server config.
**Why it is valuable:**
languard's Create Server wizard captures some fixed parameters but has no UI for arbitrary additional startup flags, which power users need.
**Adapter strategy:**
```
Create: frontend/src/components/ParameterEditor.tsx
- Props: value: string[], onChange: (v: string[]) => void
- Renders list of text inputs with remove buttons
- Add button appends empty string
- Used in: CreateServerPage wizard (step 3) and ServerDetailPage settings tab
- Save: include in PUT /api/servers/{id} { parameters: [...] }
```
---
## Priority Ranking
| Priority | Candidate | Effort | Value |
|----------|-----------|--------|-------|
| High | Status Polling (asyncio task per server) | Medium | Accurate live player counts |
| High | Mission Rotation Table UI | Medium | Key missing workflow |
| High | Mission Multi-File Upload | Low | Missing feature |
| High | Mod Discovery with Parallel Metadata | Medium | Rich mod metadata |
| Medium | Startup Parameter Editor UI | Low | Power-user feature |
| Medium | Mod Split-Pane Selection UI | Medium | UX improvement for large mod lists |
| Medium | Log File Discovery + Cleanup | Low | Historical log access |
| Low | EventEmitter Broadcast Bridge | Low | Already partially implemented |
| Low | Config-Driven Optional Auth | Low | Dev convenience only |

314
docs/HOW_IT_WORKS.md Normal file
View File

@@ -0,0 +1,314 @@
# How Arma Server Web Admin Works
## Application Boot Sequence
```
node app.js
├── Load config.js
├── Create Express app + HTTP server
├── Attach Socket.IO to HTTP server
├── Instantiate: Settings, Missions, Mods, Manager
├── Manager.load() → read servers.json, restore Server instances
├── Register event bridges (manager/missions/mods → io.emit)
├── Mount routes (/api/*)
├── Serve public/ (SPA)
├── Optional: setup-basic-auth middleware
└── http.listen(config.port)
```
---
## Core Flow: Server Lifecycle
### Start a Server
```
POST /api/servers/:id/start
→ routes/servers.js → manager.startServer(id)
→ lib/server.js Server.start()
├── Instantiate ArmaServer.Server with merged config
├── Write server.cfg to filesystem (via arma-server lib)
├── Spawn child process:
│ Windows → arma3server.exe [params]
│ Linux → ./arma3server [params]
│ Wine → wine arma3server.exe [params]
├── On Linux: pipe stdout/stderr to dated .rpt log file
├── Start queryStatusInterval every 5 s (Gamedig)
├── If number_of_headless_clients > 0 → startHeadlessClients()
└── Emit 'state' event → manager bubbles → io.emit('servers', ...)
```
### Stop a Server
```
POST /api/servers/:id/stop
→ Server.stop()
├── instance.kill() (SIGTERM)
├── setTimeout(5000) → instance.kill() if still alive (SIGKILL)
├── stopHeadlessClients()
├── clearInterval(queryStatusInterval)
└── On 'close' → emit 'state'
```
### Status Polling (every 5 seconds)
```
setInterval → Server.queryStatus()
→ Gamedig.query({ type: 'arma3', host: '127.0.0.1', port })
→ On success: store { players, mission, status } in server.state
→ On failure: set state = 'stopped' if instance.exitCode set
→ Emit 'state' → manager → io.emit('servers', getServers())
```
### Persistence
```
Any mutation (add/edit/delete/start/stop)
→ Manager.save()
→ JSON.stringify(serversArr.map(s => s.toJSON()))
→ Write to servers.json
→ Emit 'servers' event → io.emit('servers', ...)
```
---
## Core Flow: Mission Management
### List Missions
```
GET /api/missions/
→ missions.missions (pre-loaded array)
→ [ { name, world, filename, size, created, modified }, ... ]
```
### Upload Missions
```
POST /api/missions (multipart/form-data, field: "missions")
→ multer stores files to temp dir
→ Filter: only .pbo extension allowed
→ async.parallelLimit(8): fs.move(temp → mpmissions/filename)
→ missions.updateMissions()
→ fs.readdir(mpmissions/)
→ stat each file → build metadata object
→ update this.missions array
→ io.emit('missions', missions.missions)
```
### Steam Workshop Download
```
POST /api/missions/workshop { id: "workshop_id" }
→ steamWorkshop.downloadFile(id, mpmissionsDir)
→ missions.updateMissions()
→ io.emit('missions', ...)
```
---
## Core Flow: Mod Management
### Discovery Pipeline
```
Mods.updateMods()
→ glob('**/{@*,csla,ef,...}/addons', gamePath)
→ For each modDir, async.map → resolveModData(modDir)
├── async.parallel:
│ folderSize(modDir) → recursive sum of file sizes (symlink-aware)
│ modFile(modDir) → parse mod.cpp → { name }
│ steamMeta(modDir) → parse meta.cpp → { id, name }
└── Merge results into:
{ name: relative_path, size, formattedSize, modFile, steamMeta }
→ this.mods = result array
→ Emit 'mods'
```
### Assign Mods to Server
```
Client UI: drag/click mod from "Available" → "Selected"
→ Backbone model update: server.mods = [ 'path/to/@mod', ... ]
→ PUT /api/servers/:id { mods: [...] }
→ manager.saveServer(id, body)
→ Manager.save() → servers.json
```
---
## Core Flow: Log Management
### Locate Log Files
```
Logs.logsPath()
→ if config.type === 'windows' → AppData/Local/[GameName]/
→ if config.type === 'linux' → config.path/logs/
→ if config.type === 'wine' → .wine/drive_c/users/.../AppData/[GameName]/
Logs.logFiles()
→ fs.readdir(logsPath)
→ filter: /\.rpt$/
→ stat each → { name, size, created, modified }
→ sort by modified desc
```
### Auto-Cleanup
```
After any delete or Linux log write:
Logs.cleanupOldLogFiles()
→ logFiles() → sort by modified
→ if count > 20: delete oldest (count - 20) files
```
### Linux Real-Time Logging
```
Server.start() (Linux)
→ logStream = fs.createWriteStream(logPath, { flags: 'a' })
→ process.stdout.pipe(logStream)
→ process.stderr.pipe(logStream)
```
---
## Real-Time Architecture (Socket.IO)
```
Backend (EventEmitter chain)
Manager/Missions/Mods emit events via Node EventEmitter
app.js bridges each to Socket.IO:
manager.on('servers', () => io.emit('servers', manager.getServers()))
missions.on('missions', (m) => io.emit('missions', m))
mods.on('mods', (m) => io.emit('mods', m))
On new client connection:
socket.emit('missions', missions.missions) // push initial snapshot
socket.emit('mods', mods.mods)
socket.emit('servers', manager.getServers())
socket.emit('settings', settings.getPublicSettings())
Frontend (Backbone + Socket.IO)
socket.on('servers', (servers) → serversCollection.set(servers))
socket.on('missions', (m) → missionsCollection.set(m))
socket.on('mods', (m) → modsCollection.set(m))
→ Backbone triggers 'change'/'add'/'remove' → Marionette re-renders views
```
---
## Frontend SPA Architecture
### Routing
Five Backbone routes drive the entire SPA:
| Route | Handler | View |
|-------|---------|------|
| `` (home) | `home()` | `ServersListView` — server grid |
| `logs` | `logs()` | `LogsListView` — log file browser |
| `missions` | `missions()` | `MissionsView` — upload + list |
| `mods` | `mods()` | `ModsView` — mod browser |
| `servers/:id` | `server(id)` | `ServerView` — tabbed detail page |
### View Hierarchy
```
LayoutView (root, persists across route changes)
├── Region: navigation → NavigationView (server list sidebar)
└── Region: content → (swapped per route)
├── ServersListView
│ └── ServerItemView (per server card)
├── LogsListView
│ └── LogItemView (per log file)
├── MissionsView
│ ├── UploadView (file input + drag-and-drop)
│ ├── WorkshopView (Steam ID input)
│ └── MissionsListView → MissionItemView
├── ModsView
│ ├── AvailableModsListView → ModItemView
│ └── SelectedModsListView → SelectedModItemView
└── ServerView (tabbed LayoutView)
├── Tab: Info → InfoView (status, start/stop, PID, players)
├── Tab: Mods → ServerModsView (split pane)
├── Tab: Missions → MissionRotationView (add/remove rotation rows)
├── Tab: Parameters → ParametersView (startup param editor)
├── Tab: Players → PlayersView (live player table)
└── Tab: Settings → FormView (full server config form)
```
### Settings Form Save Flow
```
FormView.save()
→ Collect: jQuery serializeArray() + checkbox state
→ Validate: title required
→ AJAX PUT /api/servers/:id { ...formData }
→ On 200: server model updated, navigate to /servers/:newId
→ On error: SweetAlert error dialog
```
---
## Configuration System
### Global Config (`config.js`)
```javascript
{
game: 'arma3', // Game variant
path: '/opt/arma3', // Game install path
port: 3000, // Web UI port
host: '0.0.0.0',
type: 'linux', // 'windows' | 'linux' | 'wine'
parameters: [], // Global startup params (all servers)
serverMods: [], // Server-side mods (all servers)
admins: [], // Steam IDs auto-granted admin
auth: { username, password }, // Optional Basic Auth
prefix: '', // Prepended to all server hostnames
suffix: '',
additionalConfigurationOptions: '' // Appended to all server.cfg
}
```
### Per-Server Config (stored in `servers.json`)
```javascript
{
id: 'my-server', // URL-safe slug of title
title: 'My Server',
port: 2302,
max_players: 32,
password: '',
admin_password: '',
motd: '',
auto_start: false,
battle_eye: true,
persistent: false,
von: true,
verify_signatures: false,
file_patching: false,
allowed_file_patching: 0,
forcedDifficulty: '',
number_of_headless_clients: 0,
parameters: [],
additionalConfigurationOptions: '',
missions: [{ name: 'mission.world', difficulty: '' }],
mods: ['@CBA_A3', '@ACE']
}
```
---
## Authentication Flow
```
If config.auth defined:
setupBasicAuth(app, config.auth)
→ app.use(expressBasicAuth({ users: { username: password } }))
→ All routes require valid Authorization: Basic ... header
→ req.auth.user available in all route handlers
→ Morgan logs include authenticated username
```

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

72
frontend/README.md Normal file
View File

@@ -0,0 +1,72 @@
# Languard Server Manager — Frontend
React 19 + TypeScript + Vite frontend for the Languard game server management panel.
## Stack
- **React 19** with hooks
- **TypeScript** strict mode
- **Vite** dev server + build
- **TanStack Query** for server state (all API calls)
- **Zustand** for client state (auth, UI notifications)
- **react-hook-form + Zod** for form validation
- **Tailwind CSS** with custom neumorphic design tokens
- **Vitest** for unit tests
## Dev Server
```bash
# From this directory
npx vite --host
# → http://localhost:5173
```
## Tests
```bash
npx vitest run # run once
npx vitest # watch mode
npx tsc --noEmit # type check only
```
## Project Structure
```
src/
├── components/
│ ├── layout/ # Sidebar
│ ├── servers/ # ServerCard, ConfigEditor, PlayerTable, MissionList, ModList, LogViewer, BanTable
│ ├── settings/ # PasswordChange, UserManager
│ └── ui/ # StatusLed, (planned) TagListEditor, ConfirmModal
├── hooks/
│ ├── useServers.ts # Dashboard server list + start/stop/restart mutations
│ ├── useServerDetail.ts # All per-server queries and mutations
│ ├── useAuth.ts
│ └── useWebSocket.ts # Real-time events (logs, status changes)
├── pages/
│ ├── LoginPage.tsx
│ ├── DashboardPage.tsx
│ ├── ServerDetailPage.tsx
│ ├── CreateServerPage.tsx
│ └── SettingsPage.tsx
├── store/
│ ├── auth.store.ts # JWT + user role
│ └── ui.store.ts # Notification queue
└── lib/
├── api.ts # Axios instance with JWT interceptor + 401 redirect
└── logger.ts
```
## CSS Conventions
Custom utility classes defined in `src/index.css` (do not add new CSS files):
| Class | Use |
|-------|-----|
| `neu-card` | Card surface with neumorphic raised shadow |
| `neu-input` | Input with recessed shadow |
| `btn-primary` | Amber accent button |
| `btn-ghost` | Text-only button with hover background |
| `btn-danger` | Red destructive button |
Tailwind design tokens in `tailwind.config.js`: `surface-{base,raised,recessed,overlay}`, `text-{primary,secondary,muted}`, `status-{running,stopped,crashed,starting,restarting}`, `accent`.

23
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5672
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More