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
+6
View File
@@ -162,6 +162,12 @@ type OffboxTarget struct {
// 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.
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.
@@ -7,6 +7,7 @@ import (
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
"golang.org/x/crypto/bcrypt"
)
@@ -271,6 +272,18 @@ func (s *Server) escrowClaimAPIHandler(w http.ResponseWriter, r *http.Request) {
return
}
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}, "")
code = "" // drop the reference promptly (GC caveat: best-effort)
_ = 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)
}
// 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
// participation toggles and the manual-target form.
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()
}
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)
}
+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)
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)
return
}
@@ -84,6 +84,12 @@
<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>
{{/* 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">
<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>
@@ -55,9 +55,28 @@
{{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")}}
awaiting (v0.138.0: ceremony done, hub confirm pending) → info; awaiting timed out → warn +
re-ceremony CTA; pending → CTA; escrowed+stale → warning + re-ceremony CTA; escrowed clean →
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">
<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>