658bda1d24
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
824 lines
36 KiB
Markdown
824 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://raw.githubusercontent.com/dbledeez/panel/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).
|
|
|
|
```
|
|
┌─────────────────────────┐
|
|
│ 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).
|