# 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://: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//`, 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//` 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\d+)\s+of\s+(?P\d+) players' fields: { players_online: 1, players_max: 2 } events: join: { pattern: "(?P\\S+) joined", kind: join } leave: { pattern: "(?P\\S+) left", kind: leave } chat: { pattern: "<(?P[^>]+)> (?P.+)", 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-: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//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 panelctl start panelctl stop [grace-seconds] panelctl delete [--purge] panelctl update [provider-id] panelctl rcon panelctl watch [instance-id] panelctl backup create [description] panelctl backup restore 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 `/admin-token` (auto-generated on first start, mode 0600). ``` panelctl admin token-show panelctl admin status panelctl admin instances panelctl admin ark-rebind panelctl admin set-config KEY=VALUE [KEY=VALUE ...] panelctl admin del-config KEY [KEY ...] panelctl admin recreate [--mount CONTAINER_PATH=HOST_PATH ...] ``` Common flags: ``` --http URL Default http://localhost:8080 --admin-token TOK Or $PANEL_ADMIN_TOKEN, or read from /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//` 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: ` header (constant-time compared against `/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/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//`). 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 │ └── / │ ├── 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/.json # Per-instance metadata (rcon password, paths) └── instances// # Bind-mount volumes per instance ├── logs/ └── ... ``` `admin-token` lives at `/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 `. 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/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//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 `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).