实现PostgreSQL受控备份恢复并加固取消后的数据库回滚
This commit is contained in:
parent
943fb8c459
commit
626ba534f7
@ -38,9 +38,29 @@ type backupManifestFile struct {
|
||||
}
|
||||
|
||||
func createBackup(ctx context.Context, cfg config.Config, configPath, output string) error {
|
||||
return createBackupWithPostgresTools(ctx, cfg, configPath, output, loadPostgresTools)
|
||||
}
|
||||
|
||||
func createBackupWithPostgresTools(ctx context.Context, cfg config.Config, configPath, output string, factory postgresToolsFactory) error {
|
||||
if err := validateBackupDriver(cfg.Database.Driver); err != nil {
|
||||
return err
|
||||
}
|
||||
var tools *postgresTools
|
||||
var postgresConnection config.DatabaseConfig
|
||||
if cfg.Database.Driver == "postgres" {
|
||||
var err error
|
||||
tools, err = factory(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
postgresConnection, err = resolveMaintenanceDatabase(ctx, cfg.Database, cfg.Paths.Config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, err := postgresToolConnection(postgresConnection); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
output = filepath.Clean(output)
|
||||
if err := ensureBackupDestination(output); err != nil {
|
||||
return err
|
||||
@ -112,6 +132,11 @@ func createBackup(ctx context.Context, cfg config.Config, configPath, output str
|
||||
return err
|
||||
}
|
||||
}
|
||||
if cfg.Database.Driver == "postgres" {
|
||||
if err := tools.backup(ctx, postgresConnection, filepath.Join(staging, "scert.pgdump")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if info, err := os.Lstat(cfg.Paths.Certificate); err == nil {
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||
@ -127,12 +152,18 @@ func createBackup(ctx context.Context, cfg config.Config, configPath, output str
|
||||
return fmt.Errorf("检查证书根目录失败:%w", err)
|
||||
}
|
||||
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeBackupManifest(staging); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := syncDirectory(staging); err != nil {
|
||||
return fmt.Errorf("同步备份暂存目录失败:%w", err)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(staging, output); err != nil {
|
||||
return fmt.Errorf("发布备份目录失败:%w", err)
|
||||
}
|
||||
@ -242,6 +273,9 @@ func validateRestoreCompatibility(current, backup config.Config) error {
|
||||
if backup.Database.Driver == "mysql" && strings.TrimSpace(current.Database.DSN) != strings.TrimSpace(backup.Database.DSN) {
|
||||
return errors.New("备份 MySQL 连接配置与当前配置不一致,拒绝跨数据库恢复")
|
||||
}
|
||||
if backup.Database.Driver == "postgres" && strings.TrimSpace(current.Database.DSN) != strings.TrimSpace(backup.Database.DSN) {
|
||||
return errors.New("备份 PostgreSQL 连接配置与当前配置不一致,拒绝跨数据库恢复")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ package cli
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/config"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/database"
|
||||
@ -11,15 +12,23 @@ import (
|
||||
|
||||
func validateBackupDriver(driver string) error {
|
||||
switch driver {
|
||||
case "sqlite", "mysql":
|
||||
case "sqlite", "mysql", "postgres":
|
||||
return nil
|
||||
case "postgres":
|
||||
return errors.New("PostgreSQL 完整备份与恢复尚未实现,拒绝生成或恢复不含数据库的备份;请使用数据库原生工具并单独保护配置、加密凭据和证书")
|
||||
default:
|
||||
return errors.New("当前数据库类型不支持备份与恢复")
|
||||
}
|
||||
}
|
||||
|
||||
// 原请求取消后仍需尝试恢复旧数据,但不能让回滚无限等待。
|
||||
func runDatabaseRollback(ctx context.Context, restore func(context.Context) error) error {
|
||||
rollbackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Minute)
|
||||
defer cancel()
|
||||
if err := restore(rollbackCtx); err != nil {
|
||||
return errors.Join(errors.New("数据库自动回滚失败,请保留恢复前快照并停止服务,联系管理员手动恢复"), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 返回值仅供本次维护操作连接数据库,禁止写入配置、备份清单或日志。
|
||||
func resolveMaintenanceDatabase(ctx context.Context, cfg config.DatabaseConfig, directory string) (config.DatabaseConfig, error) {
|
||||
if err := database.ValidateConfig(cfg); err != nil {
|
||||
|
||||
@ -2,10 +2,12 @@ package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
|
||||
@ -14,7 +16,7 @@ import (
|
||||
)
|
||||
|
||||
func TestUnsupportedBackupDriverFailsBeforeFilesystemChanges(t *testing.T) {
|
||||
for _, driver := range []string{"postgres", "unknown", ""} {
|
||||
for _, driver := range []string{"unknown", ""} {
|
||||
t.Run(driver, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
cfg := config.Config{Database: config.DatabaseConfig{Driver: driver}}
|
||||
@ -30,7 +32,7 @@ func TestUnsupportedBackupDriverFailsBeforeFilesystemChanges(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresRestoreFailsBeforeReplacingExistingConfig(t *testing.T) {
|
||||
func TestPostgresRestoreWithoutSecretsFailsBeforeReplacingExistingConfig(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
input := filepath.Join(root, "input")
|
||||
if err := os.Mkdir(input, 0o700); err != nil {
|
||||
@ -66,8 +68,8 @@ func TestPostgresRestoreFailsBeforeReplacingExistingConfig(t *testing.T) {
|
||||
command.SetOut(&output)
|
||||
command.SetErr(&output)
|
||||
command.SetArgs([]string{"--input", input})
|
||||
if err := command.Execute(); err == nil || !strings.Contains(err.Error(), "PostgreSQL 完整备份与恢复尚未实现") {
|
||||
t.Fatal("未支持的 PostgreSQL 恢复没有明确拒绝")
|
||||
if err := command.Execute(); err == nil || !strings.Contains(err.Error(), "master.key") {
|
||||
t.Fatal("缺少数据库凭据的 PostgreSQL 恢复没有明确拒绝")
|
||||
}
|
||||
content, err := os.ReadFile(currentPath)
|
||||
if err != nil || string(content) != original {
|
||||
@ -78,6 +80,32 @@ func TestPostgresRestoreFailsBeforeReplacingExistingConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresBackupDriverSupported(t *testing.T) {
|
||||
if err := validateBackupDriver("postgres"); err != nil {
|
||||
t.Fatalf("PostgreSQL 应支持完整备份恢复:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseRollbackSurvivesCanceledRequestWithDeadline(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
called := false
|
||||
err := runDatabaseRollback(ctx, func(rollbackCtx context.Context) error {
|
||||
called = true
|
||||
if rollbackCtx.Err() != nil {
|
||||
t.Fatal("原请求取消不应立即取消数据库回滚")
|
||||
}
|
||||
deadline, ok := rollbackCtx.Deadline()
|
||||
if !ok || time.Until(deadline) <= 0 || time.Until(deadline) > 5*time.Minute {
|
||||
t.Fatal("回滚必须使用独立且有界的超时")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil || !called {
|
||||
t.Fatal("取消后仍应执行回滚")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaintenanceResolvesEncryptedMySQLCredentialsOnlyInMemory(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
store, err := secretstore.Open(directory)
|
||||
|
||||
@ -352,6 +352,10 @@ func newBackupCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
func newRestoreCommand() *cobra.Command {
|
||||
return newRestoreCommandWithPostgresTools(loadPostgresTools)
|
||||
}
|
||||
|
||||
func newRestoreCommandWithPostgresTools(factory postgresToolsFactory) *cobra.Command {
|
||||
var input string
|
||||
command := &cobra.Command{
|
||||
Use: "restore",
|
||||
@ -375,6 +379,17 @@ func newRestoreCommand() *cobra.Command {
|
||||
if err := validateBackupSecretStore(cmd.Context(), input, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
var tools *postgresTools
|
||||
postgresSource := filepath.Join(input, "scert.pgdump")
|
||||
if cfg.Database.Driver == "postgres" {
|
||||
tools, err = factory(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tools.validateArchive(cmd.Context(), postgresSource); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
currentPath := filepath.Clean(resolveConfigPath())
|
||||
expectedConfigPath := filepath.Join(cfg.Paths.Config, "scert.json")
|
||||
@ -449,17 +464,27 @@ func newRestoreCommand() *cobra.Command {
|
||||
|
||||
// 当前账号用于恢复前快照、导入和回滚,不能因替换配置或主密钥而丢失。
|
||||
// 无当前配置时才使用已校验的备份凭据;连接信息始终仅保留在内存。
|
||||
var mysqlConnection config.DatabaseConfig
|
||||
if cfg.Database.Driver == "mysql" {
|
||||
var maintenanceConnection config.DatabaseConfig
|
||||
if cfg.Database.Driver == "mysql" || cfg.Database.Driver == "postgres" {
|
||||
connectionConfig, secretDirectory := cfg.Database, input
|
||||
if currentConfigErr == nil {
|
||||
connectionConfig, secretDirectory = currentConfig.Database, currentConfig.Paths.Config
|
||||
}
|
||||
mysqlConnection, err = resolveMaintenanceDatabase(cmd.Context(), connectionConfig, secretDirectory)
|
||||
maintenanceConnection, err = resolveMaintenanceDatabase(cmd.Context(), connectionConfig, secretDirectory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
postgresBackupPath := ""
|
||||
if cfg.Database.Driver == "postgres" {
|
||||
postgresBackupPath, err = tools.createRestoreSnapshot(cmd.Context(), cfg, maintenanceConnection)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tools.validateRestoreInventory(cmd.Context(), postgresSource, postgresBackupPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
configBackupPath := ""
|
||||
if isRegularFile(currentPath) {
|
||||
@ -479,7 +504,7 @@ func newRestoreCommand() *cobra.Command {
|
||||
|
||||
mysqlBackupPath := ""
|
||||
if cfg.Database.Driver == "mysql" {
|
||||
mysqlBackupPath, err = createMySQLRestoreBackup(cmd.Context(), cfg, mysqlConnection)
|
||||
mysqlBackupPath, err = createMySQLRestoreBackup(cmd.Context(), cfg, maintenanceConnection)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -488,10 +513,18 @@ func newRestoreCommand() *cobra.Command {
|
||||
configRestoreAttempted := false
|
||||
sqliteRestoreAttempted := false
|
||||
mysqlRestoreAttempted := false
|
||||
postgresRestoreAttempted := false
|
||||
rollbackCore := func() error {
|
||||
var rollbackErr error
|
||||
if mysqlRestoreAttempted && mysqlBackupPath != "" {
|
||||
rollbackErr = errors.Join(rollbackErr, restoreMySQL(cmd.Context(), mysqlConnection, mysqlBackupPath))
|
||||
rollbackErr = errors.Join(rollbackErr, runDatabaseRollback(cmd.Context(), func(ctx context.Context) error {
|
||||
return restoreMySQL(ctx, maintenanceConnection, mysqlBackupPath)
|
||||
}))
|
||||
}
|
||||
if postgresRestoreAttempted && postgresBackupPath != "" {
|
||||
rollbackErr = errors.Join(rollbackErr, runDatabaseRollback(cmd.Context(), func(ctx context.Context) error {
|
||||
return tools.restore(ctx, maintenanceConnection, postgresBackupPath)
|
||||
}))
|
||||
}
|
||||
if sqliteRestoreAttempted {
|
||||
if databaseBackupPath != "" {
|
||||
@ -510,6 +543,9 @@ func newRestoreCommand() *cobra.Command {
|
||||
return rollbackErr
|
||||
}
|
||||
|
||||
if err := cmd.Context().Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
configRestoreAttempted = true
|
||||
if err := copyFileAtomic(configFile, currentPath, 0o600); err != nil {
|
||||
return errors.Join(err, rollbackCore())
|
||||
@ -522,10 +558,19 @@ func newRestoreCommand() *cobra.Command {
|
||||
}
|
||||
if cfg.Database.Driver == "mysql" {
|
||||
mysqlRestoreAttempted = true
|
||||
if err := restoreMySQL(cmd.Context(), mysqlConnection, mysqlSource); err != nil {
|
||||
if err := restoreMySQL(cmd.Context(), maintenanceConnection, mysqlSource); err != nil {
|
||||
return errors.Join(fmt.Errorf("恢复 MySQL 数据库失败:%w", err), rollbackCore())
|
||||
}
|
||||
}
|
||||
if cfg.Database.Driver == "postgres" {
|
||||
postgresRestoreAttempted = true
|
||||
if err := tools.restore(cmd.Context(), maintenanceConnection, postgresSource); err != nil {
|
||||
return errors.Join(err, rollbackCore())
|
||||
}
|
||||
}
|
||||
if err := cmd.Context().Err(); err != nil {
|
||||
return errors.Join(err, rollbackCore())
|
||||
}
|
||||
if err := restoreAdditionalAssets(cfg, input); err != nil {
|
||||
return errors.Join(fmt.Errorf("恢复敏感配置或证书失败:%w", err), rollbackCore())
|
||||
}
|
||||
|
||||
166
backend/internal/cli/postgres_backup.go
Normal file
166
backend/internal/cli/postgres_backup.go
Normal file
@ -0,0 +1,166 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/config"
|
||||
)
|
||||
|
||||
func (tools *postgresTools) backup(ctx context.Context, connection config.DatabaseConfig, output string) (result error) {
|
||||
dsn, env, err := postgresToolConnection(connection)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.OpenFile(output, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return errors.New("创建 PostgreSQL 备份文件失败,目标不得已存在")
|
||||
}
|
||||
defer func() {
|
||||
_ = file.Close()
|
||||
if result != nil {
|
||||
// 只清理本次独占创建的不完整文件,不触碰既有备份。
|
||||
_ = os.Remove(output)
|
||||
}
|
||||
}()
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Minute)
|
||||
defer cancel()
|
||||
args := []string{"--format=custom", "--no-password", "--lock-wait-timeout=30s", "--dbname=" + dsn}
|
||||
if err := tools.run(ctx, tools.dumpPath, args, env, file); err != nil {
|
||||
return errors.Join(errors.New("创建 PostgreSQL 一致性快照失败"), err)
|
||||
}
|
||||
if err := file.Sync(); err != nil {
|
||||
return errors.New("同步 PostgreSQL 备份文件失败")
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return errors.New("关闭 PostgreSQL 备份文件失败")
|
||||
}
|
||||
return tools.validateArchive(ctx, output)
|
||||
}
|
||||
|
||||
func (tools *postgresTools) validateArchive(ctx context.Context, input string) error {
|
||||
if !isRegularFile(input) {
|
||||
return errors.New("PostgreSQL 备份目录缺少有效的 scert.pgdump 文件")
|
||||
}
|
||||
file, err := os.Open(input)
|
||||
if err != nil {
|
||||
return errors.New("无法读取 PostgreSQL 备份")
|
||||
}
|
||||
var magic [5]byte
|
||||
_, readErr := io.ReadFull(file, magic[:])
|
||||
_ = file.Close()
|
||||
if readErr != nil || string(magic[:]) != "PGDMP" {
|
||||
return errors.New("PostgreSQL 备份必须是 pg_dump 自定义格式归档")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Minute)
|
||||
defer cancel()
|
||||
// 完整解码归档而非仅检查目录;不连接数据库,不保留含业务数据的 SQL 输出。
|
||||
if err := tools.run(ctx, tools.restorePath, []string{"--file=-", "--", input}, postgresToolEnvironment(), io.Discard); err != nil {
|
||||
return errors.Join(errors.New("PostgreSQL 归档完整性校验失败"), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var postgresArchiveEntry = regexp.MustCompile(`^\d+;\s+\d+\s+\d+\s+(.+)$`)
|
||||
|
||||
func (tools *postgresTools) inventory(ctx context.Context, input string) (map[string]struct{}, error) {
|
||||
output := &limitedPostgresOutput{remaining: 8 << 20}
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
if err := tools.run(ctx, tools.restorePath, []string{"--list", "--", input}, postgresToolEnvironment(), output); err != nil {
|
||||
return nil, errors.Join(errors.New("读取 PostgreSQL 归档对象清单失败"), err)
|
||||
}
|
||||
items := make(map[string]struct{})
|
||||
for _, line := range strings.Split(output.String(), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, ";") {
|
||||
continue
|
||||
}
|
||||
match := postgresArchiveEntry.FindStringSubmatch(line)
|
||||
if len(match) != 2 {
|
||||
return nil, errors.New("PostgreSQL 归档对象清单格式无效")
|
||||
}
|
||||
if _, duplicate := items[match[1]]; duplicate {
|
||||
return nil, errors.New("PostgreSQL 归档对象清单存在重复条目")
|
||||
}
|
||||
items[match[1]] = struct{}{}
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil, errors.New("PostgreSQL 归档对象清单为空,拒绝无法验证的恢复")
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (tools *postgresTools) validateRestoreInventory(ctx context.Context, input, snapshot string) error {
|
||||
source, err := tools.inventory(ctx, input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target, err := tools.inventory(ctx, snapshot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// pg_restore --clean 只删除归档列出的对象。严格相等保证导入及反向回滚
|
||||
// 都不遗留额外对象;跨迁移版本或空库灾难恢复需管理员使用原生流程。
|
||||
if len(source) != len(target) {
|
||||
return errors.New("PostgreSQL 备份与当前数据库对象清单不一致,拒绝自动恢复;请在隔离数据库使用原生工具演练迁移或灾难恢复")
|
||||
}
|
||||
for item := range source {
|
||||
if _, exists := target[item]; !exists {
|
||||
return errors.New("PostgreSQL 备份与当前数据库对象或所有者不一致,拒绝可能残留对象的恢复")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tools *postgresTools) createRestoreSnapshot(ctx context.Context, cfg config.Config, connection config.DatabaseConfig) (string, error) {
|
||||
root := filepath.Clean(strings.TrimSpace(cfg.Paths.Backup))
|
||||
if root == "." {
|
||||
return "", errors.New("PostgreSQL 恢复前快照目录不能为空")
|
||||
}
|
||||
if err := os.MkdirAll(root, 0o700); err != nil {
|
||||
return "", fmt.Errorf("创建 PostgreSQL 恢复前快照目录失败:%w", err)
|
||||
}
|
||||
info, err := os.Lstat(root)
|
||||
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return "", errors.New("PostgreSQL 恢复前快照目录必须为普通目录")
|
||||
}
|
||||
directory, err := os.MkdirTemp(root, "scert-postgres-before-restore-")
|
||||
if err != nil {
|
||||
return "", errors.New("创建 PostgreSQL 恢复前快照失败")
|
||||
}
|
||||
output := filepath.Join(directory, "scert.pgdump")
|
||||
if err := tools.backup(ctx, connection, output); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := syncDirectory(directory); err != nil {
|
||||
return "", errors.New("同步 PostgreSQL 恢复前快照目录失败,拒绝执行恢复")
|
||||
}
|
||||
if err := syncDirectory(root); err != nil {
|
||||
return "", errors.New("同步 PostgreSQL 快照父目录失败,拒绝执行恢复")
|
||||
}
|
||||
// 成功后也保留原数据库快照,便于人工恢复,不自动清理用户备份。
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func (tools *postgresTools) restore(ctx context.Context, connection config.DatabaseConfig, input string) error {
|
||||
dsn, env, err := postgresToolConnection(connection)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Minute)
|
||||
defer cancel()
|
||||
// 保留对象所有者和授权,权限或角色不足时失败,而不是悄悄丢失访问控制。
|
||||
args := []string{"--clean", "--if-exists", "--single-transaction", "--exit-on-error", "--no-password", "--dbname=" + dsn, "--", input}
|
||||
if err := tools.run(ctx, tools.restorePath, args, env, io.Discard); err != nil {
|
||||
return errors.Join(errors.New("PostgreSQL 单事务恢复失败"), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
373
backend/internal/cli/postgres_backup_test.go
Normal file
373
backend/internal/cli/postgres_backup_test.go
Normal file
@ -0,0 +1,373 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/config"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/secretstore"
|
||||
)
|
||||
|
||||
// 替身只模拟工具协议和数据库状态,不构成 PostgreSQL 实机恢复证据。
|
||||
type postgresFixture struct {
|
||||
t *testing.T
|
||||
state string
|
||||
restores int
|
||||
failDump bool
|
||||
failDecode bool
|
||||
failRestore bool
|
||||
failRollback bool
|
||||
extraObject bool
|
||||
cancelRestore context.CancelFunc
|
||||
tools *postgresTools
|
||||
}
|
||||
|
||||
func newPostgresFixture(t *testing.T) *postgresFixture {
|
||||
f := &postgresFixture{t: t, state: "current"}
|
||||
f.tools = &postgresTools{dumpPath: "dump", restorePath: "restore", run: f.run}
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *postgresFixture) factory(context.Context) (*postgresTools, error) { return f.tools, nil }
|
||||
|
||||
func (f *postgresFixture) run(ctx context.Context, tool string, args, env []string, output io.Writer) error {
|
||||
f.t.Helper()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
connected := slices.ContainsFunc(args, func(arg string) bool { return strings.HasPrefix(arg, "--dbname=") })
|
||||
if connected {
|
||||
if !slices.Contains(env, "PGPASSWORD=current-password") || strings.Contains(strings.Join(args, " "), "current-password") || strings.Contains(strings.Join(args, " "), "backup-password") {
|
||||
f.t.Fatal("维护操作必须固定当前凭据且不将密码放入命令行")
|
||||
}
|
||||
}
|
||||
if tool == "dump" {
|
||||
if !slices.Contains(args, "--format=custom") || !slices.Contains(args, "--no-password") || slices.Contains(args, "--no-acl") || slices.Contains(args, "--no-owner") {
|
||||
f.t.Fatal("数据库快照工具参数不完整")
|
||||
}
|
||||
_, err := io.WriteString(output, "PGDMP"+f.state)
|
||||
if f.failDump {
|
||||
return errors.New("模拟快照失败")
|
||||
}
|
||||
return err
|
||||
}
|
||||
input := args[len(args)-1]
|
||||
data, err := os.ReadFile(input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if slices.Contains(args, "--list") {
|
||||
_, err := io.WriteString(output, "; Archive TOC\n10; 1259 100 TABLE public settings owner\n11; 0 100 TABLE DATA public settings owner\n")
|
||||
if f.extraObject && strings.HasPrefix(string(data), "PGDMPcurrent") {
|
||||
_, err = io.WriteString(output, "12; 1259 101 TABLE public extra owner\n")
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !connected {
|
||||
if !slices.Contains(args, "--file=-") || f.failDecode {
|
||||
return errors.New("模拟归档解码失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if slices.Contains(args, "--no-owner") || slices.Contains(args, "--no-acl") {
|
||||
f.t.Fatal("完整恢复不能静默丢弃所有者或对象授权")
|
||||
}
|
||||
for _, flag := range []string{"--clean", "--if-exists", "--single-transaction", "--exit-on-error", "--no-password"} {
|
||||
if !slices.Contains(args, flag) {
|
||||
f.t.Fatalf("恢复缺少必要参数:%s", flag)
|
||||
}
|
||||
}
|
||||
f.restores++
|
||||
if f.restores > 1 {
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok || time.Until(deadline) > 5*time.Minute || ctx.Err() != nil {
|
||||
f.t.Fatal("回滚上下文必须独立且有界")
|
||||
}
|
||||
if f.failRollback {
|
||||
return errors.New("模拟数据库回滚失败")
|
||||
}
|
||||
}
|
||||
f.state = strings.TrimPrefix(string(data), "PGDMP")
|
||||
if f.restores == 1 && f.cancelRestore != nil {
|
||||
f.cancelRestore()
|
||||
return context.Canceled
|
||||
}
|
||||
if f.restores == 1 && f.failRestore {
|
||||
return errors.New("模拟导入失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func preparePostgresRestoreFixture(t *testing.T, blockedCertificate bool) (config.Config, string, string) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
cfg := config.Default(root)
|
||||
cfg.Paths = config.PathsConfig{
|
||||
Install: root, Config: filepath.Join(root, "conf"), Data: filepath.Join(root, "data"),
|
||||
Certificate: filepath.Join(root, "ssl"), Log: filepath.Join(root, "log"), Backup: filepath.Join(root, "backups"),
|
||||
}
|
||||
cfg.Database = config.DatabaseConfig{
|
||||
Driver: "postgres", DSN: "postgres://db.example.test:5432/scert?sslmode=verify-full",
|
||||
CredentialRef: "secret://database/maintenance", MaxOpenConns: 4, MaxIdleConns: 2, ConnMaxLifetimeMins: 30,
|
||||
}
|
||||
cfg.Server.Port = 9004
|
||||
current := filepath.Join(cfg.Paths.Config, "scert.json")
|
||||
if err := cfg.Save(current); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
input := filepath.Join(root, "input")
|
||||
backup := cfg
|
||||
backup.Server.Port = 9005
|
||||
if err := backup.Save(filepath.Join(input, "scert.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for directory, password := range map[string]string{cfg.Paths.Config: "current-password", input: "backup-password"} {
|
||||
store, err := secretstore.Open(directory)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.Put(t.Context(), cfg.Database.CredentialRef, map[string]string{"username": "owner", "password": password}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if blockedCertificate {
|
||||
if err := os.WriteFile(cfg.Paths.Certificate, []byte("preserve-blocked-target"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
if err := os.MkdirAll(cfg.Paths.Certificate, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(cfg.Paths.Certificate, "old.pem"), []byte("old-certificate"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(input, "certificates"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for path, value := range map[string]string{
|
||||
filepath.Join(input, "scert.pgdump"): "PGDMPrestored",
|
||||
filepath.Join(input, "certificates", "new.pem"): "new-certificate",
|
||||
} {
|
||||
if err := os.WriteFile(path, []byte(value), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := writeBackupManifest(input); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
previous := configPath
|
||||
configPath = current
|
||||
t.Cleanup(func() { configPath = previous })
|
||||
return cfg, current, input
|
||||
}
|
||||
|
||||
func TestPostgresCompleteBackupAndFailedPublication(t *testing.T) {
|
||||
for _, failure := range []string{"", "dump", "decode"} {
|
||||
t.Run("failure="+failure, func(t *testing.T) {
|
||||
cfg, current, _ := preparePostgresRestoreFixture(t, false)
|
||||
f := newPostgresFixture(t)
|
||||
f.failDump, f.failDecode = failure == "dump", failure == "decode"
|
||||
output := filepath.Join(t.TempDir(), "backup")
|
||||
err := createBackupWithPostgresTools(t.Context(), cfg, current, output, f.factory)
|
||||
if failure != "" {
|
||||
if err == nil {
|
||||
t.Fatal("快照或解码失败时不能报告备份成功")
|
||||
}
|
||||
if _, err := os.Stat(output); !os.IsNotExist(err) {
|
||||
t.Fatal("失败备份不能发布")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateBackupManifest(output); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"scert.pgdump", "scert.json", "master.key", "secrets.json", "certificates/old.pem"} {
|
||||
if !isRegularFile(filepath.Join(output, name)) {
|
||||
t.Fatalf("完整备份缺少:%s", name)
|
||||
}
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(output, "scert.json"))
|
||||
if err != nil || bytes.Contains(data, []byte("current-password")) {
|
||||
t.Fatal("备份配置不得写入内存连接密码")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresRestoreSuccessAndRollback(t *testing.T) {
|
||||
for _, scenario := range []string{"success", "certificate", "import", "cancel", "rollback", "inventory", "snapshot", "decode"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
cfg, current, input := preparePostgresRestoreFixture(t, scenario == "certificate")
|
||||
before, _ := os.ReadFile(current)
|
||||
beforeKey, _ := os.ReadFile(filepath.Join(cfg.Paths.Config, "master.key"))
|
||||
beforeSecrets, _ := os.ReadFile(filepath.Join(cfg.Paths.Config, "secrets.json"))
|
||||
f := newPostgresFixture(t)
|
||||
f.failRestore = scenario == "import" || scenario == "rollback"
|
||||
f.failRollback, f.extraObject = scenario == "rollback", scenario == "inventory"
|
||||
f.failDump, f.failDecode = scenario == "snapshot", scenario == "decode"
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
if scenario == "cancel" {
|
||||
f.cancelRestore = cancel
|
||||
}
|
||||
command := newRestoreCommandWithPostgresTools(f.factory)
|
||||
command.SetArgs([]string{"--input", input})
|
||||
command.SetContext(ctx)
|
||||
var output bytes.Buffer
|
||||
command.SetOut(&output)
|
||||
command.SetErr(&output)
|
||||
err := command.Execute()
|
||||
if scenario == "success" {
|
||||
if err != nil || f.state != "restored" || f.restores != 1 {
|
||||
t.Fatalf("完整恢复未成功:%v", err)
|
||||
}
|
||||
loaded, err := config.Load(current)
|
||||
if err != nil || loaded.Server.Port != 9005 || !isRegularFile(filepath.Join(cfg.Paths.Certificate, "new.pem")) {
|
||||
t.Fatal("恢复未包含配置或证书")
|
||||
}
|
||||
store, err := secretstore.Open(cfg.Paths.Config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
credentials, err := store.Get(t.Context(), cfg.Database.CredentialRef)
|
||||
if err != nil || credentials["password"] != "backup-password" {
|
||||
t.Fatal("备份加密凭据未恢复")
|
||||
}
|
||||
} else {
|
||||
if err == nil || strings.Contains(output.String(), "恢复已完成") {
|
||||
t.Fatal("恢复失败不能报告成功")
|
||||
}
|
||||
if scenario == "rollback" {
|
||||
if !strings.Contains(err.Error(), "数据库自动回滚失败") {
|
||||
t.Fatal("回滚失败必须明确要求人工恢复")
|
||||
}
|
||||
} else if f.state != "current" {
|
||||
t.Fatal("失败时没有恢复原数据库状态")
|
||||
}
|
||||
after, _ := os.ReadFile(current)
|
||||
afterKey, _ := os.ReadFile(filepath.Join(cfg.Paths.Config, "master.key"))
|
||||
afterSecrets, _ := os.ReadFile(filepath.Join(cfg.Paths.Config, "secrets.json"))
|
||||
if !bytes.Equal(before, after) || !bytes.Equal(beforeKey, afterKey) || !bytes.Equal(beforeSecrets, afterSecrets) {
|
||||
t.Fatal("失败时未保留或回滚原配置与加密凭据")
|
||||
}
|
||||
if scenario == "inventory" || scenario == "snapshot" || scenario == "decode" {
|
||||
if f.restores != 0 {
|
||||
t.Fatal("预检查失败时不应执行数据库导入")
|
||||
}
|
||||
} else if f.restores != 2 {
|
||||
t.Fatal("导入后失败应执行数据库回滚")
|
||||
}
|
||||
}
|
||||
if scenario != "snapshot" && scenario != "decode" {
|
||||
matches, _ := filepath.Glob(filepath.Join(cfg.Paths.Backup, "scert-postgres-before-restore-*", "scert.pgdump"))
|
||||
if len(matches) != 1 {
|
||||
t.Fatal("恢复前快照应保留供管理员检查")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresRestoreRejectsDifferentDatabase(t *testing.T) {
|
||||
current := config.Config{Database: config.DatabaseConfig{Driver: "postgres", DSN: "postgres://db.test/current?sslmode=verify-full"}}
|
||||
backup := current
|
||||
backup.Database.DSN = "postgres://db.test/other?sslmode=verify-full"
|
||||
if err := validateRestoreCompatibility(current, backup); err == nil || !strings.Contains(err.Error(), "拒绝跨数据库恢复") {
|
||||
t.Fatal("恢复不得连接不同目标数据库")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresBackupDoesNotOverwriteExistingFile(t *testing.T) {
|
||||
cfg, _, _ := preparePostgresRestoreFixture(t, false)
|
||||
connection, err := resolveMaintenanceDatabase(t.Context(), cfg.Database, cfg.Paths.Config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
output := filepath.Join(t.TempDir(), "existing.pgdump")
|
||||
if err := os.WriteFile(output, []byte("existing-backup"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := newPostgresFixture(t)
|
||||
if err := f.tools.backup(t.Context(), connection, output); err == nil {
|
||||
t.Fatal("既有文件不能被覆盖")
|
||||
}
|
||||
content, err := os.ReadFile(output)
|
||||
if err != nil || string(content) != "existing-backup" {
|
||||
t.Fatal("拒绝覆盖后必须保留原文件")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresArchiveRejectsInvalidMagicAndInventory(t *testing.T) {
|
||||
input := filepath.Join(t.TempDir(), "scert.pgdump")
|
||||
if err := os.WriteFile(input, []byte("not-a-custom-archive"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := newPostgresFixture(t)
|
||||
if err := f.tools.validateArchive(t.Context(), input); err == nil {
|
||||
t.Fatal("非自定义格式归档必须拒绝")
|
||||
}
|
||||
for _, listing := range []string{"", "; empty", "malformed", "1; 1259 1 TABLE public a owner\n2; 1259 2 TABLE public a owner\n", strings.Repeat("x", (8<<20)+1)} {
|
||||
tools := &postgresTools{restorePath: "restore", run: func(_ context.Context, _ string, _, _ []string, output io.Writer) error {
|
||||
_, err := io.WriteString(output, listing)
|
||||
return err
|
||||
}}
|
||||
if _, err := tools.inventory(t.Context(), input); err == nil {
|
||||
t.Fatal("空、畸形、重复或超限清单必须拒绝")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresCanceledBackupIsNotPublished(t *testing.T) {
|
||||
cfg, current, _ := preparePostgresRestoreFixture(t, false)
|
||||
f := newPostgresFixture(t)
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
f.tools.run = func(ctx context.Context, tool string, args, env []string, output io.Writer) error {
|
||||
err := f.run(ctx, tool, args, env, output)
|
||||
if tool == "restore" && slices.Contains(args, "--file=-") {
|
||||
cancel()
|
||||
}
|
||||
return err
|
||||
}
|
||||
output := filepath.Join(t.TempDir(), "backup")
|
||||
if err := createBackupWithPostgresTools(ctx, cfg, current, output, f.factory); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("完成快照后取消仍应停止发布:%v", err)
|
||||
}
|
||||
if _, err := os.Stat(output); !os.IsNotExist(err) {
|
||||
t.Fatal("取消的备份不能发布")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresMissingRestoreToolsPreserveCurrentAssets(t *testing.T) {
|
||||
cfg, current, input := preparePostgresRestoreFixture(t, false)
|
||||
before, _ := os.ReadFile(current)
|
||||
factory := func(context.Context) (*postgresTools, error) {
|
||||
return nil, errors.New("模拟官方工具未安装")
|
||||
}
|
||||
command := newRestoreCommandWithPostgresTools(factory)
|
||||
command.SetArgs([]string{"--input", input})
|
||||
command.SetOut(io.Discard)
|
||||
command.SetErr(io.Discard)
|
||||
if err := command.Execute(); err == nil {
|
||||
t.Fatal("缺少工具不能恢复")
|
||||
}
|
||||
after, _ := os.ReadFile(current)
|
||||
if !bytes.Equal(before, after) || !isRegularFile(filepath.Join(cfg.Paths.Certificate, "old.pem")) {
|
||||
t.Fatal("缺少工具不得改变当前配置或证书")
|
||||
}
|
||||
if _, err := os.Stat(cfg.Paths.Data); !os.IsNotExist(err) {
|
||||
t.Fatal("缺少工具时不应进入锁和数据库恢复阶段")
|
||||
}
|
||||
}
|
||||
148
backend/internal/cli/postgres_tools.go
Normal file
148
backend/internal/cli/postgres_tools.go
Normal file
@ -0,0 +1,148 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/config"
|
||||
)
|
||||
|
||||
type postgresToolRunner func(context.Context, string, []string, []string, io.Writer) error
|
||||
type postgresToolsFactory func(context.Context) (*postgresTools, error)
|
||||
|
||||
type postgresTools struct {
|
||||
dumpPath string
|
||||
restorePath string
|
||||
run postgresToolRunner
|
||||
}
|
||||
|
||||
var postgresVersionPattern = regexp.MustCompile(`^pg_(?:dump|restore) \(PostgreSQL\) (\d+)\.`)
|
||||
|
||||
func loadPostgresTools(ctx context.Context) (*postgresTools, error) {
|
||||
dump, err := exec.LookPath("pg_dump")
|
||||
if err != nil {
|
||||
return nil, errors.New("PostgreSQL 备份需要安装官方 pg_dump 和 pg_restore(相同主版本,16 或更高),并加入 PATH")
|
||||
}
|
||||
restore, err := exec.LookPath("pg_restore")
|
||||
if err != nil {
|
||||
return nil, errors.New("PostgreSQL 恢复及备份校验需要 pg_restore,请安装与 pg_dump 相同主版本的官方工具")
|
||||
}
|
||||
tools := &postgresTools{dumpPath: dump, restorePath: restore, run: executePostgresTool}
|
||||
if err := tools.validateVersions(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tools, nil
|
||||
}
|
||||
|
||||
func executePostgresTool(ctx context.Context, path string, args, env []string, output io.Writer) error {
|
||||
command := exec.CommandContext(ctx, path, args...)
|
||||
command.Env = env
|
||||
command.Stdout = output
|
||||
// 工具可能在错误中回显数据库内容或连接信息,不捕获、不透传原始输出。
|
||||
command.Stderr = io.Discard
|
||||
command.WaitDelay = 2 * time.Second
|
||||
if err := command.Run(); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
return errors.New("PostgreSQL 工具执行失败,请检查工具版本、TLS 证书、数据库权限、连接及备份完整性;原始输出已隐藏")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 清除继承的连接设置,避免 PGHOST、PGSERVICE 等环境变量改变目标或降低 TLS。
|
||||
func postgresToolEnvironment() []string {
|
||||
env := make([]string, 0, len(os.Environ())+6)
|
||||
for _, entry := range os.Environ() {
|
||||
key, _, _ := strings.Cut(entry, "=")
|
||||
key = strings.ToUpper(key)
|
||||
if strings.HasPrefix(key, "PG") || key == "LC_ALL" || key == "LANG" {
|
||||
continue
|
||||
}
|
||||
env = append(env, entry)
|
||||
}
|
||||
return append(env, "LC_ALL=C", "LANG=C", "PGPASSFILE="+os.DevNull, "PGSERVICEFILE="+os.DevNull)
|
||||
}
|
||||
|
||||
func postgresToolConnection(cfg config.DatabaseConfig) (string, []string, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(cfg.DSN))
|
||||
if err != nil || cfg.Driver != "postgres" || parsed.User == nil {
|
||||
return "", nil, errors.New("PostgreSQL 维护连接无效或未解析加密账号")
|
||||
}
|
||||
username := parsed.User.Username()
|
||||
password, exists := parsed.User.Password()
|
||||
if username == "" || !exists || password == "" || strings.ContainsRune(username+password, 0) {
|
||||
return "", nil, errors.New("PostgreSQL 维护账号或密码无效")
|
||||
}
|
||||
parsed.User = nil
|
||||
if err := config.ValidatePostgresDSN(parsed.String()); err != nil {
|
||||
return "", nil, errors.New("PostgreSQL 维护连接参数不满足安全要求")
|
||||
}
|
||||
query := parsed.Query()
|
||||
// 只接受已确认在 pgx 和 libpq 中具有一致连接语义的参数。
|
||||
for key := range query {
|
||||
switch key {
|
||||
case "sslmode", "sslrootcert", "sslcert", "sslkey", "connect_timeout", "application_name":
|
||||
default:
|
||||
return "", nil, errors.New("PostgreSQL 维护连接包含不支持的参数,请仅使用 TLS 证书、连接超时及应用名称设置")
|
||||
}
|
||||
}
|
||||
if query.Get("sslrootcert") == "" {
|
||||
// libpq 16 起支持系统信任库;不同平台的库来源仍需实机核对,不能降级 TLS。
|
||||
query.Set("sslrootcert", "system")
|
||||
}
|
||||
if query.Get("connect_timeout") == "" {
|
||||
query.Set("connect_timeout", "10")
|
||||
}
|
||||
query.Set("gssencmode", "disable")
|
||||
parsed.RawQuery = query.Encode()
|
||||
env := append(postgresToolEnvironment(), "PGUSER="+username, "PGPASSWORD="+password)
|
||||
return parsed.String(), env, nil
|
||||
}
|
||||
|
||||
func (tools *postgresTools) validateVersions(ctx context.Context) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
major := 0
|
||||
for _, path := range []string{tools.dumpPath, tools.restorePath} {
|
||||
output := &limitedPostgresOutput{remaining: 4096}
|
||||
if err := tools.run(ctx, path, []string{"--version"}, postgresToolEnvironment(), output); err != nil {
|
||||
return errors.New("无法读取 PostgreSQL 工具版本,请检查 PATH 和可执行文件")
|
||||
}
|
||||
match := postgresVersionPattern.FindStringSubmatch(strings.TrimSpace(output.String()))
|
||||
if len(match) != 2 {
|
||||
return errors.New("无法识别 PostgreSQL 工具版本")
|
||||
}
|
||||
version, err := strconv.Atoi(match[1])
|
||||
if err != nil || version < 16 || (major != 0 && major != version) {
|
||||
return errors.New("pg_dump 和 pg_restore 必须具有相同主版本且不低于 16;pg_dump 也不得低于数据库服务端主版本")
|
||||
}
|
||||
major = version
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type limitedPostgresOutput struct {
|
||||
buffer bytes.Buffer
|
||||
remaining int
|
||||
}
|
||||
|
||||
func (output *limitedPostgresOutput) String() string { return output.buffer.String() }
|
||||
|
||||
func (output *limitedPostgresOutput) Write(value []byte) (int, error) {
|
||||
if len(value) > output.remaining {
|
||||
return 0, fmt.Errorf("PostgreSQL 工具输出超过安全上限")
|
||||
}
|
||||
output.remaining -= len(value)
|
||||
return output.buffer.Write(value)
|
||||
}
|
||||
168
backend/internal/cli/postgres_tools_test.go
Normal file
168
backend/internal/cli/postgres_tools_test.go
Normal file
@ -0,0 +1,168 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/config"
|
||||
)
|
||||
|
||||
func TestPostgresToolConnectionKeepsSecretsOutOfArguments(t *testing.T) {
|
||||
t.Setenv("PGHOST", "unauthorized.example.test")
|
||||
t.Setenv("PGSERVICE", "unexpected-service")
|
||||
t.Setenv("PGOPTIONS", "-c search_path=unexpected")
|
||||
t.Setenv("PGSSLMODE", "disable")
|
||||
t.Setenv("PGPASSWORD", "unexpected-password")
|
||||
const password = "p@ss:' /\\\nword"
|
||||
parsed, _ := url.Parse("postgres://db.example.test:5432/scert?sslmode=verify-full&application_name=scert")
|
||||
parsed.User = url.UserPassword("backup-user", password)
|
||||
dsn, env, err := postgresToolConnection(config.DatabaseConfig{Driver: "postgres", DSN: parsed.String()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(dsn, password) || strings.Contains(dsn, "backup-user") {
|
||||
t.Fatal("账号密码不能进入命令行连接参数")
|
||||
}
|
||||
target, err := url.Parse(dsn)
|
||||
if err != nil || target.Host != "db.example.test:5432" || target.Path != "/scert" || target.User != nil {
|
||||
t.Fatal("维护连接改变了数据库目标")
|
||||
}
|
||||
for key, value := range map[string]string{"sslmode": "verify-full", "sslrootcert": "system", "connect_timeout": "10", "gssencmode": "disable"} {
|
||||
if target.Query().Get(key) != value {
|
||||
t.Fatalf("维护连接参数未保留安全值:%s", key)
|
||||
}
|
||||
}
|
||||
for _, entry := range env {
|
||||
if strings.HasPrefix(entry, "PGHOST=") || strings.HasPrefix(entry, "PGSERVICE=") || strings.HasPrefix(entry, "PGOPTIONS=") || strings.Contains(entry, "unexpected-password") {
|
||||
t.Fatal("工具不应继承外部 PostgreSQL 连接环境")
|
||||
}
|
||||
}
|
||||
if !slices.Contains(env, "PGUSER=backup-user") || !slices.Contains(env, "PGPASSWORD="+password) || !slices.Contains(env, "PGPASSFILE="+os.DevNull) {
|
||||
t.Fatal("子进程专属凭据环境不完整")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresToolConnectionRejectsAmbiguousParameters(t *testing.T) {
|
||||
for _, raw := range []string{
|
||||
"postgres://owner:password@db.test/scert?sslmode=disable",
|
||||
"postgres://owner:password@db.test/scert?sslmode=verify-full&password=override",
|
||||
"postgres://owner:password@db.test/scert?sslmode=verify-full&host=other.test",
|
||||
"postgres://owner:password@db.test/scert?sslmode=verify-full&options=-c+search_path%3Dother",
|
||||
"postgres://owner:password@db.test/scert?sslmode=verify-full&service=other",
|
||||
"postgres://owner:p%00word@db.test/scert?sslmode=verify-full",
|
||||
"postgres://db.test/scert?sslmode=verify-full",
|
||||
} {
|
||||
if _, _, err := postgresToolConnection(config.DatabaseConfig{Driver: "postgres", DSN: raw}); err == nil {
|
||||
t.Fatal("不安全或语义不一致的维护连接未被拒绝")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresToolConnectionPreservesExplicitTLSFiles(t *testing.T) {
|
||||
parsed, _ := url.Parse("postgres://owner:password@db.test/scert?sslmode=verify-full")
|
||||
query := parsed.Query()
|
||||
for key, value := range map[string]string{"sslrootcert": "/secure/ca bundle.pem", "sslcert": "/secure/client.pem", "sslkey": "/secure/client.key", "connect_timeout": "7"} {
|
||||
query.Set(key, value)
|
||||
}
|
||||
parsed.RawQuery = query.Encode()
|
||||
dsn, _, err := postgresToolConnection(config.DatabaseConfig{Driver: "postgres", DSN: parsed.String()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
actual, _ := url.Parse(dsn)
|
||||
for key, value := range query {
|
||||
if actual.Query().Get(key) != value[0] {
|
||||
t.Fatalf("维护连接改变了显式 TLS 或超时配置:%s", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresToolOutputLimitAppliesToCopy(t *testing.T) {
|
||||
output := &limitedPostgresOutput{remaining: 4}
|
||||
if _, err := io.Copy(output, strings.NewReader("output-over-limit")); err == nil {
|
||||
t.Fatal("工具输出不能通过流式复制绕过容量限制")
|
||||
}
|
||||
if len(output.String()) > 4 {
|
||||
t.Fatal("超限输出不应保留在内存中")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresToolsRequireCompatibleVersions(t *testing.T) {
|
||||
for _, pair := range [][2]string{{"16.4", "16.9"}, {"15.9", "15.9"}, {"16.4", "17.2"}, {"invalid", "16.4"}} {
|
||||
t.Run(strings.Join(pair[:], "-"), func(t *testing.T) {
|
||||
tools := &postgresTools{dumpPath: "pg_dump", restorePath: "pg_restore"}
|
||||
tools.run = func(ctx context.Context, path string, args, env []string, output io.Writer) error {
|
||||
if ctx.Err() != nil || !slices.Equal(args, []string{"--version"}) {
|
||||
t.Fatal("版本检查参数无效")
|
||||
}
|
||||
version := pair[0]
|
||||
if path == "pg_restore" {
|
||||
version = pair[1]
|
||||
}
|
||||
_, err := io.WriteString(output, path+" (PostgreSQL) "+version+"\n")
|
||||
return err
|
||||
}
|
||||
err := tools.validateVersions(t.Context())
|
||||
if (err == nil) != (pair[0] == "16.4" && pair[1] == "16.9") {
|
||||
t.Fatalf("版本检查结果不符:%v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresMissingToolsFailBeforeBackupFilesystemChanges(t *testing.T) {
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
if _, err := loadPostgresTools(t.Context()); err == nil {
|
||||
t.Fatal("没有官方工具时必须明确拒绝")
|
||||
}
|
||||
root := t.TempDir()
|
||||
output := filepath.Join(root, "missing", "backup")
|
||||
err := createBackup(t.Context(), config.Config{Database: config.DatabaseConfig{Driver: "postgres"}}, "unused.json", output)
|
||||
if err == nil {
|
||||
t.Fatal("缺少工具时不能生成备份")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Dir(output)); !os.IsNotExist(err) {
|
||||
t.Fatal("工具检查失败前不应创建输出目录")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresToolHelperProcess(t *testing.T) {
|
||||
mode := os.Getenv("SCERT_POSTGRES_TOOL_TEST")
|
||||
if mode == "" {
|
||||
return
|
||||
}
|
||||
if mode == "cancel" {
|
||||
time.Sleep(30 * time.Second)
|
||||
} else {
|
||||
_, _ = io.WriteString(os.Stderr, "sensitive-database-value")
|
||||
os.Exit(7)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresToolExecutionRedactsErrorsAndCancels(t *testing.T) {
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, mode := range []string{"failure", "cancel"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
err := executePostgresTool(ctx, executable, []string{"-test.run=^TestPostgresToolHelperProcess$"}, append(postgresToolEnvironment(), "SCERT_POSTGRES_TOOL_TEST="+mode), io.Discard)
|
||||
if err == nil || strings.Contains(err.Error(), "sensitive-database-value") || strings.Contains(err.Error(), executable) {
|
||||
t.Fatal("工具错误必须脱敏并保持失败状态")
|
||||
}
|
||||
if mode == "cancel" && !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatal("超时必须终止工具并返回取消原因")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
65
doc/PostgreSQL备份恢复说明.md
Normal file
65
doc/PostgreSQL备份恢复说明.md
Normal file
@ -0,0 +1,65 @@
|
||||
# PostgreSQL 备份恢复说明
|
||||
|
||||
## 支持范围
|
||||
|
||||
Scert 的 `backup` 命令现已接入 PostgreSQL 官方工具,可在同一备份目录保存:
|
||||
|
||||
- `scert.pgdump`:自定义格式的数据库一致性归档,包含数据库对象、数据、序列、对象所有者与授权。
|
||||
- `scert.json`:不含数据库账号密码的配置。
|
||||
- `master.key`、`secrets.json`:主密钥和加密凭据。
|
||||
- `certificates/`:托管证书文件。
|
||||
- `manifest.json`:文件大小、权限与 SHA-256 校验清单。
|
||||
|
||||
自动 `restore` 目前只支持**原目标数据库、对象清单及所有者一致**的恢复。跨数据库、跨结构迁移版本、对象增删、角色变更或空库灾难恢复不在自动流程支持范围;检查不符时明确拒绝,不能把拒绝保护当作灾难恢复已完成。
|
||||
|
||||
PostgreSQL 集群级角色、角色密码、表空间定义、服务端配置,以及数据库连接所用的外置 CA/客户端证书文件不由此归档创建或恢复。请由数据库管理员单独保护这些资源。数据库账户轮换后,旧备份中的加密密码可能已经失效;恢复旧凭据前应确认它们仍然有效。
|
||||
|
||||
## 运行前提
|
||||
|
||||
1. 在运行 Scert CLI 的机器安装官方 `pg_dump`、`pg_restore`,将可信工具目录加入 `PATH`。
|
||||
2. 两个工具的主版本一致,至少为 PostgreSQL 16;`pg_dump` 的主版本不得低于数据库服务端主版本。工具缺失、版本检查失败或服务端不兼容时停止,不发布不完整备份。
|
||||
3. 数据库连接保持 `sslmode=verify-full`。允许显式 CA、客户端证书/私钥、连接超时和应用名称参数;没有显式 CA 时使用工具的系统信任库,不跳过证书验证。
|
||||
4. 使用 Scert 专属数据库,并停止所有连接该数据库的 Scert 实例及外部结构变更任务。CLI 的本机进程锁不能阻止另一台机器上的进程写入。
|
||||
5. 账号应有读取完整数据及恢复对应对象、所有者和授权的权限。需要的角色、扩展、表空间必须预先存在;权限不足时单事务恢复失败,不忽略错误或静默丢弃 ACL。
|
||||
6. 为新备份及恢复前完整快照预留足够空间。成功恢复后也会保留恢复前快照,需由管理员根据保留策略管理;Scert 不自动删除历史备份。
|
||||
|
||||
以下命令中的路径仅为示例,应替换为本机实际配置与备份路径:
|
||||
|
||||
```bash
|
||||
pg_dump --version
|
||||
pg_restore --version
|
||||
scert --config /scert/config/scert.json backup --output /scert/backup/manual-20260916
|
||||
scert --config /scert/config/scert.json restore --input /scert/backup/manual-20260916
|
||||
```
|
||||
|
||||
## 恢复安全流程
|
||||
|
||||
1. 校验备份清单、配置和加密凭据引用。
|
||||
2. 检查 PostgreSQL 自定义归档头,并通过 `pg_restore` 完整解码到丢弃流;此时不连接目标库。
|
||||
3. 验证配置路径、数据目录、数据库连接目标与当前配置一致,取得本机进程锁。
|
||||
4. 在内存中固定当前账号,用它创建恢复前快照;替换配置或主密钥不能改变本次导入及回滚账号。
|
||||
5. 比较两份归档对象清单。采用严格相等检查,避免 `pg_restore --clean` 只清理归档列出对象而残留额外数据,也保证反向恢复不会留下新对象。
|
||||
6. 保存当前配置,使用 `--clean --if-exists --single-transaction --exit-on-error` 导入,然后恢复凭据及证书。
|
||||
7. 任一步失败时尝试恢复原数据库及配置。请求取消后,数据库回滚仍使用独立的五分钟超时;回滚失败明确要求管理员停止服务并使用保留的快照人工恢复。
|
||||
|
||||
普通工具操作最长三十分钟,版本检查最长十秒,对象清单读取最长三十秒。超时不会被报告为成功。独立回滚不保证抵抗进程被强制杀死、主机断电、磁盘故障或数据库不可达。
|
||||
|
||||
## 敏感信息与信任边界
|
||||
|
||||
- 账号密码只在内存中解密,通过专属子进程环境传递,不写命令行、普通配置、清单或错误日志。继承的 `PG*` 环境变量会清除,避免其改变目标或降低 TLS。
|
||||
- 子进程环境不是对同一操作系统账号或管理员的保密边界。维护操作应在可信主机、专用账号下执行,不能与不可信程序共享账号。
|
||||
- 不透传 `pg_dump`、`pg_restore` 的原始错误输出,避免其回显业务数据或连接信息;失败时检查工具、网络、TLS、空间和数据库权限,不要将完整连接串粘贴到日志。
|
||||
- 归档中有业务数据,备份同时包含主密钥及证书私钥。目录及文件采用受限权限,但**整个备份不是加密容器**;异地存储时须加密并限制访问。
|
||||
- SHA-256 清单只能发现文件损坏或与既有清单不符,不能证明备份来源可信。PostgreSQL 归档可能携带可执行 SQL,只能恢复可信来源的备份,不能导入第三方提供的未知归档。
|
||||
|
||||
## 当前验证状态
|
||||
|
||||
- 已有工具协议替身测试,覆盖完整备份、配置/凭据/证书联动、导入与证书失败、取消、回滚失败、对象差异、归档解码失败、既有文件保护等分支。
|
||||
- 实际测试子进程验证了执行失败脱敏和超时终止;该子进程不是 PostgreSQL 官方工具。
|
||||
- 尚未完成真实 PostgreSQL 服务上的备份、恢复、权限及业务恢复演练,也未完成空库灾难恢复开发。不得仅凭替身测试、Linux 构建或前端测试宣称生产验收通过。
|
||||
|
||||
## 官方语义参考
|
||||
|
||||
- [PostgreSQL 16 libpq 连接参数](https://www.postgresql.org/docs/16/libpq-connect.html):`sslrootcert=system` 与严格 TLS。
|
||||
- [PostgreSQL 16 pg_restore](https://www.postgresql.org/docs/16/app-pgrestore.html):标准输出、单事务恢复、所有者、ACL 及可信归档要求。
|
||||
- [PostgreSQL 16 pg_dump](https://www.postgresql.org/docs/16/app-pgdump.html):自定义格式、一致性归档与服务端版本兼容。
|
||||
@ -39,7 +39,7 @@
|
||||
- [✓] 修复关闭 Web 静态页面导致核心服务无法启动的回归;保留 API、桌面管理及续期服务,覆盖已初始化和首次切换两种场景。
|
||||
- [✓] 修复 PostgreSQL URL 查询参数覆盖加密账号、连接目标和脱敏遗漏;新增落盘拒绝、运行时拒绝、特殊密码、驱动优先级及模糊测试,最新全量回归通过。
|
||||
- [✓] 复审三库接入后的备份恢复兼容性:MySQL 接入加密凭据,PostgreSQL 未实现完整备份时明确拒绝,禁止漏备份却报告成功;内存凭据与拒绝操作的回归测试通过,不代表真实数据库备份恢复已验收。
|
||||
- [!] PostgreSQL 完整备份恢复尚未实现,不能宣称三种数据库的全部运维能力均已完成。
|
||||
- [✓] PostgreSQL 官方工具备份及同目标、同对象清单恢复代码和本地回归通过,包含恢复前快照和取消后的独立限时回滚;真实数据库演练、空库灾难恢复和跨结构版本恢复尚未完成,不能宣称三库运维全部验收。
|
||||
- [!] 新版 Linux 发布包和桌面浏览器验收:本地独立服务启动及发布脚本执行此前被策略拒绝,未绕过限制。
|
||||
- [ ] 真实 MySQL/PostgreSQL 的连接、迁移、权限及完整业务链路验收。
|
||||
- [ ] Linux 最小权限 systemd 下的初始化、防火墙及管理入口切换验收。
|
||||
@ -47,6 +47,20 @@
|
||||
|
||||
## 本轮验证
|
||||
|
||||
### PostgreSQL 维护功能续接验证(2026-09-16 21:43)
|
||||
|
||||
- 修改前创建并推送中文检查点 `943fb8c`,未混入历史补丁、脚本或二进制。
|
||||
- 最新后端全量普通测试、全量竞态检测、`go vet`、`go mod verify`、`go mod tidy -diff` 全部通过。
|
||||
- 完整备份、恢复成功/失败/取消/回滚、取消不发布、缺工具保护等关键场景以 `-race -count=10` 重复通过;数据库行为由工具协议替身模拟,没有真实数据库连接。
|
||||
- 实际子进程失败脱敏、超时终止测试通过;测试子进程是 Go 测试程序,不是 PostgreSQL 官方工具。
|
||||
- 桌面模块普通测试、竞态检测、静态检查、依赖校验及一致性检查通过;前端 39 项测试及生产构建通过,产物保留在 `.runtime/frontend-postgres-backup-20260916-2128`。
|
||||
- Linux amd64 无 CGO 源码构建及 CLI 测试二进制交叉编译通过,最新测试产物为 `.runtime/cli-postgres-backup-linux-20260916-2140.test`;没有执行 Linux 测试二进制,没有生成新版发布包或部署远程服务器。
|
||||
- 覆盖率快照 `.runtime/postgres-backup-20260916-2136.out` 的 CLI 总语句覆盖率为 51.5%,此快照早于最后两个补充测试,不代表全代码全面覆盖或实机验收。
|
||||
- 中间发现测试代码括号遗漏以及测试断言将 `--no-password` 错判为密码泄漏,均已修正;上述最终全量回归是在修正和全部代码改动完成后执行。
|
||||
- 差异、Go 格式和 UTF-8 无 BOM 检查通过。文档、注释与提示为中文,无中文 HTML 数字实体;未删除用户历史文件,未连接远程主机或修改真实 DNS、证书和生产数据库。
|
||||
|
||||
以下为前序初始化及连接串安全阶段的验证记录,不替代上述本轮结果。
|
||||
|
||||
在 Windows 开发工作区执行并通过:
|
||||
|
||||
```text
|
||||
@ -107,7 +121,9 @@ HTTPS 集成测试只连接本机回环地址,严格校验证书及名称,
|
||||
|
||||
新增数据库加密引用后,MySQL 维护操作必须在内存中解密账号再建立连接,不能直接使用无账号的持久化 DSN。恢复前应固定可用连接,避免配置和主密钥替换过程中失去快照或回滚连接。备份校验必须包括数据库凭据引用。
|
||||
|
||||
PostgreSQL 当前可选择初始化,但原完整备份实现只有 SQLite/MySQL。本轮先让 PostgreSQL 的 `backup`、`restore` 明确失败,在创建输出或替换目标前阻止不完整备份被当作成功。完整实现及真实恢复演练仍是生产准入阻塞,不能把这一防护当作备份功能开发完成。
|
||||
PostgreSQL 原完整备份实现只有 SQLite/MySQL,前序版本先采用明确拒绝保护。2026-09-16 本轮已接入官方 `pg_dump`/`pg_restore`:要求同主版本且至少为 16,生成自定义归档并完整解码校验,配置、加密凭据、证书共同纳入备份清单。缺工具、解码失败或快照失败不能发布备份。
|
||||
|
||||
自动恢复只支持相同目标及严格一致的对象清单,保留所有者和授权;先保存当前数据库快照,再以单事务导入,后续证书或凭据失败则恢复原数据。MySQL 和 PostgreSQL 回滚不再沿用已取消请求,改用独立五分钟超时。空库灾难恢复、跨结构版本恢复仍需原生工具和管理员流程。详细前提、保密边界及限制见 `PostgreSQL备份恢复说明.md`;本地替身测试不能替代真实恢复演练。
|
||||
|
||||
### PostgreSQL 连接串安全边界
|
||||
|
||||
@ -129,4 +145,4 @@ PostgreSQL 连接串日志辅助函数改为整体隐藏,解析失败不返回
|
||||
|
||||
## 下一步
|
||||
|
||||
优先补齐 PostgreSQL 完整备份恢复,完成非特权防火墙策略回归及 Linux 实机验证;在允许启动服务和执行发布脚本的环境中完成浏览器、实际证书指纹核对与 Linux 发布验收。真实数据库和 DNS/ACME 测试必须使用专属测试数据与已授权域名,并明确测试范围,不能清空生产数据库或触碰禁止修改的域名。
|
||||
优先完成 PostgreSQL 官方工具与真实数据库的备份恢复演练,并继续设计空库灾难恢复和跨结构版本恢复;完成非特权防火墙策略及 Linux 实机验证。在允许启动服务和执行发布脚本的环境中完成浏览器、实际证书指纹核对与 Linux 发布验收。真实数据库和 DNS/ACME 测试必须使用专属测试数据与已授权域名,并明确测试范围,不能清空生产数据库或触碰禁止修改的域名。
|
||||
|
||||
Loading…
Reference in New Issue
Block a user