Self-hostable game server control panel: Go controller + agent, 26 game modules, embedded web UI. One-line install via install.sh / install.ps1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.
# Linux (Debian/Ubuntu/Fedora/RHEL/Raspbian) — run as root:
curl -fsSL https://raw.githubusercontent.com/dbledeez/panel/main/install.sh | sudo bash
# 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.
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:# 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.shSetting
PANEL_PREBUILT_URLto an empty value forces a from-source build againstPANEL_REPO_URL. See 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 | Installers, docker-compose, from-source, flags reference, systemd, reverse proxy + TLS, firewall |
| docs/NETWORKING.md | Per-game port table, router port-forwarding, what the panel auto-allocates |
| docs/PAIRING.md | mTLS agent pairing, multi-host setup |
| docs/ADMIN.md | First-admin bootstrap, password reset, Steam sign-in, Steam credentials for paid games |
| docs/BACKUP.md | Backups, pg_dump, disaster-recovery runbook |
| docs/architecture.md | The real stack, component by component |
Table of contents
- Status
- Why panel
- Architecture
- Quick start (dev)
- Production-ish layout
- Modules
- Module manifest
- Wine/Xvfb modules
panelctlCLI- HTTP API
- Admin HTTP (out-of-band repair)
- Scheduler
- Mods & Workshop
- Backups
- File manager
- Repository layout
- Data directory layout
- Build
- Working with the embedded UI
- Troubleshooting
- Contributing / writing a new module
- 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)
- ✅ mTLS agent pairing: one-time token minted in the UI →
agent --pair-tokenfetches 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) - ✅ 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-IDresume + snapshot) +panelctl watch - ✅ Template Library: 236-entry game catalog (
static/catalog.json, distilled from CubeCoders' MIT templates bytools/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.yamlinmodules/<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, andpanelctl admin recreate/set-config/ark-rebindstill 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.htmlshell +static/rcc.js+static/rcc.css, served hash-stamped with immutable caching via//go:embed; the legacy single-filestatic/index.htmlis the frozen "classic" UI, opt-in via thepanel_ui=classiccookie) - 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-dirat boot (default./modules) - Drives the local Docker daemon (
/var/run/docker.sockornpipe:////./pipe/docker_engineon 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_dirmounted at the instance's game-content volume; supports+@sSteamCmdForcePlatformType windowsfor 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 WebRCONstdio— 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 on PATH for proto stubs.
# 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:
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 — read
that before debugging any specific game.
Module manifest
Every module is one declarative YAML file. The schema is in
pkg/module/manifest.go. A condensed example:
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.envget forwarded to the container. Adding aconfig_valuesentry without the matching env declaration silently drops the value — this trips people up. volumes:entries use$INSTANCE_IDand$DATA_PATHsubstitutions resolved by the agent at create time.internal: trueports are not exposed by the allocator but still get theirenv:substituted.update_providersare picked from a dropdown in the UI; multiple providers per module is fine (Conan shipsenhancedandlegacy).
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 -ato avoid colliding with host Xvfb (a bug we hit when host networking is on — seememory/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 -kfor 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):
{
"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.
{
"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:
{"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
ClusterDirOverridemounts; 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|Legacyfiltering driven by the instance'sEDITIONconfig_value, so UE5 servers don't see UE4-only mods.ServerModList=stamped into ServerSettings.ini andConanSandbox/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.
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-timeparsed into typed entries. - Read (
GET /files/read?path=) —docker cpto 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 withrealpathfirst; 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/.
# 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:
$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 andgo buildcannot 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:andready_pattern:inmodule.yaml, served viaGET /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-diris 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.exedirectly (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 usexvfb-run -ato auto-allocate.
The maintainer's running list of caught-on-fire-once notes lives in
memory/gotchas.md.
Contributing / writing a new module
- 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. - If you need a custom image, add a
Dockerfilenext to it. Includesetpriv,xvfb-run, andwinefor Windows-on-Linux modules. - Pre-declare every env key that any
config_valuesentry maps to insideruntime.docker.env. Empty defaults are fine. - Restart the agent — modules load on boot. The new game appears in the dashboard's create-instance dropdown.
- Test the lifecycle end-to-end: create → update → start → connect a client → stop → delete (no purge) → recreate → verify world saves survived.
- If the game has a Workshop / mods scene, add a
<game>mods.gotocontroller/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 — read it before redesigning
any tab.
License
Apache-2.0 — see LICENSE.