592818492c
Let a customer bind their own freshly-installed appliance without the operator: operator "Send self-bind link" mints a 7-day tokenized capability link, emailed (Hungarian, sibling sender) to the customer, who opens a public /bind/<token> page and proves two factors — the console pairing code shown on the box screen + their retrieval passphrase — and the hub stages the bind via the same BindAppliance (provenance customer_selfbind). The box's ~30s appliance poll delivers. Viktor's three rulings verbatim: console pairing code (no appliance list ever rendered), operator-sent tokenized link, 5-attempt lockout -> "call support". Wrong code == wrong passphrase (one generic failure, no oracle, both factors compared unconditionally); expiry falls back to operator-bind unchanged. THE TRAP: one public prefix /bind/, exempt from auth+CSRF at both /login gate sites via a single isPublicBindPath predicate (tight trailing-slash match; ServeMux ..-cleans; handler rejects '/' in token). 9 tests (Scenarios A-F + F1/F2); 4 red-proofs verified red-then-green (lockout, oracle, widened-prefix, single-active). GC verdict: no appliance GC -> the 7-day TTL stands alone. Controller/agent untouched; R-27b deferred. Green: full hub build/vet/test (17 ok) + bash -n + hub confirm gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017qDiBqKKQ5vPB5fXBqu7Kp
251 lines
8.8 KiB
Go
251 lines
8.8 KiB
Go
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
|
|
}
|