Wednesday, April 30, 2008

Powershell script to report on all IIS servers in our domain

There are many scripts out there to query IIS via WMI or ADSI and enumerate the settings and report on them. However, we back up all of our server's code to a central share every night, which includes grabbing the IIS metabase.xml file, so I decided to avoid yet another call to the remote servers (and having to deal with possible authentication / connection / etc issues) and instead use Powershell's ability to read XML files to parse each of these and report on the servers' IIS settings.

This is actually two separate scripts. One is "ReportMetabase.ps1" which does the actual parsing of the metabase file. You call it as "ReportMetabase.ps1 [full\path\to\metabase.xml] [report.csv]" and it will generate a CSV of all websites, virtual folders, and their associated application pools, webroots, application names, serverbindings, and metabase IDs.

The second script is "ProcessMetabases.ps1" which will, based on the settings of its .config file, step through a directory tree and for each match of "metabase.xml", invoke "ReportMetabase.ps1" and capture its results into a compiled CSV of all systems.

One thing I added into this after making it is the ability to specify a list of server renames. Apparently, when you rename a server, the IIS Metabase keeps the "ServerComment" field with the old server's name. I discovered this after suddenly seeing servers I thought we decommissioned years ago show up on the report...

While I run this against a copy of all of our code, you can easily run this against a local server's metabase.xml even while IIS is running. It simply reads in the metabase.xml, doesn't actually open or lock the file, and does not even attempt to open it for writing.

Pre-Requisites:
  • Windows 2003 (not tested on 2000 or 2008) and IIS 6.0
  • Powershell 1.0
  • A copy of all metabase.xml files in your environment and access to read them, OR, run ReportMetabase.ps1 against the local metabase of a machine.

What this script does:
  1. ProcesMetabases.ps1 reads in its .config file to determine what directory tree to traverse, what to use as a temporary folder, and where to save the file report.
  2. ProcesMetabase.ps1 then traverses a directory tree and calls ReportMetabase.ps1 with the location to the Metabase.xml and a temp.csv file name. Each iteration it adds the contents of temp.csv to a compiled list to be saved after all Metabase.xml files have been read
  3. ReportMetabase.ps1 reads in its .config file to determine a list of server renames to apply, and a list of virtual directories or websites to exclude from the report
  4. ReportMetabase.ps1 then imports the specified Metabase.xml and loops through each Website and VirtualDirectory key, saving each value to a custom object
  5. The custom object is then exported to the specified csv file.

Script Files:

Steps:
  1. Download the zipfile
  2. Extract it to a target folder
  3. Modify both .config files to contain the proper values
  4. Either call ReportMetabase.ps1 against a local server, or ProcessMetabases.ps1 against a directory tree of metabase.xml files.

For reference, here are the scripts:

ProcessMetabases.ps1:
$Version="v8.4.21 Aaron Dodd"
$Description="Wrapper for ReportMetabases.ps1"

#------------------------------------------------------------------------------
# Settings / Variables
#------------------------------------------------------------------------------
If (Test-Path "ProcessMetabases.config") {
$cfg=[xml](get-content "ProcessMetabases.config")
} Else {
Write-Host "!! ERROR !! - Config file not found"
Write-Host "A file with the same name as this script, ending in .config, must exist in the same directory as this script."
exit
}

$StateManagementRoot=$cfg.configuration.StateManagementRoot.name
$FinalReport=$cfg.configuration.FinalReport.name
$TempDir=$cfg.configuration.TempFolder.name
$TempReport=$TempDir + "\temp.csv"

#------------------------------------------------------------------------------

#------------------------------------------------------------------------------
# Process metabase files
#------------------------------------------------------------------------------
$Metabases=Get-ChildItem -path $StateManagementRoot -recurse -include Metabase.xml | select fullname

ForEach ($Metabase in $Metabases) {
./ReportMetabase.ps1 $Metabase.FullName $TempReport
$TempCsv += Import-Csv $TempReport
}
$TempCsv | Export-Csv $FinalReport -notype
#------------------------------------------------------------------------------


ReportMetabases.ps1:
$Version="v8.4.21 Aaron Dodd"
$Description="Processes a metabase.xml file and reports all websites and various important settings"
$Usage="ReportOnMetabase.ps1 [path\to\metabase.xml] [report.csv]"


#==============================================================================
# Verify we received arguments
#------------------------------------------------------------------------------
If ($args.count -lt 2) {
Write-Host $Version
Write-Host $Description
Write-Host $Usage
exit
}
#==============================================================================
# Settings / Variables
#------------------------------------------------------------------------------
# Import config file
If (Test-Path "ReportMetabase.config") {
$cfg=[xml](get-content "ReportMetabase.config")
} Else {
Write-Host "!! ERROR !! - Config file not found"
Write-Host "A file with the same name as this script, ending in .config, must exist in the same directory as this script."
exit
}

# Object to hold the contents of the metabase file
$mb=[xml](get-content $args[0])

# Report to save
$ReportFile=$args[1]

# Hash table of metabase location # and ServerComment values from IIsWebServer keys
# Used to map virtual directories and other settings to their root websites by name
$LocationCommentMapping = @{}
$LocationBindingMapping = @{}

#------------------------------------------------------------------------------
# Array of virtual directories to ignore
#------------------------------------------------------------------------------
# These would be the 5th element of the metabase "location" key, i.e.:
# /LM/W3SVC/#/ROOT/VirtualDirectory
$VirtualDirectoryIgnoreList = New-Object System.Collections.ArrayList
ForEach ($key in $cfg.configuration.Exclusions.VirtualDirectory.add) {
$VirtualDirectoryIgnoreList.add($key.name) > $Null
}
#------------------------------------------------------------------------------

#------------------------------------------------------------------------------
# Array of websites to ignore
#------------------------------------------------------------------------------
# This would be the value of the metabase "ServerComment" key
$WebSiteIgnoreList = New-Object System.Collections.ArrayList
ForEach ($key in $cfg.configuration.Exclusions.WebSite.add) {
$WebSiteIgnoreList.add($key.name) > $Null
}
#------------------------------------------------------------------------------

#------------------------------------------------------------------------------
# Array of metabase IDs to ingore
#------------------------------------------------------------------------------
# These would be the 3rd element of the metabase "location" key, i.e.:
# /LM/W3SVC/3
$LocationIndexIgnoreList = New-Object System.Collections.ArrayList
ForEach ($key in $cfg.configuration.Exclusions.LocationIndex.add) {
$LocationIndexIgnoreList.add($key.name) > $Null
}
#------------------------------------------------------------------------------

#------------------------------------------------------------------------------
# Hash table of server renames
#------------------------------------------------------------------------------
# We take the setting from the metabase for what the servername is. However,
# if a server is renamed, the metabase retains the original name. This is hash
# table of known renames, to replace
$ServerRenames = @{}
ForEach ($key in $cfg.configuration.ServerRenames.add) {
$ServerRenames.add($key.key,$key.value)
}
#------------------------------------------------------------------------------

#------------------------------------------------------------------------------
# Disconnected Recordset to hold all our info before writing to disk
#------------------------------------------------------------------------------
$adVarChar = 200 # field type to be set to variant in recordset
$adFldIsNullable = 32 # to make recordset field nullable
$MaxCharacters = 255 # max size of field

$MBRecordSet = New-Object -com "ADOR.RecordSet"
$MBRecordSet.Fields.Append("Location", $adVarChar, $MaxCharacters, $adFldIsNullable)
$MBRecordSet.Fields.Append("Name", $adVarChar, $MaxCharacters, $adFldIsNullable)
$MBRecordSet.Fields.Append("Path", $adVarChar, $MaxCharacters, $adFldIsNullable)
$MBRecordSet.Fields.Append("ServerBindings", $adVarChar, $MaxCharacters, $adFldIsNullable)
$MBRecordSet.Fields.Append("AppPoolId", $adVarChar, $MaxCharacters, $adFldIsNullable)
$MBRecordSet.Fields.Append("AppRoot", $adVarChar, $MaxCharacters, $adFldIsNullable)
$MBRecordSet.Open()
#------------------------------------------------------------------------------

#------------------------------------------------------------------------------
# Objects containing the metabase settings we'll query
#------------------------------------------------------------------------------
$WebSites = $mb.configuration.MBProperty.IIsWebServer | select ServerComment,ServerBindings,AppPoolId,Location
$WebVDirs = $mb.configuration.MBProperty.IIsWebVirtualDir | select AppFriendlyName,Path,AppPoolId,Location,AppRoot
#------------------------------------------------------------------------------

#==============================================================================
# Process Metabase
#------------------------------------------------------------------------------
# Determine ServerName
#------------------------------------------------------------------------------
If (!$mb.configuration.MBProperty.IIsWebService.ServerComment) {
$WebServerName = "[UNKNOWN]"
} Else {
$WebServerName = $mb.configuration.MBProperty.IIsWebService.ServerComment
}
If ($ServerRenames.ContainsKey($WebServerName)) {
$WebServerName = $ServerRenames.Get_Item($WebServerName)
}
#------------------------------------------------------------------------------

#------------------------------------------------------------------------------
# Process the "IIsWebServer" key first to enumerate all root websites
#------------------------------------------------------------------------------
ForEach ($Site in $WebSites) {
# Site.Location is /LM/W3SVC/number. We want the "number"
$ThisLocationIndex = $Site.Location.Split('/')[3]

# Proceed if this isn't a website location index to ignore
If ($LocationIndexIgnoreList -notcontains $ThisLocationIndex) {
# Populate the recordset
$MBRecordSet.AddNew()
$MBRecordSet.Fields.Item("Location") = $Site.Location
$MBRecordSet.Fields.Item("Name") = $Site.ServerComment
$MBRecordSet.Fields.Item("AppRoot") = $Site.ServerComment

# Multiple bindings are separated by linebreaks and tabs. Lets convert to semi-colon
$TempBindings = [regex]::Replace($Site.ServerBindings,"`n",";");
$TempBindings = [regex]::Replace($TempBindings,"`t","");
$MBRecordSet.Fields.Item("ServerBindings") = $TempBindings

# Populate hash tables
$LocationBindingMapping.Add($ThisLocationIndex,$TempBindings)
$LocationCommentMapping.Add($ThisLocationIndex,$Site.ServerComment)
}
}
#------------------------------------------------------------------------------

#------------------------------------------------------------------------------
# Process the "IIsWebVirtualDir" key to enumerate all VDirs and root settings
#------------------------------------------------------------------------------
ForEach ($Site in $WebVDirs) {
# Site.Location is /LM/W3SVC/number/AppRoot/AppName
$ThisLocationIndex = $Site.Location.Split('/')[3]
$ThisAppName = $Site.Location.Split('/')[5]


# Proceed if this isn't a website location index to ignore
If ($LocationIndexIgnoreList -notcontains $ThisLocationIndex) {
# If there is no AppPoolId, it's the DefaultAppPool
If (!$Site.AppPoolId) {
$ThisAppPool="DefaultAppPool"
} Else {
$ThisAppPool=$Site.AppPoolId
}

If (!$ThisAppName) {
# Process /LM/W3SVC/#/ROOT sites, but not /ROOT/Vdir
# This updates existing root site properties from Bindings above
$MBRecordSet.MoveFirst();
do {
$MbLocationIndex = $MBRecordSet.Fields.Item("Location").Value.Split('/')[3]
If ($MbLocationIndex -eq $ThisLocationIndex) {
$MBRecordSet.Fields.Item("Path") = $Site.Path
$MBRecordSet.Fields.Item("AppPoolId") = $ThisAppPool

$MBRecordSet.Update()
}
$MBRecordSet.MoveNext()
} until ($MBRecordSet.EOF)

} Else {
# Process /LM/W3SVC/#/ROOT/Vdir sites
$ThisAppRoot = $LocationCommentMapping.Get_Item($ThisLocationIndex)
$ThisBinding = $LocationBindingMapping.Get_Item($ThisLocationIndex)

$MBRecordSet.AddNew()
$MBRecordSet.Fields.Item("Location") = $Site.Location
$MBRecordSet.Fields.Item("Path") = $Site.Path
$MBRecordSet.Fields.Item("Name") = $ThisAppName
$MBRecordSet.Fields.Item("AppRoot") = $ThisAppRoot
$MBRecordSet.Fields.Item("ServerBindings") = $ThisBinding
$MBRecordSet.Fields.Item("AppPoolId") = $ThisAppPool
}
}
}
#------------------------------------------------------------------------------
#==============================================================================
# Save the report
#------------------------------------------------------------------------------
# Start the CSV file, adding the headers
"IIS Website Name,Application Name,Server,Path,AppPoolId,ServerBindings,MetabaseLocationID" | out-file $ReportFile


# Loop through recordset and output results to CSV
$MBRecordSet.Sort = "AppRoot ASC"
$MBRecordSet.MoveFirst();
do {
$ThisLocationIndex = $MBRecordSet.Fields.Item("Location").Value.Split('/')[3]
$ThisWebSite = $MBRecordSet.Fields.Item("AppRoot").Value
$ThisVirtualDir = $MBRecordSet.Fields.Item("Name").Value

# Make sure we're not outputting items listed in the IgnoreList arrays
If (
$WebSiteIgnoreList -notcontains $ThisWebSite -and
$VirtualDirectoryIgnoreList -notcontains $ThisVirtualDir -and
$LocationIndexIgnoreList -notcontains $ThisLocationIndex
) {

$Output = $MBRecordSet.Fields.Item("AppRoot").Value.ToLower() + ","
$Output += $MBRecordSet.Fields.Item("Name").Value.ToLower() + ","
$Output += $WebServerName.ToLower() + ","
$Output += $MBRecordSet.Fields.Item("Path").Value.ToLower() + ","
$Output += $MBRecordSet.Fields.Item("AppPoolId").Value.ToLower() + ","
$Output += $MBRecordSet.Fields.Item("ServerBindings").Value.ToLower() + ","
$Output += $MBRecordSet.Fields.Item("Location").Value.ToLower()
$Output | out-file -append $ReportFile
}
$MBRecordSet.MoveNext()
} until ($MBRecordSet.EOF)
#==============================================================================

10 comments:

Anonymous said...

which version of ADO are you using to run this script.

Thanks

Aaron Dodd said...

Hello,

I'm running MDAC 2.8 SP2 under Windows 2003. Are you having issues with a different version?

Peter Nealon said...

Firstly this is a nice piece of work. However, having an issue

script is returning:

Cannot find an overload for "Append" and the argument count: "4".
At C:\Scripts\IIS_Metabase_Reporting\ReportMetabase.ps1:90 char:27

Running ps1 and mdac 2.81 on XP SP3.

Command that is being run is the following:

PS C:\IIS_Metabase_Reporting> .\ReportMetabase.ps1 C:\IIS_Metabase_Reporting\metabase.xml C:\IIS_Metabase_Reporting\repo
rt_test.csv

Insights?

Thanks

Peter

Peter Nealon said...

Just tested on Server 2003 SP1 with mdac 2.82 and scripts work fine. Very interesting and curious that under xp SP3 they aren't working. I may try updating mdac to match my server 2003 box.

Thanks for the great work though!

Aaron Dodd said...

Hi Peter, thanks. I haven't tested under any XP version (or Vista), so I'm not sure. If I get a chance I look. Personally, I run 2003 or 2008 on my desktop ;-) but I'll fire up a VM at some point.

Anonymous said...

I am getting
Get-Content : Cannot bind argument to parameter 'Path' because it is null.
at line 28 ReportMetabase.ps1
and
$MBRecordSet.Fields.Item( <<<< "ServerBindings") = $TempBindings
Exception setting "Item"
at line 139 ReportMetabase.ps1

This running the script on Win 2003 SP1

Any ideas?

Anonymous said...

Is the file hosted anywhere else? I seem to be missing the config files by just using what is on the site.

Aaron Dodd said...

My apologies for the missing attachments. It was due to a misconfigured .htaccess file. I've fixed it and the script should be available again.

Anonymous said...

Hi Aaron,

Any chance you could post the config files again?

The link to the zip file isn't working.

Thanks,

Lem

Anonymous said...

Hi Aaron,

I'm also interested in seeing an example of the format of the .config file. The link to the zip file is not working.

Thanks, Sarah.