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>
This commit is contained in:
Olaf Berberich
2025-12-08 00:31:27 +00:00
commit 29a2886402
26 changed files with 3826 additions and 0 deletions

View File

@@ -0,0 +1,226 @@
package providers
import (
"context"
"fmt"
"net"
"net/http"
"time"
"github.com/cloudscale-ch/cloudscale-go-sdk/v6"
)
// CloudscaleProvider implements CloudProvider for Cloudscale.ch
type CloudscaleProvider struct {
client *cloudscale.Client
}
// NewCloudscaleProvider creates a new Cloudscale provider
func NewCloudscaleProvider(token string) (*CloudscaleProvider, error) {
if token == "" {
return nil, fmt.Errorf("cloudscale API token required")
}
client := cloudscale.NewClient(http.DefaultClient)
client.AuthToken = token
return &CloudscaleProvider{client: client}, nil
}
func (p *CloudscaleProvider) Name() string {
return "cloudscale"
}
func (p *CloudscaleProvider) CreateVM(ctx context.Context, opts VMOptions) (*VM, error) {
zone := opts.Zone
if zone == "" {
zone = "lpg1" // Default: Lupfig
}
image := opts.Image
if image == "" {
image = "ubuntu-24.04"
}
flavor := opts.Flavor
if flavor == "" {
flavor = "flex-4-2" // 4 vCPU, 2GB RAM
}
req := &cloudscale.ServerRequest{
Name: opts.Name,
Flavor: flavor,
Image: image,
Zone: zone,
SSHKeys: []string{opts.SSHPublicKey},
VolumeSizeGB: int(opts.DiskSizeGB),
}
if opts.UserData != "" {
req.UserData = opts.UserData
}
server, err := p.client.Servers.Create(ctx, req)
if err != nil {
return nil, fmt.Errorf("failed to create server: %w", err)
}
// Wait for server to be running
for i := 0; i < 60; i++ {
server, err = p.client.Servers.Get(ctx, server.UUID)
if err != nil {
return nil, fmt.Errorf("failed to get server status: %w", err)
}
if server.Status == "running" {
break
}
time.Sleep(5 * time.Second)
}
// Get public IP
var publicIP string
for _, iface := range server.Interfaces {
if iface.Type == "public" {
for _, addr := range iface.Addresses {
if addr.Version == 4 {
publicIP = addr.Address
break
}
}
}
}
return &VM{
ID: server.UUID,
Name: server.Name,
PublicIP: publicIP,
Status: server.Status,
Provider: "cloudscale",
Zone: server.Zone.Slug,
CreatedAt: server.CreatedAt,
}, nil
}
func (p *CloudscaleProvider) DeleteVM(ctx context.Context, vmID string) error {
return p.client.Servers.Delete(ctx, vmID)
}
func (p *CloudscaleProvider) GetVM(ctx context.Context, vmID string) (*VM, error) {
server, err := p.client.Servers.Get(ctx, vmID)
if err != nil {
return nil, err
}
var publicIP string
for _, iface := range server.Interfaces {
if iface.Type == "public" {
for _, addr := range iface.Addresses {
if addr.Version == 4 {
publicIP = addr.Address
break
}
}
}
}
return &VM{
ID: server.UUID,
Name: server.Name,
PublicIP: publicIP,
Status: server.Status,
Provider: "cloudscale",
Zone: server.Zone.Slug,
CreatedAt: server.CreatedAt,
}, nil
}
func (p *CloudscaleProvider) WaitForSSH(ctx context.Context, ip string, port int, timeout time.Duration) error {
if port == 0 {
port = 22
}
deadline := time.Now().Add(timeout)
address := fmt.Sprintf("%s:%d", ip, port)
for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", address, 5*time.Second)
if err == nil {
conn.Close()
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(5 * time.Second):
}
}
return fmt.Errorf("SSH not available at %s after %v", address, timeout)
}
func (p *CloudscaleProvider) ListFlavors(ctx context.Context) ([]Flavor, error) {
// Make raw API request to /v1/flavors
req, err := p.client.NewRequest(ctx, "GET", "v1/flavors", nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
var flavors []cloudscale.Flavor
if err := p.client.Do(ctx, req, &flavors); err != nil {
return nil, fmt.Errorf("failed to list flavors: %w", err)
}
var result []Flavor
for _, f := range flavors {
result = append(result, Flavor{
ID: f.Slug,
Name: f.Name,
CPUs: f.VCPUCount,
Memory: f.MemoryGB * 1024, // Convert to MB
})
}
return result, nil
}
func (p *CloudscaleProvider) ListImages(ctx context.Context, filter string) ([]Image, error) {
// Make raw API request to /v1/images
req, err := p.client.NewRequest(ctx, "GET", "v1/images", nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
var images []cloudscale.Image
if err := p.client.Do(ctx, req, &images); err != nil {
return nil, fmt.Errorf("failed to list images: %w", err)
}
var result []Image
for _, img := range images {
if filter == "" || contains(img.Slug, filter) || contains(img.Name, filter) {
result = append(result, Image{
ID: img.Slug,
Name: img.Name,
})
}
}
return result, nil
}
func (p *CloudscaleProvider) ListZones(ctx context.Context) ([]string, error) {
regions, err := p.client.Regions.List(ctx)
if err != nil {
return nil, err
}
var zones []string
for _, r := range regions {
for _, z := range r.Zones {
zones = append(zones, z.Slug)
}
}
return zones, nil
}

View File

@@ -0,0 +1,319 @@
package providers
import (
"context"
"fmt"
"net"
"time"
v3 "github.com/exoscale/egoscale/v3"
"github.com/exoscale/egoscale/v3/credentials"
)
// ExoscaleProvider implements CloudProvider for Exoscale
type ExoscaleProvider struct {
creds *credentials.Credentials
}
// NewExoscaleProvider creates a new Exoscale provider
func NewExoscaleProvider(apiKey, apiSecret string) (*ExoscaleProvider, error) {
creds := credentials.NewStaticCredentials(apiKey, apiSecret)
return &ExoscaleProvider{creds: creds}, nil
}
func (p *ExoscaleProvider) Name() string {
return "exoscale"
}
// getClientForZone creates a zone-specific client
func (p *ExoscaleProvider) getClientForZone(zone string) (*v3.Client, error) {
endpoint := p.getEndpointForZone(zone)
return v3.NewClient(p.creds, v3.ClientOptWithEndpoint(endpoint))
}
// getEndpointForZone maps zone names to API endpoints
func (p *ExoscaleProvider) getEndpointForZone(zone string) v3.Endpoint {
endpoints := map[string]v3.Endpoint{
"ch-gva-2": v3.CHGva2,
"ch-dk-2": v3.CHDk2,
"de-fra-1": v3.DEFra1,
"de-muc-1": v3.DEMuc1,
"at-vie-1": v3.ATVie1,
"at-vie-2": v3.ATVie2,
"bg-sof-1": v3.BGSof1,
}
if endpoint, ok := endpoints[zone]; ok {
return endpoint
}
return v3.CHGva2 // Default
}
func (p *ExoscaleProvider) CreateVM(ctx context.Context, opts VMOptions) (*VM, error) {
// Get zone endpoint
zone := opts.Zone
if zone == "" {
zone = "ch-gva-2" // Default zone
}
// Create client for specific zone
client, err := p.getClientForZone(zone)
if err != nil {
return nil, fmt.Errorf("failed to create zone client: %w", err)
}
// Find template (image)
templates, err := client.ListTemplates(ctx, v3.ListTemplatesWithVisibility("public"))
if err != nil {
return nil, fmt.Errorf("failed to list templates: %w", err)
}
selectedTemplate, err := templates.FindTemplate(opts.Image)
if err != nil {
return nil, fmt.Errorf("template not found: %s (%w)", opts.Image, err)
}
// Find instance type
instanceTypes, err := client.ListInstanceTypes(ctx)
if err != nil {
return nil, fmt.Errorf("failed to list instance types: %w", err)
}
selectedType, err := instanceTypes.FindInstanceTypeByIdOrFamilyAndSize(opts.Flavor)
if err != nil {
return nil, fmt.Errorf("instance type not found: %s (%w)", opts.Flavor, err)
}
// Determine disk size
diskSize := opts.DiskSizeGB
if diskSize == 0 {
diskSize = 50 // Default 50GB
}
// Register the SSH key temporarily
sshKeyName := fmt.Sprintf("recovery-%s-%d", opts.Name, time.Now().Unix())
sshKeyOp, err := client.RegisterSSHKey(ctx, v3.RegisterSSHKeyRequest{
Name: sshKeyName,
PublicKey: opts.SSHPublicKey,
})
if err != nil {
return nil, fmt.Errorf("failed to register SSH key: %w", err)
}
// Wait for SSH key registration
sshKeyOp, err = client.Wait(ctx, sshKeyOp, v3.OperationStateSuccess)
if err != nil {
return nil, fmt.Errorf("SSH key registration failed: %w", err)
}
// Create the instance
createReq := v3.CreateInstanceRequest{
Name: opts.Name,
InstanceType: &selectedType,
Template: &selectedTemplate,
DiskSize: diskSize,
SSHKey: &v3.SSHKey{Name: sshKeyName},
}
// Add user data if provided
if opts.UserData != "" {
createReq.UserData = opts.UserData
}
op, err := client.CreateInstance(ctx, createReq)
if err != nil {
return nil, fmt.Errorf("failed to create instance: %w", err)
}
// Wait for operation to complete
op, err = client.Wait(ctx, op, v3.OperationStateSuccess)
if err != nil {
return nil, fmt.Errorf("instance creation failed: %w", err)
}
// Get the created instance
if op.Reference == nil {
return nil, fmt.Errorf("operation completed but no reference returned")
}
instance, err := client.GetInstance(ctx, op.Reference.ID)
if err != nil {
return nil, fmt.Errorf("failed to get created instance: %w", err)
}
// Extract public IP
var publicIP string
if instance.PublicIP != nil {
publicIP = instance.PublicIP.String()
}
return &VM{
ID: string(instance.ID),
Name: instance.Name,
PublicIP: publicIP,
Status: string(instance.State),
Provider: "exoscale",
Zone: zone,
CreatedAt: instance.CreatedAT,
}, nil
}
func (p *ExoscaleProvider) DeleteVM(ctx context.Context, vmID string) error {
// We need to find which zone the VM is in
zones := []string{"ch-gva-2", "ch-dk-2", "de-fra-1", "de-muc-1", "at-vie-1", "at-vie-2", "bg-sof-1"}
for _, zone := range zones {
client, err := p.getClientForZone(zone)
if err != nil {
continue
}
op, err := client.DeleteInstance(ctx, v3.UUID(vmID))
if err != nil {
continue // Try next zone
}
_, err = client.Wait(ctx, op, v3.OperationStateSuccess)
if err != nil {
return fmt.Errorf("delete failed: %w", err)
}
return nil
}
return fmt.Errorf("VM %s not found in any zone", vmID)
}
func (p *ExoscaleProvider) GetVM(ctx context.Context, vmID string) (*VM, error) {
zones := []string{"ch-gva-2", "ch-dk-2", "de-fra-1", "de-muc-1", "at-vie-1", "at-vie-2", "bg-sof-1"}
for _, zone := range zones {
client, err := p.getClientForZone(zone)
if err != nil {
continue
}
instance, err := client.GetInstance(ctx, v3.UUID(vmID))
if err != nil {
continue
}
var publicIP string
if instance.PublicIP != nil {
publicIP = instance.PublicIP.String()
}
return &VM{
ID: string(instance.ID),
Name: instance.Name,
PublicIP: publicIP,
Status: string(instance.State),
Provider: "exoscale",
Zone: zone,
CreatedAt: instance.CreatedAT,
}, nil
}
return nil, fmt.Errorf("VM %s not found", vmID)
}
func (p *ExoscaleProvider) WaitForSSH(ctx context.Context, ip string, port int, timeout time.Duration) error {
if port == 0 {
port = 22
}
deadline := time.Now().Add(timeout)
address := fmt.Sprintf("%s:%d", ip, port)
for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", address, 5*time.Second)
if err == nil {
conn.Close()
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(5 * time.Second):
// Continue trying
}
}
return fmt.Errorf("SSH not available at %s after %v", address, timeout)
}
func (p *ExoscaleProvider) ListFlavors(ctx context.Context) ([]Flavor, error) {
// Use default zone for listing
client, err := p.getClientForZone("ch-gva-2")
if err != nil {
return nil, err
}
types, err := client.ListInstanceTypes(ctx)
if err != nil {
return nil, fmt.Errorf("failed to list instance types: %w", err)
}
var flavors []Flavor
for _, it := range types.InstanceTypes {
flavors = append(flavors, Flavor{
ID: string(it.ID),
Name: string(it.Size),
CPUs: int(it.Cpus),
Memory: int(it.Memory),
})
}
return flavors, nil
}
func (p *ExoscaleProvider) ListImages(ctx context.Context, filter string) ([]Image, error) {
client, err := p.getClientForZone("ch-gva-2")
if err != nil {
return nil, err
}
templates, err := client.ListTemplates(ctx, v3.ListTemplatesWithVisibility("public"))
if err != nil {
return nil, fmt.Errorf("failed to list templates: %w", err)
}
var images []Image
for _, tmpl := range templates.Templates {
if filter == "" || contains(tmpl.Name, filter) {
images = append(images, Image{
ID: string(tmpl.ID),
Name: tmpl.Name,
})
}
}
return images, nil
}
func (p *ExoscaleProvider) ListZones(ctx context.Context) ([]string, error) {
return []string{
"ch-gva-2", // Geneva
"ch-dk-2", // Zurich
"de-fra-1", // Frankfurt
"de-muc-1", // Munich
"at-vie-1", // Vienna 1
"at-vie-2", // Vienna 2
"bg-sof-1", // Sofia
}, nil
}
// Helper function
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
(len(s) > 0 && len(substr) > 0 && findSubstring(s, substr)))
}
func findSubstring(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}

View File

@@ -0,0 +1,40 @@
package providers
import (
"fmt"
)
// NewProvider creates a cloud provider by name
func NewProvider(name string, config map[string]string) (CloudProvider, error) {
switch name {
case "exoscale":
apiKey := config["api_key"]
apiSecret := config["api_secret"]
if apiKey == "" || apiSecret == "" {
return nil, fmt.Errorf("exoscale requires api_key and api_secret")
}
return NewExoscaleProvider(apiKey, apiSecret)
case "cloudscale":
token := config["token"]
if token == "" {
return nil, fmt.Errorf("cloudscale requires token")
}
return NewCloudscaleProvider(token)
case "hetzner":
token := config["token"]
if token == "" {
return nil, fmt.Errorf("hetzner requires token")
}
return NewHetznerProvider(token)
default:
return nil, fmt.Errorf("unknown provider: %s", name)
}
}
// SupportedProviders returns list of supported cloud providers
func SupportedProviders() []string {
return []string{"exoscale", "cloudscale", "hetzner"}
}

View File

@@ -0,0 +1,233 @@
package providers
import (
"context"
"fmt"
"net"
"time"
"github.com/hetznercloud/hcloud-go/v2/hcloud"
)
// HetznerProvider implements CloudProvider for Hetzner Cloud
type HetznerProvider struct {
client *hcloud.Client
}
// NewHetznerProvider creates a new Hetzner provider
func NewHetznerProvider(token string) (*HetznerProvider, error) {
if token == "" {
return nil, fmt.Errorf("hetzner API token required")
}
client := hcloud.NewClient(hcloud.WithToken(token))
return &HetznerProvider{client: client}, nil
}
func (p *HetznerProvider) Name() string {
return "hetzner"
}
func (p *HetznerProvider) CreateVM(ctx context.Context, opts VMOptions) (*VM, error) {
// Parse zone/location
location := opts.Zone
if location == "" {
location = "fsn1" // Default: Falkenstein
}
image := opts.Image
if image == "" {
image = "ubuntu-24.04"
}
serverType := opts.Flavor
if serverType == "" {
serverType = "cx22" // 2 vCPU, 4GB RAM
}
// Register SSH key if provided
var sshKeys []*hcloud.SSHKey
if opts.SSHPublicKey != "" {
keyName := fmt.Sprintf("recovery-%s-%d", opts.Name, time.Now().Unix())
sshKey, _, err := p.client.SSHKey.Create(ctx, hcloud.SSHKeyCreateOpts{
Name: keyName,
PublicKey: opts.SSHPublicKey,
})
if err != nil {
return nil, fmt.Errorf("failed to register SSH key: %w", err)
}
sshKeys = append(sshKeys, sshKey)
}
// Create server
createOpts := hcloud.ServerCreateOpts{
Name: opts.Name,
ServerType: &hcloud.ServerType{Name: serverType},
Image: &hcloud.Image{Name: image},
Location: &hcloud.Location{Name: location},
SSHKeys: sshKeys,
}
if opts.UserData != "" {
createOpts.UserData = opts.UserData
}
result, _, err := p.client.Server.Create(ctx, createOpts)
if err != nil {
return nil, fmt.Errorf("failed to create server: %w", err)
}
// Wait for server to be running
server := result.Server
for i := 0; i < 60; i++ {
server, _, err = p.client.Server.GetByID(ctx, server.ID)
if err != nil {
return nil, fmt.Errorf("failed to get server status: %w", err)
}
if server.Status == hcloud.ServerStatusRunning {
break
}
time.Sleep(5 * time.Second)
}
var publicIP string
if server.PublicNet.IPv4.IP != nil {
publicIP = server.PublicNet.IPv4.IP.String()
}
return &VM{
ID: fmt.Sprintf("%d", server.ID),
Name: server.Name,
PublicIP: publicIP,
Status: string(server.Status),
Provider: "hetzner",
Zone: server.Datacenter.Location.Name,
CreatedAt: server.Created,
}, nil
}
func (p *HetznerProvider) DeleteVM(ctx context.Context, vmID string) error {
var id int64
fmt.Sscanf(vmID, "%d", &id)
server, _, err := p.client.Server.GetByID(ctx, id)
if err != nil {
return err
}
if server == nil {
return fmt.Errorf("server not found: %s", vmID)
}
_, _, err = p.client.Server.DeleteWithResult(ctx, server)
return err
}
func (p *HetznerProvider) GetVM(ctx context.Context, vmID string) (*VM, error) {
var id int64
fmt.Sscanf(vmID, "%d", &id)
server, _, err := p.client.Server.GetByID(ctx, id)
if err != nil {
return nil, err
}
if server == nil {
return nil, fmt.Errorf("server not found: %s", vmID)
}
var publicIP string
if server.PublicNet.IPv4.IP != nil {
publicIP = server.PublicNet.IPv4.IP.String()
}
return &VM{
ID: fmt.Sprintf("%d", server.ID),
Name: server.Name,
PublicIP: publicIP,
Status: string(server.Status),
Provider: "hetzner",
Zone: server.Datacenter.Location.Name,
CreatedAt: server.Created,
}, nil
}
func (p *HetznerProvider) WaitForSSH(ctx context.Context, ip string, port int, timeout time.Duration) error {
if port == 0 {
port = 22
}
deadline := time.Now().Add(timeout)
address := fmt.Sprintf("%s:%d", ip, port)
for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", address, 5*time.Second)
if err == nil {
conn.Close()
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(5 * time.Second):
}
}
return fmt.Errorf("SSH not available at %s after %v", address, timeout)
}
func (p *HetznerProvider) ListFlavors(ctx context.Context) ([]Flavor, error) {
types, err := p.client.ServerType.All(ctx)
if err != nil {
return nil, err
}
var result []Flavor
for _, t := range types {
result = append(result, Flavor{
ID: t.Name,
Name: t.Description,
CPUs: t.Cores,
Memory: int(t.Memory * 1024), // Convert GB to MB
Disk: t.Disk,
})
}
return result, nil
}
func (p *HetznerProvider) ListImages(ctx context.Context, filter string) ([]Image, error) {
images, err := p.client.Image.All(ctx)
if err != nil {
return nil, err
}
var result []Image
for _, img := range images {
if img.Type != hcloud.ImageTypeSystem {
continue // Only show system images
}
if filter == "" || contains(img.Name, filter) || contains(img.Description, filter) {
result = append(result, Image{
ID: img.Name,
Name: img.Description,
})
}
}
return result, nil
}
func (p *HetznerProvider) ListZones(ctx context.Context) ([]string, error) {
locations, err := p.client.Location.All(ctx)
if err != nil {
return nil, err
}
var zones []string
for _, l := range locations {
zones = append(zones, l.Name)
}
return zones, nil
}

View File

@@ -0,0 +1,96 @@
package providers
import (
"context"
"time"
)
// VMOptions contains options for creating a new VM
type VMOptions struct {
Name string
Zone string // e.g., "ch-gva-2", "lpg1", "fsn1"
Flavor string // Instance type/size
Image string // OS image name or ID
SSHPublicKey string // Ephemeral public key content
UserData string // Cloud-init script
DiskSizeGB int64 // Root disk size
Tags map[string]string // Optional tags/labels
}
// VM represents a created virtual machine
type VM struct {
ID string
Name string
PublicIP string
PrivateIP string
Status string
Provider string
Zone string
CreatedAt time.Time
}
// Flavor represents an instance type/size
type Flavor struct {
ID string
Name string
CPUs int
Memory int // MB
Disk int // GB (if applicable)
}
// Image represents an OS image
type Image struct {
ID string
Name string
}
// CloudProvider defines the interface for cloud providers
type CloudProvider interface {
// Name returns the provider name
Name() string
// CreateVM creates a new virtual machine
CreateVM(ctx context.Context, opts VMOptions) (*VM, error)
// DeleteVM deletes a virtual machine by ID
DeleteVM(ctx context.Context, vmID string) error
// GetVM gets VM details by ID
GetVM(ctx context.Context, vmID string) (*VM, error)
// WaitForSSH waits until SSH is available on the VM
WaitForSSH(ctx context.Context, ip string, port int, timeout time.Duration) error
// ListFlavors lists available instance types
ListFlavors(ctx context.Context) ([]Flavor, error)
// ListImages lists available OS images
ListImages(ctx context.Context, filter string) ([]Image, error)
// ListZones lists available zones/regions
ListZones(ctx context.Context) ([]string, error)
}
// GenerateCloudInit creates a cloud-init user-data script
func GenerateCloudInit(ephemeralPubKey string) string {
return `#cloud-config
ssh_pwauth: false
users:
- name: root
ssh_authorized_keys:
- "` + ephemeralPubKey + `"
package_update: true
packages:
- rsync
- docker.io
- docker-compose
- wireguard-tools
write_files:
- path: /var/tmp/recovery-ready
content: "ready"
permissions: '0644'
runcmd:
- systemctl enable docker
- systemctl start docker
`
}