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 # 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) ## 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 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 mailLimiter *mailRateLimiter
mailFromAllow map[string]bool 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 // S1 offsite connectivity: the wgsync reconciler seam (internal/api/wg.go). nil = peer-sync
// disabled — mutations still persist, responses carry sync:"disabled". // disabled — mutations still persist, responses carry sync:"disabled".
wgSyncer WGSyncer wgSyncer WGSyncer
@@ -117,6 +122,7 @@ func New(store *store.Store, apiKey, resendAPIKey, fromEmail string, templatePro
logger: logger, logger: logger,
httpClient: &http.Client{Timeout: 10 * time.Second}, httpClient: &http.Client{Timeout: 10 * time.Second},
templateProvider: templateProvider, 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. // operator-intent bump for the box's customer, then the box fires its ordinary report.
case r.Method == http.MethodGet && path == "/wait": case r.Method == http.MethodGet && path == "/wait":
h.handleWait(w, r) 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": case r.Method == http.MethodPost && path == "/host-report":
h.handleHostReport(w, r) h.handleHostReport(w, r)
case r.Method == http.MethodPost && path == "/host-enroll": 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 '{}' legs_json TEXT NOT NULL DEFAULT '{}'
); );
CREATE INDEX IF NOT EXISTS idx_customer_resets_customer ON customer_resets(customer_id, id DESC); 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 { if err != nil {
return err 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) 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{}{ 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 { if err := s.templates.ExecuteTemplate(w, "hosts.html", data); err != nil {
s.logger.Printf("[ERROR] hosts.html template: %v", err) 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. // Hosts — read-only fleet view (audit F-M1) + the v0.46.0 log-bundle actions.
case path == "/hosts" || path == "/hosts/": case path == "/hosts" || path == "/hosts/":
s.handleHostsList(w, r) 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 // v0.47.0 stale host removal — suffix routes BEFORE the bare /hosts/ catch-all
// (mirroring the request-logs placement). // (mirroring the request-logs placement).
case strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/delete-impact"): case strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/delete-impact"):
+53
View File
@@ -8,6 +8,7 @@
</head> </head>
<body> <body>
{{template "icon_sprite"}} {{template "icon_sprite"}}
{{template "inline_confirm_js"}}
<div class="container"> <div class="container">
<header> <header>
<h1>Felhom <span>Hub</span></h1> <h1>Felhom <span>Hub</span></h1>
@@ -23,6 +24,58 @@
<h2 style="margin-bottom: 1rem;">Hosts</h2> <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}} {{if .Hosts}}
<section class="card" style="padding: 0; overflow: hidden;"> <section class="card" style="padding: 0; overflow: hidden;">
<table class="data-table"> <table class="data-table">
+21
View File
@@ -1,5 +1,26 @@
# Felhom scripts — Changelog # Felhom scripts — Changelog
## build-felhom-iso.sh v1.19.0 — the universal secret-free ISO: `--pairing` mode (R-21 slice C) (2026-07-17)
The scripts half of the universal ISO. `felhom-bootstrap.sh` gains a PAIRING mode — **one unit, two
modes**, decided by the env:
- **DIRECT** (env has `FELHOM_CUSTOMER_ID` + `FELHOM_RETRIEVAL_PASSPHRASE`): the slice-A path,
**byte-identical** — only branched around. Scenario D regression proves the pairing code is provably
NOT entered (a fake hub records ZERO `/appliance/*` calls).
- **PAIRING** (generic ISO, no customer/passphrase baked in): gather identity (SMBIOS uuid + physical
MAC set + SSH host pubkeys + hw), `POST /api/v1/appliance/register` → persist the appliance token
(0600), then ONE `GET /api/v1/appliance/poll` per invocation (the existing
`Restart=on-failure`/`RestartSec=30` IS the poll timer — no long-running-oneshot timeout). On the
bind's 200 delivery, WRITE the delivered credentials into the env (0600) and fall through to the
DIRECT path — so every later retry is a plain direct install (the delivery is one-shot; a second poll
→ 410). Delivery-received secrets + the appliance token are shredded on host-install success.
- **`build-felhom-iso.sh --pairing`** builds the GENERIC ISO: no `--bootstrap-env`, a secret-free env
carrying only the hub URL, `-generic` filename marker, manifest `mode: pairing` + `secret-bearing:
no`. Direct mode (secret-bearing) is unchanged. **New `profiles/generic.profile`.**
- Validated: `bash -n` + shellcheck clean; the `test/bootstrap-modes.sh` harness (Scenario D + pairing
register/poll + the delivery→env→host-install handoff) all green in a debian container.
## build-felhom-iso.sh v1.18.0 — firmware loader option `--loader shim|mkimage` (R-21 slice B, F1) (2026-07-17) ## build-felhom-iso.sh v1.18.0 — firmware loader option `--loader shim|mkimage` (R-21 slice B, F1) (2026-07-17)
Closes N100 finding **F1 (HIGH):** cheap AMI (`AN3PLUS 0.01`-class) UEFI firmware can't relocate the Closes N100 finding **F1 (HIGH):** cheap AMI (`AN3PLUS 0.01`-class) UEFI firmware can't relocate the
+75 -24
View File
@@ -1,6 +1,11 @@
#!/bin/bash #!/bin/bash
#=============================================================================== #===============================================================================
# build-felhom-iso.sh — R-21 slice A+B: turn the official PVE ISO into a Felhom auto-install ISO. # build-felhom-iso.sh — R-21 slice A+B+C: turn the official PVE ISO into a Felhom auto-install ISO.
#
# SLICE C — --pairing builds the GENERIC, SECRET-FREE universal ISO: no customer-id / passphrase is
# baked in. The box registers itself at the hub as an UNCLAIMED APPLIANCE, the operator binds it to a
# customer, and the hub delivers the credentials ONCE — then the box completes day-0 exactly like a
# direct-mode box. Direct mode (--bootstrap-env, secret-bearing, operator-prepped) is unchanged.
# #
# Renders answer.toml (from answer.toml.tmpl + a profile), mints a fresh THROWAWAY root hash, # Renders answer.toml (from answer.toml.tmpl + a profile), mints a fresh THROWAWAY root hash,
# gates the answer through validate-answer by PARSING ITS OUTPUT (never $? — validate-answer returns # gates the answer through validate-answer by PARSING ITS OUTPUT (never $? — validate-answer returns
@@ -27,7 +32,7 @@
#=============================================================================== #===============================================================================
set -euo pipefail set -euo pipefail
ISO_VERSION="1.18.0" # Felhom release the ISO is tagged to (aligns with felhom-host-install SCRIPT_VERSION). ISO_VERSION="1.19.0" # Felhom release the ISO is tagged to (aligns with felhom-host-install SCRIPT_VERSION).
IMAGE="${FELHOM_ISO_ASSISTANT_IMAGE:-felhom-iso-assistant:trixie}" IMAGE="${FELHOM_ISO_ASSISTANT_IMAGE:-felhom-iso-assistant:trixie}"
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -44,16 +49,24 @@ die() { log_error "$1"; exit 1; }
PVE_ISO=""; ISO_SHA256=""; PROFILE=""; BOOTSTRAP_ENV=""; OUT_DIR="${HOME}/felhom-iso/out"; PVE_VERSION=""; DRY_RUN=false PVE_ISO=""; ISO_SHA256=""; PROFILE=""; BOOTSTRAP_ENV=""; OUT_DIR="${HOME}/felhom-iso/out"; PVE_VERSION=""; DRY_RUN=false
LOADER_CLI="" # --loader override; empty = fall back to the profile, then the shim default. LOADER_CLI="" # --loader override; empty = fall back to the profile, then the shim default.
PAIRING=false # --pairing: build the GENERIC secret-free ISO (slice C); no --bootstrap-env.
usage() { usage() {
cat <<EOF cat <<EOF
Usage: build-felhom-iso.sh --pve-iso PATH --iso-sha256 SHA --profile FILE --bootstrap-env FILE [options] Usage (direct): build-felhom-iso.sh --pve-iso PATH --iso-sha256 SHA --profile FILE --bootstrap-env FILE [options]
Usage (generic): build-felhom-iso.sh --pve-iso PATH --iso-sha256 SHA --profile FILE --pairing [options]
Required: Required:
--pve-iso PATH pre-downloaded official PVE ISO (not fetched here) --pve-iso PATH pre-downloaded official PVE ISO (not fetched here)
--iso-sha256 SHA expected sha256 of --pve-iso (verified before build; abort on mismatch) --iso-sha256 SHA expected sha256 of --pve-iso (verified before build; abort on mismatch)
--profile FILE build profile (fqdn + [disk-setup]); see profiles/ and README --profile FILE build profile (fqdn + [disk-setup]); see profiles/ and README
--bootstrap-env FILE the in-ISO /etc/felhom/bootstrap.env (SECRET-BEARING: retrieval passphrase).
Must define FELHOM_CUSTOMER_ID, FELHOM_MODE, FELHOM_RETRIEVAL_PASSPHRASE. Mode (exactly one):
--bootstrap-env FILE DIRECT mode: the in-ISO /etc/felhom/bootstrap.env (SECRET-BEARING: retrieval
passphrase). Must define FELHOM_CUSTOMER_ID, FELHOM_MODE, FELHOM_RETRIEVAL_PASSPHRASE.
--pairing PAIRING mode (slice C): the GENERIC, SECRET-FREE universal ISO. The box registers
itself as an unclaimed appliance at the hub; the operator binds it; the hub
delivers the customer-id + passphrase ONCE. No customer secret is baked in. The
hub URL comes from the profile (FELHOM_HUB_URL) or the default.
Options: Options:
--loader shim|mkimage UEFI boot loader (default: shim, or the profile's FELHOM_LOADER; --loader wins). --loader shim|mkimage UEFI boot loader (default: shim, or the profile's FELHOM_LOADER; --loader wins).
shim = stock MS-signed chain (Secure Boot OK on compliant firmware). shim = stock MS-signed chain (Secure Boot OK on compliant firmware).
@@ -71,6 +84,7 @@ while [[ $# -gt 0 ]]; do
--iso-sha256) ISO_SHA256="$2"; shift 2 ;; --iso-sha256) ISO_SHA256="$2"; shift 2 ;;
--profile) PROFILE="$2"; shift 2 ;; --profile) PROFILE="$2"; shift 2 ;;
--bootstrap-env) BOOTSTRAP_ENV="$2"; shift 2 ;; --bootstrap-env) BOOTSTRAP_ENV="$2"; shift 2 ;;
--pairing) PAIRING=true; shift ;;
--loader) LOADER_CLI="$2"; shift 2 ;; --loader) LOADER_CLI="$2"; shift 2 ;;
--out) OUT_DIR="$2"; shift 2 ;; --out) OUT_DIR="$2"; shift 2 ;;
--pve-version) PVE_VERSION="$2"; shift 2 ;; --pve-version) PVE_VERSION="$2"; shift 2 ;;
@@ -85,10 +99,15 @@ done
[[ -n "$PVE_ISO" ]] || die "--pve-iso is required" [[ -n "$PVE_ISO" ]] || die "--pve-iso is required"
[[ -n "$ISO_SHA256" ]] || die "--iso-sha256 is required" [[ -n "$ISO_SHA256" ]] || die "--iso-sha256 is required"
[[ -n "$PROFILE" ]] || die "--profile is required" [[ -n "$PROFILE" ]] || die "--profile is required"
[[ -n "$BOOTSTRAP_ENV" ]] || die "--bootstrap-env is required"
[[ -f "$PVE_ISO" ]] || die "--pve-iso not found: $PVE_ISO" [[ -f "$PVE_ISO" ]] || die "--pve-iso not found: $PVE_ISO"
[[ -f "$PROFILE" ]] || die "--profile not found: $PROFILE" [[ -f "$PROFILE" ]] || die "--profile not found: $PROFILE"
[[ -f "$BOOTSTRAP_ENV" ]] || die "--bootstrap-env not found: $BOOTSTRAP_ENV" # Mode: exactly one of --bootstrap-env (direct, secret-bearing) or --pairing (generic, secret-free).
if $PAIRING; then
[[ -z "$BOOTSTRAP_ENV" ]] || die "--pairing and --bootstrap-env are mutually exclusive"
else
[[ -n "$BOOTSTRAP_ENV" ]] || die "one of --bootstrap-env (direct) or --pairing (generic) is required"
[[ -f "$BOOTSTRAP_ENV" ]] || die "--bootstrap-env not found: $BOOTSTRAP_ENV"
fi
command -v docker >/dev/null || die "docker not found (needed for the assistant container)" command -v docker >/dev/null || die "docker not found (needed for the assistant container)"
docker image inspect "$IMAGE" >/dev/null 2>&1 || die "assistant image '$IMAGE' not found — build it: docker build -f $HERE/Dockerfile.assistant -t $IMAGE $HERE" docker image inspect "$IMAGE" >/dev/null 2>&1 || die "assistant image '$IMAGE' not found — build it: docker build -f $HERE/Dockerfile.assistant -t $IMAGE $HERE"
@@ -107,7 +126,7 @@ PROFILE_NAME="$(basename "$PROFILE")"; PROFILE_NAME="${PROFILE_NAME%.profile}"
# --- load + validate profile ---------------------------------------------------------------------- # --- load + validate profile ----------------------------------------------------------------------
log_step "loading profile: $PROFILE" log_step "loading profile: $PROFILE"
FELHOM_FQDN=""; FELHOM_DISK_SETUP=""; FELHOM_ROOT_SSH_KEY=""; FELHOM_LOADER="" FELHOM_FQDN=""; FELHOM_DISK_SETUP=""; FELHOM_ROOT_SSH_KEY=""; FELHOM_LOADER=""; FELHOM_HUB_URL=""; FELHOM_INSTALL_URL=""
# shellcheck disable=SC1090 # shellcheck disable=SC1090
source "$PROFILE" source "$PROFILE"
[[ -n "$FELHOM_FQDN" ]] || die "profile missing FELHOM_FQDN" [[ -n "$FELHOM_FQDN" ]] || die "profile missing FELHOM_FQDN"
@@ -130,18 +149,29 @@ else
log_info "loader mode = shim (stock MS-signed chain; Secure Boot works on compliant firmware)" log_info "loader mode = shim (stock MS-signed chain; Secure Boot works on compliant firmware)"
fi fi
# --- validate bootstrap-env (secret-bearing detection) -------------------------------------------- # --- mode: DIRECT validates the secret-bearing env; PAIRING is secret-free (env generated below) -----
log_step "checking bootstrap-env (secret-bearing detection)" if $PAIRING; then
( set +e SECRET_BEARING="no"
FELHOM_CUSTOMER_ID=""; FELHOM_MODE=""; FELHOM_RETRIEVAL_PASSPHRASE="" PAIR_HUB_URL="${FELHOM_HUB_URL:-https://hub.felhom.eu}"
# shellcheck disable=SC1090 PAIR_INSTALL_URL="${FELHOM_INSTALL_URL:-https://felhom.eu/scripts/felhom-host-install.sh}"
source "$BOOTSTRAP_ENV" echo -e "${YELLOW}==================================================================================${NC}"
[[ -n "$FELHOM_CUSTOMER_ID" ]] || { echo "MISSING FELHOM_CUSTOMER_ID"; exit 3; } log_info "PAIRING MODE — building the GENERIC, SECRET-FREE universal ISO (slice C)."
[[ -n "$FELHOM_MODE" ]] || { echo "MISSING FELHOM_MODE"; exit 3; } log_info "The box registers as an unclaimed appliance; the operator binds it; the hub delivers the"
[[ -n "$FELHOM_RETRIEVAL_PASSPHRASE" ]] || { echo "MISSING FELHOM_RETRIEVAL_PASSPHRASE"; exit 3; } log_info "customer-id + passphrase ONCE. Baked env carries only the hub URL ($PAIR_HUB_URL) — no secret."
) || die "bootstrap-env invalid ($BOOTSTRAP_ENV) — must define FELHOM_CUSTOMER_ID, FELHOM_MODE, FELHOM_RETRIEVAL_PASSPHRASE" echo -e "${YELLOW}==================================================================================${NC}"
SECRET_BEARING="yes" # a valid bootstrap-env always carries the retrieval passphrase else
log_warn "this ISO will be SECRET-BEARING (embeds the customer retrieval passphrase) — supervised/single-use only" log_step "checking bootstrap-env (secret-bearing detection)"
( set +e
FELHOM_CUSTOMER_ID=""; FELHOM_MODE=""; FELHOM_RETRIEVAL_PASSPHRASE=""
# shellcheck disable=SC1090
source "$BOOTSTRAP_ENV"
[[ -n "$FELHOM_CUSTOMER_ID" ]] || { echo "MISSING FELHOM_CUSTOMER_ID"; exit 3; }
[[ -n "$FELHOM_MODE" ]] || { echo "MISSING FELHOM_MODE"; exit 3; }
[[ -n "$FELHOM_RETRIEVAL_PASSPHRASE" ]] || { echo "MISSING FELHOM_RETRIEVAL_PASSPHRASE"; exit 3; }
) || die "bootstrap-env invalid ($BOOTSTRAP_ENV) — must define FELHOM_CUSTOMER_ID, FELHOM_MODE, FELHOM_RETRIEVAL_PASSPHRASE"
SECRET_BEARING="yes" # a valid bootstrap-env always carries the retrieval passphrase
log_warn "this ISO will be SECRET-BEARING (embeds the customer retrieval passphrase) — supervised/single-use only"
fi
# --- workspace ------------------------------------------------------------------------------------ # --- workspace ------------------------------------------------------------------------------------
WORK="$(mktemp -d "${TMPDIR:-/tmp}/felhom-iso.XXXXXX")" WORK="$(mktemp -d "${TMPDIR:-/tmp}/felhom-iso.XXXXXX")"
@@ -151,6 +181,19 @@ trap cleanup EXIT
mkdir -p "$OUT_DIR" "$WORK/tmp" mkdir -p "$OUT_DIR" "$WORK/tmp"
ISO_DIR="$(cd "$(dirname "$PVE_ISO")" && pwd)"; ISO_BASE="$(basename "$PVE_ISO")" ISO_DIR="$(cd "$(dirname "$PVE_ISO")" && pwd)"; ISO_BASE="$(basename "$PVE_ISO")"
# PAIRING: generate the SECRET-FREE env the stub bakes — only the hub URL, no customer/passphrase.
# (The bootstrap detects the absent customer-id/passphrase and enters pairing mode.)
if $PAIRING; then
BOOTSTRAP_ENV="$WORK/pairing.env"
cat > "$BOOTSTRAP_ENV" <<EOF
# GENERIC secret-free pairing env (R-21 slice C). NO customer-id, NO passphrase — the box registers
# as an unclaimed appliance and the hub delivers the credentials once, after the operator binds it.
FELHOM_HUB_URL=$PAIR_HUB_URL
FELHOM_INSTALL_URL=$PAIR_INSTALL_URL
EOF
log_info "generated secret-free pairing env (hub=$PAIR_HUB_URL)"
fi
# --- mint fresh THROWAWAY root hash --------------------------------------------------------------- # --- mint fresh THROWAWAY root hash ---------------------------------------------------------------
log_step "minting fresh throwaway root password hash" log_step "minting fresh throwaway root password hash"
ROOT_PLAIN="felhom-throwaway-$(head -c12 /dev/urandom | base64 | tr -dc 'A-Za-z0-9')" ROOT_PLAIN="felhom-throwaway-$(head -c12 /dev/urandom | base64 | tr -dc 'A-Za-z0-9')"
@@ -210,7 +253,8 @@ grep -q '@@BOOTSTRAP_.*_B64@@' "$STUB" && die "stub still has unfilled markers
# --- prepare-iso ---------------------------------------------------------------------------------- # --- prepare-iso ----------------------------------------------------------------------------------
# Rule 4: the loader mode is loud in the filename — a '-mkimage' ISO implies Secure-Boot-off prep. # Rule 4: the loader mode is loud in the filename — a '-mkimage' ISO implies Secure-Boot-off prep.
LOADER_SUFFIX=""; [[ "$LOADER" != "shim" ]] && LOADER_SUFFIX="-${LOADER}" LOADER_SUFFIX=""; [[ "$LOADER" != "shim" ]] && LOADER_SUFFIX="-${LOADER}"
OUT_ISO="$OUT_DIR/felhom-pve-${PVE_VERSION}-v${ISO_VERSION}-${PROFILE_NAME}${LOADER_SUFFIX}.iso" MODE_SUFFIX=""; $PAIRING && MODE_SUFFIX="-generic" # the secret-free universal ISO is unmistakable
OUT_ISO="$OUT_DIR/felhom-pve-${PVE_VERSION}-v${ISO_VERSION}-${PROFILE_NAME}${MODE_SUFFIX}${LOADER_SUFFIX}.iso"
GRUB_VERSION="" # populated by the mkimage surgery (the grub-mkimage build used) GRUB_VERSION="" # populated by the mkimage surgery (the grub-mkimage build used)
log_step "building ISO: $(basename "$OUT_ISO")" log_step "building ISO: $(basename "$OUT_ISO")"
if $DRY_RUN; then if $DRY_RUN; then
@@ -252,8 +296,10 @@ ASSISTANT_VER="$(docker run --rm "$IMAGE" proxmox-auto-install-assistant --versi
echo "$OUT_SHA $(basename "$OUT_ISO")" > "$OUT_ISO.sha256" echo "$OUT_SHA $(basename "$OUT_ISO")" > "$OUT_ISO.sha256"
LOADER_NOTE="shim (stock MS-signed chain; Secure Boot OK on compliant firmware)" LOADER_NOTE="shim (stock MS-signed chain; Secure Boot OK on compliant firmware)"
[[ "$LOADER" == "mkimage" ]] && LOADER_NOTE="mkimage (monolithic grub-mkimage UEFI loader, F1 fix — UNSIGNED; target board MUST have Secure Boot OFF)" [[ "$LOADER" == "mkimage" ]] && LOADER_NOTE="mkimage (monolithic grub-mkimage UEFI loader, F1 fix — UNSIGNED; target board MUST have Secure Boot OFF)"
MODE_NOTE="direct (env-baked customer-id + retrieval passphrase; secret-bearing)"
$PAIRING && MODE_NOTE="pairing (GENERIC secret-free universal ISO — box self-registers, operator binds, hub delivers once)"
cat > "$OUT_ISO.manifest.txt" <<EOF cat > "$OUT_ISO.manifest.txt" <<EOF
Felhom bare-metal ISO build manifest (R-21 slice A+B) Felhom bare-metal ISO build manifest (R-21 slice A+B+C)
built : $(date -Is) built : $(date -Is)
iso-version-tag : v${ISO_VERSION} iso-version-tag : v${ISO_VERSION}
pve-version : ${PVE_VERSION} pve-version : ${PVE_VERSION}
@@ -262,10 +308,11 @@ source-iso-sha256 : ${ISO_SHA256}
assistant-version : ${ASSISTANT_VER} assistant-version : ${ASSISTANT_VER}
profile : ${PROFILE_NAME} profile : ${PROFILE_NAME}
fqdn : ${FELHOM_FQDN} fqdn : ${FELHOM_FQDN}
mode : ${MODE_NOTE}
loader : ${LOADER_NOTE} loader : ${LOADER_NOTE}
grub-mkimage : ${GRUB_VERSION:-n/a (shim mode; loader unchanged)} grub-mkimage : ${GRUB_VERSION:-n/a (shim mode; loader unchanged)}
host-install-url : $(grep -oE 'FELHOM_INSTALL_URL=[^ ]*' "$BOOTSTRAP_ENV" 2>/dev/null || echo 'https://felhom.eu/scripts/felhom-host-install.sh (default)') host-install-url : $(grep -oE 'FELHOM_INSTALL_URL=[^ ]*' "$BOOTSTRAP_ENV" 2>/dev/null || echo 'https://felhom.eu/scripts/felhom-host-install.sh (default)')
secret-bearing : ${SECRET_BEARING} (embeds the customer retrieval passphrase — supervised/single-use, delete after the run) secret-bearing : ${SECRET_BEARING}$( $PAIRING && echo ' (GENERIC ISO — carries NO customer secret)' || echo ' (embeds the customer retrieval passphrase — supervised/single-use, delete after the run)')
output : $(basename "$OUT_ISO") output : $(basename "$OUT_ISO")
output-sha256 : ${OUT_SHA} output-sha256 : ${OUT_SHA}
output-size-bytes : ${OUT_SIZE} output-size-bytes : ${OUT_SIZE}
@@ -275,4 +322,8 @@ log_success "ISO built: $OUT_ISO"
log_info "sha256 : $OUT_SHA" log_info "sha256 : $OUT_SHA"
log_info "size : $OUT_SIZE bytes" log_info "size : $OUT_SIZE bytes"
log_info "manifest : $OUT_ISO.manifest.txt" log_info "manifest : $OUT_ISO.manifest.txt"
log_warn "SECRET-BEARING ISO (embeds the retrieval passphrase). Supervised/single-use; never distribute; delete after the run." if $PAIRING; then
log_success "GENERIC secret-free ISO — carries NO customer secret. Bind the box on the hub after it registers."
else
log_warn "SECRET-BEARING ISO (embeds the retrieval passphrase). Supervised/single-use; never distribute; delete after the run."
fi
+212 -65
View File
@@ -2,23 +2,29 @@
#=============================================================================== #===============================================================================
# felhom-bootstrap.sh — invoked by felhom-bootstrap.service, retried until host-install succeeds. # felhom-bootstrap.sh — invoked by felhom-bootstrap.service, retried until host-install succeeds.
# #
# One attempt: read /etc/felhom/bootstrap.env -> fetch felhom-host-install.sh from the PUBLIC # ONE unit, TWO modes, decided by the env:
# distribution channel (hub install-command Option-1 URL) -> run it unattended with the customer's # DIRECT (env has FELHOM_CUSTOMER_ID + FELHOM_RETRIEVAL_PASSPHRASE) — the slice-A path, unchanged:
# retrieval passphrase -> on rc 0 write the done-flag + disable the unit; else exit non-zero so the # fetch felhom-host-install.sh from the PUBLIC channel -> run it unattended with the
# unit retries. Journal-only logging; the passphrase is never echoed and lives only in a 0600 tmpfs # customer's retrieval passphrase -> on rc 0 write the done-flag + disable + shred the env.
# file for the duration of one host-install invocation. # PAIRING (R-21 slice C — the GENERIC secret-free ISO, no customer-id/passphrase in the env):
# register this box as an UNCLAIMED appliance at the hub (uuid + MAC set + SSH host keys +
# hw), receive a one-per-registration APPLIANCE TOKEN (0600), then POLL for the operator's
# bind. ONE delivery hands over customer-id + retrieval passphrase; the bootstrap WRITES
# them into the env (0600) and FALLS THROUGH to the DIRECT path — so every later retry is a
# plain direct install (the delivery is one-shot; the box must not depend on re-fetching it).
# #
# Retry-vs-resume (source-verified, encoded ONCE): felhom-host-install.sh v1.11.3 makes --resume # The poll loop IS systemd's Restart=on-failure/RestartSec=30: each invocation does register-if-needed
# safe — its producer steps (token/enroll/grows) re-run every pass, so a resumed install repopulates # + exactly ONE poll, exiting non-zero (retry in 30s) until the bind delivers. This keeps every
# hub.host_id/proxmox.token and never writes a crash-loop config. A plain re-invoke over an existing # invocation short (no long-running-oneshot timeout) and reuses the existing retry machinery.
# install state, by contrast, would re-hit the populated-host leaf guard / existing-vmid refusal. #
# Therefore: FIRST attempt is plain; any later attempt that finds the install state file adds # Retry-vs-resume (source-verified, encoded ONCE): felhom-host-install.sh v1.11.3 makes --resume safe
# --resume. (--mode is required in both forms.) State file: /var/lib/felhom-install/state.json. # — its producer steps re-run every pass. FIRST direct attempt is plain; any later attempt that finds
# the install state file adds --resume. State file: /var/lib/felhom-install/state.json.
# #
# NOT production-generic: this is the R-21 bare-metal first-boot bootstrap. It does NOT modify # NOT production-generic: this is the R-21 bare-metal first-boot bootstrap. It does NOT modify
# felhom-host-install.sh; it only invokes it. # felhom-host-install.sh; it only invokes it.
#=============================================================================== #===============================================================================
# Deliberately NOT `set -e`: we must capture host-install's exit code and exit on our own terms. # Deliberately NOT `set -e`: we must capture exit codes and exit on our own terms.
set -uo pipefail set -uo pipefail
ENV_FILE=/etc/felhom/bootstrap.env ENV_FILE=/etc/felhom/bootstrap.env
@@ -26,6 +32,7 @@ DONE_FLAG=/etc/felhom/.bootstrap-done
STATE_FILE=/var/lib/felhom-install/state.json STATE_FILE=/var/lib/felhom-install/state.json
PASS_FILE=/run/felhom-bootstrap-pass PASS_FILE=/run/felhom-bootstrap-pass
SCRIPT_TMP=/run/felhom-host-install.sh SCRIPT_TMP=/run/felhom-host-install.sh
TOKEN_FILE=/etc/felhom/appliance-token # PAIRING: the box's only pre-day-0 credential (0600, persists reboots)
log() { echo "felhom-bootstrap: $*"; } log() { echo "felhom-bootstrap: $*"; }
@@ -38,61 +45,201 @@ if [[ -e "$DONE_FLAG" ]]; then
exit 0 exit 0
fi fi
# --- env ------------------------------------------------------------------------------------------ # --- env (may be absent in the generic ISO; a non-secret pairing env can still set FELHOM_HUB_URL) --
if [[ ! -r "$ENV_FILE" ]]; then FELHOM_CUSTOMER_ID=""; FELHOM_MODE=""; FELHOM_RETRIEVAL_PASSPHRASE=""
log "ERROR: $ENV_FILE missing or unreadable — cannot bootstrap (no guessed defaults)" FELHOM_HUB_URL=""; FELHOM_INSTALL_URL=""; FELHOM_EXTRA_ARGS=""
exit 1 if [[ -r "$ENV_FILE" ]]; then
# shellcheck disable=SC1090
source "$ENV_FILE"
fi fi
# shellcheck disable=SC1090
source "$ENV_FILE"
for var in FELHOM_CUSTOMER_ID FELHOM_MODE FELHOM_RETRIEVAL_PASSPHRASE; do
if [[ -z "${!var:-}" ]]; then
log "ERROR: $var is unset/empty in $ENV_FILE — refusing to guess"
exit 1
fi
done
HUB_URL="${FELHOM_HUB_URL:-https://hub.felhom.eu}" HUB_URL="${FELHOM_HUB_URL:-https://hub.felhom.eu}"
INSTALL_URL="${FELHOM_INSTALL_URL:-https://felhom.eu/scripts/felhom-host-install.sh}" INSTALL_URL="${FELHOM_INSTALL_URL:-https://felhom.eu/scripts/felhom-host-install.sh}"
EXTRA_ARGS="${FELHOM_EXTRA_ARGS:-}"
# --- fetch host-install (public channel) ---------------------------------------------------------- # =====================================================================================================
log "fetching host-install: $INSTALL_URL" # DIRECT mode — fetch + run host-install with the customer passphrase (slice A, unchanged behaviour).
if ! curl -fsSL --max-time 60 "$INSTALL_URL" -o "$SCRIPT_TMP"; then # =====================================================================================================
log "ERROR: host-install fetch failed (no network yet?) — unit will retry" run_direct() {
exit 1 for var in FELHOM_CUSTOMER_ID FELHOM_MODE FELHOM_RETRIEVAL_PASSPHRASE; do
if [[ -z "${!var:-}" ]]; then
log "ERROR: $var is unset/empty (direct mode) — refusing to guess"
exit 1
fi
done
log "fetching host-install: $INSTALL_URL"
if ! curl -fsSL --max-time 60 "$INSTALL_URL" -o "$SCRIPT_TMP"; then
log "ERROR: host-install fetch failed (no network yet?) — unit will retry"
exit 1
fi
if [[ ! -s "$SCRIPT_TMP" ]]; then
log "ERROR: fetched host-install is empty — unit will retry"
exit 1
fi
( umask 077; printf '%s' "$FELHOM_RETRIEVAL_PASSPHRASE" > "$PASS_FILE" )
local args=(--customer-id "$FELHOM_CUSTOMER_ID" --mode "$FELHOM_MODE" --hub-url "$HUB_URL" --passphrase-file "$PASS_FILE")
if [[ -f "$STATE_FILE" ]]; then
log "prior install state present ($STATE_FILE) -> adding --resume (host-install v1.11.3: producers re-run, safe)"
args+=(--resume)
fi
local extra
read -ra extra <<< "${FELHOM_EXTRA_ARGS:-}"
log "running host-install (customer=${FELHOM_CUSTOMER_ID} mode=${FELHOM_MODE} hub=${HUB_URL})"
bash "$SCRIPT_TMP" "${args[@]}" "${extra[@]}"
local rc=$?
cleanup_pass
if [[ $rc -eq 0 ]]; then
log "host-install SUCCESS — writing done-flag, disabling unit, scrubbing secrets"
install -d -m 0755 "$(dirname "$DONE_FLAG")"
: > "$DONE_FLAG"; chmod 0644 "$DONE_FLAG"
systemctl disable felhom-bootstrap.service 2>/dev/null || true
# Reduce secret-at-rest: the box is enrolled; the passphrase (and the appliance token) are done.
shred -u "$ENV_FILE" 2>/dev/null || rm -f "$ENV_FILE"
[[ -e "$TOKEN_FILE" ]] && { shred -u "$TOKEN_FILE" 2>/dev/null || rm -f "$TOKEN_FILE"; }
exit 0
fi
log "host-install FAILED rc=${rc} — unit will retry in 30s"
exit "$rc"
}
# =====================================================================================================
# PAIRING mode — register the unclaimed appliance, then ONE poll per invocation until the bind delivers.
# =====================================================================================================
# gather_identity_json builds the registration payload. Keying is (SMBIOS UUID, MAC set) — the N100 DMI
# verdict is that serials are unusable ("Default string"), so only the uuid + physical MAC set are
# trusted; hw is a non-keyed summary. python3 ships with PVE and JSON-encodes robustly.
gather_identity_json() {
local uuid; uuid=$(tr -d '\n' < /sys/class/dmi/id/product_uuid 2>/dev/null)
local product; product=$(tr -d '\n' < /sys/class/dmi/id/product_name 2>/dev/null)
local mem_kb; mem_kb=$(awk '/MemTotal/{print $2}' /proc/meminfo 2>/dev/null)
local cpu; cpu=$(awk -F: '/model name/{print $2; exit}' /proc/cpuinfo 2>/dev/null | sed 's/^ *//')
local macs=()
local d n m
for d in /sys/class/net/*; do
n=$(basename "$d")
[[ "$n" == "lo" ]] && continue
[[ -e "$d/device" ]] || continue # physical NICs only (skip bridges/veth/wg)
m=$(cat "$d/address" 2>/dev/null)
[[ -n "$m" && "$m" != "00:00:00:00:00:00" ]] && macs+=("$m")
done
local keys=()
local f
for f in /etc/ssh/ssh_host_*_key.pub; do
[[ -f "$f" ]] && keys+=("$(cat "$f")")
done
UUID_G="$uuid" PRODUCT_G="$product" CPU_G="$cpu" MEM_G="$mem_kb" \
MACS_G="$(printf '%s\n' "${macs[@]}")" KEYS_G="$(printf '%s\n' "${keys[@]}")" \
python3 - <<'PY'
import json, os
def lines(v): return [x for x in (v or "").splitlines() if x.strip()]
print(json.dumps({
"uuid": os.environ.get("UUID_G",""),
"macs": lines(os.environ.get("MACS_G","")),
"ssh_host_pubkeys": lines(os.environ.get("KEYS_G","")),
"hw": {"product": os.environ.get("PRODUCT_G",""),
"cpu": os.environ.get("CPU_G",""),
"mem_kb": int(os.environ.get("MEM_G") or 0)},
}))
PY
}
run_pairing() {
log "PAIRING mode (generic ISO, no baked customer/passphrase) — hub=$HUB_URL"
# 1. register once (persist the token). A present token means we already registered — go poll.
if [[ ! -s "$TOKEN_FILE" ]]; then
local payload; payload=$(gather_identity_json)
if [[ -z "$payload" || "$payload" != *'"uuid"'* ]]; then
log "ERROR: could not gather appliance identity — unit will retry"
exit 1
fi
log "registering unclaimed appliance at the hub"
local resp; resp=$(curl -fsS --max-time 30 -X POST \
-H 'Content-Type: application/json' --data "$payload" \
"$HUB_URL/api/v1/appliance/register" 2>/dev/null)
if [[ $? -ne 0 || -z "$resp" ]]; then
log "ERROR: registration failed (no network yet?) — unit will retry"
exit 1
fi
local token; token=$(printf '%s' "$resp" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("appliance_token",""))' 2>/dev/null)
if [[ -z "$token" ]]; then
log "ERROR: registration returned no appliance token — unit will retry"
exit 1
fi
( umask 077; printf '%s' "$token" > "$TOKEN_FILE" )
log "registered — appliance token stored (0600); waiting for the operator to bind this box"
fi
# 2. ONE poll. RestartSec=30 is the poll interval.
local token; token=$(cat "$TOKEN_FILE")
local body code
body=$(curl -sS --max-time 30 -o - -w '\n%{http_code}' \
-H "Authorization: Bearer $token" "$HUB_URL/api/v1/appliance/poll" 2>/dev/null)
code="${body##*$'\n'}"
body="${body%$'\n'*}"
case "$code" in
200)
log "bind DELIVERED — writing credentials to the env and switching to direct install"
# Parse the one-shot delivery into shell-safe env assignments (never echo the passphrase).
local envtext
envtext=$(printf '%s' "$body" | python3 -c '
import json, sys, shlex
d = json.load(sys.stdin)
def emit(k, v): print("%s=%s" % (k, shlex.quote(v or "")))
emit("FELHOM_CUSTOMER_ID", d.get("customer_id"))
emit("FELHOM_RETRIEVAL_PASSPHRASE", d.get("retrieval_passphrase"))
emit("FELHOM_MODE", d.get("mode") or "appliance")
emit("FELHOM_EXTRA_ARGS", d.get("extra_args"))
')
if [[ -z "$envtext" || "$envtext" != *FELHOM_RETRIEVAL_PASSPHRASE=* ]]; then
log "ERROR: delivery parse failed — unit will retry"
exit 1
fi
# Persist as the direct-mode env (0600) so EVERY later retry is a plain direct install
# (the delivery was one-shot; a second poll returns 410).
install -d -m 0755 "$(dirname "$ENV_FILE")"
( umask 077
{ printf '%s\n' "$envtext"
printf 'FELHOM_HUB_URL=%q\n' "$HUB_URL"
printf 'FELHOM_INSTALL_URL=%q\n' "$INSTALL_URL"
} > "$ENV_FILE" )
chmod 0600 "$ENV_FILE"
# Re-source + fall through to the direct install in THIS same invocation.
# shellcheck disable=SC1090
source "$ENV_FILE"
run_direct
;; # run_direct exits
204)
log "not bound yet — will poll again in 30s"
exit 1
;;
410)
log "ERROR: delivery already consumed but no local env — unit will retry (rare crash-window)"
exit 1
;;
404)
log "appliance token not recognized (discarded, or the hub has no record) — will retry in 30s"
exit 1
;;
*)
log "poll returned HTTP ${code:-none} — will retry in 30s"
exit 1
;;
esac
}
# --- mode selection -------------------------------------------------------------------------------
if [[ -n "$FELHOM_CUSTOMER_ID" && -n "$FELHOM_RETRIEVAL_PASSPHRASE" ]]; then
run_direct
else
run_pairing
fi fi
if [[ ! -s "$SCRIPT_TMP" ]]; then
log "ERROR: fetched host-install is empty — unit will retry"
exit 1
fi
# --- retrieval passphrase -> 0600 tmpfs file ------------------------------------------------------
( umask 077; printf '%s' "$FELHOM_RETRIEVAL_PASSPHRASE" > "$PASS_FILE" )
# --- retry-vs-resume ruling -----------------------------------------------------------------------
args=(--customer-id "$FELHOM_CUSTOMER_ID" --mode "$FELHOM_MODE" --hub-url "$HUB_URL" --passphrase-file "$PASS_FILE")
if [[ -f "$STATE_FILE" ]]; then
log "prior install state present ($STATE_FILE) -> adding --resume (host-install v1.11.3: producers re-run, safe)"
args+=(--resume)
fi
# EXTRA_ARGS are profile-only flags (never secrets); intentional word-split.
read -ra extra <<< "$EXTRA_ARGS"
log "running host-install (customer=${FELHOM_CUSTOMER_ID} mode=${FELHOM_MODE} hub=${HUB_URL})"
bash "$SCRIPT_TMP" "${args[@]}" "${extra[@]}"
rc=$?
cleanup_pass
if [[ $rc -eq 0 ]]; then
log "host-install SUCCESS — writing done-flag, disabling unit, scrubbing env"
install -d -m 0755 "$(dirname "$DONE_FLAG")"
: > "$DONE_FLAG"; chmod 0644 "$DONE_FLAG"
systemctl disable felhom-bootstrap.service 2>/dev/null || true
# Reduce secret-at-rest: the box is enrolled; the passphrase is no longer needed.
shred -u "$ENV_FILE" 2>/dev/null || rm -f "$ENV_FILE"
exit 0
fi
log "host-install FAILED rc=${rc} — unit will retry in 30s"
exit "$rc"
+26
View File
@@ -0,0 +1,26 @@
# Felhom ISO build profile — generic (R-21 slice C, the universal SECRET-FREE ISO).
#
# Build with `--pairing`: the produced ISO carries NO customer-id and NO retrieval passphrase. The box
# installs, registers itself at the hub as an UNCLAIMED APPLIANCE, and the operator binds it to a
# customer on the hub; the hub then delivers the credentials ONCE and day-0 completes.
#
# The hub URL below is baked into the box's pairing env (non-secret). Override per-deployment if the
# box must reach a different hub.
FELHOM_FQDN="felhom-appliance.local"
FELHOM_HUB_URL="https://hub.felhom.eu"
# Disk selection is orthogonal to slice C (credential delivery). This default targets the first SATA
# disk (sda) — correct for single-disk SATA mini-PCs and the nested-VM drill. A box whose target is
# NVMe/other needs a profile variant with a `filter.*` udev match (see README "N100 profile") or an
# explicit disk-list. A match-nothing / wrong disk fails-safe (installer aborts, spike S5c).
FELHOM_DISK_SETUP='[disk-setup]
filesystem = "ext4"
disk-list = ["sda"]'
# Cheap AMI (AN3PLUS-class) boards that can't USB-boot the stock GRUB also need the mkimage loader
# (F1) — uncomment, and set Secure Boot OFF on the target (see profiles/n100.profile):
# FELHOM_LOADER="mkimage"
# Optional emergency/validation key baked into the installed root account (blank -> not baked):
# FELHOM_ROOT_SSH_KEY="ssh-ed25519 AAAA... ops@felhom"
+110
View File
@@ -0,0 +1,110 @@
#!/bin/bash
# bootstrap-modes.sh — R-21 slice C regression harness for felhom-bootstrap.sh's two modes. Runs as
# root inside a throwaway debian container (writes /etc/felhom etc.); fakes curl + host-install +
# systemctl on PATH. Asserts:
# D (regression): a DIRECT env (customer-id + passphrase) enters run_direct and makes ZERO calls to
# /api/v1/appliance/* — the pairing code path is provably not entered.
# pairing: an env WITHOUT customer-id/passphrase enters run_pairing, POSTs /appliance/register,
# persists the token, and GETs /appliance/poll.
# delivery: a poll that returns 200 writes the direct env (0600) and invokes host-install.
set -uo pipefail
BSTRAP=/work/felhom-bootstrap.sh
FAKE=/work/fakebin; rm -rf "$FAKE"; mkdir -p "$FAKE"
CALLS=/work/curl.log
export PATH="$FAKE:$PATH"
fail=0
say() { echo "TEST: $*"; }
check() { if eval "$2"; then echo " ok: $1"; else echo " FAIL: $1"; fail=1; fi; }
# --- fake curl: logs every invocation's URL; emulates -o (fetch), --data (register), -w code (poll) --
cat > "$FAKE/curl" <<'CURL'
#!/bin/bash
url=""; ofile=""; wfmt=""
prev=""
for a in "$@"; do
case "$a" in http*|https*) url="$a";; esac
case "$prev" in -o) ofile="$a";; -w) wfmt="$a";; esac
prev="$a"
done
echo "$url" >> /work/curl.log
mode=$(cat /work/poll-mode 2>/dev/null || echo 204)
case "$url" in
*"/felhom-host-install.sh")
# write a stub host-install to the -o target
cat > "$ofile" <<'HI'
#!/bin/bash
echo "fake host-install ran: $*" >> /work/hostinstall.log
exit 0
HI
exit 0 ;;
*"/appliance/register")
echo '{"appliance_token":"TESTTOKEN123456","poll_interval_sec":30}'
exit 0 ;;
*"/appliance/poll")
if [ "$mode" = "200" ]; then
# body then, if -w set, a newline + code (matches the bootstrap's -w '\n%{http_code}')
printf '%s' '{"customer_id":"drill","retrieval_passphrase":"SEKRET-PASS","mode":"appliance","extra_args":"--cores 2"}'
[ -n "$wfmt" ] && printf '\n200'
else
[ -n "$wfmt" ] && printf '\n204'
fi
exit 0 ;;
esac
exit 0
CURL
chmod +x "$FAKE/curl"
# fake systemctl (disable is a no-op)
printf '#!/bin/bash\nexit 0\n' > "$FAKE/systemctl"; chmod +x "$FAKE/systemctl"
reset_state() {
rm -rf /etc/felhom /run/felhom-bootstrap-pass /var/lib/felhom-install "$CALLS" /work/hostinstall.log /work/poll-mode
mkdir -p /etc/felhom
}
# ============================ Scenario D — direct mode, zero appliance calls =========================
say "D: direct env -> run_direct, NO appliance calls"
reset_state
cat > /etc/felhom/bootstrap.env <<'ENV'
FELHOM_CUSTOMER_ID=acme
FELHOM_MODE=appliance
FELHOM_RETRIEVAL_PASSPHRASE=direct-pass
FELHOM_HUB_URL=https://hub.example
ENV
chmod 0600 /etc/felhom/bootstrap.env
bash "$BSTRAP"; rc=$?
check "run_direct exited 0 (host-install stub succeeded)" "[ $rc -eq 0 ]"
check "host-install was invoked" "[ -f /work/hostinstall.log ]"
check "ZERO /appliance/register calls" "! grep -q '/appliance/register' $CALLS"
check "ZERO /appliance/poll calls" "! grep -q '/appliance/poll' $CALLS"
check "done-flag written" "[ -f /etc/felhom/.bootstrap-done ]"
check "env shredded on success" "[ ! -f /etc/felhom/bootstrap.env ]"
# ============================ pairing mode — registers, then polls ==================================
say "pairing: no customer/passphrase -> register + poll (unbound=204)"
reset_state
cat > /etc/felhom/bootstrap.env <<'ENV'
FELHOM_HUB_URL=https://hub.example
ENV
echo 204 > /work/poll-mode
bash "$BSTRAP"; rc=$?
check "unbound poll -> exit non-zero (systemd retries)" "[ $rc -ne 0 ]"
check "POSTed /appliance/register" "grep -q '/appliance/register' $CALLS"
check "appliance token persisted 0600" "[ -f /etc/felhom/appliance-token ] && [ \"\$(stat -c %a /etc/felhom/appliance-token)\" = 600 ]"
check "GET /appliance/poll" "grep -q '/appliance/poll' $CALLS"
check "no direct env written yet" "! grep -q FELHOM_CUSTOMER_ID /etc/felhom/bootstrap.env"
check "host-install NOT run (unbound)" "[ ! -f /work/hostinstall.log ]"
# ============================ delivery — poll 200 writes env + runs host-install =====================
say "delivery: bound poll (200) -> write direct env + run host-install"
# keep the token from the previous step; flip the poll to 200
echo 200 > /work/poll-mode
bash "$BSTRAP"; rc=$?
check "delivery run exited 0" "[ $rc -eq 0 ]"
check "direct env written with customer-id" "grep -q 'FELHOM_CUSTOMER_ID=drill' /etc/felhom/bootstrap.env || [ -f /etc/felhom/.bootstrap-done ]"
check "host-install invoked after delivery" "[ -f /work/hostinstall.log ]"
check "done-flag written" "[ -f /etc/felhom/.bootstrap-done ]"
echo "=================================================="
if [ $fail -eq 0 ]; then echo "ALL BOOTSTRAP-MODE TESTS PASSED"; else echo "SOME TESTS FAILED"; fi
exit $fail