592818492c
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
262 lines
11 KiB
Go
262 lines
11 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// Appliance registration (v0.62.0, R-21 slice C). A box booted from the GENERIC secret-free ISO
|
|
// registers itself here as an unclaimed appliance and polls for its credentials; the operator binds
|
|
// it to a customer; ONE poll then delivers (customer-id + retrieval passphrase) and the record is
|
|
// consumed. Keyed by (uuid, mac_set) — serials are unusable (N100 DMI "Default string") and cheap
|
|
// boards duplicate SMBIOS UUIDs, so the MAC set is the tiebreaker. The appliance token is the box's
|
|
// only pre-day-0 credential; only its sha256 is stored here, never the token itself.
|
|
|
|
// Appliance statuses.
|
|
const (
|
|
ApplianceRegistered = "registered" // seen, awaiting an operator bind
|
|
ApplianceBound = "bound" // bound to a customer; delivery staged, not yet consumed
|
|
ApplianceDelivered = "delivered" // credentials delivered once; every later poll → 410
|
|
ApplianceDiscarded = "discarded" // operator ignored it; token invalidated, polls → 404
|
|
)
|
|
|
|
// ApplianceRegistration is one unclaimed/bound appliance record.
|
|
type ApplianceRegistration struct {
|
|
ID int64
|
|
UUID string
|
|
MACSet string // sorted, comma-joined physical MACs
|
|
SSHHostPubkeys string // newline-joined authorized_keys-format lines
|
|
HWSummary string // JSON blob (product, cpu, mem, mode hint)
|
|
Status string
|
|
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
|
|
DeliveredAt *time.Time
|
|
DiscardedAt *time.Time
|
|
}
|
|
|
|
// RegisterAppliance upserts by (uuid, mac_set) and stores the fresh token's hash. Re-registration
|
|
// updates last_seen and never duplicates. A DISCARDED record stays discarded (sticky — the operator
|
|
// said ignore; its poll keeps returning 404, no oracle). Any other existing record is RESET to
|
|
// `registered` with the fresh token and its staged bind cleared — a box that is re-registering has no
|
|
// 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.
|
|
// 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
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
var id int64
|
|
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, 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 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 = ?, 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 = ?, 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, effectiveCode, id); err != nil {
|
|
return false, "", fmt.Errorf("register appliance update: %w", err)
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return false, "", err
|
|
}
|
|
return false, effectiveCode, nil
|
|
default:
|
|
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(pairing_code,''),
|
|
first_seen, last_seen, bound_at, delivered_at, discarded_at`
|
|
|
|
func scanAppliance(sc interface{ Scan(...any) error }) (*ApplianceRegistration, error) {
|
|
var a 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.PairingCode,
|
|
&firstSeen, &lastSeen, &boundAt, &deliveredAt, &discardedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
a.FirstSeen = parseSQLiteTime(firstSeen)
|
|
a.LastSeen = parseSQLiteTime(lastSeen)
|
|
if boundAt.Valid && boundAt.String != "" {
|
|
t := parseSQLiteTime(boundAt.String)
|
|
a.BoundAt = &t
|
|
}
|
|
if deliveredAt.Valid && deliveredAt.String != "" {
|
|
t := parseSQLiteTime(deliveredAt.String)
|
|
a.DeliveredAt = &t
|
|
}
|
|
if discardedAt.Valid && discardedAt.String != "" {
|
|
t := parseSQLiteTime(discardedAt.String)
|
|
a.DiscardedAt = &t
|
|
}
|
|
return &a, nil
|
|
}
|
|
|
|
// ApplianceByToken resolves a token hash to its record (nil, nil when unknown — the poll maps that to
|
|
// 404, indistinguishable from a discarded/never-registered token: no enumeration oracle).
|
|
func (s *Store) ApplianceByToken(tokenHash string) (*ApplianceRegistration, error) {
|
|
if tokenHash == "" {
|
|
return nil, nil
|
|
}
|
|
a, err := scanAppliance(s.db.QueryRow(`SELECT `+applianceCols+` FROM appliance_registrations WHERE token_hash = ?`, tokenHash))
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
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))
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
return a, err
|
|
}
|
|
|
|
// ListUnclaimedAppliances returns the registered + bound (not-yet-delivered/discarded) records for the
|
|
// operator's "Unclaimed appliances" section, newest activity first.
|
|
func (s *Store) ListUnclaimedAppliances() ([]ApplianceRegistration, error) {
|
|
rows, err := s.db.Query(`SELECT ` + applianceCols + ` FROM appliance_registrations
|
|
WHERE status IN ('registered','bound') ORDER BY last_seen DESC`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []ApplianceRegistration
|
|
for rows.Next() {
|
|
a, err := scanAppliance(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, *a)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// BindAppliance stages the delivery: bind an appliance to a customer + record what the box consumes
|
|
// on its next poll (customer-id comes from the record; mode/extra ride here). Allowed from registered
|
|
// or an already-bound-not-delivered state (operator re-bind / changed mind). Refuses once delivered or
|
|
// discarded.
|
|
func (s *Store) BindAppliance(id int64, customerID, mode, extraArgs string) error {
|
|
res, err := s.db.Exec(`UPDATE appliance_registrations
|
|
SET status = 'bound', customer_id = ?, install_mode = ?, extra_args = ?, bound_at = datetime('now')
|
|
WHERE id = ? AND status IN ('registered','bound')`, customerID, mode, extraArgs, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
return fmt.Errorf("appliance %d not bindable (missing, delivered, or discarded)", id)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MarkApplianceDelivered atomically flips bound→delivered EXACTLY ONCE. ok=true for the single winning
|
|
// poll; ok=false for every later poll (already delivered) or a lost race — the handler maps ok=false
|
|
// on a bound-looking record to 410. This is the one-shot delivery gate.
|
|
func (s *Store) MarkApplianceDelivered(tokenHash string) (ok bool, err error) {
|
|
res, err := s.db.Exec(`UPDATE appliance_registrations
|
|
SET status = 'delivered', delivered_at = datetime('now')
|
|
WHERE token_hash = ? AND status = 'bound'`, tokenHash)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
return n == 1, nil
|
|
}
|
|
|
|
// DiscardAppliance marks a registration ignored and INVALIDATES its token (blanks the hash so no poll
|
|
// can ever match it, belt-and-suspenders atop the status check). Sticky: a later re-registration of
|
|
// the same (uuid, mac_set) keeps it discarded.
|
|
func (s *Store) DiscardAppliance(id int64) error {
|
|
res, err := s.db.Exec(`UPDATE appliance_registrations
|
|
SET status = 'discarded', discarded_at = datetime('now'), token_hash = ''
|
|
WHERE id = ?`, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
return fmt.Errorf("appliance %d not found", id)
|
|
}
|
|
return nil
|
|
}
|