From 592818492cf9eb377b2571534215526dc287b20d Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Fri, 17 Jul 2026 23:56:53 +0200 Subject: [PATCH] hub v0.66.0 + ISO v1.20.0: customer self-bind (R-27 slice 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ 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) Claude-Session: https://claude.ai/code/session_017qDiBqKKQ5vPB5fXBqu7Kp --- CONTEXT.md | 21 ++ REPORT.md | 164 ++++----- .../architecture/00-capability-map.md | 3 +- documentation/backlog/ROADMAP.md | 3 +- hub/CHANGELOG.md | 53 +++ hub/cmd/hub/main.go | 3 +- hub/internal/api/appliance.go | 15 +- hub/internal/configgen/configgen.go | 54 +++ hub/internal/notify/dispatcher.go | 21 ++ hub/internal/notify/templates.go | 29 ++ hub/internal/store/appliance.go | 86 +++-- hub/internal/store/selfbind.go | 171 +++++++++ hub/internal/store/store.go | 22 ++ hub/internal/web/appliances.go | 9 +- hub/internal/web/appliances_test.go | 2 +- hub/internal/web/selfbind.go | 284 +++++++++++++++ hub/internal/web/selfbind_mint.go | 94 +++++ hub/internal/web/selfbind_test.go | 325 ++++++++++++++++++ hub/internal/web/server.go | 29 +- .../web/templates/customer_unified.html | 12 + hub/internal/web/templates/hosts.html | 3 +- scripts/CHANGELOG.md | 12 + scripts/iso/build-felhom-iso.sh | 2 +- scripts/iso/felhom-bootstrap.sh | 25 +- 24 files changed, 1304 insertions(+), 138 deletions(-) create mode 100644 hub/internal/store/selfbind.go create mode 100644 hub/internal/web/selfbind.go create mode 100644 hub/internal/web/selfbind_mint.go create mode 100644 hub/internal/web/selfbind_test.go diff --git a/CONTEXT.md b/CONTEXT.md index fb12de8..2f5830c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -3,6 +3,27 @@ > Created with the REUSE.md rollout (2026-07-03). Authoritative history: `hub/CHANGELOG.md` (hub), > `website/CHANGELOG.md`, `scripts/CHANGELOG.md`; end-of-task detail in `REPORT.md`. +- **2026-07-17 — CUSTOMER SELF-BIND shipped (hub v0.66.0 + ISO scripts v1.20.0, R-27 slice 1).** A + customer binds their OWN freshly-installed appliance without the operator. Operator clicks **"Send + self-bind link"** on the customer Setup tab → hub mints a **7-day tokenized capability link** → + emails it (Hungarian, sibling sender, NOT via the claim engine) → customer opens the **public, + Hungarian `/bind/`** page (no login — the URL token IS the auth) → enters the **console + pairing code** (shown on the box screen) + their **retrieval passphrase** → hub stages the bind via + the same `BindAppliance` (provenance `customer_selfbind`) → the box's ~30 s appliance poll delivers. + **Viktor's three rulings, verbatim:** (a) *"only their own visible"* → console pairing code, **no + appliance list ever rendered**; (b) *first-box entry* → operator-sent tokenized capability link over + Hungarian email; (c) *lockout after 5 failed attempts* → token locks, "call support". Wrong code and + wrong passphrase = **one identical generic failure** (no oracle; both factors compared + unconditionally); expiry falls back to operator-bind unchanged. **THE TRAP (§9.2):** one public + prefix `/bind/`, exempted from auth+CSRF at both `/login` gate sites via a single `isPublicBindPath` + predicate (tight trailing-slash match; ServeMux `..`-cleans; handler rejects `/` in the token) — 4 + red-proofs verified red-then-green (lockout→C1, oracle→B, widened-prefix→E, single-active→C4). + **GC verdict:** no appliance-staleness GC exists (`applianceStaleAfter` is a display badge only) → the + 7-day token TTL stands alone, single-active-per-customer, no reaper needed. **Controller/agent + untouched; R-27b (controller second-box dismissable prompt) DEFERRED, mechanism sketched.** Green: + full hub `build/vet/test` (17 ok, +9 self-bind tests) + `bash -n`. **LIVE validation pending deploy + (this session).** See REPORT.md. + - **2026-07-17 — PBS DR STORAGE VISIBILITY + Offsite tab split + dual gauges shipped (hub v0.65.0 + tenantsync v1.2.0, R-5), LIVE.** Scoping correction (Viktor): "restic box" and "PBS box" are NOT two Hetzner Storage Boxes — restic = subaccounts on the shared Hetzner box (Hetzner API, v0.64.0); **PBS DR = diff --git a/REPORT.md b/REPORT.md index 2627e32..6424cd2 100644 --- a/REPORT.md +++ b/REPORT.md @@ -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/` 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/` 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/` 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). diff --git a/documentation/architecture/00-capability-map.md b/documentation/architecture/00-capability-map.md index 0b175bc..388a04f 100644 --- a/documentation/architecture/00-capability-map.md +++ b/documentation/architecture/00-capability-map.md @@ -31,8 +31,9 @@ |---|---|---|---|---| | Appliance day-0 install: golden image → first boot → auto-confirm (zero clicks) → claimable box | installer, agent, hub, golden | **PROVEN-LIVE** (nested VM) | `DRILL-day0-vm-2026-07-12`, `DRILL-day0-take2-2026-07-12` | First firing on real customer hardware pending → R-1 | | BYO install: `--mode byo`, mandatory caps, host-mutation disclosure, coexistence guards | installer v1.15+, agent | **PARTIAL** | `DRILL-GL6-2026-07-08` (demo box); GL-8 coexistence fixes | Peti clean-slate reinstall on proxmox2 is the first real BYO run of the current path → R-1 | -| Bare-metal Felhom ISO (blank hardware → zero-touch auto-install → first-boot `host-install`); selectable UEFI loader; **universal secret-free / operator-bind** mode | scripts v1.19.0 (`scripts/iso/`) + hub v0.62.0 + assistant container | **PARTIAL** (nested VM 310/311; live endpoints) | slice A `SPIKE-baremetal-iso-2026-07-16` (build gate, disk-filter fail-safe, stub→host-install fetch); slice B RUNBOOK-B (shim boots+installs OVMF SB-enforcing + SeaBIOS; `--loader mkimage` boots+installs SB-off; mkimage SB-enforcing **FAILS** `Access Denied`; surgery byte-identical); **slice C (2026-07-17): the GENERIC secret-free ISO** — box self-registers as an unclaimed appliance (`POST /api/v1/appliance/register`, one-shot poll delivery, 404-no-oracle — all live-verified through the public ingress), operator binds on the Hosts page, hub delivers credentials once; bootstrap harness proves direct(zero-appliance-calls)/pairing/delivery; artifact proven secret-free (baked env = hub URL only) | **F1 loader caveat:** `--loader mkimage` fixes cheap AMI firmware that can't USB-boot the stock GRUB — UNSIGNED → **Secure Boot must be OFF**; default `shim` keeps SB. **Slice C bind is operator-password-gated** (CC stages, Viktor binds) → the live boot→register→bind→day-0 composition + physical N100 boot fold into the supervised rehearsal (R-1). Customer-facing **self-bind page** = future item (registered) | +| Bare-metal Felhom ISO (blank hardware → zero-touch auto-install → first-boot `host-install`); selectable UEFI loader; **universal secret-free / operator-bind** mode | scripts v1.19.0 (`scripts/iso/`) + hub v0.62.0 + assistant container | **PARTIAL** (nested VM 310/311; live endpoints) | slice A `SPIKE-baremetal-iso-2026-07-16` (build gate, disk-filter fail-safe, stub→host-install fetch); slice B RUNBOOK-B (shim boots+installs OVMF SB-enforcing + SeaBIOS; `--loader mkimage` boots+installs SB-off; mkimage SB-enforcing **FAILS** `Access Denied`; surgery byte-identical); **slice C (2026-07-17): the GENERIC secret-free ISO** — box self-registers as an unclaimed appliance (`POST /api/v1/appliance/register`, one-shot poll delivery, 404-no-oracle — all live-verified through the public ingress), operator binds on the Hosts page, hub delivers credentials once; bootstrap harness proves direct(zero-appliance-calls)/pairing/delivery; artifact proven secret-free (baked env = hub URL only) | **F1 loader caveat:** `--loader mkimage` fixes cheap AMI firmware that can't USB-boot the stock GRUB — UNSIGNED → **Secure Boot must be OFF**; default `shim` keeps SB. **Slice C bind is operator-password-gated** (CC stages, Viktor binds) → the live boot→register→bind→day-0 composition + physical N100 boot fold into the supervised rehearsal (R-1). Customer-facing **self-bind page = R-27 slice 1 SHIPPED (hub v0.66.0, 2026-07-17)** — see the dedicated self-bind row | | Customer claim: one-time emailed code → customer sets own password (bcrypt, operator never sees it) | controller v0.122, hub v0.50 | **PROVEN-LIVE** (drill VM) | `DRILL-day0-vm-2026-07-12` §10/F-4 (gate ON via real edge; claimed, code consumed) | Never executed by a non-Viktor human → R-3. (Dropped mis-cited `CAMPAIGN-4` F-C — that is the escrow-claim 502, not password claim) | +| Customer binds their own appliance (self-service): operator-sent 7-day tokenized capability link → public two-factor `/bind/` (console pairing code + retrieval passphrase) → hub stages the bind, no operator | hub v0.66.0 + ISO scripts v1.20.0 | **IMPLEMENTED** | hub v0.66.0 (`web/selfbind.go`, `store/selfbind.go`; Scenarios A–F + F1/F2; 4 red-proofs verified red — THE TRAP `/bind/` exemption, no-oracle, lockout, single-active); GC verdict §3 (no appliance GC → TTL stands alone) | R-27 **slice 1**. No appliance list ever rendered; wrong code == wrong passphrase (one generic failure); 5-attempt lockout → call support; expiry falls back to operator-bind. **Live first-run + new-ISO console banner pending** (folds into the supervised rehearsal R-1). **R-27b** (controller second-box dismissable prompt) deferred; **multi-box-per-link** = repeated operator sends | | Escrow ceremony: customer-facing wizard, one-shot R claim, operator zero-knowledge | controller v0.127, agent v0.88/0.89 | **PROVEN-LIVE** (drill VM, endpoint-exact) | agent v0.88.0 REPORT (ceremony ~4s, one-shot claim 200→410, R absent from every payload); `SPIKE-controller-escrow-2026-07-13` | Endpoints driven on the drill VM; customer-facing **browser wizard** leg not yet live-validated. First supervised ceremony with a real customer pending → R-1. **agent v0.89.0:** `/escrow/preflight` `pbs_storage_id` row now live-reloads (reads current agent.json) — a pbsdr convergence that seeds the id flips it green with NO service restart. **hub v0.60.0 (data-first retention):** a re-escrow with a DIFFERENT sealed passphrase no longer destroys the old blob — the hub RETAINS it (`host_escrow_superseded`), so a previous passphrase stays recoverable with its recovery code (turns the reinstall-orphan incident from "history destroyed" into "history recoverable"). Guided-recovery flow = R-26. Red-proof `TestSaveHostEscrow_RetainsSuperseded`. **hub v0.60.1 — custody survives the host lifecycle:** host deletion (with the escrow ack) DEMOTES the current blob to retained custody (moved into `host_escrow_superseded`, never destroyed; existing superseded rows spared); the customer Danger-zone Delete is the one true purge point (cascades both escrow tables incl. already-deleted hosts). No operator path through host lifecycle can lose a blob. Red-proofs `TestDeleteHost_DemotesEscrowNeverDestroys` + `TestDeleteCustomer_PurgesEscrowCustody` | | DR tier by default: PBS + WireGuard base infra on every install, hub-controlled activation | installer v1.15, agent v0.86, hub v0.51 | **IMPLEMENTED** | `DRILL-day0-take2-2026-07-12` §2 (WG enabled both modes, PBS-DR descriptor auto-provisioned ~1s after WG registration, zero operator steps); ships installer v1.15/agent v0.86/hub v0.51 | Live only on demo/drill fleet. (Cited spike was slice-0 mechanics — shipped nothing; corrected. Candidate upgrade to PROVEN-LIVE — see REPORT.) **agent v0.89.0 closes the F4 non-default-storage-id gap (R-22) — PROVEN-LIVE 2026-07-17:** the reconcile self-grants the ACL through the root wrapper on a pre-check 403 instead of dead-locking. Reproduced F4 on the demo (marker moved aside = reinstall fresh-state + felhom-offsite ACLs revoked) → next reconcile tick `pbsdr: pre-check 403 … self-granting … (R-22)` → `converged state=adopted` in ~3 s, ACLs self-restored, `pvesm status felhom-offsite`=active, zero operator action. No more one-shot `pveum` grant | | Customer RESET (middle lifecycle tier: host delete < RESET < customer Delete): one operator action → pre-first-install; all operational state destroyed, identity + basic config survive | hub v0.61.0, felhom-tenantsync v1.1.0 | **PROVEN-LIVE (external teardown)** | hub v0.61.0 REPORT; **ep0 live drill 2026-07-17** (throwaway `drill-reset-01` with a real backup: deprovision `deleted:true` destroyed the namespace + backup group + token, idempotent re-run `deleted:false`, all 3 real tenants + shared user survived); red-proofs (ack-gate, partial-failure resumability) + orchestration/store/offsite/render tests | External teardown FIRST, DB purge LAST, every leg idempotent; refuses while any host row exists; separate escrow-custody ack; clears claim (fresh code next onboarding); keeps the offsite tier CHOICE, drops provisioned fields. **Not live-clicked:** the web POST is password-gated (CC verifies via render + httptest orchestration tests); the Hetzner sub-account delete is unit-tested + a faithful mirror of the live-proven `ReissueCredentials`. **Consistency gap → R-25b:** the Danger-zone DELETE leaves host rows and doesn't run this teardown | diff --git a/documentation/backlog/ROADMAP.md b/documentation/backlog/ROADMAP.md index 40b8ca3..d07b17f 100644 --- a/documentation/backlog/ROADMAP.md +++ b/documentation/backlog/ROADMAP.md @@ -48,7 +48,8 @@ | ID | Item | Size | Status | Notes | |----|------|------|--------|-------| | R-26 | **Guided old-history recovery via a retained superseded escrow + the recovery code.** Enabled by hub v0.60.0 (Part B) which now RETAINS superseded escrow blobs (`host_escrow_superseded`, `ListSupersededEscrow`). Build the flow that, given the customer's recovery code, unwraps a retained old blob → recovers the old repo passphrase → mounts/reads the moved-aside `.orphaned-` repo for restore. | M | idea (enabled by v0.60.0) | Turns "history recoverable in principle" into a real customer-drivable path; pairs with the controller v0.142.0 orphaned-repo move-aside. Origin `DIAGNOSE-offbox-repo-orphaned-2026-07-17` | -| R-27 | **Customer-facing self-bind page (R-21 slice C follow-on).** Today an unclaimed appliance is bound by the OPERATOR on the Hosts page (hub v0.62.0). Build the customer-facing flow so a customer can claim/bind their own freshly-installed box (e.g. enter a claim code / the appliance's displayed pairing id → the hub binds it to their account → delivery proceeds). Turns "operator binds every box" into true self-service onboarding. | M | idea | Origin: hub v0.62.0 slice C (operator-bind ruling; self-bind deliberately deferred). Reuses the appliance_registrations + one-shot delivery machinery; adds a customer-auth surface + a pairing-id/claim-code channel. Pairs with the claim engine | +| R-27 | **Customer-facing self-bind page (R-21 slice C follow-on).** Today an unclaimed appliance is bound by the OPERATOR on the Hosts page (hub v0.62.0). Build the customer-facing flow so a customer can claim/bind their own freshly-installed box. | M | **SHIPPED (slice 1, hub v0.66.0, 2026-07-17)** | **Slice 1 = the FIRST-box flow, DONE:** operator "Send self-bind link" → 7-day tokenized capability link over Hungarian email → public two-factor `/bind/` (console pairing code shown on the box screen + retrieval passphrase) → hub stages the bind via the same `BindAppliance`, no operator. Viktor's 3 rulings verbatim (console code not a list; operator-sent link; 5-attempt lockout→call support). No oracle; expiry falls back to operator-bind; THE TRAP `/bind/` exemption tight (single predicate, 4 red-proofs). GC verdict §3 (no appliance GC → the 7-day TTL stands alone). **Live first-run + new-ISO console banner fold into the supervised rehearsal (R-1).** Reused the appliance_registrations + one-shot delivery machinery; the capability link is the whole customer-auth surface (no login system built). Controller/agent untouched. See hub v0.66.0 REPORT. **Multi-box per link is out of scope by design** (single-active, one-shot → binding several boxes = repeated operator sends) | +| R-27b | **Customer self-bind, second-box flow (controller side).** For a customer who ALREADY has a bound box and installs another, the controller shows a dismissable "bind another box" prompt (and a bind-later entry under settings) that walks to the hub `/bind/` page — so a returning customer isn't emailed a fresh operator-sent link for every box. Mechanism sketched in the hub v0.66.0 REPORT; NOT built (R-27 slice 1 deliberately did not touch the controller). | M | idea (minted by hub v0.66.0) | Origin: hub v0.66.0 slice-1 ship (first-box only). Reuses the same `/bind/` public page + tokenized-link machinery; adds a controller-side entry point + the operator "mint a link for an existing customer" affordance | | R-25b | **Customer DELETE ↔ RESET consistency.** The middle-tier Customer RESET (hub v0.61.0) runs the full external teardown (Hetzner sub-account/box + PBS namespace/groups/token) and refuses while any host row exists. The Danger-zone DELETE still (a) leaves host rows and (b) does NOT run that teardown — it purges escrow custody + drops the config only. Decide the model: DELETE requires a prior RESET, or DELETE subsumes RESET's teardown, or they stay orthogonal (RESET = recycle-in-place, DELETE = escrow-purge). | S | idea | Origin: hub v0.61.0 RESET ship. Flips a future "customer fully offboarded (external resources released)" map row. Cheap once the model is chosen | | R-25 | **Device-node TOCTOU hardening (drive init).** Graduate the controller v0.141.0 Observation: the `format → resolveEnrollUUID(path) → AssignDisk(uuid)` sequence has a narrow /dev-re-enumeration window (agent-guarded on the destructive format via anti-retarget durable-id; benign fs-UUID mount). Bind resolve+assign to the format's durable-id so the mount can't target a moved node. | S | idea | From the v0.141.0 F6 commit's security-review finding (`felhom-controller` REPORT). Low real risk (single-operator, agent-guarded), but cheap to close | | R-24 | **Guest RAM resize (live) — SHIPPED (agent v0.90.0 + controller v0.143.0, 2026-07-17).** The customer right-sizes the guest's memory from the controller's Rendszer page; the agent enforces every bound fresh + applies via PVE `SetConfig` (live cgroup, no reboot, Phase-0 proven). **Framing note:** the original hub-desired-state framing is SUPERSEDED by Viktor's controller-direct ruling (2026-07-17) — the resize is controller→agent local-API, never through the hub. Memory only (cores stay observation). Deployed + live-validated on the demo (refusals render Hungarian end-to-end); a successful grow on a normal-sized box folds into the rehearsal. **Cores/live-resize as hub desired-state is NOT built** (deferred, low demand). | M | **SHIPPED** | See felhom-agent + felhom-controller REPORTs; capability-map row "customer right-sizes guest RAM". | diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index 4d3cf55..e80b266 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -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/` (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 diff --git a/hub/cmd/hub/main.go b/hub/cmd/hub/main.go index c7df20c..e9b875a 100644 --- a/hub/cmd/hub/main.go +++ b/hub/cmd/hub/main.go @@ -290,7 +290,8 @@ func main() { webServer := web.New(dataStore, cfg.Auth.PasswordHash, cfg.API.ReportAPIKey, Version, staleThreshold, logger) webServer.SetTemplateFetcher(templateFetcher) webServer.SetAssetManager(assetsMgr) - webServer.SetClaimEngine(claimEngine) // v0.50.0 — Setup-tab claim chip + resend button + webServer.SetClaimEngine(claimEngine) // v0.50.0 — Setup-tab claim chip + resend button + webServer.SetSelfBindMailer(dispatcher) // v0.66.0 (R-27) — customer self-bind link button (sibling of claim mailer) // Day-0 artifact version dropdowns: let the operator pick a version and have the hub derive the // sha256 from Gitea (no hand-copied checksums). Reuses the registry creds; degrades to manual text // entry when they're absent. diff --git a/hub/internal/api/appliance.go b/hub/internal/api/appliance.go index 64a5a9e..1449767 100644 --- a/hub/internal/api/appliance.go +++ b/hub/internal/api/appliance.go @@ -140,7 +140,15 @@ func (h *Handler) handleApplianceRegister(w http.ResponseWriter, r *http.Request http.Error(w, "internal error", http.StatusInternalServerError) return } - isNew, err := h.store.RegisterAppliance(uuid, macSet, sshKeys, hwSummary, sha256hex(token)) + // v0.66.0 (R-27): a stable 6-char pairing code the box prints on its console + the customer types + // into the self-bind page. The candidate is used only on first insert; a re-register keeps the code. + candidateCode, err := configgen.RandomPairingCode() + if err != nil { + h.logger.Printf("[ERROR] appliance register: pairing-code mint: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + isNew, pairingCode, err := h.store.RegisterAppliance(uuid, macSet, sshKeys, hwSummary, sha256hex(token), candidateCode) if err != nil { h.logger.Printf("[ERROR] appliance register (uuid=%s): %v", uuid, err) http.Error(w, "internal error", http.StatusInternalServerError) @@ -153,7 +161,10 @@ func (h *Handler) handleApplianceRegister(w http.ResponseWriter, r *http.Request } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]any{"appliance_token": token, "poll_interval_sec": 30}) + // pairing_code is additive — a pre-v0.66.0 bootstrap ignores it and stays operator-bind-only. + json.NewEncoder(w).Encode(map[string]any{ + "appliance_token": token, "poll_interval_sec": 30, "pairing_code": configgen.FormatPairingCode(pairingCode), + }) } // handleAppliancePoll — GET /api/v1/appliance/poll (Bearer appliance-token). One-shot delivery: diff --git a/hub/internal/configgen/configgen.go b/hub/internal/configgen/configgen.go index 3d21782..c316841 100644 --- a/hub/internal/configgen/configgen.go +++ b/hub/internal/configgen/configgen.go @@ -4,6 +4,8 @@ import ( "crypto/rand" "encoding/hex" "fmt" + "math/big" + "strings" "time" "gitea.dooplex.hu/admin/felhom-hub/internal/store" @@ -126,3 +128,55 @@ func RandomHex(n int) (string, error) { } return hex.EncodeToString(b), nil } + +// pairingAlphabet excludes visually ambiguous characters (0/O, 1/I/L) so a customer can read the code +// off the box's console banner and type it into the self-bind page without confusion. +const pairingAlphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" + +// RandomPairingCode returns a 6-char appliance pairing code from the unambiguous alphabet (rendered +// "ABC-DEF" for display; stored/compared without the dash). It is NOT a secret on its own — the +// self-bind flow also requires the customer's retrieval passphrase. +func RandomPairingCode() (string, error) { + b := make([]byte, 6) + max := big.NewInt(int64(len(pairingAlphabet))) + for i := range b { + idx, err := rand.Int(rand.Reader, max) + if err != nil { + return "", err + } + b[i] = pairingAlphabet[idx.Int64()] + } + return string(b), nil +} + +// FormatPairingCode renders a stored 6-char code as "ABC-DEF" for the console banner + the operator UI. +func FormatPairingCode(code string) string { + if len(code) == 6 { + return code[:3] + "-" + code[3:] + } + return code +} + +// NormalizePairingCode strips separators/whitespace and upper-cases (the customer may type "abc-def", +// "abc def", or "ABCDEF") so the compare is against a canonical form. +func NormalizePairingCode(s string) string { + var b strings.Builder + for _, r := range strings.ToUpper(s) { + if (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + } + } + return b.String() +} + +// NormalizePassphrase canonicalizes a diceware passphrase for the constant-time compare: trim, +// lower-case, and collapse any run of dashes/whitespace to a single dash. The WORDS themselves are +// compared exactly (no accent-folding — the Hungarian wordlist is the source of truth), so a mistyped +// word fails. +func NormalizePassphrase(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + fields := strings.FieldsFunc(s, func(r rune) bool { + return r == '-' || r == ' ' || r == '\t' || r == '\n' || r == '\r' + }) + return strings.Join(fields, "-") +} diff --git a/hub/internal/notify/dispatcher.go b/hub/internal/notify/dispatcher.go index d152831..a787215 100644 --- a/hub/internal/notify/dispatcher.go +++ b/hub/internal/notify/dispatcher.go @@ -245,3 +245,24 @@ func (d *Dispatcher) SendClaimEmail(kind, customerID, email, domain, code string d.store.LogNotification(customerID, eventType, "info", subject, "sent", "", "customer") return nil } + +// SendSelfBindEmail delivers the customer self-bind capability link (v0.66.0, R-27 slice 1) to the +// REGISTERED customer address. Sibling of SendClaimEmail — NOT routed through the claim engine. The +// link is the capability; it is logged only via the notification_log subject (which carries no +// token), never the raw link. On failure the caller (web) invalidates the just-minted token so it is +// not left silently live. +func (d *Dispatcher) SendSelfBindEmail(customerID, email, link string) error { + if d.resendAPIKey == "" { + d.logger.Printf("[ERROR] self-bind link email for %s NOT sent: no Resend API key configured", customerID) + return fmt.Errorf("notify: no resend api key") + } + subject, body := FormatSelfBindEmail(customerID, link) + if err := d.sendEmailFn(email, subject, body); err != nil { + d.logger.Printf("[ERROR] self-bind link email to customer %s failed: %v", customerID, err) + d.store.LogNotification(customerID, "selfbind_link", "info", subject, "failed", err.Error(), "customer") + return err + } + d.logger.Printf("[INFO] self-bind link emailed to the registered address of %s", customerID) + d.store.LogNotification(customerID, "selfbind_link", "info", subject, "sent", "", "customer") + return nil +} diff --git a/hub/internal/notify/templates.go b/hub/internal/notify/templates.go index dfd01e4..473a732 100644 --- a/hub/internal/notify/templates.go +++ b/hub/internal/notify/templates.go @@ -230,3 +230,32 @@ Felhom.eu`, code, dashboardURL) return subject, body } } + +// FormatSelfBindEmail builds the customer-facing Hungarian email carrying the self-bind capability +// link (v0.66.0, R-27 slice 1). The link is the ONLY secret here — the passphrase is never in the +// mail (the customer already holds it), and the console pairing code is read off the box screen. The +// copy tells the customer they will need both factors on the page. Adult tone, no emoji. +func FormatSelfBindEmail(customerID, link string) (string, string) { + subject := "[Felhom] Kösd össze a Felhom dobozodat" + body := fmt.Sprintf(`Kedves Ügyfél! + +Elkészült a Felhom dobozod, és készen áll az összekötésre. Az alábbi hivatkozáson +tudod te magad összekötni a fiókoddal — nincs szükség bejelentkezésre: + +%s + +A hivatkozás megnyitása után két adatot kell megadnod: + + 1. A párosító kódot, amely a doboz képernyőjén (a monitoron) látható. + 2. A visszaállító jelszavadat (az 5 szóból álló kifejezést), amelyet a + beállításkor kaptál. + +A hivatkozás 7 napig érvényes. Biztonsági okból 5 sikertelen próbálkozás után +zárolódik — ilyenkor vedd fel a kapcsolatot az ügyfélszolgálattal. + +Ha nem te kérted ezt, hagyd figyelmen kívül ezt az e-mailt. + +Üdvözlettel, +Felhom.eu`, link) + return subject, body +} diff --git a/hub/internal/store/appliance.go b/hub/internal/store/appliance.go index 70d903b..d39e11c 100644 --- a/hub/internal/store/appliance.go +++ b/hub/internal/store/appliance.go @@ -32,6 +32,7 @@ type ApplianceRegistration struct { CustomerID string // set at bind InstallMode string // staged at bind (appliance|byo) ExtraArgs string // staged at bind + PairingCode string // v0.66.0 (R-27): stable 6-char code shown on the box console + the self-bind page FirstSeen time.Time LastSeen time.Time BoundAt *time.Time @@ -46,57 +47,65 @@ type ApplianceRegistration struct { // token yet, so it is genuinely starting over; the operator re-binds. isNew is true only on first // insert (so the caller can log the first sighting). The token itself is never passed in — only its // hash. -func (s *Store) RegisterAppliance(uuid, macSet, sshKeys, hwSummary, tokenHash string) (isNew bool, err error) { +// v0.66.0 (R-27 slice 1): the caller passes a candidateCode (minted via configgen — store must not +// import configgen, an import cycle); it is used ONLY on the first insert and stays STABLE across the +// idempotent re-register. The effective stored code is returned so the register response can carry it. +func (s *Store) RegisterAppliance(uuid, macSet, sshKeys, hwSummary, tokenHash, candidateCode string) (isNew bool, pairingCode string, err error) { tx, err := s.db.Begin() if err != nil { - return false, err + return false, "", err } defer tx.Rollback() var id int64 - var status string - row := tx.QueryRow(`SELECT id, status FROM appliance_registrations WHERE uuid = ? AND mac_set = ?`, uuid, macSet) - switch err := row.Scan(&id, &status); err { + var status, existingCode string + row := tx.QueryRow(`SELECT id, status, COALESCE(pairing_code,'') FROM appliance_registrations WHERE uuid = ? AND mac_set = ?`, uuid, macSet) + switch err := row.Scan(&id, &status, &existingCode); err { case sql.ErrNoRows: if _, err := tx.Exec(` - INSERT INTO appliance_registrations (uuid, mac_set, ssh_host_pubkeys, hw_summary, token_hash, status) - VALUES (?, ?, ?, ?, ?, 'registered')`, uuid, macSet, sshKeys, hwSummary, tokenHash); err != nil { - return false, fmt.Errorf("register appliance insert: %w", err) + INSERT INTO appliance_registrations (uuid, mac_set, ssh_host_pubkeys, hw_summary, token_hash, status, pairing_code) + VALUES (?, ?, ?, ?, ?, 'registered', ?)`, uuid, macSet, sshKeys, hwSummary, tokenHash, candidateCode); err != nil { + return false, "", fmt.Errorf("register appliance insert: %w", err) } if err := tx.Commit(); err != nil { - return false, err + return false, "", err } - return true, nil + return true, candidateCode, nil case nil: + // The code stays STABLE across re-register; a pre-v0.66.0 row (empty code) is backfilled once. + effectiveCode := existingCode + if effectiveCode == "" { + effectiveCode = candidateCode + } // Existing record: refresh last_seen + identity + the token. Sticky-discard keeps its status; // everything else resets to registered (a re-registering box is starting over). if status == ApplianceDiscarded { if _, err := tx.Exec(`UPDATE appliance_registrations - SET token_hash = ?, ssh_host_pubkeys = ?, hw_summary = ?, last_seen = datetime('now') - WHERE id = ?`, tokenHash, sshKeys, hwSummary, id); err != nil { - return false, fmt.Errorf("register appliance (discarded) update: %w", err) + SET token_hash = ?, ssh_host_pubkeys = ?, hw_summary = ?, pairing_code = ?, last_seen = datetime('now') + WHERE id = ?`, tokenHash, sshKeys, hwSummary, effectiveCode, id); err != nil { + return false, "", fmt.Errorf("register appliance (discarded) update: %w", err) } } else { if _, err := tx.Exec(`UPDATE appliance_registrations - SET token_hash = ?, ssh_host_pubkeys = ?, hw_summary = ?, status = 'registered', + SET token_hash = ?, ssh_host_pubkeys = ?, hw_summary = ?, pairing_code = ?, status = 'registered', customer_id = NULL, install_mode = NULL, extra_args = NULL, bound_at = NULL, delivered_at = NULL, last_seen = datetime('now') - WHERE id = ?`, tokenHash, sshKeys, hwSummary, id); err != nil { - return false, fmt.Errorf("register appliance update: %w", err) + WHERE id = ?`, tokenHash, sshKeys, hwSummary, effectiveCode, id); err != nil { + return false, "", fmt.Errorf("register appliance update: %w", err) } } if err := tx.Commit(); err != nil { - return false, err + return false, "", err } - return false, nil + return false, effectiveCode, nil default: - return false, fmt.Errorf("register appliance lookup: %w", err) + return false, "", fmt.Errorf("register appliance lookup: %w", err) } } // scanAppliance scans a full appliance row (column order fixed by applianceCols). const applianceCols = `id, uuid, mac_set, ssh_host_pubkeys, hw_summary, status, - COALESCE(customer_id,''), COALESCE(install_mode,''), COALESCE(extra_args,''), + COALESCE(customer_id,''), COALESCE(install_mode,''), COALESCE(extra_args,''), COALESCE(pairing_code,''), first_seen, last_seen, bound_at, delivered_at, discarded_at` func scanAppliance(sc interface{ Scan(...any) error }) (*ApplianceRegistration, error) { @@ -104,7 +113,7 @@ func scanAppliance(sc interface{ Scan(...any) error }) (*ApplianceRegistration, var firstSeen, lastSeen string var boundAt, deliveredAt, discardedAt sql.NullString if err := sc.Scan(&a.ID, &a.UUID, &a.MACSet, &a.SSHHostPubkeys, &a.HWSummary, &a.Status, - &a.CustomerID, &a.InstallMode, &a.ExtraArgs, + &a.CustomerID, &a.InstallMode, &a.ExtraArgs, &a.PairingCode, &firstSeen, &lastSeen, &boundAt, &deliveredAt, &discardedAt); err != nil { return nil, err } @@ -138,6 +147,41 @@ func (s *Store) ApplianceByToken(tokenHash string) (*ApplianceRegistration, erro return a, err } +// ApplianceByPairingCode resolves a console pairing code to the ONE bindable (registered, not yet +// bound/delivered/discarded) appliance carrying it — the customer-self-bind factor-1 lookup (v0.66.0, +// R-27). Returns (nil, nil) when zero OR more than one registered appliance matches (an ambiguous +// code binds nothing — the public handler maps that to the same generic failure as a wrong code, no +// oracle). code is the RAW stored form (uppercased, no separator); the caller normalizes first. +func (s *Store) ApplianceByPairingCode(code string) (*ApplianceRegistration, error) { + if code == "" { + return nil, nil + } + rows, err := s.db.Query(`SELECT `+applianceCols+` FROM appliance_registrations + WHERE pairing_code = ? AND status = 'registered'`, code) + if err != nil { + return nil, err + } + defer rows.Close() + var matches []*ApplianceRegistration + for rows.Next() { + a, err := scanAppliance(rows) + if err != nil { + return nil, err + } + matches = append(matches, a) + if len(matches) > 1 { + return nil, nil // ambiguous — bind nothing + } + } + if err := rows.Err(); err != nil { + return nil, err + } + if len(matches) != 1 { + return nil, nil + } + return matches[0], nil +} + // GetAppliance fetches by row id (operator UI actions). func (s *Store) GetAppliance(id int64) (*ApplianceRegistration, error) { a, err := scanAppliance(s.db.QueryRow(`SELECT `+applianceCols+` FROM appliance_registrations WHERE id = ?`, id)) diff --git a/hub/internal/store/selfbind.go b/hub/internal/store/selfbind.go new file mode 100644 index 0000000..e31b918 --- /dev/null +++ b/hub/internal/store/selfbind.go @@ -0,0 +1,171 @@ +package store + +import ( + "database/sql" + "fmt" + "time" +) + +// selfbind.go — the customer self-bind capability-token store (v0.66.0, R-27 slice 1). +// +// The operator mints one token per customer and the hub emails a 7-day tokenized capability link. +// The customer opens it (public, no login — the token IS the capability), enters the box's console +// pairing code + their retrieval passphrase, and the hub stages the bind. Only sha256(token) is +// stored here (never the token); attempts lock at 5 (Viktor's ruling: "call support"); consumed_at +// is the one-shot flip. There is NO appliance data on this table — the two-factor check happens at +// bind time in web/selfbind.go, so a leaked token row reveals nothing about any box. +// +// Custody rules honoured here: single-active per customer (a re-mint deletes the prior row, so an old +// link dies the moment a new one is sent), and the token hash is the only representation at rest. + +// SelfBindMaxAttempts is the lockout threshold: the 5th failed factor-check locks the token and the +// customer is told to call support. Distinct from the controller's claim lockout — this is the +// hub-side public-surface guard against online guessing of the pairing code + passphrase. +const SelfBindMaxAttempts = 5 + +// SelfBindToken is a minted capability link's state. +type SelfBindToken struct { + ID int64 + CustomerID string + Attempts int + Locked bool + CreatedAt time.Time + ExpiresAt time.Time + EmailedAt *time.Time + ConsumedAt *time.Time +} + +// Expired reports whether the link is past its TTL (falls back to operator-bind, unchanged). +func (t *SelfBindToken) Expired(now time.Time) bool { return now.After(t.ExpiresAt) } + +// Consumed reports whether the link was already used (one-shot). +func (t *SelfBindToken) Consumed() bool { return t.ConsumedAt != nil } + +// Usable reports whether a POST may attempt a factor-check: not locked, not consumed, not expired. +func (t *SelfBindToken) Usable(now time.Time) bool { + return !t.Locked && !t.Consumed() && !t.Expired(now) +} + +const selfBindCols = `id, customer_id, attempts, locked, created_at, expires_at, emailed_at, consumed_at` + +func scanSelfBindToken(sc interface{ Scan(...any) error }) (*SelfBindToken, error) { + var t SelfBindToken + var locked int + var createdAt, expiresAt string + var emailedAt, consumedAt sql.NullString + if err := sc.Scan(&t.ID, &t.CustomerID, &t.Attempts, &locked, &createdAt, &expiresAt, &emailedAt, &consumedAt); err != nil { + return nil, err + } + t.Locked = locked != 0 + t.CreatedAt = parseSQLiteTime(createdAt) + t.ExpiresAt = parseSQLiteTime(expiresAt) + if emailedAt.Valid && emailedAt.String != "" { + e := parseSQLiteTime(emailedAt.String) + t.EmailedAt = &e + } + if consumedAt.Valid && consumedAt.String != "" { + c := parseSQLiteTime(consumedAt.String) + t.ConsumedAt = &c + } + return &t, nil +} + +// MintSelfBindToken creates a fresh capability token for the customer, single-active: any prior token +// for the same customer is DELETED in the same transaction, so an earlier link stops working the +// instant a new one is minted (no parallel-link enumeration). tokenHash is sha256(token) — the +// plaintext token exists only inside the email send. The token is valid for ttl from now. +func (s *Store) MintSelfBindToken(customerID, tokenHash string, ttl time.Duration) error { + if customerID == "" || tokenHash == "" { + return fmt.Errorf("selfbind: customer_id and token hash are required") + } + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(`DELETE FROM selfbind_tokens WHERE customer_id = ?`, customerID); err != nil { + return err + } + expiresAt := time.Now().Add(ttl).UTC().Format("2006-01-02 15:04:05") + if _, err := tx.Exec(`INSERT INTO selfbind_tokens (customer_id, token_hash, expires_at) + VALUES (?, ?, ?)`, customerID, tokenHash, expiresAt); err != nil { + return err + } + return tx.Commit() +} + +// DeleteSelfBindTokens removes every self-bind token for a customer. Used on the email-failure path +// (F2): a minted token whose delivery failed is not left silently live — the plaintext link is gone +// from memory, so the row is unusable dead weight; deleting it keeps the operator page honest (no +// stale "active link" with no delivery). Idempotent. +func (s *Store) DeleteSelfBindTokens(customerID string) error { + _, err := s.db.Exec(`DELETE FROM selfbind_tokens WHERE customer_id = ?`, customerID) + return err +} + +// SelfBindTokenByHash resolves sha256(token) to its row, or (nil, nil) when unknown — the public GET +// maps that to the same generic "invalid link" as an expired one: no oracle for "was this ever real". +func (s *Store) SelfBindTokenByHash(tokenHash string) (*SelfBindToken, error) { + if tokenHash == "" { + return nil, nil + } + t, err := scanSelfBindToken(s.db.QueryRow(`SELECT `+selfBindCols+` FROM selfbind_tokens WHERE token_hash = ?`, tokenHash)) + if err == sql.ErrNoRows { + return nil, nil + } + return t, err +} + +// MarkSelfBindEmailed records that the capability link was delivered (operator "emailed_at" honesty +// on the customer page). Best-effort: a failure to record does not un-send the mail. +func (s *Store) MarkSelfBindEmailed(tokenHash string) error { + _, err := s.db.Exec(`UPDATE selfbind_tokens SET emailed_at = datetime('now') WHERE token_hash = ?`, tokenHash) + return err +} + +// RecordSelfBindAttempt increments the failed-attempt counter for a token and locks it at the 5th +// failure. Called on EVERY failed factor-check (wrong code OR wrong passphrase — indistinguishable). +// Returns the post-increment attempt count and whether the token is now locked. A missing/consumed +// token is a no-op that reports locked=true (the caller already refuses those; belt-and-suspenders). +func (s *Store) RecordSelfBindAttempt(tokenHash string) (attempts int, locked bool, err error) { + tx, err := s.db.Begin() + if err != nil { + return 0, false, err + } + defer tx.Rollback() + t, err := scanSelfBindToken(tx.QueryRow(`SELECT `+selfBindCols+` FROM selfbind_tokens WHERE token_hash = ?`, tokenHash)) + if err == sql.ErrNoRows { + return SelfBindMaxAttempts, true, nil + } + if err != nil { + return 0, false, err + } + attempts = t.Attempts + 1 + locked = attempts >= SelfBindMaxAttempts + lockedInt := 0 + if locked { + lockedInt = 1 + } + if _, err := tx.Exec(`UPDATE selfbind_tokens SET attempts = ?, locked = ? WHERE id = ?`, attempts, lockedInt, t.ID); err != nil { + return 0, false, err + } + if err := tx.Commit(); err != nil { + return 0, false, err + } + return attempts, locked, nil +} + +// ConsumeSelfBindToken flips consumed_at EXACTLY ONCE on a successful bind. ok=true for the single +// winning caller; ok=false for a replay or a lost race (already consumed). The two-factor check has +// already passed when this is called — this is the one-shot gate against a double-bind. It refuses a +// locked or expired token defensively (WHERE guards), though the handler checks Usable() first. +func (s *Store) ConsumeSelfBindToken(tokenHash string) (ok bool, err error) { + res, err := s.db.Exec(`UPDATE selfbind_tokens + SET consumed_at = datetime('now') + WHERE token_hash = ? AND consumed_at IS NULL AND locked = 0 AND expires_at > datetime('now')`, tokenHash) + if err != nil { + return false, err + } + n, _ := res.RowsAffected() + return n == 1, nil +} diff --git a/hub/internal/store/store.go b/hub/internal/store/store.go index 6d47bd1..1bdf227 100644 --- a/hub/internal/store/store.go +++ b/hub/internal/store/store.go @@ -637,15 +637,37 @@ func (s *Store) migrate() error { bound_at DATETIME, delivered_at DATETIME, discarded_at DATETIME, + pairing_code TEXT NOT NULL DEFAULT '', UNIQUE(uuid, mac_set) ); CREATE INDEX IF NOT EXISTS idx_appliance_status ON appliance_registrations(status, last_seen DESC); CREATE INDEX IF NOT EXISTS idx_appliance_token ON appliance_registrations(token_hash); + + -- selfbind_tokens (v0.66.0, R-27 slice 1 — customer self-bind): the 7-day tokenized capability + -- link the operator emails. token_hash is sha256 at rest (never the token). Single-active per + -- customer (delete-then-insert on re-mint). attempts lock at 5 (Viktor's ruling); consumed_at is + -- the one-shot flip. NO appliance data here — the code+passphrase check happens at bind time. + CREATE TABLE IF NOT EXISTS selfbind_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + customer_id TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + attempts INTEGER NOT NULL DEFAULT 0, + locked INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT (datetime('now')), + expires_at DATETIME NOT NULL, + emailed_at DATETIME, + consumed_at DATETIME + ); + CREATE INDEX IF NOT EXISTS idx_selfbind_token ON selfbind_tokens(token_hash); `) if err != nil { return err } + // v0.66.0 (R-27 slice 1): pairing_code on pre-existing appliance rows (idempotent — errors if the + // column already exists, which is fine on a fresh DB where the CREATE above already added it). + s.db.Exec("ALTER TABLE appliance_registrations ADD COLUMN pairing_code TEXT NOT NULL DEFAULT ''") + // v0.51.0 dr_tier one-time legacy backfill — see the ALTER above; runs last so every table // it touches (hosts, customer_configs) exists on a fresh DB too (where it finds nothing). if drTierAlterErr == nil { diff --git a/hub/internal/web/appliances.go b/hub/internal/web/appliances.go index 56af54a..ba59521 100644 --- a/hub/internal/web/appliances.go +++ b/hub/internal/web/appliances.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "gitea.dooplex.hu/admin/felhom-hub/internal/configgen" "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) @@ -28,6 +29,7 @@ type applianceRow struct { CPU string MemGB string SSHFingerprints []string + PairingCode string // v0.66.0 (R-27): the code the customer reads off the box console + types on /bind FirstSeen *time.Time LastSeen *time.Time Stale bool @@ -61,9 +63,10 @@ func sshFingerprint(line string) string { // applianceToRow builds the view model (parses hw_summary + computes SSH fingerprints). func applianceToRow(a store.ApplianceRegistration, now time.Time, customerName func(string) string) applianceRow { row := applianceRow{ - ID: a.ID, - UUID: a.UUID, - Bound: a.Status == store.ApplianceBound, + ID: a.ID, + UUID: a.UUID, + PairingCode: configgen.FormatPairingCode(a.PairingCode), + Bound: a.Status == store.ApplianceBound, } if a.MACSet != "" { row.MACs = strings.Split(a.MACSet, ",") diff --git a/hub/internal/web/appliances_test.go b/hub/internal/web/appliances_test.go index 17534cc..3936fd4 100644 --- a/hub/internal/web/appliances_test.go +++ b/hub/internal/web/appliances_test.go @@ -17,7 +17,7 @@ func seedAppliance(t *testing.T, st *store.Store, uuid, macSet string) int64 { // a real ed25519 host key line so the fingerprint helper has something to parse sshKey := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHVBv+9slP74+1/vNhiI0OJDrXQ2nvb8iwmIxMfUZn36 host" hw := `{"product":"Intel N100 mini","cpu":"Intel(R) N100","mem_kb":16150372}` - if _, err := st.RegisterAppliance(uuid, macSet, sshKey, hw, "hash-"+uuid); err != nil { + if _, _, err := st.RegisterAppliance(uuid, macSet, sshKey, hw, "hash-"+uuid, "ABCDEF"); err != nil { t.Fatalf("register appliance: %v", err) } list, err := st.ListUnclaimedAppliances() diff --git a/hub/internal/web/selfbind.go b/hub/internal/web/selfbind.go new file mode 100644 index 0000000..3686210 --- /dev/null +++ b/hub/internal/web/selfbind.go @@ -0,0 +1,284 @@ +package web + +import ( + "crypto/subtle" + "html/template" + "net/http" + "strings" + "sync" + "time" + + "gitea.dooplex.hu/admin/felhom-hub/internal/configgen" + "gitea.dooplex.hu/admin/felhom-hub/internal/store" +) + +// selfbind.go — the CUSTOMER side of self-bind (v0.66.0, R-27 slice 1): the PUBLIC /bind/ page. +// A logged-out customer opens the emailed capability link and binds their own freshly-installed +// appliance by proving TWO factors — the console pairing code (physical possession of the box) and +// their retrieval passphrase (customer identity). No hub login exists; the URL token IS the auth. +// +// THE TRAP (spec §9.2): /bind/ is the ONE new public prefix, exempted from operator auth + CSRF at +// the two gate sites the /login exemption occupies. isPublicBindPath is the SINGLE definition of that +// prefix — both gate sites and the route dispatch call it, so widening it is one visible change (and +// the red-proof targets exactly this function). It is matched TIGHTLY (trailing slash → no sibling +// prefix like /bindsecret; the ServeMux cleans .. before we see the path → no traversal reach). +// +// No-oracle rules on this surface: the page NEVER renders/enumerates any appliance data; a wrong code +// and a wrong passphrase produce ONE identical generic failure; both factors are compared +// unconditionally before the decision; and only attempt COUNTS are logged (never the secrets, never +// the raw token — a hash prefix at most). + +// isPublicBindPath reports whether a path is the public customer self-bind surface. THE single +// definition of the /bind/ public prefix (THE TRAP §9.2) — do not inline a second copy anywhere. +func isPublicBindPath(path string) bool { + return strings.HasPrefix(path, "/bind/") +} + +// --- per-IP rate limiter (web-package sibling of api.ipRateLimiter; the type there is unexported) --- + +type bindBucket struct { + tokens float64 + last time.Time +} + +type bindRateLimiter struct { + mu sync.Mutex + perMinute float64 + buckets map[string]*bindBucket + now func() time.Time +} + +func newBindRateLimiter(perMinute int) *bindRateLimiter { + if perMinute <= 0 { + perMinute = 30 + } + return &bindRateLimiter{perMinute: float64(perMinute), buckets: make(map[string]*bindBucket), now: time.Now} +} + +func (rl *bindRateLimiter) allow(ip string) bool { + rl.mu.Lock() + defer rl.mu.Unlock() + now := rl.now() + b, ok := rl.buckets[ip] + if !ok { + rl.buckets[ip] = &bindBucket{tokens: rl.perMinute - 1, last: now} + return true + } + b.tokens += now.Sub(b.last).Seconds() * (rl.perMinute / 60.0) + if b.tokens > rl.perMinute { + b.tokens = rl.perMinute + } + b.last = now + if b.tokens < 1 { + return false + } + b.tokens-- + return true +} + +// bindClientIP extracts the client IP behind the ingress (first XFF hop, else RemoteAddr) — buckets +// only; the ingress geo-gate is the real access control. +func bindClientIP(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + if i := strings.IndexByte(xff, ','); i > 0 { + return strings.TrimSpace(xff[:i]) + } + return strings.TrimSpace(xff) + } + if i := strings.LastIndexByte(r.RemoteAddr, ':'); i > 0 { + return r.RemoteAddr[:i] + } + return r.RemoteAddr +} + +// --- the page --- + +type bindPageData struct { + State string // "form" | "success" | "expired" | "consumed" | "locked" + Token string // echoed into the form action (the capability itself; already in the URL) + Failed bool // generic factor-check failure (form state only) +} + +var bindTemplate = template.Must(template.New("bind").Parse(bindPageHTML)) + +func (s *Server) renderBind(w http.ResponseWriter, status int, data bindPageData) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + if err := bindTemplate.Execute(w, data); err != nil { + s.logger.Printf("[ERROR] rendering /bind page: %v", err) + } +} + +// handleBind serves the public self-bind page. GET renders the current link state; POST validates +// both factors and, on success, stages the bind (same BindAppliance the operator uses; provenance = +// customer self-bind). Reached only via isPublicBindPath — auth + CSRF exempt at the gate sites. +func (s *Server) handleBind(w http.ResponseWriter, r *http.Request) { + if s.bindLimiter != nil && !s.bindLimiter.allow(bindClientIP(r)) { + s.renderBind(w, http.StatusTooManyRequests, bindPageData{State: "expired"}) + return + } + token := strings.TrimPrefix(r.URL.Path, "/bind/") + // A trailing segment only — reject anything with further path structure (defence in depth atop + // the ServeMux path-clean; the token is a flat hex string). + if token == "" || strings.Contains(token, "/") { + s.renderBind(w, http.StatusNotFound, bindPageData{State: "expired"}) + return + } + hash := selfBindHash(token) + tok, err := s.store.SelfBindTokenByHash(hash) + if err != nil { + s.logger.Printf("[ERROR] /bind lookup failed: %v", err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + now := time.Now() + + // Terminal link states — identical for GET and POST, no factor check attempted. An unknown token + // (nil) is folded into "expired": no oracle for "was this link ever real". + switch { + case tok == nil || tok.Expired(now): + s.renderBind(w, http.StatusOK, bindPageData{State: "expired"}) + return + case tok.Consumed(): + s.renderBind(w, http.StatusOK, bindPageData{State: "consumed"}) + return + case tok.Locked: + s.renderBind(w, http.StatusOK, bindPageData{State: "locked"}) + return + } + + if r.Method != http.MethodPost { + s.renderBind(w, http.StatusOK, bindPageData{State: "form", Token: token}) + return + } + + // --- POST: validate BOTH factors unconditionally, then decide (no oracle) --- + normCode := configgen.NormalizePairingCode(r.FormValue("pairing_code")) + normPass := configgen.NormalizePassphrase(r.FormValue("passphrase")) + + // Factor 2 (passphrase) — the customer's retrieval passphrase, constant-time compared. Computed + // even when the customer/appliance is absent so the two paths are indistinguishable by timing. + var storedPass string + if cc, cerr := s.store.GetCustomerConfig(tok.CustomerID); cerr == nil && cc != nil { + storedPass = configgen.NormalizePassphrase(cc.RetrievalPassword) + } + passOK := storedPass != "" && subtle.ConstantTimeCompare([]byte(normPass), []byte(storedPass)) == 1 + + // Factor 1 (pairing code) — the ONE bindable appliance carrying that console code. + appliance, aerr := s.store.ApplianceByPairingCode(normCode) + if aerr != nil { + s.logger.Printf("[ERROR] /bind appliance lookup failed: %v", aerr) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + codeOK := appliance != nil + + if !codeOK || !passOK { + attempts, locked, rerr := s.store.RecordSelfBindAttempt(hash) + if rerr != nil { + s.logger.Printf("[ERROR] /bind recording attempt: %v", rerr) + } + // COUNTS only — never which factor failed, never the secrets, never the raw token. + s.logger.Printf("[WARN] self-bind attempt %d/%d failed for token %s… (customer %s)", attempts, store.SelfBindMaxAttempts, hash[:8], tok.CustomerID) + if locked { + s.renderBind(w, http.StatusOK, bindPageData{State: "locked"}) + return + } + s.renderBind(w, http.StatusOK, bindPageData{State: "form", Token: token, Failed: true}) + return + } + + // Both factors passed. Consume one-shot FIRST (atomic gate against a double-bind race). + consumed, cerr := s.store.ConsumeSelfBindToken(hash) + if cerr != nil { + s.logger.Printf("[ERROR] /bind consuming token: %v", cerr) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + if !consumed { + // Lost the race (a concurrent request consumed it) — it is already being bound. + s.renderBind(w, http.StatusOK, bindPageData{State: "consumed"}) + return + } + if err := s.store.BindAppliance(appliance.ID, tok.CustomerID, "appliance", ""); err != nil { + // Rare: the appliance became unbindable (operator discarded it) between lookup and bind. The + // token is spent; surface a neutral generic failure rather than an appliance-state oracle. + s.logger.Printf("[WARN] self-bind: BindAppliance %d → %s failed after factor match: %v", appliance.ID, tok.CustomerID, err) + s.renderBind(w, http.StatusOK, bindPageData{State: "form", Failed: true}) + return + } + if _, err := s.store.SaveEvent(tok.CustomerID, "appliance_bound", "info", + "Az ügyfél saját maga kötötte össze az új eszközt (bare-metal telepítés); a hozzáférést a doboz a következő lekérdezéskor megkapja.", "", "customer_selfbind"); err != nil { + s.logger.Printf("[WARN] self-bind: save event for %s: %v", tok.CustomerID, err) + } + s.logger.Printf("[INFO] self-bind SUCCESS: appliance %d bound to customer %s by customer self-service (token %s…)", appliance.ID, tok.CustomerID, hash[:8]) + s.renderBind(w, http.StatusOK, bindPageData{State: "success"}) +} + +// bindPageHTML is the self-contained public page. It CANNOT link /style.css (that route is +// operator-auth gated), so all styling is inline — mirroring the login page. Design tokens: navy +// surface, 2px radius, hairline rules, exception color for the failure banner. Hungarian, adult tone, +// no emoji. It renders NO appliance data in any state. +const bindPageHTML = ` + + + + + +Felhom — Doboz összekötése + + + +
+
+

Felhom doboz összekötése

+ {{if eq .State "form"}} +

Kösd össze a most telepített Felhom dobozodat a fiókoddal. Add meg a doboz képernyőjén látható párosító kódot és a visszaállító jelszavadat.

+ {{if .Failed}}{{end}} +
+ + +

A doboz monitorán jelenik meg, a telepítés után.

+ + +

Az öt szóból álló kifejezés, amelyet a beállításkor kaptál.

+ +
+

Biztonsági okból 5 sikertelen próbálkozás után a hivatkozás zárolódik. Ilyenkor vedd fel a kapcsolatot az ügyfélszolgálattal.

+ {{else if eq .State "success"}} +

Sikeres összekötés.

+

A doboz kb. egy percen belül folytatja a telepítést. Ezt az oldalt bezárhatod — a beállítás a háttérben befejeződik, és a vezérlőpultod hamarosan elérhető lesz.

+ {{else if eq .State "consumed"}} +

Ez a hivatkozás már fel lett használva.

+

A doboz összekötése megtörtént. Ha úgy gondolod, hogy ez tévedés, vedd fel a kapcsolatot az ügyfélszolgálattal.

+ {{else if eq .State "locked"}} +

Ez a hivatkozás zárolva van.

+

Túl sok sikertelen próbálkozás történt. Biztonsági okból a hivatkozás zárolódott — kérjük, vedd fel a kapcsolatot az ügyfélszolgálattal a doboz összekötéséhez.

+ {{else}} +

Ez a hivatkozás érvénytelen vagy lejárt.

+

A hivatkozás 7 napig érvényes. Ha lejárt, kérj újat az ügyfélszolgálattól, vagy az összekötést az üzemeltető is elvégezheti.

+ {{end}} +
+

Felhom.eu

+
+ +` diff --git a/hub/internal/web/selfbind_mint.go b/hub/internal/web/selfbind_mint.go new file mode 100644 index 0000000..26acccf --- /dev/null +++ b/hub/internal/web/selfbind_mint.go @@ -0,0 +1,94 @@ +package web + +import ( + "crypto/sha256" + "encoding/hex" + "net/http" + "time" + + "gitea.dooplex.hu/admin/felhom-hub/internal/configgen" +) + +// selfbind_mint.go — the OPERATOR side of customer self-bind (v0.66.0, R-27 slice 1): the "Send +// self-bind link" button on the customer page mints a 7-day capability token and emails the customer +// a public bind link. The customer side (the public /bind/ page) lives in selfbind.go. No hub +// customer-login exists — the emailed link IS the auth model. + +// selfBindTTL is the capability link's lifetime. After it, self-bind falls back to operator-bind +// unchanged (the token simply reads as expired; nothing else regresses). +const selfBindTTL = 7 * 24 * time.Hour + +// selfBindBaseURL is the hub's public origin, matching the hardcoded origin used elsewhere +// (configgen). The link is https://hub.felhom.eu/bind/. +const selfBindBaseURL = "https://hub.felhom.eu" + +// SelfBindMailer delivers the self-bind capability link to the registered customer address. The +// notify.Dispatcher implements it (a sibling of the claim mailer — NOT routed through claim). The +// signature takes plain strings so the implementation needs no import of this package. +type SelfBindMailer interface { + SendSelfBindEmail(customerID, email, link string) error +} + +// SetSelfBindMailer wires the self-bind link sender (v0.66.0). Absent → the button returns 502. +func (s *Server) SetSelfBindMailer(m SelfBindMailer) { s.selfBindMailer = m } + +func selfBindHash(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} + +// handleSelfBindLinkSend — POST /customers/{id}/selfbind-link. Mints a single-active capability token +// for the customer and emails the public bind link. Honesty rules: +// - F1: no registered email → nothing is minted, LOUD flash (a link no one can receive is useless). +// - F2: email send fails → the just-minted token is deleted (not left silently live), LOUD flash. +// +// The plaintext token exists only between minting and the send; it is never logged (only an 8-char +// hash prefix) and never persisted (only its sha256). +func (s *Server) handleSelfBindLinkSend(w http.ResponseWriter, r *http.Request, customerID string) { + if s.selfBindMailer == nil { + http.Error(w, "Self-bind mailer is not configured on this hub", http.StatusBadGateway) + return + } + cfg, err := s.store.GetCustomerConfig(customerID) + if err != nil || cfg == nil { + http.NotFound(w, r) + return + } + // F1: refuse to mint a link that cannot be delivered. + if cfg.Email == "" { + s.logger.Printf("[WARN] self-bind link for %s NOT sent: customer has no registered email", customerID) + http.Redirect(w, r, "/customers/"+customerID+"?flash=selfbind-no-email#tab=setup", http.StatusSeeOther) + return + } + + token, err := configgen.RandomHex(32) // 256-bit capability token + if err != nil { + s.logger.Printf("[ERROR] self-bind link for %s: token generation: %v", customerID, err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + hash := selfBindHash(token) + if err := s.store.MintSelfBindToken(customerID, hash, selfBindTTL); err != nil { + s.logger.Printf("[ERROR] self-bind link for %s: minting token: %v", customerID, err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + + link := selfBindBaseURL + "/bind/" + token + if err := s.selfBindMailer.SendSelfBindEmail(customerID, cfg.Email, link); err != nil { + // F2: delivery failed — do not leave a live capability token behind (the plaintext link is + // already gone from memory, so nobody could ever use it; delete it and surface the failure). + if derr := s.store.DeleteSelfBindTokens(customerID); derr != nil { + s.logger.Printf("[ERROR] self-bind link for %s: send failed AND cleanup failed: send=%v cleanup=%v", customerID, err, derr) + } else { + s.logger.Printf("[ERROR] self-bind link for %s: email send failed, token invalidated (hash %s…): %v", customerID, hash[:8], err) + } + http.Redirect(w, r, "/customers/"+customerID+"?flash=selfbind-send-failed#tab=setup", http.StatusSeeOther) + return + } + if err := s.store.MarkSelfBindEmailed(hash); err != nil { + s.logger.Printf("[WARN] self-bind link sent to %s but emailed_at not recorded: %v", customerID, err) + } + s.logger.Printf("[INFO] self-bind link (hash %s…, valid 7 days) emailed to the registered address of %s", hash[:8], customerID) + http.Redirect(w, r, "/customers/"+customerID+"?flash=selfbind-sent#tab=setup", http.StatusSeeOther) +} diff --git a/hub/internal/web/selfbind_test.go b/hub/internal/web/selfbind_test.go new file mode 100644 index 0000000..c78f044 --- /dev/null +++ b/hub/internal/web/selfbind_test.go @@ -0,0 +1,325 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "testing" + "time" + + "golang.org/x/crypto/bcrypt" + + "gitea.dooplex.hu/admin/felhom-hub/internal/store" +) + +// selfbind_test.go — customer self-bind (v0.66.0, R-27 slice 1), Scenarios A–F. +// +// Each red-proof below names the ONE line to break to turn a scenario red — the guard that the test +// actually pins. If a red-proof does NOT turn its scenario red, the test is hollow. + +const ( + testPass = "alpha beta gamma delta epsilon" // the customer retrieval passphrase (5 words) + testCode = "ABC234" // an appliance console pairing code (raw stored form) + testCodeFmt = "abc-234" // as a human might type it (lowercased, separated) +) + +// selfBindSetup seeds a customer (with passphrase + email) and one registered appliance carrying the +// given console pairing code, and returns the appliance id. Distinct customers must use distinct +// codes (a code shared by two registered appliances is ambiguous → binds nothing). +func selfBindSetup(t *testing.T, st *store.Store, customerID, code string) int64 { + t.Helper() + if err := st.SaveCustomerConfig(&store.CustomerConfig{ + CustomerID: customerID, CustomerName: customerID, APIKey: "k", + RetrievalPassword: testPass, Email: customerID + "@example.test", + }); err != nil { + t.Fatal(err) + } + sshKey := "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHVBv+9slP74+1/vNhiI0OJDrXQ2nvb8iwmIxMfUZn36 host" + if _, _, err := st.RegisterAppliance("uuid-"+customerID, "bc:24:11:98:10:0e", sshKey, `{"product":"N100"}`, "aphash-"+customerID, code); err != nil { + t.Fatal(err) + } + list, _ := st.ListUnclaimedAppliances() + for _, a := range list { + if a.UUID == "uuid-"+customerID { + return a.ID + } + } + t.Fatal("seeded appliance not found") + return 0 +} + +// mintLink mints a self-bind token for the customer and returns the plaintext token (URL segment). +// Each call uses a fresh nonce so distinct calls yield distinct tokens (single-active still applies — +// the store deletes the prior row for the customer on each mint). +var mintNonce int + +func mintLink(t *testing.T, st *store.Store, customerID string, ttl time.Duration) string { + t.Helper() + mintNonce++ + token := "tok-" + customerID + "-" + string(rune('a'+mintNonce%26)) + strconv.Itoa(mintNonce) + if err := st.MintSelfBindToken(customerID, selfBindHash(token), ttl); err != nil { + t.Fatal(err) + } + return token +} + +func bindGET(t *testing.T, s *Server, token string) *httptest.ResponseRecorder { + t.Helper() + rr := httptest.NewRecorder() + s.handleBind(rr, httptest.NewRequest("GET", "/bind/"+token, nil)) + return rr +} + +func bindPOST(t *testing.T, s *Server, token, code, pass string) *httptest.ResponseRecorder { + t.Helper() + form := url.Values{"pairing_code": {code}, "passphrase": {pass}} + req := httptest.NewRequest("POST", "/bind/"+token, strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() + s.handleBind(rr, req) + return rr +} + +// --- Scenario A: happy path (GET renders form; POST with both correct factors stages the bind) --- +func TestSelfBind_A_HappyPath(t *testing.T) { + s, st := newTestServer(t) + id := selfBindSetup(t, st, "acme", testCode) + token := mintLink(t, st, "acme", selfBindTTL) + + if body := bindGET(t, s, token).Body.String(); !strings.Contains(body, "Párosító kód") || !strings.Contains(body, "action=\"/bind/"+token+"\"") { + t.Fatalf("GET did not render the entry form") + } + // A human types the code lowercased + separated and the passphrase with odd spacing — normalization + // must accept both. + rr := bindPOST(t, s, token, testCodeFmt, " Alpha Beta gamma-delta epsilon ") + if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "egy percen belül") { + t.Fatalf("POST success page not rendered: code=%d body=%q", rr.Code, rr.Body.String()) + } + // The appliance is bound to the customer (same effect as an operator bind). + if a, _ := st.GetAppliance(id); a == nil || a.Status != store.ApplianceBound || a.CustomerID != "acme" { + t.Fatalf("appliance not bound: %+v", a) + } + // Provenance event is customer self-bind, not operator/hub. + ev, _ := st.GetLatestEventByType("acme", "appliance_bound") + if ev == nil || ev.Source != "customer_selfbind" { + t.Fatalf("expected customer_selfbind provenance event, got %+v", ev) + } + // One-shot: the token is now consumed; a second open shows the consumed state. + if body := bindGET(t, s, token).Body.String(); !strings.Contains(body, "már fel lett használva") { + t.Fatalf("token not consumed after success") + } + // Red-proof: drop the ConsumeSelfBindToken call (or make BindAppliance the only effect) → the + // token stays live and this consumed-state assertion goes red. +} + +// --- Scenario B: NO ORACLE — wrong code and wrong passphrase yield the SAME generic failure --- +func TestSelfBind_B_NoOracle(t *testing.T) { + s, st := newTestServer(t) + // Two customers with DISTINCT pairing codes so the single-active mint does not cross-invalidate, + // and each has one fresh (attempt-count 1) token — the only difference between the two failure + // pages is then the token in the form action, which we normalize out. + selfBindSetup(t, st, "acme", testCode) + selfBindSetup(t, st, "acme2", "XYZ789") + + t1 := mintLink(t, st, "acme", selfBindTTL) // wrong-code attempt (right passphrase) + t2 := mintLink(t, st, "acme2", selfBindTTL) // wrong-passphrase attempt (right code) + wrongCode := strings.ReplaceAll(bindPOST(t, s, t1, "ZZZ999", testPass).Body.String(), t1, "TOKEN") + wrongPass := strings.ReplaceAll(bindPOST(t, s, t2, "xyz-789", "wrong words here now").Body.String(), t2, "TOKEN") + + if wrongCode != wrongPass { + t.Fatalf("failure pages differ between wrong-code and wrong-passphrase — that is an oracle") + } + // The generic failure must not leak any appliance data. + if !strings.Contains(wrongCode, "nem megfelelőek") { + t.Fatalf("failure page missing the generic banner: %q", wrongCode) + } + if strings.Contains(wrongCode, "uuid-") || strings.Contains(wrongCode, "N100") { + t.Fatalf("failure page leaked appliance data") + } + // Red-proof: give wrong-code and wrong-passphrase distinct messages/states (an oracle) → the + // wrongCode == wrongPass assertion goes red. +} + +// --- Scenario C1: LOCKOUT after 5 failed attempts; a subsequent CORRECT attempt cannot bind --- +func TestSelfBind_C1_Lockout(t *testing.T) { + s, st := newTestServer(t) + id := selfBindSetup(t, st, "acme", testCode) + token := mintLink(t, st, "acme", selfBindTTL) + + for i := 1; i <= store.SelfBindMaxAttempts; i++ { + rr := bindPOST(t, s, token, "ZZZ999", "definitely wrong words indeed") + if i < store.SelfBindMaxAttempts && !strings.Contains(rr.Body.String(), "nem megfelelőek") { + t.Fatalf("attempt %d should re-render the form with a failure, got %q", i, rr.Body.String()) + } + } + // The 5th failure locked it: even the CORRECT secrets now bind nothing. + rr := bindPOST(t, s, token, testCodeFmt, testPass) + if !strings.Contains(rr.Body.String(), "zárolva") { + t.Fatalf("token not locked after %d failures: %q", store.SelfBindMaxAttempts, rr.Body.String()) + } + if a, _ := st.GetAppliance(id); a.Status != store.ApplianceRegistered { + t.Fatalf("a locked link still bound the appliance: %+v", a) + } + // Red-proof: remove the `locked = attempts >= SelfBindMaxAttempts` lock (never lock) → the correct + // post-lockout POST binds and both the "zárolva" and still-registered assertions go red. +} + +// --- Scenario C4: SINGLE-ACTIVE per customer — re-minting kills the prior link --- +func TestSelfBind_C4_SingleActive(t *testing.T) { + s, st := newTestServer(t) + selfBindSetup(t, st, "acme", testCode) + + first := mintLink(t, st, "acme", selfBindTTL) + second := mintLink(t, st, "acme", selfBindTTL) // re-mint for the SAME customer + + if body := bindGET(t, s, first).Body.String(); !strings.Contains(body, "érvénytelen vagy lejárt") { + t.Fatalf("the first link still resolves after a re-mint (not single-active): %q", body) + } + if body := bindGET(t, s, second).Body.String(); !strings.Contains(body, "Párosító kód") { + t.Fatalf("the freshly-minted link does not render the form: %q", body) + } + // Red-proof: drop the `DELETE FROM selfbind_tokens WHERE customer_id` in MintSelfBindToken → the + // first link still resolves and the érvénytelen assertion goes red. +} + +// --- Scenario D: the operator auth gate is intact — self-bind's exemption did NOT open other routes --- +func TestSelfBind_D_AuthGateIntact(t *testing.T) { + s, st := newAuthServer(t) + selfBindSetup(t, st, "acme", testCode) + h := s.RequireAuth(http.HandlerFunc(s.ServeHTTP)) + + for _, path := range []string{"/", "/hosts", "/customers/acme", "/configuration", "/offsite"} { + rr := httptest.NewRecorder() + h.ServeHTTP(rr, httptest.NewRequest("GET", path, nil)) + if rr.Code != http.StatusFound || rr.Header().Get("Location") != "/login" { + t.Fatalf("gated route %s not redirected to /login: code=%d loc=%q", path, rr.Code, rr.Header().Get("Location")) + } + } + // Red-proof: this is the companion to Scenario E — widening isPublicBindPath turns THIS red too. +} + +// --- Scenario E: THE TRAP — /bind/ is exempt from operator auth, matched TIGHTLY (no leak/traversal) --- +func TestSelfBind_E_TheTrap(t *testing.T) { + s, st := newAuthServer(t) + selfBindSetup(t, st, "acme", testCode) + token := mintLink(t, st, "acme", selfBindTTL) + h := s.RequireAuth(http.HandlerFunc(s.ServeHTTP)) + + // The public bind link renders WITHOUT a login (the exemption works). + rr := httptest.NewRecorder() + h.ServeHTTP(rr, httptest.NewRequest("GET", "/bind/"+token, nil)) + if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "Párosító kód") { + t.Fatalf("public /bind/ link was gated or not rendered: code=%d", rr.Code) + } + + // A sibling prefix must NOT be exempt: /bindsecret is still gated (tight trailing-slash match). + rr = httptest.NewRecorder() + h.ServeHTTP(rr, httptest.NewRequest("GET", "/bindsecret", nil)) + if rr.Code != http.StatusFound { + t.Fatalf("/bindsecret leaked through the exemption (prefix not tight): code=%d", rr.Code) + } + + // Traversal through the exempt prefix must not reach a gated handler: it stays inside handleBind, + // which rejects a token containing '/', never touching /hosts. + rr = httptest.NewRecorder() + h.ServeHTTP(rr, httptest.NewRequest("GET", "/bind/../hosts", nil)) + if strings.Contains(rr.Body.String(), "Unclaimed appliances") || strings.Contains(rr.Body.String(), "No hosts enrolled") { + t.Fatalf("path traversal through /bind/ reached the hosts page") + } + // Red-proof: widen isPublicBindPath to strings.HasPrefix(path, "/bind") (drop the slash) → the + // /bindsecret gated assertion goes red; broaden it further and Scenario D goes red too. +} + +// --- Scenario F: EXPIRY falls back — an expired link binds nothing, even with correct factors --- +func TestSelfBind_F_ExpiryFallsBack(t *testing.T) { + s, st := newTestServer(t) + id := selfBindSetup(t, st, "acme", testCode) + token := mintLink(t, st, "acme", -1*time.Hour) // already expired + + if body := bindGET(t, s, token).Body.String(); !strings.Contains(body, "érvénytelen vagy lejárt") { + t.Fatalf("expired link did not render the expired state") + } + // Even the CORRECT secrets on an expired link bind nothing (operator-bind fallback is unchanged). + bindPOST(t, s, token, testCodeFmt, testPass) + if a, _ := st.GetAppliance(id); a.Status != store.ApplianceRegistered { + t.Fatalf("an expired link still bound the appliance: %+v", a) + } + // Red-proof: drop the `expires_at > datetime('now')` guard in ConsumeSelfBindToken AND the + // tok.Expired() gate → the expired POST binds and the still-registered assertion goes red. +} + +// --- Scenario (F1/F2): the operator MINT honesty paths --- + +// F1: a customer with no registered email → nothing minted, LOUD flash. +func TestSelfBind_MintNoEmail(t *testing.T) { + s, st := newTestServer(t) + if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "noemail", APIKey: "k", RetrievalPassword: testPass}); err != nil { + t.Fatal(err) + } + s.SetSelfBindMailer(&stubMailer{}) + rr := httptest.NewRecorder() + req := httptest.NewRequest("POST", "/customers/noemail/selfbind-link", nil) + s.handleSelfBindLinkSend(rr, req, "noemail") + if rr.Code != http.StatusSeeOther || !strings.Contains(rr.Header().Get("Location"), "selfbind-no-email") { + t.Fatalf("no-email mint should redirect with selfbind-no-email: code=%d loc=%q", rr.Code, rr.Header().Get("Location")) + } + if tok, _ := st.SelfBindTokenByHash(selfBindHash("x")); tok != nil { + t.Fatal("a token was minted despite no email") + } +} + +// F2: the email send fails → the just-minted token is deleted (not left silently live). +func TestSelfBind_MintSendFailsCleansUp(t *testing.T) { + s, st := newTestServer(t) + selfBindSetup(t, st, "acme", testCode) + stub := &stubMailer{fail: true} + s.SetSelfBindMailer(stub) + rr := httptest.NewRecorder() + s.handleSelfBindLinkSend(rr, httptest.NewRequest("POST", "/customers/acme/selfbind-link", nil), "acme") + if !strings.Contains(rr.Header().Get("Location"), "selfbind-send-failed") { + t.Fatalf("send failure should redirect with selfbind-send-failed: %q", rr.Header().Get("Location")) + } + // The token that was minted for the (failed) send is gone — not left silently live (F2 cleanup). + // Recover the token from the link the mailer was handed, and confirm it no longer resolves. + tokenFromLink := stub.link[strings.LastIndexByte(stub.link, '/')+1:] + if tokenFromLink == "" { + t.Fatal("mailer was never handed a link") + } + if tok, _ := st.SelfBindTokenByHash(selfBindHash(tokenFromLink)); tok != nil { + t.Fatal("a failed send left a live token behind") + } +} + +// stubMailer records the last send and can be told to fail. +type stubMailer struct { + fail bool + link string +} + +func (m *stubMailer) SendSelfBindEmail(customerID, email, link string) error { + m.link = link + if m.fail { + return errStubSend + } + return nil +} + +var errStubSend = &stubErr{} + +type stubErr struct{} + +func (*stubErr) Error() string { return "stub send failure" } + +// newAuthServer is newTestServer with an operator password configured, so RequireAuth is live. +func newAuthServer(t *testing.T) (*Server, *store.Store) { + t.Helper() + s, st := newTestServer(t) + h, err := bcrypt.GenerateFromPassword([]byte("operator-pw"), bcrypt.MinCost) + if err != nil { + t.Fatal(err) + } + s.configPasswordHash = string(h) + return s, st +} diff --git a/hub/internal/web/server.go b/hub/internal/web/server.go index 16997a1..82a2e2e 100644 --- a/hub/internal/web/server.go +++ b/hub/internal/web/server.go @@ -71,6 +71,8 @@ type Server struct { pbsdrBox func() (monitor.PBSBoxSnapshot, bool) // optional (v0.65.0, R-5); the PBS-DR datastore fill snapshot accessor tenantsync tenancyProvisioner // optional; enables PBS DR tier provisioning (web/pbsdr.go) claimEngine *claim.Engine // optional; enables the customer-claim resend button (v0.50.0) + selfBindMailer SelfBindMailer // optional; enables the customer self-bind link button (v0.66.0, R-27) + bindLimiter *bindRateLimiter // per-IP throttle for the PUBLIC /bind/ surface (v0.66.0, R-27) // intentHub (v0.58.0, Direction-2 immediate-sync) is Bumped by every operator-intent handler // (config save/delete, claim resend, offsite re-issue/freeze, floor, block/unblock, log pull) // so a box long-polling GET /api/v1/wait wakes in seconds. Shared with the API handler. nil = @@ -129,6 +131,7 @@ func New(store *store.Store, passwordHash, apiKey, version string, staleThreshol templates: tmpl, staleThreshold: staleThreshold, sessions: make(map[string]*hubSession), + bindLimiter: newBindRateLimiter(30), // public /bind/ surface: 30 req/min/IP burst (R-27) } } @@ -253,7 +256,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { // CSRF protection for all state-changing requests (web routes only). // API routes (/api/v1/) are Bearer-token authenticated and exempt. if r.Method != http.MethodGet && r.Method != http.MethodHead && r.Method != http.MethodOptions { - if path != "/login" && s.effectivePasswordHash() != "" { + // /bind/ is the public customer self-bind surface (THE TRAP §9.2): no operator session to + // ride, so CSRF is exempt here exactly as it is for /login. The URL capability token is the + // authorization boundary; a cross-site POST without both secrets only burns attempts. + if path != "/login" && !isPublicBindPath(path) && s.effectivePasswordHash() != "" { if !s.validateCSRF(r) { s.logger.Printf("[WARN] CSRF rejected: %s %s from %s", r.Method, path, r.RemoteAddr) http.Error(w, "CSRF token missing or invalid. Please reload the page.", http.StatusForbidden) @@ -377,6 +383,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.handleHostDetail(w, r, hostID) case path == "/login": s.handleLogin(w, r) + case isPublicBindPath(path): + // PUBLIC customer self-bind (R-27 slice 1) — GET renders the form/state, POST validates the + // two factors. Auth + CSRF exempt above via the SAME isPublicBindPath predicate. + s.handleBind(w, r) case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/block"): customerID := strings.TrimPrefix(path, "/customers/") customerID = strings.TrimSuffix(customerID, "/block") @@ -385,6 +395,15 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { } else { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } + case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/selfbind-link"): + // R-27 slice 1: mint + email a customer self-bind capability link. POST only. + customerID := strings.TrimPrefix(path, "/customers/") + customerID = strings.TrimSuffix(customerID, "/selfbind-link") + if r.Method == http.MethodPost { + s.handleSelfBindLinkSend(w, r, customerID) + } else { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/unblock"): customerID := strings.TrimPrefix(path, "/customers/") customerID = strings.TrimSuffix(customerID, "/unblock") @@ -561,8 +580,12 @@ func (s *Server) RequireAuth(next http.Handler) http.Handler { return } - // Always allow the login page through (GET and POST) - if r.URL.Path == "/login" { + // Always allow the login page through (GET and POST), and the PUBLIC customer self-bind + // surface (THE TRAP §9.2): /bind/ is exempt from operator auth exactly as /login is — the + // emailed URL capability token IS the auth model there. isPublicBindPath is the SINGLE + // definition of the prefix (matched tightly: trailing slash, path already .. -cleaned by the + // ServeMux) so this exemption cannot reach any operator-gated route. + if r.URL.Path == "/login" || isPublicBindPath(r.URL.Path) { next.ServeHTTP(w, r) return } diff --git a/hub/internal/web/templates/customer_unified.html b/hub/internal/web/templates/customer_unified.html index e49b1ee..fd1d38d 100644 --- a/hub/internal/web/templates/customer_unified.html +++ b/hub/internal/web/templates/customer_unified.html @@ -55,6 +55,9 @@ {{else if eq .Flash "log_tail_requested"}}Log tail requested — the controller delivers it on its next report cycle (a few minutes). A customer-visible event line was recorded. {{else if eq .Flash "claim-resent"}}Code re-sent to the registered address. A kód a doboz következő jelentésekor (~15 percen belül) aktiválódik. {{else if eq .Flash "claim-resend-failed"}}Claim code resend FAILED — check the hub log (email delivery / send error). + {{else if eq .Flash "selfbind-sent"}}Self-bind link sent to the registered address — valid for 7 days. The customer enters the box's console pairing code + their retrieval passphrase; no operator bind needed. + {{else if eq .Flash "selfbind-no-email"}}Self-bind link NOT sent — this customer has no registered email address. Set one first, or bind the appliance manually from the Hosts page. + {{else if eq .Flash "selfbind-send-failed"}}Self-bind link send FAILED — the link was invalidated (not left live). Check the hub log (email delivery / send error). {{else if eq .Flash "reset_done"}}Customer RESET complete — every operational trace was destroyed (offsite repo, PBS namespace, DR recipe, claim state, retained escrow custody). Identity and basic config survive; the audit event stream records it. {{end}} @@ -454,6 +457,15 @@ {{end}} + +
+ + Let the customer bind their own freshly-installed appliance — no operator bind needed. Sends a 7-day capability link to the registered address ({{.Email}}); the customer opens it and enters the box's console pairing code + their retrieval passphrase. Wrong entries lock the link after 5 attempts. If the link expires, bind the appliance manually from the Hosts page. +
+ {{.CSRFField}} + +
+
diff --git a/hub/internal/web/templates/hosts.html b/hub/internal/web/templates/hosts.html index 0d03e6f..d713724 100644 --- a/hub/internal/web/templates/hosts.html +++ b/hub/internal/web/templates/hosts.html @@ -39,7 +39,7 @@
- + {{range .Unclaimed}} @@ -48,6 +48,7 @@ {{if .Stale}}
stale{{end}} {{if .Bound}}
bound → {{.BoundCustomer}}{{end}} + diff --git a/scripts/CHANGELOG.md b/scripts/CHANGELOG.md index cb05ec3..092aae4 100644 --- a/scripts/CHANGELOG.md +++ b/scripts/CHANGELOG.md @@ -1,5 +1,17 @@ # Felhom scripts — Changelog +## build-felhom-iso.sh v1.20.0 — console pairing-code banner for customer self-bind (R-27 slice 1) (2026-07-17) + +Supports the hub's customer self-bind flow (hub v0.66.0). In PAIRING mode, `felhom-bootstrap.sh` now +reads the additive `pairing_code` from the `POST /api/v1/appliance/register` response, persists it at +`/etc/felhom/appliance-pairing-code`, and prints a Hungarian **console banner** (to `/dev/console`, +stdout fallback) each pairing cycle so the customer can read the code off the physical screen and type +it — together with their retrieval passphrase — on the hub's public `/bind/` page. The code is +**non-secret** (possession proof only; the passphrase is the second factor), so it is safe on the +console. **Graceful degradation both ways:** a hub older than v0.66.0 omits `pairing_code` → the banner +prints nothing and register/poll are unchanged; an old ISO against a v0.66.0 hub simply ignores the new +field. No change to DIRECT mode. Green: `bash -n` clean on both scripts. + ## build-felhom-iso.sh v1.19.0 — the universal secret-free ISO: `--pairing` mode (R-21 slice C) (2026-07-17) The scripts half of the universal ISO. `felhom-bootstrap.sh` gains a PAIRING mode — **one unit, two diff --git a/scripts/iso/build-felhom-iso.sh b/scripts/iso/build-felhom-iso.sh index 12d5bea..cb60d57 100644 --- a/scripts/iso/build-felhom-iso.sh +++ b/scripts/iso/build-felhom-iso.sh @@ -32,7 +32,7 @@ #=============================================================================== set -euo pipefail -ISO_VERSION="1.19.0" # Felhom release the ISO is tagged to (aligns with felhom-host-install SCRIPT_VERSION). +ISO_VERSION="1.20.0" # Felhom release the ISO is tagged to (aligns with felhom-host-install SCRIPT_VERSION). IMAGE="${FELHOM_ISO_ASSISTANT_IMAGE:-felhom-iso-assistant:trixie}" HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" diff --git a/scripts/iso/felhom-bootstrap.sh b/scripts/iso/felhom-bootstrap.sh index 7a83bf6..5783fae 100644 --- a/scripts/iso/felhom-bootstrap.sh +++ b/scripts/iso/felhom-bootstrap.sh @@ -33,9 +33,26 @@ STATE_FILE=/var/lib/felhom-install/state.json PASS_FILE=/run/felhom-bootstrap-pass SCRIPT_TMP=/run/felhom-host-install.sh TOKEN_FILE=/etc/felhom/appliance-token # PAIRING: the box's only pre-day-0 credential (0600, persists reboots) +PAIRING_CODE_FILE=/etc/felhom/appliance-pairing-code # R-27: non-secret pairing code shown on the console log() { echo "felhom-bootstrap: $*"; } +# print_pairing_banner (R-27, v0.66.0) — show the pairing code prominently on the physical console while +# the box waits to be bound, so the customer can read it into the self-bind page. Non-secret (the bind +# still requires the customer's retrieval passphrase). A hub older than v0.66.0 sends no code → no banner +# (the box stays operator-bind-only — graceful, no behavior change). +print_pairing_banner() { + local code; code=$(cat "$PAIRING_CODE_FILE" 2>/dev/null) + [[ -n "$code" ]] || return 0 + { printf '\n================================================\n' + printf ' Felhom — a doboz parositasra var / párosításra vár\n\n' + printf ' Párosító kód: %s\n\n' "$code" + printf ' Nyisd meg az e-mailben kapott self-bind linket,\n' + printf ' és add meg ezt a kódot + a jelszavadat.\n' + printf '================================================\n\n' + } > /dev/console 2>/dev/null || printf 'Párosító kód: %s\n' "$code" +} + cleanup_pass() { [[ -e "$PASS_FILE" ]] && { shred -u "$PASS_FILE" 2>/dev/null || rm -f "$PASS_FILE"; }; return 0; } trap cleanup_pass EXIT @@ -175,8 +192,14 @@ run_pairing() { exit 1 fi ( umask 077; printf '%s' "$token" > "$TOKEN_FILE" ) - log "registered — appliance token stored (0600); waiting for the operator to bind this box" + # R-27 (v0.66.0): persist the non-secret pairing code (absent on a pre-v0.66.0 hub — tolerated). + local pcode; pcode=$(printf '%s' "$resp" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("pairing_code",""))' 2>/dev/null) + if [[ -n "$pcode" ]]; then + printf '%s' "$pcode" > "$PAIRING_CODE_FILE" + fi + log "registered — appliance token stored (0600); waiting for the operator or a customer self-bind" fi + print_pairing_banner # show the code on the console each pairing cycle # 2. ONE poll. RestartSec=30 is the poll interval. local token; token=$(cat "$TOKEN_FILE")
ApplianceMACsHardwareSSH host keysSeenBind to customer
AppliancePairing codeMACsHardwareSSH host keysSeenBind to customer
{{if .PairingCode}}{{.PairingCode}}{{else}}{{end}} {{range .MACs}}{{.}}
{{end}}
{{if .Product}}{{.Product}}
{{end}}{{if .CPU}}{{.CPU}}
{{end}}{{if .MemGB}}{{.MemGB}}{{end}}
{{range .SSHFingerprints}}{{.}}
{{end}}