Files
agent/backend/internal/config/config.go

139 lines
3.7 KiB
Go

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
}
}