Files
demo/modules/Invoke-LinuxDns.ps1
2026-09-06 02:03:36 +01:00

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"
}
}