Private/Dns/RemoteCollector.ps1

$script:PSIDnsRemote={
    param([string]$TargetName)
    $ErrorActionPreference='Stop'
    function V($o,$p){if($null -ne $o -and $null -ne $o.PSObject.Properties[$p]){return $o.$p};return $null}
    function Flat($v){if($null -eq $v){return $null};if($v -is [datetime]){return ([datetimeoffset]$v).ToString('o')};if($v -is [timespan]){return $v.ToString('c')};if($v -is [bool] -or $v -is [ValueType] -or $v -is [string]){return $v};return (@($v)|ForEach-Object{[string]$_}) -join '; '}
    function Emit([string]$Key,[string]$Status,[object[]]$Records,[string]$Message='',[string]$ErrorId='', [long]$DurationMs=0){
        [pscustomobject]@{Kind='Section';Target=$TargetName;Key=$Key;Status=$Status;Records=@($Records);Method='WinRM / local DNS configuration providers';StartedAt=[datetimeoffset]::Now.ToString('o');CollectedAt=[datetimeoffset]::Now.ToString('o');DurationMs=$DurationMs;ErrorId=$ErrorId;ErrorMessage=$Message}
    }
    function Read-Category([string]$Key,[scriptblock]$Action){
        $watch=[diagnostics.stopwatch]::StartNew();$rows=@();$status='Succeeded';$message='';$id=''
        try{$rows=@(& $Action)}catch{$status='Failed';$message=$_.Exception.Message;$id=$_.FullyQualifiedErrorId}
        $watch.Stop();Emit $Key $status $rows $message $id $watch.ElapsedMilliseconds
    }
    Read-Category 'client' {
        Get-CimInstance -ClassName Win32_NetworkAdapterConfiguration -Filter 'IPEnabled=TRUE' -ErrorAction Stop|ForEach-Object{
            $dns=@($_.DNSServerSearchOrder|Where-Object{$_})
            [pscustomobject]@{Name=$TargetName;Adapter=$_.Description;'DNS search order'=Flat $dns;'Preferred DNS'=if($dns.Count){$dns[0]}else{$null};'Alternate DNS'=if($dns.Count -gt 1){$dns[1]}else{$null};'Additional DNS'=if($dns.Count -gt 2){Flat $dns[2..($dns.Count-1)]}else{$null}}
        }
    }
    $other=@('forwarders','recursion','scavenging','rootHints','zones','conditional','aging','delegations','scopes','nameServers','nsAddresses','zoneQueries')
    $svc=$null;$absent=$false;$errorRecord=$null
    try{$svc=Get-Service -Name DNS -ErrorAction Stop}catch{if($_.FullyQualifiedErrorId -like 'NoServiceFoundForGivenName*'){$absent=$true}else{$errorRecord=$_}}
    if($null -ne $errorRecord){Emit 'role' 'Failed' @() $errorRecord.Exception.Message $errorRecord.FullyQualifiedErrorId;foreach($k in $other){Emit $k 'Failed' @() 'DNS role detection failed; no absence was inferred.' $errorRecord.FullyQualifiedErrorId};return}
    Emit 'role' 'Succeeded' @([pscustomobject]@{Name=$TargetName;'DNS role present'=(-not $absent);'Service state'=if($null -ne $svc){[string]$svc.Status}else{'Not installed'}})
    if($absent){foreach($k in $other){Emit $k 'NotApplicable' @() 'DNS Server service is not installed.'};return}
    try{Import-Module DnsServer -ErrorAction Stop}catch{foreach($k in $other){Emit $k 'Failed' @() $_.Exception.Message $_.FullyQualifiedErrorId};return}
    Read-Category 'forwarders' {
        $c=Get-DnsServerForwarder -ErrorAction Stop;$addresses=@($c.IPAddress|Where-Object{$null -ne $_});$i=0
        if(-not $addresses.Count){$addresses=@('(none configured)')}
        foreach($a in $addresses){$i++;[pscustomobject]@{Name=$TargetName;Forwarder=[string]$a;Order=$i;'Timeout seconds'=V $c 'Timeout';'Use root hints'=V $c 'UseRootHint'}}
    }
    Read-Category 'recursion' {$c=Get-DnsServerRecursion -ErrorAction Stop;[pscustomobject]@{Name=$TargetName;Enabled=V $c 'Enable';'Additional timeout'=Flat (V $c 'AdditionalTimeout');'Retry interval'=Flat (V $c 'RetryInterval');Timeout=Flat (V $c 'Timeout');'Secure response'=V $c 'SecureResponse'}}
    Read-Category 'scavenging' {$c=Get-DnsServerScavenging -ErrorAction Stop;[pscustomobject]@{Name=$TargetName;Enabled=V $c 'ScavengingState';Interval=Flat (V $c 'ScavengingInterval');'Default no-refresh interval'=Flat (V $c 'NoRefreshInterval');'Default refresh interval'=Flat (V $c 'RefreshInterval');'Last scavenge time'=Flat (V $c 'LastScavengeTime')}}
    Read-Category 'rootHints' {
        Get-DnsServerRootHint -ErrorAction Stop|ForEach-Object{
            $ns=V (V $_ 'NameServer') 'RecordData';$addresses=New-Object collections.ArrayList
            foreach($a in @(V $_ 'IPAddress')){$rd=V $a 'RecordData';foreach($p in @('IPv4Address','IPv6Address')){$v=V $rd $p;if($null -ne $v){[void]$addresses.Add([string]$v)}}}
            [pscustomobject]@{Name=$TargetName;'Name server'=V $ns 'NameServer';Addresses=$addresses.ToArray() -join '; '}
        }
    }
    $zones=@();$zoneFailure=$null
    try{$zones=@(Get-DnsServerZone -ErrorAction Stop)}catch{$zoneFailure=$_}
    if($null -ne $zoneFailure){foreach($k in @('zones','conditional','aging','delegations','scopes','nameServers','nsAddresses','zoneQueries')){Emit $k 'Failed' @() $zoneFailure.Exception.Message $zoneFailure.FullyQualifiedErrorId};return}
    $zoneRows=@($zones|ForEach-Object{[pscustomobject]@{Name=$TargetName;Zone=$_.ZoneName;Type=[string]$_.ZoneType;'AD integrated'=V $_ 'IsDsIntegrated';'Directory partition'=V $_ 'DirectoryPartitionName';'Dynamic updates'=Flat (V $_ 'DynamicUpdate');'Reverse lookup'=V $_ 'IsReverseLookupZone';Paused=V $_ 'IsPaused';Shutdown=V $_ 'IsShutdown';'Zone file'=V $_ 'ZoneFile'}})
    Emit 'zones' 'Succeeded' $zoneRows
    Emit 'conditional' 'Succeeded' @($zones|Where-Object{[string]$_.ZoneType -eq 'Forwarder'}|ForEach-Object{[pscustomobject]@{Name=$TargetName;Zone=$_.ZoneName;'Master servers'=Flat (V $_ 'MasterServers');'AD integrated'=V $_ 'IsDsIntegrated';'Directory partition'=V $_ 'DirectoryPartitionName';'Timeout seconds'=V $_ 'ForwarderTimeout';'Use recursion'=V $_ 'UseRecursion'}})
    $zoneLedger=New-Object collections.ArrayList;$nsRows=New-Object collections.ArrayList
    foreach($operation in @(@{Key='aging';Command='Get-DnsServerZoneAging'},@{Key='delegations';Command='Get-DnsServerZoneDelegation'},@{Key='scopes';Command='Get-DnsServerZoneScope'},@{Key='nameServers';Command='Get-DnsServerResourceRecord'})){
        $rows=New-Object collections.ArrayList;$failures=0;$attempted=0
        foreach($zone in $zones){
            $status='Succeeded';$message='Query completed.';$errorId=$null;$watch=[diagnostics.stopwatch]::StartNew()
            if(($operation.Key -eq 'nameServers' -and [string]$zone.ZoneType -notin @('Primary','Secondary','Stub')) -or ($operation.Key -ne 'nameServers' -and [string]$zone.ZoneType -ne 'Primary')){$status='NotAttempted';$message='Excluded by declared selection: NS reads select Primary/Secondary/Stub zones; other per-zone configuration reads select Primary zones.'}
            else{
                $attempted++
                try{
                    if(-not (Get-Command -Name $operation.Command -ErrorAction SilentlyContinue)){$status='Unsupported';throw 'DNS cmdlet is unavailable on this target.'}
                    $argsForQuery=@{ErrorAction='Stop'}
                    if($operation.Key -eq 'nameServers'){$argsForQuery.ZoneName=$zone.ZoneName;$argsForQuery.RRType='NS'}elseif($operation.Key -eq 'scopes'){$argsForQuery.ZoneName=$zone.ZoneName}else{$argsForQuery.Name=$zone.ZoneName}
                    $items=@(& $operation.Command @argsForQuery)
                    foreach($item in $items){
                        $record= switch($operation.Key){
                            'aging'{[pscustomobject]@{Name=$TargetName;Zone=$zone.ZoneName;'Aging enabled'=V $item 'AgingEnabled';'No-refresh interval'=Flat (V $item 'NoRefreshInterval');'Refresh interval'=Flat (V $item 'RefreshInterval');'Scavenge servers'=Flat (V $item 'ScavengeServers');'Available for scavenging'=Flat (V $item 'AvailForScavengeTime')}}
                            'delegations'{[pscustomobject]@{Name=$TargetName;Zone=$zone.ZoneName;'Child zone'=V $item 'ChildZoneName';'Name servers'=Flat (V $item 'NameServer');'IP addresses'=Flat (V $item 'IPAddress')}}
                            'nameServers'{[pscustomobject]@{Name=$TargetName;Zone=$zone.ZoneName;Owner=V $item 'HostName';'Name server'=V (V $item 'RecordData') 'NameServer';TTL=Flat (V $item 'TimeToLive');Timestamp=Flat (V $item 'Timestamp')}}
                            'scopes'{[pscustomobject]@{Name=$TargetName;Zone=$zone.ZoneName;Scope=V $item 'ZoneScope';'File name'=V $item 'FileName'}}
                        }
                        [void]$rows.Add($record);if($operation.Key -eq 'nameServers'){[void]$nsRows.Add($record)}
                    }
                }catch{if($status -ne 'Unsupported'){$status='Failed'};$message=$_.Exception.Message;$errorId=$_.FullyQualifiedErrorId;$failures++}
            }
            $watch.Stop();[void]$zoneLedger.Add([pscustomobject]@{Name=$TargetName;Zone=$zone.ZoneName;Operation=$operation.Key;Status=$status;'Duration ms'=$watch.ElapsedMilliseconds;'Error ID'=$errorId;Message=$message;'Collected at'=[datetimeoffset]::Now.ToString('o')})
        }
        Emit $operation.Key $(if($failures){'Partial'}else{'Succeeded'}) $rows.ToArray() $(if($failures){"$failures of $attempted selected zone queries failed or were unsupported; successful rows retained."}else{'Selected zone queries completed; see zoneQueries for excluded types.'})
    }
    # Read stored address nodes only; never ask a resolver or an advertised NS endpoint.
    $addressRows=New-Object collections.ArrayList;$addressFailures=0;$addressCache=@{}
    foreach($ns in $nsRows){
        $target=([string]$ns.'Name server').TrimEnd('.')
        $matching=@($zones|Where-Object{[string]$_.ZoneType -in @('Primary','Secondary') -and ($target -ieq $_.ZoneName.TrimEnd('.') -or $target.EndsWith('.'+$_.ZoneName.TrimEnd('.'),[StringComparison]::OrdinalIgnoreCase))}|Sort-Object @{Expression={$_.ZoneName.Length};Descending=$true})
        if(-not $target -or -not $matching.Count){
            [void]$zoneLedger.Add([pscustomobject]@{Name=$TargetName;Zone=$ns.Zone;Operation='NS address selection';Status='NotAttempted';'Duration ms'=0;'Error ID'=$null;Message=('No eligible hosted address zone selected for NS target: '+$target);'Collected at'=[datetimeoffset]::Now.ToString('o')});continue
        }
        $addressZone=$matching[0].ZoneName;$normalized=$addressZone.TrimEnd('.')
        $owner=if($target -ieq $normalized){'@'}else{$target.Substring(0,$target.Length-$normalized.Length-1)}
        foreach($type in @('A','AAAA')){
            $cacheKey=$addressZone+'|'+$owner+'|'+$type
            if(-not $addressCache.ContainsKey($cacheKey)){
                $watch=[diagnostics.stopwatch]::StartNew();$records=@();$status='Succeeded';$message='Stored node query completed; zero returned rows is valid.';$id=$null
                try{
                    $records=@(Get-DnsServerResourceRecord -ZoneName $addressZone -Name $owner -RRType $type -Node -ErrorAction Stop|ForEach-Object{
                        [pscustomobject]@{Owner=V $_ 'HostName';Address=Flat (V (V $_ 'RecordData') $(if($type -eq 'A'){'IPv4Address'}else{'IPv6Address'}));TTL=Flat (V $_ 'TimeToLive')}
                    })
                }catch{$status='Failed';$records=@();$message=$_.Exception.Message;$id=$_.FullyQualifiedErrorId;$addressFailures++}
                $watch.Stop();$addressCache[$cacheKey]=[pscustomobject]@{Records=$records;Status=$status;Message=$message;Id=$id;Duration=$watch.ElapsedMilliseconds}
            }
            $cached=$addressCache[$cacheKey]
            foreach($record in $cached.Records){[void]$addressRows.Add([pscustomobject]@{Name=$TargetName;Zone=$ns.Zone;'Name server'=$ns.'Name server';'Address zone'=$addressZone;Owner=$record.Owner;'Record type'=$type;Address=$record.Address;TTL=$record.TTL})}
            [void]$zoneLedger.Add([pscustomobject]@{Name=$TargetName;Zone=$ns.Zone;Operation=('NS address '+$type);Status=$cached.Status;'Duration ms'=$cached.Duration;'Error ID'=$cached.Id;Message=($target+' via stored '+$addressZone+'/'+$owner+': '+$cached.Message);'Collected at'=[datetimeoffset]::Now.ToString('o')})
        }
    }
    $nsFailed=@($zoneLedger|Where-Object{$_.Operation -eq 'nameServers' -and $_.Status -in @('Failed','Unsupported')}).Count
    Emit 'nsAddresses' $(if($addressFailures -or $nsFailed){'Partial'}else{'Succeeded'}) $addressRows.ToArray() 'Stored node reads only for successfully observed NS targets with an eligible hosted address zone. Exclusions and read failures are recorded separately; no address resolution or health conclusion.'
    Emit 'zoneQueries' 'Succeeded' $zoneLedger.ToArray() 'Per-zone query ledger captured; inspect each row for outcomes.'
}

$script:PSIDnsWorker={
    param($Controller,$Credential,$Settings,[string]$RemoteText)
    $ErrorActionPreference='Stop'
    $p=@{ComputerName=$Controller.HostName;ScriptBlock=[scriptblock]::Create($RemoteText);ArgumentList=@([string]$Controller.HostName);Authentication='Kerberos';ErrorAction='Stop';SessionOption=New-PSSessionOption -OpenTimeout ($Settings.OpenTimeoutSeconds*1000) -OperationTimeout 60000 -CancelTimeout 5000}
    if($null -ne $Credential){$p.Credential=$Credential};if($Settings.UseSSL){$p.UseSSL=$true};if($Settings.Port -gt 0){$p.Port=$Settings.Port}
    Invoke-Command @p
}