Private/Forest/Collector.ps1
|
$script:PSIForestCollector={ param([string]$Domain,[pscredential]$Credential) $ErrorActionPreference='Stop' Import-Module ActiveDirectory -ErrorAction Stop function Value($o,$p){if($null -ne $o -and $null -ne $o.PSObject.Properties[$p]){return $o.$p};return $null} function Bag($o,[string[]]$properties){ $row=[ordered]@{} foreach($p in $properties){ $v=Value $o $p if($null -eq $v){$row[$p]=$null} elseif($v -is [timespan]){$row[$p]=$v.ToString('c')} elseif($v -is [bool]){$row[$p]=$v} elseif($v -is [collections.IEnumerable] -and $v -isnot [string]){$row[$p]=@($v|ForEach-Object{[string]$_})} else{$row[$p]=[string]$v} } [pscustomobject]$row } function Params([string]$server){$p=@{Server=$server;ErrorAction='Stop'};if($null -ne $Credential){$p.Credential=$Credential};return $p} function Emit([string]$Key,[string]$TargetDomain,[string]$SourceDC,[scriptblock]$Action){ $clock=[diagnostics.stopwatch]::StartNew();$rows=@();$status='Succeeded';$id=$null;$message=$null try{$rows=@(& $Action)}catch{$status='Failed';$id=$_.FullyQualifiedErrorId;$message=$_.Exception.Message} $clock.Stop() [pscustomobject]@{Kind='Section';Key=$Key;Target=$TargetDomain;SourceDC=$SourceDC;Status=$status;Records=$rows;Method='ADWS / read-only forest and domain metadata';CollectedAt=[datetimeoffset]::Now.ToString('o');DurationMs=$clock.ElapsedMilliseconds;ErrorId=$id;ErrorMessage=$message} } $seed=Params $Domain;$root=Get-ADRootDSE @seed;$source=[string]$root.dnsHostName if(-not $source){throw 'Forest discovery did not return a source DC.'} $seed=Params $source;$root=Get-ADRootDSE @seed;$initial=Get-ADDomain @seed if([string]$initial.DNSRoot -ine $Domain){throw 'The connection domain does not match discovery.'} $forest=Get-ADForest @seed;$names=@($forest.Domains|ForEach-Object{[string]$_}|Sort-Object -Unique) if(-not $names.Count -or $names -notcontains $Domain){throw 'The connection domain is not present in the returned forest domain list.'} $forestProperties=@('Name','RootDomain','ForestMode','Domains','UPNSuffixes','SPNSuffixes','SchemaMaster','DomainNamingMaster','GlobalCatalogs','ApplicationPartitions','Sites') $domainProperties=@('DNSRoot','Name','NetBIOSName','DomainSID','ObjectGUID','DistinguishedName','Forest','DomainMode','ParentDomain','ChildDomains','PDCEmulator','RIDMaster','InfrastructureMaster','ComputersContainer','UsersContainer','DomainControllersContainer','DeletedObjectsContainer','LostAndFoundContainer','SystemsContainer','AllowedDNSSuffixes','ReplicaDirectoryServers','ReadOnlyReplicaDirectoryServers','LastLogonReplicationInterval') $policyProperties=@('DistinguishedName','ComplexityEnabled','ReversibleEncryptionEnabled','MinPasswordLength','PasswordHistoryCount','MinPasswordAge','MaxPasswordAge','LockoutThreshold','LockoutDuration','LockoutObservationWindow') $rootProperties=@('dnsHostName','configurationNamingContext','schemaNamingContext','defaultNamingContext','rootDomainNamingContext','namingContexts') [pscustomobject]@{Kind='Context';Forest=[string]$forest.Name;RootDomain=[string]$forest.RootDomain;Domain=$Domain;Server=$source;Domains=$names;ConfigurationNC=[string]$root.configurationNamingContext;Root=(Bag $root $rootProperties)} Emit 'forest' $forest.Name $source {Bag $forest $forestProperties} Emit 'partitions' $forest.Name $source { Get-ADObject -SearchBase ('CN=Partitions,'+[string]$root.configurationNamingContext) -SearchScope OneLevel -LDAPFilter '(objectClass=crossRef)' -Properties nCName,dnsRoot,nETBIOSName,systemFlags,enabled,msDS-NC-Replica-Locations,msDS-NC-RO-Replica-Locations @seed | ForEach-Object{ Bag $_ @('Name','DistinguishedName','nCName','dnsRoot','nETBIOSName','systemFlags','enabled','msDS-NC-Replica-Locations','msDS-NC-RO-Replica-Locations') } } foreach($name in $names){ $target=$null;$info=$null;$targetRoot=$null;$connectionError=$null try{ if($name -ieq $Domain){$target=$source;$info=$initial;$targetRoot=$root} else{ $connect=Params $name;$candidate=Get-ADRootDSE @connect;$target=[string]$candidate.dnsHostName if(-not $target){throw 'Domain discovery did not return a source DC.'} $connect=Params $target;$targetRoot=Get-ADRootDSE @connect;$info=Get-ADDomain @connect } if([string]$info.DNSRoot -ine $name -or [string]$info.Forest -ine [string]$forest.Name){throw 'Discovered domain/forest does not match the forest membership reference.'} }catch{$connectionError=$_;$target=$null} if($null -ne $connectionError){ foreach($key in @('domain:','policy:')){ [pscustomobject]@{Kind='Section';Key=$key+$name;Target=$name;SourceDC=$null;Status='Failed';Records=@();Method='ADWS / domain discovery';CollectedAt=[datetimeoffset]::Now.ToString('o');DurationMs=0;ErrorId=$connectionError.FullyQualifiedErrorId;ErrorMessage=$connectionError.Exception.Message} } continue } Emit ('domain:'+$name) $name $target {[pscustomobject]@{Domain=(Bag $info $domainProperties);Root=(Bag $targetRoot $rootProperties)}} Emit ('policy:'+$name) $name $target { $p=Params $target $policy=Get-ADDefaultDomainPasswordPolicy -Identity $name @p Bag $policy $policyProperties } } } function Invoke-PSIForestCollection { [CmdletBinding()] param([string]$Domain,[pscredential]$Credential,[ValidateRange(1,1800)][int]$TimeoutSeconds=180,[scriptblock]$Worker=$script:PSIForestCollector,[ValidateSet('Forest','Trusts')][string]$QueryPlan='Forest') $context=$null;$required=@();$seen=@{};$results=New-Object collections.ArrayList;$job=$null;$errorText='' function Read-Result($item){ if($item.Kind -eq 'Context'){ if($null -ne $scriptContext.Value){throw 'Duplicate forest context.'} $scriptContext.Value=$item $keys=@();if($QueryPlan -eq 'Trusts'){$keys=@($item.Domains|ForEach-Object{'trust:'+$_})}else{$keys=@('forest','partitions');foreach($domainName in @($item.Domains)){$keys+=@(('domain:'+$domainName),('policy:'+$domainName))}} $scriptRequired.Value=$keys }elseif($item.Kind -eq 'Section'){ if($null -eq $scriptContext.Value -or $item.Key -notin $scriptRequired.Value -or $seen.ContainsKey($item.Key)){throw 'Unknown, premature or duplicate forest query outcome.'} [void]$results.Add($item);$seen[$item.Key]=$true }else{throw 'Unexpected forest worker output.'} } # Explicit references keep the nested reader scoped to this invocation. $scriptContext=[ref]$context;$scriptRequired=[ref]$required try{ $job=Start-Job -ScriptBlock $Worker -ArgumentList $Domain,$Credential -ErrorAction Stop $clock=[diagnostics.stopwatch]::StartNew();$timedOut=$false while($true){ $errors=@();$items=@(Receive-Job $job -ErrorAction SilentlyContinue -ErrorVariable errors) if(Get-Command Receive-PSIInventoryDisplay -ErrorAction SilentlyContinue){Receive-PSIInventoryDisplay -Items $items;Update-PSIInventoryDisplay -Total $required.Count} if($errors.Count){$errorText=($errors|ForEach-Object{$_.Exception.Message}) -join '; '} foreach($item in $items){Read-Result $item} if($job.State -in @('Completed','Failed','Stopped')){break} if($clock.Elapsed.TotalSeconds -ge $TimeoutSeconds){$timedOut=$true;Stop-Job $job -ErrorAction SilentlyContinue;break} Start-Sleep -Milliseconds 200 } $errors=@();$items=@(Receive-Job $job -ErrorAction SilentlyContinue -ErrorVariable errors) if(Get-Command Receive-PSIInventoryDisplay -ErrorAction SilentlyContinue){Receive-PSIInventoryDisplay -Items $items;Update-PSIInventoryDisplay -Total $required.Count} if($errors.Count){$errorText=($errors|ForEach-Object{$_.Exception.Message}) -join '; '} foreach($item in $items){Read-Result $item} if($null -eq $context){throw "Verified forest context was not returned. $errorText"} foreach($key in $required){if(-not $seen.ContainsKey($key)){ [void]$results.Add([pscustomobject]@{Kind='Section';Key=$key;Target=if($key.Contains(':')){$key.Substring($key.IndexOf(':')+1)}else{$context.Forest};SourceDC=$null;Status=if($timedOut){'TimedOut'}else{'Failed'};Records=@();Method='Forest worker / incomplete query';CollectedAt=[datetimeoffset]::Now.ToString('o');DurationMs=$clock.ElapsedMilliseconds;ErrorId='PSInsightInventory.ForestQueryIncomplete';ErrorMessage=if($timedOut){"Overall collector budget of $TimeoutSeconds seconds expired."}elseif($errorText){$errorText}else{'Query did not return an outcome.'}}) }} return [pscustomobject]@{Context=$context;Envelopes=@($results.ToArray())} }finally{if($null -ne $job){if($job.State -in @('Running','Blocked','NotStarted')){Stop-Job $job -ErrorAction SilentlyContinue};Remove-Job $job -Force -ErrorAction SilentlyContinue}} } |