eb227486d0
- internal/poke: pinned-host-key SSH poke sender (wgsync sibling) + fire-and-forget Notifier (PokeHost/PokeAllHosts). Poke refuses non-WG targets pre-dial; contentless via ep0 forced command to the box WG /32:51822. - wiring: Server.SetPoke; applyPBSDR pokes the host after each descriptor gen-bump; handleSetArtifacts (MinAgent floor) pokes all hosts. main.go env POKE_SSH_KEY_FILE (reuses peersync endpoint/hostkey). - scripts/felhom-poke.sh (non-root forced command) + offsite-endpoint.md §11; manifests/hub.yaml Secret/agent-poke + POKE_SSH_KEY_FILE (image tag bump follows the build).
900 lines
34 KiB
Go
900 lines
34 KiB
Go
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/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)
|
||
tenantsync tenancyProvisioner // optional; enables PBS DR tier provisioning (web/pbsdr.go)
|
||
claimEngine *claim.Engine // optional; enables the customer-claim resend button (v0.50.0)
|
||
// 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),
|
||
}
|
||
}
|
||
|
||
// 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 }
|
||
|
||
// 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 {
|
||
if path != "/login" && 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)
|
||
// 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 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"):
|
||
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.effectivePasswordHash() == "" {
|
||
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.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)
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|