Compare commits

...

45 Commits

Author SHA1 Message Date
admin 3e3ee94b7b REPORT: live 422 proven on hardware; R-308 withdrawn (my quoting bug, not a stale credential)
gates / gates (push) Successful in 12s
2026-08-12 19:05:23 +02:00
admin ae10f64806 REPORT: controller v0.214.0 — the screen stops hedging, and the claim guard grew a surface
gates / gates (push) Successful in 18s
2026-08-12 18:49:56 +02:00
admin 3ed5e3e770 v0.214.0 — the recovery screen stops hedging about a code it can now check (R-311)
gates / gates (push) Successful in 13s
MinAgent: 0.129.0

What was already right: the screen did not bluntly accuse. R-222/R-226 hedged,
naming both causes and the kept package, and saying it could not tell them apart.
That was honest - and it could not tell them apart because nothing ever looked.
Agent v0.129.0 looks, so the hedge becomes an answer.

New class RecoveryCodeOpensRetained on HTTP 422, gated by
FeatureRetainedRecoveryClass (MinAgent 0.129.0). The gate is SEPARATE from the
R-224 one because the two name different agent versions and a box can sit between
them, where a 422 is a shape we did not design. ClassifyRecoveryFailure therefore
takes both flags; the compiler found every call site.

The message says the code is correct, names the supersession date, says the
earlier package is kept, and says the CURRENT backups are unaffected - the half a
customer will otherwise assume wrong. It promises NO restore: there is no
in-product route to a set-aside store (R-312) and the retained package may itself
predate the repository-password field. It routes to support, which can do it.

The claim guard grew a surface and immediately convicted something. It scanned
templates only, while every recovery message is a Go string in a handler - the
highest-stakes copy in the product, never scanned. It now scans recovery_handlers.go
too, and found a PRE-EXISTING unregistered claim on its first run.

Six handler tests asserting which SENTENCE the customer sees; red-proofs asserted
applied, including: 422 unconditional makes an agent that never looked read as
having looked, and routing 400 to the new class congratulates a mistype.
2026-08-12 18:42:02 +02:00
admin 3168a78935 REPORT: v0.213.0 pinned-fingerprint condition, red-proofs, claim guard
gates / gates (push) Successful in 12s
2026-08-12 15:39:08 +02:00
admin 89712563a0 R-302: the abandon banner promises only what the box can still see is true
gates / gates (push) Successful in 10s
The retrieval clause rendered unconditionally on every page and is false on a
reachable state - the same screen where the orphan card says we cannot tell.

The condition is a fingerprint PINNED at the decision, not a comparison against
the current key. The obvious proxy asks about the wrong key: the set-aside
copies were written under an older key the box no longer has, so on a
twice-rebuilt box the proxy promises about copies nothing can open. Demonstrated
- under the proxy, the replaced-package and legacy cases both flip back to
promising.

The pin is a recorded assumption and says so: nothing on the box records which
key wrote those copies. Empty is not a match. A countdown started before this
carries no pin and takes the cautious branch, not a backfill.

A sweep of all 36 templates found a fourth instance (backups page, same
condition applied) and a fifth (the confirmation screen, correctly left alone -
true at the moment of the decision).

New retrieval_promise_gate registers each claim with a reason rather than
banning a verb: a string ban failed twice, and the honest replacement copy
contains the stem.
2026-08-12 15:27:29 +02:00
admin 1b66010298 REPORT: v0.212.0 orphan card second promise
gates / gates (push) Successful in 16s
2026-08-12 14:05:45 +02:00
admin 68f3e12398 R-299: the orphan card's second promise, and a guard that matched one inflection
gates / gates (push) Successful in 14s
The explanation paragraph - the always-visible half of the card - still ended
'a hozzajuk tartozo helyreallitasi koddal kesobb visszaallithatok lehetnek',
the same unevaluable claim v0.211.0 removed from the confirm block below it.

It survived because the spec called that line accurate, and because the
regression guard asserted the SINGULAR form while the card carried the plural,
which does not contain that substring. The guard now matches the stem, so any
conjugation fails it. The two accurate halves are kept.

Also: the guard's failure message sliced rendered HTML at a byte offset and cut
Hungarian mid-character; it now slices on rune boundaries.
2026-08-12 13:49:15 +02:00
admin f87be3575f REPORT: correct installer publication status
gates / gates (push) Successful in 12s
2026-08-10 14:20:22 +02:00
admin 38f4535bfa REPORT: golden 0.211.0 baked and published; only the Day-0 vouch remains
gates / gates (push) Successful in 14s
2026-08-10 14:20:08 +02:00
admin 397d62136f REPORT: v0.211.0 written, not delivered - bake and Day-0 approval outstanding
gates / gates (push) Successful in 13s
2026-08-10 13:58:45 +02:00
admin 86a78c6767 R-294/R-295: orphan card stops promising restorability; one name per secret
gates / gates (push) Successful in 14s
The orphan card told a customer their set-aside off-site history may be
restorable later with their recovery code. The discriminator lives on the hub
and no wire field carries it, so the box rendering that card cannot evaluate the
promise. Copy replaced per the spec: state what happens, decline what we cannot
know and say why, name a route.

The claim page called the same three-word dashboard code two different names
depending on branch, one of which collides with the ten-word escrow code.
Retired 'Visszaallito kod'; the name is now constant and the sentence changes.
Naming only - a test pins that a reset code is still accepted.

secret_in_markup_gate no longer convicts Go template comments, which are
stripped before render; still convicts a real rendered secret.
2026-08-10 13:53:21 +02:00
admin b762a37097 R-280: attach list from mounted-but-unregistered filesystems; two-clicks promise made conditional
gates / gates (push) Successful in 17s
After a reinstall the data drive could not be re-attached through any dashboard
route: both candidate lists came from the agent's unclaimed-disk scan, and the
rebuilt box's drives are claimed. The restore page said it was two clicks while
pointing at an empty picker.

The attach list now also carries the controller's own mounted-but-unregistered
filesystems. initialize is untouched, so the format wizard's system/backup
protection is unchanged. The 'two clicks' sentence is conditional on the picker
being non-empty, and says something true and actionable when it is not.
2026-08-10 13:41:32 +02:00
admin c732fe1283 v0.210.0 — R-259 and R-258: two pictures that were not true
gates / gates (push) Successful in 18s
Both are one shape: something the box already knows, drawn as its opposite.

R-259 — A DISK WE FAILED TO READ WAS DRAWN AS A HEALTHY EMPTY DISK. readDiskUsage
(internal/system/info_linux.go) logged a statfs failure at DEBUG and returned, leaving the caller's
TotalGB/UsedGB/AvailGB/Percent at zero — and usageColor(0) is "nominal". The dashboard's
most-looked-at meter therefore rendered "0.0 GB / 0.0 GB (0%)" with a 0%-wide bar in the healthy
colour. "We could not look" and "there is plenty of room" were the same picture.

readDiskUsage now returns whether the measurement succeeded; SystemInfo gains DiskKnown and
HDDKnown (HDDConfigured is not a substitute: it says a path was configured, not that reading it
worked); and the template draws NO figure, NO percentage and NO meter fill when unknown, saying
"A tarhely merete most nem olvashato ki." instead. A healthy box is byte-identical, colour band
included.

This session rules the convention (felhom.eu CONTEXT.md S-39): an explicit `...Known bool` companion
beside the figures, checked in the template — the shape Offbox.StatsKnown already uses, whose own
comment says "a 0%-wide bar over an unread store is a picture of emptiness, and a picture is a
claim". Pointers and separate error fields are both legitimate Go, but a codebase with three
dialects cannot be gated (ROADMAP G-3 was blocked on exactly this). Existing call sites NOT
converted.

R-258 — THE PER-APP BACKUP TICK WAS GREEN ON PRESENCE, AND RED ONLY ON A GLOBAL CONDITION.
buildAppBackupRows set Tier1LastStatus from status.LastDBDump.Success, which is the box's single
most recent dump RUN, whichever app it belonged to. An app whose own dump failed showed a tick as
long as some other app dumped successfully afterwards; an app with no database took the nil branch
and went green on the mere existence of a restore point.

appDumpVerdict now reads THIS app's own entries in DBDumpStatus.Results (matched on
DumpResult.DB.StackName, failure = non-nil Error). Three states: any failing database -> error; all
clean -> ok; no result recorded -> NO verdict and no icon, titled "Errol a mentesrol nincs
eredmenyunk." The recovery unit carries no per-run outcome of its own, so green cannot honestly be
derived from presence. The global tier1DBStatus label is untouched — it is correct as a global.

RECENCY IS DELIBERATELY NOT ADDED. A tick over a three-week-old restore point is a real weakness,
but an age threshold means inventing a number and the time is already printed beside the icon.
Recorded as an observation, not changed.

AN EXISTING TEST WAS ASSERTING THE DEFECT AND WAS CORRECTED, NOT DELETED:
TestBuildAppBackupRows_Tier1FromRestorePoints expected "ok" for a status with no LastDBDump at all —
green from nothing but a file's existence. It now expects no verdict; its real subject, the
Tier1LastRun time, is unchanged.

The dashboard test EXTRACTS the meter block from the shipped template rather than copying it: a
copied block drifts, and a drifted copy passes while the page it claims to cover has changed — the
fixture-is-not-the-wire mistake this project has now hit twice.

Six red-proofs across both parts, each with the mutation asserted applied.

No new tag on any declared wire — report/builder.go maps into its own types and is untouched;
wire_contract_gate.py confirmed green.

go build / go vet / go test ./... green (28 packages), controller_gates --fast all OK, both run
separately from this commit.
2026-08-08 16:29:52 +02:00
admin fcffaf573a v0.209.0 — R-247: the box stops saying a false thing about its own recovery package
gates / gates (push) Successful in 17s
The answer was on the wire and was discarded at the boundary, for the third time.

The hub has sent `escrow_stale` in the report ACK since v0.57.0 (json:"escrow_stale,omitempty").
report.EscrowStatus had no field for it, so encoding/json dropped it, and an empty restic_pw_sha256
had exactly one possible reading here: "hash-less supersession".

On demo-hp that reading was false in EVERY clause for four days, and the box told the customer so in
its own words. The hub HAD the hash and was withholding it because the escrow row carries a stale
flag (R-246); there had been no supersession; and the bundle DID cover the password — the hashes
matched exactly.

Fixed by receiving the field. EscrowStatus.Stale decodes, and reconcileEscrowed tells the two
conditions apart: a withheld hash now reports that the hub has flagged the row and is withholding,
that this box therefore cannot verify its bundle either way, and that it is NOT established that the
bundle fails to cover the password. The genuinely hash-less case keeps its wording.

Deliberately NOT changed, and said rather than skipped: the stale verdict itself (the hub's flag is
still the hub's verdict; runs still continue), and the customer-facing Hungarian card copy. Clearing
the wrong flag is an operator act hub-side (R-246); re-wording the card is UI work with its own
review path. This change is the wire and the diagnosis.

Found by felhom.eu/scripts/wire_contract_gate.py (G-1), which was built first and seen failing on 40
fields before anything was fixed, and which now refuses any new field of this shape.

go build / go vet / go test ./... green, run separately from this commit.
2026-08-08 08:46:29 +02:00
admin 37b5ba08a7 REPORT: v0.208.0 — both R-254 sites, the guard's measured holes, and what the live read could not prove
gates / gates (push) Successful in 13s
Records the deliverables, and is explicit about the limit on the live half: the
curl of an app info page could not be done, and names exactly what was tried —
crafty-controller is the only app declaring initial_credentials and is deployed
nowhere, and demo-hp's dashboard password in ~/.config/credentials no longer
authenticates (200 with no session cookie). A probe of the new routes was
discarded because its control killed it: real and bogus paths both 302 behind the
auth middleware.

§7.2's answer including the part that contradicts the task's premise: no line in
the repo says 'no silent auto-fill'; the rule is CONTEXT.md:2070 about accidental
EMPTY-password deployments. The hidden input is deliberate and untouched.

§7.4's measurement: the gate covers all 36 templates and catches a launder through
a local variable, but is blind to a secret under a neutral page-data key — the
exact shape of site two. Runtime coverage is 4 of 27 pages. Filed as R-255 rather
than described as complete.

§7.3: no evidence of actual exposure on the fleet, with the limit stated — it is a
current-state measurement and nothing recorded reads, which was part of the fault.

Also corrects v0.207.0's report: html/template STRIPS HTML comments; they do not
ship in the response body. Measured.
2026-08-07 21:29:13 +02:00
admin 27d1165962 v0.208.0 — R-254: the last two secrets leave the page source, plus a gate against a fourth
gates / gates (push) Successful in 17s
Site one. app_info.html rendered {{.InitialCreds.Password}} into a hidden span —
a REAL per-install credential, read live out of the running container, in the
response body of every render. The page now carries the non-secret half plus a
boolean; the value comes from POST /apps/<slug>/initial-credentials/reveal, which
RE-READS the container rather than serving a cached copy (caching it in the
handler would put it back in the body one layer in). no-store, CSRF-covered,
logged as an act. Both buttons go through it. A reveal that cannot read the value
SAYS SO rather than returning an empty string that renders as a blank password.

Site two, established before changing. The hidden input is NOT the defect and was
left alone: it fires only pre-deploy, and README §318 documents why the value must
round-trip — the customer notes the generated secrets down and submitting them
back is what makes the saved value the same one they saw. The defect was the
neighbouring READONLY input, which on an ALREADY-DEPLOYED app rendered the secret
into a page with nothing to submit. Fixed by POST /stacks/<name>/auto-field/reveal,
authorised by requiring a type:secret auto-field of that stack. Both directions
pinned.

The premise that this contradicted a repo rule does not hold: the rule is
CONTEXT.md:2070 'Password fields require explicit input — prevents accidental
empty-password deployments', about EMPTINESS. No line in the repo says 'no silent
auto-fill'.

The gate. scripts/secret_in_markup_gate.py, registered in controller_gates.py,
convicts any template expression that names a secret unless allowlisted with a
reason. Its limits are MEASURED and in its docstring: it catches a launder through
a local variable (the assignment names the secret) but is blind to a secret
arriving under a neutral page-data key — verified both ways. That is the shape of
site two, which this gate would NOT have caught. The runtime body assertion covers
all shapes but only 4 of 27 page templates; the other 23 are R-255, filed rather
than glossed. Two nets, different holes, both named.

Correction to v0.207.0's report: HTML comments do NOT ship in the response body
here — html/template strips them, text/template does not. Measured. A red-proof
planting a secret in a comment therefore correctly does not fail.
2026-08-07 21:20:26 +02:00
admin 62998aab4f REPORT: v0.207.0 — R-249 before/after on a live box, the census, and what was NOT proven live
gates / gates (push) Successful in 18s
Records the deliverables: the raw response body before (1 occurrence, v0.206.0)
and after (0, v0.207.0) with a positive control in both directions; the §7.1
census finding two more instances of the render-then-hide pattern (R-254, one a
real per-install secret); §7.2's decision and why the promise was the wrong half;
every changed Hungarian string; all eight tests with their red-proof outcomes.

States plainly what was NOT proven live: R-252/R-253's notices could not be
rendered on VM 325 because both states are rebuild-only and the box re-registers
a drive on restart — the live run therefore exercised Scenario E instead, and the
notices are pinned at the template + predicate level with red-proofs.

Also records that red-proof D caught a fault in my own work: the explanatory HTML
comment quoted the old sentence, and HTML comments ship in the response body, so
the contradiction was still on the page and the assertion forbidding it could
never fail.
2026-08-07 18:33:37 +02:00
admin 8dbbc98ff2 v0.207.0 — R-249: the retrieval passphrase leaves the page body; R-252/R-253: two refusals learn to say what to do
gates / gates (push) Successful in 18s
R-249. settings_security.html rendered the passphrase into a display:none
span behind a Megjelenit button. That toggle stops a browser DRAWING the value
and nothing else — the plaintext was in the response body of every render, so a
curl of the page returned it. Found by exactly that: it landed in a session
transcript while driving the documented rebuild path.

The codebase already stated this rule for the recovery code and this page did not
follow it (escrow_handlers.go: 'reveal (claim XHR only — R is NEVER templated
server-side into HTML)'). The page now carries only HasRetrievalPassword; the
value comes from POST /settings/retrieval-password/reveal — CSRF-covered because
POST, no-store, and LOGGED as an act, which reading it off the markup never was.

The tests assert the RAW RESPONSE BODY. Every test that asked what the customer
sees passed while the bytes carried the secret; that is why this survived.

Census: the render-then-hide pattern appears twice more — app_info.html (a real
per-install app password in a hidden span) and deploy.html. Filed as R-254, NOT
fixed here.

R-252. A rebuilt box keeps its drives but loses their REGISTRATION. The restore
page now states that before the customer presses anything, says the backups and
drives are both still there, and links to Tarhely > Meghajtok. Page and resolver
ask ONE question — HasRestoreDestination() reads the same
GetSchedulableStoragePaths() the scratch resolver reads.

R-253. The list promised 'a visszaallitas elobb ujratelepiti' three lines above a
refusal that fired BECAUSE the app was not installed. The promise was the wrong
half: reconstitution writes to the app's own GetStackHDDPath, which exists only
once the CUSTOMER has chosen a drive at deploy time. Auto-reinstalling would mean
the product making that choice for them. Copy now says to install first and routes
to /stacks/<app>/deploy.

Both notices are conditional — a healthy box renders as before, pinned by a test
that fails if either becomes unconditional.
2026-08-07 18:04:26 +02:00
admin 3d3b4496f3 REPORT.md — R-241 fixed, deployed, live-validated on both demo boxes
gates / gates (push) Successful in 22s
Scenario A's live result first: on demo-hp in the rebuilt shape, no key was
minted on the real start-up offsite-apply path, and the hub received the state
it reports instead - offsite.state=awaiting_recovery_key with enabled:false.
Key restored byte-identical afterwards.

Includes Q4's seven rows mapped to the three states, the SEC 7.2 choice and
why, SEC 7.3's answer on the new-code button, every changed Hungarian string
quoted, all nine red-proofs with what was mutated, the R-245 reasoning, and
three observations noticed but not acted on.
2026-08-07 12:23:55 +02:00
admin 0a9158d53e docs for v0.206.0: CHANGELOG, CONTEXT, REUSE, README
gates / gates (push) Successful in 19s
CHANGELOG v0.206.0 with the ruling that reversed the fix, the three changes,
the SEC 7.2 staleness decision, Q7's closed trap, and the two bugs the tests
caught rather than review.

CONTEXT carries the three rules this session established, in the form the next
session needs them:
  - a box does not create a repository key while the hub holds a sealed
    package for it;
  - the fact that answers a question must be kept where the question is asked;
  - fix the state, do not remember that it is wrong.

REUSE gains four rows, each carrying the trap rather than just the signature:
the mint guard is a CONJUNCTION and t.Enabled is load-bearing in the derived
predicate; the discriminator ships INERT unless wired in main.go's confirmer
literal; the countdown removes BOTH halves or neither and must be driven by an
injected clock; and the epoch must be synced FIRST and unconditionally or the
falling edge is lost.

README documents the three customer-visible changes and the operator levers.

No version literal was edited: the controller version is ldflags-only.
2026-08-07 12:12:35 +02:00
admin 72368654e4 R-241 part 5: escalating reminders, and operator levers for a running countdown
REMINDERS (SEC 2.3). The offer epoch now stamps when it began, and the
undecided reminder escalates in EMPHASIS at 1, 3, 7 and 14 days.

THE READING IS STATED BECAUSE THE SPEC IS AMBIGUOUS, and it is written into
the code where it can be corrected. For an ABANDONING box, 5/3/1 are
unambiguously days REMAINING before a deletion. An undecided box has no
deadline - nothing counts down to anything, because SEC 7.5 deliberately does
NOT auto-abandon - so 14/7/3/1 cannot be "remaining" and are taken as days
ELAPSED, with the wording firming up rather than the bar appearing and
disappearing. If the operator meant something else, one function changes.

The stamp is re-set on every entry into the offered state, so a box that
settles and is later rebuilt starts its ladder again instead of inheriting an
old one.

OPERATOR LEVERS (SEC 7.5). --abandon-status, --abandon-extend=N and
--abandon-stop on the controller CLI, beside the existing operator
subcommands. They exist because the path that ACTUALLY happens is the customer
telephoning, and support needs something to press.

They live on the CLI and not in the customer UI deliberately: extending a
deletion the customer asked for is an operator judgement, and a customer who
wants it stopped already has the self-service route - they recover with their
code, which cancels it.

BOTH REFUSE RATHER THAN NO-OP, in two situations: when no countdown is
running, and when the store has already been deleted. A silent success is the
thing an operator most easily mistakes for "handled" - they would tell the
customer their data was safe when it is gone. Pinned by two tests.

--abandon-extend counts from NOW, not from the old due date, and a test proves
the old date passes without deleting anything.

Green: go build, go vet, go test ./... all pass; controller gates OK.
2026-08-07 12:08:11 +02:00
admin de39e47f53 R-241 part 4: the three-state surface, and the copy tells the truth about the date
FULL PAGE ONCE PER ENTRY, NOT ONCE EVER. "Most nem" used to set a flag that
nothing ever cleared, so a box that abandoned its history and was rebuilt
months later - a genuinely NEW situation - would never see the page again. The
offer now carries an EPOCH, advanced on the edge into the offered state, and a
dismissal is recorded against the epoch it was made in. A fresh entry passes
the dismissal by arithmetic, with nothing to clear and nothing that can be
forgotten to clear.

That is NOT the flag the operator's ruling forbids. The forbidden thing
remembers that the customer decided so the screen can be suppressed while the
state stays wrong. This records WHICH SITUATION a dismissal was about.

A REAL BUG, caught by the test and not by review: the first draft returned
early from recoveryInterrupts when the offer was false, so the FALLING edge
was never recorded, RecoveryOfferActive stayed true through a settled period,
and the next entry counted as a continuation. The page never came back - the
exact defect the epoch exists to fix, reintroduced inside the fix. The sync is
now unconditional and the ordering is commented as load-bearing.

THREE LEVERS, THREE SCOPES, and none of them removes the route:
  - clicking the bar away  -> a browser SESSION cookie, cleared on login, so
    the reminder is genuinely back at the next login. Nothing persisted.
  - "ne emlekeztessen ujra" -> durable, epoch-scoped, silences the BANNER ONLY.
    It starts no countdown, abandons nothing, and a fresh entry reminds again.
  - "most nem" -> suppresses the full page only, as before.
The entry point on /backups/remote is bound to the OFFER and to nothing else,
pinned by a test that fires all three dismissals and asserts it survives.

SEC 7.3 / Q7 - THE TRAP DOES NOT SURVIVE THIS SESSION. While a recovery is
outstanding the "Helyrealitasi kod letrehozasa" button is UNAVAILABLE, not
merely captioned: creating a new code seals the current key, demotes the
package that opens the earlier history to retained custody that no shipped
path can read (R-199), and re-enables the recovery screen through the orphan
route while invalidating the code that screen accepts. A warning beside a
button is a warning people click past. The card now explains and points at
/recovery instead.

SEC 2.4 - the abandon confirmation changes with the behaviour. It used to
promise "felretesszuk - nem toroljuk". It now states the grace in days (from
the constant the countdown actually uses, never a literal in prose), that the
sealed package goes with it, that the customer can change their mind, where
the date is visible, and that the question does not come back afterwards.

The countdown is shown on /backups/remote for the WHOLE window - the bar
elsewhere is a nudge, this is the record, and a deletion date must be findable
on a quiet day too.

Tests: once-per-entry across a full settle-and-re-enter cycle; the banner
dismissal proven to be a session cookie (MaxAge 0, no Expires) and to persist
nothing; the opt-out proven to silence the banner while leaving the offer, the
route and the countdown untouched, and to remind again on a fresh entry; the
entry point surviving all three dismissals; a settled box showing nothing; and
the back-redirect refusing "//evil.example".

An existing test (TestRecovery_E) was updated: it asserted the legacy boolean,
which the epoch replaces. It now asserts the dismissal landed on the current
epoch, which is the stronger property.

Green: go build, go vet, go test ./... all pass; controller gates OK.
2026-08-07 12:01:30 +02:00
admin a5d90ff801 R-241 part 3: abandoning starts a 14-day countdown that ends the question
Until now "set aside" renamed the remote store and touched neither the escrow
nor the key, so the hub went on holding a sealed package for a key the box no
longer used. Shape (c) compares those two, finds them different, and offers
recovery - correctly, and for ever. A customer who had already said "I do not
want the old data" would be asked again at every login.

The operator's ruling is that the answer is NOT a "they decided" flag: fix the
state, do not remember that it is wrong. So the decision starts a countdown,
at the end of which the set-aside store and the sealed package that protects
it are removed TOGETHER. Afterwards shape (c) has nothing to compare and the
offer falls silent on its own - because the state is right, not because
something remembers it once was not.

THE GRACE IS REAL. The recovery offer stays reachable for the whole 14 days;
that is the change-of-mind path, and a grace in which recovery is impossible
would be decorative.

BOTH HALVES OR NEITHER. Removing only the store leaves a package that opens
nothing; removing only the package leaves ciphertext nobody can ever decrypt.
The two cannot be atomic across two machines, so it is a two-phase commit:
delete the store, record a durable marker, and keep DECLARING
offsite.abandon_purge_requested until the hub's ACK stops reporting a
superseded package. A crash between the halves re-declares on the next sweep;
it never leaves the pair half-removed and silent.

HUB HALF - SEC 8.2 ANSWERED: yes, the hub was needed, and only for this.
store.PurgeSupersededEscrowForCustomer is the one place R-198's retention is
ever undone, and it never touches host_escrow (the package covering the key
the box uses now). The handler acts on the DECLARATION, never an inference,
and is placed immediately BEFORE the ACK is built - so
GetEscrowStatusForCustomer reads the effect and the SAME response closes the
box's two-phase commit. No second round-trip and no window where the box
thinks it is still owed. felhom-agent was NOT touched.

The countdown starts in ResetOrphanedRepo, NOT in the shared helper: the
helper is also the unclaimed auto-reset path, where nobody decided anything,
and an as-delivered box tidying a stranger's leftover store must not get a
customer's deletion clock. Pinned by a test.

Cancellation is wired into the recovery unlock, BEFORE the tier-up and the
listing - those can fail, and a countdown surviving a successful unlock
because a later step errored would delete the history the customer just
proved they can open.

The sweep is a Daily job at 05:10, not on the backup leg: it must run on a box
whose tier is not configured for runs. Quiet by construction on every box with
no countdown, and that silence is asserted.

Tests (all clock-injected; SEC 7.4 forbids shortening a live timer):
Scenario E (aside + package kept + countdown + offer still reachable, and
NOTHING deleted), Scenario F (both halves, the declaration repeating, the
close-out), Scenario G (cancel, path still nameable, no later deletion),
plus: not closed out while the package remains, a transport failure leaves the
countdown due and retrying, the no-op sweep issues zero remote commands, and
the unclaimed auto-reset starts no countdown.

RED-PROOFS, each with the mutation confirmed present in the file first:
  F1) store deletion skipped -> Scenario F FAILS (no rm issued)
  F2) declaration dropped from the report -> Scenario F FAILS (the hub is
      never asked; the package would outlive the store for ever)
  G)  CancelAbandon made a no-op -> Scenario G FAILS (uncancellable countdown)

Green: controller and hub both build, vet and test clean; controller gates OK.
NOTHING WAS DELETED ANYWHERE - the terminal step has only ever run against
in-test fakes.
2026-08-07 11:47:42 +02:00
admin a491abef6c R-241 part 2: the comparison the box already makes becomes the thing that offers recovery
THE FACT WAS COMPUTED EVERY CYCLE AND KEPT NOWHERE. EscrowAutoConfirmer.Reconcile
has compared the hub's restic_pw_sha256 against the local key on every ACK since
SLICE 3. On the final-walk venue it logged, at 03:28:03Z and thirty-five minutes
before the customer looked, "the hub's escrow blob does not cover the CURRENT repo
password (hub hash 30ef574f != local 9b4a9a9d)" - and dropped it. The recovery
screen, evaluating in the same process, went on asking a question that could not
see it.

Now persisted: settings.HubEscrowKeySHA256 + HubEscrowKeyCheckedAt, recorded
UNCONDITIONALLY in Reconcile beside RecordPresence and RecordSuperseded - same
place, same reason: the box that needs it most is the rebuilt one with no target,
on which every gate below returns early.

OffsiteRecoveryOffer gains SHAPE (c): the hub holds a package for a key OTHER than
the one we are using. (a) and (b) are both proxies for that question and both have
now been wrong in opposite directions - (a) goes false the moment anything mints,
(b) is unreachable while the escrow is pending.

SEC 7.2, decided deliberately and stated in the code:
  - a KNOWN DIFFERENCE offers, however old the reading. Age is not gated on. Both
    sides are local; only the hub's half can be stale, and what the hub holds does
    not change without a ceremony THIS box runs, which refreshes the hash on the
    next ACK. Gating on age would make a box offline from the hub silently stop
    offering - the exact failure this session removes. CheckedAt is persisted for
    diagnosis, not as a gate.
  - an ABSENT hash falls back to (a)/(b) and does NOT offer. "" is the hub
    positively saying its package seals no repository password (legacy hash-less
    escrow). Nothing to compare, and offering would put a permanent screen in
    front of every legacy box.

The write damper: CheckedAt refreshes on every ack carrying a hash, but a save is
skipped when both the hash and the UTC day are unchanged, so an idle box does not
rewrite settings.json every fifteen minutes. It records WHEN WE LAST HEARD, not
when it last changed - the R-100 distinction.

Tests: Scenario C (a differing key offers, with both proxies asserted false first),
Scenario D (a matching key offers nothing), fact 1 still required, shape (a) still
works, and both SEC 7.2 halves.

RED-PROOFS, each with the mutation confirmed present in the file first:
  D) hubHash != localHash conjunct dropped -> Scenario D FAILS (a healthy box
     offered recovery forever); Scenario C still passes
  WIRING) RecordEscrowKeyHash removed from the EscrowAutoConfirmer literal in
     main.go -> TestMainWiresRecordEscrowKeyHash FAILS. This is the ships-inert
     shape: unwired, everything compiles, every test in the package passes, the
     auto-confirm still works, and shape (c) reads an empty hash forever.

Green: go build, go vet, go test ./... all pass.
2026-08-07 11:33:42 +02:00
admin 763de3a025 R-241 part 1: the box does not mint a repository key over a sealed package
THE DEFECT. WriteOffboxSecrets auto-generated on ONE input - does the file
exist. Its two neighbours in the same file, OffsiteRecoveryOffer and
needsOffsiteCredential, both consult GetHubEscrowIdentityPresent(). The same
fact was available on three paths and used on two.

Measured on the final walk: a rebuilt box's credential self-heal reached here
at 03:18:06Z and minted 9b4a9a9d over a hub package sealing 30ef574f. The
recovery screen then correctly reported nothing recoverable under the key the
box held. The screen was honest; the minting was not. And the flag was not
merely available at that moment - it was the PRECONDITION of the chain that
reached this function, logged at 02:48:03Z, six ticks earlier.

THE GUARD IS A CONJUNCTION, deliberately: a package held AND no key present.
A box the hub holds nothing for mints exactly as before.

The refusal is a HOLDING state, not a failure. ApplyOffsiteTarget catches the
sentinel and still writes the transport (ssh key, known_hosts, coordinates),
so the recovery screen can bring the tier up the instant the escrowed key is
placed (R-219). Returning the error instead would leave needsOffsiteCredential
true forever and the hub re-staging a consumed credential on every cycle.

New declared state offsite.state=awaiting_recovery_key, shown INERT to every
existing hub reader from their code rather than assumed: offsiteheal acts on
exactly one string; isStale needs Enabled && escrowed and this carries
Enabled=false; the delivery checker skips the applied shape; an unknown state
string is ignored by encoding/json. So NO hub change is needed for this part.

OffboxAwaitingRecoveryKey is DERIVED, not stored - the operator's ruling that
the state should be fixed rather than remembered, applied to this field too.

t.Enabled is load-bearing in that predicate and was MISSING in the first
draft. The existing TestOffsiteDeclare_DisabledTargetIsNotStranded caught it,
not review: a customer who switched off-site off is not awaiting anything.
Now pinned from the new predicate's own side as well.

Tests: Scenario A (no key written; transport still written; apply holds and
stages nothing), Scenario B (first-time box still mints), idempotency, the
nil-settings fail-safe, and the Scenario E carve-out.

RED-PROOFS, each with the mutation confirmed present in the file first:
  A) guard block deleted   -> both Scenario A tests FAIL with
     "R-241 REGRESSION: apply minted a repository password over the sealed
     package"; Scenario B still passes (the mutation is specific)
  B) guard over-widened (hub-package conjunct dropped) -> Scenario B FAILS
     with a first-time box unable to start; Scenario A still passes

Green: go build, go vet, go test ./... all pass; controller_gates all OK.
2026-08-07 11:25:58 +02:00
admin c6b69d888e v0.205.0 — a run that skipped an app the customer selected is not successful (R-234)
gates / gates (push) Successful in 21s
THE VERDICT. The R-203 block already said "a warning beside a success is read as a
success" and applied it to ONE of the two shapes it describes: an app missing a
declared mandatory FOLDER made the run incomplete, while an app skipped ENTIRELY
still reported ok. Both do now. Which skips count, decided by measurement:
selected+deployed with no recovery unit YES; selected but NOT deployed no (named,
with what to do — a box left amber by an app somebody removed is a status nobody
reads); disconnected/decommissioned drive no (own signal); nothing selected no.
LastSuccess and SnapshotCount still record what WAS captured.

THE FILED MECHANISM WAS NOT THE MEASURED CAUSE, and saying so is the point. §3
stated that toggling an app on leaves it without a bundle so the first run skips
it. Measured on demo-hp: the run's own pre-dump phase calls captureAllRecoveryUnits
for every DEPLOYED stack, through admitApp, before the push — a unit moved aside
was RECREATED and the run reported ok. That state does not survive a run.

What actually produced the 2026-08-06 sequence: the manual run was dropped by the
single-flight while an earlier run was still going. runOffboxBackup returned nil,
the handler had already answered "A tavoli mentes elindult", and the card then
showed the PREVIOUS run's green verdict — read as covering the app just selected.
The decision is now taken synchronously in the handler and a dropped request says
so. The nightly path still returns nil on purpose: nobody asked, and it retries.

§7.3 measured before deciding: CaptureRecoveryUnit writes a few KB of compose +
manifest, only ENUMERATES dumps rather than creating them, is idempotent and does
NOT stop the app — and already runs inside the off-site run. So there is no wait to
remove for a deployed app and NOTHING was built.

28 packages ok, 9/9 gates. Four red-proofs, each asserted to have applied. Fixture
note: the shared provider's ListDeployedStacks returned nil, so Scenario A first
passed for the wrong reason; fixed with an opt-in deployed set that defaults to nil.
2026-08-06 21:58:21 +02:00
admin 53e9bf0224 v0.204.0 — the restore list is keyed on the store (R-237); the size gate stops refusing in silence (R-238)
gates / gates (push) Successful in 26s
R-237: /backups/restore listed apps that are CURRENTLY DEPLOYED and CURRENTLY
TOGGLED ON for future off-site backups. A rebuilt box has neither, so a household
that had just lost everything was shown nothing to restore while the repository
held their snapshots — measured live on the R-201 re-walk. To restore an app you
had to select it, to select it you had to have installed it, and to know what to
install you had to see the backup you could not see.

The store is now the source of the list (offsite_restore_list.go), built on the
existing R-193 OffsiteInventoryList. Installed-ness became a property OF a row,
never a filter on it. Every case is answered rather than hidden: a snapshot for an
app that is not installed is offered and says it will reinstall first; an installed
app with no snapshot is shown as having nothing; an unreadable store renders as
UNKNOWN (R-225's rule, one screen over) AND keeps the action, because "we could
not look" is not "there is nothing"; no-target is its own state. The felhom-offbox
and _shares marker tags are excluded from the app list.

R-238 classified as a HARNESS ARTIFACT: mode=full without confirm=1 is step 1 of a
deliberate two-step — it starts no job by design and redirects carrying
&full_prep=<app>, which deriveWizardStep requires to reveal the commit. A driver
that did not carry it forward landed back on the intent step. The operator's
browser run completed the same restore. The wizard's precedence rules were NOT
re-keyed: a stale ?full_prep= must never resurrect a commit button mid-restore.

The residue WAS real and is fixed: neither branch of that step wrote anything to
the log, so a refusal — including by the headroom gate — left no trace on the box.
Both branches now log, and so does the concurrent-op refusal.

resolveWizardApp is removed: it was dead once the gate moved, and its test pinned
the defect's behaviour (an untoggled app refused), which would have read as policy.

28 packages ok, 9/9 gates OK. Three red-proofs, each asserted to have applied.
2026-08-06 16:44:05 +02:00
admin 4d349d1106 REPORT + CONTEXT for v0.203.0: the retry shape, the marker answer, R-220's shape
gates / gates (push) Successful in 10s
Records the decisions rather than only the code:
- POLL not ACK, decided on Scenario B against the ACTUAL promises — the
  no-target message gives no deadline and the card says 'within a day', so a
  5-minute tick is inside both and no text needed changing. If either promise
  tightens to minutes, go ACK-driven.
- The marker question: applied_marker lives in the guest's DataDir, which a
  rebuild destroys, so it cannot suppress a legitimate re-run. Left alone.
- R-220 candidate (b), corroborated rather than a wider prefix, reading
  /proc/mounts because the lsblk args are pinned in sudoers.

Live: Scenario C proven on demo-hp WITH a positive control — the job ran once
and logged nothing. A first reading counted 2 lines that turned out to be the
start-up reconcile, not the retry; the instrument was corrected before the
conclusion. Scenarios A and E are deliberately NOT live-proven here: both need a
rebuilt box, and that state arises naturally in Part 4.
2026-08-06 13:05:30 +02:00
admin 9dc26459ea v0.203.0: the box collects what the hub staged for it (R-218 consume half) + R-220's message
gates / gates (push) Successful in 10s
R-218's declaration half shipped in v0.201.0 and works. Its consume half never
existed. Reconcile ran exactly twice per process — at start-up and when the
recovery screen drives it — and BOTH fire before the hub has anything staged,
because the hub stages in RESPONSE to the declaration those runs precede.

Measured on the R-201 re-walk: unlock reconcile 11:43:07, hub staged 11:44:57
saying 'next cycle', a full report cycle ran 11:55:46, still unconsumed at
12:06. A guest command line applied it in 18 seconds — everything correct except
the trigger.

Bridge.RetryIfDeclared re-runs the SAME reconcile on a 5-minute tick, driven from
the box's own published declaration (OffboxReportStatus().State) — the very
statement the hub acts on, so the two cannot disagree.

Poll, not an ACK flag, decided on the promise: the no-target message says 'amint
megvannak' (no deadline) and the card says 'within a day'. Five minutes is inside
both by a wide margin and needs no hub change.

It stops by construction — a healthy box does no work and logs nothing — and the
settle gate is deliberately kept via ReconcileWhenSettled.

The marker was investigated and left alone: applied_marker lives in the guest's
DataDir, which a rebuild destroys, so it cannot suppress a legitimate re-run.

R-220's customer half: the refusal no longer tells the customer to choose from a
list that may be empty. It names the rebuild, points at the Meghajtók page, and
promises no outcome.

Red-proofs: remove the retry -> credential uncollected (the dead end reproduced);
drop the stop condition -> a healthy box hammers the hub; call Reconcile instead
of ReconcileWhenSettled -> settle gate bypassed; restore the old sentence -> the
impossible action returns.

28 packages ok, vet clean, all controller gates OK.
2026-08-06 12:56:12 +02:00
admin 66d80efb9f docs: R-168 is CLOSED — the "CI is still owed" sentence was stale (R-229 part 2)
gates / gates (push) Successful in 19s
Corrected in all four instruction files across all four repos. Found while confirming this
session own push by run ID, which is precisely the check that catches it.

In felhom-agent/CLAUDE.md the sentence contradicted the same file release section, which
already said R-168 mails the failure -- a contradiction inside one instruction file, the exact
class the R-229 work exists to find.

REPORT.md deliberately NOT overwritten in the sibling repos: a one-line docs correction must not
destroy the record of their last real implementation.
2026-08-06 11:02:55 +02:00
admin 7db42c5fec docs: CLAUDE.md becomes a core plus path-scoped rules (R-229)
gates / gates (push) Successful in 12s
215 lines -> 110 (92 effective; block-level HTML comments are stripped before
injection and never reach the model, verified empirically on Claude Code 2.1.222
with a control and a treatment run).

Four new .claude/rules/*.md, each with a paths: glob list so it loads only when a
matching file is read: gates, ui-hungarian, backup-paths, agent-coupling.

The ## Layout tree was deleted as derivable; REUSE.md already owns the per-package
seams its annotations stood in for. The host/access table was deleted in favour of
a pointer to documentation/operations/nodes.md -- it carried three defects at once:
demo-felhom given as the LAN fallback address as if it were the route, a pinned
"agent 0.93.0" against the project's own no-versions-in-docs rule, and the claim
that no drill VM was provisioned on demo-hp. Measured live: qm list shows VM 300
drill-r50. felhom-agent/CLAUDE.md was right; this file was wrong.

Kept verbatim: the seven session-critical invariants, the F9 live-validation fence,
the end-of-session checklist.

controller_gates.py registers the shared instructions gate (felhom.eu/scripts/,
never copied here; an absent sibling clone FAILS).

Docs only -- no Go, no version bump, no image, no deploy.
Ledger: felhom.eu/documentation/audits/LEDGER-instruction-trim-2026-08-06.md

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJc8sAGRWmavP3rMtdpkr2
2026-08-06 09:38:27 +02:00
admin a62bb3874b REPORT + CONTEXT: v0.202.0's rule, its live proof, and what was NOT verified live
gates / gates (push) Successful in 9s
CONTEXT gains the rule so it outlives the bug: on the unlock path the customer
is blamed only after a real attempt REFUSED their code; every other outcome,
including an unclassifiable one, says something else. Plus the two things that
must not be 'fixed' into it — elapsed time is never a classifier, and the error
TEXT is never read (when the distinction was not a value, the agent was changed
to provide one).

REPORT states the split honestly: the AGENT half is proven live on the venue
(400 -> 502 -> 400, same wrong code, only the hub's reachability changed), while
the controller's message selection rests on handler tests and red-proofs,
because /recovery correctly redirects since F7 set the old data aside and
restoring that state is the reconfiguration §11 forbids. Also records that the
correct codes were shredded by the previous session, so the live re-run used a
WRONG code — which makes the test harder, not weaker.

Two venue changes stated because they were not asked for, both restorations: a
fresh dashboard password (the previous session shredded it, leaving the box
impossible to log into) set through the supported --print-reset-code escape
hatch, and one normal off-site run to populate stats_known.
2026-08-06 08:34:19 +02:00
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
admin 05cf352a2f docs: CAMPAIGN-11 fix pass — REPORT + CONTEXT (controller v0.201.0)
gates / gates (push) Successful in 8s
2026-08-05 18:03:50 +02:00
admin a3499d1807 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.
2026-08-05 17:48:08 +02:00
admin a315d623b8 docs: R-193 CLOSED — CONTEXT + REPORT (controller v0.200.0)
gates / gates (push) Successful in 10s
2026-08-05 12:56:43 +02:00
admin 62b85ecf13 CHANGELOG: controller v0.200.0 (R-193, the recovery screen)
gates / gates (push) Successful in 9s
2026-08-05 12:46:13 +02:00
admin 636c51e542 R-193: the recovery screen — unlocking, and only unlocking (v0.200.0)
A customer whose machine was rebuilt had everything needed to get their data
back and no way to find out: the only route was a command line. This is the
screen that closes that.

IT UNLOCKS, AND ONLY UNLOCKS (operator ruling). It explains, takes the recovery
code, opens the repository and shows what is in there — apps, dates, sizes. It
restores nothing: restore is already per-app and lives in the backups area, and
a screen that unlocks and then offers to overwrite is two decisions wearing one
button.

ONE CORE, TWO CALLERS. RecoverInstallCore is split out of RecoverAndInstall; the
CLI wrapper keeps its exit codes and printed lines byte-identical, and the
handler drives the same function. Two implementations of the one operation that
can permanently lose a customer's data would drift, and only one would be
tested. Asserted from source on both sides by AST.

THREE WAYS OUT, none a dismiss button: recover; 'most nem' (the full page stops
interrupting, the backups-area entry point stays PERMANENTLY, bound to the offer
and never to the postpone flag); and 'I do not want the old data' — confirmed
TWICE and reaching the SHIPPED move-aside, which sets aside and never deletes.

THE CODE IS HANDLED NO MORE LOOSELY THAN ON THE COMMAND LINE: POST body only,
never logged, never persisted, never echoed, cleared on every path, no-store,
autocomplete off. No lockout — the code is a ten-word phrase, and locking a
customer out of their own data for a typo is worse than anything it prevents.

TWO DEFECTS THE TESTS CAUGHT, both fixed: an UNCLAIMED (legacy-open) box would
have been shown the page, because RequireAuth passes such a box through; and the
inventory nil-dereferenced when no off-site target was configured, which is
exactly the pristine rebuilt shape.
2026-08-05 12:45:48 +02:00
admin be3c5fa7f6 docs: R-204 item 4 (box half) — CONTEXT + REPORT (controller v0.199.0)
gates / gates (push) Successful in 10s
2026-08-05 11:06:19 +02:00
admin 992803c10b CHANGELOG: controller v0.199.0 (R-204 item 4, box half)
gates / gates (push) Successful in 10s
2026-08-05 10:49:20 +02:00
admin a91f055960 pre-push: refuse a push from a clone outside the felhom workspace (R-204 rider)
Identical to the assertion added in felhom-agent 0404f60 and
app-catalog-felhom.eu ee2c810. See those commits for the reasoning.
2026-08-05 10:47:58 +02:00
admin 1214bae0a2 R-204 item 4 (box half): a rebuilt box DECLARES that it needs a credential (v0.199.0)
An absent off-site object has four meanings — never configured, mid-restart, a
transient config read failure, and rebuilt-and-stranded — and the hub cannot tell
them apart. The box can, from two local facts it holds with certainty, so it says
so instead of leaving the hub to deduce it from a silence (operator ruling).

The ACK's identity_blob_present is now recorded on EVERY ACK, before the gates
that used to discard it: on a box with no off-site target the auto-confirm returns
immediately, which is exactly a rebuilt box, so the one fact distinguishing it from
a box that never had off-site backups was thrown away every cycle.

The declaration needs BOTH halves — a fresh data area AND a hub-held recovery
package. Freshness alone is a box that never had off-site backups; dropping that
condition makes the whole fleet ask for credentials, which is what the Scenario B
test exists to catch.

The object carries enabled:false and zero sizes, which is what makes it inert to
the hub's existing fill and staleness checkers and to a pre-upgrade hub. A
configured box's JSON is byte-identical to v0.198.0's.
2026-08-05 10:47:51 +02:00
admin 68f195676b docs: R-204 items 1 & 3 — CONTEXT, REPORT, README (controller v0.198.0)
gates / gates (push) Successful in 9s
2026-08-05 07:37:25 +02:00
89 changed files with 11286 additions and 401 deletions
+43
View File
@@ -0,0 +1,43 @@
---
paths: ["controller/internal/agentapi/**"]
---
# Coupling to the host agent — felhom-controller
`internal/agentapi` is **the disk seam**: the pinned-TLS client to the host agent's per-guest local
API. The controller holds no Proxmox credentials; everything disk/host/Proxmox goes through here.
## Declaring a coupled feature
Controller behaviour that depends on a specific agent version needs **all three**, or it ships broken
on an older box:
1. a `featureProbes` table row in `internal/agentapi/features.go`
2. a `Supports` gate call **at the feature's entry point** — not somewhere on the path to it
3. `MinAgent: X.Y.Z` in the CHANGELOG entry header
Rules: `felhom.eu/documentation/runbooks/publish-train-rules.md`.
## Never push a controller past the agent it depends on
The R-216 guard compared the box's agent against the **golden's** MinAgent while serving a **floor**
that could point elsewhere. Raise a floor above the vouched golden — which the day-0 runbook
recommends and a per-customer override makes trivial — and the guard checks a version it is not
serving. A box then landed on a controller needing a newer agent, and its customer was told a correct
recovery code was wrong.
**A floor above the vouched golden is HELD, with its own reason** (hub v0.97.0).
## Distinguish "could not reach" from "wrong answer"
A failed bundle FETCH must not be reported to a customer as a bad recovery code. Classify by **value**
(`ErrBundleFetch` → HTTP 502), never by error string — a string is not something a caller can branch
on. Unknown class → neutral message, never the typing message.
<!--
R-224, measured live 2026-08-05 (CAMPAIGN-11 F3/F4) with a correct current code: 0.0556 s with the
hub firewalled off and 0.0299 s with the agent stopped, against ~1.0 s for a genuine unseal — the
machine accused the customer of something it had not attempted. A green test named this exact
consequence since v0.125.0 and did not prevent it, because it asserted this package's error STRING
one layer below where the merge happened. Fixed agent v0.126.0 + controller v0.202.0.
-->
+41
View File
@@ -0,0 +1,41 @@
---
paths: ["controller/internal/backup/**", "controller/internal/appbackup/**", "controller/internal/recovery/**", "controller/internal/appexport/**", "controller/internal/quiesce/**"]
---
# Backup, recovery units and export — felhom-controller
## Assert the consequence across the whole run, not the mechanism inside one function
The R-181 recovery-unit refusal claimed *"the previous unit is untouched and NOTHING was deleted"*.
*Nothing deleted* held; **untouched was measured false** — the floor was checked ONLY in
`captureAllRecoveryUnits`, while the two dump legs wrote the bulk into the same tree first and
unguarded, so a 182,272 B tar became 2,147,666,432 B under a manifest that had not moved. A full
green suite plus three of its own red-proofs missed it, because every one asserted the mechanism
inside `captureAllRecoveryUnits`.
**The test that catches this class: fingerprint the tree before and after the whole backup run, and
compare.** Full doctrine and the other eight instances: the `felhom-testing` skill.
## Presence is not success
A timestamp recording an **attempt** must never be read as evidence of a **result**. Where a status
field travels alongside a timestamp, the verdict consults both — or the timestamp records only
successes. Ask of any timestamp: *what exactly must have happened for this to be set?* If the answer
is "we tried", it cannot answer "did it work".
**Corollary:** when a verdict changes which field it counts from, the alarm text has to change with
it. `last run 8h ago` while alarming on a six-day-old success turns a true alarm into one the
operator dismisses.
<!--
Two instances. F-CRIT-2: a phantom snapshot's ctime set tier freshness — an aborted 1-byte upload
made the tier look backed up. R-100: LastRun is written on failure, so a nightly-failing offsite
tier kept the staleness clock fresh forever.
-->
## Storage keys and paths
- Never guess a persisted key — it is `offbox`, not `offbox_target` (R-7b).
- `.fab` export/import uses strict segment validation; bundles from controller ≤0.124.0 are hollow.
- Recovery-unit restore and tier-2 copies share `appbackup`'s path primitives — change them there,
once, not per caller.
+54
View File
@@ -0,0 +1,54 @@
---
paths: ["controller/**/*.go", "controller/**/*.html", "controller/**/*.css", "controller/scripts/**"]
---
# Gates and logging — felhom-controller
## The ONE entry point
**Run `python3 controller/scripts/controller_gates.py` (from `controller/`) after ANY change in this
repo.** It runs all seven local gates — `template_id_gate`, `emoji_gate`, `native_confirm_gate`,
`offbox_rename_gate`, `app_row_dedup_gate`, `mojibake_gate`, `docker_run_volume_path_gate` — plus
`reuse_refs_check` and `instructions_gate` on the repo root, streaming each gate's own output and
exiting non-zero if any fails.
- `--fast` selects the gates that touch no network and no container runtime; today that is all of them.
- **A missing gate script is a FAILURE, never a skip.**
- **The shared `reuse_refs_check.py` and `instructions_gate.py` live in `felhom.eu/scripts/` and are
never copied here** — a copy would recreate the drift they detect; an absent sibling clone FAILS.
- **The pre-push hook** (`.githooks/pre-push`) runs it with `--fast` and refuses a failing push. It is
per-clone — switch it on once with `git config core.hooksPath .githooks`, and a manual run WARNS
when this clone is unarmed. `git push --no-verify` bypasses it deliberately; **say so in the session
report when you use it** — CI re-runs the same entry point on every push and **emails the operator
on failure**, so a bypass is noticed even though it is not blocked (R-168, CLOSED 2026-08-02).
<!--
WHY A RUNNER AND NOT SEVEN INVOCATIONS (2026-08-02, R-29) — rationale, not a directive.
A census of all thirteen gates across the four repos found that every check a CLAUDE.md named was
passing, and two of the four nobody is told to run were failing. This repo's CLAUDE.md used to name
two of the seven; the other five were reachable only through a line in REUSE.md, and
docker_run_volume_path_gate.py was RED. The single-entry-point shape is the only one that
demonstrably gets run. app-catalog-felhom.eu/scripts/catalog_gates.py is the canonical version of
the runner (R-161); repo_gates.py copies it. site_gates.py is a *gate*, not a runner — do not model
new work on it.
-->
## Logging
New leveled lines use `internal/logx` — DEBUG always reaches the debug ring; stdout respects
`logging.level`. English, keys-never-values, durations on outcomes. Full rules:
`felhom.eu/documentation/runbooks/logging-conventions.md`.
## Health checks issue no block I/O
A probe that touches a wedged device enters uninterruptible sleep, survives `SIGKILL`, and cannot be
recovered until the device returns or the host reboots — so `systemctl restart` hangs too. A timeout
protects the caller's control flow and nothing else: the blocked thread remains. Liveness is decided
from `/proc` and kernel state, never by reading or writing the filesystem.
<!--
Measured, R-117 spike §6.3 (felhom.eu/documentation/audits/SPIKE-r117-bind-liveness-2026-07-30.md):
a probe stayed in D state 3m50s after kill -9; a buffered write with no fsync blocked too (O_CREAT
needs journal access); and statfs/getdents returned HEALTHY on a namespace that EIOs every byte —
fast, and wrong.
-->
+27
View File
@@ -0,0 +1,27 @@
---
paths: ["controller/internal/web/templates/**", "controller/internal/web/**/*.go", "**/*.css", "**/*.html"]
---
# UI and Hungarian copy — felhom-controller
- **All UI text is Hungarian**, Budapest timezone.
- Design tokens, badge/colour rules, the 2px/no-shadow/no-emoji/BOM hard rules and the mechanical
gate to run after each surface: **use the `felhom-ui-design` skill.**
- Template methods need **value receivers** — pointer receivers compile, pass `go vet`, pass the
suite, and then 500 at render time.
## Grep fetched pages with ASCII-only substrings
Accented Hungarian patterns get mangled through the `ssh → pct exec → bash -c` chain and return a
false `0` — which reads exactly like the banner or string being gone. Use `kezel`, `Utols`,
`Biztons`. **Never let an accented pattern gate a conclusion.**
<!--
From the 2026-07-20 remediation: an accented grep nearly produced a wrong "banner cleared" claim.
This is the "an absent line is not evidence" rule aimed at a UTF-8 transport, not at a log.
-->
## Credentials containing `!` or `'` break in heredoc-built helper scripts
History expansion eats `!!`. Use the proven inline `-d "password=$PW"` form for authed curl, and
delete any credential-bearing helper from `/tmp` (host AND guest) when done.
+35
View File
@@ -29,6 +29,41 @@ root=$(git rev-parse --show-toplevel 2>/dev/null) || {
}
cd "$root" || exit 1
# ── WORKSPACE-ROOT ASSERTION (2026-08-05, R-204 rider) ───────────────────────────────────────────
# Refuse a push from a clone outside the felhom workspace.
#
# WHY THIS IS A HOOK AND NOT A LINE IN A DOCUMENT: the workspace root is ALREADY written down, in
# documentation/runbooks/workspace-CLAUDE.md and in the workspace-root CLAUDE.md ("stay inside it"),
# and work drifted into a home directory anyway. A rule that has failed once as a reminder is not
# fixed by writing it down again — it has to be asserted where it can bite.
#
# A PUSH IS THE RIGHT TRIGGER, deliberately: throwaway clones under /tmp for probes and red-proofs
# never push, so nothing legitimate breaks. Reads and builds elsewhere stay unaffected.
#
# Symlinks are resolved on BOTH sides before comparison, so a symlinked path neither falsely passes
# nor falsely fails. If the workspace root does not exist on this machine the check is SKIPPED, not
# failed — this hook must not brick a legitimate clone on a different host.
#
# The only bypass is the documented `git push --no-verify`, whose use is already reportable.
FELHOM_WORKSPACE_ROOT=/mnt/5_hdd/felhom.eu
if [ -d "$FELHOM_WORKSPACE_ROOT" ]; then
ws_real=$(cd "$FELHOM_WORKSPACE_ROOT" 2>/dev/null && pwd -P) || ws_real=""
root_real=$(pwd -P) || root_real=""
if [ -n "$ws_real" ] && [ -n "$root_real" ]; then
case "$root_real/" in
"$ws_real"/*) : ;; # inside the workspace — proceed
*)
echo "pre-push: PUSH REFUSED - this clone is OUTSIDE the felhom workspace." >&2
echo " clone: $root_real" >&2
echo " expected: under $ws_real (repos live in $ws_real/git/<repo>)" >&2
echo " Work in the workspace clone, or bypass with 'git push --no-verify'" >&2
echo " and state that you did in the session report." >&2
exit 1
;;
esac
fi
fi
if ! command -v python3 >/dev/null 2>&1; then
echo "pre-push: FAIL - python3 not found, so the gates CANNOT run. This is a failure, never a" >&2
echo " pass by default. Install python3, or push with --no-verify and say so." >&2
+931
View File
@@ -1,3 +1,934 @@
## v0.214.0 — the recovery screen stops hedging about a code it can now check (2026-08-12, R-311)
**MinAgent: 0.129.0**
**What was already right, and is worth saying first.** The screen did NOT bluntly accuse a customer
holding an older code: R-222/R-226 already hedged, naming both possible causes and the kept package.
That sentence was honest — *"innen nem tudjuk megkülönböztetni őket"*, we cannot tell them apart from
here. **It could not tell them apart because nothing ever looked.** Agent v0.129.0 looks, so the hedge
can become an answer.
**New failure class `RecoveryCodeOpensRetained`** on HTTP 422, gated by `FeatureRetainedRecoveryClass`
(MinAgent 0.129.0). The gate is the R-224 twin and is SEPARATE from `trustRefusal` on purpose: the two
name different agent versions (0.126.0 and 0.129.0) and a box can sit between them, where a 422 is a
shape we did not design and must not be read as a verdict. `ClassifyRecoveryFailure` therefore takes
both flags; the compiler found every call site.
**The message, and what it deliberately does not say.** It states the code is correct, names the
supersession date, says the earlier package is kept, and — the half a customer will otherwise assume
wrong — says the CURRENT backups are unaffected. It does **not** promise the older history can be
reopened from this screen: there is no in-product route to a set-aside store (the restore machinery
resolves its repository from settings and its password from one file), and the retained package may
itself predate the repository-password field. A conditional promise that turns out false on this
screen is worse than saying less — the R-202 lesson, on the highest-stakes copy in the product. It
routes to support, which CAN do it: the 2026-08-12 drill did exactly that by hand.
**An older agent keeps the hedged sentence.** Unknown → claims less → heals itself on update.
**The claim guard grew a surface (and immediately convicted something).** `retrieval_promise_gate.py`
scanned `internal/web/templates` only — while every recovery message is a Go string in a handler, i.e.
the highest-stakes copy in the product had never been scanned. It now scans `recovery_handlers.go`
too, with Go comments stripped for the same reason template comments are. On its first run it found a
PRE-EXISTING unregistered claim (`RecoverRefused`'s "reopening would overwrite it") — now registered
as an explanation rather than a promise. `visszanyit` joins the stems: the new message uses a fourth
verb for the same claim, and the gate's own history is what happens when it chases words not claims.
Six handler tests asserting which SENTENCE the customer sees, with red-proofs asserted applied —
including: make 422 unconditional and an agent that never looked is read as having looked; route 400
to the new class and a mistype is congratulated.
---
## v0.213.0 — the banner promises only what the box can still see is true (2026-08-12, R-302) — MinAgent 0.127.0
**The abandon countdown told every customer who had given up their off-site history: *„Addig még
visszaszerezheted őket a helyreállítási kóddal."* Unconditionally, on every page. It is false on a
reachable state — and it rendered on the same screen as the orphan card correctly saying we cannot tell.
**Why the obvious condition was rejected, recorded so nobody re-proposes it.** The natural proxy —
*does the hub hold a key different from the one this box uses?* — asks about the WRONG key. The
set-aside copies were written under an OLDER key the box no longer has, which is why they were set
aside. On a twice-rebuilt box the proxy answers “yes, promise it” about copies no key on file can open:
right in the ordinary case, wrong in the very case that started the investigation. **Demonstrated, not
argued** — under the proxy both Scenario B (package replaced) and Scenario D (legacy countdown) flip
back to promising.
**Instead the fact is recorded at the one moment it is a fact.** `startAbandonCountdown` pins the hubs
escrow key fingerprint as cached AT THE DECISION (`AbandonPinnedEscrowKeySHA256`). From then on the box
asks one exact question — *is the hub still holding that same package?* — rather than guessing which key
is which. Written once, never refreshed: a field re-read at render answers a different question. Same
shape as R-300s ownership record two sessions ago.
**⚠ IT IS A RECORDED ASSUMPTION, AND IT SAYS SO.** Nothing on the box records which key wrote the
set-aside copies. The pin presumes the package held at the decision is that one — true in the ordinary
rebuilt-box story, not provable, and wrong on a twice-rebuilt box. Written into the field comment and
into R-302 so it can be narrowed later rather than hardening into a fact.
- **The certain half always renders**: the deletion and its date. Only the retrieval clause is conditional.
- **Empty is not a match**, on either side — the hub sends “” for a package sealing no repository password.
- **A countdown started before this release carries no pin and takes the cautious branch.** Not
backfilled: that would assert as recorded-at-the-decision something read long afterwards.
- **A FOURTH and FIFTH instance of the same promise were found by sweeping every template.** The backups
page block (`„a mentéseid visszaszerezhetők, és a törlés elmarad"`) got the same condition — fixing the
strip and not the page would leave one contradicting the other. The abandon CONFIRMATION screen
(`recovery.html`) was deliberately left: it renders at the moment of the decision, where the promise is
true by construction, because that is the package about to be pinned.
**New gate — `retrieval_promise_gate.py`, and it pins the CLAIM rather than the word.** A string ban was
tried twice and failed twice (singular vs plural; then one verb vs another). It cannot simply be
broadened either: **the honest replacement copy contains the stem**, inside a question about whether the
thing is knowable. So every retrieval-claim occurrence across all 36 templates is now REGISTERED with a
reason, and unregistered ones fail. Proven by planting all three historical wordings in turn — each
convicted, each cleared on removal.
## v0.212.0 — the second promise (2026-08-12, R-299) — MinAgent 0.127.0
**R-299 — the orphan cards OTHER sentence made the same unevaluable promise, and the spec said it was
fine.** v0.211.0 fixed the confirm block; the EXPLANATION paragraph above it
(`internal/web/templates/backups_remote.html`) still ended *„a hozzájuk tartozó helyreállítási kóddal
később **visszaállíthatók lehetnek**”* — the identical claim in the plural.
**It survived for two independent reasons, and both are the interesting part:**
1. `SPEC-orphan-card-copy-2026-08-10.md` §1 listed that line as *“Accurate; keep”*. The spec has been
corrected.
2. **The regression guard matched one INFLECTION.** It asserted `visszaállítható lehet` (singular); the
card carried `visszaállíthatók lehetnek` (plural), which does not contain that substring at all. **A
guard matching one inflection of a Hungarian verb guards one sentence, not the claim.** It now
matches the stem `visszaállíthat`, so any conjugation fails. Proven by planting the exact shipped
plural: the stem guard convicts and quotes it, while the old singular guard does not match it.
**And it was the ALWAYS-VISIBLE half.** The paragraph fixed in v0.211.0 renders only after the customer
clicks „Új távoli mentés indítása…”. On first view the explanation is the only text they read — so
until now, the sentence a customer actually saw was the one still promising.
**The two accurate halves are kept**, because declining a promise must not turn into telling the
customer less than we know: the store IS orphaned (and why), and new backups genuinely cannot be
written. New ending: *„A meglévő mentések nem sérültek. Azt viszont ez a gép nem tudja megállapítani,
hogy később megnyithatók-e — ez attól függ, megvan-e még a hozzájuk tartozó kulcs. Ha szükséged van
rájuk, írj nekünk.”*
Also fixed in the guard itself: its failure message sliced the rendered HTML at a BYTE offset, which
cuts Hungarian mid-character and printed a replacement char — a garbled failure message reads like an
encoding bug in the product. It now slices on rune boundaries.
## v0.211.0 — the wall a rebuilt box could not get past (2026-08-10, R-280 / R-294 / R-295) — MinAgent 0.127.0
**R-294 / R-202 — the orphan card stops promising what it cannot know.** The card told a customer,
at the moment they had just lost their off-site history, that the old copies *„a hozzá tartozó
helyreállítási kóddal később visszaállítható lehet"*. The discriminator is
`host_escrow_superseded.identity_blob` and it lives on the **hub**; the box caches only
`HubEscrowIdentityPresent` (the CURRENT escrow) and no report or ACK field carries superseded-blob
retention. **The renderer could not evaluate the condition it was stating**, and for everything set
aside before hub v0.93.0 (2026-08-04 ~11:11Z) it is false and unfixable. Copy replaced verbatim from
`documentation/design/SPEC-orphan-card-copy-2026-08-10.md` §4: it states what happens, declines the
claim it cannot evaluate and says why, and names a route (write to us). Four render tests, per branch
of the gate.
**SPEC DEFECT FOUND AND NOT ACTED ON — `backups_remote.html:98` makes the same promise.** The spec
lists that line as *"Accurate; keep"*, but it ends *„a hozzájuk tartozó helyreállítási kóddal később
visszaállíthatók lehetnek"* — the identical claim in a different conjugation, which the spec's own
regression guard (`visszaállító` + `lehet`, singular) does not match. Left as-is deliberately: the
instruction is not to improvise Hungarian at the customer. **Needs a wording decision → R-296.**
**R-295 — one name per secret (controller half).** The claim page called the SAME three-word
dashboard code „Beállító kód" on the first-time branch and „Visszaállító kód" on the reset branch,
while the TEN-word escrow code is „Helyreállítási kód". Two near-homographs for two different
secrets; the collision cost a real code. „Visszaállító kód" is **retired**: the dashboard code is
„Beállító kód" on both branches (`claim.html`) and in both operator-facing strings (`claim.go`
the `print-reset-code` output and the lockout message), and where the one secret serves two
situations the **name is constant and the sentence changes**. **Naming only — no acceptance logic
moved**, pinned by `TestResetCode_StillAcceptedOnTheSetupPage`.
**Gate fix (instrument, not product).** `secret_in_markup_gate.py` treated a Go template comment
`{{/* ... */}}` as a rendered expression and convicted the prose explaining a fix for containing the
word "secret". Template comments are stripped by `html/template` and cannot reach the response body,
so they are now skipped — `<!-- -->` comments deliberately are NOT, because those do ship. Proven in
both directions: the gate passes the comment and still convicts a planted `{{.RecoveryPassword}}`.
**R-280 — after a reinstall the data drive can be re-attached, and the page stops promising a click
that does not exist.** Measured on the rebuilt demo-hp 2026-08-09: the restore page diagnosed the
situation perfectly, said *„Ez két kattintás"*, and pointed at a picker holding nothing. It was zero
clicks; getting past it needed an internal path no customer could produce.
**Why the obvious fix would not have worked, kept here because it cost the session an hour.** The
agent builds BOTH `initialize` and `attach` from its unclaimed-DISK scan, and widening that scan is
the fix the finding proposed. But the filesystem a rebuilt box must re-register is an **in-guest**
one — on demo-hp `/mnt/sys_drive`, the guest's own 70 GB data volume, which is what the escape hatch
actually registered. The agent enumerates HOST block devices and would have offered the 1 TB NVMe
(the `felhom-backup` target): the wrong drive, non-destructively attached, customer data still
unreachable. **So this ships in the controller and the agent is unchanged** — MinAgent stays 0.127.0.
- **`attach` now also carries the controller's own mounted-but-unregistered filesystems**
(`internal/web/attach_sources.go`), read from its own mount table — the controller runs in-guest
with `/mnt` bind-mounted in, so what it can see is what it can register. `initialize` is passed
through **untouched**: the format wizard's system/backup protection lives in the agent's scan and
is not widened by a single line here (pinned by `TestMergeAttachCandidates_InitializeIsUntouched`,
whose red-proof put the customer's data volume in the FORMAT list).
- **A union, not a replacement.** The agent's entries serve the case this wizard was built for — a
fresh external drive carrying a filesystem, not yet mounted — which a mount table cannot report
precisely because it is not mounted. Dropping them would fix the reinstall and break the USB.
- **These candidates are REGISTERED in place, never mounted** (`POST /api/storage/register-mounted`).
The posted path is re-derived server-side and refused if it is not currently offered, so the route
cannot register an arbitrary directory.
- **The „két kattintás" sentence is now conditional on the picker being non-empty**, and the false
branch says what is true and names a route. Both branches are render-tested.
- **Exclusions, each with a reason in the code:** the guest rootfs at `/mnt`, the intermediary-model
parent `/mnt/felhom-drives`, tmpfs/overlay, anything outside `/mnt/`, non-ext4/xfs, already-
registered paths — and **any bind alias of the rootfs, excluded by DEVICE**, because a bind
republishes a filesystem under a second path and registering that one would put app data on the
box's own root. That last guard was found by trying to build the live reproduction, not by review.
- **Fail-safe:** an unreadable mount table yields an EMPTY list, never a permissive one — and because
the page gates its promise on that list, "we could not look" renders as "we cannot offer this".
## v0.210.0 — two pictures that were not true (2026-08-08, R-259 / R-258) — MinAgent 0.127.0
Both are the same shape: something the box already knows, drawn as its opposite.
**R-259 — a disk we failed to read was drawn as a healthy empty disk.** `readDiskUsage`
(`internal/system/info_linux.go`) logged a `statfs` failure at DEBUG and returned, leaving the
caller's `TotalGB/UsedGB/AvailGB/Percent` at zero — and `usageColor(0)` is `"nominal"`. The
dashboard's most-looked-at meter therefore rendered „0.0 GB / 0.0 GB (0%)" with a 0%-wide bar in the
healthy colour. **"We could not look" and "there is plenty of room" were the same picture.**
`readDiskUsage` now returns whether the measurement succeeded; `SystemInfo` gains `DiskKnown` and
`HDDKnown`; and the template draws **no figure, no percentage and no meter fill** when unknown,
saying „A tárhely mérete most nem olvasható ki." instead. A healthy box is byte-identical to before,
colour band included.
**This session rules the convention** (`felhom.eu/CONTEXT.md` S-39): an explicit `…Known bool`
companion beside the figures, checked in the template — the shape `Offbox.StatsKnown` already uses,
whose own comment says *"a 0%-wide bar over an unread store is a picture of emptiness, and a picture
is a claim"*. Pointers and separate error fields are both legitimate Go, but a codebase with three
dialects cannot be gated. Existing call sites were **not** converted.
**R-258 — the per-app backup tick was green on presence, and red only on a global condition.**
`buildAppBackupRows` set `Tier1LastStatus` from `status.LastDBDump.Success`, which is the box's
single most recent dump **run, whichever app it belonged to**. An app whose own dump failed showed a
tick as long as some other app dumped successfully afterwards, and an app with no database took the
`nil` branch and went green on the mere existence of a restore point.
Now `appDumpVerdict` reads THIS app's own entries in `DBDumpStatus.Results` (matched on
`DumpResult.DB.StackName`, failure = non-nil `Error`). **Three states:** any failing database →
`error`; all clean → `ok`; **no result recorded → no verdict and no icon**, with the title
„Erről a mentésről nincs eredményünk." The recovery unit carries no per-run outcome of its own, so
green cannot honestly be derived from presence. The global `tier1DBStatus` label is untouched.
**RECENCY IS DELIBERATELY NOT ADDED.** A tick over a three-week-old restore point is a real
weakness, but an age threshold means inventing a number and the time is already printed beside the
icon. Recorded as an observation.
**An existing test was asserting the defect and was corrected, not deleted:**
`TestBuildAppBackupRows_Tier1FromRestorePoints` expected `"ok"` for a status with no `LastDBDump` at
all — green from nothing but a file's existence. It now expects no verdict; its real subject, the
`Tier1LastRun` time, is unchanged.
Four red-proofs, each with the mutation asserted applied: reverting to the global field returns app
X's false green; mapping "no result" to `ok` returns green-on-presence; ignoring the known-flag
returns „0.0 GB / 0.0 GB (0%)"; forcing the flag false shows a healthy box losing its numbers.
**No new tag on any declared wire** — `report/builder.go` maps into its own types and is untouched;
`wire_contract_gate.py` confirmed green.
## v0.209.0 — the box stops saying a false thing about its own recovery package (2026-08-08, R-247 / R-260) — MinAgent 0.127.0
**R-247, and it is the third instance of one shape: the answer was on the wire and was discarded at
the boundary.** The hub has sent `escrow_stale` in the report ACK since v0.57.0
(`json:"escrow_stale,omitempty"`). `report.EscrowStatus` had no field for it, so `encoding/json`
dropped it, and an empty `restic_pw_sha256` had exactly one possible reading here — *hash-less
supersession*.
On `demo-hp` that reading was **false in every clause** for four days, and the box said so in its own
words: the hub HAD the hash and was withholding it because the escrow row carries a stale flag
(R-246); there had been no supersession; and the bundle DID cover the password — the hashes matched
exactly.
**Fixed by receiving the field.** `EscrowStatus.Stale` now decodes, and `reconcileEscrowed` tells the
two conditions apart. A withheld hash now reports that the hub has flagged the row and is withholding,
that **this box therefore cannot verify its bundle either way**, and that it is *not established* that
the bundle fails to cover the password. The genuinely hash-less case keeps its original wording.
**Deliberately NOT changed:** the stale verdict itself (the hub's flag is still the hub's verdict, and
runs still continue), and the customer-facing card copy. Clearing the wrong flag is an operator act
hub-side and is R-246; re-wording the Hungarian card is UI work with its own review path. This change
is the wire and the diagnosis.
`felhom.eu/scripts/wire_contract_gate.py` (G-1) now refuses any new field of this shape on the three
declared wires.
## v0.208.0 — the last two secrets leave the page source, and a gate so there is no fourth (2026-08-08, R-254) — MinAgent 0.127.0
v0.207.0 removed a password from one page. The census that fix required found two more sites; this
closes both, and adds a check so the next one is caught rather than searched for.
### 1. An app's first-login password (R-254 site one)
`app_info.html` rendered `{{.InitialCreds.Password}}` into a `hidden` span — a **real per-install
credential**, read live out of the running container, in the response body of every render. `hidden`
stops a browser DRAWING it and nothing else.
The page now carries the non-secret half (username, note) plus a boolean; the value comes from
**`POST /apps/<slug>/initial-credentials/reveal`**, which **re-reads the container** rather than
serving a cached copy — caching it in the handler would put it straight back in the body one layer in.
`no-store`, CSRF-covered, and **logged as an act**. Both buttons (Megjelenítés *and* Másolás) go
through it; neither keeps the value between presses.
A reveal can now legitimately fail (container stopped, file deleted after first login) and **says so**
— an empty string would have rendered as a blank password.
### 2. The deploy form — established before changing (R-254 site two)
The task named the hidden input. **It is not the defect, and it was left alone:** it fires only on the
PRE-DEPLOY form, and `README §318` documents why the value must round-trip — the customer is shown the
generated secrets so they can note them down, and submitting them back is what makes the saved value
the same one they saw ("no silent re-generation on submit"). A form must carry what it submits.
**The defect was the neighbouring readonly display input.** On an ALREADY-DEPLOYED app the hidden
input is correctly omitted — nothing is being submitted — yet `<input type="password" value="{{$val}}"
readonly>` still rendered the secret into a page the customer merely opens. That is fixed by
**`POST /stacks/<name>/auto-field/reveal`**, authorised by requiring the field to be a `type: secret`
auto-generated field of *that stack's* catalog metadata. Both directions are pinned by tests: the
deployed page must not carry the value, and the pre-deploy form must still submit it.
**The premise that this contradicted a repo rule does not hold.** The rule is `CONTEXT.md:2070`,
*"Password fields require explicit input — prevents accidental empty-password deployments"*: it is
about EMPTINESS, not auto-fill. No line anywhere in the repo says "no silent auto-fill".
### 3. A gate, because three instances in two days is a pattern
`scripts/secret_in_markup_gate.py` (registered in `controller_gates.py`) reads all 36 templates and
convicts any `{{ … }}` whose expression names a secret, unless allowlisted with a stated reason.
**Its limits are measured, not estimated, and are in its own docstring.** It catches a launder through
a local variable (the assignment names the secret). It is **blind to a secret arriving under a neutral
page-data key** — `data["Tagline"] = creds.Password` then `{{.AppInfo.Tagline}}` passes it cleanly,
verified both ways. That is the shape of site two, which this gate would NOT have caught.
The complementary net is the runtime body assertion, which catches all of them — but needs each page's
data to be constructible, and **only 4 of 27 page templates have that today**. The other 23 have no
runtime coverage: **R-255**, filed rather than glossed. Two nets, different holes, both named.
### A correction to v0.207.0's report
It stated that HTML comments ship in the response body. **They do not, here** — this package renders
with `html/template`, which strips comments (measured: `text/template` keeps them, `html/template`
does not). A red-proof that plants a secret in a comment therefore correctly does **not** fail.
## v0.207.0 — a password stops living in the page source, and two refusals learn to say what to do (2026-08-08, R-249/R-252/R-253) — MinAgent 0.127.0
Three items the fifth walk exposed by passing. None of them touches the recovery path it proved; all
three are about what the product *says*.
### 1. The retrieval passphrase leaves the page body (R-249)
`settings_security.html` rendered the passphrase into a `display:none` span behind a „Megjelenít"
button. **That toggle stops a browser DRAWING the value and nothing else** — the plaintext was in the
response body of every render, so a `curl` of the page returned it. It was found by doing exactly
that: it landed in a session transcript on 2026-08-07 while driving the documented rebuild path.
**The product already had this rule and this page did not follow it.** `escrow_handlers.go` states it
for the recovery code — *"reveal (claim XHR only — R is NEVER templated server-side into HTML)"*. The
passphrase now follows the same shape: the page carries only `HasRetrievalPassword`, and the value
comes from **`POST /settings/retrieval-password/reveal`**, behind the same RequireAuth + CsrfProtect
every other POST sits behind, `Cache-Control: no-store`, **and logged as an act** — reading it off the
markup left no trace anywhere, where the hub's equivalent break-glass reveal has always emitted an
event.
**POST for a read, deliberately:** a GET would be re-fetchable from history, pre-fetchable, cacheable,
and — since CsrfProtect only covers unsafe methods — uncovered by CSRF.
**The test asserts the raw response body, not a rendered view**, because that is precisely why this
survived: every test that asked what the customer *sees* passed while the bytes carried the secret.
**Census (§7.1), reported not fixed:** the render-then-hide pattern appears **twice more**
`deploy.html` (an auto-generated app secret in a `type="password"` input's `value=`; unavoidable on
the pre-deploy form, which must post it, but not on an already-deployed app's page) and
**`app_info.html`, which puts a per-install generated app password inside a `hidden` span** — the same
shape with a real secret. Filed as **R-254**.
### 2. „nincs elérhető adatmeghajtó" now names the reason and the route (R-252)
A rebuilt box's drives survive; their **registration** does not. Every restore then refused with a
sentence that named no next step and read like data loss. The restore page now states the precondition
**before** the customer presses anything, says the backups and the drives are both still there, and
links to Tárhely → Meghajtók. The refusal string says the same.
The page asks the question through the backup manager's own `HasRestoreDestination()`, which reads the
**same** `GetSchedulableStoragePaths()` the resolver reads — a second copy of that predicate is exactly
how a page ends up promising what the handler refuses, which is the next item.
### 3. The page no longer promises a reinstall the restore cannot do (R-253)
The restore list said **„Nincs telepítve — a visszaállítás előbb újratelepíti."** Three lines later the
restore refused *because* the app was not installed. Two shipped sentences, in the customer's own
language, contradicting each other at the last step of a recovery.
**The promise was the wrong half, and this is why:** reconstitution writes to the app's own data path
(`GetStackHDDPath`), which exists only once the customer has chosen a drive at deploy time. An
automatic reinstall would mean the product picking that drive for them — the one decision this whole
recovery path exists to leave with the customer. So the copy now says to install it first and routes
to `/stacks/<app>/deploy`; the refusal was reworded to match.
**A healthy box renders exactly as before** — both notices are conditional, and a test fails if either
becomes unconditional.
## v0.206.0 — the box does not mint a key over a sealed package, and abandoning ends the question (2026-08-07, R-241) — MinAgent 0.127.0
**R-241 was ruled a MINTING defect, not a screen-predicate defect** (`SPIKE-r241-recovery-offer-2026-08-07.md`),
and that reversed the fix. The recovery screen was telling the truth: there genuinely was nothing
recoverable under the key the box held, **because the box minted that key itself, over the top of a
sealed package it already knew the hub was holding.** Mending the screen would have papered over a
machine quietly making its own backups unopenable.
### 1. It stops minting
`WriteOffboxSecrets` auto-generated on **one** input — does the file exist. Its two neighbours in the
same file, `OffsiteRecoveryOffer` and `needsOffsiteCredential`, both consult
`GetHubEscrowIdentityPresent()`. **The same fact was available on three paths and used on two.**
Measured on the final walk: the credential self-heal reached it at 03:18:06Z and minted `9b4a9a9d…`
over a package sealing `30ef574f…`. The flag was not merely available at that moment — it was the
**precondition of the chain that reached the function**, logged at 02:48:03Z, six ticks earlier.
The guard is a **conjunction** (a package held AND no key present), so a first-time box mints exactly
as before. The refusal is a **holding state, not a failure**: the transport is still written, so the
recovery screen can bring the tier up the instant the key arrives (R-219). Returning an error instead
would have left the hub re-staging a consumed credential for ever. New declared state
`offsite.state=awaiting_recovery_key`, shown inert to every existing hub reader from their code.
### 2. The comparison it already made now drives the offer
`EscrowAutoConfirmer.Reconcile` has compared the hub's `restic_pw_sha256` against the local key on
every ACK since SLICE 3. On the venue it logged the mismatch at **03:28:03Z — thirty-five minutes
before the customer looked** — and threw it away. It is now persisted, and `OffsiteRecoveryOffer`
gains **shape (c)**: the hub holds a package for a key other than the one we are using.
**§7.2, decided deliberately:** a **known difference offers however old the reading** (age is not
gated on — gating would make a box offline from the hub silently stop offering); a **hash never
learned falls back to (a)/(b)**, because an empty hash is the hub positively saying its package seals
no key, not an unknown.
### 3. Abandoning is now a finishable thing
Setting the old history aside used to touch neither the escrow nor the key, so the hub went on holding
a package for a key nobody used and the question returned at every login. It now starts a **14-day
countdown**, visible and reversible, at the end of which the set-aside store **and the sealed package
that protects it are removed together** — after which shape (c) has nothing to compare and the offer
falls silent **because the state is right, not because something remembers it once was not**.
The grace is real: the recovery offer stays reachable throughout. The two halves cannot be atomic
across two machines, so it is a two-phase commit whose confirmation rides the **same ACK** that
carries the request. Needs hub **v0.98.0**.
### 4. The surface, and the trap that does not survive this session
The full page appears **once per entry into the offered state, not once ever** (an epoch, so a box
rebuilt months later is a new situation). Three dismissal levers with three scopes — a per-visit
session cookie, a durable epoch-scoped reminder opt-out, and the existing "most nem" — and **none of
them removes the entry point on the backups page.**
**§7.3 / Q7:** while a recovery is outstanding, „Helyreállítási kód létrehozása" is now **unavailable**
rather than merely captioned. Creating a new code seals the current key, demotes the package that
opens the earlier history to retained custody no shipped path can read (R-199), and re-enables the
screen while invalidating the code it accepts. A warning beside a button is a warning people click
past.
**The abandon confirmation changed with the behaviour (§2.4):** it used to promise *„félretesszük —
nem töröljük"*, and after this the history **is** deleted, on a date it now states.
### 5. Reminders and operator levers
Escalating emphasis at 1/3/7/14 days for an undecided box, 5/3/1 days remaining for an abandoning one.
`--abandon-status` / `--abandon-extend=N` / `--abandon-stop` on the controller CLI, because the path
that actually happens is the customer telephoning. Both levers **refuse rather than no-op** when
nothing is running or the store is already gone.
**The automatic 30-day abandonment is recorded and NOT built** → R-245.
### Caught by tests rather than review
Two real bugs in this change: `OffboxAwaitingRecoveryKey` omitted `t.Enabled`, so a customer who had
switched off-site off would have declared a holding state (caught by the existing
`TestOffsiteDeclare_DisabledTargetIsNotStranded`); and `recoveryInterrupts` returned early when the
offer was false, so the **falling** edge was never recorded and the page never came back — the exact
defect the epoch exists to fix, reintroduced inside the fix.
## v0.205.0 — a backup that skipped an app the customer chose is not „Rendben" (2026-08-06, R-234) — MinAgent 0.127.0
**Two defects, and the one that actually produced the measured sequence was NOT the one filed.**
### The verdict now counts a skipped selection
The R-203 verdict block already carries the sentence *"a warning beside a success is read as a
success"* — and applied it to **one of the two shapes it describes**. An app missing a declared
mandatory FOLDER made the run `incomplete`; an app skipped **entirely**, with nothing of it in the
snapshot at all, still reported `ok` with a warning beside it. The smaller gap moved the verdict and
the bigger one did not. It does now.
**Which skips count (§7.2), decided by measurement rather than assumption:**
| skip | counts? | why |
|---|---|---|
| selected + **deployed**, no recovery unit | **yes** | the app the customer chose is not protected |
| selected but **not deployed** | **no**, but NAMED with what to do | a box left amber forever by an app somebody removed is a status nobody reads |
| drive disconnected / decommissioned | **no** | it has its own card and its own signal |
| nothing selected at all | **no** | unchanged: the existing zero-selection notice |
The operator signal is **reused, not mirrored** — a skipped app is reported through the existing
mandatory-gap notification as a whole-unit gap, so one vocabulary covers both.
`LastSuccess` and `SnapshotCount` still record what WAS captured: half a backup is not no backup.
### The measured cause: a manual run silently dropped by the single-flight
Reproduced on demo-hp: **the pre-dump phase (`captureAllRecoveryUnits`) writes a unit for every
deployed stack before the push**, so "selected but no bundle yet" does not normally survive a run —
moving a unit aside and running recreated it and reported `ok`. So the filed mechanism could not have
produced the 2026-08-06 sequence.
What did: the customer pressed „Távoli mentés most”, the handler answered „A távoli mentés elindult”, `acquireRunning` refused because a run was already going, and the run
returned **nil** — no error, no signal. The card then showed the **previous** run's „Rendben", which
reads as covering the app just selected. It did not, and the restore refused minutes later.
The single-flight decision is now taken **synchronously in the handler**, before the goroutine, and a
dropped request says so. The nightly path is deliberately unchanged: returning nil is right for it —
nobody asked, and the next scheduled run retries.
### §7.3 — the wait, measured before deciding
`CaptureRecoveryUnit` writes compose config + a manifest (**a few KB**, per `admission.go`'s own
note), **enumerates** dumps already present rather than creating them, is idempotent, and **does not
stop the app**. It already runs for every deployed stack inside the off-site run's own pre-dump phase,
through `admitApp`. **So the inline capture this task contemplated already exists — nothing was built**,
and for a deployed app there is no wait to remove.
### Hungarian
- „Ezek az alkalmazások NEM kerültek be a távoli mentésbe, mert még nincs helyi mentési egységük: %s. A következő mentés általában már elkészíti — ha a második futás után is itt szerepelnek, szólj az üzemeltetőnek.”
- „Ezek az alkalmazások ki vannak jelölve távoli mentésre, de nincsenek telepítve, ezért nem menthetők: %s. Ha már nincs rájuk szükséged, vedd ki a kijelölésüket a Távoli mentés oldalon.”
- „Már fut egy távoli mentés — ez a kérés nem indított újat. A most látható eredmény még a korábbi futásé; várd meg, míg ez befejeződik.”
- sibling (mandatory folders), extended so both read alike: „… Ellenőrizd, hogy a mappák megvannak-e a meghajtón; ha igen és ez a következő mentés után is látszik, szólj az üzemeltetőnek.”
## v0.204.0 — what you can restore is decided by the store, not by what happens to be installed (2026-08-06, R-237 / R-238) — MinAgent 0.127.0
**A household that had just lost its box was shown nothing to restore.** Measured live on the R-201
re-walk (`felhom.eu/documentation/tests/part4-rewalk-2026-08-06/journal.md`): after a rebuild, with
the key recovered, the tier configured and the escrow re-sealed, `/backups/restore` said „Nincs
telepített alkalmazás" and the wizard refused every app with „Ez az alkalmazás nincs távoli mentésre
kijelölve" — while the repository held their snapshots the whole time.
**The list was keyed on the wrong thing.** It was `buildOffboxApps()` filtered on `.Enabled`: apps
**currently deployed** AND **currently toggled on for FUTURE off-site backups**. A rebuilt box has
neither. That is a circular dead end at the worst possible moment — to restore an app you must select
it, to select it you must have installed it, and to know what to install you must see the backup you
cannot see. **The toggle is a statement about future backups; requiring it to look at a past one
conflates two different questions, and that conflation was the defect.**
**The store is now the source of the list** (`internal/web/offsite_restore_list.go`), built on the
existing R-193 inventory (`OffsiteInventoryList`) which already reads the repository and groups by
app tag. Installed-ness became a property OF a row, never a filter on it: it changes what restoring
implies, not whether the row exists. Every case is answered rather than hidden —
| case | what the customer sees |
|---|---|
| snapshot present, app NOT installed | listed and restorable, plus „Nincs telepítve — a visszaállítás előbb újratelepíti." |
| app installed, no snapshot | listed, „Nincs mentése a távoli tárolóban — nincs mit visszaállítani." |
| store unreadable | „Nem tudjuk elolvasni a távoli tárolót, ezért **nem tudjuk, mi van benne**. Ez nem azt jelenti, hogy üres…" — **and the action is still offered**, because "we could not look" is not "there is nothing" |
| no target yet (the pristine rebuilt shape) | „A távoli tároló kapcsolódási adatai még nem érkeztek meg ehhez a géphez… Ez magától rendeződik." |
| store genuinely empty | „A távoli tároló üres — nincs mit visszaállítani." |
That unknown-is-not-empty rule is **R-225's, one screen over**, and it now points both ways: a read
failure must not be rendered as an empty list, and it must not silently withhold the action either.
**Two marker tags are excluded from the app list**: `felhom-offbox` rides on every snapshot, and
`_shares` has its own restore entry with no per-app wizard — listing either would have offered a
restore of something that does not exist. (The R-193 unlock listing still shows them; that is
recorded, not fixed here.)
### R-238 — the size gate no longer refuses in silence
**Classified as a harness artifact, and the residue fixed anyway.** `POST /backup/offbox/restore`
with `mode=full` and no `confirm=1` is step 1 of a deliberate two-step: it computes size + headroom,
**starts no job**, and redirects carrying `&full_prep=<app>` so `deriveWizardStep` reveals the
commit. A driver that does not carry that parameter forward lands back on the intent step — which is
`deriveWizardStep` working exactly as its precedence comments describe, and is why the endpoint-level
run read as "the button does nothing". **The operator's browser run completed the same restore.**
**What was genuinely wrong: neither branch of that step wrote anything to the log.** `restore-status`
is empty by design (no job), and `offboxRedirectTo` only flashes to the page — so a customer refused
a disaster restore, **including a refusal by the headroom gate**, left no trace on the box at all.
Both branches now log, and so does the concurrent-op refusal. Nothing about the wizard's precedence
rules was re-keyed: a stale parameter must still never resurrect a commit button mid-restore.
## v0.203.0 — the box collects what the hub staged for it (2026-08-06, R-218 consume half / R-220 message) — MinAgent 0.127.0
**R-218's declaration half shipped in v0.201.0 and works. Its consume half never existed.**
A stranded box says `offsite.state=needs_credential`; the hub's `offsiteheal` re-stages the one-time
secret and logs *"the box re-consumes on its next cycle"*. **There was no next cycle.** `Reconcile`
ran exactly twice in a process's life — once at start-up (whose own comment said *"retries on next
config refresh/restart"*) and once when the recovery screen drives it (R-219) — and **both fire before
the hub has anything staged, because the hub stages in RESPONSE to the declaration those runs
precede.** So the hub held a credential the box would never fetch.
**Measured on the R-201 re-walk, 2026-08-06:** unlock reconcile **11:43:07** · hub staged **11:44:57**
saying "next cycle" · a full report cycle ran **11:55:46** · **still unconsumed at 12:06**. A guest
command line applied it in **18 seconds** — proving the credential, the target and the key were all
correct and only the trigger was missing. That was the first of the two dead ends that kept the
recovery journey failing.
**The fix: re-run the SAME reconcile, on a tick, for exactly as long as the box says it needs a
credential.** `Bridge.RetryIfDeclared` is driven from the box's own published declaration
(`OffboxReportStatus().State`) — **the very statement the hub acts on**, so the two can never disagree
about whether a retry is wanted.
**Why a poll and not an ACK flag.** The deciding criterion was the promise the customer is given: the
no-target message says *"amint megvannak"* (no deadline) and the backups card says *"ha egy napon
belül nem áll be"* — **within a day**. A 5-minute tick is inside both by a wide margin and needs **no
hub change**. If either promise ever tightens to minutes, revisit.
**It stops by construction.** The instant a target exists the declaration goes false: a healthy box
does no work and **logs nothing** (asserted). **The settle gate is deliberately kept** — the retry goes
through `ReconcileWhenSettled`, so the day-0 floor race it guards is unchanged.
**The marker was investigated and left alone.** `applied_marker` lives at `<DataDir>/offbox/` — inside
the guest's data dir, which a rebuild destroys — so it cannot suppress a legitimate post-rebuild
re-run. It is not part of this defect.
### R-220's customer-facing half — a refusal that named an impossible action
The deploy refusal said *"Válasszon a listából csatlakoztatott meghajtót"* — choose an attached drive
from the list — **while the list was empty**, on a rebuilt box, for a reason the customer had no part
in. That is the I3 breach the campaign recorded. It now says what is true (the drive is not registered
**on this machine**, which is what a rebuild causes), points at the page where re-attaching happens
rather than at a possibly-empty list, and **promises no outcome**, because whether the drive can be
re-attached is not knowable from there. The NAS refusal is a different situation and is untouched.
Tests: `internal/offsiteapply/retry_test.go` (a credential staged after start-up is collected; a
healthy box does nothing and logs nothing; a nil bridge is silent; the settle gate holds) and
`internal/settings/refuse_message_test.go`. **Red-proofs:** removing the retry leaves the credential
uncollected — the re-walk's dead end reproduced; dropping the stop condition makes a healthy box
hammer the hub; calling `Reconcile` instead of `ReconcileWhenSettled` bypasses the settle gate;
restoring the old sentence brings the impossible action back.
## docs — the "CI is still owed" claim was stale; corrected (2026-08-06, R-229 part 2) — no version bump
**One sentence, no code.** This file asserted that continuous integration was still owed
(`felhom.eu` `OPEN-ITEMS.md` R-168). **R-168 was CLOSED on 2026-08-02** — a Gitea Actions runner
re-runs each repo's gate entry point on every push and emails the operator on failure. Found while
confirming this session's own push by run ID, which is the check that caught it.
The same stale sentence was in four instruction files across all four repos and is corrected in all
four. In `felhom-agent/CLAUDE.md` it **contradicted the same file's release section**, which already
said R-168 mails the failure — a contradiction inside one instruction file, which is the exact class
the R-229 work exists to find.
## docs — CLAUDE.md split into a core plus path-scoped rules (2026-08-06, R-229) — no version bump
**Documentation and gate registration only. No Go changed, no image built, no deploy.**
`CLAUDE.md` went from 215 lines to 110 (92 *effective* — block-level HTML comments are stripped
before injection and never reach the model, verified empirically on Claude Code 2.1.222 with a
control and a treatment run). What moved, rather than what was cut, is the point:
- Four new `.claude/rules/*.md`, each carrying a `paths:` glob list so it loads only when a matching
file is read: `gates.md`, `ui-hungarian.md`, `backup-paths.md`, `agent-coupling.md`.
- The `## Layout` tree was deleted as derivable (`ls controller/internal/`); `REUSE.md` already owns
the per-package seams and traps its annotations stood in for.
- The host/access table was deleted in favour of a pointer to `documentation/operations/nodes.md`.
**It carried three defects at once:** `demo-felhom` given as plain `root@192.168.0.162` (the LAN
*fallback*, not the route), a pinned `agent 0.93.0` against the project's own no-versions-in-docs
rule, and the claim that no drill VM was provisioned on `demo-hp`. **Measured live 2026-08-06:**
`qm list` shows VM `300 drill-r50`. `felhom-agent/CLAUDE.md` was right; this file was wrong.
- The seven session-critical invariants, the F9 live-validation fence and the end-of-session
checklist were kept verbatim — they are the file's highest-value content.
`controller_gates.py` now registers **`instructions`**, the shared
`felhom.eu/scripts/instructions_gate.py`. Never copied here; an absent sibling clone FAILS.
Full per-block accounting: `felhom.eu/documentation/audits/LEDGER-instruction-trim-2026-08-06.md`.
## 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
byte-identical; the journey did not exist** — four operator interventions stood between a customer and
their files, and the first one was the machine telling them their perfectly correct recovery code was
wrong. This closes six of the nine findings.
**R-216 — the headline. A 404 was reported to the customer as a bad code.** The unseal lives in the
agent behind `POST /escrow/recover-offsite-password`, which ships in agent **v0.125.0**. On an older
agent the route 404s, and the unlock was attempted anyway: *„A megadott helyreállítási kódot nem
fogadtuk el. Ellenőrizd, hogy mind a tíz szót pontosan…"* — measured live in 0.134 s, far too fast for
`age`'s scrypt, against a code that was perfect. **It was the default state, not a misconfiguration**:
the day-0 manifest vouches agent 0.120.0, and a reinstall actively *downgrades* a hand-fixed box back
to it. Three parts:
- `FeatureOffsiteKeyRecovery` now exists in `featureProbes` + `featureMinAgent` (0.125.0) with a
`Supports` gate at the unlock entry point — **the convention `features.go:72-76` and
`publish-train-rules.md` rule 4 already required, which v0.195.0/v0.196.0 declared in their headers
and v0.200.0 dropped when it put the same coupled feature behind a customer-facing button.**
- **This gate FAILS CLOSED**, alone in that table: anything but `SupportYes` says *the machine* cannot
ask yet, and no unlock is attempted. An attempt that cannot succeed must never be made, because its
failure is what got attributed to the code. The asymmetry is documented beside the row.
- The hub's own guard could not catch it (see the hub changelog, v0.97.0).
**R-218 — succeeding at recovery stopped the box asking for what it still needed.**
`needsOffsiteCredential` short-circuited on a repository password existing — and installing one is the
recovery screen's whole job. 32 seconds after the hub re-staged the credential, the customer's success
switched off the mechanism that would have delivered the coordinates for the key they had just
recovered. **The short-circuit is deleted**; the declaration now stops when the tier works, not when a
key exists. Scenario E (a deliberately disabled target stays silent) is pinned by its own test.
**R-219 — the promised listing could never render on the shape the screen exists for.** Listing needs
a target; a target cannot exist without a repository password; shape (a) is defined by having none. The
unlock now **finishes the job** — place the key, bring the tier up, then list — and a tier that is not
up yet says *waiting for the connection details* rather than reporting a failure.
**R-217 — an unreadable store claimed to have opened.** The failure path passed
`backup.OffsiteInventory{}`, whose `Empty=false` the template read as *„A tároló megnyílt, és van benne
tartalom, de nem tudtuk alkalmazásokhoz rendelni."* — three assertions, none of them known. The field
built to prevent exactly this says so in its own doc comment. Opened / empty / unreadable are now three
distinguishable states.
**R-222 — reaching for a retained earlier package read as a wrong code.** The hub's ACK carries
`superseded_present` / `superseded_at` (hub v0.97.0); the screen now names that situation. It states
the two facts the hub knows and **does not promise the earlier package can be opened** — that read path
does not exist.
**R-215 — `GET /recovery` rendered the recovery story on a box that never had backups.** The predicate
was right and the page never asked it. Gated on the same predicate as the interception.
**Four messages where there was one**, each saying what to do next and only one mentioning typing.
Left open deliberately: **R-214** (the console never stops showing a stale pairing code), **R-220**
(drives cannot be re-enrolled after a rebuild), **R-221** (a rebuilt box cannot run the escrow ceremony
— a real blocker for that flow).
Tests: `recovery_gate_test.go` (Scenarios A/F/G/H/I, handler-level), `offbox_declare_test.go`
(Scenarios D/E). **Five red-proofs, each demonstrated failing and restored** — removing the capability
gate brings the accusation back verbatim.
## v0.200.0 — the recovery screen: unlocking, and only unlocking (2026-08-05, R-193)
R-193's remaining half. The drill proved a customer's file comes back after a machine is destroyed;
the sessions since removed every step that needed the operator. What was left is that the customer had
no way to *begin* without a command line.
### It unlocks, and only unlocks (operator ruling, 2026-08-05)
The page explains the situation, takes the recovery code, opens the repository, and shows what is in
there — which apps, from when, how big. **It restores nothing.** Restore is already per-app and already
lives in the backups area; putting files back is a separate item and is not started here. A screen
that unlocks and then offers to overwrite is two decisions wearing one button.
### One core, two callers
`RecoverInstallCore` is split out of `RecoverAndInstall`. The CLI wrapper keeps its exit codes and
printed lines **byte-identical** — every pre-existing CLI test passes unchanged — and the web handler
drives the same function. Two implementations of the one operation that can permanently lose a
customer's data would drift, and only one of them would ever be tested. **Both directions are asserted
from source by AST** (comments dropped, so a commented-out call cannot satisfy them), plus a third test
that the routes and the landing-page interception exist at all.
### When it appears — two facts, and a third shape the task did not name
`backup.OffsiteRecoveryOffer` requires the hub to hold a sealed package **and** this box to be unable
to open what it protects. The second half has **two** shapes:
- **(a) no repository password at all** — the pristine rebuilt box. This is the literal reading of
"the data area is fresh".
- **(b) a password exists but the inherited history will not open under it** (`RepoState == orphaned`).
**Shape (b) had to be added, and the reason is load-bearing.** `WriteOffboxSecrets` AUTO-GENERATES a
repository password when none is present — that is precisely R-193's orphaning mechanism — and since
hub v0.96.0's credential self-heal the re-apply now happens by itself within ~1530 minutes. Shape (a)
alone would have made this screen appear only inside a half-hour window that closes on its own, so the
customer who logs in the next morning — the actual customer — would never have seen it.
Shape (b) is also the state the shipped move-aside requires, which is what lets "I do not want the old
data" reach the existing handler rather than needing a new one.
**Claimed and behind the household password.** Added after a test caught the omission: a legacy-open
box (no password anywhere) reaches `ServeHTTP` through `RequireAuth`'s pass-through, so without an
explicit `authEnabled()` check the interception fired for an unauthenticated visitor.
### Three ways out, and none of them is a dismiss button
- **Recover** — the main path.
- **„Most nem"** — the full page stops interrupting. **The backups-area entry point stays,
permanently**: it is bound to `recoveryOffer`, never to the postpone flag, because the data is still
there whether or not anyone clicked and a one-shot notice a flustered person clicks past is a notice
that never happened.
- **„Nem kérem vissza a korábbi adatokat"** — the exceptional path, not an equal third button. Two
confirmations, the second naming exactly what happens, then the **shipped** `/backup/offbox/reset`,
which sets the store aside and never deletes. Offered only when that handler can actually run.
### The recovery code is handled no more loosely than on the command line
POST body only (`PostFormValue` — never a query string), never logged at any level, never persisted,
never echoed into any message, cleared on every path, the page served `no-store`, and the field
`autocomplete="off"`. **No lockout** (§8.4): the code is a ten-word EFF phrase, guessing is not the
risk, and locking a customer out of their own data for a typo is a worse failure than anything a
lockout prevents. Failed attempts ARE logged locally — without the code — so a box being probed is
visible in the debug ring.
### Two defects the tests caught before they shipped
1. **An unclaimed box would have been shown the page** (above).
2. **The inventory nil-dereferenced when no off-site target was configured** — which is exactly the
pristine rebuilt shape. It now returns a named error and the page says the key is back and the
listing will appear once the box has re-connected, rather than showing a failure the customer
cannot act on.
## v0.199.0 — a rebuilt box asks for its credential back (2026-08-05, R-204 item 4 / R-193)
The last of the four manual interventions the 2026-08-04 drill needed. A rebuilt box has no off-site
credential of its own — its predecessor spent the one-time provider password — and everything after
that point is already self-service. **This is the box's half: it now DECLARES what it needs.**
### Why a declaration and not an inference (the operator ruling, and the whole design)
From the hub, an **absent** off-site object has FOUR meanings — never configured, mid-restart, a
transient config read failure, and rebuilt-and-stranded — and the hub cannot tell them apart. **The
box can**, from two local facts it holds with certainty. So it says so, in its ordinary report, and
the hub acts on a stated request instead of on a silence.
### The two halves, and why neither is sufficient
`backup.needsOffsiteCredential` requires **both**:
- **a fresh data area** — no repository password on disk. Alone this is simply a box that never had
off-site backups, and declaring on it would make *every un-configured box in the fleet* ask for a
credential. That is the plausible wrong fix, and `TestOffsiteDeclare_NeverHadOffsiteSaysNothing` is
the guard that catches it.
- **a hub-held recovery package** — the report ACK's `escrow.identity_blob_present`. Alone this is a
healthy box that has run its ceremony.
A target that exists but is merely **disabled** is the customer's own choice and never declares.
### The ACK field stopped being discarded
`EscrowAutoConfirmer.Reconcile` now records `identity_blob_present` **first, before every gate**. Those
gates return immediately when the box is neither pending nor escrowed — which is exactly a rebuilt
box — so the one fact that distinguishes it from a box that never had off-site backups was thrown
away on every cycle. It is recorded through the confirmer because that is already the ONE place the
ACK's escrow object arrives and is already wired; a second consumer would be a second wiring point,
and this project's count of features built but never wired is six. `TestMainWiresRecordPresence`
asserts the wiring from main.go's AST.
The recorder is **last-write-wins, not set-only**: a customer RESET that removes the hub's escrow row
must be able to turn the declaration back off. A nil ACK escrow object records nothing — absence of a
statement is not a statement of absence.
### Inert to every existing reader
The declaration carries `enabled:false` and zero sizes. Established from the hub's code rather than
assumed: `OffsiteChecker.isStale` returns early on `!Enabled`, and `fillBand` returns OK on a zero
quota/size — so it raises no staleness and no fill alarm, on a new hub **or an old one**, and an
unknown `state` string is ignored by `encoding/json`. **A configured box's report JSON is
byte-identical to v0.198.0's** — there is no `state` key at all.
The one reader that would have misread it is the hub's `reportHasOffsite`, whose comment asserted
*"presence == applied-on-the-box"*; felhom.eu v0.96.0 tightens it to require `enabled:true`.
### Rider — the pre-push hook refuses a clone outside the workspace
`.githooks/pre-push` gains one assertion, identical in all four repos. The workspace root was already
written down and was drifted from anyway; a rule that has failed once as a reminder is not fixed by
writing it down again. A push is the right trigger — throwaway `/tmp` clones for probes never push.
The only bypass is the documented `--no-verify`.
## v0.198.0 — the four steps a customer would have hit alone: two of them closed (2026-08-05, R-204 items 1 & 3)
The 2026-08-04 recovery drill (R-201) passed — and it only passed because a person was there. Four
+88 -193
View File
@@ -1,215 +1,110 @@
# CLAUDE.md — Project Instructions for Claude Code (`felhom-controller`)
# CLAUDE.md — `felhom-controller`
> Read automatically at session start. Stable orientation only — **current state lives in
> `CONTEXT.md` and the top of `CHANGELOG.md`**, never here. Cross-repo orientation: workspace-root
> `/mnt/5_hdd/felhom.eu/git/CLAUDE.md`.
> Stable orientation only — **current state lives in `CONTEXT.md` and the top of `CHANGELOG.md`**,
> never here. Cross-repo conventions (clean-tree gate, secrets, trunk-based, artifact taxonomy):
> workspace-root `/mnt/5_hdd/felhom.eu/git/CLAUDE.md`. Path-scoped detail: `.claude/rules/`.
!!! IMPORTANT !!!
- Always update CHANGELOG.md whenever you modified the code, and pushed to git!!
- IF controller feature changed (new/modify/remove) always update the relevant part of controller/README.md with the architectural change!!
## What this repo is
## Project overview
Felhom is a managed home-server business for Hungarian customers. This repo contains the
**felhom-controller** — the Go application that manages Docker Compose stacks inside each customer
LXC guest via a Hungarian-language web dashboard.
Read in this order:
- **`REUSE.md`** — before writing new code (canonical helpers, patterns, traps, seams).
- `CONTEXT.md` — current project state, decisions, roadmap (update after each session).
- `controller/README.md` — full feature/architecture reference (update when features change).
- `TASK.md` — the current task to implement (if it exists).
## System context — the three-component model
The project runs **on Proxmox**, with a locked three-component model:
- **Hub** (`felhom.eu/hub/`) — operator backend on k3s.
- **Host agent** (`felhom-agent/`) — one per Proxmox host; operator-tier; owns ALL Proxmox interaction.
- **In-guest controller** (THIS repo) — one per customer LXC; **Docker-only; holds NO Proxmox
credentials**. De-privileged: disk/host/Proxmox concerns are delegated to the host agent via the
pinned local-API client (`internal/agentapi`); the controller keeps the app domain — stack/deploy
management, the Hungarian web UI, app-data backup, metrics/telemetry, integrations, git-sync,
notifications. Whole-guest backup (PBS vzdump) is the agent's.
> **Authoritative maps:** `felhom.eu/documentation/architecture/01/02/03-*.md` (topology/trust,
> controller module map, host agent) + the code-verified feature docs in
> `felhom.eu/documentation/controller/`. Match the current code, not summaries, if they drift.
The **in-guest controller** — one per customer LXC, Docker-only, **holds NO Proxmox credentials**. It
owns the app domain: stack/deploy management, the Hungarian web UI, app-data backup, metrics,
integrations, git-sync, notifications. Disk/host/Proxmox concerns are delegated to the host agent via
`internal/agentapi`. Whole-guest backup (PBS vzdump) is the agent's, not ours.
**Don't confuse the two ex-"controllers":** `felhom-agent` (host, operator-tier, was
`proxmox-controller`) vs this `felhom-controller` (in-guest, was `deploy-felhom-compose`).
`proxmox-controller`) vs this repo (in-guest, was `deploy-felhom-compose`).
## Layout (verified against the tree)
## Doing X → read Y
```
controller/cmd/controller/ entry point + startup wiring (scheduler block, init-only setters)
controller/internal/
agentapi/ pinned-TLS client to the host agent's per-guest local API (THE disk seam)
api/ REST /api/* router (writeJSON envelope, limitBody, config writes)
appbackup/ felhom-data paths/namespaces, DB dumps, userdata skeleton (shared primitives)
appexport/ .fab export/import bundles (password crypto, strict segment validation)
assets/ app logo/screenshot sync from the hub
backup/ app-data backup manager, recovery units, tier-2 copies, offbox restic
bootstrap/ bootstrap.json ingest → controller.yaml (Day-0 + refresh)
channelhealth/ agent-channel health checker (debounce + born-down alerting)
cloudflare/ geo-enforcement remnant (agent-delegated)
config/ controller.yaml load/validate (LoadPermissive = setup-mode only)
crypto/ AES-256-GCM app.yaml secret encryption (ENC: prefix)
infra/ traefik/cloudflared/filebrowser base-stack templates
integrations/ app-to-app integrations (e.g. OnlyOffice)
mailrelay/ app-email SMTP shim → hub relay
metrics/ telemetry collection
monitor/ health checks, protected containers
notify/ hub event push (typed Notify* wrappers)
quiesce/ quiesce loop for whole-guest backup (marker + recover)
recovery/ recovery-unit restore
report/ hub report builder/pusher + pull-based config refresh
scheduler/ background jobs (Every/Daily, Budapest DST-safe)
selftest/ startup self-checks
selfupdate/ controller image self-update via the agent swap
settings/ settings.json persistence (registry, flags, corruption recovery)
setup/ first-boot setup wizard (own CSRF)
stacks/ compose ops: deploy/delete/migrate/state (THE app domain core)
sync/ git-sync of the app catalog
system/ mounts/probes (linux + permissive _other stubs)
util/ small shared helpers
web/ dashboard UI: server, auth/CSRF, handlers, funcmap, templates (Hungarian)
```
| Doing | Read |
|---|---|
| writing any new code | `REUSE.md` — canonical helpers, patterns, traps, seams |
| needing current state / roadmap | `CONTEXT.md` |
| needing a feature or architecture reference | `controller/README.md` |
| build, deploy, publish, verify a version | the **`felhom-build-deploy`** skill |
| writing or reviewing a test, fixing a bug | the **`felhom-testing`** skill |
| UI, tokens, badges, Hungarian copy | the **`felhom-ui-design`** skill |
| which box may I break | `felhom.eu/documentation/runbooks/target-selection.md` |
| host addresses, break-glass, node facts | `felhom.eu/documentation/operations/nodes.md` |
| what version is live anywhere | ask the hub (`/hosts`, `/configs`) or the box — **never a doc** |
| the authoritative design | `felhom.eu/documentation/architecture/01/02/03-*.md` |
Per-package helpers/seams/traps: **`REUSE.md`** (maintained same-commit as helper changes).
## Session-critical invariants
## Conventions & cardinal rules
- **Trunk-based — no branches.** All shippable work commits directly to `main`; `main` equals what is
deployed. Report-only artifacts → `felhom.eu/documentation/` (`audits/`, `backlog/`). Risky fixes
are implemented during the supervised session itself, on `main`; if a fix can't be verified/shipped,
revert + report — never park on a branch.
- Code quality: double-check for bugs/edge cases; add debug logging; **ask rather than guess**.
- All UI text is Hungarian (Budapest timezone). Design tokens/gates: use the `felhom-ui-design` skill.
- **Run `python3 controller/scripts/controller_gates.py` (from `controller/`) after ANY change in
this repo.** It is the ONE entry point and runs all seven local gates — `template_id_gate`,
`emoji_gate`, `native_confirm_gate`, `offbox_rename_gate`, `app_row_dedup_gate`, `mojibake_gate`,
`docker_run_volume_path_gate` — plus `reuse_refs_check` on the repo root, streaming each gate's
own output and exiting non-zero if any fails. `--fast` selects the gates that touch no network and
no container runtime; today that is all of them. A missing gate script is a FAILURE, never a skip.
**Why a runner and not seven invocations** (2026-08-02, R-29): a census of all thirteen gates
across the four repos found that every check a `CLAUDE.md` names was passing, and two of the four
nobody is told to run were failing. This file used to name two of the seven; the other five were
reachable only through a line in `REUSE.md`, and `docker_run_volume_path_gate.py` was RED.
**The shared `reuse_refs_check.py` lives in `felhom.eu/scripts/` and is never copied here**
a copy would recreate the drift it detects; an absent sibling clone FAILS the gate.
**The pre-push hook** (`.githooks/pre-push`) runs it with `--fast` and refuses a failing push. It
is per-clone — switch it on once with `git config core.hooksPath .githooks`, and a manual run
WARNS when this clone is unarmed. `git push --no-verify` bypasses it deliberately; **say so in the
session report when you use it.** Both facts are why CI is still owed (`OPEN-ITEMS.md` R-168).
- Testing doctrine (non-hollow tests, red-proofs, seams): use the `felhom-testing` skill.
- **Logging**: new leveled lines use `internal/logx` (DEBUG always reaches the debug ring; stdout
respects `logging.level`); English, keys-never-values, durations on outcomes — full rules in
`felhom.eu/documentation/runbooks/logging-conventions.md`.
- Update `REUSE.md` if you added/changed/deprecated a shared helper or pattern (same commit).
- **Coupled features** (controller behavior that depends on a specific agent version): add a
`featureProbes` table row in `internal/agentapi/features.go` + a `Supports` gate call at the
feature's entry point; declare `MinAgent: X.Y.Z` in the CHANGELOG entry header. Rules:
`felhom.eu/documentation/runbooks/publish-train-rules.md`.
> **In every repository where you make a change, update both files in that repo:**
> - **`CHANGELOG.md`** — cumulative log, newest on top.
> - **`REPORT.md`** — **overwrite** with the most recent implementation/validation summary only.
>
> **Never write secrets** into any committed file — reference them as "stored out-of-band".
## Live validation
Exercise the SERVER-SIDE PIPELINE a real user triggers, end-to-end (connect → enroll → deploy). The
forbidden shortcut is BYPASSING that pipeline (the F9 episode: raw agent guest-attach + hand-set
state). **`claude-in-chrome` is NOT available in the DooPlex environment** — the standard method is
endpoint-level: invoke the exact endpoint the UI invokes (no server logic is skipped, only
rendering) and say which method was used. Strict end-to-end UI coverage is a manual click-through.
Two traps in that method, both from the 2026-07-20 remediation:
- **Grep the fetched page with ASCII-only substrings.** Accented Hungarian patterns get mangled
through the `ssh → pct exec → bash -c` chain and return a false `0` — which reads exactly like the
banner/string being gone. Use `kezel`, `Utols`, `Biztons`; never let an accented pattern gate a
conclusion (it nearly produced a wrong "banner cleared" claim).
- **Credentials with `!` or `'` break in heredoc-built helper scripts** (history expansion eats
`!!`). Use the proven inline `-d "password=$PW"` form for authed curl, and delete any
credential-bearing helper from `/tmp` (host AND guest) when done.
## Environment & access
Claude Code runs **on DooPlex (192.168.0.180, Debian 13, user `kisfenyo`)**; repos in
`/mnt/5_hdd/felhom.eu/git/`, build dirs in `/mnt/5_hdd/felhom.eu/build/`. All repos hosted at
`gitea.dooplex.hu/admin/`. Builds are local commands; felhom-pve is one SSH hop.
| Host | Access | Role |
|------|--------|------|
| **DooPlex (this host)** | local — `/mnt/5_hdd/felhom.eu/{git,build}/` | build + push images, `sudo kubectl` |
| Demo Proxmox host `demo-felhom` | `ssh felhom-pve` (root@192.168.0.162) | `pct` into guests; live validation |
| Demo guest 9201 | `ssh felhom-pve "pct exec 9201 -- ..."` | the live demo controller (golden/bootstrap-managed) |
| Demo host `demo-hp` (HP t740) | `ssh demo-hp` (TS `100.76.96.79` / LAN `192.168.0.87`); **no baked key** — break-glass root via hub `host_recovery/demo-hp-bb76ea` + `sshpass` (recipe in `felhom.eu/documentation/operations/nodes.md`) | 2nd demo node (guest 9201 `demo-hp`, agent 0.93.0). **Designated drill+build VM host** per the 2026-07-25 ruling — but **no drill VM is provisioned there yet** (forward-looking); the drill `drill.qcow2` still lives on DooPlex (off). See nodes.md. |
| felhotest (legacy) | `ssh -p 33022 kisfenyo@router.abonet.hu` | OLD /opt/docker compose mechanism |
> **Legacy: Windows workstation.** Until 2026-07-19 CC ran on Windows 11 with repos in `E:\git\`,
> and every remote command needed `SSH=/c/Windows/System32/OpenSSH/ssh.exe` (Git Bash's ssh lacks
> the Windows agent and fails silently — see `docs/vscode-ssh-fix.md`), plus `MSYS_NO_PATHCONV=1`
> for `pct exec`. Retained in case that environment is revived.
> **felhom-pve is back on the home LAN (as of 2026-07-25).** The host holds `192.168.0.162` again and
> the **agent is UP** — `localapi` binds `192.168.0.162:8443`, the service is `active`, capabilities
> self-check clean, and all agent-backed features (storage, PBS backup, quiesce, restore-test, DR) are
> reachable. `ssh felhom-pve` remains a **Tailscale** alias (`100.70.170.35`, location-independent, the
> N100 is travel-portable) with `Host felhom-pve-lan` as the direct-LAN fallback; both work. The earlier
> "remote site — agent DOWN (binds a stale `.162`)" block was the 2026-07-20→24 vacation window and is
> now retired; the historical record stays in
> `felhom.eu/documentation/audits/AUDIT-vacation-remote-ops-2026-07-20.md`.
External access via Cloudflare Tunnel → Traefik; Pi-hole forwards `*.demo-felhom.eu` → .162 locally.
## Build & deploy — MANDATORY after code changes
**Full runbook: use the `felhom-build-deploy` skill.** Summary (guest 9201 is bootstrap-managed —
**no compose file**; `felhom-controller-bootstrap.service` runs the tag in `/etc/felhom-controller-image`):
> **Clean-tree gate before any build:** `git status --porcelain` must be empty and
> `git rev-parse HEAD` must equal `git rev-parse origin/main` in the repo being built. An unpushed
> change does not exist — never build a dirty or unpushed tree. The `git pull` in the build step
> stays (it is a no-op when you work in this tree, and load-bearing if anything was pushed from
> elsewhere).
| Step | Command |
|------|---------|
| 1. Commit + push | `git add <explicit paths> && git commit -m "..." && git push` |
| 2. Build + push image | `cd /mnt/5_hdd/felhom.eu/build/felhom-controller && git -C /mnt/5_hdd/felhom.eu/git/felhom-controller pull && ./build.sh <VER> --push` (build.sh does NOT pull — the explicit pull is load-bearing) |
| 3. Deploy (9201) | `ssh felhom-pve "pct exec 9201 -- bash -c 'docker pull gitea.dooplex.hu/admin/felhom-controller:<VER> && echo gitea.dooplex.hu/admin/felhom-controller:<VER> > /etc/felhom-controller-image && systemctl restart felhom-controller-bootstrap.service'"` |
| 4. Verify | `ssh felhom-pve "pct exec 9201 -- docker ps --filter name=felhom-controller --format '{{.Image}} {{.Status}}'"` + container logs |
Hub build/deploy lives in `felhom.eu` (GitOps) — see that repo's CLAUDE.md / the skill. Catalog
changes (`app-catalog-felhom.eu`): commit+push; controller sync picks them up ≤15 min or via the
"Sablonok frissítése" button.
## Session-critical invariants (the rest live in REUSE.md)
The rest live in `REUSE.md`. These cost incidents to learn:
- `docker compose restart` does NOT pick up new images/env — always `up -d` (`RedeployFromEnv`).
- Docker's `.State` says "running" even for unhealthy containers — `.Status` parse is the truth.
- In-memory `Deployed` flag is set BEFORE `compose up -d` (slow-pull race); reverted on failure.
- `compose up -d` exits 0 on crash-loops — post-start status check is the detection.
- Docker's `.State` says "running" even for unhealthy containers — the `.Status` parse is the truth.
- In-memory `Deployed` is set BEFORE `compose up -d` (slow-pull race); reverted on failure.
- `compose up -d` exits 0 on crash-loops — the post-start status check is the detection.
- Env var KEYS are logged, never values. Protected stacks (traefik, cloudflared, felhom-controller)
can't be stopped from the UI.
cannot be stopped from the UI.
- Verify a container image HAS the healthcheck tool before using it (BusyBox wget / python3 / curl —
catalog REUSE.md maps the families).
the catalog `REUSE.md` maps the families).
- `IsRunning()` is CONCURRENCY, false during a verification restore — display MUST use
`RestoreStatus()`.
## Live validation — the fence
Exercise the SERVER-SIDE PIPELINE a real user triggers, end-to-end (connect → enroll → deploy). **The
forbidden shortcut is BYPASSING that pipeline** — the F9 episode was a raw agent guest-attach with
hand-set state, and it proved nothing.
`claude-in-chrome` is NOT available on DooPlex. The standard method is endpoint-level: invoke the
exact endpoint the UI invokes (no server logic is skipped, only rendering) and **say which method was
used**. Strict end-to-end UI coverage is a manual click-through by the operator.
Two traps in that method live in `.claude/rules/ui-hungarian.md` (ASCII-only greps; `!` in
credentials) — they load when you touch a template or stylesheet.
## Commands — one per surface
| Surface | Command |
|---|---|
| Gates (after ANY change) | `python3 controller/scripts/controller_gates.py` — from `controller/` |
| Green gate | `go build ./... && go vet ./... && go test ./...` |
| Build + deploy | the **`felhom-build-deploy`** skill — do not hand-roll it |
Guest 9201 is **bootstrap-managed — there is no compose file**;
`felhom-controller-bootstrap.service` runs the tag written in `/etc/felhom-controller-image`. Catalog
changes (`app-catalog-felhom.eu`) are picked up by controller sync ≤15 min, or via the "Sablonok
frissítése" button.
## Working with CHANGELOG.md
**DO NOT read the full file** — it is large and will waste context.
- Session start: use `CONTEXT.md` + `controller/README.md` for current state.
- Session start: `CONTEXT.md` + `controller/README.md` for current state.
- Adding an entry: Read only the top ~30 lines for format, then Edit-insert after line 1.
- History: Grep for topics instead of reading.
## End-of-session checklist
1. **Commit and push** all code changes
2. **Build, push, and deploy** the new controller image (if controller code changed)
3. **Update CHANGELOG.md** with what was done
4. **Update CONTEXT.md** with decisions made, state and what's next
5. **Update controller/README.md** if architecture or features changed
6. **Verify** the deployment is working (check `docker ps` and logs)
7. **Update REUSE.md** if you added/changed/deprecated a shared helper or pattern (same commit)
1. **Commit and push** all code changes (explicit paths; no `git add -A`).
2. **Build, push, and deploy** the new controller image, if controller code changed.
3. **`CHANGELOG.md`** — always, whenever code changed and was pushed.
4. **`CONTEXT.md`** — decisions made, state, what is next.
5. **`controller/README.md`** — whenever a feature was added, modified or removed.
6. **`REPORT.md`** — overwrite with this run's summary only.
7. **`REUSE.md`** — if a shared helper or pattern was added/changed/deprecated (same commit).
8. **Verify** the deployment (`docker ps` + logs).
<!--
WHY THIS FILE IS SHORT (2026-08-06, instruction-trim task).
Removed from here and rehomed, not lost:
- the `## Layout (verified against the tree)` block -> derivable by `ls internal/`; REUSE.md
carries the per-package seams and traps that the annotations were really for.
- the `!!! IMPORTANT !!!` header -> its two requirements are checklist items 3 and 5. One voice,
one place; a rule stated twice in one file is a rule that gets edited in one of them.
- the host/access table -> documentation/operations/nodes.md is the single home. The copy here
had drifted: it gave demo-felhom as plain root@192.168.0.162 (the LAN fallback, not the route),
pinned "agent 0.93.0" against the project's own no-versions-in-docs rule, and claimed no drill
VM was provisioned on demo-hp. Measured 2026-08-06: `qm list` on demo-hp shows VM 300
`drill-r50` present. felhom-agent/CLAUDE.md was right; this file was wrong.
- the "felhom-pve is back on the home LAN" block -> it was bookkeeping about a retired block; the
record is in documentation/audits/AUDIT-vacation-remote-ops-2026-07-20.md.
- the "Legacy: Windows workstation" block -> the workspace-root CLAUDE.md carries the full version.
- the gates/logging/coupling/UI paragraphs -> .claude/rules/*.md, which load when a matching file
is read instead of in every session.
Full per-block accounting: felhom.eu/documentation/audits/LEDGER-instruction-trim-2026-08-06.md
-->
+309 -1
View File
@@ -7,7 +7,228 @@
>
> Ask Claude Code: "Please update CONTEXT.md with what we did today"
Last updated: 2026-08-02 (v0.190.0 — R-157 A · R-170 · R-171: boot recovery finished)
Last updated: 2026-08-08 (v0.208.0 — R-254: the last two secrets leave the page source, and a gate against a fourth)
> **2026-08-08 — v0.208.0 (R-254). THE RULE, stated so it outlives this session:**
>
> ### A secret is never in a page's response body. It is fetched by an explicit act, and the act is recorded.
>
> Three instances of one pattern shipped in two days, each found by hand: the retrieval passphrase
> (R-249), an app's real first-login password (R-254 site one), and an already-deployed app's generated
> secret field (R-254 site two). Every one was "hidden" with `display:none`, `hidden`, or
> `type="password"` — **instructions a browser honours when DRAWING and nothing else.** The plaintext
> was in the bytes; a `curl` returned it; caches, history, saved pages and screen-shares had it.
>
> **The shape of the fix, now used three times:** the page carries a BOOLEAN; the value comes from a
> **POST** (so CSRF covers it and it is not re-fetchable from history) with **`Cache-Control:
> no-store`**; the reveal is **LOGGED as an act** — reading a value off markup left no trace anywhere,
> which is why nobody can say whether any of these was ever read. **Per-secret endpoints, never one
> generic "reveal any named secret"** — that would turn three narrow exposures into one lever.
>
> **And the test must assert the RAW RESPONSE BODY.** Every test that asked what the customer *sees*
> passed while the bytes carried the secret. That is precisely how this survived three times.
>
> **What is NOT this defect:** a form must carry what it submits. The pre-deploy hidden input round-trips
> a generated secret deliberately (README §318) so the saved value is the one the customer wrote down.
> The defect there was the neighbouring READONLY input on an already-deployed app, where nothing is
> submitted at all.
>
> **The gate:** `scripts/secret_in_markup_gate.py`. Name-based, all 36 templates, **blind to a secret
> arriving under a neutral page-data key** — measured, not assumed. The complementary runtime
> body-assertion covers 4 of 27 page templates; the other 23 are **R-255**.
>
> **A correction to v0.207.0's report:** it said HTML comments ship in the response body. They do not
> here — `html/template` strips them (`text/template` does not). Measured.
> **2026-08-08 — v0.207.0 (R-249, R-252, R-253). Three things the fifth walk exposed BY PASSING.**
> The walk closed R-201 (both halves) on 2026-08-07; none of the below touches the recovery path it
> proved.
>
> **R-249 — a secret was living in the page source.** `settings_security.html` rendered the retrieval
> passphrase into a `display:none` span behind a „Megjelenít" button. That toggle stops a browser
> DRAWING it and nothing else: the plaintext was in the response body of every render. Found by doing
> exactly that — it landed in a session transcript while driving the documented rebuild path.
> **THE RULE, which the codebase already stated for R and this page did not follow:** a secret is
> revealed by an XHR, never templated server-side into HTML (`escrow_handlers.go`). The page now
> carries only `HasRetrievalPassword`; the value comes from `POST /settings/retrieval-password/reveal`
> — CSRF-covered, `no-store`, and **logged as an act**, which reading it off the markup never was.
> **The test asserts the RAW RESPONSE BODY** — every test that asked what the customer *sees* passed
> while the bytes carried the secret, and that is why it survived.
> **The census found two more instances** (`app_info.html`, a real per-install app password in a
> `hidden` span; `deploy.html`, a generated secret in a `value=`) — **filed as R-254, not fixed.**
>
> **R-252 / R-253 — the two obstacles, and the rule they share.** A rebuilt box keeps its drives but
> loses their REGISTRATION, so every restore refused with a sentence naming no next step; and the
> restore list promised „a visszaállítás előbb újratelepíti" three lines above a refusal that fired
> *because* the app was not installed. **The promise was the wrong half:** reconstitution writes to
> the app's own `GetStackHDDPath`, which exists only once the CUSTOMER has chosen a drive at deploy
> time — an automatic reinstall would mean the product making that choice for them, which is the one
> decision this recovery path exists to leave with them. Both now name a reason and route to the step
> that clears it, and both notices are conditional (a healthy box is byte-identical, pinned by a test
> that fails if either becomes unconditional).
>
> **The page and the resolver ask ONE question:** `HasRestoreDestination()` reads the same
> `GetSchedulableStoragePaths()` the scratch resolver reads. A second copy of that predicate is
> exactly how a page ends up promising what the handler refuses — which is R-253 itself.
> **2026-08-07 — v0.206.0 (R-241). THE RULING, and it reversed the fix: this was a MINTING defect,
> not a screen-predicate defect.** The recovery screen was telling the truth — there genuinely was
> nothing recoverable under the key the box held, because **the box minted that key itself over the
> top of a sealed package it already knew the hub was holding**. Fixing the predicate would have
> papered over a machine quietly making its own backups unopenable.
>
> **THE RULE: a box does not create a repository key while the hub holds a sealed package for it.**
> The guard is a conjunction (package held AND no key), so a first-time box is untouched, and the
> refusal is a HOLDING state rather than a failure — the transport is still configured so the
> recovery screen can bring the tier up the moment the key arrives.
>
> **THE SECOND RULE: the fact that answers a question must be kept where the question is asked.** The
> hub-vs-local key comparison had been computed on every ACK since SLICE 3 and persisted nowhere; on
> the venue it logged the right answer thirty-five minutes before the customer looked at a screen
> that could not see it. It is now persisted and drives shape (c) of the offer.
>
> **THE THIRD RULE (the operator's, and it generalises): fix the state, do not remember that it is
> wrong.** Abandoning the old history now starts a 14-day countdown that removes the set-aside store
> and its sealed package TOGETHER, after which the offer falls silent on its own because there is
> nothing left to compare — rather than a "they decided" flag suppressing a screen over a state that
> is still wrong. The recovery offer stays reachable for the whole grace; a grace in which recovery
> is impossible is decorative.
>
> **Surface:** the full page appears once per ENTRY into the offered state, not once ever — a box
> rebuilt months later is a new situation. Three dismissal levers with three scopes, and none of them
> removes the entry point on the backups page.
>
> **Needs hub v0.98.0** for the superseded-package purge. `felhom-agent` untouched.
>
> **Two real bugs were caught by tests rather than by review** — a missing `t.Enabled` (an existing
> test) and a missing falling-edge sync that reintroduced the very defect the epoch exists to fix.
>
> **NOT built, deliberately:** the automatic 30-day abandonment (R-245, with the operator's reasoning
> recorded), and R-242's release-to-golden gate.
> **2026-08-06 — v0.205.0 (R-234).** THE RULE: **a run that skipped an app the customer selected is
> not a successful run.** The R-203 verdict block already said *"a warning beside a success is read
> as a success"* and applied it to one of the two shapes it describes — a missing declared FOLDER
> made the run `incomplete`, an app skipped ENTIRELY did not. Now both do. A selected-but-UNDEPLOYED
> app is named with what to do but does NOT move the verdict, because a box left permanently amber by
> an app somebody removed is a status nobody reads.
>
> **§7.3, MEASURED rather than assumed — and the answer was "already done".** `CaptureRecoveryUnit`
> writes compose config + a manifest (a few KB), only ENUMERATES dumps rather than creating them, is
> idempotent, and does NOT stop the app; the off-site run already calls it for every deployed stack in
> its own pre-dump phase, through `admitApp`. So there is no wait to remove for a deployed app, and
> **nothing was built**. Proven on demo-hp: a unit moved aside was RECREATED by the run.
>
> **AND THE FILED MECHANISM WAS NOT THE MEASURED CAUSE.** R-234 was filed as "the first run after a
> toggle finds no bundle and skips the app". That cannot happen for a deployed app (above). What did
> happen on 2026-08-06: the manual run was dropped by the **single-flight** while an earlier run was
> still going; `runOffboxBackup` returned nil; the handler had already said „elindult”; and the card
> then showed the PREVIOUS run's „✓ Rendben”. Fixed by taking that decision synchronously in the
> handler. **The nightly path deliberately still returns nil** — nobody asked, and it retries.
> **2026-08-05 — v0.200.0 (R-193 CLOSED).** The customer-facing recovery screen. Until now a customer
> whose machine was rebuilt had everything needed to get their data back and no way to find out — the
> only route was a command line.
>
> **IT UNLOCKS AND ONLY UNLOCKS** (operator ruling). Explains, takes the recovery code, opens the
> repository, lists what is in it (apps, dates, sizes). **Restores nothing** — restore is per-app and
> lives in the backups area; the put-back is **R-213** and its stated requirement is a
> live-versus-backup comparison.
>
> **ONE CORE, TWO CALLERS.** `backup.RecoverInstallCore` is the only fetch→unseal→compare→install path.
> `RecoverAndInstall` is now a thin CLI wrapper — exit codes and printed lines byte-identical, every
> pre-existing CLI test passed unchanged — and the handler calls the same function. Asserted from
> source by AST on BOTH sides, plus a test that the routes and the landing-page interception exist.
>
> **THE TRIGGER HAS TWO SHAPES and the second is the one that matters.** `OffsiteRecoveryOffer` = the
> hub holds a package AND (no repository password OR the tier is orphaned). The literal "no repository
> password" alone is a window that CLOSES BY ITSELF — `WriteOffboxSecrets` auto-generates one on
> re-apply (R-193's own orphaning mechanism) and hub v0.96.0's self-heal re-applies within ~1530 min.
> Shape (b) is also what the shipped move-aside requires, which is why the discard choice can reach it.
>
> **CLAIMED is part of the predicate** — a legacy-open box passes through `RequireAuth`, so without an
> explicit `authEnabled()` check the interception fired for an unauthenticated visitor. A test caught it.
>
> **„Most nem" suppresses the FULL PAGE ONLY.** The backups-area entry point is bound to
> `recoveryOffer`, never to the postpone flag.
>
> **The code:** POST body only, never logged/persisted/echoed, cleared on every path, `no-store`,
> `autocomplete=off`. **No lockout** — a ten-word phrase is not guessable and locking a customer out of
> their own data for a typo is worse; failures are logged locally without the code, and NO operator
> alert is raised (reasoning in REPORT.md §4).
>
> *Live:* demo-felhom is genuinely in shape (b), so validation needed no arrangement — `/launcher` →
> 302 `/recovery`, both mandatory sentences rendered, three wrong codes refused with the `offbox/`
> listing byte-identical and no lockout, and the code found in no file, log or ring **with a
> planted-copy positive control that first exposed a mis-aimed sweep**. **NOT proven live: a CORRECT
> code** — none was kept for demo-felhom's orphaned history and demo-hp's is operator-held.
> **2026-08-05 — v0.199.0 (R-204 item 4 / R-193).** The last of the four manual interventions the
> 2026-08-04 drill needed. **Operator ruling: automate it, and the trigger is a state the BOX
> DECLARES.** From the hub an absent off-site object has FOUR meanings — never configured,
> mid-restart, a transient config read failure, rebuilt-and-stranded — and the hub cannot tell them
> apart. The box can.
>
> **The declaration needs BOTH halves** (`backup.needsOffsiteCredential`): a fresh data area (no
> repository password) AND a hub-held recovery package (the ACK's `identity_blob_present`). Freshness
> alone is a box that never had off-site backups; dropping that condition makes the whole fleet ask
> for credentials, which is what `TestOffsiteDeclare_NeverHadOffsiteSaysNothing` catches. A merely
> DISABLED target is the customer's own choice and never declares.
>
> **The ACK field stopped being discarded.** `EscrowAutoConfirmer.Reconcile` returns early when the box
> is neither pending nor escrowed — exactly a rebuilt box — so the fact was thrown away every cycle. It
> is recorded FIRST, before every gate, via `RecordPresence`, wired in main.go and asserted by
> `TestMainWiresRecordPresence` (AST, comments dropped). Last-write-wins, not set-only, so a customer
> RESET turns the declaration back off; a nil ACK escrow records nothing.
>
> **Inert to every existing reader:** `enabled:false` + zero sizes, so the hub's `isStale` and
> `fillBand` both short-circuit; an unknown `state` string is ignored by encoding/json. **A configured
> box's report JSON is byte-identical to v0.198.0's.** The one reader that would have misread it is the
> hub's `reportHasOffsite`, tightened in hub v0.96.0 to require `enabled:true`.
>
> *Live:* both demo boxes now record `hub_escrow_identity_present=true` in settings.json (the recorder
> working on a HEALTHY box). demo-felhom 9201, arranged reversibly into the stranded shape, produced
> report id=16743 carrying `{enabled:false, state:needs_credential, quota_gb:0, repo_size_bytes:0}`;
> the single declaration was absorbed by the hub's debounce (no self-heal event) and the box was
> restored the same minute. **The hub half is felhom.eu v0.96.0.**
>
> *Rider:* `.githooks/pre-push` in all four repos now refuses a push from a clone outside
> `/mnt/5_hdd/felhom.eu`. Proven both ways against a scratch clone.
> **2026-08-05 — v0.198.0 (R-204 items 1 & 3).** The 2026-08-04 drill (R-201) passed only because a
> person was there; four manual interventions stood between a recovered key and a restored file. Two
> of the three defects are in this repo.
>
> **Item 1 — the reset code needed a restart.** `--print-reset-code` is a SEPARATE process; it
> persisted a new code while the running server kept the old one cached, so the code the customer was
> told to type was refused until the controller restarted, and nothing said so. `effectiveClaimCode`
> now calls `settings.ReloadClaimCode()` first. **The settings-vs-config precedence is unchanged** —
> the defect was freshness, not precedence. **Read-through, not a TTL, and that is the point:** a TTL
> makes the new code visible AND leaves a window in which the superseded one still works, which is
> worse than the bug. That is the mutation `TestClaimCode_SupersededByASecondMint_RefusedImmediately`
> exists to kill, and its red-proof produced exactly *"the SUPERSEDED code was accepted"*. The
> function now returns an error and **every caller fails closed**; an absent settings file is NOT an
> error. `ClaimConsumedGeneration` is deliberately NOT re-read — this process is its only writer and
> re-reading could move it BACKWARDS if a save had failed, resurrecting a consumed code.
>
> **Item 3 — the restore's default returned the wrong thing silently.** `mode=unit` restores the
> recovery unit (definition + config + DB dumps) and not the customer's files. `restoreScratchOutcomeMsg`
> now names what came back, what did not, and the next step; the wizard's intent card states its scope
> before the choice. **The size gate is untouched** and pinned unchanged by
> `TestOffboxRestore_FullPathUnchanged`. **The default stays `unit`** — all three wizard forms set
> `mode` explicitly, so a change would alter nothing visible while silently changing a mode-less POST.
>
> *Live-validated endpoint-level (no browser on DooPlex):* on demo-felhom 9201 with `restarts=0`
> across both mints, a superseded code returned „Hibás vagy lejárt kód" and the current one was
> accepted first time; on demo-hp 9201 a `privatebin` unit restore produced the scoped Hungarian
> outcome and `mode=full` without confirm revealed `full_size=6.8+KB` without restoring anything.
> demo-hp's drill scratch (`calibre-web`) was not touched.
>
> **Item 2 is the hub's** (felhom.eu v0.95.0, R-196). **Item 4 — a rebuilt box cannot obtain an
> off-site credential unaided — remains OPEN (R-193)** and was deliberately not begun.
> **2026-08-02 — v0.190.0 (R-157 mechanism A · R-170 · R-171).** Three items, one live validation
> cycle, because all three are boot behaviour and all three are proven by hard-resetting the box.
@@ -1672,6 +1893,93 @@ Last updated: 2026-06-13 (v0.60.0 backlog-Medium cleanup)
---
## THE TWO RULES THE RECOVERY JOURNEY LEANS ON (v0.203.0, 2026-08-06)
> **1. A credential the hub stages is collected by the box, not waited for.** The reconcile that
> collects runs on a tick for exactly as long as the box's own declaration says it needs one — and
> stops the instant a target exists. It is driven from `OffboxReportStatus().State`, the same statement
> the hub acts on, so the two can never disagree about whether a retry is wanted.
>
> **2. A mount Felhom itself made is not "something else".** Enrolment mounts a drive twice — the
> managed path and a raw `/mnt/<name>` on the host — and the host survives a guest rebuild while the
> guest's registry does not. The claimed check forgives a non-managed mount **only when corroborated**
> by the same device also being mounted under the managed path. **A genuinely foreign mount is still
> refused, and that fence has its own test.**
**Why both are stated here rather than left in the code:** each was a dead end that kept the unaided
recovery journey failing, and each looked correct in isolation. R-218's declaration half shipped and
worked while nothing consumed what it asked for; R-220's check was right about foreign disks and wrong
about our own. **Neither is a bug in the thing it guards — both are about what runs, and when.**
Two things that must not be "simplified" back:
- **The settle gate stays.** The retry goes through `ReconcileWhenSettled`, so the day-0 floor race is
unchanged. A retry that skipped it would trade one defect for another.
- **The R-220 exemption is corroborated, never a prefix.** Widening it to any `/mnt/*` path offers a
disk another system is using for formatting — the red-proof shows exactly that.
## THE UNLOCK PATH'S RULE (v0.202.0, 2026-08-06) — state it before changing anything there
> **On the recovery unlock path the customer is blamed only after a real attempt REFUSED their code.
> Every other outcome — including one that cannot be classified — says something else.**
This is the rule, and it outlives the bug that produced it. It was learned twice, because fixing it
once was not enough:
- **v0.201.0** stopped an agent that is too OLD from being reported as a wrong code (R-216).
- **v0.202.0** found the same defect through a different door: an agent that is **stopped**, and a hub
that cannot be **reached**, still fell through to a message about the code. Measured with a
**correct** code at 0.0299 s and 0.0556 s, against ~1.0 s for a real unseal — the machine accused the
customer of something it had not tried (R-224).
- And the inverse: the one message that says *"check your ten words"* was unreachable on any box that
had re-escrowed, which is exactly the box a customer has just recovered (R-226).
**How it is enforced.** `agentapi.ClassifyRecoveryFailure` maps the failure to one of five classes
**from the value, never the text**; the typing message is reachable from **one** of them
(`RecoveryAskedAndRefused`, i.e. HTTP 400, i.e. the bundle was fetched and `age` refused it); and the
zero value is `RecoveryUnknown`, which renders **neutral**. **The safe default is the load-bearing
part** — an unrecognised status must not fall into an accusation.
**Two things that are deliberately NOT how it works, and must not be "fixed" into it:**
1. **Elapsed time is never a classifier.** It is what diagnosed this, it is logged for the operator,
and that is all. A duration guard would be a second thing that can be wrong.
2. **The error's TEXT is never read.** A string match is a defect waiting for a rewording. When the
distinction was not available as a value, the **agent was changed to provide one**
(`escrow.ErrBundleFetch` → HTTP 502, agent v0.126.0, `MinAgent 0.126.0`) rather than parsed for.
**The coupling degrades safely and silently:** an agent below 0.126.0 answers 400 for both causes, so
`FeatureRecoveryFailureClass` withholds the refusal reading and the 400 becomes neutral. The gate
blocks nothing; it only decides whether the customer may be told to check their typing.
## CAMPAIGN 11 — what changed in v0.201.0 (2026-08-05)
**The off-site key recovery is a COUPLED feature and now declares it.** It needs agent **0.125.0**
(`POST /escrow/recover-offsite-password`). `FeatureOffsiteKeyRecovery` has a `featureProbes` row, a
`featureMinAgent` row and a `Supports` gate at the unlock entry point.
**That gate FAILS CLOSED — alone in that table.** The package default is fail-open, and that default
is what produced R-216: an agent that could not answer 404'd, the unlock was attempted anyway, and the
customer was told their correct recovery code was wrong. Anything but `SupportYes` now says *the
machine* cannot ask yet, and no attempt is made. Do not "fix" it back to the package default.
**The box declares `needs_credential` until the TIER WORKS, not until a key exists.** The old
short-circuit on "a repository password is present" is deleted: installing one is the recovery
screen's whole job, so it made succeeding at recovery switch off the mechanism that delivers the
coordinates to use it. A disabled target still short-circuits at the first line (Scenario E).
**The unlock finishes the job**: place the key → bring the tier up (`offsiteapply.Bridge.Reconcile`,
wired via `SetRecoveryTierUp`) → list. Without the middle step the promised listing can never render on
shape (a), because no key ⇒ no target ⇒ no inventory.
**Four messages, not one.** Wrong code (the only one mentioning typing) · the machine cannot ask ·
the store could not be read / the connection details have not arrived · the code belongs to a RETAINED
earlier package. The last one is driven by the ACK's `superseded_present`/`superseded_at` (hub
v0.97.0) and **promises nothing** — no read path for a superseded package exists.
**Still open from the campaign:** R-214 (console pairing banner), R-220 (drives unenrollable after a
rebuild), R-221 (a rebuilt box cannot run the escrow ceremony), R-223 (the Day-0 manifest still vouches
agent 0.120.0 — operator decision).
## R-203 (v0.197.0) — the namespace-root contract, and what `ok` now means
**The contract, in one line:** `appbackup`'s path helpers (`UserdataDir`, `PrimaryBackupPath`,
+74 -41
View File
@@ -1,56 +1,89 @@
# REPORT — controller v0.197.0: R-203, both halves (2026-08-04)
# REPORT — controller v0.214.0: the screen stops hedging about a code it can now check (R-311, 2026-08-12)
Deployed to demo-hp guest 9201. No hub change. Nothing deleted, wiped or moved; the R-201 drill was
not resumed.
## The premise, corrected before anything was written
## What shipped
The task described the customer being told *"the recovery code did not open the sealed bundle"*. That
is the **agent's local-API** reply — machine-facing English. **The customer-facing screen already
hedged** (R-222/R-226): it named both possible causes, named the kept package and its date, and said
*„innen nem tudjuk megkülönböztetni őket"* — we cannot tell them apart from here.
**Part 1 — one resolver, five callers.** `appbackup.NamespaceRootFor` / `IsEnrolledDrive` are now the
single expression of the drive-kind rule; `backup.Manager.namespaceRoot` and `stacks.Manager.inGuest`
delegate (they were two copies that already differed over `filepath.Clean`). Routed through it:
`stacks/deploy.go` `withPathVars` (the live defect), `appexport/fabplan.go` + `export.go` via a new
`GetStackNamespaceRoot` provider method, `web/handlers.go`'s FileBrowser mounts (the fifth site, which
the spec did not name — latent today), and `stacks/delete.go`'s `ExportDataMounts` (its own commit).
`ComputeFabBuckets` now receives the namespace root, matching `ComputeCaptureSet`.
That sentence was **honest**. It was also a statement about our own incuriosity: it could not tell
them apart **because nothing ever looked**. Agent v0.129.0 looks, so the hedge becomes an answer. That
is what shipped — a smaller and more precise change than "stop the lie", and stated as such.
**Part 2 — the verdict.** `last_status` gains `incomplete`; the capture gaps are recorded structurally
so the verdict has something to act on; the operator hears it through the existing per-run digest.
Verified at `file:line` against live source, per rule 1, including the claims in the prompt itself.
## Proven live on demo-hp
## What changed
```
bind before: /mnt/sys_drive/userdata/media/books
bind after: /mnt/sys_drive/felhom-data/userdata/media/books
capture log: 0 mandatory path(s) -> 1 mandatory path(s)
- `internal/agentapi/features.go``FeatureRetainedRecoveryClass`, **MinAgent 0.129.0**, with the
`featureProbes` row the version path requires (a `featureMinAgent` row without one is never
consulted).
- `internal/agentapi/escrow.go` — class `RecoveryCodeOpensRetained` on **422**.
`ClassifyRecoveryFailure` now takes `(err, trustRefusal, trustRetained)`. **Two gates, not one:**
they name different agent versions (0.126.0, 0.129.0) and a box can sit between them, where a 422 is
a shape we did not design. The compiler found every call site.
- `internal/web/recovery_handlers.go` — the new case and its message; `recoveryRetainedTrusted`.
- `internal/web/server.go` — the test seam field.
- `scripts/retrieval_promise_gate.py` — see below.
$ restic ls -l latest --tag calibre-web
-rw-r--r-- 1000 1000 181 2026-08-04 12:53:06 .../media/books/DRILL-SENTINEL.txt
```
## The message, and what it deliberately will not say
The file, by name and size, inside the snapshot — not a green status.
> „A kódod helyes, de egy korábbi csomagot nyit meg, nem azt, amit most őrzünk ehhez a géphez. A géped
> időközben új mentési kulcsot kapott. A korábbi csomagot (dátum) nem töröltük, megőrizzük — a mostani
> mentéseidet ez nem érinti, azokkal semmi nem történt. A régebbi előzményed visszanyitásához a Felhom
> ügyfélszolgálatának segítsége kell: írj nekik, és add meg, hogy a régi mentéseidhez szeretnél
> hozzáférni. A kódodat tedd el, szükség lesz rá."
## Tests
Bytes verified as hex: `á c3 a1 · é c3 a9 · í c3 ad · ó c3 b3 · ö c3 b6 · ú c3 ba · ü c3 bc ·
ő c5 91 · — e2 80 94`. No emoji. 443 chars / 487 bytes.
`go build && go vet && go test ./...` rc=0; `controller_gates.py --fast` OK. Table-driven over BOTH
drive kinds, because this defect survived by being invisible on the kind that already worked.
It states the code is correct, names the date, says the package is kept, and denies the assumption it
otherwise creates — that the **current** backups are affected. It **promises no restore**: there is no
in-product route to a set-aside store (R-312), and a retained package may itself predate the
repository-password field. A conditional promise that turns out false on this screen is worse than
saying less — R-202, on the highest-stakes copy in the product. It routes to support, which **can** do
it: yesterday's drill did exactly that by hand.
**One red-proof PASSED and the test was wrong, not the code:** the first Scenario-C test reached only
`offboxCaptureSet` while the mutation lives in `runOffboxInternal`. Replaced with a run-level test
driving `RunOffboxBackup`; it now fails under both an unreachable gap-recording and an unconditional
`ok`. Scenario D's proof also could not fail by construction (the tier filter already excludes
optional paths) — the two-part mutation that does is recorded.
**An older agent keeps the hedged sentence.** Unknown → claims less → heals itself on update.
## Corrections to the spec
## The claim guard grew a surface, and immediately convicted something
- **Five call sites, not four.** The fifth is the FileBrowser mount builder.
- **`ExportDataMounts` is export-only despite living in `delete.go`.** Its only caller is the `.fab`
adapter, and the delete path's own `ProtectedHDDPaths` guard is layout-agnostic by construction, so
the destructive-path risk the spec flagged does not exist.
`retrieval_promise_gate.py` scanned `internal/web/templates` **only** — while every recovery message
is a Go string in a handler. The highest-stakes copy in the product had never been scanned. It now
also scans `recovery_handlers.go` (Go comments stripped, as template comments are), and `visszanyit`
joins the stems because the new message uses a fourth verb for the same claim — the gate's own history
is what happens when it chases words instead of claims.
## Observations
**On its first run it found a PRE-EXISTING unregistered claim** (`RecoverRefused`'s *"reopening would
overwrite it"*), now registered as an explanation rather than a promise. 8 → 10 registered claims.
`resolveAbs` resolves `RootHDD` and `RootUserdata` against one root — fine on enrolled drives, and now
consistent between export and backup, but whether `${HDD_PATH}` should mean the namespace root on the
system drive is a separate decision affecting every deployed app. The deploy API is
first-deployment-only (409 after), so an existing app picks up the corrected path via start/redeploy.
The CSRF form field is `_csrf`, not `csrf_token` — second session running that this cost time.
## Tests and red-proofs
Six handler tests asserting **which sentence the customer sees**. Full suite green; all 11 controller
gates OK. Red-proofs, each mutation asserted to have applied:
| Mutation | Result |
|---|---|
| delete the `RecoveryCodeOpensRetained` case | FAILS — customer gets the **neutral** message (R-224's safe default catches it; the lie itself lives in the agent) |
| make 422 unconditional (ignore the gate) | FAILS — an agent that never looked is read as having looked |
| route 400 to the new class | FAILS — a mistype is congratulated |
## Deployed
`gitea.dooplex.hu/admin/felhom-controller:0.214.0` built, pushed, deployed to guest 9201 —
`Up … (healthy)`.
**Live, on hardware:** the agent returns **HTTP 422** for the old code (`opens_retained: true`,
`superseded_at: 2026-08-12T15:18:55Z`) and **400** for a wrong one. The hub serves the retained
packages (200, `count=2`, `unopenable_count=1`).
**What was NOT walked:** the customer's rendered sentence. `recoveryUnlockHandler` redirects to
`/backups/remote` when `!recoveryOffer()`, and this box holds its own repository password again, so it
is correctly not in the offered state. Forcing it would mean removing that password to fake a rebuilt
box — destabilising a healthy machine to render a sentence pinned by six handler tests whose upstream
422 is proven live. **Method: endpoint-level for the agent/hub, handler-level for the message.**
**Correction — R-308 is WITHDRAWN.** I previously reported the stored dashboard password as stale. It
is not: the `~/.config/credentials` values are wrapped in SINGLE quotes and I stripped only double
ones, so I was sending a literal `'`. Unquoted correctly it logs in first try (HTTP 302 + session
cookie). The same bug made this session's first live R-311 test read as a failure for twenty minutes.
+5
View File
@@ -57,6 +57,10 @@
| Symbol | File | Short signature | Use for | Gotchas |
|---|---|---|---|---|
| `backup.ErrOffboxSealedPackageHeld` + `IsOffboxSealedPackageHeld` + `sealedPackageHeld` + `OffboxAwaitingRecoveryKey` (R-241, v0.206.0) | controller/internal/backup/offbox.go | sentinel; `(error) bool`; `() bool`; `() bool` | **THE MINT GUARD** — a box never creates a repository key while the hub holds a sealed package for it | **The guard is a CONJUNCTION** (package held AND no key present). Widening it to "never mint" leaves a first-time box unable to start, waiting for a package that will never exist — pinned by `TestR241_ScenarioB_FirstTimeBoxStillMints`. **The refusal is a HOLDING state, not a failure:** `ApplyOffsiteTarget` catches the sentinel and still writes the transport, so `/recovery`'s synchronous tier-up (R-219) can bring the tier up the instant the key arrives; returning the error instead leaves `needsOffsiteCredential` true and the hub re-staging a consumed credential for ever. `OffboxAwaitingRecoveryKey` is **DERIVED, never stored** — and **`t.Enabled` is load-bearing in it**: a customer who switched off-site OFF is not awaiting anything (the Scenario-E carve-out `needsOffsiteCredential` makes two functions above; the first draft omitted it and an existing test caught it). A nil settings store reads as "no package held" — a transient read failure must never become a permanently-held tier |
| `settings.HubEscrowKeySHA256` + `SetHubEscrowKeySHA256` / `GetHubEscrowKeySHA256`, and `OffsiteRecoveryOffer` **shape (c)** (R-241, v0.206.0) | controller/internal/settings/settings.go, controller/internal/backup/offbox.go | `(sha, checkedAt string) error` / `() (string, string)` | **THE DISCRIMINATOR the recovery screen asks** — does the hub hold a package for a key other than the one we use? | **The comparison was ALREADY computed on every ACK since SLICE 3 and persisted nowhere** — that is R-241's second half. Wire the recorder in `main.go`'s `EscrowAutoConfirmer` literal or shape (c) reads an empty hash for ever and the fix ships INERT (pinned by `TestMainWiresRecordEscrowKeyHash`). **§7.2 staleness, decided:** a KNOWN DIFFERENCE offers **however old the reading** — age is deliberately NOT gated on, because gating makes a box offline from the hub silently stop offering; an **ABSENT hash falls back to (a)/(b)** and does NOT offer, because `""` is the hub positively saying its package seals no key (legacy hash-less escrow), not an unknown. `CheckedAt` is for diagnosis, never a gate |
| `backup.AbandonStatus` / `AbandonSweep` / `CancelAbandon` / `ClearAbandonPurgeIfConfirmed` / `ExtendAbandon` / `StopAbandon` + `AbandonGraceDays` (R-241, v0.206.0) | controller/internal/backup/offbox_abandon.go | see file | **The 14-day abandonment countdown** — the ONLY thing in the product that deletes a customer's off-site history | **BOTH HALVES OR NEITHER.** The set-aside store and the sealed package that protects it are two halves of one thing; removing only one leaves a package that opens nothing, or ciphertext nobody can decrypt. Not atomic across two machines, so it is a **two-phase commit**: delete the store, set `AbandonPurgeRequested`, and keep declaring it until the hub's ACK stops reporting a superseded package — the confirmation rides the SAME ACK as the request. **The countdown starts in `ResetOrphanedRepo`, NOT in the shared `resetOrphanedRepo`** — the helper is also the UNCLAIMED auto-reset, where nobody decided anything. **The recovery offer stays reachable for the whole grace** (a grace in which recovery is impossible is decorative). **Drive it with `SetOffboxClock`, never a shortened live timer** (§7.4). A transport failure leaves the countdown DUE so tomorrow retries; the operator levers REFUSE rather than no-op when nothing is running or the store is already gone |
| `settings.SyncRecoveryOfferEpoch` / `PostponeRecoveryNoticeForEpoch` / `OptOutRecoveryRemindersForEpoch` + `web.recoveryBannerCookie` (R-241, v0.206.0) | controller/internal/settings/settings.go, controller/internal/web/recovery_handlers.go | `(offered bool, now) (RecoveryOfferView, error)` | **The offer EPOCH** — "once per entry into the offered state", not once ever | **Sync the epoch FIRST and UNCONDITIONALLY in `recoveryInterrupts`.** The first draft returned early when the offer was false, so the FALLING edge was never recorded, `RecoveryOfferActive` stayed true through a settled period, and the next entry counted as a continuation — **the exact defect the epoch exists to fix, reintroduced inside the fix**. Dismissals are recorded against the epoch they were made in, so a fresh entry resets them **by arithmetic**, with nothing to clear. **Three levers, three scopes, and NONE removes the entry point on `/backups/remote`:** the banner cookie is a browser SESSION cookie (no MaxAge — cleared on login) and persists nothing; the reminder opt-out is durable but silences the BANNER ONLY; "most nem" suppresses the full page only |
| `atomicWrite` | controller/internal/backup/recovery_unit.go | `(path, data, perm) error` | Atomic file writes (backup pkg) | tmp+rename; no dir creation, no fallback |
| `writeFileAtomic` | controller/internal/bootstrap/bootstrap.go | `(path, b) error` | controller.yaml writes from bootstrap | Always 0600 (holds local-api token + hub key) |
| `writeConfig0600` | controller/internal/api/router.go | `(path, body) error` | config writes via API | ALWAYS chmods 0600 even pre-existing (F8); direct-write fallback on bind-mount EBUSY (non-atomic!) |
@@ -123,6 +127,7 @@
| `EncryptFile` / `DecryptFile` / `IsEncryptedFAB` | controller/internal/appexport/crypto.go | password-based file crypto | .fab export bundles | scrypt-derived AES+HMAC keys |
| `maskRepoURL` | controller/internal/sync/sync.go | `(url) string` | Logging git URLs | Strips embedded credentials |
| `metrics.RedactLine` | controller/internal/metrics/redact.go | `(s string) string` | ANY log line shipped off-box (issue context, log tails) | Masks password/passwd/secret/token/api-key/authorization/bearer values + 64-hex; apply BEFORE the line leaves the box — controller-side redaction is authoritative |
| `settingsRetrievalPasswordRevealHandler` | controller/internal/web/handlers.go | `POST /settings/retrieval-password/reveal` | **THE PATTERN for showing a secret in the UI** — an XHR that returns only the value | **Never template a secret into a page and hide it with CSS.** `display:none` / `hidden` / `type="password"` stop a browser DRAWING the value; the plaintext is still in the response body, so a `curl` of the page returns it, and it reaches caches, history and any screen-share of the source. R-249 shipped exactly that for two months and was found by it landing in a transcript. The page carries a **boolean** (`HasRetrievalPassword`); the value comes from a POST (CSRF-covered, uncacheable) and the reveal is **logged as an act**. `escrow_handlers.go` states the same rule for R. **Test on the RESPONSE BODY** — a test asserting what the customer *sees* cannot see this class at all. **Both R-254 sites are now FIXED the same way**`POST /apps/<slug>/initial-credentials/reveal` (re-reads the container, never a cached copy) and `POST /stacks/<name>/auto-field/reveal` (authorised on the field being a `type: secret` auto-field of that stack). **Per-secret, never one generic reveal-any-named-secret endpoint.** The PRE-DEPLOY hidden input is deliberate and untouched — a form must carry what it submits (README §318). Enforced by `scripts/secret_in_markup_gate.py`, whose measured blind spot (a secret under a neutral page-data key) is in its docstring; runtime body-assertion covers 4 of 27 pages — R-255. |
### Storage registry + mount detection
+88
View File
@@ -154,6 +154,60 @@ backups, monitoring and notifications. All Proxmox/disk operations are delegated
action block right; used by the dashboard installed-apps list, the Távoli mentés toggle list
and the Visszaállítás restore-to-verify/.fab lists; the backups-apps expander header is
ALIGNED to the same grammar (own markup — it carries the toggle). Protected infra stacks
**The off-site restore list is keyed on the STORE (v0.204.0, R-237):** `offsite_restore_list.go`
builds it from `OffsiteInventoryList` (the repository's own snapshot tags), NOT from deployed +
offsite-toggled apps. A rebuilt box has neither and used to be shown nothing to restore while its
snapshots sat in the repository. Installed-ness is a property OF a row (it changes what restoring
implies), never a filter on it; an unreadable store renders as UNKNOWN and keeps the action; the
`felhom-offbox` and `_shares` marker tags are never offered as apps.
**Its two preconditions now name a reason AND a route (v0.207.0, R-252/R-253):** a rebuilt box keeps
its drives but loses their REGISTRATION, so the page renders a notice — *„Előbb csatold vissza az
adatmeghajtót"*, linking to `/storage` — whenever `HasRestoreDestination()` is false, asked through
the backup manager's own predicate so page and resolver read the same `GetSchedulableStoragePaths()`.
And the not-installed row no longer promises *„a visszaállítás előbb újratelepíti"*: reconstitution
writes to the app's own `GetStackHDDPath`, which exists only after the customer picks a drive at
deploy time, so the copy says to install it first and links to `/stacks/<app>/deploy`. **Both notices
are conditional** — a healthy box renders exactly as before, pinned by a test that fails if either
becomes unconditional.
**A run that skipped a selected app is `incomplete` (v0.205.0, R-234):** the off-site verdict now
counts `missingUnprotected` beside `mandatoryGaps` — an app the customer selected that is DEPLOYED
but has no recovery unit is not protected, so the run is not `ok`. A selected-but-UNDEPLOYED app is
named with what to do and does NOT move the verdict (a permanently amber box is a status nobody
reads); a disconnected/decommissioned drive has its own signal. The manual „Távoli mentés most”
also refuses SYNCHRONOUSLY when a run is already in flight, instead of answering „elindult” and
leaving the previous run's verdict on the card.
**The box does not mint a repository key over a sealed package (v0.206.0, R-241):**
`WriteOffboxSecrets` auto-generated on ONE input — does the password file exist — while its two
neighbours in the same file both consulted `GetHubEscrowIdentityPresent()`. A rebuilt box's
credential self-heal therefore wrote a fresh key over the package the hub was holding for it, and
the recovery screen then correctly reported that nothing was recoverable under the key the box
held. The guard is a CONJUNCTION (a package held AND no key present), so a first-time box mints
exactly as before; the refusal is a HOLDING state that still writes the transport and declares
`offsite.state=awaiting_recovery_key`, so `/recovery` can bring the tier up the moment the key
arrives. `--abandon-status` / `--abandon-extend=N` / `--abandon-stop` are the operator levers.
**The recovery offer has a third shape (v0.206.0, R-241):** shape (c) — *the hub holds a package
for a key OTHER than the one we are using*. Shapes (a) "no key at all" and (b) "a run proved the
repo will not open" are proxies for that question and have each been wrong in opposite directions.
The comparison was already computed on every report ACK and persisted nowhere; it now lives in
`settings.HubEscrowKeySHA256`. A KNOWN difference offers however old the reading; a hash never
learned falls back to (a)/(b).
**Abandoning the old history is a finishable thing (v0.206.0, R-241):** the customer's confirmed
set-aside starts a **14-day countdown**, visible on Távoli mentés and reversible by recovering with
the code, at the end of which the set-aside store AND the hub's retained sealed package are removed
TOGETHER (two-phase commit; the hub half is `PurgeSupersededEscrowForCustomer`, hub v0.98.0).
Afterwards shape (c) has nothing to compare and the offer ends because the state is right. The
recovery screen appears **once per ENTRY into the offered state** (an epoch), the reminder bar is
per-visit, the durable opt-out silences the bar only, and **none of the three removes the entry
point on Távoli mentés**. While a recovery is outstanding, „Helyreállítási kód létrehozása" is
UNAVAILABLE — creating a new code would put the earlier history beyond every shipped path.
**A code for an EARLIER package is now recognised as correct (v0.214.0, R-311; MinAgent 0.129.0).**
When the supplied code does not open the package the hub currently holds, the agent tries the
RETAINED ones and answers 422 if one opens; the screen then says the code is *correct*, names the
supersession date, says the earlier package is kept and that the CURRENT backups are unaffected, and
routes to support. It deliberately promises **no restore** — there is no in-product route to a
set-aside store (R-312), and the retained package may itself predate the repository-password field.
On an agent older than 0.129.0 the screen keeps the earlier hedged wording, which was honest: until
something looked, a correct-but-earlier code and a mistype really were indistinguishable.
(traefik/cloudflared/filebrowser) get curated Hungarian display identity from the
`inframeta.go` map (name + description + generic `/static/infra-logo.svg` fallback icon);
filebrowser is the only infra stack with a customer link (`files.<domain>`).
@@ -456,6 +510,17 @@ Each app can define rich metadata in `.felhom.yml`:
+ `dumps_at`; a manifest without them is a pre-v0.148 pair of unknown skew, surfaced at restore
time. The periodic refresh carries the prior stamp forward and never invents one. A dump-leg
failure is a loud WARN that does NOT abort the push (data-first: a degraded backup beats none).
- **A scratch restore NAMES ITS SCOPE (v0.198.0, R-204 item 3 — `restoreScratchOutcomeMsg`).**
`mode=unit` (the default) restores the recovery unit only — the app's definition, configuration
and DB dumps — because `RestoreOffboxScratch` passes `--include <unit path>`; the userdata that is
in the SAME snapshot is excluded by it. The outcome flash used to be one sentence for both modes
and named neither scope, so on the last step of a disaster recovery the customer was told
„visszaállítva" after the thing they were looking for had not been. The unit outcome now states
what came back, that the customer's own files did NOT, and the step that gets them; the full
outcome states that the files came with it (an absence is not a statement). The wizard's intent
card 1 carries the same scope BEFORE the choice. **The `mode=full` two-step size gate is
untouched**, and the default stays `unit` — all three wizard forms set `mode` explicitly, so
changing it would alter nothing the customer sees while silently changing a mode-less POST.
- **Offsite reconstitution (v0.148.0, R-43 — `offbox_reconstitute.go`):** the leg that was missing.
`ReconstituteFromOffsite` (`/backup/offbox/reconstitute`, „Teljes visszaállítás (fájlok +
adatbázis)") makes the live app equal to the chosen snapshot: **safety dump → stop → files
@@ -2241,6 +2306,17 @@ race where a new `felhom.<domain>` cert appears in CT logs minutes before any pa
never rewrites, a hub outage never clears). The report carries `claimed` (hub ingests set-only).
- **Escape hatch**: `felhom-controller --print-reset-code` prints a one-time local code (generation
above cached/baked/consumed); the same gate consumes it. Root-gated by `docker exec` reachability.
**It runs as a SEPARATE PROCESS, which is why `effectiveClaimCode` READS THROUGH to the persisted
state (v0.198.0, R-204 item 1).** Until then the hatch persisted a new code while the running
server kept the old one cached, so the minted code was refused until the controller was restarted —
and nothing said so. The read-through (`settings.ReloadClaimCode`) is on the claim path only, and
only while the box carries no password (`claimGateActive` returns on `authEnabled()` first).
**Deliberately not a watcher, a signal handler or a TTL:** a TTL would leave a window in which a
SUPERSEDED code still works, which is worse than the bug. Refreshes hash/generation/issuedAt only —
never `ClaimConsumedGeneration`, which this process alone writes and must stay monotonic.
`effectiveClaimCode` returns an error and **every caller fails closed** (the gate stays UP, the claim
is refused); an ABSENT settings file is not an error, since a pre-first-save box falls back to the
controller.yaml bake.
#### Session Auth (`internal/web/auth.go`)
@@ -2478,6 +2554,18 @@ During setup wizard drive scan, both current and historical backups are discover
Generates `recovery-info.txt` on the system data partition with customer ID, Hub URL, retrieval password, and recovery instructions in Hungarian. Updated on startup and after config changes. Also displayed on the Settings page in a "Vészhelyzeti információk" section.
**No secret is rendered into a page's response body (v0.207.0 + v0.208.0, R-249/R-254).** Three endpoints implement one rule — the page carries a BOOLEAN, the value comes from an explicit authenticated POST with `Cache-Control: no-store`, and the reveal is LOGGED (reading a value off markup left no trace at all):
| Secret | Endpoint | Read from |
|---|---|---|
| retrieval passphrase | `POST /settings/retrieval-password/reveal` | settings |
| an app's generated first-login password | `POST /apps/<slug>/initial-credentials/reveal` | **live from the container** — never a cached copy |
| an already-deployed app's auto-generated secret field | `POST /stacks/<name>/auto-field/reveal` | the decrypted `app.yaml`; authorised by requiring a `type: secret` auto-field of that stack |
They are deliberately **per-secret**, not one generic "reveal any named secret" endpoint — that would turn three narrow exposures into one lever with a parameter. **The PRE-DEPLOY hidden input is untouched and deliberate:** a form must carry what it submits (see §318 below). `scripts/secret_in_markup_gate.py` (in `controller_gates.py`) enforces the rule over all templates; its measured blind spot — a secret arriving under a neutral page-data key — is in its docstring.
**The retrieval passphrase is NOT rendered into that page (v0.207.0, R-249).** `securityPageData` passes only `HasRetrievalPassword` (a boolean), and the value is fetched by an explicit act: **`POST /settings/retrieval-password/reveal`** → `{"ok":true,"data":{"password":"…"}}`, behind RequireAuth + CsrfProtect like every other POST, `Cache-Control: no-store`, and logged (`retrieval passphrase revealed via the security page from <ip>` — the value is never logged). Until v0.207.0 the page rendered the plaintext into a `display:none` span, so any fetch of the page returned it; the toggle was cosmetic. This follows the rule `escrow_handlers.go` already states for the recovery code: a secret is revealed by an XHR, never templated server-side into HTML.
### 11. Disaster Recovery
When a system drive fails and is replaced, the recovery flow uses the setup wizard:
+165
View File
@@ -78,6 +78,12 @@ func main() {
recoverOffsiteCheck := flag.Bool("recover-offsite-check", false, "R-200 diagnostic: read the customer recovery code from STDIN, recover the offsite repository password from the hub-held sealed escrow via the agent, and report whether it matches the one on disk — BY HASH. Compares, never installs; writes nothing. Exit 0 = match, 2 = clean mismatch, 1 = a step failed.")
recoverOffsiteInstall := flag.Bool("recover-offsite-install", false, "R-200: read the customer recovery code from STDIN, recover the offsite repository password, print both hashes, and — with --confirm-install — PLACE it so the existing repository reopens. Without --confirm-install it is a dry run that writes nothing.")
confirmInstall := flag.Bool("confirm-install", false, "Required alongside --recover-offsite-install to actually write the recovered repository password. Deliberately a second invocation so the hashes are seen before any write is possible.")
// §7.5 (R-241): the operator levers for a running abandonment countdown. The automatic 30-day
// ending is deliberately NOT built (R-245) — what is built is the path that actually happens,
// which is the customer getting in touch and support needing something to press.
abandonStatus := flag.Bool("abandon-status", false, "R-241: print the state of this box's off-site abandonment countdown (set-aside path, due date, days left) and exit. Read-only.")
abandonExtend := flag.Int("abandon-extend", 0, "R-241 (operator): extend a running abandonment countdown by N days from now, then exit. Refuses when no countdown is running.")
abandonStop := flag.Bool("abandon-stop", false, "R-241 (operator): stop a running abandonment countdown, then exit. The set-aside history is kept and nothing is deleted. Refuses when no countdown is running.")
flag.Parse()
if *showVersion {
@@ -152,6 +158,52 @@ func main() {
}))
}
// §7.5 (R-241) — the operator's abandonment levers. Grouped in one block, before the server
// starts, exactly like the other CLI subcommands: each loads config + settings, acts, and exits.
if *abandonStatus || *abandonExtend > 0 || *abandonStop {
cfg, err := config.LoadPermissive(*configPath)
if err != nil {
fmt.Fprintf(os.Stderr, "abandon: loading config: %v\n", err)
os.Exit(1)
}
lg := log.New(os.Stderr, "", 0)
sett, err := settings.Load(cfg.Paths.DataDir+"/settings.json", lg)
if err != nil {
fmt.Fprintf(os.Stderr, "abandon: loading settings: %v\n", err)
os.Exit(1)
}
mgr := backup.NewManager(cfg, sett, lg)
st := mgr.AbandonStatus()
switch {
case *abandonStop:
if serr := mgr.StopAbandon(); serr != nil {
fmt.Fprintf(os.Stderr, "abandon-stop: %v\n", serr)
os.Exit(2)
}
fmt.Printf("abandonment STOPPED — the set-aside history at %s is kept; nothing was deleted\n", st.RepoPath)
case *abandonExtend > 0:
due, eerr := mgr.ExtendAbandon(*abandonExtend)
if eerr != nil {
fmt.Fprintf(os.Stderr, "abandon-extend: %v\n", eerr)
os.Exit(2)
}
fmt.Printf("abandonment EXTENDED by %d day(s) — the set-aside history at %s is now deleted on %s\n",
*abandonExtend, st.RepoPath, due.Format("2006-01-02"))
default:
if !st.Active && !st.PurgeRequested {
fmt.Println("no abandonment countdown is running on this box")
os.Exit(0)
}
if st.PurgeRequested {
fmt.Printf("set-aside history DELETED at %s; awaiting the hub to drop the sealed package\n", st.RepoPath)
os.Exit(0)
}
fmt.Printf("abandonment countdown RUNNING\n set-aside history : %s\n chosen on : %s\n deleted on : %s\n days left : %d\n",
st.RepoPath, st.StartedAt.Format("2006-01-02"), st.DueAt.Format("2006-01-02"), st.DaysLeft)
}
os.Exit(0)
}
if *printResetCode {
cfg, err := config.LoadPermissive(*configPath)
if err != nil {
@@ -494,6 +546,10 @@ func main() {
// updater's floor/update-running state and defer the one-time-password consume past a managed
// day-0 floor-update (the F10 race). The gate is wired ONLY when an updater exists — with no update
// mechanism there is no floor-update to race, so the bridge reconciles immediately (Settle nil).
// offsiteBridge is hoisted out of the block below ONLY so the recovery screen can drive one
// reconcile between placing a recovered key and reading the repository (R-219). nil when off-site
// is not configured for this customer, which the web seam treats as "skip".
var offsiteBridge *offsiteapply.Bridge
if backupMgr != nil && cfg.Offsite.Enabled && cfg.Hub.URL != "" && cfg.Hub.APIKey != "" {
bridge := &offsiteapply.Bridge{
Cfg: cfg,
@@ -526,6 +582,7 @@ func main() {
return Version, floor, u.IsUpdateRunning(), floor != ""
})
}
offsiteBridge = bridge
go func() {
// ReconcileWhenSettled runs the settle-gate FIRST (its own bounds), then Reconcile under a
// fresh 3-minute context — the gate's wait never eats the reconcile budget.
@@ -665,6 +722,29 @@ func main() {
}
return ac.WipeStagedEscrowSecret(ctx)
},
// v0.199.0 (R-204 item 4 / R-193): remember whether the HUB holds a sealed recovery
// package for us — recorded on EVERY ACK, including when no off-site target exists, which
// is precisely the case that needs it. Half of the stranded-rebuild predicate in
// backup.needsOffsiteCredential; the other half (no repository password) is local.
RecordPresence: sett.SetHubEscrowIdentityPresent,
// v0.201.0 (R-222): whether the hub is ALSO keeping an EARLIER sealed package, so the
// recovery screen can name that situation instead of blaming the customer's typing.
// v0.206.0 (R-241): the superseded fact ALSO closes out an abandonment. When the hub stops
// reporting a superseded package, the sealed package that protected the deleted set-aside
// history is gone too — both halves are away and the question is finished (Scenario F).
// Chained onto the existing recorder rather than added as a second ACK consumer, for the
// reason RecordPresence's own comment gives.
RecordSuperseded: func(present bool, at string) error {
if backupMgr != nil {
backupMgr.ClearAbandonPurgeIfConfirmed(present)
}
return sett.SetHubEscrowSuperseded(present, at)
},
// v0.206.0 (R-241): the hash the hub's package COVERS. This is the fact the recovery
// screen's shape (c) reads, and the comparison against it was already being computed here
// on every ACK and discarded. Wired at the same point as the two above, deliberately —
// a second wiring site in this file is how six features got built and never reached.
RecordEscrowKeyHash: sett.SetHubEscrowKeySHA256,
Logger: logger,
}
// Wire hub verification: update settings when hub reports customer status
@@ -743,6 +823,55 @@ func main() {
return err
})
// ── R-218, THE CONSUME HALF (v0.203.0) ────────────────────────────────────────────────
//
// The declaration half shipped in v0.201.0 and works: a stranded box says
// `offsite.state=needs_credential`, and the hub's `offsiteheal` re-stages the one-time secret
// and logs *"the box re-consumes on its next cycle"*.
//
// **THERE WAS NO NEXT CYCLE.** `Reconcile` ran exactly twice in a process's life: once at
// start-up (the goroutine above, whose own comment says "retries on next config refresh/
// restart") and once when the recovery screen drives it (R-219). Both fire BEFORE the hub has
// anything staged, because the hub only stages in response to the declaration those runs
// precede.
//
// Measured on the R-201 re-walk, 2026-08-06: unlock reconcile 11:43:07 · hub staged 11:44:57
// saying "next cycle" · a full report cycle ran 11:55:46 · still unconsumed at 12:06. A guest
// command line moved it in **18 seconds**, which proves the credential, the target and the key
// were all fine and only the trigger was missing.
//
// So: re-run the SAME reconcile, on a tick, for exactly as long as the box itself says it needs
// a credential. The signal is the box's own `OffboxReportStatus().State` — the very statement
// the hub acts on, so the two can never disagree about whether a retry is wanted.
//
// WHY A POLL AND NOT AN ACK FLAG: the customer-facing text promises "amint megvannak" (no
// deadline) and the backups card promises "within a day"; a 5-minute tick is inside both by a
// wide margin, and it needs no hub change. If either promise ever tightens to minutes, revisit.
//
// IT STOPS BY CONSTRUCTION: the instant a target exists `OffboxReportStatus` stops returning
// the declared state, so a healthy box does no work and makes no noise. The settle gate is
// deliberately kept — `ReconcileWhenSettled` waits for floor knowledge exactly as at start-up.
sched.Every("offsite-credential-retry", 5*time.Minute, func(ctx context.Context) error {
if offsiteBridge == nil {
return nil // off-site not configured for this customer
}
attempted, err := offsiteBridge.RetryIfDeclared(ctx, func() bool {
st := backupMgr.OffboxReportStatus()
return st != nil && st.State == backup.OffsiteStateNeedsCredential
})
if !attempted {
return nil // a target exists (or we never needed one) — no work, and no log line
}
if err != nil {
// NOT a job failure: the box still declares, so the next tick tries again. Logged at
// WARN because a credential that never arrives is exactly what this exists to surface.
logger.Printf("[WARN] [offsite-apply] credential retry: %v (the box still declares a need; retrying)", err)
return nil
}
logger.Printf("[INFO] [offsite-apply] credential retry: the staged credential was collected and the tier applied")
return nil
})
// Cache refresh: every 5 minutes. Recompute the effective window each pass so the cached
// "next DB dump" follows a runtime window change (the UI save also refreshes immediately).
sched.Every("backup-cache", 5*time.Minute, func(ctx context.Context) error {
@@ -870,6 +999,12 @@ func main() {
case "offbox_repo_reset":
notifier.PushEvent("offbox_repo_reset", "info",
"A távoli mentési tároló visszaállítva: a régi előzmény félretéve (nem törölve), és egy üres, új tároló jött létre a mostani kulccsal.", map[string]string{"renamed_to": renamedTo})
case "offbox_abandon_completed":
// R-241: the ONLY event in the product that reports a customer's off-site history
// being deleted. It is fired after the deletion, not before — the operator wants to
// know it happened, and a pre-announcement that then fails would be worse than silence.
notifier.PushEvent("offbox_abandon_completed", "info",
"A korábbi távoli mentések a türelmi idő lejártával törlésre kerültek, az ügyfél döntése alapján. A hozzájuk tartozó lezárt helyreállítási csomag eltávolítását is kértük.", map[string]string{"deleted_path": renamedTo})
}
})
sched.Daily("offbox-backup", offboxLeg, func(ctx context.Context) error {
@@ -879,6 +1014,26 @@ func main() {
}
return backupMgr.RunOffboxBackup(ctx)
})
// R-241 — the abandonment terminal step. DAILY and not on the backup leg, deliberately: it must
// run on a box whose off-site tier is NOT configured for runs (an abandoning box may be sitting
// with escrow pending), and tying it to the backup leg would make the deletion depend on a
// condition that has nothing to do with it.
//
// It is quiet by construction: on every box with no countdown it returns immediately and logs
// nothing, which is asserted (TestR241_Sweep_QuietWhenNothingDue).
sched.Daily("offsite-abandon-sweep", "05:10", func(ctx context.Context) error {
deleted, err := backupMgr.AbandonSweep(ctx)
if err != nil {
// NOT a job failure: the countdown stays due and tomorrow's sweep retries. A transport
// blip must never silently abandon the abandonment.
logger.Printf("[WARN] [offbox] abandonment sweep: %v (the countdown stays due and retries)", err)
return nil
}
if deleted {
logger.Printf("[INFO] [offbox] abandonment sweep: the set-aside history was deleted this cycle")
}
return nil
})
}
// Metrics prune — daily at 04:00
@@ -1259,6 +1414,16 @@ func main() {
// Escrow wizard (v0.127.0): the Scenario-F stale-blob flag feeds the Távoli mentés card.
if escrowConfirmer != nil {
webServer.SetEscrowStale(escrowConfirmer.StaleBlob)
// R-193: the recovery screen may state WHEN the hub sealed the package, and nothing more,
// before a code is entered. A timestamp, never a secret.
webServer.SetEscrowSealedAt(escrowConfirmer.SealedAt)
}
// R-219 / R-218: the unlock brings the off-site tier up between placing the recovered key and
// reading the repository. Without it the listing the screen promises can never render on the
// pristine rebuilt box — no key ⇒ no target ⇒ no inventory — and the apply-bridge's only other
// trigger is a config refresh or a restart, neither of which the customer can cause.
if offsiteBridge != nil {
webServer.SetRecoveryTierUp(offsiteBridge.Reconcile)
}
webServer.SetIntegrationManager(integrationMgr)
if reportTrigger != nil {
+5
View File
@@ -424,6 +424,11 @@ type DiskCandidate struct {
Mountable bool `json:"mountable"`
MountSource string `json:"mount_source,omitempty"`
DurableID string `json:"durable_id,omitempty"`
// AlreadyMounted marks a candidate the CONTROLLER contributed from its own mount table (R-280),
// not one the agent scanned. The agent NEVER sets it. Its action is REGISTER the existing
// mountpoint — sending it down the device-attach path would try to mount an in-guest path as if
// it were a raw device. See web/attach_sources.go for why the agent's scan cannot supply these.
AlreadyMounted bool `json:"already_mounted,omitempty"`
}
// CandidatesResult mirrors GET /disks/candidates: disks free to enroll, split into initialize (all
+135 -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,132 @@ 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
// RecoveryCodeOpensRetained — the code was used, it WORKED, and it opened a RETAINED earlier
// package rather than the one currently held (R-311, agent >= v0.129.0).
//
// **The customer is not at fault here and must not be told they might be.** This class exists
// because until 2026-08-12 this situation and a mistype were indistinguishable: both fail closed
// against the current package, and nothing ever tried the retained ones. The screen said as much
// out loud — a true sentence about our own incuriosity that a customer reads as a statement about
// their code.
RecoveryCodeOpensRetained
)
// 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.
// ⚠ `trustRetained` is the R-311 twin of `trustRefusal` and is separate on purpose: the two gates
// name different agent versions (v0.126.0 and v0.129.0) and a box can sit between them. Passing
// `trustRefusal` for both would let a v0.126128 agent's unexpected 422 be read as a verdict it
// cannot produce.
func ClassifyRecoveryFailure(err error, trustRefusal, trustRetained 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.StatusUnprocessableEntity:
// R-311. Gated on the SAME trust flag as 400, and for the mirror-image reason: an agent that
// predates the retained lookup cannot emit 422 at all, so a 422 from anywhere else is a shape
// we did not design and must not be read as a statement about the customer's code.
if trustRetained {
return RecoveryCodeOpensRetained
}
return RecoveryUnknown
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"
case RecoveryCodeOpensRetained:
return "code-opens-retained"
default:
return "unknown"
}
}
+90
View File
@@ -35,6 +35,54 @@ const FeatureGuestMemoryResize Feature = "guest_memory_resize"
// an empty field means legacy.
const FeatureBackupAgeState Feature = "backup_age_state"
// FeatureOffsiteKeyRecovery is the customer-facing off-site key recovery (agent v0.125.0, R-199
// links 78): POST /escrow/recover-offsite-password fetches this host's sealed bundle, unseals it
// with R and returns the single repository-password field.
//
// ⚠ THIS GATE FAILS CLOSED, and it is the ONLY feature in this table that does. Read §7.1 of the
// R-216 fix before "correcting" it back to the package default.
//
// The package default is fail-OPEN: SupportUnknown proceeds, because for every other coupled feature
// a wrong "unsupported" would block something harmless while a down agent already speaks through the
// normal error paths. **That default is what produced R-216.** Measured live on 2026-08-05
// (CAMPAIGN-11 Phase 1): an agent 0.120.0 answered the recovery route with 404, the unlock attempt
// went ahead anyway, and the customer was told — in Hungarian, on the one screen whose whole purpose
// is to be believed about backups — that their perfectly correct recovery code was not accepted and
// they should check their typing. A correct code, refused in 0.134 s, blamed on the customer.
//
// So here: anything other than SupportYes means the screen says THE MACHINE cannot ask yet. The
// unlock is never attempted when it cannot complete, because the failure of an attempt that could
// 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"
// FeatureRetainedRecoveryClass is agent v0.129.0's FIFTH status on a failed unlock (R-311): 422, the
// code is correct and opens a RETAINED earlier package rather than the current one.
//
// ⚠ WHAT THIS GATE GUARDS is whether the screen may say WHICH of the two causes it is. Before
// v0.129.0 nothing ever tried the retained packages, so a correct-but-earlier code and a mistype were
// genuinely indistinguishable and the screen said so. That sentence was HONEST then and becomes a
// falsehood the moment the agent can tell them apart — so the gate decides which of two true
// sentences to print, never whether to attempt the unlock.
//
// Unknown → the older, hedged sentence. That is the safe direction: it claims less, it was correct
// for two months, and it heals itself when the agent updates.
const FeatureRetainedRecoveryClass Feature = "retained_recovery_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
@@ -92,12 +140,36 @@ var featureProbes = map[Feature]func(ctx context.Context, p SupportProber) error
_, err := gm.GuestMemory(ctx)
return err
},
// The recovery route is a POST that performs work and consumes a recovery code — it cannot be
// probed. Like the memory prober's negative case this returns a sentinel that classifies to
// SupportUnknown, so the decision falls to the VERSION path above.
//
// The row must exist even though it cannot probe: SupportsWithSource looks up featureProbes
// FIRST and returns "unregistered"/SupportUnknown on a table gap, before the version path runs.
// A featureMinAgent row without a featureProbes row is therefore never consulted at all.
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
},
// R-311, same route and same reason. The row must exist or SupportsWithSource returns
// "unregistered"/SupportUnknown on the table gap and the version row is never consulted.
FeatureRetainedRecoveryClass: func(ctx context.Context, p SupportProber) error {
return errNoRecoveryProbe
},
}
// errNoMemoryProbe classifies to SupportUnknown (not a *StatusError 404), so a prober that cannot be
// asked never reads as "unsupported".
var errNoMemoryProbe = errors.New("agentapi: prober does not support the guest-memory probe")
// errNoRecoveryProbe classifies to SupportUnknown: the off-site key recovery route is a POST that
// consumes a recovery code and so cannot be probed, leaving the VERSION path to decide. Its caller
// fails CLOSED on Unknown — see FeatureOffsiteKeyRecovery.
var errNoRecoveryProbe = errors.New("agentapi: the offsite key recovery route cannot be probed")
// featureMinAgent maps each coupled feature to the MINIMUM agent version that carries its coupled
// semantics (the CHANGELOG `MinAgent:` header value). Used by Supports when the agent's version is
// KNOWN (the v0.82.0 X-Felhom-Agent-Version channel) — a direct comparison, no probe traffic. A
@@ -107,8 +179,26 @@ var featureMinAgent = map[Feature]string{
FeatureGuestMemoryResize: "0.90.0",
// R-88 Part 2: /backup/due carries age_state, distinguishing "never backed up" from "cannot tell".
FeatureBackupAgeState: "0.105.0",
// 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",
// R-311 — the FIFTH status: 422, "your code is correct, it opens an EARLIER package". Before
// v0.129.0 the agent never looked at retained packages, so this situation was indistinguishable
// from a mistype and arrived as 400. An older agent therefore cannot produce a 422 at all, and the
// screen must keep saying it cannot tell the two apart — which was true, and is what this gate
// preserves for boxes that have not updated yet.
FeatureRetainedRecoveryClass: "0.129.0",
}
// MinAgentFor returns the declared minimum agent version for a feature ("" when the feature has no
// row). Read-only accessor over featureMinAgent so a refusal can NAME the version it needs instead of
// hard-coding the number a second time at the call site.
func MinAgentFor(f Feature) string { return featureMinAgent[f] }
// AgentVersionReporter is optionally implemented by a SupportProber (*Client is one): it reports
// the last strictly-validated agent version seen on its traffic ("" = unknown → probe fallback).
type AgentVersionReporter interface {
+13
View File
@@ -103,6 +103,13 @@ type Manager struct {
// offboxSizer (3a) — the mandatory-set byte estimator for the pre-push enlargement gate, overridable
// in tests so the gate is unit-testable without a real du. Nil → the real dirSizeBytes (du -sb).
offboxSizer func(path string) int64
// offboxNow (v0.206.0, R-241) is the abandonment countdown's clock. Nil → time.Now.
//
// IT EXISTS SO THE TERMINAL STEP IS TESTABLE WITHOUT SHORTENING A LIVE TIMER (§7.4). The sweep is
// the only thing in the product that deletes a customer's off-site history; driving it with a
// clock keeps that step exercised on every run of the suite instead of once, on real data, by an
// operator who then has to hope.
offboxNow func() time.Time
// offboxEnlargeBlockedNotify (3a), if set, is called ONCE per app that NEWLY enters the
// quota-blocked (enlargement-refused) state — edge-triggered against the persisted EnlargedBlocked
// set so a nightly schedule can't re-notify a persistently-blocked app (the hub owns cooldown; the
@@ -857,6 +864,12 @@ func (m *Manager) IsRunning() bool {
return m.running
}
// AcquireRunningForTest / ReleaseRunningForTest occupy the single-flight from another package's
// test, so the "a run is already in flight" branch can be exercised without racing a real run.
// Test-only seam, in the same spirit as SetOffboxRunner; nothing in production calls them.
func (m *Manager) AcquireRunningForTest() error { return m.acquireRunning() }
func (m *Manager) ReleaseRunningForTest() { m.releaseRunning() }
// acquireRunning atomically sets the running flag. Returns error if already running.
func (m *Manager) acquireRunning() error {
m.mu.Lock()
+432 -12
View File
@@ -77,6 +77,16 @@ func (m *Manager) SetOffboxSSH(fn func(ctx context.Context, host, user string, p
// the orphan card instead of the raw restic error.
var ErrOffboxOrphaned = fmt.Errorf("offbox repo orphaned: exists but keyed under a previous, no-longer-available passphrase")
// ErrOffboxRunInFlight is returned to the MANUAL caller only, when the single-flight dropped the
// request because a run was already going (R-234). It is not a failure of anything — the run in
// flight is doing the work — but it IS a request that did nothing, and the page must say so instead
// of showing the previous run's verdict under a „started" message.
// offboxWholeUnitGap is the pseudo-path used to report a WHOLE-unit gap through the mandatory-gap
// notification, so a skipped app and a skipped directory reach the operator in one vocabulary.
const offboxWholeUnitGap = "(a teljes alkalmazás — nincs helyi mentési egysége)"
var ErrOffboxRunInFlight = fmt.Errorf("an off-box backup is already running; this request did not start a new one")
// classifyResticProbe maps a `restic cat config` failure to a repo class. The signatures are the exact
// restic stderr matched in the 2026-07-17 diagnosis + restic's no-repo message:
// - "orphaned": repo present, wrong key ("wrong password or no key found") — the definitive signal
@@ -334,7 +344,21 @@ func (m *Manager) ResetOrphanedRepo(ctx context.Context) error {
}
t := m.settings.GetOffboxTarget()
base, env := m.offboxBaseArgs(t)
return m.resetOrphanedRepo(ctx, base, env, "operator-confirmed (claimed)")
if err := m.resetOrphanedRepo(ctx, base, env, "operator-confirmed (claimed)"); err != nil {
return err
}
// R-241: THE COUNTDOWN STARTS HERE AND NOT IN THE SHARED HELPER, deliberately. The helper is also
// the UNCLAIMED auto-reset path (Scenario B in ensureOffboxRepo), where nobody decided anything —
// an as-delivered box tidying a stranger's leftover store must not put a customer's 14-day
// deletion clock on it. Only the confirmed, claimed choice is a decision.
//
// The path is read back from OrphanedRenamedTo, which the helper has just written.
if cur := m.settings.GetOffboxTarget(); cur != nil && cur.OrphanedRenamedTo != "" {
m.startAbandonCountdown(cur.OrphanedRenamedTo)
} else {
m.logger.Printf("[WARN] [offbox] the reset succeeded but no set-aside path was recorded — no countdown started; the old history stays indefinitely")
}
return nil
}
// shellQuote single-quotes a path for the remote shell (our repo paths have no single quotes).
@@ -371,10 +395,46 @@ func (m *Manager) offboxKeyPath() string { return filepath.Join(m.offboxDir()
func (m *Manager) offboxPwPath() string { return filepath.Join(m.offboxDir(), "repo_password") }
func (m *Manager) offboxKnownHosts() string { return filepath.Join(m.offboxDir(), "known_hosts") }
// ErrOffboxSealedPackageHeld is the R-241 mint refusal: this box has no repository password and the
// hub is holding a sealed recovery package for it, so minting one would write a key the package does
// not cover — orphaning the very history the customer's recovery code protects.
//
// It is a SENTINEL, not a failure. `ApplyOffsiteTarget` catches it and still configures the transport
// (SSH key, known_hosts, host/user/path), because the transport is not the problem and having it is
// what lets the recovery screen bring the tier up the moment the key arrives (R-219). What it does
// NOT do is let the tier come up under a key nobody escrowed.
var ErrOffboxSealedPackageHeld = fmt.Errorf("offbox: the hub holds a sealed recovery package for this box — not minting a repository password over it")
// ErrOffboxSealedPackageHeld reports whether err is the mint refusal (errors.Is-friendly for callers
// that wrap it).
func IsOffboxSealedPackageHeld(err error) bool { return errors.Is(err, ErrOffboxSealedPackageHeld) }
// WriteOffboxSecrets persists the SSH private key + (auto-generated if empty) repo password + the pinned
// known-host line as 0600/0644 files in the data dir. The key is provided out-of-band by the operator
// (UI), never logged. Returns the repo password so the caller need not read the file. Idempotent: an empty
// sshKey/knownHosts leaves the existing file untouched (a re-save of just the target shouldn't wipe keys).
// (UI), never logged. Idempotent: an empty sshKey/knownHosts leaves the existing file untouched (a
// re-save of just the target shouldn't wipe keys).
//
// ⚠ R-241 (v0.206.0) — IT NO LONGER MINTS OVER A SEALED PACKAGE, AND THAT IS THE WHOLE FIX.
//
// Until now the auto-generate branch consulted **one** input: does the file exist. Not the settings,
// not the hub's ACK — nothing about whether anything already depended on a different key. Its two
// neighbours in this very file, `OffsiteRecoveryOffer` (:1412) and `needsOffsiteCredential` (:1377),
// BOTH consult `GetHubEscrowIdentityPresent()`. The same fact was available on three paths and used
// on two.
//
// WHAT THAT COST, measured on the final walk (2026-08-06/07, SPIKE-r241-recovery-offer-2026-08-07):
// a rebuilt box's credential self-heal reached here at 03:18:06Z and minted `9b4a9a9d…` over a hub
// package sealing `30ef574f…`. The recovery screen then looked, found a key present and no orphan
// recorded, and correctly said there was nothing to recover. **The screen was telling the truth; the
// lie happened thirty minutes earlier, here.** And the flag was not merely available at that moment —
// it was the PRECONDITION of the chain that reached this function: the credential retry only runs
// while `needsOffsiteCredential` is true, which requires this exact flag, and the venue logged it at
// 02:48:03Z, six ticks before the mint.
//
// THE GUARD IS DELIBERATELY NARROW — see the Scenario B test. It fires ONLY when a package is held AND
// no password exists. A box the hub holds nothing for mints exactly as before, which is every
// first-time box in the fleet; widening this to "never mint" would leave a new customer unable to
// start, waiting for a package that will never exist.
func (m *Manager) WriteOffboxSecrets(sshKey, knownHosts string) error {
if err := os.MkdirAll(m.offboxDir(), 0o700); err != nil {
return fmt.Errorf("offbox dir: %w", err)
@@ -397,8 +457,15 @@ func (m *Manager) WriteOffboxSecrets(sshKey, knownHosts string) error {
return fmt.Errorf("offbox known_hosts: %w", err)
}
}
// Auto-generate the repo password once (0600), never log it.
// Auto-generate the repo password once (0600), never log it — UNLESS the hub is holding a sealed
// package for us (R-241). The transport files above are already written and that is deliberate.
if _, err := os.Stat(m.offboxPwPath()); os.IsNotExist(err) {
if m.sealedPackageHeld() {
m.logger.Printf("[WARN] [offbox] NOT minting a repository password: the hub holds a sealed recovery package for this box, " +
"and a fresh key would orphan the history that package protects (R-241). The transport is configured; " +
"the tier stays down until the customer's recovery code places the escrowed key.")
return ErrOffboxSealedPackageHeld
}
pw, gerr := generateOffboxPassword()
if gerr != nil {
return gerr
@@ -410,6 +477,37 @@ func (m *Manager) WriteOffboxSecrets(sshKey, knownHosts string) error {
return nil
}
// sealedPackageHeld reports the ACK-cached fact that the hub is holding a sealed recovery package for
// this box. It is the SAME call `OffsiteRecoveryOffer` and `needsOffsiteCredential` already make —
// deliberately, so the three paths can never disagree about it. A nil settings store reads as "no
// package": the mint guard must never block a box whose settings could not be read, because that
// would turn a transient read failure into a tier that never comes up.
func (m *Manager) sealedPackageHeld() bool {
return m.settings != nil && m.settings.GetHubEscrowIdentityPresent()
}
// OffboxAwaitingRecoveryKey reports the R-241 holding state: a transport target exists, but no
// repository password does, because the hub holds a sealed package and the mint was refused.
//
// DERIVED, NOT STORED, and that is the §2.1 ruling applied to this field too: a stored flag would be a
// second copy of a fact the three inputs already carry, and a second copy is a thing that can drift.
// The moment a recovery places the escrowed key, this goes false on its own with nothing to clear.
//
// ⚠ `t.Enabled` IS LOAD-BEARING, and it was missing in the first draft — caught by the existing
// TestOffsiteDeclare_DisabledTargetIsNotStranded rather than by review. A customer who switched
// off-site OFF is not awaiting anything, and a box that declares a holding state for a tier nobody
// asked for is the R-215 shape (a screen about data the customer did not ask to protect). This is the
// SAME Scenario-E carve-out `needsOffsiteCredential` makes two functions above; the two must agree,
// and now do.
func (m *Manager) OffboxAwaitingRecoveryKey() bool {
t := m.settings.GetOffboxTarget()
if t == nil || !t.Enabled || !m.sealedPackageHeld() {
return false
}
_, hasPw := m.OffboxRepoPasswordHash()
return !hasPw
}
// generateOffboxPassword returns a 256-bit hex repo password.
func generateOffboxPassword() (string, error) {
b := make([]byte, 32)
@@ -479,9 +577,20 @@ var offboxRepoPwPattern = regexp.MustCompile(`^[0-9a-fA-F]{64}$`)
// the repo password to the agent for escrow — the SAME fork-4 enable path a manual config takes. `stage` is
// the agent escrow-stage push (nil skips it, e.g. when the agent is unreachable — the run gate still holds).
func (m *Manager) ApplyOffsiteTarget(ctx context.Context, tgt *settings.OffboxTarget, sshKeyPEM, knownHosts string, stage func(ctx context.Context, pw string) error) error {
// R-241: the mint refusal is a HOLDING state, not a failure. The transport files were written
// before the refusal, so we still record the target — `OffboxConfigured()` stays false because the
// password file is absent, which is what keeps runs gated, and the recovery screen can bring the
// tier up the instant the escrowed key is placed (R-219's synchronous tier-up).
//
// Returning the error here instead would leave `needsOffsiteCredential` true forever, so the hub
// would re-stage a credential the box had already consumed, on every cycle, for ever.
awaitingKey := false
if err := m.WriteOffboxSecrets(sshKeyPEM, knownHosts); err != nil {
if !IsOffboxSealedPackageHeld(err) {
return fmt.Errorf("apply offsite secrets: %w", err)
}
awaitingKey = true
}
// Re-apply (v0.109.1 live finding): the bridge rebuilds the target from the descriptor, but the
// EXISTING target's custody + runtime status must carry over — EscrowState tracks the REPO PASSWORD
// (preserved by WriteOffboxSecrets above, never rotated by this path), not the target coords; and the
@@ -496,6 +605,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"
@@ -503,6 +613,14 @@ func (m *Manager) ApplyOffsiteTarget(ctx context.Context, tgt *settings.OffboxTa
if err := m.settings.SetOffboxTarget(tgt); err != nil {
return fmt.Errorf("apply offsite target: %w", err)
}
// R-241: nothing to stage — there is no repository password, by design. Say so once, plainly, and
// return without touching the escrow. `PushOffboxPasswordForEscrow` would fail on the absent file
// anyway; naming the situation beats a misleading "agent unreachable?" warning.
if awaitingKey {
m.logger.Printf("[INFO] [offbox] apply-offsite: transport configured for %s@%s:%s, tier HELD awaiting the escrowed key "+
"(the hub holds a sealed package; no key was minted — R-241)", tgt.User, tgt.Host, tgt.RepoPath)
return nil
}
if stage != nil {
// Best-effort: the offbox is configured + pending regardless. A stage-push failure (agent momentarily
// unreachable) is logged, not fatal — the escrow can be (re-)staged later (operator ceremony / re-enable).
@@ -745,7 +863,19 @@ func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error
}
if err := m.acquireRunning(); err != nil {
m.logger.Printf("[INFO] [offbox] skipped — another backup is running")
return nil // single-flight: don't race; the next scheduled run retries
// R-234 (the MEASURED cause). The nightly path is unchanged: returning nil is right for it —
// nobody asked, and the next scheduled run retries.
//
// The MANUAL path is a different question, and answering it the same way is what produced the
// 2026-08-06 sequence. The customer pressed „Távoli mentés most" and was told
// „A távoli mentés elindult"; the run was dropped here and returned nil; the card then showed
// the PREVIOUS run's „✓ Rendben", which they read as covering the app they had just selected.
// It did not — the restore refused for that app minutes later. A request that did nothing must
// not be reported as one that started, so the manual caller is told.
if withProgress {
return ErrOffboxRunInFlight
}
return nil
}
defer m.releaseRunning()
@@ -873,16 +1003,45 @@ func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error
// backup is not no backup, and reporting it as none would be its own lie. `incomplete` is
// minted here because the existing vocabulary ("ok" | "error" | "running") has nothing that
// means "it ran, and this app is not fully protected".
if len(runResult.mandatoryGaps) > 0 {
// R-234 EXTENDS THE SAME RULE TO THE BIGGER CASE. Until v0.205.0 the paragraph above was
// applied to ONE of the two shapes it describes: an app missing a declared mandatory
// FOLDER made the run incomplete, while an app skipped ENTIRELY — no recovery unit, so
// nothing of it in the snapshot at all — still reported ok with a warning beside it. The
// smaller gap moved the verdict and the bigger one did not. Measured 2026-08-06: a run
// reported „✓ Rendben · 1 pillanatkép" and the restore then refused for the app the
// customer had just selected.
gaps := len(runResult.mandatoryGaps) > 0
unprotected := len(runResult.missingUnprotected) > 0
if gaps || unprotected {
o.LastStatus = "incomplete"
if m.offboxGapNotify != nil {
m.offboxGapNotify(runResult.mandatoryGaps)
// Reuse, not mirror: the operator signal for "this run left an app less protected
// than the customer asked for" is the same signal. A skipped app is reported as a
// whole-unit gap so one notification shape covers both, and the recipient does not
// have to learn a second vocabulary for the worse case.
notify := runResult.mandatoryGaps
if unprotected {
if notify == nil {
notify = map[string][]string{}
} else {
cp := make(map[string][]string, len(notify)+len(runResult.missingUnprotected))
for k, v := range notify {
cp[k] = v
}
notify = cp
}
for _, a := range runResult.missingUnprotected {
notify[a] = append(notify[a], offboxWholeUnitGap)
}
}
m.offboxGapNotify(notify)
}
} else {
o.LastStatus = "ok"
}
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
@@ -893,9 +1052,18 @@ func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error
if len(apps) == 0 && !runResult.sharesBackedUp {
warns = append(warns, "Sikeres — nincs mentésre jelölt alkalmazás")
}
if len(missing) > 0 {
warns = append(warns, fmt.Sprintf("Figyelmeztetés: %d alkalmazásnak nincs elérhető mentése, ezek kimaradtak: %s",
len(missing), strings.Join(missing, ", ")))
// R-234 §7.4 — WHICH apps, WHY, and WHEN. The old sentence said only that N apps "had no
// available backup and were left out", which names a problem with no next step and reads
// the same whether the customer must act or simply wait.
if len(runResult.missingUnprotected) > 0 {
warns = append(warns, fmt.Sprintf(
"Ezek az alkalmazások NEM kerültek be a távoli mentésbe, mert még nincs helyi mentési egységük: %s. A következő mentés általában már elkészíti — ha a második futás után is itt szerepelnek, szólj az üzemeltetőnek.",
strings.Join(runResult.missingUnprotected, ", ")))
}
if len(runResult.missingNotDeployed) > 0 {
warns = append(warns, fmt.Sprintf(
"Ezek az alkalmazások ki vannak jelölve távoli mentésre, de nincsenek telepítve, ezért nem menthetők: %s. Ha már nincs rájuk szükséged, vedd ki a kijelölésüket a Távoli mentés oldalon.",
strings.Join(runResult.missingNotDeployed, ", ")))
}
// 3a: capture-gap warnings (structurally-refused / on-disk-missing mandatory paths, undeployed).
warns = append(warns, runResult.warns...)
@@ -1057,6 +1225,39 @@ type offboxRunResult struct {
// that could NOT be captured. It is the STRUCTURED form of the warnings above, and it is what
// decides the run's verdict: a run that dropped a mandatory directory is not a successful run.
mandatoryGaps map[string][]string
// missingUnprotected / missingNotDeployed (R-234) split `missing` by WHY, because only one of the
// two may move the verdict. See the classification comment at the skip site: an app the customer
// selected and that IS deployed but has no unit is unprotected and counts; an app that is no
// longer installed is named but does not, so a removed app cannot leave the box amber forever.
missingUnprotected []string
missingNotDeployed []string
}
// stackDeployed reports whether the stack is currently deployed on this box. Used only to classify a
// skip (R-234) — never to decide whether to back something up.
func (m *Manager) stackDeployed(stack string) bool {
if m.stackProvider == nil {
return false
}
for _, st := range m.stackProvider.ListDeployedStacks() {
if st.Name == stack {
return true
}
}
return false
}
// driveUnavailableFor reports whether the app's drive is disconnected or decommissioned — states that
// already have their own customer-facing signal, so a skip caused by them is not re-reported here.
func (m *Manager) driveUnavailableFor(stack string) bool {
if m.settings == nil {
return false
}
d := m.GetAppDrivePath(stack)
if d == "" {
return false
}
return m.settings.IsDisconnected(d) || m.settings.IsDecommissioned(d)
}
// runOffboxInternal does the repo-ensure + per-app DISCOVER → capture-set → gate → multi-path backup +
@@ -1077,6 +1278,28 @@ func (m *Manager) runOffboxInternal(ctx context.Context, apps, base, env []strin
if !ok {
m.logger.Printf("[WARN] [offbox] %s: no recovery unit found on any connected drive — skipping", stack)
res.missing = append(res.missing, stack)
// R-234 §7.2 — WHICH skips make the run not-successful. The list above is prose for the
// customer; this classification is what the VERDICT may consult, and the two are not the
// same question. Established by measurement on demo-hp 2026-08-06, not assumed:
//
// * DEPLOYED, no unit — the run's own pre-dump phase (captureAllRecoveryUnits) writes a
// unit for every deployed stack before the push, so this state does not normally
// survive a run. Reaching here means the capture was refused (the reserve) or failed.
// The app the customer selected is NOT protected: it COUNTS.
// * NOT DEPLOYED — nothing can protect an app that is not there, and the remedy is to
// deselect it. It is NAMED so the customer can act, but it does NOT count: a box left
// permanently amber over an app somebody removed is a status that stops being read,
// which is how this whole class of defect starts.
// * drive disconnected/decommissioned — has its own signal and its own card; not ours to
// re-report as a backup gap.
switch {
case !m.stackDeployed(stack):
res.missingNotDeployed = append(res.missingNotDeployed, stack)
case m.driveUnavailableFor(stack):
// counted as neither: the drive card is the honest surface for this one.
default:
res.missingUnprotected = append(res.missingUnprotected, stack)
}
continue
}
// Task 3-core TierOffsite capture set: mandatory userdata paths added to the unit snapshot,
@@ -1188,6 +1411,24 @@ func offboxAnchorAfterRun(prev, at string, runErr error) string {
type OffboxReportStatus struct {
Enabled bool `json:"enabled"`
EscrowState string `json:"escrow_state"`
// State (v0.199.0, R-204 item 4 / R-193) is a DECLARED condition — the box naming its own
// situation rather than the hub deducing it from a silence. Empty on every configured box, so a
// healthy report's JSON is byte-identical to v0.198.0's.
//
// WHY A DECLARATION AND NOT AN INFERENCE (the operator ruling, 2026-08-05). An ABSENT off-site
// object has FOUR meanings — never configured, mid-restart, a transient config read failure, and
// rebuilt-and-stranded — and the hub cannot tell them apart. The BOX can, from two local facts it
// holds with certainty. So it says so.
State string `json:"state,omitempty"`
// AbandonPurgeRequested (v0.206.0, R-241) — the customer's abandonment countdown has run out, the
// set-aside off-site history HAS been deleted, and the hub is asked to drop the sealed package
// that protected it so the two halves go together (Scenario F).
//
// It is a DECLARATION, on the same principle as State: the box knows it has deleted the store; the
// hub cannot see that and must not infer it. It keeps being sent until the ACK stops reporting a
// superseded package, so a lost request retries by itself rather than leaving the pair half-gone.
// Absent/false on every other box, so a healthy report is byte-identical to v0.205.0's.
AbandonPurgeRequested bool `json:"abandon_purge_requested,omitempty"`
LastRun string `json:"last_run,omitempty"` // RFC3339
LastStatus string `json:"last_status,omitempty"` // "ok" | "incomplete" (R-203) | "error" | "running"
// LastSuccess (R-100) is the last run that SUCCEEDED — the hub's staleness anchor. Absent on a
@@ -1199,17 +1440,195 @@ type OffboxReportStatus struct {
QuotaGB int `json:"quota_gb"`
}
// OffboxReportStatus returns the offsite summary for the hub report (nil = not configured; the hub's
// checker treats absence as "nothing to watch" — pre-v0.109 reports look the same).
// OffsiteStateNeedsCredential is the ONE declared state (v0.199.0, R-204 item 4 / R-193): this box
// has no off-site tier, holds no repository password, and the hub says it is keeping a sealed
// recovery package for it — i.e. it is a REBUILT box whose predecessor spent the one-time provider
// password, and it cannot configure its off-site tier without a credential it has no way to obtain.
// That was the last of the four manual interventions the 2026-08-04 drill needed.
const OffsiteStateNeedsCredential = "needs_credential"
// OffsiteStateAwaitingRecoveryKey (v0.206.0, R-241) is the declared HOLDING state: the transport is
// configured, but no repository password exists because the hub holds a sealed package and minting
// one would orphan the history it protects. The box is not stranded (it has its credential) and not
// healthy (it cannot run) — it is waiting for a person with a recovery code.
//
// WHY IT IS INERT TO EVERY EXISTING HUB READER, established from their code rather than assumed —
// the same discipline `OffsiteStateNeedsCredential`'s own note applies:
//
// - `offsiteheal` acts on EXACTLY ONE string, `needs_credential` ("Everything else … is a no-op"),
// so it will not re-stage a credential this box already has;
// - `monitor.OffsiteChecker.isStale` returns false unless `Enabled && EscrowState == "escrowed"`,
// and this object carries Enabled=false;
// - `monitor/offsite_delivery.go` keys on the delivery shape, which is `applied` here (the secret
// WAS consumed), and that branch is skipped;
// - an unknown `state` string is ignored by encoding/json on an older hub.
//
// So this needs NO hub change to be safe. It does mean a held box raises no alarm — which is R-243,
// filed and deliberately not widened here; the difference from R-241 is that this state is now
// VISIBLE to the customer instead of silent.
const OffsiteStateAwaitingRecoveryKey = "awaiting_recovery_key"
// needsOffsiteCredential is the stranded-rebuild predicate. BOTH facts are required and neither is
// sufficient on its own — this is the whole correctness of the feature:
//
// - the data area is FRESH (no repository password on disk). Alone, this is simply a box that never
// had off-site backups, and declaring on it would make every un-configured box in the fleet ask
// for a credential.
// - the HUB holds a sealed recovery package (the ACK's identity_blob_present, cached in settings).
// Alone, this is a healthy box that has run its ceremony.
//
// Only together do they mean "this box HAD an off-site tier, and no longer has what it needs to use
// it". A target that exists but is DISABLED is not stranded either — the customer switched it off —
// so the caller only consults this when there is no enabled target, and a non-nil disabled target
// short-circuits to false here.
//
// ⚠ THE DECLARATION STOPS WHEN THE TIER WORKS, NOT WHEN A KEY EXISTS (R-218, v0.201.0).
//
// This used to carry a third condition: hold a repository password ⇒ not stranded, stop declaring.
// It reads as a sound freshness test and it is the exact opposite on the one path that matters,
// because installing a repository password is the RECOVERY SCREEN'S WHOLE JOB. Measured live on
// 2026-08-05 (CAMPAIGN-11 Phase 1):
//
// 13:39:54 needs_credential the box asks
// 13:42:43 needs_credential the hub's 2-report debounce is satisfied
// 13:47:03 hub re-stages the credential — "the box re-consumes on its next cycle"
// 13:47:35 the customer's unlock succeeds and places the recovered key
// 13:53:42 (silence) …and never asks again
//
// Thirty-two seconds after the remedy fired, the customer's own success switched off the mechanism
// that would have delivered the coordinates for the key they had just recovered. The hub held an
// unconsumed credential the box had no reason to collect; the box held a correct key and nowhere to
// use it; the screen said "a few minutes"; nothing was ever going to happen. Two features, each
// correct alone, cancelling on the path both were built for.
//
// The two remaining conditions are the honest ones: no target at all, and the hub is keeping a sealed
// package for us. Both stay true exactly until the tier is configured — which is the moment the box
// genuinely no longer needs a credential — and `OffboxReportStatus` stops consulting this predicate
// the instant a target exists. **Scenario E is unaffected and is pinned by its own test**: a disabled
// target is non-nil and still short-circuits at the first line, so a box whose customer switched
// off-site off stays silent.
func (m *Manager) needsOffsiteCredential(t *settings.OffboxTarget) bool {
if t != nil {
return false // a target exists (merely disabled) — the customer's own choice, not a rebuild
}
if m.settings == nil || !m.settings.GetHubEscrowIdentityPresent() {
return false // the hub holds nothing for us: never had off-site backups
}
return true
}
// OffsiteRecoveryOffer reports whether the customer should be OFFERED the recovery screen (R-193):
// the hub is holding a sealed recovery package for this box, and this box cannot open what that
// package protects.
//
// TWO FACTS, BOTH REQUIRED — and this is the whole correctness of the screen:
//
// 1. **the hub holds a sealed package** (the ACK's identity_blob_present, cached in settings). Without
// it there is nothing to recover, and a box that never had off-site backups must never be greeted
// by a recovery screen for data it never had. Dropping this condition is the plausible wrong fix.
// 2. **this box cannot open the history that package protects** — see the two shapes below.
//
// ⚠ WHY SHAPE (b) EXISTS, recorded because the task specified only shape (a) and the difference is
// load-bearing. The literal reading of "the data area is fresh" is *no repository password on disk*,
// which is true of a rebuilt box — but only until it re-applies its off-site target, because
// `WriteOffboxSecrets` AUTO-GENERATES a repository password when none is present. That is precisely
// R-193's orphaning mechanism, and since hub v0.96.0's credential self-heal the re-apply happens by
// itself within ~1530 minutes. So shape (a) alone would make this screen appear only inside a
// half-hour window that closes on its own, and the customer who logs in the next morning — the actual
// customer — would never see it. Shape (b) is the state they are in: a repository password exists, but
// it is a NEW one and the inherited history cannot be opened with it, which the box has already
// measured and recorded as `RepoState == "orphaned"`.
//
// Shape (b) also happens to be the state the existing move-aside requires (`ResetOrphanedRepo` refuses
// unless orphaned), which is what lets "I do not want the old data" reach the shipped handler rather
// than needing a new one.
//
// Scenario B still holds exactly: a healthy box has its own password and is not orphaned; a box that
// never had off-site backups fails fact 1; an unclaimed box never reaches an authenticated page.
// ── SHAPE (c), v0.206.0, R-241 — THE DISCRIMINATOR THAT ANSWERS THE REAL QUESTION ───────────────
//
// Shapes (a) and (b) are both PROXIES for one question — *does the hub hold a package for a key other
// than the one I am using?* — and both have now been wrong, in opposite directions:
//
// - (a) "no repository password" went false the moment anything minted one. Before v0.206.0's mint
// guard that happened by itself, ~30 minutes after a rebuild, and the customer who logged in the
// next morning never saw the screen. That is R-241.
// - (b) "a run proved the repo will not open" is unreachable on exactly that box: the only producer
// of RepoState=="orphaned" is ensureOffboxRepo, which is downstream of the escrow gate in
// runOffboxBackup, and the escrow can never confirm while the hub's package covers a different
// key. Self-locking.
//
// (c) asks the question directly, from two facts the box already holds: the hash the hub's package
// covers (ACK-cached) and the hash of the key on disk. **This comparison was already computed on every
// ACK and thrown away** — see settings.HubEscrowKeySHA256.
//
// ⚠ §7.2 — WHAT A STALE OR ABSENT READING RESOLVES TO, decided deliberately rather than by default:
//
// - **A KNOWN DIFFERENCE OFFERS, however old the reading.** Age is not gated on. Both sides of the
// comparison are local; only the hub's half can be stale, and what the hub holds does not change
// without a ceremony THIS BOX runs — which refreshes the hash on the next ACK. Gating on age would
// add a second failure mode (a box offline from the hub silently stops offering) to fix a window
// that closes itself. `HubEscrowKeyCheckedAt` is persisted for diagnosis, not as a gate.
// - **AN ABSENT HASH FALLS BACK TO (a)/(b), it does not offer.** "" is what the hub sends for a
// legacy hash-less package — one that provably seals no repository password. There is nothing for
// (c) to compare, and offering on it would put a permanent screen in front of every legacy box.
// This is the one place where "not knowing" resolves to silence, and it does so because an empty
// hash is not an unknown: it is the hub positively saying the package covers no key.
//
// So: fail-closed (offer) on a known difference; fall back on a hash never learned. Pinned by
// TestR241_ScenarioD_* and TestR241_StaleComparison_*.
func (m *Manager) OffsiteRecoveryOffer() bool {
if m.settings == nil || !m.settings.GetHubEscrowIdentityPresent() {
return false // the hub holds nothing for us — nothing to recover
}
localHash, hasLocal := m.OffboxRepoPasswordHash()
if !hasLocal {
return true // (a) no repository password at all — the pristine rebuilt box
}
if hubHash, _ := m.settings.GetHubEscrowKeySHA256(); hubHash != "" && hubHash != localHash {
return true // (c) the hub's package covers a DIFFERENT key than the one we are using
}
return m.OffboxOrphaned() // (b) a password exists but the inherited history will not open under it
}
// OffboxReportStatus returns the offsite summary for the hub report.
//
// nil = nothing to say (not configured, and nothing to ask for) — the hub's checker treats absence as
// "nothing to watch"; pre-v0.109 reports look the same.
//
// v0.199.0: there is now ONE case where an UNCONFIGURED box still reports an object — the stranded
// rebuild, which DECLARES OffsiteStateNeedsCredential rather than leaving the hub to deduce it from a
// silence. An absent object has FOUR meanings (never configured / mid-restart / a transient config
// read failure / rebuilt-and-stranded) and the hub cannot tell them apart; the box can.
//
// WHY THE DECLARATION IS INERT TO EVERY EXISTING READER, established from their code rather than
// assumed: it carries Enabled=false and zero quota/size, and the hub's OffsiteChecker gates
// `isStale` on `!off.Enabled` (returns false) and `fillBand` on a zero quota/size (returns OK). So it
// raises no staleness and no fill alarm on a new hub OR an old one, and an unknown `state` string is
// ignored by encoding/json. The ONE reader that would have misread it is the store's
// `reportHasOffsite` ("presence == applied-on-the-box"), which hub v0.96.0 tightens to require
// enabled=true — provably a no-op for every report shape that exists today, because this function has
// never emitted a disabled object before.
func (m *Manager) OffboxReportStatus() *OffboxReportStatus {
t := m.settings.GetOffboxTarget()
// R-241: the HOLDING state is declared before the enabled/disabled split, because a held target IS
// enabled — the customer wants off-site backups; what is missing is the key. Reported with
// Enabled=false so every existing hub reader treats it exactly as the stranded declaration (see
// OffsiteStateAwaitingRecoveryKey), while the string names the difference for anything that looks.
if m.OffboxAwaitingRecoveryKey() {
return &OffboxReportStatus{Enabled: false, State: OffsiteStateAwaitingRecoveryKey, EscrowState: t.EscrowState}
}
if t == nil || !t.Enabled {
if m.needsOffsiteCredential(t) {
return &OffboxReportStatus{Enabled: false, State: OffsiteStateNeedsCredential}
}
return nil
}
return &OffboxReportStatus{
Enabled: true, EscrowState: t.EscrowState, LastRun: t.LastRun, LastStatus: t.LastStatus,
LastSuccess: t.LastSuccess,
SnapshotCount: t.SnapshotCount, RepoSizeBytes: t.RepoSizeBytes, QuotaGB: t.QuotaGB,
AbandonPurgeRequested: t.AbandonPurgeRequested, // R-241: declared until the hub drops the package
}
}
@@ -1297,6 +1716,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
})
}
}
+14 -1
View File
@@ -23,10 +23,23 @@ type offbox3aProvider struct {
hdd map[string]string
binds map[string][]ClassifiedBind
has map[string]bool
// deployed is OPT-IN and defaults to nil, so every existing fixture keeps ListDeployedStacks()
// returning nil and nothing about their behaviour moves. R-234's classification is the only
// thing that needs a real deployed set.
deployed map[string]bool
}
func (p *offbox3aProvider) GetStackComposePath(string) (string, bool) { return "", false }
func (p *offbox3aProvider) ListDeployedStacks() []StackSummary { return nil }
func (p *offbox3aProvider) ListDeployedStacks() []StackSummary {
if len(p.deployed) == 0 {
return nil
}
out := make([]StackSummary, 0, len(p.deployed))
for n := range p.deployed {
out = append(out, StackSummary{Name: n})
}
return out
}
func (p *offbox3aProvider) GetStackHDDMounts(string) []string { return nil }
func (p *offbox3aProvider) GetStackHDDPath(n string) string { return p.hdd[n] }
func (p *offbox3aProvider) GetImportRoot() string { return "" } // R-75: no import binds in this fixture
@@ -0,0 +1,299 @@
package backup
import (
"context"
"fmt"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// ABANDONMENT — deciding to give up the old off-site history is a finishable thing (R-241, v0.206.0).
//
// THE PROBLEM THIS SOLVES. `resetOrphanedRepo` renamed the remote store aside and touched neither the
// escrow nor the key, so the hub went on holding a sealed package for a key the box no longer used.
// Shape (c) compares those two, finds them different, and offers recovery — correctly, and for ever.
// A customer who has already said "I do not want the old data" would be asked again at every login.
//
// THE OPERATOR'S RULING (2026-08-07) is that the answer is NOT a "they decided" flag. A flag would
// leave the box in a state that is genuinely wrong (the hub holding a package for a key nobody uses)
// and paper over it. Instead the decision starts a **14-day countdown**, at the end of which the
// set-aside store and the sealed package that protects it are removed TOGETHER — after which there is
// nothing left to compare and nothing left to ask about. **Fix the state, do not remember that it is
// wrong.**
//
// THE GRACE IS REAL, NOT DECORATIVE. The recovery offer stays reachable for the whole window; that is
// the change-of-mind path (Scenario G). A grace period during which recovery is impossible would be
// theatre.
// abandonGraceDays is the countdown the operator set. Reminders fire at 5, 3 and 1 days (see
// AbandonRemindAtDays) — visible, reversible, and running out in public.
const abandonGraceDays = 14
// AbandonGraceDays is the exported grace, for the customer-facing copy. The confirmation screen must
// state the SAME number the countdown uses — a literal typed into prose is how a promise drifts away
// from the code that keeps it.
const AbandonGraceDays = abandonGraceDays
// AbandonRemindAtDays are the remaining-day marks at which the abandoning box reminds the customer.
// Descending, so the surface can pick the first one that has been reached.
var AbandonRemindAtDays = []int{5, 3, 1}
// abandonNow is the countdown's clock seam. Tests inject; nil → time.Now. It exists so the terminal
// step can be driven deterministically — §7.4 forbids shortening a live timer to watch it fire,
// because that is how an irreversible step gets tested once and regretted once.
func (m *Manager) abandonNow() time.Time {
if m.offboxNow != nil {
return m.offboxNow()
}
return time.Now()
}
// SetOffboxClock injects the abandonment clock (tests only).
func (m *Manager) SetOffboxClock(fn func() time.Time) { m.offboxNow = fn }
// startAbandonCountdown records the decision and the date the terminal step will run. Called by
// resetOrphanedRepo AFTER the move-aside has succeeded — a countdown started before the store has
// actually moved would count down to deleting a path that does not exist.
func (m *Manager) startAbandonCountdown(setAsidePath string) {
now := m.abandonNow().UTC()
due := now.AddDate(0, 0, abandonGraceDays)
// R-302: pin the hub's escrow key fingerprint HERE, at the decision — the one moment it is a fact
// rather than something inferred later from an adjacent value. From now on the banner asks exactly
// one question, "is the hub still holding that same package?", instead of guessing which key is
// which. Written once and never refreshed: a field re-read at render answers a different question
// and would silently restore the defect this replaces.
pinned := ""
if m.settings != nil {
pinned, _ = m.settings.GetHubEscrowKeySHA256()
}
if err := m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
o.AbandonStartedAt = now.Format(time.RFC3339)
o.AbandonAt = due.Format(time.RFC3339)
o.AbandonRepoPath = setAsidePath
o.AbandonPurgeRequested = false
o.AbandonPinnedEscrowKeySHA256 = pinned
}); err != nil {
m.logger.Printf("[WARN] [offbox] could not record the abandonment countdown: %v", err)
return
}
m.logger.Printf("[INFO] [offbox] abandonment countdown started: the set-aside history at %s and the hub's sealed package "+
"are removed together on %s (%d days). The recovery screen stays reachable until then.",
setAsidePath, due.Format("2006-01-02"), abandonGraceDays)
}
// AbandonState is the surface's read model. Zero value = nothing in progress.
type AbandonState struct {
Active bool // a countdown is running
StartedAt time.Time //
DueAt time.Time // when the terminal step runs
DaysLeft int // ceiling, so "0 days left" only ever means "today"
RepoPath string // the set-aside store awaiting deletion
PurgeRequested bool // the store is gone; awaiting the hub to drop the sealed package
// RetrievalStillOffered (R-302) — may the banner still say the set-aside copies can be retrieved
// with the recovery code? TRUE only while the hub is holding the SAME sealed package it held when
// the customer decided. Derived here, once, so the banner and anything else asking cannot disagree.
//
// FALSE covers: the package was replaced after the decision (a fresh escrow ceremony — the act that
// cost both demo boxes their history); the hub reports an empty hash (a legacy package sealing no
// repository password); and a countdown started before R-302, which carries no pin. All three are
// "we cannot see that this is still true", and all three must read as such rather than as a promise.
RetrievalStillOffered bool
}
// AbandonStatus reports the countdown for the UI and the report. It never mutates.
func (m *Manager) AbandonStatus() AbandonState {
t := m.settings.GetOffboxTarget()
if t == nil {
return AbandonState{}
}
st := AbandonState{RepoPath: t.AbandonRepoPath, PurgeRequested: t.AbandonPurgeRequested}
if t.AbandonAt == "" {
return st
}
due, err := time.Parse(time.RFC3339, t.AbandonAt)
if err != nil {
// A malformed stamp must not silently mean "never due" — that would strand the store for ever
// with a countdown the customer can see and nothing behind it.
m.logger.Printf("[WARN] [offbox] abandonment due-date is unparseable (%q) — treating the countdown as NOT running: %v", t.AbandonAt, err)
return st
}
st.Active, st.DueAt = true, due
if s, serr := time.Parse(time.RFC3339, t.AbandonStartedAt); serr == nil {
st.StartedAt = s
}
// R-302: the pinned fingerprint vs what the hub reports NOW. Both must be non-empty and equal.
// Empty on either side is "we could not see", never "they match" — the settings comment on
// HubEscrowKeySHA256 establishes that the hub sends "" for a package sealing no repo password.
if cur, _ := m.settings.GetHubEscrowKeySHA256(); cur != "" &&
t.AbandonPinnedEscrowKeySHA256 != "" && cur == t.AbandonPinnedEscrowKeySHA256 {
st.RetrievalStillOffered = true
}
// Ceiling: a countdown with 30 minutes left says "1 day", never "0". Zero is reserved for due.
remaining := due.Sub(m.abandonNow())
if remaining <= 0 {
st.DaysLeft = 0
} else {
st.DaysLeft = int((remaining + 24*time.Hour - time.Nanosecond) / (24 * time.Hour))
}
return st
}
// CancelAbandon stops a running countdown — the change-of-mind path (Scenario G). Called when a
// recovery succeeds: the customer has their code after all, and the history they were about to give
// up is exactly what the code opens.
//
// It clears the schedule but KEEPS AbandonRepoPath, so the set-aside store remains nameable on the
// backups page. Nothing has been deleted at this point by construction — the terminal step is the
// only thing that deletes, and it has not run.
func (m *Manager) CancelAbandon(reason string) {
t := m.settings.GetOffboxTarget()
if t == nil || (t.AbandonAt == "" && !t.AbandonPurgeRequested) {
return // nothing running — silent, so a healthy recovery does not log about a countdown
}
if err := m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
o.AbandonStartedAt, o.AbandonAt = "", ""
o.AbandonPurgeRequested = false
}); err != nil {
m.logger.Printf("[WARN] [offbox] could not cancel the abandonment countdown: %v", err)
return
}
m.logger.Printf("[INFO] [offbox] abandonment countdown CANCELLED (%s) — the set-aside history at %s is kept and nothing was deleted", reason, t.AbandonRepoPath)
}
// AbandonSweep is the daily terminal step. It is the ONLY thing in the product that deletes a
// customer's off-site history, and it does so on a date the customer was shown.
//
// ⚠ IT REMOVES BOTH HALVES OR NEITHER — Scenario F. The set-aside store and the sealed package that
// protects it are the two halves of one thing; removing only the store leaves the hub holding a
// package for a key that opens nothing, and removing only the package leaves ciphertext nobody can
// ever decrypt. Either is a state that asks a question nobody can answer.
//
// The two halves cannot be made atomic across two machines, so this is a two-phase commit with the
// STORE FIRST and a durable marker: delete the remote store, record AbandonPurgeRequested, and keep
// declaring it in the report until the hub's ACK stops reporting a superseded package. A crash
// between the two leaves the marker set and the next sweep re-declares — it never leaves the pair
// half-removed and silent.
//
// Returns (deleted, err). deleted=false with err=nil is the normal "nothing due" case.
func (m *Manager) AbandonSweep(ctx context.Context) (bool, error) {
st := m.AbandonStatus()
// Phase 2 outstanding: the store is gone, the hub has not confirmed. Re-declare and wait.
if st.PurgeRequested {
m.logger.Printf("[DEBUG] [offbox] abandonment: the set-aside store is deleted; awaiting the hub to drop the sealed package")
return false, nil
}
if !st.Active || st.DueAt.After(m.abandonNow()) {
return false, nil // not due — quiet by construction on every healthy box
}
t := m.settings.GetOffboxTarget()
if t == nil || t.AbandonRepoPath == "" {
m.logger.Printf("[WARN] [offbox] abandonment is due but no set-aside path is recorded — nothing deleted; clearing the countdown so it does not retry for ever")
m.CancelAbandon("no set-aside path recorded")
return false, fmt.Errorf("abandonment due with no recorded path")
}
port := t.Port
if port == 0 {
port = 22
}
m.logger.Printf("[WARN] [offbox] abandonment DUE — deleting the set-aside off-site history at %s (chosen by the customer on %s; this is irreversible)",
t.AbandonRepoPath, st.StartedAt.Format("2006-01-02"))
out, err := m.sshRunner()(ctx, t.Host, t.User, port, m.offboxKeyPath(), m.offboxKnownHosts(),
"rm -rf "+shellQuote(t.AbandonRepoPath))
if err != nil {
// NOT cleared: a transport failure must retry tomorrow, not silently abandon the abandonment.
m.logger.Printf("[ERROR] [offbox] abandonment: deleting the set-aside history failed — the countdown stays due and retries: %v: %s", err, truncate(out))
return false, fmt.Errorf("delete set-aside history: %w", err)
}
// Phase 1 done. Record it durably BEFORE anything else, so a crash here re-declares rather than
// forgetting that the store is already gone.
if uerr := m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
o.AbandonPurgeRequested = true
o.AbandonAt = "" // the schedule has fired; the marker now drives the rest
}); uerr != nil {
m.logger.Printf("[ERROR] [offbox] abandonment: the store was deleted but the marker could not be saved — the hub's package may outlive it: %v", uerr)
return true, uerr
}
m.logger.Printf("[INFO] [offbox] abandonment: set-aside history deleted; requesting the hub to drop the sealed package that protected it")
if m.offboxOrphanEvent != nil {
m.offboxOrphanEvent("offbox_abandon_completed", t.AbandonRepoPath)
}
return true, nil
}
// ClearAbandonPurgeIfConfirmed closes the two-phase commit: once the hub's ACK stops reporting a
// superseded package, both halves are gone and the abandonment is finished. Called from the ACK path.
//
// This is what makes §2.1 work without a "they decided" flag: afterwards the hub holds a package for
// the key the box is actually using (or none at all), shape (c) has nothing to compare, and the
// recovery offer falls silent on its own — because the state is right, not because something is
// remembering that it once was not.
func (m *Manager) ClearAbandonPurgeIfConfirmed(supersededPresent bool) {
t := m.settings.GetOffboxTarget()
if t == nil || !t.AbandonPurgeRequested || supersededPresent {
return
}
if err := m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
o.AbandonPurgeRequested = false
o.AbandonRepoPath = ""
o.AbandonStartedAt = ""
o.OrphanedRenamedTo = ""
}); err != nil {
m.logger.Printf("[WARN] [offbox] could not close out the abandonment: %v", err)
return
}
m.logger.Printf("[INFO] [offbox] abandonment COMPLETE — the set-aside history and the sealed package that protected it are both gone; nothing further to ask about")
}
// ── OPERATOR CONTROL (§7.5) ─────────────────────────────────────────────────────────────────────
//
// The automatic 30-day abandonment is deliberately NOT built (see R-245). What IS built is the path
// that actually happens: **the customer gets in touch.** Someone who cannot find their recovery code
// rings support, and support needs something to press — either "give them longer" or "stop it".
//
// Both live on the controller CLI rather than in the customer UI, deliberately: extending a deletion
// the customer asked for is an operator judgement, not a self-service button, and a customer who
// wants it stopped already has the self-service route — they recover with their code, which cancels
// it (Scenario G).
// ExtendAbandon pushes the terminal step out by `days` from NOW. Returns the new due date.
//
// It refuses when no countdown is running: extending nothing would print a reassuring date for a
// deletion that was never scheduled, which is the kind of comfort this project keeps removing.
func (m *Manager) ExtendAbandon(days int) (time.Time, error) {
if days <= 0 {
return time.Time{}, fmt.Errorf("the extension must be a positive number of days")
}
st := m.AbandonStatus()
if !st.Active {
if st.PurgeRequested {
return time.Time{}, fmt.Errorf("too late: the set-aside history has already been deleted and only the sealed package is still being removed")
}
return time.Time{}, fmt.Errorf("no abandonment countdown is running on this box — nothing to extend")
}
due := m.abandonNow().UTC().AddDate(0, 0, days)
if err := m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
o.AbandonAt = due.Format(time.RFC3339)
}); err != nil {
return time.Time{}, fmt.Errorf("record the extension: %w", err)
}
m.logger.Printf("[WARN] [offbox] abandonment EXTENDED by an operator: the set-aside history at %s is now deleted on %s (was %s)",
st.RepoPath, due.Format("2006-01-02"), st.DueAt.Format("2006-01-02"))
return due, nil
}
// StopAbandon cancels the countdown outright — the operator's version of Scenario G, for the
// customer who telephoned instead of finding their code. The set-aside history is kept and nothing
// is deleted; it is `CancelAbandon` with an operator's reason and a refusal when nothing is running,
// so an operator never gets a silent no-op they might read as success.
func (m *Manager) StopAbandon() error {
st := m.AbandonStatus()
if !st.Active {
if st.PurgeRequested {
return fmt.Errorf("too late: the set-aside history has already been deleted")
}
return fmt.Errorf("no abandonment countdown is running on this box — nothing to stop")
}
m.CancelAbandon("stopped by an operator")
return nil
}
@@ -0,0 +1,208 @@
package backup
import (
"context"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// ── R-302 — THE BANNER PROMISES ONLY WHAT THE BOX CAN STILL SEE IS TRUE ─────────────────────────
//
// The abandon banner said "until then you can still retrieve them with your recovery code",
// unconditionally, on every page. Yesterday's reading proved that false on a reachable state.
//
// THE CONDITION IS A PIN, NOT A COMPARISON AGAINST THE CURRENT KEY, and the difference is the whole
// design. The obvious proxy — "does the hub hold a key different from the one I use?" — asks about the
// wrong key: the set-aside copies were written under an OLDER key the box no longer has, which is why
// they were set aside. On a twice-rebuilt box the proxy answers "yes, promise it" about copies no key
// on file can open. The pin instead records the package the hub held AT THE DECISION and asks only
// "is the hub still holding that same one?".
//
// ⚠ THE PIN IS A RECORDED ASSUMPTION. It presumes the package held at the decision is the one that
// opens the set-aside copies. Nothing on the box records which key wrote them. See the field comment
// on settings.AbandonPinnedEscrowKeySHA256.
//
// The countdown is never started, shortened or triggered on a real machine — the clock is injected.
const pinnedHubKey = "1111111111111111111111111111111111111111111111111111111111111111"
const replacedHubKey = "2222222222222222222222222222222222222222222222222222222222222222"
// startedCountdown drives the PRODUCTION path (ResetOrphanedRepo → resetOrphanedRepo →
// startAbandonCountdown) so the pin cannot be written by tests alone while the live path never sets
// it — the inert-seam shape that has shipped here before, fully green.
func startedCountdown(t *testing.T, hubKeyAtDecision string) (*Manager, *settings.Settings, time.Time) {
t.Helper()
start := time.Date(2026, 8, 12, 12, 0, 0, 0, time.UTC)
m, sett, _ := abandonFixture(t, start)
if err := sett.SetHubEscrowKeySHA256(hubKeyAtDecision, start.Format(time.RFC3339)); err != nil {
t.Fatal(err)
}
if err := m.ResetOrphanedRepo(context.Background()); err != nil {
t.Fatalf("the production reset path failed: %v", err)
}
return m, sett, start
}
// PRODUCTION WIRING: the live decision path writes the pin. If this fails, every render test below is
// testing a field nothing sets.
func TestR302_ProductionResetPathWritesThePin(t *testing.T) {
_, sett, _ := startedCountdown(t, pinnedHubKey)
got := sett.GetOffboxTarget().AbandonPinnedEscrowKeySHA256
if got != pinnedHubKey {
t.Fatalf("pinned fingerprint = %q, want the hub key cached at the decision (%q). The whole "+
"design is that this is recorded when it is a fact; if the live path does not write it, "+
"the banner falls to the cautious branch for ever and the grace period becomes theatre",
got, pinnedHubKey)
}
if sett.GetOffboxTarget().AbandonAt == "" {
t.Error("no countdown recorded — the fixture is not exercising the path it claims to")
}
}
// ── SCENARIO A — package unchanged since the decision → the promise stands ──────────────────────
//
// RED-PROOF: force the condition false (drop the `cur == t.AbandonPinnedEscrowKeySHA256` arm) and this
// fails — a customer who can genuinely still change their mind loses the clause, which the code says
// explicitly must not happen ("a grace period during which recovery is impossible would be theatre").
func TestR302_ScenarioA_PackageUnchanged_RetrievalStillOffered(t *testing.T) {
m, _, _ := startedCountdown(t, pinnedHubKey)
st := m.AbandonStatus()
if !st.Active {
t.Fatal("countdown not active")
}
if !st.RetrievalStillOffered {
t.Error("the hub still holds the same package it held at the decision, so the customer really " +
"can still change their mind — the promise must stand")
}
}
// ── SCENARIO B — the package was REPLACED after the decision → promise withdrawn ────────────────
//
// This is the act that cost both demo boxes their history on 2026-08-04: a fresh escrow ceremony
// supersedes the package, and the old key it covered is unreachable (superseded packages grant no
// read path — hub store.go's own comment).
//
// RED-PROOF: re-read the pin at render (compare `cur` against itself, i.e. use the CURRENT cached
// value on both sides) and this fails — the promise returns, which is today's defect.
func TestR302_ScenarioB_PackageReplaced_PromiseWithdrawn(t *testing.T) {
m, sett, start := startedCountdown(t, pinnedHubKey)
// A fresh ceremony after the decision.
if err := sett.SetHubEscrowKeySHA256(replacedHubKey, start.Add(48*time.Hour).Format(time.RFC3339)); err != nil {
t.Fatal(err)
}
if st := m.AbandonStatus(); st.RetrievalStillOffered {
t.Error("the hub's package was replaced after the customer decided, so the key that opened the " +
"set-aside copies is no longer served — the banner must stop promising retrieval")
}
// The pin itself must NOT have moved: it is written once, at the decision.
if got := sett.GetOffboxTarget().AbandonPinnedEscrowKeySHA256; got != pinnedHubKey {
t.Errorf("the pin was refreshed to %q — a field re-read later answers a different question and "+
"silently restores the defect this replaces", got)
}
}
// ── SCENARIO D — a countdown started BEFORE this shipped carries no pin ─────────────────────────
//
// RED-PROOF: backfill the pin from the current cached value when it is empty and this fails — a legacy
// countdown gets promised at, asserting as recorded-at-the-decision something read long afterwards.
func TestR302_ScenarioD_LegacyCountdownWithoutAPin_TakesTheCautiousBranch(t *testing.T) {
m, sett, _ := startedCountdown(t, pinnedHubKey)
// Model the pre-R-302 on-disk shape: a live countdown, no pin.
if err := sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
o.AbandonPinnedEscrowKeySHA256 = ""
}); err != nil {
t.Fatal(err)
}
st := m.AbandonStatus()
if !st.Active {
t.Fatal("countdown should still be running")
}
if st.RetrievalStillOffered {
t.Error("a countdown with no pin was promised at. There is no honest way to know whether the " +
"hub's package is still the one from the decision, and the cautious answer is the only one " +
"available")
}
}
// ── SCENARIO E — pinned present, hub's cached value EMPTY → cautious ────────────────────────────
//
// The hub sends "" for a legacy package that provably seals no repository password. Empty is a
// measurement, not a match.
//
// RED-PROOF: treat empty as equal (drop the `cur != ""` arm) and this fails.
func TestR302_ScenarioE_EmptyHubHash_IsNotAMatch(t *testing.T) {
m, sett, start := startedCountdown(t, pinnedHubKey)
if err := sett.SetHubEscrowKeySHA256("", start.Add(time.Hour).Format(time.RFC3339)); err != nil {
t.Fatal(err)
}
if st := m.AbandonStatus(); st.RetrievalStillOffered {
t.Error("an EMPTY hub hash was read as a match. It means the hub holds a package that seals no " +
"repository password — the opposite of evidence that retrieval works")
}
}
// ── SCENARIO F — no countdown → nothing about retrieval is claimed at all ───────────────────────
func TestR302_ScenarioF_NoCountdown_NoClaim(t *testing.T) {
start := time.Date(2026, 8, 12, 12, 0, 0, 0, time.UTC)
m, _, _ := abandonFixture(t, start)
st := m.AbandonStatus()
if st.Active {
t.Fatal("no countdown was started, yet one is reported active")
}
if st.RetrievalStillOffered {
t.Error("retrieval was offered with no countdown running — the flag must be meaningless " +
"outside an abandonment, not default-true")
}
}
// The pin is a hash of a secret. It must never reach a customer-facing surface or the report; this
// pins that it is not accidentally exported through the read model.
func TestR302_PinIsNotExposedThroughTheReadModel(t *testing.T) {
m, _, _ := startedCountdown(t, pinnedHubKey)
st := m.AbandonStatus()
if st.RepoPath == pinnedHubKey {
t.Fatal("the pin leaked into RepoPath")
}
// AbandonState carries a BOOLEAN verdict, never the fingerprint itself.
if got := st.RetrievalStillOffered; got != true && got != false {
t.Fatal("unreachable")
}
}
// ── SCENARIO E, the case that actually bites — BOTH sides empty ─────────────────────────────────
//
// A legacy countdown (no pin) on a box whose hub reports an empty hash (a package sealing no repo
// password). "" == "" is the equality that would quietly become a promise, and it is the ONLY state
// where dropping the emptiness guards changes the answer — TestR302_ScenarioE above passes even with
// them removed, because its pin is non-empty so the equality fails on its own. That test guards the
// sentence; this one guards the claim.
//
// RED-PROOF: drop either `cur != ""` or `t.AbandonPinnedEscrowKeySHA256 != ""` and this fails.
func TestR302_ScenarioE2_BothSidesEmpty_IsNotAMatch(t *testing.T) {
m, sett, start := startedCountdown(t, pinnedHubKey)
if err := sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
o.AbandonPinnedEscrowKeySHA256 = "" // legacy countdown, no pin
}); err != nil {
t.Fatal(err)
}
if err := sett.SetHubEscrowKeySHA256("", start.Add(time.Hour).Format(time.RFC3339)); err != nil {
t.Fatal(err)
}
st := m.AbandonStatus()
if !st.Active {
t.Fatal("countdown should still be running")
}
if st.RetrievalStillOffered {
t.Error("two absences compared equal and became a promise. Empty means we could not see; two " +
"things we could not see are not a match")
}
}
@@ -0,0 +1,335 @@
package backup
import (
"context"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// R-241 — abandoning starts a countdown that ENDS THE QUESTION (Scenarios E, F, G).
//
// The countdown is driven by an injected clock throughout. §7.4 forbids shortening a live timer to
// watch the terminal step fire: it is the only thing in the product that deletes a customer's
// off-site history, and a step tested once on real data is a step regretted once.
// abandonFixture: an orphaned, configured box holding a key, with the hub holding a package for a
// DIFFERENT key — i.e. shape (c) is live and the customer is being offered recovery.
// Returns the manager and a recorder of every remote shell command issued.
type sshRecorder struct{ cmds []string }
func (r *sshRecorder) run(ctx context.Context, host, user string, port int, keyPath, knownHosts, remoteCmd string) ([]byte, error) {
r.cmds = append(r.cmds, remoteCmd)
return []byte(""), nil
}
func abandonFixture(t *testing.T, now time.Time) (*Manager, *settings.Settings, *sshRecorder) {
t.Helper()
m, sett, _ := offerFixture(t, true)
if err := sett.SetHubEscrowKeySHA256(otherKeyHash, now.Format(time.RFC3339)); err != nil {
t.Fatal(err)
}
if err := sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
o.EscrowState = "escrowed"
o.RepoState = "orphaned"
}); err != nil {
t.Fatal(err)
}
rec := &sshRecorder{}
m.SetOffboxSSH(rec.run)
m.SetOffboxRunner(func(ctx context.Context, env []string, args ...string) ([]byte, error) { return []byte(""), nil })
m.SetOffboxClock(func() time.Time { return now })
return m, sett, rec
}
// ── SCENARIO E — abandoning sets aside, keeps the package, starts a countdown, stays reversible ──
func TestR241_ScenarioE_AbandonStartsAReversibleCountdown(t *testing.T) {
start := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC)
m, _, rec := abandonFixture(t, start)
if err := m.ResetOrphanedRepo(context.Background()); err != nil {
t.Fatalf("abandon: %v", err)
}
// The store was MOVED, not deleted — no rm anywhere in this phase.
joined := strings.Join(rec.cmds, " | ")
if !strings.Contains(joined, "mv ") {
t.Errorf("the old store must be moved aside; commands were: %s", joined)
}
if strings.Contains(joined, "rm -rf") {
t.Fatalf("NOTHING may be deleted when the customer abandons — only at the end of the grace. Commands: %s", joined)
}
st := m.AbandonStatus()
if !st.Active {
t.Fatal("a countdown must be running after an abandonment")
}
if got := st.DueAt.Sub(start); got != abandonGraceDays*24*time.Hour {
t.Errorf("countdown length = %v, want %d days", got, abandonGraceDays)
}
if st.DaysLeft != abandonGraceDays {
t.Errorf("DaysLeft = %d, want %d", st.DaysLeft, abandonGraceDays)
}
if st.RepoPath == "" {
t.Error("the set-aside path must be recorded, or the terminal step has nothing to delete")
}
// THE GRACE IS REAL: the recovery offer stays reachable for the whole window. A grace in which
// recovery is impossible would be decorative.
if !m.OffsiteRecoveryOffer() {
t.Fatal("the recovery offer MUST stay reachable during the grace — that is the change-of-mind path")
}
}
// ── SCENARIO G — changing your mind inside the window ───────────────────────────────────────────
//
// RED-PROOF: make the countdown uncancellable (delete the body of CancelAbandon). The countdown then
// survives a successful recovery and this test fails — a customer who proved they hold their code
// would still have the history deleted under them.
func TestR241_ScenarioG_RecoveryInsideTheWindowCancelsTheCountdown(t *testing.T) {
start := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC)
m, _, _ := abandonFixture(t, start)
if err := m.ResetOrphanedRepo(context.Background()); err != nil {
t.Fatal(err)
}
day6 := start.AddDate(0, 0, 6)
m.SetOffboxClock(func() time.Time { return day6 })
if st := m.AbandonStatus(); !st.Active || st.DaysLeft != 8 {
t.Fatalf("precondition: day 6 of 14 should leave 8 days, got %+v", st)
}
pathBefore := m.AbandonStatus().RepoPath
m.CancelAbandon("the customer recovered with their code")
st := m.AbandonStatus()
if st.Active {
t.Fatal("a countdown must be cancellable — the customer found their code")
}
if st.RepoPath != pathBefore {
t.Errorf("the set-aside store must stay NAMEABLE after a cancel: got %q want %q", st.RepoPath, pathBefore)
}
// And a sweep now deletes nothing, on any later date.
m.SetOffboxClock(func() time.Time { return start.AddDate(0, 0, 90) })
deleted, err := m.AbandonSweep(context.Background())
if err != nil || deleted {
t.Fatalf("a cancelled countdown must never delete: deleted=%v err=%v", deleted, err)
}
}
// ── SCENARIO F — the countdown ends the question, and removes BOTH halves ────────────────────────
//
// RED-PROOF (store half): make AbandonSweep skip the rm. The first assertion fails.
// RED-PROOF (package half): drop AbandonPurgeRequested from OffboxReportStatus. The declaration
// assertion fails — the hub is never asked and the package outlives the store for ever.
func TestR241_ScenarioF_TerminalStepRemovesBothHalvesTogether(t *testing.T) {
start := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC)
m, sett, rec := abandonFixture(t, start)
if err := m.ResetOrphanedRepo(context.Background()); err != nil {
t.Fatal(err)
}
setAside := m.AbandonStatus().RepoPath
// Not due yet — nothing happens, quietly.
m.SetOffboxClock(func() time.Time { return start.AddDate(0, 0, 13) })
if deleted, err := m.AbandonSweep(context.Background()); deleted || err != nil {
t.Fatalf("day 13 must not delete: deleted=%v err=%v", deleted, err)
}
// Due.
m.SetOffboxClock(func() time.Time { return start.AddDate(0, 0, 14).Add(time.Minute) })
rec.cmds = nil
deleted, err := m.AbandonSweep(context.Background())
if err != nil {
t.Fatalf("terminal step: %v", err)
}
if !deleted {
t.Fatal("the terminal step must delete when due")
}
// HALF 1: the store is gone.
joined := strings.Join(rec.cmds, " | ")
if !strings.Contains(joined, "rm -rf") || !strings.Contains(joined, setAside) {
t.Fatalf("the set-aside store at %s must be deleted; commands: %s", setAside, joined)
}
// HALF 2: the hub is ASKED for the package, and keeps being asked until it confirms.
st := m.OffboxReportStatus()
if st == nil || !st.AbandonPurgeRequested {
t.Fatalf("the report must declare abandon_purge_requested until the hub drops the package, got %+v", st)
}
// It repeats — a lost request must retry rather than leave the pair half-removed.
if d2, err2 := m.AbandonSweep(context.Background()); d2 || err2 != nil {
t.Fatalf("a second sweep must be a quiet no-op while awaiting the hub: deleted=%v err=%v", d2, err2)
}
if st2 := m.OffboxReportStatus(); st2 == nil || !st2.AbandonPurgeRequested {
t.Fatal("the declaration must persist across sweeps until confirmed")
}
// The hub confirms by no longer reporting a superseded package → the question is over.
m.ClearAbandonPurgeIfConfirmed(false)
if got := sett.GetOffboxTarget(); got.AbandonPurgeRequested || got.AbandonRepoPath != "" || got.AbandonAt != "" {
t.Errorf("the abandonment must be fully closed out, got %+v", got)
}
if st3 := m.OffboxReportStatus(); st3 != nil && st3.AbandonPurgeRequested {
t.Error("the declaration must stop once the hub has confirmed")
}
}
// While the hub STILL reports a superseded package, the close-out must not fire — otherwise the box
// stops asking and the package outlives the store silently, which is exactly half of Scenario F.
func TestR241_PurgeIsNotClosedOutWhileThePackageRemains(t *testing.T) {
start := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC)
m, sett, _ := abandonFixture(t, start)
if err := m.ResetOrphanedRepo(context.Background()); err != nil {
t.Fatal(err)
}
m.SetOffboxClock(func() time.Time { return start.AddDate(0, 0, 15) })
if _, err := m.AbandonSweep(context.Background()); err != nil {
t.Fatal(err)
}
m.ClearAbandonPurgeIfConfirmed(true) // the hub STILL holds a retained package
if !sett.GetOffboxTarget().AbandonPurgeRequested {
t.Fatal("the request must stand while the hub still reports a superseded package")
}
}
// A transport failure during the terminal step must NOT clear the countdown — it retries tomorrow.
// Silently abandoning the abandonment would leave the store for ever with nothing counting down.
func TestR241_TerminalStepFailureKeepsTheCountdownDue(t *testing.T) {
start := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC)
m, sett, _ := abandonFixture(t, start)
if err := m.ResetOrphanedRepo(context.Background()); err != nil {
t.Fatal(err)
}
m.SetOffboxSSH(func(ctx context.Context, host, user string, port int, keyPath, knownHosts, remoteCmd string) ([]byte, error) {
return []byte("ssh: connect to host nas.local port 22: No route to host"), context.DeadlineExceeded
})
m.SetOffboxClock(func() time.Time { return start.AddDate(0, 0, 15) })
deleted, err := m.AbandonSweep(context.Background())
if deleted || err == nil {
t.Fatalf("a failed deletion must be reported, not swallowed: deleted=%v err=%v", deleted, err)
}
got := sett.GetOffboxTarget()
if got.AbandonAt == "" || got.AbandonPurgeRequested {
t.Fatalf("a failed terminal step must leave the countdown DUE and unrequested, got %+v", got)
}
if !m.AbandonStatus().Active {
t.Error("the countdown must still be active so tomorrow's sweep retries")
}
}
// Quiet by construction: a box with no countdown does no work and says nothing (§ the daily job's
// own contract). Asserted, because "it probably does nothing" is how a sweep with a bug hides.
func TestR241_Sweep_QuietWhenNothingDue(t *testing.T) {
m, _, rec := abandonFixture(t, time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC))
deleted, err := m.AbandonSweep(context.Background())
if deleted || err != nil {
t.Fatalf("a box with no countdown must be a pure no-op: deleted=%v err=%v", deleted, err)
}
if len(rec.cmds) != 0 {
t.Fatalf("a no-op sweep must issue no remote commands, got %v", rec.cmds)
}
if m.AbandonStatus().Active {
t.Error("no countdown should be reported")
}
}
// The UNCLAIMED auto-reset must NOT start a customer countdown — nobody decided anything there.
// An as-delivered box tidying a stranger's leftover store must not put a 14-day deletion clock on it.
func TestR241_UnclaimedAutoResetStartsNoCountdown(t *testing.T) {
start := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC)
m, _, _ := abandonFixture(t, start)
t2 := m.settings.GetOffboxTarget()
base, env := m.offboxBaseArgs(t2)
if err := m.resetOrphanedRepo(context.Background(), base, env, "auto (unclaimed)"); err != nil {
t.Fatal(err)
}
if m.AbandonStatus().Active {
t.Fatal("the unclaimed auto-reset must not start a customer abandonment countdown")
}
}
// ── §7.5 — THE OPERATOR LEVERS ──────────────────────────────────────────────────────────────────
//
// The automatic 30-day ending is deliberately NOT built (R-245). These are what IS built: the path
// that actually happens is the customer telephoning, and support needs something to press.
func TestR241_OperatorCanExtendARunningCountdown(t *testing.T) {
start := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC)
m, _, rec := abandonFixture(t, start)
if err := m.ResetOrphanedRepo(context.Background()); err != nil {
t.Fatal(err)
}
day10 := start.AddDate(0, 0, 10)
m.SetOffboxClock(func() time.Time { return day10 })
due, err := m.ExtendAbandon(30)
if err != nil {
t.Fatalf("extend: %v", err)
}
if want := day10.AddDate(0, 0, 30); !due.Equal(want) {
t.Errorf("new due = %v, want %v (from NOW, not from the old date)", due, want)
}
// The original date has passed and nothing is deleted, because the extension moved it.
m.SetOffboxClock(func() time.Time { return start.AddDate(0, 0, 15) })
rec.cmds = nil
if deleted, serr := m.AbandonSweep(context.Background()); deleted || serr != nil {
t.Fatalf("an extended countdown must not fire on the old date: deleted=%v err=%v", deleted, serr)
}
if len(rec.cmds) != 0 {
t.Fatalf("nothing may be deleted after an extension, got %v", rec.cmds)
}
}
func TestR241_OperatorCanStopARunningCountdown(t *testing.T) {
start := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC)
m, _, rec := abandonFixture(t, start)
if err := m.ResetOrphanedRepo(context.Background()); err != nil {
t.Fatal(err)
}
if err := m.StopAbandon(); err != nil {
t.Fatalf("stop: %v", err)
}
if m.AbandonStatus().Active {
t.Fatal("the countdown must be stopped")
}
m.SetOffboxClock(func() time.Time { return start.AddDate(0, 0, 90) })
rec.cmds = nil
if deleted, err := m.AbandonSweep(context.Background()); deleted || err != nil {
t.Fatalf("a stopped countdown must never delete: deleted=%v err=%v", deleted, err)
}
if len(rec.cmds) != 0 {
t.Fatalf("a stopped countdown must issue no remote commands, got %v", rec.cmds)
}
}
// Both levers REFUSE when nothing is running. A silent no-op is the thing an operator most easily
// mistakes for success — they would tell the customer it was handled.
func TestR241_OperatorLeversRefuseWhenNothingIsRunning(t *testing.T) {
m, _, _ := abandonFixture(t, time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC))
if _, err := m.ExtendAbandon(30); err == nil {
t.Error("extending a countdown that is not running must be an error, never a quiet success")
}
if err := m.StopAbandon(); err == nil {
t.Error("stopping a countdown that is not running must be an error, never a quiet success")
}
if _, err := m.ExtendAbandon(0); err == nil {
t.Error("a non-positive extension must be refused")
}
}
// Once the store is deleted there is nothing left to extend or stop, and saying otherwise would be
// the worst kind of reassurance: an operator telling a customer their data is safe when it is gone.
func TestR241_OperatorLeversRefuseAfterTheDeletion(t *testing.T) {
start := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC)
m, _, _ := abandonFixture(t, start)
if err := m.ResetOrphanedRepo(context.Background()); err != nil {
t.Fatal(err)
}
m.SetOffboxClock(func() time.Time { return start.AddDate(0, 0, 15) })
if _, err := m.AbandonSweep(context.Background()); err != nil {
t.Fatal(err)
}
if _, err := m.ExtendAbandon(30); err == nil {
t.Error("extending after the deletion must be refused — there is nothing left to save")
}
if err := m.StopAbandon(); err == nil {
t.Error("stopping after the deletion must be refused — there is nothing left to save")
}
}
+3 -1
View File
@@ -75,7 +75,9 @@ func (m *Manager) offboxCaptureSet(stack string) (extra []string, warns []string
extra = append(extra, p.Abs)
}
if len(gaps) > 0 {
warns = append(warns, fmt.Sprintf("Figyelmeztetés: a(z) %s alkalmazás egyes adatmappái nem kerültek a távoli mentésbe: %s.",
// R-234 §7.4: this sits beside the whole-app gap message on the same card, and both now drive
// the same `incomplete` verdict — so it says what to do, not only what happened.
warns = append(warns, fmt.Sprintf("Figyelmeztetés: a(z) %s alkalmazás egyes adatmappái nem kerültek a távoli mentésbe: %s. Ellenőrizd, hogy a mappák megvannak-e a meghajtón; ha igen és ez a következő mentés után is látszik, szólj az üzemeltetőnek.",
stack, strings.Join(gaps, ", ")))
}
return extra, warns, gaps
@@ -0,0 +1,193 @@
package backup
import (
"encoding/json"
"io"
"log"
"os"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// R-204 item 4 / R-193 — a REBUILT box declares that it needs an off-site credential, instead of
// reporting an absence the hub cannot interpret.
//
// THE POINT OF THESE TESTS is the conjunction. An absent off-site object has FOUR meanings (never
// configured / mid-restart / a transient read failure / rebuilt-and-stranded). The declaration has
// one, and it is only sound because BOTH halves are required: a fresh data area AND a hub-held
// recovery package. Scenario B is the one that matters most — drop the escrow half and every
// un-configured box in the fleet starts asking for a credential.
// bareManager builds a Manager with NO off-site target and NO repository password — the shape of a
// freshly rebuilt box before anything is configured.
func bareManager(t *testing.T) (*Manager, *settings.Settings) {
t.Helper()
lg := log.New(io.Discard, "", 0)
dataDir := t.TempDir()
sett, err := settings.Load(filepath.Join(dataDir, "settings.json"), lg)
if err != nil {
t.Fatal(err)
}
cfg := &config.Config{}
cfg.Paths.DataDir = dataDir
cfg.Paths.SystemDataPath = filepath.Join(dataDir, "sys")
return NewManager(cfg, sett, lg), sett
}
// SCENARIO A — a rebuilt box (fresh data area + a hub-held escrow) DECLARES the state.
//
// RED-PROOF: remove the `GetHubEscrowIdentityPresent()` condition from needsOffsiteCredential —
// Scenario A still passes (it has an escrow), and SCENARIO B FAILS, which is the point: the plausible
// wrong fix is to declare on freshness alone, and that would make every un-configured box in the
// fleet ask for a credential.
func TestOffsiteDeclare_RebuiltBoxDeclaresNeedsCredential(t *testing.T) {
m, sett := bareManager(t)
if err := sett.SetHubEscrowIdentityPresent(true); err != nil {
t.Fatal(err)
}
st := m.OffboxReportStatus()
if st == nil {
t.Fatal("a rebuilt box reported NO off-site object — the hub cannot distinguish it from a box that never had off-site backups (this is the defect)")
}
if st.State != OffsiteStateNeedsCredential {
t.Fatalf("declared state = %q, want %q", st.State, OffsiteStateNeedsCredential)
}
// Enabled MUST be false and the sizes zero — that is what makes the declaration inert to the
// hub's existing fill and staleness checkers (and to a pre-upgrade hub).
if st.Enabled {
t.Error("a declaration must not claim the tier is enabled — the hub's staleness check keys on it")
}
if st.QuotaGB != 0 || st.RepoSizeBytes != 0 || st.SnapshotCount != 0 {
t.Errorf("a declaration must carry zero sizes (fill band keys on them): %+v", st)
}
// And it must be on the off-site object, not a new top-level field.
b, err := json.Marshal(st)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(b), `"state":"needs_credential"`) {
t.Fatalf("declared state absent from the marshalled off-site object: %s", b)
}
if !strings.Contains(string(b), `"enabled":false`) {
t.Fatalf("marshalled object must carry enabled:false: %s", b)
}
}
// SCENARIO B — a box that never had off-site backups says NOTHING. This is the guard on the
// conjunction; without it the feature churns credentials fleet-wide.
func TestOffsiteDeclare_NeverHadOffsiteSaysNothing(t *testing.T) {
m, _ := bareManager(t) // fresh data area, but NO hub-held escrow
if st := m.OffboxReportStatus(); st != nil {
t.Fatalf("a box that never had off-site backups DECLARED a need: %+v — every un-configured box in the fleet would now ask for a credential", st)
}
}
// SCENARIO D (R-218) — THE DECLARATION STOPS WHEN THE TIER WORKS, NOT WHEN A KEY EXISTS.
//
// ⚠ THIS TEST ASSERTED THE OPPOSITE until v0.201.0, and it was green the whole time. It required a
// box holding a repository password to stay SILENT — which reads as a sound freshness test and is the
// exact opposite on the one path that matters, because installing a repository password is the
// RECOVERY SCREEN'S WHOLE JOB. Measured live 2026-08-05 (CAMPAIGN-11 Phase 1): 32 seconds after the
// hub re-staged the credential, the customer's successful unlock switched off the mechanism that
// would have delivered the coordinates for the key they had just recovered. Deadlock, both halves.
//
// RED-PROOF: restore the `if _, ok := m.OffboxRepoPasswordHash(); ok { return false }` short-circuit
// in needsOffsiteCredential and this test FAILS — the box goes silent again with no target, which is
// the deadlock. Demonstrated failing before this test was kept.
func TestOffsiteDeclare_StillDeclaresAfterARecoveredKeyIsPlaced(t *testing.T) {
m, sett := bareManager(t)
if err := sett.SetHubEscrowIdentityPresent(true); err != nil {
t.Fatal(err)
}
// The post-unlock shape: the recovered repository password is on disk, and there is STILL no
// off-site target — so the box cannot use what it just recovered.
if err := os.MkdirAll(m.offboxDir(), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(m.offboxPwPath(), []byte("a-recovered-repository-password"), 0o600); err != nil {
t.Fatal(err)
}
st := m.OffboxReportStatus()
if st == nil {
t.Fatal("R-218: the box went SILENT after recovering its key while still having no off-site target — the hub's staged credential is never collected and nothing ever asks again")
}
if st.State != OffsiteStateNeedsCredential {
t.Fatalf("declared state = %q, want %q", st.State, OffsiteStateNeedsCredential)
}
}
// SCENARIO E — and once the tier ACTUALLY WORKS the box goes quiet. This is the condition that
// replaces the deleted one, and the pair above/below is what makes the deletion safe.
func TestOffsiteDeclare_ConfiguredTierIsSilent(t *testing.T) {
m, sett := bareManager(t)
if err := sett.SetHubEscrowIdentityPresent(true); err != nil {
t.Fatal(err)
}
if err := sett.SetOffboxTarget(&settings.OffboxTarget{
Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo",
Schedule: "daily", EscrowState: "escrowed",
}); err != nil {
t.Fatal(err)
}
st := m.OffboxReportStatus()
if st == nil {
t.Fatal("a configured tier must still report its ordinary off-site object")
}
if st.State == OffsiteStateNeedsCredential {
t.Fatal("a box whose tier is configured must not keep asking for a credential")
}
}
// A DISABLED target is the customer's own choice, not a rebuild — it must not declare either.
func TestOffsiteDeclare_DisabledTargetIsNotStranded(t *testing.T) {
m, sett := bareManager(t)
if err := sett.SetHubEscrowIdentityPresent(true); err != nil {
t.Fatal(err)
}
if err := sett.SetOffboxTarget(&settings.OffboxTarget{
Enabled: false, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo",
}); err != nil {
t.Fatal(err)
}
if st := m.OffboxReportStatus(); st != nil {
t.Fatalf("a deliberately DISABLED target declared a need: %+v", st)
}
}
// A CONFIGURED box's report object must be byte-identical to v0.198.0's — no `state` key at all.
// This is what lets a pre-upgrade hub and every existing checker read the fleet unchanged.
func TestOffsiteDeclare_ConfiguredBoxJSONIsUnchanged(t *testing.T) {
m, sett := bareManager(t)
if err := sett.SetOffboxTarget(&settings.OffboxTarget{
Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo",
Schedule: "daily", EscrowState: "escrowed", LastStatus: "ok",
}); err != nil {
t.Fatal(err)
}
st := m.OffboxReportStatus()
if st == nil {
t.Fatal("a configured box must still report an off-site object")
}
if st.State != "" {
t.Errorf("a configured box must declare NO state, got %q", st.State)
}
b, err := json.Marshal(st)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(b), `"state"`) {
t.Fatalf("a healthy report's JSON gained a `state` key — it must stay byte-compatible: %s", b)
}
if !strings.Contains(string(b), `"enabled":true`) {
t.Fatalf("a configured box must report enabled:true: %s", b)
}
}
@@ -0,0 +1,131 @@
package backup
import (
"context"
"encoding/json"
"errors"
"sort"
"time"
)
// R-193 Part 3 — WHAT IS IN THERE. After a successful unlock the customer is shown the contents of the
// repository they just opened: which apps, from when, how big.
//
// READ-ONLY, AND THAT IS THE POINT. This restores nothing, puts nothing back, and compares nothing
// against live data. Unlocking and restoring are separate (operator ruling, 2026-08-05): restore is
// already per-app and already lives in the backups area, and a screen that unlocks and then offers to
// overwrite is two decisions wearing one button.
//
// WHY A LISTING AT ALL, rather than a success message: "unlocked" with nothing shown is
// indistinguishable from having unlocked an EMPTY store, and the customer has no way to tell whether
// what came back is the right thing. Seeing their own app names and dates is how they know.
// errNoOffsiteTarget is returned when the repository cannot even be addressed — no off-site target is
// configured on this box yet. Distinguished from a read failure because the remedy differs: this one
// resolves by itself once the tier is re-applied.
var errNoOffsiteTarget = errors.New("no off-site target is configured on this box yet")
// ErrNoOffsiteTarget reports whether err is the not-yet-configured case, so a caller can say the right
// thing rather than showing a generic failure.
func ErrNoOffsiteTarget(err error) bool { return errors.Is(err, errNoOffsiteTarget) }
// ErrNoOffsiteTargetSentinel exposes the sentinel itself so other packages — and their tests — can
// construct the not-yet-configured case. Added for R-237, whose restore list must distinguish
// "no target yet" (resolves by itself) from "could not read" (does not), and must be able to pin
// both in a table test.
func ErrNoOffsiteTargetSentinel() error { return errNoOffsiteTarget }
// OffsiteInventoryApp is one app's presence in the opened repository. Non-secret throughout.
type OffsiteInventoryApp struct {
App string // the restic tag == the stack name
LatestAt time.Time // the newest snapshot's time for this app
SizeBytes int64 // restore size of that newest snapshot (0 = could not be determined)
}
// OffsiteInventory is the whole answer, including the EMPTY case stated explicitly.
type OffsiteInventory struct {
Apps []OffsiteInventoryApp
// Empty is true when the repository opened cleanly and holds no snapshots. It is a real and
// confusing outcome — a bare list there reads as a broken page — so it is named rather than
// inferred from len(Apps)==0, which is also what a failed read looks like.
Empty bool
}
// OffsiteInventoryList opens the repository and reports what is in it, grouped per app. One
// `snapshots --json` call for the whole repo, then one `stats` per app for the newest snapshot's size.
//
// A per-app size failure is NOT fatal: the app is still listed, with SizeBytes 0, because knowing an
// app is in there matters more than knowing how big it is, and dropping it would under-report the
// customer's own data.
func (m *Manager) OffsiteInventoryList(ctx context.Context) (OffsiteInventory, error) {
var inv OffsiteInventory
// A box can hold a recovered key and still have no off-site COORDINATES — the pristine rebuilt
// shape, before its target is re-applied. Reading the repository is impossible then, and saying so
// is the honest answer; without this guard offboxBaseArgs nil-derefs on the missing target.
if !m.OffboxConfigured() {
return inv, errNoOffsiteTarget
}
t := m.settings.GetOffboxTarget()
base, env := m.offboxBaseArgs(t)
sctx, cancel := context.WithTimeout(ctx, offboxProbeTimeout)
defer cancel()
out, err := m.runner()(sctx, env, append(append([]string{}, base...), "snapshots", "--json")...)
if err != nil {
return inv, err
}
var snaps []struct {
ShortID string `json:"short_id"`
ID string `json:"id"`
Time time.Time `json:"time"`
Tags []string `json:"tags"`
}
if uerr := json.Unmarshal(out, &snaps); uerr != nil {
return inv, uerr
}
if len(snaps) == 0 {
inv.Empty = true
return inv, nil
}
// Newest snapshot per tag. A snapshot may carry several tags; each names an app it belongs to.
newest := map[string]struct {
id string
at time.Time
}{}
for _, s := range snaps {
id := s.ShortID
if id == "" {
id = s.ID
}
for _, tag := range s.Tags {
if tag == "" {
continue
}
if cur, ok := newest[tag]; !ok || s.Time.After(cur.at) {
newest[tag] = struct {
id string
at time.Time
}{id: id, at: s.Time}
}
}
}
if len(newest) == 0 {
// Snapshots exist but carry no tags — not "empty", and saying so would be a lie. Report an
// empty app list without the Empty flag; the page renders the honest in-between wording.
return inv, nil
}
for tag, n := range newest {
app := OffsiteInventoryApp{App: tag, LatestAt: n.at}
if size, serr := m.offboxSnapshotSize(ctx, n.id); serr == nil {
app.SizeBytes = size
} else {
m.logger.Printf("[WARN] [offbox] inventory: size of %s's newest snapshot unknown: %v (listing it anyway)", tag, serr)
}
inv.Apps = append(inv.Apps, app)
}
sort.Slice(inv.Apps, func(i, j int) bool { return inv.Apps[i].App < inv.Apps[j].App })
return inv, nil
}
// HumanizeBytes exposes the shared byte formatter to the web layer so the recovery page renders sizes
// the same way every other surface does.
func HumanizeBytes(n int64) string { return humanizeBytes(n) }
@@ -0,0 +1,192 @@
package backup
import (
"context"
"log"
"os"
"path/filepath"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// R-241 — THE MINT GUARD. This file is the session's headline test.
//
// The defect, measured on the final walk (SPIKE-r241-recovery-offer-2026-08-07): a rebuilt box's
// credential self-heal reached WriteOffboxSecrets at 03:18:06Z and minted a fresh repository password
// over a hub package sealing a DIFFERENT key. The recovery screen then correctly reported that there
// was nothing recoverable under the key the box held. The screen was honest; the minting was not.
//
// Scenario A asserts the key is NOT written. Scenario B asserts the guard is narrow enough that a
// first-time box still starts — the guard's own failure mode, and the one an over-broad fix produces.
// mintGuardManager builds a Manager with NO offbox secrets written, so the mint branch is live.
// hubHoldsPackage sets the ACK-cached fact the guard consults.
func mintGuardManager(t *testing.T, hubHoldsPackage bool) (*Manager, *settings.Settings, string) {
t.Helper()
logger := log.New(os.Stderr, "", 0)
dataDir := t.TempDir()
sett, err := settings.Load(filepath.Join(dataDir, "settings.json"), logger)
if err != nil {
t.Fatal(err)
}
cfg := &config.Config{}
cfg.Paths.DataDir = dataDir
cfg.Paths.SystemDataPath = filepath.Join(dataDir, "sys")
m := NewManager(cfg, sett, logger)
if err := sett.SetHubEscrowIdentityPresent(hubHoldsPackage); err != nil {
t.Fatal(err)
}
return m, sett, filepath.Join(dataDir, "offbox", "repo_password")
}
// ── SCENARIO A — the box does not mint over a sealed package ────────────────────────────────────
//
// RED-PROOF: delete the `if m.sealedPackageHeld()` block in WriteOffboxSecrets. The password file
// then exists and this test fails on the first assertion — which is exactly the 03:18:06Z event.
func TestR241_ScenarioA_NoMintWhenHubHoldsSealedPackage(t *testing.T) {
m, _, pwPath := mintGuardManager(t, true)
err := m.WriteOffboxSecrets("PRIVATE-KEY-MATERIAL", "nas.local ssh-ed25519 AAAAhostkey")
if !IsOffboxSealedPackageHeld(err) {
t.Fatalf("want the sealed-package refusal sentinel, got %v", err)
}
// THE ASSERTION THAT IS THE WHOLE SESSION: no key on disk.
if _, serr := os.Stat(pwPath); !os.IsNotExist(serr) {
t.Fatalf("R-241 REGRESSION: a repository password was minted over the hub's sealed package (stat err=%v)", serr)
}
// The transport IS still written — the refusal is a holding state, not a failure. Without this the
// recovery screen could not bring the tier up when the key arrives (R-219).
for _, f := range []string{"ssh_key", "known_hosts"} {
if _, serr := os.Stat(filepath.Join(filepath.Dir(pwPath), f)); serr != nil {
t.Errorf("transport file %s should still be written on the refusal path: %v", f, serr)
}
}
}
// Scenario A at the APPLY level — the path the self-heal actually takes. ApplyOffsiteTarget must
// swallow the sentinel, record the target, and NOT stage an escrow.
func TestR241_ScenarioA_ApplyOffsiteTargetHoldsInsteadOfMinting(t *testing.T) {
m, sett, pwPath := mintGuardManager(t, true)
staged := 0
stage := func(ctx context.Context, pw string) error { staged++; return nil }
tgt := &settings.OffboxTarget{Enabled: true, Host: "box.example", Port: 23, User: "u1", RepoPath: "/home/felhom-repo"}
if err := m.ApplyOffsiteTarget(context.Background(), tgt, "KEYMATERIAL", "box.example ssh-ed25519 HOSTKEY", stage); err != nil {
t.Fatalf("apply should SUCCEED into the holding state, not fail: %v", err)
}
if _, serr := os.Stat(pwPath); !os.IsNotExist(serr) {
t.Fatalf("R-241 REGRESSION: apply minted a repository password over the sealed package")
}
if staged != 0 {
t.Errorf("nothing may be staged for escrow — there is no key to escrow; staged=%d", staged)
}
// The target is recorded, so the box stops declaring needs_credential and the hub stops re-staging.
if got := sett.GetOffboxTarget(); got == nil {
t.Fatal("the transport target must be recorded, or the hub re-stages a consumed credential forever")
}
// Runs stay gated: no password file ⇒ not configured.
if m.OffboxConfigured() {
t.Error("OffboxConfigured must be false while the key is awaited — runs must not proceed")
}
// And the box says so, in the state the hub reads.
if !m.OffboxAwaitingRecoveryKey() {
t.Error("OffboxAwaitingRecoveryKey should be true in the holding state")
}
st := m.OffboxReportStatus()
if st == nil || st.State != OffsiteStateAwaitingRecoveryKey {
t.Fatalf("want declared state %q, got %+v", OffsiteStateAwaitingRecoveryKey, st)
}
if st.Enabled {
t.Error("the declared holding object must carry Enabled=false so existing hub readers stay inert")
}
}
// ── SCENARIO B — a box the hub holds nothing for still mints, exactly as today ───────────────────
//
// RED-PROOF: widen the guard to `if true` (or drop the GetHubEscrowIdentityPresent() conjunct in
// sealedPackageHeld). A first-time box then cannot start, and this test fails — the failure mode an
// over-broad fix produces, which is why the guard is written as a conjunction.
func TestR241_ScenarioB_FirstTimeBoxStillMints(t *testing.T) {
m, _, pwPath := mintGuardManager(t, false) // the hub holds nothing for us
if err := m.WriteOffboxSecrets("PRIVATE-KEY-MATERIAL", "nas.local ssh-ed25519 AAAAhostkey"); err != nil {
t.Fatalf("a first-time box must mint exactly as before, got %v", err)
}
pw, rerr := os.ReadFile(pwPath)
if rerr != nil {
t.Fatalf("a first-time box must get a repository password: %v", rerr)
}
if !offboxRepoPwPattern.Match(pw) {
t.Errorf("minted password is not the expected 64-hex shape")
}
if m.OffboxAwaitingRecoveryKey() {
t.Error("a box with no sealed package is not awaiting anything")
}
}
// The guard must not fire once a key EXISTS — a healthy box re-applying its target (a quota bump,
// a hub re-push) must be untouched, package or no package. This is the idempotency half.
func TestR241_ExistingKeyIsNeverDisturbed(t *testing.T) {
m, _, pwPath := mintGuardManager(t, false)
if err := m.WriteOffboxSecrets("K", "kh"); err != nil {
t.Fatal(err)
}
before, err := os.ReadFile(pwPath)
if err != nil {
t.Fatal(err)
}
// Now the hub starts holding a package (the ceremony ran) and the target is re-applied.
if err := m.settings.SetHubEscrowIdentityPresent(true); err != nil {
t.Fatal(err)
}
if err := m.WriteOffboxSecrets("K2", "kh2"); err != nil {
t.Fatalf("a re-apply on a box that already has a key must not be refused: %v", err)
}
after, err := os.ReadFile(pwPath)
if err != nil {
t.Fatal(err)
}
if string(before) != string(after) {
t.Error("the existing repository password must never be rotated by an apply")
}
if m.OffboxAwaitingRecoveryKey() {
t.Error("a box holding its key is not awaiting one")
}
}
// Fail-safe: an unreadable settings store must not block a tier. A transient read failure turning
// into a permanently-held tier is a worse defect than the one being fixed.
func TestR241_NilSettingsDoesNotBlockTheMint(t *testing.T) {
logger := log.New(os.Stderr, "", 0)
dataDir := t.TempDir()
cfg := &config.Config{}
cfg.Paths.DataDir = dataDir
m := NewManager(cfg, nil, logger)
if m.sealedPackageHeld() {
t.Fatal("a nil settings store must read as 'no package held' — fail toward letting the box work")
}
}
// Scenario E's carve-out, pinned for the HOLDING state too. A customer who switched off-site off is
// not awaiting a recovery key, and must not declare one. The first draft of
// OffboxAwaitingRecoveryKey omitted `t.Enabled` and TestOffsiteDeclare_DisabledTargetIsNotStranded
// caught it; this test pins the same invariant from the new predicate's own side, so a future edit
// to THIS function fails here rather than in a neighbouring file.
func TestR241_DisabledTargetIsNotAwaitingAnything(t *testing.T) {
m, sett, _ := mintGuardManager(t, true) // the hub holds a package, and there is no key
if err := sett.SetOffboxTarget(&settings.OffboxTarget{
Enabled: false, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo",
}); err != nil {
t.Fatal(err)
}
if m.OffboxAwaitingRecoveryKey() {
t.Fatal("a deliberately DISABLED target must never declare the holding state (Scenario E)")
}
if st := m.OffboxReportStatus(); st != nil {
t.Fatalf("a disabled target must stay silent in the report, got %+v", st)
}
}
@@ -0,0 +1,143 @@
package backup
import (
"os"
"path/filepath"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// R-241 shape (c) — the recovery offer is driven by the comparison the box already makes.
//
// Scenarios C and D from the task, plus §7.2's two staleness cases. The point of shape (c) is that
// it asks the real question — *does the hub hold a package for a key other than the one I am
// using?* — rather than the two proxies that have each now been wrong in opposite directions.
// offerFixture builds a manager holding a repository password, with the hub's cached facts settable.
// Returns the local key's hash so a test can make the hub's hash match or differ deliberately.
func offerFixture(t *testing.T, hubHoldsPackage bool) (*Manager, *settings.Settings, string) {
t.Helper()
m, sett, pwPath := mintGuardManager(t, false) // mint freely first
if err := sett.SetOffboxTarget(&settings.OffboxTarget{
Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo", Schedule: "daily",
}); err != nil {
t.Fatal(err)
}
if err := m.WriteOffboxSecrets("KEYMATERIAL", "nas.local ssh-ed25519 HOSTKEY"); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(pwPath); err != nil {
t.Fatalf("fixture should hold a repository password: %v", err)
}
local, ok := m.OffboxRepoPasswordHash()
if !ok {
t.Fatal("fixture should be able to hash its own key")
}
if err := sett.SetHubEscrowIdentityPresent(hubHoldsPackage); err != nil {
t.Fatal(err)
}
return m, sett, local
}
const otherKeyHash = "9b4a9a9dcec7898e7544f35b18470aac77c3d9064e5d3a302897617fa62edd65"
// ── SCENARIO C — a differing key offers recovery, whatever the reason for the difference ────────
//
// This is the venue's exact state on 2026-08-07: a key present, no orphan recorded, escrow stuck
// pending — and before shape (c), silence.
func TestR241_ScenarioC_DifferingKeyOffersRecovery(t *testing.T) {
m, sett, local := offerFixture(t, true)
if local == otherKeyHash {
t.Fatal("fixture precondition: the local key must differ from the hub's")
}
if err := sett.SetHubEscrowKeySHA256(otherKeyHash, "2026-08-07T03:28:03Z"); err != nil {
t.Fatal(err)
}
// Neither proxy fires: a key EXISTS (so not shape (a)) and nothing is orphaned (so not shape (b)).
if _, ok := m.OffboxRepoPasswordHash(); !ok {
t.Fatal("precondition: shape (a) must be false")
}
if m.OffboxOrphaned() {
t.Fatal("precondition: shape (b) must be false")
}
if !m.OffsiteRecoveryOffer() {
t.Fatal("R-241: the hub holds a package for a DIFFERENT key and the screen was not offered — this is the defect")
}
}
// ── SCENARIO D — a healthy box is never offered recovery ────────────────────────────────────────
//
// RED-PROOF: drop the `hubHash != localHash` conjunct in shape (c) (make it `hubHash != ""`). A
// healthy box is then offered recovery forever, and this test fails — which is how a screen stops
// being read.
func TestR241_ScenarioD_MatchingKeyOffersNothing(t *testing.T) {
m, sett, local := offerFixture(t, true)
if err := sett.SetHubEscrowKeySHA256(local, "2026-08-07T09:00:00Z"); err != nil {
t.Fatal(err)
}
if m.OffsiteRecoveryOffer() {
t.Fatal("a box whose key the hub's package covers must never be offered recovery")
}
}
// A box the hub holds nothing for is never offered, even if a stale hash lingers in settings. Fact 1
// stays required — the spike's comment block calls dropping it "the plausible wrong fix".
func TestR241_ShapeC_NeverHadOffsiteIsStillSilent(t *testing.T) {
m, sett, _ := offerFixture(t, false) // the hub holds NOTHING
if err := sett.SetHubEscrowKeySHA256(otherKeyHash, "2026-08-07T09:00:00Z"); err != nil {
t.Fatal(err)
}
if m.OffsiteRecoveryOffer() {
t.Fatal("a box that never had off-site backups must never be greeted by a recovery screen")
}
}
// ── §7.2 — the staleness decision, both halves ──────────────────────────────────────────────────
// A KNOWN DIFFERENCE OFFERS, however old the reading. Age is deliberately not gated on: gating would
// make a box offline from the hub silently stop offering, which is the failure this session exists
// to remove.
func TestR241_StaleComparison_KnownDifferenceStillOffers(t *testing.T) {
m, sett, _ := offerFixture(t, true)
if err := sett.SetHubEscrowKeySHA256(otherKeyHash, "2020-01-01T00:00:00Z"); err != nil { // ancient
t.Fatal(err)
}
if !m.OffsiteRecoveryOffer() {
t.Fatal("a known difference must offer regardless of how old the reading is (§7.2)")
}
}
// AN ABSENT HASH FALLS BACK TO (a)/(b) — it does not offer. "" is the hub positively saying its
// package seals no repository password (legacy hash-less escrow); there is nothing to compare, and
// offering would put a permanent screen in front of every legacy box.
func TestR241_StaleComparison_AbsentHashFallsBackAndDoesNotOffer(t *testing.T) {
m, sett, _ := offerFixture(t, true)
if err := sett.SetHubEscrowKeySHA256("", ""); err != nil {
t.Fatal(err)
}
if m.OffsiteRecoveryOffer() {
t.Fatal("a hash never learned must fall back to (a)/(b), not offer (§7.2)")
}
// ...and the fallback still works: mark the repo orphaned and shape (b) fires as before.
if err := m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.RepoState = "orphaned" }); err != nil {
t.Fatal(err)
}
if !m.OffsiteRecoveryOffer() {
t.Fatal("shape (b) must still work when the hub's hash was never learned")
}
}
// Shape (a) is untouched: a box with no key at all is still offered, which is the pristine rebuild.
func TestR241_ShapeAStillWorks(t *testing.T) {
m, sett, _ := offerFixture(t, true)
if err := os.Remove(filepath.Join(m.cfg.Paths.DataDir, "offbox", "repo_password")); err != nil {
t.Fatal(err)
}
if err := sett.SetHubEscrowKeySHA256(otherKeyHash, "2026-08-07T09:00:00Z"); err != nil {
t.Fatal(err)
}
if !m.OffsiteRecoveryOffer() {
t.Fatal("shape (a) — no repository password at all — must still offer")
}
}
@@ -197,7 +197,14 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string) (Of
hdd := strings.TrimSpace(m.stackProvider.GetStackHDDPath(stack))
if hdd == "" {
return res, fmt.Errorf("a(z) %s nincs telepítve — előbb állítsd helyre az alkalmazást, utána az adatokat", stack)
// R-253: the same sentence the restore page now shows, so the page and the refusal cannot
// drift apart again. It is a REFUSAL, not a failure — the data is untouched and the customer
// has one step to take. The restore deliberately does NOT deploy the app itself: the
// destination is the app's own HDD path, which is a drive the CUSTOMER chooses at deploy
// time, and picking it for them is the decision this whole recovery path exists to leave
// with them.
return res, fmt.Errorf("a(z) %s nincs telepítve, ezért nincs hová visszaállítani az adatait — "+
"telepítsd újra az alkalmazást (Alkalmazások), utána ez a visszaállítás működni fog", stack)
}
liveNs := m.namespaceRoot(hdd)
@@ -137,6 +137,91 @@ func RunRecoveryCheck(d RecoveryCheckDeps) int {
// refused — a password is present and DIFFERS. Installing would clobber the key this box's CURRENT
// repository is encrypted under, so it is refused. No force option is offered here: that
// decision needs a human who knows which history they intend to keep.
// RecoverInstallOutcome names the terminal states of a recovery+install. Distinct values because
// "it did nothing", "it refused" and "it installed" are different facts and a caller — CLI or web —
// must be able to say which happened without parsing prose.
type RecoverInstallOutcome string
const (
// RecoverInstalled — the box had NO repository password; the recovered one is now in place.
RecoverInstalled RecoverInstallOutcome = "installed"
// RecoverUnchanged — a password was present and is byte-identical to the recovered one.
RecoverUnchanged RecoverInstallOutcome = "unchanged"
// RecoverRefused — a DIFFERENT password is present; installing would clobber the key the box's
// current repository is encrypted under.
RecoverRefused RecoverInstallOutcome = "refused"
// RecoverDryRun — nothing was written because confirm was false.
RecoverDryRun RecoverInstallOutcome = "dry_run"
)
// RecoverInstallResult is the non-secret outcome of a recovery. It carries HASHES ONLY — never the
// password, never R. The hashes are of 256-bit random secrets, non-reversible, and are the same
// values the hub already stores and serves in report ACKs.
type RecoverInstallResult struct {
Outcome RecoverInstallOutcome
LocalPresent bool
LocalSHA256 string
RecoveredSHA256 string
}
// RecoverInstallCore is THE recovery+install path in this codebase — fetch the sealed bundle through
// the agent, unseal it with R, compare against what is on disk, and place it when that is the right
// thing to do.
//
// ONE FUNCTION, TWO CALLERS (R-193). The CLI (`--recover-offsite-install`) and the customer's recovery
// page both call this. They must not each carry a copy: two implementations of the one operation that
// can permanently lose a customer's data would drift, and only one of them would ever be tested.
// `RecoverAndInstall` below is a thin wrapper that maps this result onto the CLI's exit codes and
// printed lines; the web handler maps it onto Hungarian copy. Neither contains recovery logic.
//
// R IS THE CALLER'S TO CLEAR. This function does not retain it: it is passed to the agent seam and
// never stored, logged or returned. The password recovered from the bundle IS cleared here, on every
// path, before returning — it never leaves this function in any form.
//
// The three outcomes and their reasoning are unchanged from the CLI's original implementation; see
// RecoverAndInstall's header, which remains the authority on WHY a differing local password is
// refused rather than forced.
func RecoverInstallCore(ctx context.Context, m *Manager, rec OffsiteKeyRecoverer, R string, confirm bool) (RecoverInstallResult, error) {
var res RecoverInstallResult
if m == nil || rec == nil {
return res, fmt.Errorf("recovery not configured (no backup manager or no agent channel)")
}
pw, recoveredHash, err := rec.RecoverOffsiteRepoPassword(ctx, R)
if err != nil {
return res, err // the agent's message names the step; it carries no secret
}
res.RecoveredSHA256 = recoveredHash
res.LocalSHA256, res.LocalPresent = m.OffboxRepoPasswordHash()
switch {
case res.LocalPresent && res.LocalSHA256 == recoveredHash:
pw = ""
res.Outcome = RecoverUnchanged
return res, nil
case res.LocalPresent:
pw = ""
res.Outcome = RecoverRefused
return res, nil
}
if !confirm {
pw = ""
res.Outcome = RecoverDryRun
return res, nil
}
if err := m.InjectOffboxPassword(pw, false); err != nil {
pw = ""
return res, fmt.Errorf("placing the recovered password: %w", err)
}
pw = ""
// Re-read from disk rather than trusting what we just wrote — the observable is the file's state.
afterHash, ok := m.OffboxRepoPasswordHash()
if !ok || afterHash != recoveredHash {
return res, fmt.Errorf("the password was written but does not read back as expected (on-disk %q)", afterHash)
}
res.Outcome = RecoverInstalled
return res, nil
}
func RecoverAndInstall(d RecoveryCheckDeps, confirm bool) int {
out, errw := d.Out, d.Err
if out == nil {
@@ -172,53 +257,39 @@ func RecoverAndInstall(d RecoveryCheckDeps, confirm bool) int {
defer cancel()
fmt.Fprintln(out, "=== offsite key recovery INSTALL (R-200) ===")
// The recovery itself is the same call the check makes, so there is exactly one fetch+unseal path
// in this codebase and no chance of the two drifting.
pw, recoveredHash, err := d.Recoverer.RecoverOffsiteRepoPassword(ctx, R)
// THE RECOVERY ITSELF IS RecoverInstallCore — the same function the customer's recovery page
// drives (R-193). This wrapper adds the CLI's stdin handling, its printed lines and its exit
// codes, and NOTHING else; there is exactly one fetch→unseal→compare→install path in this
// codebase and no chance of the two callers drifting. Pinned by
// TestRecoverAndInstall_DrivesTheSharedCore and by the AST wiring test.
res, err := RecoverInstallCore(ctx, d.Manager, d.Recoverer, R, confirm)
R = "" // cleared immediately, on every path below
if err != nil {
fmt.Fprintf(errw, " [FAIL] %v\n", err)
fmt.Fprintln(errw, " nothing was written.")
return 1
}
localHash, localPresent := d.Manager.OffboxRepoPasswordHash()
if localPresent {
fmt.Fprintf(out, " on-disk sha256: %s\n", localHash)
if res.LocalPresent {
fmt.Fprintf(out, " on-disk sha256: %s\n", res.LocalSHA256)
} else {
fmt.Fprintln(out, " on-disk sha256: (none — this box has no repository password)")
}
fmt.Fprintf(out, " recovered sha256: %s\n", recoveredHash)
fmt.Fprintf(out, " recovered sha256: %s\n", res.RecoveredSHA256)
switch {
case localPresent && localHash == recoveredHash:
pw = ""
switch res.Outcome {
case RecoverUnchanged:
fmt.Fprintln(out, " [UNCHANGED] the box already holds exactly this key. Nothing written.")
return 0
case localPresent:
pw = ""
case RecoverRefused:
fmt.Fprintln(errw, " [REFUSED] a DIFFERENT repository password is already present.")
fmt.Fprintln(errw, " Installing would clobber the key this box's current repository is encrypted under,")
fmt.Fprintln(errw, " and which history to keep is not a decision this command may take. Nothing written.")
return 2
}
if !confirm {
pw = ""
case RecoverDryRun:
fmt.Fprintln(out, " [DRY RUN] nothing written. The recovered key is ready to install.")
fmt.Fprintln(out, " Re-run with --confirm-install to place it.")
return 0
}
if err := d.Manager.InjectOffboxPassword(pw, false); err != nil {
pw = ""
fmt.Fprintf(errw, " [FAIL] placing the recovered password: %v\n", err)
return 1
}
pw = ""
// Re-read from disk rather than trusting what we just wrote — the observable is the file's state.
afterHash, ok := d.Manager.OffboxRepoPasswordHash()
if !ok || afterHash != recoveredHash {
fmt.Fprintf(errw, " [FAIL] the password was written but does not read back as expected (on-disk %q)\n", afterHash)
return 1
}
fmt.Fprintln(out, " [INSTALLED] the recovered repository password is in place and reads back identical.")
fmt.Fprintln(out, " Re-apply the offsite target and run a backup: the existing repository should open.")
return 0
+30 -1
View File
@@ -171,7 +171,36 @@ func (m *Manager) offboxRestoreScratchDir(stack string) (scratch, nsRoot string,
}
}
}
return "", "", fmt.Errorf("nincs elérhető adatmeghajtó a visszaállításhoz")
// R-252: name the reason AND the way to act on it. This refusal is what a rebuilt box hits — the
// drives are physically fine and still mounted, it is their REGISTRATION that the destroyed guest
// took with it — and until v0.207.0 it said only that a drive was missing, which reads like data
// loss and offers nothing to do.
return "", "", fmt.Errorf("nincs regisztrált adatmeghajtó, ezért nincs hová visszaállítani — " +
"a meghajtók megvannak, csak újra kell csatolni őket a Tárhely → Meghajtók oldalon, utána " +
"ez a visszaállítás működni fog")
}
// HasRestoreDestination reports whether an offsite restore has anywhere on this box to write.
//
// R-252: the restore PAGE asks this question through the same helper the resolver answers it with,
// so the notice cannot appear on a box that would restore fine (Scenario E) nor stay hidden on one
// that would refuse. A second copy of the predicate is exactly how a page ends up promising what the
// handler then refuses — which is the neighbouring defect, R-253.
//
// It mirrors the resolver's BOX-level branches (2) and (3) — the schedulable storage paths. Branch
// (1), the app's own HDD path, is deliberately not consulted: an installed app's HDD path IS a
// registered storage path, so the two cannot disagree in practice, and where they could, erring
// toward showing the notice is erring toward telling the customer something true.
func (m *Manager) HasRestoreDestination() bool {
if m.settings == nil {
return false
}
for _, sp := range m.settings.GetSchedulableStoragePaths() {
if strings.TrimSpace(sp.Path) != "" {
return true
}
}
return false
}
// RestoreOffboxScratch restores an app's latest offsite snapshot to an on-data-drive scratch dir
@@ -0,0 +1,165 @@
package backup
import (
"context"
"strings"
"testing"
)
// R-234 — a run that SKIPPED an app the customer selected is not a successful run.
//
// The same paragraph the R-203 verdict block already carries — "a warning beside a success is read
// as a success" — was applied to one of the two shapes it describes. An app missing a declared
// mandatory FOLDER made the run `incomplete`; an app skipped ENTIRELY, with nothing of it in the
// snapshot at all, still reported `ok`. The smaller gap moved the verdict and the bigger one did not.
//
// Run-level on purpose: the classification and the verdict are both inside the run, and the sibling
// test file records what happened when its first version asserted the capture helper alone — its
// red-proof passed while the defect was untouched.
// Scenario A — a selected, DEPLOYED app with no recovery unit makes the run incomplete, names itself,
// and does not suppress what was captured.
//
// RED-PROOF: drop `unprotected` from the verdict condition (leave only mandatoryGaps) → this FAILS
// with the run reporting ok over a skipped app, which is production behaviour up to v0.204.0.
func TestOffboxRun_SkippedSelectedAppIsIncomplete(t *testing.T) {
drive := t.TempDir()
m, sett, prov := classifiedOffboxManager(t, drive)
// `kept` has a unit and is pushed; `dropped` is selected and deployed but has NO unit, so the
// per-app loop skips it. backedUp>0 is what made the existing no-silent-success guard stay quiet.
mkUnit(t, drive, "kept")
prov.hdd["kept"] = drive
prov.has["kept"] = true
prov.hdd["dropped"] = drive
prov.has["dropped"] = true
prov.deployed = map[string]bool{"kept": true, "dropped": true}
_ = sett.SetAppOffbox("kept", true)
_ = sett.SetAppOffbox("dropped", true)
var gapNotified map[string][]string
m.SetOffboxGapNotify(func(g map[string][]string) { gapNotified = g })
cap := &backupCapture{}
m.SetOffboxRunner(cap.runner())
if err := m.RunOffboxBackup(context.Background()); err != nil {
t.Fatalf("the run itself must SUCCEED — a skipped app is a coverage gap, not a failed run: %v", err)
}
got := sett.GetOffboxTarget()
if got.LastStatus != "incomplete" {
t.Fatalf("LastStatus = %q, want \"incomplete\" — the customer selected an app and the run did not "+
"carry it; on 2026-08-06 this reported „✓ Rendben” and the restore refused minutes later", got.LastStatus)
}
// Scenario A: the counters and the anchor still record what WAS captured.
if got.LastSuccess == "" {
t.Error("LastSuccess must still record what was captured — half a backup is not no backup")
}
if cap.backups != 1 {
t.Errorf("the app that HAD a unit must still be pushed, got %d backup calls", cap.backups)
}
// Scenario E: which app, and why.
if !strings.Contains(got.LastWarning, "dropped") {
t.Errorf("the warning must NAME the skipped app, got %q", got.LastWarning)
}
if !strings.Contains(got.LastWarning, "nincs helyi ment") {
t.Errorf("the warning must say WHY it was skipped, got %q", got.LastWarning)
}
if !strings.Contains(got.LastWarning, "következő ment") {
t.Errorf("the warning must say WHEN it will be protected, got %q", got.LastWarning)
}
// Scenario B: the operator hears about it, in the same vocabulary as a folder gap.
if len(gapNotified["dropped"]) == 0 {
t.Fatalf("the operator signal must carry the skipped app, got %v", gapNotified)
}
}
// Scenario C — a healthy run is untouched. Without this, "always incomplete" would also pass above,
// and a status that is never green is a status that stops being read.
//
// RED-PROOF: count EVERY skip (drop the classification switch and use len(res.missing)) → a healthy
// run goes amber and this FAILS.
func TestOffboxRun_HealthyRunStaysOk(t *testing.T) {
drive := t.TempDir()
m, sett, prov := classifiedOffboxManager(t, drive)
mkUnit(t, drive, "kept")
prov.hdd["kept"] = drive
prov.has["kept"] = true
_ = sett.SetAppOffbox("kept", true)
fired := false
m.SetOffboxGapNotify(func(map[string][]string) { fired = true })
cap := &backupCapture{}
m.SetOffboxRunner(cap.runner())
if err := m.RunOffboxBackup(context.Background()); err != nil {
t.Fatalf("run: %v", err)
}
got := sett.GetOffboxTarget()
if got.LastStatus != "ok" {
t.Fatalf("LastStatus = %q, want ok — every selected app was carried", got.LastStatus)
}
if fired {
t.Error("the operator signal must NOT fire when nothing was missed")
}
if strings.Contains(got.LastWarning, "NEM kerültek be") {
t.Errorf("a healthy run must carry no skip warning, got %q", got.LastWarning)
}
}
// Scenario D — a box with NOTHING selected keeps today's behaviour: ok, with the existing
// zero-selection notice. An unconfigured box reporting incomplete forever is its own defect.
//
// RED-PROOF: count the empty selection as a gap → this box goes permanently amber and this FAILS.
func TestOffboxRun_NothingSelectedIsNotAGap(t *testing.T) {
drive := t.TempDir()
m, sett, _ := classifiedOffboxManager(t, drive)
cap := &backupCapture{}
m.SetOffboxRunner(cap.runner())
if err := m.RunOffboxBackup(context.Background()); err != nil {
t.Fatalf("run: %v", err)
}
got := sett.GetOffboxTarget()
if got.LastStatus != "ok" {
t.Fatalf("LastStatus = %q, want ok — nothing was selected, so nothing was skipped", got.LastStatus)
}
if !strings.Contains(got.LastWarning, "nincs mentésre jelölt alkalmazás") {
t.Errorf("the existing zero-selection notice must survive, got %q", got.LastWarning)
}
}
// Scenario F — a selected app that is NOT deployed. Decided deliberately: it is NAMED with what to do
// about it, and it does NOT move the verdict, because a box left amber forever by an app somebody
// removed is a status nobody reads.
func TestOffboxRun_SelectedButUndeployedIsNamedNotCounted(t *testing.T) {
drive := t.TempDir()
m, sett, prov := classifiedOffboxManager(t, drive)
mkUnit(t, drive, "kept")
prov.hdd["kept"] = drive
prov.has["kept"] = true
prov.deployed = map[string]bool{"kept": true} // "removed-app" deliberately absent
_ = sett.SetAppOffbox("kept", true)
// selected, no unit, and NOT in the deployed set
_ = sett.SetAppOffbox("removed-app", true)
fired := false
m.SetOffboxGapNotify(func(map[string][]string) { fired = true })
cap := &backupCapture{}
m.SetOffboxRunner(cap.runner())
if err := m.RunOffboxBackup(context.Background()); err != nil {
t.Fatalf("run: %v", err)
}
got := sett.GetOffboxTarget()
if got.LastStatus != "ok" {
t.Fatalf("LastStatus = %q, want ok — an app that is not installed cannot be protected, and must "+
"not hold the box amber forever", got.LastStatus)
}
if !strings.Contains(got.LastWarning, "removed-app") {
t.Errorf("the undeployed selection must still be NAMED, got %q", got.LastWarning)
}
if !strings.Contains(got.LastWarning, "vedd ki a kijelöl") {
t.Errorf("it must say what to do about it, got %q", got.LastWarning)
}
if fired {
t.Error("an undeployed app must not raise the operator gap signal")
}
}
@@ -0,0 +1,46 @@
package backup
import (
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// R-252 — THE PAGE'S QUESTION AND THE RESOLVER'S ANSWER ARE THE SAME QUESTION.
//
// The restore page renders its "re-attach your drive" notice from HasRestoreDestination(); every
// restore refuses from offboxRestoreScratchDir(). If those two ever disagree the page either warns a
// box that would restore fine, or stays silent on one that cannot — and the second is R-252 exactly
// as the fifth walk met it. So this asserts the CONSEQUENCE (does the resolver refuse?) against the
// predicate the page renders from, on one fixture.
//
// RED-PROOF: make HasRestoreDestination return true unconditionally and the first block fails — the
// predicate claims a destination while the resolver refuses in the very next assertion.
func TestHasRestoreDestination_AgreesWithTheResolver(t *testing.T) {
m, sett := bareManager(t)
// No registered storage path: the resolver refuses, and the predicate must say so.
if m.HasRestoreDestination() {
t.Error("HasRestoreDestination() is true with no registered storage path — the restore page " +
"would stay silent while every restore refuses")
}
if _, _, err := m.offboxRestoreScratchDir("calibre-web"); err == nil {
t.Fatal("the resolver found a destination with no registered storage path — fixture is wrong, " +
"so the agreement below would prove nothing")
} else if !strings.Contains(err.Error(), "Tárhely") {
t.Errorf("the refusal names no route: %v", err)
}
// Re-attach a drive, exactly as the customer does from Tárhely → Meghajtók.
if err := sett.AddStoragePath(settings.StoragePath{Path: "/mnt/felhom-drives/adatok", Label: "Adatok", Schedulable: true}); err != nil {
t.Fatalf("register storage path: %v", err)
}
if !m.HasRestoreDestination() {
t.Error("HasRestoreDestination() is still false after the drive was re-attached — the notice " +
"would stay on screen after the customer fixed the thing it asked them to fix")
}
if _, _, err := m.offboxRestoreScratchDir("calibre-web"); err != nil {
t.Errorf("the resolver still refuses after a drive was registered: %v", err)
}
}
@@ -341,3 +341,36 @@ func (b *Bridge) ReconcileWhenSettled(gateCtx context.Context) error {
defer cancel()
return b.Reconcile(ctx)
}
// ── R-218, THE CONSUME HALF ──────────────────────────────────────────────────────────────────────
//
// `Reconcile` was correct from the day it shipped and was simply never run again. It fires at
// start-up and once more when the recovery screen drives it (R-219) — and BOTH precede the moment the
// hub has anything staged, because the hub stages in RESPONSE to the declaration those runs come
// before. So the hub held a credential the box would never fetch.
//
// Measured on the R-201 re-walk, 2026-08-06: unlock reconcile 11:43:07 · hub staged 11:44:57 saying
// "the box re-consumes on its next cycle" · a full report cycle ran 11:55:46 · still unconsumed at
// 12:06. A guest command line moved it in 18 seconds — everything was fine except the trigger.
// NeedsCredentialFunc reports whether the box STILL declares it needs a transport credential. It is
// deliberately the box's own published declaration (`backup.OffboxReportStatus().State`) rather than a
// second predicate: the hub acts on that statement, so driving the retry from anything else would let
// the two disagree about whether a retry is wanted.
type NeedsCredentialFunc func() bool
// RetryIfDeclared is ONE tick of the consume half.
//
// It reconciles **only while the box declares a need**, which is what makes it stop: the instant a
// target exists the declaration goes false, this returns immediately, and a healthy box does no work
// and logs nothing. The settle gate is deliberately preserved — `ReconcileWhenSettled` waits for floor
// knowledge exactly as the start-up path does, because the day-0 race it guards is unchanged.
//
// Returns whether a reconcile was ATTEMPTED, so a caller (and a test) can tell "declined to run" from
// "ran and failed" without reading the log.
func (b *Bridge) RetryIfDeclared(ctx context.Context, declared NeedsCredentialFunc) (attempted bool, err error) {
if b == nil || declared == nil || !declared() {
return false, nil
}
return true, b.ReconcileWhenSettled(ctx)
}
@@ -0,0 +1,101 @@
package offsiteapply
import (
"context"
"strings"
"testing"
)
// ── R-218's CONSUME HALF ─────────────────────────────────────────────────────────────────────────
//
// Measured on the R-201 re-walk, 2026-08-06: the hub staged a credential at 11:44:57 and logged
// "the box re-consumes on its next cycle"; a full report cycle ran at 11:55:46; at 12:06 it was still
// unconsumed, and a guest command line applied it in 18 seconds. Everything was correct except that
// nothing ever re-ran the reconcile.
//
// These assert the EFFECT — was a reconcile attempted, and did the tier get applied — not that a
// helper returned a bool.
// ── SCENARIO A — a credential staged AFTER start-up is collected, unaided ────────────────────────
//
// RED-PROOF: make RetryIfDeclared return (false, nil) unconditionally — i.e. remove the retry, which
// is the pre-v0.203.0 world — and this FAILS with the credential still sitting unconsumed. That is
// the re-walk's first dead end, reproduced.
func TestRetryIfDeclared_CollectsACredentialStagedAfterStartup(t *testing.T) {
b, cons, _, en, _ := newBridge(t, goodOffsite())
// The box declares: it was rebuilt, has no target, and the hub holds a package for it.
attempted, err := b.RetryIfDeclared(context.Background(), func() bool { return true })
if err != nil {
t.Fatalf("retry: %v", err)
}
if !attempted {
t.Fatal("R-218 RETURNED: the box declared a need and no reconcile was attempted")
}
// EFFECT: the one-time password was actually consumed and the tier configured.
if cons.calls == 0 {
t.Fatal("the staged credential was never collected")
}
if en.calls == 0 {
t.Fatal("the off-site tier was never configured after collecting the credential")
}
}
// ── SCENARIO C — a healthy box does not retry, and makes no noise ────────────────────────────────
//
// RED-PROOF: drop the `!declared()` guard so the tick always reconciles → this FAILS, and a box whose
// tier already works hammers the hub forever.
func TestRetryIfDeclared_HealthyBoxDoesNothing(t *testing.T) {
b, cons, inst, en, logbuf := newBridge(t, goodOffsite())
attempted, err := b.RetryIfDeclared(context.Background(), func() bool { return false })
if err != nil {
t.Fatalf("retry: %v", err)
}
if attempted {
t.Fatal("a box that declares NO need must not reconcile")
}
if cons.calls != 0 || inst.calls != 0 || en.calls != 0 {
t.Fatalf("a healthy box touched the hub: consume=%d install=%d enable=%d", cons.calls, inst.calls, en.calls)
}
if strings.TrimSpace(logbuf.String()) != "" {
t.Fatalf("a healthy box logged noise every tick: %q", logbuf.String())
}
}
// A nil bridge (off-site not configured for this customer) is a silent no-op, not a panic — main.go
// wires nil in exactly that case.
func TestRetryIfDeclared_NilBridgeIsSilent(t *testing.T) {
var b *Bridge
attempted, err := b.RetryIfDeclared(context.Background(), func() bool { return true })
if attempted || err != nil {
t.Fatalf("a nil bridge must be a silent no-op, got attempted=%v err=%v", attempted, err)
}
}
// ── SCENARIO D — the settle gate still holds on the retry path ───────────────────────────────────
//
// The retry must not become a back door around the day-0 floor race the gate exists for. It goes
// through ReconcileWhenSettled, so an unsettled box WAITS rather than reconciling immediately.
//
// RED-PROOF: change RetryIfDeclared to call Reconcile directly instead of ReconcileWhenSettled →
// this FAILS, because the reconcile happens while the floor is still unknown.
func TestRetryIfDeclared_HonoursTheSettleGate(t *testing.T) {
b, cons, _, _, _ := newBridge(t, goodOffsite())
// Floor never becomes known → the gate must hold the reconcile off until its own bound expires.
b.Settle = SettleFunc(func() (string, string, bool, bool) { return "0.203.0", "", false, false })
ctx, cancel := context.WithCancel(context.Background())
cancel() // the gate observes a cancelled context and must not proceed to reconcile
attempted, err := b.RetryIfDeclared(ctx, func() bool { return true })
if !attempted {
t.Fatal("the box declared, so a retry attempt must be reported even when the gate stops it")
}
if err == nil {
t.Fatal("a cancelled gate must surface an error, not silently reconcile")
}
if cons.calls != 0 {
t.Fatal("SETTLE GATE BYPASSED: the retry consumed a password while the floor was unknown")
}
}
@@ -20,6 +20,22 @@ type EscrowStatus struct {
IdentityBlobPresent bool `json:"identity_blob_present"`
ResticPwSHA256 string `json:"restic_pw_sha256"`
CreatedAt string `json:"created_at"`
// Stale (R-247 / R-260, v0.209.0) — the hub has FLAGGED this host's escrow row stale and is
// therefore WITHHOLDING ResticPwSHA256 rather than having no hash to send.
//
// The hub has sent this on every ACK since v0.57.0 (`json:"escrow_stale,omitempty"` on
// store.EscrowStatus). This struct had no field for it, so encoding/json discarded it on
// arrival and an empty hash had exactly one possible reading here: "hash-less supersession".
// That reading was FALSE on demo-hp for four days and the box told the customer so in its own
// words — the hub had the hash, the bundle did cover the password, and there had been no
// supersession (R-246). The answer was on the wire the whole time and was dropped at the
// boundary; a gate now refuses that shape (`felhom.eu/scripts/wire_contract_gate.py`).
Stale bool `json:"escrow_stale"`
// SupersededPresent / SupersededAt (v0.201.0, R-222) — the hub is ALSO keeping an earlier sealed
// package, and when it was set aside. Absent on a pre-0.97.0 hub, which reads as "no earlier
// package" and simply keeps today's message: an older hub cannot make the screen say anything new.
SupersededPresent bool `json:"superseded_present"`
SupersededAt string `json:"superseded_at"`
}
// EscrowAutoConfirmer runs the auto-confirm check on each report ACK. Long-lived (one per process) so
@@ -40,11 +56,51 @@ type EscrowAutoConfirmer struct {
Flip func() error
// Wipe removes the agent-staged secret (best-effort — the flip is the primary effect).
Wipe func(ctx context.Context) error
// RecordPresence persists the ACK's `identity_blob_present` — whether the HUB holds a sealed
// recovery package for this box (v0.199.0, R-204 item 4 / R-193).
//
// WHY IT LIVES HERE, in the auto-confirmer, rather than in its own ACK consumer: this is already
// the ONE place the ACK's escrow object arrives, and it is already wired. A second Reconcile call
// in main.go would be a second wiring point, and this project's count of features built but never
// wired is six. Pinned by TestEscrowConfirm_RecordsPresenceEvenWhenOffboxUnconfigured and by the
// wiring test.
//
// It is called FIRST, before every gate below, and that ordering is the whole fix: on a box with
// no off-site target `Pending()` and `Escrowed()` are both false and Reconcile returned
// immediately, so the one fact that distinguishes a REBUILT box from a box that never had
// off-site backups was thrown away on every cycle. nil → not recorded (older wiring, tests).
RecordPresence func(present bool) error
// RecordSuperseded persists the ACK's `superseded_present` / `superseded_at` (v0.201.0, R-222) —
// whether the hub is ALSO keeping an EARLIER sealed package for this box. Wired here for the same
// reason as RecordPresence: this is the one place the ACK's escrow object already arrives, and a
// second wiring point in main.go is how this project accumulated six features that were built and
// never wired. nil → not recorded (older wiring, tests).
RecordSuperseded func(present bool, at string) error
// RecordEscrowKeyHash persists the ACK's `restic_pw_sha256` — the hash of the repository password
// the hub's sealed package COVERS — with the time it was recorded (v0.206.0, R-241).
//
// WHY IT LIVES HERE, and it is the point of the whole change: the comparison between this hash and
// the local key is ALREADY MADE in Reconcile, on every ACK, and has been since SLICE 3 — and the
// result was used for one warning line and then discarded. On the final-walk venue that line
// (03:28:03Z) was the correct answer to the recovery screen's real question, thirty-five minutes
// before the customer looked at a screen that could not see it.
//
// Recorded UNCONDITIONALLY, before every gate below, for exactly the reason RecordPresence is: the
// box that needs this most is the rebuilt one with no configured target, on which `Pending()` and
// `Escrowed()` are both false and Reconcile used to return immediately. nil → not recorded.
RecordEscrowKeyHash func(sha, checkedAt string) error
// Now returns the current time; nil → time.Now. Injected so the persisted "when we last heard"
// stamp is testable without a sleep.
Now func() time.Time
Logger *log.Logger
mu sync.Mutex
warnedHash string // last mismatched hub hash we warned about (dedupe; shared by both branches)
stale bool // Scenario F: the hub blob does not cover the CURRENT password (display-only)
// sealedAt is the ACK's escrow created_at (v0.200.0, R-193) — the ONE non-secret fact the
// recovery screen may state before a code is entered. Recorded on every ACK that carries an
// escrow object, including on an unconfigured box, for the same reason RecordPresence is.
sealedAt string
}
// staleHashlessMarker is the warnedHash dedupe sentinel for the hash-less supersession case
@@ -62,6 +118,14 @@ func (c *EscrowAutoConfirmer) StaleBlob() bool {
return c.stale
}
// SealedAt returns the ACK-reported creation time of the hub's sealed recovery package ("" when no
// ACK has carried one). In-memory, recomputed from ACKs after a restart — the hub is the authority.
func (c *EscrowAutoConfirmer) SealedAt() string {
c.mu.Lock()
defer c.mu.Unlock()
return c.sealedAt
}
func (c *EscrowAutoConfirmer) logf(f string, a ...any) {
if c.Logger != nil {
c.Logger.Printf(f, a...)
@@ -76,6 +140,34 @@ func (c *EscrowAutoConfirmer) Reconcile(es *EscrowStatus) {
if es == nil {
return
}
// FIRST, unconditionally — see RecordPresence. Every gate below is allowed to skip the
// auto-confirm; none of them may skip this, because an unconfigured box is exactly the case that
// needs the fact. A record failure is logged and does NOT stop the auto-confirm: the two are
// independent, and swallowing it silently is the shape this project keeps removing.
if c.RecordPresence != nil {
if err := c.RecordPresence(es.IdentityBlobPresent); err != nil {
c.logf("[WARN] [escrow-confirm] could not record the hub's identity-blob presence (present=%v): %v", es.IdentityBlobPresent, err)
}
}
// Same discipline, same place, same reason (R-222): recorded unconditionally, because the box that
// needs it most is the unconfigured rebuilt one every gate below skips. Failure is logged, never
// swallowed, and never blocks the auto-confirm.
if c.RecordSuperseded != nil {
if err := c.RecordSuperseded(es.SupersededPresent, es.SupersededAt); err != nil {
c.logf("[WARN] [escrow-confirm] could not record the hub's superseded-package state (present=%v): %v", es.SupersededPresent, err)
}
}
// R-241: persist the hash the hub's package covers, with the moment we heard it. Same discipline,
// same place, same reason as the two above — and this one is the fact the recovery screen has been
// unable to see. A record failure is logged, never swallowed, and never blocks the auto-confirm.
if c.RecordEscrowKeyHash != nil {
if err := c.RecordEscrowKeyHash(es.ResticPwSHA256, c.now().UTC().Format(time.RFC3339)); err != nil {
c.logf("[WARN] [escrow-confirm] could not record the hub's escrowed-key hash (%.12s…): %v", es.ResticPwSHA256, err)
}
}
c.mu.Lock()
c.sealedAt = es.CreatedAt // in-memory only; a timestamp, never a secret
c.mu.Unlock()
if !c.Pending() {
// Scenario F (v0.127.0): an ESCROWED box re-checks the hash on every ACK — a superseding
// blob that does not cover the current password must be surfaced (warn + card flag), while
@@ -154,8 +246,22 @@ func (c *EscrowAutoConfirmer) reconcileEscrowed(es *EscrowStatus) {
return
}
if hubHash == "" {
// TWO DIFFERENT CONDITIONS, told apart at last (R-247). Before v0.209.0 both printed the
// second sentence, and on a withheld hash every clause of it was false.
if es.Stale {
c.logf("[WARN] [escrow-confirm] STALE escrow: the hub has FLAGGED this box's escrow row stale and is WITHHOLDING the password hash, so this box cannot verify its bundle either way — it is NOT established that the bundle fails to cover the offsite password. Clearing the flag is an operator act (hub-side); a new recovery code (wizard /backup/escrow) also resolves it. State stays escrowed; runs continue")
return
}
c.logf("[WARN] [escrow-confirm] STALE escrow: the hub's current blob carries NO password hash (hash-less supersession) — the stored recovery bundle does not cover the offsite password; create a new recovery code (wizard /backup/escrow). State stays escrowed; runs continue")
return
}
c.logf("[WARN] [escrow-confirm] STALE escrow: the hub's current blob does not cover the CURRENT repo password (hub hash %.12s… != local %.12s…) — create a new recovery code (wizard /backup/escrow). State stays escrowed; runs continue", hubHash, localHash)
}
// now returns the injected clock or time.Now.
func (c *EscrowAutoConfirmer) now() time.Time {
if c.Now != nil {
return c.Now()
}
return time.Now()
}
@@ -0,0 +1,81 @@
package report
import (
"io"
"log"
"testing"
)
// R-204 item 4 / R-193 — the ACK's `identity_blob_present` must be recorded on EVERY ACK, including
// (especially) on a box with no off-site target.
//
// THE DEFECT THIS PINS: Reconcile returns early when the box is neither pending nor escrowed, which
// is exactly a REBUILT box's state — so the one fact that distinguishes it from a box that never had
// off-site backups was discarded on every cycle. The recorder therefore runs BEFORE every gate, and
// the test drives Reconcile itself rather than calling the recorder, because the ordering IS the fix.
func TestEscrowConfirm_RecordsPresenceEvenWhenOffboxUnconfigured(t *testing.T) {
var recorded []bool
c := &EscrowAutoConfirmer{
// The unconfigured-box shape: neither pending nor escrowed. Every gate below will skip.
Pending: func() bool { return false },
Escrowed: func() bool { return false },
LocalHash: func() (string, bool) { return "", false },
Flip: func() error { t.Fatal("an unconfigured box must never flip"); return nil },
RecordPresence: func(p bool) error { recorded = append(recorded, p); return nil },
Logger: log.New(io.Discard, "", 0),
}
c.Reconcile(&EscrowStatus{IdentityBlobPresent: true, ResticPwSHA256: "SHA1"})
if len(recorded) != 1 || !recorded[0] {
t.Fatalf("presence not recorded on an unconfigured box: %v — a rebuilt box cannot learn the hub holds its recovery package", recorded)
}
// It must also record the NEGATIVE, so a customer RESET (the hub losing its escrow row) turns the
// box's declaration back off. A set-only flag would strand the declaration forever.
c.Reconcile(&EscrowStatus{IdentityBlobPresent: false})
if len(recorded) != 2 || recorded[1] {
t.Fatalf("a false presence was not recorded: %v", recorded)
}
}
// A nil ACK escrow object records nothing (an old hub, or no escrow row) — absence of a statement is
// not a statement of absence, and overwriting a known-true with false here would un-declare a genuinely
// stranded box every time an old hub answered.
func TestEscrowConfirm_NilAckRecordsNothing(t *testing.T) {
called := false
c := &EscrowAutoConfirmer{
Pending: func() bool { return false },
Escrowed: func() bool { return false },
RecordPresence: func(bool) error { called = true; return nil },
Logger: log.New(io.Discard, "", 0),
}
c.Reconcile(nil)
if called {
t.Fatal("a nil ACK escrow object must not record a presence")
}
}
// A recorder FAILURE must be logged, not swallowed, and must not stop the auto-confirm — the two are
// independent concerns and a failed settings write must not also break escrow confirmation.
func TestEscrowConfirm_RecordFailureDoesNotBlockAutoConfirm(t *testing.T) {
flipped := false
c := &EscrowAutoConfirmer{
Pending: func() bool { return true },
Escrowed: func() bool { return false },
LocalHash: func() (string, bool) { return "MATCH", true },
Flip: func() error { flipped = true; return nil },
RecordPresence: func(bool) error { return errRecord },
Logger: log.New(io.Discard, "", 0),
}
c.Reconcile(&EscrowStatus{IdentityBlobPresent: true, ResticPwSHA256: "MATCH"})
if !flipped {
t.Fatal("a presence-record failure blocked the escrow auto-confirm — they are independent")
}
}
type recordErr struct{}
func (recordErr) Error() string { return "record failed" }
var errRecord = recordErr{}
@@ -0,0 +1,126 @@
package report
import (
"go/ast"
"go/parser"
"go/token"
"testing"
)
// TestMainWiresRecordPresence — the seam-discipline test (§9 rule 6).
//
// `RecordPresence` is a nil-able field: an unwired confirmer compiles, every test in this package
// passes, the fleet reports nothing new, and the whole of R-204 item 4 is inert. That is this
// project's most-repeated failure shape — six features built and never wired, one of them an off-site
// restage event that existed and never fired once.
//
// It walks the AST of main.go rather than grepping the file, because a commented-out field still
// contains the string (the lesson from the lifecycle-gate wiring test next door), and it parses with
// comments DROPPED so a commented assignment cannot satisfy it.
func TestMainWiresRecordPresence(t *testing.T) {
const mainPath = "../../cmd/controller/main.go"
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, mainPath, nil, 0) // comments dropped on purpose
if err != nil {
t.Fatalf("parse %s: %v — the wiring of RecordPresence is now unasserted", mainPath, err)
}
found := false
sawConfirmerLiteral := false
ast.Inspect(f, func(n ast.Node) bool {
lit, ok := n.(*ast.CompositeLit)
if !ok {
return true
}
// Match `report.EscrowAutoConfirmer{...}` (and a bare `EscrowAutoConfirmer{...}`).
name := ""
switch t := lit.Type.(type) {
case *ast.SelectorExpr:
name = t.Sel.Name
case *ast.Ident:
name = t.Name
}
if name != "EscrowAutoConfirmer" {
return true
}
sawConfirmerLiteral = true
for _, el := range lit.Elts {
kv, ok := el.(*ast.KeyValueExpr)
if !ok {
continue
}
if k, ok := kv.Key.(*ast.Ident); ok && k.Name == "RecordPresence" {
found = true
}
}
return true
})
// Distinguish "the literal moved" from "the field was dropped" — otherwise a refactor that
// relocated the confirmer would read as a passing test over nothing (the §12 rule: an absent
// thing is not evidence).
if !sawConfirmerLiteral {
t.Fatalf("no EscrowAutoConfirmer composite literal found in %s — did the wiring move? This test can no longer see it", mainPath)
}
if !found {
t.Fatal("EscrowAutoConfirmer is constructed WITHOUT RecordPresence — the box will never learn the hub holds its recovery package, and R-204 item 4 ships inert")
}
}
// confirmerFieldIsWired is the generalised form of the walk above: it reports whether
// `EscrowAutoConfirmer{...}` in main.go assigns `field`, and whether the literal was found at all.
// Comments are dropped on purpose, so a commented-out assignment cannot satisfy it.
func confirmerFieldIsWired(t *testing.T, field string) (found, sawLiteral bool) {
t.Helper()
const mainPath = "../../cmd/controller/main.go"
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, mainPath, nil, 0)
if err != nil {
t.Fatalf("parse %s: %v — the wiring of %s is now unasserted", mainPath, err, field)
}
ast.Inspect(f, func(n ast.Node) bool {
lit, ok := n.(*ast.CompositeLit)
if !ok {
return true
}
name := ""
switch tt := lit.Type.(type) {
case *ast.SelectorExpr:
name = tt.Sel.Name
case *ast.Ident:
name = tt.Name
}
if name != "EscrowAutoConfirmer" {
return true
}
sawLiteral = true
for _, el := range lit.Elts {
kv, ok := el.(*ast.KeyValueExpr)
if !ok {
continue
}
if k, ok := kv.Key.(*ast.Ident); ok && k.Name == field {
found = true
}
}
return true
})
return found, sawLiteral
}
// TestMainWiresRecordEscrowKeyHash — R-241's seam-discipline test, and it matters more than most.
//
// `RecordEscrowKeyHash` is nil-able exactly like `RecordPresence`. Unwired, the confirmer still
// compiles, every test in this package still passes, the auto-confirm still works — and
// `OffsiteRecoveryOffer`'s shape (c) reads an empty hash forever, silently falling back to the two
// proxies that R-241 proved insufficient. **The fix would ship inert, in precisely the shape the
// spike found: a correct answer computed and kept nowhere.**
func TestMainWiresRecordEscrowKeyHash(t *testing.T) {
found, sawLiteral := confirmerFieldIsWired(t, "RecordEscrowKeyHash")
if !sawLiteral {
t.Fatal("no EscrowAutoConfirmer composite literal found in main.go — did the wiring move? This test can no longer see it")
}
if !found {
t.Fatal("EscrowAutoConfirmer is constructed WITHOUT RecordEscrowKeyHash — the hub's escrowed-key hash is never persisted, so the recovery screen's shape (c) can never fire and R-241 ships inert")
}
}
@@ -0,0 +1,40 @@
package settings
import (
"strings"
"testing"
)
// ── SCENARIO G (R-220) — AN EMPTY LIST MUST EXPLAIN ITSELF ──────────────────────────────────────
//
// The old refusal said "choose an attached drive from the list" while the list was empty — on a
// rebuilt box, for a reason the customer had no part in and could not see. Measured three times live.
// A refusal that names an action the customer cannot perform is the I3 breach the campaign recorded.
//
// RED-PROOF: restore the old sentence and this FAILS on the impossible-action assertion.
func TestRefuseAppNamespace_DoesNotNameAnImpossibleAction(t *testing.T) {
msg := refuseAppNamespaceUndeterminable
if strings.Contains(msg, "Válasszon a listából") {
t.Fatal("R-220's I3 breach RETURNED: the refusal tells the customer to choose from a list that may be empty")
}
// It must say WHY the list can be empty — the rebuild — so the state is explicable.
if !strings.Contains(msg, "újratelepítettük") && !strings.Contains(msg, "újra") {
t.Fatalf("the refusal must explain why the drive is unregistered; got %q", msg)
}
// And point somewhere a customer can actually go.
if !strings.Contains(msg, "Meghajtók") {
t.Fatalf("the refusal must name where re-attaching happens; got %q", msg)
}
// It must not promise an outcome it cannot know.
for _, forbidden := range []string{"biztosan", "garantál", "mindig sikerül"} {
if strings.Contains(msg, forbidden) {
t.Errorf("the refusal promises an outcome it cannot know (%q)", forbidden)
}
}
// The NAS refusal is a different situation and keeps its own wording — it points at a list that
// genuinely does have entries, so it is not the same defect.
if !strings.Contains(refuseAppNamespaceNetwork, "Válasszon csatlakoztatott meghajtót") {
t.Fatal("the NAS refusal was changed; it is a different situation and was not part of R-220")
}
}
+385 -2
View File
@@ -55,6 +55,101 @@ type Settings struct {
// re-enabled it on every read — this replaces it).
OffboxEnlargeNoticeSeeded bool `json:"offbox_enlarge_notice_seeded,omitempty"`
// HubEscrowIdentityPresent (v0.199.0, R-204 item 4 / R-193) caches the report ACK's
// `escrow.identity_blob_present` — whether the HUB is holding a sealed recovery package for this
// box. It is recorded on EVERY ACK that carries an escrow object, including when no off-site
// target is configured, which is the whole point: until now that field was read only by the
// auto-confirm, which returns early on an unconfigured box, so a REBUILT box threw away the one
// fact that distinguishes it from a box that never had off-site backups.
//
// It is half of the "I am rebuilt and stranded" predicate (see backup.OffboxReportStatus). The
// other half is local: no repository password on disk. **Freshness alone is a box that never had
// off-site backups; an escrow alone is a healthy box. Only both together mean rebuilt.**
//
// Cached, not derived: a rebuilt box has an empty settings.json, so this is re-learned from its
// first ACK — which is correct, because the hub is the authority on what the hub holds.
HubEscrowIdentityPresent bool `json:"hub_escrow_identity_present,omitempty"`
// HubEscrowSupersededPresent / HubEscrowSupersededAt (v0.201.0, R-222) cache the report ACK's
// `escrow.superseded_present` / `superseded_at` — whether the hub is ALSO keeping an EARLIER
// sealed package for this box, and when it was set aside.
//
// They exist for one message and nothing else. Without them the recovery screen cannot tell a
// genuinely wrong recovery code from a code that is RIGHT about an earlier package, because both
// look identical from here: the unseal fails closed against the package the hub currently serves.
// Measured on 2026-08-05 (CAMPAIGN-11 Phase 3 step 7) — the customer entered the correct code for
// their orphaned history and was told to check their typing.
//
// ⚠ These grant NO read path. The hub serves the CURRENT package only; retrieving a superseded one
// is a link that has never been built (R-199's inventory). The screen may say an earlier package is
// kept and when — both true, both non-secret — and must not promise it can be opened.
HubEscrowSupersededPresent bool `json:"hub_escrow_superseded_present,omitempty"`
HubEscrowSupersededAt string `json:"hub_escrow_superseded_at,omitempty"`
// HubEscrowKeySHA256 / HubEscrowKeyCheckedAt (v0.206.0, R-241) cache the report ACK's
// `escrow.restic_pw_sha256` — the sha256 of the repository password the hub's sealed package
// COVERS — and when it was last recorded.
//
// ⚠ THIS FACT WAS ALREADY COMPUTED EVERY CYCLE AND KEPT NOWHERE, and that is the whole of R-241's
// second half. `report.EscrowAutoConfirmer.Reconcile` has compared this hash against the local key
// on every ACK since SLICE 3; on the final-walk venue it logged, at 03:28:03Z and thirty-five
// minutes before the customer looked, *"the hub's escrow blob does not cover the CURRENT repo
// password (hub hash 30ef574f… != local 9b4a9a9d…)"* — and then dropped it on the floor. The
// recovery screen, evaluating in the same process, went on asking a question that could not see it.
//
// It is the ONE fact that answers the screen's real question directly: *does the hub hold a package
// for a key other than the one I am using?* Shape (a) ("no key at all") and shape (b) ("a run
// proved the repo will not open") are both proxies for it, and both have now been wrong in
// opposite directions — (a) goes false the moment anything mints, (b) is unreachable while the
// escrow is pending.
//
// NON-SECRET: the sha256 of a 256-bit random secret is non-reversible and is already logged and
// served over the ACK. It must still never reach a customer-facing message.
//
// EMPTY IS MEANINGFUL AND IS NOT "THEY DIFFER": the hub sends "" for a legacy hash-less escrow
// (a package that provably seals no repository password). Shape (c) requires a NON-EMPTY hash —
// see backup.OffsiteRecoveryOffer for the staleness reasoning.
HubEscrowKeySHA256 string `json:"hub_escrow_key_sha256,omitempty"`
HubEscrowKeyCheckedAt string `json:"hub_escrow_key_checked_at,omitempty"` // RFC3339
// RecoveryNoticePostponed (v0.200.0, R-193) — the customer chose "most nem" on the full-page
// recovery screen. It suppresses THE FULL-PAGE INTERRUPTION ONLY. The entry point in the backups
// area stays, permanently, for as long as the situation lasts: the data is still there whether or
// not anyone clicked, and a one-shot notice a flustered person clicks past is a notice that never
// happened. It is deliberately NOT cleared by anything except the situation ending.
RecoveryNoticePostponed bool `json:"recovery_notice_postponed,omitempty"`
// ── THE OFFER EPOCH (v0.206.0, R-241) ───────────────────────────────────────────────────────
//
// RecoveryOfferEpoch counts ENTRIES into the offered state; RecoveryOfferActive is the edge
// detector that makes counting possible. Together they turn "once ever" into "once per entry".
//
// ⚠ WHY THIS IS NOT THE FLAG §2.1 FORBIDS. That ruling forbids remembering *that the customer
// decided* so the screen can be suppressed while the underlying state stays wrong. These record
// something else entirely: WHICH SITUATION a dismissal was about. A box that abandons, is rebuilt
// months later and enters the offered state afresh is in a NEW situation, and a dismissal of the
// old one must not swallow it. `RecoveryNoticePostponed` alone did exactly that — it was
// deliberately never cleared by anything.
//
// RecoveryNoticePostponedEpoch / RecoveryRemindOptOutEpoch record the epoch a choice was made in.
// The full page interrupts while `Epoch > PostponedEpoch`, and the banner reminds while
// `Epoch > OptOutEpoch` — so a fresh entry resets BOTH by arithmetic, with nothing to clear and
// nothing that can be forgotten to clear (§7.1 condition 2).
RecoveryOfferEpoch int `json:"recovery_offer_epoch,omitempty"`
RecoveryOfferActive bool `json:"recovery_offer_active,omitempty"`
// RecoveryOfferSince (RFC3339) stamps when the CURRENT epoch began — the anchor the undecided
// reminders escalate against (§2.3). Re-stamped on every entry, so a box that settles and is later
// rebuilt starts its reminder ladder again rather than inheriting an old one.
RecoveryOfferSince string `json:"recovery_offer_since,omitempty"`
RecoveryNoticePostponedEpoch int `json:"recovery_notice_postponed_epoch,omitempty"`
// RecoveryRemindOptOutEpoch — the customer ticked „ne emlékeztessen újra" in this epoch.
//
// It silences THE BANNER AND NOTHING ELSE (§7.1 condition 3). It is not an abandonment, it starts
// no countdown, and the entry point on the backups page never goes away because of it — silencing
// a reminder is not the same as removing the route, and this session exists partly because a route
// disappeared.
RecoveryRemindOptOutEpoch int `json:"recovery_remind_optout_epoch,omitempty"`
// Cached state
DBValidations map[string]DBValidationCache `json:"db_validations,omitempty"`
@@ -187,6 +282,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"`
@@ -224,8 +335,69 @@ 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"`
// ── ABANDONMENT (v0.206.0, R-241) — deciding to give up the old history is a FINISHABLE thing ──
//
// Until now "set aside" renamed the remote store and touched nothing else, so the hub went on
// holding a package for a key the box no longer used — and the recovery question came back at
// every login, for ever. The operator's ruling (2026-08-07) is that the answer is NOT to remember
// that the customer decided, but to reach a state where the question stops arising: **fix the
// state, do not remember that it is wrong.** These fields are that countdown.
//
// ⚠ THEY ARE NOT A "THEY DECIDED" FLAG, and the distinction is the ruling. Nothing here suppresses
// the recovery offer — the offer stays reachable for the whole 14 days, because a grace period in
// which nothing can be done is decorative (Scenario E). What ends the offer is the TERMINAL STEP
// removing the store and the sealed package together, after which shape (c) has nothing to
// compare and falls silent on its own.
//
// AbandonStartedAt / AbandonAt are RFC3339; AbandonRepoPath is the move-aside path the terminal
// step deletes. AbandonRepoPath is separate from OrphanedRenamedTo deliberately: that field is
// overwritten by the NEXT reset, and a countdown that loses the path it is counting down to would
// delete nothing and report success.
AbandonStartedAt string `json:"abandon_started_at,omitempty"`
AbandonAt string `json:"abandon_at,omitempty"`
AbandonRepoPath string `json:"abandon_repo_path,omitempty"`
// AbandonPurgeRequested — the remote store IS deleted and the hub has not yet confirmed the sealed
// package is gone. It is a two-phase-commit marker for an operation in flight, NOT a memory of a
// decision: it is set by the terminal step, declared in the report, and cleared the moment the
// hub's ACK stops reporting a superseded package. If the two halves could not be removed together
// this marker is what makes the box keep asking until they are (Scenario F).
AbandonPurgeRequested bool `json:"abandon_purge_requested,omitempty"`
// AbandonPinnedEscrowKeySHA256 (R-302) — the hub's escrow key fingerprint AS CACHED AT THE MOMENT
// THE CUSTOMER DECIDED. It is NOT the current key and NOT re-read: the banner's retrieval promise
// is rendered only while the hub is still holding that same package.
//
// ⚠ IT IS A RECORDED ASSUMPTION, NOT A PROOF, AND THAT IS DELIBERATE. Nothing on the box records
// which key wrote the set-aside copies — that key is gone, which is why they were set aside. What
// is pinned is the package the hub held at the decision, which in the ordinary rebuilt-box story IS
// the pre-rebuild escrow covering the pre-rebuild repo password, i.e. the one that wrote them. On a
// TWICE-rebuilt box that presumption can be wrong: the hub may hold rebuild #2's package while the
// set-aside copies are rebuild #1's, and no key on file opens them. This pin cannot detect that.
//
// What it DOES detect, and what the rejected alternative could not: the package being REPLACED
// after the decision — a fresh escrow ceremony, which is exactly the act that cost both demo boxes
// their history on 2026-08-04. The rejected proxy (hub fingerprint vs the CURRENT key, compared at
// render) answers "does the hub hold a different key?", which is TRUE in the co-render case and so
// would promise precisely where the promise is least safe.
//
// EMPTY IS MEANINGFUL AND IS NOT A MATCH: the hub sends "" for a legacy package that provably seals
// no repository password, and a countdown started before R-302 has no pin at all. Both take the
// cautious branch. NEVER rendered, logged or reported — it is the hash of a secret.
AbandonPinnedEscrowKeySHA256 string `json:"abandon_pinned_escrow_key_sha256,omitempty"`
}
// CrossDriveBackup configures per-app backup to a secondary drive.
@@ -601,6 +773,73 @@ func (s *Settings) SetLauncherSharePasswordHash(hash string) error {
return s.save()
}
// ── Hub-held recovery package (v0.199.0, R-204 item 4) ─────────────────────────
// GetHubEscrowIdentityPresent reports whether the hub is holding a sealed identity/recovery package
// for this box, as last stated by a report ACK. False when no ACK has carried an escrow object yet.
func (s *Settings) GetHubEscrowIdentityPresent() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.HubEscrowIdentityPresent
}
// SetHubEscrowIdentityPresent records the ACK's `escrow.identity_blob_present`. It is a plain
// last-write-wins mirror of the hub's statement — NOT set-only, because the hub losing an escrow row
// (a customer RESET) must be able to turn the box's declaration back off. Saves only on a change, so
// the ordinary 15-minute ACK does not rewrite settings.json every cycle.
func (s *Settings) SetHubEscrowIdentityPresent(present bool) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.HubEscrowIdentityPresent == present {
return nil
}
s.HubEscrowIdentityPresent = present
return s.save()
}
// GetHubEscrowSuperseded reports whether the hub is keeping an EARLIER sealed package for this box,
// and when it was set aside (R-222). Both zero until an ACK has said so.
func (s *Settings) GetHubEscrowSuperseded() (present bool, at string) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.HubEscrowSupersededPresent, s.HubEscrowSupersededAt
}
// SetHubEscrowSuperseded records the ACK's `escrow.superseded_present` / `superseded_at`. Same
// last-write-wins mirror discipline as SetHubEscrowIdentityPresent — the hub is the authority, and a
// customer RESET that clears the retained rows must be able to turn the box's message back off.
// Saves only on a change.
func (s *Settings) SetHubEscrowSuperseded(present bool, at string) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.HubEscrowSupersededPresent == present && s.HubEscrowSupersededAt == at {
return nil
}
s.HubEscrowSupersededPresent, s.HubEscrowSupersededAt = present, at
return s.save()
}
// ── Recovery screen (v0.200.0, R-193) ──────────────────────────────────────────
// GetRecoveryNoticePostponed reports whether the customer chose "most nem" on the recovery page.
// Suppresses the full-page interruption ONLY — never the backups-area entry point.
func (s *Settings) GetRecoveryNoticePostponed() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.RecoveryNoticePostponed
}
// SetRecoveryNoticePostponed records the "most nem" choice.
func (s *Settings) SetRecoveryNoticePostponed(v bool) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.RecoveryNoticePostponed == v {
return nil
}
s.RecoveryNoticePostponed = v
return s.save()
}
// ── Customer-claim arc (v0.122.0) ──────────────────────────────────────────────
// GetClaimed reports whether this box has completed a claim (set-only).
@@ -1447,8 +1686,23 @@ func (s *Settings) RefuseAsAppNamespace(path string) (bool, string) {
const (
refuseAppNamespaceNetwork = "Hálózati tárhelyen (NAS) nem futtatható alkalmazás adatkönyvtára — " +
"a NAS megosztás tallózásra és médiatárolásra használható. Válasszon csatlakoztatott meghajtót."
// ⚠ R-220 / SCENARIO G — THIS SENTENCE USED TO NAME AN IMPOSSIBLE ACTION.
//
// It said *"Válasszon a listából csatlakoztatott meghajtót"* — choose an attached drive from the
// list — and on a rebuilt box that list is EMPTY, for a reason the customer had no part in and no
// way to see. Measured three times live (CAMPAIGN-11 Phase 1, and twice on the R-201 re-walk).
// Telling someone to pick from an empty list is the I3 breach the campaign recorded: a refusal must
// name a reason a person can act on.
//
// It now says what is true — the drive is not registered ON THIS MACHINE, which is what a rebuild
// causes — and points at the page where re-attaching happens, rather than at a list that may hold
// nothing. It promises no outcome, because whether the drive can be re-attached is not knowable
// from here.
refuseAppNamespaceUndeterminable = "A megadott tárhely nem azonosítható regisztrált meghajtóként, " +
"ezért alkalmazás adatkönyvtáraként nem használható. Válasszon a listából csatlakoztatott meghajtót."
"ezért alkalmazás adatkönyvtáraként nem használható. Ha a gépet nemrég telepítettük újra, a " +
"meghajtóid megvannak, de még nincsenek újra csatlakoztatva ehhez a géphez — a Tárhely → " +
"Meghajtók oldalon csatlakoztathatod őket, és utána indítsd újra a telepítést. Ha ott sem " +
"látszanak, keresd a Felhom ügyfélszolgálatát."
)
// IsStoragePathSchedulable returns whether a path belongs to a registered,
@@ -1904,3 +2158,132 @@ func (s *Settings) GetIntegrationsForTarget(target string) map[string]Integratio
}
return result
}
// GetHubEscrowKeySHA256 returns the sha256 the hub's sealed package covers, and when it was last
// recorded from an ACK ("" / "" when never learned). See the field comment: empty is "the hub never
// told us", not "they match".
func (s *Settings) GetHubEscrowKeySHA256() (sha, checkedAt string) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.HubEscrowKeySHA256, s.HubEscrowKeyCheckedAt
}
// SetHubEscrowKeySHA256 records the ACK's `escrow.restic_pw_sha256` and stamps when. Same
// last-write-wins mirror discipline as SetHubEscrowIdentityPresent — the hub is the authority on
// what the hub holds, and a re-ceremony legitimately moves this.
//
// The timestamp is refreshed on EVERY ack that carries a hash, including an unchanged one, because
// it records *when we last heard*, not *when it last changed* — a distinction this project has got
// wrong before (R-100: LastRun recorded an attempt and was read as a result). A no-op save is
// avoided only when BOTH the hash and the day are unchanged, so an idle box does not rewrite
// settings.json every fifteen minutes.
func (s *Settings) SetHubEscrowKeySHA256(sha, checkedAt string) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.HubEscrowKeySHA256 == sha && sameDayStamp(s.HubEscrowKeyCheckedAt, checkedAt) {
return nil
}
s.HubEscrowKeySHA256, s.HubEscrowKeyCheckedAt = sha, checkedAt
return s.save()
}
// sameDayStamp reports whether two RFC3339 stamps fall on the same UTC day — the write-damper for
// SetHubEscrowKeySHA256. Unparseable stamps are treated as different, so a malformed value always
// gets replaced rather than sticking.
func sameDayStamp(a, b string) bool {
if a == "" || b == "" {
return false
}
ta, erra := time.Parse(time.RFC3339, a)
tb, errb := time.Parse(time.RFC3339, b)
if erra != nil || errb != nil {
return false
}
return ta.UTC().Format("2006-01-02") == tb.UTC().Format("2006-01-02")
}
// ── The recovery-offer epoch (v0.206.0, R-241) ─────────────────────────────────
// RecoveryOfferView is the epoch read model.
type RecoveryOfferView struct {
Epoch int
Active bool
PostponedEpoch int
OptOutEpoch int
Since string // RFC3339 — when the current epoch began
}
// GetRecoveryOfferView returns the epoch state in one lock.
func (s *Settings) GetRecoveryOfferView() RecoveryOfferView {
s.mu.RLock()
defer s.mu.RUnlock()
return RecoveryOfferView{
Epoch: s.RecoveryOfferEpoch,
Active: s.RecoveryOfferActive,
PostponedEpoch: s.RecoveryNoticePostponedEpoch,
OptOutEpoch: s.RecoveryRemindOptOutEpoch,
Since: s.RecoveryOfferSince,
}
}
// SyncRecoveryOfferEpoch advances the epoch on the EDGE into the offered state and returns the view.
// Idempotent: called on every landing page load, it writes only on a transition.
//
// THE LEGACY MIGRATION IS HERE AND HAPPENS ONCE. A box upgrading from ≤v0.205.0 may carry
// `RecoveryNoticePostponed=true` — a customer who already said "most nem" about the situation they
// are still in. Re-interrupting them on upgrade would be a regression dressed as a feature, so the
// first epoch inherits that choice and the legacy flag is retired. A box that never dismissed keeps
// PostponedEpoch 0 and is interrupted, which is correct.
func (s *Settings) SyncRecoveryOfferEpoch(offered bool, now time.Time) (RecoveryOfferView, error) {
s.mu.Lock()
defer s.mu.Unlock()
changed := false
if offered && !s.RecoveryOfferActive {
s.RecoveryOfferActive = true
s.RecoveryOfferEpoch++
s.RecoveryOfferSince = now.UTC().Format(time.RFC3339)
if s.RecoveryOfferEpoch == 1 && s.RecoveryNoticePostponed {
s.RecoveryNoticePostponedEpoch = 1 // inherit the pre-epoch dismissal, once
s.RecoveryNoticePostponed = false
}
changed = true
} else if !offered && s.RecoveryOfferActive {
s.RecoveryOfferActive = false
changed = true
}
view := RecoveryOfferView{
Epoch: s.RecoveryOfferEpoch, Active: s.RecoveryOfferActive,
PostponedEpoch: s.RecoveryNoticePostponedEpoch, OptOutEpoch: s.RecoveryRemindOptOutEpoch,
Since: s.RecoveryOfferSince,
}
if !changed {
return view, nil
}
return view, s.save()
}
// PostponeRecoveryNoticeForEpoch records "most nem" for the CURRENT epoch. It suppresses the
// full-page interruption only — the banner and the backups-area entry point both survive.
func (s *Settings) PostponeRecoveryNoticeForEpoch() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.RecoveryNoticePostponedEpoch == s.RecoveryOfferEpoch {
return nil
}
s.RecoveryNoticePostponedEpoch = s.RecoveryOfferEpoch
return s.save()
}
// OptOutRecoveryRemindersForEpoch records „ne emlékeztessen újra" for the CURRENT epoch. It silences
// the BANNER and nothing else: no countdown starts, nothing is abandoned, and the entry point on the
// backups page remains. A later fresh entry into the offered state advances the epoch and reminds
// again — the §7.1 conditions, satisfied by arithmetic rather than by remembering to clear a flag.
func (s *Settings) OptOutRecoveryRemindersForEpoch() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.RecoveryRemindOptOutEpoch == s.RecoveryOfferEpoch {
return nil
}
s.RecoveryRemindOptOutEpoch = s.RecoveryOfferEpoch
return s.save()
}
+14
View File
@@ -25,12 +25,26 @@ type SystemInfo struct {
DiskUsedGB float64 `json:"disk_used_gb"`
DiskAvailGB float64 `json:"disk_avail_gb"`
DiskPercent float64 `json:"disk_percent"`
// DiskKnown (R-259) — the statfs on "/" SUCCEEDED. Without it, a failed measurement is
// indistinguishable from an empty disk: readDiskUsage returned early on error and left every
// figure above at its zero value, `usageColor(0)` is "nominal", and the dashboard drew
// „0.0 GB / 0.0 GB (0%)" with a 0%-wide bar in the healthy colour. "We could not look" and
// "there is plenty of room" were the same picture.
//
// This is the house three-state form, and this session rules it THE one (CONTEXT S-39):
// an explicit `…Known bool` companion beside the figures, checked in the template before
// anything is rendered — the shape `Offbox.StatsKnown` already uses, whose own comment says
// "a 0%-wide bar over an unread store is a picture of emptiness, and a picture is a claim".
DiskKnown bool `json:"disk_known"`
HDDTotalGB float64 `json:"hdd_total_gb,omitempty"`
HDDUsedGB float64 `json:"hdd_used_gb,omitempty"`
HDDAvailGB float64 `json:"hdd_avail_gb,omitempty"`
HDDPercent float64 `json:"hdd_percent,omitempty"`
HDDConfigured bool `json:"hdd_configured"`
// HDDKnown (R-259) — as DiskKnown, for the configured HDD path. HDDConfigured is NOT a
// substitute: it says a path was configured, not that reading it worked.
HDDKnown bool `json:"hdd_known"`
CPUPercent float64 `json:"cpu_percent"`
LoadAvg1 float64 `json:"load_avg_1"`
+11 -4
View File
@@ -29,12 +29,12 @@ func GetInfo(hddPath string, cpuCollector *CPUCollector) SystemInfo {
readMemInfo(&info)
// --- Root filesystem disk usage ---
readDiskUsage("/", &info.DiskTotalGB, &info.DiskUsedGB, &info.DiskAvailGB, &info.DiskPercent)
info.DiskKnown = readDiskUsage("/", &info.DiskTotalGB, &info.DiskUsedGB, &info.DiskAvailGB, &info.DiskPercent)
// --- HDD disk usage (if configured) ---
if hddPath != "" {
info.HDDConfigured = true
readDiskUsage(hddPath, &info.HDDTotalGB, &info.HDDUsedGB, &info.HDDAvailGB, &info.HDDPercent)
info.HDDKnown = readDiskUsage(hddPath, &info.HDDTotalGB, &info.HDDUsedGB, &info.HDDAvailGB, &info.HDDPercent)
}
// --- Load average ---
@@ -256,11 +256,17 @@ func parseMemLine(line string) uint64 {
return val
}
func readDiskUsage(path string, totalGB, usedGB, availGB *float64, percent *float64) {
// readDiskUsage fills the four figures and reports whether the measurement SUCCEEDED (R-259).
//
// It used to return nothing. On a statfs error it logged at DEBUG and returned, leaving the
// caller's floats at zero — and zero renders as a healthy empty disk. The boolean is the whole
// fix: the caller now knows the difference between "0 GB used" and "we could not look", and the
// template refuses to draw a picture it does not have.
func readDiskUsage(path string, totalGB, usedGB, availGB *float64, percent *float64) bool {
var stat syscall.Statfs_t
if err := syscall.Statfs(path, &stat); err != nil {
debugf("[DEBUG] [system] readDiskUsage: statfs(%q) failed: %v", path, err)
return
return false
}
bsize := uint64(stat.Bsize)
@@ -277,6 +283,7 @@ func readDiskUsage(path string, totalGB, usedGB, availGB *float64, percent *floa
}
debugf("[DEBUG] [system] readDiskUsage: path=%q bsize=%d total=%.1fGB used=%.1fGB avail=%.1fGB (%.1f%%)",
path, bsize, *totalGB, *usedGB, *availGB, *percent)
return true
}
// readLoadAvg reads 1/5/15 minute load averages from /proc/loadavg.
@@ -0,0 +1,117 @@
package web
import (
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// ── R-302 — RENDERED, at the boundary the defect lives at ───────────────────────────────────────
//
// The condition is unit-tested in internal/backup; these assert the SENTENCES, because the defect was
// always copy that disagreed with what the box could see, and only the rendered bytes show that.
// abandonBannerData renders a page carrying the countdown strip. `offered` is the R-302 verdict.
func abandonBannerData(offered bool, repoState string) map[string]interface{} {
d := splitTestData()
d["Offbox"] = &settings.OffboxTarget{
Enabled: true, Host: "nas.local", User: "felhom", RepoPath: "/srv/repo",
EscrowState: "escrowed", RepoState: repoState, QuotaGB: 50, StatsKnown: true,
}
d["OffboxQuotaPct"] = 0
d["RecoveryBanner"] = true
d["RecoveryAbandonDays"] = 3
d["RecoveryAbandonDate"] = "2026-08-26"
d["RecoveryAbandonRetrievalOffered"] = offered
return d
}
const (
promiseClause = "visszaszerezheted őket a helyreállítási kóddal"
cautiousClause = "nem tudjuk megállapítani"
deletionClause = "véglegesen töröljük"
writeToUs = "írj nekünk a törlés előtt"
)
// ── SCENARIO A — package unchanged → the clause stands, byte-identical in meaning to before ─────
func TestR302_Render_A_PromiseKeptWhenStillTrue(t *testing.T) {
html := renderBackupPage(t, "backups_remote", abandonBannerData(true, "ok"))
if !strings.Contains(html, deletionClause) {
t.Fatal("the deletion sentence is missing — that half is certain and must always render")
}
if !strings.Contains(html, promiseClause) {
t.Error("R-302: a customer who can genuinely still change their mind lost the retrieval clause. " +
"The grace period is explicitly NOT decorative; hedging a true sentence is its own dishonesty")
}
}
// ── SCENARIO B/D/E (rendered) — cautious branch says what is true and names a route ─────────────
//
// RED-PROOF: remove the `{{if .RecoveryAbandonRetrievalOffered}}` conditional from layout.html and
// this fails on the first assertion — the false promise returns.
func TestR302_Render_B_CautiousBranchWhenNotKnowable(t *testing.T) {
html := renderBackupPage(t, "backups_remote", abandonBannerData(false, "ok"))
if strings.Contains(html, promiseClause) {
t.Error("R-302: the banner still promises retrieval when the box cannot see that it is true — " +
"this is the sentence a customer reads after giving up their history")
}
if !strings.Contains(html, cautiousClause) {
t.Error("R-302: the cautious branch does not say we cannot determine it — silence is not the " +
"same as declining a claim")
}
if !strings.Contains(html, writeToUs) {
t.Error("R-302: the cautious branch names no route, and it is time-bounded — the customer must " +
"be told to write in BEFORE the deletion date")
}
// The certain half is unconditional.
if !strings.Contains(html, deletionClause) {
t.Error("R-302: the deletion sentence was lost with the promise — it is the part we DO know")
}
}
// ── SCENARIO C — THE CO-RENDER. The card and the banner must not contradict each other ──────────
//
// Reachable per yesterday's reading: ResetOrphanedRepo clears RepoState then starts the countdown, but
// markOrphaned (offbox.go:804) has NO guard against an active countdown, so a later run finding the
// FRESH store unopenable re-raises the card while the countdown runs.
//
// RED-PROOF — THE ONE THAT MATTERS: replace the condition with the rejected proxy (hub fingerprint vs
// the CURRENT key, compared at render). In this state those differ, so the proxy answers "promise it"
// and the false promise returns on the very screen the card is declining it. That is why the pin was
// chosen over the obvious condition.
func TestR302_Render_C_CoRenderDoesNotContradictItself(t *testing.T) {
html := renderBackupPage(t, "backups_remote", abandonBannerData(false, "orphaned"))
if !strings.Contains(html, "offbox-orphan-card") {
t.Fatal("the orphan card did not render — this test would then prove nothing about the co-render")
}
if !strings.Contains(html, deletionClause) {
t.Fatal("the banner did not render — likewise")
}
// The card says we cannot tell. The banner must not say the opposite one strip above it.
if strings.Contains(html, promiseClause) {
t.Error("R-302 CO-RENDER: the orphan card says we cannot determine whether the set-aside copies " +
"can be opened, and the banner above it tells the customer they can still retrieve them. " +
"One page, two answers, and the confident one is the wrong one")
}
}
// ── SCENARIO F — no countdown → the undecided reminder ladder is untouched ──────────────────────
func TestR302_Render_F_NoCountdownLeavesTheLadderAlone(t *testing.T) {
d := splitTestData()
d["RecoveryBanner"] = true
d["RecoveryReminderTier"] = 14 // an undecided box, two weeks waiting
html := renderBackupPage(t, "backups_remote", d)
for _, s := range []string{deletionClause, promiseClause, cautiousClause} {
if strings.Contains(html, s) {
t.Errorf("abandonment copy %q leaked onto a box with no countdown running", s)
}
}
if !strings.Contains(html, "Két hete") {
t.Error("the undecided reminder ladder changed — it is not in scope and must be byte-identical")
}
}
+41 -3
View File
@@ -126,8 +126,18 @@ func (s *Server) agentDisksListHandler(w http.ResponseWriter, r *http.Request) {
// agentDiskCandidatesHandler proxies GET /api/disks/candidates → agent GET /disks/candidates (Impl-2b):
// the raw-device scan (Impl-2a) that feeds the enrollment wizards. The agent's unclaimed-disk filter
// already excludes claimed/OS/enrolled disks (fail-safe), so the controller passes the list through
// untouched — no controller-side filtering.
// already excludes claimed/OS/enrolled disks (fail-safe), so `initialize` passes through UNTOUCHED —
// no controller-side filtering, and the system/backup drives it hides from the format wizard stay
// hidden.
//
// R-280: `attach` additionally carries the controller's own mounted-but-unregistered filesystems.
// The agent's scan alone left a rebuilt box with an empty picker under a sentence promising „két
// kattintás", because the drive that must be re-registered is an in-guest filesystem no host-disk
// scan can see. Attaching is non-destructive, so this list is additive by nature — it can only ever
// offer MORE places to put data back, never a new way to erase any. Why the union rather than a
// replacement: the agent's entries serve the case this endpoint was built for — a fresh external
// drive that already carries a filesystem and is not yet mounted — which the mount table cannot
// report precisely because it is not mounted. Dropping them would fix the reinstall and break the USB.
func (s *Server) agentDiskCandidatesHandler(w http.ResponseWriter, r *http.Request) {
client, err := s.agentClient()
if err != nil {
@@ -140,7 +150,35 @@ func (s *Server) agentDiskCandidatesHandler(w http.ResponseWriter, r *http.Reque
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
return
}
writeDiskJSON(w, http.StatusOK, true, "", resp)
writeDiskJSON(w, http.StatusOK, true, "", mergeAttachCandidates(resp, s.attachableStores()))
}
// mergeAttachCandidates adds the mounted-but-unregistered stores to `attach` and returns the result.
// `initialize` is passed through untouched — the ONE line that keeps the format wizard's protection
// intact, and the reason this is a separate function rather than two appends at the call site: it can
// be tested, and a change to it fails a test instead of shipping.
func mergeAttachCandidates(resp agentapi.CandidatesResult, stores []mountedStore) agentapi.CandidatesResult {
resp.Attach = append(resp.Attach, mountedStoreCandidates(stores)...)
return resp
}
// mountedStoreCandidates renders mounted-but-unregistered stores in the picker's shape. MountSource
// carries the mountpoint (the thing the register action needs); Device is display only.
func mountedStoreCandidates(stores []mountedStore) []agentapi.DiskCandidate {
out := make([]agentapi.DiskCandidate, 0, len(stores))
for _, m := range stores {
out = append(out, agentapi.DiskCandidate{
Device: m.Device,
FSType: m.FSType,
MountSource: m.Path,
DataBearing: true,
Mountable: true,
// Size is deliberately absent: measuring it means statfs on a possibly-wedged device
// inside a request handler, and a picker entry is actionable without it.
AlreadyMounted: true,
})
}
return out
}
// sortDisksForView orders the agent's disk list deterministically (user-data → system → backup →
@@ -0,0 +1,83 @@
package web
import (
"errors"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
)
// R-258 — the per-app tier-1 tick must answer about THIS app, and say nothing when it knows nothing.
//
// Driven through appDumpVerdict, which is the seam buildAppBackupRows now calls. The defect was a
// verdict derived from a GLOBAL field, so the test that matters is the one with two apps where the
// global answer and the per-app answer disagree.
func res(stack string, err error) appbackup.DumpResult {
return appbackup.DumpResult{DB: appbackup.DiscoveredDB{StackName: stack}, Error: err}
}
// SCENARIO F — X's own dump failed; Y's succeeded and is the most recent on the box.
func TestAppDumpVerdict_IsPerApp_NotTheBoxsMostRecentRun(t *testing.T) {
dump := &backup.DBDumpStatus{
LastRun: time.Now(),
// Y ran last and succeeded, so the box-level Success is true — which is exactly the value
// the old code used for every app.
Success: true,
Results: []appbackup.DumpResult{
res("appX", errors.New("pg_dump: connection refused")),
res("appY", nil),
},
}
if got := appDumpVerdict(dump, "appX"); got != "error" {
t.Errorf("app X's own dump FAILED but its tick is %q, want \"error\".\n"+
"This is R-258: the verdict was read from the box's most recent dump run — app Y's — so a "+
"failed backup showed a green tick to the customer.", got)
}
if got := appDumpVerdict(dump, "appY"); got != "ok" {
t.Errorf("app Y succeeded but its tick is %q, want \"ok\"", got)
}
}
// SCENARIO G — nothing known is not the same as fine.
func TestAppDumpVerdict_NoResultForThisApp_IsNoVerdict(t *testing.T) {
dump := &backup.DBDumpStatus{Success: true, Results: []appbackup.DumpResult{res("other", nil)}}
if got := appDumpVerdict(dump, "appWithNoDatabase"); got != "" {
t.Errorf("an app with no dump result of its own got the verdict %q; want \"\" (no icon).\n"+
"A green tick standing for \"a restore point file exists\" is the presence-is-not-success "+
"rule as a UI badge.", got)
}
// and with no dump run recorded at all
if got := appDumpVerdict(nil, "anything"); got != "" {
t.Errorf("no dump status at all gave the verdict %q; want \"\"", got)
}
}
// An app with SEVERAL databases: any failure among them makes the app's backup a failure. A partial
// dump is not a success, and reporting the last-listed result would make the verdict order-dependent.
func TestAppDumpVerdict_AnyFailingDatabaseFailsTheApp(t *testing.T) {
for _, tc := range []struct {
name string
results []appbackup.DumpResult
}{
{"failure first", []appbackup.DumpResult{res("app", errors.New("boom")), res("app", nil)}},
{"failure last", []appbackup.DumpResult{res("app", nil), res("app", errors.New("boom"))}},
} {
t.Run(tc.name, func(t *testing.T) {
if got := appDumpVerdict(&backup.DBDumpStatus{Results: tc.results}, "app"); got != "error" {
t.Errorf("one of the app's databases failed to dump, verdict = %q, want \"error\"", got)
}
})
}
}
// All of this app's databases dumped cleanly → ok.
func TestAppDumpVerdict_AllCleanIsOK(t *testing.T) {
dump := &backup.DBDumpStatus{Results: []appbackup.DumpResult{res("app", nil), res("app", nil)}}
if got := appDumpVerdict(dump, "app"); got != "ok" {
t.Errorf("verdict = %q, want \"ok\"", got)
}
}
@@ -0,0 +1,202 @@
package web
import (
"bytes"
"io"
"log"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// R-254 site one — AN APP'S GENERATED FIRST-LOGIN PASSWORD MUST NOT BE IN THE RESPONSE BODY.
//
// The third instance of one pattern in two days (R-249 was the retrieval passphrase; this is a real
// per-install app credential, read live out of the running container). The markup rendered it into
// `<span id="initcred-pw-val" hidden>…</span>` — `hidden` stops a browser DRAWING the value and
// nothing else, so a `curl` of an app's info page returned it in plaintext.
//
// These assert the RAW BODY. A test that asks what the customer *sees* passes on this defect, which
// is exactly how it survived three times.
const testAppPassword = "TESTONLY-app-initial-pw-7Kq2mZ"
// renderAppInfo drives the REAL template with the page data shape the handler produces, and returns
// the bytes a browser would receive.
func renderAppInfo(t *testing.T, creds *stacks.ExtractedCreds, hasPassword bool) string {
t.Helper()
s := securityHarness(t)
s.loadTemplates()
data := map[string]interface{}{
"Page": "stacks", "Title": "Teszt app", "Domain": "example.hu",
"Stack": stacks.Stack{Name: "crafty", Deployed: true, State: "running"},
"Meta": stacks.Metadata{DisplayName: "Crafty", Slug: "crafty", Category: "media"},
"AppInfo": stacks.AppInfo{Tagline: "teszt"},
// The credentials card lives inside {{if .HasAppInfo}} — without this the card never renders
// and every assertion below would pass for the wrong reason.
"HasAppInfo": true,
}
if creds != nil {
data["InitialCreds"] = creds
data["InitialCredsHasPassword"] = hasPassword
}
var buf bytes.Buffer
if err := s.tmpl.ExecuteTemplate(&buf, "app_info", data); err != nil {
t.Fatalf("render app_info: %v", err)
}
return buf.String()
}
// ── SCENARIO A — the app password is not in the page ────────────────────────────────────────────
// RED-PROOF: put `<span id="initcred-pw-val" hidden>{{.InitialCreds.Password}}</span>` back into
// app_info.html AND restore `data["InitialCreds"] = creds` in the handler — this fails on the first
// assertion, showing the plaintext returning to the body. That is the defect, reproduced.
//
// NOTE the second assertion, and a CORRECTION to what v0.207.0's report claimed. It said HTML
// comments ship in the response body. **They do not, here:** this package renders with
// `html/template` (server.go), which STRIPS comments — measured: text/template keeps them,
// html/template does not. So a comment cannot leak a secret, and a red-proof planting one in a
// comment correctly does NOT fail. The assertion is kept because it catches the real regression —
// the hidden element itself coming back into the markup.
func TestAppInfoPage_DoesNotContainTheInitialPassword(t *testing.T) {
html := renderAppInfo(t, &stacks.ExtractedCreds{
Available: true, Username: "admin", Password: testAppPassword,
}, true)
if strings.Contains(html, testAppPassword) {
t.Error("R-254: the app's first-login password is in the response body of its info page — " +
"a fetch of this page returns a real per-install credential, and the reveal button only " +
"stops a browser DRAWING it")
}
if strings.Contains(html, "initcred-pw-val") {
t.Error("the old hidden-value element is back in the markup")
}
// The feature must survive: the fix removes the VALUE, not the customer's access (Scenario B).
if !strings.Contains(html, "Kezdeti belépési adatok") {
t.Error("the initial-credentials card vanished — the fix must not take the password away " +
"from the person whose app it is")
}
if !strings.Contains(html, "initial-credentials/reveal") {
t.Error("no reveal call rendered, so the customer has no way to obtain the password at all")
}
// The username is NOT a secret and must still render — otherwise the card is useless.
if !strings.Contains(html, "admin") {
t.Error("the username stopped rendering; only the password was supposed to leave the page")
}
}
// ── SCENARIO E — an app with no generated credentials is unchanged ──────────────────────────────
// RED-PROOF: make the card unconditional (drop `{{if .InitialCreds}}`) and this fails — an app that
// has no generated credential grows a reveal control for a password that does not exist.
func TestAppInfoPage_NoCredentialsCard_WhenAppHasNone(t *testing.T) {
html := renderAppInfo(t, nil, false)
if strings.Contains(html, "Kezdeti belépési adatok") {
t.Error("the initial-credentials card rendered for an app with no generated credentials")
}
if strings.Contains(html, "initial-credentials/reveal") {
t.Error("a reveal control rendered for an app that has no password to reveal")
}
}
// ── SCENARIO B — the customer can still get it, and C — the act is recorded ─────────────────────
// credsHarness builds a Server with a REAL stack manager carrying one deployed app whose slug is
// "crafty", plus a capturing logger. Only the container READ is seamed — slug resolution is the
// production path, because a reveal answering for the wrong app is the failure mode that matters.
func credsHarness(t *testing.T) (*Server, *bytes.Buffer) {
t.Helper()
dir := t.TempDir()
buf := &bytes.Buffer{}
lg := log.New(io.MultiWriter(buf), "", 0)
cfg := config.Default()
cfg.Customer.Domain = "example.hu"
cfg.Paths.StacksDir = filepath.Join(dir, "stacks")
cfg.Paths.DataDir = filepath.Join(dir, "data")
cfg.Web.SessionSecret = "test-session-secret-abcdef"
sd := filepath.Join(cfg.Paths.StacksDir, "crafty")
if err := os.MkdirAll(sd, 0o755); err != nil {
t.Fatal(err)
}
os.WriteFile(filepath.Join(sd, ".felhom.yml"), []byte("display_name: Crafty\nslug: crafty\n"), 0o644)
os.WriteFile(filepath.Join(sd, "docker-compose.yml"), []byte("services: {}\n"), 0o644)
os.WriteFile(filepath.Join(sd, "app.yaml"), []byte("deployed: true\n"), 0o644)
sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg)
if err != nil {
t.Fatal(err)
}
mgr, err := stacks.NewManager(cfg, lg)
if err != nil {
t.Fatal(err)
}
// Discovery is a separate step — the constructor only prepares the directory.
_ = mgr.ScanStacks() // container-status refresh fails on a docker-less host; discovery is enough
if _, ok := mgr.GetStack("crafty"); !ok {
t.Fatal("crafty not discovered by ScanStacks — the fixture would prove nothing")
}
return &Server{cfg: cfg, settings: sett, stackMgr: mgr, logger: lg, version: "test"}, buf
}
// RED-PROOF for B: delete the route case from server.go (or make the handler always 404) and the
// customer is shown unable to reach their own app password.
// RED-PROOF for C: delete the s.logger.Printf line and the "recorded" assertion fails.
func TestAppInitialCredsReveal_ReturnsThePasswordAndRecordsTheAct(t *testing.T) {
s, logBuf := credsHarness(t)
s.initialCredsFn = func(name string) (*stacks.ExtractedCreds, error) {
return &stacks.ExtractedCreds{Available: true, Username: "admin", Password: testAppPassword}, nil
}
rr := httptest.NewRecorder()
s.appInitialCredsRevealHandler(rr, httptest.NewRequest("POST", "/apps/crafty/initial-credentials/reveal", nil), "crafty")
if rr.Code != 200 {
t.Fatalf("reveal returned %d, want 200 — the customer cannot get their own app password", rr.Code)
}
if !strings.Contains(rr.Body.String(), testAppPassword) {
t.Error("the reveal did not return the password — Scenario A's fix must not protect the " +
"secret by taking it from its owner")
}
if got := rr.Header().Get("Cache-Control"); !strings.Contains(got, "no-store") {
t.Errorf("Cache-Control = %q, want no-store — a cached reveal is the same defect one layer down", got)
}
// SCENARIO C — the act is recorded, and the VALUE is not.
logged := logBuf.String()
if !strings.Contains(logged, "initial-credential password revealed") {
t.Error("the reveal was not recorded — a silent read is what the markup allowed, and why " +
"nobody can say whether any of these was ever read")
}
if strings.Contains(logged, testAppPassword) {
t.Error("the password was written to the log")
}
}
// §7.1 — a reveal that cannot read the value SAYS SO. An empty string would render as a blank
// password and read to the customer as "your password is empty".
func TestAppInitialCredsReveal_SaysWhyWhenUnreadable(t *testing.T) {
s, _ := credsHarness(t)
s.initialCredsFn = func(name string) (*stacks.ExtractedCreds, error) {
return &stacks.ExtractedCreds{Available: false}, nil // container stopped / file gone
}
rr := httptest.NewRecorder()
s.appInitialCredsRevealHandler(rr, httptest.NewRequest("POST", "/apps/crafty/initial-credentials/reveal", nil), "crafty")
if rr.Code != 404 {
t.Errorf("unreadable reveal returned %d, want 404", rr.Code)
}
body := rr.Body.String()
if !strings.Contains(body, "futnia kell") {
t.Errorf("the refusal does not say WHY it could not be read: %s", body)
}
if strings.Contains(body, `"password"`) {
t.Error("an unreadable reveal still carried a password field")
}
}
+163
View File
@@ -0,0 +1,163 @@
package web
import (
"os"
"path"
"sort"
"strings"
)
// R-280 — where the `attach` list comes from, and why it is NOT the agent's disk scan.
//
// After a reinstall the customer's drives survive but their REGISTRATION does not, and every restore
// then refuses. The restore page diagnosed that correctly and sent the customer to a page offering
// nothing: `GET /disks/candidates` returned `{"initialize":[],"attach":[]}`.
//
// The agent builds BOTH lists from its unclaimed-DISK scan (felhom-agent
// internal/localapi/disks.go handleDiskCandidates → storage.ListCandidateDisks). That filter is
// CORRECT for `initialize` — never offer to format a disk in use — and over-broad for `attach`,
// which is non-destructive.
//
// But widening the agent's scan would still not fix it, and that is the part worth writing down:
// the filesystem that must be re-registered after a reinstall is an IN-GUEST one. On the rebuilt
// demo-hp the drive the customer needed was `/mnt/sys_drive` (the guest's own data volume), and the
// escape hatch that unblocked everything registered exactly that path. The agent's host-disk scan
// cannot see it — it enumerates HOST block devices, and would have offered the 1 TB NVMe (the
// felhom-backup target) instead: the wrong drive, non-destructively attached, and the customer's
// data still not reachable.
//
// So the attach source is the controller's OWN mount table. The controller runs in-guest with /mnt
// bind-mounted in, so the filesystems it can see ARE the ones it can register — the source and the
// action finally agree.
//
// These candidates are ALREADY MOUNTED, so the action is REGISTER, never mount-a-device. That is why
// they carry AlreadyMounted: the wizard must not send them down the device-attach path, which would
// try to mount an in-guest path as if it were a raw device.
// mountedFSTypes are the on-disk filesystems a mounted store may carry. Deliberately the same pair
// the agent calls attach-mountable (storage.mountableFSTypes) and the init flow offers
// (validFSTypes) — a third dialect here is how the three lists drift apart.
var mountedFSTypes = map[string]bool{"ext4": true, "xfs": true}
// managedDrivesParent is the intermediary-model PARENT. Children of it are real drives; the parent
// itself is the container that holds them and is never a storage destination of its own.
const managedDrivesParent = "/mnt/felhom-drives"
// mountedStore is one already-mounted filesystem the controller can register as a storage location.
type mountedStore struct {
Path string // the in-guest mountpoint, e.g. /mnt/sys_drive — what gets registered, verbatim
Device string // backing device, for display only ("/dev/mapper/pve-vm--9201--disk--1")
FSType string
}
// parseMountTable turns mount-table text (/proc/mounts format) into (device, mountpoint, fstype)
// rows. Space escaping (\040) is undone so a path with a space still compares — the same handling
// the agent's own procMounts does.
func parseMountTable(text string) [][3]string {
var out [][3]string
for _, line := range strings.Split(text, "\n") {
fields := strings.Fields(line)
if len(fields) < 3 {
continue
}
out = append(out, [3]string{
fields[0],
strings.ReplaceAll(fields[1], `\040`, " "),
fields[2],
})
}
return out
}
// mountedUnregisteredStores returns the mounted filesystems under /mnt that are NOT already in the
// storage registry — the drives a customer can attach (register) without anything being erased.
//
// ⚠ FAIL-SAFE: an unreadable mount table yields an EMPTY list, never a permissive one. "We could not
// look" must never render as "here is what you may attach" — and, because the caller gates the
// „two kattintás" sentence on this list being non-empty, an empty list makes the page say so plainly
// rather than promise a click that does not exist.
func mountedUnregisteredStores(mountsText string, registered map[string]bool) []mountedStore {
rows := parseMountTable(mountsText)
// Devices that back the box's OWN root. A bind mount republishes a filesystem under a second
// path, and a bind of the rootfs at /mnt/<name> looks exactly like a data drive to everything
// below — offering it would invite the customer to store app data on the root filesystem and
// fill it. Excluded by DEVICE, so no alias can smuggle it back in under a different path.
rootDevices := map[string]bool{}
for _, row := range rows {
if w := path.Clean(row[1]); w == "/" || w == "/mnt" {
rootDevices[row[0]] = true
}
}
var out []mountedStore
seen := map[string]bool{}
for _, row := range rows {
dev, where, fstype := row[0], path.Clean(row[1]), row[2]
if rootDevices[dev] {
continue
}
// A real block device only. Excludes overlay/tmpfs/proc/sysfs and every virtual mount.
if !strings.HasPrefix(dev, "/dev/") {
continue
}
// An on-disk filesystem we can actually hand to apps.
if !mountedFSTypes[fstype] {
continue
}
// The storage convention is /mnt/<name>. `/mnt` itself is the guest rootfs mount, not a drive.
if !strings.HasPrefix(where, "/mnt/") || where == "/mnt" {
continue
}
// The intermediary-model parent holds drives; it is not one.
if where == managedDrivesParent {
continue
}
// Already registered → not a candidate. This is what makes a healthy box render exactly as
// before: its store is registered, so it never appears here (Scenario D).
if registered[where] {
continue
}
// /proc/mounts lists a mountpoint once per mount event; a bind or a re-mount would otherwise
// produce the same path twice in the picker.
if seen[where] {
continue
}
seen[where] = true
out = append(out, mountedStore{Path: where, Device: dev, FSType: fstype})
}
// Deterministic order — the picker must not reshuffle between reloads.
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
return out
}
// readMountTable reads the controller's own mount table. Returns "" on failure, which
// mountedUnregisteredStores turns into an empty (never permissive) list.
func readMountTable() string {
b, err := os.ReadFile("/proc/mounts")
if err != nil {
return ""
}
return string(b)
}
// registeredStoragePaths is the set of paths already in the registry, for the exclusion above.
func (s *Server) registeredStoragePaths() map[string]bool {
out := map[string]bool{}
if s.settings == nil {
return out
}
for _, sp := range s.settings.GetStoragePaths() {
out[path.Clean(sp.Path)] = true
}
return out
}
// attachableStores is the one derivation both the candidates endpoint and the restore page's
// precondition read, so the picker and the sentence pointing at it cannot disagree — the same
// single-derivation rule R-252 applied to HasRestoreDestination.
func (s *Server) attachableStores() []mountedStore {
return mountedUnregisteredStores(readMountTable(), s.registeredStoragePaths())
}
@@ -0,0 +1,252 @@
package web
import (
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
)
// R-280 — THE DRIVE CAN BE RE-ATTACHED AFTER A REINSTALL.
//
// Measured on the rebuilt demo-hp (2026-08-09): the restore page diagnosed the situation correctly
// and then sent the customer to a picker that was empty. `GET /disks/candidates` answered
// `{"initialize":[],"attach":[]}` because BOTH lists come from the agent's unclaimed-DISK scan, and
// the box's NVMe is claimed (it is the felhom-backup target). Getting past it needed an internal path
// no customer could produce.
//
// These tests pin the SOURCE of the attach list, and the mount table below is the real one from that
// box — /mnt/sys_drive is the guest data volume the escape hatch had to register by hand.
// demoHPMounts is guest 9201's actual mount table on the rebuilt demo-hp, trimmed to the rows that
// matter. Keeping the real shape means the fixture cannot quietly diverge from the box.
const demoHPMounts = `proc /proc proc rw,relatime 0 0
/dev/mapper/pve-vm--9201--disk--0 /mnt ext4 rw,relatime,stripe=16 0 0
/dev/mapper/pve-root /mnt/felhom-drives ext4 rw,relatime,errors=remount-ro 0 0
/dev/mapper/pve-vm--9201--disk--1 /mnt/sys_drive ext4 rw,relatime,stripe=16 0 0
tmpfs /dev/shm tmpfs rw,nosuid,nodev 0 0
overlay /var/lib/docker/overlay2/x/merged overlay rw,relatime 0 0
`
// ── SCENARIO A — a mounted, unregistered filesystem on a CLAIMED disk is offered ────────────────
//
// RED-PROOF (the one that matters): revert the attach list to the unclaimed-disk scan — i.e. make
// mountedUnregisteredStores return nil, or drop the `resp.Attach = append(...)` line in
// agentDiskCandidatesHandler. This fails with `attach candidates: 0`, which IS yesterday's wall:
// an empty picker under a sentence promising two clicks.
func TestMountedUnregisteredStores_OffersTheGuestDataVolume(t *testing.T) {
got := mountedUnregisteredStores(demoHPMounts, map[string]bool{})
if len(got) != 1 {
t.Fatalf("attach candidates: %d, want 1 (/mnt/sys_drive) — got %+v", len(got), got)
}
if got[0].Path != "/mnt/sys_drive" {
t.Errorf("offered %q, want /mnt/sys_drive — the drive the rebuilt box could not re-attach", got[0].Path)
}
if got[0].FSType != "ext4" {
t.Errorf("fstype %q, want ext4", got[0].FSType)
}
if got[0].Device != "/dev/mapper/pve-vm--9201--disk--1" {
t.Errorf("device %q — the backing device is shown to the customer and must be the real one", got[0].Device)
}
}
// The disk is CLAIMED — that is the whole point. The claim filter is a property of the agent's scan,
// and this source deliberately does not consult it, because attaching erases nothing. This pins that
// the mount table alone decides, so re-introducing a claim check here would fail.
func TestMountedUnregisteredStores_ClaimedDiskIsStillOffered(t *testing.T) {
// pve-vm--9201--disk--1 is LVM on the OS disk: claimed by every definition the agent uses.
got := mountedUnregisteredStores(demoHPMounts, map[string]bool{})
if len(got) == 0 {
t.Fatal("a claimed-but-mounted filesystem was filtered out — attaching is non-destructive, " +
"and this filter is exactly what made the picker empty on the rebuilt box")
}
}
// ── SCENARIO D — a normal box with a registered store is unchanged ──────────────────────────────
// RED-PROOF: drop the `registered[where]` exclusion and this fails with the already-registered store
// offered for attaching a second time.
func TestMountedUnregisteredStores_RegisteredStoreIsNotOffered(t *testing.T) {
got := mountedUnregisteredStores(demoHPMounts, map[string]bool{"/mnt/sys_drive": true})
if len(got) != 0 {
t.Errorf("a healthy box offered %+v — its store is registered, so the picker must be empty "+
"and the page byte-identical to before this change", got)
}
}
// ── The exclusions, each with the reason it exists ──────────────────────────────────────────────
func TestMountedUnregisteredStores_Exclusions(t *testing.T) {
cases := []struct {
name, table, why string
}{
{
"guest rootfs at /mnt",
"/dev/mapper/pve-vm--9201--disk--0 /mnt ext4 rw 0 0\n",
"/mnt is the guest rootfs mount, not a drive — offering it would register the box's own root",
},
{
"the managed parent itself",
"/dev/mapper/pve-root /mnt/felhom-drives ext4 rw 0 0\n",
"the intermediary-model parent holds drives; it is not one",
},
{
"tmpfs",
"tmpfs /mnt/scratch tmpfs rw 0 0\n",
"a RAM filesystem would silently lose the customer's data on reboot",
},
{
"overlay",
"overlay /mnt/ovl overlay rw 0 0\n",
"not a real block device",
},
{
"outside /mnt",
"/dev/sdb1 /srv/data ext4 rw 0 0\n",
"the storage convention is /mnt/<name>; registering outside it is the manual-add path",
},
{
"unsupported fs",
"/dev/sdb1 /mnt/win ntfs rw 0 0\n",
"ntfs is data-bearing but not one the stack hands to apps",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := mountedUnregisteredStores(c.table, map[string]bool{}); len(got) != 0 {
t.Errorf("offered %+v — %s", got, c.why)
}
})
}
}
// FAIL-SAFE: an unreadable mount table yields nothing, never everything. Paired with the caller's
// non-empty assertion, "we could not look" renders as "we cannot offer this", never as a promise.
func TestMountedUnregisteredStores_UnreadableTableOffersNothing(t *testing.T) {
if got := mountedUnregisteredStores("", map[string]bool{}); len(got) != 0 {
t.Errorf("an unreadable mount table produced %+v — it must produce nothing", got)
}
}
// A mountpoint listed twice (bind / re-mount) must appear once, or the picker shows a duplicate.
func TestMountedUnregisteredStores_DeduplicatesMountpoints(t *testing.T) {
table := "/dev/sdb1 /mnt/data ext4 rw 0 0\n/dev/sdb1 /mnt/data ext4 rw,remount 0 0\n"
if got := mountedUnregisteredStores(table, map[string]bool{}); len(got) != 1 {
t.Errorf("got %d entries, want 1 — a re-mount must not double the picker row", len(got))
}
}
// ── SCENARIO C — nothing attachable, said plainly ───────────────────────────────────────────────
// RED-PROOF: remove the `{{if .HasAttachDestination}}` conditional from backups_restore.html and this
// fails on the first assertion — the „két kattintás" promise returns over an empty picker, which is
// the exact sentence that cost the rehearsal its time.
func TestRestorePage_NothingAttachable_DoesNotPromiseTwoClicks(t *testing.T) {
d := restoreData()
d["NoRestoreDestination"] = true
d["HasAttachDestination"] = false
html := renderBackupPage(t, "backups_restore", d)
if strings.Contains(html, "két kattintás") {
t.Error("R-280: the page still promises „két kattintás" +
"\" while there is nothing to click — it is zero clicks, and the customer cannot get past it")
}
if !strings.Contains(html, "Csatolható meghajtót viszont most nem látunk") {
t.Error("R-280: the page does not say plainly that there is nothing to attach")
}
if !strings.Contains(html, "üzemeltető") {
t.Error("R-280: a refusal with no route is the R-252 defect again — it must name what to do instead")
}
}
// ── SCENARIO A, rendered — the promise is kept only when it is true ─────────────────────────────
func TestRestorePage_Attachable_KeepsTheTwoClicksRoute(t *testing.T) {
d := restoreData()
d["NoRestoreDestination"] = true
d["HasAttachDestination"] = true
html := renderBackupPage(t, "backups_restore", d)
if !strings.Contains(html, "két kattintás") {
t.Error("with a real destination the original instruction must survive — this change narrows " +
"a false promise, it does not remove a true one")
}
if !strings.Contains(html, `href="/storage"`) {
t.Error("the instruction no longer routes to the picker")
}
}
// ── SCENARIO B — `initialize` is left exactly as it was ─────────────────────────────────────────
//
// The format wizard hides system and backup drives, and it says so on the page. That protection is a
// property of the agent's unclaimed scan, and the mounted-store source must never reach it.
//
// RED-PROOF: switch the initialize list to the new source too — in mergeAttachCandidates, add
// `resp.Initialize = append(resp.Initialize, mountedStoreCandidates(stores)...)`. This fails with the
// guest data volume offered for FORMATTING, which is the protection breaking in the open.
func TestMergeAttachCandidates_InitializeIsUntouched(t *testing.T) {
agentSaid := agentapi.CandidatesResult{
VMID: 9201,
Initialize: []agentapi.DiskCandidate{{Device: "/dev/sdd", FSType: ""}},
Attach: []agentapi.DiskCandidate{{Device: "/dev/sdd", MountSource: "/dev/sdd1", FSType: "ext4"}},
}
stores := []mountedStore{{Path: "/mnt/sys_drive", Device: "/dev/mapper/pve-vm--9201--disk--1", FSType: "ext4"}}
got := mergeAttachCandidates(agentSaid, stores)
// initialize: byte-for-byte the agent's list.
if len(got.Initialize) != 1 || got.Initialize[0].Device != "/dev/sdd" {
t.Fatalf("initialize was modified: %+v — the format wizard's system/backup protection lives "+
"in the agent's unclaimed scan, and widening it is how a customer is offered their own "+
"data drive to format", got.Initialize)
}
for _, c := range got.Initialize {
if c.AlreadyMounted {
t.Errorf("a mounted store reached the FORMAT list: %+v", c)
}
}
// attach: the agent's entry survives (the fresh-USB case) AND the mounted store is added.
if len(got.Attach) != 2 {
t.Fatalf("attach has %d entries, want 2 (agent's + the mounted store) — got %+v", len(got.Attach), got.Attach)
}
var foundMounted bool
for _, c := range got.Attach {
if c.MountSource == "/mnt/sys_drive" {
foundMounted = true
if !c.AlreadyMounted {
t.Error("the mounted store is not flagged already_mounted — the wizard would send it " +
"down the device-attach path and try to mount an in-guest path as a raw device")
}
}
}
if !foundMounted {
t.Error("the mounted store did not reach attach — this is the rebuilt box's empty picker")
}
if got.Attach[0].Device != "/dev/sdd" {
t.Error("the agent's own attach entry was dropped — a fresh external drive with a filesystem " +
"on it is exactly what this wizard was built for, and the mount table cannot report it")
}
}
// A bind mount republishes a filesystem under a second path. A bind of the guest ROOTFS under
// /mnt/<name> is indistinguishable from a data drive by path alone — and registering it would put
// app data on the root filesystem.
//
// RED-PROOF: drop the `rootDevices[dev]` exclusion and this fails with /mnt/rootcopy offered.
func TestMountedUnregisteredStores_RootfsAliasIsNotOffered(t *testing.T) {
table := demoHPMounts + "/dev/mapper/pve-vm--9201--disk--0 /mnt/rootcopy ext4 rw 0 0\n"
got := mountedUnregisteredStores(table, map[string]bool{})
for _, m := range got {
if m.Path == "/mnt/rootcopy" {
t.Error("a bind of the guest rootfs was offered as an attachable store — registering it " +
"would store the customer's app data on the box's own root filesystem")
}
}
// The real drive on a DIFFERENT device must survive the new exclusion.
if len(got) != 1 || got[0].Path != "/mnt/sys_drive" {
t.Errorf("the genuine data volume was lost to the rootfs guard: %+v", got)
}
}
+5
View File
@@ -206,6 +206,11 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
Secure: isSecure,
})
// R-241 (v0.206.0): a fresh login clears the per-visit recovery-banner dismissal, so the reminder
// is genuinely "back at the next login" (§7.1 / Scenario H) rather than merely "back when the
// browser is closed". The durable opt-out is a separate, explicit choice and is untouched here.
http.SetCookie(w, &http.Cookie{Name: recoveryBannerCookie, Value: "", Path: "/", MaxAge: -1})
s.logger.Printf("[INFO] [web] Login from %s", r.RemoteAddr)
// Redirect to ?next= target if provided, otherwise to dashboard
@@ -164,8 +164,16 @@ func TestBuildAppBackupRows_Tier1FromRestorePoints(t *testing.T) {
if hu == nil || hu.Tier1LastRun != mtime.Format(time.RFC3339) {
t.Fatalf("hasunit Tier1LastRun = %q, want %q", huTier1(hu), mtime.Format(time.RFC3339))
}
if hu.Tier1LastStatus != "ok" {
t.Errorf("hasunit Tier1LastStatus = %q, want ok", hu.Tier1LastStatus)
// ⚠ CHANGED 2026-08-08 (R-258), and the change is the finding. This asserted `== "ok"` for a
// FullBackupStatus with NO LastDBDump at all — i.e. it pinned the defect: a green tick derived
// from nothing but the presence of a recovery-unit file. The old code took the `nil` branch and
// returned "ok"; the verdict now comes from THIS app's own dump result, and there is none here,
// so the honest answer is no verdict and the template renders no icon.
//
// The row's real subject — Tier1LastRun, the time — is unchanged and still asserted above.
if hu.Tier1LastStatus != "" {
t.Errorf("hasunit Tier1LastStatus = %q, want \"\" (no dump result for this app ⇒ no verdict; "+
"a tick standing for \"a file exists\" is R-258)", hu.Tier1LastStatus)
}
nu := findRow(rows, "nounit")
if nu == nil || nu.Tier1LastRun != "" {
@@ -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")
}
}
@@ -28,6 +28,12 @@ func splitTestData() map[string]interface{} {
"OffboxConfigured": true,
"OffboxApps": []OffboxAppRow{{Name: "calibre-web", DisplayName: "Calibre-Web", Enabled: true}},
"OffboxToggledCount": 1,
// R-237: the restore list is driven by the STORE. The happy fixture is "the app is in the
// repository AND installed here" — the pre-rebuild shape.
"OffsiteRestoreRows": []OffsiteRestoreRow{
{App: "calibre-web", DisplayName: "Calibre-Web", InStore: true, Installed: true, Enabled: true},
},
"OffsiteStoreState": string(offsiteStoreKnown),
"OffboxQuotaPct": 0,
"GuestBackup": map[string]interface{}{"Available": false, "Note": "n/a"},
}
+2 -2
View File
@@ -497,7 +497,7 @@ func PrintLocalResetCode(sett ClaimHatchSettings, cfg ClaimHatchConfig) int {
fmt.Fprintf(os.Stderr, "print-reset-code: saving code: %v\n", err)
return 1
}
fmt.Printf("Egyszer használható helyi beállító/visszaállító kód (generation %d):\n\n %s\n\nAdd meg a vezérlőpult beállító oldalán (/claim), majd válassz új jelszót.\n", nextGen, code)
fmt.Printf("Egyszer használható helyi beállító kód (generation %d):\n\n %s\n\nAdd meg a vezérlőpult beállító oldalán (/claim), majd válassz új jelszót.\n", nextGen, code)
return 0
}
@@ -537,7 +537,7 @@ func (s *Server) reportClaimLockout(ip string) {
s.logger.Printf("[WARN] [web] claim: code lockout tripped (source %s) — 15 min", ip)
if s.notifier != nil {
s.notifier.PushEvent("claim_lockout", "warning",
"Túl sok hibás beállító/visszaállító kód — a beállító oldal 15 percre zárolva",
"Túl sok hibás beállító kód — a beállító oldal 15 percre zárolva",
map[string]interface{}{"source": ip})
}
}
@@ -0,0 +1,107 @@
package web
import (
"bytes"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// ── R-295 — ONE NAME PER SECRET ─────────────────────────────────────────────────────────────────
//
// Two different secrets were both called „Visszaállító kód":
//
// - the THREE-word code that gives a person control of the dashboard (the claim/reset code), and
// - the TEN-word code that opens the sealed off-site backups (the escrow recovery code).
//
// The names are near-homographs of each other and of „Helyreállítási kód", and the collision cost a
// real code. The ruling: the dashboard code is „Beállító kód" everywhere — the name the box already
// showed on the page where it is typed — the escrow code is „Helyreállítási kód", and „Visszaállító
// kód" is retired. Where one secret serves two situations the NAME stays constant and the SENTENCE
// changes.
//
// THIS IS NAMING, NOT FUNCTION. TestResetCode_StillAcceptedOnTheSetupPage below is the pin that says
// so: the code kept working throughout, and a rename that quietly broke acceptance would be a far
// worse outcome than the collision it fixed.
// claimPageHTML renders the claim page in one of its two branches through the PRODUCTION template
// tree. IsReset is the only field varied — in production it is `s.authEnabled()` (a set password
// means this is the reset flow), and it is the branch that used to rename the secret.
func claimPageHTML(t *testing.T, isReset bool) string {
t.Helper()
s := testServer(t)
s.loadTemplates()
var buf bytes.Buffer
data := map[string]interface{}{
"Title": "A szerver beállítása", "CustomerName": "Teszt Ügyfél", "Domain": "pelda.hu",
"ClaimCSRF": "t", "IsReset": isReset, "HasCode": true, "MinPassword": 12,
}
if err := s.tmpl.ExecuteTemplate(&buf, "claim", data); err != nil {
t.Fatalf("render claim (IsReset=%v): %v", isReset, err)
}
return buf.String()
}
// RED-PROOF: restore `{{if .IsReset}}Visszaállító kód{{else}}Beállító kód{{end}}` on the label in
// claim.html and the reset branch fails here, with the retired name quoted back.
func TestClaimPage_BothBranchesNameTheSameSecretTheSameWay(t *testing.T) {
for _, isReset := range []bool{false, true} {
branch := "first-time"
if isReset {
branch = "reset"
}
html := claimPageHTML(t, isReset)
if strings.Contains(html, "isszaállító kód") {
t.Errorf("[%s branch] the retired name „Visszaállító kód" +
"\" is still on the page — it collides with the escrow „Helyreállítási kód", branch)
}
if !strings.Contains(html, "eállító kód") {
t.Errorf("[%s branch] the page no longer names the secret „Beállító kód" +
"\" at all", branch)
}
}
}
// The escrow code's name must NOT appear on the dashboard-claim page — that confusion is the whole
// finding. (Substring is ASCII-safe on purpose: an accented pattern that fails to match reads exactly
// like the string being absent.)
func TestClaimPage_DoesNotMentionTheEscrowCodeName(t *testing.T) {
for _, isReset := range []bool{false, true} {
if html := claimPageHTML(t, isReset); strings.Contains(html, "elyreállítási kód") {
t.Error("the claim page names the ESCROW code — the two secrets are different, and " +
"naming one on the other's page is how a customer types the wrong one")
}
}
}
// ── The pin the ruling explicitly asks for: acceptance did not move ─────────────────────────────
// A reset-issued code is still accepted on the setup page and still sets the password. The rename
// touched copy only; if this ever fails, a naming change has broken a recovery path.
func TestResetCode_StillAcceptedOnTheSetupPage(t *testing.T) {
s, code, sett := claimTestServer(t)
form := url.Values{
"_csrf": {s.claimCSRFToken()}, "code": {code},
"new_password": {"a-strong-passphrase-12"}, "confirm_password": {"a-strong-passphrase-12"},
}
req := httptest.NewRequest(http.MethodPost, "/claim", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{Name: claimCSRFCookie, Value: s.claimCSRFToken()})
rr := httptest.NewRecorder()
s.handleClaimSubmit(rr, req)
if rr.Code != http.StatusFound {
t.Fatalf("a valid code was REFUSED after the rename: got %d, body=%q — the ruling was that "+
"this is naming, not function", rr.Code, claimFirstLine(rr.Body.String()))
}
if !sett.GetClaimed() {
t.Error("the box was not marked claimed — acceptance logic moved with the copy")
}
if !s.authEnabled() {
t.Error("the password was not set — acceptance logic moved with the copy")
}
}
@@ -0,0 +1,112 @@
package web
import (
"bytes"
"html/template"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
)
// R-259 — a disk we could not measure must not be drawn as a healthy empty one.
//
// These render the REAL dashboard template block against a real SystemInfo, because the defect was
// entirely in what the markup does with a zero. A test on the Go struct alone cannot see it.
// diskMeterBlock EXTRACTS the system-disk block from the SHIPPED dashboard.html, rather than
// duplicating it here. A copied block drifts, and a drifted copy is a test that passes while the
// page it claims to cover has changed — the fixture-is-not-the-wire mistake, which this project has
// now hit twice (R-262, and the OOB fixture in hub v0.99.0).
func diskMeterBlock(t *testing.T) string {
t.Helper()
raw, err := templateFS.ReadFile("templates/dashboard.html")
if err != nil {
t.Fatalf("read dashboard.html: %v", err)
}
src := string(raw)
const startMark = `{{if not .SystemInfo.DiskKnown}}`
i := strings.Index(src, startMark)
if i < 0 {
t.Fatalf("the DiskKnown guard is not in dashboard.html — R-259's fix is not in the shipped markup")
}
// the guard's matching {{end}} is the one closing the if/else chain: take through the
// "Kritikusan kevés hely" branch and its two closers
const endMark = `Kritikusan kevés hely</div>{{end}}`
j := strings.Index(src[i:], endMark)
if j < 0 {
t.Fatal("could not find the end of the disk meter block")
}
block := src[i : i+j+len(endMark)]
// close the outer if/else opened by startMark
return block + "\n </div>\n {{end}}"
}
// renderDiskMeter renders just the system-disk block of dashboard.html with the production funcmap.
func renderDiskMeter(t *testing.T, info system.SystemInfo) string {
t.Helper()
// the block under test, copied verbatim from dashboard.html by the guard below
src := diskMeterBlock(t)
tpl, err := template.New("m").Funcs((&Server{}).templateFuncMap()).Parse(src)
if err != nil {
t.Fatalf("parse: %v", err)
}
var buf bytes.Buffer
if err := tpl.Execute(&buf, map[string]any{"SystemInfo": info}); err != nil {
t.Fatalf("execute: %v", err)
}
return buf.String()
}
func healthyDisk() system.SystemInfo {
return system.SystemInfo{
DiskTotalGB: 100, DiskUsedGB: 42, DiskAvailGB: 58, DiskPercent: 42, DiskKnown: true,
}
}
// SCENARIO D — a disk we could not read says so, and draws nothing.
func TestDiskMeter_UnknownDrawsNoPictureAndSaysSo(t *testing.T) {
// exactly what a failed statfs leaves behind: every figure zero, and now DiskKnown=false
out := renderDiskMeter(t, system.SystemInfo{DiskKnown: false})
if strings.Contains(out, "0.0 GB") || strings.Contains(out, "(0%)") {
t.Errorf("a failed measurement printed a figure — „0.0 GB / 0.0 GB (0%%)\" is R-259, "+
"and it is the healthy-looking picture of an unread disk.\n%s", out)
}
if strings.Contains(out, "meter-fill") {
t.Errorf("a meter fill was drawn for a disk that was never measured — a 0%%-wide bar over an "+
"unread store is a picture of emptiness, and a picture is a claim.\n%s", out)
}
if strings.Contains(out, "nominal") {
t.Errorf("an unmeasured disk was coloured as healthy.\n%s", out)
}
if !strings.Contains(out, "nem olvashat") { // ASCII-safe fragment of „nem olvasható ki"
t.Errorf("the customer is not told the measurement could not be taken.\n%s", out)
}
}
// SCENARIO E — a disk we CAN read is unchanged, including its colour band.
func TestDiskMeter_KnownIsUnchanged(t *testing.T) {
out := renderDiskMeter(t, healthyDisk())
// fmtGB: >=100 renders whole, >=10 renders one decimal (funcmap.go:208-215)
for _, want := range []string{"42.0 GB", "100 GB", "(42%)", "meter-fill", "nominal"} {
if !strings.Contains(out, want) {
t.Errorf("a healthy box lost %q from its meter — this change must be invisible on a "+
"machine that is fine.\n%s", want, out)
}
}
if strings.Contains(out, "nem olvashat") {
t.Errorf("a healthy box gained the could-not-measure caveat.\n%s", out)
}
}
// The colour bands still work on a known disk — the guard must not have swallowed them.
func TestDiskMeter_KnownStillBands(t *testing.T) {
crit := healthyDisk()
crit.DiskPercent = 92
out := renderDiskMeter(t, crit)
if !strings.Contains(out, "crit") || !strings.Contains(out, "Kritikusan") {
t.Errorf("a critically full KNOWN disk lost its warning.\n%s", out)
}
}
@@ -0,0 +1,86 @@
package web
import (
"bytes"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// R-254 site two — WHAT §7.2 ESTABLISHED, PINNED SO IT CANNOT DRIFT BACK.
//
// The deploy page has TWO places a generated secret can appear, and they are NOT the same question:
//
// - the PRE-DEPLOY hidden input — a form must carry what it submits. README §318 documents why:
// the customer is shown the generated secrets so they can note them down, and submitting them
// back is what makes the saved value the SAME one they saw ("no silent re-generation on submit").
// This is NOT the defect and is deliberately left alone.
// - the READONLY display input on an ALREADY-DEPLOYED app — nothing is being submitted there (the
// hidden input is correctly omitted), yet the value was rendered into the body of a page the
// customer merely opens. That IS R-249's shape, and it is what v0.208.0 fixes.
//
// Both directions are asserted, because "fixed" here means one branch changed and the other did not.
const testDeploySecret = "TESTONLY-generated-db-pw-Xy91"
func renderDeployPage(t *testing.T, alreadyDeployed bool) string {
t.Helper()
s := securityHarness(t)
s.loadTemplates()
data := map[string]interface{}{
"Page": "stacks", "Title": "Telepítés", "Domain": "example.hu",
"Stack": stacks.Stack{Name: "vaultwarden", Deployed: alreadyDeployed},
"Meta": stacks.Metadata{DisplayName: "Vaultwarden", Slug: "vaultwarden"},
"AlreadyDeployed": alreadyDeployed,
"AutoFields": []stacks.DeployField{
{EnvVar: "DB_PASSWORD", Label: "Adatbázis jelszó", Type: "secret"},
},
"AutoFieldValues": map[string]string{"DB_PASSWORD": testDeploySecret},
}
var buf bytes.Buffer
if err := s.tmpl.ExecuteTemplate(&buf, "deploy", data); err != nil {
t.Fatalf("render deploy: %v", err)
}
return buf.String()
}
// RED-PROOF: drop the `{{if $isDeployed}}` branch so the deployed page renders `value="{{$val}}"`
// again — this fails, showing the secret returning to the body of a page with nothing to submit.
func TestDeployPage_DeployedApp_DoesNotCarryTheSecret(t *testing.T) {
html := renderDeployPage(t, true)
if strings.Contains(html, testDeploySecret) {
t.Error("R-254 site two: an already-deployed app's generated secret is in the response body " +
"of its settings page — nothing there submits it, so there is no form reason for it to " +
"be in the page at all")
}
// Assert the CONTROL, not the URL: the revealAutoField() function ships in the page script on
// both variants, so a substring match on the endpoint path matches the script and would report a
// control that is not there. (This test caught exactly that on itself.)
if !strings.Contains(html, `onclick="revealAutoField('vaultwarden','DB_PASSWORD'`) {
t.Error("no reveal control rendered, so the customer cannot see their own generated secret")
}
// The hidden input must NOT appear on a deployed app — it never did, and that is the asymmetry
// that makes the readonly input indefensible there.
if strings.Contains(html, `<input type="hidden" name="DB_PASSWORD"`) {
t.Error("a submit-carrying hidden input rendered on an already-deployed app")
}
}
// The OTHER half of §7.2's answer: the pre-deploy form still carries the value, deliberately. If this
// ever starts failing, someone has "fixed" a form by stopping it submitting what it must submit —
// which would silently re-generate the secret on save and hand the customer a password that is not
// the one they wrote down.
func TestDeployPage_PreDeployForm_StillCarriesTheValue_Deliberately(t *testing.T) {
html := renderDeployPage(t, false)
if !strings.Contains(html, `<input type="hidden" name="DB_PASSWORD" value="`+testDeploySecret+`">`) {
t.Error("the pre-deploy form no longer submits the generated secret — the saved value would " +
"then not be the one the customer was shown (README §318, 'no silent re-generation on submit')")
}
if strings.Contains(html, `onclick="revealAutoField(`) {
t.Error("the deployed-app reveal control leaked onto the pre-deploy form, where the value is " +
"already legitimately present")
}
}
+3
View File
@@ -322,6 +322,9 @@ func (s *Server) templateFuncMap() template.FuncMap {
}
return t.In(loc).Format("2006-01-02 15:04")
},
// humanBytes (v0.200.0, R-193) renders a size the same way every other surface does — the
// recovery page's listing shares the backup package's formatter rather than growing a second one.
"humanBytes": backup.HumanizeBytes,
"fmtTimeShort": func(t time.Time) string {
if t.IsZero() {
return ""
+292 -10
View File
@@ -176,6 +176,7 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) {
sysInfo := system.GetInfo(s.primaryHDDPath(), s.cpuCollector)
data := s.baseData("dashboard", "Vezérlőpult")
s.addRecoveryBanner(data, r) // R-241: the reminder bar, per visit
data["SettingsWarning"] = s.settings.LoadWarning // non-empty if settings.json was recovered from corruption
data["Stacks"] = deployedStacks
data["MissingStorage"] = s.missingStorageMap(deployedStacks)
@@ -312,6 +313,7 @@ func (s *Server) launcherApps() []LauncherApp {
// "Indítópult megosztása" share state (v0.165.0) for the modal.
func (s *Server) launcherHandler(w http.ResponseWriter, r *http.Request) {
data := s.baseData("launcher", "Indítópult")
s.addRecoveryBanner(data, r) // R-241: the reminder bar, per visit
data["Apps"] = s.launcherApps()
// Share modal state. The share URL is built from the request Host at render time (the canonical
@@ -669,11 +671,29 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s
// Initial auto-generated login (e.g. Crafty writes a random admin password to a file at first
// boot). Read it live from the container so the customer doesn't have to dig through logs. Only
// for deployed apps that declare an initial_credentials spec; hidden when unreadable.
//
// ⚠ R-254 (v0.208.0) — THE PASSWORD DOES NOT GO INTO THE PAGE DATA, AND THAT IS THE WHOLE FIX.
//
// Until v0.208.0 this handed the whole struct to the template, which rendered the password into
// `<span id="initcred-pw-val" hidden>…</span>`. `hidden` is an attribute the browser honours when
// DRAWING; the plaintext was in the response body of every render, so a `curl` of an app's info
// page returned a real per-install credential. Identical in shape to R-249 one page over, and this
// one is an app the customer actually logs into.
//
// What travels now is the non-secret half (username, note) plus a BOOLEAN. The value is fetched by
// POST /apps/<slug>/initial-credentials/reveal, which re-reads it LIVE from the container — see
// §7.1: caching it here would put it straight back where it started, one layer in.
if found.Deployed && found.Meta.InitialCreds != nil {
if creds, err := s.stackMgr.ReadInitialCredentials(found.Name); err != nil {
if creds, err := s.readInitialCreds(found.Name); err != nil {
s.logger.Printf("[WARN] [web] initial-creds for %s: %v", found.Name, err)
} else if creds != nil && creds.Available {
data["InitialCreds"] = creds
data["InitialCreds"] = &stacks.ExtractedCreds{
Available: creds.Available,
Username: creds.Username,
Note: creds.Note,
// Password deliberately NOT carried — the reveal endpoint is the only path to it.
}
data["InitialCredsHasPassword"] = strings.TrimSpace(creds.Password) != ""
}
}
@@ -920,6 +940,31 @@ func (s *Server) backupsRemoteHandler(w http.ResponseWriter, r *http.Request) {
data["OffboxCeremonyTimedOut"] = timedOut
// v0.142.0 offsite-repo continuity: the orphan card + the auto-refresh (Part C) trigger.
data["OffboxOrphaned"] = s.backupMgr != nil && s.backupMgr.OffboxOrphaned()
// R-193: the PERMANENT entry point to the recovery screen. It is bound to recoveryOffer, NOT to
// recoveryInterrupts — "most nem" silences the full-page interruption and must never remove the
// route to the data. A one-shot notice a flustered person clicks past is a notice that never
// happened; this is what makes Scenario E true.
data["RecoveryOffer"] = s.recoveryOffer()
// R-241 (v0.206.0): the abandonment countdown, stated on the page the customer chose it from.
// It is shown for the WHOLE window, not only at the reminder marks — the bar on other pages is a
// nudge, this is the record, and a deletion date must be findable on a quiet day too.
if s.backupMgr != nil {
if st := s.backupMgr.AbandonStatus(); st.Active {
data["AbandonActive"] = true
data["AbandonDaysLeft"] = st.DaysLeft
data["AbandonDate"] = st.DueAt.Format("2006-01-02")
// R-302: this block makes the SAME retrieval promise as the banner, under a different verb
// („visszaszerezhetők" vs the banner's „visszaszerezheted"), which is why it was a fourth
// instance nobody had counted. Same single derivation — fixing one surface and not the other
// would leave the page contradicting the strip above it.
data["AbandonRetrievalOffered"] = st.RetrievalStillOffered
} else if st.PurgeRequested {
// The store is deleted and the sealed package is on its way out. Say so rather than
// showing nothing, or the page silently loses a thing the customer was watching.
data["AbandonPurging"] = true
}
}
s.addRecoveryBanner(data, r)
s.executeTemplate(w, r, "backups_remote", data)
}
@@ -1002,6 +1047,23 @@ func (s *Server) backupsRestoreHandler(w http.ResponseWriter, r *http.Request) {
if s.backupMgr != nil {
data["OffsiteRestoreCopies"] = s.backupMgr.ListOffsiteRestoreCopies()
}
// R-237: the restore list is driven by the STORE, not by what is deployed and toggled. A rebuilt
// box has neither, and used to be shown nothing at all while its snapshots sat in the repository.
if s.backupMgr != nil {
rows, state := s.offsiteRestoreRows(r.Context())
data["OffsiteRestoreRows"] = rows
data["OffsiteStoreState"] = string(state)
// R-252: a rebuilt box's drives survive but their REGISTRATION does not, and every restore
// then refuses with a message that names no next step. Asked through the backup manager's own
// predicate so the page and the resolver cannot disagree. FALSE on a healthy box, where the
// template renders exactly as before (Scenario E).
data["NoRestoreDestination"] = !s.backupMgr.HasRestoreDestination()
// R-280: the notice above told the customer this was „két kattintás" and pointed at a picker
// that was empty, so it was zero clicks. The promise is now conditional on the destination it
// points at actually having something in it — and when it does not, the page says so and names
// what to do instead. Same derivation the picker uses, so the two cannot disagree.
data["HasAttachDestination"] = len(s.attachableStores()) > 0
}
s.executeTemplate(w, r, "backups_restore", data)
}
@@ -1093,6 +1155,42 @@ type AppBackupRow struct {
Warnings []string
}
// appDumpVerdict is THIS app's tier-1 verdict, from THIS app's own most recent dump result.
//
// "" (no icon) — no dump result recorded for this stack: the app has no database, or no run has
//
// happened since start-up. Presence of a restore point is NOT evidence the last
// run worked, and this is the case that used to be drawn as a green tick (R-258).
//
// "error" — this app's own most recent result carries an Error.
// "ok" — this app's own most recent result succeeded.
//
// Deliberately NOT considered: RECENCY. A tick over a three-week-old restore point is a real
// weakness, but an age threshold means inventing a number, and the time is already printed beside
// the icon. Recorded as an observation rather than changed here.
//
// Deliberately NOT used: DBDumpStatus.Success, which is the box's most recent RUN whichever app it
// belonged to. It is correct for the global tier1DBStatus label a few lines above and is the exact
// lookalike that produced this defect.
func appDumpVerdict(dump *backup.DBDumpStatus, stackName string) string {
if dump == nil {
return ""
}
verdict := ""
for _, res := range dump.Results {
if res.DB.StackName != stackName {
continue
}
// Results is one entry per DATABASE; an app may have several. Any failure among this
// app's databases makes the app's backup a failure — a partial dump is not a success.
if res.Error != nil {
return "error"
}
verdict = "ok"
}
return verdict
}
// buildAppBackupRows constructs one AppBackupRow per deployed app for the backup page.
// Disk-tier (cross-drive / restic) backup has moved to the host agent; this now
// reflects only the app-data backup (DB dumps + Docker-volume tars).
@@ -1187,12 +1285,24 @@ func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackup
if s.backupMgr != nil {
if pts, ok := s.backupMgr.ListRestorePoints(app.StackName); ok && len(pts) > 0 {
row.Tier1LastRun = pts[0].Time
// A unit exists: green unless the DB dump failed (keep tier1DBStatus as the source).
if status.LastDBDump != nil && !status.LastDBDump.Success {
row.Tier1LastStatus = "error"
} else {
row.Tier1LastStatus = "ok"
}
// R-259's sibling, R-258: THE VERDICT MUST BE ABOUT THIS APP, AND SILENT WHEN
// THERE IS NOTHING TO SAY.
//
// This used to read `status.LastDBDump.Success`, which is the box's single most
// recent dump RUN — whichever app it belonged to (backup.go: `m.lastDBDump`). So an
// app whose own dump failed last night showed a tick as long as some OTHER app
// dumped successfully afterwards, and an app with no database at all took the
// `nil` branch and went green on the mere existence of a restore point. A tick
// standing for "a file exists" is the presence-is-not-success rule as a UI badge.
//
// Per-app truth needs no new plumbing: DBDumpStatus.Results carries one DumpResult
// per database, each with its DiscoveredDB.StackName and its own Error.
//
// Three states, deliberately — the template renders an icon for "ok" and "error"
// and NOTHING for any third value, which is the slot "we do not know" belongs in.
// The recovery unit carries no per-run verdict of its own (recovery_unit.go: times
// and checksums, no outcome), so green cannot honestly be derived from presence.
row.Tier1LastStatus = appDumpVerdict(status.LastDBDump, app.StackName)
}
}
@@ -1547,8 +1657,23 @@ func (s *Server) notificationsPageData() map[string]interface{} {
func (s *Server) securityPageData() map[string]interface{} {
data := s.settingsBaseData("settings-security", "Biztonság és hozzáférés")
// Recovery info for emergency section
data["RetrievalPassword"] = s.settings.GetRetrievalPassword()
// Recovery info for emergency section.
//
// ⚠ R-249 — THE VALUE DOES NOT GO IN THE PAGE, AND THAT IS THE WHOLE FIX.
//
// Until v0.207.0 this line put the retrieval passphrase into the template data and
// `settings_security.html` rendered it into a `display:none` span behind a „Megjelenít" button.
// That toggle stops the browser DRAWING it and nothing else: the plaintext was in the response
// body of every render, so a `curl` of the page returned it — which is how it was found, by
// landing in a session transcript during the 2026-08-07 walk. It was therefore also in browser
// caches, in history, in any saved page and in any screen-share of the page source.
//
// The product already had this exact rule and this page did not follow it — `escrow_handlers.go`
// states it for the recovery code: *"reveal (claim XHR only — R is NEVER templated server-side
// into HTML)"*. The passphrase now follows the same shape: the page carries only whether one
// EXISTS, and the value comes from POST /settings/retrieval-password/reveal, which is an
// explicit authenticated act and is logged as one.
data["HasRetrievalPassword"] = strings.TrimSpace(s.settings.GetRetrievalPassword()) != ""
data["HubURL"] = s.cfg.Hub.URL
data["SupportEmail"] = "support@felhom.eu"
data["SupportURL"] = "https://felhom.eu/kapcsolat"
@@ -1582,6 +1707,163 @@ func (s *Server) securityPageData() map[string]interface{} {
return data
}
// settingsRetrievalPasswordRevealHandler — POST /settings/retrieval-password/reveal (v0.207.0, R-249).
//
// The ONLY path by which the retrieval passphrase reaches a browser. It is behind RequireAuth and
// CsrfProtect like every other POST on this mux, so reaching it takes a live session AND a token
// bound to it — where the old rendering took nothing but the ability to read a page the customer
// merely opened.
//
// WHY A POST FOR A READ, deliberately and not by accident: a GET would be re-fetchable from history,
// pre-fetchable by a browser, and loggable in any proxy's access log with the response cached. The
// act of revealing a secret is a state change in every sense that matters here, and CsrfProtect only
// covers unsafe methods — a GET would have no CSRF cover at all.
//
// `no-store` matters as much as the method: without it a back-navigation can re-present the response
// body from the disk cache, which is the same defect one layer down.
func (s *Server) settingsRetrievalPasswordRevealHandler(w http.ResponseWriter, r *http.Request) {
pw := strings.TrimSpace(s.settings.GetRetrievalPassword())
if pw == "" {
// Not an error: a box that never stored one has nothing to reveal, and saying so is not a
// leak. The page does not offer the button in that case (HasRetrievalPassword gates it).
w.Header().Set("Cache-Control", "no-store")
escrowJSON(w, http.StatusNotFound, nil, "Ezen a gépen nincs tárolt visszaállítási jelszó.")
return
}
// The reveal is an event, and it was not one before: the same act on the hub's break-glass
// credential writes `recovery_credential_revealed`, while reading this value off the page markup
// left no trace anywhere. The VALUE is never logged — only that it was asked for, and by whom.
s.logger.Printf("[INFO] [web] retrieval passphrase revealed via the security page from %s (value never logged)", clientIP(r))
w.Header().Set("Cache-Control", "no-store")
escrowJSON(w, http.StatusOK, map[string]any{"password": pw}, "")
}
// readInitialCreds is the ONE place an app's generated first-login credential is read — the live
// container read, behind a test seam. Both the info page (which takes only the non-secret half) and
// the reveal endpoint (which takes the value) go through here, so they cannot diverge.
func (s *Server) readInitialCreds(stackName string) (*stacks.ExtractedCreds, error) {
if s.initialCredsFn != nil {
return s.initialCredsFn(stackName)
}
return s.stackMgr.ReadInitialCredentials(stackName)
}
// appAutoFieldRevealHandler — POST /stacks/{name}/auto-field/reveal (v0.208.0, R-254 site two).
//
// WHAT §7.2 ESTABLISHED, AND WHY THIS EXISTS RATHER THAN A CHANGE TO THE HIDDEN INPUT.
//
// The hidden input (`{{if and (not $isDeployed) (eq .Type "secret")}}`) is NOT this defect. It fires
// only on the PRE-DEPLOY form, and README §318 documents why the value must round-trip: the customer
// is shown the generated secrets so they can write them down, and submitting them back is what makes
// the saved value the SAME one they saw ("no silent re-generation on submit"). A form must carry what
// it submits.
//
// The defect is the neighbouring READONLY display input. On an ALREADY-DEPLOYED app the hidden input
// is correctly omitted — nothing is being submitted — yet `<input type="password" … value="{{$val}}"
// readonly>` still renders the secret into the body of a page the customer merely opens. That is
// R-249's shape exactly, with no form to justify it.
//
// PER-SECRET, NOT GENERIC: it serves only fields the CATALOG declares `type: secret` on that stack.
// An env var that is not an auto-generated secret field is refused — that check is the authorisation,
// and it is what stops this becoming "read me any value out of any app's config".
func (s *Server) appAutoFieldRevealHandler(w http.ResponseWriter, r *http.Request, stackName string) {
if s.stackMgr == nil {
escrowJSON(w, http.StatusServiceUnavailable, nil, "Az alkalmazáskezelő nem elérhető.")
return
}
stack, ok := s.stackMgr.GetStack(stackName)
if !ok {
escrowJSON(w, http.StatusNotFound, nil, "Ismeretlen alkalmazás.")
return
}
_ = r.ParseForm()
envVar := strings.TrimSpace(r.FormValue("env_var"))
if envVar == "" {
escrowJSON(w, http.StatusBadRequest, nil, "Hiányzó mező.")
return
}
// AUTHORISATION: the field must be an auto-generated SECRET of this stack's catalog metadata.
allowed := false
for _, f := range stack.Meta.AutoGeneratedFields() {
if f.EnvVar == envVar && f.Type == "secret" {
allowed = true
break
}
}
if !allowed {
s.logger.Printf("[WARN] [web] auto-field reveal refused for %s/%s: not an auto-generated secret field", stackName, envVar)
escrowJSON(w, http.StatusForbidden, nil, "Ez a mező nem kérhető le.")
return
}
appCfg := s.stackMgr.LoadAppConfigByName(stackName)
if appCfg == nil {
escrowJSON(w, http.StatusNotFound, nil, "Az alkalmazás beállításai nem olvashatók.")
return
}
val := crypto.DecryptMap(s.encKey, appCfg.Env)[envVar]
if strings.TrimSpace(val) == "" {
w.Header().Set("Cache-Control", "no-store")
escrowJSON(w, http.StatusNotFound, nil, "Ehhez a mezőhöz nincs mentett érték.")
return
}
s.logger.Printf("[INFO] [web] auto-generated secret revealed for %s/%s from %s (value never logged)", stackName, envVar, clientIP(r))
w.Header().Set("Cache-Control", "no-store")
escrowJSON(w, http.StatusOK, map[string]any{"value": val}, "")
}
// appInitialCredsRevealHandler — POST /apps/{slug}/initial-credentials/reveal (v0.208.0, R-254).
//
// The ONLY path by which an app's generated first-login password reaches a browser. Same shape as
// v0.207.0's retrieval-password reveal, deliberately: POST (so CsrfProtect covers it and it is not
// re-fetchable from history), `no-store`, and **logged as an act** — reading it off the markup left
// no trace anywhere, which is why nobody can say whether any of these was ever read.
//
// ⚠ PER-SECRET, NOT GENERIC. This serves exactly one kind of value for one app. A single endpoint
// that returned any named secret would be a worse thing than the defect it fixed: it would turn three
// narrow exposures into one lever with a parameter.
//
// §7.1 — IT RE-READS THE CONTAINER, it does not serve a copy the page already had. Caching the value
// in the handler's page data would put it back in the response body one layer in, which is the defect.
// The consequence is that the reveal can legitimately fail (container stopped, file deleted after
// first login) and it SAYS SO — an empty string here would render as a blank password and read as
// "your password is empty".
func (s *Server) appInitialCredsRevealHandler(w http.ResponseWriter, r *http.Request, slug string) {
if s.stackMgr == nil {
escrowJSON(w, http.StatusServiceUnavailable, nil, "Az alkalmazáskezelő nem elérhető.")
return
}
// Resolved EXACTLY as appDetailHandler resolves it — same loop, same field. A second definition
// of "which app is this slug" is how a reveal ends up answering for a different app than the page
// the customer is looking at.
var found *stacks.Stack
for _, stack := range s.stackMgr.GetStacks() {
if stack.Meta.Slug == slug {
found = &stack
break
}
}
if found == nil {
escrowJSON(w, http.StatusNotFound, nil, "Ismeretlen alkalmazás.")
return
}
creds, err := s.readInitialCreds(found.Name)
if err != nil {
// Never swallowed, and never surfaced raw — the error can name a container/path.
s.logger.Printf("[WARN] [web] initial-creds reveal for %s: %v", found.Name, err)
escrowJSON(w, http.StatusBadGateway, nil, "A kezdeti jelszó beolvasása nem sikerült.")
return
}
if creds == nil || !creds.Available || strings.TrimSpace(creds.Password) == "" {
w.Header().Set("Cache-Control", "no-store")
escrowJSON(w, http.StatusNotFound, nil,
"A kezdeti jelszó most nem olvasható ki — az alkalmazásnak futnia kell hozzá, és lehet, hogy a fájlt az első bejelentkezés után már törölték.")
return
}
s.logger.Printf("[INFO] [web] initial-credential password revealed for %s from %s (value never logged)", found.Name, clientIP(r))
w.Header().Set("Cache-Control", "no-store")
escrowJSON(w, http.StatusOK, map[string]any{"password": creds.Password}, "")
}
func (s *Server) settingsHandler(w http.ResponseWriter, r *http.Request) {
s.executeTemplate(w, r, "settings_system", s.systemPageData())
}
@@ -3,6 +3,7 @@ package web
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
@@ -101,6 +102,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
@@ -228,12 +230,28 @@ func (s *Server) offboxRunHandler(w http.ResponseWriter, r *http.Request) {
offboxRedirect(w, r, "A távoli tároló elárvult — előbb indíts új távoli mentést a kártyán látható módon.", true)
return
}
// R-234: the single-flight decision is taken SYNCHRONOUSLY, before the goroutine, so the customer
// is told what actually happened to THEIR request. Deciding it inside the goroutine is what made
// the drop invisible: the handler had already answered „elindult" and the page then showed the
// PREVIOUS run's „✓ Rendben".
// IsRunning() is the CONCURRENCY flag — the very one acquireRunning guards — which is what this
// question is about. (The documented "use RestoreStatus for display" trap is a different question.)
if s.backupMgr.IsRunning() {
s.logger.Printf("[INFO] [web] manual off-box backup NOT started for this request: a run is already in flight")
offboxRedirect(w, r, "Már fut egy távoli mentés — ez a kérés nem indított újat. A most látható eredmény még a korábbi futásé; várd meg, míg ez befejeződik.", true)
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Hour)
defer cancel()
// ...WithProgress: this is the MANUAL trigger, so the page gets live bytes/percent/current app
// (4c). The nightly scheduler keeps calling RunOffboxBackup and stays silent.
if err := s.backupMgr.RunOffboxBackupWithProgress(ctx); err != nil {
if errors.Is(err, backup.ErrOffboxRunInFlight) {
// Lost the race between the check above and acquireRunning — rare, and still not a failure.
s.logger.Printf("[INFO] [web] manual off-box backup dropped by the single-flight (raced)")
return
}
s.logger.Printf("[WARN] [web] manual off-box backup failed: %v", err)
}
}()
@@ -315,9 +333,18 @@ func (s *Server) offboxRestoreHandler(w http.ResponseWriter, r *http.Request) {
defer cancel()
sizeHuman, err := s.backupMgr.OffboxRestorePrepareFull(pctx, app)
if err != nil {
// R-238: this leg starts no job, so it appears NOWHERE in the restore-op status. Until
// v0.204.0 it also logged nothing, which made a refused disaster restore — including a
// refusal by the headroom gate — completely invisible on the box: no error, no line, and
// a redirect that lands the customer back where they started. Diagnosing a silence is
// what this project has spent a fortnight removing.
s.logger.Printf("[WARN] [web] off-box full-restore preparation REFUSED for %s (no job started): %v", app, err)
offboxRedirectTo(w, r, restoreWizardPath(app), err.Error(), true)
return
}
// The success half is logged too: it is the step that decides the customer may proceed, and
// "the size gate passed at N" is the line that explains the confirm they were then shown.
s.logger.Printf("[INFO] [web] off-box full-restore prepared for %s (size %s) — awaiting the customer's confirm; no restore has started", app, sizeHuman)
// R-48: the size-gate reveal now lands on the app's wizard (prepare-confirm step) rather than
// on the list page. Same params, same meaning — only the surface that renders them changed.
http.Redirect(w, r, restoreWizardPath(app)+"&full_prep="+url.QueryEscape(app)+"&full_size="+url.QueryEscape(sizeHuman), http.StatusFound)
@@ -326,6 +353,8 @@ func (s *Server) offboxRestoreHandler(w http.ResponseWriter, r *http.Request) {
// Fast-path refuse a concurrent op, then run async on a BACKGROUND context (a proxy read-timeout on
// r.Context() would CANCEL the SFTP restore mid-flight — the F4 lesson).
if s.backupMgr.IsRunning() {
// Same silence class as the size gate: a refusal that starts nothing must still be findable.
s.logger.Printf("[WARN] [web] off-box restore refused for %s (mode=%s): another backup/restore op is already running", app, mode)
offboxRedirectTo(w, r, restoreWizardPath(app), "Egy mentési/visszaállítási művelet már fut.", true)
return
}
@@ -0,0 +1,72 @@
package web
import (
"bytes"
"context"
"errors"
"log"
"net/http/httptest"
"net/url"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// R-238 — the full-restore size gate must never refuse in silence.
//
// WHAT WAS MEASURED (2026-08-06, part4 venue). `POST /backup/offbox/restore` with `mode=full` and no
// `confirm=1` is step 1 of a deliberate two-step: it computes size + headroom, starts NO job, and
// redirects carrying `&full_prep=<app>` so the wizard reveals the commit. Driving it without
// carrying that parameter forward lands back on the intent step — which is correct behaviour, and is
// why the endpoint-level run looked like "the button does nothing".
//
// The REAL defect underneath, and the one this pins: neither branch of that step wrote anything to
// the controller's log. `restore-status` is empty by design (no job), the redirect is invisible, and
// `offboxRedirectTo` only flashes to the page — so a customer refused a disaster restore, INCLUDING
// a refusal by the headroom gate, left no trace on the box at all. "No error, no log line" is a
// diagnosis problem whoever triggers it.
//
// Handler-level on purpose: the silence was in the handler, and a helper-level assertion cannot
// observe it. That mistake has been made three times in this arc.
func TestOffboxRestore_FullPrepareRefusal_IsNotSilent(t *testing.T) {
s, sett, m := newOffboxWebServer(t)
if err := sett.SetOffboxTarget(&settings.OffboxTarget{
Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo",
Schedule: "daily", EscrowState: "escrowed",
}); err != nil {
t.Fatal(err)
}
if err := m.WriteOffboxSecrets("PRIVATE-KEY-MATERIAL", "nas.local ssh-ed25519 AAAAhostkey"); err != nil {
t.Fatal(err)
}
if !m.OffboxConfigured() {
t.Fatal("fixture: the target must be configured, or the handler exits before the size gate")
}
// Deterministic refusal: every restic call fails, so the size gate errors for a real reason
// instead of being skipped, and no network is involved.
m.SetOffboxRunner(func(ctx context.Context, env []string, args ...string) ([]byte, error) {
return nil, errors.New("repository unreachable")
})
var logbuf bytes.Buffer
s.logger = log.New(&logbuf, "", 0)
form := url.Values{"app": {"calibre-web"}, "mode": {"full"}}
req := httptest.NewRequest("POST", "/backup/offbox/restore", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
s.offboxRestoreHandler(w, req)
if w.Code != 302 {
t.Fatalf("the size gate redirects; got %d", w.Code)
}
got := logbuf.String()
// The CONSEQUENCE, not the mechanism: whatever the outcome, this step must be findable in the log.
if !strings.Contains(got, "full-restore preparation REFUSED") && !strings.Contains(got, "full-restore prepared") {
t.Errorf("the full-restore size gate wrote NOTHING to the log \u2014 that is R-238's residue.\nlog was: %q", got)
}
if !strings.Contains(got, "calibre-web") {
t.Errorf("the line must name the app it refused, got %q", got)
}
}
@@ -0,0 +1,71 @@
package web
import (
"bytes"
"log"
"net/http/httptest"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// R-234, THE MEASURED CAUSE — a manual run that the single-flight dropped must not be reported as one
// that started.
//
// WHAT WAS MEASURED (Part 4 venue, 2026-08-06). The customer toggled an app on and pressed
// „Távoli mentés most". The handler answered „A távoli mentés elindult — az állapot itt frissül.",
// `runOffboxBackup` hit `acquireRunning`, logged an INFO and returned **nil**, and the card then
// showed the PREVIOUS run's „✓ Rendben · 1 pillanatkép" — which reads as "the app I just selected is
// backed up". It was not: the restore refused for that app minutes later, and only a third run
// carried it.
//
// The verdict fix (R-234 part 1) does not cover this: there was no skipped app in that run, because
// there was no run. A request that did nothing must say so.
//
// Handler-level on purpose: the decision now lives in the handler, before the goroutine, and a
// manager-level assertion cannot observe what the customer was told.
func TestOffboxRunHandler_InFlightRequestIsNotReportedAsStarted(t *testing.T) {
s, sett, m := newOffboxWebServer(t)
if err := sett.SetOffboxTarget(&settings.OffboxTarget{
Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo",
Schedule: "daily", EscrowState: "escrowed",
}); err != nil {
t.Fatal(err)
}
if err := m.WriteOffboxSecrets("PRIVATE-KEY-MATERIAL", "nas.local ssh-ed25519 AAAAhostkey"); err != nil {
t.Fatal(err)
}
if !m.OffboxConfigured() || !m.OffboxRunnable() {
t.Fatal("fixture: the target must be configured and runnable, or the handler exits earlier")
}
// Occupy the single-flight exactly as a run in progress would.
if err := m.AcquireRunningForTest(); err != nil {
t.Fatalf("fixture: %v", err)
}
defer m.ReleaseRunningForTest()
var logbuf bytes.Buffer
s.logger = log.New(&logbuf, "", 0)
w := httptest.NewRecorder()
s.offboxRunHandler(w, httptest.NewRequest("POST", "/backup/offbox/run", nil))
if w.Code != 302 {
t.Fatalf("the handler redirects; got %d", w.Code)
}
loc := w.Header().Get("Location")
if strings.Contains(loc, "elind") {
t.Errorf("a dropped request must NOT be reported as started — that is the defect. Location: %q", loc)
}
if !strings.Contains(loc, "flash_error") {
t.Errorf("it must reach the customer as a problem, not a success flash. Location: %q", loc)
}
// It must also say the visible result belongs to the EARLIER run — that is what was misread.
if !strings.Contains(loc, "kor%C3%A1bbi") {
t.Errorf("the message must say the shown result is the earlier run's. Location: %q", loc)
}
if !strings.Contains(logbuf.String(), "NOT started") {
t.Errorf("the drop must be findable in the log too, got %q", logbuf.String())
}
}
@@ -0,0 +1,185 @@
package web
import (
"context"
"errors"
"sort"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
)
// R-237 — what a customer may restore is decided by WHAT IS IN THE STORE, not by what happens to be
// installed.
//
// WHY THIS EXISTS. Until v0.204.0 the off-site restore list was `buildOffboxApps()` filtered on
// `.Enabled`: apps that are **currently deployed** AND **currently toggled on for future off-site
// backups**. Both halves are wrong for the one situation the list exists to serve. Measured on the
// R-201 re-walk (2026-08-06, `documentation/tests/part4-rewalk-2026-08-06/journal.md`): after a
// rebuild the box had no deployed stacks, so the page said „Nincs telepített alkalmazás" and the
// wizard refused every app with „Ez az alkalmazás nincs távoli mentésre kijelölve" — while the
// repository held their snapshots the whole time.
//
// That is a circular dead end at the worst possible moment: to restore an app you must select it, to
// select it you must have installed it, and to know what to install you must see the backup you
// cannot see. A household that has just lost its box does not know what it used to run.
//
// The toggle is a statement about FUTURE backups. Requiring it to look at a PAST one conflates two
// different questions, and that conflation is the defect.
//
// THE RULE, stated where it is implemented: the store is the source of the list. Installed-ness is a
// property OF a row, never a filter on it — it changes what restoring implies, not whether the row
// exists.
// offboxMarkerTag is on EVERY off-site snapshot (`restic backup --tag felhom-offbox --tag <stack>`),
// so it appears in the tag set beside the real app names. It is a marker, not an app, and listing it
// would offer the customer a restore of something that does not exist.
const offboxMarkerTag = "felhom-offbox"
// offsiteStoreState is what we know about the repository itself, kept separate from the rows so that
// "we could not read it" can never be rendered as "there is nothing in it".
//
// This is R-225's rule one screen over: an unknown is not a zero. The remote-backup card already
// learned to say „még nem tudjuk" rather than 0; a restore list that renders a read failure as an
// empty list makes the same false claim about something more consequential.
type offsiteStoreState string
const (
// offsiteStoreKnown — the repository was read. The rows are the truth.
offsiteStoreKnown offsiteStoreState = "known"
// offsiteStoreUnreadable — the repository could not be read. Rows may still list installed apps,
// but the page MUST say the store's contents are unknown.
offsiteStoreUnreadable offsiteStoreState = "unreadable"
// offsiteStoreNoTarget — no off-site target is configured yet. Distinguished from unreadable
// because it resolves by itself once the tier is (re-)applied, which is the pristine rebuilt
// shape (R-236).
offsiteStoreNoTarget offsiteStoreState = "no-target"
)
// OffsiteRestoreRow is one restorable thing, from the union of "in the store" and "installed here".
type OffsiteRestoreRow struct {
App string
DisplayName string
Slug string
// InStore — a snapshot in the repository carries this app's tag. This is what makes the row
// restorable at all.
InStore bool
// Installed — the app is deployed on this box right now. NOT a filter: it decides what restoring
// implies (restore in place vs. reinstall first), which the page states rather than hiding.
Installed bool
// Enabled — the app is toggled on for FUTURE off-site backups. Carried for display only; it must
// never gate a restore.
Enabled bool
// StoreUnknown — the repository could not be read, so InStore is not a claim about anything. The
// row keeps its action: refusing to offer a restore because we could not look would be the SAME
// false claim as rendering the read failure as an empty list, pointed the other way. The customer
// is told we could not read it; the attempt then fails honestly rather than being pre-empted.
StoreUnknown bool
LatestAt time.Time
SizeBytes int64
}
// Restorable reports whether a restore may be OFFERED for this row: the store holds it, or we could
// not read the store and must not pretend that means "no".
func (r OffsiteRestoreRow) Restorable() bool { return r.InStore || r.StoreUnknown }
// buildOffsiteRestoreRows merges the repository's contents with the installed set.
//
// Pure on purpose: every §7 edge case (a snapshot with no app, an app with no snapshot, an unreadable
// store, the marker tags) is a row in one table test rather than a live-repository fixture.
//
// invErr is classified, not swallowed: a not-yet-configured target and a failed read are different
// answers to the customer and the page says which.
func buildOffsiteRestoreRows(inv backup.OffsiteInventory, invErr error, installed []OffboxAppRow) ([]OffsiteRestoreRow, offsiteStoreState) {
state := offsiteStoreKnown
if invErr != nil {
state = offsiteStoreUnreadable
if backup.ErrNoOffsiteTarget(invErr) {
state = offsiteStoreNoTarget
}
}
byApp := map[string]*OffsiteRestoreRow{}
// The store first — it is the source of the list.
if invErr == nil {
for _, a := range inv.Apps {
if a.App == offboxMarkerTag || a.App == backup.SharesPseudoStack {
// The marker is not an app; shares have their own entry with their own restore path
// and no per-app wizard, so a synthetic row here would be a button that cannot work.
continue
}
byApp[a.App] = &OffsiteRestoreRow{
App: a.App, DisplayName: a.App, InStore: true,
LatestAt: a.LatestAt, SizeBytes: a.SizeBytes,
}
}
}
// Then the installed set — it supplies display names and slugs, and contributes rows of its own so
// that "installed, but nothing to restore" is SHOWN rather than silently absent.
for _, ia := range installed {
row, ok := byApp[ia.Name]
if !ok {
row = &OffsiteRestoreRow{App: ia.Name}
byApp[ia.Name] = row
}
row.Installed = true
row.Enabled = ia.Enabled
if ia.DisplayName != "" {
row.DisplayName = ia.DisplayName
}
row.Slug = ia.Slug
}
out := make([]OffsiteRestoreRow, 0, len(byApp))
for _, r := range byApp {
r.StoreUnknown = state != offsiteStoreKnown
out = append(out, *r)
}
// Restorable rows first (that is what the page is for), then alphabetically — a stable order, so
// the list does not reshuffle between reloads.
sort.Slice(out, func(i, j int) bool {
if out[i].InStore != out[j].InStore {
return out[i].InStore
}
return out[i].App < out[j].App
})
return out, state
}
// offsiteRestoreRows reads the repository and merges it with the installed set. One `restic
// snapshots --json` behind the manager's own probe timeout; a read failure is CLASSIFIED and
// returned, never swallowed into an empty list.
func (s *Server) offsiteRestoreRows(ctx context.Context) ([]OffsiteRestoreRow, offsiteStoreState) {
installed := s.buildOffboxApps()
if s.backupMgr == nil {
return buildOffsiteRestoreRows(backup.OffsiteInventory{}, errNoBackupManager, installed)
}
inv, err := s.backupMgr.OffsiteInventoryList(ctx)
if err != nil && !backup.ErrNoOffsiteTarget(err) {
// Loud on purpose: an unreadable store is the case that used to render as "empty", and the
// customer-facing wording depends on this being distinguishable in the log too.
s.logger.Printf("[WARN] [web] offsite restore list: repository unreadable — listing it as UNKNOWN, not empty: %v", err)
}
return buildOffsiteRestoreRows(inv, err, installed)
}
// errNoBackupManager stands in for "this box cannot answer" so the page says unknown rather than
// empty when the manager is absent (tests, and the brief window before wiring).
var errNoBackupManager = errors.New("backup manager unavailable")
// resolveOffsiteRestoreApp finds the wizard's app among the rows.
//
// It deliberately does NOT require the future-backup toggle — that gate is what R-237 is about. It
// requires the row to be RESTORABLE, because a wizard for an app with no snapshot would be a page of
// controls with nothing behind them.
func resolveOffsiteRestoreApp(rows []OffsiteRestoreRow, name string) *OffsiteRestoreRow {
for i := range rows {
if rows[i].App == name && rows[i].Restorable() {
cp := rows[i]
return &cp
}
}
return nil
}
@@ -0,0 +1,181 @@
package web
import (
"errors"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
)
// R-237 — the truth table for "what may this customer restore". Every §7 edge case is a row here
// rather than a live-repository fixture, because the case that matters most (a REBUILT box: nothing
// installed, nothing toggled, snapshots present) is precisely the one that is hard to stand up live
// and was therefore never tested before it shipped broken.
func rowFor(rows []OffsiteRestoreRow, app string) *OffsiteRestoreRow {
for i := range rows {
if rows[i].App == app {
return &rows[i]
}
}
return nil
}
func TestOffsiteRestoreRows_TruthTable(t *testing.T) {
now := time.Now()
inv := backup.OffsiteInventory{Apps: []backup.OffsiteInventoryApp{
{App: "calibre-web", LatestAt: now, SizeBytes: 12345},
{App: "immich", LatestAt: now},
{App: offboxMarkerTag}, // the marker every snapshot carries
{App: backup.SharesPseudoStack}, // shares have their own entry, not an app wizard
}}
installed := []OffboxAppRow{
{Name: "calibre-web", DisplayName: "Calibre-Web", Slug: "cw", Enabled: false}, // toggled OFF on purpose
{Name: "bookstack", DisplayName: "BookStack", Slug: "bs", Enabled: true}, // installed, no snapshot
}
rows, state := buildOffsiteRestoreRows(inv, nil, installed)
if state != offsiteStoreKnown {
t.Fatalf("state = %q, want known", state)
}
// THE FINDING: a snapshot whose app is NOT installed is listed, and is restorable.
if r := rowFor(rows, "immich"); r == nil || !r.InStore || r.Installed || !r.Restorable() {
t.Errorf("immich: want listed+in-store+not-installed+restorable, got %+v", r)
}
// THE OTHER HALF OF THE FINDING: the future-backup toggle must not gate a past backup.
r := rowFor(rows, "calibre-web")
if r == nil || !r.Restorable() {
t.Fatalf("calibre-web must be restorable even with the offsite toggle OFF, got %+v", r)
}
if r.Enabled {
t.Errorf("fixture sanity: calibre-web should be carrying Enabled=false")
}
if r.DisplayName != "Calibre-Web" || r.Slug != "cw" {
t.Errorf("installed metadata should decorate the store row, got %+v", r)
}
// An installed app with no snapshot is SHOWN, not silently absent — and is not restorable.
if r := rowFor(rows, "bookstack"); r == nil || r.InStore || !r.Installed || r.Restorable() {
t.Errorf("bookstack: want listed+installed+not-in-store+not-restorable, got %+v", r)
}
// Neither marker tag is an app.
if rowFor(rows, offboxMarkerTag) != nil {
t.Errorf("the %q marker tag must never be offered as an app", offboxMarkerTag)
}
if rowFor(rows, backup.SharesPseudoStack) != nil {
t.Errorf("the shares pseudo-stack must never appear as an app row")
}
}
// The rebuilt box: nothing installed, nothing toggled, snapshots in the store. This is the exact
// shape measured on 2026-08-06 that produced „Nincs telepített alkalmazás" and a wizard that refused
// every app.
func TestOffsiteRestoreRows_RebuiltBox_SeesItsSnapshots(t *testing.T) {
inv := backup.OffsiteInventory{Apps: []backup.OffsiteInventoryApp{
{App: "calibre-web", LatestAt: time.Now()},
{App: offboxMarkerTag},
}}
rows, state := buildOffsiteRestoreRows(inv, nil, nil) // nil = NOTHING installed
if state != offsiteStoreKnown {
t.Fatalf("state = %q", state)
}
if len(rows) != 1 {
t.Fatalf("want exactly the one stored app, got %d: %+v", len(rows), rows)
}
if !rows[0].Restorable() || rows[0].Installed {
t.Errorf("a rebuilt box must be offered its stored app, got %+v", rows[0])
}
if resolveOffsiteRestoreApp(rows, "calibre-web") == nil {
t.Errorf("the wizard must resolve an app that is in the store but not installed")
}
}
// R-225's rule one screen over: a read failure is UNKNOWN, never empty — and it must not withhold the
// action either, because "we could not look" is not "there is nothing".
func TestOffsiteRestoreRows_UnreadableStoreIsUnknownNotEmpty(t *testing.T) {
installed := []OffboxAppRow{{Name: "calibre-web", DisplayName: "Calibre-Web"}}
rows, state := buildOffsiteRestoreRows(backup.OffsiteInventory{}, errors.New("sftp: connection refused"), installed)
if state != offsiteStoreUnreadable {
t.Fatalf("state = %q, want unreadable", state)
}
r := rowFor(rows, "calibre-web")
if r == nil || r.InStore {
t.Fatalf("unreadable store must not claim the app IS in it, got %+v", r)
}
if !r.StoreUnknown || !r.Restorable() {
t.Errorf("unreadable store must keep the action offered and flagged unknown, got %+v", r)
}
if resolveOffsiteRestoreApp(rows, "calibre-web") == nil {
t.Errorf("the wizard must not pre-emptively refuse when the store could not be read")
}
}
// The pristine rebuilt shape (R-236): no target yet. Distinguished from a read failure because it
// resolves by itself, and the page says so.
func TestOffsiteRestoreRows_NoTargetIsItsOwnState(t *testing.T) {
_, state := buildOffsiteRestoreRows(backup.OffsiteInventory{}, backup.ErrNoOffsiteTargetSentinel(), nil)
if state != offsiteStoreNoTarget {
t.Fatalf("state = %q, want no-target", state)
}
}
// A genuinely empty repository is empty — the one case where "nothing to restore" is true.
func TestOffsiteRestoreRows_EmptyStoreIsEmpty(t *testing.T) {
rows, state := buildOffsiteRestoreRows(backup.OffsiteInventory{Empty: true}, nil, nil)
if state != offsiteStoreKnown || len(rows) != 0 {
t.Fatalf("want known+empty, got state=%q rows=%+v", state, rows)
}
}
// The rendered page, not just the builder: a rebuilt box must SEE its restore entry. A helper-level
// assertion could not observe this — the defect was in what the template iterated.
func TestOffsiteRestoreList_RenderedForRebuiltBox(t *testing.T) {
data := splitTestData()
data["OffboxApps"] = []OffboxAppRow{} // nothing installed
data["OffboxToggledCount"] = 0 // nothing toggled
data["OffsiteRestoreRows"] = []OffsiteRestoreRow{
{App: "calibre-web", DisplayName: "Calibre-Web", InStore: true, Installed: false},
}
html := renderBackupPage(t, "backups_restore", data)
if !strings.Contains(html, `href="/backups/restore/app?name=calibre-web"`) {
t.Errorf("a rebuilt box must be offered its stored app; the page did not render the entry")
}
// v0.207.0 (R-253): the INTENT of this assertion is unchanged — the not-installed consequence
// must be stated rather than hidden — but the sentence it pinned was a promise the restore could
// not keep ("a visszaállítás előbb újratelepíti"), and the reconstitute path refused on exactly
// this state. The copy now states the consequence AND routes to the step that clears it, so this
// asserts both halves rather than the old wording.
if !strings.Contains(html, "Nincs telepítve") || !strings.Contains(html, "telepítsd újra") {
t.Errorf("the not-installed consequence must be stated, not hidden")
}
if !strings.Contains(html, `href="/stacks/calibre-web/deploy"`) {
t.Errorf("the consequence is stated but the customer is not routed to the step that clears it")
}
if strings.Contains(html, "Nincs távoli mentésre jelölt alkalmazás") {
t.Errorf("the old toggle-keyed dead end is still rendered")
}
}
func TestOffsiteRestoreList_RenderedStates(t *testing.T) {
for _, tc := range []struct{ state, want string }{
{"unreadable", "nem tudjuk, mi van benne"},
{"no-target", "kapcsolódási adatai még nem érkeztek meg"},
} {
data := splitTestData()
data["OffsiteRestoreRows"] = []OffsiteRestoreRow{}
data["OffsiteStoreState"] = tc.state
html := renderBackupPage(t, "backups_restore", data)
if !strings.Contains(html, tc.want) {
t.Errorf("state %q: page must say %q", tc.state, tc.want)
}
if strings.Contains(html, "A távoli tároló üres") {
t.Errorf("state %q must NOT be rendered as an empty store", tc.state)
}
}
}
@@ -0,0 +1,161 @@
package web
import (
"strings"
"testing"
"unicode/utf8"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// ── R-294 / R-202 — THE ORPHAN CARD STOPS PROMISING WHAT IT CANNOT KNOW ─────────────────────────
//
// The card told a customer, at the moment they had just lost their off-site history, that the old
// copies "may be restorable later with their recovery code". The discriminator is
// host_escrow_superseded.identity_blob and it lives on the HUB; the box caches only
// HubEscrowIdentityPresent, which describes the CURRENT escrow, and no report or ACK field carries
// superseded-blob retention. The renderer therefore could not evaluate the condition it stated.
//
// For everything set aside before hub v0.93.0 (in force 2026-08-04 ~11:11Z) the promise is false and
// unfixable, and on 2026-08-10 it was being made to a real machine in exactly that state.
//
// Implements documentation/design/SPEC-orphan-card-copy-2026-08-10.md §5. Render tests per branch of
// the gate, because a template gate without one is the seam-wiring lesson.
// orphanCardData renders the backups_remote page with the offbox repo in the given state. Only
// RepoState varies between the branches below, so what each test proves is attributable.
func orphanCardData(repoState string) map[string]interface{} {
d := splitTestData()
d["Offbox"] = &settings.OffboxTarget{
Enabled: true, Host: "nas.local", User: "felhom", RepoPath: "/srv/repo",
EscrowState: "escrowed", RepoState: repoState, QuotaGB: 50, StatsKnown: true,
}
d["OffboxQuotaPct"] = 0
return d
}
// ── Branch 1: the card IS shown ─────────────────────────────────────────────────────────────────
// SPEC §5.1 — the regression guard. The promise must not return in any form.
//
// RED-PROOF: restore the old sentence („…és a hozzá tartozó helyreállítási kóddal később
// visszaállítható lehet.") in backups_remote.html and this fails on the first assertion, with the
// promise quoted back in the failure message.
func TestOrphanCard_DoesNotPromiseRestorability(t *testing.T) {
html := renderBackupPage(t, "backups_remote", orphanCardData("orphaned"))
if !strings.Contains(html, "offbox-orphan-card") {
t.Fatal("the orphan card did not render at all — this test would then pass vacuously, " +
"which is the way a copy guard silently stops guarding")
}
// R-299: the guard matches the STEM, not one inflection. The first version of this test asserted
// „visszaállítható lehet" (singular) and the card carried „visszaállíthatók lehetnek" (plural) one
// paragraph above it — the same claim, invisible to the guard. A guard matching one inflection of a
// Hungarian verb guards one SENTENCE, not the claim. `visszaállíthat` is the potential stem
// ("can be restored"); the plain forms the rest of the UI uses („visszaállítás", „visszaállítani")
// do not contain it, so this does not over-match.
//
// It asserts on the RENDERED bytes, which is why the explanatory {{/* */}} comment in the template
// may quote the retired wording: html/template strips it. An HTML <!-- --> comment would SHIP and
// would make this test unfailable — that is the R-253 trap, and it is why the comment form matters.
if idx := strings.Index(html, "visszaállíthat"); idx >= 0 {
// Slice on RUNE boundaries. Go string indexing is by byte, and cutting Hungarian text at a
// byte offset splits a multi-byte character — the failure message then shows a replacement
// char and reads like an encoding bug in the product rather than in this message.
start, end := idx-80, idx+90
if start < 0 {
start = 0
}
if end > len(html) {
end = len(html)
}
for start < len(html) && !utf8.RuneStart(html[start]) {
start++
}
for end < len(html) && !utf8.RuneStart(html[end]) {
end++
}
t.Errorf("R-294/R-299: the card still promises the set-aside copies may be restorable. The box "+
"cannot evaluate that — the discriminator (superseded identity_blob) is on the hub and no "+
"wire field carries it — and for everything set aside before 2026-08-04 it is false.\n"+
" found: …%s…", html[start:end])
}
}
// SPEC §5.2 — a refusal that names no route is a defect in this project. Pin the route, not only the
// absence of the promise.
//
// RED-PROOF: delete the „írj nekünk" sentence and this fails — the customer is told we cannot promise
// anything and given nowhere to go.
func TestOrphanCard_NamesARouteAfterDeclining(t *testing.T) {
html := renderBackupPage(t, "backups_remote", orphanCardData("orphaned"))
if !strings.Contains(html, "nem tudjuk megígérni") {
t.Error("R-294: the card no longer DECLINES the claim — stating nothing is not the same as " +
"saying plainly that we cannot promise it")
}
if !strings.Contains(html, "írj nekünk") {
t.Error("R-294: the card declines the promise but names no route the customer can take")
}
// It must still say the copy is kept — otherwise "we cannot promise" reads as "it is gone".
if !strings.Contains(html, "nem töröljük") {
t.Error("R-294: the card no longer says the set-aside copy is kept; without that, declining " +
"the promise reads to the customer as data loss")
}
}
// The reason the card exists — the orphan EXPLANATION — is accurate and must survive the copy change.
func TestOrphanCard_KeepsTheExplanation(t *testing.T) {
html := renderBackupPage(t, "backups_remote", orphanCardData("orphaned"))
if !strings.Contains(html, "korábbi, már nem elérhető kulccsal") {
t.Error("the explanation of WHY the store is orphaned was lost — the customer is then shown a " +
"refusal with no cause")
}
}
// ── Branch 2: the card is NOT shown ─────────────────────────────────────────────────────────────
// SPEC §5.3 — per branch of the gate. A healthy store must not see any of this copy: R-215's shape is
// a screen about a situation the customer is not in.
func TestOrphanCard_HealthyStoreSeesNoneOfIt(t *testing.T) {
html := renderBackupPage(t, "backups_remote", orphanCardData("ok"))
if strings.Contains(html, "offbox-orphan-card") {
t.Fatal("the orphan card rendered for a healthy store")
}
for _, s := range []string{"nem tudjuk megígérni", "írj nekünk", "félretéve marad"} {
if strings.Contains(html, s) {
t.Errorf("orphan copy %q leaked onto a healthy box's page", s)
}
}
}
// R-299 — the EXPLANATION paragraph keeps its two accurate halves and drops the promise.
//
// This is the always-visible half of the card; the paragraph fixed yesterday only appears after the
// customer clicks through to the confirm block. So on first view this is the ONLY text they read,
// and until now it was the one still making the promise.
func TestOrphanCard_ExplanationKeepsWhatIsTrueAndDropsThePromise(t *testing.T) {
html := renderBackupPage(t, "backups_remote", orphanCardData("orphaned"))
// The two halves that ARE knowable from the box must survive.
for _, keep := range []string{
"nem elérhető kulccsal", // why the store is orphaned
"nem írható a tárolóba", // the consequence right now
"nem sérültek", // the backups are intact — knowable, and reassuring for a reason
} {
if !strings.Contains(html, keep) {
t.Errorf("R-299: the explanation lost an accurate statement (%q). Declining the promise must "+
"not turn into telling the customer less than we know", keep)
}
}
// …and the part the box cannot evaluate must be declined, with a route.
if !strings.Contains(html, "nem tudja megállapítani") {
t.Error("R-299: the explanation no longer says the machine cannot determine this — silence is " +
"not the same as declining a claim")
}
if !strings.Contains(html, "írj nekünk") {
t.Error("R-299: the explanation declines the claim but names no route")
}
}
@@ -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, 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, true) == agentapi.ClassifyRecoveryFailure(refusal(502, same), true, 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")
}
}
@@ -0,0 +1,360 @@
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()
// ⚠ 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))
}
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
@@ -0,0 +1,724 @@
package web
import (
"context"
"net/http"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// R-193 — THE RECOVERY SCREEN. A customer whose machine was rebuilt has everything they need to get
// their data back and, until this page, no way to find out: the only route was a command line.
//
// IT UNLOCKS, AND ONLY UNLOCKS (operator ruling, 2026-08-05). It explains the situation, takes the
// recovery code, opens the repository, and shows what is in there. It does NOT restore anything.
// Restoring is already per-app and already lives in the backups area; putting files back is a
// separate item. A screen that unlocks and then offers to overwrite is two decisions wearing one
// button.
//
// THREE WAYS OUT, and none of them is a dismiss button:
// - RECOVER — the main path (/recovery/unlock).
// - MOST NEM — the full page stops interrupting; the backups-area entry point stays PERMANENTLY,
// because the data is still there whether or not anyone clicked (/recovery/postpone).
// - I DO NOT WANT THE OLD DATA — deliberate, explained, confirmed TWICE, and it reaches the
// SHIPPED move-aside (`/backup/offbox/reset`), which sets the store aside and never deletes.
//
// THE RECOVERY CODE IS HANDLED NO MORE LOOSELY THAN ON THE COMMAND LINE (§8.3): POST body only, never
// a query string, never logged at any level, never persisted, never echoed back, cleared on every
// path, and the page is served no-store with autocomplete off.
// recoveryOffer reports whether this box is in the situation the screen exists for. The predicate and
// its two shapes live in backup.OffsiteRecoveryOffer — read its header before changing anything here.
func (s *Server) recoveryOffer() bool {
// CLAIMED AND BEHIND THE PASSWORD (§8.1). Before claiming there is no customer, and this page
// states metadata about the household's own backups — accepted as metadata rather than content
// (operator ruling 2026-08-05), which is only true while it sits behind the household password.
// A legacy-open box (no password anywhere) reaches ServeHTTP through RequireAuth's pass-through,
// so WITHOUT this line the interception would fire on an unauthenticated visitor. Caught by
// TestRecovery_B_DoesNotAppearForAnyoneElse/not_claimed, which failed before it was added.
if !s.authEnabled() {
return false
}
return s.backupMgr != nil && s.backupMgr.OffsiteRecoveryOffer()
}
// recoveryBannerCookie is the PER-VISIT banner dismissal (v0.206.0, R-241, §7.1). It is a browser
// SESSION cookie — no MaxAge, no Expires — and it is cleared on login, so "I have seen this" lasts
// for the visit and the reminder is back next time.
//
// It is deliberately NOT persisted in settings. A dismissal that outlived the visit would be a
// permanently-dismissed banner over data still sitting there, which is the failure Scenario H exists
// to catch. The durable, deliberate version of "stop reminding me" is the tick-box (§7.1), and that
// one is an explicit decision the customer takes, not a click to get a bar off the screen.
const recoveryBannerCookie = "felhom_recovery_banner"
// recoveryOfferEpoch advances and returns the offer-epoch view. Called from the landing-page
// interception, which runs on every dashboard/launcher GET — so the edge is detected promptly without
// a second scheduler job. Writes only on a transition.
func (s *Server) recoveryOfferEpoch() settings.RecoveryOfferView {
if s.settings == nil {
return settings.RecoveryOfferView{}
}
v, err := s.settings.SyncRecoveryOfferEpoch(s.recoveryOffer(), s.recoveryNow())
if err != nil {
s.logger.Printf("[WARN] [web] recovery: could not persist the offer epoch: %v", err)
}
return v
}
// recoveryInterrupts reports whether the FULL PAGE should take over the landing pages.
//
// ⚠ ONCE PER ENTRY INTO THE OFFERED STATE, NOT ONCE EVER (v0.206.0, §7.1). "Most nem" used to set a
// flag that nothing ever cleared, so a box that abandoned its history and was rebuilt months later —
// a genuinely NEW situation — would never show the page again. The epoch fixes that by arithmetic:
// a fresh entry advances it past the dismissal, with nothing to clear.
//
// It still suppresses the full page ONLY. `recoveryOffer` stays true, so the banner and the
// backups-area entry point both survive, and that asymmetry is the whole of Scenario E.
func (s *Server) recoveryInterrupts() bool {
// ⚠ THE EPOCH IS SYNCED FIRST AND UNCONDITIONALLY, and that ordering is the whole mechanism.
// The first draft returned early when the offer was false, so the FALLING edge was never
// recorded — `RecoveryOfferActive` stayed true through a settled period and the next entry
// therefore counted as a continuation rather than a new situation. The page never came back.
// Caught by TestR241_FullPageAppearsOncePerEntryNotOnceEver, not by review.
if s.settings == nil {
return s.recoveryOffer()
}
v := s.recoveryOfferEpoch()
if !v.Active {
return false
}
return v.Epoch > v.PostponedEpoch
}
// recoveryBannerVisible reports whether the per-visit reminder bar should render on ordinary pages.
// Three conditions, and each is a separate lever: the situation holds, the customer has not opted out
// of reminders for THIS epoch, and they have not clicked the bar away during this visit.
func (s *Server) recoveryBannerVisible(r *http.Request) bool {
if !s.recoveryOffer() || s.settings == nil {
return false
}
if c, err := r.Cookie(recoveryBannerCookie); err == nil && c.Value == "1" {
return false // dismissed for this visit only
}
v := s.settings.GetRecoveryOfferView()
return v.Epoch > v.OptOutEpoch
}
// recoveryNoStore stamps the page uncacheable. The rendered page carries no secret, but it does carry
// the form the code is typed into, and a cached copy of a recovery form is a form served from disk.
func recoveryNoStore(w http.ResponseWriter) {
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, private")
w.Header().Set("Pragma", "no-cache")
}
// 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)
}
// renderRecovery is the one render path: pre-unlock (explanation + code form), or post-unlock (the
// 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)
data["Offer"] = s.recoveryOffer()
data["Postponed"] = s.settings != nil && s.settings.GetRecoveryNoticePostponed()
data["Error"] = errorMsg
data["Flash"] = flash
data["SealedAt"] = s.recoverySealedAt()
// The set-aside choice is offered ONLY when the shipped move-aside can actually run — it refuses
// unless the tier is orphaned. Showing a button that is guaranteed to refuse would be worse than
// not showing it, and rewriting the move-aside is explicitly out of scope.
data["CanSetAside"] = s.backupMgr != nil && s.backupMgr.OffboxOrphaned()
// §7.3: the confirmation states the grace in days, from the constant the countdown actually uses —
// never a literal in the copy, which is how a number in prose drifts away from the number in code.
data["AbandonGraceDays"] = backup.AbandonGraceDays
data["ConfirmSetAside"] = r.URL.Query().Get("setaside") == "1"
if inv != nil {
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 {
s.logger.Printf("[ERROR] [web] Template error (recovery): %v", err)
http.Error(w, "Internal error", http.StatusInternalServerError)
}
}
// recoverySealedAt returns the human date the hub says the sealed package was created (""
// when unknown). Non-secret: a timestamp, and the page must not pretend to know more than that
// before a code is entered.
func (s *Server) recoverySealedAt() string {
if s.escrowSealedAtFn == nil {
return ""
}
return s.escrowSealedAtFn()
}
// SetEscrowSealedAt wires the ACK's escrow created_at (report.EscrowAutoConfirmer). INIT-ONLY.
func (s *Server) SetEscrowSealedAt(fn func() string) { s.escrowSealedAtFn = fn }
// recoveryRecoverer returns the agent seam that unseals the package. nil → the shared agentClient(),
// the same channel the CLI uses; tests inject. The seam exists so the HANDLER can be driven in a test
// — a helper-level test would not observe a mutation that lives in the handler (§10).
func (s *Server) recoveryRecoverer() (backup.OffsiteKeyRecoverer, error) {
if s.recoveryRecovererFn != nil {
return s.recoveryRecovererFn()
}
return s.agentClient()
}
// SetRecoveryRecoverer overrides the agent seam (tests).
func (s *Server) SetRecoveryRecoverer(fn func() (backup.OffsiteKeyRecoverer, error)) {
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
}
// 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
}
// recoveryRetainedTrusted reports whether a 422 from the agent may be read as "the code is correct
// and opens a RETAINED earlier package" (R-311).
//
// Only agent >= v0.129.0 ever looks at retained packages, so only it can produce that verdict. On
// anything older the two causes really are indistinguishable and the screen must keep saying so.
//
// ⚠ Like its R-224 twin this gate BLOCKS NOTHING — the unlock is attempted either way. It decides
// only which of two TRUE sentences the customer reads, and "not sure" picks the one that claims less.
func (s *Server) recoveryRetainedTrusted(ctx context.Context) bool {
if s.recoveryRetainedTrustedFn != nil {
return s.recoveryRetainedTrustedFn(ctx)
}
agent, err := s.agentClient()
if err != nil {
return false
}
state, _ := s.netFeatures.SupportsWithSource(ctx, agent, agentapi.FeatureRetainedRecoveryClass)
return state == agentapi.SupportYes
}
// SetRecoveryRetainedTrusted overrides the R-311 version gate (tests). INIT-ONLY.
func (s *Server) SetRecoveryRetainedTrusted(fn func(context.Context) bool) {
s.recoveryRetainedTrustedFn = 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.
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:
// - POST body only. A GET with a query string would put R in the access log, the browser history
// and any Referer header the page later emits.
// - never logged. Not at any level, not truncated, not hashed-and-logged.
// - never persisted — not in the session, not in a cookie, not in a file.
// - cleared on every path below, success and failure alike.
// - never echoed. No message built here contains it; the agent's errors name the step, not the code.
//
// NO LOCKOUT (§8.4). The code is a 10-word EFF phrase — guessing is not the risk — and locking a
// customer out of their own data because they mistyped is a worse failure than anything it prevents.
// Failures ARE logged locally (without the code) so a box being probed is visible in the debug ring.
func (s *Server) recoveryUnlockHandler(w http.ResponseWriter, r *http.Request) {
if !s.recoveryOffer() {
http.Redirect(w, r, "/backups/remote", http.StatusFound)
return
}
_ = r.ParseForm()
code := r.PostFormValue("recovery_code") // PostFormValue: body only, never the query string
if code == "" {
s.renderRecovery(w, r, "Add meg a helyreállítási kódot.", "", nil)
return
}
rec, err := s.recoveryRecoverer()
if err != nil {
code = ""
s.logger.Printf("[ERROR] [web] recovery: agent channel unavailable: %v", err)
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
// 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.
//
// ── 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.recoveryRetainedTrusted(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.RecoveryCodeOpensRetained:
// ── R-311 — THE CODE IS CORRECT, AND WE CHECKED. ──────────────────────────────────
//
// The agent tried the retained packages and one of them opened. This is no longer an
// inference from "the hub says an earlier package exists" (R-222) — it is a measurement,
// and it is the difference between hedging and knowing.
//
// What this message may NOT do: promise the older history can be reopened. The retained
// package may itself predate the repository-password field, and there is no route from
// this screen to a set-aside store in any case (the restore machinery resolves its
// repository from settings and its password from one file — see the session's spike). A
// conditional promise that turns out false HERE is worse than saying less; that is the
// R-202 lesson and it applies with full force to a screen about someone's backups.
//
// So it states what is known, denies what the customer will otherwise assume (that their
// current backups are affected), and names a route. A refusal that names no route is a
// defect on this surface.
when := ""
if _, at := s.recoverySuperseded(); at != "" {
when = " (" + at + ")"
}
s.logger.Printf("[INFO] [web] recovery: the code opened a RETAINED package — the customer is not at fault")
s.renderRecovery(w, r, "A kódod helyes, de egy korábbi csomagot nyit meg, nem azt, amit most őrzünk ehhez a géphez. A géped időközben új mentési kulcsot kapott. A korábbi csomagot"+when+" nem töröltük, megőrizzük — a mostani mentéseidet ez nem érinti, azokkal semmi nem történt. A régebbi előzményed visszanyitásához a Felhom ügyfélszolgálatának segítsége kell: írj nekik, és add meg, hogy a régi mentéseidhez szeretnél hozzáférni. A kódodat tedd el, szükség lesz rá.", "", 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
// 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).
//
// ── 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 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. ──────────
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
}
switch res.Outcome {
case backup.RecoverRefused:
s.logger.Printf("[WARN] [web] recovery: refused — a different repository password is already present")
s.renderRecovery(w, r, "Ezen a gépen már van egy másik mentési kulcs. A régi előzmény visszanyitása felülírná azt, ezért nem hajtottuk végre. Vedd fel a kapcsolatot a Felhom ügyfélszolgálatával.", "", nil)
return
}
s.logger.Printf("[INFO] [web] recovery: the offsite repository key was recovered and placed (outcome=%s)", res.Outcome)
// ── SCENARIO G — CHANGING YOUR MIND INSIDE THE WINDOW (R-241, v0.206.0) ─────────────────────
//
// The customer may have chosen to abandon the old history and then found their code after all.
// The countdown is cancelled HERE, at the moment the code proves they still have it — the same
// act that makes the abandonment wrong is the act that stops it.
//
// It is placed before the tier-up and the listing deliberately: those can fail, and a countdown
// that survives a successful unlock because a later step errored would delete the very history
// the customer just proved they can open. Nothing has been deleted at this point by construction —
// AbandonSweep is the only deleter, and a running countdown means it has not fired.
if s.backupMgr != nil {
s.backupMgr.CancelAbandon("the customer recovered with their code")
}
// ── 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)
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 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.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)
}
// recoveryPostponeHandler records "most nem" (POST /recovery/postpone). It suppresses the FULL-PAGE
// interruption ONLY: recoveryOffer stays true, so the backups-area entry point survives permanently.
func (s *Server) recoveryPostponeHandler(w http.ResponseWriter, r *http.Request) {
if s.settings != nil {
// Recorded against the CURRENT epoch (v0.206.0): a dismissal is about the situation the
// customer is in, not about the screen for ever. A later fresh entry shows the page again.
if err := s.settings.PostponeRecoveryNoticeForEpoch(); err != nil {
s.logger.Printf("[WARN] [web] recovery: recording the postpone failed: %v", err)
}
}
s.logger.Printf("[INFO] [web] recovery: the customer chose to postpone; the banner and the backups-area entry point both stay")
http.Redirect(w, r, "/launcher", http.StatusFound)
}
// recoveryBannerDismissHandler records "seen it, for now" (POST /recovery/banner/dismiss) — a browser
// SESSION cookie and nothing durable. The bar is back at the next login, because the data is still
// sitting there whether or not anyone clicked.
func (s *Server) recoveryBannerDismissHandler(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: recoveryBannerCookie, Value: "1", Path: "/",
HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: r.TLS != nil,
// NO MaxAge and NO Expires — a session cookie, deliberately. See recoveryBannerCookie.
})
http.Redirect(w, r, redirectBackTo(r, "/launcher"), http.StatusFound)
}
// recoveryRemindOptOutHandler records „ne emlékeztessen újra" (POST /recovery/remind-optout).
//
// ⚠ IT SILENCES THE BANNER AND NOTHING ELSE (§7.1 condition 3). It is not an abandonment, it starts
// no countdown, and it must never be presented as a way of deciding. The entry point on the backups
// page stays exactly where it was (condition 1) — silencing a reminder is not removing the route, and
// this whole session exists partly because a route disappeared. A fresh entry into the offered state
// reminds again (condition 2), by epoch arithmetic.
func (s *Server) recoveryRemindOptOutHandler(w http.ResponseWriter, r *http.Request) {
if s.settings != nil {
if err := s.settings.OptOutRecoveryRemindersForEpoch(); err != nil {
s.logger.Printf("[WARN] [web] recovery: recording the reminder opt-out failed: %v", err)
}
}
s.logger.Printf("[INFO] [web] recovery: reminders silenced for this situation at the customer's request — the backups-area entry point is UNCHANGED and no countdown was started")
http.Redirect(w, r, redirectBackTo(r, "/backups/remote"), http.StatusFound)
}
// redirectBackTo returns a SAFE same-site redirect target from the form, or the fallback. Only a
// leading single "/" is accepted: "//evil.example" is a protocol-relative URL and must not pass.
func redirectBackTo(r *http.Request, fallback string) string {
v := r.FormValue("back")
if len(v) > 1 && v[0] == '/' && v[1] != '/' {
return v
}
return fallback
}
// addRecoveryBanner decorates a page's data with the reminder bar's state (R-241, v0.206.0).
//
// It is an EXPLICIT call rather than a `baseData` change, deliberately: `baseData` has no request and
// the per-visit dismissal is a cookie, and threading a request through every caller to reach four
// pages would be a large diff for a small feature. The callers are the pages a customer actually
// lands on — the dashboard, the launcher and the backups area.
//
// ⚠ IT IS A REMINDER, NOT THE ROUTE. Nothing here gates the entry point on /backups/remote; that is
// driven by `.RecoveryOffer` in the template and stays put whatever the customer does about the bar.
func (s *Server) addRecoveryBanner(data map[string]interface{}, r *http.Request) {
if !s.recoveryBannerVisible(r) {
return
}
data["RecoveryBanner"] = true
data["RecoveryBannerBack"] = r.URL.Path
if data["CSRFField"] == nil {
data["CSRFField"] = s.csrfField(r)
}
// §2.3 — THE UNDECIDED LADDER. A customer who never decides is reminded with escalating
// EMPHASIS as the situation ages: marks at 1, 3, 7 and 14 days since this epoch began.
//
// ⚠ THE READING IS STATED BECAUSE THE SPEC IS AMBIGUOUS. For an ABANDONING box, 5/3/1 are
// unambiguously days REMAINING before a deletion. An undecided box has no deadline — nothing is
// counting down to anything, because §7.5 deliberately does NOT auto-abandon — so 14/7/3/1 cannot
// be "remaining" and are taken as days ELAPSED, with the wording escalating rather than the bar
// appearing and disappearing. If the operator meant something else, this is the line to change.
dw := s.recoveryDaysWaiting()
data["RecoveryDaysWaiting"] = dw
data["RecoveryReminderTier"] = RecoveryReminderTier(dw)
// While a countdown runs the bar counts it down instead of asking the same question — and the
// reminder opt-out is deliberately NOT offered there: a deletion date is not something to silence.
if s.backupMgr != nil {
if st := s.backupMgr.AbandonStatus(); st.Active {
for _, mark := range backup.AbandonRemindAtDays {
if st.DaysLeft <= mark {
data["RecoveryAbandonDays"] = st.DaysLeft
data["RecoveryAbandonDate"] = st.DueAt.Format("2006-01-02")
// R-302: the retrieval clause is conditional; the deletion sentence is not. Taken
// from the read model so this surface cannot form its own opinion.
data["RecoveryAbandonRetrievalOffered"] = st.RetrievalStillOffered
break
}
}
if data["RecoveryAbandonDays"] == nil {
// Outside the reminder marks the countdown is visible on the backups page only —
// a bar on every screen for fourteen days is a bar nobody reads by day three.
delete(data, "RecoveryBanner")
}
}
}
}
// recoveryDaysWaiting returns whole days since the current offered epoch began (0 when unknown).
// It drives the escalating emphasis of the undecided reminder — see addRecoveryBanner.
func (s *Server) recoveryDaysWaiting() int {
if s.settings == nil {
return 0
}
v := s.settings.GetRecoveryOfferView()
if v.Since == "" {
return 0
}
since, err := time.Parse(time.RFC3339, v.Since)
if err != nil {
return 0 // an unparseable stamp means "we do not know", never "it has been ages"
}
d := int(s.recoveryNow().Sub(since) / (24 * time.Hour))
if d < 0 {
return 0
}
return d
}
// RecoveryReminderTier maps days-waiting to the escalation marks in §2.3. It returns the HIGHEST
// mark reached, so the copy can firm up without the bar flickering in and out.
func RecoveryReminderTier(daysWaiting int) int {
tier := 0
for _, mark := range []int{1, 3, 7, 14} {
if daysWaiting >= mark {
tier = mark
}
}
return tier
}
@@ -0,0 +1,167 @@
package web
import (
"context"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
)
// ── R-311 — A CORRECT CODE FOR AN EARLIER PACKAGE STOPS BEING CALLED WRONG ───────────────────────
//
// What was measured on 2026-08-12: a recovery code that provably opens a RETAINED package — unsealed
// by hand, and it restored planted files byte-identical from a store the box itself could no longer
// open — was answered by the product as a code that opened nothing.
//
// The screen already hedged (R-222/R-226): "it may be a typo, or it may be an older code, and we
// cannot tell them apart from here." That sentence was TRUE and it was honest. It was also a
// statement about our own incuriosity — nothing ever tried the retained packages — read by the
// customer as a statement about their code. Now something tries, so the hedge can become an answer.
//
// Every test asserts the EFFECT at the HANDLER: which sentence the customer is shown.
// saysCodeIsCorrect is the load-bearing half of the new message. Anything that leaves this out has
// failed at the one job it has.
func saysCodeIsCorrect(body string) bool { return strings.Contains(body, "A kódod helyes") }
// saysCurrentBackupsUnaffected — a customer told "your code opens something else" will otherwise
// assume their CURRENT backups are in doubt. They are not, and the message must say so.
func saysCurrentBackupsUnaffected(body string) bool {
return strings.Contains(body, "mostani mentéseidet ez nem érinti")
}
// namesARoute — a refusal that names no next step is a defect on this surface.
func namesARoute(body string) bool { return strings.Contains(body, "ügyfélszolgálat") }
// ── THE ONE THAT MATTERS ────────────────────────────────────────────────────────────────────────
//
// RED-PROOF: delete the `agentapi.RecoveryCodeOpensRetained` case from recoveryUnlockHandler so a 422
// falls through → the customer gets the hedged "we cannot tell them apart" text (or, without R-222's
// gate, the bare typing accusation) → this FAILS on saysCodeIsCorrect. That is yesterday's behaviour
// returning, and it is the whole reason this case exists.
func TestR311_CodeOpensRetained_IsNotReportedAsWrong(t *testing.T) {
f := newRecoveryFixture(t)
f.rec.failWith = refusal(422, "the recovery code is correct, but it belongs to an EARLIER sealed package (superseded 2026-08-12 15:18:55), not the one currently held")
body := postUnlockWith(t, f.s, testRecoveryCode).Body.String()
if namesTyping(body) || isBareAccusation(body) {
t.Fatalf("a CORRECT code was reported as a possible mistype — the R-311 defect is back; got %q", firstAlert(body))
}
if !saysCodeIsCorrect(body) {
t.Fatalf("the message must say the code is CORRECT; got %q", firstAlert(body))
}
if !saysCurrentBackupsUnaffected(body) {
t.Errorf("the message must deny the assumption it creates — that the CURRENT backups are affected; got %q", firstAlert(body))
}
if !namesARoute(body) {
t.Errorf("a refusal on this screen must name a route; got %q", firstAlert(body))
}
}
// The message must NOT promise the older history can be reopened from this screen. There is no
// in-product route to a set-aside store (the restore machinery resolves its repository from settings
// and its password from one file), and the retained package may itself predate the repository-password
// field. A conditional promise that turns out false HERE is worse than saying less — the R-202 lesson.
//
// RED-PROOF: add "és vissza is állítjuk" (or any unconditional retrieval promise) to the message →
// this FAILS.
func TestR311_TheMessageDoesNotPromiseARestore(t *testing.T) {
f := newRecoveryFixture(t)
f.rec.failWith = refusal(422, "belongs to an EARLIER sealed package")
body := firstAlert(postUnlockWith(t, f.s, testRecoveryCode).Body.String())
// A promise would take the form "we will restore it" / "you can get them back from here".
for _, promise := range []string{"vissza is állítjuk", "most visszaállítjuk", "innen visszaszerezheted", "azonnal visszaállítható"} {
if strings.Contains(body, promise) {
t.Fatalf("the message promises a restore this screen cannot perform (%q): %q", promise, body)
}
}
// It must still say the package is KEPT — that is the true, useful half.
if !strings.Contains(body, "nem töröltük") {
t.Errorf("the message must say the earlier package was kept; got %q", body)
}
}
// AN OLDER AGENT CANNOT PRODUCE THIS VERDICT, so the screen must keep the hedged sentence. The gate
// decides which of two TRUE sentences to print, never whether to attempt the unlock.
//
// RED-PROOF: make ClassifyRecoveryFailure return RecoveryCodeOpensRetained for 422 regardless of
// trustRetained → an agent that never looked at a retained package is treated as having looked →
// this FAILS.
func TestR311_OlderAgent_KeepsTheHedgedSentence(t *testing.T) {
f := newRecoveryFixture(t)
f.s.SetRecoveryRetainedTrusted(func(context.Context) bool { return false })
f.rec.failWith = refusal(422, "some shape this agent should not be producing")
body := postUnlockWith(t, f.s, testRecoveryCode).Body.String()
if saysCodeIsCorrect(body) {
t.Fatalf("an agent too old to look at retained packages was read as having looked; got %q", firstAlert(body))
}
// RecoveryUnknown → the neutral message. It must not accuse either.
if isBareAccusation(body) {
t.Errorf("an unclassifiable 422 produced an accusation; got %q", firstAlert(body))
}
}
// SCENARIO C — a genuinely wrong code must still reach the typing message. The new branch must not
// become an escape hatch that stops the product ever saying "check your typing".
//
// RED-PROOF: route 400 to RecoveryCodeOpensRetained → a mistype is congratulated → this FAILS.
func TestR311_WrongCodeStillReachesTheTypingMessage(t *testing.T) {
f := newRecoveryFixture(t)
f.rec.failWith = refusal(400, "the recovery code did not open the sealed bundle — nothing was written")
body := postUnlockWith(t, f.s, testRecoveryCode).Body.String()
if saysCodeIsCorrect(body) {
t.Fatalf("a WRONG code was told it is correct; got %q", firstAlert(body))
}
if !namesTyping(body) {
t.Errorf("a genuine refusal must still be able to mention typing; got %q", firstAlert(body))
}
}
// SCENARIO A — the ordinary successful recovery is untouched. This is the path that WORKS, and it is
// the one a change like this is most likely to break by accident.
//
// RED-PROOF: return the 422 refusal from the fixture's recoverer on the success path → this FAILS.
func TestR311_OrdinaryRecoveryUnaffected(t *testing.T) {
f := newRecoveryFixture(t)
f.rec.failWith = nil // the current package opens
rr := postUnlockWith(t, f.s, testRecoveryCode)
body := rr.Body.String()
if saysCodeIsCorrect(body) {
t.Fatal("the retained message rendered on a SUCCESSFUL recovery")
}
if namesTyping(body) || isBareAccusation(body) {
t.Fatalf("a successful recovery produced a refusal; got %q", firstAlert(body))
}
}
// The classifier maps 422 by STATUS and gate, never by sentence — the R-224 rule, applied to the new
// status so it cannot regress the same way.
//
// RED-PROOF: classify on strings.Contains(reason, "EARLIER") instead of the status → the two
// same-text/different-status calls below stop differing → this FAILS.
func TestR311_ClassificationFollowsStatusNotText(t *testing.T) {
same := "the recovery code is correct, but it belongs to an EARLIER sealed package"
if agentapi.ClassifyRecoveryFailure(refusal(422, same), true, true) == agentapi.ClassifyRecoveryFailure(refusal(400, same), true, true) {
t.Fatal("classification followed the TEXT — it must follow the status")
}
if got := agentapi.ClassifyRecoveryFailure(refusal(422, same), true, true); got != agentapi.RecoveryCodeOpensRetained {
t.Fatalf("422 with the gate open = %v, want RecoveryCodeOpensRetained", got)
}
if got := agentapi.ClassifyRecoveryFailure(refusal(422, same), true, false); got != agentapi.RecoveryUnknown {
t.Fatalf("422 with the gate CLOSED = %v, want RecoveryUnknown (neutral)", got)
}
// The two gates are independent: an agent between v0.126.0 and v0.129.0 trusts 400 but not 422.
if got := agentapi.ClassifyRecoveryFailure(refusal(400, same), true, false); got != agentapi.RecoveryAskedAndRefused {
t.Fatalf("400 with only the R-224 gate open = %v, want RecoveryAskedAndRefused", got)
}
}
@@ -0,0 +1,186 @@
package web
import (
"net/http"
"net/http/httptest"
"testing"
)
// R-241 §7.1 / Scenario H — the three-state surface.
//
// The full page once PER ENTRY into the offered state (not once ever), a per-visit banner, and an
// entry point on the restore page that NOTHING removes.
// ── THE FULL PAGE APPEARS ONCE PER ENTRY, NOT ONCE EVER ─────────────────────────────────────────
//
// RED-PROOF: make recoveryInterrupts read the legacy boolean again (`!GetRecoveryNoticePostponed()`).
// The second entry is then swallowed and this test fails — a box that abandoned its history and was
// rebuilt months later would never see the page again.
func TestR241_FullPageAppearsOncePerEntryNotOnceEver(t *testing.T) {
f := newRecoveryFixture(t)
// Entry #1 → interrupts.
if !f.s.recoveryInterrupts() {
t.Fatal("the first entry into the offered state must interrupt")
}
rr := httptest.NewRecorder()
f.s.recoveryPostponeHandler(rr, httptest.NewRequest(http.MethodPost, "/recovery/postpone", nil))
if f.s.recoveryInterrupts() {
t.Fatal("after 'most nem' the full page must stop interrupting for THIS situation")
}
// The situation ends (the customer recovered, or the state was fixed): the offer goes false and
// the epoch's active edge falls.
if err := f.sett.SetHubEscrowIdentityPresent(false); err != nil {
t.Fatal(err)
}
if f.s.recoveryInterrupts() {
t.Fatal("a settled box must not interrupt")
}
// ENTRY #2 — a genuinely new situation months later.
if err := f.sett.SetHubEscrowIdentityPresent(true); err != nil {
t.Fatal(err)
}
if !f.s.recoveryInterrupts() {
t.Fatal("a FRESH entry into the offered state must show the full page again — a dismissal is about a situation, not for ever")
}
}
// ── THE BANNER IS PER-VISIT ─────────────────────────────────────────────────────────────────────
//
// RED-PROOF: persist the dismissal in settings (or give the cookie a MaxAge). It then survives the
// visit and this test fails — a permanently-dismissed banner over data still sitting there.
func TestR241_ScenarioH_BannerIsDismissedForTheVisitOnly(t *testing.T) {
f := newRecoveryFixture(t)
f.s.recoveryInterrupts() // establish the epoch
req := httptest.NewRequest(http.MethodGet, "/launcher", nil)
if !f.s.recoveryBannerVisible(req) {
t.Fatal("the banner should be visible while the situation holds")
}
rr := httptest.NewRecorder()
f.s.recoveryBannerDismissHandler(rr, httptest.NewRequest(http.MethodPost, "/recovery/banner/dismiss", nil))
var dismissed *http.Cookie
for _, c := range rr.Result().Cookies() {
if c.Name == recoveryBannerCookie {
dismissed = c
}
}
if dismissed == nil {
t.Fatal("the dismissal must set its cookie")
}
// IT MUST BE A SESSION COOKIE — no MaxAge, no Expires. That is what makes it per-visit.
if dismissed.MaxAge != 0 || !dismissed.Expires.IsZero() {
t.Fatalf("the banner dismissal must be a SESSION cookie (MaxAge=0, no Expires), got MaxAge=%d Expires=%v", dismissed.MaxAge, dismissed.Expires)
}
// With the cookie presented, the banner is gone…
req2 := httptest.NewRequest(http.MethodGet, "/launcher", nil)
req2.AddCookie(dismissed)
if f.s.recoveryBannerVisible(req2) {
t.Fatal("the banner must be hidden for the rest of this visit")
}
// …and NOTHING durable was written: a fresh visit (no cookie) sees it again.
if !f.s.recoveryBannerVisible(httptest.NewRequest(http.MethodGet, "/launcher", nil)) {
t.Fatal("the banner must be back on the next visit — the dismissal must not be persisted")
}
if v := f.sett.GetRecoveryOfferView(); v.OptOutEpoch != 0 {
t.Fatalf("clicking the bar away must NOT record an opt-out, got %+v", v)
}
}
// ── THE EXPLICIT OPT-OUT SILENCES THE BANNER AND NOTHING ELSE ───────────────────────────────────
//
// RED-PROOF: make the opt-out also clear the offer (or gate the backups entry point on it). The
// route to the data then disappears and this test fails — the failure this whole session exists to
// remove.
func TestR241_ScenarioH_OptOutSilencesTheBannerOnly(t *testing.T) {
f := newRecoveryFixture(t)
f.s.recoveryInterrupts()
rr := httptest.NewRecorder()
f.s.recoveryRemindOptOutHandler(rr, httptest.NewRequest(http.MethodPost, "/recovery/remind-optout", nil))
if rr.Code != http.StatusFound {
t.Fatalf("opt-out = %d, want a redirect", rr.Code)
}
// 3. It silences the BANNER…
if f.s.recoveryBannerVisible(httptest.NewRequest(http.MethodGet, "/launcher", nil)) {
t.Fatal("the banner must be silenced after an explicit opt-out")
}
// 1. …and the ROUTE never goes away.
if !f.s.recoveryOffer() {
t.Fatal("CONDITION 1: the entry point must survive — silencing a reminder is not removing the route")
}
// …nor is it an abandonment: no countdown started.
if st := f.s.backupMgr.AbandonStatus(); st.Active {
t.Fatal("an opt-out must never start a countdown — it is not a decision about the data")
}
// 2. A FRESH entry into the offered state reminds again.
if err := f.sett.SetHubEscrowIdentityPresent(false); err != nil {
t.Fatal(err)
}
f.s.recoveryInterrupts() // the edge falls
if err := f.sett.SetHubEscrowIdentityPresent(true); err != nil {
t.Fatal(err)
}
f.s.recoveryInterrupts() // a new epoch
if !f.s.recoveryBannerVisible(httptest.NewRequest(http.MethodGet, "/launcher", nil)) {
t.Fatal("CONDITION 2: a fresh entry into the offered state must remind again")
}
}
// The backups-page entry point is bound to the OFFER and to nothing else — not to the interruption,
// not to the banner, not to the opt-out. Pinned here because every one of those is a lever someone
// could plausibly bind it to, and the last time a route disappeared it cost a walk.
func TestR241_EntryPointSurvivesEveryDismissal(t *testing.T) {
f := newRecoveryFixture(t)
f.s.recoveryInterrupts()
f.s.recoveryPostponeHandler(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/recovery/postpone", nil))
f.s.recoveryRemindOptOutHandler(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/recovery/remind-optout", nil))
f.s.recoveryBannerDismissHandler(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/recovery/banner/dismiss", nil))
if !f.s.recoveryOffer() {
t.Fatal("no combination of dismissals may remove the route to the customer's data")
}
// And the page itself still renders rather than redirecting away.
rr := httptest.NewRecorder()
f.s.recoveryPageHandler(rr, httptest.NewRequest(http.MethodGet, "/recovery", nil))
if rr.Code != http.StatusOK {
t.Fatalf("GET /recovery = %d after every dismissal, want 200", rr.Code)
}
}
// A settled box shows no banner at all (Scenario D's surface half).
func TestR241_SettledBoxHasNoBanner(t *testing.T) {
f := newRecoveryFixture(t)
if err := f.sett.SetHubEscrowIdentityPresent(false); err != nil {
t.Fatal(err)
}
if f.s.recoveryBannerVisible(httptest.NewRequest(http.MethodGet, "/launcher", nil)) {
t.Fatal("a settled box must show nothing — no page, no banner, no entry point")
}
if f.s.recoveryInterrupts() {
t.Fatal("a settled box must not interrupt")
}
}
// redirectBackTo must refuse an off-site target. A dismissal button that can be pointed at another
// host is an open redirect on an authenticated page.
func TestR241_BannerRedirectIsSameSiteOnly(t *testing.T) {
for _, tc := range []struct{ in, want string }{
{"/backups/remote", "/backups/remote"},
{"//evil.example/x", "/launcher"},
{"https://evil.example", "/launcher"},
{"", "/launcher"},
{"/", "/launcher"}, // len<=1 falls back; harmless and keeps the rule simple
} {
r := httptest.NewRequest(http.MethodPost, "/x?back="+tc.in, nil)
if got := redirectBackTo(r, "/launcher"); got != tc.want {
t.Errorf("redirectBackTo(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
+521
View File
@@ -0,0 +1,521 @@
package web
import (
"context"
"encoding/json"
"io"
"log"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"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"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// R-193 — the recovery screen. HANDLER-LEVEL tests throughout: a test that reaches a helper while the
// mutation lives in the handler cannot observe it, which is how a red-proof passed three sessions ago.
// Everything below drives the real handler (or the real mux) and asserts the rendered page, the
// on-disk effect, or the absence of the code.
const testRecoveryCode = "helyre-allitasi-kod-tiz-szo-pontosan-igy-ni-most"
const testRepoPW = "b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1b1"
// fakeRecoverer is the agent seam. It records every code it was handed so a test can prove the
// handler passed the RIGHT one, and can fail on demand for the wrong-code path.
type fakeRecoverer struct {
mu sync.Mutex
pw string
sha string
fail bool
// 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 {
// 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
}
// recoveryRunner is the restic seam for the post-unlock inventory.
type recoveryRunner struct {
snapshots []map[string]any
statsSize int64
}
func (rr *recoveryRunner) run(_ context.Context, _ []string, args ...string) ([]byte, error) {
joined := strings.Join(args, " ")
switch {
case strings.Contains(joined, " snapshots"):
b, _ := json.Marshal(rr.snapshots)
return b, nil
case strings.Contains(joined, " stats "):
b, _ := json.Marshal(map[string]any{"total_size": rr.statsSize})
return b, nil
}
return []byte(""), nil
}
type recoveryFixture struct {
s *Server
mgr *backup.Manager
sett *settings.Settings
rec *fakeRecoverer
runner *recoveryRunner
dataDir string
}
// newRecoveryFixture builds a Server in the REBUILT-BOX shape by default: a claimed box (password
// set), no repository password on disk, and the hub holding a sealed package.
func newRecoveryFixture(t *testing.T) *recoveryFixture {
t.Helper()
lg := log.New(io.Discard, "", 0)
dir := t.TempDir()
cfg := &config.Config{}
cfg.Paths.DataDir = filepath.Join(dir, "data")
cfg.Paths.SystemDataPath = filepath.Join(dir, "sys")
cfg.Paths.StacksDir = filepath.Join(dir, "stacks")
cfg.Web.SessionSecret = "test-session-secret-abcdef"
cfg.Web.PasswordHash = "$2a$10$abcdefghijklmnopqrstuv" // claimed: auth is enabled
sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg)
if err != nil {
t.Fatal(err)
}
if err := sett.SetHubEscrowIdentityPresent(true); err != nil { // the hub holds a package
t.Fatal(err)
}
// The realistic shape once the credential self-heal has re-applied the tier: coordinates exist,
// but this box holds no repository password for the history they point at.
if err := sett.SetOffboxTarget(&settings.OffboxTarget{
Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo",
Schedule: "daily", EscrowState: "escrowed",
}); err != nil {
t.Fatal(err)
}
mgr := backup.NewManager(cfg, sett, lg)
// R-241 (v0.206.0): with a sealed package held, WriteOffboxSecrets now REFUSES to mint — which is
// precisely the state this fixture used to hand-construct by deleting the key afterwards. Accept
// the sentinel; it is the product doing what this test's precondition describes.
if err := mgr.WriteOffboxSecrets("PRIVATE-KEY-MATERIAL", "nas.local ssh-ed25519 AAAAhostkey"); err != nil &&
!backup.IsOffboxSealedPackageHeld(err) {
t.Fatal(err)
}
// Belt and braces for any path that DID mint (a fixture variant with no package held): "this box
// cannot open the inherited history" is the whole precondition of the screen.
_ = os.Remove(filepath.Join(cfg.Paths.DataDir, "offbox", "repo_password"))
rr := &recoveryRunner{statsSize: 4 << 20}
mgr.SetOffboxRunner(rr.run)
rec := &fakeRecoverer{pw: testRepoPW, sha: backup.HashResticPassword(testRepoPW)}
stackMgr, serr := stacks.NewManager(cfg, lg)
if serr != nil {
t.Fatal(serr)
}
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 })
// 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 })
// R-311: likewise a current agent, so a 422 may be read as "the code opens a RETAINED package".
// The old-agent behaviour has its own test, which overrides this.
s.SetRecoveryRetainedTrusted(func(context.Context) bool { return true })
return &recoveryFixture{s: s, mgr: mgr, sett: sett, rec: rec, runner: rr, dataDir: cfg.Paths.DataDir}
}
// placeRepoPassword makes the box look HEALTHY (it holds its own repository password).
func (f *recoveryFixture) placeRepoPassword(t *testing.T) {
t.Helper()
if err := f.mgr.InjectOffboxPassword(testRepoPW, true); err != nil {
t.Fatal(err)
}
}
func getRecoveryPage(t *testing.T, s *Server) *httptest.ResponseRecorder {
t.Helper()
rr := httptest.NewRecorder()
s.recoveryPageHandler(rr, httptest.NewRequest(http.MethodGet, "/recovery", nil))
return rr
}
// SCENARIO A — the page appears for the fresh + package box, and interrupts the landing pages.
func TestRecovery_A_PageAppearsForARebuiltBox(t *testing.T) {
f := newRecoveryFixture(t)
if !f.s.recoveryOffer() {
t.Fatal("a rebuilt box (fresh data area + a hub-held package) is not offered the recovery screen")
}
if !f.s.recoveryInterrupts() {
t.Fatal("the full page must interrupt the landing pages before any postpone")
}
rr := getRecoveryPage(t, f.s)
if rr.Code != http.StatusOK {
t.Fatalf("GET /recovery = %d", rr.Code)
}
body := rr.Body.String()
// The two MANDATORY sentences of §8.2 (ASCII-safe fragments — accented patterns get mangled
// through the ssh→pct chain and a false 0 reads exactly like the sentence being gone).
if !strings.Contains(body, "helyre") || !strings.Contains(body, "llít") {
t.Error("the page does not mention the recovery code at all")
}
if !strings.Contains(body, "senki nem tudja p") {
t.Error("MANDATORY: the page must say that nobody can replace a lost recovery code")
}
if !strings.Contains(body, "semmi nem v") {
t.Error("MANDATORY: the page must say that nothing is restored or changed in this step")
}
// It takes the code in a POST body, and the field does not autofill.
if !strings.Contains(body, `action="/recovery/unlock"`) || !strings.Contains(body, `method="POST"`) {
t.Error("the code form must POST to /recovery/unlock")
}
if !strings.Contains(body, `autocomplete="off"`) {
t.Error("the recovery-code field must not autofill")
}
// And it is not cached.
if cc := rr.Header().Get("Cache-Control"); !strings.Contains(cc, "no-store") {
t.Errorf("the recovery page must be no-store, got %q", cc)
}
}
// SCENARIO B — it does NOT appear for anyone else. THE GUARD ON THE CONJUNCTION.
//
// RED-PROOF: drop the hub-package condition from backup.OffsiteRecoveryOffer → the
// "never had off-site backups" case below FAILS, i.e. a brand-new customer is greeted on day one by
// a recovery screen for data they never had. That is the plausible wrong fix.
func TestRecovery_B_DoesNotAppearForAnyoneElse(t *testing.T) {
t.Run("healthy box (holds its own repository password)", func(t *testing.T) {
f := newRecoveryFixture(t)
f.placeRepoPassword(t) // healthy, and not orphaned
if f.s.recoveryOffer() {
t.Fatal("a HEALTHY box was offered the recovery screen")
}
})
t.Run("never had off-site backups (no hub package)", func(t *testing.T) {
f := newRecoveryFixture(t)
if err := f.sett.SetHubEscrowIdentityPresent(false); err != nil {
t.Fatal(err)
}
if f.s.recoveryOffer() {
t.Fatal("a box that never had off-site backups was offered a recovery screen for data it never had")
}
})
t.Run("not claimed — the page is behind the household password", func(t *testing.T) {
f := newRecoveryFixture(t)
f.s.cfg.Web.PasswordHash = "" // unclaimed: no password anywhere
if f.s.authEnabled() {
t.Fatal("fixture error: the box still reads as claimed")
}
// The interception is inside the authenticated surface: RequireAuth gates /launcher and
// /dashboard before ServeHTTP ever runs. Assert that through the REAL middleware chain.
mux := http.NewServeMux()
mux.Handle("/", f.s.RequireAuth(f.s.CsrfProtect(http.HandlerFunc(f.s.ServeHTTP))))
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/launcher", nil))
if rr.Code == http.StatusFound && rr.Header().Get("Location") == "/recovery" {
t.Fatal("an UNCLAIMED box redirected to the recovery screen — it shows metadata that belongs behind the household password")
}
})
}
// SCENARIO C — the correct code unlocks, places the key, and the page then shows what is in there.
func TestRecovery_C_UnlockOpensAndLists(t *testing.T) {
f := newRecoveryFixture(t)
now := time.Now().UTC()
f.runner.snapshots = []map[string]any{
{"short_id": "aaa1111", "time": now.Add(-24 * time.Hour).Format(time.RFC3339), "tags": []string{"immich"}},
{"short_id": "bbb2222", "time": now.Format(time.RFC3339), "tags": []string{"immich"}},
{"short_id": "ccc3333", "time": now.Add(-48 * time.Hour).Format(time.RFC3339), "tags": []string{"calibre-web"}},
}
rr := postUnlock(t, f.s, testRecoveryCode)
if rr.Code != http.StatusOK {
t.Fatalf("unlock = %d", rr.Code)
}
// EFFECT 1: the recovered key is on disk.
got, present := f.mgr.OffboxRepoPasswordHash()
if !present || got != backup.HashResticPassword(testRepoPW) {
t.Fatalf("the recovered repository password was not placed (present=%v)", present)
}
// EFFECT 2: the handler passed the code it was given, unmodified.
if len(f.rec.codes) != 1 || f.rec.codes[0] != testRecoveryCode {
t.Fatalf("the handler did not hand the agent the typed code: %+v", f.rec.codes)
}
// EFFECT 3: the page lists what is in there — apps and dates. A success message with nothing
// shown is indistinguishable from having unlocked an EMPTY store.
body := rr.Body.String()
for _, want := range []string{"immich", "calibre-web"} {
if !strings.Contains(body, want) {
t.Errorf("the listing does not name %q: the customer cannot tell whether this is their data", want)
}
}
if !strings.Contains(body, "4.0 MB") && !strings.Contains(body, "MB") {
t.Errorf("the listing shows no size")
}
// EFFECT 4: it did NOT restore anything — the page points at the restore page rather than doing it.
if !strings.Contains(body, "/backups/restore") {
t.Error("the page must point at the per-app restore rather than restoring")
}
if strings.Contains(body, "/backup/offbox/reconstitute") || strings.Contains(body, "/backup/offbox/place") {
t.Fatal("the recovery page offers a RESTORE action — unlocking and restoring are separate")
}
}
// The EMPTY store is stated plainly rather than shown as a bare list.
func TestRecovery_C_EmptyStoreSaysSo(t *testing.T) {
f := newRecoveryFixture(t)
f.runner.snapshots = nil // opened cleanly, holds nothing
body := postUnlock(t, f.s, testRecoveryCode).Body.String()
if !strings.Contains(body, "nincs benne egyetlen ment") {
t.Fatalf("an empty store must say so plainly — silence there reads as a broken page. body=%.400q", body)
}
}
// SCENARIO D — a wrong code fails closed, writes nothing, says what to check, and does NOT lock out.
func TestRecovery_D_WrongCodeFailsClosedAndIsKind(t *testing.T) {
f := newRecoveryFixture(t)
f.rec.fail = true
for i := 0; i < 6; i++ { // well past any plausible lockout threshold
rr := postUnlock(t, f.s, "rossz kod")
if rr.Code != http.StatusOK {
t.Fatalf("attempt %d: got %d, want a re-rendered page", i, rr.Code)
}
body := rr.Body.String()
if !strings.Contains(body, "nem fogadtuk el") {
t.Fatalf("attempt %d: the page does not say the code was not accepted: %.300q", i, body)
}
if !strings.Contains(body, "z szót") && !strings.Contains(body, "t sz") {
t.Errorf("attempt %d: the message does not say what to check", i)
}
// The raw agent error must NOT be shown to the customer.
if strings.Contains(body, "age:") || strings.Contains(body, "incorrect passphrase") {
t.Errorf("attempt %d: the raw technical error was rendered to the customer", i)
}
// NOTHING was written.
if _, present := f.mgr.OffboxRepoPasswordHash(); present {
t.Fatalf("attempt %d: a repository password was written on a FAILED unlock", i)
}
// And the form is still there — no lockout.
if !strings.Contains(body, `name="recovery_code"`) {
t.Fatalf("attempt %d: the customer was locked out of their own data after a mistyped code", i)
}
}
}
// SCENARIO E — "most nem" stops the interruption and NOTHING else. The entry point survives.
//
// RED-PROOF: bind the backups-page entry point to recoveryInterrupts instead of recoveryOffer → the
// route to the data disappears after one click.
func TestRecovery_E_PostponeKeepsTheEntryPoint(t *testing.T) {
f := newRecoveryFixture(t)
// v0.206.0 (R-241): the interruption is now epoch-scoped, so the epoch has to exist before a
// dismissal can be recorded against it. recoveryInterrupts advances it on the edge, exactly as the
// landing-page interception does in production.
if !f.s.recoveryInterrupts() {
t.Fatal("precondition: the full page should interrupt on the first entry into the offered state")
}
rr := httptest.NewRecorder()
f.s.recoveryPostponeHandler(rr, httptest.NewRequest(http.MethodPost, "/recovery/postpone", nil))
if rr.Code != http.StatusFound {
t.Fatalf("postpone = %d, want a redirect", rr.Code)
}
// Recorded against the CURRENT epoch — the situation, not the screen for ever.
if v := f.sett.GetRecoveryOfferView(); v.PostponedEpoch != v.Epoch || v.Epoch == 0 {
t.Fatalf("the postpone was not recorded against the current epoch: %+v", v)
}
// The full page no longer interrupts…
if f.s.recoveryInterrupts() {
t.Fatal("the full page still interrupts after 'most nem'")
}
// …and it must NOT interrupt through the real mux either.
mux := http.NewServeMux()
mux.Handle("/", http.HandlerFunc(f.s.ServeHTTP))
rr2 := httptest.NewRecorder()
mux.ServeHTTP(rr2, httptest.NewRequest(http.MethodGet, "/launcher", nil))
if rr2.Code == http.StatusFound && rr2.Header().Get("Location") == "/recovery" {
t.Fatal("the landing page still redirects to the recovery screen after 'most nem'")
}
// …but the ENTRY POINT is untouched: the offer stands, so the backups page still renders it.
if !f.s.recoveryOffer() {
t.Fatal("'most nem' removed the OFFER — the customer has lost the route to their own data")
}
// And the page itself is still reachable directly.
if getRecoveryPage(t, f.s).Code != http.StatusOK {
t.Fatal("the recovery page is unreachable after 'most nem'")
}
// THE ENTRY POINT ITSELF, rendered: the backups page must still carry the route. Asserted on the
// rendered flag rather than on recoveryOffer alone, because the defect this guards against is the
// TEMPLATE being fed the wrong predicate.
rr3 := httptest.NewRecorder()
f.s.backupsRemoteHandler(rr3, httptest.NewRequest(http.MethodGet, "/backups/remote", nil))
if !strings.Contains(rr3.Body.String(), `href="/recovery"`) {
t.Fatal("the backups page no longer offers the route to the recovery screen after 'most nem' — a customer who clicked past it once has lost the way to their own data")
}
}
// SCENARIO F — "I do not want the old data" is confirmed TWICE and reaches the SHIPPED move-aside.
//
// RED-PROOF: render the final button on the first view (drop the ConfirmSetAside gate) → one click
// suffices, and this test fails on the first assertion.
func TestRecovery_F_SetAsideNeedsTwoConfirmations(t *testing.T) {
f := newRecoveryFixture(t)
// The move-aside only exists once the tier is orphaned — that is the shipped handler's own
// precondition, and the page only offers the choice when it can actually run.
if err := f.sett.SetOffboxTarget(&settings.OffboxTarget{
Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo",
Schedule: "daily", EscrowState: "escrowed", RepoState: "orphaned",
}); err != nil {
t.Fatal(err)
}
if !f.mgr.OffboxOrphaned() {
t.Fatal("fixture: the tier is not orphaned, so the set-aside cannot be offered")
}
// FIRST VIEW — the destructive-looking button must NOT be present yet.
first := getRecoveryPage(t, f.s).Body.String()
if strings.Contains(first, `action="/backup/offbox/reset"`) {
t.Fatal("the set-aside form is on the FIRST view — one click would set the customer's history aside")
}
if !strings.Contains(first, "setaside=1") {
t.Fatal("the first view offers no route to the set-aside choice at all")
}
// And it must read as the exceptional path, not an equal third button.
if strings.Count(first, "btn-primary") > 1 {
t.Error("the set-aside is styled as an equal primary action")
}
// SECOND VIEW — now the confirmation, naming exactly what happens.
rr := httptest.NewRecorder()
f.s.recoveryPageHandler(rr, httptest.NewRequest(http.MethodGet, "/recovery?setaside=1", nil))
second := rr.Body.String()
if !strings.Contains(second, `action="/backup/offbox/reset"`) {
t.Fatal("the second view does not reach the SHIPPED move-aside handler")
}
if !strings.Contains(second, `name="confirm" value="1"`) {
t.Fatal("the second view does not carry the shipped handler's confirm gate")
}
// The copy must say SET ASIDE, not delete — the whole point of the ruling.
if !strings.Contains(second, "lretessz") {
t.Error("the confirmation does not say the backups are SET ASIDE")
}
if !strings.Contains(second, "nem t") {
t.Error("the confirmation does not say they are NOT deleted")
}
}
// SCENARIO G — the command line and the page drive ONE core.
//
// RED-PROOF: give the handler its own copy of fetch→compare→install instead of calling
// RecoverInstallCore → this test still passes on the happy path, so it asserts the SHARED SYMBOL
// from source (below) as well as the behaviour here.
func TestRecovery_G_PageAndCLIShareOneCore(t *testing.T) {
// Behavioural half: the same fake, the same outcome, through both callers.
f := newRecoveryFixture(t)
res, err := backup.RecoverInstallCore(context.Background(), f.mgr, f.rec, testRecoveryCode, true)
if err != nil || res.Outcome != backup.RecoverInstalled {
t.Fatalf("core install: outcome=%q err=%v", res.Outcome, err)
}
// Re-running is UNCHANGED, not a second install — the same three outcomes the CLI documents.
res2, err2 := backup.RecoverInstallCore(context.Background(), f.mgr, f.rec, testRecoveryCode, true)
if err2 != nil || res2.Outcome != backup.RecoverUnchanged {
t.Fatalf("core re-run: outcome=%q err=%v", res2.Outcome, err2)
}
}
// SCENARIO H (§8.3) — the recovery code persists NOWHERE, with the planted-copy positive control.
func TestRecovery_H_CodeLeavesNoTrace(t *testing.T) {
f := newRecoveryFixture(t)
var logBuf strings.Builder
f.s.logger = log.New(&logBuf, "", 0)
rr := postUnlock(t, f.s, testRecoveryCode)
// 1) not echoed in the response
if strings.Contains(rr.Body.String(), testRecoveryCode) {
t.Fatal("the recovery code was echoed back in the rendered page")
}
// 2) not in any log line
if strings.Contains(logBuf.String(), testRecoveryCode) {
t.Fatal("the recovery code reached the log")
}
// 3) not in any file under the data dir
found := grepTree(t, f.dataDir, testRecoveryCode)
if found != "" {
t.Fatalf("the recovery code was persisted to %s", found)
}
// THE POSITIVE CONTROL — a sweep whose sensitivity was never shown is not evidence. Plant a copy
// where the sweep looks and require it to be found; then remove it.
planted := filepath.Join(f.dataDir, "planted-control.txt")
if err := os.WriteFile(planted, []byte("x "+testRecoveryCode+" x"), 0o600); err != nil {
t.Fatal(err)
}
if got := grepTree(t, f.dataDir, testRecoveryCode); got == "" {
t.Fatal("POSITIVE CONTROL FAILED: the sweep could not find a planted copy, so its earlier silence proves nothing")
}
if err := os.Remove(planted); err != nil {
t.Fatal(err)
}
if got := grepTree(t, f.dataDir, testRecoveryCode); got != "" {
t.Fatalf("the control was not cleaned up: %s", got)
}
}
// grepTree returns the first file under root whose contents contain needle ("" when none).
func grepTree(t *testing.T, root, needle string) string {
t.Helper()
var hit string
_ = filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
if err != nil || info == nil || info.IsDir() || hit != "" {
return nil
}
b, rerr := os.ReadFile(p)
if rerr == nil && strings.Contains(string(b), needle) {
hit = p
}
return nil
})
return hit
}
func postUnlock(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
}
@@ -0,0 +1,146 @@
package web
import (
"go/ast"
"go/parser"
"go/token"
"testing"
)
// SCENARIO I / §8.5 — the seam-discipline tests. Both walk the AST rather than grepping, because a
// commented-out call still contains the string, and both parse with comments DROPPED so a commented
// line cannot satisfy them. This project's built-but-never-wired count is six.
// The handler must drive the SHARED core. If it ever grows its own fetch→compare→install, the CLI and
// the page can diverge and only one of them will be tested — on the one operation that can
// permanently lose a customer's data.
//
// RED-PROOF: replace the RecoverInstallCore call in recoveryUnlockHandler with an inline copy →
// this fails.
func TestRecoveryHandlerDrivesTheSharedCore(t *testing.T) {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "recovery_handlers.go", nil, 0)
if err != nil {
t.Fatalf("parse recovery_handlers.go: %v", err)
}
var fn *ast.FuncDecl
ast.Inspect(f, func(n ast.Node) bool {
if d, ok := n.(*ast.FuncDecl); ok && d.Name.Name == "recoveryUnlockHandler" {
fn = d
return false
}
return true
})
if fn == nil {
t.Fatal("recoveryUnlockHandler not found — did it move? the shared-core wiring is now unasserted")
}
callsCore := false
// Any DIRECT use of the agent's unseal from the handler would be a second implementation.
callsRecoverDirectly := false
ast.Inspect(fn, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
switch sel.Sel.Name {
case "RecoverInstallCore":
callsCore = true
case "RecoverOffsiteRepoPassword", "InjectOffboxPassword":
callsRecoverDirectly = true
}
return true
})
if !callsCore {
t.Fatal("recoveryUnlockHandler does NOT call backup.RecoverInstallCore — the page and the command line would be two implementations of one irreversible operation")
}
if callsRecoverDirectly {
t.Fatal("recoveryUnlockHandler reaches the agent/injection DIRECTLY — that is a second recovery implementation, which is exactly what the shared core exists to prevent")
}
}
// And the CLI wrapper must drive the same core, from the other side.
//
// RED-PROOF: restore the inline fetch→compare→install inside RecoverAndInstall → this fails.
func TestCLIWrapperDrivesTheSharedCore(t *testing.T) {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "../backup/offbox_recovery_cli.go", nil, 0)
if err != nil {
t.Fatalf("parse offbox_recovery_cli.go: %v", err)
}
var fn *ast.FuncDecl
ast.Inspect(f, func(n ast.Node) bool {
if d, ok := n.(*ast.FuncDecl); ok && d.Name.Name == "RecoverAndInstall" {
fn = d
return false
}
return true
})
if fn == nil {
t.Fatal("RecoverAndInstall not found — the shared-core wiring is now unasserted on the CLI side")
}
callsCore, callsDirect := false, false
ast.Inspect(fn, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
if id, ok := call.Fun.(*ast.Ident); ok && id.Name == "RecoverInstallCore" {
callsCore = true
}
if sel, ok := call.Fun.(*ast.SelectorExpr); ok {
switch sel.Sel.Name {
case "RecoverOffsiteRepoPassword", "InjectOffboxPassword":
callsDirect = true
}
}
return true
})
if !callsCore {
t.Fatal("RecoverAndInstall no longer calls RecoverInstallCore — the CLI has its own copy again")
}
if callsDirect {
t.Fatal("RecoverAndInstall reaches the agent/injection directly — the two callers have diverged")
}
}
// The page must be REACHABLE: routed in ServeHTTP, and the landing-page interception present.
//
// RED-PROOF: comment out the interception block → this fails, and a rebuilt box's owner would never
// meet the screen unless they guessed the URL.
func TestRecoveryRoutesAreWired(t *testing.T) {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "server.go", nil, 0)
if err != nil {
t.Fatalf("parse server.go: %v", err)
}
var handlersSeen, interceptSeen bool
ast.Inspect(f, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
switch sel.Sel.Name {
case "recoveryPageHandler", "recoveryUnlockHandler", "recoveryPostponeHandler":
handlersSeen = true
case "recoveryInterrupts":
interceptSeen = true
}
return true
})
if !handlersSeen {
t.Fatal("no recovery handler is routed in ServeHTTP — the page exists and is unreachable")
}
if !interceptSeen {
t.Fatal("ServeHTTP never consults recoveryInterrupts — the full page would never take over the landing pages, so a customer would have to guess the URL")
}
}
@@ -0,0 +1,93 @@
package web
import (
"strings"
"testing"
)
// R-252 / R-253 — A REFUSAL ON THE RECOVERY PATH NAMES A REASON A PERSON CAN ACT ON, AND THE WAY TO
// ACT ON IT.
//
// Both of these were met on the fifth walk (2026-08-07) at the LAST step of a successful recovery —
// the customer had entered their recovery code, seen their data listed, and pressed through. Neither
// needed a shell to clear, which is why the walk's journey half passed; both required knowing
// something the product never said, which is why they are filed.
//
// These assert the RENDERED page, not the state that feeds it: the defect in both cases was copy
// that disagreed with behaviour, and only the rendered bytes can show that.
// restoreData is the healthy fixture — app in the store, installed here, drives registered. Every
// test below overlays exactly one field, so what it proves is attributable.
func restoreData() map[string]interface{} {
d := splitTestData()
d["NoRestoreDestination"] = false
d["OffsiteRestoreRows"] = []OffsiteRestoreRow{
{App: "calibre-web", DisplayName: "Calibre-Web", InStore: true, Installed: true, Enabled: true},
}
return d
}
// ── SCENARIO C — a refusal for a missing drive says what to do ──────────────────────────────────
// RED-PROOF: restore the old copy — delete the `{{if .NoRestoreDestination}}` block from
// backups_restore.html — and this fails on both assertions: the customer is left with a page that
// looks normal and a button that will refuse with a message naming no next step.
func TestRestorePage_NoRegisteredDrive_NamesTheReasonAndTheRoute(t *testing.T) {
d := restoreData()
d["NoRestoreDestination"] = true
html := renderBackupPage(t, "backups_restore", d)
if !strings.Contains(html, "csatold vissza az adatmeghajtót") {
t.Error("R-252: the restore page says nothing about the drives being unregistered — the " +
"customer presses through and gets a refusal that names no next step")
}
if !strings.Contains(html, `href="/storage"`) {
t.Error("R-252: the notice does not ROUTE to the place that fixes it — a reason without a " +
"route is what made this an obstacle rather than a message")
}
// It must say the data is safe. „nincs elérhető adatmeghajtó" reads like data loss; it is not.
if !strings.Contains(html, "megvannak") {
t.Error("R-252: the notice does not say the backups and the drives are both still there")
}
}
// ── SCENARIO E — a healthy box is unchanged ─────────────────────────────────────────────────────
// RED-PROOF: make the notice unconditional (drop the `{{if .NoRestoreDestination}}` guard) and this
// fails — a box with every precondition satisfied is warned about a problem it does not have, which
// is the R-215 shape (a screen about a situation the customer is not in).
func TestRestorePage_HealthyBox_HasNoPreconditionNotice(t *testing.T) {
html := renderBackupPage(t, "backups_restore", restoreData())
if strings.Contains(html, "csatold vissza az adatmeghajtót") {
t.Error("the drive notice rendered on a box whose drives ARE registered")
}
if strings.Contains(html, "előbb") && strings.Contains(html, "telepítsd újra") {
t.Error("the not-installed copy rendered for an app that IS installed")
}
}
// ── SCENARIO D — the page and the handler agree about reinstalling ──────────────────────────────
// RED-PROOF: restore the old string „Nincs telepítve — a visszaállítás előbb újratelepíti." and this
// fails on the first assertion — the page promises a reinstall the reconstitute path refuses to do,
// which is the contradiction R-253 filed.
func TestRestorePage_NotInstalled_DoesNotPromiseAReinstall(t *testing.T) {
d := restoreData()
d["OffsiteRestoreRows"] = []OffsiteRestoreRow{
{App: "calibre-web", DisplayName: "Calibre-Web", InStore: true, Installed: false, Enabled: true},
}
html := renderBackupPage(t, "backups_restore", d)
if strings.Contains(html, "a visszaállítás előbb újratelepíti") {
t.Error("R-253: the page still promises that the restore reinstalls the app — the " +
"reconstitute path refuses precisely because it is not installed, and cannot deploy it " +
"itself (the destination is the app's own HDD path, a drive the CUSTOMER chooses)")
}
if !strings.Contains(html, "telepítsd újra") {
t.Error("R-253: the page no longer tells the customer to install the app first")
}
if !strings.Contains(html, `href="/stacks/calibre-web/deploy"`) {
t.Error("R-253: the copy names the step but does not route to it")
}
}
+12 -19
View File
@@ -160,23 +160,6 @@ func deriveWizardStep(in restoreWizardInput) restoreWizardView {
}
}
// resolveWizardApp finds the wizard's app in the offsite-toggled set — the same gating the list page
// applies. Pure, so the two refusal rows (unknown app, app present but NOT toggled for offsite) are
// table-testable without a live backup manager.
//
// An app that is not toggled has no offsite snapshot to restore FROM, so its wizard would be a page
// of controls that cannot work. Both refusals return nil and the caller redirects — a customer-visible
// URL that survives a bookmark, an app rename or a toggle being switched off must never 500.
func resolveWizardApp(rows []OffboxAppRow, name string) *OffboxAppRow {
for _, a := range rows {
if a.Name == name && a.Enabled {
cp := a
return &cp
}
}
return nil
}
// restoreOpInFlight reports whether a restore op is in flight, FOR DISPLAY.
//
// **Use this, not `Manager.IsRunning()`.** The Manager carries two different booleans and they are
@@ -216,9 +199,19 @@ func (s *Server) backupsRestoreWizardHandler(w http.ResponseWriter, r *http.Requ
return
}
row := resolveWizardApp(s.buildOffboxApps(), app)
// R-237: the gate is "is it in the store", NOT "is it toggled on for future backups". The old
// resolver required the toggle, which locked a rebuilt box out of its own snapshots — measured
// live on the R-201 re-walk. A refusal here still never 500s.
rows, storeState := s.offsiteRestoreRows(r.Context())
row := resolveOffsiteRestoreApp(rows, app)
if row == nil {
offboxRedirectTo(w, r, "/backups/restore", "Ez az alkalmazás nincs távoli mentésre kijelölve.", true)
msg := "Ehhez az alkalmazáshoz nincs mentés a távoli tárolóban."
if storeState != offsiteStoreKnown {
// Never say "there is nothing" when we could not look — R-225's rule, one screen over.
msg = "Nem tudjuk elolvasni a távoli tárolót, ezért nem tudjuk, van-e benne mentés ehhez az alkalmazáshoz."
}
s.logger.Printf("[INFO] [web] restore wizard refused for %q: not restorable (store=%s)", app, storeState)
offboxRedirectTo(w, r, "/backups/restore", msg, true)
return
}
+17 -10
View File
@@ -113,23 +113,30 @@ func TestDeriveWizardStep_Table(t *testing.T) {
// --- Group C (Scenario B error rows): refusals resolve, never 500 ----------------------------------
func TestResolveWizardApp_Refusals(t *testing.T) {
rows := []OffboxAppRow{
{Name: "immich", DisplayName: "Immich", Enabled: true},
{Name: "radarr", DisplayName: "Radarr", Enabled: false},
func TestResolveOffsiteRestoreApp_Refusals(t *testing.T) {
// R-237 replaced the toggle gate with a store gate. The row that used to be refused for being
// untoggled (radarr) now resolves when the STORE holds it — that reversal IS the fix.
rows := []OffsiteRestoreRow{
{App: "immich", DisplayName: "Immich", InStore: true, Enabled: true},
{App: "radarr", DisplayName: "Radarr", InStore: true, Enabled: false},
{App: "bookstack", DisplayName: "BookStack", Installed: true},
}
if got := resolveWizardApp(rows, "immich"); got == nil || got.DisplayName != "Immich" {
t.Fatalf("toggled app must resolve, got %+v", got)
if got := resolveOffsiteRestoreApp(rows, "immich"); got == nil || got.DisplayName != "Immich" {
t.Fatalf("a stored app must resolve, got %+v", got)
}
if got := resolveWizardApp(rows, "radarr"); got != nil {
t.Errorf("an app that is NOT toggled for offsite has no snapshot to restore from — want nil, got %+v", got)
if got := resolveOffsiteRestoreApp(rows, "radarr"); got == nil {
t.Errorf("the offsite TOGGLE must not gate a PAST backup — want resolved, got nil")
}
if got := resolveWizardApp(rows, "does-not-exist"); got != nil {
if got := resolveOffsiteRestoreApp(rows, "bookstack"); got != nil {
t.Errorf("an app with nothing in the store has no restore to offer — want nil, got %+v", got)
}
if got := resolveOffsiteRestoreApp(rows, "does-not-exist"); got != nil {
t.Errorf("unknown app must not resolve, got %+v", got)
}
if got := resolveWizardApp(nil, "immich"); got != nil {
if got := resolveOffsiteRestoreApp(nil, "immich"); got != nil {
t.Errorf("empty set must not resolve, got %+v", got)
}
}
// The customer-visible URL must redirect, not 500, when the offsite target is not configured at all.
@@ -0,0 +1,147 @@
package web
import (
"bytes"
"io"
"log"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// R-249 — THE PASSPHRASE MUST NOT BE IN THE RESPONSE BODY OF A PAGE THE CUSTOMER MERELY OPENS.
//
// WHY THESE TESTS ASSERT ON THE BODY AND NOT ON A RENDERED VIEW, which is the whole reason the
// defect survived: the old markup put the value inside `<span style="display:none">`. Every test
// that asked "what does the customer SEE" passed, because a browser drew asterisks. The value was
// in the bytes the whole time, and a `curl` of the page returned it — which is how it was found, by
// landing in a session transcript on 2026-08-07.
//
// So: render the real page and search the raw HTML. A test that cannot see a display:none span
// cannot see this defect at all.
const testRetrievalPassphrase = "edeni-oshalom-disztok-harul-Zsolna-TESTONLY"
// securityHarness builds a Server complete enough for securityPageData, which also reads the stack
// list for the geo per-app override selector.
func securityHarness(t *testing.T) *Server {
t.Helper()
dir := t.TempDir()
lg := log.New(io.Discard, "", 0)
cfg := config.Default()
cfg.Customer.ID = "c1"
cfg.Customer.Domain = "example.hu"
cfg.Paths.StacksDir = filepath.Join(dir, "stacks")
cfg.Paths.DataDir = filepath.Join(dir, "data")
cfg.Web.SessionSecret = "test-session-secret-abcdef"
sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg)
if err != nil {
t.Fatalf("settings: %v", err)
}
mgr, err := stacks.NewManager(cfg, lg)
if err != nil {
t.Fatalf("stacks: %v", err)
}
return &Server{cfg: cfg, settings: sett, stackMgr: mgr, logger: lg, version: "test"}
}
// renderSecurityPage drives the REAL page-data builder and the REAL template, and returns the bytes
// a browser would receive.
func renderSecurityPage(t *testing.T, passphrase string) string {
t.Helper()
s := securityHarness(t)
if passphrase != "" {
if err := s.settings.SetRetrievalPassword(passphrase); err != nil {
t.Fatalf("seed retrieval password: %v", err)
}
}
s.loadTemplates()
var buf bytes.Buffer
if err := s.tmpl.ExecuteTemplate(&buf, "settings_security", s.securityPageData()); err != nil {
t.Fatalf("render settings_security: %v", err)
}
return buf.String()
}
// ── SCENARIO A — the password is not in the page ────────────────────────────────────────────────
// RED-PROOF (run and confirmed failing before the fix was restored): put the value back in the page
// data and the template —
//
// handlers.go: data["RetrievalPassword"] = s.settings.GetRetrievalPassword()
// template: <code class="mono">{{.RetrievalPassword}}</code> inside the display:none span
//
// — and this test fails on the first assertion, printing the plaintext's presence in the body. That
// is the defect, reproduced. The mutation was applied and observed failing; see the session report.
func TestSecurityPage_DoesNotContainTheRetrievalPassphrase(t *testing.T) {
html := renderSecurityPage(t, testRetrievalPassphrase)
if strings.Contains(html, testRetrievalPassphrase) {
t.Error("R-249: the retrieval passphrase is in the response body of the security page — " +
"a fetch of this page returns the plaintext, and the reveal toggle only stops a " +
"browser DRAWING it (this is the defect, and it is invisible to any test that asserts " +
"on what is displayed)")
}
// The card must still be there — the fix is to remove the VALUE, not the feature (Scenario B).
if !strings.Contains(html, "Visszaállítási jelszó") {
t.Error("the recovery-info card vanished — the fix must not remove the customer's access, " +
"only the value from the markup")
}
if !strings.Contains(html, `id="retrieval-pw-btn"`) {
t.Error("no reveal control rendered, so the customer has no way to obtain the passphrase at all")
}
}
// The card is gated on EXISTENCE, and existence is not the value. A box with no stored passphrase
// must not render the card — the old gate was `{{if .RetrievalPassword}}`, which read the secret to
// decide whether to show the secret.
func TestSecurityPage_NoCardWhenNoPassphraseStored(t *testing.T) {
html := renderSecurityPage(t, "")
if strings.Contains(html, "Visszaállítási jelszó") {
t.Error("the recovery-info card rendered on a box with no stored retrieval passphrase")
}
}
// ── SCENARIO B — the customer can still get it ──────────────────────────────────────────────────
// RED-PROOF: delete the `/settings/retrieval-password/reveal` case from server.go (or make the
// handler return 404 unconditionally) and this fails — the customer is shown unable to obtain the
// passphrase at all, which is the wrong fix for Scenario A.
func TestRevealEndpoint_ReturnsThePassphraseToAnAuthenticatedCaller(t *testing.T) {
s := securityHarness(t)
if err := s.settings.SetRetrievalPassword(testRetrievalPassphrase); err != nil {
t.Fatalf("seed: %v", err)
}
rr := httptest.NewRecorder()
s.settingsRetrievalPasswordRevealHandler(rr, httptest.NewRequest("POST", "/settings/retrieval-password/reveal", nil))
if rr.Code != 200 {
t.Fatalf("reveal returned %d, want 200 — the customer cannot get their own passphrase", rr.Code)
}
if !strings.Contains(rr.Body.String(), testRetrievalPassphrase) {
t.Error("the reveal endpoint did not return the passphrase — Scenario A's fix must not " +
"protect the secret by removing the customer's access to it")
}
// A cached reveal is the same defect one layer down: a back-navigation would re-present the body.
if got := rr.Header().Get("Cache-Control"); !strings.Contains(got, "no-store") {
t.Errorf("reveal response Cache-Control = %q, want no-store", got)
}
}
// A box with nothing stored answers cleanly rather than leaking the distinction as a 500.
func TestRevealEndpoint_404sWhenNothingStored(t *testing.T) {
s := securityHarness(t)
rr := httptest.NewRecorder()
s.settingsRetrievalPasswordRevealHandler(rr, httptest.NewRequest("POST", "/settings/retrieval-password/reveal", nil))
if rr.Code != 404 {
t.Errorf("reveal on a box with no passphrase returned %d, want 404", rr.Code)
}
if strings.Contains(rr.Body.String(), testRetrievalPassphrase) {
t.Error("the empty-case response carried a passphrase")
}
}
+73
View File
@@ -74,6 +74,12 @@ type Server struct {
agentCliErr error
agentCliOnce sync.Once
// initialCredsFn is the R-254 read seam for an app's generated first-login credential. nil → the
// real live container read (stackMgr.ReadInitialCredentials). ONE definition, used by BOTH the
// info page and the reveal endpoint — two ways to read the same secret is how one of them ends up
// caching it back into the page.
initialCredsFn func(stackName string) (*stacks.ExtractedCreds, error)
// Hub push status callback — set via SetHubPushStatus for monitoring page
hubPushStatusFn func() HubPushStatusData
@@ -96,6 +102,31 @@ type Server struct {
escrowStageFn func(ctx context.Context) error
escrowStaleFn func() bool
// escrowSealedAtFn (v0.200.0, R-193) reports WHEN the hub's sealed recovery package was created —
// the one non-secret fact the recovery screen may state before a code is entered. Wired via
// SetEscrowSealedAt from the report ACK; nil → the page says nothing about the date rather than
// guessing one.
escrowSealedAtFn func() string
// 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
// 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
// recoveryRetainedTrustedFn overrides the R-311 gate deciding whether a 422 may be read as "the
// code is correct and opens a RETAINED earlier package" (tests). INIT-ONLY.
recoveryRetainedTrustedFn 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.
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).
netAdd netAddState
@@ -361,6 +392,15 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.logger.Printf("[DEBUG] [web] ServeHTTP: %s %s from %s", r.Method, logPath, r.RemoteAddr)
}
// R-193: the recovery screen takes over the LANDING pages (and only those) while the situation
// holds and the customer has not postponed. Placed before the switch so it cannot be defeated by
// a route added later, and scoped to two paths so it never traps the customer inside it — every
// other page, including the backups area the entry point lives in, stays reachable.
if (path == "/launcher" || path == "/dashboard") && r.Method == http.MethodGet && s.recoveryInterrupts() {
http.Redirect(w, r, "/recovery", http.StatusFound)
return
}
switch {
// Customer-claim arc (v0.122.0, F-4): the code-entry page + its handlers. Reachable pre-auth
// (code-gated internally); CSRF via the pre-auth HMAC token (validated inside the handlers).
@@ -375,6 +415,23 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// canonical landing page. "/" 302s to /launcher (ONE canonical URL per page — the launcher body
// is never served AT "/"). Post-login lands on "/", so it flows here → the launcher.
http.Redirect(w, r, "/launcher", http.StatusFound)
// R-193 — the recovery screen. A FULL PAGE, not a banner: someone who has just lost a machine
// deserves a screen about that and nothing else. It takes over the landing pages while the
// situation holds AND the customer has not chosen "most nem"; afterwards it stays reachable here
// (and from the backups area) for as long as the situation lasts.
case path == "/recovery" && r.Method == http.MethodGet:
s.recoveryPageHandler(w, r)
case path == "/recovery/unlock" && r.Method == http.MethodPost:
s.recoveryUnlockHandler(w, r)
case path == "/recovery/postpone" && r.Method == http.MethodPost:
s.recoveryPostponeHandler(w, r)
// R-241 (v0.206.0): the per-visit banner dismissal and the durable reminder opt-out. They are
// SEPARATE ROUTES because they are separate decisions — one is "not now", the other is "stop
// asking about this situation", and neither removes the entry point on the backups page.
case path == "/recovery/banner/dismiss" && r.Method == http.MethodPost:
s.recoveryBannerDismissHandler(w, r)
case path == "/recovery/remind-optout" && r.Method == http.MethodPost:
s.recoveryRemindOptOutHandler(w, r)
case path == "/dashboard":
s.dashboardHandler(w, r)
case path == "/launcher":
@@ -445,6 +502,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.settingsSecurityPageHandler(w, r)
case path == "/settings/password" && r.Method == http.MethodPost:
s.settingsPasswordHandler(w, r)
// R-249: the retrieval passphrase is fetched by an explicit authenticated act, never templated
// into the security page. POST (not GET) so it is CSRF-covered and uncacheable — see the handler.
case path == "/settings/retrieval-password/reveal" && r.Method == http.MethodPost:
s.settingsRetrievalPasswordRevealHandler(w, r)
case path == "/settings/notifications" && r.Method == http.MethodPost:
s.settingsNotificationsHandler(w, r)
case path == "/settings/notifications/test" && r.Method == http.MethodPost:
@@ -509,6 +570,12 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.offboxConfirmEscrowHandler(w, r)
case path == "/backup/offbox/inject-password" && r.Method == http.MethodPost:
s.offboxInjectPasswordHandler(w, r)
// R-254 site two: an already-deployed app's generated secrets are fetched by an explicit act,
// not rendered into the settings page. The PRE-DEPLOY hidden input is untouched and deliberate
// (README §318) — see the handler for what §7.2 established.
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/auto-field/reveal") && r.Method == http.MethodPost:
name := strings.TrimSuffix(strings.TrimPrefix(path, "/stacks/"), "/auto-field/reveal")
s.appAutoFieldRevealHandler(w, r, name)
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/export"):
name := strings.TrimPrefix(path, "/stacks/")
name = strings.TrimSuffix(name, "/export")
@@ -552,6 +619,12 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, AppPlaceholderSVG)
case strings.HasPrefix(path, "/static/assets/"):
s.serveAsset(w, r, strings.TrimPrefix(path, "/static/assets/"))
// R-254: the app's generated first-login password is fetched by an explicit authenticated act,
// never templated into the info page. Placed BEFORE the /apps/ catch-all so the more specific
// path wins. POST (not GET) so CsrfProtect covers it and it is not cacheable — see the handler.
case strings.HasPrefix(path, "/apps/") && strings.HasSuffix(path, "/initial-credentials/reveal") && r.Method == http.MethodPost:
slug := strings.TrimSuffix(strings.TrimPrefix(path, "/apps/"), "/initial-credentials/reveal")
s.appInitialCredsRevealHandler(w, r, slug)
case strings.HasPrefix(path, "/apps/"):
slug := strings.TrimPrefix(path, "/apps/")
s.appDetailHandler(w, r, slug)
@@ -373,6 +373,8 @@ func (s *Server) ServeStorageAPI(w http.ResponseWriter, r *http.Request) {
s.handleStorageImpact(w, r)
case r.URL.Path == "/api/storage/register" && r.Method == http.MethodPost:
s.handleStorageRegister(w, r)
case r.URL.Path == "/api/storage/register-mounted" && r.Method == http.MethodPost:
s.handleStorageRegisterMounted(w, r)
// E-2 Parts 3+4. State drives the degraded banner and the OFFER; assign is the offer's
// ACCEPTANCE and the ONLY writer of the role — registration above deliberately does not set it.
case r.URL.Path == "/api/storage/backup-target" && r.Method == http.MethodGet:
@@ -865,6 +867,53 @@ func (s *Server) handleStorageRegister(w http.ResponseWriter, r *http.Request) {
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"registered": true, "where": stable, "raw": req.Where})
}
// handleStorageRegisterMounted registers an ALREADY-mounted, unregistered filesystem verbatim
// (R-280). It is the action behind an `already_mounted` attach candidate, and it is the route the
// rebuilt demo-hp needed: the escape hatch that unblocked that box registered `/mnt/sys_drive`, an
// in-guest path, and nothing in the dashboard offered it.
//
// It differs from handleStorageRegister deliberately: that one takes the agent's RAW /mnt/<name> host
// mount and registers the STABLE /mnt/felhom-drives/<name> the intermediary model binds it to. These
// candidates are not agent drives and have no stable twin — the mountpoint IS the path to register,
// so translating it would register a directory that does not exist.
//
// The posted path is NOT trusted: it is matched against the freshly re-derived set of mounted,
// unregistered filesystems. A path that is not currently offered is refused, so this cannot be used
// to register an arbitrary directory.
func (s *Server) handleStorageRegisterMounted(w http.ResponseWriter, r *http.Request) {
var req struct {
Path string `json:"path"`
Label string `json:"label"`
SetDefault bool `json:"set_default"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
return
}
want := path.Clean(strings.TrimSpace(req.Path))
var match *mountedStore
for _, m := range s.attachableStores() {
if m.Path == want {
found := m
match = &found
break
}
}
if match == nil {
// Names a reason the customer can act on, and a route — never a bare refusal.
writeDiskJSON(w, http.StatusBadRequest, false,
"Ez a meghajtó most nem csatolható — lehet, hogy már regisztrálva van, vagy időközben lecsatolódott. Frissítsd az oldalt, és nézd meg a Tárhely → Meghajtók listát.", nil)
return
}
if err := s.registerStoragePath(match.Path, req.Label, req.SetDefault); err != nil {
s.logger.Printf("[WARN] [web] mounted-store register %s failed: %v", match.Path, err)
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
return
}
s.logger.Printf("[INFO] [web] storage path registered (already-mounted store): %s", match.Path)
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"registered": true, "where": match.Path})
}
func (s *Server) handleStorageAttach(w http.ResponseWriter, r *http.Request) {
var req storageProvReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+37 -14
View File
@@ -180,11 +180,14 @@ function appMigrate(btn,app,label){
{{end}}
<tr>
<td class="initcred-label" style="padding:.25rem .75rem .25rem 0;color:var(--text-3);white-space:nowrap;vertical-align:middle">Jelszó</td>
<!-- R-254: the value is NOT in this page. It used to be rendered into a `hidden`
span, which stops a browser drawing it and nothing else — a fetch of this page
returned a real per-install password. Both buttons now ask the server. -->
<td style="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap">
<code id="initcred-pw">••••••••••••</code>
<span id="initcred-pw-val" hidden>{{.InitialCreds.Password}}</span>
<button type="button" class="btn btn-sm btn-outline" onclick="icRevealPw(this)">Megjelenítés</button>
<button type="button" class="btn btn-sm btn-outline" id="initcred-reveal" onclick="icRevealPw(this)">Megjelenítés</button>
<button type="button" class="btn btn-sm btn-outline" onclick="icCopyPw(this)">Másolás</button>
<span id="initcred-err" class="form-hint" style="display:none;color:var(--red)"></span>
</td>
</tr>
</table>
@@ -212,33 +215,53 @@ function appMigrate(btn,app,label){
{{if .InitialCreds}}
<script>
// Initial-credential password reveal/copy. The value lives in a hidden element (HTML-escaped by the
// template) so it's never inlined into a JS string literal.
function icPwVal() {
var el = document.getElementById('initcred-pw-val');
return el ? el.textContent : '';
// R-254: the password is NOT in this page. It is fetched on demand from the server, which re-reads it
// live from the running container — so this is the only way it reaches a browser, and every fetch is
// recorded server-side. Nothing caches it in a variable between presses: each act asks again.
function icFetchPw() {
return fetch('/apps/{{.Meta.Slug}}/initial-credentials/reveal', {
method: 'POST',
headers: {'X-CSRF-Token': '{{.CSRFToken}}'},
credentials: 'same-origin'
}).then(function (r) { return r.json(); }).then(function (j) {
if (!j.ok) { throw new Error(j.error || 'A kezdeti jelszó beolvasása nem sikerült.'); }
return j.data.password;
});
}
function icErr(msg) {
var e = document.getElementById('initcred-err');
e.textContent = msg;
e.style.display = 'inline';
}
function icRevealPw(btn) {
var code = document.getElementById('initcred-pw');
if (!code) return;
if (code.dataset.shown === '1') {
document.getElementById('initcred-err').style.display = 'none';
if (code.dataset.shown === '1') { // hide: drop the value out of the DOM again
code.textContent = '••••••••••••';
code.dataset.shown = '0';
btn.textContent = 'Megjelenítés';
} else {
code.textContent = icPwVal();
return;
}
btn.disabled = true;
icFetchPw().then(function (pw) {
btn.disabled = false;
code.textContent = pw;
code.dataset.shown = '1';
btn.textContent = 'Elrejtés';
}
}).catch(function (e) { btn.disabled = false; icErr(e.message); });
}
function icCopyPw(btn) {
var val = icPwVal();
if (!val) return;
navigator.clipboard.writeText(val).then(function () {
document.getElementById('initcred-err').style.display = 'none';
btn.disabled = true;
icFetchPw().then(function (pw) {
return navigator.clipboard.writeText(pw).then(function () {
btn.disabled = false;
var orig = btn.textContent;
btn.textContent = 'Másolva';
setTimeout(function () { btn.textContent = orig; }, 1500);
});
}).catch(function (e) { btn.disabled = false; icErr(e.message); });
}
</script>
{{end}}
@@ -169,9 +169,14 @@
<span class="layer-badge">Auto</span>
<span class="tier-location">helyi</span>
{{if .Tier1LastRun}}
{{/* R-258: three states. The tick is THIS app's own most recent dump result,
not the box's most recent run — and a third value renders NO icon, which is
where "we have no result for this backup" belongs. A tick standing for
"a file exists" is what this replaced. */}}
<span class="layer-last">Utolsó: {{timeAgoStr .Tier1LastRun}}
{{if eq .Tier1LastStatus "ok"}}<span class="text-ok"><svg class="ico ico-sm"><use href="#i-check"/></svg></span>
{{else if eq .Tier1LastStatus "error"}}<span class="text-error"><svg class="ico ico-sm"><use href="#i-x"/></svg></span>{{end}}
{{else if eq .Tier1LastStatus "error"}}<span class="text-error"><svg class="ico ico-sm"><use href="#i-x"/></svg></span>
{{else}}<span class="state-text-neutral" title="Erről a mentésről nincs eredményünk."></span>{{end}}
</span>
{{end}}
<span class="tier-contents">{{.BackupContents}}</span>
@@ -8,6 +8,19 @@
{{template "backups_flash" .}}
{{if .RecoveryOffer}}
<!-- R-193: the PERMANENT entry point. Bound to RecoveryOffer, never to the postpone flag — a
customer who clicked "Most nem" once must not lose the route to their own data. -->
<div class="alert alert-warning">
<strong>A korábbi, házon kívüli mentéseid visszaszerezhetők.</strong>
Ezt a gépet újratelepítették, és a Felhom központi rendszere őriz hozzá egy lezárt csomagot. A
helyreállítási kódoddal feloldhatod a korábbi mentéseidet, és megnézheted, mi van bennük.
<div class="form-actions" style="margin-top:.5rem">
<a href="/recovery" class="btn btn-primary">Adatok visszaszerzése</a>
</div>
</div>
{{end}}
{{if not .Backup}}
{{template "backups_empty" .}}
{{else}}
@@ -36,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>
@@ -45,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>{{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}}
@@ -63,10 +95,25 @@
{{if eq .Offbox.RepoState "orphaned"}}
<div class="card" style="border-left:3px solid var(--crit,#e5484d);margin:.75rem 0;padding:.75rem 1rem" id="offbox-orphan-card">
<p style="margin:0 0 .35rem;font-weight:600">A távoli tároló másik kulccsal készült mentéseket tartalmaz</p>
<p class="form-hint" style="margin:0 0 .5rem">A távoli tárhelyen lévő mentések egy korábbi, már nem elérhető kulccsal készültek (jellemzően újratelepítés után). Emiatt új mentés jelenleg nem írható a tárolóba. A meglévő mentések nem sérültek — a hozzájuk tartozó helyreállítási kóddal később visszaállíthatók lehetnek.</p>
{{/* R-299: this paragraph carried the SAME unevaluable promise as the confirm block below, in a
different conjugation („visszaállíthatók lehetnek" vs „visszaállítható lehet") — which is
also why the first regression guard, matching the singular form, did not catch it. The
discriminator is superseded identity_blob on the HUB; the box has no wire field for it. The
two accurate halves are kept: the store IS orphaned, and new backups genuinely cannot be
written. The guard now matches the STEM, so any inflection fails the test. */}}
<p class="form-hint" style="margin:0 0 .5rem">A távoli tárhelyen lévő mentések egy korábbi, már nem elérhető kulccsal készültek (jellemzően újratelepítés után). Emiatt új mentés jelenleg nem írható a tárolóba. A meglévő mentések nem sérültek. Azt viszont ez a gép nem tudja megállapítani, hogy később megnyithatók-e — ez attól függ, megvan-e még a hozzájuk tartozó kulcs. Ha szükséged van rájuk, írj nekünk.</p>
<button type="button" class="btn btn-sm btn-outline" id="orphan-reveal" onclick="var c=document.getElementById('orphan-confirm');c.style.display='block';this.style.display='none'">Új távoli mentés indítása…</button>
<div id="orphan-confirm" style="display:none;margin-top:.6rem">
<p class="form-hint" style="margin:0 0 .5rem">A régi előzmény <strong>félretéve marad</strong> (nem törlődik), és a hozzá tartozó helyreállítási kóddal később visszaállítható lehet. Egy üres, új tároló jön létre a mostani kulccsal, és a következő mentés ide készül.</p>
{{/* R-294 / R-202: this paragraph promised the set-aside copies "may be restorable later
with the recovery code". The discriminator is host_escrow_superseded.identity_blob,
which lives on the HUB; the box caches only HubEscrowIdentityPresent (about the
CURRENT escrow) and no report or ACK field carries superseded-blob retention. So the
renderer cannot evaluate the condition it was stating — and a conditional promise the
system cannot evaluate is the same defect as an unconditional false one. It now
states what it knows, declines what it does not, and names a route.
Copy is verbatim from documentation/design/SPEC-orphan-card-copy-2026-08-10.md §4. */}}
<p class="form-hint" style="margin:0 0 .5rem">A régi előzmény <strong>félretéve marad a tárhelyen — nem töröljük</strong>. Új, üres tárolót hozunk létre, és a következő mentés oda készül.</p>
<p class="form-hint" style="margin:0 0 .5rem"><strong>A félretett mentések megnyithatóságát itt nem tudjuk megígérni.</strong> Ez attól függ, megvan-e még a hozzájuk tartozó kulcs, és ezt ez a gép nem tudja megállapítani. Ha szeretnéd, hogy utánanézzünk, <strong>írj nekünk</strong> — a félretett másolat addig is a helyén marad.</p>
<form method="POST" action="/backup/offbox/reset" style="display:inline">{{.CSRFField}}
<input type="hidden" name="confirm" value="1">
<button type="submit" class="btn btn-sm btn-primary">Megerősítés — új távoli mentés indítása</button>
@@ -101,7 +148,19 @@
<div class="card" style="border-left:3px solid var(--warn);margin:.75rem 0;padding:.75rem 1rem">
<p style="margin:0 0 .35rem"><strong>Helyreállítási kód szükséges</strong></p>
<p class="form-hint" style="margin:0 0 .5rem">A távoli mentések csak akkor állíthatók vissza egy teljes meghibásodás után, ha létrehozza a helyreállítási kódot.</p>
{{if .EscrowAgentOK}}
{{/* §7.3 / Q7 — THE TRAP THAT MUST NOT SURVIVE THIS SESSION. While a recovery is outstanding,
creating a NEW code seals the CURRENT key and demotes the package that opens the earlier
history to retained custody, which no shipped path can read (R-199). It also re-enables
the recovery screen through the orphan route while invalidating the code that screen
accepts. The button is therefore made UNAVAILABLE here rather than merely captioned:
a warning beside a button is a warning people click past. */}}
{{if .RecoveryOffer}}
<p class="form-hint" style="margin:0">
Ehhez a géphez <strong>egy korábbi helyreállítási kód tartozik</strong>, és a korábbi mentéseid még megvannak.
Új kód létrehozása <strong>a régi mentéseidet elérhetetlenné tenné</strong>, ezért most nem indítható.
Előbb <a href="/recovery">add meg a meglévő kódodat</a> — vagy ott jelezheted, ha nem kéred vissza a korábbi adatokat.
</p>
{{else if .EscrowAgentOK}}
<a href="/backup/escrow" class="btn btn-sm btn-primary">Helyreállítási kód létrehozása</a>
{{else}}
<p class="form-hint" style="margin:0">A funkcióhoz az ügynök frissítése szükséges — a frissítés automatikusan megérkezik.</p>
@@ -124,6 +183,27 @@
{{if .EscrowAgentOK}}<a href="/backup/escrow" class="btn btn-sm btn-outline">Új helyreállítási kód készítése</a>{{end}}
</div>
{{end}}
{{if .AbandonActive}}
{{/* R-241: the countdown, stated for the whole window on the page the choice was made from. */}}
<div class="card" style="border-left:3px solid var(--warn);margin:.75rem 0;padding:.75rem 1rem">
<p style="margin:0 0 .35rem"><strong>A korábbi mentések törlése folyamatban</strong></p>
<p class="form-hint" style="margin:0 0 .5rem">
{{/* R-302: the FOURTH instance of the retrieval promise, under a different verb than the
banner's — which is why no earlier guard counted it. Same condition, same single
derivation: fixing the strip above and not this would leave one page contradicting the
other. The deletion and its date are certain and always render. */}}
A kérésed szerint a korábbi távoli mentéseidet <strong>{{.AbandonDate}}</strong> napján véglegesen töröljük
(még <strong>{{.AbandonDaysLeft}} nap</strong>).
{{if .AbandonRetrievalOffered}}Addig meggondolhatod magad: ha megvan a helyreállítási kódod,
a mentéseid visszaszerezhetők, és a törlés elmarad.{{else}}Hogy ezek még visszaszerezhetők-e a helyreállítási kóddal, azt innen nem tudjuk megállapítani — ha vissza szeretnéd kapni őket, <strong>írj nekünk a törlés előtt</strong>.{{end}}
</p>
<a href="/recovery" class="btn btn-sm btn-primary">Mégis visszaszerzem a kóddal</a>
</div>
{{else if .AbandonPurging}}
<div class="card" style="border-left:3px solid var(--border,#2a3142);margin:.75rem 0;padding:.75rem 1rem">
<p class="form-hint" style="margin:0">A korábbi távoli mentéseid törlése megtörtént. A hozzájuk tartozó lezárt helyreállítási csomag eltávolítása még folyamatban van.</p>
</div>
{{end}}
{{if .OffboxConfigured}}
<div class="schedule-actions" style="margin-top:1rem">
<form method="POST" action="/backup/offbox/run" style="display:inline">{{.CSRFField}}
@@ -66,22 +66,70 @@
<div class="backup-section-card">
<h3>Visszaállítás a távoli tárolóból</h3>
<p class="form-hint" style="margin:-0.25rem 0 1rem">Válaszd ki az alkalmazást, és a következő oldalon döntsd el, mit szeretnél: ellenőrzést külön mappába, csak a hiányzó fájlok visszahozását, vagy teljes visszaállítást. Egyik sem indul el kérdés nélkül.</p>
{{if .OffboxToggledCount}}
<!-- R-237: driven by what is IN THE STORE, not by what is deployed and toggled. A rebuilt box has
neither and used to be told there was nothing to restore, while its snapshots sat in the
repository the whole time. Installed-ness is a property OF a row, never a filter on it. -->
<!-- R-252: the precondition a rebuilt box fails, said BEFORE the customer presses a button that
would refuse. Rendered only when it is true — a healthy box sees nothing new here. -->
<!-- R-280: the „két kattintás" promise is conditional on the picker it points at being non-empty.
It was printed unconditionally, and on a rebuilt box the picker had nothing in it — the
sentence sent the customer to an empty page and the wall had no way past it. The false branch
says what is true and names a route, rather than promising a click that does not exist. -->
{{if .NoRestoreDestination}}
<p class="form-hint" style="border-left:2px solid var(--amber);padding-left:.75rem">
<strong>Előbb csatold vissza az adatmeghajtót.</strong> A mentéseid megvannak, és a meghajtók is
megvannak — újratelepítés után viszont a gép még nem ismeri őket, ezért most nincs hová
visszaállítani.
{{if .HasAttachDestination}}
Ez két kattintás: <a href="/storage" style="color:var(--blue)">Tárhely →
Meghajtók</a>, „Meglévő meghajtó csatolása". Utána gyere vissza ide.
{{else}}
Csatolható meghajtót viszont most nem látunk ezen a gépen, ezért ezt innen nem tudod
elindítani. Ha a meghajtó be van kötve, de nincs csatlakoztatva, az üzemeltető tudja
csatlakoztatni — jelezd neki. A <a href="/storage" style="color:var(--blue)">Tárhely →
Meghajtók</a> oldalon látod, mit ismer most a gép.
{{end}}
</p>
{{end}}
{{if eq .OffsiteStoreState "unreadable"}}
<p class="form-hint">Nem tudjuk elolvasni a távoli tárolót, ezért <strong>nem tudjuk, mi van benne</strong>. Ez nem azt jelenti, hogy üres — próbáld újra később, vagy jelezd az üzemeltetőnek.</p>
{{else if eq .OffsiteStoreState "no-target"}}
<p class="form-hint">A távoli tároló kapcsolódási adatai még nem érkeztek meg ehhez a géphez, ezért még nem tudjuk megmutatni, mi van benne. Ez magától rendeződik.</p>
{{end}}
{{if .OffsiteRestoreRows}}
<div class="app-row-list">
{{range .OffboxApps}}
{{if .Enabled}}
{{range .OffsiteRestoreRows}}
{{template "app_list_row" dict "Slug" .Slug "Name" .DisplayName}}
{{if .Restorable}}
<!-- R-253: this row used to promise that the restore would reinstall the app first.
It cannot: reconstitution writes to the app's OWN data path (GetStackHDDPath),
which exists only once the customer has chosen a drive during deploy — the
restore has no answer to that question and must not invent one. The copy now
says what the handler does and routes to the place that does it. (The old
sentence is deliberately NOT quoted here: an HTML comment ships in the response
body, so quoting it would keep the contradiction on the page and would make the
test that forbids it unfailable.) -->
{{if and .InStore (not .Installed)}}
<span class="form-hint" style="margin-right:.5rem">Nincs telepítve — előbb <a href="/stacks/{{.App}}/deploy" style="color:var(--blue)">telepítsd újra</a>, utána hozhatod vissza az adatait.</span>
{{end}}
{{if .StoreUnknown}}
<span class="form-hint" style="margin-right:.5rem">Nem tudjuk, van-e mentése — a tárolót nem sikerült elolvasni.</span>
{{end}}
<!-- R-48: ONE entry per app. The five inline forms that used to live here — verify,
prepare, the revealed commit, the missing-only merge and the true reconstitution —
were separable only by layout, and two of them differed by whether the customer's
data comes back at all. They are now described intents inside the wizard. -->
<a href="/backups/restore/app?name={{.Name}}" class="btn btn-xs btn-outline">Visszaállítás…</a>
<a href="/backups/restore/app?name={{.App}}" class="btn btn-xs btn-outline">Visszaállítás…</a>
{{else}}
<!-- Shown, not hidden: "installed but nothing in the store" is an answer. Silently
dropping the row is what made R-220's empty list unreadable, one screen over. -->
<span class="form-hint">Nincs mentése a távoli tárolóban — nincs mit visszaállítani.</span>
{{end}}
{{template "app_list_row_end"}}
{{end}}
{{end}}
</div>
{{else}}
<p class="form-hint">Nincs távoli mentésre jelölt alkalmazás — a kijelölés a <a href="/backups/remote">Távoli mentés</a> oldalon történik.</p>
{{else if eq .OffsiteStoreState "known"}}
<p class="form-hint">A távoli tároló üres — nincs mit visszaállítani.</p>
{{end}}
<!-- R-7b: the shares source. Not an app row — it has no per-app toggle and no recovery unit —
+12 -3
View File
@@ -16,14 +16,23 @@
{{if .Flash}}<div class="alert alert-info">{{.Flash}}</div>{{end}}
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
{{/* R-295 — ONE NAME PER SECRET. This page used to call the SAME three-word code „Beállító
kód" on the first-time branch and „Visszaállító kód" on the reset branch, while the
TEN-word code that opens the sealed backups is called „Helyreállítási kód" elsewhere in
this UI. Two of those three names are near-homographs, and the collision cost a real code.
The ruling: the code that gives a person control of the DASHBOARD is „Beállító kód"
everywhere — the name this page already showed where it is typed — and where one secret
serves two situations the NAME stays constant and the SENTENCE changes. „Visszaállító
kód" is retired. This is naming only: no acceptance logic moved, and a test pins that a
reset code is still accepted here. */}}
{{if .HasCode}}
<p style="font-size:0.85rem;color:var(--text-muted,#8a94a6);margin:0 0 1rem">
{{if .IsReset}}Add meg az e-mailben kapott visszaállító kódot, majd válassz új jelszót.{{else}}Add meg az e-mailben kapott beállító kódot, majd válassz saját jelszót a vezérlőpult védelméhez.{{end}}
{{if .IsReset}}Add meg az e-mailben kapott beállító kódot, majd válassz új jelszót.{{else}}Add meg az e-mailben kapott beállító kódot, majd válassz saját jelszót a vezérlőpult védelméhez.{{end}}
</p>
<form method="POST" action="/claim">
<input type="hidden" name="_csrf" value="{{.ClaimCSRF}}">
<div class="form-group">
<label for="code">{{if .IsReset}}Visszaállító kód{{else}}Beállító kód{{end}}</label>
<label for="code">Beállító kód</label>
<input type="text" id="code" name="code" required autofocus autocomplete="off"
placeholder="szó-szó-szó" class="form-control">
</div>
@@ -45,7 +54,7 @@
<form method="POST" action="/claim/request-new-code" style="margin-top:1rem">
<input type="hidden" name="_csrf" value="{{.ClaimCSRF}}">
<button type="submit" class="btn btn-outline btn-full">{{if .IsReset}}Visszaállító kód kérése{{else}}Nem kaptad meg a kódot? Új kód kérése{{end}}</button>
<button type="submit" class="btn btn-outline btn-full">{{if .IsReset}}Új beállító kód kérése{{else}}Nem kaptad meg a kódot? Új kód kérése{{end}}</button>
</form>
<p class="login-footer">Felhom — Otthoni szerver kezelés<br>
@@ -53,6 +53,21 @@
{{end}}
</div>
<div class="system-info-items" style="margin-top: 1rem;">
{{/* R-259: the figures and the bar render ONLY when the measurement succeeded. A statfs
failure used to leave every number at zero, and usageColor(0) is "nominal" — so a disk
we could not read was drawn as a healthy empty one. Same reasoning as the off-site
card: a 0%-wide bar over an unmeasured store is a picture of emptiness, and a picture
is a claim. */}}
{{if not .SystemInfo.DiskKnown}}
<div class="system-info-item meter meter-empty">
<div class="system-info-header">
<span class="system-info-label">Rendszer (/)</span>
<span class="system-info-value"><span class="state-text-neutral">nem ismert</span></span>
</div>
<div class="meter-track"></div>
<div class="meter-flag">A tárhely mérete most nem olvasható ki.</div>
</div>
{{else}}
{{$duc := usageColor .SystemInfo.DiskPercent}}
<div class="system-info-item meter {{$duc}}">
<div class="system-info-header">
@@ -64,6 +79,7 @@
</div>
{{if eq $duc "warn"}}<div class="meter-flag"><svg class="ico"><use href="#i-triangle-alert"/></svg>Fogyóban a hely</div>{{else if eq $duc "crit"}}<div class="meter-flag"><svg class="ico"><use href="#i-triangle-alert"/></svg>Kritikusan kevés hely</div>{{end}}
</div>
{{end}}
{{range .StorageBars}}
{{if .Disconnected}}
<div class="system-info-item meter meter-empty storage-disconnected">
@@ -471,6 +471,7 @@
<p class="form-section-desc">Ezek az értékek a telepítéssel együtt mentésre kerülnek. Jegyezze fel a szükséges jelszavakat!</p>
{{end}}
{{$autoValues := .AutoFieldValues}}
{{$stackName := .Stack.Name}}
{{$isDeployed := .AlreadyDeployed}}
{{range .AutoFields}}
{{$val := index $autoValues .EnvVar}}
@@ -478,10 +479,21 @@
<label>{{.Label}} {{if eq .Type "secret"}}<span class="auto-generated-badge"><svg class="ico ico-sm"><use href="#i-check"/></svg> Automatikusan generálva</span>{{end}}</label>
{{if $val}}
{{if eq .Type "secret"}}
{{if $isDeployed}}
<!-- R-254 site two: on an ALREADY-DEPLOYED app nothing is being submitted, so there is
no reason for the value to be in this page at all. It is fetched on demand. The
pre-deploy branch below is different and deliberate — the form must carry what it
submits, so the saved value is the one the customer was shown (README §318). -->
<div class="input-with-button">
<input type="password" id="auto-field-{{.EnvVar}}" class="form-control" value="" placeholder="••••••••••••" readonly>
<button type="button" class="btn btn-sm btn-outline" onclick="revealAutoField('{{$stackName}}','{{.EnvVar}}', this)">Megjelenítés</button>
</div>
{{else}}
<div class="input-with-button">
<input type="password" id="auto-field-{{.EnvVar}}" class="form-control" value="{{$val}}" readonly>
<button type="button" class="btn btn-sm btn-outline" onclick="toggleAutoField('auto-field-{{.EnvVar}}', this)">Megjelenítés</button>
</div>
{{end}}
{{else}}
<input type="text" id="auto-field-{{.EnvVar}}" class="form-control" value="{{$val}}" readonly>
{{end}}
@@ -743,6 +755,33 @@ document.addEventListener('DOMContentLoaded', function() {
if (sel) checkStorageSpace(sel);
});
// R-254 site two: on an already-deployed app the value is NOT in this page — it is fetched on
// demand, and the server records the act. Nothing caches it between presses.
function revealAutoField(stackName, envVar, btn) {
var el = document.getElementById('auto-field-' + envVar);
if (!el) return;
if (btn.dataset.shown === '1') {
el.value = '';
el.type = 'password';
btn.dataset.shown = '';
btn.textContent = 'Megjelenítés';
return;
}
btn.disabled = true;
fetch('/stacks/' + encodeURIComponent(stackName) + '/auto-field/reveal', {
method: 'POST',
headers: Object.assign({'Content-Type': 'application/x-www-form-urlencoded'}, csrfHeaders()),
credentials: 'same-origin',
body: 'env_var=' + encodeURIComponent(envVar)
}).then(function (r) { return r.json(); }).then(function (j) {
btn.disabled = false;
if (!j.ok) { showAlert(j.error || 'A lekérés nem sikerült.'); return; }
el.value = j.data.value;
el.type = 'text';
btn.dataset.shown = '1';
btn.textContent = 'Elrejtés';
}).catch(function () { btn.disabled = false; showAlert('A lekérés nem sikerült.'); });
}
function toggleAutoField(fieldId, btn) {
var el = document.getElementById(fieldId);
if (!el) return;
@@ -130,6 +130,50 @@
</div>
</div>
{{end}}
{{/* R-241 (v0.206.0) — the recovery reminder bar. Rendered while the situation holds, the customer
has not opted out of reminders for THIS situation, and they have not clicked it away this visit.
It NEVER replaces the entry point on the Távoli mentés page — silencing a reminder is not the
same as removing the route. */}}
{{if .RecoveryBanner}}
<div class="alerts-container">
<div class="alert-banner alert-banner-warning">
<span class="alert-icon"><svg class="ico"><use href="#i-triangle-alert"/></svg></span>
<span class="alert-message">
{{if .RecoveryAbandonDays}}
{{/* R-302: the DELETION and its date are certain and always render. The RETRIEVAL clause is
conditional on the hub still holding the same sealed package it held when the customer
decided — pinned then, compared now. It rendered unconditionally, and was false on a
reachable state: a fresh escrow ceremony during the window replaces the package, which
is the exact act that cost both demo boxes their history on 2026-08-04. A countdown
started before this shipped carries no pin and takes the cautious branch. */}}
A korábbi távoli mentéseidet <strong>{{.RecoveryAbandonDays}} nap múlva</strong> ({{.RecoveryAbandonDate}}) véglegesen töröljük, a kérésed szerint.
{{if .RecoveryAbandonRetrievalOffered}}Addig még visszaszerezheted őket a helyreállítási kóddal.{{else}}Hogy ezek még visszaszerezhetők-e a helyreállítási kóddal, azt innen nem tudjuk megállapítani — ha vissza szeretnéd kapni őket, <strong>írj nekünk a törlés előtt</strong>.{{end}}
{{else if ge .RecoveryReminderTier 14}}
<strong>Két hete</strong> várnak rád a korábbi távoli mentéseid, és még nem adtad meg a helyreállítási kódodat. Amíg nem teszed, ezekhez a mentésekhez nem férsz hozzá.
{{else if ge .RecoveryReminderTier 7}}
Már <strong>egy hete</strong> megvannak a korábbi távoli mentéseid, de a helyreállítási kódod nélkül nem tudjuk megnyitni őket.
{{else if ge .RecoveryReminderTier 3}}
A korábbi távoli mentéseid megvannak — a megnyitásukhoz a helyreállítási kódod szükséges.
{{else}}
A korábbi távoli mentéseid megvannak, de ehhez a géphez a helyreállítási kódod szükséges.
{{end}}
<a href="/recovery">Megnézem</a>
</span>
<span class="alert-actions">
<form method="POST" action="/recovery/banner/dismiss" style="display:inline">
{{.CSRFField}}<input type="hidden" name="back" value="{{.RecoveryBannerBack}}">
<button type="submit" class="btn btn-sm btn-outline">Most nem</button>
</form>
{{if not .RecoveryAbandonDays}}
<form method="POST" action="/recovery/remind-optout" style="display:inline">
{{.CSRFField}}<input type="hidden" name="back" value="{{.RecoveryBannerBack}}">
<button type="submit" class="btn btn-sm btn-outline">Ne emlékeztessen újra</button>
</form>
{{end}}
</span>
</div>
</div>
{{end}}
{{if .Alerts}}
<div class="alerts-container">
{{range .Alerts}}
@@ -0,0 +1,210 @@
{{define "recovery"}}
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<title>Adatok visszaszerzése — Felhom</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="login-body">
<div class="login-card" style="max-width:46rem">
<img src="/static/felhom-logo.svg" alt="Felhom.eu" class="login-logo">
{{if .Unlocked}}
<!-- ── AFTER THE UNLOCK: what is in there. Read-only — nothing was restored. ────────────── -->
<h1 class="login-title">A mentéseid <span class="title-accent">elérhetők</span></h1>
{{if .Flash}}<div class="alert alert-info">{{.Flash}}</div>{{end}}
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
{{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
mentések, keresd a Felhom ügyfélszolgálatát, mielőtt bármit tennél.
</div>
{{else if .InvUntagged}}
<div class="alert alert-warning">
A tároló megnyílt, és van benne tartalom, de nem tudtuk alkalmazásokhoz rendelni. A Biztonsági
mentés oldalon nézheted meg részletesen.
</div>
{{else}}
<p class="login-subtitle" style="margin-bottom:1rem">
Ezek a <strong>te</strong> mentéseid, a lent jelzett időpontokból. Nézd át, hogy tényleg azt
találod-e itt, amire számítasz — <strong>semmit nem állítottunk vissza és semmi nem változott.</strong>
</p>
<table class="data-table" style="width:100%;margin-bottom:1rem">
<thead><tr><th>Alkalmazás</th><th>Legutóbbi mentés</th><th>Méret</th></tr></thead>
<tbody>
{{range .InvApps}}
<tr>
<td>{{.App}}</td>
<td>{{fmtTime .LatestAt}}</td>
<td>{{if gt .SizeBytes 0}}{{humanBytes .SizeBytes}}{{else}}—{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
<p class="form-hint">
A visszaállítás alkalmazásonként történik, a <strong>Biztonsági mentés → Visszaállítás</strong>
oldalon. Ott választhatod ki, melyik alkalmazás mit hozzon vissza.
</p>
<div class="form-actions">
<a href="/backups/restore" class="btn btn-primary">Tovább a visszaállításhoz</a>
<a href="/launcher" class="btn btn-outline">Vissza a kezdőlapra</a>
</div>
{{else}}
<!-- ── BEFORE ANY CODE: explain, then take the code. ───────────────────────────────────── -->
<h1 class="login-title">Adatok <span class="title-accent">visszaszerzése</span></h1>
<p class="login-subtitle">{{.CustomerName}}</p>
{{if .Flash}}<div class="alert alert-info">{{.Flash}}</div>{{end}}
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
<p>
Ezt a gépet újratelepítették. A korábbi, <strong>házon kívüli mentéseid megvannak</strong> — a
Felhom központi rendszere őriz hozzájuk egy lezárt csomagot{{with .SealedAt}}, amelyet
<strong>{{.}}</strong> zártunk le{{end}}. A csomagot csak a <strong>te helyreállítási
kódoddal</strong> lehet kinyitni.
</p>
<div class="alert alert-warning">
<strong>A helyreállítási kódot senki nem tudja pótolni</strong> — sem a Felhom, sem az
ügyfélszolgálat, sem az üzemeltető. Ez szándékos: így a mentéseidet rajtad kívül senki nem
tudja megnyitni. Ha a kód elveszett, a korábbi mentések nem nyithatók meg többé.
</div>
<p>
Ha megadod a kódot, <strong>feloldjuk a mentéseid zárolását és megmutatjuk, mi van bennük</strong>
— melyik alkalmazás, mikorról, mekkora. <strong>Ebben a lépésben semmit nem állítunk vissza és
semmi nem változik.</strong> A visszaállítást utána, alkalmazásonként külön választhatod.
</p>
<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"
autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false"
placeholder="tíz szó, szóközökkel elválasztva" required>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Mentések feloldása</button>
<form method="POST" action="/recovery/postpone" style="display:inline">
{{.CSRFField}}
<button type="submit" class="btn btn-outline">Most nem</button>
</form>
</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
bármikor újra elérhető.
</p>
<!-- ── THE EXCEPTIONAL PATH. Deliberately not an equal third button. ───────────────────── -->
<hr style="margin:1.5rem 0;border:none;border-top:1px solid var(--border,#2a3142)">
{{if .ConfirmSetAside}}
{{/* §7.3 / §2.4 — THE COPY CHANGES WITH THE BEHAVIOUR (v0.206.0, R-241).
It used to promise "félretesszük — nem töröljük". After this change the set-aside history IS
deleted, on a date, together with the sealed package that protects it — which is what lets
the question end instead of returning at every login. A confirmation that still said "we do
not delete" would be the most consequential false sentence on the whole surface. */}}
<div class="alert alert-error">
<p><strong>Biztosan nem kéred vissza a korábbi mentéseket?</strong></p>
<p>Ha megerősíted:</p>
<ul>
<li>a korábbi mentéseket <strong>most félretesszük</strong>, és <strong>{{.AbandonGraceDays}} nap múlva véglegesen töröljük</strong> — a lezárt helyreállítási csomaggal együtt;</li>
<li>a {{.AbandonGraceDays}} nap alatt <strong>meggondolhatod magad</strong>: ha előkerül a helyreállítási kódod, a mentéseid visszaszerezhetők, és a törlés elmarad;</li>
<li>a pontos dátumot a <strong>Távoli mentés</strong> oldalon végig látni fogod, és emlékeztetni is fogunk;</li>
<li>a gép <strong>új, üres mentési tárolót kezd</strong>, és mostantól oda ment;</li>
<li>a törlés után <strong>ez a kérdés nem jön vissza többé</strong> — mert nem marad mit visszaszerezni.</li>
</ul>
<p>Ha csak most nincs kéznél a kódod, válaszd inkább a „Most nem” lehetőséget — az semmit nem indít el.</p>
</div>
<div class="form-actions">
<form method="POST" action="/backup/offbox/reset">
{{.CSRFField}}
<input type="hidden" name="confirm" value="1">
<button type="submit" class="btn btn-danger">Igen, félretehetitek a korábbi mentéseket</button>
</form>
<a href="/recovery" class="btn btn-outline">Mégsem</a>
</div>
{{else}}
<p class="form-hint">
Ha a helyreállítási kódod véglegesen elveszett, és tudomásul veszed, hogy a korábbi mentések
így nem nyithatók meg többé:
{{if .CanSetAside}}
<a href="/recovery?setaside=1">nem kérem vissza a korábbi adatokat</a>.
{{else}}
ez a lehetőség akkor válik elérhetővé, ha a gép már újra kapcsolódott a házon kívüli
tárhelyhez. Addig a mentéseid érintetlenül megmaradnak.
{{end}}
</p>
{{end}}
{{end}}
</div>
</body>
</html>
{{end}}
@@ -405,7 +405,7 @@ function openDialog(opts){
</script>
<!-- Section: Recovery Info -->
{{if .RetrievalPassword}}
{{if .HasRetrievalPassword}}
<div class="settings-card">
<h3>Vészhelyzeti információk</h3>
<p class="settings-card-desc">
@@ -422,14 +422,13 @@ function openDialog(opts){
</div>
<div class="settings-row">
<span class="settings-label">Visszaállítási jelszó</span>
<!-- R-249: the value is NOT in this page. The old markup rendered it into a
display:none span, which hid it from the eye and from nothing else — a fetch of the
page returned the plaintext. „Megjelenít" now asks the server for it. -->
<span class="settings-value">
<span id="retrieval-pw-hidden">••••••••••••••••
<button type="button" class="btn btn-xs btn-outline" onclick="document.getElementById('retrieval-pw-hidden').style.display='none';document.getElementById('retrieval-pw-visible').style.display='inline';">Megjelenít</button>
</span>
<span id="retrieval-pw-visible" style="display:none">
<code class="mono">{{.RetrievalPassword}}</code>
<button type="button" class="btn btn-xs btn-outline" onclick="document.getElementById('retrieval-pw-visible').style.display='none';document.getElementById('retrieval-pw-hidden').style.display='inline';">Elrejt</button>
</span>
<span id="retrieval-pw-slot" class="mono">••••••••••••••••</span>
<button type="button" id="retrieval-pw-btn" class="btn btn-xs btn-outline" onclick="revealRetrievalPw()">Megjelenít</button>
<span id="retrieval-pw-err" class="form-hint" style="display:none;color:var(--red)"></span>
</span>
</div>
<div class="settings-row">
@@ -442,6 +441,42 @@ function openDialog(opts){
</div>
</div>
</div>
<script>
// R-249: fetch the passphrase on demand. The page itself never contains it, so this is the only
// way it reaches a browser — and it takes a live session plus the CSRF token bound to it.
function revealRetrievalPw() {
var slot = document.getElementById('retrieval-pw-slot');
var btn = document.getElementById('retrieval-pw-btn');
var err = document.getElementById('retrieval-pw-err');
err.style.display = 'none';
if (btn.dataset.shown === '1') { // „Elrejt" — drop the value out of the DOM again
slot.textContent = '••••••••••••••••';
btn.textContent = 'Megjelenít';
btn.dataset.shown = '';
return;
}
btn.disabled = true;
fetch('/settings/retrieval-password/reveal', {
method: 'POST',
headers: {'X-CSRF-Token': '{{.CSRFToken}}'},
credentials: 'same-origin'
}).then(function (r) { return r.json(); }).then(function (j) {
btn.disabled = false;
if (!j.ok) {
err.textContent = j.error || 'A visszaállítási jelszó lekérése nem sikerült.';
err.style.display = 'inline';
return;
}
slot.textContent = j.data.password;
btn.textContent = 'Elrejt';
btn.dataset.shown = '1';
}).catch(function () {
btn.disabled = false;
err.textContent = 'A visszaállítási jelszó lekérése nem sikerült.';
err.style.display = 'inline';
});
}
</script>
{{end}}
{{template "layout_end" .}}
{{end}}
@@ -23,7 +23,9 @@
<label>Kiválasztott eszköz</label>
<span class="settings-value mono" id="sel-device"></span>
</div>
<div class="form-group">
<!-- R-280: an already-mounted store has no mount name to choose — it is already at its path,
and the action is to register that path. The group is hidden for those. -->
<div class="form-group" id="mount-name-group">
<label for="mount-name">Csatlakoztatási név <span class="required">*</span></label>
<div style="display:flex;align-items:center;gap:.25rem">
<span class="mono" style="opacity:.6">/mnt/</span>
@@ -32,6 +34,10 @@
</div>
<span class="form-hint">A meghajtó a /mnt/&lt;név&gt; útvonalra kerül.</span>
</div>
<p class="form-hint" id="mounted-note" style="display:none">
Ez a meghajtó már csatlakoztatva van, csak a gép nem tartja nyilván. A „Csatolás" a
meglévő helyén veszi nyilvántartásba — <strong>semmi nem törlődik</strong>.
</p>
<div class="form-group">
<label for="storage-label">Megnevezés</label>
<input type="text" id="storage-label" class="form-control" placeholder="Külső HDD 1TB" maxlength="50">
@@ -49,7 +55,7 @@
</div>
<script>
var selDevice = "", selFSType = "";
var selDevice = "", selFSType = "", selMounted = false;
function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c];}); }
function fmtSize(b){
if(!b) return '';
@@ -74,11 +80,15 @@ async function loadDisks(){
if(d.fstype) parts.push(esc(d.fstype));
var sub = parts.join(' · ');
// Attach the FS-bearing node (mount_source, e.g. /dev/sdd1); the agent resolves its UUID.
// R-280: for an already-mounted store mount_source is its MOUNTPOINT (/mnt/sys_drive) and the
// action is register-in-place, so the card is labelled by the path the customer will see.
var dev = d.mount_source || d.device;
var mounted = !!d.already_mounted;
if(mounted){ title = esc(d.mount_source); sub = [esc(d.device), esc(d.fstype)].filter(Boolean).join(' · '); }
html+='<label class="drive-card role-user-data is-selectable" id="dc-'+i+'">'
+'<div class="drive-card-top"><div class="drive-select"><input type="radio" name="disk" value="'+esc(dev)+'" data-fs="'+esc(d.fstype)+'" data-i="'+i+'" onchange="pickDisk(this)">'
+'<div class="drive-card-top"><div class="drive-select"><input type="radio" name="disk" value="'+esc(dev)+'" data-fs="'+esc(d.fstype)+'" data-i="'+i+'" data-mounted="'+(mounted?'1':'')+'" onchange="pickDisk(this)">'
+'<div class="drive-id"><span class="drive-name">'+title+'</span><span class="drive-sub">'+sub+'</span></div></div>'
+'<div class="drive-badges"><span class="badge badge-ok">Fájlrendszer: '+esc(d.fstype)+'</span></div></div></label>';
+'<div class="drive-badges">'+(mounted?'<span class="badge badge-ok">Már csatlakoztatva</span>':'<span class="badge badge-ok">Fájlrendszer: '+esc(d.fstype)+'</span>')+'</div></div></label>';
});
html+='</div>';
document.getElementById('disk-list').innerHTML=html;
@@ -87,7 +97,14 @@ async function loadDisks(){
function pickDisk(radio){
selDevice=radio.value; selFSType=radio.getAttribute('data-fs')||"";
selMounted=!!radio.getAttribute('data-mounted');
document.getElementById('sel-device').textContent=selDevice;
// An already-mounted store keeps its own path; there is no name to pick. Dropping `required` too,
// or the hidden empty field blocks form submission with no visible cause.
var mng=document.getElementById('mount-name-group'), mni=document.getElementById('mount-name');
mng.style.display = selMounted ? 'none' : '';
mni.required = !selMounted;
document.getElementById('mounted-note').style.display = selMounted ? '' : 'none';
document.querySelectorAll('.drive-card').forEach(function(c){c.classList.remove('is-picked');});
var card=document.getElementById('dc-'+radio.getAttribute('data-i')); if(card) card.classList.add('is-picked');
document.getElementById('cfg-card').style.display='block';
@@ -99,12 +116,22 @@ async function submitAttach(ev){
var btn=document.getElementById('attach-btn'); var out=document.getElementById('attach-result');
btn.disabled=true; out.innerHTML='<p class="form-hint">Csatlakoztatás folyamatban…</p>';
try{
var body={device:selDevice, fstype:selFSType, mount_name:document.getElementById('mount-name').value,
// R-280: an already-mounted store is REGISTERED in place. Sending it to /api/storage/attach would
// ask the agent to mount an in-guest path as if it were a raw device.
var url, body;
if(selMounted){
url='/api/storage/register-mounted';
body={path:selDevice, label:document.getElementById('storage-label').value,
set_default:document.getElementById('set-default').checked};
}else{
url='/api/storage/attach';
body={device:selDevice, fstype:selFSType, mount_name:document.getElementById('mount-name').value,
label:document.getElementById('storage-label').value, set_default:document.getElementById('set-default').checked};
var r=await fetch('/api/storage/attach',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify(body)});
}
var r=await fetch(url,{method:'POST',headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify(body)});
var j=await r.json();
if(!j.ok){ throw new Error(j.error||'Hiba'); }
out.innerHTML='<div class="alert alert-success">A meghajtó sikeresen csatolva és regisztrálva: <strong class="mono">'+(j.data.where||'')+'</strong>. <a href="/settings">Vissza a Beállításokhoz →</a></div>';
out.innerHTML='<div class="alert alert-success">A meghajtó sikeresen '+(selMounted?'nyilvántartásba véve':'csatolva és regisztrálva')+': <strong class="mono">'+(j.data.where||'')+'</strong>. <a href="/settings">Vissza a Beállításokhoz →</a></div>';
}catch(e){ out.innerHTML='<div class="alert alert-error">Hiba: '+e.message+'</div>'; btn.disabled=false; }
return false;
}
+5
View File
@@ -45,6 +45,8 @@ SCRIPTS = os.path.dirname(os.path.abspath(__file__))
CTRL = os.path.dirname(SCRIPTS) # <repo>/controller — every gate's cwd
REPO = os.path.dirname(CTRL) # <repo> — the root REUSE.md lives here
SHARED_REUSE = os.path.join(os.path.dirname(REPO), "felhom.eu", "scripts", "reuse_refs_check.py")
SHARED_INSTRUCTIONS = os.path.join(
os.path.dirname(REPO), "felhom.eu", "scripts", "instructions_gate.py")
# (label, absolute script path, args, fast)
GATES = [
@@ -55,7 +57,10 @@ GATES = [
("app-row-dedup", os.path.join(SCRIPTS, "app_row_dedup_gate.py"), [], True),
("mojibake", os.path.join(SCRIPTS, "mojibake_gate.py"), [], True),
("docker-v", os.path.join(SCRIPTS, "docker_run_volume_path_gate.py"), [], True),
("secret-markup", os.path.join(SCRIPTS, "secret_in_markup_gate.py"), [], True),
("retrieval-promise", os.path.join(SCRIPTS, "retrieval_promise_gate.py"), [], True),
("reuse-refs", SHARED_REUSE, [REPO], True),
("instructions", SHARED_INSTRUCTIONS, [REPO], True),
]
VERDICT = {0: "OK", 1: "FAILED", 2: "INCONCLUSIVE"}
@@ -0,0 +1,152 @@
#!/usr/bin/env python3
"""retrieval_promise_gate — pin the CLAIM, not the word (R-302).
WHY THIS IS NOT A STRING BAN. Five instances of "you can get your old backups back with your recovery
code" have surfaced ONE AT A TIME (R-294, R-299, and the two R-302 fixed this session), each found only
after the previous one was fixed. The obvious guard forbid the sentence was tried twice and failed
twice:
* v0.211.0 asserted the SINGULAR visszaállítható lehet"; the card carried the PLURAL
visszaállíthatók lehetnek" one paragraph above it and walked straight past (R-299).
* Broadening to the stem `visszaállíthat` then missed the banner entirely, because the banner says
visszaszerezheted" — a different verb for the same claim.
AND THE STEM CANNOT BE BANNED. The honest replacement copy this session ships *contains the stem*:
Hogy ezek még visszaszerezhetők-e azt innen nem tudjuk megállapítani" is a QUESTION about
retrievability, and it is the correct sentence. A guard that forbade the stem would force the product
to avoid a normal Hungarian verb a guard shaping the product around itself.
SO: every occurrence of a retrieval stem in a customer-facing template must be REGISTERED here with a
reason. Unregistered occurrences fail. The failure mode this actually catches is the real one a new
claim appearing somewhere nobody was looking without pretending a word is a claim.
Go template comments ({{/* */}}) are stripped before scanning: html/template never renders them, so
prose explaining a fix is not a claim. HTML <!-- --> comments DO ship and are deliberately scanned.
"""
import os
import re
import sys
_HERE = os.path.dirname(os.path.abspath(__file__))
TEMPLATES = os.path.join(_HERE, "..", "internal", "web", "templates")
# R-311 — THE GATE HAD A BLIND SPOT THE SIZE OF THE RECOVERY SCREEN.
#
# It scanned `internal/web/templates` only. But every one of the recovery screen's messages is a Go
# STRING in a handler, not template text — including the four R-224 messages and the R-222/R-226 one,
# i.e. the highest-stakes customer copy in the product, on the one screen whose whole purpose is to be
# believed about someone's backups. None of it had ever been scanned.
#
# Go `//` and `/* */` comments are stripped for the same reason template comments are: they never
# reach a customer. (A `//` inside a Hungarian string literal would be stripped too — there are none,
# and a false NEGATIVE there is the safe direction for a guard that convicts on presence.)
GO_SOURCES = [
os.path.join(_HERE, "..", "internal", "web", "recovery_handlers.py".replace(".py", ".go")),
]
# The verbs that carry the claim "your old backups can be got back".
# R-311 adds `visszanyit`: the honest new message says a customer needs support's help „a régebbi
# előzményed visszanyitásához". That is the SAME claim in a fourth verb, and the docstring above
# records what happens when the guard chases words instead of claims — it misses the next one.
STEMS = ["visszaállíthat", "visszaszerezhet", "visszahozhat", "visszanyit"]
# (template, substring that identifies the occurrence) -> why it is allowed.
# The substring must be specific enough that a DIFFERENT claim in the same file does not match it.
ALLOWLIST = {
("backups.html", "amelyből az egész készülék visszaállítható"):
"the LOCAL whole-device backup, made and held by the host agent. Nothing to do with the "
"off-site escrow claim — no recovery code is involved.",
("backups_apps.html", "alkalmazásonként visszaállítható"):
"per-app restore from the local app-data backup. Same: local, no recovery code.",
("backups_remote.html", "mentéseid visszaszerezhetők.</strong>"):
"the RecoveryOffer entry point. TRUE where it renders: it is gated on the hub telling this box "
"it holds a sealed package for it, which is the claim being made. Deliberately left alone.",
("backups_remote.html", "a mentéseid visszaszerezhetők, és a törlés elmarad."):
"the abandon block's promise — R-302 made it conditional on AbandonRetrievalOffered; this is "
"the TRUE branch.",
("backups_remote.html", "Hogy ezek még visszaszerezhetők-e"):
"R-302's cautious branch. Contains the stem inside a QUESTION about knowability — the sentence "
"the gate exists to protect, not to forbid.",
("layout.html", "Addig még visszaszerezheted őket a helyreállítási kóddal."):
"the banner's promise — R-302 made it conditional on RecoveryAbandonRetrievalOffered; TRUE branch.",
("layout.html", "Hogy ezek még visszaszerezhetők-e"):
"R-302's cautious branch on the banner. As above.",
("recovery_handlers.go", "A régi előzmény visszanyitása felülírná azt"):
"PRE-EXISTING and never scanned until R-311 extended this gate to Go handlers — which is the "
"point of extending it. It is NOT a promise: it is the reason for a REFUSAL (RecoverRefused, "
"a different repository password is already present), i.e. the sentence says the reopening "
"would overwrite and was therefore not done. Registered as an explanation, not a claim.",
("recovery_handlers.go", "A régebbi előzményed visszanyitásához a Felhom ügyfélszolgálatának segítsége kell"):
"R-311's truthful message for a code that opens a RETAINED package. It is a claim, and it is "
"TRUE: the drill of 2026-08-12 recovered exactly this by hand (unsealed the retained package, "
"opened the set-aside store, restored planted files byte-identical). It routes to SUPPORT "
"rather than to a button precisely because there is no in-product route yet — the restore "
"machinery resolves its repository from settings and its password from one file. If that route "
"is ever built, this entry changes; if support ever cannot do it, this sentence must go.",
("recovery.html", "a mentéseid visszaszerezhetők, és a törlés elmarad;"):
"the abandon CONFIRMATION screen, shown at the moment of the decision. True by construction "
"there: the package the hub holds right now is the one about to be pinned. Left alone.",
}
TEMPLATE_COMMENT = re.compile(r"\{\{/\*.*?\*/\}\}", re.S)
GO_COMMENT = re.compile(r"//[^\n]*|/\*.*?\*/", re.S)
def scan():
convictions, seen_keys = [], set()
files = sorted(f for f in os.listdir(TEMPLATES) if f.endswith(".html"))
sources = [(f, os.path.join(TEMPLATES, f), TEMPLATE_COMMENT) for f in files]
for gp in GO_SOURCES:
if not os.path.exists(gp):
raise SystemExit(f"retrieval-promise gate: declared Go source is missing: {gp}")
sources.append((os.path.basename(gp), gp, GO_COMMENT))
files = files + [os.path.basename(gp)]
for name, path, stripper in sources:
text = stripper.sub("", open(path, encoding="utf-8").read())
for stem in STEMS:
for m in re.finditer(re.escape(stem) + r"[a-záéíóöőúüű]*", text):
line = text[: m.start()].count("\n") + 1
# SPAN-based, not window-based. The promise and the cautious disclaimer sit within a
# hundred characters of each other in the same paragraph, so a proximity window matches
# whichever key it tries first and reports the other as stale — which is exactly what a
# first draft of this gate did. An occurrence belongs to an entry only if it falls
# INSIDE that entry's own text.
hit = None
for k in (k for k in ALLOWLIST if k[0] == name):
for om in re.finditer(re.escape(k[1]), text):
if om.start() <= m.start() and m.end() <= om.end():
hit = k
break
if hit:
break
if hit:
seen_keys.add(hit)
else:
ctx = text[max(0, m.start() - 100): m.end() + 100]
convictions.append((name, line, m.group(0), " ".join(ctx.split())[:160]))
return files, convictions, seen_keys
def main():
files, convictions, seen = scan()
stale = [k for k in ALLOWLIST if k not in seen]
for name, line, word, ctx in convictions:
print(f" {name}:{line} unregistered retrieval claim ({word}):\n{ctx}")
for k in stale:
print(f" STALE ALLOWLIST ENTRY (no longer present): {k[0]} :: {k[1]!r}")
if convictions or stale:
print(f"\nRETRIEVAL-PROMISE GATE FAILED: {len(convictions)} unregistered, {len(stale)} stale, "
f"across {len(files)} template(s).")
print("Five instances of this claim have surfaced one at a time. If the new text is a genuine")
print("claim, make it CONDITIONAL on what the box can see; if it is a question about")
print("knowability, or an unrelated local-backup sentence, add it to ALLOWLIST with the reason.")
return 1
print(f"retrieval-promise gate OK — {len(files)} surface(s) incl. {len(GO_SOURCES)} Go handler file(s), "
f"{len(ALLOWLIST)} registered claim(s), none unregistered")
print(" (BLIND SPOT: it registers WHERE the claim is made, not whether each conditional is wired")
print(" to a true predicate — that is what the R-302 render tests are for.)")
return 0
if __name__ == "__main__":
sys.exit(main())
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""secret_in_markup_gate.py — a secret must never be rendered into a template.
WHY THIS EXISTS. Three instances of ONE pattern shipped in two days, each found by hand:
R-249 settings_security.html {{.RetrievalPassword}} inside a display:none span
R-254 app_info.html {{.InitialCreds.Password}} inside a `hidden` span
R-254 deploy.html value="{{$val}}" in a readonly type=password input
Each was "hidden" by an instruction the browser honours when DRAWING and by nothing else, so the
plaintext sat in the response body of a page the customer merely opened. **Hiding is not containment.**
A pattern found three times is not closed by searching a fourth time; it is closed by a check.
WHAT THIS GATE DOES. It reads every template and convicts any `{{ }}` action whose expression names
a secret (password / secret / token / credential / passphrase / apikey), unless that exact expression
is on the ALLOWLIST below with a stated reason.
WHAT IT DOES *NOT* DO, STATED PLAINLY SO NOBODY READS IT AS COMPLETE COVERAGE.
1. It is NAME-BASED, and the hole was MEASURED rather than guessed at. It catches
`{{.InitialCreds.Password}}`, and it also catches a launder through a local variable, because the
ASSIGNMENT names the secret (`{{$v := .InitialCreds.Password}}` is convicted). What it cannot see
is a secret that arrives under a NEUTRAL PAGE-DATA KEY `data["Tagline"] = creds.Password` then
`{{.AppInfo.Tagline}}` passes this gate cleanly. Verified both ways during the 2026-08-08 session.
The third instance above (`value="{{$val}}"` inside an `{{if eq .Type "secret"}}` branch) is that
shape: this gate would NOT have caught it.
2. It reasons about TEMPLATES, not about rendered output. A handler that writes a secret into a
neutrally-named page-data key is invisible to it.
3. Runtime body-assertion rendering a page with a sentinel and grepping the response is the
check that catches all three, and it needs each page's data to be constructible. Four pages have
that today (settings_security, app_info, deploy, backups_restore) and each has its own test; the
other 23 page templates do NOT. Closing that gap is R-255.
So: this is the cheap layer that would have caught two of the three, plus a per-page runtime assertion
for the pages that can afford one. Together they are not a proof; they are two nets with different
holes, and the holes are named above.
"""
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
CTRL = os.path.dirname(HERE)
TPL = os.path.join(CTRL, "internal", "web", "templates")
SECRETY = re.compile(r"pass(word|phrase)|secret|token|credential|apikey|api_key", re.I)
ACTION = re.compile(r"\{\{-?\s*(.*?)\s*-?\}\}", re.S)
# Expressions that name a secret but are NOT one, each with the reason it is safe. An entry here is a
# claim someone made; it should be short enough to re-check by eye.
ALLOWLIST = {
# booleans / presence flags — the whole point of the R-249 and R-254 fixes
".HasRetrievalPassword": "boolean: whether one exists, never the value",
".InitialCredsHasPassword": "boolean: whether one exists, never the value",
".SharePasswordSet": "boolean: whether a share password is set",
".PasswordError": "an error MESSAGE for a failed password change, not a password",
".MinPassword": "the minimum LENGTH policy number",
# form field names and types, not values
'eq .Type "password"': "a field TYPE discriminator",
'eq .Type "secret"': "a field TYPE discriminator",
'eq .Type "secret_input"': "a field TYPE discriminator",
'if or .Required (eq .Type "password")': "a field TYPE discriminator",
'if and (not $isDeployed) (eq .Type "secret")': "guards the PRE-DEPLOY hidden input — a form must "
"carry what it submits (README §318); see R-254 site two",
"define \"launcher_share_password\"": "a template name",
# the CSRF token is not a secret in this sense: it is bound to the session and useless without it,
# and it MUST be in the form for the form to work.
".CSRFToken": "CSRF token — session-bound, must be in the page for any POST to work",
".CSRFField": "CSRF token — same",
}
def check(path):
convictions = []
src = open(path, encoding="utf-8").read()
for m in ACTION.finditer(src):
expr = m.group(1).strip()
# A TEMPLATE comment `{{/* ... */}}` is stripped by html/template and never reaches the
# response body, so it cannot leak anything into markup — unlike an HTML `<!-- -->` comment,
# which does ship and is deliberately NOT skipped here. Without this the gate convicted the
# prose explaining a fix, purely for containing the word "secret" (2026-08-10), which is a
# false positive that teaches people to write worse comments or to widen the ALLOWLIST —
# both of which cost more than the check is worth.
if expr.startswith("/*"):
continue
if not SECRETY.search(expr):
continue
if expr in ALLOWLIST:
continue
# `{{if .X}}` / `{{with .X}}` where .X is allowlisted is the same claim as `.X`
bare = re.sub(r"^(if|with|else if)\s+", "", expr).strip()
if bare in ALLOWLIST:
continue
line = src[: m.start()].count("\n") + 1
convictions.append((line, expr))
return convictions
def main():
if not os.path.isdir(TPL):
print("secret-in-markup gate INCONCLUSIVE: template dir not found: %s" % TPL)
return 2
files = sorted(f for f in os.listdir(TPL) if f.endswith(".html"))
if not files:
print("secret-in-markup gate INCONCLUSIVE: no templates found in %s" % TPL)
return 2
total = 0
bad = 0
for f in files:
total += 1
for line, expr in check(os.path.join(TPL, f)):
bad += 1
print(" %s:%d renders a secret-named expression into the markup: {{%s}}" % (f, line, expr))
if bad:
print()
print("SECRET-IN-MARKUP GATE FAILED: %d expression(s) across %d template(s)." % (bad, total))
print("A secret must not be in the response body of a page the customer merely opens —")
print("hiding it with `hidden` / display:none / type=password stops it being DRAWN and nothing else.")
print("Fix: carry a BOOLEAN in the page data and fetch the value with an explicit authenticated")
print("POST that sets Cache-Control: no-store and logs the act (see R-249's and R-254's endpoints).")
print("If the expression genuinely is not a secret, add it to ALLOWLIST with the reason.")
return 1
print("secret-in-markup gate OK — %d templates, no secret-named expression rendered" % total)
print(" (NAME-BASED: blind to a secret arriving under a neutral PAGE-DATA key — see the docstring)")
return 0
if __name__ == "__main__":
sys.exit(main())