panel v0.9.1 — open-source game server manager
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+129
@@ -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
@@ -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)).
|
||||
+288
@@ -0,0 +1,288 @@
|
||||
# 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+. Docker Desktop must be installed and **running** first
|
||||
(the script checks and refuses otherwise).
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File install.ps1 -AdminPassword secret123
|
||||
```
|
||||
|
||||
Parameters (mirror the Linux env vars): `-RepoUrl`, `-PrebuiltUrl`,
|
||||
`-Version`, `-InstallDir` (default `C:\Panel`), `-HttpPort` (8080),
|
||||
`-GrpcPort` (8443), `-AdminPassword`, `-DbUrl`, `-DbPassword`,
|
||||
`-SkipAgent`. The same `PANEL_*` env vars are honored as defaults.
|
||||
|
||||
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
|
||||
```
|
||||
@@ -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
|
||||
**7000–9999** (`controller/cmd/controller/portalloc.go`). You can set a
|
||||
per-agent window in the agent edit modal (e.g. box A gets 7000–7999,
|
||||
box B gets 8000–8999). 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 | 30000–30003 (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
@@ -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. 7000–7999 / 8000–8999) 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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user