feat: customer config management — CRUD, API retrieval, per-customer auth (v0.2.0)

New "Configurations" section lets operators pre-configure customer settings
in the Hub, then docker-setup.sh can download a ready-made controller.yaml
using just a customer ID and retrieval password.

- Store: customer_configs table with CRUD + per-customer API key lookup
- API: GET /api/v1/config/{id} with X-Retrieval-Password auth
- Auth: per-customer API keys alongside existing global key (backward compatible)
- Web UI: /configs list, create, edit, delete, YAML preview, copy-to-clipboard
- YAML gen: deep-merge controller.yaml.example template with customer overrides
- Template fetcher: background goroutine refreshing template from Gitea repo
- Navigation: Dashboard / Configurations tabs on all pages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 13:36:32 +01:00
parent 36a7d1c162
commit 4c8bf63ce3
18 changed files with 1631 additions and 67 deletions
+107 -46
View File
@@ -2,6 +2,7 @@ package api
import (
"bytes"
"crypto/subtle"
"encoding/json"
"fmt"
"io"
@@ -10,31 +11,58 @@ import (
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/configgen"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// ConfigTemplateProvider returns the controller.yaml template for config generation.
type ConfigTemplateProvider interface {
Template() string
}
// Handler handles API endpoints for report ingest and customer queries.
type Handler struct {
store *store.Store
apiKey string
resendAPIKey string
fromEmail string
logger *log.Logger
httpClient *http.Client
store *store.Store
apiKey string
resendAPIKey string
fromEmail string
logger *log.Logger
httpClient *http.Client
templateProvider ConfigTemplateProvider
}
// New creates a new API handler.
func New(store *store.Store, apiKey, resendAPIKey, fromEmail string, logger *log.Logger) *Handler {
func New(store *store.Store, apiKey, resendAPIKey, fromEmail string, templateProvider ConfigTemplateProvider, logger *log.Logger) *Handler {
return &Handler{
store: store,
apiKey: apiKey,
resendAPIKey: resendAPIKey,
fromEmail: fromEmail,
logger: logger,
httpClient: &http.Client{Timeout: 10 * time.Second},
store: store,
apiKey: apiKey,
resendAPIKey: resendAPIKey,
fromEmail: fromEmail,
logger: logger,
httpClient: &http.Client{Timeout: 10 * time.Second},
templateProvider: templateProvider,
}
}
// checkAuth verifies the Bearer token against the global API key or a per-customer API key.
// Returns true if authorized.
func (h *Handler) checkAuth(r *http.Request) bool {
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
return false
}
token := strings.TrimPrefix(auth, "Bearer ")
// Check global key first
if h.apiKey != "" && subtle.ConstantTimeCompare([]byte(token), []byte(h.apiKey)) == 1 {
return true
}
// Check per-customer key
cfg, err := h.store.GetCustomerConfigByAPIKey(token)
return err == nil && cfg != nil
}
// ServeHTTP routes API requests.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/v1")
@@ -60,19 +88,18 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} else {
h.handleCustomer(w, r, customerID)
}
case r.Method == http.MethodGet && strings.HasPrefix(path, "/config/"):
customerID := strings.TrimPrefix(path, "/config/")
h.handleConfigRetrieve(w, r, customerID)
default:
http.NotFound(w, r)
}
}
func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) {
// Verify bearer token
if h.apiKey != "" {
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") != h.apiKey {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if !h.checkAuth(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit
@@ -203,13 +230,9 @@ func (h *Handler) handleCustomerHistory(w http.ResponseWriter, r *http.Request,
// handleNotify processes notification events from customer controllers.
func (h *Handler) handleNotify(w http.ResponseWriter, r *http.Request) {
// Verify bearer token (same auth as /report)
if h.apiKey != "" {
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") != h.apiKey {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if !h.checkAuth(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
@@ -292,13 +315,9 @@ func (h *Handler) handleNotify(w http.ResponseWriter, r *http.Request) {
// handleSavePreferences stores notification preferences pushed from a customer controller.
func (h *Handler) handleSavePreferences(w http.ResponseWriter, r *http.Request) {
// Same bearer token auth as /report and /notify
if h.apiKey != "" {
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") != h.apiKey {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if !h.checkAuth(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
@@ -330,12 +349,9 @@ func (h *Handler) handleSavePreferences(w http.ResponseWriter, r *http.Request)
// handleInfraBackupPush stores an infrastructure snapshot from a controller.
func (h *Handler) handleInfraBackupPush(w http.ResponseWriter, r *http.Request) {
if h.apiKey != "" {
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") != h.apiKey {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if !h.checkAuth(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit
@@ -365,12 +381,9 @@ func (h *Handler) handleInfraBackupPush(w http.ResponseWriter, r *http.Request)
// handleInfraBackupGet returns the infrastructure backup for a customer.
func (h *Handler) handleInfraBackupGet(w http.ResponseWriter, r *http.Request, customerID string) {
if h.apiKey != "" {
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") != h.apiKey {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if !h.checkAuth(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if customerID == "" {
@@ -393,6 +406,54 @@ func (h *Handler) handleInfraBackupGet(w http.ResponseWriter, r *http.Request, c
w.Write(data)
}
// handleConfigRetrieve returns a generated controller.yaml for a customer.
// Auth: X-Retrieval-Password header (not Bearer token).
func (h *Handler) handleConfigRetrieve(w http.ResponseWriter, r *http.Request, customerID string) {
if customerID == "" {
http.Error(w, "Missing customer_id", http.StatusBadRequest)
return
}
password := r.Header.Get("X-Retrieval-Password")
if password == "" {
http.Error(w, "Unauthorized: X-Retrieval-Password header required", http.StatusUnauthorized)
return
}
cfg, err := h.store.GetCustomerConfig(customerID)
if err != nil {
h.logger.Printf("[ERROR] Failed to get customer config for %s: %v", customerID, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
if cfg == nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
// Constant-time comparison to prevent timing attacks
if subtle.ConstantTimeCompare([]byte(password), []byte(cfg.RetrievalPassword)) != 1 {
http.Error(w, "Unauthorized: invalid password", http.StatusUnauthorized)
return
}
if h.templateProvider == nil {
http.Error(w, "Config generation not available", http.StatusServiceUnavailable)
return
}
yamlOutput, err := configgen.Generate(h.templateProvider.Template(), cfg)
if err != nil {
h.logger.Printf("[ERROR] Failed to generate config for %s: %v", customerID, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
h.logger.Printf("[INFO] Config downloaded for customer %s", customerID)
w.Header().Set("Content-Type", "text/yaml; charset=utf-8")
w.Write([]byte(yamlOutput))
}
// sendResendEmail sends an email via the Resend HTTP API.
func (h *Handler) sendResendEmail(to, subject, textBody string) error {
payload := map[string]interface{}{