Skip to content

Backup — Standalone peer trust (design for signature)

Synced read-only from /home/synnet/mirrors/synvirt-product/docs/superpowers/specs/backup-standalone-peer-trust.md. Edit at the source, not here.

Backup — Standalone peer trust (design for signature)

Section titled “Backup — Standalone peer trust (design for signature)”

Status: DRAFT for signature — no code may land against this document until the signature block is filled. This is the P2 deferral coming due: SynVirt-peer backup repositories between hosts that are not members of the same cluster. It extends the signed P4 spec (backup-p4-peer-data-plane.md); every §D reference below is to that document. The data plane itself does not change — this spec is about how trust is established without membership, where it lives, and what a trusted-but-not-member peer is allowed to do.

Author: engineering session 2026-07-10 (fleet-validation window). Reviewer/signer: Tony.


Is: a one-time, operator-driven trust exchange between two SynVirt hosts identified by IP, authenticated by the target host’s admin credentials (PAM), producing a mutual mesh-SPKI pin persisted on both sides — after which the existing P4 peer data plane (§D1–§D11) works between them exactly as it does between cluster members.

Is not: cluster membership, federation-lite, discovery, inventory sharing, cross-host clone, Live Motion, or any Raft/quorum/VIP participation. A trusted standalone peer can receive and serve backup bytes, answer the §D9 capability probe, and nothing else.


  • Pairing is the only trusted join path for clusters and is a mutual ed25519 challenge/response; the operator confirms the peer fingerprint out-of-band (synvirt-cluster/src/pairing.rs:1-14). It binds proof of key possession to a host the operator intends — but its UX (compare fingerprints by hand) is exactly what the backup wizard must NOT require (“todo super sencillo”).
  • Pins live in the PeerRegistrynode_id -> {SPKI fingerprint, address}, populated from the pairing bootstrap set and refreshed from applied cluster_members (mesh/peers.rs:1-9). The gRPC server uses it for tier-2 authorization; the client uses it for per-target SPKI pinning (mesh/client.rs:38-67, server_name carries no trust).
  • The shared mTLS listener (:7443, DEFAULT_MESH_PORT) accepts any valid self-signed ed25519 peer at tier 1; every sensitive service is mounted through Tier2Gate::guard, which admits only registry members (mesh/interceptor.rs:2-13 — “wiring a tier-2 service without the gate is impossible by construction”).
  • The P4 backup surface is already a distinct RPC set on the federation service: backup_run_begin / put_file / close_file / abort_run / read_file / stat_file / stats + the §D9 probe — all brokered backupd → daemon → clusterd → mesh (§D1; backupd holds no mesh identity).
  • PeerConnection.address is reserved for exactly this phase: “for a future standalone↔standalone mode and stays None for cluster peers” (synvirt-backup-core — PeerConnection rustdoc, verified in the openapi snapshot).
  • PAM Basic Auth is the daemon’s existing admin surface (:443, crates/daemon/src/auth.rs; single process-wide PAM lock preserved by synvirt-identity::PamBackend). The dashboard itself authenticates this way — an IP + admin password is already the product’s meaning of “I administer that host.”
  • clusterd runs on every host including standalone mode (ships in lockstep with the daemon; cluster_state: "standalone" hosts still run it). Whether its mesh listener binds in standalone mode is configuration, not architecture.
  • §D2 (signed): auth/pinning is inherited from clusterd; backup adds no second trust system. This spec extends how a pin gets into the registry, never what a pin means.

§S1 — A “trusted standalone peer” is a pin + a scope, not a member.

Section titled “§S1 — A “trusted standalone peer” is a pin + a scope, not a member.”

Trust product = one row per peer: {spki_fingerprint, address, friendly_name, scope=backup, created_at, created_by} on both hosts. No Raft membership change, no cluster DB row in cluster_members, no discovery advertisement, no VIP/quorum effect. Removing the row removes the trust (§S8). Recommendation: accept.

§S2 — Bootstrap: IP + admin user/password ONCE, over the daemon’s PAM surface. Password never stored.

Section titled “§S2 — Bootstrap: IP + admin user/password ONCE, over the daemon’s PAM surface. Password never stored.”

The wizard collects target IP + admin username/password. Origin daemon → (control UDS) → origin clusterd, which calls the target daemon’s public HTTPS API (:443, self-signed TLS, PAM Basic Auth — the same front door the dashboard uses):

POST /api/v1/peer-trust/exchange body: {origin_fingerprint, origin_mesh_address, origin_friendly_name, nonce}

The target daemon PAM-verifies the credentials, delegates to its own clusterd over the control UDS, which (a) persists the origin’s pin (scope=backup), (b) answers {peer_fingerprint, peer_mesh_address, peer_friendly_name, proof} where proof = ed25519_sign(peer_key, nonce ‖ origin_fingerprint) — possession proof in the pairing.rs mold (make_challenge/verify_challenge reuse). The origin verifies the proof against the returned SPKI and persists the target’s pin. Credentials exist only inside that one request; neither side writes them anywhere (§D2 pattern extended: pins are the durable trust, passwords are ephemeral bootstrap). Recommendation: accept. Explicitly NOT pairing-by-fingerprint UX; see §S11 for the residual-risk statement.

§S3 — Pins persist in clusterd’s DB, new trusted_peers table; the in-memory PeerRegistry is fed from BOTH sources.

Section titled “§S3 — Pins persist in clusterd’s DB, new trusted_peers table; the in-memory PeerRegistry is fed from BOTH sources.”

Cluster members keep coming from applied cluster_members; standalone backup peers come from trusted_peers (clusterd SQLite, both sides). On boot clusterd loads both into the same registry, with the scope tag (§S4). One registry, one pinning code path, two provenance tables. Recommendation: accept. Rejected alternative: a backupd-owned store — violates §D2 (clusterd is the sole trust holder).

§S4 — Transport: the SAME mesh listener (:7443) and federation service. No dedicated listener. A new scope gate: Tier-2b.

Section titled “§S4 — Transport: the SAME mesh listener (:7443) and federation service. No dedicated listener. A new scope gate: Tier-2b.”

Tier-2b = “pinned, backup-scope”. The interceptor learns scopes: cluster members ⇒ tier-2 (everything, as today); trusted_peers rows ⇒ tier-2b ⇒ only backup_* RPCs + the §D9 probe. vm_power, inventory, dataset_send (clone), libvirt_tunnel, Raft and the write-proxy remain tier-2-members-only. Same by-construction guard style as Tier2Gate::guard — a backup RPC reachable without a scope check must be impossible to wire. On a standalone host the mesh listener binds on demand: first trusted_peers row ⇒ bind + persist enable; zero rows ⇒ listener stays down (today’s standalone posture unchanged). Recommendation: accept.

§S5 — Least privilege is directional and symmetric.

Section titled “§S5 — Least privilege is directional and symmetric.”

The exchange trusts BOTH directions with scope=backup (A can push to B and restore from B; B can push to A). Asymmetric trust (“B may only receive”) is deliberately NOT modeled in v1 — one more toggle nobody asked for. Revisit only with a real operator demand. Recommendation: accept.

§S6 — PeerConnection.address becomes meaningful for standalone peers.

Section titled “§S6 — PeerConnection.address becomes meaningful for standalone peers.”

Cluster-member repos: address: None (resolve via membership, as today). Standalone-peer repos: address: Some("ip:mesh_port") — the data plane dials it with the pinned SPKI exactly like a member address. target_dataset stays the §D5 pool LABEL. Wire-stable, additive. Recommendation: accept.

§S7 — §D9 capability probe: unchanged, and it is the ONLY reachability oracle.

Section titled “§S7 — §D9 capability probe: unchanged, and it is the ONLY reachability oracle.”

Same RPC, same fail-closed semantics, same E_BACKUP_PEER_CAPABILITY_MISSING on an old peer. The wizard’s post-exchange auto-test IS this probe. A standalone peer that stops answering flips the repo to unreachable exactly like a member would. Recommendation: accept (this is a restatement, kept as a decision so the signature covers it).

§S8 — Revocation: local-authoritative delete, best-effort mutual.

Section titled “§S8 — Revocation: local-authoritative delete, best-effort mutual.”

DELETE /api/v1/peer-trust/{fingerprint} removes MY pin of them — that alone kills both directions from my host’s perspective (their calls fail the gate; my dials refuse an unpinned target). Best-effort notify asks the peer to drop the reverse pin; if unreachable, their stale row is harmless (my gate rejects) and visibly unreachable in their UI. Repos referencing a revoked peer flip to data_plane_pending / unreachable, fail-closed; restore points already received REMAIN on the receiving pool (revocation is about transport, never data deletion — §D3 ownership rules unchanged). Recommendation: accept.

§S9 — Rotation: pin = SPKI, so identity rotation invalidates trust by design. v1 = re-run the exchange.

Section titled “§S9 — Rotation: pin = SPKI, so identity rotation invalidates trust by design. v1 = re-run the exchange.”

No silent re-pin, no rotation protocol in v1. The failure is loud and typed (E_BACKUP_PEER_UNREACHABLE with a pin-mismatch detail), the wizard offers “Re-establish trust” which is the §S2 exchange again. Document in ops runbook; automatic rotation handshakes are a later, separate spec if fleets ever rotate mesh identities routinely. Recommendation: accept.

§S10 — Wizard flow AS IT WILL LOOK (replaces the Part-3 honest placeholder).

Section titled “§S10 — Wizard flow AS IT WILL LOOK (replaces the Part-3 honest placeholder).”

Type picker tile “SynVirt host by IP” stops being explanatory and becomes: IP → admin user → admin password → [Connect] (4 visible fields incl. the button). Connect = §S2 exchange + §S7 probe, one spinner, ≤10 s. Success reveals: friendly name (returned, editable) + pool picker (peer pool labels via backup_stats/probe surface) → Done. Both host fingerprints are displayed on the success panel (operator MAY compare; is not forced to). Failure states per §S11 map to single-sentence operator English. Recommendation: accept.

§S11 — Failure modes and the honest residual risk.

Section titled “§S11 — Failure modes and the honest residual risk.”

Enumerated, each with its typed error and wizard copy:

  1. Wrong credentials → PAM 401 → “The username or password was not accepted by the host at {ip}.”
  2. Unreachable IP / TLS dial fail → “No SynVirt host answered at {ip}.”
  3. Peer too old (no /peer-trust/exchange) → 404 → “That host runs an older SynVirt release without standalone trust — update it, or add it to your cloud instead.”
  4. Proof verification fails → hard abort, nothing persisted either side → “The host’s identity proof did not verify.”
  5. Exchange OK, probe fails (§D9) → trust persisted, repo creatable but data_plane_pending — same posture the fleet had 2026-07-08/10.
  6. Revoked/rotated mid-run → stream fails typed; the run fails honest and cleans up (§D6/§D10 unchanged — verified on the fleet 2026-07-10).
  7. Residual (stated, not hidden): the §S2 exchange is TOFU-hardened-by-password over self-signed TLS. An active MITM on the management network during the ONE exchange, proxying PAM through to the real host, could pin its own key. This is the same trust class as the operator’s first dashboard login to that host. Mitigations shipped: possession proof (§S2), fingerprints displayed (§S10), exchange is one-shot + audit-logged on both sides. A strict pre-shared-fingerprint mode is a checkbox-sized later addition if ever demanded. Recommendation: accept with the residual risk acknowledged in the signature.

§S12 — Fleet vs standalone capability matrix (normative).

Section titled “§S12 — Fleet vs standalone capability matrix (normative).”
Capability Cluster member Trusted standalone peer
§D9 capability probe
Backup push to peer (P4 §D1–§D7)
Restore pull from peer (§D8)
Capacity/used stats (§D7)
Repo host picker source cluster inventory trusted_peers rows
Clone data mover (dataset_send) ✗ (tier-2 only)
Live Motion / libvirt tunnel
Inventory / federation tree
VM power ops
Raft / quorum / VIP
Trust origin pairing + membership §S2 exchange
Trust removal leave/forget cluster §S8 revoke

Recommendation: accept as the contract; any ✗→✓ move is a new spec.

Both sides append to the settings AuditStore: exchange (who, from where, resulting fingerprint), revocation, pin-mismatch rejections. The observability surface gets one detector: a repo whose peer has been unreachable > 24 h raises a warning Issue (same class as the disk banner). No new subsystem. Recommendation: accept.


  1. Daemon REST: POST /api/v1/peer-trust/exchange (PAM-guarded, target side), GET /api/v1/peer-trust (list), DELETE /api/v1/peer-trust/{fingerprint} (+ audit).
  2. clusterd control UDS: PATH_PEER_TRUST_EXCHANGE_OUT (origin dials out), PATH_PEER_TRUST_ACCEPT (target persists + proves), PATH_PEER_TRUST_LIST/REVOKE.
  3. clusterd DB: trusted_peers table (§S3).
  4. Mesh: no new services, no new RPCs — one interceptor scope (§S4).
  5. PeerConnection.address populated for standalone repos (§S6) — already wire-stable.

5. Evidence ladder (when this gets a code GO)

Section titled “5. Evidence ladder (when this gets a code GO)”
  1. Hermetic 2-host standalone rig (NO cluster): exchange via API → trusted_peers row both sides, passwords absent from both DBs and logs (grep gate).
  2. §D9 probe flips repo reachable; backup push lands under <pool>/Backups/<origin-fpr>/…; restore pull byte-identical (sha256) — the same bar the fleet cleared 2026-07-10.
  3. Scope gate proof: a trusted standalone peer calling vm_power / dataset_send gets a typed tier rejection (unit + rig test).
  4. Revoke → in-flight run fails typed + cleans; probe fail-closed; re-exchange restores service.
  5. Wrong-password, stale-release (404), and proof-forgery attempts all land their §S11 copy in the wizard (Playwright).
  6. Fleet is NOT touched by this ladder — standalone rigs only, until a separate deploy GO.
Decision Signature (Tony) Conditions
§S1 pin+scope, not member
§S2 PAM bootstrap, password ephemeral
§S3 trusted_peers in clusterd DB
§S4 same listener, Tier-2b scope
§S5 symmetric scope v1
§S6 PeerConnection.address
§S7 §D9 probe unchanged
§S8 revocation local-authoritative
§S9 rotation = re-exchange
§S10 wizard flow
§S11 failure modes + residual risk
§S12 capability matrix
§S13 audit + detector