Scert/backend/internal/cli/postgres_tools.go

149 lines
5.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}