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
+45 -291
View File
@@ -94,12 +94,6 @@ func (s *Store) migrate() error {
CREATE INDEX IF NOT EXISTS idx_notification_log_customer
ON notification_log(customer_id, created_at DESC);
CREATE TABLE IF NOT EXISTS infra_backups (
customer_id TEXT PRIMARY KEY,
backup_json TEXT NOT NULL,
updated_at DATETIME NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS customer_configs (
customer_id TEXT PRIMARY KEY,
customer_name TEXT NOT NULL DEFAULT '',
@@ -196,27 +190,31 @@ func (s *Store) migrate() error {
return err
}
// v0.7.0: versioned infra backups with GFS retention
_, err = s.db.Exec(`
CREATE TABLE IF NOT EXISTS infra_backup_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id TEXT NOT NULL,
backup_json TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_ibv_customer_time
ON infra_backup_versions(customer_id, created_at DESC);
`)
if err != nil {
return err
// Phase-1 retire (2026-06-16): the Infra Backup mechanism is gone. It pushed
// plaintext customer secrets to the hub (the app-secret encryption key, restic
// password, Cloudflare tokens — a zero-knowledge violation) and had been dead since
// slice 8C. Drop both tables and VACUUM so the freed pages — which still hold the
// plaintext — are physically reclaimed from the DB file, not merely delinked.
// Gated on existence so ordinary restarts don't pay the VACUUM cost.
var infraTables int
s.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table'
AND name IN ('infra_backup_versions','infra_backups')`).Scan(&infraTables)
if infraTables > 0 {
if _, err = s.db.Exec(`DROP TABLE IF EXISTS infra_backup_versions;
DROP TABLE IF EXISTS infra_backups;`); err != nil {
return fmt.Errorf("dropping retired infra_backup tables: %w", err)
}
// VACUUM rewrites the database file, discarding the freed (plaintext) pages.
if _, err = s.db.Exec(`VACUUM`); err != nil {
return fmt.Errorf("vacuum after infra_backup drop: %w", err)
}
// WAL checkpoint(TRUNCATE) so no plaintext lingers in the -wal sidecar either.
s.db.Exec(`PRAGMA wal_checkpoint(TRUNCATE)`)
if s.logger != nil {
s.logger.Printf("[INFO] Retired infra-backup: dropped %d table(s) and VACUUMed to reclaim plaintext pages", infraTables)
}
}
// One-time migration: copy existing single-row backups to versioned table
s.db.Exec(`INSERT INTO infra_backup_versions (customer_id, backup_json, created_at)
SELECT customer_id, backup_json, updated_at FROM infra_backups
WHERE NOT EXISTS (SELECT 1 FROM infra_backup_versions
WHERE infra_backup_versions.customer_id = infra_backups.customer_id)`)
// v0.7.0: host-domain (slice 3). Purely additive — the controller path
// (reports/customer_configs) is untouched; the schema cutover is slice 10.
// Columns marked INERT exist now so slice 10 needs no ALTER; nothing reads or
@@ -633,272 +631,8 @@ func (s *Store) GetCustomerHistory(customerID string, since time.Duration) ([]Cu
return history, rows.Err()
}
// SaveInfraBackup inserts a new infra backup version and prunes old ones (GFS retention).
func (s *Store) SaveInfraBackup(customerID string, backupJSON []byte) error {
_, err := s.db.Exec(`
INSERT INTO infra_backup_versions (customer_id, backup_json, created_at)
VALUES (?, ?, datetime('now'))`,
customerID, string(backupJSON),
)
if err != nil {
return err
}
// Also maintain legacy table for backward compatibility during rollback window
s.db.Exec(`INSERT INTO infra_backups (customer_id, backup_json, updated_at)
VALUES (?, ?, datetime('now'))
ON CONFLICT(customer_id) DO UPDATE SET
backup_json = excluded.backup_json,
updated_at = datetime('now')`,
customerID, string(backupJSON))
s.pruneInfraBackups(customerID)
return nil
}
// GetInfraBackup returns the latest infra backup JSON for a customer, or nil if not found.
func (s *Store) GetInfraBackup(customerID string) ([]byte, error) {
var data string
err := s.db.QueryRow(
"SELECT backup_json FROM infra_backup_versions WHERE customer_id = ? ORDER BY created_at DESC LIMIT 1",
customerID,
).Scan(&data)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return []byte(data), nil
}
// GetInfraBackupByID returns the infra backup JSON for a specific version ID, or nil if not found.
func (s *Store) GetInfraBackupByID(id int64) ([]byte, error) {
var data string
err := s.db.QueryRow(
"SELECT backup_json FROM infra_backup_versions WHERE id = ?", id,
).Scan(&data)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return []byte(data), nil
}
// InfraBackupMeta holds summary info for the dashboard (avoids parsing full JSON).
type InfraBackupMeta struct {
UpdatedAt time.Time
StackCount int
DiskCount int
VersionCount int
}
// GetInfraBackupMeta returns summary metadata for a customer's latest infra backup.
func (s *Store) GetInfraBackupMeta(customerID string) (*InfraBackupMeta, error) {
var backupJSON, createdAt string
err := s.db.QueryRow(
"SELECT backup_json, created_at FROM infra_backup_versions WHERE customer_id = ? ORDER BY created_at DESC LIMIT 1",
customerID,
).Scan(&backupJSON, &createdAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
meta := &InfraBackupMeta{
UpdatedAt: parseSQLiteTime(createdAt),
}
// Count total versions
s.db.QueryRow("SELECT COUNT(*) FROM infra_backup_versions WHERE customer_id = ?", customerID).Scan(&meta.VersionCount)
// Parse just the fields we need
parseInfraBackupCounts(backupJSON, &meta.StackCount, &meta.DiskCount, nil, s.logger, customerID)
return meta, nil
}
// InfraBackupVersion holds summary info for a single backup version.
type InfraBackupVersion struct {
ID int64 `json:"id"`
CreatedAt time.Time `json:"created_at"`
StackCount int `json:"stack_count"`
DiskCount int `json:"disk_count"`
StackNames []string `json:"stack_names,omitempty"`
}
// ListInfraBackupVersions returns metadata for all retained versions of a customer's backup.
func (s *Store) ListInfraBackupVersions(customerID string) ([]InfraBackupVersion, error) {
rows, err := s.db.Query(
"SELECT id, backup_json, created_at FROM infra_backup_versions WHERE customer_id = ? ORDER BY created_at DESC",
customerID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var versions []InfraBackupVersion
for rows.Next() {
var v InfraBackupVersion
var backupJSON, createdAt string
if err := rows.Scan(&v.ID, &backupJSON, &createdAt); err != nil {
return nil, err
}
v.CreatedAt = parseSQLiteTime(createdAt)
parseInfraBackupCounts(backupJSON, &v.StackCount, &v.DiskCount, &v.StackNames, nil, "")
versions = append(versions, v)
}
return versions, rows.Err()
}
// pruneInfraBackups applies GFS retention: keep all from last 24h, latest per day (7d),
// latest per week (4w), latest per month (3mo). Delete everything else.
func (s *Store) pruneInfraBackups(customerID string) {
rows, err := s.db.Query(
"SELECT id, created_at FROM infra_backup_versions WHERE customer_id = ? ORDER BY created_at DESC",
customerID,
)
if err != nil {
return
}
defer rows.Close()
type entry struct {
id int64
createdAt time.Time
}
var all []entry
for rows.Next() {
var e entry
var ts string
if err := rows.Scan(&e.id, &ts); err != nil {
return
}
e.createdAt = parseSQLiteTime(ts)
all = append(all, e)
}
if len(all) <= 1 {
return
}
now := time.Now().UTC()
keep := make(map[int64]bool)
seenDays := make(map[string]bool)
seenWeeks := make(map[string]bool)
seenMonths := make(map[string]bool)
for _, e := range all {
age := now.Sub(e.createdAt)
// Keep all from last 24h
if age < 24*time.Hour {
keep[e.id] = true
continue
}
// Latest per calendar day for last 7 days
if age < 7*24*time.Hour {
day := e.createdAt.Format("2006-01-02")
if !seenDays[day] {
seenDays[day] = true
keep[e.id] = true
}
continue
}
// Latest per ISO week for last 4 weeks
if age < 28*24*time.Hour {
year, week := e.createdAt.ISOWeek()
wk := fmt.Sprintf("%d-W%02d", year, week)
if !seenWeeks[wk] {
seenWeeks[wk] = true
keep[e.id] = true
}
continue
}
// Latest per calendar month for last 3 months
if age < 90*24*time.Hour {
month := e.createdAt.Format("2006-01")
if !seenMonths[month] {
seenMonths[month] = true
keep[e.id] = true
}
continue
}
// Older than 3 months — don't keep
}
// Build delete list
var deleteIDs []interface{}
var placeholders []string
for _, e := range all {
if !keep[e.id] {
deleteIDs = append(deleteIDs, e.id)
placeholders = append(placeholders, "?")
}
}
if len(deleteIDs) == 0 {
return
}
query := "DELETE FROM infra_backup_versions WHERE id IN (" + joinStrings(placeholders, ",") + ")"
_, err = s.db.Exec(query, deleteIDs...)
if err != nil {
s.logger.Printf("[WARN] Failed to prune infra backup versions for %s: %v", customerID, err)
} else {
s.logger.Printf("[INFO] Pruned %d old infra backup version(s) for %s (kept %d)", len(deleteIDs), customerID, len(keep))
}
}
// parseInfraBackupCounts extracts stack/disk counts and optionally stack names from backup JSON.
func parseInfraBackupCounts(backupJSON string, stackCount, diskCount *int, stackNames *[]string, logger *log.Logger, customerID string) {
var parsed struct {
DeployedStacks []struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
} `json:"deployed_stacks"`
DiskLayout struct {
Mounts []json.RawMessage `json:"mounts"`
} `json:"disk_layout"`
}
if err := json.Unmarshal([]byte(backupJSON), &parsed); err != nil {
if logger != nil {
logger.Printf("[WARN] Failed to parse infra backup metadata for %s: %v", customerID, err)
}
return
}
*stackCount = len(parsed.DeployedStacks)
*diskCount = len(parsed.DiskLayout.Mounts)
if stackNames != nil {
for _, s := range parsed.DeployedStacks {
name := s.DisplayName
if name == "" {
name = s.Name
}
*stackNames = append(*stackNames, name)
}
}
}
func joinStrings(ss []string, sep string) string {
if len(ss) == 0 {
return ""
}
result := ss[0]
for _, s := range ss[1:] {
result += sep + s
}
return result
}
// (Infra-backup store methods retired 2026-06-16. The tables are dropped + VACUUMed
// in migrate(); the push/get/versions handlers and the operator panel are gone.)
// Prune deletes reports older than the given number of days.
func (s *Store) Prune(maxDays int) (int64, error) {
@@ -1645,6 +1379,26 @@ func (s *Store) SaveHostReport(hostID, customerID string, reportJSON []byte, d H
return err
}
// GetLatestHostReportJSON returns the most recent host-report payload (report_json)
// for a customer, by received_at, or ("", nil) if the customer has no host-report.
// The backup deadline check uses it to read the agent's own backup reality
// (pbs_snapshots + vzdump backups) — the authoritative offsite-backup signal
// post-slice-8C, replacing the no-longer-emitted backup_completed event.
func (s *Store) GetLatestHostReportJSON(customerID string) (string, error) {
var j string
err := s.db.QueryRow(
`SELECT report_json FROM host_reports WHERE customer_id = ? ORDER BY received_at DESC LIMIT 1`,
customerID,
).Scan(&j)
if err == sql.ErrNoRows {
return "", nil
}
if err != nil {
return "", err
}
return j, nil
}
// UpsertGuestFromReport upserts the REALITY columns of a guest. On conflict it
// must NOT clobber the inert columns (api_key / desired_spec_json).
func (s *Store) UpsertGuestFromReport(g *Guest) error {