CleanupActiveDirectory.psm1
function ConvertFrom-DistinguishedName { <# .SYNOPSIS Converts a Distinguished Name to CN, OU, Multiple OUs or DC .DESCRIPTION Converts a Distinguished Name to CN, OU, Multiple OUs or DC .PARAMETER DistinguishedName Distinguished Name to convert .PARAMETER ToOrganizationalUnit Converts DistinguishedName to Organizational Unit .PARAMETER ToDC Converts DistinguishedName to DC .PARAMETER ToDomainCN Converts DistinguishedName to Domain Canonical Name (CN) .PARAMETER ToCanonicalName Converts DistinguishedName to Canonical Name .EXAMPLE $DistinguishedName = 'CN=Przemyslaw Klys,OU=Users,OU=Production,DC=ad,DC=evotec,DC=xyz' ConvertFrom-DistinguishedName -DistinguishedName $DistinguishedName -ToOrganizationalUnit Output: OU=Users,OU=Production,DC=ad,DC=evotec,DC=xyz .EXAMPLE $DistinguishedName = 'CN=Przemyslaw Klys,OU=Users,OU=Production,DC=ad,DC=evotec,DC=xyz' ConvertFrom-DistinguishedName -DistinguishedName $DistinguishedName Output: Przemyslaw Klys .EXAMPLE ConvertFrom-DistinguishedName -DistinguishedName 'OU=Users,OU=Production,DC=ad,DC=evotec,DC=xyz' -ToMultipleOrganizationalUnit -IncludeParent Output: OU=Users,OU=Production,DC=ad,DC=evotec,DC=xyz OU=Production,DC=ad,DC=evotec,DC=xyz .EXAMPLE ConvertFrom-DistinguishedName -DistinguishedName 'OU=Users,OU=Production,DC=ad,DC=evotec,DC=xyz' -ToMultipleOrganizationalUnit Output: OU=Production,DC=ad,DC=evotec,DC=xyz .EXAMPLE $Con = @( 'CN=Windows Authorization Access Group,CN=Builtin,DC=ad,DC=evotec,DC=xyz' 'CN=Mmm,DC=elo,CN=nee,DC=RootDNSServers,CN=MicrosoftDNS,CN=System,DC=ad,DC=evotec,DC=xyz' 'CN=e6d5fd00-385d-4e65-b02d-9da3493ed850,CN=Operations,CN=DomainUpdates,CN=System,DC=ad,DC=evotec,DC=xyz' 'OU=Domain Controllers,DC=ad,DC=evotec,DC=pl' 'OU=Microsoft Exchange Security Groups,DC=ad,DC=evotec,DC=xyz' ) ConvertFrom-DistinguishedName -DistinguishedName $Con -ToLastName Output: Windows Authorization Access Group Mmm e6d5fd00-385d-4e65-b02d-9da3493ed850 Domain Controllers Microsoft Exchange Security Groups .EXAMPLEE ConvertFrom-DistinguishedName -DistinguishedName 'DC=ad,DC=evotec,DC=xyz' -ToCanonicalName ConvertFrom-DistinguishedName -DistinguishedName 'OU=Users,OU=Production,DC=ad,DC=evotec,DC=xyz' -ToCanonicalName ConvertFrom-DistinguishedName -DistinguishedName 'CN=test,OU=Users,OU=Production,DC=ad,DC=evotec,DC=xyz' -ToCanonicalName Output: ad.evotec.xyz ad.evotec.xyz\Production\Users ad.evotec.xyz\Production\Users\test .NOTES General notes #> [CmdletBinding(DefaultParameterSetName = 'Default')] param([Parameter(ParameterSetName = 'ToOrganizationalUnit')] [Parameter(ParameterSetName = 'ToMultipleOrganizationalUnit')] [Parameter(ParameterSetName = 'ToDC')] [Parameter(ParameterSetName = 'ToDomainCN')] [Parameter(ParameterSetName = 'Default')] [Parameter(ParameterSetName = 'ToLastName')] [Parameter(ParameterSetName = 'ToCanonicalName')] [alias('Identity', 'DN')][Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName, Position = 0)][string[]] $DistinguishedName, [Parameter(ParameterSetName = 'ToOrganizationalUnit')][switch] $ToOrganizationalUnit, [Parameter(ParameterSetName = 'ToMultipleOrganizationalUnit')][alias('ToMultipleOU')][switch] $ToMultipleOrganizationalUnit, [Parameter(ParameterSetName = 'ToMultipleOrganizationalUnit')][switch] $IncludeParent, [Parameter(ParameterSetName = 'ToDC')][switch] $ToDC, [Parameter(ParameterSetName = 'ToDomainCN')][switch] $ToDomainCN, [Parameter(ParameterSetName = 'ToLastName')][switch] $ToLastName, [Parameter(ParameterSetName = 'ToCanonicalName')][switch] $ToCanonicalName) Process { foreach ($Distinguished in $DistinguishedName) { if ($ToDomainCN) { $DN = $Distinguished -replace '.*?((DC=[^=]+,)+DC=[^=]+)$', '$1' $CN = $DN -replace ',DC=', '.' -replace "DC=" if ($CN) { $CN } } elseif ($ToOrganizationalUnit) { $Value = [Regex]::Match($Distinguished, '(?=OU=)(.*\n?)(?<=.)').Value if ($Value) { $Value } } elseif ($ToMultipleOrganizationalUnit) { if ($IncludeParent) { $Distinguished } while ($true) { $Distinguished = $Distinguished -replace '^.+?,(?=..=)' if ($Distinguished -match '^DC=') { break } $Distinguished } } elseif ($ToDC) { $Value = $Distinguished -replace '.*?((DC=[^=]+,)+DC=[^=]+)$', '$1' if ($Value) { $Value } } elseif ($ToLastName) { $NewDN = $Distinguished -split ",DC=" if ($NewDN[0].Contains(",OU=")) { [Array] $ChangedDN = $NewDN[0] -split ",OU=" } elseif ($NewDN[0].Contains(",CN=")) { [Array] $ChangedDN = $NewDN[0] -split ",CN=" } else { [Array] $ChangedDN = $NewDN[0] } if ($ChangedDN[0].StartsWith('CN=')) { $ChangedDN[0] -replace 'CN=', '' } else { $ChangedDN[0] -replace 'OU=', '' } } elseif ($ToCanonicalName) { $Domain = $null $Rest = $null foreach ($O in $Distinguished -split '(?<!\\),') { if ($O -match '^DC=') { $Domain += $O.Substring(3) + '.' } else { $Rest = $O.Substring(3) + '\' + $Rest } } if ($Domain -and $Rest) { $Domain.Trim('.') + '\' + ($Rest.TrimEnd('\') -replace '\\,', ',') } elseif ($Domain) { $Domain.Trim('.') } elseif ($Rest) { $Rest.TrimEnd('\') -replace '\\,', ',' } } else { $Regex = '^CN=(?<cn>.+?)(?<!\\),(?<ou>(?:(?:OU|CN).+?(?<!\\),)+(?<dc>DC.+?))$' $Found = $Distinguished -match $Regex if ($Found) { $Matches.cn } } } } } function ConvertTo-OperatingSystem { <# .SYNOPSIS Allows easy conversion of OperatingSystem, Operating System Version to proper Windows 10 naming based on WMI or AD .DESCRIPTION Allows easy conversion of OperatingSystem, Operating System Version to proper Windows 10 naming based on WMI or AD .PARAMETER OperatingSystem Operating System as returned by Active Directory .PARAMETER OperatingSystemVersion Operating System Version as returned by Active Directory .EXAMPLE $Computers = Get-ADComputer -Filter * -Properties OperatingSystem, OperatingSystemVersion | ForEach-Object { $OPS = ConvertTo-OperatingSystem -OperatingSystem $_.OperatingSystem -OperatingSystemVersion $_.OperatingSystemVersion Add-Member -MemberType NoteProperty -Name 'OperatingSystemTranslated' -Value $OPS -InputObject $_ -Force $_ } $Computers | Select-Object DNS*, Name, SamAccountName, Enabled, OperatingSystem*, DistinguishedName | Format-Table .EXAMPLE $Registry = Get-PSRegistry -ComputerName 'AD1' -RegistryPath 'HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion' ConvertTo-OperatingSystem -OperatingSystem $Registry.ProductName -OperatingSystemVersion $Registry.CurrentBuildNumber .NOTES General notes #> [CmdletBinding()] param([string] $OperatingSystem, [string] $OperatingSystemVersion) if ($OperatingSystem -like 'Windows 10*' -or $OperatingSystem -like 'Windows 11*') { $Systems = @{'10.0 (22000)' = 'Windows 11 21H2' '10.0 (19043)' = 'Windows 10 21H1' '10.0 (19042)' = 'Windows 10 20H2' '10.0 (19041)' = 'Windows 10 2004' '10.0 (18898)' = 'Windows 10 Insider Preview' '10.0 (18363)' = "Windows 10 1909" '10.0 (18362)' = "Windows 10 1903" '10.0 (17763)' = "Windows 10 1809" '10.0 (17134)' = "Windows 10 1803" '10.0 (16299)' = "Windows 10 1709" '10.0 (15063)' = "Windows 10 1703" '10.0 (14393)' = "Windows 10 1607" '10.0 (10586)' = "Windows 10 1511" '10.0 (10240)' = "Windows 10 1507" '10.0.22000' = 'Windows 11 21H2' '10.0.19043' = 'Windows 10 21H1' '10.0.19042' = 'Windows 10 20H2' '10.0.19041' = 'Windows 10 2004' '10.0.18898' = 'Windows 10 Insider Preview' '10.0.18363' = "Windows 10 1909" '10.0.18362' = "Windows 10 1903" '10.0.17763' = "Windows 10 1809" '10.0.17134' = "Windows 10 1803" '10.0.16299' = "Windows 10 1709" '10.0.15063' = "Windows 10 1703" '10.0.14393' = "Windows 10 1607" '10.0.10586' = "Windows 10 1511" '10.0.10240' = "Windows 10 1507" '22000' = 'Windows 11 21H2' '19043' = 'Windows 10 21H1' '19042' = 'Windows 10 20H2' '19041' = 'Windows 10 2004' '18898' = 'Windows 10 Insider Preview' '18363' = "Windows 10 1909" '18362' = "Windows 10 1903" '17763' = "Windows 10 1809" '17134' = "Windows 10 1803" '16299' = "Windows 10 1709" '15063' = "Windows 10 1703" '14393' = "Windows 10 1607" '10586' = "Windows 10 1511" '10240' = "Windows 10 1507" } $System = $Systems[$OperatingSystemVersion] if (-not $System) { $System = $OperatingSystem } } elseif ($OperatingSystem -like 'Windows Server*') { $Systems = @{'10.0 (20348)' = 'Windows Server 2022' '10.0 (19042)' = 'Windows Server 2019 20H2' '10.0 (19041)' = 'Windows Server 2019 2004' '10.0 (18363)' = 'Windows Server 2019 1909' '10.0 (18362)' = "Windows Server 2019 1903" '10.0 (17763)' = "Windows Server 2019 1809" '10.0 (17134)' = "Windows Server 2016 1803" '10.0 (14393)' = "Windows Server 2016 1607" '6.3 (9600)' = 'Windows Server 2012 R2' '6.1 (7601)' = 'Windows Server 2008 R2' '5.2 (3790)' = 'Windows Server 2003' '10.0.20348' = 'Windows Server 2022' '10.0.19042' = 'Windows Server 2019 20H2' '10.0.19041' = 'Windows Server 2019 2004' '10.0.18363' = 'Windows Server 2019 1909' '10.0.18362' = "Windows Server 2019 1903" '10.0.17763' = "Windows Server 2019 1809" '10.0.17134' = "Windows Server 2016 1803" '10.0.14393' = "Windows Server 2016 1607" '6.3.9600' = 'Windows Server 2012 R2' '6.1.7601' = 'Windows Server 2008 R2' '5.2.3790' = 'Windows Server 2003' '20348' = 'Windows Server 2022' '19042' = 'Windows Server 2019 20H2' '19041' = 'Windows Server 2019 2004' '18363' = 'Windows Server 2019 1909' '18362' = "Windows Server 2019 1903" '17763' = "Windows Server 2019 1809" '17134' = "Windows Server 2016 1803" '14393' = "Windows Server 2016 1607" '9600' = 'Windows Server 2012 R2' '7601' = 'Windows Server 2008 R2' '3790' = 'Windows Server 2003' } $System = $Systems[$OperatingSystemVersion] if (-not $System) { $System = $OperatingSystem } } else { $System = $OperatingSystem } if ($System) { $System } else { 'Unknown' } } function Get-GitHubVersion { <# .SYNOPSIS Get the latest version of a GitHub repository and compare with local version .DESCRIPTION Get the latest version of a GitHub repository and compare with local version .PARAMETER Cmdlet Cmdlet to find module for .PARAMETER RepositoryOwner Repository owner .PARAMETER RepositoryName Repository name .EXAMPLE Get-GitHubVersion -Cmdlet 'Start-DelegationModel' -RepositoryOwner 'evotecit' -RepositoryName 'DelegationModel' .NOTES General notes #> [cmdletBinding()] param([Parameter(Mandatory)][string] $Cmdlet, [Parameter(Mandatory)][string] $RepositoryOwner, [Parameter(Mandatory)][string] $RepositoryName) $App = Get-Command -Name $Cmdlet -ErrorAction SilentlyContinue if ($App) { [Array] $GitHubReleases = (Get-GitHubLatestRelease -Url "https://api.github.com/repos/$RepositoryOwner/$RepositoryName/releases" -Verbose:$false) $LatestVersion = $GitHubReleases[0] if (-not $LatestVersion.Errors) { if ($App.Version -eq $LatestVersion.Version) { "Current/Latest: $($LatestVersion.Version) at $($LatestVersion.PublishDate)" } elseif ($App.Version -lt $LatestVersion.Version) { "Current: $($App.Version), Published: $($LatestVersion.Version) at $($LatestVersion.PublishDate). Update?" } elseif ($App.Version -gt $LatestVersion.Version) { "Current: $($App.Version), Published: $($LatestVersion.Version) at $($LatestVersion.PublishDate). Lucky you!" } } else { "Current: $($App.Version)" } } } function Write-Color { <# .SYNOPSIS Write-Color is a wrapper around Write-Host. It provides: - Easy manipulation of colors, - Logging output to file (log) - Nice formatting options out of the box. .DESCRIPTION Author: przemyslaw.klys at evotec.pl Project website: https://evotec.xyz/hub/scripts/write-color-ps1/ Project support: https://github.com/EvotecIT/PSWriteColor Original idea: Josh (https://stackoverflow.com/users/81769/josh) .EXAMPLE Write-Color -Text "Red ", "Green ", "Yellow " -Color Red,Green,Yellow .EXAMPLE Write-Color -Text "This is text in Green ", "followed by red ", "and then we have Magenta... ", "isn't it fun? ", "Here goes DarkCyan" -Color Green,Red,Magenta,White,DarkCyan .EXAMPLE Write-Color -Text "This is text in Green ", "followed by red ", "and then we have Magenta... ", "isn't it fun? ", "Here goes DarkCyan" -Color Green,Red,Magenta,White,DarkCyan -StartTab 3 -LinesBefore 1 -LinesAfter 1 .EXAMPLE Write-Color "1. ", "Option 1" -Color Yellow, Green Write-Color "2. ", "Option 2" -Color Yellow, Green Write-Color "3. ", "Option 3" -Color Yellow, Green Write-Color "4. ", "Option 4" -Color Yellow, Green Write-Color "9. ", "Press 9 to exit" -Color Yellow, Gray -LinesBefore 1 .EXAMPLE Write-Color -LinesBefore 2 -Text "This little ","message is ", "written to log ", "file as well." ` -Color Yellow, White, Green, Red, Red -LogFile "C:\testing.txt" -TimeFormat "yyyy-MM-dd HH:mm:ss" Write-Color -Text "This can get ","handy if ", "want to display things, and log actions to file ", "at the same time." ` -Color Yellow, White, Green, Red, Red -LogFile "C:\testing.txt" .EXAMPLE # Added in 0.5 Write-Color -T "My text", " is ", "all colorful" -C Yellow, Red, Green -B Green, Green, Yellow wc -t "my text" -c yellow -b green wc -text "my text" -c red .NOTES Additional Notes: - TimeFormat https://msdn.microsoft.com/en-us/library/8kb3ddd4.aspx #> [alias('Write-Colour')] [CmdletBinding()] param ([alias ('T')] [String[]]$Text, [alias ('C', 'ForegroundColor', 'FGC')] [ConsoleColor[]]$Color = [ConsoleColor]::White, [alias ('B', 'BGC')] [ConsoleColor[]]$BackGroundColor = $null, [alias ('Indent')][int] $StartTab = 0, [int] $LinesBefore = 0, [int] $LinesAfter = 0, [int] $StartSpaces = 0, [alias ('L')] [string] $LogFile = '', [Alias('DateFormat', 'TimeFormat')][string] $DateTimeFormat = 'yyyy-MM-dd HH:mm:ss', [alias ('LogTimeStamp')][bool] $LogTime = $true, [int] $LogRetry = 2, [ValidateSet('unknown', 'string', 'unicode', 'bigendianunicode', 'utf8', 'utf7', 'utf32', 'ascii', 'default', 'oem')][string]$Encoding = 'Unicode', [switch] $ShowTime, [switch] $NoNewLine) $DefaultColor = $Color[0] if ($null -ne $BackGroundColor -and $BackGroundColor.Count -ne $Color.Count) { Write-Error "Colors, BackGroundColors parameters count doesn't match. Terminated." return } if ($LinesBefore -ne 0) { for ($i = 0; $i -lt $LinesBefore; $i++) { Write-Host -Object "`n" -NoNewline } } if ($StartTab -ne 0) { for ($i = 0; $i -lt $StartTab; $i++) { Write-Host -Object "`t" -NoNewline } } if ($StartSpaces -ne 0) { for ($i = 0; $i -lt $StartSpaces; $i++) { Write-Host -Object ' ' -NoNewline } } if ($ShowTime) { Write-Host -Object "[$([datetime]::Now.ToString($DateTimeFormat))] " -NoNewline } if ($Text.Count -ne 0) { if ($Color.Count -ge $Text.Count) { if ($null -eq $BackGroundColor) { for ($i = 0; $i -lt $Text.Length; $i++) { Write-Host -Object $Text[$i] -ForegroundColor $Color[$i] -NoNewline } } else { for ($i = 0; $i -lt $Text.Length; $i++) { Write-Host -Object $Text[$i] -ForegroundColor $Color[$i] -BackgroundColor $BackGroundColor[$i] -NoNewline } } } else { if ($null -eq $BackGroundColor) { for ($i = 0; $i -lt $Color.Length; $i++) { Write-Host -Object $Text[$i] -ForegroundColor $Color[$i] -NoNewline } for ($i = $Color.Length; $i -lt $Text.Length; $i++) { Write-Host -Object $Text[$i] -ForegroundColor $DefaultColor -NoNewline } } else { for ($i = 0; $i -lt $Color.Length; $i++) { Write-Host -Object $Text[$i] -ForegroundColor $Color[$i] -BackgroundColor $BackGroundColor[$i] -NoNewline } for ($i = $Color.Length; $i -lt $Text.Length; $i++) { Write-Host -Object $Text[$i] -ForegroundColor $DefaultColor -BackgroundColor $BackGroundColor[0] -NoNewline } } } } if ($NoNewLine -eq $true) { Write-Host -NoNewline } else { Write-Host } if ($LinesAfter -ne 0) { for ($i = 0; $i -lt $LinesAfter; $i++) { Write-Host -Object "`n" -NoNewline } } if ($Text.Count -and $LogFile) { $TextToFile = "" for ($i = 0; $i -lt $Text.Length; $i++) { $TextToFile += $Text[$i] } $Saved = $false $Retry = 0 Do { $Retry++ try { if ($LogTime) { "[$([datetime]::Now.ToString($DateTimeFormat))] $TextToFile" | Out-File -FilePath $LogFile -Encoding $Encoding -Append -ErrorAction Stop -WhatIf:$false } else { "$TextToFile" | Out-File -FilePath $LogFile -Encoding $Encoding -Append -ErrorAction Stop -WhatIf:$false } $Saved = $true } catch { if ($Saved -eq $false -and $Retry -eq $LogRetry) { $PSCmdlet.WriteError($_) } else { Write-Warning "Write-Color - Couldn't write to log file $($_.Exception.Message). Retrying... ($Retry/$LogRetry)" } } } Until ($Saved -eq $true -or $Retry -ge $LogRetry) } } function Write-Event { [alias('Write-WinEvent', 'Write-Events')] [cmdletBinding()] param([string[]] $Computer, [Parameter(Mandatory)][alias('EventLog')][string] $LogName, [Parameter(Mandatory)][alias('Provider', 'ProviderName')][string] $Source, [int] $Category, [alias('Level')][System.Diagnostics.EventLogEntryType] $EntryType = [System.Diagnostics.EventLogEntryType]::Information, [Parameter(Mandatory)][alias('EventID')][int] $ID, [Parameter(Mandatory)][string] $Message, [Array] $AdditionalFields) Begin {} Process { if (-not $Computer) { $SourceExists = Get-WinEvent -ListProvider $Source -ErrorAction SilentlyContinue if ($null -eq $SourceExists -or $SourceExists.LogLinks.LogName -notcontains $LogName) { try { New-EventLog -LogName $LogName -Source $Source -ErrorAction Stop } catch { Write-Warning "New-WinEvent - Couldn't create new event log source - $($_.Exception.Message)" return } } $Computer = $Env:COMPUTERNAME } foreach ($Machine in $Computer) { $EventInstance = [System.Diagnostics.EventInstance]::new($ID, $Category, $EntryType) $Event = [System.Diagnostics.EventLog]::new() $Event.Log = $LogName $Event.Source = $Source if ($Machine -ne $Env:COMPUTERNAME) { $Event.MachineName = $Machine } [Array] $JoinedMessage = @($Message $AdditionalFields | ForEach-Object { $_ }) try { $Event.WriteEvent($EventInstance, $JoinedMessage) } catch { Write-Warning "Write-Event - Couldn't create new event - $($_.Exception.Message)" } } } } function Get-GitHubLatestRelease { <# .SYNOPSIS Gets one or more releases from GitHub repository .DESCRIPTION Gets one or more releases from GitHub repository .PARAMETER Url Url to github repository .EXAMPLE Get-GitHubLatestRelease -Url "https://api.github.com1/repos/evotecit/Testimo/releases" | Format-Table .NOTES General notes #> [CmdLetBinding()] param([parameter(Mandatory)][alias('ReleasesUrl')][uri] $Url) $ProgressPreference = 'SilentlyContinue' $Responds = Test-Connection -ComputerName $URl.Host -Quiet -Count 1 if ($Responds) { Try { [Array] $JsonOutput = (Invoke-WebRequest -Uri $Url -ErrorAction Stop | ConvertFrom-Json) foreach ($JsonContent in $JsonOutput) { [PSCustomObject] @{PublishDate = [DateTime] $JsonContent.published_at CreatedDate = [DateTime] $JsonContent.created_at PreRelease = [bool] $JsonContent.prerelease Version = [version] ($JsonContent.name -replace 'v', '') Tag = $JsonContent.tag_name Branch = $JsonContent.target_commitish Errors = '' } } } catch { [PSCustomObject] @{PublishDate = $null CreatedDate = $null PreRelease = $null Version = $null Tag = $null Branch = $null Errors = $_.Exception.Message } } } else { [PSCustomObject] @{PublishDate = $null CreatedDate = $null PreRelease = $null Version = $null Tag = $null Branch = $null Errors = "No connection (ping) to $($Url.Host)" } } $ProgressPreference = 'Continue' } function Get-ADComputersToDelete { [cmdletBinding()] param( [Array] $Computers, [System.Collections.IDictionary] $DeleteOnlyIf, [Array] $Exclusions = @('OU=Domain Controllers'), [Microsoft.ActiveDirectory.Management.ADDomain] $DomainInformation, [System.Collections.IDictionary] $ProcessedComputers ) $Today = Get-Date :SkipComputer foreach ($Computer in $Computers) { if ($null -ne $DeleteOnlyIf.ListProcessedMoreThan) { # if more then 0 this means computer has to be on list of disabled computers for that number of days. if ($ProcessedComputers.Count -gt 0) { $FoundComputer = $ProcessedComputers["$($Computer.DistinguishedName)"] if ($FoundComputer) { if ($FoundComputer.ActionDate -is [DateTime]) { $TimeSpan = New-TimeSpan -Start $FoundComputer.ActionDate -End $Today if ($TimeSpan.Days -gt $DeleteOnlyIf.ListProcessedMoreThan) { } else { continue SkipComputer } } else { continue SkipComputer } } else { continue SkipComputer } } else { # ListProcessed doesn't have members, and it's part of requirement break } } foreach ($PartialExclusion in $Exclusions) { if ($Computer.DistinguishedName -like "$PartialExclusion") { continue SkipComputer } if ($Computer.SamAccountName -like "$PartialExclusion") { continue SkipComputer } if ($Computer.DNSHostName -like "$PartialExclusion") { continue SkipComputer } <# if ($PartialExclusion -like '*DC=*') { $Exclusion = $PartialExclusion } else { $Exclusion = -join ($PartialExclusion, ',', $DomainInformation.DistinguishedName) } if ($Computer.DistinguishedName -like "*$Exclusion") { continue SkipComputer } #> } if ($DeleteOnlyIf.ExcludeSystems.Count -gt 0) { foreach ($Exclude in $DeleteOnlyIf.ExcludeSystems) { if ($Computer.OperatingSystem -like $Exclude) { continue SkipComputer } } } if ($DeleteOnlyIf.IncludeSystems.Count -gt 0) { $FoundInclude = $false foreach ($Include in $DeleteOnlyIf.IncludeSystems) { if ($Computer.OperatingSystem -like $Include) { $FoundInclude = $true break } } # If not found in includes we need to skip the computer if (-not $FoundInclude) { continue SkipComputer } } if ($DeleteOnlyIf.IsEnabled -eq $true) { # Delete computer only if it's Enabled if ($Computer.Enabled -eq $false) { continue SkipComputer } } elseif ($DeleteOnlyIf.IsEnabled -eq $false) { # Delete computer only if it's Disabled if ($Computer.Enabled -eq $true) { continue SkipComputer } } if ($DeleteOnlyIf.NoServicePrincipalName -eq $true) { # Delete computer only if it has no service principal names defined if ($Computer.servicePrincipalName.Count -gt 0) { continue SkipComputer } } elseif ($DeleteOnlyIf.NoServicePrincipalName -eq $false) { # Delete computer only if it has service principal names defined if ($Computer.servicePrincipalName.Count -eq 0) { continue SkipComputer } } if ($DeleteOnlyIf.LastLogonDateMoreThan) { # This runs only if more than 0 if ($Computer.LastLogonDate) { # We ignore empty $TimeToCompare = ($Computer.LastLogonDate).AddDays($DeleteOnlyIf.LastLogonDateMoreThan) if ($TimeToCompare -gt $Today) { continue SkipComputer } } } if ($DeleteOnlyIf.PasswordLastSetMoreThan) { # This runs only if more than 0 if ($Computer.PasswordLastSet) { # We ignore empty $TimeToCompare = ($Computer.PasswordLastSet).AddDays($DeleteOnlyIf.PasswordLastSetMoreThan) if ($TimeToCompare -gt $Today) { continue SkipComputer } } } [PSCustomObject] @{ 'DNSHostName' = $Computer.DNSHostName 'SamAccountName' = $Computer.SamAccountName 'Enabled' = $Computer.Enabled 'Action' = 'Delete' 'ActionStatus' = $null 'ActionDate' = $null 'OperatingSystem' = $Computer.OperatingSystem 'OperatingSystemVersion' = $Computer.OperatingSystemVersion 'OperatingSystemLong' = ConvertTo-OperatingSystem -OperatingSystem $Computer.OperatingSystem -OperatingSystemVersion $Computer.OperatingSystemVersion 'LastLogonDate' = $Computer.LastLogonDate 'LastLogonDays' = ([int] $(if ($null -ne $Computer.LastLogonDate) { "$(-$($Computer.LastLogonDate - $Today).Days)" } else { })) 'PasswordLastSet' = $Computer.PasswordLastSet 'PasswordLastChangedDays' = ([int] $(if ($null -ne $Computer.PasswordLastSet) { "$(-$($Computer.PasswordLastSet - $Today).Days)" } else { })) 'PasswordExpired' = $Computer.PasswordExpired 'LogonCount' = $Computer.logonCount 'ManagedBy' = $Computer.ManagedBy 'DistinguishedName' = $Computer.DistinguishedName 'OrganizationalUnit' = ConvertFrom-DistinguishedName -DistinguishedName $Computer.DistinguishedName -ToOrganizationalUnit 'Description' = $Computer.Description 'WhenCreated' = $Computer.WhenCreated 'WhenChanged' = $Computer.WhenChanged 'ServicePrincipalName' = $Computer.servicePrincipalName -join [System.Environment]::NewLine } } } function Get-ADComputersToDisable { [cmdletBinding()] param( [Array] $Computers, [System.Collections.IDictionary] $DisableOnlyIf, [Array] $Exclusions = @('OU=Domain Controllers'), [string] $Filter = '*', [Microsoft.ActiveDirectory.Management.ADDomain] $DomainInformation, [System.Collections.IDictionary] $ProcessedComputers ) $Today = Get-Date :SkipComputer foreach ($Computer in $Computers) { if ($ProcessedComputers.Count -gt 0) { $FoundComputer = $ProcessedComputers["$($Computer.DistinguishedName)"] if ($FoundComputer) { if ($Computer.Enabled -eq $true) { # We checked and it seems the computer has been enabled since it was added to list, we remove it from the list and reprocess Write-Color -Text "[*] Removing computer from pending list (computer is enabled) ", $FoundComputer.SamAccountName, " ($($FoundComputer.DistinguishedName))" -Color DarkMagenta, Green, DarkMagenta $ProcessedComputers.Remove("$($Computer.DistinguishedName)") } else { # we skip adding to disabled because it's already on the list for removing continue SkipComputer } } } if ($DisableOnlyIf.IsEnabled -eq $true) { # Disable computer only if it's Enabled if ($Computer.Enabled -eq $false) { continue SkipComputer } } elseif ($DisableOnlyIf.IsEnabled -eq $false) { # Disable computer only if it's Disabled # a bit useless as it's already disable right... # so we skip computer both times as it's already done if ($Computer.Enabled -eq $true) { continue SkipComputer } else { continue SkipComputer } } else { # If null it should ignore condition } foreach ($PartialExclusion in $Exclusions) { if ($Computer.DistinguishedName -like "$PartialExclusion") { continue SkipComputer } if ($Computer.SamAccountName -like "$PartialExclusion") { continue SkipComputer } if ($Computer.DNSHostName -like "$PartialExclusion") { continue SkipComputer } <# if ($PartialExclusion -like '*DC=*') { $Exclusion = $PartialExclusion } else { $Exclusion = -join ($PartialExclusion, ',', $DomainInformation.DistinguishedName) } if ($Computer.DistinguishedName -like "*$Exclusion") { continue SkipComputer } #> } if ($DisableOnlyIf.ExcludeSystems.Count -gt 0) { foreach ($Exclude in $DisableOnlyIf.ExcludeSystems) { if ($Computer.OperatingSystem -like $Exclude) { continue SkipComputer } } } if ($DisableOnlyIf.IncludeSystems.Count -gt 0) { $FoundInclude = $false foreach ($Include in $DisableOnlyIf.IncludeSystems) { if ($Computer.OperatingSystem -like $Include) { $FoundInclude = $true break } } # If not found in includes we need to skip the computer if (-not $FoundInclude) { continue SkipComputer } } if ($DisableOnlyIf.NoServicePrincipalName -eq $true) { # Disable computer only if it has no service principal names defined if ($Computer.servicePrincipalName.Count -gt 0) { continue SkipComputer } } elseif ($DisableOnlyIf.NoServicePrincipalName -eq $false) { # Disable computer only if it has service principal names defined if ($Computer.servicePrincipalName.Count -eq 0) { continue SkipComputer } } else { # If null it should ignore confition } if ($DisableOnlyIf.LastLogonDateMoreThan) { # This runs only if more than 0 if ($Computer.LastLogonDate) { # We ignore empty $TimeToCompare = ($Computer.LastLogonDate).AddDays($DisableOnlyIf.LastLogonDateMoreThan) if ($TimeToCompare -gt $Today) { continue SkipComputer } } } if ($DisableOnlyIf.PasswordLastSetMoreThan) { # This runs only if more than 0 if ($Computer.PasswordLastSet) { # We ignore empty $TimeToCompare = ($Computer.PasswordLastSet).AddDays($DisableOnlyIf.PasswordLastSetMoreThan) if ($TimeToCompare -gt $Today) { continue SkipComputer } } } [PSCustomObject] @{ 'DNSHostName' = $Computer.DNSHostName 'SamAccountName' = $Computer.SamAccountName 'Enabled' = $Computer.Enabled 'Action' = 'Disable' 'ActionStatus' = $null 'ActionDate' = $null 'OperatingSystem' = $Computer.OperatingSystem 'OperatingSystemVersion' = $Computer.OperatingSystemVersion 'OperatingSystemLong' = ConvertTo-OperatingSystem -OperatingSystem $Computer.OperatingSystem -OperatingSystemVersion $Computer.OperatingSystemVersion 'LastLogonDate' = $Computer.LastLogonDate 'LastLogonDays' = ([int] $(if ($null -ne $Computer.LastLogonDate) { "$(-$($Computer.LastLogonDate - $Today).Days)" } else { })) 'PasswordLastSet' = $Computer.PasswordLastSet 'PasswordLastChangedDays' = ([int] $(if ($null -ne $Computer.PasswordLastSet) { "$(-$($Computer.PasswordLastSet - $Today).Days)" } else { })) 'PasswordExpired' = $Computer.PasswordExpired 'LogonCount' = $Computer.logonCount 'ManagedBy' = $Computer.ManagedBy 'DistinguishedName' = $Computer.DistinguishedName 'OrganizationalUnit' = ConvertFrom-DistinguishedName -DistinguishedName $Computer.DistinguishedName -ToOrganizationalUnit 'Description' = $Computer.Description 'WhenCreated' = $Computer.WhenCreated 'WhenChanged' = $Computer.WhenChanged 'ServicePrincipalName' = $Computer.servicePrincipalName -join [System.Environment]::NewLine } } } function New-HTMLProcessedComputers { [CmdletBinding()] param( [System.Collections.IDictionary] $Export, [string] $FilePath, [switch] $Online, [switch] $ShowHTML, [string] $LogFile ) New-HTML { New-HTMLTabStyle -BorderRadius 0px -TextTransform capitalize -BackgroundColorActive SlateGrey New-HTMLSectionStyle -BorderRadius 0px -HeaderBackGroundColor Grey -RemoveShadow New-HTMLPanelStyle -BorderRadius 0px New-HTMLTableOption -DataStore JavaScript -BoolAsString -ArrayJoinString ', ' -ArrayJoin New-HTMLHeader { New-HTMLSection -Invisible { New-HTMLSection { New-HTMLText -Text "Report generated on $(Get-Date)" -Color Blue } -JustifyContent flex-start -Invisible New-HTMLSection { New-HTMLText -Text "Active Directory Cleanup - $($Export['Version'])" -Color Blue } -JustifyContent flex-end -Invisible } } New-HTMLTab -Name 'Devices Current Run' { New-HTMLTable -DataTable $Export.CurrentRun -Filtering -ScrollX { New-HTMLTableCondition -Name 'Action' -ComparisonType string -Value 'Delete' -BackgroundColor PinkLace New-HTMLTableCondition -Name 'Action' -ComparisonType string -Value 'Disable' -BackgroundColor EnergyYellow New-HTMLTableCondition -Name 'ActionStatus' -ComparisonType string -Value 'True' -BackgroundColor LightGreen New-HTMLTableCondition -Name 'ActionStatus' -ComparisonType string -Value 'False' -BackgroundColor Salmon New-HTMLTableCondition -Name 'ActionStatus' -ComparisonType string -Value 'Whatif' -BackgroundColor LightBlue } } New-HTMLTab -Name 'Devices History' { New-HTMLTable -DataTable $Export.History -Filtering -ScrollX { New-HTMLTableCondition -Name 'Action' -ComparisonType string -Value 'Delete' -BackgroundColor PinkLace New-HTMLTableCondition -Name 'Action' -ComparisonType string -Value 'Disable' -BackgroundColor EnergyYellow New-HTMLTableCondition -Name 'ActionStatus' -ComparisonType string -Value 'True' -BackgroundColor LightGreen New-HTMLTableCondition -Name 'ActionStatus' -ComparisonType string -Value 'False' -BackgroundColor Salmon New-HTMLTableCondition -Name 'ActionStatus' -ComparisonType string -Value 'Whatif' -BackgroundColor LightBlue } } New-HTMLTab -Name 'Devices Pending' { New-HTMLTable -DataTable $Export.PendingDeletion -Filtering -ScrollX { New-HTMLTableCondition -Name 'Action' -ComparisonType string -Value 'Delete' -BackgroundColor PinkLace New-HTMLTableCondition -Name 'Action' -ComparisonType string -Value 'Disable' -BackgroundColor EnergyYellow New-HTMLTableCondition -Name 'ActionStatus' -ComparisonType string -Value 'True' -BackgroundColor LightGreen New-HTMLTableCondition -Name 'ActionStatus' -ComparisonType string -Value 'False' -BackgroundColor Salmon New-HTMLTableCondition -Name 'ActionStatus' -ComparisonType string -Value 'Whatif' -BackgroundColor LightBlue } } if ($LogFile -and (Test-Path -LiteralPath $LogFile)) { $LogContent = Get-Content -Raw -LiteralPath $LogFile New-HTMLTab -Name 'Log' { New-HTMLCodeBlock -Code $LogContent -Style generic } } } -FilePath $FilePath -Online:$Online.IsPresent -ShowHTML:$ShowHTML.IsPresent } function Set-LoggingCapabilities { [CmdletBinding()] param( [System.Collections.IDictionary] $Configuration ) if ($Configuration.LogPath) { $FolderPath = [io.path]::GetDirectoryName($Configuration.LogPath) if (-not (Test-Path -LiteralPath $FolderPath)) { $null = New-Item -Path $FolderPath -ItemType Directory -Force } $Script:PSDefaultParameterValues = @{ "Write-Color:LogFile" = $Configuration.LogPath } $CurrentLogs = Get-ChildItem -LiteralPath $FolderPath | Sort-Object -Property CreationTime -Descending | Select-Object -Skip $Configuration.LogMaximum if ($CurrentLogs) { Write-Color -Text '[i] ', "Logs directory has more than ", $Configuration.LogMaximum, " log files. Cleanup required..." -Color Yellow, DarkCyan, Red, DarkCyan foreach ($Log in $CurrentLogs) { try { Remove-Item -LiteralPath $Log.FullName -Confirm:$false Write-Color -Text '[+] ', "Deleted ", "$($Log.FullName)" -Color Yellow, White, Green } catch { Write-Color -Text '[-] ', "Couldn't delete log file $($Log.FullName). Error: ', "$($_.Exception.Message) -Color Yellow, White, Red } } } } } function Invoke-ADComputersCleanup { <# .SYNOPSIS Active Directory Cleanup function that can disable or delete computers that have not been logged on for a certain amount of time. .DESCRIPTION Active Directory Cleanup function that can disable or delete computers that have not been logged on for a certain amount of time. It has many options to customize the cleanup process. .PARAMETER Disable Enable the disable process, meaning the computers that meet the criteria will be disabled. .PARAMETER Delete Enable the delete process, meaning the computers that meet the criteria will be deleted. .PARAMETER DisableIsEnabled Disable computer only if it's Enabled or only if it's Disabled. By default it will try to disable all computers that are either disabled or enabled. While counter-intuitive for already disabled computers, this is useful if you want preproceess computers for deletion and need to get them on the list. .PARAMETER DisableNoServicePrincipalName Disable computer only if it has a ServicePrincipalName or only if it doesn't have a ServicePrincipalName. By default it doesn't care if it has a ServicePrincipalName or not. .PARAMETER DisableLastLogonDateMoreThan Disable computer only if it has a LastLogonDate that is more than the specified number of days. .PARAMETER DisablePasswordLastSetMoreThan Disable computer only if it has a PasswordLastSet that is more than the specified number of days. .PARAMETER DisableExcludeSystems Disable computer only if it's not on the list of excluded operating systems. If you want to exclude Windows 10, you can specify 'Windows 10' or 'Windows 10*' or 'Windows 10*' or '*Windows 10*' or '*Windows 10*'. You can also specify multiple operating systems by separating them with a comma. It's using the -like operator, so you can use wildcards. It's using OperatingSystem property of the computer object for comparison. .PARAMETER DisableIncludeSystems Disable computer only if it's on the list of included operating systems. If you want to include Windows 10, you can specify 'Windows 10' or 'Windows 10*' or 'Windows 10*' or '*Windows 10*' or '*Windows 10*'. You can also specify multiple operating systems by separating them with a comma. It's using the -like operator, so you can use wildcards. .PARAMETER DeleteIsEnabled Delete computer only if it's Enabled or only if it's Disabled. By default it will try to delete all computers that are either disabled or enabled. .PARAMETER DeleteNoServicePrincipalName Delete computer only if it has a ServicePrincipalName or only if it doesn't have a ServicePrincipalName. By default it doesn't care if it has a ServicePrincipalName or not. .PARAMETER DeleteLastLogonDateMoreThan Delete computer only if it has a LastLogonDate that is more than the specified number of days. .PARAMETER DeletePasswordLastSetMoreThan Delete computer only if it has a PasswordLastSet that is more than the specified number of days. .PARAMETER DeleteListProcessedMoreThan Delete computer only if it has been processed by this script more than the specified number of days ago. This is useful if you want to delete computers that have been disabled for a certain amount of time. It uses XML file to store the list of processed computers, so please make sure to not remove it or it will start over. .PARAMETER DeleteExcludeSystems Delete computer only if it's not on the list of excluded operating systems. If you want to exclude Windows 10, you can specify 'Windows 10' or 'Windows 10*' or 'Windows 10*' or '*Windows 10*' or '*Windows 10*'. You can also specify multiple operating systems by separating them with a comma. It's using the -like operator, so you can use wildcards. It's using OperatingSystem property of the computer object for comparison. .PARAMETER DeleteIncludeSystems Delete computer only if it's on the list of included operating systems. If you want to include Windows 10, you can specify 'Windows 10' or 'Windows 10*' or 'Windows 10*' or '*Windows 10*' or '*Windows 10*'. You can also specify multiple operating systems by separating them with a comma. It's using the -like operator, so you can use wildcards. .PARAMETER DeleteLimit Limit the number of computers that will be deleted. 0 = unlimited. Default is 1. This is to prevent accidental deletion of all computers that meet the criteria. Adjust the limit to your needs. .PARAMETER DisableLimit Limit the number of computers that will be disabled. 0 = unlimited. Default is 1. This is to prevent accidental disabling of all computers that meet the criteria. Adjust the limit to your needs. .PARAMETER Exclusions List of computers to exclude from the process. You can specify multiple computers by separating them with a comma. It's using the -like operator, so you can use wildcards. You can use SamAccoutName (remember about ending $), DistinguishedName, or DNSHostName property of the computer object for comparison. .PARAMETER DisableModifyDescription Modify the description of the computer object to include the date and time when it was disabled. By default it will not modify the description. .PARAMETER DisableModifyAdminDescription Modify the admin description of the computer object to include the date and time when it was disabled. By default it will not modify the admin description. .PARAMETER Filter Filter to use when searching for computers in Get-ADComputer cmdlet. Default is '*' .PARAMETER DataStorePath Path to the XML file that will be used to store the list of processed computers, current run, and history data. Default is $PSScriptRoot\ProcessedComputers.xml .PARAMETER ReportOnly Only generate the report, don't disable or delete computers. .PARAMETER WhatIfDelete WhatIf parameter for the Delete process. It's not nessessary to specify this parameter if you use WhatIf parameter which applies to both processes. .PARAMETER WhatIfDisable WhatIf parameter for the Disable process. It's not nessessary to specify this parameter if you use WhatIf parameter which applies to both processes. .PARAMETER LogPath Path to the log file. Default is no logging to file. .PARAMETER LogMaximum Maximum number of log files to keep. Default is 5. .PARAMETER Suppress Suppress output of the object and only display to console .PARAMETER ShowHTML Show HTML report in the browser once the function is complete .PARAMETER Online Online parameter causes HTML report to use CDN for CSS and JS files. This can be useful to minimize the size of the HTML report. Otherwise the report will start with at least 2MB in size. .PARAMETER ReportPath Path to the HTML report file. Default is $PSScriptRoot\ProcessedComputers.html .EXAMPLE An example .NOTES General notes #> [CmdletBinding(SupportsShouldProcess)] param( [switch] $Disable, [switch] $Delete, [nullable[bool]] $DisableIsEnabled, [nullable[bool]] $DisableNoServicePrincipalName = $null, [nullable[int]] $DisableLastLogonDateMoreThan = 180, [nullable[int]] $DisablePasswordLastSetMoreThan = 180, [Array] $DisableExcludeSystems = @(), [Array] $DisableIncludeSystems = @(), [nullable[bool]] $DeleteIsEnabled, [nullable[bool]] $DeleteNoServicePrincipalName = $null, [nullable[int]] $DeleteLastLogonDateMoreThan = 180, [nullable[int]] $DeletePasswordLastSetMoreThan = 180, [nullable[int]] $DeleteListProcessedMoreThan, [Array] $DeleteExcludeSystems = @(), [Array] $DeleteIncludeSystems = @(), [int] $DeleteLimit = 1, # 0 = unlimited [int] $DisableLimit = 1, # 0 = unlimited [Array] $Exclusions = @('OU=Domain Controllers'), [switch] $DisableModifyDescription, [switch] $DisableModifyAdminDescription, [string] $Filter = '*', [string] $DataStorePath, [switch] $ReportOnly, [switch] $WhatIfDelete, [switch] $WhatIfDisable, [string] $LogPath, [int] $LogMaximum = 5, [switch] $Suppress, [switch] $ShowHTML, [switch] $Online, [string] $ReportPath ) # just in case user wants to use -WhatIf instead of -WhatIfDelete and -WhatIfDisable if (-not $WhatIfDelete -and -not $WhatIfDisable) { $WhatIfDelete = $WhatIfDisable = $WhatIfPreference } # lets enable global logging Set-LoggingCapabilities -Configuration @{ LogPath = $LogPath LogMaximum = $LogMaximum } # prepare configuration $DisableOnlyIf = [ordered] @{ IsEnabled = $DisableIsEnabled NoServicePrincipalName = $DisableNoServicePrincipalName LastLogonDateMoreThan = $DisableLastLogonDateMoreThan PasswordLastSetMoreThan = $DisablePasswordLastSetMoreThan ExcludeSystems = $DisableExcludeSystems IncludeSystems = $DisableIncludeSystems } $DeleteOnlyIf = [ordered] @{ IsEnabled = $DeleteIsEnabled NoServicePrincipalName = $DeleteNoServicePrincipalName LastLogonDateMoreThan = $DeleteLastLogonDateMoreThan PasswordLastSetMoreThan = $DeletePasswordLastSetMoreThan ListProcessedMoreThan = $DeleteListProcessedMoreThan ExcludeSystems = $DeleteExcludeSystems IncludeSystems = $DeleteIncludeSystems } if (-not $DataStorePath) { $DataStorePath = $($MyInvocation.PSScriptRoot) + '\ProcessedComputers.xml' } if (-not $ReportPath) { $ReportPath = $($MyInvocation.PSScriptRoot) + '\ProcessedComputers.html' } $Today = Get-Date $Properties = 'DistinguishedName', 'DNSHostName', 'SamAccountName', 'Enabled', 'OperatingSystem', 'OperatingSystemVersion', 'LastLogonDate', 'PasswordLastSet', 'PasswordExpired', 'servicePrincipalName', 'logonCount', 'ManagedBy', 'Description', 'WhenCreated', 'WhenChanged' $Export = [ordered] @{ Version = Get-GitHubVersion -Cmdlet 'Invoke-ADComputersCleanup' -RepositoryOwner 'evotecit' -RepositoryName 'CleanupActiveDirectory' CurrentRun = $null History = $null PendingDeletion = $null } Write-Color '[i] ', "[CleanupActiveDirectory] ", 'Version', ' [Informative] ', $Export['Version'] -Color Yellow, DarkGray, Yellow, DarkGray, Magenta Write-Color -Text "[i] Started process of cleaning up stale computers" -Color Green Write-Color -Text "[i] Executed by: ", $Env:USERNAME, ' from domain ', $Env:USERDNSDOMAIN -Color Green try { $Forest = Get-ADForest } catch { Write-Color -Text "[i] ", "Couldn't get forest. Terminating. Lack of domain contact? Error: $($_.Exception.Message)." -Color Yellow, Red return } try { if ($DataStorePath -and (Test-Path -LiteralPath $DataStorePath)) { $FileImport = Import-Clixml -LiteralPath $DataStorePath -ErrorAction Stop #$ProcessedComputers = Import-Clixml -LiteralPath $FilePath -ErrorAction Stop $ProcessedComputers = $FileImport.ProcessedComputers $Export['History'] = $FileImport.History } if (-not $ProcessedComputers) { $ProcessedComputers = [ordered] @{ } } } catch { Write-Color -Text "[i] ", "Couldn't read the list or wrong format. Error: $($_.Exception.Message)" -Color Yellow, Red return } if (-not $Disable -and -not $Delete) { Write-Color -Text "[i] ", "No action was taken. You need to enable Disable or/and Delete feature to have any action." -Color Yellow, Red return } $AllComputers = [ordered] @{} $Report = [ordered] @{ } foreach ($Domain in $Forest.Domains) { $Report["$Domain"] = @{ } $DC = Get-ADDomainController -Discover -DomainName $Domain $Server = $DC.HostName[0] $DomainInformation = Get-ADDomain -Identity $Domain -Server $Server $Report["$Domain"]['Server'] = $Server Write-Color "[i] Getting all computers for domain ", $Domain -Color Yellow, Magenta, Yellow [Array] $Computers = Get-ADComputer -Filter $Filter -Server $Server -Properties $Properties foreach ($Computer in $Computers) { $AllComputers[$($Computer.DistinguishedName)] = $Computer } Write-Color "[i] ", "Computers found for domain $Domain`: ", $($Computers.Count) -Color Yellow, Cyan, Green if ($Disable) { Write-Color "[i] ", "Processing computers to disable for domain $Domain" -Color Yellow, Cyan, Green Write-Color "[i] ", "Looking for computers with LastLogonDate more than ", $DisableLastLogonDateMoreThan, " days" -Color Yellow, Cyan, Green, Cyan Write-Color "[i] ", "Looking for computers with PasswordLastSet more than ", $DisablePasswordLastSetMoreThan, " days" -Color Yellow, Cyan, Green, Cyan if ($DisableNoServicePrincipalName) { Write-Color "[i] ", "Looking for computers with no ServicePrincipalName" -Color Yellow, Cyan, Green } $Report["$Domain"]['ComputersToBeDisabled'] = Get-ADComputersToDisable -Computers $Computers -DisableOnlyIf $DisableOnlyIf -Exclusions $Exclusions -DomainInformation $DomainInformation -ProcessedComputers $ProcessedComputers } if ($Delete) { Write-Color "[i] ", "Processing computers to delete for domain $Domain" -Color Yellow, Cyan, Green Write-Color "[i] ", "Looking for computers with LastLogonDate more than ", $DeleteLastLogonDateMoreThan, " days" -Color Yellow, Cyan, Green, Cyan Write-Color "[i] ", "Looking for computers with PasswordLastSet more than ", $DeletePasswordLastSetMoreThan, " days" -Color Yellow, Cyan, Green, Cyan if ($DeleteNoServicePrincipalName) { Write-Color "[i] ", "Looking for computers with no ServicePrincipalName" -Color Yellow, Cyan, Green } if ($null -ne $DeleteIsEnabled) { if ($DeleteIsEnabled) { Write-Color "[i] ", "Looking for computers that are enabled" -Color Yellow, Cyan, Green } else { Write-Color "[i] ", "Looking for computers that are disabled" -Color Yellow, Cyan, Green } } $Report["$Domain"]['ComputersToBeDeleted'] = Get-ADComputersToDelete -Computers $Computers -DeleteOnlyIf $DeleteOnlyIf -Exclusions $Exclusions -DomainInformation $DomainInformation -ProcessedComputers $ProcessedComputers } } foreach ($Domain in $Report.Keys) { if ($Disable) { if ($DisableLimit -eq 0) { $DisableLimitText = 'Unlimited' } else { $DisableLimitText = $DisableLimit } $ComputersToBeDisabled = if ($null -ne $Report["$Domain"]['ComputersToBeDisabled'].Count) { $Report["$Domain"]['ComputersToBeDisabled'].Count } else { 0 } Write-Color "[i] ", "Computers to be disabled for domain $Domain`: ", $ComputersToBeDisabled, ". Current disable limit: ", $DisableLimitText -Color Yellow, Cyan, Green, Cyan, Yellow } if ($Delete) { if ($DeleteLimit -eq 0) { $DeleteLimitText = 'Unlimited' } else { $DeleteLimitText = $DeleteLimit } $ComputersToBeDeleted = if ($null -ne $Report["$Domain"]['ComputersToBeDeleted'].Count) { $Report["$Domain"]['ComputersToBeDeleted'].Count } else { 0 } Write-Color "[i] ", "Computers to be deleted for domain $Domain`: ", $ComputersToBeDeleted, ". Current delete limit: ", $DeleteLimitText -Color Yellow, Cyan, Green, Cyan, Yellow } } if ($Disable) { $CountDisable = 0 # :top means name of the loop, so we can break it [Array] $ReportDisabled = :topLoop foreach ($Domain in $Report.Keys) { Write-Color "[i] ", "Starting process of disabling computers for domain $Domain" -Color Yellow, Green foreach ($Computer in $Report["$Domain"]['ComputersToBeDisabled']) { $Server = $Report["$Domain"]['Server'] if ($ReportOnly) { $Computer } else { Write-Color -Text "[i] Disabling computer ", $Computer.SamAccountName, ' DN: ', $Computer.DistinguishedName, ' Enabled: ', $Computer.Enabled, ' Operating System: ', $Computer.OperatingSystem, ' LastLogon: ', $Computer.LastLogonDate, " / " , $Computer.LastLogonDays , ' days, PasswordLastSet: ', $Computer.PasswordLastSet, " / ", $Computer.PasswordLastChangedDays, " days" -Color Yellow, Green, Yellow, Green, Yellow, Green, Yellow, Green, Yellow, Green, Yellow, Green, Yellow, Green try { Disable-ADAccount -Identity $Computer.DistinguishedName -Server $Server -WhatIf:$WhatIfDisable -ErrorAction Stop Write-Color -Text "[+] Disabling computer ", $Computer.DistinguishedName, " (WhatIf: $WhatIfDisable) successful." -Color Yellow, Green, Yellow Write-Event -ID 10 -LogName 'Application' -EntryType Information -Category 1000 -Source 'CleanupComputers' -Message "Disabling computer $($Computer.SamAccountName) successful." -AdditionalFields @('Disable', $Computer.SamAccountName, $Computer.DistinguishedName, $Computer.Enabled, $Computer.OperatingSystem, $Computer.LastLogonDate, $Computer.PasswordLastSet, $WhatIfDisable) -WarningAction SilentlyContinue -WarningVariable warnings foreach ($W in $Warnings) { Write-Color -Text "[-] ", "Warning: ", $W -Color Yellow, Cyan, Red } $Success = $true } catch { $Success = $false Write-Color -Text "[-] Disabling computer ", $Computer.DistinguishedName, " (WhatIf: $WhatIfDisable) failed. Error: $($_.Exception.Message)" -Color Yellow, Red, Yellow Write-Event -ID 10 -LogName 'Application' -EntryType Error -Category 1001 -Source 'CleanupComputers' -Message "Disabling computer $($Computer.SamAccountName) failed. Error: $($_.Exception.Message)" -AdditionalFields @('Disable', $Computer.SamAccountName, $Computer.DistinguishedName, $Computer.Enabled, $Computer.OperatingSystem, $Computer.LastLogonDate, $Computer.PasswordLastSet, $WhatIfDisable, $($_.Exception.Message)) -WarningAction SilentlyContinue -WarningVariable warnings foreach ($W in $Warnings) { Write-Color -Text "[-] ", "Warning: ", $W -Color Yellow, Cyan, Red } } if ($Success) { if ($DisableModifyDescription -eq $true) { $DisableModifyDescriptionText = "Disabled by a script, LastLogon $($Computer.LastLogonDate) ($($DisableOnlyIf.LastLogonDateMoreThan)), PasswordLastSet $($Computer.PasswordLastSet) ($($DisableOnlyIf.PasswordLastSetMoreThan))" try { Set-ADComputer -Identity $Computer.DistinguishedName -Description $DisableModifyDescriptionText -WhatIf:$WhatIfDisable -ErrorAction Stop -Server $Server Write-Color -Text "[+] ", "Setting description on disabled computer ", $Computer.DistinguishedName, " (WhatIf: $WhatIfDisable) successful. Set to: ", $DisableModifyDescriptionText -Color Yellow, Green, Yellow, Green, Yellow } catch { Write-Color -Text "[-] ", "Setting description on disabled computer ", $Computer.DistinguishedName, " (WhatIf: $WhatIfDisable) failed. Error: $($_.Exception.Message)" -Color Yellow, Red, Yellow } } if ($DisableModifyAdminDescription) { $DisableModifyAdminDescriptionText = "Disabled by a script, LastLogon $($Computer.LastLogonDate) ($($DisableOnlyIf.LastLogonDateMoreThan)), PasswordLastSet $($Computer.PasswordLastSet) ($($DisableOnlyIf.PasswordLastSetMoreThan))" try { Set-ADObject -Identity $Computer.DistinguishedName -Replace @{ AdminDescription = $DisableModifyAdminDescriptionText } -WhatIf:$WhatIfDisable -ErrorAction Stop -Server $Server Write-Color -Text "[+] ", "Setting admin description on disabled computer ", $Computer.DistinguishedName, " (WhatIf: $WhatIfDisable) successful. Set to: ", $DisableModifyAdminDescriptionText -Color Yellow, Green, Yellow, Green, Yellow } catch { Write-Color -Text "[-] ", "Setting admin description on disabled computer ", $Computer.DistinguishedName, " (WhatIf: $WhatIfDisable) failed. Error: $($_.Exception.Message)" -Color Yellow, Red, Yellow } } } # this is to store actual disabling time - we can't trust WhenChanged date $Computer.ActionDate = $Today if ($WhatIfDisable.IsPresent) { $Computer.ActionStatus = 'WhatIf' } else { $Computer.ActionStatus = $Success } $ProcessedComputers["$($Computer.DistinguishedName)"] = $Computer # return computer to $ReportDisabled so we can see summary just in case $Computer $CountDisable++ if ($DisableLimit) { if ($DisableLimit -eq $CountDisable) { break topLoop # this breaks top loop } } } } } } if ($Delete) { $CountDeleteLimit = 0 # :top means name of the loop, so we can break it [Array] $ReportDeleted = :topLoop foreach ($Domain in $Report.Keys) { foreach ($Computer in $Report["$Domain"]['ComputersToBeDeleted']) { $Server = $Report["$Domain"]['Server'] if ($ReportOnly) { $Computer } else { Write-Color -Text "[i] Deleting computer ", $Computer.SamAccountName, ' DN: ', $Computer.DistinguishedName, ' Enabled: ', $Computer.Enabled, ' Operating System: ', $Computer.OperatingSystem, ' LastLogon: ', $Computer.LastLogonDate, " / " , $Computer.LastLogonDays , ' days, PasswordLastSet: ', $Computer.PasswordLastSet, " / ", $Computer.PasswordLastChangedDays, " days" -Color Yellow, Green, Yellow, Green, Yellow, Green, Yellow, Green, Yellow, Green, Yellow, Green, Yellow, Green try { $Success = $true Remove-ADObject -Identity $Computer.DistinguishedName -Recursive -WhatIf:$WhatIfDelete -Server $Server -ErrorAction Stop -Confirm:$false Write-Color -Text "[+] Deleting computer ", $Computer.DistinguishedName, " (WhatIf: $($WhatIfDelete.IsPresent)) successful." -Color Yellow, Green, Yellow Write-Event -ID 10 -LogName 'Application' -EntryType Warning -Category 1000 -Source 'CleanupComputers' -Message "Deleting computer $($Computer.SamAccountName) successful." -AdditionalFields @('Delete', $Computer.SamAccountName, $Computer.DistinguishedName, $Computer.Enabled, $Computer.OperatingSystem, $Computer.LastLogonDate, $Computer.PasswordLastSet, $WhatIfDelete) -WarningAction SilentlyContinue -WarningVariable warnings foreach ($W in $Warnings) { Write-Color -Text "[-] ", "Warning: ", $W -Color Yellow, Cyan, Red } } catch { $Success = $false Write-Color -Text "[-] Deleting computer ", $Computer.DistinguishedName, " (WhatIf: $($WhatIfDelete.IsPresent)) failed. Error: $($_.Exception.Message)" -Color Yellow, Red, Yellow Write-Event -ID 10 -LogName 'Application' -EntryType Error -Category 1000 -Source 'CleanupComputers' -Message "Deleting computer $($Computer.SamAccountName) failed." -AdditionalFields @('Delete', $Computer.SamAccountName, $Computer.DistinguishedName, $Computer.Enabled, $Computer.OperatingSystem, $Computer.LastLogonDate, $Computer.PasswordLastSet, $WhatIfDelete, $($_.Exception.Message)) -WarningAction SilentlyContinue -WarningVariable warnings foreach ($W in $Warnings) { Write-Color -Text "[-] ", "Warning: ", $W -Color Yellow, Cyan, Red } } $Computer.ActionDate = $Today if ($WhatIfDelete.IsPresent) { $Computer.ActionStatus = 'WhatIf' } else { $Computer.ActionStatus = $Success } $ProcessedComputers.Remove("$($Computer.DistinguishedName)") # return computer to $ReportDeleted so we can see summary just in case $Computer $CountDeleteLimit++ if ($DeleteLimit) { if ($DeleteLimit -eq $CountDeleteLimit) { break topLoop # this breaks top loop } } } } } } Write-Color "[i] ", "Cleanup process for processed computers that no longer exists in AD" -Color Yellow, Green foreach ($DN in [string[]] $ProcessedComputers.Keys) { if (-not $AllComputers["$($DN)"]) { Write-Color -Text "[*] Removing computer from pending list ", $ProcessedComputers[$DN].SamAccountName, " ($DN)" -Color Yellow, Green, Yellow $ProcessedComputers.Remove("$($DN)") } } # Building up summary $Export.PendingDeletion = $ProcessedComputers.Values $Export.CurrentRun = $ReportDisabled + $ReportDeleted $Export.History = $Export.History + $ReportDisabled + $ReportDeleted #if ($DeleteListProcessedMoreThan) { Write-Color "[i] ", "Exporting Processed List" -Color Yellow, Magenta # $ProcessedComputers | Export-Clixml -LiteralPath $DataStorePath -Encoding Unicode $Export | Export-Clixml -LiteralPath $DataStorePath -Encoding Unicode Write-Color -Text "[i] ", "Summary of cleaning up stale computers" -Color Yellow, Cyan foreach ($Domain in $Report.Keys | Where-Object { $_ -notin 'ReportPendingDeletion', 'ReportDisabled', 'ReportDeleted' }) { if ($Disable) { Write-Color -Text "[i] ", "Computers to be disabled for domain $Domain`: ", $Report["$Domain"]['ComputersToBeDisabled'].Count -Color Yellow, Cyan, Green } if ($Delete) { Write-Color -Text "[i] ", "Computers to be deleted for domain $Domain`: ", $Report["$Domain"]['ComputersToBeDeleted'].Count -Color Yellow, Cyan, Green } } Write-Color -Text "[i] ", "Computers pending deletion`:", $Report['ReportPendingDeletion'].Count -Color Yellow, Cyan, Green if ($Disable) { Write-Color -Text "[i] ", "Computers disabled in this run`: ", $ReportDisabled.Count -Color Yellow, Cyan, Green } if ($Delete) { Write-Color -Text "[i] ", "Computers deleted in this run`: ", $ReportDeleted.Count -Color Yellow, Cyan, Green } if ($Export -and $ReportPath) { Write-Color "[i] ", "Generating HTML report" -Color Yellow, Magenta New-HTMLProcessedComputers -Export $Export -FilePath $ReportPath -Online:$Online.IsPresent -ShowHTML:$ShowHTML.IsPresent -LogFile $LogPath } Write-Color -Text "[i] Finished process of cleaning up stale computers" -Color Green if (-not $Suppress) { $Export } } # Export functions and aliases as required Export-ModuleMember -Function @('Invoke-ADComputersCleanup') -Alias @() # SIG # Begin signature block # MIInPgYJKoZIhvcNAQcCoIInLzCCJysCAQExDzANBglghkgBZQMEAgEFADB5Bgor # BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG # KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCAvIXN3AiRyN2SV # juAriNg1WjPLqxm0rp1pGn1GDG4oyaCCITcwggO3MIICn6ADAgECAhAM5+DlF9hG # /o/lYPwb8DA5MA0GCSqGSIb3DQEBBQUAMGUxCzAJBgNVBAYTAlVTMRUwEwYDVQQK # EwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xJDAiBgNV # BAMTG0RpZ2lDZXJ0IEFzc3VyZWQgSUQgUm9vdCBDQTAeFw0wNjExMTAwMDAwMDBa # Fw0zMTExMTAwMDAwMDBaMGUxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2Vy # dCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xJDAiBgNVBAMTG0RpZ2lD # ZXJ0IEFzc3VyZWQgSUQgUm9vdCBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC # AQoCggEBAK0OFc7kQ4BcsYfzt2D5cRKlrtwmlIiq9M71IDkoWGAM+IDaqRWVMmE8 # tbEohIqK3J8KDIMXeo+QrIrneVNcMYQq9g+YMjZ2zN7dPKii72r7IfJSYd+fINcf # 4rHZ/hhk0hJbX/lYGDW8R82hNvlrf9SwOD7BG8OMM9nYLxj+KA+zp4PWw25EwGE1 # lhb+WZyLdm3X8aJLDSv/C3LanmDQjpA1xnhVhyChz+VtCshJfDGYM2wi6YfQMlqi # uhOCEe05F52ZOnKh5vqk2dUXMXWuhX0irj8BRob2KHnIsdrkVxfEfhwOsLSSplaz # vbKX7aqn8LfFqD+VFtD/oZbrCF8Yd08CAwEAAaNjMGEwDgYDVR0PAQH/BAQDAgGG # MA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFEXroq/0ksuCMS1Ri6enIZ3zbcgP # MB8GA1UdIwQYMBaAFEXroq/0ksuCMS1Ri6enIZ3zbcgPMA0GCSqGSIb3DQEBBQUA # A4IBAQCiDrzf4u3w43JzemSUv/dyZtgy5EJ1Yq6H6/LV2d5Ws5/MzhQouQ2XYFwS # TFjk0z2DSUVYlzVpGqhH6lbGeasS2GeBhN9/CTyU5rgmLCC9PbMoifdf/yLil4Qf # 6WXvh+DfwWdJs13rsgkq6ybteL59PyvztyY1bV+JAbZJW58BBZurPSXBzLZ/wvFv # hsb6ZGjrgS2U60K3+owe3WLxvlBnt2y98/Efaww2BxZ/N3ypW2168RJGYIPXJwS+ # S86XvsNnKmgR34DnDDNmvxMNFG7zfx9jEB76jRslbWyPpbdhAbHSoyahEHGdreLD # +cOZUbcrBwjOLuZQsqf6CkUvovDyMIIFMDCCBBigAwIBAgIQBAkYG1/Vu2Z1U0O1 # b5VQCDANBgkqhkiG9w0BAQsFADBlMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGln # aUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSQwIgYDVQQDExtE # aWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgQ0EwHhcNMTMxMDIyMTIwMDAwWhcNMjgx # MDIyMTIwMDAwWjByMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5j # MRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMTEwLwYDVQQDEyhEaWdpQ2VydCBT # SEEyIEFzc3VyZWQgSUQgQ29kZSBTaWduaW5nIENBMIIBIjANBgkqhkiG9w0BAQEF # AAOCAQ8AMIIBCgKCAQEA+NOzHH8OEa9ndwfTCzFJGc/Q+0WZsTrbRPV/5aid2zLX # cep2nQUut4/6kkPApfmJ1DcZ17aq8JyGpdglrA55KDp+6dFn08b7KSfH03sjlOSR # I5aQd4L5oYQjZhJUM1B0sSgmuyRpwsJS8hRniolF1C2ho+mILCCVrhxKhwjfDPXi # TWAYvqrEsq5wMWYzcT6scKKrzn/pfMuSoeU7MRzP6vIK5Fe7SrXpdOYr/mzLfnQ5 # Ng2Q7+S1TqSp6moKq4TzrGdOtcT3jNEgJSPrCGQ+UpbB8g8S9MWOD8Gi6CxR93O8 # vYWxYoNzQYIH5DiLanMg0A9kczyen6Yzqf0Z3yWT0QIDAQABo4IBzTCCAckwEgYD # VR0TAQH/BAgwBgEB/wIBADAOBgNVHQ8BAf8EBAMCAYYwEwYDVR0lBAwwCgYIKwYB # BQUHAwMweQYIKwYBBQUHAQEEbTBrMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5k # aWdpY2VydC5jb20wQwYIKwYBBQUHMAKGN2h0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0 # LmNvbS9EaWdpQ2VydEFzc3VyZWRJRFJvb3RDQS5jcnQwgYEGA1UdHwR6MHgwOqA4 # oDaGNGh0dHA6Ly9jcmw0LmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEFzc3VyZWRJRFJv # b3RDQS5jcmwwOqA4oDaGNGh0dHA6Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2Vy # dEFzc3VyZWRJRFJvb3RDQS5jcmwwTwYDVR0gBEgwRjA4BgpghkgBhv1sAAIEMCow # KAYIKwYBBQUHAgEWHGh0dHBzOi8vd3d3LmRpZ2ljZXJ0LmNvbS9DUFMwCgYIYIZI # AYb9bAMwHQYDVR0OBBYEFFrEuXsqCqOl6nEDwGD5LfZldQ5YMB8GA1UdIwQYMBaA # FEXroq/0ksuCMS1Ri6enIZ3zbcgPMA0GCSqGSIb3DQEBCwUAA4IBAQA+7A1aJLPz # ItEVyCx8JSl2qB1dHC06GsTvMGHXfgtg/cM9D8Svi/3vKt8gVTew4fbRknUPUbRu # pY5a4l4kgU4QpO4/cY5jDhNLrddfRHnzNhQGivecRk5c/5CxGwcOkRX7uq+1UcKN # JK4kxscnKqEpKBo6cSgCPC6Ro8AlEeKcFEehemhor5unXCBc2XGxDI+7qPjFEmif # z0DLQESlE/DmZAwlCEIysjaKJAL+L3J+HNdJRZboWR3p+nRka7LrZkPas7CM1ekN # 3fYBIM6ZMWM9CBoYs4GbT8aTEAb8B4H6i9r5gkn3Ym6hU/oSlBiFLpKR6mhsRDKy # ZqHnGKSaZFHvMIIFPTCCBCWgAwIBAgIQBNXcH0jqydhSALrNmpsqpzANBgkqhkiG # 9w0BAQsFADByMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkw # FwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMTEwLwYDVQQDEyhEaWdpQ2VydCBTSEEy # IEFzc3VyZWQgSUQgQ29kZSBTaWduaW5nIENBMB4XDTIwMDYyNjAwMDAwMFoXDTIz # MDcwNzEyMDAwMFowejELMAkGA1UEBhMCUEwxEjAQBgNVBAgMCcWabMSFc2tpZTER # MA8GA1UEBxMIS2F0b3dpY2UxITAfBgNVBAoMGFByemVteXPFgmF3IEvFgnlzIEVW # T1RFQzEhMB8GA1UEAwwYUHJ6ZW15c8WCYXcgS8WCeXMgRVZPVEVDMIIBIjANBgkq # hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAv7KB3iyBrhkLUbbFe9qxhKKPBYqDBqln # r3AtpZplkiVjpi9dMZCchSeT5ODsShPuZCIxJp5I86uf8ibo3vi2S9F9AlfFjVye # 3dTz/9TmCuGH8JQt13ozf9niHecwKrstDVhVprgxi5v0XxY51c7zgMA2g1Ub+3ti # i0vi/OpmKXdL2keNqJ2neQ5cYly/GsI8CREUEq9SZijbdA8VrRF3SoDdsWGf3tZZ # zO6nWn3TLYKQ5/bw5U445u/V80QSoykszHRivTj+H4s8ABiforhi0i76beA6Ea41 # zcH4zJuAp48B4UhjgRDNuq8IzLWK4dlvqrqCBHKqsnrF6BmBrv+BXQIDAQABo4IB # xTCCAcEwHwYDVR0jBBgwFoAUWsS5eyoKo6XqcQPAYPkt9mV1DlgwHQYDVR0OBBYE # FBixNSfoHFAgJk4JkDQLFLRNlJRmMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAK # BggrBgEFBQcDAzB3BgNVHR8EcDBuMDWgM6Axhi9odHRwOi8vY3JsMy5kaWdpY2Vy # dC5jb20vc2hhMi1hc3N1cmVkLWNzLWcxLmNybDA1oDOgMYYvaHR0cDovL2NybDQu # ZGlnaWNlcnQuY29tL3NoYTItYXNzdXJlZC1jcy1nMS5jcmwwTAYDVR0gBEUwQzA3 # BglghkgBhv1sAwEwKjAoBggrBgEFBQcCARYcaHR0cHM6Ly93d3cuZGlnaWNlcnQu # Y29tL0NQUzAIBgZngQwBBAEwgYQGCCsGAQUFBwEBBHgwdjAkBggrBgEFBQcwAYYY # aHR0cDovL29jc3AuZGlnaWNlcnQuY29tME4GCCsGAQUFBzAChkJodHRwOi8vY2Fj # ZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRTSEEyQXNzdXJlZElEQ29kZVNpZ25p # bmdDQS5jcnQwDAYDVR0TAQH/BAIwADANBgkqhkiG9w0BAQsFAAOCAQEAmr1sz4ls # LARi4wG1eg0B8fVJFowtect7SnJUrp6XRnUG0/GI1wXiLIeow1UPiI6uDMsRXPHU # F/+xjJw8SfIbwava2eXu7UoZKNh6dfgshcJmo0QNAJ5PIyy02/3fXjbUREHINrTC # vPVbPmV6kx4Kpd7KJrCo7ED18H/XTqWJHXa8va3MYLrbJetXpaEPpb6zk+l8Rj9y # G4jBVRhenUBUUj3CLaWDSBpOA/+sx8/XB9W9opYfYGb+1TmbCkhUg7TB3gD6o6ES # Jre+fcnZnPVAPESmstwsT17caZ0bn7zETKlNHbc1q+Em9kyBjaQRcEQoQQNpezQu # g9ufqExx6lHYDjCCBY0wggR1oAMCAQICEA6bGI750C3n79tQ4ghAGFowDQYJKoZI # hvcNAQEMBQAwZTELMAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZ # MBcGA1UECxMQd3d3LmRpZ2ljZXJ0LmNvbTEkMCIGA1UEAxMbRGlnaUNlcnQgQXNz # dXJlZCBJRCBSb290IENBMB4XDTIyMDgwMTAwMDAwMFoXDTMxMTEwOTIzNTk1OVow # YjELMAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQ # d3d3LmRpZ2ljZXJ0LmNvbTEhMB8GA1UEAxMYRGlnaUNlcnQgVHJ1c3RlZCBSb290 # IEc0MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAv+aQc2jeu+RdSjww # IjBpM+zCpyUuySE98orYWcLhKac9WKt2ms2uexuEDcQwH/MbpDgW61bGl20dq7J5 # 8soR0uRf1gU8Ug9SH8aeFaV+vp+pVxZZVXKvaJNwwrK6dZlqczKU0RBEEC7fgvMH # hOZ0O21x4i0MG+4g1ckgHWMpLc7sXk7Ik/ghYZs06wXGXuxbGrzryc/NrDRAX7F6 # Zu53yEioZldXn1RYjgwrt0+nMNlW7sp7XeOtyU9e5TXnMcvak17cjo+A2raRmECQ # ecN4x7axxLVqGDgDEI3Y1DekLgV9iPWCPhCRcKtVgkEy19sEcypukQF8IUzUvK4b # A3VdeGbZOjFEmjNAvwjXWkmkwuapoGfdpCe8oU85tRFYF/ckXEaPZPfBaYh2mHY9 # WV1CdoeJl2l6SPDgohIbZpp0yt5LHucOY67m1O+SkjqePdwA5EUlibaaRBkrfsCU # tNJhbesz2cXfSwQAzH0clcOP9yGyshG3u3/y1YxwLEFgqrFjGESVGnZifvaAsPvo # ZKYz0YkH4b235kOkGLimdwHhD5QMIR2yVCkliWzlDlJRR3S+Jqy2QXXeeqxfjT/J # vNNBERJb5RBQ6zHFynIWIgnffEx1P2PsIV/EIFFrb7GrhotPwtZFX50g/KEexcCP # orF+CiaZ9eRpL5gdLfXZqbId5RsCAwEAAaOCATowggE2MA8GA1UdEwEB/wQFMAMB # Af8wHQYDVR0OBBYEFOzX44LScV1kTN8uZz/nupiuHA9PMB8GA1UdIwQYMBaAFEXr # oq/0ksuCMS1Ri6enIZ3zbcgPMA4GA1UdDwEB/wQEAwIBhjB5BggrBgEFBQcBAQRt # MGswJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBDBggrBgEF # BQcwAoY3aHR0cDovL2NhY2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0QXNzdXJl # ZElEUm9vdENBLmNydDBFBgNVHR8EPjA8MDqgOKA2hjRodHRwOi8vY3JsMy5kaWdp # Y2VydC5jb20vRGlnaUNlcnRBc3N1cmVkSURSb290Q0EuY3JsMBEGA1UdIAQKMAgw # BgYEVR0gADANBgkqhkiG9w0BAQwFAAOCAQEAcKC/Q1xV5zhfoKN0Gz22Ftf3v1cH # vZqsoYcs7IVeqRq7IviHGmlUIu2kiHdtvRoU9BNKei8ttzjv9P+Aufih9/Jy3iS8 # UgPITtAq3votVs/59PesMHqai7Je1M/RQ0SbQyHrlnKhSLSZy51PpwYDE3cnRNTn # f+hZqPC/Lwum6fI0POz3A8eHqNJMQBk1RmppVLC4oVaO7KTVPeix3P0c2PR3WlxU # jG/voVA9/HYJaISfb8rbII01YBwCA8sgsKxYoA5AY8WYIsGyWfVVa88nq2x2zm8j # LfR+cWojayL/ErhULSd+2DrZ8LaHlv1b0VysGMNNn3O3AamfV6peKOK5lDCCBq4w # ggSWoAMCAQICEAc2N7ckVHzYR6z9KGYqXlswDQYJKoZIhvcNAQELBQAwYjELMAkG # A1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQd3d3LmRp # Z2ljZXJ0LmNvbTEhMB8GA1UEAxMYRGlnaUNlcnQgVHJ1c3RlZCBSb290IEc0MB4X # DTIyMDMyMzAwMDAwMFoXDTM3MDMyMjIzNTk1OVowYzELMAkGA1UEBhMCVVMxFzAV # BgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMTswOQYDVQQDEzJEaWdpQ2VydCBUcnVzdGVk # IEc0IFJTQTQwOTYgU0hBMjU2IFRpbWVTdGFtcGluZyBDQTCCAiIwDQYJKoZIhvcN # AQEBBQADggIPADCCAgoCggIBAMaGNQZJs8E9cklRVcclA8TykTepl1Gh1tKD0Z5M # om2gsMyD+Vr2EaFEFUJfpIjzaPp985yJC3+dH54PMx9QEwsmc5Zt+FeoAn39Q7SE # 2hHxc7Gz7iuAhIoiGN/r2j3EF3+rGSs+QtxnjupRPfDWVtTnKC3r07G1decfBmWN # lCnT2exp39mQh0YAe9tEQYncfGpXevA3eZ9drMvohGS0UvJ2R/dhgxndX7RUCyFo # bjchu0CsX7LeSn3O9TkSZ+8OpWNs5KbFHc02DVzV5huowWR0QKfAcsW6Th+xtVhN # ef7Xj3OTrCw54qVI1vCwMROpVymWJy71h6aPTnYVVSZwmCZ/oBpHIEPjQ2OAe3Vu # JyWQmDo4EbP29p7mO1vsgd4iFNmCKseSv6De4z6ic/rnH1pslPJSlRErWHRAKKtz # Q87fSqEcazjFKfPKqpZzQmiftkaznTqj1QPgv/CiPMpC3BhIfxQ0z9JMq++bPf4O # uGQq+nUoJEHtQr8FnGZJUlD0UfM2SU2LINIsVzV5K6jzRWC8I41Y99xh3pP+OcD5 # sjClTNfpmEpYPtMDiP6zj9NeS3YSUZPJjAw7W4oiqMEmCPkUEBIDfV8ju2TjY+Cm # 4T72wnSyPx4JduyrXUZ14mCjWAkBKAAOhFTuzuldyF4wEr1GnrXTdrnSDmuZDNIz # tM2xAgMBAAGjggFdMIIBWTASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBS6 # FtltTYUvcyl2mi91jGogj57IbzAfBgNVHSMEGDAWgBTs1+OC0nFdZEzfLmc/57qY # rhwPTzAOBgNVHQ8BAf8EBAMCAYYwEwYDVR0lBAwwCgYIKwYBBQUHAwgwdwYIKwYB # BQUHAQEEazBpMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20w # QQYIKwYBBQUHMAKGNWh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2Vy # dFRydXN0ZWRSb290RzQuY3J0MEMGA1UdHwQ8MDowOKA2oDSGMmh0dHA6Ly9jcmwz # LmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRSb290RzQuY3JsMCAGA1UdIAQZ # MBcwCAYGZ4EMAQQCMAsGCWCGSAGG/WwHATANBgkqhkiG9w0BAQsFAAOCAgEAfVmO # wJO2b5ipRCIBfmbW2CFC4bAYLhBNE88wU86/GPvHUF3iSyn7cIoNqilp/GnBzx0H # 6T5gyNgL5Vxb122H+oQgJTQxZ822EpZvxFBMYh0MCIKoFr2pVs8Vc40BIiXOlWk/ # R3f7cnQU1/+rT4osequFzUNf7WC2qk+RZp4snuCKrOX9jLxkJodskr2dfNBwCnzv # qLx1T7pa96kQsl3p/yhUifDVinF2ZdrM8HKjI/rAJ4JErpknG6skHibBt94q6/ae # sXmZgaNWhqsKRcnfxI2g55j7+6adcq/Ex8HBanHZxhOACcS2n82HhyS7T6NJuXdm # kfFynOlLAlKnN36TU6w7HQhJD5TNOXrd/yVjmScsPT9rp/Fmw0HNT7ZAmyEhQNC3 # EyTN3B14OuSereU0cZLXJmvkOHOrpgFPvT87eK1MrfvElXvtCl8zOYdBeHo46Zzh # 3SP9HSjTx/no8Zhf+yvYfvJGnXUsHicsJttvFXseGYs2uJPU5vIXmVnKcPA3v5gA # 3yAWTyf7YGcWoWa63VXAOimGsJigK+2VQbc61RWYMbRiCQ8KvYHZE/6/pNHzV9m8 # BPqC3jLfBInwAM1dwvnQI38AC+R2AibZ8GV2QqYphwlHK+Z/GqSFD/yYlvZVVCsf # gPrA8g4r5db7qS9EFUrnEw4d2zc4GqEr9u3WfPwwggbAMIIEqKADAgECAhAMTWly # S5T6PCpKPSkHgD1aMA0GCSqGSIb3DQEBCwUAMGMxCzAJBgNVBAYTAlVTMRcwFQYD # VQQKEw5EaWdpQ2VydCwgSW5jLjE7MDkGA1UEAxMyRGlnaUNlcnQgVHJ1c3RlZCBH # NCBSU0E0MDk2IFNIQTI1NiBUaW1lU3RhbXBpbmcgQ0EwHhcNMjIwOTIxMDAwMDAw # WhcNMzMxMTIxMjM1OTU5WjBGMQswCQYDVQQGEwJVUzERMA8GA1UEChMIRGlnaUNl # cnQxJDAiBgNVBAMTG0RpZ2lDZXJ0IFRpbWVzdGFtcCAyMDIyIC0gMjCCAiIwDQYJ # KoZIhvcNAQEBBQADggIPADCCAgoCggIBAM/spSY6xqnya7uNwQ2a26HoFIV0Mxom # rNAcVR4eNm28klUMYfSdCXc9FZYIL2tkpP0GgxbXkZI4HDEClvtysZc6Va8z7GGK # 6aYo25BjXL2JU+A6LYyHQq4mpOS7eHi5ehbhVsbAumRTuyoW51BIu4hpDIjG8b7g # L307scpTjUCDHufLckkoHkyAHoVW54Xt8mG8qjoHffarbuVm3eJc9S/tjdRNlYRo # 44DLannR0hCRRinrPibytIzNTLlmyLuqUDgN5YyUXRlav/V7QG5vFqianJVHhoV5 # PgxeZowaCiS+nKrSnLb3T254xCg/oxwPUAY3ugjZNaa1Htp4WB056PhMkRCWfk3h # 3cKtpX74LRsf7CtGGKMZ9jn39cFPcS6JAxGiS7uYv/pP5Hs27wZE5FX/NurlfDHn # 88JSxOYWe1p+pSVz28BqmSEtY+VZ9U0vkB8nt9KrFOU4ZodRCGv7U0M50GT6Vs/g # 9ArmFG1keLuY/ZTDcyHzL8IuINeBrNPxB9ThvdldS24xlCmL5kGkZZTAWOXlLimQ # prdhZPrZIGwYUWC6poEPCSVT8b876asHDmoHOWIZydaFfxPZjXnPYsXs4Xu5zGcT # B5rBeO3GiMiwbjJ5xwtZg43G7vUsfHuOy2SJ8bHEuOdTXl9V0n0ZKVkDTvpd6kVz # HIR+187i1Dp3AgMBAAGjggGLMIIBhzAOBgNVHQ8BAf8EBAMCB4AwDAYDVR0TAQH/ # BAIwADAWBgNVHSUBAf8EDDAKBggrBgEFBQcDCDAgBgNVHSAEGTAXMAgGBmeBDAEE # AjALBglghkgBhv1sBwEwHwYDVR0jBBgwFoAUuhbZbU2FL3MpdpovdYxqII+eyG8w # HQYDVR0OBBYEFGKK3tBh/I8xFO2XC809KpQU31KcMFoGA1UdHwRTMFEwT6BNoEuG # SWh0dHA6Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNFJTQTQw # OTZTSEEyNTZUaW1lU3RhbXBpbmdDQS5jcmwwgZAGCCsGAQUFBwEBBIGDMIGAMCQG # CCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20wWAYIKwYBBQUHMAKG # TGh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNFJT # QTQwOTZTSEEyNTZUaW1lU3RhbXBpbmdDQS5jcnQwDQYJKoZIhvcNAQELBQADggIB # AFWqKhrzRvN4Vzcw/HXjT9aFI/H8+ZU5myXm93KKmMN31GT8Ffs2wklRLHiIY1UJ # RjkA/GnUypsp+6M/wMkAmxMdsJiJ3HjyzXyFzVOdr2LiYWajFCpFh0qYQitQ/Bu1 # nggwCfrkLdcJiXn5CeaIzn0buGqim8FTYAnoo7id160fHLjsmEHw9g6A++T/350Q # p+sAul9Kjxo6UrTqvwlJFTU2WZoPVNKyG39+XgmtdlSKdG3K0gVnK3br/5iyJpU4 # GYhEFOUKWaJr5yI+RCHSPxzAm+18SLLYkgyRTzxmlK9dAlPrnuKe5NMfhgFknADC # 6Vp0dQ094XmIvxwBl8kZI4DXNlpflhaxYwzGRkA7zl011Fk+Q5oYrsPJy8P7mxNf # arXH4PMFw1nfJ2Ir3kHJU7n/NBBn9iYymHv+XEKUgZSCnawKi8ZLFUrTmJBFYDOA # 4CPe+AOk9kVH5c64A0JH6EE2cXet/aLol3ROLtoeHYxayB6a1cLwxiKoT5u92Bya # UcQvmvZfpyeXupYuhVfAYOd4Vn9q78KVmksRAsiCnMkaBXy6cbVOepls9Oie1FqY # yJ+/jbsYXEP10Cro4mLueATbvdH7WwqocH7wl4R44wgDXUcsY6glOJcB0j862uXl # 9uab3H4szP8XTE0AotjWAQ64i+7m4HJViSwnGWH2dwGMMYIFXTCCBVkCAQEwgYYw # cjELMAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQ # d3d3LmRpZ2ljZXJ0LmNvbTExMC8GA1UEAxMoRGlnaUNlcnQgU0hBMiBBc3N1cmVk # IElEIENvZGUgU2lnbmluZyBDQQIQBNXcH0jqydhSALrNmpsqpzANBglghkgBZQME # AgEFAKCBhDAYBgorBgEEAYI3AgEMMQowCKACgAChAoAAMBkGCSqGSIb3DQEJAzEM # BgorBgEEAYI3AgEEMBwGCisGAQQBgjcCAQsxDjAMBgorBgEEAYI3AgEVMC8GCSqG # SIb3DQEJBDEiBCDAL84CbkEp4GRWX9+0768xeIK16SGn5NSmEOYohO5MujANBgkq # hkiG9w0BAQEFAASCAQBJpTVxiczjLvJhHzkOaRiBuIZiGdCLC9BA6AwX4+33s5MY # 8fdlsx1JJxeeJIiGSE/V77vS3f5NXDqTSm1KRNauQjQv/uzDZOp70P6B6UbTuj+D # 9mQ3U0x4+n/3ccHnDsohZ4u7UZt+Jj0z1nFn+vPcMAa5ftgnjjBBXcoPiIhZqv1r # 6+Zh54o5zb0rs+hVWnso8Evvn7303Aj1FhxaPI9E7jvoNyTeHoKUfdSiAclZrljs # 95KwPV5rUKnk30vDlCX1btOLO6z9pRfwc3QnbQxVzDTGbrkVRgpw6dGaS5qZGUWD # fS8bN1JCQhGoZ7MTCqn1BWECDB7KjfeFONDvJCQdoYIDIDCCAxwGCSqGSIb3DQEJ # BjGCAw0wggMJAgEBMHcwYzELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0 # LCBJbmMuMTswOQYDVQQDEzJEaWdpQ2VydCBUcnVzdGVkIEc0IFJTQTQwOTYgU0hB # MjU2IFRpbWVTdGFtcGluZyBDQQIQDE1pckuU+jwqSj0pB4A9WjANBglghkgBZQME # AgEFAKBpMBgGCSqGSIb3DQEJAzELBgkqhkiG9w0BBwEwHAYJKoZIhvcNAQkFMQ8X # DTIzMDMwNTE5NTE0OFowLwYJKoZIhvcNAQkEMSIEIDl+yD7NOJILTWFrXeaZPYUX # cLh9MzgJU83purxWD7IBMA0GCSqGSIb3DQEBAQUABIICAESsgdNwEa1Ci9wWE8zU # xaaJpcooA9Q4HrCwTe0B/XJmaMTkjD2jJLzkN5S1Ba6VRpANA9QiF1V1XnPkld+l # Jfmr6ZuO7Usu8+x6Ww+E1TOuPAgeevQlLkvZ9zm3VoXdEeiehCB3lXle3Z9wzpaf # Hw+69YrCtluTB9tx0kywkqvH9D4f7BeJdrHkVVyXjNZmaMlTb8xhI+KtAGeB23Qw # VC+sAzcdqgCJITZ/dLA1xNI0B62bA1KfmpEjdssumGZ0DcU+gyMSUL0dGAFtXAmF # j6HV4JMLTNyMqTNqs8+Fa0/74dedjbxO65ybIu+EVHTqtUvG22p8Bca6kK6XcfSR # 7htpv5HmwX/wkdss/GDhkCH33Ahz0Tcl9rOiJTis7DJDBmteD+CtAgXk8uWsPxdn # 4Cj3C0xGtX9avYAn0XN1G8bi3SxJTAZZLQj5rivI6QjPa6M5HClEqqvmGvvY1GDt # dp4zvZDRZE/EI4DwjQxL2uZYQ3QCWqkXZ7CwjuRTVcedEQkCpO52B3BBDBaXjRdr # YGZd8IrcvKQ57meGsM8rURR8zPBEcKaY2V7GZUw77fahEW+U969QzL1hfqMbwddI # 9fCGRYPNlyi0lU9bwOBVJ2/eOBeQk7OILTMg1KFXKT1S39ptcSwoQQ2wbZ2rgYRN # Ck0RJxeObt5IVzkZiuVJ/3o/ # SIG # End signature block |