完善外部数据库凭据隔离与写入验证
This commit is contained in:
parent
2e1f654513
commit
1bb0199891
@ -65,27 +65,31 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("初始化日志失败:%w", err)
|
||||
}
|
||||
db, err := database.Open(ctx, cfg.Database)
|
||||
if err != nil {
|
||||
_ = logger.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := database.Migrate(ctx, db.DB, cfg.Database.Driver); err != nil {
|
||||
_ = db.Close()
|
||||
_ = logger.Close()
|
||||
return nil, err
|
||||
}
|
||||
secretStore, err := secretstore.Open(cfg.Paths.Config)
|
||||
if err != nil {
|
||||
_ = db.Close()
|
||||
_ = logger.Close()
|
||||
return nil, fmt.Errorf("初始化敏感信息存储失败:%w", err)
|
||||
}
|
||||
runtimeDatabaseConfig, err := database.ResolveConfig(ctx, cfg.Database, secretStore)
|
||||
if err != nil {
|
||||
_ = logger.Close()
|
||||
return nil, err
|
||||
}
|
||||
db, err := database.Open(ctx, runtimeDatabaseConfig)
|
||||
if err != nil {
|
||||
_ = logger.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := database.Migrate(ctx, db.DB, runtimeDatabaseConfig.Driver); err != nil {
|
||||
_ = db.Close()
|
||||
_ = logger.Close()
|
||||
return nil, err
|
||||
}
|
||||
dnsTester := dnsprovider.NewTester(dnsprovider.NewLegoFactory())
|
||||
dnsVerification := setup.NewDNSVerificationManager(dnsTester, secretStore)
|
||||
repo := repository.NewSQLDomainRepository(db.DB, cfg.Database.Driver)
|
||||
serverRepo := repository.NewSQLServerRepository(db.DB, cfg.Database.Driver)
|
||||
userRepo := repository.NewSQLUserRepository(db.DB, cfg.Database.Driver)
|
||||
repo := repository.NewSQLDomainRepository(db.DB, runtimeDatabaseConfig.Driver)
|
||||
serverRepo := repository.NewSQLServerRepository(db.DB, runtimeDatabaseConfig.Driver)
|
||||
userRepo := repository.NewSQLUserRepository(db.DB, runtimeDatabaseConfig.Driver)
|
||||
if cfg.SetupReady && cfg.Admin.Username != "" && cfg.Admin.PasswordHash != "" {
|
||||
if err := userRepo.EnsureSuperAdmin(ctx, repository.User{ID: "primary-super-admin", Username: cfg.Admin.Username, Email: cfg.Admin.Email, PasswordHash: cfg.Admin.PasswordHash}); err != nil {
|
||||
_ = db.Close()
|
||||
@ -93,7 +97,7 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string
|
||||
return nil, fmt.Errorf("初始化超级管理员用户失败:%w", err)
|
||||
}
|
||||
}
|
||||
taskRepo := repository.NewSQLTaskRepository(db.DB, cfg.Database.Driver)
|
||||
taskRepo := repository.NewSQLTaskRepository(db.DB, runtimeDatabaseConfig.Driver)
|
||||
if recovered, err := taskRepo.RecoverInterruptedTasks(ctx, time.Now().UTC()); err != nil {
|
||||
_ = db.Close()
|
||||
_ = logger.Close()
|
||||
@ -101,7 +105,7 @@ func NewWithConfigPath(ctx context.Context, cfg config.Config, configPath string
|
||||
} else if recovered > 0 {
|
||||
logger.Warn("\u670d\u52a1\u91cd\u542f\u524d\u672a\u5b8c\u6210\u4efb\u52a1\u5df2\u6807\u8bb0\u4e3a\u5931\u8d25", "count", recovered)
|
||||
}
|
||||
auditRepo := repository.NewSQLAuditRepository(db.DB, cfg.Database.Driver)
|
||||
auditRepo := repository.NewSQLAuditRepository(db.DB, runtimeDatabaseConfig.Driver)
|
||||
if deleted, err := auditRepo.PruneExpiredAuditLogs(ctx, time.Now().UTC().AddDate(0, 0, -auditLogRetentionDays)); err != nil {
|
||||
logger.Warn("启动时清理过期审计日志失败", "error", err)
|
||||
} else if deleted > 0 {
|
||||
|
||||
@ -478,6 +478,21 @@ func (c Config) Validate() error {
|
||||
if strings.TrimSpace(c.Database.DSN) == "" {
|
||||
return fmt.Errorf("数据库连接信息不能为空")
|
||||
}
|
||||
if c.Database.Driver == "sqlite" {
|
||||
if strings.TrimSpace(c.Database.CredentialRef) != "" {
|
||||
return fmt.Errorf("SQLite 不允许配置数据库凭据引用")
|
||||
}
|
||||
} else {
|
||||
if strings.TrimSpace(c.Database.CredentialRef) == "" {
|
||||
return fmt.Errorf("外部数据库必须配置加密凭据引用")
|
||||
}
|
||||
if !strings.HasPrefix(strings.TrimSpace(c.Database.CredentialRef), "secret://database/") {
|
||||
return fmt.Errorf("数据库凭据引用必须使用 secret://database/ 前缀")
|
||||
}
|
||||
if databaseDSNContainsPassword(c.Database.Driver, c.Database.DSN) {
|
||||
return fmt.Errorf("数据库连接信息不能包含密码,请使用加密凭据引用")
|
||||
}
|
||||
}
|
||||
if c.Database.MaxOpenConns < 1 || c.Database.MaxIdleConns < 0 || c.Database.MaxIdleConns > c.Database.MaxOpenConns {
|
||||
return fmt.Errorf("数据库连接池配置无效")
|
||||
}
|
||||
@ -603,6 +618,38 @@ func (c Config) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func databaseDSNContainsPassword(driver, dsn string) bool {
|
||||
driver = strings.ToLower(strings.TrimSpace(driver))
|
||||
dsn = strings.TrimSpace(dsn)
|
||||
if dsn == "" {
|
||||
return false
|
||||
}
|
||||
switch driver {
|
||||
case "mysql":
|
||||
at := strings.Index(dsn, "@")
|
||||
if at < 0 {
|
||||
return false
|
||||
}
|
||||
credentials := dsn[:at]
|
||||
return strings.Contains(credentials, ":")
|
||||
case "postgres":
|
||||
if parsed, err := url.Parse(dsn); err == nil && (parsed.Scheme == "postgres" || parsed.Scheme == "postgresql") {
|
||||
if parsed.User == nil {
|
||||
return false
|
||||
}
|
||||
_, present := parsed.User.Password()
|
||||
return present
|
||||
}
|
||||
for _, field := range strings.Fields(dsn) {
|
||||
key, _, found := strings.Cut(field, "=")
|
||||
if found && strings.EqualFold(strings.TrimSpace(key), "password") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ResetURL 返回通过主 Web 服务访问密码重置入口的公开地址。
|
||||
func (c Config) ResetURL() string {
|
||||
rawHost := strings.TrimSpace(c.Web.Hostname)
|
||||
|
||||
@ -78,6 +78,41 @@ func TestValidateRejectsInvalidPort(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateProtectsExternalDatabasePasswords(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
edit func(*Config)
|
||||
}{
|
||||
{
|
||||
name: "MySQL password in DSN",
|
||||
edit: func(cfg *Config) {
|
||||
cfg.Database = DatabaseConfig{Driver: "mysql", DSN: "user:password@tcp(db.example.test:3306)/scert", CredentialRef: "secret://database/mysql"}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "PostgreSQL password in DSN",
|
||||
edit: func(cfg *Config) {
|
||||
cfg.Database = DatabaseConfig{Driver: "postgres", DSN: "postgres://user:password@db.example.test:5432/scert?sslmode=verify-full", CredentialRef: "secret://database/postgres"}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing external database credential reference",
|
||||
edit: func(cfg *Config) {
|
||||
cfg.Database = DatabaseConfig{Driver: "mysql", DSN: "user@tcp(db.example.test:3306)/scert"}
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, item := range tests {
|
||||
t.Run(item.name, func(t *testing.T) {
|
||||
cfg := Default(t.TempDir())
|
||||
item.edit(&cfg)
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Fatal("不安全外部数据库配置未被拒绝")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetURLAlwaysUsesWebHostnameWithoutPort(t *testing.T) {
|
||||
cfg := Default(t.TempDir())
|
||||
cfg.Web.Hostname = "scert.example.test"
|
||||
|
||||
152
backend/internal/database/credentials.go
Normal file
152
backend/internal/database/credentials.go
Normal file
@ -0,0 +1,152 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/config"
|
||||
)
|
||||
|
||||
// CredentialReader 读取已加密保存的数据库用户名和密码。
|
||||
// 数据库密码只能在调用期间留存在内存中,不能保存到普通配置文件。
|
||||
type CredentialReader interface {
|
||||
Get(context.Context, string) (map[string]string, error)
|
||||
}
|
||||
|
||||
// NewCredentialReference 为外部数据库生成不可预测的加密凭据引用。
|
||||
func NewCredentialReference() (string, error) {
|
||||
buffer := make([]byte, 18)
|
||||
if _, err := rand.Read(buffer); err != nil {
|
||||
return "", fmt.Errorf("生成数据库凭据引用失败:%w", err)
|
||||
}
|
||||
return "secret://database/" + hex.EncodeToString(buffer), nil
|
||||
}
|
||||
|
||||
// ValidateConfig 校验可持久化的数据库配置。外部数据库的 DSN 不能包含密码。
|
||||
func ValidateConfig(cfg config.DatabaseConfig) error {
|
||||
driver := strings.ToLower(strings.TrimSpace(cfg.Driver))
|
||||
dsn := strings.TrimSpace(cfg.DSN)
|
||||
credentialRef := strings.TrimSpace(cfg.CredentialRef)
|
||||
if driver != "sqlite" && driver != "mysql" && driver != "postgres" {
|
||||
return fmt.Errorf("数据库驱动无效:%s", cfg.Driver)
|
||||
}
|
||||
if dsn == "" {
|
||||
return fmt.Errorf("数据库连接信息不能为空")
|
||||
}
|
||||
if driver == "sqlite" {
|
||||
if credentialRef != "" {
|
||||
return fmt.Errorf("SQLite 不允许配置数据库凭据引用")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if credentialRef == "" || !strings.HasPrefix(credentialRef, "secret://database/") {
|
||||
return fmt.Errorf("外部数据库必须配置 secret://database/ 加密凭据引用")
|
||||
}
|
||||
if containsPassword(driver, dsn) {
|
||||
return fmt.Errorf("数据库连接信息不能包含密码,请使用加密凭据引用")
|
||||
}
|
||||
if driver == "mysql" {
|
||||
parsed, err := mysql.ParseDSN(dsn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解析 MySQL 连接信息失败:%w", err)
|
||||
}
|
||||
if strings.TrimSpace(parsed.User) == "" {
|
||||
return fmt.Errorf("MySQL 连接信息必须包含用户名")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if _, err := normalizePostgresDSN(dsn); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolveConfig 从凭据存储中读取外部数据库账号,在内存中构造连接配置。
|
||||
// 返回值绝不能保存或记录到日志。
|
||||
func ResolveConfig(ctx context.Context, cfg config.DatabaseConfig, reader CredentialReader) (config.DatabaseConfig, error) {
|
||||
if err := ValidateConfig(cfg); err != nil {
|
||||
return config.DatabaseConfig{}, err
|
||||
}
|
||||
resolved := cfg
|
||||
if strings.EqualFold(resolved.Driver, "sqlite") {
|
||||
return resolved, nil
|
||||
}
|
||||
if reader == nil {
|
||||
return config.DatabaseConfig{}, fmt.Errorf("数据库敏感凭据存储未初始化")
|
||||
}
|
||||
credentials, err := reader.Get(ctx, resolved.CredentialRef)
|
||||
if err != nil {
|
||||
return config.DatabaseConfig{}, fmt.Errorf("读取数据库加密凭据失败:%w", err)
|
||||
}
|
||||
username := strings.TrimSpace(credentials["username"])
|
||||
password, exists := credentials["password"]
|
||||
if username == "" || !exists || password == "" {
|
||||
return config.DatabaseConfig{}, fmt.Errorf("数据库加密凭据必须包含用户名和密码")
|
||||
}
|
||||
if strings.EqualFold(resolved.Driver, "mysql") {
|
||||
dsn, err := withMySQLCredentials(resolved.DSN, username, password)
|
||||
if err != nil {
|
||||
return config.DatabaseConfig{}, err
|
||||
}
|
||||
resolved.DSN = dsn
|
||||
return resolved, nil
|
||||
}
|
||||
dsn, err := withPostgresCredentials(resolved.DSN, username, password)
|
||||
if err != nil {
|
||||
return config.DatabaseConfig{}, err
|
||||
}
|
||||
resolved.DSN = dsn
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func withMySQLCredentials(dsn, username, password string) (string, error) {
|
||||
parsed, err := mysql.ParseDSN(strings.TrimSpace(dsn))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解析 MySQL 连接信息失败:%w", err)
|
||||
}
|
||||
parsed.User = username
|
||||
parsed.Passwd = password
|
||||
parsed.ParseTime = true
|
||||
parsed.Loc = time.UTC
|
||||
return parsed.FormatDSN(), nil
|
||||
}
|
||||
|
||||
func withPostgresCredentials(dsn, username, password string) (string, error) {
|
||||
raw := strings.TrimSpace(dsn)
|
||||
parsedURL, err := url.Parse(raw)
|
||||
if err != nil || (parsedURL.Scheme != "postgres" && parsedURL.Scheme != "postgresql") {
|
||||
return "", fmt.Errorf("PostgreSQL 数据库连接信息必须使用不含密码的 postgres:// URL")
|
||||
}
|
||||
parsedURL.User = url.UserPassword(username, password)
|
||||
return parsedURL.String(), nil
|
||||
}
|
||||
|
||||
func containsPassword(driver, dsn string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(driver)) {
|
||||
case "mysql":
|
||||
at := strings.Index(dsn, "@")
|
||||
return at >= 0 && strings.Contains(dsn[:at], ":")
|
||||
case "postgres":
|
||||
if parsed, err := url.Parse(dsn); err == nil && (parsed.Scheme == "postgres" || parsed.Scheme == "postgresql") {
|
||||
if parsed.User == nil {
|
||||
return false
|
||||
}
|
||||
_, exists := parsed.User.Password()
|
||||
return exists
|
||||
}
|
||||
for _, field := range strings.Fields(dsn) {
|
||||
key, _, found := strings.Cut(field, "=")
|
||||
if found && strings.EqualFold(strings.TrimSpace(key), "password") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
108
backend/internal/database/credentials_test.go
Normal file
108
backend/internal/database/credentials_test.go
Normal file
@ -0,0 +1,108 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/config"
|
||||
)
|
||||
|
||||
type memoryCredentialReader map[string]map[string]string
|
||||
|
||||
func (m memoryCredentialReader) Get(_ context.Context, reference string) (map[string]string, error) {
|
||||
return m[reference], nil
|
||||
}
|
||||
|
||||
func TestValidateConfigRequiresEncryptedExternalDatabaseCredentials(t *testing.T) {
|
||||
base := config.Default(t.TempDir()).Database
|
||||
for _, item := range []struct {
|
||||
name string
|
||||
cfg config.DatabaseConfig
|
||||
}{
|
||||
{name: "SQLite cannot have credential reference", cfg: config.DatabaseConfig{Driver: "sqlite", DSN: filepath.Join(t.TempDir(), "scert.db"), CredentialRef: "secret://database/unexpected"}},
|
||||
{name: "MySQL requires reference", cfg: config.DatabaseConfig{Driver: "mysql", DSN: "user@tcp(db.example.test:3306)/scert"}},
|
||||
{name: "MySQL rejects password in saved DSN", cfg: config.DatabaseConfig{Driver: "mysql", DSN: "user:password@tcp(db.example.test:3306)/scert", CredentialRef: "secret://database/test"}},
|
||||
{name: "Postgres requires secure reference", cfg: config.DatabaseConfig{Driver: "postgres", DSN: "postgres://user@db.example.test:5432/scert?sslmode=verify-full", CredentialRef: "secret://dns/not-database"}},
|
||||
{name: "Postgres rejects password in saved DSN", cfg: config.DatabaseConfig{Driver: "postgres", DSN: "postgres://user:password@db.example.test:5432/scert?sslmode=verify-full", CredentialRef: "secret://database/test"}},
|
||||
} {
|
||||
t.Run(item.name, func(t *testing.T) {
|
||||
if err := ValidateConfig(item.cfg); err == nil {
|
||||
t.Fatal("不安全数据库配置未被拒绝")
|
||||
}
|
||||
})
|
||||
}
|
||||
if err := ValidateConfig(base); err != nil {
|
||||
t.Fatalf("有效 SQLite 配置被拒绝:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveConfigBuildsRuntimeDSNWithoutChangingPersistedConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg config.DatabaseConfig
|
||||
wantSecret string
|
||||
}{
|
||||
{
|
||||
name: "MySQL",
|
||||
cfg: config.DatabaseConfig{Driver: "mysql", DSN: "user@tcp(db.example.test:3306)/scert?charset=utf8mb4", CredentialRef: "secret://database/mysql"},
|
||||
wantSecret: "mysql-password",
|
||||
},
|
||||
{
|
||||
name: "PostgreSQL",
|
||||
cfg: config.DatabaseConfig{Driver: "postgres", DSN: "postgres://user@db.example.test:5432/scert?sslmode=verify-full", CredentialRef: "secret://database/postgres"},
|
||||
wantSecret: "postgres-password",
|
||||
},
|
||||
}
|
||||
for _, item := range tests {
|
||||
t.Run(item.name, func(t *testing.T) {
|
||||
originalDSN := item.cfg.DSN
|
||||
resolved, err := ResolveConfig(t.Context(), item.cfg, memoryCredentialReader{
|
||||
item.cfg.CredentialRef: {"username": "user", "password": item.wantSecret},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("组装运行时连接信息失败:%v", err)
|
||||
}
|
||||
if !strings.Contains(resolved.DSN, item.wantSecret) {
|
||||
t.Fatalf("运行时连接信息未包含内存凭据:%s", SanitizeDSN(item.cfg.Driver, resolved.DSN))
|
||||
}
|
||||
if item.cfg.DSN != originalDSN || strings.Contains(item.cfg.DSN, item.wantSecret) {
|
||||
t.Fatalf("持久化配置被改写或包含密码:%+v", item.cfg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyWritableSQLiteCleansTemporaryTable(t *testing.T) {
|
||||
cfg := config.Default(t.TempDir()).Database
|
||||
if err := VerifyWritable(t.Context(), cfg); err != nil {
|
||||
t.Fatalf("SQLite 可写验证失败:%v", err)
|
||||
}
|
||||
db, err := Open(t.Context(), cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
var count int
|
||||
if err := db.DB.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name LIKE ?`, verificationTablePrefix+"%").Scan(&count); err != nil {
|
||||
t.Fatalf("检查临时表清理失败:%v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("数据库验证遗留临时表:%d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCredentialReferenceHasDatabasePrefixAndIsUnique(t *testing.T) {
|
||||
first, err := NewCredentialReference()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := NewCredentialReference()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(first, "secret://database/") || first == second {
|
||||
t.Fatalf("数据库凭据引用不安全:first=%s second=%s", first, second)
|
||||
}
|
||||
}
|
||||
@ -22,6 +22,9 @@ type Database struct {
|
||||
}
|
||||
|
||||
func Open(ctx context.Context, cfg config.DatabaseConfig) (*Database, error) {
|
||||
if err := ValidateConfigForOpen(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
driver := strings.ToLower(strings.TrimSpace(cfg.Driver))
|
||||
if driver != "sqlite" && driver != "mysql" && driver != "postgres" {
|
||||
return nil, fmt.Errorf("数据库驱动无效:%s", cfg.Driver)
|
||||
@ -74,6 +77,22 @@ func Open(ctx context.Context, cfg config.DatabaseConfig) (*Database, error) {
|
||||
return &Database{DB: db}, nil
|
||||
}
|
||||
|
||||
// ValidateConfigForOpen 接受已在内存中补全凭据的外部数据库配置。
|
||||
// 持久化配置必须使用 ValidateConfig;运行时配置由 ResolveConfig 生成。
|
||||
func ValidateConfigForOpen(cfg config.DatabaseConfig) error {
|
||||
driver := strings.ToLower(strings.TrimSpace(cfg.Driver))
|
||||
if driver != "sqlite" && driver != "mysql" && driver != "postgres" {
|
||||
return fmt.Errorf("数据库驱动无效:%s", cfg.Driver)
|
||||
}
|
||||
if strings.TrimSpace(cfg.DSN) == "" {
|
||||
return fmt.Errorf("数据库连接信息不能为空")
|
||||
}
|
||||
if driver == "sqlite" && strings.TrimSpace(cfg.CredentialRef) != "" {
|
||||
return fmt.Errorf("SQLite 不允许配置数据库凭据引用")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func configureSQLite(ctx context.Context, db *sql.DB) error {
|
||||
if _, err := db.ExecContext(ctx, "PRAGMA busy_timeout = 5000"); err != nil {
|
||||
return fmt.Errorf("配置 SQLite 忙等待失败:%w", err)
|
||||
|
||||
91
backend/internal/database/verification.go
Normal file
91
backend/internal/database/verification.go
Normal file
@ -0,0 +1,91 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/config"
|
||||
)
|
||||
|
||||
const verificationTablePrefix = "scert_bootstrap_verify_"
|
||||
|
||||
// VerifyWritable 通过随机临时表执行创建、写入、读取和清理,验证数据库账号具备运行所需写权限。
|
||||
// cfg 必须是 ResolveConfig 返回的仅内存配置,因其中可能含有数据库密码。
|
||||
func VerifyWritable(ctx context.Context, cfg config.DatabaseConfig) (err error) {
|
||||
db, err := Open(ctx, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
tableName, err := newVerificationTableName()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
value, err := newVerificationValue()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
quotedTable := quoteIdentifier(cfg.Driver, tableName)
|
||||
defer func() {
|
||||
cleanupContext, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cleanupCancel()
|
||||
cleanupErr := dropVerificationTable(cleanupContext, db.DB, quotedTable)
|
||||
if cleanupErr != nil && err == nil {
|
||||
err = fmt.Errorf("清理数据库验证临时表失败:%w", cleanupErr)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := db.DB.ExecContext(ctx, "CREATE TABLE "+quotedTable+" (value TEXT NOT NULL)"); err != nil {
|
||||
return fmt.Errorf("创建数据库验证临时表失败:%w", err)
|
||||
}
|
||||
placeholder := "?"
|
||||
if strings.EqualFold(cfg.Driver, "postgres") {
|
||||
placeholder = "$1"
|
||||
}
|
||||
if _, err := db.DB.ExecContext(ctx, "INSERT INTO "+quotedTable+" (value) VALUES ("+placeholder+")", value); err != nil {
|
||||
return fmt.Errorf("写入数据库验证临时表失败:%w", err)
|
||||
}
|
||||
var actual string
|
||||
if err := db.DB.QueryRowContext(ctx, "SELECT value FROM "+quotedTable+" LIMIT 1").Scan(&actual); err != nil {
|
||||
return fmt.Errorf("读取数据库验证临时表失败:%w", err)
|
||||
}
|
||||
if actual != value {
|
||||
return errors.New("数据库验证读取结果不一致")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newVerificationTableName() (string, error) {
|
||||
buffer := make([]byte, 12)
|
||||
if _, err := rand.Read(buffer); err != nil {
|
||||
return "", fmt.Errorf("生成数据库验证临时表名失败:%w", err)
|
||||
}
|
||||
return verificationTablePrefix + hex.EncodeToString(buffer), nil
|
||||
}
|
||||
|
||||
func newVerificationValue() (string, error) {
|
||||
buffer := make([]byte, 24)
|
||||
if _, err := rand.Read(buffer); err != nil {
|
||||
return "", fmt.Errorf("生成数据库验证随机值失败:%w", err)
|
||||
}
|
||||
return hex.EncodeToString(buffer), nil
|
||||
}
|
||||
|
||||
func quoteIdentifier(driver, identifier string) string {
|
||||
if strings.EqualFold(driver, "mysql") {
|
||||
return "`" + identifier + "`"
|
||||
}
|
||||
return `"` + identifier + `"`
|
||||
}
|
||||
|
||||
func dropVerificationTable(ctx context.Context, db *sql.DB, table string) error {
|
||||
_, err := db.ExecContext(ctx, "DROP TABLE IF EXISTS "+table)
|
||||
return err
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user