hub v0.62.0 + scripts v1.19.0 — R-21 slice C: the universal secret-free ISO

A generic ISO carries NO customer secret. The box registers itself at the hub
as an unclaimed appliance; the operator binds it to a customer; the hub delivers
the customer-id + retrieval passphrase ONCE; day-0 completes via the slice-A path.

Hub (v0.62.0):
- store/appliance.go: appliance_registrations keyed by (uuid, mac_set) — MAC set
  is the tiebreaker (duplicate SMBIOS UUIDs); token stored as sha256 only.
  Idempotent register (sticky-discard), atomic one-shot delivery, bind/discard.
- api/appliance.go: POST /appliance/register (the one unauth endpoint, per-IP
  rate-limited, 256-bit token); GET /appliance/poll (404 no-oracle / 204 unbound
  / 200 deliver-once / 410 delivered). Passphrase read live, never logged.
- web/appliances.go: Hosts-page "Unclaimed appliances" section + BIND (customer
  picker, host count display-only) + DISCARD; SSH host-key fingerprints; events.
- Red-proofs: one-shot delivery + register idempotency (both proven red);
  404-no-oracle, sticky-discard, bind staging, render. Green + confirm gate.

Scripts (v1.19.0):
- felhom-bootstrap.sh: ONE unit, TWO modes. Direct (env has customer/passphrase)
  = slice-A path, byte-identical, only branched around. Pairing (generic) =
  register + poll (RestartSec=30 is the poll timer); on delivery write the env
  0600 and fall through to direct. Secrets + token shredded on success.
- build-felhom-iso.sh --pairing: generic secret-free ISO, -generic filename,
  manifest mode=pairing. profiles/generic.profile (new).
- test/bootstrap-modes.sh: Scenario D (direct = zero appliance calls) + pairing
  register/poll + delivery handoff — all green in a debian container.
This commit is contained in:
2026-07-17 15:07:31 +02:00
parent 3172df1927
commit 36c5cd5fdf
16 changed files with 1531 additions and 90 deletions
+12
View File
@@ -53,6 +53,11 @@ type Handler struct {
mailLimiter *mailRateLimiter
mailFromAllow map[string]bool
// applianceLimiter (v0.62.0, R-21 slice C) throttles the ONE unauthenticated endpoint,
// POST /api/v1/appliance/register, per client IP — the unclaimed population is tiny and the
// ingress already geo-restricts to HU, so this is a cheap anti-abuse bound, not a fleet lever.
applianceLimiter *ipRateLimiter
// S1 offsite connectivity: the wgsync reconciler seam (internal/api/wg.go). nil = peer-sync
// disabled — mutations still persist, responses carry sync:"disabled".
wgSyncer WGSyncer
@@ -117,6 +122,7 @@ func New(store *store.Store, apiKey, resendAPIKey, fromEmail string, templatePro
logger: logger,
httpClient: &http.Client{Timeout: 10 * time.Second},
templateProvider: templateProvider,
applianceLimiter: newIPRateLimiter(20), // 20 registrations/min/IP burst — booting boxes retry ~30s
}
}
@@ -196,6 +202,12 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// operator-intent bump for the box's customer, then the box fires its ordinary report.
case r.Method == http.MethodGet && path == "/wait":
h.handleWait(w, r)
// R-21 slice C — the universal ISO. register is the ONE unauthenticated endpoint (per-IP
// rate-limited); poll is Bearer appliance-token. Both minimal, no enumeration oracle.
case r.Method == http.MethodPost && path == "/appliance/register":
h.handleApplianceRegister(w, r)
case r.Method == http.MethodGet && path == "/appliance/poll":
h.handleAppliancePoll(w, r)
case r.Method == http.MethodPost && path == "/host-report":
h.handleHostReport(w, r)
case r.Method == http.MethodPost && path == "/host-enroll":