527 lines
20 KiB
PowerShell
527 lines
20 KiB
PowerShell
# ============================================================
|
|
# 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"
|
|
}
|
|
}
|