Public/Get-VBDHCPLease.ps1

function Get-VBDHCPLease {
<#
.SYNOPSIS
    Resolve an IP address to a DHCP lease record (Layer 2).
 
.DESCRIPTION
    On the first call within a session the function enumerates ALL active and
    inactive leases across every scope in $Context.DHCPScopeIds and loads them
    into a script-scope hashtable keyed by IP address. All subsequent calls
    perform a hashtable lookup -- no further DHCP server calls are made.
 
    If $Context.DHCPIsLocal is $true the function queries the local server
    (no -ComputerName). Otherwise it uses $Context.DHCPServer.
 
    Expired leases are still returned (Status = 'Success') with IsLeaseExpired
    set to $true so the orchestrator can flag DHCP churn risk.
 
    Prerequisites: $Context.DHCPAvailable must be $true.
 
.PARAMETER IPAddress
    The RFC1918 / CGNAT / link-local IP address to look up.
 
.PARAMETER Context
    Environment context object from Get-VBEnrichmentContext. Provides
    DHCPAvailable, DHCPIsLocal, DHCPServer, and DHCPScopeIds.
 
.OUTPUTS
    [PSCustomObject] -- base layer result fields plus:
        Hostname [string]
        MACAddress [string]
        LeaseExpiry [datetime]
        IsLeaseExpired [bool]
        ScopeId [string]
 
.EXAMPLE
    $ctx = Get-VBEnrichmentContext -DHCPServer 'dhcp01.corp.local'
    Get-VBDHCPLease -IPAddress '192.168.1.45' -Context $ctx
 
.EXAMPLE
    '192.168.1.45','192.168.1.46' | Get-VBDHCPLease -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  = 2
        $LAYER_NAME = 'DHCP'

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

        $CacheTTLMinutes = 60
        if ($null -ne $Script:VBDhcpLeaseCache) {
            $ageMin = ((Get-Date) - $Script:VBDhcpCacheBuiltAt).TotalMinutes
            if ($ageMin -gt $CacheTTLMinutes) {
                Write-Verbose "[$LAYER_NAME] DHCP cache is $([int]$ageMin) min old (TTL $CacheTTLMinutes min) -- rebuilding"
                $Script:VBDhcpLeaseCache = $null
                $Script:VBDhcpCacheBuilt = $false
            }
        }

        if ($null -eq $Script:VBDhcpLeaseCache) {
            if ($Context -and -not $Context.DHCPAvailable) {
                $Script:VBDhcpLeaseCache  = @{}
                $Script:VBDhcpCacheBuilt  = $false
            }
            else {
                try {
                    Write-Verbose "[$LAYER_NAME] Building DHCP lease cache (one-shot)..."

                    $serverSplat = @{}
                    if ($Context -and -not $Context.DHCPIsLocal -and $Context.DHCPServer) {
                        $serverSplat['ComputerName'] = $Context.DHCPServer
                    }

                    $scopeIds = @()
                    if ($Context -and $Context.DHCPScopeIds.Count -gt 0) {
                        $scopeIds = $Context.DHCPScopeIds
                    }
                    else {
                        # Discover all scopes if none were supplied
                        $scopeIds = @(
                            Get-DhcpServerv4Scope @serverSplat -ErrorAction Stop |
                                ForEach-Object { $_.ScopeId.ToString() }
                        )
                    }

                    $Script:VBDhcpLeaseCache = @{}
                    $failedScopes = [System.Collections.Generic.List[string]]::new()
                    foreach ($scopeId in $scopeIds) {
                        try {
                            $leases = Get-DhcpServerv4Lease -ScopeId $scopeId @serverSplat -ErrorAction Stop
                            foreach ($lease in $leases) {
                                if (-not [string]::IsNullOrWhiteSpace($lease.IPAddress)) {
                                    $Script:VBDhcpLeaseCache[$lease.IPAddress.ToString()] = $lease
                                }
                            }
                        }
                        catch {
                            $failedScopes.Add($scopeId)
                            Write-Warning "[$LAYER_NAME] Scope $scopeId failed to enumerate: $($_.Exception.Message)"
                        }
                    }
                    if ($failedScopes.Count -gt 0) {
                        Write-Warning "[$LAYER_NAME] $($failedScopes.Count) of $($scopeIds.Count) scopes failed. IPs in these scopes will not be DHCP-resolved: $($failedScopes -join ', ')"
                    }

                    $Script:VBDhcpCacheBuilt   = $true
                    $Script:VBDhcpCacheBuiltAt = Get-Date
                    Write-Verbose "[$LAYER_NAME] Cache built: $($Script:VBDhcpLeaseCache.Count) leases across $($scopeIds.Count) scope(s)"
                }
                catch {
                    Write-Warning "[$LAYER_NAME] DHCP cache build failed: $($_.Exception.Message)"
                    $Script:VBDhcpLeaseCache = @{}
                    $Script:VBDhcpCacheBuilt = $false
                }
            }
        }
    }

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

        if ($Context -and -not $Context.DHCPAvailable) {
            $sw.Stop()
            return New-VBLayerResult -IPAddress $IPAddress -Layer $LAYER_NUM -LayerName $LAYER_NAME `
                -Status 'Skipped' -ExecutionMs $sw.ElapsedMilliseconds `
                -SkipReason 'DHCPUnavailable' `
                -Impact 'No DHCP-derived hostname or MAC address'
        }

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

        try {
            $lease = $Script:VBDhcpLeaseCache[$IPAddress]

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

            $leaseExpiry   = $lease.LeaseExpiryTime
            $isExpired     = ($null -ne $leaseExpiry -and $leaseExpiry -lt (Get-Date))
            $macNormalised = ConvertTo-VBNormalisedMAC -MACAddress $lease.ClientId

            $sw.Stop()
            New-VBLayerResult -IPAddress $IPAddress -Layer $LAYER_NUM -LayerName $LAYER_NAME `
                -Status 'Success' -ExecutionMs $sw.ElapsedMilliseconds `
                -ExtraFields @{
                    Hostname       = $lease.HostName
                    MACAddress     = $lease.ClientId
                    MACNormalised  = $macNormalised
                    LeaseExpiry    = $leaseExpiry
                    IsLeaseExpired = $isExpired
                    ScopeId        = $lease.ScopeId.ToString()
                }
        }
        catch {
            $sw.Stop()
            New-VBLayerResult -IPAddress $IPAddress -Layer $LAYER_NUM -LayerName $LAYER_NAME `
                -Status 'Failed' -ExecutionMs $sw.ElapsedMilliseconds `
                -ErrorDetail $_.Exception.Message
        }
    }
}