435f4a5229
gates / gates (push) Successful in 7s
Link 6 of the recovery chain had no client. The hub has served the identity blob since
slice 10D from handleReEnroll / handleGetRestoreDirective, gated on operator-armed recovery
mode and the global key -- and nothing in the agent, the hub UI, any script or any runbook
ever called either. The only documented retrieval was sqlite3 writefile() by hand on a
kubectl cp-ed database.
GET /api/v1/hosts/{host_id}/escrow is the box-authenticated mirror of the PUT that put the
blob there. Self-scoped (a per-host key reads only its own; global may read any). A host with
no bundle gets 200 {present:false} -- a 404 is indistinguishable from an unknown host and a
bare empty 200 from a zero-length blob.
THE TRADE IS RECORDED IN THE HANDLER, not inferred: obtaining the blob used to require the
operator to arm recovery mode; now whoever controls a rebuilt box can obtain it with that
box's own credential. They still cannot open it -- the hub has never held R and a wrong code
fails closed at age's scrypt KDF. The mitigation is that every retrieval raises
escrow_blob_served (warning, operator-only), recorded before the bytes leave.
escrowSelfServiceRetrieval is the single decision point: flip it to false and the endpoint
additionally requires recovery mode, changing nothing else.
The operator-driven DR path is untouched, pinned by a test. Red-proofs observed: removing the
ownership check serves host B's blob to host A; removing the record makes it silent.
2492 lines
111 KiB
Go
2492 lines
111 KiB
Go
package api
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"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/claim"
|
||
"gitea.dooplex.hu/admin/felhom-hub/internal/configgen"
|
||
"gitea.dooplex.hu/admin/felhom-hub/internal/intent"
|
||
"gitea.dooplex.hu/admin/felhom-hub/internal/mailrelay"
|
||
"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
|
||
}
|
||
|
||
// LatestVersionProvider returns the latest controller image version known to the hub's registry
|
||
// checker (e.g. "0.86.0"), or "" if unknown. Satisfied by *web.VersionChecker; nil when the
|
||
// registry checker is disabled. Used to advertise latest on the controller report ACK.
|
||
type LatestVersionProvider interface {
|
||
LatestVersion() string
|
||
}
|
||
|
||
// Poker is the agent-plane immediate-sync seam (v0.63.0): satisfied by *poke.Notifier. A system-
|
||
// initiated desired-state write here (admin-set, operator-peer bump) fires a contentless, fire-and-
|
||
// forget nudge so the box ticks in seconds. nil = poke disabled — mutations still persist; the box
|
||
// picks them up on its next ≤15-min cycle. Both methods are safe to call on a nil *poke.Notifier,
|
||
// but every call site still guards with `if h.poker != nil` (the field itself may be nil).
|
||
type Poker interface {
|
||
PokeHost(hostID string)
|
||
PokeAllHosts()
|
||
}
|
||
|
||
// 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
|
||
latestVersion LatestVersionProvider
|
||
|
||
// App-email passthrough (POST /api/v1/mail). nil sender = endpoint returns 503.
|
||
mailSender mailrelay.Sender
|
||
mailLimiter *mailRateLimiter
|
||
mailFromAllow map[string]bool
|
||
|
||
// applianceLimiter (v0.62.0, R-21 slice C) throttles the ONE unauthenticated endpoint,
|
||
// POST /api/v1/appliance/register, per client IP — the unclaimed population is tiny and the
|
||
// ingress already geo-restricts to HU, so this is a cheap anti-abuse bound, not a fleet lever.
|
||
applianceLimiter *ipRateLimiter
|
||
|
||
// S1 offsite connectivity: the wgsync reconciler seam (internal/api/wg.go). nil = peer-sync
|
||
// disabled — mutations still persist, responses carry sync:"disabled".
|
||
wgSyncer WGSyncer
|
||
|
||
// claimEngine is the customer-claim code engine (v0.50.0). nil = claim arc disabled: no codes
|
||
// issued, no claim field in ACKs/configs — pre-arc behavior exactly.
|
||
claimEngine *claim.Engine
|
||
|
||
// wgRegisteredHook (v0.51.0, DR-tier-by-default scenario A) fires after a host's FIRST WG
|
||
// peer registration — main.go wires it to the web server's PBSDRAutoProvision so a DR-ON
|
||
// customer's pbs_dr descriptor lands hands-free (WG registers → provision → the agent's next
|
||
// desired-state tick). nil = no cascade hook (pre-v0.51.0 behavior). Runs in a detached
|
||
// goroutine; must never delay or fail the registration response.
|
||
wgRegisteredHook func(ctx context.Context, customerID string)
|
||
|
||
// offsiteReissuer (F3, v0.57.0) re-issues the customer's offsite credentials on clean-slate
|
||
// re-enrollment — main.go wires it to the web server's ReissueOffsiteForCustomer (same machinery
|
||
// as the manual "Re-issue offsite credentials" button, so escrow invalidation + events ride
|
||
// along). nil = no auto re-issue; a no-op when offsite isn't provisioned/enabled for the customer.
|
||
offsiteReissuer func(ctx context.Context, customerID string) error
|
||
|
||
// intentHub (v0.58.0, Direction-2 immediate-sync) is the in-memory per-customer generation
|
||
// notifier that GET /api/v1/wait long-polls against. nil = wait endpoint returns 503 (the box
|
||
// falls back to the 15-min cycle). Shared with the web server, whose intent handlers Bump it.
|
||
intentHub *intent.Hub
|
||
|
||
// poker (v0.63.0, Direction-2a agent-plane immediate-sync) fires a fire-and-forget nudge after a
|
||
// system-initiated HOST desired-state write (admin-set desired-state, operator-peer bump) so the
|
||
// box ticks in seconds instead of ≤15 min. Shared with the web server (same *poke.Notifier). nil
|
||
// = poke disabled (a no-op; the report cycle still reconciles).
|
||
poker Poker
|
||
}
|
||
|
||
// SetClaimEngine wires the customer-claim code engine (nil-safe everywhere it is used).
|
||
func (h *Handler) SetClaimEngine(e *claim.Engine) {
|
||
h.claimEngine = e
|
||
}
|
||
|
||
// SetOffsiteReissuer wires the clean-slate re-enroll offsite re-issue seam (nil-safe).
|
||
func (h *Handler) SetOffsiteReissuer(f func(ctx context.Context, customerID string) error) {
|
||
h.offsiteReissuer = f
|
||
}
|
||
|
||
// SetWGRegisteredHook wires the post-WG-registration cascade hook (v0.51.0; nil-safe).
|
||
func (h *Handler) SetWGRegisteredHook(f func(ctx context.Context, customerID string)) {
|
||
h.wgRegisteredHook = f
|
||
}
|
||
|
||
// SetLatestVersionProvider wires the registry version checker so the controller report ACK can
|
||
// advertise the latest available version (Phase 2). nil-safe (no latest_version field emitted).
|
||
func (h *Handler) SetLatestVersionProvider(p LatestVersionProvider) {
|
||
h.latestVersion = p
|
||
}
|
||
|
||
// SetIntentHub wires the operator-intent notifier for GET /api/v1/wait (v0.58.0; nil-safe — an
|
||
// unset hub makes the wait endpoint return 503).
|
||
func (h *Handler) SetIntentHub(hub *intent.Hub) {
|
||
h.intentHub = hub
|
||
}
|
||
|
||
// SetPoker wires the agent-plane immediate-sync notifier (v0.63.0; nil-safe — an unset poker makes
|
||
// the admin desired-state writes fire no nudge, and the box picks the change up on its next cycle).
|
||
func (h *Handler) SetPoker(p Poker) {
|
||
h.poker = p
|
||
}
|
||
|
||
// 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,
|
||
applianceLimiter: newIPRateLimiter(20), // 20 registrations/min/IP burst — booting boxes retry ~30s
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
// Direction-2 immediate-sync (v0.58.0): the box long-polls here; the hub completes it on any
|
||
// operator-intent bump for the box's customer, then the box fires its ordinary report.
|
||
case r.Method == http.MethodGet && path == "/wait":
|
||
h.handleWait(w, r)
|
||
// R-21 slice C — the universal ISO. register is the ONE unauthenticated endpoint (per-IP
|
||
// rate-limited); poll is Bearer appliance-token. Both minimal, no enumeration oracle.
|
||
case r.Method == http.MethodPost && path == "/appliance/register":
|
||
h.handleApplianceRegister(w, r)
|
||
case r.Method == http.MethodGet && path == "/appliance/poll":
|
||
h.handleAppliancePoll(w, r)
|
||
case r.Method == http.MethodPost && path == "/host-report":
|
||
h.handleHostReport(w, r)
|
||
case r.Method == http.MethodPost && path == "/host-enroll":
|
||
h.handleHostEnroll(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)
|
||
// R-199 (v0.94.0): the box-authenticated MIRROR of the PUT above — a host reads back its own
|
||
// opaque identity blob so it can be unsealed with the customer's recovery code. Distinct from the
|
||
// operator-driven DR path in dr.go, which stays exactly as it is (see handleHostEscrowGet).
|
||
case r.Method == http.MethodGet && strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/escrow"):
|
||
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/escrow")
|
||
h.handleHostEscrowGet(w, r, hostID)
|
||
// G1 break-glass: day-0 vaults the root@pam console credential (self-scoped host key); the
|
||
// operator retrieves it via the /admin/ path (global key only).
|
||
case r.Method == http.MethodPut && strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/recovery-credential"):
|
||
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/recovery-credential")
|
||
h.handleHostRecoveryCredentialPut(w, r, hostID)
|
||
case r.Method == http.MethodGet && strings.HasPrefix(path, "/admin/hosts/") && strings.HasSuffix(path, "/recovery-credential"):
|
||
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/admin/hosts/"), "/recovery-credential")
|
||
h.handleAdminGetRecoveryCredential(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)
|
||
// S2 offsite connectivity: box-facing WG pubkey registration (per-host key, self-scoped).
|
||
case r.Method == http.MethodPost && strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/wg"):
|
||
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/wg")
|
||
h.handleRegisterHostWG(w, r, hostID)
|
||
// PBS DR tier (SLICE 1): the agent's consume-once fetch of its PBS token secret (api/pbsdr.go).
|
||
case r.Method == http.MethodPost && strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/pbs/consume-token"):
|
||
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/pbs/consume-token")
|
||
h.handleConsumePBSToken(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)
|
||
// S1 offsite connectivity — WG endpoint record + peer registry (global key only, api/wg.go).
|
||
// DELETE carries the pubkey in the body: base64 '/'+'+' keep pubkeys out of URL paths.
|
||
case r.Method == http.MethodPut && path == "/admin/wg/endpoint":
|
||
h.handleAdminSetWGEndpoint(w, r)
|
||
case r.Method == http.MethodGet && path == "/admin/wg/endpoint":
|
||
h.handleAdminGetWGEndpoint(w, r)
|
||
case r.Method == http.MethodPost && path == "/admin/wg/peers":
|
||
h.handleAdminAddWGPeer(w, r)
|
||
case r.Method == http.MethodDelete && path == "/admin/wg/peers":
|
||
h.handleAdminDeleteWGPeer(w, r)
|
||
case r.Method == http.MethodGet && path == "/admin/wg/peers":
|
||
h.handleAdminListWGPeers(w, r)
|
||
// H1: the fleet operator OOB peer (register/rotate at an explicit /32) + read-back.
|
||
case r.Method == http.MethodPut && path == "/admin/wg/operator-peer":
|
||
h.handleAdminSetOperatorPeer(w, r)
|
||
case r.Method == http.MethodGet && path == "/admin/wg/operator-peer":
|
||
h.handleAdminGetOperatorPeer(w, r)
|
||
case r.Method == http.MethodPost && path == "/claim/reset-request":
|
||
h.handleClaimResetRequest(w, r)
|
||
case r.Method == http.MethodPost && path == "/event":
|
||
h.handleEvent(w, r)
|
||
case r.Method == http.MethodPost && path == "/mail":
|
||
h.handleMail(w, r)
|
||
case r.Method == http.MethodPost && path == "/notify":
|
||
h.handleNotify(w, r)
|
||
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.MethodPost && strings.HasPrefix(path, "/offsite/consume-password/"):
|
||
customerID := strings.TrimPrefix(path, "/offsite/consume-password/")
|
||
h.handleOffsiteConsumePassword(w, r, customerID)
|
||
case r.Method == http.MethodGet && strings.HasPrefix(path, "/artifacts/"):
|
||
customerID := strings.TrimPrefix(path, "/artifacts/")
|
||
h.handleArtifactManifest(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)
|
||
}
|
||
}
|
||
|
||
// On-demand log tails (v0.43.0) — the controller ships these on the cycle after the ACK
|
||
// requested them. Storing one clears its pending request (consume-once, in SaveAppLogTail)
|
||
// so the next ACK stops advertising it. Backward-compatible: old controllers never send this.
|
||
var tailPayload struct {
|
||
LogTails []struct {
|
||
App string `json:"app"`
|
||
CollectedAt time.Time `json:"collected_at"`
|
||
Lines []string `json:"lines"`
|
||
} `json:"log_tails"`
|
||
}
|
||
if err := json.Unmarshal(body, &tailPayload); err == nil {
|
||
for _, lt := range tailPayload.LogTails {
|
||
if lt.App == "" {
|
||
continue
|
||
}
|
||
if err := h.store.SaveAppLogTail(payload.CustomerID, lt.App, lt.CollectedAt, lt.Lines); err != nil {
|
||
h.logger.Printf("[WARN] Failed to save log tail %s/%s: %v", payload.CustomerID, lt.App, err)
|
||
} else {
|
||
h.logger.Printf("[INFO] Log tail received for %s/%s (%d lines)", payload.CustomerID, lt.App, len(lt.Lines))
|
||
}
|
||
}
|
||
}
|
||
|
||
// Controller self-log tail (v0.46.0) — the controller's OWN debug ring, shipped on the
|
||
// cycle after the ACK's controller_log_requested. SaveLogBundle runs the secret gate
|
||
// (a hit stores a BLOCKED flag row, nothing else) and clears the pending request
|
||
// (consume-once). Backward-compatible: old controllers never send this.
|
||
var selfTailPayload struct {
|
||
ControllerLogTail *struct {
|
||
CollectedAt time.Time `json:"collected_at"`
|
||
Lines []string `json:"lines"`
|
||
} `json:"controller_log_tail"`
|
||
}
|
||
if err := json.Unmarshal(body, &selfTailPayload); err == nil && selfTailPayload.ControllerLogTail != nil {
|
||
lt := selfTailPayload.ControllerLogTail
|
||
blocked, berr := h.store.SaveLogBundle(payload.CustomerID, store.LogBundleComponentController, lt.CollectedAt, lt.Lines)
|
||
switch {
|
||
case berr != nil:
|
||
h.logger.Printf("[WARN] Failed to save controller log bundle for %s: %v", payload.CustomerID, berr)
|
||
case blocked:
|
||
h.logger.Printf("[WARN] controller log bundle for %s BLOCKED: possible secret in log content — nothing stored", payload.CustomerID)
|
||
default:
|
||
h.logger.Printf("[INFO] controller log bundle received for %s (%d lines)", payload.CustomerID, len(lt.Lines))
|
||
}
|
||
}
|
||
|
||
// DR recipe — persist the controller's secret-free customer/apps half (preserving any host half).
|
||
// Backward-compatible (old controllers won't have this field); a failure must not drop the report.
|
||
var drPayload struct {
|
||
DRRecipe json.RawMessage `json:"dr_recipe"`
|
||
}
|
||
if err := json.Unmarshal(body, &drPayload); err == nil && len(drPayload.DRRecipe) > 0 {
|
||
var ver drRecipeVersionOnly
|
||
_ = json.Unmarshal(drPayload.DRRecipe, &ver)
|
||
if err := h.store.SaveDRRecipeAppHalf(payload.CustomerID, ver.RecipeVersion, drPayload.DRRecipe); err != nil {
|
||
h.logger.Printf("[WARN] Failed to save DR-recipe app-half for %s: %v", payload.CustomerID, err)
|
||
} else {
|
||
h.logger.Printf("[INFO] DR-recipe app-half stored for customer %s (v%d)", payload.CustomerID, ver.RecipeVersion)
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
// Config-refresh (v0.26.0): advertise the per-customer config_version. The controller compares
|
||
// it against its last-applied version and, on a change, re-pulls controller.yaml + self-restarts
|
||
// (pull-based config delivery — the hub never connects into the box). Only emitted for
|
||
// config-managed customers (a report-only box without a config row gets no field and is unaffected).
|
||
resp["config_version"] = custCfg.ConfigVersion
|
||
|
||
// Customer-claim arc (v0.50.0, F-4): ensure a claim code exists for every reporting managed
|
||
// customer (idempotent — the live-box entry point; Day-0 boxes get theirs at config retrieve),
|
||
// ingest the controller's reported claimed flag (set-only — a wiped settings.json can never
|
||
// un-claim), and serve the ACTIVE code hash + generation in the ACK. The hash is bcrypt (non-
|
||
// reversible) — safe to serve on the authenticated report channel; the plaintext code exists
|
||
// only in the customer's mailbox.
|
||
if h.claimEngine != nil {
|
||
cs, cerr := h.claimEngine.EnsureIssued(custCfg)
|
||
if cerr != nil {
|
||
h.logger.Printf("[WARN] claim issue for %s on report: %v", payload.CustomerID, cerr)
|
||
}
|
||
var claimedPayload struct {
|
||
Claimed *bool `json:"claimed"`
|
||
}
|
||
if err := json.Unmarshal(body, &claimedPayload); err == nil &&
|
||
claimedPayload.Claimed != nil && *claimedPayload.Claimed {
|
||
if err := h.claimEngine.MarkClaimed(custCfg); err != nil {
|
||
h.logger.Printf("[WARN] claim mark-claimed for %s: %v", payload.CustomerID, err)
|
||
} else if cs != nil && cs.ClaimedAt == nil {
|
||
cs, _ = h.store.GetClaim(payload.CustomerID) // refresh for the ACK below
|
||
}
|
||
}
|
||
if cs != nil {
|
||
resp["claim"] = map[string]interface{}{
|
||
"code_hash": cs.CodeHash,
|
||
"generation": cs.Generation,
|
||
"issued_at": cs.IssuedAt.UTC().Format(time.RFC3339),
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// SLICE 3 — escrow status for the hub-verified auto-confirm: the controller flips its offbox
|
||
// EscrowState pending→escrowed ONLY when sha256(its local repo password) matches restic_pw_sha256
|
||
// (blob-presence alone must never confirm — a stale blob may not cover the current key). The hash is
|
||
// non-reversible (256-bit random secret) — safe to serve; omitted entirely when no escrow row exists.
|
||
if es, err := h.store.GetEscrowStatusForCustomer(payload.CustomerID); err == nil && es != nil {
|
||
resp["escrow"] = es
|
||
}
|
||
|
||
// v0.43.0 — pending log-tail requests (same additive ACK-flag pattern as escrow): the
|
||
// controller collects the named apps' tails and ships them on its NEXT report; the field
|
||
// is omitted when nothing is pending. The hub never connects into the box.
|
||
if apps, err := h.store.GetPendingLogTailRequests(payload.CustomerID); err == nil && len(apps) > 0 {
|
||
resp["log_tail_requests"] = apps
|
||
}
|
||
|
||
// v0.46.0 — pending CONTROLLER self-log pull (same additive ACK-flag pattern): the
|
||
// controller ships controller_log_tail on its NEXT report; omitted when nothing pending.
|
||
if pending, err := h.store.PendingLogBundleRequest(payload.CustomerID, store.LogBundleComponentController); err == nil && pending {
|
||
resp["controller_log_requested"] = true
|
||
}
|
||
|
||
// Phase 2 managed updates: advertise the effective controller-version FLOOR (per-customer override
|
||
// else global default) and the latest available version. The controller compares its current
|
||
// version against the floor and auto-updates when below it (latest stays the customer's opt-in
|
||
// "update to latest" button — NOT the auto-target). Both fields are omitted when empty, so an old
|
||
// controller that ignores them, or a hub with no floor configured, behaves exactly as before.
|
||
// Part D: the per-box MinAgent conditional floor — HOLD the controller-version floor for a box
|
||
// whose host agent is below the current golden's required MinAgent (never push a controller past
|
||
// the agent it depends on). A held box gets NO directive (behaves as if no floor) but is flagged
|
||
// on the dashboard, never silently stale.
|
||
if fd := h.store.ResolveManagedFloor(payload.CustomerID); fd.Floor != "" {
|
||
resp["min_controller_version"] = fd.Floor
|
||
} else if fd.Held {
|
||
h.logger.Printf("[INFO] managed floor HELD for %s: agent %q < MinAgent %s (controller floor withheld)",
|
||
payload.CustomerID, fd.AgentVersion, fd.MinAgent)
|
||
}
|
||
if h.latestVersion != nil {
|
||
if latest := h.latestVersion.LatestVersion(); latest != "" {
|
||
resp["latest_version"] = latest
|
||
}
|
||
}
|
||
|
||
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"`
|
||
// DR recipe — the agent's storage/guest/PBS half (secret-free). RawMessage = stored verbatim,
|
||
// ignore-unknown (forward-compat). Persisted to dr_recipe, assembled with the controller half.
|
||
DRRecipe json.RawMessage `json:"dr_recipe"`
|
||
// LogTail (v0.46.0) — the agent's on-demand debug-ring tail, present only on the
|
||
// heartbeat right after the envelope's log_tail_requested (agent ≥ 0.83.0).
|
||
LogTail *struct {
|
||
CollectedAt time.Time `json:"collected_at"`
|
||
Lines []string `json:"lines"`
|
||
} `json:"log_tail"`
|
||
}
|
||
|
||
// drRecipeVersionOnly extracts just recipe_version from a half's JSON (ignore-unknown). 0 if absent.
|
||
type drRecipeVersionOnly struct {
|
||
RecipeVersion int `json:"recipe_version"`
|
||
}
|
||
|
||
// 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))
|
||
|
||
// Agent log tail (v0.46.0) — the debug-ring bundle a prior envelope requested.
|
||
// SaveLogBundle runs the secret gate + clears the pending request (consume-once).
|
||
// A failure must NOT drop the heartbeat; just warn.
|
||
if rep.LogTail != nil {
|
||
blocked, berr := h.store.SaveLogBundle(hostID, store.LogBundleComponentAgent, rep.LogTail.CollectedAt, rep.LogTail.Lines)
|
||
switch {
|
||
case berr != nil:
|
||
h.logger.Printf("[WARN] Failed to save agent log bundle for %s: %v", hostID, berr)
|
||
case blocked:
|
||
h.logger.Printf("[WARN] agent log bundle for %s BLOCKED: possible secret in log content — nothing stored", hostID)
|
||
default:
|
||
h.logger.Printf("[INFO] agent log bundle received for %s (%d lines)", hostID, len(rep.LogTail.Lines))
|
||
}
|
||
}
|
||
|
||
// DR recipe — persist the agent's secret-free storage/guest/PBS half (preserving any app half).
|
||
// A failure here must NOT drop the heartbeat (the report already saved); just warn.
|
||
if len(rep.DRRecipe) > 0 && custID != "" {
|
||
var ver drRecipeVersionOnly
|
||
_ = json.Unmarshal(rep.DRRecipe, &ver)
|
||
if err := h.store.SaveDRRecipeHostHalf(custID, hostID, ver.RecipeVersion, rep.DRRecipe); err != nil {
|
||
h.logger.Printf("[WARN] Failed to save DR-recipe host-half for customer %s (host %s): %v", custID, hostID, err)
|
||
} else {
|
||
h.logger.Printf("[INFO] DR-recipe host-half stored for customer %s (host %s, v%d)", custID, hostID, ver.RecipeVersion)
|
||
}
|
||
}
|
||
|
||
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,
|
||
}
|
||
// v0.46.0 — pending agent log pull: the NEXT heartbeat carries log_tail (agent ≥
|
||
// 0.83.0; older agents ignore the flag and the request stays visibly pending).
|
||
if pending, err := h.store.PendingLogBundleRequest(hostID, store.LogBundleComponentAgent); err == nil && pending {
|
||
resp["log_tail_requested"] = true
|
||
}
|
||
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})
|
||
}
|
||
|
||
// handleHostEnroll is the passphrase-authed, mint-once-reuse host enrollment for Day-0
|
||
// (option C, SPIKE-day0-firstboot-handshake-2026-06-26). It is the sibling of the
|
||
// global-key handleAdminCreateHost: the operator/host-bootstrap script carries ONLY the
|
||
// customer's retrieval passphrase (no global key in the field deploy path), POSTs the
|
||
// customer_id, and gets back the host credential — minted on first call, REUSED byte-for-
|
||
// byte on every subsequent call (so re-running the bootstrap never orphans a live agent's
|
||
// key). Auth (passphrase) is checked BEFORE any mint — a bad-auth call never writes a row.
|
||
// The proven GET /config/{id} controller pull and POST /admin/hosts escape hatch are
|
||
// untouched.
|
||
func (h *Handler) handleHostEnroll(w http.ResponseWriter, r *http.Request) {
|
||
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"`
|
||
}
|
||
if err := json.Unmarshal(body, &req); err != nil || req.CustomerID == "" {
|
||
http.Error(w, "Invalid payload: customer_id required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Passphrase auth — mirrors handleConfigRetrieve exactly (header, 404-then-401 order,
|
||
// constant-time compare). Happens BEFORE any mint.
|
||
password := r.Header.Get("X-Retrieval-Password")
|
||
if password == "" {
|
||
http.Error(w, "Unauthorized: X-Retrieval-Password header required", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
cc, err := h.store.GetCustomerConfig(req.CustomerID)
|
||
if err != nil {
|
||
h.logger.Printf("[ERROR] host-enroll: customer lookup failed for %s: %v", req.CustomerID, err)
|
||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if cc == nil {
|
||
http.Error(w, "Not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
if subtle.ConstantTimeCompare([]byte(password), []byte(cc.RetrievalPassword)) != 1 {
|
||
http.Error(w, "Unauthorized: invalid password", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
// Mint-once-reuse: an existing host for this customer is returned as-is (idempotent).
|
||
existing, err := h.store.GetHostByCustomer(req.CustomerID)
|
||
if err != nil {
|
||
h.logger.Printf("[ERROR] host-enroll: host lookup failed for %s: %v", req.CustomerID, err)
|
||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if existing != nil {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
json.NewEncoder(w).Encode(map[string]string{"host_id": existing.HostID, "api_key": existing.APIKey})
|
||
return
|
||
}
|
||
|
||
// First enroll: mint (mirrors handleAdminCreateHost's mint block).
|
||
sfx, err := configgen.RandomHex(3) // 6 hex chars — host_id suffix
|
||
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] host-enroll: failed to mint host for %s: %v", req.CustomerID, err)
|
||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
h.logger.Printf("[INFO] host enrolled: %s (customer %s)", hostID, req.CustomerID)
|
||
|
||
// F2 (v0.57.0) — clean-slate reinstall of an existing customer. This mint path fires exactly
|
||
// once per fresh host record (the clean-slate flow deletes the stale host, so re-enroll mints),
|
||
// so it is the natural single-shot re-enroll hook. For a CLAIMED customer the fresh box has no
|
||
// password; auto-issue a reset code so the customer isn't stranded at the claim page hunting for
|
||
// the manual "request a new code" button (delivery rides the report ACK). No-op for an unclaimed
|
||
// customer (first provision). Also re-issue offsite credentials to the fresh box (F3) — the
|
||
// one-time offsite password only ever reached the OLD controller, so the fresh one has no target.
|
||
h.reissueOnReenroll(cc)
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusCreated)
|
||
json.NewEncoder(w).Encode(map[string]string{"host_id": hostID, "api_key": apiKey})
|
||
}
|
||
|
||
// reissueOnReenroll runs the F2+F3 clean-slate re-enrollment side effects for a customer whose box
|
||
// was wiped and re-minted a host record: re-issue the claim code (if claimed) and re-issue offsite
|
||
// credentials (if provisioned). Every action that fires emits a visible customer event. Best-effort
|
||
// and non-fatal — a failure here never fails the enrollment (the box is already minted).
|
||
func (h *Handler) reissueOnReenroll(cc *store.CustomerConfig) {
|
||
// F2 — claim continuity.
|
||
if h.claimEngine != nil {
|
||
if gen, reissued, err := h.claimEngine.ReissueForReenroll(cc); err != nil {
|
||
h.logger.Printf("[WARN] claim re-issue on re-enroll for %s failed: %v", cc.CustomerID, err)
|
||
} else if reissued {
|
||
if _, serr := h.store.SaveEvent(cc.CustomerID, "claim_reissued_reenroll", "info",
|
||
fmt.Sprintf("Új beállító kódot küldtünk a szerver újratelepítése után (%d. generáció) az ügyfél címére.", gen),
|
||
"", "hub"); serr != nil {
|
||
h.logger.Printf("[WARN] save claim_reissued_reenroll for %s: %v", cc.CustomerID, serr)
|
||
}
|
||
}
|
||
}
|
||
// F3 — offsite continuity: re-stage the one-time offsite password to the fresh controller (the
|
||
// one-time password only ever reached the OLD controller). ⚠ CORRECTED 2026-08-04 (R-196): this
|
||
// used to claim "the re-issuer resets the restic repo password, which makes the OLD escrow blob
|
||
// stale". It does not and cannot — the re-issuer resets the PROVIDER account password; the
|
||
// repository password is generated on the box and never leaves it except sealed under R. The
|
||
// provisioner does mark the escrow stale, but PRECAUTIONARILY (see the reasoning at
|
||
// offsite.ReissueCredentials), not because this call rotated anything.
|
||
// Skips silently when offsite isn't provisioned/enabled.
|
||
if h.offsiteReissuer != nil {
|
||
if err := h.offsiteReissuer(context.Background(), cc.CustomerID); err != nil {
|
||
h.logger.Printf("[WARN] offsite re-issue on re-enroll for %s failed: %v", cc.CustomerID, err)
|
||
}
|
||
}
|
||
// Direction-2 (v0.63.0): wake a long-polling controller so the re-staged claim code / offsite
|
||
// password ride the next ACK in seconds, not on the 15-min cycle. Both legs above are
|
||
// best-effort; an over-bump costs one cheap wake. (On the clean-slate path the controller
|
||
// usually does not exist yet — its startup fetch covers that shape; a bump landing during a
|
||
// fresh controller's FIRST hold is recorded as baseline without firing — the known open
|
||
// observation, fixed later by carrying intent_gen in the report ACK. Out of scope here.)
|
||
if h.intentHub != nil {
|
||
h.intentHub.Bump(cc.CustomerID)
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
// SLICE 3 — sha256 hex of the restic repo password sealed in the identity blob (non-reversible hash
|
||
// of a 256-bit random secret — safe to store/serve; present only when a staged password was folded in).
|
||
ResticPwSHA256 string `json:"restic_pw_sha256,omitempty"`
|
||
}
|
||
|
||
// 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. Part B (v0.60.0):
|
||
// when this upload supersedes a DIFFERENT-passphrase old blob, the old one is RETAINED (not
|
||
// overwritten) so its recovery-code-recoverable history survives (Viktor's data-first ruling).
|
||
superseded, prevPwSHA, serr := h.store.SaveHostEscrow(pathHostID, blob, req.KeyFingerprint, req.Posture, createdAt, req.ResticPwSHA256)
|
||
if serr != nil {
|
||
h.logger.Printf("[ERROR] Failed to store escrow for host %s: %v", pathHostID, serr)
|
||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if superseded {
|
||
n, _ := h.store.CountSupersededEscrow(pathHostID)
|
||
h.logger.Printf("[INFO] escrow for host %s superseded a different-passphrase blob — RETAINED (now %d superseded blob(s) held)", pathHostID, n)
|
||
// Hub-internal audit event (not gated by allowedEventTypes) — tied to the owning customer.
|
||
if host, herr := h.store.GetHost(pathHostID); herr == nil && host != nil && host.CustomerID != "" {
|
||
details, _ := json.Marshal(map[string]any{"host_id": pathHostID, "retained_count": n})
|
||
if _, eerr := h.store.SaveEvent(host.CustomerID, "escrow_superseded", "info",
|
||
"A korábbi helyreállítási csomag megőrizve (új kulcs érkezett).", string(details), "hub"); eerr != nil {
|
||
h.logger.Printf("[WARN] escrow_superseded event save failed for %s: %v", pathHostID, eerr)
|
||
}
|
||
// R-197: the box's offsite DATA key demonstrably changed. Both halves of that comparison
|
||
// have been stored since SLICE 3 and nothing read them — demo-felhom's key changed on
|
||
// 2026-08-03 and nothing said so for thirteen hours.
|
||
h.maybeEmitRepoKeyChanged(host.CustomerID, pathHostID, prevPwSHA, req.ResticPwSHA256, n)
|
||
}
|
||
}
|
||
// 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"}`))
|
||
}
|
||
|
||
// eventRepoKeyChanged (R-197) — the box's offsite restic REPOSITORY password changed, proven by the
|
||
// hub's own stored hashes. Hub-internal (not in allowedEventTypes, like escrow_superseded) and
|
||
// registered operator-only in notify.operatorOnlyEvents.
|
||
const eventRepoKeyChanged = "offsite_repo_key_changed"
|
||
|
||
// eventEscrowBlobServed (R-199) — a host retrieved its own sealed identity blob. Hub-internal,
|
||
// operator-only. See handleHostEscrowGet for why every retrieval is loud.
|
||
const eventEscrowBlobServed = "escrow_blob_served"
|
||
|
||
// escrowSelfServiceRetrieval is THE SINGLE DECISION POINT for the §8.2/§8.3 trade (R-199).
|
||
//
|
||
// true (§8.2, shipped v0.94.0) — a host may read its own blob whenever it authenticates as itself.
|
||
// false (§8.3, the fallback) — the same read additionally requires operator-armed recovery mode.
|
||
//
|
||
// It is one condition on purpose: the operator may overrule the trade below, and switching must cost a
|
||
// boolean rather than a redesign. Everything else in the recovery chain is identical either way.
|
||
const escrowSelfServiceRetrieval = true
|
||
|
||
// handleHostEscrowGet serves a host its OWN opaque identity-escrow blob (R-199, v0.94.0).
|
||
//
|
||
// WHAT THIS GIVES OUT, WHY IT IS SAFE, AND WHAT IT CHANGES ABOUT WHO IS REQUIRED — recorded here so the
|
||
// next reader finds the trade rather than inferring it (the dr.go header convention).
|
||
//
|
||
// WHAT: the age-wrapped `IdentityBundle` — opaque ciphertext. It carries the offsite restic repository
|
||
// password, the tunnel token, the PBS token and the WG key. The hub stores these bytes and has no
|
||
// decrypt path; the recovery code R that opens them exists only in the customer's hands.
|
||
//
|
||
// WHY IT IS SAFE TO GIVE OUT: the blob is useless without R (age scrypt + ChaCha20-Poly1305; a wrong R
|
||
// fails closed at the KDF, never to a plausible-but-wrong bundle), and a 10-word EFF code carries ~129
|
||
// bits. The caller already authenticates as this host for its report, its desired state, its WG
|
||
// registration and its PBS token — this adds no new identity, only a new object, and it is the exact
|
||
// MIRROR of the PUT above, which is how the blob got here in the first place.
|
||
//
|
||
// WHAT IT CHANGES, STATED PLAINLY BECAUSE IT IS THE WHOLE OF THE TRADE: before this, obtaining the blob
|
||
// required the OPERATOR to arm recovery mode with the global key (dr.go). Now whoever controls a
|
||
// rebuilt box can obtain it with that box's own credential. That is a real reduction in the number of
|
||
// parties required. They still cannot open it. The mitigation is that the capability is AUDITED rather
|
||
// than silent: every successful retrieval raises an operator event (below), because a silent capability
|
||
// is the shape this project has spent two weeks removing.
|
||
//
|
||
// THE OPERATOR-DRIVEN DR PATH IS UNTOUCHED. `handleReEnroll` / `handleGetRestoreDirective` keep their
|
||
// recovery-mode gate and their global-key arming, and they serve the K-escrow and the directive as
|
||
// well. This endpoint serves ONE object to ONE authenticated owner. Do not merge them.
|
||
func (h *Handler) handleHostEscrowGet(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
|
||
}
|
||
// SELF-SCOPED: a per-host key reads only its OWN escrow. The global operator key may read any —
|
||
// the same asymmetry the PUT has. Without this line any host key is a fleet-wide blob reader.
|
||
if !isGlobal && authHostID != pathHostID {
|
||
h.logger.Printf("[WARN] escrow GET REFUSED: host %s asked for %s's blob (self-scope)", authHostID, pathHostID)
|
||
http.Error(w, "Forbidden: host_id mismatch", http.StatusForbidden)
|
||
return
|
||
}
|
||
host, err := h.store.GetHost(pathHostID)
|
||
if err != nil {
|
||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if host == nil {
|
||
http.Error(w, "Unknown host_id", http.StatusNotFound)
|
||
return
|
||
}
|
||
// §8.3 fallback lives here and nowhere else.
|
||
if !escrowSelfServiceRetrieval && !host.InRecoveryMode(time.Now().UTC()) {
|
||
h.logger.Printf("[WARN] escrow GET REFUSED for %s — self-service retrieval is disabled and recovery mode is not armed", pathHostID)
|
||
http.Error(w, "Forbidden: host not in recovery mode (operator must arm it)", http.StatusForbidden)
|
||
return
|
||
}
|
||
|
||
bundle, berr := h.store.GetHostDRBundle(pathHostID)
|
||
if berr != nil {
|
||
h.logger.Printf("[ERROR] escrow GET for %s: %v", pathHostID, berr)
|
||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
// A host with no sealed bundle gets a CLEAN ANSWER, not a fault: 200 with present=false. A 404
|
||
// here would be indistinguishable from an unknown host, and an empty 200 without the flag would be
|
||
// indistinguishable from a zero-length blob — both read as "something is broken" to a caller whose
|
||
// situation is simply "no ceremony has run yet".
|
||
if bundle == nil || len(bundle.IdentityBlob) == 0 {
|
||
h.logger.Printf("[INFO] escrow GET for %s: no identity blob stored (no ceremony has run)", pathHostID)
|
||
writeJSON(w, http.StatusOK, map[string]any{"host_id": pathHostID, "present": false, "identity_escrow_b64": ""})
|
||
return
|
||
}
|
||
|
||
// THE MITIGATION (§8.2). Recorded BEFORE the bytes leave, so a retrieval cannot be served without
|
||
// its audit row; a save failure is logged and does NOT block the response (the blob is opaque and
|
||
// refusing it would break a recovery over an audit hiccup — but the log line always exists).
|
||
//
|
||
// SEVERITY = warning, i.e. it reaches the operator by e-mail. Retrieval is not routine today: it
|
||
// happens during a recovery and nowhere else. IF a customer-facing self-service flow ever makes it
|
||
// routine, revisit this — but revisit it deliberately, do not let it decay to info because the
|
||
// mail became annoying.
|
||
if host.CustomerID != "" {
|
||
msg := fmt.Sprintf("Recovery blob served: host %s retrieved its own sealed identity escrow (%d opaque bytes). "+
|
||
"This is the recovery path in use — the blob cannot be opened without the customer's recovery code, which the hub never holds. "+
|
||
"If no recovery is in progress on that box, investigate.", pathHostID, len(bundle.IdentityBlob))
|
||
details, _ := json.Marshal(map[string]any{
|
||
"host_id": pathHostID,
|
||
"blob_bytes": len(bundle.IdentityBlob),
|
||
"self_scope": !isGlobal,
|
||
})
|
||
if _, eerr := h.store.SaveEvent(host.CustomerID, eventEscrowBlobServed, "warning", msg, string(details), "hub"); eerr != nil {
|
||
h.logger.Printf("[WARN] %s event save FAILED for %s (serving anyway): %v", eventEscrowBlobServed, pathHostID, eerr)
|
||
} else if h.dispatcher != nil {
|
||
go h.dispatcher.ProcessEvent(host.CustomerID, eventEscrowBlobServed, "warning", msg, string(details), "hub")
|
||
}
|
||
}
|
||
h.logger.Printf("[WARN] escrow blob SERVED to host %s (%d opaque bytes, self_scope=%v) — recovery path in use",
|
||
pathHostID, len(bundle.IdentityBlob), !isGlobal)
|
||
writeJSON(w, http.StatusOK, map[string]any{
|
||
"host_id": pathHostID,
|
||
"present": true,
|
||
"identity_escrow_b64": base64.StdEncoding.EncodeToString(bundle.IdentityBlob),
|
||
})
|
||
}
|
||
|
||
// maybeEmitRepoKeyChanged raises ONE operator signal per supersession when the sealed offsite repo
|
||
// password demonstrably changed. Both hashes have been stored since SLICE 3 (host_escrow and, since
|
||
// v0.60.0, host_escrow_superseded) and NOTHING compared them: demo-felhom's repository password
|
||
// changed on 2026-08-03, orphaning 36 snapshots / 1.14 GB, and no event, e-mail, card or log line
|
||
// said so for thirteen hours — the comparison that eventually found it is this one
|
||
// (audits/RECON-offsite-dr-chain-2026-08-04.md, R-197).
|
||
//
|
||
// THE PREDICATE IS DELIBERATELY NARROW: both hashes known AND different. A first-ever hash (prev "")
|
||
// is onboarding, not a change; a hash-less supersession (now "") cannot show a change happened; an
|
||
// identical hash is a re-ceremony of the SAME password, which is a normal healthy act and must stay
|
||
// silent or a customer is punished for re-running a ceremony. The in-between shapes are LOGGED rather
|
||
// than dropped, so "we chose not to alarm" and "the check did not run" never look identical.
|
||
//
|
||
// SEVERITY = warning, chosen for the world v0.93.0 creates rather than the one it inherits. Before
|
||
// R-198 a changed key meant the previous history was unopenable by anyone, ever — that would have
|
||
// argued for error. From v0.93.0 the superseding ceremony RETAINS the old identity blob, so the
|
||
// previous history stays recoverable with the recovery code that sealed it: the situation is "this
|
||
// customer's off-site history now depends on an older recovery code", which is operator-actionable
|
||
// (check the orphan card, expect a fresh repository) and is not a loss. warning also routes: the
|
||
// dispatcher notifies on warning/error/critical and treats info as an intentional non-notify, and
|
||
// the whole point of this row is that the operator learns on the day.
|
||
//
|
||
// EDGE-TRIGGERED: called only from the superseded branch of the escrow PUT, i.e. once per
|
||
// supersession, never per report. No timer lives here — the dispatcher owns cooldown.
|
||
//
|
||
// NO HASH VALUE TRAVELS. The message and the details name the host, the customer and the retained
|
||
// count only. The hashes are non-reversible, but a hash is still a fingerprint of a live secret and
|
||
// this project's rule is that values do not leave the store.
|
||
func (h *Handler) maybeEmitRepoKeyChanged(customerID, hostID, prevSHA, newSHA string, retained int) {
|
||
switch {
|
||
case prevSHA == "":
|
||
h.logger.Printf("[INFO] escrow for host %s: no previous repo-password hash recorded (first hash, or a legacy hash-less blob) — repo-key-change check not applicable", hostID)
|
||
return
|
||
case newSHA == "":
|
||
h.logger.Printf("[WARN] escrow for host %s: the NEW blob carries no repo-password hash (hash-less supersession) — whether the repository key changed CANNOT be determined from the hub's data", hostID)
|
||
return
|
||
case prevSHA == newSHA:
|
||
return // same password re-sealed: a healthy re-ceremony (Scenario E — silence is correct)
|
||
}
|
||
msg := fmt.Sprintf("Offsite repository key CHANGED for host %s: the new escrow seals a different repository password than the one it replaced. "+
|
||
"The previous off-site history is no longer opened by this box's current key. The superseding blob was retained (%d held), so that history stays recoverable with the recovery code that sealed it — verify the box's off-site tier reports a repository rather than an orphan card, and expect the next backup to start a fresh history.",
|
||
hostID, retained)
|
||
details, _ := json.Marshal(map[string]any{
|
||
"host_id": hostID,
|
||
"retained_count": retained,
|
||
"repo_key": "changed", // never the hash values
|
||
})
|
||
if _, err := h.store.SaveEvent(customerID, eventRepoKeyChanged, "warning", msg, string(details), "hub"); err != nil {
|
||
h.logger.Printf("[WARN] %s event save failed for %s: %v", eventRepoKeyChanged, hostID, err)
|
||
return // audit row first: an e-mail without its event row lies (the OffsiteChecker convention)
|
||
}
|
||
h.logger.Printf("[WARN] offsite repository key CHANGED for host %s (customer %s) — previous history now depends on the superseded recovery code; %d retained blob(s)",
|
||
hostID, customerID, retained)
|
||
if h.dispatcher != nil {
|
||
go h.dispatcher.ProcessEvent(customerID, eventRepoKeyChanged, "warning", msg, string(details), "hub")
|
||
}
|
||
}
|
||
|
||
// handleHostRecoveryCredentialPut vaults a host's break-glass root@pam console credential (TASK G1).
|
||
// SELF-SCOPED (a host key writes only its own; global may write any) — day-0 posts it with the
|
||
// host api_key. The secret is stored at rest and NEVER logged (only the username + a length are
|
||
// logged). This is the human fallback for when both the sshd path AND the agent-independent
|
||
// auto-heal have failed: the operator retrieves it to reach the PVE web console (pveproxy — a
|
||
// failure domain distinct from sshd).
|
||
func (h *Handler) handleHostRecoveryCredentialPut(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
|
||
}
|
||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<16)) // 64 KiB cap; a username+password is tiny
|
||
if err != nil {
|
||
http.Error(w, "Bad request", http.StatusBadRequest)
|
||
return
|
||
}
|
||
var req struct {
|
||
Username string `json:"username"`
|
||
Password string `json:"password"`
|
||
}
|
||
if err := json.Unmarshal(body, &req); err != nil || req.Username == "" || req.Password == "" {
|
||
http.Error(w, "Invalid payload: username + password required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
// The host must exist (mint-first) — a per-host key already proves it; the global path re-checks.
|
||
if isGlobal {
|
||
host, herr := h.store.GetHost(pathHostID)
|
||
if herr != nil || host == nil {
|
||
http.Error(w, "Unknown host_id", http.StatusBadRequest)
|
||
return
|
||
}
|
||
}
|
||
if err := h.store.SaveHostRecoveryCredential(pathHostID, req.Username, req.Password); err != nil {
|
||
h.logger.Printf("[ERROR] Failed to vault recovery credential for host %s: %v", pathHostID, err)
|
||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
// SECRET DISCIPLINE: log the username + a length only — NEVER the password.
|
||
h.logger.Printf("[INFO] vaulted break-glass recovery credential for host %s (user=%s, secret %d chars)",
|
||
pathHostID, req.Username, len(req.Password))
|
||
w.WriteHeader(http.StatusOK)
|
||
w.Write([]byte(`{"status":"ok"}`))
|
||
}
|
||
|
||
// handleAdminGetRecoveryCredential returns a host's vaulted break-glass credential to the OPERATOR
|
||
// (global key only — a per-host key must NOT read its own console password back out). This is the
|
||
// authenticated retrieval path the break-glass runbook uses. The response body carries the secret by
|
||
// necessity; it is never written to the hub log.
|
||
func (h *Handler) handleAdminGetRecoveryCredential(w http.ResponseWriter, r *http.Request, pathHostID string) {
|
||
_, _, isGlobal, ok := h.checkAuthHost(r)
|
||
if !ok || !isGlobal {
|
||
http.Error(w, "Unauthorized", http.StatusUnauthorized) // operator/global key ONLY
|
||
return
|
||
}
|
||
if pathHostID == "" {
|
||
http.Error(w, "Missing host_id", http.StatusBadRequest)
|
||
return
|
||
}
|
||
cred, err := h.store.GetHostRecoveryCredential(pathHostID)
|
||
if err != nil {
|
||
h.logger.Printf("[ERROR] Failed to read recovery credential for host %s: %v", pathHostID, err)
|
||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if cred == nil {
|
||
http.Error(w, "No recovery credential vaulted for this host", http.StatusNotFound)
|
||
return
|
||
}
|
||
h.logger.Printf("[INFO] operator retrieved break-glass recovery credential for host %s (user=%s)", pathHostID, cred.Username)
|
||
resp, _ := json.Marshal(map[string]string{
|
||
"host_id": cred.HostID,
|
||
"username": cred.Username,
|
||
"password": cred.Secret,
|
||
"set_at": cred.SetAt.UTC().Format(time.RFC3339),
|
||
})
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
w.Write(resp)
|
||
}
|
||
|
||
// 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 = "{}"
|
||
}
|
||
// S2: merge the hub-OWNED wireguard block at read time (no peer → pass-through unchanged;
|
||
// the stored operator blob is never modified). See api/wg.go mergeWireguard.
|
||
desired = h.mergeWireguard(pathHostID, 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
|
||
}
|
||
// S2: the wireguard block is HUB-owned, merged at read time — an operator copy-paste of a
|
||
// served desired-state must never write it back into the stored blob (it would go stale and
|
||
// shadow the live assignment). Reject at the door.
|
||
var topKeys map[string]json.RawMessage
|
||
if err := json.Unmarshal(body, &topKeys); err == nil {
|
||
if _, has := topKeys["wireguard"]; has {
|
||
http.Error(w, "wireguard is hub-owned; register via POST /hosts/{id}/wg", 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))
|
||
if h.poker != nil {
|
||
h.poker.PokeHost(pathHostID) // agent-plane immediate-sync (Direction-2a): generation bumped → nudge the box now
|
||
}
|
||
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})
|
||
}
|
||
|
||
// handleClaimResetRequest is the controller-forwarded "Elfelejtett jelszó" (v0.50.0): the box
|
||
// asks the hub to email a fresh reset code to the REGISTERED customer address — the requester
|
||
// never chooses the destination. Auth: the customer's own report Bearer key (self-scoped).
|
||
// The response is deliberately neutral 200 on every authorized outcome (cap reached, email
|
||
// failure) — the customer-facing message is always "ha az e-mail cím regisztrálva van…"; the
|
||
// real outcome goes to the operator log + notification_log.
|
||
func (h *Handler) handleClaimResetRequest(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, 4096))
|
||
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 !isGlobal && authCustomerID != payload.CustomerID {
|
||
http.Error(w, "Forbidden: customer_id mismatch", http.StatusForbidden)
|
||
return
|
||
}
|
||
if h.claimEngine == nil {
|
||
http.Error(w, "Claim engine not available", http.StatusServiceUnavailable)
|
||
return
|
||
}
|
||
cfg, err := h.store.GetCustomerConfig(payload.CustomerID)
|
||
if err != nil || cfg == nil {
|
||
http.Error(w, "Not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
if err := h.claimEngine.RequestReset(cfg); err != nil {
|
||
// Neutral to the box; loud to the operator (cap reached / send failure / no email).
|
||
h.logger.Printf("[WARN] claim reset-request for %s: %v", payload.CustomerID, err)
|
||
} else {
|
||
h.logger.Printf("[INFO] claim reset-request for %s: reset code emailed to the registered address", payload.CustomerID)
|
||
}
|
||
// v0.52.0 (take-two F-15): serve the ACTIVE code state in the response — same shape and same
|
||
// bcrypt-only guarantee as the report ACK — so the box accepts the emailed code the moment it
|
||
// lands instead of waiting for the next ACK (~15 min). Served on every authorized outcome: on
|
||
// a cap-reached refusal it is the unrotated row (a controller-side no-op by generation).
|
||
resp := map[string]interface{}{"status": "ok"}
|
||
if cs, err := h.store.GetClaim(payload.CustomerID); err == nil && cs != nil {
|
||
resp["claim"] = map[string]interface{}{
|
||
"code_hash": cs.CodeHash,
|
||
"generation": cs.Generation,
|
||
"issued_at": cs.IssuedAt.UTC().Format(time.RFC3339),
|
||
}
|
||
}
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
json.NewEncoder(w).Encode(resp)
|
||
}
|
||
|
||
// allowedEventTypes lists all valid event_type values the Hub accepts.
|
||
var allowedEventTypes = map[string]bool{
|
||
// R-85: restore-test signals. Hub-GENERATED (source "hub"), but listed here on purpose —
|
||
// allowedEventTypes is the project's single register of legitimate event types, and R-77's
|
||
// lesson was that an event type missing from it ships as an inert seam. Neither has a
|
||
// customerMessages entry, so both stay operator-tier.
|
||
"restore_test_failed": true,
|
||
"restore_test_stale": true,
|
||
|
||
// R-97a: the WHOLE-GUEST (vzdump) backup tier's outcome. Controller-pushed from
|
||
// `internal/quiesce`, which until now had no route to the hub at all — on 2026-07-27 three failed
|
||
// whole-guest backups and twelve app-stack stop/starts produced ZERO events.
|
||
//
|
||
// DELIBERATELY NOT `backup_failed`/`backup_completed`. Those two carry customerMessages entries
|
||
// AND sit in demo-felhom's live enabled_events, so reusing them would email the CUSTOMER, in
|
||
// Hungarian, that their backup failed — while it is still retrying behind the R-88 breaker. A
|
||
// customer can take no action on a failed whole-guest backup.
|
||
//
|
||
// OPERATOR-ONLY IS ENFORCED BY `notify.operatorOnlyEvents`, NOT by the absence of a
|
||
// customerMessages entry. v0.78.0 claimed the latter and was WRONG (corrected in v0.79.0/R-97c):
|
||
// `FormatCustomerEmail` treats a missing entry as a fallback to the raw message, and the only
|
||
// customer gate is `prefs.EnabledEvents` — configuration, which a customer or a future code path
|
||
// can change. The register is checked before customer dispatch and logs a `skipped/operator_only`
|
||
// row. Adding a type here does NOT make it operator-only; add it to that register too.
|
||
"whole_guest_backup_failed": true,
|
||
"whole_guest_backup_recovered": true,
|
||
|
||
// R-158 / R-167 (controller v0.191.0, decision D-c): a per-app Tier-1 recovery-unit capture
|
||
// failed. Until then a `[WARN]` line in the controller reached no hub channel at all — the fifth
|
||
// instance in this project of a mechanism built and left disconnected.
|
||
//
|
||
// DELIBERATELY NOT `backup_failed`, for exactly the reason recorded above for the whole-guest
|
||
// pair: that type carries a customerMessages entry AND sits in the controller's
|
||
// DefaultEnabledEvents, so reusing it emails the CUSTOMER, in Hungarian, about a failure they
|
||
// cannot act on. R-158's original proposal named `backup_failed`; D-c routes this to the
|
||
// operator, and where the two disagree D-c wins.
|
||
//
|
||
// OPERATOR-ONLY IS ENFORCED BY `notify.operatorOnlyEvents` — see the paragraph above. This entry
|
||
// alone does NOT make it operator-only.
|
||
"recovery_unit_capture_failed": true,
|
||
// R-182. The per-run backup digest: one event at the end of a run, listing every app whose
|
||
// backup failed or was refused. Allowlisting it is NOT what keeps it away from customers —
|
||
// `notify.operatorOnlyEvents` is (see the comment there); both entries ship together and
|
||
// `backup_run_digest_event_test.go` pins the pair.
|
||
"backup_run_failures": true,
|
||
|
||
// Controller-pushed events
|
||
"controller_started": true,
|
||
"claim_lockout": true, // v0.50.0 — claim/reset code brute-force lockout tripped
|
||
"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,
|
||
// controller v0.134.1 — enlarged offsite push refused by the quota gate (warning; the controller's
|
||
// dynamic Hungarian message is customer-grade — deliberately NO customerMessages entry, which would
|
||
// discard the numbers (templates.go:129 priority)).
|
||
"offbox_enlarge_blocked": true,
|
||
// controller v0.142.0 — offsite-repo continuity: the remote repo is orphaned (reinstall shape) /
|
||
// was reset (move-aside + re-init). Customer-grade messages below.
|
||
"offbox_repo_orphaned": true,
|
||
"offbox_repo_reset": true,
|
||
"storage_disconnected": true,
|
||
"storage_reconnected": true,
|
||
// controller v0.184.0 (E-2) — the assigned whole-guest backup TARGET drive is absent. Distinct
|
||
// from storage_disconnected on purpose: that one says "a drive went away and some apps may have
|
||
// stopped"; this one says "the thing that makes a backup survive a disk failure is gone", which
|
||
// is a different action for the customer and a different urgency for the operator. Before this
|
||
// the only signal was the tier's own failure at its next due cycle — up to ~24 h on the daily
|
||
// local tier — i.e. the R-100 shape: a real fault visible only after a deadline elapsed.
|
||
"backup_target_absent": true,
|
||
"backup_target_restored": true,
|
||
"disk_warning": true,
|
||
"disk_critical": true,
|
||
// controller v0.169.0 — per-disk SMART degradation (Rendben→Figyelmeztetés/Hiba). The controller
|
||
// sends a dynamic Hungarian message (disk label + the triggering attribute names), so — like
|
||
// offbox_enlarge_blocked — there is deliberately NO customerMessages entry (which would discard the
|
||
// specifics via the templates.go fallback priority).
|
||
"disk_health_degraded": true,
|
||
"health_degraded": true,
|
||
"health_critical": true,
|
||
"health_recovered": true,
|
||
"app_deployed": true,
|
||
"app_removed": true,
|
||
"app_start_failed": true, // controller fix-3 (CAMPAIGN-3): a deployed app is not running
|
||
"disaster_recovery_started": true,
|
||
"disaster_recovery_completed": true,
|
||
// Controller→agent channel health (controller v0.90.0) — operator-only (not customer toggles)
|
||
"agent_channel_pin_mismatch": true,
|
||
"agent_channel_unauthorized": true,
|
||
"agent_channel_unreachable": true,
|
||
"agent_channel_timeout": true,
|
||
"agent_channel_misconfigured": true,
|
||
"agent_channel_construction_error": true,
|
||
"agent_channel_unknown": true,
|
||
"agent_channel_recovered": true,
|
||
// controller v0.173.0 (R-77): controller.yaml and bootstrap.json disagree on local_api.endpoint.
|
||
// Operator-only, deliberately NOT an agent_channel_* type — during the 2026-07-25 island-migration
|
||
// outage the generic "unreachable" alert was the only signal and it hid a specific config fault.
|
||
"local_api_endpoint_drift": true,
|
||
// Hub-generated events
|
||
"node_stale": true,
|
||
"node_down": true,
|
||
"node_recovered": true,
|
||
// v0.57.0 reinstall arc (F2/F3/2.3) — hub-emitted on clean-slate re-enrollment / offsite re-issue
|
||
"claim_reissued_reenroll": true, // reset code auto-issued to a reinstalled claimed customer
|
||
"offsite_reissued": true, // offsite one-time password re-staged (manual button or re-enroll)
|
||
"escrow_stale": true, // key-escrow blob invalidated by an offsite password re-issue
|
||
// Hub-generated host-domain events (v0.7.0, slice 3)
|
||
"host_stale": true,
|
||
"host_down": true,
|
||
"host_recovered": true,
|
||
// Hub-generated host root-fs disk-pressure (v0.23.0) — distinct from the controller's GUEST disk_*
|
||
"host_disk_warning": true,
|
||
"host_disk_critical": true,
|
||
"storage_fill_warning": true,
|
||
"storage_fill_critical": 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 (exact-match lowercase; unknown values coerce to info)
|
||
switch payload.Severity {
|
||
case "info", "warning", "error", "critical":
|
||
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
|
||
}
|
||
|
||
// Empty-email no-clobber guard (v0.71.0, audit F12): a controller push with an empty email
|
||
// (e.g. an unconfigured box) must never wipe a stored non-empty address — the seeded/edited
|
||
// email is the customer's alert lifeline. Events + cooldown from the push still apply; a push
|
||
// with a non-empty email updates everything (customer edits keep working).
|
||
saveEmail := payload.Email
|
||
if saveEmail == "" {
|
||
if existing, err := h.store.GetNotificationPrefs(payload.CustomerID); err == nil && existing != nil && existing.Email != "" {
|
||
saveEmail = existing.Email
|
||
h.logger.Printf("[INFO] Notification prefs push for %s had empty email — preserving stored address", payload.CustomerID)
|
||
}
|
||
}
|
||
|
||
if err := h.store.SaveNotificationPrefs(payload.CustomerID, saveEmail, 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, saveEmail, payload.EnabledEvents)
|
||
w.WriteHeader(http.StatusOK)
|
||
w.Write([]byte(`{"status":"ok"}`))
|
||
}
|
||
|
||
// handleRecovery returns the generated controller.yaml for disaster recovery.
|
||
// Auth: X-Retrieval-Password header (same as config retrieval).
|
||
//
|
||
// The infra-backup payload was retired (Phase-1, 2026-06-16): it pushed plaintext
|
||
// customer secrets to the hub (a zero-knowledge violation) and had been dead since
|
||
// slice 8C. DR config now comes from the generated controller.yaml here; the data
|
||
// bytes come from the agent's PBS whole-CT snapshot. A secret-free DR recipe is the
|
||
// later DR slice's job.
|
||
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. The claim state is baked read-only (no issue on the DR path — a
|
||
// recovered box whose settings.json is gone re-gates on the EXISTING hash; the reset flow
|
||
// covers a customer who lost the password with the box).
|
||
var configYAML string
|
||
if h.templateProvider != nil {
|
||
var claimState *store.ClaimState
|
||
if h.claimEngine != nil {
|
||
claimState, _ = h.store.GetClaim(customerID)
|
||
}
|
||
yamlOutput, err := configgen.Generate(h.templateProvider.Template(), cfg, claimState)
|
||
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
|
||
}
|
||
|
||
// infra_backup retired: the response keeps has_infra_backup=false so any old client
|
||
// degrades gracefully to the config_yaml-only path.
|
||
resp := struct {
|
||
CustomerID string `json:"customer_id"`
|
||
ConfigYAML string `json:"config_yaml"`
|
||
HasInfraBackup bool `json:"has_infra_backup"`
|
||
}{
|
||
CustomerID: customerID,
|
||
ConfigYAML: configYAML,
|
||
HasInfraBackup: false,
|
||
}
|
||
|
||
h.logger.Printf("[INFO] Recovery data downloaded for customer %s (config only; infra-backup retired)", customerID)
|
||
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
|
||
}
|
||
|
||
// Customer-claim arc (v0.50.0): the REAL config pull (Day-0 installer / controller refresh) is
|
||
// the Day-0 claim entry point — issue + email the first code here (idempotent), and bake the
|
||
// active hash into the generated web.claim_code_hash so the box is gated from FIRST boot.
|
||
// (The operator-UI preview deliberately does NOT issue — it only bakes an existing hash.)
|
||
var claimState *store.ClaimState
|
||
if h.claimEngine != nil {
|
||
var cerr error
|
||
claimState, cerr = h.claimEngine.EnsureIssued(cfg)
|
||
if cerr != nil {
|
||
// Loud but non-fatal: the config is still served; if a hash was stored the gate is armed
|
||
// and the operator resends the email from the customer page.
|
||
h.logger.Printf("[WARN] claim issue for %s on config retrieve: %v", customerID, cerr)
|
||
}
|
||
}
|
||
|
||
yamlOutput, err := configgen.Generate(h.templateProvider.Template(), cfg, claimState)
|
||
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))
|
||
}
|
||
|
||
// artifactManifestResponse is the wire shape the host-bootstrap script consumes: the operator-vouched
|
||
// current agent binary + golden archive (version + sha256 each). The script fetches each artifact from
|
||
// Gitea with the config-retrieve git token and verifies its sha256 against THESE values before install
|
||
// — so the hub is the checksum trust root, a different root than Gitea (which only stores the bytes).
|
||
type artifactManifestResponse struct {
|
||
Agent artifactEntry `json:"agent"`
|
||
Golden artifactEntry `json:"golden"`
|
||
}
|
||
|
||
type artifactEntry struct {
|
||
Version string `json:"version"`
|
||
SHA256 string `json:"sha256"`
|
||
}
|
||
|
||
// handleArtifactManifest serves the current artifact set for a customer. Auth mirrors
|
||
// handleConfigRetrieve EXACTLY (X-Retrieval-Password header, 404-then-401 order, constant-time
|
||
// compare) so a script that can pull the controller.yaml can pull the manifest with the same secret.
|
||
// v0.16.0 returns the GLOBAL current set for every customer (per-customer pinning is a future hook).
|
||
// An unset manifest returns empty fields (not an error) — the script falls back to the local golden
|
||
// and fails clearly on a missing binary.
|
||
func (h *Handler) handleArtifactManifest(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] artifacts: customer lookup failed 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
|
||
}
|
||
|
||
m := h.store.GetArtifactManifest()
|
||
resp := artifactManifestResponse{
|
||
Agent: artifactEntry{Version: m.AgentVersion, SHA256: m.AgentSHA256},
|
||
Golden: artifactEntry{Version: m.GoldenVersion, SHA256: m.GoldenSHA256},
|
||
}
|
||
h.logger.Printf("[INFO] Artifact manifest served for customer %s (agent=%s golden=%s)", customerID, m.AgentVersion, m.GoldenVersion)
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
json.NewEncoder(w).Encode(resp)
|
||
}
|
||
|
||
// 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)
|
||
}
|