// Package config provides configuration loading with environment variable binding and startup validation. // All configuration values are loaded from environment variables with sensible defaults. // Required fields that are missing will cause a fatal error at startup. package config import ( "fmt" "os" "strconv" "strings" "time" ) // Config holds all application configuration. // Fields are populated from environment variables during Load(). type Config struct { // Environment Env string // "development" | "production" | "test" LogLevel string // "debug" | "info" | "warn" | "error" LogFmt string // "text" | "json" // Server Port int Host string GinMode string // "debug" | "release" | "test" // Database DatabaseURL string // Full connection string (takes priority) PostgresHost string PostgresPort int PostgresUser string PostgresPassword string PostgresDB string PostgresSSLMode string // NATS NATSUrl string // Redis RedisHost string RedisPort int // LLM LLMBaseURL string LLMAPIKey string LLMModel string LLMFallbackModel string // Embedding EmbeddingAPIURL string EmbeddingModel string EmbeddingAPIKey string // Voice DashScopeAPIKey string TTSProvider string // "edge-tts" | "espeak" ASRProvider string // "dashscope" | "whisper-local" // Auth JWTSecret string JWTExpiryHours int AdminUsername string AdminPassword string RegistrationOpen bool // Internal Service InternalServiceToken string // CORS AllowedOrigins []string // Background Thinking EnableBackgroundThinking bool ThinkIntervalSec int ThinkSilenceTimeoutSec int // Platform Bridge PlatformBridgeEnabled bool PlatformChannels string // IoT IoTProvider string // "mock" | "homeassistant" | "mqtt" | "none" // IoT Debug Service URL (legacy, kept for v1 compat) IoTServiceURL string // RAG PersonaDir string KnowledgeDir string DataDir string // Search SearXNGURL string // Host Exec HostExecBackend string // "wsl" | "docker" | "direct" | "" // Caddy (production) Domain string ACMEEmail string // Ethend (deprecated, kept for compat) EthendPort int // WebSocket WSMaxConnections int SessionIdleTimeoutMin int // Timezone TZ string } // DefaultConfig returns a Config populated with safe default values. func DefaultConfig() *Config { return &Config{ Env: "development", LogLevel: "info", LogFmt: "text", Port: 8080, Host: "0.0.0.0", GinMode: "debug", PostgresHost: "localhost", PostgresPort: 5432, PostgresUser: "cyrene", PostgresPassword: "cyrene_pass", PostgresDB: "cyrene_ai", PostgresSSLMode: "disable", NATSUrl: "nats://localhost:4222", RedisHost: "localhost", RedisPort: 6379, LLMBaseURL: "https://api.openai.com/v1", LLMModel: "gpt-4o", LLMFallbackModel: "gpt-4o-mini", EmbeddingModel: "text-embedding-3-small", TTSProvider: "edge-tts", ASRProvider: "dashscope", JWTExpiryHours: 720, AdminUsername: "admin", RegistrationOpen: true, EnableBackgroundThinking: true, ThinkIntervalSec: 300, ThinkSilenceTimeoutSec: 120, IoTProvider: "mock", PersonaDir: "./internal/persona", KnowledgeDir: "./data/knowledge", DataDir: "./data", HostExecBackend: "", EthendPort: 9090, WSMaxConnections: 1000, SessionIdleTimeoutMin: 30, TZ: "Asia/Shanghai", } } // DatabaseURL returns the constructed database URL if no explicit DATABASE_URL was set. func (c *Config) GetDatabaseURL() string { if c.DatabaseURL != "" { return c.DatabaseURL } return fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=%s", c.PostgresUser, c.PostgresPassword, c.PostgresHost, c.PostgresPort, c.PostgresDB, c.PostgresSSLMode) } // IsDevelopment returns true when running in development mode. func (c *Config) IsDevelopment() bool { return c.Env == "development" } // IsProduction returns true when running in production mode. func (c *Config) IsProduction() bool { return c.Env == "production" } // validate runs all validation checks on the loaded config. // Returns a list of validation errors. func (c *Config) validate() []string { var errs []string // Required: LLM API Key (only required in production or when not using mock) if c.LLMAPIKey == "" { errs = append(errs, "LLM_API_KEY is required") } // Required: JWT Secret if c.JWTSecret == "" { errs = append(errs, "JWT_SECRET is required") } if len(c.JWTSecret) < 32 { errs = append(errs, "JWT_SECRET must be at least 32 characters") } // Required: Admin Password if c.AdminPassword == "" { errs = append(errs, "ADMIN_PASSWORD is required") } if len(c.AdminPassword) < 8 { errs = append(errs, "ADMIN_PASSWORD must be at least 8 characters") } // Required: Internal Service Token if c.InternalServiceToken == "" && c.IsProduction() { errs = append(errs, "INTERNAL_SERVICE_TOKEN is required in production") } // IoT Provider must be valid validIoTProviders := map[string]bool{"mock": true, "homeassistant": true, "mqtt": true, "none": true} if !validIoTProviders[c.IoTProvider] { errs = append(errs, fmt.Sprintf("IOT_PROVIDER must be one of: mock, homeassistant, mqtt, none (got: %s)", c.IoTProvider)) } return errs } // ValidateOrFatal validates the config and calls log.Fatal if any required fields are missing. func (c *Config) ValidateOrFatal() { errs := c.validate() if len(errs) > 0 { fmt.Fprintf(os.Stderr, "=== Configuration validation errors ===\n") for _, e := range errs { fmt.Fprintf(os.Stderr, " - %s\n", e) } fmt.Fprintf(os.Stderr, "=======================================\n") fmt.Fprintf(os.Stderr, "Fix the above errors and restart.\n") os.Exit(1) } } // --- Helper functions for loading from environment --- func getEnv(key, defaultVal string) string { if v := os.Getenv(key); v != "" { return v } return defaultVal } func getEnvInt(key string, defaultVal int) int { v := os.Getenv(key) if v == "" { return defaultVal } i, err := strconv.Atoi(v) if err != nil { fmt.Fprintf(os.Stderr, "WARNING: invalid integer for %s=%q, using default %d\n", key, v, defaultVal) return defaultVal } return i } func getEnvBool(key string, defaultVal bool) bool { v := os.Getenv(key) if v == "" { return defaultVal } switch strings.ToLower(v) { case "1", "true", "yes", "on": return true case "0", "false", "no", "off": return false default: fmt.Fprintf(os.Stderr, "WARNING: invalid boolean for %s=%q, using default %v\n", key, v, defaultVal) return defaultVal } } func getEnvSlice(key string, defaultVal []string) []string { v := os.Getenv(key) if v == "" { return defaultVal } parts := strings.Split(v, ",") result := make([]string, 0, len(parts)) for _, p := range parts { p = strings.TrimSpace(p) if p != "" { result = append(result, p) } } if len(result) == 0 { return defaultVal } return result } func getEnvDuration(key string, defaultVal time.Duration) time.Duration { v := os.Getenv(key) if v == "" { return defaultVal } d, err := time.ParseDuration(v) if err != nil { fmt.Fprintf(os.Stderr, "WARNING: invalid duration for %s=%q, using default %v\n", key, v, defaultVal) return defaultVal } return d }