Files
recover-server/internal/backup/hetzner_storage.go
Olaf Berberich 29a2886402 Initial commit: Disaster recovery CLI tool
A Go-based CLI tool for recovering servers from backups to new cloud VMs.

Features:
- Multi-cloud support: Exoscale, Cloudscale, Hetzner Cloud
- Backup sources: Local filesystem, Hetzner Storage Box
- 6-stage restore pipeline with /etc whitelist protection
- DNS migration with safety checks and auto-rollback
- Dry-run by default, requires --yes to execute
- Cloud-init for SSH key injection

Packages:
- cmd/recover-server: CLI commands (recover, migrate-dns, list, cleanup)
- internal/providers: Cloud provider implementations
- internal/backup: Backup source implementations
- internal/restore: 6-stage restore pipeline
- internal/dns: Exoscale DNS management
- internal/ui: Prompts, progress, dry-run display
- internal/config: Environment and host configuration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 00:31:27 +00:00

201 lines
5.1 KiB
Go

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
}