package config import ( "errors" "os" "strconv" "strings" ) type Config struct { AppEnv string AppAddr string MySQLDSN string RedisAddr string RedisPassword string RedisDB int JWTSecret string PaymentConfigEncryptionKey string FieldEncryptionKey string FieldEncryptionLegacyKey string ExternalUploadSecret string ExternalUploadAllowedIPs []string BootstrapAdminUsername string BootstrapAdminPassword string BootstrapAdminNickname string Storage StorageConfig StorageMirror StorageConfig SMS SMSConfig Realname RealnameConfig Log LogConfig RateLimit RateLimitConfig BackupStatusFile string } type StorageConfig struct { Endpoint string Bucket string AccessKeyID string SecretAccessKey string Region string BucketLookup string } type SMSConfig struct { Provider string AliyunAccessKeyID string AliyunAccessKeySecret string AliyunEndpoint string AliyunSignName string AliyunLoginTemplateCode string } type RealnameConfig struct { Provider string CloudMarketURL string CloudMarketAppCode string } type LogConfig struct { Level string Dir string EnableConsole bool EnableFile bool // RetainDays 日志保留天数(含当天);过期的 .log / .log.gz 会被清理。0 表示不自动清理。 RetainDays int } type RateLimitConfig struct { Enabled bool RequestsPerMinute int } func Load() Config { return Config{ AppEnv: getEnv("APP_ENV", "development"), AppAddr: getEnv("APP_ADDR", ":18088"), MySQLDSN: getEnv("MYSQL_DSN", "hfb:secret@tcp(127.0.0.1:13306)/hfb_sys?charset=utf8mb4&parseTime=True&loc=Local"), RedisAddr: getEnv("REDIS_ADDR", "127.0.0.1:16379"), RedisPassword: getEnv("REDIS_PASSWORD", ""), RedisDB: getEnvInt("REDIS_DB", 0), JWTSecret: getEnv("JWT_SECRET", "change-me"), PaymentConfigEncryptionKey: getEnv("PAYMENT_CONFIG_ENCRYPTION_KEY", ""), FieldEncryptionKey: getEnv("FIELD_ENCRYPTION_KEY", ""), // 旧密钥回退:生产环境必须显式设置(空 = 禁用回退,用于密钥轮换收敛); // 非生产默认填历史硬编码密钥,兼容开发/测试库的存量密文。不能用 getEnv 的 fallback—— // 那样生产删掉 env 会重新注入已泄露的硬编码密钥,破坏轮换闭环。 FieldEncryptionLegacyKey: fieldEncryptionLegacyKey(getEnv("APP_ENV", "development")), ExternalUploadSecret: getEnv("EXTERNAL_UPLOAD_SECRET", ""), ExternalUploadAllowedIPs: getEnvList("EXTERNAL_UPLOAD_ALLOWED_IPS"), BootstrapAdminUsername: getEnv("ADMIN_BOOTSTRAP_USERNAME", ""), BootstrapAdminPassword: getEnv("ADMIN_BOOTSTRAP_PASSWORD", ""), BootstrapAdminNickname: getEnv("ADMIN_BOOTSTRAP_NICKNAME", "超级管理员"), Storage: StorageConfig{ Endpoint: getEnv("STORAGE_ENDPOINT", "http://localhost:19090"), Bucket: getEnv("STORAGE_BUCKET", "hfb-sys"), AccessKeyID: getEnv("STORAGE_ACCESS_KEY_ID", "minioadmin"), SecretAccessKey: getEnv("STORAGE_SECRET_ACCESS_KEY", "minioadmin"), Region: getEnv("STORAGE_REGION", ""), BucketLookup: getEnv("STORAGE_BUCKET_LOOKUP", "auto"), }, // StorageMirror 仅在对象存储迁移期间使用。镜像端配置完整时,新上传文件会同步写入两个存储端。 StorageMirror: StorageConfig{ Endpoint: getEnv("STORAGE_MIRROR_ENDPOINT", ""), Bucket: getEnv("STORAGE_MIRROR_BUCKET", ""), AccessKeyID: getEnv("STORAGE_MIRROR_ACCESS_KEY_ID", ""), SecretAccessKey: getEnv("STORAGE_MIRROR_SECRET_ACCESS_KEY", ""), Region: getEnv("STORAGE_MIRROR_REGION", ""), BucketLookup: getEnv("STORAGE_MIRROR_BUCKET_LOOKUP", "auto"), }, SMS: SMSConfig{ Provider: getEnv("SMS_PROVIDER", "mock"), AliyunAccessKeyID: getEnv("ALIYUN_ACCESS_KEY_ID", ""), AliyunAccessKeySecret: getEnv("ALIYUN_ACCESS_KEY_SECRET", ""), AliyunEndpoint: getEnv("ALIYUN_SMS_ENDPOINT", "dysmsapi.aliyuncs.com"), AliyunSignName: getEnv("ALIYUN_SMS_SIGN_NAME", ""), AliyunLoginTemplateCode: getEnv("ALIYUN_SMS_LOGIN_TEMPLATE_CODE", ""), }, Realname: RealnameConfig{ Provider: getEnv("REALNAME_PROVIDER", "mock"), CloudMarketURL: getEnv("REALNAME_CLOUDMARKET_URL", "https://sinocheck2.market.alicloudapi.com/fortest/ttttt"), CloudMarketAppCode: getEnv("REALNAME_CLOUDMARKET_APPCODE", ""), }, Log: LogConfig{ Level: getEnv("LOG_LEVEL", "info"), Dir: getEnv("LOG_DIR", "logs"), EnableConsole: getEnvBool("LOG_ENABLE_CONSOLE", true), EnableFile: getEnvBool("LOG_ENABLE_FILE", true), RetainDays: getEnvInt("LOG_RETAIN_DAYS", 14), }, RateLimit: RateLimitConfig{ Enabled: getEnvBool("RATE_LIMIT_ENABLED", true), RequestsPerMinute: getEnvInt("RATE_LIMIT_REQUESTS_PER_MINUTE", 300), }, BackupStatusFile: getEnv("BACKUP_STATUS_FILE", "/var/run/hfb-backup-status/status.json"), } } func (c Config) ValidateProductionSecurity() error { if !IsProductionEnv(c.AppEnv) { return nil } if !strings.EqualFold(strings.TrimSpace(c.SMS.Provider), "aliyun") { return errors.New("SMS_PROVIDER must be aliyun in production") } if strings.TrimSpace(c.SMS.AliyunAccessKeyID) == "" || strings.TrimSpace(c.SMS.AliyunAccessKeySecret) == "" || strings.TrimSpace(c.SMS.AliyunSignName) == "" || strings.TrimSpace(c.SMS.AliyunLoginTemplateCode) == "" { return errors.New("aliyun SMS credentials, sign name, and template code are required in production") } if strings.TrimSpace(c.JWTSecret) == "" || isPlaceholder(c.JWTSecret) || len([]byte(c.JWTSecret)) < 32 { return errors.New("JWT_SECRET must be a non-default random value of at least 32 bytes in production") } keyLen := len([]byte(c.PaymentConfigEncryptionKey)) if isPlaceholder(c.PaymentConfigEncryptionKey) || (keyLen != 16 && keyLen != 24 && keyLen != 32) { return errors.New("PAYMENT_CONFIG_ENCRYPTION_KEY must be 16, 24, or 32 bytes in production") } fieldKeyLen := len([]byte(c.FieldEncryptionKey)) if c.FieldEncryptionKey == "" || isPlaceholder(c.FieldEncryptionKey) || (fieldKeyLen != 16 && fieldKeyLen != 24 && fieldKeyLen != 32) { return errors.New("FIELD_ENCRYPTION_KEY must be 16, 24, or 32 bytes in production") } legacyKeyLen := len([]byte(c.FieldEncryptionLegacyKey)) if c.FieldEncryptionLegacyKey != "" && (isPlaceholder(c.FieldEncryptionLegacyKey) || (legacyKeyLen != 16 && legacyKeyLen != 24 && legacyKeyLen != 32)) { return errors.New("FIELD_ENCRYPTION_LEGACY_KEY must be empty or 16, 24, or 32 bytes in production") } if c.FieldEncryptionKey == c.FieldEncryptionLegacyKey { return errors.New("FIELD_ENCRYPTION_KEY must differ from FIELD_ENCRYPTION_LEGACY_KEY in production (set a new primary key to rotate)") } if c.BootstrapAdminPassword != "" && isPlaceholder(c.BootstrapAdminPassword) { return errors.New("ADMIN_BOOTSTRAP_PASSWORD must not use the example placeholder in production") } return nil } func getEnv(key, fallback string) string { value := os.Getenv(key) if value == "" { return fallback } return value } // historicFieldEncryptionKey 是迁移前 pkg/crypto 的硬编码密钥,仅用于解密历史存量密文。 const historicFieldEncryptionKey = "hfb-sys-2024-secret-key-32bytes!" // fieldEncryptionLegacyKey 解析 FIELD_ENCRYPTION_LEGACY_KEY: // - 生产环境:必须显式设置,空表示禁用回退(密钥轮换收敛后删除 env 即关闭旧密钥)。 // 不能用硬编码默认值,否则轮换闭环不成立。 // - 非生产环境:未设置时回退到历史硬编码密钥,兼容开发/测试库的存量密文(零配置)。 func fieldEncryptionLegacyKey(appEnv string) string { if IsProductionEnv(appEnv) { // 生产显式空 = 禁用;未设置也视为禁用。 return os.Getenv("FIELD_ENCRYPTION_LEGACY_KEY") } if v := os.Getenv("FIELD_ENCRYPTION_LEGACY_KEY"); v != "" { return v } return historicFieldEncryptionKey } func getEnvInt(key string, fallback int) int { value := os.Getenv(key) if value == "" { return fallback } parsed, err := strconv.Atoi(value) if err != nil { return fallback } return parsed } func getEnvBool(key string, fallback bool) bool { value := os.Getenv(key) if value == "" { return fallback } parsed, err := strconv.ParseBool(value) if err != nil { return fallback } return parsed } func getEnvList(key string) []string { raw := os.Getenv(key) if raw == "" { return nil } parts := strings.Split(raw, ",") values := make([]string, 0, len(parts)) for _, part := range parts { value := strings.TrimSpace(part) if value != "" { values = append(values, value) } } return values } func isPlaceholder(value string) bool { normalized := strings.ToLower(strings.TrimSpace(value)) return normalized == "change-me" || strings.HasPrefix(normalized, "change-") || strings.Contains(normalized, "change-to-") } // IsProductionEnv 判断 AppEnv 是否为生产环境(与 ValidateProductionSecurity 同口径)。 func IsProductionEnv(appEnv string) bool { return strings.ToLower(strings.TrimSpace(appEnv)) == "production" }