56bfcb59d3
After conflict resolution from rebase, some imports were accidentally removed and the CORS middleware function was eliminated but still referenced by tests. This commit: - Adds fmt import to api/rest/keys.go (used for error messages) - Adds gorilla/mux import to cmd/server/main.go (used for router type) - Restores newCORSMiddleware function to api/rest/router.go for test compatibility - Formats code with gofmt
427 lines
14 KiB
Go
427 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/gorilla/mux"
|
|
grpcapi "github.com/igodwin/notifier/api/grpc"
|
|
pb "github.com/igodwin/notifier/api/grpc/pb"
|
|
"github.com/igodwin/notifier/api/rest"
|
|
"github.com/igodwin/notifier/internal/auth"
|
|
"github.com/igodwin/notifier/internal/config"
|
|
"github.com/igodwin/notifier/internal/domain"
|
|
"github.com/igodwin/notifier/internal/logging"
|
|
"github.com/igodwin/notifier/internal/notifier"
|
|
"github.com/igodwin/notifier/internal/queue"
|
|
"github.com/igodwin/notifier/internal/service"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/reflection"
|
|
)
|
|
|
|
var (
|
|
// Build information - set via ldflags during build
|
|
// Example: go build -ldflags "-X main.Version=1.0.0 -X main.GitCommit=$(git rev-parse HEAD)"
|
|
Version = "dev"
|
|
GitCommit = "unknown"
|
|
BuildTime = "unknown"
|
|
)
|
|
|
|
func main() {
|
|
// Print service identifier and build info
|
|
fmt.Printf("====================================\n")
|
|
fmt.Printf("Notifier Service\n")
|
|
fmt.Printf("Version: %s\n", Version)
|
|
fmt.Printf("Git Commit: %s\n", GitCommit)
|
|
fmt.Printf("Build Time: %s\n", BuildTime)
|
|
fmt.Printf("====================================\n")
|
|
|
|
// Load configuration
|
|
cfg, err := config.Load("")
|
|
if err != nil {
|
|
// Use basic logger before we have config
|
|
logger, _ := logging.NewFromConfig("info", "stdout")
|
|
logger.Warnf("Failed to load config, using defaults: %v", err)
|
|
cfg = getDefaultConfig()
|
|
}
|
|
|
|
// Create logger from config
|
|
logger, err := logging.NewFromConfig(cfg.Logging.Level, cfg.Logging.OutputPath)
|
|
if err != nil {
|
|
// Fallback to stdout if log file can't be opened
|
|
logger, _ = logging.NewFromConfig(cfg.Logging.Level, "stdout")
|
|
logger.Warnf("Failed to open log file, using stdout: %v", err)
|
|
}
|
|
|
|
// Log which config file was loaded
|
|
logger.Infof("Loaded configuration from: %s", cfg.ConfigFile)
|
|
|
|
// Log sanitized config (with sensitive data redacted)
|
|
if sanitized, err := json.MarshalIndent(cfg.Sanitize(), "", " "); err == nil {
|
|
logger.Infof("Configuration:\n%s", string(sanitized))
|
|
}
|
|
|
|
logger.Infof("Starting Notifier Service in mode: %s", cfg.Server.Mode)
|
|
|
|
// Create context
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
// Initialize queue
|
|
var q domain.Queue
|
|
if cfg.Queue.Type == "local" {
|
|
q, err = queue.NewLocalQueue(cfg.Queue.Local)
|
|
if err != nil {
|
|
logger.Fatalf("Failed to create queue: %v", err)
|
|
}
|
|
logger.Info("Using local queue")
|
|
} else {
|
|
logger.Fatalf("Queue type %s not implemented yet", cfg.Queue.Type)
|
|
}
|
|
|
|
// Initialize authentication if enabled (must be before service creation for RBAC)
|
|
var authStore *auth.APIKeyStore
|
|
var hybridKeyStore *auth.HybridKeyStore
|
|
var authz *auth.NotifierAuthz
|
|
if cfg.Auth.Enabled {
|
|
authStore = auth.NewAPIKeyStore()
|
|
authz = auth.NewNotifierAuthz()
|
|
logger.Info("API authentication enabled")
|
|
|
|
// Create database backend if configured
|
|
var dbStore *auth.KeyStoreDB
|
|
if cfg.Auth.Database.URL != "" {
|
|
dbStore, err = auth.NewKeyStoreDB(cfg.Auth.Database.URL)
|
|
if err != nil {
|
|
logger.Fatalf("Failed to create database key store: %v", err)
|
|
}
|
|
logger.Infof("Connected to authentication database: %s", cfg.Auth.Database.URL)
|
|
} else {
|
|
logger.Warn("No database configured for authentication - API keys will only be stored in memory")
|
|
}
|
|
|
|
// Create hybrid key store for key management (in-memory cache + database backend)
|
|
hybridKeyStore = auth.NewHybridKeyStore(authStore, dbStore)
|
|
logger.Debugf("Initialized hybrid key store for API key management")
|
|
|
|
// Bootstrap admin key if configured
|
|
if cfg.Auth.Bootstrap.Enabled {
|
|
bootstrapCfg := &auth.BootstrapConfig{
|
|
Enabled: cfg.Auth.Bootstrap.Enabled,
|
|
AdminKeyFileName: cfg.Auth.Bootstrap.AdminKeyFileName,
|
|
PrintToStdout: cfg.Auth.Bootstrap.PrintToStdout,
|
|
}
|
|
|
|
// Try to load existing key from Kubernetes secret first
|
|
existingKey, err := auth.LoadAdminKeyFromKubernetesSecret(
|
|
ctx,
|
|
cfg.Auth.Bootstrap.KubernetesSecretName,
|
|
cfg.Auth.Bootstrap.KubernetesSecretKey,
|
|
logger,
|
|
)
|
|
if err != nil {
|
|
logger.Warnf("Error loading from Kubernetes secret: %v", err)
|
|
}
|
|
|
|
// If we have an existing key, use it
|
|
if existingKey != "" {
|
|
if _, err := auth.RegisterAdminKeyInMemory(authStore, existingKey, logger); err != nil {
|
|
logger.Warnf("Failed to register existing admin key: %v", err)
|
|
}
|
|
} else {
|
|
// Generate new key
|
|
if apiKey, err := auth.BootstrapAdminKeyInMemory(authStore, bootstrapCfg, logger); err != nil {
|
|
logger.Warnf("Bootstrap admin key creation failed: %v", err)
|
|
} else if apiKey != nil {
|
|
// Store in Kubernetes secret if configured
|
|
if cfg.Auth.Bootstrap.KubernetesSecretName != "" {
|
|
if err := auth.CreateKubernetesSecret(
|
|
ctx,
|
|
cfg.Auth.Bootstrap.KubernetesSecretName,
|
|
cfg.Auth.Bootstrap.KubernetesSecretKey,
|
|
apiKey.Key,
|
|
logger,
|
|
); err != nil {
|
|
logger.Warnf("Failed to create Kubernetes secret: %v", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Initialize notifier factory and register notifiers
|
|
factory := notifier.NewFactory()
|
|
registerNotifiers(cfg, factory, logger)
|
|
|
|
// Check if any notifiers are registered
|
|
if len(factory.SupportedTypes()) == 0 {
|
|
logger.Fatal("No notifiers configured. Please enable at least one notifier in notifier.config")
|
|
}
|
|
|
|
logger.Infof("Supported notification types: %v", factory.SupportedTypes())
|
|
|
|
// Register authorization rules for notifiers (after factory registration)
|
|
if authz != nil {
|
|
registerAuthorizationRules(cfg, authz, logger)
|
|
}
|
|
|
|
// Create notification service (pass config as account resolver and authz for RBAC)
|
|
svc := service.NewNotificationService(factory, q, cfg.Queue.WorkerCount, cfg, authz, logger)
|
|
|
|
// Configure notification retention if enabled
|
|
if err := svc.WithRetentionConfig(cfg.Retention); err != nil {
|
|
logger.Warnf("Failed to configure retention: %v", err)
|
|
// Log defaults that will be used
|
|
logger.Infof("Using default retention config: enabled=%v", cfg.Retention.Enabled)
|
|
} else if cfg.Retention.Enabled {
|
|
logger.Infof("Configured notification retention: ttl=%s, check_frequency=%s, max_size=%d",
|
|
cfg.Retention.TTL, cfg.Retention.CheckFrequency, cfg.Retention.MaxSize)
|
|
}
|
|
|
|
// Start workers
|
|
if err := svc.Start(ctx); err != nil {
|
|
logger.Fatalf("Failed to start service: %v", err)
|
|
}
|
|
logger.Infof("Started %d worker(s)", cfg.Queue.WorkerCount)
|
|
|
|
// Wait group for both servers
|
|
var wg sync.WaitGroup
|
|
|
|
// Start gRPC server if enabled
|
|
var grpcServer *grpc.Server
|
|
if cfg.Server.Mode == "both" || cfg.Server.Mode == "grpc" {
|
|
wg.Add(1)
|
|
grpcServer = startGRPCServer(ctx, &wg, cfg, svc, logger, authStore)
|
|
}
|
|
|
|
// Start REST server if enabled
|
|
var restServer *http.Server
|
|
if cfg.Server.Mode == "both" || cfg.Server.Mode == "rest" {
|
|
wg.Add(1)
|
|
restServer = startRESTServer(ctx, &wg, cfg, svc, logger, authStore, hybridKeyStore)
|
|
}
|
|
|
|
// Wait for interrupt signal
|
|
sigChan := make(chan os.Signal, 1)
|
|
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
|
<-sigChan
|
|
|
|
logger.Info("Shutting down servers...")
|
|
|
|
// Graceful shutdown
|
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer shutdownCancel()
|
|
|
|
// Stop REST server
|
|
if restServer != nil {
|
|
if err := restServer.Shutdown(shutdownCtx); err != nil {
|
|
logger.Errorf("Error during REST server shutdown: %v", err)
|
|
}
|
|
}
|
|
|
|
// Stop gRPC server
|
|
if grpcServer != nil {
|
|
grpcServer.GracefulStop()
|
|
}
|
|
|
|
// Wait for servers to stop
|
|
wg.Wait()
|
|
|
|
// Stop service
|
|
if err := svc.Stop(); err != nil {
|
|
logger.Errorf("Error stopping service: %v", err)
|
|
}
|
|
|
|
logger.Info("Servers stopped")
|
|
}
|
|
|
|
func registerNotifiers(cfg *config.Config, factory *notifier.Factory, logger *logging.Logger) {
|
|
if cfg.Notifiers.Stdout {
|
|
stdoutNotifier := notifier.NewStdoutNotifier()
|
|
if err := factory.RegisterNotifier(domain.TypeStdout, "", stdoutNotifier); err != nil {
|
|
logger.Fatalf("Failed to register stdout notifier: %v", err)
|
|
}
|
|
logger.Info("Registered stdout notifier")
|
|
}
|
|
|
|
// Register SMTP notifiers (now supports multiple accounts)
|
|
for accountName, smtpConfig := range cfg.Notifiers.SMTP {
|
|
smtpNotifier, err := notifier.NewSMTPNotifier(smtpConfig)
|
|
if err != nil {
|
|
logger.Warnf("Failed to create SMTP notifier for account '%s': %v", accountName, err)
|
|
} else {
|
|
if err := factory.RegisterNotifier(domain.TypeEmail, accountName, smtpNotifier); err != nil {
|
|
logger.Fatalf("Failed to register SMTP notifier for account '%s': %v", accountName, err)
|
|
}
|
|
defaultStr := ""
|
|
if smtpConfig.Default {
|
|
defaultStr = " (default)"
|
|
}
|
|
logger.Infof("Registered SMTP notifier for account '%s'%s", accountName, defaultStr)
|
|
}
|
|
}
|
|
|
|
// Register Slack notifiers (now supports multiple accounts)
|
|
for accountName, slackConfig := range cfg.Notifiers.Slack {
|
|
slackNotifier, err := notifier.NewSlackNotifier(slackConfig)
|
|
if err != nil {
|
|
logger.Warnf("Failed to create Slack notifier for account '%s': %v", accountName, err)
|
|
} else {
|
|
if err := factory.RegisterNotifier(domain.TypeSlack, accountName, slackNotifier); err != nil {
|
|
logger.Fatalf("Failed to register Slack notifier for account '%s': %v", accountName, err)
|
|
}
|
|
defaultStr := ""
|
|
if slackConfig.Default {
|
|
defaultStr = " (default)"
|
|
}
|
|
logger.Infof("Registered Slack notifier for account '%s'%s", accountName, defaultStr)
|
|
}
|
|
}
|
|
|
|
// Register Ntfy notifiers (now supports multiple accounts)
|
|
for accountName, ntfyConfig := range cfg.Notifiers.Ntfy {
|
|
ntfyNotifier, err := notifier.NewNtfyNotifier(ntfyConfig)
|
|
if err != nil {
|
|
logger.Warnf("Failed to create Ntfy notifier for account '%s': %v", accountName, err)
|
|
} else {
|
|
if err := factory.RegisterNotifier(domain.TypeNtfy, accountName, ntfyNotifier); err != nil {
|
|
logger.Fatalf("Failed to register Ntfy notifier for account '%s': %v", accountName, err)
|
|
}
|
|
defaultStr := ""
|
|
if ntfyConfig.Default {
|
|
defaultStr = " (default)"
|
|
}
|
|
logger.Infof("Registered Ntfy notifier for account '%s'%s", accountName, defaultStr)
|
|
}
|
|
}
|
|
}
|
|
|
|
func startGRPCServer(ctx context.Context, wg *sync.WaitGroup, cfg *config.Config, svc domain.NotificationService, logger *logging.Logger, authStore *auth.APIKeyStore) *grpc.Server {
|
|
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.GRPCPort)
|
|
|
|
lis, err := net.Listen("tcp", addr)
|
|
if err != nil {
|
|
logger.Fatalf("Failed to listen on %s: %v", addr, err)
|
|
}
|
|
|
|
// Create gRPC server options
|
|
var serverOpts []grpc.ServerOption
|
|
|
|
// Add authentication interceptors if enabled
|
|
if authStore != nil {
|
|
authMiddleware := auth.NewGRPCAuthMiddleware(authStore, logger)
|
|
serverOpts = append(serverOpts,
|
|
grpc.UnaryInterceptor(authMiddleware.UnaryInterceptor()),
|
|
grpc.StreamInterceptor(authMiddleware.StreamInterceptor()),
|
|
)
|
|
}
|
|
|
|
grpcServer := grpc.NewServer(serverOpts...)
|
|
|
|
// Create and register gRPC handler
|
|
grpcHandler := grpcapi.NewNotifierHandler(svc, logger)
|
|
pb.RegisterNotifierServiceServer(grpcServer, grpcHandler)
|
|
|
|
// Enable reflection for tools like grpcurl
|
|
reflection.Register(grpcServer)
|
|
|
|
logger.Info("Registered gRPC NotifierService")
|
|
|
|
go func() {
|
|
defer wg.Done()
|
|
logger.Infof("gRPC server listening on %s", addr)
|
|
if err := grpcServer.Serve(lis); err != nil {
|
|
logger.Fatalf("Failed to serve gRPC: %v", err)
|
|
}
|
|
}()
|
|
|
|
return grpcServer
|
|
}
|
|
|
|
func startRESTServer(ctx context.Context, wg *sync.WaitGroup, cfg *config.Config, svc domain.NotificationService, logger *logging.Logger, authStore *auth.APIKeyStore, hybridKeyStore *auth.HybridKeyStore) *http.Server {
|
|
var router *mux.Router
|
|
if authStore != nil && hybridKeyStore != nil {
|
|
router = rest.NewRouterWithAuthAndKeyStore(svc, logger, authStore, hybridKeyStore)
|
|
} else if authStore != nil {
|
|
router = rest.NewRouterWithAuth(svc, logger, authStore)
|
|
} else {
|
|
router = rest.NewRouter(svc, logger)
|
|
}
|
|
|
|
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.RESTPort)
|
|
server := &http.Server{
|
|
Addr: addr,
|
|
Handler: router,
|
|
ReadTimeout: 15 * time.Second,
|
|
WriteTimeout: 15 * time.Second,
|
|
IdleTimeout: 60 * time.Second,
|
|
}
|
|
|
|
go func() {
|
|
defer wg.Done()
|
|
logger.Infof("REST server listening on %s", addr)
|
|
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
logger.Fatalf("Failed to start REST server: %v", err)
|
|
}
|
|
}()
|
|
|
|
return server
|
|
}
|
|
|
|
func registerAuthorizationRules(cfg *config.Config, authz *auth.NotifierAuthz, logger *logging.Logger) {
|
|
// Register SMTP authorization rules
|
|
for accountName, smtpConfig := range cfg.Notifiers.SMTP {
|
|
if len(smtpConfig.AllowedRoles) > 0 {
|
|
authz.RegisterRule(domain.TypeEmail, accountName, smtpConfig.AllowedRoles)
|
|
logger.Infof("Registered auth rule for SMTP account '%s' - allowed roles: %v", accountName, smtpConfig.AllowedRoles)
|
|
}
|
|
}
|
|
|
|
// Register Slack authorization rules
|
|
for accountName, slackConfig := range cfg.Notifiers.Slack {
|
|
if len(slackConfig.AllowedRoles) > 0 {
|
|
authz.RegisterRule(domain.TypeSlack, accountName, slackConfig.AllowedRoles)
|
|
logger.Infof("Registered auth rule for Slack account '%s' - allowed roles: %v", accountName, slackConfig.AllowedRoles)
|
|
}
|
|
}
|
|
|
|
// Register Ntfy authorization rules
|
|
for accountName, ntfyConfig := range cfg.Notifiers.Ntfy {
|
|
if len(ntfyConfig.AllowedRoles) > 0 {
|
|
authz.RegisterRule(domain.TypeNtfy, accountName, ntfyConfig.AllowedRoles)
|
|
logger.Infof("Registered auth rule for Ntfy account '%s' - allowed roles: %v", accountName, ntfyConfig.AllowedRoles)
|
|
}
|
|
}
|
|
}
|
|
|
|
func getDefaultConfig() *config.Config {
|
|
return &config.Config{
|
|
Server: config.ServerConfig{
|
|
GRPCPort: 50051,
|
|
RESTPort: 8080,
|
|
Host: "0.0.0.0",
|
|
Mode: "both",
|
|
},
|
|
Queue: domain.QueueConfig{
|
|
Type: "local",
|
|
WorkerCount: 5,
|
|
RetryAttempts: 3,
|
|
Local: &domain.LocalQueueConfig{
|
|
BufferSize: 1000,
|
|
},
|
|
},
|
|
Notifiers: config.NotifiersConfig{
|
|
Stdout: true,
|
|
},
|
|
}
|
|
}
|