package setup import ( crand "crypto/rand" "crypto/sha256" "embed" "encoding/base64" "encoding/hex" "encoding/json" "fmt" "html/template" "log" "net/http" "os" "path/filepath" "strings" "sync" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/backup" "gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/report" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/web" "golang.org/x/crypto/bcrypt" ) //go:embed templates/*.html var templateFS embed.FS // Server handles the setup wizard HTTP routes. type Server struct { cfg *config.Config dataDir string logger *log.Logger tmpl *template.Template state *SetupState version string // Scan state for async drive scanning scanMu sync.Mutex scanRunning bool scanResults []DriveBackup scanDone bool scanError string // Restore progress restoreMu sync.Mutex restoreRunning bool restoreSteps []RestoreStep restoreError string restoreDone bool } // RestoreStep tracks progress of a restore operation. type RestoreStep struct { Label string `json:"label"` Status string `json:"status"` // "pending", "running", "done", "failed" Error string `json:"error,omitempty"` } // NewServer creates a new setup wizard server. func NewServer(cfg *config.Config, dataDir string, logger *log.Logger, version string) *Server { s := &Server{ cfg: cfg, dataDir: dataDir, logger: logger, state: LoadState(dataDir), version: version, } s.loadTemplates() return s } func (s *Server) loadTemplates() { s.tmpl = template.Must( template.New("").Funcs(template.FuncMap{ "timeNow": func() string { return time.Now().Format("2006-01-02 15:04") }, }).ParseFS(templateFS, "templates/*.html"), ) } // Handler returns the HTTP handler for the setup wizard. func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/", s.handleRoot) mux.HandleFunc("/setup", s.handleWelcome) mux.HandleFunc("/setup/scan", s.handleScan) mux.HandleFunc("/setup/scan/status", s.handleScanStatus) mux.HandleFunc("/setup/hub-restore", s.handleHubRestore) mux.HandleFunc("/setup/restore", s.handleRestore) mux.HandleFunc("/setup/restore/status", s.handleRestoreStatus) mux.HandleFunc("/setup/fresh", s.handleFreshHub) mux.HandleFunc("/setup/manual", s.handleManual) mux.HandleFunc("/setup/failed", s.handleFailed) mux.HandleFunc("/static/style.css", s.handleCSS) mux.HandleFunc("/static/felhom-logo.svg", s.handleLogo) return mux } // --- Route Handlers --- func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.Redirect(w, r, "/setup", http.StatusFound) return } http.Redirect(w, r, "/setup", http.StatusFound) } func (s *Server) handleWelcome(w http.ResponseWriter, r *http.Request) { csrf := ensureCSRFToken(w, r) domain := s.cfg.Customer.Domain ips := DetectLocalIPs() var accessURLs []string if domain != "" { accessURLs = append(accessURLs, fmt.Sprintf("https://felhom.%s", domain)) } for _, ip := range ips { accessURLs = append(accessURLs, fmt.Sprintf("http://%s%s", ip, s.cfg.Web.SetupListen)) } data := map[string]interface{}{ "CSRF": csrf, "AccessURLs": accessURLs, "Version": s.version, } s.render(w, "setup_welcome", data) } func (s *Server) handleScan(w http.ResponseWriter, r *http.Request) { csrf := ensureCSRFToken(w, r) // Start scan if not already running s.scanMu.Lock() if !s.scanRunning && !s.scanDone { s.scanRunning = true go s.runDriveScan() } s.scanMu.Unlock() s.state.SetStep("scan") data := map[string]interface{}{ "CSRF": csrf, } s.render(w, "setup_scan", data) } func (s *Server) handleScanStatus(w http.ResponseWriter, r *http.Request) { s.scanMu.Lock() defer s.scanMu.Unlock() resp := map[string]interface{}{ "running": s.scanRunning, "done": s.scanDone, "results": s.scanResults, "error": s.scanError, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) } func (s *Server) handleHubRestore(w http.ResponseWriter, r *http.Request) { csrf := ensureCSRFToken(w, r) if r.Method == http.MethodPost { if !validateCSRF(r) { http.Error(w, "Invalid CSRF token", http.StatusForbidden) return } s.processHubRestore(w, r) return } data := map[string]interface{}{ "CSRF": csrf, "CustomerID": s.state.GetFormField("customer_id"), } s.render(w, "setup_hub_restore", data) } func (s *Server) handleRestore(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Redirect(w, r, "/setup", http.StatusFound) return } if !validateCSRF(r) { http.Error(w, "Invalid CSRF token", http.StatusForbidden) return } source := r.FormValue("source") switch source { case "local": drivePath := r.FormValue("drive_path") go s.executeLocalRestore(drivePath) case "hub": go s.executeHubRestore() default: http.Error(w, "Invalid restore source", http.StatusBadRequest) return } csrf := ensureCSRFToken(w, r) data := map[string]interface{}{ "CSRF": csrf, } s.render(w, "setup_restore_exec", data) } func (s *Server) handleRestoreStatus(w http.ResponseWriter, r *http.Request) { s.restoreMu.Lock() defer s.restoreMu.Unlock() resp := map[string]interface{}{ "running": s.restoreRunning, "done": s.restoreDone, "steps": s.restoreSteps, "error": s.restoreError, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) } func (s *Server) handleFreshHub(w http.ResponseWriter, r *http.Request) { csrf := ensureCSRFToken(w, r) if r.Method == http.MethodPost { if !validateCSRF(r) { http.Error(w, "Invalid CSRF token", http.StatusForbidden) return } s.processFreshHub(w, r) return } data := map[string]interface{}{ "CSRF": csrf, "CustomerID": s.state.GetFormField("customer_id"), } s.render(w, "setup_fresh_hub", data) } func (s *Server) handleManual(w http.ResponseWriter, r *http.Request) { csrf := ensureCSRFToken(w, r) if r.Method == http.MethodPost { if !validateCSRF(r) { http.Error(w, "Invalid CSRF token", http.StatusForbidden) return } s.processManual(w, r) return } data := map[string]interface{}{ "CSRF": csrf, "FormData": s.state.FormData, "DefaultGit": "https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git", } s.render(w, "setup_manual", data) } func (s *Server) handleFailed(w http.ResponseWriter, r *http.Request) { csrf := ensureCSRFToken(w, r) data := map[string]interface{}{ "CSRF": csrf, } s.render(w, "setup_failed", data) } // --- Static Assets (reuse from web package embed) --- func (s *Server) handleCSS(w http.ResponseWriter, r *http.Request) { // Read the main style.css from the web package templates cssPath := filepath.Join(filepath.Dir(s.dataDir), "..", "internal", "web", "templates", "style.css") data, err := os.ReadFile(cssPath) if err != nil { // Fallback: serve minimal CSS w.Header().Set("Content-Type", "text/css; charset=utf-8") w.Write([]byte(minimalCSS)) return } w.Header().Set("Content-Type", "text/css; charset=utf-8") w.Header().Set("Cache-Control", "public, max-age=3600") w.Write(data) } func (s *Server) handleLogo(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "image/svg+xml") w.Header().Set("Cache-Control", "public, max-age=86400") fmt.Fprint(w, web.FelhomLogoSVG) } // --- Processing Logic --- func (s *Server) processHubRestore(w http.ResponseWriter, r *http.Request) { customerID := strings.TrimSpace(r.FormValue("customer_id")) password := r.FormValue("password") hubURL := DefaultHubURL s.state.SetFormField("customer_id", customerID) if customerID == "" || password == "" { s.renderError(w, r, "setup_hub_restore", "Kérem töltse ki mindkét mezőt.", customerID) return } recovery, err := report.PullRecovery(hubURL, customerID, password) if err != nil { var msg string switch { case isError(err, report.ErrHubUnreachable): msg = "A Hub (hub.felhom.eu) nem elérhető. Ellenőrizze az internetkapcsolatot." case isError(err, report.ErrAuthFailed): msg = "Helytelen ügyfél-azonosító vagy jelszó." case isError(err, report.ErrNotFound): msg = "Ez az ügyfél-azonosító nem található a Hub-on." default: msg = fmt.Sprintf("Hiba történt: %v", err) } s.renderError(w, r, "setup_hub_restore", msg, customerID) return } // Store recovery data in state for restore execution s.state.SelectedBackup = &SelectedBackup{ Source: "hub", CustomerID: customerID, } s.state.SetFormField("retrieval_password", password) s.state.SetFormField("hub_config_yaml", recovery.ConfigYAML) if recovery.HasInfraBackup && recovery.InfraBackup != nil { ibJSON, _ := json.Marshal(recovery.InfraBackup) s.state.SetFormField("hub_infra_backup", string(ibJSON)) s.state.SelectedBackup.Timestamp = recovery.InfraBackup.Timestamp } s.state.SetStep("restore-confirm") s.state.Save() // Show confirmation page with backup details csrf := ensureCSRFToken(w, r) data := map[string]interface{}{ "CSRF": csrf, "CustomerID": customerID, "HasInfraBackup": recovery.HasInfraBackup, "HasConfig": recovery.ConfigYAML != "", "Source": "hub", } if recovery.HasInfraBackup && recovery.InfraBackup != nil { data["Timestamp"] = recovery.InfraBackup.Timestamp data["StackCount"] = len(recovery.InfraBackup.DeployedStacks) } s.render(w, "setup_restore_exec", data) } func (s *Server) processFreshHub(w http.ResponseWriter, r *http.Request) { customerID := strings.TrimSpace(r.FormValue("customer_id")) password := r.FormValue("password") hubURL := DefaultHubURL s.state.SetFormField("customer_id", customerID) if customerID == "" || password == "" { s.renderError(w, r, "setup_fresh_hub", "Kérem töltse ki mindkét mezőt.", customerID) return } s.logger.Printf("[INFO] Setup: downloading config from Hub (%s) for customer %s", hubURL, customerID) configYAML, err := report.PullConfig(hubURL, customerID, password) if err != nil { s.logger.Printf("[ERROR] Setup: Hub config download failed: %v", err) var msg string switch { case isError(err, report.ErrHubUnreachable): msg = "A Hub (hub.felhom.eu) nem elérhető. Ellenőrizze az internetkapcsolatot." case isError(err, report.ErrAuthFailed): msg = "Helytelen ügyfél-azonosító vagy jelszó." case isError(err, report.ErrNotFound): msg = "Ez az ügyfél-azonosító nem található a Hub-on." default: msg = fmt.Sprintf("Hiba történt: %v", err) } s.renderError(w, r, "setup_fresh_hub", msg, customerID) return } s.logger.Printf("[INFO] Setup: config downloaded (%d bytes), writing config...", len(configYAML)) // Write config and finish setup s.state.SetFormField("retrieval_password", password) if err := s.writeFreshConfig(configYAML, password); err != nil { s.logger.Printf("[ERROR] Setup: writeFreshConfig failed: %v", err) s.renderError(w, r, "setup_fresh_hub", fmt.Sprintf("Konfigurációs hiba: %v", err), customerID) return } s.logger.Printf("[INFO] Setup: fresh install from Hub completed for %s", customerID) s.finishSetup() } func (s *Server) processManual(w http.ResponseWriter, r *http.Request) { // Save all form fields fields := []string{"customer_id", "display_name", "domain", "email", "cf_tunnel_token", "cf_api_token", "system_data_path", "password", "password_confirm", "git_repo_url", "git_username", "git_token"} for _, f := range fields { s.state.SetFormField(f, r.FormValue(f)) } // Validate customerID := strings.TrimSpace(r.FormValue("customer_id")) domain := strings.TrimSpace(r.FormValue("domain")) password := r.FormValue("password") passwordConfirm := r.FormValue("password_confirm") var errs []string if customerID == "" { errs = append(errs, "Ügyfél-azonosító kötelező") } if domain == "" || domain == "homeserver.local" { errs = append(errs, "Érvényes domain szükséges") } if password != "" && len(password) < 8 { errs = append(errs, "A jelszó legalább 8 karakter legyen") } if password != passwordConfirm { errs = append(errs, "A jelszavak nem egyeznek") } if len(errs) > 0 { csrf := ensureCSRFToken(w, r) data := map[string]interface{}{ "CSRF": csrf, "FormData": s.state.FormData, "DefaultGit": "https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git", "Errors": errs, } s.render(w, "setup_manual", data) return } // Generate controller.yaml configYAML := s.generateManualConfig() if err := s.writeFreshConfig(configYAML, ""); err != nil { csrf := ensureCSRFToken(w, r) data := map[string]interface{}{ "CSRF": csrf, "FormData": s.state.FormData, "DefaultGit": "https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git", "Errors": []string{fmt.Sprintf("Konfigurációs hiba: %v", err)}, } s.render(w, "setup_manual", data) return } s.logger.Printf("[INFO] Setup: manual configuration completed for %s", customerID) s.finishSetup() } // --- Restore Execution --- func (s *Server) executeLocalRestore(drivePath string) { s.restoreMu.Lock() s.restoreRunning = true s.restoreDone = false s.restoreError = "" s.restoreSteps = []RestoreStep{ {Label: "Mentés beolvasása...", Status: "running"}, {Label: "Konfiguráció visszaállítása...", Status: "pending"}, {Label: "Beállítás befejezése...", Status: "pending"}, } s.restoreMu.Unlock() // Step 1: Read backup backupData, _, err := backup.ReadLocalInfraBackup(drivePath) if err != nil { s.setRestoreError(0, fmt.Sprintf("Mentés olvasási hiba: %v", err)) return } var ib report.InfraBackup if err := json.Unmarshal(backupData, &ib); err != nil { s.setRestoreError(0, fmt.Sprintf("Mentés formátum hiba: %v", err)) return } s.setRestoreStepDone(0) // Step 2: Write config files s.setRestoreStepRunning(1) if err := s.writeRestoredConfig(&ib); err != nil { s.setRestoreError(1, fmt.Sprintf("Konfiguráció írási hiba: %v", err)) return } s.setRestoreStepDone(1) // Step 3: Finalize s.setRestoreStepRunning(2) // Save retrieval password from state if available retrievalPw := s.state.GetFormField("retrieval_password") if retrievalPw != "" { sett, err := settings.Load(filepath.Join(s.dataDir, "settings.json"), s.logger) if err == nil { sett.SetRetrievalPassword(retrievalPw) } } // Queue DR event s.queueDREvent("local", ib.Timestamp, len(ib.DeployedStacks)) s.setRestoreStepDone(2) s.restoreMu.Lock() s.restoreRunning = false s.restoreDone = true s.restoreMu.Unlock() s.logger.Printf("[INFO] Setup: local restore completed from %s", drivePath) // Wait a moment for the UI to poll, then exit time.Sleep(2 * time.Second) s.finishSetup() } func (s *Server) executeHubRestore() { s.restoreMu.Lock() s.restoreRunning = true s.restoreDone = false s.restoreError = "" s.restoreSteps = []RestoreStep{ {Label: "Konfiguráció visszaállítása...", Status: "running"}, {Label: "Beállítás befejezése...", Status: "pending"}, } s.restoreMu.Unlock() // Get stored data from state configYAML := s.state.GetFormField("hub_config_yaml") ibJSON := s.state.GetFormField("hub_infra_backup") // Write controller.yaml configPath := "/opt/docker/felhom-controller/controller.yaml" if err := atomicWriteFile(configPath, []byte(configYAML), 0600); err != nil { s.setRestoreError(0, fmt.Sprintf("Konfiguráció írási hiba: %v", err)) return } // Restore settings from infra backup if available if ibJSON != "" { var ib report.InfraBackup if err := json.Unmarshal([]byte(ibJSON), &ib); err == nil { s.restoreFromInfraBackup(&ib) } } s.setRestoreStepDone(0) // Step 2: Finalize s.setRestoreStepRunning(1) // Save retrieval password retrievalPw := s.state.GetFormField("retrieval_password") if retrievalPw != "" { sett, err := settings.Load(filepath.Join(s.dataDir, "settings.json"), s.logger) if err == nil { sett.SetRetrievalPassword(retrievalPw) } } // Queue DR event stackCount := 0 timestamp := "" if ibJSON != "" { var ib report.InfraBackup if json.Unmarshal([]byte(ibJSON), &ib) == nil { stackCount = len(ib.DeployedStacks) timestamp = ib.Timestamp } } s.queueDREvent("hub", timestamp, stackCount) s.setRestoreStepDone(1) s.restoreMu.Lock() s.restoreRunning = false s.restoreDone = true s.restoreMu.Unlock() s.logger.Printf("[INFO] Setup: Hub restore completed") time.Sleep(2 * time.Second) s.finishSetup() } // --- Config Writing --- func (s *Server) writeRestoredConfig(ib *report.InfraBackup) error { // Decode and write controller.yaml if ib.ControllerConfigB64 != "" { configData, err := base64.StdEncoding.DecodeString(ib.ControllerConfigB64) if err != nil { return fmt.Errorf("decoding controller.yaml: %w", err) } configPath := "/opt/docker/felhom-controller/controller.yaml" if err := atomicWriteFile(configPath, configData, 0600); err != nil { return fmt.Errorf("writing controller.yaml: %w", err) } } s.restoreFromInfraBackup(ib) return nil } func (s *Server) restoreFromInfraBackup(ib *report.InfraBackup) { // Decode and write settings.json if ib.SettingsJSONB64 != "" { if data, err := base64.StdEncoding.DecodeString(ib.SettingsJSONB64); err == nil { settingsPath := filepath.Join(s.dataDir, "settings.json") if err := atomicWriteFile(settingsPath, data, 0644); err != nil { s.logger.Printf("[WARN] Setup: failed to restore settings.json: %v", err) } } } // Restore restic password if ib.ResticPassword != "" { if data, err := base64.StdEncoding.DecodeString(ib.ResticPassword); err == nil { pwFile := "/opt/docker/felhom-controller/data/restic-password" if err := atomicWriteFile(pwFile, data, 0600); err != nil { s.logger.Printf("[WARN] Setup: failed to restore restic password: %v", err) } } } } func (s *Server) writeFreshConfig(configYAML, retrievalPassword string) error { configPath := "/opt/docker/felhom-controller/controller.yaml" if err := atomicWriteFile(configPath, []byte(configYAML), 0600); err != nil { return fmt.Errorf("writing controller.yaml: %w", err) } // Create initial settings with password hash and retrieval password sett, err := settings.Load(filepath.Join(s.dataDir, "settings.json"), s.logger) if err != nil { sett = &settings.Settings{} } // Hash the dashboard password if provided in form if pw := s.state.GetFormField("password"); pw != "" { hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost) if err == nil { sett.SetPasswordHash(string(hash)) } } if retrievalPassword != "" { sett.SetRetrievalPassword(retrievalPassword) } return nil } func (s *Server) generateManualConfig() string { fd := s.state.FormData customerID := fd["customer_id"] displayName := fd["display_name"] if displayName == "" { displayName = customerID } domain := fd["domain"] email := fd["email"] cfTunnelToken := fd["cf_tunnel_token"] cfAPIToken := fd["cf_api_token"] systemDataPath := fd["system_data_path"] if systemDataPath == "" { systemDataPath = "/mnt/sys_drive" } // Generate session secret secretBytes := make([]byte, 32) crand.Read(secretBytes) sessionSecret := hex.EncodeToString(secretBytes) // Generate password hash passwordHash := "" if pw := fd["password"]; pw != "" { if hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost); err == nil { passwordHash = string(hash) } } gitRepoURL := fd["git_repo_url"] if gitRepoURL == "" { gitRepoURL = "https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git" } gitUsername := fd["git_username"] gitToken := fd["git_token"] // Build YAML manually (simple key-value, no templates needed) var b strings.Builder b.WriteString("# Generated by felhom-controller setup wizard\n") b.WriteString("customer:\n") fmt.Fprintf(&b, " id: %q\n", customerID) fmt.Fprintf(&b, " name: %q\n", displayName) fmt.Fprintf(&b, " domain: %q\n", domain) if email != "" { fmt.Fprintf(&b, " email: %q\n", email) } b.WriteString("\ninfrastructure:\n") if cfTunnelToken != "" { fmt.Fprintf(&b, " cf_tunnel_token: %q\n", cfTunnelToken) } if cfAPIToken != "" { fmt.Fprintf(&b, " cf_api_token: %q\n", cfAPIToken) } b.WriteString("\npaths:\n") b.WriteString(" stacks_dir: \"/opt/docker/stacks\"\n") b.WriteString(" data_dir: \"/opt/docker/felhom-controller/data\"\n") fmt.Fprintf(&b, " system_data_path: %q\n", systemDataPath) b.WriteString("\nsystem:\n") b.WriteString(" reserved_memory_mb: 384\n") b.WriteString("\nweb:\n") b.WriteString(" listen: \":8080\"\n") b.WriteString(" setup_listen: \":8081\"\n") if passwordHash != "" { fmt.Fprintf(&b, " password_hash: %q\n", passwordHash) } fmt.Fprintf(&b, " session_secret: %q\n", sessionSecret) b.WriteString("\ngit:\n") fmt.Fprintf(&b, " repo_url: %q\n", gitRepoURL) b.WriteString(" branch: \"main\"\n") b.WriteString(" sync_interval: \"15m\"\n") if gitUsername != "" { fmt.Fprintf(&b, " username: %q\n", gitUsername) } if gitToken != "" { fmt.Fprintf(&b, " token: %q\n", gitToken) } b.WriteString("\nstacks:\n") b.WriteString(" protected:\n") b.WriteString(" - \"traefik\"\n") b.WriteString(" - \"cloudflared\"\n") b.WriteString(" - \"felhom-controller\"\n") b.WriteString(" - \"filebrowser\"\n") b.WriteString(" update_window: \"03:00-05:00\"\n") b.WriteString("\nbackup:\n") b.WriteString(" enabled: true\n") b.WriteString(" restic_password_file: \"/opt/docker/felhom-controller/data/restic-password\"\n") b.WriteString(" db_dump_schedule: \"02:30\"\n") b.WriteString(" restic_schedule: \"03:00\"\n") b.WriteString(" retention:\n") b.WriteString(" keep_daily: 7\n") b.WriteString(" keep_weekly: 4\n") b.WriteString(" keep_monthly: 6\n") b.WriteString(" prune_schedule: \"weekly\"\n") b.WriteString("\nmonitoring:\n") b.WriteString(" enabled: true\n") b.WriteString(" healthchecks_base: \"https://status.felhom.eu\"\n") b.WriteString(" system_health_interval: \"5m\"\n") b.WriteString(" health_check_schedule: \"06:00\"\n") b.WriteString("\nhub:\n") b.WriteString(" enabled: true\n") b.WriteString(" url: \"https://hub.felhom.eu\"\n") // Generate a Hub API key from customer ID apiKeyHash := sha256.Sum256([]byte(customerID + "-" + sessionSecret)) fmt.Fprintf(&b, " api_key: %q\n", hex.EncodeToString(apiKeyHash[:])) b.WriteString(" push_interval: \"15m\"\n") b.WriteString("\nself_update:\n") b.WriteString(" enabled: true\n") b.WriteString(" check_interval: \"6h\"\n") b.WriteString(" image: \"gitea.dooplex.hu/admin/felhom-controller\"\n") b.WriteString(" auto_update: false\n") b.WriteString(" health_timeout_seconds: 60\n") b.WriteString("\nlogging:\n") b.WriteString(" level: \"info\"\n") return b.String() } // --- Helpers --- func (s *Server) finishSetup() { s.state.Remove() s.logger.Printf("[INFO] Setup complete — restarting controller") os.Exit(0) // Docker restart policy will restart us } func (s *Server) queueDREvent(source, backupTimestamp string, stackCount int) { sett, err := settings.Load(filepath.Join(s.dataDir, "settings.json"), s.logger) if err != nil { s.logger.Printf("[WARN] Setup: failed to load settings for DR event: %v", err) return } details, _ := json.Marshal(map[string]interface{}{ "source": source, "backup_timestamp": backupTimestamp, "stacks_count": stackCount, "controller_version": s.version, }) sett.AddPendingEvent(settings.PendingEvent{ EventType: "disaster_recovery_completed", Severity: "warning", Message: "System restored from backup", Details: string(details), CreatedAt: time.Now().UTC().Format(time.RFC3339), }) } func (s *Server) setRestoreStepDone(idx int) { s.restoreMu.Lock() defer s.restoreMu.Unlock() if idx < len(s.restoreSteps) { s.restoreSteps[idx].Status = "done" } } func (s *Server) setRestoreStepRunning(idx int) { s.restoreMu.Lock() defer s.restoreMu.Unlock() if idx < len(s.restoreSteps) { s.restoreSteps[idx].Status = "running" } } func (s *Server) setRestoreError(idx int, msg string) { s.restoreMu.Lock() defer s.restoreMu.Unlock() if idx < len(s.restoreSteps) { s.restoreSteps[idx].Status = "failed" s.restoreSteps[idx].Error = msg } s.restoreRunning = false s.restoreError = msg } func (s *Server) render(w http.ResponseWriter, name string, data interface{}) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil { s.logger.Printf("[ERROR] Template %s render error: %v", name, err) http.Error(w, "Internal error", http.StatusInternalServerError) } } func (s *Server) renderError(w http.ResponseWriter, r *http.Request, tmpl, msg, customerID string) { csrf := ensureCSRFToken(w, r) data := map[string]interface{}{ "CSRF": csrf, "Error": msg, "CustomerID": customerID, } s.render(w, tmpl, data) } func atomicWriteFile(path string, data []byte, perm os.FileMode) error { if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return err } tmp := path + ".tmp" if err := os.WriteFile(tmp, data, perm); err != nil { os.Remove(tmp) return err } if err := os.Rename(tmp, path); err != nil { os.Remove(tmp) return err } return nil } func isError(err, target error) bool { return err != nil && strings.Contains(err.Error(), target.Error()) } // Minimal CSS for when the main stylesheet can't be loaded const minimalCSS = ` :root { --bg-primary: #0d1117; --bg-card: #1c2128; --text-primary: #e6edf3; --text-secondary: #8b949e; --accent-blue: #0088cc; --border: #30363d; --green: #238636; --red: #da3633; } * { margin: 0; padding: 0; box-sizing: border-box; } body { background: var(--bg-primary); color: var(--text-primary); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } .setup-container { max-width: 700px; margin: 0 auto; padding: 2rem 1.5rem; } .setup-header { text-align: center; margin-bottom: 2rem; } .setup-header img { width: 120px; margin-bottom: 1rem; } .setup-header h1 { font-size: 1.5rem; margin-bottom: 0.5rem; } .setup-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 1.5rem; margin-bottom: 1rem; cursor: pointer; transition: border-color 0.2s; } .setup-card:hover { border-color: var(--accent-blue); } .setup-card h3 { margin-bottom: 0.5rem; } .setup-card p { color: var(--text-secondary); font-size: 0.9rem; } .form-group { margin-bottom: 1rem; } .form-group label { display: block; margin-bottom: 0.25rem; font-size: 0.9rem; color: var(--text-secondary); } .form-control { width: 100%; padding: 0.5rem 0.75rem; background: var(--bg-primary); border: 1px solid var(--border); border-radius: 6px; color: var(--text-primary); font-size: 0.9rem; } .btn { display: inline-flex; align-items: center; justify-content: center; padding: 0.6rem 1.5rem; border-radius: 6px; border: none; font-size: 0.9rem; font-weight: 500; cursor: pointer; text-decoration: none; } .btn-primary { background: var(--green); color: #fff; } .btn-primary:hover { background: #2ea043; } .btn-outline { background: transparent; color: var(--text-secondary); border: 1px solid var(--border); } .alert { padding: 0.75rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.9rem; } .alert-error { background: rgba(218,54,51,0.15); color: #f85149; border: 1px solid rgba(218,54,51,0.3); } .alert-info { background: rgba(0,136,204,0.15); color: #58a6ff; border: 1px solid rgba(0,136,204,0.3); } .info-box { background: rgba(0,136,204,0.1); border: 1px solid rgba(0,136,204,0.2); border-radius: 6px; padding: 0.75rem 1rem; margin-bottom: 1.5rem; font-size: 0.85rem; color: var(--text-secondary); } table { width: 100%; border-collapse: collapse; } th { text-align: left; padding: 0.5rem 0.75rem; color: var(--text-secondary); font-size: 0.85rem; border-bottom: 1px solid var(--border); } td { padding: 0.6rem 0.75rem; border-bottom: 1px solid var(--border); font-size: 0.9rem; } .badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 0.75rem; } .badge-ok { background: rgba(63,185,80,0.15); color: var(--green); } .badge-error { background: rgba(218,54,51,0.15); color: var(--red); } .spinner { display: inline-block; width: 20px; height: 20px; border: 2px solid var(--border); border-top-color: var(--accent-blue); border-radius: 50%; animation: spin 0.8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } .section { margin-bottom: 1.5rem; } .section-header { cursor: pointer; padding: 0.75rem 1rem; background: var(--bg-card); border: 1px solid var(--border); border-radius: 6px; display: flex; justify-content: space-between; align-items: center; } .section-body { padding: 1rem; border: 1px solid var(--border); border-top: none; border-radius: 0 0 6px 6px; } .step-list { list-style: none; } .step-list li { padding: 0.5rem 0; display: flex; align-items: center; gap: 0.75rem; } .step-done { color: var(--green); } .step-running { color: var(--accent-blue); } .step-failed { color: var(--red); } `