############################################################################### # windows-dns-smoke-tests.ps1 - Verify Windows DNS Server health # # Checks DNS service status, zone loading, AD-integrated zone replication, # conditional forwarders, forward and reverse resolution, scavenging # configuration, root hints, forwarder connectivity, zone delegation, # cache statistics, and event log errors. # # Author: Phil Connor # Contact: contact@mylinux.work # License: MIT # Version 1.0 # # Usage: # .\windows-dns-smoke-tests.ps1 # .\windows-dns-smoke-tests.ps1 -DnsServer 10.0.0.53 -Domain corp.local # .\windows-dns-smoke-tests.ps1 -OutputFormat tap # .\windows-dns-smoke-tests.ps1 -Help ############################################################################### [CmdletBinding()] param( [string]$DnsServer = "localhost", [string]$Domain = $(if ($env:USERDNSDOMAIN) { $env:USERDNSDOMAIN } else { "" }), [string]$TestDomain = "example.com", [ValidateSet("text","tap")] [string]$OutputFormat = "text", [switch]$NoColor, [switch]$Help ) $ErrorActionPreference = "Continue" $script:Pass = 0; $script:Fail = 0; $script:Skip = 0; $script:Total = 0 $script:Results = @(); $script:StartTime = $null # ============================================================================ # COLORS # ============================================================================ function Write-Color { param([string]$Text, [string]$Color = "White") if ($NoColor) { Write-Host $Text } else { Write-Host $Text -ForegroundColor $Color } } # ============================================================================ # TEST RESULT RECORDING # ============================================================================ function Record-Pass { param([string]$Name, [string]$Detail = "") $script:Pass++; $script:Total++ $script:Results += [PSCustomObject]@{ Status="PASS"; Name=$Name; Detail=$Detail } if ($OutputFormat -eq "tap") { Write-Host "ok $($script:Total) - $Name" } else { $msg = " $(if($NoColor){'[PASS]'}else{[char]0x2713}) $Name" if ($Detail) { $msg += " - $Detail" } Write-Color $msg "Green" } } function Record-Fail { param([string]$Name, [string]$Detail = "") $script:Fail++; $script:Total++ $script:Results += [PSCustomObject]@{ Status="FAIL"; Name=$Name; Detail=$Detail } if ($OutputFormat -eq "tap") { Write-Host "not ok $($script:Total) - $Name" if ($Detail) { Write-Host " # $Detail" } } else { $msg = " $(if($NoColor){'[FAIL]'}else{[char]0x2717}) $Name" if ($Detail) { $msg += " - $Detail" } Write-Color $msg "Red" } } function Record-Skip { param([string]$Name, [string]$Reason = "") $script:Skip++; $script:Total++ $script:Results += [PSCustomObject]@{ Status="SKIP"; Name=$Name; Detail=$Reason } if ($OutputFormat -eq "tap") { Write-Host "ok $($script:Total) - $Name # SKIP $Reason" } else { $msg = " $(if($NoColor){'[SKIP]'}else{[char]0x2298}) $Name" if ($Reason) { $msg += " - $Reason" } Write-Color $msg "Yellow" } } # ============================================================================ # HELPERS # ============================================================================ function Get-DnsComputerParam { if ($DnsServer -eq "localhost" -or $DnsServer -eq "127.0.0.1" -or $DnsServer -eq "::1") { return @{} } return @{ ComputerName = $DnsServer } } function Write-Section { param([string]$Title) if ($OutputFormat -ne "tap") { Write-Host ""; Write-Color $Title "White" } } # ============================================================================ # TESTS # ============================================================================ function Test-DnsService { Write-Section "DNS Service" try { $params = @{ Name = "DNS"; ErrorAction = "Stop" } if ($DnsServer -ne "localhost" -and $DnsServer -ne "127.0.0.1" -and $DnsServer -ne "::1") { $params["ComputerName"] = $DnsServer } $svc = Get-Service @params if ($svc.Status -eq "Running") { Record-Pass "DNS service running" } else { Record-Fail "DNS service running" "status: $($svc.Status)" } } catch { Record-Fail "DNS service running" $_.Exception.Message } } function Test-DnsResponds { try { $result = Resolve-DnsName -Name $TestDomain -Server $DnsServer -Type A -DnsOnly -ErrorAction Stop if ($result) { Record-Pass "DNS server responds" "$TestDomain resolved" } else { Record-Fail "DNS server responds" "no result for $TestDomain" } } catch { Record-Fail "DNS server responds" $_.Exception.Message } } function Test-ZonesLoaded { $cp = Get-DnsComputerParam try { $zones = @(Get-DnsServerZone @cp -ErrorAction Stop | Where-Object { $_.ZoneType -ne "Forwarder" -and $_.ZoneName -ne "TrustAnchors" }) if ($zones.Count -gt 0) { Record-Pass "zones loaded" "$($zones.Count) zones" } else { Record-Fail "zones loaded" "no zones found" } } catch { Record-Fail "zones loaded" $_.Exception.Message } } function Test-ADIntegratedZones { if (-not $Domain) { Record-Skip "AD-integrated zones" "Domain not set"; return } $cp = Get-DnsComputerParam try { $zones = @(Get-DnsServerZone @cp -ErrorAction Stop | Where-Object { $_.IsAutoCreated -eq $false -and $_.IsDsIntegrated -eq $true }) if ($zones.Count -eq 0) { Record-Skip "AD-integrated zones" "none found"; return } foreach ($z in $zones) { $scope = if ($z.DirectoryPartitionName) { $z.DirectoryPartitionName } else { "legacy" } Record-Pass "AD-integrated zone ($($z.ZoneName))" "replication: $scope" } } catch { Record-Fail "AD-integrated zones" $_.Exception.Message } } function Test-ForwardLookup { Write-Section "DNS Resolution" if (-not $Domain) { Record-Skip "forward lookup" "Domain not set"; return } try { $result = Resolve-DnsName -Name $Domain -Server $DnsServer -Type A -DnsOnly -ErrorAction Stop if ($result) { $addrs = ($result | Where-Object { $_.QueryType -eq "A" } | Select-Object -ExpandProperty IPAddress) -join ", " if ($addrs) { Record-Pass "forward lookup ($Domain)" $addrs } else { Record-Pass "forward lookup ($Domain)" "resolved (non-A records)" } } else { Record-Fail "forward lookup ($Domain)" "no result" } } catch { Record-Fail "forward lookup ($Domain)" $_.Exception.Message } } function Test-ReverseLookup { $cp = Get-DnsComputerParam try { $rz = @(Get-DnsServerZone @cp -ErrorAction Stop | Where-Object { $_.IsReverseLookupZone -eq $true -and $_.ZoneType -ne "Forwarder" }) if ($rz.Count -eq 0) { Record-Skip "reverse lookup" "no reverse zones found"; return } $found = $false foreach ($zone in $rz) { try { $ptrs = Get-DnsServerResourceRecord -ZoneName $zone.ZoneName -RRType PTR @cp -ErrorAction Stop if ($ptrs) { $first = $ptrs | Select-Object -First 1 Record-Pass "reverse lookup" "PTR $($first.HostName) -> $($first.RecordData.PtrDomainName)" $found = $true; break } } catch { } } if (-not $found) { Record-Skip "reverse lookup" "no PTR records in reverse zones" } } catch { Record-Fail "reverse lookup" $_.Exception.Message } } function Test-ConditionalForwarders { Write-Section "Forwarders" $cp = Get-DnsComputerParam try { $fzones = @(Get-DnsServerZone @cp -ErrorAction Stop | Where-Object { $_.ZoneType -eq "Forwarder" }) if ($fzones.Count -eq 0) { Record-Skip "conditional forwarders" "none configured"; return } foreach ($fz in $fzones) { $targets = $fz.MasterServers if (-not $targets -or @($targets).Count -eq 0) { Record-Fail "conditional forwarder ($($fz.ZoneName))" "no targets"; continue } $ok = $false foreach ($t in $targets) { try { $r = Resolve-DnsName -Name $fz.ZoneName -Server $t.IPAddressToString -Type SOA -DnsOnly -ErrorAction Stop if ($r) { $ok = $true; break } } catch { } } if ($ok) { Record-Pass "conditional forwarder ($($fz.ZoneName))" "target reachable" } else { Record-Fail "conditional forwarder ($($fz.ZoneName))" "no targets responded" } } } catch { Record-Fail "conditional forwarders" $_.Exception.Message } } function Test-RootHints { $cp = Get-DnsComputerParam try { $hints = @(Get-DnsServerRootHint @cp -ErrorAction Stop) if ($hints.Count -eq 0) { Record-Skip "root hints" "none configured"; return } Record-Pass "root hints configured" "$($hints.Count) root servers" $tested = $false foreach ($h in $hints) { foreach ($ip in ($h.IPAddress | Where-Object { $_.RecordType -eq "A" })) { try { $addr = $ip.RecordData.IPv4Address.IPAddressToString $r = Resolve-DnsName -Name "." -Server $addr -Type NS -DnsOnly -ErrorAction Stop if ($r) { Record-Pass "root hint reachable" $addr; $tested = $true; break } } catch { } } if ($tested) { break } } if (-not $tested) { Record-Fail "root hint reachable" "no root servers responded" } } catch { Record-Fail "root hints" $_.Exception.Message } } function Test-Forwarders { $cp = Get-DnsComputerParam try { $fwd = Get-DnsServerForwarder @cp -ErrorAction Stop $ips = $fwd.IPAddress if (-not $ips -or @($ips).Count -eq 0) { Record-Skip "forwarder configuration" "no forwarders configured"; return } foreach ($ip in $ips) { $addr = $ip.IPAddressToString try { $r = Resolve-DnsName -Name $TestDomain -Server $addr -Type A -DnsOnly -ErrorAction Stop if ($r) { Record-Pass "forwarder responds ($addr)" } else { Record-Fail "forwarder responds ($addr)" "no response" } } catch { Record-Fail "forwarder responds ($addr)" $_.Exception.Message } } } catch { Record-Fail "forwarder configuration" $_.Exception.Message } } function Test-Scavenging { Write-Section "Server Configuration" $cp = Get-DnsComputerParam try { $scav = Get-DnsServerScavenging @cp -ErrorAction Stop if ($scav.ScavengingState -eq $true) { $detail = "interval: $($scav.ScavengingInterval)" $last = $scav.LastScavengeTime if ($last -and $last -ne [DateTime]::MinValue) { $days = [math]::Floor(((Get-Date) - $last).TotalDays) $detail += ", last run: $($last.ToString('yyyy-MM-dd')) (${days}d ago)" if ($days -gt 14) { Record-Fail "scavenging" "$detail — over 14 days ago"; return } } else { $detail += ", last run: never" } Record-Pass "scavenging enabled" $detail } else { Record-Pass "scavenging disabled" "not enabled on this server" } } catch { Record-Fail "scavenging" $_.Exception.Message } } function Test-ZoneDelegation { if (-not $Domain) { Record-Skip "zone delegation" "Domain not set"; return } $cp = Get-DnsComputerParam try { $ns = @(Get-DnsServerResourceRecord -ZoneName $Domain -RRType NS @cp -ErrorAction Stop | Where-Object { $_.HostName -ne "@" }) if ($ns.Count -eq 0) { Record-Skip "zone delegation" "no delegated subdomains"; return } foreach ($r in $ns) { Record-Pass "zone delegation ($($r.HostName))" "NS -> $($r.RecordData.NameServer)" } } catch { if ($_.Exception.Message -match "not found") { Record-Skip "zone delegation" "zone $Domain not hosted on this server" } else { Record-Fail "zone delegation" $_.Exception.Message } } } function Test-CacheStats { Write-Section "Statistics" $cp = Get-DnsComputerParam try { $stats = Get-DnsServerStatistics @cp -ErrorAction Stop $cs = $stats.CacheStatistics if ($cs) { $hits = $cs.CacheHits; $misses = $cs.CacheMisses; $total = $hits + $misses if ($total -gt 0) { $ratio = [math]::Round(($hits / $total) * 100, 1) Record-Pass "cache statistics" "hits: $hits, misses: $misses, hit ratio: ${ratio}%" } else { Record-Pass "cache statistics" "no queries processed yet" } } elseif ($stats.QueryStatistics) { Record-Pass "cache statistics" "total queries: $($stats.QueryStatistics.TotalQueries)" } else { Record-Skip "cache statistics" "not available" } } catch { Record-Fail "cache statistics" $_.Exception.Message } } function Test-EventLog { $cp = Get-DnsComputerParam try { $filter = @{ LogName = "DNS Server"; Level = 2; StartTime = (Get-Date).AddHours(-24) } if ($cp.ContainsKey("ComputerName")) { $events = @(Get-WinEvent -FilterHashtable $filter -ComputerName $cp.ComputerName -ErrorAction Stop) } else { $events = @(Get-WinEvent -FilterHashtable $filter -ErrorAction Stop) } if ($events.Count -gt 0) { $latest = $events[0] $msg = $latest.Message.Substring(0, [Math]::Min(80, $latest.Message.Length)) Record-Fail "event log" "$($events.Count) error(s) in last 24h — latest: $msg" } else { Record-Pass "event log clean" "0 errors in last 24h" } } catch { if ($_.Exception.Message -match "No events were found") { Record-Pass "event log clean" "0 errors in last 24h" } elseif ($_.Exception.Message -match "not found|does not exist") { Record-Skip "event log" "DNS Server event log not available" } else { Record-Fail "event log" $_.Exception.Message } } } # ============================================================================ # OUTPUT # ============================================================================ function Write-Summary { $duration = [math]::Floor(((Get-Date) - $script:StartTime).TotalSeconds) Write-Host "" $sep = [string]::new([char]0x2500, 40) Write-Color $sep "White" Write-Color "Summary $DnsServer" "White" Write-Host " $($script:Pass) passed $($script:Fail) failed $($script:Skip) skipped (${duration}s)" Write-Color $sep "White" if ($script:Fail -eq 0) { Write-Color "All tests passed." "Green" } else { Write-Color "$($script:Fail) test(s) failed." "Red" } } function Write-TapHeader { Write-Host "TAP version 13" } function Write-TapFooter { Write-Host "1..$($script:Total)" Write-Host "# pass $($script:Pass)" Write-Host "# fail $($script:Fail)" Write-Host "# skip $($script:Skip)" } # ============================================================================ # HELP # ============================================================================ function Show-Usage { @" Usage: .\windows-dns-smoke-tests.ps1 [OPTIONS] Smoke-test a Windows DNS Server. PowerShell 5.1+, DnsServer module only. Parameters: -DnsServer SERVER DNS server to test (default: localhost) -Domain DOMAIN AD domain name (default: auto-detect from environment) -TestDomain DOMAIN Domain for resolution tests (default: example.com) -OutputFormat FMT Output format: text (default), tap -NoColor Disable colored output -Help Show this help message Examples: .\windows-dns-smoke-tests.ps1 .\windows-dns-smoke-tests.ps1 -DnsServer 10.0.0.53 -Domain corp.local .\windows-dns-smoke-tests.ps1 -OutputFormat tap .\windows-dns-smoke-tests.ps1 -NoColor | Out-File dns-results.txt Requirements: - PowerShell 5.1+ or PowerShell 7+ - DnsServer PowerShell module (DNS Server role or RSAT) - Administrator privileges for statistics and event log access "@ } # ============================================================================ # MAIN # ============================================================================ if ($Help) { Show-Usage; exit 0 } $script:StartTime = Get-Date if ($OutputFormat -eq "tap") { Write-TapHeader } else { Write-Host "" Write-Color "Windows DNS Smoke Tests" "White" Write-Host "Server: $DnsServer" if ($Domain) { Write-Host "Domain: $Domain" } Write-Host "Time: $(Get-Date -Format 'yyyy-MM-ddTHH:mm:ssZ')" } try { Import-Module DnsServer -ErrorAction Stop } catch { Write-Color "[ERROR] DnsServer module not available. Install the DNS Server role or RSAT DNS tools." "Red" exit 1 } Test-DnsService Test-DnsResponds Test-ZonesLoaded Test-ADIntegratedZones Test-ForwardLookup Test-ReverseLookup Test-ConditionalForwarders Test-RootHints Test-Forwarders Test-Scavenging Test-ZoneDelegation Test-CacheStats Test-EventLog if ($OutputFormat -eq "tap") { Write-TapFooter } else { Write-Summary } if ($script:Fail -eq 0) { exit 0 } else { exit 1 }