v0.7.0: Phase 1 — Authentication, Persistence & Settings Page
- New settings.json persistence layer (internal/settings/settings.go) - Atomic write (tmp + rename), thread-safe with sync.RWMutex - Stores password hash overrides and DB validation cache - Auto-creates on first save, graceful handling if missing - Auth improvements - Password resolution priority: settings.json > controller.yaml > none - Session duration extended to 7 days (was 24h) - ?next= redirect after session expiry (returns to original page) - Flash messages on login page (used after password change) - Conditional logout link (hidden when auth disabled) - Session invalidation on password change - New Settings page (/settings) - Read-only system config display (customer, domain, git, backup, monitoring) - Password change form with validation (min 8 chars, match check) - Sidebar "Beállítások" item pinned to bottom above version - DB validation persistence - Validation results saved to settings.json after each dump - Cached data survives container restarts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,7 @@ import (
|
|||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/monitor"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/monitor"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/report"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/report"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
|
"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/stacks"
|
||||||
catalogsync "gitea.dooplex.hu/admin/felhom-controller/internal/sync"
|
catalogsync "gitea.dooplex.hu/admin/felhom-controller/internal/sync"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
"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)",
|
logger.Printf("[INFO] felhom-controller %s starting (customer: %s, domain: %s)",
|
||||||
Version, cfg.Customer.ID, cfg.Customer.Domain)
|
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 ---
|
// --- Initialize stack manager ---
|
||||||
stackMgr, err := stacks.NewManager(cfg, logger)
|
stackMgr, err := stacks.NewManager(cfg, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -99,7 +107,7 @@ func main() {
|
|||||||
// --- Initialize backup manager ---
|
// --- Initialize backup manager ---
|
||||||
var backupMgr *backup.Manager
|
var backupMgr *backup.Manager
|
||||||
if cfg.Backup.Enabled {
|
if cfg.Backup.Enabled {
|
||||||
backupMgr = backup.NewManager(cfg, pinger, logger)
|
backupMgr = backup.NewManager(cfg, pinger, sett, logger)
|
||||||
backupMgr.AfterBackup = func() {
|
backupMgr.AfterBackup = func() {
|
||||||
nextDBDump := scheduler.NextDailyRun(cfg.Backup.DBDumpSchedule)
|
nextDBDump := scheduler.NextDailyRun(cfg.Backup.DBDumpSchedule)
|
||||||
nextBackup := scheduler.NextDailyRun(cfg.Backup.ResticSchedule)
|
nextBackup := scheduler.NextDailyRun(cfg.Backup.ResticSchedule)
|
||||||
@@ -210,7 +218,7 @@ func main() {
|
|||||||
apiRouter := api.NewRouter(cfg, stackMgr, syncer, cpuCollector, backupMgr, metricsStore, logger)
|
apiRouter := api.NewRouter(cfg, stackMgr, syncer, cpuCollector, backupMgr, metricsStore, logger)
|
||||||
|
|
||||||
// --- Initialize web server ---
|
// --- 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 ---
|
// --- Build HTTP mux ---
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|||||||
@@ -11,14 +11,16 @@ import (
|
|||||||
|
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/monitor"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/monitor"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Manager orchestrates database dumps and restic backups.
|
// Manager orchestrates database dumps and restic backups.
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
restic *ResticManager
|
restic *ResticManager
|
||||||
logger *log.Logger
|
logger *log.Logger
|
||||||
pinger *monitor.Pinger
|
pinger *monitor.Pinger
|
||||||
|
settings *settings.Settings
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
lastDBDump *DBDumpStatus
|
lastDBDump *DBDumpStatus
|
||||||
@@ -100,12 +102,13 @@ type BackupStatus struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewManager creates a new backup manager.
|
// 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{
|
return &Manager{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
restic: NewResticManager(cfg, logger),
|
restic: NewResticManager(cfg, logger),
|
||||||
logger: logger,
|
logger: logger,
|
||||||
pinger: pinger,
|
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)
|
results := DumpAll(ctx, dbs, m.cfg.Paths.DBDumpDir, m.logger)
|
||||||
|
|
||||||
// Check results
|
// Check results and persist validations
|
||||||
allOK := true
|
allOK := true
|
||||||
var summary []string
|
var summary []string
|
||||||
var totalSize int64
|
var totalSize int64
|
||||||
@@ -148,6 +151,22 @@ func (m *Manager) RunDBDumps(ctx context.Context) error {
|
|||||||
} else {
|
} else {
|
||||||
totalSize += r.Size
|
totalSize += r.Size
|
||||||
summary = append(summary, fmt.Sprintf("OK %s (%s)", r.DB.ContainerName, formatBytes(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -17,14 +18,32 @@ type session struct {
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
sessionCookieName = "felhom_session"
|
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.
|
// RequireAuth returns middleware that checks for valid session or shows login.
|
||||||
func (s *Server) RequireAuth(next http.Handler) http.Handler {
|
func (s *Server) RequireAuth(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Skip auth if no password is configured
|
// Skip auth if no password is configured
|
||||||
if s.cfg.Web.PasswordHash == "" {
|
if !s.authEnabled() {
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -39,7 +58,7 @@ func (s *Server) RequireAuth(next http.Handler) http.Handler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if r.URL.Path == "/login" {
|
if r.URL.Path == "/login" {
|
||||||
s.renderLogin(w, "")
|
s.renderLogin(w, "", r.URL.Query().Get("flash"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if r.URL.Path == "/logout" {
|
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"}`)
|
fmt.Fprint(w, `{"ok":false,"error":"authentication required"}`)
|
||||||
return
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,15 +90,17 @@ func (s *Server) RequireAuth(next http.Handler) http.Handler {
|
|||||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
_ = r.ParseForm()
|
_ = r.ParseForm()
|
||||||
password := r.FormValue("password")
|
password := r.FormValue("password")
|
||||||
|
nextURL := r.FormValue("next")
|
||||||
|
|
||||||
if password == "" {
|
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
|
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.logger.Printf("[WARN] Failed login from %s", r.RemoteAddr)
|
||||||
s.renderLogin(w, "Hibás jelszó")
|
s.renderLogin(w, "Hibás jelszó", "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +117,13 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
s.logger.Printf("[INFO] Login from %s", r.RemoteAddr)
|
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) {
|
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)
|
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() {
|
func (s *Server) cleanupSessions() {
|
||||||
ticker := time.NewTicker(15 * time.Minute)
|
ticker := time.NewTicker(15 * time.Minute)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
@@ -148,11 +188,12 @@ func (s *Server) Close() {
|
|||||||
close(s.done)
|
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{}{
|
data := map[string]interface{}{
|
||||||
"Title": "Bejelentkezés",
|
"Title": "Bejelentkezés",
|
||||||
"CustomerName": s.cfg.Customer.Name,
|
"CustomerName": s.cfg.Customer.Name,
|
||||||
"Error": errorMsg,
|
"Error": errorMsg,
|
||||||
|
"Flash": flashMsg,
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := s.tmpl.ExecuteTemplate(w, "login", data); err != nil {
|
if err := s.tmpl.ExecuteTemplate(w, "login", data); err != nil {
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ package web
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) baseData(page, title string) map[string]interface{} {
|
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,
|
"CustomerName": s.cfg.Customer.Name,
|
||||||
"Domain": s.cfg.Customer.Domain,
|
"Domain": s.cfg.Customer.Domain,
|
||||||
"Version": s.version,
|
"Version": s.version,
|
||||||
|
"AuthEnabled": s.authEnabled(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,3 +213,88 @@ func (s *Server) backupsHandler(w http.ResponseWriter, _ *http.Request) {
|
|||||||
|
|
||||||
s.render(w, "backups", data)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
|
"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/stacks"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
||||||
)
|
)
|
||||||
@@ -23,6 +24,7 @@ type Server struct {
|
|||||||
cpuCollector *system.CPUCollector
|
cpuCollector *system.CPUCollector
|
||||||
backupMgr *backup.Manager
|
backupMgr *backup.Manager
|
||||||
scheduler *scheduler.Scheduler
|
scheduler *scheduler.Scheduler
|
||||||
|
settings *settings.Settings
|
||||||
logger *log.Logger
|
logger *log.Logger
|
||||||
version string
|
version string
|
||||||
tmpl *template.Template
|
tmpl *template.Template
|
||||||
@@ -32,13 +34,14 @@ type Server struct {
|
|||||||
done chan 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{
|
s := &Server{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
stackMgr: stackMgr,
|
stackMgr: stackMgr,
|
||||||
cpuCollector: cpuCollector,
|
cpuCollector: cpuCollector,
|
||||||
backupMgr: backupMgr,
|
backupMgr: backupMgr,
|
||||||
scheduler: sched,
|
scheduler: sched,
|
||||||
|
settings: sett,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
version: version,
|
version: version,
|
||||||
sessions: make(map[string]*session),
|
sessions: make(map[string]*session),
|
||||||
@@ -46,6 +49,16 @@ func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *syste
|
|||||||
}
|
}
|
||||||
s.loadTemplates()
|
s.loadTemplates()
|
||||||
go s.cleanupSessions()
|
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
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +81,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.backupsHandler(w, r)
|
s.backupsHandler(w, r)
|
||||||
case path == "/monitoring":
|
case path == "/monitoring":
|
||||||
s.monitoringHandler(w, r)
|
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"):
|
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/logs"):
|
||||||
name := strings.TrimPrefix(path, "/stacks/")
|
name := strings.TrimPrefix(path, "/stacks/")
|
||||||
name = strings.TrimSuffix(name, "/logs")
|
name = strings.TrimSuffix(name, "/logs")
|
||||||
|
|||||||
@@ -19,9 +19,12 @@
|
|||||||
<li><a href="/backups" class="{{if eq .Page "backups"}}active{{end}}">Biztonsági mentés</a></li>
|
<li><a href="/backups" class="{{if eq .Page "backups"}}active{{end}}">Biztonsági mentés</a></li>
|
||||||
<li><a href="/monitoring" class="{{if eq .Page "monitoring"}}active{{end}}">Rendszermonitor</a></li>
|
<li><a href="/monitoring" class="{{if eq .Page "monitoring"}}active{{end}}">Rendszermonitor</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-bottom">
|
||||||
<span class="version">v{{.Version}}</span>
|
<a href="/settings" class="sidebar-settings-link {{if eq .Page "settings"}}active{{end}}">⚙ Beállítások</a>
|
||||||
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
<div class="sidebar-footer">
|
||||||
|
<span class="version">v{{.Version}}</span>
|
||||||
|
{{if .AuthEnabled}}<a href="/logout" class="logout-link">Kijelentkezés ↗</a>{{end}}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<main class="content">
|
<main class="content">
|
||||||
|
|||||||
@@ -11,8 +11,10 @@
|
|||||||
<div class="login-card">
|
<div class="login-card">
|
||||||
<img src="/static/felhom-logo.svg" alt="Felhom.eu" class="login-logo">
|
<img src="/static/felhom-logo.svg" alt="Felhom.eu" class="login-logo">
|
||||||
<p class="login-subtitle">{{.CustomerName}}</p>
|
<p class="login-subtitle">{{.CustomerName}}</p>
|
||||||
|
{{if .Flash}}<div class="alert alert-info">{{.Flash}}</div>{{end}}
|
||||||
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
|
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
|
||||||
<form method="POST" action="/login">
|
<form method="POST" action="/login">
|
||||||
|
<input type="hidden" name="next" id="next-field" value="">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="password">Jelszó</label>
|
<label for="password">Jelszó</label>
|
||||||
<input type="password" id="password" name="password" required autofocus
|
<input type="password" id="password" name="password" required autofocus
|
||||||
@@ -23,6 +25,15 @@
|
|||||||
<p class="login-footer">Felhom — Otthoni szerver kezelés<br>
|
<p class="login-footer">Felhom — Otthoni szerver kezelés<br>
|
||||||
<a href="https://felhom.eu" target="_blank">felhom.eu</a></p>
|
<a href="https://felhom.eu" target="_blank">felhom.eu</a></p>
|
||||||
</div>
|
</div>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var params = new URLSearchParams(window.location.search);
|
||||||
|
var next = params.get('next');
|
||||||
|
if (next) {
|
||||||
|
document.getElementById('next-field').value = next;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
{{define "settings"}}
|
||||||
|
{{template "layout_start" .}}
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>Beállítások</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section A: System Configuration (read-only) -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<h3>Rendszer konfiguráció</h3>
|
||||||
|
<p class="settings-card-desc">Az üzemeltető által beállított értékek. Módosításhoz kérd az üzemeltetőt.</p>
|
||||||
|
<div class="settings-grid">
|
||||||
|
<div class="settings-row">
|
||||||
|
<span class="settings-label">Ügyfél azonosító</span>
|
||||||
|
<span class="settings-value mono">{{.CustomerID}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<span class="settings-label">Ügyfél neve</span>
|
||||||
|
<span class="settings-value">{{.CustomerName}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<span class="settings-label">Domain</span>
|
||||||
|
<span class="settings-value mono">{{.CustomerDomain}}</span>
|
||||||
|
</div>
|
||||||
|
{{if .GitRepoURL}}
|
||||||
|
<div class="settings-row">
|
||||||
|
<span class="settings-label">Alkalmazás sablon forrás</span>
|
||||||
|
<span class="settings-value mono settings-value-truncate">{{.GitRepoURL}}</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
<div class="settings-row">
|
||||||
|
<span class="settings-label">Sablon szinkronizálás</span>
|
||||||
|
<span class="settings-value mono">{{.GitSyncInterval}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<span class="settings-label">Biztonsági mentés</span>
|
||||||
|
<span class="settings-value">{{if .BackupEnabled}}<span class="state-text-green">✅ Aktív</span>{{else}}<span class="state-text-red">❌ Inaktív</span>{{end}}</span>
|
||||||
|
</div>
|
||||||
|
{{if .BackupEnabled}}
|
||||||
|
<div class="settings-row">
|
||||||
|
<span class="settings-label">Mentés ütemezés</span>
|
||||||
|
<span class="settings-value mono">{{.DBDumpSchedule}} / {{.ResticSchedule}}</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
<div class="settings-row">
|
||||||
|
<span class="settings-label">Monitoring</span>
|
||||||
|
<span class="settings-value">{{if .MonitoringEnabled}}<span class="state-text-green">✅ Aktív</span>{{else}}<span class="state-text-red">❌ Inaktív</span>{{end}}</span>
|
||||||
|
</div>
|
||||||
|
{{if .MonitoringEnabled}}
|
||||||
|
<div class="settings-row">
|
||||||
|
<span class="settings-label">Healthchecks URL</span>
|
||||||
|
<span class="settings-value mono settings-value-truncate">{{if .HealthchecksBase}}{{.HealthchecksBase}}{{else}}–{{end}}</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
<div class="settings-row">
|
||||||
|
<span class="settings-label">Hub jelentés</span>
|
||||||
|
<span class="settings-value">{{if .HubEnabled}}<span class="state-text-green">✅ Aktív</span>{{else}}–{{end}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<span class="settings-label">Controller verzió</span>
|
||||||
|
<span class="settings-value mono">v{{.Version}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section B: Password Change -->
|
||||||
|
<div class="settings-card">
|
||||||
|
<h3>Jelszó módosítás</h3>
|
||||||
|
{{if .AuthEnabled}}
|
||||||
|
{{if .PasswordError}}<div class="alert alert-error">{{.PasswordError}}</div>{{end}}
|
||||||
|
<form method="POST" action="/settings/password">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="current_password">Jelenlegi jelszó</label>
|
||||||
|
<input type="password" id="current_password" name="current_password" required
|
||||||
|
placeholder="Adja meg a jelenlegi jelszavát" class="form-control">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="new_password">Új jelszó</label>
|
||||||
|
<input type="password" id="new_password" name="new_password" required minlength="8"
|
||||||
|
placeholder="Legalább 8 karakter" class="form-control">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="confirm_password">Új jelszó megerősítése</label>
|
||||||
|
<input type="password" id="confirm_password" name="confirm_password" required minlength="8"
|
||||||
|
placeholder="Jelszó mégegyszer" class="form-control">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Jelszó módosítása</button>
|
||||||
|
</form>
|
||||||
|
{{else}}
|
||||||
|
<div class="alert alert-info">
|
||||||
|
A jelszavas védelem nincs beállítva. Kérd az üzemeltetőt a beállításhoz.
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{template "layout_end" .}}
|
||||||
|
{{end}}
|
||||||
@@ -1611,6 +1611,82 @@ a.stat-card:hover {
|
|||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Settings page --- */
|
||||||
|
.settings-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.5rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.settings-card h3 {
|
||||||
|
margin-bottom: .5rem;
|
||||||
|
}
|
||||||
|
.settings-card-desc {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: .85rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.settings-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.settings-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: .5rem .75rem;
|
||||||
|
border-bottom: 1px solid rgba(48, 54, 61, 0.3);
|
||||||
|
font-size: .9rem;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.settings-row:last-child { border-bottom: none; }
|
||||||
|
.settings-label {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.settings-value {
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-align: right;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.settings-value.mono {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: .85rem;
|
||||||
|
}
|
||||||
|
.settings-value-truncate {
|
||||||
|
max-width: 400px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar bottom section */
|
||||||
|
.sidebar-bottom {
|
||||||
|
margin-top: auto;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
.sidebar-settings-link {
|
||||||
|
display: block;
|
||||||
|
padding: .75rem 1.5rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: .95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: color 0.2s ease, background 0.2s ease;
|
||||||
|
}
|
||||||
|
.sidebar-settings-link:hover {
|
||||||
|
color: var(--accent-light);
|
||||||
|
background: rgba(0, 136, 204, 0.08);
|
||||||
|
}
|
||||||
|
.sidebar-settings-link.active {
|
||||||
|
color: var(--accent-light);
|
||||||
|
background: rgba(0, 136, 204, 0.12);
|
||||||
|
border-left: 3px solid var(--accent-blue);
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media(max-width: 768px) {
|
@media(max-width: 768px) {
|
||||||
.sidebar { width: 100%; height: auto; position: relative; border-right: none; border-bottom: 1px solid var(--border-color); }
|
.sidebar { width: 100%; height: auto; position: relative; border-right: none; border-bottom: 1px solid var(--border-color); }
|
||||||
|
|||||||
Reference in New Issue
Block a user