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
+3
View File
@@ -31,6 +31,9 @@
不得把 MySQL 端口向全网开放。项目负责人明确批准公网直连时,只允许固定 `/32`
来源白名单,并同时满足:公网接口其余来源 DROP、账号按来源 IP 创建、最小库权限、
`REQUIRE SSL`、root 仍仅限本机。公网 IP 变化时先更新白名单,不得临时改成 `%`。
- Admin 公网连接必须使用 `database.tls_mode: verify_ca` 和服务器公开 CA:要求
TLS 1.2+、验证证书链、禁止回退明文。`disabled` 只允许同机或 SSH 隧道连接;
不得使用驱动的 `preferred` 或单纯 `skip-verify`。
- Windows 本地运行允许把 MySQL 账号密码写入已被 Git 忽略的 `admin/config.yaml`;
线上部署仍优先使用权限为 `600` 的环境文件。`CMAUTOBUY_DB_*` 环境变量按字段
覆盖 YAML,真实 `config.yaml` 不得提交、打包、截图或复制到工单和日志。
+19
View File
@@ -0,0 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDBDCCAeygAwIBAgIBATANBgkqhkiG9w0BAQsFADA7MTkwNwYDVQQDDDBNeVNR
TF9TZXJ2ZXJfOC40LjhfQXV0b19HZW5lcmF0ZWRfQ0FfQ2VydGlmaWNhdGUwHhcN
MjYwNzA4MDExMzA1WhcNMzYwNzA1MDExMzA1WjA7MTkwNwYDVQQDDDBNeVNRTF9T
ZXJ2ZXJfOC40LjhfQXV0b19HZW5lcmF0ZWRfQ0FfQ2VydGlmaWNhdGUwggEiMA0G
CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCq06CBhNdZk4/qSY7VnwZEI0JRFuQO
V5sNxi8NtoEQeMVkeyd9LmRkNMWOE3NiHinjN7n9ZMJYZ3+8a0kBdlqbImVZ1c0S
ND+E5HSvwdnjr4wSPZLE6+f5lrY4zMbfL7hTTuROWY7A3Zq+Q4m97fe5Lif4IAHU
wpeI9gkx3VNCPOqjETmkFrzz+vyh9OcEW95wpPPj5H36eJhgJsDSvokzvjkLKcw7
jnoBT9iOZ2WvixpI7fBJW08NYKx1twE9wCAna243pmmGY9oPudhqq8bYbOi6yF4g
turWY4eyLKzDGuISJTUCezJQR2TE1uBHtrXwcXpvCFUszBCFYv/ALprTAgMBAAGj
EzARMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAGcgeu5S27ML
DEmfIxDZJU9HovOnLdHwR8fPyhU3DbwxQOsfQkYDXzalTABNqqUHSbVtjlHP1PLW
yyJ2/Qc7PisgzPieROXEzscbYLOfeySsIleBKufUDLAYuHQZ1pwX2iSxLGJPTnhi
fAvAscp893eeCo0EvQ3C/KcRPCp8wb5loFmiKWLCqnhjN8ErJtbfiNLhKhePO+sk
UWGNUmXzUI49l/2IL6HwzSNh9CpAjDQBQWvy0Ag5qRv4tK+G9KjoJQgz39RT4H5l
l0c3R8D02zrKcyyGB4afqGU8p7xuM+J8JnadjEydgZB1W3TTxE++J6Eaa2dOC6k7
Wbbe3APyKNA=
-----END CERTIFICATE-----
+5
View File
@@ -25,6 +25,11 @@ database:
name: autobuy
user: buy
# 本机/SSH 隧道使用 disabled。公网直连必须使用 verify_ca,加载服务器公开 CA,
# 强制 TLS 1.2+ 并验证证书链,连接失败时不会退回明文。
tls_mode: disabled
tls_ca: certs/mysql84-ca.pem
# `[必须]` 数据库密码也是明文配置,必须加引号;config.yaml 只能留在
# 运行机器上,不得提交、打包、截图或粘贴到工单和日志。
password: "你的 MySQL 密码"
+26 -2
View File
@@ -29,6 +29,13 @@ const (
databaseNameEnv = "CMAUTOBUY_DB_NAME"
databaseUserEnv = "CMAUTOBUY_DB_USER"
databasePasswordEnv = "CMAUTOBUY_DB_PASSWORD"
databaseTLSModeEnv = "CMAUTOBUY_DB_TLS_MODE"
databaseTLSCAEnv = "CMAUTOBUY_DB_TLS_CA"
)
const (
DatabaseTLSDisabled = "disabled"
DatabaseTLSVerifyCA = "verify_ca"
)
// DatabaseConfig 是 MySQL 8 的连接配置。
@@ -41,6 +48,8 @@ type DatabaseConfig struct {
Name string `yaml:"name"`
User string `yaml:"user"`
Password string `yaml:"password"`
TLSMode string `yaml:"tls_mode"`
TLSCA string `yaml:"tls_ca"`
}
// String 永远隐藏密码,防止排错时用 %v 把凭据写进日志。
@@ -49,8 +58,8 @@ func (c DatabaseConfig) String() string {
if c.Password != "" {
password = "****"
}
return fmt.Sprintf("DatabaseConfig{Host:%s Port:%s Name:%s User:%s Password:%s}",
c.Host, c.Port, c.Name, c.User, password)
return fmt.Sprintf("DatabaseConfig{Host:%s Port:%s Name:%s User:%s Password:%s TLSMode:%s TLSCA:%s}",
c.Host, c.Port, c.Name, c.User, password, c.TLSMode, c.TLSCA)
}
// LoadDatabase 读取 MySQL 配置。config.yaml 适合本地双击 run_admin.bat;
@@ -98,6 +107,8 @@ func mergeDatabaseConfig(cfg DatabaseConfig) (DatabaseConfig, error) {
databaseNameEnv: &cfg.Name,
databaseUserEnv: &cfg.User,
databasePasswordEnv: &cfg.Password,
databaseTLSModeEnv: &cfg.TLSMode,
databaseTLSCAEnv: &cfg.TLSCA,
}
for envName, target := range overrides {
value := os.Getenv(envName)
@@ -113,12 +124,17 @@ func mergeDatabaseConfig(cfg DatabaseConfig) (DatabaseConfig, error) {
cfg.Port = strings.TrimSpace(cfg.Port)
cfg.Name = strings.TrimSpace(cfg.Name)
cfg.User = strings.TrimSpace(cfg.User)
cfg.TLSMode = strings.ToLower(strings.TrimSpace(cfg.TLSMode))
cfg.TLSCA = strings.TrimSpace(cfg.TLSCA)
if cfg.Host == "" {
cfg.Host = "127.0.0.1"
}
if cfg.Port == "" {
cfg.Port = "3307"
}
if cfg.TLSMode == "" {
cfg.TLSMode = DatabaseTLSDisabled
}
var missing []string
if cfg.Name == "" {
missing = append(missing, "database.name / "+databaseNameEnv)
@@ -129,9 +145,17 @@ func mergeDatabaseConfig(cfg DatabaseConfig) (DatabaseConfig, error) {
if cfg.Password == "" {
missing = append(missing, "database.password / "+databasePasswordEnv)
}
if cfg.TLSMode == DatabaseTLSVerifyCA && cfg.TLSCA == "" {
missing = append(missing, "database.tls_ca / "+databaseTLSCAEnv)
}
if len(missing) > 0 {
return DatabaseConfig{}, fmt.Errorf("缺少 MySQL 配置:%s", strings.Join(missing, "、"))
}
if cfg.TLSMode != DatabaseTLSDisabled && cfg.TLSMode != DatabaseTLSVerifyCA {
return DatabaseConfig{}, fmt.Errorf(
"不支持的 MySQL TLS 模式 %q:database.tls_mode / %s 只能是 %s 或 %s",
cfg.TLSMode, databaseTLSModeEnv, DatabaseTLSDisabled, DatabaseTLSVerifyCA)
}
return cfg, nil
}
+63
View File
@@ -45,6 +45,8 @@ database:
name: autobuy
user: buy
password: "yaml-secret"
tls_mode: verify_ca
tls_ca: certs/mysql84-ca.pem
`)
cfg, err := loadDatabaseFromPath(path)
@@ -57,6 +59,9 @@ database:
if cfg.Password != "yaml-secret" {
t.Fatal("YAML 数据库密码没有原样读出")
}
if cfg.TLSMode != DatabaseTLSVerifyCA || cfg.TLSCA != "certs/mysql84-ca.pem" {
t.Fatalf("YAML TLS 配置读取错误:%s", cfg)
}
}
func TestLoadDatabaseFromPath_环境变量按字段覆盖YAML(t *testing.T) {
@@ -101,6 +106,62 @@ func TestLoadDatabaseFromPath_文件不存在时完整环境变量仍可启动(t
if cfg.Host != "127.0.0.1" || cfg.Port != "3307" {
t.Fatalf("线上环境变量模式应该保留安全默认地址,实际:%s", cfg)
}
if cfg.TLSMode != DatabaseTLSDisabled {
t.Fatalf("未配置 TLS 时应该保持本机连接兼容,实际:%s", cfg)
}
}
func TestLoadDatabaseFromPath_TLS环境变量覆盖YAML(t *testing.T) {
clearDatabaseEnv(t)
t.Setenv(databaseTLSModeEnv, DatabaseTLSVerifyCA)
t.Setenv(databaseTLSCAEnv, `D:\certs\mysql-ca.pem`)
path := writeConfigFile(t, `
database:
name: autobuy
user: buy
password: "yaml-secret"
tls_mode: disabled
`)
cfg, err := loadDatabaseFromPath(path)
if err != nil {
t.Fatal(err)
}
if cfg.TLSMode != DatabaseTLSVerifyCA || cfg.TLSCA != `D:\certs\mysql-ca.pem` {
t.Fatalf("TLS 环境变量没有覆盖 YAML:%s", cfg)
}
}
func TestLoadDatabaseFromPath_TLS模式校验(t *testing.T) {
clearDatabaseEnv(t)
path := writeConfigFile(t, `
database:
name: autobuy
user: buy
password: "yaml-secret"
tls_mode: preferred
`)
_, err := loadDatabaseFromPath(path)
if err == nil || !strings.Contains(err.Error(), "只能是 disabled 或 verify_ca") {
t.Fatalf("应该拒绝可能回退明文的 TLS 模式,实际:%v", err)
}
}
func TestLoadDatabaseFromPath_VerifyCA必须配置证书(t *testing.T) {
clearDatabaseEnv(t)
path := writeConfigFile(t, `
database:
name: autobuy
user: buy
password: "yaml-secret"
tls_mode: verify_ca
`)
_, err := loadDatabaseFromPath(path)
if err == nil || !strings.Contains(err.Error(), databaseTLSCAEnv) {
t.Fatalf("verify_ca 缺少 CA 时应该明确报错,实际:%v", err)
}
}
func clearDatabaseEnv(t *testing.T) {
@@ -111,6 +172,8 @@ func clearDatabaseEnv(t *testing.T) {
databaseNameEnv,
databaseUserEnv,
databasePasswordEnv,
databaseTLSModeEnv,
databaseTLSCAEnv,
} {
t.Setenv(name, "")
}
+42
View File
@@ -0,0 +1,42 @@
package main
import (
"net/http"
"net/http/httptest"
"os"
"testing"
"cmautobuy/admin/config"
"cmautobuy/admin/repository"
)
// TestAdmin_公网MySQLTLS页面冒烟走和 main 相同的数据库及路由组装路径。
// 只有显式开启时才连接线上 autobuy;测试只追加 schema migration,不清理业务表。
func TestAdmin_公网MySQLTLS页面冒烟(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)
}
db, err := repository.OpenMySQL(cfg)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err := repository.MigrateMySQL(db); err != nil {
t.Fatal(err)
}
router, err := newRouter(db)
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodGet, "/", nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code < 200 || response.Code >= 400 {
t.Fatalf("首页状态码=%d,期望成功或重定向", response.Code)
}
}
+76 -17
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 cfg.TLSMode == config.DatabaseTLSVerifyCA {
tlsConfig, err := loadMySQLTLSConfig(cfg.TLSCA)
if err != nil {
return nil, fmt.Errorf("准备 MySQL 连接失败: %w", err)
}
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 失败,请检查服务、库名和环境变量: %w", err)
}
if err := CheckMySQLServer(db, cfg.Name); err != nil {
db.Close()
return nil, err
}
return db, nil
driverConfig.TLS = tlsConfig
}
return driverConfig, nil
}
// 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)
}
roots := x509.NewCertPool()
if !roots.AppendCertsFromPEM(pemData) {
return nil, fmt.Errorf("解析 MySQL TLS CA 文件 %s 失败: 文件中没有有效 PEM 证书", caPath)
}
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 的连接。
+103
View File
@@ -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)
}
}
+13 -4
View File
@@ -174,13 +174,20 @@ database:
port: "3307"
name: autobuy
user: buy
tls_mode: disabled
tls_ca: certs/mysql84-ca.pem
password: "从安全渠道取得"
```
Admin 本地开发连接线上 MySQL 时默认仍通过 SSH 隧道,把 `port` 改为隧道的本地
端口。数据库客户端确需公网直连时,只能在项目负责人明确批准后采用固定 `/32`
来源白名单、来源限定账号、最小权限和强制 SSL,绝不能向全网开放。线上 systemd
部署继续使用环境变量:
来源白名单、来源限定账号、最小权限和强制 SSL,绝不能向全网开放。
- 同机或 SSH 隧道:`tls_mode: disabled`;
- 公网直连:`tls_mode: verify_ca`,`tls_ca` 指向服务器公开 CA。客户端要求 TLS 1.2+
并校验证书链,失败时不会回退明文。CA 证书可以公开,私钥绝不能复制到客户端。
线上 systemd 部署继续使用环境变量:
```text
CMAUTOBUY_DB_HOST=127.0.0.1
@@ -188,11 +195,13 @@ CMAUTOBUY_DB_PORT=3307
CMAUTOBUY_DB_NAME=<独立数据库名>
CMAUTOBUY_DB_USER=<最小权限账号>
CMAUTOBUY_DB_PASSWORD=<从安全渠道取得>
CMAUTOBUY_DB_TLS_MODE=disabled
CMAUTOBUY_DB_TLS_CA=<verify_ca 模式下的 CA 路径>
```
环境变量按字段覆盖 `config.yaml`,所以现有线上部署方式不受影响。生产环境文件和
本地 `config.yaml` 都不得进入仓库、日志或工单;线上 MySQL 只监听本机,本地开发
通过 SSH 隧道连接独立的 `_test` 数据库。
本地 `config.yaml` 都不得进入仓库、日志或工单;本地自动化测试仍通过 SSH 隧道
连接独立的 `_test` 数据库,不得对生产 `autobuy` 执行清理测试。
程序旁边的 `data/` 只保留日志、上传文件和迁移前的 SQLite 只读备份:
+5
View File
@@ -173,6 +173,11 @@ Git 忽略且不得随程序打包、截图或粘贴到工单和日志;线上
固定 `/32` 来源白名单、公网接口其余来源 DROP、来源 IP 限定账号、最小库权限和
`REQUIRE SSL`;root 始终仅限服务器本机。
`[必须]` Admin 公网连接使用 `database.tls_mode: verify_ca`:最低 TLS 1.2,加载
服务器公开 CA 并验证证书链,验证失败即拒绝连接,不得使用允许明文回退的
`preferred`。服务器自动证书没有 SAN,客户端只信任该服务器的专属 CA,不做公网
IP 主机名匹配;CA 轮换时必须同步更新客户端公开证书。
## 7. 性能
规模很小(个位数操作员、集中部署),不要提前优化。但下面几条是基本功: