Files
felhom-controller/controller/internal/web/escrow_handlers.go
T
admin 120332103a feat(escrow): v0.138.0 — "awaiting hub confirmation" waiting state
After a completed escrow ceremony the Távoli mentés page showed the yellow
"Helyreállítási kód szükséges" card for ~15 min until the next hub-report ACK
flipped pending→escrowed. Phase-0 diagnosis (read-only) = verdict A (report-cycle
lag), already resolved on the demo box (escrow_state:"escrowed"); hub Hypothesis B
verified false (SaveHostEscrow ON CONFLICT already clears stale_at on upload) → no
hub change.

- settings.OffboxTarget.CeremonyCompletedAt: stamped on the recovery-code claim,
  zeroed on the auto-confirmer Flip + the deprecated manual confirm; persisted.
- web/handlers.go: offboxCeremonyWaitState + escrowCeremonyGraceWindow (35m).
- backups_remote.html: info "megerősítésre vár, legfeljebb 15 perc" card → warn
  "a megerősítés nem érkezett meg" past the window. Existing branches untouched.
- backups_escrow.html: "Mi történik ezután?" note on the wizard's final step.
- Test web/escrow_wait_state_test.go (truth table + red-proof recorded in REPORT).

No scheduler/agent/hub/endpoint changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qDiBqKKQ5vPB5fXBqu7Kp
2026-07-16 18:44:13 +02:00

324 lines
14 KiB
Go

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