docs(report): N100 rehearsal S8 wrap
Overwrites REPORT.md per convention: evidence bundle manifest, the map rows flipped with citations, ROADMAP IDs assigned (R-30..R-39 + R-27c), the seven discrepancies found against the brief, and the remaining-to-first-invite line. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
This commit is contained in:
@@ -2,92 +2,149 @@
|
||||
|
||||
> **Overwrite** this file with a summary of the most recent task only (uniform with the other repos; not cumulative). The cumulative hub history lives in [hub/CHANGELOG.md](hub/CHANGELOG.md); the scripts history lives in [scripts/CHANGELOG.md](scripts/CHANGELOG.md).
|
||||
|
||||
## Hub v0.66.0 — Customer self-bind (R-27 slice 1) — 2026-07-17
|
||||
## N100 rehearsal S8 wrap — evidence, VALIDATION, map flips, ROADMAP, R-3 — 2026-07-18
|
||||
|
||||
A customer can now bind their **own** freshly-installed appliance without the operator. The operator
|
||||
sends a **7-day tokenized capability link** by email; the customer opens a **public, Hungarian**
|
||||
`/bind/<token>` page (no login — the URL token IS the capability), enters the **console pairing code**
|
||||
shown on the box screen plus their **retrieval passphrase**, and the hub stages the bind through the
|
||||
same `BindAppliance` the operator uses. The box's ~30 s appliance poll then delivers credentials and
|
||||
day-0 install proceeds. The controller and agent are **untouched**.
|
||||
**Class:** documentation/evidence run. **Repos touched:** `felhom.eu` only, docs only.
|
||||
**No code changed anywhere** — every finding is recorded and ranked, none fixed (campaign discipline).
|
||||
|
||||
Viktor's three rulings, honoured verbatim:
|
||||
1. **"Only their own visible"** → possession is proven by the console pairing code; **no appliance
|
||||
list is ever rendered** on any public surface.
|
||||
2. **First-box entry** → an operator-sent tokenized capability link over Hungarian email (claim-engine
|
||||
delivery pattern, a **sibling** sender in `web/`+`notify/` — deliberately NOT routed through the
|
||||
claim engine).
|
||||
3. **Lockout after 5 failed attempts** → the token locks; the page says *call support*.
|
||||
**Commit:** `aa9c08f` — *docs(rehearsal): N100 S8 wrap — VALIDATION, map flips, ROADMAP R-30..R-39,
|
||||
R-3 draft* (pushed to `main`; parent `bbaa844`).
|
||||
|
||||
### Components
|
||||
No CHANGELOG entry: `felhom.eu` keeps per-area changelogs (`hub/`, `scripts/`, `website/`) and this
|
||||
run touched none of those areas.
|
||||
|
||||
| Part | Area | What |
|
||||
|---|---|---|
|
||||
| 1 | store + api + bootstrap | `pairing_code` on `appliance_registrations`; additive `pairing_code` in the register response; stable across idempotent re-register; console banner (`felhom-bootstrap.sh`, ISO **v1.20.0**) |
|
||||
| 2 | store + notify + web | `selfbind_tokens` table (sha256-at-rest, single-active/customer, 5-attempt lock, one-shot); operator **"Send self-bind link"** button; Hungarian email (sibling sender); F1/F2 honesty |
|
||||
| 3 | web | public `/bind/<token>` page; **THE TRAP** auth+CSRF exemption via one `isPublicBindPath` predicate; two-factor unconditional compare; provenance event `customer_selfbind`; own rate limiter |
|
||||
| 4 | tests | Scenarios A–F + F1/F2 (9 tests) + **4 red-proofs** |
|
||||
| 5 | docs + deploy | this REPORT, CHANGELOGs, CONTEXT, capability-map, ROADMAP; build 0.66.0, manifest bump, sync; live validation |
|
||||
### What the run proved
|
||||
|
||||
### Security properties (spec §9)
|
||||
The complete final-product flow ran **end to end on real metal in one pass**: RESET → generic
|
||||
pairing ISO v1.20.0 → **customer self-bind** → credential delivery → day-0 → managed-floor lift →
|
||||
drive enrolment → escrow ceremony → offsite snapshots.
|
||||
|
||||
- **THE TRAP (§9.2):** exactly one new public prefix `/bind/`, exempted from operator auth and CSRF at
|
||||
the two gate sites `/login` occupies, through a **single** `isPublicBindPath` definition. Matched
|
||||
tightly — trailing slash (no `/bindsecret` sibling), the `http.ServeMux` `..`-cleans before the
|
||||
handler sees the path, and the handler rejects any token containing `/`. Scenario E asserts a public
|
||||
link renders logged-out while `/`, `/hosts`, `/customers/…`, `/configuration`, `/offsite` still
|
||||
redirect to `/login`; Scenario D is its companion.
|
||||
- **No oracle:** an unknown token folds into "expired" (no *was-this-real* signal); wrong code and
|
||||
wrong passphrase yield one byte-identical generic failure (both factors compared unconditionally
|
||||
before deciding); the page renders no appliance data in any state.
|
||||
- **Custody:** `sha256(token)` at rest, prefix-only in logs, raw token never in logs/events; the
|
||||
passphrase is never logged/echoed/persisted; only attempt COUNTS are logged (`attempt N/5 … <8hex>…`).
|
||||
- **No customer-login system** was built; the capability link is the whole auth model. A cross-site
|
||||
POST without both secrets only burns attempts (accepted + documented).
|
||||
| Leg | Wall-clock |
|
||||
|---|---|
|
||||
| Blank-metal boot → registered as an unclaimed appliance | **< 1 s** |
|
||||
| Bind → credential delivered | **26 s** |
|
||||
| Bind → controller running the CURRENT version | **2 min 44 s** |
|
||||
| Managed floor 0.143.0 → 0.145.0 (`initiated_by: auto-floor`) | **5 s**, unattended |
|
||||
| Escrow ceremony → offsite runs enabled | **12 s** |
|
||||
| Drive enrol (confirm → usable, incl. mkfs) | **30.3 s** |
|
||||
|
||||
### GC verdict (spec §3)
|
||||
**No post-bind leg stalled** — that is the immediacy row's real-onboarding proof.
|
||||
|
||||
There is **no appliance-staleness garbage collection** in the hub — `applianceStaleAfter` (7 d) is a
|
||||
DISPLAY badge only; `pruneAll` and `PurgeExpiredLogBundles` touch host reports and log bundles, not
|
||||
appliances or self-bind tokens. The **7-day token TTL stands alone** and needs no reaper: single-active
|
||||
per customer means at most one row per customer, a re-mint deletes the prior row, and an expired row
|
||||
simply reads as expired (no security or storage pressure).
|
||||
---
|
||||
|
||||
### Verification
|
||||
## Phase A — evidence bundle (completed before the box was unplugged)
|
||||
|
||||
- **Green gate:** `go build ./... && go vet ./... && go test ./...` — all hub packages pass (17 ok),
|
||||
including 9 new self-bind tests. `bash -n` clean on both ISO scripts. Hub confirm gate OK.
|
||||
- **Red-proofs (all confirmed red-then-green):**
|
||||
- lockout removed (`locked = false`) → **C1** red.
|
||||
- oracle introduced (distinct state on wrong code) → **B** red.
|
||||
- `/bind/` prefix widened (drop the slash) → **E** red (`/bindsecret` leaks past auth).
|
||||
- single-active DELETE dropped → **C4** red (the prior link still resolves).
|
||||
- **D2 regression evidence:** with an operator password configured, `/`, `/hosts`, `/customers/acme`,
|
||||
`/configuration`, `/offsite` all `302 → /login` through `RequireAuth`, while `/bind/<token>` renders
|
||||
`200` — proving the exemption is tight (`TestSelfBind_D_AuthGateIntact`, `TestSelfBind_E_TheTrap`).
|
||||
`180:~/n100-rehearsal/` — 984 KB, 10 files + `MANIFEST.md`:
|
||||
|
||||
### Live validation (hub 0.66.0 live on k3s, through the real `hub.felhom.eu` ingress, 2026-07-18)
|
||||
| File | Evidences |
|
||||
|---|---|
|
||||
| `bootstrap-journal.txt` | first boot 16:17:14 UTC, PAIRING mode, unclaimed registration, the 30 s poll loop and its **52 systemd FAILED lines**, day-0 SUCCESS 16:32:32 |
|
||||
| `host-state.txt` | `pct config 9201` (12288 MB), `pveversion -v`, `pvesm status` (**felhom-pbs 401/inactive**), `lsblk`, `blkid` |
|
||||
| `guest-docker.txt` | 11 containers, controller 0.145.0 healthy, image pin |
|
||||
| `controller-log-full.txt` / `controller-log.txt` | the whole controller life: auto-floor update, 16:55 config-refresh restart, escrow ceremony, orphan guard, both backup runs |
|
||||
| `agent-journal.txt` | agent 0.90.0 start, WG registration, storage-wipe gate pending→confirmed, format + enrol, PBS apply, and the repeating PBS verify-loop permission error |
|
||||
| `pbs-dr-state.txt` | the R-39 failure: storage.cfg stanza, `pvesm status`, credential metadata, direct token probe (401 everywhere), WG health, agent errors |
|
||||
| `restic-and-pool.txt` | 2 snapshots / **48.717 MiB** attributed, pool `/home` listing + `du` showing **1.4 G + 3.0 M unattributed** |
|
||||
| `hub-state.txt` | hosts row, both RESET ledger rows, the **unconsumed** PBS secret, self-bind token lifecycle, appliance registration, events timeline |
|
||||
| `hub-events-timeline.txt` | the 15-row demo-felhom event stream from 15:50 |
|
||||
| `timezones.txt` | the live-captured CEST/UTC split |
|
||||
|
||||
- **Public page renders logged-out, no oracle:** `GET /bind/<garbage>` → `200` rendering the Hungarian
|
||||
*"Doboz összekötése … érvénytelen vagy lejárt"* page (an unknown token folds into the generic
|
||||
expired state — no *was-this-real* signal). The `200` (not `500`) also **proves the `selfbind_tokens`
|
||||
table migrated live** — `SelfBindTokenByHash` queried it successfully (a missing table would 500).
|
||||
- **Auth gate intact (THE TRAP is tight live):** `GET /` and `GET /hosts` → `302 → /login`.
|
||||
- **CSRF exemption is `/bind/`-only, live:** `POST /bind/<garbage>` with **no** CSRF/session → `200`
|
||||
(exempt at gate site 1), renders the generic failure; the control `POST /customers/acme/block` with
|
||||
no CSRF → `302 → /login` (auth fires first — the exemption did not widen).
|
||||
- **Clean startup:** migrate() ran without error; `Listening on :8080`, all checkers seeded.
|
||||
Secrets were read only to run probes and are recorded as lengths and metadata, never as values.
|
||||
|
||||
**Pending (NOT-live, as anticipated):** the operator-minted full customer walk (the "Send self-bind
|
||||
link" button and the customer's real two-factor success are behind the operator password gate → CC
|
||||
cannot log in) and the new-ISO console pairing-code banner on a physically re-imaged box — both fold
|
||||
into the supervised rehearsal (R-1).
|
||||
**Not collected:** the operator gauge screenshots (Viktor's set) and the **PBS DR tab**, which
|
||||
remains uncaptured — and now matters more, because R-39 means it would have shown a fault. The hub
|
||||
UI is password-gated, so CC cannot capture it.
|
||||
|
||||
### Not in this slice (deferred)
|
||||
---
|
||||
|
||||
- **R-27b** — the controller's second-box flow (a dismissable "bind another box" prompt + bind-later
|
||||
under settings). Mechanism sketched; not built. The controller/agent were not touched.
|
||||
- **Multi-box per link** — one link binds one box (single-active, one-shot). Binding several boxes for
|
||||
one customer = repeated operator sends. Noted on the ROADMAP.
|
||||
- Attaching the appliance's stored SSH host key to the host record on bind (no clean hand-off surface
|
||||
today — same open item as slice C).
|
||||
## Phases B–E — what landed
|
||||
|
||||
- **Phase B** — `documentation/tests/VALIDATION-n100-rehearsal-2026-07-18.md`: run context, a
|
||||
**UTC-normalised** timeline (the host journal is CEST, everything else UTC — a 2 h trap), derived
|
||||
wall-clocks, per-ledger verdicts for S1–S8 plus ledgers 8 and 9, 12 findings, the not-exercised
|
||||
list, and the discrepancy table.
|
||||
- **Phase C** — 10 capability-map rows annotated, each citing the Phase-B doc.
|
||||
- **Phase D** — 5 items collapsed, 11 new ranked items, 1 pre-invite checklist.
|
||||
- **Phase E** — Viktor's draft (which landed as `runbooks/RUNBOOK-onboarding-draft-v3.md`, not the
|
||||
filename the brief predicted) refined: all four `[REFINE]` slots filled, self-bind made the default
|
||||
bind path, the measured wall-clock table inlined, interim workarounds for R-31/R-36/R-39 added, and
|
||||
C6→C7 marked as the single unexecuted step. **Committed as DRAFT** — it graduates when C7 has
|
||||
evidence.
|
||||
|
||||
### Capability-map rows flipped
|
||||
|
||||
| Row | Change |
|
||||
|---|---|
|
||||
| Bare-metal Felhom ISO | PARTIAL → **PROVEN-LIVE** — F1 closed on physical hardware |
|
||||
| Customer self-bind (slice 1) | IMPLEMENTED → **PROVEN-LIVE** — `customer_selfbind`, 0 failed attempts |
|
||||
| Guest RAM resize (R-24) | IMPLEMENTED → **PROVEN-LIVE** — shrink **and** grow, live cgroup |
|
||||
| Customer RESET | + two real firings, verified external teardown, orphan-guard interaction |
|
||||
| Escrow ceremony | + first live customer-facing wizard firing |
|
||||
| Immediacy | "real-onboarding proof pending" **cleared** |
|
||||
| Publish train / floor | + box-side floor lift proven on a fresh install |
|
||||
| Customer claim | + R-4 gmail half (Inbox under `p=quarantine`) |
|
||||
| Offsite orphan guard | staged live leg **fired on its own** |
|
||||
| **DR tier by default** | candidate PROVEN-LIVE upgrade **WITHDRAWN** (R-39) |
|
||||
|
||||
**Not flipped, as instructed:** customer-performs-restore (still MISSING), BYO, DLNA, multi-user.
|
||||
|
||||
### ROADMAP IDs assigned
|
||||
|
||||
| Rank | IDs |
|
||||
|---|---|
|
||||
| P2-HIGH | **R-39** PBS DR applied-but-dead · **R-30** liveness from the wait channel · **R-31** async offsite + status card · **R-32** RESET base-dir purge + orphan-card honesty + unattributed-bytes gauge · **R-33** bootstrap console quiet-poll |
|
||||
| P2 | **R-34** backup data lifecycle · **R-35** config-apply session survival · **R-36** post-RESET re-enroll offsite prompt · **R-27c** console-passphrase bind |
|
||||
| P3 | **R-37** post-RESET health card · **R-38** installer GRUB slice |
|
||||
|
||||
Collapsed: **R-1** (appliance half done; Peti half survives), **R-21** (physically closed), **R-24**,
|
||||
**R-27** slice 1, **R-4** (gmail half). No existing item renumbered.
|
||||
|
||||
---
|
||||
|
||||
## Discrepancies found vs the brief (live evidence won in each case)
|
||||
|
||||
1. **"expect ~11675 MB" vs `pct config` 12288 MB.** Both correct at different times: F5 auto-sized to
|
||||
11675, then the customer shrank to 8192 (16:50:22) and grew to 12288 (17:02:17). Not a fault —
|
||||
it is what completes ledger 9.
|
||||
2. **"2.6 GB pool vs ~0 attributed"** → measured **1.4 G + 3.0 M** unattributed vs **49 M**
|
||||
attributed, across **two** `.orphaned-*` directories, not one. The R-32 ruling is unaffected; the
|
||||
numbers are.
|
||||
3. **"felhom-pbs entry present"** → present but **401 / inactive**. The brief treated the descriptor
|
||||
as done; it is provisioned but dead. → R-39.
|
||||
4. **"RESET ×2 … second + third live firings"** → the ledger holds **exactly two rows, both
|
||||
tonight**, on **two different customers** (`demo-vm-felhom` 15:49:57, `demo-felhom` 16:08:51).
|
||||
Recorded as tonight's two firings rather than as ordinals.
|
||||
5. **"keep R-29 if the number is free"** → **it is not free.** R-29 was committed earlier the same day
|
||||
(`2eccda4`) for the design-v2 gate rot — which *is* the brief's item 5. So the liveness item took
|
||||
**R-30**, and **no new ID was minted for the red-gate item**.
|
||||
6. **Tip and filename:** the brief's tip `a03e142` is two commits behind `bbaa844`, and Viktor's R-3
|
||||
draft landed as `RUNBOOK-onboarding-draft-v3.md`, not `DRAFT-R3-onboarding-runbook.md`.
|
||||
7. **R-39 has no counterpart in the brief at all** — it was found by probing the box, not by working
|
||||
the finding list. Its P2-HIGH rank is therefore **provisional pending Viktor**.
|
||||
|
||||
### The one finding worth reading twice
|
||||
|
||||
**R-39 — the DR tier can report `applied` and be dead simultaneously.** The descriptor
|
||||
auto-provisioned and the agent converged `state=applied` at 16:45:53. But `pvesm status` returns
|
||||
`401 Unauthorized`/`inactive`, and a direct probe with the stored credential 401s on **every**
|
||||
endpoint including `/version` while the WG transport is healthy (handshake 9 s, 27.9 ms RTT) — so it
|
||||
is authentication, not ACL scope. The hub minted a **second** token secret at 16:47:52, two minutes
|
||||
*after* the agent applied the first, and its `consumed_at` is **still NULL**; the converged state
|
||||
machine will not re-apply, and the agent's 15-minute verify loop **cannot read the credential to
|
||||
notice**, because it reads directly as non-root a file it writes through a root sudo wrapper. A
|
||||
silent DR failure surfaces first at a restore, which is the worst possible moment — hence the
|
||||
withdrawn map upgrade and the interim `pvesm status` check now in R-3 step E1.
|
||||
|
||||
---
|
||||
|
||||
## Remaining to first invite
|
||||
|
||||
**freemail.hu test-send (Viktor, remote-doable) + C6 customer-restore evidence (remote-doable on the
|
||||
reborn box from vacation — the dashboard is remote) + R-11 rulings (contact channel, agreement,
|
||||
thresholds) + golden 0.145.x rebuild.**
|
||||
|
||||
R-39 is not on that line because it was discovered after the brief was written; if Viktor confirms
|
||||
the P2-HIGH rank, it belongs there too — a first tester whose DR tier is silently dead is exactly the
|
||||
scenario the alpha exists to avoid.
|
||||
|
||||
Viktor's polish/tweak notes arrive separately and are neither pre-empted nor invented here.
|
||||
|
||||
Reference in New Issue
Block a user