Files
felhom.eu/hub/internal/web/server.go
T
admin 6b40eb8619 hub: customer-claim password arc parts 1+2 — code engine, emails, ACK, configgen bake, UI (v0.50.0)
Closes DRILL-day0-vm F-4 hub-side: per-customer claim state (customer_claims,
bcrypt-only custody), the claim engine (issue at real config retrieve = Day-0
bake; first-report issue for live boxes; resend rotates generation; reset
rate-limited 3/day), three Hungarian emails via the dispatcher, report-ACK
claim object {code_hash, generation, issued_at} + set-only claimed ingest,
web.claim_code_* baked into generated controller.yaml, Setup-tab status chip
+ resend button, POST /api/v1/claim/reset-request (self-scoped), claim_lockout
event allowlisted. 13 new tests; full repo green.

Claude-Session: https://claude.ai/code/session_01NptTCFtu7dz2Ru89qHRagN
2026-07-12 18:12:48 +02:00

794 lines
28 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"
"strings"
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/assets"
"gitea.dooplex.hu/admin/felhom-hub/internal/claim"
"gitea.dooplex.hu/admin/felhom-hub/internal/gitea"
"gitea.dooplex.hu/admin/felhom-hub/internal/offsite"
"gitea.dooplex.hu/admin/felhom-hub/internal/semver"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
"golang.org/x/crypto/bcrypt"
)
// Generic-package names + the artifact filename inside each version (used to resolve version lists +
// sha256 from Gitea for the Day-0 artifact manifest UI).
const (
pkgAgent = "felhom-agent"
fileAgent = "felhom-agent"
pkgGolden = "felhom-golden"
fileGolden = "golden.tar.zst"
)
// artifactChoice is one selectable artifact version + its Gitea-resolved sha256 (for the dropdown +
// the read-only sha display).
type artifactChoice struct {
Version string
SHA256 string
}
// 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
gitea *gitea.Client // optional; enables the Day-0 artifact version dropdowns
offsite *offsite.Provisioner // optional; enables Hetzner offsite provisioning (SLICE 1)
tenantsync tenancyProvisioner // optional; enables PBS DR tier provisioning (web/pbsdr.go)
claimEngine *claim.Engine // optional; enables the customer-claim resend button (v0.50.0)
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,
"timeAgoPtr": func(t *time.Time) string {
if t == nil {
return "—"
}
return timeAgo(*t)
},
"statusColor": statusColor,
"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
}
// SetOffsiteProvisioner enables Hetzner offsite provisioning (optional). Without it, saving a config with
// offsite enabled returns an error (offsite not configured on this hub).
func (s *Server) SetOffsiteProvisioner(p *offsite.Provisioner) { s.offsite = p }
// SetClaimEngine wires the customer-claim code engine for the Setup-tab resend button (v0.50.0).
func (s *Server) SetClaimEngine(e *claim.Engine) { s.claimEngine = e }
// SetGiteaClient enables the Day-0 artifact version dropdowns (optional). Without it the artifact form
// degrades to manual text entry.
func (s *Server) SetGiteaClient(c *gitea.Client) {
s.gitea = c
}
// artifactChoices resolves the currently-available versions of a generic package + each one's sha256
// from Gitea, newest first, for the Day-0 artifact dropdown. Returns nil (→ manual text-entry
// fallback) when no Gitea client is configured or Gitea is unreachable. A failed sha lookup for a
// single version drops just that version, not the whole list.
func (s *Server) artifactChoices(ctx context.Context, pkg, file string) []artifactChoice {
if s.gitea == nil {
return nil
}
vers, err := s.gitea.ListVersions(ctx, pkg)
if err != nil {
s.logger.Printf("[WARN] artifact versions (%s): %v", pkg, err)
return nil
}
const maxChoices = 20
if len(vers) > maxChoices {
s.logger.Printf("[INFO] artifact versions (%s): showing newest %d of %d", pkg, maxChoices, len(vers))
vers = vers[:maxChoices]
}
out := make([]artifactChoice, 0, len(vers))
for _, v := range vers {
sha, err := s.gitea.FileSHA256(ctx, pkg, v, file)
if err != nil {
s.logger.Printf("[WARN] artifact sha (%s/%s): %v", pkg, v, err)
continue
}
out = append(out, artifactChoice{Version: v, SHA256: sha})
}
return out
}
// 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 strings.HasPrefix(path, "/static/fonts/"):
// Vendored brand fonts (design system v2) — embedded, no CDN.
name := strings.TrimPrefix(path, "/static/fonts/")
data, err := fontFS.ReadFile("static/fonts/" + name)
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "font/woff2")
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
w.Write(data)
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, "/dismiss-issues"):
appName := strings.TrimPrefix(path, "/apps/")
appName = strings.TrimSuffix(appName, "/dismiss-issues")
if r.Method == http.MethodPost {
s.handleDismissAppIssues(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)
// Offsite — WG endpoint + peer registry (S2), plus endpoint management forms
// (v0.47.0; peer mutations stay on the admin API, allocation stays lowest-endpoint-id).
case path == "/offsite":
s.handleOffsite(w, r)
case path == "/offsite/endpoints":
if r.Method == http.MethodPost {
s.handleOffsiteEndpointSave(w, r)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case strings.HasPrefix(path, "/offsite/endpoints/") && strings.HasSuffix(path, "/delete"):
endpointID := strings.TrimSuffix(strings.TrimPrefix(path, "/offsite/endpoints/"), "/delete")
if r.Method == http.MethodPost {
s.handleOffsiteEndpointDelete(w, r, endpointID)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
// Hosts — read-only fleet view (audit F-M1) + the v0.46.0 log-bundle actions.
case path == "/hosts" || path == "/hosts/":
s.handleHostsList(w, r)
// v0.47.0 stale host removal — suffix routes BEFORE the bare /hosts/ catch-all
// (mirroring the request-logs placement).
case strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/delete-impact"):
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/delete-impact")
if r.Method == http.MethodGet {
s.handleHostDeleteImpact(w, r, hostID)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/delete"):
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/delete")
if r.Method == http.MethodPost {
s.handleHostDelete(w, r, hostID)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/request-logs"):
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/request-logs")
if r.Method == http.MethodPost {
s.handleRequestLogBundle(w, r, hostID)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case strings.HasPrefix(path, "/hosts/") && strings.Contains(path, "/log-bundles/"):
rest := strings.TrimPrefix(path, "/hosts/")
if i := strings.Index(rest, "/log-bundles/"); i > 0 {
s.handleLogBundleView(w, r, rest[:i], rest[i+len("/log-bundles/"):])
} else {
http.NotFound(w, r)
}
case strings.HasPrefix(path, "/hosts/"):
hostID := strings.TrimPrefix(path, "/hosts/")
s.handleHostDetail(w, r, hostID)
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/") && strings.HasSuffix(path, "/request-log-tail"):
customerID := strings.TrimPrefix(path, "/customers/")
customerID = strings.TrimSuffix(customerID, "/request-log-tail")
if r.Method == http.MethodPost {
s.handleRequestLogTail(w, r, customerID)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case strings.HasPrefix(path, "/customers/") && strings.Contains(path, "/log-tail/"):
rest := strings.TrimPrefix(path, "/customers/")
parts := strings.SplitN(rest, "/log-tail/", 2)
if len(parts) != 2 {
http.NotFound(w, r)
return
}
s.handleLogTailView(w, r, parts[0], parts[1])
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)
}
// Global settings live under the Configuration tab (moved from Customers).
case path == "/configuration/global-floor/impact":
if r.Method == http.MethodGet {
s.handleGlobalFloorImpact(w, r)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case path == "/configuration/global-floor":
if r.Method == http.MethodPost {
s.handleSetGlobalFloor(w, r)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case path == "/configuration/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, "/offsite-reissue"):
customerID := strings.TrimPrefix(path, "/configs/")
customerID = strings.TrimSuffix(customerID, "/offsite-reissue")
if r.Method == http.MethodPost {
s.handleOffsiteReissue(w, r, customerID)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/claim-resend"):
customerID := strings.TrimPrefix(path, "/configs/")
customerID = strings.TrimSuffix(customerID, "/claim-resend")
if r.Method == http.MethodPost {
s.handleClaimResend(w, r, customerID)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/pbsdr-reissue"):
customerID := strings.TrimPrefix(path, "/configs/")
customerID = strings.TrimSuffix(customerID, "/pbsdr-reissue")
if r.Method == http.MethodPost {
s.handlePBSDRReissue(w, r, customerID)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/offsite-freeze"):
customerID := strings.TrimPrefix(path, "/configs/")
customerID = strings.TrimSuffix(customerID, "/offsite-freeze")
if r.Method == http.MethodPost {
s.handleOffsiteFreeze(w, r, customerID, r.FormValue("unfreeze") != "1")
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
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:#0083D8;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:#0083D8;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
EventCriticals int
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.EventCriticals = counts["critical"]
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 delegates to THE hub comparator (internal/semver). Kept as a thin wrapper so the
// web package's many call sites are unchanged.
func compareVersions(a, b string) int { return semver.Compare(a, b) }
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)
}
// statusColor maps a status value to a design-system-v2 semantic token, consumed as a
// class suffix (status-dot-nominal, tag-nominal, …) — never as an inline color (D4).
// Exception-color principle: a healthy fleet is blue/neutral; amber/red only on deviation.
// ok -> nominal (blue — operating normally)
// warn, stale -> warn (amber — degraded / stale report)
// down, fail -> crit (red — outage)
// pending -> neutral (a not-yet-provisioned customer is a normal fleet state)
// disabled -> neutral (deliberately paused — not a deviation)
// blocked -> warn (an operator cut a customer off: intentional but attention-worthy)
func statusColor(status string) string {
switch status {
case "ok":
return "nominal"
case "warn", "stale":
return "warn"
case "down", "fail":
return "crit"
case "blocked":
return "warn"
case "disabled", "pending":
return "neutral"
default:
return "neutral"
}
}
// 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
}
}
ctx := r.Context()
data := map[string]interface{}{
"CSRFToken": csrfToken,
"CSRFField": s.csrfField(r),
"AssetCount": assetCount,
"AssetLastSync": assetLastSync,
"GlobalFloor": s.store.GetGlobalMinControllerVersion(),
"FloorRes": s.store.ResolveGlobalFloor(),
"Artifacts": s.store.GetArtifactManifest(),
"AgentChoices": s.artifactChoices(ctx, pkgAgent, fileAgent),
"GoldenChoices": s.artifactChoices(ctx, pkgGolden, fileGolden),
"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)
}
}