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:
2026-07-17 15:07:31 +02:00
parent 3172df1927
commit 36c5cd5fdf
16 changed files with 1531 additions and 90 deletions
+28
View File
@@ -1,5 +1,33 @@
# Felhom Hub — Changelog
## v0.62.0 — R-21 slice C: the universal ISO — unclaimed-appliance registration + operator bind + one-shot delivery (2026-07-17)
The hub half of the universal, **secret-free** bare-metal ISO. A box booted from the generic ISO
registers itself as an UNCLAIMED APPLIANCE; the operator binds it to a customer on the Hosts page; the
hub delivers the customer-id + retrieval passphrase on the box's next poll, ONCE. The distributed ISO
carries no customer secret (§4.4).
- **Store (`internal/store/appliance.go`, new):** `appliance_registrations` 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 (same uuid + different mac-set = distinct appliance). `token_hash` = sha256
of the appliance token (the token itself is never stored). `RegisterAppliance` (idempotent upsert;
sticky-discard), `ApplianceByToken`, `BindAppliance`, `MarkApplianceDelivered` (atomic one-shot
bound→delivered), `DiscardAppliance` (invalidates the token), `ListUnclaimedAppliances`. The table's
own timestamps ARE the pre-bind provenance (no customer to scope an events row to yet).
- **API (`internal/api/appliance.go`, new):** `POST /api/v1/appliance/register` — the ONE
unauthenticated endpoint, per-IP rate-limited, returns a random 256-bit appliance token. `GET
/api/v1/appliance/poll` (Bearer token): unknown/discarded → **404** (no oracle), unbound → **204**,
bound → **200** + credentials (consumed once), delivered → **410**. The passphrase is read live from
`customer_configs` (plaintext, as the day-0 command already needs it) and never logged.
- **Web (`internal/web/appliances.go`, new):** the Hosts page grows an "Unclaimed appliances" section
(uuid, MACs, hw, **SSH host-key fingerprints**, first/last seen, stale >7d badge) with **BIND**
(customer picker showing host counts — display only, never a gate) and **DISCARD**. Bind stages the
delivery + emits `appliance_bound`; delivery emits `appliance_credential_delivered`.
- **Red-proofs (run-fail-revert):** the one-shot delivery (defeat the bound→delivered flip → second
poll re-delivers the passphrase → FAIL) and register idempotency (drop the upsert → duplicate/UNIQUE
violation → FAIL), both proven red then restored; plus 404-no-oracle + sticky-discard, bind
staging/refusal, and the render test. Green: `go build/vet/test`; hub confirm gate OK.
## v0.61.0 — Customer RESET: the middle lifecycle tier (2026-07-17)
One operator action returns a customer to **pre-first-install**: every OPERATIONAL trace dies (offsite
+239
View File
@@ -0,0 +1,239 @@
package api
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net"
"net/http"
"sort"
"strings"
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/configgen"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// R-21 slice C — the universal secret-free ISO. A box booted from the GENERIC ISO registers itself
// (unauthenticated) and receives a random APPLIANCE TOKEN — its only pre-day-0 credential. It then
// polls (token-authed) until the operator binds it to a customer; ONE poll delivers the customer-id +
// retrieval passphrase, and every later poll → 410. The token is stored only as sha256; the endpoints
// are minimal (no enumeration oracle) and register is per-IP rate-limited.
const maxApplianceBytes = 64 << 10 // register payload: uuid + a few MACs + 3 SSH host keys + hw summary
// ipRateLimiter is a per-IP token bucket for the one unauthenticated endpoint. Reuses tokenBucket
// (mail.go); in-memory (lost on restart, acceptable — same posture as the mail limiter).
type ipRateLimiter struct {
mu sync.Mutex
perMinute int
buckets map[string]*tokenBucket
now func() time.Time
}
func newIPRateLimiter(perMinute int) *ipRateLimiter {
if perMinute <= 0 {
perMinute = 20
}
return &ipRateLimiter{perMinute: perMinute, buckets: make(map[string]*tokenBucket), now: time.Now}
}
func (rl *ipRateLimiter) allow(ip string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := rl.now()
capacity := float64(rl.perMinute)
b, ok := rl.buckets[ip]
if !ok {
rl.buckets[ip] = &tokenBucket{tokens: capacity - 1, last: now}
return true
}
elapsed := now.Sub(b.last).Seconds()
b.tokens += elapsed * (capacity / 60.0)
if b.tokens > capacity {
b.tokens = capacity
}
b.last = now
if b.tokens < 1 {
return false
}
b.tokens--
return true
}
// clientIP extracts the real client IP behind the nginx/cloudflared ingress: the first
// X-Forwarded-For hop, else RemoteAddr. Only used for rate-limiting (a spoofed XFF just picks a
// different bucket — the geo gate at the ingress is the real access control).
func clientIP(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 host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}
func sha256hex(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
// normalizeMACSet lowercases, trims, drops all-zero/empty MACs, dedups and SORTS — so the mac_set is
// a stable key regardless of interface enumeration order (the (uuid, mac_set) tiebreaker).
func normalizeMACSet(macs []string) string {
seen := map[string]bool{}
var out []string
for _, m := range macs {
m = strings.ToLower(strings.TrimSpace(m))
if m == "" || m == "00:00:00:00:00:00" {
continue
}
if !seen[m] {
seen[m] = true
out = append(out, m)
}
}
sort.Strings(out)
return strings.Join(out, ",")
}
type applianceRegisterReq struct {
UUID string `json:"uuid"`
MACs []string `json:"macs"`
SSHHostPubkeys []string `json:"ssh_host_pubkeys"`
HW json.RawMessage `json:"hw"`
}
// handleApplianceRegister — POST /api/v1/appliance/register (UNAUTHENTICATED, per-IP rate-limited,
// idempotent by (uuid, mac_set)). Returns a fresh random appliance token (the box's only credential).
func (h *Handler) handleApplianceRegister(w http.ResponseWriter, r *http.Request) {
if h.applianceLimiter != nil && !h.applianceLimiter.allow(clientIP(r)) {
http.Error(w, "rate limited", http.StatusTooManyRequests)
return
}
var req applianceRegisterReq
if err := json.NewDecoder(io.LimitReader(r.Body, maxApplianceBytes)).Decode(&req); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
uuid := strings.TrimSpace(req.UUID)
macSet := normalizeMACSet(req.MACs)
if uuid == "" || macSet == "" {
http.Error(w, "uuid and at least one MAC are required", http.StatusBadRequest)
return
}
sshKeys := strings.Join(sanitizeLines(req.SSHHostPubkeys), "\n")
hwSummary := ""
if len(req.HW) > 0 {
hwSummary = string(req.HW)
}
token, err := configgen.RandomHex(32) // 256-bit
if err != nil {
h.logger.Printf("[ERROR] appliance register: token mint: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
isNew, err := h.store.RegisterAppliance(uuid, macSet, sshKeys, hwSummary, sha256hex(token))
if err != nil {
h.logger.Printf("[ERROR] appliance register (uuid=%s): %v", uuid, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if isNew {
// Provenance is the appliance_registrations row itself (first_seen) — there is no customer to
// scope an events row to yet. Token withheld (fingerprint would leak a guess vector; log the id-free fact).
h.logger.Printf("[INFO] appliance registered: new unclaimed box (uuid=%s macs=%d ssh_keys=%d)", uuid, strings.Count(macSet, ",")+1, len(sanitizeLines(req.SSHHostPubkeys)))
}
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})
}
// handleAppliancePoll — GET /api/v1/appliance/poll (Bearer appliance-token). One-shot delivery:
//
// unknown/discarded token → 404 (no oracle) registered (unbound) → 204 (keep polling)
// bound (staged, this poll wins) → 200 + creds already delivered / lost race → 410
func (h *Handler) handleAppliancePoll(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
token := strings.TrimPrefix(auth, "Bearer ")
appl, err := h.store.ApplianceByToken(sha256hex(token))
if err != nil {
h.logger.Printf("[ERROR] appliance poll lookup: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if appl == nil || appl.Status == store.ApplianceDiscarded {
http.Error(w, "not found", http.StatusNotFound) // no oracle: unknown == discarded
return
}
switch appl.Status {
case store.ApplianceRegistered:
w.WriteHeader(http.StatusNoContent) // bound not yet — keep polling
return
case store.ApplianceDelivered:
http.Error(w, "already delivered", http.StatusGone)
return
case store.ApplianceBound:
// One-shot: only the winning poll flips bound→delivered.
ok, err := h.store.MarkApplianceDelivered(sha256hex(token))
if err != nil {
h.logger.Printf("[ERROR] appliance poll deliver: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if !ok {
http.Error(w, "already delivered", http.StatusGone) // lost the race
return
}
cc, err := h.store.GetCustomerConfig(appl.CustomerID)
if err != nil || cc == nil {
h.logger.Printf("[ERROR] appliance deliver: bound customer %q missing: %v", appl.CustomerID, err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
mode := appl.InstallMode
if mode == "" {
mode = "appliance"
}
// Audit: now a customer exists to scope the event to.
if _, serr := h.store.SaveEvent(appl.CustomerID, "appliance_credential_delivered", "info",
"Új eszköz (bare-metal telepítés) megkapta a hozzáférést és megkezdi a beállítást.", "", "hub"); serr != nil {
h.logger.Printf("[WARN] appliance deliver: save event: %v", serr)
}
h.logger.Printf("[INFO] appliance credentials DELIVERED once to appliance %d (customer=%s mode=%s; passphrase withheld)", appl.ID, appl.CustomerID, mode)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{
"customer_id": appl.CustomerID,
"retrieval_passphrase": cc.RetrievalPassword,
"mode": mode,
"extra_args": appl.ExtraArgs,
})
return
default:
http.Error(w, "not found", http.StatusNotFound)
return
}
}
// sanitizeLines trims + drops empty entries (SSH host key lines).
func sanitizeLines(in []string) []string {
var out []string
for _, s := range in {
if s = strings.TrimSpace(s); s != "" {
out = append(out, s)
}
}
return out
}
+149
View File
@@ -0,0 +1,149 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// R-21 slice C — the appliance register + one-shot poll delivery. The load-bearing contracts:
// - register is idempotent by (uuid, mac_set); a DIFFERENT mac-set is a distinct appliance.
// - a bound appliance's credentials are delivered EXACTLY ONCE; every later poll → 410.
// - an unknown / discarded token → 404, indistinguishable (no enumeration oracle).
func registerAppliance(t *testing.T, h *Handler, uuid string, macs []string) string {
t.Helper()
body, _ := json.Marshal(applianceRegisterReq{UUID: uuid, MACs: macs, SSHHostPubkeys: []string{"ssh-ed25519 AAAAKEY host"}})
req := httptest.NewRequest("POST", "/api/v1/appliance/register", bytes.NewReader(body))
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code != 200 {
t.Fatalf("register = %d (%s), want 200", rr.Code, rr.Body.String())
}
var resp struct {
Token string `json:"appliance_token"`
Poll int `json:"poll_interval_sec"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if len(resp.Token) < 32 || resp.Poll != 30 {
t.Fatalf("bad register response: token_len=%d poll=%d", len(resp.Token), resp.Poll)
}
return resp.Token
}
func poll(t *testing.T, h *Handler, token string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest("GET", "/api/v1/appliance/poll", nil)
req.Header.Set("Authorization", "Bearer "+token)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
return rr
}
func countAppliances(t *testing.T, st *store.Store) int {
t.Helper()
list, err := st.ListUnclaimedAppliances()
if err != nil {
t.Fatal(err)
}
return len(list)
}
// Scenario A: register is idempotent by (uuid, mac_set); a different mac-set is a distinct appliance.
// (Red-proof: making RegisterAppliance always-INSERT — dropping the (uuid,mac_set) upsert — makes the
// re-register assertion see 2 rows → FAIL. Verified run-fail-revert.)
func TestApplianceRegister_Idempotent(t *testing.T) {
h, st, _ := newTestHandler(t)
macs := []string{"bc:24:11:98:10:0e", "bc:24:11:98:10:0f"}
registerAppliance(t, h, "uuid-A", macs)
registerAppliance(t, h, "uuid-A", macs) // same box, re-register
if n := countAppliances(t, st); n != 1 {
t.Fatalf("re-register duplicated the appliance: %d rows, want 1", n)
}
// MAC order must not matter (normalized/sorted).
registerAppliance(t, h, "uuid-A", []string{macs[1], macs[0]})
if n := countAppliances(t, st); n != 1 {
t.Fatalf("MAC reorder duplicated the appliance: %d rows, want 1", n)
}
// Same UUID, DIFFERENT mac-set = a distinct appliance (cheap-board duplicate-UUID tiebreaker).
registerAppliance(t, h, "uuid-A", []string{"aa:aa:aa:aa:aa:aa"})
if n := countAppliances(t, st); n != 2 {
t.Fatalf("different mac-set should be a distinct appliance: %d rows, want 2", n)
}
}
// Scenario C: one-shot delivery + 410; and 404-no-oracle.
func TestAppliancePoll_OneShotAnd410(t *testing.T) {
h, st, _ := newTestHandler(t)
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "acme", APIKey: "k", RetrievalPassword: "the-passphrase"}); err != nil {
t.Fatal(err)
}
token := registerAppliance(t, h, "uuid-C", []string{"bc:24:11:98:10:0e"})
// Unbound → 204 (keep polling), no credentials.
if rr := poll(t, h, token); rr.Code != http.StatusNoContent {
t.Fatalf("unbound poll = %d, want 204", rr.Code)
}
// Operator binds it.
list, _ := st.ListUnclaimedAppliances()
if err := st.BindAppliance(list[0].ID, "acme", "appliance", "--cores 4"); err != nil {
t.Fatal(err)
}
// First poll after bind: ONE delivery with the credentials.
rr := poll(t, h, token)
if rr.Code != 200 {
t.Fatalf("bound poll = %d (%s), want 200", rr.Code, rr.Body.String())
}
var creds map[string]string
json.Unmarshal(rr.Body.Bytes(), &creds)
if creds["customer_id"] != "acme" || creds["retrieval_passphrase"] != "the-passphrase" ||
creds["mode"] != "appliance" || creds["extra_args"] != "--cores 4" {
t.Fatalf("delivery payload wrong: %+v", creds)
}
// Red-proof target: every subsequent poll → 410, NEVER re-delivers the passphrase.
// (Defeating MarkApplianceDelivered's status flip makes this re-receive 200+passphrase → FAIL.)
rr2 := poll(t, h, token)
if rr2.Code != http.StatusGone {
t.Fatalf("second poll = %d, want 410 (one-shot)", rr2.Code)
}
if strings.Contains(rr2.Body.String(), "the-passphrase") {
t.Fatal("the passphrase was re-delivered on the second poll — one-shot broken")
}
}
func TestAppliancePoll_NoOracle(t *testing.T) {
h, st, _ := newTestHandler(t)
// Unknown token → 404.
if rr := poll(t, h, "totally-unknown-token"); rr.Code != http.StatusNotFound {
t.Fatalf("unknown token = %d, want 404", rr.Code)
}
// Registered-then-discarded → 404, indistinguishable from unknown.
token := registerAppliance(t, h, "uuid-D", []string{"bc:24:11:98:10:0e"})
list, _ := st.ListUnclaimedAppliances()
if err := st.DiscardAppliance(list[0].ID); err != nil {
t.Fatal(err)
}
rr := poll(t, h, token)
if rr.Code != http.StatusNotFound {
t.Fatalf("discarded token poll = %d, want 404 (no oracle)", rr.Code)
}
// Discard is sticky across a re-register: the box gets a token but its poll stays 404.
token2 := registerAppliance(t, h, "uuid-D", []string{"bc:24:11:98:10:0e"})
if rr := poll(t, h, token2); rr.Code != http.StatusNotFound {
t.Fatalf("re-registered-after-discard poll = %d, want 404 (sticky discard)", rr.Code)
}
if n := countAppliances(t, st); n != 0 {
t.Fatalf("discarded appliance still listed as unclaimed: %d", n)
}
}
+12
View File
@@ -53,6 +53,11 @@ type Handler struct {
mailLimiter *mailRateLimiter
mailFromAllow map[string]bool
// applianceLimiter (v0.62.0, R-21 slice C) throttles the ONE unauthenticated endpoint,
// POST /api/v1/appliance/register, per client IP — the unclaimed population is tiny and the
// ingress already geo-restricts to HU, so this is a cheap anti-abuse bound, not a fleet lever.
applianceLimiter *ipRateLimiter
// S1 offsite connectivity: the wgsync reconciler seam (internal/api/wg.go). nil = peer-sync
// disabled — mutations still persist, responses carry sync:"disabled".
wgSyncer WGSyncer
@@ -117,6 +122,7 @@ func New(store *store.Store, apiKey, resendAPIKey, fromEmail string, templatePro
logger: logger,
httpClient: &http.Client{Timeout: 10 * time.Second},
templateProvider: templateProvider,
applianceLimiter: newIPRateLimiter(20), // 20 registrations/min/IP burst — booting boxes retry ~30s
}
}
@@ -196,6 +202,12 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// operator-intent bump for the box's customer, then the box fires its ordinary report.
case r.Method == http.MethodGet && path == "/wait":
h.handleWait(w, r)
// R-21 slice C — the universal ISO. register is the ONE unauthenticated endpoint (per-IP
// rate-limited); poll is Bearer appliance-token. Both minimal, no enumeration oracle.
case r.Method == http.MethodPost && path == "/appliance/register":
h.handleApplianceRegister(w, r)
case r.Method == http.MethodGet && path == "/appliance/poll":
h.handleAppliancePoll(w, r)
case r.Method == http.MethodPost && path == "/host-report":
h.handleHostReport(w, r)
case r.Method == http.MethodPost && path == "/host-enroll":
+217
View File
@@ -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
}
+30
View File
@@ -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: registeredbounddelivered (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
+187
View File
@@ -0,0 +1,187 @@
package web
import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// R-21 slice C — the operator surface for unclaimed appliances (a box booted from the GENERIC ISO
// that registered itself and is polling for a bind). Lives on the Hosts page: an "Unclaimed
// appliances" section, plus BIND (to a customer) and DISCARD actions.
const applianceStaleAfter = 7 * 24 * time.Hour // no poll in 7 days → badge as stale
// applianceRow is the per-appliance view model.
type applianceRow struct {
ID int64
UUID string
MACs []string
Product string
CPU string
MemGB string
SSHFingerprints []string
FirstSeen *time.Time
LastSeen *time.Time
Stale bool
Bound bool
BoundCustomer string
}
// customerPickerOption is one entry in the BIND customer picker. HostCount is DISPLAYED (multi-host
// customers are real — Peti) but never gates the bind.
type customerPickerOption struct {
CustomerID string
CustomerName string
HostCount int
}
// sshFingerprint returns the OpenSSH SHA256 fingerprint of one authorized_keys-format line, or "" if
// unparseable. Format: "<type> <base64 blob> [comment]".
func sshFingerprint(line string) string {
f := strings.Fields(line)
if len(f) < 2 {
return ""
}
blob, err := base64.StdEncoding.DecodeString(f[1])
if err != nil {
return ""
}
sum := sha256.Sum256(blob)
return f[0] + " SHA256:" + base64.RawStdEncoding.EncodeToString(sum[:])
}
// 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,
}
if a.MACSet != "" {
row.MACs = strings.Split(a.MACSet, ",")
}
fs := a.FirstSeen
row.FirstSeen = &fs
ls := a.LastSeen
row.LastSeen = &ls
row.Stale = now.Sub(a.LastSeen) > applianceStaleAfter
for _, k := range strings.Split(a.SSHHostPubkeys, "\n") {
if fp := sshFingerprint(k); fp != "" {
row.SSHFingerprints = append(row.SSHFingerprints, fp)
}
}
if a.HWSummary != "" {
var hw struct {
Product string `json:"product"`
CPU string `json:"cpu"`
MemKB int64 `json:"mem_kb"`
}
if json.Unmarshal([]byte(a.HWSummary), &hw) == nil {
row.Product = hw.Product
row.CPU = hw.CPU
if hw.MemKB > 0 {
row.MemGB = fmt.Sprintf("%.1f GB", float64(hw.MemKB)/1024.0/1024.0)
}
}
}
if row.Bound {
row.BoundCustomer = customerName(a.CustomerID)
}
return row
}
// gatherUnclaimed builds the Unclaimed-appliances rows + the customer picker (with host counts).
func (s *Server) gatherUnclaimed(now time.Time) ([]applianceRow, []customerPickerOption, error) {
appls, err := s.store.ListUnclaimedAppliances()
if err != nil {
return nil, nil, err
}
rows := make([]applianceRow, 0, len(appls))
for _, a := range appls {
rows = append(rows, applianceToRow(a, now, s.customerName))
}
var picker []customerPickerOption
if len(rows) > 0 { // only pay for the customer list when there's something to bind
cfgs, err := s.store.ListCustomerConfigs()
if err != nil {
return nil, nil, err
}
for _, c := range cfgs {
hosts, _ := s.store.ListHostsByCustomer(c.CustomerID)
name := c.CustomerName
if name == "" {
name = c.CustomerID
}
picker = append(picker, customerPickerOption{CustomerID: c.CustomerID, CustomerName: name, HostCount: len(hosts)})
}
}
return rows, picker, nil
}
// handleApplianceBind — POST /appliances/{id}/bind. Stages the delivery for that appliance's token.
// Does NOT gate on the customer's host count (multi-host customers are real).
func (s *Server) handleApplianceBind(w http.ResponseWriter, r *http.Request, id int64) {
customerID := strings.TrimSpace(r.FormValue("customer_id"))
mode := strings.TrimSpace(r.FormValue("mode"))
if mode == "" {
mode = "appliance"
}
extraArgs := strings.TrimSpace(r.FormValue("extra_args"))
if customerID == "" {
http.Error(w, "customer_id is required", http.StatusBadRequest)
return
}
cc, err := s.store.GetCustomerConfig(customerID)
if err != nil {
s.logger.Printf("[ERROR] appliance bind %d: customer lookup: %v", id, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
if cc == nil {
http.Error(w, "Unknown customer_id", http.StatusBadRequest)
return
}
if err := s.store.BindAppliance(id, customerID, mode, extraArgs); err != nil {
s.logger.Printf("[WARN] appliance bind %d → %s refused: %v", id, customerID, err)
http.Error(w, "Bind failed: "+err.Error(), http.StatusConflict)
return
}
// Provenance is the appliance row (bound_at); audit event now that a customer scopes it.
if _, err := s.store.SaveEvent(customerID, "appliance_bound", "info",
"Egy új eszközt (bare-metal telepítés) ehhez az ügyfélhez rendeltünk; a hozzáférést a következő lekérdezéskor megkapja.", "", "hub"); err != nil {
s.logger.Printf("[WARN] appliance bind %d: save event: %v", id, err)
}
s.logger.Printf("[INFO] appliance %d BOUND to customer %s (mode=%s) — delivery staged for its next poll", id, customerID, mode)
http.Redirect(w, r, "/hosts?flash=appliance_bound", http.StatusSeeOther)
}
// handleApplianceDiscard — POST /appliances/{id}/discard. Ignores the registration + invalidates its
// token. Provenance is the appliance row (discarded_at); no customer to scope an event to.
func (s *Server) handleApplianceDiscard(w http.ResponseWriter, r *http.Request, id int64) {
if err := s.store.DiscardAppliance(id); err != nil {
s.logger.Printf("[WARN] appliance discard %d: %v", id, err)
http.Error(w, "Discard failed: "+err.Error(), http.StatusConflict)
return
}
s.logger.Printf("[INFO] appliance %d DISCARDED (token invalidated; polls now 404)", id)
http.Redirect(w, r, "/hosts?flash=appliance_discarded", http.StatusSeeOther)
}
// parseApplianceID extracts the {id} from /appliances/{id}/<action>.
func parseApplianceID(path, action string) (int64, bool) {
rest := strings.TrimPrefix(path, "/appliances/")
rest = strings.TrimSuffix(rest, "/"+action)
id, err := strconv.ParseInt(rest, 10, 64)
if err != nil {
return 0, false
}
return id, true
}
+147
View File
@@ -0,0 +1,147 @@
package web
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// R-21 slice C — the operator unclaimed-appliance surface: render + bind/discard.
func seedAppliance(t *testing.T, st *store.Store, uuid, macSet string) int64 {
t.Helper()
// 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 {
t.Fatalf("register appliance: %v", err)
}
list, err := st.ListUnclaimedAppliances()
if err != nil {
t.Fatal(err)
}
for _, a := range list {
if a.UUID == uuid && a.MACSet == macSet {
return a.ID
}
}
t.Fatal("seeded appliance not found")
return 0
}
func renderHosts(t *testing.T, s *Server) string {
t.Helper()
rr := httptest.NewRecorder()
s.handleHostsList(rr, httptest.NewRequest("GET", "/hosts", nil))
if rr.Code != 200 {
t.Fatalf("hosts page = %d", rr.Code)
}
return rr.Body.String()
}
func TestAppliances_UnclaimedSectionRenders(t *testing.T) {
s, st := newTestServer(t)
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "acme", CustomerName: "Acme Kft", APIKey: "k", RetrievalPassword: "pw"}); err != nil {
t.Fatal(err)
}
seedAppliance(t, st, "uuid-vis", "bc:24:11:98:10:0e,bc:24:11:98:10:0f")
html := renderHosts(t, s)
for _, want := range []string{
"Unclaimed appliances", "uuid-vis", "bc:24:11:98:10:0e",
"Intel N100 mini", "SHA256:", // hw + a computed SSH fingerprint
`action="/appliances/`, "/bind", "/discard",
`Acme Kft (0 hosts)`, // the picker shows host counts (display only)
} {
if !strings.Contains(html, want) {
t.Errorf("unclaimed section missing %q", want)
}
}
}
func TestAppliances_BindStagesDelivery(t *testing.T) {
s, st := newTestServer(t)
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "acme", CustomerName: "Acme", APIKey: "k", RetrievalPassword: "pw"}); err != nil {
t.Fatal(err)
}
id := seedAppliance(t, st, "uuid-bind", "bc:24:11:98:10:0e")
form := url.Values{"customer_id": {"acme"}, "mode": {"appliance"}, "extra_args": {"--cores 4"}}
req := httptest.NewRequest("POST", "/appliances/x/bind", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
s.handleApplianceBind(rr, req, id)
if rr.Code != http.StatusSeeOther {
t.Fatalf("bind = %d (%s), want 303", rr.Code, rr.Body.String())
}
// The appliance is now bound with the staged delivery.
a, _ := st.GetAppliance(id)
if a.Status != store.ApplianceBound || a.CustomerID != "acme" || a.InstallMode != "appliance" || a.ExtraArgs != "--cores 4" {
t.Fatalf("bind did not stage the delivery: %+v", a)
}
// Audit event recorded (a customer scopes it now).
if ev, _ := st.GetLatestEventByType("acme", "appliance_bound"); ev == nil {
t.Error("no appliance_bound event recorded")
}
// It leaves the unclaimed section as a bound row (still shown until delivered).
if !strings.Contains(renderHosts(t, s), "bound → Acme") {
t.Error("bound appliance not shown as bound in the UI")
}
}
// Bind must NOT gate on the customer's host count (a post-RESET / drill customer is hostless).
func TestAppliances_BindDoesNotGateOnHostCount(t *testing.T) {
s, st := newTestServer(t)
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "hostless", APIKey: "k", RetrievalPassword: "pw"}); err != nil {
t.Fatal(err)
}
id := seedAppliance(t, st, "uuid-h", "bc:24:11:98:10:0e")
form := url.Values{"customer_id": {"hostless"}}
req := httptest.NewRequest("POST", "/appliances/x/bind", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
s.handleApplianceBind(rr, req, id)
if rr.Code != http.StatusSeeOther {
t.Fatalf("bind to hostless customer = %d, want 303 (host count is display-only)", rr.Code)
}
}
func TestAppliances_BindUnknownCustomerRejected(t *testing.T) {
s, st := newTestServer(t)
id := seedAppliance(t, st, "uuid-u", "bc:24:11:98:10:0e")
form := url.Values{"customer_id": {"ghost"}}
req := httptest.NewRequest("POST", "/appliances/x/bind", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
s.handleApplianceBind(rr, req, id)
if rr.Code != http.StatusBadRequest {
t.Fatalf("bind to unknown customer = %d, want 400", rr.Code)
}
if a, _ := st.GetAppliance(id); a.Status != store.ApplianceRegistered {
t.Error("a rejected bind still mutated the appliance")
}
}
func TestAppliances_Discard(t *testing.T) {
s, st := newTestServer(t)
id := seedAppliance(t, st, "uuid-d", "bc:24:11:98:10:0e")
req := httptest.NewRequest("POST", "/appliances/x/discard", nil)
rr := httptest.NewRecorder()
s.handleApplianceDiscard(rr, req, id)
if rr.Code != http.StatusSeeOther {
t.Fatalf("discard = %d, want 303", rr.Code)
}
a, _ := st.GetAppliance(id)
if a.Status != store.ApplianceDiscarded {
t.Fatalf("discard did not set status: %+v", a)
}
// No longer in the unclaimed list.
list, _ := st.ListUnclaimedAppliances()
if len(list) != 0 {
t.Errorf("discarded appliance still unclaimed: %d", len(list))
}
}
+12 -1
View File
@@ -334,8 +334,19 @@ func (s *Server) handleHostsList(w http.ResponseWriter, r *http.Request) {
rows = append(rows, row)
}
// R-21 slice C: the unclaimed-appliance section + bind picker.
unclaimed, picker, err := s.gatherUnclaimed(time.Now())
if err != nil {
s.logger.Printf("[ERROR] Hosts list: unclaimed appliances: %v", err)
// non-fatal: still render the host list
}
data := map[string]interface{}{
"Hosts": rows,
"Hosts": rows,
"Unclaimed": unclaimed,
"CustomerPicker": picker,
"Flash": r.URL.Query().Get("flash"),
"CSRFToken": s.getCSRFToken(r),
}
if err := s.templates.ExecuteTemplate(w, "hosts.html", data); err != nil {
s.logger.Printf("[ERROR] hosts.html template: %v", err)
+13
View File
@@ -316,6 +316,19 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Hosts — read-only fleet view (audit F-M1) + the v0.46.0 log-bundle actions.
case path == "/hosts" || path == "/hosts/":
s.handleHostsList(w, r)
// R-21 slice C — unclaimed-appliance operator actions (bind/discard). POST only.
case strings.HasPrefix(path, "/appliances/") && strings.HasSuffix(path, "/bind"):
if id, ok := parseApplianceID(path, "bind"); ok && r.Method == http.MethodPost {
s.handleApplianceBind(w, r, id)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case strings.HasPrefix(path, "/appliances/") && strings.HasSuffix(path, "/discard"):
if id, ok := parseApplianceID(path, "discard"); ok && r.Method == http.MethodPost {
s.handleApplianceDiscard(w, r, id)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
// v0.47.0 stale host removal — suffix routes BEFORE the bare /hosts/ catch-all
// (mirroring the request-logs placement).
case strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/delete-impact"):
+53
View File
@@ -8,6 +8,7 @@
</head>
<body>
{{template "icon_sprite"}}
{{template "inline_confirm_js"}}
<div class="container">
<header>
<h1>Felhom <span>Hub</span></h1>
@@ -23,6 +24,58 @@
<h2 style="margin-bottom: 1rem;">Hosts</h2>
{{if .Flash}}
<div class="flash flash-success" style="margin-bottom: 1rem;">
{{if eq .Flash "appliance_bound"}}Appliance bound — its credentials are delivered on its next poll (within ~30s); it then completes day-0 install.
{{else if eq .Flash "appliance_discarded"}}Appliance discarded — its token is invalidated; further polls are ignored.
{{end}}
</div>
{{end}}
{{if .Unclaimed}}
<section class="card" style="margin-bottom: 1.5rem; border-color: var(--warn);">
<h2 style="margin-top: 0;">Unclaimed appliances <span class="text-muted" style="font-size: 0.8em; font-weight: normal;">(booted from the generic ISO, awaiting a bind)</span></h2>
<p class="text-muted" style="margin-top: 0;">A box that installed from the universal secret-free ISO and registered itself. <strong>Bind</strong> it to a customer to deliver its retrieval passphrase once; <strong>discard</strong> to ignore it.</p>
<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>
</thead>
<tbody>
{{range .Unclaimed}}
<tr>
<td><code style="font-size: 0.8em;">{{.UUID}}</code>
{{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-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>
<td style="font-size: 0.78em;">{{if .FirstSeen}}first {{timeAgoPtr .FirstSeen}}<br>{{end}}{{if .LastSeen}}last {{timeAgoPtr .LastSeen}}{{end}}</td>
<td>
<form method="POST" action="/appliances/{{.ID}}/bind" style="display: flex; flex-direction: column; gap: 0.3rem;">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<select name="customer_id" required style="max-width: 16em;">
<option value="">— pick a customer —</option>
{{range $.CustomerPicker}}<option value="{{.CustomerID}}">{{.CustomerName}} ({{.HostCount}} host{{if ne .HostCount 1}}s{{end}})</option>{{end}}
</select>
<button type="submit" class="btn btn-sm" style="border-color: var(--warn); color: var(--warn);">Bind &amp; deliver</button>
</form>
</td>
<td>
<form method="POST" action="/appliances/{{.ID}}/discard">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<button type="submit" class="btn btn-sm btn-outline" data-confirm="Discard this appliance? Its token is invalidated and further polls are ignored.">Discard</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</section>
{{end}}
{{if .Hosts}}
<section class="card" style="padding: 0; overflow: hidden;">
<table class="data-table">