Files
lijiaoqiao/gateway/internal/config/config.go
Your Name 1fec3e981d feat(ci): 实现 Phase 1/2 严格退出标准的所有代码实现
Phase 1 Criterion 4: contract tests 场景清单 → backend-verify.sh --phase1-contract-gate(含四个场景:合法token全链路、吊销拒绝、scope不足拒绝、runtime快速失败),repo_integrity_check.sh 集成调用

Phase 2 Criterion 1: manifest.json 系统(lib/manifest_lib.sh + staging_release_pipeline.sh),run_id 作为硬门禁,manifest_hard_gate_run_id() 验证非空

Phase 2 Criterion 2: superpowers_stage_validate.sh exit 1 条件从 NO_GO 扩展到 CONDITIONAL_GO,staging 硬门禁不再放行条件通过

Phase 2 Criterion 3: DEFERRED 语义修正,CONDITIONAL_GO 不再出现在复审结论选项中;CONDITIONAL_GO 在 pipeline 中强制 exit 1

Phase 2 Criterion 5: cross_service_smoke.sh 从 DESIGN_ONLY 变为可执行(exit 0=PASS/1=FAIL/2=SKIP_LOCAL_PLACEHOLDER),纳入 staging_release_pipeline.sh STEP-03

Phase 2 Criterion 4: 配置分离(已之前落地,本次确认)

环境问题记录: docs/plans/2026-04-21-environmental-issues-log.md
- P3-A: HTTP timeout + cache eviction(需要真实 staging env + env var 热加载支持)
- P3-B/C: /metrics 端点(需要 Prometheus scrape 配置 + 运维介入)
- P3-D: graceful shutdown(需要 staging 流量压测验证)
2026-04-21 12:14:50 +08:00

372 lines
9.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package config
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"os"
"strconv"
"strings"
"time"
)
const defaultEncryptionKey = "default-key-32-bytes-long!!!!!!!"
// Config 网关配置
type Config struct {
Server ServerConfig
Database DatabaseConfig
Redis RedisConfig
Auth AuthConfig
Router RouterConfig
RateLimit RateLimitConfig
Alert AlertConfig
Providers []ProviderConfig
}
// ServerConfig 服务配置
type ServerConfig struct {
Host string
Port int
ReadTimeout time.Duration
WriteTimeout time.Duration
IdleTimeout time.Duration
}
// AuthConfig 鉴权运行时配置
type AuthConfig struct {
Env string
TokenRuntimeMode string
TokenRuntimeURL string
TrustedProxies []string // 可信的代理IP列表用于IP伪造防护
CORSAllowOrigins []string // 允许的CORS来源为空则使用默认通配符
// P3-A design-only env var draft for remote runtime hardening:
// - GATEWAY_TOKEN_RUNTIME_HTTP_TIMEOUT
// - GATEWAY_TOKEN_RUNTIME_DIAL_TIMEOUT
// - GATEWAY_TOKEN_RUNTIME_IDLE_CONN_TIMEOUT
// - GATEWAY_TOKEN_RUNTIME_MAX_IDLE_CONNS_PER_HOST
// - GATEWAY_TOKEN_RUNTIME_CACHE_ACTIVE_TTL
// - GATEWAY_TOKEN_RUNTIME_CACHE_EXPIRED_TTL
// - GATEWAY_TOKEN_RUNTIME_CACHE_REVOKED_TTL
// - GATEWAY_TOKEN_RUNTIME_CACHE_MAX_ENTRIES
}
// DatabaseConfig 数据库配置
type DatabaseConfig struct {
Host string
Port int
User string
Password string // 兼容旧版本,仍可直接使用明文密码(不推荐)
EncryptedPassword string // 加密后的密码优先级高于Password字段
Database string
MaxConns int
}
// GetPassword 返回解密后的数据库密码
// 优先使用EncryptedPassword如果为空则返回Password字段兼容旧版本
func (c *DatabaseConfig) GetPassword() string {
if c.EncryptedPassword != "" {
decrypted, err := decryptPassword(c.EncryptedPassword)
if err != nil {
// 解密失败时返回原始加密字符串,让后续逻辑处理错误
return c.EncryptedPassword
}
return decrypted
}
return c.Password
}
// RedisConfig Redis配置
type RedisConfig struct {
Host string
Port int
Password string // 兼容旧版本
EncryptedPassword string // 加密后的密码
DB int
PoolSize int
}
// GetPassword 返回解密后的Redis密码
func (c *RedisConfig) GetPassword() string {
if c.EncryptedPassword != "" {
decrypted, err := decryptPassword(c.EncryptedPassword)
if err != nil {
return c.EncryptedPassword
}
return decrypted
}
return c.Password
}
// RouterConfig 路由配置
type RouterConfig struct {
Strategy string // "latency", "cost", "availability", "weighted"
Timeout time.Duration
MaxRetries int
RetryDelay time.Duration
HealthCheckInterval time.Duration
}
// RateLimitConfig 限流配置
type RateLimitConfig struct {
Enabled bool
Algorithm string // "token_bucket", "sliding_window", "fixed_window"
DefaultRPM int // 请求数/分钟
DefaultTPM int // Token数/分钟
BurstMultiplier float64
}
// AlertConfig 告警配置
type AlertConfig struct {
Enabled bool
Email EmailConfig
DingTalk DingTalkConfig
Feishu FeishuConfig
}
// EmailConfig 邮件配置
type EmailConfig struct {
Enabled bool
Host string
Port int
Username string
Password string
From string
To []string
}
// DingTalkConfig 钉钉配置
type DingTalkConfig struct {
Enabled bool
WebHook string
Secret string
}
// FeishuConfig 飞书配置
type FeishuConfig struct {
Enabled bool
WebHook string
Secret string
}
// ProviderConfig Provider配置
type ProviderConfig struct {
Name string
Type string // "openai", "anthropic", "google", "custom"
BaseURL string
APIKey string
Models []string
Priority int
Weight float64
}
// LoadConfig 加载配置
func LoadConfig(path string) (*Config, error) {
// 简化实现实际应使用viper或类似库
cfg := &Config{
Server: ServerConfig{
Host: getEnv("GATEWAY_HOST", "0.0.0.0"),
Port: getEnvInt("GATEWAY_PORT", 8080),
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
},
Auth: AuthConfig{
Env: NormalizeEnv(getEnv("GATEWAY_ENV", "dev")),
TokenRuntimeMode: strings.ToLower(getEnv("GATEWAY_TOKEN_RUNTIME_MODE", "inmemory")),
TokenRuntimeURL: strings.TrimSpace(getEnv("GATEWAY_TOKEN_RUNTIME_URL", "")),
},
Router: RouterConfig{
Strategy: "latency",
Timeout: 30 * time.Second,
MaxRetries: 3,
RetryDelay: 1 * time.Second,
HealthCheckInterval: 10 * time.Second,
},
RateLimit: RateLimitConfig{
Enabled: true,
Algorithm: "token_bucket",
DefaultRPM: 60,
DefaultTPM: 60000,
BurstMultiplier: 1.5,
},
Alert: AlertConfig{
Enabled: true,
Email: EmailConfig{
Enabled: false,
Host: getEnv("SMTP_HOST", "smtp.example.com"),
Port: 587,
},
DingTalk: DingTalkConfig{
Enabled: getEnv("DINGTALK_ENABLED", "false") == "true",
WebHook: getEnv("DINGTALK_WEBHOOK", ""),
Secret: getEnv("DINGTALK_SECRET", ""),
},
Feishu: FeishuConfig{
Enabled: getEnv("FEISHU_ENABLED", "false") == "true",
WebHook: getEnv("FEISHU_WEBHOOK", ""),
Secret: getEnv("FEISHU_SECRET", ""),
},
},
Providers: []ProviderConfig{
{
Name: "openai",
Type: "openai",
BaseURL: strings.TrimSpace(getEnv("OPENAI_BASE_URL", "https://api.openai.com")),
APIKey: getEnv("OPENAI_API_KEY", ""),
Models: splitCSV(getEnv("OPENAI_MODELS", "gpt-4,gpt-3.5-turbo")),
Priority: 1,
Weight: 1.0,
},
},
}
if err := ValidateAuthConfig(cfg.Auth); err != nil {
return nil, err
}
return cfg, nil
}
// NormalizeEnv 将兼容别名统一折叠到仓库内的唯一环境枚举。
// Phase P2-C 之后,代码与文档内部只保留 dev/staging/prod。
func NormalizeEnv(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "", "dev":
return "dev"
case "staging":
return "staging"
case "production", "online", "prod":
return "prod"
default:
return strings.ToLower(strings.TrimSpace(raw))
}
}
func ValidateAuthConfig(cfg AuthConfig) error {
mode := strings.ToLower(strings.TrimSpace(cfg.TokenRuntimeMode))
env := NormalizeEnv(cfg.Env)
switch mode {
case "inmemory", "remote_introspection":
default:
return fmt.Errorf("unsupported token runtime mode %q", cfg.TokenRuntimeMode)
}
if (env == "prod" || env == "staging") && mode == "inmemory" {
return fmt.Errorf("inmemory token runtime is not allowed in %s, use remote_introspection", env)
}
if mode == "remote_introspection" && strings.TrimSpace(cfg.TokenRuntimeURL) == "" {
return errors.New("GATEWAY_TOKEN_RUNTIME_URL is required when token runtime mode is remote_introspection")
}
return nil
}
func splitCSV(value string) []string {
rawParts := strings.Split(value, ",")
parts := make([]string, 0, len(rawParts))
for _, part := range rawParts {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
parts = append(parts, trimmed)
}
}
return parts
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
func getEnvInt(key string, defaultValue int) int {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return defaultValue
}
parsed, err := strconv.Atoi(value)
if err != nil {
return defaultValue
}
return parsed
}
func currentEncryptionKey() []byte {
return []byte(getEnv("PASSWORD_ENCRYPTION_KEY", defaultEncryptionKey))
}
// encryptPassword 使用AES-GCM加密密码
func encryptPassword(plaintext string) (string, error) {
if plaintext == "" {
return "", nil
}
block, err := aes.NewCipher(currentEncryptionKey())
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return "", err
}
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
// decryptPassword 解密密码
func decryptPassword(encrypted string) (string, error) {
if encrypted == "" {
return "", nil
}
// 检查是否是旧格式(未加密的明文)
if len(encrypted) < 4 || encrypted[:4] != "enc:" {
// 尝试作为新格式解密
ciphertext, err := base64.StdEncoding.DecodeString(encrypted)
if err != nil {
// 如果不是有效的base64可能是旧格式明文直接返回
return encrypted, nil
}
block, err := aes.NewCipher(currentEncryptionKey())
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonceSize := gcm.NonceSize()
if len(ciphertext) < nonceSize {
return "", errors.New("ciphertext too short")
}
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", err
}
return string(plaintext), nil
}
// 旧格式:直接返回"enc:"后的部分
return encrypted[4:], nil
}