Public/Get-VBSwitchARP.ps1

function Get-VBSwitchARP {
<#
.SYNOPSIS
    Look up a device's MAC address, switch port, and port description via SNMP (Layer 10).
 
.DESCRIPTION
    On the first call within a session, walks the ARP, MAC-address, and interface
    alias tables on every switch in $Context.SwitchTargets and builds a script-scope
    hashtable keyed by IP. All subsequent calls are hashtable lookups with zero I/O.
 
    SNMP OIDs walked per switch:
        1.3.6.1.2.1.4.22.1.2 ipNetToMediaPhysAddress -- IP -> MAC (ARP table)
        1.3.6.1.2.1.17.4.3.1.2 dot1dTpFdbPort -- MAC -> bridge port number
        1.3.6.1.2.1.31.1.1.1.18 ifAlias -- port number -> description
 
    The COM object is always released in a finally block. If a switch times out or
    refuses the community string it is skipped with Write-Warning; remaining switches
    are still queried.
 
    Prerequisites:
        $Context.SNMPAvailable must be $true (olePrn COM available)
        $Context.SwitchTargets.Count -gt 0 (at least one switch IP configured)
 
.PARAMETER IPAddress
    The RFC1918 / CGNAT / link-local IP address to look up.
 
.PARAMETER Context
    Environment context from Get-VBEnrichmentContext. Provides SNMPAvailable,
    SNMPCommunityStrings, SwitchTargets, and DefaultTimeoutMs.SNMP.
 
.OUTPUTS
    [PSCustomObject] -- base layer result fields plus:
        MACAddress [string]
        SwitchIP [string]
        SwitchPort [string]
        PortDescription [string] ifAlias value -- used as Location
 
.EXAMPLE
    $ctx = Get-VBEnrichmentContext -SwitchTargets '192.168.1.254','192.168.2.254'
    Get-VBSwitchARP -IPAddress '192.168.1.45' -Context $ctx
 
.EXAMPLE
    '192.168.1.45' | Get-VBSwitchARP -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  = 10
        $LAYER_NAME = 'Switch'

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

        $communityStrings = if ($Context -and $Context.SNMPCommunityStrings.Count -gt 0) {
            @($Context.SNMPCommunityStrings | ForEach-Object {
                [Runtime.InteropServices.Marshal]::PtrToStringAuto(
                    [Runtime.InteropServices.Marshal]::SecureStringToBSTR($_))
            })
        } else { @('public') }

        $timeoutMs = if ($Context -and $Context.DefaultTimeoutMs) {
            $Context.DefaultTimeoutMs.SNMP
        } else { 2000 }

        $switchTargets = if ($Context) { $Context.SwitchTargets } else { @() }

        # One-shot cache: build ARP+port map from all switches on first call
        $CacheTTLMinutes = 60
        if ($null -ne $Script:VBSwitchARPCache) {
            $ageMin = ((Get-Date) - $Script:VBSwitchARPCacheBuiltAt).TotalMinutes
            if ($ageMin -gt $CacheTTLMinutes) {
                Write-Verbose "[$LAYER_NAME] Switch ARP cache is $([int]$ageMin) min old (TTL $CacheTTLMinutes min) -- rebuilding"
                $Script:VBSwitchARPCache      = $null
                $Script:VBSwitchARPCacheBuilt = $false
            }
        }

        if ($null -eq $Script:VBSwitchARPCache) {
            $Script:VBSwitchARPCache      = @{}
            $Script:VBSwitchARPCacheBuilt = $false

            $skipReasons = @()
            if ($Context -and -not $Context.SNMPAvailable) { $skipReasons += 'SNMPUnavailable' }
            if ($switchTargets.Count -eq 0)                { $skipReasons += 'NoSwitchTargets' }

            if ($skipReasons.Count -eq 0) {
                Write-Verbose "[$LAYER_NAME] Building switch ARP cache from $($switchTargets.Count) switch(es)..."
                foreach ($switchIP in $switchTargets) {
                    $switchEntries = Invoke-VBSwitchSNMPWalk -SwitchIP $switchIP `
                        -CommunityStrings $communityStrings -TimeoutMs $timeoutMs
                    foreach ($key in $switchEntries.Keys) {
                        if (-not $Script:VBSwitchARPCache.ContainsKey($key)) {
                            $Script:VBSwitchARPCache[$key] = $switchEntries[$key]
                        }
                    }
                }
                $Script:VBSwitchARPCacheBuilt   = $true
                $Script:VBSwitchARPCacheBuiltAt = Get-Date
                Write-Verbose "[$LAYER_NAME] Switch ARP cache built: $($Script:VBSwitchARPCache.Count) IP entries"
            }
        }
    }

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

        if ($Context -and -not $Context.SNMPAvailable) {
            $sw.Stop()
            return New-VBLayerResult -IPAddress $IPAddress -Layer $LAYER_NUM -LayerName $LAYER_NAME `
                -Status 'Skipped' -ExecutionMs $sw.ElapsedMilliseconds `
                -SkipReason 'SNMPUnavailable' `
                -Impact 'No switch port location -- unresolved IPs will lack physical location'
        }

        if ($switchTargets.Count -eq 0) {
            $sw.Stop()
            return New-VBLayerResult -IPAddress $IPAddress -Layer $LAYER_NUM -LayerName $LAYER_NAME `
                -Status 'Skipped' -ExecutionMs $sw.ElapsedMilliseconds `
                -SkipReason 'NoSwitchTargets' `
                -Impact 'No switch port location -- re-run Get-VBEnrichmentContext with -SwitchTargets'
        }

        try {
            $entry = $Script:VBSwitchARPCache[$IPAddress]

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

            $sw.Stop()
            New-VBLayerResult -IPAddress $IPAddress -Layer $LAYER_NUM -LayerName $LAYER_NAME `
                -Status 'Success' -ExecutionMs $sw.ElapsedMilliseconds `
                -ExtraFields @{
                    MACAddress      = $entry.MACAddress
                    SwitchIP        = $entry.SwitchIP
                    SwitchPort      = $entry.SwitchPort
                    PortDescription = $entry.PortDescription
                }
        }
        catch {
            $sw.Stop()
            New-VBLayerResult -IPAddress $IPAddress -Layer $LAYER_NUM -LayerName $LAYER_NAME `
                -Status 'Failed' -ExecutionMs $sw.ElapsedMilliseconds `
                -ErrorDetail $_.Exception.Message
        }
    }
}