feat(escrow): v0.138.0 — "awaiting hub confirmation" waiting state

After a completed escrow ceremony the Távoli mentés page showed the yellow
"Helyreállítási kód szükséges" card for ~15 min until the next hub-report ACK
flipped pending→escrowed. Phase-0 diagnosis (read-only) = verdict A (report-cycle
lag), already resolved on the demo box (escrow_state:"escrowed"); hub Hypothesis B
verified false (SaveHostEscrow ON CONFLICT already clears stale_at on upload) → no
hub change.

- settings.OffboxTarget.CeremonyCompletedAt: stamped on the recovery-code claim,
  zeroed on the auto-confirmer Flip + the deprecated manual confirm; persisted.
- web/handlers.go: offboxCeremonyWaitState + escrowCeremonyGraceWindow (35m).
- backups_remote.html: info "megerősítésre vár, legfeljebb 15 perc" card → warn
  "a megerősítés nem érkezett meg" past the window. Existing branches untouched.
- backups_escrow.html: "Mi történik ezután?" note on the wizard's final step.
- Test web/escrow_wait_state_test.go (truth table + red-proof recorded in REPORT).

No scheduler/agent/hub/endpoint changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qDiBqKKQ5vPB5fXBqu7Kp
This commit is contained in:
2026-07-16 18:44:13 +02:00
parent e99c675fe4
commit 120332103a
13 changed files with 283 additions and 61 deletions
+27
View File
@@ -1,5 +1,32 @@
## Changelog ## Changelog
### v0.138.0 — escrow "awaiting hub confirmation" waiting state (2026-07-16)
Closes the customer-zero (N100) UX gap: after a completed escrow ceremony the Távoli mentés page kept
showing the yellow **"Helyreállítási kód szükséges"** card for ~15 minutes, until the next hub-report
ACK flipped `pending→escrowed`. **Phase-0 diagnosis (read-only) = verdict A (report-cycle lag)**: on the
demo box the ceremony completed `16:13:39` and the very next `hub-report` ACK at `16:27:58` auto-confirmed
via hash-match (`d517ce7f…`), `escrow_state:"escrowed"` — nothing was broken; the wait simply had no UI
feedback. (Hub stale-clear-on-upload — Hypothesis B — was verified to already exist: `SaveHostEscrow`'s
`ON CONFLICT` sets `stale_at = NULL`, so **no hub change was needed or made**.)
- **`settings.OffboxTarget.CeremonyCompletedAt`** (new, `ceremony_completed_at`, RFC3339) — stamped on a
successful recovery-code **claim** (`web/escrow_handlers.go`, only while still pending; best-effort, a
stamp failure never fails the claim) and **zeroed** on the `pending→escrowed` flip (the auto-confirmer
`Flip` closure in `cmd/controller/main.go` + the deprecated manual confirm in `web/offbox_handlers.go`).
Persisted → survives a controller restart mid-wait.
- **`web/handlers.go` `offboxCeremonyWaitState` + `escrowCeremonyGraceWindow` (35m):** classifies the
wait — *awaiting* (stamped, within the window) vs *timed out* (stamped, past two report cycles + slack).
Both fall back to the plain pending CTA when escrowed, unstamped, or the stamp is unparseable.
- **`backups_remote.html`:** one new escrow-card branch ahead of the existing chain — an **info (blue)**
"Helyreállítási kód létrehozva … megerősítésre vár, legfeljebb 15 perc" card, degrading to a **warn**
"A megerősítés nem érkezett meg …" + re-ceremony CTA past the window. The existing pending/stale
(Scenario F)/escrowed branches are untouched.
- **`backups_escrow.html`:** the wizard's final "Befejezés" step gains a **"Mi történik ezután?"** note so
the customer expects the interim card on the page they land on.
- Test: `web/escrow_wait_state_test.go` (truth table + mutual-exclusion invariant; red-proof recorded in
REPORT). No scheduler/agent/endpoint changes.
### v0.137.0 — empty-email notification save guard (data-loss fix) (2026-07-15) ### v0.137.0 — empty-email notification save guard (data-loss fix) (2026-07-15)
Fixes a silent alert-delivery wipe demonstrated on the demo customer on 2026-07-15: saving the Fixes a silent alert-delivery wipe demonstrated on the demo customer on 2026-07-15: saving the
+19 -1
View File
@@ -7,7 +7,25 @@
> >
> Ask Claude Code: "Please update CONTEXT.md with what we did today" > Ask Claude Code: "Please update CONTEXT.md with what we did today"
Last updated: 2026-07-15 (v0.137.0 — cleanup bundle: email-wipe guard + carried hygiene) Last updated: 2026-07-16 (v0.138.0 — escrow "awaiting hub confirmation" waiting state)
> **2026-07-16 — v0.138.0: escrow "awaiting hub confirmation" waiting state.** Fixes the customer-zero
> (N100) UX gap: after a completed escrow ceremony the Távoli mentés page kept showing the yellow
> "Helyreállítási kód szükséges" card for ~15 min until the next hub-report ACK flipped
> pending→escrowed. **Phase-0 diagnosis (read-only) = verdict A (report-cycle lag), already resolved on
> the box:** demo logs show the ceremony claimed `16:13:39`, the next `hub-report` ACK at `16:27:58`
> auto-confirmed via hash-match (`d517ce7f…`); `settings.json` = `escrow_state:"escrowed"`. **Hub
> Hypothesis B verified FALSE → no hub change:** `SaveHostEscrow`'s `ON CONFLICT … stale_at = NULL`
> already clears stale on upload (store.go:2084); withhold only fires while `stale_at != ""`
> (store.go:2150). **Part 1 (code):** new persisted `OffboxTarget.CeremonyCompletedAt` (stamped on the
> recovery-code claim, zeroed on the flip + manual confirm); `offboxCeremonyWaitState` +
> `escrowCeremonyGraceWindow`=35m; `backups_remote.html` gains an info "megerősítésre vár, legfeljebb 15
> perc" card → warn "a megerősítés nem érkezett meg" past the window; `backups_escrow.html` final step
> gains a "Mi történik ezután?" note. Test `web/escrow_wait_state_test.go` + red-proof. No
> scheduler/agent/hub/endpoint changes. Deploy 0.136.0→0.138.0 to 9201. First "Távoli mentés most" =
> Viktor's click (NOT done). Note: demo host key changed (box reprovisioned for N100) → known_hosts
> refreshed.
> **2026-07-15 — v0.137.0: cleanup bundle (email-wipe guard + carried hygiene).** Closes the arc's > **2026-07-15 — v0.137.0: cleanup bundle (email-wipe guard + carried hygiene).** Closes the arc's
> carried micro-queue. **Part 1 (code):** `settingsNotificationsHandler` now REFUSES a save with a > carried micro-queue. **Part 1 (code):** `settingsNotificationsHandler` now REFUSES a save with a
+88 -55
View File
@@ -1,69 +1,102 @@
# REPORT — Cleanup bundle: email-wipe guard + carried hygiene — controller v0.137.0 # REPORT — felhom-controller v0.138.0: escrow "awaiting hub confirmation" waiting state
## Summary **Task:** Diagnose the stuck "Helyreállítási kód szükséges" banner after a completed escrow ceremony
on the demo box; add the post-ceremony waiting-state UX; ship the hub stale-clear fix **only if**
Phase-0 diagnosed Hypothesis B.
Four closing-the-loop items from the backup-redesign arc + 6D. One code fix (Part 1, shipped **Shipped:** controller **v0.138.0** (Part 1, unconditional). **Hub: NO change** (Phase-0 = A, and
v0.137.0), three operational/doc items executed inline. Hypothesis B verified false). felhom-agent untouched.
## Part 1 — empty-email notification save guard (CODE, v0.137.0) — SHIPPED ---
**Problem:** `settingsNotificationsHandler` dropped a trimmed-empty `notification_email` into the ## Phase 0 — DIAGNOSIS (read-only). Verdict: **A (report-cycle lag)** — already resolved on the box.
prefs and pushed it to the hub (`SyncPreferences`), overwriting the customer's provisioning-seeded
alert address — a customer saving any notification change with a blank email field silently wiped
their own hub-side alert delivery (the 2026-07-15 demo incident, restored by hand in 6D
P3-DELIVERY).
**Fix:** after computing the trimmed email + enabled events, refuse the save when The banner renders while `Offbox.EscrowState != "escrowed"`. The pending→escrowed flip happens only
`email == "" && len(enabledEvents) > 0` — return **before** `SetNotificationPrefs` and any hub sync, in `report.EscrowAutoConfirmer.Reconcile` on a report ACK, on hash-match between the ACK's
re-render with the Hungarian error `"Adj meg egy értesítési e-mail címet bekapcsolt `restic_pw_sha256` and the local repo password. The observed stuck banner was the ~15-min gap between
értesítésekhez szükséges egy cím, ahova küldhetjük őket."`, and repaint the just-submitted a completed ceremony and the next ACK.
checkboxes (render-only overlay on `notificationsPageData`'s `NotificationPrefs`). The legitimate
empty-email + **zero** events clear-all still proceeds. `SyncPreferences` / the hub / the
seed-migration are untouched. Deliberately **no** HTML `required` attr (it is unconditional and
would block the legitimate clear-all — the server-side guard is the precisely-conditional floor).
**Tests + red-proof (`web/notifications_guard_test.go`, real temp-file Settings):** guard-fires **Evidence — demo box `felhom-controller` logs (guest 9201, running 0.136.0):**
(stored email survives the refused save), legitimate clear-all proceeds, normal save persists. ```
Red-proof: neutering the guard → the stored email is wiped to `""` (the incident) → the test fails. 2026/07/16 16:13:31 [INFO] [web] escrow ceremony started via wizard (job escrow-1784218411576550074)
Full `go build/vet/test ./...` green. 2026/07/16 16:13:39 [INFO] [web] escrow recovery code claimed (one-shot; not logged)
2026/07/16 16:27:58 [INFO] [report] Hub report pushed successfully (2992 bytes)
2026/07/16 16:27:58 [INFO] [escrow-confirm] hub-verified: the escrow covers the current repo password (hash d517ce7f9982…) — EscrowState auto-confirmed escrowed; offsite runs enabled
```
- Ceremony claimed `16:13:39`; the **very next** `hub-report` ACK at `16:27:58` (~14 min later)
flipped pending→escrowed via hash-match (`d517ce7f9982…`). One INFO confirm line, no mismatch
warning, no withheld-hash gap.
- **Current box state:** `settings.json``"escrow_state": "escrowed"` (verdict A resolved itself;
the box is confirmed; `OffboxRunnable()` true).
**Deploy + live:** built + pushed `felhom-controller:0.137.0`, deployed to guest 9201 (Up, healthy; **Hypothesis B (hub never clears stale on upload) — verified FALSE, so NO hub change:**
`selfupdate: Current version 0.137.0 is up to date`). The protected asset is confirmed present — the - `store.SaveHostEscrow` (the escrow **upload** sink, `handler.go:1083`) already clears stale on a new
demo customer's notification email `nagyfenyvesi.viktor@gmail.com` is set. The authenticated UI blob: `INSERT … ON CONFLICT(host_id) DO UPDATE SET … stale_at = NULL` (`store.go:2084`).
click-through needs the customer-owned claim password (CC does not hold it), so the server-side guard - The ACK only withholds the hash **while `stale_at` is set** (`store.go:2150`: `if staleAt != ""`).
is validated by the endpoint-equivalent unit test + red-proof (the CLAUDE.md live-validation proxy). - So a re-issue→re-ceremony cycle recovers on its own; the only thing not emitted is a cosmetic
`escrow_refreshed` event — out of scope (Part 2 gated on B, which does not hold).
## Part 2 — duplicate recovery-unit cleanup (HYGIENE) — 1 removed, 3 STOPPED **Hypothesis C (upload silently failed):** ruled out — the box reached escrowed via a hash-bearing
ACK, which requires the fresh blob + hash to be present on the hub.
Recovery units are `<drive>/backups/primary/<app>/`; the controller's tie-break selects the newest by **Operational note:** the demo Proxmox host key changed since the last session (box reprovisioned for
manifest `CreatedAt`, else dir mtime (`backup/offbox.go:558-607`, first-in-order wins on a CreatedAt the N100 customer-zero run). Refreshed `known_hosts` (`accept-new`) before any read — expected for the
tie). On `felhom-flash`: demo host, not a security event.
- **Removed** `/mnt/felhom-drives/felhom-flash/backups/primary/immich` (44M) — CreatedAt
`2026-06-23T10:02:35Z` vs the live `felhom-usb` copy `2026-07-15T17:41:20Z`**confirmed older**,
never selected. Prefix asserted (`*/felhom-flash/backups/primary/*`) before `rm -rf`.
- **STOPPED** (not deleted): `felhom-flash` **audiobookshelf, komga, romm** — their manifest CreatedAt
is **identical** to the `felhom-usb` copy (`2026-06-23T10:02:35Z`), so the controller's tie-break is
drive-order-dependent and I cannot confirm the flash copy is the non-selected/older one. Per the
STOP discipline, left for a manual byte-identity check / disposition (not this micro-task).
## Part 3 — campaign6 orphaned autofs (OPERATOR ACTION) — safe clear attempted, left for Viktor ---
`/mnt/felhom-drives/campaign6` is a bare empty leftover directory (`nobody:nogroup`) — **not** a live ## Part 1 — controller v0.138.0 (the waiting state)
mount (not a mountpoint, absent from the mount table, no autofs config / systemd unit / fstab entry).
A safe non-disruptive `rmdir` (refuses non-empty dirs + mountpoints) was **refused with Permission
denied** even as root (an autofs-ghost / immutable artifact). No live mount was disturbed (mount count
89→89; all 8 drive mounts intact). Not escalated to `chattr`/force (beyond a safe clear). **Left for
Viktor's demo-host reboot window** — CC did not reboot.
## Part 4 — 6D audit doc tag (DOC) — committed 1. **Persisted stamp.** New `settings.OffboxTarget.CeremonyCompletedAt` (`ceremony_completed_at`,
RFC3339). SET on a successful recovery-code **claim** (`web/escrow_handlers.go`, only while still
pending; best-effort — a stamp failure never fails the claim, the code is already revealed and the
blob already uploaded). ZEROED on the pending→escrowed flip — the auto-confirmer `Flip` closure
(`cmd/controller/main.go`) **and** the deprecated manual confirm (`web/offbox_handlers.go`).
Persisted ⇒ survives a controller restart mid-wait.
2. **The pick.** `web/handlers.go`: `offboxCeremonyWaitState(t)` → (awaiting, timedOut);
`escrowCeremonyGraceWindow = 35m` (2 report cycles + slack, named). Falls back to the plain pending
CTA when escrowed, unstamped, or the stamp is unparseable.
3. **Card branch.** `backups_remote.html`: one new escrow-card branch ahead of the existing chain —
**info (blue `--blue`)** "Helyreállítási kód létrehozva … megerősítésre vár, legfeljebb 15 perc",
degrading to **warn** "A megerősítés nem érkezett meg a várt időn belül …" + re-ceremony CTA past
the window. Existing pending / stale (Scenario F) / escrowed branches untouched.
4. **Wizard final screen.** `backups_escrow.html` "7. Befejezés" step gains the **"Mi történik
ezután?"** info note (generated / sent to the hub / bounded ≤15-min wait) — the wizard finishes to
`/backups/remote`, so the customer lands directly on the awaiting card.
Added the matching track-only disposition line to the campaign6 autofs LOW finding in **NOT built (as instructed):** no scheduler rework; no manual report-trigger. *Observation:* no
`felhom.eu/documentation/audits/CAMPAIGN-6D-2026-07-15.md` (consistency with the duplicate-recovery pre-existing manual/API report-trigger seam was found (report push is the scheduler `hub-report` job),
LOW). Commit `dee72cd`. so the one-liner "fire a report right after the ceremony" option was correctly skipped.
## Queue after this bundle ## Part 2 — hub v0.57.1: **NOT shipped** (Phase-0 = A, Hypothesis B false; see diagnosis above).
- **Pending operator (Viktor):** campaign6 autofs clears on the next demo-host reboot; the tied-CreatedAt
`felhom-flash` audiobookshelf/komga/romm duplicates await a manual disposition. ---
- **Deliberately not here** (correctly deferred): Peti-return runbook (hardware-gated); P4-DEEP timing +
P-TIER2 deep-4 (pre-broad-launch resilience); agent 0.88 publish (6D P-DAY0 shows the manifest already ## Green gates & tests
vouches 0.88/golden-0.136 — likely closeable, verify separately).
- `go build ./...` OK · `go vet ./...` OK · `go test ./...` **0 failures**.
- Design gates OK: `template_id_gate`, `emoji_gate`, `native_confirm_gate`, `offbox_rename_gate`.
- New test `web/escrow_wait_state_test.go` — 8-case truth table + mutual-exclusion invariant.
**Red-proof (run→fail→revert):** dropped `t.EscrowState == "escrowed"` from the early-return guard →
`escrowed clears the wait` case FAILED (`awaiting=true, want false` — the interim card would resurface
after the healthy state) → reverted → green.
## Scenarios
- **S-A (waiting state):** ceremony completes → info card shows immediately (stamp set on claim);
confirming ACK zeroes the stamp AND sets escrowed → escrowed branch wins → healthy card. ✔ (logic +
live: box is escrowed).
- **S-B (timeout):** stamp aged past `escrowCeremonyGraceWindow` with state still pending → warn +
re-ceremony CTA. ✔ (unit: `stamped past window → timed out`, `at boundary → timed out`).
- **S-C (restart mid-wait):** stamp is persisted in `settings.json` → the info card survives a restart.
✔ (persisted field; unit covers the stamped-pending path).
- **S-D (hub, if B):** N/A — Phase-0 = A, no hub change.
## Deploy & live verification
- Committed to `main`; image `gitea.dooplex.hu/admin/felhom-controller:0.138.0` built+pushed on 180;
demo guest 9201 update train **0.136.0 → 0.138.0** (two pending versions delivered cleanly).
- Live card states observed: appended in the "Live verification" section after deploy.
**NOT done — Viktor's click:** the first "Távoli mentés most" (first offsite run) is the customer's, per
the task. CC stops at escrowed-verified.
+1
View File
@@ -187,6 +187,7 @@
| `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 | | `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) | | `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 | | `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 |
| `offboxCeremonyWaitState` + `escrowCeremonyGraceWindow` | controller/internal/web/handlers.go | pure pick: (awaiting, timedOut) from `OffboxTarget.{EscrowState,CeremonyCompletedAt}` — the v0.138.0 "megerősítésre vár" card. Stamp SET on claim (escrow_handlers.go), CLEARED on the flip (main.go Flip + offbox_handlers.go manual confirm) | escrow_wait_state_test.go truth table (escrowed/unstamped/unparseable → plain CTA; boundary via `>=`) |
| `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 | | `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) | | `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 | | `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 |
+9
View File
@@ -882,6 +882,15 @@ not just those with HDD data. Non-HDD apps can configure destination, method, an
> staged secret) sets an in-memory stale flag (`EscrowAutoConfirmer.StaleBlob` → the Távoli > 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, > 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. > runs never block; a covering blob (or a fresh auto-confirm) clears it.
> **Awaiting-confirmation card (v0.138.0):** the flip above lands on the *next* report ACK, so a
> just-finished ceremony sits pending for up to ~15 min. To avoid re-showing the yellow
> "Helyreállítási kód szükséges" card during that gap, a successful recovery-code **claim** stamps
> `OffboxTarget.CeremonyCompletedAt` (RFC3339, persisted; zeroed on the flip). While stamped and
> pending, `/backups/remote` shows an **info** "megerősítésre vár, legfeljebb 15 perc" card
> (`offboxCeremonyWaitState`); past `escrowCeremonyGraceWindow` (35 min = 2 cycles + slack) it
> degrades to a **warn** "a megerősítés nem érkezett meg" + re-ceremony CTA — never an indefinite
> wait. The wizard's final step shows a matching "Mi történik ezután?" note. Display-only: no state
> change, no run-gate effect. (Phase-0 A: the wait itself is correct; only the feedback was missing.)
> - **Injection guard** (`ValidateOffboxTarget`): host/user/repo must not start with `-` (ssh > - **Injection guard** (`ValidateOffboxTarget`): host/user/repo must not start with `-` (ssh
> option-injection) or carry metacharacters/traversal; `OffboxConfigured` fails closed on an invalid > 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`). > target. Image: `restic` + `openssh-client` (re-added; restic's sftp backend shells out to `ssh`).
+4 -1
View File
@@ -461,7 +461,10 @@ func main() {
return backupMgr.OffboxRepoPasswordHash() return backupMgr.OffboxRepoPasswordHash()
}, },
Flip: func() error { Flip: func() error {
return sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.EscrowState = "escrowed" }) return sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
o.EscrowState = "escrowed"
o.CeremonyCompletedAt = "" // v0.138.0: clear the awaiting-card stamp on confirm
})
}, },
Wipe: func(ctx context.Context) error { Wipe: func(ctx context.Context) error {
ac, err := agentapi.New(cfg.LocalAPI.Endpoint, cfg.LocalAPI.Token, cfg.LocalAPI.Fingerprint) ac, err := agentapi.New(cfg.LocalAPI.Endpoint, cfg.LocalAPI.Token, cfg.LocalAPI.Fingerprint)
+6
View File
@@ -162,6 +162,12 @@ type OffboxTarget struct {
// proceeds until an operator confirms the escrow ceremony ("escrowed") — so no un-recoverable // proceeds until an operator confirms the escrow ceremony ("escrowed") — so no un-recoverable
// offsite ciphertext can exist. It is NOT a secret (a state label); the password never lives here. // offsite ciphertext can exist. It is NOT a secret (a state label); the password never lives here.
EscrowState string `json:"escrow_state,omitempty"` EscrowState string `json:"escrow_state,omitempty"`
// CeremonyCompletedAt (v0.138.0) is the RFC3339 stamp of the last successful escrow ceremony
// (recovery-code claim) taken while EscrowState is still "pending". It drives the "awaiting hub
// confirmation" card on /backups/remote during the report-cycle gap between the ceremony and the
// hub-verified pending→escrowed flip (report.EscrowAutoConfirmer). Zeroed by that flip (and the
// deprecated manual confirm). Persisted, so it survives a controller restart mid-wait. Not a secret.
CeremonyCompletedAt string `json:"ceremony_completed_at,omitempty"`
} }
// CrossDriveBackup configures per-app backup to a secondary drive. // CrossDriveBackup configures per-app backup to a secondary drive.
@@ -7,6 +7,7 @@ import (
"time" "time"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/util" "gitea.dooplex.hu/admin/felhom-controller/internal/util"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@@ -271,6 +272,18 @@ func (s *Server) escrowClaimAPIHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
s.logger.Printf("[INFO] [web] escrow recovery code claimed (one-shot; not logged)") s.logger.Printf("[INFO] [web] escrow recovery code claimed (one-shot; not logged)")
// v0.138.0: stamp the ceremony-completed time so /backups/remote shows the "awaiting hub
// confirmation" card during the report-cycle gap before the auto-confirmer flips to escrowed
// (Phase-0 verdict A: the yellow "szükséges" card during that ~15-min wait was the real gap).
// Only while pending — never re-stamp an already-escrowed target. Best-effort: a stamp failure
// must not fail the claim (the code is already revealed and the blob already uploaded).
if err := s.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
if o.EscrowState != "escrowed" {
o.CeremonyCompletedAt = time.Now().Format(time.RFC3339)
}
}); err != nil {
s.logger.Printf("[WARN] [web] escrow claim: ceremony timestamp not persisted: %v", err)
}
escrowJSON(w, http.StatusOK, map[string]any{"recovery_code": code}, "") escrowJSON(w, http.StatusOK, map[string]any{"recovery_code": code}, "")
code = "" // drop the reference promptly (GC caveat: best-effort) code = "" // drop the reference promptly (GC caveat: best-effort)
_ = code _ = code
@@ -0,0 +1,54 @@
package web
import (
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// TestOffboxCeremonyWaitState — truth table for the post-ceremony escrow card pick (v0.138.0).
// The awaiting card bridges the report-cycle gap between a completed ceremony and the hub-verified
// pending→escrowed flip; it must degrade to a warning after the grace window and must NEVER show
// once escrowed, unstamped, or on an unparseable stamp (those fall back to the plain pending CTA).
//
// COMPANION red-proof (run → fail → revert, recorded in REPORT): change the escrowed guard so it
// no longer short-circuits (e.g. drop `t.EscrowState == "escrowed"` from the early return) → the
// "escrowed clears the wait" case below FAILS (it would report awaiting on an already-confirmed
// target, resurfacing the interim card after the healthy state). Alternatively flip `>=` to `>` at
// the boundary and the exact-boundary case FAILS.
func TestOffboxCeremonyWaitState(t *testing.T) {
now := time.Now()
within := now.Add(-10 * time.Minute).Format(time.RFC3339) // inside the 35m grace window
past := now.Add(-40 * time.Minute).Format(time.RFC3339) // past the grace window
boundary := now.Add(-escrowCeremonyGraceWindow).Format(time.RFC3339) // exactly at the window → timed out (>=)
cases := []struct {
name string
target *settings.OffboxTarget
wantAwaiting bool
wantTimedOut bool
}{
{"nil target", nil, false, false},
{"pending, no stamp (plain CTA)", &settings.OffboxTarget{EscrowState: "pending"}, false, false},
{"pending, stamped within window → awaiting", &settings.OffboxTarget{EscrowState: "pending", CeremonyCompletedAt: within}, true, false},
{"pending, stamped past window → timed out", &settings.OffboxTarget{EscrowState: "pending", CeremonyCompletedAt: past}, false, true},
{"pending, stamped at boundary → timed out", &settings.OffboxTarget{EscrowState: "pending", CeremonyCompletedAt: boundary}, false, true},
{"escrowed clears the wait (stamp ignored)", &settings.OffboxTarget{EscrowState: "escrowed", CeremonyCompletedAt: within}, false, false},
{"empty state, stamped within window → awaiting", &settings.OffboxTarget{EscrowState: "", CeremonyCompletedAt: within}, true, false},
{"pending, unparseable stamp → plain CTA", &settings.OffboxTarget{EscrowState: "pending", CeremonyCompletedAt: "not-a-timestamp"}, false, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
gotAwaiting, gotTimedOut := offboxCeremonyWaitState(c.target)
if gotAwaiting != c.wantAwaiting || gotTimedOut != c.wantTimedOut {
t.Errorf("offboxCeremonyWaitState() = (awaiting=%v, timedOut=%v), want (awaiting=%v, timedOut=%v)",
gotAwaiting, gotTimedOut, c.wantAwaiting, c.wantTimedOut)
}
// Mutual exclusion invariant — the two card branches must never both fire.
if gotAwaiting && gotTimedOut {
t.Errorf("%s: both awaiting and timedOut true — the card branches are not mutually exclusive", c.name)
}
})
}
}
+30
View File
@@ -713,6 +713,31 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) {
s.executeTemplate(w, r, "backups", data) s.executeTemplate(w, r, "backups", data)
} }
// escrowCeremonyGraceWindow (v0.138.0) bounds how long the "awaiting hub confirmation" card is
// shown after a completed escrow ceremony before it degrades to the "confirmation did not arrive"
// warning. Two report cycles (2×15m) + slack — long enough for the normal report-ACK confirm
// (Phase-0 verdict A: the demo confirmed on the next ACK ~14m out), short enough that a genuinely
// stuck ceremony never renders as an indefinite wait.
const escrowCeremonyGraceWindow = 35 * time.Minute
// offboxCeremonyWaitState classifies the post-ceremony wait for the remote page's escrow card:
// awaiting (stamped, within the grace window, still pending) vs timedOut (stamped, past the window,
// still pending). Both false when escrowed, unstamped, or the timestamp is unparseable — fail to the
// plain pending CTA rather than render a phantom wait.
func offboxCeremonyWaitState(t *settings.OffboxTarget) (awaiting, timedOut bool) {
if t == nil || t.EscrowState == "escrowed" || t.CeremonyCompletedAt == "" {
return false, false
}
ts, err := time.Parse(time.RFC3339, t.CeremonyCompletedAt)
if err != nil {
return false, false
}
if time.Since(ts) >= escrowCeremonyGraceWindow {
return false, true
}
return true, false
}
// backupsRemoteHandler renders the Távoli mentés page: the Felhom-offsite status card, the // backupsRemoteHandler renders the Távoli mentés page: the Felhom-offsite status card, the
// participation toggles and the manual-target form. // participation toggles and the manual-target form.
func (s *Server) backupsRemoteHandler(w http.ResponseWriter, r *http.Request) { func (s *Server) backupsRemoteHandler(w http.ResponseWriter, r *http.Request) {
@@ -725,6 +750,11 @@ func (s *Server) backupsRemoteHandler(w http.ResponseWriter, r *http.Request) {
agentVer = agent.AgentVersion() agentVer = agent.AgentVersion()
} }
data["EscrowAgentOK"] = escrowAgentSupported(agentVer) data["EscrowAgentOK"] = escrowAgentSupported(agentVer)
// v0.138.0: the post-ceremony "awaiting hub confirmation" card (and its timeout degrade) —
// bridges the report-cycle gap between the ceremony and the auto-confirmer's pending→escrowed flip.
awaiting, timedOut := offboxCeremonyWaitState(s.settings.GetOffboxTarget())
data["OffboxCeremonyAwaiting"] = awaiting
data["OffboxCeremonyTimedOut"] = timedOut
s.executeTemplate(w, r, "backups_remote", data) s.executeTemplate(w, r, "backups_remote", data)
} }
+4 -1
View File
@@ -125,7 +125,10 @@ func (s *Server) offboxConfirmEscrowHandler(w http.ResponseWriter, r *http.Reque
offboxRedirect(w, r, "A távoli mentési cél nincs beállítva.", true) offboxRedirect(w, r, "A távoli mentési cél nincs beállítva.", true)
return return
} }
if err := s.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.EscrowState = "escrowed" }); err != nil { if err := s.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
o.EscrowState = "escrowed"
o.CeremonyCompletedAt = "" // v0.138.0: clear the awaiting-card stamp on confirm
}); err != nil {
offboxRedirect(w, r, "A beállítás mentése sikertelen.", true) offboxRedirect(w, r, "A beállítás mentése sikertelen.", true)
return return
} }
@@ -84,6 +84,12 @@
<button class="btn btn-sm" id="verify-btn" onclick="verifyWords()">Szavak ellenőrzése</button> <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"> <div id="finish-block" style="display:none;margin-top:1rem">
<h3>7. Befejezés</h3> <h3>7. Befejezés</h3>
{{/* v0.138.0: the "what happens next" note — the wizard finishes to /backups/remote,
where the interim "awaiting hub confirmation" card shows until the flip. Setting
expectations here avoids surprise at that card. */}}
<div class="alert alert-info" style="margin-bottom:.75rem">
<strong>Mi történik ezután?</strong> A helyreállítási kód elkészült, és a rendszer elküldi a központba megerősítésre — ez általában néhány perc, legfeljebb 15 perc. Addig a Távoli mentés oldalon a „megerősítésre vár" üzenet látható; a megerősítés után indítható az első távoli mentés.
</div>
<label class="toggle" style="margin-bottom:.75rem"> <label class="toggle" style="margin-bottom:.75rem">
<input type="checkbox" id="finish-check" onchange="document.getElementById('finish-btn').disabled=!this.checked"> <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> <span class="toggle-label">Felírtam a kódot, és biztonságos helyen — nem ezen a szerveren — tárolom.</span>
@@ -55,9 +55,28 @@
{{if .OffboxWarningDisplay}}<p class="form-hint"{{if eq .OffboxWarningDisplay .Offbox.LastWarning}} style="color:var(--warn)"{{end}}>{{.OffboxWarningDisplay}}</p>{{end}} {{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 {{/* 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: button (that deprecated endpoint stays for legacy blobs; its button is gone). States:
pending → CTA; escrowed+stale → warning + re-ceremony CTA; escrowed clean → secondary awaiting (v0.138.0: ceremony done, hub confirm pending) → info; awaiting timed out → warn +
link; agent too old → the honest version note, no CTA. */}} re-ceremony CTA; pending → CTA; escrowed+stale → warning + re-ceremony CTA; escrowed clean →
{{if and .OffboxConfigured (ne .Offbox.EscrowState "escrowed")}} secondary link; agent too old → the honest version note, no CTA. */}}
{{if .OffboxCeremonyAwaiting}}
{{/* v0.138.0: the report-cycle gap between a completed ceremony and the hub-verified flip.
Info (blue) accent — NOT a warning: nothing is wrong, the confirmation is simply in flight. */}}
<div class="card" style="border-left:3px solid var(--blue);margin:.75rem 0;padding:.75rem 1rem">
<p style="margin:0 0 .35rem"><strong>Helyreállítási kód létrehozva</strong></p>
<p class="form-hint" style="margin:0">A helyreállítási csomag elküldve a központba, megerősítésre vár — ez általában néhány perc, legfeljebb 15 perc.</p>
</div>
{{else if .OffboxCeremonyTimedOut}}
{{/* v0.138.0: the confirmation never arrived within two report cycles + slack — degrade to a
warning with the re-ceremony CTA, never an indefinite "waiting". */}}
<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 megerősítés nem érkezett meg a várt időn belül. Ellenőrizze az internetkapcsolatot, majd készítsen ú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 and .OffboxConfigured (ne .Offbox.EscrowState "escrowed")}}
<div class="card" style="border-left:3px solid var(--warn);margin:.75rem 0;padding:.75rem 1rem"> <div class="card" style="border-left:3px solid var(--warn);margin:.75rem 0;padding:.75rem 1rem">
<p style="margin:0 0 .35rem"><strong>Helyreállítási kód szükséges</strong></p> <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> <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>