Private/DomainLookupHelpers.ps1

# LDAP and DC-locator access for Get-NSPADDomainInfo / Get-NSPADComputer. No AD module, no AD Web
# Services: the DC locator (DsGetDcName, the same lookup Windows logon uses) and plain LDAP. Tests
# mock these two functions.

function Find-NSPADDomainController {
    # A responsive DC: -Server is checked and returned as the DC's own name; otherwise the locator
    # picks one for this computer's domain, forcing rediscovery so a DC that went bad since the
    # last lookup isn't reused from cache.
    [CmdletBinding(DefaultParameterSetName = 'Server')]
    param(
        [Parameter(ParameterSetName = 'Server')][string]$Server,
        [Parameter(Mandatory, ParameterSetName = 'Domain')][string]$DomainName
    )
    if ($Server) {
        $context = [System.DirectoryServices.ActiveDirectory.DirectoryContext]::new('DirectoryServer', $Server)
        return [System.DirectoryServices.ActiveDirectory.DomainController]::GetDomainController($context).Name
    }
    $domain = if ($DomainName) {
        $context = [System.DirectoryServices.ActiveDirectory.DirectoryContext]::new('Domain', $DomainName)
        [System.DirectoryServices.ActiveDirectory.Domain]::GetDomain($context)
    } else {
        [System.DirectoryServices.ActiveDirectory.Domain]::GetComputerDomain()
    }
    $domain.FindDomainController([System.DirectoryServices.ActiveDirectory.LocatorOptions]::ForceRediscovery).Name
}

function Read-NSPADRootDse {
    # The DC's RootDSE naming contexts. RootDSE is read as an entry, not searched: a
    # DirectorySearcher rooted at RootDSE fails ("the provider does not support searching").
    param([Parameter(Mandatory)][string]$Server)
    $rootDse = [ADSI]"LDAP://$Server/RootDSE"
    @{
        defaultnamingcontext       = [string]$rootDse.defaultNamingContext[0]
        configurationnamingcontext = [string]$rootDse.configurationNamingContext[0]
    }
}

function New-NSPADLdapEntry {
    # Separate constructors let offline tests exercise the real search/lifetime logic.
    param([string]$Server, [string]$SearchBase)
    [ADSI]"LDAP://$Server/$($SearchBase -replace '/', '\/')"
}

function New-NSPADLdapSearcher {
    param($Root, [string]$Filter)
    New-Object System.DirectoryServices.DirectorySearcher($Root, $Filter)
}

function Read-NSPADLdap {
    # One LDAP search against one DC. Returns one hashtable per result: lower-case property name ->
    # first value (byte arrays such as objectSid stay byte arrays), or every value for the names in
    # -MultiValued (an array). -Scope Base reads one object (constructed attributes such as
    # tokenGroups only come back from a Base read).
    param(
        [Parameter(Mandatory)][string]$Server,
        [Parameter(Mandatory)][string]$SearchBase,
        [string]$Filter = '(objectClass=*)',
        [Parameter(Mandatory)][string[]]$Property,
        [ValidateSet('Base', 'OneLevel', 'Subtree')][string]$Scope = 'Subtree',
        [string[]]$MultiValued = @(),
        [ValidateRange(1, 1000)][int]$PageSize = 500,
        [ValidateRange(0, 2147483647)][int]$SizeLimit = 0,
        [ValidateScript({ $_ -ge [timespan]::Zero -and $_.TotalSeconds -le [int]::MaxValue })]
        [timespan]$ServerTimeLimit = [timespan]::Zero
    )
    $root = $null
    $searcher = $null
    $results = $null
    try {
        $root = New-NSPADLdapEntry -Server $Server -SearchBase $SearchBase
        $searcher = New-NSPADLdapSearcher -Root $root -Filter $Filter
        $searcher.SearchScope = $Scope
        $searcher.PageSize = $PageSize
        $searcher.SizeLimit = $SizeLimit
        $searcher.ServerTimeLimit = $ServerTimeLimit
        # A domain search must stay in its selected naming context. Forest reads enumerate
        # domains explicitly instead of silently following referrals across environments.
        $searcher.ReferralChasing = [System.DirectoryServices.ReferralChasingOption]::None
        foreach ($name in $Property) { [void]$searcher.PropertiesToLoad.Add($name) }
        $multi = @($MultiValued | ForEach-Object { $_.ToLowerInvariant() })
        $results = $searcher.FindAll()
        foreach ($result in $results) {
            $item = @{}
            foreach ($name in $result.Properties.PropertyNames) {
                $key = $name.ToLowerInvariant()
                $item[$key] = if ($multi -contains $key) { , @($result.Properties[$name]) } else { , $result.Properties[$name][0] }
            }
            $item
        }
    } finally {
        if ($null -ne $results) { $results.Dispose() }
        if ($null -ne $searcher) { $searcher.Dispose() }
        if ($null -ne $root) { $root.Dispose() }
    }
}