From 08ed43385dd9ba4306bd150c8b5b7f72fd80d8f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=AD=E5=B7=9E=E6=98=8E=E5=A9=B3=E7=A7=91=E6=8A=80?= Date: Thu, 9 Jul 2026 12:54:52 +0800 Subject: [PATCH] =?UTF-8?q?fix(config):=20=E6=A8=A1=E5=9D=97=2001=20?= =?UTF-8?q?=E8=AF=84=E5=AE=A1=E4=BF=AE=E5=A4=8D=20+=20=E5=AF=B9=E6=8A=97?= =?UTF-8?q?=E5=A4=8D=E6=A0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit config 模块(glm_check_report_module_01_config.md)11 项 finding 修复: - H-config-1: Set(cfg) 用 mapstructure 重建 m.v,消除 Get/GetString/viper 视图分裂(C1) - M-config-1: 热重载回调独立 recover,panic 不杀 watcher、不阻断后续回调(C9) - M-config-2: DB SSL/TLS -- Postgres SSLMode(默认 prefer)+ MySQL TLS/TLSRootCA; database.ensureMySQLTLSRegistered 注册私有 CA 命名配置,fail-fast 不回退明文。 O-1 文档准确性修正:同名注册会覆盖 master,改为 per-host 不同名 + 自建 replica DSN 指引 - M-config-3: App.Shutdown closeResources 停 configManager watcher(C7) - M-config-4: Clone 切片/map 字段反射守卫测试 - M-config-5: WithConfig/WithConfigPath 全局可见性文档强化 - L-config-2: watchLoop 增 ctx 逃生通道,StopWatcher cancel+Close 双重退出 - L-config-3/4/5/6: Validate 连接池交叉校验、哨兵 errors.New、去冗余 TrimSpace、nil receiver 防御 含契约级回归测试(同源一致性/生命周期闭环/扩展点防御),-race/vet/build 全绿。 附 module 01 评审报告 + 全局结构图 + 扫描收敛协议。 Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 17 ++ app.go | 19 +- app_config_test.go | 60 +++++++ config/config.go | 140 +++++++++++++-- config/config_fix_test.go | 329 ++++++++++++++++++++++++++++++++++ config/config_test.go | 2 +- config/validate.go | 26 +++ database/manager.go | 6 + database/tls.go | 72 ++++++++ database/tls_internal_test.go | 146 +++++++++++++++ scan_protocol.md | 90 ++++++++++ 11 files changed, 890 insertions(+), 17 deletions(-) create mode 100644 app_config_test.go create mode 100644 config/config_fix_test.go create mode 100644 database/tls.go create mode 100644 database/tls_internal_test.go create mode 100644 scan_protocol.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 15a9061..d12361b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,9 +17,13 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange ## [Unreleased] > 复审报告 `gpt_check_report_review.md` 第一优先级(致命/进程级可用性)修复。P1 共 4 项,本次发布已推进 M13 cron panic、M1 App 生命周期、M3 logger 生命周期临界区;M8 CSRF JSON body 上限随后推进。 +> +> config 模块评审(`glm_check_report_module_01_config.md`)修复:H-config-1 Set/viper 同源、M-config-1 回调 panic 隔离、M-config-2 DB SSL/TLS、M-config-3 App 关闭停 watcher、M-config-4 Clone 守卫、L-config-2/3/4/5/6。 ### Breaking ⚠️ +- **PostgresDSN 默认 sslmode 由 `disable` 改为 `prefer`**(M-config-2):原 `PostgresDSN` 硬编码 `sslmode=disable`,生产 DB 流量明文。新增 `DatabaseConfig.SSLMode` 字段,空值默认 `prefer`(优先加密、失败回退明文),可显式配置 `disable/allow/prefer/require/verify-ca/verify-full`(非法值在 `Validate` 阶段报错)。依赖明文 Postgres 连接的下游若因 `prefer` 回退行为受影响,请显式设置 `ssl_mode: disable`。MySQL 不受影响(用 `TLS`/`TLSRootCA`)。 + - **cache 写操作与计数器/原始 Redis helper 在 Redis 未初始化时返回 `ErrRedisNotReady`**:`Set` / `Delete` / `DeleteByPattern` / `Incr` / `IncrBy` / `Decr` / `GetTTL` / `SetExpire` / `GetRaw` / `SetRaw` 旧行为会静默返回成功或零值,调用方容易误判缓存写入、计数器更新或过期时间设置已经生效;现在统一显式返回错误。公共接口签名不变,但依赖“未启用 Redis 时当作成功”的下游需要改为忽略 `errors.Is(err, cache.ErrRedisNotReady)` 或显式启用 Redis。 - **`cache.WithLock` / `cache.WithLockAutoExtend` 未获取到锁时返回 `ErrLockNotAcquired`**:旧行为返回 `nil` 并跳过业务函数,调用方无法区分“业务执行成功”和“根本没有执行”。同时锁 TTL 小于 1ms、续期/重试间隔非正会返回显式错误,避免 Redis PX=0 或 `time.NewTicker(0)` 崩溃。 - **`cache.WithLock` / `cache.WithLockAutoExtend` 的业务函数签名改为 `func(context.Context) error`**:旧签名 `func() error` 无法强制业务函数接收取消信号,容易在请求取消/超时后继续访问 DB/HTTP 等下游资源;现在框架会把调用方 ctx 传入业务函数。nil 业务函数返回新增 `ErrLockFuncNil`。 @@ -43,8 +47,21 @@ xlgo 框架更新日志。本文档遵循 [Keep a Changelog](https://keepachange - **`cron.ParseCron` 非法表达式改为 fail-fast panic**(cron/cron.go,M13):旧行为会把非法表达式静默回退为每分钟执行,容易让拼写错误变成高频任务。动态输入请用 `ParseCronStrict` 处理 error;确实需要旧回退语义时改用新增 `ParseCronOrDefault`。 - **repository 查询保护默认开启**(repository/repository.go,M5/N5):`FindAll` 默认最多返回 `DefaultFindAllLimit=1000` 条;明确需要全表扫描时改用 `FindAllUnbounded`。`FindPage*` / `QueryBuilder.Page` 会归一化 `page/pageSize` 并限制 `MaxPageSize=100`、`MaxPage=10000`。`Find*Ordered` / `QueryBuilder.Order` 只接受简单字段排序(如 `created_at DESC, id ASC`),复杂表达式/raw SQL 会返回 `ErrUnsafeOrder`;`UpdateBatch` 字段名不合法返回 `ErrUnsafeField`。 +### Security 🔒 + +- **MySQL 连接支持 TLS**(M-config-2):`DatabaseConfig.TLS` 为 true 时 `MySQLDSN` 追加 `tls=true`(go-sql-driver/mysql v1.7.0 内置安全语义:系统根 CA + ServerName 自动取自 Host + 证书校验,无需注册)。配合 `DatabaseConfig.TLSRootCA`(PEM 路径)可指定私有 CA/自签证书,由 `database` 包在 `InitDB` 时 `RegisterTLSConfig` 注册命名配置(`config.MySQLTLSConfigName`);CA 不可读或非 PEM 时 fail-fast,不静默回退明文。 +- **Postgres 连接支持 sslmode 配置**(M-config-2):见 Breaking 项,默认 `prefer` 优先加密。 + ### Fixed 🐛 +- **config `Set(cfg)` 与 viper 视图同源修复**(H-config-1):`Set` 原只更新类型化视图 `m.cfg`,`m.v`(viper)停留旧值,导致 `Get()` 与 `GetString/GetInt/GetBool/GetViper` 返回不同世界(违反 C1 单一配置源)。现在 `Set` 用 mapstructure 将 `*Config` 重建为不含 `AutomaticEnv` 的 viper 视图,保证 Get 与 GetString 同源。 +- **config 热重载回调 panic 隔离**(M-config-1):单个 `onChange` 回调 panic 原会传播致 watcher 泄漏、后续热更新静默失效。现在每个回调独立 `recover`(标准库 `log` 记录 + 堆栈),不阻断后续回调、不杀 watcher。 +- **App.Shutdown 停止 configManager watcher**(M-config-3):`closeResources` 末尾新增 `configManager.StopWatcher()`,用户对 App 的 configManager 调 `LoadWithWatch`/`StartWatcher` 后由 Shutdown 统一收口,避免关闭后遗留监听 goroutine(违反 C7)。 +- **config `Clone` 切片字段覆盖守卫**(M-config-4):新增反射测试枚举 `Config` 所有切片/map 字段,断言 `Clone` 深拷贝;新增切片字段未同步 fixture 或 `Clone` 时测试失败,形成机械守卫。 +- **config `watchLoop` 增加 ctx 逃生通道**(L-config-2):原仅靠 `w.Events` 关闭退出,与"for 消费循环须 ctx.Done"红线有张力。`StopWatcher` 改为 cancel ctx + Close watcher 双重退出。 +- **config `Validate` 连接池交叉校验**(L-config-3):`MaxOpenConns>0` 时 `MaxIdleConns>MaxOpenConns` 视为配置错误。 +- **config 哨兵错误改用 `errors.New`**(L-config-4);**`DSN()` 去除冗余 TrimSpace**(L-config-5);**`DSN/MySQLDSN/PostgresDSN/Addr` nil receiver 防御**(L-config-6)。 + - **M9 JWT issuer / refresh expiry 契约修复**:`ParseToken`、`InvalidateToken`、`GetClaimsFromToken` 统一按当前配置校验 issuer;`RefreshToken` 不再忽略 `refresh_expire`;空 JTI 不再写入永不命中的 `jwt_bl:` 黑名单键;新增 `ParseTokenFailClosed` / `ParseTokenWithBlacklistPolicy` / `TokenBlacklist.IsBlacklistedE`,默认 `ParseToken` 仍保持黑名单检查 fail-open 兼容语义,安全敏感路由可显式选择 fail-closed;解析侧配置错误现在可通过 `errors.Is` 区分 `ErrEmptySecret` / `ErrUnsupportedAlgorithm`;`InvalidateToken` 使用不校验时序的解析路径,允许提前吊销 `nbf` 在未来的外部 token。 - **M10 分布式锁参数与取消传播修复**:锁 TTL 统一校验到 Redis 毫秒粒度;`TryLock` 的非正 retry interval 不再 busy-loop;`WithLockAutoExtend` 的非正 extend interval 不再触发 goroutine panic;`UnlockByKey` 在 Redis 未初始化时与 `ForceUnlock` 一样返回 `ErrRedisNotReady`;`WithLock` / `WithLockAutoExtend` 现在把调用方 ctx 传入业务函数,避免取消后业务函数继续运行。 - **M10 cache 剩余错误语义收口**:新增 `cache.GetE` / `cache.ExistsE` 与可选 `CacheGetter` / `CacheExistChecker`,让调用方能区分 cache miss、Redis/backend 故障和反序列化错误;保留旧 `Get` / `Exists` bool-only 兼容方法但记录后端错误;`KeyBuilder` 现在忽略 nil option,`WithPrefix` / `WithSeparator` / `WithCacheType` 直接作用于 nil builder 时 no-op,避免扩展配置路径 panic。 diff --git a/app.go b/app.go index f99ba4b..311d3e7 100644 --- a/app.go +++ b/app.go @@ -143,7 +143,13 @@ type App struct { // Option 应用选项 type Option func(*App) -// WithConfigPath 设置配置文件路径 +// WithConfigPath 设置配置文件路径。 +// +// M-config-5 全局可见性提示:WithConfigPath 在 Init 时会把 App 持有的 configManager 提升为 +// 全局默认(config.SetDefaultManager),故 config.Get / config.GetString 等包级便捷函数可读到 +// 该配置。这与 WithConfig 形成不对称:WithConfig 仅保留在 App 实例内,不污染全局,包级 +// config.Get() 取不到注入配置。下游若混用 a.config 与 config.GetString,两种注入方式行为不同-- +// 需要 config.Get/GetString 的下游请用 WithConfigPath(或自行 config.SetDefaultManager)。 func WithConfigPath(path string) Option { return func(a *App) { a.configPath = path @@ -154,7 +160,9 @@ func WithConfigPath(path string) Option { // WithConfig 设置配置对象。 // 配置会在传入时深拷贝成 App 私有快照,并在 Init 时 Validate;调用方后续修改 cfg // 不会污染 App 内部配置。不再调用 config.Set(cfg),配置仅保留在 App 实例内,不污染全局状态。 -// 依赖 config.Get() 获取注入配置的下游代码请改用 WithConfigPath。 +// +// M-config-5:因此 config.Get / config.GetString 等包级函数取不到此注入配置(默认空 manager)。 +// 依赖 config.Get() 获取注入配置的下游代码请改用 WithConfigPath(会提升为全局默认)。 func WithConfig(cfg *config.Config) Option { return func(a *App) { a.config = cfg.Clone() @@ -522,6 +530,13 @@ func (a *App) closeResources() error { a.initializedLogger = false a.loggerManager = nil } + // M-config-3:停止 App 持有的 configManager 热重载 watcher(若已启用),避免 App 关闭后 + // 遗留监听 goroutine。默认流程(resolveConfig 用 Load 不启 watcher)为 no-op;用户对 + // configManager 调用 LoadWithWatch/StartWatcher 后由这里统一收口。StopWatcher 幂等且会等待 + // 进行中的 reload 回调结束,符合 C7 生命周期契约。configManager 可能为 nil(WithConfig 注入)。 + if a.configManager != nil { + a.configManager.StopWatcher() + } return errors.Join(errs...) } diff --git a/app_config_test.go b/app_config_test.go new file mode 100644 index 0000000..248919a --- /dev/null +++ b/app_config_test.go @@ -0,0 +1,60 @@ +package xlgo_test + +import ( + "os" + "path/filepath" + "runtime" + "testing" + "time" + + xlgo "github.com/EthanCodeCraft/xlgo-core" + "github.com/EthanCodeCraft/xlgo-core/config" +) + +// TestAppShutdownStopsConfigWatcher_Mconfig3 固化 M-config-3:App.Shutdown 须停止其 +// configManager 的热重载 watcher,避免关闭后遗留监听 goroutine(违反 C7 生命周期契约)。 +// +// Init 后 resolveConfig 把 a.configManager 提升为全局默认,故经包级 config.StartWatcher +// 在其上启动 watcher(等价于用户对 configManager 调 LoadWithWatch/StartWatcher); +// Shutdown 后断言 watchLoop goroutine 已退出。 +func TestAppShutdownStopsConfigWatcher_Mconfig3(t *testing.T) { + dir := filepath.Join(os.TempDir(), "xlgo_app_mconfig3") + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + defer os.RemoveAll(dir) + cfgPath := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(cfgPath, []byte("app:\n name: m3\n env: dev\nserver:\n port: 18080\n"), 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + app := xlgo.New(xlgo.WithConfigPath(cfgPath)) + if err := app.Init(); err != nil { + t.Fatalf("Init: %v", err) + } + + // 经全局默认(=a.configManager)启动 watcher,模拟用户开启热重载 + if err := config.StartWatcher(); err != nil { + t.Fatalf("StartWatcher: %v", err) + } + defer config.SetDefaultManager(nil) + + time.Sleep(120 * time.Millisecond) // 等 watchLoop 就绪 + before := runtime.NumGoroutine() + + if err := app.Shutdown(); err != nil { + t.Fatalf("Shutdown: %v", err) + } + + // watchLoop goroutine 应随 Shutdown 退出 + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if runtime.NumGoroutine() < before { + break + } + time.Sleep(20 * time.Millisecond) + } + if after := runtime.NumGoroutine(); after >= before { + t.Errorf("App.Shutdown 未停止 config watcher(M-config-3): before=%d after=%d", before, after) + } +} diff --git a/config/config.go b/config/config.go index d370cd4..ea7c36e 100644 --- a/config/config.go +++ b/config/config.go @@ -1,10 +1,13 @@ package config import ( + "context" "errors" "fmt" + "log" "net/url" "path/filepath" + "runtime/debug" "strings" "sync" "sync/atomic" @@ -17,8 +20,9 @@ import ( // 配置错误 var ( - ErrConfigNotLoaded = fmt.Errorf("配置未加载") - ErrInvalidConfig = fmt.Errorf("配置非法") + // L-config-4:无格式化的哨兵错误用 errors.New(fmt.Errorf 无动词等价但语义上后者暗示格式化)。 + ErrConfigNotLoaded = errors.New("配置未加载") + ErrInvalidConfig = errors.New("配置非法") ) // Config 全局配置结构体 @@ -230,6 +234,12 @@ const ( DriverPostgres = "postgres" ) +// MySQLTLSConfigName 是 database 包为 MySQL 私有 CA TLS 注册的命名配置名(M-config-2)。 +// 当 DatabaseConfig.TLS=true 且 TLSRootCA 非空时,MySQLDSN 追加 tls=<本常量>, +// 由 database 包在 InitDB 时通过 go-sql-driver/mysql.RegisterTLSConfig 注册自定义 *tls.Config。 +// TLS=true 但 TLSRootCA 为空时则用内置 tls=true(系统根 CA),无需注册。 +const MySQLTLSConfigName = "xlgo-mysql" + // DSNBuilder 根据 DatabaseConfig 生成连接字符串 type DSNBuilder func(*DatabaseConfig) string @@ -311,17 +321,34 @@ type DatabaseConfig struct { HealthCheckInterval time.Duration `mapstructure:"health_check_interval"` // HealthCheckFailureThreshold 连续探活失败多少次标记不健康(#21)。0 表示用默认 3 HealthCheckFailureThreshold int `mapstructure:"health_check_failure_threshold"` + // SSLMode PostgreSQL sslmode(disable/allow/prefer/require/verify-ca/verify-full)。 + // 空时默认 "prefer"(M-config-2:原硬编码 disable 已改为默认 prefer,优先加密、失败回退明文)。 + // 该字段仅对 PostgreSQL 生效;MySQL 用 TLS/TLSRootCA。 + SSLMode string `mapstructure:"ssl_mode"` + // TLS 是否对 MySQL 启用 TLS。true 时 MySQLDSN 追加 tls=true(内置:系统根 CA + 证书校验, + // ServerName 自动取自 Host)。配合 TLSRootCA 可指定私有 CA(M-config-2)。 + TLS bool `mapstructure:"tls"` + // TLSRootCA MySQL TLS 自定义 CA 证书 PEM 路径(用于私有 CA/自签证书)。 + // 非空时 MySQLDSN 改用 tls=MySQLTLSConfigName,由 database 包在 InitDB 时注册命名 TLS 配置 + // (ServerName 取自 Host)。该命名配置仅覆盖「replica DSN host 与主库 Host 相同」的单 host 集群; + // 多 host replicas + 私有 CA 时须为每个 replica host 注册不同名 TLS 配置并自建 replica DSN(框架 + // MySQLDSN 硬编码 MySQLTLSConfigName,不适用),详见 database.ensureMySQLTLSRegistered 注释。 + // 空时用内置 tls=true(系统根 CA)。仅 MySQL 生效。 + TLSRootCA string `mapstructure:"tls_root_ca"` } // DSN 根据驱动返回连接字符串。设置了 CustomDSN 时优先返回 CustomDSN。 // 未指定 Driver 时按 MySQL 处理;非空但未注册的 Driver 返回空字符串, // 避免把拼写错误静默当作 MySQL 连接,正常加载路径会由 Validate 提前报错。 func (c *DatabaseConfig) DSN() string { + if c == nil { // L-config-6:防御 nil receiver + return "" + } if c.CustomDSN != "" { return c.CustomDSN } driver := strings.TrimSpace(c.Driver) - if strings.TrimSpace(driver) == "" { + if driver == "" { // L-config-5:去除原重复的 TrimSpace(driver) driver = DriverMySQL } if builder, ok := LookupDSNBuilder(driver); ok { @@ -343,25 +370,47 @@ func (c DatabaseConfig) isConfigured() bool { // MySQLDSN 返回 MySQL 连接字符串。 // 密码经 url.QueryEscape 转义,避免含 @/:/空格 等特殊字符破坏 DSN(M9)。 // loc 由 Timezone 配置,空则默认 "Local"(向后兼容)。 +// TLS=true 时追加 tls 参数(M-config-2):TLSRootCA 为空用内置 tls=true(系统根 CA + 证书校验); +// TLSRootCA 非空用 tls=MySQLTLSConfigName(database 包注册的私有 CA 命名配置)。 func (c *DatabaseConfig) MySQLDSN() string { + if c == nil { // L-config-6 + return "" + } loc := c.Timezone if loc == "" { loc = "Local" } - return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=%s", + dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=%s", url.QueryEscape(c.User), url.QueryEscape(c.Password), c.Host, c.Port, url.PathEscape(c.Name), url.QueryEscape(loc)) + if c.TLS { + if strings.TrimSpace(c.TLSRootCA) == "" { + dsn += "&tls=true" + } else { + dsn += "&tls=" + MySQLTLSConfigName + } + } + return dsn } // PostgresDSN 返回 PostgreSQL 连接字符串。 // 字符串字段统一用单引号包裹并转义,避免含空格/引号/反斜杠破坏 key=value DSN。 // TimeZone 由 Timezone 配置,空则默认 "Asia/Shanghai"(向后兼容)。 +// sslmode 由 SSLMode 配置,空则默认 "prefer"(M-config-2:原硬编码 disable 改为 prefer,优先加密)。 func (c *DatabaseConfig) PostgresDSN() string { + if c == nil { // L-config-6 + return "" + } tz := c.Timezone if tz == "" { tz = "Asia/Shanghai" } - return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable TimeZone=%s", - postgresQuote(c.Host), c.Port, postgresQuote(c.User), postgresQuote(c.Password), postgresQuote(c.Name), postgresQuote(tz)) + sslmode := strings.TrimSpace(c.SSLMode) + if sslmode == "" { + sslmode = "prefer" + } + // sslmode 不加引号(与原 sslmode=disable 格式一致);SSLMode 已被 Validate 限定为固定枚举,无注入风险。 + return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s TimeZone=%s", + postgresQuote(c.Host), c.Port, postgresQuote(c.User), postgresQuote(c.Password), postgresQuote(c.Name), sslmode, postgresQuote(tz)) } func postgresQuote(s string) string { @@ -380,6 +429,9 @@ type RedisConfig struct { // Addr 返回 Redis 地址 func (c *RedisConfig) Addr() string { + if c == nil { // L-config-6 + return "" + } return fmt.Sprintf("%s:%d", c.Host, c.Port) } @@ -524,6 +576,10 @@ type Manager struct { watcher *fsnotify.Watcher // watchDone 在监听 goroutine 退出时被 close,供 StopWatcher 等待退出确认。 watchDone chan struct{} + // watchCancel 取消 watchLoop 的 ctx(L-config-2)。watchLoop 同时监听 ctx.Done 与 w.Events, + // 提供 fsnotify 致命错误且 Events 未关闭时的逃生通道,避免监听 goroutine 永驻。 + // StopWatcher 时 cancel + Close(w) 双重退出保障。 + watchCancel context.CancelFunc } // defaultManager 是包级默认管理器(C10a)。改用 atomic.Pointer 保护读写, @@ -558,6 +614,38 @@ func cloneViper(src *viper.Viper, configPath string) *viper.Viper { return cp } +// configToMap 将 *Config 按 mapstructure tag 转为嵌套 map[string]any(H-config-1)。 +// mapstructure struct->map 会递归嵌套结构体为 map,切片/map 字段原样保留, +// 用于在 Set(cfg) 后重建 m.v,使 GetString/GetInt/GetBool/GetViper 与 Get() 读到同一配置。 +func configToMap(cfg *Config) map[string]any { + if cfg == nil { + return nil + } + out := map[string]any{} + if err := mapstructure.Decode(cfg, &out); err != nil { + return nil + } + return out +} + +// viperFromConfig 由 *Config 重建一个不含 AutomaticEnv 的 viper(H-config-1)。 +// 用于 Set(cfg) 后同步 m.v:不启用 AutomaticEnv 是为了确保 GetString 等只读取 cfg 派生的值, +// 与 Get()(返回 m.cfg 即调用方传入的 cfg)严格同源,避免 env 覆盖造成二者的二次分裂。 +// 保留 SetConfigFile(m.path) 以便后续 Reload 从文件重读。 +// +// 已知差异(Duration 字段):mapstructure struct->map 将 time.Duration 原样保留为 time.Duration, +// 故 GetString("jwt.expire") 在 Set 后返回 Duration.String() 格式(如 "24h0m0s"),与文件加载路径 +// 返回的原始字符串(如 "24h")字面不同。二者语义一致(均可 ParseDuration),typed view(Get().JWT.Expire) +// 与 GetDuration 在两条路径下完全一致。Duration 字段应经 typed view 或 GetDuration 读取,勿用 GetString 字面比较。 +func viperFromConfig(configPath string, cfg *Config) *viper.Viper { + v := viper.New() + v.SetConfigFile(configPath) + if m := configToMap(cfg); m != nil { + _ = v.MergeConfigMap(m) + } + return v +} + // unmarshalConfig 将 viper 解析到 Config,启用 string→time.Duration decode hook, // 使 ServerConfig/JWTConfig 的 Duration 字段可写 "24h"/"15s" 等字符串。 func unmarshalConfig(v *viper.Viper, cfg *Config) error { @@ -653,19 +741,22 @@ func (m *Manager) StartWatcher() error { } m.watcher = w m.watchDone = make(chan struct{}) + // L-config-2:为 watchLoop 创建可取消 ctx,作为 w.Events 关闭之外的逃生通道。 + ctx, cancel := context.WithCancel(context.Background()) + m.watchCancel = cancel target := filepath.Base(m.path) done := m.watchDone m.mu.Unlock() - go m.watchLoop(w, target, done) + go m.watchLoop(ctx, w, target, done) return nil } // watchLoop 是文件监听 goroutine 主体。文件变更经去抖后调用 reload; -// watcher 被 Close(Events 通道关闭)时退出并 close done。 -// done 由 StartWatcher 在锁内捕获传入,避免本 goroutine 读取 m.watchDone 字段 -// 与 StopWatcher 写入竞争。 -func (m *Manager) watchLoop(w *fsnotify.Watcher, target string, done chan struct{}) { +// watcher 被 Close(Events 通道关闭)或 ctx 被 cancel(L-config-2)时退出并 close done。 +// done 与 ctx 均由 StartWatcher 在锁内捕获传入,避免本 goroutine 读取 m.watchDone / +// m.watchCancel 字段与 StopWatcher 写入竞争。ctx 提供 fsnotify 致命错误下的逃生通道。 +func (m *Manager) watchLoop(ctx context.Context, w *fsnotify.Watcher, target string, done chan struct{}) { defer close(done) const debounce = 200 * time.Millisecond timer := time.NewTimer(time.Hour) @@ -676,6 +767,8 @@ func (m *Manager) watchLoop(w *fsnotify.Watcher, target string, done chan struct var timerC <-chan time.Time for { select { + case <-ctx.Done(): // L-config-2:StopWatcher cancel 时退出,不单靠 w.Events 关闭 + return case ev, ok := <-w.Events: if !ok { return @@ -711,7 +804,7 @@ func (m *Manager) watchLoop(w *fsnotify.Watcher, target string, done chan struct } // StopWatcher 停止配置文件监听并释放 watcher(C10d)。幂等。 -// 关闭 fsnotify watcher → Events 通道关闭 → watchLoop 退出 → 等待 watchDone。 +// cancel ctx 与关闭 fsnotify watcher 双重退出(L-config-2),等待 watchDone 确认 goroutine 退出。 func (m *Manager) StopWatcher() { if m == nil { return @@ -719,13 +812,18 @@ func (m *Manager) StopWatcher() { m.mu.Lock() w := m.watcher done := m.watchDone + cancel := m.watchCancel m.watcher = nil m.watchDone = nil + m.watchCancel = nil m.mu.Unlock() + if cancel != nil { + cancel() // L-config-2:先 cancel 让 watchLoop 经 ctx.Done 退出 + } if w == nil { return } - _ = w.Close() + _ = w.Close() // 再 Close watcher 让 w.Events 关闭,双重保障 if done != nil { <-done } @@ -807,6 +905,9 @@ func (m *Manager) GetStringMap(key string) map[string]any { } // Set 手动设置配置。非 nil 配置会先 Validate,并以深拷贝形式保存。 +// H-config-1:非 nil 配置同时用其重建 m.v(不含 AutomaticEnv),使 Get() 与 +// GetString/GetInt/GetBool/GetViper 读到同一配置世界,消除原"Set 只更新类型化视图、 +// viper 视图停留在旧值"的静默分裂(违反 C1 单一配置源)。 func (m *Manager) Set(cfg *Config) error { if m == nil { return ErrConfigNotLoaded @@ -821,6 +922,8 @@ func (m *Manager) Set(cfg *Config) error { m.cfg = cfg.Clone() if cfg == nil { m.v = nil + } else { + m.v = viperFromConfig(m.path, cfg) } return nil } @@ -864,8 +967,17 @@ func (m *Manager) reload() error { // M-G:回调传入 newCfg 的深拷贝,避免回调修改切片字段与 Get() 读者(持有 &newCfg)竞态。 // 回调为 onChange 通知语义,应观察而非改写配置;改写副本不影响内部 m.cfg。 + // M-config-1:每个回调独立 recover,单个回调 panic 不得杀掉 watcher 或阻断后续回调。 + // config 是叶子包(logger 依赖 config),不能用框架 zap,故用标准库 log 记录 + 堆栈。 for _, cb := range cbs { - cb(newCfg.Clone()) + func(cb func(*Config)) { + defer func() { + if r := recover(); r != nil { + log.Printf("config: 配置变更回调 panic(已隔离,继续后续回调): %v\n%s", r, debug.Stack()) + } + }() + cb(newCfg.Clone()) + }(cb) } return nil } diff --git a/config/config_fix_test.go b/config/config_fix_test.go new file mode 100644 index 0000000..06603d1 --- /dev/null +++ b/config/config_fix_test.go @@ -0,0 +1,329 @@ +package config_test + +import ( + "os" + "reflect" + "strings" + "testing" + "time" + + "github.com/EthanCodeCraft/xlgo-core/config" +) + +// TestSetKeepsViperInSync_Hconfig1 固化 H-config-1:Set(cfg) 后 Get() 与 +// GetString/GetInt/GetBool/GetViper 必须读到同一配置世界,消除原"Set 只更新类型化视图、 +// viper 视图停留旧值"的静默分裂(违反 C1 单一配置源)。 +func TestSetKeepsViperInSync_Hconfig1(t *testing.T) { + cfg := &config.Config{ + App: config.AppConfig{Name: "sync-test", SiteName: "sync_site", Env: "prod", Debug: true}, + Server: config.ServerConfig{Port: 7777}, + } + if err := config.Set(cfg); err != nil { + t.Fatalf("Set: %v", err) + } + defer config.SetDefaultManager(nil) + + // 类型化视图 + got := config.Get() + if got == nil || got.App.Name != "sync-test" || got.Server.Port != 7777 { + t.Fatalf("Get() 不一致: %+v", got) + } + // viper 视图必须同源 + if v := config.GetString("app.name"); v != "sync-test" { + t.Errorf("GetString(app.name) = %q, want sync-test(H-config-1 同源)", v) + } + if v := config.GetInt("server.port"); v != 7777 { + t.Errorf("GetInt(server.port) = %d, want 7777(H-config-1)", v) + } + if v := config.GetBool("app.debug"); v != true { + t.Errorf("GetBool(app.debug) = %v, want true(H-config-1)", v) + } + if v := config.GetString("app.env"); v != "prod" { + t.Errorf("GetString(app.env) = %q, want prod(H-config-1)", v) + } + vp := config.GetViper() + if vp == nil || vp.GetString("app.name") != "sync-test" { + t.Errorf("GetViper().GetString(app.name) 不一致(H-config-1): %v", vp) + } + + // Set(nil) 清空,GetString 返回空(m.v=nil) + if err := config.Set(nil); err != nil { + t.Fatalf("Set(nil): %v", err) + } + if v := config.GetString("app.name"); v != "" { + t.Errorf("Set(nil) 后 GetString 应为空, got %q", v) + } +} + +// TestSetDurationGetDurationConsistent_Hconfig1 锁定 H-config-1 已知行为:Duration 字段经 Set +// 重建 viper 后,GetString 字面格式与文件加载不同(mapstructure 存 time.Duration -> Duration.String()), +// 但 typed view 与 GetDuration 在两条路径下一致。Duration 应经 typed view / GetDuration 读取。 +func TestSetDurationGetDurationConsistent_Hconfig1(t *testing.T) { + cfg := &config.Config{ + JWT: config.JWTConfig{Secret: strings.Repeat("k", 32), Expire: 24 * time.Hour}, + } + if err := config.Set(cfg); err != nil { + t.Fatalf("Set: %v", err) + } + defer config.SetDefaultManager(nil) + + if got := config.Get().JWT.Expire; got != 24*time.Hour { + t.Errorf("Get().JWT.Expire = %v, want 24h", got) + } + if got := config.GetViper().GetDuration("jwt.expire"); got != 24*time.Hour { + t.Errorf("GetDuration(jwt.expire) = %v, want 24h(Duration 同源应读 GetDuration/typed view)", got) + } +} + +// TestReloadCallbackPanicIsolation_Mconfig1 固化 M-config-1:单个热重载回调 panic 不得 +// 向上传播、不得阻断后续回调、不得让 watcher/reload 链路静默失效。 +func TestReloadCallbackPanicIsolation_Mconfig1(t *testing.T) { + p := writeConfig(t, "m1_panic.yaml", validConfigYAML(8301)) + defer os.Remove(p) + + m := config.NewManager(p) + if _, err := m.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + + called := make(chan int, 4) + m.RegisterCallback(func(*config.Config) { + panic("boom from user callback") // 模拟用户回调 panic + }) + m.RegisterCallback(func(c *config.Config) { + called <- c.Server.Port + }) + + // Reload 不应把回调 panic 向上传播 + if err := m.Reload(); err != nil { + t.Fatalf("Reload 不应传播回调 panic: %v", err) + } + // 第二个回调仍触发:panic 被隔离,未阻断后续回调 + select { + case port := <-called: + if port != 8301 { + t.Errorf("第二个回调端口 = %d, want 8301", port) + } + case <-time.After(time.Second): + t.Fatal("panic 后续回调未触发(M-config-1 隔离失败)") + } + + // 后续 reload 仍正常,watcher/reload 链路未静默失效 + if err := os.WriteFile(p, []byte(validConfigYAML(8302)), 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + if err := m.Reload(); err != nil { + t.Fatalf("panic 后 Reload 应仍可用: %v", err) + } + select { + case port := <-called: + if port != 8302 { + t.Errorf("第二次 reload 回调端口 = %d, want 8302", port) + } + case <-time.After(time.Second): + t.Fatal("panic 后 reload 链路静默失效(M-config-1)") + } +} + +// TestMySQLDSN_TLS_Mconfig2 固化 M-config-2 MySQL TLS:TLS=false 无 tls 参数; +// TLS=true 无 CA 用内置 tls=true;TLS=true 有 CA 用 tls=MySQLTLSConfigName。 +func TestMySQLDSN_TLS_Mconfig2(t *testing.T) { + db := config.DatabaseConfig{Host: "h", Port: 3306, User: "u", Password: "p", Name: "n"} + if dsn := db.MySQLDSN(); strings.Contains(dsn, "tls=") { + t.Errorf("TLS=false 不应含 tls 参数, dsn=%s", dsn) + } + + db.TLS = true + if dsn := db.MySQLDSN(); !strings.Contains(dsn, "&tls=true") { + t.Errorf("TLS=true 无 CA 应用内置 tls=true, dsn=%s", dsn) + } + + db.TLSRootCA = "/path/ca.pem" + if dsn := db.MySQLDSN(); !strings.Contains(dsn, "&tls="+config.MySQLTLSConfigName) { + t.Errorf("TLS=true 有 CA 应用 tls=%s, dsn=%s", config.MySQLTLSConfigName, dsn) + } + + // CustomDSN 优先,TLS 字段不影响 DSN() + db.CustomDSN = "custom-dsn" + if dsn := db.DSN(); dsn != "custom-dsn" { + t.Errorf("CustomDSN 应优先, got %s", dsn) + } +} + +// TestPostgresDSN_SSLMode_Mconfig2 固化 M-config-2 Postgres SSLMode:空默认 prefer; +// 显式值透传(sslmode 不加引号,与原 disable 格式一致)。 +func TestPostgresDSN_SSLMode_Mconfig2(t *testing.T) { + db := config.DatabaseConfig{Driver: config.DriverPostgres, Host: "h", Port: 5432, User: "u", Password: "p", Name: "n"} + if dsn := db.PostgresDSN(); !strings.Contains(dsn, "sslmode=prefer ") { + t.Errorf("空 SSLMode 默认 prefer, dsn=%s", dsn) + } + + for _, mode := range []string{"disable", "allow", "require", "verify-ca", "verify-full"} { + db.SSLMode = mode + if dsn := db.PostgresDSN(); !strings.Contains(dsn, "sslmode="+mode+" ") { + t.Errorf("SSLMode=%s 应透传, dsn=%s", mode, dsn) + } + } +} + +// TestValidateMaxIdleExceedsOpen_Lconfig3 固化 L-config-3:MaxOpenConns>0 时 +// MaxIdleConns>MaxOpenConns 视为配置错误;MaxOpenConns=0(无限)不校验。 +func TestValidateMaxIdleExceedsOpen_Lconfig3(t *testing.T) { + cfg := &config.Config{ + Database: config.DatabaseConfig{ + Host: "h", Port: 3306, User: "u", Password: "p", Name: "n", + MaxOpenConns: 10, MaxIdleConns: 20, + }, + } + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), "max_idle_conns") { + t.Fatalf("MaxIdleConns>MaxOpenConns 应报错, got: %v", err) + } + + cfg.Database.MaxOpenConns = 0 // 未配置/无限,不校验 idle + if err := cfg.Validate(); err != nil { + t.Fatalf("MaxOpenConns=0 时不该校验 idle, got: %v", err) + } + + cfg.Database.MaxOpenConns = 20 // 合法 + if err := cfg.Validate(); err != nil { + t.Fatalf("合法配置不应报错, got: %v", err) + } +} + +// TestValidatePostgresSSLMode_Mconfig2 固化 M-config-2:非法 SSLMode 在 Validate 阶段报错。 +func TestValidatePostgresSSLMode_Mconfig2(t *testing.T) { + cfg := &config.Config{ + Database: config.DatabaseConfig{ + Driver: config.DriverPostgres, Host: "h", Port: 5432, User: "u", Password: "p", Name: "n", + SSLMode: "invalid-mode", + }, + } + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), "ssl_mode") { + t.Fatalf("非法 SSLMode 应报错, got: %v", err) + } + cfg.Database.SSLMode = "require" + if err := cfg.Validate(); err != nil { + t.Fatalf("合法 SSLMode=require 不应报错, got: %v", err) + } +} + +// TestValidateTLSRootCAWithoutTLS_Mconfig2 固化 M-config-2:TLSRootCA 需配合 tls:true。 +func TestValidateTLSRootCAWithoutTLS_Mconfig2(t *testing.T) { + cfg := &config.Config{ + Database: config.DatabaseConfig{ + Host: "h", Port: 3306, User: "u", Password: "p", Name: "n", + TLSRootCA: "/path/ca.pem", // TLS=false + }, + } + err := cfg.Validate() + if err == nil || !strings.Contains(err.Error(), "tls_root_ca") { + t.Fatalf("TLSRootCA 无 tls:true 应报错, got: %v", err) + } + cfg.Database.TLS = true + if err := cfg.Validate(); err != nil { + t.Fatalf("TLS=true + TLSRootCA 合法不应报错, got: %v", err) + } +} + +// TestDSNAddrNilReceiver_Lconfig6 固化 L-config-6:DSN/MySQLDSN/PostgresDSN/Addr 对 nil receiver 安全。 +func TestDSNAddrNilReceiver_Lconfig6(t *testing.T) { + var db *config.DatabaseConfig + if v := db.DSN(); v != "" { + t.Errorf("nil DatabaseConfig.DSN() = %q, want empty", v) + } + if v := db.MySQLDSN(); v != "" { + t.Errorf("nil MySQLDSN() = %q, want empty", v) + } + if v := db.PostgresDSN(); v != "" { + t.Errorf("nil PostgresDSN() = %q, want empty", v) + } + var r *config.RedisConfig + if v := r.Addr(); v != "" { + t.Errorf("nil RedisConfig.Addr() = %q, want empty", v) + } +} + +// TestCloneDeepCopiesAllSliceFields_Mconfig4 固化 M-config-4:反射枚举 Config 所有切片/map 字段, +// 断言 Clone 深拷贝(不同底层数组/map)。fixture 必须填充每个切片字段--新增切片字段若未在 Clone +// 中处理、或未在 fixture 中填充,本测试都会失败,形成机械守卫。 +func TestCloneDeepCopiesAllSliceFields_Mconfig4(t *testing.T) { + original := allSlicesPopulatedConfig() + clone := original.Clone() + + var check func(o, c reflect.Value, path string) + check = func(o, c reflect.Value, path string) { + for o.Kind() == reflect.Pointer { + o = o.Elem() + } + for c.Kind() == reflect.Pointer { + c = c.Elem() + } + if o.Kind() != reflect.Struct { + return + } + for i := 0; i < o.NumField(); i++ { + of := o.Field(i) + cf := c.Field(i) + p := path + "." + o.Type().Field(i).Name + switch of.Kind() { + case reflect.Pointer, reflect.Struct: + check(of, cf, p) + case reflect.Slice: + if of.IsNil() || of.Len() == 0 { + t.Errorf("M-config-4: fixture 未填充切片 %s(新增切片字段须同步 fixture 与 Clone)", p) + continue + } + if cf.IsNil() || cf.Len() != of.Len() { + t.Errorf("M-config-4: Clone 后切片 %s 为 nil 或长度不一致", p) + continue + } + if of.Pointer() == cf.Pointer() { + t.Errorf("M-config-4: Clone 未深拷贝切片 %s(共享底层数组)", p) + } + case reflect.Map: + if of.IsNil() || of.Len() == 0 { + t.Errorf("M-config-4: fixture 未填充 map %s", p) + continue + } + if of.Pointer() == cf.Pointer() { + t.Errorf("M-config-4: Clone 未深拷贝 map %s(共享 map)", p) + } + } + } + } + check(reflect.ValueOf(original), reflect.ValueOf(clone), "Config") +} + +// allSlicesPopulatedConfig 返回一个填充了所有切片字段的 Config,供 Clone 覆盖断言使用。 +// 新增任何切片/map 字段到 Config 或其子结构体时,必须同步在此填充,否则 +// TestCloneDeepCopiesAllSliceFields_Mconfig4 会以"未填充"失败提醒。 +func allSlicesPopulatedConfig() *config.Config { + return &config.Config{ + CORS: config.CORSConfig{ + AllowedOrigins: []string{"https://a.example.com"}, + AllowedMethods: []string{"GET"}, + AllowedHeaders: []string{"X-Test"}, + ExposedHeaders: []string{"X-Exp"}, + }, + Upload: config.UploadConfig{ + AllowedImageTypes: []string{"image/jpeg"}, + AllowedVideoTypes: []string{"video/mp4"}, + }, + Storage: config.StorageConfig{ + Local: config.LocalStorageConfig{ + Upload: config.UploadPolicy{ + AllowedExts: []string{".jpg"}, + AllowedMIMEs: []string{"image/jpeg"}, + }, + }, + OSS: config.OSSStorageConfig{ + Upload: config.UploadPolicy{ + AllowedExts: []string{".png"}, + AllowedMIMEs: []string{"image/png"}, + }, + }, + }, + } +} diff --git a/config/config_test.go b/config/config_test.go index ff8f5e8..bef5051 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -102,7 +102,7 @@ func TestDatabaseConfigPostgresDSN(t *testing.T) { } dsn := db.DSN() - expected := "host='localhost' port=5432 user='postgres' password='password' dbname='testdb' sslmode=disable TimeZone='Asia/Shanghai'" + expected := "host='localhost' port=5432 user='postgres' password='password' dbname='testdb' sslmode=prefer TimeZone='Asia/Shanghai'" if dsn != expected { t.Errorf("Postgres DSN = %s, want %s", dsn, expected) } diff --git a/config/validate.go b/config/validate.go index 461fcb7..0bf1e4e 100644 --- a/config/validate.go +++ b/config/validate.go @@ -56,6 +56,23 @@ func (c *Config) Validate() error { if strings.TrimSpace(c.Database.CustomDSN) == "" && (c.Database.Port <= 0 || c.Database.Port > 65535) { problems = append(problems, fmt.Sprintf("database.port 超出范围(1-65535): %d", c.Database.Port)) } + // L-config-3:连接池配置交叉校验。MaxOpenConns>0 时 MaxIdleConns 不应超过它, + // 否则 database/sql 会按 MaxOpenConns 截断空闲连接,配置意图与实际不符。 + if c.Database.MaxOpenConns > 0 && c.Database.MaxIdleConns > c.Database.MaxOpenConns { + problems = append(problems, fmt.Sprintf( + "database.max_idle_conns(%d) 不应大于 max_open_conns(%d)", c.Database.MaxIdleConns, c.Database.MaxOpenConns)) + } + // M-config-2:Postgres SSLMode 合法性(非空时校验,空由 PostgresDSN 默认 prefer)。 + if sslmode := strings.TrimSpace(c.Database.SSLMode); sslmode != "" { + if !validPostgresSSLMode(sslmode) { + problems = append(problems, fmt.Sprintf( + "database.ssl_mode 非法: %s(允许: disable/allow/prefer/require/verify-ca/verify-full)", sslmode)) + } + } + // M-config-2:TLSRootCA 仅在 TLS=true 时生效,单独配置而无 tls:true 视为配置不一致。 + if strings.TrimSpace(c.Database.TLSRootCA) != "" && !c.Database.TLS { + problems = append(problems, "database.tls_root_ca 需配合 tls: true 才生效") + } } // Redis:仅当配置了 host 时校验 @@ -73,3 +90,12 @@ func (c *Config) Validate() error { // validDuration 校验 Duration 非负(0 表示未配置/用默认,合法)。 func validDuration(d time.Duration) bool { return d >= 0 } + +// validPostgresSSLMode 校验 PostgreSQL sslmode 取值(M-config-2)。 +func validPostgresSSLMode(s string) bool { + switch s { + case "disable", "allow", "prefer", "require", "verify-ca", "verify-full": + return true + } + return false +} diff --git a/database/manager.go b/database/manager.go index 5851be4..201e937 100644 --- a/database/manager.go +++ b/database/manager.go @@ -493,6 +493,12 @@ func (m *Manager) initDB(ctx context.Context, cfg *config.Config) error { Logger: gormlogger.Default.LogMode(gormLogLevel), } + // M-config-2:MySQL 启用 TLS 且配置自定义 CA 时,注册命名 TLS 配置,使 DSN 中 tls= 生效。 + // 失败 fail-fast 返回错误,绝不静默回退明文连接。非 MySQL / 未配 CA 为 no-op。 + if err := ensureMySQLTLSRegistered(cfg); err != nil { + return err + } + // 重试配置 maxRetries := 5 retryDelay := time.Second diff --git a/database/tls.go b/database/tls.go new file mode 100644 index 0000000..fc05376 --- /dev/null +++ b/database/tls.go @@ -0,0 +1,72 @@ +package database + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "os" + "strings" + + "github.com/EthanCodeCraft/xlgo-core/config" + mysqldriver "github.com/go-sql-driver/mysql" +) + +// ensureMySQLTLSRegistered 按 cfg.Database 的 TLS 配置注册命名 TLS 配置到 go-sql-driver/mysql(M-config-2)。 +// +// go-sql-driver/mysql v1.7.0 的 tls DSN 参数语义: +// - tls=true:内置安全(&tls.Config{},系统根 CA + ServerName 自动取自 host + 证书校验),无需注册。 +// - tls=:引用经 RegisterTLSConfig 注册的命名配置,用于私有 CA/自签证书。 +// +// 本函数仅在「TLS=true 且 TLSRootCA 非空」时注册 config.MySQLTLSConfigName 命名配置: +// 加载 TLSRootCA 的 PEM 到 RootCAs,ServerName 取自 Host,MinVersion=TLS1.2。其余情况(TLS 未启用、 +// 或启用但用内置 tls=true)直接返回 nil。 +// +// 非 MySQL 驱动(如 postgres,用 SSLMode)跳过。失败返回错误,由 initDB fail-fast, +// 绝不静默回退明文连接(生产 DB 流量明文是安全风险)。 +// +// 注意:RegisterTLSConfig 是驱动级全局状态——一个名字对应唯一 *tls.Config(含唯一 ServerName), +// 同名重复注册会覆盖前者且不报错,故无法用「同名」同时匹配多个不同 host。 +// +// 覆盖范围(注册配置的 ServerName 固定取自主库 Host): +// - 单 host 集群:replica DSN 的 host 与主库 Host 相同(同机不同端口,或经同一 LB/主机名暴露)—— +// master 与 replica DSN 均用 tls=MySQLTLSConfigName,ServerName 一致,握手通过。 +// - 多 host replicas + 私有 CA:replica DSN 的 host 与主库 Host 不同时,本注册的 ServerName(主库 +// host)与 replica 证书 SAN 不匹配,握手失败。此场景须用户为每个 replica host 注册**不同名**的 +// TLS 配置(各自 ServerName=该 replica host)并自行构造对应 replica DSN(tls=<该名>);框架 +// MySQLDSN() 硬编码 tls=MySQLTLSConfigName,不能用于这些 replica DSN。切勿对 MySQLTLSConfigName +// 同名重复注册——会覆盖主库注册、导致主库握手失败。 +// +// replica 路径(InitDBWithReplicas 的 replicaDSNs)由调用方传原始 DSN,不经本函数注册。 +// 多集群不同 CA 亦属已知多 App 限制,需用户按上述方式自行注册。 +func ensureMySQLTLSRegistered(cfg *config.Config) error { + if cfg == nil { + return nil + } + db := &cfg.Database + if !db.TLS || strings.TrimSpace(db.TLSRootCA) == "" { + return nil + } + // 仅 MySQL 走注册路径;postgres 用 SSLMode,其他驱动自管 TLS。空 driver 默认 MySQL。 + drv := normalizeDriver(db.Driver) + if drv != "" && drv != DriverMySQL { + return nil + } + + pem, err := os.ReadFile(db.TLSRootCA) + if err != nil { + return fmt.Errorf("读取 MySQL TLS CA 文件失败 %q: %w", db.TLSRootCA, err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(pem) { + return fmt.Errorf("解析 MySQL TLS CA 文件失败 %q: 非 PEM 格式或无有效证书", db.TLSRootCA) + } + tlsCfg := &tls.Config{ + RootCAs: pool, + ServerName: db.Host, + MinVersion: tls.VersionTLS12, // 禁用 TLS1.0/1.1,符合安全基线 + } + if err := mysqldriver.RegisterTLSConfig(config.MySQLTLSConfigName, tlsCfg); err != nil { + return fmt.Errorf("注册 MySQL TLS 配置 %q 失败: %w", config.MySQLTLSConfigName, err) + } + return nil +} diff --git a/database/tls_internal_test.go b/database/tls_internal_test.go new file mode 100644 index 0000000..f32d2b0 --- /dev/null +++ b/database/tls_internal_test.go @@ -0,0 +1,146 @@ +package database + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/EthanCodeCraft/xlgo-core/config" + mysqldriver "github.com/go-sql-driver/mysql" +) + +// writeSelfSignedCAPEM 生成一个自签名 CA 证书并写入临时 PEM 文件,返回路径。 +func writeSelfSignedCAPEM(t *testing.T) string { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("rsa.GenerateKey: %v", err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "xlgo-test-ca"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + IsCA: true, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatalf("x509.CreateCertificate: %v", err) + } + p := filepath.Join(os.TempDir(), "xlgo_tls_ca_test.pem") + if err := os.WriteFile(p, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + return p +} + +// TestEnsureMySQLTLSRegistered_NoOpCases 固化 M-config-2:非 MySQL / 未启用 TLS / 无 CA 时 +// ensureMySQLTLSRegistered 为 no-op,不注册、不报错。 +func TestEnsureMySQLTLSRegistered_NoOpCases(t *testing.T) { + if err := ensureMySQLTLSRegistered(nil); err != nil { + t.Errorf("nil cfg 应 no-op, got %v", err) + } + mysqlNoTLS := &config.Config{Database: config.DatabaseConfig{ + Driver: "mysql", Host: "h", Port: 3306, User: "u", Password: "p", Name: "n", + }} + if err := ensureMySQLTLSRegistered(mysqlNoTLS); err != nil { + t.Errorf("TLS=false 应 no-op, got %v", err) + } + mysqlBuiltIn := &config.Config{Database: config.DatabaseConfig{ + Driver: "mysql", Host: "h", Port: 3306, User: "u", Password: "p", Name: "n", + TLS: true, // 无 TLSRootCA,用内置 tls=true + }} + if err := ensureMySQLTLSRegistered(mysqlBuiltIn); err != nil { + t.Errorf("TLS=true 无 CA 应 no-op(内置 tls=true), got %v", err) + } + // postgres 用 SSLMode,不走 MySQL TLS 注册 + pg := &config.Config{Database: config.DatabaseConfig{ + Driver: config.DriverPostgres, Host: "h", Port: 5432, User: "u", Password: "p", Name: "n", + TLS: true, TLSRootCA: "/path/ca.pem", + }} + if err := ensureMySQLTLSRegistered(pg); err != nil { + t.Errorf("postgres 应跳过 MySQL TLS 注册, got %v", err) + } + // 空 driver(默认 MySQL)+ 无 TLS:no-op + emptyDrv := &config.Config{Database: config.DatabaseConfig{ + Host: "h", Port: 3306, User: "u", Password: "p", Name: "n", + }} + if err := ensureMySQLTLSRegistered(emptyDrv); err != nil { + t.Errorf("空 driver 无 TLS 应 no-op, got %v", err) + } +} + +// TestEnsureMySQLTLSRegistered_BadCA 固化 M-config-2:CA 文件不可读或非 PEM 时 fail-fast, +// 绝不静默回退明文连接。 +func TestEnsureMySQLTLSRegistered_BadCA(t *testing.T) { + missing := &config.Config{Database: config.DatabaseConfig{ + Driver: "mysql", Host: "h", Port: 3306, User: "u", Password: "p", Name: "n", + TLS: true, TLSRootCA: "/no/such/ca.pem", + }} + if err := ensureMySQLTLSRegistered(missing); err == nil || !strings.Contains(err.Error(), "读取") { + t.Fatalf("CA 文件不存在应报读取错误, got: %v", err) + } + + nonPEM := filepath.Join(os.TempDir(), "xlgo_tls_nonpem.pem") + if err := os.WriteFile(nonPEM, []byte("this is not a pem file"), 0644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + bad := &config.Config{Database: config.DatabaseConfig{ + Driver: "mysql", Host: "h", Port: 3306, User: "u", Password: "p", Name: "n", + TLS: true, TLSRootCA: nonPEM, + }} + if err := ensureMySQLTLSRegistered(bad); err == nil || !strings.Contains(err.Error(), "解析") { + t.Fatalf("非 PEM 文件应报解析错误, got: %v", err) + } +} + +// TestEnsureMySQLTLSRegistered_ResolvesViaParseDSN 固化 M-config-2 端到端: +// 注册前 ParseDSN(tls=xlgo-mysql) 报 unknown config name;注册后成功解析且 TLS 非 nil。 +// 利用 go-sql-driver/mysql 的 TLS 解析发生在 ParseDSN 的 normalize() 阶段,无需真实 DB 连接。 +func TestEnsureMySQLTLSRegistered_ResolvesViaParseDSN(t *testing.T) { + caPath := writeSelfSignedCAPEM(t) + defer os.Remove(caPath) + + // 清理同名残留注册,保证「注册前」断言不被前序测试污染 + mysqldriver.DeregisterTLSConfig(config.MySQLTLSConfigName) + defer mysqldriver.DeregisterTLSConfig(config.MySQLTLSConfigName) + + cfg := &config.Config{Database: config.DatabaseConfig{ + Driver: "mysql", Host: "127.0.0.1", Port: 3306, User: "u", Password: "p", Name: "n", + TLS: true, TLSRootCA: caPath, + }} + dsn := cfg.Database.DSN() + if !strings.Contains(dsn, "tls="+config.MySQLTLSConfigName) { + t.Fatalf("DSN 应含 tls=%s: %s", config.MySQLTLSConfigName, dsn) + } + + // 注册前:ParseDSN 应报 unknown config name + if _, err := mysqldriver.ParseDSN(dsn); err == nil { + t.Fatal("注册前 ParseDSN 不应成功(未知 TLS 配置名)") + } else if !strings.Contains(err.Error(), "unknown config name") { + t.Logf("注册前 ParseDSN 错误(预期未知配置名): %v", err) + } + + // 注册后:ParseDSN 成功,TLS 已解析为非 nil + if err := ensureMySQLTLSRegistered(cfg); err != nil { + t.Fatalf("ensureMySQLTLSRegistered: %v", err) + } + mc, err := mysqldriver.ParseDSN(dsn) + if err != nil { + t.Fatalf("注册后 ParseDSN 应成功, got: %v", err) + } + if mc.TLS == nil { + t.Error("注册后 ParseDSN 的 TLS 配置应为非 nil(CA 已注册)") + } + if mc.TLS.ServerName != "127.0.0.1" { + t.Errorf("TLS ServerName = %q, want 127.0.0.1", mc.TLS.ServerName) + } +} diff --git a/scan_protocol.md b/scan_protocol.md new file mode 100644 index 0000000..935affe --- /dev/null +++ b/scan_protocol.md @@ -0,0 +1,90 @@ +# 扫描收敛协议(review discipline) + +> 本文件是 CLAUDE.md「开发纪律」的姊妹篇。开发纪律管"怎么写代码",本协议管"怎么审代码"。 +> 起源:多轮大模型全局复审非收敛--每换一个 reviewer 就重新生成一批 nits/功能建议/风格偏好,finding 数永不归零,压迫感持续累积却无关真实质量。本协议把复审收敛到"只产出严重问题"。 + +## 0. 一句话原则 + +**只报可达、真实、严重的缺陷。功能正常、质量良好、无 bug 的代码不报。低于 HIGH 一律不报。** + +## 1. 栅栏:只报 CRITICAL 与 HIGH + +仅当缺陷**同时**满足以下三条才计入交付: + +- **可达**:存在真实调用路径触发(必须给出路径:哪条调用链、什么输入、什么并发时序)。纸上推演"理论上如果有人这么调"不算,必须有真实调用方或合理下游用法。 +- **是缺陷,非设计**:不是已文档化的设计取舍,不是功能缺口,不是已知 footgun。 +- **严重**:达到 CRITICAL 或 HIGH。 + +级别定义: + +- **CRITICAL**:正常使用下可触发的 数据损坏 / 安全突破 / 死锁 / panic / 资源泄漏。 +- **HIGH**:真实(不必常见)路径下的 契约违反 / 并发竞态 / 生命周期泄漏 / 安全弱点,且无优雅降级。 + +低于 HIGH(MEDIUM/LOW)**默认不报**。若某条 MEDIUM 实际后果严重(如静默停摆),应在证伪后上调为 HIGH 再报,而不是以 MEDIUM 身份进清单。 + +## 2. 吹毛求疵禁区(明确不报) + +以下一律**不作为 finding**。可在交付末尾"观察区(非 bug)"单列,但**不计入、不阻塞、不计数**: + +1. **风格 / 格式 / 命名**:`fmt.Errorf` vs `errors.New`、变量命名、注释措辞、import 顺序。 +2. **"无害冗余" / 微优化**:无热路径实测证据的重复调用、可选的零拷贝、可省的临时变量。 +3. **功能缺口 / 未实现能力**:TLS 未加、交叉校验未加、某配置项未暴露--这些是 **roadmap**,不是 bug。归观察区。 +4. **已文档化的设计决策 / footgun**:代码注释或 CLAUDE.md 已声明"刻意如此"的,不重挂。 +5. **不可达路径的防御性补丁**:找不到真实 nil 调用方,就不报"缺 nil-check";找不到触发 panic 的输入,就不报"缺 recover"。防御性编码是好习惯,但不构成缺陷。 +6. **与红线有张力但场景"可接受"**:逐条对照开发纪律判定,若该场景被纪律明文豁免(如 watcher 的 for-range 关闭语义),不报。 +7. **维护性风险但当前无缺陷**:Clone 手动列举字段今天工作正确、某 switch 缺 default 今天不会命中--只有当"未来扩展会漏"已造成**当前**可证缺陷时才报,否则不报。 + +## 3. 证伪门(计入前强制) + +每条 finding 在写入交付前,reviewer 必须主动执行证伪: + +1. **写出触发路径**(具体到调用链 / 输入 / 并发时序)。 +2. **尝试证伪**:路径上有无 guard?调用方是否真实存在?是否已文档化?是否已是已知设计?是否其实有优雅降级? +3. **裁定**:证伪成功 → 丢弃或降级;证伪失败 → 计入。 + +只有**证伪失败**的 finding 进入交付清单。证伪成功的进入"证伪台账"(见 §5),透明留痕但不制造压迫。 + +## 4. 范围规则(防重新生成噪声) + +非收敛的头号成因是"对已加固模块反复全量重扫"--每个新 reviewer 都会重新生成一批 §2 禁区项。规则: + +- **默认扫 diff**:自上次绿灯复审以来的变更,不扫全模块。 +- **已加固模块不重扫**,除非本次改动了它。模块是否"已加固"以最近一份"达标"总评为准。 +- **全模块扫描**只在"首次复审"或"大重构后"做,且必须在报告头标注"首次 / 重构后重审"。 +- **换 reviewer 不算重扫理由**:同一模块换模型复审前,先确认自上次复审有无改动;无改动则拒绝重扫。 + +## 5. 输出格式 + +交付物只含三段,严格物理分隔: + +### 5.1 真 bug 清单(仅 CRITICAL/HIGH,证伪失败) +每条: +- 标题 +- `file:line`(源码唯一证据) +- 触发路径(可达性证明) +- 为何是缺陷(违反哪条契约 / 红线) +- 修复方向(一两句,不展开实现) + +### 5.2 观察区(非 bug,不计入,不阻塞) +功能缺口 / roadmap 建议 / 可选增强。明确标注"非 bug"。用户可忽略。 + +### 5.3 证伪台账(透明留痕) +列出本轮考虑过但丢弃的条目及丢弃理由(落入 §2 哪一条 / 证伪成功)。让用户看到"审过了但拒绝计入",避免"是不是漏审了"的疑虑。 + +**明确不输出**:风格 nits、"无害冗余"、防御性补丁建议、纯维护性担忧。 + +## 6. 成功度量(避免把不可达目标绑在自己身上) + +- ✅ **证伪后剩余的真实 CRITICAL/HIGH 数** -- 可归零,这是唯一硬指标。 +- ✅ **是否出现新 bug 类别** -- 已收敛的类别(多视图状态 / 公开生命周期 / 用户扩展点 / 间接层吞错)再出现同类的实例,属"扫尾",不属"新问题"。 +- ❌ **原始 finding 条数** -- 永不归零,不作度量。任何用"finding 条数下降"衡量进步的做法都是错的。 + +## 7. 给 reviewer 的硬约束(可直接喂入 prompt) + +> 你在审查 xlgo 框架代码。遵守 `scan_protocol.md`: +> 1. 只报 CRITICAL/HIGH,且每条必须给出可达触发路径,否则不报。 +> 2. 风格、冗余、功能缺口、已文档化设计、不可达防御、可接受场景、纯维护性风险--一律不报。 +> 3. 每条 finding 先尝试证伪;证伪成功的丢弃。 +> 4. 交付三段:真 bug 清单 / 观察区(非 bug)/ 证伪台账。 +> 5. 不重扫已加固模块,只扫本次改动 diff(除非报告头标注"首次/重构后")。 +> 6. 若你只能找到 MEDIUM/LOW,直接在证伪台账说明并结束,不要为凑数而上调。