5eaf6fe6fb
- Add SanitizeDatabaseURL() function to config package that redacts passwords from database connection URLs - Handles various URL formats: postgresql, mysql, etc. - Correctly handles passwords containing special characters including @ symbols by using LastIndex - Update startup logging in cmd/server/main.go to use sanitized database URL - Add comprehensive tests covering various URL formats and edge cases This ensures sensitive database credentials are not exposed in application logs.
521 lines
18 KiB
Go
521 lines
18 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,
|
|
"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 ""
|
|
}
|