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:
@@ -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 <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
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+28
-11
@@ -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
|
||||
}
|
||||
|
||||
+7
-476
@@ -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 != "<nil>" {
|
||||
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 != "<nil>" {
|
||||
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."})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Felhom Hub — {{.Config.CustomerID}}</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>Felhom Hub</h1>
|
||||
<nav class="nav-links">
|
||||
<a href="/" class="nav-link">Dashboard</a>
|
||||
<a href="/configs" class="nav-link active">Customers</a>
|
||||
<a href="/apps" class="nav-link">Apps</a>
|
||||
<a href="/configuration" class="nav-link">Configuration</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<a href="/configs" class="back-link">← All customers</a>
|
||||
|
||||
{{if .Flash}}
|
||||
<div class="flash flash-success">
|
||||
{{if eq .Flash "created"}}Configuration created successfully.
|
||||
{{else if eq .Flash "updated"}}Configuration updated.
|
||||
{{else if eq .Flash "password_regenerated"}}Retrieval password regenerated.
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||
<h2 style="margin: 0;">
|
||||
<code>{{.Config.CustomerID}}</code>
|
||||
{{if .Config.CustomerName}}<span class="text-muted" style="font-weight: 400;"> — {{.Config.CustomerName}}</span>{{end}}
|
||||
</h2>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<a href="/configs/{{.Config.CustomerID}}/edit" class="btn btn-outline">Edit</a>
|
||||
<form method="POST" action="/configs/{{.Config.CustomerID}}/delete" style="display:inline"
|
||||
onsubmit="return confirm('Delete configuration for {{.Config.CustomerID}}? This cannot be undone.')">
|
||||
<button type="submit" class="btn btn-danger">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Customer Details</h2>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="label">Customer ID</span>
|
||||
<span class="value"><code>{{.Config.CustomerID}}</code></span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Name</span>
|
||||
<span class="value">{{if .Config.CustomerName}}{{.Config.CustomerName}}{{else}}—{{end}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Domain</span>
|
||||
<span class="value">{{if .Config.Domain}}{{.Config.Domain}}{{else}}—{{end}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Email</span>
|
||||
<span class="value">{{if .Config.Email}}{{.Config.Email}}{{else}}—{{end}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Created</span>
|
||||
<span class="value">{{timeAgo .Config.CreatedAt}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Updated</span>
|
||||
<span class="value">{{timeAgo .Config.UpdatedAt}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Credentials</h2>
|
||||
<div class="credential-row">
|
||||
<div>
|
||||
<span class="label">Retrieval Password</span>
|
||||
<div class="credential-box">
|
||||
<code id="retrieval-pw">{{.Config.RetrievalPassword}}</code>
|
||||
<button type="button" class="copy-btn" onclick="copyText('retrieval-pw')" title="Copy">⎘</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="POST" action="/configs/{{.Config.CustomerID}}/regen-password" style="margin-top: 0.5rem;"
|
||||
onsubmit="return confirm('Regenerate retrieval password? The old password will stop working immediately.')">
|
||||
<button type="submit" class="btn btn-outline btn-sm">Regenerate</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="credential-row" style="margin-top: 1rem;">
|
||||
<div>
|
||||
<span class="label">API Key</span>
|
||||
<div class="credential-box">
|
||||
<code id="api-key">{{.Config.APIKey}}</code>
|
||||
<button type="button" class="copy-btn" onclick="copyText('api-key')" title="Copy">⎘</button>
|
||||
</div>
|
||||
</div>
|
||||
<span class="form-hint">Used by the controller for ongoing hub communication (reports, notifications, backups)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Setup Commands</h2>
|
||||
<p class="text-muted" style="margin-bottom: 1rem; font-size: 0.85rem;">Use one of these methods to configure a customer node:</p>
|
||||
|
||||
<h3>Option 1: docker-setup.sh (recommended)</h3>
|
||||
<div class="credential-box">
|
||||
<code id="cmd-setup">sudo ./docker-setup.sh --hub-customer {{.Config.CustomerID}} --hub-password {{.Config.RetrievalPassword}}</code>
|
||||
<button type="button" class="copy-btn" onclick="copyText('cmd-setup')" title="Copy">⎘</button>
|
||||
</div>
|
||||
|
||||
<h3 style="margin-top: 1rem;">Option 2: Direct download</h3>
|
||||
<div class="credential-box">
|
||||
<code id="cmd-curl">curl -fsSL https://hub.felhom.eu/api/v1/config/{{.Config.CustomerID}} -H "X-Retrieval-Password: {{.Config.RetrievalPassword}}" -o controller.yaml</code>
|
||||
<button type="button" class="copy-btn" onclick="copyText('cmd-curl')" title="Copy">⎘</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>YAML Preview</h2>
|
||||
<div id="yaml-preview" class="yaml-preview">
|
||||
<p class="text-muted">Loading preview...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p>Felhom Hub {{hubVersion}} — Customer Management</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function copyText(elementId) {
|
||||
const el = document.getElementById(elementId);
|
||||
const text = el.textContent || el.innerText;
|
||||
navigator.clipboard.writeText(text.trim()).then(function() {
|
||||
const btn = el.parentElement.querySelector('.copy-btn');
|
||||
const orig = btn.innerHTML;
|
||||
btn.innerHTML = '✓';
|
||||
setTimeout(function() { btn.innerHTML = orig; }, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
// Load YAML preview
|
||||
fetch('/configs/{{.Config.CustomerID}}/preview')
|
||||
.then(function(r) { return r.text(); })
|
||||
.then(function(yaml) {
|
||||
document.getElementById('yaml-preview').innerHTML = '<pre>' + yaml.replace(/&/g,'&').replace(/</g,'<') + '</pre>';
|
||||
})
|
||||
.catch(function() {
|
||||
document.getElementById('yaml-preview').innerHTML = '<p class="text-muted">Failed to load preview.</p>';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,323 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Customer.CustomerName}} — Felhom Hub</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
<meta http-equiv="refresh" content="60">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<nav class="nav-links" style="margin-bottom: 0.5rem;">
|
||||
<a href="/" class="nav-link">Dashboard</a>
|
||||
<a href="/configs" class="nav-link">Customers</a>
|
||||
<a href="/apps" class="nav-link">Apps</a>
|
||||
<a href="/configuration" class="nav-link">Configuration</a>
|
||||
</nav>
|
||||
<a href="/" class="back-link">← Back to Dashboard</a>
|
||||
<h1>
|
||||
<span class="status-dot" style="color: {{statusColor .OverallStatus}}">{{statusIcon .OverallStatus}}</span>
|
||||
{{.Customer.CustomerName}}
|
||||
</h1>
|
||||
<p class="subtitle">Last report: {{timeAgo .Customer.ReceivedAt}} · Controller {{.Customer.ControllerVersion}}</p>
|
||||
</header>
|
||||
|
||||
<!-- System Info -->
|
||||
<section class="card">
|
||||
<h2>System</h2>
|
||||
<div class="info-grid">
|
||||
{{with .Report.system}}
|
||||
<div class="info-item">
|
||||
<span class="label">Hostname</span>
|
||||
<span class="value">{{index . "hostname"}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">OS</span>
|
||||
<span class="value">{{index . "os"}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Kernel</span>
|
||||
<span class="value">{{index . "kernel"}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">CPU</span>
|
||||
<span class="value">{{index . "cpu_model"}} ({{index . "cpu_cores"}} cores)</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="metrics-grid">
|
||||
<div class="metric">
|
||||
<span class="metric-label">CPU</span>
|
||||
<span class="metric-value">{{formatFloat .Customer.CPUPercent}}%</span>
|
||||
<div class="bar"><div class="bar-fill" style="width: {{formatFloat .Customer.CPUPercent}}%"></div></div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">Memory</span>
|
||||
<span class="metric-value">{{formatFloat .Customer.MemoryPercent}}%</span>
|
||||
<div class="bar"><div class="bar-fill" style="width: {{formatFloat .Customer.MemoryPercent}}%"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Storage -->
|
||||
<section class="card">
|
||||
<h2>Storage</h2>
|
||||
{{with .Report.storage}}
|
||||
<div class="metrics-grid">
|
||||
{{range .}}
|
||||
<div class="metric">
|
||||
<span class="metric-label">{{with index . "label"}}{{.}}{{else}}{{index . "mount"}}{{end}}</span>
|
||||
<span class="metric-value">{{printf "%.0f" (index . "percent")}}%</span>
|
||||
<div class="bar"><div class="bar-fill" style="width: {{printf "%.0f" (index . "percent")}}%"></div></div>
|
||||
<span class="metric-detail">{{printf "%.1f" (index . "used_gb")}} / {{printf "%.1f" (index . "total_gb")}} GB</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<!-- Containers -->
|
||||
<section class="card">
|
||||
<h2>Containers ({{.Customer.ContainerRunning}}/{{.Customer.ContainerTotal}})</h2>
|
||||
{{with .Report.containers}}
|
||||
{{$list := index . "list"}}
|
||||
{{if $list}}
|
||||
<table class="container-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>State</th>
|
||||
<th>CPU</th>
|
||||
<th>Memory</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range $list}}
|
||||
<tr>
|
||||
<td>{{index . "name"}}</td>
|
||||
<td><span class="container-state container-state-{{index . "state"}}">{{index . "state"}}</span></td>
|
||||
<td>{{printf "%.1f" (index . "cpu_percent")}}%</td>
|
||||
<td>{{printf "%.0f" (index . "memory_mb")}} MB</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<!-- Backup -->
|
||||
<section class="card">
|
||||
<h2>Backup</h2>
|
||||
{{with .Report.backup}}
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="label">Enabled</span>
|
||||
<span class="value">{{if index . "enabled"}}Yes{{else}}No{{end}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Snapshots</span>
|
||||
<span class="value">{{index . "snapshot_count"}}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Repo Size</span>
|
||||
<span class="value">{{index . "repo_size_mb"}} MB</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Integrity</span>
|
||||
<span class="value">{{if index . "integrity_ok"}}OK{{else}}Unknown{{end}}</span>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<!-- Health -->
|
||||
<section class="card">
|
||||
<h2>Health</h2>
|
||||
{{if eq .OverallStatus "disabled"}}
|
||||
<p class="health-status health-status-disabled">Reporting has been disabled on this node</p>
|
||||
<p class="hint">Enable it in the controller's <code>controller.yaml</code>: <code>hub.enabled: true</code></p>
|
||||
{{else}}
|
||||
{{with .Report.health}}
|
||||
<p class="health-status health-status-{{index . "status"}}">
|
||||
Status: {{index . "status"}}
|
||||
</p>
|
||||
{{$issues := index . "issues"}}
|
||||
{{if $issues}}
|
||||
<h3>Issues</h3>
|
||||
<ul class="issue-list">
|
||||
{{range $issues}}
|
||||
<li class="issue">{{.}}</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
{{$warnings := index . "warnings"}}
|
||||
{{if $warnings}}
|
||||
<h3>Warnings</h3>
|
||||
<ul class="warning-list">
|
||||
{{range $warnings}}
|
||||
<li class="warning">{{.}}</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<!-- Controller Update -->
|
||||
<section class="card">
|
||||
<h2>Controller Update</h2>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="label">Controller version</span>
|
||||
<span class="value">{{.Customer.ControllerVersion}}</span>
|
||||
</div>
|
||||
{{if .LatestVersion}}
|
||||
<div class="info-item">
|
||||
<span class="label">Registry latest</span>
|
||||
<span class="value">
|
||||
v{{.LatestVersion}}
|
||||
{{if .UpdateAvailable}}
|
||||
<span style="color: #4ade80; margin-left: 0.3em;">● update available</span>
|
||||
{{else}}
|
||||
<span style="color: #94a3b8; margin-left: 0.3em;">— up to date</span>
|
||||
{{end}}
|
||||
</span>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .ControllerURL}}
|
||||
<div class="info-item">
|
||||
<span class="label">Controller URL</span>
|
||||
<span class="value"><a href="{{.ControllerURL}}" target="_blank" style="color: #60a5fa;">{{.ControllerURL}}</a></span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if and .ControllerURL .UpdateAvailable}}
|
||||
<div style="margin-top: 0.75em;">
|
||||
<button class="btn" id="btn-trigger-update" onclick="triggerControllerUpdate('{{.Customer.CustomerID}}')">
|
||||
Trigger Update
|
||||
</button>
|
||||
<span id="update-msg" style="margin-left: 0.5em; display: none;"></span>
|
||||
</div>
|
||||
{{else if and .ControllerURL (not .LatestVersion)}}
|
||||
<div style="margin-top: 0.75em;">
|
||||
<button class="btn" id="btn-trigger-update" onclick="triggerControllerUpdate('{{.Customer.CustomerID}}')">
|
||||
Trigger Update
|
||||
</button>
|
||||
<span id="update-msg" style="margin-left: 0.5em; display: none;"></span>
|
||||
<p style="color: #94a3b8; font-size: 0.85em; margin-top: 0.3em;">Registry check not configured — cannot verify if update is available</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<script>
|
||||
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('update-msg');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Triggering...';
|
||||
msg.style.display = 'none';
|
||||
fetch('/customers/' + customerID + '/trigger-update', {method: 'POST'})
|
||||
.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';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Notifications -->
|
||||
<section class="card">
|
||||
<h2>Notifications</h2>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="label">Email</span>
|
||||
<span class="value">{{if .NotifPrefs}}{{if .NotifPrefs.Email}}{{.NotifPrefs.Email}}{{else}}Not set{{end}}{{else}}Not configured{{end}}</span>
|
||||
</div>
|
||||
{{if .NotifPrefs}}
|
||||
<div class="info-item">
|
||||
<span class="label">Events</span>
|
||||
<span class="value">{{if .NotifPrefs.EnabledEvents}}{{joinStrings .NotifPrefs.EnabledEvents ", "}}{{else}}None{{end}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .RecentNotifications}}
|
||||
<h3>Recent (last 10)</h3>
|
||||
<table class="history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Event</th>
|
||||
<th>Status</th>
|
||||
<th>Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .RecentNotifications}}
|
||||
<tr>
|
||||
<td>{{.CreatedAt.Format "Jan 02 15:04"}}</td>
|
||||
<td>{{.EventType}}</td>
|
||||
<td><span class="status-badge status-badge-{{.Status}}">{{.Status}}</span></td>
|
||||
<td>{{.Message}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<!-- Report History (last 24h) -->
|
||||
{{if .History}}
|
||||
<section class="card">
|
||||
<h2>Report History (last 24h)</h2>
|
||||
<details>
|
||||
<summary>{{len .History}} reports</summary>
|
||||
<table class="history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Status</th>
|
||||
<th>CPU</th>
|
||||
<th>Memory</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .History}}
|
||||
<tr>
|
||||
<td>{{.ReceivedAt.Format "Jan 02 15:04"}}</td>
|
||||
<td><span class="status-badge status-badge-{{.HealthStatus}}">{{.HealthStatus}}</span></td>
|
||||
<td>{{formatFloat .CPUPercent}}%</td>
|
||||
<td>{{formatFloat .MemoryPercent}}%</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<footer>
|
||||
<p>Auto-refreshes every 60 seconds · <a href="/">Felhom Hub</a> {{hubVersion}}</p>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -380,16 +380,22 @@
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Setup Commands</h2>
|
||||
<p class="text-muted" style="margin-bottom: 1rem; font-size: 0.85rem;">Use one of these methods to configure a customer node:</p>
|
||||
<h2>Setup Command</h2>
|
||||
<p class="text-muted" style="margin-bottom: 1rem; font-size: 0.85rem;">
|
||||
Day-0 host bootstrap. Run on a freshly-PVE-installed Proxmox <strong>host</strong> 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 <code>controller.yaml</code>.
|
||||
The retrieval passphrase is entered at the no-echo prompt — never on the command line.
|
||||
</p>
|
||||
|
||||
<h3>Option 1: docker-setup.sh (recommended)</h3>
|
||||
<h3>Option 1: Host install (recommended)</h3>
|
||||
<div class="credential-box">
|
||||
<code id="cmd-setup">sudo ./docker-setup.sh --hub-customer {{.CustomerID}} --hub-password {{.Config.RetrievalPassword}}</code>
|
||||
<code id="cmd-setup">sudo ./felhom-host-install.sh --customer-id {{.CustomerID}}</code>
|
||||
<button type="button" class="copy-btn" onclick="copyText('cmd-setup')" title="Copy">⎘</button>
|
||||
</div>
|
||||
|
||||
<h3 style="margin-top: 1rem;">Option 2: Direct download</h3>
|
||||
<h3 style="margin-top: 1rem;">Option 2: Manual config fetch (debug only)</h3>
|
||||
<p class="text-muted" style="margin: 0 0 0.4rem; font-size: 0.8rem;">The same payload the controller pulls itself — for inspection, not normal provisioning.</p>
|
||||
<div class="credential-box">
|
||||
<code id="cmd-curl">curl -fsSL https://hub.felhom.eu/api/v1/config/{{.CustomerID}} -H "X-Retrieval-Password: {{.Config.RetrievalPassword}}" -o controller.yaml</code>
|
||||
<button type="button" class="copy-btn" onclick="copyText('cmd-curl')" title="Copy">⎘</button>
|
||||
@@ -456,43 +462,12 @@
|
||||
<button class="btn btn-outline btn-sm" type="submit">Save floor</button>
|
||||
<span style="font-size: 0.8em; color: #94a3b8;">Boxes below the effective floor auto-update on their next report. Blank clears the override.</span>
|
||||
</form>
|
||||
|
||||
{{if and .HasConfig .ConfigSyncStatus}}
|
||||
<div class="info-item" style="margin-top: 0.5rem;">
|
||||
<span class="label">Config Sync</span>
|
||||
<span class="value">
|
||||
{{if eq .ConfigSyncStatus "in_sync"}}<span style="color: #22c55e;">✓ In sync</span>
|
||||
{{else if eq .ConfigSyncStatus "mismatch"}}<span style="color: #f59e0b;">⚠ Config mismatch — {{.ConfigDiffCount}} difference{{if gt .ConfigDiffCount 1}}s{{end}}</span>
|
||||
<button class="btn btn-outline btn-sm" style="margin-left: 0.5em; font-size: 0.8em;" onclick="showConfigDiff('{{.CustomerID}}')">Show Diff</button>
|
||||
{{else}}<span style="color: #94a3b8;">Unknown — use "Show Diff" to compare live</span>
|
||||
{{end}}
|
||||
</span>
|
||||
</div>
|
||||
<div id="config-diff-container" style="display: none; margin-top: 0.5rem;"></div>
|
||||
{{end}}
|
||||
<div style="margin-top: 0.75em; display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
||||
{{if and .ControllerURL .UpdateAvailable}}
|
||||
<button class="btn btn-sm" id="btn-trigger-update" onclick="triggerControllerUpdate('{{.CustomerID}}')">
|
||||
Trigger Update
|
||||
</button>
|
||||
{{else if and .ControllerURL (not .LatestVersion)}}
|
||||
<button class="btn btn-sm" id="btn-trigger-update" onclick="triggerControllerUpdate('{{.CustomerID}}')">
|
||||
Trigger Update
|
||||
</button>
|
||||
{{end}}
|
||||
{{if and .HasConfig .ControllerURL}}
|
||||
<button class="btn btn-outline btn-sm" id="btn-push-config" onclick="pushConfig('{{.CustomerID}}')">
|
||||
Push Config
|
||||
</button>
|
||||
<button class="btn btn-outline btn-sm" id="btn-pull-config" onclick="pullConfig('{{.CustomerID}}')">
|
||||
Pull Config
|
||||
</button>
|
||||
{{end}}
|
||||
<span id="action-msg" style="margin-left: 0.5em; display: none;"></span>
|
||||
</div>
|
||||
{{if and .ControllerURL (not .LatestVersion)}}
|
||||
<p style="color: #94a3b8; font-size: 0.85em; margin-top: 0.3em;">Registry check not configured — cannot verify if update is available</p>
|
||||
{{end}}
|
||||
<p class="text-muted" style="margin-top: 0.75em; font-size: 0.8em;">
|
||||
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 <strong>Edit</strong> button (top of page); the controller re-pulls and restarts on its
|
||||
next report.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Events -->
|
||||
@@ -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 = '<p class="text-muted">Loading diff...</p>';
|
||||
container.style.display = 'block';
|
||||
fetch('/customers/' + customerID + '/config-diff')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.ok) {
|
||||
container.innerHTML = '<p style="color: #f87171;">' + (data.error || 'Failed to load diff') + '</p>';
|
||||
return;
|
||||
}
|
||||
if (data.in_sync) {
|
||||
container.innerHTML = '<p style="color: #22c55e;">Configs are in sync (no differences found).</p>';
|
||||
return;
|
||||
}
|
||||
var html = '<table class="data-table" style="font-size: 0.85em;">';
|
||||
html += '<thead><tr><th>Key</th><th>Hub Value</th><th>Controller Value</th><th>Status</th></tr></thead><tbody>';
|
||||
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 += '<tr class="' + cls + '">';
|
||||
html += '<td style="font-family: monospace; white-space: nowrap;">' + escHtml(d.key) + '</td>';
|
||||
html += '<td style="word-break: break-all;">' + escHtml(d.hub) + '</td>';
|
||||
html += '<td style="word-break: break-all;">' + escHtml(d.controller) + '</td>';
|
||||
html += '<td>' + statusLabel + '</td>';
|
||||
html += '</tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
container.innerHTML = html;
|
||||
})
|
||||
.catch(function() {
|
||||
container.innerHTML = '<p style="color: #f87171;">Failed to fetch diff from controller.</p>';
|
||||
});
|
||||
}
|
||||
|
||||
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')
|
||||
|
||||
Reference in New Issue
Block a user