Public/Get-ADForestHealth.ps1
|
function Get-ADForestHealth { <# .SYNOPSIS Generates an HTML health report for all domain controllers across an Active Directory forest. .DESCRIPTION Discovers all domains in the forest and, for each domain controller, collects the following by targeting it directly from the machine running this script (Dcdiag's /s: switch and WMI's -ComputerName both work remotely on their own): - DCDiag tests: Connectivity, DFSREvent, KccEvent, FSMO, NetLogons, Replication - OS drive free space (GB) - CPU usage (%) - Memory usage (%) - Uptime (days) Read-only. Uses WMI/DCOM and Dcdiag.exe remote targeting only - no PSRemoting/WinRM is used or required, since WinRM is assumed blocked in this environment. Outputs a colour-coded HTML report to a central network share and optionally sends it by email. Each domain is collected in isolation so one unreachable domain does not prevent the report from covering the rest. .PARAMETER OutputFolder Folder where the HTML report file is written. Defaults to $env:TEMP. .EXAMPLE Get-ADForestHealth Runs the health check and writes the HTML report to $env:TEMP. .EXAMPLE Get-ADForestHealth -OutputFolder "\\ServerName\C$\Scripts\HealthCheck\Reports" Runs the health check and writes to a network share. .NOTES Author: K Shankar R Karanth Website: https://karanth.ovh Version: 9.0 Created: 26-02-2026 Run as Domain Admin or equivalent with WMI/DCOM access to all domain controllers. WinRM/PSRemoting is not used. To enable email reporting, uncomment the Send-MailMessage block at the bottom and update $smtpsettings. #> [CmdletBinding()] param( [string]$OutputFolder = 'C:\ADOpsKit\Reports\Get-ADForestHealth' ) # ============ CONFIGURATION ============ Set-StrictMode -Version Latest $now = Get-Date $reportTime = $now $allDomains = (Get-ADForest).Domains # ============ EMAIL CONFIGURATION ============ # Update these values before enabling email reporting # $smtpSettings = @{ # To = 'recipient@example.com' # From = 'sender@example.com' # Subject = "$reportEmailSubject - $date" # SmtpServer = 'smtp.example.com' # Port = 25 # } # ============ OUTPUT CONFIGURATION ============ $forestName = (Get-ADForest).Name $safeForest = $forestName -replace '[\\/:*?"<>| ]', '_' $outFile = Join-Path $OutputFolder ("$(Get-Date -Format 'yyyy-MM-dd')_ADHealth_{0}.html" -f $safeForest) # ========== DATA COLLECTION SCRIPTBLOCK ========== # Invoked locally per domain. Every remote touchpoint inside # (Dcdiag /s:, Get-WmiObject -ComputerName) targets DCs directly # and does not require WinRM/PSRemoting. $domainHealthScriptBlock = { param([string]$DomainName) Import-Module ActiveDirectory -ErrorAction Stop function Get-AllDomainControllers { param($ComputerName) Get-ADDomainController -Filter * -Server $ComputerName | Sort-Object HostName } function Get-DCUptimeDays { param($ComputerName) if (-not (Test-Connection $ComputerName -Count 1 -Quiet)) { return 'Fail' } try { $os = Get-WmiObject Win32_OperatingSystem -ComputerName $ComputerName -ErrorAction Stop $lastBoot = $os.ConvertToDateTime($os.LastBootUpTime) return (New-TimeSpan -Start $lastBoot -End (Get-Date)).Days } catch { return 'WMI Failure' } } function Get-DCDiagResults { param($ComputerName) $results = [PSCustomObject]@{ ServerName = $ComputerName Connectivity = $null DFSREvent = $null KccEvent = $null KnowsOfRoleHolders = $null NetLogons = $null ObjectsReplicated = $null } if (-not (Test-Connection $ComputerName -Count 1 -Quiet)) { foreach ($prop in $results.PSObject.Properties.Name) { if ($prop -ne 'ServerName') { $results.$prop = 'Failed' } } return $results } $params = @( "/s:$ComputerName", '/test:Connectivity', '/test:DFSREvent', '/test:KccEvent', '/test:KnowsOfRoleHolders', '/test:NetLogons', '/test:ObjectsReplicated' ) $dcdiagOutput = (Dcdiag.exe @params) -split '[\r\n]' $testName = $null $testStatus = $null foreach ($line in $dcdiagOutput) { if ($line -match 'Starting test:') { $testName = ($line -replace '.*Starting test:').Trim() } if ($line -match 'passed test|failed test') { $testStatus = if ($line -match 'passed test') { 'Passed' } else { 'Failed' } } if ($testName -and $testStatus) { if ($results.PSObject.Properties.Name -contains $testName) { $results.$testName = $testStatus } $testName = $null $testStatus = $null } } return $results } function Get-DCOSDriveFreeSpaceGB { param($ComputerName) if (-not (Test-Connection $ComputerName -Count 1 -Quiet)) { return 'Fail' } try { $os = Get-WmiObject Win32_OperatingSystem -ComputerName $ComputerName -ErrorAction Stop $drive = Get-WmiObject Win32_LogicalDisk -ComputerName $ComputerName ` -Filter "DeviceID='$($os.SystemDrive)'" -ErrorAction Stop return [math]::Round($drive.FreeSpace / 1GB, 2) } catch { return 'WMI Failure' } } function Get-DCCPUUsage { param($ComputerName) if (-not (Test-Connection $ComputerName -Count 1 -Quiet)) { return 'Fail' } try { $avg = Get-WmiObject Win32_Processor -ComputerName $ComputerName -ErrorAction Stop | Measure-Object -Property LoadPercentage -Average | Select-Object -ExpandProperty Average return [math]::Round($avg, 2) } catch { return 'WMI Failure' } } function Get-DCMemoryUsage { param($ComputerName) if (-not (Test-Connection $ComputerName -Count 1 -Quiet)) { return 'Fail' } try { $os = Get-WmiObject Win32_OperatingSystem -ComputerName $ComputerName -ErrorAction Stop $used = $os.TotalVisibleMemorySize - $os.FreePhysicalMemory return [math]::Round(($used / $os.TotalVisibleMemorySize) * 100, 2) } catch { return 'WMI Failure' } } $results = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($dc in (Get-AllDomainControllers $DomainName)) { $diag = Get-DCDiagResults $dc.HostName $results.Add([PSCustomObject]@{ Server = ($dc.HostName.Split('.')[0]).ToUpper() Site = $dc.Site 'DCDIAG: Connectivity' = $diag.Connectivity 'DCDIAG: DFSREvent' = $diag.DFSREvent 'DCDIAG: KccEvent' = $diag.KccEvent 'DCDIAG: FSMO' = $diag.KnowsOfRoleHolders 'DCDIAG: NetLogons' = $diag.NetLogons 'Replication' = $diag.ObjectsReplicated 'OS Free Space (GB)' = Get-DCOSDriveFreeSpaceGB $dc.HostName 'CPU Usage (%)' = Get-DCCPUUsage $dc.HostName 'Memory Usage (%)' = Get-DCMemoryUsage $dc.HostName 'Uptime (days)' = Get-DCUptimeDays $dc.HostName }) } return $results } # ========== COLLECT DATA FROM ALL DOMAINS ========== # Each domain is isolated in its own try/catch so one unreachable # domain does not abort collection for the rest of the forest. $perDomainResults = @{} $failedDomains = [System.Collections.Generic.List[string]]::new() foreach ($domain in $allDomains) { Write-Host "Collecting health data from domain '$domain'..." try { $perDomainResults[$domain] = & $domainHealthScriptBlock -DomainName $domain } catch { Write-Warning "Failed to collect health data for domain '$domain': $($_.Exception.Message)" $perDomainResults[$domain] = @() $failedDomains.Add($domain) } } # ========== HTML HELPER FUNCTION ========== function New-StatusCell { param( $Value, [string]$Width = '70px' ) $style = "height:25px;width:$Width;border:1px solid #000;padding:6px;text-align:center;" $color = switch ($Value) { { $_ -in 'Success','Passed','Pass' } { 'background-color:#6BBF59;color:#000;' } 'Warn' { 'background-color:#FFD966;color:#000;' } { $_ -in 'Fail','Failed' } { 'background-color:#D9534F;color:#fff;' } default { '' } } return "<td style='$style$color'>$(ConvertTo-ADOKXmlEscaped ([string]$Value))</td>" } function New-MetricCell { param($Value, [double]$WarnThreshold, [double]$DangerThreshold, [string]$Width = '70px') $style = "height:25px;width:$Width;border:1px solid #000;padding:6px;text-align:center;" if ($Value -is [double] -or $Value -is [int]) { $color = if ($Value -le $WarnThreshold) { 'background-color:#6BBF59;color:#000;' } elseif ($Value -le $DangerThreshold) { 'background-color:#FFD966;color:#000;' } else { 'background-color:#D9534F;color:#fff;' } return "<td style='$style$color'>$Value</td>" } return "<td style='${style}background-color:#D9534F;color:#fff;'>$(ConvertTo-ADOKXmlEscaped ([string]$Value))</td>" } # ========== BUILD HTML REPORT ========== $htmlHead = @" <html> <body style='font-family:Segoe UI,Tahoma,Geneva,Verdana,sans-serif;font-size:10pt;'> <h1 style='font-size:20px;'>Domain Controller Health Check Report</h1> <h3 style='font-size:14px;'>Generated: $reportTime</h3> "@ $tableHeader = @" <table border='1' cellpadding='0' cellspacing='0' style='width:1300px;border-collapse:collapse;font-size:10pt;table-layout:fixed;'> <tr style='background-color:#f2f2f2;'> <th style='width:120px;'>Server</th> <th style='width:110px;'>Site</th> <th style='width:70px;'>Connectivity</th> <th style='width:70px;'>DFSREvent</th> <th style='width:70px;'>KccEvent</th> <th style='width:70px;'>FSMO</th> <th style='width:70px;'>NetLogons</th> <th style='width:70px;'>Replication</th> <th style='width:70px;'>OS Free Space (GB)</th> <th style='width:70px;'>CPU Usage (%)</th> <th style='width:70px;'>Memory Usage (%)</th> <th style='width:70px;'>Uptime (days)</th> </tr> "@ $explanationTable = @" <h3 style='color:#0056b3;margin-top:30px;'>Column Reference</h3> <table border='1' cellpadding='4' cellspacing='0' style='border-collapse:collapse;width:50%;font-size:12px;'> <thead><tr style='background-color:#f2f2f2;'> <th>Field</th><th>Description</th> </tr></thead> <tbody> <tr><td>Connectivity</td><td>Checks basic connectivity between DCs.</td></tr> <tr><td>DFSREvent</td><td>Checks DFS Replication health for SYSVOL.</td></tr> <tr><td>KccEvent</td><td>Checks KCC event log for replication topology errors.</td></tr> <tr><td>FSMO</td><td>Confirms the DC knows all FSMO role holders.</td></tr> <tr><td>NetLogons</td><td>Validates the secure channel via Netlogon.</td></tr> <tr><td>Replication</td><td>Confirms AD objects replicate correctly.</td></tr> <tr><td>OS Free Space (GB)</td><td>Available disk space on the system drive.</td></tr> <tr><td>CPU Usage (%)</td><td>Current CPU utilisation. Warn >75%, Fail >90%.</td></tr> <tr><td>Memory Usage (%)</td><td>Current RAM utilisation. Warn >75%, Fail >90%.</td></tr> <tr><td>Uptime (days)</td><td>Days since last reboot. Warn >30 days, Fail >45 days.</td></tr> </tbody> </table> "@ $htmlTail = @" <p style='font-size:11px;color:#555;margin-top:20px;'> Report generated by Get-ADForestHealth — karanth.ovh </p> </body></html> "@ # --------- ASSEMBLE PER-DOMAIN TABLES --------- $allDomainTables = foreach ($domain in $allDomains) { $table = "<h2 style='color:#174ea6;'>Domain: $(ConvertTo-ADOKXmlEscaped $domain)</h2>" if ($domain -in $failedDomains) { $table += "<p style='color:#D9534F;font-weight:bold;'>Collection failed for this domain - see console/log output. No data available.</p>" $table continue } $table += $tableHeader foreach ($dc in $perDomainResults[$domain]) { $row = '<tr>' $row += "<td style='text-align:center;'><b>$(ConvertTo-ADOKXmlEscaped $dc.Server)</b></td>" $row += "<td style='text-align:center;'>$(ConvertTo-ADOKXmlEscaped $dc.Site)</td>" $row += New-StatusCell $dc.'DCDIAG: Connectivity' $row += New-StatusCell $dc.'DCDIAG: DFSREvent' $row += New-StatusCell $dc.'DCDIAG: KccEvent' $row += New-StatusCell $dc.'DCDIAG: FSMO' $row += New-StatusCell $dc.'DCDIAG: NetLogons' $row += New-StatusCell $dc.'Replication' $row += New-MetricCell $dc.'OS Free Space (GB)' -WarnThreshold 40 -DangerThreshold 20 $row += New-MetricCell $dc.'CPU Usage (%)' -WarnThreshold 75 -DangerThreshold 90 $row += New-MetricCell $dc.'Memory Usage (%)' -WarnThreshold 75 -DangerThreshold 90 $row += New-MetricCell $dc.'Uptime (days)' -WarnThreshold 30 -DangerThreshold 45 $row += '</tr>' $table += $row } $table + '</table>' } $htmlBody = $htmlHead + ($allDomainTables -join '<br/><br/>') + $explanationTable + $htmlTail # ========== OUTPUT ========== if (-not (Test-Path -LiteralPath $OutputFolder)) { New-Item -Path $OutputFolder -ItemType Directory -Force | Out-Null } $htmlBody | Out-File -LiteralPath $outFile -Encoding UTF8 Write-Host "Report written to: $outFile" -ForegroundColor Green # ========== EMAIL (uncomment to enable) ========== # Send-MailMessage @smtpSettings -Body $htmlBody -BodyAsHtml ` # -Encoding ([System.Text.Encoding]::UTF8) -ErrorAction Stop # Write-Host "Email sent successfully." -ForegroundColor Green } |