package config import ( "errors" "fmt" "net/netip" "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 Embeddings Embeddings Inbox Inbox Scheduler Scheduler } 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 // 登录限流滑动窗口 TrustedProxies []netip.Prefix // 可信反向代理网段;仅来自这些对端的 X-Forwarded-For 被采信 } 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 TraceRetention 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 } // Embeddings 配置本地 Ollama 向量化(默认 bge-m3)。Enabled 为 false 时网关不构造 // OllamaEmbedder,知识库退回纯 FTS 检索,AddKnowledgeDocument 不生成 embedding。 type Embeddings struct { Enabled bool BaseURL string Model string Dim int BatchSize int Timeout time.Duration } // Inbox 配置站内消息(M8 P4)。Channel 是通知 worker 落库后 PUBLISH 的 Redis 频道, // 供未来实时推送订阅;未读数以 PostgreSQL 为权威源,不依赖 Redis。 type Inbox struct { Channel string } type Scheduler struct { GatewayBaseURL string PollInterval time.Duration ExecutionTimeout time.Duration BatchSize int MaxAttempts int } 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), TrustedProxies: parsePrefixList(env("TRUSTED_PROXIES", "127.0.0.0/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7")), }, 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), TraceRetention: duration("TRACE_RETENTION", 90*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), }, Embeddings: Embeddings{ Enabled: boolValue("EMBEDDINGS_ENABLED", true), BaseURL: strings.TrimRight(env("OLLAMA_BASE_URL", "http://ollama:11434"), "/"), Model: env("EMBEDDING_MODEL", "bge-m3"), Dim: intValue("EMBEDDING_DIM", 1024), BatchSize: intValue("EMBEDDING_BATCH_SIZE", 64), Timeout: duration("EMBEDDING_TIMEOUT", 120*time.Second), }, Inbox: Inbox{ Channel: env("INBOX_CHANNEL", "gateway:inbox:events"), }, Scheduler: Scheduler{ GatewayBaseURL: strings.TrimRight(env("SCHEDULER_GATEWAY_BASE_URL", "http://gateway-api:8080"), "/"), PollInterval: duration("SCHEDULER_POLL_INTERVAL", 5*time.Second), ExecutionTimeout: duration("SCHEDULER_EXECUTION_TIMEOUT", 5*time.Minute), BatchSize: intValue("SCHEDULER_BATCH_SIZE", 10), MaxAttempts: intValue("SCHEDULER_MAX_ATTEMPTS", 3), }, } 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.SessionTTL > 7*24*time.Hour || 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.TraceRetention < 24*time.Hour || c.Audit.TraceRetention > 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")) } if c.Embeddings.Enabled { if err := validateHTTPURL(c.Embeddings.BaseURL); err != nil { errs = append(errs, fmt.Errorf("OLLAMA_BASE_URL: %w", err)) } if c.Embeddings.Model == "" { errs = append(errs, errors.New("EMBEDDING_MODEL is required when embeddings are enabled")) } if c.Embeddings.Dim < 128 || c.Embeddings.Dim > 8192 { errs = append(errs, errors.New("EMBEDDING_DIM must be between 128 and 8192")) } if c.Embeddings.Dim != 1024 { // 知识库 embedding 列固定为 vector(1024);维度不符会让入库向量报错。 errs = append(errs, errors.New("EMBEDDING_DIM must be 1024 to match the vector(1024) column")) } if c.Embeddings.BatchSize < 1 || c.Embeddings.BatchSize > 512 { errs = append(errs, errors.New("EMBEDDING_BATCH_SIZE must be between 1 and 512")) } if c.Embeddings.Timeout < time.Second || c.Embeddings.Timeout > 30*time.Minute { errs = append(errs, errors.New("EMBEDDING_TIMEOUT must be between 1s and 30m")) } } if err := validateHTTPURL(c.Scheduler.GatewayBaseURL); err != nil { errs = append(errs, fmt.Errorf("SCHEDULER_GATEWAY_BASE_URL: %w", err)) } if c.Scheduler.PollInterval < time.Second || c.Scheduler.PollInterval > time.Minute || c.Scheduler.ExecutionTimeout < time.Minute || c.Scheduler.ExecutionTimeout > time.Hour || c.Scheduler.BatchSize < 1 || c.Scheduler.BatchSize > 100 || c.Scheduler.MaxAttempts < 1 || c.Scheduler.MaxAttempts > 10 { errs = append(errs, errors.New("scheduler settings are invalid")) } 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")) } } // 拒绝已知弱默认密钥(所有环境,含本地 compose):deploy/docker-compose.yml // 曾把全零密钥作为默认值,凡是用该值加密的 Provider 凭据/TOTP 密钥/ // Webhook 签名密钥,任何拿到仓库的人都能解密。 if c.Credentials.MasterKey != "" { switch strings.ToLower(strings.TrimSpace(c.Credentials.MasterKey)) { case "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa=", "change-me", "changeme", "password", "secret": errs = append(errs, errors.New("CREDENTIAL_MASTER_KEY is set to a known weak default; generate a strong random key with: openssl rand -base64 32")) } } 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 } // parsePrefixList 解析逗号分隔的 IP/CIDR 列表;非法项跳过并返回 nil 表示不信任任何代理。 func parsePrefixList(raw string) []netip.Prefix { var prefixes []netip.Prefix for _, part := range strings.Split(raw, ",") { part = strings.TrimSpace(part) if part == "" { continue } prefix, err := netip.ParsePrefix(part) if err != nil { if addr, addrErr := netip.ParseAddr(part); addrErr == nil { prefix = netip.PrefixFrom(addr, addr.BitLen()) } else { continue } } prefixes = append(prefixes, prefix.Masked()) } return prefixes } 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 }