1e354cbd41
- Add Configuration page with "Refresh Assets" button - Replace seedIfEmpty with seedOrUpdate (SHA-256 compare on startup) - Translate all Hungarian text on Apps pages to English - Add Configuration tab to all template navigation - Expand isAssetFile to match favicon patterns - Add felhom-logo.svg to website assets for the pipeline Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
361 lines
11 KiB
Go
361 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"flag"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"syscall"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-hub/internal/api"
|
|
"gitea.dooplex.hu/admin/felhom-hub/internal/assets"
|
|
"gitea.dooplex.hu/admin/felhom-hub/internal/monitor"
|
|
"gitea.dooplex.hu/admin/felhom-hub/internal/notify"
|
|
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
|
"gitea.dooplex.hu/admin/felhom-hub/internal/web"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
var (
|
|
Version = "dev"
|
|
BuildTime = "unknown"
|
|
)
|
|
|
|
// Config is the hub configuration loaded from hub.yaml.
|
|
type Config struct {
|
|
Auth struct {
|
|
PasswordHash string `yaml:"password_hash"`
|
|
} `yaml:"auth"`
|
|
API struct {
|
|
ReportAPIKey string `yaml:"report_api_key"`
|
|
} `yaml:"api"`
|
|
Notifications struct {
|
|
ResendAPIKey string `yaml:"resend_api_key"`
|
|
FromEmail string `yaml:"from_email"`
|
|
OperatorEmail string `yaml:"operator_email"`
|
|
OperatorEnabled bool `yaml:"operator_enabled"`
|
|
} `yaml:"notifications"`
|
|
Retention struct {
|
|
MaxDays int `yaml:"max_days"`
|
|
PruneSchedule string `yaml:"prune_schedule"`
|
|
} `yaml:"retention"`
|
|
Alerting struct {
|
|
StaleThreshold string `yaml:"stale_threshold"`
|
|
} `yaml:"alerting"`
|
|
Registry struct {
|
|
Image string `yaml:"image"`
|
|
Username string `yaml:"username"`
|
|
Token string `yaml:"token"`
|
|
CheckInterval string `yaml:"check_interval"`
|
|
TemplateInterval string `yaml:"template_interval"`
|
|
} `yaml:"registry"`
|
|
Server struct {
|
|
Listen string `yaml:"listen"`
|
|
DataDir string `yaml:"data_dir"`
|
|
} `yaml:"server"`
|
|
}
|
|
|
|
func main() {
|
|
configPath := flag.String("config", "/etc/felhom-hub/hub.yaml", "Path to configuration file")
|
|
showVersion := flag.Bool("version", false, "Show version and exit")
|
|
flag.Parse()
|
|
|
|
if *showVersion {
|
|
fmt.Printf("felhom-hub %s (built %s)\n", Version, BuildTime)
|
|
os.Exit(0)
|
|
}
|
|
|
|
logger := log.New(os.Stdout, "", log.LstdFlags)
|
|
logger.Printf("[INFO] felhom-hub %s starting", Version)
|
|
|
|
// Load config
|
|
cfg := loadConfig(*configPath, logger)
|
|
|
|
// Environment variable overrides (for k8s Secrets)
|
|
if v := os.Getenv("REGISTRY_USERNAME"); v != "" {
|
|
cfg.Registry.Username = v
|
|
}
|
|
if v := os.Getenv("REGISTRY_TOKEN"); v != "" {
|
|
cfg.Registry.Token = v
|
|
}
|
|
|
|
// Ensure data dir exists
|
|
os.MkdirAll(cfg.Server.DataDir, 0755)
|
|
|
|
// Initialize store
|
|
dbPath := filepath.Join(cfg.Server.DataDir, "hub.db")
|
|
dataStore, err := store.New(dbPath, logger)
|
|
if err != nil {
|
|
logger.Fatalf("[FATAL] Failed to initialize store: %v", err)
|
|
}
|
|
defer dataStore.Close()
|
|
logger.Printf("[INFO] Database opened at %s", dbPath)
|
|
|
|
// Parse stale threshold
|
|
staleThreshold, err := time.ParseDuration(cfg.Alerting.StaleThreshold)
|
|
if err != nil {
|
|
staleThreshold = 30 * time.Minute
|
|
}
|
|
|
|
// Background context for all goroutines
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
// Initialize template fetcher for customer config generation
|
|
var templateFetcher *web.TemplateFetcher
|
|
if cfg.Registry.Username != "" && cfg.Registry.Token != "" {
|
|
templateInterval, err := time.ParseDuration(cfg.Registry.TemplateInterval)
|
|
if err != nil {
|
|
templateInterval = 1 * time.Hour
|
|
}
|
|
templateFetcher = web.NewTemplateFetcher(cfg.Registry.Username, cfg.Registry.Token, templateInterval, logger)
|
|
go templateFetcher.Run(ctx)
|
|
logger.Printf("[INFO] Template fetcher started (every %s)", cfg.Registry.TemplateInterval)
|
|
}
|
|
|
|
// Initialize asset manager (PVC storage with image seed)
|
|
assetsDir := filepath.Join(cfg.Server.DataDir, "assets")
|
|
assetsMgr := assets.New(assetsDir, "/usr/share/felhom/assets-seed", logger)
|
|
|
|
// Initialize handlers — pass templateFetcher as interface (nil-safe)
|
|
var templateProvider api.ConfigTemplateProvider
|
|
if templateFetcher != nil {
|
|
templateProvider = templateFetcher
|
|
}
|
|
apiHandler := api.New(dataStore, cfg.API.ReportAPIKey, cfg.Notifications.ResendAPIKey, cfg.Notifications.FromEmail, templateProvider, logger)
|
|
apiHandler.SetAssetManager(assetsMgr)
|
|
|
|
// Initialize notification dispatcher
|
|
dispatcher := notify.NewDispatcher(
|
|
dataStore,
|
|
cfg.Notifications.ResendAPIKey,
|
|
cfg.Notifications.FromEmail,
|
|
cfg.Notifications.OperatorEmail,
|
|
cfg.Notifications.OperatorEnabled,
|
|
logger,
|
|
)
|
|
apiHandler.SetDispatcher(dispatcher)
|
|
|
|
webServer := web.New(dataStore, cfg.Auth.PasswordHash, cfg.API.ReportAPIKey, Version, staleThreshold, logger)
|
|
webServer.SetTemplateFetcher(templateFetcher)
|
|
webServer.SetAssetManager(assetsMgr)
|
|
|
|
// Build HTTP mux
|
|
mux := http.NewServeMux()
|
|
|
|
// Health check endpoint — bypasses auth (for k8s probes)
|
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
|
if err := dataStore.Ping(); err != nil {
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
w.Write([]byte("db unhealthy"))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte("ok"))
|
|
})
|
|
|
|
// API routes (no dashboard auth for report ingest)
|
|
mux.Handle("/api/v1/", apiHandler)
|
|
|
|
// Web routes (auth required)
|
|
if cfg.Auth.PasswordHash != "" {
|
|
mux.Handle("/", webServer.RequireAuth(http.HandlerFunc(webServer.ServeHTTP)))
|
|
} else {
|
|
mux.Handle("/", http.HandlerFunc(webServer.ServeHTTP))
|
|
}
|
|
|
|
// Start HTTP server
|
|
server := &http.Server{
|
|
Addr: cfg.Server.Listen,
|
|
Handler: mux,
|
|
ReadTimeout: 30 * time.Second,
|
|
WriteTimeout: 60 * time.Second,
|
|
IdleTimeout: 120 * time.Second,
|
|
}
|
|
|
|
// Initialize version checker for controller image registry
|
|
var versionChecker *web.VersionChecker
|
|
if cfg.Registry.Username != "" && cfg.Registry.Token != "" {
|
|
checkInterval, err := time.ParseDuration(cfg.Registry.CheckInterval)
|
|
if err != nil {
|
|
checkInterval = 6 * time.Hour
|
|
}
|
|
versionChecker = web.NewVersionChecker(cfg.Registry.Image, cfg.Registry.Username, cfg.Registry.Token, checkInterval, logger)
|
|
go versionChecker.Run(ctx)
|
|
logger.Printf("[INFO] Registry version checker started (every %s)", cfg.Registry.CheckInterval)
|
|
} else {
|
|
logger.Printf("[INFO] Registry version checker disabled (no credentials configured)")
|
|
}
|
|
webServer.SetVersionChecker(versionChecker)
|
|
|
|
// Session cleanup — removes expired sessions every hour
|
|
go webServer.CleanupSessions(ctx)
|
|
|
|
// Prune on startup, then daily at configured time (default 04:30)
|
|
if cfg.Retention.MaxDays > 0 {
|
|
pruneAll(dataStore, cfg.Retention.MaxDays, logger)
|
|
go scheduleDaily(ctx, "prune", cfg.Retention.PruneSchedule, func() {
|
|
pruneAll(dataStore, cfg.Retention.MaxDays, logger)
|
|
}, logger)
|
|
}
|
|
|
|
// Staleness checker — runs every 60s
|
|
stalenessChecker := monitor.NewStalenessChecker(dataStore, staleThreshold, dispatcher.ProcessEvent, logger)
|
|
go func() {
|
|
ticker := time.NewTicker(60 * time.Second)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
stalenessChecker.Check()
|
|
}
|
|
}
|
|
}()
|
|
|
|
// Backup deadline checker — runs daily at 05:00 Budapest
|
|
go scheduleDaily(ctx, "deadline-check", "05:00", func() {
|
|
monitor.CheckBackupDeadlines(dataStore, stalenessChecker, dispatcher.ProcessEvent, logger)
|
|
}, logger)
|
|
|
|
// Signal handling
|
|
sigCh := make(chan os.Signal, 1)
|
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
|
|
|
go func() {
|
|
sig := <-sigCh
|
|
logger.Printf("[INFO] Received signal %v, shutting down...", sig)
|
|
cancel()
|
|
|
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer shutdownCancel()
|
|
|
|
if err := server.Shutdown(shutdownCtx); err != nil {
|
|
logger.Printf("[ERROR] HTTP server shutdown error: %v", err)
|
|
}
|
|
}()
|
|
|
|
logger.Printf("[INFO] Listening on %s", cfg.Server.Listen)
|
|
if err := server.ListenAndServe(); err != http.ErrServerClosed {
|
|
logger.Fatalf("[FATAL] HTTP server error: %v", err)
|
|
}
|
|
|
|
logger.Println("[INFO] felhom-hub stopped")
|
|
}
|
|
|
|
func loadConfig(path string, logger *log.Logger) *Config {
|
|
cfg := &Config{}
|
|
|
|
// Defaults
|
|
cfg.Server.Listen = ":8080"
|
|
cfg.Server.DataDir = "/data"
|
|
cfg.Retention.MaxDays = 90
|
|
cfg.Retention.PruneSchedule = "04:30"
|
|
cfg.Alerting.StaleThreshold = "30m"
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
logger.Printf("[WARN] Config file not found at %s, using defaults", path)
|
|
return cfg
|
|
}
|
|
|
|
if err := yaml.Unmarshal(data, cfg); err != nil {
|
|
logger.Printf("[WARN] Failed to parse config: %v, using defaults", err)
|
|
return cfg
|
|
}
|
|
|
|
// Apply defaults for zero values
|
|
if cfg.Server.Listen == "" {
|
|
cfg.Server.Listen = ":8080"
|
|
}
|
|
if cfg.Server.DataDir == "" {
|
|
cfg.Server.DataDir = "/data"
|
|
}
|
|
if cfg.Retention.MaxDays == 0 {
|
|
cfg.Retention.MaxDays = 90
|
|
}
|
|
if cfg.Alerting.StaleThreshold == "" {
|
|
cfg.Alerting.StaleThreshold = "30m"
|
|
}
|
|
if cfg.Notifications.FromEmail == "" {
|
|
cfg.Notifications.FromEmail = "monitoring@felhom.eu"
|
|
}
|
|
if cfg.Registry.Image == "" {
|
|
cfg.Registry.Image = "gitea.dooplex.hu/admin/felhom-controller"
|
|
}
|
|
if cfg.Registry.CheckInterval == "" {
|
|
cfg.Registry.CheckInterval = "6h"
|
|
}
|
|
if cfg.Registry.TemplateInterval == "" {
|
|
cfg.Registry.TemplateInterval = "1h"
|
|
}
|
|
|
|
return cfg
|
|
}
|
|
|
|
// scheduleDaily runs fn once daily at the given "HH:MM" time in Europe/Budapest.
|
|
// It blocks until ctx is cancelled.
|
|
func scheduleDaily(ctx context.Context, name, timeStr string, fn func(), logger *log.Logger) {
|
|
budapest, err := time.LoadLocation("Europe/Budapest")
|
|
if err != nil {
|
|
budapest = time.FixedZone("CET", 3600)
|
|
}
|
|
|
|
hour, min := parseHM(timeStr)
|
|
|
|
for {
|
|
now := time.Now().In(budapest)
|
|
next := time.Date(now.Year(), now.Month(), now.Day(), hour, min, 0, 0, budapest)
|
|
if !next.After(now) {
|
|
next = next.Add(24 * time.Hour)
|
|
}
|
|
delay := time.Until(next)
|
|
logger.Printf("[INFO] %s: next run at %s (in %s)", name, next.Format("2006-01-02 15:04 MST"), delay.Round(time.Second))
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-time.After(delay):
|
|
fn()
|
|
}
|
|
}
|
|
}
|
|
|
|
// parseHM parses "HH:MM" into hour and minute. Returns 0, 0 on invalid input.
|
|
func parseHM(s string) (int, int) {
|
|
var h, m int
|
|
if _, err := fmt.Sscanf(s, "%d:%d", &h, &m); err != nil {
|
|
return 0, 0
|
|
}
|
|
return h, m
|
|
}
|
|
|
|
func pruneAll(s *store.Store, maxDays int, logger *log.Logger) {
|
|
if deleted, err := s.Prune(maxDays); err != nil {
|
|
logger.Printf("[WARN] Prune reports failed: %v", err)
|
|
} else if deleted > 0 {
|
|
logger.Printf("[INFO] Pruned %d old report rows", deleted)
|
|
}
|
|
if deleted, err := s.PruneEvents(maxDays); err != nil {
|
|
logger.Printf("[WARN] Prune events failed: %v", err)
|
|
} else if deleted > 0 {
|
|
logger.Printf("[INFO] Pruned %d old event rows", deleted)
|
|
}
|
|
if n, err := s.PruneAppTelemetry(time.Now().Add(-90 * 24 * time.Hour)); err != nil {
|
|
logger.Printf("[ERROR] Prune app telemetry: %v", err)
|
|
} else if n > 0 {
|
|
logger.Printf("[INFO] Pruned %d old app telemetry rows", n)
|
|
}
|
|
if n, err := s.PruneStaleIssues(time.Now().Add(-30 * 24 * time.Hour)); err != nil {
|
|
logger.Printf("[ERROR] Prune stale issues: %v", err)
|
|
} else if n > 0 {
|
|
logger.Printf("[INFO] Pruned %d stale app issues", n)
|
|
}
|
|
}
|