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:
@@ -2,119 +2,79 @@
|
||||
|
||||
> **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.65.0 — PBS DR storage visibility (ep0 `usage` op) + Offsite tab split (Restic / PBS DR) + dual dashboard gauges (R-5) — 2026-07-17
|
||||
## Hub v0.66.0 — Customer self-bind (R-27 slice 1) — 2026-07-17
|
||||
|
||||
### 1. Baselines used
|
||||
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**.
|
||||
|
||||
- **felhom.eu** @ `3588a31` (v0.64.0 REPORT), clean on `main`, local == origin re-confirmed.
|
||||
- **Hub** v0.64.0 → **v0.65.0**. Manifest `felhom-hub:0.64.0` → `:0.65.0`.
|
||||
- **`scripts/felhom-tenantsync.sh`** v1.1.0 → **v1.2.0** (adds the read-only `usage` op).
|
||||
- READ-ONLY against ep0 and Hetzner throughout (no mutation, no admin token; the usage op is a pure `df`).
|
||||
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*.
|
||||
|
||||
### 2. Phase-0 probe (the gate — PASSED)
|
||||
### Components
|
||||
|
||||
On ep0 (`felhom-hetzner`, root SSH, PBS **4.2.3**): `proxmox-backup-manager datastore usage` does not exist
|
||||
in PBS 4.2, so `df` on the datastore path is the authoritative read-only source. `datastore list
|
||||
--output-format json` → `felhom-offsite` path `/srv/pbs-felhom`; `df -B1 --output=size,used,avail /srv/pbs-felhom`:
|
||||
| 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 |
|
||||
|
||||
```
|
||||
1B-blocks Used Avail
|
||||
39990112256 7628091392 30686175232
|
||||
```
|
||||
### Security properties (spec §9)
|
||||
|
||||
All **bytes** (total 37.2 GiB / used 7.1 GiB → ~19%), read-only, in the existing sudo context, no admin
|
||||
token. Gate PASSED → the op was built (Option A). The v1.2.0 op emits exactly these as
|
||||
`{"status":"ok","total","used","avail"}` — verified against the real ep0 before writing the harness.
|
||||
- **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).
|
||||
|
||||
### 3. Files created / modified
|
||||
### GC verdict (spec §3)
|
||||
|
||||
**Code + script + runbook (commit `7f11cfb`):**
|
||||
- `scripts/felhom-tenantsync.sh` → v1.2.0 (read-only `usage` op) `+ scripts/tenantsync-usage-harness.sh` (new Group-A test).
|
||||
- `hub/internal/tenantsync/client.go` — `Usage()` + `BoxUsage` + `ErrUsageUnsupported` + response fields `+ client_test.go`.
|
||||
- `hub/internal/monitor/pbsdr_box.go` (new) `+ _test.go` — `PBSDRBoxChecker` (usageReader seam, throttle, 3 states, bands).
|
||||
- `hub/internal/notify/dispatcher_test.go` — the pbsdr-box operator-only test.
|
||||
- `hub/cmd/hub/main.go` — `Alerting.PBSDRBoxFill*` config; checker construction with the tenantsync client; 60 s sweep; `SetPBSDRBox`.
|
||||
- `hub/internal/web/{server.go,offsite.go,pbsdr_box.go(new),pbsdr_box_render_test.go(new),offsite_box_render_test.go,render_test.go}` — PBS view/tile, tab wiring, two-gauge payload, render tests.
|
||||
- `hub/internal/web/templates/{offsite.html,dashboard.html,style.css}` — Restic/PBS-DR tabs, two gauges, gauge container CSS.
|
||||
- `documentation/runbooks/offsite-endpoint.md` §10 — v1.2.0 install steps (no sudoers/authorized_keys change).
|
||||
- `hub/CHANGELOG.md`.
|
||||
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).
|
||||
|
||||
**Docs (commit `a9cd308`):** REPORT (this), CONTEXT, capability-map, ROADMAP.
|
||||
**Manifest (commit `ab91e49`):** `manifests/hub.yaml` → `:0.65.0`.
|
||||
### Verification
|
||||
|
||||
### 4. Test results + red-proof outcomes
|
||||
- **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`).
|
||||
|
||||
10 Go tests + a bash harness, all green:
|
||||
### Live validation
|
||||
|
||||
| Test | Group | Asserts |
|
||||
|------|-------|---------|
|
||||
| `TestUsage_Op` | B | usage op parses total/used/avail (bytes) |
|
||||
| `TestUsage_UnknownOpTypedUnsupported` | B | ep0 ≤ v1.1.0 `unknown op` → typed `ErrUsageUnsupported` |
|
||||
| `TestPBSDRBox_Throttle` | C1 | ≈4 usage calls over an hour of 60 s sweeps (not ≈60) |
|
||||
| `TestPBSDRBox_FillBands` | C2/C3 | 75→no; 82→warn; 85 in-band→no re-emit; 92→crit; 70→re-arm; re-breach→emit; scope "pbsdr-box" |
|
||||
| `TestPBSDRBox_Unavailable` | C4 | ErrUsageUnsupported → "unavailable" state, NO alert, no band |
|
||||
| `TestPBSDRBox_DegradedKeepsLast` | C5 | exec error → degraded, keeps last values, no band transition, no emit |
|
||||
| `TestProcessEvent_PBSDRBoxScopeOperatorOnly` | C3 | a pbsdr-box event → operator channel ONLY |
|
||||
| `TestPBSDRPanel_OK` / `_Unavailable` / `_NotConfigured` | D | panel renders ok / the pending-update message / not-configured |
|
||||
| `tenantsync-usage-harness.sh` | A | usage JSON + exit 0 + **zero mutation** (stub-logged) + provision regression |
|
||||
Filled in after deploy — see the CONTEXT.md live-state note.
|
||||
|
||||
**Red-proofs (run-fail-restore), all confirmed red then restored:**
|
||||
### Not in this slice (deferred)
|
||||
|
||||
| # | Removed / broken | Test | Result |
|
||||
|---|------------------|------|--------|
|
||||
| A | the usage op emits a mutation (`acl update`) | harness | FAILED ("usage ran mutations: … acl update …") → restored |
|
||||
| C-esc | the escalation-only guard | `PBSDRBox_FillBands` | FAILED ("same-band must NOT re-emit, got 2") → restored |
|
||||
| C-unavail | unavailable drives a fill band | `PBSDRBox_Unavailable` | FAILED ("must yield 'unavailable', got State:ok … critical") → restored |
|
||||
|
||||
Full suite: `go build ./... && go vet ./... && go test ./...` (17 packages) + `bash -n` + hub confirm gate —
|
||||
all green. Test count: +10 Go (+2 tenantsync, +4 monitor, +1 notify, +3 web) + 1 bash harness.
|
||||
|
||||
### 5. Deploy verification
|
||||
|
||||
Built `felhom-hub:0.65.0` on 180 (source `7f11cfb`). **ep0 updated to tenantsync v1.2.0 THIS session** (root
|
||||
SSH, `.bak-1.1.0` kept; `tr -d '\r'` → `bash -n` OK → `install -m0755`; NO sudoers/authorized_keys change).
|
||||
On-box verify: `echo '{"op":"usage"}' | /usr/local/bin/felhom-tenantsync` →
|
||||
`{"status":"ok","total":39990112256,"used":7628349440,"avail":30685917184}`; `fingerprint` regression OK.
|
||||
Manifest bump (`ab91e49`) → ArgoCD hard-refresh → sync → rollout succeeded; image `:0.65.0`. Startup log:
|
||||
|
||||
```
|
||||
[INFO] felhom-hub 0.65.0 starting
|
||||
[INFO] PBS DR tenantsync enabled (endpoint 167.233.158.164:22, user felhom-peersync; …)
|
||||
[INFO] PBS-DR box checker initialized: fill warn=80% crit=90%, refresh 15m0s
|
||||
```
|
||||
|
||||
### 6. Live-rendered values (the PROVEN-LIVE evidence)
|
||||
|
||||
Because ep0 was updated to v1.2.0, the PBS gauge shows **real** numbers. The hub UI is operator-password-gated
|
||||
(CC can't screenshot), so the in-cluster checker's first live poll is the proof — the exact numbers the PBS
|
||||
panel + gauge render:
|
||||
|
||||
```
|
||||
[INFO] PBS-DR box refreshed: 19.1% full (7.1 GB of 37.2 GB)
|
||||
```
|
||||
|
||||
Live-confirmed end-to-end (hub checker → tenantsync `usage` op → ep0 `df` → snapshot): **7.1 GB of 37.2 GB,
|
||||
19.1%** — matching the Phase-0 probe. The restic gauge (v0.64.0) continues to render its own live numbers.
|
||||
Both tabs (Restic / PBS DR) and both dashboard gauges are live; the panel-render is UNIT-verified
|
||||
(`TestPBSDRPanel_*`), the data proven live.
|
||||
|
||||
### 7. NOT yet live-validated
|
||||
|
||||
- **The UI render** (tabs + gauges) could not be screenshotted (hub UI password-gated). It is unit-verified
|
||||
(render tests) and the data behind both gauges is proven live (§6).
|
||||
- **The PBS fill alert leg** (`pbsdr_box_fill` operator email) is NOT fired live: the datastore is nominal
|
||||
(19.1%, far from 80/90%). Unit + red-proof verified; a live-fire would email Viktor (offered on demand).
|
||||
- **The graceful "unavailable" state** was exercised in unit tests, not live — ep0 was updated straight to
|
||||
v1.2.0, so the hub never saw the old-script path in production (the hub-independence property is verified
|
||||
by construction + `TestUsage_UnknownOpTypedUnsupported` + `TestPBSDRBox_Unavailable`).
|
||||
|
||||
### 8. Observations
|
||||
|
||||
- **Separate PBS threshold pair:** shipped as `Alerting.PBSDRBoxFill{Warn,Crit}Percent` defaulting to the
|
||||
same 80/90 as restic — independently tunable later without touching the restic policy. Whether Viktor
|
||||
wants a different PBS pair is his call (one-liner). No oversubscription concept for PBS (namespaces, not
|
||||
quotas) — fill only, as ruled.
|
||||
- **Dashboard decimal:** the gauges use `%.0f` (e.g. "19%"), so a near-empty box shows "0%" not "0.2%" —
|
||||
matches the restic tile's existing rounding. The panels show the finer detail.
|
||||
- The `usage` op is `df`-based because PBS 4.2 has no native `datastore usage` command; if a future PBS adds
|
||||
one with dedup-aware logical sizing, the op can switch source with no hub/client change (same JSON shape).
|
||||
- **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).
|
||||
|
||||
Reference in New Issue
Block a user