diff --git a/admin/AGENTS.md b/admin/AGENTS.md index ed56187..43b3936 100644 --- a/admin/AGENTS.md +++ b/admin/AGENTS.md @@ -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` 不得提交、打包、截图或复制到工单和日志。 diff --git a/admin/certs/mysql84-ca.pem b/admin/certs/mysql84-ca.pem new file mode 100644 index 0000000..1f82116 --- /dev/null +++ b/admin/certs/mysql84-ca.pem @@ -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----- diff --git a/admin/config.example.yaml b/admin/config.example.yaml index aa0eb65..81e3f08 100644 --- a/admin/config.example.yaml +++ b/admin/config.example.yaml @@ -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 密码" diff --git a/admin/config/config.go b/admin/config/config.go index 8250ef4..c3e25e2 100644 --- a/admin/config/config.go +++ b/admin/config/config.go @@ -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 } diff --git a/admin/config/config_test.go b/admin/config/config_test.go index e96df17..f3033de 100644 --- a/admin/config/config_test.go +++ b/admin/config/config_test.go @@ -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, "") } diff --git a/admin/mysql_public_tls_smoke_test.go b/admin/mysql_public_tls_smoke_test.go new file mode 100644 index 0000000..375da47 --- /dev/null +++ b/admin/mysql_public_tls_smoke_test.go @@ -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) + } +} diff --git a/admin/repository/mysql_db.go b/admin/repository/mysql_db.go index a5436b4..1571078 100644 --- a/admin/repository/mysql_db.go +++ b/admin/repository/mysql_db.go @@ -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 的连接。 diff --git a/admin/repository/mysql_db_tls_test.go b/admin/repository/mysql_db_tls_test.go new file mode 100644 index 0000000..e4af6cb --- /dev/null +++ b/admin/repository/mysql_db_tls_test.go @@ -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) + } +} diff --git a/docs/admin/00-getting-started.md b/docs/admin/00-getting-started.md index fbd1980..f2c40fd 100644 --- a/docs/admin/00-getting-started.md +++ b/docs/admin/00-getting-started.md @@ -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= ``` 环境变量按字段覆盖 `config.yaml`,所以现有线上部署方式不受影响。生产环境文件和 -本地 `config.yaml` 都不得进入仓库、日志或工单;线上 MySQL 只监听本机,本地开发 -通过 SSH 隧道连接独立的 `_test` 数据库。 +本地 `config.yaml` 都不得进入仓库、日志或工单;本地自动化测试仍通过 SSH 隧道 +连接独立的 `_test` 数据库,不得对生产 `autobuy` 执行清理测试。 程序旁边的 `data/` 只保留日志、上传文件和迁移前的 SQLite 只读备份: diff --git a/docs/admin/06-quality-security.md b/docs/admin/06-quality-security.md index da3ffaf..602fb87 100644 --- a/docs/admin/06-quality-security.md +++ b/docs/admin/06-quality-security.md @@ -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. 性能 规模很小(个位数操作员、集中部署),不要提前优化。但下面几条是基本功: