225 lines
8.1 KiB
Go
225 lines
8.1 KiB
Go
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
|
||
SMS SMSConfig
|
||
Realname RealnameConfig
|
||
Log LogConfig
|
||
RateLimit RateLimitConfig
|
||
}
|
||
|
||
type StorageConfig struct {
|
||
Endpoint string
|
||
Bucket string
|
||
AccessKeyID string
|
||
SecretAccessKey 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"),
|
||
},
|
||
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),
|
||
},
|
||
}
|
||
}
|
||
|
||
func (c Config) ValidateProductionSecurity() error {
|
||
if !IsProductionEnv(c.AppEnv) {
|
||
return nil
|
||
}
|
||
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"
|
||
}
|