Files
panel/docs/architecture.md
T
2026-07-14 23:18:05 -07:00

6.8 KiB

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) 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).
  • Secrets: per-instance generated secrets in instance metadata; Steam credentials AES-GCM-encrypted with a key HKDF-derived from the CA key (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) → 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.