diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index ce259f4..8913433 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,5 +1,12 @@ # Felhom Hub — Changelog +## v0.3.5 (2026-02-21) + +**Recovery Endpoint & Customer Standing** + +- New `GET /api/v1/recovery/{customer_id}` endpoint: returns both generated controller.yaml and infra backup in a single response for disaster recovery. Auth via `X-Retrieval-Password` header (same as config retrieval). +- Report response now includes `customer_blocked: true` when customer status is "blocked" — allows controllers to detect standing and enter limited mode. + ## v0.3.4 (2026-02-20) - Rename version labels: "Current version" → "Controller version", "Latest version" → "Registry latest". diff --git a/hub/README.md b/hub/README.md index 9858ea1..b60df8c 100644 --- a/hub/README.md +++ b/hub/README.md @@ -4,7 +4,7 @@ A lightweight Go service that receives periodic reports and structured events from felhom-controller instances, stores them in SQLite, and provides a web dashboard for fleet monitoring. Also serves as the infrastructure backup store for disaster recovery, event-based dead man's switch monitoring, and notification dispatch. -**Current version: v0.3.4** +**Current version: v0.3.5** --- @@ -67,10 +67,32 @@ The infra-backup payload contains everything needed to restore a customer deploy **Disaster recovery flow:** 1. Customer's system drive fails → replaced with fresh Debian install -2. `docker-setup.sh` deploys controller with Hub details (customer_id + API key) -3. Controller detects fresh deployment → calls `GET /api/v1/infra-backup/{customer_id}` -4. Controller uses disk UUIDs to auto-mount surviving drives -5. Controller restores apps from local backups on those drives +2. `docker-setup.sh` deploys controller with minimal config (domain only) +3. Controller enters setup wizard → user chooses restore from local drive or Hub +4. For Hub restore: calls `GET /api/v1/recovery/{customer_id}` (gets config + infra backup) +5. Controller uses disk UUIDs to auto-mount surviving drives +6. Controller restores apps from local backups on those drives + +### Recovery (Disaster Recovery) + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/v1/recovery/{customer_id}` | Combined recovery: returns generated controller.yaml + infra backup in one response | + +Auth: `X-Retrieval-Password` header (same per-customer password as config retrieval). Response: +```json +{ + "customer_id": "example", + "config_yaml": "customer:\n id: example\n ...", + "infra_backup": { ... }, + "has_infra_backup": true +} +``` +If no infra backup exists yet, `infra_backup` is null and `has_infra_backup` is false. + +### Report Response + +The `POST /api/v1/report` response now includes `customer_blocked: true` when the customer's status is "blocked". Controllers use this to detect their standing and enter limited mode after a grace period. ### Events diff --git a/hub/internal/api/handler.go b/hub/internal/api/handler.go index 0f300ab..2a26fea 100644 --- a/hub/internal/api/handler.go +++ b/hub/internal/api/handler.go @@ -109,6 +109,9 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } 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) @@ -152,8 +155,17 @@ func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) { } h.logger.Printf("[INFO] Received report from %s (%d bytes)", payload.CustomerID, len(body)) + + // Build response with optional customer_blocked flag + resp := map[string]interface{}{"status": "ok"} + if custCfg, err := h.store.GetCustomerConfig(payload.CustomerID); err == nil && custCfg != nil { + if custCfg.Status == "blocked" { + resp["customer_blocked"] = true + } + } + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"status":"ok"}`)) + json.NewEncoder(w).Encode(resp) } // allowedEventTypes lists all valid event_type values the Hub accepts. @@ -552,6 +564,73 @@ func (h *Handler) handleInfraBackupGet(w http.ResponseWriter, r *http.Request, c w.Write(data) } +// handleRecovery returns both the generated controller.yaml and the infra backup for disaster recovery. +// Auth: X-Retrieval-Password header (same as config retrieval). +func (h *Handler) handleRecovery(w http.ResponseWriter, r *http.Request, customerID string) { + if customerID == "" { + http.Error(w, "Missing customer_id", http.StatusBadRequest) + return + } + + password := r.Header.Get("X-Retrieval-Password") + if password == "" { + http.Error(w, "Unauthorized: X-Retrieval-Password header required", http.StatusUnauthorized) + return + } + + cfg, err := h.store.GetCustomerConfig(customerID) + if err != nil { + h.logger.Printf("[ERROR] Recovery: failed to get customer config for %s: %v", customerID, err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + if cfg == nil { + http.Error(w, "Not found", http.StatusNotFound) + return + } + + if subtle.ConstantTimeCompare([]byte(password), []byte(cfg.RetrievalPassword)) != 1 { + http.Error(w, "Unauthorized: invalid password", http.StatusUnauthorized) + return + } + + // Generate controller.yaml + var configYAML string + if h.templateProvider != nil { + yamlOutput, err := configgen.Generate(h.templateProvider.Template(), cfg) + if err != nil { + h.logger.Printf("[ERROR] Recovery: failed to generate config for %s: %v", customerID, err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + configYAML = yamlOutput + } + + // Fetch infra backup (optional — may not exist for new customers) + var infraBackup json.RawMessage + hasInfraBackup := false + if data, err := h.store.GetInfraBackup(customerID); err == nil && data != nil { + infraBackup = data + hasInfraBackup = true + } + + resp := struct { + CustomerID string `json:"customer_id"` + ConfigYAML string `json:"config_yaml"` + InfraBackup json.RawMessage `json:"infra_backup"` + HasInfraBackup bool `json:"has_infra_backup"` + }{ + CustomerID: customerID, + ConfigYAML: configYAML, + InfraBackup: infraBackup, + HasInfraBackup: hasInfraBackup, + } + + h.logger.Printf("[INFO] Recovery data downloaded for customer %s (has_infra_backup=%v)", customerID, hasInfraBackup) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + // handleConfigRetrieve returns a generated controller.yaml for a customer. // Auth: X-Retrieval-Password header (not Bearer token). func (h *Handler) handleConfigRetrieve(w http.ResponseWriter, r *http.Request, customerID string) {