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:
+14
-130
@@ -169,14 +169,6 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
h.handleEvent(w, r)
|
||||
case r.Method == http.MethodPost && path == "/notify":
|
||||
h.handleNotify(w, r)
|
||||
case r.Method == http.MethodPost && path == "/infra-backup":
|
||||
h.handleInfraBackupPush(w, r)
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(path, "/infra-backup/") && strings.HasSuffix(path, "/versions"):
|
||||
customerID := strings.TrimPrefix(path, "/infra-backup/")
|
||||
customerID = strings.TrimSuffix(customerID, "/versions")
|
||||
h.handleInfraBackupVersions(w, r, customerID)
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(path, "/infra-backup/"):
|
||||
h.handleInfraBackupGet(w, r, strings.TrimPrefix(path, "/infra-backup/"))
|
||||
case r.Method == http.MethodPost && path == "/preferences":
|
||||
h.handleSavePreferences(w, r)
|
||||
case r.Method == http.MethodGet && path == "/customers":
|
||||
@@ -1244,94 +1236,14 @@ func (h *Handler) handleSavePreferences(w http.ResponseWriter, r *http.Request)
|
||||
w.Write([]byte(`{"status":"ok"}`))
|
||||
}
|
||||
|
||||
// handleInfraBackupPush stores an infrastructure snapshot from a controller.
|
||||
func (h *Handler) handleInfraBackupPush(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.checkAuth(r) {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit
|
||||
if err != nil {
|
||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
CustomerID string `json:"customer_id"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil || payload.CustomerID == "" {
|
||||
http.Error(w, "Invalid payload: customer_id required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.SaveInfraBackup(payload.CustomerID, body); err != nil {
|
||||
h.logger.Printf("[ERROR] Failed to save infra backup for %s: %v", payload.CustomerID, err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Printf("[INFO] Infra backup saved for %s (%d bytes)", payload.CustomerID, len(body))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"status":"ok"}`))
|
||||
}
|
||||
|
||||
// handleInfraBackupGet returns the infrastructure backup for a customer.
|
||||
func (h *Handler) handleInfraBackupGet(w http.ResponseWriter, r *http.Request, customerID string) {
|
||||
if !h.checkAuth(r) {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if customerID == "" {
|
||||
http.Error(w, "Missing customer_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
data, err := h.store.GetInfraBackup(customerID)
|
||||
if err != nil {
|
||||
h.logger.Printf("[ERROR] Failed to get infra backup for %s: %v", customerID, err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if data == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
// handleInfraBackupVersions returns a list of backup versions for a customer.
|
||||
// Auth: Bearer token.
|
||||
func (h *Handler) handleInfraBackupVersions(w http.ResponseWriter, r *http.Request, customerID string) {
|
||||
if !h.checkAuth(r) {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if customerID == "" {
|
||||
http.Error(w, "Missing customer_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
versions, err := h.store.ListInfraBackupVersions(customerID)
|
||||
if err != nil {
|
||||
h.logger.Printf("[ERROR] Failed to list infra backup versions for %s: %v", customerID, err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if versions == nil {
|
||||
versions = []store.InfraBackupVersion{}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(versions)
|
||||
}
|
||||
|
||||
// handleRecovery returns both the generated controller.yaml and the infra backup for disaster recovery.
|
||||
// handleRecovery returns the generated controller.yaml for disaster recovery.
|
||||
// Auth: X-Retrieval-Password header (same as config retrieval).
|
||||
//
|
||||
// The infra-backup payload was retired (Phase-1, 2026-06-16): it pushed plaintext
|
||||
// customer secrets to the hub (a zero-knowledge violation) and had been dead since
|
||||
// slice 8C. DR config now comes from the generated controller.yaml here; the data
|
||||
// bytes come from the agent's PBS whole-CT snapshot. A secret-free DR recipe is the
|
||||
// later DR slice's job.
|
||||
func (h *Handler) handleRecovery(w http.ResponseWriter, r *http.Request, customerID string) {
|
||||
if customerID == "" {
|
||||
http.Error(w, "Missing customer_id", http.StatusBadRequest)
|
||||
@@ -1372,47 +1284,19 @@ func (h *Handler) handleRecovery(w http.ResponseWriter, r *http.Request, custome
|
||||
configYAML = yamlOutput
|
||||
}
|
||||
|
||||
// Fetch infra backup (optional — may not exist for new customers)
|
||||
var infraBackup json.RawMessage
|
||||
hasInfraBackup := false
|
||||
|
||||
// Support ?version=ID for selecting a specific backup version
|
||||
if versionStr := r.URL.Query().Get("version"); versionStr != "" {
|
||||
var versionID int64
|
||||
if _, err := fmt.Sscanf(versionStr, "%d", &versionID); err == nil {
|
||||
if data, err := h.store.GetInfraBackupByID(versionID); err == nil && data != nil {
|
||||
infraBackup = data
|
||||
hasInfraBackup = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if data, err := h.store.GetInfraBackup(customerID); err == nil && data != nil {
|
||||
infraBackup = data
|
||||
hasInfraBackup = true
|
||||
}
|
||||
}
|
||||
|
||||
// Include version list for version picker
|
||||
var backupVersions []store.InfraBackupVersion
|
||||
if versions, err := h.store.ListInfraBackupVersions(customerID); err == nil {
|
||||
backupVersions = versions
|
||||
}
|
||||
|
||||
// infra_backup retired: the response keeps has_infra_backup=false so any old client
|
||||
// degrades gracefully to the config_yaml-only path.
|
||||
resp := struct {
|
||||
CustomerID string `json:"customer_id"`
|
||||
ConfigYAML string `json:"config_yaml"`
|
||||
InfraBackup json.RawMessage `json:"infra_backup"`
|
||||
HasInfraBackup bool `json:"has_infra_backup"`
|
||||
BackupVersions []store.InfraBackupVersion `json:"backup_versions,omitempty"`
|
||||
CustomerID string `json:"customer_id"`
|
||||
ConfigYAML string `json:"config_yaml"`
|
||||
HasInfraBackup bool `json:"has_infra_backup"`
|
||||
}{
|
||||
CustomerID: customerID,
|
||||
ConfigYAML: configYAML,
|
||||
InfraBackup: infraBackup,
|
||||
HasInfraBackup: hasInfraBackup,
|
||||
BackupVersions: backupVersions,
|
||||
HasInfraBackup: false,
|
||||
}
|
||||
|
||||
h.logger.Printf("[INFO] Recovery data downloaded for customer %s (has_infra_backup=%v, versions=%d)", customerID, hasInfraBackup, len(backupVersions))
|
||||
h.logger.Printf("[INFO] Recovery data downloaded for customer %s (config only; infra-backup retired)", customerID)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user