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:
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {'&':'&','<':'<','>':'>','"':'"'}[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)">✓</span>'
|
||||
: (informational ? '<span style="color:var(--text-3)">•</span>'
|
||||
: '<span style="color:var(--crit)">✗</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)">✗</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">
|
||||
|
||||
Reference in New Issue
Block a user