hub v0.62.0 + scripts v1.19.0 — R-21 slice C: the universal secret-free ISO
A generic ISO carries NO customer secret. The box registers itself at the hub as an unclaimed appliance; the operator binds it to a customer; the hub delivers the customer-id + retrieval passphrase ONCE; day-0 completes via the slice-A path. Hub (v0.62.0): - store/appliance.go: appliance_registrations keyed by (uuid, mac_set) — MAC set is the tiebreaker (duplicate SMBIOS UUIDs); token stored as sha256 only. Idempotent register (sticky-discard), atomic one-shot delivery, bind/discard. - api/appliance.go: POST /appliance/register (the one unauth endpoint, per-IP rate-limited, 256-bit token); GET /appliance/poll (404 no-oracle / 204 unbound / 200 deliver-once / 410 delivered). Passphrase read live, never logged. - web/appliances.go: Hosts-page "Unclaimed appliances" section + BIND (customer picker, host count display-only) + DISCARD; SSH host-key fingerprints; events. - Red-proofs: one-shot delivery + register idempotency (both proven red); 404-no-oracle, sticky-discard, bind staging, render. Green + confirm gate. Scripts (v1.19.0): - felhom-bootstrap.sh: ONE unit, TWO modes. Direct (env has customer/passphrase) = slice-A path, byte-identical, only branched around. Pairing (generic) = register + poll (RestartSec=30 is the poll timer); on delivery write the env 0600 and fall through to direct. Secrets + token shredded on success. - build-felhom-iso.sh --pairing: generic secret-free ISO, -generic filename, manifest mode=pairing. profiles/generic.profile (new). - test/bootstrap-modes.sh: Scenario D (direct = zero appliance calls) + pairing register/poll + delivery handoff — all green in a debian container.
This commit is contained in:
@@ -0,0 +1,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
|
||||
mailFromAllow map[string]bool
|
||||
|
||||
// applianceLimiter (v0.62.0, R-21 slice C) throttles the ONE unauthenticated endpoint,
|
||||
// POST /api/v1/appliance/register, per client IP — the unclaimed population is tiny and the
|
||||
// ingress already geo-restricts to HU, so this is a cheap anti-abuse bound, not a fleet lever.
|
||||
applianceLimiter *ipRateLimiter
|
||||
|
||||
// S1 offsite connectivity: the wgsync reconciler seam (internal/api/wg.go). nil = peer-sync
|
||||
// disabled — mutations still persist, responses carry sync:"disabled".
|
||||
wgSyncer WGSyncer
|
||||
@@ -117,6 +122,7 @@ func New(store *store.Store, apiKey, resendAPIKey, fromEmail string, templatePro
|
||||
logger: logger,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
templateProvider: templateProvider,
|
||||
applianceLimiter: newIPRateLimiter(20), // 20 registrations/min/IP burst — booting boxes retry ~30s
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,6 +202,12 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// operator-intent bump for the box's customer, then the box fires its ordinary report.
|
||||
case r.Method == http.MethodGet && path == "/wait":
|
||||
h.handleWait(w, r)
|
||||
// R-21 slice C — the universal ISO. register is the ONE unauthenticated endpoint (per-IP
|
||||
// rate-limited); poll is Bearer appliance-token. Both minimal, no enumeration oracle.
|
||||
case r.Method == http.MethodPost && path == "/appliance/register":
|
||||
h.handleApplianceRegister(w, r)
|
||||
case r.Method == http.MethodGet && path == "/appliance/poll":
|
||||
h.handleAppliancePoll(w, r)
|
||||
case r.Method == http.MethodPost && path == "/host-report":
|
||||
h.handleHostReport(w, r)
|
||||
case r.Method == http.MethodPost && path == "/host-enroll":
|
||||
|
||||
Reference in New Issue
Block a user