Files
felhom.eu/hub/internal/web/server.go
T
admin 4c8bf63ce3 feat: customer config management — CRUD, API retrieval, per-customer auth (v0.2.0)
New "Configurations" section lets operators pre-configure customer settings
in the Hub, then docker-setup.sh can download a ready-made controller.yaml
using just a customer ID and retrieval password.

- Store: customer_configs table with CRUD + per-customer API key lookup
- API: GET /api/v1/config/{id} with X-Retrieval-Password auth
- Auth: per-customer API keys alongside existing global key (backward compatible)
- Web UI: /configs list, create, edit, delete, YAML preview, copy-to-clipboard
- YAML gen: deep-merge controller.yaml.example template with customer overrides
- Template fetcher: background goroutine refreshing template from Gitea repo
- Navigation: Dashboard / Configurations tabs on all pages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 13:36:32 +01:00

468 lines
14 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 (
"encoding/json"
"fmt"
"html/template"
"io"
"log"
"math"
"net/http"
"strconv"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
"golang.org/x/crypto/bcrypt"
)
// Server handles the dashboard web UI.
type Server struct {
store *store.Store
passwordHash string
apiKey string // report API key — used for controller callbacks
logger *log.Logger
templates *template.Template
staleThreshold time.Duration
versionChecker *VersionChecker
templateFetcher *TemplateFetcher
}
// New creates a new web server.
func New(store *store.Store, passwordHash, apiKey string, staleThreshold time.Duration, logger *log.Logger) *Server {
funcMap := template.FuncMap{
"timeAgo": timeAgo,
"statusColor": statusColor,
"statusIcon": statusIcon,
"formatFloat": func(f float64) string { return fmt.Sprintf("%.0f", f) },
"joinStrings": func(s []string, sep string) string { return strings.Join(s, sep) },
"json": func(v interface{}) template.JS {
b, _ := json.Marshal(v)
return template.JS(b)
},
}
tmpl := template.Must(template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html"))
return &Server{
store: store,
passwordHash: passwordHash,
apiKey: apiKey,
logger: logger,
templates: tmpl,
staleThreshold: staleThreshold,
}
}
// 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
}
// ServeHTTP routes web requests.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
switch {
case path == "/":
s.handleDashboard(w, r)
case path == "/style.css":
s.handleCSS(w, r)
case path == "/login":
s.handleLogin(w, r)
case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/trigger-update"):
customerID := strings.TrimPrefix(path, "/customers/")
customerID = strings.TrimSuffix(customerID, "/trigger-update")
if r.Method == http.MethodPost {
s.handleTriggerUpdate(w, r, customerID)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case strings.HasPrefix(path, "/customers/"):
customerID := strings.TrimPrefix(path, "/customers/")
s.handleCustomerDetail(w, r, customerID)
// Config management routes — exact matches first, then prefix matches
case path == "/configs":
s.handleConfigList(w, r)
case path == "/configs/new":
if r.Method == http.MethodPost {
s.handleConfigCreate(w, r)
} else {
s.handleConfigNewForm(w, r)
}
case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/delete"):
customerID := strings.TrimPrefix(path, "/configs/")
customerID = strings.TrimSuffix(customerID, "/delete")
if r.Method == http.MethodPost {
s.handleConfigDelete(w, r, customerID)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/edit"):
customerID := strings.TrimPrefix(path, "/configs/")
customerID = strings.TrimSuffix(customerID, "/edit")
if r.Method == http.MethodPost {
s.handleConfigUpdate(w, r, customerID)
} else {
s.handleConfigEditForm(w, r, customerID)
}
case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/preview"):
customerID := strings.TrimPrefix(path, "/configs/")
customerID = strings.TrimSuffix(customerID, "/preview")
s.handleConfigPreview(w, r, customerID)
case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/regen-password"):
customerID := strings.TrimPrefix(path, "/configs/")
customerID = strings.TrimSuffix(customerID, "/regen-password")
if r.Method == http.MethodPost {
s.handleConfigRegenPassword(w, r, customerID)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case strings.HasPrefix(path, "/configs/"):
customerID := strings.TrimPrefix(path, "/configs/")
s.handleConfigDetail(w, r, customerID)
default:
http.NotFound(w, r)
}
}
// RequireAuth wraps a handler with basic authentication.
func (s *Server) RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Skip auth if no password configured
if s.passwordHash == "" {
next.ServeHTTP(w, r)
return
}
// Check session cookie
if cookie, err := r.Cookie("hub_session"); err == nil && cookie.Value == "authenticated" {
next.ServeHTTP(w, r)
return
}
// Check basic auth
_, password, ok := r.BasicAuth()
if ok && bcrypt.CompareHashAndPassword([]byte(s.passwordHash), []byte(password)) == nil {
next.ServeHTTP(w, r)
return
}
// Show login page for browser requests
if r.URL.Path == "/login" && r.Method == http.MethodPost {
s.handleLogin(w, r)
return
}
w.Header().Set("WWW-Authenticate", `Basic realm="Felhom Hub"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
})
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
password := r.FormValue("password")
if bcrypt.CompareHashAndPassword([]byte(s.passwordHash), []byte(password)) == nil {
http.SetCookie(w, &http.Cookie{
Name: "hub_session",
Value: "authenticated",
Path: "/",
HttpOnly: true,
MaxAge: 86400 * 7, // 7 days
})
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
http.Error(w, "Invalid password", http.StatusUnauthorized)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`<html><body><form method="post"><input type="password" name="password"><button>Login</button></form></body></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
}
type dashboardCustomer struct {
store.CustomerSummary
OverallStatus string // "ok", "warn", "down"
BackupAge string
}
var data []dashboardCustomer
for _, c := range customers {
dc := dashboardCustomer{CustomerSummary: c}
// Determine overall status
if c.HealthStatus == "disabled" {
dc.OverallStatus = "disabled"
} else if c.TimeSinceReport > time.Hour {
dc.OverallStatus = "down"
} else if c.TimeSinceReport > 30*time.Minute || c.HealthStatus == "warn" {
dc.OverallStatus = "warn"
} else if c.HealthStatus == "fail" {
dc.OverallStatus = "down"
} else {
dc.OverallStatus = "ok"
}
// Backup age
if c.BackupLastSnapshot != nil {
dc.BackupAge = timeAgo(*c.BackupLastSnapshot)
} else {
dc.BackupAge = ""
}
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)
}
}
func (s *Server) handleCustomerDetail(w http.ResponseWriter, r *http.Request, customerID string) {
customer, err := s.store.GetCustomer(customerID)
if err != nil {
s.logger.Printf("[ERROR] Customer detail: %v", err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
if customer == nil {
http.NotFound(w, r)
return
}
// Parse the full report
var report map[string]interface{}
json.Unmarshal([]byte(customer.ReportJSON), &report)
// Get history (last 24h)
history, _ := s.store.GetCustomerHistory(customerID, 24*time.Hour)
// Get notification preferences and recent log
notifPrefs, _ := s.store.GetNotificationPrefs(customerID)
recentNotifs, _ := s.store.GetRecentNotifications(customerID, 10)
// Get infra backup metadata
infraMeta, _ := s.store.GetInfraBackupMeta(customerID)
type detailData struct {
Customer *store.CustomerSummary
Report map[string]interface{}
History []store.CustomerSummary
OverallStatus string
NotifPrefs *store.NotificationPrefs
RecentNotifications []store.NotificationLogEntry
InfraBackup *store.InfraBackupMeta
InfraBackupAge string
ControllerURL string // controller's external URL
LatestVersion string // latest controller image version from registry
UpdateAvailable bool // true if latest > current
}
// Get controller URL (from denormalized field or report JSON fallback)
controllerURL := customer.ControllerURL
if controllerURL == "" {
var rpt struct {
ControllerURL string `json:"controller_url"`
}
json.Unmarshal([]byte(customer.ReportJSON), &rpt)
controllerURL = rpt.ControllerURL
}
// Check if update is available
var latestVersion string
var updateAvailable bool
if s.versionChecker != nil {
latestVersion = s.versionChecker.LatestVersion()
if latestVersion != "" && customer.ControllerVersion != "" {
updateAvailable = latestVersion != customer.ControllerVersion && compareVersions(latestVersion, customer.ControllerVersion) > 0
}
}
overallStatus := "ok"
if customer.HealthStatus == "disabled" {
overallStatus = "disabled"
} else if customer.TimeSinceReport > time.Hour {
overallStatus = "down"
} else if customer.TimeSinceReport > 30*time.Minute || customer.HealthStatus == "warn" {
overallStatus = "warn"
} else if customer.HealthStatus == "fail" {
overallStatus = "down"
}
var infraBackupAge string
if infraMeta != nil {
infraBackupAge = timeAgo(infraMeta.UpdatedAt)
}
data := detailData{
Customer: customer,
Report: report,
History: history,
OverallStatus: overallStatus,
NotifPrefs: notifPrefs,
RecentNotifications: recentNotifs,
InfraBackup: infraMeta,
InfraBackupAge: infraBackupAge,
ControllerURL: controllerURL,
LatestVersion: latestVersion,
UpdateAvailable: updateAvailable,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.templates.ExecuteTemplate(w, "customer.html", data); err != nil {
s.logger.Printf("[ERROR] Template render: %v", err)
}
}
func (s *Server) handleTriggerUpdate(w http.ResponseWriter, r *http.Request, customerID string) {
customer, err := s.store.GetCustomer(customerID)
if err != nil {
s.logger.Printf("[ERROR] Trigger update — get customer %s: %v", customerID, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
if customer == nil {
http.NotFound(w, r)
return
}
// Get controller URL — from denormalized field or report JSON fallback
controllerURL := customer.ControllerURL
if controllerURL == "" {
var rpt struct {
ControllerURL string `json:"controller_url"`
}
json.Unmarshal([]byte(customer.ReportJSON), &rpt)
controllerURL = rpt.ControllerURL
}
if controllerURL == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"ok":false,"error":"Controller URL not available — waiting for next report"}`))
return
}
if s.apiKey == "" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`{"ok":false,"error":"API key not configured"}`))
return
}
// POST to controller's self-update endpoint
updateURL := controllerURL + "/api/selfupdate/update"
req, err := http.NewRequest("POST", updateURL, nil)
if err != nil {
s.logger.Printf("[ERROR] Trigger update — create request for %s: %v", updateURL, err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`{"ok":false,"error":"Failed to create request"}`))
return
}
req.Header.Set("Authorization", "Bearer "+s.apiKey)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
s.logger.Printf("[ERROR] Trigger update — request to %s failed: %v", updateURL, err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadGateway)
json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": fmt.Sprintf("Controller unreachable: %v", err)})
return
}
defer resp.Body.Close()
// Forward the controller's response
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
s.logger.Printf("[INFO] Trigger update for %s — controller responded %d: %s", customerID, resp.StatusCode, string(body))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
w.Write(body)
}
// compareVersions returns >0 if a > b, 0 if equal, <0 if a < b.
// Accepts "X.Y.Z" format. Returns 0 on parse error.
func compareVersions(a, b string) int {
a = strings.TrimPrefix(a, "v")
b = strings.TrimPrefix(b, "v")
aParts := strings.SplitN(a, ".", 3)
bParts := strings.SplitN(b, ".", 3)
if len(aParts) != 3 || len(bParts) != 3 {
return 0
}
for i := 0; i < 3; i++ {
ai, e1 := strconv.Atoi(aParts[i])
bi, e2 := strconv.Atoi(bParts[i])
if e1 != nil || e2 != nil {
return 0
}
if ai != bi {
return ai - bi
}
}
return 0
}
func (s *Server) handleCSS(w http.ResponseWriter, r *http.Request) {
data, err := templateFS.ReadFile("templates/style.css")
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/css")
w.Header().Set("Cache-Control", "public, max-age=3600")
w.Write(data)
}
func timeAgo(t time.Time) string {
d := time.Since(t)
if d < time.Minute {
return "just now"
}
if d < time.Hour {
m := int(math.Round(d.Minutes()))
return fmt.Sprintf("%d min ago", m)
}
if d < 24*time.Hour {
h := int(math.Round(d.Hours()))
return fmt.Sprintf("%dh ago", h)
}
days := int(d.Hours() / 24)
return fmt.Sprintf("%dd ago", days)
}
func statusColor(status string) string {
switch status {
case "ok":
return "#4ade80" // green
case "warn":
return "#facc15" // yellow
case "down", "fail":
return "#f87171" // red
case "disabled":
return "#94a3b8" // gray
default:
return "#94a3b8" // gray
}
}
func statusIcon(status string) string {
return "●"
}