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>
190 lines
4.9 KiB
Go
190 lines
4.9 KiB
Go
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
|
|
}
|