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

178
internal/dns/exoscale.go Normal file
View File

@@ -0,0 +1,178 @@
package dns
import (
"context"
"fmt"
"net"
v3 "github.com/exoscale/egoscale/v3"
"github.com/exoscale/egoscale/v3/credentials"
)
// ExoscaleDNS manages DNS records via Exoscale API
type ExoscaleDNS struct {
client *v3.Client
}
// DNSRecord represents a DNS record
type DNSRecord struct {
ID string
Type string // A, AAAA, CNAME, etc.
Name string // subdomain or @ for apex
Content string // IP or target
TTL int64
}
// NewExoscaleDNS creates a new Exoscale DNS client
func NewExoscaleDNS(apiKey, apiSecret string) (*ExoscaleDNS, error) {
creds := credentials.NewStaticCredentials(apiKey, apiSecret)
client, err := v3.NewClient(creds)
if err != nil {
return nil, fmt.Errorf("failed to create Exoscale client: %w", err)
}
return &ExoscaleDNS{client: client}, nil
}
// GetRecord gets a specific DNS record
func (d *ExoscaleDNS) GetRecord(ctx context.Context, zone, recordType, name string) (*DNSRecord, error) {
domains, err := d.client.ListDNSDomains(ctx)
if err != nil {
return nil, fmt.Errorf("failed to list domains: %w", err)
}
var domainID v3.UUID
for _, domain := range domains.DNSDomains {
if domain.UnicodeName == zone {
domainID = domain.ID
break
}
}
if domainID == "" {
return nil, fmt.Errorf("zone %s not found", zone)
}
records, err := d.client.ListDNSDomainRecords(ctx, domainID)
if err != nil {
return nil, fmt.Errorf("failed to list records: %w", err)
}
for _, rec := range records.DNSDomainRecords {
if rec.Type == v3.DNSDomainRecordType(recordType) && rec.Name == name {
return &DNSRecord{
ID: string(rec.ID),
Type: string(rec.Type),
Name: rec.Name,
Content: rec.Content,
TTL: rec.Ttl,
}, nil
}
}
return nil, fmt.Errorf("record %s.%s not found", name, zone)
}
// UpdateRecord updates a DNS record
func (d *ExoscaleDNS) UpdateRecord(ctx context.Context, zone string, record *DNSRecord) error {
domains, err := d.client.ListDNSDomains(ctx)
if err != nil {
return fmt.Errorf("failed to list domains: %w", err)
}
var domainID v3.UUID
for _, domain := range domains.DNSDomains {
if domain.UnicodeName == zone {
domainID = domain.ID
break
}
}
if domainID == "" {
return fmt.Errorf("zone %s not found", zone)
}
op, err := d.client.UpdateDNSDomainRecord(ctx, domainID, v3.UUID(record.ID), v3.UpdateDNSDomainRecordRequest{
Content: record.Content,
Ttl: record.TTL,
})
if err != nil {
return fmt.Errorf("failed to update record: %w", err)
}
_, err = d.client.Wait(ctx, op, v3.OperationStateSuccess)
if err != nil {
return fmt.Errorf("update operation failed: %w", err)
}
return nil
}
// ListRecords lists all records in a zone
func (d *ExoscaleDNS) ListRecords(ctx context.Context, zone string) ([]DNSRecord, error) {
domains, err := d.client.ListDNSDomains(ctx)
if err != nil {
return nil, fmt.Errorf("failed to list domains: %w", err)
}
var domainID v3.UUID
for _, domain := range domains.DNSDomains {
if domain.UnicodeName == zone {
domainID = domain.ID
break
}
}
if domainID == "" {
return nil, fmt.Errorf("zone %s not found", zone)
}
records, err := d.client.ListDNSDomainRecords(ctx, domainID)
if err != nil {
return nil, fmt.Errorf("failed to list records: %w", err)
}
var result []DNSRecord
for _, rec := range records.DNSDomainRecords {
result = append(result, DNSRecord{
ID: string(rec.ID),
Type: string(rec.Type),
Name: rec.Name,
Content: rec.Content,
TTL: rec.Ttl,
})
}
return result, nil
}
// ListZones lists all DNS zones managed in Exoscale
func (d *ExoscaleDNS) ListZones(ctx context.Context) ([]string, error) {
domains, err := d.client.ListDNSDomains(ctx)
if err != nil {
return nil, fmt.Errorf("failed to list domains: %w", err)
}
var zones []string
for _, domain := range domains.DNSDomains {
zones = append(zones, domain.UnicodeName)
}
return zones, nil
}
// ResolveCurrentIP resolves the current IP for a hostname
func ResolveCurrentIP(hostname string) (string, error) {
ips, err := net.LookupIP(hostname)
if err != nil {
return "", err
}
for _, ip := range ips {
if ipv4 := ip.To4(); ipv4 != nil {
return ipv4.String(), nil
}
}
return "", fmt.Errorf("no IPv4 address found for %s", hostname)
}

135
internal/dns/health.go Normal file
View File

@@ -0,0 +1,135 @@
package dns
import (
"context"
"crypto/tls"
"fmt"
"net"
"net/http"
"time"
)
// HealthResult contains the result of health checks
type HealthResult struct {
SSHReady bool
HTTPSReady bool
SSHError error
HTTPSError error
}
// HealthChecker performs health checks on VMs
type HealthChecker struct {
SSHTimeout time.Duration
HTTPSTimeout time.Duration
}
// NewHealthChecker creates a new health checker
func NewHealthChecker() *HealthChecker {
return &HealthChecker{
SSHTimeout: 10 * time.Second,
HTTPSTimeout: 10 * time.Second,
}
}
// CheckSSH checks if SSH is accessible
func (h *HealthChecker) CheckSSH(ctx context.Context, ip string, port int) error {
if port == 0 {
port = 22
}
address := fmt.Sprintf("%s:%d", ip, port)
dialer := &net.Dialer{
Timeout: h.SSHTimeout,
}
conn, err := dialer.DialContext(ctx, "tcp", address)
if err != nil {
return fmt.Errorf("SSH not accessible: %w", err)
}
conn.Close()
return nil
}
// CheckHTTPS checks if HTTPS is accessible
func (h *HealthChecker) CheckHTTPS(ctx context.Context, ip string, port int) error {
if port == 0 {
port = 443
}
// Use IP directly with insecure TLS (we just want to verify the port is open)
client := &http.Client{
Timeout: h.HTTPSTimeout,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
},
}
url := fmt.Sprintf("https://%s:%d/", ip, port)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return err
}
resp, err := client.Do(req)
if err != nil {
// Connection refused or timeout is a failure
// But TLS errors mean the port is open (which is what we want)
if _, ok := err.(*tls.CertificateVerificationError); ok {
return nil // Port is open, TLS is working
}
// For other TLS errors, the port is still open
if netErr, ok := err.(net.Error); ok && !netErr.Timeout() {
// Non-timeout network error might still mean port is open
// Check if we can at least connect
conn, connErr := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", ip, port), h.HTTPSTimeout)
if connErr == nil {
conn.Close()
return nil
}
}
return fmt.Errorf("HTTPS not accessible: %w", err)
}
defer resp.Body.Close()
return nil
}
// CheckAll performs all health checks
func (h *HealthChecker) CheckAll(ctx context.Context, ip string) *HealthResult {
result := &HealthResult{}
result.SSHError = h.CheckSSH(ctx, ip, 22)
result.SSHReady = result.SSHError == nil
result.HTTPSError = h.CheckHTTPS(ctx, ip, 443)
result.HTTPSReady = result.HTTPSError == nil
return result
}
// WaitForReady waits for all health checks to pass
func (h *HealthChecker) WaitForReady(ctx context.Context, ip string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
result := h.CheckAll(ctx, ip)
if result.SSHReady {
return nil // SSH is the minimum requirement
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(5 * time.Second):
// Continue checking
}
}
return fmt.Errorf("timeout waiting for VM to be ready")
}

189
internal/dns/migration.go Normal file
View File

@@ -0,0 +1,189 @@
package dns
import (
"context"
"fmt"
"strings"
)
// MigrationRequest contains DNS migration parameters
type MigrationRequest struct {
Hostname string // Full hostname (e.g., proton.obr.sh)
OldIP string // Expected current IP
NewIP string // New IP to point to
Zone string // DNS zone (e.g., obr.sh)
DryRun bool
}
// MigrationResult contains the result of a DNS migration
type MigrationResult struct {
Success bool
Message string
OldRecord *DNSRecord
NewRecord *DNSRecord
RolledBack bool
}
// Migrator handles DNS migration with safety checks
type Migrator struct {
dns *ExoscaleDNS
health *HealthChecker
verbose bool
}
// NewMigrator creates a new DNS migrator
func NewMigrator(dns *ExoscaleDNS, verbose bool) *Migrator {
return &Migrator{
dns: dns,
health: NewHealthChecker(),
verbose: verbose,
}
}
// Migrate performs DNS migration with safety checks
func (m *Migrator) Migrate(ctx context.Context, req MigrationRequest) (*MigrationResult, error) {
result := &MigrationResult{}
// Parse hostname to get subdomain and zone
subdomain, zone := parseHostname(req.Hostname, req.Zone)
if m.verbose {
fmt.Printf("DNS Migration: %s -> %s\n", req.OldIP, req.NewIP)
fmt.Printf(" Zone: %s, Subdomain: %s\n", zone, subdomain)
}
// Step 1: Verify new VM is accessible
if m.verbose {
fmt.Println("\n=== Pre-flight checks ===")
}
healthResult := m.health.CheckAll(ctx, req.NewIP)
if !healthResult.SSHReady {
return nil, fmt.Errorf("new VM not accessible on SSH (port 22): %s", req.NewIP)
}
if m.verbose {
fmt.Printf(" ✓ SSH accessible on %s\n", req.NewIP)
}
// Step 2: Get current DNS record
record, err := m.dns.GetRecord(ctx, zone, "A", subdomain)
if err != nil {
return nil, fmt.Errorf("failed to get current DNS record: %w", err)
}
result.OldRecord = record
// Step 3: Verify current DNS matches expected old IP
if record.Content != req.OldIP {
return nil, fmt.Errorf("DNS mismatch: expected %s, found %s", req.OldIP, record.Content)
}
if m.verbose {
fmt.Printf(" ✓ Current DNS points to expected IP: %s\n", req.OldIP)
}
// Step 4: Verify via live DNS resolution
resolvedIP, err := ResolveCurrentIP(req.Hostname)
if err != nil {
if m.verbose {
fmt.Printf(" ⚠ Could not verify live DNS: %v\n", err)
}
} else if resolvedIP != req.OldIP {
return nil, fmt.Errorf("live DNS mismatch: expected %s, resolved %s", req.OldIP, resolvedIP)
} else if m.verbose {
fmt.Printf(" ✓ Live DNS resolution verified: %s\n", resolvedIP)
}
// Step 5: Perform migration (or dry-run)
if req.DryRun {
result.Success = true
result.Message = fmt.Sprintf("[DRY RUN] Would update %s.%s: %s -> %s",
subdomain, zone, req.OldIP, req.NewIP)
return result, nil
}
if m.verbose {
fmt.Println("\n=== Updating DNS ===")
}
// Update the record
newRecord := &DNSRecord{
ID: record.ID,
Type: record.Type,
Name: record.Name,
Content: req.NewIP,
TTL: record.TTL,
}
if err := m.dns.UpdateRecord(ctx, zone, newRecord); err != nil {
return nil, fmt.Errorf("failed to update DNS: %w", err)
}
result.NewRecord = newRecord
if m.verbose {
fmt.Printf(" ✓ DNS updated: %s -> %s\n", req.OldIP, req.NewIP)
}
// Step 6: Verify the update
verifyRecord, err := m.dns.GetRecord(ctx, zone, "A", subdomain)
if err != nil || verifyRecord.Content != req.NewIP {
// Rollback
if m.verbose {
fmt.Println(" ✗ Verification failed, rolling back...")
}
rollbackRecord := &DNSRecord{
ID: record.ID,
Type: record.Type,
Name: record.Name,
Content: req.OldIP,
TTL: record.TTL,
}
if rollbackErr := m.dns.UpdateRecord(ctx, zone, rollbackRecord); rollbackErr != nil {
return nil, fmt.Errorf("CRITICAL: rollback failed: %w (original error: %v)", rollbackErr, err)
}
result.RolledBack = true
return nil, fmt.Errorf("DNS update verification failed, rolled back: %w", err)
}
result.Success = true
result.Message = fmt.Sprintf("DNS migration complete: %s now points to %s", req.Hostname, req.NewIP)
return result, nil
}
// parseHostname extracts subdomain and zone from hostname
func parseHostname(hostname, defaultZone string) (subdomain, zone string) {
if defaultZone != "" {
if strings.HasSuffix(hostname, "."+defaultZone) {
subdomain = strings.TrimSuffix(hostname, "."+defaultZone)
return subdomain, defaultZone
}
}
// Known zones
knownZones := []string{
"obr.sh", "obnh.io", "obnh.network", "obnh.org",
"obr.digital", "obr.im", "s-n-r.net", "as60284.net", "baumert.cc",
}
for _, z := range knownZones {
if strings.HasSuffix(hostname, "."+z) {
subdomain = strings.TrimSuffix(hostname, "."+z)
return subdomain, z
}
}
// Fallback: assume last two parts are zone
parts := strings.Split(hostname, ".")
if len(parts) >= 2 {
zone = strings.Join(parts[len(parts)-2:], ".")
subdomain = strings.Join(parts[:len(parts)-2], ".")
if subdomain == "" {
subdomain = "@"
}
}
return subdomain, zone
}