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:
2026-07-13 19:01:31 +02:00
parent 51c871ad9b
commit 08a966b92f
13 changed files with 1419 additions and 16 deletions
+302
View File
@@ -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)
}
@@ -0,0 +1,415 @@
package web
import (
"context"
"fmt"
"io"
"log"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"golang.org/x/crypto/bcrypt"
)
// Escrow wizard handler tests (v0.127.0). The agent + staging seams record CALL ORDER — the
// load-bearing Scenario A/B assertion is "re-stage happened BEFORE the agent trigger", and every
// Scenario E gate must exit with the agent NEVER called.
const wizardPassword = "titkos-jelszo"
type fakeEscrowAgent struct {
order *[]string // shared call-order log (harness-owned)
version string
startResp agentapi.EscrowCeremonyStartResponse
startStatus int
startErr error
status agentapi.EscrowCeremonyStatusResponse
claimCode string
claimStatus int
claimErr error
pf agentapi.EscrowPreflightResponse
}
func (f *fakeEscrowAgent) EscrowPreflight(context.Context) (agentapi.EscrowPreflightResponse, error) {
*f.order = append(*f.order, "preflight")
return f.pf, nil
}
func (f *fakeEscrowAgent) EscrowCeremonyStart(context.Context) (agentapi.EscrowCeremonyStartResponse, int, error) {
*f.order = append(*f.order, "start")
return f.startResp, f.startStatus, f.startErr
}
func (f *fakeEscrowAgent) EscrowCeremonyStatus(context.Context) (agentapi.EscrowCeremonyStatusResponse, error) {
return f.status, nil
}
func (f *fakeEscrowAgent) EscrowCeremonyClaim(context.Context) (string, int, error) {
*f.order = append(*f.order, "claim")
return f.claimCode, f.claimStatus, f.claimErr
}
func (f *fakeEscrowAgent) AgentVersion() string { return f.version }
type escrowWizardHarness struct {
s *Server
sett *settings.Settings
m *backup.Manager
agent *fakeEscrowAgent
order []string
}
func newEscrowWizardHarness(t *testing.T) *escrowWizardHarness {
t.Helper()
tmp := t.TempDir()
lg := log.New(io.Discard, "", 0)
sett, err := settings.Load(filepath.Join(tmp, "settings.json"), lg)
if err != nil {
t.Fatal(err)
}
cfg := &config.Config{}
cfg.Paths.DataDir = tmp
hash, err := bcrypt.GenerateFromPassword([]byte(wizardPassword), bcrypt.MinCost)
if err != nil {
t.Fatal(err)
}
cfg.Web.PasswordHash = string(hash)
h := &escrowWizardHarness{
sett: sett,
m: backup.NewManager(cfg, sett, lg),
}
h.agent = &fakeEscrowAgent{
order: &h.order,
version: "0.88.0",
startResp: agentapi.EscrowCeremonyStartResponse{JobID: "escrow-1", Phase: "running"},
startStatus: http.StatusAccepted,
}
h.s = &Server{cfg: cfg, backupMgr: h.m, settings: sett, logger: lg}
h.s.escrowAgentFn = func() (escrowAgent, error) { return h.agent, nil }
h.s.escrowStageFn = func(context.Context) error { h.order = append(h.order, "stage"); return nil }
return h
}
// configureOffbox makes OffboxConfigured() true with the given escrow state.
func (h *escrowWizardHarness) configureOffbox(t *testing.T, state string) {
t.Helper()
if err := h.m.WriteOffboxSecrets("KEYMATERIAL", "nas.local ssh-ed25519 HOSTKEY"); err != nil {
t.Fatal(err)
}
if err := h.sett.SetOffboxTarget(&settings.OffboxTarget{
Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo",
Schedule: "daily", EscrowState: state,
}); err != nil {
t.Fatal(err)
}
if !h.m.OffboxConfigured() {
t.Fatal("setup: offbox should be configured")
}
}
func postStart(t *testing.T, s *Server, password string) *httptest.ResponseRecorder {
t.Helper()
form := url.Values{"password": {password}}
r := httptest.NewRequest("POST", "/api/escrow/start", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
s.escrowStartAPIHandler(w, r)
return w
}
// Scenario A — pending offsite happy path: correct password → re-stage FIRST, then the agent
// trigger; 200 with the job id.
func TestEscrowStart_StagesBeforeTrigger(t *testing.T) {
h := newEscrowWizardHarness(t)
h.configureOffbox(t, "pending")
w := postStart(t, h.s, wizardPassword)
if w.Code != http.StatusOK {
t.Fatalf("start: got %d (%s)", w.Code, w.Body.String())
}
if got := strings.Join(h.order, ","); got != "stage,start" {
t.Fatalf("call order = %q, want stage BEFORE start (a ceremony without the staged secret mints a hash-less blob)", got)
}
if !strings.Contains(w.Body.String(), "escrow-1") {
t.Fatalf("response lacks the job id: %s", w.Body.String())
}
}
// Scenario B — re-ceremony from ESCROWED state stages too (the same order assertion).
func TestEscrowStart_ReceremonyStagesToo(t *testing.T) {
h := newEscrowWizardHarness(t)
h.configureOffbox(t, "escrowed")
if w := postStart(t, h.s, wizardPassword); w.Code != http.StatusOK {
t.Fatalf("re-ceremony start: got %d", w.Code)
}
if got := strings.Join(h.order, ","); got != "stage,start" {
t.Fatalf("re-ceremony call order = %q, want stage,start", got)
}
}
// Scenario C — no offbox configured: NO staging attempted; the ceremony still runs.
func TestEscrowStart_NoOffboxSkipsStaging(t *testing.T) {
h := newEscrowWizardHarness(t)
if w := postStart(t, h.s, wizardPassword); w.Code != http.StatusOK {
t.Fatalf("start: got %d", w.Code)
}
if got := strings.Join(h.order, ","); got != "start" {
t.Fatalf("call order = %q, want start only (no staging without offbox)", got)
}
}
// Scenario E — every gate refuses BEFORE the agent (and staging) is touched.
func TestEscrowStart_SecurityGates(t *testing.T) {
t.Run("wrong password", func(t *testing.T) {
h := newEscrowWizardHarness(t)
h.configureOffbox(t, "pending")
w := postStart(t, h.s, "rossz-jelszo")
if w.Code != http.StatusUnauthorized {
t.Fatalf("wrong password: got %d, want 401", w.Code)
}
if len(h.order) != 0 {
t.Fatalf("agent/staging touched despite failed re-auth: %v", h.order)
}
// The failure rides the LOGIN rate limiter.
h.s.loginAttemptMu.Lock()
var count int
for _, a := range h.s.loginAttempts {
count += a.count
}
h.s.loginAttemptMu.Unlock()
if count != 1 {
t.Fatalf("wrong password must increment the login rate-limit counter, got %d", count)
}
})
t.Run("rate limited after max attempts", func(t *testing.T) {
h := newEscrowWizardHarness(t)
for i := 0; i < loginMaxAttempts; i++ {
postStart(t, h.s, "rossz-jelszo")
}
w := postStart(t, h.s, wizardPassword) // even the CORRECT password is refused inside the window
if w.Code != http.StatusTooManyRequests {
t.Fatalf("rate limit: got %d, want 429", w.Code)
}
if len(h.order) != 0 {
t.Fatalf("agent touched while rate-limited: %v", h.order)
}
})
t.Run("passwordless box refused", func(t *testing.T) {
h := newEscrowWizardHarness(t)
h.s.cfg.Web.PasswordHash = ""
w := postStart(t, h.s, "")
if w.Code != http.StatusForbidden {
t.Fatalf("passwordless: got %d, want 403", w.Code)
}
if len(h.order) != 0 {
t.Fatal("agent touched on a passwordless box")
}
})
t.Run("stage failure aborts before trigger", func(t *testing.T) {
h := newEscrowWizardHarness(t)
h.configureOffbox(t, "pending")
h.s.escrowStageFn = func(context.Context) error { return fmt.Errorf("agent down") }
w := postStart(t, h.s, wizardPassword)
if w.Code != http.StatusBadGateway {
t.Fatalf("stage failure: got %d, want 502", w.Code)
}
for _, c := range h.order {
if c == "start" {
t.Fatal("the ceremony started despite the failed staging (would mint a hash-less blob)")
}
}
})
t.Run("agent too old", func(t *testing.T) {
h := newEscrowWizardHarness(t)
h.agent.version = "0.87.0"
w := postStart(t, h.s, wizardPassword)
if w.Code != http.StatusConflict {
t.Fatalf("old agent: got %d, want 409", w.Code)
}
for _, c := range h.order {
if c == "start" {
t.Fatal("an old agent must not be triggered")
}
}
})
t.Run("header-less agent counts as older", func(t *testing.T) {
h := newEscrowWizardHarness(t)
h.agent.version = ""
if w := postStart(t, h.s, wizardPassword); w.Code != http.StatusConflict {
t.Fatalf("version-less agent: got %d, want 409", w.Code)
}
})
t.Run("ceremony already running", func(t *testing.T) {
h := newEscrowWizardHarness(t)
h.agent.startStatus = http.StatusConflict
h.agent.startErr = fmt.Errorf("agentapi: POST /escrow/ceremony: HTTP 409: already running")
w := postStart(t, h.s, wizardPassword)
if w.Code != http.StatusConflict {
t.Fatalf("busy agent: got %d, want 409", w.Code)
}
if !strings.Contains(w.Body.String(), "folyamatban") {
t.Fatalf("busy refusal must speak Hungarian: %s", w.Body.String())
}
})
}
// Scenario D (controller half) — the claim proxy: no-store on 200, the 410 void message, and the
// code appears ONLY in the claim response.
func TestEscrowClaim_ProxySemantics(t *testing.T) {
const code = "proba-kod-tiz-szo"
t.Run("success", func(t *testing.T) {
h := newEscrowWizardHarness(t)
h.agent.claimCode = code
h.agent.claimStatus = http.StatusOK
w := httptest.NewRecorder()
h.s.escrowClaimAPIHandler(w, httptest.NewRequest("POST", "/api/escrow/claim", nil))
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), code) {
t.Fatalf("claim: got %d %s", w.Code, w.Body.String())
}
if cc := w.Header().Get("Cache-Control"); cc != "no-store" {
t.Fatalf("claim Cache-Control = %q, want no-store", cc)
}
})
t.Run("gone", func(t *testing.T) {
h := newEscrowWizardHarness(t)
h.agent.claimStatus = http.StatusGone
h.agent.claimErr = fmt.Errorf("gone")
w := httptest.NewRecorder()
h.s.escrowClaimAPIHandler(w, httptest.NewRequest("POST", "/api/escrow/claim", nil))
if w.Code != http.StatusGone || !strings.Contains(w.Body.String(), "újra nem kérhető le") {
t.Fatalf("gone: got %d %s", w.Code, w.Body.String())
}
})
}
// The status proxy relays the agent's non-secret job view verbatim.
func TestEscrowStatus_Proxy(t *testing.T) {
h := newEscrowWizardHarness(t)
h.agent.status = agentapi.EscrowCeremonyStatusResponse{
Phase: "done", JobID: "escrow-1", ResticPwSealed: true, Uploaded: true,
Claimable: true, ClaimExpiresInSec: 599,
}
w := httptest.NewRecorder()
h.s.escrowStatusAPIHandler(w, httptest.NewRequest("GET", "/api/escrow/status", nil))
body := w.Body.String()
for _, want := range []string{`"phase":"done"`, `"claimable":true`, `"restic_pw_sealed":true`} {
if !strings.Contains(body, want) {
t.Fatalf("status proxy missing %s: %s", want, body)
}
}
}
// The wizard page + card render states (Hungarian copy, no server-side R anywhere by
// construction — the template has no code variable to leak).
func TestEscrowTemplates_Render(t *testing.T) {
base := func() map[string]interface{} {
return map[string]interface{}{
"Title": "Helyreállítási kód", "Domain": "example.hu",
"AgentSupported": true, "Receremony": false, "OffboxConfigured": true,
}
}
t.Run("wizard fresh", func(t *testing.T) {
html := renderBackupPage(t, "backups_escrow", base())
for _, want := range []string{
"Előfeltételek ellenőrzése",
"a mentései utolsó kulcsa",
"A folytatáshoz adja meg a bejelentkezési jelszavát",
"Kód létrehozása",
"Kód megjelenítése",
"Ez a kód többé nem jeleníthető meg.",
"nem ezen a szerveren",
"biztonsági okból újra nem kérhető le",
} {
if !strings.Contains(html, want) {
t.Errorf("wizard missing %q", want)
}
}
if strings.Contains(html, "érvényét veszti") {
t.Error("fresh wizard must not show the re-ceremony supersede warning")
}
})
t.Run("wizard re-ceremony variant", func(t *testing.T) {
d := base()
d["Receremony"] = true
if html := renderBackupPage(t, "backups_escrow", d); !strings.Contains(html, "érvényét veszti") {
t.Error("re-ceremony wizard must show the supersede warning")
}
})
t.Run("wizard agent too old", func(t *testing.T) {
d := base()
d["AgentSupported"] = false
html := renderBackupPage(t, "backups_escrow", d)
if !strings.Contains(html, "az ügynök frissítése szükséges") {
t.Error("old-agent wizard must show the version note")
}
if strings.Contains(html, "Kód létrehozása") {
t.Error("old-agent wizard must be inert (no start form)")
}
})
t.Run("remote card states", func(t *testing.T) {
data := splitTestData()
data["EscrowAgentOK"] = true
data["EscrowStale"] = false
html := renderBackupPage(t, "backups_remote", data) // escrowed clean
if !strings.Contains(html, "Új helyreállítási kód készítése") {
t.Error("escrowed card must offer the secondary re-ceremony link")
}
if strings.Contains(html, "Letét megerősítése") {
t.Error("the deprecated manual-confirm button must be GONE from the card")
}
data["EscrowStale"] = true
html = renderBackupPage(t, "backups_remote", data)
if !strings.Contains(html, "nem fedi a jelenlegi távoli mentési jelszót") {
t.Error("stale card must show the exact stale warning")
}
pending := splitTestData()
pending["EscrowAgentOK"] = true
pending["EscrowStale"] = false
pending["Offbox"].(*settings.OffboxTarget).EscrowState = "pending"
html = renderBackupPage(t, "backups_remote", pending)
if !strings.Contains(html, "Helyreállítási kód szükséges") || !strings.Contains(html, "Helyreállítási kód létrehozása") {
t.Error("pending card must show the CTA state")
}
old := splitTestData()
old["EscrowAgentOK"] = false
old["EscrowStale"] = false
old["Offbox"].(*settings.OffboxTarget).EscrowState = "pending"
html = renderBackupPage(t, "backups_remote", old)
if !strings.Contains(html, "az ügynök frissítése szükséges") || strings.Contains(html, `href="/backup/escrow"`) {
t.Error("old-agent card must show the version note with NO CTA")
}
})
}
// The preflight proxy augments the agent checklist with the version gate + offbox facts.
func TestEscrowPreflight_Augments(t *testing.T) {
h := newEscrowWizardHarness(t)
h.configureOffbox(t, "escrowed")
h.agent.pf = agentapi.EscrowPreflightResponse{OK: true, Items: []agentapi.EscrowPreflightItem{
{ID: "pbs_storage_id", OK: true, Detail: "felhom-pbs"},
}}
w := httptest.NewRecorder()
h.s.escrowPreflightAPIHandler(w, httptest.NewRequest("GET", "/api/escrow/preflight", nil))
body := w.Body.String()
for _, want := range []string{`"agent_supported":true`, `"escrow_state":"escrowed"`, `"offbox_configured":true`, `"pbs_storage_id"`} {
if !strings.Contains(body, want) {
t.Fatalf("preflight missing %s: %s", want, body)
}
}
// An old agent flips both the flag and the aggregate ok.
h.agent.version = "0.87.0"
w2 := httptest.NewRecorder()
h.s.escrowPreflightAPIHandler(w2, httptest.NewRequest("GET", "/api/escrow/preflight", nil))
if !strings.Contains(w2.Body.String(), `"agent_supported":false`) || !strings.Contains(w2.Body.String(), `"ok":false`) {
t.Fatalf("old agent must flip agent_supported + ok: %s", w2.Body.String())
}
}
+7
View File
@@ -710,6 +710,13 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) {
func (s *Server) backupsRemoteHandler(w http.ResponseWriter, r *http.Request) {
data := s.backupsCommonData("backups-remote", "Biztonsági mentés — Távoli mentés", r)
s.backupsOffboxData(data)
// Escrow ceremony card states (v0.127.0): the Scenario-F stale flag + the agent version gate.
data["EscrowStale"] = s.escrowStale()
agentVer := ""
if agent, err := s.escrowAgentConn(); err == nil {
agentVer = agent.AgentVersion()
}
data["EscrowAgentOK"] = escrowAgentSupported(agentVer)
s.executeTemplate(w, r, "backups_remote", data)
}
+11
View File
@@ -75,6 +75,14 @@ type Server struct {
// escrowed (DELETE /escrow/stage-secret). nil → the default agentClient()-backed impl; tests inject.
wipeStagedEscrowFn func(ctx context.Context) error
// Controller-driven escrow ceremony (v0.127.0) seams. escrowAgentFn nil → the shared
// agentClient(); escrowStageFn nil → PushOffboxPasswordForEscrow over the client;
// escrowStaleFn is the Scenario-F stale-blob flag (report.EscrowAutoConfirmer.StaleBlob,
// wired via SetEscrowStale; nil → never stale).
escrowAgentFn func() (escrowAgent, error)
escrowStageFn func(ctx context.Context) error
escrowStaleFn func() bool
// NAS add orchestration (verify-before-commit): the single-flight job slot + the two seams.
// netAgentFn nil → the shared agentClient(); netProbeFn nil → runNetProbe (the uid-1000 re-exec).
netAdd netAddState
@@ -360,6 +368,9 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.offboxRunHandler(w, r)
case path == "/backup/offbox/restore" && r.Method == http.MethodPost:
s.offboxRestoreHandler(w, r)
// Controller-driven escrow ceremony wizard (v0.127.0): the customer-facing R flow.
case path == "/backup/escrow" && r.Method == http.MethodGet:
s.escrowWizardPageHandler(w, r)
// fork-4: escrow atomicity — confirm the R-escrow ceremony; DR pre-place the recovered password.
case path == "/backup/offbox/confirm-escrow" && r.Method == http.MethodPost:
s.offboxConfirmEscrowHandler(w, r)
@@ -0,0 +1,238 @@
{{define "backups_escrow"}}
{{template "layout_start" .}}
<!-- Controller-driven escrow ceremony wizard (v0.127.0). R (the recovery code) arrives ONLY via
the claim XHR and lives ONLY in this page's JS scope — it is never templated server-side,
never sent anywhere else, and the page + claim response are Cache-Control: no-store. -->
<div class="page-header">
<div style="display:flex;align-items:center;gap:.5rem">
<a href="/backups/remote" class="btn btn-sm btn-outline">← Vissza</a>
<h2>Helyreállítási kód</h2>
</div>
<span class="domain-badge">{{.Domain}}</span>
</div>
{{if not .AgentSupported}}
<div class="alert alert-info">A funkcióhoz az ügynök frissítése szükséges — a frissítés automatikusan megérkezik. Próbálja újra később.</div>
{{else}}
<!-- 1. Preflight -->
<div class="settings-card" id="pf-card">
<h3>1. Előfeltételek ellenőrzése</h3>
<div id="pf-error" class="alert alert-error" style="display:none;margin-bottom:1rem"></div>
<div id="pf-list"><p class="form-hint">Ellenőrzés folyamatban…</p></div>
</div>
<!-- 2. Warning + 3. Re-auth (revealed when preflight is green) -->
<div class="settings-card" id="warn-card" style="display:none">
<h3>2. Fontos tudnivalók</h3>
<div class="alert alert-warning" style="margin-bottom:.75rem">
A helyreállítási kód a mentései utolsó kulcsa. Pontosan egyszer jelenik meg — a rendszer
sehol nem tárolja, és a Felhom sem ismeri. Ha a szerver megsemmisül, a távoli mentések CSAK
ezzel a kóddal állíthatók vissza. Írja fel papírra vagy mentse jelszókezelőbe — de NE ezen a
szerveren tárolja.
</div>
{{if .Receremony}}
<div class="alert alert-info" style="margin-bottom:.75rem">
Az új kód létrehozásával a korábbi kód a jövőbeli mentésekre érvényét veszti; a már meglévő
mentési előzményekhez érvényes marad.
</div>
{{end}}
<h3 style="margin-top:1.25rem">3. Megerősítés</h3>
<form id="start-form" onsubmit="return startCeremony(event)">
<div class="form-group">
<label for="reauth-password">A folytatáshoz adja meg a bejelentkezési jelszavát</label>
<input type="password" id="reauth-password" class="form-control" autocomplete="current-password" required style="max-width:280px">
</div>
<div id="start-error" class="alert alert-error" style="display:none;margin-bottom:.75rem"></div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" id="start-btn">Kód létrehozása</button>
<a href="/backups/remote" class="btn btn-outline">Mégsem</a>
</div>
</form>
</div>
<!-- 4. Progress -->
<div class="settings-card" id="progress-card" style="display:none">
<h3>4. Létrehozás</h3>
<p class="form-hint" id="progress-line">A kód létrehozása folyamatban… (általában néhány másodperc)</p>
<div id="progress-error" class="alert alert-error" style="display:none"></div>
</div>
<!-- 5. Reveal + 6. Typed-back + 7. Finish -->
<div class="settings-card" id="reveal-card" style="display:none">
<h3>5. A helyreállítási kód</h3>
<div id="reveal-gate">
<p class="form-hint">A kód pontosan egyszer jeleníthető meg. Készítsen elő papírt és tollat, mielőtt megnyomja a gombot.</p>
<button class="btn btn-primary" id="reveal-btn" onclick="claimCode()">Kód megjelenítése</button>
<div id="reveal-error" class="alert alert-error" style="display:none;margin-top:.75rem"></div>
</div>
<div id="reveal-shown" style="display:none">
<div class="alert alert-warning" style="margin-bottom:.5rem">Ez a kód többé nem jeleníthető meg.</div>
<pre class="mono" id="code-block" style="white-space:pre-wrap;word-break:break-all;background:var(--bg-0);border:1px solid var(--line);border-radius:var(--radius);padding:1rem;font-size:1.05rem"></pre>
<h3 style="margin-top:1.25rem">6. Ellenőrzés</h3>
<div class="form-group">
<label id="verify-label" for="verify-w1">Megerősítés: írja be a kód szavait</label>
<div style="display:flex;gap:.5rem">
<input type="text" id="verify-w1" class="form-control" autocomplete="off" style="max-width:180px">
<input type="text" id="verify-w2" class="form-control" autocomplete="off" style="max-width:180px">
</div>
<div id="verify-error" class="alert alert-error" style="display:none;margin-top:.5rem">A megadott szavak nem egyeznek — ellenőrizze a felírt kódot.</div>
</div>
<button class="btn btn-sm" id="verify-btn" onclick="verifyWords()">Szavak ellenőrzése</button>
<div id="finish-block" style="display:none;margin-top:1rem">
<h3>7. Befejezés</h3>
<label class="toggle" style="margin-bottom:.75rem">
<input type="checkbox" id="finish-check" onchange="document.getElementById('finish-btn').disabled=!this.checked">
<span class="toggle-label">Felírtam a kódot, és biztonságos helyen — nem ezen a szerveren — tárolom.</span>
</label>
<div><button class="btn btn-primary" id="finish-btn" disabled onclick="finishWizard()">Befejezés</button></div>
</div>
</div>
</div>
<!-- Void / unclaimed state -->
<div class="settings-card" id="void-card" style="display:none">
<div class="alert alert-warning">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.</div>
<a href="/backup/escrow" class="btn btn-sm btn-primary">Újraindítás</a>
</div>
<script>
function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c];}); }
// Hungarian labels for the agent preflight row ids.
var PF_LABELS = {
pbs_storage_id: 'Mentési tároló (PBS) beállítva',
dr_tier: 'Helyreállítási (DR) szint aktív',
age_binary: 'Titkosító eszköz telepítve',
hub_upload: 'Központi feltöltés beállítva',
staged_secret: 'Távoli mentés jelszava előkészítve',
sudo_grant: 'Rendszerjogosultság engedélyezve'
};
async function loadPreflight(){
try{
var r = await fetch('/api/escrow/preflight'); var j = await r.json();
if(!j.ok && !j.data){ throw new Error(j.error||'Hiba'); }
var d = j.data||{};
var html='<ul style="list-style:none;padding:0;margin:0">';
(d.items||[]).forEach(function(it){
// staged_secret is informational: the controller re-stages before every run when offsite is
// configured, so a red row there would be noise — render it neutral when not ok.
var informational = it.id==='staged_secret';
var mark = it.ok ? '<span style="color:var(--blue-bright)">&#10003;</span>'
: (informational ? '<span style="color:var(--text-3)">&#8226;</span>'
: '<span style="color:var(--crit)">&#10007;</span>');
html+='<li style="padding:.35rem 0;border-bottom:1px solid var(--line-soft)">'+mark+' '
+esc(PF_LABELS[it.id]||it.id)
+(it.detail?(' <span class="form-hint">— '+esc(it.detail)+'</span>'):'')+'</li>';
});
if(!d.agent_supported){
html+='<li style="padding:.35rem 0"><span style="color:var(--crit)">&#10007;</span> A funkcióhoz az ügynök frissítése szükséges — a frissítés automatikusan megérkezik.</li>';
}
html+='</ul>';
document.getElementById('pf-list').innerHTML=html;
if(d.ok){
document.getElementById('warn-card').style.display='block';
} else {
document.getElementById('pf-list').insertAdjacentHTML('beforeend',
'<p class="form-hint" style="margin-top:.75rem">A folyamat a piros feltételek teljesüléséig nem indítható.</p>');
}
}catch(e){
var el=document.getElementById('pf-error'); el.style.display='block';
el.textContent='Az előfeltételek ellenőrzése nem sikerült: '+e.message;
}
}
async function startCeremony(ev){
ev.preventDefault();
var btn=document.getElementById('start-btn'); btn.disabled=true;
var errEl=document.getElementById('start-error'); errEl.style.display='none';
try{
var body=new URLSearchParams({password:document.getElementById('reauth-password').value});
var r=await fetch('/api/escrow/start',{method:'POST',
headers:Object.assign({'Content-Type':'application/x-www-form-urlencoded'},csrfHeaders()),body:body});
var j=await r.json();
if(!j.ok){ errEl.textContent=j.error||'A folyamat indítása nem sikerült.'; errEl.style.display='block'; btn.disabled=false; return false; }
document.getElementById('reauth-password').value='';
document.getElementById('warn-card').style.display='none';
document.getElementById('progress-card').style.display='block';
pollStatus();
}catch(e){ errEl.textContent='Hiba: '+e.message; errEl.style.display='block'; btn.disabled=false; }
return false;
}
var pollTimer=null;
function pollStatus(){
pollTimer=setInterval(async function(){
try{
var r=await fetch('/api/escrow/status'); var j=await r.json();
var d=(j&&j.data)||{};
if(d.phase==='done'){
clearInterval(pollTimer);
document.getElementById('progress-card').style.display='none';
if(d.claimable){ document.getElementById('reveal-card').style.display='block'; }
else { document.getElementById('void-card').style.display='block'; }
} else if(d.phase==='failed'){
clearInterval(pollTimer);
var el=document.getElementById('progress-error'); el.style.display='block';
el.textContent='A létrehozás nem sikerült.'+(d.detail?(' Részletek: '+d.detail):'');
} else if(d.phase==='unclaimed_void' || d.phase==='none'){
clearInterval(pollTimer);
document.getElementById('progress-card').style.display='none';
document.getElementById('void-card').style.display='block';
}
}catch(e){ /* transient poll error — keep polling */ }
}, 2000);
}
// The claimed code lives ONLY in this closure variable + the #code-block textContent, until
// finishWizard() clears both. It never leaves the page's JS scope.
var claimedR='';
var verifyIdx=[0,0];
async function claimCode(){
var btn=document.getElementById('reveal-btn'); btn.disabled=true;
var errEl=document.getElementById('reveal-error'); errEl.style.display='none';
try{
var r=await fetch('/api/escrow/claim',{method:'POST',headers:csrfHeaders()});
var j=await r.json();
if(r.status===410){ document.getElementById('reveal-card').style.display='none'; document.getElementById('void-card').style.display='block'; return; }
if(!j.ok || !j.data || !j.data.recovery_code){ errEl.textContent=j.error||'A kód lekérése nem sikerült.'; errEl.style.display='block'; btn.disabled=false; return; }
claimedR=j.data.recovery_code;
document.getElementById('code-block').textContent=claimedR; // textContent — never innerHTML
document.getElementById('reveal-gate').style.display='none';
document.getElementById('reveal-shown').style.display='block';
// Typed-back: two random distinct 1-based word positions of the dash-separated code.
var n=claimedR.split('-').length;
var a=1+Math.floor(Math.random()*n), b=1+Math.floor(Math.random()*n);
while(b===a){ b=1+Math.floor(Math.random()*n); }
verifyIdx=[Math.min(a,b),Math.max(a,b)];
document.getElementById('verify-label').textContent='Megerősítés: írja be a kód '+verifyIdx[0]+'. és '+verifyIdx[1]+'. szavát';
}catch(e){ errEl.textContent='Hiba: '+e.message; errEl.style.display='block'; btn.disabled=false; }
}
function verifyWords(){
var words=claimedR.split('-');
var w1=(document.getElementById('verify-w1').value||'').trim().toLowerCase();
var w2=(document.getElementById('verify-w2').value||'').trim().toLowerCase();
var ok = w1===words[verifyIdx[0]-1] && w2===words[verifyIdx[1]-1];
document.getElementById('verify-error').style.display=ok?'none':'block';
if(ok){ document.getElementById('finish-block').style.display='block'; }
}
function finishWizard(){
// Drop every copy of the code this page holds, then leave.
claimedR='';
document.getElementById('code-block').textContent='';
window.location='/backups/remote';
}
loadPreflight();
</script>
{{end}}
{{template "layout_end" .}}
{{end}}
@@ -53,13 +53,31 @@
{{/* Part E: display pick — a stale zero-toggle warning is replaced once the selection
changed (neutral color: the replacement is reassurance, not a deviation). */}}
{{if .OffboxWarningDisplay}}<p class="form-hint"{{if eq .OffboxWarningDisplay .Offbox.LastWarning}} style="color:var(--warn)"{{end}}>{{.OffboxWarningDisplay}}</p>{{end}}
{{/* Escrow ceremony card (v0.127.0): the customer-driveable wizard replaced the manual-confirm
button (that deprecated endpoint stays for legacy blobs; its button is gone). States:
pending → CTA; escrowed+stale → warning + re-ceremony CTA; escrowed clean → secondary
link; agent too old → the honest version note, no CTA. */}}
{{if and .OffboxConfigured (ne .Offbox.EscrowState "escrowed")}}
<div class="card" style="border-left:3px solid var(--warn);margin:.75rem 0;padding:.75rem 1rem">
<p class="form-hint" style="color:var(--warn);margin:0 0 .5rem">A távoli mentés a kulcs letétbe helyezésére vár — a mentés addig nem fut (így nem keletkezik visszaállíthatatlan másolat). Futtasd a letéti szertartást, majd erősítsd meg.</p>
<form method="POST" action="/backup/offbox/confirm-escrow" style="display:inline">{{.CSRFField}}
<button type="submit" class="btn btn-sm">Letét megerősítése</button>
</form>
<p style="margin:0 0 .35rem"><strong>Helyreállítási kód szükséges</strong></p>
<p class="form-hint" style="margin:0 0 .5rem">A távoli mentések csak akkor állíthatók vissza egy teljes meghibásodás után, ha létrehozza a helyreállítási kódot.</p>
{{if .EscrowAgentOK}}
<a href="/backup/escrow" class="btn btn-sm btn-primary">Helyreállítási kód létrehozása</a>
{{else}}
<p class="form-hint" style="margin:0">A funkcióhoz az ügynök frissítése szükséges — a frissítés automatikusan megérkezik.</p>
{{end}}
</div>
{{else if and .OffboxConfigured .EscrowStale}}
<div class="card" style="border-left:3px solid var(--warn);margin:.75rem 0;padding:.75rem 1rem">
<p class="form-hint" style="color:var(--warn);margin:0 0 .5rem">A letétben lévő helyreállítási csomag nem fedi a jelenlegi távoli mentési jelszót. Hozzon létre új helyreállítási kódot.</p>
{{if .EscrowAgentOK}}
<a href="/backup/escrow" class="btn btn-sm btn-primary">Új helyreállítási kód készítése</a>
{{else}}
<p class="form-hint" style="margin:0">A funkcióhoz az ügynök frissítése szükséges — a frissítés automatikusan megérkezik.</p>
{{end}}
</div>
{{else if .OffboxConfigured}}
<p class="form-hint" style="margin:.5rem 0">A helyreállítási kód letétbe helyezve.{{if .EscrowAgentOK}} <a href="/backup/escrow">Új helyreállítási kód készítése</a>{{end}}</p>
{{end}}
{{if .OffboxConfigured}}
<div class="schedule-actions" style="margin-top:1rem">