diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index dbc91f1..8f11856 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -18,6 +18,7 @@ import ( "gitea.dooplex.hu/admin/felhom-controller/internal/monitor" "gitea.dooplex.hu/admin/felhom-controller/internal/report" "gitea.dooplex.hu/admin/felhom-controller/internal/scheduler" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" catalogsync "gitea.dooplex.hu/admin/felhom-controller/internal/sync" "gitea.dooplex.hu/admin/felhom-controller/internal/system" @@ -51,6 +52,13 @@ func main() { logger.Printf("[INFO] felhom-controller %s starting (customer: %s, domain: %s)", Version, cfg.Customer.ID, cfg.Customer.Domain) + // --- Load settings --- + settingsPath := cfg.Paths.DataDir + "/settings.json" + sett, err := settings.Load(settingsPath, logger) + if err != nil { + logger.Fatalf("[FATAL] Failed to load settings from %s: %v", settingsPath, err) + } + // --- Initialize stack manager --- stackMgr, err := stacks.NewManager(cfg, logger) if err != nil { @@ -99,7 +107,7 @@ func main() { // --- Initialize backup manager --- var backupMgr *backup.Manager if cfg.Backup.Enabled { - backupMgr = backup.NewManager(cfg, pinger, logger) + backupMgr = backup.NewManager(cfg, pinger, sett, logger) backupMgr.AfterBackup = func() { nextDBDump := scheduler.NextDailyRun(cfg.Backup.DBDumpSchedule) nextBackup := scheduler.NextDailyRun(cfg.Backup.ResticSchedule) @@ -210,7 +218,7 @@ func main() { apiRouter := api.NewRouter(cfg, stackMgr, syncer, cpuCollector, backupMgr, metricsStore, logger) // --- Initialize web server --- - webServer := web.NewServer(cfg, stackMgr, cpuCollector, backupMgr, sched, logger, Version) + webServer := web.NewServer(cfg, stackMgr, cpuCollector, backupMgr, sched, sett, logger, Version) // --- Build HTTP mux --- mux := http.NewServeMux() diff --git a/controller/internal/backup/backup.go b/controller/internal/backup/backup.go index cda10a6..76d8380 100644 --- a/controller/internal/backup/backup.go +++ b/controller/internal/backup/backup.go @@ -11,14 +11,16 @@ import ( "gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/monitor" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // Manager orchestrates database dumps and restic backups. type Manager struct { - cfg *config.Config - restic *ResticManager - logger *log.Logger - pinger *monitor.Pinger + cfg *config.Config + restic *ResticManager + logger *log.Logger + pinger *monitor.Pinger + settings *settings.Settings mu sync.Mutex lastDBDump *DBDumpStatus @@ -100,12 +102,13 @@ type BackupStatus struct { } // NewManager creates a new backup manager. -func NewManager(cfg *config.Config, pinger *monitor.Pinger, logger *log.Logger) *Manager { +func NewManager(cfg *config.Config, pinger *monitor.Pinger, sett *settings.Settings, logger *log.Logger) *Manager { return &Manager{ - cfg: cfg, - restic: NewResticManager(cfg, logger), - logger: logger, - pinger: pinger, + cfg: cfg, + restic: NewResticManager(cfg, logger), + logger: logger, + pinger: pinger, + settings: sett, } } @@ -136,7 +139,7 @@ func (m *Manager) RunDBDumps(ctx context.Context) error { results := DumpAll(ctx, dbs, m.cfg.Paths.DBDumpDir, m.logger) - // Check results + // Check results and persist validations allOK := true var summary []string var totalSize int64 @@ -148,6 +151,22 @@ func (m *Manager) RunDBDumps(ctx context.Context) error { } else { totalSize += r.Size summary = append(summary, fmt.Sprintf("OK %s (%s)", r.DB.ContainerName, formatBytes(r.Size))) + + // Persist validation result to settings.json + if m.settings != nil && r.FilePath != "" { + filename := filepath.Base(r.FilePath) + cache := settings.DBValidationCache{ + ValidatedAt: time.Now().Format(time.RFC3339), + TableCount: r.Validation.TableCount, + HasHeader: r.Validation.Valid, + } + if !r.Validation.Valid { + cache.Error = r.Validation.Error + } + if err := m.settings.SetDBValidation(filename, cache); err != nil { + m.logger.Printf("[WARN] Failed to cache validation for %s: %v", filename, err) + } + } } } diff --git a/controller/internal/settings/settings.go b/controller/internal/settings/settings.go new file mode 100644 index 0000000..8dc6d4a --- /dev/null +++ b/controller/internal/settings/settings.go @@ -0,0 +1,129 @@ +package settings + +import ( + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "sync" +) + +// Settings holds customer-modifiable overrides and cached state. +// Persisted as a single JSON file (settings.json) in the data directory. +type Settings struct { + mu sync.RWMutex `json:"-"` + path string `json:"-"` + log *log.Logger `json:"-"` + + // Auth + PasswordHash string `json:"password_hash,omitempty"` // bcrypt hash, overrides controller.yaml + + // Notification preferences (Phase 2 — define struct now, leave empty) + Notifications *NotificationPrefs `json:"notifications,omitempty"` + + // Cached state + DBValidations map[string]DBValidationCache `json:"db_validations,omitempty"` +} + +// NotificationPrefs is a placeholder for Phase 2 notification settings. +type NotificationPrefs struct{} + +// DBValidationCache holds cached DB dump validation results. +type DBValidationCache struct { + ValidatedAt string `json:"validated_at"` // RFC3339 + TableCount int `json:"table_count"` + HasHeader bool `json:"has_header"` + Error string `json:"error,omitempty"` +} + +// Load reads settings from the given file path. +// Returns empty Settings if the file doesn't exist (not an error). +func Load(path string, logger *log.Logger) (*Settings, error) { + s := &Settings{ + path: path, + log: logger, + } + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + logger.Printf("[INFO] No settings.json found, using defaults") + return s, nil + } + return nil, fmt.Errorf("reading settings file: %w", err) + } + + if err := json.Unmarshal(data, s); err != nil { + return nil, fmt.Errorf("parsing settings file: %w", err) + } + + logger.Printf("[DEBUG] Settings loaded from %s", path) + return s, nil +} + +// Save writes settings to disk atomically (write to .tmp, rename). +// Caller must hold the write lock or call this from a method that does. +func (s *Settings) save() error { + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return fmt.Errorf("marshaling settings: %w", err) + } + + tmpPath := s.path + ".tmp" + if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil { + return fmt.Errorf("creating settings dir: %w", err) + } + + if err := os.WriteFile(tmpPath, data, 0644); err != nil { + return fmt.Errorf("writing tmp settings: %w", err) + } + + if err := os.Rename(tmpPath, s.path); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("renaming settings file: %w", err) + } + + s.log.Printf("[DEBUG] Settings saved to %s", s.path) + return nil +} + +// GetPasswordHash returns the stored password hash (thread-safe). +func (s *Settings) GetPasswordHash() string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.PasswordHash +} + +// SetPasswordHash updates the password hash and saves to disk. +func (s *Settings) SetPasswordHash(hash string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.PasswordHash = hash + return s.save() +} + +// GetDBValidations returns a copy of the cached DB validations. +func (s *Settings) GetDBValidations() map[string]DBValidationCache { + s.mu.RLock() + defer s.mu.RUnlock() + if s.DBValidations == nil { + return nil + } + result := make(map[string]DBValidationCache, len(s.DBValidations)) + for k, v := range s.DBValidations { + result[k] = v + } + return result +} + +// SetDBValidation saves a validation result for a dump file and persists to disk. +func (s *Settings) SetDBValidation(filename string, cache DBValidationCache) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.DBValidations == nil { + s.DBValidations = make(map[string]DBValidationCache) + } + s.DBValidations[filename] = cache + return s.save() +} diff --git a/controller/internal/web/auth.go b/controller/internal/web/auth.go index 8f5bf7f..e02ec2a 100644 --- a/controller/internal/web/auth.go +++ b/controller/internal/web/auth.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "fmt" "net/http" + "net/url" "strings" "time" @@ -17,14 +18,32 @@ type session struct { const ( sessionCookieName = "felhom_session" - sessionMaxAge = 24 * time.Hour + sessionMaxAge = 7 * 24 * time.Hour ) +// effectivePasswordHash returns the active password hash using the priority: +// 1. settings.json → password_hash (customer changed it) +// 2. controller.yaml → web.password_hash (operator provisioned) +// 3. Empty string → no auth required +func (s *Server) effectivePasswordHash() string { + if s.settings != nil { + if h := s.settings.GetPasswordHash(); h != "" { + return h + } + } + return s.cfg.Web.PasswordHash +} + +// authEnabled returns true if a password is configured from any source. +func (s *Server) authEnabled() bool { + return s.effectivePasswordHash() != "" +} + // RequireAuth returns middleware that checks for valid session or shows login. func (s *Server) RequireAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Skip auth if no password is configured - if s.cfg.Web.PasswordHash == "" { + if !s.authEnabled() { next.ServeHTTP(w, r) return } @@ -39,7 +58,7 @@ func (s *Server) RequireAuth(next http.Handler) http.Handler { return } if r.URL.Path == "/login" { - s.renderLogin(w, "") + s.renderLogin(w, "", r.URL.Query().Get("flash")) return } if r.URL.Path == "/logout" { @@ -55,7 +74,12 @@ func (s *Server) RequireAuth(next http.Handler) http.Handler { fmt.Fprint(w, `{"ok":false,"error":"authentication required"}`) return } - http.Redirect(w, r, "/login", http.StatusFound) + // Redirect to login with ?next= so we can return to the original page + loginURL := "/login" + if r.URL.Path != "/" && r.URL.Path != "/dashboard" { + loginURL = "/login?next=" + url.QueryEscape(r.URL.RequestURI()) + } + http.Redirect(w, r, loginURL, http.StatusFound) return } @@ -66,15 +90,17 @@ func (s *Server) RequireAuth(next http.Handler) http.Handler { func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() password := r.FormValue("password") + nextURL := r.FormValue("next") if password == "" { - s.renderLogin(w, "Kérjük adja meg a jelszót") + s.renderLogin(w, "Kérjük adja meg a jelszót", "") return } - if err := bcrypt.CompareHashAndPassword([]byte(s.cfg.Web.PasswordHash), []byte(password)); err != nil { + effectiveHash := s.effectivePasswordHash() + if err := bcrypt.CompareHashAndPassword([]byte(effectiveHash), []byte(password)); err != nil { s.logger.Printf("[WARN] Failed login from %s", r.RemoteAddr) - s.renderLogin(w, "Hibás jelszó") + s.renderLogin(w, "Hibás jelszó", "") return } @@ -91,7 +117,13 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { }) s.logger.Printf("[INFO] Login from %s", r.RemoteAddr) - http.Redirect(w, r, "/", http.StatusFound) + + // Redirect to ?next= target if provided, otherwise to dashboard + redirectTo := "/" + if nextURL != "" && strings.HasPrefix(nextURL, "/") { + redirectTo = nextURL + } + http.Redirect(w, r, redirectTo, http.StatusFound) } func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { @@ -123,6 +155,14 @@ func (s *Server) isValidSession(token string) bool { return ok && time.Now().Before(sess.expiresAt) } +// invalidateAllSessions clears all sessions, forcing re-login. +// Used after password change. +func (s *Server) invalidateAllSessions() { + s.sessionsMu.Lock() + s.sessions = make(map[string]*session) + s.sessionsMu.Unlock() +} + func (s *Server) cleanupSessions() { ticker := time.NewTicker(15 * time.Minute) defer ticker.Stop() @@ -148,11 +188,12 @@ func (s *Server) Close() { close(s.done) } -func (s *Server) renderLogin(w http.ResponseWriter, errorMsg string) { +func (s *Server) renderLogin(w http.ResponseWriter, errorMsg, flashMsg string) { data := map[string]interface{}{ "Title": "Bejelentkezés", "CustomerName": s.cfg.Customer.Name, "Error": errorMsg, + "Flash": flashMsg, } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := s.tmpl.ExecuteTemplate(w, "login", data); err != nil { diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index 61b9267..e3e414d 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -3,10 +3,12 @@ package web import ( "fmt" "net/http" + "net/url" "gitea.dooplex.hu/admin/felhom-controller/internal/scheduler" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" "gitea.dooplex.hu/admin/felhom-controller/internal/system" + "golang.org/x/crypto/bcrypt" ) func (s *Server) baseData(page, title string) map[string]interface{} { @@ -16,6 +18,7 @@ func (s *Server) baseData(page, title string) map[string]interface{} { "CustomerName": s.cfg.Customer.Name, "Domain": s.cfg.Customer.Domain, "Version": s.version, + "AuthEnabled": s.authEnabled(), } } @@ -210,3 +213,88 @@ func (s *Server) backupsHandler(w http.ResponseWriter, _ *http.Request) { s.render(w, "backups", data) } + +func (s *Server) settingsHandler(w http.ResponseWriter, _ *http.Request) { + data := s.baseData("settings", "Beállítások") + + // System configuration (read-only display from controller.yaml) + data["CustomerID"] = s.cfg.Customer.ID + data["CustomerDomain"] = s.cfg.Customer.Domain + data["GitRepoURL"] = s.cfg.Git.RepoURL + data["GitSyncInterval"] = s.cfg.Git.SyncInterval + data["BackupEnabled"] = s.cfg.Backup.Enabled + data["DBDumpSchedule"] = s.cfg.Backup.DBDumpSchedule + data["ResticSchedule"] = s.cfg.Backup.ResticSchedule + data["MonitoringEnabled"] = s.cfg.Monitoring.Enabled + data["HealthchecksBase"] = s.cfg.Monitoring.HealthchecksBase + data["HubEnabled"] = s.cfg.Hub.Enabled + + s.render(w, "settings", data) +} + +func (s *Server) settingsPasswordHandler(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + currentPassword := r.FormValue("current_password") + newPassword := r.FormValue("new_password") + confirmPassword := r.FormValue("confirm_password") + + data := s.baseData("settings", "Beállítások") + data["CustomerID"] = s.cfg.Customer.ID + data["CustomerDomain"] = s.cfg.Customer.Domain + data["GitRepoURL"] = s.cfg.Git.RepoURL + data["GitSyncInterval"] = s.cfg.Git.SyncInterval + data["BackupEnabled"] = s.cfg.Backup.Enabled + data["DBDumpSchedule"] = s.cfg.Backup.DBDumpSchedule + data["ResticSchedule"] = s.cfg.Backup.ResticSchedule + data["MonitoringEnabled"] = s.cfg.Monitoring.Enabled + data["HealthchecksBase"] = s.cfg.Monitoring.HealthchecksBase + data["HubEnabled"] = s.cfg.Hub.Enabled + + // Validate current password + effectiveHash := s.effectivePasswordHash() + if err := bcrypt.CompareHashAndPassword([]byte(effectiveHash), []byte(currentPassword)); err != nil { + data["PasswordError"] = "Hibás jelenlegi jelszó" + s.render(w, "settings", data) + return + } + + // Validate new password length + if len(newPassword) < 8 { + data["PasswordError"] = "A jelszónak legalább 8 karakter hosszúnak kell lennie" + s.render(w, "settings", data) + return + } + + // Validate passwords match + if newPassword != confirmPassword { + data["PasswordError"] = "A két jelszó nem egyezik" + s.render(w, "settings", data) + return + } + + // Generate bcrypt hash + hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), 10) + if err != nil { + s.logger.Printf("[ERROR] Failed to hash new password: %v", err) + data["PasswordError"] = "Belső hiba a jelszó mentésekor" + s.render(w, "settings", data) + return + } + + // Save to settings.json + if err := s.settings.SetPasswordHash(string(hash)); err != nil { + s.logger.Printf("[ERROR] Failed to save password to settings.json: %v", err) + data["PasswordError"] = "Belső hiba a jelszó mentésekor" + s.render(w, "settings", data) + return + } + + s.logger.Printf("[INFO] Password changed via settings page from %s", r.RemoteAddr) + + // Invalidate all sessions (force re-login) + s.invalidateAllSessions() + + // Redirect to login with flash message + flash := url.QueryEscape("Jelszó sikeresen módosítva. Kérjük, jelentkezzen be az új jelszóval.") + http.Redirect(w, r, "/login?flash="+flash, http.StatusFound) +} diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index cb4d353..61da45a 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -13,6 +13,7 @@ import ( "gitea.dooplex.hu/admin/felhom-controller/internal/backup" "gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/scheduler" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" "gitea.dooplex.hu/admin/felhom-controller/internal/system" ) @@ -23,6 +24,7 @@ type Server struct { cpuCollector *system.CPUCollector backupMgr *backup.Manager scheduler *scheduler.Scheduler + settings *settings.Settings logger *log.Logger version string tmpl *template.Template @@ -32,13 +34,14 @@ type Server struct { done chan struct{} } -func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, sched *scheduler.Scheduler, logger *log.Logger, version string) *Server { +func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, sched *scheduler.Scheduler, sett *settings.Settings, logger *log.Logger, version string) *Server { s := &Server{ cfg: cfg, stackMgr: stackMgr, cpuCollector: cpuCollector, backupMgr: backupMgr, scheduler: sched, + settings: sett, logger: logger, version: version, sessions: make(map[string]*session), @@ -46,6 +49,16 @@ func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *syste } s.loadTemplates() go s.cleanupSessions() + + // Log auth source on startup + if sett != nil && sett.GetPasswordHash() != "" { + logger.Printf("[INFO] Auth: using password from settings.json") + } else if cfg.Web.PasswordHash != "" { + logger.Printf("[INFO] Auth: using password from controller.yaml") + } else { + logger.Printf("[INFO] Auth: no password configured — dashboard is open") + } + return s } @@ -68,6 +81,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.backupsHandler(w, r) case path == "/monitoring": s.monitoringHandler(w, r) + case path == "/settings": + s.settingsHandler(w, r) + case path == "/settings/password" && r.Method == http.MethodPost: + s.settingsPasswordHandler(w, r) case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/logs"): name := strings.TrimPrefix(path, "/stacks/") name = strings.TrimSuffix(name, "/logs") diff --git a/controller/internal/web/templates/layout.html b/controller/internal/web/templates/layout.html index ccfd40b..08dc927 100644 --- a/controller/internal/web/templates/layout.html +++ b/controller/internal/web/templates/layout.html @@ -19,9 +19,12 @@
  • Biztonsági mentés
  • Rendszermonitor
  • -