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:
@@ -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,
|
||||
|
||||
@@ -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
@@ -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`).
|
||||
|
||||
@@ -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))))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 (E — already escrowed or offbox not configured).
|
||||
// onboarding); escrowed → the v0.127.0 stale-blob re-check (F — warn-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")
|
||||
}
|
||||
}
|
||||
@@ -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