#Requires -Version 5.1 <# .SYNOPSIS Restores DNS settings on Windows and Linux servers from a rollback JSON file. .DESCRIPTION Reads a rollback JSON file produced by Invoke-DnsMigration.ps1 and restores each server's DNS configuration to its pre-migration state. For Windows servers: Reconnects via WinRM/PSSession/PsExec and calls Set-DnsClientServerAddress with the original DNS IPs. For Linux servers: SSHes in via plink, overwrites each modified config file with its original content (stored as base64 in the rollback JSON), then restarts the appropriate service. Supports selective rollback (-Hostname) and dry-run mode (-DryRun). .PARAMETER RollbackFile Path to the dns-rollback-.json file generated during migration. Mandatory. .PARAMETER Hostname Optional. Limit rollback to a single server. Case-insensitive match against the Hostname field in the rollback file. .PARAMETER ConfigFile Path to config.psd1. Defaults to .\config.psd1 in the script directory. .PARAMETER OutputDir Directory for the rollback CSV report. Defaults to config.OutputDir. .PARAMETER LinuxSSHPassword Override the LinuxSSHPassword from config.psd1 at runtime. .PARAMETER WindowsCredential Optional PSCredential for Windows remote connections. If not provided, uses implicit authentication (current user / Kerberos). Example: -WindowsCredential (Get-Credential) .PARAMETER WindowsUsername Optional Windows username. Must be used with -WindowsPassword. Example: -WindowsUsername "CORP\migration-svc" -WindowsPassword "P@ssw0rd" .PARAMETER DryRun Read-only mode. Shows what would be restored (first 10 lines of each file's original content) without making any changes. .EXAMPLE # Dry run — preview full fleet rollback .\Invoke-DnsRollback.ps1 -RollbackFile ".\logs\dns-rollback-20250115-103000.json" -DryRun .EXAMPLE # Roll back a single server .\Invoke-DnsRollback.ps1 -RollbackFile ".\logs\dns-rollback-20250115-103000.json" ` -Hostname "ubuntu-app-01.corp.local" .EXAMPLE # Full fleet rollback .\Invoke-DnsRollback.ps1 -RollbackFile ".\logs\dns-rollback-20250115-103000.json" #> [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory)] [string] $RollbackFile, [string] $Hostname = "", [string] $ConfigFile = "", [string] $OutputDir = "", [string] $LinuxSSHPassword = "", # ── Windows authentication (optional — omit for implicit Kerberos) ──────── [System.Management.Automation.PSCredential] [System.Management.Automation.Credential()] $WindowsCredential = [System.Management.Automation.PSCredential]::Empty, [string] $WindowsUsername = "", [string] $WindowsPassword = "", [switch] $DryRun ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" $ScriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { Split-Path $MyInvocation.MyCommand.Path } # ── Load modules ────────────────────────────────────────────────────────────── $modulePaths = @( "modules\Set-WindowsDns.ps1", "modules\Invoke-LinuxDns.ps1", "modules\Write-MigrationReport.ps1" ) foreach ($mp in $modulePaths) { $fullPath = Join-Path $ScriptDir $mp if (-not (Test-Path $fullPath)) { throw "Required module not found: $fullPath" } . $fullPath } # ── Load config ─────────────────────────────────────────────────────────────── if (-not $ConfigFile) { $ConfigFile = Join-Path $ScriptDir "config.psd1" } if (-not (Test-Path $ConfigFile)) { throw "Configuration file not found: $ConfigFile" } $config = Import-PowerShellDataFile $ConfigFile if ($LinuxSSHPassword) { $config.LinuxSSHPassword = $LinuxSSHPassword } if ($OutputDir) { $config.OutputDir = $OutputDir } # ── Resolve Windows credential ──────────────────────────────────────────────── $winCredential = [System.Management.Automation.PSCredential]::Empty if ($WindowsCredential -ne [System.Management.Automation.PSCredential]::Empty -and $null -ne $WindowsCredential) { $winCredential = $WindowsCredential } elseif ($WindowsUsername -and $WindowsPassword) { $securePass = ConvertTo-SecureString $WindowsPassword -AsPlainText -Force $winCredential = New-Object System.Management.Automation.PSCredential($WindowsUsername, $securePass) } elseif ($config.WindowsUsername -and $config.WindowsPassword) { $securePass = ConvertTo-SecureString $config.WindowsPassword -AsPlainText -Force $winCredential = New-Object System.Management.Automation.PSCredential($config.WindowsUsername, $securePass) } $config._WinCredential = $winCredential if (-not [System.IO.Path]::IsPathRooted($config.OutputDir)) { $config.OutputDir = Join-Path $ScriptDir $config.OutputDir } if (-not (Test-Path $config.OutputDir)) { New-Item -ItemType Directory -Path $config.OutputDir -Force | Out-Null } # ── Banner ──────────────────────────────────────────────────────────────────── $runMode = if ($DryRun) { "DRY RUN — no changes will be made" } else { "LIVE ROLLBACK — DNS settings WILL be restored" } Write-Host "" Write-Host "╔══════════════════════════════════════════════════════════════╗" -ForegroundColor Yellow Write-Host "║ DNS ROLLBACK ORCHESTRATOR ║" -ForegroundColor Yellow Write-Host "╚══════════════════════════════════════════════════════════════╝" -ForegroundColor Yellow Write-Host " Mode : " -NoNewline if ($DryRun) { Write-Host $runMode -ForegroundColor Yellow } else { Write-Host $runMode -ForegroundColor Red } Write-Host " Rollback file: $RollbackFile" if ($Hostname) { Write-Host " Scope : Single server — $Hostname" -ForegroundColor Cyan } else { Write-Host " Scope : Full fleet" } Write-Host " Win auth : " -NoNewline if ($winCredential -ne [System.Management.Automation.PSCredential]::Empty) { Write-Host "Explicit ($($winCredential.UserName))" -ForegroundColor Yellow } else { Write-Host "Implicit (current user / Kerberos)" -ForegroundColor Gray } Write-Host " Started at : $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" Write-Host "" # ── Load rollback file ──────────────────────────────────────────────────────── Write-Host "── Loading rollback data ────────────────────────────────────────" -ForegroundColor Yellow $rollbackData = Read-RollbackFile -Path $RollbackFile # ── Filter entries ──────────────────────────────────────────────────────────── $entries = @($rollbackData.Servers) if ($Hostname) { $entries = @($entries | Where-Object { $_.Hostname -ieq $Hostname }) if ($entries.Count -eq 0) { Write-Warning "No entry found for hostname '$Hostname' in rollback file." Write-Host "Available hostnames:" $rollbackData.Servers | ForEach-Object { Write-Host " $($_.Hostname)" } exit 1 } Write-Host " Filtered to 1 server: $Hostname" -ForegroundColor Cyan } Write-Host " Processing $($entries.Count) server(s) for rollback" -ForegroundColor Yellow Write-Host "" # ── Process rollback entries ────────────────────────────────────────────────── Write-Host "── Rolling back servers ─────────────────────────────────────────" -ForegroundColor Yellow $allResults = [System.Collections.Generic.List[PSCustomObject]]::new() $i = 0 foreach ($entry in $entries) { $i++ $entryHostname = $entry.Hostname $entryOS = $entry.OS Write-Host " [$i/$($entries.Count)] $entryHostname ($entryOS) ..." -NoNewline $result = $null try { if ($entryOS -eq "Windows") { $result = Invoke-WindowsRollback -Entry $entry -Config $config -Credential $config._WinCredential -DryRun:$DryRun } elseif ($entryOS -eq "Linux") { # Convert PSCustomObject entry to hashtable for Invoke-LinuxDnsRollback $entryHT = ConvertTo-RollbackHashtable -Entry $entry $result = Invoke-LinuxDnsRollback -RollbackEntry $entryHT -Config $config -DryRun:$DryRun } else { $result = [PSCustomObject]@{ Hostname = $entryHostname ResolvedIP = "Unknown" OS = $entryOS Distro = $entry.Distro OldDNS = ($entry.NewDNS -join ";") NewDNS = ($entry.OldDNS -join ";") ConnectionMethod = "None" ChangeStatus = "Skipped" ValidationStatus = "Skipped" ModifiedFiles = "" ServiceRestarted = "" ErrorMessage = "Unknown OS in rollback file: $entryOS" DryRun = $DryRun.IsPresent Timestamp = (Get-Date -Format "yyyy-MM-ddTHH:mm:ss") } } } catch { $result = [PSCustomObject]@{ Hostname = $entryHostname ResolvedIP = "Unknown" OS = $entryOS Distro = $entry.Distro OldDNS = ($entry.NewDNS -join ";") NewDNS = ($entry.OldDNS -join ";") ConnectionMethod = "Error" ChangeStatus = "Failed" ValidationStatus = "NotRun" ModifiedFiles = "" ServiceRestarted = "" ErrorMessage = "Rollback exception: $($_.Exception.Message)" DryRun = $DryRun.IsPresent Timestamp = (Get-Date -Format "yyyy-MM-ddTHH:mm:ss") } } $statusColor = switch ($result.ChangeStatus) { "Success" { "Green" } "DryRun" { "Cyan" } "Failed" { "Red" } "Skipped" { "Yellow" } default { "White" } } Write-Host " [$($result.ChangeStatus)]" -ForegroundColor $statusColor -NoNewline Write-Host " (Validation: $($result.ValidationStatus))" $allResults.Add($result) } # ── Write rollback report ───────────────────────────────────────────────────── $runTimestamp = Get-Date -Format "yyyyMMdd-HHmmss" $rollbackReportPath = Join-Path $config.OutputDir "dns-rollback-report-$runTimestamp.csv" Write-Host "" Write-Host "── Writing rollback report ──────────────────────────────────────" -ForegroundColor Yellow Write-MigrationReport -Results $allResults.ToArray() -OutputPath $rollbackReportPath -NoAppend Write-Host " Rollback completed at: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" -ForegroundColor Gray Write-Host "" # ── Windows rollback function ───────────────────────────────────────────────── function Invoke-WindowsRollback { <# .SYNOPSIS Restores original DNS settings on a Windows server from rollback data. .DESCRIPTION Connects via WinRM/PSSession/PsExec and calls Set-DnsClientServerAddress with the original DNS IPs stored in the rollback file. #> param( [object] $Entry, [hashtable]$Config, [System.Management.Automation.PSCredential] [System.Management.Automation.Credential()] $Credential = [System.Management.Automation.PSCredential]::Empty, [switch] $DryRun ) $hostname = $Entry.Hostname $oldDNS = @($Entry.OldDNS) # This is what we restore TO (pre-migration DNS) $nicName = $Entry.NICName $timestamp = Get-Date -Format "yyyy-MM-ddTHH:mm:ss" $useCredential = ($Credential -ne [System.Management.Automation.PSCredential]::Empty -and $null -ne $Credential) $resolvedIP = Resolve-ADHostnameToIP -Hostname $hostname $result = [PSCustomObject]@{ Hostname = $hostname ResolvedIP = $resolvedIP OS = "Windows" Distro = $Entry.Distro OldDNS = ($Entry.NewDNS -join ";") # Rolling back FROM NewDNS NewDNS = ($oldDNS -join ";") # Rolling back TO OldDNS ConnectionMethod = "" ChangeStatus = "Pending" ValidationStatus = "NotRun" ModifiedFiles = if ($nicName) { "NIC:$nicName" } else { "N/A" } ServiceRestarted = "N/A" ErrorMessage = "" DryRun = $DryRun.IsPresent Timestamp = $timestamp } if ($resolvedIP -eq "Unresolvable") { $result.ChangeStatus = "Skipped" $result.ValidationStatus = "Skipped" $result.ErrorMessage = "Hostname unresolvable" return $result } if ($DryRun) { Write-Host "" Write-Host " [$hostname] ROLLBACK DRY RUN (Windows)" -ForegroundColor Yellow Write-Host " NIC : $nicName" -ForegroundColor Cyan Write-Host " Restore DNS : $($oldDNS -join ', ')" -ForegroundColor Cyan Write-Host " Current DNS : $($Entry.NewDNS -join ', ')" -ForegroundColor Gray $result.ChangeStatus = "DryRun" $result.ValidationStatus = "DryRun" return $result } # Remote scriptblock to restore DNS $restoreBlock = { param([string[]]$OldDNS, [string]$NICName) $out = @{ Status="Pending"; Error="" } try { $targetNIC = $NICName if (-not $targetNIC -or $targetNIC -eq "") { # Fall back to NIC with default route $route = Get-NetRoute -DestinationPrefix "0.0.0.0/0" -ErrorAction SilentlyContinue | Sort-Object RouteMetric | Select-Object -First 1 if ($route) { $adapter = Get-NetAdapter -InterfaceIndex $route.InterfaceIndex -ErrorAction SilentlyContinue $targetNIC = $adapter.Name } if (-not $targetNIC) { $targetNIC = (Get-NetAdapter -Physical | Where-Object Status -eq Up | Select-Object -First 1).Name } } Set-DnsClientServerAddress -InterfaceAlias $targetNIC -ServerAddresses $OldDNS -ErrorAction Stop Clear-DnsClientCache -ErrorAction SilentlyContinue $out.Status = "Success" } catch { $out.Status = "Failed" $out.Error = $_.Exception.Message } return $out } # Attempt WinRM → PSSession → PsExec (same pattern as Set-WindowsDns) $remoteResult = $null $connectionMethod = "" try { $sessionOpt = New-PSSessionOption -OpenTimeout ($Config.WinRMTimeout * 1000) $invokeParams = @{ ComputerName = $hostname ScriptBlock = $restoreBlock ArgumentList = @(,$oldDNS), $nicName SessionOption = $sessionOpt ErrorAction = "Stop" } if ($useCredential) { $invokeParams.Credential = $Credential } $remoteResult = Invoke-Command @invokeParams $connectionMethod = "WinRM" } catch { Write-Warning "[$hostname] WinRM failed: $($_.Exception.Message)" try { $sessionOpt = New-PSSessionOption -OpenTimeout ($Config.WinRMTimeout * 1000) $sessionParams = @{ ComputerName = $hostname; SessionOption = $sessionOpt; ErrorAction = "Stop" } if ($useCredential) { $sessionParams.Credential = $Credential } $session = New-PSSession @sessionParams $remoteResult = Invoke-Command -Session $session -ScriptBlock $restoreBlock ` -ArgumentList @(,$oldDNS), $nicName Remove-PSSession $session -ErrorAction SilentlyContinue $connectionMethod = "PSSession" } catch { Write-Warning "[$hostname] PSSession failed: $($_.Exception.Message)" if (Test-Path $Config.PsExecPath) { $dnsJoined = '"{0}"' -f ($oldDNS -join '","') $dnsArray = "@($dnsJoined)" $nicArg = if ($nicName) { "`$nic='$nicName'" } else { "`$route=Get-NetRoute -DestinationPrefix '0.0.0.0/0'|Sort RouteMetric|Select -First 1;" + "`$nic=(Get-NetAdapter -InterfaceIndex `$route.InterfaceIndex).Name" } $credArgs = @() if ($useCredential) { $credArgs = @("-u", $Credential.UserName, "-p", $Credential.GetNetworkCredential().Password) } $cmd = "$nicArg; Set-DnsClientServerAddress -InterfaceAlias `$nic -ServerAddresses $dnsArray; Clear-DnsClientCache; Write-Output 'ROLLBACK_OK'" $enc = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($cmd)) $psexecArgs = @("\\$hostname", "-accepteula", "-nobanner", "-h") + $credArgs + @("powershell.exe", "-NonInteractive", "-EncodedCommand", $enc) $out = & $Config.PsExecPath @psexecArgs 2>&1 $connectionMethod = "PsExec" $remoteResult = @{ Status = if ($out -match "ROLLBACK_OK") { "Success" } else { "Failed" } Error = if ($out -notmatch "ROLLBACK_OK") { $out -join " " } else { "" } } } else { $result.ChangeStatus = "Failed" $result.ConnectionMethod = "None" $result.ErrorMessage = "All connection methods failed and PsExec not found" return $result } } } $result.ConnectionMethod = $connectionMethod $result.ChangeStatus = if ($remoteResult.Status -eq "Success") { "Success" } else { "Failed" } $result.ErrorMessage = $remoteResult.Error # Validate — confirm old DNS IPs are resolvable if ($result.ChangeStatus -eq "Success") { $result.ValidationStatus = Test-WindowsDnsValidation ` -Hostname $hostname ` -NewDNS ($oldDNS | Select-Object -First 1) ` -Domain $Config.Domain ` -ConnectionMethod $connectionMethod ` -Config $Config ` -Credential $Credential } return $result } # ── Helper: convert PSCustomObject rollback entry to hashtable ──────────────── function ConvertTo-RollbackHashtable { <# .SYNOPSIS Converts a JSON-deserialized rollback entry (PSCustomObject) to a hashtable with the shape expected by Invoke-LinuxDnsRollback. #> param([object]$Entry) # Normalise ModifiedFiles — JSON deserialization returns PSCustomObject[] $modifiedFiles = @($Entry.ModifiedFiles | ForEach-Object { @{ Path = $_.Path ContentBefore = $_.ContentBefore ContentAfter = $_.ContentAfter } }) return @{ Hostname = $Entry.Hostname OS = $Entry.OS Distro = $Entry.Distro ConnectionMethod = $Entry.ConnectionMethod OldDNS = @($Entry.OldDNS) NewDNS = @($Entry.NewDNS) NICName = $Entry.NICName ModifiedFiles = $modifiedFiles ServiceRestarted = $Entry.ServiceRestarted ResolvConfBefore = $Entry.ResolvConfBefore ResolvConfAfter = $Entry.ResolvConfAfter Timestamp = $Entry.Timestamp } }