package web import ( "context" "crypto/rand" "crypto/subtle" "encoding/hex" "encoding/json" "fmt" "html/template" "io" "log" "math" "net/http" "strconv" "strings" "sync" "time" "gitea.dooplex.hu/admin/felhom-hub/internal/store" "golang.org/x/crypto/bcrypt" ) // hubSession holds per-session auth and CSRF data. type hubSession struct { expiresAt time.Time csrfToken string } // Server handles the dashboard web UI. type Server struct { store *store.Store passwordHash string apiKey string // report API key — used for controller callbacks version string logger *log.Logger templates *template.Template staleThreshold time.Duration versionChecker *VersionChecker templateFetcher *TemplateFetcher sessions map[string]*hubSession sessionsMu sync.RWMutex } // New creates a new web server. func New(store *store.Store, passwordHash, apiKey, version string, staleThreshold time.Duration, logger *log.Logger) *Server { funcMap := template.FuncMap{ "timeAgo": timeAgo, "statusColor": statusColor, "statusIcon": statusIcon, "formatFloat": func(f float64) string { return fmt.Sprintf("%.0f", f) }, "joinStrings": func(s []string, sep string) string { return strings.Join(s, sep) }, "json": func(v interface{}) template.JS { b, _ := json.Marshal(v) return template.JS(b) }, "hubVersion": func() string { return version }, "add": func(a, b int) int { return a + b }, "mapGet": func(m map[string]int, key string) int { if m == nil { return 0 } return m[key] }, "memoryColor": memoryColor, "accuracyClass": accuracyClass, "gt": func(a, b int) bool { return a > b }, } tmpl := template.Must(template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html")) return &Server{ store: store, passwordHash: passwordHash, apiKey: apiKey, version: version, logger: logger, templates: tmpl, staleThreshold: staleThreshold, sessions: make(map[string]*hubSession), } } // CleanupSessions removes expired sessions. Call with: go s.CleanupSessions(ctx). func (s *Server) CleanupSessions(ctx context.Context) { ticker := time.NewTicker(15 * time.Minute) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: s.sessionsMu.Lock() now := time.Now() for t, sess := range s.sessions { if now.After(sess.expiresAt) { delete(s.sessions, t) } } s.sessionsMu.Unlock() } } } // SetVersionChecker sets the version checker (optional, may be nil if no registry credentials). func (s *Server) SetVersionChecker(vc *VersionChecker) { s.versionChecker = vc } // SetTemplateFetcher sets the template fetcher for config generation (optional). func (s *Server) SetTemplateFetcher(tf *TemplateFetcher) { s.templateFetcher = tf } // ServeHTTP routes web requests. func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { path := r.URL.Path // CSRF protection for all state-changing requests (web routes only). // API routes (/api/v1/) are Bearer-token authenticated and exempt. if r.Method != http.MethodGet && r.Method != http.MethodHead && r.Method != http.MethodOptions { if path != "/login" && s.passwordHash != "" { if !s.validateCSRF(r) { s.logger.Printf("[WARN] CSRF rejected: %s %s from %s", r.Method, path, r.RemoteAddr) http.Error(w, "CSRF token missing or invalid. Please reload the page.", http.StatusForbidden) return } } } switch { case path == "/": s.handleDashboard(w, r) case path == "/style.css": s.handleCSS(w, r) case path == "/static/chart.min.js": w.Header().Set("Content-Type", "application/javascript") w.Header().Set("Cache-Control", "public, max-age=86400") w.Write(chartJS) case path == "/apps" || path == "/apps/": s.handleApps(w, r) case strings.HasPrefix(path, "/apps/") && strings.HasSuffix(path, "/reset-telemetry"): appName := strings.TrimPrefix(path, "/apps/") appName = strings.TrimSuffix(appName, "/reset-telemetry") if r.Method == http.MethodPost { s.handleResetAppTelemetry(w, r, appName) } else { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } case strings.HasPrefix(path, "/apps/"): appName := strings.TrimPrefix(path, "/apps/") s.handleAppDetail(w, r, appName) case path == "/login": s.handleLogin(w, r) case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/trigger-update"): customerID := strings.TrimPrefix(path, "/customers/") customerID = strings.TrimSuffix(customerID, "/trigger-update") if r.Method == http.MethodPost { s.handleTriggerUpdate(w, r, customerID) } else { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/block"): customerID := strings.TrimPrefix(path, "/customers/") customerID = strings.TrimSuffix(customerID, "/block") if r.Method == http.MethodPost { s.handleBlockCustomer(w, r, customerID) } else { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/unblock"): customerID := strings.TrimPrefix(path, "/customers/") customerID = strings.TrimSuffix(customerID, "/unblock") if r.Method == http.MethodPost { s.handleUnblockCustomer(w, r, customerID) } else { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/push-config"): customerID := strings.TrimPrefix(path, "/customers/") customerID = strings.TrimSuffix(customerID, "/push-config") if r.Method == http.MethodPost { s.handlePushConfig(w, r, customerID) } else { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/pull-config"): customerID := strings.TrimPrefix(path, "/customers/") customerID = strings.TrimSuffix(customerID, "/pull-config") if r.Method == http.MethodPost { s.handlePullConfig(w, r, customerID) } else { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/config-diff"): customerID := strings.TrimPrefix(path, "/customers/") customerID = strings.TrimSuffix(customerID, "/config-diff") if r.Method == http.MethodGet { s.handleConfigDiff(w, r, customerID) } else { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/create-config"): customerID := strings.TrimPrefix(path, "/customers/") customerID = strings.TrimSuffix(customerID, "/create-config") if r.Method == http.MethodPost { s.handleCreateConfigFromReport(w, r, customerID) } else { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } case strings.HasPrefix(path, "/customers/"): customerID := strings.TrimPrefix(path, "/customers/") s.handleCustomerUnified(w, r, customerID) // Config management routes — exact matches first, then prefix matches case path == "/configs": s.handleConfigList(w, r) case path == "/configs/new": if r.Method == http.MethodPost { s.handleConfigCreate(w, r) } else { s.handleConfigNewForm(w, r) } case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/delete"): customerID := strings.TrimPrefix(path, "/configs/") customerID = strings.TrimSuffix(customerID, "/delete") if r.Method == http.MethodPost { s.handleConfigDelete(w, r, customerID) } else { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/edit"): customerID := strings.TrimPrefix(path, "/configs/") customerID = strings.TrimSuffix(customerID, "/edit") if r.Method == http.MethodPost { s.handleConfigUpdate(w, r, customerID) } else { s.handleConfigEditForm(w, r, customerID) } case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/preview"): customerID := strings.TrimPrefix(path, "/configs/") customerID = strings.TrimSuffix(customerID, "/preview") s.handleConfigPreview(w, r, customerID) case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/regen-password"): customerID := strings.TrimPrefix(path, "/configs/") customerID = strings.TrimSuffix(customerID, "/regen-password") if r.Method == http.MethodPost { s.handleConfigRegenPassword(w, r, customerID) } else { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } case strings.HasPrefix(path, "/configs/"): // Redirect old config detail URL to unified customer page customerID := strings.TrimPrefix(path, "/configs/") http.Redirect(w, r, "/customers/"+customerID, http.StatusSeeOther) default: http.NotFound(w, r) } } // RequireAuth wraps a handler with session or basic authentication. func (s *Server) RequireAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Skip auth if no password configured if s.passwordHash == "" { next.ServeHTTP(w, r) return } // Always allow the login page through (GET and POST) if r.URL.Path == "/login" { next.ServeHTTP(w, r) return } // Check session cookie (random token stored server-side) if cookie, err := r.Cookie("hub_session"); err == nil { s.sessionsMu.RLock() sess, ok := s.sessions[cookie.Value] s.sessionsMu.RUnlock() if ok && time.Now().Before(sess.expiresAt) { next.ServeHTTP(w, r) return } } // Check basic auth (for programmatic/CLI access) _, password, ok := r.BasicAuth() if ok && bcrypt.CompareHashAndPassword([]byte(s.passwordHash), []byte(password)) == nil { next.ServeHTTP(w, r) return } // Redirect browsers to login page; send 401 for API-like requests if r.Header.Get("Accept") == "application/json" || r.Header.Get("X-Requested-With") != "" { w.Header().Set("WWW-Authenticate", `Basic realm="Felhom Hub"`) http.Error(w, "Unauthorized", http.StatusUnauthorized) return } http.Redirect(w, r, "/login", http.StatusFound) }) } func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost { password := r.FormValue("password") if s.passwordHash != "" && bcrypt.CompareHashAndPassword([]byte(s.passwordHash), []byte(password)) == nil { // Generate random session token b := make([]byte, 32) _, _ = rand.Read(b) sessionToken := hex.EncodeToString(b) // Generate CSRF token cb := make([]byte, 32) _, _ = rand.Read(cb) csrfToken := hex.EncodeToString(cb) s.sessionsMu.Lock() s.sessions[sessionToken] = &hubSession{ expiresAt: time.Now().Add(7 * 24 * time.Hour), csrfToken: csrfToken, } s.sessionsMu.Unlock() isSecure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" http.SetCookie(w, &http.Cookie{ Name: "hub_session", Value: sessionToken, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: isSecure, MaxAge: 86400 * 7, }) http.Redirect(w, r, "/", http.StatusSeeOther) return } // Render login with error w.WriteHeader(http.StatusUnauthorized) w.Write([]byte(`Felhom Hub — Bejelentkezés

Felhom Hub

Hibás jelszó

`)) return } w.WriteHeader(http.StatusOK) w.Write([]byte(`Felhom Hub — Bejelentkezés

Felhom Hub

`)) } // validateCSRF checks the CSRF token for a session-based request. // Returns true if CSRF is valid or if no session cookie is present (Basic Auth path). func (s *Server) validateCSRF(r *http.Request) bool { cookie, err := r.Cookie("hub_session") if err != nil { // No session cookie — likely Basic Auth or programmatic access; skip CSRF return true } s.sessionsMu.RLock() sess, ok := s.sessions[cookie.Value] s.sessionsMu.RUnlock() if !ok { return false } submitted := r.FormValue("_csrf") if submitted == "" { submitted = r.Header.Get("X-CSRF-Token") } return submitted != "" && subtle.ConstantTimeCompare([]byte(submitted), []byte(sess.csrfToken)) == 1 } // csrfToken returns the CSRF token for the current session. func (s *Server) csrfToken(r *http.Request) string { cookie, err := r.Cookie("hub_session") if err != nil { return "" } s.sessionsMu.RLock() sess, ok := s.sessions[cookie.Value] s.sessionsMu.RUnlock() if !ok { return "" } return sess.csrfToken } // csrfField returns an HTML hidden input for embedding in forms. func (s *Server) csrfField(r *http.Request) template.HTML { tok := s.csrfToken(r) return template.HTML(``) } func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { customers, err := s.store.GetCustomers() if err != nil { s.logger.Printf("[ERROR] Dashboard: %v", err) http.Error(w, "Internal error", http.StatusInternalServerError) return } configs, _ := s.store.ListCustomerConfigs() type dashboardCustomer struct { store.CustomerSummary OverallStatus string // "ok", "warn", "down", "pending" BackupAge string EventErrors int EventWarnings int } // Build map of report customers keyed by ID seen := make(map[string]bool) var data []dashboardCustomer for _, c := range customers { // Skip blocked customers if s.store.IsCustomerBlocked(c.CustomerID) { continue } seen[c.CustomerID] = true dc := dashboardCustomer{CustomerSummary: c} // Determine overall status if c.HealthStatus == "disabled" { dc.OverallStatus = "disabled" } else if c.TimeSinceReport > time.Hour { dc.OverallStatus = "down" } else if c.TimeSinceReport > 30*time.Minute || c.HealthStatus == "warn" { dc.OverallStatus = "warn" } else if c.HealthStatus == "fail" { dc.OverallStatus = "down" } else { dc.OverallStatus = "ok" } // Backup age if c.BackupLastSnapshot != nil { dc.BackupAge = timeAgo(*c.BackupLastSnapshot) } else { dc.BackupAge = "–" } // Event counts (last 24h) if counts, err := s.store.CountEventsBySeverity(c.CustomerID, time.Now().Add(-24*time.Hour)); err == nil { dc.EventErrors = counts["error"] dc.EventWarnings = counts["warning"] } data = append(data, dc) } // Add config-only customers (no reports yet) as "pending" for _, cfg := range configs { if seen[cfg.CustomerID] || cfg.Status == "blocked" { continue } dc := dashboardCustomer{ CustomerSummary: store.CustomerSummary{ CustomerID: cfg.CustomerID, CustomerName: cfg.CustomerName, }, OverallStatus: "pending", BackupAge: "–", } data = append(data, dc) } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := s.templates.ExecuteTemplate(w, "dashboard.html", data); err != nil { s.logger.Printf("[ERROR] Template render: %v", err) } } func (s *Server) handleTriggerUpdate(w http.ResponseWriter, r *http.Request, customerID string) { customer, err := s.store.GetCustomer(customerID) if err != nil { s.logger.Printf("[ERROR] Trigger update — get customer %s: %v", customerID, err) http.Error(w, "Internal error", http.StatusInternalServerError) return } if customer == nil { http.NotFound(w, r) return } // Get controller URL — from denormalized field or report JSON fallback controllerURL := customer.ControllerURL if controllerURL == "" { var rpt struct { ControllerURL string `json:"controller_url"` } json.Unmarshal([]byte(customer.ReportJSON), &rpt) controllerURL = rpt.ControllerURL } if controllerURL == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) w.Write([]byte(`{"ok":false,"error":"Controller URL not available — waiting for next report"}`)) return } if s.apiKey == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(`{"ok":false,"error":"API key not configured"}`)) return } // POST to controller's self-update endpoint updateURL := controllerURL + "/api/selfupdate/update" req, err := http.NewRequest("POST", updateURL, nil) if err != nil { s.logger.Printf("[ERROR] Trigger update — create request for %s: %v", updateURL, err) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(`{"ok":false,"error":"Failed to create request"}`)) return } req.Header.Set("Authorization", "Bearer "+s.apiKey) client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { s.logger.Printf("[ERROR] Trigger update — request to %s failed: %v", updateURL, err) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadGateway) json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": fmt.Sprintf("Controller unreachable: %v", err)}) return } defer resp.Body.Close() // Forward the controller's response body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16)) s.logger.Printf("[INFO] Trigger update for %s — controller responded %d: %s", customerID, resp.StatusCode, string(body)) w.Header().Set("Content-Type", "application/json") w.WriteHeader(resp.StatusCode) w.Write(body) } // compareVersions returns >0 if a > b, 0 if equal, <0 if a < b. // Accepts "X.Y.Z" format. Returns 0 on parse error. func compareVersions(a, b string) int { a = strings.TrimPrefix(a, "v") b = strings.TrimPrefix(b, "v") aParts := strings.SplitN(a, ".", 3) bParts := strings.SplitN(b, ".", 3) if len(aParts) != 3 || len(bParts) != 3 { return 0 } for i := 0; i < 3; i++ { ai, e1 := strconv.Atoi(aParts[i]) bi, e2 := strconv.Atoi(bParts[i]) if e1 != nil || e2 != nil { return 0 } if ai != bi { return ai - bi } } return 0 } func (s *Server) handleCSS(w http.ResponseWriter, r *http.Request) { data, err := templateFS.ReadFile("templates/style.css") if err != nil { http.NotFound(w, r) return } w.Header().Set("Content-Type", "text/css") w.Header().Set("Cache-Control", "public, max-age=3600") w.Write(data) } func timeAgo(t time.Time) string { d := time.Since(t) if d < time.Minute { return "just now" } if d < time.Hour { m := int(math.Round(d.Minutes())) return fmt.Sprintf("%d min ago", m) } if d < 24*time.Hour { h := int(math.Round(d.Hours())) return fmt.Sprintf("%dh ago", h) } days := int(d.Hours() / 24) return fmt.Sprintf("%dd ago", days) } func statusColor(status string) string { switch status { case "ok": return "#4ade80" // green case "warn": return "#facc15" // yellow case "down", "fail": return "#f87171" // red case "disabled", "pending", "blocked": return "#94a3b8" // gray default: return "#94a3b8" // gray } } func statusIcon(status string) string { return "●" }