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