Agent protocol
How the HostSSH agent talks to the control plane — mTLS, license activation, telemetry, the capture/restore/migrate RPCs, the live progress stream, and the .hsi image format.
Agent protocol
Every HostSSH deployment is three planes: the agent on each server, the control plane at hostssh.com, and customer-owned storage holding the encrypted images. This page documents the wire between the first two — how a single agent authenticates, activates its license, streams telemetry, runs the privileged RPCs, and reports progress. For the planes themselves see Architecture; for license semantics see Licensing and the root LICENSING spec; for the threat model see SECURITY.
The guiding principle: the data path never depends on the control plane. Capture, restore, and offline (Mode 1) transfer work with the control plane unreachable. The control plane carries license state, telemetry, and the orchestration of managed migrations — the metadata, never the plaintext.
The channel — mTLS, pinned
All agent ⇄ control-plane traffic rides mutual TLS. The agent presents a client certificate issued at activation; the control plane presents its server certificate, which the agent pins (SECURITY.md). A rotated or unexpected control-plane cert fails closed rather than trusting a substitute.
| Property | Value |
|---|---|
| Transport | HTTPS / mTLS, control plane at HOSTSSH_API (default https://api.hostssh.com) |
| Client auth | per-agent client cert, issued during activation, bound to the machine fingerprint |
| Server auth | pinned control-plane cert; no fallback to system trust on pin mismatch |
| Identity | signed license token (ed25519) carried as a bearer credential inside the mTLS session |
| Failure mode | fail-closed — a broken or MITM'd channel never silently downgrades |
Everything below — activation, heartbeat, telemetry, RPC control messages — travels inside this channel. The image data path (image bytes to/from storage, or a peer stream in Mode 2) is separate and independently encrypted; the control plane never sees plaintext image bytes.
License activation handshake
This is the hostssh license activate <key> flow the
installer triggers after dropping the binary. It is a
signed, fingerprint-bound exchange — the key never sits in shell history, and
the resulting token is offline-verifiable but only HostSSH can mint it.
agent control plane
│ compute machine fingerprint │
│ (machine-id + primary NIC + salt, │
│ hashed) │
│ │
│ POST /v1/license/activate │
│ { key, fingerprint } ───────────► │ verify key, bind seat
│ │ to fingerprint (≤ seats)
│ │ mint ed25519 token
│ ◄─────────── { token, client_cert } │
│ │
│ cache token + cert in /etc/hostssh │
│ pin control-plane cert │
The token is a signed JWT/PASETO over ed25519 carrying: license_id,
plan (tier), seats, features[], issued_at, expires_at, customer_id,
and optional server_binding[] fingerprints (LICENSING.md). The agent embeds
the HostSSH public key, so it verifies the signature locally with no network
round-trip — the control plane holds the private key and is the only party that
can issue a valid token.
Two-layer check on every privileged action:
- Offline — verify the ed25519 signature,
expires_at, and the seat/feature claims against the embedded public key. Fast, no network. Stops casual copying. - Online — a daily
POST /v1/license/heartbeat { token, fingerprint }confirms the license is active, not revoked, and within seat count, and returns a fresh short-TTL token.
Offline grace. If the heartbeat cannot reach the control plane, the last valid token keeps working for N days (configurable, default 14). Air-gapped installs stay functional; a permanently offline copy eventually expires. Sites that must never call home can buy an offline license SKU (longer re-signed tokens, no heartbeat).
License API surface (control plane, v1)
POST /v1/license/activate { key, fingerprint } → { token, client_cert }
POST /v1/license/heartbeat { token, fingerprint } → fresh token | revoked
POST /v1/license/transfer { token, old_fp, new_fp } → rebind (used by migrate)
GET /v1/license/status { token } → plan, seats, expiry, features
license/transfer is what keeps relocation friendly: a managed migration
(hostssh migrate --new-ip) or a peer transfer (hostssh transfer --new-key)
that moves a server to a fresh box calls transfer to move the seat from the
old fingerprint to the new one automatically — the customer never burns a seat
for doing the thing the product exists to do. Manual rebind and self-serve seat
release live in the dashboard.
Failure behavior the protocol must honor
| State | Privileged actions | Emergency restore |
|---|---|---|
| Valid | allowed | allowed |
| Expired | blocked (capture/migrate) | still allowed — never trap a customer's data behind billing |
| Revoked | blocked at next heartbeat | still allowed |
| Offline within grace | allowed | allowed |
| Offline past grace | blocked | still allowed |
Emergency restore of an existing image is a dedicated path that ignores license, retention, quota, and connection status (see Connections §3). The protocol surfaces clear, actionable errors — never a silent failure — when an action is blocked.
Telemetry push
The telemetry client pushes customer-visible diagnostics to the control plane over the same mTLS channel. This is the metadata that is the customer-facing value — never image plaintext, and (in zero-knowledge custody) never any key material. Air-gapped installs can opt out entirely.
POST /v1/telemetry { token, fingerprint, samples[] } → { ack, next_interval }
A sample set carries:
| Signal | Example fields | Surfaced as |
|---|---|---|
| Backup health | last capture at, success/fail, image id, duration, bytes | green/red backup status |
| Storage | repo size, image count, target reachability | storage panel, quota warnings |
| Uptime | agent up since, engine version, agent version | fleet uptime |
| Restore-drills | last drill target, row-count diff, pass/fail | "recoverability proven" card |
| Connection status | per-target valid/invalid/disabled (no secret values) | connections health |
All values are redacted of secrets before they leave the host — no APP_KEY, no DB creds, no credential field values, no raw upstream error bodies (CONNECTIONS.md, SECURITY.md). Telemetry feeds the fleet dashboard and alerting; restore-drill results are what let the dashboard claim a backup actually restores, not merely "completed".
Node-legibility telemetry (2026-07-12)
The heartbeat also carries the signals that make a node readable from the dashboard without SSHing in — the container inventory, the software stack, and per-GPU stats. Same channel, same redaction rules.
Field (Heartbeat) | Source | Cadence | Surfaced as |
|---|---|---|---|
gpus[] | gpu.Stats() — nvidia-smi --query-gpu=name,memory.total,memory.used,utilization.gpu | each beat | GPU model / VRAM / utilization |
containers[] | inventory.Containers() — docker ps -a (name/image/state/status) | each beat | the running-container list |
software | inventory.Node() — OS, kernel, Docker, NVIDIA driver/CUDA/nvidia-container-toolkit | sampled ONCE, cached | the node's package stack |
The end-to-end path:
agent (internal/inventory + internal/gpu)
│ gpu.Stats() · inventory.Containers() · inventory.Node() (cached)
▼
Heartbeat{ …, gpus[], containers[], software } (internal/telemetry)
│ POST /v1/telemetry/heartbeat (mTLS, license-gated)
▼
control plane: heartbeatBody (zod) → upsertAgent
│ persists as agents.telemetry (jsonb, migration 0030, COALESCE-preserved)
▼
getAgents → agentsToServers → parseTelemetry (snake→camel)
▼
Hardware page: collapsible per-node "telemetry" panel (native <details>)
Design notes: software is sampled once at agent start and cached (versions rarely change);
containers are fresh each beat. Persistence is COALESCE(EXCLUDED.telemetry, agents.telemetry)
so a beat that omits it never wipes the stored picture. A CPU node simply sends no GPU/driver
fields. Code: agent/internal/inventory, agent/internal/gpu, control-plane/web/lib/fleet/agents.ts
(parseTelemetry), app/(god)/hardware/page.tsx. See also GPU support.
Control RPCs — capture / restore / clone / migrate / transfer
The privileged operations are issued as control messages over the mTLS
channel (and exposed identically through the CLI, the hostssh-mcp MCP server,
and the REST API — the parity principle in INTERFACES). Each
is license-gated, each emits a progress stream, and the
destructive ones require explicit confirmation.
| RPC | CLI verb | What the agent does | Control-plane role |
|---|---|---|---|
capture | hostssh capture | Snapshot the six layers → encrypted .hsi to the storage target | schedule, record, alert |
restore | hostssh restore | Roll a box back to a prior .hsi (non-destructive until cutover confirmed) | locate image, watch progress |
clone | hostssh clone | Duplicate a server to another machine (WG stays off unless --activate-wg) | provision/track target |
migrate --new-ip | hostssh migrate --new-ip | Restore to a fresh VPS + run the IP-rewrite + bring up sidecars + TLS | Mode 3 orchestration, DNS flip, license/transfer |
transfer --new-key | hostssh transfer --new-key | Mint a one-time, scope-locked transfer key; stream the image peer→peer | relay only if both sides are behind NAT |
Why no inbound ports. Core function is egress-only: the agent dials the control plane and storage. The peer endpoint (Mode 2 receive side) and the local admin panel are opt-in, authenticated, and bindable to localhost/Tailscale/CF-Tunnel only. The control plane sends an RPC by handing the agent a signed, scoped job on its next poll/heartbeat — it does not open a socket back to the host.
The three transfer modes map onto these RPCs exactly as in
PRODUCT-SPEC: Offline/Download is capture + a moved
file + restore; Peer transfer is transfer --new-key + a pull;
Managed migration is the orchestrated migrate driven from the panel.
Live progress stream
Long-running RPCs (capture, restore, migrate) stream structured progress so
the dashboard, CLI (--json), MCP, and REST callers all watch the same events.
GET /v1/jobs/{id}/stream (server-sent events over mTLS)
event: phase { job, phase: "databases", index: 2, of: 6 }
event: progress { job, phase: "volumes", bytes_done, bytes_total, pct }
event: log { job, level: "info", msg: "pg_dump app_db … 412MB" }
event: verify { job, check: "row_counts", status: "pass", detail: {…} }
event: result { job, status: "success"|"failed", image_id, manifest_ref }
A migration's stream walks the full Mode-3 checklist — restore brain → DBs → volumes → IP-rewrite → sidecars up → TLS re-issue → DNS flip → verify — and ends with the green/red verification card (DB row counts match, domains 200, TLS valid). The stream is observation only; it never carries image bytes.
The .hsi image format
One portable, self-describing, encrypted artifact equals a full server
snapshot. The protocol treats an .hsi as a named, signed reference the agent
can resolve, verify, and restore — not something that flows through the control
plane. Full detail in Architecture §2; the protocol-level
contract:
image.hsi (age / AES-256-GCM envelope; key = customer passphrase OR escrowed KMS key)
├── manifest.json schema_version, source box, public/overlay IPs, platform versions, layer index
├── control-plane/ brain DB dump + data dir tar + APP_KEY (sealed). Legacy images (v1): coolify/
├── databases/ per-DB dumps (+ globals)
├── volumes/ tar of each named volume / bind dir (content-addressed)
├── system/ wireguard, systemd, firewall, crontab, packages
├── sidecars/ docker inspect json per non-platform container
└── checksums + ed25519 signature
- Encrypted before it leaves the host. The envelope is keyed by a customer-scoped key, so a leaked bucket is not leaked data. The image inherently contains the platform APP_KEY (so restored secrets are alive), which is exactly why encryption is non-negotiable (SECURITY.md).
- Custody is the customer's choice —
agent_local,zero_knowledge(customer-held passphrase; the control plane can never decrypt), orescrow(KMS-wrapped for one-click recovery). The protocol carries the custody shape, never the key inzero_knowledge/agent_localmode (CONNECTIONS.md §5). - Backed by restic. An
.hsiis really a signed restic snapshot ref plus the manifest, over any BYO backend — R2 (default, zero-egress), S3, MinIO, B2, Wasabi, SFTP, or local. "Download an image" exports a self-contained restic subset. - Verified before restore. Content checksums and the ed25519 signature are checked before any restore touches the target. A tampered image is rejected.
Versioned schema — forward-compatible restore
manifest.json carries an explicit schema_version. The contract: a
newer agent must restore an older image. When an agent reads a manifest with
a lower schema version, it applies the documented up-migration for each absent or
changed field and proceeds; it refuses an image whose schema is newer than it
understands with a clear "upgrade the agent" error rather than guessing. This is
what lets the fleet roll forward without orphaning months of backups.
The WireGuard-overlay insight (IP-stable internal refs)
The reason relocation is a deterministic rewrite rather than a guessing game:
inter-service traffic rides a WireGuard overlay (e.g. 10.10.0.x on our
fleet) that is IP-stable. So migrate --new-ip only has to rewrite the
public-facing references; everything that talks over the overlay needs no
change.
| Reference | Where | Rewrite |
|---|---|---|
| Control-plane server record | the brain DB's servers.ip (Coolify's coolify-db today) | SQL replace(old,new) |
| Preview domains | *.<public-ip>.sslip.io in app/service FQDNs | SQL replace() |
| DNS A records | DNS provider (Cloudflare etc.) | provider API (dns_cutover) |
| Overlay peer endpoint | wg0.conf Endpoint | sed — replace-mode only |
| Firewall allow-lists | nft/iptables / provider FW | re-apply |
Safety invariant the agent enforces: WireGuard is not activated on a
clone unless --activate-wg (replace-mode). Otherwise two boxes would claim the
same overlay IP and break the live mesh. A data box is nearly IP-portable
(≈1 public ref); an app box is where the rewrite earns its keep.
Phase notes
The specs above are the target contract. As of Phase 1, treat these as not-yet-final:
- Phase note (binary + activation): the Go agent binary and the
/v1/license/*activation/heartbeat endpoints are being stood up. The installer supports pointingHOSTSSH_API/HOSTSSH_DL_BASEat a reachable mirror to dry-run the activation flow. - Phase note (telemetry & job streams):
/v1/telemetryand/v1/jobs/{id}/streamevent shapes are stabilizing; field names may change before the v1 freeze. The control-plane dashboard currently renders these from fixtures (see Control-plane dashboard, "From mock to real"). - Phase note (platform adapters): the protocol is platform-aware via
adapters. The CoolifyAdapter ships first; Dokploy / CapRover / plain
Docker Compose adapters follow, each declaring its own "brain", secret
encryption, and where IPs hide (ARCHITECTURE.md §7). The manifest
schema_versionis the seam that keeps older images restorable as adapters land.
See also: API & CLI · Control-plane dashboard · Architecture · Licensing · Security · Connections