Files
felhom.eu/hub/internal/api/handler.go
T
admin 36a7d1c162 feat: add controller update trigger + version checker (v0.1.8)
Hub now tracks controller_url from reports, periodically checks the Gitea
registry for the latest controller image version, and shows a "Trigger Update"
button on the customer detail page that proxies to the controller's self-update
API endpoint using the shared API key.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 18:16:38 +01:00

471 lines
15 KiB
Go

package api
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// 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
}
// New creates a new API handler.
func New(store *store.Store, apiKey, resendAPIKey, fromEmail string, logger *log.Logger) *Handler {
return &Handler{
store: store,
apiKey: apiKey,
resendAPIKey: resendAPIKey,
fromEmail: fromEmail,
logger: logger,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
// 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 == "/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)
}
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
}
}
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
}
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
}
h.logger.Printf("[INFO] Received report from %s (%d bytes)", payload.CustomerID, len(body))
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
}
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) {
// 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
}
}
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)
// 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")
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")
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")
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())
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", "")
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) {
// 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
}
}
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"`
}
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); 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.apiKey != "" {
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") != h.apiKey {
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.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 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)
}
// 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",
"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
}