v0.127.0: customer-facing escrow ceremony wizard (/backup/escrow) + Scenario-F stale-blob re-check — one-shot R reveal (no-store, typed-back), re-stage-first start order, agent version gate (MinAgent 0.88.0), escrowed-state hash re-check with card warning (never flips, never blocks); manual-confirm button removed (endpoint stays deprecated)
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Controller-driven escrow ceremony wizard (v0.127.0, agent ≥ v0.88.0; mechanics validated by
|
||||
// SPIKE-controller-escrow-2026-07-13). The customer runs the ceremony from /backup/escrow:
|
||||
// preflight → warnings → password re-auth → the agent's detached root job → the ONE-SHOT R
|
||||
// reveal (claim XHR only — R is NEVER templated server-side into HTML) → typed-back confirm.
|
||||
//
|
||||
// R-handling absolutes (§9 rule 4 of the task): R is never logged (either repo, any level,
|
||||
// including the debug ring), never persisted, never placed in any payload except the claim XHR
|
||||
// response (Cache-Control: no-store), never echoed in errors.
|
||||
|
||||
// minEscrowAgentVersion is the MinAgent for the ceremony endpoints (the v0.88.0 localapi job).
|
||||
// Gate: AgentVersion() compare, HEADER ABSENT = OLDER (fail-closed — unlike the probe-based
|
||||
// Supports, an unknown agent must not be triggered blind; the preflight/stage traffic populates
|
||||
// the passive version header, so a live 0.88+ agent is always known by the time start runs).
|
||||
const minEscrowAgentVersion = "0.88.0"
|
||||
|
||||
// escrowAgent is the narrow agent surface the wizard needs (*agentapi.Client satisfies it;
|
||||
// tests inject fakes to assert call order and refusal short-circuits).
|
||||
type escrowAgent interface {
|
||||
EscrowPreflight(ctx context.Context) (agentapi.EscrowPreflightResponse, error)
|
||||
EscrowCeremonyStart(ctx context.Context) (agentapi.EscrowCeremonyStartResponse, int, error)
|
||||
EscrowCeremonyStatus(ctx context.Context) (agentapi.EscrowCeremonyStatusResponse, error)
|
||||
EscrowCeremonyClaim(ctx context.Context) (string, int, error)
|
||||
AgentVersion() string
|
||||
}
|
||||
|
||||
// SetEscrowStale wires the Scenario-F stale-blob flag source (report.EscrowAutoConfirmer.StaleBlob).
|
||||
// Init-time only, like every Set*.
|
||||
func (s *Server) SetEscrowStale(fn func() bool) { s.escrowStaleFn = fn }
|
||||
|
||||
// escrowStale reads the stale-blob display flag (false when unwired).
|
||||
func (s *Server) escrowStale() bool {
|
||||
return s.escrowStaleFn != nil && s.escrowStaleFn()
|
||||
}
|
||||
|
||||
// escrowAgentConn resolves the agent surface (seam-first; default = the shared pinned client).
|
||||
func (s *Server) escrowAgentConn() (escrowAgent, error) {
|
||||
if s.escrowAgentFn != nil {
|
||||
return s.escrowAgentFn()
|
||||
}
|
||||
return s.agentClient()
|
||||
}
|
||||
|
||||
// escrowStage re-stages the CURRENT offbox repo password to the agent (re-stage-first: a ceremony
|
||||
// without the staged secret would mint the forbidden hash-less blob). Seam-first for tests.
|
||||
func (s *Server) escrowStage(ctx context.Context) error {
|
||||
if s.escrowStageFn != nil {
|
||||
return s.escrowStageFn(ctx)
|
||||
}
|
||||
client, err := s.agentClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.backupMgr.PushOffboxPasswordForEscrow(ctx, client.StageEscrowSecret)
|
||||
}
|
||||
|
||||
// escrowAgentSupported reports whether ver (the passive X-Felhom-Agent-Version capture) is at
|
||||
// least minEscrowAgentVersion. "" (header-less agent or no traffic yet) = OLDER — fail-closed.
|
||||
func escrowAgentSupported(ver string) bool {
|
||||
if ver == "" {
|
||||
return false
|
||||
}
|
||||
av, err := util.ParseVersion(ver)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
mv, err := util.ParseVersion(minEscrowAgentVersion)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return av.Compare(mv) >= 0
|
||||
}
|
||||
|
||||
// escrowJSON writes the {ok,data,error} envelope the /api/* surface speaks.
|
||||
func escrowJSON(w http.ResponseWriter, code int, data map[string]any, errMsg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": errMsg == "", "data": data, "error": errMsg})
|
||||
}
|
||||
|
||||
// ServeEscrowAPI dispatches /api/escrow/* (session-authed + CSRF-protected at the mux, like the
|
||||
// disk API).
|
||||
func (s *Server) ServeEscrowAPI(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/api/escrow/preflight" && r.Method == http.MethodGet:
|
||||
s.escrowPreflightAPIHandler(w, r)
|
||||
case r.URL.Path == "/api/escrow/start" && r.Method == http.MethodPost:
|
||||
s.escrowStartAPIHandler(w, r)
|
||||
case r.URL.Path == "/api/escrow/status" && r.Method == http.MethodGet:
|
||||
s.escrowStatusAPIHandler(w, r)
|
||||
case r.URL.Path == "/api/escrow/claim" && r.Method == http.MethodPost:
|
||||
s.escrowClaimAPIHandler(w, r)
|
||||
default:
|
||||
escrowJSON(w, http.StatusNotFound, nil, "ismeretlen végpont")
|
||||
}
|
||||
}
|
||||
|
||||
// escrowWizardPageHandler renders /backup/escrow (GET). The page itself is no-store: it hosts
|
||||
// the R reveal, and a cached copy of ANY wizard state is one copy too many.
|
||||
func (s *Server) escrowWizardPageHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
offboxTgt := s.settings.GetOffboxTarget()
|
||||
escrowed := offboxTgt != nil && offboxTgt.EscrowState == "escrowed"
|
||||
agentVer := ""
|
||||
if agent, err := s.escrowAgentConn(); err == nil {
|
||||
agentVer = agent.AgentVersion()
|
||||
}
|
||||
data := map[string]interface{}{
|
||||
"Title": "Helyreállítási kód",
|
||||
"CustomerName": s.cfg.Customer.Name,
|
||||
"Domain": s.cfg.Customer.Domain,
|
||||
"ActivePage": "backups-remote",
|
||||
"Receremony": escrowed || s.escrowStale(), // the supersede warning variant
|
||||
"OffboxConfigured": s.backupMgr != nil && s.backupMgr.OffboxConfigured(),
|
||||
"AgentSupported": escrowAgentSupported(agentVer),
|
||||
}
|
||||
s.executeTemplate(w, r, "backups_escrow", data)
|
||||
}
|
||||
|
||||
// escrowPreflightAPIHandler proxies the agent checklist + the controller-side facts the wizard
|
||||
// renders (agent version gate, offbox/escrow state for the supersede copy).
|
||||
func (s *Server) escrowPreflightAPIHandler(w http.ResponseWriter, r *http.Request) {
|
||||
agent, err := s.escrowAgentConn()
|
||||
if err != nil {
|
||||
escrowJSON(w, http.StatusBadGateway, nil, "Az ügynök nem elérhető.")
|
||||
return
|
||||
}
|
||||
pf, err := agent.EscrowPreflight(r.Context())
|
||||
if err != nil {
|
||||
s.logger.Printf("[WARN] [web] escrow preflight: %v", err)
|
||||
escrowJSON(w, http.StatusBadGateway, nil, "Az előfeltételek ellenőrzése nem sikerült — az ügynök nem válaszol.")
|
||||
return
|
||||
}
|
||||
offboxTgt := s.settings.GetOffboxTarget()
|
||||
escrowState := ""
|
||||
if offboxTgt != nil {
|
||||
escrowState = offboxTgt.EscrowState
|
||||
}
|
||||
agentOK := escrowAgentSupported(agent.AgentVersion())
|
||||
escrowJSON(w, http.StatusOK, map[string]any{
|
||||
"ok": pf.OK && agentOK, "items": pf.Items,
|
||||
"agent_supported": agentOK,
|
||||
"offbox_configured": s.backupMgr != nil && s.backupMgr.OffboxConfigured(),
|
||||
"escrow_state": escrowState,
|
||||
"stale": s.escrowStale(),
|
||||
}, "")
|
||||
}
|
||||
|
||||
// escrowStartAPIHandler is the wizard's run trigger. Order (load-bearing, Scenario A/E):
|
||||
// (1) password re-auth — rides the LOGIN rate limiter; (2) re-stage-first when offbox is
|
||||
// configured, ABORT on failure (a ceremony without the staged secret mints the forbidden
|
||||
// hash-less blob); (3) agent version gate (the stage/preflight traffic has populated the passive
|
||||
// header by now); (4) trigger the agent job. Every refusal exits BEFORE the agent is called.
|
||||
func (s *Server) escrowStartAPIHandler(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
|
||||
// (1) Re-auth. A passwordless box cannot re-auth — refuse (the claim gate normally prevents
|
||||
// this state; a legacy-open box must claim/set a password first).
|
||||
hash := s.effectivePasswordHash()
|
||||
if hash == "" {
|
||||
escrowJSON(w, http.StatusForbidden, nil, "A vezérlőpult jelszava nincs beállítva — előbb állítson be jelszót.")
|
||||
return
|
||||
}
|
||||
ip := requestIP(r)
|
||||
if s.escrowRateLimited(ip) {
|
||||
s.logger.Printf("[WARN] [web] escrow start rate limited for %s", ip)
|
||||
escrowJSON(w, http.StatusTooManyRequests, nil, "Túl sok sikertelen próbálkozás, próbálja újra 1 perc múlva.")
|
||||
return
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(r.FormValue("password"))); err != nil {
|
||||
s.logger.Printf("[WARN] [web] escrow start: failed re-auth from %s", r.RemoteAddr)
|
||||
s.recordEscrowAuthFailure(ip)
|
||||
escrowJSON(w, http.StatusUnauthorized, nil, "Hibás jelszó.")
|
||||
return
|
||||
}
|
||||
s.clearAuthFailures(ip)
|
||||
|
||||
// (2) Re-stage-first (only when offsite is configured — Scenario C boxes skip it).
|
||||
if s.backupMgr != nil && s.backupMgr.OffboxConfigured() {
|
||||
if err := s.escrowStage(r.Context()); err != nil {
|
||||
s.logger.Printf("[WARN] [web] escrow start: re-stage failed (ceremony NOT started): %v", err) // err carries no secret
|
||||
escrowJSON(w, http.StatusBadGateway, nil, "A távoli mentés jelszavának letéti előkészítése nem sikerült — a folyamat nem indult el. Próbálja újra.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// (3) Agent + version gate.
|
||||
agent, err := s.escrowAgentConn()
|
||||
if err != nil {
|
||||
escrowJSON(w, http.StatusBadGateway, nil, "Az ügynök nem elérhető.")
|
||||
return
|
||||
}
|
||||
if !escrowAgentSupported(agent.AgentVersion()) {
|
||||
escrowJSON(w, http.StatusConflict, nil, "A funkcióhoz az ügynök frissítése szükséges — a frissítés automatikusan megérkezik.")
|
||||
return
|
||||
}
|
||||
|
||||
// (4) Trigger.
|
||||
resp, status, err := agent.EscrowCeremonyStart(r.Context())
|
||||
if err != nil {
|
||||
if status == http.StatusConflict {
|
||||
escrowJSON(w, http.StatusConflict, nil, "Egy kódkészítés már folyamatban van — várja meg, míg befejeződik.")
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[WARN] [web] escrow start: agent trigger: %v", err)
|
||||
escrowJSON(w, http.StatusBadGateway, nil, "A folyamat indítása nem sikerült — az ügynök nem válaszol.")
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] [web] escrow ceremony started via wizard (job %s)", resp.JobID)
|
||||
escrowJSON(w, http.StatusOK, map[string]any{"job_id": resp.JobID, "phase": resp.Phase}, "")
|
||||
}
|
||||
|
||||
// escrowStatusAPIHandler proxies the NON-SECRET job status for the wizard's 2 s poll.
|
||||
func (s *Server) escrowStatusAPIHandler(w http.ResponseWriter, r *http.Request) {
|
||||
agent, err := s.escrowAgentConn()
|
||||
if err != nil {
|
||||
escrowJSON(w, http.StatusBadGateway, nil, "Az ügynök nem elérhető.")
|
||||
return
|
||||
}
|
||||
st, err := agent.EscrowCeremonyStatus(r.Context())
|
||||
if err != nil {
|
||||
escrowJSON(w, http.StatusBadGateway, nil, "Az állapot lekérdezése nem sikerült.")
|
||||
return
|
||||
}
|
||||
escrowJSON(w, http.StatusOK, map[string]any{
|
||||
"phase": st.Phase, "job_id": st.JobID,
|
||||
"key_fingerprint": st.KeyFingerprint, "entropy_bits": st.EntropyBits,
|
||||
"restic_pw_sealed": st.ResticPwSealed, "uploaded": st.Uploaded,
|
||||
"claimable": st.Claimable, "claimed": st.Claimed,
|
||||
"claim_expires_in_sec": st.ClaimExpiresInSec, "detail": st.Detail,
|
||||
}, "")
|
||||
}
|
||||
|
||||
// escrowClaimAPIHandler proxies the ONE-SHOT claim. no-store on the response; the body is never
|
||||
// logged; R goes to the wizard's XHR and nowhere else. 410 relays the agent's void verdict.
|
||||
func (s *Server) escrowClaimAPIHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
agent, err := s.escrowAgentConn()
|
||||
if err != nil {
|
||||
escrowJSON(w, http.StatusBadGateway, nil, "Az ügynök nem elérhető.")
|
||||
return
|
||||
}
|
||||
code, status, err := agent.EscrowCeremonyClaim(r.Context())
|
||||
if err != nil {
|
||||
if status == http.StatusGone {
|
||||
escrowJSON(w, http.StatusGone, nil, "A kód létrejött, de nem lett megjelenítve — biztonsági okból újra nem kérhető le. Indítsa újra a folyamatot: az új kód a régit érvényteleníti.")
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[WARN] [web] escrow claim failed (status %d)", status) // reason text may echo agent detail; the code itself is never in errors
|
||||
escrowJSON(w, http.StatusBadGateway, nil, "A kód lekérése nem sikerült.")
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] [web] escrow recovery code claimed (one-shot; not logged)")
|
||||
escrowJSON(w, http.StatusOK, map[string]any{"recovery_code": code}, "")
|
||||
code = "" // drop the reference promptly (GC caveat: best-effort)
|
||||
_ = code
|
||||
}
|
||||
|
||||
// escrowRateLimited / recordEscrowAuthFailure / clearAuthFailures ride the SAME per-IP counter as
|
||||
// the login form (loginAttempts, loginMaxAttempts, loginWindowDuration) — a wrong wizard password
|
||||
// is a wrong password, wherever it was typed.
|
||||
func (s *Server) escrowRateLimited(ip string) bool {
|
||||
s.loginAttemptMu.Lock()
|
||||
defer s.loginAttemptMu.Unlock()
|
||||
attempt := s.loginAttempts[ip]
|
||||
if attempt != nil && time.Since(attempt.lastFail) > loginWindowDuration {
|
||||
delete(s.loginAttempts, ip)
|
||||
return false
|
||||
}
|
||||
return attempt != nil && attempt.count >= loginMaxAttempts
|
||||
}
|
||||
|
||||
func (s *Server) recordEscrowAuthFailure(ip string) {
|
||||
s.loginAttemptMu.Lock()
|
||||
defer s.loginAttemptMu.Unlock()
|
||||
if s.loginAttempts == nil {
|
||||
s.loginAttempts = map[string]*loginAttempt{}
|
||||
}
|
||||
if s.loginAttempts[ip] == nil {
|
||||
s.loginAttempts[ip] = &loginAttempt{}
|
||||
}
|
||||
s.loginAttempts[ip].count++
|
||||
s.loginAttempts[ip].lastFail = time.Now()
|
||||
}
|
||||
|
||||
func (s *Server) clearAuthFailures(ip string) {
|
||||
s.loginAttemptMu.Lock()
|
||||
defer s.loginAttemptMu.Unlock()
|
||||
delete(s.loginAttempts, ip)
|
||||
}
|
||||
Reference in New Issue
Block a user