In one of the Desktop Management meetings here at the University, the question arose as to how we could more accurately judge how many machines we could remotely manage. Many of the desktops had gone through a successful, but fairly invasive migration process to get them into the domain, and as such things usually happen, there are a few that are a bit…“wonky”. SMS works fine for these desktops, but since the SMS client on the desktop requests a package (i.e., pulls) from the server, successful package installation isn’t a good indicator of remote manageability (via scripting, etc.). There were also many other things we wanted to know–how many had IPSec functioning properly (used to secure remote administration traffic) being one of them. So, I decided to pull together a script that could be run against our desktop fleet to determine just how many machines were “healthy”, from a remote-manageability standpoint.

The script follows a pretty simple pattern: Can I ping the machine? If so, can I connect to the remote WMI provider? If so, finally query for and return some (user-configurable) data. The ping test starts it all off; if I can’t ping the machine, there’s no point in trying to do anything else. This also verifies that IPSec is working, since all traffic between our management server and the desktops uses IPSec–even ICMP.

The second test (can I connect via WMI?) verifies that the management server can get to the RPC endpoint mapper service (TCP port 135) and can successfully connect to the high port on which epmap tells us the WMI provider is listening. If this works it will return pretty quickly; if it doesn’t, the script has to wait something like 45 seconds for the attempted connection to time out (if anyone knows how to impose a shorter timeout in PowerShell, I’m all-ears). Once the script determines that WMI is open to us, it will begin querying the remote computer for the requested data.

Speaking of “requested data”, the script must be provided an XML file containing the data we want returned (see below for an example). One of the neat things about PowerShell is that it can take an XML file and dynamically build an object based on it (the DOM and all that). I used this to make the script self-contained, with only the attributes file needing to be modified if we wanted alternate data.

The script returns an object that contains all the requested values as Properties of the object. I can take this output and pipe it to, say, Export-Csv or Export-Clixml for better presentation in a spreadsheet or whatever.

To download the script, click here. From this point on, I’ll go through the script and explain the relevant bits, starting with line 38 (the first part is just licensing, etc.)

The first part is the param statement, followed by a BEGIN block. The param statement specifies what parameters and switches the script should expect. (Note below that if you don’t provide the script with the -AttributesFile parameter it will try to find a file called “Attributes.xml” in the current directory and use that.) The BEGIN block is where you can put code that should only be executed once for the entire pipeline, at the beginning before any pipeline data is processed.

param($Computer="", $AttributesFile="Attributes.xml", [switch]$v, $inputObject=$Null)

BEGIN { # This section executes before the pipeline.

    # This has something to do with pipelining.  
    # Let's call it "magic voodoo" for now.
    if ($inputObject) {
        Write-Output $inputObject | &($MyInvocation.InvocationName) -AttributesFile $AttributesFile
        break
    }

I feel I have to explain the “magic voodoo”, but I can’t explain it any better than the guy I stole it from, so check out this blog about what it all means.

The following section tries to find the Quest ActiveRoles Management Snapin. (You’ll need this in order for the script to function, as some data is gathered from AD about each computer object.) I think the comments below speak for themselves.

# Check to ensure that the Quest snapin has been registered.
# Iterate through all the loaded snapins, searching for the Quest snapin.
foreach ($snapin in (Get-PSSnapin | Sort-Object -Property Name)) {
    if ($snapin.name.ToUpper() -eq "QUEST.ACTIVEROLES.ADMANAGEMENT") {
    # Done, we have the extension and it's loaded.
    $questLoaded = $True
    break
    }
}
if (!($questLoaded)) {
    # The Quest snapin was not loaded, so see if the 
    # extension is at least registered with the system.
    foreach ($snapin in (Get-PSSnapin -registered | Sort-Object -Property Name)) {
        if ($snapin.name.ToUpper() -eq "QUEST.ACTIVEROLES.ADMANAGEMENT") {
            # Found the snapin; add it to the environment.
            trap { continue }
            Add-PSSnapin Quest.ActiveRoles.ADManagement
            Write-Host "Quest Active Directory Management Extensions found and added to this session."
            $questLoaded = $True
            break
        }
    }
}

if (!($questLoaded)) {
    # The Quest snapin is not installed on this system.
    # Print an error and bail.
    Write-Error -Category NotInstalled `
    -RecommendedAction "Install Quest Active Directory Management Extensions" `
    -Message "Quest Active Directory Management Extensions are not installed.  Please install the Extensions and re-run this command."
    continue
}

Next some variables are set up, and the script ensures that it can find an attributes file, and that it is valid XML. If it can, then it’s time to start processing the pipeline.

# Create the $ping object just once for the entire pipeline, so it can be reused.
$ping = New-Object
System.Net.NetworkInformation.Ping
    
    # See if an Attributes file was specified.  If not, bail.
    if ( !($AttributesFile) -or !(Test-Path $AttributesFile) ) {
        Write-Host "No Attributes file specified, or path does not exist: $AttributesFile"
        # Commented out so that we fall through to the PROCESS block and print the usage 
        # information there.
        #continue
    } else {
        # If we got here, then the Attributes file exists.  Try to load it.
        [xml]$attrFile = Get-Content -Path $AttributesFile
        if (!($attrFile)) {
            # Oops, either the file specified wasn't an XML file, or there was
            # an error in the file somewhere.  Bail.
            Write-Host "Error in $AttributesFile"
            continue
        }
    }
    
    # If we made it this far, start chucking stuff down the pipeline into the PROCESS block.
} # end 'BEGIN'

Like the comment says, the PROCESS block is executed for each object on the pipeline. This is where the real work happens, unless no data was passed in; if not, the script prints a usage statement and dies.

PROCESS {   # This section executes for each object in the pipeline.
    
    # Did we get data, either from the pipeline or explicitly on the command line?
    # If not, print out some (arguably "useful") help.
    if ( (!($_) -and !($Computer)) -or (!($AttributesFile)) ) {
        @"
        
USAGE:  ps_GetInfo [-v] -Computer <Computer> [-AttributesFile <Attributes.xml>]
Retrieve information from a remote computer.

Example: ps_GetInfo -Computer "$Env:ComputerName" -AttributesFile .\Attributes.xml

-v                  Verbose.  Writes the current computer name to the console.
                    Useful to monitor the progress of a pipeline operation.

-Computer           The computer to which you want to connect.

-AttributesFile     An XML file containing the attributes to retrieve.
                    If not specified, look for a file called "Attributes.xml"
                    in the current directory.

"@

        return
    }
    
    # If we got data via the pipeline, assign it to a named variable 
    # to make things easier to read.
    if ($_) { $Computer = $_ }

This part uses Quest’s tools to try to find the computer object in Active Directory.

# Try to find the computer in AD.  I guess this isn't really needed,
# but it's one more validation that the computer exists.
if ($Computer.GetType().Name -eq "ArsComputerObject") {
    # A Computer Object was passed; set an interim variable to the ArsComputerObject.
    $objComputer = $Computer
} elseif ($Computer.GetType().Name -eq "String") {
    # A bare string was passed.  Turn it into an ArsComputerObject and
    # find it in Active Directory, then return the DnsName.
    $objComputer = Get-QADComputer -Name $Computer
    if (!($objComputer)) {
        # The computer account wasn't found in AD.  Bail on this object.
        Write-Host "$Computer is not in Active Directory."
        return
    }
} else {
    # We have no idea what this object is.  Bail.
    Write-Host "$Computer is not an ArsComputerObject or a String."
    return
}

Here, I create a new object to hold all the (hopefully) returned data. Again, the comments should speak for themselves.

# Construct a new generic object to represent the Computer and use 
# Add-Member to add some generic properties to the object.
$Computer = New-Object PSObject
$Computer = Add-Member -PassThru -InputObject $Computer NoteProperty Name $objComputer.DnsName
# This property specifies whether the computer was able to be pinged.
Add-Member -InputObject $Computer NoteProperty Pingable $False
# This property specifies whether the script could connect to WMI on the remote computer.
Add-Member -InputObject $Computer NoteProperty ConnectViaWmi $False
# This property records the ModifcationDate (WhenChanged) property from the AD object.
# Useful to decide how stale the computer object is.
Add-Member -InputObject $Computer NoteProperty ADModificationDate $objComputer.ModificationDate

# Iterate over the WMI Classes and Properties specified in the Attributes file and 
# add properties to the $Computer object corresponding to the various
# attributes in the Attributes file.
foreach ($class in $attrFile.Attributes.Wmi.Classes.Class) {
    foreach ($property in $class.Property) {
        # Make sure to Trim() off any random whitespace from the XML file.
        Add-Member -InputObject $Computer NoteProperty $property.Trim() $null
    }
}

# Iterate over the files specified in the Attributes file and
# add properties to the $Computer object.
foreach ($file in $attrFile.Attributes.Files.File) {
    # Trim() off any random whitespace from the XML file.
    $propName = $file.Name.Trim()
    Add-Member -InputObject $Computer NoteProperty $propName $null
}

Try to ping the target computer. This uses the .NET Class System.Net.NetworkInformation.Ping . (Dynamic typing is great, but sometimes I like strongly-typed objects, I guess.)

# Since System.Net.NetworkInformation.Ping.Send() will generate
# a nasty exception if the ping fails, and since it's a .NET object
# (and doesn't implement the "-ErrorAction" parameter), 
# suppress printing exceptions for this call.
$ErrorActionPreference = "SilentlyContinue"
$reply = $null
# Ping the computer to see if it is alive.
[System.Net.NetworkInformation.PingReply]$reply = $ping.Send($Computer.Name)
$ErrorActionPreference = "Continue"

# The following logic test uses the .NET Enumeration 
# instead of a string match, just in case.
if ($reply.Status -eq [System.Net.NetworkInformation.IPStatus]::Success ) {
    # The computer is pingable, so note that and do some more stuff.
    $Computer.Pingable = $True

Here’s the workhorse section: verify that we can connect to WMI, and then for each attribute specified in the file, query the remote computer for it and save it in our computer object.

    # Clear the $wmi variable.
    $wmi = $null
    
    # Iterate through all WMI classes and get the various attributes
    # specified in the Attributes.xml file.
    foreach ($class in $attrFile.Attributes.Wmi.Classes.Class) {
        $wmi = Get-WmiObject -ComputerName $Computer.Name -ErrorAction SilentlyContinue $class.Name
        
        if ($wmi) {
            # We were able to connect to WMI.  Note this and continue.
            $Computer.ConnectViaWmi = $True
            
            # This actually retrieves the requested properties 
            # from the specified WMI class and sets the relevant
            # property on the $Computer object.
            foreach ($property in $class.Property) {
                # Get the value of the property, trimming off any whitespace
                # that may have made it into the XML file.
                $property = $property.Trim()
                
                # On the off-chance that this Property is of the type CimType.DateTime,
                # convert it to a more friendly string.
                if ($wmi.PSBase.Properties["$property"].Type -eq [System.Management.CimType]::DateTime) {
                    # Retrieve the property, convert it, and save it.
                    $Computer.$property = $wmi.ConvertToDateTime($wmi.$property)
                } else {
                    # Retrieve the property and save it.
                    $Computer.$property = $wmi.$property
                } # end if
            } # end foreach ($property)
        } # end if ($wmi)
    } # end foreach ($class)

This next section retrieves file information, also via WMI.

        # Since getting file information is a WMI call, only attempt it 
        # if the initial connection to WMI was successful.
        if ($wmi) {
            # Iterate over all of the files specified in the Attributes file 
            # and record the relevant information.
            foreach ($f in $attrFile.Attributes.Files.File) {
                # Just like above, construct the basename for the property name
                $baseName = $f.Name.Trim()
                # The WMI call wants escaped backslashes.
                $file = $baseName.Replace("\", "\\")
                
                # Retrieve the file's metadata from WMI (hopefully).
                $fObj = Get-WmiObject -ComputerName $Computer.Name -Class CIM_Datafile -Filter "Name=`'$file`'" -ErrorAction SilentlyContinue
                
                # If the file was found...
                if ($fObj) {
                    # ...and it has a version number...
                    if ($fObj.Version) {
                        # ...write that to the $Computer object...
                        $Computer.$baseName = $fObj.Version
                    } else {
                        # ...or else just record the fact that it was found.
                        $Computer.$baseName = $True
                    }
                } else {
                    # The file wasn't found, so set the property to false.
                    $Computer.$baseName = $False
                } # end if
            } # end foreach ($f)
        } # end if ($wmi)
    } # end if (Pingable)
    
    # We're done with this computer, so output it to pass it on down the pipeline.
    Write-Output $Computer}

The pipeline is now over, and the last section is the END block…which is not needed here, so it’s empty.

    END {   # This section executes only once, after the pipeline.
        # Of course, there's not much we need to do here.
    } # end 'END'
} # end function

And with that, we’re done with the script. Now, on to the attributes file and what it should look like:

<Attributes>
<Wmi>
    <Classes>
        <Class Name="Win32_OperatingSystem">
            <Property>
            LastBootUpTime
            </Property>
            <Property>
            ServicePackMajorVersion
            </Property>
            <Property>
            ServicePackMinorVersion
            </Property>
        </Class>
        <Class Name="Win32_ComputerSystem">
            <Property>
            UserName
            </Property>
        </Class>
        <Class Name="Win32_BIOS">
            <Property>
            SerialNumber
            </Property>
        </Class>
    </Classes>
</Wmi>
<Files>
    <File Name="C:\Program Files\Adobe\Reader 9.0\Reader\AcroRd32.dll"/>
    <File Name="C:\Program Files\Adobe\Reader 8.0\Reader\AcroRd32.dll"/>
</Files>
</Attributes>

Hopefully this is easy enough to understand: the <Class Name="..."> sections describe which WMI class to query, and the <Property/> sections within each <Class> section describe the various properties to retrieve. The <Files/> section describes which files to check. Note that the script only checks for the existence of the file and will return the version number, if it has one. If I get around to it, I’d like to update the script to also check files for a specific version number (which is why the <File/> tags have Version values).

The output of this script looks something like this:

PS C:\> .\ps_GetInfo.ps1 -Computer "my_computer"


Name                                                        : my_computer.fqdn
Pingable                                                    : True
ConnectViaWmi                                               : True
ADModificationDate                                          : 4/20/2009 6:33:34 PM
LastBootUpTime                                              : 4/16/2009 5:16:37 PM
ServicePackMajorVersion                                     : 0
ServicePackMinorVersion                                     : 0
UserName                                                    : DOMAIN\wrightst
SerialNumber                                                : [DellServiceTagHere]
C:\Program Files (x86)\Adobe\Reader 9.0\Reader\AcroRd32.dll : 9.1.0.163
C:\Program Files\Adobe\Reader 8.0\Reader\AcroRd32.dll       : False

Granted, it probably would look better if I didn’t have to abstract some of the more important parts, but that’s what you get. If you wanted to run this command against an entire OU, you can use the Quest extensions to do something like this:

PS C:\> Get-QADComputer -SearchRoot my.domain.local/My/Search/Root | 
>> .\ps_GetInfo -AttributesFile .\Attributes.xml

or

PS C:\> Get-QADComputer -SearchRoot my.domain.local/My/Search/Root | 
>> .\ps_GetInfo -v -AttributesFile .\Attributes.xml | Export-Csv output.csv

The first line will output the results from processing each computer object just like above. For better-looking output, pipe the command through Export-Csv like the last line, and produce a nice report. (The ‘-v’ switch in the second command line will output the name of the computer the script is currently working on; without it there is no visible sign that the script is working, since all output is being piped to the Export-Csv cmdlet.)

I know there are a few minor bugs with this script (currently you have to query for a file–even if it’s just a nonexistent file–or else the script complains loudly), but I just finished running it against a few thousand computers with no errors, so it still works pretty well. ;-) If you see something that could be done better or easier, or if this helps you in your environment, please let me know!