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/intent" "gitea.dooplex.hu/admin/felhom-hub/internal/monitor" "gitea.dooplex.hu/admin/felhom-hub/internal/poke" "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 // configPasswordHash is the operator login password bcrypt hash SEEDED from hub.yaml // (auth.password_hash) at startup. It is the fallback only — a hub_settings DB override set via // the Configuration UI wins. Never read this field directly for an auth decision; call // effectivePasswordHash(). configPasswordHash 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) offsiteBox func() (monitor.BoxSnapshot, bool) // optional (v0.64.0, R-5); the restic pool-box aggregate snapshot accessor pbsdrBox func() (monitor.PBSBoxSnapshot, bool) // optional (v0.65.0, R-5); the PBS-DR datastore fill snapshot accessor tenantsync tenancyProvisioner // optional; enables PBS DR tier provisioning (web/pbsdr.go) claimEngine *claim.Engine // optional; enables the customer-claim resend button (v0.50.0) selfBindMailer SelfBindMailer // optional; enables the customer self-bind link button (v0.66.0, R-27) bindLimiter *bindRateLimiter // per-IP throttle for the PUBLIC /bind/ surface (v0.66.0, R-27) // intentHub (v0.58.0, Direction-2 immediate-sync) is Bumped by every operator-intent handler // (config save/delete, claim resend, offsite re-issue/freeze, floor, block/unblock, log pull) // so a box long-polling GET /api/v1/wait wakes in seconds. Shared with the API handler. nil = // no immediacy (bumps are no-ops; the 15-min cycle still reconciles). intentHub *intent.Hub // poke (v0.59.0, Direction-2a agent-plane immediate-sync) fires a fire-and-forget UDP nudge to // a host's box (via the ep0 forced command) when an AGENT-plane desired-state change is saved // (a pbsdr descriptor, the MinAgent floor) so the box ticks in seconds instead of ≤15 min. nil // = no immediacy (a no-op; the report cycle still reconciles). Its sibling intentHub handles // the CONTROLLER plane (customer/app config) via the long-poll wait channel. poke *poke.Notifier 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, configPasswordHash: passwordHash, apiKey: apiKey, version: version, logger: logger, templates: tmpl, staleThreshold: staleThreshold, sessions: make(map[string]*hubSession), bindLimiter: newBindRateLimiter(30), // public /bind/ surface: 30 req/min/IP burst (R-27) } } // effectivePasswordHash returns the operator login password bcrypt hash in force: the UI-set DB // override (hub_settings, via the Configuration page) when present, otherwise the config/env seed // (auth.password_hash from hub.yaml). This is the SINGLE source of truth for every auth check — the // DB override wins and the ConfigMap value is the break-glass fallback, mirroring the // controller-version floor's precedence. Empty return = auth disabled (dev/test only). func (s *Server) effectivePasswordHash() string { if h := s.store.GetOperatorPasswordHash(); h != "" { return h } return s.configPasswordHash } // 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 } // SetOffsiteBox wires the pool-box aggregate snapshot accessor (v0.64.0, R-5): the checker's cached // snapshot, read on the Offsite tab + the Dashboard tile. nil (no HETZNER_TOKEN/box id) → both render an // honest "not configured". The web layer NEVER fetches from Hetzner — it only reads this cache. func (s *Server) SetOffsiteBox(fn func() (monitor.BoxSnapshot, bool)) { s.offsiteBox = fn } // SetPBSDRBox wires the PBS-DR datastore fill snapshot accessor (v0.65.0, R-5): read on the Offsite // "PBS DR" tab + the Dashboard PBS gauge. nil (no tenantsync client) → "not configured". The snapshot // carries its own state (ok/unavailable/degraded); the web layer never polls ep0. func (s *Server) SetPBSDRBox(fn func() (monitor.PBSBoxSnapshot, bool)) { s.pbsdrBox = fn } // 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 } // SetIntentHub wires the operator-intent notifier (v0.58.0). Every intent handler bumps it via // s.bumpIntent; nil-safe (bumps become no-ops). func (s *Server) SetIntentHub(hub *intent.Hub) { s.intentHub = hub } // bumpIntent advances the customer's wait generation so a box long-polling GET /api/v1/wait wakes // immediately. nil-safe. Call AFTER the successful store write (mirror the report.Trigger // fire-after-commit rule — never on an error path). func (s *Server) bumpIntent(customerID string) { if s.intentHub != nil { s.intentHub.Bump(customerID) } } // 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 { // /bind/ is the public customer self-bind surface (THE TRAP §9.2): no operator session to // ride, so CSRF is exempt here exactly as it is for /login. The URL capability token is the // authorization boundary; a cross-site POST without both secrets only burns attempts. if path != "/login" && !isPublicBindPath(path) && s.effectivePasswordHash() != "" { 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) // R-21 slice C — unclaimed-appliance operator actions (bind/discard). POST only. case strings.HasPrefix(path, "/appliances/") && strings.HasSuffix(path, "/bind"): if id, ok := parseApplianceID(path, "bind"); ok && r.Method == http.MethodPost { s.handleApplianceBind(w, r, id) } else { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } case strings.HasPrefix(path, "/appliances/") && strings.HasSuffix(path, "/discard"): if id, ok := parseApplianceID(path, "discard"); ok && r.Method == http.MethodPost { s.handleApplianceDiscard(w, r, id) } else { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } // 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) } // v0.84.0 break-glass console credential — suffix route BEFORE the bare /hosts/ catch-all // (registered after it, the POST would 404 and the GET would silently render the host page). case strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/reveal-recovery-credential"): hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/reveal-recovery-credential") if r.Method == http.MethodPost { s.handleHostRevealRecoveryCredential(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 isPublicBindPath(path): // PUBLIC customer self-bind (R-27 slice 1) — GET renders the form/state, POST validates the // two factors. Auth + CSRF exempt above via the SAME isPublicBindPath predicate. s.handleBind(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, "/selfbind-link"): // R-27 slice 1: mint + email a customer self-bind capability link. POST only. customerID := strings.TrimPrefix(path, "/customers/") customerID = strings.TrimSuffix(customerID, "/selfbind-link") if r.Method == http.MethodPost { s.handleSelfBindLinkSend(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 path == "/configuration/password": if r.Method == http.MethodPost { s.handleChangePassword(w, r) } else { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/delete"): // Customer DELETE cascade (v0.69.0, R-25b): GET returns the guided dialog's live inventory // (hosts + offsite + PBS + custody + any incomplete journal), POST runs the full teardown. customerID := strings.TrimPrefix(path, "/configs/") customerID = strings.TrimSuffix(customerID, "/delete") if r.Method == http.MethodPost { s.handleCustomerDelete(w, r, customerID) } else { s.handleCustomerDeletePreview(w, r, customerID) } 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/") && strings.HasSuffix(path, "/reset"): // Customer RESET (v0.61.0): GET renders the confirm surface (live inventory), POST executes. customerID := strings.TrimPrefix(path, "/configs/") customerID = strings.TrimSuffix(customerID, "/reset") if r.Method == http.MethodPost { s.handleCustomerReset(w, r, customerID) } else { s.handleCustomerResetPreview(w, r, customerID) } 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.effectivePasswordHash() == "" { next.ServeHTTP(w, r) return } // Always allow the login page through (GET and POST), and the PUBLIC customer self-bind // surface (THE TRAP §9.2): /bind/ is exempt from operator auth exactly as /login is — the // emailed URL capability token IS the auth model there. isPublicBindPath is the SINGLE // definition of the prefix (matched tightly: trailing slash, path already .. -cleaned by the // ServeMux) so this exemption cannot reach any operator-gated route. if r.URL.Path == "/login" || isPublicBindPath(r.URL.Path) { 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.effectivePasswordHash()), []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") effHash := s.effectivePasswordHash() if effHash != "" && bcrypt.CompareHashAndPassword([]byte(effHash), []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" HostCause string // v0.53.0 roll-up: "" or "host down|stale|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} // Controller-derived status + the v0.53.0 dead-host roll-up (rollup.go): a customer // may never look better than its worst expected host. dc.OverallStatus, dc.HostCause = s.foldHostStatus(c.CustomerID, controllerStatus(&c), true) // 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: "–", } // Roll-up for the never-reported customer too: a down/stale host worsens even during // onboarding — only never-reported ("pending") hosts are excluded here. dc.OverallStatus, dc.HostCause = s.foldHostStatus(cfg.CustomerID, dc.OverallStatus, false) data = append(data, dc) } payload := struct { Customers []dashboardCustomer OffsiteTile *offsiteTile // R-5: restic pool box; nil → no gauge PBSTile *pbsdrTile // R-5 v0.65.0: PBS DR datastore; nil → no gauge }{Customers: data, OffsiteTile: s.offsiteBoxTile(), PBSTile: s.pbsdrBoxTile()} w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := s.templates.ExecuteTemplate(w, "dashboard.html", payload); 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) } } // minOperatorPasswordLen is the minimum accepted new operator login password length (bytes). A low // floor by design — this is the single operator's own login, not a customer-facing credential — but // it stops fat-finger empties/typos from silently becoming the password. bcrypt caps input at 72 // bytes, so that is the hard upper bound. const minOperatorPasswordLen = 8 // handleChangePassword updates the operator login password from the Configuration page (v0.54.0). // It requires the CURRENT password (verified against the effective hash — DB override → config seed), // a new password of at least minOperatorPasswordLen bytes, and a matching confirmation. On success it // bcrypts the new password (cost 10, matching the ConfigMap seed) and persists it to hub_settings, the // DB override that wins over the hub.yaml seed. The ConfigMap value stays the break-glass fallback: // blank the DB row (or edit the manifest + redeploy) to reset a lost password. Existing sessions are // intentionally left valid — only the /login and Basic-Auth checks consult the new hash. CSRF is // already enforced by ServeHTTP for this POST. func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "Bad request", http.StatusBadRequest) return } current := r.FormValue("current_password") next := r.FormValue("new_password") confirm := r.FormValue("confirm_password") // Verify the current password against the effective hash. Empty effective hash (auth disabled) // also blocks the change — there is nothing to authenticate against. if eff := s.effectivePasswordHash(); eff == "" || bcrypt.CompareHashAndPassword([]byte(eff), []byte(current)) != nil { s.logger.Printf("[WARN] Change-password rejected: current password mismatch from %s", r.RemoteAddr) http.Redirect(w, r, "/configuration?flash=pw_current_wrong", http.StatusSeeOther) return } if len(next) < minOperatorPasswordLen { http.Redirect(w, r, "/configuration?flash=pw_too_short", http.StatusSeeOther) return } if len(next) > 72 { // bcrypt hard limit — reject up front for a friendly message http.Redirect(w, r, "/configuration?flash=pw_too_long", http.StatusSeeOther) return } if next != confirm { http.Redirect(w, r, "/configuration?flash=pw_mismatch", http.StatusSeeOther) return } if next == current { // no-op change — keep the flash honest http.Redirect(w, r, "/configuration?flash=pw_unchanged", http.StatusSeeOther) return } hash, err := bcrypt.GenerateFromPassword([]byte(next), bcrypt.DefaultCost) if err != nil { s.logger.Printf("[ERROR] Change-password: bcrypt generate failed: %v", err) http.Error(w, "Internal error", http.StatusInternalServerError) return } if err := s.store.SetOperatorPasswordHash(string(hash)); err != nil { s.logger.Printf("[ERROR] Change-password: persist failed: %v", err) http.Error(w, "Internal error", http.StatusInternalServerError) return } s.logger.Printf("[INFO] Operator login password changed via Configuration UI from %s", r.RemoteAddr) http.Redirect(w, r, "/configuration?flash=pw_changed", http.StatusSeeOther) }