CleanupMonster.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 (22621)' = 'Windows 11 22H2' '10.0 (22000)' = 'Windows 11 21H2' '10.0 (19045)' = 'Windows 10 22H2' '10.0 (19044)' = 'Windows 10 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.22621' = 'Windows 11 22H2' '10.0.22000' = 'Windows 11 21H2' '10.0.19045' = 'Windows 10 22H2' '10.0.19044' = 'Windows 10 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" '22621' = 'Windows 11 22H2' '22000' = 'Windows 11 21H2' '19045' = 'Windows 10 22H2' '19044' = 'Windows 10 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 = $OperatingSystemVersion } } 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 = $OperatingSystemVersion } } 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)" } } else { "Current: Unknown" } } function Get-WinADForestDetails { <# .SYNOPSIS Get details about Active Directory Forest, Domains and Domain Controllers in a single query .DESCRIPTION Get details about Active Directory Forest, Domains and Domain Controllers in a single query .PARAMETER Forest Target different Forest, by default current forest is used .PARAMETER ExcludeDomains Exclude domain from search, by default whole forest is scanned .PARAMETER IncludeDomains Include only specific domains, by default whole forest is scanned .PARAMETER ExcludeDomainControllers Exclude specific domain controllers, by default there are no exclusions, as long as VerifyDomainControllers switch is enabled. Otherwise this parameter is ignored. .PARAMETER IncludeDomainControllers Include only specific domain controllers, by default all domain controllers are included, as long as VerifyDomainControllers switch is enabled. Otherwise this parameter is ignored. .PARAMETER SkipRODC Skip Read-Only Domain Controllers. By default all domain controllers are included. .PARAMETER ExtendedForestInformation Ability to provide Forest Information from another command to speed up processing .PARAMETER Filter Filter for Get-ADDomainController .PARAMETER TestAvailability Check if Domain Controllers are available .PARAMETER Test Pick what to check for availability. Options are: All, Ping, WinRM, PortOpen, Ping+WinRM, Ping+PortOpen, WinRM+PortOpen. Default is All .PARAMETER Ports Ports to check for availability. Default is 135 .PARAMETER PortsTimeout Ports timeout for availability check. Default is 100 .PARAMETER PingCount How many pings to send. Default is 1 .PARAMETER PreferWritable Prefer writable domain controllers over read-only ones when returning Query Servers .PARAMETER Extended Return extended information about domains with NETBIOS names .EXAMPLE Get-WinADForestDetails | Format-Table .EXAMPLE Get-WinADForestDetails -Forest 'ad.evotec.xyz' | Format-Table .NOTES General notes #> [CmdletBinding()] param( [alias('ForestName')][string] $Forest, [string[]] $ExcludeDomains, [string[]] $ExcludeDomainControllers, [alias('Domain', 'Domains')][string[]] $IncludeDomains, [alias('DomainControllers', 'ComputerName')][string[]] $IncludeDomainControllers, [switch] $SkipRODC, [string] $Filter = '*', [switch] $TestAvailability, [ValidateSet('All', 'Ping', 'WinRM', 'PortOpen', 'Ping+WinRM', 'Ping+PortOpen', 'WinRM+PortOpen')] $Test = 'All', [int[]] $Ports = 135, [int] $PortsTimeout = 100, [int] $PingCount = 1, [switch] $PreferWritable, [switch] $Extended, [System.Collections.IDictionary] $ExtendedForestInformation ) if ($Global:ProgressPreference -ne 'SilentlyContinue') { $TemporaryProgress = $Global:ProgressPreference $Global:ProgressPreference = 'SilentlyContinue' } if (-not $ExtendedForestInformation) { $Findings = [ordered] @{ } try { if ($Forest) { $ForestInformation = Get-ADForest -ErrorAction Stop -Identity $Forest } else { $ForestInformation = Get-ADForest -ErrorAction Stop } } catch { Write-Warning "Get-WinADForestDetails - Error discovering DC for Forest - $($_.Exception.Message)" return } if (-not $ForestInformation) { return } $Findings['Forest'] = $ForestInformation $Findings['ForestDomainControllers'] = @() $Findings['QueryServers'] = @{ } $Findings['DomainDomainControllers'] = @{ } [Array] $Findings['Domains'] = foreach ($Domain in $ForestInformation.Domains) { if ($IncludeDomains) { if ($Domain -in $IncludeDomains) { $Domain.ToLower() } continue } if ($Domain -notin $ExcludeDomains) { $Domain.ToLower() } } [Array] $DomainsActive = foreach ($Domain in $Findings['Forest'].Domains) { try { $DC = Get-ADDomainController -DomainName $Domain -Discover -ErrorAction Stop -Writable:$PreferWritable.IsPresent $OrderedDC = [ordered] @{ Domain = $DC.Domain Forest = $DC.Forest HostName = [Array] $DC.HostName IPv4Address = $DC.IPv4Address IPv6Address = $DC.IPv6Address Name = $DC.Name Site = $DC.Site } } catch { Write-Warning "Get-WinADForestDetails - Error discovering DC for domain $Domain - $($_.Exception.Message)" continue } if ($Domain -eq $Findings['Forest']['Name']) { $Findings['QueryServers']['Forest'] = $OrderedDC } $Findings['QueryServers']["$Domain"] = $OrderedDC $Domain } [Array] $Findings['Domains'] = foreach ($Domain in $Findings['Domains']) { if ($Domain -notin $DomainsActive) { Write-Warning "Get-WinADForestDetails - Domain $Domain doesn't seem to be active (no DCs). Skipping." continue } $Domain } [Array] $Findings['ForestDomainControllers'] = foreach ($Domain in $Findings.Domains) { $QueryServer = $Findings['QueryServers'][$Domain]['HostName'][0] [Array] $AllDC = try { try { $DomainControllers = Get-ADDomainController -Filter $Filter -Server $QueryServer -ErrorAction Stop } catch { Write-Warning "Get-WinADForestDetails - Error listing DCs for domain $Domain - $($_.Exception.Message)" continue } foreach ($S in $DomainControllers) { if ($IncludeDomainControllers.Count -gt 0) { If (-not $IncludeDomainControllers[0].Contains('.')) { if ($S.Name -notin $IncludeDomainControllers) { continue } } else { if ($S.HostName -notin $IncludeDomainControllers) { continue } } } if ($ExcludeDomainControllers.Count -gt 0) { If (-not $ExcludeDomainControllers[0].Contains('.')) { if ($S.Name -in $ExcludeDomainControllers) { continue } } else { if ($S.HostName -in $ExcludeDomainControllers) { continue } } } $DSAGuid = (Get-ADObject -Identity $S.NTDSSettingsObjectDN -Server $QueryServer).ObjectGUID $Server = [ordered] @{ Domain = $Domain HostName = $S.HostName Name = $S.Name Forest = $ForestInformation.RootDomain Site = $S.Site IPV4Address = $S.IPV4Address IPV6Address = $S.IPV6Address IsGlobalCatalog = $S.IsGlobalCatalog IsReadOnly = $S.IsReadOnly IsSchemaMaster = ($S.OperationMasterRoles -contains 'SchemaMaster') IsDomainNamingMaster = ($S.OperationMasterRoles -contains 'DomainNamingMaster') IsPDC = ($S.OperationMasterRoles -contains 'PDCEmulator') IsRIDMaster = ($S.OperationMasterRoles -contains 'RIDMaster') IsInfrastructureMaster = ($S.OperationMasterRoles -contains 'InfrastructureMaster') OperatingSystem = $S.OperatingSystem OperatingSystemVersion = $S.OperatingSystemVersion OperatingSystemLong = ConvertTo-OperatingSystem -OperatingSystem $S.OperatingSystem -OperatingSystemVersion $S.OperatingSystemVersion LdapPort = $S.LdapPort SslPort = $S.SslPort DistinguishedName = $S.ComputerObjectDN NTDSSettingsObjectDN = $S.NTDSSettingsObjectDN DsaGuid = $DSAGuid DsaGuidName = "$DSAGuid._msdcs.$($ForestInformation.RootDomain)" Pingable = $null WinRM = $null PortOpen = $null Comment = '' } if ($TestAvailability) { if ($Test -eq 'All' -or $Test -like 'Ping*') { $Server.Pingable = Test-Connection -ComputerName $Server.IPV4Address -Quiet -Count $PingCount } if ($Test -eq 'All' -or $Test -like '*WinRM*') { $Server.WinRM = (Test-WinRM -ComputerName $Server.HostName).Status } if ($Test -eq 'All' -or '*PortOpen*') { $Server.PortOpen = (Test-ComputerPort -Server $Server.HostName -PortTCP $Ports -Timeout $PortsTimeout).Status } } [PSCustomObject] $Server } } catch { [PSCustomObject]@{ Domain = $Domain HostName = '' Name = '' Forest = $ForestInformation.RootDomain IPV4Address = '' IPV6Address = '' IsGlobalCatalog = '' IsReadOnly = '' Site = '' SchemaMaster = $false DomainNamingMasterMaster = $false PDCEmulator = $false RIDMaster = $false InfrastructureMaster = $false LdapPort = '' SslPort = '' DistinguishedName = '' NTDSSettingsObjectDN = '' DsaGuid = '' DsaGuidName = '' Pingable = $null WinRM = $null PortOpen = $null Comment = $_.Exception.Message -replace "`n", " " -replace "`r", " " } } if ($SkipRODC) { [Array] $Findings['DomainDomainControllers'][$Domain] = $AllDC | Where-Object { $_.IsReadOnly -eq $false } } else { [Array] $Findings['DomainDomainControllers'][$Domain] = $AllDC } if ($null -ne $Findings['DomainDomainControllers'][$Domain]) { [Array] $Findings['DomainDomainControllers'][$Domain] } } if ($Extended) { $Findings['DomainsExtended'] = @{ } $Findings['DomainsExtendedNetBIOS'] = @{ } foreach ($DomainEx in $Findings['Domains']) { try { $Findings['DomainsExtended'][$DomainEx] = Get-ADDomain -Server $Findings['QueryServers'][$DomainEx].HostName[0] | ForEach-Object { [ordered] @{ AllowedDNSSuffixes = $_.AllowedDNSSuffixes | ForEach-Object -Process { $_ } ChildDomains = $_.ChildDomains | ForEach-Object -Process { $_ } ComputersContainer = $_.ComputersContainer DeletedObjectsContainer = $_.DeletedObjectsContainer DistinguishedName = $_.DistinguishedName DNSRoot = $_.DNSRoot DomainControllersContainer = $_.DomainControllersContainer DomainMode = $_.DomainMode DomainSID = $_.DomainSID.Value ForeignSecurityPrincipalsContainer = $_.ForeignSecurityPrincipalsContainer Forest = $_.Forest InfrastructureMaster = $_.InfrastructureMaster LastLogonReplicationInterval = $_.LastLogonReplicationInterval LinkedGroupPolicyObjects = $_.LinkedGroupPolicyObjects | ForEach-Object -Process { $_ } LostAndFoundContainer = $_.LostAndFoundContainer ManagedBy = $_.ManagedBy Name = $_.Name NetBIOSName = $_.NetBIOSName ObjectClass = $_.ObjectClass ObjectGUID = $_.ObjectGUID ParentDomain = $_.ParentDomain PDCEmulator = $_.PDCEmulator PublicKeyRequiredPasswordRolling = $_.PublicKeyRequiredPasswordRolling | ForEach-Object -Process { $_ } QuotasContainer = $_.QuotasContainer ReadOnlyReplicaDirectoryServers = $_.ReadOnlyReplicaDirectoryServers | ForEach-Object -Process { $_ } ReplicaDirectoryServers = $_.ReplicaDirectoryServers | ForEach-Object -Process { $_ } RIDMaster = $_.RIDMaster SubordinateReferences = $_.SubordinateReferences | ForEach-Object -Process { $_ } SystemsContainer = $_.SystemsContainer UsersContainer = $_.UsersContainer } } $NetBios = $Findings['DomainsExtended'][$DomainEx]['NetBIOSName'] $Findings['DomainsExtendedNetBIOS'][$NetBios] = $Findings['DomainsExtended'][$DomainEx] } catch { Write-Warning "Get-WinADForestDetails - Error gathering Domain Information for domain $DomainEx - $($_.Exception.Message)" continue } } } if ($TemporaryProgress) { $Global:ProgressPreference = $TemporaryProgress } $Findings } else { $Findings = Copy-DictionaryManual -Dictionary $ExtendedForestInformation [Array] $Findings['Domains'] = foreach ($_ in $Findings.Domains) { if ($IncludeDomains) { if ($_ -in $IncludeDomains) { $_.ToLower() } continue } if ($_ -notin $ExcludeDomains) { $_.ToLower() } } foreach ($_ in [string[]] $Findings.DomainDomainControllers.Keys) { if ($_ -notin $Findings.Domains) { $Findings.DomainDomainControllers.Remove($_) } } foreach ($_ in [string[]] $Findings.DomainsExtended.Keys) { if ($_ -notin $Findings.Domains) { $Findings.DomainsExtended.Remove($_) $NetBiosName = $Findings.DomainsExtended.$_.'NetBIOSName' if ($NetBiosName) { $Findings.DomainsExtendedNetBIOS.Remove($NetBiosName) } } } [Array] $Findings['ForestDomainControllers'] = foreach ($Domain in $Findings.Domains) { [Array] $AllDC = foreach ($S in $Findings.DomainDomainControllers["$Domain"]) { if ($IncludeDomainControllers.Count -gt 0) { If (-not $IncludeDomainControllers[0].Contains('.')) { if ($S.Name -notin $IncludeDomainControllers) { continue } } else { if ($S.HostName -notin $IncludeDomainControllers) { continue } } } if ($ExcludeDomainControllers.Count -gt 0) { If (-not $ExcludeDomainControllers[0].Contains('.')) { if ($S.Name -in $ExcludeDomainControllers) { continue } } else { if ($S.HostName -in $ExcludeDomainControllers) { continue } } } $S } if ($SkipRODC) { [Array] $Findings['DomainDomainControllers'][$Domain] = $AllDC | Where-Object { $_.IsReadOnly -eq $false } } else { [Array] $Findings['DomainDomainControllers'][$Domain] = $AllDC } [Array] $Findings['DomainDomainControllers'][$Domain] } $Findings } } function Remove-EmptyValue { <# .SYNOPSIS Removes empty values from a hashtable recursively. .DESCRIPTION This function removes empty values from a given hashtable. It can be used to clean up a hashtable by removing keys with null, empty string, empty array, or empty dictionary values. The function supports recursive removal of empty values. .PARAMETER Hashtable The hashtable from which empty values will be removed. .PARAMETER ExcludeParameter An array of keys to exclude from the removal process. .PARAMETER Recursive Indicates whether to recursively remove empty values from nested hashtables. .PARAMETER Rerun Specifies the number of times to rerun the removal process recursively. .PARAMETER DoNotRemoveNull If specified, null values will not be removed. .PARAMETER DoNotRemoveEmpty If specified, empty string values will not be removed. .PARAMETER DoNotRemoveEmptyArray If specified, empty array values will not be removed. .PARAMETER DoNotRemoveEmptyDictionary If specified, empty dictionary values will not be removed. .EXAMPLE $hashtable = @{ 'Key1' = ''; 'Key2' = $null; 'Key3' = @(); 'Key4' = @{} } Remove-EmptyValue -Hashtable $hashtable -Recursive Description ----------- This example removes empty values from the $hashtable recursively. #> [alias('Remove-EmptyValues')] [CmdletBinding()] param( [alias('Splat', 'IDictionary')][Parameter(Mandatory)][System.Collections.IDictionary] $Hashtable, [string[]] $ExcludeParameter, [switch] $Recursive, [int] $Rerun, [switch] $DoNotRemoveNull, [switch] $DoNotRemoveEmpty, [switch] $DoNotRemoveEmptyArray, [switch] $DoNotRemoveEmptyDictionary ) foreach ($Key in [string[]] $Hashtable.Keys) { if ($Key -notin $ExcludeParameter) { if ($Recursive) { if ($Hashtable[$Key] -is [System.Collections.IDictionary]) { if ($Hashtable[$Key].Count -eq 0) { if (-not $DoNotRemoveEmptyDictionary) { $Hashtable.Remove($Key) } } else { Remove-EmptyValue -Hashtable $Hashtable[$Key] -Recursive:$Recursive } } else { if (-not $DoNotRemoveNull -and $null -eq $Hashtable[$Key]) { $Hashtable.Remove($Key) } elseif (-not $DoNotRemoveEmpty -and $Hashtable[$Key] -is [string] -and $Hashtable[$Key] -eq '') { $Hashtable.Remove($Key) } elseif (-not $DoNotRemoveEmptyArray -and $Hashtable[$Key] -is [System.Collections.IList] -and $Hashtable[$Key].Count -eq 0) { $Hashtable.Remove($Key) } } } else { if (-not $DoNotRemoveNull -and $null -eq $Hashtable[$Key]) { $Hashtable.Remove($Key) } elseif (-not $DoNotRemoveEmpty -and $Hashtable[$Key] -is [string] -and $Hashtable[$Key] -eq '') { $Hashtable.Remove($Key) } elseif (-not $DoNotRemoveEmptyArray -and $Hashtable[$Key] -is [System.Collections.IList] -and $Hashtable[$Key].Count -eq 0) { $Hashtable.Remove($Key) } } } } if ($Rerun) { for ($i = 0; $i -lt $Rerun; $i++) { Remove-EmptyValue -Hashtable $Hashtable -Recursive:$Recursive } } } function Write-Color { <# .SYNOPSIS Write-Color is a wrapper around Write-Host delivering a lot of additional features for easier color options. .DESCRIPTION Write-Color is a wrapper around Write-Host delivering a lot of additional features for easier color options. It provides: - Easy manipulation of colors, - Logging output to file (log) - Nice formatting options out of the box. - Ability to use aliases for parameters .PARAMETER Text Text to display on screen and write to log file if specified. Accepts an array of strings. .PARAMETER Color Color of the text. Accepts an array of colors. If more than one color is specified it will loop through colors for each string. If there are more strings than colors it will start from the beginning. Available colors are: Black, DarkBlue, DarkGreen, DarkCyan, DarkRed, DarkMagenta, DarkYellow, Gray, DarkGray, Blue, Green, Cyan, Red, Magenta, Yellow, White .PARAMETER BackGroundColor Color of the background. Accepts an array of colors. If more than one color is specified it will loop through colors for each string. If there are more strings than colors it will start from the beginning. Available colors are: Black, DarkBlue, DarkGreen, DarkCyan, DarkRed, DarkMagenta, DarkYellow, Gray, DarkGray, Blue, Green, Cyan, Red, Magenta, Yellow, White .PARAMETER StartTab Number of tabs to add before text. Default is 0. .PARAMETER LinesBefore Number of empty lines before text. Default is 0. .PARAMETER LinesAfter Number of empty lines after text. Default is 0. .PARAMETER StartSpaces Number of spaces to add before text. Default is 0. .PARAMETER LogFile Path to log file. If not specified no log file will be created. .PARAMETER DateTimeFormat Custom date and time format string. Default is yyyy-MM-dd HH:mm:ss .PARAMETER LogTime If set to $true it will add time to log file. Default is $true. .PARAMETER LogRetry Number of retries to write to log file, in case it can't write to it for some reason, before skipping. Default is 2. .PARAMETER Encoding Encoding of the log file. Default is Unicode. .PARAMETER ShowTime Switch to add time to console output. Default is not set. .PARAMETER NoNewLine Switch to not add new line at the end of the output. Default is not set. .PARAMETER NoConsoleOutput Switch to not output to console. Default all output goes to console. .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 Write-Color -T "My text", " is ", "all colorful" -C Yellow, Red, Green -B Green, Green, Yellow Write-Color -t "my text" -c yellow -b green Write-Color -text "my text" -c red .EXAMPLE Write-Color -Text "Testuję czy się ładnie zapisze, czy będą problemy" -Encoding unicode -LogFile 'C:\temp\testinggg.txt' -Color Red -NoConsoleOutput .NOTES Understanding Custom date and time format strings: https://learn.microsoft.com/en-us/dotnet/standard/base-types/custom-date-and-time-format-strings Project support: https://github.com/EvotecIT/PSWriteColor Original idea: Josh (https://stackoverflow.com/users/81769/josh) #> [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, [alias('HideConsole')][switch] $NoConsoleOutput ) if (-not $NoConsoleOutput) { $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 } } # Add empty line before if ($StartTab -ne 0) { for ($i = 0; $i -lt $StartTab; $i++) { Write-Host -Object "`t" -NoNewline } } # Add TABS before text if ($StartSpaces -ne 0) { for ($i = 0; $i -lt $StartSpaces; $i++) { Write-Host -Object ' ' -NoNewline } } # Add SPACES before text if ($ShowTime) { Write-Host -Object "[$([datetime]::Now.ToString($DateTimeFormat))] " -NoNewline } # Add Time before output if ($Text.Count -ne 0) { if ($Color.Count -ge $Text.Count) { # the real deal coloring 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 } # Support for no new line if ($LinesAfter -ne 0) { for ($i = 0; $i -lt $LinesAfter; $i++) { Write-Host -Object "`n" -NoNewline } } # Add empty line after } if ($Text.Count -and $LogFile) { # Save to file $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) { Write-Warning "Write-Color - Couldn't write to log file $($_.Exception.Message). Tried ($Retry/$LogRetry))" } 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 Copy-DictionaryManual { [CmdletBinding()] param( [System.Collections.IDictionary] $Dictionary ) $clone = @{} foreach ($Key in $Dictionary.Keys) { $value = $Dictionary.$Key $clonedValue = switch ($Dictionary.$Key) { { $null -eq $_ } { $null continue } { $_ -is [System.Collections.IDictionary] } { Copy-DictionaryManual -Dictionary $_ continue } { $type = $_.GetType() $type.IsPrimitive -or $type.IsValueType -or $_ -is [string] } { $_ continue } default { $_ | Select-Object -Property * } } if ($value -is [System.Collections.IList]) { $clone[$Key] = @($clonedValue) } else { $clone[$Key] = $clonedValue } } $clone } 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.com/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 Test-ComputerPort { <# .SYNOPSIS Tests the connectivity of a computer on specified TCP and UDP ports. .DESCRIPTION The Test-ComputerPort function tests the connectivity of a computer on specified TCP and UDP ports. It checks if the specified ports are open and reachable on the target computer. .PARAMETER ComputerName Specifies the name of the computer to test the port connectivity. .PARAMETER PortTCP Specifies an array of TCP ports to test connectivity. .PARAMETER PortUDP Specifies an array of UDP ports to test connectivity. .PARAMETER Timeout Specifies the timeout value in milliseconds for the connection test. Default is 5000 milliseconds. .EXAMPLE Test-ComputerPort -ComputerName "Server01" -PortTCP 80,443 -PortUDP 53 -Timeout 3000 Tests the connectivity of Server01 on TCP ports 80 and 443, UDP port 53 with a timeout of 3000 milliseconds. .EXAMPLE Test-ComputerPort -ComputerName "Server02" -PortTCP 3389 -PortUDP 123 Tests the connectivity of Server02 on TCP port 3389, UDP port 123 with the default timeout of 5000 milliseconds. #> [CmdletBinding()] param ( [alias('Server')][string[]] $ComputerName, [int[]] $PortTCP, [int[]] $PortUDP, [int]$Timeout = 5000 ) begin { if ($Global:ProgressPreference -ne 'SilentlyContinue') { $TemporaryProgress = $Global:ProgressPreference $Global:ProgressPreference = 'SilentlyContinue' } } process { foreach ($Computer in $ComputerName) { foreach ($P in $PortTCP) { $Output = [ordered] @{ 'ComputerName' = $Computer 'Port' = $P 'Protocol' = 'TCP' 'Status' = $null 'Summary' = $null 'Response' = $null } $TcpClient = Test-NetConnection -ComputerName $Computer -Port $P -InformationLevel Detailed -WarningAction SilentlyContinue if ($TcpClient.TcpTestSucceeded) { $Output['Status'] = $TcpClient.TcpTestSucceeded $Output['Summary'] = "TCP $P Successful" } else { $Output['Status'] = $false $Output['Summary'] = "TCP $P Failed" $Output['Response'] = $Warnings } [PSCustomObject]$Output } foreach ($P in $PortUDP) { $Output = [ordered] @{ 'ComputerName' = $Computer 'Port' = $P 'Protocol' = 'UDP' 'Status' = $null 'Summary' = $null } $UdpClient = [System.Net.Sockets.UdpClient]::new($Computer, $P) $UdpClient.Client.ReceiveTimeout = $Timeout $Encoding = [System.Text.ASCIIEncoding]::new() $byte = $Encoding.GetBytes("Evotec") [void]$UdpClient.Send($byte, $byte.length) $RemoteEndpoint = [System.Net.IPEndPoint]::new([System.Net.IPAddress]::Any, 0) try { $Bytes = $UdpClient.Receive([ref]$RemoteEndpoint) [string]$Data = $Encoding.GetString($Bytes) If ($Data) { $Output['Status'] = $true $Output['Summary'] = "UDP $P Successful" $Output['Response'] = $Data } } catch { $Output['Status'] = $false $Output['Summary'] = "UDP $P Failed" $Output['Response'] = $_.Exception.Message } $UdpClient.Close() $UdpClient.Dispose() [PSCustomObject]$Output } } } end { if ($TemporaryProgress) { $Global:ProgressPreference = $TemporaryProgress } } } function Test-WinRM { <# .SYNOPSIS Tests the WinRM connectivity on the specified computers. .DESCRIPTION The Test-WinRM function tests the WinRM connectivity on the specified computers and returns the status of the connection. .PARAMETER ComputerName Specifies the names of the computers to test WinRM connectivity on. .EXAMPLE Test-WinRM -ComputerName "Server01", "Server02" Tests the WinRM connectivity on Server01 and Server02. .EXAMPLE Test-WinRM -ComputerName "Server03" Tests the WinRM connectivity on Server03. #> [CmdletBinding()] param ( [alias('Server')][string[]] $ComputerName ) $Output = foreach ($Computer in $ComputerName) { $Test = [PSCustomObject] @{ Output = $null Status = $null ComputerName = $Computer } try { $Test.Output = Test-WSMan -ComputerName $Computer -ErrorAction Stop $Test.Status = $true } catch { $Test.Status = $false } $Test } $Output } function Assert-InitialSettings { [cmdletbinding()] param( [System.Collections.IDictionary] $DisableOnlyIf, [System.Collections.IDictionary] $MoveOnlyIf, [System.Collections.IDictionary] $DeleteOnlyIf ) $AzureRequired = $false $IntuneRequired = $false $JamfRequired = $false if ($DisableOnlyIf) { if ($null -ne $DisableOnlyIf.LastSyncAzureMoreThan -or $null -ne $DisableOnlyIf.LastSeenAzureMoreThan) { $AzureRequired = $true } if ($null -ne $DisableOnlyIf.LastContactJamfMoreThan) { $JamfRequired = $true } if ($null -ne $DisableOnlyIf.LastSeenIntuneMoreThan) { $IntuneRequired = $true } } if ($MoveOnlyIf) { if ($null -ne $MoveOnlyIf.LastSyncAzureMoreThan -or $null -ne $MoveOnlyIf.LastSeenAzureMoreThan) { $AzureRequired = $true } if ($null -ne $MoveOnlyIf.LastContactJamfMoreThan) { $JamfRequired = $true } if ($null -ne $MoveOnlyIf.LastSeenIntuneMoreThan) { $IntuneRequired = $true } } if ($DeleteOnlyIf) { if ($null -ne $DeleteOnlyIf.LastSyncAzureMoreThan -or $null -ne $DeleteOnlyIf.LastSeenAzureMoreThan) { $AzureRequired = $true } if ($null -ne $DeleteOnlyIf.LastContactJamfMoreThan) { $JamfRequired = $true } if ($null -ne $DeleteOnlyIf.LastSeenIntuneMoreThan) { $IntuneRequired = $true } } if ($AzureRequired -or $IntuneRequired) { $ModuleAvailable = Get-Module -Name GraphEssentials -ListAvailable if (-not $ModuleAvailable) { Write-Color -Text "[e] ", "GraphEssentials module is required but not available. Terminating." -Color Yellow, Red return $false } } if ($JamfRequired) { $ModuleAvailable = Get-Module -Name PowerJamf -ListAvailable if (-not $ModuleAvailable) { Write-Color -Text "[e] ", "PowerJamf module is required but not available. Terminating." -Color Yellow, Red return $false } } } function Convert-ListProcessed { <# .SYNOPSIS This function converts old format of writting down pending list with DN to new format with SamAccountName with domain. .DESCRIPTION This function converts old format of writting down pending list with DN to new format with SamAccountName with domain. The old way would probably break if someone would move computer after disabling which would cause the computer to be removed from the list. .PARAMETER FileImport Hashtable with PendingDeletion and History keys. .EXAMPLE $FileImport = Import-Clixml -LiteralPath $DataStorePath -ErrorAction Stop # convert old format to new format $FileImport = Convert-ListProcessed -FileImport $FileImport .NOTES General notes #> [CmdletBinding()] param( [System.Collections.IDictionary] $FileImport ) if (-not $FileImport.Contains('PendingDeletion') -and $FileImport.PendingDeletion.Keys.Count -eq 0) { return $FileImport } if ($FileImport.PendingDeletion.Keys[0] -like "*@*") { # Write-Color -Text "[i] ", "List is already converted. Terminating." -Color Yellow, Green return $FileImport } Write-Color -Text "[i] ", "Converting list to new format." -Color Yellow, Green foreach ($Key in [string[]] $FileImport.PendingDeletion.Keys) { $DomainName = ConvertFrom-DistinguishedName -DistinguishedName $FileImport.PendingDeletion[$Key].DistinguishedName -ToDomainCN $NewKey = -join ($FileImport.PendingDeletion[$Key].SamAccountName, "@", $DomainName) $FileImport.PendingDeletion[$NewKey] = $FileImport.PendingDeletion[$Key] $null = $FileImport.PendingDeletion.Remove($Key) } Write-Color -Text "[i] ", "List converted." -Color Yellow, Green $FileImport } function ConvertTo-PreparedComputer { [CmdletBinding()] param( [Microsoft.ActiveDirectory.Management.ADComputer[]] $Computers, [System.Collections.IDictionary] $AzureInformationCache, [System.Collections.IDictionary] $JamfInformationCache, [switch] $IncludeAzureAD, [switch] $IncludeIntune, [switch] $IncludeJamf ) foreach ($Computer in $Computers) { if ($IncludeAzureAD) { $AzureADComputer = $AzureInformationCache['AzureAD']["$($Computer.Name)"] $DataAzureAD = [ordered] @{ 'AzureLastSeen' = $AzureADComputer.LastSeen 'AzureLastSeenDays' = $AzureADComputer.LastSeenDays 'AzureLastSync' = $AzureADComputer.LastSynchronized 'AzureLastSyncDays' = $AzureADComputer.LastSynchronizedDays 'AzureOwner' = $AzureADComputer.OwnerDisplayName 'AzureOwnerStatus' = $AzureADComputer.OwnerEnabled 'AzureOwnerUPN' = $AzureADComputer.OwnerUserPrincipalName } } if ($IncludeIntune) { # data was requested from Intune $IntuneComputer = $AzureInformationCache['Intune']["$($Computer.Name)"] $DataIntune = [ordered] @{ 'IntuneLastSeen' = $IntuneComputer.LastSeen 'IntuneLastSeenDays' = $IntuneComputer.LastSeenDays 'IntuneUser' = $IntuneComputer.UserDisplayName 'IntuneUserUPN' = $IntuneComputer.UserPrincipalName 'IntuneUserEmail' = $IntuneComputer.EmailAddress } } if ($IncludeJamf) { $JamfComputer = $JamfInformationCache["$($Computer.Name)"] $DataJamf = [ordered] @{ JamfLastContactTime = $JamfComputer.lastContactTime JamfLastContactTimeDays = $JamfComputer.lastContactTimeDays JamfCapableUsers = $JamfComputer.mdmCapableCapableUsers } } $LastLogonDays = if ($null -ne $Computer.LastLogonDate) { - $($Computer.LastLogonDate - $Today).Days } else { $null } $PasswordLastChangedDays = if ($null -ne $Computer.PasswordLastSet) { - $($Computer.PasswordLastSet - $Today).Days } else { $null } $DataStart = [ordered] @{ 'DNSHostName' = $Computer.DNSHostName 'SamAccountName' = $Computer.SamAccountName 'DomainName' = ConvertFrom-DistinguishedName -DistinguishedName $Computer.DistinguishedName -ToDomainCN 'Enabled' = $Computer.Enabled 'Action' = 'Not required' 'ActionStatus' = $null 'ActionDate' = $null 'ActionComment' = $null 'OperatingSystem' = $Computer.OperatingSystem 'OperatingSystemVersion' = $Computer.OperatingSystemVersion 'OperatingSystemLong' = ConvertTo-OperatingSystem -OperatingSystem $Computer.OperatingSystem -OperatingSystemVersion $Computer.OperatingSystemVersion 'LastLogonDate' = $Computer.LastLogonDate 'LastLogonDays' = $LastLogonDays 'PasswordLastSet' = $Computer.PasswordLastSet 'PasswordLastChangedDays' = $PasswordLastChangedDays 'ProtectedFromAccidentalDeletion' = $Computer.ProtectedFromAccidentalDeletion } $DataEnd = [ordered] @{ '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 'DistinguishedNameAfterMove' = $null } if ($IncludeAzureAD -and $IncludeIntune -and $IncludeJamf) { $Data = $DataStart + $DataAzureAD + $DataIntune + $DataJamf + $DataEnd } elseif ($IncludeAzureAD -and $IncludeIntune) { $Data = $DataStart + $DataAzureAD + $DataIntune + $DataEnd } elseif ($IncludeAzureAD -and $IncludeJamf) { $Data = $DataStart + $DataAzureAD + $DataJamf + $DataEnd } elseif ($IncludeIntune -and $IncludeJamf) { $Data = $DataStart + $DataIntune + $DataJamf + $DataEnd } elseif ($IncludeAzureAD) { $Data = $DataStart + $DataAzureAD + $DataEnd } elseif ($IncludeIntune) { $Data = $DataStart + $DataIntune + $DataEnd } elseif ($IncludeJamf) { $Data = $DataStart + $DataJamf + $DataEnd } else { $Data = $DataStart + $DataEnd } [PSCustomObject] $Data } } function Disable-WinADComputer { [CmdletBinding(SupportsShouldProcess)] param( [bool] $Success, [switch] $WhatIfDisable, [switch] $DontWriteToEventLog, [PSCustomObject] $Computer, [string] $Server ) if ($Success) { if ($Computer.Enabled -eq $true) { 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 { if ($Computer.DistinguishedNameAfterMove) { $DN = $Computer.DistinguishedNameAfterMove } else { $DN = $Computer.DistinguishedName } Disable-ADAccount -Identity $DN -Server $Server -WhatIf:$WhatIfDisable -ErrorAction Stop Write-Color -Text "[+] Disabling computer ", $DN, " (WhatIf: $WhatIfDisable) successful." -Color Yellow, Green, Yellow if (-not $DontWriteToEventLog) { 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 { $Computer.ActionComment = $_.Exception.Message $Success = $false Write-Color -Text "[-] Disabling computer ", $DN, " (WhatIf: $WhatIfDisable) failed. Error: $($_.Exception.Message)" -Color Yellow, Red, Yellow if (-not $DontWriteToEventLog) { 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 } } } else { Write-Color -Text "[i] Computer ", $Computer.SamAccountName, " is already disabled." -Color Yellow, Green, Yellow } } $Success } function Get-ADComputersToProcess { [CmdletBinding()] param( [parameter(Mandatory)][ValidateSet('Disable', 'Move', 'Delete')][string] $Type, [Array] $Computers, [alias('DeleteOnlyIf', 'DisableOnlyIf', 'MoveOnlyIf')][System.Collections.IDictionary] $ActionIf, [Array] $Exclusions, [System.Collections.IDictionary] $DomainInformation, [System.Collections.IDictionary] $ProcessedComputers, [System.Collections.IDictionary] $AzureInformationCache, [System.Collections.IDictionary] $JamfInformationCache, [switch] $IncludeAzureAD, [switch] $IncludeIntune, [switch] $IncludeJamf ) Write-Color -Text "[i] ", "Applying following rules to $Type action: " -Color Yellow, Cyan, Green foreach ($Key in $ActionIf.Keys) { if ($null -eq $ActionIf[$Key] -or $ActionIf[$Key].Count -eq 0) { Write-Color -Text " [>] ", $($Key), " is ", 'Not Set' -Color Yellow, Cyan, Yellow } else { if ($Key -in 'LastLogonDateMoreThan', 'LastLogonDateOlderThan') { Write-Color -Text " [>] ", $($Key), " is ", $($ActionIf[$Key]), " or ", "Never logged on" -Color Yellow, Cyan, Green } elseif ($Key -in 'PasswordLastSetMoreThan', 'PasswordLastSetOlderThan') { Write-Color -Text " [>] ", $($Key), " is ", $($ActionIf[$Key]), " or ", "Never changed" -Color Yellow, Cyan, Green } elseif ($Key -in 'LastSeenAzureMoreThan', 'LastSeenIntuneMoreThan', 'LastSyncAzureMoreThan', 'LastContactJamfMoreThan') { Write-Color -Text " [>] ", $($Key), " is ", $($ActionIf[$Key]), " or ", "Never synced/seen" -Color Yellow, Cyan, Green } else { Write-Color -Text " [>] ", $($Key), " is ", $($ActionIf[$Key]) -Color Yellow, Cyan, Green } } } $Count = 0 $Today = Get-Date # Let's cache the destination move, if we have them $CachedDestinationMove = @{} if ($ActionIf.MoveTargetOrganizationalUnit -is [string]) { $Domain = ConvertFrom-DistinguishedName -DistinguishedName $ActionIf.MoveTargetOrganizationalUnit -ToDomainCN $CachedDestinationMove[$ActionIf.MoveTargetOrganizationalUnit] = $Domain } elseif ($ActionIf.MoveTargetOrganizationalUnit -is [System.Collections.IDictionary]) { foreach ($Domain in $ActionIf.MoveTargetOrganizationalUnit.Keys) { $OU = $ActionIf.MoveTargetOrganizationalUnit[$Domain] $CachedDestinationMove[$OU] = $Domain } } Write-Color -Text "[i] ", "Looking for computers to $Type" -Color Yellow, Cyan, Green :SkipComputer foreach ($Computer in $Computers) { if ($Type -eq 'Delete') { # actions to happen only if we are deleting computers if ($null -ne $ActionIf.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) { $FullComputerName = "$($Computer.SamAccountName)@$($Computer.DomainName)" $FoundComputer = $ProcessedComputers[$FullComputerName] if ($FoundComputer) { if ($FoundComputer.ActionDate -is [DateTime]) { $TimeSpan = New-TimeSpan -Start $FoundComputer.ActionDate -End $Today if ($TimeSpan.Days -gt $ActionIf.ListProcessedMoreThan) { } else { continue SkipComputer } } else { continue SkipComputer } } else { continue SkipComputer } } else { # ListProcessed doesn't have members, and it's part of requirement break } } } if ($Type -eq 'Disable') { # actions to happen only if we are disabling computers if ($ProcessedComputers.Count -gt 0) { $FullComputerName = "$($Computer.SamAccountName)@$($Computer.DomainName)" $FoundComputer = $ProcessedComputers[$FullComputerName] 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 DarkYellow, Green, DarkYellow $ProcessedComputers.Remove($FullComputerName) } elseif ($ActionIf.DisableAndMove -and $Computer.Enabled -eq $false) { if ($ActionIf.MoveTargetOrganizationalUnit) { if (-not $CachedDestinationMove[$Computer.OrganizationalUnit]) { # We checked and it seems the computer has been moved since it was added to list, we remove it from the list and reprocess Write-Color -Text "[*] Removing computer from pending list (computer is moved out of pending deletion OU) ", $FoundComputer.SamAccountName, " ($($FoundComputer.DistinguishedName))" -Color DarkYellow, Green, DarkYellow $ProcessedComputers.Remove($FullComputerName) } else { # We checked and it seems the computer is in place where it's supposed to, we skip to next computer continue SkipComputer } } else { # We checked and it seems the computer is in place where it's supposed to, we skip to next computer continue SkipComputer } } else { # we skip adding to disabled because it's already on the list for removing continue SkipComputer } } } } # rest of actions are same for all types foreach ($PartialExclusion in $Exclusions) { if ($Computer.DistinguishedName -like "$PartialExclusion") { $Computer.'Action' = 'ExcludedByFilter' continue SkipComputer } if ($Computer.SamAccountName -like "$PartialExclusion") { $Computer.'Action' = 'ExcludedByFilter' continue SkipComputer } if ($Computer.DNSHostName -like "$PartialExclusion") { $Computer.'Action' = 'ExcludedByFilter' continue SkipComputer } } if ($ActionIf.IncludeSystems.Count -gt 0) { $FoundInclude = $false foreach ($Include in $ActionIf.IncludeSystems) { if ($Computer.OperatingSystem -like $Include) { $FoundInclude = $true break } } # If not found in includes we need to skip the computer if (-not $FoundInclude) { $Computer.'Action' = 'ExcludedBySetting' continue SkipComputer } } if ($ActionIf.ExcludeServicePrincipalName.Count -gt 0) { foreach ($ExcludeSPN in $ActionIf.ExcludeServicePrincipalName) { if ($Computer.servicePrincipalName -like "$ExcludeSPN") { $Computer.'Action' = 'ExcludedBySetting' continue SkipComputer } } } if ($ActionIf.IncludeServicePrincipalName.Count -gt 0) { $FoundInclude = $false foreach ($IncludeSPN in $ActionIf.IncludeServicePrincipalName) { if ($Computer.servicePrincipalName -like "$IncludeSPN") { $FoundInclude = $true break } } # If not found in includes we need to skip the computer if (-not $FoundInclude) { $Computer.'Action' = 'ExcludedBySetting' continue SkipComputer } } if ($ActionIf.ExcludeSystems.Count -gt 0) { foreach ($Exclude in $ActionIf.ExcludeSystems) { if ($Computer.OperatingSystem -like $Exclude) { $Computer.'Action' = 'ExcludedBySetting' continue SkipComputer } } } if ($ActionIf.NoServicePrincipalName -eq $true) { # action computer only if it has no service principal names defined if ($Computer.servicePrincipalName.Count -gt 0) { $Computer.'Action' = 'ExcludedBySetting' continue SkipComputer } } elseif ($ActionIf.NoServicePrincipalName -eq $false) { # action computer only if it has service principal names defined if ($Computer.servicePrincipalName.Count -eq 0) { $Computer.'Action' = 'ExcludedBySetting' continue SkipComputer } } if ($ActionIf.RequireWhenCreatedMoreThan) { # This runs only if more than 0 if ($Computer.WhenCreated) { # We ignore empty $TimeToCompare = ($Computer.WhenCreated).AddDays($ActionIf.RequireWhenCreatedMoreThan) if ($TimeToCompare -gt $Today) { continue SkipComputer } } } if ($ActionIf.IsEnabled -eq $true) { # action computer only if it's Enabled if ($Computer.Enabled -eq $false) { continue SkipComputer } } elseif ($ActionIf.IsEnabled -eq $false) { # action computer only if it's Disabled if ($Computer.Enabled -eq $true) { continue SkipComputer } } if ($ActionIf.LastLogonDateMoreThan) { # This runs only if more than 0 if ($Computer.LastLogonDate) { # We ignore empty $TimeToCompare = ($Computer.LastLogonDate).AddDays($ActionIf.LastLogonDateMoreThan) if ($TimeToCompare -gt $Today) { continue SkipComputer } } } if ($ActionIf.PasswordLastSetMoreThan) { # This runs only if more than 0 if ($Computer.PasswordLastSet) { # We ignore empty $TimeToCompare = ($Computer.PasswordLastSet).AddDays($ActionIf.PasswordLastSetMoreThan) if ($TimeToCompare -gt $Today) { continue SkipComputer } } } if ($ActionIf.PasswordLastSetOlderThan) { # This runs only if not null if ($Computer.PasswordLastSet) { # We ignore empty if ($ActionIf.PasswordLastSetOlderThan -le $Computer.PasswordLastSet) { continue SkipComputer } } } if ($ActionIf.LastLogonDateOlderThan) { # This runs only if not null if ($Computer.LastLogonDate) { # We ignore empty if ($ActionIf.LastLogonDateOlderThan -le $Computer.LastLogonDate) { continue SkipComputer } } } if ($IncludeAzureAD) { if ($null -ne $ActionIf.LastSeenAzureMoreThan -and $null -ne $Computer.AzureLastSeenDays) { if ($Computer.AzureLastSeenDays -le $ActionIf.LastSeenAzureMoreThan) { continue SkipComputer } } if ($null -ne $ActionIf.LastSyncAzureMoreThan -and $null -ne $Computer.AzureLastSyncDays) { if ($Computer.AzureLastSyncDays -le $ActionIf.LastSyncAzureMoreThan) { continue SkipComputer } } } if ($IncludeIntune) { if ($null -ne $ActionIf.LastSeenIntuneMoreThan -and $null -ne $Computer.IntuneLastSeenDays) { if ($Computer.IntuneLastSeenDays -le $ActionIf.LastSeenIntuneMoreThan) { continue SkipComputer } } } if ($IncludeJamf) { if ($null -ne $ActionIf.LastContactJamfMoreThan -and $null -ne $Computer.JamfLastContactTimeDays) { if ($Computer.JamfLastContactTimeDays -le $ActionIf.LastContactJamfMoreThan) { continue SkipComputer } } } $Computer.'Action' = $Type $Count++ } $Count } function Get-InitialADComputers { [CmdletBinding()] param( [System.Collections.IDictionary] $Report, [System.Collections.IDictionary] $ForestInformation, [object] $Filter, [object] $SearchBase, [string[]] $Properties, [bool] $Disable, [bool] $Delete, [bool] $Move, [System.Collections.IDictionary] $DisableOnlyIf, [System.Collections.IDictionary] $DeleteOnlyIf, [System.Collections.IDictionary] $MoveOnlyIf, [Array] $Exclusions, [System.Collections.IDictionary] $ProcessedComputers, [nullable[int]] $SafetyADLimit, [System.Collections.IDictionary] $AzureInformationCache, [System.Collections.IDictionary] $JamfInformationCache, [object] $TargetServers ) $AllComputers = [ordered] @{} $AzureRequired = $false $IntuneRequired = $false $JamfRequired = $false if ($DisableOnlyIf) { if ($null -ne $DisableOnlyIf.LastSyncAzureMoreThan -or $null -ne $DisableOnlyIf.LastSeenAzureMoreThan) { $AzureRequired = $true } if ($null -ne $DisableOnlyIf.LastContactJamfMoreThan) { $JamfRequired = $true } if ($null -ne $DisableOnlyIf.LastSeenIntuneMoreThan) { $IntuneRequired = $true } } if ($MoveOnlyIf) { if ($null -ne $MoveOnlyIf.LastSyncAzureMoreThan -or $null -ne $MoveOnlyIf.LastSeenAzureMoreThan) { $AzureRequired = $true } if ($null -ne $MoveOnlyIf.LastContactJamfMoreThan) { $JamfRequired = $true } if ($null -ne $MoveOnlyIf.LastSeenIntuneMoreThan) { $IntuneRequired = $true } } if ($DeleteOnlyIf) { if ($null -ne $DeleteOnlyIf.LastSyncAzureMoreThan -or $null -ne $DeleteOnlyIf.LastSeenAzureMoreThan) { $AzureRequired = $true } if ($null -ne $DeleteOnlyIf.LastContactJamfMoreThan) { $JamfRequired = $true } if ($null -ne $DeleteOnlyIf.LastSeenIntuneMoreThan) { $IntuneRequired = $true } } if ($TargetServers) { # User provided target servers/server. If there is only one we assume user wants to use it for all domains (hopefully just one domain) # If there are multiple we assume user wants to use different servers for different domains using hashtable/dictionary # If there is no server for a domain we will use the default server, as detected if ($TargetServers -is [string]) { $TargetServer = $TargetServers } if ($TargetServers -is [System.Collections.IDictionary]) { $TargetServerDictionary = $TargetServers[$Domain] } } $CountDomains = 0 foreach ($Domain in $ForestInformation.Domains) { $CountDomains++ $Report["$Domain"] = [ordered] @{ } $Server = $ForestInformation['QueryServers'][$Domain].HostName[0] if (-not $Server) { Write-Color "[e] ", "No server found for domain $Domain" -Color Yellow, Red continue } if ($TargetServer) { Write-Color -Text "Overwritting target server for domain ", $Domain, ": ", $TargetServer -Color Yellow, Magenta $Server = $TargetServer } elseif ($TargetServerDictionary) { if ($TargetServerDictionary[$Domain]) { Write-Color -Text "Overwritting target server for domain ", $Domain, ": ", $TargetServerDictionary[$Domain] -Color Yellow, Magenta $Server = $TargetServerDictionary[$Domain] } } $DomainInformation = $ForestInformation.DomainsExtended[$Domain] $Report["$Domain"]['Server'] = $Server Write-Color "[i] Getting all computers for domain ", $Domain, " [", $CountDomains, "/", $ForestInformation.Domains.Count, "]", " from ", $Server -Color Yellow, Magenta, Yellow, Magenta, Yellow, Magenta, Yellow, Yellow, Magenta if ($Filter) { if ($Filter -is [string]) { $FilterToUse = $Filter } elseif ($Filter -is [System.Collections.IDictionary]) { $FilterToUse = $Filter[$Domain] } else { Write-Color "[e] ", "Filter must be a string or a hashtable/ordereddictionary" -Color Yellow, Red return $false } } else { $FilterToUse = "*" } if ($SearchBase) { if ($SearchBase -is [string]) { $SearchBaseToUse = $SearchBase } elseif ($SearchBase -is [System.Collections.IDictionary]) { $SearchBaseToUse = $SearchBase[$Domain] } else { Write-Color "[e] ", "SearchBase must be a string or a hashtable/ordereddictionary" -Color Yellow, Red return $false } } else { $SearchBaseToUse = $DomainInformation.DistinguishedName } $getADComputerSplat = @{ Filter = $FilterToUse Server = $Server Properties = $Properties ErrorAction = 'Stop' } if ($SearchBaseToUse) { $getADComputerSplat.SearchBase = $SearchBaseToUse } try { [Array] $Computers = Get-ADComputer @getADComputerSplat } catch { if ($_.Exception.Message -like "*distinguishedName must belong to one of the following partition*") { Write-Color "[e] ", "Error getting computers for domain $($Domain): ", $_.Exception.Message -Color Yellow, Red Write-Color "[e] ", "Please check if the distinguishedName for SearchBase is correct for the domain. If you have multiple domains please use Hashtable/Dictionary to provide relevant data or using IncludeDomains/ExcludeDomains functionality" -Color Yellow, Red } else { Write-Color "[e] ", "Error getting computers for domain $($Domain): ", $_.Exception.Message -Color Yellow, Red } return $false } foreach ($Computer in $Computers) { # we will be using it later to just check if computer exists in AD $DomainName = ConvertFrom-DistinguishedName -DistinguishedName $Computer.DistinguishedName -ToDomainCN $ComputerFullName = -join ($Computer.SamAccountName, "@", $DomainName) # initially we used DN, but DN changes when moving so it wouldn't work $AllComputers[$ComputerFullName] = $Computer } $Report["$Domain"]['Computers'] = @( $convertToPreparedComputerSplat = @{ Computers = $Computers AzureInformationCache = $AzureInformationCache JamfInformationCache = $JamfInformationCache IncludeAzureAD = $AzureRequired IncludeJamf = $JamfRequired IncludeIntune = $IntuneRequired } ConvertTo-PreparedComputer @convertToPreparedComputerSplat ) 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 $getADComputersToDisableSplat = @{ Computers = $Report["$Domain"]['Computers'] DisableOnlyIf = $DisableOnlyIf Exclusions = $Exclusions DomainInformation = $DomainInformation ProcessedComputers = $ProcessedComputers AzureInformationCache = $AzureInformationCache JamfInformationCache = $JamfInformationCache IncludeAzureAD = $AzureRequired IncludeJamf = $JamfRequired IncludeIntune = $IntuneRequired Type = 'Disable' } $Report["$Domain"]['ComputersToBeDisabled'] = Get-ADComputersToProcess @getADComputersToDisableSplat #Write-Color "[i] ", "Computers to be disabled for domain $Domain`: ", $($Report["$Domain"]['ComputersToBeDisabled'].Count) -Color Yellow, Cyan, Green } if ($Move) { Write-Color "[i] ", "Processing computers to move for domain $Domain" -Color Yellow, Cyan, Green $getADComputersToDeleteSplat = @{ Computers = $Report["$Domain"]['Computers'] MoveOnlyIf = $MoveOnlyIf Exclusions = $Exclusions DomainInformation = $DomainInformation ProcessedComputers = $ProcessedComputers AzureInformationCache = $AzureInformationCache JamfInformationCache = $JamfInformationCache IncludeAzureAD = $AzureRequired IncludeJamf = $JamfRequired IncludeIntune = $IntuneRequired Type = 'Move' } $Report["$Domain"]['ComputersToBeMoved'] = Get-ADComputersToProcess @getADComputersToDeleteSplat #Write-Color "[i] ", "Computers to be moved for domain $Domain`: ", $($Report["$Domain"]['ComputersToBeMoved'].Count) -Color Yellow, Cyan, Green } if ($Delete) { Write-Color "[i] ", "Processing computers to delete for domain $Domain" -Color Yellow, Cyan, Green $getADComputersToDeleteSplat = @{ Computers = $Report["$Domain"]['Computers'] DeleteOnlyIf = $DeleteOnlyIf Exclusions = $Exclusions DomainInformation = $DomainInformation ProcessedComputers = $ProcessedComputers AzureInformationCache = $AzureInformationCache JamfInformationCache = $JamfInformationCache IncludeAzureAD = $AzureRequired IncludeJamf = $JamfRequired IncludeIntune = $IntuneRequired Type = 'Delete' } $Report["$Domain"]['ComputersToBeDeleted'] = Get-ADComputersToProcess @getADComputersToDeleteSplat #Write-Color "[i] ", "Computers to be deleted for domain $Domain`: ", $($Report["$Domain"]['ComputersToBeDeleted'].Count) -Color Yellow, Cyan, Green } } if ($null -ne $SafetyADLimit -and $AllComputers.Count -lt $SafetyADLimit) { Write-Color "[e] ", "Only ", $($AllComputers.Count), " computers found in AD, this is less than the safety limit of ", $SafetyADLimit, ". Terminating!" -Color Yellow, Cyan, Red, Cyan return $false } $AllComputers } function Get-InitialGraphComputers { [CmdletBinding()] param( [nullable[int]] $SafetyAzureADLimit, [nullable[int]] $SafetyIntuneLimit, [nullable[int]] $DeleteLastSeenAzureMoreThan, [nullable[int]] $DeleteLastSeenIntuneMoreThan, [nullable[int]] $DeleteLastSyncAzureMoreThan, [nullable[int]] $DisableLastSeenAzureMoreThan, [nullable[int]] $DisableLastSeenIntuneMoreThan, [nullable[int]] $DisableLastSyncAzureMoreThan, [nullable[int]] $MoveLastSeenAzureMoreThan, [nullable[int]] $MoveLastSeenIntuneMoreThan, [nullable[int]] $MoveLastSyncAzureMoreThan ) $AzureInformationCache = [ordered] @{ AzureAD = [ordered] @{} Intune = [ordered] @{} } if ($PSBoundParameters.ContainsKey('DisableLastSeenAzureMoreThan') -or $PSBoundParameters.ContainsKey('DisableLastSyncAzureMoreThan') -or $PSBoundParameters.ContainsKey('DeleteLastSeenAzureMoreThan') -or $PSBoundParameters.ContainsKey('DeleteLastSyncAzureMoreThan') -or $PSBoundParameters.ContainsKey('MoveLastSeenAzureMoreThan') -or $PSBoundParameters.ContainsKey('MoveLastSyncAzureMoreThan')) { Write-Color "[i] ", "Getting all computers from AzureAD" -Color Yellow, Cyan, Green try { [Array] $Devices = Get-MyDevice -Synchronized -WarningAction SilentlyContinue -WarningVariable WarningVar } catch { Write-Color "[e] ", "Error getting computers from AzureAD: ", $_, " Terminating!" -Color Yellow, Red, Yellow, Red return $false } #[Array] $Devices = Get-MyDevice -Synchronized -WarningAction SilentlyContinue -WarningVariable WarningVar if ($WarningVar) { Write-Color "[e] ", "Error getting computers from AzureAD: ", $WarningVar, " Terminating!" -Color Yellow, Red, Yellow, Red return $false } if ($Devices.Count -eq 0) { Write-Color "[e] ", "No computers found in AzureAD, terminating! Please disable Azure AD integration or fix connectivity." -Color Yellow, Red return $false } foreach ($Device in $Devices) { $AzureInformationCache.AzureAD[$Device.Name] = $Device } if ($null -ne $SafetyAzureADLimit -and $Devices.Count -lt $SafetyAzureADLimit) { Write-Color "[e] ", "Only ", $($Devices.Count), " computers found in AzureAD, this is less than the safety limit of ", $SafetyAzureADLimit, ". Terminating!" -Color Yellow, Cyan, Red, Cyan return $false } Write-Color "[i] ", "Synchronized Computers found in AzureAD`: ", $($Devices.Count) -Color Yellow, Cyan, Green } if ($PSBoundParameters.ContainsKey('DisableLastSeenIntuneMoreThan') -or $PSBoundParameters.ContainsKey('DeleteLastSeenIntuneMoreThan') -or $PSBoundParameters.ContainsKey('MoveLastSeenIntuneMoreThan')) { Write-Color "[i] ", "Getting all computers from Intune" -Color Yellow, Cyan, Green [Array] $DevicesIntune = Get-MyDeviceIntune -WarningAction SilentlyContinue -WarningVariable WarningVar if ($WarningVar) { Write-Color "[e] ", "Error getting computers from Intune: ", $WarningVar, " Terminating!" -Color Yellow, Red, Yellow, Red return $false } if ($DevicesIntune.Count -eq 0) { Write-Color "[e] ", "No computers found in Intune, terminating! Please disable Intune integration or fix connectivity." -Color Yellow, Red return $false } foreach ($device in $DevicesIntune) { $AzureInformationCache.Intune[$Device.Name] = $device } if ($null -ne $SafetyIntuneLimit -and $DevicesIntune.Count -lt $SafetyIntuneLimit) { Write-Color "[e] ", "Only ", $($DevicesIntune.Count), " computers found in Intune, this is less than the safety limit of ", $SafetyIntuneLimit, ". Terminating!" -Color Yellow, Cyan, Red, Cyan return $false } Write-Color "[i] ", "Synchronized Computers found in Intune`: ", $($DevicesIntune.Count) -Color Yellow, Cyan, Green } $AzureInformationCache } function Get-InitialJamfComputers { [CmdletBinding()] param( [bool] $DisableLastContactJamfMoreThan, [bool] $MoveLastContactJamfMoreThan, [bool] $DeleteLastContactJamfMoreThan, [nullable[int]] $SafetyJamfLimit ) $JamfCache = [ordered] @{} if ($PSBoundParameters.ContainsKey('DisableLastContactJamfMoreThan') -or $PSBoundParameters.ContainsKey('DeleteLastContactJamfMoreThan') -or $PSBoundParameters.ContainsKey('MoveLastContactJamfMoreThan') ) { Write-Color "[i] ", "Getting all computers from Jamf" -Color Yellow, Cyan, Green [Array] $Jamf = Get-JamfDevice -WarningAction SilentlyContinue -WarningVariable WarningVar if ($WarningVar) { Write-Color "[e] ", "Error getting computers from Jamf: ", $WarningVar, " Terminating!" -Color Yellow, Red, Yellow, Red return $false } if ($Jamf.Count -eq 0) { Write-Color "[e] ", "No computers found in Jamf, terminating! Please disable Jamf integration or fix connectivity." -Color Yellow, Red return $false } else { Write-Color "[i] ", "Computers found in Jamf`: ", $($Jamf.Count) -Color Yellow, Cyan, Green } if ($null -ne $SafetyJamfLimit -and $Jamf.Count -lt $SafetyJamfLimit) { Write-Color "[e] ", "Only ", $($Jamf.Count), " computers found in Jamf, this is less than the safety limit of ", $SafetyJamfLimit, ". Terminating!" -Color Yellow, Cyan, Red, Cyan return $false } foreach ($device in $Jamf) { $JamfCache[$Device.Name] = $device } } $JamfCache } function Import-ComputersData { [CmdletBinding()] param( [string] $DataStorePath, [System.Collections.IDictionary] $Export ) $ProcessedComputers = [ordered] @{ } try { if ($DataStorePath -and (Test-Path -LiteralPath $DataStorePath -ErrorAction Stop)) { $FileImport = Import-Clixml -LiteralPath $DataStorePath -ErrorAction Stop # convert old format to new format $FileImport = Convert-ListProcessed -FileImport $FileImport if ($FileImport.PendingDeletion) { if ($FileImport.PendingDeletion.GetType().Name -notin 'Hashtable', 'OrderedDictionary') { Write-Color -Text "[e] ", "Incorrect XML format. PendingDeletion is not a hashtable/ordereddictionary. Terminating." -Color Yellow, Red return $false } } if ($FileImport.History) { if ($FileImport.History.GetType().Name -ne 'ArrayList') { Write-Color -Text "[e] ", "Incorrect XML format. History is not a ArrayList. Terminating." -Color Yellow, Red return $False } } $ProcessedComputers = $FileImport.PendingDeletion $Export['History'] = $FileImport.History } if (-not $ProcessedComputers) { $ProcessedComputers = [ordered] @{ } } } catch { Write-Color -Text "[e] ", "Couldn't read the list or wrong format. Error: $($_.Exception.Message)" -Color Yellow, Red return $false } $ProcessedComputers } function Move-WinADComputer { [CmdletBinding(SupportsShouldProcess)] param( [bool] $Success, [bool] $DisableAndMove, [System.Collections.IDictionary] $OrganizationalUnit, [PSCustomObject] $Computer, [switch] $WhatIfDisable, [switch] $DontWriteToEventLog, [string] $Server ) if ($Success -and $DisableAndMove) { # we only move if we successfully disabled the computer if ($OrganizationalUnit[$Domain]) { if ($Computer.OrganizationalUnit -eq $OrganizationalUnit[$Domain]) { Write-Color -Text "[i] Computer ", $Computer.DistinguishedName, " is already in the correct OU." -Color Yellow, Green, Yellow } else { try { $MovedObject = Move-ADObject -Identity $Computer.DistinguishedName -WhatIf:$WhatIfDisable -Server $Server -ErrorAction Stop -Confirm:$false -TargetPath $OrganizationalUnit[$Domain] -PassThru Write-Color -Text "[+] Moving computer ", $Computer.DistinguishedName, " (WhatIf: $($WhatIfDisable.IsPresent)) successful." -Color Yellow, Green, Yellow if (-not $DontWriteToEventLog) { Write-Event -ID 11 -LogName 'Application' -EntryType Warning -Category 1000 -Source 'CleanupComputers' -Message "Moving computer $($Computer.SamAccountName) successful." -AdditionalFields @('Move', $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 } $Computer.DistinguishedNameAfterMove = $MovedObject.DistinguishedName $Success = $true } catch { $Success = $false Write-Color -Text "[-] Moving computer ", $Computer.DistinguishedName, " (WhatIf: $($WhatIfDisable.IsPresent)) failed. Error: $($_.Exception.Message)" -Color Yellow, Red, Yellow if (-not $DontWriteToEventLog) { Write-Event -ID 11 -LogName 'Application' -EntryType Error -Category 1000 -Source 'CleanupComputers' -Message "Moving computer $($Computer.SamAccountName) failed." -AdditionalFields @('Move', $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 } $Computer.ActionComment = $Computer.ActionComment + [System.Environment]::NewLine + $_.Exception.Message } } } } $Success } function New-ADComputersStatistics { [CmdletBinding()] param( [Array] $ComputersToProcess ) $Statistics = [ordered] @{ All = $ComputersToProcess.Count ToMove = 0 ToMoveComputerWorkstation = 0 ToMoveComputerServer = 0 ToMoveComputerUnknown = 0 ToDisable = 0 ToDisableComputerUnknown = 0 ToDisableComputerWorkstation = 0 ToDisableComputerServer = 0 ToDelete = 0 ToDeleteComputerWorkstation = 0 ToDeleteComputerServer = 0 ToDeleteComputerUnknown = 0 TotalWindowsServers = 0 TotalWindowsWorkstations = 0 TotalMacOS = 0 TotalLinux = 0 TotalUnknown = 0 Delete = [ordered] @{ LastLogonDays = [ordered ]@{} PasswordLastChangedDays = [ordered] @{} Systems = [ordered] @{} } Move = [ordered] @{ LastLogonDays = [ordered] @{} PasswordLastChangedDays = [ordered] @{} Systems = [ordered] @{} } Disable = [ordered] @{ LastLogonDays = [ordered] @{} PasswordLastChangedDays = [ordered] @{} Systems = [ordered] @{} } 'Not required' = [ordered] @{ LastLogonDays = [ordered] @{} PasswordLastChangedDays = [ordered] @{} Systems = [ordered] @{} } 'ExcludedBySetting' = [ordered] @{ LastLogonDays = [ordered] @{} PasswordLastChangedDays = [ordered] @{} Systems = [ordered] @{} } 'ExcludedByFilter' = [ordered] @{ LastLogonDays = [ordered] @{} PasswordLastChangedDays = [ordered] @{} Systems = [ordered] @{} } } foreach ($Computer in $ComputersToProcess) { if ($Computer.OperatingSystem -like "Windows Server*") { $Statistics.TotalWindowsServers++ } elseif ($Computer.OperatingSystem -notlike "Windows Server*" -and $Computer.OperatingSystem -like "Windows*") { $Statistics.TotalWindowsWorkstations++ } elseif ($Computer.OperatingSystem -like "Mac*") { $Statistics.TotalMacOS++ } elseif ($Computer.OperatingSystem -like "Linux*") { $Statistics.TotalLinux++ } else { $Statistics.TotalUnknown++ } if ($Computer.Action -eq 'Disable') { $Statistics.ToDisable++ if ($Computer.OperatingSystem -like "Windows Server*") { $Statistics.ToDisableComputerServer++ } elseif ($Computer.OperatingSystem -notlike "Windows Server*" -and $Computer.OperatingSystem -like "Windows*") { $Statistics.ToDisableComputerWorkstation++ } else { $Statistics.ToDisableComputerUnknown++ } } elseif ($Computer.Action -eq 'Move') { $Statistics.ToMove++ if ($Computer.OperatingSystem -like "Windows Server*") { $Statistics.ToMoveComputerServer++ } elseif ($Computer.OperatingSystem -notlike "Windows Server*" -and $Computer.OperatingSystem -like "Windows*") { $Statistics.ToMoveComputerWorkstation++ } else { $Statistics.ToMoveComputerUnknown++ } } elseif ($Computer.Action -eq 'Delete') { $Statistics.ToDelete++ if ($Computer.OperatingSystem -like "Windows Server*") { $Statistics.ToDeleteComputerServer++ } elseif ($Computer.OperatingSystem -notlike "Windows Server*" -and $Computer.OperatingSystem -like "Windows*") { $Statistics.ToDeleteComputerWorkstation++ } else { $Statistics.ToDeleteComputerUnknown++ } } if ($Computer.OperatingSystem) { $Statistics[$Computer.Action]['Systems'][$Computer.OperatingSystem]++ } else { $Statistics[$Computer.Action]['Systems']['Unknown']++ } if ($Computer.LastLogonDays -gt 720) { $Statistics[$Computer.Action]['LastLogonDays']['Over 720 days']++ } elseif ($Computer.LastLogonDays -gt 360) { $Statistics[$Computer.Action]['LastLogonDays']['Over 360 days']++ } elseif ($Computer.LastLogonDays -gt 180) { $Statistics[$Computer.Action]['LastLogonDays']['Over 180 days']++ } elseif ($Computer.LastLogonDays -gt 90) { $Statistics[$Computer.Action]['LastLogonDays']['Over 90 days']++ } elseif ($Computer.LastLogonDays -gt 30) { $Statistics[$Computer.Action]['LastLogonDays']['Over 30 days']++ } else { $Statistics[$Computer.Action]['LastLogonDays']['Under 30 days']++ } if ($Computer.PasswordLastChangedDays -gt 720) { $Statistics[$Computer.Action]['PasswordLastChangedDays']['Over 720 days']++ } elseif ($Computer.PasswordLastChangedDays -gt 360) { $Statistics[$Computer.Action]['PasswordLastChangedDays']['Over 360 days']++ } elseif ($Computer.PasswordLastChangedDays -gt 180) { $Statistics[$Computer.Action]['PasswordLastChangedDays']['Over 180 days']++ } elseif ($Computer.PasswordLastChangedDays -gt 90) { $Statistics[$Computer.Action]['PasswordLastChangedDays']['Over 90 days']++ } elseif ($Computer.PasswordLastChangedDays -gt 30) { $Statistics[$Computer.Action]['PasswordLastChangedDays']['Over 30 days']++ } else { $Statistics[$Computer.Action]['PasswordLastChangedDays']['Under 30 days']++ } } $Statistics } function New-HTMLProcessedComputers { [CmdletBinding()] param( [System.Collections.IDictionary] $Export, [System.Collections.IDictionary] $DisableOnlyIf, [System.Collections.IDictionary] $DeleteOnlyIf, [System.Collections.IDictionary] $MoveOnlyIf, [Array] $ComputersToProcess, [string] $FilePath, [switch] $Online, [switch] $ShowHTML, [string] $LogFile, [switch] $Disable, [switch] $Delete, [switch] $Move, [switch] $ReportOnly ) New-HTML { New-HTMLTabStyle -BorderRadius 0px -TextTransform capitalize -BackgroundColorActive SlateGrey -BackgroundColor BlizzardBlue 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 "Cleanup Monster - $($Export['Version'])" -Color Blue } -JustifyContent flex-end -Invisible } } if (-not $ReportOnly) { New-HTMLTab -Name 'Devices Current Run' { New-HTMLSection { [Array] $ListAll = $($Export.CurrentRun) New-HTMLPanel { New-HTMLToast -TextHeader 'Total in this run' -Text "Actions (disable & delete): $($ListAll.Count)" -BarColorLeft MintGreen -IconSolid info-circle -IconColor MintGreen } -Invisible [Array] $ListDisabled = $($($Export.CurrentRun | Where-Object { $_.Action -eq 'Disable' })) New-HTMLPanel { New-HTMLToast -TextHeader 'Disable' -Text "Computers disabled: $($ListDisabled.Count)" -BarColorLeft OrangePeel -IconSolid info-circle -IconColor OrangePeel } -Invisible [Array] $ListMoved = $($($Export.CurrentRun | Where-Object { $_.Action -eq 'Move' })) New-HTMLPanel { New-HTMLToast -TextHeader 'Move' -Text "Computers moved: $($ListMoved.Count)" -BarColorLeft OrangePeel -IconSolid info-circle -IconColor OrangePeel } -Invisible [Array] $ListDeleted = $($($Export.CurrentRun | Where-Object { $_.Action -eq 'Delete' })) New-HTMLPanel { New-HTMLToast -TextHeader 'Delete' -Text "Computers deleted: $($ListDeleted.Count)" -BarColorLeft OrangeRed -IconSolid info-circle -IconColor OrangeRed } -Invisible } -Invisible New-HTMLTable -DataTable $Export.CurrentRun -Filtering -ScrollX { New-HTMLTableCondition -Name 'Action' -ComparisonType string -Value 'Delete' -BackgroundColor PinkLace New-HTMLTableCondition -Name 'Action' -ComparisonType string -Value 'Move' -BackgroundColor Yellow 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-HTMLTableCondition -Name 'ProtectedFromAccidentalDeletion' -ComparisonType string -Value $false -BackgroundColor LightBlue -FailBackgroundColor Salmon } -WarningAction SilentlyContinue } New-HTMLTab -Name 'Devices History' { New-HTMLSection { [Array] $ListAll = $($Export.History) New-HTMLPanel { New-HTMLToast -TextHeader 'Total History' -Text "Actions (disable & move & delete): $($ListAll.Count)" -BarColorLeft MintGreen -IconSolid info-circle -IconColor MintGreen } -Invisible [Array] $ListDisabled = $($($Export.History | Where-Object { $_.Action -eq 'Disable' })) New-HTMLPanel { New-HTMLToast -TextHeader 'Disabled History' -Text "Computers disabled so far: $($ListDisabled.Count)" -BarColorLeft OrangePeel -IconSolid info-circle -IconColor OrangePeel } -Invisible [Array] $ListMoved = $($($Export.History | Where-Object { $_.Action -eq 'Move' })) New-HTMLPanel { New-HTMLToast -TextHeader 'Moved History' -Text "Computers moved so far: $($ListMoved.Count)" -BarColorLeft OrangePeel -IconSolid info-circle -IconColor OrangePeel } -Invisible [Array] $ListDeleted = $($($Export.History | Where-Object { $_.Action -eq 'Delete' })) New-HTMLPanel { New-HTMLToast -TextHeader 'Deleted History' -Text "Computers deleted so far: $($ListDeleted.Count)" -BarColorLeft OrangeRed -IconSolid info-circle -IconColor OrangeRed } -Invisible } -Invisible New-HTMLTable -DataTable $Export.History -Filtering -ScrollX { New-HTMLTableCondition -Name 'Action' -ComparisonType string -Value 'Delete' -BackgroundColor PinkLace New-HTMLTableCondition -Name 'Action' -ComparisonType string -Value 'Move' -BackgroundColor Yellow 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 } -WarningAction SilentlyContinue -AllProperties } New-HTMLTab -Name 'Devices Pending' { New-HTMLTable -DataTable $Export.PendingDeletion.Values -Filtering -ScrollX { New-HTMLTableCondition -Name 'Action' -ComparisonType string -Value 'Delete' -BackgroundColor PinkLace New-HTMLTableCondition -Name 'Action' -ComparisonType string -Value 'Move' -BackgroundColor Yellow 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-HTMLTableCondition -Name 'ProtectedFromAccidentalDeletion' -ComparisonType string -Value $false -BackgroundColor LightBlue -FailBackgroundColor Salmon } -WarningAction SilentlyContinue -AllProperties } } New-HTMLTab -Name 'Devices' { New-HTMLSection { New-HTMLPanel { New-HTMLToast -TextHeader 'Total' -Text "Computers Total: $($ComputersToProcess.Count)" -BarColorLeft MintGreen -IconSolid info-circle -IconColor MintGreen } -Invisible New-HTMLPanel { New-HTMLToast -TextHeader 'To disable' -Text "Computers to be disabled: $($Export.Statistics.ToDisable)" -BarColorLeft OrangePeel -IconSolid info-circle -IconColor OrangePeel } -Invisible New-HTMLPanel { New-HTMLToast -TextHeader 'To move' -Text "Computers to be moved: $($Export.Statistics.ToMove)" -BarColorLeft OrangePeel -IconSolid info-circle -IconColor OrangePeel } -Invisible New-HTMLPanel { New-HTMLToast -TextHeader 'To delete' -Text "Computers to be deleted: $($Export.Statistics.ToDelete)" -BarColorLeft OrangeRed -IconSolid info-circle -IconColor OrangeRed } -Invisible } -Invisible New-HTMLSection -HeaderText 'General statistics' -CanCollapse { New-HTMLPanel { New-HTMLChart { New-ChartPie -Name 'To be disabled' -Value $Export.Statistics.ToDisable New-ChartPie -Name 'To be moved' -Value $Export.Statistics.ToMove New-ChartPie -Name 'To be deleted' -Value $Export.Statistics.ToDelete } -Title "Computers to be disabled or deleted" } if ($Export.Statistics.ToDisableComputerWorkstation -or $Export.Statistics.ToDisableComputerServer -or $Export.Statistics.ToDisableComputerUnknown) { New-HTMLPanel { New-HTMLChart { New-ChartPie -Name "Disable workstations" -Value $Export.Statistics.ToDisableComputerWorkstation New-ChartPie -Name "Disable servers" -Value $Export.Statistics.ToDisableComputerServer New-ChartPie -Name "Disable unknown" -Value $Export.Statistics.ToDisableComputerUnknown } -Title "Computers to be disabled by type" } } if ($Export.Statistics.ToDeleteComputerWorkstation -or $Export.Statistics.ToDeleteComputerServer -or $Export.Statistics.ToDeleteComputerUnknown) { New-HTMLPanel { New-HTMLChart { New-ChartPie -Name "Delete workstations" -Value $Export.Statistics.ToDeleteComputerWorkstation New-ChartPie -Name "Delete servers" -Value $Export.Statistics.ToDeleteComputerServer New-ChartPie -Name "Delete unknown" -Value $Export.Statistics.ToDeleteComputerUnknown } -Title "Computers to be deleted by type" } } } New-HTMLText -LineBreak New-HTMLHeading -Heading h3 -HeadingText "Full list of computers that will be processed if there are no limits to processing. " New-HTMLText -LineBreak New-HTMLSection -Invisible { New-HTMLPanel { if ($Disable) { New-HTMLText -Text "Computers will be disabled (and moved) only if: " -FontWeight bold New-HTMLList { foreach ($Key in $DisableOnlyIf.Keys) { $newHTMLListItemSplat = @{ Text = @( if ($null -eq $DisableOnlyIf[$Key] -or $DisableOnlyIf[$Key].Count -eq 0) { $($Key), " is ", 'Not Set' $ColorInUse = 'Cinnabar' } else { if ($Key -in 'LastLogonDateMoreThan', 'LastLogonDateOlderThan') { $($Key), " is ", $($DisableOnlyIf[$Key]), " or ", "Never logged on" } elseif ($Key -in 'PasswordLastSetMoreThan', 'PasswordLastSetOlderThan') { $($Key), " is ", $($DisableOnlyIf[$Key]), " or ", "Never changed" } elseif ($Key -in 'LastSeenAzureMoreThan', 'LastSeenIntuneMoreThan', 'LastSyncAzureMoreThan', 'LastContactJamfMoreThan') { $($Key), " is ", $($DisableOnlyIf[$Key]), " or ", "Never synced/seen" } elseif ($Key -in 'MoveTargetOrganizationalUnit') { if ($DisableOnlyIf[$Key] -is [string]) { $($Key), " is ", $MoveOnlyIf[$Key] } elseif ($DisableOnlyIf[$Key] -is [System.Collections.IDictionary]) { $Key } } else { $($Key), " is ", $($DisableOnlyIf[$Key]) } $ColorInUse = 'Apple' } ) FontWeight = 'bold', 'normal', 'bold', 'normal', 'bold' Color = $ColorInUse, 'None', 'CornflowerBlue', 'None', 'CornflowerBlue' } if ($Key -eq 'MoveTargetOrganizationalUnit') { $newHTMLListItemSplat.NestedListItems = { New-HTMLList { foreach ($MoveKey in $DisableOnlyIf[$Key].Keys) { New-HTMLListItem -Text @( $MoveKey, " to ", $DisableOnlyIf[$Key][$MoveKey] ) -FontWeight 'bold', 'normal', 'bold', 'normal', 'bold' -Color $ColorInUse, 'None', 'CornflowerBlue', 'None', 'CornflowerBlue' } } } } New-HTMLListItem @newHTMLListItemSplat } } } else { New-HTMLText -Text "Computers will not be disabled, as the disable functionality was not enabled. " -FontWeight bold } } New-HTMLPanel { if ($Move) { New-HTMLText -Text "Computers will be moved only if: " -FontWeight bold New-HTMLList { foreach ($Key in $MoveOnlyIf.Keys) { $newHTMLListItemSplat = @{ Text = @( if ($null -eq $MoveOnlyIf[$Key] -or $MoveOnlyIf[$Key].Count -eq 0) { $($Key), " is ", 'Not Set' $ColorInUse = 'Cinnabar' } else { if ($Key -in 'LastLogonDateMoreThan', 'LastLogonDateOlderThan') { $($Key), " is ", $($MoveOnlyIf[$Key]), " or ", "Never logged on" } elseif ($Key -in 'PasswordLastSetMoreThan', 'PasswordLastSetOlderThan') { $($Key), " is ", $($MoveOnlyIf[$Key]), " or ", "Never changed" } elseif ($Key -in 'LastSeenAzureMoreThan', 'LastSeenIntuneMoreThan', 'LastSyncAzureMoreThan', 'LastContactJamfMoreThan') { $($Key), " is ", $($MoveOnlyIf[$Key]), " or ", "Never synced/seen" } elseif ($Key -in 'TargetOrganizationalUnit') { if ($MoveOnlyIf[$Key] -is [string]) { $($Key), " is ", $MoveOnlyIf[$Key] } elseif ($MoveOnlyIf[$Key] -is [System.Collections.IDictionary]) { $Key } } else { $($Key), " is ", $($MoveOnlyIf[$Key]) } $ColorInUse = 'Apple' } ) FontWeight = 'bold', 'normal', 'bold', 'normal', 'bold' Color = $ColorInUse, 'None', 'CornflowerBlue', 'None', 'CornflowerBlue' } if ($Key -eq 'TargetOrganizationalUnit') { $newHTMLListItemSplat.NestedListItems = { New-HTMLList { foreach ($MoveKey in $MoveOnlyIf[$Key].Keys) { New-HTMLListItem -Text @( $MoveKey, " to ", $MoveOnlyIf[$Key][$MoveKey] ) -FontWeight 'bold', 'normal', 'bold', 'normal', 'bold' -Color $ColorInUse, 'None', 'CornflowerBlue', 'None', 'CornflowerBlue' } } } } New-HTMLListItem @newHTMLListItemSplat } } } else { New-HTMLText -Text "Computers will not be moved, as the move functionality was not enabled. " -FontWeight bold } } New-HTMLPanel { if ($Delete) { New-HTMLText -Text "Computers will be deleted only if: " -FontWeight bold New-HTMLList { foreach ($Key in $DeleteOnlyIf.Keys) { New-HTMLListItem -Text @( if ($null -eq $DeleteOnlyIf[$Key] -or $DeleteOnlyIf[$Key].Count -eq 0) { $($Key), " is ", 'Not Set' $ColorInUse = 'Cinnabar' } else { if ($Key -in 'LastLogonDateMoreThan', 'LastLogonDateOlderThan') { $($Key), " is ", $($DeleteOnlyIf[$Key]), " or ", "Never logged on" } elseif ($Key -in 'PasswordLastSetMoreThan', 'PasswordLastSetOlderThan') { $($Key), " is ", $($DeleteOnlyIf[$Key]), " or ", "Never changed" } elseif ($Key -in 'LastSeenAzureMoreThan', 'LastSeenIntuneMoreThan', 'LastSyncAzureMoreThan', 'LastContactJamfMoreThan') { $($Key), " is ", $($DeleteOnlyIf[$Key]), " or ", "Never synced/seen" } else { $($Key), " is ", $($DeleteOnlyIf[$Key]) } $ColorInUse = 'Apple' } ) -FontWeight bold, normal, bold, normal, bold -Color $ColorInUse, None, CornflowerBlue, None, CornflowerBlue } } } else { New-HTMLText -Text "Computers will not be deleted, as the delete functionality was not enabled. " -FontWeight bold } } } New-HTMLTable -DataTable $ComputersToProcess -Filtering -ScrollX { New-HTMLTableCondition -Name 'Action' -ComparisonType string -Value 'Delete' -BackgroundColor PinkLace New-HTMLTableCondition -Name 'Action' -ComparisonType string -Value 'Move' -BackgroundColor Yellow 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-HTMLTableCondition -Name 'Action' -ComparisonType string -Value 'ExcludedByFilter' -BackgroundColor LightBlue New-HTMLTableCondition -Name 'Action' -ComparisonType string -Value 'ExcludedBySetting' -BackgroundColor LightPink New-HTMLTableCondition -Name 'ProtectedFromAccidentalDeletion' -ComparisonType string -Value $false -BackgroundColor LightBlue -FailBackgroundColor Salmon } -WarningAction SilentlyContinue } try { if ($LogFile -and (Test-Path -LiteralPath $LogFile -ErrorAction Stop)) { $LogContent = Get-Content -Raw -LiteralPath $LogFile -ErrorAction Stop New-HTMLTab -Name 'Log' { New-HTMLCodeBlock -Code $LogContent -Style generic } } } catch { Write-Color -Text "[e] ", "Couldn't read the log file. Skipping adding log to HTML. Error: $($_.Exception.Message)" -Color Yellow, Red } } -FilePath $FilePath -Online:$Online.IsPresent -ShowHTML:$ShowHTML.IsPresent } function Request-ADComputersDelete { [cmdletBinding(SupportsShouldProcess)] param( [System.Collections.IDictionary] $Report, [switch] $ReportOnly, [switch] $WhatIfDelete, [int] $DeleteLimit, [System.Collections.IDictionary] $ProcessedComputers, [DateTime] $Today, [switch] $DontWriteToEventLog ) $CountDeleteLimit = 0 # :top means name of the loop, so we can break it :topLoop foreach ($Domain in $Report.Keys) { foreach ($Computer in $Report["$Domain"]['Computers']) { $Server = $Report["$Domain"]['Server'] if ($Computer.Action -ne 'Delete') { continue } 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 if (-not $DontWriteToEventLog) { Write-Event -ID 12 -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 if (-not $DontWriteToEventLog) { Write-Event -ID 12 -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.ActionComment = $_.Exception.Message } $Computer.ActionDate = $Today if ($WhatIfDelete.IsPresent) { $Computer.ActionStatus = 'WhatIf' } else { if ($Success) { # lets remove computer from $ProcessedComputers # but only if it's not WhatIf and only if it's successful $ComputerOnTheList = -join ($Computer.SamAccountName, "@", $Domain) $ProcessedComputers.Remove("$ComputerOnTheList") } $Computer.ActionStatus = $Success } # 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 } } } } } } function Request-ADComputersDisable { [cmdletbinding(SupportsShouldProcess)] param( [nullable[bool]] $Delete, [nullable[bool]] $Move, [nullable[bool]] $DisableAndMove, [System.Collections.IDictionary] $Report, [switch] $WhatIfDisable, [switch] $DisableModifyDescription, [switch] $DisableModifyAdminDescription, [int] $DisableLimit, [switch] $ReportOnly, [DateTime] $Today, [switch] $DontWriteToEventLog, [Object] $DisableMoveTargetOrganizationalUnit, [switch] $DoNotAddToPendingList, [ValidateSet( 'DisableAndMove', 'MoveAndDisable' )][string] $DisableAndMoveOrder = 'DisableAndMove' ) if ($DisableAndMove -and $DisableMoveTargetOrganizationalUnit) { if ($DisableMoveTargetOrganizationalUnit -is [System.Collections.IDictionary]) { $OrganizationalUnit = $DisableMoveTargetOrganizationalUnit } elseif ($DisableMoveTargetOrganizationalUnit -is [string]) { $DomainCN = ConvertFrom-DistinguishedName -DistinguishedName $DisableMoveTargetOrganizationalUnit -ToDomainCN $OrganizationalUnit = [ordered] @{ $DomainCN = $DisableMoveTargetOrganizationalUnit } } else { Write-Color -Text "[-] DisableMoveTargetOrganizationalUnit is not a string or hashtable. Skipping moving to proper OU." -Color Yellow, Red return } } $CountDisable = 0 # :top means name of the loop, so we can break it :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"]['Computers']) { $Server = $Report["$Domain"]['Server'] if ($Computer.Action -ne 'Disable') { continue } if ($ReportOnly) { $Computer } else { $Success = $true if ($DisableAndMoveOrder -eq 'DisableAndMove') { $Success = Disable-WinADComputer -Success $Success -WhatIfDisable:$WhatIfDisable -DontWriteToEventLog:$DontWriteToEventLog -Computer $Computer -Server $Server $Success = Move-WinADComputer -Success $Success -DisableAndMove $DisableAndMove -OrganizationalUnit $OrganizationalUnit -Computer $Computer -WhatIfDisable:$WhatIfDisable -DontWriteToEventLog:$DontWriteToEventLog -Server $Server } else { $Success = Move-WinADComputer -Success $Success -DisableAndMove $DisableAndMove -OrganizationalUnit $OrganizationalUnit -Computer $Computer -WhatIfDisable:$WhatIfDisable -DontWriteToEventLog:$DontWriteToEventLog -Server $Server $Success = Disable-WinADComputer -Success $Success -WhatIfDisable:$WhatIfDisable -DontWriteToEventLog:$DontWriteToEventLog -Computer $Computer -Server $Server } 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 { $Computer.ActionComment = $Computer.ActionComment + [System.Environment]::NewLine + $_.Exception.Message 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 { $Computer.ActionComment + [System.Environment]::NewLine + $_.Exception.Message 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 } # We add computer to pending list in all cases because otherwise we would be going in circles # if move or delete were not enabled # please use -DoNotAddToPendingList if you don't want to add computer to pending list if (-not $DoNotAddToPendingList) { $FullComputerName = -join ($Computer.SamAccountName, '@', $Domain) $ProcessedComputers[$FullComputerName] = $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 } } } } } } function Request-ADComputersMove { [cmdletBinding(SupportsShouldProcess)] param( [nullable[bool]] $Delete, [System.Collections.IDictionary] $Report, [switch] $ReportOnly, [switch] $WhatIfMove, [int] $MoveLimit, [System.Collections.IDictionary] $ProcessedComputers, [DateTime] $Today, [Object] $TargetOrganizationalUnit, [switch] $DontWriteToEventLog, [switch] $DoNotAddToPendingList ) if ($TargetOrganizationalUnit -is [System.Collections.IDictionary]) { $OrganizationalUnit = $TargetOrganizationalUnit } elseif ($TargetOrganizationalUnit -is [string]) { $DomainCN = ConvertFrom-DistinguishedName -DistinguishedName $TargetOrganizationalUnit -ToDomainCN $OrganizationalUnit = [ordered] @{ $DomainCN = $TargetOrganizationalUnit } } else { Write-Color -Text "[-] TargetOrganizationalUnit is not a string or hashtable. Skipping moving to proper OU." -Color Yellow, Red return } $CountMoveLimit = 0 # :top means name of the loop, so we can break it :topLoop foreach ($Domain in $Report.Keys) { foreach ($Computer in $Report["$Domain"]['Computers']) { $Server = $Report["$Domain"]['Server'] if ($Computer.Action -ne 'Move') { continue } if ($ReportOnly) { $Computer } else { if ($OrganizationalUnit[$Domain]) { # we check if the computer is already in the correct OU if ($Computer.OrganizationalUnit -eq $OrganizationalUnit[$Domain]) { # this shouldn't really happen as we should have filtered it out earlier } else { Write-Color -Text "[i] Moving 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 { $MovedObject = Move-ADObject -Identity $Computer.DistinguishedName -WhatIf:$WhatIfMove -Server $Server -ErrorAction Stop -Confirm:$false -TargetPath $OrganizationalUnit[$Domain] -PassThru $Computer.DistinguishedNameAfterMove = $MovedObject.DistinguishedName Write-Color -Text "[+] Moving computer ", $Computer.DistinguishedName, " (WhatIf: $($WhatIfMove.IsPresent)) successful." -Color Yellow, Green, Yellow if (-not $DontWriteToEventLog) { Write-Event -ID 11 -LogName 'Application' -EntryType Warning -Category 1000 -Source 'CleanupComputers' -Message "Moving computer $($Computer.SamAccountName) successful." -AdditionalFields @('Move', $Computer.SamAccountName, $Computer.DistinguishedName, $Computer.Enabled, $Computer.OperatingSystem, $Computer.LastLogonDate, $Computer.PasswordLastSet, $WhatIfMove) -WarningAction SilentlyContinue -WarningVariable warnings } foreach ($W in $Warnings) { Write-Color -Text "[-] ", "Warning: ", $W -Color Yellow, Cyan, Red } if (-not $Delete) { # lets remove computer from $ProcessedComputers # we only remove it if Delete is not part of the removal process and move is the last step if (-not $DoNotAddToPendingList) { $ComputerOnTheList = -join ($Computer.SamAccountName, "@", $Domain) $ProcessedComputers.Remove("$ComputerOnTheList") } } $Success = $true } catch { $Success = $false Write-Color -Text "[-] Moving computer ", $Computer.DistinguishedName, " (WhatIf: $($WhatIfMove.IsPresent)) failed. Error: $($_.Exception.Message)" -Color Yellow, Red, Yellow if (-not $DontWriteToEventLog) { Write-Event -ID 11 -LogName 'Application' -EntryType Error -Category 1000 -Source 'CleanupComputers' -Message "Moving computer $($Computer.SamAccountName) failed." -AdditionalFields @('Move', $Computer.SamAccountName, $Computer.DistinguishedName, $Computer.Enabled, $Computer.OperatingSystem, $Computer.LastLogonDate, $Computer.PasswordLastSet, $WhatIfMove, $($_.Exception.Message)) -WarningAction SilentlyContinue -WarningVariable warnings } foreach ($W in $Warnings) { Write-Color -Text "[-] ", "Warning: ", $W -Color Yellow, Cyan, Red } $Computer.ActionComment = $_.Exception.Message } $Computer.ActionDate = $Today if ($WhatIfMove.IsPresent) { $Computer.ActionStatus = 'WhatIf' } else { $Computer.ActionStatus = $Success } # return computer to $ReportMoved so we can see summary just in case $Computer $CountMoveLimit++ if ($MoveLimit) { if ($MoveLimit -eq $CountMoveLimit) { break topLoop # this breaks top loop } } } } else { Write-Color -Text "[-] Moving computer ", $Computer.SamAccountName, " failed. TargetOrganizationalUnit for domain $Domain not found." -Color Yellow, Red, Yellow if (-not $DontWriteToEventLog) { Write-Event -ID 11 -LogName 'Application' -EntryType Error -Category 1000 -Source 'CleanupComputers' -Message "Moving computer $($Computer.SamAccountName) failed." -AdditionalFields @('Move', $Computer.SamAccountName, $Computer.DistinguishedName, $Computer.Enabled, $Computer.OperatingSystem, $Computer.LastLogonDate, $Computer.PasswordLastSet, $WhatIfMove, "TargetOrganizationalUnit for domain $Domain not found.") -WarningAction SilentlyContinue -WarningVariable warnings } foreach ($W in $Warnings) { Write-Color -Text "[-] ", "Warning: ", $W -Color Yellow, Cyan, Red } $Computer.ActionComment = "TargetOrganizationalUnit for domain $Domain not found." $Computer.ActionDate = $Today $Computer.ActionStatus = $false # return computer to $ReportMoved so we can see summary just in case $Computer $CountMoveLimit++ if ($MoveLimit) { if ($MoveLimit -eq $CountMoveLimit) { break topLoop # this breaks top loop } } } } } } } function Set-LoggingCapabilities { [CmdletBinding()] param( [parameter()][string] $LogPath, [parameter()][string] $ScriptPath, [parameter(Mandatory)][int] $LogMaximum, [switch] $ShowTime, [string] $TimeFormat ) $Script:PSDefaultParameterValues = @{ "Write-Color:LogFile" = $LogPath "Write-Color:ShowTime" = if ($PSBoundParameters.ContainsKey('ShowTime')) { $ShowTime.IsPresent } else { $null } "Write-Color:TimeFormat" = $TimeFormat } if ($LogPath) { try { $FolderPath = [io.path]::GetDirectoryName($LogPath) if (-not (Test-Path -LiteralPath $FolderPath)) { $null = New-Item -Path $FolderPath -ItemType Directory -Force -WhatIf:$false } if ($LogMaximum -gt 0) { if ($ScriptPath) { $ScriptPathFolder = [io.path]::GetDirectoryName($ScriptPath) if ($ScriptPathFolder -eq $FolderPath) { Write-Color -Text '[i] ', "LogMaximum is set to ", $LogMaximum, " but log files are in the same folder as the script. Cleanup disabled." -Color Yellow, White, DarkCyan, White return } $CurrentLogs = Get-ChildItem -LiteralPath $FolderPath | Sort-Object -Property CreationTime -Descending | Select-Object -Skip $LogMaximum if ($CurrentLogs) { Write-Color -Text '[i] ', "Logs directory has more than ", $LogMaximum, " log files. Cleanup required..." -Color Yellow, DarkCyan, Red, DarkCyan foreach ($Log in $CurrentLogs) { try { Remove-Item -LiteralPath $Log.FullName -Confirm:$false -WhatIf:$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 } } } } else { Write-Color -Text '[i] ', "LogMaximum is set to ", $LogMaximum, " but no script path detected. Most likely running interactively. Cleanup disabled." -Color Yellow, White, DarkCyan, White } } else { Write-Color -Text '[i] ', "LogMaximum is set to 0 (Unlimited). No log files will be deleted." -Color Yellow, DarkCyan } } catch { Write-Color -Text "[e] ", "Couldn't create the log directory. Error: $($_.Exception.Message)" -Color Yellow, Red $Script:PSDefaultParameterValues["Write-Color:LogFile"] = $null } } else { $Script:PSDefaultParameterValues["Write-Color:LogFile"] = $null } Remove-EmptyValue -Hashtable $Script:PSDefaultParameterValues } function Set-ReportingCapabilities { [CmdletBinding()] param( [string] $ReportPath, [int] $ReportMaximum ) if ($ReportPath) { try { $FolderPath = [io.path]::GetDirectoryName($ReportPath) if (-not (Test-Path -LiteralPath $FolderPath -ErrorAction Stop)) { $null = New-Item -Path $FolderPath -ItemType Directory -Force -WhatIf:$false -ErrorAction Stop } if ($ReportMaximum -gt 0) { $CurrentLogs = Get-ChildItem -LiteralPath $FolderPath -ErrorAction Stop | Sort-Object -Property CreationTime -Descending | Select-Object -Skip $ReportMaximum if ($CurrentLogs) { Write-Color -Text '[i] ', "Reporting directory has more than ", $ReportMaximum, " report files. Cleanup required..." -Color Yellow, DarkCyan, Red, DarkCyan foreach ($Report in $CurrentLogs) { try { Remove-Item -LiteralPath $Report.FullName -Confirm:$false -WhatIf:$false Write-Color -Text '[+] ', "Deleted ", "$($Report.FullName)" -Color Yellow, White, Green } catch { Write-Color -Text '[-] ', "Couldn't delete report file $($Report.FullName). Error: ', "$($_.Exception.Message) -Color Yellow, White, Red } } } } else { Write-Color -Text '[i] ', "ReportMaximum is set to 0 (Unlimited). No report files will be deleted." -Color Yellow, DarkCyan } } catch { Write-Color -Text "[e] ", "Couldn't create the reporting directory. Error: $($_.Exception.Message)" -Color Yellow, 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 Forest Forest to use when connecting to Active Directory. .PARAMETER IncludeDomains List of domains to include in the process. .PARAMETER ExcludeDomains List of domains to exclude from the process. .PARAMETER Disable Enable the disable process, meaning the computers that meet the criteria will be disabled. .PARAMETER DisableAndMove Enable the disable and move process, meaning the computers that meet the criteria will be disabled and moved (in that order). This is useful if you want to disable computers first and then move them to a different OU right after. It's integral part of disabling process. If you want Move as a separate process, use Move settings. .PARAMETER DisableAndMoveOrder Order of the Disable and Move process. Default is 'DisableAndMove'. If you want to move computers first and then disable them, use 'MoveAndDisable'. .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 DisableRequireWhenCreatedMoreThan Disable computer only if it was created more than the specified number of days ago. .PARAMETER DisablePasswordLastSetOlderThan Disable computer only if it has a PasswordLastSet that is older than the specified date. .PARAMETER DisableLastLogonDateOlderThan Disable computer only if it has a LastLogonDate that is older than the specified date. .PARAMETER DisableLastSeenAzureMoreThan Disable computer only if it Last Seen in Azure is more than the specified number of days. Please note that you need to make connection to Azure using Connect-MgGraph with proper permissions first. Additionally you will need GraphEssentials PowerShell Module installed. .PARAMETER DisableLastSeenIntuneMoreThan Disable computer only if it Last Seen in Intune is more than the specified number of days. Please note that you need to make connection to Intune using Connect-MgGraph with proper permissions first. Additionally you will need GraphEssentials PowerShell Module installed. .PARAMETER DisableLastSyncAzureMoreThan Disable computer only if it Last Synced in Azure is more than the specified number of days. Please note that you need to make connection to Azure AD using Connect-MgGraph with proper permissions first. Additionally you will need GraphEssentials PowerShell Module installed. .PARAMETER DisableLastContactJamfMoreThan Disable computer only if it Last Contacted in Jamf is more than the specified number of days. Please note that you need to make connection to Jamf using PowerJamf PowerShell Module first. Additionally you will need PowerJamf PowerShell Module installed. .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 DisableExcludeServicePrincipalName Disable computer only if it's not on the list of excluded ServicePrincipalNames. You can also specify multiple ServicePrincipalNames by providing an array of entries. It's using the -like operator, so you can use wildcards. .PARAMETER DisableIncludeServicePrincipalName Disable computer only if it's on the list of included ServicePrincipalNames. You can also specify multiple ServicePrincipalNames by providing an array of entries. It's using the -like operator, so you can use wildcards. .PARAMETER DisableMoveTargetOrganizationalUnit Move computer to the specified OU after it's disabled. It can take a string with DistinguishedName, or hashtable with key being the domain, and value being the DistinguishedName. If you have a forest with multiple domains and want to move computers to different OUs based on their domain, you can use hashtable. .PARAMETER DisableDoNotAddToPendingList By default, computers that are disabled are added to the list of computers that will be actioned later (moved/deleted). If you want to disable computers, but not add them to the list of computers that will be actioned later (aka pending list), use this switch. .PARAMETER Delete Enable the delete process, meaning the computers that meet the criteria will be deleted. .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 DeleteRequireWhenCreatedMoreThan Delete computer only if it was created more than the specified number of days ago. .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 DeletePasswordLastSetOlderThan Delete computer only if it has a PasswordLastSet that is older than the specified date. .PARAMETER DeleteLastLogonDateOlderThan Delete computer only if it has a LastLogonDate that is older than the specified date. .PARAMETER DeleteLastSeenAzureMoreThan Delete computer only if it Last Seen in Azure is more than the specified number of days. Please note that you need to make connection to Azure using Connect-MgGraph with proper permissions first. Additionally yopu will need GraphEssentials PowerShell Module installed. .PARAMETER DeleteLastSeenIntuneMoreThan Delete computer only if it Last Seen in Intune is more than the specified number of days. Please note that you need to make connection to Intune using Connect-MgGraph with proper permissions first. Additionally you will need GraphEssentials PowerShell Module installed. .PARAMETER DeleteLastSyncAzureMoreThan Delete computer only if it Last Synced in Azure is more than the specified number of days. Please note that you need to make connection to Azure AD using Connect-MgGraph with proper permissions first. Additionally you will need GraphEssentials PowerShell Module installed. .PARAMETER DeleteLastContactJamfMoreThan Delete computer only if it Last Contacted in Jamf is more than the specified number of days. Please note that you need to make connection to Jamf using PowerJamf PowerShell Module first. Additionally you will need PowerJamf PowerShell Module installed. .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 DeleteExcludeServicePrincipalName Delete computer only if it's not on the list of excluded ServicePrincipalNames. You can also specify multiple ServicePrincipalNames by providing an array of entries. It's using the -like operator, so you can use wildcards. .PARAMETER DeleteIncludeServicePrincipalName Delete computer only if it's on the list of included ServicePrincipalNames. You can also specify multiple ServicePrincipalNames by providing an array of entries. 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 MoveIsEnabled Move computer only if it's Enabled or only if it's Disabled. By default it will try to Move all computers that are either disabled or enabled. .PARAMETER MoveNoServicePrincipalName Move 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 MoveLastLogonDateMoreThan Move computer only if it has a LastLogonDate that is more than the specified number of days. .PARAMETER MovePasswordLastSetMoreThan Move computer only if it has a PasswordLastSet that is more than the specified number of days. .PARAMETER MoveListProcessedMoreThan Move 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 Move 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 MovePasswordLastSetOlderThan Move computer only if it has a PasswordLastSet that is older than the specified date. .PARAMETER MoveRequireWhenCreatedMoreThan Move computer only if it was created more than the specified number of days ago. .PARAMETER MoveLastLogonDateOlderThan Move computer only if it has a LastLogonDate that is older than the specified date. .PARAMETER MoveLastSeenAzureMoreThan Move computer only if it Last Seen in Azure is more than the specified number of days. Please note that you need to make connection to Azure using Connect-MgGraph with proper permissions first. Additionally yopu will need GraphEssentials PowerShell Module installed. .PARAMETER MoveLastSeenIntuneMoreThan Move computer only if it Last Seen in Intune is more than the specified number of days. Please note that you need to make connection to Intune using Connect-MgGraph with proper permissions first. Additionally you will need GraphEssentials PowerShell Module installed. .PARAMETER MoveLastSyncAzureMoreThan Move computer only if it Last Synced in Azure is more than the specified number of days. Please note that you need to make connection to Azure AD using Connect-MgGraph with proper permissions first. Additionally you will need GraphEssentials PowerShell Module installed. .PARAMETER MoveLastContactJamfMoreThan Move computer only if it Last Contacted in Jamf is more than the specified number of days. Please note that you need to make connection to Jamf using PowerJamf PowerShell Module first. Additionally you will need PowerJamf PowerShell Module installed. .PARAMETER MoveExcludeSystems Move 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 MoveIncludeSystems Move 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 MoveExcludeServicePrincipalName Move computer only if it's not on the list of excluded ServicePrincipalNames. You can also specify multiple ServicePrincipalNames by providing an array of entries. It's using the -like operator, so you can use wildcards. .PARAMETER MoveIncludeServicePrincipalName Move computer only if it's on the list of included ServicePrincipalNames. You can also specify multiple ServicePrincipalNames by providing an array of entries. It's using the -like operator, so you can use wildcards. .PARAMETER MoveTargetOrganizationalUnit Target Organizational Unit where the computer will be moved as part of Move action. It can take a string with DistinguishedName, or hashtable with key being the domain, and value being the DistinguishedName. If you have a forest with multiple domains and want to move computers to different OUs based on their domain, you can use hashtable. .PARAMETER MoveLimit Limit the number of computers that will be moved. 0 = unlimited. Default is 1. This is to prevent accidental move of all computers that meet the criteria. Adjust the limit to your needs. .PARAMETER MoveDoNotAddToPendingList By default the script will add computers that are moved to a list of computers that will be actioned later (deleted). If you want to move computers, but not add them to the list of computers that will be action later (aka pending list), use this switch. .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 SearchBase SearchBase to use when searching for computers in Get-ADComputer cmdlet. Default is not set. It will search the whole domain. You can provide a string or hashtable of domains with their SearchBase. .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 ReportMaximum Maximum number of reports to keep. Default is Unlimited (0). .PARAMETER WhatIfDelete WhatIf parameter for the Delete process. It's not nessessary to specify this parameter if you use WhatIf parameter which applies to all 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 all processes. .PARAMETER WhatIfMove WhatIf parameter for the Move process. It's not nessessary to specify this parameter if you use WhatIf parameter which applies to all 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 LogShowTime Show time in the log file. Default is $false .PARAMETER LogTimeFormat Time format to use when logging to file. Default is 'yyyy-MM-dd HH:mm:ss' .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 .PARAMETER SafetyADLimit Minimum number of computers that must be returned by AD cmdlets to proceed with the process. Default is not to check. This is there to prevent accidental deletion of all computers if there is a problem with AD. .PARAMETER SafetyAzureADLimit Minimum number of computers that must be returned by AzureAD cmdlets to proceed with the process. Default is not to check. This is there to prevent accidental deletion of all computers if there is a problem with AzureAD. It only applies if Azure AD parameters are used. .PARAMETER SafetyIntuneLimit Minimum number of computers that must be returned by Intune cmdlets to proceed with the process. Default is not to check. This is there to prevent accidental deletion of all computers if there is a problem with Intune. It only applies if Intune parameters are used. .PARAMETER SafetyJamfLimit Minimum number of computers that must be returned by Jamf cmdlets to proceed with the process. Default is not to check. This is there to prevent accidental deletion of all computers if there is a problem with Jamf. It only applies if Jamf parameters are used. .PARAMETER DontWriteToEventLog By default the function will write to the event log making sure the cleanup process is logged. This parameter will prevent the function from writing to the event log. .PARAMETER TargetServers Target servers to use when connecting to Active Directory. It can take a string with server name, or hashtable with key being the domain, and value being the server name. If you have a forest with multiple domains and want to use different servers for different domains, you can use hashtable. It will use the default server if no server is provided for a domain, which is default approach. This feature is only nessecary if you have specific requirments per domain/forest rather than using the automatic detection. .EXAMPLE $Output = Invoke-ADComputersCleanup -DeleteIsEnabled $false -Delete -WhatIfDelete -ShowHTML -ReportOnly -LogPath $PSScriptRoot\Logs\DeleteComputers_$((Get-Date).ToString('yyyy-MM-dd_HH_mm_ss')).log -ReportPath $PSScriptRoot\Reports\DeleteComputers_$((Get-Date).ToString('yyyy-MM-dd_HH_mm_ss')).html $Output .EXAMPLE $Output = Invoke-ADComputersCleanup -DeleteListProcessedMoreThan 100 -Disable -DeleteIsEnabled $false -Delete -WhatIfDelete -ShowHTML -ReportOnly -LogPath $PSScriptRoot\Logs\DeleteComputers_$((Get-Date).ToString('yyyy-MM-dd_HH_mm_ss')).log -ReportPath $PSScriptRoot\Reports\DeleteComputers_$((Get-Date).ToString('yyyy-MM-dd_HH_mm_ss')).html $Output .EXAMPLE # this is a fresh run and it will provide report only according to it's defaults $Output = Invoke-ADComputersCleanup -WhatIf -ReportOnly -Disable -Delete -ShowHTML $Output .EXAMPLE # this is a fresh run and it will try to disable computers according to it's defaults # read documentation to understand what it does $Output = Invoke-ADComputersCleanup -Disable -ShowHTML -WhatIfDisable -WhatIfDelete -Delete $Output .EXAMPLE # this is a fresh run and it will try to delete computers according to it's defaults # read documentation to understand what it does $Output = Invoke-ADComputersCleanup -Delete -WhatIfDelete -ShowHTML -LogPath $PSScriptRoot\Logs\DeleteComputers_$((Get-Date).ToString('yyyy-MM-dd_HH_mm_ss')).log -ReportPath $PSScriptRoot\Reports\DeleteComputers_$((Get-Date).ToString('yyyy-MM-dd_HH_mm_ss')).html $Output .EXAMPLE # Run the script $Configuration = @{ Disable = $true DisableNoServicePrincipalName = $null DisableIsEnabled = $true DisableLastLogonDateMoreThan = 90 DisablePasswordLastSetMoreThan = 90 DisableExcludeSystems = @( # 'Windows Server*' ) DisableIncludeSystems = @() DisableLimit = 2 # 0 means unlimited, ignored for reports DisableModifyDescription = $false DisableAdminModifyDescription = $true Delete = $true DeleteIsEnabled = $false DeleteNoServicePrincipalName = $null DeleteLastLogonDateMoreThan = 180 DeletePasswordLastSetMoreThan = 180 DeleteListProcessedMoreThan = 90 # 90 days since computer was added to list DeleteExcludeSystems = @( # 'Windows Server*' ) DeleteIncludeSystems = @( ) DeleteLimit = 2 # 0 means unlimited, ignored for reports Exclusions = @( '*OU=Domain Controllers*' '*OU=Servers,OU=Production*' 'EVOMONSTER$' 'EVOMONSTER.AD.EVOTEC.XYZ' ) Filter = '*' WhatIfDisable = $true WhatIfDelete = $true LogPath = "$PSScriptRoot\Logs\DeleteComputers_$((Get-Date).ToString('yyyy-MM-dd_HH_mm_ss')).log" DataStorePath = "$PSScriptRoot\DeleteComputers_ListProcessed.xml" ReportPath = "$PSScriptRoot\Reports\DeleteComputers_$((Get-Date).ToString('yyyy-MM-dd_HH_mm_ss')).html" ShowHTML = $true } # Run one time as admin: Write-Event -ID 10 -LogName 'Application' -EntryType Information -Category 0 -Message 'Initialize' -Source 'CleanupComputers' $Output = Invoke-ADComputersCleanup @Configuration $Output .NOTES General notes #> [CmdletBinding(SupportsShouldProcess)] param( [string] $Forest, [alias('Domain')][string[]] $IncludeDomains, [string[]] $ExcludeDomains, # Disable options [switch] $Disable, [switch] $DisableAndMove, [ValidateSet( 'DisableAndMove', 'MoveAndDisable' )][string] $DisableAndMoveOrder = 'DisableAndMove', [nullable[bool]] $DisableIsEnabled, [nullable[bool]] $DisableNoServicePrincipalName, [nullable[int]] $DisableLastLogonDateMoreThan = 180, [nullable[int]] $DisablePasswordLastSetMoreThan = 180, [nullable[int]] $DisableRequireWhenCreatedMoreThan = 90, [nullable[DateTime]] $DisablePasswordLastSetOlderThan, [nullable[DateTime]] $DisableLastLogonDateOlderThan, [nullable[int]] $DisableLastSeenAzureMoreThan, [nullable[int]] $DisableLastSeenIntuneMoreThan, [nullable[int]] $DisableLastSyncAzureMoreThan, [nullable[int]] $DisableLastContactJamfMoreThan, [Array] $DisableExcludeSystems = @(), [Array] $DisableIncludeSystems = @(), [Array] $DisableExcludeServicePrincipalName = @(), [Array] $DisableIncludeServicePrincipalName = @(), [int] $DisableLimit = 1, # 0 = unlimited [Object] $DisableMoveTargetOrganizationalUnit, [switch] $DisableDoNotAddToPendingList, # Move options [switch] $Move, [nullable[bool]] $MoveIsEnabled, [nullable[bool]] $MoveNoServicePrincipalName, [nullable[int]] $MoveLastLogonDateMoreThan, [nullable[int]] $MovePasswordLastSetMoreThan, [nullable[int]] $MoveListProcessedMoreThan, [nullable[int]] $MoveRequireWhenCreatedMoreThan, [nullable[DateTime]] $MovePasswordLastSetOlderThan, [nullable[DateTime]] $MoveLastLogonDateOlderThan, [nullable[int]] $MoveLastSeenAzureMoreThan, [nullable[int]] $MoveLastSeenIntuneMoreThan, [nullable[int]] $MoveLastSyncAzureMoreThan, [nullable[int]] $MoveLastContactJamfMoreThan, [Array] $MoveExcludeSystems = @(), [Array] $MoveIncludeSystems = @(), [Array] $MoveExcludeServicePrincipalName = @(), [Array] $MoveIncludeServicePrincipalName = @(), [int] $MoveLimit = 1, # 0 = unlimited [Object] $MoveTargetOrganizationalUnit, # Delete options [switch] $Delete, [nullable[bool]] $DeleteIsEnabled, [nullable[bool]] $DeleteNoServicePrincipalName, [nullable[int]] $DeleteLastLogonDateMoreThan = 180, [nullable[int]] $DeletePasswordLastSetMoreThan = 180, [nullable[int]] $DeleteRequireWhenCreatedMoreThan = 90, [nullable[int]] $DeleteListProcessedMoreThan, [nullable[DateTime]] $DeletePasswordLastSetOlderThan, [nullable[DateTime]] $DeleteLastLogonDateOlderThan, [nullable[int]] $DeleteLastSeenAzureMoreThan, [nullable[int]] $DeleteLastSeenIntuneMoreThan, [nullable[int]] $DeleteLastSyncAzureMoreThan, [nullable[int]] $DeleteLastContactJamfMoreThan, [Array] $DeleteExcludeSystems = @(), [Array] $DeleteIncludeSystems = @(), [Array] $DeleteExcludeServicePrincipalName = @(), [Array] $DeleteIncludeServicePrincipalName = @(), [int] $DeleteLimit = 1, # 0 = unlimited # General options [Array] $Exclusions = @( # default globalexclusions '*OU=Domain Controllers*' ), [switch] $DisableModifyDescription, [alias('DisableAdminModifyDescription')][switch] $DisableModifyAdminDescription, [object] $Filter = '*', [object] $SearchBase, [string] $DataStorePath, [switch] $ReportOnly, [int] $ReportMaximum, [switch] $WhatIfDelete, [switch] $WhatIfDisable, [switch] $WhatIfMove, [string] $LogPath, [int] $LogMaximum = 5, [switch] $LogShowTime, [string] $LogTimeFormat, [switch] $Suppress, [switch] $ShowHTML, [switch] $Online, [string] $ReportPath, [nullable[int]] $SafetyADLimit, [nullable[int]] $SafetyAzureADLimit, [nullable[int]] $SafetyIntuneLimit, [nullable[int]] $SafetyJamfLimit, [switch] $DontWriteToEventLog, [Object] $TargetServers ) # we will use it to check for intune/azuread/jamf functionality $Script:CleanupOptions = [ordered] @{} # 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 -LogPath $LogPath -LogMaximum $LogMaximum -ShowTime:$LogShowTime -TimeFormat $LogTimeFormat -ScriptPath $MyInvocation.ScriptName # prepare configuration $DisableOnlyIf = [ordered] @{ # Active directory IsEnabled = $DisableIsEnabled NoServicePrincipalName = $DisableNoServicePrincipalName LastLogonDateMoreThan = $DisableLastLogonDateMoreThan PasswordLastSetMoreThan = $DisablePasswordLastSetMoreThan ExcludeSystems = $DisableExcludeSystems IncludeSystems = $DisableIncludeSystems ExcludeServicePrincipalName = $DisableExcludeServicePrincipalName IncludeServicePrincipalName = $DisableIncludeServicePrincipalName PasswordLastSetOlderThan = $DisablePasswordLastSetOlderThan LastLogonDateOlderThan = $DisableLastLogonDateOlderThan # Intune LastSeenIntuneMoreThan = $DisableLastSeenIntuneMoreThan # Azure LastSyncAzureMoreThan = $DisableLastSyncAzureMoreThan LastSeenAzureMoreThan = $DisableLastSeenAzureMoreThan # Jamf LastContactJamfMoreThan = $DisableLastContactJamfMoreThan DoNotAddToPendingList = $DisableDoNotAddToPendingList MoveTargetOrganizationalUnit = $DisableMoveTargetOrganizationalUnit DisableAndMove = $DisableAndMove.IsPresent RequireWhenCreatedMoreThan = $DisableRequireWhenCreatedMoreThan } $MoveOnlyIf = [ordered] @{ # Active directory IsEnabled = $MoveIsEnabled NoServicePrincipalName = $MoveNoServicePrincipalName LastLogonDateMoreThan = $MoveLastLogonDateMoreThan PasswordLastSetMoreThan = $MovePasswordLastSetMoreThan ListProcessedMoreThan = $MoveListProcessedMoreThan ExcludeSystems = $MoveExcludeSystems IncludeSystems = $MoveIncludeSystems ExcludeServicePrincipalName = $MoveExcludeServicePrincipalName IncludeServicePrincipalName = $MoveIncludeServicePrincipalName PasswordLastSetOlderThan = $MovePasswordLastSetOlderThan LastLogonDateOlderThan = $MoveLastLogonDateOlderThan # Intune LastSeenIntuneMoreThan = $MoveLastSeenIntuneMoreThan # Azure LastSeenAzureMoreThan = $MoveLastSeenAzureMoreThan LastSyncAzureMoreThan = $MoveLastSyncAzureMoreThan # Jamf LastContactJamfMoreThan = $MoveLastContactJamfMoreThan # special option for move only TargetOrganizationalUnit = $MoveTargetOrganizationalUnit DoNotAddToPendingList = $MoveDoNotAddToPendingList #MoveTargetOrganizationalUnit = $MoveTargetOrganizationalUnit RequireWhenCreatedMoreThan = $MoveRequireWhenCreatedMoreThan } $DeleteOnlyIf = [ordered] @{ # Active directory IsEnabled = $DeleteIsEnabled NoServicePrincipalName = $DeleteNoServicePrincipalName LastLogonDateMoreThan = $DeleteLastLogonDateMoreThan PasswordLastSetMoreThan = $DeletePasswordLastSetMoreThan ListProcessedMoreThan = $DeleteListProcessedMoreThan ExcludeSystems = $DeleteExcludeSystems IncludeSystems = $DeleteIncludeSystems ExcludeServicePrincipalName = $DeleteExcludeServicePrincipalName IncludeServicePrincipalName = $DeleteIncludeServicePrincipalName PasswordLastSetOlderThan = $DeletePasswordLastSetOlderThan LastLogonDateOlderThan = $DeleteLastLogonDateOlderThan # Intune LastSeenIntuneMoreThan = $DeleteLastSeenIntuneMoreThan # Azure LastSeenAzureMoreThan = $DeleteLastSeenAzureMoreThan LastSyncAzureMoreThan = $DeleteLastSyncAzureMoreThan # Jamf LastContactJamfMoreThan = $DeleteLastContactJamfMoreThan RequireWhenCreatedMoreThan = $DeleteRequireWhenCreatedMoreThan } if (-not $DataStorePath) { $DataStorePath = $($MyInvocation.PSScriptRoot) + '\ProcessedComputers.xml' } if (-not $ReportPath) { $ReportPath = $($MyInvocation.PSScriptRoot) + '\ProcessedComputers.html' } # lets create report path, reporting is enabled by default Set-ReportingCapabilities -ReportPath $ReportPath -ReportMaximum $ReportMaximum $Success = Assert-InitialSettings -DisableOnlyIf $DisableOnlyIf -MoveOnlyIf $MoveOnlyIf -DeleteOnlyIf $DeleteOnlyIf if ($Success -contains $false) { return } $Today = Get-Date $Properties = 'DistinguishedName', 'DNSHostName', 'SamAccountName', 'Enabled', 'OperatingSystem', 'OperatingSystemVersion', 'LastLogonDate', 'PasswordLastSet', 'PasswordExpired', 'servicePrincipalName', 'logonCount', 'ManagedBy', 'Description', 'WhenCreated', 'WhenChanged', 'ProtectedFromAccidentalDeletion' $Export = [ordered] @{ Version = Get-GitHubVersion -Cmdlet 'Invoke-ADComputersCleanup' -RepositoryOwner 'evotecit' -RepositoryName 'CleanupMonster' CurrentRun = $null History = $null PendingDeletion = $null } Write-Color '[i] ', "[CleanupMonster] ", '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 { $ForestInformation = Get-WinADForestDetails -PreferWritable -Forest $Forest -Extended -IncludeDomains $IncludeDomains -ExcludeDomains $ExcludeDomains } catch { Write-Color -Text "[i] ", "Couldn't get forest. Terminating. Lack of domain contact? Error: $($_.Exception.Message)." -Color Yellow, Red return } if (-not $ReportOnly) { $ProcessedComputers = Import-ComputersData -Export $Export -DataStorePath $DataStorePath if ($ProcessedComputers -eq $false) { return } Write-Color -Text "[i] ", "Loaded ", $($ProcessedComputers.Count), " computers from $($DataStorePath) and added to pending list of computers." -Color Yellow, White, Green, White } if (-not $Disable -and -not $Delete) { Write-Color -Text "[i] ", "No action can be taken. You need to enable Disable or/and Delete feature to have any action." -Color Yellow, Red return } $Report = [ordered] @{} $getInitialGraphComputersSplat = [ordered] @{ SafetyAzureADLimit = $SafetyAzureADLimit SafetyIntuneLimit = $SafetyIntuneLimit DeleteLastSeenAzureMoreThan = $DeleteLastSeenAzureMoreThan DeleteLastSeenIntuneMoreThan = $DeleteLastSeenIntuneMoreThan DeleteLastSyncAzureMoreThan = $DeleteLastSyncAzureMoreThan DisableLastSeenAzureMoreThan = $DisableLastSeenAzureMoreThan DisableLastSeenIntuneMoreThan = $DisableLastSeenIntuneMoreThan DisableLastSyncAzureMoreThan = $DisableLastSyncAzureMoreThan MoveLastSeenAzureMoreThan = $MoveLastSeenAzureMoreThan MoveLastSeenIntuneMoreThan = $MoveLastSeenIntuneMoreThan MoveLastSyncAzureMoreThan = $MoveLastSyncAzureMoreThan } Remove-EmptyValue -Hashtable $getInitialGraphComputersSplat $AzureInformationCache = Get-InitialGraphComputers @getInitialGraphComputersSplat if ($AzureInformationCache -eq $false) { return } $getInitialJamf = @{ DisableLastContactJamfMoreThan = $DisableLastContactJamfMoreThan DeleteLastContactJamfMoreThan = $DeleteLastContactJamfMoreThan MoveLastContactJamfMoreThan = $MoveLastContactJamfMoreThan SafetyJamfLimit = $SafetyJamfLimit } Remove-EmptyValue -Hashtable $getInitialJamf $JamfInformationCache = Get-InitialJamfComputers @getInitialJamf if ($JamfInformationCache -eq $false) { return } $SplatADComputers = [ordered] @{ Report = $Report ForestInformation = $ForestInformation Filter = $Filter SearchBase = $SearchBase Properties = $Properties Disable = $Disable Delete = $Delete Move = $Move DisableOnlyIf = $DisableOnlyIf DeleteOnlyIf = $DeleteOnlyIf MoveOnlyIf = $MoveOnlyIf Exclusions = $Exclusions ProcessedComputers = $ProcessedComputers SafetyADLimit = $SafetyADLimit AzureInformationCache = $AzureInformationCache JamfInformationCache = $JamfInformationCache TargetServers = $TargetServers } $AllComputers = Get-InitialADComputers @SplatADComputers if ($AllComputers -eq $false) { return } foreach ($Domain in $Report.Keys) { if ($Disable -or $DisableAndMove) { if ($DisableLimit -eq 0) { $DisableLimitText = 'Unlimited' } else { $DisableLimitText = $DisableLimit } Write-Color "[i] ", "Computers to be disabled for domain $Domain`: ", $Report["$Domain"]['ComputersToBeDisabled'], ". Current disable limit: ", $DisableLimitText -Color Yellow, Cyan, Green, Cyan, Yellow } if ($Move) { if ($MoveLimit -eq 0) { $MoveLimitText = 'Unlimited' } else { $MoveLimitText = $MoveLimit } Write-Color "[i] ", "Computers to be moved for domain $Domain`: ", $Report["$Domain"]['ComputersToBeMoved'], ". Current move limit: ", $MoveLimitText -Color Yellow, Cyan, Green, Cyan, Yellow } if ($Delete) { if ($DeleteLimit -eq 0) { $DeleteLimitText = 'Unlimited' } else { $DeleteLimitText = $DeleteLimit } Write-Color "[i] ", "Computers to be deleted for domain $Domain`: ", $Report["$Domain"]['ComputersToBeDeleted'], ". Current delete limit: ", $DeleteLimitText -Color Yellow, Cyan, Green, Cyan, Yellow } } if ($Disable -or $DisableAndMove) { $requestADComputersDisableSplat = @{ # those 2 are added only to make sure we don't add to processing list # if there is no process later on Delete = $Delete Move = $Move # we can disable and move on one go DisableAndMove = $DisableAndMove Report = $Report WhatIfDisable = $WhatIfDisable WhatIf = $WhatIfPreference DisableModifyDescription = $DisableModifyDescription.IsPresent DisableModifyAdminDescription = $DisableModifyAdminDescription.IsPresent DisableLimit = $DisableLimit ReportOnly = $ReportOnly Today = $Today DontWriteToEventLog = $DontWriteToEventLog DisableMoveTargetOrganizationalUnit = $DisableMoveTargetOrganizationalUnit DoNotAddToPendingList = $DisableDoNotAddToPendingList DisableAndMoveOrder = $DisableAndMoveOrder } [Array] $ReportDisabled = Request-ADComputersDisable @requestADComputersDisableSplat } if ($Move) { $requestADComputersMoveSplat = @{ Report = $Report WhatIfMove = $WhatIfMove WhatIf = $WhatIfPreference MoveLimit = $MoveLimit ReportOnly = $ReportOnly Today = $Today ProcessedComputers = $ProcessedComputers TargetOrganizationalUnit = $MoveTargetOrganizationalUnit DontWriteToEventLog = $DontWriteToEventLog Delete = $Delete DoNotAddToPendingList = $MoveDoNotAddToPendingList } [Array] $ReportMoved = Request-ADComputersMove @requestADComputersMoveSplat } if ($Delete) { $requestADComputersDeleteSplat = @{ Report = $Report WhatIfDelete = $WhatIfDelete WhatIf = $WhatIfPreference DeleteLimit = $DeleteLimit ReportOnly = $ReportOnly Today = $Today ProcessedComputers = $ProcessedComputers DontWriteToEventLog = $DontWriteToEventLog } [Array] $ReportDeleted = Request-ADComputersDelete @requestADComputersDeleteSplat } Write-Color "[i] ", "Cleanup process for processed computers that no longer exists in AD" -Color Yellow, Green foreach ($FullName in [string[]] $ProcessedComputers.Keys) { if (-not $AllComputers["$($FullName)"]) { Write-Color -Text "[*] Removing computer from pending list ", $ProcessedComputers[$FullName].SamAccountName, " ($($ProcessedComputers[$FullName].DistinguishedName))" -Color Yellow, Green, Yellow $ProcessedComputers.Remove("$($FullName)") } } # Building up summary $Export.PendingDeletion = $ProcessedComputers $Export.CurrentRun = @( if ($ReportDisabled.Count -gt 0) { $ReportDisabled } if ($ReportMoved.Count -gt 0) { $ReportMoved } if ($ReportDeleted.Count -gt 0) { $ReportDeleted } ) $Export.History = @( if ($Export.History) { $Export.History } if ($ReportDisabled.Count -gt 0) { $ReportDisabled } if ($ReportMoved.Count -gt 0) { $ReportMoved } if ($ReportDeleted.Count -gt 0) { $ReportDeleted } ) Write-Color "[i] ", "Exporting Processed List" -Color Yellow, Magenta if (-not $ReportOnly) { try { $Export | Export-Clixml -LiteralPath $DataStorePath -Encoding Unicode -WhatIf:$false -ErrorAction Stop } catch { Write-Color -Text "[-] Exporting Processed List failed. Error: $($_.Exception.Message)" -Color Yellow, Red } } Write-Color -Text "[i] ", "Summary of cleaning up stale computers" -Color Yellow, Cyan foreach ($Domain in $Report.Keys) { if ($Disable -or $DisableAndMove) { Write-Color -Text "[i] ", "Computers to be disabled for domain $Domain`: ", $Report["$Domain"]['ComputersToBeDisabled'] -Color Yellow, Cyan, Green } if ($Move) { Write-Color -Text "[i] ", "Computers to be moved for domain $Domain`: ", $Report["$Domain"]['ComputersToBeMoved'] -Color Yellow, Cyan, Green } if ($Delete) { Write-Color -Text "[i] ", "Computers to be deleted for domain $Domain`: ", $Report["$Domain"]['ComputersToBeDeleted'] -Color Yellow, Cyan, Green } } if (-not $ReportOnly) { Write-Color -Text "[i] ", "Computers on pending list`: ", $Export['PendingDeletion'].Count -Color Yellow, Cyan, Green } if (($Disable -or $DisableAndMove) -and -not $ReportOnly) { Write-Color -Text "[i] ", "Computers disabled in this run`: ", $ReportDisabled.Count -Color Yellow, Cyan, Green } if ($Move -and -not $ReportOnly) { Write-Color -Text "[i] ", "Computers moved in this run`: ", $ReportMoved.Count -Color Yellow, Cyan, Green } if ($Delete -and -not $ReportOnly) { Write-Color -Text "[i] ", "Computers deleted in this run`: ", $ReportDeleted.Count -Color Yellow, Cyan, Green } if ($Export -and $ReportPath) { [Array] $ComputersToProcess = foreach ($Domain in $Report.Keys) { if ($Report["$Domain"]['Computers'].Count -gt 0) { $Report["$Domain"]['Computers'] } } Write-Color -Text "[i] ", "Computers to be processed for HTML report`: ", $ComputersToProcess.Count -Color Yellow, Cyan, Green $Export.Statistics = New-ADComputersStatistics -ComputersToProcess $ComputersToProcess $newHTMLProcessedComputersSplat = @{ Export = $Export FilePath = $ReportPath Online = $Online.IsPresent ShowHTML = $ShowHTML.IsPresent LogFile = $LogPath ComputersToProcess = $ComputersToProcess DisableOnlyIf = $DisableOnlyIf DeleteOnlyIf = $DeleteOnlyIf MoveOnlyIf = $MoveOnlyIf Delete = $Delete Disable = $Disable Move = $Move ReportOnly = $ReportOnly } Write-Color "[i] ", "Generating HTML report ($ReportPath)" -Color Yellow, Magenta New-HTMLProcessedComputers @newHTMLProcessedComputersSplat } 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 # MIItqwYJKoZIhvcNAQcCoIItnDCCLZgCAQExDzANBglghkgBZQMEAgEFADB5Bgor # BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG # KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCDnt02iIu+6EIM/ # ZwdG/XPv1O/cu7zpbhhNMjuuikpYKqCCJq4wggWNMIIEdaADAgECAhAOmxiO+dAt # 5+/bUOIIQBhaMA0GCSqGSIb3DQEBDAUAMGUxCzAJBgNVBAYTAlVTMRUwEwYDVQQK # EwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xJDAiBgNV # BAMTG0RpZ2lDZXJ0IEFzc3VyZWQgSUQgUm9vdCBDQTAeFw0yMjA4MDEwMDAwMDBa # Fw0zMTExMDkyMzU5NTlaMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2Vy # dCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lD # ZXJ0IFRydXN0ZWQgUm9vdCBHNDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC # ggIBAL/mkHNo3rvkXUo8MCIwaTPswqclLskhPfKK2FnC4SmnPVirdprNrnsbhA3E # MB/zG6Q4FutWxpdtHauyefLKEdLkX9YFPFIPUh/GnhWlfr6fqVcWWVVyr2iTcMKy # unWZanMylNEQRBAu34LzB4TmdDttceItDBvuINXJIB1jKS3O7F5OyJP4IWGbNOsF # xl7sWxq868nPzaw0QF+xembud8hIqGZXV59UWI4MK7dPpzDZVu7Ke13jrclPXuU1 # 5zHL2pNe3I6PgNq2kZhAkHnDeMe2scS1ahg4AxCN2NQ3pC4FfYj1gj4QkXCrVYJB # MtfbBHMqbpEBfCFM1LyuGwN1XXhm2ToxRJozQL8I11pJpMLmqaBn3aQnvKFPObUR # WBf3JFxGj2T3wWmIdph2PVldQnaHiZdpekjw4KISG2aadMreSx7nDmOu5tTvkpI6 # nj3cAORFJYm2mkQZK37AlLTSYW3rM9nF30sEAMx9HJXDj/chsrIRt7t/8tWMcCxB # YKqxYxhElRp2Yn72gLD76GSmM9GJB+G9t+ZDpBi4pncB4Q+UDCEdslQpJYls5Q5S # UUd0viastkF13nqsX40/ybzTQRESW+UQUOsxxcpyFiIJ33xMdT9j7CFfxCBRa2+x # q4aLT8LWRV+dIPyhHsXAj6KxfgommfXkaS+YHS312amyHeUbAgMBAAGjggE6MIIB # NjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTs1+OC0nFdZEzfLmc/57qYrhwP # TzAfBgNVHSMEGDAWgBRF66Kv9JLLgjEtUYunpyGd823IDzAOBgNVHQ8BAf8EBAMC # AYYweQYIKwYBBQUHAQEEbTBrMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdp # Y2VydC5jb20wQwYIKwYBBQUHMAKGN2h0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNv # bS9EaWdpQ2VydEFzc3VyZWRJRFJvb3RDQS5jcnQwRQYDVR0fBD4wPDA6oDigNoY0 # aHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0QXNzdXJlZElEUm9vdENB # LmNybDARBgNVHSAECjAIMAYGBFUdIAAwDQYJKoZIhvcNAQEMBQADggEBAHCgv0Nc # Vec4X6CjdBs9thbX979XB72arKGHLOyFXqkauyL4hxppVCLtpIh3bb0aFPQTSnov # Lbc47/T/gLn4offyct4kvFIDyE7QKt76LVbP+fT3rDB6mouyXtTP0UNEm0Mh65Zy # oUi0mcudT6cGAxN3J0TU53/oWajwvy8LpunyNDzs9wPHh6jSTEAZNUZqaVSwuKFW # juyk1T3osdz9HNj0d1pcVIxv76FQPfx2CWiEn2/K2yCNNWAcAgPLILCsWKAOQGPF # mCLBsln1VWvPJ6tsds5vIy30fnFqI2si/xK4VC0nftg62fC2h5b9W9FcrBjDTZ9z # twGpn1eqXijiuZQwggWQMIIDeKADAgECAhAFmxtXno4hMuI5B72nd3VcMA0GCSqG # SIb3DQEBDAUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMx # GTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0IFRy # dXN0ZWQgUm9vdCBHNDAeFw0xMzA4MDExMjAwMDBaFw0zODAxMTUxMjAwMDBaMGIx # CzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3 # dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0IFRydXN0ZWQgUm9vdCBH # NDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAL/mkHNo3rvkXUo8MCIw # aTPswqclLskhPfKK2FnC4SmnPVirdprNrnsbhA3EMB/zG6Q4FutWxpdtHauyefLK # EdLkX9YFPFIPUh/GnhWlfr6fqVcWWVVyr2iTcMKyunWZanMylNEQRBAu34LzB4Tm # dDttceItDBvuINXJIB1jKS3O7F5OyJP4IWGbNOsFxl7sWxq868nPzaw0QF+xembu # d8hIqGZXV59UWI4MK7dPpzDZVu7Ke13jrclPXuU15zHL2pNe3I6PgNq2kZhAkHnD # eMe2scS1ahg4AxCN2NQ3pC4FfYj1gj4QkXCrVYJBMtfbBHMqbpEBfCFM1LyuGwN1 # XXhm2ToxRJozQL8I11pJpMLmqaBn3aQnvKFPObURWBf3JFxGj2T3wWmIdph2PVld # QnaHiZdpekjw4KISG2aadMreSx7nDmOu5tTvkpI6nj3cAORFJYm2mkQZK37AlLTS # YW3rM9nF30sEAMx9HJXDj/chsrIRt7t/8tWMcCxBYKqxYxhElRp2Yn72gLD76GSm # M9GJB+G9t+ZDpBi4pncB4Q+UDCEdslQpJYls5Q5SUUd0viastkF13nqsX40/ybzT # QRESW+UQUOsxxcpyFiIJ33xMdT9j7CFfxCBRa2+xq4aLT8LWRV+dIPyhHsXAj6Kx # fgommfXkaS+YHS312amyHeUbAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYD # VR0PAQH/BAQDAgGGMB0GA1UdDgQWBBTs1+OC0nFdZEzfLmc/57qYrhwPTzANBgkq # hkiG9w0BAQwFAAOCAgEAu2HZfalsvhfEkRvDoaIAjeNkaA9Wz3eucPn9mkqZucl4 # XAwMX+TmFClWCzZJXURj4K2clhhmGyMNPXnpbWvWVPjSPMFDQK4dUPVS/JA7u5iZ # aWvHwaeoaKQn3J35J64whbn2Z006Po9ZOSJTROvIXQPK7VB6fWIhCoDIc2bRoAVg # X+iltKevqPdtNZx8WorWojiZ83iL9E3SIAveBO6Mm0eBcg3AFDLvMFkuruBx8lbk # apdvklBtlo1oepqyNhR6BvIkuQkRUNcIsbiJeoQjYUIp5aPNoiBB19GcZNnqJqGL # FNdMGbJQQXE9P01wI4YMStyB0swylIQNCAmXHE/A7msgdDDS4Dk0EIUhFQEI6FUy # 3nFJ2SgXUE3mvk3RdazQyvtBuEOlqtPDBURPLDab4vriRbgjU2wGb2dVf0a1TD9u # KFp5JtKkqGKX0h7i7UqLvBv9R0oN32dmfrJbQdA75PQ79ARj6e/CVABRoIoqyc54 # zNXqhwQYs86vSYiv85KZtrPmYQ/ShQDnUBrkG5WdGaG5nLGbsQAe79APT0JsyQq8 # 7kP6OnGlyE0mpTX9iV28hWIdMtKgK1TtmlfB2/oQzxm3i0objwG2J5VT6LaJbVu8 # aNQj6ItRolb58KaAoNYes7wPD1N1KarqE3fk3oyBIa0HEEcRrYc9B9F1vM/zZn4w # ggauMIIElqADAgECAhAHNje3JFR82Ees/ShmKl5bMA0GCSqGSIb3DQEBCwUAMGIx # CzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3 # dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0IFRydXN0ZWQgUm9vdCBH # NDAeFw0yMjAzMjMwMDAwMDBaFw0zNzAzMjIyMzU5NTlaMGMxCzAJBgNVBAYTAlVT # MRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjE7MDkGA1UEAxMyRGlnaUNlcnQgVHJ1 # c3RlZCBHNCBSU0E0MDk2IFNIQTI1NiBUaW1lU3RhbXBpbmcgQ0EwggIiMA0GCSqG # SIb3DQEBAQUAA4ICDwAwggIKAoICAQDGhjUGSbPBPXJJUVXHJQPE8pE3qZdRodbS # g9GeTKJtoLDMg/la9hGhRBVCX6SI82j6ffOciQt/nR+eDzMfUBMLJnOWbfhXqAJ9 # /UO0hNoR8XOxs+4rgISKIhjf69o9xBd/qxkrPkLcZ47qUT3w1lbU5ygt69OxtXXn # HwZljZQp09nsad/ZkIdGAHvbREGJ3HxqV3rwN3mfXazL6IRktFLydkf3YYMZ3V+0 # VAshaG43IbtArF+y3kp9zvU5EmfvDqVjbOSmxR3NNg1c1eYbqMFkdECnwHLFuk4f # sbVYTXn+149zk6wsOeKlSNbwsDETqVcplicu9Yemj052FVUmcJgmf6AaRyBD40Nj # gHt1biclkJg6OBGz9vae5jtb7IHeIhTZgirHkr+g3uM+onP65x9abJTyUpURK1h0 # QCirc0PO30qhHGs4xSnzyqqWc0Jon7ZGs506o9UD4L/wojzKQtwYSH8UNM/STKvv # mz3+DrhkKvp1KCRB7UK/BZxmSVJQ9FHzNklNiyDSLFc1eSuo80VgvCONWPfcYd6T # /jnA+bIwpUzX6ZhKWD7TA4j+s4/TXkt2ElGTyYwMO1uKIqjBJgj5FBASA31fI7tk # 42PgpuE+9sJ0sj8eCXbsq11GdeJgo1gJASgADoRU7s7pXcheMBK9Rp6103a50g5r # mQzSM7TNsQIDAQABo4IBXTCCAVkwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4E # FgQUuhbZbU2FL3MpdpovdYxqII+eyG8wHwYDVR0jBBgwFoAU7NfjgtJxXWRM3y5n # P+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMGA1UdJQQMMAoGCCsGAQUFBwMIMHcG # CCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQu # Y29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5jb20vRGln # aUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNVHR8EPDA6MDigNqA0hjJodHRwOi8v # Y3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNybDAgBgNV # HSAEGTAXMAgGBmeBDAEEAjALBglghkgBhv1sBwEwDQYJKoZIhvcNAQELBQADggIB # AH1ZjsCTtm+YqUQiAX5m1tghQuGwGC4QTRPPMFPOvxj7x1Bd4ksp+3CKDaopafxp # wc8dB+k+YMjYC+VcW9dth/qEICU0MWfNthKWb8RQTGIdDAiCqBa9qVbPFXONASIl # zpVpP0d3+3J0FNf/q0+KLHqrhc1DX+1gtqpPkWaeLJ7giqzl/Yy8ZCaHbJK9nXzQ # cAp876i8dU+6WvepELJd6f8oVInw1YpxdmXazPByoyP6wCeCRK6ZJxurJB4mwbfe # Kuv2nrF5mYGjVoarCkXJ38SNoOeY+/umnXKvxMfBwWpx2cYTgAnEtp/Nh4cku0+j # Sbl3ZpHxcpzpSwJSpzd+k1OsOx0ISQ+UzTl63f8lY5knLD0/a6fxZsNBzU+2QJsh # IUDQtxMkzdwdeDrknq3lNHGS1yZr5Dhzq6YBT70/O3itTK37xJV77QpfMzmHQXh6 # OOmc4d0j/R0o08f56PGYX/sr2H7yRp11LB4nLCbbbxV7HhmLNriT1ObyF5lZynDw # N7+YAN8gFk8n+2BnFqFmut1VwDophrCYoCvtlUG3OtUVmDG0YgkPCr2B2RP+v6TR # 81fZvAT6gt4y3wSJ8ADNXcL50CN/AAvkdgIm2fBldkKmKYcJRyvmfxqkhQ/8mJb2 # VVQrH4D6wPIOK+XW+6kvRBVK5xMOHds3OBqhK/bt1nz8MIIGsDCCBJigAwIBAgIQ # CK1AsmDSnEyfXs2pvZOu2TANBgkqhkiG9w0BAQwFADBiMQswCQYDVQQGEwJVUzEV # MBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29t # MSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3QgRzQwHhcNMjEwNDI5MDAw # MDAwWhcNMzYwNDI4MjM1OTU5WjBpMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGln # aUNlcnQsIEluYy4xQTA/BgNVBAMTOERpZ2lDZXJ0IFRydXN0ZWQgRzQgQ29kZSBT # aWduaW5nIFJTQTQwOTYgU0hBMzg0IDIwMjEgQ0ExMIICIjANBgkqhkiG9w0BAQEF # AAOCAg8AMIICCgKCAgEA1bQvQtAorXi3XdU5WRuxiEL1M4zrPYGXcMW7xIUmMJ+k # jmjYXPXrNCQH4UtP03hD9BfXHtr50tVnGlJPDqFX/IiZwZHMgQM+TXAkZLON4gh9 # NH1MgFcSa0OamfLFOx/y78tHWhOmTLMBICXzENOLsvsI8IrgnQnAZaf6mIBJNYc9 # URnokCF4RS6hnyzhGMIazMXuk0lwQjKP+8bqHPNlaJGiTUyCEUhSaN4QvRRXXegY # E2XFf7JPhSxIpFaENdb5LpyqABXRN/4aBpTCfMjqGzLmysL0p6MDDnSlrzm2q2AS # 4+jWufcx4dyt5Big2MEjR0ezoQ9uo6ttmAaDG7dqZy3SvUQakhCBj7A7CdfHmzJa # wv9qYFSLScGT7eG0XOBv6yb5jNWy+TgQ5urOkfW+0/tvk2E0XLyTRSiDNipmKF+w # c86LJiUGsoPUXPYVGUztYuBeM/Lo6OwKp7ADK5GyNnm+960IHnWmZcy740hQ83eR # Gv7bUKJGyGFYmPV8AhY8gyitOYbs1LcNU9D4R+Z1MI3sMJN2FKZbS110YU0/EpF2 # 3r9Yy3IQKUHw1cVtJnZoEUETWJrcJisB9IlNWdt4z4FKPkBHX8mBUHOFECMhWWCK # ZFTBzCEa6DgZfGYczXg4RTCZT/9jT0y7qg0IU0F8WD1Hs/q27IwyCQLMbDwMVhEC # AwEAAaOCAVkwggFVMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFGg34Ou2 # O/hfEYb7/mF7CIhl9E5CMB8GA1UdIwQYMBaAFOzX44LScV1kTN8uZz/nupiuHA9P # MA4GA1UdDwEB/wQEAwIBhjATBgNVHSUEDDAKBggrBgEFBQcDAzB3BggrBgEFBQcB # AQRrMGkwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBBBggr # BgEFBQcwAoY1aHR0cDovL2NhY2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1 # c3RlZFJvb3RHNC5jcnQwQwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybDMuZGln # aWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZFJvb3RHNC5jcmwwHAYDVR0gBBUwEzAH # BgVngQwBAzAIBgZngQwBBAEwDQYJKoZIhvcNAQEMBQADggIBADojRD2NCHbuj7w6 # mdNW4AIapfhINPMstuZ0ZveUcrEAyq9sMCcTEp6QRJ9L/Z6jfCbVN7w6XUhtldU/ # SfQnuxaBRVD9nL22heB2fjdxyyL3WqqQz/WTauPrINHVUHmImoqKwba9oUgYftzY # gBoRGRjNYZmBVvbJ43bnxOQbX0P4PpT/djk9ntSZz0rdKOtfJqGVWEjVGv7XJz/9 # kNF2ht0csGBc8w2o7uCJob054ThO2m67Np375SFTWsPK6Wrxoj7bQ7gzyE84FJKZ # 9d3OVG3ZXQIUH0AzfAPilbLCIXVzUstG2MQ0HKKlS43Nb3Y3LIU/Gs4m6Ri+kAew # Q3+ViCCCcPDMyu/9KTVcH4k4Vfc3iosJocsL6TEa/y4ZXDlx4b6cpwoG1iZnt5Lm # Tl/eeqxJzy6kdJKt2zyknIYf48FWGysj/4+16oh7cGvmoLr9Oj9FpsToFpFSi0HA # SIRLlk2rREDjjfAVKM7t8RhWByovEMQMCGQ8M4+uKIw8y4+ICw2/O/TOHnuO77Xr # y7fwdxPm5yg/rBKupS8ibEH5glwVZsxsDsrFhsP2JjMMB0ug0wcCampAMEhLNKhR # ILutG4UI4lkNbcoFUCvqShyepf2gpx8GdOfy1lKQ/a+FSCH5Vzu0nAPthkX0tGFu # v2jiJmCG6sivqf6UHedjGzqGVnhOMIIGvDCCBKSgAwIBAgIQC65mvFq6f5WHxvnp # BOMzBDANBgkqhkiG9w0BAQsFADBjMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGln # aUNlcnQsIEluYy4xOzA5BgNVBAMTMkRpZ2lDZXJ0IFRydXN0ZWQgRzQgUlNBNDA5 # NiBTSEEyNTYgVGltZVN0YW1waW5nIENBMB4XDTI0MDkyNjAwMDAwMFoXDTM1MTEy # NTIzNTk1OVowQjELMAkGA1UEBhMCVVMxETAPBgNVBAoTCERpZ2lDZXJ0MSAwHgYD # VQQDExdEaWdpQ2VydCBUaW1lc3RhbXAgMjAyNDCCAiIwDQYJKoZIhvcNAQEBBQAD # ggIPADCCAgoCggIBAL5qc5/2lSGrljC6W23mWaO16P2RHxjEiDtqmeOlwf0KMCBD # Er4IxHRGd7+L660x5XltSVhhK64zi9CeC9B6lUdXM0s71EOcRe8+CEJp+3R2O8oo # 76EO7o5tLuslxdr9Qq82aKcpA9O//X6QE+AcaU/byaCagLD/GLoUb35SfWHh43rO # H3bpLEx7pZ7avVnpUVmPvkxT8c2a2yC0WMp8hMu60tZR0ChaV76Nhnj37DEYTX9R # eNZ8hIOYe4jl7/r419CvEYVIrH6sN00yx49boUuumF9i2T8UuKGn9966fR5X6kgX # j3o5WHhHVO+NBikDO0mlUh902wS/Eeh8F/UFaRp1z5SnROHwSJ+QQRZ1fisD8UTV # DSupWJNstVkiqLq+ISTdEjJKGjVfIcsgA4l9cbk8Smlzddh4EfvFrpVNnes4c16J # idj5XiPVdsn5n10jxmGpxoMc6iPkoaDhi6JjHd5ibfdp5uzIXp4P0wXkgNs+CO/C # acBqU0R4k+8h6gYldp4FCMgrXdKWfM4N0u25OEAuEa3JyidxW48jwBqIJqImd93N # Rxvd1aepSeNeREXAu2xUDEW8aqzFQDYmr9ZONuc2MhTMizchNULpUEoA6Vva7b1X # CB+1rxvbKmLqfY/M/SdV6mwWTyeVy5Z/JkvMFpnQy5wR14GJcv6dQ4aEKOX5AgMB # AAGjggGLMIIBhzAOBgNVHQ8BAf8EBAMCB4AwDAYDVR0TAQH/BAIwADAWBgNVHSUB # Af8EDDAKBggrBgEFBQcDCDAgBgNVHSAEGTAXMAgGBmeBDAEEAjALBglghkgBhv1s # BwEwHwYDVR0jBBgwFoAUuhbZbU2FL3MpdpovdYxqII+eyG8wHQYDVR0OBBYEFJ9X # LAN3DigVkGalY17uT5IfdqBbMFoGA1UdHwRTMFEwT6BNoEuGSWh0dHA6Ly9jcmwz # LmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNFJTQTQwOTZTSEEyNTZUaW1l # U3RhbXBpbmdDQS5jcmwwgZAGCCsGAQUFBwEBBIGDMIGAMCQGCCsGAQUFBzABhhho # dHRwOi8vb2NzcC5kaWdpY2VydC5jb20wWAYIKwYBBQUHMAKGTGh0dHA6Ly9jYWNl # cnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNFJTQTQwOTZTSEEyNTZU # aW1lU3RhbXBpbmdDQS5jcnQwDQYJKoZIhvcNAQELBQADggIBAD2tHh92mVvjOIQS # R9lDkfYR25tOCB3RKE/P09x7gUsmXqt40ouRl3lj+8QioVYq3igpwrPvBmZdrlWB # b0HvqT00nFSXgmUrDKNSQqGTdpjHsPy+LaalTW0qVjvUBhcHzBMutB6HzeledbDC # zFzUy34VarPnvIWrqVogK0qM8gJhh/+qDEAIdO/KkYesLyTVOoJ4eTq7gj9UFAL1 # UruJKlTnCVaM2UeUUW/8z3fvjxhN6hdT98Vr2FYlCS7Mbb4Hv5swO+aAXxWUm3Wp # ByXtgVQxiBlTVYzqfLDbe9PpBKDBfk+rabTFDZXoUke7zPgtd7/fvWTlCs30VAGE # sshJmLbJ6ZbQ/xll/HjO9JbNVekBv2Tgem+mLptR7yIrpaidRJXrI+UzB6vAlk/8 # a1u7cIqV0yef4uaZFORNekUgQHTqddmsPCEIYQP7xGxZBIhdmm4bhYsVA6G2WgNF # YagLDBzpmk9104WQzYuVNsxyoVLObhx3RugaEGru+SojW4dHPoWrUhftNpFC5H7Q # EY7MhKRyrBe7ucykW7eaCuWBsBb4HOKRFVDcrZgdwaSIqMDiCLg4D+TPVgKx2EgE # deoHNHT9l3ZDBD+XgbF+23/zBjeCtxz+dL/9NWR6P2eZRi7zcEO1xwcdcqJsyz/J # ceENc2Sg8h3KeFUCS7tpFk7CrDqkMIIHXzCCBUegAwIBAgIQB8JSdCgUotar/iTq # F+XdLjANBgkqhkiG9w0BAQsFADBpMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGln # aUNlcnQsIEluYy4xQTA/BgNVBAMTOERpZ2lDZXJ0IFRydXN0ZWQgRzQgQ29kZSBT # aWduaW5nIFJTQTQwOTYgU0hBMzg0IDIwMjEgQ0ExMB4XDTIzMDQxNjAwMDAwMFoX # DTI2MDcwNjIzNTk1OVowZzELMAkGA1UEBhMCUEwxEjAQBgNVBAcMCU1pa2/FgsOz # dzEhMB8GA1UECgwYUHJ6ZW15c8WCYXcgS8WCeXMgRVZPVEVDMSEwHwYDVQQDDBhQ # cnplbXlzxYJhdyBLxYJ5cyBFVk9URUMwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAw # ggIKAoICAQCUmgeXMQtIaKaSkKvbAt8GFZJ1ywOH8SwxlTus4McyrWmVOrRBVRQA # 8ApF9FaeobwmkZxvkxQTFLHKm+8knwomEUslca8CqSOI0YwELv5EwTVEh0C/Daeh # vxo6tkmNPF9/SP1KC3c0l1vO+M7vdNVGKQIQrhxq7EG0iezBZOAiukNdGVXRYOLn # 47V3qL5PwG/ou2alJ/vifIDad81qFb+QkUh02Jo24SMjWdKDytdrMXi0235CN4Rr # W+8gjfRJ+fKKjgMImbuceCsi9Iv1a66bUc9anAemObT4mF5U/yQBgAuAo3+jVB8w # iUd87kUQO0zJCF8vq2YrVOz8OJmMX8ggIsEEUZ3CZKD0hVc3dm7cWSAw8/FNzGNP # lAaIxzXX9qeD0EgaCLRkItA3t3eQW+IAXyS/9ZnnpFUoDvQGbK+Q4/bP0ib98XLf # QpxVGRu0cCV0Ng77DIkRF+IyR1PcwVAq+OzVU3vKeo25v/rntiXCmCxiW4oHYO28 # eSQ/eIAcnii+3uKDNZrI15P7VxDrkUIc6FtiSvOhwc3AzY+vEfivUkFKRqwvSSr4 # fCrrkk7z2Qe72Zwlw2EDRVHyy0fUVGO9QMuh6E3RwnJL96ip0alcmhKABGoIqSW0 # 5nXdCUbkXmhPCTT5naQDuZ1UkAXbZPShKjbPwzdXP2b8I9nQ89VSgQIDAQABo4IC # AzCCAf8wHwYDVR0jBBgwFoAUaDfg67Y7+F8Rhvv+YXsIiGX0TkIwHQYDVR0OBBYE # FHrxaiVZuDJxxEk15bLoMuFI5233MA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAK # BggrBgEFBQcDAzCBtQYDVR0fBIGtMIGqMFOgUaBPhk1odHRwOi8vY3JsMy5kaWdp # Y2VydC5jb20vRGlnaUNlcnRUcnVzdGVkRzRDb2RlU2lnbmluZ1JTQTQwOTZTSEEz # ODQyMDIxQ0ExLmNybDBToFGgT4ZNaHR0cDovL2NybDQuZGlnaWNlcnQuY29tL0Rp # Z2lDZXJ0VHJ1c3RlZEc0Q29kZVNpZ25pbmdSU0E0MDk2U0hBMzg0MjAyMUNBMS5j # cmwwPgYDVR0gBDcwNTAzBgZngQwBBAEwKTAnBggrBgEFBQcCARYbaHR0cDovL3d3 # dy5kaWdpY2VydC5jb20vQ1BTMIGUBggrBgEFBQcBAQSBhzCBhDAkBggrBgEFBQcw # AYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29tMFwGCCsGAQUFBzAChlBodHRwOi8v # Y2FjZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkRzRDb2RlU2lnbmlu # Z1JTQTQwOTZTSEEzODQyMDIxQ0ExLmNydDAJBgNVHRMEAjAAMA0GCSqGSIb3DQEB # CwUAA4ICAQC3EeHXUPhpe31K2DL43Hfh6qkvBHyR1RlD9lVIklcRCR50ZHzoWs6E # BlTFyohvkpclVCuRdQW33tS6vtKPOucpDDv4wsA+6zkJYI8fHouW6Tqa1W47YSrc # 5AOShIcJ9+NpNbKNGih3doSlcio2mUKCX5I/ZrzJBkQpJ0kYha/pUST2CbE3JroJ # f2vQWGUiI+J3LdiPNHmhO1l+zaQkSxv0cVDETMfQGZKKRVESZ6Fg61b0djvQSx51 # 0MdbxtKMjvS3ZtAytqnQHk1ipP+Rg+M5lFHrSkUlnpGa+f3nuQhxDb7N9E8hUVev # xALTrFifg8zhslVRH5/Df/CxlMKXC7op30/AyQsOQxHW1uNx3tG1DMgizpwBasrx # h6wa7iaA+Lp07q1I92eLhrYbtw3xC2vNIGdMdN7nd76yMIjdYnAn7r38wwtaJ3KY # D0QTl77EB8u/5cCs3ShZdDdyg4K7NoJl8iEHrbqtooAHOMLiJpiL2i9Yn8kQMB6/ # Q6RMO3IUPLuycB9o6DNiwQHf6Jt5oW7P09k5NxxBEmksxwNbmZvNQ65Zn3exUAKq # G+x31Egz5IZ4U/jPzRalElEIpS0rgrVg8R8pEOhd95mEzp5WERKFyXhe6nB6bSYH # v8clLAV0iMku308rpfjMiQkqS3LLzfUJ5OHqtKKQNMLxz9z185UCszGCBlMwggZP # AgEBMH0waTELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMUEw # PwYDVQQDEzhEaWdpQ2VydCBUcnVzdGVkIEc0IENvZGUgU2lnbmluZyBSU0E0MDk2 # IFNIQTM4NCAyMDIxIENBMQIQB8JSdCgUotar/iTqF+XdLjANBglghkgBZQMEAgEF # AKCBhDAYBgorBgEEAYI3AgEMMQowCKACgAChAoAAMBkGCSqGSIb3DQEJAzEMBgor # BgEEAYI3AgEEMBwGCisGAQQBgjcCAQsxDjAMBgorBgEEAYI3AgEVMC8GCSqGSIb3 # DQEJBDEiBCCN5LBmFRm1iQ0j17EdbRYrGhIK/0DnrvpUTTBb3hv7FDANBgkqhkiG # 9w0BAQEFAASCAgBubS3vwe8ZyDYeJ1uNqokE1PVVQrPomOPfvt+VCjCIyH9KJVEt # m+aSf+OSzpBbSPKEYzYSuNlUr70cqyKpUigP5eZaAs7rtM63QpWzx4uHXXp8U3JW # McAV+B5ZWteyUlT1tqd7xc6DVwD+vAV7inixRiX0uJ8YrffaqUEjBksRmzsW73RG # TLPqAMsI5OtGjv4u3DYfA47v1BnqUG8pM8imoRODTXZaf3Ikm12ZpKitpcm3/dEa # bTkct+BOeXQ05M5HCFitvINaSfgn3xToAq6Q3I0L5NPUTGjWaCVM1Ddy296A2BGA # faPSkYXBiHqpgs+Rx5mxZ9oxswYa9HzEbF35FF7KqjBgizTEyS5jnHmUaMddTxEJ # bliYGY0YkPVMtDRat4rM4ogg0DctjaxPeXEDo7tOI47aU+9o4mBvNmSVrTP5pH63 # 1Dso7AUcz/pKAdTU7j+8k/3IsnL3fqT7G+g/0fFbrhb8r099DM1+KC9TsWZqqdZx # 6hR48QMqapiaezwkkxiPDYSCIyRls1MZG2wyhypzIy5GhcmIJHVvAFVFkUWCevOi # RIOzuPKVy+4ic8IfkVxShbUCkWvYbJLPsEXGc57B9YIfgiEDZ+g7tNoFyxUSH0kI # e6g/Yqe2ZzE7PYmS0peJqu3jZ+d8/LJqIpE82JEH2dMkED+IuTWiEsDwk6GCAyAw # ggMcBgkqhkiG9w0BCQYxggMNMIIDCQIBATB3MGMxCzAJBgNVBAYTAlVTMRcwFQYD # VQQKEw5EaWdpQ2VydCwgSW5jLjE7MDkGA1UEAxMyRGlnaUNlcnQgVHJ1c3RlZCBH # NCBSU0E0MDk2IFNIQTI1NiBUaW1lU3RhbXBpbmcgQ0ECEAuuZrxaun+Vh8b56QTj # MwQwDQYJYIZIAWUDBAIBBQCgaTAYBgkqhkiG9w0BCQMxCwYJKoZIhvcNAQcBMBwG # CSqGSIb3DQEJBTEPFw0yNDEwMjEwNzIyMDdaMC8GCSqGSIb3DQEJBDEiBCA2u4NQ # fXf8S/Sq3fU0xe2rny5WvUQl0e6pDhNcaTdgYDANBgkqhkiG9w0BAQEFAASCAgAD # fDYWC2C2J+GWYha3+q2Y9B1uBFNsmNAZFFy+HLI5+VwhjOSfVp6jqxaohOIbRMoB # sqOdMIgqAT/mLYqD2wNae2DIPwCF7HYOEsaCpU/qbKRnZ/enXYkUKUAxDeAuC8Xb # wUFNKL+SSmUDU7ITRgvCWODloVMSwuAg1pkCtiE6k9DI3eGgztx+/3IIuTn8lhz2 # cbB/fa85t2j5aqGKrCT9f63DNYtrrCk9Wx8xWWFnxqXi4c98F/y7+wGKvSBMVVub # CCaU2yV2dl07dfWX6cITEGEy3VcFa+0hPUeM1F+ANkamQl3zJHhiyC1YgMJG9SJt # 3QuKttzjv8ztwN3UKcOdJvB9VAzRZpws0DQABgxZSMd6DOLaXk13ygMvxhLvVhGT # sDJBfgFkZ5eZEuSrxwLT8E+U7apQKB4G4+qWgia0/p6i4lB/nP30pN2sHVg59h/8 # F44W32Dbj0ViAvARkVtXUuf8hjWGVwYl0GmgZ4t4e7ZR8GjUMIjW6KcjyEXUZEnG # xEqeu5HeCGq3cEO4nRdzLY1/iUDW8auXT2xXikr4EZ5P/G49G5QwP6DCrAMO+D6e # vQPacVLIapxlkBC/oDfvI+S8m63AYw+ZRzC0U5wP0f5tgEmgsBmRRjpzRU4akOE8 # 2a6iPOT0KUtPinkMB/hLM3EhQkEQB0lmCpsmL5h+iQ== # SIG # End signature block |