Files
felhom.eu/hub/internal/web/server.go
T
admin a3ac6c9488 hub v0.26.0: pull-based config delivery + retire inbound GUI controls
config_version counter (bumped on every config save) advertised in the report
ACK; controller re-pulls + self-restarts on a change. Retire Trigger Update /
Push Config / Pull Config / Show Diff handlers+routes+buttons and the inbound
geo-notify (keep hub->Cloudflare geo removal). Setup command -> host-install;
delete dead customer.html + config_detail.html. Closes AUDIT-hub-gui F-S1/F-S4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HxLA1mZurFq9kt8hneFeCs
2026-06-30 21:49:33 +02:00

607 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package web
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"html/template"
"log"
"math"
"net/http"
"strconv"
"strings"
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/assets"
"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
assetsMgr *assets.Manager
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
}
// SetAssetManager sets the asset manager for the Configuration page (optional).
func (s *Server) SetAssetManager(am *assets.Manager) {
s.assetsMgr = am
}
// 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 == "/configuration":
if r.Method == http.MethodPost {
s.handleConfigurationAction(w, r)
} else {
s.handleConfiguration(w, r)
}
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/") && strings.HasSuffix(path, "/delete-issues"):
appName := strings.TrimPrefix(path, "/apps/")
appName = strings.TrimSuffix(appName, "/delete-issues")
if r.Method == http.MethodPost {
s.handleDeleteAppIssues(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, "/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, "/geo/disable"):
customerID := strings.TrimPrefix(path, "/customers/")
customerID = strings.TrimSuffix(customerID, "/geo/disable")
if r.Method == http.MethodPost {
s.handleGeoDisable(w, r, customerID)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/floor"):
customerID := strings.TrimPrefix(path, "/customers/")
customerID = strings.TrimSuffix(customerID, "/floor")
if r.Method == http.MethodPost {
s.handleSetCustomerFloor(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/") && strings.HasSuffix(path, "/dr-recipe.json"):
customerID := strings.TrimPrefix(path, "/customers/")
customerID = strings.TrimSuffix(customerID, "/dr-recipe.json")
s.handleDRRecipeDownload(w, r, customerID)
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 path == "/configs/global-floor":
if r.Method == http.MethodPost {
s.handleSetGlobalFloor(w, r)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case path == "/configs/artifacts":
if r.Method == http.MethodPost {
s.handleSetArtifacts(w, r)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
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(`<html><head><title>Felhom Hub — Bejelentkezés</title></head><body style="font-family:sans-serif;display:flex;justify-content:center;padding-top:4rem"><form method="post" style="display:flex;flex-direction:column;gap:.75rem;width:300px"><h2>Felhom Hub</h2><p style="color:red">Hibás jelszó</p><input type="password" name="password" placeholder="Jelszó" autofocus style="padding:.5rem;border:1px solid #ccc;border-radius:4px"><button type="submit" style="padding:.5rem;background:#0088cc;color:#fff;border:none;border-radius:4px;cursor:pointer">Bejelentkezés</button></form></body></html>`))
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`<html><head><title>Felhom Hub — Bejelentkezés</title></head><body style="font-family:sans-serif;display:flex;justify-content:center;padding-top:4rem"><form method="post" style="display:flex;flex-direction:column;gap:.75rem;width:300px"><h2>Felhom Hub</h2><input type="password" name="password" placeholder="Jelszó" autofocus style="padding:.5rem;border:1px solid #ccc;border-radius:4px"><button type="submit" style="padding:.5rem;background:#0088cc;color:#fff;border:none;border-radius:4px;cursor:pointer">Bejelentkezés</button></form></body></html>`))
}
// 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(`<input type="hidden" name="_csrf" value="` + template.HTMLEscapeString(tok) + `">`)
}
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)
}
}
// 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 "●"
}
// handleConfiguration renders the Configuration page.
func (s *Server) handleConfiguration(w http.ResponseWriter, r *http.Request) {
csrfToken := s.getCSRFToken(r)
assetCount := 0
assetLastSync := ""
if s.assetsMgr != nil {
assetCount = s.assetsMgr.FileCount()
if m := s.assetsMgr.GetManifest(); m != nil {
assetLastSync = m.Generated
}
}
data := map[string]interface{}{
"CSRFToken": csrfToken,
"AssetCount": assetCount,
"AssetLastSync": assetLastSync,
"Flash": r.URL.Query().Get("flash"),
}
if err := s.templates.ExecuteTemplate(w, "configuration.html", data); err != nil {
s.logger.Printf("[ERROR] configuration.html template: %v", err)
}
}
// handleConfigurationAction handles POST actions on the Configuration page.
func (s *Server) handleConfigurationAction(w http.ResponseWriter, r *http.Request) {
action := r.FormValue("action")
switch action {
case "refresh_assets":
if s.assetsMgr == nil {
http.Redirect(w, r, "/configuration?flash=assets_not_configured", http.StatusSeeOther)
return
}
if err := s.assetsMgr.ReSeed(); err != nil {
s.logger.Printf("[ERROR] Asset re-seed failed: %v", err)
http.Redirect(w, r, "/configuration?flash=assets_error", http.StatusSeeOther)
return
}
s.logger.Printf("[INFO] Manual asset re-seed completed")
http.Redirect(w, r, "/configuration?flash=assets_refreshed", http.StatusSeeOther)
default:
http.Redirect(w, r, "/configuration", http.StatusSeeOther)
}
}