Files
notifier/internal/config/config.go
T
igodwin 56bfcb59d3 Fix missing imports and restore CORS middleware for tests
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
2025-10-30 23:47:53 -07:00

477 lines
17 KiB
Go

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"
}
// 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)
}
// 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,
"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
}
// 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 ""
}