How to get remote or local user profiles using PowerShell

10 minute read

Much like my past posts on finding logged on users and logging them off, we are going to wrap an executable so that we can use it with PowerShell and the pipeline. This executable, however, is not available natively on Windows so we’ll have to grab it.

Delprof2

Delprof2 is a commandline tool written by Helge Klein that is, what they call, the ‘unofficial successor to Microsoft’s Delprof’. It’s main usage is cleaning up inactive profiles in Windows.

I’ve used this tool on and off over the years. After picking up PowerShell, it was easy for me to use traditional command line tools less because they don’t integrate with the pipeline. I missed this one enough that I decided to wrap it with PowerShell and make it work!

So to follow along and use the tool I’m walking through, make sure you download delprof2.

Syntax

I’m not going to run through everything you can do with delprof2 because that would be an entire blog post in and of itself, so instead lets focus on the ones that I’ve integrated with PowerShell.

delprof2 /l

This is the simplest usage and simply lists all of the profiles on a system. By default it will list out all profiles and what it would do with them by default:

DelProf2 by Helge Klein (http://helgeklein.com)

Listing inactive profiles on 'VHOST01'.

Ignoring profile '\\VHOST01\C$\Users\Default' (reason: special profile)
Ignoring profile '\\VHOST01\C$\Users\Public' (reason: special profile)
Ignoring profile '\\VHOST01\C$\Users\Administrator' (reason: in use)

The following user profiles match the deletion criteria:

\\VHOST01\C$\Users\D
\\VHOST01\C$\Users\L

You’ll notice that it even lists out the special profiles and safely ignores profiles in use.

If you ran delprof2 without any parameters, it would have deleted the D and L profiles in this case.

It does also include some filtering parameters. Specifically for what I thought was most useful, it can include certain profiles (which excludes all that don’t match):

delprof2 /l /id:a*

Exclude them (which includes those that don’t match):

delprof2 /l /ed:adm?nistrator

And find ones older than a certain number of days:

delprof2 /l /d:30

Each of this uses the /l flag so that it only lists data, it doesn’t delete anything. Both the include and exclude parameters support * as a wildcard and ? as any single character.

Oh, and it does also work on remote systems:

delprof2 /l /c:ALPHAWOLF

And we can combine any of the above parameters to get a combination of includes, excludes, that are older than a certain number of days, and from a remote computer. Yeah, it is that awesome!

Wrapping delprof2

So now, if we want to wrap delprof2 and return objectized data for the pipeline to consume, we need to do some string parsing. The best tool for the job? Regex! Though, funny story, in writing this blog post I discovered that I’m much better at regex than I was when I originally wrote this function, so if you are just learning regex, have no fear! Better regex skillz do come with time.

Prereqs

Ok, so first things first, we need to determine how we are going to run delprof2. For my function, I decided that I’d have the .exe is a standard location and make that an optional parameter:

Param (
    ...
    [string]$delprof2 = "c:\windows\system32\delprof2.exe"
)

I’ve selected system32 because that is the default start path for elevated PowerShell, but pick something that works for you.

To actually run delprof2, we now use the ampersand &, which is the call operator, like this:

& $delprof2 '/l'

Dealing with the output

Then, to get the output, we need to assign that to a variable:

$output = & $delprof2 '/l'

Once we have the output, we’ll use a foreach loop to parse through each string checking to see what each line actually is.

Ignored profiles

An ignored profile looks like:

Ignoring profile '\\VHOST01\C$\Users\Default' (reason: special profile)

We need to get several pieces of data from there. Besides the fact that it is an ignored profile, I’d like to collect the profile name and the reason. So a little bit of regex magic like:

if ($line -match "^Ignoring profile \'(?<profilePath>\\\\([^\\]+\\)+(?<profile>[^\']+))\' \(reason\: (?<reason>[^\)]+)\)")

Will deliver us some nice data:

Name               Value
----               -----
profile            Default
reason             special profile
profilePath        \\VHOST01\C$\Users\Default
1                  Users\
0                  Ignoring profile '\\VHOST01\C$\Users\Default' (reason: special profile)

So we can build a return object:

[pscustomobject]@{
    Ignored = $true
    Reason = $Matches.reason
    ProfilePath = $Matches.profilePath
    Profile = $Matches.profile
}

Included profiles

Included profiles are a little easier, there isn’t additional information to parse. So the regex is going to look like:

if ($line -match "^(?<profilePath>\\\\([^\\]+\\)+(?<profile>\S+))")

This gives us more useful data that we can use in a return object:

[pscustomobject]@{
    Ignored = $false
    Reason = $null
    ProfilePath = $Matches.ProfilePath
    Profile = $Matches.Profile
}

And also input!

Since we do need to be able to define parameters for this function, we do want to be able to also accept input with some parameters.

For the param block, we can start with (I’m excluding the Parameter() settings for brevity):

param (
    [string]$Name
    ,
    [string[]]$Include
    ,
    [string[]]$Exclude
    ,
    [int]$OlderThan
)

So what we’ll need is a couple of foreach loops to get all the switches in an array:

$switches = & {
    "/l"
    if ($PSBoundParameters.ContainsKey('Include')) {
        foreach ($inc in $include) {
            "/id:$inc"
        }
    }
    if ($PSBoundParameters.ContainsKey('Exclude')) {
        foreach ($exc in $exclude) {
            "/ed:$exc"
        }
    }
    if ($PSBoundParameters.ContainsKey('OlderThan')) {
        "/d:$OlderThan"
    }
}

Then, since we are running it against a remote computer, we should test connectivity:

if ($PSBoundParameters.ContainsKey('Name') -and ($Name -ne $env:COMPUTERNAME)) {
    if (Test-Connection $Name -Count 1 -Quiet) {
        $computer += "/c:$Name"
    } else {
        Throw "Cannot ping $name"
    }
}

And now our command would look like:

$return = & $delprof2 $computer $switches

Functionitized

So the whole function would be:

Function Get-InactiveUserProfiles {
    [cmdletbinding()]
    param (
        [Parameter(
            Position = 1,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $true
        )]
        [Alias('Computer','ComputerName','HostName')]
        [ValidateNotNullOrEmpty()]
        [string]$Name = $env:COMPUTERNAME
        ,
        [Parameter()]
        [string[]]$include # Supports ? and *
        ,
        [Parameter()]
        [string[]]$exclude # Supports ? and *
        ,
        [Parameter()]
        [int]$olderThan
        ,
        [string]$delprof2 = "c:\windows\system32\delprof2.exe"
    )
    Begin {
        $switches = & {
            "/l"
            if ($include) {
                foreach ($inc in $include) {
                    "/id:$inc"
                }
            }
            if ($exclude) {
                foreach ($exc in $exclude) {
                    "/ed:$exc"
                }
            }
            if ($olderThan) {
                "/d:$olderThan"
            }
        }
    }
    Process {
        if ($PSBoundParameters.ContainsKey('Name') -and ($Name -ne $env:COMPUTERNAME)) {
            if (Test-Connection $Name -Count 1 -Quiet) {
                $computer = "/c:$Name"
            } else {
                Throw "Cannot ping $Name"
            }
        }
        Write-Verbose "CMD: $delprof2 $computer $switches"
        $return = & $delprof2 $computer $switches
        foreach ($line in $return) {
            if ($line -match "^Ignoring profile \'(?<profilePath>\\\\([^\\]+\\)+(?<profile>[^\']+))\' \(reason\: (?<reason>[^\)]+)\)") {
                # Ignored profile
                [pscustomobject]@{
                    Ignored = $true
                    Reason = $Matches.reason
                    ProfilePath = $Matches.profilePath
                    Profile = $Matches.profile
                    ComputerName = $Name
                }
            } elseif ($line -match "^(?<profilePath>\\\\([^\\]+\\)+(?<profile>\S+))") {
                # Included profile
                [pscustomobject]@{
                    Ignored = $false
                    Reason = $null
                    ProfilePath = $Matches.ProfilePath
                    Profile = $Matches.Profile
                    ComputerName = $Name
                }
            } elseif ($line -match "^Access denied to profile \'(?<profilePath>\\\\([^\\]+\\)+(?<profile>[^\']+))\'") {
                # Access denied
                [pscustomobject]@{
                    Ignored = $true
                    Reason = 'Access denied'
                    ProfilePath = $Matches.ProfilePath
                    Profile = $Matches.Profile
                    ComputerName = $Name
                }
            }
        }
    }
    End{}
}

This is also now in my Utilities Github repo

Get-InactiveUserProfiles

Now we can check for inactive user profiles locally or remotely! Lets try it out.

Simple usage

To check the local computer (piping to format table for nice output):

Get-InactiveUserProfiles | Format-Table

We get:

Ignored Reason          ProfilePath                      Profile       ComputerName
------- ------          -----------                      -------       ------------
   True special profile \\VHOST01\C$\Users\Default       Default       VHOST01
   True special profile \\VHOST01\C$\Users\Public        Public        VHOST01
   True in use          \\VHOST01\C$\Users\Administrator Administrator VHOST01
  False                 \\VHOST01\C$\Users\D             D             VHOST01
  False                 \\VHOST01\C$\Users\L             L             VHOST01

Or if we wanted to get only profiles that start with D and haven’t been used for longer than 20 days:

Get-InactiveUserProfiles -Include 'D*' -OlderThan 20 | Format-Table

We get:

Ignored Reason              ProfilePath                      Profile       ComputerName
------- ------              -----------                      -------       ------------
   True special profile     \\VHOST01\C$\Users\Default       Default       VHOST01
   True special profile     \\VHOST01\C$\Users\Public        Public        VHOST01
   True directory inclusion \\VHOST01\C$\Users\Administrator Administrator VHOST01
   True directory inclusion \\VHOST01\C$\Users\L             L             VHOST01
  False                     \\VHOST01\C$\Users\D             D             VHOST01

Advanced usage

Now, remember that we can both accept pipeline input and output objects? Yeah, so we can do some pretty cool things like, if you’ve got to do some profile cleanup on a bunch of computers:

Get-ADComputer -Filter {Name -like 'CallCenter-*'} | Get-InactiveUserProfiles -Exclude 'Administrator' -OlderThan 60 | Where-Object {-not $_.Ignored}

That will list out profiles from all the computers returned in that AD query that aren’t the Administrator profile and haven’t been touched in 2 months. Notice I’m using the Where-Object cmdlet to filter for only the profiles that meet that criteria. So you could even use Export-Excel and create a nice report for the call center manager if needed.

Oh, and I should mention that the -Name parameter is passed via the pipeline for Get-InactiveUserProfiles, no need to explicitly state it.

You could also have a text file list of computers and use that:

Get-Content C:\path\to\comps.txt | Get-InactiveUserProfiles | Where-Object {-not $_.ignored}

There’s a LOT that you can do with it now that it works in PowerShell!

Conclusion

Anyways, I hope you find this helpful! Be sure to check out the other functions in my Utilities Repo. If you find something broken, would like to see an additional feature, or maybe you’ve got a question about something, open an issue.

Or you can comment here, reach out to me on Twitter, by email, LinkedIn, whatever strikes your fancy.

Next week I’ll be writing about how you can:

Get-InactiveProfiles -Computer RemoteComputer | Remove-InactiveProfiles

Leave a Comment