claude-code-proxy/proxy/internal/config/config.go

434 lines
12 KiB
Go

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"`
Anthropic AnthropicConfig
}
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"`
}
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"`
}
type AnthropicConfig struct {
BaseURL string
Version string
MaxRetries int
}
type StorageConfig struct {
RequestsDir string `yaml:"requests_dir"`
DBPath string `yaml:"db_path"`
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)
}
// 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 envAllow := os.Getenv("OPENAI_ALLOW_CLIENT_API_KEY"); envAllow != "" {
cfg.Providers.OpenAI.AllowClientAPIKey = envAllow == "true" || envAllow == "1"
}
if envHeader := os.Getenv("OPENAI_CLIENT_API_KEY_HEADER"); envHeader != "" {
cfg.Providers.OpenAI.ClientAPIKeyHeader = envHeader
}
// Override auth settings
if envAuthEnabled := os.Getenv("AUTH_ENABLED"); envAuthEnabled != "" {
cfg.Auth.Enabled = envAuthEnabled == "true" || envAuthEnabled == "1"
}
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 envLocalBypass := os.Getenv("AUTH_ALLOW_LOCALHOST_BYPASS"); envLocalBypass != "" {
cfg.Auth.AllowLocalhostBypass = envLocalBypass == "true" || envLocalBypass == "1"
}
// Override storage settings
if envPath := os.Getenv("DB_PATH"); envPath != "" {
cfg.Storage.DBPath = envPath
}
if envCaptureReq := os.Getenv("STORAGE_CAPTURE_REQUEST_BODY"); envCaptureReq != "" {
cfg.Storage.CaptureRequestBody = envCaptureReq == "true" || envCaptureReq == "1"
}
if envCaptureResp := os.Getenv("STORAGE_CAPTURE_RESPONSE_BODY"); envCaptureResp != "" {
cfg.Storage.CaptureResponseBody = envCaptureResp == "true" || envCaptureResp == "1"
}
if envMetadataOnly := os.Getenv("STORAGE_METADATA_ONLY"); envMetadataOnly != "" {
cfg.Storage.MetadataOnly = envMetadataOnly == "true" || envMetadataOnly == "1"
}
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)
}
// Sync legacy Anthropic config
cfg.Anthropic = AnthropicConfig{
BaseURL: cfg.Providers.Anthropic.BaseURL,
Version: cfg.Providers.Anthropic.Version,
MaxRetries: cfg.Providers.Anthropic.MaxRetries,
}
// 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
}
}
// Sync legacy Anthropic config with new structure
cfg.Anthropic = AnthropicConfig{
BaseURL: cfg.Providers.Anthropic.BaseURL,
Version: cfg.Providers.Anthropic.Version,
MaxRetries: cfg.Providers.Anthropic.MaxRetries,
}
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: "127.0.0.1",
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,
},
OpenAI: OpenAIProviderConfig{
BaseURL: "https://api.openai.com",
APIKey: "",
AllowClientAPIKey: false,
ClientAPIKeyHeader: "x-openai-api-key",
},
},
Storage: StorageConfig{
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{
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://localhost:5173",
"http://127.0.0.1:5173",
},
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 = "127.0.0.1"
}
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 for public access", 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 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
}