Files
felhom.eu/hub/internal/web/server.go
T
admin 7264f02172
gates / gates (push) Successful in 28s
hub v0.101.0 — memoise the artifact dropdown for 60s (R-267, operator ruling)
v0.100.x removed the serialisation: 26.2s -> ~9.85s mean. What remained was one Gitea package SEARCH
per dropdown at 0.20-3.8s depending on load, which concurrency cannot help.

Memoised for 60s IN MEMORY. The TTL was ruled by the operator against the workflow that cares: a
bake-and-vouch session publishes an artifact and comes straight here to select it, so a minute is
short enough not to be noticed and long enough that every reload in that session is instant.

NOT persisted. Gitea IS the store for both the version list and the sha; a copy in hub_settings would
be a second source of truth that can drift from the registry it describes, and the operator reads the
sha here to confirm what they are about to vouch. An in-memory cache dies with the process and can
never be mistaken for a record.

A failed resolve is NOT cached — a blip must not pin an empty dropdown for a minute. But an empty
list from a package that genuinely has no versions IS cached, because 'we found nothing' and 'we
could not look' are different answers (CONTEXT S-39, applied to a list instead of a figure).

THE FIRST VERSION OF THIS GOT THAT WRONG: the comment said only successful resolves were cached and
the code cached the empty list anyway. TestArtifactChoices_FailureIsNotCached caught it before it
shipped — which is the argument for writing the test that asserts the comment, and the same class
this session spent the day closing.

go build/vet/test green, go test -race clean, run separately from this commit.
2026-08-08 20:08:51 +02:00

1070 lines
44 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/intent"
"gitea.dooplex.hu/admin/felhom-hub/internal/monitor"
"gitea.dooplex.hu/admin/felhom-hub/internal/offsite"
"gitea.dooplex.hu/admin/felhom-hub/internal/poke"
"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
// artifactCache (R-267) memoises the Day-0 dropdown contents for artifactChoicesTTL. In memory
// only — see the constant's comment for why this must never become a database row.
artifactCache map[string]artifactChoiceCacheEntry
artifactCacheMu sync.Mutex
}
// 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.
// artifactChoicesTTL bounds how stale the dropdown may be (R-267, operator ruling 2026-08-08).
//
// 60 s, chosen deliberately against the one workflow that cares: a bake-and-vouch session publishes
// an artifact and then goes straight to this page to select it. A minute is short enough that the
// operator does not notice waiting for it, and long enough that the page is instant for every reload
// during that session. A longer TTL was rejected for exactly that case — it would hide a
// just-published golden at the moment someone is looking for it.
//
// NOT persisted, and that is the point. Gitea IS the store for both the version list and the sha;
// a copy in the hub's database would be a second source of truth that can drift from the registry
// it describes, and the operator reads the sha here to confirm what they are about to vouch. An
// in-memory cache dies with the process and can never be mistaken for a record.
const artifactChoicesTTL = 60 * time.Second
type artifactChoiceCacheEntry struct {
choices []artifactChoice
at time.Time
}
func (s *Server) artifactChoices(ctx context.Context, pkg, file string) []artifactChoice {
if s.gitea == nil {
return nil
}
s.artifactCacheMu.Lock()
if e, ok := s.artifactCache[pkg]; ok && time.Since(e.at) < artifactChoicesTTL {
s.artifactCacheMu.Unlock()
return e.choices
}
s.artifactCacheMu.Unlock()
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]
}
// CONCURRENTLY (v0.100.0). Each sha is one independent metadata round-trip, and doing them in
// series made /configuration take 26 SECONDS on a page with two dropdowns: 2 packages x (1
// version list + 20 sha lookups) = 42 sequential requests at ~0.6 s each.
//
// ⚠ NOTHING IS BEING HASHED HERE, and the operator's reasonable guess that it was is worth
// recording so nobody re-derives it: Gitea stores the sha256 with the package file and
// FileSHA256 reads it as metadata — the artifact bytes are never downloaded (see its comment).
// The cost was never CPU; it was latency x count, and the fix is therefore concurrency, not a
// cached or precomputed hash. A hash copied into the hub's own DB would be a SECOND SOURCE OF
// TRUTH that can drift from the registry it describes, and the operator reads this value to
// confirm what they are about to vouch — a stale one would be a confident wrong answer.
//
// Bounded at 8 in flight: enough to collapse the wall-clock, few enough not to stampede Gitea
// on a page an operator may reload. Order is preserved by writing into a slot, not appending —
// the dropdown is newest-first and must stay that way.
const parallel = 8
shas := make([]string, len(vers))
errs := make([]error, len(vers))
sem := make(chan struct{}, parallel)
var wg sync.WaitGroup
for i, v := range vers {
wg.Add(1)
go func(i int, v string) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
shas[i], errs[i] = s.gitea.FileSHA256(ctx, pkg, v, file)
}(i, v)
}
wg.Wait()
out := make([]artifactChoice, 0, len(vers))
for i, v := range vers {
if errs[i] != nil {
// unchanged behaviour: a failed lookup drops THAT version, never the whole list
s.logger.Printf("[WARN] artifact sha (%s/%s): %v", pkg, v, errs[i])
continue
}
out = append(out, artifactChoice{Version: v, SHA256: shas[i]})
}
// Only a SUCCESSFUL resolve is cached. A Gitea blip must not pin an empty list in front of the
// operator for a minute — the earlier returns leave the cache untouched, and so does this one
// when every version's sha lookup failed.
//
// The `len(vers) > 0` half matters: a package that genuinely has no versions yet resolves to an
// empty list legitimately, and caching THAT is correct. Distinguishing the two is the whole
// point — "we found nothing" and "we could not look" are not the same answer, which is the rule
// CONTEXT S-39 states for figures and applies just as well here.
if len(out) == 0 && len(vers) > 0 {
s.logger.Printf("[WARN] artifact choices (%s): every version's sha lookup failed — not caching", pkg)
return out
}
s.artifactCacheMu.Lock()
if s.artifactCache == nil {
s.artifactCache = map[string]artifactChoiceCacheEntry{}
}
s.artifactCache[pkg] = artifactChoiceCacheEntry{choices: out, at: time.Now()}
s.artifactCacheMu.Unlock()
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(`<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"
HostCause string // v0.53.0 roll-up: "" or "host down|stale|pending: <id>"
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()
// The two dropdowns are independent, so they are resolved side by side rather than one after the
// other (v0.100.0). Each is internally concurrent already; doing them in series still cost the
// full latency of BOTH package searches, and that search is the slowest single call on this page
// — measured in-cluster at 1.12.2 s each against ~0.24 s for a file's metadata.
var agentChoices, goldenChoices []artifactChoice
var choicesWG sync.WaitGroup
choicesWG.Add(2)
go func() { defer choicesWG.Done(); agentChoices = s.artifactChoices(ctx, pkgAgent, fileAgent) }()
go func() { defer choicesWG.Done(); goldenChoices = s.artifactChoices(ctx, pkgGolden, fileGolden) }()
choicesWG.Wait()
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": agentChoices,
"GoldenChoices": goldenChoices,
"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)
}