eda033ff9b
Addresses errcheck, gosec, revive, staticcheck, and unused findings
across the codebase (unchecked error returns, unsafe file inclusion
warnings on operator/test-controlled paths, missing package comments,
unused parameters, deprecated API usage). Also fixes two suppression
comments that were silently no-ops due to wrong syntax (#nosec needs
a leading '#', nolint reasons need '//' not '--').
With the backlog clear, drop continue-on-error from the CI lint job
per the plan left in b4b4806.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
540 lines
19 KiB
Go
540 lines
19 KiB
Go
// 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 ""
|
|
}
|