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
+40
View File
@@ -1,5 +1,45 @@
## Changelog
### v0.127.0 — customer-facing escrow ceremony wizard + stale-blob re-check (2026-07-13) — MinAgent: 0.88.0 (wizard only; everything else unchanged)
The missing friend-alpha piece: the recovery-code ceremony moves from operator-SSH to a
customer-driveable wizard (`/backup/escrow`). R is displayed EXACTLY ONCE in the browser
(one-shot claim, typed-back confirm); operator ruling F1 2026-07-13 accepts the single CF-tunnel
transit (same trust class as the claim code — threat model in felhom.eu
RUNBOOK-escrow-ceremony.md). Mechanics validated by SPIKE-controller-escrow-2026-07-13.
- **Wizard** (`templates/backups_escrow.html` + `web/escrow_handlers.go`): preflight checklist →
warning copy (re-ceremony adds the supersede warning) → password re-auth (rides the LOGIN rate
limiter) → run (poll 2 s) → one-shot reveal ("Ez a kód többé nem jeleníthető meg.") →
typed-back (two random words, client-side only — R never leaves the page's JS scope; no
copy-to-clipboard by design) → finish. Void/expired → the honest "újra nem kérhető le" state.
Page + claim response `Cache-Control: no-store`; R is NEVER templated server-side, logged, or
persisted.
- **Start-handler order (load-bearing):** re-auth → **re-stage-first** (offbox configured →
`PushOffboxPasswordForEscrow`; failure ABORTS — a ceremony without the staged secret mints the
forbidden hash-less blob) → agent version gate (`AgentVersion()` ≥ 0.88.0, header absent =
older, fail-closed) → trigger. Every refusal exits with the agent untouched (seam-asserted).
- **agentapi** (`agentapi/escrow.go`): `EscrowPreflight` / `EscrowCeremonyStart` /
`EscrowCeremonyStatus` / `EscrowCeremonyClaim` (status-aware; 410 = void; claim body never
logged) over the existing envelope helpers.
- **Scenario F — stale-blob re-check** (`report/escrow_confirm.go`): `Reconcile` no longer
early-returns on non-pending; an ESCROWED box compares the ACK hash every cycle — mismatch OR
a present blob with an EMPTY hash (the spike's hash-less supersession) sets an in-memory stale
flag (surfaced on the Távoli mentés card: "A letétben lévő helyreállítási csomag nem fedi a
jelenlegi távoli mentési jelszót") + ONE warn per distinct hub hash (`warnedHash` reuse;
hash-less dedupes under a sentinel). State NEVER flips; runs NEVER block; a matching hash (or
a fresh auto-confirm) clears the flag. NOTE: the live demo's legacy hash-less blob will show
this warning honestly — the wizard is the fix.
- **Card rework** (`templates/backups_remote.html`): the deprecated manual-confirm BUTTON is gone
(the endpoint stays for legacy blobs); states: pending → "Helyreállítási kód szükséges" + CTA;
escrowed+stale → warning + "Új helyreállítási kód készítése"; escrowed clean → secondary link;
agent < 0.88.0 → "az ügynök frissítése szükséges" note, no CTA.
- Tests: call-order (stage BEFORE trigger, from pending AND escrowed), Scenario C no-stage,
security gates (wrong password 401 + rate-limit counter, 429 lockout, passwordless 403, stage
failure 502 pre-trigger, old agent 409, busy 409 — agent seam call-count 0 in each), claim
proxy no-store + 410, §8 stale truth table incl. dedupe + clear, template render states. §10
red-proofs demonstrated (felhom.eu REPORT).
### docs — controller.yaml.example: hub api_key literal scrubbed (2026-07-13)
The example carried the REAL hub global bearer key (the `manifests/hub.yaml` committed literal,
+1
View File
@@ -178,6 +178,7 @@
| `diskAgent` | controller/internal/web/storage_handlers.go | `*agentapi.Client` | `mockAgent` in controller/internal/web/storage_handlers_test.go |
| `netAgent` + `Server.netAgentFn/netProbeFn/netListFn` | controller/internal/web/netstorage_job.go (+ server.go fields) | `*agentapi.Client` / `runNetProbe` (linux re-exec) / `agent.ListNetStorage` | `fakeNetAgent` + fn injections in controller/internal/web/netstorage_job_test.go — the NAS add orchestration never shells/TLS-dials in tests |
| `Server.agentLogsFn` (func seam) | controller/internal/web/server.go | nil → `agentClient().DebugLogs` (agent GET /debug/logs) | injected in controller/internal/web/observability_test.go (incl. the pre-0.83 typed-404 notice path) |
| `escrowAgent` + `Server.escrowAgentFn/escrowStageFn/escrowStaleFn` | controller/internal/web/escrow_handlers.go (+ server.go fields) | `*agentapi.Client` / `PushOffboxPasswordForEscrow` / `report.EscrowAutoConfirmer.StaleBlob` (SetEscrowStale) | `fakeEscrowAgent` + fn injections in escrow_wizard_test.go — call-ORDER assertions (stage BEFORE trigger) + agent-never-called gates. The claim leg is the ONLY surface R crosses: no-store, never logged, never templated |
| `report.SetPendingControllerLog` / `SetControllerLogSource` | controller/internal/report/selftail.go | ACK-armed consume-once self-log pull (the logtail.go shape) | selftail_test.go; source = `logBuffer.Lines`, wired once in main.go |
| `util.ParseVersion` / `util.Version.Compare` | controller/internal/util/version.go | THE one semver comparator (house rule: never a second) — selfupdate aliases it; agentapi's MinAgent comparison uses it | rejects pre-release/dev/latest (callers fall back, never trust); numeric compare (0.100 > 0.81) |
| `agentapi.AgentVersionReporter` + `featureMinAgent` | controller/internal/agentapi/features.go | version-first Supports (v0.82.0 header channel); probe = fallback for header-less agents | a coupled feature adds BOTH a featureProbes row AND a featureMinAgent row; v0.116.0: `SupportsWithSource` also reports HOW the verdict was reached (version/probe-cache/probe) for the gate log line |
+19 -4
View File
@@ -795,10 +795,20 @@ not just those with HDD data. Non-HDD apps can configure destination, method, an
> real repo). **Atomicity gate:** enabling offsite pushes the password to the agent (`StageEscrowSecret` →
> `POST /escrow/stage-secret`) and marks `EscrowState="pending"`; **no offsite backup runs until the escrow
> is confirmed** (`OffboxRunnable()`), so an un-recoverable offsite copy can never exist.
> **Operator ceremony (pilot, supervised):** enable offsite (→ pending) → run
> `felhom-agent --selftest=escrow-create --identity-bundle <assembled> --upload` (the bundle now carries
> the staged restic password, auto-injected, then wiped) → hand the customer the fresh **R** (once;
> supersedes any prior code) → `POST /backup/offbox/confirm-escrow` (or the "Letét megerősítése" button).
> **Ceremony — PRIMARY path (v0.127.0, agent ≥ v0.88.0): the customer wizard** at `/backup/escrow`
> (`web/escrow_handlers.go` + `templates/backups_escrow.html`): preflight (agent
> `GET /escrow/preflight` + version gate) → warnings (re-ceremony adds the supersede copy) →
> password re-auth (login rate limiter) → **re-stage-first** (offbox configured →
> `PushOffboxPasswordForEscrow`; a staging failure ABORTS the start — no hash-less blob, ever) →
> agent job (`POST /escrow/ceremony`, poll `GET /escrow/ceremony/status` @2 s) → **one-shot R
> reveal** (`POST /escrow/ceremony/claim`, `Cache-Control: no-store`; R exists only in the page's
> JS scope; 10-min unclaimed TTL → void, re-run supersedes) → typed-back (two random words,
> client-side) → finish. R is never templated/logged/persisted on either side.
> **Operator fallback (CLI, unchanged text mode):** enable offsite (→ pending) → run
> `felhom-agent --selftest=escrow-create --upload` (the staged restic password auto-injected,
> then wiped) → hand the customer the fresh **R** (once; supersedes any prior code) → the
> auto-confirm flips escrowed hands-free. (The manual "Letét megerősítése" button is GONE from
> the card; the deprecated endpoint remains for legacy hash-less blobs.)
> **DR:** recover R → the escrow yields the password → `POST /backup/offbox/inject-password {password}`
> pre-places it 0600 → configure offbox → restore. The SFTP access key is **regenerated** at DR (a fresh
> sub-account key), NOT escrowed; the DR recipe carries only the non-secret `offsite_restic`
@@ -813,6 +823,11 @@ not just those with HDD data. Non-HDD apps can configure destination, method, an
> agent-staged secret. The canonical hasher (`backup.HashResticPassword`, trimmed-string sha256) is pinned
> by a cross-repo test vector against the agent's. The manual `POST /backup/offbox/confirm-escrow` is a
> **deprecated fallback** for legacy hash-less blobs (e.g. the demo's).
> **Stale-blob re-check (v0.127.0, Scenario F):** an ESCROWED box re-compares the ACK hash every
> cycle — mismatch OR a present blob with an EMPTY hash (a superseding ceremony that missed the
> staged secret) sets an in-memory stale flag (`EscrowAutoConfirmer.StaleBlob` → the Távoli
> mentés card's warning + re-ceremony CTA) + one warn per distinct hub hash. State never flips,
> runs never block; a covering blob (or a fresh auto-confirm) clears it.
> - **Injection guard** (`ValidateOffboxTarget`): host/user/repo must not start with `-` (ssh
> option-injection) or carry metacharacters/traversal; `OffboxConfigured` fails closed on an invalid
> target. Image: `restic` + `openssh-client` (re-added; restic's sftp backend shells out to `ssh`).
+17 -2
View File
@@ -435,16 +435,24 @@ func main() {
// --- Central hub pusher (declared early so backup closure can reference it) ---
var hubPusher *report.Pusher
// escrowConfirmer is hoisted so the web server (built later) can read its Scenario-F
// stale-blob flag (SetEscrowStale below). nil when no hub is configured.
var escrowConfirmer *report.EscrowAutoConfirmer
if cfg.Hub.URL != "" && cfg.Hub.APIKey != "" {
hubPusher = report.NewPusher(&cfg.Hub, logger, cfg.Logging.Level == "debug")
// SLICE 3 — hub-verified escrow auto-confirm (long-lived: the mismatch warn dedupes per hash,
// not per 15-min cycle). Flips offbox pending→escrowed ONLY when the hub-recorded hash of the
// escrowed password matches the local repo password's hash; never un-confirms.
escrowConfirmer := &report.EscrowAutoConfirmer{
// escrowed password matches the local repo password's hash; never un-confirms. v0.127.0 adds
// the escrowed-state STALE re-check (Scenario F — warn + card flag, never a state change).
escrowConfirmer = &report.EscrowAutoConfirmer{
Pending: func() bool {
return backupMgr != nil && backupMgr.OffboxConfigured() &&
sett.GetOffboxTarget() != nil && sett.GetOffboxTarget().EscrowState == "pending"
},
Escrowed: func() bool {
return backupMgr != nil && backupMgr.OffboxConfigured() &&
sett.GetOffboxTarget() != nil && sett.GetOffboxTarget().EscrowState == "escrowed"
},
LocalHash: func() (string, bool) {
if backupMgr == nil {
return "", false
@@ -855,6 +863,10 @@ func main() {
stackMgr.RecoverMigration(ctx)
webServer.SetEncryptionKey(encKey)
webServer.SetAppExporter(appExporter)
// Escrow wizard (v0.127.0): the Scenario-F stale-blob flag feeds the Távoli mentés card.
if escrowConfirmer != nil {
webServer.SetEscrowStale(escrowConfirmer.StaleBlob)
}
webServer.SetIntegrationManager(integrationMgr)
if quiesceLoop != nil {
webServer.SetBackupTrigger(quiesceLoop) // "Mentés most" → app-consistent backup via the quiesce loop
@@ -960,6 +972,9 @@ func main() {
mux.Handle("/api/export/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeExportAPI))))
// Debug API routes handled by web server (debug-mode gating inside handler)
mux.Handle("/api/debug/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeDebugAPI))))
// Escrow ceremony wizard API (v0.127.0) — session auth + CSRF on POSTs; the claim response is
// the ONLY surface the recovery code R ever crosses (no-store, never logged).
mux.Handle("/api/escrow/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeEscrowAPI))))
// Self-update API — accepts session auth OR hub API key (for external triggering)
// CsrfProtect exempts Bearer-token requests automatically.
mux.Handle("/api/selfupdate/", selfUpdateAuthMiddleware(cfg, webServer, webServer.CsrfProtect(http.HandlerFunc(apiRouter.ServeHTTP))))
+119
View File
@@ -0,0 +1,119 @@
package agentapi
import (
"context"
"encoding/json"
"fmt"
"net/http"
)
// Controller-driven escrow ceremony client methods (v0.127.0, agent ≥ v0.88.0). The claim call
// is the ONLY place the recovery code R crosses this client — its response body must never be
// logged (the shared helpers log path/status/duration only, never bodies) and the caller hands
// R straight to the wizard's claim XHR, nowhere else.
// EscrowPreflightItem mirrors one agent preflight checklist row.
type EscrowPreflightItem struct {
ID string `json:"id"`
OK bool `json:"ok"`
Detail string `json:"detail"`
}
// EscrowPreflightResponse mirrors GET /escrow/preflight.
type EscrowPreflightResponse struct {
VMID int `json:"vmid"`
OK bool `json:"ok"`
Items []EscrowPreflightItem `json:"items"`
}
// EscrowPreflight fetches the agent's ceremony prerequisite checklist.
func (c *Client) EscrowPreflight(ctx context.Context) (EscrowPreflightResponse, error) {
var out EscrowPreflightResponse
body, err := c.get(ctx, "/escrow/preflight")
if err != nil {
return out, err
}
if err := json.Unmarshal(body, &out); err != nil {
return out, fmt.Errorf("agentapi: decode /escrow/preflight: %w", err)
}
return out, nil
}
// EscrowCeremonyStartResponse mirrors the POST /escrow/ceremony 202 payload.
type EscrowCeremonyStartResponse struct {
JobID string `json:"job_id"`
Phase string `json:"phase"`
}
// EscrowCeremonyStart triggers the agent's detached root ceremony job. Status-aware: the HTTP
// status is returned so the caller can map the agent's 409 (a ceremony already running) to its
// own house-style refusal.
func (c *Client) EscrowCeremonyStart(ctx context.Context) (EscrowCeremonyStartResponse, int, error) {
var out EscrowCeremonyStartResponse
env, status, err := c.postWithStatus(ctx, "/escrow/ceremony", struct{}{})
if err != nil {
return out, status, err
}
if err := refusalError("/escrow/ceremony", status, env); err != nil {
return out, status, err
}
if err := json.Unmarshal(env.Data, &out); err != nil {
return out, status, fmt.Errorf("agentapi: decode /escrow/ceremony: %w", err)
}
return out, status, nil
}
// EscrowCeremonyStatusResponse mirrors GET /escrow/ceremony/status — the NON-SECRET job view
// (R is structurally absent from the agent's payload).
type EscrowCeremonyStatusResponse struct {
Phase string `json:"phase"` // none | running | done | failed | unclaimed_void
JobID string `json:"job_id"`
KeyFingerprint string `json:"key_fingerprint"`
EntropyBits float64 `json:"entropy_bits"`
ResticPwSealed bool `json:"restic_pw_sealed"`
Uploaded bool `json:"uploaded"`
Claimable bool `json:"claimable"`
Claimed bool `json:"claimed"`
ClaimExpiresInSec int `json:"claim_expires_in_sec"`
Detail string `json:"detail"`
}
// EscrowCeremonyStatus polls the ceremony job.
func (c *Client) EscrowCeremonyStatus(ctx context.Context) (EscrowCeremonyStatusResponse, error) {
var out EscrowCeremonyStatusResponse
body, err := c.get(ctx, "/escrow/ceremony/status")
if err != nil {
return out, err
}
if err := json.Unmarshal(body, &out); err != nil {
return out, fmt.Errorf("agentapi: decode /escrow/ceremony/status: %w", err)
}
return out, nil
}
// EscrowCeremonyClaim performs the ONE-SHOT R claim. Returns the recovery code + the agent's
// HTTP status (410 = already claimed / expired — the wizard's void state). The code must never
// be logged, persisted, or placed anywhere but the claim XHR response; the error path carries
// the agent's reason text, never the code.
func (c *Client) EscrowCeremonyClaim(ctx context.Context) (string, int, error) {
env, status, err := c.postWithStatus(ctx, "/escrow/ceremony/claim", struct{}{})
if err != nil {
return "", status, err
}
if status == http.StatusGone {
return "", status, fmt.Errorf("agentapi: POST /escrow/ceremony/claim: gone (claimed or expired)")
}
if err := refusalError("/escrow/ceremony/claim", status, env); err != nil {
return "", status, err
}
var out struct {
RecoveryCode string `json:"recovery_code"`
}
if err := json.Unmarshal(env.Data, &out); err != nil {
return "", status, fmt.Errorf("agentapi: decode /escrow/ceremony/claim: %w", err)
}
if out.RecoveryCode == "" {
return "", status, fmt.Errorf("agentapi: /escrow/ceremony/claim returned no code")
}
return out.RecoveryCode, status, nil
}
+79 -6
View File
@@ -25,9 +25,15 @@ type EscrowStatus struct {
// EscrowAutoConfirmer runs the auto-confirm check on each report ACK. Long-lived (one per process) so
// the mismatch warning dedupes per distinct hash instead of firing every 15-minute cycle.
type EscrowAutoConfirmer struct {
// Pending reports whether the offbox target is configured AND EscrowState=="pending" — the ONLY
// state this confirmer acts on. "escrowed" is never revisited (auto-UN-confirm does not exist).
// Pending reports whether the offbox target is configured AND EscrowState=="pending" — the
// confirm-flip state. "escrowed" is never flipped back (auto-UN-confirm does not exist), but
// since v0.127.0 it IS re-checked: see Escrowed + the stale-blob branch (Scenario F).
Pending func() bool
// Escrowed reports whether the offbox target is configured AND EscrowState=="escrowed" — the
// v0.127.0 stale-blob re-check state (Scenario F: a superseding ceremony that did NOT cover
// the current password — e.g. a CLI run without the staged secret — must be surfaced, not
// silently ignored; the spike left the drill box in exactly that state). nil → no re-check.
Escrowed func() bool
// LocalHash returns the canonical hash of the local repo password (ok=false → no password file).
LocalHash func() (hash string, ok bool)
// Flip transitions EscrowState pending→escrowed (settings.UpdateOffboxStatus).
@@ -37,7 +43,23 @@ type EscrowAutoConfirmer struct {
Logger *log.Logger
mu sync.Mutex
warnedHash string // last mismatched hub hash we warned about (dedupe)
warnedHash string // last mismatched hub hash we warned about (dedupe; shared by both branches)
stale bool // Scenario F: the hub blob does not cover the CURRENT password (display-only)
}
// staleHashlessMarker is the warnedHash dedupe sentinel for the hash-less supersession case
// (the hub hash is EMPTY there, which must still warn exactly once, and must not collide with
// the zero value of warnedHash).
const staleHashlessMarker = "(hashless)"
// StaleBlob reports the Scenario-F display flag: EscrowState is escrowed but the hub's CURRENT
// blob does not cover the current repo password. In-memory only (recomputed from ACKs after a
// restart); NEVER blocks runs and NEVER flips state — the web card renders the warning + the
// re-ceremony CTA from it.
func (c *EscrowAutoConfirmer) StaleBlob() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.stale
}
func (c *EscrowAutoConfirmer) logf(f string, a ...any) {
@@ -48,9 +70,19 @@ func (c *EscrowAutoConfirmer) logf(f string, a ...any) {
// Reconcile applies one ACK's escrow status. Scenarios: match → flip+wipe (A); mismatch → stay pending
// + warn once per hash (B); no status / no hash / no local file → stay pending silently (C, normal
// onboarding); not pending → no-op (Ealready escrowed or offbox not configured).
// onboarding); escrowed → the v0.127.0 stale-blob re-check (Fwarn-only, never a state change);
// otherwise → no-op (E — offbox not configured).
func (c *EscrowAutoConfirmer) Reconcile(es *EscrowStatus) {
if es == nil || !c.Pending() {
if es == nil {
return
}
if !c.Pending() {
// Scenario F (v0.127.0): an ESCROWED box re-checks the hash on every ACK — a superseding
// blob that does not cover the current password must be surfaced (warn + card flag), while
// runs continue and the state stays escrowed (no auto-UN-confirm, ever).
if c.Escrowed != nil && c.Escrowed() {
c.reconcileEscrowed(es)
}
return
}
// Fail-closed: the hash must exist AND ride a present identity blob (the hash-bearing container).
@@ -69,7 +101,7 @@ func (c *EscrowAutoConfirmer) Reconcile(es *EscrowStatus) {
c.warnedHash = es.ResticPwSHA256
c.mu.Unlock()
if !warned {
c.logf("[WARN] [escrow-confirm] the hub's escrow blob does not cover the CURRENT repo password (hub hash %.12s… != local %.12s…) — run the escrow ceremony (felhom-agent --selftest=escrow-create --upload); staying pending", es.ResticPwSHA256, localHash)
c.logf("[WARN] [escrow-confirm] the hub's escrow blob does not cover the CURRENT repo password (hub hash %.12s… != local %.12s…) — run the escrow ceremony (wizard /backup/escrow, or felhom-agent --selftest=escrow-create --upload); staying pending", es.ResticPwSHA256, localHash)
}
return
}
@@ -85,4 +117,45 @@ func (c *EscrowAutoConfirmer) Reconcile(es *EscrowStatus) {
c.logf("[ERROR] [escrow-confirm] escrowed but the agent-staged secret was NOT wiped: %v", err)
}
}
c.mu.Lock()
c.stale = false // a fresh hub-verified confirm clears any earlier stale flag
c.mu.Unlock()
}
// reconcileEscrowed is the Scenario-F branch (§8 truth table, escrowed rows): compare the ACK's
// hash exactly as the pending branch does; a mismatch OR a present blob with an EMPTY hash (the
// hash-less supersession — the spike's exact case) raises the stale flag + ONE warn per distinct
// hub hash (warnedHash reuse); a match clears the flag. State is never flipped; runs never block
// (offsite backups still protect against non-total loss).
func (c *EscrowAutoConfirmer) reconcileEscrowed(es *EscrowStatus) {
localHash, ok := c.LocalHash()
if !ok {
return // no local repo password file — nothing to compare against
}
hubHash := es.ResticPwSHA256
if hubHash != "" && hubHash == localHash {
c.mu.Lock()
c.stale = false
c.mu.Unlock()
return
}
// Stale: hash mismatch, or a blob whose hash is empty (hash-less supersession). Dedupe the
// warn per distinct hub hash; the empty hash dedupes under a sentinel so it still fires once.
dedupeKey := hubHash
if dedupeKey == "" {
dedupeKey = staleHashlessMarker
}
c.mu.Lock()
warned := c.warnedHash == dedupeKey
c.warnedHash = dedupeKey
c.stale = true
c.mu.Unlock()
if warned {
return
}
if hubHash == "" {
c.logf("[WARN] [escrow-confirm] STALE escrow: the hub's current blob carries NO password hash (hash-less supersession) — the stored recovery bundle does not cover the offsite password; create a new recovery code (wizard /backup/escrow). State stays escrowed; runs continue")
return
}
c.logf("[WARN] [escrow-confirm] STALE escrow: the hub's current blob does not cover the CURRENT repo password (hub hash %.12s… != local %.12s…) — create a new recovery code (wizard /backup/escrow). State stays escrowed; runs continue", hubHash, localHash)
}
@@ -0,0 +1,149 @@
package report
import (
"context"
"log"
"strings"
"testing"
"bytes"
)
// Scenario F (v0.127.0) — the escrowed-state stale-blob re-check. The §8 truth table's NEW rows:
// an ESCROWED box whose hub blob does not cover the current password (hash mismatch, or a present
// blob with an EMPTY hash — the spike's hash-less supersession) raises a display-only stale flag
// + ONE warn per distinct hub hash. State never flips; nothing blocks.
type staleHarness struct {
*confirmerHarness
escrowed bool
}
func newStaleHarness(t *testing.T) *staleHarness {
t.Helper()
h := &staleHarness{confirmerHarness: &confirmerHarness{local: hubHash, localOK: true, logbuf: &bytes.Buffer{}}}
h.escrowed = true // the box state under test
h.c = &EscrowAutoConfirmer{
Pending: func() bool { return h.pending },
Escrowed: func() bool { return h.escrowed },
LocalHash: func() (string, bool) { return h.local, h.localOK },
Flip: func() error { h.flips++; return nil },
Wipe: func(context.Context) error { h.wipes++; return nil },
Logger: log.New(h.logbuf, "", 0),
}
return h
}
// escrowed + hash mismatch → stale flag + ONE warn (deduped per distinct hub hash), no flip.
func TestEscrowStale_MismatchWarnsOnceAndFlags(t *testing.T) {
h := newStaleHarness(t)
h.local = otherHash
h.c.Reconcile(matchStatus(hubHash))
if !h.c.StaleBlob() {
t.Fatal("mismatch on an escrowed box must raise the stale flag")
}
if h.flips != 0 {
t.Fatal("the stale re-check must NEVER flip state (no auto-UN-confirm)")
}
if got := strings.Count(h.logbuf.String(), "STALE escrow"); got != 1 {
t.Fatalf("want exactly 1 STALE warn, got %d: %s", got, h.logbuf.String())
}
// Dedupe: the same hub hash again → still exactly one warn; the flag stays up.
h.c.Reconcile(matchStatus(hubHash))
if got := strings.Count(h.logbuf.String(), "STALE escrow"); got != 1 {
t.Fatalf("same stale hash must warn ONCE, got %d", got)
}
if !h.c.StaleBlob() {
t.Fatal("flag must persist across deduped ACKs")
}
// A NEW distinct stale hash → warns again.
h.c.Reconcile(matchStatus("2222222222222222222222222222222222222222222222222222222222222222"))
if got := strings.Count(h.logbuf.String(), "STALE escrow"); got != 2 {
t.Fatalf("a new distinct stale hash must warn again, got %d", got)
}
}
// escrowed + blob present with an EMPTY hash (the spike's exact hash-less supersession) → stale
// + one warn under the hashless dedupe sentinel.
func TestEscrowStale_HashlessBlobWarnsOnce(t *testing.T) {
h := newStaleHarness(t)
h.c.Reconcile(&EscrowStatus{IdentityBlobPresent: true}) // blob present, hash empty
if !h.c.StaleBlob() {
t.Fatal("a hash-less superseding blob must raise the stale flag")
}
if got := strings.Count(h.logbuf.String(), "NO password hash"); got != 1 {
t.Fatalf("want the hash-less warn once, got %d: %s", got, h.logbuf.String())
}
h.c.Reconcile(&EscrowStatus{IdentityBlobPresent: false}) // K-only legacy shape — still hash-less
if got := strings.Count(h.logbuf.String(), "NO password hash"); got != 1 {
t.Fatalf("hash-less must dedupe under its sentinel, got %d warns", got)
}
}
// escrowed + hash MATCHES → clears an earlier stale flag; no warn on the clean path.
func TestEscrowStale_MatchClearsFlag(t *testing.T) {
h := newStaleHarness(t)
h.local = otherHash
h.c.Reconcile(matchStatus(hubHash)) // go stale
if !h.c.StaleBlob() {
t.Fatal("setup: expected stale")
}
h.local = hubHash
h.c.Reconcile(matchStatus(hubHash)) // a covering blob arrives (re-ceremony ran)
if h.c.StaleBlob() {
t.Fatal("a matching hash must CLEAR the stale flag")
}
// And a clean box never warns.
h2 := newStaleHarness(t)
h2.c.Reconcile(matchStatus(hubHash))
if h2.c.StaleBlob() || strings.Contains(h2.logbuf.String(), "STALE") {
t.Fatalf("match must be silent: %s", h2.logbuf.String())
}
}
// Not-escrowed / no-local-password / nil-status rows: the re-check never runs (silent).
func TestEscrowStale_SilentRows(t *testing.T) {
h := newStaleHarness(t)
h.escrowed = false // offbox not configured (or any non-escrowed state)
h.c.Reconcile(matchStatus(otherHash))
if h.c.StaleBlob() || h.logbuf.Len() != 0 {
t.Fatalf("non-escrowed must be silent: %s", h.logbuf.String())
}
h2 := newStaleHarness(t)
h2.localOK = false // no local repo password file
h2.c.Reconcile(matchStatus(otherHash))
if h2.c.StaleBlob() || h2.logbuf.Len() != 0 {
t.Fatal("no local password → nothing to compare → silent")
}
h3 := newStaleHarness(t)
h3.c.Reconcile(nil) // no escrow row at all (blob absent — out of the truth table)
if h3.c.StaleBlob() || h3.logbuf.Len() != 0 {
t.Fatal("nil status must be silent")
}
}
// A fresh pending→escrowed auto-confirm clears any stale leftovers (the flag must not survive a
// successful re-ceremony's confirm).
func TestEscrowStale_AutoConfirmClears(t *testing.T) {
h := newStaleHarness(t)
h.local = otherHash
h.c.Reconcile(matchStatus(hubHash)) // stale while escrowed
if !h.c.StaleBlob() {
t.Fatal("setup: expected stale")
}
// The re-ceremony re-staged + re-uploaded; the box re-enters pending (edit flow) and the new
// blob covers the local password → auto-confirm path runs and must clear the flag.
h.escrowed = false
h.pending = true
h.c.Reconcile(matchStatus(otherHash))
if h.flips != 1 {
t.Fatal("setup: auto-confirm should have flipped")
}
if h.c.StaleBlob() {
t.Fatal("a hub-verified auto-confirm must clear the stale flag")
}
}
+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">