PowerShell: Removing a list of computers from Active Directory

@Kid_Zer0 on Twitter recently asked the following question:

Need to delete a list of computers from AD – anyone know how to do this in #PowerShell or #VBScript (List is from a file)

I’ve previously written several versions of an Active Directory cleanup script, but if you’re not seeking something that complicated, you can simply leverage the cmdlets built into Microsoft Windows Server 2008 R2. This simple, one-liner command should take a list of computers from a text file, and remove them from Active Directory:

Get-Content ComputersToDelete.txt | % { Get-ADComputer -Filter { Name -eq $_ } } | Remove-ADComputer -WhatIf

Upon running this command, you should receive output similar to the following (my text file only has one computer account in it):

If you want the script to actually make changes, simply leave the -WhatIf off the end of the script. Keep in mind that this command also depends on the input text file to be in the local directory, otherwise you will need to specify the full path to the text file. Another assumption in this command, is that you are running the command from a server that is a member of the same domain that you want to remove accounts from. If you have a more complex Active Directory environment, you will need to use other parameters to specify the domain / forest, and credentials if necessary, that you need to act against.

Hope this helps!

PowerShell: Finding Currently Loaded DLLs

I was just browsing through the rootcimv2 WMI namespace this morning, using SAPIEN’s free WMI Explorer tool, when I happened across a WMI class called CIM_ProcessExecutable. In fact, what I was doing in a bit more detail, was going through the CIM_* classes, with the Instances tab selected, so I could discover if any of them were actually usable. I was stepping through them, in reverse order, by hitting {Up Arrow} + {Enter}, and finally came across one that had instances (this one, being CIM_ProcessExecutable).

Now, by clicking through a handful of these instances, it became quickly apparent to me that this class could be useful. It appeared that the classes’ purpose was to correlate running processes (instances of Win32_Process) with files on the filesystem (instances of CIM_DataFile), that are executable. Executable files would typically include files with the .dll or .exe file extensions.

Depending on what you are trying to accomplish, this could be incredibly useful information. For example, a lot of malware can load itself as a DLL in another process, or more simply, as its own executable. This info would be useful in discovering some (but not all) malware. Additionally, since you can get a reference to specific files on the filesystem, you could then determine the version of the file that is loaded into memory. This could be helpful if you are troubleshooting an issue that requires a specific version of an executable file.

Exploring the Class

If we simply enumerate a list of CIM_ProcessExecutable instances, we will get a long list of WMI paths to instances of other classes, specifically Win32_Process and CIM_DataFile. Go ahead and fire up you favorite PowerShell editor, or just the PowerShell console itself, and run this command:

Get-WmiObject -Namespace rootcimv2 -Class CIM_ProcessExecutable

Let’s find out how many executable files are currently loaded on the system. We do that simply by assigning the results of the above command to a PowerShell variable, and then echoing the value of its Count or Length property:

$ProcExes = Get-WmiObject -Namespace rootcimv2 -Class CIM_ProcessExecutable
Write-Host $ProcExes.Count

Retrieving File Properties

Let’s say that we want to just grab a list of interesting file properties, for the loaded executables. In order to do that, we can iterate over the items in $ProcExes, pass the WMI path for the CIM_DataFile instance to the WMI type accelerator in PowerShell, and then select a subset of the properties on the CIM_DataFile instance. Here’s how we do that:

$ProcExes = Get-WmiObject -Namespace rootcimv2 -Class CIM_ProcessExecutable

foreach ($item in $ProcExes)
{
    # Get the CIM_DataFile instance from the WMI path in Antecedent
    # Pass the CIM_DataFile object to the Select-Object cmdlet, and select only a few properties
    [wmi]"$($item.Antecedent)" | select FileName,Extension,Manufacturer,Version
}

Here is a sample of the output we’d get from running the above script:

Now we can take that even a step further. Let’s say our goal is to find out these same file properties, but only for non-Microsoft executables. This could help in a malware investigation, even a remote one, since we’re just using WMI here! We can use the Where-Object PowerShell cmdlet to filter the results that are written to the console. FYI, I am using the default short-hand for Where-Object, which is simply a question mark; Don’t let the syntax confuse you :)

foreach ($item in $ProcExes)
{
    # Get the CIM_DataFile instance from the WMI path in Antecedent
    # Filter for only files that are NOT from Microsoft
    # Pass the CIM_DataFile object to the Select-Object cmdlet, and select only a few properties
    [wmi]"$($item.Antecedent)" | ? { $_.Manufacturer -ne 'Microsoft Corporation' } | select FileName,Extension,Manufacturer,Version
}

As you can see, we’ve added the necessary code to filter out non-Microsoft files. That will work, at least as long as the file’s metadata is properly filled out. As you’ll see from the following screenshot, we still have some files that are obviously Microsoft PowerShell related, but were not caught because the Manufacturer field was missing from the assembly’s metadata.

Viewing Executables for a Process

Since the CIM_ProcessExecutable class binds executable files and processes together (these are not typically a 1:1 relationship), you might want to view all of the executable files that a particular process has loaded. The Dependent property of CIM_ProcessExecutable contains the WMI path to the process that has a particular executable file loaded. Each process has a unique ProcessID, but processes (instances of rootcimv2:Win32_Process) are uniquely identified in WMI by the Handle property. This is verified by examining the Win32_Process.Handle property in WMI Explorer (or wbemtest), and finding that the Key WMI qualifier is set to “True.” The discussion of WMI qualifiers is beyond the scope of this article, but in short, they are metadata that describe WMI properties. You can read more about WMI qualifiers on MSDN.

Typically, we won’t know the handle of a process, but we will likely know at least the name, or maybe even the process ID. Therefore, our process flow, for the goal we just mentioned, would look something like this:

  1. Input a process name
  2. Get the process Handle for the process(es) — we will actually just get the built-in __PATH property, because this directly correlates to the Dependent property on CIM_ProcessExecutable. Then we can avoid doing a LIKE query with just the process handle.
  3. Retrieve instances of CIM_DataFile for the process handle (this would be a string comparison on the Dependent property)
  4. For each instance of CIM_DataFile we retrieve: 1) get an instance of CIM_DataFile using the WMI path in Antecedent, 2) Echo the file properties to the console

Let’s take a look at the code necessary to make the above workflow happen:

Clear-Host

function Get-Executables()
{
    PARAM (
        [string] $ProcessName = $(throw "Please specify a process name.")
    )

    process
    {
        # Retrieve instance of Win32_Process based on the process name passed into the function
        $Procs = Get-WmiObject -Namespace rootcimv2 -Query "select * from Win32_Process where Name = '$ProcessName'"

        # If there are no processes returned from the query, then simply exit the function
        if (-not $Procs)
        {
            Write-Host "No processes were found named $ProcessName"
            break
        }
        # If one process is found, get the value of __PATH, which we will use for our next query
        elseif (@($Procs).Count -eq 1)
        {
            Write-Verbose "One process was found named $ProcessName"
            $ProcPath = @($Procs)[0].__PATH
            Write-Verbose "Proc path is $ProcPath"
        }
        # If there is more than one process, use the process at index 0, for the time being
        elseif ($Procs.Count -gt 1)
        {
            Write-Host "More than one process was found named $ProcessName"
            $ProcPath = @($Procs)[0].__PATH
            Write-Host "Using process with path: $ProcPath"
        }

        # Get the CIM_ProcessExecutable instances for the process we retrieved
        $ProcQuery = "select * from CIM_ProcessExecutable where Dependent = '$ProcPath'".Replace("","\")

        Write-Verbose $ProcQuery
        $ProcExes = Get-WmiObject -Namespace rootcimv2 -Query $ProcQuery

        # If there are instances of CIM_ProcessExecutable for the specified process, go ahead and grab the important properties
        if ($ProcExes)
        {
            foreach ($ProcExe in $ProcExes)
            {
                # Use the [wmi] type accelerator to retrieve an instance of CIM_DataFile from the WMI __PATH in the Antecentdent property
                $ExeFile = [wmi]"$($ProcExe.Antecedent)"
                # If the WMI instance we just retrieve "IS A" (think WMI operator) CIM_DataFile, then write properties to console
                if ($ExeFile.__CLASS -eq 'CIM_DataFile')
                {
                    Select-Object -InputObject $ExeFile -Property FileName,Extension,Manufacturer,Version -OutVariable $Executables
                }
            }
        }
    }

    # Do a little clean-up work. Not exactly necessary, but useful for debugging in PowerGUI
    end
    {
        Write-Verbose "End: Cleaning up variables used for function"
        Remove-Item -ErrorAction SilentlyContinue -Path variable:ExeFile,variable:ProcessName,variable:ProcExe,
        variable:ProcExes,variable:ProcPath,variable:ProcQuery,variable:Procs
    }
}

# Call the function we just defined, with its single parameter
. Get-Executables gmailgrowl.exe

Here is an example of the output we would get, from running the above code. Make sure to change the process name, at the very bottom of the script, where we call the function, to something you’re interested in discovering information about.

Conclusion

That’s all I’ve got for now, folks! Hopefully this gives you some ideas around what else you can do with this information :) There are lots of uncovered gems out there to help you with systems management, and this is just another one of them!

New 1E WakeUp iPhone App


Notice: Undefined index:  :) in C:\HostingSpaces\pcgeek86\trevorsullivan.net\wwwroot\wp-includes\formatting.php on line 1736

1E has recently gotten a new, awesome application approved on the Apple AppStore called 1E WakeUp, or 1E Remote WakeUp!

Cool, what does it do?

This app enables iPhone, iPod Touch, and iPad users to remotely wake up their corporate computers, on networks where the 1E Power & Patch Management solution (NightWatchman & WakeUp) has been implemented. Most workstations, when configured properly, can be awoken from most ACPI power states, including S5 (shutdown), S4 (hibernate / suspend to disk), and S2-3 (standby / sleep).

Soooo why do I need it?

Sometimes it’s easier to respond to questions with questions!

Do you have an iPhone and a computer at the office that you sometimes need to access from home? Are you an IT administrator, or helpdesk user, that may need to wake up corporate computers to work on remotely (from home or afar)? If you answered “yes” to either of these questions, then it’s very possible that the 1E WakeUp iOS app will be of great use to you!

If you’re an end-user of corporate-owned device, 1E WakeUp can help keep you more productive, by remotely waking up your corporate PC, so you can use it whenever you need it available!

If you’re an IT administrator, or in helpdesk support, you can use 1E WakeUp to remotely power on computers, so you can work on them from anywhere around the world! Maybe you could even talk your manager into letting you work from the beach in Hawaii!! :)

Sounds awesome! How do I get it?

Open the AppStore on your iOS-powered device, and search for “1E WakeUp” The only result you should get (at the time of this writing), is the 1E WakeUp app.

When was it published?

The “Post Date” on the Apple AppStore shows that the application was published on August 6th, 2010.

How large is the download?

The AppStore shows the size of the 1E WakeUp app is 706 kilobytes (KB).

How old do I have to be to use it?

The AppStore shows that the 1E WakeUp app is safe to use for 4 years and older. Â

1E WakeUp iOS Guide

Assumptions

This guide assumes that you have:

  • Implemented 1E WakeUp in your corporate network environment OR
  • Implemented 1E WakeUp in a lab environment
  • An iOS-powered device, such as an Apple iPhone, iPod Touch, or iPad
  • Your iOS device has access to your WakeUp server and can authenticate to AD — usually satisfied by corporate Wifi or VPN access
  • You know the name (or part of the name) of your computer at the office

Installing the App

  1. Visit the AppStore
  2. Go to the Search page
  3. Type “1E WakeUp” in the search box and hit Search

    AppStore Search Screen

    AppStore Search Screen

  4. Select the 1E WakeUp app

    AppStore Search Result

    1E WakeUp AppStore Search Result

  5. Hit the “FREE” / Install button

    Install 1E WakeUp App

    Install 1E WakeUp App

  6. Type your iTunes password

After following these directions, you should have the 1E WakeUp App on your iOS device’s home screen!

App Setup

After you download the 1E WakeUp app, launch it from your home screen. By default, the app will be “connected” to a demo server, which will let you sample the functionality of the app, using a few, fake client machines. A small bit of configuration is required, but you’ll be up and running in no time!

On the app’s main screen, hit the Preferences button.

1E WakeUp App - Main Screen

1E WakeUp App - Main Screen

You’ll see the demo server listed under the Servers heading, but we want to add our own server, so hit “Add Server

1E WakeUp App - Preferences Screen

1E WakeUp App - Preferences Screen

Type the fully qualified DNS name of your 1E Web WakeUp server in the Server field. If you have 1E Web WakeUp installed on a server separate from your 1E WakeUp Server, you’ll need to ensure that you enter the former! In the Account field, enter your domain and username in the format: <domain><username>. If you’re an end user, and are unsure of the domain name, check with your administrator or helpdesk. The Password field ought to be self-explanatory, but if not … enter your password here :) If your Web WakeUp web service is configured to use TLS, click the HTTPS button. If you’re unsure of this setting, try HTTP; If it doesn’t work, then try HTTPS.

1E WakeUp App - Server Settings Screen

1E WakeUp App - Server Settings Screen

Now just click the Save button, and you’re all set to start waking up computers!

Waking up Computers

Now that you’ve got the 1E WakeUp App configured to talk to the server, you can start waking up computers! Let’s start out by going back to the main screen of the WakeUp App. If you know your exact computer name, select the Wake Up by Name option; If you only know part of your computer name, or aren’t sure, you can select Wake Up Search, which will let you type any part of the computer name to find it.

1E WakeUp App - Main Screen

1E WakeUp App - Main Screen

Let’s do a search for now, even though we’re using the whole computer name

1E WakeUp App - Computer Search Screen

1E WakeUp App - Computer Search Screen

As you can see from the above search, we get a single client back in our search results

1E WakeUp App - Computer Search Results Screen

1E WakeUp App - Computer Search Results Screen

If we click on the computer, it takes us to the wake-up page, and attempts to wake the computer

1E WakeUp App - WakeUp Machine

1E WakeUp App - WakeUp Machine

Or if the computer is already awake, then it simply tells us that, that is the case

1E WakeUp App - WakeUp Machine (Already awake)

1E WakeUp App - WakeUp Machine (Already awake)

Once we’ve woken the computer, we have the option Register Machine which lets us set our default machine on the app’s main screen. This saves us having to manually type the computer name, or search for it in the database, every time we want to wake it up.

Once you’ve successfully woken your computer, you should be able to access it using your normal remote control routine. Usually, this would involve establishing a VPN session to your corporate network and using Remote Desktop (built into Windows).

Conclusion

This article has discussed the features of the 1E WakeUp app on the Apple AppStore. Remember that, in order to use this application, you’ll need to have the 1E NightWatchman & WakeUp solution implemented in your enterprise IT environment. Check with your IT staff, to see if this functionality is available, and if not, ask them if they can get it! :)

Until next time …!

Cheers,

Trevor Sullivan

PowerShell: AD / SCCM Workstation Cleanup Script Version 3.0

I just realized, I still haven’t posted the script that removes SCCM resources, alongside Active Directory cleanup. I had written a version 3.0 of a script I previously posted, but never posted it. So, here it is (I haven’t tested it in a while):

Disclaimer: I am not responsible for what you do with this script.

Update (2010-08-18): Shay Levy (PowerShell MVP) has noted that using Replace(“/”, “-”) will not work in all cultures. Rather, he suggests using this format: (Get-Date -f ‘M-d-yyyy hMMss tt’)

##############################################################################
#
# Author: Trevor Sullivan
#
# Date: October 28, 2009
#
# Lessons learned:
#
# 1. ADSI property names are lower case using DirectorySearcher or DirectoryEntry
# 2. Must explicitly cast 64-bit integers from AD
# 3. The Excel API is terrible (already knew that)
#
#
# Change Log:
#    2009-11-06
#        -Added: function to delete objects from SCCM (untested)
#        -Added: User variables at top of script to ease usage
#        -Added: function to auto-detect SCCM site code, based on server name
#        -Added: Windows Vista accounts to search criteria
#        -Fixed: Replaced -bxor operator with -bor to prevent computer accounts
#                from being re-enabled
#        -Fixed: Casted [void] from loading Excel Interop assembly to prevent
#                Assembly object from being written to pipeline
#
##############################################################################


### Populate these variables please. ###
$ExcelLog = $Env:USERPROFILE + '' + (Get-Date).ToString().Replace("/","-").Replace(":","") + " AD Workstation Cleanup.xlsx" # Full path to save Excel log to
$TargetDn = 'cn=computers,dc=ts,dc=loc' # Top-level distinguishedName 
$DisabledDn    = 'ou=Disabled,ou=Workstations,dc=ts,dc=;pc' # OU to place disabled accounts into
$DisableAge = 60 # Age (in days) of computer account, to be disabled
$DeleteAge = 30 # Age of computer account (<DisabledDate> + X) to delete computer accounts
$SccmServer    = 'sccm01' # The server on which your SMS Provider component is installed
$BreakStuff = $false # If set to $true, the script WILL TAKE ACTION!
$Debug = $false # Enables additional logging to file and stdout
###  ### ############################# ###  ###

function DisableOldAccounts(${TargetDn}, ${DisableAge} = 60)
{
    ${Computers} = GetComputerList ${TargetDn}

    foreach (${Computer} in ${Computers})
    {
        # PwdLastSet is a 64-bit integer that indicates the number of 100-nanosecond intervals since 12:00 AM January 1st, 1601
        # The FromFileTime method converts a 64-bit integer to datetime
        # http://www.rlmueller.net/Integer8Attributes.htm
        ${PwdLastSet} = [DateTime]::FromFileTime([Int64]"$(${Computer}.Properties['pwdlastset'])")
        ${CompAge} = ([DateTime]::Now - $PwdLastSet).Days
        if (${CompAge} -gt ${DisableAge})
        {
            LogMessage "$($Computer.Properties['cn']) age is ${CompAge}. Account will be disabled" 2
            WriteDisabledEntry $Computer.Properties['cn'].Item(0) $CompAge $Computer.Properties['distinguishedname'].Item(0) $DisabledDn
            DisableAccount $Computer.Properties['distinguishedname'].Item(0)
        }
        else
        {
            LogMessage "$($Computer.Properties['cn'].Item(0)) age is ${CompAge}, $($Computer.Properties['pwdlastset'].Item(0)), ${PwdLastSet}" 1
        }
    }
}

# Gets a full list of computer accounts from the target distinguishedName defined at the top of the script
function GetComputerList($TargetDn)
{
    # Define the LDAP search syntax filter to locate workstation objects.
    # See this link for info: http://msdn.microsoft.com/en-us/library/aa746475(VS.85).aspx
    ${tFilter} = '(&(objectClass=computer)(|(operatingSystem=Windows 2000 Professional)(operatingSystem=Windows XP*)(operatingSystem=*Vista*)(operatingSystem=Windows 7*)))'

    # Create a DirectorySearcher using filter defined above
    ${Searcher} = New-Object System.DirectoryServices.DirectorySearcher $tFilter
    # Set the search root to the distinguishedName specified in the function parameter
    ${Searcher}.SearchRoot = "LDAP://${TargetDn}"
    # Search current container and all subcontainers
    ${Searcher}.SearchScope = [System.DirectoryServices.SearchScope]::Subtree
    # See this link for info on why this next line is necessary: http://www.eggheadcafe.com/software/aspnet/32967284/searchall-in-ad-ldap-f.aspx
    ${Searcher}.PageSize = 1000
    $Results = $Searcher.FindAll()
    LogMessage "Found $($Results.Count) computer accounts to evaluate for disablement" 1
    return $Results
}

# Set description on computer account, disable it, and move it to the Disabled OU
function DisableAccount($dn)
{
#    LogMessage "DisableAccount method called with param: ${dn}" 1
    # Get a reference to the object at <distinguishedName>
    $comp = [adsi]"LDAP://${dn}"
    # Disable the account
#    LogMessage "userAccountControl ($($comp.Name)) is: $($comp.userAccountControl)"
    $comp.userAccountControl = $comp.userAccountControl.Value -bor 2
    # Write the current date to the description field
    if ($comp.Description -ne '') { LogMessage "Description attribute of ($comp.Name) is set to: $($comp.Description)" 2 }
    $comp.Description = "$(([DateTime]::Now).ToShortDateString())"

    # Uncomment these lines to write changes to Active Directory
    if ($BreakStuff)
    {
        [Void] $comp.SetInfo()
        $comp.psbase.MoveTo("LDAP://${DisabledDn}")
    }
}

# Parameter ($DeleteAge): Days from disable date to delete computer account
function DeleteDisabledAccounts($DeleteAge)
{
    # Get reference to OU for disabled workstation accounts
    ${DisabledOu} = [adsi]"LDAP://${DisabledDn}"

    ${Searcher} = New-Object System.DirectoryServices.DirectorySearcher '(objectClass=computer)'
    ${Searcher}.SearchRoot = ${DisabledOu}
    ${Searcher}.SearchScope = [System.DirectoryServices.SearchScope]::Subtree
    # Page size is used to return result count > default size limit on domain controllers.
    # See: http://geekswithblogs.net/mnf/archive/2005/12/20/63581.aspx
    ${Searcher}.PageSize = 1000
    LogMessage "Finding computers to evaluate for deletion in container: ${DisabledDn}" 1
    ${Computers} = ${Searcher}.FindAll()

    foreach (${Computer} in ${Computers})
    {
        ${DisableDate} = [DateTime]::Parse(${Computer}.Properties['description'])
        trap {
            LogMessage "Couldn't parse date for $($Computer.Properties['cn'])" 3
            continue
        }
        ${CurrentAge} = ([DateTime]::Now - ${DisableDate}).Days
        if (${CurrentAge} -gt ${DeleteAge})
        {
            LogMessage "$(${Computer}.Properties['cn']) age is ${CurrentAge} and will be deleted" 2
            WriteDeletedEntry $Computer.Properties['cn'].Item(0) $CurrentAge $Computer.Properties['distinguishedname'].Item(0) "Note"
            if ($BreakStuff)
            {
                $DisabledOu.Delete('computer', 'CN=' + ${Computer}.Properties['cn'])
            }
            RemoveFromSccm ${Computer}.Properties['cn'] $SccmServer
        }
        else
        {
            LogMessage "$(${Computer}.Properties['cn']) age is ${CurrentAge} and will not be deleted" 1
        }
    }
}

# Purpose: This function deletes a resource from the Configuration Manager database
function RemoveFromSccm($tPcName, $tSiteServer)
{
    $tSysQuery = "select * from SMS_R_System where Name = '$tPcName'"
    $tWmiNs = "rootsmssite_" + $Global:SccmSiteCode
    if ($Debug)
    {
        LogMessage "Site code is: $Global:SccmSiteCode" 1
        LogMessage $tSysQuery 1
        LogMessage "tSiteServer is: $tSiteServer" 1
        LogMessage "tWmiNs is: $tWmiNs" 1
    }
    $Resources = Get-WmiObject -ComputerName $tSiteServer -Namespace $tWmiNs -Query $tSysQuery

    if ($Resources -eq $null) { return; }

    foreach ($Resource in $Resources)
    {
        $AgentTime = $($Resource.AgentTime | Sort-Object | Select-Object -Last 1)
        $UserName = $Resource.LastLogonUserDomain + '' + $Resource.LastLogonUserName
        # Log the deleted SCCM resource to the Excel log
        WriteSccmDeletionEntry $Resource.ResourceID $Resource.Name $AgentTime $UserName
        # This line deletes records from the ConfigMgr database
        if ($BreakStuff)
        {
            # Delete the resource from the ConfigMgr site server
            $resource.Delete()
        }
    }
}

# Purpose: This function looks up the site code for the SMS Provider, given a server name
function GetSiteCode($tSiteServer)
{
    # Dynamically obtain SMS provider location based only on server name
    $tSiteCode = (Get-WmiObject -ComputerName $tSiteServer -Class SMS_ProviderLocation -Namespace rootsms).NamespacePath
    # Return only the last 3 characters of the NamespacePath property, which indicates the site code
    return $tSiteCode.SubString($tSiteCode.Length - 3).ToLower()
}

# This function logs a message to the console and a log file.
# Params:
#    $tMessage = A string representing the message to be logged to the file & console
#    $Severity = A integer from 1-3 representing the severity of the message: Info, Warning, Error
function LogMessage(${tMessage}, ${Severity})
{
    switch(${Severity})
    {
        1 {
            $LogPrefix = "INFO"
            $fgcolor = [ConsoleColor]::Blue
            $bgcolor = [ConsoleColor]::White
        }
        2 {
            $LogPrefix = "WARNING"
            $fgcolor = [ConsoleColor]::Black
            $bgcolor = [ConsoleColor]::Yellow
        }
        3 {
            $LogPrefix = "ERROR"
            $fgcolor = [ConsoleColor]::Yellow
            $bgcolor = [ConsoleColor]::Red
        }
        default {
            $LogPrefix = "DEFAULT"
            $fgcolor = [ConsoleColor]::Black
            $bgcolor = [ConsoleColor]::White
        }
    }

    if ($Debug)
    {
        Add-Content -Path "AD-Workstation-Cleanup.log" -Value "$((Get-Date).ToString()) ${LogPrefix}: ${tMessage}"
        Write-Host -ForegroundColor $fgcolor -BackgroundColor $bgcolor -Object "$((Get-Date).ToString()) ${LogPrefix}: ${tMessage}"
    }
}

function SetupExcel()
{
    LogMessage "Setting up Excel logging" 1
    [void] [System.Reflection.Assembly]::LoadWithPartialName("Microsoft.Office.Interop.Excel")
    $Global:Excel = New-Object Microsoft.Office.Interop.Excel.ApplicationClass
    $Excel.Visible = $true
    $Global:Workbook = $Excel.Workbooks.Add()

    # Setup worksheet for deleted SCCM resource records
    $Global:SccmResourceLog = $Workbook.Worksheets.Item("Sheet3")
    $SccmResourceLog.Name = "SCCM Resources"
    $SccmResourceLog.Tab.ThemeColor = [Microsoft.Office.Interop.Excel.XlThemeColor]::xlThemeColorAccent3
    $SccmResourceLog.Cells.Item(1, 1).Value2 = "Date"
    $SccmResourceLog.Cells.Item(1, 2).Value2 = "Resource ID"
    $SccmResourceLog.Cells.Item(1, 3).Value2 = "Name"
    $SccmResourceLog.Cells.Item(1, 4).Value2 = "Last Agent Time"
    $SccmResourceLog.Cells.Item(1, 5).Value2 = "Username"
    $Global:tSccmResRow = 2

    # Setup worksheet for disabled accounts
    $Global:DisabledLog = $Workbook.Worksheets.Item("Sheet2")
    $DisabledLog.Tab.ThemeColor = [Microsoft.Office.Interop.Excel.XlThemeColor]::xlThemeColorAccent2
    $DisabledLog.Name = "Disabled"
    $DisabledLog.Cells.Item(1, 1).Value2 = "Date"
    $DisabledLog.Cells.Item(1, 2).Value2 = "Name"
    $DisabledLog.Cells.Item(1, 3).Value2 = "Age"
    $DisabledLog.Cells.Item(1, 4).Value2 = "Source Container"
    $DisabledLog.Cells.Item(1, 5).Value2 = "Destination Container"
    $Global:tDisabledRow = 2

    # Setup worksheet for deleted accounts log
    $Global:DeletedLog = $Workbook.Worksheets.Item("Sheet1")
    $DeletedLog.Tab.ThemeColor = [Microsoft.Office.Interop.Excel.XlThemeColor]::xlThemeColorAccent5
    $DeletedLog.Name = "Deleted"
    $DeletedLog.Cells.Item(1, 1).Value2 = "Date"
    $DeletedLog.Cells.Item(1, 2).Value2 = "Name"
    $DeletedLog.Cells.Item(1, 3).Value2 = "Age"
    $DeletedLog.Cells.Item(1, 4).Value2 = "DN"
    $DeletedLog.Cells.Item(1, 5).Value2 = "Note"
    $Global:tDeletedRow = 2
}

# Writes an entry to the global variable used to reference the log for disabled accounts
function WriteDisabledEntry([string] $tName, $tAge, [string] $tSourceDn, [string] $tDestinationDn)
{
    #LogMessage "Writing disabled computer to Excel log: $tName" 1
    #Write-Host "Value of tname is $tName"
    #Write-Host "Value of tage is $tAge"
    #Write-Host "Value of tsourcedn is $tSourceDn"
    #Write-Host "Value of tDestinationDn is $tDestinationDn"
    $tArrContainer = $tSourceDn.Split(",")
    $tContainer = [string]::Join(",", ($tArrContainer | select -Last ($tArrContainer.Length - 1)))
    $DisabledLog.Cells.Item($tDisabledRow, 1).Value2 = [DateTime]::Now.ToString()
    $DisabledLog.Cells.Item($tDisabledRow, 2).Value2 = $tName
    $DisabledLog.Cells.Item($tDisabledRow, 3).Value2 = $tAge
    $DisabledLog.Cells.Item($tDisabledRow, 4).Value2 = $tContainer
    $DisabledLog.Cells.Item($tDisabledRow, 5).Value2 = $tDestinationDn
    $Global:tDisabledRow++
}

# Writes an entry to the global variable used to reference the log for deleted accounts 
function WriteDeletedEntry($tName, $tAge, $tDN, $tNote)
{
    #LogMessage "Writing deleted computer to Excel log: $tName" 1
    #Write-Host "Value of tName is $tName"
    #Write-Host "Value of tAge is $tAge"
    #Write-Host "Value of tDN is $tDN"
    #Write-Host "Value of tNote is $tNote"
    $DeletedLog.Cells.Item($tDeletedRow,1).Value2 = [DateTime]::Now.ToString()
    $DeletedLog.Cells.Item($tDeletedRow,2).Value2 = $tName.ToString()
    $DeletedLog.Cells.Item($tDeletedRow,3).Value2 = $tAge.ToString()
    $DeletedLog.Cells.Item($tDeletedRow,4).Value2 = $tDN.ToString()
    $DeletedLog.Cells.Item($tDeletedRow,5).Value2 = $tNote.ToString()
    $Global:tDeletedRow++
    return
}

function WriteSccmDeletionEntry($tResourceId, $tName, $tLastAgentTime, $tUserName)
{
    $SccmResourceLog.Cells.Item($tSccmResRow, 1).Value2 = [DateTime]::Now.ToString()
    $SccmResourceLog.Cells.Item($tSccmResRow, 2).Value2 = $tResourceId
    $SccmResourceLog.Cells.Item($tSccmResRow, 3).Value2 = $tName
    $SccmResourceLog.Cells.Item($tSccmResRow, 4).Value2 = $tLastAgentTime
    $SccmResourceLog.Cells.Item($tSccmResRow, 5).Value2 = $tUserName
    $Global:tSccmResRow++
    return
}

function CloseExcel()
{
    # AutoFit the columns

    foreach ($tSheet in $Workbook.Worksheets)
    {
        $tSheet.Activate()
        [Void] $Excel.ActiveCell.CurrentRegion.Columns.AutoFit()
        [Void] $Excel.ActiveCell.CurrentRegion.Select()
        $Global:ListObject = $Excel.ActiveSheet.ListObjects.Add([Microsoft.Office.Interop.Excel.XlListObjectSourceType]::xlSrcRange, $Excel.ActiveCell.CurrentRegion, $null ,[Microsoft.Office.Interop.Excel.XlYesNoGuess]::xlYes)
        $ListObject.Name = "TableData"
        $ListObject.TableStyle = "TableStyleLight9"
    }

    LogMessage "Saving and closing Excel workbook" 1
    $Global:Workbook.SaveAs($ExcelLog)
    $Global:Excel.Quit()
}

function Main()
{
    Clear-Host
    LogMessage "Beginning workstation account cleanup script" 1

    # Retrieve SCCM site code from site server specified by user
    $Global:SccmSiteCode = GetSiteCode $SccmServer

    # Setup Excel logging
    SetupExcel

    # Delete accounts that have been disabled for X days
    DeleteDisabledAccounts $DeleteAge

    # Disable accounts that are older than X days
    DisableOldAccounts $TargetDn $DisableAge

    CloseExcel
    LogMessage "Completed workstation account cleanup script" 1
}

Main

PowerShell: Enable / Disable Wake-on-LAN (in ConfigMgr)

Hello from Houston, Texas everyone!

Today I’ve got a PowerShell script that was specially developed for my consulting engagements going forward. When implementing 1E WakeUp at a customer, who is integrating it with ConfigMgr (to wake up machines using Software Distribution and Software Updates assignments), I have found that occasionally, a customer has already enabled wake-on-LAN for some of their distributions. The purpose of this script is to disable, or enable, the wake-on-LAN option for all ConfigMgr software distribution Advertisements and Updates Assignments (aka. Deployment Management objects).

Before I get into the script however, I’d like to talk about why 1E WakeUp can help to enhance your organization’s patching and software distribution success.

Why 1E WakeUp? What about ConfigMgr Wake-On-LAN? (this is optional reading)

Now at this point, you might be asking yourself an important question: “Why do I need 1E WakeUp, if ConfigMgr has built-in wake-on-LAN capabilities?” That’s a great question! There are several reasons why 1E WakeUp can help you, in tandem with ConfigMgr.

Distributed Wake-on-LAN

ConfigMgr’s wake-on-LAN functionality is centralized, meaning that only ConfigMgr Primary site servers can send wake-up packets to clients. There are two types of wake-up packets that ConfigMgr sends out, each of which uncovers a unique challenge:

  • Unicast - Unicast packets are subject to fail if a switch’s ARP cache expires
  • Subnet-directed Broadcast – Most enterprise routers block broadcast traffic for security reasons

1E WakeUp is able to work around these issues by sending out wake-up frames on the local subnet using a software agent that sits on each client system. The 1E WakeUp Server sends a list of computers to wake up, down to the local 1E WakeUp “main proxy agent” on each subnet, and that proxy agent is then responsible for sending out broadcasts on its local subnet only. This is beneficial, because no modifications are required to the network infrastructure, in order to reliably wake up computers in subnets that are distant from the ConfigMgr primary site.

What happens if the “main agent” for a subnet is taken offline (eg. a user shuts it down)? Another agent in the same subnet will automagically (hey, I like that word!) take over as the main agent, and the solution will continue to function normally!

Last Known IP

ConfigMgr uses the last known IP address from its hardware inventory to determine what IP address (unicast) or subnet (subnet-directed broadcast) to send a wakeup packet to. This can cause wake-up challenges if a computer has not recently run a hardware inventory cycle, or changed subnets (eg. a laptop changing from a docked / wired connection, to wireless). 1E WakeUp works around this situation, because the software agent on each computer updates its IP address with the WakeUp server every time its IP changes. Thus, the most up-to-date IP information is always stored in the 1E database, and is used to more accurately target wake-up packets.

Summary

There are other reasons that the 1E WakeUp solution works better together with ConfigMgr, but I personally think the above two points are most critical. Anyway, now that I’ve gone off on a tangent and explained why 1E WakeUp is awesome, let’s talk about the script!

—–

Why Would You Blanketly Disable Wake-on-LAN?

When first implementing 1E WakeUp with ConfigMgr integration, unless you switch off the “Enable Wake-on-LAN” option for ConfigMgr advertisements and updates assignments, the WakeUp Server will automatically process these scheduled assignments, and begin waking up workstations! Depending on your goals with implementing 1E WakeUp, this may or may not be desirable. For example, some customers are implementing 1E WakeUp to help increase software distribution and patching success, while others are looking to save energy using 1E NightWatchman, while simultaneously making sure that computers can be made available when necessary using 1E WakeUp.

For those customers looking to save energy, and only perform controlled wake-ups of enterprise clients, it may make sense to disable wake-on-LAN for any scheduled deployments. Conversely, for customers looking to increase deployment success, it may make sense to enable wake-on-LAN for all deployments! The script I’ve included below will do both!

Script Usage

To use the script below, all you need to do is:

  1. Find the “Configuration Section” near the bottom
  2. Plug in your ConfigMgr site server name
  3. Plug in your ConfigMgr site code
  4. Set the $EnableWolAdverts and $EnableWolUpdates to $true or $false dependent on your needs
  5. Enable the $ReallyRun variable by setting it to $true (this is just a safe guard)
  6. Run the script!

Disclaimer: I take no responsibility for what you do with this script!


########################################################
# Enable Wake-on-LAN for all SMS_Advertisement objects #
########################################################

#######################################################################
# Wake-on-LAN is the 22nd bit in the AdvertFlags property, which is
# 2 to the 22nd power. The decimal (base-10) value of this is: 4194304.
# 
#
#######################################################################

function Set-WoLAdvertisements([bool] $EnableWol)
{
    Log-Message 3 "Enabling wake-on-lan for all advertisements in site code: $SiteCode"

    $AdvertisementList = @()
    $AdvertisementList = Get-WmiObject -Namespace "rootsmssite_$SiteCode" -ComputerName $SiteServer -Class SMS_Advertisement -ErrorAction SilentlyContinue
    # If an error occurs, exit the function
    if (-not $?) {
        Log-Message 1 "Error detected while retrieving advertisement list"
        Start-Sleep 2
        return $null
    }

    # Iterate over advertisement objects from ConfigMgr provider
    foreach ($Advertisement in $AdvertisementList)
    {
        # Get a direct reference to the WMI object - The ConfigMgr provider requires this for lazy properties
        $Advertisement = [wmi]"$($Advertisement.Path)"

        # Grab the current AdvertFlags value, to compare with the updated one later
        $AdvertFlagsOld = $Advertisement.AdvertFlags

        # If Wake-on-LAN is enabled, then ...
        if (($Advertisement.AdvertFlags -band 4194304) -eq 4194304 -and -not $EnableWol) {
            Log-Message 3 "Wake-on-LAN is enabled, but disablement has been requested; Disabling ..."
            $Advertisement.AdvertFlags = $Advertisement.AdvertFlags -bxor 4194304
        }
        # If Wake-on-LAN is disabled, then ...
        elseif (($Advertisement.AdvertFlags -band 4194304) -eq 0 -and $EnableWol) {
            Log-Message 3 "Wake-on-LAN is disabled, but enablement has been requested; Enabling ..."
            $Advertisement.AdvertFlags = $Advertisement.AdvertFlags -bxor 4194304
        }

        # Echo the old and new $AdvertFlags
        Log-Message 3 "`$AdvertFlagsOld: $AdvertFlagsOld -- New: $($Advertisement.AdvertFlags)"

        # If $ReallyRun is $true, then go ahead and write the instance back to the provider
        if ($ReallyRun) {
            # Commit the in-memory WMI instance to the ConfigMgr provider
            $PutResult = $Advertisement.Put()
            if ($?) {
                Log-Message 3 "Successfully enabled WoL for advertisement: $($Advertisement.AdvertisementName)"
            }
        }
        # If $ReallyRun is $false, then just echo the advertisement name that would have been updated
        else {
            Log-Message 2 "Pretending to set Wake-on-LAN for advertisement: $($Advertisement.AdvertisementName)"
        }
    }
    Log-Message 3 "Completed enabling wake-on-lan for all advertisements in site code: $SiteCode"
}

############################################################
# END Enable Wake-on-LAN for all SMS_Advertisement objects #
############################################################


########################################################
# Enable Wake-on-LAN for SMS_UpdatesAssignment objects #
########################################################

function Set-UpdatesAssignmentWol([bool] $EnableWol)
{
    Log-Message 3 "Enabling wake-on-LAN for all SUP deployment management objects in site code: $SiteCode"
    $Updates = Get-WmiObject -Namespace "rootsmssite_$SiteCode" -ComputerName $SiteServer -Class SMS_UpdatesAssignment -ErrorAction SilentlyContinue
    # If an error occurs, exit the function
    if (-not $?) {
        Log-Message 1 "Error detected while retrieving updates assignment list"
        # Exit the function, returning $null
        return $null
    }

    # Iterate over each SMS_UpdatesAssignment
    foreach ($UpdateObj in $Updates)
    {
        # Set the WoLEnabled property to $true or $false
        $UpdateObj.WoLEnabled = $EnableWol

        # Only commit the change to the provider if we REALLY want to (this is a safe-guard)
        if ($ReallyRun) {
            # Commit updated WMI instance to the provider
            $PutResult = $UpdateObj.Put()

            # Error handler
            if ($?) {
                Log-Message 3 "Successfully enabled WoL for deployment management object: $($UpdateObj.AssignmentName)"
            }
            else {
                Log-Message 1 "An error occurred enabling WoL for deployment management object: $($UpdateObj.AssignmentName)"
            }
        }
        # If we don't have $ReallyRun set to $true, then just echo the name of the object we would be updating
        else {
            Log-Message 3 "Pretending to enable WoL for deployment management object: $($UpdateObj.AssignmentName)"
        }
    }
}

############################################################
# END Enable Wake-on-LAN for SMS_UpdatesAssignment objects #
############################################################

################################
# Get-DateTimeShort() function #
################################

function Get-DateTimeShort()
{
    $Date = [DateTime]::Now
    $tDate = "$($Date.Year)-$($Date.Month.ToString().PadLeft(2,'0'))-$($Date.Day.ToString().PadLeft(2,'0'))"
    $tDate = $tDate + " $(($Date.Hour % 12).ToString().PadLeft(2,'0')).$($Date.Minute.ToString().PadLeft(2,'0')).$($Date.Second.ToString().PadLeft(2,'0'))"
    Write-Output $tDate
}

####################################
# END Get-DateTimeShort() function #
####################################

##########################
# Log-Message() function #
##########################

function Log-Message($tSeverity, $tMessage)
{
    switch ($tSeverity)
    {
        1
        { $tSeverity = "ERROR" }
        2
        { $tSeverity = "WARNING" }
        3
        { $tSeverity = "INFO" }
        default
        { $tSeverity = "INFO" }
    }

    $tMessage = "$(Get-DateTimeShort): $tSeverity`: $tMessage"
    Add-Content -Path $LogPath -Value $tMessage
}

##############################
# END Log-Message() function #
##############################

#################### Configuration Section ####################
$SiteServer = 'sccm01'
$SiteCode = 'lab'
$ReallyRun = $false # Set this to $true if you want to actually affect objects
$LogPath = $env:windir + "Temp$(Get-DateTimeShort) Set-ConfigMgrWoL.log"
$EnableWolAdverts = $true
$EnableWolUpdates = $true
###############################################################

function Main()
{
    Log-Message 3 "Starting script."
    if (-not $ReallyRun) { Log-Message 2 "Script is running in read-only mode! No changes will be made!" }

    Set-UpdatesAssignmentWol $EnableWolUpdates
    Set-WoLAdvertisements $EnableWolAdverts

    if (-not $ReallyRun) { Log-Message 2 "Script is running in read-only mode! No changes were made!" }
    Log-Message 3 "Script completed."
}

Main

ccmsetup.exe: Trailing slash in “Source” parameter

Hello everyone!

Tonight, while I’m traveling in Houston, Texas, I wrestled with ccmsetup.exe for a little while. I was working on getting a ConfigMgr vNext client agent installed on a Windows 7 Ultimate virtual machine, and kept getting a message in my ccmsetup.log saying “Source <path> is inaccessible.

—–

A little bit about %~dp0

Now, whenever I build installer packages for software, whether to run manually, or distribute through ConfigMgr, I generally wrap the commands inside a simple batch file, so I don’t have to constantly type out the entire command, or forget what a particular parameter should be set to.

Because I write lots of simple batch files, and use these packages from both UNC paths, as well as local installs, I use a nifty batch trick: %~dp0. Without going into too much detail, if you use %~dp0 in a batch file, it will reference the folder in which the batch file resides, including a trailing slash. For example, if you want to run a MSI package from a local or UNC path, you could write a dynamic batch file like so:

msiexec /i “%~dp0MyPackage.msi” /quiet

In the above command, the double quotes will take care of any spaces in the path, and the %~dp0 will reference the folder path in which the batch file itself resides. This command also assumes that a Windows Installer package called “MyPackage.msi” resides in the same folder alongside the batch file. Using %~dp0 is great, because no matter where I copy an entire package folder, I always know that I can execute a batch file, and it will use appropriate relative pathing to find the supporting files necessary to run the command.

Anyway, that’s enough history about %~dp0 and why I use it!

—–

The Trailing Slash Issue

Installing the ConfigMgr client is generally quite simple: you can simply execute ccmsetup.exe by double-clicking it, or invoking it, with no parameters from a script. In this circumstance, however, I needed to specify the source folder for where to get the files, otherwise ccmsetup.exe was going to point to my ConfigMgr 2007 management point, rather than the ConfigMgr vNext management point that I wanted it to get the files from. Instead of using the /MP parameter, I elected to simply point ccmsetup.exe to the specific folder where the source files resided (on the ConfigMgr network share).

Naturally, since I was building my agent install batch file right inside the client folder on the site server (\vnext01sms_vnxclient), I simply used “%~dp0″ to point ccmsetup.exe to its own folder (which was a UNC path). The command I placed into the batch file was the following:

“%~dp0ccmsetup.exe” /retry:1 /source:”%~dp0″ FSP=vnext01.ts.loc

Upon execution of this batch file, is when I experienced the “inaccessible source” error message in ccmsetup.log, as described in my opening paragraph. After encountering this issue, I experimented with a number of different permutations of the command line, none of which worked really. I finally realized that, as best I can tell, ccmsetup.exe appeared to be incorrectly parsing the value passed to the /source parameter. If you include a trailing slash on the <path> you pass to /source, then ccmsetup.exe will actually use the remainder of the command line as the <path>. This is very bad!

In short, if you want ccmsetup.exe to succeed at finding its source files, you must not include a trailing slash on the value you feed into the /source parameter!

The only scenario I have not yet tested, is passing a <path> to /source, that has a trailing slash, but does not have double-quotes around the path. I always recommend using double-quotes for any command-line value that may have spaces in it, so I would not really even consider this scenario, other than for the sake of testing.

—–

Conclusion

Well, I hope this has given some of you some insight into a potential problem, and maybe even saves you a few gray hairs by finding this on Google, instead of stressing over what you are doing wrong. It appears to be a small software bug, and I’m somewhat surprised that no one else seems to have encountered it, but there’s a first time for everything, right?

If you have any feedback, or would like to ask a question, feel free to leave a comment on this article, or e-mail me directly at pcgeek86@gmail.com.

Cheers!

Update: If you’d like to help out by recreating the issue, posting your comments, upvoting the bug report on Microsoft Connect, that would be fabulous! Make sure you log into Microsoft Connect, join the ConfigMgr vNext Beta Program, and then click the following link:

https://connect.microsoft.com/ConfigurationManagervnext/feedback/details/583512/trailing-slash-in-ccmsetup-exe-source-parameter-causes-failed-client-installation#

If you can reproduce the issue, make sure to click the “I can too” link on the above page.