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')
        # 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()
    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=@() } }
    try {
        foreach ($s in (Get-DhcpServerv4Scope -ErrorAction SilentlyContinue)) {
            $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 })
            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 }
    return ,@{ DhcpScopes=@($scopes); DhcpReservations=@($res) }
}

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
}

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