package backup import ( "bytes" "context" "fmt" "os" "os/exec" "strings" "time" ) // HetznerStorageSource implements BackupSource for Hetzner Storage Box type HetznerStorageSource struct { User string // e.g., u480813 Host string // e.g., u480813.your-storagebox.de BasePath string // e.g., /backups Port int // SSH port (default 23 for sftp, 22 for ssh) } // NewHetznerStorageSource creates a new Hetzner Storage Box source func NewHetznerStorageSource(user, host string) *HetznerStorageSource { if user == "" { user = "u480813" } if host == "" { host = "u480813.your-storagebox.de" } return &HetznerStorageSource{ User: user, Host: host, BasePath: "/backups", Port: 23, // Hetzner uses port 23 for SFTP } } func (s *HetznerStorageSource) Name() string { return "hetzner" } func (s *HetznerStorageSource) sshAddress() string { return fmt.Sprintf("%s@%s", s.User, s.Host) } func (s *HetznerStorageSource) List(ctx context.Context, host string) ([]BackupInfo, error) { var backups []BackupInfo // Use sftp to list directories path := s.BasePath if host != "" { path = fmt.Sprintf("%s/%s", s.BasePath, host) } // Run sftp ls command cmd := exec.CommandContext(ctx, "sftp", "-P", fmt.Sprintf("%d", s.Port), "-o", "BatchMode=yes", s.sshAddress()) cmd.Stdin = strings.NewReader(fmt.Sprintf("ls -la %s\nquit\n", path)) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return nil, fmt.Errorf("sftp failed: %w: %s", err, stderr.String()) } // Parse output lines := strings.Split(stdout.String(), "\n") for _, line := range lines { fields := strings.Fields(line) if len(fields) < 9 { continue } // Skip . and .. name := fields[len(fields)-1] if name == "." || name == ".." { continue } // Check if it's a directory if !strings.HasPrefix(fields[0], "d") { continue } hostName := name if host != "" { // We're looking at subdirs of a specific host continue } info := &BackupInfo{ Host: hostName, Source: "hetzner", Path: fmt.Sprintf("%s/%s", s.BasePath, hostName), Timestamp: time.Now(), // Would need additional sftp commands for real timestamp } // Check for subdirectories (simplified - assume all exist) info.HasRoot = true info.HasOpt = true info.HasEtc = true backups = append(backups, *info) } return backups, nil } func (s *HetznerStorageSource) SyncTo(ctx context.Context, host string, targetSSH string, sshKeyPath string, dirs []string) error { // For Hetzner Storage Box, we need to rsync from storage box to target // This requires the target VM to pull from storage box // OR we rsync storage->local->target (two-hop) // Option 1: Direct rsync from storage box (requires storage box SSH access from target) // Option 2: Two-hop: storage -> local staging -> target // We'll use Option 2 for reliability (target may not have storage box access) stagingDir := fmt.Sprintf("/tmp/restore-staging-%s", host) if err := os.MkdirAll(stagingDir, 0700); err != nil { return fmt.Errorf("failed to create staging dir: %w", err) } defer os.RemoveAll(stagingDir) srcPath := fmt.Sprintf("%s:%s/%s/", s.sshAddress(), s.BasePath, host) for _, dir := range dirs { // Step 1: Rsync from Hetzner to local staging localStaging := fmt.Sprintf("%s/%s/", stagingDir, dir) if err := os.MkdirAll(localStaging, 0700); err != nil { return err } pullArgs := []string{ "-avz", "--progress", "-e", fmt.Sprintf("ssh -p %d -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null", s.Port), fmt.Sprintf("%s%s/", srcPath, dir), localStaging, } pullCmd := exec.CommandContext(ctx, "rsync", pullArgs...) pullCmd.Stdout = os.Stdout pullCmd.Stderr = os.Stderr if err := pullCmd.Run(); err != nil { // Directory might not exist, continue continue } // Step 2: Rsync from local staging to target var targetPath string switch dir { case "root": targetPath = "/root/" case "opt": targetPath = "/opt/" case "etc": targetPath = "/srv/restore/etc/" default: targetPath = "/" + dir + "/" } pushArgs := []string{ "-avz", "--progress", "-e", fmt.Sprintf("ssh -i %s -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null", sshKeyPath), localStaging, fmt.Sprintf("%s:%s", targetSSH, targetPath), } pushCmd := exec.CommandContext(ctx, "rsync", pushArgs...) pushCmd.Stdout = os.Stdout pushCmd.Stderr = os.Stderr if err := pushCmd.Run(); err != nil { return fmt.Errorf("rsync to target failed for %s: %w", dir, err) } } return nil } func (s *HetznerStorageSource) GetPath(host string) string { return fmt.Sprintf("%s@%s:%s/%s", s.User, s.Host, s.BasePath, host) } func (s *HetznerStorageSource) Validate(ctx context.Context) error { // Test SFTP connection cmd := exec.CommandContext(ctx, "sftp", "-P", fmt.Sprintf("%d", s.Port), "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", s.sshAddress()) cmd.Stdin = strings.NewReader("ls\nquit\n") var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return fmt.Errorf("cannot connect to Hetzner Storage Box: %w: %s", err, stderr.String()) } return nil }