Files
ai-gateway-go/internal/platform/config/config.go
T
superidou 9501751792 0.10.1: 安全与业务逻辑加固、新品牌与部署加固
三轮审查修复(60+ 项),相对远端 main(b536672)的关键变更:
- 安全: 数据面 SSRF 拨号防护(防 DNS rebinding)/上游凭据剥离/登录防枚举
  与锁定态统一/可信代理(X-Forwarded-For)限流加固/会话版本失效机制/
  撤销即时传播/弱密钥拒绝启动/脱敏字节级重写(保签名契约)
- 业务逻辑: 裸 body 上传 panic/bootstrap 审计管线卡死/定价通配符优先级/
  全局工具可见性/调度器停机补跑/TOTP 挑战令牌消费顺序/熔断探针语义/
  >4MB 响应 token 计量/管理员重置密码作废会话 等
- 前端: 新 logo(语枢 AI 网关主题)/Provider 凭据异常警示/删除入口/
  后端错误消息透传/localStorage 敏感数据收敛
- 部署: CREDENTIAL_MASTER_KEY 持久化与弱值拒绝/Provider DELETE 接口/
  nginx 安全头/worker 内存限制
- 新增迁移 000029(key_hash 索引)/000030(usage_daily 币种维度)
2026-08-13 10:50:51 +08:00

492 lines
20 KiB
Go

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
}