Files
felhom.eu/hub/internal/api/appliance.go
T
admin 592818492c hub v0.66.0 + ISO v1.20.0: customer self-bind (R-27 slice 1)
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
2026-07-17 23:56:53 +02:00

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
}