v0.201.0 — a correct recovery code is never called wrong again (CAMPAIGN-11) — MinAgent 0.125.0
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:
2026-08-05 17:48:08 +02:00
parent a315d623b8
commit a3499d1807
12 changed files with 778 additions and 20 deletions
@@ -0,0 +1,344 @@
package web
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"os"
"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/settings"
)
// CAMPAIGN-11 fixes — R-216, R-217, R-219, R-222, R-215.
//
// HANDLER-LEVEL throughout. Every assertion below is on the RENDERED PAGE or an on-disk effect,
// because each of these defects lived in the handler while a helper-level test looked green: R-215's
// predicate was correct and its page never asked, and R-217's failure branch built a struct whose
// zero value the TEMPLATE misread. A test that stops at the helper observes neither.
// postUnlockWith drives the real unlock handler with a code.
func postUnlockWith(t *testing.T, s *Server, code string) *httptest.ResponseRecorder {
t.Helper()
form := url.Values{"recovery_code": {code}}
req := httptest.NewRequest(http.MethodPost, "/recovery/unlock", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
s.recoveryUnlockHandler(rr, req)
return rr
}
// blamesTyping reports whether the page tells the customer to check how they typed their code. It is
// the ONE sentence that may only ever appear for a genuinely wrong code.
func blamesTyping(body string) bool {
return strings.Contains(body, "z szót pontosan") || strings.Contains(body, "nem fogadtuk el")
}
// ── SCENARIO A — an agent that cannot answer is NAMED, never the customer's code (R-216) ─────────
//
// THE HEADLINE. Measured live 2026-08-05: agent 0.120.0 404s the recovery route, the unlock was
// attempted anyway, and a CORRECT code came back as "we did not accept your recovery code, check
// that all ten words…" in 0.134 s.
//
// RED-PROOF: delete the `if support := s.recoverySupport(...)` block from recoveryUnlockHandler and
// this test FAILS on the blamesTyping assertion — the accusation returns verbatim. Demonstrated
// failing before this test was kept.
func TestRecoveryGate_A_OldAgentIsNamed_NeverTheCode(t *testing.T) {
for _, tc := range []struct {
name string
state agentapi.SupportState
}{
{"agent predates the route (404)", agentapi.SupportNo},
{"agent cannot be asked at all", agentapi.SupportUnknown}, // fail-CLOSED: §7.1
} {
t.Run(tc.name, func(t *testing.T) {
f := newRecoveryFixture(t)
f.s.SetRecoverySupport(func(context.Context) agentapi.SupportState { return tc.state })
// Model the REAL agent: one that cannot answer the route also fails the call. Without
// this the fake would succeed and the red-proof could not show the accusation returning —
// the mutation would fail the test for weaker reasons than the finding itself.
f.rec.fail = true
rr := postUnlockWith(t, f.s, testRecoveryCode)
if rr.Code != http.StatusOK {
t.Fatalf("unlock = %d", rr.Code)
}
body := rr.Body.String()
// EFFECT 1 — the customer is NOT blamed. This is the whole finding.
if blamesTyping(body) {
t.Fatal("R-216 RETURNED: an agent that cannot answer is still reported as a wrong recovery code")
}
// EFFECT 2 — the machine is named as the thing that cannot do it yet.
if !strings.Contains(body, "Ez a gép még nem tudja") {
t.Errorf("the refusal must say THE MACHINE cannot do this yet; got: %q", firstAlert(body))
}
// EFFECT 3 — it says the code is fine and was not consumed, so the customer keeps it.
if !strings.Contains(body, "kódoddal semmi baj") {
t.Error("the refusal must reassure the customer that their code is fine")
}
// EFFECT 4 — no attempt was made. An attempt that cannot succeed must never be made,
// because its failure is what got attributed to the code.
if n := len(f.rec.codes); n != 0 {
t.Fatalf("the handler attempted an unlock it could not complete (%d attempt(s))", n)
}
})
}
}
// The supported case still works — the gate must not refuse everything.
func TestRecoveryGate_A_SupportedAgentProceeds(t *testing.T) {
f := newRecoveryFixture(t)
rr := postUnlockWith(t, f.s, testRecoveryCode)
if rr.Code != http.StatusOK {
t.Fatalf("unlock = %d", rr.Code)
}
if len(f.rec.codes) != 1 {
t.Fatalf("a supported agent must be asked exactly once, got %d", len(f.rec.codes))
}
if _, ok := f.mgr.OffboxRepoPasswordHash(); !ok {
t.Fatal("the recovered key was not placed")
}
}
// The declared coupling itself: the table must carry the row, at the version that ships the route.
func TestRecoveryGate_A_CouplingIsDeclared(t *testing.T) {
if got := agentapi.MinAgentFor(agentapi.FeatureOffsiteKeyRecovery); got != "0.125.0" {
t.Fatalf("FeatureOffsiteKeyRecovery MinAgent = %q, want 0.125.0 (the version shipping POST /escrow/recover-offsite-password)", got)
}
}
// ── SCENARIO G — opened / empty / unreadable are THREE sentences (R-217) ─────────────────────────
//
// RED-PROOF: replace `s.renderRecoveryUnlockedNoList(w, r, msg)` with
// `s.renderRecovery(w, r, msg, "", &backup.OffsiteInventory{})` and this test FAILS — the zero value's
// Empty=false falls through to .InvUntagged and the page claims the store opened with content it
// could not attribute. Demonstrated failing before this test was kept.
func TestRecoveryGate_G_UnreadableStoreNeverClaimsToHaveOpened(t *testing.T) {
f := newRecoveryFixture(t)
// The tier is up, but reading the repository fails.
f.mgr.SetOffboxRunner(func(context.Context, []string, ...string) ([]byte, error) {
return nil, context.DeadlineExceeded
})
body := postUnlockWith(t, f.s, testRecoveryCode).Body.String()
if strings.Contains(body, "van benne tartalom") {
t.Fatal("R-217 RETURNED: an UNREADABLE store is reported as opened, with unattributable content")
}
if strings.Contains(body, "nincs benne egyetlen") {
t.Fatal("an unreadable store must not be reported as opened-and-empty either")
}
// It says the key is safe and what is pending — no claim about the contents.
if !strings.Contains(body, "kulcs visszakerült") {
t.Errorf("the customer must be told the key came back; got: %q", firstAlert(body))
}
}
// The three states must be DISTINGUISHABLE, which is the actual requirement.
func TestRecoveryGate_G_ThreeDistinctStates(t *testing.T) {
// (1) opened, with apps.
f1 := newRecoveryFixture(t)
f1.runner.snapshots = []map[string]any{{"short_id": "aaa1111", "time": "2026-08-01T10:00:00Z", "tags": []string{"immich"}}}
b1 := postUnlockWith(t, f1.s, testRecoveryCode).Body.String()
// (2) opened, empty.
f2 := newRecoveryFixture(t)
f2.runner.snapshots = nil
b2 := postUnlockWith(t, f2.s, testRecoveryCode).Body.String()
// (3) could not be read.
f3 := newRecoveryFixture(t)
f3.mgr.SetOffboxRunner(func(context.Context, []string, ...string) ([]byte, error) {
return nil, context.DeadlineExceeded
})
b3 := postUnlockWith(t, f3.s, testRecoveryCode).Body.String()
if !strings.Contains(b1, "immich") {
t.Error("state 1 (opened, with apps) must list the app")
}
if !strings.Contains(b2, "nincs benne egyetlen") {
t.Error("state 2 (opened, empty) must say so explicitly")
}
if strings.Contains(b3, "nincs benne egyetlen") || strings.Contains(b3, "van benne tartalom") {
t.Error("state 3 (unreadable) must claim nothing about the contents")
}
}
// ── SCENARIO H — the retained earlier package is NAMED, not blamed on the customer (R-222) ───────
func TestRecoveryGate_H_SupersededPackageIsNamed(t *testing.T) {
f := newRecoveryFixture(t)
f.rec.fail = true // the unseal fails against the CURRENT package — the real engine behaviour
if err := f.sett.SetHubEscrowSuperseded(true, "2026-08-05T15:03:14Z"); err != nil {
t.Fatal(err)
}
body := postUnlockWith(t, f.s, testRecoveryCode).Body.String()
if blamesTyping(body) {
t.Fatal("R-222 RETURNED: a code that is RIGHT about a retained earlier package is blamed on the customer's typing")
}
if !strings.Contains(body, "nem töröltük") {
t.Errorf("the customer must be told the earlier package is kept; got: %q", firstAlert(body))
}
if !strings.Contains(body, "2026-08-05T15:03:14Z") {
t.Error("when the hub knows WHEN the earlier package was set aside, say so")
}
// §7.6 — it may state the facts and must NOT promise the earlier package can be opened.
for _, forbidden := range []string{"vissza tudod állítani", "megnyithatod", "vissza fogod kapni"} {
if strings.Contains(body, forbidden) {
t.Errorf("the message promises the earlier package can be opened (%q) — the read path does not exist", forbidden)
}
}
}
// And with NO retained package the wrong-code message is unchanged — the only one mentioning typing.
func TestRecoveryGate_H_WithoutASupersededPackageItIsStillTheWrongCodeMessage(t *testing.T) {
f := newRecoveryFixture(t)
f.rec.fail = true
body := postUnlockWith(t, f.s, testRecoveryCode).Body.String()
if !blamesTyping(body) {
t.Fatal("a genuinely wrong code must still get the wrong-code message")
}
if strings.Contains(body, "nem töröltük") {
t.Fatal("a box with no retained earlier package must not claim one exists")
}
}
// ── SCENARIO I — /recovery is not reachable by typing the path (R-215) ──────────────────────────
//
// RED-PROOF: delete the `if !s.recoveryOffer()` guard from recoveryPageHandler and this test FAILS —
// a box that never had off-site backups renders "this machine was reinstalled, your off-site backups
// are there". Demonstrated failing before this test was kept.
func TestRecoveryGate_I_DirectGetRefusedOnABoxThatNeverHadBackups(t *testing.T) {
f := newRecoveryFixture(t)
// The defining fact: the hub holds nothing for this box.
if err := f.sett.SetHubEscrowIdentityPresent(false); err != nil {
t.Fatal(err)
}
if f.s.recoveryOffer() {
t.Fatal("fixture error: the box still reads as offered")
}
rr := getRecoveryPage(t, f.s)
if rr.Code == http.StatusOK && strings.Contains(rr.Body.String(), "jratelep") {
t.Fatal("R-215 RETURNED: a box that never had off-site backups renders the recovery story on a direct GET")
}
if rr.Code != http.StatusFound {
t.Errorf("a direct GET on a non-offered box should redirect away, got %d", rr.Code)
}
}
// A box that IS in the situation still reaches the page directly — including after "most nem",
// which is what makes the permanent backups-area entry point work at all.
func TestRecoveryGate_I_DirectGetStillWorksWhenOffered(t *testing.T) {
f := newRecoveryFixture(t)
if err := f.sett.SetRecoveryNoticePostponed(true); err != nil {
t.Fatal(err)
}
if f.s.recoveryInterrupts() {
t.Fatal("fixture error: postpone did not suppress the interruption")
}
rr := getRecoveryPage(t, f.s)
if rr.Code != http.StatusOK {
t.Fatalf("an offered box must still reach /recovery directly after a postpone, got %d", rr.Code)
}
}
// ── SCENARIO F — the unlock finishes the job: key placed, tier brought up, then the listing ──────
func TestRecoveryGate_F_UnlockBringsTheTierUpBeforeListing(t *testing.T) {
f := newRecoveryFixture(t)
// The pristine rebuilt shape: no coordinates at all, so the listing is impossible until the tier
// comes up. Removing the target is what makes this the shape (a) the screen exists for.
if err := f.sett.SetOffboxTarget(nil); err != nil {
t.Fatal(err)
}
called := 0
f.s.SetRecoveryTierUp(func(context.Context) error {
called++
// Bringing the tier up = coordinates exist afterwards.
return f.sett.SetOffboxTarget(&settings.OffboxTarget{
Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo",
Schedule: "daily", EscrowState: "escrowed",
})
})
f.runner.snapshots = []map[string]any{{"short_id": "bbb2222", "time": "2026-08-02T09:00:00Z", "tags": []string{"calibre-web"}}}
body := postUnlockWith(t, f.s, testRecoveryCode).Body.String()
if called != 1 {
t.Fatalf("the unlock must bring the tier up exactly once, got %d", called)
}
if !strings.Contains(body, "calibre-web") {
t.Fatalf("the listing the screen PROMISES did not render; got: %q", firstAlert(body))
}
}
// A tier that cannot come up yet is PENDING, not a failure, and never a wrong code.
func TestRecoveryGate_F_TierNotUpYetSaysPendingNotFailed(t *testing.T) {
f := newRecoveryFixture(t)
if err := f.sett.SetOffboxTarget(nil); err != nil {
t.Fatal(err)
}
f.s.SetRecoveryTierUp(func(context.Context) error { return context.DeadlineExceeded })
body := postUnlockWith(t, f.s, testRecoveryCode).Body.String()
if blamesTyping(body) {
t.Fatal("a tier that has not come up yet must never read as a wrong code")
}
if !strings.Contains(body, "kapcsolódási adatait") {
t.Errorf("say what is pending — the connection details; got: %q", firstAlert(body))
}
// The key IS placed regardless: nothing failed.
if _, ok := f.mgr.OffboxRepoPasswordHash(); !ok {
t.Fatal("the recovered key must be placed even when the tier cannot come up yet")
}
}
// firstAlert extracts the first rendered alert body, for readable failure output.
func firstAlert(body string) string {
i := strings.Index(body, "alert-error")
if i < 0 {
i = strings.Index(body, "alert-info")
}
if i < 0 {
return "(no alert rendered)"
}
seg := body[i:]
if j := strings.Index(seg, "</div>"); j > 0 {
seg = seg[:j]
}
if len(seg) > 400 {
seg = seg[:400]
}
return strings.TrimSpace(seg)
}
// dataDirHasNoCode asserts the recovery code never reached disk — the sweep, with a positive control
// in the caller. Kept here because every path above handles a code.
func dataDirHasNoCode(t *testing.T, dir, code string) {
t.Helper()
var hits []string
_ = filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
if err != nil || info == nil || info.IsDir() {
return nil
}
b, rerr := os.ReadFile(p)
if rerr == nil && strings.Contains(string(b), code) {
hits = append(hits, p)
}
return nil
})
if len(hits) > 0 {
t.Fatalf("the recovery code was written to disk: %v", hits)
}
}
var _ = backup.HashResticPassword
+168 -7
View File
@@ -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)
+6
View File
@@ -16,6 +16,7 @@ import (
"testing"
"time"
"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"
@@ -127,6 +128,11 @@ func newRecoveryFixture(t *testing.T) *recoveryFixture {
s := &Server{cfg: cfg, settings: sett, backupMgr: mgr, stackMgr: stackMgr, logger: lg, version: "test"}
s.loadTemplates()
s.SetRecoveryRecoverer(func() (backup.OffsiteKeyRecoverer, error) { return rec, nil })
// R-216: the capability gate FAILS CLOSED, so the default fixture states the supported case
// explicitly. Without this every unlock test would exercise the refusal instead — which is exactly
// the protection working, and exactly not what those tests are about. The refusal has its own
// tests in recovery_gate_test.go, each overriding this.
s.SetRecoverySupport(func(context.Context) agentapi.SupportState { return agentapi.SupportYes })
return &recoveryFixture{s: s, mgr: mgr, sett: sett, rec: rec, runner: rr, dataDir: cfg.Paths.DataDir}
}
+7
View File
@@ -104,6 +104,13 @@ type Server struct {
// recoveryRecovererFn is the recovery screen's agent seam (nil → the shared agentClient(), the
// same channel the CLI uses). Tests inject a fake so the HANDLER itself can be driven.
recoveryRecovererFn func() (backup.OffsiteKeyRecoverer, error)
// recoverySupportFn overrides the R-216 agent-capability verdict for the unlock path (tests).
// nil → the real gate over netFeatures. See recoverySupport: Unknown means CANNOT ASK here.
recoverySupportFn func(context.Context) agentapi.SupportState
// recoveryTierUp brings the off-site tier up between placing a recovered key and reading the
// repository (R-219). Wired from main.go to the apply-bridge's Reconcile; nil → skipped, and the
// listing branch then reports what is pending rather than claiming a failure.
recoveryTierUp func(context.Context) error
// 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).
@@ -18,7 +18,12 @@
{{if .Flash}}<div class="alert alert-info">{{.Flash}}</div>{{end}}
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
{{if .InvEmpty}}
{{if .InvUnavailable}}
<!-- R-217: the unlock SUCCEEDED but the repository could not be read. It renders NO listing and
claims nothing about the contents — the .Error above already says what is pending. This
branch exists because the previous code passed a zero-value OffsiteInventory here, whose
Empty=false fell through to .InvUntagged and asserted the store had opened with content. -->
{{else if .InvEmpty}}
<div class="alert alert-warning">
A tároló megnyílt, de <strong>nincs benne egyetlen mentés sem</strong>. Ez azt jelenti, hogy a
kulcs jó volt, de ehhez a géphez nem tartozik korábbi mentés. Ha korábban biztosan készültek