package config import ( "errors" "fmt" "net/url" "os" "strconv" "strings" "time" ) type Config struct { Environment string Server Server Database Database Redis Redis Security Security Auth Auth Credentials Credentials Upstream Upstream Audit Audit Outbox Outbox RuntimeData RuntimeData Shadow Shadow ObjectStorage ObjectStorage } type Server struct { Address string ReadHeaderTimeout time.Duration IdleTimeout time.Duration ShutdownTimeout time.Duration MaxBodyBytes int64 } type Database struct { URL string MaxConns int32 MinConns int32 } type Redis struct { CriticalURL string CacheURL string } type Security struct { BootstrapAPIKey string BootstrapAPIKeyEnabled bool } type Auth struct { SessionTTL time.Duration TOTPChallengeTTL time.Duration MaxFailures int LockDuration time.Duration LoginRateLimitMax int // 单 IP 滑动窗口内的最大登录尝试次数 LoginRateLimitWindow time.Duration // 登录限流滑动窗口 } type Credentials struct { MasterKey string KEKVersion int KEKKeyring string AllowPrivateProviderURL bool AllowPrivateToolURL bool AllowPrivateWebhookURL bool ProviderRefreshInterval time.Duration } type Upstream struct { BaseURL string APIKey string FallbackEnabled bool ResponseHeaderTimeout time.Duration MaxRetries int RetryBackoff time.Duration CircuitThreshold int CircuitOpenDuration time.Duration } type Audit struct { QueueSize int BatchSize int FlushInterval time.Duration Retention time.Duration UsageRetention time.Duration PartitionMonthsAhead int MaintenanceInterval time.Duration } type Outbox struct { Stream string BatchSize int PollInterval time.Duration Lease time.Duration MaxAttempts int MaxBackoff time.Duration StreamMaxLength int64 MarkerTTL time.Duration } type RuntimeData struct { ContentPolicyRefreshInterval time.Duration PricingRefreshInterval time.Duration } type Shadow struct { BaseURL string APIKey string SampleRate float64 Timeout time.Duration MaxBodyBytes int64 MaxConcurrent int } type ObjectStorage struct { Endpoint string AccessKeyID string SecretAccessKey string Bucket string Region string UseSSL bool MaxFileBytes int64 } func Load() (Config, error) { cfg := Config{ Environment: env("APP_ENV", "local"), Server: Server{ Address: env("HTTP_ADDR", "127.0.0.1:8080"), ReadHeaderTimeout: duration("HTTP_READ_HEADER_TIMEOUT", 5*time.Second), IdleTimeout: duration("HTTP_IDLE_TIMEOUT", 120*time.Second), ShutdownTimeout: duration("HTTP_SHUTDOWN_TIMEOUT", 20*time.Second), MaxBodyBytes: int64Value("HTTP_MAX_BODY_BYTES", 32<<20), }, Database: Database{ URL: strings.TrimSpace(os.Getenv("DATABASE_URL")), MaxConns: int32(intValue("DATABASE_MAX_CONNS", 40)), MinConns: int32(intValue("DATABASE_MIN_CONNS", 4)), }, Redis: Redis{ CriticalURL: strings.TrimSpace(os.Getenv("REDIS_CRITICAL_URL")), CacheURL: strings.TrimSpace(os.Getenv("REDIS_CACHE_URL")), }, Security: Security{ BootstrapAPIKey: strings.TrimSpace(os.Getenv("GATEWAY_BOOTSTRAP_API_KEY")), BootstrapAPIKeyEnabled: boolValue("GATEWAY_BOOTSTRAP_API_KEY_ENABLED", false), }, Auth: Auth{ SessionTTL: duration("AUTH_SESSION_TTL", 12*time.Hour), TOTPChallengeTTL: duration("AUTH_TOTP_CHALLENGE_TTL", 5*time.Minute), MaxFailures: intValue("LOGIN_MAX_FAILURES", 5), LockDuration: duration("LOGIN_LOCK_DURATION", 15*time.Minute), LoginRateLimitMax: intValue("LOGIN_RATE_LIMIT_MAX", 30), LoginRateLimitWindow: duration("LOGIN_RATE_LIMIT_WINDOW", 5*time.Minute), }, Credentials: Credentials{ MasterKey: strings.TrimSpace(os.Getenv("CREDENTIAL_MASTER_KEY")), KEKVersion: intValue("CREDENTIAL_KEK_VERSION", 1), KEKKeyring: strings.TrimSpace(os.Getenv("CREDENTIAL_KEK_KEYRING")), AllowPrivateProviderURL: boolValue("ALLOW_PRIVATE_PROVIDER_URLS", false), AllowPrivateToolURL: boolValue("ALLOW_PRIVATE_TOOL_URLS", false), AllowPrivateWebhookURL: boolValue("ALLOW_PRIVATE_WEBHOOK_URLS", false), ProviderRefreshInterval: duration("PROVIDER_REFRESH_INTERVAL", 5*time.Second), }, Upstream: Upstream{ BaseURL: strings.TrimRight(env("UPSTREAM_BASE_URL", "https://api.openai.com"), "/"), APIKey: strings.TrimSpace(os.Getenv("UPSTREAM_API_KEY")), FallbackEnabled: boolValue("UPSTREAM_FALLBACK_ENABLED", true), ResponseHeaderTimeout: duration("UPSTREAM_RESPONSE_HEADER_TIMEOUT", 60*time.Second), MaxRetries: intValue("UPSTREAM_MAX_RETRIES", 2), RetryBackoff: duration("UPSTREAM_RETRY_BACKOFF", 50*time.Millisecond), CircuitThreshold: intValue("UPSTREAM_CIRCUIT_THRESHOLD", 5), CircuitOpenDuration: duration("UPSTREAM_CIRCUIT_OPEN_DURATION", 30*time.Second), }, Audit: Audit{ QueueSize: intValue("AUDIT_QUEUE_SIZE", 4096), BatchSize: intValue("AUDIT_BATCH_SIZE", 200), FlushInterval: duration("AUDIT_FLUSH_INTERVAL", time.Second), Retention: duration("AUDIT_RETENTION", 90*24*time.Hour), UsageRetention: duration("USAGE_RETENTION", 730*24*time.Hour), PartitionMonthsAhead: intValue("AUDIT_PARTITION_MONTHS_AHEAD", 3), MaintenanceInterval: duration("AUDIT_MAINTENANCE_INTERVAL", 6*time.Hour), }, Outbox: Outbox{ Stream: env("OUTBOX_STREAM", "gateway:{outbox}:events"), BatchSize: intValue("OUTBOX_BATCH_SIZE", 100), PollInterval: duration("OUTBOX_POLL_INTERVAL", 500*time.Millisecond), Lease: duration("OUTBOX_LEASE", 30*time.Second), MaxAttempts: intValue("OUTBOX_MAX_ATTEMPTS", 10), MaxBackoff: duration("OUTBOX_MAX_BACKOFF", 5*time.Minute), StreamMaxLength: int64Value("OUTBOX_STREAM_MAX_LENGTH", 100_000), MarkerTTL: duration("OUTBOX_MARKER_TTL", 30*24*time.Hour), }, RuntimeData: RuntimeData{ ContentPolicyRefreshInterval: duration("CONTENT_POLICY_REFRESH_INTERVAL", 30*time.Second), PricingRefreshInterval: duration("PRICING_REFRESH_INTERVAL", 30*time.Second), }, Shadow: Shadow{ BaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("SHADOW_BASE_URL")), "/"), APIKey: strings.TrimSpace(os.Getenv("SHADOW_API_KEY")), SampleRate: floatValue("SHADOW_SAMPLE_RATE", 0), Timeout: duration("SHADOW_TIMEOUT", 20*time.Second), MaxBodyBytes: int64Value("SHADOW_MAX_BODY_BYTES", 2<<20), MaxConcurrent: intValue("SHADOW_MAX_CONCURRENT", 16), }, ObjectStorage: ObjectStorage{ Endpoint: strings.TrimRight(env("S3_ENDPOINT", "http://minio:9000"), "/"), AccessKeyID: env("S3_ACCESS_KEY_ID", "gateway"), SecretAccessKey: env("S3_SECRET_ACCESS_KEY", "gateway-secret"), Bucket: env("S3_BUCKET", "gateway-files"), Region: env("S3_REGION", "us-east-1"), UseSSL: boolValue("S3_USE_SSL", false), MaxFileBytes: int64Value("S3_MAX_FILE_BYTES", 128<<20), }, } return cfg, cfg.Validate() } func (c Config) Validate() error { var errs []error if c.Server.MaxBodyBytes <= 0 { errs = append(errs, errors.New("HTTP_MAX_BODY_BYTES must be positive")) } if c.Database.MinConns < 0 || c.Database.MaxConns < 1 || c.Database.MinConns > c.Database.MaxConns { errs = append(errs, errors.New("database pool sizes are invalid")) } if c.Auth.SessionTTL < 5*time.Minute || c.Auth.TOTPChallengeTTL < time.Minute || c.Auth.TOTPChallengeTTL > 15*time.Minute || c.Auth.MaxFailures < 1 || c.Auth.LockDuration < time.Minute { errs = append(errs, errors.New("authentication limits are invalid")) } if c.Auth.LoginRateLimitMax < 1 || c.Auth.LoginRateLimitWindow < time.Second { errs = append(errs, errors.New("login rate limit is invalid")) } if c.Credentials.KEKVersion < 1 { errs = append(errs, errors.New("CREDENTIAL_KEK_VERSION must be positive")) } if c.Credentials.ProviderRefreshInterval < time.Second || c.Credentials.ProviderRefreshInterval > time.Minute { errs = append(errs, errors.New("PROVIDER_REFRESH_INTERVAL must be between 1s and 1m")) } if err := validateHTTPURL(c.Upstream.BaseURL); err != nil { errs = append(errs, fmt.Errorf("UPSTREAM_BASE_URL: %w", err)) } if c.Upstream.ResponseHeaderTimeout < time.Second || c.Upstream.ResponseHeaderTimeout > 10*time.Minute || c.Upstream.MaxRetries < 0 || c.Upstream.MaxRetries > 5 || c.Upstream.RetryBackoff < 0 || c.Upstream.RetryBackoff > 5*time.Second || c.Upstream.CircuitThreshold < 1 || c.Upstream.CircuitThreshold > 100 || c.Upstream.CircuitOpenDuration < time.Second || c.Upstream.CircuitOpenDuration > 10*time.Minute { errs = append(errs, errors.New("upstream resilience settings are invalid")) } if c.Audit.QueueSize < 100 || c.Audit.QueueSize > 1_000_000 || c.Audit.BatchSize < 1 || c.Audit.BatchSize > c.Audit.QueueSize || c.Audit.FlushInterval < 100*time.Millisecond || c.Audit.FlushInterval > time.Minute { errs = append(errs, errors.New("audit buffering settings are invalid")) } if c.Audit.Retention < 24*time.Hour || c.Audit.Retention > 10*365*24*time.Hour || c.Audit.UsageRetention < c.Audit.Retention || c.Audit.UsageRetention > 10*365*24*time.Hour || c.Audit.PartitionMonthsAhead < 1 || c.Audit.PartitionMonthsAhead > 24 || c.Audit.MaintenanceInterval < time.Hour || c.Audit.MaintenanceInterval > 7*24*time.Hour { errs = append(errs, errors.New("audit retention settings are invalid")) } if !strings.Contains(c.Outbox.Stream, "{outbox}") || c.Outbox.BatchSize < 1 || c.Outbox.BatchSize > 1000 || c.Outbox.PollInterval < 50*time.Millisecond || c.Outbox.PollInterval > time.Minute || c.Outbox.Lease < 5*time.Second || c.Outbox.Lease > 10*time.Minute || c.Outbox.MaxAttempts < 1 || c.Outbox.MaxAttempts > 100 || c.Outbox.MaxBackoff < time.Second || c.Outbox.MaxBackoff > time.Hour || c.Outbox.StreamMaxLength < 1000 || c.Outbox.StreamMaxLength > 100_000_000 || c.Outbox.MarkerTTL < 24*time.Hour || c.Outbox.MarkerTTL > 365*24*time.Hour { errs = append(errs, errors.New("outbox delivery settings are invalid")) } if c.RuntimeData.ContentPolicyRefreshInterval < time.Second || c.RuntimeData.ContentPolicyRefreshInterval > 10*time.Minute || c.RuntimeData.PricingRefreshInterval < time.Second || c.RuntimeData.PricingRefreshInterval > 10*time.Minute { errs = append(errs, errors.New("runtime data refresh settings must be between 1s and 10m")) } if c.Shadow.SampleRate < 0 || c.Shadow.SampleRate > 1 || c.Shadow.Timeout < time.Second || c.Shadow.Timeout > 2*time.Minute || c.Shadow.MaxBodyBytes < 1024 || c.Shadow.MaxBodyBytes > 32<<20 || c.Shadow.MaxConcurrent < 1 || c.Shadow.MaxConcurrent > 1000 { errs = append(errs, errors.New("shadow traffic settings are invalid")) } if c.Shadow.BaseURL != "" { if err := validateHTTPURL(c.Shadow.BaseURL); err != nil { errs = append(errs, fmt.Errorf("SHADOW_BASE_URL: %w", err)) } if c.Shadow.APIKey == "" { errs = append(errs, errors.New("SHADOW_API_KEY is required when SHADOW_BASE_URL is set")) } } if err := validateHTTPURL(c.ObjectStorage.Endpoint); err != nil { errs = append(errs, fmt.Errorf("S3_ENDPOINT: %w", err)) } else if endpointURL, parseErr := url.Parse(c.ObjectStorage.Endpoint); parseErr == nil && endpointURL.Path != "" { // minio-go 的 Endpoint 不接受带路径的完整 URL(报 "fully qualified paths")。 errs = append(errs, errors.New("S3_ENDPOINT must not contain a path")) } if c.ObjectStorage.AccessKeyID == "" || c.ObjectStorage.SecretAccessKey == "" { errs = append(errs, errors.New("S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY are required")) } if c.ObjectStorage.Bucket == "" || len(c.ObjectStorage.Bucket) > 63 { errs = append(errs, errors.New("S3_BUCKET must be a non-empty bucket name of at most 63 characters")) } if c.ObjectStorage.MaxFileBytes < 1<<20 || c.ObjectStorage.MaxFileBytes > 512<<20 { errs = append(errs, errors.New("S3_MAX_FILE_BYTES must be between 1 MiB and 512 MiB")) } return errors.Join(errs...) } func (c Config) ValidateRuntime() error { var errs []error if strings.EqualFold(c.Environment, "production") { if c.Database.URL == "" { errs = append(errs, errors.New("DATABASE_URL is required in production")) } if c.Redis.CriticalURL == "" { errs = append(errs, errors.New("REDIS_CRITICAL_URL is required in production")) } if c.Security.BootstrapAPIKeyEnabled { if len(c.Security.BootstrapAPIKey) < 32 { errs = append(errs, errors.New("GATEWAY_BOOTSTRAP_API_KEY must contain at least 32 characters in production")) } // Reject the documented placeholder and other obviously weak values // so an operator cannot accidentally deploy with the example secret. switch strings.ToLower(c.Security.BootstrapAPIKey) { case "change-me-in-production", "local-development-key", "changeme", "change-me", "password", "secret": errs = append(errs, errors.New("GATEWAY_BOOTSTRAP_API_KEY is set to a known weak default; generate a strong random value")) } } if c.Upstream.FallbackEnabled && c.Upstream.APIKey == "" { errs = append(errs, errors.New("UPSTREAM_API_KEY is required in production")) } if c.Credentials.MasterKey == "" { errs = append(errs, errors.New("CREDENTIAL_MASTER_KEY is required in production")) } } return errors.Join(errs...) } func validateHTTPURL(raw string) error { u, err := url.Parse(raw) if err != nil { return err } if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil { return errors.New("must be an absolute http(s) URL without user info") } return nil } func env(key, fallback string) string { if value := strings.TrimSpace(os.Getenv(key)); value != "" { return value } return fallback } func duration(key string, fallback time.Duration) time.Duration { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return fallback } parsed, err := time.ParseDuration(value) if err != nil { return fallback } return parsed } func intValue(key string, fallback int) int { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return fallback } parsed, err := strconv.Atoi(value) if err != nil { return fallback } return parsed } func int64Value(key string, fallback int64) int64 { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return fallback } parsed, err := strconv.ParseInt(value, 10, 64) if err != nil { return fallback } return parsed } func floatValue(key string, fallback float64) float64 { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return fallback } parsed, err := strconv.ParseFloat(value, 64) if err != nil { return fallback } return parsed } func boolValue(key string, fallback bool) bool { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return fallback } parsed, err := strconv.ParseBool(value) if err != nil { return fallback } return parsed }