Public/healthcheck/Get-AdDomainControllerHealth.ps1

#Requires -Version 5.1
function Get-AdDomainControllerHealth {
    <#
        .SYNOPSIS
            Checks Active Directory Domain Services health on a Domain Controller
 
        .DESCRIPTION
            Performs comprehensive AD DS health diagnostics on one or more Domain Controllers.
            Verifies the executing account has sufficient privileges (local Administrator plus
            Domain Admins or Enterprise Admins membership) before running diagnostics.
            Checks NTDS service status, replication health via repadmin, dcdiag test results,
            SYSVOL and NETLOGON share accessibility, and returns a typed health object per DC.
            If privileges are insufficient, OverallHealth is set to 'InsufficientPrivilege'
            and partial results are still returned where possible.
 
        .PARAMETER ComputerName
            One or more computer names to query. Defaults to the local machine.
            Accepts pipeline input by value and by property name.
 
        .PARAMETER Credential
            Optional PSCredential for authenticating to remote computers.
            Not used for local queries.
 
        .EXAMPLE
            Get-AdDomainControllerHealth
 
            Checks AD DS health on the local Domain Controller.
 
        .EXAMPLE
            Get-AdDomainControllerHealth -ComputerName 'DC01' -Credential (Get-Credential)
 
            Checks AD DS health on a remote Domain Controller using explicit credentials.
 
        .EXAMPLE
            'DC01', 'DC02' | Get-AdDomainControllerHealth
 
            Checks AD DS health on multiple Domain Controllers via pipeline input.
 
        .OUTPUTS
            PSWinOps.AdDomainControllerHealth
            Returns one object per queried Domain Controller with service status,
            privilege validation, replication counters, dcdiag results, share
            accessibility, and overall health.
 
        .NOTES
            Author: Franck SALLET
            Version: 1.1.0
            Last Modified: 2026-03-26
            Requires: PowerShell 5.1+ / Windows only
            Requires: AD-Domain-Services role
            Requires: Local Administrator + Domain Admins or Enterprise Admins
 
        .LINK
            https://github.com/k9fr4n/PSWinOps
 
        .LINK
            https://learn.microsoft.com/en-us/powershell/module/activedirectory/
    #>

    [CmdletBinding()]
    [OutputType('PSWinOps.AdDomainControllerHealth')]
    param(
        [Parameter(Mandatory = $false, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [ValidateNotNullOrEmpty()]
        [Alias('CN', 'Name', 'DNSHostName')]
        [string[]]$ComputerName = $env:COMPUTERNAME,

        [Parameter(Mandatory = $false)]
        [ValidateNotNull()]
        [System.Management.Automation.PSCredential]
        [System.Management.Automation.Credential()]
        $Credential = [System.Management.Automation.PSCredential]::Empty
    )

    begin {
        Write-Verbose -Message "[$($MyInvocation.MyCommand)] Starting"
        $localNames = @($env:COMPUTERNAME, 'localhost', '.')

        $scriptBlock = {
            $data = @{
                ServiceStatus         = 'NotFound'
                ADModuleAvailable     = $false
                RunAsAccount          = $null
                IsLocalAdmin          = $false
                IsDomainAdmin         = $false
                HasRequiredPrivileges = $false
                DCName                = $null
                DomainName            = $null
                ForestName            = $null
                DomainMode            = $null
                SiteName              = $null
                IsGlobalCatalog       = $false
                IsReadOnly            = $false
                OperatingSystem       = $null
                SysvolAccessible      = $false
                NetlogonAccessible    = $false
                ReplicationSuccesses  = 0
                ReplicationFailures   = 0
                DcDiagPassedTests     = 0
                DcDiagFailedTests     = 0
            }

            # ---------------------------------------------------------------
            # 0. Privilege check
            # ---------------------------------------------------------------
            $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent()
            $principal = [System.Security.Principal.WindowsPrincipal]::new($identity)
            $data['RunAsAccount'] = $identity.Name

            # Local Administrator check
            $data['IsLocalAdmin'] = $principal.IsInRole(
                [System.Security.Principal.WindowsBuiltInRole]::Administrator
            )

            # Domain Admins (RID -512) or Enterprise Admins (RID -519)
            foreach ($group in $identity.Groups) {
                if ($group.Value -match '-512$|-519') {
                    $data['IsDomainAdmin'] = $true
                    break
                }
            }

            $data['HasRequiredPrivileges'] = $data['IsLocalAdmin'] -and $data['IsDomainAdmin']

            if (-not $data['HasRequiredPrivileges']) {
                $missing = [System.Collections.Generic.List[string]]::new()
                if (-not $data['IsLocalAdmin']) {
                    $missing.Add('Local Administrator')
                }
                if (-not $data['IsDomainAdmin']) {
                    $missing.Add('Domain Admins or Enterprise Admins')
                }
                Write-Warning -Message (
                    "Insufficient privileges for full AD DS diagnostics on $env:COMPUTERNAME. " +
                    "Missing: $($missing -join ', '). " +
                    "Account: $($data['RunAsAccount']). " +
                    'Results may be incomplete (repadmin/dcdiag require Domain Admin + local admin).'
                )
            }

            # ---------------------------------------------------------------
            # 1. NTDS service status
            # ---------------------------------------------------------------
            $ntdsSvc = Get-Service -Name 'NTDS' -ErrorAction SilentlyContinue
            if ($ntdsSvc) {
                $data['ServiceStatus'] = $ntdsSvc.Status.ToString()
            }

            # ---------------------------------------------------------------
            # 2. ActiveDirectory module availability
            # ---------------------------------------------------------------
            $adModule = Get-Module -Name 'ActiveDirectory' -ListAvailable -ErrorAction SilentlyContinue
            if ($adModule) {
                $data['ADModuleAvailable'] = $true
            }

            if (-not $data['ADModuleAvailable']) {
                return $data
            }

            Import-Module -Name 'ActiveDirectory' -ErrorAction Stop

            # ---------------------------------------------------------------
            # 3a. Get-ADDomainController
            # ---------------------------------------------------------------
            try {
                $dcInfo = Get-ADDomainController -Identity $env:COMPUTERNAME -ErrorAction Stop
                $data['DCName'] = $dcInfo.HostName
                $data['SiteName'] = $dcInfo.Site
                $data['IsGlobalCatalog'] = $dcInfo.IsGlobalCatalog
                $data['IsReadOnly'] = $dcInfo.IsReadOnly
                $data['OperatingSystem'] = $dcInfo.OperatingSystem
            } catch {
                Write-Warning -Message "Get-ADDomainController failed: $_"
            }

            # ---------------------------------------------------------------
            # 3b. Get-ADDomain
            # ---------------------------------------------------------------
            try {
                $domainInfo = Get-ADDomain -ErrorAction Stop
                $data['DomainName'] = $domainInfo.DNSRoot
                $data['ForestName'] = $domainInfo.Forest
                $data['DomainMode'] = $domainInfo.DomainMode.ToString()
            } catch {
                Write-Warning -Message "Get-ADDomain failed: $_"
            }

            # ---------------------------------------------------------------
            # 3c. repadmin /showrepl
            # ---------------------------------------------------------------
            $repadminPath = Get-Command -Name 'repadmin' -ErrorAction SilentlyContinue
            if ($repadminPath) {
                try {
                    $replOutput = & repadmin /showrepl 2>&1
                    $replText = $replOutput | Out-String
                    $successCount = ([regex]::Matches($replText, 'successful', 'IgnoreCase')).Count
                    $failCount = ([regex]::Matches($replText, 'failed', 'IgnoreCase')).Count
                    $data['ReplicationSuccesses'] = $successCount
                    $data['ReplicationFailures'] = $failCount
                } catch {
                    $data['ReplicationSuccesses'] = -1
                    $data['ReplicationFailures'] = -1
                }
            } else {
                $data['ReplicationSuccesses'] = -1
                $data['ReplicationFailures'] = -1
            }

            # ---------------------------------------------------------------
            # 3d. dcdiag /q
            # ---------------------------------------------------------------
            $dcdiagPath = Get-Command -Name 'dcdiag' -ErrorAction SilentlyContinue
            if ($dcdiagPath) {
                try {
                    $dcdiagOutput = & dcdiag /s:$env:COMPUTERNAME /test:Connectivity /test:Replications /test:Services /test:Advertising /test:FsmoCheck 2>&1
                    $dcdiagText = $dcdiagOutput | Out-String
                    $passedCount = ([regex]::Matches($dcdiagText, 'passed test', 'IgnoreCase')).Count
                    $failedCount = ([regex]::Matches($dcdiagText, 'failed test', 'IgnoreCase')).Count
                    $data['DcDiagPassedTests'] = $passedCount
                    $data['DcDiagFailedTests'] = $failedCount
                } catch {
                    $data['DcDiagPassedTests'] = -1
                    $data['DcDiagFailedTests'] = -1
                }
            } else {
                $data['DcDiagPassedTests'] = -1
                $data['DcDiagFailedTests'] = -1
            }

            # ---------------------------------------------------------------
            # 3e-f. SYSVOL and NETLOGON share accessibility
            # ---------------------------------------------------------------
            $sysvolPath = "\\$env:COMPUTERNAME\SYSVOL"
            $netlogonPath = "\\$env:COMPUTERNAME\NETLOGON"
            $data['SysvolAccessible'] = Test-Path -Path $sysvolPath
            $data['NetlogonAccessible'] = Test-Path -Path $netlogonPath

            return $data
        }
    }

    process {
        foreach ($machine in $ComputerName) {
            $displayName = $machine.ToUpper()
            Write-Verbose -Message "[$($MyInvocation.MyCommand)] Querying '${machine}'"

            try {
                $isLocal = $localNames -contains $machine

                if ($isLocal) {
                    $result = & $scriptBlock
                } else {
                    $invokeParams = @{
                        ComputerName = $machine
                        ScriptBlock  = $scriptBlock
                        ErrorAction  = 'Stop'
                    }
                    if ($Credential -ne [System.Management.Automation.PSCredential]::Empty) {
                        $invokeParams['Credential'] = $Credential
                    }
                    $result = Invoke-Command @invokeParams
                }

                # Compute OverallHealth outside the scriptblock
                if (-not $result['ADModuleAvailable']) {
                    $healthStatus = 'RoleUnavailable'
                } elseif (-not $result['HasRequiredPrivileges']) {
                    $healthStatus = 'InsufficientPrivilege'
                } elseif ($result['ServiceStatus'] -ne 'Running' -or
                    $result['ReplicationFailures'] -gt 0 -or
                    $result['DcDiagFailedTests'] -gt 0) {
                    $healthStatus = 'Critical'
                } elseif (-not $result['SysvolAccessible'] -or
                    -not $result['NetlogonAccessible']) {
                    $healthStatus = 'Degraded'
                } else {
                    $healthStatus = 'Healthy'
                }

                [PSCustomObject]@{
                    PSTypeName            = 'PSWinOps.AdDomainControllerHealth'
                    ComputerName          = $displayName
                    ServiceName           = 'NTDS'
                    ServiceStatus         = $result['ServiceStatus']
                    RunAsAccount          = $result['RunAsAccount']
                    HasRequiredPrivileges = [bool]$result['HasRequiredPrivileges']
                    DomainName            = $result['DomainName']
                    ForestName            = $result['ForestName']
                    SiteName              = $result['SiteName']
                    IsGlobalCatalog       = $result['IsGlobalCatalog']
                    IsReadOnly            = $result['IsReadOnly']
                    SysvolAccessible      = $result['SysvolAccessible']
                    NetlogonAccessible    = $result['NetlogonAccessible']
                    ReplicationSuccesses  = $result['ReplicationSuccesses']
                    ReplicationFailures   = $result['ReplicationFailures']
                    DcDiagPassedTests     = $result['DcDiagPassedTests']
                    DcDiagFailedTests     = $result['DcDiagFailedTests']
                    OverallHealth         = $healthStatus
                    Timestamp             = Get-Date -Format 'o'
                }
            } catch {
                Write-Error -Message "[$($MyInvocation.MyCommand)] Failed on '${machine}': $_"
                continue
            }
        }
    }

    end {
        Write-Verbose -Message "[$($MyInvocation.MyCommand)] Completed"
    }
}