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 } // v0.66.0 (R-27): a stable 6-char pairing code the box prints on its console + the customer types // into the self-bind page. The candidate is used only on first insert; a re-register keeps the code. candidateCode, err := configgen.RandomPairingCode() if err != nil { h.logger.Printf("[ERROR] appliance register: pairing-code mint: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } isNew, pairingCode, err := h.store.RegisterAppliance(uuid, macSet, sshKeys, hwSummary, sha256hex(token), candidateCode) 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) // pairing_code is additive — a pre-v0.66.0 bootstrap ignores it and stays operator-bind-only. json.NewEncoder(w).Encode(map[string]any{ "appliance_token": token, "poll_interval_sec": 30, "pairing_code": configgen.FormatPairingCode(pairingCode), }) } // 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 }