hub v0.62.0 + scripts v1.19.0 — R-21 slice C: the universal secret-free ISO
A generic ISO carries NO customer secret. The box registers itself at the hub as an unclaimed appliance; the operator binds it to a customer; the hub delivers the customer-id + retrieval passphrase ONCE; day-0 completes via the slice-A path. Hub (v0.62.0): - store/appliance.go: appliance_registrations keyed by (uuid, mac_set) — MAC set is the tiebreaker (duplicate SMBIOS UUIDs); token stored as sha256 only. Idempotent register (sticky-discard), atomic one-shot delivery, bind/discard. - api/appliance.go: POST /appliance/register (the one unauth endpoint, per-IP rate-limited, 256-bit token); GET /appliance/poll (404 no-oracle / 204 unbound / 200 deliver-once / 410 delivered). Passphrase read live, never logged. - web/appliances.go: Hosts-page "Unclaimed appliances" section + BIND (customer picker, host count display-only) + DISCARD; SSH host-key fingerprints; events. - Red-proofs: one-shot delivery + register idempotency (both proven red); 404-no-oracle, sticky-discard, bind staging, render. Green + confirm gate. Scripts (v1.19.0): - felhom-bootstrap.sh: ONE unit, TWO modes. Direct (env has customer/passphrase) = slice-A path, byte-identical, only branched around. Pairing (generic) = register + poll (RestartSec=30 is the poll timer); on delivery write the env 0600 and fall through to direct. Secrets + token shredded on success. - build-felhom-iso.sh --pairing: generic secret-free ISO, -generic filename, manifest mode=pairing. profiles/generic.profile (new). - test/bootstrap-modes.sh: Scenario D (direct = zero appliance calls) + pairing register/poll + delivery handoff — all green in a debian container.
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
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
|
||||
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.
|
||||
func (s *Store) RegisterAppliance(uuid, macSet, sshKeys, hwSummary, tokenHash string) (isNew bool, err error) {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
case nil:
|
||||
// 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)
|
||||
}
|
||||
} else {
|
||||
if _, err := tx.Exec(`UPDATE appliance_registrations
|
||||
SET token_hash = ?, ssh_host_pubkeys = ?, hw_summary = ?, 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)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return false, 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,''),
|
||||
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,
|
||||
&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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -611,6 +611,36 @@ func (s *Store) migrate() error {
|
||||
legs_json TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_customer_resets_customer ON customer_resets(customer_id, id DESC);
|
||||
|
||||
-- appliance_registrations (v0.62.0, R-21 slice C — the universal secret-free ISO): a box
|
||||
-- booted from the GENERIC ISO registers itself here as an UNCLAIMED appliance, the operator
|
||||
-- binds it to a customer, and one poll delivers the customer-id + retrieval passphrase ONCE.
|
||||
-- Keyed by (uuid, mac_set): the N100 DMI verdict says serials are unusable ("Default string"),
|
||||
-- and cheap boards ship DUPLICATE SMBIOS UUIDs — the MAC set is the tiebreaker, so the same
|
||||
-- uuid with a different mac_set is a DISTINCT appliance. token_hash = sha256(appliance token);
|
||||
-- the token itself is never stored. status: registered→bound→delivered (one-shot) | discarded.
|
||||
-- This table's own timestamps ARE the provenance for the pre-bind phase (no customer to scope a
|
||||
-- customer-events row to yet — mirrors host_deletions/customer_resets self-contained provenance).
|
||||
CREATE TABLE IF NOT EXISTS appliance_registrations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT NOT NULL,
|
||||
mac_set TEXT NOT NULL,
|
||||
ssh_host_pubkeys TEXT NOT NULL DEFAULT '',
|
||||
hw_summary TEXT NOT NULL DEFAULT '',
|
||||
token_hash TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'registered',
|
||||
customer_id TEXT,
|
||||
install_mode TEXT,
|
||||
extra_args TEXT,
|
||||
first_seen DATETIME NOT NULL DEFAULT (datetime('now')),
|
||||
last_seen DATETIME NOT NULL DEFAULT (datetime('now')),
|
||||
bound_at DATETIME,
|
||||
delivered_at DATETIME,
|
||||
discarded_at DATETIME,
|
||||
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);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
Reference in New Issue
Block a user