modules/DHCP/DHCP.psm1

<#
    DHCP.psm1 - DHCP Server role discovery (read-only). Role-gated.
#>


function Get-DiscoveryModuleMetadata {
    [pscustomobject]@{
        ModuleName='DHCP'; DisplayName='DHCP Server'; Category='Identity'; Version='1.0.0'
        DefaultInFast=$true; DefaultInDeep=$true; RequiresAdmin=$false; RequiresDomainContext=$false
        RequiresRole='DHCP'; EstimatedImpact='Low'; CanRunAsSystem=$true
        ProducesDatasets=@('DhcpScopes','DhcpReservations','DhcpOptions','DhcpFailover','DhcpServerSummary')
        # ProducesRisks is $false, not a placeholder: this module's own datasets (scope/
        # reservation lists) are informational, not risk-scored, and no risk-rules.json rule
        # references them. The real "server hosts the DHCP role" risk finding already exists
        # (RULE-DHCP-001) and is deliberately sourced from RolesFeatures instead, since that
        # module runs on every server (this one is role-gated and simply never runs on the
        # other 90%+ of a fleet).
        ProducesRisks=$false; ProducesFollowUpQuestions=$false; SupportsDeepMode=$true; SupportsComplianceLens=$false
    }
}

function Test-DhcpPresent {
    if (Get-CommandAvailable -Name 'Get-DhcpServerv4Scope') { return $true }
    if (Get-Service -Name 'DHCPServer' -ErrorAction SilentlyContinue) { return $true }
    return $false
}

function Test-DiscoveryPrerequisites {
    param([object]$Context)
    if (Test-DhcpPresent) { return [pscustomobject]@{ ModuleName='DHCP'; CanRun=$true; Status='Ready'; Reason=''; Limitations=@() } }
    [pscustomobject]@{ ModuleName='DHCP'; CanRun=$false; Status='NotApplicable'; Reason='DHCP Server role not present.'; Limitations=@() }
}

function Invoke-DiscoveryCollection {
    param([object]$Context)
    # Default cmdlets resolve $env:COMPUTERNAME through DNS on every call (~1 s each; 20-30x slower than 'localhost'), and a resolution failure is swallowed by -ErrorAction SilentlyContinue as an empty result.
    $PSDefaultParameterValues = @{ 'Get-Dhcp*:ComputerName' = 'localhost' }
    $scopes=[System.Collections.Generic.List[object]]::new(); $res=[System.Collections.Generic.List[object]]::new()
    $opts=[System.Collections.Generic.List[object]]::new(); $fo=[System.Collections.Generic.List[object]]::new(); $srv=[System.Collections.Generic.List[object]]::new()
    if (-not (Get-CommandAvailable -Name 'Get-DhcpServerv4Scope')) { Add-Limitation -Context $Context -Module 'DHCP' -Message 'DhcpServer module not available; DHCP present but not enumerable.' | Out-Null; return ,@{ DhcpScopes=@(); DhcpReservations=@(); DhcpOptions=@(); DhcpFailover=@(); DhcpServerSummary=@() } }
    $svc = Get-Service -Name 'DHCPServer' -ErrorAction SilentlyContinue
    try {
        foreach ($s in (Get-DhcpServerv4Scope -ErrorAction SilentlyContinue)) {
            # Lease COUNTS only: individual leases carry client host names and MAC addresses.
            $st = $null; try { $st = Get-DhcpServerv4ScopeStatistics -ScopeId $s.ScopeId -ErrorAction Stop } catch { }
            $scopes.Add([pscustomobject]@{ ScopeId=[string]$s.ScopeId; Name=$s.Name; StartRange=[string]$s.StartRange; EndRange=[string]$s.EndRange; SubnetMask=[string]$s.SubnetMask; State=[string]$s.State; LeaseDuration=[string]$s.LeaseDuration
                LeasesInUse=$(if ($st) { [int]$st.InUse } else { $null }); AddressesFree=$(if ($st) { [int]$st.Free } else { $null }); PercentInUse=$(if ($st) { [double]$st.PercentageInUse } else { $null }) })
            try { foreach ($o in (Get-DhcpServerv4OptionValue -ScopeId $s.ScopeId -ErrorAction Stop)) { $opts.Add([pscustomobject]@{ Level='Scope'; ScopeId=[string]$s.ScopeId; OptionId=$o.OptionId; Name=$o.Name; Value=((@($o.Value)) -join ', ') }) } } catch { }
            try { foreach ($r in (Get-DhcpServerv4Reservation -ScopeId $s.ScopeId -ErrorAction SilentlyContinue)) { $res.Add([pscustomobject]@{ ScopeId=[string]$s.ScopeId; IPAddress=[string]$r.IPAddress; ClientId=[string]$r.ClientId; Name=$r.Name; Description=$r.Description }) } } catch { }
        }
    } catch { Add-Limitation -Context $Context -Module 'DHCP' -Message 'DHCP scope enumeration failed.' -Reason $_.Exception.Message | Out-Null }
    try { foreach ($o in (Get-DhcpServerv4OptionValue -ErrorAction Stop)) { $opts.Add([pscustomobject]@{ Level='Server'; ScopeId=''; OptionId=$o.OptionId; Name=$o.Name; Value=((@($o.Value)) -join ', ') }) } } catch { }
    try { foreach ($f in (Get-DhcpServerv4Failover -ErrorAction Stop)) { $fo.Add([pscustomobject]@{ Name=$f.Name; PartnerServer=$f.PartnerServer; Mode=[string]$f.Mode; State=[string]$f.State; ScopeIds=((@($f.ScopeId) | ForEach-Object { [string]$_ }) -join ', ') }) } } catch { }
    $authorized = $null
    try { $authorized = [bool](@(Get-DhcpServerInDC -ErrorAction Stop | Where-Object { [string]$_.DnsName -match ('(?i)^' + [regex]::Escape($env:COMPUTERNAME) + '(\.|$)') }).Count -gt 0) } catch { }
    $srv.Add([pscustomobject]@{ ServiceState=$(if ($svc) { [string]$svc.Status } else { '' }); ScopeCount=$scopes.Count; ActiveScopeCount=@($scopes | Where-Object { $_.State -eq 'Active' }).Count
        ReservationCount=$res.Count; FailoverRelationships=$fo.Count; HasFailover=[bool]($fo.Count -gt 0); AuthorizedInAd=$authorized
        HostsActiveDhcp=[bool](@($scopes | Where-Object { $_.State -eq 'Active' }).Count -gt 0) })
    return ,@{ DhcpScopes=@($scopes); DhcpReservations=@($res); DhcpOptions=@($opts); DhcpFailover=@($fo); DhcpServerSummary=@($srv) }
}

function ConvertTo-DiscoveryDatasets {
    param([object]$Context, $RawData)
    if (-not $RawData) { $RawData = @{} }
    $get = { param($k) if ($RawData[$k]) { @($RawData[$k]) } else { @() } }
    Add-DataSet -Context $Context -Name 'DhcpScopes'       -Description 'DHCP scopes.'       -Rows (& $get 'DhcpScopes')       -Visibility 'Internal' -SourceModule 'DHCP' | Out-Null
    Add-DataSet -Context $Context -Name 'DhcpReservations' -Description 'DHCP reservations.' -Rows (& $get 'DhcpReservations') -Visibility 'Internal' -SourceModule 'DHCP' | Out-Null
    Add-DataSet -Context $Context -Name 'DhcpOptions'      -Description 'DHCP server- and scope-level options.' -Rows (& $get 'DhcpOptions') -Visibility 'Internal' -SourceModule 'DHCP' | Out-Null
    Add-DataSet -Context $Context -Name 'DhcpFailover'     -Description 'DHCP failover relationships.' -Rows (& $get 'DhcpFailover') -Visibility 'Internal' -SourceModule 'DHCP' | Out-Null
    Add-DataSet -Context $Context -Name 'DhcpServerSummary' -Description 'DHCP cutover summary (active scopes, failover, AD authorization).' -Rows (& $get 'DhcpServerSummary') -Visibility 'Internal' -SourceModule 'DHCP' | Out-Null
}

Export-ModuleMember -Function 'Get-DiscoveryModuleMetadata','Test-DiscoveryPrerequisites','Invoke-DiscoveryCollection','ConvertTo-DiscoveryDatasets','Test-DhcpPresent'