Public/Get-VBADComputer.ps1

function Get-VBADComputer {
<#
.SYNOPSIS
    Resolve an IP address to an AD computer object (Layer 1).
 
.DESCRIPTION
    Queries Active Directory for a computer whose IPv4Address matches the supplied IP.
    On the first call within a session the function loads ALL computer objects from AD
    into a script-scope hashtable keyed by IPv4Address. All subsequent calls hit that
    hashtable -- never one LDAP query per IP.
 
    DC detection is also cached on first call from Get-ADDomainController.
 
    Prerequisites: $Context.ADAvailable must be $true. If context is not supplied the
    function emits Write-Warning and attempts the query anyway.
 
.PARAMETER IPAddress
    The RFC1918 / CGNAT / link-local IP address to look up.
 
.PARAMETER Context
    Environment context object from Get-VBEnrichmentContext. Provides the ADAvailable
    flag. If omitted the function runs without prerequisite gating.
 
.OUTPUTS
    [PSCustomObject] -- base layer result fields plus:
        Hostname [string]
        OperatingSystem [string]
        OSClass [string] Workstation | Server | DomainController
        OU [string]
        DistinguishedName [string]
 
.EXAMPLE
    $ctx = Get-VBEnrichmentContext
    Get-VBADComputer -IPAddress '192.168.1.45' -Context $ctx
 
.EXAMPLE
    '192.168.1.45','192.168.1.46' | Get-VBADComputer -Context $ctx
 
.NOTES
    Version: 1.0.0
    MinPSVersion: 5.1
    Author: VB
    ChangeLog:
        1.0.0 -- 2026-05-11 -- Initial release
#>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        [string]$IPAddress,

        [Parameter()]
        [PSCustomObject]$Context
    )

    begin {
        $LAYER_NUM  = 1
        $LAYER_NAME = 'AD'

        if (-not $Context) {
            Write-Warning "[$LAYER_NAME] No context provided -- running without prerequisite validation."
        }

        # One-shot cache: build on first call, reused for the lifetime of the session
        $CacheTTLMinutes = 60
        if ($null -ne $Script:VBAdComputerCache) {
            $ageMin = ((Get-Date) - $Script:VBAdCacheBuiltAt).TotalMinutes
            if ($ageMin -gt $CacheTTLMinutes) {
                Write-Verbose "[$LAYER_NAME] AD cache is $([int]$ageMin) min old (TTL $CacheTTLMinutes min) -- rebuilding"
                $Script:VBAdComputerCache = $null
                $Script:VBAdCacheBuilt    = $false
            }
        }

        if ($null -eq $Script:VBAdComputerCache) {
            if ($Context -and -not $Context.ADAvailable) {
                # AD unavailable -- leave cache as sentinel so we skip cleanly each call
                $Script:VBAdComputerCache = @{}
                $Script:VBAdCacheBuilt    = $false
            }
            else {
                try {
                    Write-Verbose "[$LAYER_NAME] Building AD computer cache (one-shot)..."
                    $allComputers = Get-ADComputer -Filter * -Properties IPv4Address, OperatingSystem,
                        OperatingSystemVersion, DistinguishedName -ErrorAction Stop

                    $Script:VBAdComputerCache = @{}
                    foreach ($comp in $allComputers) {
                        if (-not [string]::IsNullOrWhiteSpace($comp.IPv4Address)) {
                            $Script:VBAdComputerCache[$comp.IPv4Address] = $comp
                        }
                    }

                    # Cache DC IPs separately
                    $Script:VBAdDCIPs = @{}
                    $dcList = Get-ADDomainController -Filter * -ErrorAction Stop
                    foreach ($dc in $dcList) {
                        if (-not [string]::IsNullOrWhiteSpace($dc.IPv4Address)) {
                            $Script:VBAdDCIPs[$dc.IPv4Address] = $true
                        }
                    }

                    $Script:VBAdCacheBuilt   = $true
                    $Script:VBAdCacheBuiltAt = Get-Date
                    Write-Verbose "[$LAYER_NAME] Cache built: $($Script:VBAdComputerCache.Count) computers, $($Script:VBAdDCIPs.Count) DCs"
                }
                catch {
                    Write-Warning "[$LAYER_NAME] AD cache build failed: $($_.Exception.Message)"
                    $Script:VBAdComputerCache = @{}
                    $Script:VBAdCacheBuilt    = $false
                }
            }
        }
    }

    process {
        $sw = [System.Diagnostics.Stopwatch]::StartNew()

        # Skip if AD unavailable and cache build was skipped
        if ($Context -and -not $Context.ADAvailable) {
            $sw.Stop()
            return New-VBLayerResult -IPAddress $IPAddress -Layer $LAYER_NUM -LayerName $LAYER_NAME `
                -Status 'Skipped' -ExecutionMs $sw.ElapsedMilliseconds `
                -SkipReason 'ADUnavailable' `
                -Impact 'Cannot resolve domain-joined hosts via AD'
        }

        if (-not $Script:VBAdCacheBuilt) {
            $sw.Stop()
            return New-VBLayerResult -IPAddress $IPAddress -Layer $LAYER_NUM -LayerName $LAYER_NAME `
                -Status 'Skipped' -ExecutionMs $sw.ElapsedMilliseconds `
                -SkipReason 'ADCacheBuildFailed' `
                -Impact 'AD cache could not be built -- check AD connectivity'
        }

        try {
            $comp = $Script:VBAdComputerCache[$IPAddress]

            if ($null -eq $comp) {
                $sw.Stop()
                return New-VBLayerResult -IPAddress $IPAddress -Layer $LAYER_NUM -LayerName $LAYER_NAME `
                    -Status 'NoResult' -ExecutionMs $sw.ElapsedMilliseconds
            }

            # Determine OSClass
            $osClass = 'Workstation'
            if ($Script:VBAdDCIPs.ContainsKey($IPAddress)) {
                $osClass = 'DomainController'
            }
            elseif ($comp.OperatingSystem -match 'Server') {
                $osClass = 'Server'
            }

            # Extract OU from DistinguishedName -- everything after the first CN= component
            $ou = $null
            if ($comp.DistinguishedName -match '^CN=[^,]+,(.+)$') {
                $ou = $Matches[1]
            }

            $sw.Stop()
            New-VBLayerResult -IPAddress $IPAddress -Layer $LAYER_NUM -LayerName $LAYER_NAME `
                -Status 'Success' -ExecutionMs $sw.ElapsedMilliseconds `
                -ExtraFields @{
                    Hostname          = $comp.DNSHostName
                    OperatingSystem   = $comp.OperatingSystem
                    OSClass           = $osClass
                    OU                = $ou
                    DistinguishedName = $comp.DistinguishedName
                }
        }
        catch {
            $sw.Stop()
            New-VBLayerResult -IPAddress $IPAddress -Layer $LAYER_NUM -LayerName $LAYER_NAME `
                -Status 'Failed' -ExecutionMs $sw.ElapsedMilliseconds `
                -ErrorDetail $_.Exception.Message
        }
    }
}