package config import ( "encoding/base64" "fmt" "net" "net/url" "strconv" "strings" "time" ) // Validate checks the configuration for required fields and valid values. // Similar to Chatwoot's config validation in config/initializers. func Validate(cfg *Config) error { // Server validation if cfg.Server.Port < 1 || cfg.Server.Port > 65535 { return fmt.Errorf("invalid server port: %d", cfg.Server.Port) } if cfg.Server.Mode != "debug" && cfg.Server.Mode != "release" && cfg.Server.Mode != "test" { return fmt.Errorf("invalid server mode: %s (must be debug, release, or test)", cfg.Server.Mode) } // Database validation (ref: Chatwoot requires POSTGRES_* env vars) if cfg.Database.DSN == "" { return fmt.Errorf("database DSN is required") } dbURL, err := url.Parse(cfg.Database.DSN) if err != nil { return fmt.Errorf("invalid database DSN: %w", err) } if dbURL.Scheme != "postgres" && dbURL.Scheme != "postgresql" { return fmt.Errorf("invalid database DSN: scheme must be postgres or postgresql, got %s", dbURL.Scheme) } if dbURL.Host == "" { return fmt.Errorf("invalid database DSN: host is required") } // Redis validation (ref: Chatwoot config/cable.yml requires Redis connection) if cfg.Redis.DSN == "" { return fmt.Errorf("redis DSN is required") } redisURL, err := url.Parse(cfg.Redis.DSN) if err != nil { return fmt.Errorf("invalid redis DSN: %w", err) } if redisURL.Scheme != "redis" && redisURL.Scheme != "rediss" { return fmt.Errorf("invalid redis DSN: scheme must be redis or rediss, got %s", redisURL.Scheme) } if redisURL.Host == "" { return fmt.Errorf("invalid redis DSN: host is required") } for _, proxy := range cfg.Server.TrustedProxies { if net.ParseIP(proxy) == nil { if _, _, err := net.ParseCIDR(proxy); err != nil { return fmt.Errorf("invalid trusted proxy %q", proxy) } } } // JWT validation if cfg.Server.Mode == "release" { if cfg.JWT.AllowInsecureHeaderAuth { return fmt.Errorf("insecure header authentication is not allowed in release mode") } if err := validateProductionJWTSecret("JWT secret", cfg.JWT.Secret); err != nil { return err } seen := map[string]bool{cfg.JWT.Secret: true} for _, secret := range cfg.JWT.PreviousSecrets { if err := validateProductionJWTSecret("previous JWT secret", secret); err != nil { return err } if seen[secret] { return fmt.Errorf("previous JWT secrets must be unique and differ from the active secret") } seen[secret] = true } } // Log validation validLogLevels := map[string]bool{"debug": true, "info": true, "warn": true, "error": true} if !validLogLevels[cfg.Log.Level] { return fmt.Errorf("invalid log level: %s", cfg.Log.Level) } // Worker validation (ref: Chatwoot sidekiq.yml concurrency check) if cfg.Worker.Concurrency < 1 { return fmt.Errorf("worker concurrency must be >= 1") } if cfg.Worker.BlockTimeoutS < 1 { return fmt.Errorf("worker.redis_block_timeout_s must be >= 1") } if cfg.Worker.SweepIntervalS < 1 { return fmt.Errorf("worker.redis_sweep_interval_s must be >= 1") } // Search validation. Meilisearch is the production parity engine; db remains // available only as an explicit development fallback. engine := strings.ToLower(cfg.Search.Engine) if engine == "" { engine = "meilisearch" } if engine != "meilisearch" && engine != "db" { return fmt.Errorf("invalid search engine: %s (must be meilisearch or db)", cfg.Search.Engine) } if engine == "db" && cfg.Server.Mode == "release" { return fmt.Errorf("search.engine=db is only allowed for local development; release mode requires meilisearch") } if engine == "meilisearch" { if cfg.Search.Host == "" { return fmt.Errorf("search.host is required when search.engine=meilisearch") } searchURL, err := url.Parse(cfg.Search.Host) if err != nil || searchURL.Scheme == "" || searchURL.Host == "" { return fmt.Errorf("invalid search.host: %s", cfg.Search.Host) } } if cfg.Search.TimeoutSeconds < 0 { return fmt.Errorf("search.timeout_seconds must be >= 0") } if cfg.Server.Mode == "release" { if dbURL.User == nil || dbURL.User.Username() == "" { return fmt.Errorf("production database credentials are required") } if password, ok := dbURL.User.Password(); !ok || password == "" || containsPlaceholder(password) { return fmt.Errorf("production database password is required and must not contain placeholders") } if err := ValidateProductionDatabaseDSN(cfg.Database.DSN); err != nil { return err } if redisURL.User != nil { if password, ok := redisURL.User.Password(); !ok || password == "" || containsPlaceholder(password) { return fmt.Errorf("production Redis password is required and must not contain placeholders") } } if redisURL.Scheme == "rediss" && strings.EqualFold(redisURL.Query().Get("insecure_skip_verify"), "true") { return fmt.Errorf("production Redis TLS certificate verification cannot be disabled") } if cfg.JWT.AccessExpiryMinutes <= 0 || cfg.JWT.AccessExpiryMinutes > 15 { return fmt.Errorf("production JWT access_expiry_minutes must be between 1 and 15") } if !cfg.Encryption.Enabled { return fmt.Errorf("production sensitive-field encryption must be enabled") } if len(cfg.Search.APIKey) < 16 || containsPlaceholder(cfg.Search.APIKey) { return fmt.Errorf("production search API key must be at least 16 bytes and must not contain placeholders") } } if cfg.Encryption.Enabled { key, err := base64.StdEncoding.DecodeString(cfg.Encryption.AESKey) if err != nil || len(key) != 32 || cfg.Encryption.CurrentKeyVersion < 1 { return fmt.Errorf("encryption requires a base64-encoded 32-byte AES key and positive key version") } for version, encoded := range cfg.Encryption.PreviousKeys { parsedVersion, err := strconv.Atoi(version) if err != nil || parsedVersion < 1 || parsedVersion == cfg.Encryption.CurrentKeyVersion { return fmt.Errorf("invalid previous encryption key version %q", version) } decoded, err := base64.StdEncoding.DecodeString(encoded) if err != nil || len(decoded) != 32 { return fmt.Errorf("previous encryption key v%s must be base64-encoded 32 bytes", version) } } } return nil } // ValidateProductionDatabaseDSN protects every direct Go database client from // local targets, duplicate sslmode downgrades, and unsafe certificate paths. func ValidateProductionDatabaseDSN(dsn string) error { dbURL, err := url.Parse(dsn) if err != nil { return fmt.Errorf("invalid production database DSN: %w", err) } hostname := strings.TrimSuffix(strings.ToLower(dbURL.Hostname()), ".") if zone := strings.LastIndexByte(hostname, '%'); zone >= 0 { hostname = hostname[:zone] } ip := net.ParseIP(hostname) if hostname == "" || hostname == "postgres" || hostname == "localhost" || ip != nil && ip.IsLoopback() { return fmt.Errorf("production database DSN must use an external PostgreSQL host") } query, err := url.ParseQuery(dbURL.RawQuery) if err != nil { return fmt.Errorf("invalid production database DSN query: %w", err) } modes := query["sslmode"] if len(modes) != 1 { return fmt.Errorf("production database DSN must contain exactly one sslmode") } if modes[0] != "disable" && modes[0] != "verify-ca" && modes[0] != "verify-full" { return fmt.Errorf("production database DSN must use sslmode=disable, verify-full or verify-ca") } fixedPaths := map[string]string{ "sslrootcert": "/run/secrets/external-db-ca.crt", "sslcert": "/run/secrets/external-db-client.crt", "sslkey": "/run/secrets/external-db-client.key", } usesCertificateFiles := false for parameter := range fixedPaths { usesCertificateFiles = usesCertificateFiles || len(query[parameter]) > 0 } if usesCertificateFiles { for parameter, path := range fixedPaths { values := query[parameter] if len(values) != 1 || values[0] != path { return fmt.Errorf("production database DSN %s must appear exactly once and use %s", parameter, path) } } } return nil } func containsPlaceholder(value string) bool { value = strings.ToLower(value) return strings.Contains(value, "change_me") || strings.Contains(value, "change-me") || strings.Contains(value, "changeme") } func validateProductionJWTSecret(name, secret string) error { secret = strings.TrimSpace(secret) if len([]byte(secret)) < 32 { return fmt.Errorf("%s must be at least 32 bytes (256 bits) in release mode", name) } normalized := strings.NewReplacer("-", "_", " ", "_").Replace(strings.ToLower(secret)) for _, placeholder := range []string{"change_me", "changeme", "change_in_production", "replace_me", "your_jwt_secret", "default_secret", "example_secret", "test_secret", "quickstart"} { if strings.Contains(normalized, placeholder) { return fmt.Errorf("%s contains a common placeholder", name) } } return nil } // ParseStatementTimeout converts the string timeout to time.Duration. func ParseStatementTimeout(timeout string) (time.Duration, error) { if timeout == "" { return 14 * time.Second, nil } // Handle plain seconds (e.g., "14s") if secs, err := strconv.Atoi(timeout); err == nil { return time.Duration(secs) * time.Second, nil } // Handle Go duration format (e.g., "14s", "500ms") d, err := time.ParseDuration(timeout) if err != nil { return 0, fmt.Errorf("invalid statement_timeout format: %s", timeout) } return d, nil }