|
| 1 | +/* |
| 2 | +cmd/create/backup.go |
| 3 | +
|
| 4 | +Copyright © 2025 CODE MONKEY CYBERSECURITY git@cybermonkey.net.au |
| 5 | +*/ |
| 6 | + |
| 7 | +package create |
| 8 | + |
| 9 | +import ( |
| 10 | + "fmt" |
| 11 | + "os" |
| 12 | + "os/exec" |
| 13 | + "path/filepath" |
| 14 | + "strings" |
| 15 | + |
| 16 | + eos "github.com/CodeMonkeyCybersecurity/eos/pkg/eos_cli" |
| 17 | + "github.com/CodeMonkeyCybersecurity/eos/pkg/eos_io" |
| 18 | + "github.com/CodeMonkeyCybersecurity/eos/pkg/interaction" |
| 19 | + "github.com/uptrace/opentelemetry-go-extra/otelzap" |
| 20 | + |
| 21 | + "github.com/spf13/cobra" |
| 22 | + "go.uber.org/zap" |
| 23 | +) |
| 24 | + |
| 25 | +// CreateBackupCmd represents the create backup command |
| 26 | +type backupOptions struct { |
| 27 | + Host string |
| 28 | + User string |
| 29 | + RepoDir string |
| 30 | + PasswordFile string |
| 31 | + Paths []string |
| 32 | +} |
| 33 | + |
| 34 | +var ( |
| 35 | + backupOpts backupOptions |
| 36 | + pathsFlag string |
| 37 | +) |
| 38 | + |
| 39 | +var CreateBackupCmd = &cobra.Command{ |
| 40 | + Use: "backup", |
| 41 | + Short: "Create a new restic backup", |
| 42 | + Long: `This command initializes a restic repository if not already initialized, |
| 43 | +then creates a backup of specified directories.`, |
| 44 | + RunE: eos.Wrap(func(rc *eos_io.RuntimeContext, cmd *cobra.Command, args []string) error { |
| 45 | + |
| 46 | + backupOpts.Host, _ = interaction.PromptIfMissing(rc.Ctx, cmd, "host", "Enter backup host", false) |
| 47 | + backupOpts.User, _ = interaction.PromptIfMissing(rc.Ctx, cmd, "user", "Enter backup user", false) |
| 48 | + backupOpts.RepoDir, _ = interaction.PromptIfMissing(rc.Ctx, cmd, "repo-dir", "Enter remote restic repo directory", false) |
| 49 | + backupOpts.PasswordFile, _ = interaction.PromptIfMissing(rc.Ctx, cmd, "password-file", "Enter restic password file", false) |
| 50 | + pathsFlag, _ = interaction.PromptIfMissing(rc.Ctx, cmd, "paths", "Enter paths to backup (comma separated)", false) |
| 51 | + if pathsFlag != "" { |
| 52 | + for _, p := range strings.Split(pathsFlag, ",") { |
| 53 | + p = strings.TrimSpace(p) |
| 54 | + if p != "" { |
| 55 | + backupOpts.Paths = append(backupOpts.Paths, p) |
| 56 | + } |
| 57 | + } |
| 58 | + } |
| 59 | + |
| 60 | + // Step 1: Ensure Restic is Installed |
| 61 | + otelzap.Ctx(rc.Ctx).Info("Ensuring Restic is installed") |
| 62 | + if err := ensureResticInstalled(rc); err != nil { |
| 63 | + otelzap.Ctx(rc.Ctx).Error("Failed to ensure Restic is installed", zap.Error(err)) |
| 64 | + return err |
| 65 | + } |
| 66 | + |
| 67 | + // Step 2: Generate SSH Keys |
| 68 | + otelzap.Ctx(rc.Ctx).Info("Generating SSH keys for backup") |
| 69 | + if err := generateSSHKeys(); err != nil { |
| 70 | + otelzap.Ctx(rc.Ctx).Error("Failed to generate SSH keys", zap.Error(err)) |
| 71 | + return err |
| 72 | + } |
| 73 | + |
| 74 | + // Step 3: Copy SSH Keys to Backup Server |
| 75 | + otelzap.Ctx(rc.Ctx).Info("Copying SSH keys to the backup server") |
| 76 | + if err := copySSHKeys(); err != nil { |
| 77 | + otelzap.Ctx(rc.Ctx).Error("Failed to copy SSH keys", zap.Error(err)) |
| 78 | + return err |
| 79 | + } |
| 80 | + |
| 81 | + // Step 4: Initialize Restic Repository |
| 82 | + otelzap.Ctx(rc.Ctx).Info("Initializing restic repository") |
| 83 | + if err := initializeResticRepo(rc); err != nil { |
| 84 | + otelzap.Ctx(rc.Ctx).Error("Failed to initialize restic repository", zap.Error(err)) |
| 85 | + return err |
| 86 | + } |
| 87 | + |
| 88 | + // Step 5: Backup Data |
| 89 | + otelzap.Ctx(rc.Ctx).Info("Backing up data") |
| 90 | + if err := performResticBackup(rc); err != nil { |
| 91 | + otelzap.Ctx(rc.Ctx).Error("Failed to backup data", zap.Error(err)) |
| 92 | + return err |
| 93 | + } |
| 94 | + |
| 95 | + otelzap.Ctx(rc.Ctx).Info("Backup completed successfully") |
| 96 | + return nil |
| 97 | + }), |
| 98 | +} |
| 99 | + |
| 100 | +// ensureResticInstalled ensures Restic is installed on the system |
| 101 | +func ensureResticInstalled(rc *eos_io.RuntimeContext) error { |
| 102 | + |
| 103 | + // Ensure the script is run with sudo |
| 104 | + if os.Getenv("SUDO_USER") == "" { |
| 105 | + otelzap.Ctx(rc.Ctx).Error("Restic installation requires sudo permissions") |
| 106 | + return fmt.Errorf("need sudo permissions: run `sudo apt install restic`") |
| 107 | + } |
| 108 | + // Check for Restic installation |
| 109 | + _, err := exec.LookPath("restic") |
| 110 | + if err != nil { |
| 111 | + otelzap.Ctx(rc.Ctx).Error("Restic is not installed", zap.Error(err)) |
| 112 | + return fmt.Errorf("restic is not installed: run `sudo apt install restic`") |
| 113 | + } |
| 114 | + otelzap.Ctx(rc.Ctx).Info("Restic is installed and ready to use") |
| 115 | + return nil |
| 116 | +} |
| 117 | + |
| 118 | +// generateSSHKeys generates SSH keys for accessing the backup server |
| 119 | +func generateSSHKeys() error { |
| 120 | + keyPath := filepath.Join("/home", backupOpts.User, ".ssh", "id_rsa") |
| 121 | + cmd := exec.Command("ssh-keygen", "-q", "-N", "", "-f", keyPath) |
| 122 | + cmd.Stdout = os.Stdout |
| 123 | + cmd.Stderr = os.Stderr |
| 124 | + return cmd.Run() |
| 125 | +} |
| 126 | + |
| 127 | +// copySSHKeys copies the generated SSH keys to the backup server |
| 128 | +func copySSHKeys() error { |
| 129 | + target := fmt.Sprintf("%s@%s", backupOpts.User, backupOpts.Host) |
| 130 | + cmd := exec.Command("ssh-copy-id", target) |
| 131 | + cmd.Stdout = os.Stdout |
| 132 | + cmd.Stderr = os.Stderr |
| 133 | + return cmd.Run() |
| 134 | +} |
| 135 | + |
| 136 | +// initializeResticRepo initializes the Restic repository |
| 137 | +func initializeResticRepo(rc *eos_io.RuntimeContext) error { |
| 138 | + repoPath := buildRepoPath(rc) |
| 139 | + cmd := exec.Command("restic", "-r", repoPath, "init") |
| 140 | + cmd.Stdout = os.Stdout |
| 141 | + cmd.Stderr = os.Stderr |
| 142 | + return cmd.Run() |
| 143 | +} |
| 144 | + |
| 145 | +// performResticBackup performs the Restic backup |
| 146 | +func performResticBackup(rc *eos_io.RuntimeContext) error { |
| 147 | + repoPath := buildRepoPath(rc) |
| 148 | + password := getResticPassword(rc, backupOpts.PasswordFile) |
| 149 | + |
| 150 | + args := []string{"-r", repoPath, "--password-file=" + backupOpts.PasswordFile, "--verbose", "backup"} |
| 151 | + args = append(args, backupOpts.Paths...) |
| 152 | + cmd := exec.Command("restic", args...) |
| 153 | + cmd.Stdout = os.Stdout |
| 154 | + cmd.Stderr = os.Stderr |
| 155 | + cmd.Env = append(os.Environ(), "RESTIC_PASSWORD="+password) |
| 156 | + |
| 157 | + return cmd.Run() |
| 158 | +} |
| 159 | + |
| 160 | +// getResticPassword retrieves and stores the Restic repository password |
| 161 | +func getResticPassword(rc *eos_io.RuntimeContext, file string) string { |
| 162 | + |
| 163 | + var password string |
| 164 | + var err error |
| 165 | + password, err = interaction.PromptSecret(rc.Ctx, "Enter your Restic repository password") |
| 166 | + if err != nil { |
| 167 | + otelzap.Ctx(rc.Ctx).Error("Failed to retrieve password", zap.Error(err)) |
| 168 | + os.Exit(1) |
| 169 | + } |
| 170 | + |
| 171 | + err = os.WriteFile(file, []byte(password), 0600) |
| 172 | + if err != nil { |
| 173 | + otelzap.Ctx(rc.Ctx).Error("Failed to write password file", zap.Error(err)) |
| 174 | + os.Exit(1) |
| 175 | + } |
| 176 | + return password |
| 177 | +} |
| 178 | + |
| 179 | +// hostname retrieves the current hostname |
| 180 | +func hostname(rc *eos_io.RuntimeContext) string { |
| 181 | + |
| 182 | + name, err := os.Hostname() |
| 183 | + if err != nil { |
| 184 | + otelzap.Ctx(rc.Ctx).Warn("Failed to retrieve hostname, using 'unknown'", zap.Error(err)) |
| 185 | + return "unknown" |
| 186 | + } |
| 187 | + return name |
| 188 | +} |
| 189 | + |
| 190 | +func buildRepoPath(rc *eos_io.RuntimeContext) string { |
| 191 | + hostName := hostname(rc) |
| 192 | + return fmt.Sprintf("sftp:%s@%s:%s/%s", backupOpts.User, backupOpts.Host, backupOpts.RepoDir, hostName) |
| 193 | +} |
| 194 | + |
| 195 | +func init() { |
| 196 | + // Register this backup command under the create command |
| 197 | + CreateCmd.AddCommand(CreateBackupCmd) |
| 198 | + CreateBackupCmd.Flags().StringVar(&backupOpts.Host, "host", "", "Backup server host") |
| 199 | + CreateBackupCmd.Flags().StringVar(&backupOpts.User, "user", "", "SSH user for backup host") |
| 200 | + CreateBackupCmd.Flags().StringVar(&backupOpts.RepoDir, "repo-dir", "", "Remote restic repository directory") |
| 201 | + CreateBackupCmd.Flags().StringVar(&backupOpts.PasswordFile, "password-file", "", "Path to restic password file") |
| 202 | + CreateBackupCmd.Flags().StringVar(&pathsFlag, "paths", "", "Comma-separated list of paths to backup") |
| 203 | +} |
0 commit comments