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