Compare commits
32 Commits
7534ea203d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e3ee94b7b | |||
| ae10f64806 | |||
| 3ed5e3e770 | |||
| 3168a78935 | |||
| 89712563a0 | |||
| 1b66010298 | |||
| 68f3e12398 | |||
| f87be3575f | |||
| 38f4535bfa | |||
| 397d62136f | |||
| 86a78c6767 | |||
| b762a37097 | |||
| c732fe1283 | |||
| fcffaf573a | |||
| 37b5ba08a7 | |||
| 27d1165962 | |||
| 62998aab4f | |||
| 8dbbc98ff2 | |||
| 3d3b4496f3 | |||
| 0a9158d53e | |||
| 72368654e4 | |||
| de39e47f53 | |||
| a5d90ff801 | |||
| a491abef6c | |||
| 763de3a025 | |||
| c6b69d888e | |||
| 53e9bf0224 | |||
| 4d349d1106 | |||
| 9dc26459ea | |||
| 66d80efb9f | |||
| 7db42c5fec | |||
| a62bb3874b |
@@ -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.
|
||||
-->
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
-->
|
||||
@@ -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.
|
||||
+648
@@ -1,3 +1,651 @@
|
||||
## 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 hub’s
|
||||
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-300’s 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 card’s 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
|
||||
|
||||
@@ -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
|
||||
-->
|
||||
|
||||
+176
-1
@@ -7,7 +7,124 @@
|
||||
>
|
||||
> Ask Claude Code: "Please update CONTEXT.md with what we did today"
|
||||
|
||||
Last updated: 2026-08-05 (v0.200.0 — R-193: the recovery screen, unlocking only)
|
||||
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
|
||||
@@ -1776,6 +1893,64 @@ 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**
|
||||
|
||||
@@ -1,144 +1,89 @@
|
||||
# REPORT — CAMPAIGN 11 fix pass: a correct recovery code is never called wrong again
|
||||
# REPORT — controller v0.214.0: the screen stops hedging about a code it can now check (R-311, 2026-08-12)
|
||||
|
||||
**2026-08-05 · controller `v0.201.0` · commit `a3499d1`** (paired with hub `v0.97.1`)
|
||||
## The premise, corrected before anything was written
|
||||
|
||||
Campaign 11 walked the customer's recovery journey end to end for the first time. **The data came back
|
||||
byte-identical; the journey did not exist.** This pass closes six of the nine findings.
|
||||
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.
|
||||
|
||||
## Scenario A and its red-proof — the session's headline
|
||||
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.
|
||||
|
||||
Measured live on the campaign venue 2026-08-05: agent 0.120.0 answers
|
||||
`POST /escrow/recover-offsite-password` with 404, the unlock was attempted anyway, and a **correct**
|
||||
recovery code came back — in **0.134 s**, far too fast for `age`'s scrypt — as:
|
||||
Verified at `file:line` against live source, per rule 1, including the claims in the prompt itself.
|
||||
|
||||
> *„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…"*
|
||||
## What changed
|
||||
|
||||
**RED-PROOF.** Deleting the `if support := s.recoverySupport(...)` block from `recoveryUnlockHandler`:
|
||||
- `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.
|
||||
|
||||
```
|
||||
--- FAIL: TestRecoveryGate_A_OldAgentIsNamed_NeverTheCode/agent_predates_the_route_(404)
|
||||
R-216 RETURNED: an agent that cannot answer is still reported as a wrong recovery code
|
||||
--- FAIL: TestRecoveryGate_A_OldAgentIsNamed_NeverTheCode/agent_cannot_be_asked_at_all
|
||||
R-216 RETURNED: an agent that cannot answer is still reported as a wrong recovery code
|
||||
```
|
||||
## The message, and what it deliberately will not say
|
||||
|
||||
The accusation returns verbatim, on the assertion that names the finding. Restored; green.
|
||||
> „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á."
|
||||
|
||||
*(The first attempt at this red-proof failed the test for weaker reasons — the fake recoverer
|
||||
succeeded regardless of the agent verdict, so the accusation could not appear. The fixture now models
|
||||
the real agent: one that cannot answer the route also fails the call.)*
|
||||
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.
|
||||
|
||||
## The four messages, quoted for review as copy
|
||||
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.
|
||||
|
||||
1. **The code did not open it** — the only one that mentions typing:
|
||||
> „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."
|
||||
2. **The machine cannot ask** (R-216):
|
||||
> „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."
|
||||
3. **The store could not be read** (R-217) — two shapes:
|
||||
> „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."
|
||||
> „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."
|
||||
4. **A retained earlier package** (R-222) — states two facts and promises nothing:
|
||||
> „Ez a kód nem nyitja meg azt a csomagot, amit most őrzünk ehhez a géphez. Ha egy korábbi kódot
|
||||
> adtál meg: a géped azóta új mentési kulcsot kapott, és a régebbi csomagot (…) nem töröltük —
|
||||
> megőrizzük. Megnyitni viszont innen egyelőre nem lehet, ezért ha a régebbi mentéseidre van
|
||||
> szükséged, keresd a Felhom ügyfélszolgálatát. A kódoddal semmi nem történt, és semmi nem
|
||||
> változott."
|
||||
**An older agent keeps the hedged sentence.** Unknown → claims less → heals itself on update.
|
||||
|
||||
**Live proof of message 4**, on the exact box and the exact code that produced the defect in Phase 3
|
||||
step 7: rendered verbatim with the real supersession timestamp `2026-08-05 15:03:14`, and
|
||||
`blames typing = False`.
|
||||
## The claim guard grew a surface, and immediately convicted something
|
||||
|
||||
## Part 1b — the shape chosen, and why nobody types a number twice
|
||||
`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.
|
||||
|
||||
`ResolveManagedFloor` compared the box's agent against `ArtifactManifest.MinAgent`, which by its own
|
||||
doc comment describes **the golden's** controller, while `publish-train-rules.md` rule 3 states the
|
||||
rule about **the floor's**. They are the same number only while the floor sits at or below the golden.
|
||||
|
||||
**Chosen: a floor ABOVE the vouched golden is HELD**, with its own reason. **No new operator input at
|
||||
all** — the vouched MinAgent keeps its exact meaning and the guard simply stops applying it to a
|
||||
version it does not describe. The rejected alternative was a second "floor MinAgent" field, which
|
||||
would have the operator type the same CHANGELOG number twice for the same release.
|
||||
|
||||
Live, immediately after deploy:
|
||||
|
||||
```
|
||||
managed floor HELD for c11: held: floor 0.200.0 is ABOVE the vouched golden 0.192.0, so its agent
|
||||
requirement is unknown — vouch a golden carrying the floor's controller (publish-train rule 1)
|
||||
```
|
||||
|
||||
**⚠ What it does NOT do:** with the guard corrected and the Day-0 agent unchanged, a new box is
|
||||
**held, not served**. It stops being lied to; the feature works for it only after R-223.
|
||||
|
||||
**A slip caught by the deploy itself, and fixed in v0.97.1:** the first deployed build logged
|
||||
`agent "0.125.0" < MinAgent 0.113.0` for a box held for the NEW reason — a comparison that is false.
|
||||
That is `CLAUDE.md`'s corollary (a verdict that changes which field it counts from must change its
|
||||
alarm text). Both surfaces now come from one `ManagedFloorDecision.HoldReason()`, pinned by a test.
|
||||
|
||||
## R-218's live proof — stated with its limit
|
||||
|
||||
**Proven by test + red-proof**, not yet by a live rebuild-shaped run. Restoring the deleted
|
||||
short-circuit fails `TestOffsiteDeclare_StillDeclaresAfterARecoveredKeyIsPlaced` with *"the box went
|
||||
SILENT after recovering its key while still having no off-site target"*.
|
||||
|
||||
**The live half is deliberately not taken**: reproducing the stranded shape means reconfiguring the
|
||||
campaign venue, and §11 forbids that because Phase 2 needs it as it is. What IS verified live on the
|
||||
venue is Scenario E's side — the configured tier is silent — plus the whole R-222 ACK chain end to
|
||||
end (hub 0.97.1 → ACK → controller 0.201.0 → `settings.json`: `hub_escrow_superseded_present: True`,
|
||||
`hub_escrow_superseded_at: 2026-08-05 15:03:14`).
|
||||
**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.
|
||||
|
||||
## Tests and red-proofs
|
||||
|
||||
| Scenario | Test | Red-proof — what was mutated | Result |
|
||||
|---|---|---|---|
|
||||
| A | `TestRecoveryGate_A_OldAgentIsNamed_NeverTheCode` | the capability gate deleted | **FAILED as required** |
|
||||
| A | `TestRecoveryGate_A_SupportedAgentProceeds`, `_CouplingIsDeclared` | — | pass |
|
||||
| B/C | `TestResolveManagedFloor_R216_FloorAboveGolden` (5 sub-cases) | the floor-above-golden branch deleted | **FAILED as required** |
|
||||
| — | `TestResolveManagedFloor_HoldReasonMatchesTheCause` | — | pass |
|
||||
| D | `TestOffsiteDeclare_StillDeclaresAfterARecoveredKeyIsPlaced` | the short-circuit restored | **FAILED as required** |
|
||||
| E | `TestOffsiteDeclare_ConfiguredTierIsSilent`, `_DisabledTargetIsNotStranded` | — | pass |
|
||||
| F | `TestRecoveryGate_F_UnlockBringsTheTierUpBeforeListing`, `_TierNotUpYetSaysPendingNotFailed` | — | pass |
|
||||
| G | `TestRecoveryGate_G_UnreadableStoreNeverClaimsToHaveOpened`, `_ThreeDistinctStates` | the zero-value inventory restored | **FAILED as required** |
|
||||
| H | `TestRecoveryGate_H_SupersededPackageIsNamed`, `_WithoutASuperseded…` | — | pass |
|
||||
| I | `TestRecoveryGate_I_DirectGetRefusedOnABoxThatNeverHadBackups`, `_StillWorksWhenOffered` | the direct-GET gate deleted | **FAILED as required** |
|
||||
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:
|
||||
|
||||
Green gates: `go build ./... && go vet ./... && go test ./...` rc=0 in both repos, read separately
|
||||
from every commit. `controller_gates.py --fast` and `repo_gates.py --fast` both all-OK. The `-run`
|
||||
filter was proven to match (13 `=== RUN` lines) rather than trusted.
|
||||
| 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
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| controller `0.201.0` | campaign venue (c11 guest 9201) **and** demo-felhom guest 9201 — both `healthy` |
|
||||
| hub `0.97.1` | GitOps: manifest bump → ArgoCD hard-refresh + deliberate sync → `Synced Healthy`, rollout complete, image verified |
|
||||
| agent | **untouched** — v0.125.0 is what R-223 asks the operator to vouch, not what this changed |
|
||||
`gitea.dooplex.hu/admin/felhom-controller:0.214.0` built, pushed, deployed to guest 9201 —
|
||||
`Up … (healthy)`.
|
||||
|
||||
CI: controller task **171** (`a3499d18`) success; felhom.eu tasks **172–175** success. No
|
||||
`--no-verify`; every pre-push gate ran and passed.
|
||||
**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`).
|
||||
|
||||
## Still open, deliberately
|
||||
**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.**
|
||||
|
||||
**R-214** (console pairing banner), **R-220** (drives unenrollable after a rebuild — the deploy refuses
|
||||
and the wizard's list is empty), **R-221** (a rebuilt box cannot run the escrow ceremony at all).
|
||||
R-221 in particular is a real blocker for a different flow. **R-213** and **R-202** untouched.
|
||||
|
||||
## Observations, not acted on
|
||||
|
||||
1. **`SupportsWithSource` looks up `featureProbes` FIRST and returns `SupportUnknown` on a table gap,
|
||||
before the version path runs.** So `FeatureBackupAgeState`, which has a `featureMinAgent` row and no
|
||||
`featureProbes` row, can never be decided by version — it is always Unknown. This fix works around it
|
||||
by registering a probe that returns a sentinel; the ordering itself is untouched (out of scope).
|
||||
2. The recovery screen renders `SealedAt` as a raw RFC3339 UTC string (`2026-08-05T13:11:11Z`) to a
|
||||
Hungarian household; `recoverySealedAt()` is documented as "the human date".
|
||||
3. The escrow wizard's form says *„tíz szó"* while the issued code is hyphen-joined, and the
|
||||
wrong-code message says *szóközökkel elválasztva* (space-separated). Both forms should be accepted
|
||||
or the copy aligned; not investigated.
|
||||
**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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>`).
|
||||
@@ -2500,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:
|
||||
|
||||
@@ -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 {
|
||||
@@ -677,8 +729,23 @@ func main() {
|
||||
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.
|
||||
RecordSuperseded: sett.SetHubEscrowSuperseded,
|
||||
Logger: logger,
|
||||
// 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
|
||||
hubPusher.OnPushResponse = func(resp *report.PushResponse) {
|
||||
@@ -756,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 {
|
||||
@@ -883,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 {
|
||||
@@ -892,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -201,6 +201,15 @@ const (
|
||||
// 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.
|
||||
@@ -210,7 +219,11 @@ const (
|
||||
// "the code was refused" — it means "one of two things, and we cannot tell which". Pass false there
|
||||
// and the 400 degrades to RecoveryUnknown, which is neutral. That degradation is the point: it is
|
||||
// safe, it is silent, and it heals itself when the agent updates.
|
||||
func ClassifyRecoveryFailure(err error, trustRefusal bool) RecoveryFailure {
|
||||
// ⚠ `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.126–128 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
|
||||
}
|
||||
@@ -230,6 +243,14 @@ func ClassifyRecoveryFailure(err error, trustRefusal bool) RecoveryFailure {
|
||||
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
|
||||
@@ -253,6 +274,8 @@ func (f RecoveryFailure) String() string {
|
||||
return "no-bundle"
|
||||
case RecoveryBundleTooOld:
|
||||
return "bundle-too-old"
|
||||
case RecoveryCodeOpensRetained:
|
||||
return "code-opens-retained"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
@@ -70,6 +70,19 @@ const FeatureOffsiteKeyRecovery Feature = "offsite_key_recovery"
|
||||
// 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
|
||||
@@ -141,6 +154,11 @@ var featureProbes = map[Feature]func(ctx context.Context, p SupportProber) error
|
||||
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
|
||||
@@ -167,6 +185,13 @@ var featureMinAgent = map[Feature]string{
|
||||
|
||||
// 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
|
||||
|
||||
@@ -95,7 +95,7 @@ type Manager struct {
|
||||
offboxOrphanEvent func(eventType, renamedTo string)
|
||||
// offboxGapNotify (R-203) fires when a COMPLETED offsite run could not capture a directory an
|
||||
// app declares MANDATORY — a coverage gap, not a failed run. nil → no signal.
|
||||
offboxGapNotify func(gaps map[string][]string)
|
||||
offboxGapNotify func(gaps map[string][]string)
|
||||
// offboxSSH (v0.142.0) is the raw-ssh exec seam for the orphaned-repo move-aside (restic has no
|
||||
// rename); tests inject a fake. Nil → the real ssh invocation (defaultOffboxSSH).
|
||||
offboxSSH func(ctx context.Context, host, user string, port int, keyPath, knownHosts, remoteCmd string) ([]byte, error)
|
||||
@@ -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()
|
||||
|
||||
@@ -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,8 +577,19 @@ 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 {
|
||||
return fmt.Errorf("apply offsite secrets: %w", err)
|
||||
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
|
||||
@@ -504,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).
|
||||
@@ -746,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()
|
||||
|
||||
@@ -874,10 +1003,38 @@ 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"
|
||||
@@ -895,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...)
|
||||
@@ -1059,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 +
|
||||
@@ -1079,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,
|
||||
@@ -1199,6 +1420,15 @@ type OffboxReportStatus struct {
|
||||
// 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
|
||||
@@ -1217,6 +1447,27 @@ type OffboxReportStatus struct {
|
||||
// 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:
|
||||
//
|
||||
@@ -1294,13 +1545,49 @@ func (m *Manager) needsOffsiteCredential(t *settings.OffboxTarget) bool {
|
||||
//
|
||||
// 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
|
||||
}
|
||||
if _, ok := m.OffboxRepoPasswordHash(); !ok {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1324,6 +1611,13 @@ func (m *Manager) OffsiteRecoveryOffer() bool {
|
||||
// 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}
|
||||
@@ -1334,6 +1628,7 @@ func (m *Manager) OffboxReportStatus() *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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,17 +23,30 @@ 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) 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
|
||||
func (p *offbox3aProvider) GetDockerVolumes(string) []string { return nil }
|
||||
func (p *offbox3aProvider) StopStack(string) error { return nil }
|
||||
func (p *offbox3aProvider) StartStack(string) error { return nil }
|
||||
func (p *offbox3aProvider) RefreshAndIsRunning(string) bool { return false }
|
||||
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
|
||||
func (p *offbox3aProvider) GetDockerVolumes(string) []string { return nil }
|
||||
func (p *offbox3aProvider) StopStack(string) error { return nil }
|
||||
func (p *offbox3aProvider) StartStack(string) error { return nil }
|
||||
func (p *offbox3aProvider) RefreshAndIsRunning(string) bool { return false }
|
||||
func (p *offbox3aProvider) GetStackRecoveryInfo(string) (RecoveryInfo, bool) {
|
||||
return RecoveryInfo{}, false
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -29,6 +29,12 @@ var errNoOffsiteTarget = errors.New("no off-site target is configured on this bo
|
||||
// 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,17 @@ 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.
|
||||
@@ -65,7 +76,23 @@ type EscrowAutoConfirmer struct {
|
||||
// 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
|
||||
Logger *log.Logger
|
||||
// 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)
|
||||
@@ -130,6 +157,14 @@ func (c *EscrowAutoConfirmer) Reconcile(es *EscrowStatus) {
|
||||
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()
|
||||
@@ -211,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()
|
||||
}
|
||||
|
||||
@@ -66,3 +66,61 @@ func TestMainWiresRecordPresence(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,32 @@ type Settings struct {
|
||||
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
|
||||
@@ -93,6 +119,37 @@ type Settings struct {
|
||||
// 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"`
|
||||
|
||||
@@ -292,6 +349,55 @@ type OffboxTarget struct {
|
||||
// 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.
|
||||
@@ -1580,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,
|
||||
@@ -2037,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()
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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"},
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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) != ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -925,6 +945,26 @@ func (s *Server) backupsRemoteHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -1007,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)
|
||||
}
|
||||
|
||||
@@ -1098,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).
|
||||
@@ -1192,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1552,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"
|
||||
@@ -1587,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())
|
||||
}
|
||||
@@ -2435,7 +2712,7 @@ type fbPathDeps struct {
|
||||
// than derived here because only the caller knows the system-data path. nil → identity, which is
|
||||
// the pre-R-203 behaviour and correct for every enrolled drive.
|
||||
nsRootFor func(string) string
|
||||
logger *log.Logger
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
// buildFileBrowserPaths computes one FileBrowser sync pass's volume mount lines + the source-list
|
||||
|
||||
@@ -3,6 +3,7 @@ package web
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -229,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)
|
||||
}
|
||||
}()
|
||||
@@ -316,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)
|
||||
@@ -327,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")
|
||||
}
|
||||
}
|
||||
@@ -231,14 +231,14 @@ func TestClassifyRecoveryFailure_MapsFromTheValueNotTheText(t *testing.T) {
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := agentapi.ClassifyRecoveryFailure(tc.err, tc.trusted); got != tc.want {
|
||||
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) == agentapi.ClassifyRecoveryFailure(refusal(502, same), true) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"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
|
||||
@@ -44,14 +45,67 @@ func (s *Server) recoveryOffer() bool {
|
||||
return s.backupMgr != nil && s.backupMgr.OffsiteRecoveryOffer()
|
||||
}
|
||||
|
||||
// recoveryInterrupts reports whether the FULL PAGE should take over the landing pages. "Most nem"
|
||||
// suppresses this and nothing else — recoveryOffer stays true, so the backups-area entry point
|
||||
// survives. That asymmetry is the whole of Scenario E.
|
||||
// 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 {
|
||||
if !s.recoveryOffer() {
|
||||
// ⚠ 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 s.settings == nil || !s.settings.GetRecoveryNoticePostponed()
|
||||
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
|
||||
@@ -112,6 +166,9 @@ func (s *Server) renderRecoveryState(w http.ResponseWriter, r *http.Request, err
|
||||
// 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
|
||||
@@ -212,6 +269,31 @@ 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
|
||||
@@ -316,7 +398,7 @@ func (s *Server) recoveryUnlockHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// 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()))
|
||||
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:
|
||||
@@ -340,6 +422,30 @@ func (s *Server) recoveryUnlockHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// 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.
|
||||
@@ -405,6 +511,20 @@ func (s *Server) recoveryUnlockHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
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
|
||||
@@ -469,10 +589,136 @@ func (s *Server) recoveryUnlockHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// 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 {
|
||||
if err := s.settings.SetRecoveryNoticePostponed(true); err != 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 backups-area entry point stays")
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,14 +118,16 @@ func newRecoveryFixture(t *testing.T) *recoveryFixture {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mgr := backup.NewManager(cfg, sett, lg)
|
||||
if err := mgr.WriteOffboxSecrets("PRIVATE-KEY-MATERIAL", "nas.local ssh-ed25519 AAAAhostkey"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// WriteOffboxSecrets auto-generates a repository password — remove it, because "this box cannot
|
||||
// open the inherited history" is the whole precondition of the screen.
|
||||
if err := os.Remove(filepath.Join(cfg.Paths.DataDir, "offbox", "repo_password")); err != nil {
|
||||
// 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)
|
||||
|
||||
@@ -145,6 +147,9 @@ func newRecoveryFixture(t *testing.T) *recoveryFixture {
|
||||
// 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}
|
||||
}
|
||||
|
||||
@@ -335,13 +340,20 @@ func TestRecovery_D_WrongCodeFailsClosedAndIsKind(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
if !f.sett.GetRecoveryNoticePostponed() {
|
||||
t.Fatal("the postpone was not recorded")
|
||||
// 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() {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -110,6 +116,9 @@ type Server struct {
|
||||
// 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
|
||||
@@ -416,6 +425,13 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
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":
|
||||
@@ -486,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:
|
||||
@@ -550,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")
|
||||
@@ -593,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 {
|
||||
|
||||
@@ -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 () {
|
||||
var orig = btn.textContent;
|
||||
btn.textContent = 'Másolva';
|
||||
setTimeout(function () { btn.textContent = orig; }, 1500);
|
||||
});
|
||||
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>
|
||||
|
||||
@@ -95,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>
|
||||
@@ -133,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>
|
||||
@@ -156,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}}
|
||||
<!-- 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>
|
||||
{{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={{.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 —
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -166,16 +166,22 @@
|
||||
<!-- ── 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>félretesszük — nem töröljük</strong>;</li>
|
||||
<li>a félretett mentések <strong>innen többé nem nyithatók meg</strong> — sem kóddal, sem anélkül;</li>
|
||||
<li>a 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>ez az oldal <strong>többé nem jelenik meg</strong>.</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.</p>
|
||||
<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">
|
||||
|
||||
@@ -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/<név> ú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 {'&':'&','<':'<','>':'>','"':'"'}[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,
|
||||
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)});
|
||||
// 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(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;
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user