From 36c5cd5fdf355fb439b1f909340b17cd75c685fb Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Fri, 17 Jul 2026 15:07:31 +0200 Subject: [PATCH] =?UTF-8?q?hub=20v0.62.0=20+=20scripts=20v1.19.0=20?= =?UTF-8?q?=E2=80=94=20R-21=20slice=20C:=20the=20universal=20secret-free?= =?UTF-8?q?=20ISO?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- hub/CHANGELOG.md | 28 +++ hub/internal/api/appliance.go | 239 ++++++++++++++++++++++ hub/internal/api/appliance_test.go | 149 ++++++++++++++ hub/internal/api/handler.go | 12 ++ hub/internal/store/appliance.go | 217 ++++++++++++++++++++ hub/internal/store/store.go | 30 +++ hub/internal/web/appliances.go | 187 +++++++++++++++++ hub/internal/web/appliances_test.go | 147 ++++++++++++++ hub/internal/web/hosts.go | 13 +- hub/internal/web/server.go | 13 ++ hub/internal/web/templates/hosts.html | 53 +++++ scripts/CHANGELOG.md | 21 ++ scripts/iso/build-felhom-iso.sh | 99 ++++++--- scripts/iso/felhom-bootstrap.sh | 277 ++++++++++++++++++++------ scripts/iso/profiles/generic.profile | 26 +++ scripts/iso/test/bootstrap-modes.sh | 110 ++++++++++ 16 files changed, 1531 insertions(+), 90 deletions(-) create mode 100644 hub/internal/api/appliance.go create mode 100644 hub/internal/api/appliance_test.go create mode 100644 hub/internal/store/appliance.go create mode 100644 hub/internal/web/appliances.go create mode 100644 hub/internal/web/appliances_test.go create mode 100644 scripts/iso/profiles/generic.profile create mode 100644 scripts/iso/test/bootstrap-modes.sh diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index 8782e5a..96d56cb 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -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 diff --git a/hub/internal/api/appliance.go b/hub/internal/api/appliance.go new file mode 100644 index 0000000..64a5a9e --- /dev/null +++ b/hub/internal/api/appliance.go @@ -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 +} diff --git a/hub/internal/api/appliance_test.go b/hub/internal/api/appliance_test.go new file mode 100644 index 0000000..0e7a97a --- /dev/null +++ b/hub/internal/api/appliance_test.go @@ -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) + } +} diff --git a/hub/internal/api/handler.go b/hub/internal/api/handler.go index e402007..708608c 100644 --- a/hub/internal/api/handler.go +++ b/hub/internal/api/handler.go @@ -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": diff --git a/hub/internal/store/appliance.go b/hub/internal/store/appliance.go new file mode 100644 index 0000000..70d903b --- /dev/null +++ b/hub/internal/store/appliance.go @@ -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 +} diff --git a/hub/internal/store/store.go b/hub/internal/store/store.go index 266c9ec..6d47bd1 100644 --- a/hub/internal/store/store.go +++ b/hub/internal/store/store.go @@ -611,6 +611,36 @@ func (s *Store) migrate() error { legs_json TEXT NOT NULL DEFAULT '{}' ); CREATE INDEX IF NOT EXISTS idx_customer_resets_customer ON customer_resets(customer_id, id DESC); + + -- appliance_registrations (v0.62.0, R-21 slice C — the universal secret-free ISO): a box + -- booted from the GENERIC ISO registers itself here as an UNCLAIMED appliance, the operator + -- binds it to a customer, and one poll delivers the customer-id + retrieval passphrase ONCE. + -- Keyed by (uuid, mac_set): the N100 DMI verdict says serials are unusable ("Default string"), + -- and cheap boards ship DUPLICATE SMBIOS UUIDs — the MAC set is the tiebreaker, so the same + -- uuid with a different mac_set is a DISTINCT appliance. token_hash = sha256(appliance token); + -- the token itself is never stored. status: registered→bound→delivered (one-shot) | discarded. + -- This table's own timestamps ARE the provenance for the pre-bind phase (no customer to scope a + -- customer-events row to yet — mirrors host_deletions/customer_resets self-contained provenance). + CREATE TABLE IF NOT EXISTS appliance_registrations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL, + mac_set TEXT NOT NULL, + ssh_host_pubkeys TEXT NOT NULL DEFAULT '', + hw_summary TEXT NOT NULL DEFAULT '', + token_hash TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'registered', + customer_id TEXT, + install_mode TEXT, + extra_args TEXT, + first_seen DATETIME NOT NULL DEFAULT (datetime('now')), + last_seen DATETIME NOT NULL DEFAULT (datetime('now')), + bound_at DATETIME, + delivered_at DATETIME, + discarded_at DATETIME, + UNIQUE(uuid, mac_set) + ); + CREATE INDEX IF NOT EXISTS idx_appliance_status ON appliance_registrations(status, last_seen DESC); + CREATE INDEX IF NOT EXISTS idx_appliance_token ON appliance_registrations(token_hash); `) if err != nil { return err diff --git a/hub/internal/web/appliances.go b/hub/internal/web/appliances.go new file mode 100644 index 0000000..56af54a --- /dev/null +++ b/hub/internal/web/appliances.go @@ -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: " [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}/. +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 +} diff --git a/hub/internal/web/appliances_test.go b/hub/internal/web/appliances_test.go new file mode 100644 index 0000000..17534cc --- /dev/null +++ b/hub/internal/web/appliances_test.go @@ -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)) + } +} diff --git a/hub/internal/web/hosts.go b/hub/internal/web/hosts.go index 79ed97c..5219823 100644 --- a/hub/internal/web/hosts.go +++ b/hub/internal/web/hosts.go @@ -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) diff --git a/hub/internal/web/server.go b/hub/internal/web/server.go index ca728b7..079424e 100644 --- a/hub/internal/web/server.go +++ b/hub/internal/web/server.go @@ -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"): diff --git a/hub/internal/web/templates/hosts.html b/hub/internal/web/templates/hosts.html index 474589f..0d03e6f 100644 --- a/hub/internal/web/templates/hosts.html +++ b/hub/internal/web/templates/hosts.html @@ -8,6 +8,7 @@ {{template "icon_sprite"}} + {{template "inline_confirm_js"}}

Felhom Hub

@@ -23,6 +24,58 @@

Hosts

+ {{if .Flash}} +
+ {{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}} +
+ {{end}} + + {{if .Unclaimed}} +
+

Unclaimed appliances (booted from the generic ISO, awaiting a bind)

+

A box that installed from the universal secret-free ISO and registered itself. Bind it to a customer to deliver its retrieval passphrase once; discard to ignore it.

+
+ + + + + + {{range .Unclaimed}} + + + + + + + + + + {{end}} + +
ApplianceMACsHardwareSSH host keysSeenBind to customer
{{.UUID}} + {{if .Stale}}
stale{{end}} + {{if .Bound}}
bound → {{.BoundCustomer}}{{end}} +
{{range .MACs}}{{.}}
{{end}}
{{if .Product}}{{.Product}}
{{end}}{{if .CPU}}{{.CPU}}
{{end}}{{if .MemGB}}{{.MemGB}}{{end}}
{{range .SSHFingerprints}}{{.}}
{{end}}
{{if .FirstSeen}}first {{timeAgoPtr .FirstSeen}}
{{end}}{{if .LastSeen}}last {{timeAgoPtr .LastSeen}}{{end}}
+
+ + + +
+
+
+ + +
+
+
+
+ {{end}} + {{if .Hosts}}
diff --git a/scripts/CHANGELOG.md b/scripts/CHANGELOG.md index 0d008ab..cb05ec3 100644 --- a/scripts/CHANGELOG.md +++ b/scripts/CHANGELOG.md @@ -1,5 +1,26 @@ # 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) Closes N100 finding **F1 (HIGH):** cheap AMI (`AN3PLUS 0.01`-class) UEFI firmware can't relocate the diff --git a/scripts/iso/build-felhom-iso.sh b/scripts/iso/build-felhom-iso.sh index 4e18674..12d5bea 100644 --- a/scripts/iso/build-felhom-iso.sh +++ b/scripts/iso/build-felhom-iso.sh @@ -1,6 +1,11 @@ #!/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, # gates the answer through validate-answer by PARSING ITS OUTPUT (never $? — validate-answer returns @@ -27,7 +32,7 @@ #=============================================================================== 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}" 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 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() { cat </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" @@ -107,7 +126,7 @@ PROFILE_NAME="$(basename "$PROFILE")"; PROFILE_NAME="${PROFILE_NAME%.profile}" # --- load + validate 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 source "$PROFILE" [[ -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)" fi -# --- validate bootstrap-env (secret-bearing detection) -------------------------------------------- -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" +# --- mode: DIRECT validates the secret-bearing env; PAIRING is secret-free (env generated below) ----- +if $PAIRING; then + SECRET_BEARING="no" + PAIR_HUB_URL="${FELHOM_HUB_URL:-https://hub.felhom.eu}" + PAIR_INSTALL_URL="${FELHOM_INSTALL_URL:-https://felhom.eu/scripts/felhom-host-install.sh}" + echo -e "${YELLOW}==================================================================================${NC}" + log_info "PAIRING MODE — building the GENERIC, SECRET-FREE universal ISO (slice C)." + log_info "The box registers as an unclaimed appliance; the operator binds it; the hub delivers the" + log_info "customer-id + passphrase ONCE. Baked env carries only the hub URL ($PAIR_HUB_URL) — no secret." + echo -e "${YELLOW}==================================================================================${NC}" +else + 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 ------------------------------------------------------------------------------------ WORK="$(mktemp -d "${TMPDIR:-/tmp}/felhom-iso.XXXXXX")" @@ -151,6 +181,19 @@ trap cleanup EXIT mkdir -p "$OUT_DIR" "$WORK/tmp" 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" < "$OUT_ISO.sha256" 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)" +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" </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-sha256 : ${OUT_SHA} output-size-bytes : ${OUT_SIZE} @@ -275,4 +322,8 @@ log_success "ISO built: $OUT_ISO" log_info "sha256 : $OUT_SHA" log_info "size : $OUT_SIZE bytes" 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 diff --git a/scripts/iso/felhom-bootstrap.sh b/scripts/iso/felhom-bootstrap.sh index 8f91484..7a83bf6 100644 --- a/scripts/iso/felhom-bootstrap.sh +++ b/scripts/iso/felhom-bootstrap.sh @@ -2,23 +2,29 @@ #=============================================================================== # 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 -# distribution channel (hub install-command Option-1 URL) -> run it unattended with the customer's -# retrieval passphrase -> on rc 0 write the done-flag + disable the unit; else exit non-zero so the -# unit retries. Journal-only logging; the passphrase is never echoed and lives only in a 0600 tmpfs -# file for the duration of one host-install invocation. +# ONE unit, TWO modes, decided by the env: +# DIRECT (env has FELHOM_CUSTOMER_ID + FELHOM_RETRIEVAL_PASSPHRASE) — the slice-A path, unchanged: +# fetch felhom-host-install.sh from the PUBLIC channel -> run it unattended with the +# customer's retrieval passphrase -> on rc 0 write the done-flag + disable + shred the env. +# 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 -# safe — its producer steps (token/enroll/grows) re-run every pass, so a resumed install repopulates -# hub.host_id/proxmox.token and never writes a crash-loop config. A plain re-invoke over an existing -# 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 -# --resume. (--mode is required in both forms.) State file: /var/lib/felhom-install/state.json. +# The poll loop IS systemd's Restart=on-failure/RestartSec=30: each invocation does register-if-needed +# + exactly ONE poll, exiting non-zero (retry in 30s) until the bind delivers. This keeps every +# invocation short (no long-running-oneshot timeout) and reuses the existing retry machinery. +# +# Retry-vs-resume (source-verified, encoded ONCE): felhom-host-install.sh v1.11.3 makes --resume safe +# — 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 # 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 ENV_FILE=/etc/felhom/bootstrap.env @@ -26,6 +32,7 @@ DONE_FLAG=/etc/felhom/.bootstrap-done STATE_FILE=/var/lib/felhom-install/state.json PASS_FILE=/run/felhom-bootstrap-pass 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: $*"; } @@ -38,61 +45,201 @@ if [[ -e "$DONE_FLAG" ]]; then exit 0 fi -# --- env ------------------------------------------------------------------------------------------ -if [[ ! -r "$ENV_FILE" ]]; then - log "ERROR: $ENV_FILE missing or unreadable — cannot bootstrap (no guessed defaults)" - exit 1 +# --- env (may be absent in the generic ISO; a non-secret pairing env can still set FELHOM_HUB_URL) -- +FELHOM_CUSTOMER_ID=""; FELHOM_MODE=""; FELHOM_RETRIEVAL_PASSPHRASE="" +FELHOM_HUB_URL=""; FELHOM_INSTALL_URL=""; FELHOM_EXTRA_ARGS="" +if [[ -r "$ENV_FILE" ]]; then + # shellcheck disable=SC1090 + source "$ENV_FILE" 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}" 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" -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 +# ===================================================================================================== +# DIRECT mode — fetch + run host-install with the customer passphrase (slice A, unchanged behaviour). +# ===================================================================================================== +run_direct() { + 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 -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" diff --git a/scripts/iso/profiles/generic.profile b/scripts/iso/profiles/generic.profile new file mode 100644 index 0000000..20e7411 --- /dev/null +++ b/scripts/iso/profiles/generic.profile @@ -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" diff --git a/scripts/iso/test/bootstrap-modes.sh b/scripts/iso/test/bootstrap-modes.sh new file mode 100644 index 0000000..9fafa9d --- /dev/null +++ b/scripts/iso/test/bootstrap-modes.sh @@ -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