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
+8 -59
View File
@@ -1,7 +1,6 @@
package web
import (
"encoding/base64"
"encoding/json"
"fmt"
"html/template"
@@ -186,34 +185,13 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
}
}
// Config value comparison (parse both YAMLs, compare actual values)
var configSyncStatus string // "in_sync", "mismatch", "unknown"
// Config drift badge: the at-rest comparison source (infra-backup) was retired
// 2026-06-16. With no stored controller.yaml to diff against, the passive badge is
// left empty (the template hides it when ConfigSyncStatus == ""). The live "Show
// Diff" path (handleCompareConfig, which fetches the controller's config over HTTP)
// is unaffected and remains the way to check drift on demand.
var configSyncStatus string // "" hides the badge; "in_sync"/"mismatch" reserved for a future live source
var configDiffCount int
if cfg != nil {
infraData, _ := s.store.GetInfraBackup(customerID)
if infraData != nil {
controllerYAML := extractControllerYAML(infraData)
if controllerYAML != "" {
templateYAML := defaultControllerTemplate
if s.templateFetcher != nil {
templateYAML = s.templateFetcher.Template()
}
if hubYAML, err := configgen.Generate(templateYAML, cfg); err == nil {
diffs := compareYAMLValues(hubYAML, controllerYAML)
configDiffCount = len(diffs)
if configDiffCount == 0 {
configSyncStatus = "in_sync"
} else {
configSyncStatus = "mismatch"
}
}
} else {
configSyncStatus = "unknown"
}
} else {
configSyncStatus = "unknown"
}
}
// Version check
var latestVersion string
@@ -225,13 +203,10 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
}
}
// History, notifications, events, infra backup
// History, notifications, events
var history []store.CustomerSummary
var notifPrefs *store.NotificationPrefs
var recentNotifs []store.NotificationLogEntry
var infraMeta *store.InfraBackupMeta
var infraBackupAge string
var infraBackupVersions []store.InfraBackupVersion
var events []store.Event
var eventCounts map[string]int
@@ -241,11 +216,6 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
history, _ = s.store.GetCustomerHistory(customerID, 24*time.Hour)
notifPrefs, _ = s.store.GetNotificationPrefs(customerID)
recentNotifs, _ = s.store.GetRecentNotifications(customerID, 10)
infraMeta, _ = s.store.GetInfraBackupMeta(customerID)
if infraMeta != nil {
infraBackupAge = timeAgo(infraMeta.UpdatedAt)
}
infraBackupVersions, _ = s.store.ListInfraBackupVersions(customerID)
events, _ = s.store.GetRecentEvents(customerID, 50)
eventCounts, _ = s.store.CountEventsBySeverity(customerID, time.Now().Add(-24*time.Hour))
appTelemetry, _ = s.store.GetCustomerAppSummary(customerID, time.Now().Add(-7*24*time.Hour))
@@ -274,9 +244,6 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
ConfigSyncStatus string // "in_sync", "mismatch", "unknown"
ConfigDiffCount int
InfraBackup *store.InfraBackupMeta
InfraBackupAge string
InfraBackupVersions []store.InfraBackupVersion
NotifPrefs *store.NotificationPrefs
RecentNotifications []store.NotificationLogEntry
History []store.CustomerSummary
@@ -316,9 +283,6 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
ConfigSyncStatus: configSyncStatus,
ConfigDiffCount: configDiffCount,
InfraBackup: infraMeta,
InfraBackupAge: infraBackupAge,
InfraBackupVersions: infraBackupVersions,
NotifPrefs: notifPrefs,
RecentNotifications: recentNotifs,
History: history,
@@ -734,22 +698,7 @@ func buildConfigJSON(r *http.Request) string {
return string(data)
}
// --- Config comparison helpers ---
// extractControllerYAML decodes the controller.yaml from an infra backup JSON payload.
func extractControllerYAML(infraData []byte) string {
var parsed struct {
ControllerConfigB64 string `json:"controller_config_b64"`
}
if err := json.Unmarshal(infraData, &parsed); err != nil || parsed.ControllerConfigB64 == "" {
return ""
}
data, err := base64.StdEncoding.DecodeString(parsed.ControllerConfigB64)
if err != nil {
return ""
}
return string(data)
}
// --- Config comparison helpers (used by the live "Show Diff" handler) ---
// volatileKeys are YAML keys ignored during config comparison (always differ or deprecated).
var volatileKeys = map[string]bool{
-23
View File
@@ -133,29 +133,6 @@
{{end}}
</section>
<!-- Infra Backup (Disaster Recovery) -->
<section class="card">
<h2>Infra Backup</h2>
{{if .InfraBackup}}
<div class="info-grid">
<div class="info-item">
<span class="label">Last Updated</span>
<span class="value">{{.InfraBackupAge}}</span>
</div>
<div class="info-item">
<span class="label">Deployed Stacks</span>
<span class="value">{{.InfraBackup.StackCount}}</span>
</div>
<div class="info-item">
<span class="label">Disks</span>
<span class="value">{{.InfraBackup.DiskCount}}</span>
</div>
</div>
{{else}}
<p style="color: #facc15">No infra backup received yet</p>
{{end}}
</section>
<!-- Health -->
<section class="card">
<h2>Health</h2>
@@ -302,55 +302,6 @@
{{end}}
{{end}}
<!-- Infra Backup -->
<section class="card">
<h2>Infra Backup</h2>
{{if .InfraBackup}}
<div class="info-grid">
<div class="info-item">
<span class="label">Last Updated</span>
<span class="value">{{.InfraBackupAge}}</span>
</div>
<div class="info-item">
<span class="label">Deployed Stacks</span>
<span class="value">{{.InfraBackup.StackCount}}</span>
</div>
<div class="info-item">
<span class="label">Disks</span>
<span class="value">{{.InfraBackup.DiskCount}}</span>
</div>
<div class="info-item">
<span class="label">Versions</span>
<span class="value">{{.InfraBackup.VersionCount}}</span>
</div>
</div>
{{if .InfraBackupVersions}}
<details style="margin-top: 0.75rem;">
<summary style="cursor: pointer; color: var(--text-secondary, #94a3b8); font-size: 0.85em;">Backup History ({{len .InfraBackupVersions}} versions)</summary>
<table style="width: 100%; margin-top: 0.5rem; font-size: 0.85em;">
<thead>
<tr>
<th style="text-align: left; padding: 0.25rem 0.5rem;">Date</th>
<th style="text-align: left; padding: 0.25rem 0.5rem;">Apps</th>
<th style="text-align: right; padding: 0.25rem 0.5rem;">Disks</th>
</tr>
</thead>
<tbody>
{{range .InfraBackupVersions}}
<tr>
<td style="padding: 0.25rem 0.5rem;">{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
<td style="padding: 0.25rem 0.5rem;">{{.StackCount}}{{if .StackNames}}: {{range $i, $n := .StackNames}}{{if $i}}, {{end}}{{$n}}{{end}}{{end}}</td>
<td style="text-align: right; padding: 0.25rem 0.5rem;">{{.DiskCount}}</td>
</tr>
{{end}}
</tbody>
</table>
</details>
{{end}}
{{else}}
<p style="color: #facc15">No infra backup received yet</p>
{{end}}
</section>
<!-- Health -->
<section class="card">
@@ -489,7 +440,7 @@
{{if eq .ConfigSyncStatus "in_sync"}}<span style="color: #22c55e;">&#x2713; In sync</span>
{{else if eq .ConfigSyncStatus "mismatch"}}<span style="color: #f59e0b;">&#x26A0; Config mismatch — {{.ConfigDiffCount}} difference{{if gt .ConfigDiffCount 1}}s{{end}}</span>
<button class="btn btn-outline btn-sm" style="margin-left: 0.5em; font-size: 0.8em;" onclick="showConfigDiff('{{.CustomerID}}')">Show Diff</button>
{{else}}<span style="color: #94a3b8;">Unknown — no infra backup available yet</span>
{{else}}<span style="color: #94a3b8;">Unknown — use "Show Diff" to compare live</span>
{{end}}
</span>
</div>