package config import ( "fmt" "math" "os" "path/filepath" "strings" "gopkg.in/yaml.v3" ) type Config struct { Env string `yaml:"env"` Port string `yaml:"port"` DSN string `yaml:"dsn"` StorageDir string `yaml:"storage_dir"` MaxUploadBytes int64 `yaml:"max_upload_bytes"` AuthSecret string `yaml:"auth_secret"` SystemAIKey string `yaml:"system_ai_key"` AIKeyEncryptionSecret string `yaml:"ai_key_encryption_secret"` AllowedOrigins []string `yaml:"allowed_origins"` } func Load() Config { cfg, err := LoadFromDir("etc") if err != nil { panic(err) } return cfg } func LoadFromDir(configDir string) (Config, error) { mode := strings.TrimSpace(os.Getenv("SENLIN_APP_MODE")) if mode == "" { mode = "dev" } path := filepath.Join(configDir, "agent."+strings.ToLower(mode)+".yaml") data, err := os.ReadFile(path) if err != nil { return Config{}, err } var cfg Config if err := yaml.Unmarshal(data, &cfg); err != nil { return Config{}, err } selectedEnvironment, err := canonicalEnvironment(mode) if err != nil { return Config{}, err } configuredEnvironment, err := canonicalEnvironment(cfg.Env) if err != nil { return Config{}, err } if selectedEnvironment != configuredEnvironment { return Config{}, fmt.Errorf("SENLIN_APP_MODE %s does not match config env %s", mode, cfg.Env) } if cfg.MaxUploadBytes <= 0 { cfg.MaxUploadBytes = 32 << 20 } if strings.TrimSpace(cfg.StorageDir) == "" { return Config{}, fmt.Errorf("storage_dir must not be empty") } hasAllowedOrigin := false for _, origin := range cfg.AllowedOrigins { if strings.TrimSpace(origin) != "" { hasAllowedOrigin = true break } } if !hasAllowedOrigin { return Config{}, fmt.Errorf("allowed_origins must include at least one origin") } production := selectedEnvironment == "production" if err := validateSecret(production, "auth_secret", cfg.AuthSecret); err != nil { return Config{}, err } if err := validateSecret(production, "ai_key_encryption_secret", cfg.AIKeyEncryptionSecret); err != nil { return Config{}, err } return cfg, nil } func canonicalEnvironment(value string) (string, error) { switch strings.ToLower(strings.TrimSpace(value)) { case "dev", "development": return "development", nil case "prod", "production": return "production", nil case "test": return "test", nil default: return "", fmt.Errorf("unsupported environment %q", value) } } func validateSecret(production bool, field, value string) error { secret := strings.TrimSpace(value) if secret == "" { return fmt.Errorf("%s must not be empty", field) } if production { if len([]byte(secret)) < 32 || estimatedEntropyBits(secret) < 128 || isCommonSecret(secret) { return fmt.Errorf("%s must contain at least 32 bytes and an estimated 128 bits of entropy, without development sentinels, in production", field) } } return nil } func estimatedEntropyBits(value string) float64 { data := []byte(value) counts := make(map[byte]int, len(data)) for _, item := range data { counts[item]++ } length := float64(len(data)) entropyPerByte := 0.0 for _, count := range counts { probability := float64(count) / length entropyPerByte -= probability * math.Log2(probability) } return entropyPerByte * length } func isCommonSecret(value string) bool { normalized := strings.ToLower(strings.TrimSpace(value)) for _, marker := range []string{"change-me", "changeme", "development", "dev-secret", "local-secret", "test-secret", "placeholder"} { if strings.Contains(normalized, marker) { return true } } switch normalized { case "secret", "password", "default", "admin": return true default: return false } }