2487681396
gofmt -w across the controller tree (46 files) so gofmt -l is empty — disarms the
formatting landmine where a targeted edit + accidental gofmt -w swept ~46 unrelated
files. Pure formatting: whitespace + gofmt's optional-semicolon removal in reflowed
inline closures. One doc comment reworded ('' -> 'the empty string') to avoid gofmt's
Go-1.19 doc-comment typographic substitition ('' -> curly quote) muddying its meaning.
No build/vet/test behavior change.
328 lines
14 KiB
Go
328 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)
|
|
}
|
|
// 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()
|
|
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)
|
|
}
|