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.
1. What this is, and is not
Section titled “1. What this is, and is not”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.
2. Ground truth (verified today)
Section titled “2. Ground truth (verified today)”- 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
PeerRegistry—node_id -> {SPKI fingerprint, address}, populated from the pairing bootstrap set and refreshed from appliedcluster_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_namecarries 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 throughTier2Gate::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.addressis reserved for exactly this phase: “for a future standalone↔standalone mode and staysNonefor 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 bysynvirt-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.
3. Numbered decisions
Section titled “3. Numbered decisions”§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:
- Wrong credentials → PAM 401 → “The username or password was not accepted by the host at {ip}.”
- Unreachable IP / TLS dial fail → “No SynVirt host answered at {ip}.”
- 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.” - Proof verification fails → hard abort, nothing persisted either side → “The host’s identity proof did not verify.”
- Exchange OK, probe fails (§D9) → trust persisted, repo creatable but
data_plane_pending— same posture the fleet had 2026-07-08/10. - Revoked/rotated mid-run → stream fails typed; the run fails honest and cleans up (§D6/§D10 unchanged — verified on the fleet 2026-07-10).
- 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.
§S13 — Audit + observability.
Section titled “§S13 — Audit + observability.”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.
4. Wire additions (additive, enumerated)
Section titled “4. Wire additions (additive, enumerated)”- 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). - clusterd control UDS:
PATH_PEER_TRUST_EXCHANGE_OUT(origin dials out),PATH_PEER_TRUST_ACCEPT(target persists + proves),PATH_PEER_TRUST_LIST/REVOKE. - clusterd DB:
trusted_peerstable (§S3). - Mesh: no new services, no new RPCs — one interceptor scope (§S4).
PeerConnection.addresspopulated 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)”- Hermetic 2-host standalone rig (NO cluster): exchange via API →
trusted_peersrow both sides, passwords absent from both DBs and logs (grep gate). - §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. - Scope gate proof: a trusted standalone peer calling
vm_power/dataset_sendgets a typed tier rejection (unit + rig test). - Revoke → in-flight run fails typed + cleans; probe fail-closed; re-exchange restores service.
- Wrong-password, stale-release (404), and proof-forgery attempts all land their §S11 copy in the wizard (Playwright).
- Fleet is NOT touched by this ladder — standalone rigs only, until a separate deploy GO.
6. Signature block
Section titled “6. Signature block”| 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 |