648 lines
28 KiB
PowerShell
648 lines
28 KiB
PowerShell
#Requires -Version 5.1
|
|
<#
|
|
.SYNOPSIS
|
|
Automates DNS server reconfiguration across a mixed Windows/Linux fleet.
|
|
|
|
.DESCRIPTION
|
|
Orchestrates DNS migration from legacy Domain Controller IPs to a new DC.
|
|
Supports three input modes (combinable):
|
|
-OUPath : Discover Windows servers from an Active Directory OU
|
|
-ServerFile : Read a CSV or TXT file containing Windows and/or Linux server hostnames
|
|
-Servers : Inline comma-separated or array of hostnames
|
|
|
|
For each server the script:
|
|
1. Resolves the hostname to an IP via DNS
|
|
2. Connects remotely (WinRM/PSSession/PsExec for Windows, SSH/plink for Linux)
|
|
3. Reads and records the current DNS configuration
|
|
4. Writes a rollback JSON file before making any changes
|
|
5. Applies the new DNS IP(s)
|
|
6. Validates DNS resolution using the new server
|
|
7. Writes a detailed CSV report
|
|
|
|
Use -DryRun to probe connectivity and show proposed changes without
|
|
modifying any server.
|
|
|
|
.PARAMETER OUPath
|
|
Distinguished name of the AD OU to query for Windows servers.
|
|
Example: "OU=Servers,OU=DC,DC=corp,DC=local"
|
|
Can be combined with -ServerFile to cover Windows + Linux in one run.
|
|
|
|
.PARAMETER ServerFile
|
|
Path to a CSV or TXT file containing Windows and/or Linux servers to process.
|
|
CSV format: Hostname, OS, Distro (OS = Windows or Linux; Distro is optional)
|
|
TXT format: one hostname per line (OS auto-detected at runtime via WinRM/SSH probe)
|
|
Lines starting with # are treated as comments.
|
|
Both OS types can coexist in the same file — the OS column controls routing.
|
|
|
|
.PARAMETER Servers
|
|
Inline list of hostnames. Accepts a comma-separated string or an array.
|
|
OS is auto-detected at runtime via WinRM probe (Windows) or SSH (Linux).
|
|
Example: -Servers "srv01,srv02,ubuntu-01"
|
|
|
|
.PARAMETER ConfigFile
|
|
Path to config.psd1. Defaults to .\config.psd1 in the script directory.
|
|
|
|
.PARAMETER OutputDir
|
|
Directory for CSV reports, rollback JSON, and log files.
|
|
Defaults to .\logs relative to the script directory.
|
|
|
|
.PARAMETER NewDNS
|
|
Override the NewDNS value from config.psd1 at runtime.
|
|
Example: -NewDNS "10.35.40.1"
|
|
|
|
.PARAMETER LinuxSSHPassword
|
|
Override the LinuxSSHPassword from config.psd1 at runtime.
|
|
Avoids storing the password in the config file.
|
|
|
|
.PARAMETER WindowsCredential
|
|
Optional PSCredential for Windows remote connections (WinRM / PSSession / PsExec).
|
|
If not provided, the script uses implicit authentication — the identity of the
|
|
user running this script (Kerberos / current logged-in domain account).
|
|
Use this when you need to connect as a different domain or local account.
|
|
Example: -WindowsCredential (Get-Credential)
|
|
|
|
.PARAMETER WindowsUsername
|
|
Optional Windows username as an alternative to -WindowsCredential.
|
|
Must be used together with -WindowsPassword.
|
|
Example: -WindowsUsername "CORP\migration-svc" -WindowsPassword "P@ssw0rd"
|
|
|
|
.PARAMETER DryRun
|
|
Read-only mode. Connects to each server, reads current DNS, shows a
|
|
diff table of what would change — makes zero modifications.
|
|
|
|
.PARAMETER Parallel
|
|
Process servers concurrently. Uses ForEach-Object -Parallel (PS7+) or
|
|
Start-Job (PS5). Controlled by ThrottleLimit in config.psd1.
|
|
|
|
.PARAMETER NoRecurse
|
|
When using -OUPath, search only the immediate OU (not sub-OUs).
|
|
|
|
.EXAMPLE
|
|
# Dry run — Windows servers from AD OU
|
|
.\Invoke-DnsMigration.ps1 -OUPath "OU=Servers,DC=corp,DC=local" -DryRun
|
|
|
|
.EXAMPLE
|
|
# Full migration — servers from CSV (Windows and Linux mixed)
|
|
.\Invoke-DnsMigration.ps1 -ServerFile ".\servers.csv"
|
|
|
|
.EXAMPLE
|
|
# Combined run — AD for Windows + CSV for mixed fleet
|
|
.\Invoke-DnsMigration.ps1 -OUPath "OU=Servers,DC=corp,DC=local" `
|
|
-ServerFile ".\servers.csv"
|
|
|
|
.EXAMPLE
|
|
# Inline list, dry run
|
|
.\Invoke-DnsMigration.ps1 -Servers "srv01,srv02,ubuntu-01" -DryRun
|
|
|
|
.EXAMPLE
|
|
# Explicit Windows credentials (different account)
|
|
.\Invoke-DnsMigration.ps1 -OUPath "OU=Servers,DC=corp,DC=local" `
|
|
-WindowsCredential (Get-Credential)
|
|
|
|
.EXAMPLE
|
|
# Explicit Windows credentials via username/password params
|
|
.\Invoke-DnsMigration.ps1 -OUPath "OU=Servers,DC=corp,DC=local" `
|
|
-WindowsUsername "CORP\migration-svc" `
|
|
-WindowsPassword "P@ssw0rd"
|
|
#>
|
|
[CmdletBinding(SupportsShouldProcess)]
|
|
param(
|
|
# ── Input sources ─────────────────────────────────────────────────────────
|
|
[string] $OUPath,
|
|
[string] $ServerFile,
|
|
[string[]] $Servers,
|
|
|
|
# ── Configuration ─────────────────────────────────────────────────────────
|
|
[string] $ConfigFile = "",
|
|
[string] $OutputDir = "",
|
|
[string] $NewDNS = "",
|
|
[string] $LinuxSSHPassword = "",
|
|
|
|
# ── Windows authentication (all optional — omit to use implicit Kerberos) ─
|
|
[System.Management.Automation.PSCredential]
|
|
[System.Management.Automation.Credential()]
|
|
$WindowsCredential = [System.Management.Automation.PSCredential]::Empty,
|
|
|
|
[string] $WindowsUsername = "",
|
|
[string] $WindowsPassword = "",
|
|
|
|
# ── Execution flags ───────────────────────────────────────────────────────
|
|
[switch] $DryRun,
|
|
[switch] $Parallel,
|
|
[switch] $NoRecurse
|
|
)
|
|
|
|
Set-StrictMode -Version Latest
|
|
$ErrorActionPreference = "Stop"
|
|
|
|
# ── Resolve script root reliably in both PS5 and PS7 ─────────────────────────
|
|
$ScriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { Split-Path $MyInvocation.MyCommand.Path }
|
|
|
|
# ── Load modules ─────────────────────────────────────────────────────────────
|
|
$modulePaths = @(
|
|
"modules\Get-ServersFromOU.ps1",
|
|
"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 configuration ────────────────────────────────────────────────────────
|
|
if (-not $ConfigFile) {
|
|
$ConfigFile = Join-Path $ScriptDir "config.psd1"
|
|
}
|
|
if (-not (Test-Path $ConfigFile)) {
|
|
throw "Configuration file not found: $ConfigFile`nExpected at: $ConfigFile"
|
|
}
|
|
|
|
$config = Import-PowerShellDataFile $ConfigFile
|
|
|
|
# Apply runtime overrides
|
|
if ($NewDNS) { $config.NewDNS = @($NewDNS) }
|
|
if ($LinuxSSHPassword) { $config.LinuxSSHPassword = $LinuxSSHPassword }
|
|
if ($OutputDir) { $config.OutputDir = $OutputDir }
|
|
|
|
# ── Resolve Windows credential ────────────────────────────────────────────────
|
|
# Priority: -WindowsCredential > -WindowsUsername/-WindowsPassword > config.psd1 > implicit
|
|
$winCredential = [System.Management.Automation.PSCredential]::Empty
|
|
|
|
if ($WindowsCredential -ne [System.Management.Automation.PSCredential]::Empty -and $null -ne $WindowsCredential) {
|
|
# Explicit PSCredential object passed directly
|
|
$winCredential = $WindowsCredential
|
|
Write-Verbose "Windows auth: using -WindowsCredential ($($winCredential.UserName))"
|
|
}
|
|
elseif ($WindowsUsername -and $WindowsPassword) {
|
|
# Username + password params
|
|
$securePass = ConvertTo-SecureString $WindowsPassword -AsPlainText -Force
|
|
$winCredential = New-Object System.Management.Automation.PSCredential($WindowsUsername, $securePass)
|
|
Write-Verbose "Windows auth: using -WindowsUsername/$WindowsPassword ($WindowsUsername)"
|
|
}
|
|
elseif ($config.WindowsUsername -and $config.WindowsPassword) {
|
|
# Credentials from config.psd1
|
|
$securePass = ConvertTo-SecureString $config.WindowsPassword -AsPlainText -Force
|
|
$winCredential = New-Object System.Management.Automation.PSCredential($config.WindowsUsername, $securePass)
|
|
Write-Verbose "Windows auth: using config.psd1 credentials ($($config.WindowsUsername))"
|
|
}
|
|
else {
|
|
Write-Verbose "Windows auth: implicit (current user / Kerberos)"
|
|
}
|
|
|
|
# Store resolved credential in config for passing to sub-functions
|
|
$config._WinCredential = $winCredential
|
|
|
|
# Resolve OutputDir to absolute path
|
|
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
|
|
}
|
|
|
|
# ── Validate inputs ───────────────────────────────────────────────────────────
|
|
if (-not $OUPath -and -not $ServerFile -and (-not $Servers -or $Servers.Count -eq 0)) {
|
|
throw "No input source specified. Use -OUPath, -ServerFile, or -Servers."
|
|
}
|
|
|
|
if (-not $config.NewDNS -or $config.NewDNS.Count -eq 0) {
|
|
throw "NewDNS is not configured. Set it in config.psd1 or pass -NewDNS."
|
|
}
|
|
|
|
# ── Banner ────────────────────────────────────────────────────────────────────
|
|
$runMode = if ($DryRun) { "DRY RUN — no changes will be made" } else { "LIVE RUN — DNS settings WILL be changed" }
|
|
Write-Host ""
|
|
Write-Host "╔══════════════════════════════════════════════════════════════╗" -ForegroundColor Cyan
|
|
Write-Host "║ DNS MIGRATION ORCHESTRATOR ║" -ForegroundColor Cyan
|
|
Write-Host "╚══════════════════════════════════════════════════════════════╝" -ForegroundColor Cyan
|
|
Write-Host " Mode : " -NoNewline
|
|
if ($DryRun) { Write-Host $runMode -ForegroundColor Yellow }
|
|
else { Write-Host $runMode -ForegroundColor Red }
|
|
Write-Host " New DNS : $($config.NewDNS -join ', ')"
|
|
Write-Host " Old DNS : $($config.OldDNS -join ', ')"
|
|
Write-Host " Domain : $($config.Domain)"
|
|
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 " Output dir : $($config.OutputDir)"
|
|
Write-Host " Started at : $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
|
|
Write-Host ""
|
|
|
|
# ── Build server list ─────────────────────────────────────────────────────────
|
|
Write-Host "── Discovering servers ──────────────────────────────────────────" -ForegroundColor Cyan
|
|
|
|
$allServers = [System.Collections.Generic.List[object]]::new()
|
|
$sourceLists = [System.Collections.Generic.List[object[]]]::new()
|
|
|
|
# Source 1: Active Directory OU
|
|
if ($OUPath) {
|
|
Write-Host " [AD] Querying OU: $OUPath"
|
|
try {
|
|
$ouServers = Get-ServersFromOU -OUPath $OUPath -NoRecurse:$NoRecurse
|
|
if ($ouServers -and $ouServers.Count -gt 0) {
|
|
$sourceLists.Add($ouServers)
|
|
Write-Host " [AD] Found $($ouServers.Count) Windows server(s)" -ForegroundColor Green
|
|
}
|
|
else {
|
|
Write-Warning " [AD] No servers found in OU: $OUPath"
|
|
}
|
|
}
|
|
catch {
|
|
Write-Warning " [AD] Failed to query OU '$OUPath': $($_.Exception.Message)"
|
|
}
|
|
}
|
|
|
|
# Source 2: Server file (mixed Windows + Linux)
|
|
if ($ServerFile) {
|
|
Write-Host " [File] Parsing: $ServerFile"
|
|
try {
|
|
$fileServers = ConvertFrom-ServerListFile -Path $ServerFile
|
|
if ($fileServers -and $fileServers.Count -gt 0) {
|
|
$sourceLists.Add($fileServers)
|
|
Write-Host " [File] Found $($fileServers.Count) server(s)" -ForegroundColor Green
|
|
}
|
|
}
|
|
catch {
|
|
Write-Warning " [File] Failed to parse '$ServerFile': $($_.Exception.Message)"
|
|
}
|
|
}
|
|
|
|
# Source 3: Inline servers
|
|
if ($Servers -and $Servers.Count -gt 0) {
|
|
Write-Host " [Inline] Parsing inline server list..."
|
|
try {
|
|
$inlineServers = ConvertFrom-InlineServerList -Servers $Servers
|
|
if ($inlineServers -and $inlineServers.Count -gt 0) {
|
|
$sourceLists.Add($inlineServers)
|
|
Write-Host " [Inline] Found $($inlineServers.Count) server(s)" -ForegroundColor Green
|
|
}
|
|
}
|
|
catch {
|
|
Write-Warning " [Inline] Failed to parse server list: $($_.Exception.Message)"
|
|
}
|
|
}
|
|
|
|
# Merge and deduplicate all sources
|
|
if ($sourceLists.Count -gt 1) {
|
|
$mergedArr = $sourceLists.ToArray()
|
|
$allServerArr = Merge-ServerLists -Lists $mergedArr
|
|
}
|
|
elseif ($sourceLists.Count -eq 1) {
|
|
$allServerArr = $sourceLists[0]
|
|
}
|
|
else {
|
|
throw "No servers could be discovered from the provided input sources."
|
|
}
|
|
|
|
foreach ($s in $allServerArr) { $allServers.Add($s) }
|
|
|
|
if ($allServers.Count -eq 0) {
|
|
throw "No servers to process after discovery and deduplication."
|
|
}
|
|
|
|
Write-Host ""
|
|
Write-Host " Total unique servers to process: $($allServers.Count)" -ForegroundColor Cyan
|
|
|
|
# ── Auto-detect OS for servers with OS = "Unknown" ────────────────────────────
|
|
$unknownOS = @($allServers | Where-Object { $_.OS -eq "Unknown" -or $_.OS -eq "" })
|
|
if ($unknownOS.Count -gt 0) {
|
|
Write-Host ""
|
|
Write-Host "── Auto-detecting OS for $($unknownOS.Count) server(s) ──────────────────" -ForegroundColor Cyan
|
|
|
|
foreach ($srv in $unknownOS) {
|
|
$detectedOS = Detect-ServerOS -Hostname $srv.Hostname -Config $config
|
|
$srv.OS = $detectedOS
|
|
Write-Host " $($srv.Hostname) → $detectedOS"
|
|
}
|
|
}
|
|
|
|
# ── Dry run preview table ─────────────────────────────────────────────────────
|
|
if ($DryRun) {
|
|
Write-Host ""
|
|
Write-Host "── DRY RUN PREVIEW ─────────────────────────────────────────────" -ForegroundColor Yellow
|
|
Write-Host " The following changes WOULD be made:" -ForegroundColor Yellow
|
|
Write-Host ""
|
|
$previewHeader = "{0,-35} {1,-10} {2,-12} {3,-28} {4,-15} {5}" `
|
|
-f "Hostname", "OS", "Connectivity", "Current DNS", "Proposed DNS", "WouldChange"
|
|
Write-Host $previewHeader -ForegroundColor White
|
|
Write-Host ("-" * 115)
|
|
}
|
|
|
|
# ── Timestamp for output files ────────────────────────────────────────────────
|
|
$runTimestamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
|
$csvOutputPath = Join-Path $config.OutputDir "dns-migration-report-$runTimestamp.csv"
|
|
|
|
# ── Process servers ───────────────────────────────────────────────────────────
|
|
Write-Host ""
|
|
if (-not $DryRun) {
|
|
Write-Host "── Processing servers ──────────────────────────────────────────" -ForegroundColor Cyan
|
|
}
|
|
|
|
$allResults = [System.Collections.Concurrent.ConcurrentBag[object]]::new()
|
|
|
|
# Script block used for parallel execution — captures needed functions
|
|
$processServer = {
|
|
param($server, $config, $DryRun, $ScriptDir)
|
|
|
|
# Re-dot-source modules in parallel runspace (each runspace is isolated)
|
|
. (Join-Path $ScriptDir "modules\Set-WindowsDns.ps1")
|
|
. (Join-Path $ScriptDir "modules\Invoke-LinuxDns.ps1")
|
|
|
|
$hostname = $server.Hostname
|
|
|
|
try {
|
|
if ($server.OS -eq "Windows") {
|
|
$result = Set-WindowsDns -Hostname $hostname -NewDNS $config.NewDNS `
|
|
-Config $config -DryRun:$DryRun
|
|
}
|
|
elseif ($server.OS -eq "Linux") {
|
|
$result = Invoke-LinuxDns -Hostname $hostname -Config $config `
|
|
-DryRun:$DryRun -Distro $server.Distro
|
|
}
|
|
else {
|
|
$result = [PSCustomObject]@{
|
|
Hostname = $hostname
|
|
ResolvedIP = $server.ResolvedIP
|
|
OS = $server.OS
|
|
Distro = ""
|
|
OldDNS = ""
|
|
NewDNS = $config.NewDNS -join ";"
|
|
ConnectionMethod = "None"
|
|
ChangeStatus = "Skipped"
|
|
ValidationStatus = "Skipped"
|
|
ModifiedFiles = ""
|
|
ServiceRestarted = ""
|
|
ErrorMessage = "OS could not be determined (got: '$($server.OS)')"
|
|
DryRun = $DryRun
|
|
Timestamp = (Get-Date -Format "yyyy-MM-ddTHH:mm:ss")
|
|
_RollbackData = $null
|
|
}
|
|
}
|
|
return $result
|
|
}
|
|
catch {
|
|
return [PSCustomObject]@{
|
|
Hostname = $hostname
|
|
ResolvedIP = $server.ResolvedIP
|
|
OS = $server.OS
|
|
Distro = ""
|
|
OldDNS = ""
|
|
NewDNS = $config.NewDNS -join ";"
|
|
ConnectionMethod = "Error"
|
|
ChangeStatus = "Failed"
|
|
ValidationStatus = "NotRun"
|
|
ModifiedFiles = ""
|
|
ServiceRestarted = ""
|
|
ErrorMessage = "Unhandled exception: $($_.Exception.Message)"
|
|
DryRun = $DryRun
|
|
Timestamp = (Get-Date -Format "yyyy-MM-ddTHH:mm:ss")
|
|
_RollbackData = $null
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($Parallel -and $allServers.Count -gt 1) {
|
|
$throttle = if ($config.ThrottleLimit) { [int]$config.ThrottleLimit } else { 10 }
|
|
|
|
# PS7+: ForEach-Object -Parallel
|
|
if ($PSVersionTable.PSVersion.Major -ge 7) {
|
|
Write-Host " Running parallel (PS7, throttle: $throttle)..." -ForegroundColor Gray
|
|
|
|
$serverArray = $allServers.ToArray()
|
|
$results = $serverArray | ForEach-Object -Parallel {
|
|
$server = $_
|
|
$config = $using:config
|
|
$DryRun = $using:DryRun
|
|
$ScriptDir = $using:ScriptDir
|
|
|
|
. (Join-Path $ScriptDir "modules\Set-WindowsDns.ps1")
|
|
. (Join-Path $ScriptDir "modules\Invoke-LinuxDns.ps1")
|
|
|
|
$hostname = $server.Hostname
|
|
try {
|
|
if ($server.OS -eq "Windows") {
|
|
Set-WindowsDns -Hostname $hostname -NewDNS $config.NewDNS `
|
|
-Config $config -Credential $config._WinCredential -DryRun:$DryRun
|
|
}
|
|
elseif ($server.OS -eq "Linux") {
|
|
Invoke-LinuxDns -Hostname $hostname -Config $config `
|
|
-DryRun:$DryRun -Distro $server.Distro
|
|
}
|
|
else {
|
|
[PSCustomObject]@{
|
|
Hostname="$hostname"; ResolvedIP=$server.ResolvedIP
|
|
OS=$server.OS; Distro=""; OldDNS=""; NewDNS=$config.NewDNS -join ";"
|
|
ConnectionMethod="None"; ChangeStatus="Skipped"
|
|
ValidationStatus="Skipped"; ModifiedFiles=""; ServiceRestarted=""
|
|
ErrorMessage="Unknown OS: $($server.OS)"; DryRun=$DryRun
|
|
Timestamp=(Get-Date -Format "yyyy-MM-ddTHH:mm:ss"); _RollbackData=$null
|
|
}
|
|
}
|
|
}
|
|
catch {
|
|
[PSCustomObject]@{
|
|
Hostname="$hostname"; ResolvedIP=$server.ResolvedIP
|
|
OS=$server.OS; Distro=""; OldDNS=""; NewDNS=$config.NewDNS -join ";"
|
|
ConnectionMethod="Error"; ChangeStatus="Failed"
|
|
ValidationStatus="NotRun"; ModifiedFiles=""; ServiceRestarted=""
|
|
ErrorMessage="Exception: $($_.Exception.Message)"; DryRun=$DryRun
|
|
Timestamp=(Get-Date -Format "yyyy-MM-ddTHH:mm:ss"); _RollbackData=$null
|
|
}
|
|
}
|
|
} -ThrottleLimit $throttle
|
|
|
|
foreach ($r in $results) { $allResults.Add($r) }
|
|
}
|
|
else {
|
|
# PS5: Start-Job fallback
|
|
Write-Host " Running parallel (PS5 jobs, throttle: $throttle)..." -ForegroundColor Gray
|
|
|
|
$jobs = [System.Collections.Generic.List[System.Management.Automation.Job]]::new()
|
|
$serverArr = $allServers.ToArray()
|
|
$idx = 0
|
|
|
|
while ($idx -lt $serverArr.Count) {
|
|
# Throttle: wait if max active jobs reached
|
|
while (@($jobs | Where-Object { $_.State -eq "Running" }).Count -ge $throttle) {
|
|
Start-Sleep -Milliseconds 500
|
|
}
|
|
|
|
$srv = $serverArr[$idx]
|
|
$job = Start-Job -ScriptBlock $processServer `
|
|
-ArgumentList $srv, $config, $DryRun.IsPresent, $ScriptDir
|
|
$jobs.Add($job)
|
|
$idx++
|
|
}
|
|
|
|
# Collect results
|
|
foreach ($job in $jobs) {
|
|
$result = $job | Wait-Job | Receive-Job
|
|
if ($result) { $allResults.Add($result) }
|
|
Remove-Job $job -Force
|
|
}
|
|
}
|
|
}
|
|
else {
|
|
# Sequential execution
|
|
$i = 0
|
|
foreach ($server in $allServers) {
|
|
$i++
|
|
$hostname = $server.Hostname
|
|
Write-Host " [$i/$($allServers.Count)] $hostname ($($server.OS)) ..." -NoNewline
|
|
|
|
$result = $null
|
|
try {
|
|
if ($server.OS -eq "Windows") {
|
|
$result = Set-WindowsDns -Hostname $hostname -NewDNS $config.NewDNS `
|
|
-Config $config -Credential $config._WinCredential -DryRun:$DryRun
|
|
}
|
|
elseif ($server.OS -eq "Linux") {
|
|
$result = Invoke-LinuxDns -Hostname $hostname -Config $config `
|
|
-DryRun:$DryRun -Distro $server.Distro
|
|
}
|
|
else {
|
|
$result = [PSCustomObject]@{
|
|
Hostname="$hostname"; ResolvedIP=$server.ResolvedIP
|
|
OS=$server.OS; Distro=""; OldDNS=""; NewDNS=($config.NewDNS -join ";")
|
|
ConnectionMethod="None"; ChangeStatus="Skipped"
|
|
ValidationStatus="Skipped"; ModifiedFiles=""; ServiceRestarted=""
|
|
ErrorMessage="Unknown OS: $($server.OS)"; DryRun=$DryRun.IsPresent
|
|
Timestamp=(Get-Date -Format "yyyy-MM-ddTHH:mm:ss"); _RollbackData=$null
|
|
}
|
|
}
|
|
}
|
|
catch {
|
|
$result = [PSCustomObject]@{
|
|
Hostname="$hostname"; ResolvedIP=$server.ResolvedIP
|
|
OS=$server.OS; Distro=""; OldDNS=""; NewDNS=($config.NewDNS -join ";")
|
|
ConnectionMethod="Error"; ChangeStatus="Failed"
|
|
ValidationStatus="NotRun"; ModifiedFiles=""; ServiceRestarted=""
|
|
ErrorMessage="Exception: $($_.Exception.Message)"; DryRun=$DryRun.IsPresent
|
|
Timestamp=(Get-Date -Format "yyyy-MM-ddTHH:mm:ss"); _RollbackData=$null
|
|
}
|
|
}
|
|
|
|
# Inline status indicator
|
|
$statusColor = switch ($result.ChangeStatus) {
|
|
"Success" { "Green" }
|
|
"DryRun" { "Cyan" }
|
|
"Failed" { "Red" }
|
|
"ConnectionFailed" { "Red" }
|
|
"Skipped" { "Yellow" }
|
|
default { "White" }
|
|
}
|
|
Write-Host " [$($result.ChangeStatus)]" -ForegroundColor $statusColor -NoNewline
|
|
Write-Host " (Validation: $($result.ValidationStatus))"
|
|
|
|
# Dry run table row
|
|
if ($DryRun) {
|
|
$wouldChange = if ($result.OldDNS -and $result.OldDNS -ne $result.NewDNS) { "Yes" } else { "No" }
|
|
$connStatus = if ($result.ChangeStatus -eq "DryRun") { "OK ($($result.ConnectionMethod))" }
|
|
elseif ($result.ChangeStatus -eq "Failed") { "FAILED" }
|
|
else { $result.ConnectionMethod }
|
|
|
|
$row = "{0,-35} {1,-10} {2,-12} {3,-28} {4,-15} {5}" -f `
|
|
$hostname.Substring(0, [Math]::Min($hostname.Length, 34)),
|
|
$result.OS,
|
|
$connStatus,
|
|
($result.OldDNS ?? "Unknown").Substring(0, [Math]::Min(($result.OldDNS ?? "Unknown").Length, 27)),
|
|
($result.NewDNS ?? "").Substring(0, [Math]::Min(($result.NewDNS ?? "").Length, 14)),
|
|
$wouldChange
|
|
Write-Host " $row"
|
|
}
|
|
|
|
$allResults.Add($result)
|
|
}
|
|
}
|
|
|
|
# ── Collect results ───────────────────────────────────────────────────────────
|
|
$resultsArray = @($allResults)
|
|
|
|
if ($resultsArray.Count -eq 0) {
|
|
Write-Warning "No results collected. Nothing to report."
|
|
exit 0
|
|
}
|
|
|
|
# ── Write rollback file (before/after data is in results) ─────────────────────
|
|
if (-not $DryRun) {
|
|
Write-Host ""
|
|
Write-Host "── Writing rollback file ────────────────────────────────────────" -ForegroundColor Cyan
|
|
$rollbackPath = Write-RollbackFile -Results $resultsArray -OutputDir $config.OutputDir
|
|
Write-Host " Rollback file: $rollbackPath" -ForegroundColor Green
|
|
}
|
|
|
|
# ── Write CSV report ──────────────────────────────────────────────────────────
|
|
Write-Host ""
|
|
Write-Host "── Writing migration report ─────────────────────────────────────" -ForegroundColor Cyan
|
|
Write-MigrationReport -Results $resultsArray -OutputPath $csvOutputPath
|
|
|
|
Write-Host ""
|
|
Write-Host " Run completed at: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" -ForegroundColor Gray
|
|
if (-not $DryRun) {
|
|
Write-Host " To rollback: .\Invoke-DnsRollback.ps1 -RollbackFile `"$rollbackPath`"" -ForegroundColor Yellow
|
|
}
|
|
Write-Host ""
|
|
|
|
|
|
# ── Helper: auto-detect OS via connection probe ───────────────────────────────
|
|
function Detect-ServerOS {
|
|
<#
|
|
.SYNOPSIS
|
|
Probes a server to determine if it is Windows or Linux.
|
|
.DESCRIPTION
|
|
Tries WinRM first (Windows indicator). Falls back to SSH (Linux indicator).
|
|
Used when OS column is missing or "Unknown" in the server list.
|
|
#>
|
|
param([string]$Hostname, [hashtable]$Config)
|
|
|
|
# Try WinRM — quick test-connection style probe
|
|
try {
|
|
$sessionOpt = New-PSSessionOption -OpenTimeout 5000 -OperationTimeout 5000
|
|
$invokeParams = @{
|
|
ComputerName = $Hostname
|
|
ScriptBlock = { $true }
|
|
SessionOption = $sessionOpt
|
|
ErrorAction = "Stop"
|
|
}
|
|
$cred = $Config._WinCredential
|
|
if ($cred -and $cred -ne [System.Management.Automation.PSCredential]::Empty) {
|
|
$invokeParams.Credential = $cred
|
|
}
|
|
$null = Invoke-Command @invokeParams
|
|
return "Windows"
|
|
}
|
|
catch { }
|
|
|
|
# Try SSH via plink — just attempt a connection with immediate exit
|
|
if (Test-Path $Config.PlinkPath) {
|
|
try {
|
|
$proc = New-Object System.Diagnostics.Process
|
|
$proc.StartInfo.FileName = $Config.PlinkPath
|
|
$proc.StartInfo.Arguments = "-ssh -pw `"$($Config.LinuxSSHPassword)`" " +
|
|
"-o StrictHostKeyChecking=no -batch " +
|
|
"`"$($Config.LinuxSSHUser)@$Hostname`" `"echo LINUX_OK`""
|
|
$proc.StartInfo.UseShellExecute = $false
|
|
$proc.StartInfo.RedirectStandardOutput = $true
|
|
$proc.StartInfo.RedirectStandardError = $true
|
|
$proc.StartInfo.CreateNoWindow = $true
|
|
|
|
$null = $proc.Start()
|
|
$output = $proc.StandardOutput.ReadToEnd()
|
|
$proc.WaitForExit(($Config.SSHConnectTimeout + 5) * 1000) | Out-Null
|
|
|
|
if ($output -match "LINUX_OK") { return "Linux" }
|
|
}
|
|
catch { }
|
|
}
|
|
|
|
return "Unknown"
|
|
}
|