Private/Dns/Engine.ps1

#requires -Version 5.1
[CmdletBinding(DefaultParameterSetName='Live')]
param(
    [Parameter(Mandatory=$true,ParameterSetName='Live')][ValidatePattern('^(?=.{1,253}$)[A-Za-z0-9][A-Za-z0-9.-]*[A-Za-z0-9]$')][string]$Domain,
    [Parameter(ParameterSetName='Live')][string[]]$ComputerName,
    [Parameter(ParameterSetName='Live')][pscredential]$Credential,
    [Parameter(ParameterSetName='Live')][ValidateRange(1,16)][int]$ThrottleLimit=4,
    [Parameter(ParameterSetName='Live')][ValidateRange(15,3600)][int]$TargetTimeoutSeconds=180,
    [Parameter(ParameterSetName='Live')][ValidateRange(10,600)][int]$DiscoveryTimeoutSeconds=60,
    [Parameter(ParameterSetName='Live')][ValidateRange(3,120)][int]$OpenTimeoutSeconds=15,
    [Parameter(ParameterSetName='Live')][switch]$UseSSL,
    [Parameter(ParameterSetName='Live')][ValidateRange(0,65535)][int]$Port=0,
    [Parameter(Mandatory=$true,ParameterSetName='Sample')][switch]$SampleData,
    [ValidateNotNullOrEmpty()][string]$OutputPath='C:\Temp\Reports',
    [switch]$Open
)
Set-StrictMode -Version 2.0;$ErrorActionPreference='Stop'
foreach($f in @('Common.ps1','Schema.ps1','RemoteCollector.ps1')){. (Join-Path $PSScriptRoot $f)}
$moduleRoot=Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
foreach($f in @('Helpers.ps1','Export.ps1')){. (Join-Path (Join-Path (Split-Path -Parent $PSScriptRoot) 'Sites') $f)}
$started=[datetimeoffset]::Now;$script:DCInvRunId=[guid]::NewGuid().ToString('N').Substring(0,8)
$folder=Join-Path $OutputPath ('PSInsightInventory_Dns_'+$started.ToString('yyyyMMdd_HHmmss_fff')+'_'+$script:DCInvRunId)
$reports=Join-Path $folder 'Reports';$logs=Join-Path $folder 'Logs';$null=New-Item -ItemType Directory -Path $reports,$logs -Force
$script:DCInvLogPath=Join-Path $logs ('DnsInventory_'+$started.ToString('yyyyMMdd_HHmmss')+'.jsonl')
$script:DCInvSections=Get-PSIDnsSchema;$script:DCInvData=[ordered]@{};$script:DCInvSeen=@{}
foreach($k in $script:DCInvSections.Keys){$script:DCInvData[$k]=New-Object collections.ArrayList}
try{
    Write-DCInvLog 'Information' 'Run' $Domain 'Start' 'Started' ('DNS inventory; domain DC scope; SampleData='+[bool]$SampleData)
    if($SampleData){
        $fixture=[IO.File]::ReadAllText((Join-Path $moduleRoot 'Assets/Dns.sample.json'))|ConvertFrom-Json
        $scope=$fixture.domain
        foreach($e in $fixture.envelopes){Add-DCInvSection $e}
    }else{
        if($env:OS -ne 'Windows_NT'){throw 'Live DNS inventory requires Windows, RSAT ActiveDirectory and authorized WinRM access.'}
        if($Domain -notmatch '\.'){throw 'Specify one DNS domain name.'}
        if(-not (Get-Module -ListAvailable ActiveDirectory)){throw 'RSAT ActiveDirectory module is required.'}
        $found=@(Invoke-DCInvDiscovery $Domain $Credential $DiscoveryTimeoutSeconds)
        $targets=@(Select-DCInvTargets $found $Domain @($ComputerName|Where-Object{$_}));$scope=$Domain
        foreach($dc in $targets){Add-DCInvSection ([pscustomobject]@{Target=$dc.HostName;Key='dcs';Status='Succeeded';Records=@([pscustomobject]@{Name=$dc.HostName;Domain=$dc.Domain;Site=$dc.Site;OS=$dc.OperatingSystem;Inventory='Pending';'Zones observed'=$null});Method='ADWS / explicit domain DC discovery';StartedAt=$started.ToString('o');CollectedAt=[datetimeoffset]::Now.ToString('o');DurationMs=0;ErrorId=$null;ErrorMessage=$null})}
        $settings=[pscustomobject]@{ThrottleLimit=$ThrottleLimit;TargetTimeoutSeconds=$TargetTimeoutSeconds;OpenTimeoutSeconds=$OpenTimeoutSeconds;UseSSL=[bool]$UseSSL;Port=$Port}
        Invoke-DCInvWorkers $targets $script:PSIDnsWorker $script:PSIDnsRemote.ToString() $settings $Credential
    }
    foreach($dc in $script:DCInvData.dcs){
        $ledger=@($script:DCInvData.collection.ToArray()|Where-Object{$_.Name -eq $dc.Name})
        if($ledger.Count -ne 15){throw "DNS coverage contract failed for $($dc.Name); expected 15 category outcomes."}
        $dc.Inventory=if(@($ledger|Where-Object{$_.Status -notin @('Succeeded','NotApplicable')}).Count){'Partial'}else{'Complete'}
        $zoneOutcome=@($ledger|Where-Object{$_.'Check ID' -eq 'zones'})[0]
        $dc.'Zones observed'=if($zoneOutcome.Status -eq 'Succeeded'){@($script:DCInvData.zones.ToArray()|Where-Object{$_.Name -eq $dc.Name}).Count}elseif($zoneOutcome.Status -eq 'NotApplicable'){$null}else{$null}
    }
 if(Get-Command Update-PSIInventoryDisplay -ErrorAction SilentlyContinue){Update-PSIInventoryDisplay -Stage 'Preparing report data' -Force}
    $model=ConvertTo-PSIDnsModel;$end=[datetimeoffset]::Now
    $meta=[ordered]@{moduleName='PSInsightInventory';version='1.0.0';inventoryId='ADDns';title='DNS Configuration Inventory';run=$script:DCInvRunId;dataSource=if($SampleData){'Sample'}else{'Live'};scope='Selected domain controllers in one explicit domain';forest='Not queried (domain-scoped inventory)';connectionDomain=$scope;sourceDC='Multiple selected DCs';configurationNC='Not queried';started=$started.ToString('o');generated=$end.ToString('o');generatedDisplay=$end.ToString('dd MMM yyyy, HH:mm:ss zzz');expectedQueries=$model.Data.dcs.Count*15;selectedDCs=$model.Data.dcs.Count;notice=if($SampleData){'SAMPLE DATA. Fictional DC DNS configuration; no infrastructure was contacted.'}else{'Read-only DNS configuration on selected DCs in one domain. This is not a DNS health or resolution test.'};limitations=@('The target list contains only DCs discovered in the explicitly supplied domain; non-DC DNS servers and child domains are not auto-enumerated.','Client adapter resolvers, server forwarders and conditional forwarders are separate datasets.','Zone observations repeat per server. Zone names across DCs are not merged into a claimed unique/consistent configuration.','Per-zone aging, delegation and scope reads are limited to Primary zones in the default virtualization instance; excluded types are listed as NotAttempted. Delegation reads use the default zone scope.','One failed/unsupported zone query leaves other successful rows intact; category status becomes Partial. See Per-zone collection for details.','An absent DNS role is NotApplicable; access denial, missing modules and failed queries are not treated as absence.','NS records are read from Primary, Secondary and Stub zones in the default zone scope/virtualization instance. NS address reads use only exact A/AAAA nodes in the most-specific matching hosted Primary/Secondary zone; external targets are not resolved. See per-query outcomes.',
'No full resource-record dump, resolver probe, scavenging pass, DNS update, cache flush, service change or email is performed. Inventory completeness is not health.');configuration=@{ThrottleLimit=$ThrottleLimit;TargetTimeoutSeconds=$TargetTimeoutSeconds;OpenTimeoutSeconds=$OpenTimeoutSeconds;UseSSL=[bool]$UseSSL;Port=$Port}}
    if(Get-Command Update-PSIInventoryDisplay -ErrorAction SilentlyContinue){Complete-PSIInventoryCollectionDisplay -Collection $model.Data.collection;Update-PSIInventoryDisplay -Stage 'Saving reports' -Force}
    $paths=Export-PSISitesReport $model $meta $moduleRoot $reports 'Dns.html' 'Dns-Configuration'
    Write-DCInvLog 'Information' 'Export' $scope 'Report' 'Succeeded' $paths.HtmlReport
    if($Open){try{Invoke-Item -LiteralPath $paths.HtmlReport}catch{Write-Warning "Report saved, but could not open automatically: $($_.Exception.Message)"}}
    [pscustomobject][ordered]@{ModuleName='PSInsightInventory';ModuleVersion='1.0.0';InventoryId='ADDns';Domain=$scope;SelectedDCs=$model.Data.dcs.Count;DNSRoleObserved=@($model.Data.role|Where-Object{$_.'DNS role present' -eq $true}).Count;PartialInventories=@($model.Data.dcs|Where-Object{$_.Inventory -eq 'Partial'}).Count;ZoneObservations=$model.Data.zones.Count;DataSource=$meta.dataSource;HtmlReport=$paths.HtmlReport;JsonSnapshot=$paths.JsonSnapshot;CsvFolder=$paths.CsvFolder;LogFile=$script:DCInvLogPath;OutputFolder=$folder}
}catch{Write-DCInvLog 'Error' 'Run' $Domain 'Complete' 'Failed' $_.Exception.Message 0 $_.FullyQualifiedErrorId $_.Exception.Message;throw}