# ============================================================ # Invoke-LinuxDns.ps1 # ============================================================ # PowerShell SSH dispatcher for Linux DNS migration. # Uses plink.exe (PuTTY) for password-based SSH from Windows. # Streams set-linux-dns.sh via stdin, parses structured output, # returns a result object compatible with Write-MigrationReport. # ============================================================ function Invoke-LinuxDns { <# .SYNOPSIS Changes DNS settings on a remote Linux server via SSH (plink). .DESCRIPTION Streams set-linux-dns.sh to the remote host via plink.exe. Passes new DNS IP and domain as environment variables. Parses the structured key=value output from the bash script. Returns a PSCustomObject with the same schema as Set-WindowsDns. .PARAMETER Hostname Hostname or IP of the target Linux server. .PARAMETER Config Hashtable from config.psd1 containing SSH credentials and paths. .PARAMETER DryRun If set, passes --dry-run to the bash script. No changes made. .PARAMETER Distro Optional distro hint (e.g. "Ubuntu 22.04"). Script will auto-detect if not provided. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Hostname, [Parameter(Mandatory)] [hashtable]$Config, [switch]$DryRun, [string]$Distro = "" ) $timestamp = Get-Date -Format "yyyy-MM-ddTHH:mm:ss" $resolvedIP = Resolve-LinuxHostnameToIP -Hostname $Hostname # Base result object $result = [PSCustomObject]@{ Hostname = $Hostname ResolvedIP = $resolvedIP OS = "Linux" Distro = $Distro OldDNS = "" NewDNS = ($Config.NewDNS -join ";") ConnectionMethod = "SSH" ChangeStatus = "Pending" ValidationStatus = "NotRun" ModifiedFiles = "" ServiceRestarted = "" ErrorMessage = "" DryRun = $DryRun.IsPresent Timestamp = $timestamp # Extended fields for rollback — not in CSV but available in rollback JSON _RollbackData = $null } # Validate plink exists if (-not (Test-Path $Config.PlinkPath)) { $result.ChangeStatus = "Failed" $result.ErrorMessage = "plink.exe not found at: $($Config.PlinkPath). Download PuTTY from https://www.chiark.greenend.org.uk/~sgtatham/putty/latest.html" Write-Warning "[$Hostname] plink.exe not found at $($Config.PlinkPath)" return $result } # Validate bash script exists $bashScriptPath = Join-Path $PSScriptRoot "..\scripts\set-linux-dns.sh" $bashScriptPath = [System.IO.Path]::GetFullPath($bashScriptPath) if (-not (Test-Path $bashScriptPath)) { $result.ChangeStatus = "Failed" $result.ErrorMessage = "Bash script not found at: $bashScriptPath" Write-Warning "[$Hostname] Bash script not found: $bashScriptPath" return $result } if ($resolvedIP -eq "Unresolvable") { $result.ChangeStatus = "Skipped" $result.ValidationStatus = "Skipped" $result.ErrorMessage = "Hostname could not be resolved" Write-Warning "[$Hostname] Cannot resolve hostname — skipping" return $result } # ── Build remote command ────────────────────────────────────────────────── # Pass new DNS IP and domain as env vars so the script can use them. # --dry-run is appended as a script argument when DryRun is set. $newDNS = $Config.NewDNS[0] $domain = $Config.Domain $dryRunArg = if ($DryRun) { "-- --dry-run" } else { "" } # Remote command: set env vars, then execute bash from stdin $remoteCmd = "NEW_DNS='$newDNS' DNS_DOMAIN='$domain' sudo -E bash -s $dryRunArg".Trim() # ── Build plink argument list ───────────────────────────────────────────── $plinkArgs = @( "-ssh", "-pw", $Config.LinuxSSHPassword, "-o", "StrictHostKeyChecking=no", "-o", "BatchMode=no", "-batch", "$($Config.LinuxSSHUser)@$Hostname", $remoteCmd ) Write-Verbose "[$Hostname] Connecting via plink SSH..." # ── Execute via plink, piping the bash script through stdin ────────────── $rawOutput = "" $exitCode = 0 $errorOutput = "" try { # Use a temp file approach to pipe stdin reliably from PowerShell to plink # plink reads the script from stdin, executes it as bash $proc = New-Object System.Diagnostics.Process $proc.StartInfo.FileName = $Config.PlinkPath $proc.StartInfo.Arguments = ($plinkArgs | ForEach-Object { if ($_ -match '\s') { "`"$_`"" } else { $_ } }) -join " " $proc.StartInfo.UseShellExecute = $false $proc.StartInfo.RedirectStandardInput = $true $proc.StartInfo.RedirectStandardOutput = $true $proc.StartInfo.RedirectStandardError = $true $proc.StartInfo.CreateNoWindow = $true $null = $proc.Start() # Stream the bash script content to plink's stdin $scriptContent = Get-Content $bashScriptPath -Raw $proc.StandardInput.Write($scriptContent) $proc.StandardInput.Close() # Read output with timeout $timeoutMs = ($Config.SSHConnectTimeout + 120) * 1000 $rawOutput = $proc.StandardOutput.ReadToEnd() $errorOutput = $proc.StandardError.ReadToEnd() $proc.WaitForExit($timeoutMs) | Out-Null $exitCode = $proc.ExitCode } catch { $result.ChangeStatus = "ConnectionFailed" $result.ErrorMessage = "plink execution error: $($_.Exception.Message)" Write-Warning "[$Hostname] SSH connection error: $($_.Exception.Message)" return $result } # ── Handle connection failure ───────────────────────────────────────────── if ($exitCode -ne 0 -and [string]::IsNullOrWhiteSpace($rawOutput)) { $result.ChangeStatus = "ConnectionFailed" $result.ErrorMessage = "SSH failed (exit $exitCode). stderr: $($errorOutput.Trim())" Write-Warning "[$Hostname] SSH failed with exit code $exitCode" return $result } Write-Verbose "[$Hostname] SSH completed. Parsing output..." # ── Parse structured key=value output ──────────────────────────────────── $parsed = Parse-LinuxOutput -RawOutput $rawOutput # ── Populate result from parsed output ─────────────────────────────────── if ($parsed.ContainsKey("DISTRO")) { $result.Distro = $parsed["DISTRO"] } if ($parsed.ContainsKey("OLD_DNS")) { $result.OldDNS = $parsed["OLD_DNS"] } if ($parsed.ContainsKey("SERVICE_RESTARTED") -and $parsed["SERVICE_RESTARTED"]) { $result.ServiceRestarted = $parsed["SERVICE_RESTARTED"] } # ── Map STATUS to ChangeStatus ──────────────────────────────────────────── $statusRaw = $parsed["STATUS"] ?? "unknown" $result.ChangeStatus = switch ($statusRaw) { "success" { "Success" } "dryrun" { "DryRun" } "failed" { "Failed" } default { "Unknown" } } if ($parsed.ContainsKey("ERROR") -and $parsed["ERROR"]) { $result.ErrorMessage = $parsed["ERROR"] } elseif ($exitCode -ne 0 -and $result.ChangeStatus -eq "Unknown") { $result.ErrorMessage = "Exit code $exitCode. stderr: $($errorOutput.Trim())" $result.ChangeStatus = "Failed" } # ── Validation status ───────────────────────────────────────────────────── $validationRaw = $parsed["VALIDATION"] ?? "unknown" $result.ValidationStatus = switch -Wildcard ($validationRaw) { "success" { "Success" } "failed" { "ValidationFailed"} "dryrun_success" { "DryRun_OK" } "dryrun_failed" { "DryRun_NoResolve"} "dryrun_skipped" { "DryRun_Skipped" } "skipped" { "Skipped" } default { "Unknown" } } # ── Modified files list (for CSV column) ───────────────────────────────── $fileCount = [int]($parsed["FILE_COUNT"] ?? 0) $modifiedFilesList = [System.Collections.Generic.List[string]]::new() for ($i = 1; $i -le $fileCount; $i++) { $filePath = $parsed["MODIFIED_FILE_$i"] if ($filePath) { $modifiedFilesList.Add($filePath) } } $result.ModifiedFiles = $modifiedFilesList -join ";" # ── Build rollback data (Task 10 will consume this) ─────────────────────── $rollbackFiles = [System.Collections.Generic.List[hashtable]]::new() for ($i = 1; $i -le $fileCount; $i++) { $filePath = $parsed["MODIFIED_FILE_$i"] $fileBefore = $parsed["FILE_BEFORE_$i"] $fileAfter = $parsed["FILE_AFTER_$i"] if ($filePath) { $rollbackFiles.Add(@{ Path = $filePath ContentBefore = $fileBefore # base64 — decoded by rollback script ContentAfter = $fileAfter }) } } $result._RollbackData = @{ Hostname = $Hostname OS = "Linux" Distro = $result.Distro ConnectionMethod = "SSH" OldDNS = $parsed["OLD_DNS"] -split "," | ForEach-Object { $_.Trim() } NewDNS = $Config.NewDNS NICName = $parsed["NIC"] ?? ($parsed["NM_CONNECTION"] ?? "") ModifiedFiles = $rollbackFiles.ToArray() ServiceRestarted = $result.ServiceRestarted ResolvConfBefore = $parsed["RESOLV_CONF_BEFORE"] ResolvConfAfter = $parsed["RESOLV_CONF_AFTER"] Timestamp = $timestamp } # Log validation output for debugging if verbose if ($parsed.ContainsKey("VALIDATION_OUTPUT")) { try { $validationText = [System.Text.Encoding]::UTF8.GetString( [Convert]::FromBase64String($parsed["VALIDATION_OUTPUT"])) Write-Verbose "[$Hostname] Validation output: $validationText" } catch { } } Write-Verbose "[$Hostname] Status: $($result.ChangeStatus) | Validation: $($result.ValidationStatus)" return $result } function Parse-LinuxOutput { <# .SYNOPSIS Parses the key=value output from set-linux-dns.sh into a hashtable. .DESCRIPTION Each line from the bash script is in format KEY=VALUE. For base64-encoded values (FILE_BEFORE_N, FILE_AFTER_N, etc.), the raw base64 string is preserved — callers decode as needed. Lines that don't match the pattern are captured as diagnostic output. #> param([string]$RawOutput) $result = @{} $diagnostics = [System.Collections.Generic.List[string]]::new() foreach ($line in ($RawOutput -split "`n")) { $line = $line.TrimEnd("`r") if ([string]::IsNullOrWhiteSpace($line)) { continue } # Match KEY=VALUE — key is uppercase letters/digits/underscores if ($line -match '^([A-Z][A-Z0-9_]*)=(.*)$') { $key = $Matches[1] $value = $Matches[2] $result[$key] = $value } else { # Non-matching lines are SSH banner, sudo prompts, etc. — log as diagnostic $diagnostics.Add($line) Write-Verbose "SSH diagnostic: $line" } } if ($diagnostics.Count -gt 0) { $result["_DIAGNOSTICS"] = $diagnostics -join "|" } return $result } function Resolve-LinuxHostnameToIP { <# .SYNOPSIS Resolves a hostname to its first IPv4 address. Returns "Unresolvable" if DNS lookup fails. #> param([string]$Hostname) if ($Hostname -match '^\d{1,3}(\.\d{1,3}){3}$') { return $Hostname } try { $addresses = [System.Net.Dns]::GetHostAddresses($Hostname) | Where-Object { $_.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetwork } if ($addresses) { return ($addresses | Select-Object -First 1).IPAddressToString } return "Unresolvable" } catch { return "Unresolvable" } } function Invoke-LinuxDnsRollback { <# .SYNOPSIS Restores DNS settings on a Linux server from rollback data. .DESCRIPTION SSHes into the server, overwrites each modified file with its pre-change content (base64-decoded), then restarts the service. Used by Invoke-DnsRollback.ps1. .PARAMETER RollbackEntry Hashtable from the rollback JSON file for this server. .PARAMETER Config Hashtable from config.psd1. .PARAMETER DryRun If set, shows what would be restored without making changes. #> [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable]$RollbackEntry, [Parameter(Mandatory)] [hashtable]$Config, [switch]$DryRun ) $hostname = $RollbackEntry.Hostname $timestamp = Get-Date -Format "yyyy-MM-ddTHH:mm:ss" $result = [PSCustomObject]@{ Hostname = $hostname ResolvedIP = Resolve-LinuxHostnameToIP -Hostname $hostname OS = "Linux" Distro = $RollbackEntry.Distro OldDNS = $RollbackEntry.NewDNS -join ";" # NewDNS is what we're rolling back FROM NewDNS = $RollbackEntry.OldDNS -join ";" # OldDNS is what we're restoring TO ConnectionMethod = "SSH" ChangeStatus = "Pending" ValidationStatus = "NotRun" ModifiedFiles = ($RollbackEntry.ModifiedFiles | ForEach-Object { $_.Path }) -join ";" ServiceRestarted = $RollbackEntry.ServiceRestarted ErrorMessage = "" DryRun = $DryRun.IsPresent Timestamp = $timestamp } if (-not (Test-Path $Config.PlinkPath)) { $result.ChangeStatus = "Failed" $result.ErrorMessage = "plink.exe not found at: $($Config.PlinkPath)" return $result } # Build a rollback bash script dynamically $rollbackScript = Build-LinuxRollbackScript ` -RollbackEntry $RollbackEntry ` -DryRun $DryRun.IsPresent if ($DryRun) { # Show preview of what would be restored Write-Host "`n [$hostname] ROLLBACK DRY RUN" -ForegroundColor Yellow foreach ($file in $RollbackEntry.ModifiedFiles) { Write-Host " File: $($file.Path)" -ForegroundColor Cyan try { $preview = [System.Text.Encoding]::UTF8.GetString( [Convert]::FromBase64String($file.ContentBefore)) $lines = $preview -split "`n" | Select-Object -First 10 Write-Host " First 10 lines of restored content:" -ForegroundColor Gray $lines | ForEach-Object { Write-Host " $_" -ForegroundColor Gray } } catch { Write-Host " (could not preview content)" -ForegroundColor Gray } } Write-Host " Service to restart: $($RollbackEntry.ServiceRestarted)" -ForegroundColor Cyan $result.ChangeStatus = "DryRun" $result.ValidationStatus = "DryRun" return $result } # Execute rollback script via plink 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`" " + "`"sudo bash -s`"" $proc.StartInfo.UseShellExecute = $false $proc.StartInfo.RedirectStandardInput = $true $proc.StartInfo.RedirectStandardOutput = $true $proc.StartInfo.RedirectStandardError = $true $proc.StartInfo.CreateNoWindow = $true $null = $proc.Start() $proc.StandardInput.Write($rollbackScript) $proc.StandardInput.Close() $output = $proc.StandardOutput.ReadToEnd() $stderr = $proc.StandardError.ReadToEnd() $proc.WaitForExit(120000) | Out-Null if ($proc.ExitCode -eq 0) { $result.ChangeStatus = "Success" # Validate DNS points back to old DNS $oldDnsFirst = $RollbackEntry.OldDNS | Select-Object -First 1 $result.ValidationStatus = Test-LinuxDnsResolution ` -Hostname $hostname -DNS $oldDnsFirst -Domain $Config.Domain -Config $Config } else { $result.ChangeStatus = "Failed" $result.ErrorMessage = "Rollback exit $($proc.ExitCode). stderr: $($stderr.Trim())" } } catch { $result.ChangeStatus = "Failed" $result.ErrorMessage = $_.Exception.Message } return $result } function Build-LinuxRollbackScript { <# .SYNOPSIS Generates a bash script that restores files from rollback data. #> param( [hashtable]$RollbackEntry, [bool]$DryRun ) $lines = [System.Collections.Generic.List[string]]::new() $lines.Add("#!/usr/bin/env bash") $lines.Add("set -euo pipefail") foreach ($file in $RollbackEntry.ModifiedFiles) { $b64Content = $file.ContentBefore $filePath = $file.Path $lines.Add("# Restore $filePath") $lines.Add("echo '$b64Content' | base64 -d > '$filePath'") } # Restart the service if ($RollbackEntry.ServiceRestarted) { $services = $RollbackEntry.ServiceRestarted -split "," foreach ($svc in $services) { $svc = $svc.Trim() if ($svc -eq "netplan") { $lines.Add("netplan apply 2>&1 || true") } elseif ($svc -match "nmcli") { # For nmcli rollback — restart NetworkManager $lines.Add("systemctl restart NetworkManager 2>&1 || true") $lines.Add("sleep 2") } else { $lines.Add("systemctl restart $svc 2>&1 || true") $lines.Add("sleep 2") } } } $lines.Add("echo 'ROLLBACK_STATUS=success'") return $lines -join "`n" } function Test-LinuxDnsResolution { <# .SYNOPSIS Validates DNS resolution on a Linux server via a quick SSH nslookup check. #> param( [string]$Hostname, [string]$DNS, [string]$Domain, [hashtable]$Config ) $cmd = "nslookup $Domain $DNS 2>&1 && echo DNS_OK || echo DNS_FAIL" 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`" `"$cmd`"" $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(30000) | Out-Null if ($output -match "DNS_OK") { return "Success" } return "ValidationFailed" } catch { return "ValidationFailed" } }