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.

PropertyValue
TransportHTTPS / mTLS, control plane at HOSTSSH_API (default https://api.hostssh.com)
Client authper-agent client cert, issued during activation, bound to the machine fingerprint
Server authpinned control-plane cert; no fallback to system trust on pin mismatch
Identitysigned license token (ed25519) carried as a bearer credential inside the mTLS session
Failure modefail-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:

  1. Offline — verify the ed25519 signature, expires_at, and the seat/feature claims against the embedded public key. Fast, no network. Stops casual copying.
  2. 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

StatePrivileged actionsEmergency restore
Validallowedallowed
Expiredblocked (capture/migrate)still allowed — never trap a customer's data behind billing
Revokedblocked at next heartbeatstill allowed
Offline within graceallowedallowed
Offline past graceblockedstill 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:

SignalExample fieldsSurfaced as
Backup healthlast capture at, success/fail, image id, duration, bytesgreen/red backup status
Storagerepo size, image count, target reachabilitystorage panel, quota warnings
Uptimeagent up since, engine version, agent versionfleet uptime
Restore-drillslast drill target, row-count diff, pass/fail"recoverability proven" card
Connection statusper-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)SourceCadenceSurfaced as
gpus[]gpu.Stats()nvidia-smi --query-gpu=name,memory.total,memory.used,utilization.gpueach beatGPU model / VRAM / utilization
containers[]inventory.Containers()docker ps -a (name/image/state/status)each beatthe running-container list
softwareinventory.Node() — OS, kernel, Docker, NVIDIA driver/CUDA/nvidia-container-toolkitsampled ONCE, cachedthe 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.

RPCCLI verbWhat the agent doesControl-plane role
capturehostssh captureSnapshot the six layers → encrypted .hsi to the storage targetschedule, record, alert
restorehostssh restoreRoll a box back to a prior .hsi (non-destructive until cutover confirmed)locate image, watch progress
clonehostssh cloneDuplicate a server to another machine (WG stays off unless --activate-wg)provision/track target
migrate --new-iphostssh migrate --new-ipRestore to a fresh VPS + run the IP-rewrite + bring up sidecars + TLSMode 3 orchestration, DNS flip, license/transfer
transfer --new-keyhostssh transfer --new-keyMint a one-time, scope-locked transfer key; stream the image peer→peerrelay 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 choiceagent_local, zero_knowledge (customer-held passphrase; the control plane can never decrypt), or escrow (KMS-wrapped for one-click recovery). The protocol carries the custody shape, never the key in zero_knowledge/agent_local mode (CONNECTIONS.md §5).
  • Backed by restic. An .hsi is 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.

ReferenceWhereRewrite
Control-plane server recordthe brain DB's servers.ip (Coolify's coolify-db today)SQL replace(old,new)
Preview domains*.<public-ip>.sslip.io in app/service FQDNsSQL replace()
DNS A recordsDNS provider (Cloudflare etc.)provider API (dns_cutover)
Overlay peer endpointwg0.conf Endpointsed — replace-mode only
Firewall allow-listsnft/iptables / provider FWre-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 pointing HOSTSSH_API / HOSTSSH_DL_BASE at a reachable mirror to dry-run the activation flow.
  • Phase note (telemetry & job streams): /v1/telemetry and /v1/jobs/{id}/stream event 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_version is the seam that keeps older images restorable as adapters land.

See also: API & CLI · Control-plane dashboard · Architecture · Licensing · Security · Connections