package web import ( "encoding/base64" "encoding/json" "fmt" "html/template" "io" "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" "gopkg.in/yaml.v3" ) var validCustomerID = regexp.MustCompile(`^[a-zA-Z0-9.\-]+$`) // 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 } // 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, } } 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, } } } // 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 }) data := struct { Customers []customerListEntry ActiveNav string Flash string CSRFToken string }{ Customers: entries, ActiveNav: "configs", Flash: r.URL.Query().Get("flash"), CSRFToken: s.csrfToken(r), } 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 } } // Config value comparison (parse both YAMLs, compare actual values) var configSyncStatus string // "in_sync", "mismatch", "unknown" var configDiffCount int if cfg != nil { infraData, _ := s.store.GetInfraBackup(customerID) if infraData != nil { controllerYAML := extractControllerYAML(infraData) if controllerYAML != "" { templateYAML := defaultControllerTemplate if s.templateFetcher != nil { templateYAML = s.templateFetcher.Template() } if hubYAML, err := configgen.Generate(templateYAML, cfg); err == nil { diffs := compareYAMLValues(hubYAML, controllerYAML) configDiffCount = len(diffs) if configDiffCount == 0 { configSyncStatus = "in_sync" } else { configSyncStatus = "mismatch" } } } else { configSyncStatus = "unknown" } } else { configSyncStatus = "unknown" } } // 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 } } // History, notifications, events, infra backup var history []store.CustomerSummary var notifPrefs *store.NotificationPrefs var recentNotifs []store.NotificationLogEntry var infraMeta *store.InfraBackupMeta var infraBackupAge string 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) infraMeta, _ = s.store.GetInfraBackupMeta(customerID) if infraMeta != nil { infraBackupAge = timeAgo(infraMeta.UpdatedAt) } 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 ConfigSyncStatus string // "in_sync", "mismatch", "unknown" ConfigDiffCount int InfraBackup *store.InfraBackupMeta InfraBackupAge string 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 Flash string ActiveNav string CSRFField template.HTML CSRFToken string } 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, ConfigSyncStatus: configSyncStatus, ConfigDiffCount: configDiffCount, InfraBackup: infraMeta, InfraBackupAge: infraBackupAge, NotifPrefs: notifPrefs, RecentNotifications: recentNotifs, History: history, Events: events, EventCounts: eventCounts, AppTelemetry: appTelemetry, HasAppTelemetry: len(appTelemetry) > 0, 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) } // 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 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) } // --- Config comparison helpers --- // extractControllerYAML decodes the controller.yaml from an infra backup JSON payload. func extractControllerYAML(infraData []byte) string { var parsed struct { ControllerConfigB64 string `json:"controller_config_b64"` } if err := json.Unmarshal(infraData, &parsed); err != nil || parsed.ControllerConfigB64 == "" { return "" } data, err := base64.StdEncoding.DecodeString(parsed.ControllerConfigB64) if err != nil { return "" } return string(data) } // volatileKeys are YAML keys ignored during config comparison (always differ or deprecated). var volatileKeys = map[string]bool{ "web.session_secret": true, } // sensitiveKeyParts are substrings that indicate a value should be masked in diff output. var sensitiveKeyParts = []string{"token", "password", "secret", "api_key"} // flattenYAML recursively flattens a nested map into dot-separated key-value pairs. func flattenYAML(m map[string]interface{}, prefix string) map[string]string { result := make(map[string]string) for k, v := range m { key := k if prefix != "" { key = prefix + "." + k } switch val := v.(type) { case map[string]interface{}: for fk, fv := range flattenYAML(val, key) { result[fk] = fv } case []interface{}: for i, item := range val { itemKey := fmt.Sprintf("%s.%d", key, i) if sub, ok := item.(map[string]interface{}); ok { for fk, fv := range flattenYAML(sub, itemKey) { result[fk] = fv } } else { result[itemKey] = fmt.Sprintf("%v", item) } } default: result[key] = fmt.Sprintf("%v", v) } } return result } // configDiff represents a single key-value difference between two configs. type configDiff struct { Key string `json:"key"` HubValue string `json:"hub"` CtrlValue string `json:"controller"` Status string `json:"status"` // "changed", "hub_only", "controller_only" } // compareYAMLValues parses two YAML strings and returns their value differences. // Volatile keys (e.g., web.session_secret) are excluded. func compareYAMLValues(hubYAML, controllerYAML string) []configDiff { var hubMap, ctrlMap map[string]interface{} yaml.Unmarshal([]byte(hubYAML), &hubMap) yaml.Unmarshal([]byte(controllerYAML), &ctrlMap) hubFlat := flattenYAML(hubMap, "") ctrlFlat := flattenYAML(ctrlMap, "") var diffs []configDiff // Keys in hub but different/missing in controller for k, hv := range hubFlat { if volatileKeys[k] { continue } cv, exists := ctrlFlat[k] if !exists { if hv != "" && hv != "" { diffs = append(diffs, configDiff{Key: k, HubValue: hv, CtrlValue: "(not set)", Status: "hub_only"}) } } else if hv != cv { diffs = append(diffs, configDiff{Key: k, HubValue: hv, CtrlValue: cv, Status: "changed"}) } } // Keys in controller but missing in hub for k, cv := range ctrlFlat { if volatileKeys[k] { continue } if _, exists := hubFlat[k]; !exists { if cv != "" && cv != "" { diffs = append(diffs, configDiff{Key: k, HubValue: "(not set)", CtrlValue: cv, Status: "controller_only"}) } } } sort.Slice(diffs, func(i, j int) bool { return diffs[i].Key < diffs[j].Key }) return diffs } // maskSensitive masks a value if the key contains sensitive substrings. func maskSensitive(key, value string) string { if value == "" || value == "(not set)" { return value } keyLower := strings.ToLower(key) for _, part := range sensitiveKeyParts { if strings.Contains(keyLower, part) { if len(value) > 8 { return "***" + value[len(value)-4:] } return "***" } } return value } // handleConfigDiff returns a JSON diff between Hub-generated and controller's live config. func (s *Server) handleConfigDiff(w http.ResponseWriter, r *http.Request, customerID string) { cfg, err := s.store.GetCustomerConfig(customerID) if err != nil || cfg == nil { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "No config found"}) return } // Get controller URL customer, _ := s.store.GetCustomer(customerID) controllerURL := "" if customer != nil { controllerURL = customer.ControllerURL if controllerURL == "" { var rpt struct { ControllerURL string `json:"controller_url"` } json.Unmarshal([]byte(customer.ReportJSON), &rpt) controllerURL = rpt.ControllerURL } } if controllerURL == "" { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "Controller URL not available"}) return } // Fetch live config from controller fetchURL := controllerURL + "/api/config" req, err := http.NewRequest("GET", fetchURL, nil) if err != nil { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "Failed to create request"}) return } req.Header.Set("Authorization", "Bearer "+s.apiKey) client := &http.Client{Timeout: 15 * time.Second} resp, err := client.Do(req) if err != nil { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": fmt.Sprintf("Controller unreachable: %v", err)}) return } defer resp.Body.Close() if resp.StatusCode != 200 { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": fmt.Sprintf("Controller returned HTTP %d", resp.StatusCode)}) return } controllerYAML, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "Failed to read controller response"}) return } // Generate Hub YAML templateYAML := defaultControllerTemplate if s.templateFetcher != nil { templateYAML = s.templateFetcher.Template() } hubYAML, err := configgen.Generate(templateYAML, cfg) if err != nil { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "Failed to generate Hub config"}) return } // Compare diffs := compareYAMLValues(hubYAML, string(controllerYAML)) // Mask sensitive values for i := range diffs { diffs[i].HubValue = maskSensitive(diffs[i].Key, diffs[i].HubValue) diffs[i].CtrlValue = maskSensitive(diffs[i].Key, diffs[i].CtrlValue) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "ok": true, "in_sync": len(diffs) == 0, "diff_count": len(diffs), "diffs": diffs, }) } // handlePullConfig fetches the controller's live config and imports it into the Hub. func (s *Server) handlePullConfig(w http.ResponseWriter, r *http.Request, customerID string) { cfg, err := s.store.GetCustomerConfig(customerID) if err != nil || cfg == nil { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "No config found"}) return } // Get controller URL customer, _ := s.store.GetCustomer(customerID) controllerURL := "" if customer != nil { controllerURL = customer.ControllerURL if controllerURL == "" { var rpt struct { ControllerURL string `json:"controller_url"` } json.Unmarshal([]byte(customer.ReportJSON), &rpt) controllerURL = rpt.ControllerURL } } if controllerURL == "" { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "Controller URL not available"}) return } // Fetch live config from controller fetchURL := controllerURL + "/api/config" req, err := http.NewRequest("GET", fetchURL, nil) if err != nil { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "Failed to create request"}) return } req.Header.Set("Authorization", "Bearer "+s.apiKey) client := &http.Client{Timeout: 15 * time.Second} resp, err := client.Do(req) if err != nil { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": fmt.Sprintf("Controller unreachable: %v", err)}) return } defer resp.Body.Close() if resp.StatusCode != 200 { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": fmt.Sprintf("Controller returned HTTP %d", resp.StatusCode)}) return } body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "Failed to read controller response"}) return } // Parse controller's YAML var parsed map[string]interface{} if err := yaml.Unmarshal(body, &parsed); err != nil { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "Failed to parse controller YAML"}) return } // Extract identity fields if customer, ok := parsed["customer"].(map[string]interface{}); ok { if v, ok := customer["name"].(string); ok && v != "" { cfg.CustomerName = v } if v, ok := customer["domain"].(string); ok && v != "" { cfg.Domain = v } if v, ok := customer["email"].(string); ok && v != "" { cfg.Email = v } } // Build config_json from override fields overrides := make(map[string]interface{}) // Infrastructure tokens if infra, ok := parsed["infrastructure"].(map[string]interface{}); ok { infraOverrides := make(map[string]interface{}) if v, ok := infra["cf_tunnel_token"].(string); ok && v != "" { infraOverrides["cf_tunnel_token"] = v } if v, ok := infra["cf_api_token"].(string); ok && v != "" { infraOverrides["cf_api_token"] = v } if len(infraOverrides) > 0 { overrides["infrastructure"] = infraOverrides } } // Git credentials if git, ok := parsed["git"].(map[string]interface{}); ok { gitOverrides := make(map[string]interface{}) if v, ok := git["username"].(string); ok && v != "" { gitOverrides["username"] = v } if v, ok := git["token"].(string); ok && v != "" { gitOverrides["token"] = v } if len(gitOverrides) > 0 { overrides["git"] = gitOverrides } } configJSON, _ := json.Marshal(overrides) cfg.ConfigJSON = string(configJSON) if err := s.store.SaveCustomerConfig(cfg); err != nil { s.logger.Printf("[ERROR] Pull config: failed to update config for %s: %v", customerID, err) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "Failed to save config"}) return } s.logger.Printf("[INFO] Config pulled from controller for %s", customerID) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Config imported from controller"}) } // handleGeoDisable removes all [felhom-geo] WAF rules from Cloudflare for a customer, // and notifies the controller to disable geo-restriction in its settings. 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) // 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) }