Files
recover-server/internal/dns/migration.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

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
}