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 := clientIP(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 { switch status { case http.StatusGone: // 410 — the code was minted but never shown; permanently void. 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.") case http.StatusNotFound: // 404 — no ceremony has run (e.g. phase:none post-reboot). F-C: // this used to fall through to a 502; a bad-gateway class code for "nothing to claim" // is wrong. Relay a clean, honest 4xx. escrowJSON(w, http.StatusNotFound, nil, "Nincs aktív helyreállítási folyamat — előbb indítsa el a kódkészítést.") case http.StatusConflict: // 409 — the ceremony state doesn't allow a claim right now. F-C. escrowJSON(w, http.StatusConflict, nil, "A folyamat jelenlegi állapotában a kód nem kérhető le.") default: // status 0 (agent unreachable / transport error) or a genuine agent 5xx — a real // bad gateway; keep 502. 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) }