Files
ptero-reforger-panel/README.md
T
2026-07-05 16:54:59 -07:00

184 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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=<uuid> name=<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 1030 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`.