docs: R-200 diagnostic — CONTEXT + REPORT (proven live on demo-felhom)
gates / gates (push) Successful in 10s
gates / gates (push) Successful in 10s
This commit is contained in:
+20
@@ -1672,6 +1672,26 @@ Last updated: 2026-06-13 (v0.60.0 backlog-Medium cleanup)
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## R-200 (v0.195.0) — the offsite key recovery diagnostic
|
||||||
|
|
||||||
|
`--recover-offsite-check` is a `docker exec` escape hatch (the `--print-reset-code` shape), NOT a page
|
||||||
|
or an API a browser can reach. R comes from **STDIN** — never argv, never `ps`, never shell history,
|
||||||
|
never a transcript. It asks the agent (>= v0.125.0) to fetch this host's sealed bundle and open it,
|
||||||
|
then reports whether the recovered repository password matches the on-disk one **by sha256**.
|
||||||
|
|
||||||
|
docker exec -i felhom-controller /usr/local/bin/felhom-controller --recover-offsite-check < /path/to/code
|
||||||
|
|
||||||
|
**IT COMPARES AND NEVER INSTALLS.** `CheckOffsiteKeyRecoverable` must stay free of any write — if a
|
||||||
|
future change makes it place the recovered password, it stops being a diagnostic and needs the drill's
|
||||||
|
supervision (that is link 9, R-200's remaining half). Pinned by
|
||||||
|
`TestCheckOffsiteKeyRecoverable_WritesNothing`, whose red-proof is adding the install call.
|
||||||
|
|
||||||
|
Exit codes are load-bearing: **0** match, **2** a clean MISMATCH, **1** a step failed. A mismatch is a
|
||||||
|
finding about the system; a failure is a finding about the run, and they must never share a status.
|
||||||
|
|
||||||
|
**Proven live on demo-felhom 2026-08-04** — recovered sha256 == on-disk sha256 == the hub's stored
|
||||||
|
hash. Nothing customer-facing ships with it: no card, no form, no preview.
|
||||||
|
|
||||||
## About Viktor (project owner)
|
## About Viktor (project owner)
|
||||||
|
|
||||||
- Works at Deutsche Telekom (Budapest), building Felhom.eu as a side business
|
- Works at Deutsche Telekom (Budapest), building Felhom.eu as a side business
|
||||||
|
|||||||
@@ -1,97 +1,60 @@
|
|||||||
# REPORT — R-182: one operator email per backup run, and nothing dropped without a trace
|
# REPORT — controller v0.195.0: prove the offsite key comes back (2026-08-04, R-200 plumbing half)
|
||||||
|
|
||||||
**Date:** 2026-08-03 · **Repo:** `felhom-controller` · **v0.193.1 → v0.194.0** · commit `88897a2`
|
**Deployed:** `gitea.dooplex.hu/admin/felhom-controller:0.195.0` on guest 9201 (demo-felhom), `Up (healthy)`.
|
||||||
**Baseline on arrival:** `db0d4b129d3b`, v0.193.1 — matched §1.
|
**Requires agent >= v0.125.0** (declared MinAgent) and hub >= v0.94.0.
|
||||||
|
|
||||||
## The defect
|
## What shipped
|
||||||
|
|
||||||
Nine per-app `recovery_unit_capture_failed` events reached the hub on 2026-08-03 and **two operator
|
`--recover-offsite-check` — a `docker exec` diagnostic in the shape of `--print-reset-code`. It reads
|
||||||
emails went out**. The hub's operator cooldown key is `customerID:eventType(+tier)`, and that event
|
the customer's recovery code from **STDIN**, asks the agent to fetch this host's hub-held sealed bundle
|
||||||
carries `app` but **no `tier`** — so the key held no app identifier. The first refused app took the
|
and open it, and reports whether the recovered offsite repository password matches the one on disk
|
||||||
hour's slot and every other app's failure was discarded **before `LogNotification`**, leaving no row
|
**by sha256**. Two hashes and a verdict; never a password, never the recovery code, never a blob.
|
||||||
on any channel.
|
|
||||||
|
|
||||||
**The obvious fix was ruled against:** putting `app` in the key produces one email per failing app,
|
- `internal/backup/offbox_recovery_check.go` — `CheckOffsiteKeyRecoverable`, hashes only.
|
||||||
which on a full disk is a dozen.
|
- `internal/backup/offbox_recovery_cli.go` — the entry point, R from stdin.
|
||||||
|
- `internal/agentapi/escrow.go` — `RecoverOffsiteRepoPassword` (the only place R crosses outbound).
|
||||||
|
- `cmd/controller/main.go` — the flag, alongside `--print-reset-code`.
|
||||||
|
|
||||||
## What ships
|
**R from stdin and not a flag** because a flag value is visible in `ps`, in shell history, in a
|
||||||
|
container's command line and in any session transcript. **It compares and never installs** (§8.5):
|
||||||
|
the recovered password is not written to `offbox/repo_password`, and link 9 — placing it so the
|
||||||
|
existing repository opens — is next session's work, with the drill around it.
|
||||||
|
|
||||||
`internal/backup/runsummary.go` — a per-run collector with exactly `admissionSet`'s lifetime, fed by
|
## THE RESULT
|
||||||
all three write legs, emitting `backup_run_failures` **once** at the end and **only when something
|
|
||||||
failed**. The per-app event stays and becomes the record.
|
|
||||||
|
|
||||||
- **A refusal is noted ONCE, inside `admitApp` where the verdict is taken** — not at the three legs
|
On demo-felhom, 2026-08-04 13:49 CEST:
|
||||||
that consult it. R-181's contract is one verdict per app per run; noting per leg listed a single
|
|
||||||
refused app three times and produced *"2 of 1 apps failed"*. **Found by the digest's own test.**
|
|
||||||
- **Deliberate skips are excluded** — a disconnected or decommissioned drive has its own alert.
|
|
||||||
- **A manual run always reports:** a unique `run_id` the hub's cooldown cannot collapse.
|
|
||||||
|
|
||||||
**A gap the spec did not anticipate, and its fix.** `recovery_unit_capture_failed` also fires from
|
|
||||||
`GetFullStatus`'s periodic sweep, outside any run. With it now record-only, those failures would have
|
|
||||||
been recorded and **never notified** — a new silence created while closing one. The sweep therefore
|
|
||||||
emits a digest too, deliberately with **no `run_id`**, so it stays under the ordinary hourly cooldown
|
|
||||||
exactly as before while the mail now lists every failing app instead of whichever was first.
|
|
||||||
|
|
||||||
## §3's safety property — confirmed, not assumed
|
|
||||||
|
|
||||||
`hub/internal/monitor/deadline.go:396` (`expected_backup_missed`) and `:417`
|
|
||||||
(`expected_dbdump_missed`) are raised by the **hub**, from `assessBackupFreshness(reportJSON,…)` and
|
|
||||||
`GetEventsByType` — i.e. from the box's report freshness and stored events, **independently of any
|
|
||||||
email the controller chooses to send**. A digest's silence therefore still means "the run finished
|
|
||||||
and found nothing wrong". This is what makes the whole design safe, and weakening that check
|
|
||||||
re-opens a silent-failure path.
|
|
||||||
|
|
||||||
## Files
|
|
||||||
|
|
||||||
`internal/backup/runsummary.go` (new) · `runsummary_test.go` (new) · `admission.go` ·
|
|
||||||
`admission_test.go` · `backup.go` · `recovery_unit.go` · `internal/notify/notifier.go` ·
|
|
||||||
`internal/web/handler_debug.go` · `internal/api/router.go` · `cmd/controller/main.go` ·
|
|
||||||
`CHANGELOG.md` · `REUSE.md`
|
|
||||||
|
|
||||||
## Tests — `go build && go vet && go test ./...` → **28 packages ok, rc=0**
|
|
||||||
|
|
||||||
7 new tests. `controller_gates.py` → all 8 OK.
|
|
||||||
|
|
||||||
### Red-proofs
|
|
||||||
|
|
||||||
| # | Mutation | Result |
|
|
||||||
|---|---|---|
|
|
||||||
| A | **all four** `noteFailure` feeds removed (the pre-R-182 per-app-only path) | **RED** — Scenario A and the refresh-sweep test both fail; the digest is never emitted |
|
|
||||||
| I (1st attempt) | the `main.go` seam wiring commented out | **DID NOT FAIL — recorded as such.** The AST test walked the backup package and not `main.go`, so the seam could be disconnected with the suite green. **The test was fixed**, not the result recorded |
|
|
||||||
| I (re-run) | same mutation, against the fixed test | **RED** — and the string `SetRunSummaryNotify` is still present in the file, which is why this is an AST walk and not `strings.Contains` |
|
|
||||||
|
|
||||||
## Live proof — demo-hp guest 9201
|
|
||||||
|
|
||||||
**Method:** endpoint-level — `POST /api/debug/backup/dbdump`, the exact endpoint the debug UI button
|
|
||||||
calls, running the production `RunDBDumps`. No browser on DooPlex.
|
|
||||||
|
|
||||||
Filled for real with `fallocate` to **241 MB free / 100% used**; the thin pool held **30.78 → 30.78**
|
|
||||||
(instrument re-proven before use — demo-hp's pool is 53.93 GiB and a genuine fill would exhaust it).
|
|
||||||
|
|
||||||
```
|
```
|
||||||
[INFO] [backup] Run summary: 2 of 2 apps failed (manual run) — notifying the operator once
|
on-disk sha256: c60c8bc737a6b7c6647c7849283f52087f650a885babedb4ef5fdf9a5c9543cb
|
||||||
[INFO] Event pushed: backup_run_failures (error) — 2 of 2 apps failed to back up in this manual run: opengist, privatebin
|
recovered sha256: c60c8bc737a6b7c6647c7849283f52087f650a885babedb4ef5fdf9a5c9543cb
|
||||||
|
[MATCH] the offsite repository password IS recoverable from the sealed escrow.
|
||||||
```
|
```
|
||||||
|
|
||||||
Both apps refused, **one** digest naming both. Against the previous behaviour: two refusals, one
|
Both equal the hash the hub independently stores as `restic_pw_sha256` — three sources agree.
|
||||||
email naming one app, one vanishing. Hub-side evidence is in `felhom.eu/REPORT.md`.
|
Confirmed non-write: `offbox/repo_password` mtime is still `2026-08-03 07:18:02` afterwards.
|
||||||
|
|
||||||
**Scenario E:** a second run in the same hour produced a second digest (11:50:45 and 11:51:28).
|
Five minutes earlier the same path with a deliberately wrong code returned
|
||||||
**Scenario B:** after freeing the space, the run completed with 2 volume dumps and **no** digest.
|
`[FAIL] … the recovery code did not open the sealed bundle … nothing was written`, exit 1.
|
||||||
|
|
||||||
## Teardown
|
## Tests
|
||||||
|
|
||||||
Fill file removed, guest helper scripts and the credential file `shred`-ed, `pct fstrim 9201`
|
`go build ./... && go vet ./... && go test ./...` — rc=0; `controller_gates.py --fast` — all OK.
|
||||||
returned 63.3 GiB, thin pool at **30.76**. Disk back to 1.9G used / 64G free.
|
New: `offbox_recovery_check_test.go` — match/mismatch verdicts, the no-local-password shape, R passed
|
||||||
|
through verbatim, agent-failure-is-not-a-mismatch, and the four exit codes with a check that no
|
||||||
|
printed stream carries a secret.
|
||||||
|
|
||||||
## Observations — NOT acted on
|
**Red-proof observed:** adding `InjectOffboxPassword` to the check fails
|
||||||
|
`TestCheckOffsiteKeyRecoverable_WritesNothing` with *"the check INSTALLED the recovered password"*.
|
||||||
|
|
||||||
1. **Only two apps are deployed on the demo box**, so "several apps" was 2 of 2. That is exactly the
|
## Not in this release
|
||||||
measured case being closed (two refusals → one email), but a five-app demonstration would be
|
|
||||||
stronger and needs a box with more apps.
|
No card, no form, no preview, no customer-facing text — building an interface on a chain nobody had
|
||||||
2. **`FormatOperatorEmail` puts an emoji in every operator subject**, including this digest. Part 3
|
walked is how the preceding three weeks went wrong. The customer-facing shape the operator ruled on
|
||||||
asked for no emoji; the icon comes from the shared formatter, and changing it globally would alter
|
(yell → recovery-code form → preview → proceed) is R-200's remaining half, now priced against a chain
|
||||||
every other operator mail and its tests. Left alone deliberately — the digest's own copy has none.
|
that exists. **R-201 stays open:** no recovered password has been installed, no repository reopened,
|
||||||
3. **The digest's leg name for a refusal is `whole app (refused before any write)`** rather than a
|
no file restored.
|
||||||
specific leg, because the reserve refuses all three at once. Accurate, but longer than the other
|
|
||||||
leg names and it widens the column.
|
## Observation
|
||||||
|
|
||||||
|
The controller binary is at `/usr/local/bin/felhom-controller`, not `/app/…` — a guessed path cost the
|
||||||
|
first diagnostic invocation. Worth a line in the runbook when one is written.
|
||||||
|
|||||||
Reference in New Issue
Block a user