package web import ( "context" "encoding/json" "fmt" "html/template" "net/http" "regexp" "sort" "strings" "time" 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" ) var validCustomerID = regexp.MustCompile(`^[a-zA-Z0-9.\-]+$`) // validSemver matches a bare X.Y.Z controller version (the floor format). Empty is also accepted by // the floor handlers (clears the override). var validSemver = regexp.MustCompile(`^\d+\.\d+\.\d+$`) // normalizeFloorInput trims, strips a leading "v", and validates a floor version submitted from the // operator UI. Returns (value, true) on a valid bare semver or empty string; (_, false) otherwise. func normalizeFloorInput(raw string) (string, bool) { v := strings.TrimSpace(raw) v = strings.TrimPrefix(v, "v") if v == "" { return "", true } if !validSemver.MatchString(v) { return "", false } return v, true } // customerListEntry is a merged view of a customer from both configs and reports. type customerListEntry struct { CustomerID string CustomerName string Domain string HasConfig bool IsBlocked bool OverallStatus string // ok, warn, down, disabled, pending, "" if no reports ControllerVersion string TimeSinceReport time.Duration ConfigCreatedAt time.Time // Phase 2 managed-update floor FloorOverride string // per-customer override ("" = none) EffectiveFloor string // override else global ("" = no floor) BelowFloor bool // current < effective floor (would auto-update) } // handleConfigList shows all customers (merged from configs + reports). func (s *Server) handleConfigList(w http.ResponseWriter, r *http.Request) { configs, err := s.store.ListCustomerConfigs() if err != nil { s.logger.Printf("[ERROR] Failed to list configs: %v", err) http.Error(w, "Internal error", http.StatusInternalServerError) return } customers, err := s.store.GetCustomers() if err != nil { s.logger.Printf("[ERROR] Failed to list customers: %v", err) http.Error(w, "Internal error", http.StatusInternalServerError) return } // Build merged map keyed by customer_id merged := make(map[string]*customerListEntry) for _, cfg := range configs { merged[cfg.CustomerID] = &customerListEntry{ CustomerID: cfg.CustomerID, CustomerName: cfg.CustomerName, Domain: cfg.Domain, HasConfig: true, IsBlocked: cfg.Status == "blocked", ConfigCreatedAt: cfg.CreatedAt, FloorOverride: cfg.MinControllerVersion, } } for _, c := range customers { status := "ok" if c.HealthStatus == "disabled" { status = "disabled" } else if c.TimeSinceReport > time.Hour { status = "down" } else if c.TimeSinceReport > 30*time.Minute || c.HealthStatus == "warn" { status = "warn" } else if c.HealthStatus == "fail" { status = "down" } if entry, ok := merged[c.CustomerID]; ok { // Config exists — enrich with report data entry.OverallStatus = status entry.ControllerVersion = c.ControllerVersion entry.TimeSinceReport = c.TimeSinceReport if entry.CustomerName == "" { entry.CustomerName = c.CustomerName } } else { // Report-only customer (no config) merged[c.CustomerID] = &customerListEntry{ CustomerID: c.CustomerID, CustomerName: c.CustomerName, OverallStatus: status, ControllerVersion: c.ControllerVersion, TimeSinceReport: c.TimeSinceReport, } } } // Phase 2 floor: resolve each customer's effective floor (override else global) + below-floor flag. globalFloor := s.store.GetGlobalMinControllerVersion() for _, e := range merged { e.EffectiveFloor = e.FloorOverride if e.EffectiveFloor == "" { e.EffectiveFloor = globalFloor } if e.EffectiveFloor != "" && e.ControllerVersion != "" { e.BelowFloor = compareVersions(e.EffectiveFloor, e.ControllerVersion) > 0 } } // Sort by customer_id entries := make([]customerListEntry, 0, len(merged)) for _, e := range merged { entries = append(entries, *e) } sort.Slice(entries, func(i, j int) bool { return entries[i].CustomerID < entries[j].CustomerID }) // GlobalFloor + the artifact manifest are global settings — they render + save on the // Configuration tab now (handleConfiguration), not here. globalFloor above is still used // for per-customer effective-floor resolution. data := struct { Customers []customerListEntry ActiveNav string Flash string }{ Customers: entries, ActiveNav: "configs", Flash: r.URL.Query().Get("flash"), } s.templates.ExecuteTemplate(w, "configs.html", data) } // handleCustomerUnified shows the unified customer detail page (config + reports). func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, customerID string) { cfg, _ := s.store.GetCustomerConfig(customerID) customer, _ := s.store.GetCustomer(customerID) // 404 if neither config nor reports exist if cfg == nil && customer == nil { http.NotFound(w, r) return } // Determine identity fields from best source name := "" domain := "" email := "" if cfg != nil { name = cfg.CustomerName domain = cfg.Domain email = cfg.Email } if name == "" && customer != nil { name = customer.CustomerName } // Parse report JSON var report map[string]interface{} if customer != nil { json.Unmarshal([]byte(customer.ReportJSON), &report) } // Parse config overrides var overrides map[string]interface{} if cfg != nil { json.Unmarshal([]byte(cfg.ConfigJSON), &overrides) } // Overall status overallStatus := "pending" if customer != nil { if customer.HealthStatus == "disabled" { overallStatus = "disabled" } else if customer.TimeSinceReport > time.Hour { overallStatus = "down" } else if customer.TimeSinceReport > 30*time.Minute || customer.HealthStatus == "warn" { overallStatus = "warn" } else if customer.HealthStatus == "fail" { overallStatus = "down" } else { overallStatus = "ok" } } if cfg != nil && cfg.Status == "blocked" { overallStatus = "blocked" } // Controller URL 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 } } // Version check var latestVersion string var updateAvailable bool if s.versionChecker != nil && customer != nil { latestVersion = s.versionChecker.LatestVersion() if latestVersion != "" && customer.ControllerVersion != "" { updateAvailable = latestVersion != customer.ControllerVersion && compareVersions(latestVersion, customer.ControllerVersion) > 0 } } // Phase 2 managed-update floor: per-customer override, the global default, the effective floor, and // whether the box is currently below it (i.e. would auto-update on its next report). floorOverride := "" if cfg != nil { floorOverride = cfg.MinControllerVersion } globalFloor := s.store.GetGlobalMinControllerVersion() effectiveFloor := s.store.EffectiveMinControllerVersion(customerID) belowFloor := false if effectiveFloor != "" && customer != nil && customer.ControllerVersion != "" { belowFloor = compareVersions(effectiveFloor, customer.ControllerVersion) > 0 } // History, notifications, events var history []store.CustomerSummary var notifPrefs *store.NotificationPrefs var recentNotifs []store.NotificationLogEntry var events []store.Event var eventCounts map[string]int var appTelemetry []store.CustomerAppSummary if customer != nil { history, _ = s.store.GetCustomerHistory(customerID, 24*time.Hour) notifPrefs, _ = s.store.GetNotificationPrefs(customerID) recentNotifs, _ = s.store.GetRecentNotifications(customerID, 10) events, _ = s.store.GetRecentEvents(customerID, 50) eventCounts, _ = s.store.CountEventsBySeverity(customerID, time.Now().Add(-24*time.Hour)) appTelemetry, _ = s.store.GetCustomerAppSummary(customerID, time.Now().Add(-7*24*time.Hour)) } type pageData struct { CustomerID string CustomerName string Domain string Email string HasConfig bool Config *store.CustomerConfig Overrides map[string]interface{} IsBlocked bool HasReports bool Customer *store.CustomerSummary Report map[string]interface{} OverallStatus string LatestVersion string UpdateAvailable bool ControllerURL string // Phase 2 managed-update floor controls FloorOverride string // per-customer override ("" = none) GlobalFloor string // global default (hub_settings → config/env) EffectiveFloor string // override else global ("" = no floor) BelowFloor bool // current < effective floor (would auto-update) NotifPrefs *store.NotificationPrefs RecentNotifications []store.NotificationLogEntry History []store.CustomerSummary Events []store.Event EventCounts map[string]int // severity → count (last 24h) AppTelemetry []store.CustomerAppSummary HasAppTelemetry bool HasDRRecipe bool DRRecipeUpdatedAt string DRRecipeHasHost bool DRRecipeHasApps bool Flash string ActiveNav string CSRFField template.HTML CSRFToken string } // DR recipe presence — show the secret-free reconstruction recipe panel + download link when // either half has landed (host-report and/or controller report). var hasDR, drHost, drApps bool var drUpdated string if rec, err := s.store.GetDRRecipe(customerID); err == nil && rec != nil { hasDR = rec.HostHalfJSON != "" || rec.AppHalfJSON != "" drHost = rec.HostHalfJSON != "" drApps = rec.AppHalfJSON != "" drUpdated = rec.UpdatedAt } data := pageData{ CustomerID: customerID, CustomerName: name, Domain: domain, Email: email, HasConfig: cfg != nil, Config: cfg, Overrides: overrides, IsBlocked: cfg != nil && cfg.Status == "blocked", HasReports: customer != nil, Customer: customer, Report: report, OverallStatus: overallStatus, LatestVersion: latestVersion, UpdateAvailable: updateAvailable, ControllerURL: controllerURL, FloorOverride: floorOverride, GlobalFloor: globalFloor, EffectiveFloor: effectiveFloor, BelowFloor: belowFloor, NotifPrefs: notifPrefs, RecentNotifications: recentNotifs, History: history, Events: events, EventCounts: eventCounts, AppTelemetry: appTelemetry, HasAppTelemetry: len(appTelemetry) > 0, HasDRRecipe: hasDR, DRRecipeUpdatedAt: drUpdated, DRRecipeHasHost: drHost, DRRecipeHasApps: drApps, Flash: r.URL.Query().Get("flash"), ActiveNav: "configs", CSRFField: s.csrfField(r), CSRFToken: s.csrfToken(r), } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := s.templates.ExecuteTemplate(w, "customer_unified.html", data); err != nil { s.logger.Printf("[ERROR] Template render: %v", err) } } // handleConfigNewForm shows the form to create a new customer config. func (s *Server) handleConfigNewForm(w http.ResponseWriter, r *http.Request) { data := struct { IsNew bool Config *store.CustomerConfig Overrides map[string]interface{} ActiveNav string Error string CSRFField template.HTML }{ IsNew: true, Config: &store.CustomerConfig{}, Overrides: make(map[string]interface{}), ActiveNav: "configs", CSRFField: s.csrfField(r), } s.templates.ExecuteTemplate(w, "config_form.html", data) } // handleConfigCreate processes the form submission to create a new config. func (s *Server) handleConfigCreate(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "Bad request", http.StatusBadRequest) return } customerID := strings.TrimSpace(r.FormValue("customer_id")) if customerID == "" || !validCustomerID.MatchString(customerID) { s.renderConfigForm(w, r, true, &store.CustomerConfig{ CustomerName: r.FormValue("customer_name"), Domain: r.FormValue("domain"), Email: r.FormValue("email"), }, nil, "Invalid Customer ID. Use only letters, numbers, dots, and hyphens.") return } // Check for duplicates existing, _ := s.store.GetCustomerConfig(customerID) if existing != nil { s.renderConfigForm(w, r, true, &store.CustomerConfig{ CustomerID: customerID, CustomerName: r.FormValue("customer_name"), Domain: r.FormValue("domain"), Email: r.FormValue("email"), }, nil, fmt.Sprintf("Customer ID %q already exists.", customerID)) return } // Generate credentials retrievalPassword, err := configgen.RandomPassphrase(5) if err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } apiKey, err := configgen.RandomHex(32) if err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } // Build config_json from optional form fields configJSON := buildConfigJSON(r) cfg := &store.CustomerConfig{ CustomerID: customerID, CustomerName: strings.TrimSpace(r.FormValue("customer_name")), Domain: strings.TrimSpace(r.FormValue("domain")), Email: strings.TrimSpace(r.FormValue("email")), RetrievalPassword: retrievalPassword, APIKey: apiKey, ConfigJSON: configJSON, } if err := s.store.SaveCustomerConfig(cfg); err != nil { s.logger.Printf("[ERROR] Failed to save config for %s: %v", customerID, err) http.Error(w, "Internal error", http.StatusInternalServerError) return } s.logger.Printf("[INFO] Customer config created: %s", customerID) http.Redirect(w, r, "/customers/"+customerID+"?flash=created", http.StatusSeeOther) } // handleConfigEditForm shows the edit form for a customer config. func (s *Server) handleConfigEditForm(w http.ResponseWriter, r *http.Request, customerID string) { cfg, err := s.store.GetCustomerConfig(customerID) if err != nil || cfg == nil { http.NotFound(w, r) return } var overrides map[string]interface{} json.Unmarshal([]byte(cfg.ConfigJSON), &overrides) data := struct { IsNew bool Config *store.CustomerConfig Overrides map[string]interface{} ActiveNav string Error string CSRFField template.HTML }{ IsNew: false, Config: cfg, Overrides: overrides, ActiveNav: "configs", CSRFField: s.csrfField(r), } s.templates.ExecuteTemplate(w, "config_form.html", data) } // handleConfigUpdate processes the edit form submission. func (s *Server) handleConfigUpdate(w http.ResponseWriter, r *http.Request, customerID string) { if err := r.ParseForm(); err != nil { http.Error(w, "Bad request", http.StatusBadRequest) return } cfg, err := s.store.GetCustomerConfig(customerID) if err != nil || cfg == nil { http.NotFound(w, r) return } cfg.CustomerName = strings.TrimSpace(r.FormValue("customer_name")) cfg.Domain = strings.TrimSpace(r.FormValue("domain")) cfg.Email = strings.TrimSpace(r.FormValue("email")) cfg.ConfigJSON = buildConfigJSON(r) if err := s.store.SaveCustomerConfig(cfg); err != nil { s.logger.Printf("[ERROR] Failed to update config for %s: %v", customerID, err) http.Error(w, "Internal error", http.StatusInternalServerError) return } s.logger.Printf("[INFO] Customer config updated: %s", customerID) http.Redirect(w, r, "/customers/"+customerID+"?flash=updated", http.StatusSeeOther) } // handleConfigDelete deletes a customer config. func (s *Server) handleConfigDelete(w http.ResponseWriter, r *http.Request, customerID string) { if err := s.store.DeleteCustomerConfig(customerID); err != nil { s.logger.Printf("[ERROR] Failed to delete config %s: %v", customerID, err) http.Error(w, "Internal error", http.StatusInternalServerError) return } s.logger.Printf("[INFO] Customer config deleted: %s", customerID) http.Redirect(w, r, "/configs?flash=deleted", http.StatusSeeOther) } // handleConfigPreview returns the generated YAML for a customer config. func (s *Server) handleConfigPreview(w http.ResponseWriter, r *http.Request, customerID string) { cfg, err := s.store.GetCustomerConfig(customerID) if err != nil || cfg == nil { http.NotFound(w, r) return } 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 preview for %s: %v", customerID, err) http.Error(w, "Generation error: "+err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/yaml; charset=utf-8") w.Write([]byte(yamlOutput)) } // handleConfigRegenPassword regenerates the retrieval password. func (s *Server) handleConfigRegenPassword(w http.ResponseWriter, r *http.Request, customerID string) { newPassword, err := configgen.RandomPassphrase(5) if err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } if err := s.store.UpdateRetrievalPassword(customerID, newPassword); err != nil { s.logger.Printf("[ERROR] Failed to regen password for %s: %v", customerID, err) http.Error(w, "Internal error", http.StatusInternalServerError) return } s.logger.Printf("[INFO] Retrieval password regenerated for %s", customerID) http.Redirect(w, r, "/customers/"+customerID+"?flash=password_regenerated", http.StatusSeeOther) } // handleBlockCustomer sets a customer's status to "blocked". func (s *Server) handleBlockCustomer(w http.ResponseWriter, r *http.Request, customerID string) { cfg, _ := s.store.GetCustomerConfig(customerID) if cfg == nil { http.NotFound(w, r) return } if err := s.store.SetCustomerConfigStatus(customerID, "blocked"); err != nil { s.logger.Printf("[ERROR] Failed to block %s: %v", customerID, err) http.Error(w, "Internal error", http.StatusInternalServerError) return } s.logger.Printf("[INFO] Customer blocked: %s", customerID) http.Redirect(w, r, "/customers/"+customerID+"?flash=blocked", http.StatusSeeOther) } // handleUnblockCustomer sets a customer's status back to "active". func (s *Server) handleUnblockCustomer(w http.ResponseWriter, r *http.Request, customerID string) { cfg, _ := s.store.GetCustomerConfig(customerID) if cfg == nil { http.NotFound(w, r) return } if err := s.store.SetCustomerConfigStatus(customerID, "active"); err != nil { s.logger.Printf("[ERROR] Failed to unblock %s: %v", customerID, err) http.Error(w, "Internal error", http.StatusInternalServerError) return } s.logger.Printf("[INFO] Customer unblocked: %s", customerID) http.Redirect(w, r, "/customers/"+customerID+"?flash=unblocked", http.StatusSeeOther) } // handleSetGlobalFloor sets (or clears) the global controller-version floor (Phase 2 managed // updates). Empty clears the hub_settings override, falling back to the config/env default. func (s *Server) handleSetGlobalFloor(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "Bad request", http.StatusBadRequest) return } v, ok := normalizeFloorInput(r.FormValue("min_controller_version")) if !ok { http.Redirect(w, r, "/configuration?flash=floor_invalid", http.StatusSeeOther) return } if err := s.store.SetGlobalMinControllerVersion(v); err != nil { s.logger.Printf("[ERROR] Failed to set global floor: %v", err) http.Error(w, "Internal error", http.StatusInternalServerError) return } s.logger.Printf("[INFO] Global controller-version floor set to %q", v) http.Redirect(w, r, "/configuration?flash=floor_set", http.StatusSeeOther) } // validSHA256 matches a lowercase 64-hex sha256 digest. Empty is also accepted by the artifact // handler (clears that artifact's checksum). var validSHA256 = regexp.MustCompile(`^[0-9a-f]{64}$`) // normalizeSHA256 trims/lowercases and validates a sha256 submitted from the operator UI. // Returns (value, true) on a valid 64-hex digest or empty string; (_, false) otherwise. func normalizeSHA256(raw string) (string, bool) { v := strings.ToLower(strings.TrimSpace(raw)) if v == "" { return "", true } if !validSHA256.MatchString(v) { return "", false } return v, true } // handleSetArtifacts records the operator-vouched current artifact set (agent binary + golden // archive) into hub_settings — the checksum TRUST ROOT the host-bootstrap script verifies fetched // artifacts against. The operator picks a VERSION (from the Gitea-populated dropdown); the hub DERIVES // that version's sha256 from Gitea itself (never trusting a client-supplied checksum), so there is no // hand-copied sha to get wrong. When no Gitea client is configured (no registry creds) it falls back // to the submitted sha256 (legacy manual path). Empty version clears that artifact. func (s *Server) handleSetArtifacts(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "Bad request", http.StatusBadRequest) return } agentVer, okAV := normalizeFloorInput(r.FormValue("agent_version")) goldenVer, okGV := normalizeFloorInput(r.FormValue("golden_version")) if !okAV || !okGV { http.Redirect(w, r, "/configuration?flash=artifact_ver_invalid", http.StatusSeeOther) return } agentSHA, okAS := s.resolveArtifactSHA(r.Context(), pkgAgent, fileAgent, agentVer, r.FormValue("agent_sha256")) goldenSHA, okGS := s.resolveArtifactSHA(r.Context(), pkgGolden, fileGolden, goldenVer, r.FormValue("golden_sha256")) if !okAS || !okGS { http.Redirect(w, r, "/configuration?flash=artifact_sha_invalid", http.StatusSeeOther) return } if err := s.store.SetArtifactManifest(store.ArtifactManifest{ AgentVersion: agentVer, AgentSHA256: agentSHA, GoldenVersion: goldenVer, GoldenSHA256: goldenSHA, }); err != nil { s.logger.Printf("[ERROR] Failed to set artifact manifest: %v", err) http.Error(w, "Internal error", http.StatusInternalServerError) return } s.logger.Printf("[INFO] Artifact manifest set: agent=%s golden=%s", agentVer, goldenVer) http.Redirect(w, r, "/configuration?flash=artifacts_set", http.StatusSeeOther) } // resolveArtifactSHA determines the sha256 to store for a chosen artifact version. An empty version // clears the artifact (returns "",true). With a Gitea client it fetches the sha AUTHORITATIVELY from // Gitea (the submitted value is ignored — nothing hand-typed to trust); a fetch failure returns // (_,false) so the caller refuses the save rather than storing a version with a wrong/blank checksum. // Without a Gitea client it validates + uses the submitted sha (legacy manual path). func (s *Server) resolveArtifactSHA(ctx context.Context, pkg, file, version, submittedSHA string) (string, bool) { if version == "" { return "", true } if s.gitea != nil { sha, err := s.gitea.FileSHA256(ctx, pkg, version, file) if err != nil { s.logger.Printf("[WARN] artifact sha resolve (%s/%s): %v", pkg, version, err) return "", false } return sha, true } return normalizeSHA256(submittedSHA) } // handleSetCustomerFloor sets (or clears) a customer's per-customer controller-version floor // override. Empty clears the override (the customer then uses the global floor). func (s *Server) handleSetCustomerFloor(w http.ResponseWriter, r *http.Request, customerID string) { cfg, err := s.store.GetCustomerConfig(customerID) if err != nil || cfg == nil { http.NotFound(w, r) return } if err := r.ParseForm(); err != nil { http.Error(w, "Bad request", http.StatusBadRequest) return } v, ok := normalizeFloorInput(r.FormValue("min_controller_version")) if !ok { http.Redirect(w, r, "/customers/"+customerID+"?flash=floor_invalid", http.StatusSeeOther) return } if err := s.store.SetMinControllerVersion(customerID, v); err != nil { s.logger.Printf("[ERROR] Failed to set floor for %s: %v", customerID, err) http.Error(w, "Internal error", http.StatusInternalServerError) return } s.logger.Printf("[INFO] Customer %s controller-version floor override set to %q", customerID, v) http.Redirect(w, r, "/customers/"+customerID+"?flash=floor_set", http.StatusSeeOther) } // 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 existing, _ := s.store.GetCustomerConfig(customerID) if existing != nil { http.Redirect(w, r, "/configs/"+customerID+"/edit", http.StatusSeeOther) return } // Get report data to pre-fill customer, _ := s.store.GetCustomer(customerID) name := customerID if customer != nil && customer.CustomerName != "" { name = customer.CustomerName } // Generate credentials retrievalPassword, _ := configgen.RandomPassphrase(5) apiKey, _ := configgen.RandomHex(32) cfg := &store.CustomerConfig{ CustomerID: customerID, CustomerName: name, RetrievalPassword: retrievalPassword, APIKey: apiKey, ConfigJSON: "{}", } if err := s.store.SaveCustomerConfig(cfg); err != nil { s.logger.Printf("[ERROR] Failed to create config from report for %s: %v", customerID, err) http.Error(w, "Internal error", http.StatusInternalServerError) return } s.logger.Printf("[INFO] Config auto-created from report for %s", customerID) http.Redirect(w, r, "/configs/"+customerID+"/edit", http.StatusSeeOther) } // renderConfigForm is a helper to re-render the form with an error. func (s *Server) renderConfigForm(w http.ResponseWriter, r *http.Request, isNew bool, cfg *store.CustomerConfig, overrides map[string]interface{}, errMsg string) { if overrides == nil { overrides = make(map[string]interface{}) } data := struct { IsNew bool Config *store.CustomerConfig Overrides map[string]interface{} ActiveNav string Error string CSRFField template.HTML }{ IsNew: isNew, Config: cfg, Overrides: overrides, ActiveNav: "configs", Error: errMsg, CSRFField: s.csrfField(r), } s.templates.ExecuteTemplate(w, "config_form.html", data) } // buildConfigJSON builds the config_json from optional form fields. func buildConfigJSON(r *http.Request) string { overrides := make(map[string]interface{}) // Infrastructure infra := make(map[string]interface{}) if v := strings.TrimSpace(r.FormValue("cf_tunnel_token")); v != "" { infra["cf_tunnel_token"] = v } if v := strings.TrimSpace(r.FormValue("cf_api_token")); v != "" { infra["cf_api_token"] = v } if len(infra) > 0 { overrides["infrastructure"] = infra } // Git git := make(map[string]interface{}) if v := strings.TrimSpace(r.FormValue("git_username")); v != "" { git["username"] = v } if v := strings.TrimSpace(r.FormValue("git_token")); v != "" { git["token"] = v } if len(git) > 0 { overrides["git"] = git } data, _ := json.Marshal(overrides) return string(data) } // 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 { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusNotFound) json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "Customer not found"}) return } // Extract CF API token from config_json → infrastructure.cf_api_token var overrides map[string]interface{} if err := json.Unmarshal([]byte(cfg.ConfigJSON), &overrides); err != nil { overrides = make(map[string]interface{}) } var cfToken string if infra, ok := overrides["infrastructure"].(map[string]interface{}); ok { cfToken, _ = infra["cf_api_token"].(string) } if cfToken == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "No Cloudflare API token configured for this customer"}) return } if cfg.Domain == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "No domain configured for this customer"}) return } // 1. Remove WAF rules directly via Cloudflare API if err := cfClient.RemoveGeoRules(cfToken, cfg.Domain, s.logger); err != nil { s.logger.Printf("[ERROR] Geo disable for %s: Cloudflare removal failed: %v", customerID, err) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadGateway) json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": fmt.Sprintf("Cloudflare API error: %v", err)}) return } s.logger.Printf("[INFO] Geo disable for %s: Cloudflare WAF rules removed", customerID) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Geo-restriction removed from Cloudflare."}) }