hub v0.26.0: pull-based config delivery + retire inbound GUI controls

config_version counter (bumped on every config save) advertised in the report
ACK; controller re-pulls + self-restarts on a change. Retire Trigger Update /
Push Config / Pull Config / Show Diff handlers+routes+buttons and the inbound
geo-notify (keep hub->Cloudflare geo removal). Setup command -> host-install;
delete dead customer.html + config_detail.html. Closes AUDIT-hub-gui F-S1/F-S4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HxLA1mZurFq9kt8hneFeCs
This commit is contained in:
2026-06-30 21:49:33 +02:00
parent e51e03bd7b
commit a3ac6c9488
10 changed files with 226 additions and 1247 deletions
-100
View File
@@ -8,7 +8,6 @@ import (
"encoding/json"
"fmt"
"html/template"
"io"
"log"
"math"
"net/http"
@@ -174,14 +173,6 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.handleAppDetail(w, r, appName)
case path == "/login":
s.handleLogin(w, r)
case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/trigger-update"):
customerID := strings.TrimPrefix(path, "/customers/")
customerID = strings.TrimSuffix(customerID, "/trigger-update")
if r.Method == http.MethodPost {
s.handleTriggerUpdate(w, r, customerID)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/block"):
customerID := strings.TrimPrefix(path, "/customers/")
customerID = strings.TrimSuffix(customerID, "/block")
@@ -206,30 +197,6 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/push-config"):
customerID := strings.TrimPrefix(path, "/customers/")
customerID = strings.TrimSuffix(customerID, "/push-config")
if r.Method == http.MethodPost {
s.handlePushConfig(w, r, customerID)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/pull-config"):
customerID := strings.TrimPrefix(path, "/customers/")
customerID = strings.TrimSuffix(customerID, "/pull-config")
if r.Method == http.MethodPost {
s.handlePullConfig(w, r, customerID)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/config-diff"):
customerID := strings.TrimPrefix(path, "/customers/")
customerID = strings.TrimSuffix(customerID, "/config-diff")
if r.Method == http.MethodGet {
s.handleConfigDiff(w, r, customerID)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/floor"):
customerID := strings.TrimPrefix(path, "/customers/")
customerID = strings.TrimSuffix(customerID, "/floor")
@@ -522,73 +489,6 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
}
}
func (s *Server) handleTriggerUpdate(w http.ResponseWriter, r *http.Request, customerID string) {
customer, err := s.store.GetCustomer(customerID)
if err != nil {
s.logger.Printf("[ERROR] Trigger update — get customer %s: %v", customerID, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
if customer == nil {
http.NotFound(w, r)
return
}
// Get controller URL — from denormalized field or report JSON fallback
controllerURL := customer.ControllerURL
if controllerURL == "" {
var rpt struct {
ControllerURL string `json:"controller_url"`
}
json.Unmarshal([]byte(customer.ReportJSON), &rpt)
controllerURL = rpt.ControllerURL
}
if controllerURL == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"ok":false,"error":"Controller URL not available — waiting for next report"}`))
return
}
if s.apiKey == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`{"ok":false,"error":"API key not configured"}`))
return
}
// POST to controller's self-update endpoint
updateURL := controllerURL + "/api/selfupdate/update"
req, err := http.NewRequest("POST", updateURL, nil)
if err != nil {
s.logger.Printf("[ERROR] Trigger update — create request for %s: %v", updateURL, err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`{"ok":false,"error":"Failed to create request"}`))
return
}
req.Header.Set("Authorization", "Bearer "+s.apiKey)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
s.logger.Printf("[ERROR] Trigger update — request to %s failed: %v", updateURL, err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadGateway)
json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": fmt.Sprintf("Controller unreachable: %v", err)})
return
}
defer resp.Body.Close()
// Forward the controller's response
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
s.logger.Printf("[INFO] Trigger update for %s — controller responded %d: %s", customerID, resp.StatusCode, string(body))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
w.Write(body)
}
// compareVersions returns >0 if a > b, 0 if equal, <0 if a < b.
// Accepts "X.Y.Z" format. Returns 0 on parse error.
func compareVersions(a, b string) int {