Start-ADEHM.ps1
|
<# .SYNOPSIS ADEHM - Active Directory Enterprise Health Monitor Main entry point of the monitoring engine. .DESCRIPTION Monitors every domain controller defined in the configuration, collects system and Active Directory indicators through CIM/WinRM, generates a professional HTML report and sends it by email. Execution flow: Read Configuration -> Create Logger -> Read DC list -> CIM Connection -> Collection -> Report Generation -> Mail Delivery -> Archiving -> End .PARAMETER ConfigPath Path to the ADEHM.config.psd1 configuration file. Default: .\Config\ADEHM.config.psd1 .PARAMETER Credential Service account used for remote CIM connections to the domain controllers. Must hold the minimal delegations described in Docs/PERMISSIONS.md (least-privilege principle). .PARAMETER MailCredential Credentials used to authenticate to the SMTP server. Optional and only relevant when Config.Mail.Anonymous is $false (default): - Omitted: -Credential is reused for mail, which is correct for relays that trust the AD service account. - Provided: used instead of -Credential, for a mail identity that differs from the AD service account (e.g. Gmail, Office 365 consumer, a dedicated mail-only account). For an anonymous internal relay (no credentials expected at all), set Mail.Anonymous = $true in the configuration instead — neither -Credential nor -MailCredential is then needed for mail delivery. .PARAMETER DemoMode Generates simulated data, with no real network connection, to validate the installation, the HTML report rendering and the mail flow. Useful for acceptance testing before production rollout. .EXAMPLE .\Start-ADEHM.ps1 -ConfigPath .\Config\ADEHM.config.psd1 -Credential (Get-Credential) .EXAMPLE .\Start-ADEHM.ps1 -DemoMode .EXAMPLE .\Start-ADEHM.ps1 -Credential (Get-Credential DOMAIN\svc-adehm) -MailCredential (Get-Credential yourbox@gmail.com) #> [CmdletBinding()] param( [string]$ConfigPath = (Join-Path $PSScriptRoot 'Config\ADEHM.config.psd1'), [System.Management.Automation.PSCredential]$Credential, [System.Management.Automation.PSCredential]$MailCredential, [switch]$DemoMode ) $ErrorActionPreference = 'Stop' $startTime = Get-Date # --------------------------------------------------------------------- # 0. Module loading (modular architecture) # --------------------------------------------------------------------- $moduleRoot = Join-Path $PSScriptRoot 'Modules' Get-ChildItem -Path $moduleRoot -Filter '*.psm1' | ForEach-Object { # -Global is required so that functions from one internal module (e.g. # Write-ADEHMError in ADEHM.Logger.psm1) remain visible from inside a # DIFFERENT internal module (e.g. ADEHM.Mail.psm1's catch block). # Without it, this works when Start-ADEHM.ps1 is run directly, but # breaks when invoked through the PowerShell Gallery module wrapper # (Install-Module ADEHM -> Start-ADEHM), because the wrapper's own # module session state changes where a plain Import-Module lands. Import-Module $_.FullName -Force -Global } try { # ------------------------------------------------------------- # 1. Read Configuration # ------------------------------------------------------------- $config = Import-ADEHMConfig -Path $ConfigPath # ------------------------------------------------------------- # 2. Create Logger # ------------------------------------------------------------- $logPath = Initialize-ADEHMLogger -LogDirectory $config.Paths.Logs -DebugMode:$config.General.DebugMode Write-ADEHMLog -Level INFO -Module Core -Message 'ADEHM Started' Write-ADEHMLog -Level INFO -Module Core -Message 'Reading Configuration' if ($DemoMode) { Write-ADEHMLog -Level WARN -Module Core -Message 'DemoMode active: simulated data, no real network connection.' } # ------------------------------------------------------------- # 3. Read DC list # ------------------------------------------------------------- $dcList = $config.DomainControllers Write-ADEHMLog -Level INFO -Module Core -Message "Domain Controllers to check: $($dcList.Count)" $results = New-Object System.Collections.Generic.List[object] $svcNames = $config.Services | ForEach-Object { $_.Name } if (-not $DemoMode) { $adIdentity = if ($Credential) { $Credential.UserName } else { "$env:USERDOMAIN\$env:USERNAME (current context)" } Write-ADEHMLog -Level INFO -Module Core -Message "AD/CIM identity: $adIdentity" } foreach ($dc in $dcList) { Write-ADEHMLog -Level INFO -Module Core -Message "Connecting $dc" $row = [ordered]@{ Hostname = $dc Site = $null IPAddress = $null Ping = $false WinRM = $false NTDS = $null DNS = $null DFSR = $null Netlogon = $null KDC = $null ADWS = $null SYSVOL = $false NetlogonShare = $false # \\DC\NETLOGON share (distinct from the Netlogon service: PowerShell hashtable keys are case-insensitive) CpuPercent = $null RamPercent = $null DiskFreePercent = $null DiskDetail = @() OSCaption = $null BuildNumber = $null LastBootTime = $null UptimeDays = $null } if ($DemoMode) { # ----------------------------------------------------- # Simulated data: validates the HTML report and the mail # flow without a real AD infrastructure. # ----------------------------------------------------- $row.Site = 'Default-First-Site-Name' $row.IPAddress = '10.10.{0}.{1}' -f (Get-Random -Minimum 0 -Maximum 5), (Get-Random -Minimum 2 -Maximum 254) $row.Ping = $true $row.WinRM = $true foreach ($svc in $svcNames) { $row[$svc] = ((Get-Random -Minimum 0 -Maximum 10) -gt 0) } $row.SYSVOL = $true $row.NetlogonShare = $true $row.CpuPercent = Get-Random -Minimum 5 -Maximum 95 $row.RamPercent = Get-Random -Minimum 20 -Maximum 95 $row.DiskFreePercent = Get-Random -Minimum 2 -Maximum 80 $row.DiskDetail = @([PSCustomObject]@{ DeviceID = 'C:' SizeGB = 120 FreeGB = [math]::Round(120 * $row.DiskFreePercent / 100, 1) FreePercent = $row.DiskFreePercent }) $row.OSCaption = 'Microsoft Windows Server 2022 Standard' $row.BuildNumber = '20348' $row.LastBootTime = (Get-Date).AddDays(-(Get-Random -Minimum 1 -Maximum 90)) $row.UptimeDays = [math]::Round(((Get-Date) - $row.LastBootTime).TotalDays, 1) Write-ADEHMLog -Level INFO -Module Connectivity -Message "$dc Ping OK (demo)" Write-ADEHMLog -Level INFO -Module System -Message "$dc CPU OK (demo)" Write-ADEHMLog -Level INFO -Module System -Message "$dc RAM OK (demo)" } else { # ----------------------------------------------------- # 4a. Basic connectivity # ----------------------------------------------------- $row.Ping = Test-ADEHMPing -ComputerName $dc -TimeoutSeconds $config.General.TimeoutSeconds $dns = Resolve-ADEHMDns -ComputerName $dc $row.IPAddress = $dns.IPAddress $row.WinRM = Test-ADEHMWinRM -ComputerName $dc -TimeoutSeconds $config.General.TimeoutSeconds if (-not $row.Ping -or -not $row.WinRM) { Write-ADEHMError -Server $dc -Module 'Connectivity' ` -Exception 'Ping or WinRM unavailable' ` -ProbableCause 'Server down, firewall blocking ICMP/WinRM, or WinRM service stopped.' ` -Recommendation "Check the state of $dc and the WinRM configuration (winrm quickconfig) / firewall." $results.Add([PSCustomObject]$row) continue } # ----------------------------------------------------- # 4b. CIM connection (one single session per server) # ----------------------------------------------------- $cim = $null try { $cim = New-ADEHMCimSession -ComputerName $dc -TimeoutSeconds $config.General.TimeoutSeconds -Credential $Credential Write-ADEHMLog -Level DEBUG -Module System -Message "$dc CIM session opened" # ------------------------------------------------- # 4c. Collection # ------------------------------------------------- $sysInfo = Get-ADEHMSystemInfo -CimSession $cim $diskInfo = Get-ADEHMDiskInfo -CimSession $cim $services = Get-ADEHMServiceStatus -CimSession $cim -ServiceNames $svcNames $domainInfo = Get-ADEHMDomainInfo -ComputerName $dc $row.Site = $domainInfo.Site $row.CpuPercent = $sysInfo.CpuLoadPercent $row.RamPercent = $sysInfo.RamUsedPercent $row.OSCaption = $sysInfo.OSCaption $row.BuildNumber = $sysInfo.BuildNumber $row.LastBootTime = $sysInfo.LastBootTime $row.UptimeDays = $sysInfo.UptimeDays $row.DiskDetail = $diskInfo $cDisk = $diskInfo | Where-Object { $_.DeviceID -eq 'C:' } | Select-Object -First 1 $row.DiskFreePercent = if ($cDisk) { $cDisk.FreePercent } else { ($diskInfo | Sort-Object FreePercent | Select-Object -First 1).FreePercent } foreach ($svc in $svcNames) { $row[$svc] = $services[$svc].Running } $row.SYSVOL = Test-ADEHMShare -ComputerName $dc -ShareName 'SYSVOL' $row.NetlogonShare = Test-ADEHMShare -ComputerName $dc -ShareName 'NETLOGON' Write-ADEHMLog -Level INFO -Module System -Message "$dc CPU OK" Write-ADEHMLog -Level INFO -Module System -Message "$dc RAM OK" } catch { $msg = $_.Exception.Message # The probable cause is inferred from the error type to avoid # sending the diagnosis down the wrong path. if ($msg -match 'parameter') { $cause = 'Client-side error (ADEHM host), raised BEFORE any connection to the DC: cmdlet incompatibility or engine bug.' $reco = "The issue concerns neither $dc nor permissions. Check the host PowerShell version and report the error." } elseif ($msg -match 'denied|access') { $cause = "Insufficient rights for the service account on $dc." $reco = 'Check the CIM/WMI and WinRM delegations of the service account (see Docs/PERMISSIONS.md).' } elseif ($msg -match 'timeout|timed out') { $cause = "Response timeout: $dc overloaded, or WinRM traffic (5985) filtered by a firewall." $reco = "Run Test-NetConnection $dc -Port 5985 and raise General.TimeoutSeconds if the server is slow." } else { $cause = "CIM/WinRM service unavailable on $dc, or unexpected error." $reco = 'Review the exception message above and the debug-level log (General.DebugMode = $true).' } Write-ADEHMError -Server $dc -Module 'System/ActiveDirectory' -Exception $msg ` -ProbableCause $cause -Recommendation $reco } finally { # Systematic session cleanup if ($cim) { Remove-CimSession -CimSession $cim -ErrorAction SilentlyContinue Write-ADEHMLog -Level DEBUG -Module System -Message "$dc CIM session closed" } } } $results.Add([PSCustomObject]$row) } # ------------------------------------------------------------- # 5. Report Generation # ------------------------------------------------------------- $endTime = Get-Date $reportFileName = 'ADEHM_Report_{0}.html' -f (Get-Date -Format 'yyyyMMdd_HHmmss') $reportPath = Join-Path $config.Paths.Reports $reportFileName New-ADEHMHtmlReport -Config $config -Results $results -StartTime $startTime -EndTime $endTime ` -ErrorLog (Get-ADEHMErrorLog) -OutputPath $reportPath | Out-Null Write-ADEHMLog -Level INFO -Module Report -Message 'Report Generated' # ------------------------------------------------------------- # 6. Mail Delivery # ------------------------------------------------------------- $emailBody = $null if ($config.Mail.Enabled -and $config.Mail.EmbedReportInBody) { # Email-optimized twin rendering: same data, built with HTML tables # + inline styles for faithful display in Outlook. $emailBody = New-ADEHMEmailReport -Config $config -Results $results ` -StartTime $startTime -EndTime $endTime -ErrorLog (Get-ADEHMErrorLog) } $mailAuth = if ($MailCredential) { $MailCredential } else { $Credential } if ($config.Mail.Anonymous) { Write-ADEHMLog -Level INFO -Module Mail -Message 'SMTP identity: none (anonymous relay, Mail.Anonymous = $true)' } else { $mailIdentity = if ($mailAuth) { $mailAuth.UserName } else { "$env:USERDOMAIN\$env:USERNAME (current context)" } $reused = if (-not $MailCredential -and $Credential) { ' (reused from -Credential; pass -MailCredential to use a different identity)' } else { '' } Write-ADEHMLog -Level INFO -Module Mail -Message "SMTP identity: $mailIdentity$reused" } $sent = Send-ADEHMReport -Config $config -ReportPath $reportPath -RunDate $startTime ` -BodyHtml $emailBody -Credential $mailAuth if ($sent) { Write-ADEHMLog -Level INFO -Module Mail -Message 'Mail Sent' } # ------------------------------------------------------------- # 7. Archiving # ------------------------------------------------------------- if ($config.General.ArchiveReports) { $cutoff = (Get-Date).AddDays(-$config.General.ArchiveDays) Get-ChildItem -Path $config.Paths.Reports -Filter 'ADEHM_Report_*.html' -ErrorAction SilentlyContinue | Where-Object { $_.LastWriteTime -lt $cutoff } | ForEach-Object { Move-Item -LiteralPath $_.FullName -Destination $config.Paths.Archive -Force } Get-ChildItem -Path $config.Paths.Logs -Filter 'ADEHM_*.log' -ErrorAction SilentlyContinue | Where-Object { $_.LastWriteTime -lt $cutoff } | ForEach-Object { Move-Item -LiteralPath $_.FullName -Destination $config.Paths.Archive -Force } Write-ADEHMLog -Level DEBUG -Module Core -Message "Archiving: items older than $($cutoff.ToString('yyyy-MM-dd')) moved." } # ------------------------------------------------------------- # 8. End # ------------------------------------------------------------- Write-ADEHMLog -Level INFO -Module Core -Message 'ADEHM Finished' return [PSCustomObject]@{ ReportPath = $reportPath LogPath = $logPath Results = $results Errors = Get-ADEHMErrorLog } } catch { Write-Host "FATAL ERROR: $($_.Exception.Message)" -ForegroundColor Red if (Get-Command Write-ADEHMLog -ErrorAction SilentlyContinue) { Write-ADEHMLog -Level ERROR -Module Core -Message "Fatal error: $($_.Exception.Message)" } exit 1 } |