Documenting Microsoft Active Directory with Microsoft Word and PowerShell

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 http://carlwebster.com/where-to-get-copies-of-the-documentation-scripts/

Thanks

Webster

About Carl Webster

Webster is an independent consultant in the Nashville, TN area and specializes in Citrix, Active Directory and Technical Documentation. Webster has been working with Citrix products for many years starting with Multi-User OS/2 in 1990.

View all posts by Carl Webster

4 Responses to “Documenting Microsoft Active Directory with Microsoft Word and PowerShell”

  1. Carsten Says:

    Hi Carl,
    Great work Thank you very much :-)
    cheers
    Carsten

    Reply

  2. Zachary Says:

    I cannot wait to test out this script!

    I’ve written something similar (except it is all based on information which can be gathered with a non-privileged account). http://gallery.technet.microsoft.com/Active-Directory-Audit-7754a877 Perhaps you can use some of the code in your script as well. I’ve been slowly working on a word document export for it but have not finished (yet!). Anyway, thanks for your excellent community contributions.

    Zach

    Reply

    • Carl Webster Says:

      my script will also work with a non admin account except for gathering the hardware and services information about domain controllers. i created and tested the script with a regular domain user account.

      webster

      Reply

  3. Nils Kaczenski Says:

    Carl,

    great, I’ll look at it thoroughly!

    Just for your information: I’ve been maintaining an AD documemtation script for a dozen years now. It’s legacy code, i.e. VBScript ;) but maybe you can get some inspiration or look up where some information is stored in AD.

    Thanks to community contribution there’s an English version now as well. See
    http://www.faq-o-matic.net/jose-en/

    Thanks a lot (I’ll provode some feedback when I’ve had an opportunity to test your solution),

    Nils

    Reply

Leave a Reply

Current ye@r *