panel v0.9.1 — open-source game server manager

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 23:18:05 -07:00
commit 4cf3471398
2161 changed files with 300831 additions and 0 deletions
+129
View File
@@ -0,0 +1,129 @@
# Admin accounts, password reset, and Steam
## First-admin bootstrap
On every start the controller checks the `users` table
(`controller/cmd/controller/auth.go`, `bootstrapAdmin`). If it's
**empty**, it creates:
- email: `admin@panel.local`
- role: `admin`
- password: **randomly generated** and printed to the log inside a loud
`ADMIN BOOTSTRAP` banner (systemd: `journalctl -u panel-controller`;
compose: `docker compose logs controller`). Log in once and change it
via the UI.
To pre-seed a stable password instead (dev, CI, unattended installs),
set `PANEL_ADMIN_PASSWORD` in the controller's environment before first
boot. The banner then says the password came from the env var and never
prints it.
Sessions are cookie-based (`panel_session`); passwords are stored as
argon2id hashes.
## Resetting a lost admin password
Use the `reset-admin` tool (`controller/cmd/reset-admin`). It writes the
DB directly, so it works even when you can't log in:
```bash
go build -o bin/reset-admin ./controller/cmd/reset-admin # or use a prebuilt copy
./bin/reset-admin --email admin@panel.local --password 'NewS3cret' \
--db-url 'postgres://panel:...@127.0.0.1:5432/panel?sslmode=disable'
# → reset-admin: password updated for admin@panel.local
```
`--db-url` falls back to `$PANEL_DB_URL`, then to the controller's dev
default DSN. The controller does **not** need a restart — the next login
verifies against the new hash. Existing sessions stay valid; log out /
delete rows from `sessions` if you want them revoked.
Hash-only mode (no DB access from the tool — e.g. you only have `psql`):
```bash
./bin/reset-admin 'NewS3cret'
# → $argon2id$v=19$m=65536,t=3,p=2$...$...
```
then apply it manually:
```sql
UPDATE users
SET password_hash = '$argon2id$v=19$m=65536,t=3,p=2$...$...',
updated_at = NOW()
WHERE email = 'admin@panel.local';
```
(Quote the hash in single quotes — it contains `$`.)
With the managed `panel-postgres` container:
```bash
docker exec -it panel-postgres psql -U panel -d panel
```
## Sign in with Steam (operator login)
Optional OpenID login for the dashboard. Two controller flags:
- `-public-url https://panel.example.com` — the public base URL of the
dashboard. Steam's OpenID flow redirects back to
`<public-url>/api/auth/steam/callback`, so this must be the URL your
browser actually uses (reverse-proxied name included).
- `-initial-admin-steam-id 7656119…` — your 17-digit SteamID64.
- Empty DB → the bootstrap admin is created with this Steam ID linked;
"Sign in with Steam" works from the first boot.
- Existing DB → the first admin gets retro-linked, **if** it has no
Steam ID yet (an existing different link is never clobbered).
Users can also link/unlink Steam on their own account from the UI
(`/api/auth/steam/link-start`). Steam-linked users may sign in with
either method; Steam-only users (created by an admin via
`POST /api/users`) have no password at all.
Find your SteamID64 at steamid.io or via your profile URL.
## Steam credentials for paid games (`requires_steam_login`)
Some dedicated servers refuse SteamCMD's `+login anonymous` — DayZ
(app 223350) is the shipped example; its provider declares
`requires_steam_login: true` in `modules/dayz/module.yaml`. Those
downloads need a real Steam account **that owns the game**.
How you supply them — you don't pre-configure anything:
1. Click **Update** (or create) on such an instance.
2. No cached credential → the API answers
`{"steam_login_required": true, ...}` and the dashboard pops the
**Steam login modal**.
3. Enter Steam username + password. If the account has Steam Guard,
the first attempt returns `{"need_guard": true}` and the modal
re-prompts for the emailed / mobile-authenticator code.
4. The controller runs a one-shot SteamCMD sidecar to validate the
login, then re-runs your original update with the now-cached
credential.
Storage (see the header of `controller/cmd/controller/steamauth.go`):
- The password is encrypted at rest with AES-GCM; the key is
HKDF-derived from the internal CA private key (so restoring
`data/ca` + the Postgres dump restores decryptability — no separate
key file).
- The Steam Guard **sentry** blob is stored and mounted into every later
SteamCMD run, so 2FA is a one-time ceremony per account.
- The password is never logged and never sent back to the UI.
Managing stored accounts: `GET /api/steam/accounts` lists them (no
secrets), `DELETE /api/steam/accounts` removes one. Use a throwaway
account that owns only the games you host if you're uncomfortable
storing your main account's credential.
## Out-of-band admin (dashboard is broken)
A separate token-authed HTTP surface (`/admin/v1/*`) plus
`panelctl admin` exist for repair when the UI is down — see the
"Admin HTTP" section of the [README](../README.md). Auth is loopback
bypass on the controller host, or the `X-Panel-Admin-Token` header with
the token from `<data-dir>/admin-token` (auto-generated, mode 0600;
`panelctl admin token-show` prints it).
+106
View File
@@ -0,0 +1,106 @@
# Backups and disaster recovery
Two different things need backing up:
1. **Instance backups** — game saves/worlds, made by the panel itself.
2. **Panel state** — the Postgres database + the controller's `data/`
directory. The panel does NOT back these up for you.
## Instance backups (panel-side)
`POST /api/instances/{id}/backups` (dashboard Backups tab, or
`panelctl backup create <agent-id> <instance-id> [description]`) spawns
a tar-sidecar container that mounts the instance's volumes and writes a
timestamped tarball to the **agent's** `--backup-dir` (default
`./data/backups/<instance-id>/` on the agent host — not the controller,
unless they're the same box).
Restore stops the game container first, then reverses the flow:
dashboard, or `panelctl backup restore <instance-id> <backup-id>`.
`panelctl backup list <instance-id>` lists.
**Scheduling:** the scheduler has a `backup` action type, so a nightly
auto-backup is one schedule row:
```json
{
"instance_id": "mc-1",
"trigger_kind": "cron",
"cron_spec": "0 0 4 * * *",
"action": {"type": "backup", "description": "Nightly auto-backup"}
}
```
**Retention is manual.** Nothing prunes old tarballs — list, pick,
delete (UI or `DELETE /api/instances/{id}/backups/{bkpId}`, which
removes the DB row). Watch your disk if you schedule backups.
Instance backup tarballs live outside Postgres; copy
`data/backups/` off-host if you want them to survive the machine.
## Panel state: what to save
| What | Where | Why |
|---|---|---|
| Postgres dump | `pg_dump` (below) | users, agents, instances, assigned ports, schedules, backup index, Steam credential ciphertext |
| `data/ca/` | controller host | internal CA — all agent certs chain to it, and the Steam-credential encryption key is derived from it. **Losing it = re-pairing every agent + re-entering Steam creds.** |
| `data/admin-token` | controller host | out-of-band admin auth (regenerable, but scripts may pin it) |
| `data/certs/` | each agent host | that agent's cert (regenerable by re-pairing) |
| `data/instance-meta/` | each agent host | rehydrated from the controller on connect — no need to back up |
| `data/instances/` + named volumes | each agent host | the actual game data — covered by instance backups above, or copy raw |
## pg_dump procedure
Managed `panel-postgres` container (from `install.sh` / compose):
```bash
docker exec panel-postgres pg_dump -U panel -d panel -Fc \
> panel-$(date +%F).dump
```
External Postgres: `pg_dump -h dbhost -U panel -d panel -Fc > panel.dump`.
Cron it; the dump is small (panel metadata, not game data).
## DR runbook — restore to a new host
Assumes you have: a Postgres dump, a copy of the controller's `data/`
directory (at minimum `data/ca/`), and (optionally) instance backup
tarballs / raw instance data from the agent hosts.
1. **Fresh install, controller only:**
```bash
PANEL_SKIP_AGENT=1 bash install.sh
sudo systemctl stop panel-controller
```
2. **Restore the database** (drop the schema the fresh boot created):
```bash
docker exec -i panel-postgres psql -U panel -d postgres \
-c 'DROP DATABASE panel;' -c 'CREATE DATABASE panel OWNER panel;'
docker exec -i panel-postgres pg_restore -U panel -d panel < panel-YYYY-MM-DD.dump
```
3. **Restore `data/`** over the fresh one — `data/ca/` is the critical
piece; keep ownership (`chown -R panel:panel /opt/panel/data`).
4. **Start the controller:** `sudo systemctl start panel-controller`.
Because the users table is non-empty, no new admin is bootstrapped —
your old logins work.
5. **Agents:**
- Same agent hosts, new controller IP/name → update `--controller`
in their units; their existing certs still chain to the restored CA.
If the controller's *hostname* changed, mint the server cert with
the new name (`-tls-hostnames`) — the CA is what matters.
- New agent hosts → pair them fresh ([PAIRING.md](PAIRING.md)) using
the **same agent-id** as before so instance rows re-attach, then
restore game data into `--data-root` / named volumes (or use the
panel's Restore on each instance from copied-in tarballs under
`--backup-dir/<instance-id>/`).
6. **Verify:** dashboard lists agents as connected, instances present;
start one instance and check its world loaded.
## What is NOT covered
- No automatic offsite copies — everything above is manual/cron.
- Instance backup tarballs sit on the same disk as the instances by
default; point `--backup-dir` at other storage if that worries you.
- Steam Guard sentry blobs restore with the DB + `data/ca`; if either is
lost you'll redo the Steam login ceremony ([ADMIN.md](ADMIN.md)).
+313
View File
@@ -0,0 +1,313 @@
# Installing panel
Four supported paths, in order of least effort:
1. [Linux one-liner](#linux-one-liner) — `install.sh` (recommended)
2. [Windows one-liner](#windows-one-liner) — `install.ps1`
3. [docker-compose](#docker-compose) — controller + agent + Postgres as containers
4. [From source](#from-source) — full manual control
Then: [Postgres provisioning](#postgres-provisioning),
[flags reference](#controller-flags), [systemd](#systemd),
[reverse proxy + TLS](#reverse-proxy--real-tls), [firewall](#firewall-basics).
---
## Linux one-liner
Debian, Ubuntu, Fedora, CentOS/RHEL, Raspbian. Run as root:
```bash
curl -fsSL https://raw.githubusercontent.com/dbledeez/panel/main/install.sh | sudo bash
```
What it does (idempotent — safe to re-run; existing containers, units,
CA, DB and passwords are kept, binaries and modules refreshed):
1. Installs Docker via `get.docker.com` if missing.
2. Starts a managed `panel-postgres` container (unless you point it at
an existing DB with `PANEL_DB_URL`).
3. Builds (or downloads, with `PANEL_PREBUILT_URL`) `controller`,
`agent`, and `panelctl` into `/opt/panel/bin/`.
4. Boots the controller once to bootstrap the internal CA and the admin
account, then pairs the local agent over mTLS.
5. Installs and enables `panel-controller.service` and
`panel-agent.service`.
Tunables (env vars, all optional — from `install.sh`'s header):
| Env var | Meaning | Default |
|---|---|---|
| `PANEL_REPO_URL` | git/https base of the panel source | github repo |
| `PANEL_PREBUILT_URL` | tarball with prebuilt `bin/{controller,agent,panelctl}` + `modules/` (skips the Go build) | — |
| `PANEL_VERSION` | version string baked into the binaries | `git describe \|\| dev` |
| `PANEL_DIR` | install prefix | `/opt/panel` |
| `PANEL_DATA_DIR` | data directory | `$PANEL_DIR/data` |
| `PANEL_HTTP_PORT` | dashboard port | `8080` |
| `PANEL_GRPC_PORT` | agent gRPC port | `8443` |
| `PANEL_ADMIN_PASSWORD` | stable first-boot admin password | generated by the controller |
| `PANEL_DB_URL` | use an EXISTING Postgres instead of the managed container | — |
| `PANEL_DB_PASSWORD` | password for the managed `panel-postgres` container | generated |
| `PANEL_SKIP_AGENT=1` | controller only (add agents on other boxes later — see [PAIRING.md](PAIRING.md)) | `0` |
Example — controller-only box on a custom port with a pinned admin password:
```bash
PANEL_SKIP_AGENT=1 PANEL_HTTP_PORT=9090 PANEL_ADMIN_PASSWORD='s3cret' \
bash install.sh
```
## Windows one-liner
PowerShell 5+, run **as Administrator**:
```powershell
irm https://git.pdxtechs.com/dbledeez/panel/raw/branch/main/install.ps1 | iex
```
**Docker Desktop** is required (it runs Postgres + your game servers). If it
isn't installed, the installer offers to install it for you via `winget`.
Because Docker Desktop needs WSL2/virtualization and usually a **reboot**, the
Windows install is effectively two steps the first time:
1. Run the command above. If Docker is missing it installs Docker Desktop and
then asks you to reboot + start Docker Desktop (wait for the whale icon to
settle).
2. Re-run the exact same command — it picks up from there and finishes.
To skip the prompt: `-InstallDocker` (or `PANEL_INSTALL_DOCKER=1`) auto-installs
Docker without asking; `-SkipDockerInstall` keeps the old fail-if-missing
behavior. On a box that already has Docker Desktop running, it's a genuine
one-liner.
> **Windows note:** the controller runs great on Windows. Running game
> *servers* on Windows uses Docker Desktop, where the agent automatically
> switches modules from host networking to published bridge ports so they're
> reachable — but the smoothest, fully-proven game-hosting path is a Linux
> agent. See [NETWORKING.md](NETWORKING.md).
Parameters (mirror the Linux env vars): `-RepoUrl`, `-PrebuiltUrl`,
`-Version`, `-InstallDir` (default `C:\Panel`), `-HttpPort` (8080),
`-GrpcPort` (8443), `-AdminEmail`, `-AdminPassword`, `-DbUrl`, `-DbPassword`,
`-DbContainer`, `-DbPort`, `-DbVolume`, `-SkipAgent`, `-InstallDocker`,
`-SkipDockerInstall`. The same `PANEL_*` env vars are honored as defaults.
When run in a real console it prompts you to choose the admin email + password
up front (no log-reading); when piped (`irm | iex`) it uses env/param values or
generates a password.
The controller and agent are registered as **logon-time Scheduled
Tasks**. For a hardened always-on Windows service, wrap the same command
lines with [nssm](https://nssm.cc):
`nssm install panel-controller C:\Panel\bin\controller.exe ...`.
## docker-compose
A production single-box stack (Postgres + controller + agent) lives at
`deploy/docker-compose.yml`. From the repo root:
```bash
cp deploy/.env.example deploy/.env # then edit deploy/.env (PANEL_DB_PASSWORD is required)
docker compose -f deploy/docker-compose.yml --env-file deploy/.env up -d --build
```
First run: watch `docker compose logs controller` for the
`ADMIN BOOTSTRAP` banner (generated admin password), then mint a pair
token in the UI and pair the agent — see [PAIRING.md](PAIRING.md).
> **Security note (from the compose file itself):** the agent container
> mounts the **host** Docker socket to manage game-server containers.
> Docker-socket access is root-equivalent on the host. Run this stack
> only on a host you fully trust and keep the dashboard behind auth/TLS.
## From source
Prereqs: Go 1.26+, Docker, [`buf`](https://buf.build/docs/installation/)
on PATH for proto stubs.
```bash
buf generate # proto stubs (once per clone / .proto change)
go build -o bin/controller ./controller/cmd/controller
go build -o bin/agent ./agent/cmd/agent
go build -o bin/panelctl ./controller/cmd/panelctl
# 1) Postgres (dev container) — or see "Postgres provisioning" below
docker compose -f docker-compose.dev.yml up -d
# 2) Controller — first run prints the ADMIN BOOTSTRAP banner
./bin/controller
# 3) Agent (same box, dev): plaintext is fine locally
./bin/agent --insecure --modules-dir=./modules \
--data-root=./data/instances --meta-dir=./data/instance-meta \
--backup-dir=./data/backups
```
For production, pair the agent instead of `--insecure`
([PAIRING.md](PAIRING.md)) and run both under systemd (below).
There is also a root `Makefile` (`make build`, `make test`, `make vet`,
`make install-local`, ...).
---
## Postgres provisioning
The controller needs Postgres (17 is what the managed container and
compose stack use; the schema is plain SQL with embedded migrations, run
automatically at controller start).
Bring your own instance:
```sql
CREATE ROLE panel LOGIN PASSWORD 'choose-a-password';
CREATE DATABASE panel OWNER panel;
```
Point the controller at it via either:
- `--db-url 'postgres://panel:...@dbhost:5432/panel?sslmode=disable'`
- `PANEL_DB_URL` env var (honored when `--db-url` is left at its default)
The default (dev) DSN is
`postgres://panel:panel_dev@127.0.0.1:5432/panel?sslmode=disable`.
Or let `install.sh` run the `panel-postgres` container for you.
---
## Controller flags
From `controller/cmd/controller/main.go`:
| Flag | Default | Meaning |
|---|---|---|
| `-grpc` | `:8443` | gRPC listen address (both Agent + Panel services) |
| `-http` | `:8080` | HTTP dashboard + REST API listen address |
| `-ca-dir` | `./data/ca` | directory holding internal CA + server cert (auto-generated on first run) |
| `-tls` | `auto` | `auto` \| `on` \| `off``auto` enables mTLS if CA + server cert present |
| `-tls-hostnames` | — | comma-separated SAN DNS names to add to the server cert (e.g. `panel.example.com`) |
| `-log-level` | `info` | `debug`, `info`, `warn`, `error` |
| `-db-url` | dev DSN | Postgres connection string (env `PANEL_DB_URL` also honored) |
| `-public-url` | — | public base URL of the dashboard (needed for Steam OpenID sign-in — see [ADMIN.md](ADMIN.md)) |
| `-initial-admin-steam-id` | — | 17-digit SteamID64 linked to the first admin, enabling Sign in with Steam ([ADMIN.md](ADMIN.md)) |
Env vars: `PANEL_DB_URL`, `PANEL_ADMIN_PASSWORD` (first-boot admin
password preseed).
## Agent flags
From `agent/cmd/agent/main.go`:
| Flag | Default | Meaning |
|---|---|---|
| `--controller` | `localhost:8443` | controller gRPC address `host:port` |
| `--agent-id` | mTLS cert CN, or hostname when `--insecure` | agent identifier |
| `--modules-dir` | `./modules` | path to modules directory |
| `--data-root` | `./data/instances` | root directory for instance data |
| `--meta-dir` | `./data/instance-meta` | per-instance sidecar metadata (survives agent restart) |
| `--backup-dir` | `./data/backups` | where instance backup archives are written |
| `--cert-dir` | `./data/certs` | holds `agent.crt` + `agent.key` + `ca.crt` after pairing |
| `--heartbeat` | `10s` | heartbeat interval |
| `--insecure` | `false` | force plaintext (no TLS) — dev/local only |
| `--pair-token` | — | pair mode: fetch CA + signed cert from `--pair-url` and exit ([PAIRING.md](PAIRING.md)) |
| `--pair-url` | `http://localhost:8080` | controller HTTP URL for the pair endpoints |
| `--log-level` | `info` | `debug`, `info`, `warn`, `error` |
## systemd
Reference units live in `deploy/systemd/` (`panel-controller.service`,
`panel-agent.service`) — `install.sh` installs them for you. Highlights:
- Both run as the `panel` user from `WorkingDirectory=/opt/panel`; the
agent additionally gets `SupplementaryGroups=docker`.
- `/opt/panel/panel.env` (optional `EnvironmentFile`) overrides
`PANEL_HTTP_PORT`, `PANEL_GRPC_PORT`, `PANEL_DB_URL`,
`PANEL_ADMIN_PASSWORD`, ...
- The controller unit is hardened (`ProtectSystem=strict`,
`ReadWritePaths=/opt/panel`); the agent's hardening is intentionally
lighter because it drives the Docker socket.
```bash
sudo cp deploy/systemd/*.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now panel-controller panel-agent
journalctl -u panel-controller -f # ADMIN BOOTSTRAP banner lives here
```
## Reverse proxy + real TLS
The dashboard speaks plain HTTP on `:8080`; put a reverse proxy with a
real certificate in front of it for anything internet-facing. **Only
proxy the HTTP port** — agents talk to gRPC `:8443` directly (mTLS,
LAN/VPN is fine; see [PAIRING.md](PAIRING.md)).
The dashboard uses SSE (`/api/events`) — disable proxy buffering on
that path or the live console will lag.
### Caddy
```
panel.example.com {
reverse_proxy 127.0.0.1:8080 {
flush_interval -1 # SSE: stream immediately
}
}
```
That's the whole file — Caddy handles Let's Encrypt automatically.
### nginx
```nginx
server {
listen 443 ssl;
server_name panel.example.com;
ssl_certificate /etc/letsencrypt/live/panel.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/panel.example.com/privkey.pem;
client_max_body_size 512m; # scenario/mod zip uploads
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/events { # SSE — no buffering
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 1h;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
}
```
If the proxy terminates TLS with a public hostname and agents pair
through it, add that hostname to the controller cert SANs:
`-tls-hostnames panel.example.com`.
## Firewall basics
On the **controller** box:
| Port | Proto | Who needs it | Expose to internet? |
|---|---|---|---|
| 8080 (HTTP) | tcp | browsers, `panelctl admin`, pairing agents | only via the reverse proxy above |
| 8443 (gRPC) | tcp | agents + `panelctl` | no — LAN/VPN preferred; mTLS protects it if you must |
| 5432 (Postgres) | tcp | controller only | never |
On each **agent** box: no inbound panel ports at all — the agent dials
out to the controller. You only open the **game ports** of the instances
it hosts; see [NETWORKING.md](NETWORKING.md) for the per-game table.
Example (ufw, controller on a LAN):
```bash
sudo ufw allow from 192.168.1.0/24 to any port 8080 proto tcp
sudo ufw allow from 192.168.1.0/24 to any port 8443 proto tcp
```
+105
View File
@@ -0,0 +1,105 @@
# Networking for normal humans
You run panel at home, your friends want to join your server, and you
have a consumer router. This page is for you.
## The 60-second version
1. panel gives every instance its **host ports** at create time (see
[what the panel auto-allocates](#what-the-panel-auto-allocates)).
The dashboard's instance card / Ports panel shows the exact numbers.
2. Log in to your router and **forward those ports** (right protocol —
most games are UDP) to the LAN IP of the **agent box** running that
instance.
3. Give your friends `your-public-ip:game-port`.
You do **not** need to forward the panel's own ports (8080/8443) for
people to *play* — only to administer the panel from outside.
## What the panel auto-allocates
At instance create time the controller assigns each declared port a
**host port** from the agent's configured port window — default
**70009999** (`controller/cmd/controller/portalloc.go`). You can set a
per-agent window in the agent edit modal (e.g. box A gets 70007999,
box B gets 80008999). The allocator:
- reserves ports of **stopped** instances too (they come back),
- probes live sockets so it won't hand out a port some other process holds,
- honors manual overrides from the Ports UI when they fit the window,
- errors when the window is exhausted (widen it or delete instances).
So the *actual* host port is usually **not** the game's default port —
always read it off the dashboard. Ports marked `internal` in the module
manifest (RCON, admin APIs) are **never** exposed on the host and never
need forwarding.
## Per-game port table
Defaults from each `modules/<id>/module.yaml` (`ports:`). "Forward"
means players/queries need it; internal ports are panel-only.
| Game | Port (default) | Proto | Forward? | Notes |
|---|---|---|---|---|
| 7 Days to Die | 26900, 26901, 26902 | udp | yes (all 3, contiguous) | 8080/tcp web dashboard optional; telnet 8081 internal |
| ARK: Survival Ascended | 7777, 7778 game + 27015 query | udp | yes | RCON 27020/tcp internal |
| Barotrauma | 27015 game + 27016 query | udp | yes | |
| Conan Exiles | 7777 game + 7778 pinger + 27015 query | udp | yes | RCON 25575/tcp internal |
| Core Keeper | 27015 | udp | yes | |
| DayZ | 2302 game/query | udp | yes | 2303/2304 optional; BE RCON 2305 internal |
| Dragonwilds | 7777 | udp | yes | |
| Empyrion | 3000030003 (game, query, client, EAC) | udp | yes (all 4) | CSW 30004 internal; EAH API 30007/tcp only if you use EAH |
| Enshrouded | 15637 | udp | yes | |
| Factorio | 34197 | udp | yes | RCON 27015/tcp internal |
| Minecraft Bedrock | 19132 (+19133 IPv6) | udp | yes | |
| Minecraft Java | 25565 | tcp | yes | RCON 25575 internal |
| Palworld | 8211 game + 27015 query | udp | yes | RCON 25575/tcp internal |
| Project Zomboid | 16261, 16262 | udp | yes (both) | RCON 27015/tcp internal |
| Rust | 28015 | udp | yes | RCON 28016 internal; Rust+ 28082/tcp optional |
| Satisfactory | 7777/udp game + 8888/tcp reliable | both | yes (both) | |
| Sons of the Forest | 8766 game + 27016 query | udp | yes | |
| Soulmask | 8777 game + 27015 query | udp | yes | admin 8888/tcp internal |
| Terraria | 7777 | tcp | yes | |
| V Rising | 9876 game + 9877 query | udp | yes | |
| Valheim | 2456, 2457, 2458 | udp | yes (all 3, contiguous) | |
| Vein | 7777 game + 27015 query | udp | yes | RCON 7878/udp internal |
| Windrose | 7777 | udp | yes | |
Remember: these are the *container defaults* — forward the **host ports
the panel actually assigned** (dashboard → instance → Ports).
## Forwarding ports on a generic router
Every router is different; the shape is always:
1. Browse to your router's admin page — usually `http://192.168.1.1` or
`http://192.168.0.1` (it's the "Default Gateway" in
`ipconfig` / `ip route`).
2. Find **Port Forwarding** — sometimes under "NAT", "Virtual Server",
"Gaming", or "Advanced".
3. Add a rule per port (or range): external port = internal port =
the host port panel assigned; protocol per the table above; internal
IP = the LAN address of the **agent** box.
4. Give the agent box a **static LAN IP** (DHCP reservation) so the rule
doesn't break after a reboot.
5. Test from *outside* your network (phone on cellular). Sites like
canyouseeme.org only test TCP — most game ports are UDP, so the real
test is a friend joining.
If you're behind **CGNAT** (ISP gives you a shared public IP — common on
cellular/fiber ISPs), port forwarding can't work; use a VPN/tunnel
(WireGuard on a cheap VPS, playit.gg, Tailscale Funnel, etc.) or ask
your ISP for a public IP.
## What about `opnfwd`?
You may notice an "OPNsense auto-forward" card in the admin settings and
`opnfwd_*` code in the tree. That is **maintainer-specific automation**
for a home lab running an OPNsense firewall: when enabled, the panel
calls the OPNsense API to create port forwards automatically on instance
create/delete.
It is **off by default** (`panel_settings.opnfwd_enabled` must be
explicitly set to `true`) and is irrelevant to normal users — if you
don't run OPNsense, ignore it entirely and forward ports on your router
as described above.
+116
View File
@@ -0,0 +1,116 @@
# Agent pairing (mTLS)
The controller runs an internal CA (`--ca-dir`, default `./data/ca`,
auto-generated on first run). Every agent gets a client certificate
signed by that CA via a one-time pairing token. After pairing, all
controller↔agent gRPC traffic is mutually-authenticated TLS, and any
"agent" without a CA-signed cert is rejected during the TLS handshake —
it never reaches application code.
`install.sh` / `install.ps1` pair the local agent for you. Do this by
hand only for **additional** hosts or manual installs.
## The flow
### 1. Mint a one-time token (admin)
**Dashboard:** Agents view → **Pair agent** → optional description +
expiry (default 15 minutes) → **Generate**. The token is displayed
**exactly once** — only its SHA-256 hash is stored, so copy it now.
**Or curl** (with an authenticated admin session cookie):
```bash
curl -X POST https://panel.example.com/api/admin/pair-tokens \
-H 'Content-Type: application/json' \
-b "panel_session=$SESSION" \
-d '{"description":"gamebox-2","expires_minutes":15}'
# → {"id":"pt_…","token":"…copy me…","expires_at":"…"}
```
(`GET /api/admin/pair-tokens` lists outstanding tokens.)
### 2. Run the agent once in pair mode (new host)
```bash
./bin/agent \
--pair-url https://panel.example.com \
--pair-token 'THE_TOKEN' \
--agent-id gamebox-2 \
--cert-dir ./data/certs
```
What happens (`agent/cmd/agent/pair.go`):
1. Fetches the controller CA cert from `GET /api/pair/ca`. This first
fetch skips TLS verification — the one-time token is what gates
actual cert issuance.
2. Generates an RSA-2048 keypair **locally** — the private key never
leaves the host.
3. Builds a CSR with CN = the agent id and POSTs
`{token, agent_id, csr_pem}` to `POST /api/pair/issue`, this time
verifying the server against the CA it just fetched.
4. Writes `ca.crt`, `agent.crt`, `agent.key` (key mode 0600) into
`--cert-dir` and **exits**.
`--pair-url` is the controller's **HTTP** dashboard port (8080 /
your reverse proxy), not the gRPC port.
### 3. Run the agent normally
```bash
./bin/agent --controller panel.example.com:8443 \
--modules-dir ./modules --data-root ./data/instances \
--meta-dir ./data/instance-meta --backup-dir ./data/backups \
--cert-dir ./data/certs
```
The agent auto-detects the cert files in `--cert-dir` and dials with
mTLS. Its identity (`--agent-id` default) is the cert CN. No
`--insecure` anywhere.
## Multi-host setup
- **Controller box:** one controller + Postgres. Agents dial *out* to
gRPC `:8443`, so agent boxes need **no inbound panel ports** at all.
- **Each game box:** copy (or clone) the repo's `modules/` directory and
the agent binary, pair once (steps above), run under systemd
(`deploy/systemd/panel-agent.service` — set
`--controller <controller-host>:8443`).
- **Port windows:** give each agent a disjoint port range in the agent
edit modal (e.g. 70007999 / 80008999) so instance host ports never
collide in your router's forwarding rules.
- **Hostnames:** the controller's server cert covers its hostname/IPs at
generation time. If agents will dial a DNS name (or a reverse-proxied
public name), start the controller with
`-tls-hostnames panel.example.com` **before** pairing so the SAN is in
the cert.
`panelctl` uses the same certs: it defaults to mTLS with the certs in
`--cert-dir` and falls back to plaintext only against `--insecure`
controllers.
## How rogue agents are rejected
- The controller's gRPC listener (in `-tls auto|on` mode) requires a
client certificate **signed by its internal CA**. A connection without
one fails the TLS handshake — before any RPC is served.
- Pair tokens are one-time, short-lived (default 15 min), and stored
only as hashes; a leaked *expired* token is useless, and a used token
can't be replayed.
- The agent's identity is the certificate CN, so an agent can't
impersonate another agent id without a cert bearing that CN.
- Compromised host? Delete its cert material and stop trusting it —
and treat any secrets that agent held (instance data) as exposed.
## Troubleshooting
- **`connection refused` in pair mode** — `--pair-url` points at gRPC
`:8443`; it must be the HTTP port (`:8080` or your proxy URL).
- **TLS handshake errors after pairing** — controller cert doesn't cover
the name the agent dials. Re-run the controller with `-tls-hostnames`,
delete `data/ca` server cert *only if you know what you're doing*
(re-pairing all agents), or dial by the covered IP.
- **Dev shortcut** — `--insecure` on the agent + `-tls off` on the
controller skips all of this on a trusted single box. Never on a
network you don't own.
+128
View File
@@ -0,0 +1,128 @@
# Architecture
How panel actually fits together. (An earlier revision of this document
described the original design — SvelteKit, SQLite, Casbin, SFTP; none of
that shipped. This is the real stack.)
## Topology
```
┌───────────────────────────────┐ ┌────────────────────────────┐
│ Controller │ gRPC/mTLS │ Agent (per host) │
│ - Embedded web dashboard │◄──────────►│ - one persistent bidi │
│ - REST /api/* + SSE events │ :8443 │ stream (Agent.Connect) │
│ - Postgres (all state) │ │ - Docker runtime │
│ - Internal CA + pairing │ │ - module loader │
│ - Scheduler (cron + CEL) │ │ - RCON adapters │
│ - Admin HTTP /admin/v1/* │ │ - state tracker │
│ - Prometheus /metrics │ │ - SteamCMD / tar sidecars │
└───────────────────────────────┘ └────────────────────────────┘
▲ HTTP :8080 │ docker.sock
browser / panelctl / curl game containers (one per instance)
```
One controller orchestrates N agents. Agents connect **outbound only**
— an agent box needs no inbound panel ports, just its game ports.
## Components
**Controller** (`controller/cmd/controller`) — a single Go binary:
- **Embedded vanilla-JS dashboard.** No framework, no build step:
`static/new.html` (shell) + `static/rcc.js` + `static/rcc.css`,
compiled in via `//go:embed` and served hash-stamped with immutable
caching. The legacy single-file `static/index.html` is a frozen
"classic" UI, opt-in via the `panel_ui=classic` cookie. Live data
reaches the browser over **SSE** (`GET /api/events`, with
`Last-Event-ID` resume), not WebSockets.
- **Postgres** for everything durable: users/sessions, agents,
instances + assigned ports, schedules, backup index, pair-token
hashes, Steam credential ciphertext. Migrations are embedded and run
at startup. (No SQLite anywhere.)
- **Internal CA** (`controller/internal/ca`, material in `data/ca/`) —
issues the gRPC server cert and, via the pairing flow, each agent's
client cert. `-tls auto` turns mTLS on whenever the CA exists.
- **Scheduler** — robfig/cron 6-field triggers **and** CEL expressions
evaluated against the live event bus with sustained-for windows
("empty for 30 minutes → stop"). Actions: rcon, instance
start/stop/restart, backup.
- **Auth** — argon2id + session cookies; roles are just
`admin`/`user`; optional Sign-in-with-Steam (OpenID). No
OIDC/WebAuthn/Casbin.
- **Admin HTTP** (`/admin/v1/*`) — out-of-band repair surface, gated by
loopback or the `X-Panel-Admin-Token` header, usable when the
dashboard or a session is broken.
**Agent** (`agent/cmd/agent`) — a single Go binary per game host:
- Dials the controller's gRPC port and holds one bidi stream; all
control traffic (create/start/stop/update/backup/RCON/file ops,
events, heartbeats) is multiplexed over `oneof` envelope messages
(`proto/panel/v1`). Reconnect + rehydrate: instance metadata survives
agent restarts via `--meta-dir` sidecar files rebuilt from the
controller's DB.
- **Docker-per-game runtime** (`agent/internal/runtime`): one container
per instance, named volumes and/or bind mounts under `--data-root`,
optional host networking. `panel-*` images are **auto-built** from
the module's in-tree Dockerfile on first use; third-party images are
pulled. Windows-only servers run under Wine + Xvfb in those images.
- **Sidecars**: `steamcmd/steamcmd` for installs/updates (Windows-depot
and beta-branch capable), alpine tar containers for backup/restore.
- **State tracker** (`agent/internal/state`): container state + RCON
polling + log-line pattern matching → typed PlayerEvent/AppState
events streamed upstream and fanned out to the browser (SSE), the
scheduler (CEL), and `panelctl watch`.
- **RCON adapters** (`agent/internal/rcon`): `source_rcon`, `telnet`,
`websocket_rcon` (Rust), `stdio`, `be_rcon` (DayZ), `docker_exec_rcon`
— all redial on EOF.
**panelctl** (`controller/cmd/panelctl`) — operator CLI. gRPC surface
for day-to-day lifecycle (agents/instances/create/start/stop/rcon/
backup/watch), plus a `panelctl admin` HTTP surface for the repair
endpoints. Uses the same mTLS certs as agents.
**Modules** (`modules/<id>/module.yaml`) — declarative manifests: image
or Dockerfile, ports, env, volumes, config files + templated values,
RCON adapter + commands, state-source polls, log event patterns, update
providers, appearance. The loader (`pkg/module`) is strict
(`KnownFields`) — unknown keys are rejected, not ignored. The README's
"Module manifest" section documents the schema by example.
## Security model
- **Control plane:** gRPC with mTLS; agent certs are issued via
one-time pairing tokens ([PAIRING.md](PAIRING.md)) and verified at the
TLS handshake (`tls.RequireAndVerifyClientCert`). Plaintext exists
only as an explicit dev opt-in (`--insecure` / `-tls off`).
- **Browser:** session cookies over your reverse proxy's TLS
([INSTALL.md](INSTALL.md)).
- **Secrets:** per-instance generated secrets in instance metadata;
Steam credentials AES-GCM-encrypted with a key HKDF-derived from the
CA key ([ADMIN.md](ADMIN.md)).
- **Blast radius:** the agent needs the Docker socket — root-equivalent
on its host by design. Run agents on hosts you trust and don't expose
the controller's gRPC port to the internet without cause.
## Data flow, end to end
1. Operator clicks Create → `POST /api/instances` → controller
allocates host ports (per-agent window, DB-reserved — see
[NETWORKING.md](NETWORKING.md)) → `InstanceCreate` envelope to the
agent.
2. Agent resolves the manifest (env filtering, `$INSTANCE_ID` /
`$DATA_PATH` substitution), auto-builds/pulls the image, creates the
container, persists sidecar metadata.
3. Update → SteamCMD sidecar into the game volume. Start → container
start; state tracker begins polling/tailing; readiness regex flips
the card to Running.
4. Events stream to the controller → Postgres aggregates + SSE fanout →
dashboard, scheduler CEL evaluation, `panelctl watch`.
## Not implemented (on purpose or yet)
- OIDC / WebAuthn / Casbin ACLs — local auth + Steam OpenID only.
- SFTP — the web file manager (docker exec/cp with bind-mount fast
path) covers the common case.
- Host-mode (non-Docker) runtime — manifests declare it, the agent
fails loudly if asked.
- Multi-tenancy/billing — this is for someone running their own boxes.