package web import ( "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "regexp" "sort" "strings" "time" "gitea.dooplex.hu/admin/felhom-hub/internal/configgen" "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) 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 }{ 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 } } // Config hash comparison var controllerConfigHash string var hubConfigHash string var configSyncStatus string // "in_sync", "mismatch", "unknown" if customer != nil && cfg != nil { var rptHash struct { ConfigHash string `json:"config_hash"` } json.Unmarshal([]byte(customer.ReportJSON), &rptHash) controllerConfigHash = rptHash.ConfigHash if controllerConfigHash != "" { // Generate Hub-side YAML and compute its hash templateYAML := defaultControllerTemplate if s.templateFetcher != nil { templateYAML = s.templateFetcher.Template() } if yamlOutput, err := configgen.Generate(templateYAML, cfg); err == nil { h := sha256.Sum256([]byte(yamlOutput)) hubConfigHash = hex.EncodeToString(h[:]) if hubConfigHash == controllerConfigHash { configSyncStatus = "in_sync" } else { configSyncStatus = "mismatch" } } } 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 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)) } 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" ControllerConfigHash string HubConfigHash string 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) Flash string ActiveNav 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, ControllerConfigHash: controllerConfigHash, HubConfigHash: hubConfigHash, InfraBackup: infraMeta, InfraBackupAge: infraBackupAge, NotifPrefs: notifPrefs, RecentNotifications: recentNotifs, History: history, Events: events, EventCounts: eventCounts, Flash: r.URL.Query().Get("flash"), ActiveNav: "configs", } 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 }{ IsNew: true, Config: &store.CustomerConfig{}, Overrides: make(map[string]interface{}), ActiveNav: "configs", } 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, 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, 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.RandomHex(32) 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 }{ IsNew: false, Config: cfg, Overrides: overrides, ActiveNav: "configs", } 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.RandomHex(32) 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.RandomHex(32) 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, 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 }{ IsNew: isNew, Config: cfg, Overrides: overrides, ActiveNav: "configs", Error: errMsg, } 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 } // Monitoring UUIDs (legacy — only written if user explicitly provides values) uuids := make(map[string]interface{}) for _, key := range []string{"heartbeat", "system_health", "db_dump", "backup", "backup_integrity"} { if v := strings.TrimSpace(r.FormValue("uuid_" + key)); v != "" { uuids[key] = v } } if len(uuids) > 0 { if _, ok := overrides["monitoring"]; !ok { overrides["monitoring"] = make(map[string]interface{}) } overrides["monitoring"].(map[string]interface{})["ping_uuids"] = uuids } data, _ := json.Marshal(overrides) return string(data) }