diff --git a/README.md b/README.md index 084c841..cc005ac 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,9 @@ EXPECTED_REMOTE=gdrive:backup | `--help`, `-h` | 显示帮助信息 | ✓ | ✓ | | `--config ` | 指定配置文件路径 | - | ✓ | | `--version` | 显示版本信息 | - | ✓ | +| `config list` | 列出可导出的配置文件 | - | ✓ | +| `config export` | 导出配置文件(age 加密归档) | - | ✓ | +| `config import` | 导入配置文件(解密还原) | - | ✓ | ## 环境变量 @@ -245,16 +248,17 @@ Go 版本位于 `go/` 目录,提供跨平台支持和并行处理能力。 ``` go/ -├── main.go # 程序入口,CLI 参数解析 -├── orchestrator.go # 备份流程编排器 -├── config.go # 配置管理 -├── docker.go # Docker Compose 服务管理 -├── backup.go # Kopia 备份操作 -├── logger.go # 日志系统 -├── gist.go # GitHub Gist 日志上传 -├── notify.go # Apprise 通知 -├── Makefile # 交叉编译脚本 -└── dist/ # 编译产物目录 +├── main.go # 程序入口,CLI 参数解析,子命令路由 +├── orchestrator.go # 备份流程编排器 +├── config.go # 配置管理 +├── config_bundle.go # 配置导出/导入(age 加密) +├── docker.go # Docker Compose 服务管理 +├── backup.go # Kopia 备份操作 +├── logger.go # 日志系统 +├── gist.go # GitHub Gist 日志上传 +├── notify.go # Apprise 通知 +├── Makefile # 交叉编译脚本 +└── dist/ # 编译产物目录 ``` #### 构建命令 @@ -489,6 +493,157 @@ journalctl -u yewresin-backup.service -f - **错误通知**:脚本已集成 Apprise 通知,配置后可自动发送备份结果 - **避免重叠**:脚本内置锁机制,防止多个备份任务同时运行 +### 使用 sudo cron 运行 + +Docker 操作通常需要 root 权限,但 Kopia 和 rclone 的配置文件默认存储在**当前用户**的 home 目录下。如果你以普通用户配置了 Kopia 和 rclone,然后在 `sudo crontab` 中运行脚本,root 用户会找不到配置文件。 + +通过 `KOPIA_CONFIG_FILE` 和 `RCLONE_CONFIG` 环境变量,你可以将配置文件路径指向原来的非 root 用户目录,避免手动复制配置: + +```bash +# 假设你以 yewfence 用户配置了 kopia 和 rclone +# 在 .env 中添加以下配置: + +# Kopia 配置文件(默认位于 ~/.config/kopia/repository.config) +KOPIA_CONFIG_FILE="/home/yewfence/.config/kopia/repository.config" + +# Rclone 配置文件(默认位于 ~/.config/rclone/rclone.conf) +RCLONE_CONFIG="/home/yewfence/.config/rclone/rclone.conf" +``` + +然后在 root 的 crontab 中配置定时任务: + +```bash +sudo crontab -e + +# 每天北京时间凌晨 3 点执行(UTC 19:00) +0 19 * * * /home/yewfence/yewresin/yewresin -y +``` + +> **提示**: +> - 用 `echo ~yewfence` 确认用户的 home 目录路径 +> - 如果你的普通用户在 `docker` 用户组中可以免 sudo 运行 Docker,也可以直接使用普通用户的 `crontab -e` 配置,这样无需额外指定配置文件路径 + + +## 异地恢复引导 + +当服务器需要迁移或灾难恢复时,按以下步骤从备份中恢复数据。 + +### 快速迁移(Go 版本) + +Go 版本提供配置导出/导入功能,可以将 rclone、kopia 的配置文件加密打包,在新服务器上一键还原: + +```bash +# 在旧服务器上导出配置(会自动检测 rclone.conf、repository.config 等) +yewresin config export -o my-config.age + +# 将 my-config.age 传到新服务器后导入 +yewresin config import my-config.age +``` + +导出内容包括:`.env`、`rclone.conf`、`repository.config`、`repository.config.kopia-password`(如存在)。归档使用 age scrypt 密码加密,可以安全地通过任何渠道传输。 + +导出时需要输入加密密码(两次确认),导入时需要输入相同的密码解密。密码不会存储在任何地方,请妥善保管。 + +使用 `yewresin config list` 可以预览会导出哪些文件及其路径。 + +### 手动恢复 + +如果你使用 Shell 版本,或需要更细粒度的控制,可以按以下步骤手动恢复。 + +### 1. 安装依赖 + +在新机器上安装 Kopia 和 rclone(如果备份使用了 rclone 远端): + +```bash +# 安装 kopia +curl -s https://kopia.io/signing-key | sudo gpg --dearmor -o /etc/apt/keyrings/kopia-keyring.gpg +echo "deb [signed-by=/etc/apt/keyrings/kopia-keyring.gpg] http://packages.kopia.io/apt/ stable main" | sudo tee /etc/apt/sources.list.d/kopia.list +sudo apt update && sudo apt install kopia + +# 安装 rclone(如果备份存储在云端) +curl https://rclone.org/install.sh | sudo bash +``` + +### 2. 配置 rclone(如需) + +如果你的 Kopia 仓库使用 rclone 作为存储后端,需要先配置好相同的远端: + +```bash +# 交互式配置(按提示输入云端凭据) +rclone config + +# 或者直接从旧机器复制配置文件 +# 默认位置:~/.config/rclone/rclone.conf +``` + +### 3. 连接 Kopia 仓库 + +```bash +# 连接 rclone 远端仓库(与备份时的 EXPECTED_REMOTE 一致) +kopia repository connect rclone --remote-path="gdrive:backup" + +# 或连接本地/文件系统仓库 +kopia repository connect filesystem --path /path/to/kopia-repo + +# 或使用 S3 仓库 +kopia repository connect s3 --bucket=my-backup-bucket \ + --access-key=AKIAIOSFODNN7EXAMPLE \ + --secret-access-key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY +``` + +> 连接时需要输入创建仓库时设置的密码(即 `KOPIA_PASSWORD`)。 + +### 4. 查看可用快照 + +```bash +kopia snapshot list +``` + +输出示例: + +```text +user@hostname:/opt/docker_file + 2025-12-20 03:00:15 UTC k1a2b3c4d5e6f7 102.6 MB + 2025-12-21 03:00:12 UTC k8a9b0c1d2e3f4 103.1 MB (+0.5 MB) +``` + +### 5. 恢复数据 + +**方式一:直接恢复到目标目录(推荐)** + +```bash +# 恢复整个快照到指定目录 +kopia snapshot restore /opt/docker_file +``` + +**方式二:挂载后手动选择文件** + +```bash +mkdir /tmp/kopia-mount +kopia mount /tmp/kopia-mount & + +# 浏览并按需复制文件 +ls /tmp/kopia-mount/ +cp -r /tmp/kopia-mount/some-service /opt/docker_file/ + +# 完成后卸载 +umount /tmp/kopia-mount +``` + +### 6. 恢复后启动服务 + +```bash +# 逐个进入服务目录启动(docker compose 会自动检测 compose 文件) +cd /opt/docker_file +for dir in */; do + if ls "$dir"compose*.y*ml "$dir"docker-compose*.y*ml 2>/dev/null | head -1 > /dev/null; then + echo "Starting $dir..." + (cd "$dir" && docker compose up -d) + fi +done +``` + +> 更多 Kopia 用法参考 [Kopia 官方文档](https://kopia.io/docs/),rclone 配置参考 [rclone 官方文档](https://rclone.org/docs/)。 ## License diff --git a/go/config.go b/go/config.go index e8ec6a4..224bdaa 100644 --- a/go/config.go +++ b/go/config.go @@ -41,18 +41,28 @@ type Config struct { RcloneConfig string // Rclone 配置文件路径 } +// resolveEnvPath 解析 .env 文件路径 +// 如果 configPath 非空则直接返回,否则默认为程序同目录的 .env +func resolveEnvPath(configPath string) (string, error) { + if configPath != "" { + return configPath, nil + } + exe, err := os.Executable() + if err != nil { + return "", fmt.Errorf("获取程序路径失败: %w", err) + } + return filepath.Join(filepath.Dir(exe), ".env"), nil +} + // LoadConfig 从 .env 文件和环境变量加载配置 func LoadConfig(configPath string) (*Config, error) { originalPath := configPath // 确定配置文件路径 - if configPath == "" { - // 默认使用程序所在目录的 .env - exe, err := os.Executable() - if err != nil { - return nil, fmt.Errorf("获取程序路径失败: %w", err) - } - configPath = filepath.Join(filepath.Dir(exe), ".env") + resolved, err := resolveEnvPath(configPath) + if err != nil { + return nil, err } + configPath = resolved // 加载 .env 文件(如果存在) if _, err := os.Stat(configPath); err != nil { diff --git a/go/config_bundle.go b/go/config_bundle.go new file mode 100644 index 0000000..eddbc54 --- /dev/null +++ b/go/config_bundle.go @@ -0,0 +1,785 @@ +package main + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "encoding/json" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "filippo.io/age" + "github.com/joho/godotenv" + "golang.org/x/term" +) + +// ConfigFileEntry 描述一个待导出/导入的配置文件 +type ConfigFileEntry struct { + ArchiveName string `json:"archive_name"` // 归档内文件名 + OriginalPath string `json:"original_path"` // 原始绝对路径 + Source string `json:"source"` // 路径来源说明 + Description string `json:"description"` // 文件描述 +} + +// Manifest 归档元数据,作为 tar 的第一个条目存储 +type Manifest struct { + Version int `json:"version"` + CreatedAt string `json:"created_at"` + YewResinVersion string `json:"yewresin_version"` + Files []ConfigFileEntry `json:"files"` +} + +// =================== 路径探测 =================== + +func defaultRcloneConfigPath() (string, error) { + if runtime.GOOS == "windows" { + appData := os.Getenv("APPDATA") + if appData == "" { + return "", fmt.Errorf("APPDATA 未设置") + } + return filepath.Join(appData, "rclone", "rclone.conf"), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("获取用户主目录失败: %w", err) + } + return filepath.Join(home, ".config", "rclone", "rclone.conf"), nil +} + +func defaultKopiaConfigPath() (string, error) { + if runtime.GOOS == "windows" { + localAppData := os.Getenv("LOCALAPPDATA") + if localAppData == "" { + return "", fmt.Errorf("LOCALAPPDATA 未设置") + } + return filepath.Join(localAppData, "kopia", "repository.config"), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("获取用户主目录失败: %w", err) + } + return filepath.Join(home, ".config", "kopia", "repository.config"), nil +} + +// detectConfigFiles 探测所有可导出的配置文件 +// 返回完整的文件列表(包括不存在的),调用方自行过滤 +func detectConfigFiles(configPath string) ([]ConfigFileEntry, error) { + envPath, err := resolveEnvPath(configPath) + if err != nil { + return nil, err + } + + // 用 godotenv.Read 读取 .env 值,不会污染当前进程环境变量 + envValues := make(map[string]string) + if _, statErr := os.Stat(envPath); statErr == nil { + vals, readErr := godotenv.Read(envPath) + if readErr != nil { + return nil, fmt.Errorf("读取 .env 文件失败 %s: %w", envPath, readErr) + } + envValues = vals + } else if os.IsNotExist(statErr) { + if configPath != "" { + return nil, fmt.Errorf("配置文件不存在或不可访问: %w", statErr) + } + } else { + return nil, fmt.Errorf("检查 .env 文件失败 %s: %w", envPath, statErr) + } + + // 辅助函数:环境变量优先,其次 .env 中的值 + getVal := func(key string) string { + if v := os.Getenv(key); v != "" { + return v + } + return envValues[key] + } + + sourceLabel := func(envKey, defaultDesc string) string { + if os.Getenv(envKey) != "" { + return envKey + " 环境变量" + } + if envValues[envKey] != "" { + return envKey + "(.env 文件)" + } + return defaultDesc + } + + var files []ConfigFileEntry + + // 1. .env 文件 + envSource := "默认路径" + if configPath != "" { + envSource = "--config 参数" + } + files = append(files, ConfigFileEntry{ + ArchiveName: ".env", + OriginalPath: envPath, + Source: envSource, + Description: "YewResin 配置文件", + }) + + // 2. rclone.conf + rclonePath := getVal("RCLONE_CONFIG") + rcloneSource := sourceLabel("RCLONE_CONFIG", "默认路径") + if rclonePath == "" { + rclonePath, err = defaultRcloneConfigPath() + if err != nil { + return nil, err + } + rcloneSource = "默认路径" + } + files = append(files, ConfigFileEntry{ + ArchiveName: "rclone.conf", + OriginalPath: rclonePath, + Source: rcloneSource, + Description: "Rclone 配置文件", + }) + + // 3. repository.config + kopiaPath := getVal("KOPIA_CONFIG_FILE") + kopiaSource := sourceLabel("KOPIA_CONFIG_FILE", "默认路径") + if kopiaPath == "" { + kopiaPath, err = defaultKopiaConfigPath() + if err != nil { + return nil, err + } + kopiaSource = "默认路径" + } + files = append(files, ConfigFileEntry{ + ArchiveName: "repository.config", + OriginalPath: kopiaPath, + Source: kopiaSource, + Description: "Kopia 仓库配置文件", + }) + + // 4. repository.config.kopia-password(同目录自动探测) + kopiaPasswordPath := kopiaPath + ".kopia-password" + files = append(files, ConfigFileEntry{ + ArchiveName: "repository.config.kopia-password", + OriginalPath: kopiaPasswordPath, + Source: "自动检测(同 repository.config 目录)", + Description: "Kopia 仓库密码文件", + }) + + return files, nil +} + +// =================== 归档操作 =================== + +func createTarGz(manifest *Manifest, files []ConfigFileEntry) (*bytes.Buffer, error) { + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + + // 写入 manifest.json 作为第一个条目 + manifestJSON, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return nil, fmt.Errorf("序列化 manifest 失败: %w", err) + } + if err := tw.WriteHeader(&tar.Header{ + Name: "manifest.json", + Size: int64(len(manifestJSON)), + Mode: 0644, + }); err != nil { + return nil, fmt.Errorf("写入 manifest header 失败: %w", err) + } + if _, err := tw.Write(manifestJSON); err != nil { + return nil, fmt.Errorf("写入 manifest 数据失败: %w", err) + } + + // 写入每个配置文件 + for _, f := range files { + data, err := os.ReadFile(f.OriginalPath) + if err != nil { + return nil, fmt.Errorf("读取文件 %s 失败: %w", f.OriginalPath, err) + } + info, err := os.Stat(f.OriginalPath) + if err != nil { + return nil, fmt.Errorf("获取文件信息 %s 失败: %w", f.OriginalPath, err) + } + if err := tw.WriteHeader(&tar.Header{ + Name: f.ArchiveName, + Size: int64(len(data)), + Mode: int64(info.Mode()), + ModTime: info.ModTime(), + }); err != nil { + return nil, fmt.Errorf("写入 tar header 失败: %w", err) + } + if _, err := tw.Write(data); err != nil { + return nil, fmt.Errorf("写入 tar 数据失败: %w", err) + } + } + + if err := tw.Close(); err != nil { + return nil, fmt.Errorf("关闭 tar writer 失败: %w", err) + } + if err := gw.Close(); err != nil { + return nil, fmt.Errorf("关闭 gzip writer 失败: %w", err) + } + return &buf, nil +} + +func extractTarGz(r io.Reader) (*Manifest, map[string][]byte, error) { + gr, err := gzip.NewReader(r) + if err != nil { + return nil, nil, fmt.Errorf("解压失败: %w", err) + } + defer gr.Close() + + tr := tar.NewReader(gr) + fileContents := make(map[string][]byte) + var manifest *Manifest + + for { + header, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, nil, fmt.Errorf("读取归档条目失败: %w", err) + } + + data, err := io.ReadAll(tr) + if err != nil { + return nil, nil, fmt.Errorf("读取归档数据失败: %w", err) + } + + if header.Name == "manifest.json" { + manifest = &Manifest{} + if err := json.Unmarshal(data, manifest); err != nil { + return nil, nil, fmt.Errorf("解析 manifest 失败: %w", err) + } + } else { + fileContents[header.Name] = data + } + } + + if manifest == nil { + return nil, nil, fmt.Errorf("归档中缺少 manifest.json") + } + return manifest, fileContents, nil +} + +// =================== 加密/解密 =================== + +func encryptWithAge(passphrase string, plaintext io.Reader, output io.Writer) error { + recipient, err := age.NewScryptRecipient(passphrase) + if err != nil { + return fmt.Errorf("创建加密接收者失败: %w", err) + } + w, err := age.Encrypt(output, recipient) + if err != nil { + return fmt.Errorf("初始化加密失败: %w", err) + } + if _, err := io.Copy(w, plaintext); err != nil { + return fmt.Errorf("加密数据写入失败: %w", err) + } + return w.Close() +} + +func decryptWithAge(passphrase string, ciphertext io.Reader) (io.Reader, error) { + identity, err := age.NewScryptIdentity(passphrase) + if err != nil { + return nil, fmt.Errorf("创建解密身份失败: %w", err) + } + r, err := age.Decrypt(ciphertext, identity) + if err != nil { + return nil, fmt.Errorf("解密失败(密码可能不正确): %w", err) + } + return r, nil +} + +// =================== 密码输入 =================== + +func promptPassphrase(prompt string) (string, error) { + fmt.Fprint(os.Stderr, prompt) + password, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Fprintln(os.Stderr) // 隐藏输入后换行 + if err != nil { + return "", fmt.Errorf("读取密码失败: %w", err) + } + return string(password), nil +} + +func promptPassphraseConfirm() (string, error) { + pass1, err := promptPassphrase("输入加密密码: ") + if err != nil { + return "", err + } + if pass1 == "" { + return "", fmt.Errorf("密码不能为空") + } + pass2, err := promptPassphrase("再次输入密码: ") + if err != nil { + return "", err + } + if pass1 != pass2 { + return "", fmt.Errorf("两次输入的密码不一致") + } + return pass1, nil +} + +// =================== 辅助函数 =================== + +func humanSize(bytes int64) string { + const unit = 1024 + if bytes < unit { + return fmt.Sprintf("%d B", bytes) + } + div, exp := int64(unit), 0 + for n := bytes / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) +} + +type ImportPlanEntry struct { + ArchiveName string + Description string + SourcePath string + TargetPath string +} + +func detectConfigFileMap(configPath string) (map[string]ConfigFileEntry, error) { + files, err := detectConfigFiles(configPath) + if err != nil { + return nil, err + } + fileMap := make(map[string]ConfigFileEntry, len(files)) + for _, file := range files { + fileMap[file.ArchiveName] = file + } + return fileMap, nil +} + +func resolveImportPlan(manifest *Manifest, configPath string) ([]ImportPlanEntry, error) { + targets, err := detectConfigFileMap(configPath) + if err != nil { + return nil, err + } + + seen := make(map[string]struct{}, len(manifest.Files)) + plan := make([]ImportPlanEntry, 0, len(manifest.Files)) + for _, file := range manifest.Files { + if file.ArchiveName == "" { + return nil, fmt.Errorf("manifest 包含空的归档文件名") + } + if _, exists := seen[file.ArchiveName]; exists { + return nil, fmt.Errorf("manifest 中包含重复条目: %s", file.ArchiveName) + } + seen[file.ArchiveName] = struct{}{} + + target, ok := targets[file.ArchiveName] + if !ok { + return nil, fmt.Errorf("manifest 包含不受支持的配置文件: %s", file.ArchiveName) + } + + targetPath := filepath.Clean(target.OriginalPath) + if targetPath == "." || targetPath == "" { + return nil, fmt.Errorf("无法确定 %s 的目标路径", target.Description) + } + + plan = append(plan, ImportPlanEntry{ + ArchiveName: target.ArchiveName, + Description: target.Description, + SourcePath: file.OriginalPath, + TargetPath: targetPath, + }) + } + + return plan, nil +} + +func writeFileAtomic(path string, data []byte) error { + dir := filepath.Dir(path) + tmpFile, err := os.CreateTemp(dir, filepath.Base(path)+".tmp-*") + if err != nil { + return fmt.Errorf("创建临时文件失败: %w", err) + } + + tmpPath := tmpFile.Name() + cleanup := true + defer func() { + if cleanup { + tmpFile.Close() + os.Remove(tmpPath) + } + }() + + if _, err := tmpFile.Write(data); err != nil { + return fmt.Errorf("写入临时文件失败: %w", err) + } + if err := tmpFile.Sync(); err != nil { + return fmt.Errorf("同步临时文件失败: %w", err) + } + if err := tmpFile.Close(); err != nil { + return fmt.Errorf("关闭临时文件失败: %w", err) + } + if err := os.Chmod(tmpPath, 0600); err != nil { + return fmt.Errorf("设置临时文件权限失败: %w", err) + } + + if runtime.GOOS == "windows" { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("替换目标文件失败: %w", err) + } + } + + if err := os.Rename(tmpPath, path); err != nil { + return fmt.Errorf("重命名临时文件失败: %w", err) + } + + cleanup = false + return nil +} + +func defaultExportOutputPath(now time.Time) string { + return fmt.Sprintf("yewresin-config-%s.age", now.Format("20060102-150405")) +} + +func openExportOutputFile(path string) (*os.File, error) { + return os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) +} + +type restoreBackup struct { + path string + existed bool + data []byte + mode os.FileMode +} + +func validateImportPlanData(importPlan []ImportPlanEntry, fileContents map[string][]byte) error { + for _, f := range importPlan { + if _, ok := fileContents[f.ArchiveName]; !ok { + return fmt.Errorf("归档不完整,缺少 %s 的数据", f.ArchiveName) + } + } + return nil +} + +func rollbackImportedFiles(backups []restoreBackup) error { + var errs []string + for i := len(backups) - 1; i >= 0; i-- { + backup := backups[i] + if backup.existed { + if err := writeFileAtomic(backup.path, backup.data); err != nil { + errs = append(errs, fmt.Sprintf("恢复 %s 失败: %v", backup.path, err)) + continue + } + if err := os.Chmod(backup.path, backup.mode); err != nil { + errs = append(errs, fmt.Sprintf("恢复 %s 权限失败: %v", backup.path, err)) + } + continue + } + if err := os.Remove(backup.path); err != nil && !os.IsNotExist(err) { + errs = append(errs, fmt.Sprintf("删除 %s 失败: %v", backup.path, err)) + } + } + if len(errs) > 0 { + return fmt.Errorf("回滚失败: %s", strings.Join(errs, "; ")) + } + return nil +} + +func restoreWithRollback(backups []restoreBackup, format string, args ...any) error { + restoreErr := fmt.Errorf(format, args...) + rollbackErr := rollbackImportedFiles(backups) + if rollbackErr != nil { + return fmt.Errorf("%v;%v", restoreErr, rollbackErr) + } + if len(backups) > 0 { + return fmt.Errorf("%v,已回滚先前写入的文件", restoreErr) + } + return restoreErr +} + +func restoreImportPlan(importPlan []ImportPlanEntry, fileContents map[string][]byte, output io.Writer) (int, error) { + if err := validateImportPlanData(importPlan, fileContents); err != nil { + return 0, err + } + + backups := make([]restoreBackup, 0, len(importPlan)) + restored := 0 + + for _, f := range importPlan { + backup := restoreBackup{path: f.TargetPath} + if info, err := os.Stat(f.TargetPath); err == nil { + backup.existed = true + backup.mode = info.Mode().Perm() + backup.data, err = os.ReadFile(f.TargetPath) + if err != nil { + return restored, restoreWithRollback(backups, "读取现有文件 %s 失败: %w", f.TargetPath, err) + } + } else if !os.IsNotExist(err) { + return restored, restoreWithRollback(backups, "检查目标文件 %s 失败: %w", f.TargetPath, err) + } + + dir := filepath.Dir(f.TargetPath) + if err := os.MkdirAll(dir, 0o755); err != nil { + return restored, restoreWithRollback(backups, "创建目录 %s 失败: %w", dir, err) + } + + if err := writeFileAtomic(f.TargetPath, fileContents[f.ArchiveName]); err != nil { + return restored, restoreWithRollback(backups, "写入 %s 失败: %w", f.TargetPath, err) + } + + backups = append(backups, backup) + restored++ + if output != nil { + fmt.Fprintf(output, " 已还原: %s -> %s\n", f.Description, f.TargetPath) + } + } + + return restored, nil +} + +// =================== CLI 子命令 =================== + +func printConfigUsage() { + fmt.Fprintf(os.Stderr, "用法: %s config <子命令> [选项]\n\n", os.Args[0]) + fmt.Fprintf(os.Stderr, "子命令:\n") + fmt.Fprintf(os.Stderr, " export 导出配置文件(加密归档)\n") + fmt.Fprintf(os.Stderr, " import 导入配置文件(解密还原)\n") + fmt.Fprintf(os.Stderr, " list 列出可导出的配置文件\n") +} + +func runConfigCmd(args []string) { + if len(args) == 0 || isHelpFlag(args[0]) { + printConfigUsage() + if len(args) == 0 { + os.Exit(1) + } + return + } + + switch args[0] { + case "export": + runConfigExport(args[1:]) + case "import": + runConfigImport(args[1:]) + case "list": + runConfigList(args[1:]) + default: + fmt.Fprintf(os.Stderr, "未知的 config 子命令: %s\n\n", args[0]) + printConfigUsage() + os.Exit(1) + } +} + +func runConfigList(args []string) { + fs := flag.NewFlagSet("config list", flag.ExitOnError) + configFile := fs.String("config", "", "配置文件路径") + fs.Parse(args) + + files, err := detectConfigFiles(*configFile) + if err != nil { + fmt.Fprintf(os.Stderr, "探测配置文件失败: %v\n", err) + os.Exit(1) + } + + fmt.Println() + fmt.Println("==========================================") + fmt.Println("YewResin 配置文件检测结果") + fmt.Println("==========================================") + + found := 0 + for _, f := range files { + fmt.Printf("\n %s\n", f.Description) + fmt.Printf(" 路径: %s\n", f.OriginalPath) + fmt.Printf(" 来源: %s\n", f.Source) + if info, err := os.Stat(f.OriginalPath); err == nil { + fmt.Printf(" 状态: 存在 (%s)\n", humanSize(info.Size())) + found++ + } else { + fmt.Printf(" 状态: 不存在\n") + } + } + + fmt.Println() + fmt.Println("==========================================") + fmt.Printf("共检测 %d 个文件,其中 %d 个存在可导出\n", len(files), found) + fmt.Println("==========================================") +} + +func runConfigExport(args []string) { + fs := flag.NewFlagSet("config export", flag.ExitOnError) + outputFile := fs.String("output", "", "输出文件路径(默认: yewresin-config-YYYYMMDD-HHMMSS.age)") + fs.StringVar(outputFile, "o", "", "输出文件路径(简写)") + configFile := fs.String("config", "", "配置文件路径") + fs.Parse(args) + + // 探测配置文件 + files, err := detectConfigFiles(*configFile) + if err != nil { + fmt.Fprintf(os.Stderr, "探测配置文件失败: %v\n", err) + os.Exit(1) + } + + // 过滤出存在的文件 + var existingFiles []ConfigFileEntry + for _, f := range files { + if _, err := os.Stat(f.OriginalPath); err == nil { + existingFiles = append(existingFiles, f) + } + } + if len(existingFiles) == 0 { + fmt.Fprintln(os.Stderr, "未找到任何可导出的配置文件") + os.Exit(1) + } + + // 展示将导出的文件 + fmt.Println("\n将导出以下配置文件:") + for _, f := range existingFiles { + info, _ := os.Stat(f.OriginalPath) + fmt.Printf(" %-35s %s (%s)\n", f.Description+":", f.OriginalPath, humanSize(info.Size())) + } + fmt.Println() + + // 提示输入密码 + passphrase, err := promptPassphraseConfirm() + if err != nil { + fmt.Fprintf(os.Stderr, "密码输入失败: %v\n", err) + os.Exit(1) + } + + // 构建 manifest + manifest := &Manifest{ + Version: 1, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + YewResinVersion: version, + Files: existingFiles, + } + + // 创建 tar.gz + tarBuf, err := createTarGz(manifest, existingFiles) + if err != nil { + fmt.Fprintf(os.Stderr, "创建归档失败: %v\n", err) + os.Exit(1) + } + + // 确定输出路径 + if *outputFile == "" { + *outputFile = defaultExportOutputPath(time.Now()) + } + + // 加密并写入 + outFile, err := openExportOutputFile(*outputFile) + if err != nil { + fmt.Fprintf(os.Stderr, "创建输出文件失败: %v\n", err) + os.Exit(1) + } + defer outFile.Close() + + if err := encryptWithAge(passphrase, tarBuf, outFile); err != nil { + _ = outFile.Close() + os.Remove(*outputFile) // 清理不完整的文件 + fmt.Fprintf(os.Stderr, "加密失败: %v\n", err) + os.Exit(1) + } + + fmt.Printf("配置已导出到: %s\n", *outputFile) +} + +func runConfigImport(args []string) { + fs := flag.NewFlagSet("config import", flag.ExitOnError) + force := fs.Bool("force", false, "强制覆盖已存在的文件") + fs.BoolVar(force, "f", false, "强制覆盖(简写)") + configFile := fs.String("config", "", "目标配置文件路径") + fs.Parse(args) + + // 验证输入文件 + remaining := fs.Args() + if len(remaining) == 0 { + fmt.Fprintln(os.Stderr, "请指定要导入的归档文件路径") + fmt.Fprintf(os.Stderr, "用法: %s config import [选项] \n", os.Args[0]) + os.Exit(1) + } + inputFile := remaining[0] + + // 打开归档文件 + cipherData, err := os.Open(inputFile) + if err != nil { + fmt.Fprintf(os.Stderr, "打开归档文件失败: %v\n", err) + os.Exit(1) + } + defer cipherData.Close() + + // 提示输入密码 + passphrase, err := promptPassphrase("输入解密密码: ") + if err != nil { + fmt.Fprintf(os.Stderr, "密码输入失败: %v\n", err) + os.Exit(1) + } + + // 解密 + plainReader, err := decryptWithAge(passphrase, cipherData) + if err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } + + // 解压并读取 manifest + manifest, fileContents, err := extractTarGz(plainReader) + if err != nil { + fmt.Fprintf(os.Stderr, "解析归档失败: %v\n", err) + os.Exit(1) + } + + importPlan, err := resolveImportPlan(manifest, *configFile) + if err != nil { + fmt.Fprintf(os.Stderr, "生成导入计划失败: %v\n", err) + os.Exit(1) + } + if err := validateImportPlanData(importPlan, fileContents); err != nil { + fmt.Fprintf(os.Stderr, "导入失败: %v\n", err) + os.Exit(1) + } + + // 展示归档信息 + fmt.Printf("\n归档信息: YewResin %s, 创建于 %s\n", manifest.YewResinVersion, manifest.CreatedAt) + fmt.Println("\n将还原以下文件:") + + var conflicts []ImportPlanEntry + for _, f := range importPlan { + exists := false + if _, statErr := os.Stat(f.TargetPath); statErr == nil { + exists = true + conflicts = append(conflicts, f) + } + status := "(新建)" + if exists { + status = "(已存在,将覆盖)" + } + fmt.Printf(" %-35s -> %s %s\n", f.Description, f.TargetPath, status) + if f.SourcePath != "" && filepath.Clean(f.SourcePath) != f.TargetPath { + fmt.Printf(" 归档原路径: %s\n", f.SourcePath) + } + } + + // 确认覆盖 + if len(conflicts) > 0 && !*force { + fmt.Printf("\n%d 个文件将被覆盖,确认继续?[y/N] ", len(conflicts)) + var response string + fmt.Scanln(&response) + if !strings.EqualFold(response, "y") && !strings.EqualFold(response, "yes") { + fmt.Println("已取消导入") + os.Exit(0) + } + } + + // 写入文件 + fmt.Println() + restored, err := restoreImportPlan(importPlan, fileContents, os.Stdout) + if err != nil { + fmt.Fprintf(os.Stderr, "导入失败: %v\n", err) + os.Exit(1) + } + + fmt.Printf("\n导入完成: %d/%d 个文件已还原\n", restored, len(importPlan)) +} diff --git a/go/config_bundle_test.go b/go/config_bundle_test.go new file mode 100644 index 0000000..8293658 --- /dev/null +++ b/go/config_bundle_test.go @@ -0,0 +1,174 @@ +package main + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +func TestDetectConfigFilesReturnsErrorForInvalidEnv(t *testing.T) { + envPath := filepath.Join(t.TempDir(), ".env") + if err := os.WriteFile(envPath, []byte("BROKEN=\"unterminated\n"), 0o600); err != nil { + t.Fatalf("write env: %v", err) + } + + if _, err := detectConfigFiles(envPath); err == nil || !strings.Contains(err.Error(), "读取 .env 文件失败") { + t.Fatalf("expected invalid .env error, got %v", err) + } +} + +func TestDetectConfigFilesReturnsErrorForMissingExplicitConfig(t *testing.T) { + missingPath := filepath.Join(t.TempDir(), "missing.env") + + if _, err := detectConfigFiles(missingPath); err == nil || !strings.Contains(err.Error(), "配置文件不存在或不可访问") { + t.Fatalf("expected missing explicit config error, got %v", err) + } +} + +func TestResolveImportPlanUsesLocalTargetPaths(t *testing.T) { + baseDir := t.TempDir() + envPath := filepath.Join(baseDir, ".env") + if err := os.WriteFile(envPath, []byte(""), 0o600); err != nil { + t.Fatalf("write env: %v", err) + } + + rclonePath := filepath.Join(baseDir, "target", "rclone.conf") + kopiaPath := filepath.Join(baseDir, "target", "repository.config") + t.Setenv("RCLONE_CONFIG", rclonePath) + t.Setenv("KOPIA_CONFIG_FILE", kopiaPath) + + manifest := &Manifest{ + Files: []ConfigFileEntry{ + {ArchiveName: ".env", OriginalPath: "/tmp/source/.env"}, + {ArchiveName: "rclone.conf", OriginalPath: "/tmp/source/rclone.conf"}, + {ArchiveName: "repository.config", OriginalPath: "/tmp/source/repository.config"}, + {ArchiveName: "repository.config.kopia-password", OriginalPath: "/tmp/source/repository.config.kopia-password"}, + }, + } + + plan, err := resolveImportPlan(manifest, envPath) + if err != nil { + t.Fatalf("resolveImportPlan: %v", err) + } + + want := map[string]string{ + ".env": filepath.Clean(envPath), + "rclone.conf": filepath.Clean(rclonePath), + "repository.config": filepath.Clean(kopiaPath), + "repository.config.kopia-password": filepath.Clean(kopiaPath + ".kopia-password"), + } + + for _, entry := range plan { + if got := entry.TargetPath; got != want[entry.ArchiveName] { + t.Fatalf("archive %s expected target %q, got %q", entry.ArchiveName, want[entry.ArchiveName], got) + } + } +} + +func TestResolveImportPlanRejectsUnknownArchiveName(t *testing.T) { + envPath := filepath.Join(t.TempDir(), ".env") + if err := os.WriteFile(envPath, []byte(""), 0o600); err != nil { + t.Fatalf("write env: %v", err) + } + + manifest := &Manifest{ + Files: []ConfigFileEntry{{ArchiveName: "../../evil", OriginalPath: "/tmp/evil"}}, + } + + if _, err := resolveImportPlan(manifest, envPath); err == nil || !strings.Contains(err.Error(), "不受支持") { + t.Fatalf("expected unsupported archive error, got %v", err) + } +} + +func TestWriteFileAtomicReplacesExistingFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.txt") + if err := os.WriteFile(path, []byte("old"), 0o600); err != nil { + t.Fatalf("write old file: %v", err) + } + + if err := writeFileAtomic(path, []byte("new")); err != nil { + t.Fatalf("writeFileAtomic: %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read new file: %v", err) + } + if string(data) != "new" { + t.Fatalf("expected file content new, got %q", string(data)) + } +} + +func TestDefaultExportOutputPathIncludesTimestamp(t *testing.T) { + got := defaultExportOutputPath(time.Date(2026, time.March, 7, 11, 22, 33, 0, time.UTC)) + want := "yewresin-config-20260307-112233.age" + if got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} + +func TestOpenExportOutputFileFailsIfExists(t *testing.T) { + path := filepath.Join(t.TempDir(), "bundle.age") + if err := os.WriteFile(path, []byte("existing"), 0o600); err != nil { + t.Fatalf("write existing bundle: %v", err) + } + + file, err := openExportOutputFile(path) + if err == nil { + file.Close() + t.Fatal("expected error when output file already exists") + } + if !os.IsExist(err) { + t.Fatalf("expected already exists error, got %v", err) + } +} + +func TestRestoreImportPlanRollsBackOnFailure(t *testing.T) { + baseDir := t.TempDir() + firstPath := filepath.Join(baseDir, "config", ".env") + if err := os.MkdirAll(filepath.Dir(firstPath), 0o755); err != nil { + t.Fatalf("mkdir first dir: %v", err) + } + if err := os.WriteFile(firstPath, []byte("old"), 0o640); err != nil { + t.Fatalf("write original file: %v", err) + } + + blockedDir := filepath.Join(baseDir, "blocked") + if err := os.WriteFile(blockedDir, []byte("not a directory"), 0o600); err != nil { + t.Fatalf("write blocker file: %v", err) + } + secondPath := filepath.Join(blockedDir, "rclone.conf") + + plan := []ImportPlanEntry{ + {ArchiveName: ".env", Description: "YewResin 配置文件", TargetPath: firstPath}, + {ArchiveName: "rclone.conf", Description: "Rclone 配置文件", TargetPath: secondPath}, + } + contents := map[string][]byte{ + ".env": []byte("new"), + "rclone.conf": []byte("rclone"), + } + + restored, err := restoreImportPlan(plan, contents, nil) + if err == nil || !strings.Contains(err.Error(), "已回滚先前写入的文件") { + t.Fatalf("expected rollback error, got restored=%d err=%v", restored, err) + } + + data, readErr := os.ReadFile(firstPath) + if readErr != nil { + t.Fatalf("read rolled back file: %v", readErr) + } + if string(data) != "old" { + t.Fatalf("expected original content after rollback, got %q", string(data)) + } + + info, statErr := os.Stat(firstPath) + if statErr != nil { + t.Fatalf("stat rolled back file: %v", statErr) + } + if runtime.GOOS != "windows" && info.Mode().Perm() != 0o640 { + t.Fatalf("expected original permissions 0640, got %o", info.Mode().Perm()) + } +} diff --git a/go/go.mod b/go/go.mod index afbbfc9..076bf81 100644 --- a/go/go.mod +++ b/go/go.mod @@ -1,5 +1,15 @@ module github.com/YewFence/yewresin -go 1.23 +go 1.24.0 -require github.com/joho/godotenv v1.5.1 +require ( + filippo.io/age v1.3.1 + github.com/joho/godotenv v1.5.1 + golang.org/x/term v0.40.0 +) + +require ( + filippo.io/hpke v0.4.0 // indirect + golang.org/x/crypto v0.45.0 // indirect + golang.org/x/sys v0.41.0 // indirect +) diff --git a/go/go.sum b/go/go.sum index d61b19e..a3ad5b5 100644 --- a/go/go.sum +++ b/go/go.sum @@ -1,2 +1,14 @@ +c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M= +c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo= +filippo.io/age v1.3.1 h1:hbzdQOJkuaMEpRCLSN1/C5DX74RPcNCk6oqhKMXmZi0= +filippo.io/age v1.3.1/go.mod h1:EZorDTYUxt836i3zdori5IJX/v2Lj6kWFU0cfh6C0D4= +filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A= +filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= diff --git a/go/main.go b/go/main.go index 8499b46..77985ed 100644 --- a/go/main.go +++ b/go/main.go @@ -14,7 +14,103 @@ import ( // 版本信息,构建时注入 var version = "dev" +func isVersionFlag(arg string) bool { + return arg == "--version" || arg == "-version" +} + +func isHelpFlag(arg string) bool { + return arg == "--help" || arg == "-help" || arg == "-h" +} + +func extractConfigValue(args []string) (string, bool) { + for i := 0; i < len(args); i++ { + switch { + case args[i] == "--config" || args[i] == "-config": + if i+1 >= len(args) { + return "", false + } + return args[i+1], true + case strings.HasPrefix(args[i], "--config="): + return strings.TrimPrefix(args[i], "--config="), true + case strings.HasPrefix(args[i], "-config="): + return strings.TrimPrefix(args[i], "-config="), true + } + } + return "", false +} + +func hasConfigFlag(args []string) bool { + _, ok := extractConfigValue(args) + return ok +} + +func prepareConfigCommandArgs(globalArgs, subcommandArgs []string) []string { + if len(subcommandArgs) == 0 { + return subcommandArgs + } + if hasConfigFlag(subcommandArgs) { + return subcommandArgs + } + configValue, ok := extractConfigValue(globalArgs) + if !ok { + return subcommandArgs + } + args := make([]string, 0, len(subcommandArgs)+2) + args = append(args, subcommandArgs[0], "--config", configValue) + args = append(args, subcommandArgs[1:]...) + return args +} + +func findCommand(args []string) (int, string) { + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "--": + if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { + return i + 1, args[i+1] + } + return -1, "" + case arg == "--config" || arg == "-config": + i++ + case strings.HasPrefix(arg, "--config=") || strings.HasPrefix(arg, "-config="): + case arg == "--dry-run" || arg == "-n" || arg == "--yes" || arg == "-y": + case isVersionFlag(arg) || isHelpFlag(arg): + case strings.HasPrefix(arg, "-"): + return -1, "" + default: + return i, arg + } + } + return -1, "" +} + func main() { + // --version 全局处理(兼容有无子命令的情况) + if len(os.Args) > 1 { + for _, arg := range os.Args[1:] { + if isVersionFlag(arg) { + fmt.Printf("YewResin %s\n", version) + os.Exit(0) + } + } + + if commandIndex, command := findCommand(os.Args[1:]); commandIndex >= 0 { + switch command { + case "config": + runConfigCmd(prepareConfigCommandArgs(os.Args[1:1+commandIndex], os.Args[2+commandIndex:])) + return + default: + fmt.Fprintf(os.Stderr, "未知命令: %s\n运行 '%s --help' 查看用法\n", command, os.Args[0]) + os.Exit(1) + } + } + } + + runBackup() +} + +// runBackup 执行备份主流程(默认行为) +func runBackup() { // CLI 参数定义 dryRun := flag.Bool("dry-run", false, "模拟运行,不执行实际操作") flag.BoolVar(dryRun, "n", false, "模拟运行(-dry-run 的简写)") @@ -23,11 +119,15 @@ func main() { flag.BoolVar(autoConfirm, "y", false, "跳过确认(-yes 的简写)") configFile := flag.String("config", "", "配置文件路径(默认为程序同目录的 .env)") - showVersion := flag.Bool("version", false, "显示版本信息") flag.Usage = func() { fmt.Fprintf(os.Stderr, "YewResin - Docker 服务备份工具 (Go 版本)\n\n") - fmt.Fprintf(os.Stderr, "用法: %s [选项]\n\n", os.Args[0]) + fmt.Fprintf(os.Stderr, "用法: %s [选项]\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " %s <命令> [选项]\n\n", os.Args[0]) + fmt.Fprintf(os.Stderr, "命令:\n") + fmt.Fprintf(os.Stderr, " config export 导出配置文件(加密归档)\n") + fmt.Fprintf(os.Stderr, " config import 导入配置文件(解密还原)\n") + fmt.Fprintf(os.Stderr, " config list 列出可导出的配置文件\n\n") fmt.Fprintf(os.Stderr, "选项:\n") flag.PrintDefaults() fmt.Fprintf(os.Stderr, "\n示例:\n") @@ -37,11 +137,6 @@ func main() { flag.Parse() - if *showVersion { - fmt.Printf("YewResin %s\n", version) - os.Exit(0) - } - // 加载配置(先加载配置以获取日志文件路径) cfg, err := LoadConfig(*configFile) if err != nil { diff --git a/go/main_test.go b/go/main_test.go index 81cc33a..ac5f127 100644 --- a/go/main_test.go +++ b/go/main_test.go @@ -2,6 +2,7 @@ package main import ( "os" + "reflect" "testing" ) @@ -51,3 +52,33 @@ func TestConfirm(t *testing.T) { } }) } + +func TestFindCommandAfterGlobalConfigFlag(t *testing.T) { + index, command := findCommand([]string{"--config", "custom.env", "config", "list"}) + if index != 2 || command != "config" { + t.Fatalf("expected command config at index 2, got %q at %d", command, index) + } +} + +func TestPrepareConfigCommandArgsForwardsGlobalConfig(t *testing.T) { + got := prepareConfigCommandArgs([]string{"--config", "custom.env"}, []string{"list"}) + want := []string{"list", "--config", "custom.env"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("expected %v, got %v", want, got) + } +} + +func TestPrepareConfigCommandArgsHandlesEmptySubcommand(t *testing.T) { + got := prepareConfigCommandArgs([]string{"--config", "custom.env"}, nil) + if got != nil { + t.Fatalf("expected nil args, got %v", got) + } +} + +func TestPrepareConfigCommandArgsKeepsSubcommandConfig(t *testing.T) { + got := prepareConfigCommandArgs([]string{"--config", "global.env"}, []string{"list", "--config", "local.env"}) + want := []string{"list", "--config", "local.env"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("expected %v, got %v", want, got) + } +}