diff --git a/backend/internal/cli/backup_restore.go b/backend/internal/cli/backup_restore.go index bc14678..0eefbc9 100644 --- a/backend/internal/cli/backup_restore.go +++ b/backend/internal/cli/backup_restore.go @@ -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 } diff --git a/backend/internal/cli/database_maintenance.go b/backend/internal/cli/database_maintenance.go index 47196d8..58c35a3 100644 --- a/backend/internal/cli/database_maintenance.go +++ b/backend/internal/cli/database_maintenance.go @@ -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 { diff --git a/backend/internal/cli/database_maintenance_test.go b/backend/internal/cli/database_maintenance_test.go index f8ce455..4683034 100644 --- a/backend/internal/cli/database_maintenance_test.go +++ b/backend/internal/cli/database_maintenance_test.go @@ -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) diff --git a/backend/internal/cli/operations.go b/backend/internal/cli/operations.go index ba1db35..5a69e7e 100644 --- a/backend/internal/cli/operations.go +++ b/backend/internal/cli/operations.go @@ -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()) } diff --git a/backend/internal/cli/postgres_backup.go b/backend/internal/cli/postgres_backup.go new file mode 100644 index 0000000..4217380 --- /dev/null +++ b/backend/internal/cli/postgres_backup.go @@ -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 +} diff --git a/backend/internal/cli/postgres_backup_test.go b/backend/internal/cli/postgres_backup_test.go new file mode 100644 index 0000000..2a9b0db --- /dev/null +++ b/backend/internal/cli/postgres_backup_test.go @@ -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("缺少工具时不应进入锁和数据库恢复阶段") + } +} diff --git a/backend/internal/cli/postgres_tools.go b/backend/internal/cli/postgres_tools.go new file mode 100644 index 0000000..7110f03 --- /dev/null +++ b/backend/internal/cli/postgres_tools.go @@ -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) +} diff --git a/backend/internal/cli/postgres_tools_test.go b/backend/internal/cli/postgres_tools_test.go new file mode 100644 index 0000000..33fb167 --- /dev/null +++ b/backend/internal/cli/postgres_tools_test.go @@ -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("超时必须终止工具并返回取消原因") + } + }) + } +} diff --git a/doc/PostgreSQL备份恢复说明.md b/doc/PostgreSQL备份恢复说明.md new file mode 100644 index 0000000..3e6d006 --- /dev/null +++ b/doc/PostgreSQL备份恢复说明.md @@ -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):自定义格式、一致性归档与服务端版本兼容。 diff --git a/doc/首次IP初始化开发记录.md b/doc/首次IP初始化开发记录.md index 31cc233..63de6dc 100644 --- a/doc/首次IP初始化开发记录.md +++ b/doc/首次IP初始化开发记录.md @@ -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 测试必须使用专属测试数据与已授权域名,并明确测试范围,不能清空生产数据库或触碰禁止修改的域名。