package config import ( "fmt" "net" "os" "path/filepath" "strconv" "strings" "time" "github.com/joho/godotenv" "gopkg.in/yaml.v3" ) type Config struct { Server ServerConfig `yaml:"server"` Providers ProvidersConfig `yaml:"providers"` Storage StorageConfig `yaml:"storage"` Subagents SubagentsConfig `yaml:"subagents"` Auth AuthConfig `yaml:"auth"` CORS CORSConfig `yaml:"cors"` } type CORSConfig struct { AllowedOrigins []string `yaml:"allowed_origins"` AllowedMethods []string `yaml:"allowed_methods"` AllowedHeaders []string `yaml:"allowed_headers"` } type ServerConfig struct { Host string `yaml:"host"` Port string `yaml:"port"` Timeouts TimeoutsConfig `yaml:"timeouts"` // Legacy fields ReadTimeout time.Duration WriteTimeout time.Duration IdleTimeout time.Duration } type TimeoutsConfig struct { Read string `yaml:"read"` Write string `yaml:"write"` Idle string `yaml:"idle"` } type ProvidersConfig struct { Anthropic AnthropicProviderConfig `yaml:"anthropic"` OpenAI OpenAIProviderConfig `yaml:"openai"` } type AnthropicProviderConfig struct { BaseURL string `yaml:"base_url"` Version string `yaml:"version"` MaxRetries int `yaml:"max_retries"` ResponseHeaderTimeout time.Duration `yaml:"response_header_timeout"` DemoteNonstreaming bool `yaml:"demote_nonstreaming"` } type OpenAIProviderConfig struct { BaseURL string `yaml:"base_url"` APIKey string `yaml:"api_key"` AllowClientAPIKey bool `yaml:"allow_client_api_key"` // Allow clients to provide their own API key ClientAPIKeyHeader string `yaml:"client_api_key_header"` // Header name for client API key (default: x-openai-api-key) } type AuthConfig struct { Enabled bool `yaml:"enabled"` Token string `yaml:"token"` APIKeyHeader string `yaml:"api_key_header"` AllowLocalhostBypass bool `yaml:"allow_localhost_bypass"` DashboardPassword string `yaml:"dashboard_password"` TrustProxy bool `yaml:"trust_proxy"` // Skip bind-address auth check (for Docker / reverse-proxy setups) } type StorageConfig struct { RequestsDir string `yaml:"requests_dir"` DBType string `yaml:"db_type"` DBPath string `yaml:"db_path"` DatabaseURL string `yaml:"database_url"` CaptureRequestBody bool `yaml:"capture_request_body"` CaptureResponseBody bool `yaml:"capture_response_body"` MetadataOnly bool `yaml:"metadata_only"` RetentionDays int `yaml:"retention_days"` RedactedFields []string `yaml:"redacted_fields"` } type SubagentsConfig struct { Enable bool `yaml:"enable"` Mappings map[string]string `yaml:"mappings"` } func Load() (*Config, error) { // Load .env file if it exists // Look for .env file in the project root (one level up from proxy/) envPath := filepath.Join("..", ".env") if err := godotenv.Load(envPath); err != nil { // If .env doesn't exist in parent directory, try current directory if err := godotenv.Load(".env"); err != nil { // .env file is optional, so we just log and continue // This allows the app to work with system environment variables only } } cfg := defaultConfig() if err := loadFirstAvailableConfig(cfg, candidateConfigPaths()); err != nil { return nil, err } // Apply environment variable overrides AFTER loading from file if envPort := os.Getenv("PORT"); envPort != "" { cfg.Server.Port = envPort } if envHost := os.Getenv("SERVER_HOST"); envHost != "" { cfg.Server.Host = envHost } if envTimeout := os.Getenv("READ_TIMEOUT"); envTimeout != "" { cfg.Server.ReadTimeout = getDuration("READ_TIMEOUT", cfg.Server.ReadTimeout) } if envTimeout := os.Getenv("WRITE_TIMEOUT"); envTimeout != "" { cfg.Server.WriteTimeout = getDuration("WRITE_TIMEOUT", cfg.Server.WriteTimeout) } if envTimeout := os.Getenv("IDLE_TIMEOUT"); envTimeout != "" { cfg.Server.IdleTimeout = getDuration("IDLE_TIMEOUT", cfg.Server.IdleTimeout) } // Override Anthropic settings if envURL := os.Getenv("ANTHROPIC_FORWARD_URL"); envURL != "" { cfg.Providers.Anthropic.BaseURL = envURL } if envVersion := os.Getenv("ANTHROPIC_VERSION"); envVersion != "" { cfg.Providers.Anthropic.Version = envVersion } if envRetries := os.Getenv("ANTHROPIC_MAX_RETRIES"); envRetries != "" { cfg.Providers.Anthropic.MaxRetries = getInt("ANTHROPIC_MAX_RETRIES", cfg.Providers.Anthropic.MaxRetries) } if envTimeout := os.Getenv("ANTHROPIC_RESPONSE_HEADER_TIMEOUT"); envTimeout != "" { cfg.Providers.Anthropic.ResponseHeaderTimeout = getDuration("ANTHROPIC_RESPONSE_HEADER_TIMEOUT", cfg.Providers.Anthropic.ResponseHeaderTimeout) } if os.Getenv("ANTHROPIC_DEMOTE_NONSTREAMING") != "" { cfg.Providers.Anthropic.DemoteNonstreaming = envBool("ANTHROPIC_DEMOTE_NONSTREAMING") } // Override OpenAI settings if envURL := os.Getenv("OPENAI_BASE_URL"); envURL != "" { cfg.Providers.OpenAI.BaseURL = envURL } if envKey := os.Getenv("OPENAI_API_KEY"); envKey != "" { cfg.Providers.OpenAI.APIKey = envKey } if os.Getenv("OPENAI_ALLOW_CLIENT_API_KEY") != "" { cfg.Providers.OpenAI.AllowClientAPIKey = envBool("OPENAI_ALLOW_CLIENT_API_KEY") } if envHeader := os.Getenv("OPENAI_CLIENT_API_KEY_HEADER"); envHeader != "" { cfg.Providers.OpenAI.ClientAPIKeyHeader = envHeader } // Override auth settings if os.Getenv("AUTH_ENABLED") != "" { cfg.Auth.Enabled = envBool("AUTH_ENABLED") } if envAuthToken := os.Getenv("AUTH_TOKEN"); envAuthToken != "" { cfg.Auth.Token = envAuthToken } if envAPIKeyHeader := os.Getenv("AUTH_API_KEY_HEADER"); envAPIKeyHeader != "" { cfg.Auth.APIKeyHeader = envAPIKeyHeader } if os.Getenv("AUTH_ALLOW_LOCALHOST_BYPASS") != "" { cfg.Auth.AllowLocalhostBypass = envBool("AUTH_ALLOW_LOCALHOST_BYPASS") } if envDashPass := os.Getenv("DASHBOARD_PASSWORD"); envDashPass != "" { cfg.Auth.DashboardPassword = envDashPass } if os.Getenv("TRUST_PROXY") != "" { cfg.Auth.TrustProxy = envBool("TRUST_PROXY") } // Override storage settings if envDBType := os.Getenv("DB_TYPE"); envDBType != "" { cfg.Storage.DBType = envDBType } if envPath := os.Getenv("DB_PATH"); envPath != "" { cfg.Storage.DBPath = envPath } if envDatabaseURL := os.Getenv("DATABASE_URL"); envDatabaseURL != "" { cfg.Storage.DatabaseURL = envDatabaseURL } if os.Getenv("STORAGE_CAPTURE_REQUEST_BODY") != "" { cfg.Storage.CaptureRequestBody = envBool("STORAGE_CAPTURE_REQUEST_BODY") } if os.Getenv("STORAGE_CAPTURE_RESPONSE_BODY") != "" { cfg.Storage.CaptureResponseBody = envBool("STORAGE_CAPTURE_RESPONSE_BODY") } if os.Getenv("STORAGE_METADATA_ONLY") != "" { cfg.Storage.MetadataOnly = envBool("STORAGE_METADATA_ONLY") } if envRetentionDays := os.Getenv("STORAGE_RETENTION_DAYS"); envRetentionDays != "" { cfg.Storage.RetentionDays = getInt("STORAGE_RETENTION_DAYS", cfg.Storage.RetentionDays) } if envRedacted := os.Getenv("STORAGE_REDACTED_FIELDS"); envRedacted != "" { cfg.Storage.RedactedFields = splitAndTrim(envRedacted) } if cfg.Storage.MetadataOnly { cfg.Storage.CaptureRequestBody = false cfg.Storage.CaptureResponseBody = false } // Override CORS settings (comma-separated values) if envOrigins := os.Getenv("CORS_ALLOWED_ORIGINS"); envOrigins != "" { cfg.CORS.AllowedOrigins = splitAndTrim(envOrigins) } if envMethods := os.Getenv("CORS_ALLOWED_METHODS"); envMethods != "" { cfg.CORS.AllowedMethods = splitAndTrim(envMethods) } if envHeaders := os.Getenv("CORS_ALLOWED_HEADERS"); envHeaders != "" { cfg.CORS.AllowedHeaders = splitAndTrim(envHeaders) } // After loading from file, apply any timeout conversions if needed if cfg.Server.Timeouts.Read != "" { if duration, err := time.ParseDuration(cfg.Server.Timeouts.Read); err == nil { cfg.Server.ReadTimeout = duration } } if cfg.Server.Timeouts.Write != "" { if duration, err := time.ParseDuration(cfg.Server.Timeouts.Write); err == nil { cfg.Server.WriteTimeout = duration } } if cfg.Server.Timeouts.Idle != "" { if duration, err := time.ParseDuration(cfg.Server.Timeouts.Idle); err == nil { cfg.Server.IdleTimeout = duration } } if err := validateSecurity(cfg); err != nil { return nil, err } return cfg, nil } func (c *Config) loadFromFile(path string) error { data, err := os.ReadFile(path) if err != nil { return err } return yaml.Unmarshal(data, c) } func defaultConfig() *Config { return &Config{ Server: ServerConfig{ Host: "0.0.0.0", Port: "3001", ReadTimeout: 600 * time.Second, WriteTimeout: 600 * time.Second, IdleTimeout: 600 * time.Second, }, Providers: ProvidersConfig{ Anthropic: AnthropicProviderConfig{ BaseURL: "https://api.anthropic.com", Version: "2023-06-01", MaxRetries: 3, ResponseHeaderTimeout: 300 * time.Second, }, OpenAI: OpenAIProviderConfig{ BaseURL: "https://api.openai.com", APIKey: "", AllowClientAPIKey: false, ClientAPIKeyHeader: "x-openai-api-key", }, }, Storage: StorageConfig{ DBType: "sqlite", DBPath: "requests.db", CaptureRequestBody: true, CaptureResponseBody: true, MetadataOnly: false, RetentionDays: 0, RedactedFields: []string{ "api_key", "authorization", "token", "password", "secret", "access_token", "refresh_token", "client_secret", }, }, Subagents: SubagentsConfig{ Enable: false, Mappings: make(map[string]string), }, Auth: AuthConfig{ Enabled: false, Token: "", APIKeyHeader: "x-api-key", AllowLocalhostBypass: true, }, CORS: CORSConfig{ AllowedOrigins: []string{"*"}, AllowedMethods: []string{"GET", "POST", "DELETE", "OPTIONS"}, AllowedHeaders: []string{ "Accept", "Authorization", "Content-Type", "Anthropic-Version", "Anthropic-Beta", "X-API-Key", "X-Requested-With", }, }, } } func candidateConfigPaths() []string { paths := []string{ filepath.Join(filepath.Dir(os.Args[0]), "..", "config.yaml"), "config.yaml", "../config.yaml", "../../config.yaml", } seen := make(map[string]struct{}, len(paths)) unique := make([]string, 0, len(paths)) for _, path := range paths { if _, ok := seen[path]; ok { continue } seen[path] = struct{}{} unique = append(unique, path) } return unique } func validateSecurity(cfg *Config) error { if cfg.Server.Host == "" { cfg.Server.Host = "0.0.0.0" } // When behind a reverse proxy (Docker/Traefik), skip the bind-address auth requirement. // The proxy is not directly exposed; the reverse proxy handles access control. if cfg.Auth.TrustProxy { return nil } if !isLoopbackHost(cfg.Server.Host) && !cfg.Auth.Enabled { return fmt.Errorf("refusing to bind to %q without auth enabled; set AUTH_ENABLED=true and AUTH_TOKEN, or TRUST_PROXY=true for reverse-proxy setups", cfg.Server.Host) } if cfg.Auth.Enabled && cfg.Auth.Token == "" && !isLoopbackHost(cfg.Server.Host) { return fmt.Errorf("auth is enabled for public access but AUTH_TOKEN is empty") } return nil } func isLoopbackHost(host string) bool { host = strings.TrimSpace(host) if host == "localhost" { return true } if ip := net.ParseIP(strings.Trim(host, "[]")); ip != nil { return ip.IsLoopback() } return false } func loadFirstAvailableConfig(cfg *Config, paths []string) error { for _, path := range paths { if _, err := os.Stat(path); err != nil { if os.IsNotExist(err) { continue } return fmt.Errorf("failed to stat config file %q: %w", path, err) } if err := cfg.loadFromFile(path); err != nil { return fmt.Errorf("failed to load config file %q: %w", path, err) } return nil } return nil } func envBool(key string) bool { v := strings.ToLower(os.Getenv(key)) return v == "true" || v == "1" || v == "yes" } func getEnv(key, defaultValue string) string { if value := os.Getenv(key); value != "" { return value } return defaultValue } func getDuration(key string, defaultValue time.Duration) time.Duration { value := os.Getenv(key) if value == "" { return defaultValue } duration, err := time.ParseDuration(value) if err != nil { return defaultValue } return duration } func getInt(key string, defaultValue int) int { value := os.Getenv(key) if value == "" { return defaultValue } intValue, err := strconv.Atoi(value) if err != nil { return defaultValue } return intValue } func splitAndTrim(s string) []string { parts := strings.Split(s, ",") result := make([]string, 0, len(parts)) for _, part := range parts { trimmed := strings.TrimSpace(part) if trimmed != "" { result = append(result, trimmed) } } return result }