package web import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "net/http" "os" "path/filepath" "strconv" "strings" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/backup" "gitea.dooplex.hu/admin/felhom-controller/internal/monitor" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" "gitea.dooplex.hu/admin/felhom-controller/internal/system" ) // DebugCallbacks holds functions that need main.go wiring (modules not directly on Server). type DebugCallbacks struct { TriggerHubReportPush func() error TriggerHubInfraPush func() error TriggerLocalInfraWrite func() error TriggerSetupMode func() error HubConnectivityTest func() (statusCode int, latencyMs int64, err error) GiteaConnectivityTest func() (statusCode int, latencyMs int64, err error) } // debugPageHandler renders the debug dashboard page. func (s *Server) debugPageHandler(w http.ResponseWriter, r *http.Request) { data := s.baseData("debug", "Debug") s.executeTemplate(w, r, "debug", data) } // handleDebugAPI dispatches /api/debug/* routes. func (s *Server) handleDebugAPI(w http.ResponseWriter, r *http.Request) { subpath := strings.TrimPrefix(r.URL.Path, "/api/debug/") switch { // Section 1: Diagnostic dump case subpath == "dump" && r.Method == http.MethodGet: s.debugDump(w, r) // Section 2: Notification & Event testing case subpath == "event/test" && r.Method == http.MethodPost: s.debugTestEvent(w, r) case subpath == "event/history" && r.Method == http.MethodGet: s.debugEventHistory(w, r) // Section 3: Backup testing case subpath == "backup/dbdump" && r.Method == http.MethodPost: s.debugTriggerDBDump(w, r) case subpath == "backup/crossdrive" && r.Method == http.MethodPost: s.debugTriggerCrossDrive(w, r) case subpath == "backup/integrity" && r.Method == http.MethodPost: s.debugTriggerIntegrity(w, r) case subpath == "backup/infra" && r.Method == http.MethodPost: s.debugTriggerInfraBackup(w, r) // Section 4: Storage simulation case subpath == "storage/simulate-disconnect" && r.Method == http.MethodPost: s.debugSimulateDisconnect(w, r) case subpath == "storage/simulate-reconnect" && r.Method == http.MethodPost: s.debugSimulateReconnect(w, r) case subpath == "storage/watchdog-status" && r.Method == http.MethodGet: s.debugWatchdogStatus(w, r) // Section 5: Hub & connectivity case subpath == "hub/push" && r.Method == http.MethodPost: s.debugHubPush(w, r) case subpath == "hub/infra-push" && r.Method == http.MethodPost: s.debugHubInfraPush(w, r) case subpath == "hub/test-connectivity" && r.Method == http.MethodPost: s.debugHubConnectivity(w, r) case subpath == "hub/preferences-sync" && r.Method == http.MethodPost: s.debugPreferencesSync(w, r) case subpath == "gitea/test-connectivity" && r.Method == http.MethodPost: s.debugGiteaConnectivity(w, r) // Section 6: Self-update case subpath == "selfupdate/dry-run" && r.Method == http.MethodPost: s.debugSelfUpdateDryRun(w, r) // Section 7: DR / Setup case subpath == "dr/trigger-setup" && r.Method == http.MethodPost: s.debugTriggerSetupWizard(w, r) case subpath == "dr/infra-status" && r.Method == http.MethodGet: s.debugInfraBackupStatus(w, r) // Section 8: Log viewer case subpath == "logs" && r.Method == http.MethodGet: s.debugLogBuffer(w, r) default: http.NotFound(w, r) } } // writeDebugJSON writes a standard JSON response for debug endpoints. func writeDebugJSON(w http.ResponseWriter, status int, ok bool, message string, data interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) resp := map[string]interface{}{ "ok": ok, } if message != "" { if ok { resp["message"] = message } else { resp["error"] = message } } if data != nil { resp["data"] = data } json.NewEncoder(w).Encode(resp) } // ── Section 1: Diagnostic dump ────────────────────────────────────── func (s *Server) debugDump(w http.ResponseWriter, r *http.Request) { dump := make(map[string]interface{}) // Controller info configHash := "" configPath := s.cfg.Paths.DataDir // approximate; configPath isn't on Server if data, err := os.ReadFile(filepath.Join(filepath.Dir(configPath), "controller.yaml")); err == nil { h := sha256.Sum256(data) configHash = hex.EncodeToString(h[:]) } dump["controller"] = map[string]interface{}{ "version": s.version, "uptime_seconds": int(time.Since(s.startTime).Seconds()), "config_hash": configHash, "logging_level": s.cfg.Logging.Level, "pid": os.Getpid(), } // Storage storagePaths := s.settings.GetStoragePaths() storageEntries := make([]map[string]interface{}, 0, len(storagePaths)) for _, sp := range storagePaths { entry := map[string]interface{}{ "path": sp.Path, "label": sp.Label, "disconnected": sp.Disconnected, "decommissioned": sp.Decommissioned, } if !sp.Disconnected && !sp.Decommissioned { if di := system.GetDiskUsage(sp.Path); di != nil { entry["total_gb"] = di.TotalGB entry["used_gb"] = di.UsedGB entry["used_percent"] = di.UsedPercent } } storageEntries = append(storageEntries, entry) } dump["storage"] = storageEntries // Stacks allStacks := s.stackMgr.GetStacks() deployed := 0 running := 0 stopped := 0 stackList := make([]map[string]interface{}, 0) for _, st := range allStacks { if !st.Deployed { continue } deployed++ info := map[string]interface{}{ "name": st.Name, "state": string(st.State), } if st.Meta.DisplayName != "" { info["display_name"] = st.Meta.DisplayName } containerNames := make([]string, 0, len(st.Containers)) for _, c := range st.Containers { containerNames = append(containerNames, c.Name) switch c.State { case stacks.StateRunning, stacks.StateStarting, stacks.StateUnhealthy: running++ default: stopped++ } } info["containers"] = containerNames stackList = append(stackList, info) } dump["stacks"] = map[string]interface{}{ "deployed": deployed, "running": running, "stopped": stopped, "list": stackList, } // Backup if s.backupMgr != nil { backupInfo := map[string]interface{}{ "enabled": true, "running": s.backupMgr.IsRunning(), } dbDump, backupSt := s.backupMgr.GetStatus() if dbDump != nil { backupInfo["last_db_dump"] = map[string]interface{}{ "time": dbDump.LastRun, "success": dbDump.Success, } } if backupSt != nil { backupInfo["last_backup"] = map[string]interface{}{ "time": backupSt.LastRun, "success": backupSt.Success, } if backupSt.RepoStats != nil { backupInfo["repo_size"] = backupSt.RepoStats.TotalSize backupInfo["snapshot_count"] = backupSt.RepoStats.SnapshotCount } } dump["backup"] = backupInfo } else { dump["backup"] = map[string]interface{}{"enabled": false} } // Hub hubInfo := map[string]interface{}{ "url": s.cfg.Hub.URL, "enabled": s.cfg.Hub.Enabled, } if s.hubPushStatusFn != nil { st := s.hubPushStatusFn() hubInfo["last_attempt"] = st.LastAttempt hubInfo["last_success"] = st.LastSuccess hubInfo["last_error"] = st.LastError hubInfo["consecutive_failures"] = st.Consecutive } dump["hub"] = hubInfo // Scheduler if s.scheduler != nil { jobs := s.scheduler.GetJobs() jobList := make([]map[string]interface{}, 0, len(jobs)) for _, j := range jobs { entry := map[string]interface{}{ "name": j.Name, "running": j.Running, } if j.Interval > 0 { entry["type"] = "every" entry["interval"] = j.Interval.String() } else if j.Schedule != "" { entry["type"] = "daily" entry["schedule"] = j.Schedule } if !j.LastRun.IsZero() { entry["last_run"] = j.LastRun } if j.LastErr != nil { entry["last_error"] = j.LastErr.Error() } jobList = append(jobList, entry) } dump["scheduler"] = jobList } // Health healthReport := monitor.RunHealthCheck(s.cfg, s.cpuCollector, storagePaths, s.logger) dump["health"] = map[string]interface{}{ "status": healthReport.Status, "issues": healthReport.Issues, "warnings": healthReport.Warnings, } // Notifications prefs := s.settings.GetNotificationPrefs() dump["notifications"] = map[string]interface{}{ "email": prefs.Email, "enabled_events": prefs.EnabledEvents, "cooldown_hours": prefs.CooldownHours, } // Self-update if s.updater != nil { status := s.updater.GetStatus() dump["self_update"] = map[string]interface{}{ "enabled": true, "auto": s.cfg.SelfUpdate.AutoUpdate, "last_check": status.LastCheck, } } else { dump["self_update"] = map[string]interface{}{"enabled": false} } // Alerts if s.alertManager != nil { dump["alerts"] = s.alertManager.GetAlerts() } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(dump) } // ── Section 2: Notification & Event testing ───────────────────────── func (s *Server) debugTestEvent(w http.ResponseWriter, r *http.Request) { var req struct { EventType string `json:"event_type"` Severity string `json:"severity"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeDebugJSON(w, http.StatusBadRequest, false, "Érvénytelen kérés", nil) return } if req.EventType == "" { req.EventType = "test" } if req.Severity == "" { req.Severity = "info" } if s.notifier == nil { writeDebugJSON(w, http.StatusBadRequest, false, "Notifier nincs konfigurálva", nil) return } statusCode, err := s.notifier.PushTestEventSync(req.EventType, req.Severity, fmt.Sprintf("Teszt esemény: %s (%s)", req.EventType, req.Severity)) if err != nil { writeDebugJSON(w, http.StatusOK, false, err.Error(), map[string]interface{}{ "hub_status": statusCode, }) return } writeDebugJSON(w, http.StatusOK, true, fmt.Sprintf("Esemény elküldve (HTTP %d)", statusCode), map[string]interface{}{"hub_status": statusCode}) } func (s *Server) debugEventHistory(w http.ResponseWriter, r *http.Request) { if s.notifier == nil { writeDebugJSON(w, http.StatusOK, true, "", []interface{}{}) return } history := s.notifier.GetEventHistory(20) writeDebugJSON(w, http.StatusOK, true, "", history) } // ── Section 3: Backup testing ─────────────────────────────────────── func (s *Server) debugTriggerDBDump(w http.ResponseWriter, r *http.Request) { if s.backupMgr == nil { writeDebugJSON(w, http.StatusBadRequest, false, "Backup manager nincs konfigurálva", nil) return } go func() { if err := s.backupMgr.RunDBDumps(context.Background()); err != nil { s.logger.Printf("[WARN] Debug DB dump failed: %v", err) } }() writeDebugJSON(w, http.StatusOK, true, "DB dump elindítva", nil) } func (s *Server) debugTriggerCrossDrive(w http.ResponseWriter, r *http.Request) { if s.crossDriveRunner == nil { writeDebugJSON(w, http.StatusBadRequest, false, "Cross-drive runner nincs konfigurálva", nil) return } go func() { if err := s.crossDriveRunner.RunAllConfigured(context.Background()); err != nil { s.logger.Printf("[WARN] Debug cross-drive failed: %v", err) } }() writeDebugJSON(w, http.StatusOK, true, "Cross-drive mentés elindítva", nil) } func (s *Server) debugTriggerIntegrity(w http.ResponseWriter, r *http.Request) { if s.backupMgr == nil { writeDebugJSON(w, http.StatusBadRequest, false, "Backup manager nincs konfigurálva", nil) return } go func() { if err := s.backupMgr.RunIntegrityCheck(context.Background()); err != nil { s.logger.Printf("[WARN] Debug integrity check failed: %v", err) } }() writeDebugJSON(w, http.StatusOK, true, "Integritás ellenőrzés elindítva", nil) } func (s *Server) debugTriggerInfraBackup(w http.ResponseWriter, r *http.Request) { if s.debugCallbacks == nil || s.debugCallbacks.TriggerLocalInfraWrite == nil { writeDebugJSON(w, http.StatusNotImplemented, false, "Nem bekötött", nil) return } go func() { if err := s.debugCallbacks.TriggerLocalInfraWrite(); err != nil { s.logger.Printf("[WARN] Debug infra backup failed: %v", err) } }() writeDebugJSON(w, http.StatusOK, true, "Infra mentés elindítva", nil) } // ── Section 4: Storage simulation ─────────────────────────────────── func (s *Server) debugSimulateDisconnect(w http.ResponseWriter, r *http.Request) { var req struct { Path string `json:"path"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Path == "" { writeDebugJSON(w, http.StatusBadRequest, false, "Érvénytelen kérés: path szükséges", nil) return } if s.storageWatchdog == nil { writeDebugJSON(w, http.StatusBadRequest, false, "Storage watchdog nincs konfigurálva", nil) return } stopped, err := s.storageWatchdog.SimulateDisconnect(r.Context(), req.Path) if err != nil { writeDebugJSON(w, http.StatusBadRequest, false, err.Error(), nil) return } writeDebugJSON(w, http.StatusOK, true, fmt.Sprintf("Leválasztás szimulálva: %s (%d app leállítva)", req.Path, len(stopped)), map[string]interface{}{"stopped_stacks": stopped}) } func (s *Server) debugSimulateReconnect(w http.ResponseWriter, r *http.Request) { var req struct { Path string `json:"path"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Path == "" { writeDebugJSON(w, http.StatusBadRequest, false, "Érvénytelen kérés: path szükséges", nil) return } if s.storageWatchdog == nil { writeDebugJSON(w, http.StatusBadRequest, false, "Storage watchdog nincs konfigurálva", nil) return } if err := s.storageWatchdog.SimulateReconnect(r.Context(), req.Path); err != nil { writeDebugJSON(w, http.StatusBadRequest, false, err.Error(), nil) return } writeDebugJSON(w, http.StatusOK, true, fmt.Sprintf("Visszacsatlakozás szimulálva: %s", req.Path), nil) } func (s *Server) debugWatchdogStatus(w http.ResponseWriter, r *http.Request) { if s.storageWatchdog == nil { writeDebugJSON(w, http.StatusOK, true, "", []interface{}{}) return } status := s.storageWatchdog.GetDebugStatus() writeDebugJSON(w, http.StatusOK, true, "", status) } // ── Section 5: Hub & connectivity ─────────────────────────────────── func (s *Server) debugHubPush(w http.ResponseWriter, r *http.Request) { if s.debugCallbacks == nil || s.debugCallbacks.TriggerHubReportPush == nil { writeDebugJSON(w, http.StatusNotImplemented, false, "Nem bekötött", nil) return } start := time.Now() err := s.debugCallbacks.TriggerHubReportPush() latency := time.Since(start).Milliseconds() if err != nil { writeDebugJSON(w, http.StatusOK, false, err.Error(), map[string]interface{}{"latency_ms": latency}) return } writeDebugJSON(w, http.StatusOK, true, "Hub jelentés elküldve", map[string]interface{}{"latency_ms": latency}) } func (s *Server) debugHubInfraPush(w http.ResponseWriter, r *http.Request) { if s.debugCallbacks == nil || s.debugCallbacks.TriggerHubInfraPush == nil { writeDebugJSON(w, http.StatusNotImplemented, false, "Nem bekötött", nil) return } start := time.Now() err := s.debugCallbacks.TriggerHubInfraPush() latency := time.Since(start).Milliseconds() if err != nil { writeDebugJSON(w, http.StatusOK, false, err.Error(), map[string]interface{}{"latency_ms": latency}) return } writeDebugJSON(w, http.StatusOK, true, "Infra backup elküldve a Hubra", map[string]interface{}{"latency_ms": latency}) } func (s *Server) debugHubConnectivity(w http.ResponseWriter, r *http.Request) { if s.debugCallbacks == nil || s.debugCallbacks.HubConnectivityTest == nil { writeDebugJSON(w, http.StatusNotImplemented, false, "Nem bekötött", nil) return } statusCode, latency, err := s.debugCallbacks.HubConnectivityTest() data := map[string]interface{}{ "status_code": statusCode, "latency_ms": latency, } if err != nil { writeDebugJSON(w, http.StatusOK, false, err.Error(), data) return } writeDebugJSON(w, http.StatusOK, true, fmt.Sprintf("Hub elérhető (HTTP %d, %dms)", statusCode, latency), data) } func (s *Server) debugPreferencesSync(w http.ResponseWriter, r *http.Request) { if s.notifier == nil { writeDebugJSON(w, http.StatusBadRequest, false, "Notifier nincs konfigurálva", nil) return } prefs := s.settings.GetNotificationPrefs() if err := s.notifier.SyncPreferences(prefs.Email, prefs.EnabledEvents, prefs.CooldownHours); err != nil { writeDebugJSON(w, http.StatusOK, false, err.Error(), nil) return } writeDebugJSON(w, http.StatusOK, true, "Preferenciák szinkronizálva", nil) } func (s *Server) debugGiteaConnectivity(w http.ResponseWriter, r *http.Request) { if s.debugCallbacks == nil || s.debugCallbacks.GiteaConnectivityTest == nil { writeDebugJSON(w, http.StatusNotImplemented, false, "Nem bekötött", nil) return } statusCode, latency, err := s.debugCallbacks.GiteaConnectivityTest() data := map[string]interface{}{ "status_code": statusCode, "latency_ms": latency, } if err != nil { writeDebugJSON(w, http.StatusOK, false, err.Error(), data) return } writeDebugJSON(w, http.StatusOK, true, fmt.Sprintf("Gitea elérhető (HTTP %d, %dms)", statusCode, latency), data) } // ── Section 6: Self-update ────────────────────────────────────────── func (s *Server) debugSelfUpdateDryRun(w http.ResponseWriter, r *http.Request) { if s.updater == nil { writeDebugJSON(w, http.StatusBadRequest, false, "Self-update nincs konfigurálva", nil) return } result := s.updater.DryRun() writeDebugJSON(w, http.StatusOK, true, "", result) } // ── Section 7: DR / Setup ─────────────────────────────────────────── func (s *Server) debugTriggerSetupWizard(w http.ResponseWriter, r *http.Request) { var req struct { Confirm string `json:"confirm"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeDebugJSON(w, http.StatusBadRequest, false, "Érvénytelen kérés", nil) return } if req.Confirm != "RESET" { writeDebugJSON(w, http.StatusBadRequest, false, "Érvénytelen megerősítés — írja be: RESET", nil) return } // Pre-check: verify infra backup exists on at least one drive if !s.hasInfraBackupOnDrive() { writeDebugJSON(w, http.StatusBadRequest, false, "Nincs infra backup egyetlen meghajtón sem! Először készítsen infra backupot.", nil) return } // Write marker file markerPath := filepath.Join(s.cfg.Paths.DataDir, ".needs-setup") if err := os.WriteFile(markerPath, []byte("debug-triggered\n"), 0644); err != nil { writeDebugJSON(w, http.StatusInternalServerError, false, fmt.Sprintf("Marker fájl írási hiba: %v", err), nil) return } writeDebugJSON(w, http.StatusOK, true, "Controller újraindítása setup módba...", nil) // Exit after response is sent so the container restarts into setup mode go func() { time.Sleep(500 * time.Millisecond) os.Exit(0) }() } func (s *Server) debugInfraBackupStatus(w http.ResponseWriter, r *http.Request) { storagePaths := s.settings.GetStoragePaths() drives := make([]map[string]interface{}, 0, len(storagePaths)) for _, sp := range storagePaths { if sp.Decommissioned || sp.Disconnected { continue } driveInfo := map[string]interface{}{ "path": sp.Path, "label": sp.Label, "has_backup": false, } infraDir := backup.InfraBackupDir(sp.Path) info, err := os.Stat(infraDir) if err == nil && info.IsDir() { driveInfo["has_backup"] = true driveInfo["last_modified"] = info.ModTime() // List files entries, _ := os.ReadDir(infraDir) files := make([]string, 0, len(entries)) for _, e := range entries { files = append(files, e.Name()) } driveInfo["files"] = files } drives = append(drives, driveInfo) } data := map[string]interface{}{ "drives": drives, } if s.hubPushStatusFn != nil { st := s.hubPushStatusFn() data["hub_infra_push"] = map[string]interface{}{ "last_attempt": st.LastAttempt, "last_success": st.LastSuccess, "last_error": st.LastError, } } writeDebugJSON(w, http.StatusOK, true, "", data) } // hasInfraBackupOnDrive checks if any connected storage drive has an infra backup. func (s *Server) hasInfraBackupOnDrive() bool { for _, sp := range s.settings.GetStoragePaths() { if sp.Decommissioned || sp.Disconnected { continue } infraDir := backup.InfraBackupDir(sp.Path) if info, err := os.Stat(infraDir); err == nil && info.IsDir() { return true } } return false } // ── Section 8: Log viewer ─────────────────────────────────────────── func (s *Server) debugLogBuffer(w http.ResponseWriter, r *http.Request) { if s.logBuffer == nil { writeDebugJSON(w, http.StatusOK, true, "", map[string]interface{}{ "entries": []interface{}{}, "total": 0, }) return } level := r.URL.Query().Get("level") if level == "" { level = "DEBUG" } limit := 200 if v := r.URL.Query().Get("limit"); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 1000 { limit = n } } var after time.Time if v := r.URL.Query().Get("after"); v != "" { if t, err := time.Parse(time.RFC3339Nano, v); err == nil { after = t } } entries, total := s.logBuffer.Entries(level, limit, after) writeDebugJSON(w, http.StatusOK, true, "", map[string]interface{}{ "entries": entries, "total": total, }) }