Files
panel/README.md
T
dbledeez 295eb22826 panel public release
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 01:26:41 -07:00

841 lines
36 KiB
Markdown

# panel
An open-source, modular game-server management panel — AMP-class features,
distributed Controller/Agent architecture, CEL-driven automation, full
instance lifecycle (install → run → update → backup → restore), live
dashboard, and CLI.
Written in Go. Single static binary per component. Docker-backed runtime.
26 modules ship in-tree, including the awkward Windows-only games
(Conan Exiles, Empyrion, V Rising, Sons of the Forest, DayZ, Soulmask,
Project Zomboid, Windrose) under Wine + Xvfb. The agent **auto-builds**
every module's Docker image from its in-tree `Dockerfile` the first time
an instance needs it — there is no manual per-module `docker build` step.
## 60-second quickstart
One box, one line. Docker is installed for you if missing; Postgres runs
as a managed container; controller + agent become systemd units (Linux)
or Scheduled Tasks (Windows), and the local agent is auto-paired over mTLS.
```bash
# Linux (Debian/Ubuntu/Fedora/RHEL/Raspbian) — run as root:
curl -fsSL https://git.pdxtechs.com/dbledeez/panel/raw/branch/main/install.sh | sudo bash
```
```powershell
# Windows (Docker Desktop must already be running):
powershell -ExecutionPolicy Bypass -File install.ps1
```
Then open `http://<host>:8080/` and log in with the `ADMIN BOOTSTRAP`
credentials printed in the controller log (or pre-seed via
`PANEL_ADMIN_PASSWORD`). Full options, docker-compose, and from-source
paths: [docs/INSTALL.md](docs/INSTALL.md).
> **Availability / self-hosting the source.** The one-line installer fetches
> both the source and the default prebuilt release from `git.pdxtechs.com`. If
> that host is unreachable (or you want to pin your own mirror), point the
> installer at any Git host or tarball you control:
>
> ```bash
> # Build from your own mirror of the source:
> PANEL_REPO_URL=https://github.com/you/panel PANEL_PREBUILT_URL= sudo bash install.sh
>
> # Or install a prebuilt tarball you host yourself (no Go needed):
> PANEL_PREBUILT_URL=https://mirror.example.com/panel-linux-amd64.tar.gz sudo bash install.sh
> ```
>
> Setting `PANEL_PREBUILT_URL` to an empty value forces a from-source build
> against `PANEL_REPO_URL`. See [docs/INSTALL.md](docs/INSTALL.md) for the full
> mirror/self-host path.
```
┌─────────────────────────┐
│ Operator │
│ (browser / panelctl) │
└────────────┬────────────┘
│ HTTPS / gRPC
┌────────────▼────────────┐
│ Controller │
│ • Web dashboard :8080 │
│ • Agent gRPC :8443 │
│ • Postgres │
│ • Scheduler (cron+CEL) │
│ • Event bus + SSE │
│ • Session auth │
└────────────┬────────────┘
│ gRPC bidi (mTLS)
┌────────────────────┼────────────────────┐
│ │ │
┌────────▼────────┐ ┌────────▼────────┐ ┌────────▼────────┐
│ Agent A │ │ Agent B │ │ Agent C │
│ • Docker │ │ • Docker │ │ • Docker │
│ • Modules │ │ • Modules │ │ • Modules │
│ • RCON clients │ │ • RCON clients │ │ • RCON clients │
│ • State tracker│ │ • State tracker│ │ • State tracker│
└─────────────────┘ └─────────────────┘ └─────────────────┘
```
---
## Documentation
| Doc | Covers |
|---|---|
| [docs/INSTALL.md](docs/INSTALL.md) | Installers, docker-compose, from-source, flags reference, systemd, reverse proxy + TLS, firewall |
| [docs/NETWORKING.md](docs/NETWORKING.md) | Per-game port table, router port-forwarding, what the panel auto-allocates |
| [docs/PAIRING.md](docs/PAIRING.md) | mTLS agent pairing, multi-host setup |
| [docs/ADMIN.md](docs/ADMIN.md) | First-admin bootstrap, password reset, Steam sign-in, Steam credentials for paid games |
| [docs/BACKUP.md](docs/BACKUP.md) | Backups, pg_dump, disaster-recovery runbook |
| [docs/architecture.md](docs/architecture.md) | The real stack, component by component |
## Table of contents
- [Status](#status)
- [Why panel](#why-panel)
- [Architecture](#architecture)
- [Quick start (dev)](#quick-start-dev)
- [Production-ish layout](#production-ish-layout)
- [Modules](#modules)
- [Module manifest](#module-manifest)
- [Wine/Xvfb modules](#winexvfb-modules)
- [`panelctl` CLI](#panelctl-cli)
- [HTTP API](#http-api)
- [Admin HTTP (out-of-band repair)](#admin-http-out-of-band-repair)
- [Scheduler](#scheduler)
- [Mods & Workshop](#mods--workshop)
- [Backups](#backups)
- [File manager](#file-manager)
- [Repository layout](#repository-layout)
- [Data directory layout](#data-directory-layout)
- [Build](#build)
- [Working with the embedded UI](#working-with-the-embedded-ui)
- [Troubleshooting](#troubleshooting)
- [Contributing / writing a new module](#contributing--writing-a-new-module)
- [License](#license)
---
## Status
panel is a working private deployment driving real game servers — not a
toy. Feature completeness varies by game; the headline state is:
- ✅ Manifest-driven module plugins (26 shipped — see [Modules](#modules))
- ✅ mTLS agent pairing: one-time token minted in the UI → `agent
--pair-token` fetches the CA + a signed cert and exits; the agent then
connects with mTLS and rogue agents are rejected at the TLS handshake
(see [docs/PAIRING.md](docs/PAIRING.md))
- ✅ Agent auto-builds `panel-*` module images from the in-tree Dockerfiles
on first use — pull-through for third-party images, no manual builds
- ✅ Distributed Controller + Agent over gRPC (bidi streams, reconnect +
rehydrate, sidecar metadata survives restarts)
- ✅ Docker runtime with bind-mount and named-volume support, host
networking, per-instance port allocation
- ✅ Wine + Xvfb pattern for Windows-only dedicated servers (Empyrion,
Conan Exiles, V Rising, Sons of the Forest, DayZ, Soulmask, Project
Zomboid, Windrose, Dragonwilds)
- ✅ Full state tracking: RCON polling + log-line event matching → typed
PlayerEvent + AppState bus
- ✅ Telnet **and** Source RCON adapters (Minecraft, CS-derived games,
Rust, Valheim w/ plugins, 7DTD, Conan, etc.) with redial-on-EOF
- ✅ Scheduler: 6-field cron triggers **and** CEL event triggers with
sustained-for windows
- ✅ Config-file templating with auto-generated secrets; per-game custom
Config tabs over `ServerSettings.ini` / `GameUserSettings.ini` /
`serverconfig.xml` / properties / JSON
- ✅ File manager: web UI browses container volumes via `docker exec` +
`docker cp`, bind-mount fallback, path-traversal defended, multi-root
per module
- ✅ Update providers: **Direct HTTP**, **GitHub releases**, **SteamCMD
sidecar** (with platform overrides for Windows-on-Linux), **Steam beta
branches** (e.g. Conan Legacy UE4, Conan Enhanced UE5)
- ✅ Backup / restore via tar-sidecar containers
- ✅ Postgres persistence (agents, instances, schedules, backups, users)
- ✅ Local auth: argon2id + session cookies, admin bootstrap, change
password, optional Sign-in-with-Steam linking
- ✅ Live event bus with SSE stream (`Last-Event-ID` resume + snapshot) +
`panelctl watch`
- ✅ Template Library: 236-entry game catalog (`static/catalog.json`,
distilled from CubeCoders' MIT templates by `tools/amp-distill`) with an
admin one-click module scaffolder (`POST /api/scaffold-module`)
- ✅ Update-check (check-only): agent compares installed Steam build id vs
`app_info` (15-min cache) → Updates-tab panel + card badge
- ✅ Prometheus `/metrics` (admin-token / loopback gated)
- ✅ Mods tab: ARK CurseForge / DayZ Workshop / Conan Workshop / V Rising
PlasmaCore / Empyrion scenarios — search, install, version-pin
- ✅ ARK SA cluster registry with cross-server transfer + admin rebind
- 🟡 Cross-platform: Linux works for everything except modules that
require nested KVM. Primary dev host is Windows + Docker Desktop.
- ❌ OIDC / WebAuthn / Casbin ACL (local auth only; role = admin / user)
- ❌ SFTP (web FM covers the common case)
- ❌ Host-mode runtime (cgroups v2 / Windows Job Objects)
## Why panel
Pterodactyl/Pelican is good but Docker-only and PHP, AMP is closed-source
and per-machine, GameServerApp and friends are SaaS. panel is what falls
out if you want:
- One static Go binary per component, no Composer / npm / Node runtime
on the host.
- Modules as plain YAML — drop a `module.yaml` in `modules/<id>/`,
restart the agent, the game appears in the dashboard.
- Real distributed multi-host: Controller is one box, Agents run
wherever Docker runs.
- A scheduler that can react to *the world* — "stop the server if
it's been empty for 30 minutes", "restart at 3am only on Tuesdays" —
rather than just cron.
- First-class Wine/Xvfb support: Windows-only dedicated servers run on
Linux Docker hosts without per-game artisanal scripting.
- Out-of-band repair via a token-authed `/admin/v1/*` HTTP surface —
the dashboard breaks, you SSH in, and `panelctl admin recreate` /
`set-config` / `ark-rebind` still work.
Designed for someone running ~10 game servers across 2-4 boxes for
themselves and a couple of communities, not for a hosting company. No
multi-tenancy, no billing.
## Architecture
Two long-running components plus optional sidecars.
**Controller** — `./controller/cmd/controller`
- HTTP :8080 (configurable) — dashboard, REST API, SSE event stream
- gRPC :8443 — bidi stream from each Agent
- Postgres for durable state (no SQLite default — explicitly not sharded)
- Embedded migrations, embedded static dashboard (default UI =
`static/new.html` shell + `static/rcc.js` + `static/rcc.css`, served
hash-stamped with immutable caching via `//go:embed`; the legacy
single-file `static/index.html` is the frozen "classic" UI, opt-in via
the `panel_ui=classic` cookie)
- Scheduler in-process (cron + CEL)
- Prometheus `GET /metrics` (admin-token or loopback gated)
**Agent** — `./agent/cmd/agent`
- Dials Controller, stays connected with reconnect + heartbeat
- Loads modules from `--modules-dir` at boot (default `./modules`)
- Drives the local Docker daemon (`/var/run/docker.sock` or
`npipe:////./pipe/docker_engine` on Windows)
- Spawns short-lived sidecar containers for SteamCMD updates and tar
backup/restore
- Per-instance state tracker: RCON polling, log-line tail, event
pattern matching → typed events streamed back to Controller
**Sidecars (per-game)**
- `steamcmd-sidecar` — SteamCMD with `+force_install_dir` mounted at
the instance's game-content volume; supports `+@sSteamCmdForcePlatformType
windows` for cross-platform Windows depots, beta branch pinning, and
Workshop downloads.
- `tar-sidecar` — exec'd against the volumes for backup/restore so the
game container doesn't have to expose its filesystem.
**RCON adapters** (`agent/internal/rcon/`)
- `source_rcon` — Source-RCON wire protocol (Minecraft Java, Conan,
Factorio, Palworld, Project Zomboid)
- `telnet` — Telnet-style password gate then line protocol (7DTD, Empyrion)
- `websocket_rcon` — Rust's WebRCON
- `stdio` — attach to the container's stdin (Terraria, Valheim, Vein,
Enshrouded)
- `be_rcon` — BattlEye RCON over UDP (DayZ)
- `docker_exec_rcon` — exec a CLI inside the container (ARK SA)
All adapters auto-redial on EOF (Conan and Palworld lessons baked in).
## Quick start (dev)
Prereqs: Go 1.26+, Docker Desktop (Windows) or Docker Engine (Linux),
[`buf`](https://buf.build/docs/installation/) on PATH for proto stubs.
```bash
# 1) Postgres (single dev container)
docker compose -f docker-compose.dev.yml up -d
# 2) Generate protobuf stubs (uses remote plugins — no local protoc needed)
buf generate
# 3) Start the controller. First run prints an ADMIN BOOTSTRAP banner
# with the generated admin password — copy it out of stderr.
go run ./controller/cmd/controller
# 4) Start an agent (any host with Docker + reachable to controller)
go run ./agent/cmd/agent --insecure \
--modules-dir=./modules \
--data-root=./data/instances \
--meta-dir=./data/instance-meta \
--backup-dir=./data/backups
# 5) Open http://localhost:8080/, log in as admin@panel.local with
# the bootstrap password.
```
If you'd rather pre-seed the password instead of reading it from logs:
```bash
PANEL_ADMIN_PASSWORD=changeme go run ./controller/cmd/controller
```
## Production-ish layout
This is the layout the maintainer runs. Not prescriptive — adapt freely.
- One **Controller** box with Postgres co-located. Reverse-proxied
behind Nginx Proxy Manager / Caddy / Cloudflare. Public dashboard
URL has a real cert; gRPC :8443 is on the LAN only.
- N **Agent** boxes, each with Docker. Agents don't need to be reachable
from the internet — they dial out to the Controller's gRPC port.
- A `data/` directory per machine (Controller + each Agent) under
whatever path you want. **Don't put it in the repo working tree** —
`data/` is gitignored for a reason: it holds per-instance state,
RCON passwords, mTLS material, and game saves.
- One systemd unit per binary on Linux, one Service on Windows
(NSSM works fine).
The agent is intentionally stateless beyond `data/instance-meta/*.json`
(rehydrated from the Controller on connect). Wipe the meta directory
and the agent rebuilds it from the Controller's authoritative DB.
## Modules
Game modules live under `modules/<id>/` and are picked up by every
agent at boot. `panel-*` images are **auto-built by the agent** from the
module's in-tree `Dockerfile` the first time an instance needs them
(they are never published to a registry); third-party images are pulled. Each ships with a `module.yaml` and (where needed) an
`entrypoint.sh` and `Dockerfile`.
| Module | Image / build | RCON | Notes |
|---|---|---|---|
| `7dtd` | `panel-7dtd` (auto-built) | telnet | Canonical-nim freeze, cluster playbook |
| `ark-sa` | `acekorneya/asa_server:latest` | `docker_exec_rcon` | Cluster registry, CurseForge mods, transfer flow |
| `barotrauma` | `panel-barotrauma` (auto-built) | log only | Workshop mods |
| `conan-exiles` | `panel-conan-exiles` (auto-built, Wine) | `source_rcon` | UE4 Legacy / UE5 Enhanced edition picker, Workshop |
| `core-keeper` | `panel-core-keeper` (auto-built) | log only | |
| `dayz` | `panel-dayz` (auto-built, Wine) | `be_rcon` | Steam Workshop manager; `requires_steam_login` |
| `demo` | `alpine:3.21` | none | Lifecycle / event sandbox |
| `dragonwilds` | `panel-dragonwilds` (auto-built, Wine) | log only | |
| `empyrion` | `panel-empyrion` (auto-built, Wine) | telnet | Custom scenario manager, EAH bridge, discovery streamer |
| `empyrion-bridge` | multi-stage dotnet (auto-built) | bridge | EWA gateway sidecar |
| `enshrouded` | `panel-enshrouded` (auto-built) | stdio | |
| `factorio` | `panel-factorio` (auto-built) | `source_rcon` | |
| `minecraft-bedrock` | `itzg/minecraft-bedrock-server` | log only | |
| `minecraft-java` | `itzg/minecraft-server:latest` | `source_rcon` | |
| `palworld` | `panel-palworld` (auto-built) | `source_rcon` | RCON redial-on-EOF |
| `project-zomboid` | `panel-project-zomboid` (auto-built, Wine) | `source_rcon` | |
| `rust` | `panel-rust` (auto-built) | `websocket_rcon` | Rust+ port |
| `satisfactory` | `panel-satisfactory` (auto-built) | none | |
| `sons-of-the-forest` | `panel-sons-of-the-forest` (auto-built, Wine) | log only | |
| `soulmask` | `panel-soulmask` (auto-built, Wine) | log only | |
| `steamcmd-test` | `alpine:3.21` + SteamCMD | none | HLDS smoke test (~930 MB) |
| `terraria` | `panel-terraria` (auto-built) | stdio | |
| `v-rising` | `panel-v-rising` (auto-built, Wine) | log only | PlasmaCore mods |
| `valheim` | `panel-valheim` (auto-built) | stdio | |
| `vein` | `panel-vein` (auto-built) | stdio | |
| `windrose` | `panel-windrose` (auto-built, Wine) | log only | |
Per-game gotchas live in [`memory/gotchas.md`](memory/gotchas.md) — read
that before debugging any specific game.
## Module manifest
Every module is one declarative YAML file. The schema is in
[`pkg/module/manifest.go`](pkg/module/manifest.go). A condensed example:
```yaml
id: my-game
name: "My Game"
version: 0.1.0
supported_modes: [docker]
runtime:
docker:
image: itzg/my-game-server:latest # or local-built image tag
network_mode: host # optional
browseable_root: /data # File-manager root
browseable_roots: # Optional multi-root
- { name: "Saves", path: /data, hint: "world data" }
- { name: "Game", path: /game, hint: "engine binaries" }
env: # Pre-declare every env key
MAX_PLAYERS: "20" # you want forwarded;
ADMIN_PASSWORD: "" # resolve.go filters by
volumes: # this map.
- { type: volume, name: "panel-$INSTANCE_ID-data", container: /data }
- { target: "$DATA_PATH/logs", container: /log } # bind mount
ports:
- { name: game, proto: tcp, default: 25565, required: true, env: GAME_PORT }
- { name: query, proto: udp, default: 25566, required: false, env: QUERY_PORT }
- { name: rcon, proto: tcp, default: 25575, internal: true, env: RCON_PORT }
resources:
min_ram_mb: 2048
recommended_ram_mb: 4096
rcon:
adapter: source_rcon # or telnet, stdio, websocket_rcon, be_rcon, docker_exec_rcon
host_port: rcon
password_secret: RCON_PASSWORD
password_literal: "fallback"
commands:
list: "list"
broadcast: "say {msg}"
kick: "kick {player}"
save: "save-all"
shutdown: "stop"
state_sources:
- type: rcon
command: list
every: 60s
parse:
kind: regex
pattern: '(?P<n>\d+)\s+of\s+(?P<m>\d+) players'
fields: { players_online: 1, players_max: 2 }
events:
join: { pattern: "(?P<name>\\S+) joined", kind: join }
leave: { pattern: "(?P<name>\\S+) left", kind: leave }
chat: { pattern: "<(?P<name>[^>]+)> (?P<msg>.+)", kind: chat }
config_files:
- path: server.properties
format: properties
template: templates/server.properties.tmpl
config_values: # Drive the Config tab in the dashboard
- key: MAX_PLAYERS
label: "Max players"
description: "Player slot limit."
default: "20"
- key: ADMIN_PASSWORD
label: "Admin password"
description: "In-game admin auth."
default: ""
secrets:
- { name: admin_token, generated: true }
update_providers:
- id: stable
kind: steamcmd
app_id: "294420"
install_path: /data
skip_validate: false # Set true for app_ids whose validate hangs
```
Key rules the loader enforces:
- Only env keys pre-declared under `runtime.docker.env` get forwarded
to the container. Adding a `config_values` entry without the matching
env declaration silently drops the value — this trips people up.
- `volumes:` entries use `$INSTANCE_ID` and `$DATA_PATH` substitutions
resolved by the agent at create time.
- `internal: true` ports are not exposed by the allocator but still get
their `env:` substituted.
- `update_providers` are picked from a dropdown in the UI; multiple
providers per module is fine (Conan ships `enhanced` and `legacy`).
## Wine/Xvfb modules
The Windows-only dedicated servers (Empyrion, Conan, V Rising, Sons of
the Forest, DayZ, Soulmask, Project Zomboid, Windrose, Dragonwilds) run
inside a Linux container under `xvfb-run` + `wine`. The pattern:
```
panel-<game>:latest # custom image: debian + wineHQ + xvfb + setpriv
↓ docker run
container starts as root
↓ stage 1: chown volumes, symlink /Logs → save volume, drop caps
exec setpriv → user `panel`
↓ stage 2 (panel user): stamp env into game INI, then
xvfb-run -a wine /game/.../Win64-Shipping.exe ARGS
```
Per-game entrypoints live under `modules/<id>/entrypoint.sh`. They all:
- Refuse to run wine as root (wine policy).
- Allocate a fresh X display via `xvfb-run -a` to avoid colliding with
host Xvfb (a bug we hit when host networking is on — see
`memory/gotchas.md` "network_mode: host Xvfb collision").
- Stamp non-empty env values into the game's INI/JSON config files
*idempotently* — empty env means "don't touch this key", so operators
who edit the INI directly via the Files tab won't get clobbered.
- Trap SIGTERM and call `wineserver -k` for clean shutdown.
The shared SteamCMD sidecar runs `+@sSteamCmdForcePlatformType windows`
to fetch the Windows depot onto a Linux volume.
## `panelctl` CLI
Two surfaces in one binary at `./controller/cmd/panelctl`.
**Operator gRPC** — talks to the Controller's gRPC Panel service. Day-to-day
server lifecycle.
```
panelctl agents
panelctl instances [agent-id]
panelctl create <agent-id> <instance-id> <module-id>
panelctl start <agent-id> <instance-id>
panelctl stop <agent-id> <instance-id> [grace-seconds]
panelctl delete <agent-id> <instance-id> [--purge]
panelctl update <agent-id> <instance-id> [provider-id]
panelctl rcon <agent-id> <instance-id> <command...>
panelctl watch [instance-id]
panelctl backup create <agent-id> <instance-id> [description]
panelctl backup restore <instance-id> <backup-id>
panelctl modules
```
**Admin HTTP** — out-of-band repair. Drives the recreate dance
(env-config edits, ARK cluster rebinds, generic recreate with mount
overrides) without needing a dashboard session. Auth is loopback-bypass
when run on the Controller host, OR a long-lived token at
`<data-dir>/admin-token` (auto-generated on first start, mode 0600).
```
panelctl admin token-show
panelctl admin status
panelctl admin instances
panelctl admin ark-rebind <instance-id>
panelctl admin set-config <instance-id> KEY=VALUE [KEY=VALUE ...]
panelctl admin del-config <instance-id> KEY [KEY ...]
panelctl admin recreate <instance-id> [--mount CONTAINER_PATH=HOST_PATH ...]
```
Common flags:
```
--http URL Default http://localhost:8080
--admin-token TOK Or $PANEL_ADMIN_TOKEN, or read from <data-dir>/admin-token
--data-dir DIR Default ./data
```
## HTTP API
All `/api/*` require an authenticated session cookie. `/api/login` is
the only unauthenticated endpoint.
| Method | Path | What it does |
|---|---|---|
| `POST` | `/api/login` | Issues session cookie |
| `POST` | `/api/logout` | Revokes session |
| `GET` | `/api/me` | Current user + role |
| `POST` | `/api/change-password` | Rotate own password |
| `GET` | `/api/agents` | Live-connected agents |
| `GET` | `/api/modules` | Union of modules across agents |
| `GET` | `/api/instances[?agent_id=]` | Persisted instance rows |
| `POST` | `/api/instances` | Create |
| `POST` | `/api/instances/{id}/start` | Start |
| `POST` | `/api/instances/{id}/stop[?grace=N]` | Stop (container retained) |
| `DELETE` | `/api/instances/{id}[?purge=true]` | Delete instance + optionally volumes |
| `POST` | `/api/instances/{id}/rcon` | Ad-hoc RCON exec |
| `POST` | `/api/instances/{id}/update` | Trigger update provider |
| `GET` | `/api/instances/{id}/files[?path=]` | List |
| `GET` | `/api/instances/{id}/files/read?path=` | Read file |
| `POST` | `/api/instances/{id}/files` | Write file |
| `DELETE` | `/api/instances/{id}/files?path=` | Delete |
| `GET` | `/api/instances/{id}/backups` | List backups |
| `POST` | `/api/instances/{id}/backups` | Create backup |
| `POST` | `/api/instances/{id}/backups/{bkpId}/restore` | Restore |
| `DELETE` | `/api/instances/{id}/backups/{bkpId}` | Remove DB row |
| `GET` | `/api/schedules[?instance_id=]` | List schedules |
| `POST` | `/api/schedules` | Create (cron **or** event trigger) |
| `DELETE` | `/api/schedules/{id}` | Remove |
| `POST` | `/api/schedules/{id}/{enable\|disable}` | Toggle |
| `GET` | `/api/events[?instance=&agent=]` | SSE stream (supports `Last-Event-ID` / `?last_event_id=` resume) |
| `GET` | `/api/template-catalog` | 236-entry game template catalog |
| `POST` | `/api/scaffold-module` | Admin: scaffold a new `modules/<id>/` from a catalog entry |
| `GET` | `/api/instances/{id}/update-check` | Last cached update-check result |
| `POST` | `/api/instances/{id}/update-check` | Run a check (installed build vs Steam `app_info`) |
| `GET` | `/api/update-checks` | All instances' update-check state |
| `GET` | `/metrics` | Prometheus metrics (outside `/api`; admin token or loopback only) |
## Admin HTTP (out-of-band repair)
Sits **outside** the session middleware. Auth: loopback-bypass OR
`X-Panel-Admin-Token: <hex>` header (constant-time compared against
`<data-dir>/admin-token`, auto-generated mode 0600).
| Method | Path | What it does |
|---|---|---|
| `GET` | `/admin/v1/health` | `{ok, version, hostname, loopback, time}` |
| `GET` | `/admin/v1/instances` | All instances + `config_values` + `assigned_ports` + `ark_cluster_id` |
| `POST` | `/admin/v1/instances/{id}/ark-cluster-rebind` | Re-runs the cluster mount recreate against the registry's current cluster id |
| `POST` | `/admin/v1/instances/{id}/config` | Body `{"set":{...},"delete":[...]}` — merges config_values, runs the env-config recreate dance |
| `POST` | `/admin/v1/instances/{id}/recreate` | Body `{"mount_overrides":{...}}` — generic stop → delete-preserve → create → start |
The point of this surface: when the dashboard is broken, you can SSH
in, hit `/admin/v1/*` with curl, and rescue an instance.
## Scheduler
Two trigger kinds.
**cron** — robfig/cron v3, 6-field (seconds resolution):
```json
{
"instance_id": "mc-1",
"trigger_kind": "cron",
"cron_spec": "0 0 3 * * *",
"action": {"type": "instance", "op": "restart", "grace_seconds": 30}
}
```
**event** — CEL expression over the live event bus, with optional
sustain-for window. The classic example: stop the server if it's been
empty for 30 minutes.
```json
{
"instance_id": "mc-1",
"trigger_kind": "event",
"event_spec": "event_type == \"app_state\" && players_online == 0",
"event_sustained_seconds": 1800,
"action": {"type": "instance", "op": "stop", "grace_seconds": 60}
}
```
CEL input fields available at evaluation time:
```
event_type "join" | "leave" | "chat" | "app_state" | "log_match" | …
instance_id string
agent_id string
players_online int
players_max int
uptime_seconds int
instance_status "running" | "stopped" | "starting" | "exited"
exit_code int (only on exit events)
player_kind "join" | "leave" | "chat"
player_name string
player_id string
player_detail string
log_line string (raw matched line)
log_stream "stdout" | "stderr"
```
Action types:
```json
{"type": "rcon", "command": "saveworld"}
{"type": "instance", "op": "start" | "stop" | "restart", "grace_seconds": 30}
```
## Mods & Workshop
Per-game module integrations live in
`controller/cmd/controller/<game>mods.go`. The current set:
- **ARK SA** — CurseForge API, search + install + version pin. Cluster
registry tracks shared `ClusterDirOverride` mounts; rebind via Admin
HTTP rewrites all member instances atomically.
- **DayZ** — Steam Workshop item search, mod-priority load order, deploy
via SteamCMD sidecar.
- **Conan Exiles** — Steam Workshop with `requiredtags[]=Enhanced|Legacy`
filtering driven by the instance's `EDITION` config_value, so UE5
servers don't see UE4-only mods. `ServerModList=` stamped into
ServerSettings.ini and `ConanSandbox/Mods/modlist.txt`.
- **V Rising** — PlasmaCore + community .pak drops, install path
pinned per-instance.
- **Empyrion** — custom scenario manager (zip uploads + scenario
selector), discovery streamer for in-game POI markers.
- **Barotrauma** — Steam Workshop subscriptions written into the
client/server `WorkshopMods/` tree.
Volumes are reused across instances where it's safe: e.g. all Conan
instances on a given agent share `panel-conan-workshop` for downloaded
Workshop content, mounted at the game's expected `/game/steamapps/workshop`
path.
## Backups
`POST /api/instances/{id}/backups` spawns a `tar-sidecar` container
that mounts the same volumes as the game container (read-only on the
data side) and writes a tarball to the **agent's** `--backup-dir`
(default `./data/backups/<instance-id>/`). Restore reverses the flow,
with the game container stopped first.
Backups are timestamped and labeled with an optional description, and
can be scheduled (scheduler action `{"type":"backup"}`). Retention is
manual right now — list, pick, delete. See [docs/BACKUP.md](docs/BACKUP.md).
## File manager
Three operations against an instance's `browseable_root` (or any
`browseable_roots[i].path`):
- **List** (`GET /files?path=`) — `docker exec ls -la --full-time`
parsed into typed entries.
- **Read** (`GET /files/read?path=`) — `docker cp` to a temp file,
stream back. Caps at a sane size; bigger files prompt for download.
- **Write** (`POST /files`) — temp file, `docker cp`, `chown`.
- **Delete** (`DELETE /files`) — `docker exec rm`. Symlinks are
resolved with `realpath` first; path traversal blocked at the
manifest's declared roots.
Bind-mounted volumes get a fast-path that skips `docker cp` entirely.
## Repository layout
```
panel/
├── agent/ # Per-host agent
│ ├── cmd/agent/ # Binary entrypoint
│ └── internal/
│ ├── dispatch/ # gRPC reply handlers — one file per concern
│ ├── module/ # Module loader + resolver
│ ├── rcon/ # Source/Telnet/MC RCON adapters
│ ├── runtime/ # Docker driver
│ ├── state/ # Per-instance state tracker
│ └── updater/ # SteamCMD/HTTP/GitHub sidecars
├── controller/
│ ├── cmd/
│ │ ├── controller/ # Web + gRPC server
│ │ │ └── static/ # Embedded dashboard (new.html + rcc.js/rcc.css;
│ │ │ # index.html = frozen classic; catalog.json)
│ │ └── panelctl/ # CLI
│ └── internal/
│ ├── db/ # SQL migrations + queries
│ ├── scheduler/ # cron + CEL
│ └── eventbus/ # SSE + agent fanout
├── pkg/module/ # Shared module-manifest parsing
├── proto/panel/v1/ # gRPC contract (buf-managed)
├── modules/ # 26 modules — drop-in YAML + scripts
│ └── <game>/
│ ├── module.yaml
│ ├── Dockerfile # if a custom image is needed
│ └── entrypoint.sh
├── docs/ # INSTALL, NETWORKING, PAIRING, ADMIN, BACKUP, architecture
├── docker-compose.dev.yml # Postgres only
├── go.work # Multi-module workspace
└── memory/ # Maintainer's working notes (not user docs)
```
## Data directory layout
`data/` is gitignored. On a fresh install you'll grow:
```
data/
├── ark-clusters.json # Operator-curated cluster config (TRACKED)
├── arkcluster/ # Per-cluster shared state
├── backups/ # Tarballs from tar-sidecar
├── ca/ # Internal CA private key + server cert (mTLS)
├── certs/ # Issued agent certs
├── instance-meta/<id>.json # Per-instance metadata (rcon password, paths)
└── instances/<id>/ # Bind-mount volumes per instance
├── logs/
└── ...
```
`admin-token` lives at `<data-dir>/admin-token` (mode 0600,
auto-generated on first controller start).
## Build
Multi-module Go workspace. The root `go.work` ties together
`agent/`, `controller/`, `pkg/`, `proto/`.
```bash
# Generate proto stubs (once after a clone, and after any .proto change)
buf generate
# Linux/Mac:
go build -o bin/controller ./controller/cmd/controller
go build -o bin/agent ./agent/cmd/agent
go build -o bin/panelctl ./controller/cmd/panelctl
# Windows (PowerShell):
go build -o controller.exe .\controller\cmd\controller
go build -o agent.exe .\agent\cmd\agent
go build -o panelctl.exe .\controller\cmd\panelctl
```
Cross-compile a Linux agent from a Windows host:
```powershell
$env:GOOS="linux"; $env:GOARCH="amd64"
go build -o bin/agent-linux ./agent/cmd/agent
Remove-Item Env:GOOS, Env:GOARCH
```
## Working with the embedded UI
The default dashboard is split across three embedded files in
`controller/cmd/controller/static/`: `new.html` (shell), `rcc.js`
(all feature JS), `rcc.css` (design system) — plain JS + CSS, no build
step. `/rcc.js` and `/rcc.css` are served hash-stamped with immutable
cache headers, so browsers pick up new builds without cache pain. The
legacy single-file `index.html` (~24k lines) is the **frozen classic UI**
— it carries a freeze notice, is only served on `panel_ui=classic`, and
no new features land there.
- Everything is embedded via `//go:embed`, so editing any static file
requires a controller rebuild + restart.
- **Before any controller build that touched the UI**, run the parse gate:
`node controller/.test/gate-parse.mjs` — a single JS syntax error blanks
the whole dashboard and `go build` cannot catch it.
- Per-game Config tabs are keyed by module id with a small schema
(`groups[].fields[]` with `{prop, label, type, options, hint}`) that the
form renderer turns into INI/JSON read+write against the game's config
file via the file manager API.
- Module icon/art/accent + readiness regex are manifest-driven:
`appearance:` and `ready_pattern:` in `module.yaml`, served via
`GET /api/modules` (inline maps remain as fallback).
- The shared form for `config_values` (env-driven settings) is rendered
on the dashboard's *Config Values* sub-tab; this is separate from the
game-specific config-file editors above.
## Troubleshooting
- **"agent connected but no modules"** — agent's `--modules-dir` is
wrong, or the manifest failed to parse. Check the agent's stderr.
- **"create succeeded but instance never starts"** — for SteamCMD-driven
modules, the install volume is empty. Click *Update* once first; the
entrypoints exit `EX_CONFIG (78)` with a hint when they can't find
the dedicated server binary.
- **"environment variable I added to module.yaml never reaches the
container"** — you forgot to pre-declare the key under
`runtime.docker.env`. resolve.go silently filters unknown keys.
- **"dashboard broken, can't recover"** — `panelctl admin recreate
<instance-id>`. If the controller is also down, the data directory
+ Postgres dump is enough to spin up a replacement.
- **Wine modules: container stays "running" but the game never binds
ports** — usually a top-level launcher stub problem. Invoke the deep
`Binaries/Win64/<Game>Server-Win64-Shipping.exe` directly (see Conan's
entrypoint for the canonical pattern).
- **`network_mode: host` + Xvfb collision** — when two host-net
containers both run xvfb on the same display number you'll see "Server
is already active for display" in stderr. Always use `xvfb-run -a` to
auto-allocate.
The maintainer's running list of caught-on-fire-once notes lives in
[`memory/gotchas.md`](memory/gotchas.md).
## Contributing / writing a new module
1. Create `modules/<id>/module.yaml`. Easiest is to copy the closest
existing module — Source-RCON game? Start from Conan's. Wine? V
Rising's. Plain Linux dedicated server? Factorio's.
2. If you need a custom image, add a `Dockerfile` next to it. Include
`setpriv`, `xvfb-run`, and `wine` for Windows-on-Linux modules.
3. Pre-declare every env key that any `config_values` entry maps to
inside `runtime.docker.env`. Empty defaults are fine.
4. Restart the agent — modules load on boot. The new game appears in
the dashboard's create-instance dropdown.
5. Test the lifecycle end-to-end: create → update → start → connect a
client → stop → delete (no purge) → recreate → verify world saves
survived.
6. If the game has a Workshop / mods scene, add a `<game>mods.go` to
`controller/cmd/controller/` and surface it in the dashboard's per-game
Mods tab.
PRs welcome. The maintainer's editorial preferences are recorded in
[`memory/ux_rules.md`](memory/ux_rules.md) — read it before redesigning
any tab.
## License
Apache-2.0 — see [LICENSE](LICENSE).