Advertisement
Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- <#
- .SYNOPSIS
- This script runs a variety of reporting functions against remote trusted domains and logs the output
- locally.
- .DESCRIPTION
- Administrator rights are required. The script first checks for trusted domains in the domain context
- it is executed within, then logs those to a file. It then reads that file and cycles through the
- trusted domains to check for a DC - preferring the PDC if available - and tests connectivity via
- both Test-NetConnection and WinRM. If a DC is reachable, it then adds that DC to the trusted hosts
- list and executes the various scriptblocks defined in the beginning of the script. It places the
- output of the scriptblocks into an output block, which is displayed and logged to a text file.
- The group membership section has some limitations: The script cannot query groups local to an
- individual server and query client owned domains because Oracle only has a 1-way trust with them.
- CSV format for group-input.csv:
- Domain,GroupName
- child1.domain.com,HR Team
- child2.domain.com,Finance Admins
- It is noteworthy that this script allows NTLM authentication in case of Kerberos failures, which
- can be considered insecure if executed outside a trusted network. Even within a trusted network
- it is not ideal.
- This script attempts cross-domain lookups when resolving group members, which may fail if ports
- are not appropriately open and WinRM traffic is not allowed.
- .NOTES
- Author: ME | License: CC0
- Initially Published: 27 May 2025 1122GMT
- Last Updated : 10 Jun 2025 1547GMT
- .CHANGELOG
- 20250603 - 13.7.6 - Version 13.7.6 is confirmed working with one exception: cross-domain lookups fail
- 20250603 - 14.0.1 - Adding cross-domain lookups with an enable/disable flag
- 20250603 - 14.1.1 - Moved domain controller lookups and connectivity testing to a function
- 20250603 - 14.2.1 - Added additional output file for domains that fail authentication negotiation and included in email
- 20250603 - 14.3.1 - Added display and logging of PDC name, fixed DC handling logic to not show incorrect error
- 20250603 - 14.3.2 - Added auth test prior to looping through the scripts to avoid unnecessary junk errors.
- 20250603 - 15.0.1 - Changed logging function to streaming to save memory usage and improve performance
- 20250603 - 15.0.2 - Refactored domain status to use a single array instead of multiple objects for the same reasons
- 20250603 - 15.0.3 - Moved domain status logic into Log-Activity function for the same reasons
- 20250603 - 16.0.1 - Updated Set-TrustedHost to shorter name and stricter hostname checks for validity to avoid adding malformed or rogue hosts
- 20250603 - 16.0.2 - Added toggle to disable console output since this is intended as a background task and it will improve performance to have it off
- 20250604 - 16.0.3 - Resolve issue with scripts running anyway when DC is unreachable
- 20250604 - 16.0.4 - Remove extraneous $domainsError statements, domain status is tracked in the log-activity function now
- 20250605 - 16.0.5 - Added domain-specific subfolder creation
- 20250605 - 17.0.1 - Added local domain context and toggle.
- 20250605 - 17.1.1 - Solved local group membership query issue by adding a copy of the report inside the local block; TESTED GOOD VERSION
- 20250605 - 18.0.1 - Changed logic to execute locally on the host server in order to reduce complexity and allow for GMSA use.
- 20250605 - 18.0.2 - Removed run as admin logic, as without the trusted hosts function it's no longer required.
- 20250605 - 18.0.3 - Removed redundant GroupMembership block in if(EnableLocalDomain).
- 20250605 - 18.0.4 - Implemented new authentication test now that the WinRM tests are deprecated and included deeper error logging.
- 20250605 - 18.0.5 - Fixing null group value issue
- 20250605 - 18.0.6 - Fixed group splatting issue with lookups
- 20250605 - 18.0.7 - Restored all scripts to EnableLocalDomain
- 20250605 - 18.1.1 - A lot of small work in the groupmembership section to resolve groups as the right kind of objects and handle the right arrays
- as well as adding a section in the EnableLocalDomain to resolve local DC and use it rather than trying to run on the local box
- TESTED GOOD AT THIS STAGE
- 20250605 - 18.2.1 - Added an old file/folder cleanup function with logging
- 20250606 - 18.2.2 - Added lookup for domain admins group to verify group resolution.
- 20250609 - 19.0.1 - Adding new logic to allow for queries of all domains via variable toggle.
- 20250609 - 19.0.2 - Fixed localRemoteComputer reference in enableLocalDomain loop
- 20250609 - 19.1.1 - Fixed logic for all domains toggle missing in trusted loop
- 20250609 - 1.0.0 - Reset version now that we're using threading; implemented parallel processing of domains
- 20250609 - 1.1.0 - Switched to using function instead of inline processing to reduce complexity for local domain processing
- 20250609 - 1.1.1 - Added memory tracking for each report, cleaned up logic, added error passthrough for jobs
- 20250609 - 2.0.1 - Replaced start-job with threadjob, an improved MS provided multithreading module
- 20250610 - 2.1.1 - Removed streaming log and went back to objects since the stream was locking a file and crashing the script
- #>
- $scriptStartTime = Get-Date
- # --- Configuration ---
- $Date = Get-Date -Format "HH-mm-M-d-yyyy"
- $DayStamp = Get-Date -Format "yyyy-MM-dd"
- $rootFolder = "C:\Scripts\ADreports"
- $outputRoot = Join-Path $rootFolder "test"
- $dailyFolder = Join-Path $outputRoot $DayStamp
- $activityLogPath = Join-Path $outputRoot "ActivityLog-$Date.csv"
- $global:GroupInputFolder = Join-Path $rootFolder "group-input"
- $failedAuthPath = Join-Path $dailyFolder "FailedAuthentication-$Date.txt"
- $DomainStatusPath = Join-Path $OutputRoot "DomainStatus-$Date.csv"
- $RetentionDays = 30
- $MaxParallelDomainJobs = 30
- # --- Functionality Toggles ---
- $EnableAllGroupsQuery = $true # Set to $true to query all groups in each domain instead of only those in the CSV
- $EnableCrossDomainMemberLookups = $true # Set to $false to disable recursive user lookups across domains
- $EnableConsoleOutput = $true # Set to $false to disable all real-time console output - scheduled tasks should be $false!
- $EnableLocalDomain = $true # Set to $false to not process the domain of the server running the script
- $sendEmail = $true # Set to $false to disable sending email
- # --- Email Config ---
- $emailTo = "[email protected]"
- $emailFrom = "[email protected]"
- $smtpServer = "mail.domain.com"
- $smtpPort = 25
- # --- Script Info ---
- $scriptversion = "v2.1.1"
- $scriptauthor = "me"
- $scriptupdated = "2025-06-10"
- # Ensure output dirs
- foreach ($path in @($outputRoot, $dailyFolder)) {
- if (-not (Test-Path $path)) {
- New-Item -ItemType Directory -Path $path -Force | Out-Null
- }
- }
- # Initialize domain status array
- $domainStatusTable = @{}
- # Initialize logs
- $activityLog = @()
- function Log-Activity {
- param (
- [string]$DomainName,
- [string]$RemoteComputer,
- [string]$ScriptName,
- [string]$ActionType,
- [string]$Message
- )
- $entry = [PSCustomObject]@{
- DomainName = $DomainName
- RemoteComputer = $RemoteComputer
- ScriptName = $ScriptName
- ActionType = $ActionType
- Message = $Message
- }
- $global:activityLog += $entry
- $ts = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
- $color = switch ($ActionType.ToUpper()) {
- "INFO" { "Cyan" }
- "SUCCESS" { "Green" }
- "WARNING" { "Yellow" }
- "ERROR" { "Red" }
- "RUNTIME" { "White" }
- "NOTIFY" { "Blue" }
- default { "White" }
- }
- Write-Host "[$ts] [$ActionType] [$DomainName] [$RemoteComputer] [$ScriptName] $Message" -ForegroundColor $color
- # === Real-time domain status tracking ===
- if ($DomainName -and $DomainName -notlike "<*>" -and $DomainName -ne "<Local>") {
- $statusPriority = @{ "SUCCESS" = 1; "WARNING" = 2; "ERROR" = 3 }
- # Determine if new actionType updates status
- $current = if ($domainStatusTable.ContainsKey($DomainName)) {
- $domainStatusTable[$DomainName]
- } else {
- "SUCCESS"
- }
- if ($statusPriority.ContainsKey($ActionType.ToUpper()) -and
- $statusPriority[$ActionType.ToUpper()] -gt $statusPriority[$current]) {
- $domainStatusTable[$DomainName] = $ActionType.ToUpper()
- } elseif (-not $domainStatusTable.ContainsKey($DomainName)) {
- $domainStatusTable[$DomainName] = "SUCCESS"
- }
- }
- }
- function Clean-OldReports {
- param (
- [string]$ReportRoot,
- [int]$RetentionDays = 30
- )
- $scriptName = "Cleanup"
- Log-Activity "<Init>" "<Local>" $scriptName "INFO" "Running cleanup for folders older than $RetentionDays days under $ReportRoot"
- try {
- $cutoff = (Get-Date).AddDays(-$RetentionDays)
- $oldFolders = Get-ChildItem -Path $ReportRoot -Directory -ErrorAction Stop |
- Where-Object {
- $_.Name -match '^\d{4}-\d{2}-\d{2}$' -and $_.LastWriteTime -lt $cutoff
- }
- if ($oldFolders.Count -eq 0) {
- Log-Activity "<Init>" "<Local>" $scriptName "INFO" "No old folders found to delete."
- return
- }
- foreach ($folder in $oldFolders) {
- try {
- # Log each file before deletion
- $files = Get-ChildItem -Path $folder.FullName -Recurse -File -ErrorAction SilentlyContinue
- foreach ($file in $files) {
- $sizeKB = [math]::Round($file.Length / 1KB, 2)
- Log-Activity "<Cleanup>" $file.FullName $scriptName "INFO" "Deleting file ($sizeKB KB)"
- }
- # Now delete the folder
- Remove-Item -Path $folder.FullName -Recurse -Force -ErrorAction Stop
- Log-Activity "<Cleanup>" "<Local>" $scriptName "SUCCESS" "Deleted folder: $($folder.FullName)"
- } catch {
- Log-Activity "<Cleanup>" "<Local>" $scriptName "ERROR" "Failed to delete folder $($folder.FullName): $($_.Exception.Message)"
- }
- }
- } catch {
- Log-Activity "<Cleanup>" "<Local>" $scriptName "ERROR" "Cleanup process failed: $($_.Exception.Message)"
- }
- }
- function Test-DomainAuth {
- param (
- [string]$DomainName,
- [string]$RemoteComputer
- )
- try {
- Get-ADDomain -Server $RemoteComputer -ErrorAction Stop | Out-Null
- Log-Activity -DomainName $DomainName -RemoteComputer $RemoteComputer -ScriptName "AuthTest" -ActionType "SUCCESS" -Message "Authenticated successfully."
- return $true
- } catch {
- $outer = $_.Exception
- $deepest = $outer
- while ($deepest.InnerException) {
- $deepest = $deepest.InnerException
- }
- $baseMessage = "Authentication failed against ${RemoteComputer}: $($outer.Message)"
- $innerNote = if ($deepest -ne $outer) { " | InnerException: $($deepest.Message)" } else { "" }
- $fullMessage = "$baseMessage$innerNote"
- Log-Activity -DomainName $DomainName -RemoteComputer $RemoteComputer -ScriptName "AuthTest" -ActionType "ERROR" -Message $fullMessage
- return $false
- }
- }
- function Resolve-TrustedDomainDC {
- param (
- [Parameter(Mandatory)][string]$DomainName
- )
- $logName = "DC-Resolve"
- $testedDCs = @()
- $pdc = $null
- # Attempt to discover and log the Preferred PDC
- try {
- $pdc = Get-ADDomainController -Discover -Service "PrimaryDC" -DomainName $DomainName -ErrorAction Stop
- $pdcName = $pdc.DNSHostName
- if (-not $pdcName) { $pdcName = $pdc.HostName }
- if (-not $pdcName) { $pdcName = $pdc.Name }
- Log-Activity $DomainName $pdcName $logName "INFO" "Preferred PDC discovered: $pdcName"
- } catch {
- Log-Activity $DomainName "<None>" $logName "WARNING" "PDC resolution failed: $($_.Exception.Message)"
- }
- # Try the preferred PDC first
- if ($pdc) {
- $pdcCandidates = @($pdc.DNSHostName, $pdc.HostName, $pdc.Name) | Where-Object { $_ }
- foreach ($candidate in $pdcCandidates) {
- $testedDCs += $candidate
- if (Test-Connection -ComputerName $candidate -Count 1 -Quiet) {
- Log-Activity $DomainName $candidate $logName "INFO" "Selected preferred PDC: $candidate"
- return @{ Computer = $candidate; DcsTested = $testedDCs }
- }
- }
- Log-Activity $DomainName "<None>" $logName "WARNING" "Could not reach preferred PDC after testing known candidates"
- }
- # Try fallback DCs
- try {
- $dcs = Get-ADDomainController -Filter * -Server $DomainName
- foreach ($dc in $dcs) {
- $candidate = $dc.DNSHostName
- if (-not $candidate) { $candidate = $dc.HostName }
- if (-not $candidate) { $candidate = $dc.Name }
- if (-not $candidate) { continue }
- $testedDCs += $candidate
- Log-Activity $DomainName $candidate $logName "INFO" "Testing fallback DC: $candidate"
- if (Test-Connection -ComputerName $candidate -Count 1 -Quiet) {
- Log-Activity $DomainName $candidate $logName "INFO" "Fallback DC selected: $candidate"
- return @{ Computer = $candidate; DcsTested = $testedDCs }
- } else {
- Log-Activity $DomainName $candidate $logName "WARNING" "Fallback DC $candidate unreachable via ping — skipping"
- }
- }
- $dcList = $testedDCs -join ", "
- Log-Activity $DomainName "<None>" $logName "ERROR" "All fallback DCs failed. Tested: $dcList"
- } catch {
- Log-Activity $DomainName "<None>" $logName "ERROR" "Fallback DC lookup failed: $($_.Exception.Message)"
- }
- return @{ Computer = $null; DcsTested = $testedDCs }
- }
- function Send-EmailReport {
- param ([string]$Date)
- Log-Activity "<AllDomains>" "<Local>" "EmailReport" "INFO" "Email sending is ENABLED. Attempting to send report..."
- try {
- $activityLog = Import-Csv -Path $activityLogPath -Encoding UTF8
- $warningsAndErrors = $activityLog | Where-Object { $_.ActionType -match "ERROR|WARNING" }
- $countErrors = ($warningsAndErrors | Where-Object { $_.ActionType -eq "ERROR" }).Count
- $countWarnings = ($warningsAndErrors | Where-Object { $_.ActionType -eq "WARNING" }).Count
- $failedAuthReport = ""
- if (Test-Path $failedAuthPath) {
- $failedAuthDomains = Get-Content $failedAuthPath | Where-Object { $_ -match '\S' }
- $failedAuthReport = if ($failedAuthDomains.Count -gt 0) {
- "`nDomains with Failed Authentication:`n" + ($failedAuthDomains -join "`n") + "`n"
- } else {
- "`nNo authentication failures were detected.`n"
- }
- }
- $crossDomainStatus = if ($EnableCrossDomainMemberLookups) { "ENABLED" } else { "DISABLED" }
- # Grouping for summary
- $domainsSuccess = ($activityLog | Where-Object { $_.ActionType -eq "SUCCESS" }).DomainName | Sort-Object -Unique
- $domainsWarning = ($activityLog | Where-Object { $_.ActionType -eq "WARNING" }).DomainName | Sort-Object -Unique
- $domainsError = ($activityLog | Where-Object { $_.ActionType -eq "ERROR" }).DomainName | Sort-Object -Unique
- $summaryHeader = @"
- Summary of Domain Processing:
- Success : $($domainsSuccess.Count) - $($domainsSuccess -join ", ")
- Warnings: $($domainsWarning.Count) - $($domainsWarning -join ", ")
- Errors : $($domainsError.Count) - $($domainsError -join ", ")
- Cross-domain member resolution: $crossDomainStatus
- $failedAuthReport
- "@
- $details = if ($warningsAndErrors.Count -eq 0) {
- "No errors or warnings occurred during the audit."
- } else {
- $warningsAndErrors | ForEach-Object {
- "[{0}] [{1}] [{2}] [{3}] {4}" -f $_.ActionType, $_.DomainName, $_.RemoteComputer, $_.ScriptName, $_.Message
- } | Out-String
- }
- $scriptInfoLine = "Script: $($MyInvocation.MyCommand.Name) | Version: $scriptversion | Author: $scriptauthor | Last Updated: $scriptupdated`n`n"
- $body = $scriptInfoLine + $summaryHeader + $details
- $subject = "AD Audit Summary: $countErrors ERROR(s), $countWarnings WARNING(s) - $Date"
- $attachments = @($activityLogPath)
- if (Test-Path $failedAuthPath) {
- $attachments += $failedAuthPath
- }
- Send-MailMessage -To $emailTo -From $emailFrom -Subject $subject -Body $body -SmtpServer $smtpServer -Port $smtpPort -Attachments $attachments
- Log-Activity "<AllDomains>" "<Local>" "EmailReport" "NOTIFY" "Email sent to $emailTo with activity log and any failed authentication log"
- } catch {
- Log-Activity "<AllDomains>" "<Local>" "EmailReport" "ERROR" "Failed to send email: $_"
- }
- }
- function Get-AllGroupInputsFromCsvFolder {
- [CmdletBinding()]
- param ()
- $scriptName = "GroupInputLoader"
- $groupData = @() # Native array of PSCustomObjects
- if (-not (Test-Path $global:GroupInputFolder)) {
- Log-Activity "<Init>" "<Local>" $scriptName "WARNING" "Group input folder does not exist: $global:GroupInputFolder"
- return $groupData
- }
- $csvFiles = Get-ChildItem -Path $global:GroupInputFolder -Filter *.csv -File -ErrorAction SilentlyContinue
- if ($csvFiles.Count -eq 0) {
- Log-Activity "<Init>" "<Local>" $scriptName "WARNING" "No CSV files found in folder: $global:GroupInputFolder"
- return $groupData
- }
- foreach ($csvFile in $csvFiles) {
- try {
- $rawData = Import-Csv -Path $csvFile.FullName
- if ($rawData.Count -eq 0) {
- Log-Activity "<Init>" "<Local>" $scriptName "WARNING" "$($csvFile.Name) is empty"
- continue
- }
- $sampleHeaders = $rawData[0].PSObject.Properties.Name
- $domainCol = $sampleHeaders | Where-Object { $_ -match 'domain' } | Select-Object -First 1
- $groupCol = $sampleHeaders | Where-Object { $_ -match 'group' } | Select-Object -First 1
- if (-not $domainCol -or -not $groupCol) {
- Log-Activity "<Init>" "<Local>" $scriptName "WARNING" "Could not identify Domain and Group columns in $($csvFile.Name)"
- continue
- }
- $entryCount = 0
- foreach ($entry in $rawData) {
- $domainVal = $entry.$domainCol
- $groupVal = $entry.$groupCol
- if ([string]::IsNullOrWhiteSpace($domainVal) -or [string]::IsNullOrWhiteSpace($groupVal)) {
- continue
- }
- # Strip quotes and whitespace
- $cleanDomain = ($domainVal -replace "^[\'""]+|[\'""]+$", "").ToLower().Trim()
- $cleanGroup = ($groupVal -replace "^[\'""]+|[\'""]+$", "").Trim()
- if ($cleanDomain -and $cleanGroup) {
- $groupData += [PSCustomObject]@{
- Domain = $cleanDomain
- GroupName = $cleanGroup
- }
- $entryCount++
- }
- }
- Log-Activity "<Init>" "<Local>" $scriptName "INFO" "Loaded $entryCount valid entries from $($csvFile.Name) using columns '$domainCol' and '$groupCol'"
- } catch {
- Log-Activity "<Init>" "<Local>" $scriptName "ERROR" "Failed to process $($csvFile.FullName): $_"
- }
- }
- return $groupData
- }
- if (-not $EnableAllGroupsQuery) {
- $global:GroupInputArray = @(Get-AllGroupInputsFromCsvFolder)
- }
- function Start-DomainJobs {
- param (
- [array]$TrustList,
- [string]$OutputRoot,
- [string]$Date,
- [bool]$EnableAllGroupsQuery,
- [bool]$EnableCrossDomainMemberLookups,
- [bool]$EnableConsoleOutput,
- [array]$GroupInputArray,
- [scriptblock]$GroupMembershipScript,
- [string]$ActivityLogPath,
- [int]$ThrottleLimit,
- [string]$DailyFolder
- )
- $domainJobs = @()
- foreach ($trust in $TrustList) {
- $domainName = $trust.Name
- while ($domainJobs.Count -ge $ThrottleLimit) {
- # Remove any nulls before processing
- $validJobs = $domainJobs | Where-Object { $_ -ne $null }
- # Log any null jobs (if they appear)
- if ($validJobs.Count -lt $domainJobs.Count) {
- $nullCount = $domainJobs.Count - $validJobs.Count
- Log-Activity "<Parallel>" "<Local>" "JobLaunch" "WARNING" "Detected $nullCount job(s) failed to start and were removed from tracking."
- }
- # Update working job list
- $domainJobs = $validJobs
- # Wait for any job to complete
- $finished = if ($domainJobs.Count -gt 0) {
- Wait-Job -Job $domainJobs -Any -Timeout 5
- } else {
- $null
- }
- if ($finished) {
- try {
- Receive-Job -Job $finished | Out-Null
- } catch {
- Log-Activity "<Parallel>" "<Local>" "JobReceive" "ERROR" "Exception while receiving completed job output: $_"
- }
- # Remove job from tracking
- Remove-Job -Job $finished
- $domainJobs = $domainJobs | Where-Object { $_ -and $_.State -eq 'Running' }
- }
- }
- # Start parallel job
- $job = Start-ThreadJob -ScriptBlock {
- param (
- $DomainName,
- $Trust,
- $OutputRoot,
- $Date,
- $EnableAllGroupsQuery,
- $EnableCrossDomainMemberLookups,
- $EnableConsoleOutput,
- $GroupInputArray,
- $GroupMembershipScript,
- $ActivityLogPath,
- $DailyFolder
- )
- $script:EnableConsoleOutput = $EnableConsoleOutput
- Import-Module ActiveDirectory
- # Includee the required functions
- function Log-Activity {
- param (
- [string]$DomainName,
- [string]$RemoteComputer,
- [string]$ScriptName,
- [string]$ActionType,
- [string]$Message
- )
- $ts = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
- # Prepare CSV-safe line
- $csvSafeMessage = '"' + ($Message -replace '"', '""') + '"'
- $logLine = "$DomainName,$RemoteComputer,$ScriptName,$ActionType,$csvSafeMessage"
- # === Write to disk using Add-Content (safe in parallel jobs) ===
- try {
- Add-Content -Path $ActivityLogPath -Value $logLine -Encoding UTF8 -ErrorAction Stop
- } catch {
- # Failsafe logging fallback (optional)
- Write-Warning "[$ts] [LogFail] Could not write log line to ${ActivityLogPath}: $_"
- }
- # === Optional console output ===
- if ($EnableConsoleOutput) {
- $color = switch ($ActionType.ToUpper()) {
- "INFO" { "Cyan" }
- "SUCCESS" { "Green" }
- "WARNING" { "Yellow" }
- "ERROR" { "Red" }
- "RUNTIME" { "White" }
- "NOTIFY" { "Blue" }
- default { "White" }
- }
- Write-Host "[$ts] [$ActionType] [$DomainName] [$RemoteComputer] [$ScriptName] $Message" -ForegroundColor $color
- }
- }
- function Resolve-TrustedDomainDC {
- param (
- [Parameter(Mandatory)][string]$DomainName
- )
- $logName = "DC-Resolve"
- $testedDCs = @()
- $pdc = $null
- # Attempt to discover and log the Preferred PDC
- try {
- $pdc = Get-ADDomainController -Discover -Service "PrimaryDC" -DomainName $DomainName -ErrorAction Stop
- $pdcName = $pdc.DNSHostName
- if (-not $pdcName) { $pdcName = $pdc.HostName }
- if (-not $pdcName) { $pdcName = $pdc.Name }
- Log-Activity $DomainName $pdcName $logName "INFO" "Preferred PDC discovered: $pdcName"
- } catch {
- Log-Activity $DomainName "<None>" $logName "WARNING" "PDC resolution failed: $($_.Exception.Message)"
- }
- # Try the preferred PDC first
- if ($pdc) {
- $pdcCandidates = @($pdc.DNSHostName, $pdc.HostName, $pdc.Name) | Where-Object { $_ }
- foreach ($candidate in $pdcCandidates) {
- $testedDCs += $candidate
- if (Test-Connection -ComputerName $candidate -Count 1 -Quiet) {
- Log-Activity $DomainName $candidate $logName "INFO" "Selected preferred PDC: $candidate"
- return @{ Computer = $candidate; DcsTested = $testedDCs }
- }
- }
- Log-Activity $DomainName "<None>" $logName "WARNING" "Could not reach preferred PDC after testing known candidates"
- }
- # Try fallback DCs
- try {
- $dcs = Get-ADDomainController -Filter * -Server $DomainName
- foreach ($dc in $dcs) {
- $candidate = $dc.DNSHostName
- if (-not $candidate) { $candidate = $dc.HostName }
- if (-not $candidate) { $candidate = $dc.Name }
- if (-not $candidate) { continue }
- $testedDCs += $candidate
- Log-Activity $DomainName $candidate $logName "INFO" "Testing fallback DC: $candidate"
- if (Test-Connection -ComputerName $candidate -Count 1 -Quiet) {
- Log-Activity $DomainName $candidate $logName "INFO" "Fallback DC selected: $candidate"
- return @{ Computer = $candidate; DcsTested = $testedDCs }
- } else {
- Log-Activity $DomainName $candidate $logName "WARNING" "Fallback DC $candidate unreachable via ping — skipping"
- }
- }
- $dcList = $testedDCs -join ", "
- Log-Activity $DomainName "<None>" $logName "ERROR" "All fallback DCs failed. Tested: $dcList"
- } catch {
- Log-Activity $DomainName "<None>" $logName "ERROR" "Fallback DC lookup failed: $($_.Exception.Message)"
- }
- return @{ Computer = $null; DcsTested = $testedDCs }
- }
- function Test-DomainAuth {
- param (
- [string]$DomainName,
- [string]$RemoteComputer
- )
- try {
- Get-ADDomain -Server $RemoteComputer -ErrorAction Stop | Out-Null
- Log-Activity -DomainName $DomainName -RemoteComputer $RemoteComputer -ScriptName "AuthTest" -ActionType "SUCCESS" -Message "Authenticated successfully."
- return $true
- } catch {
- $outer = $_.Exception
- $deepest = $outer
- while ($deepest.InnerException) {
- $deepest = $deepest.InnerException
- }
- $baseMessage = "Authentication failed against ${RemoteComputer}: $($outer.Message)"
- $innerNote = if ($deepest -ne $outer) { " | InnerException: $($deepest.Message)" } else { "" }
- $fullMessage = "$baseMessage$innerNote"
- Log-Activity -DomainName $DomainName -RemoteComputer $RemoteComputer -ScriptName "AuthTest" -ActionType "ERROR" -Message $fullMessage
- return $false
- }
- }
- # Lightweight NTLM/Kerberos detection
- try {
- $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent()
- $authMethod = $identity.AuthenticationType
- $user = $identity.Name
- if ($authMethod -eq "NTLM") {
- Log-Activity -DomainName $DomainName -RemoteComputer "<Local>" -ScriptName "AuthCheck" -ActionType "WARNING" -Message "Using NTLM authentication for $user — Kerberos likely failed or unavailable."
- } else {
- Log-Activity -DomainName $DomainName -RemoteComputer "<Local>" -ScriptName "AuthCheck" -ActionType "INFO" -Message "Using $authMethod authentication for $user."
- }
- } catch {
- Log-Activity -DomainName $DomainName -RemoteComputer "<Local>" -ScriptName "AuthCheck" -ActionType "WARNING" -Message "Could not determine authentication method: $($_.Exception.Message)"
- }
- # Define the domain and create the domain-specific subfolder for the reports
- $domainName = $trust.Name
- $domainFolder = Join-Path $dailyFolder $domainName
- if (-not (Test-Path $domainFolder)) {
- New-Item -ItemType Directory -Path $domainFolder -Force | Out-Null
- }
- $dcStartTime = Get-Date
- $dcResult = Resolve-TrustedDomainDC -DomainName $domainName
- $remoteComputer = [string]$dcResult.Computer
- # Catch if there's a null value or no DC was reachable and stop processing this loop
- if (-not $remoteComputer) {
- Log-Activity $domainName "<None>" "DC-Select" "ERROR" "No reachable DC found for $domainName"
- continue
- }
- # Test to make sure AD cmdlets work talking to the remote DC, no point in running the reports otherwise
- if (-not (Test-DomainAuth -DomainName $domainName -RemoteComputer $remoteComputer)) {
- continue
- }
- $dcDuration = (Get-Date) - $dcStartTime
- $dcTime = "{0:F3}" -f $dcDuration.TotalSeconds
- Log-Activity $domainName "<Local>" "DC-Select" "RUNTIME" "DC selection took $dcTime seconds"
- # Here we define the reports themselves
- $reportScripts = @(
- @{ Name = "AD_User_Export"; Script = {
- param([string]$Server)
- Get-ADUser -Server $Server -Filter * -Properties UserPrincipalName, whenCreated, LastLogonDate, PasswordLastSet, PasswordNeverExpires, PasswordExpired, PasswordNotRequired, Enabled, ObjectGUID |
- Select-Object Name, SamAccountName, UserPrincipalName, whenCreated, LastLogonDate, PasswordLastSet, PasswordNeverExpires, PasswordExpired, PasswordNotRequired, Enabled, ObjectGUID
- }},
- @{ Name = "AdminCount"; Script = {
- param([string]$Server)
- Get-ADUser -Server $Server -Filter 'adminCount -eq 1' -Properties adminCount, Name, SamAccountName, UserPrincipalName |
- Select-Object Name, SamAccountName, UserPrincipalName, adminCount
- }},
- @{ Name = "DomainTrusts"; Script = {
- param([string]$Server)
- Get-ADTrust -Server $Server -Filter * |
- Select-Object Name, TrustType, TrustDirection, TrustAttributes
- }}
- )
- # Some error handling here for the group membership script
- if (-not $domainName) {
- Log-Activity "<Init>" "<Local>" "GroupMembership" "WARNING" "Target domainName is null or empty — GroupMembership will be skipped."
- } elseif (-not $EnableAllGroupsQuery -and (-not $GroupInputArray -or $GroupInputArray.Count -eq 0)) {
- Log-Activity "<Init>" "<Local>" "GroupMembership" "WARNING" "Group input array is empty — Domain-specific GroupMembership report will be skipped."
- } else {
- # Filter for matching domain entries only (or the script will take days to run)
- if ($EnableAllGroupsQuery) {
- try {
- $domainGroupEntries = Get-ADGroup -Server $remoteComputer -Filter * | Select-Object @{Name="Domain";Expression={$domainName}}, Name
- $domainGroupEntries = $domainGroupEntries | ForEach-Object {
- [PSCustomObject]@{
- Domain = $_.Domain
- GroupName = $_.Name
- }
- }
- Log-Activity $domainName $remoteComputer "GroupMembership" "INFO" "Queried all groups in $domainName — total: $($domainGroupEntries.Count)"
- } catch {
- Log-Activity $domainName $remoteComputer "GroupMembership" "ERROR" "Failed to retrieve all groups for ${domainName}: $_"
- $domainGroupEntries = @()
- }
- } else {
- $domainGroupEntries = $GroupInputArray | Where-Object { $_.Domain -eq $domainName }
- Log-Activity $domainName "<Debug>" "DomainDebug" "INFO" "Filtered $($domainGroupEntries.Count) group entries for $domainName"
- }
- Log-Activity $domainName "<Debug>" "DomainDebug" "INFO" "Filtering group entries for $domainName"
- # Pre-check: Can we resolve Domain Admins in this domain?
- $domainAdminCheck = $null
- try {
- $domainAdminCheck = Get-ADGroup -Server $remoteComputer -Identity "Domain Admins" -ErrorAction Stop
- Log-Activity $domainName $remoteComputer "GroupMembership" "INFO" "Verified Domain Admins group exists: $($domainAdminCheck.DistinguishedName)"
- } catch {
- Log-Activity $domainName $remoteComputer "GroupMembership" "ERROR" "Cannot resolve Domain Admins group — skipping group membership lookups for this domain"
- $domainGroupEntries = @()
- }
- if ($domainGroupEntries.Count -gt 0) {
- $reportScripts += @{
- Name = "GroupMembership"
- Script = $groupMembershipScript
- Arguments = @($domainGroupEntries, $domainName, $remoteComputer)
- }
- Log-Activity "<Init>" "<Local>" "GroupMembership" "INFO" "Found $($domainGroupEntries.Count) group entries for $domainName"
- } else {
- Log-Activity "<Init>" "<Local>" "GroupMembership" "WARNING" "No matching group entries found for $domainName — skipping"
- }
- }
- # Process the reports defined earlier
- foreach ($script in $reportScripts) {
- $name = $script.Name
- $csvPath = Join-Path $domainFolder "$domainName-$name-$Date.csv"
- $warnPath = Join-Path $domainFolder "$domainName-$name-Warnings.txt"
- $memBefore = [GC]::GetTotalMemory($false)
- $scriptStart = Get-Date
- try {
- if ($script.ContainsKey("Arguments")) {
- $argList = $script.Arguments
- # Listing individual arguments is necessary to avoid splatting the array.
- $result = & $script.Script $argList[0] $argList[1] $argList[2]
- } else {
- $result = & $script.Script -Server $remoteComputer
- }
- if ($name -eq "GroupMembership") {
- $result.Results | Export-Csv -Path $csvPath -NoTypeInformation -Force
- $result.Warnings | Out-File -FilePath $warnPath -Encoding UTF8 -Force
- Log-Activity $domainName $remoteComputer $name "SUCCESS" "Exported $($result.Results.Count) users with $($result.Warnings.Count) warnings"
- } else {
- $result | Export-Csv -Path $csvPath -NoTypeInformation -Force
- Log-Activity $domainName $remoteComputer $name "SUCCESS" "Exported $($result.Count) records"
- }
- } catch {
- Log-Activity $domainName $remoteComputer $name "ERROR" "Failed to run script: $_"
- } finally {
- $memAfter = [GC]::GetTotalMemory($false)
- $memDelta = $memAfter - $memBefore
- $memUsedMB = "{0:N2}" -f ($memDelta / 1MB)
- $scriptDuration = (Get-Date) - $scriptStart
- $scriptTime = "{0:F3}" -f $scriptDuration.TotalSeconds
- Log-Activity $domainName $remoteComputer $name "RUNTIME" "$name execution took $scriptTime seconds"
- Log-Activity $domainName $remoteComputer $name "RUNTIME" "$name memory delta: $memUsedMB MB"
- }
- }
- } -ArgumentList $domainName, $trust, $OutputRoot, $Date, $EnableAllGroupsQuery, $EnableCrossDomainMemberLookups, $EnableConsoleOutput, $GroupInputArray, $GroupMembershipScript, $ActivityLogPath, $DailyFolder
- if ($job) {
- $domainJobs += $job
- } else {
- Log-Activity "<Parallel>" $domainName "JobLaunch" "ERROR" "Failed to start job for $domainName"
- }
- }
- # Wait for any remaining jobs to finish
- $domainJobs = $domainJobs | Where-Object { $_ -ne $null }
- if ($domainJobs.Count -eq 0) {
- Log-Activity "<Parallel>" "<Local>" "JobMonitor" "WARNING" "No valid jobs were found to complete. All may have failed to launch."
- }
- foreach ($j in $domainJobs) {
- $jobDomain = "<Unknown>"
- try {
- # Attempt to receive output and identify source domain
- if ($j.ChildJobs.Count -gt 0 -and $j.ChildJobs[0].JobStateInfo.Location) {
- $jobDomain = $j.ChildJobs[0].JobStateInfo.Location
- }
- Receive-Job -Job $j -ErrorAction Stop | Out-Null
- } catch {
- Log-Activity $jobDomain "<Unknown>" "JobReceive" "ERROR" "Exception receiving job output: $_"
- }
- if ($j.State -eq 'Failed' -or $j.HasMoreData -eq $false) {
- if ($j.ChildJobs[0].JobStateInfo.Reason) {
- $reason = $j.ChildJobs[0].JobStateInfo.Reason.Exception.Message
- Log-Activity $jobDomain "<Unknown>" "JobFailure" "ERROR" "Background job failed: $reason"
- } else {
- Log-Activity $jobDomain "<Unknown>" "JobFailure" "ERROR" "Background job ended in failed state without specific error."
- }
- }
- Remove-Job -Job $j
- }
- }
- # Log script start time
- Log-Activity "<SCRIPT>" "$env:hostname" "INFO" "Script starting at $DayStamp"
- # Check that Start-ThreadJob is available
- if (-not (Get-Command Start-ThreadJob -ErrorAction SilentlyContinue)) {
- Write-Warning "Start-ThreadJob is not available. Please install the ThreadJob module: Install-Module ThreadJob"
- Exit 1
- }
- # GroupMembership script now filters for target domain, not current domain (previous version used the context domain causing lookups to fail)
- $groupMembershipScript = {
- param (
- [object[]]$inputArray,
- [string]$targetDomain,
- [string]$remoteComputer,
- [bool]$EnableCrossDomainMemberLookups = $false
- )
- Import-Module ActiveDirectory
- $results = New-Object System.Collections.Generic.List[Object]
- $warnings = New-Object System.Collections.Generic.List[string]
- $visited = @{}
- function Get-DomainFromDN {
- param ($dn)
- if ($dn -match "DC=") {
- return ($dn -split "," | Where-Object { $_ -like "DC=*" } | ForEach-Object { $_ -replace "DC=", "" }) -join "."
- }
- }
- function Normalize-Domain {
- param ($domainName)
- if ($null -eq $domainName) { return $null }
- return $domainName.ToLower().Trim()
- }
- function Recurse-Members {
- param (
- $GroupDN,
- $Trail,
- $GroupDomain,
- $GroupName
- )
- if (-not $GroupDN) {
- $warnings.Add("Skipping group with empty DN — GroupDomain: '$GroupDomain', GroupName: '$GroupName'")
- return
- }
- foreach ($memberDN in $group.Member) {
- $memberDomain = Get-DomainFromDN -dn $memberDN
- $normalizedTarget = $targetDomain.ToLower()
- $member = $null
- if ($memberDomain -eq $normalizedTarget) {
- try {
- $member = Get-ADObject -Server $remoteComputer -Identity $memberDN -Properties objectClass, samAccountName, name, distinguishedName
- $warnings += "Fetched member: $($member.SamAccountName) from $memberDomain"
- } catch {
- $warnings += "Failed to get member '$memberDN': $($_.Exception.Message)"
- continue
- }
- } elseif ($EnableCrossDomainMemberLookups) {
- $foreignDC = $null
- try {
- $foreignDCInfo = Get-ADDomainController -Discover -DomainName $memberDomain -Service PrimaryDC -ErrorAction Stop
- $foreignDC = $foreignDCInfo.DNSHostName
- $warnings += "Discovered foreign DC for ${memberDomain}: $foreignDC"
- } catch {
- $warnings += "Could not resolve DC for ${memberDomain}: $($_.Exception.Message)"
- continue
- }
- if ($foreignDC) {
- try {
- $member = Get-ADObject -Server $foreignDC -Identity $memberDN -Properties objectClass, samAccountName, name, distinguishedName
- $warnings += "Fetched foreign member: $($member.SamAccountName) from $memberDomain"
- } catch {
- $warnings += "Failed remote fetch for $memberDN from ${foreignDC}: $($_.Exception.Message)"
- continue
- }
- }
- } else {
- $warnings += "Skipping cross-domain lookup for $memberDN (domain=$memberDomain) — feature disabled"
- continue
- }
- if ($null -eq $member) { continue }
- if ($member.objectClass -eq 'user') {
- $results.Add([PSCustomObject]@{
- GroupDomain = $GroupDomain
- GroupName = $GroupName
- UserDomain = $memberDomain
- SamAccountName = $member.SamAccountName
- NestedTrail = $Trail
- })
- } elseif ($member.objectClass -eq 'group') {
- $key = "$memberDomain\$($member.DistinguishedName)"
- if (-not $visited.ContainsKey($key)) {
- $visited[$key] = $true
- $nextTrail = if ($Trail) { "$Trail > $($member.Name)@$memberDomain" } else { "$($member.Name)@$memberDomain" }
- Recurse-Members -GroupDN $member.DistinguishedName -Trail $nextTrail -GroupDomain $GroupDomain -GroupName $GroupName
- }
- }
- }
- }
- # Sanity check
- if ($null -eq $inputArray -or $inputArray.Count -eq 0) {
- $warnings.Add("Input array was null or empty. Skipping execution.")
- return @{ Results = $results; Warnings = $warnings }
- }
- foreach ($entry in $inputArray) {
- try {
- $resolvedGroup = Get-ADGroup -Server $remoteComputer -Identity $entry.GroupName -Properties DistinguishedName -ErrorAction Stop
- $key = "$targetDomain\$($resolvedGroup.DistinguishedName)"
- if (-not $visited.ContainsKey($key)) {
- $visited[$key] = $true
- Recurse-Members -GroupDN $resolvedGroup.DistinguishedName -Trail "" -GroupDomain $targetDomain -GroupName $resolvedGroup.Name
- }
- } catch {
- $warnings.Add("Could not resolve group '$($entry.GroupName)' in domain '$targetDomain': $($_.Exception.Message)")
- }
- }
- Log-Activity "<Debug>" "<Local>" "GroupMembership" "INFO" "inputArray type: $($inputArray.GetType().FullName), count: $($inputArray.Count)"
- return @{ Results = $results; Warnings = $warnings }
- }
- # Domain and trust discovery that exports the trust list for logging purposes and import purposes
- Import-Module ActiveDirectory
- # Get the forest name, get the trust list, and export to a file.
- try {
- $Forest = Get-ADForest -ErrorAction Stop
- $ForestName = $Forest.Name
- $trusts = Get-ADTrust -Filter * -ErrorAction Stop | Where-Object { $_.TrustDirection -ne 'Inbound' -and $_.Name -notlike "*.EXEMPTDOMAINS.com" }
- $trustList = $trusts | Select-Object Name, TrustType, TrustDirection, TrustAttributes
- $csvPath1 = Join-Path $outputRoot "$ForestName-DomainTrusts-$Date.csv"
- $trustList | Export-Csv -Path $csvPath1 -NoTypeInformation
- Log-Activity -DomainName "<LocalForest>" -RemoteComputer "<Local>" -ScriptName "Get-ADTrust" -ActionType "INFO" -Message "Exported domain trusts to $csvPath1"
- } catch {
- Log-Activity -DomainName "<LocalForest>" -RemoteComputer "<Local>" -ScriptName "Get-ADTrust" -ActionType "ERROR" -Message $_.Exception.Message
- Exit
- }
- # Import the trusted domains list from the file
- $trusts = Import-Csv -Path $csvPath1
- # If the EnableLocalDomain variable is true, process the host's local domain
- if ($EnableLocalDomain) {
- $contextDomain = (Get-ADDomain).DNSRoot
- # Create a pseudo-trust object for compatibility
- $localTrust = [PSCustomObject]@{
- Name = $contextDomain
- }
- Start-DomainJobs -TrustList @($localTrust) `
- -OutputRoot $outputRoot `
- -Date $Date `
- -EnableAllGroupsQuery $EnableAllGroupsQuery `
- -EnableCrossDomainMemberLookups $EnableCrossDomainMemberLookups `
- -EnableConsoleOutput $EnableConsoleOutput `
- -GroupInputArray $global:GroupInputArray `
- -GroupMembershipScript $groupMembershipScript `
- -ActivityLogPath $activityLogPath `
- -ThrottleLimit $MaxParallelDomainJobs `
- -DailyFolder $dailyFolder
- }
- # Now call the function to loop through each trust and find a reachable DC preferring the PDC
- Start-DomainJobs -TrustList $trusts `
- -OutputRoot $outputRoot `
- -Date $Date `
- -EnableAllGroupsQuery $EnableAllGroupsQuery `
- -EnableCrossDomainMemberLookups $EnableCrossDomainMemberLookups `
- -EnableConsoleOutput $EnableConsoleOutput `
- -GroupInputArray $global:GroupInputArray `
- -GroupMembershipScript $groupMembershipScript `
- -ActivityLogPath $activityLogPath `
- -ThrottleLimit $MaxParallelDomainJobs `
- -DailyFolder $dailyFolder
- # Streamed summary logging — no need to re-import full log from disk
- try {
- $successes = $domainStatusTable.GetEnumerator() | Where-Object { $_.Value -eq "SUCCESS" } | ForEach-Object { $_.Key }
- $warnings = $domainStatusTable.GetEnumerator() | Where-Object { $_.Value -eq "WARNING" } | ForEach-Object { $_.Key }
- $errors = $domainStatusTable.GetEnumerator() | Where-Object { $_.Value -eq "ERROR" } | ForEach-Object { $_.Key }
- if ($successes.Count -gt 0) {
- Log-Activity "<Summary>" "<Local>" "Summary" "INFO" ("Domains with SUCCESS : " + ($successes -join ", "))
- }
- if ($warnings.Count -gt 0) {
- Log-Activity "<Summary>" "<Local>" "Summary" "INFO" ("Domains with WARNINGS: " + ($warnings -join ", "))
- }
- if ($errors.Count -gt 0) {
- Log-Activity "<Summary>" "<Local>" "Summary" "INFO" ("Domains with ERRORS : " + ($errors -join ", "))
- }
- } catch {
- Log-Activity "<Summary>" "<Local>" "Summary" "ERROR" "Failed to summarize domain results: $_"
- }
- # Export the domain status table to CSV and strip any " marks from the output
- try {
- $domainStatusCsv = $domainStatusTable.GetEnumerator() | ForEach-Object {
- [PSCustomObject]@{
- DomainName = $_.Key
- Status = $_.Value
- }
- }
- $domainStatusCsv | Sort-Object DomainName | Export-Csv -Path $domainStatusPath -NoTypeInformation -Force
- Log-Activity "<Summary>" "<Local>" "DomainStatus" "INFO" "Exported domain status table to $domainStatusPath"
- } catch {
- Log-Activity "<Summary>" "<Local>" "DomainStatus" "ERROR" "Failed to export domain status table: $($_.Exception.Message)"
- }
- # Send the report via email if the $sendEmail variable is $true
- if ($sendEmail) { Send-EmailReport -Date $Date }
- else {
- Log-Activity "<AllDomains>" "<Local>" "EmailReport" "INFO" "Email sending is DISABLED. No report was sent."
- }
- # Run function to clean old reports, activity logs, and so on.
- Clean-OldReports -ReportRoot $outputRoot -RetentionDays $RetentionDays
- $scriptDuration = (Get-Date) - $scriptStartTime
- $scriptTimeMin = "{0:F3}" -f $scriptDuration.TotalMinutes
- Log-Activity "<Summary>" "<Local>" "Script" "RUNTIME" "Total script execution time: $scriptTimeMin minutes"
- Log-Activity "<Summary>" "<Local>" "Script" "INFO" "Successfully completed script at $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
Advertisement
Add Comment
Please, Sign In to add comment
Advertisement