# Reforger Panel A private, purpose-built control panel for one community Arma Reforger training/recruiting server. It sits **on top of Pterodactyl** — Pterodactyl (and Wings) keep running the game container, files, backups, and allocations; this panel is the curated management experience for the owner and trusted crew admins. Not included by design: billing, public sign-up, multi-tenancy, arbitrary file management, raw console access, or anything that replaces Pterodactyl. ## Architecture ```text Browser (React SPA) │ same-origin /api only — no upstream credentials ever reach the browser ▼ Panel API (Express + TypeScript) ├─ Discord OAuth → local users, sessions (Postgres), roles ├─ PostgreSQL (Drizzle ORM) ├─ Workshop client → https://api.reforgermods.net (backend-only) ├─ GameServerProvider abstraction │ ├─ PterodactylProvider (Client API: status, resources, power, read-only files) │ └─ MockGameServerProvider (full local dev without credentials) └─ Log ingestion worker: Pterodactyl log download → parser → players/sessions/events ``` Monorepo layout: ```text apps/api Express API, Drizzle schema/migrations, ingestion worker, tests apps/web Vite + React + Tailwind dashboard packages/shared Roles/capabilities, DTO types, Reforger config model ``` ## Quick start (mock mode, no Pterodactyl or Discord needed) ```bash cp .env.example .env # defaults are fine for local dev # set DEV_AUTH_BYPASS=true in .env to log in without Discord docker compose up -d # Postgres on 127.0.0.1:5433 npm install npm run db:migrate npm run db:seed # creates the server row (real data is imported from the server) npm run dev # API on :3001, web on :5173 ``` Open http://localhost:5173 and use **Local development login** (requires `DEV_AUTH_BYPASS=true`; the endpoint refuses to exist in production). Mock mode serves a generated `console.log`, so within ~20 s the dashboard shows players, sessions, and events produced by the real ingestion pipeline. Useful scripts: `npm run lint`, `npm run typecheck`, `npm test`, `npm run build`, `npm run format`, `npm run db:generate` (new migration after schema changes). ## Environment variables See `.env.example` for the full annotated list. Highlights: | Variable | Purpose | | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | | `DATABASE_URL`, `SESSION_SECRET` | Postgres + cookie/state signing (32+ chars) | | `DISCORD_CLIENT_ID/SECRET`, `DISCORD_REDIRECT_URI` | Discord OAuth app | | `OWNER_DISCORD_ID` | This Discord account is auto-assigned `owner` at login | | `DEV_AUTH_BYPASS` | Local-only fake owner login; rejected when `NODE_ENV=production` | | `REFORGER_WORKSHOP_API_BASE_URL` | Workshop metadata API (backend-only) | | `PTERODACTYL_BASE_URL`, `PTERODACTYL_CLIENT_API_KEY`, `PTERODACTYL_SERVER_ID` | Client API (not Application API) | | `USE_MOCK_PTERODACTYL` | `true` = run entirely against the in-process mock | | `REFORGER_CONFIG_PATH`, `REFORGER_CONFIG_SYNC_INTERVAL_SECONDS` | Where the server's config.json lives; imported read-only at startup + interval | | `REFORGER_LOG_DIRECTORY`, `REFORGER_LOG_FILE_PATTERN` | Recommended: directory listed each sync; newest `logs_*` subfolder is followed | | `REFORGER_ADMIN_LOG_PATH` | Optional: pins one exact log file, overriding directory discovery | | `REFORGER_LOG_POLL_INTERVAL_SECONDS` / `_MAX_DOWNLOAD_BYTES` / `_STALE_AFTER_SECONDS` | Ingestion pacing, download cap, staleness threshold | Environment is validated with zod at startup; the process refuses to boot with missing/contradictory settings (e.g. real mode without Pterodactyl credentials). ## Users, roles, and enforcement Roles live in the panel database (not Discord roles). New users default to `viewer`; the account matching `OWNER_DISCORD_ID` becomes `owner` automatically. The owner manages roles under **Settings**. | Capability | owner | server_admin | mission_lead | viewer | | -------------------------------------- | :---: | :----------: | :----------: | :----: | | View dashboard/server/players/activity | ✓ | ✓ | ✓ | ✓ | | Start / stop | ✓ | ✓ | — | — | | Restart | ✓ | ✓ | ✓ | — | | Operational health diagnostics | ✓ | ✓ | — | — | | Manual log sync | ✓ | — | — | — | | User/role management, settings | ✓ | — | — | — | Enforcement is backend middleware (`requireAuth` + `requireCapability`); the frontend only hides buttons. Sessions are 7-day HTTP-only cookies (`SameSite=Lax`, `Secure` in production), stored in Postgres as SHA-256 hashes and revocable server-side. State-changing requests additionally require a custom `X-CSRF-Protection` header and pass an Origin allowlist; OAuth uses a signed `state` cookie. Auth, power, and sync endpoints are rate limited. ## Log ingestion Flow: panel backend → Pterodactyl Client API (signed download URL, streamed with a byte cap) → parser → Postgres. Wings is never touched directly and the browser never downloads logs. - **Scheduler** — one poll loop per server (default 20 s), per-server lock so syncs never overlap, exponential backoff (up to 8×) after consecutive failures, graceful shutdown that waits for in-flight syncs. Starts only when a provider and log path are configured. Owner can trigger `POST /api/servers/:slug/logs/sync` manually. - **Cursoring** — a `log_cursors` row per (server, path) stores byte offset, file fingerprint (hash of the first line when visible), hash of the last processed line, and any partial trailing line. - _First sync_: only a bounded tail (512 KiB) is imported, never full history. - _Append_: only bytes after the cursor are parsed; a stored partial line is prepended. - _Rotation/truncation/replacement_: detected via size decrease, fingerprint change, or a continuity mismatch at the cut point → cursor resets and a bounded tail of the new file is processed. - _Partial trailing lines_ are never parsed; they wait for the next sync. - _Large files_: downloads are capped (`REFORGER_LOG_MAX_DOWNLOAD_BYTES`); if the file grew past the window, the gap is noted and a bounded tail is processed. Retrieval is isolated in `pterodactyl-log-source.ts` so range/tail requests can be added without touching parsing. - **Deduplication** — events carry a unique `(server_id, source_log_path, sha256(raw line))` key enforced by a Postgres unique index, so rotation boundaries and cursor resets cannot double-import. - **Sessions** — connect opens a session; disconnect closes it with duration; a reconnect without a disconnect closes the stale session (`missed_disconnect`); a fresh server start closes all open sessions (`server_restart`) and emits `server_restart_detected`. ### Supported log events and known limitations Recognized today (patterns centralized in `apps/api/src/modules/reforger-logs/parser/patterns.ts`, verified against real server logs): - `Player #N Name (ip:port) connected` (BattlEye wrapper) → `player_connected` - `Player #N Name disconnected` → `player_disconnected` - `Player #N Name - BE GUID: …` → merged into the player as a stable identity - `Authenticated player: … identityId= name=` (BACKEND channel) → engine-level identity, available even without BattlEye - `Game successfully created` / `Server is ready to accept connections` → `server_started` When both identity lines appear for a player, the first one wins and the other is ignored, so a player is never split into duplicates. Limitations to keep in mind: - **Patterns can change between game versions.** They were validated against a live 2026 server log, but Bohemia can change the format; adjust `patterns.ts` (each pattern has a fixture-backed test). - **Player identity**: when logs provide no GUID, players are matched by display name only — two people with the same name would merge, and renames create a new player record. The GUID line, when present, upgrades matching to a stable ID. - **Timestamps** in Reforger logs are time-of-day only; the date comes from the `Log started` header or falls back to the sync date, with midnight-rollover and future-timestamp guards. Cross-midnight logs without a header can be off by a day in pathological cases. - Player data is **log-polled, not real-time** — the UI always shows "last synchronized" and flags staleness rather than pretending to be live. ### Finding the log location in Pterodactyl Open your server in Pterodactyl → **Files**. Reforger writes a new dated folder per boot (e.g. `/profile/logs/logs_2026-07-04_12-54-04/console.log`). Set `REFORGER_LOG_DIRECTORY` to the parent (e.g. `/profile/logs`) — the panel lists it on every sync and follows the newest `logs_*` folder automatically, so restarts need no reconfiguration. `REFORGER_ADMIN_LOG_PATH` exists to pin one exact file and overrides discovery. ### Configuration import The panel downloads the server's real `config.json` (default `/config.json`, override with `REFORGER_CONFIG_PATH`) at startup, every `REFORGER_CONFIG_SYNC_INTERVAL_SECONDS`, and on demand via **Configurations → Sync from server** (owner/server admin). Each change creates a new `ConfigRevision`, and the server's displayed name and max players always come from the imported config — nothing is hand-seeded. Credentials in config.json (admin password, RCON password) are never copied into the panel's model. ## Deploying privately (you + friends) The API serves the built web app itself in production, so the whole panel is one container plus Postgres: ```bash cp .env.example .env # set Discord creds, OWNER_DISCORD_ID, Pterodactyl vars, # a fresh SESSION_SECRET (openssl rand -base64 32), # USE_MOCK_PTERODACTYL=false, DEV_AUTH_BYPASS=false docker compose -f docker-compose.prod.yml up -d --build ``` Then put HTTPS in front of port 3001 — any of: - **Tailscale** (easiest for a private group): `tailscale serve https / http://localhost:3001`, share the tailnet with your friends. - **Caddy**: `reverse_proxy localhost:3001` with a domain (automatic HTTPS). - **nginx + certbot** if you already run it. Finally set `WEB_ORIGIN` and `DISCORD_REDIRECT_URI` in `.env` to the public URL (e.g. `https://panel.example.com` and `https://panel.example.com/api/auth/discord/callback`), register that redirect URI in your Discord application, and restart the stack. Production mode enforces `Secure` cookies (HTTPS required), refuses `DEV_AUTH_BYPASS`, and requires Discord credentials at boot. Access model for a private group: anyone with the URL can log in with Discord but lands as a **viewer** with read-only access; hand out **invite links** (Settings → Invites) to grant server admin / mission lead roles, and manage roles under Settings → Users. ## Connecting a real Pterodactyl server safely 1. In Pterodactyl, log in as a user that has access to **only** this game server (create a dedicated sub-user if needed). 2. Account Settings → API Credentials → create a **Client API** key. This scopes the panel to that user's servers — do not use an admin/Application API key. 3. Set `PTERODACTYL_BASE_URL`, `PTERODACTYL_CLIENT_API_KEY`, `PTERODACTYL_SERVER_ID` (the short identifier from the server URL), `USE_MOCK_PTERODACTYL=false`, and `REFORGER_ADMIN_LOG_PATH`. 4. Update the seeded server row if needed (the seed stores `PTERODACTYL_SERVER_ID` when present). 5. Restart the API and check **Settings → Integrations** and the dashboard's Operational health card. The API key stays server-side; requests have 10–30 s timeouts, size-capped downloads, and errors are sanitized (no key, no host, no stack traces) before storage or display. ## API surface ```text GET /api/auth/me POST /api/auth/logout GET /api/auth/discord GET /api/auth/discord/callback POST /api/auth/dev-login (dev only) GET /api/servers GET /api/servers/:slug GET /api/servers/:slug/resources GET /api/servers/:slug/players GET /api/servers/:slug/players/known GET /api/servers/:slug/activity GET /api/servers/:slug/configuration GET /api/servers/:slug/mod-packs POST /api/servers/:slug/power/{start,stop,restart} POST /api/servers/:slug/logs/sync GET /api/servers/:slug/logs/health GET /api/workshop/health GET /api/workshop/search?q=&page=&sort= GET /api/workshop/mods/:id GET /api/users PATCH /api/users/:id/role (owner only) ``` Errors are structured: `{ "error": { "code", "message", "requestId" } }`. ## Implemented vs scaffolded **Implemented**: Discord OAuth + sessions + role enforcement, dashboard + server pages, provider abstraction with mock and real Pterodactyl Client API, power controls with per-role limits (audited to the activity feed, simulated in mock mode), Workshop health/search/detail proxy, read-only config preview + revision history, full log ingestion pipeline (scheduler, cursoring, rotation, dedupe, sessions), operational health card, owner user/role management, 63 tests. **Scaffolded / later phases**: mod-pack editing and deployment ("Add to pack" is intentionally disabled), config generation/writing to the server (no file writes through Pterodactyl yet), config presets for mission leads, Discord-role sync, multi-server support (schema is ready; UI assumes one), historical playtime analytics. **Recommended next steps**: (1) capture real `console.log` samples from your server and harden the parser fixtures; (2) mod-pack builder writing `ModPackRevision`s from Workshop search; (3) config generation producing a real `config.json` diff/preview from `ConfigRevision`, then a guarded deploy (file write + restart) for owner/server admin; (4) preset selection for mission leads; (5) session-history charts from `player_sessions`.