On a recent project, the customer needed a way to see what they had in their numerous Active Directory (AD) forests. I offered to create a script and they gave me permission to do so. After creating the initial basic script, I sent out a request for testers. I received a lot of requests from people wanting to test the script and these people offered a lot of suggestions, enhancements and code for me to adapt. The script then took on a life of its own and has morphed into a really nice report.



Before I get started listing all the features, I want to start by thanking a dedicated and hardworking group of testers and others who provided PowerShell help and guidance for developing this script. I had more testers (54) for this script than for any other script I have ever created. This is the list of testers who gave me permission to use their names.

Alain Assaf

Barry Schiffer

Bob Free

Charles Polisher

Daniel Chenault

Donald Kuhlman

Duy Le

Eric Wittersheim

Francesco Tamba

Gunnar “Gundaris” Hermansen

J. L. Straat

James Rankin

Jim Kennedy

Jim Millard

Kevin James

Kurt Buff

Luis F. Trejo H.

Melvin Backus

Michael B. Smith

Mike Nelson

Paul Loonen

Samuel Legrand

Shibu Keloth

Thomas Vuylsteke

Tom Ide

The following items are documented:

Forest Information Domain Controllers

Sites and Services Inter-Site Transports Sites Subnets Servers Connection Objects

Domain Information Domain Trusts Domain Controllers

Domain Controllers Computer Information (optional) Services (optional)

Organizational Units

Groups Privileged Groups

Group Policies by Domain

Group Policies by Organizational Unit

Miscellaneous Data by Domain All Users Active Users Windows Computer Operating Systems Non-Windows Computer Operating Systems



I learned a lot from creating this script. I will try and list out some of the lessons.

Microsoft’s AD cmdlets do not honor -EA 0

When creating this script, I kept adding -EA 0 to all my cmdlet calls but yet I still got the big red ugly PowerShell error messages. I was able to wrap the cmdlets in Try/Catch statements but Michael B. Smith said that Try/Catch is very expensive (I assume that means in CPU cycles). He had me set a global ErrorAction value at the top of the script and then I set it back to the original value before the script ends.

$SaveEAPreference = $ErrorActionPreference $ErrorActionPreference = 'SilentlyContinue'

And then before the script exits.

$ErrorActionPreference = $SaveEAPreference

That allowed me to handle any errors in the script.

Is the User a Domain Admin?

To properly retrieve the WMI hardware inventory and or get a list of Services running on the domain controllers, the user running the script must have Domain Administrator rights in the AD Forest being processed. The code I originally found and adapted did not work if the user running the script logged in with UPN\UserName. Even though UserName had Domain Admin rights, the UPN\ part threw off my original code. I asked the testers if anyone had any code that would work and Thomas Vuylsteke sent me some code I was able to adapt for the script.

Function UserIsaDomainAdmin { #function adapted from sample code provided by Thomas Vuylsteke $IsDA = $False $name = $env:username Write-Verbose "$(Get-Date): TokenGroups - Checking groups for $name" $root = [ADSI]"" $filter = "(sAMAccountName=$name)" $props = @("distinguishedName") $Searcher = new-Object System.DirectoryServices.DirectorySearcher($root,$filter,$props) $account = $Searcher.FindOne().properties.distinguishedname $user = [ADSI]"LDAP://$Account" $user.GetInfoEx(@("tokengroups"),0) $groups = $user.Get("tokengroups") $domainAdminsSID = New-Object System.Security.Principal.SecurityIdentifier (((Get-ADDomain -Server $ADForest).DomainSid).Value+"-512") ForEach($group in $groups) { $ID = New-Object System.Security.Principal.SecurityIdentifier($group,0) If($ID.CompareTo($domainAdminsSID) -eq 0) { $IsDA = $True } } Return $IsDA }

Getting a List of Computers by Operating System

Several testers requested not only a count of computers but to break down the computers by operating system. The original code I found was several hundred lines long but barfed on the Registered Trademark symbol Microsoft used for Windows Server 2008. Jeremy Saunders sent me some code to use and then Michael B. Smith optimized it. A snippet of the code is shown below.

Function GetComputerCountByOS { Param([string]$xDomain) <# This function will count the number of Windows workstations, Windows servers and non-Windows computers and list them by Operating System. Note that for servers we filter out Cluster Name Objects (CNOs) and Virtual Computer Objects (VCOs) by checking the objects serviceprincipalname property for a value of MSClusterVirtualServer. The CNO is the cluster name, whereas a VCO is the client access point for the clustered role. These are not actual computers, so we exlude them to assist with accuracy. Function Name: GetComputerCountByOS Release: 1.0 Written by Jeremy@jhouseconsulting.com 20th May 2012 #> #function optimized by Michael B. Smith Write-Verbose "$(Get-Date): `t`tGathering computer misc data" $Computers = @() $UnknownComputers = @() $Results = Get-ADComputer -Filter * -Properties Name,Operatingsystem,servicePrincipalName,DistinguishedName -Server $Domain If($? -and $Results -ne $Null) { Write-Verbose "$(Get-Date): `t`t`tGetting server OS counts" $Computers += $Results | ` Where-Object {($_.Operatingsystem -like '*server*') -AND !($_.serviceprincipalname -like '*MSClusterVirtualServer*')} | ` Sort-Object Name Write-Verbose "$(Get-Date): `t`t`tGetting workstation OS counts" $Computers += $Results | ` Where-Object {($_.Operatingsystem -like '*windows*') -AND !($_.Operatingsystem -like '*server*')} | ` Sort-Object Name Write-Verbose "$(Get-Date): `t`t`tGetting unknown OS counts" $UnknownComputers += $Results | ` Where-Object {!($_.Operatingsystem -like '*windows*') -AND !($_.serviceprincipalname -like '*MSClusterVirtualServer*')} | ` Sort-Object Name $Computers += $UnknownComputers $UnknownComputers = $UnknownComputers | Sort DistinguishedName $Computers = $Computers | Group-Object operatingsystem | Sort-Object Count -Descending <snip> }

Handling the -ComputerName Parameter

The -ComputerName parameter can be entered as a NetBIOS name, FQDN, localhost, an IP address or not entered. If it is not entered, then the AD cmdlets will use the domain of the computer running Powershell. If enetered as localhost or an IP address, the script attempts to resolve those into a server name.

If(![String]::IsNullOrEmpty($ComputerName)) { #get server name #first test to make sure the server is reachable Write-Verbose "$(Get-Date): Testing to see if $($ComputerName) is online and reachable" If(Test-Connection -ComputerName $ComputerName -quiet) { Write-Verbose "$(Get-Date): Server $($ComputerName) is online." Write-Verbose "$(Get-Date): `tTesting to see if it is a Domain Controller." #the server may be online but is it really a domain controller? #is the ComputerName in the current domain $Results = Get-ADDomainController $ComputerName If(!$?) { #try using the Forest name $Results = Get-ADDomainController $ComputerName -Server $ADForest If(!$?) { $ErrorActionPreference = $SaveEAPreference Write-Error "`n`n`t`t$($ComputerName) is not a domain controller for $($ADForest).`n`t`tScript cannot continue.`n`n" Exit } } $Results = $Null } Else { Write-Verbose "$(Get-Date): Computer $($ComputerName) is offline" $ErrorActionPreference = $SaveEAPreference Write-Error "`n`n`t`tComputer $($ComputerName) is offline.`nScript cannot continue.`n`n" Exit } } #if computer name is localhost, get actual server name If($ComputerName -eq "localhost") { $ComputerName = $env:ComputerName Write-Verbose "$(Get-Date): Computer name has been renamed from localhost to $($ComputerName)" } #if computer name is an IP address, get host name from DNS #http://blogs.technet.com/b/gary/archive/2009/08/29/resolve-ip-addresses-to-hostname-using-powershell.aspx #help from Michael B. Smith $ip = $ComputerName -as [System.Net.IpAddress] If($ip) { $Result = [System.Net.Dns]::gethostentry($ip) If($? -and $Result -ne $Null) { $ComputerName = $Result.HostName Write-Verbose "$(Get-Date): Computer name has been renamed from $($ip) to $($ComputerName)" } Else { Write-Warning "Unable to resolve $($ComputerName) to a hostname" } }

Word Tables with Fixed Column Widths

For the table of Organizational Units, when the columns were automatically sized to fit the contents, the column with the OU name took up 90% of the table width and the remaining five columns were packed tighter than a can of sardines. I found some code on MSDN to set column widths by setting the width of each cell. While that worked perfect for formatting the table, it greatly increased the time it took the script to run and the memory consumption for the winword.exe process. The memory consumption of the winword.exe process from using $Table.Cell().SetWidth blew my mind. The process consumed roughly 2.5K of memory for every point of cell width set. So using SetWidth(50,0) would consume 125K of memory and SetWidth(200,0) would use 500K of memory. Asinine.

While working with Michael B. Smith on optimizing the memory usage, I found that the word object I created had a table property (which I used to create the tables) and that table property had a columns property. Not being a developer it took me about an hour of playing around with it but I got it figured out. My final solution decreased the script’s runtime by 68.75% and reduced memory consumption by 91.75%. Not a bad hour spent if I say so myself.

Original code which had to be repeated for every row populated in the table:

$Table.Cell($xRow,1).SetWidth(214,$wdAdjustNone) $Table.Cell($xRow,2).SetWidth(68,$wdAdjustNone) $Table.Cell($xRow,3).SetWidth(56,$wdAdjustNone) $Table.Cell($xRow,4).SetWidth(56,$wdAdjustNone) $Table.Cell($xRow,5).SetWidth(70,$wdAdjustNone) $Table.Cell($xRow,6).SetWidth(56,$wdAdjustNone)

Doing the math gives us (214+68+56+56+70+56)*2500 or (520)*2500 for 1,300,000 (or 1.300.000 for my EMEA friends) bytes of memory used for every row in the table. For 600 OUs, that is 780,000,000K or roughly 743MB of memory consumed for one section of the report. That is for 600 OUs. Now imagine the memory consumption for an AD Forest with tens or hundreds of thousands of OUs! To make matters worse, there is no easy way to get the COMObject to release and return the memory that has been consumed after it is no longer needed. I had to find a better solution.

Final code which only has to be run before the table is “finalized”:

#set column widths $xcols = $table.columns ForEach($xcol in $xcols) { switch ($xcol.Index) { 1 {$xcol.width = 214} 2 {$xcol.width = 68} 3 {$xcol.width = 56} 4 {$xcol.width = 56} 5 {$xcol.width = 70} 6 {$xcol.width = 56} } } $Table.Rows.SetLeftIndent($Indent0TabStops,$wdAdjustNone) $Table.AutoFitBehavior($wdAutoFitFixed) #return focus back to document $doc.ActiveWindow.ActivePane.view.SeekView = $wdSeekMainDocument #move to the end of the current document $selection.EndKey($wdStory,$wdMove) | Out-Null

There is more testing I need to do to see what I can optimize further.

I hope you find the report this script generates useful for your, and maybe your customer’s, environment. Please let me know what else you would like to see the script document. I already have a lit of requested enhancements for version 2 which I will start on soon.

Change -hardware to same format as rest of sections

Get the AD advanced feature – recycle bin enabled or not (forest info)

Get the file system locations for the DIT, Logs, and SYSVOL for each DC when using the –hardware param

Add formatted text output

Add HTML output

Use Michael’s optimized code to get misc user information

For privileged groups, Use Michael’s code to get password policy for users and determine if the password last set date is within the policy range from today’s date

Require PowerShell 3+

Add option to include GPO details

Add option to include DNS details

Add option to include DHCP details

NOTE: All scripts are continually updated. You can always find the most current versions by going to https://carlwebster.com/where-to-get-copies-of-the-documentation-scripts/

Thanks

Webster