Files
felhom.eu/documentation/audits/REHEARSAL-byo-reinstall-2026-08-09.md
T
admin 34646295dc
gates / gates (push) Successful in 29s
Rehearsal 2026-08-09: pre-phase + Gate 0 recorded before the destructive walk
Venue demo-hp, operator-approved at STOP 1. Records the state that P1 destroys,
plus seven pre-walk findings, while they can still be checked against a live box.

R-268 CLOSED — the leaked per-guest local-API token is rotated and the rotation
is PROVEN in both directions (old refused, new accepted, channel up with a
positive observable). Rotating it surfaced three defects:

- an out-of-process rotation does NOT revoke the old token. The daemon serves
  Lookup from a stale index and re-reads only on a MISS, so a superseded token
  is a direct hit. Red-proved in a unit probe AND live on hardware; the shipped
  RemintCoherence test passes only because it looks up the NEW token first.
- R-268's own recipe is incomplete: ensureLocalAPI returns early on a present
  local_api block, so writing bootstrap.json is not enough — the controller
  serves the old token from controller.yaml across restarts.
- the agent-channel alarm never closes: the UP branch does not notify from an
  unseeded state, and the alarm's own remedy ("re-bootstrap") resets it.

Gate 0 complete: dataset planted in the Calibre library (coverage verified, not
assumed) with two Hungarian accented filenames; the comparator watched FAILING
three ways including an NFC->NFD rename that renders identically; off-site run
driven through the product's own button; restore point recorded by identity as
snapshot 41c830db, confirmed to carry all four files.

Also corrects the record: demo-hp's off-site tier is HEALTHY. Three hub surfaces
agreed it was absent and all three mislead — the panel showing 0 snapshots renders
the LOCAL tier, 162 KB rounds to 0.0 GB, and a two-day-old stuck event reads as
current. And the managed-update floor is live at 0.200.0, not 0.156.0.
2026-08-09 10:34:57 +02:00

15 KiB
Raw Blame History

REHEARSAL — the BYO reinstall walk (2026-08-09)

Status: IN PROGRESS. Pre-phase and Gate 0 are complete; STOP 1 passed. The walk (P1P7) has not started. This file is written before the destructive phase deliberately — a finding that exists only in a session that later crashes is a finding nobody has.

Venue: demo-hp (HP t740, felhom-host, guest 9201, customer demo-hp). Operator-approved at STOP 1. Driven from DooPlex. All times UTC unless marked; the host runs CEST (UTC+2).


1. Baselines — re-confirmed live on arrival, not taken from the spec

spec said live reading source
felhom-agent v0.128.0 @ 28ba8593b8 0.128.0 on demo-felhom, 0.127.0 on demo-hp; HEAD == origin/main == 28ba8593b8 felhom-agent --version on both nodes; git rev-parse
felhom-controller v0.210.0 @ c732fe1283 0.210.0 demo-felhom, 0.208.0 demo-hp; HEAD == origin/main == c732fe1283 hub /configs; git rev-parse
hub v0.101.0 @ 56f8aa611c 0.101.0 (deployed image tag matches) kubectl get deploy hub; page footer
Day-0 manifest golden 0.210.0 / agent 0.128.0 / min 0.127.0 all three already saved — golden 0.210.0 (b9f701fa…), agent 0.128.0 (c6eba73b…), min agent 0.127.0, wrapper 104db0a4… hub /configuration, selected <option> values

All three repos were clean and at origin/main before anything ran.

The fleet is not uniform, and the venue is the box that is behind — demo-hp runs agent 0.127.0 and controller 0.208.0 against a manifest vouching 0.128.0/0.210.0. That is an argument for the venue: the reinstall genuinely exercises the manifest rather than re-installing what is already there.


2. Venue comparison — the fidelity table that decided it

The runbook's criterion is "the box whose current state most resembles what the tester will have".

criterion demo-felhom (N100) demo-hp (t740)
customer apps deployed 1 — opengist 3 — privatebin, opengist, calibre-web
enrolled user-data drive none registered. /mnt/felhom-drives empty; controller logs [WARN] Storage paths: no storage paths registered /mnt/felhom-drives/Felhom-Share + NVMe enrolled 2026-07-22; one registered storage path /mnt/sys_drive
off-site tier has actually run NOoffsite.state=needs_credential, no run has ever succeeded YES — 18 restic snapshots, daily, unbroken
escrow / recovery ceremony present, generation 2 present, generation 2
install shape mkimage loader, Secure Boot OFF (firmware workaround) shim loader, Secure Boot ENABLED — the customer shape
collateral on the box none drill-r50 (VM 300); leftover c11-scratch storage

Chosen: demo-hp. It wins every fidelity criterion that distinguishes them.

drill-r50 is not at risk, and this was checked rather than assumed: VM 300 is not in the felhom pool (pvesh get /pools/felhom → members [9201]), its disks are on local-lvm, and --uninstall removes no storage definitions and no non-pool guest — its own end-of-teardown statement says the enrolled drives are "unmounted only, NEVER wiped" and that it never deletes PBS backups, hub records or escrow blobs.

Correction recorded, because it was part of the comparison put in front of the operator: the enrolled-drive row was first reported as "real files on an enrolled drive". Felhom-Share is empty — the share exists and is enrolled, but holds no files. demo-hp still wins the row (demo-hp has a registered storage path; demo-felhom has none), on a narrower margin than first stated.


3. Pre-phase — R-268, the leaked per-guest local-API token

Done first and independently, on demo-felhom guest 9201, as the runbook required. No token value reached stdout at any point: old value captured file→file from bootstrap.json, new value minted into a file, both shredded at the end.

3.1 The rotation, and its proof

step old token new token
before rotation HTTP 200
bogus token (negative control) HTTP 401
after the on-disk rotation, before any reload HTTP 200see 3.2 200
after that lookup forced a reload HTTP 401
after systemctl restart felhom-agent HTTP 401 HTTP 200

Positive observable for the controller half: local-api: channel up (agent 169.254.253.1:8443) — guest 9201, 3 mount(s) visible, enumerating mp8/mp9/mp0 — data only the agent can supply. An absent error would not have been evidence.

R-268 is CLOSED. The old token is refused; the new one works; the controller's channel is up.

3.2 FINDING — an out-of-process token rotation does not revoke the old token

localapi.TokenStore.Mint's doc comment states "last-write wins — any previous token for this guest is revoked". Across processes that is false until something else forces a reload. The long-lived agent serves Lookup from an in-memory index and re-reads the store only on a miss (the B3 reload-on-miss optimisation). A superseded token is still a direct map hit, so it authorises.

Red-proved twice. A temporary unit probe (Scenario C with the lookup order reversed — present the rotated-out token first) fails on the claim; the shipped TestTokenStore_ReloadOnMiss_RemintCoherence passes only because it looks up the NEW token first, which is what triggers the reload that then evicts the old hash. Then proved on real hardware: the row marked ← above is a live 200 from a token that the store on disk had already superseded.

This is the CLAUDE.md case exactly — a comment asserting an invariant with a test that appears to pin it and does not. The honest fix is either a test that pins the reversed order, or an eviction that does not depend on an unrelated lookup missing first.

3.3 FINDING — R-268's own stated rotation recipe is incomplete

The register row says the new plaintext "must also be written into the guest's /etc/felhom-bootstrap/bootstrap.json or the in-guest controller loses its agent access". Writing bootstrap.json is not sufficient. bootstrap.ensureLocalAPI returns early when cfg.LocalAPI.Endpoint != "" — it fills an absent block and never refreshes a present one — so after a rotation the controller keeps serving the old token from its own controller.yaml across a restart. Proved live: two controller restarts, still HTTP 401. The rotation only completed once local_api.token was written into controller.yaml itself.

The neighbouring DetectEndpointDrift compares the endpoint and deliberately does not compare the token ("a token mismatch is a different failure"). So this shape is known to be unmodelled, and R-78 ("which file is authoritative") is its parent.

3.4 FINDING — the agent-channel alarm never closes, and its own prescribed remedy is why

channelhealth alerted correctly and promptly: agent_channel_unauthorized, no debounce, pushed to the hub — "agent rejected the controller token (HTTP 401) — token stale/rotated (re-bootstrap)".

The channel then recovered, and no recovery event was ever sent. In Checker.Check's UP branch the notify is guarded by prev != "" && prev != "up", and a controller restart resets state to "". The alert's own instruction is to re-bootstrap — i.e. restart the controller — so following the alarm's advice structurally guarantees the recovery is silent. The down side is deliberately asymmetric (F2: a born-down channel alerts on cycle 1); the up side is not.

Operator-visible result on the hub: two agent_channel_unauthorized errors, one sent and one suppressed by cooldown, and nothing afterwards. The customer dashboard is fine — SetDashboard reflects current state every cycle. It is the operator's trail that ends on "down".


4. Gate 0

4.1 The recovery code — CONFIRMED IN THE OPERATOR'S HANDS (STOP 1)

Not printed, not logged, not requested. It will be supplied at the screen at STOP 3.

4.2 The dataset — planted and fingerprinted

Planted at /var/lib/felhom/sys_drive/felhom-data/userdata/media/books/rehearsal-2026-08-09/ — inside the Calibre library, which is real app-owned customer data and is demonstrably carried off-site (the path appears in every felhom-offbox,calibre-web snapshot). Chosen after checking coverage rather than assuming it: the per-app tier-1 unit carries only the named docker volume, so a file dropped in an arbitrary place would not have travelled.

sha256 (16) bytes name name bytes (hex)
54b773c46bbfd994… 3 145 728 binary-3mb.bin 62696e6172792d336d622e62696e
52a5c5ebfcac247f… 59 árvíztűrő-tükörfúrógép.txt c3a17276c3ad7a74c5b172c5912d74c3bc6bc3b67266c3ba72c3b367c3a9702e747874
15d2024dfc224162… 25 nested/őszibarack.md 6e65737465642fc591737a6962617261636b2e6d64
924497918e55fe6d… 21 plain.txt 706c61696e2e747874

Manifest: documentation/audits/evidence/ → recorded in the session scratchpad as GATE0-before-manifest.json. The manifest keys on raw name bytes, not rendered text.

4.3 The positive control — the comparator was watched failing, three ways

control expected result
identical copy PASS PASS, exit 0
one bit flipped at byte 1 500 000 of the 3 MB binary FAIL FAIL — named the file and both sha256s
NFC→NFD rename of the accented name (renders identically) FAIL FAIL — caught as MISSING+EXTRA on the name bytes
one file deleted FAIL FAIL — named the missing file

The NFD case is the one that matters: a comparison of rendered filenames would have passed it. Every altered copy was discarded after the control ran.

4.4 The off-site tier — has run since the dataset was planted

Triggered through the product's own dashboard button (POST /backup/offbox/run → flash „A távoli mentés elindult"), not by invoking restic by hand.

[offbox] backup run started (3 app(s) toggled)
[offbox] pre-push dump leg completed in 7.329s — snapshot pair is coherent
[offbox] backed up calibre-web (…/backups/primary/calibre-web, 1 mandatory path(s))
[offbox] backup OK: 3 app(s) backed up, 18 snapshot(s), 1m22s

The restore point, by identity — never by size:

snapshot time (UTC) tag paths
41c830db 2026-08-09 08:30:38 felhom-offbox,calibre-web …/backups/primary/calibre-web + …/userdata/media/books
9e38b84c 2026-08-09 08:30:49 felhom-offbox,opengist …/backups/primary/opengist
78b93f04 2026-08-09 08:30:53 felhom-offbox,privatebin …/backups/primary/privatebin

restic ls 41c830db confirms all four planted files are present, both accented filenames included.

4.5 The box, captured

GATE0-demo-hp-before.txt (282 lines): agent version, pveversion, pct/qm lists, pool membership, pvesm status, storage.cfg, df, lsblk with serials, the agent unit + service user, sudoers, the pveum users/tokens/roles/ACL, the guest hook, the WireGuard tunnel, the OOB belt, the agent state dir, the install state file, PBS snapshots, local vzdump archives, and the enrolled-drive tree.


5. Findings so far, ranked by what they cost the person in front of you

Nothing yet is rank 1 or 2 — the walk has not started.

Rank 3 — misleads

  • F-1 · A rotated-out local-API token still authorises (§3.2). A customer would do nothing — they cannot see it. An operator rotating a leaked token would believe it revoked when it is not. The shipped test passes for the wrong reason.
  • F-2 · R-268's rotation recipe is incomplete (§3.3). Whoever follows it gets a box whose agent channel is dead across restarts, with a correct-sounding alarm and no hint that controller.yaml is the file that matters.
  • F-3 · The agent-channel alarm never closes (§3.4). The operator fixes it as instructed and is never told it worked; the hub's last word stays "down".
  • F-4 · Three hub surfaces jointly misrepresent a healthy off-site tier as absent. The customer page reads Snapshots 0 / Repo Size 0 MB / Integrity Unknown (that panel renders the local tier, while the healthy offsite object — snapshot_count: 18, last_status: ok — sits unrendered in the same report); the Offsite page shows 0.0 GB (true, but a 162 KB repo rounds to nothing); and a stale offsite_delivery_stuck event from 2026-08-07 reads as current. Three independent surfaces agreeing on a wrong picture is how a working backup gets "fixed". This one caught me — it produced a wrong statement to the operator at STOP 1, corrected before it changed anything.
  • F-5 · There is no operator-triggerable off-site backup. The only route is the customer's dashboard button; signed_jobs needs a signing key the hub does not hold. Sibling of R-177.

Rank 4 — cosmetic / hygiene

  • F-6 · Leave-behinds from earlier sessions, found while capturing state:
    • demo-hp host: storage c11-scratch (Campaign 11), and felhom-backup:…vzdump-lxc-9100-2026_08_03… for a guest that no longer exists;
    • demo-hp guest 9201 /root: .dpw (13 bytes), .h, .sec.html, all 2026-08-07 16:08;
    • demo-felhom guest 9201: /root/r204-backup/offbox.orig.
  • F-7 · The managed-update floor is 0.200.0, not 0.156.0 — the runbook's §8.3 premise is stale. Confirmed twice: hub /configuration reads "Effective floor v0.200.0 — source: DB (hub_settings); env fallback would be v0.120.0", and both boxes log settle-gate: GO — at/above floor 0.200.0. The ruling's substance still needs recording; its number must not be written down as 0.156.0.

Not a finding, checked and cleared

  • The two userdata trees under sys_drive are the same directory surfaced twice, not a backed-up copy beside an unprotected one. Checked against calibre-web's actual bind mounts.

6. Steps taken off-path, and what they cost

Rule 2 (no shell after the install command returns) does not bind yet — the walk has not begun. These are Gate 0 preparation acts by the operator, recorded because they are mutations:

  1. R-268 rotation on demo-felhom — token store appended, bootstrap.json and controller.yaml rewritten, agent and controller restarted. Required by the runbook's pre-phase. Not the venue.
  2. demo-hp's dashboard password was re-set to the value in the operator's credentials file. The customer-owned password was not known to this session, and no operator-side route to the off-site button exists (F-5). The prior password_hash is preserved at /root/.orighash and /root/settings.json.rehearsal-orig in guest 9201; claimed was left true. Done on operator instruction. Cost to fidelity: none for the walk — P1 destroys this guest, and P4 re-claims with a password the operator chooses.
  3. The off-site run was started by a script pressing the dashboard's own endpoint, with a real session and a real CSRF token, rather than by a person clicking. The server path is identical; only the click is synthetic.

7. What happens next

P1 — remove Felhom from demo-hp with --uninstall, then measure what it leaves behind. Nothing in §1–§4 is repeated after that point; the "before" is fixed as of this file.