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 }