feat: 支持 MySQL 公网 TLS CA 校验 (#86)

This commit is contained in:
chengma
2026-08-10 09:59:09 +08:00
parent 830ca58030
commit f0715ee273
10 changed files with 354 additions and 22 deletions
+75 -16
View File
@@ -2,9 +2,12 @@ package repository
import (
"context"
"crypto/tls"
"crypto/x509"
"database/sql"
"fmt"
"net"
"os"
"strings"
"time"
@@ -17,6 +20,37 @@ const mysqlSchemaVersion = 2
// OpenMySQL 打开生产 MySQL 8 数据库。错误信息绝不包含完整 DSN 或密码。
func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) {
driverConfig, err := newMySQLDriverConfig(cfg)
if err != nil {
return nil, err
}
connector, err := mysql.NewConnector(driverConfig)
if err != nil {
return nil, fmt.Errorf("准备 MySQL 连接失败: %w", err)
}
db := sql.OpenDB(connector)
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(3 * time.Minute)
db.SetConnMaxIdleTime(time.Minute)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
db.Close()
return nil, fmt.Errorf("连接 MySQL 失败,请检查服务、库名、config.yaml、环境变量和 TLS 配置: %w", err)
}
if err := CheckMySQLServer(db, cfg.Name); err != nil {
db.Close()
return nil, err
}
return db, nil
}
// newMySQLDriverConfig 只负责把业务配置转换成驱动配置,方便单元测试在不连接
// 数据库的情况下核对 TLS 是否真的开启。
func newMySQLDriverConfig(cfg config.DatabaseConfig) (*mysql.Config, error) {
driverConfig := mysql.NewConfig()
driverConfig.User = cfg.User
driverConfig.Passwd = cfg.Password
@@ -36,26 +70,51 @@ func OpenMySQL(cfg config.DatabaseConfig) (*sql.DB, error) {
"sql_mode": "'STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'",
}
db, err := sql.Open("mysql", driverConfig.FormatDSN())
if err != nil {
return nil, fmt.Errorf("准备 MySQL 连接失败: %w", err)
if cfg.TLSMode == config.DatabaseTLSVerifyCA {
tlsConfig, err := loadMySQLTLSConfig(cfg.TLSCA)
if err != nil {
return nil, err
}
driverConfig.TLS = tlsConfig
}
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(3 * time.Minute)
db.SetConnMaxIdleTime(time.Minute)
return driverConfig, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
db.Close()
return nil, fmt.Errorf("连接 MySQL 失败,请检查服务、库名和环境变量: %w", err)
// loadMySQLTLSConfig 加载服务器专属 CA,并验证服务端证书确实由它签发。
// MySQL 自动生成的服务端证书没有 SAN,Go 无法做 IP/域名匹配;这里显式跳过
// 内置主机名检查,但用 VerifyConnection 恢复证书链校验,不能退回明文连接。
func loadMySQLTLSConfig(caPath string) (*tls.Config, error) {
pemData, err := os.ReadFile(caPath)
if err != nil {
return nil, fmt.Errorf("读取 MySQL TLS CA 文件 %s 失败: %w", caPath, err)
}
if err := CheckMySQLServer(db, cfg.Name); err != nil {
db.Close()
return nil, err
roots := x509.NewCertPool()
if !roots.AppendCertsFromPEM(pemData) {
return nil, fmt.Errorf("解析 MySQL TLS CA 文件 %s 失败: 文件中没有有效 PEM 证书", caPath)
}
return db, nil
return &tls.Config{
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: true, // 主机名检查由下面的服务器专属 CA 链校验替代。
VerifyConnection: func(state tls.ConnectionState) error {
if len(state.PeerCertificates) == 0 {
return fmt.Errorf("验证 MySQL TLS 证书失败: 服务端没有提供证书")
}
intermediates := x509.NewCertPool()
for _, certificate := range state.PeerCertificates[1:] {
intermediates.AddCert(certificate)
}
_, err := state.PeerCertificates[0].Verify(x509.VerifyOptions{
Roots: roots,
Intermediates: intermediates,
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
})
if err != nil {
return fmt.Errorf("验证 MySQL TLS 证书链失败: %w", err)
}
return nil
},
}, nil
}
// CheckMySQLServer 拒绝错误版本、错误库、非 UTC 或非 utf8mb4 的连接。