Files
notifier/cmd/server/main.go
T
igodwin 172c240d1b fix(auth): repair persistence layer and stop storing plaintext keys
- Store SHA-256 digests (key_hash + key_preview) instead of raw keys, in
  both the in-memory store and Postgres; migrate legacy plaintext rows in
  place and drop the plaintext column.
- Fix TEXT[] scans that failed at runtime (missing pq.Array) in
  GetKey/ListKeys/LoadAllKeys.
- Load persisted keys at startup (InitializeFromDatabase was never called)
  and fall back to the database on cache miss, so issued keys survive
  restarts.
- Make HybridKeyStore.CreateKey genuinely write-through: cache is only
  updated after a successful DB write.
- Guard nil database backend (auth enabled without DB previously panicked
  on key creation) and degrade to in-memory operation.
- Persist bootstrap admin keys when a database is configured.
- Record real audit-log details as JSON and log audit failures instead of
  silently dropping them; add DB pool limits and ping timeout.
- Sentinel errors matched with errors.Is; unit tests for hashing,
  write-through ordering, DB fallback, and nil-DB operation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:27:49 -07:00

448 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, logger)
if err != nil {
logger.Fatalf("Failed to create database key store: %v", err)
}
logger.Infof("Connected to authentication database: %s", config.SanitizeDatabaseURL(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")
// Load persisted keys into the cache so previously issued keys
// keep authenticating across restarts.
if hybridKeyStore.HasDatabase() {
if loaded, err := hybridKeyStore.InitializeFromDatabase(ctx); err != nil {
logger.Errorf("Failed to load API keys from database: %v", err)
} else {
logger.Infof("Loaded %d API key(s) from database into cache", loaded)
}
}
// 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 != "" {
apiKey, err := auth.RegisterAdminKeyInMemory(authStore, existingKey, logger)
if err != nil {
logger.Warnf("Failed to register existing admin key: %v", err)
} else if err := hybridKeyStore.EnsurePersisted(ctx, apiKey, "bootstrap"); err != nil {
logger.Warnf("Failed to persist existing admin key: %v", err)
}
} else {
// Generate a new key — through the hybrid store when a
// database is configured so the admin key survives restarts.
var apiKey *auth.APIKey
var err error
if hybridKeyStore.HasDatabase() {
apiKey, err = auth.BootstrapAdminKey(ctx, hybridKeyStore, bootstrapCfg, logger)
} else {
apiKey, err = auth.BootstrapAdminKeyInMemory(authStore, bootstrapCfg, logger)
}
if 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,
},
}
}