Compare commits

..

3 Commits

Author SHA1 Message Date
admin 7534ea203d CHANGELOG: controller v0.202.0 (MinAgent 0.126.0)
gates / gates (push) Successful in 10s
Declares the coupling: an agent below 0.126.0 answers 400 for both a fetch
failure and a wrong code, so FeatureRecoveryFailureClass withholds the refusal
reading and the 400 degrades to the neutral message. The gate blocks nothing —
it only decides whether the customer may be told to check their typing.
2026-08-06 08:19:07 +02:00
admin c7446f2d6a R-225/R-227/R-228 Parts 2-4: unknown is not zero, the gateway speaks Hungarian, the set-aside is visible
R-225 — an unread store said '0 pillanatkép / 0 / 50 GB' above a card stating
it held backups under another key. An SFTP listing found snapshot f3d9cd67 and
12 535 KB really there; snapshot_count and repo_size_bytes were simply ABSENT
and the zero value spoke for them. StatsKnown is now NAMED, for the same reason
OffsiteInventory.Empty is: zero is what an unread store and an empty one both
look like, and on the wire 'absent' and '0' are the same bytes. The fill bar
renders only when the fill is known — a 0%-wide bar is a picture of emptiness,
and a picture is a claim. A measured zero still says zero.

R-227 — WHICH LAYER ANSWERS: traefik, and this repo generates its config. But
traefik v3 serves no static files, so a branded proxy page needs a new always-up
container for every 502 on the box — out of proportion, and scoped in the report
rather than built. Shipped instead: the unlock posts via fetch and answers a
gateway failure in Hungarian without leaving the page. Progressive enhancement —
with no JS the plain POST is unchanged and still shows the proxy's error, which
the report says plainly rather than implying otherwise.

R-228 — the set-aside history was recorded in orphaned_renamed_to and read by
nobody: a census found zero references in any template or handler, while 12 535
KB sat at that path. It is surfaced as two facts and stops. It does NOT promise
the history can be reopened, because it cannot be by anyone today (R-199's
inventory is unbuilt) — and the set-aside CONFIRMATION copy was corrected for
the same reason: 'a helyreállítási kód nélkül többé nem lesznek megnyithatók'
implied that WITH the code they could be. The field's own comment called it
'recovery-code-recoverable', which was the same over-promise in the code.

Tests: scenarios F, G, H as render tests per branch of each gate. Red-proofs,
each demonstrated failing then restored: remove the StatsKnown guards (F,
'R-225 RETURNED: an unread store reports a snapshot COUNT of zero'), delete the
set-aside block (H). The F assertion on the fill bar is scoped to the bar's own
container — a bare width:0% search matched unrelated elements and would have
passed for the wrong reason.

28 packages ok, vet clean, all controller gates OK (the emoji gate caught a
warning sign in a template comment).
2026-08-06 08:17:48 +02:00
admin 1e759a16ec R-224/R-226 Part 1: why the unlock failed decides what we say
The failure branch was a two-way choice — superseded? M4 : M1 — and BOTH are
statements about the customer's code. rerr was never inspected, so a hub that
refused, an agent that was stopped and a genuinely mistyped code all produced
the same accusation. Measured live 2026-08-05 with a CORRECT current code: hub
firewalled off 0.0556s, agent stopped 0.0299s, against ~1.0s for a real unseal.

Five classes, from the VALUE and never the text:
  hub-unreachable    502/503 from the agent — the code was NOT used
  agent-unreachable  no agent verdict at all (transport) — NOT used
  no-bundle          404
  bundle-too-old     409
  asked-and-refused  400 — the ONLY class that may mention typing
  unknown            everything else -> NEUTRAL, the safe default

agentapi.RecoveryRefusal carries the status as a value (refusalError flattened
it into a sentence, and a sentence is not something a caller can branch on).

THE OLD-AGENT CASE IS WHY THIS NEEDS A COUPLING. Agent < 0.126.0 answers 400
for both a fetch failure and a wrong code, so a 400 from one cannot be read as
a refusal. FeatureRecoveryFailureClass (MinAgent 0.126.0) withholds that
reading and the 400 degrades to neutral. The gate BLOCKS NOTHING — it only
decides whether the customer may be told to check their typing.

R-226: the superseded message now names BOTH possibilities and restores the
ten-words prompt. The two are indistinguishable at the engine; the honest
message says so. It still does not promise the earlier package can be opened.

Elapsed time is logged (it is what diagnosed this) and is NEVER a classifier.

Tests: scenarios A-E at the HANDLER + the classifier table asserting the same
sentence under two statuses classifies two ways. Red-proofs, each demonstrated
failing then restored: delete the 502 case (A), remove the mistype clause (C),
default to the accusation (D), route an instant transport failure to the typing
message (E).

Two existing tests encoded the defect and were corrected, not deleted: the web
fake returned a BARE error for 'wrong code' (which is the shape of a failure we
cannot classify), and R-222's test forbade any mention of typing on a
superseded box — half of which R-226 deliberately reverses.

28 packages ok, vet clean, all controller gates OK.
2026-08-06 08:06:57 +02:00
14 changed files with 913 additions and 18 deletions
+93
View File
@@ -1,3 +1,96 @@
## v0.202.0 — the customer is blamed only after a real attempt refused their code (2026-08-06, R-224/R-226/R-225/R-227/R-228) — MinAgent 0.126.0
**CAMPAIGN-11's headline defect had moved, not gone.** v0.201.0 stopped an agent that is too OLD from
being reported as a wrong recovery code. An agent that is **stopped**, and a hub that cannot be
**reached**, still fell through to a message about the code — measured live on 2026-08-05 with a
**correct, current** code at **0.0299 s** and **0.0556 s**, against ~1.0 s for a genuine unseal. The
machine had not tried, and told the customer their code was wrong.
**And the inverse was true at the same time (R-226).** The one message that says *"check your ten
words"* was tested AFTER the retained-earlier-package message, so on any box that has re-escrowed —
precisely the box whose customer has just been handed a new code — a genuine mistype could never
reach it. Three unrelated failures got the accusation; the one that deserved it got something else.
**One defect from both ends: nothing on that path asked WHY it failed.** `rerr` was never inspected.
### The rule, and it is the whole change
> **The customer is blamed only after a real attempt refused their code. Every other outcome —
> including one we cannot classify — says something else.**
Five classes, **from the value and never the text**:
| class | from | what the customer is told |
|---|---|---|
| hub-unreachable | 502/503 | the connection failed; **the code was NOT used** |
| agent-unreachable | no agent verdict at all | the machine's own service is not answering; **NOT used** |
| no-bundle | 404 | nothing is held for this machine; not about the code |
| bundle-too-old | 409 | the code WORKED; the package predates the field |
| **asked-and-refused** | **400** | **the only class that may mention typing** |
| unknown | anything else | **neutral — claims neither that the code was wrong nor that it went unused** |
`agentapi.RecoveryRefusal` carries the status as a value; `refusalError` flattened it into a sentence,
and a sentence is not something a caller can branch on.
**THE SAFE DEFAULT IS THE POINT.** `RecoveryUnknown` is the zero value, and an unrecognised status
lands there rather than in an accusation. **That is the rule whose absence let this survive being
fixed once.**
**Coupling — `MinAgent 0.126.0`.** An agent below it answers **400 for both** a fetch failure and a
wrong code, so a 400 from one cannot be read as a refusal. `FeatureRecoveryFailureClass` withholds
that reading and the 400 degrades to **neutral**. The gate **blocks nothing** — the unlock is
attempted either way — it only decides whether the customer may be told to check their typing, and
"not sure" means they may not. It heals itself when the agent updates.
**Elapsed time is logged** (it is what diagnosed this, and it is the cheapest tell for the operator)
**and is never the classifier.** Time is a symptom; the status is the fact.
### R-225 — unknown is not zero
An unread store rendered `Tároló méret · 0 pillanatkép` and `Tárhelykeret: 0 / 50 GB (0%)` **directly
above a card saying the store held backups under another key**. An SFTP listing found snapshot
`f3d9cd67` and **12 535 KB** really there; `snapshot_count` and `repo_size_bytes` were simply ABSENT
from `settings.json` and the zero value spoke for them. `StatsKnown` is **named**, for the same reason
`OffsiteInventory.Empty` is — zero is what an unread store and an empty one both look like, and both
fields are `omitempty` ints, so on disk "absent" and "0" are the same bytes. The fill bar renders only
when the fill is known: **a 0 %-wide bar is a picture of emptiness, and a picture is a claim.** A
*measured* zero still says zero.
### R-227 — the gateway error
**Which layer answers: traefik**, whose config this repo generates. A branded proxy page is therefore
possible here, but traefik v3 serves no static files, so it would need a **new always-up container**
for every 502 on the box — out of proportion to this finding, and **scoped in the report rather than
built**. Shipped instead: the unlock posts via `fetch`, so a gateway failure is answered in Hungarian
without leaving the page. **Progressive enhancement** — with no JS the plain POST is unchanged and
still shows the proxy's error, and this entry says so rather than implying otherwise.
### R-228 — the set-aside history is visible, and honest
A customer who chose "I do not want the old data" was told the backups would be **kept**, and the
move-aside did exactly that — 12 535 KB, byte-exact. The box recorded the path in
`orphaned_renamed_to` and **a census found zero references to it in any template or handler.** It is
surfaced now as two facts and stops.
**It does NOT promise the history can be reopened, and the confirmation copy was corrected for the
same reason.** *"a helyreállítási kód nélkül többé nem lesznek megnyithatók"* implied that **with**
the code they could be; serving a superseded package is an unbuilt link (R-199's inventory), so it
cannot be opened by the customer, the operator, or anyone. The field's own comment called it
*"recovery-code-recoverable"* — the same over-promise, in the code.
### Tests
Scenarios A-H at the **handler** and as **render tests per branch of each gate**. Red-proofs, each
demonstrated failing and restored: delete the 502 case (A) - remove the mistype clause (C) - default
to the accusation (D) - route an instant transport failure to the typing message (E) - remove the
`StatsKnown` guards (F) - delete the set-aside block (H).
**Two existing tests encoded the defect and were corrected rather than deleted.** The web fake
returned a **bare** error for "wrong code" — which is the shape of a failure we cannot classify, and
now correctly renders the neutral message; saying "wrong code" in a test requires saying it the way
the agent says it. And R-222's test forbade **any** mention of typing on a superseded box, **half of
which R-226 deliberately reverses**: what stays forbidden is the bare accusation, not the hint.
## v0.201.0 — a correct recovery code is never called wrong again (2026-08-05, CAMPAIGN-11) — MinAgent 0.125.0
CAMPAIGN-11 walked the whole recovery journey end to end for the first time. **The data came back
+112 -2
View File
@@ -3,6 +3,7 @@ package agentapi
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
)
@@ -132,8 +133,11 @@ func (c *Client) RecoverOffsiteRepoPassword(ctx context.Context, recoveryCode st
if perr != nil {
return "", "", perr
}
if rerr := refusalError("/escrow/recover-offsite-password", status, env); rerr != nil {
return "", "", rerr
// R-224: this route's refusal keeps its STATUS as a value. `refusalError` flattens status into a
// sentence, and a sentence is not something a caller can branch on — which is exactly how a failed
// fetch and a wrong recovery code came to produce one customer-facing message.
if status < 200 || status > 299 || !env.OK {
return "", "", &RecoveryRefusal{Status: status, Reason: truncateErr(env.Error, 300)}
}
var out struct {
ResticRepoPassword string `json:"restic_repo_password"`
@@ -147,3 +151,109 @@ func (c *Client) RecoverOffsiteRepoPassword(ctx context.Context, recoveryCode st
}
return out.ResticRepoPassword, out.ResticPwSHA256, nil
}
// ── R-224 — CLASSIFYING A FAILED UNLOCK ─────────────────────────────────────────────────────────
//
// CAMPAIGN-11 measured what happens without this. On 2026-08-05, with a CORRECT current recovery
// code: the hub firewalled off returned the customer "this code does not open your package" in
// 0.0556 s, and this agent stopped returned the same in 0.0299 s — against ~1.0 s for a genuine
// unseal. Neither attempted one. The failure path had exactly two branches, both of them statements
// about the customer's code, and `rerr` was never inspected.
//
// The rule this type exists to enforce: **the customer is blamed only after a real attempt refused
// their code.** Everything else — including anything we cannot classify — says something else.
// RecoveryRefusal is the agent's refusal of an unlock, carrying the STATUS as a value so callers
// classify on it rather than on the sentence. The message keeps `refusalError`'s shape so operator
// logs read as they did.
type RecoveryRefusal struct {
Status int
Reason string
}
func (e *RecoveryRefusal) Error() string {
reason := e.Reason
if reason == "" {
reason = "(no reason in agent response)"
}
return fmt.Sprintf("agentapi: POST /escrow/recover-offsite-password: HTTP %d: %s", e.Status, reason)
}
// RecoveryFailure is what went wrong, as far as it can be known.
type RecoveryFailure int
const (
// RecoveryUnknown — the cause could not be determined. **The safe default**, and deliberately the
// zero value: a new status, a transport shape nobody anticipated, or an agent too old to
// distinguish fetch from refusal all land here, and none of them may blame the customer.
RecoveryUnknown RecoveryFailure = iota
// RecoveryHubUnreachable — the agent answered, and it could not FETCH the sealed package: the hub
// refused, was unreachable, or recovery is not configured on this agent. **The code was not used.**
RecoveryHubUnreachable
// RecoveryAskedAndRefused — the bundle was fetched and the code did not open it. The ONLY class
// from which the customer may be told to check their typing.
RecoveryAskedAndRefused
// RecoveryNoBundle — the hub holds no sealed package for this host at all.
RecoveryNoBundle
// RecoveryBundleTooOld — the bundle opened but predates the repository-password field.
RecoveryBundleTooOld
// RecoveryAgentUnreachable — the machine's own in-house service never answered, so there is no
// agent verdict at all. **The code was not used.** Distinct from RecoveryHubUnreachable because
// it is a different fault, with different words and a different remedy.
RecoveryAgentUnreachable
)
// ClassifyRecoveryFailure maps an unlock error to its class, from the VALUE and never the text.
//
// ⚠ `trustRefusal` is the agent-version gate and it is not optional. An agent older than v0.126.0
// answers **400 for BOTH** a fetch failure and a wrong code, so a 400 from one cannot be read as
// "the code was refused" — it means "one of two things, and we cannot tell which". Pass false there
// and the 400 degrades to RecoveryUnknown, which is neutral. That degradation is the point: it is
// safe, it is silent, and it heals itself when the agent updates.
func ClassifyRecoveryFailure(err error, trustRefusal bool) RecoveryFailure {
if err == nil {
return RecoveryUnknown
}
var ref *RecoveryRefusal
if !errors.As(err, &ref) {
// Not a refusal at all — the request never produced an agent verdict (dial failure, TLS,
// timeout, or the channel could not be built). The machine could not even ASK its own service,
// which is a different sentence from "the hub was unreachable" and a different thing to fix.
return RecoveryAgentUnreachable
}
switch ref.Status {
case http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout:
// 502 is agent >= v0.126.0's "the sealed bundle could not be fetched". 503 is its
// "recovery is not configured on this agent (no hub client)". Neither used the code.
return RecoveryHubUnreachable
case http.StatusNotFound:
return RecoveryNoBundle
case http.StatusConflict:
return RecoveryBundleTooOld
case http.StatusBadRequest:
if trustRefusal {
return RecoveryAskedAndRefused
}
return RecoveryUnknown
default:
return RecoveryUnknown
}
}
// String names the class for the operator log. The customer never sees these words.
func (f RecoveryFailure) String() string {
switch f {
case RecoveryHubUnreachable:
return "hub-unreachable"
case RecoveryAgentUnreachable:
return "agent-unreachable"
case RecoveryAskedAndRefused:
return "asked-and-refused"
case RecoveryNoBundle:
return "no-bundle"
case RecoveryBundleTooOld:
return "bundle-too-old"
default:
return "unknown"
}
}
+22
View File
@@ -55,6 +55,21 @@ const FeatureBackupAgeState Feature = "backup_age_state"
// never have worked is attributed to the code.
const FeatureOffsiteKeyRecovery Feature = "offsite_key_recovery"
// FeatureRecoveryFailureClass is agent v0.126.0's SPLIT of a failed unlock into distinguishable
// statuses (R-224): 502 the sealed bundle could not be FETCHED · 400 it was fetched and the code was
// refused · 404 no bundle · 409 the bundle predates the repository-password field.
//
// ⚠ WHAT THIS GATE ACTUALLY GUARDS is the meaning of **400**, and nothing else. An agent older than
// v0.126.0 answers 400 for BOTH a fetch failure and a wrong code — one status, one sentence, two
// situations — so on such an agent a 400 cannot be read as "the code was refused". It means "one of
// two things and we cannot tell which", which is `RecoveryUnknown`, which is neutral.
//
// So this gate does not block anything and has no fail-closed behaviour to get wrong: the unlock is
// attempted either way (FeatureOffsiteKeyRecovery already decides THAT). It only decides whether the
// customer may be told to check their typing. Unknown → they may not. **That is the safe direction,
// and it heals itself the moment the agent updates.**
const FeatureRecoveryFailureClass Feature = "recovery_failure_class"
// SupportState is a probe verdict. The zero value is SupportUnknown (fail-open: unknown never
// refuses — the existing agent-error paths speak honestly when the agent is down).
type SupportState int
@@ -122,6 +137,10 @@ var featureProbes = map[Feature]func(ctx context.Context, p SupportProber) error
FeatureOffsiteKeyRecovery: func(ctx context.Context, p SupportProber) error {
return errNoRecoveryProbe
},
// Same POST route, same reason it cannot be probed — the decision falls to the VERSION path.
FeatureRecoveryFailureClass: func(ctx context.Context, p SupportProber) error {
return errNoRecoveryProbe
},
}
// errNoMemoryProbe classifies to SupportUnknown (not a *StatusError 404), so a prober that cannot be
@@ -145,6 +164,9 @@ var featureMinAgent = map[Feature]string{
// R-199 links 78: POST /escrow/recover-offsite-password. R-216 — this row is the whole reason a
// correct recovery code can no longer be reported as wrong on an agent that cannot answer.
FeatureOffsiteKeyRecovery: "0.125.0",
// R-224 — the four-way status split of a failed unlock.
FeatureRecoveryFailureClass: "0.126.0",
}
// MinAgentFor returns the declared minimum agent version for a feature ("" when the feature has no
+3
View File
@@ -496,6 +496,7 @@ func (m *Manager) ApplyOffsiteTarget(ctx context.Context, tgt *settings.OffboxTa
// "never succeeded" every time the hub re-pushes the descriptor.
tgt.LastSuccess = cur.LastSuccess
tgt.RepoSizeHuman, tgt.RepoSizeBytes, tgt.SnapshotCount = cur.RepoSizeHuman, cur.RepoSizeBytes, cur.SnapshotCount
tgt.StatsKnown = cur.StatsKnown // R-225: carry the KNOWN-ness with the numbers
}
if tgt.EscrowState != "escrowed" {
tgt.EscrowState = "pending"
@@ -883,6 +884,7 @@ func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error
}
o.LastError = ""
o.SnapshotCount = snapshots
o.StatsKnown = true // R-225: measured, even if the answer is zero
o.EnlargedBlocked = blockedNames // replace each run (sorted); empty slice clears it
var warns []string
// Zero-toggle honesty (take-two obs.): a configured target with NOTHING selected reports
@@ -1419,6 +1421,7 @@ func (m *Manager) offboxRecordStats(ctx context.Context, base, env []string) int
_ = m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
o.RepoSizeHuman = human
o.RepoSizeBytes = st.TotalSize
o.StatsKnown = true // R-225
})
}
}
+29 -1
View File
@@ -225,6 +225,22 @@ type OffboxTarget struct {
// gate's input (last-known value; a failed stats call keeps the previous one — stale-but-safe).
RepoSizeBytes int64 `json:"repo_size_bytes,omitempty"`
SnapshotCount int `json:"snapshot_count,omitempty"`
// StatsKnown — R-225. Whether SnapshotCount / RepoSizeBytes above were ever actually READ from the
// repository, as opposed to never having been measured.
//
// ⚠ IT IS NAMED RATHER THAN INFERRED FROM THE COUNTS, for the same reason `OffsiteInventory.Empty`
// is: **zero is what an unread store and a genuinely empty one both look like**, and the two are
// opposite news. Both fields are `omitempty` ints, so on the wire and on disk "absent" and "0" are
// the same bytes — the distinction cannot be recovered downstream and has to be carried.
//
// Measured live on 2026-08-05 (CAMPAIGN-11, R-225): after a rebuild the box rendered
// „Tároló méret · 0 pillanatkép" and „Tárhelykeret: 0 / 50 GB (0%)" — directly above a card saying
// the store held backups made under another key. An SFTP listing found snapshot `f3d9cd67` and
// 12 535 KB really there. The keys were simply ABSENT from settings.json and the zero value spoke
// for them.
//
// Set true by the run that reads the stats, whatever it reads — a true zero is knowledge too.
StatsKnown bool `json:"stats_known,omitempty"`
// LastWarning is a customer-visible notice set on an otherwise-OK run when SOME toggled apps had
// no discoverable recovery unit (partial run). Empty on a fully-successful or failed run.
LastWarning string `json:"last_warning,omitempty"`
@@ -262,7 +278,19 @@ type OffboxTarget struct {
// OrphanedAt is the RFC3339 stamp of the orphan detection (drives the card copy).
OrphanedAt string `json:"orphaned_at,omitempty"`
// OrphanedRenamedTo records the move-aside path of the last reset (e.g. <repo>.orphaned-20260717),
// so the card/log can name where the old (recovery-code-recoverable) history was set aside.
// so the card/log can name where the old history was set aside.
//
// ⚠ R-228 (2026-08-06) — THIS WAS WRITTEN AND READ BY NOBODY, and this comment used to call the
// set-aside history "recovery-code-recoverable", which is **not true today**: serving a superseded
// blob is an unbuilt link (R-199's inventory), so the old store cannot be opened by the customer,
// the operator, or anyone else. Measured on 2026-08-05 (CAMPAIGN-11 F7): after a customer chose to
// set their old backups aside, 12 535 KB sat at this exact path and `/backups/remote` mentioned it
// nowhere — a census found zero references to this field in any template or handler. The screen
// promised the data was kept and then showed nothing.
//
// It is surfaced now, as a plain statement that an earlier history is KEPT and NOT DELETED. It must
// NOT promise the history can be reopened — that is the R-202 lesson, and a conditional promise
// that turns out false is worse on this surface than saying less.
OrphanedRenamedTo string `json:"orphaned_renamed_to,omitempty"`
}
@@ -0,0 +1,144 @@
package web
import (
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// ── SCENARIO F (R-225) — AN UNREAD STORE SAYS "UNKNOWN", NEVER "ZERO" ───────────────────────────
//
// Measured live on 2026-08-05 (CAMPAIGN-11): after a rebuild this page rendered
//
// Tároló méret · 0 pillanatkép Tárhelykeret: 0 / 50 GB (0%)
//
// directly above a card stating the store contains backups made under another key. An SFTP listing
// of the remote — read-only, no decryption — found snapshot `f3d9cd67` and **12 535 KB** really
// there. `snapshot_count` and `repo_size_bytes` were simply ABSENT from settings.json, and the zero
// value spoke for them.
//
// This is R-217's defect class one card over: a field whose zero is indistinguishable from a real
// measurement, defaulted past on an unknown path. `StatsKnown` is the named state, for the same
// reason `OffsiteInventory.Empty` is named rather than inferred from `len(Apps)==0`.
//
// Render tests per branch of the gate, because a template gate without one is the v0.70.1 lesson.
func remoteStatsData(known bool, snaps int, human string) map[string]interface{} {
d := splitTestData()
d["Offbox"] = &settings.OffboxTarget{
Enabled: true, Host: "nas.local", User: "felhom", RepoPath: "/srv/repo",
LastStatus: "error", EscrowState: "escrowed", QuotaGB: 50,
SnapshotCount: snaps, RepoSizeHuman: human, StatsKnown: known,
}
d["OffboxQuotaPct"] = 0
return d
}
// The branch the finding was measured on: nothing has ever been read.
func TestBackupsRemote_UnknownStoreStatsSayUnknownNotZero(t *testing.T) {
html := renderBackupPage(t, "backups_remote", remoteStatsData(false, 0, ""))
// RED-PROOF: set StatsKnown true in this fixture (or drop the template guard) → the page renders
// "0 pillanatkép" and "0 / 50 GB (0%)" again → this FAILS. Demonstrated failing before it was kept.
if strings.Contains(html, "0 pillanatkép") {
t.Fatalf("R-225 RETURNED: an unread store reports a snapshot COUNT of zero")
}
if !strings.Contains(html, "a pillanatképek száma még ismeretlen") {
t.Fatalf("an unread store must say the count is not known")
}
if strings.Contains(html, "0 / 50 GB") {
t.Fatalf("R-225 RETURNED: an unread store reports a used figure of zero")
}
if !strings.Contains(html, "még nem tudjuk, mennyi van a tárolóban") {
t.Fatalf("an unread store must say the used figure is not known")
}
// A 0%-wide fill bar is a PICTURE of emptiness, and a picture is a claim. Scoped to the quota
// bar's own container — the page has other zero-width elements and a bare "width:0%" search
// would pass or fail for unrelated reasons.
if bar := between(html, `id="offbox-quota-bar"`, "</div>\n </div>"); strings.Contains(bar, "background:var(--border") {
t.Fatalf("the fill bar rendered over an unread store — a 0%% bar asserts emptiness")
}
}
// The other branch: a store that WAS read and is genuinely empty must still say zero. Without this,
// the fix could be "never show a number", which loses real information.
func TestBackupsRemote_KnownEmptyStoreStillSaysZero(t *testing.T) {
html := renderBackupPage(t, "backups_remote", remoteStatsData(true, 0, ""))
if !strings.Contains(html, "0 pillanatkép") {
t.Fatalf("a store that was READ and holds nothing must say zero — that is knowledge")
}
if strings.Contains(html, "még ismeretlen") {
t.Fatalf("a measured zero must not be dressed up as unknown")
}
}
// And a store with real content renders it unchanged.
func TestBackupsRemote_KnownNonEmptyStoreRendersTheNumbers(t *testing.T) {
html := renderBackupPage(t, "backups_remote", remoteStatsData(true, 2, "12.0 MB"))
if !strings.Contains(html, "2 pillanatkép") {
t.Fatalf("a measured count must render")
}
if !strings.Contains(html, "12.0 MB / 50 GB") {
t.Fatalf("a measured size must render against the quota")
}
if strings.Contains(html, "még ismeretlen") || strings.Contains(html, "még nem tudjuk") {
t.Fatalf("measured stats must not read as unknown")
}
}
// between returns the slice of s after the first `from` and before the next `to` (empty when either
// marker is missing) — so an assertion can be scoped to one card instead of the whole page.
func between(s, from, to string) string {
i := strings.Index(s, from)
if i < 0 {
return ""
}
rest := s[i+len(from):]
if j := strings.Index(rest, to); j >= 0 {
return rest[:j]
}
return rest
}
// ── SCENARIO H (R-228) — THE SET-ASIDE HISTORY IS VISIBLE AND HONESTLY DESCRIBED ────────────────
//
// Measured 2026-08-05 (CAMPAIGN-11 F7): a customer chose "I do not want the old data", was told the
// backups would be KEPT and not deleted, and the move-aside did exactly that — 12 535 KB, byte-exact,
// at `/home/felhom-repo.orphaned-20260805`. The box recorded the path in `orphaned_renamed_to` and a
// census found **zero** references to it in any template or handler. The promise was kept and shown
// to nobody.
func TestBackupsRemote_SetAsideHistoryIsSurfaced(t *testing.T) {
d := splitTestData()
d["Offbox"] = &settings.OffboxTarget{
Enabled: true, Host: "nas.local", User: "felhom", RepoPath: "/srv/repo",
LastStatus: "ok", EscrowState: "escrowed", StatsKnown: true,
OrphanedRenamedTo: "/home/felhom-repo.orphaned-20260805",
}
html := renderBackupPage(t, "backups_remote", d)
// RED-PROOF: delete the {{if .Offbox.OrphanedRenamedTo}} block → this FAILS, and the set-aside
// history is invisible again. Demonstrated failing before this test was kept.
if !strings.Contains(html, "félre vannak téve") {
t.Fatalf("R-228 RETURNED: the set-aside history is not mentioned at all")
}
if !strings.Contains(html, "nem töröltük") {
t.Fatalf("the customer must be told it was NOT deleted — that is the promise being kept")
}
// §7.6 — it must NOT promise the history can be reopened. It cannot be, by anyone, today.
for _, forbidden := range []string{"visszaállítható lehet", "vissza tudod állítani", "megnyithatod", "kóddal később"} {
if strings.Contains(html, forbidden) {
t.Errorf("the set-aside notice promises the history can be reopened (%q) — the read path does not exist", forbidden)
}
}
}
// And a box that never set anything aside must not claim it did.
func TestBackupsRemote_NoSetAsideNoClaim(t *testing.T) {
html := renderBackupPage(t, "backups_remote", remoteStatsData(true, 1, "12.0 MB"))
if strings.Contains(html, "félre vannak téve") {
t.Fatal("a box with no set-aside history must not claim one exists")
}
}
@@ -101,6 +101,7 @@ func (s *Server) offboxConfigHandler(w http.ResponseWriter, r *http.Request) {
tgt.LastWarning = prev.LastWarning
tgt.EscrowState = prev.EscrowState
tgt.RepoSizeBytes = prev.RepoSizeBytes
tgt.StatsKnown = prev.StatsKnown // R-225: preserved with the numbers it qualifies
tgt.EnlargedBlocked = prev.EnlargedBlocked
}
// fork-4: enabling offsite stages the repo password to the agent for the R-escrow ceremony and marks
@@ -0,0 +1,279 @@
package web
import (
"context"
"errors"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
)
// ── R-224 / R-226 — WHY IT FAILED DECIDES WHAT WE SAY ────────────────────────────────────────────
//
// CAMPAIGN-11 measured the defect these tests pin. With a CORRECT, CURRENT recovery code:
//
// hub firewalled off → "this code does not open your package" in 0.0556 s
// agent stopped → the same sentence in 0.0299 s
// genuinely wrong code → the same sentence after 1.194 / 1.004 / 1.014 s
//
// A real unseal costs ~1 s of scrypt, so the first two had not attempted one. Three different causes,
// one accusation, and the failure path never inspected the error.
//
// Every test here asserts the EFFECT — which sentence the customer is shown — at the HANDLER, because
// a helper-level test cannot observe a mutation that lives in the handler.
// namesTyping / isBareAccusation split what the old single predicate conflated. R-226 requires the
// typing HINT on a re-escrowed box; what stays forbidden is the bare accusation that says only that.
func namesTyping(body string) bool { return strings.Contains(body, "z szót pontosan") }
func isBareAccusation(body string) bool { return strings.Contains(body, "nem fogadtuk el") }
// saysCodeWasNotUsed is the load-bearing half of every could-not-ask message: the customer must be
// told their code was not spent, because that is what makes "keep it safe and try again" honest.
func saysCodeWasNotUsed(body string) bool { return strings.Contains(body, "NEM használtuk fel") }
// refusal builds the error shape agent >= v0.126.0 returns for a given status.
func refusal(status int, reason string) error {
return &agentapi.RecoveryRefusal{Status: status, Reason: reason}
}
// ── SCENARIO A — the hub is unreachable and the customer is not blamed ───────────────────────────
//
// RED-PROOF: delete the `agentapi.RecoveryHubUnreachable` case from recoveryUnlockHandler so a 502
// falls through to the wrong-code branch → this FAILS on namesTyping, and the accusation returns
// exactly as CAMPAIGN-11 F3 measured it. Demonstrated failing before this test was kept.
func TestRecoveryClass_A_HubUnreachableNamesTheConnection(t *testing.T) {
f := newRecoveryFixture(t)
f.rec.failWith = refusal(502, "the sealed recovery bundle could not be fetched from the hub — the recovery code was NOT used and nothing was written")
body := postUnlockWith(t, f.s, testRecoveryCode).Body.String()
if namesTyping(body) || isBareAccusation(body) {
t.Fatalf("R-224 RETURNED: a hub outage is reported as a bad recovery code; got %q", firstAlert(body))
}
if !strings.Contains(body, "központi rendszer") {
t.Errorf("the message must name the connection that failed; got %q", firstAlert(body))
}
if !saysCodeWasNotUsed(body) {
t.Errorf("the customer must be told their code was NOT used; got %q", firstAlert(body))
}
// It must not invent an earlier package either — that is a different situation.
if strings.Contains(body, "nem töröltük") {
t.Errorf("a hub outage must not be dressed up as a retained earlier package; got %q", firstAlert(body))
}
}
// ── SCENARIO B — the agent is stopped and the customer is not blamed ─────────────────────────────
//
// A dial failure produces NO agent verdict at all, so it is not a *RecoveryRefusal and classifies as
// RecoveryAgentUnreachable. This is the F4 shape: connection refused to 169.254.253.1:8443.
func TestRecoveryClass_B_AgentUnreachableNamesTheMachine(t *testing.T) {
f := newRecoveryFixture(t)
f.rec.failWith = errors.New(`agentapi: POST /escrow/recover-offsite-password: dial tcp 169.254.253.1:8443: connect: connection refused`)
body := postUnlockWith(t, f.s, testRecoveryCode).Body.String()
if namesTyping(body) || isBareAccusation(body) {
t.Fatalf("R-224 RETURNED: a stopped agent is reported as a bad recovery code; got %q", firstAlert(body))
}
if !strings.Contains(body, "házon belüli") {
t.Errorf("the message must name the machine's own service; got %q", firstAlert(body))
}
if !saysCodeWasNotUsed(body) {
t.Errorf("the customer must be told their code was NOT used; got %q", firstAlert(body))
}
// The raw technical error must never reach the customer.
if strings.Contains(body, "dial tcp") || strings.Contains(body, "169.254") {
t.Errorf("the raw transport error was rendered to the customer; got %q", firstAlert(body))
}
}
// ── SCENARIO C — a genuinely wrong code says so, even on a re-escrowed box (R-226) ───────────────
//
// RED-PROOF: remove the mistype clause from the superseded message → this FAILS on namesTyping, and
// the ten-words prompt is unreachable again on exactly the population most likely to need it.
func TestRecoveryClass_C_MistypeOnAReEscrowedBoxNamesBoth(t *testing.T) {
f := newRecoveryFixture(t)
f.rec.failWith = refusal(400, "the recovery code did not open the sealed bundle — nothing was written")
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 !namesTyping(body) {
t.Fatalf("R-226 RETURNED: a mistype on a re-escrowed box is never told to re-check the words; got %q", firstAlert(body))
}
if !strings.Contains(body, "nem töröltük") {
t.Fatalf("the retained earlier package must still be named; got %q", firstAlert(body))
}
if isBareAccusation(body) {
t.Fatalf("R-222 RETURNED: the bare accusation, with no mention of the retained package")
}
// §7.3 — it 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)", forbidden)
}
}
}
// ── SCENARIO D — an unclassifiable failure never blames the customer ─────────────────────────────
//
// THE RULE THAT WAS MISSING WHEN THIS DEFECT WAS FIXED THE FIRST TIME. The default must be neutral,
// and it must claim NEITHER that the code was wrong NOR that it went unused — neither is known.
//
// RED-PROOF: change the `default:` arm to render the wrong-code message → this FAILS. That mutation
// is precisely the pre-R-224 shape, where everything unrecognised fell through to an accusation.
func TestRecoveryClass_D_UnclassifiableIsNeutral(t *testing.T) {
for _, tc := range []struct {
name string
err error
}{
{"an unrecognised status", refusal(418, "something nobody anticipated")},
{"a 400 from an agent too old to split fetch from refusal", refusal(400, "the recovery code did not open the sealed bundle, or the bundle could not be fetched")},
} {
t.Run(tc.name, func(t *testing.T) {
f := newRecoveryFixture(t)
f.rec.failWith = tc.err
if tc.name != "an unrecognised status" {
// The OLD-agent case: a 400 may not be read as a refusal.
f.s.SetRecoveryRefusalTrusted(func(context.Context) bool { return false })
}
body := postUnlockWith(t, f.s, testRecoveryCode).Body.String()
if namesTyping(body) || isBareAccusation(body) {
t.Fatalf("an unclassifiable failure blamed the customer; got %q", firstAlert(body))
}
// And it must not claim the opposite either — "we did not use your code" is also a claim.
if saysCodeWasNotUsed(body) {
t.Fatalf("an unclassifiable failure asserted the code was unused — that is not known; got %q", firstAlert(body))
}
if !strings.Contains(body, "nem tudjuk biztosan") {
t.Errorf("the neutral message must say the cause is not known; got %q", firstAlert(body))
}
})
}
}
// ── SCENARIO E — the accusing message requires a REAL attempt ────────────────────────────────────
//
// §7.2 asks for this as a TEST rather than production logic, and the distinction matters: elapsed
// time is the symptom that DIAGNOSED R-224, never a classifier. A production guard on duration would
// be a second thing that can be wrong, and §5 forbids it outright.
//
// So the guard is STRUCTURAL: the typing hint is reachable from exactly ONE class — the one that can
// only arise from a 400, which by the agent's contract means the bundle was fetched and age ran. This
// asserts that exhaustively, and the clock is injected so the assertion needs no sleeping.
//
// RED-PROOF: route any instant-failing class (502, a transport error, an unrecognised status) to the
// wrong-code message → this FAILS on that row.
func TestRecoveryClass_E_OnlyARealRefusalMayMentionTyping(t *testing.T) {
instant := []struct {
name string
err error
}{
{"hub unreachable (502)", refusal(502, "could not be fetched")},
{"recovery not configured (503)", refusal(503, "not configured")},
{"agent unreachable (transport)", errors.New("dial tcp: connection refused")},
{"no bundle (404)", refusal(404, "no sealed bundle")},
{"bundle too old (409)", refusal(409, "predates the field")},
{"unrecognised (418)", refusal(418, "unanticipated")},
}
for _, tc := range instant {
t.Run(tc.name, func(t *testing.T) {
f := newRecoveryFixture(t)
// A clock that NEVER advances: none of these performs an unseal, and the assertion below
// must hold without any wall-clock time passing.
frozen := time.Date(2026, 8, 6, 4, 0, 0, 0, time.UTC)
f.s.SetRecoveryClock(func() time.Time { return frozen })
f.rec.failWith = tc.err
body := postUnlockWith(t, f.s, testRecoveryCode).Body.String()
if namesTyping(body) || isBareAccusation(body) {
t.Fatalf("a failure that performed NO unseal mentioned typing; got %q", firstAlert(body))
}
})
}
// The positive half: the one class that DID perform an unseal may say it. Without this the test
// would pass with the typing message deleted outright.
t.Run("a real refusal (400) may mention typing", func(t *testing.T) {
f := newRecoveryFixture(t)
f.rec.failWith = refusal(400, "the recovery code did not open the sealed bundle")
body := postUnlockWith(t, f.s, testRecoveryCode).Body.String()
if !namesTyping(body) {
t.Fatalf("a genuinely refused code must be told what to check; got %q", firstAlert(body))
}
})
}
// The classifier itself, at the value level — the table the handler switches on. Kept separate from
// the handler tests so a mapping change is named directly rather than inferred from Hungarian copy.
func TestClassifyRecoveryFailure_MapsFromTheValueNotTheText(t *testing.T) {
cases := []struct {
name string
err error
trusted bool
want agentapi.RecoveryFailure
}{
{"fetch failure", refusal(502, ""), true, agentapi.RecoveryHubUnreachable},
{"not configured", refusal(503, ""), true, agentapi.RecoveryHubUnreachable},
{"refused, trusted", refusal(400, ""), true, agentapi.RecoveryAskedAndRefused},
{"refused, NOT trusted (old agent)", refusal(400, ""), false, agentapi.RecoveryUnknown},
{"no bundle", refusal(404, ""), true, agentapi.RecoveryNoBundle},
{"bundle too old", refusal(409, ""), true, agentapi.RecoveryBundleTooOld},
{"unrecognised status", refusal(418, ""), true, agentapi.RecoveryUnknown},
{"transport", errors.New("dial tcp"), true, agentapi.RecoveryAgentUnreachable},
{"nil", nil, true, agentapi.RecoveryUnknown},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := agentapi.ClassifyRecoveryFailure(tc.err, tc.trusted); got != tc.want {
t.Fatalf("got %v, want %v", got, tc.want)
}
})
}
// The text must be irrelevant: the SAME sentence under two statuses classifies two ways.
same := "the recovery code did not open the sealed bundle"
if agentapi.ClassifyRecoveryFailure(refusal(400, same), true) == agentapi.ClassifyRecoveryFailure(refusal(502, same), true) {
t.Fatal("classification followed the TEXT — it must follow the status")
}
}
// ── SCENARIO G (R-227) — A RESTART MID-UNLOCK IS ANSWERED IN HUNGARIAN ──────────────────────────
//
// Measured 2026-08-05 (CAMPAIGN-11 F8): the controller was restarted 0.7 s into an unlock and the
// customer got traefik's raw English `Bad Gateway`. The state was clean; the page was not.
//
// The layer that answers is traefik, whose config this repo generates — but traefik v3 serves no
// static files, so a branded proxy page would need a new always-up container for every 502 on the
// box. What ships is the second sanctioned option: the unlock posts via fetch and answers a gateway
// failure in the page. This asserts the handling is PRESENT and says the right thing; with no JS the
// plain POST is unchanged and still shows the proxy's error, which the report states plainly.
func TestRecoveryClass_G_GatewayErrorIsAnsweredInHungarian(t *testing.T) {
f := newRecoveryFixture(t)
body := getRecoveryPage(t, f.s).Body.String()
// RED-PROOF: delete the fetch handler from recovery.html → this FAILS, and a restart mid-unlock
// shows `Bad Gateway` again.
if !strings.Contains(body, "unlock-gateway-error") {
t.Fatal("R-227 RETURNED: the page carries no handling for a gateway failure")
}
if !strings.Contains(body, "A gép éppen újraindul") {
t.Fatal("the gateway message must say, in Hungarian, that the machine is restarting")
}
if !strings.Contains(body, "resp.status >= 500") {
t.Fatal("a 5xx from the proxy must be caught, not rendered")
}
// It must claim NOTHING about the code — whether it was used is unknown at that point.
if namesTyping(body) && !strings.Contains(body, "Helyreállítási kód (tíz szó)") {
t.Fatal("the gateway path must not blame the code")
}
// Progressive enhancement: the plain form must survive for a JS-less browser.
if !strings.Contains(body, `method="POST" action="/recovery/unlock"`) {
t.Fatal("the plain POST form must remain for browsers without JS")
}
}
+18 -2
View File
@@ -180,8 +180,24 @@ func TestRecoveryGate_H_SupersededPackageIsNamed(t *testing.T) {
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")
// ⚠ R-226 DELIBERATELY REVERSED HALF OF THIS ASSERTION (2026-08-06), so it is spelled out.
//
// As first shipped, this test forbade ANY mention of typing here — R-222's guarantee was that a
// customer holding the RIGHT code for a retained earlier package must not be told they mistyped.
// That was right about the accusation and wrong about the omission: because this branch is tested
// BEFORE the wrong-code message, it made the ten-words prompt UNREACHABLE on every box the hub
// keeps an earlier package for — which is exactly the box whose customer has just been given a new
// code and is most likely to be typing one. Measured 2026-08-05 (CAMPAIGN-11 F1): three genuinely
// wrong codes, three real ~1 s unseals, three copies of the earlier-package message.
//
// The two causes are INDISTINGUISHABLE at the engine, so the message now names BOTH. What stays
// forbidden is the bare ACCUSATION — the M1 opener that asserts the code was simply not accepted
// and says nothing about the retained package.
if strings.Contains(body, "nem fogadtuk el") {
t.Fatal("R-222 RETURNED: the bare wrong-code accusation, with no mention of the retained earlier package")
}
if !blamesTyping(body) {
t.Fatal("R-226 RETURNED: a genuine mistype on a re-escrowed box is given no way to discover it was a mistype")
}
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))
+111 -2
View File
@@ -185,6 +185,47 @@ func (s *Server) SetRecoverySupport(fn func(context.Context) agentapi.SupportSta
s.recoverySupportFn = fn
}
// recoveryRefusalTrusted reports whether a 400 from the agent may be read as "the bundle was fetched
// and the code was REFUSED" (R-224).
//
// Only agent >= v0.126.0 splits a failed fetch out to its own status. Before it, 400 covered both,
// and reading one as a refusal is precisely how a hub outage became an accusation. Anything other
// than a definite yes therefore withholds that reading, and the caller falls to the neutral message.
//
// ⚠ This gate BLOCKS NOTHING. The unlock is attempted either way — FeatureOffsiteKeyRecovery already
// decides that, fail-closed. This only decides whether the customer may be told to check their
// typing, and "not sure" means they may not.
func (s *Server) recoveryRefusalTrusted(ctx context.Context) bool {
if s.recoveryRefusalTrustedFn != nil {
return s.recoveryRefusalTrustedFn(ctx)
}
agent, err := s.agentClient()
if err != nil {
return false
}
state, _ := s.netFeatures.SupportsWithSource(ctx, agent, agentapi.FeatureRecoveryFailureClass)
return state == agentapi.SupportYes
}
// SetRecoveryRefusalTrusted overrides the R-224 version gate (tests). INIT-ONLY.
func (s *Server) SetRecoveryRefusalTrusted(fn func(context.Context) bool) {
s.recoveryRefusalTrustedFn = fn
}
// recoveryNow is the clock the unlock path measures itself against. Real time in production; tests
// inject so §7.2's guard — the typing message may only follow a REAL unseal — can be asserted
// without sleeping. It is an observability seam and a test seam: **it must never become a
// classifier.** Time is the symptom that diagnosed R-224; the agent's status is the fact.
func (s *Server) recoveryNow() time.Time {
if s.recoveryNowFn != nil {
return s.recoveryNowFn()
}
return time.Now()
}
// SetRecoveryClock overrides the unlock clock (tests). INIT-ONLY.
func (s *Server) SetRecoveryClock(fn func() time.Time) { s.recoveryNowFn = 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.
@@ -254,12 +295,67 @@ func (s *Server) recoveryUnlockHandler(w http.ResponseWriter, r *http.Request) {
defer cancel()
// THE SHARED CORE — the same function the command line drives. There is no second recovery
// implementation in this codebase (R-193 §8.5).
unsealStart := s.recoveryNow()
res, rerr := backup.RecoverInstallCore(ctx, s.backupMgr, rec, code, true)
unsealTook := s.recoveryNow().Sub(unsealStart)
code = "" // cleared here, before any branch below, on success and failure alike
if rerr != nil {
// 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)
//
// ── R-224 — WHY IT FAILED DECIDES WHAT WE SAY. ─────────────────────────────────────────
//
// This branch used to be a two-way choice — superseded? M4 : M1 — and BOTH are statements
// about the customer's code. `rerr` was never inspected, so a hub that refused, an agent that
// was stopped, and a genuinely mistyped code all produced the same accusation.
//
// Measured live on 2026-08-05 with a CORRECT current code: hub firewalled off → 0.0556 s;
// agent stopped → 0.0299 s. A genuine unseal costs ~1.0 s of scrypt, so neither had attempted
// one. **The machine accused the customer of something it had not tried.**
//
// The duration is logged because it is what DIAGNOSED this and it is the cheapest possible
// tell for the operator — but it is NEVER the classifier. Time is a symptom; the status is
// the fact.
class := agentapi.ClassifyRecoveryFailure(rerr, s.recoveryRefusalTrusted(r.Context()))
s.logger.Printf("[WARN] [web] recovery: unlock failed after %s (class=%s): %v", unsealTook.Round(time.Millisecond), class, rerr)
switch class {
case agentapi.RecoveryHubUnreachable:
// The agent answered and could not FETCH the package. The code was NEVER USED. Say that,
// name the connection, and say nothing whatever about whether the code is right — we do
// not know, and guessing here is the whole defect.
s.renderRecovery(w, r, "Most nem sikerült elérni a Felhom központi rendszerét, ezért a mentéseidet nem tudtuk megnyitni. A kódodat NEM használtuk fel, és semmi nem változott — tedd el biztonságos helyen, és próbáld újra néhány perc múlva. Ha egy óra múlva sem megy, szólj a Felhom ügyfélszolgálatának.", "", nil)
return
case agentapi.RecoveryAgentUnreachable:
// The machine's own in-house service never answered, so there is no verdict at all. A
// different fault from the one above, with a different remedy — and, again, the code was
// not used.
s.renderRecovery(w, r, "A gép házon belüli szolgáltatása most nem válaszol, ezért a mentéseidet nem tudtuk megnyitni. A kódodat NEM használtuk fel, és semmi nem változott — tedd el biztonságos helyen. A gép magától rendbe jöhet; próbáld újra néhány perc múlva, és ha egy óra múlva sem megy, szólj a Felhom ügyfélszolgálatának.", "", nil)
return
case agentapi.RecoveryNoBundle:
// The hub answered, and it holds nothing for this machine. Not the customer's doing, and
// not something a different code would fix.
s.renderRecovery(w, r, "Ehhez a géphez nem őrzünk lezárt csomagot, ezért nincs mit megnyitni. Ez nem a kódoddal van összefüggésben. Ha korábban készültek házon kívüli mentéseid, szólj a Felhom ügyfélszolgálatának.", "", nil)
return
case agentapi.RecoveryBundleTooOld:
// The code WORKED — the bundle opened. It simply predates the field we need.
s.renderRecovery(w, r, "A kódod megnyitotta a csomagot, de az még nem tartalmazza a házon kívüli tárhely kulcsát — régebben készült, mint amikor ezt elkezdtük belerakni, és utólag nem pótolható. A kódoddal semmi baj. Keresd a Felhom ügyfélszolgálatát.", "", nil)
return
case agentapi.RecoveryAskedAndRefused:
// The bundle was fetched and the code did not open it. THIS is the only class from which
// the customer may be told to check their typing — see the two messages below.
default:
// ── THE SAFE DEFAULT (R-224 §7.1). ────────────────────────────────────────────────
//
// The cause could not be determined: an unrecognised status, an unexpected transport
// shape, or an agent older than v0.126.0 whose 400 means "wrong code OR failed fetch"
// and cannot be told apart. **We do not know, so we do not guess — and we certainly do
// not guess the customer.**
//
// It claims neither that the code was wrong nor that it went unused; both would be
// inventions. This is the branch whose ABSENCE let the defect survive being fixed once.
s.renderRecovery(w, r, "A művelet nem fejeződött be, és nem tudjuk biztosan, miért. Semmi nem változott, és a mentéseid érintetlenek. Próbáld újra néhány perc múlva — ha másodszorra sem sikerül, szólj a Felhom ügyfélszolgálatának.", "", nil)
return
}
// ── 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
@@ -276,12 +372,25 @@ func (s *Server) recoveryUnlockHandler(w http.ResponseWriter, r *http.Request) {
// 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).
//
// ── R-226 — AND IT MUST NAME THE MISTYPE TOO. ─────────────────────────────────────────
//
// As shipped, this message spoke only of an earlier package and sent the customer to support.
// But it is tested BEFORE the typing message, so on every box the hub keeps an earlier package
// for — precisely the boxes whose customer has just been handed a NEW recovery code and is
// most likely to be typing one — a genuine mistype produced this text and the ten-words prompt
// became unreachable. Measured on 2026-08-05 (CAMPAIGN-11 F1): three deliberately wrong codes,
// three real ~1 s unseals, three copies of this message.
//
// The two are INDISTINGUISHABLE at the engine — both fail closed against the current package —
// so the honest message names both and does not pretend to know which. Do not try to tell them
// apart; there is nothing to tell them apart with.
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)
s.renderRecovery(w, r, "Ez a kód nem nyitotta meg azt a csomagot, amit most őrzünk ehhez a géphez. Két oka lehet, és innen nem tudjuk megkülönböztetni őket. Lehet elgépelés: ellenőrizd, hogy mind a tíz szót pontosan, szóközökkel elválasztva írtad-e be — a kis- és nagybetűk nem számítanak. Vagy 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. Ha újrapróbálod és úgy sem megy, és 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. ──────────
+16 -4
View File
@@ -3,7 +3,6 @@ package web
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
@@ -38,16 +37,26 @@ type fakeRecoverer struct {
pw string
sha string
fail bool
codes []string
// failWith overrides `fail` with an EXACT error value, so a test can drive one specific
// R-224 class (a 502 fetch failure, a transport error, an unrecognised status).
failWith error
codes []string
}
func (f *fakeRecoverer) RecoverOffsiteRepoPassword(_ context.Context, code string) (string, string, error) {
f.mu.Lock()
f.codes = append(f.codes, code)
f.mu.Unlock()
if f.failWith != nil {
return "", "", f.failWith
}
if f.fail {
// The shape the agent returns: names the STEP, never the code.
return "", "", fmt.Errorf("unseal failed: age: incorrect passphrase")
// R-224: the shape agent >= v0.126.0 returns for a code that was TRIED AND REFUSED — HTTP 400,
// meaning the bundle was fetched and `age -d` rejected it. It used to be a bare error here,
// which is the shape of a failure we could NOT classify; under the R-224 rule that now renders
// the neutral message, and rightly so. Saying "wrong code" in a test requires saying it the way
// the agent says it.
return "", "", &agentapi.RecoveryRefusal{Status: 400, Reason: "the recovery code did not open the sealed bundle — nothing was written"}
}
return f.pw, f.sha, nil
}
@@ -133,6 +142,9 @@ func newRecoveryFixture(t *testing.T) *recoveryFixture {
// 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 })
// R-224: the fixture's agent is a current one, so a 400 may be read as a genuine refusal. Tests
// that need the OLD-agent behaviour override this explicitly.
s.SetRecoveryRefusalTrusted(func(context.Context) bool { return true })
return &recoveryFixture{s: s, mgr: mgr, sett: sett, rec: rec, runner: rr, dataDir: cfg.Paths.DataDir}
}
+6
View File
@@ -107,6 +107,12 @@ type Server struct {
// 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
// recoveryRefusalTrustedFn overrides the R-224 gate deciding whether a 400 may be read as a
// genuine refusal (tests). nil → the agent-version path.
recoveryRefusalTrustedFn func(context.Context) bool
// recoveryNowFn is the unlock path's clock (tests inject; nil → time.Now). Observability and
// tests only — never a classifier.
recoveryNowFn func() time.Time
// 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.
@@ -49,8 +49,8 @@
<div class="stat-label">Utolsó távoli mentés{{if .Offbox.LastRun}}<br><span class="relative-time">{{timeAgoStr .Offbox.LastRun}}</span>{{end}}</div>
</div>
<div class="stat-card">
<div class="stat-value" style="font-size:1.15rem">{{if .Offbox.RepoSizeHuman}}{{.Offbox.RepoSizeHuman}}{{else}}{{end}}</div>
<div class="stat-label">Tároló méret · {{.Offbox.SnapshotCount}} pillanatkép</div>
<div class="stat-value" style="font-size:1.15rem">{{if and .Offbox.StatsKnown .Offbox.RepoSizeHuman}}{{.Offbox.RepoSizeHuman}}{{else}}{{end}}</div>
<div class="stat-label">Tároló méret · {{if .Offbox.StatsKnown}}{{.Offbox.SnapshotCount}} pillanatkép{{else}}a pillanatképek száma még ismeretlen{{end}}</div>
</div>
<div class="stat-card">
<div class="stat-value" style="font-size:1.05rem">{{if .Offbox.Enabled}}{{.Offbox.User}}@{{.Offbox.Host}}{{else}}Kikapcsolva{{end}}</div>
@@ -58,12 +58,31 @@
</div>
</div>
{{if and .Offbox.Enabled (gt .Offbox.QuotaGB 0)}}
<!-- R-225: the BAR renders only when the fill is known. A 0%-wide bar over an unread store is a
picture of emptiness, and a picture is a claim. -->
<!-- SLICE 4: soft-quota usage bar (shared model; quota_gb from the hub descriptor). -->
<div id="offbox-quota-bar" style="max-width:560px;margin:.5rem 0">
<div class="stat-label" style="margin-bottom:.25rem">Tárhelykeret: {{if .Offbox.RepoSizeHuman}}{{.Offbox.RepoSizeHuman}}{{else}}0{{end}} / {{.Offbox.QuotaGB}} GB ({{.OffboxQuotaPct}}%)</div>
<div style="background:var(--border,#334);border-radius:4px;height:8px;overflow:hidden">
<div class="stat-label" style="margin-bottom:.25rem">Tárhelykeret: {{if .Offbox.StatsKnown}}{{if .Offbox.RepoSizeHuman}}{{.Offbox.RepoSizeHuman}}{{else}}0{{end}} / {{.Offbox.QuotaGB}} GB ({{.OffboxQuotaPct}}%){{else}}még nem tudjuk, mennyi van a tárolóban — legfeljebb {{.Offbox.QuotaGB}} GB{{end}}</div>
{{if .Offbox.StatsKnown}}<div style="background:var(--border,#334);border-radius:4px;height:8px;overflow:hidden">
<div style="height:8px;border-radius:4px;width:{{.OffboxQuotaPct}}%;background:{{if ge .OffboxQuotaPct 100}}var(--crit,#e5484d){{else if ge .OffboxQuotaPct 80}}var(--warn,#f5a524){{else}}var(--ok,#30a46c){{end}}"></div>
</div>
</div>{{end}}
</div>
{{end}}
{{/* R-228 — THE SET-ASIDE HISTORY IS SAID OUT LOUD.
The customer chose "I do not want the old data", was told it would be KEPT and not deleted,
and then it vanished from every screen: the box recorded exactly where it went
(OrphanedRenamedTo) and showed that to nobody. Measured 2026-08-05 (CAMPAIGN-11 F7) —
12 535 KB at a path with zero references in any template or handler.
NOTE — IT STATES TWO FACTS AND STOPS. It does NOT promise the history can be reopened, because it
cannot be: serving a superseded package is an unbuilt link (R-199's inventory). A conditional
promise that turns out false is worse here than saying less — the R-202 lesson. */}}
{{if .Offbox.OrphanedRenamedTo}}
<div class="alert alert-info" style="margin-top:.75rem">
<p><strong>A korábbi mentéseid félre vannak téve — nem töröltük őket.</strong></p>
<p class="form-hint">Amikor új mentési kulcsot kapott a géped, a régebbi előzményt átmozgattuk
a távoli tárhelyen, és ott is maradt. <strong>Megnyitni innen egyelőre nem lehet</strong>, és
ez nem a kódodon múlik. Ha szükséged van rá, keresd a Felhom ügyfélszolgálatát.</p>
</div>
{{end}}
{{if .Offbox.LastError}}<p class="form-hint" style="color:var(--crit)">Utolsó hiba: {{.Offbox.LastError}}</p>{{end}}
@@ -89,7 +89,9 @@
semmi nem változik.</strong> A visszaállítást utána, alkalmazásonként külön választhatod.
</p>
<form method="POST" action="/recovery/unlock" autocomplete="off">
<div id="unlock-gateway-error" class="alert alert-error" style="display:none" role="alert"></div>
<form id="unlock-form" method="POST" action="/recovery/unlock" autocomplete="off">
{{.CSRFField}}
<label for="recovery_code">Helyreállítási kód (tíz szó)</label>
<input type="password" id="recovery_code" name="recovery_code"
@@ -104,6 +106,57 @@
</div>
</form>
{{/* R-227 — A RESTART MID-UNLOCK MUST NOT SHOW A RAW ENGLISH GATEWAY ERROR.
Measured 2026-08-05 (CAMPAIGN-11 F8): the controller was restarted 0.7 s into an unlock and
the customer got traefik's `Bad Gateway` — a raw upstream error, in English, naming no reason
and saying nothing about whether the key was installed. The state was clean; only the page
was not. It breaches I3 (every refusal names a reason a person can act on, in Hungarian, with
no raw error).
WHICH LAYER ANSWERS: traefik, and its config IS generated by this repo
(internal/infra/templates/traefik*.tmpl). A fully branded proxy error page is therefore
possible here — but traefik v3 serves no static files itself, so it would need a new
always-up container purely to hold an error page, for every 502 on the box. That is out of
proportion to this finding and is scoped in the report rather than built.
What ships instead is the second sanctioned option: the unlock posts via fetch, so a gateway
error or a dropped connection is caught in the page and answered in Hungarian, without
leaving it. PROGRESSIVE ENHANCEMENT — with no JS the plain POST is unchanged, and that path
still shows the proxy's own error. Said plainly rather than implied. */}}
<script>
(function () {
var form = document.getElementById('unlock-form');
var box = document.getElementById('unlock-gateway-error');
if (!form || !box || !window.fetch) { return; }
form.addEventListener('submit', function (ev) {
ev.preventDefault();
box.style.display = 'none';
var btn = form.querySelector('button[type=submit]');
if (btn) { btn.disabled = true; btn.textContent = 'Feloldás folyamatban…'; }
fetch(form.action, {
method: 'POST',
body: new FormData(form),
credentials: 'same-origin',
redirect: 'follow'
}).then(function (resp) {
if (resp.status >= 500) { throw new Error('gateway'); }
return resp.text().then(function (html) {
document.open(); document.write(html); document.close();
});
}).catch(function () {
// A 5xx from the proxy, or no response at all: the machine is very likely restarting.
// NOTHING is claimed about the code — we do not know whether it was used.
if (btn) { btn.disabled = false; btn.textContent = 'Mentések feloldása'; }
box.textContent = 'A gép éppen újraindul, ezért most nem tudtuk befejezni a műveletet. '
+ 'Semmi nem változott. Várj néhány másodpercet, és próbáld újra — a kódodra továbbra is szükséged lesz, '
+ 'úgyhogy tartsd kéznél.';
box.style.display = '';
});
});
})();
</script>
<p class="form-hint">
A „Most nem” csak azt jelenti, hogy nem zavarunk vele többet a kezdőlapon. A mentéseid ettől
megmaradnak, és ez az oldal a <strong>Biztonsági mentés → Távoli mentés</strong> oldalról
@@ -118,7 +171,7 @@
<p>Ha megerősíted:</p>
<ul>
<li>a korábbi mentéseket <strong>félretesszük — nem töröljük</strong>;</li>
<li>a helyreállítási kód nélkül <strong>többé nem lesznek megnyithatók</strong>;</li>
<li>a félretett mentések <strong>innen többé nem nyithatók meg</strong> — sem kóddal, sem anélkül;</li>
<li>a gép <strong>új, üres mentési tárolót kezd</strong>, és mostantól oda ment;</li>
<li>ez az oldal <strong>többé nem jelenik meg</strong>.</li>
</ul>