hub v0.12.0: retire Infra Backup, purge its plaintext secrets, fix backup-deadline email

Phase-1 of SPIKE-infra-backup-2026-06-15. The infra-backup mechanism was dead
since slice 8C yet stored plaintext customer secrets at rest (app-secret key,
restic password, Cloudflare tokens) — a zero-knowledge violation — and its
absence made the daily expected_backup_missed email fire for healthy customers.

- Repoint monitor.CheckBackupDeadlines backup half to the agent host-report's
  PBS snapshots (+vzdump): alarm only on no-backup / >26h stale / verify failed.
  Keep the db_dump half. No host-report → no backup alarm (liveness owns that).
  New store.GetLatestHostReportJSON. Tests incl. a companion that fails pre-fix.
- Remove the infra-backup endpoints, store methods/types, and operator panel;
  /recovery now returns config_yaml only.
- migrate(): DROP infra_backup_versions/infra_backups + VACUUM (+wal_checkpoint)
  to physically reclaim the plaintext pages, gated on table existence.

Flagged out-of-scope: exposed creds need operator rotation; legacy reports table
holds historical plaintext restic_password rows (separate leak, not purged here).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-16 11:08:06 +02:00
parent 2f7acb7d07
commit 0635640848
10 changed files with 466 additions and 595 deletions
+43
View File
@@ -1,5 +1,48 @@
# Felhom Hub — Changelog
## v0.12.0 — retire Infra Backup + purge its plaintext secrets + fix the daily backup-deadline email (2026-06-16)
Phase-1 of the Infra Backup retirement (per `documentation/audits/SPIKE-infra-backup-2026-06-15.md`).
The mechanism had been dead since slice 8C, yet the hub still stored each version as a **plaintext
JSON blob at rest** containing the customer's app-secret encryption key, restic password, and
Cloudflare tokens — a zero-knowledge violation. Its absence was also the root cause of the daily
`expected_backup_missed` false-alarm email.
### Changed
- **Backup-deadline check repointed to PBS freshness.** `monitor.CheckBackupDeadlines` no longer
looks for a `backup_completed` event (no component emits it anymore — the disk-tier backup moved to
the agent in slice 8C, so the check fired daily for every healthy customer). It now reads the
customer's **latest agent host-report** and raises `expected_backup_missed` only on positive
evidence: no PBS snapshot / successful vzdump at all, the newest backup older than **26h**, or the
newest PBS snapshot's `verify_state == "failed"`. A fresh-but-not-yet-verified snapshot is **not** a
failure (PBS verifies on its own cadence) — alarming on it would just re-create the false alarm. The
**db-dump half is unchanged** (the in-guest controller still emits `db_dump_completed`). A customer
with **no host-report** (legacy/defunct) gets no backup alarm here — liveness is the
host-staleness checker's job. New store accessor `GetLatestHostReportJSON`. Tests:
`internal/monitor/deadline_test.go` (fresh+verified→quiet, stale→alarm, failed-verify→alarm,
no-report→quiet, db-dump half preserved, plus a pure `assessBackupFreshness` table). The
fresh+verified→quiet test is the **companion**: it fails against the old event-based check.
### Removed
- **The Infra Backup feature**: ingest endpoint `POST /api/v1/infra-backup`, getters
`GET /api/v1/infra-backup/{id}[/versions]` and their handlers; store methods
`SaveInfraBackup` / `GetInfraBackup` / `GetInfraBackupByID` / `GetInfraBackupMeta` /
`ListInfraBackupVersions` / `pruneInfraBackups` + the `InfraBackupMeta` / `InfraBackupVersion`
types; the operator **"Infra Backup" panel** (`customer_unified.html`, `customer.html`). The
`GET /api/v1/recovery/{id}` endpoint is kept but now returns **only** the generated `config_yaml`
(no infra-backup payload). The customer-page **config-drift badge** that diffed against the stored
controller.yaml is hidden (its at-rest source is gone); the live **"Show Diff"** path is unaffected.
### Security / migration
- **Plaintext secret purge.** `migrate()` now `DROP`s `infra_backup_versions` + `infra_backups` and
runs **`VACUUM`** (+ `wal_checkpoint(TRUNCATE)`) so the freed pages holding the plaintext keys/
tokens are **physically reclaimed**, not merely delinked. Gated on table existence so normal
restarts don't pay the VACUUM cost.
- **Out of scope (flagged for the operator):** the exposed Cloudflare / hub / session credentials in
the dropped blobs remain valid until rotated (operator step). Separately, the legacy `reports`
table holds thousands of historical rows with a plaintext `restic_password` value from old
controller versions — a distinct leak, not purged here (the live controller no longer sends it).
## v0.11.0 — slice 10D: DR capstone — recovery mode + re-enroll + directive serving (2026-06-10)
The hub half of the slice-10 DR capstone (closes slice 10). The hub ORCHESTRATES recovery but holds