hub v0.66.0 + ISO v1.20.0: customer self-bind (R-27 slice 1)
Let a customer bind their own freshly-installed appliance without the operator: operator "Send self-bind link" mints a 7-day tokenized capability link, emailed (Hungarian, sibling sender) to the customer, who opens a public /bind/<token> page and proves two factors — the console pairing code shown on the box screen + their retrieval passphrase — and the hub stages the bind via the same BindAppliance (provenance customer_selfbind). The box's ~30s appliance poll delivers. Viktor's three rulings verbatim: console pairing code (no appliance list ever rendered), operator-sent tokenized link, 5-attempt lockout -> "call support". Wrong code == wrong passphrase (one generic failure, no oracle, both factors compared unconditionally); expiry falls back to operator-bind unchanged. THE TRAP: one public prefix /bind/, exempt from auth+CSRF at both /login gate sites via a single isPublicBindPath predicate (tight trailing-slash match; ServeMux ..-cleans; handler rejects '/' in token). 9 tests (Scenarios A-F + F1/F2); 4 red-proofs verified red-then-green (lockout, oracle, widened-prefix, single-active). GC verdict: no appliance GC -> the 7-day TTL stands alone. Controller/agent untouched; R-27b deferred. Green: full hub build/vet/test (17 ok) + bash -n + hub confirm gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017qDiBqKKQ5vPB5fXBqu7Kp
This commit is contained in:
@@ -1,5 +1,58 @@
|
||||
# Felhom Hub — Changelog
|
||||
|
||||
## v0.66.0 — Customer self-bind (R-27 slice 1): tokenized capability link + public two-factor `/bind/` page (2026-07-17)
|
||||
|
||||
Lets a customer bind their OWN freshly-installed appliance without the operator. Until now every
|
||||
box booted from the universal secret-free ISO (R-21 slice C) had to be bound by the operator on the
|
||||
Hosts page; this adds the self-service path. **Viktor's three rulings, each honoured verbatim:**
|
||||
(a) *"only their own visible"* → the customer proves possession with the **console pairing code**
|
||||
shown on the box screen — **no appliance list is ever rendered** on any public surface; (b) *first-box
|
||||
entry* → an **operator-sent 7-day tokenized capability link** over Hungarian email (the claim-engine
|
||||
delivery pattern, a sibling sender — NOT routed through the claim engine); (c) **lockout after 5
|
||||
failed attempts** → the token locks and the page says *"call support"*. Wrong code and wrong
|
||||
passphrase produce **one identical generic failure** (no oracle); an expired link falls back to
|
||||
operator-bind, unchanged. Does **not** touch the controller or agent. Green: `go build/vet/test`
|
||||
(full hub suite + 9 new self-bind tests), all 4 red-proofs verified red-then-green, hub confirm gate.
|
||||
|
||||
- **Pairing code (Part 1).** `POST /api/v1/appliance/register` now returns an additive `pairing_code`
|
||||
(6 chars, ambiguity-free alphabet, `ABC-234` display) minted once at first registration and stable
|
||||
across the idempotent re-register/upsert (backfilled if a pre-existing row had none). Persisted on
|
||||
`appliance_registrations.pairing_code`; shown in the operator Hosts "Unclaimed appliances" table.
|
||||
The bootstrap (`felhom-bootstrap.sh`, ISO v1.20.0) parses it and prints a Hungarian **console
|
||||
banner** to `/dev/console` so the customer can read it off the screen. Old ISOs ignore the field;
|
||||
an old hub omits it and the banner prints nothing — additive both ways.
|
||||
- **Capability token (Part 2).** New `selfbind_tokens` table: `sha256(token)` at rest (never the
|
||||
token), **single-active per customer** (a re-mint deletes the prior row in one tx — an old link dies
|
||||
the instant a new one is sent), `attempts` locking at 5, one-shot `consumed_at`, `emailed_at`
|
||||
honesty. Operator **"Send self-bind link"** button on the customer Setup tab
|
||||
(`POST /customers/{id}/selfbind-link`) mints a 256-bit token and emails
|
||||
`https://hub.felhom.eu/bind/<token>` (Hungarian, adult tone, no emoji, names both factors + the
|
||||
7-day + 5-attempt limits). **F1** (no registered email → nothing minted, LOUD flash) and **F2**
|
||||
(email send fails → the just-minted token is deleted, not left silently live) are both honest.
|
||||
- **Public bind page (Part 3) — THE TRAP (§9.2).** One new public prefix `/bind/`, exempted from
|
||||
operator auth AND CSRF at the two gate sites the `/login` exemption occupies, via a **single**
|
||||
predicate `isPublicBindPath` (matched tightly: trailing slash → no sibling like `/bindsecret`; the
|
||||
ServeMux `..`-cleans before we see the path → no traversal reach; the handler also rejects a token
|
||||
containing `/`). `GET` renders form/consumed/locked/expired; `POST` normalizes both inputs, compares
|
||||
**both factors unconditionally** (constant-time passphrase vs the customer's retrieval passphrase;
|
||||
the ONE bindable appliance carrying the console code), then decides — identical generic failure
|
||||
either way. On success: the **same `BindAppliance`** the operator uses, a provenance event with
|
||||
source `customer_selfbind`, one-shot consume, and *"A doboz kb. egy percen belül folytatja a
|
||||
telepítést."* The box's ~30 s appliance poll picks up the delivery. Own per-IP rate limiter; the
|
||||
page is self-contained (it cannot link `/style.css`, which is itself operator-gated). No hub
|
||||
customer-login/session was built — the URL capability token IS the auth model; a cross-site POST
|
||||
without both secrets only burns attempts (accepted + documented).
|
||||
- **Tests + red-proofs (Part 4).** Scenarios A–F + F1/F2 (9 tests). The **4 red-proofs** were each
|
||||
applied and confirmed to turn exactly their scenario red, then reverted green: lockout removed → C1;
|
||||
oracle introduced → B; `/bind/` prefix widened (drop the slash) → E (and D); single-active DELETE
|
||||
dropped → C4. The passphrase is never logged/echoed/persisted; only attempt COUNTS are logged
|
||||
(`self-bind attempt N/5 … token <8hex>…`); the raw link token never enters logs or events.
|
||||
- **GC verdict (spec §3):** there is **no appliance-staleness GC** in the hub (`applianceStaleAfter`
|
||||
is a DISPLAY badge only; `pruneAll`/`PurgeExpiredLogBundles` touch reports/log-bundles, not
|
||||
appliances or selfbind tokens). The 7-day token TTL therefore stands alone and needs no reaper —
|
||||
single-active-per-customer means at most one row per customer, superseded rows are deleted on
|
||||
re-mint, and an expired row simply reads as expired (no security or storage pressure).
|
||||
|
||||
## v0.65.0 — PBS DR storage visibility (ep0 `usage` op) + Offsite tab split (Restic / PBS DR) + dual dashboard gauges (R-5) (2026-07-17)
|
||||
|
||||
Makes the **PBS DR** storage visible like the restic pool box already is (v0.64.0), the two clearly
|
||||
|
||||
+2
-1
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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, "-")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, ",")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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/<token> 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 = `<!DOCTYPE html>
|
||||
<html lang="hu">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>Felhom — Doboz összekötése</title>
|
||||
<style>
|
||||
:root { --navy:#0A2540; --ink:#0A2540; --muted:#5b6b7d; --line:#d8e0e8; --brand:#0083D8; --exc:#c0392b; --bg:#f4f7fa; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; background:var(--bg); color:var(--ink); line-height:1.5; }
|
||||
.wrap { max-width:460px; margin:3rem auto; padding:0 1rem; }
|
||||
.card { background:#fff; border:1px solid var(--line); border-radius:2px; padding:1.75rem; }
|
||||
h1 { font-size:1.25rem; margin:0 0 .25rem; color:var(--navy); }
|
||||
h1 span { color:var(--brand); }
|
||||
.lead { color:var(--muted); font-size:.92rem; margin:.25rem 0 1.25rem; }
|
||||
label { display:block; font-weight:600; font-size:.9rem; margin:1rem 0 .35rem; }
|
||||
.hint { color:var(--muted); font-size:.8rem; margin:.15rem 0 0; }
|
||||
input[type=text] { width:100%; padding:.6rem .7rem; border:1px solid var(--line); border-radius:2px; font-size:1rem; }
|
||||
input.code { text-transform:uppercase; letter-spacing:.12em; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }
|
||||
button { margin-top:1.5rem; width:100%; padding:.7rem; background:var(--brand); color:#fff; border:none; border-radius:2px; font-size:1rem; cursor:pointer; }
|
||||
button:hover { background:#006cb0; }
|
||||
.banner { border:1px solid var(--exc); color:var(--exc); background:#fbeae8; border-radius:2px; padding:.6rem .75rem; font-size:.88rem; margin:0 0 1rem; }
|
||||
.note { border-top:1px solid var(--line); margin-top:1.5rem; padding-top:1rem; color:var(--muted); font-size:.82rem; }
|
||||
.foot { text-align:center; color:var(--muted); font-size:.75rem; margin-top:1.25rem; }
|
||||
.ok { color:#1e7e34; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="card">
|
||||
<h1>Felhom <span>doboz</span> összekötése</h1>
|
||||
{{if eq .State "form"}}
|
||||
<p class="lead">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.</p>
|
||||
{{if .Failed}}<div class="banner">A megadott adatok nem megfelelőek. Ellenőrizd a párosító kódot és a jelszót, majd próbáld újra.</div>{{end}}
|
||||
<form method="POST" action="/bind/{{.Token}}">
|
||||
<label for="pairing_code">Párosító kód</label>
|
||||
<input class="code" type="text" id="pairing_code" name="pairing_code" autocomplete="off" autocapitalize="characters" spellcheck="false" required autofocus placeholder="ABC-234">
|
||||
<p class="hint">A doboz monitorán jelenik meg, a telepítés után.</p>
|
||||
<label for="passphrase">Visszaállító jelszó</label>
|
||||
<input type="text" id="passphrase" name="passphrase" autocomplete="off" spellcheck="false" required placeholder="öt szó, kötőjellel vagy szóközzel">
|
||||
<p class="hint">Az öt szóból álló kifejezés, amelyet a beállításkor kaptál.</p>
|
||||
<button type="submit">Összekötés</button>
|
||||
</form>
|
||||
<p class="note">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.</p>
|
||||
{{else if eq .State "success"}}
|
||||
<p class="lead ok">Sikeres összekötés.</p>
|
||||
<p>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.</p>
|
||||
{{else if eq .State "consumed"}}
|
||||
<p class="lead">Ez a hivatkozás már fel lett használva.</p>
|
||||
<p>A doboz összekötése megtörtént. Ha úgy gondolod, hogy ez tévedés, vedd fel a kapcsolatot az ügyfélszolgálattal.</p>
|
||||
{{else if eq .State "locked"}}
|
||||
<p class="lead">Ez a hivatkozás zárolva van.</p>
|
||||
<p>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.</p>
|
||||
{{else}}
|
||||
<p class="lead">Ez a hivatkozás érvénytelen vagy lejárt.</p>
|
||||
<p>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.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
<p class="foot">Felhom.eu</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
@@ -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/<token>.
|
||||
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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}}
|
||||
</div>
|
||||
@@ -454,6 +457,15 @@
|
||||
<button type="submit" class="btn btn-outline btn-sm" data-confirm="Send a fresh code to the registered address? The previous code stops working immediately (the box activates it on its next report, ~15 min).">{{if .Claim.ClaimedAt}}Visszaállító kód küldése{{else}}Kód újraküldése{{end}}</button>
|
||||
</form>
|
||||
{{end}}
|
||||
|
||||
<div class="form-group" style="margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border);">
|
||||
<label class="form-label">Customer self-bind (R-27)</label>
|
||||
<span class="form-hint">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 <strong>console pairing code</strong> + their <strong>retrieval passphrase</strong>. Wrong entries lock the link after 5 attempts. If the link expires, bind the appliance manually from the Hosts page.</span>
|
||||
<form method="POST" action="/customers/{{.CustomerID}}/selfbind-link" style="margin-top: 0.5rem;">
|
||||
{{.CSRFField}}
|
||||
<button type="submit" class="btn btn-outline btn-sm" data-confirm="Email a self-bind link to the registered address? Any previous self-bind link for this customer stops working immediately.">Send self-bind link</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
<div style="overflow-x: auto;">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr><th>Appliance</th><th>MACs</th><th>Hardware</th><th>SSH host keys</th><th>Seen</th><th>Bind to customer</th><th></th></tr>
|
||||
<tr><th>Appliance</th><th>Pairing code</th><th>MACs</th><th>Hardware</th><th>SSH host keys</th><th>Seen</th><th>Bind to customer</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Unclaimed}}
|
||||
@@ -48,6 +48,7 @@
|
||||
{{if .Stale}}<br><span class="status-badge status-warn" title="No poll in over 7 days">stale</span>{{end}}
|
||||
{{if .Bound}}<br><span class="status-badge status-ok" title="Bound — awaiting the box's next poll">bound → {{.BoundCustomer}}</span>{{end}}
|
||||
</td>
|
||||
<td style="font-family: var(--font-mono); letter-spacing: 0.05em;">{{if .PairingCode}}<strong>{{.PairingCode}}</strong>{{else}}<span class="text-muted">—</span>{{end}}</td>
|
||||
<td style="font-size: 0.78em; font-family: var(--font-mono)">{{range .MACs}}{{.}}<br>{{end}}</td>
|
||||
<td style="font-size: 0.8em;">{{if .Product}}{{.Product}}<br>{{end}}{{if .CPU}}<span class="text-muted">{{.CPU}}</span><br>{{end}}{{if .MemGB}}<span class="text-muted">{{.MemGB}}</span>{{end}}</td>
|
||||
<td style="font-size: 0.72em; font-family: var(--font-mono)">{{range .SSHFingerprints}}{{.}}<br>{{end}}</td>
|
||||
|
||||
Reference in New Issue
Block a user