feat: 支持 MySQL 公网 TLS CA 校验 (#86)
This commit is contained in:
@@ -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 的连接。
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cmautobuy/admin/config"
|
||||
)
|
||||
|
||||
func TestOpenMySQL_公网TLS只读冒烟(t *testing.T) {
|
||||
if os.Getenv("CMAUTOBUY_MYSQL_PUBLIC_TLS_TEST") != "1" {
|
||||
t.Skip("未启用公网 MySQL TLS 冒烟")
|
||||
}
|
||||
cfg, err := config.LoadDatabaseFromEnv()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.TLSMode != config.DatabaseTLSVerifyCA {
|
||||
t.Fatalf("公网冒烟必须使用 verify_ca,实际 %q", cfg.TLSMode)
|
||||
}
|
||||
db, err := OpenMySQL(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
var variableName, cipher string
|
||||
if err := db.QueryRow(`SHOW STATUS LIKE 'Ssl_cipher'`).Scan(&variableName, &cipher); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cipher == "" {
|
||||
t.Fatal("公网 MySQL 连接没有协商 TLS cipher")
|
||||
}
|
||||
t.Logf("公网 MySQL TLS 已启用:%s", cipher)
|
||||
}
|
||||
|
||||
func TestNewMySQLDriverConfig_VerifyCA启用TLS且不回退明文(t *testing.T) {
|
||||
cfg := config.DatabaseConfig{
|
||||
Host: "185.216.248.75",
|
||||
Port: "3307",
|
||||
Name: "autobuy_test",
|
||||
User: "buy",
|
||||
Password: "test-secret",
|
||||
TLSMode: config.DatabaseTLSVerifyCA,
|
||||
TLSCA: filepath.Join("..", "certs", "mysql84-ca.pem"),
|
||||
}
|
||||
|
||||
driverConfig, err := newMySQLDriverConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if driverConfig.TLS == nil {
|
||||
t.Fatal("verify_ca 必须启用 TLS")
|
||||
}
|
||||
if driverConfig.TLS.MinVersion != tls.VersionTLS12 {
|
||||
t.Fatalf("TLS 最低版本=%d,期望 TLS 1.2", driverConfig.TLS.MinVersion)
|
||||
}
|
||||
if driverConfig.TLS.VerifyConnection == nil {
|
||||
t.Fatal("verify_ca 必须安装证书链校验回调")
|
||||
}
|
||||
if driverConfig.AllowFallbackToPlaintext {
|
||||
t.Fatal("公网 TLS 不得回退明文")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewMySQLDriverConfig_Disabled保持本机连接兼容(t *testing.T) {
|
||||
cfg := config.DatabaseConfig{
|
||||
Host: "127.0.0.1",
|
||||
Port: "3307",
|
||||
Name: "autobuy_test",
|
||||
User: "buy",
|
||||
Password: "test-secret",
|
||||
TLSMode: config.DatabaseTLSDisabled,
|
||||
}
|
||||
|
||||
driverConfig, err := newMySQLDriverConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if driverConfig.TLS != nil {
|
||||
t.Fatal("disabled 不应改变现有本机连接")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewMySQLDriverConfig_CA文件损坏时报明确错误(t *testing.T) {
|
||||
caPath := filepath.Join(t.TempDir(), "broken-ca.pem")
|
||||
if err := os.WriteFile(caPath, []byte("not a certificate"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := config.DatabaseConfig{
|
||||
Host: "185.216.248.75", Port: "3307", Name: "autobuy_test",
|
||||
User: "buy", Password: "test-secret",
|
||||
TLSMode: config.DatabaseTLSVerifyCA, TLSCA: caPath,
|
||||
}
|
||||
|
||||
_, err := newMySQLDriverConfig(cfg)
|
||||
if err == nil || !strings.Contains(err.Error(), "没有有效 PEM 证书") {
|
||||
t.Fatalf("损坏 CA 应该返回明确错误,实际:%v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user