v0.201.0 — a correct recovery code is never called wrong again (CAMPAIGN-11) — MinAgent 0.125.0
gates / gates (push) Successful in 9s
gates / gates (push) Successful in 9s
R-216: the offsite key recovery is a coupled feature and now says so. featureProbes +
featureMinAgent 0.125.0 + a Supports gate at the unlock entry point, FAILING CLOSED — an
agent that cannot answer is named as such instead of the customer's code being blamed.
Measured live: a 404 from agent 0.120.0 came back as "we did not accept your recovery
code, check that all ten words", in 0.134 s, against a perfect code.
R-218: delete the repo-password short-circuit in needsOffsiteCredential. The declaration
stops when the TIER WORKS, not when a key exists — installing a key is the recovery
screen's whole job, so succeeding at recovery was switching off the mechanism that would
have delivered the coordinates to use it.
R-219: the unlock finishes the job — place the key, bring the tier up, then list. Without
it the promised listing could never render on the shape the screen exists for.
R-217: an unreadable store no longer claims to have opened with unattributable content
(the OffsiteInventory{} zero value). Opened / empty / unreadable are three states.
R-222: a code that is right about a RETAINED earlier package is named, not blamed. States
what the hub knows and promises nothing — no read path exists.
R-215: GET /recovery is gated on the same predicate as the interception.
Five red-proofs, each demonstrated failing and restored.
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
)
|
||||
|
||||
@@ -62,7 +63,23 @@ func recoveryNoStore(w http.ResponseWriter) {
|
||||
|
||||
// recoveryPageHandler renders the screen (GET /recovery). Reachable whenever the situation holds —
|
||||
// including after "most nem", which is how the backups-area entry point can point at it forever.
|
||||
//
|
||||
// ⚠ GATED ON THE SAME PREDICATE AS THE INTERCEPTION (R-215, v0.201.0). It was not, and the page
|
||||
// rendered its whole story — *"Ezt a gépet újratelepítették. A korábbi, házon kívüli mentéseid
|
||||
// megvannak"* — on any claimed box, including one installed hours earlier that had never had an
|
||||
// off-site backup in its life. Measured on 2026-08-05 (CAMPAIGN-11 §F9): the hub held no escrow row
|
||||
// for that host at all, so `recoveryOffer()` was correctly false; the page simply never asked it.
|
||||
//
|
||||
// The predicate was right, the POST sibling below already consulted it, and the backups-area template
|
||||
// gates the identical sentence on `.RecoveryOffer` — this GET was the one door left open. A box with
|
||||
// no link to /recovery is only reached by typing the path or following a stale bookmark, but what it
|
||||
// then asserts is false in two ways at once, on the one screen whose purpose is to be believed about
|
||||
// backups.
|
||||
func (s *Server) recoveryPageHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.recoveryOffer() {
|
||||
http.Redirect(w, r, "/backups/remote", http.StatusFound)
|
||||
return
|
||||
}
|
||||
s.renderRecovery(w, r, "", "", nil)
|
||||
}
|
||||
|
||||
@@ -70,6 +87,19 @@ func (s *Server) recoveryPageHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// read-only listing). errorMsg/flash are already-customer-facing Hungarian; NEITHER EVER CONTAINS THE
|
||||
// CODE — see unlockHandler.
|
||||
func (s *Server) renderRecovery(w http.ResponseWriter, r *http.Request, errorMsg, flash string, inv *backup.OffsiteInventory) {
|
||||
s.renderRecoveryState(w, r, errorMsg, flash, inv, false)
|
||||
}
|
||||
|
||||
// renderRecoveryUnlockedNoList is the R-217 shape: the unlock SUCCEEDED but the repository could not
|
||||
// be listed. It renders the unlocked page with NO listing block at all — never the zero-value
|
||||
// inventory, whose Empty=false made the template claim the store had opened with unattributable
|
||||
// content. "Opened with apps", "opened and empty" and "could not be read" are three states and this
|
||||
// is the third.
|
||||
func (s *Server) renderRecoveryUnlockedNoList(w http.ResponseWriter, r *http.Request, errorMsg string) {
|
||||
s.renderRecoveryState(w, r, errorMsg, "", nil, true)
|
||||
}
|
||||
|
||||
func (s *Server) renderRecoveryState(w http.ResponseWriter, r *http.Request, errorMsg, flash string, inv *backup.OffsiteInventory, unlockedNoList bool) {
|
||||
recoveryNoStore(w)
|
||||
data := s.baseData("recovery", "Adatok visszaszerzése")
|
||||
data["CSRFField"] = s.csrfField(r)
|
||||
@@ -87,7 +117,13 @@ func (s *Server) renderRecovery(w http.ResponseWriter, r *http.Request, errorMsg
|
||||
data["Unlocked"] = true
|
||||
data["InvApps"] = inv.Apps
|
||||
data["InvEmpty"] = inv.Empty
|
||||
// InvUntagged is "the repository OPENED and holds snapshots we could not attribute to an app".
|
||||
// It is only ever computed from a real reading — a failed read reaches the branch below and
|
||||
// renders no listing at all (R-217).
|
||||
data["InvUntagged"] = !inv.Empty && len(inv.Apps) == 0
|
||||
} else if unlockedNoList {
|
||||
data["Unlocked"] = true
|
||||
data["InvUnavailable"] = true
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := s.tmpl.ExecuteTemplate(w, "recovery", data); err != nil {
|
||||
@@ -124,6 +160,48 @@ func (s *Server) SetRecoveryRecoverer(fn func() (backup.OffsiteKeyRecoverer, err
|
||||
s.recoveryRecovererFn = fn
|
||||
}
|
||||
|
||||
// recoverySupport is the R-216 capability verdict for the unlock path. It uses the SAME machinery the
|
||||
// NAS gate uses (`netFeatures` + the agent client as prober), so the version channel decides without
|
||||
// probe traffic whenever the agent's version is known.
|
||||
//
|
||||
// It returns SupportUnknown when the agent handle cannot be built — and the CALLER treats Unknown as
|
||||
// "cannot ask". That is deliberate and is the whole point: a box that cannot reach its agent must not
|
||||
// attempt an unlock whose failure would be read as a bad code.
|
||||
func (s *Server) recoverySupport(ctx context.Context) agentapi.SupportState {
|
||||
if s.recoverySupportFn != nil {
|
||||
return s.recoverySupportFn(ctx)
|
||||
}
|
||||
agent, err := s.agentClient()
|
||||
if err != nil {
|
||||
return agentapi.SupportUnknown
|
||||
}
|
||||
state, source := s.netFeatures.SupportsWithSource(ctx, agent, agentapi.FeatureOffsiteKeyRecovery)
|
||||
s.logger.Printf("[DEBUG] [web] recovery capability gate: %s=%s (source=%s)", agentapi.FeatureOffsiteKeyRecovery, state, source)
|
||||
return state
|
||||
}
|
||||
|
||||
// SetRecoverySupport overrides the capability verdict (tests). INIT-ONLY.
|
||||
func (s *Server) SetRecoverySupport(fn func(context.Context) agentapi.SupportState) {
|
||||
s.recoverySupportFn = fn
|
||||
}
|
||||
|
||||
// recoverySuperseded reports the hub's statement that an EARLIER sealed package is kept, and when
|
||||
// (R-222). Both zero on a pre-0.97.0 hub, which keeps the old message — an older hub simply cannot
|
||||
// make the screen claim anything new.
|
||||
func (s *Server) recoverySuperseded() (bool, string) {
|
||||
if s.settings == nil {
|
||||
return false, ""
|
||||
}
|
||||
return s.settings.GetHubEscrowSuperseded()
|
||||
}
|
||||
|
||||
// SetRecoveryTierUp wires the off-site apply-bridge reconcile (R-219 / R-218). INIT-ONLY.
|
||||
//
|
||||
// The unlock calls it between placing the recovered key and reading the repository — the step that
|
||||
// turns the screen's promised listing from structurally impossible into merely conditional. nil (or
|
||||
// an error) is survivable: the key is placed either way and the listing branch says what is pending.
|
||||
func (s *Server) SetRecoveryTierUp(fn func(context.Context) error) { s.recoveryTierUp = fn }
|
||||
|
||||
// recoveryUnlockHandler takes the recovery code and drives the SHARED core (POST /recovery/unlock).
|
||||
//
|
||||
// R HANDLING, and it must not drift from the CLI's:
|
||||
@@ -155,6 +233,23 @@ func (s *Server) recoveryUnlockHandler(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderRecovery(w, r, "A gép házon belüli kapcsolata most nem elérhető — próbáld újra néhány perc múlva.", "", nil)
|
||||
return
|
||||
}
|
||||
// ── MESSAGE 2 of 4 — THE MACHINE CANNOT ASK (R-216). This gate FAILS CLOSED. ────────────────
|
||||
//
|
||||
// The unseal happens in the agent, behind POST /escrow/recover-offsite-password, which ships in
|
||||
// agent v0.125.0. On an older agent that route 404s — and until v0.201.0 the unlock was attempted
|
||||
// anyway and the 404 came back as "we did not accept your recovery code, check your ten words".
|
||||
// A correct code, refused in 0.134 s, blamed on the customer (CAMPAIGN-11 Phase 1, the headline).
|
||||
//
|
||||
// So: anything other than SupportYes stops here. Unknown counts as cannot-ask, against this
|
||||
// package's fail-open default — see FeatureOffsiteKeyRecovery for why this one feature inverts it.
|
||||
// An attempt that cannot succeed must never be made, because its failure is attributed to the code.
|
||||
if support := s.recoverySupport(r.Context()); support != agentapi.SupportYes {
|
||||
code = ""
|
||||
s.logger.Printf("[WARN] [web] recovery: refused before attempting — agent capability %s=%s (needs %s)",
|
||||
agentapi.FeatureOffsiteKeyRecovery, support, agentapi.MinAgentFor(agentapi.FeatureOffsiteKeyRecovery))
|
||||
s.renderRecovery(w, r, "Ez a gép még nem tudja megnyitni a mentéseidet — a hozzá tartozó házon belüli szolgáltatás régebbi, mint amit ehhez a lépéshez használunk. A kódoddal semmi baj, és nem is használtuk fel: tedd el biztonságos helyen. A gép magától frissül; próbáld újra később, vagy szólj a Felhom ügyfélszolgálatának, ha egy nap múlva sem működik.", "", nil)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
// THE SHARED CORE — the same function the command line drives. There is no second recovery
|
||||
@@ -165,6 +260,31 @@ func (s *Server) recoveryUnlockHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// The agent's error names the STEP (fetch / unseal / place) and carries no secret. It is not
|
||||
// shown raw: a customer needs to know what to check, not what age's KDF returned.
|
||||
s.logger.Printf("[WARN] [web] recovery: unlock failed: %v", rerr)
|
||||
// ── MESSAGE 4 of 4 — AN EARLIER PACKAGE IS KEPT (R-222) ────────────────────────────────
|
||||
//
|
||||
// The unseal failed against the package the hub CURRENTLY holds. That is the right outcome for
|
||||
// a wrong code — and it is also the right outcome for a code that is perfectly correct about an
|
||||
// EARLIER package the hub is deliberately keeping. From here the two are indistinguishable: the
|
||||
// engine fails closed either way, which is exactly what it should do.
|
||||
//
|
||||
// So when the hub has told us an earlier package exists, say that instead of blaming typing.
|
||||
// Measured on 2026-08-05 (CAMPAIGN-11 Phase 3 step 7): the customer entered the correct code
|
||||
// for their orphaned history, got a real 1.111 s unseal attempt, and was told to check their
|
||||
// ten words — while the same screen showed a seal date from after the code they were holding.
|
||||
//
|
||||
// ⚠ It states two facts the hub knows and STOPS. It does not promise the earlier package can be
|
||||
// opened, because it cannot be: serving a superseded blob is an unbuilt link (R-199's
|
||||
// inventory), and a conditional promise that turns out false on this screen is worse than
|
||||
// saying less (the R-202 lesson).
|
||||
if present, at := s.recoverySuperseded(); present {
|
||||
when := ""
|
||||
if at != "" {
|
||||
when = " (" + at + ")"
|
||||
}
|
||||
s.renderRecovery(w, r, "Ez a kód nem nyitja meg azt a csomagot, amit most őrzünk ehhez a géphez. Ha egy korábbi kódot adtál meg: a géped azóta új mentési kulcsot kapott, és a régebbi csomagot"+when+" nem töröltük — megőrizzük. Megnyitni viszont innen egyelőre nem lehet, ezért ha a régebbi mentéseidre van szükséged, keresd a Felhom ügyfélszolgálatát. A kódoddal semmi nem történt, és semmi nem változott.", "", nil)
|
||||
return
|
||||
}
|
||||
// ── MESSAGE 1 of 4 — THE CODE DID NOT OPEN IT. The ONLY one that mentions typing. ──────────
|
||||
s.renderRecovery(w, r, "A megadott helyreállítási kódot nem fogadtuk el. Ellenőrizd, hogy mind a tíz szót pontosan, szóközökkel elválasztva írtad be — a kis- és nagybetűk nem számítanak. Semmi nem változott, nyugodtan próbáld újra.", "", nil)
|
||||
return
|
||||
}
|
||||
@@ -175,21 +295,62 @@ func (s *Server) recoveryUnlockHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] [web] recovery: the offsite repository key was recovered and placed (outcome=%s)", res.Outcome)
|
||||
|
||||
// ── FINISH THE JOB (R-219, v0.201.0) ───────────────────────────────────────────────────────
|
||||
//
|
||||
// The screen promises: *"feloldjuk a mentéseid zárolását és megmutatjuk, mi van bennük"*. On the
|
||||
// shape this screen exists for that promise was structurally unkeepable, and this call is what
|
||||
// makes it possible at all:
|
||||
//
|
||||
// - listing needs an off-site TARGET (OffsiteInventoryList returns errNoOffsiteTarget without);
|
||||
// - a target cannot exist without a repository password, because ApplyOffsiteTarget →
|
||||
// WriteOffboxSecrets MINTS one whenever none is present;
|
||||
// - shape (a) is DEFINED by having no repository password. So shape (a) ⇒ no target ⇒ the
|
||||
// listing could never render. Not sometimes. Ever.
|
||||
//
|
||||
// And there was no second chance: placing the key flips recoveryOffer() false (a password now
|
||||
// exists and the box is not orphaned), so the unlock response was the customer's ONLY opportunity
|
||||
// to see the listing — and it was guaranteed not to contain it.
|
||||
//
|
||||
// So bring the tier up here, synchronously, between placing the key and reading the repository.
|
||||
// It is also the second half of R-218's deadlock: the apply-bridge only retried "on next config
|
||||
// refresh/restart", and nothing triggered either.
|
||||
if s.recoveryTierUp != nil {
|
||||
tctx, tcancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
if terr := s.recoveryTierUp(tctx); terr != nil {
|
||||
// NOT a failure of the unlock — the key IS placed. Logged, never swallowed, and the
|
||||
// listing branch below says what is pending rather than what failed.
|
||||
s.logger.Printf("[WARN] [web] recovery: the offsite tier could not be brought up yet: %v", terr)
|
||||
}
|
||||
tcancel()
|
||||
}
|
||||
|
||||
// Unlocked. Now show what is in there — read-only.
|
||||
ictx, icancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer icancel()
|
||||
inv, ierr := s.backupMgr.OffsiteInventoryList(ictx)
|
||||
if ierr != nil {
|
||||
// ── MESSAGE 3 of 4 — THE STORE COULD NOT BE READ (R-217) ────────────────────────────────
|
||||
//
|
||||
// ⚠ PASS nil, NOT backup.OffsiteInventory{}. The zero value has Empty=false and Apps=nil, which
|
||||
// the template reads as InvUntagged — *"A tároló megnyílt, és van benne tartalom, de nem tudtuk
|
||||
// alkalmazásokhoz rendelni."* Three assertions, none of them known: it did not open, its
|
||||
// contents are unknown, and attribution is not the problem. Measured on 2026-08-05 (CAMPAIGN-11
|
||||
// Phase 1), rendered directly beneath the honest message, contradicting it.
|
||||
//
|
||||
// OffsiteInventory.Empty exists precisely to prevent this — its own doc comment says it is
|
||||
// "named rather than inferred from len(Apps)==0, WHICH IS ALSO WHAT A FAILED READ LOOKS LIKE".
|
||||
// The field built for the hazard was defaulted past. nil means "no inventory to render", and
|
||||
// the template shows no listing block at all.
|
||||
s.logger.Printf("[WARN] [web] recovery: unlocked but the inventory could not be read: %v", ierr)
|
||||
empty := backup.OffsiteInventory{}
|
||||
msg := "A kulcs visszakerült, de a mentések listáját most nem sikerült beolvasni. Nézd meg a Biztonsági mentés oldalt néhány perc múlva."
|
||||
msg := "A kulcs visszakerült, de a mentések listáját most nem sikerült beolvasni. A mentéseid nincsenek veszélyben — nézd meg a Biztonsági mentés oldalt néhány perc múlva."
|
||||
if backup.ErrNoOffsiteTarget(ierr) {
|
||||
// The pristine rebuilt shape: the key is in place but the box has no off-site coordinates
|
||||
// yet. It resolves by itself once the tier is re-applied, so say that rather than showing a
|
||||
// failure the customer cannot act on.
|
||||
msg = "A kulcs visszakerült. A gép még most kapcsolódik újra a házon kívüli tárhelyhez — a mentéseid listája néhány perc múlva jelenik meg a Biztonsági mentés oldalon."
|
||||
// The tier could not be brought up in time — the transport credential has not arrived yet.
|
||||
// Nothing has failed; something is pending, and the declaration stays alive (R-218) so the
|
||||
// hub's self-heal can still act.
|
||||
msg = "A kulcs visszakerült, és biztonságban van. A gép még várja a házon kívüli tárhely kapcsolódási adatait — amint megvannak, a mentéseid listája megjelenik a Biztonsági mentés oldalon. Nincs teendőd."
|
||||
}
|
||||
s.renderRecovery(w, r, msg, "", &empty)
|
||||
s.renderRecoveryUnlockedNoList(w, r, msg)
|
||||
return
|
||||
}
|
||||
s.renderRecovery(w, r, "", "A mentéseid zárolása feloldva. Az alábbiakat találtuk a tárolóban — semmit nem állítottunk vissza.", &inv)
|
||||
|
||||
Reference in New Issue
Block a user