hub v0.66.0 + ISO v1.20.0: customer self-bind (R-27 slice 1)
Let a customer bind their own freshly-installed appliance without the operator: operator "Send self-bind link" mints a 7-day tokenized capability link, emailed (Hungarian, sibling sender) to the customer, who opens a public /bind/<token> page and proves two factors — the console pairing code shown on the box screen + their retrieval passphrase — and the hub stages the bind via the same BindAppliance (provenance customer_selfbind). The box's ~30s appliance poll delivers. Viktor's three rulings verbatim: console pairing code (no appliance list ever rendered), operator-sent tokenized link, 5-attempt lockout -> "call support". Wrong code == wrong passphrase (one generic failure, no oracle, both factors compared unconditionally); expiry falls back to operator-bind unchanged. THE TRAP: one public prefix /bind/, exempt from auth+CSRF at both /login gate sites via a single isPublicBindPath predicate (tight trailing-slash match; ServeMux ..-cleans; handler rejects '/' in token). 9 tests (Scenarios A-F + F1/F2); 4 red-proofs verified red-then-green (lockout, oracle, widened-prefix, single-active). GC verdict: no appliance GC -> the 7-day TTL stands alone. Controller/agent untouched; R-27b deferred. Green: full hub build/vet/test (17 ok) + bash -n + hub confirm gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017qDiBqKKQ5vPB5fXBqu7Kp
This commit is contained in:
@@ -1,5 +1,58 @@
|
||||
# Felhom Hub — Changelog
|
||||
|
||||
## v0.66.0 — Customer self-bind (R-27 slice 1): tokenized capability link + public two-factor `/bind/` page (2026-07-17)
|
||||
|
||||
Lets a customer bind their OWN freshly-installed appliance without the operator. Until now every
|
||||
box booted from the universal secret-free ISO (R-21 slice C) had to be bound by the operator on the
|
||||
Hosts page; this adds the self-service path. **Viktor's three rulings, each honoured verbatim:**
|
||||
(a) *"only their own visible"* → the customer proves possession with the **console pairing code**
|
||||
shown on the box screen — **no appliance list is ever rendered** on any public surface; (b) *first-box
|
||||
entry* → an **operator-sent 7-day tokenized capability link** over Hungarian email (the claim-engine
|
||||
delivery pattern, a sibling sender — NOT routed through the claim engine); (c) **lockout after 5
|
||||
failed attempts** → the token locks and the page says *"call support"*. Wrong code and wrong
|
||||
passphrase produce **one identical generic failure** (no oracle); an expired link falls back to
|
||||
operator-bind, unchanged. Does **not** touch the controller or agent. Green: `go build/vet/test`
|
||||
(full hub suite + 9 new self-bind tests), all 4 red-proofs verified red-then-green, hub confirm gate.
|
||||
|
||||
- **Pairing code (Part 1).** `POST /api/v1/appliance/register` now returns an additive `pairing_code`
|
||||
(6 chars, ambiguity-free alphabet, `ABC-234` display) minted once at first registration and stable
|
||||
across the idempotent re-register/upsert (backfilled if a pre-existing row had none). Persisted on
|
||||
`appliance_registrations.pairing_code`; shown in the operator Hosts "Unclaimed appliances" table.
|
||||
The bootstrap (`felhom-bootstrap.sh`, ISO v1.20.0) parses it and prints a Hungarian **console
|
||||
banner** to `/dev/console` so the customer can read it off the screen. Old ISOs ignore the field;
|
||||
an old hub omits it and the banner prints nothing — additive both ways.
|
||||
- **Capability token (Part 2).** New `selfbind_tokens` table: `sha256(token)` at rest (never the
|
||||
token), **single-active per customer** (a re-mint deletes the prior row in one tx — an old link dies
|
||||
the instant a new one is sent), `attempts` locking at 5, one-shot `consumed_at`, `emailed_at`
|
||||
honesty. Operator **"Send self-bind link"** button on the customer Setup tab
|
||||
(`POST /customers/{id}/selfbind-link`) mints a 256-bit token and emails
|
||||
`https://hub.felhom.eu/bind/<token>` (Hungarian, adult tone, no emoji, names both factors + the
|
||||
7-day + 5-attempt limits). **F1** (no registered email → nothing minted, LOUD flash) and **F2**
|
||||
(email send fails → the just-minted token is deleted, not left silently live) are both honest.
|
||||
- **Public bind page (Part 3) — THE TRAP (§9.2).** One new public prefix `/bind/`, exempted from
|
||||
operator auth AND CSRF at the two gate sites the `/login` exemption occupies, via a **single**
|
||||
predicate `isPublicBindPath` (matched tightly: trailing slash → no sibling like `/bindsecret`; the
|
||||
ServeMux `..`-cleans before we see the path → no traversal reach; the handler also rejects a token
|
||||
containing `/`). `GET` renders form/consumed/locked/expired; `POST` normalizes both inputs, compares
|
||||
**both factors unconditionally** (constant-time passphrase vs the customer's retrieval passphrase;
|
||||
the ONE bindable appliance carrying the console code), then decides — identical generic failure
|
||||
either way. On success: the **same `BindAppliance`** the operator uses, a provenance event with
|
||||
source `customer_selfbind`, one-shot consume, and *"A doboz kb. egy percen belül folytatja a
|
||||
telepítést."* The box's ~30 s appliance poll picks up the delivery. Own per-IP rate limiter; the
|
||||
page is self-contained (it cannot link `/style.css`, which is itself operator-gated). No hub
|
||||
customer-login/session was built — the URL capability token IS the auth model; a cross-site POST
|
||||
without both secrets only burns attempts (accepted + documented).
|
||||
- **Tests + red-proofs (Part 4).** Scenarios A–F + F1/F2 (9 tests). The **4 red-proofs** were each
|
||||
applied and confirmed to turn exactly their scenario red, then reverted green: lockout removed → C1;
|
||||
oracle introduced → B; `/bind/` prefix widened (drop the slash) → E (and D); single-active DELETE
|
||||
dropped → C4. The passphrase is never logged/echoed/persisted; only attempt COUNTS are logged
|
||||
(`self-bind attempt N/5 … token <8hex>…`); the raw link token never enters logs or events.
|
||||
- **GC verdict (spec §3):** there is **no appliance-staleness GC** in the hub (`applianceStaleAfter`
|
||||
is a DISPLAY badge only; `pruneAll`/`PurgeExpiredLogBundles` touch reports/log-bundles, not
|
||||
appliances or selfbind tokens). The 7-day token TTL therefore stands alone and needs no reaper —
|
||||
single-active-per-customer means at most one row per customer, superseded rows are deleted on
|
||||
re-mint, and an expired row simply reads as expired (no security or storage pressure).
|
||||
|
||||
## v0.65.0 — PBS DR storage visibility (ep0 `usage` op) + Offsite tab split (Restic / PBS DR) + dual dashboard gauges (R-5) (2026-07-17)
|
||||
|
||||
Makes the **PBS DR** storage visible like the restic pool box already is (v0.64.0), the two clearly
|
||||
|
||||
Reference in New Issue
Block a user