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:
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user