Public/Get-ADAvailableDomainController.ps1

Function Get-ADAvailableDomainController() {
    <#
        .SYNOPSIS
            Find a Domain Controller using DNS that is available for PowerShell ActiveDirectory commands (using AD Web Services)
 
        .DESCRIPTION
            Rather than statically specifying a Domain Controller to use in a script this function will return a fully qualified domain name of an Active Directory Domain Controller that is able to response to PowerShell ActiveDirectory cmdlets (Get-ADUser, Get-ADDomain, etc). It does this by:
 
            - Performing a DNS lookup against the SRV record _ldap._tcp.dc._msdcs.<domain name> to find the fqdns of the Domain Controllers
            - Testing each domain controller by fqdn and check that a DefaultNamingContext is available
 
            Questions:
                Q - Why not just use the IP address to run cmdlets against?
                A - Exchange cmdlets don't allow use of an IP address in the parameter -DomainController and require the fqdn/server name
 
                Q - What does this function require to run?
                A - .Net 4.8 or above is required
 
                Q - What port is required for remote ActiveDirectory cmdlet operation?
                A - TCP port 389 and 9389 is required (and the Active Directory Web Services service has to be running and available)
 
        .PARAMETER DnsDomainName
            Mandatory. This string should specify the DNS domain (e.g. contoso.com) of the Active Directory domain
 
        .PARAMETER DomainCredential
            Optional. This parameter should be used when you require to use a credential to authenticate against Active Directory
 
        .EXAMPLE
            [string]$sAvailableDCName = Get-ADAvailableADDomainController -DnsDomainName "mydomain.com"
 
            # This example will attempt to discover a Domain Controller that is responding to ActiveDirectory module cmdlets using the current logged on users' credentials
 
        .EXAMPLE
            [PSCredential]$objADCredential = Get-Credential
            [string]$sAvailableDCName = Get-ADAvailableADDomainController -DnsDomainName "mydomain.com" -DomainCredential $objADCredential
 
            # This example will attempt to discover a Domain Controller that is responding to ActiveDirectory module cmdlets using credentials specified under the -DomainCredential parameter
 
        .NOTES
        MVogwell - Nov 2021 - Version 1.5
 
        Version history:
            1.0 - Initial tested release
            1.1 - Updated to use DNS query to return Domain Controller fqdn
            1.2 - Updated to use .net query for domain rather than the PowerShell ActiveDirectory module
            1.3 - Added more detail to error messages
            1.4 - Bug in previous version - sPwdClear was being cleared before the second attempt to access an available DC
            1.5 - Updated error messages returned if no DCs are avaialble. Also found Error[0] was not referenced as $Global:Error[0]
    #>


    [CmdletBinding()]
    param (
        [Parameter(Mandatory=$true)][string]$DnsDomainName,
        [Parameter(Mandatory=$false)][PSCredential]$DomainCredential
    )

    $ErrorActionPreference = "Stop"

    #@# Load the private function Get-ADDomainControllerDirectoryEntry
    $sPathADDomainControllerDirectoryEntry = ($PSScriptRoot).Replace("Public","Private") + "\Get-ADDomainControllerDirectoryEntry.ps1"

    Write-Verbose "Loading function: $sPathADDomainControllerDirectoryEntry"

    try {
        . $sPathADDomainControllerDirectoryEntry
    }
    catch {
        $sErrMsg = "Failed to load function Get-ADDomainControllerDirectoryEntry.ps1 from $sPathADDomainControllerDirectoryEntry - check the file is available."

        throw $sErrMsg
    }

    # Variable initialisation
    $sAvailableDcName = $null   # This is the return
    [System.Collections.ArrayList]$arrDcContactErrors = @()

    if (!($null -eq $DomainCredential)) {
        try {
            Write-Verbose "*** Extracting credential $($DomainCredential.UserName)"

            # Convert password to plain text -this is required for System.DirectoryServices.DirectoryEntry
            $BSTR = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($DomainCredential.Password)
            $sPwdClear = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($BSTR)

            if ([string]::IsNullOrEmpty($sPwdClear)) {
                throw "Password null or empty"
            }

            # Clear the BSTR value - no longer required
            $BSTR = $null

            Write-Verbose "`t+++ Success"
        }
        catch {
            $BSTR = $null
            $sPwdClear = $null

            $sThrowMsg = ("Failed to extract credentials - unable to continue. Error: " + ($Global:Error[0].Exception.Message).Replace("`n"," ").Replace("`r",""))

            throw $sThrowMsg
        }
    }


    # Perform a DNS lookup to get the IP addresses of domain controllers for the domain
    try {
        Write-Verbose "*** Discovering domain controller IP addresses for domain $DnsDomainName"

        $sDnsPath = ("_ldap._tcp.dc._msdcs." + $DnsDomainName)

        $arrAdDCs = Resolve-DnsName -Type SRV -Name $sDnsPath -ErrorAction SilentlyContinue | Where-Object {$_.Type -eq 'SRV'} | Select-Object -ExpandProperty NameTarget

        if ($null -eq $arrAdDCs) {
            throw "Failed to discover any Domain Controllers from the DNS lookup - null returned"
        }

        Write-Verbose "`t+++ Success"
    }
    catch {
        $sThrowMsg = ("Failed to perform DNS lookup on SRV record " + $sDnsPath + ". Failed to discover an available Domain Controller. Error: " + ($Global:Error[0].Exception.Message).Replace("`n"," ").Replace("`r",""))

        Write-Verbose "`t+++ $sThrowMsg"

        # Tidy up
        Remove-Variable DnsDomainName,DomainCredential,sPwdClear -ErrorAction "SilentlyContinue"

        throw $sThrowMsg

        # Exit point
    }

    # Loop through and test each of the DCs returned from the DNS lookup to find an available DC
    Write-Verbose "*** Testing access to Domain Controllers (by DNS Name):"

    foreach ($sReplicaDirectoryServer in $arrAdDCs) {
        try {
            # Set the path for the DirectoryEntry root
            $sDirectoryEntryPath = "LDAP://" + $sReplicaDirectoryServer + "/RootDSE"

            # Query the DC - use credentials if given in the params
            if ($null -eq $DomainCredential) {        # Test access without specifying a credential
                Write-Verbose "`tTesting $($sDirectoryEntryPath): "

                # Attempt to pull data from the DC
                $objDirectoryEntry = Get-ADDomainControllerDirectoryEntry -sDirectoryEntryPath $sDirectoryEntryPath
            }
            else {        # Test access using a credential
                Write-Verbose "`tTesting $($sDirectoryEntryPath) using Credential $($DomainCredential.UserName)"

                # Attempt to pull data from the DC using the credential
                $objDirectoryEntry = Get-ADDomainControllerDirectoryEntry -sDirectoryEntryPath $sDirectoryEntryPath -sUserName $DomainCredential.UserName -sPwdClear $sPwdClear
            }
        }
        catch {
            $sDcCheckErrMsg = $sReplicaDirectoryServer + ": Server not available " + ($Global:Error[0].Exception.Message).Replace("`n"," ").Replace("`r","")

            [void]$arrDcContactErrors.Add($sDcCheckErrMsg)
        }

        # Show the result to the user. Escape the loop on accessing a DC successfully
        if ($null -eq $objDirectoryEntry) {
            Write-Verbose "`t`t--- Server not available (null returned)"
        }
        elseif ((($objDirectoryEntry | Get-Member -MemberType Properties | Select-Object -ExpandProperty name) -contains "defaultNamingContext") -eq $false) {
            Write-Verbose "`t`t--- Server not available (defaultNamingContext not returned)"

            $sDcCheckErrMsg = $sReplicaDirectoryServer + ": Server not available (defaultNamingContext not returned probably caused by invalid credentials - please update the SeceretStore credentials for " + $DomainCredential.UserName + ")"

            [void]$arrDcContactErrors.Add($sDcCheckErrMsg)
        }
        else {
            $sDefNamingContext = $objDirectoryEntry.defaultNamingContext

            Write-Verbose "`t`t+++ Found responsive Domain Controller by FQDN (DefaultNamingContext = $($sDefNamingContext))"

            $sAvailableDcName = $sReplicaDirectoryServer

            # Clear the cleartext password
            $sPwdClear = $null

            # Exit the for loop as a working DC has been found
            break
        }

        $objDirectoryEntry = $null
        $sDirectoryEntryPath = $null
    } # End of: Loop through and test each of the DCs returned from the DNS lookup to find an available DC


    # If at least one DC hasn't been discovered then throw an error
    if ($null -eq $sAvailableDcName) {
        # Generate an error message including each of the error messages returned when attempting to access each DC in turn
        $sThrowMsg = "Failed to access to a Domain Controller for " + $DnsDomainName + ". The following errors were returned during DC tests: " + ($arrDcContactErrors -join " || ")

        # Tidy up
        Remove-Variable DnsDomainName,DomainCredential,sPwdClear -ErrorAction "SilentlyContinue"

        throw $sThrowMsg

        # Exit point
    }
    else {
        Write-Verbose "*** Returning Domain Controller DNS name: $sAvailableDcName"
    }

    # Tidy up
    Remove-Variable DnsDomainName,DomainCredential,sPwdClear -ErrorAction "SilentlyContinue"

    # return a string containing the DC DNS name
    return $sAvailableDcName
}