3457415117
Recovery-mode toggle (global key, bounded auto-expiry) gates re-enroll + restore-directive serving. Re-enroll rotates the agent<->hub credential to the new box (old key revoked); returns the opaque escrow blobs + non-secret directive. Store gains recovery_mode_until + identity_blob + directive_json. Hub holds no usable secret + no Cloudflare write-power (operator-side rotation). Doc 03 §9: slice 10 CLOSED. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1581 lines
59 KiB
Go
1581 lines
59 KiB
Go
package api
|
||
|
||
import (
|
||
"bytes"
|
||
"crypto/subtle"
|
||
"database/sql"
|
||
"encoding/base64"
|
||
"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
|
||
}
|
||
|
||
// checkAuthHost resolves a Bearer token to a HOST identity (the agent's auth
|
||
// path). It is a sibling of checkAuthCustomer — the controller path is unchanged.
|
||
// - global key -> ("", "", true, true) caller trusts body.host_id
|
||
// - per-host key -> (hostID, customerID, false, true)
|
||
// - failure -> ("", "", false, false)
|
||
func (h *Handler) checkAuthHost(r *http.Request) (hostID, customerID string, isGlobal, ok bool) {
|
||
auth := r.Header.Get("Authorization")
|
||
if !strings.HasPrefix(auth, "Bearer ") {
|
||
return "", "", false, false
|
||
}
|
||
token := strings.TrimPrefix(auth, "Bearer ")
|
||
|
||
// Global key first (same constant-time compare as checkAuthCustomer).
|
||
if h.apiKey != "" && subtle.ConstantTimeCompare([]byte(token), []byte(h.apiKey)) == 1 {
|
||
return "", "", true, true
|
||
}
|
||
|
||
host, err := h.store.GetHostByAPIKey(token)
|
||
if err != nil || host == nil {
|
||
return "", "", false, false
|
||
}
|
||
return host.HostID, host.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 == "/host-report":
|
||
h.handleHostReport(w, r)
|
||
case r.Method == http.MethodPost && path == "/admin/hosts":
|
||
h.handleAdminCreateHost(w, r)
|
||
case r.Method == http.MethodPut && strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/escrow"):
|
||
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/escrow")
|
||
h.handleHostEscrowPut(w, r, hostID)
|
||
// DR capstone (slice 10D). Recovery-mode toggle (global key); re-enroll + restore-directive
|
||
// (gated on recovery mode — no old key needed, the box is lost).
|
||
case r.Method == http.MethodPut && strings.HasPrefix(path, "/admin/hosts/") && strings.HasSuffix(path, "/recovery-mode"):
|
||
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/admin/hosts/"), "/recovery-mode")
|
||
h.handleSetRecoveryMode(w, r, hostID)
|
||
case r.Method == http.MethodDelete && strings.HasPrefix(path, "/admin/hosts/") && strings.HasSuffix(path, "/recovery-mode"):
|
||
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/admin/hosts/"), "/recovery-mode")
|
||
h.handleClearRecoveryMode(w, r, hostID)
|
||
case r.Method == http.MethodPost && strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/re-enroll"):
|
||
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/re-enroll")
|
||
h.handleReEnroll(w, r, hostID)
|
||
case r.Method == http.MethodGet && strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/restore-directive"):
|
||
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/restore-directive")
|
||
h.handleGetRestoreDirective(w, r, hostID)
|
||
// Desired-state serving (slice 10A) — per-host-key, self-scoped (a host reads only its own).
|
||
case r.Method == http.MethodGet && strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/desired-state"):
|
||
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/desired-state")
|
||
h.handleGetDesiredState(w, r, hostID)
|
||
case r.Method == http.MethodGet && strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/jobs"):
|
||
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/jobs")
|
||
h.handleGetJobs(w, r, hostID)
|
||
// Job completion (slice 10B) — per-host-key, self-scoped: DELETE /hosts/{id}/jobs/{job_id}.
|
||
case r.Method == http.MethodDelete && strings.HasPrefix(path, "/hosts/") && strings.Contains(path, "/jobs/"):
|
||
rest := strings.TrimPrefix(path, "/hosts/")
|
||
if i := strings.Index(rest, "/jobs/"); i > 0 {
|
||
h.handleDeleteJob(w, r, rest[:i], rest[i+len("/jobs/"):])
|
||
} else {
|
||
http.NotFound(w, r)
|
||
}
|
||
// Admin-set (slice 10A) — global/operator key only; bumps the generation.
|
||
case r.Method == http.MethodPut && strings.HasPrefix(path, "/admin/hosts/") && strings.HasSuffix(path, "/desired-state"):
|
||
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/admin/hosts/"), "/desired-state")
|
||
h.handleAdminSetDesiredState(w, r, hostID)
|
||
case r.Method == http.MethodPost && strings.HasPrefix(path, "/admin/hosts/") && strings.HasSuffix(path, "/jobs"):
|
||
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/admin/hosts/"), "/jobs")
|
||
h.handleAdminEnqueueJob(w, r, hostID)
|
||
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/") && strings.HasSuffix(path, "/versions"):
|
||
customerID := strings.TrimPrefix(path, "/infra-backup/")
|
||
customerID = strings.TrimSuffix(customerID, "/versions")
|
||
h.handleInfraBackupVersions(w, r, customerID)
|
||
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)
|
||
}
|
||
|
||
// defaultHostPollSeconds is the cadence the hub hands every agent this slice (no
|
||
// per-host override UI yet — that is a later slice).
|
||
const defaultHostPollSeconds = 900
|
||
|
||
// maxHostReportBytes bounds a host-report body. Larger than the controller path's
|
||
// 1 MiB because host reports carry the full guest list + (later) storage/backup
|
||
// arrays. We read one byte past it and reject explicitly (413) rather than letting
|
||
// LimitReader silently truncate — a truncated-but-valid JSON would otherwise be
|
||
// accepted as a partial report, dropping guests from the mirror.
|
||
const maxHostReportBytes = 4 << 20 // 4 MiB
|
||
|
||
// hostReportPayload is the subset of the agent host-report (slice-3 contract,
|
||
// §3 / agent spec §4) the hub needs for denorm + guest reality. The remaining fields
|
||
// (backups/restore_tests/pbs_snapshots/audit_tail) are ignored, so an empty or absent
|
||
// collection is accepted without error.
|
||
//
|
||
// storage_targets (slice 5) is now parsed: the agent populates it, and the hub accepts
|
||
// + persists it. Persistence is the full report_json row (which carries the targets
|
||
// verbatim) plus the denorm counts below — the RICH manifest schema (desired class/role/
|
||
// policy/creds) is hub-owned and lands in slice 10; this slice only mirrors what the agent
|
||
// observes.
|
||
type hostReportPayload struct {
|
||
HostID string `json:"host_id"`
|
||
AgentVersion string `json:"agent_version"`
|
||
Host struct {
|
||
CPUPercent float64 `json:"cpu_percent"`
|
||
MemoryPercent float64 `json:"memory_percent"`
|
||
DiskPercent float64 `json:"disk_percent"`
|
||
} `json:"host"`
|
||
Guests []struct {
|
||
VMID int `json:"vmid"`
|
||
Name string `json:"name"`
|
||
Status string `json:"status"`
|
||
ControllerVersion string `json:"controller_version"`
|
||
} `json:"guests"`
|
||
StorageTargets []hostStorageTarget `json:"storage_targets"`
|
||
Backups []hostBackup `json:"backups"` // slice 6
|
||
RestoreTests []hostRestoreTest `json:"restore_tests"` // slice 6
|
||
PBSSnapshots []hostPBSSnapshot `json:"pbs_snapshots"` // slice 6 Phase B
|
||
Cloudflared struct {
|
||
Status string `json:"status"`
|
||
} `json:"cloudflared"`
|
||
}
|
||
|
||
// hostPBSSnapshot mirrors the agent's hub.PBSSnapshot wire contract (slice 6 Phase B). The
|
||
// hub persists it via report_json and surfaces a FAILED verify prominently (the loudest
|
||
// offsite-DR signal — same treatment as a failed restore-test).
|
||
type hostPBSSnapshot struct {
|
||
Namespace string `json:"namespace"`
|
||
BackupType string `json:"backup_type"`
|
||
BackupID string `json:"backup_id"`
|
||
BackupTime string `json:"backup_time"`
|
||
SizeBytes int64 `json:"size_bytes"`
|
||
Owner string `json:"owner"`
|
||
Protected bool `json:"protected"`
|
||
Encrypted bool `json:"encrypted"`
|
||
VerifyState string `json:"verify_state"`
|
||
VerifyUPID string `json:"verify_upid,omitempty"`
|
||
}
|
||
|
||
// hostBackup / hostRestoreTest mirror the agent's hub.Backup / hub.RestoreTest wire
|
||
// contract field-for-field (slice 6, doc 03 §8). DUPLICATED contract — the golden stays
|
||
// byte-identical with felhom-agent's copy and the key-set tests guard drift. The hub
|
||
// persists these via report_json (no new columns this slice) and surfaces a FAILED
|
||
// restore-test prominently (the loudest DR signal). The rich backup policy is slice 10.
|
||
type hostBackup struct {
|
||
TargetID string `json:"target_id"`
|
||
VMID int `json:"vmid"`
|
||
Archive string `json:"archive"`
|
||
Mode string `json:"mode"`
|
||
CrashConsistent bool `json:"crash_consistent"`
|
||
SizeBytes int64 `json:"size_bytes"`
|
||
Success bool `json:"success"`
|
||
Error string `json:"error,omitempty"`
|
||
StartedAt string `json:"started_at"`
|
||
DurationSeconds float64 `json:"duration_seconds"`
|
||
UncoveredVolumes []string `json:"uncovered_volumes"`
|
||
}
|
||
|
||
type hostRestoreTest struct {
|
||
SourceArchive string `json:"source_archive"`
|
||
SourceTier string `json:"source_tier"`
|
||
ScratchVMID int `json:"scratch_vmid"`
|
||
Pass bool `json:"pass"`
|
||
Verified string `json:"verified"`
|
||
Error string `json:"error,omitempty"`
|
||
TestedAt string `json:"tested_at"`
|
||
DurationSeconds float64 `json:"duration_seconds"`
|
||
// Warnings are the guest-start task's warning line(s) on a PASS (e.g. the systemd-nesting
|
||
// advisory). The verdict is liveness-only, so a passed restore-test can carry warnings.
|
||
Warnings []string `json:"warnings,omitempty"`
|
||
// WarningsRecognized is true iff every warning is the known-benign anchor. Absent ⇒ false,
|
||
// which is the SAFE default: the hub then treats it as an unrecognized warning (the louder
|
||
// path), so a missing flag can only over-notice, never hide a real warning.
|
||
WarningsRecognized bool `json:"warnings_recognized,omitempty"`
|
||
}
|
||
|
||
// hostStorageTarget mirrors the agent's hub.StorageTarget wire contract field-for-field.
|
||
// It is a DUPLICATED contract (no shared types module yet); testdata/host-report.golden.json
|
||
// must stay byte-identical with felhom-agent's copy and the key-set test guards drift.
|
||
// The hub does not act on these yet beyond persisting + counting them (slice 10 adds the
|
||
// authoritative manifest), but mirroring the full shape keeps the cross-repo contract honest.
|
||
type hostStorageTarget struct {
|
||
Name string `json:"name"`
|
||
Type string `json:"type"`
|
||
DurableID string `json:"durable_id"`
|
||
State string `json:"state"`
|
||
Reachable bool `json:"reachable"`
|
||
TotalBytes int64 `json:"total_bytes"`
|
||
UsedBytes int64 `json:"used_bytes"`
|
||
AvailBytes int64 `json:"avail_bytes"`
|
||
UsedFraction float64 `json:"used_fraction"`
|
||
Content string `json:"content"`
|
||
MountPath string `json:"mount_path"`
|
||
BackingDevice string `json:"backing_device"`
|
||
ClassHint string `json:"class_hint"`
|
||
Role string `json:"role"`
|
||
ThinPool *struct {
|
||
DataUsedFraction float64 `json:"data_used_fraction"`
|
||
MetadataUsedFraction *float64 `json:"metadata_used_fraction"`
|
||
} `json:"thin_pool,omitempty"`
|
||
Smart struct {
|
||
Health string `json:"health"`
|
||
TemperatureC *int `json:"temperature_c"`
|
||
PowerOnHours *int `json:"power_on_hours"`
|
||
ReallocatedSectors *int `json:"reallocated_sectors"`
|
||
PendingSectors *int `json:"pending_sectors"`
|
||
OfflineUncorrectable *int `json:"offline_uncorrectable"`
|
||
CriticalWarning *int `json:"critical_warning"`
|
||
MediaErrors *int `json:"media_errors"`
|
||
PercentageUsed *int `json:"percentage_used"`
|
||
} `json:"smart"`
|
||
}
|
||
|
||
// handleHostReport ingests the agent's host-report (the heartbeat) and returns the
|
||
// control envelope (agent spec §5).
|
||
func (h *Handler) handleHostReport(w http.ResponseWriter, r *http.Request) {
|
||
hostID, custID, isGlobal, ok := h.checkAuthHost(r)
|
||
if !ok {
|
||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
body, err := io.ReadAll(io.LimitReader(r.Body, maxHostReportBytes+1))
|
||
if err != nil {
|
||
http.Error(w, "Bad request", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if len(body) > maxHostReportBytes {
|
||
http.Error(w, "Payload too large", http.StatusRequestEntityTooLarge)
|
||
return
|
||
}
|
||
var rep hostReportPayload
|
||
if err := json.Unmarshal(body, &rep); err != nil || rep.HostID == "" {
|
||
http.Error(w, "Invalid payload: host_id required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
if isGlobal {
|
||
// Global-key bootstrap: trust body.host_id but require the host to exist
|
||
// (it must be minted first) and resolve its customer from the row.
|
||
host, err := h.store.GetHost(rep.HostID)
|
||
if err != nil {
|
||
h.logger.Printf("[ERROR] host lookup failed for %s: %v", rep.HostID, err)
|
||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if host == nil {
|
||
http.Error(w, "Unknown host_id (mint via /admin/hosts first)", http.StatusBadRequest)
|
||
return
|
||
}
|
||
hostID, custID = rep.HostID, host.CustomerID
|
||
} else if rep.HostID != hostID {
|
||
http.Error(w, "Forbidden: host_id mismatch", http.StatusForbidden)
|
||
return
|
||
}
|
||
|
||
running := 0
|
||
for _, g := range rep.Guests {
|
||
if g.Status == "running" {
|
||
running++
|
||
}
|
||
}
|
||
denorm := store.HostReportDenorm{
|
||
AgentVersion: rep.AgentVersion,
|
||
CPUPercent: rep.Host.CPUPercent,
|
||
MemoryPercent: rep.Host.MemoryPercent,
|
||
DiskPercent: rep.Host.DiskPercent,
|
||
GuestTotal: len(rep.Guests),
|
||
GuestRunning: running,
|
||
CloudflaredStatus: rep.Cloudflared.Status,
|
||
}
|
||
if err := h.store.SaveHostReport(hostID, custID, body, denorm); err != nil {
|
||
h.logger.Printf("[ERROR] Failed to save host-report from %s: %v", hostID, err)
|
||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
for _, g := range rep.Guests {
|
||
status := g.Status
|
||
if status == "" {
|
||
status = "unknown"
|
||
}
|
||
guest := &store.Guest{
|
||
GuestID: store.GuestID(hostID, g.VMID),
|
||
CustomerID: custID,
|
||
HostID: hostID,
|
||
VMID: g.VMID,
|
||
DisplayName: g.Name,
|
||
Status: status,
|
||
ControllerVersion: g.ControllerVersion,
|
||
}
|
||
if err := h.store.UpsertGuestFromReport(guest); err != nil {
|
||
// A guest upsert failure must not drop the whole report (liveness).
|
||
h.logger.Printf("[WARN] Failed to upsert guest %s: %v", guest.GuestID, err)
|
||
}
|
||
}
|
||
|
||
// storage_targets (slice 5): persisted as part of report_json above. Count + surface
|
||
// disconnected ones in the log (the slice-10 manifest will reconcile them; for now the
|
||
// signal is the visibility — a disconnected target is the storage analog of host-down).
|
||
disconnected := 0
|
||
for _, st := range rep.StorageTargets {
|
||
if st.State == "disconnected" {
|
||
disconnected++
|
||
}
|
||
}
|
||
if disconnected > 0 {
|
||
h.logger.Printf("[WARN] host %s reports %d disconnected storage target(s) of %d",
|
||
hostID, disconnected, len(rep.StorageTargets))
|
||
}
|
||
|
||
// restore_tests (slice 6): a FAILED self-restore-test is the loudest DR signal there is
|
||
// — surface it prominently. A PASS that carried start warnings (e.g. the systemd-nesting
|
||
// advisory) is surfaced too: INFO when every warning is recognized-benign, escalated to
|
||
// WARN when an UNRECOGNIZED warning stood out (as loud as a failed PBS verify is for
|
||
// backups), so a real restore warning can't hide behind a green pass. A backup whose
|
||
// vzdump failed is also worth a warning.
|
||
for _, rt := range rep.RestoreTests {
|
||
switch {
|
||
case !rt.Pass:
|
||
h.logger.Printf("[WARN] host %s restore-test FAILED: archive=%s tier=%s scratch=%d err=%q",
|
||
hostID, rt.SourceArchive, rt.SourceTier, rt.ScratchVMID, rt.Error)
|
||
case len(rt.Warnings) == 0:
|
||
// clean pass — nothing to surface here (counted in the summary line below).
|
||
case rt.WarningsRecognized:
|
||
h.logger.Printf("[INFO] host %s restore-test passed WITH WARNINGS (recognized): archive=%s tier=%s warnings=%v",
|
||
hostID, rt.SourceArchive, rt.SourceTier, rt.Warnings)
|
||
default:
|
||
h.logger.Printf("[WARN] host %s restore-test passed WITH UNRECOGNIZED WARNINGS: archive=%s tier=%s warnings=%v",
|
||
hostID, rt.SourceArchive, rt.SourceTier, rt.Warnings)
|
||
}
|
||
}
|
||
for _, bk := range rep.Backups {
|
||
if !bk.Success {
|
||
h.logger.Printf("[WARN] host %s backup FAILED: target=%s vmid=%d err=%q",
|
||
hostID, bk.TargetID, bk.VMID, bk.Error)
|
||
}
|
||
}
|
||
// pbs_snapshots (slice 6 Phase B): a FAILED PBS verify is the loudest offsite-DR signal.
|
||
for _, ps := range rep.PBSSnapshots {
|
||
if ps.VerifyState == "failed" {
|
||
h.logger.Printf("[WARN] host %s PBS verify FAILED: %s/%s ns=%s owner=%s",
|
||
hostID, ps.BackupType, ps.BackupID, ps.Namespace, ps.Owner)
|
||
}
|
||
}
|
||
|
||
h.logger.Printf("[INFO] host-report from %s (%d guests, %d storage targets, %d backups, %d restore-tests, %d pbs-snapshots, %d bytes)",
|
||
hostID, len(rep.Guests), len(rep.StorageTargets), len(rep.Backups), len(rep.RestoreTests), len(rep.PBSSnapshots), len(body))
|
||
|
||
blocked := false
|
||
if cc, err := h.store.GetCustomerConfig(custID); err == nil && cc != nil && cc.Status == "blocked" {
|
||
blocked = true
|
||
}
|
||
|
||
// Control envelope (slice 10A): the cheap change-notification. desired_generation is the
|
||
// host's current generation (the agent re-fetches the full desired-state only when it
|
||
// advances past its cached one); has_signed_ops flags a non-empty signed-jobs queue (the
|
||
// agent fetches/executes them in 10B). Both degrade safely to their slice-4 defaults on a
|
||
// store error — a heartbeat must never fail on the control channel.
|
||
var desiredGen int64
|
||
if host, err := h.store.GetHost(hostID); err == nil && host != nil {
|
||
desiredGen = host.DesiredGeneration
|
||
}
|
||
hasSignedOps := false
|
||
if n, err := h.store.CountSignedJobs(hostID); err == nil && n > 0 {
|
||
hasSignedOps = true
|
||
}
|
||
resp := map[string]interface{}{
|
||
"status": "ok",
|
||
"poll_interval_seconds": defaultHostPollSeconds,
|
||
"blocked": blocked,
|
||
"desired_generation": desiredGen,
|
||
"has_signed_ops": hasSignedOps,
|
||
}
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
json.NewEncoder(w).Encode(resp)
|
||
}
|
||
|
||
// handleAdminCreateHost mints a host identity (host_id + per-host api_key).
|
||
//
|
||
// PROVISIONAL (slice-3 bootstrap): global-key only, so the demo agent can
|
||
// authenticate before enrollment (slices 7–8) exists. Enrollment will mint host
|
||
// identity + pin signing keys; this endpoint should be removed/locked down then
|
||
// (tracked under doc 05 §11 auth-tightening at cutover).
|
||
func (h *Handler) handleAdminCreateHost(w http.ResponseWriter, r *http.Request) {
|
||
_, _, isGlobal, ok := h.checkAuthHost(r)
|
||
if !ok || !isGlobal {
|
||
http.Error(w, "Forbidden: global key required", http.StatusForbidden)
|
||
return
|
||
}
|
||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||
if err != nil {
|
||
http.Error(w, "Bad request", http.StatusBadRequest)
|
||
return
|
||
}
|
||
var req struct {
|
||
CustomerID string `json:"customer_id"`
|
||
HostID string `json:"host_id"`
|
||
DisplayName string `json:"display_name"`
|
||
}
|
||
if err := json.Unmarshal(body, &req); err != nil || req.CustomerID == "" {
|
||
http.Error(w, "Invalid payload: customer_id required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
cc, err := h.store.GetCustomerConfig(req.CustomerID)
|
||
if err != nil {
|
||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if cc == nil {
|
||
http.Error(w, "Unknown customer_id", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
hostID := req.HostID
|
||
if hostID == "" {
|
||
sfx, err := configgen.RandomHex(3) // 6 hex chars — human-legible for the demo
|
||
if err != nil {
|
||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
hostID = req.CustomerID + "-" + sfx
|
||
}
|
||
apiKey, err := configgen.RandomHex(32)
|
||
if err != nil {
|
||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if err := h.store.UpsertHost(&store.Host{HostID: hostID, CustomerID: req.CustomerID, APIKey: apiKey}); err != nil {
|
||
h.logger.Printf("[ERROR] Failed to mint host for %s: %v", req.CustomerID, err)
|
||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
h.logger.Printf("[INFO] provisional host mint: %s (customer %s)", hostID, req.CustomerID)
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusCreated)
|
||
json.NewEncoder(w).Encode(map[string]string{"host_id": hostID, "api_key": apiKey})
|
||
}
|
||
|
||
// escrowUploadRequest is the agent→hub wire shape for the OPAQUE PBS recovery-code escrow blob
|
||
// (slice 7, doc 03 §8a). It MUST stay in lockstep with the agent's emit struct
|
||
// (felhom-agent cmd/felhom-agent escrowUploadRequest). The hub stores the bytes and NEVER decrypts
|
||
// them (it has no recovery code).
|
||
type escrowUploadRequest struct {
|
||
BlobB64 string `json:"blob_b64"` // base64 of the opaque R-wrapped blob (ciphertext)
|
||
KeyFingerprint string `json:"key_fingerprint"` // for operator display only
|
||
Posture string `json:"posture"` // e.g. "zero_knowledge"
|
||
CreatedAt string `json:"created_at"` // RFC3339
|
||
// Slice 10D.1 — optional DR bundle, stored alongside the K-escrow (both opaque/non-secret).
|
||
IdentityBlobB64 string `json:"identity_blob_b64,omitempty"` // age-wrapped {tunnel_token, pbs_token}
|
||
DirectiveJSON json.RawMessage `json:"directive,omitempty"` // non-secret directive (pbs repo/ns, expected fp, tunnel id)
|
||
}
|
||
|
||
// handleHostEscrowPut stores a host's opaque escrow blob (doc 03 §8a). Authed with the PER-HOST key
|
||
// (a host may only write its own escrow; the global operator key is also accepted). The hub keeps
|
||
// the ciphertext and never opens it. Last-write-wins (rotation). No serving this slice (slice 10).
|
||
func (h *Handler) handleHostEscrowPut(w http.ResponseWriter, r *http.Request, pathHostID string) {
|
||
authHostID, _, isGlobal, ok := h.checkAuthHost(r)
|
||
if !ok {
|
||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
if pathHostID == "" {
|
||
http.Error(w, "Missing host_id", http.StatusBadRequest)
|
||
return
|
||
}
|
||
// A per-host key may only write ITS OWN escrow; the global key may write any.
|
||
if !isGlobal && authHostID != pathHostID {
|
||
http.Error(w, "Forbidden: host_id mismatch", http.StatusForbidden)
|
||
return
|
||
}
|
||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1 MB cap; the blob is ~hundreds of bytes
|
||
if err != nil {
|
||
http.Error(w, "Bad request", http.StatusBadRequest)
|
||
return
|
||
}
|
||
var req escrowUploadRequest
|
||
if err := json.Unmarshal(body, &req); err != nil || req.BlobB64 == "" {
|
||
http.Error(w, "Invalid payload: blob_b64 required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
blob, err := base64.StdEncoding.DecodeString(req.BlobB64)
|
||
if err != nil || len(blob) == 0 {
|
||
http.Error(w, "Invalid payload: blob_b64 not valid base64", http.StatusBadRequest)
|
||
return
|
||
}
|
||
createdAt := req.CreatedAt
|
||
if createdAt == "" {
|
||
createdAt = time.Now().UTC().Format(time.RFC3339)
|
||
}
|
||
// Store the OPAQUE bytes. No decrypt path exists — the hub cannot open this.
|
||
if err := h.store.SaveHostEscrow(pathHostID, blob, req.KeyFingerprint, req.Posture, createdAt); err != nil {
|
||
h.logger.Printf("[ERROR] Failed to store escrow for host %s: %v", pathHostID, err)
|
||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
// Slice 10D.1: optionally store the IDENTITY escrow blob + the non-secret DR directive alongside
|
||
// the K-escrow (both opaque / non-secret — no usable secret hub-side). Additive: a slice-7
|
||
// upload without these is unchanged.
|
||
if req.IdentityBlobB64 != "" {
|
||
idBlob, derr := base64.StdEncoding.DecodeString(req.IdentityBlobB64)
|
||
if derr != nil || len(idBlob) == 0 {
|
||
http.Error(w, "Invalid payload: identity_blob_b64 not valid base64", http.StatusBadRequest)
|
||
return
|
||
}
|
||
directive := req.DirectiveJSON
|
||
if len(directive) == 0 || !json.Valid(directive) {
|
||
directive = json.RawMessage("{}")
|
||
}
|
||
if err := h.store.SaveHostDRBundle(pathHostID, idBlob, string(directive)); err != nil {
|
||
h.logger.Printf("[ERROR] Failed to store DR bundle for host %s: %v", pathHostID, err)
|
||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
h.logger.Printf("[INFO] stored DR bundle for host %s (identity %d bytes + directive)", pathHostID, len(idBlob))
|
||
}
|
||
h.logger.Printf("[INFO] stored opaque escrow blob for host %s (%d bytes, posture=%s, fp=%s)",
|
||
pathHostID, len(blob), req.Posture, req.KeyFingerprint)
|
||
w.WriteHeader(http.StatusOK)
|
||
w.Write([]byte(`{"status":"ok"}`))
|
||
}
|
||
|
||
// handleGetDesiredState serves a host its authoritative desired-state (slice 10A). Per-host key,
|
||
// SELF-SCOPED: a host reads ONLY its own (the global operator key may read any). The agent fetches
|
||
// this when the heartbeat envelope's desired_generation has advanced past its cached one. The
|
||
// response carries the generation the state corresponds to, so the agent caches it atomically.
|
||
func (h *Handler) handleGetDesiredState(w http.ResponseWriter, r *http.Request, pathHostID string) {
|
||
authHostID, _, isGlobal, ok := h.checkAuthHost(r)
|
||
if !ok {
|
||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
if pathHostID == "" {
|
||
http.Error(w, "Missing host_id", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if !isGlobal && authHostID != pathHostID {
|
||
http.Error(w, "Forbidden: host_id mismatch", http.StatusForbidden)
|
||
return
|
||
}
|
||
host, err := h.store.GetHost(pathHostID)
|
||
if err != nil {
|
||
h.logger.Printf("[ERROR] desired-state lookup for %s: %v", pathHostID, err)
|
||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if host == nil {
|
||
http.Error(w, "Unknown host_id", http.StatusNotFound)
|
||
return
|
||
}
|
||
desired := host.DesiredJSON
|
||
if strings.TrimSpace(desired) == "" {
|
||
desired = "{}"
|
||
}
|
||
resp := map[string]interface{}{
|
||
"generation": host.DesiredGeneration,
|
||
"desired_state": json.RawMessage(desired), // opaque to the hub — agent owns the schema
|
||
}
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
json.NewEncoder(w).Encode(resp)
|
||
}
|
||
|
||
// handleGetJobs serves a host its pending signed-op blobs (slice 10A). Per-host key, SELF-SCOPED.
|
||
// The blobs are OPAQUE (the hub never forged or opened them); the agent verifies + executes them
|
||
// in 10B. 10A only serves the queue.
|
||
func (h *Handler) handleGetJobs(w http.ResponseWriter, r *http.Request, pathHostID string) {
|
||
authHostID, _, isGlobal, ok := h.checkAuthHost(r)
|
||
if !ok {
|
||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
if pathHostID == "" {
|
||
http.Error(w, "Missing host_id", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if !isGlobal && authHostID != pathHostID {
|
||
http.Error(w, "Forbidden: host_id mismatch", http.StatusForbidden)
|
||
return
|
||
}
|
||
jobs, err := h.store.GetSignedJobs(pathHostID)
|
||
if err != nil {
|
||
h.logger.Printf("[ERROR] jobs lookup for %s: %v", pathHostID, err)
|
||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
out := make([]map[string]string, 0, len(jobs))
|
||
for _, j := range jobs {
|
||
out = append(out, map[string]string{
|
||
"job_id": j.JobID,
|
||
"blob_b64": base64.StdEncoding.EncodeToString(j.Blob),
|
||
"created_at": j.CreatedAt,
|
||
})
|
||
}
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
json.NewEncoder(w).Encode(map[string]interface{}{"jobs": out})
|
||
}
|
||
|
||
// handleDeleteJob clears a processed job from a host's queue (slice 10B). Per-host key,
|
||
// SELF-SCOPED (a host clears only its own jobs; the global key may clear any). Idempotent.
|
||
func (h *Handler) handleDeleteJob(w http.ResponseWriter, r *http.Request, pathHostID, jobID string) {
|
||
authHostID, _, isGlobal, ok := h.checkAuthHost(r)
|
||
if !ok {
|
||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
if pathHostID == "" || jobID == "" {
|
||
http.Error(w, "Missing host_id or job_id", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if !isGlobal && authHostID != pathHostID {
|
||
http.Error(w, "Forbidden: host_id mismatch", http.StatusForbidden)
|
||
return
|
||
}
|
||
if err := h.store.DeleteSignedJob(pathHostID, jobID); err != nil {
|
||
h.logger.Printf("[ERROR] delete job %s for %s: %v", jobID, pathHostID, err)
|
||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
h.logger.Printf("[INFO] host %s cleared signed-op job %s (executed or rejected)", pathHostID, jobID)
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
w.Write([]byte(`{"status":"ok"}`))
|
||
}
|
||
|
||
// handleAdminSetDesiredState sets a host's desired-state (slice 10A). GLOBAL/operator key ONLY —
|
||
// a per-host key cannot author its own intent. The body is the desired-state JSON (opaque to the
|
||
// hub: it stores + serves bytes, never validates/interprets the schema — the agent/CLI owns it).
|
||
// Writing BUMPS desired_generation so the next heartbeat signals the agent to re-fetch.
|
||
func (h *Handler) handleAdminSetDesiredState(w http.ResponseWriter, r *http.Request, pathHostID string) {
|
||
_, _, isGlobal, ok := h.checkAuthHost(r)
|
||
if !ok || !isGlobal {
|
||
http.Error(w, "Forbidden: global key required", http.StatusForbidden)
|
||
return
|
||
}
|
||
if pathHostID == "" {
|
||
http.Error(w, "Missing host_id", http.StatusBadRequest)
|
||
return
|
||
}
|
||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||
if err != nil {
|
||
http.Error(w, "Bad request", http.StatusBadRequest)
|
||
return
|
||
}
|
||
// Validate it is well-formed JSON (the hub does not interpret the schema, but a malformed
|
||
// blob would break the agent's parse — reject it at the door).
|
||
if !json.Valid(body) {
|
||
http.Error(w, "Invalid payload: body must be JSON", http.StatusBadRequest)
|
||
return
|
||
}
|
||
gen, err := h.store.SetHostDesired(pathHostID, body)
|
||
if err == sql.ErrNoRows {
|
||
http.Error(w, "Unknown host_id", http.StatusNotFound)
|
||
return
|
||
}
|
||
if err != nil {
|
||
h.logger.Printf("[ERROR] set desired-state for %s: %v", pathHostID, err)
|
||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
h.logger.Printf("[INFO] admin-set desired-state for host %s (generation now %d, %d bytes)", pathHostID, gen, len(body))
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
json.NewEncoder(w).Encode(map[string]interface{}{"status": "ok", "generation": gen})
|
||
}
|
||
|
||
// handleAdminEnqueueJob appends an opaque signed-op blob to a host's queue (slice 10A). GLOBAL key
|
||
// ONLY. The blob is pre-signed off-hub (the hub holds no signing key); the hub stores it verbatim.
|
||
// This is the minimal operator path to seed the queue so HasSignedOps/serving are exercisable; the
|
||
// rich operator/signing UX is later. Execution is 10B.
|
||
func (h *Handler) handleAdminEnqueueJob(w http.ResponseWriter, r *http.Request, pathHostID string) {
|
||
_, _, isGlobal, ok := h.checkAuthHost(r)
|
||
if !ok || !isGlobal {
|
||
http.Error(w, "Forbidden: global key required", http.StatusForbidden)
|
||
return
|
||
}
|
||
if pathHostID == "" {
|
||
http.Error(w, "Missing host_id", http.StatusBadRequest)
|
||
return
|
||
}
|
||
host, err := h.store.GetHost(pathHostID)
|
||
if err != nil || host == nil {
|
||
http.Error(w, "Unknown host_id", http.StatusNotFound)
|
||
return
|
||
}
|
||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||
if err != nil {
|
||
http.Error(w, "Bad request", http.StatusBadRequest)
|
||
return
|
||
}
|
||
var req struct {
|
||
JobID string `json:"job_id"`
|
||
BlobB64 string `json:"blob_b64"`
|
||
}
|
||
if err := json.Unmarshal(body, &req); err != nil || req.BlobB64 == "" {
|
||
http.Error(w, "Invalid payload: blob_b64 required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
blob, err := base64.StdEncoding.DecodeString(req.BlobB64)
|
||
if err != nil || len(blob) == 0 {
|
||
http.Error(w, "Invalid payload: blob_b64 not valid base64", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if req.JobID == "" {
|
||
req.JobID, _ = configgen.RandomHex(8)
|
||
}
|
||
if err := h.store.EnqueueSignedJob(pathHostID, req.JobID, blob); err != nil {
|
||
h.logger.Printf("[ERROR] enqueue job for %s: %v", pathHostID, err)
|
||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
h.logger.Printf("[INFO] enqueued signed-op job %s for host %s (%d bytes)", req.JobID, pathHostID, len(blob))
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusCreated)
|
||
json.NewEncoder(w).Encode(map[string]interface{}{"status": "ok", "job_id": req.JobID})
|
||
}
|
||
|
||
// 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,
|
||
// Hub-generated host-domain events (v0.7.0, slice 3)
|
||
"host_stale": true,
|
||
"host_down": true,
|
||
"host_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)
|
||
}
|
||
|
||
// handleInfraBackupVersions returns a list of backup versions for a customer.
|
||
// Auth: Bearer token.
|
||
func (h *Handler) handleInfraBackupVersions(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
|
||
}
|
||
|
||
versions, err := h.store.ListInfraBackupVersions(customerID)
|
||
if err != nil {
|
||
h.logger.Printf("[ERROR] Failed to list infra backup versions for %s: %v", customerID, err)
|
||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if versions == nil {
|
||
versions = []store.InfraBackupVersion{}
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(versions)
|
||
}
|
||
|
||
// 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
|
||
|
||
// Support ?version=ID for selecting a specific backup version
|
||
if versionStr := r.URL.Query().Get("version"); versionStr != "" {
|
||
var versionID int64
|
||
if _, err := fmt.Sscanf(versionStr, "%d", &versionID); err == nil {
|
||
if data, err := h.store.GetInfraBackupByID(versionID); err == nil && data != nil {
|
||
infraBackup = data
|
||
hasInfraBackup = true
|
||
}
|
||
}
|
||
} else {
|
||
if data, err := h.store.GetInfraBackup(customerID); err == nil && data != nil {
|
||
infraBackup = data
|
||
hasInfraBackup = true
|
||
}
|
||
}
|
||
|
||
// Include version list for version picker
|
||
var backupVersions []store.InfraBackupVersion
|
||
if versions, err := h.store.ListInfraBackupVersions(customerID); err == nil {
|
||
backupVersions = versions
|
||
}
|
||
|
||
resp := struct {
|
||
CustomerID string `json:"customer_id"`
|
||
ConfigYAML string `json:"config_yaml"`
|
||
InfraBackup json.RawMessage `json:"infra_backup"`
|
||
HasInfraBackup bool `json:"has_infra_backup"`
|
||
BackupVersions []store.InfraBackupVersion `json:"backup_versions,omitempty"`
|
||
}{
|
||
CustomerID: customerID,
|
||
ConfigYAML: configYAML,
|
||
InfraBackup: infraBackup,
|
||
HasInfraBackup: hasInfraBackup,
|
||
BackupVersions: backupVersions,
|
||
}
|
||
|
||
h.logger.Printf("[INFO] Recovery data downloaded for customer %s (has_infra_backup=%v, versions=%d)", customerID, hasInfraBackup, len(backupVersions))
|
||
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)
|
||
}
|