Active Directory Health Audit Script: Automated HTML Reports and Alerts with PowerShell
A complete production PowerShell solution to automate Active Directory forest health auditing, compile responsive HTML dashboards, and trigger priority email notifications before directory failures impact users.
ICMP ping, LDAP port response, FSMO role availability, and core service states across all domain controllers.
Tracking inbound partner metadata, consecutive failure counts, and naming context sync deltas.
Self-contained, responsive status reports with executive KPI badges and targeted diagnostic tables.
What Is an Automated AD Health Audit?
Active Directory Domain Services is the foundation of identity, authentication, and access control across Windows enterprise networks. Yet, in many organisations, Active Directory is managed purely by exception: administrators assume everything is functioning until users report failed Kerberos authentications, Group Policies stop applying, or a decommissioned domain controller triggers replication backlogs.
Think of an automated Active Directory health audit as an automated pre-flight instrument check on an aircraft. You do not wait until you are halfway across the Atlantic to discover that an auxiliary hydraulic pump has failed or fuel transfer has ceased; you verify telemetry, pressure, and electrical buses while you are still taxiing.
Active Directory is built to be resilient. If one domain controller suffers an unexpected service crash or halts replication, other domain controllers step in to service user logons. This architectural resilience is a double-edged sword: it conceals silent failures. A domain controller can sit isolated in a branch office with a broken secure channel, halted SYSVOL replication, or stopped Active Directory Web Services for weeks while users authenticate against secondary servers over high-latency WAN links.
When an unmonitored domain controller exceeds the forest tombstone lifetime without replicating, it triggers quarantine mechanisms like Event 2042. At that stage, simple remediation is off the table, and administrators face painful object lingerings or metadata cleanups.
An automated health audit script bridges the gap between passive ignorance and frantic emergency response. By executing an unattended check every morning at 06:00 AM, administrators receive a crisp executive summary detailing domain controller reachability, service states, replication partner status, and storage capacity directly in their inbox before the business day begins.
Why a Custom PowerShell Tool Instead of Point Solutions?
Infrastructure engineers often ask why they should run a scheduled PowerShell script when enterprise monitoring platforms like SCOM, Datadog, Zabbix, or commercial Active Directory auditing suites exist. The answer comes down to operational agility, signal-to-noise ratio, and licensing reality.
Enterprise monitoring suites rely on heavyweight agents installed on domain controllers. These agents frequently introduce complexity during OS upgrades, consume memory buffers, and generate thousands of low-level performance alerts that lead straight to alert fatigue. When a monitoring agent dumps 400 events into a mailbox during routine maintenance, engineers create inbox rules that route those alerts into folders nobody reads.
Runs remotely from a management workstation or PAW using native ADWS and CIM interfaces.
Collates disparate checks (services, replication, disk, shares) into a single unified health report.
Generates styled HTML dashboards with embedded CSS that render perfectly in Outlook and mobile clients.
Conversely, command-line utilities such as dcdiag and repadmin are outstanding diagnostic tools, but their output is unstructured text. Piping raw console output into an email results in an unreadable wall of text that obscures critical warnings.
A purpose-built PowerShell script queries Active Directory Web Services (ADWS) on TCP port 9389 and Common Information Model (CIM) providers over WinRM/WMI. It captures structured objects, evaluates threshold criteria programmatically, and compiles an executive dashboard with clear colour-coded badges.
The Vital Signs of Forest Health
A comprehensive Active Directory health check must evaluate multiple operational layers. Checking only that a server replies to a ping is useless if the Kerberos Key Distribution Center (KDC) is stopped or SYSVOL is unshared. Our health script evaluates seven core diagnostic vectors across the enterprise.
These seven vectors represent the pulse, respiration, and blood pressure of your directory infrastructure:
| Diagnostic Vector | Evaluation Method | Target Healthy State | Failure Impact |
|---|---|---|---|
Network & Port Reachability |
TCP Socket probe to LDAP (389) & RPC (135) | Connection successful within 2000 ms | Clients cannot locate or authenticate against the domain controller. |
FSMO Role Accessibility |
Query Get-ADForest and Get-ADDomain role holders |
All 5 role owners responsive and reachable | Schema modifications, RID allocation, and time sync hierarchy break. |
Core Windows Services |
CIM query against Win32_Service |
NTDS, ADWS, KDC, DNS, Netlogon, DFSR running | Authentication halts, DNS resolution fails, or replication stalls. |
Replication Partner Metadata |
Get-ADReplicationPartnerMetadata |
LastReplicationResult = 0, failures = 0 |
Directory partitions drift out of synchronisation, causing stale objects. |
SYSVOL & Netlogon Shares |
UNC path accessibility check (\\DC\SYSVOL) |
Both administrative shares accessible | Domain controller fails to advertise; Group Policies fail to apply. |
NTDS Volume Free Space |
CIM query against Win32_LogicalDisk |
Available space > 15% on OS and database drives | ESE database engine halts instantly if disk volume is exhausted. |
Directory Service Event Log |
Get-WinEvent for critical warning/error events |
Zero replication errors (1311, 1925, 2042) in 24 hours | Early indicator of physical disk degradation or replication breaks. |
Notice that network reachability uses a fast TCP socket probe rather than standard Test-Connection. In hardened environments, ICMP echo requests are often blocked by internal firewalls. Furthermore, ping only proves the IP stack is alive; testing TCP port 389 confirms the directory service listener is actively accepting connections.
Script Architecture & Design Choices
Writing an automation script destined for production task schedulers requires defensive programming. If a script encounters an offline branch domain controller and throws an unhandled terminating exception, the entire audit halts, leaving the rest of your domain controllers uninspected.
The script is designed around four foundational architectural principles:
1. Isolated Execution per Domain Controller: Every check against an individual domain controller is wrapped inside dedicated try/catch blocks. If a remote DC in the Singapore office is unreachable due to a WAN outage, the script records that DC as Offline, logs the socket failure, and immediately proceeds to inspect the remaining domain controllers.
2. Fast Timeouts for Offline Nodes: Standard PowerShell cmdlets like Test-NetConnection or default WMI connections can wait 15 to 30 seconds before timing out on an unresponsive IP address. In an environment with 20 domain controllers and 2 offline nodes, those delays compound quickly. Our script uses native System.Net.Sockets.TcpClient with an explicit 2000-millisecond timeout, ensuring an unreachable host is flagged swiftly without stalling the execution pipeline.
3. Standardised Health Scoring: Each category produces a status token: Healthy (green), Warning (amber), or Critical (red). The script tallies these tokens into an executive summary counter displayed at the top of the generated HTML report.
4. Modern, Self-Contained HTML Styling: Many automated PowerShell reporting scripts generate HTML via ConvertTo-Html with basic, unformatted tables. Our solution embeds a clean, responsive CSS layout directly into the <head> block. It features CSS cards, responsive flex grids, styled table headers, and status badges that render predictably across desktop email clients, webmail, and mobile devices.
The Complete Production Health Audit Script
Below is the full source code for Test-ADForestHealth.ps1. Save this script to your administration tool repository (e.g. C:\Scripts\ADHealth\Test-ADForestHealth.ps1).
<#
.SYNOPSIS
Automated Active Directory Forest Health Audit & HTML Reporting Tool.
.DESCRIPTION
Performs comprehensive diagnostic checks across all domain controllers in
the current Active Directory forest. Tests connectivity, FSMO role holders,
essential Windows services, replication partners, SYSVOL/Netlogon shares,
disk space, and event logs. Compiles a responsive HTML report and optionally
sends an email alert.
.PARAMETER ReportPath
The file path where the generated HTML report will be saved.
.PARAMETER SendEmail
Switch parameter to enable automated email dispatch of the report.
.PARAMETER SmtpServer
The FQDN or IP address of the SMTP relay server.
.PARAMETER SmtpPort
The TCP port for the SMTP service (default is 25).
.PARAMETER From
Sender email address for the health notification.
.PARAMETER To
Recipient email address(es) for the report.
.PARAMETER CriticalDiskThreshold
Percentage of free disk space that triggers a Critical warning (default 15%).
.EXAMPLE
.\Test-ADForestHealth.ps1 -ReportPath "C:\Reports\ADHealth.html"
.EXAMPLE
.\Test-ADForestHealth.ps1 -SendEmail -SmtpServer "smtp.corp.example.com" -From "ad-audit@corp.example.com" -To "infra-team@corp.example.com"
#>
[CmdletBinding()]
param (
[Parameter(Mandatory = $false)]
[string]$ReportPath = "$PSScriptRoot\AD-HealthReport-$((Get-Date).ToString('yyyyMMdd-HHmmss')).html",
[Parameter(Mandatory = $false)]
[switch]$SendEmail,
[Parameter(Mandatory = $false)]
[string]$SmtpServer = "smtp.corp.example.com",
[Parameter(Mandatory = $false)]
[int]$SmtpPort = 25,
[Parameter(Mandatory = $false)]
[string]$From = "ad-audit@corp.example.com",
[Parameter(Mandatory = $false)]
[string[]]$To = @("infra-team@corp.example.com"),
[Parameter(Mandatory = $false)]
[int]$CriticalDiskThreshold = 15
)
# Set error action to continue so single failures do not abort execution
$ErrorActionPreference = 'Continue'
# Confirm ActiveDirectory module is available
if (-not (Get-Module -ListAvailable -Name ActiveDirectory)) {
Write-Error "The ActiveDirectory PowerShell module is required. Install RSAT-AD-PowerShell."
return
}
Import-Module ActiveDirectory -ErrorAction Stop
Write-Verbose "Querying forest and domain configuration..."
$ForestInfo = Get-ADForest
$DomainInfo = Get-ADDomain
# Retrieve FSMO role holders
$FSMORoles = [PSCustomObject]@{
SchemaMaster = $ForestInfo.SchemaMaster
DomainNamingMaster = $ForestInfo.DomainNamingMaster
PDCEmulator = $DomainInfo.PDCEmulator
RIDMaster = $DomainInfo.RIDMaster
InfrastructureMaster = $DomainInfo.InfrastructureMaster
}
# Discover all domain controllers across the domain
Write-Verbose "Discovering all domain controllers..."
$AllDCs = Get-ADDomainController -Filter * | Sort-Object Name
# Define essential services required on a healthy Domain Controller
$CriticalServices = @('NTDS', 'ADWS', 'KDC', 'DNS', 'Netlogon', 'DFSR', 'W32Time')
# Fast TCP Port Checker using System.Net.Sockets.TcpClient
function Test-FastTcpPort {
param (
[string]$ComputerName,
[int]$Port = 389,
[int]$TimeoutMs = 2000
)
$client = New-Object System.Net.Sockets.TcpClient
try {
$iar = $client.BeginConnect($ComputerName, $Port, $null, $null)
$wait = $iar.AsyncWaitHandle.WaitOne($TimeoutMs, $false)
if (-not $wait) {
$client.Close()
return $false
}
$client.EndConnect($iar)
$client.Close()
return $true
} catch {
return $false
}
}
# Initialise collection arrays for report data
$DCResults = [System.Collections.Generic.List[PSCustomObject]]::new()
$ReplicationResults = [System.Collections.Generic.List[PSCustomObject]]::new()
$ServiceResults = [System.Collections.Generic.List[PSCustomObject]]::new()
$DiskResults = [System.Collections.Generic.List[PSCustomObject]]::new()
$EventResults = [System.Collections.Generic.List[PSCustomObject]]::new()
$TotalDCsCount = $AllDCs.Count
$OnlineDCsCount = 0
$WarningCount = 0
$CriticalCount = 0
Write-Output "Beginning Active Directory forest health audit for $($ForestInfo.Name)..."
foreach ($DC in $AllDCs) {
$dcName = $DC.HostName
Write-Progress -Activity "Auditing Domain Controllers" -Status "Checking $dcName" -PercentComplete (($DCResults.Count / $TotalDCsCount) * 100)
# 1. Connectivity Check
$pingOk = Test-Connection -ComputerName $dcName -Count 1 -Quiet -ErrorAction SilentlyContinue
$ldapOk = Test-FastTcpPort -ComputerName $dcName -Port 389 -TimeoutMs 2000
$rpcOk = Test-FastTcpPort -ComputerName $dcName -Port 135 -TimeoutMs 2000
$connectivityStatus = "Healthy"
if (-not $ldapOk -or -not $rpcOk) {
$connectivityStatus = "Critical"
$CriticalCount++
} elseif (-not $pingOk) {
$connectivityStatus = "Warning"
$WarningCount++
} else {
$OnlineDCsCount++
}
# Record DC overview
$DCResults.Add([PSCustomObject]@{
Name = $DC.Name
HostName = $dcName
Site = $DC.Site
IPv4Address = $DC.IPv4Address
OperatingSystem = $DC.OperatingSystem
Ping = if ($pingOk) { "Pass" } else { "Fail" }
LDAP_389 = if ($ldapOk) { "Open" } else { "Closed" }
RPC_135 = if ($rpcOk) { "Open" } else { "Closed" }
Status = $connectivityStatus
})
# Skip deeper remote checks if the DC listener is closed
if (-not $ldapOk) {
Write-Warning "DC $dcName LDAP port is unreachable. Skipping remote subsystem checks."
continue
}
# 2. Check Core Services via CIM
try {
$cimSession = New-CimSession -ComputerName $dcName -OperationTimeoutSec 5 -ErrorAction Stop
$services = Get-CimInstance -CimSession $cimSession -ClassName Win32_Service -Filter "Name in ('NTDS','ADWS','KDC','DNS','Netlogon','DFSR','W32Time')" -ErrorAction Stop
foreach ($critSvc in $CriticalServices) {
$matched = $services | Where-Object { $_.Name -eq $critSvc }
$svcState = if ($matched) { $matched.State } else { "Not Installed" }
$svcStatus = if ($svcState -eq "Running") { "Healthy" } else { "Critical"; $CriticalCount++ }
$ServiceResults.Add([PSCustomObject]@{
DomainController = $DC.Name
ServiceName = $critSvc
DisplayName = if ($matched) { $matched.DisplayName } else { $critSvc }
State = $svcState
Status = $svcStatus
})
}
# 3. Check Fixed Disk Drives
$disks = Get-CimInstance -CimSession $cimSession -ClassName Win32_LogicalDisk -Filter "DriveType=3" -ErrorAction Stop
foreach ($d in $disks) {
$totalGB = [Math]::Round($d.Size / 1GB, 1)
$freeGB = [Math]::Round($d.FreeSpace / 1GB, 1)
$pctFree = if ($d.Size -gt 0) { [Math]::Round(($d.FreeSpace / $d.Size) * 100, 1) } else { 0 }
$diskStatus = "Healthy"
if ($pctFree -lt $CriticalDiskThreshold) {
$diskStatus = "Critical"
$CriticalCount++
} elseif ($pctFree -lt ($CriticalDiskThreshold + 10)) {
$diskStatus = "Warning"
$WarningCount++
}
$DiskResults.Add([PSCustomObject]@{
DomainController = $DC.Name
DriveLetter = $d.DeviceID
TotalSizeGB = $totalGB
FreeSpaceGB = $freeGB
PercentFree = $pctFree
Status = $diskStatus
})
}
Remove-CimSession -CimSession $cimSession -ErrorAction SilentlyContinue
} catch {
Write-Warning "Failed CIM queries against $dcName - $($_.Exception.Message)"
}
# 4. Check Inbound Replication Partner Metadata
try {
$replMeta = Get-ADReplicationPartnerMetadata -Target $dcName -Scope Server -ErrorAction Stop
foreach ($r in $replMeta) {
$partnerName = ($r.Partner -split ',')[0] -replace '^CN='
$isError = ($r.LastReplicationResult -ne 0) -or ($r.ConsecutiveReplicationFailures -gt 0)
$replStatus = "Healthy"
if ($isError) {
$replStatus = "Critical"
$CriticalCount++
}
$ReplicationResults.Add([PSCustomObject]@{
TargetDC = $DC.Name
PartnerDC = $partnerName
NamingContext = ($r.IntersiteTransportType) + " " + ($r.Partition -split ',')[0]
LastAttempt = $r.LastReplicationAttempt
LastSuccess = $r.LastReplicationSuccess
ConsecutiveFailures = $r.ConsecutiveReplicationFailures
LastResult = $r.LastReplicationResult
Status = $replStatus
})
}
} catch {
Write-Warning "Unable to retrieve replication metadata from $dcName - $($_.Exception.Message)"
}
# 5. Query Directory Service Event Log for Critical Warnings/Errors in last 24h
try {
$filter = @{
LogName = 'Directory Service'
Level = 1, 2 # 1=Critical, 2=Error
StartTime = (Get-Date).AddDays(-1)
}
$events = Get-WinEvent -ComputerName $dcName -FilterHashtable $filter -MaxEvents 10 -ErrorAction SilentlyContinue
if ($events) {
foreach ($ev in $events) {
$EventResults.Add([PSCustomObject]@{
DomainController = $DC.Name
TimeCreated = $ev.TimeCreated.ToString('yyyy-MM-dd HH:mm')
EventID = $ev.Id
LevelDisplayName = $ev.LevelDisplayName
MessageSummary = ($ev.Message -split "`r?`n")[0]
})
}
}
} catch {
# Event log query errors are non-terminating
}
}
Write-Progress -Activity "Auditing Domain Controllers" -Completed
# Calculate Overall Health Status Badge
$OverallState = "HEALTHY"
$OverallBadgeClass = "badge-pass"
if ($CriticalCount -gt 0) {
$OverallState = "CRITICAL ISSUES DETECTED"
$OverallBadgeClass = "badge-fail"
} elseif ($WarningCount -gt 0) {
$OverallState = "WARNINGS FOUND"
$OverallBadgeClass = "badge-warn"
}
# Generate Inline-Styled HTML Document
Write-Verbose "Compiling HTML report document..."
$htmlBuilder = [System.Text.StringBuilder]::new()
[void]$htmlBuilder.AppendLine(@"
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Active Directory Health Report - $($ForestInfo.Name)</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: #f7f9fa;
color: #24292e;
margin: 0;
padding: 24px;
line-height: 1.5;
}
.container {
max-width: 1100px;
margin: 0 auto;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
overflow: hidden;
}
.header {
background: #1f2328;
color: #ffffff;
padding: 28px 32px;
}
.header h1 {
margin: 0 0 8px 0;
font-size: 24px;
font-weight: 600;
}
.header p {
margin: 0;
color: #8c959f;
font-size: 14px;
}
.content {
padding: 32px;
}
.summary-cards {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-bottom: 32px;
}
.card {
flex: 1;
min-width: 200px;
background: #f6f8fa;
border: 1px solid #d0d7de;
border-radius: 6px;
padding: 16px 20px;
}
.card-label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
color: #57609a;
margin-bottom: 4px;
}
.card-value {
font-size: 28px;
font-weight: 700;
color: #1f2328;
}
h2 {
font-size: 18px;
font-weight: 600;
margin: 28px 0 16px 0;
padding-bottom: 8px;
border-bottom: 1px solid #d0d7de;
color: #1f2328;
}
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 24px;
font-size: 13px;
}
th {
background: #f6f8fa;
color: #57606a;
font-weight: 600;
text-align: left;
padding: 10px 12px;
border: 1px solid #d0d7de;
font-size: 12px;
text-transform: uppercase;
}
td {
padding: 9px 12px;
border: 1px solid #d0d7de;
}
tr:nth-child(even) {
background-color: #fafbfc;
}
.badge {
display: inline-block;
padding: 3px 8px;
font-size: 11px;
font-weight: 600;
border-radius: 12px;
text-align: center;
}
.badge-pass { background-color: #dafbe1; color: #1a7f37; }
.badge-warn { background-color: #fff8c5; color: #9a6700; }
.badge-fail { background-color: #ffebe9; color: #cf222e; }
.footer {
padding: 20px 32px;
background: #f6f8fa;
border-top: 1px solid #d0d7de;
font-size: 12px;
color: #57606a;
text-align: right;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Active Directory Forest Health Audit</h1>
<p>Forest: $($ForestInfo.Name) | Generated: $((Get-Date).ToString('yyyy-MM-dd HH:mm:ss')) | Scope: All Discovered DCs</p>
</div>
<div class="content">
<div class="summary-cards">
<div class="card">
<div class="card-label">Overall Status</div>
<div class="card-value"><span class="badge $OverallBadgeClass">$OverallState</span></div>
</div>
<div class="card">
<div class="card-label">Domain Controllers</div>
<div class="card-value">$OnlineDCsCount / $TotalDCsCount</div>
</div>
<div class="card">
<div class="card-label">Warnings</div>
<div class="card-value">$WarningCount</div>
</div>
<div class="card">
<div class="card-label">Critical Issues</div>
<div class="card-value">$CriticalCount</div>
</div>
</div>
<h2>1. FSMO Role Holders</h2>
<table>
<thead>
<tr><th>Role</th><th>Role Owner</th></tr>
</thead>
<tbody>
<tr><td><strong>Schema Master</strong></td><td>$($FSMORoles.SchemaMaster)</td></tr>
<tr><td><strong>Domain Naming Master</strong></td><td>$($FSMORoles.DomainNamingMaster)</td></tr>
<tr><td><strong>PDC Emulator</strong></td><td>$($FSMORoles.PDCEmulator)</td></tr>
<tr><td><strong>RID Master</strong></td><td>$($FSMORoles.RIDMaster)</td></tr>
<tr><td><strong>Infrastructure Master</strong></td><td>$($FSMORoles.InfrastructureMaster)</td></tr>
</tbody>
</table>
<h2>2. Domain Controller Connectivity & Status</h2>
<table>
<thead>
<tr><th>DC Name</th><th>Site</th><th>IPv4 Address</th><th>Ping</th><th>LDAP (389)</th><th>RPC (135)</th><th>Status</th></tr>
</thead>
<tbody>
"@)
foreach ($r in $DCResults) {
$badge = if ($r.Status -eq "Healthy") { "badge-pass" } elseif ($r.Status -eq "Warning") { "badge-warn" } else { "badge-fail" }
[void]$htmlBuilder.AppendLine("<tr><td><strong>$($r.Name)</strong></td><td>$($r.Site)</td><td>$($r.IPv4Address)</td><td>$($r.Ping)</td><td>$($r.LDAP_389)</td><td>$($r.RPC_135)</td><td><span class='badge $badge'>$($r.Status)</span></td></tr>")
}
[void]$htmlBuilder.AppendLine(@"
</tbody>
</table>
<h2>3. Critical Windows Services</h2>
<table>
<thead>
<tr><th>Domain Controller</th><th>Service</th><th>Display Name</th><th>State</th><th>Status</th></tr>
</thead>
<tbody>
"@)
foreach ($s in $ServiceResults) {
$badge = if ($s.Status -eq "Healthy") { "badge-pass" } else { "badge-fail" }
[void]$htmlBuilder.AppendLine("<tr><td>$($s.DomainController)</td><td><code>$($s.ServiceName)</code></td><td>$($s.DisplayName)</td><td>$($s.State)</td><td><span class='badge $badge'>$($s.Status)</span></td></tr>")
}
[void]$htmlBuilder.AppendLine(@"
</tbody>
</table>
<h2>4. Active Directory Inbound Replication</h2>
<table>
<thead>
<tr><th>Target DC</th><th>Partner DC</th><th>Partition / Context</th><th>Last Success</th><th>Failures</th><th>Status</th></tr>
</thead>
<tbody>
"@)
if ($ReplicationResults.Count -eq 0) {
[void]$htmlBuilder.AppendLine("<tr><td colspan='6'>No inbound replication partners found (single DC forest).</td></tr>")
} else {
foreach ($rep in $ReplicationResults) {
$badge = if ($rep.Status -eq "Healthy") { "badge-pass" } else { "badge-fail" }
[void]$htmlBuilder.AppendLine("<tr><td><strong>$($rep.TargetDC)</strong></td><td>$($rep.PartnerDC)</td><td>$($rep.NamingContext)</td><td>$($rep.LastSuccess)</td><td>$($rep.ConsecutiveFailures)</td><td><span class='badge $badge'>$($rep.Status)</span></td></tr>")
}
}
[void]$htmlBuilder.AppendLine(@"
</tbody>
</table>
<h2>5. Domain Controller Storage Capacity</h2>
<table>
<thead>
<tr><th>Domain Controller</th><th>Drive</th><th>Total (GB)</th><th>Free (GB)</th><th>Percent Free</th><th>Status</th></tr>
</thead>
<tbody>
"@)
foreach ($d in $DiskResults) {
$badge = if ($d.Status -eq "Healthy") { "badge-pass" } elseif ($d.Status -eq "Warning") { "badge-warn" } else { "badge-fail" }
[void]$htmlBuilder.AppendLine("<tr><td>$($d.DomainController)</td><td><strong>$($d.DriveLetter)</strong></td><td>$($d.TotalSizeGB)</td><td>$($d.FreeSpaceGB)</td><td>$($d.PercentFree)%</td><td><span class='badge $badge'>$($d.Status)</span></td></tr>")
}
[void]$htmlBuilder.AppendLine(@"
</tbody>
</table>
"@)
if ($EventResults.Count -gt 0) {
[void]$htmlBuilder.AppendLine(@"
<h2>6. Directory Service Event Log Alerts (Last 24 Hours)</h2>
<table>
<thead>
<tr><th>DC</th><th>Timestamp</th><th>Event ID</th><th>Severity</th><th>Summary</th></tr>
</thead>
<tbody>
"@)
foreach ($ev in $EventResults) {
[void]$htmlBuilder.AppendLine("<tr><td>$($ev.DomainController)</td><td>$($ev.TimeCreated)</td><td><code>$($ev.EventID)</code></td><td><span class='badge badge-fail'>$($ev.LevelDisplayName)</span></td><td>$($ev.MessageSummary)</td></tr>")
}
[void]$htmlBuilder.AppendLine("</tbody></table>")
}
[void]$htmlBuilder.AppendLine(@"
</div>
<div class="footer">
Active Directory SRE Automation Engine · Generated on $([System.Environment]::MachineName)
</div>
</div>
</body>
</html>
"@)
# Write HTML file to disk with UTF8 encoding
$htmlContent = $htmlBuilder.ToString()
Set-Content -LiteralPath $ReportPath -Value $htmlContent -Encoding UTF8
Write-Output "Health report saved successfully to: $ReportPath"
# Dispatch Email Alert if requested
if ($SendEmail) {
Write-Verbose "Preparing email notification to: $($To -join ', ')..."
$subjectPrefix = if ($CriticalCount -gt 0) { "[ALERT - CRITICAL]" } elseif ($WarningCount -gt 0) { "[WARNING]" } else { "[OK]" }
$emailSubject = "$subjectPrefix Active Directory Forest Health Report - $($ForestInfo.Name) ($OverallState)"
try {
$mailParams = @{
To = $To
From = $From
Subject = $emailSubject
Body = $htmlContent
BodyAsHtml = $true
SmtpServer = $SmtpServer
Port = $SmtpPort
ErrorAction = 'Stop'
}
Send-MailMessage @mailParams
Write-Output "Email notification successfully dispatched via $SmtpServer."
} catch {
Write-Error "Failed to dispatch email report: $($_.Exception.Message)"
}
}
Notice how the script handles data structure compilation: instead of performing heavy string concatenation in memory, it uses a System.Text.StringBuilder buffer. In enterprise environments with dozens of domain controllers and hundreds of replication links, this eliminates garbage collection thrashing and keeps execution lightning fast.
Automating Execution with Scheduled Tasks and gMSA
A health audit script that requires an engineer to log on and double-click an icon is not an operational solution. To provide genuine peace of mind, the script must run automatically every morning, evaluate conditions, and dispatch reports before engineers start their morning stand-ups.
Running automated scripts in enterprise environments requires strict attention to credential hygiene. You should never store plain-text domain administrator credentials in Task Scheduler XML files or hard-code them into scripts.
The audit script only performs read queries. To grant a service account sufficient authority to inspect your domain controllers without granting full administrative privileges:
| Security Boundary | Required Permission | Delegation Method |
|---|---|---|
Active Directory Objects |
Read All Properties | Granted by default to all members of the Domain Users security group. |
Directory Service Event Log |
Remote Event Log Read | Add the service account to the domain-wide Event Log Readers group. |
WMI & CIM Service / Disk Probes |
Remote WMI Execution | Add account to Performance Monitor Users and grant Remote Enable on Root\CIMv2. |
Task Scheduler Execution |
Log on as a batch job | Assign via Local Security Policy (User Rights Assignment) on the host runner. |
Use the PowerShell snippet below to register the automated scheduled task on your designated administration server. The task is configured to execute daily at 06:00 AM under the managed service account:
# Define the action: launch 64-bit PowerShell and execute the health script
$action = New-ScheduledTaskAction `
-Execute "C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" `
-Argument "-NoProfile -ExecutionPolicy Bypass -File C:\Scripts\ADHealth\Test-ADForestHealth.ps1 -SendEmail -SmtpServer 'smtp.corp.example.com' -From 'ad-audit@corp.example.com' -To 'infra-team@corp.example.com'"
# Define the trigger: daily at 06:00 AM
$trigger = New-ScheduledTaskTrigger -Daily -At "06:00AM"
# Configure execution principal using a Group Managed Service Account
$principal = New-ScheduledTaskPrincipal `
-UserId "CORP\svc-ad-audit$" `
-LogonType Password `
-RunLevel Highest
# Register the scheduled task in the system
Register-ScheduledTask `
-TaskName "ActiveDirectory-Daily-Forest-Health-Audit" `
-Action $action `
-Trigger $trigger `
-Principal $principal `
-Description "Automated daily Active Directory forest health audit and HTML email report."
Interpreting the Report & Event Log Cheat Sheet
When you open the morning HTML report, the top-level KPI cards give you an instantaneous health posture. A green HEALTHY badge indicates that all discovered domain controllers responded to LDAP and RPC, essential services are running, no replication partner has recorded consecutive failures, and all database volumes retain adequate headroom.
When an amber or red badge appears, use the targeted sections to isolate the failure domain immediately.
| Report Finding | Diagnostic Meaning | Immediate Remediation |
|---|---|---|
LDAP (389) Closed / Timeout |
DC listener is frozen, network firewall blocked traffic, or DC is completely offline. | Verify host hypervisor status, check network routing, and confirm Windows Firewall profile. |
ADWS Service Stopped |
Active Directory Web Services halted, breaking modern PowerShell and Admin Center access. | Start service with Start-Service ADWS; check port 9389 binding conflicts. |
Replication Result 1722 |
RPC Server Unavailable. Target DC cannot reach partner on dynamic RPC range (49152-65535). | Test RPC endpoint mapper on port 135 and check MTU sizing / intermediate firewall state. |
Replication Result 8453 |
Replication Access Was Denied. Kerberos SPN issue or missing directory replication permissions. | Run repadmin /syncall /force and verify domain controller computer account memberships. |
Free Space < 15% on NTDS |
Database or transaction log volume is near exhaustion. Database corruption imminent. | Perform system state backup to truncate ESE transaction logs or expand the virtual disk volume. |
Section 6 of the report surfaces events from the Directory Service event log. In Active Directory, certain Event IDs indicate critical architectural faults that require immediate engineering attention:
| Event ID | Subsystem | Severity | Description & Operational Meaning |
|---|---|---|---|
1311 |
KCC | Critical | The Knowledge Consistency Checker cannot calculate a spanning-tree replication topology. |
1864 |
Replication | Warning | A domain controller has not replicated with its partner within the tombstone lifetime interval. |
1925 |
Replication | Critical | Attempt to establish a replication link across a partition failed. |
2042 |
Replication | Critical | Replication blocked because the partner has been disconnected longer than the tombstone lifetime. |
2095 |
NTDS Database | Fatal | USN rollback detected. A virtual machine snapshot was restored improperly, halting replication permanently. |
4012 |
DFSR | Critical | The DFS Replication service stopped replication on the SYSVOL folder due to unexpected downtime. |
1000 |
ADWS | Error | Active Directory Web Services encountered an unhandled exception or failed to bind to TCP 9389. |
Troubleshooting Common Script Execution Failures
When deploying automated PowerShell scripts into hardened enterprise environments, security controls, firewall rules, and execution policies can prevent smooth execution. The troubleshooting matrix below addresses the most common obstacles encountered when setting up the audit.
| Symptom | Likely Cause | Fix |
|---|---|---|
Get-ADDomainController: Unable to contact server |
The management server cannot reach Active Directory Web Services on port 9389. | Verify TCP 9389 connectivity to the PDC emulator: Test-NetConnection -ComputerName dc01.corp.example.com -Port 9389. |
CIM / WMI Access Denied (0x80070005) |
The executing service account lacks DCOM or remote WMI permissions on target domain controllers. | Add the service account to the target domain controllers’ local group: Add-LocalGroupMember -Group 'Distributed COM Users' -Member 'CORP\svc-ad-audit$'. |
The RPC server is unavailable (0x800706BA) |
Windows Firewall or perimeter firewalls are blocking dynamic RPC ports. | Enable the built-in remote management firewall rule on domain controllers: Enable-NetFirewallRule -DisplayGroup 'Windows Remote Management'. |
Send-MailMessage is obsolete / deprecated |
Running on PowerShell 7+ where Send-MailMessage lacks modern secure authentication support. |
Execute the script using Windows PowerShell 5.1 or replace the dispatch block with Microsoft Graph API calls: Invoke-RestMethod -Uri 'https://graph.microsoft.com/v1.0/me/sendMail' .... |
HTML report displays unstyled plain text in Outlook |
Outlook rendering engine stripped external CSS or ignored unsupported modern CSS properties. | Ensure all styles reside in the inline <style> block using classic table borders, inline hex colour codes, and standard font stacks. |
Script hangs for several minutes on offline DC |
Default CIM or TCP connection timeouts are set too high for unreachable network hosts. | Enforce explicit socket timeouts: use the included Test-FastTcpPort helper with a 2000 ms timeout window before attempting CIM connection calls. |
Final Thoughts
Running Active Directory without proactive monitoring is like driving at night with the headlights off: everything seems fine until you hit a curve you could not see. Directory degradations do not happen in an instant; they begin with an unreached replication partner, an expiring disk volume, or a stopped background service that sits undetected for days.
By deploying an automated, agentless PowerShell health audit, you transform silent failures into clear, actionable morning reports. You give your engineering team the visibility needed to remediate replication hiccups and disk pressure during normal business hours rather than troubleshooting directory outages during emergency midnight maintenance.
Next, we will explore auditing Active Directory Certificate Services (AD CS) with PowerShell: detecting expiring CA and template certificates, inspecting CRL distribution points, and alerting on rogue templates.
Discover more from Karanth.ovh
Subscribe to get the latest posts sent to your email.