a757bee07a
- store/telemetry.go: new app_telemetry + app_log_issues tables with
SaveAppTelemetry, GetFleetAppSummary (with P95), GetAppTelemetryHistory,
GetAppCustomerBreakdown, GetCustomerAppSummary, GetAppIssues, prune methods
- api/handler.go: parse and save optional app_telemetry from report body,
backward-compatible with old controllers
- cmd/hub/main.go: prune app_telemetry (90d) and stale issues (30d)
- web/apps.go: handleApps + handleAppDetail + chart data aggregation helpers
- web/server.go: routes for /apps, /apps/{name}, /static/chart.min.js;
added memoryColor/accuracyClass/gt template functions
- web/embed.go: embed static/chart.min.js
- web/configs.go: add app telemetry section to handleCustomerUnified
- templates/apps.html: fleet-wide app list with summary cards and sortable table
- templates/app_detail.html: per-app page with Chart.js memory trend,
customer breakdown, and known issues table
- templates/customer_unified.html: new Alkalmazás telemetria card
- templates/style.css: badge, summary-card, chart, period-selector,
accuracy-dot, mem-color, data-table styles
- All templates: added Alkalmazások nav link
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
817 lines
27 KiB
Go
817 lines
27 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/subtle"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-hub/internal/assets"
|
|
"gitea.dooplex.hu/admin/felhom-hub/internal/configgen"
|
|
"gitea.dooplex.hu/admin/felhom-hub/internal/notify"
|
|
"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
|
|
templateProvider ConfigTemplateProvider
|
|
dispatcher *notify.Dispatcher
|
|
assetsMgr *assets.Manager
|
|
}
|
|
|
|
// New creates a new API 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},
|
|
templateProvider: templateProvider,
|
|
}
|
|
}
|
|
|
|
// SetDispatcher sets the notification dispatcher for event-triggered emails.
|
|
func (h *Handler) SetDispatcher(d *notify.Dispatcher) {
|
|
h.dispatcher = d
|
|
}
|
|
|
|
// SetAssetManager sets the asset manager for serving app assets to controllers.
|
|
func (h *Handler) SetAssetManager(am *assets.Manager) {
|
|
h.assetsMgr = am
|
|
}
|
|
|
|
// 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 {
|
|
_, _, ok := h.checkAuthCustomer(r)
|
|
return ok
|
|
}
|
|
|
|
// checkAuthCustomer verifies the Bearer token and returns the authenticated customer identity.
|
|
// For per-customer keys: returns (customerID, false, true).
|
|
// For global key: returns ("", true, true) — caller must allow any customer_id.
|
|
// On failure: returns ("", false, false).
|
|
func (h *Handler) checkAuthCustomer(r *http.Request) (customerID string, isGlobal bool, ok bool) {
|
|
auth := r.Header.Get("Authorization")
|
|
if !strings.HasPrefix(auth, "Bearer ") {
|
|
return "", false, false
|
|
}
|
|
token := strings.TrimPrefix(auth, "Bearer ")
|
|
|
|
// Check global key first
|
|
if h.apiKey != "" && subtle.ConstantTimeCompare([]byte(token), []byte(h.apiKey)) == 1 {
|
|
return "", true, true
|
|
}
|
|
|
|
// Check per-customer key
|
|
cfg, err := h.store.GetCustomerConfigByAPIKey(token)
|
|
if err != nil || cfg == nil {
|
|
return "", false, false
|
|
}
|
|
return cfg.CustomerID, false, true
|
|
}
|
|
|
|
// ServeHTTP routes API requests.
|
|
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
path := strings.TrimPrefix(r.URL.Path, "/api/v1")
|
|
|
|
switch {
|
|
case r.Method == http.MethodPost && path == "/report":
|
|
h.handleReport(w, r)
|
|
case r.Method == http.MethodPost && path == "/event":
|
|
h.handleEvent(w, r)
|
|
case r.Method == http.MethodPost && path == "/notify":
|
|
h.handleNotify(w, r)
|
|
case r.Method == http.MethodPost && path == "/infra-backup":
|
|
h.handleInfraBackupPush(w, r)
|
|
case r.Method == http.MethodGet && strings.HasPrefix(path, "/infra-backup/"):
|
|
h.handleInfraBackupGet(w, r, strings.TrimPrefix(path, "/infra-backup/"))
|
|
case r.Method == http.MethodPost && path == "/preferences":
|
|
h.handleSavePreferences(w, r)
|
|
case r.Method == http.MethodGet && path == "/customers":
|
|
h.handleCustomers(w, r)
|
|
case r.Method == http.MethodGet && strings.HasPrefix(path, "/customers/"):
|
|
parts := strings.Split(strings.TrimPrefix(path, "/customers/"), "/")
|
|
customerID := parts[0]
|
|
if len(parts) > 1 && parts[1] == "history" {
|
|
h.handleCustomerHistory(w, r, customerID)
|
|
} else {
|
|
h.handleCustomer(w, r, customerID)
|
|
}
|
|
case r.Method == http.MethodGet && strings.HasPrefix(path, "/recovery/"):
|
|
customerID := strings.TrimPrefix(path, "/recovery/")
|
|
h.handleRecovery(w, r, customerID)
|
|
case r.Method == http.MethodGet && strings.HasPrefix(path, "/config/"):
|
|
customerID := strings.TrimPrefix(path, "/config/")
|
|
h.handleConfigRetrieve(w, r, customerID)
|
|
case r.Method == http.MethodGet && path == "/assets/manifest":
|
|
h.handleAssetsManifest(w, r)
|
|
case r.Method == http.MethodGet && strings.HasPrefix(path, "/assets/file/"):
|
|
filename := strings.TrimPrefix(path, "/assets/file/")
|
|
h.handleAssetFile(w, r, filename)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}
|
|
|
|
func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) {
|
|
authCustomerID, isGlobal, ok := h.checkAuthCustomer(r)
|
|
if !ok {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit
|
|
if err != nil {
|
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Extract customer_id from JSON
|
|
var payload struct {
|
|
CustomerID string `json:"customer_id"`
|
|
}
|
|
if err := json.Unmarshal(body, &payload); err != nil || payload.CustomerID == "" {
|
|
http.Error(w, "Invalid payload: customer_id required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Validate customer_id matches authenticated customer (unless global key)
|
|
if !isGlobal && authCustomerID != payload.CustomerID {
|
|
http.Error(w, "Forbidden: customer_id mismatch", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
if err := h.store.SaveReport(payload.CustomerID, body); err != nil {
|
|
h.logger.Printf("[ERROR] Failed to save report from %s: %v", payload.CustomerID, err)
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Parse and save app telemetry (backward-compatible — old controllers won't have this field)
|
|
var telemetryPayload struct {
|
|
AppTelemetry []store.AppTelemetryRecord `json:"app_telemetry"`
|
|
}
|
|
if err := json.Unmarshal(body, &telemetryPayload); err == nil && len(telemetryPayload.AppTelemetry) > 0 {
|
|
if err := h.store.SaveAppTelemetry(payload.CustomerID, time.Now(), telemetryPayload.AppTelemetry); err != nil {
|
|
h.logger.Printf("[WARN] Failed to save app telemetry for %s: %v", payload.CustomerID, err)
|
|
}
|
|
}
|
|
|
|
h.logger.Printf("[INFO] Received report from %s (%d bytes)", payload.CustomerID, len(body))
|
|
|
|
// Build response with optional customer_blocked flag
|
|
resp := map[string]interface{}{"status": "ok"}
|
|
if custCfg, err := h.store.GetCustomerConfig(payload.CustomerID); err == nil && custCfg != nil {
|
|
if custCfg.Status == "blocked" {
|
|
resp["customer_blocked"] = true
|
|
}
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
// allowedEventTypes lists all valid event_type values the Hub accepts.
|
|
var allowedEventTypes = map[string]bool{
|
|
// Controller-pushed events
|
|
"controller_started": true,
|
|
"controller_updated": true,
|
|
"backup_completed": true,
|
|
"backup_failed": true,
|
|
"db_dump_completed": true,
|
|
"db_dump_failed": true,
|
|
"backup_integrity_ok": true,
|
|
"backup_integrity_failed": true,
|
|
"crossdrive_completed": true,
|
|
"crossdrive_failed": true,
|
|
"storage_disconnected": true,
|
|
"storage_reconnected": true,
|
|
"disk_warning": true,
|
|
"disk_critical": true,
|
|
"health_degraded": true,
|
|
"health_critical": true,
|
|
"health_recovered": true,
|
|
"app_deployed": true,
|
|
"app_removed": true,
|
|
"disaster_recovery_started": true,
|
|
"disaster_recovery_completed": true,
|
|
// Hub-generated events
|
|
"node_stale": true,
|
|
"node_down": true,
|
|
"node_recovered": true,
|
|
"expected_backup_missed": true,
|
|
"expected_dbdump_missed": true,
|
|
// Special
|
|
"test": true,
|
|
}
|
|
|
|
// handleEvent processes structured events from controllers (new endpoint, replaces /notify for updated controllers).
|
|
func (h *Handler) handleEvent(w http.ResponseWriter, r *http.Request) {
|
|
authCustomerID, isGlobal, ok := h.checkAuthCustomer(r)
|
|
if !ok {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
|
if err != nil {
|
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var payload struct {
|
|
CustomerID string `json:"customer_id"`
|
|
EventType string `json:"event_type"`
|
|
Severity string `json:"severity"`
|
|
Message string `json:"message"`
|
|
Details json.RawMessage `json:"details"`
|
|
}
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if payload.CustomerID == "" || payload.EventType == "" {
|
|
http.Error(w, "customer_id and event_type are required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Validate customer_id matches authenticated customer (unless global key)
|
|
if !isGlobal && authCustomerID != payload.CustomerID {
|
|
http.Error(w, "Forbidden: customer_id mismatch", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
// Validate event_type
|
|
if !allowedEventTypes[payload.EventType] {
|
|
http.Error(w, fmt.Sprintf("Invalid event_type: %s", payload.EventType), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Validate/default severity
|
|
switch payload.Severity {
|
|
case "info", "warning", "error":
|
|
default:
|
|
payload.Severity = "info"
|
|
}
|
|
|
|
// Store details as JSON string
|
|
detailsStr := "{}"
|
|
if len(payload.Details) > 0 && string(payload.Details) != "null" {
|
|
detailsStr = string(payload.Details)
|
|
}
|
|
|
|
_, err = h.store.SaveEvent(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, detailsStr, "controller")
|
|
if err != nil {
|
|
h.logger.Printf("[ERROR] Failed to save event from %s: %v", payload.CustomerID, err)
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
h.logger.Printf("[INFO] Event from %s: %s (%s) — %s", payload.CustomerID, payload.EventType, payload.Severity, payload.Message)
|
|
|
|
// Dispatch notifications (non-blocking)
|
|
if h.dispatcher != nil {
|
|
go h.dispatcher.ProcessEvent(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, detailsStr, "controller")
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"ok":true}`))
|
|
}
|
|
|
|
func (h *Handler) handleCustomers(w http.ResponseWriter, r *http.Request) {
|
|
customers, err := h.store.GetCustomers()
|
|
if err != nil {
|
|
h.logger.Printf("[ERROR] Failed to get customers: %v", err)
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
type customerJSON struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
ControllerVersion string `json:"controller_version"`
|
|
ControllerURL string `json:"controller_url,omitempty"`
|
|
HealthStatus string `json:"health_status"`
|
|
LastSeen time.Time `json:"last_seen"`
|
|
CPUPercent float64 `json:"cpu_percent"`
|
|
MemoryPercent float64 `json:"memory_percent"`
|
|
ContainerTotal int `json:"container_total"`
|
|
ContainerRunning int `json:"container_running"`
|
|
BackupLastSnapshot *time.Time `json:"backup_last_snapshot"`
|
|
}
|
|
|
|
result := make([]customerJSON, 0, len(customers))
|
|
for _, c := range customers {
|
|
result = append(result, customerJSON{
|
|
ID: c.CustomerID,
|
|
Name: c.CustomerName,
|
|
ControllerVersion: c.ControllerVersion,
|
|
ControllerURL: c.ControllerURL,
|
|
HealthStatus: c.HealthStatus,
|
|
LastSeen: c.ReceivedAt,
|
|
CPUPercent: c.CPUPercent,
|
|
MemoryPercent: c.MemoryPercent,
|
|
ContainerTotal: c.ContainerTotal,
|
|
ContainerRunning: c.ContainerRunning,
|
|
BackupLastSnapshot: c.BackupLastSnapshot,
|
|
})
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(result)
|
|
}
|
|
|
|
func (h *Handler) handleCustomer(w http.ResponseWriter, r *http.Request, customerID string) {
|
|
customer, err := h.store.GetCustomer(customerID)
|
|
if err != nil {
|
|
h.logger.Printf("[ERROR] Failed to get customer %s: %v", customerID, err)
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if customer == nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
// Return the full report JSON directly
|
|
w.Write([]byte(customer.ReportJSON))
|
|
}
|
|
|
|
func (h *Handler) handleCustomerHistory(w http.ResponseWriter, r *http.Request, customerID string) {
|
|
period := r.URL.Query().Get("period")
|
|
var since time.Duration
|
|
switch period {
|
|
case "7d":
|
|
since = 7 * 24 * time.Hour
|
|
case "30d":
|
|
since = 30 * 24 * time.Hour
|
|
default:
|
|
since = 24 * time.Hour
|
|
}
|
|
|
|
history, err := h.store.GetCustomerHistory(customerID, since)
|
|
if err != nil {
|
|
h.logger.Printf("[ERROR] Failed to get history for %s: %v", customerID, err)
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
type historyEntry struct {
|
|
ReceivedAt time.Time `json:"received_at"`
|
|
HealthStatus string `json:"health_status"`
|
|
CPUPercent float64 `json:"cpu_percent"`
|
|
MemoryPercent float64 `json:"memory_percent"`
|
|
}
|
|
|
|
result := make([]historyEntry, 0, len(history))
|
|
for _, h := range history {
|
|
result = append(result, historyEntry{
|
|
ReceivedAt: h.ReceivedAt,
|
|
HealthStatus: h.HealthStatus,
|
|
CPUPercent: h.CPUPercent,
|
|
MemoryPercent: h.MemoryPercent,
|
|
})
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(result)
|
|
}
|
|
|
|
// handleNotify processes notification events from customer controllers.
|
|
func (h *Handler) handleNotify(w http.ResponseWriter, r *http.Request) {
|
|
if !h.checkAuth(r) {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
|
if err != nil {
|
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var payload struct {
|
|
CustomerID string `json:"customer_id"`
|
|
EventType string `json:"event_type"`
|
|
Severity string `json:"severity"`
|
|
Message string `json:"message"`
|
|
Details string `json:"details"`
|
|
}
|
|
if err := json.Unmarshal(body, &payload); err != nil || payload.CustomerID == "" || payload.EventType == "" {
|
|
http.Error(w, "Invalid payload: customer_id and event_type required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
h.logger.Printf("[INFO] Notification from %s: %s (%s) — %s", payload.CustomerID, payload.EventType, payload.Severity, payload.Message)
|
|
|
|
// Check if customer is blocked
|
|
if h.store.IsCustomerBlocked(payload.CustomerID) {
|
|
h.logger.Printf("[INFO] Notification suppressed for blocked customer %s", payload.CustomerID)
|
|
h.store.LogNotification(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, "skipped", "customer blocked", "customer")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"status":"ok","sent":false,"reason":"blocked"}`))
|
|
return
|
|
}
|
|
|
|
// Look up customer notification preferences
|
|
prefs, err := h.store.GetNotificationPrefs(payload.CustomerID)
|
|
if err != nil {
|
|
h.logger.Printf("[ERROR] Failed to get notification prefs for %s: %v", payload.CustomerID, err)
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Check if customer has email configured and event type is enabled
|
|
if prefs == nil || prefs.Email == "" {
|
|
h.logger.Printf("[INFO] No email configured for %s, skipping notification", payload.CustomerID)
|
|
h.store.LogNotification(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, "skipped", "no email configured", "customer")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"status":"ok","sent":false,"reason":"no_email"}`))
|
|
return
|
|
}
|
|
|
|
// Check if event type is in the enabled list (test events always pass)
|
|
eventEnabled := payload.EventType == "test"
|
|
for _, e := range prefs.EnabledEvents {
|
|
if e == payload.EventType {
|
|
eventEnabled = true
|
|
break
|
|
}
|
|
}
|
|
if !eventEnabled {
|
|
h.logger.Printf("[INFO] Event %s not enabled for %s, skipping", payload.EventType, payload.CustomerID)
|
|
h.store.LogNotification(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, "skipped", "event not enabled", "customer")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"status":"ok","sent":false,"reason":"event_disabled"}`))
|
|
return
|
|
}
|
|
|
|
// Send email via Resend API
|
|
if h.resendAPIKey == "" {
|
|
h.logger.Printf("[WARN] Resend API key not configured, cannot send notification email")
|
|
h.store.LogNotification(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, "skipped", "resend api key not configured", "customer")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"status":"ok","sent":false,"reason":"no_api_key"}`))
|
|
return
|
|
}
|
|
|
|
subject, emailBody := formatNotificationEmail(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, payload.Details)
|
|
sendErr := h.sendResendEmail(prefs.Email, subject, emailBody)
|
|
if sendErr != nil {
|
|
h.logger.Printf("[ERROR] Failed to send notification email to %s: %v", prefs.Email, sendErr)
|
|
h.store.LogNotification(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, "failed", sendErr.Error(), "customer")
|
|
http.Error(w, "Failed to send email", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
h.logger.Printf("[INFO] Notification email sent to %s for %s/%s", prefs.Email, payload.CustomerID, payload.EventType)
|
|
h.store.LogNotification(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, "sent", "", "customer")
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"status":"ok","sent":true}`))
|
|
}
|
|
|
|
// handleSavePreferences stores notification preferences pushed from a customer controller.
|
|
func (h *Handler) handleSavePreferences(w http.ResponseWriter, r *http.Request) {
|
|
if !h.checkAuth(r) {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
|
if err != nil {
|
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var payload struct {
|
|
CustomerID string `json:"customer_id"`
|
|
Email string `json:"email"`
|
|
EnabledEvents []string `json:"enabled_events"`
|
|
CooldownHours int `json:"cooldown_hours"`
|
|
}
|
|
if err := json.Unmarshal(body, &payload); err != nil || payload.CustomerID == "" {
|
|
http.Error(w, "Invalid payload: customer_id required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := h.store.SaveNotificationPrefs(payload.CustomerID, payload.Email, payload.EnabledEvents, payload.CooldownHours); err != nil {
|
|
h.logger.Printf("[ERROR] Failed to save notification prefs for %s: %v", payload.CustomerID, err)
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
h.logger.Printf("[INFO] Notification preferences updated for %s: email=%s, events=%v", payload.CustomerID, payload.Email, payload.EnabledEvents)
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"status":"ok"}`))
|
|
}
|
|
|
|
// handleInfraBackupPush stores an infrastructure snapshot from a controller.
|
|
func (h *Handler) handleInfraBackupPush(w http.ResponseWriter, r *http.Request) {
|
|
if !h.checkAuth(r) {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit
|
|
if err != nil {
|
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var payload struct {
|
|
CustomerID string `json:"customer_id"`
|
|
}
|
|
if err := json.Unmarshal(body, &payload); err != nil || payload.CustomerID == "" {
|
|
http.Error(w, "Invalid payload: customer_id required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := h.store.SaveInfraBackup(payload.CustomerID, body); err != nil {
|
|
h.logger.Printf("[ERROR] Failed to save infra backup for %s: %v", payload.CustomerID, err)
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
h.logger.Printf("[INFO] Infra backup saved for %s (%d bytes)", payload.CustomerID, len(body))
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(`{"status":"ok"}`))
|
|
}
|
|
|
|
// handleInfraBackupGet returns the infrastructure backup for a customer.
|
|
func (h *Handler) handleInfraBackupGet(w http.ResponseWriter, r *http.Request, customerID string) {
|
|
if !h.checkAuth(r) {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
if customerID == "" {
|
|
http.Error(w, "Missing customer_id", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
data, err := h.store.GetInfraBackup(customerID)
|
|
if err != nil {
|
|
h.logger.Printf("[ERROR] Failed to get infra backup for %s: %v", customerID, err)
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if data == nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Write(data)
|
|
}
|
|
|
|
// handleRecovery returns both the generated controller.yaml and the infra backup for disaster recovery.
|
|
// Auth: X-Retrieval-Password header (same as config retrieval).
|
|
func (h *Handler) handleRecovery(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] Recovery: 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
|
|
}
|
|
|
|
if subtle.ConstantTimeCompare([]byte(password), []byte(cfg.RetrievalPassword)) != 1 {
|
|
http.Error(w, "Unauthorized: invalid password", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
// Generate controller.yaml
|
|
var configYAML string
|
|
if h.templateProvider != nil {
|
|
yamlOutput, err := configgen.Generate(h.templateProvider.Template(), cfg)
|
|
if err != nil {
|
|
h.logger.Printf("[ERROR] Recovery: failed to generate config for %s: %v", customerID, err)
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
configYAML = yamlOutput
|
|
}
|
|
|
|
// Fetch infra backup (optional — may not exist for new customers)
|
|
var infraBackup json.RawMessage
|
|
hasInfraBackup := false
|
|
if data, err := h.store.GetInfraBackup(customerID); err == nil && data != nil {
|
|
infraBackup = data
|
|
hasInfraBackup = true
|
|
}
|
|
|
|
resp := struct {
|
|
CustomerID string `json:"customer_id"`
|
|
ConfigYAML string `json:"config_yaml"`
|
|
InfraBackup json.RawMessage `json:"infra_backup"`
|
|
HasInfraBackup bool `json:"has_infra_backup"`
|
|
}{
|
|
CustomerID: customerID,
|
|
ConfigYAML: configYAML,
|
|
InfraBackup: infraBackup,
|
|
HasInfraBackup: hasInfraBackup,
|
|
}
|
|
|
|
h.logger.Printf("[INFO] Recovery data downloaded for customer %s (has_infra_backup=%v)", customerID, hasInfraBackup)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
// 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{}{
|
|
"from": h.fromEmail,
|
|
"to": []string{to},
|
|
"subject": subject,
|
|
"text": textBody,
|
|
}
|
|
|
|
jsonData, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return fmt.Errorf("marshaling email payload: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequest("POST", "https://api.resend.com/emails", bytes.NewReader(jsonData))
|
|
if err != nil {
|
|
return fmt.Errorf("creating request: %w", err)
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+h.resendAPIKey)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := h.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("sending request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
|
return fmt.Errorf("resend API returned %d: %s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// formatNotificationEmail creates a Hungarian email subject and body.
|
|
func formatNotificationEmail(customerID, eventType, severity, message, details string) (string, string) {
|
|
severityLabel := map[string]string{
|
|
"info": "Információ",
|
|
"warning": "Figyelmeztetés",
|
|
"error": "Hiba",
|
|
"critical": "Kritikus",
|
|
}
|
|
label := severityLabel[severity]
|
|
if label == "" {
|
|
label = severity
|
|
}
|
|
|
|
subject := fmt.Sprintf("[Felhom] %s: %s", label, message)
|
|
|
|
now := time.Now().Format("2006-01-02 15:04")
|
|
emailText := fmt.Sprintf(`Kedves Ügyfél!
|
|
|
|
A Felhom rendszered a következő figyelmeztetést jelezte:
|
|
|
|
%s
|
|
|
|
Részletek:
|
|
- Szerver: %s
|
|
- Időpont: %s
|
|
- Szint: %s
|
|
- Típus: %s`, message, customerID, now, label, eventType)
|
|
|
|
if details != "" {
|
|
emailText += fmt.Sprintf("\n- Megjegyzés: %s", details)
|
|
}
|
|
|
|
emailText += `
|
|
|
|
Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel.
|
|
|
|
Üdvözlettel,
|
|
Felhom.eu monitoring`
|
|
|
|
return subject, emailText
|
|
}
|
|
|
|
// --- Asset endpoints ---
|
|
|
|
func (h *Handler) handleAssetsManifest(w http.ResponseWriter, r *http.Request) {
|
|
if !h.checkAuth(r) {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
if h.assetsMgr == nil {
|
|
http.Error(w, "Assets not configured", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
data, err := h.assetsMgr.MarshalManifestJSON()
|
|
if err != nil {
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Write(data)
|
|
}
|
|
|
|
func (h *Handler) handleAssetFile(w http.ResponseWriter, r *http.Request, filename string) {
|
|
if !h.checkAuth(r) {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
if h.assetsMgr == nil {
|
|
http.Error(w, "Assets not configured", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
|
|
h.assetsMgr.ServeFile(w, r, filename)
|
|
}
|