Files
felhom.eu/hub/internal/web/dr_recipe.go
T
admin 5f5e3c54a1 hub v0.13.0: DR recipe — assemble + store + view the secret-free reconstruction recipe
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>
2026-06-16 18:49:45 +02:00

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)
}