From a3ac6c9488ba9b4dc26b3c6ee1507b0ecf6f9d00 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Tue, 30 Jun 2026 21:49:33 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01HxLA1mZurFq9kt8hneFeCs --- hub/CHANGELOG.md | 39 ++ hub/internal/api/config_version_ack_test.go | 71 +++ hub/internal/api/handler.go | 5 + hub/internal/store/config_version_test.go | 59 +++ hub/internal/store/store.go | 39 +- hub/internal/web/configs.go | 483 +----------------- hub/internal/web/server.go | 100 ---- hub/internal/web/templates/config_detail.html | 155 ------ hub/internal/web/templates/customer.html | 323 ------------ .../web/templates/customer_unified.html | 199 +------- 10 files changed, 226 insertions(+), 1247 deletions(-) create mode 100644 hub/internal/api/config_version_ack_test.go create mode 100644 hub/internal/store/config_version_test.go delete mode 100644 hub/internal/web/templates/config_detail.html delete mode 100644 hub/internal/web/templates/customer.html diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index 6fe221f..f92ad1e 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,5 +1,44 @@ # Felhom Hub — Changelog +## v0.26.0 — pull-based config delivery + retire the inbound GUI controls (2026-06-30) + +Closes audit `documentation/audits/AUDIT-hub-gui-2026-06-30.md` F-S1/F-S4 + the dead-template findings, +and replaces the never-inbound-violating "Push Config" with a pull-based config-refresh that rides the +report ACK (companion controller change: felhom-controller v0.94.0). + +- **Config delivery is now pull-based (`internal/store/store.go`, `internal/api/handler.go`).** New + `customer_configs.config_version` column — a **stored counter** (NOT a hash of the rendered YAML; + `configgen` emits a fresh `web.session_secret` + timestamp every call, so a content hash would change + spuriously). `SaveCustomerConfig` **bumps it on every save** (new rows seed at 1, updates increment) — + the one path that changes the generated `controller.yaml` (identity + the `config_json` overrides). The + floor, block/unblock, and retrieval-password regen deliberately do NOT bump it. The report ACK + (`handleReport`) now advertises `config_version` beside `min_controller_version`/`latest_version`; the + controller compares it to its last-applied version and re-pulls + self-restarts on a change. Omitted for + report-only (no-config) customers, so an old controller is unaffected. +- **Retired the five inbound (hub→box) controls** that violated the never-inbound posture + (`01-topology-and-trust.md:11`) and were broken behind the box's CF tunnel/NAT: + - **Trigger Update** — handler + route deleted; controller updates are agent-driven (the version floor). + - **Push Config** — handler + route deleted; replaced by the pull-based config-refresh above. + - **Pull Config** — handler + route deleted. + - **Show Diff** (`handleConfigDiff` + the `compareYAMLValues`/`flattenYAML`/`maskSensitive` helpers) — + deleted, along with the now-dead `ConfigSyncStatus`/`ConfigDiffCount` plumbing. + - **Geo-disable** — KEEPS its legitimate hub→Cloudflare WAF-rule removal (`RemoveGeoRules`); the + secondary inbound `notifyControllerGeoDisable` is deleted. After this, `grep client.Do + internal/web/` has **zero** ControllerURL targets (only Gitea registry/template fetches remain; the + ControllerURL is still shown as a display-only link). +- **GUI staleness (F-S1) + dead templates:** the customer page's Setup Commands now show the Proxmox + Day-0 host bootstrap (`sudo ./felhom-host-install.sh --customer-id `, passphrase at the no-echo + prompt) instead of the pre-Proxmox `docker-setup.sh`; Option 2 relabelled "Manual config fetch (debug + only)". Deleted the orphaned `customer.html` + `config_detail.html` (rendered by nothing; `/configs/{id}` + redirects to `/customers/{id}`). +- **Audit doc (deferred line):** the GUI audit `documentation/audits/AUDIT-hub-gui-2026-06-30.md` (committed + `e51e03b`) is the grounding for the above; its F-S1/F-S4 + dead-template findings are now resolved. Open + follow-ups noted there remain: the Hosts page (F-M1), controller-side geo intent sync, and Show-Diff + could return later as a read-only-vs-reported view. +- Tests: store `config_version` bump (create=1, edits increment, per-customer independent) + the + no-bump red-proof; ACK carries `config_version` and omits it for report-only customers + the no-bump + red-proof. `go build/vet/test ./...` green. + ## v0.25.0 — per-storage worst-fill alerting (StorageFillChecker) (2026-06-30) Generalizes the host-root disk alert (v0.23.0) to ANY reported storage target — so a dedicated diff --git a/hub/internal/api/config_version_ack_test.go b/hub/internal/api/config_version_ack_test.go new file mode 100644 index 0000000..c818a5b --- /dev/null +++ b/hub/internal/api/config_version_ack_test.go @@ -0,0 +1,71 @@ +package api + +import ( + "encoding/json" + "net/http" + "testing" + + "gitea.dooplex.hu/admin/felhom-hub/internal/store" +) + +// The report ACK advertises the per-customer config_version (v0.26.0). The controller compares it +// against its last-applied version and re-pulls + self-restarts on a change. +func TestReportACK_ConfigVersion(t *testing.T) { + h, st, _ := newTestHandler(t) + + if err := st.SaveCustomerConfig(&store.CustomerConfig{ + CustomerID: "c", RetrievalPassword: "pw", APIKey: "k", ConfigJSON: "{}", + }); err != nil { + t.Fatalf("SaveCustomerConfig: %v", err) + } + + // First report: ACK carries the baseline version (1). + rr := do(h, http.MethodPost, "/report", globalKey, `{"customer_id":"c"}`) + if rr.Code != http.StatusOK { + t.Fatalf("report status = %d, want 200 (body=%s)", rr.Code, rr.Body.String()) + } + var ack map[string]interface{} + if err := json.Unmarshal(rr.Body.Bytes(), &ack); err != nil { + t.Fatalf("decode ACK: %v", err) + } + cv, ok := ack["config_version"] + if !ok { + t.Fatalf("ACK missing config_version for a config-managed customer") + } + if cv.(float64) != 1 { + t.Errorf("ACK config_version = %v, want 1 (baseline)", cv) + } + + // Edit the config → the version bumps → the next ACK carries the new version. This is what makes + // the box re-pull + restart. RED-PROOF: if SaveCustomerConfig did NOT bump (the bump is dropped), + // the ACK would still report 1 here and the box would never converge — this assertion fails. + if err := st.SaveCustomerConfig(&store.CustomerConfig{ + CustomerID: "c", RetrievalPassword: "pw", APIKey: "k", ConfigJSON: `{"git":{"username":"x"}}`, + }); err != nil { + t.Fatalf("SaveCustomerConfig (edit): %v", err) + } + rr = do(h, http.MethodPost, "/report", globalKey, `{"customer_id":"c"}`) + if rr.Code != http.StatusOK { + t.Fatalf("report status = %d, want 200", rr.Code) + } + json.Unmarshal(rr.Body.Bytes(), &ack) + if ack["config_version"].(float64) != 2 { + t.Errorf("ACK config_version after edit = %v, want 2", ack["config_version"]) + } +} + +// A report-only customer (no config row) gets no config_version field — it has nothing to pull, and +// an old controller that ignores the field behaves exactly as before. +func TestReportACK_ConfigVersionOmittedWhenNoConfig(t *testing.T) { + h, _, _ := newTestHandler(t) + + rr := do(h, http.MethodPost, "/report", globalKey, `{"customer_id":"nobody"}`) + if rr.Code != http.StatusOK { + t.Fatalf("report status = %d, want 200", rr.Code) + } + var ack map[string]interface{} + json.Unmarshal(rr.Body.Bytes(), &ack) + if _, ok := ack["config_version"]; ok { + t.Errorf("ACK should omit config_version for a report-only (no config) customer; got %v", ack["config_version"]) + } +} diff --git a/hub/internal/api/handler.go b/hub/internal/api/handler.go index f7ac4c8..92b9b4d 100644 --- a/hub/internal/api/handler.go +++ b/hub/internal/api/handler.go @@ -291,6 +291,11 @@ func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) { 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 } // Phase 2 managed updates: advertise the effective controller-version FLOOR (per-customer override diff --git a/hub/internal/store/config_version_test.go b/hub/internal/store/config_version_test.go new file mode 100644 index 0000000..fdac772 --- /dev/null +++ b/hub/internal/store/config_version_test.go @@ -0,0 +1,59 @@ +package store + +import ( + "io" + "log" + "path/filepath" + "testing" +) + +// SaveCustomerConfig bumps config_version on every save: a new row seeds at 1, each subsequent save +// increments. This counter is the signal the controller's pull-based config-refresh keys off. +func TestSaveCustomerConfig_BumpsConfigVersion(t *testing.T) { + s, err := New(filepath.Join(t.TempDir(), "cv.db"), log.New(io.Discard, "", 0)) + if err != nil { + t.Fatalf("store.New: %v", err) + } + t.Cleanup(func() { s.Close() }) + + save := func(json string) { + t.Helper() + if err := s.SaveCustomerConfig(&CustomerConfig{ + CustomerID: "c", RetrievalPassword: "pw", APIKey: "k", ConfigJSON: json, + }); err != nil { + t.Fatalf("SaveCustomerConfig: %v", err) + } + } + get := func() int { + t.Helper() + cfg, err := s.GetCustomerConfig("c") + if err != nil || cfg == nil { + t.Fatalf("GetCustomerConfig: %v (cfg=%v)", err, cfg) + } + return cfg.ConfigVersion + } + + save("{}") + if v := get(); v != 1 { + t.Fatalf("after create config_version = %d, want 1", v) + } + save(`{"git":{"username":"a"}}`) + if v := get(); v != 2 { + t.Fatalf("after first edit config_version = %d, want 2", v) + } + save(`{"git":{"username":"b"}}`) + if v := get(); v != 3 { + t.Fatalf("after second edit config_version = %d, want 3", v) + } + + // A second, independent customer starts its own counter at 1 (not affected by c's bumps). + if err := s.SaveCustomerConfig(&CustomerConfig{ + CustomerID: "d", RetrievalPassword: "pw", APIKey: "k2", ConfigJSON: "{}", + }); err != nil { + t.Fatalf("SaveCustomerConfig(d): %v", err) + } + cfgD, _ := s.GetCustomerConfig("d") + if cfgD.ConfigVersion != 1 { + t.Errorf("new customer d config_version = %d, want 1 (per-customer counter)", cfgD.ConfigVersion) + } +} diff --git a/hub/internal/store/store.go b/hub/internal/store/store.go index c993018..c3012d6 100644 --- a/hub/internal/store/store.go +++ b/hub/internal/store/store.go @@ -132,6 +132,14 @@ func (s *Store) migrate() error { // config). Idempotent. s.db.Exec("ALTER TABLE customer_configs ADD COLUMN min_controller_version TEXT NOT NULL DEFAULT ''") + // v0.26.0: per-customer config_version — a monotonic counter bumped on every config save. The + // report ACK advertises it; the controller compares it against its last-applied version and, on a + // change, re-pulls controller.yaml + self-restarts (pull-based config delivery — no inbound). It is + // a STORED COUNTER, never a hash of the rendered YAML (configgen emits a fresh session_secret + + // timestamp every call, so a content hash would change spuriously). Idempotent. Existing rows seed + // at 1, so an already-running box records that as its baseline on its next report without restarting. + s.db.Exec("ALTER TABLE customer_configs ADD COLUMN config_version INTEGER NOT NULL DEFAULT 1") + // v0.15.0: hub_settings — a tiny key/value table for operator-set globals that must survive // restarts (currently only the global controller-version floor). The config/env DEFAULT_MIN_ // CONTROLLER_VERSION is the FALLBACK; a row here (set via the operator UI) overrides it. @@ -718,16 +726,24 @@ type CustomerConfig struct { // MinControllerVersion is the per-customer minimum controller version (managed-update FLOOR // override). Empty = use the global default. Set/cleared via the operator UI. MinControllerVersion string - CreatedAt time.Time - UpdatedAt time.Time + // ConfigVersion is the monotonic config counter (bumped on every SaveCustomerConfig). The report + // ACK advertises it; the controller re-pulls + self-restarts when it changes. Never a YAML hash. + ConfigVersion int + CreatedAt time.Time + UpdatedAt time.Time } -// SaveCustomerConfig creates or updates a customer configuration. +// SaveCustomerConfig creates or updates a customer configuration. Every save BUMPS config_version +// (new rows start at 1; updates increment) — this is the load-bearing signal that drives the +// controller's pull-based config-refresh via the report ACK. Bumping here covers every field that +// feeds the generated controller.yaml (identity + the config_json overrides). NOTE: the floor +// (min_controller_version), block/unblock status, and retrieval-password regen are deliberately NOT +// config.yaml content and intentionally do NOT bump it (they have their own signals or none). func (s *Store) SaveCustomerConfig(cfg *CustomerConfig) error { _, err := s.db.Exec(` INSERT INTO customer_configs (customer_id, customer_name, domain, email, - retrieval_password, api_key, config_json, min_controller_version, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now')) + retrieval_password, api_key, config_json, min_controller_version, config_version, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, datetime('now')) ON CONFLICT(customer_id) DO UPDATE SET customer_name = excluded.customer_name, domain = excluded.domain, @@ -736,6 +752,7 @@ func (s *Store) SaveCustomerConfig(cfg *CustomerConfig) error { api_key = excluded.api_key, config_json = excluded.config_json, min_controller_version = excluded.min_controller_version, + config_version = customer_configs.config_version + 1, updated_at = datetime('now')`, cfg.CustomerID, cfg.CustomerName, cfg.Domain, cfg.Email, cfg.RetrievalPassword, cfg.APIKey, cfg.ConfigJSON, cfg.MinControllerVersion, @@ -749,12 +766,12 @@ func (s *Store) GetCustomerConfig(customerID string) (*CustomerConfig, error) { var createdAt, updatedAt string err := s.db.QueryRow(` SELECT customer_id, customer_name, domain, email, - retrieval_password, api_key, config_json, status, min_controller_version, created_at, updated_at + retrieval_password, api_key, config_json, status, min_controller_version, config_version, created_at, updated_at FROM customer_configs WHERE customer_id = ?`, customerID, ).Scan(&cfg.CustomerID, &cfg.CustomerName, &cfg.Domain, &cfg.Email, &cfg.RetrievalPassword, &cfg.APIKey, &cfg.ConfigJSON, &cfg.Status, &cfg.MinControllerVersion, - &createdAt, &updatedAt) + &cfg.ConfigVersion, &createdAt, &updatedAt) if err == sql.ErrNoRows { return nil, nil } @@ -770,7 +787,7 @@ func (s *Store) GetCustomerConfig(customerID string) (*CustomerConfig, error) { func (s *Store) ListCustomerConfigs() ([]CustomerConfig, error) { rows, err := s.db.Query(` SELECT customer_id, customer_name, domain, email, - retrieval_password, api_key, config_json, status, min_controller_version, created_at, updated_at + retrieval_password, api_key, config_json, status, min_controller_version, config_version, created_at, updated_at FROM customer_configs ORDER BY customer_id`) if err != nil { return nil, err @@ -783,7 +800,7 @@ func (s *Store) ListCustomerConfigs() ([]CustomerConfig, error) { var createdAt, updatedAt string if err := rows.Scan(&cfg.CustomerID, &cfg.CustomerName, &cfg.Domain, &cfg.Email, &cfg.RetrievalPassword, &cfg.APIKey, &cfg.ConfigJSON, &cfg.Status, &cfg.MinControllerVersion, - &createdAt, &updatedAt); err != nil { + &cfg.ConfigVersion, &createdAt, &updatedAt); err != nil { return nil, err } cfg.CreatedAt = parseSQLiteTime(createdAt) @@ -806,12 +823,12 @@ func (s *Store) GetCustomerConfigByAPIKey(apiKey string) (*CustomerConfig, error var createdAt, updatedAt string err := s.db.QueryRow(` SELECT customer_id, customer_name, domain, email, - retrieval_password, api_key, config_json, status, min_controller_version, created_at, updated_at + retrieval_password, api_key, config_json, status, min_controller_version, config_version, created_at, updated_at FROM customer_configs WHERE api_key = ?`, apiKey, ).Scan(&cfg.CustomerID, &cfg.CustomerName, &cfg.Domain, &cfg.Email, &cfg.RetrievalPassword, &cfg.APIKey, &cfg.ConfigJSON, &cfg.Status, &cfg.MinControllerVersion, - &createdAt, &updatedAt) + &cfg.ConfigVersion, &createdAt, &updatedAt) if err == sql.ErrNoRows { return nil, nil } diff --git a/hub/internal/web/configs.go b/hub/internal/web/configs.go index 867dbb7..65c3209 100644 --- a/hub/internal/web/configs.go +++ b/hub/internal/web/configs.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "html/template" - "io" "net/http" "regexp" "sort" @@ -14,7 +13,6 @@ import ( cfClient "gitea.dooplex.hu/admin/felhom-hub/internal/cloudflare" "gitea.dooplex.hu/admin/felhom-hub/internal/configgen" "gitea.dooplex.hu/admin/felhom-hub/internal/store" - "gopkg.in/yaml.v3" ) var validCustomerID = regexp.MustCompile(`^[a-zA-Z0-9.\-]+$`) @@ -229,14 +227,6 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c } } - // Config drift badge: the at-rest comparison source (infra-backup) was retired - // 2026-06-16. With no stored controller.yaml to diff against, the passive badge is - // left empty (the template hides it when ConfigSyncStatus == ""). The live "Show - // Diff" path (handleCompareConfig, which fetches the controller's config over HTTP) - // is unaffected and remains the way to check drift on demand. - var configSyncStatus string // "" hides the badge; "in_sync"/"mismatch" reserved for a future live source - var configDiffCount int - // Version check var latestVersion string var updateAvailable bool @@ -304,9 +294,6 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c EffectiveFloor string // override else global ("" = no floor) BelowFloor bool // current < effective floor (would auto-update) - ConfigSyncStatus string // "in_sync", "mismatch", "unknown" - ConfigDiffCount int - NotifPrefs *store.NotificationPrefs RecentNotifications []store.NotificationLogEntry History []store.CustomerSummary @@ -364,9 +351,6 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c EffectiveFloor: effectiveFloor, BelowFloor: belowFloor, - ConfigSyncStatus: configSyncStatus, - ConfigDiffCount: configDiffCount, - NotifPrefs: notifPrefs, RecentNotifications: recentNotifs, History: history, @@ -718,81 +702,6 @@ func (s *Server) handleSetCustomerFloor(w http.ResponseWriter, r *http.Request, http.Redirect(w, r, "/customers/"+customerID+"?flash=floor_set", http.StatusSeeOther) } -// handlePushConfig sends the generated YAML config to the controller. -func (s *Server) handlePushConfig(w http.ResponseWriter, r *http.Request, customerID string) { - cfg, err := s.store.GetCustomerConfig(customerID) - if err != nil || cfg == nil { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusNotFound) - w.Write([]byte(`{"ok":false,"error":"No config found for this customer"}`)) - return - } - - // Get controller URL from latest report - customer, _ := s.store.GetCustomer(customerID) - controllerURL := "" - if customer != nil { - 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 first report"}`)) - return - } - - // Generate YAML - templateYAML := defaultControllerTemplate - if s.templateFetcher != nil { - templateYAML = s.templateFetcher.Template() - } - yamlOutput, err := configgen.Generate(templateYAML, cfg) - if err != nil { - s.logger.Printf("[ERROR] Failed to generate config for push to %s: %v", customerID, err) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(`{"ok":false,"error":"Failed to generate config"}`)) - return - } - - // POST to controller - pushURL := controllerURL + "/api/config/apply" - req, err := http.NewRequest("POST", pushURL, strings.NewReader(yamlOutput)) - if err != nil { - 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) - req.Header.Set("Content-Type", "text/yaml") - - client := &http.Client{Timeout: 30 * time.Second} - resp, err := client.Do(req) - if err != nil { - s.logger.Printf("[ERROR] Push config to %s failed: %v", pushURL, 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() - - body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16)) - s.logger.Printf("[INFO] Push config to %s — controller responded %d: %s", customerID, resp.StatusCode, string(body)) - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(resp.StatusCode) - w.Write(body) -} - // handleCreateConfigFromReport auto-creates a config entry from report data. func (s *Server) handleCreateConfigFromReport(w http.ResponseWriter, r *http.Request, customerID string) { // Check if config already exists @@ -886,334 +795,12 @@ func buildConfigJSON(r *http.Request) string { return string(data) } -// --- Config comparison helpers (used by the live "Show Diff" handler) --- - -// volatileKeys are YAML keys ignored during config comparison (always differ or deprecated). -var volatileKeys = map[string]bool{ - "web.session_secret": true, -} - -// sensitiveKeyParts are substrings that indicate a value should be masked in diff output. -var sensitiveKeyParts = []string{"token", "password", "secret", "api_key"} - -// flattenYAML recursively flattens a nested map into dot-separated key-value pairs. -func flattenYAML(m map[string]interface{}, prefix string) map[string]string { - result := make(map[string]string) - for k, v := range m { - key := k - if prefix != "" { - key = prefix + "." + k - } - switch val := v.(type) { - case map[string]interface{}: - for fk, fv := range flattenYAML(val, key) { - result[fk] = fv - } - case []interface{}: - for i, item := range val { - itemKey := fmt.Sprintf("%s.%d", key, i) - if sub, ok := item.(map[string]interface{}); ok { - for fk, fv := range flattenYAML(sub, itemKey) { - result[fk] = fv - } - } else { - result[itemKey] = fmt.Sprintf("%v", item) - } - } - default: - result[key] = fmt.Sprintf("%v", v) - } - } - return result -} - -// configDiff represents a single key-value difference between two configs. -type configDiff struct { - Key string `json:"key"` - HubValue string `json:"hub"` - CtrlValue string `json:"controller"` - Status string `json:"status"` // "changed", "hub_only", "controller_only" -} - -// compareYAMLValues parses two YAML strings and returns their value differences. -// Volatile keys (e.g., web.session_secret) are excluded. -func compareYAMLValues(hubYAML, controllerYAML string) []configDiff { - var hubMap, ctrlMap map[string]interface{} - yaml.Unmarshal([]byte(hubYAML), &hubMap) - yaml.Unmarshal([]byte(controllerYAML), &ctrlMap) - - hubFlat := flattenYAML(hubMap, "") - ctrlFlat := flattenYAML(ctrlMap, "") - - var diffs []configDiff - - // Keys in hub but different/missing in controller - for k, hv := range hubFlat { - if volatileKeys[k] { - continue - } - cv, exists := ctrlFlat[k] - if !exists { - if hv != "" && hv != "" { - diffs = append(diffs, configDiff{Key: k, HubValue: hv, CtrlValue: "(not set)", Status: "hub_only"}) - } - } else if hv != cv { - diffs = append(diffs, configDiff{Key: k, HubValue: hv, CtrlValue: cv, Status: "changed"}) - } - } - - // Keys in controller but missing in hub - for k, cv := range ctrlFlat { - if volatileKeys[k] { - continue - } - if _, exists := hubFlat[k]; !exists { - if cv != "" && cv != "" { - diffs = append(diffs, configDiff{Key: k, HubValue: "(not set)", CtrlValue: cv, Status: "controller_only"}) - } - } - } - - sort.Slice(diffs, func(i, j int) bool { return diffs[i].Key < diffs[j].Key }) - return diffs -} - -// maskSensitive masks a value if the key contains sensitive substrings. -func maskSensitive(key, value string) string { - if value == "" || value == "(not set)" { - return value - } - keyLower := strings.ToLower(key) - for _, part := range sensitiveKeyParts { - if strings.Contains(keyLower, part) { - if len(value) > 8 { - return "***" + value[len(value)-4:] - } - return "***" - } - } - return value -} - -// handleConfigDiff returns a JSON diff between Hub-generated and controller's live config. -func (s *Server) handleConfigDiff(w http.ResponseWriter, r *http.Request, customerID string) { - cfg, err := s.store.GetCustomerConfig(customerID) - if err != nil || cfg == nil { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "No config found"}) - return - } - - // Get controller URL - customer, _ := s.store.GetCustomer(customerID) - controllerURL := "" - if customer != nil { - 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") - json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "Controller URL not available"}) - return - } - - // Fetch live config from controller - fetchURL := controllerURL + "/api/config" - req, err := http.NewRequest("GET", fetchURL, nil) - if err != nil { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "Failed to create request"}) - return - } - req.Header.Set("Authorization", "Bearer "+s.apiKey) - - client := &http.Client{Timeout: 15 * time.Second} - resp, err := client.Do(req) - if err != nil { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": fmt.Sprintf("Controller unreachable: %v", err)}) - return - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": fmt.Sprintf("Controller returned HTTP %d", resp.StatusCode)}) - return - } - - controllerYAML, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) - if err != nil { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "Failed to read controller response"}) - return - } - - // Generate Hub YAML - templateYAML := defaultControllerTemplate - if s.templateFetcher != nil { - templateYAML = s.templateFetcher.Template() - } - hubYAML, err := configgen.Generate(templateYAML, cfg) - if err != nil { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "Failed to generate Hub config"}) - return - } - - // Compare - diffs := compareYAMLValues(hubYAML, string(controllerYAML)) - - // Mask sensitive values - for i := range diffs { - diffs[i].HubValue = maskSensitive(diffs[i].Key, diffs[i].HubValue) - diffs[i].CtrlValue = maskSensitive(diffs[i].Key, diffs[i].CtrlValue) - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "ok": true, - "in_sync": len(diffs) == 0, - "diff_count": len(diffs), - "diffs": diffs, - }) -} - -// handlePullConfig fetches the controller's live config and imports it into the Hub. -func (s *Server) handlePullConfig(w http.ResponseWriter, r *http.Request, customerID string) { - cfg, err := s.store.GetCustomerConfig(customerID) - if err != nil || cfg == nil { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "No config found"}) - return - } - - // Get controller URL - customer, _ := s.store.GetCustomer(customerID) - controllerURL := "" - if customer != nil { - 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") - json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "Controller URL not available"}) - return - } - - // Fetch live config from controller - fetchURL := controllerURL + "/api/config" - req, err := http.NewRequest("GET", fetchURL, nil) - if err != nil { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "Failed to create request"}) - return - } - req.Header.Set("Authorization", "Bearer "+s.apiKey) - - client := &http.Client{Timeout: 15 * time.Second} - resp, err := client.Do(req) - if err != nil { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": fmt.Sprintf("Controller unreachable: %v", err)}) - return - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": fmt.Sprintf("Controller returned HTTP %d", resp.StatusCode)}) - return - } - - body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) - if err != nil { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "Failed to read controller response"}) - return - } - - // Parse controller's YAML - var parsed map[string]interface{} - if err := yaml.Unmarshal(body, &parsed); err != nil { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "Failed to parse controller YAML"}) - return - } - - // Extract identity fields - if customer, ok := parsed["customer"].(map[string]interface{}); ok { - if v, ok := customer["name"].(string); ok && v != "" { - cfg.CustomerName = v - } - if v, ok := customer["domain"].(string); ok && v != "" { - cfg.Domain = v - } - if v, ok := customer["email"].(string); ok && v != "" { - cfg.Email = v - } - } - - // Build config_json from override fields - overrides := make(map[string]interface{}) - - // Infrastructure tokens - if infra, ok := parsed["infrastructure"].(map[string]interface{}); ok { - infraOverrides := make(map[string]interface{}) - if v, ok := infra["cf_tunnel_token"].(string); ok && v != "" { - infraOverrides["cf_tunnel_token"] = v - } - if v, ok := infra["cf_api_token"].(string); ok && v != "" { - infraOverrides["cf_api_token"] = v - } - if len(infraOverrides) > 0 { - overrides["infrastructure"] = infraOverrides - } - } - - // Git credentials - if git, ok := parsed["git"].(map[string]interface{}); ok { - gitOverrides := make(map[string]interface{}) - if v, ok := git["username"].(string); ok && v != "" { - gitOverrides["username"] = v - } - if v, ok := git["token"].(string); ok && v != "" { - gitOverrides["token"] = v - } - if len(gitOverrides) > 0 { - overrides["git"] = gitOverrides - } - } - - configJSON, _ := json.Marshal(overrides) - cfg.ConfigJSON = string(configJSON) - - if err := s.store.SaveCustomerConfig(cfg); err != nil { - s.logger.Printf("[ERROR] Pull config: failed to update config for %s: %v", customerID, err) - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "Failed to save config"}) - return - } - - s.logger.Printf("[INFO] Config pulled from controller for %s", customerID) - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Config imported from controller"}) -} - -// handleGeoDisable removes all [felhom-geo] WAF rules from Cloudflare for a customer, -// and notifies the controller to disable geo-restriction in its settings. +// handleGeoDisable removes all [felhom-geo] WAF rules from Cloudflare for a customer. The Cloudflare +// WAF rules ARE the geo enforcement, so removing them disables geo-restriction. This is a hub→Cloudflare +// call (NOT into the box) and stays. The old secondary inbound notify to the controller +// (notifyControllerGeoDisable) was retired in v0.26.0 to honour the never-inbound posture — the +// controller-side geo intent is left as a noted follow-up (there is no periodic re-apply, so the CF +// removal sticks). func (s *Server) handleGeoDisable(w http.ResponseWriter, r *http.Request, customerID string) { cfg, err := s.store.GetCustomerConfig(customerID) if err != nil || cfg == nil { @@ -1256,62 +843,6 @@ func (s *Server) handleGeoDisable(w http.ResponseWriter, r *http.Request, custom s.logger.Printf("[INFO] Geo disable for %s: Cloudflare WAF rules removed", customerID) - // 2. Background: notify controller to disable geo in settings (retry for up to 10 min) - customer, _ := s.store.GetCustomer(customerID) - controllerURL := "" - if customer != nil { - controllerURL = customer.ControllerURL - } - if controllerURL == "" { - var rpt struct { - ControllerURL string `json:"controller_url"` - } - if customer != nil { - json.Unmarshal([]byte(customer.ReportJSON), &rpt) - controllerURL = rpt.ControllerURL - } - } - - if controllerURL != "" && s.apiKey != "" { - go s.notifyControllerGeoDisable(customerID, controllerURL) - } else { - s.logger.Printf("[WARN] Geo disable for %s: cannot notify controller (no URL or API key)", customerID) - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Geo-restriction removed from Cloudflare. Controller will be notified."}) -} - -// notifyControllerGeoDisable retries sending geo-disable to the controller every 30s for up to 10 min. -func (s *Server) notifyControllerGeoDisable(customerID, controllerURL string) { - geoURL := controllerURL + "/api/geo/settings" - - for attempt := 1; attempt <= 20; attempt++ { - req, err := http.NewRequest("POST", geoURL, strings.NewReader(`{"enabled":false,"allowed_countries":["HU"]}`)) - if err != nil { - s.logger.Printf("[ERROR] Geo disable notify %s attempt %d: create request: %v", customerID, attempt, err) - return - } - req.Header.Set("Authorization", "Bearer "+s.apiKey) - req.Header.Set("Content-Type", "application/json") - - client := &http.Client{Timeout: 15 * time.Second} - resp, err := client.Do(req) - if err != nil { - s.logger.Printf("[WARN] Geo disable notify %s attempt %d: %v", customerID, attempt, err) - time.Sleep(30 * time.Second) - continue - } - body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16)) - resp.Body.Close() - - if resp.StatusCode >= 200 && resp.StatusCode < 300 { - s.logger.Printf("[INFO] Geo disable notify %s: controller confirmed (attempt %d): %s", customerID, attempt, string(body)) - return - } - s.logger.Printf("[WARN] Geo disable notify %s attempt %d: status %d: %s", customerID, attempt, resp.StatusCode, string(body)) - time.Sleep(30 * time.Second) - } - - s.logger.Printf("[ERROR] Geo disable notify %s: gave up after 20 attempts", customerID) + json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Geo-restriction removed from Cloudflare."}) } diff --git a/hub/internal/web/server.go b/hub/internal/web/server.go index 1ea7053..3cf2c40 100644 --- a/hub/internal/web/server.go +++ b/hub/internal/web/server.go @@ -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 { diff --git a/hub/internal/web/templates/config_detail.html b/hub/internal/web/templates/config_detail.html deleted file mode 100644 index 4f09e1c..0000000 --- a/hub/internal/web/templates/config_detail.html +++ /dev/null @@ -1,155 +0,0 @@ - - - - - - Felhom Hub — {{.Config.CustomerID}} - - - -
-
-

Felhom Hub

- -
- - ← All customers - - {{if .Flash}} -
- {{if eq .Flash "created"}}Configuration created successfully. - {{else if eq .Flash "updated"}}Configuration updated. - {{else if eq .Flash "password_regenerated"}}Retrieval password regenerated. - {{end}} -
- {{end}} - -
-

- {{.Config.CustomerID}} - {{if .Config.CustomerName}} — {{.Config.CustomerName}}{{end}} -

-
- Edit -
- -
-
-
- -
-

Customer Details

-
-
- Customer ID - {{.Config.CustomerID}} -
-
- Name - {{if .Config.CustomerName}}{{.Config.CustomerName}}{{else}}—{{end}} -
-
- Domain - {{if .Config.Domain}}{{.Config.Domain}}{{else}}—{{end}} -
-
- Email - {{if .Config.Email}}{{.Config.Email}}{{else}}—{{end}} -
-
- Created - {{timeAgo .Config.CreatedAt}} -
-
- Updated - {{timeAgo .Config.UpdatedAt}} -
-
-
- -
-

Credentials

-
-
- Retrieval Password -
- {{.Config.RetrievalPassword}} - -
-
-
- -
-
-
-
- API Key -
- {{.Config.APIKey}} - -
-
- Used by the controller for ongoing hub communication (reports, notifications, backups) -
-
- -
-

Setup Commands

-

Use one of these methods to configure a customer node:

- -

Option 1: docker-setup.sh (recommended)

-
- sudo ./docker-setup.sh --hub-customer {{.Config.CustomerID}} --hub-password {{.Config.RetrievalPassword}} - -
- -

Option 2: Direct download

-
- curl -fsSL https://hub.felhom.eu/api/v1/config/{{.Config.CustomerID}} -H "X-Retrieval-Password: {{.Config.RetrievalPassword}}" -o controller.yaml - -
-
- -
-

YAML Preview

-
-

Loading preview...

-
-
- -
-

Felhom Hub {{hubVersion}} — Customer Management

-
-
- - - - diff --git a/hub/internal/web/templates/customer.html b/hub/internal/web/templates/customer.html deleted file mode 100644 index 8b28efe..0000000 --- a/hub/internal/web/templates/customer.html +++ /dev/null @@ -1,323 +0,0 @@ - - - - - - {{.Customer.CustomerName}} — Felhom Hub - - - - -
-
- - ← Back to Dashboard -

- {{statusIcon .OverallStatus}} - {{.Customer.CustomerName}} -

-

Last report: {{timeAgo .Customer.ReceivedAt}} · Controller {{.Customer.ControllerVersion}}

-
- - -
-

System

-
- {{with .Report.system}} -
- Hostname - {{index . "hostname"}} -
-
- OS - {{index . "os"}} -
-
- Kernel - {{index . "kernel"}} -
-
- CPU - {{index . "cpu_model"}} ({{index . "cpu_cores"}} cores) -
- {{end}} -
-
-
- CPU - {{formatFloat .Customer.CPUPercent}}% -
-
-
- Memory - {{formatFloat .Customer.MemoryPercent}}% -
-
-
-
- - -
-

Storage

- {{with .Report.storage}} -
- {{range .}} -
- {{with index . "label"}}{{.}}{{else}}{{index . "mount"}}{{end}} - {{printf "%.0f" (index . "percent")}}% -
- {{printf "%.1f" (index . "used_gb")}} / {{printf "%.1f" (index . "total_gb")}} GB -
- {{end}} -
- {{end}} -
- - -
-

Containers ({{.Customer.ContainerRunning}}/{{.Customer.ContainerTotal}})

- {{with .Report.containers}} - {{$list := index . "list"}} - {{if $list}} - - - - - - - - - - - {{range $list}} - - - - - - - {{end}} - -
NameStateCPUMemory
{{index . "name"}}{{index . "state"}}{{printf "%.1f" (index . "cpu_percent")}}%{{printf "%.0f" (index . "memory_mb")}} MB
- {{end}} - {{end}} -
- - -
-

Backup

- {{with .Report.backup}} -
-
- Enabled - {{if index . "enabled"}}Yes{{else}}No{{end}} -
-
- Snapshots - {{index . "snapshot_count"}} -
-
- Repo Size - {{index . "repo_size_mb"}} MB -
-
- Integrity - {{if index . "integrity_ok"}}OK{{else}}Unknown{{end}} -
-
- {{end}} -
- - -
-

Health

- {{if eq .OverallStatus "disabled"}} -

Reporting has been disabled on this node

-

Enable it in the controller's controller.yaml: hub.enabled: true

- {{else}} - {{with .Report.health}} -

- Status: {{index . "status"}} -

- {{$issues := index . "issues"}} - {{if $issues}} -

Issues

-
    - {{range $issues}} -
  • {{.}}
  • - {{end}} -
- {{end}} - {{$warnings := index . "warnings"}} - {{if $warnings}} -

Warnings

-
    - {{range $warnings}} -
  • {{.}}
  • - {{end}} -
- {{end}} - {{end}} - {{end}} -
- - -
-

Controller Update

-
-
- Controller version - {{.Customer.ControllerVersion}} -
- {{if .LatestVersion}} -
- Registry latest - - v{{.LatestVersion}} - {{if .UpdateAvailable}} - ● update available - {{else}} - — up to date - {{end}} - -
- {{end}} - {{if .ControllerURL}} -
- Controller URL - {{.ControllerURL}} -
- {{end}} -
- {{if and .ControllerURL .UpdateAvailable}} -
- - -
- {{else if and .ControllerURL (not .LatestVersion)}} -
- - -

Registry check not configured — cannot verify if update is available

-
- {{end}} -
- - - - -
-

Notifications

-
-
- Email - {{if .NotifPrefs}}{{if .NotifPrefs.Email}}{{.NotifPrefs.Email}}{{else}}Not set{{end}}{{else}}Not configured{{end}} -
- {{if .NotifPrefs}} -
- Events - {{if .NotifPrefs.EnabledEvents}}{{joinStrings .NotifPrefs.EnabledEvents ", "}}{{else}}None{{end}} -
- {{end}} -
- {{if .RecentNotifications}} -

Recent (last 10)

- - - - - - - - - - - {{range .RecentNotifications}} - - - - - - - {{end}} - -
TimeEventStatusMessage
{{.CreatedAt.Format "Jan 02 15:04"}}{{.EventType}}{{.Status}}{{.Message}}
- {{end}} -
- - - {{if .History}} -
-

Report History (last 24h)

-
- {{len .History}} reports - - - - - - - - - - - {{range .History}} - - - - - - - {{end}} - -
TimeStatusCPUMemory
{{.ReceivedAt.Format "Jan 02 15:04"}}{{.HealthStatus}}{{formatFloat .CPUPercent}}%{{formatFloat .MemoryPercent}}%
-
-
- {{end}} - -
-

Auto-refreshes every 60 seconds · Felhom Hub {{hubVersion}}

-
-
- - diff --git a/hub/internal/web/templates/customer_unified.html b/hub/internal/web/templates/customer_unified.html index 3f8c717..dd7afa8 100644 --- a/hub/internal/web/templates/customer_unified.html +++ b/hub/internal/web/templates/customer_unified.html @@ -380,16 +380,22 @@
-

Setup Commands

-

Use one of these methods to configure a customer node:

+

Setup Command

+

+ Day-0 host bootstrap. Run on a freshly-PVE-installed Proxmox host as root + (create the customer in the hub first). It enrolls the host, installs + verifies the agent, + and provisions the guest; the in-guest controller then pulls its own controller.yaml. + The retrieval passphrase is entered at the no-echo prompt — never on the command line. +

-

Option 1: docker-setup.sh (recommended)

+

Option 1: Host install (recommended)

- sudo ./docker-setup.sh --hub-customer {{.CustomerID}} --hub-password {{.Config.RetrievalPassword}} + sudo ./felhom-host-install.sh --customer-id {{.CustomerID}}
-

Option 2: Direct download

+

Option 2: Manual config fetch (debug only)

+

The same payload the controller pulls itself — for inspection, not normal provisioning.

curl -fsSL https://hub.felhom.eu/api/v1/config/{{.CustomerID}} -H "X-Retrieval-Password: {{.Config.RetrievalPassword}}" -o controller.yaml @@ -456,43 +462,12 @@ Boxes below the effective floor auto-update on their next report. Blank clears the override. - - {{if and .HasConfig .ConfigSyncStatus}} -
- Config Sync - - {{if eq .ConfigSyncStatus "in_sync"}}✓ In sync - {{else if eq .ConfigSyncStatus "mismatch"}}⚠ Config mismatch — {{.ConfigDiffCount}} difference{{if gt .ConfigDiffCount 1}}s{{end}} - - {{else}}Unknown — use "Show Diff" to compare live - {{end}} - -
- - {{end}} -
- {{if and .ControllerURL .UpdateAvailable}} - - {{else if and .ControllerURL (not .LatestVersion)}} - - {{end}} - {{if and .HasConfig .ControllerURL}} - - - {{end}} - -
- {{if and .ControllerURL (not .LatestVersion)}} -

Registry check not configured — cannot verify if update is available

- {{end}} +

+ Controller updates are agent-driven (the version floor above) and config is delivered by the + box pulling it on a config change — the hub never connects into the box. Edit the config via + the Edit button (top of page); the controller re-pulls and restarts on its + next report. +

@@ -734,146 +709,6 @@ btn.textContent = 'Összes geo-korlátozás eltávolítása'; }); } - - function triggerControllerUpdate(customerID) { - if (!confirm('Trigger self-update on this controller?\n\nThe controller will be briefly unavailable during restart.')) return; - var btn = document.getElementById('btn-trigger-update'); - var msg = document.getElementById('action-msg'); - btn.disabled = true; - btn.textContent = 'Triggering...'; - msg.style.display = 'none'; - fetch('/customers/' + customerID + '/trigger-update', {method: 'POST', headers: csrfHeaders()}) - .then(function(r) { return r.json(); }) - .then(function(data) { - if (data.ok) { - msg.textContent = 'Update triggered — controller restarting'; - msg.style.display = 'inline'; - msg.style.color = '#4ade80'; - } else { - msg.textContent = data.error || 'Failed'; - msg.style.display = 'inline'; - msg.style.color = '#f87171'; - btn.disabled = false; - btn.textContent = 'Trigger Update'; - } - }) - .catch(function() { - msg.textContent = 'Connection error'; - msg.style.display = 'inline'; - msg.style.color = '#f87171'; - btn.disabled = false; - btn.textContent = 'Trigger Update'; - }); - } - - function pushConfig(customerID) { - if (!confirm('Push the Hub configuration to this controller?\n\nThe controller will apply the new config.')) return; - var btn = document.getElementById('btn-push-config'); - var msg = document.getElementById('action-msg'); - btn.disabled = true; - btn.textContent = 'Pushing...'; - msg.style.display = 'none'; - fetch('/customers/' + customerID + '/push-config', {method: 'POST', headers: csrfHeaders()}) - .then(function(r) { return r.json(); }) - .then(function(data) { - if (data.ok) { - msg.textContent = 'Config pushed successfully'; - msg.style.display = 'inline'; - msg.style.color = '#4ade80'; - } else { - msg.textContent = data.error || 'Failed'; - msg.style.display = 'inline'; - msg.style.color = '#f87171'; - } - btn.disabled = false; - btn.textContent = 'Push Config'; - }) - .catch(function() { - msg.textContent = 'Connection error'; - msg.style.display = 'inline'; - msg.style.color = '#f87171'; - btn.disabled = false; - btn.textContent = 'Push Config'; - }); - } - - function pullConfig(customerID) { - if (!confirm('Import the controller\'s current config into the Hub?\n\nThis updates the Hub\'s stored configuration to match the controller.')) return; - var btn = document.getElementById('btn-pull-config'); - var msg = document.getElementById('action-msg'); - btn.disabled = true; - btn.textContent = 'Pulling...'; - msg.style.display = 'none'; - fetch('/customers/' + customerID + '/pull-config', {method: 'POST', headers: csrfHeaders()}) - .then(function(r) { return r.json(); }) - .then(function(data) { - if (data.ok) { - msg.textContent = 'Config imported successfully'; - msg.style.display = 'inline'; - msg.style.color = '#4ade80'; - setTimeout(function() { location.reload(); }, 1500); - } else { - msg.textContent = data.error || 'Failed'; - msg.style.display = 'inline'; - msg.style.color = '#f87171'; - } - btn.disabled = false; - btn.textContent = 'Pull Config'; - }) - .catch(function() { - msg.textContent = 'Connection error'; - msg.style.display = 'inline'; - msg.style.color = '#f87171'; - btn.disabled = false; - btn.textContent = 'Pull Config'; - }); - } - - function showConfigDiff(customerID) { - var container = document.getElementById('config-diff-container'); - if (container.style.display !== 'none') { - container.style.display = 'none'; - return; - } - container.innerHTML = '

Loading diff...

'; - container.style.display = 'block'; - fetch('/customers/' + customerID + '/config-diff') - .then(function(r) { return r.json(); }) - .then(function(data) { - if (!data.ok) { - container.innerHTML = '

' + (data.error || 'Failed to load diff') + '

'; - return; - } - if (data.in_sync) { - container.innerHTML = '

Configs are in sync (no differences found).

'; - return; - } - var html = ''; - html += ''; - data.diffs.forEach(function(d) { - var cls = 'diff-' + d.status; - var statusLabel = d.status === 'changed' ? 'Changed' : d.status === 'hub_only' ? 'Hub only' : 'Controller only'; - html += ''; - html += ''; - html += ''; - html += ''; - html += ''; - html += ''; - }); - html += '
KeyHub ValueController ValueStatus
' + escHtml(d.key) + '' + escHtml(d.hub) + '' + escHtml(d.controller) + '' + statusLabel + '
'; - container.innerHTML = html; - }) - .catch(function() { - container.innerHTML = '

Failed to fetch diff from controller.

'; - }); - } - - function escHtml(s) { - var div = document.createElement('div'); - div.appendChild(document.createTextNode(s)); - return div.innerHTML; - } - {{if .HasConfig}} // Load YAML preview fetch('/configs/{{.CustomerID}}/preview')