Multi-DC Active Directory Health Check Script

This PowerShell script runs a broad battery of AD health checks against every Domain Controller in your domain, either auto-discovered or passed explicitly. It pulls system info, replication state, dcdiag results, event logs, account hygiene, DNS health, and more from each DC via PSRemoting, then writes per-DC text reports and a side-by-side comparison summary. Run it when you want a fast, consistent snapshot across all DCs without clicking through multiple tools.

How It Works

Pre-flight and DC Discovery

The script checks for the ActiveDirectory module locally, then either accepts a DC list via -DomainControllers or queries Get-ADDomainController -Filter * to build one. It creates a timestamped output folder under the report root before anything else touches the network.

The Health-Check Scriptblock

Everything that actually runs on a DC lives in $healthCheckBlock. This is a single scriptblock that gets invoked locally (if the target is the current machine) or shipped over PSRemoting with Invoke-Command. All output accumulates in a List[string] that becomes the per-DC report file. A parallel [ordered]@{} hash collects scalar summary values for the comparison table.

System Info, Reboot, and Resources

Grabs OS version, uptime, pending-reboot registry keys, CPU load average, and memory usage. Nothing fancy here, just WMI/CIM queries. These feed the UptimeDays, CpuPct, MemPct, and PendingReboot summary fields.

AD Services and NTDS Database

Checks a fixed list of services (NTDS, DNS, Netlogon, KDC, DFSR, etc.) and reports status plus start type. Then reads the NTDS registry key to find the ntds.dit path and reports its size. A growing database on one DC compared to the others can point at issues.

FSMO Roles and AD Configuration

Queries forest and domain objects to show all five FSMO role holders and both functional levels. It also checks whether the Recycle Bin is enabled, reads the schema version, and pulls the tombstone lifetime from the config partition.

Replication

Runs repadmin /replsummary for the narrative, then uses Get-ADReplicationFailure and Get-ADReplicationPartnerMetadata for structured data. Failure count and partner count go into the summary. DFSR backlog for SYSVOL is checked per partner using Get-DfsrBacklog.

DCDiag Battery

Runs 15 specific /test: categories one at a time. Pass/fail counts go into the summary. Full dcdiag output only lands in the report on failures, keeping the logs readable.

Account Hygiene

Three checks: enabled users with no logon activity past the stale threshold, currently locked-out accounts, and enabled accounts with PasswordNeverExpires. These run against AD from the DC itself, so results reflect that DC’s view.

DNS Health

Loads the DnsServer module if available and checks server settings, zones, and scavenging config. Then does Resolve-DnsName lookups for the three critical SRV records your clients depend on (_ldap._tcp.dc._msdcs, _kerberos._tcp.dc._msdcs, _gc._tcp).

Time, Secure Channel, and DC Advertisement

Calls w32tm /query for status, source, and peers. Runs Test-ComputerSecureChannel. Uses nltest /dsgetdc and nltest /dclist to confirm the DC is advertising correctly.

Port Check and LDAP Latency

Compares a fixed list of expected DC ports against Get-NetTCPConnection -State Listen. Missing port count goes into the summary. LDAP latency is measured by timing a Get-ADRootDSE call.

Event Log Analysis

Pulls Critical and Error events from five logs (System, Application, Directory Service, DNS Server, DFS Replication) for the lookback window. Events are grouped by ID and sorted by frequency, so you see the noisy ones first rather than a raw chronological dump.

Per-DC Reports and Comparison Summary

After all DCs run, each DC’s lines get written to .txt. The summary metrics are then assembled into a fixed-width comparison table (_SUMMARY.txt) with one column per DC. A _SUMMARY.json is also written for anything downstream that wants structured data.

Usage

Requirements

  • PowerShell 5.1 or newer
  • Run elevated (the script has #Requires -RunAsAdministrator)
  • RSAT-AD-PowerShell installed on the machine you run it from
  • PSRemoting enabled and accessible on every target DC
  • The account running the script needs permission to query AD and remote into DCs. Domain Admin or a delegated read-plus-remoting account works.

Basic Usage

Run against all DCs in the current domain with defaults:

.DCHealthCheck.ps1Code language: PowerShell (powershell)

Specify DCs Explicitly

.DCHealthCheck.ps1 -DomainControllers MAIL01, DC02Code language: PowerShell (powershell)

Override Output Path

.DCHealthCheck.ps1 -ReportDir 'D:ADReports'Code language: PowerShell (powershell)

Adjust Lookback and Thresholds

.DCHealthCheck.ps1 -EventLookbackDays 3 -StaleUserDays 60 -HotfixLookbackDays 90Code language: PowerShell (powershell)

Parameters

| Parameter | Default | Purpose |
|—|—|—|
| -DomainControllers | All DCs | Specific hostnames to check |
| -ReportDir | C:StuffDCHealth | Root folder for output |
| -EventLookbackDays | 1 | How far back to pull error events |
| -StaleUserDays | 90 | Threshold for stale user accounts |
| -HotfixLookbackDays | 60 | Recent patch window |

Output

Each run creates a folder like C:StuffDCHealth2025-01-15_1430 containing:

  • .txt for each DC
  • _SUMMARY.txt with the side-by-side comparison table
  • _SUMMARY.json for scripted post-processing

Caveats

PSRemoting Must Be Working

This is the most common failure point. If WinRM is blocked, the firewall is misconfigured, or the target DC has PSRemoting disabled, that DC gets an error entry in the summary and no report. Check connectivity with Test-WSManConnection before assuming the script is broken.

Running Against the Local DC

The script detects whether a target DC is the local machine and skips Invoke-Command in that case. The detection compares hostname and FQDN. If your DC has unusual naming or multiple NICs with different DNS suffixes, this check could misfire and attempt a remote connection to itself.

Stale User Count is Per-DC

LastLogonDate is a replicated attribute, but it replicates on a 14-day cycle by default. Numbers may differ slightly across DCs depending on replication lag. LastLogon (non-replicated) would be more accurate per-DC but would require querying every DC to get a domain-wide picture.

DFSR Backlog Check Can Be Slow

Get-DfsrBacklog can hang or time out, especially if DFSR is unhealthy. It runs per partner, so the more partners a DC has, the longer this section takes. If a DC is already having DFSR trouble, expect timeouts here.

DnsServer and DFSR Modules Are Optional

The script loads them with -ErrorAction SilentlyContinue on the remote DC. If they are not present, those sections report themselves as skipped rather than failing loudly. You will not get DNS zone details or scavenging config from older DCs that do not have these modules installed.

dcdiag Tests Run One at a Time

Running 15 separate dcdiag /test: calls is slower than a single dcdiag /a, but it lets the script capture pass/fail per test cleanly. On a loaded DC, expect this section to take 30 to 60 seconds.

Event Log Lookback of 1 Day May Miss Problems

The default -EventLookbackDays 1 keeps reports short, but a recurring error that fired two days ago will not show up. Bump to 3 or 7 for a more thorough check, especially if you are troubleshooting something intermittent.

The JSON Summary Has Limitations

_SUMMARY.json is useful for automation, but the per-DC .txt reports are plain text built with Format-Table, not structured data. Do not try to parse the text reports programmatically. The JSON is the right target for that.

Schema Version Is Forest-Wide

The schema version will be identical on every DC. It is still worth capturing, but do not read per-DC variation into it. Any difference across DCs in that column means replication has not converged yet.

Full Script

#Requires -Version 5.1
#Requires -RunAsAdministrator

<#
.SYNOPSIS
    Comprehensive multi-DC Active Directory health check.

.DESCRIPTION
    Auto-discovers all Domain Controllers in the current domain (or accepts an
    explicit list) and runs the same battery of checks against each via
    PSRemoting. Per-DC reports plus a side-by-side comparison summary are
    written to a timestamped folder. Sections include: system info, services,
    NTDS database, FSMO roles, AD config (recycle bin / schema / tombstone),
    last AD backup, sites/subnets, replication, SYSVOL/DFSR, dcdiag battery,
    password policy, privileged groups, account hygiene, DNS server, DNS
    SRV resolution, time sync, secure channel, DC advertisement, listening
    ports, LDAP latency, recent hotfixes, and structured event-log analysis.

.PARAMETER DomainControllers
    Hostnames to check. Defaults to every DC in the current domain.

.PARAMETER ReportDir
    Root output folder. A timestamped subfolder is created per run.

.NOTES
    Requires the ActiveDirectory module (RSAT-AD-PowerShell) and PSRemoting
    enabled on every target DC. Run elevated. ASCII-only on purpose so PS 5.1
    parses the file regardless of BOM.
#>

[CmdletBinding()]
param(
    [string[]]$DomainControllers,
    [string]$ReportDir = 'C:StuffDCHealth',
    [int]$EventLookbackDays = 1,
    [int]$StaleUserDays = 90,
    [int]$HotfixLookbackDays = 60
)

# ============================================================================
# Local pre-flight
# ============================================================================
if (-not (Get-Module -ListAvailable ActiveDirectory)) {
    Write-Host 'ERROR: ActiveDirectory module required (install RSAT-AD-PowerShell).' -ForegroundColor Red
    return
}
Import-Module ActiveDirectory -ErrorAction Stop

if (-not $DomainControllers) {
    try {
        $DomainControllers = (Get-ADDomainController -Filter *).HostName | Sort-Object
    } catch {
        Write-Host "ERROR: Could not auto-discover DCs: $($_.Exception.Message)" -ForegroundColor Red
        return
    }
}
if (-not $DomainControllers) {
    Write-Host 'ERROR: No DCs found.' -ForegroundColor Red
    return
}

$timestamp = Get-Date -Format 'yyyy-MM-dd_HHmm'
$runDir = Join-Path $ReportDir $timestamp
New-Item -ItemType Directory -Path $runDir -Force | Out-Null

Write-Host ''
Write-Host "DC Health Check - $timestamp" -ForegroundColor Cyan
Write-Host "Targets: $($DomainControllers -join ', ')" -ForegroundColor Cyan
Write-Host "Output:  $runDir" -ForegroundColor Cyan
Write-Host ''

# ============================================================================
# Health-check scriptblock (runs on each DC, locally or via Invoke-Command)
# ============================================================================
$healthCheckBlock = {
    param($EventLookbackDays = 1, $StaleUserDays = 90, $HotfixLookbackDays = 60)

    $report  = New-Object System.Collections.Generic.List[string]
    $summary = [ordered]@{}

    function Write-Section {
        param([string]$Title)
        $report.Add('')
        $report.Add('================================================================')
        $report.Add("  $Title")
        $report.Add('================================================================')
    }
    function Write-Log {
        param([Parameter(ValueFromPipeline)]$Text)
        process { $report.Add([string]$Text) }
    }
    function Invoke-Safe {
        param([string]$Name, [scriptblock]$Block)
        try { & $Block }
        catch { $report.Add("  ERROR in '$Name': $($_.Exception.Message)") }
    }

    $report.Add("Domain Controller Health Check - $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') on $env:COMPUTERNAME")

    # --- Pre-flight on the target DC ----------------------------------------
    $isDC = (Get-CimInstance Win32_OperatingSystem).ProductType -eq 2
    if (-not $isDC) {
        $report.Add('ERROR: Target is not a Domain Controller. Aborting.')
        return [PSCustomObject]@{
            ComputerName = $env:COMPUTERNAME
            Lines        = $report.ToArray()
            Summary      = @{ Error = 'Not a DC' }
        }
    }
    if (-not (Get-Module -ListAvailable ActiveDirectory)) {
        $report.Add('ERROR: ActiveDirectory module not installed on target.')
        return [PSCustomObject]@{
            ComputerName = $env:COMPUTERNAME
            Lines        = $report.ToArray()
            Summary      = @{ Error = 'AD module missing' }
        }
    }
    Import-Module ActiveDirectory -ErrorAction Stop
    Import-Module DnsServer -ErrorAction SilentlyContinue
    Import-Module DFSR     -ErrorAction SilentlyContinue

    # --- System info --------------------------------------------------------
    Write-Section 'System Information'
    Invoke-Safe 'System Info' {
        $os = Get-CimInstance Win32_OperatingSystem
        $cs = Get-CimInstance Win32_ComputerSystem
        $fqdn = try { [System.Net.Dns]::GetHostByName($env:COMPUTERNAME).HostName } catch { $env:COMPUTERNAME }
        $uptimeSpan = New-TimeSpan -Start $os.LastBootUpTime
        $uptime = $uptimeSpan.ToString('d.hh:mm:ss')
        "Hostname:         $($cs.Name)"          | Write-Log
        "FQDN:             $fqdn"                | Write-Log
        "OS:               $($os.Caption) ($($os.Version))" | Write-Log
        "Last Boot:        $($os.LastBootUpTime)" | Write-Log
        "Uptime:           $uptime (d.hh:mm:ss)" | Write-Log
        "Install Date:     $($os.InstallDate)"   | Write-Log
        "Manufacturer:     $($cs.Manufacturer) / $($cs.Model)" | Write-Log
        "Logical CPUs:     $($cs.NumberOfLogicalProcessors)"   | Write-Log
        "Total RAM (GB):   $([math]::Round($cs.TotalPhysicalMemory / 1GB, 2))" | Write-Log
        $summary['OS']     = $os.Caption
        $summary['Uptime'] = $uptime
        $summary['UptimeDays'] = [math]::Round($uptimeSpan.TotalDays, 1)
    }

    # --- Pending reboot -----------------------------------------------------
    Write-Section 'Pending Reboot'
    Invoke-Safe 'Pending Reboot' {
        $cbs = Test-Path 'HKLM:SOFTWAREMicrosoftWindowsCurrentVersionComponent Based ServicingRebootPending'
        $wu  = Test-Path 'HKLM:SOFTWAREMicrosoftWindowsCurrentVersionWindowsUpdateAuto UpdateRebootRequired'
        $pfr = Get-ItemProperty 'HKLM:SYSTEMCurrentControlSetControlSession Manager' -Name PendingFileRenameOperations -ErrorAction SilentlyContinue
        "Component-Based Servicing reboot pending: $cbs" | Write-Log
        "Windows Update reboot pending:            $wu"  | Write-Log
        "Pending file rename operations:           $([bool]$pfr)" | Write-Log
        $summary['PendingReboot'] = ($cbs -or $wu -or [bool]$pfr)
    }

    # --- CPU / Memory -------------------------------------------------------
    Write-Section 'CPU and Memory'
    Invoke-Safe 'CPU/Memory' {
        $cpu = (Get-CimInstance Win32_Processor | Measure-Object LoadPercentage -Average).Average
        $os  = Get-CimInstance Win32_OperatingSystem
        $totalMB = [math]::Round($os.TotalVisibleMemorySize / 1024, 0)
        $freeMB  = [math]::Round($os.FreePhysicalMemory / 1024, 0)
        $usedMB  = $totalMB - $freeMB
        $pct     = if ($totalMB -gt 0) { [math]::Round(($usedMB / $totalMB) * 100, 1) } else { 0 }
        "CPU Load: $cpu%" | Write-Log
        "Memory:   $usedMB / $totalMB MB ($pct% used)" | Write-Log
        $summary['CpuPct'] = $cpu
        $summary['MemPct'] = $pct
    }

    # --- Disk space ---------------------------------------------------------
    Write-Section 'Disk Space'
    Invoke-Safe 'Disk Space' {
        $minFree = 100
        Get-CimInstance Win32_LogicalDisk -Filter 'DriveType=3' | ForEach-Object {
            $free = [math]::Round($_.FreeSpace / 1GB, 2)
            $tot  = [math]::Round($_.Size / 1GB, 2)
            $pct  = if ($tot -gt 0) { [math]::Round(($free / $tot) * 100, 1) } else { 0 }
            if ($pct -lt $minFree) { $minFree = $pct }
            $flag = if ($pct -lt 15) { '  *** LOW ***' } else { '' }
            "Drive $($_.DeviceID) Free: $free / $tot GB ($pct% free)$flag" | Write-Log
        }
        $summary['DiskMinFreePct'] = $minFree
    }

    # --- Essential services -------------------------------------------------
    Write-Section 'Essential Services'
    Invoke-Safe 'Services' {
        $services = @('NTDS','DNS','W32Time','Netlogon','KDC','DFSR','NTFRS',
                      'LanmanServer','LanmanWorkstation','EventLog','RpcSs',
                      'Dnscache','IsmServ','ADWS')
        $notRunning = 0
        foreach ($svc in $services) {
            $s = Get-Service -Name $svc -ErrorAction SilentlyContinue
            if ($s) {
                "{0,-22} {1,-10} (StartType: {2})" -f $s.Name, $s.Status, $s.StartType | Write-Log
                if ($s.Status -ne 'Running' -and $s.StartType -ne 'Disabled' -and $s.StartType -ne 'Manual') {
                    $notRunning++
                }
            } else {
                "{0,-22} not installed" -f $svc | Write-Log
            }
        }
        $summary['ServicesNotRunning'] = $notRunning
    }

    # --- AD database --------------------------------------------------------
    Write-Section 'AD Database (NTDS)'
    Invoke-Safe 'NTDS Files' {
        $params = Get-ItemProperty 'HKLM:SYSTEMCurrentControlSetServicesNTDSParameters' -ErrorAction Stop
        $ditPath = $params.'DSA Database file'
        "Database path: $ditPath" | Write-Log
        "Log path:      $($params.'Database log files path')" | Write-Log
        if (Test-Path $ditPath) {
            $dit = Get-Item $ditPath
            $sizeMB = [math]::Round($dit.Length / 1MB, 2)
            "ntds.dit size: $sizeMB MB (last modified $($dit.LastWriteTime))" | Write-Log
            $summary['NtdsDitMB'] = $sizeMB
        }
    }

    # --- FSMO roles + functional levels -------------------------------------
    Write-Section 'FSMO Role Holders'
    Invoke-Safe 'FSMO' {
        $forest = Get-ADForest
        $domain = Get-ADDomain
        "Forest:                $($forest.Name)"            | Write-Log
        "Forest Functional Lvl: $($forest.ForestMode)"      | Write-Log
        "Domain Functional Lvl: $($domain.DomainMode)"      | Write-Log
        "Schema Master:         $($forest.SchemaMaster)"    | Write-Log
        "Domain Naming Master:  $($forest.DomainNamingMaster)" | Write-Log
        "PDC Emulator:          $($domain.PDCEmulator)"     | Write-Log
        "RID Master:            $($domain.RIDMaster)"       | Write-Log
        "Infrastructure Master: $($domain.InfrastructureMaster)" | Write-Log
        $me = "$env:COMPUTERNAME.$($domain.DNSRoot)"
        $myRoles = @()
        if ($forest.SchemaMaster -ieq $me)        { $myRoles += 'Schema' }
        if ($forest.DomainNamingMaster -ieq $me)  { $myRoles += 'DomainNaming' }
        if ($domain.PDCEmulator -ieq $me)         { $myRoles += 'PDC' }
        if ($domain.RIDMaster -ieq $me)           { $myRoles += 'RID' }
        if ($domain.InfrastructureMaster -ieq $me){ $myRoles += 'Infra' }
        $summary['FsmoRolesHeld'] = if ($myRoles) { $myRoles -join ',' } else { 'none' }
    }

    # --- AD configuration ---------------------------------------------------
    Write-Section 'AD Configuration'
    Invoke-Safe 'AD Config' {
        $rb = Get-ADOptionalFeature -Filter 'Name -eq "Recycle Bin Feature"' -ErrorAction SilentlyContinue
        $rbEnabled = [bool]$rb.EnabledScopes
        "Recycle Bin Enabled: $rbEnabled" | Write-Log
        $summary['RecycleBin'] = $rbEnabled

        $rootDSE = Get-ADRootDSE
        $schema = Get-ADObject -Identity $rootDSE.schemaNamingContext -Properties objectVersion
        "Schema Version:      $($schema.objectVersion)" | Write-Log
        $summary['SchemaVersion'] = $schema.objectVersion

        $dirSvc = Get-ADObject -Identity "CN=Directory Service,CN=Windows NT,CN=Services,$($rootDSE.configurationNamingContext)" -Properties tombstoneLifetime -ErrorAction SilentlyContinue
        $tsl = if ($dirSvc.tombstoneLifetime) { $dirSvc.tombstoneLifetime } else { '60 (default)' }
        "Tombstone Lifetime:  $tsl days" | Write-Log
    }

    # --- Last AD backup -----------------------------------------------------
    Write-Section 'Last AD Backup (repadmin /showbackup)'
    Invoke-Safe 'Backup' {
        & repadmin /showbackup 2>&1 | Out-String | Write-Log
    }

    # --- Sites and subnets --------------------------------------------------
    Write-Section 'Sites and Subnets'
    Invoke-Safe 'Sites' {
        'Sites:' | Write-Log
        Get-ADReplicationSite -Filter * |
            Select-Object Name, Description |
            Format-Table -AutoSize | Out-String | Write-Log
        'Subnets:' | Write-Log
        Get-ADReplicationSubnet -Filter * |
            Select-Object Name, Site, Location |
            Format-Table -AutoSize | Out-String | Write-Log
    }

    # --- Replication --------------------------------------------------------
    Write-Section 'AD Replication Summary'
    Invoke-Safe 'repadmin /replsummary' {
        & repadmin /replsummary 2>&1 | Out-String | Write-Log
    }
    Invoke-Safe 'Replication Failures' {
        $f = Get-ADReplicationFailure -Target $env:COMPUTERNAME -ErrorAction Stop
        $count = ($f | Measure-Object).Count
        $summary['RepFailures'] = $count
        if ($f) {
            'Replication failures detected:' | Write-Log
            $f | Select-Object Server, Partner, FailureType, FailureCount, FirstFailureTime, LastError |
                Format-Table -AutoSize | Out-String | Write-Log
        } else {
            'No replication failures.' | Write-Log
        }
    }
    Invoke-Safe 'Replication Partners' {
        $p = Get-ADReplicationPartnerMetadata -Target $env:COMPUTERNAME -ErrorAction Stop
        $summary['RepPartners'] = ($p | Measure-Object).Count
        $p | Select-Object Partner, LastReplicationSuccess, LastReplicationResult, ConsecutiveReplicationFailures |
            Format-Table -AutoSize | Out-String | Write-Log
    }

    # --- SYSVOL / DFSR ------------------------------------------------------
    Write-Section 'SYSVOL and DFSR'
    Invoke-Safe 'SYSVOL/NETLOGON shares' {
        foreach ($share in 'SYSVOL','NETLOGON') {
            $path = "\$env:COMPUTERNAME$share"
            "$share share accessible: $(Test-Path $path)" | Write-Log
        }
    }
    Invoke-Safe 'DFSR Backlog (SYSVOL)' {
        if (Get-Command Get-DfsrBacklog -ErrorAction SilentlyContinue) {
            $partners = Get-ADReplicationPartnerMetadata -Target $env:COMPUTERNAME -ErrorAction SilentlyContinue
            if (-not $partners) {
                'No replication partners found (single DC?).' | Write-Log
                $summary['DfsrBacklogTotal'] = 0
                return
            }
            $totalBacklog = 0
            foreach ($p in $partners) {
                $partnerName = ($p.Partner -split ',')[0] -replace 'CN=',''
                try {
                    $b = Get-DfsrBacklog -GroupName 'Domain System Volume' -FolderName 'SYSVOL Share' -SourceComputerName $partnerName -DestinationComputerName $env:COMPUTERNAME -ErrorAction Stop -Verbose:$false 4>&1
                    $count = ($b | Where-Object { $_ -is [psobject] -and $_.FileName }).Count
                    $totalBacklog += $count
                    "Backlog from ${partnerName}: $count files" | Write-Log
                } catch {
                    "  Could not query backlog from ${partnerName}: $($_.Exception.Message)" | Write-Log
                }
            }
            $summary['DfsrBacklogTotal'] = $totalBacklog
        } else {
            'DFSR module not available - skipping backlog check.' | Write-Log
        }
    }

    # --- dcdiag battery -----------------------------------------------------
    Write-Section 'DCDiag Tests'
    $dcdiagTests = @('Connectivity','Advertising','FsmoCheck','MachineAccount',
                     'Services','Replications','SysVolCheck','NetLogons',
                     'RidManager','KccEvent','KnowsOfRoleHolders','LocatorCheck',
                     'ObjectsReplicated','SystemLog','DNS')
    $pass = 0; $fail = 0
    foreach ($t in $dcdiagTests) {
        Invoke-Safe "dcdiag $t" {
            $out = & dcdiag /test:$t 2>&1 | Out-String
            $verdict =
                if ($out -match 'passed test')      { 'PASS' }
                elseif ($out -match 'failed test')  { 'FAIL' }
                else                                { 'UNKNOWN' }
            "[$verdict] dcdiag /test:$t" | Write-Log
            if ($verdict -eq 'PASS')      { $script:pass++ }
            elseif ($verdict -eq 'FAIL')  { $script:fail++; $out | Write-Log }
            else                          { $out | Write-Log }
        }
    }
    $summary['DcdiagPass'] = $pass
    $summary['DcdiagFail'] = $fail

    # --- Default domain password policy -------------------------------------
    Write-Section 'Default Domain Password Policy'
    Invoke-Safe 'Password Policy' {
        Get-ADDefaultDomainPasswordPolicy | Format-List | Out-String | Write-Log
    }

    # --- Privileged group membership ----------------------------------------
    Write-Section 'Privileged Group Membership'
    foreach ($g in 'Domain Admins','Enterprise Admins','Schema Admins','Administrators','Account Operators','Backup Operators') {
        Invoke-Safe $g {
            $members = Get-ADGroupMember $g -Recursive -ErrorAction Stop |
                Select-Object Name, SamAccountName, objectClass
            $count = ($members | Measure-Object).Count
            "${g} ($count members):" | Write-Log
            $members | Format-Table -AutoSize | Out-String | Write-Log
        }
    }

    # --- Account hygiene ----------------------------------------------------
    Write-Section 'Account Hygiene'
    Invoke-Safe 'Stale users' {
        $cutoff = (Get-Date).AddDays(-$StaleUserDays)
        $stale = Get-ADUser -Filter { Enabled -eq $true -and LastLogonDate -lt $cutoff } -Properties LastLogonDate |
            Sort-Object LastLogonDate
        $count = ($stale | Measure-Object).Count
        "Enabled users with no logon in $StaleUserDays days: $count" | Write-Log
        $stale | Select-Object SamAccountName, LastLogonDate -First 25 |
            Format-Table -AutoSize | Out-String | Write-Log
        $summary['StaleUsers'] = $count
    }
    Invoke-Safe 'Locked-out accounts' {
        $locked = Search-ADAccount -LockedOut
        $count = ($locked | Measure-Object).Count
        "Currently locked-out accounts: $count" | Write-Log
        $locked | Select-Object SamAccountName, LockedOut |
            Format-Table -AutoSize | Out-String | Write-Log
        $summary['LockedAccounts'] = $count
    }
    Invoke-Safe 'Password-never-expires' {
        $nopwexp = Get-ADUser -Filter { PasswordNeverExpires -eq $true -and Enabled -eq $true }
        $count = ($nopwexp | Measure-Object).Count
        "Enabled accounts with PasswordNeverExpires=True: $count" | Write-Log
        $summary['PwNeverExpires'] = $count
    }

    # --- DNS server ---------------------------------------------------------
    Write-Section 'DNS Server'
    Invoke-Safe 'DNS Server Settings' {
        if (Get-Command Get-DnsServerSetting -ErrorAction SilentlyContinue) {
            $s = Get-DnsServerSetting -All
            "Recursion enabled: $($s.EnableRecursion)" | Write-Log
            "Listening IPs:     $($s.ListeningIPAddress -join ', ')" | Write-Log
        } else {
            'DnsServer module not available.' | Write-Log
        }
    }
    Invoke-Safe 'DNS Zones' {
        if (Get-Command Get-DnsServerZone -ErrorAction SilentlyContinue) {
            Get-DnsServerZone |
                Select-Object ZoneName, ZoneType, IsAutoCreated, IsDsIntegrated, IsReverseLookupZone |
                Format-Table -AutoSize | Out-String | Write-Log
        }
    }
    Invoke-Safe 'DNS Scavenging' {
        if (Get-Command Get-DnsServerScavenging -ErrorAction SilentlyContinue) {
            Get-DnsServerScavenging | Format-List | Out-String | Write-Log
        }
    }

    # --- DNS resolution sanity ---------------------------------------------
    Write-Section 'DNS Resolution Tests'
    $adDomain = (Get-ADDomain).DNSRoot
    foreach ($rec in @("_ldap._tcp.dc._msdcs.$adDomain","_kerberos._tcp.dc._msdcs.$adDomain","_gc._tcp.$adDomain")) {
        Invoke-Safe "Resolve $rec" {
            Resolve-DnsName $rec -Type SRV -ErrorAction Stop |
                Select-Object Name, NameTarget, Port, Priority, Weight |
                Format-Table -AutoSize | Out-String | Write-Log
        }
    }

    # --- Time sync ----------------------------------------------------------
    Write-Section 'Time Sync'
    Invoke-Safe 'w32tm status' { & w32tm /query /status 2>&1 | Out-String | Write-Log }
    Invoke-Safe 'w32tm source' {
        $src = (& w32tm /query /source 2>&1) -join ' '
        "Source: $src" | Write-Log
        $summary['TimeSource'] = $src.Trim()
    }
    Invoke-Safe 'w32tm peers' { & w32tm /query /peers 2>&1 | Out-String | Write-Log }

    # --- Secure channel -----------------------------------------------------
    Write-Section 'Secure Channel'
    Invoke-Safe 'SecureChannel' {
        $r = Test-ComputerSecureChannel -ErrorAction Stop
        "Result: $r" | Write-Log
        $summary['SecureChannel'] = if ($r) { 'PASS' } else { 'FAIL' }
    }
    if (-not $summary.Contains('SecureChannel')) { $summary['SecureChannel'] = 'ERROR' }

    # --- DC advertisement ---------------------------------------------------
    Write-Section 'DC Advertisement (nltest)'
    Invoke-Safe 'nltest /dsgetdc' { & nltest /dsgetdc:$adDomain 2>&1 | Out-String | Write-Log }
    Invoke-Safe 'nltest /dclist'  { & nltest /dclist:$adDomain  2>&1 | Out-String | Write-Log }

    # --- Listening ports ----------------------------------------------------
    Write-Section 'Listening Ports'
    Invoke-Safe 'Ports' {
        $expected = [ordered]@{
            53   = 'DNS'
            88   = 'Kerberos'
            135  = 'RPC Endpoint Mapper'
            389  = 'LDAP'
            445  = 'SMB'
            464  = 'Kerberos Password Change'
            636  = 'LDAPS'
            3268 = 'GC LDAP'
            3269 = 'GC LDAPS'
            9389 = 'AD Web Services'
        }
        $listening = (Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue).LocalPort | Sort-Object -Unique
        $missing = 0
        foreach ($port in $expected.Keys) {
            $status = if ($listening -contains $port) { 'OPEN' } else { 'NOT LISTENING'; $missing++ }
            "{0,-5} {1,-26} {2}" -f $port, $expected[$port], $status | Write-Log
        }
        $summary['PortsMissing'] = $missing
    }

    # --- LDAP latency -------------------------------------------------------
    Write-Section 'LDAP Response Time'
    Invoke-Safe 'LDAP probe' {
        $sw = [System.Diagnostics.Stopwatch]::StartNew()
        $null = Get-ADRootDSE
        $sw.Stop()
        "Get-ADRootDSE elapsed: $($sw.ElapsedMilliseconds) ms" | Write-Log
        $summary['LdapMs'] = $sw.ElapsedMilliseconds
    }

    # --- Recent hotfixes ----------------------------------------------------
    Write-Section "Recent Hotfixes (last $HotfixLookbackDays days)"
    Invoke-Safe 'Hotfixes' {
        $recent = Get-HotFix | Where-Object { $_.InstalledOn -gt (Get-Date).AddDays(-$HotfixLookbackDays) } |
            Sort-Object InstalledOn -Descending |
            Select-Object HotFixID, Description, InstalledOn
        $count = ($recent | Measure-Object).Count
        $summary['RecentHotfixes'] = $count
        if ($recent) {
            $recent | Format-Table -AutoSize | Out-String | Write-Log
        } else {
            "No hotfixes installed in the last $HotfixLookbackDays days." | Write-Log
        }
    }

    # --- Event logs (grouped) ----------------------------------------------
    Write-Section "Critical/Error Events (last $EventLookbackDays day(s))"
    $totalCrit = 0
    foreach ($log in 'System','Application','Directory Service','DNS Server','DFS Replication') {
        Invoke-Safe "Events: $log" {
            "--- $log ---" | Write-Log
            $events = Get-WinEvent -FilterHashtable @{
                LogName   = $log
                Level     = 1,2
                StartTime = (Get-Date).AddDays(-$EventLookbackDays)
            } -ErrorAction SilentlyContinue
            if ($events) {
                $script:totalCrit += ($events | Measure-Object).Count
                $events | Group-Object Id | Sort-Object Count -Descending |
                    Select-Object @{N='EventID';E={$_.Name}},
                                  Count,
                                  @{N='Source';E={$_.Group[0].ProviderName}},
                                  @{N='FirstLine';E={($_.Group[0].Message -split "`n")[0].Trim()}} |
                    Format-Table -AutoSize -Wrap | Out-String | Write-Log
            } else {
                'No critical/error events.' | Write-Log
            }
        }
    }
    $summary['CriticalEvents'] = $totalCrit

    return [PSCustomObject]@{
        ComputerName = $env:COMPUTERNAME
        Lines        = $report.ToArray()
        Summary      = $summary
    }
}

# ============================================================================
# Run against each DC
# ============================================================================
$results = @()
$localFqdn = "$env:COMPUTERNAME.$((Get-CimInstance Win32_ComputerSystem).Domain)"
foreach ($dc in $DomainControllers) {
    Write-Host "[$(Get-Date -Format HH:mm:ss)] Checking $dc ..." -ForegroundColor Yellow
    try {
        $isLocal = ($dc -ieq $env:COMPUTERNAME) -or ($dc -ieq $localFqdn)
        $r = if ($isLocal) {
            & $healthCheckBlock $EventLookbackDays $StaleUserDays $HotfixLookbackDays
        } else {
            Invoke-Command -ComputerName $dc -ScriptBlock $healthCheckBlock `
                -ArgumentList $EventLookbackDays, $StaleUserDays, $HotfixLookbackDays `
                -ErrorAction Stop
        }
        $shortName = ($dc -split '.')[0]
        $perDcFile = Join-Path $runDir "$shortName.txt"
        $r.Lines | Out-File -FilePath $perDcFile -Encoding utf8
        Write-Host "    Report: $perDcFile" -ForegroundColor Gray
        $results += [PSCustomObject]@{
            DC      = $shortName
            Summary = $r.Summary
        }
    } catch {
        Write-Host "    ERROR: $($_.Exception.Message)" -ForegroundColor Red
        $shortName = ($dc -split '.')[0]
        $results += [PSCustomObject]@{
            DC      = $shortName
            Summary = [ordered]@{ Error = $_.Exception.Message }
        }
    }
}

# ============================================================================
# Build comparison summary
# ============================================================================
$summaryFile = Join-Path $runDir '_SUMMARY.txt'
$summaryLines = New-Object System.Collections.Generic.List[string]
$summaryLines.Add("DC Health Check Summary - $timestamp")
$summaryLines.Add('================================================================')
$summaryLines.Add('')

# Comparison table: one column per DC, one row per metric
$metrics = @(
    'OS','UptimeDays','PendingReboot','CpuPct','MemPct','DiskMinFreePct',
    'ServicesNotRunning','NtdsDitMB','FsmoRolesHeld','RecycleBin',
    'SchemaVersion','RepFailures','RepPartners','DfsrBacklogTotal',
    'DcdiagPass','DcdiagFail','PortsMissing','LdapMs','SecureChannel',
    'StaleUsers','LockedAccounts','PwNeverExpires','RecentHotfixes',
    'CriticalEvents','TimeSource'
)

$dcNames = $results.DC
$header = ('{0,-22}' -f 'Metric')
foreach ($n in $dcNames) { $header += ('{0,-26}' -f $n) }
$summaryLines.Add($header)
$summaryLines.Add(('-' * $header.Length))

foreach ($m in $metrics) {
    $row = ('{0,-22}' -f $m)
    foreach ($r in $results) {
        $val = if ($r.Summary.Contains($m)) { $r.Summary[$m] } else { '' }
        $row += ('{0,-26}' -f ([string]$val).Substring(0, [Math]::Min(25, ([string]$val).Length)))
    }
    $summaryLines.Add($row)
}

# Errors
$errors = $results | Where-Object { $_.Summary.Contains('Error') }
if ($errors) {
    $summaryLines.Add('')
    $summaryLines.Add('Errors:')
    foreach ($e in $errors) { $summaryLines.Add("  $($e.DC): $($e.Summary['Error'])") }
}

$summaryLines | Out-File -FilePath $summaryFile -Encoding utf8

# Also dump structured JSON for downstream tooling
$jsonFile = Join-Path $runDir '_SUMMARY.json'
$results | ConvertTo-Json -Depth 4 | Out-File -FilePath $jsonFile -Encoding utf8

# ============================================================================
# Console summary
# ============================================================================
Write-Host ''
Write-Host '================================================================' -ForegroundColor Green
Write-Host '  Comparison Summary' -ForegroundColor Green
Write-Host '================================================================' -ForegroundColor Green
Get-Content $summaryFile | ForEach-Object { Write-Host $_ }
Write-Host ''
Write-Host "Per-DC reports + summary in: $runDir" -ForegroundColor GreenCode language: Bash (bash)