fix(config): 模块 01 评审修复 + 对抗复核
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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=<name> 生效。
|
||||
// 失败 fail-fast 返回错误,绝不静默回退明文连接。非 MySQL / 未配 CA 为 no-op。
|
||||
if err := ensureMySQLTLSRegistered(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 重试配置
|
||||
maxRetries := 5
|
||||
retryDelay := time.Second
|
||||
|
||||
@@ -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=<name>:引用经 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user