Files
felhom.eu/hub/internal/api/handler.go
T
admin 7eb3772000 hub: opaque PBS recovery-code escrow storage (v0.8.0) + doc 03 §8a posture model
Slice-7 close-out (hub half). PUT /api/v1/hosts/{host_id}/escrow (per-host key)
stores the agent's OPAQUE R-wrapped blob verbatim against the host; the hub never
decrypts it (no recovery code, no decrypt path). host_escrow table + Save/GetHostEscrow.
Tests: verbatim store, rotation last-write-wins, 401/403/400 auth+body, wire contract.

doc 03 §8a rewritten into the key-custody posture model: separation principle,
topology matrix, default + anti-lockout ladder, SSH-vs-key, breach/legal, integrity
caveat. Corrected: hub opaque storage is slice 7 (this task); serving is slice 10.
Slice table + §13 updated.

No secrets committed (R/K never appear; spike findings + docs use placeholders).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 07:46:33 +02:00

1311 lines
47 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package api
import (
"bytes"
"crypto/subtle"
"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)
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
}
resp := map[string]interface{}{
"status": "ok",
"poll_interval_seconds": defaultHostPollSeconds,
"blocked": blocked,
"desired_generation": 0, // reserved (slice 4)
"has_signed_ops": false, // reserved (slice 4)
}
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 78) 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
}
// 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
}
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"}`))
}
// 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)
}