feat: 支持 MySQL 公网 TLS CA 校验 (#86)
This commit is contained in:
@@ -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` 不得提交、打包、截图或复制到工单和日志。
|
||||
|
||||
@@ -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-----
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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, "")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 只读备份:
|
||||
|
||||
|
||||
@@ -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. 性能
|
||||
|
||||
规模很小(个位数操作员、集中部署),不要提前优化。但下面几条是基本功:
|
||||
|
||||
Reference in New Issue
Block a user