5f5e3c54a1
DR recipe slice (hub half), grounded in SPIKE-dr-recipe-2026-06-16. The hub
receives two additive dr_recipe halves on the existing report paths (agent
storage/guest/PBS on host-report; controller customer/apps on the controller
report), stores them PLAINTEXT in a DEDICATED dr_recipe table keyed by customer
(each half preserves the other), and AssembleDRRecipe stitches them into one
operator-readable recipe (ignore-unknown + version-skew tolerant).
View: a DR-recipe panel on the customer page + GET /customers/{id}/dr-recipe.json
download (operator-auth, no secrets to redact). Plaintext-at-rest is correct —
the recipe is the clean inverse of the retired infra-backup.
Tests: store round-trip (each half preserves the other), assemble-matches-golden,
ignore-unknown + version skew, partial halves, no-secrets sweep. Manifest tag
bumped to v0.13.0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
62 lines
2.1 KiB
Go
62 lines
2.1 KiB
Go
package web
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
|
)
|
|
|
|
// handleDRRecipeDownload serves the assembled secret-free DR recipe for a customer as a JSON download
|
|
// (SPIKE-dr-recipe-2026-06-16). The recipe is PLAINTEXT because it carries NO secrets — only the
|
|
// reconstruction scaffolding (guest sizing, drive inventory, PVE storage, PBS coordinates, app
|
|
// inventory + storage bindings). Operator (dashboard-auth) only; no decrypt, nothing to redact.
|
|
func (s *Server) handleDRRecipeDownload(w http.ResponseWriter, r *http.Request, customerID string) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
rec, err := s.store.GetDRRecipe(customerID)
|
|
if err != nil {
|
|
s.logger.Printf("[ERROR] DR-recipe lookup failed for %s: %v", customerID, err)
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if rec == nil {
|
|
http.Error(w, "No DR recipe for this customer yet (awaiting a host-report + controller report)", http.StatusNotFound)
|
|
return
|
|
}
|
|
assembled, err := store.AssembleDRRecipe(rec)
|
|
if err != nil {
|
|
s.logger.Printf("[ERROR] DR-recipe assemble failed for %s: %v", customerID, err)
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
out, err := json.MarshalIndent(assembled, "", " ")
|
|
if err != nil {
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.Header().Set("Content-Disposition", "attachment; filename=\"dr-recipe-"+safeFilename(customerID)+".json\"")
|
|
w.Write(out)
|
|
s.logger.Printf("[INFO] DR-recipe downloaded for customer %s (v%d)", customerID, assembled.RecipeVersion)
|
|
}
|
|
|
|
// safeFilename keeps a customer id safe for a Content-Disposition filename (alnum/-/_ only).
|
|
func safeFilename(s string) string {
|
|
out := make([]rune, 0, len(s))
|
|
for _, r := range s {
|
|
switch {
|
|
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
|
|
out = append(out, r)
|
|
default:
|
|
out = append(out, '_')
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return "customer"
|
|
}
|
|
return string(out)
|
|
}
|