package web import ( "context" "encoding/json" "net/http" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/util" ) // 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) (agentapi.EscrowClaim, int, error) EscrowCeremonyConfirm(ctx context.Context, words []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) case r.URL.Path == "/api/escrow/confirm" && r.Method == http.MethodPost: s.escrowConfirmAPIHandler(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", "TitleKey": "page.title.escrow", // i18n: the Hungarian title above is what hu renders "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), } // R-546: a paused box whose agent says the ceremony cannot run yet gets the waiting card, not a red // checklist and a start form that would refuse. Only for the FIRST ceremony — an escrowed box's // re-ceremony keeps the checklist, where a red row is a real fault to show. if !escrowed { if ready, known := s.escrowReadiness(r.Context(), true); known && !ready { data["EscrowNotReady"] = true data["EscrowNotReadyMessage"] = escrowNotReadyMessage } } 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, s.msg(r, "api.escrow.dashboard_password_missing")) return } ip := rateKey(r) // R-1111: the try is reserved on the login counter BEFORE the slow compare (the R-983 shape); a right password // clears it below. Pinned by TestR1111_ParallelEscrowReauthsHitTheLimit. if _, ok := s.reserveLoginAttempt(ip); !ok { s.logger.Printf("[WARN] [web] escrow start rate limited for %s", ip) escrowJSON(w, http.StatusTooManyRequests, nil, s.msg(r, "api.escrow.too_many_attempts")) return } if err := s.comparePassword([]byte(hash), []byte(r.FormValue("password"))); err != nil { s.logger.Printf("[WARN] [web] escrow start: failed re-auth from %s", r.RemoteAddr) // The login form's own sentence (same Hungarian bytes as the literal it replaces), so an English box answers in // English. Pinned by TestEscrowReauth_WrongPasswordInTheBoxLanguage. escrowJSON(w, http.StatusUnauthorized, nil, s.msg(r, "login.msg.wrong_password")) return } s.clearAuthFailures(ip) // (1b) R-546: readiness. The page hides the start form while the agent's preflight is red, but a // direct POST (the path chaos night used) ran the ceremony and returned the agent's raw stderr. // Refuse here, BEFORE anything is staged or started. Unknown readiness does not refuse: the // agent-conn and version gates below answer that case, as before. if ready, known := s.escrowReadiness(r.Context(), true); known && !ready { s.logger.Printf("[INFO] [web] escrow start refused: the box is not ready yet (agent preflight not ok)") escrowJSON(w, http.StatusConflict, nil, escrowNotReadyMessage) return } // (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, s.msg(r, "api.escrow.stage_failed")) 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, s.msg(r, "api.escrow.already_running")) 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 } claim, status, err := agent.EscrowCeremonyClaim(r.Context()) code := claim.Code if err != nil { switch status { case http.StatusGone: // 410 — the code was minted but never shown; permanently void. escrowJSON(w, http.StatusGone, nil, s.msg(r, "api.escrow.code_not_shown")) 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, s.msg(r, "api.escrow.no_active_job")) 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 } if claim.ConfirmRequired { // R-1151: the agent keeps the new package until the household types the two words back (/api/escrow/confirm); // the ceremony is not complete yet, so nothing is stamped and no report is pushed here. s.logger.Printf("[INFO] [web] escrow recovery code claimed (one-shot; not logged) — its package waits for the typed-back words") escrowJSON(w, http.StatusOK, map[string]any{"recovery_code": code, "confirm_required": true, "verify_positions": claim.Positions}, "") code, claim = "", agentapi.EscrowClaim{} _, _ = code, claim return } s.logger.Printf("[INFO] [web] escrow recovery code claimed (one-shot; not logged)") s.escrowCompleted() escrowJSON(w, http.StatusOK, map[string]any{"recovery_code": code}, "") code = "" // drop the reference promptly (GC caveat: best-effort) _ = code } // escrowConfirmAPIHandler (R-1151) takes the two words the household typed back and hands them to the agent, which // stores the new package on the hub only when they match. Body: JSON {"words": [w1, w2]}. The words are part of the // code: never logged. On success the ceremony is complete (escrowCompleted). func (s *Server) escrowConfirmAPIHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") var req struct { Words []string `json:"words"` } if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil || len(req.Words) != 2 { escrowJSON(w, http.StatusBadRequest, nil, s.msg(r, "api.escrow.confirm_words_mismatch")) return } agent, err := s.escrowAgentConn() if err != nil { escrowJSON(w, http.StatusBadGateway, nil, "Az ügynök nem elérhető.") return } status, err := agent.EscrowCeremonyConfirm(r.Context(), req.Words) req.Words = nil if err != nil { switch status { case http.StatusUnprocessableEntity, http.StatusBadRequest: // the words do not match — the wait goes on escrowJSON(w, http.StatusUnprocessableEntity, nil, s.msg(r, "api.escrow.confirm_words_mismatch")) case http.StatusGone: escrowJSON(w, http.StatusGone, map[string]any{"restart": true}, s.msg(r, "api.escrow.confirm_expired")) case http.StatusNotFound: escrowJSON(w, http.StatusGone, map[string]any{"restart": true}, s.msg(r, "api.escrow.confirm_lost")) default: s.logger.Printf("[WARN] [web] escrow confirm failed (status %d)", status) escrowJSON(w, http.StatusBadGateway, nil, s.msg(r, "api.escrow.confirm_store_failed")) } return } s.logger.Printf("[INFO] [web] escrow recovery code typed back — the new package is on the hub (R-1151)") s.escrowCompleted() escrowJSON(w, http.StatusOK, map[string]any{"uploaded": true}, "") } // escrowCompleted runs when the new package is on the hub: at the claim for an agent older than 0.160.0, at the // typed-back confirm since (R-1151). func (s *Server) escrowCompleted() { // v0.138.0: stamp the ceremony-completed time so /backups/remote shows the "awaiting hub // confirmation" card during the report-cycle gap before the auto-confirmer flips to escrowed // (Phase-0 verdict A: the yellow "szükséges" card during that ~15-min wait was the real gap). // Only while pending — never re-stamp an already-escrowed target. Best-effort: a stamp failure // must not fail the claim (the code is already revealed and the blob already uploaded). if err := s.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) { if o.EscrowState != "escrowed" { o.CeremonyCompletedAt = time.Now().Format(time.RFC3339) } }); err != nil { s.logger.Printf("[WARN] [web] escrow claim: ceremony timestamp not persisted: %v", err) } // v0.139.0: the blob is already uploaded at claim time — an immediate report lets the // hub's ACK hash-match flip pending→escrowed in seconds (EscrowAutoConfirmer, unchanged) // instead of after the next ~15-min cycle. Non-blocking; a failed push degrades to the cycle. s.reportTriggerNow() } // clearAuthFailures clears the login counter after a right re-auth password — the counter reserveLoginAttempt counts // on, shared with the login form (a wrong wizard password is a wrong password, wherever it was typed). func (s *Server) clearAuthFailures(ip string) { s.loginAttemptMu.Lock() defer s.loginAttemptMu.Unlock() delete(s.loginAttempts, ip) }