# 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//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.