// Package config loads and validates the notifier service's configuration // (notifiers, queue, auth, retention, and related settings) from files, // environment variables, and defaults via viper. package config import ( "fmt" "os" "path/filepath" "strings" "github.com/igodwin/notifier/internal/domain" "github.com/igodwin/notifier/internal/notifier" "github.com/spf13/viper" ) // Config represents the application configuration type Config struct { Server ServerConfig `mapstructure:"server"` Queue domain.QueueConfig `mapstructure:"queue"` Notifiers NotifiersConfig `mapstructure:"notifiers"` Logging LoggingConfig `mapstructure:"logging"` Metrics MetricsConfig `mapstructure:"metrics"` HealthCheck HealthCheckConfig `mapstructure:"health_check"` Auth AuthConfig `mapstructure:"auth"` CORS CORSConfig `mapstructure:"cors"` Retention NotificationRetentionConfig `mapstructure:"retention"` ConfigFile string `mapstructure:"-"` // Path to config file used (not from config) } // ServerConfig contains server configuration type ServerConfig struct { GRPCPort int `mapstructure:"grpc_port"` RESTPort int `mapstructure:"rest_port"` Host string `mapstructure:"host"` Mode string `mapstructure:"mode"` // "both", "grpc", "rest" TLS TLSConfig `mapstructure:"tls"` } // TLSConfig enables TLS on the REST and gRPC listeners. When disabled the // servers speak plaintext, which is only appropriate behind a TLS-terminating // gateway or service mesh. type TLSConfig struct { Enabled bool `mapstructure:"enabled"` CertFile string `mapstructure:"cert_file"` KeyFile string `mapstructure:"key_file"` } // NotifiersConfig contains configuration for all notifier types type NotifiersConfig struct { SMTP map[string]*notifier.SMTPConfig `mapstructure:"smtp"` Slack map[string]*notifier.SlackConfig `mapstructure:"slack"` Ntfy map[string]*notifier.NtfyConfig `mapstructure:"ntfy"` Stdout bool `mapstructure:"stdout"` // Enable stdout notifier } // LoggingConfig contains logging configuration type LoggingConfig struct { Level string `mapstructure:"level"` // debug, info, warn, error Format string `mapstructure:"format"` // json, text OutputPath string `mapstructure:"output_path"` // stdout, stderr, or file path } // MetricsConfig contains metrics/observability configuration type MetricsConfig struct { Enabled bool `mapstructure:"enabled"` Port int `mapstructure:"port"` Path string `mapstructure:"path"` PrometheusEnabled bool `mapstructure:"prometheus_enabled"` } // HealthCheckConfig contains health check configuration type HealthCheckConfig struct { Enabled bool `mapstructure:"enabled"` Port int `mapstructure:"port"` Path string `mapstructure:"path"` Interval int `mapstructure:"interval"` // seconds } // AuthConfig contains authentication and authorization configuration type AuthConfig struct { Enabled bool `mapstructure:"enabled"` // Enable API key authentication DefaultRateLimit int `mapstructure:"default_rate_limit"` // Default rate limit in requests/minute (0 = unlimited) Database DatabaseConfig `mapstructure:"database"` // Database configuration for persistent key storage Bootstrap BootstrapConf `mapstructure:"bootstrap"` // Bootstrap admin key configuration } // DatabaseConfig contains database connection configuration type DatabaseConfig struct { URL string `mapstructure:"url"` // Database connection URL (e.g., "postgresql://user:pass@host:5432/db") } // BootstrapConf contains configuration for bootstrap admin key creation type BootstrapConf struct { Enabled bool `mapstructure:"enabled"` // Enable bootstrap on startup AdminKeyFileName string `mapstructure:"admin_key_file"` // File to save the generated admin key PrintToStdout bool `mapstructure:"print_to_stdout"` // Print admin key to stdout (only for setup) KubernetesSecretName string `mapstructure:"kubernetes_secret_name"` // Kubernetes secret name (e.g., "notifier-admin-key") KubernetesSecretKey string `mapstructure:"kubernetes_secret_key"` // Key within secret (e.g., "admin-key") } // CORSConfig contains CORS (Cross-Origin Resource Sharing) configuration type CORSConfig struct { // AllowedOrigins is a whitelist of allowed origins (e.g., ["https://example.com", "https://app.example.com"]) // Wildcards (*) are NOT supported for security reasons - you must specify exact origins AllowedOrigins []string `mapstructure:"allowed_origins"` // AllowedMethods is a list of allowed HTTP methods (e.g., ["GET", "POST", "OPTIONS", "DELETE"]) AllowedMethods []string `mapstructure:"allowed_methods"` // AllowedHeaders is a list of allowed HTTP headers (e.g., ["Content-Type", "Authorization"]) AllowedHeaders []string `mapstructure:"allowed_headers"` // AllowCredentials indicates whether credentials (cookies, authorization headers) are allowed // Note: When true, AllowedOrigins must NOT contain wildcards (enforced by validation) AllowCredentials bool `mapstructure:"allow_credentials"` // MaxAge is the duration in seconds that browsers can cache preflight responses MaxAge int `mapstructure:"max_age"` } // NotificationRetentionConfig contains notification retention and cleanup configuration type NotificationRetentionConfig struct { Enabled bool `mapstructure:"enabled"` // Enable automatic cleanup TTL string `mapstructure:"ttl"` // Time-to-live duration (e.g., "168h" for 7 days) CheckFrequency string `mapstructure:"check_frequency"` // How often to run cleanup (e.g., "1h") MaxSize int `mapstructure:"max_size"` // Maximum number of notifications to keep } // Load loads configuration from file and environment variables // Returns the loaded config and the path to the config file that was used func Load(configPath string) (*Config, error) { v := viper.New() // Set default values setDefaults(v) // Configure viper to look for config.yaml v.SetConfigName("config") v.SetConfigType("yaml") // Add config search paths if configPath != "" { v.AddConfigPath(configPath) } v.AddConfigPath(".") v.AddConfigPath("./config") v.AddConfigPath("/etc/notifier") // Add $HOME/.notifier if HOME is set if home := os.Getenv("HOME"); home != "" { v.AddConfigPath(filepath.Join(home, ".notifier")) } // Environment variable support v.SetEnvPrefix("NOTIFIER") v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) v.AutomaticEnv() // Read config file var configErr error if err := v.ReadInConfig(); err != nil { // Config file is optional if environment variables are set if _, ok := err.(viper.ConfigFileNotFoundError); !ok { return nil, fmt.Errorf("failed to read config file: %w", err) } configErr = err } var config Config if err := v.Unmarshal(&config); err != nil { return nil, fmt.Errorf("failed to unmarshal config: %w", err) } // Store which config file was used config.ConfigFile = v.ConfigFileUsed() if config.ConfigFile == "" { if configErr != nil { config.ConfigFile = "no config file found (using defaults and environment variables)" } else { config.ConfigFile = "using defaults and environment variables" } } // Validate configuration if err := config.Validate(); err != nil { return nil, fmt.Errorf("invalid configuration: %w", err) } return &config, nil } // setDefaults sets default configuration values func setDefaults(v *viper.Viper) { // Server defaults v.SetDefault("server.grpc_port", 50051) v.SetDefault("server.rest_port", 8080) v.SetDefault("server.host", "0.0.0.0") v.SetDefault("server.mode", "both") // Queue defaults v.SetDefault("queue.type", "local") v.SetDefault("queue.max_size", 10000) v.SetDefault("queue.worker_count", 10) v.SetDefault("queue.retry_attempts", 3) v.SetDefault("queue.retry_backoff", "exponential") // Local queue defaults v.SetDefault("queue.local.buffer_size", 1000) v.SetDefault("queue.local.persist_to_disk", false) // Logging defaults v.SetDefault("logging.level", "info") v.SetDefault("logging.format", "json") v.SetDefault("logging.output_path", "stdout") // Metrics defaults v.SetDefault("metrics.enabled", true) v.SetDefault("metrics.port", 9090) v.SetDefault("metrics.path", "/metrics") v.SetDefault("metrics.prometheus_enabled", true) // Health check defaults v.SetDefault("health_check.enabled", true) v.SetDefault("health_check.port", 8081) v.SetDefault("health_check.path", "/health") v.SetDefault("health_check.interval", 30) // Auth defaults v.SetDefault("auth.enabled", false) // Authentication disabled by default v.SetDefault("auth.default_rate_limit", 100) // 100 requests per minute default v.SetDefault("auth.bootstrap.enabled", false) // Bootstrap disabled by default v.SetDefault("auth.bootstrap.admin_key_file", "") // No file by default v.SetDefault("auth.bootstrap.print_to_stdout", false) // Don't print to stdout by default v.SetDefault("auth.bootstrap.kubernetes_secret_name", "notifier-admin-key") // Default secret name v.SetDefault("auth.bootstrap.kubernetes_secret_key", "admin-key") // Default secret key // CORS defaults - secure by default (no origins allowed) v.SetDefault("cors.allowed_origins", []string{}) // Empty by default - must be explicitly configured v.SetDefault("cors.allowed_methods", []string{"GET", "POST", "OPTIONS", "DELETE"}) // Standard REST methods v.SetDefault("cors.allowed_headers", []string{"Content-Type", "Authorization"}) // Common headers v.SetDefault("cors.allow_credentials", false) // Credentials disabled by default v.SetDefault("cors.max_age", 3600) // 1 hour cache for preflight // Retention defaults v.SetDefault("retention.enabled", true) // Enable retention cleanup by default v.SetDefault("retention.ttl", "168h") // 7 days default v.SetDefault("retention.check_frequency", "1h") // Check every hour v.SetDefault("retention.max_size", 100000) // Maximum 100,000 notifications // Notifier defaults v.SetDefault("notifiers.stdout", true) // Note: SMTP, Slack, and Ntfy now use named instances (maps) // so we don't set defaults at the type level } // Validate validates the configuration func (c *Config) Validate() error { // Validate server config if c.Server.GRPCPort < 1 || c.Server.GRPCPort > 65535 { return fmt.Errorf("invalid gRPC port: %d", c.Server.GRPCPort) } if c.Server.RESTPort < 1 || c.Server.RESTPort > 65535 { return fmt.Errorf("invalid REST port: %d", c.Server.RESTPort) } validModes := map[string]bool{"both": true, "grpc": true, "rest": true} if !validModes[c.Server.Mode] { return fmt.Errorf("invalid server mode: %s (must be both, grpc, or rest)", c.Server.Mode) } if c.Server.TLS.Enabled { if c.Server.TLS.CertFile == "" || c.Server.TLS.KeyFile == "" { return fmt.Errorf("server.tls.enabled requires both cert_file and key_file") } } // Validate queue config validQueueTypes := map[string]bool{"local": true, "kafka": true} if !validQueueTypes[c.Queue.Type] { return fmt.Errorf("invalid queue type: %s (must be local or kafka)", c.Queue.Type) } if c.Queue.Type == "kafka" && c.Queue.Kafka == nil { return fmt.Errorf("kafka queue type selected but no kafka configuration provided") } // Validate at least one notifier is configured if !c.HasAnyNotifier() { return fmt.Errorf("at least one notifier must be configured") } // Validate CORS configuration if err := c.validateCORS(); err != nil { return err } return nil } // validateCORS validates the CORS configuration func (c *Config) validateCORS() error { // Check for wildcard in allowed origins (security vulnerability) for _, origin := range c.CORS.AllowedOrigins { if origin == "*" { return fmt.Errorf("wildcard (*) is not allowed in CORS allowed_origins for security reasons - specify exact origins instead") } // Check for invalid origin format if origin != "" && !strings.HasPrefix(origin, "http://") && !strings.HasPrefix(origin, "https://") { return fmt.Errorf("invalid origin format: %s - origins must start with http:// or https://", origin) } } // Validate that credentials are not used with empty origin list (pointless configuration) if c.CORS.AllowCredentials && len(c.CORS.AllowedOrigins) == 0 { return fmt.Errorf("allow_credentials is enabled but no origins are allowed - this configuration is ineffective") } return nil } // HasAnyNotifier checks if at least one notifier is configured func (c *Config) HasAnyNotifier() bool { return c.Notifiers.Stdout || len(c.Notifiers.SMTP) > 0 || len(c.Notifiers.Slack) > 0 || len(c.Notifiers.Ntfy) > 0 } // GetEnabledNotifiers returns a list of enabled notifier types func (c *Config) GetEnabledNotifiers() []domain.NotificationType { var enabled []domain.NotificationType if c.Notifiers.Stdout { enabled = append(enabled, domain.TypeStdout) } if len(c.Notifiers.SMTP) > 0 { enabled = append(enabled, domain.TypeEmail) } if len(c.Notifiers.Slack) > 0 { enabled = append(enabled, domain.TypeSlack) } if len(c.Notifiers.Ntfy) > 0 { enabled = append(enabled, domain.TypeNtfy) } return enabled } // Sanitize returns a sanitized copy of the config with sensitive data redacted func (c *Config) Sanitize() map[string]interface{} { sanitized := map[string]interface{}{ "config_file": c.ConfigFile, "server": map[string]interface{}{ "grpc_port": c.Server.GRPCPort, "rest_port": c.Server.RESTPort, "host": c.Server.Host, "mode": c.Server.Mode, }, "queue": map[string]interface{}{ "type": c.Queue.Type, "worker_count": c.Queue.WorkerCount, "retry_attempts": c.Queue.RetryAttempts, }, "logging": map[string]interface{}{ "level": c.Logging.Level, "format": c.Logging.Format, }, "metrics": map[string]interface{}{ "enabled": c.Metrics.Enabled, "port": c.Metrics.Port, }, "health_check": map[string]interface{}{ "enabled": c.HealthCheck.Enabled, "port": c.HealthCheck.Port, }, } // Sanitize notifiers notifiers := map[string]interface{}{ "stdout": c.Notifiers.Stdout, } // Sanitize SMTP configs if len(c.Notifiers.SMTP) > 0 { smtpAccounts := make(map[string]interface{}) for name, cfg := range c.Notifiers.SMTP { smtpAccounts[name] = map[string]interface{}{ "host": cfg.Host, "port": cfg.Port, "username": cfg.Username, "password": "***REDACTED***", "from": cfg.From, "from_name": cfg.FromName, "use_tls": cfg.UseTLS, "default": cfg.Default, } } notifiers["smtp"] = smtpAccounts } // Sanitize Slack configs if len(c.Notifiers.Slack) > 0 { slackAccounts := make(map[string]interface{}) for name, cfg := range c.Notifiers.Slack { slackAccounts[name] = map[string]interface{}{ "webhook_url": "***REDACTED***", "token": "***REDACTED***", "username": cfg.Username, "icon_emoji": cfg.IconEmoji, "default": cfg.Default, } } notifiers["slack"] = slackAccounts } // Sanitize Ntfy configs if len(c.Notifiers.Ntfy) > 0 { ntfyAccounts := make(map[string]interface{}) for name, cfg := range c.Notifiers.Ntfy { ntfyAccounts[name] = map[string]interface{}{ "server_url": cfg.ServerURL, "token": "***REDACTED***", "username": cfg.Username, "password": "***REDACTED***", "default_topic": cfg.DefaultTopic, "default": cfg.Default, } } notifiers["ntfy"] = ntfyAccounts } sanitized["notifiers"] = notifiers // Sanitize auth config sanitized["auth"] = map[string]interface{}{ "enabled": c.Auth.Enabled, "database": map[string]interface{}{ "url": SanitizeDatabaseURL(c.Auth.Database.URL), }, "bootstrap": map[string]interface{}{ "enabled": c.Auth.Bootstrap.Enabled, "admin_key_file": c.Auth.Bootstrap.AdminKeyFileName, "print_to_stdout": c.Auth.Bootstrap.PrintToStdout, "kubernetes_secret_name": c.Auth.Bootstrap.KubernetesSecretName, "kubernetes_secret_key": c.Auth.Bootstrap.KubernetesSecretKey, }, } // Sanitize retention config sanitized["retention"] = map[string]interface{}{ "enabled": c.Retention.Enabled, "ttl": c.Retention.TTL, "check_frequency": c.Retention.CheckFrequency, "max_size": c.Retention.MaxSize, } return sanitized } // SanitizeDatabaseURL redacts the password from a database connection URL // Handles formats like: postgresql://user:password@host:port/database // Also handles passwords containing @ characters by finding the last @ func SanitizeDatabaseURL(dbURL string) string { if dbURL == "" { return "" } // Find the protocol (e.g., "postgresql://", "mysql://") protocolIdx := strings.Index(dbURL, "://") if protocolIdx == -1 { return dbURL } protocol := dbURL[:protocolIdx+3] remaining := dbURL[protocolIdx+3:] // Find the LAST @ symbol that separates credentials from host // (to handle passwords that may contain @ characters) atIdx := strings.LastIndex(remaining, "@") if atIdx == -1 { // No credentials in the URL return dbURL } // Extract credentials part and check if there's a password credentials := remaining[:atIdx] hostPart := remaining[atIdx:] // Check if there's a colon (indicating a password) colonIdx := strings.Index(credentials, ":") if colonIdx == -1 { // No password, just username return protocol + credentials + hostPart } // Extract username and redact password username := credentials[:colonIdx] return protocol + username + ":***REDACTED***" + hostPart } // GetDefaultAccount returns the default account name for a notifier type, or the first account if no default is set func (c *Config) GetDefaultAccount(notifierType domain.NotificationType) string { switch notifierType { case domain.TypeEmail: for name, cfg := range c.Notifiers.SMTP { if cfg.Default { return name } } // Return first account if no default is set for name := range c.Notifiers.SMTP { return name } case domain.TypeSlack: for name, cfg := range c.Notifiers.Slack { if cfg.Default { return name } } // Return first account if no default is set for name := range c.Notifiers.Slack { return name } case domain.TypeNtfy: for name, cfg := range c.Notifiers.Ntfy { if cfg.Default { return name } } // Return first account if no default is set for name := range c.Notifiers.Ntfy { return name } } return "" }