hub v0.3.5: Recovery endpoint + customer_blocked in report response
- New GET /api/v1/recovery/{customer_id}: returns generated controller.yaml
and infra backup in a single response for disaster recovery.
Auth via X-Retrieval-Password header.
- Report response now includes customer_blocked: true when customer
status is "blocked" — controllers use this to detect standing.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,12 @@
|
|||||||
# Felhom Hub — Changelog
|
# 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)
|
## v0.3.4 (2026-02-20)
|
||||||
|
|
||||||
- Rename version labels: "Current version" → "Controller version", "Latest version" → "Registry latest".
|
- Rename version labels: "Current version" → "Controller version", "Latest version" → "Registry latest".
|
||||||
|
|||||||
+27
-5
@@ -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.
|
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:**
|
**Disaster recovery flow:**
|
||||||
1. Customer's system drive fails → replaced with fresh Debian install
|
1. Customer's system drive fails → replaced with fresh Debian install
|
||||||
2. `docker-setup.sh` deploys controller with Hub details (customer_id + API key)
|
2. `docker-setup.sh` deploys controller with minimal config (domain only)
|
||||||
3. Controller detects fresh deployment → calls `GET /api/v1/infra-backup/{customer_id}`
|
3. Controller enters setup wizard → user chooses restore from local drive or Hub
|
||||||
4. Controller uses disk UUIDs to auto-mount surviving drives
|
4. For Hub restore: calls `GET /api/v1/recovery/{customer_id}` (gets config + infra backup)
|
||||||
5. Controller restores apps from local backups on those drives
|
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
|
### Events
|
||||||
|
|
||||||
|
|||||||
@@ -109,6 +109,9 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
} else {
|
} else {
|
||||||
h.handleCustomer(w, r, customerID)
|
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/"):
|
case r.Method == http.MethodGet && strings.HasPrefix(path, "/config/"):
|
||||||
customerID := strings.TrimPrefix(path, "/config/")
|
customerID := strings.TrimPrefix(path, "/config/")
|
||||||
h.handleConfigRetrieve(w, r, customerID)
|
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))
|
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.WriteHeader(http.StatusOK)
|
||||||
w.Write([]byte(`{"status":"ok"}`))
|
json.NewEncoder(w).Encode(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// allowedEventTypes lists all valid event_type values the Hub accepts.
|
// 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)
|
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.
|
// handleConfigRetrieve returns a generated controller.yaml for a customer.
|
||||||
// Auth: X-Retrieval-Password header (not Bearer token).
|
// Auth: X-Retrieval-Password header (not Bearer token).
|
||||||
func (h *Handler) handleConfigRetrieve(w http.ResponseWriter, r *http.Request, customerID string) {
|
func (h *Handler) handleConfigRetrieve(w http.ResponseWriter, r *http.Request, customerID string) {
|
||||||
|
|||||||
Reference in New Issue
Block a user