e531516cfa
- New notification relay endpoint: receives events from customer controllers, looks up customer email preferences, sends via Resend HTTP API - New tables: customer_notifications (per-customer email + event prefs), notification_log (audit trail for all notification attempts) - Hungarian email template with severity, event type, timestamp - Config: notifications.resend_api_key + notifications.from_email - Test events always pass event-type filter Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
216 lines
5.4 KiB
Go
216 lines
5.4 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/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"`
|
|
} `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"`
|
|
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)
|
|
|
|
// 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
|
|
}
|
|
|
|
// Initialize handlers
|
|
apiHandler := api.New(dataStore, cfg.API.ReportAPIKey, cfg.Notifications.ResendAPIKey, cfg.Notifications.FromEmail, logger)
|
|
webServer := web.New(dataStore, cfg.Auth.PasswordHash, staleThreshold, logger)
|
|
|
|
// Build HTTP mux
|
|
mux := http.NewServeMux()
|
|
|
|
// Health check endpoint — bypasses auth (for k8s probes)
|
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
|
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,
|
|
}
|
|
|
|
// Background: daily prune
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
if cfg.Retention.MaxDays > 0 {
|
|
go pruneLoop(ctx, dataStore, cfg.Retention.MaxDays, 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"
|
|
}
|
|
|
|
return cfg
|
|
}
|
|
|
|
func pruneLoop(ctx context.Context, s *store.Store, maxDays int, logger *log.Logger) {
|
|
// Prune once on startup
|
|
if deleted, err := s.Prune(maxDays); err != nil {
|
|
logger.Printf("[WARN] Prune failed: %v", err)
|
|
} else if deleted > 0 {
|
|
logger.Printf("[INFO] Pruned %d old report rows", deleted)
|
|
}
|
|
|
|
// Then daily
|
|
ticker := time.NewTicker(24 * time.Hour)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
if deleted, err := s.Prune(maxDays); err != nil {
|
|
logger.Printf("[WARN] Prune failed: %v", err)
|
|
} else if deleted > 0 {
|
|
logger.Printf("[INFO] Pruned %d old report rows", deleted)
|
|
}
|
|
}
|
|
}
|
|
}
|