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:
+107
-46
@@ -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{}{
|
||||
|
||||
Reference in New Issue
Block a user