initial commit

This commit is contained in:
2026-09-06 02:03:36 +01:00
commit 57d2f3619e
42 changed files with 3379 additions and 0 deletions

452
modules/Set-WindowsDns.ps1 Normal file
View File

@@ -0,0 +1,452 @@
# ============================================================
# Set-WindowsDns.ps1
# ============================================================
# Remotely changes DNS settings on a Windows server.
# Connection order: WinRM (Invoke-Command) -> PSSession -> PsExec
# Includes DNS validation after the change.
# ============================================================
function Set-WindowsDns {
<#
.SYNOPSIS
Changes DNS server addresses on a remote Windows machine.
.DESCRIPTION
Attempts connection via WinRM, then PSSession, then PsExec.
In DryRun mode, reads and reports current DNS without making changes.
Returns a structured result object compatible with Write-MigrationReport.
.PARAMETER Hostname
The hostname or FQDN of the target server.
.PARAMETER NewDNS
Array of new DNS server IP addresses to apply.
.PARAMETER Config
Hashtable loaded from config.psd1 containing connection settings.
.PARAMETER Credential
Optional PSCredential for Windows remote connections. If not provided,
implicit authentication (current logged-in user / Kerberos) is used.
.PARAMETER DryRun
If set, no changes are made. Returns current DNS state and what would change.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Hostname,
[Parameter(Mandatory)]
[string[]]$NewDNS,
[Parameter(Mandatory)]
[hashtable]$Config,
[System.Management.Automation.PSCredential]
[System.Management.Automation.Credential()]
$Credential = [System.Management.Automation.PSCredential]::Empty,
[switch]$DryRun
)
$timestamp = Get-Date -Format "yyyy-MM-ddTHH:mm:ss"
$resolvedIP = Resolve-HostnameToIP -Hostname $Hostname
# Base result object — populated throughout the function
$result = [PSCustomObject]@{
Hostname = $Hostname
ResolvedIP = $resolvedIP
OS = "Windows"
Distro = ""
OldDNS = ""
NewDNS = $NewDNS -join ";"
ConnectionMethod = ""
ChangeStatus = "Pending"
ValidationStatus = "NotRun"
ModifiedFiles = "N/A"
ServiceRestarted = "N/A"
ErrorMessage = ""
DryRun = $DryRun.IsPresent
Timestamp = $timestamp
}
# Cannot proceed without a resolvable hostname
if ($resolvedIP -eq "Unresolvable") {
$result.ChangeStatus = "Skipped"
$result.ValidationStatus = "Skipped"
$result.ErrorMessage = "Hostname could not be resolved to an IP address"
Write-Warning "[$Hostname] Cannot resolve hostname — skipping"
return $result
}
# ── Remote scriptblock ───────────────────────────────────────────────────
# This block runs inside the remote session (WinRM or PSSession).
# Returns a hashtable that is serialized back to the caller.
$remoteScriptBlock = {
param([string[]]$NewDNS, [bool]$DryRun, [string]$Domain)
$output = @{
OldDNS = @()
NICName = ""
OSCaption = ""
ChangeStatus = "Pending"
ErrorMessage = ""
}
try {
# Get OS caption for the result
$os = (Get-WmiObject -Class Win32_OperatingSystem -ErrorAction Stop).Caption
$output.OSCaption = $os
# Find the NIC that has a default gateway (primary NIC)
$defaultRoute = Get-NetRoute -DestinationPrefix "0.0.0.0/0" -ErrorAction SilentlyContinue |
Sort-Object RouteMetric |
Select-Object -First 1
if ($null -eq $defaultRoute) {
# Fallback: pick the first connected adapter
$nic = Get-NetAdapter -Physical | Where-Object { $_.Status -eq "Up" } | Select-Object -First 1
}
else {
$nic = Get-NetAdapter -InterfaceIndex $defaultRoute.InterfaceIndex -ErrorAction SilentlyContinue
}
if ($null -eq $nic) {
$output.ChangeStatus = "Failed"
$output.ErrorMessage = "No active network adapter found"
return $output
}
$output.NICName = $nic.Name
# Capture current DNS settings
$currentDNS = Get-DnsClientServerAddress -InterfaceAlias $nic.Name -AddressFamily IPv4 -ErrorAction Stop
$output.OldDNS = $currentDNS.ServerAddresses
if ($DryRun) {
$output.ChangeStatus = "DryRun"
return $output
}
# Apply new DNS
Set-DnsClientServerAddress -InterfaceAlias $nic.Name -ServerAddresses $NewDNS -ErrorAction Stop
# Flush DNS cache
Clear-DnsClientCache -ErrorAction SilentlyContinue
$output.ChangeStatus = "Success"
}
catch {
$output.ChangeStatus = "Failed"
$output.ErrorMessage = $_.Exception.Message
}
return $output
}
# ── PsExec scriptblock ───────────────────────────────────────────────────
# Used when WinRM and PSSession both fail.
# Builds a one-liner PowerShell command to run via PsExec.
$psexecScript = {
param([string]$Hostname, [string[]]$NewDNS, [bool]$DryRun, [string]$PsExecPath, [string[]]$PsExecCredArgs)
$newDnsJoined = '"{0}"' -f ($NewDNS -join '","')
$dnsArray = "@($newDnsJoined)"
if ($DryRun) {
$cmd = 'Get-DnsClientServerAddress -AddressFamily IPv4 | Select-Object -ExpandProperty ServerAddresses | ConvertTo-Json -Compress'
}
else {
$cmd = @"
`$nic = (Get-NetRoute -DestinationPrefix '0.0.0.0/0' | Sort-Object RouteMetric | Select-Object -First 1 | ForEach-Object { Get-NetAdapter -InterfaceIndex `$_.InterfaceIndex }).Name;
if (-not `$nic) { `$nic = (Get-NetAdapter -Physical | Where-Object Status -eq Up | Select-Object -First 1).Name };
`$old = (Get-DnsClientServerAddress -InterfaceAlias `$nic -AddressFamily IPv4).ServerAddresses -join ',';
Set-DnsClientServerAddress -InterfaceAlias `$nic -ServerAddresses $dnsArray;
Clear-DnsClientCache;
Write-Output "OLD=`$old|NIC=`$nic|STATUS=success"
"@
}
$encodedCmd = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($cmd))
$psexecArgs = @("\\$Hostname", "-accepteula", "-nobanner", "-h") +
$PsExecCredArgs +
@("powershell.exe", "-NonInteractive", "-EncodedCommand", $encodedCmd)
try {
$psexecOutput = & $PsExecPath @psexecArgs 2>&1
return $psexecOutput -join "`n"
}
catch {
return "PSEXEC_ERROR: $($_.Exception.Message)"
}
}
# ── Attempt WinRM connection ─────────────────────────────────────────────
$remoteResult = $null
$connectionMethod = ""
# Build credential splatting — only added to params when credential is provided
$credParams = @{}
$useCredential = ($Credential -ne [System.Management.Automation.PSCredential]::Empty -and $null -ne $Credential)
if ($useCredential) {
$credParams.Credential = $Credential
Write-Verbose "[$Hostname] Using explicit credentials: $($Credential.UserName)"
}
else {
Write-Verbose "[$Hostname] Using implicit authentication (current user)"
}
Write-Verbose "[$Hostname] Attempting WinRM connection..."
try {
$invokeParams = @{
ComputerName = $Hostname
ScriptBlock = $remoteScriptBlock
ArgumentList = $NewDNS, $DryRun.IsPresent, $Config.Domain
ErrorAction = "Stop"
}
if ($Config.WinRMTimeout) {
$sessionOpt = New-PSSessionOption -OpenTimeout ($Config.WinRMTimeout * 1000) -OperationTimeout ($Config.WinRMTimeout * 1000)
$invokeParams.SessionOption = $sessionOpt
}
if ($useCredential) { $invokeParams.Credential = $Credential }
$remoteResult = Invoke-Command @invokeParams
$connectionMethod = "WinRM"
Write-Verbose "[$Hostname] WinRM connection successful"
}
catch {
Write-Warning "[$Hostname] WinRM failed: $($_.Exception.Message)"
# ── Fallback: PSSession ──────────────────────────────────────────────
Write-Verbose "[$Hostname] Attempting PSSession fallback..."
try {
$sessionOpt = New-PSSessionOption -OpenTimeout ($Config.WinRMTimeout * 1000) -OperationTimeout ($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 $remoteScriptBlock `
-ArgumentList $NewDNS, $DryRun.IsPresent, $Config.Domain
Remove-PSSession $session -ErrorAction SilentlyContinue
$connectionMethod = "PSSession"
Write-Verbose "[$Hostname] PSSession connection successful"
}
catch {
Write-Warning "[$Hostname] PSSession failed: $($_.Exception.Message)"
# ── Fallback: PsExec ─────────────────────────────────────────────
Write-Verbose "[$Hostname] Attempting PsExec fallback..."
if (-not (Test-Path $Config.PsExecPath)) {
$result.ChangeStatus = "Failed"
$result.ErrorMessage = "All connection methods failed. PsExec not found at: $($Config.PsExecPath)"
$result.ConnectionMethod = "None"
Write-Warning "[$Hostname] PsExec not found at $($Config.PsExecPath)"
return $result
}
try {
# Build PsExec credential args — only added when explicit credentials provided
$psexecCredArgs = @()
if ($useCredential) {
$psexecCredArgs = @(
"-u", $Credential.UserName,
"-p", $Credential.GetNetworkCredential().Password
)
}
$psexecOutput = & $psexecScript -Hostname $Hostname -NewDNS $NewDNS `
-DryRun $DryRun.IsPresent -PsExecPath $Config.PsExecPath `
-PsExecCredArgs $psexecCredArgs
if ($psexecOutput -match "PSEXEC_ERROR:") {
throw $psexecOutput
}
# Parse PsExec output
$connectionMethod = "PsExec"
$remoteResult = @{ ChangeStatus = "Pending"; OldDNS = @(); NICName = ""; OSCaption = ""; ErrorMessage = "" }
if ($DryRun) {
try {
$dnsArray = $psexecOutput | ConvertFrom-Json
$remoteResult.OldDNS = $dnsArray
$remoteResult.ChangeStatus = "DryRun"
}
catch {
$remoteResult.OldDNS = @($psexecOutput.Trim())
$remoteResult.ChangeStatus = "DryRun"
}
}
else {
if ($psexecOutput -match "STATUS=success") {
$remoteResult.ChangeStatus = "Success"
if ($psexecOutput -match "OLD=([^|]+)") { $remoteResult.OldDNS = $Matches[1] -split "," }
if ($psexecOutput -match "NIC=([^|]+)") { $remoteResult.NICName = $Matches[1] }
}
else {
$remoteResult.ChangeStatus = "Failed"
$remoteResult.ErrorMessage = "PsExec output: $psexecOutput"
}
}
Write-Verbose "[$Hostname] PsExec connection successful"
}
catch {
$result.ChangeStatus = "Failed"
$result.ConnectionMethod = "None"
$result.ErrorMessage = "All connection methods failed. Last error: $($_.Exception.Message)"
Write-Warning "[$Hostname] All connection methods failed"
return $result
}
}
}
# ── Populate result from remote output ───────────────────────────────────
$result.ConnectionMethod = $connectionMethod
$result.OldDNS = if ($remoteResult.OldDNS) { $remoteResult.OldDNS -join ";" } else { "Unknown" }
$result.ChangeStatus = $remoteResult.ChangeStatus
$result.ErrorMessage = $remoteResult.ErrorMessage
# Populate OS/Distro from remote caption if available
if ($remoteResult.NICName) {
$result.ModifiedFiles = "NIC:$($remoteResult.NICName)"
}
if ($remoteResult.OSCaption) {
$result.Distro = $remoteResult.OSCaption
}
# ── Validate DNS resolution (Task 3) ─────────────────────────────────────
if ($result.ChangeStatus -in @("Success", "DryRun")) {
$result.ValidationStatus = Test-WindowsDnsValidation `
-Hostname $Hostname `
-NewDNS $NewDNS[0] `
-Domain $Config.Domain `
-ConnectionMethod $connectionMethod `
-Config $Config `
-Credential $Credential `
-DryRun: $DryRun
}
return $result
}
function Test-WindowsDnsValidation {
<#
.SYNOPSIS
Validates that a remote Windows server can resolve the domain via the new DNS IP.
.DESCRIPTION
Runs Resolve-DnsName against the new DNS server from the remote machine.
In DryRun mode, tests resolution against current DNS (non-destructive).
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string]$Hostname,
[Parameter(Mandatory)] [string]$NewDNS,
[Parameter(Mandatory)] [string]$Domain,
[Parameter(Mandatory)] [string]$ConnectionMethod,
[Parameter(Mandatory)] [hashtable]$Config,
[System.Management.Automation.PSCredential]
[System.Management.Automation.Credential()]
$Credential = [System.Management.Automation.PSCredential]::Empty,
[switch]$DryRun
)
$dnsToTest = $NewDNS
$useCredential = ($Credential -ne [System.Management.Automation.PSCredential]::Empty -and $null -ne $Credential)
Write-Verbose "[$Hostname] Validating DNS resolution of '$Domain' via $dnsToTest..."
$validationBlock = {
param([string]$Domain, [string]$DnsServer)
try {
$result = Resolve-DnsName -Name $Domain -Server $DnsServer -Type A -ErrorAction Stop
if ($result) {
return "Success|Resolved $Domain to: $(($result | Where-Object { $_.Type -eq 'A' } | Select-Object -ExpandProperty IPAddress) -join ', ')"
}
return "ValidationFailed|No A records returned for $Domain"
}
catch {
return "ValidationFailed|$($_.Exception.Message)"
}
}
$validationOutput = $null
try {
switch ($ConnectionMethod) {
"WinRM" {
$sessionOpt = New-PSSessionOption -OpenTimeout ($Config.WinRMTimeout * 1000)
$invokeParams = @{
ComputerName = $Hostname
ScriptBlock = $validationBlock
ArgumentList = $Domain, $dnsToTest
SessionOption = $sessionOpt
ErrorAction = "Stop"
}
if ($useCredential) { $invokeParams.Credential = $Credential }
$validationOutput = Invoke-Command @invokeParams
}
"PSSession" {
$sessionOpt = New-PSSessionOption -OpenTimeout ($Config.WinRMTimeout * 1000)
$sessionParams = @{ ComputerName = $Hostname; SessionOption = $sessionOpt; ErrorAction = "Stop" }
if ($useCredential) { $sessionParams.Credential = $Credential }
$session = New-PSSession @sessionParams
$validationOutput = Invoke-Command -Session $session -ScriptBlock $validationBlock `
-ArgumentList $Domain, $dnsToTest
Remove-PSSession $session -ErrorAction SilentlyContinue
}
"PsExec" {
# Run nslookup via PsExec as Resolve-DnsName may not be available on older OS
$cmd = "nslookup $Domain $dnsToTest"
$encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($cmd))
$credArgs = @()
if ($useCredential) {
$credArgs = @("-u", $Credential.UserName, "-p", $Credential.GetNetworkCredential().Password)
}
$psexecArgs = @("\\$Hostname", "-accepteula", "-nobanner", "-h") +
$credArgs +
@("powershell.exe", "-NonInteractive", "-EncodedCommand", $encoded)
$output = & $Config.PsExecPath @psexecArgs 2>&1
if ($LASTEXITCODE -eq 0 -and $output -match "Address") {
$validationOutput = "Success|nslookup succeeded: $($output -join ' ')"
}
else {
$validationOutput = "ValidationFailed|nslookup output: $($output -join ' ')"
}
}
}
}
catch {
return "ValidationFailed|Validation connection error: $($_.Exception.Message)"
}
if ($null -eq $validationOutput) {
return "ValidationFailed|No output from validation check"
}
$parts = $validationOutput -split "\|", 2
Write-Verbose "[$Hostname] Validation result: $($parts[0])"
return $parts[0]
}
function Resolve-HostnameToIP {
<#
.SYNOPSIS
Resolves a hostname to its first IPv4 address. Returns "Unresolvable" on failure.
#>
param([string]$Hostname)
# If already an IP, return as-is
if ($Hostname -match '^\d{1,3}(\.\d{1,3}){3}$') { return $Hostname }
try {
$addresses = [System.Net.Dns]::GetHostAddresses($Hostname) |
Where-Object { $_.AddressFamily -eq 'InterNetwork' }
if ($addresses) {
return ($addresses | Select-Object -First 1).IPAddressToString
}
return "Unresolvable"
}
catch {
return "Unresolvable"
}
}