How to remove remote or local user profiles using PowerShell

8 minute read

Last week we took a look at using Delprof2 to enumerate remote or local user profiles. If you aren’t familiar with that post, I would highly recommend you read it before this one to understand the background. We are essentially building a partner for the previous cmdlet to remove user profiles when needed.

Delprof2

We already covered the important aspects of DelProf2 in last’s week’s post, but since this is a free piece of software that I do not develop or contributor to, be sure to check out Helge Klein and the download page for Delprof2. They release this software for free and, best I can tell, keep it updated regularly.

Syntax

After I say we’ve already covered everything, I realized that we actually haven’t yet! To actually remove a profile using delprof2 we need to use the removal switch:

delprof2 /u

This will remove all profiles that meet the default criteria for delprof2. Though you can still use all of the other parameters that I mentioned in last week’s post.

Wrapping Delprof2: The sequel

Believe it or not, we are going to be doing something very similar to what we did last week. It will get a little more complicated because we’ll end up with 2 different parameter sets, but the parsing of the output is going to reuse some of the regex. This is because the output of delprof2 is going to be the same except for the output designating deletion.

Something to note, before we get too deep into this, is if these functions were a part of a module, I would write a separate private helper function to parse the output of delprof2 and reference that helper function inside both Get-InactiveUserProfiles and Remove-InactiveUserProfiles.

Profile deletion output

So we’ve already got the parsing of profile information from delprof2, we need to see what the output looks like when it actually deletes a profile:

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

Listing inactive profiles on 'VHOST01'.
Including only directories matching: temp

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: directory inclusion)
Ignoring profile '\\VHOST01\C$\Users\D' (reason: directory inclusion)
Ignoring profile '\\VHOST01\C$\Users\L' (reason: directory inclusion)

The following user profiles match the deletion criteria:

\\VHOST01\C$\Users\Temp

Deleting profile '\\VHOST01\C$\Users\Temp' ...
   ... done.

So those last two lines are what we are looking for in the output to know if a user profile deletion was attempted and then if it was successful. So instead of a foreach loop, we’ll use a for loop and reference that line and that line + 1:

for ($x=0; $x -lt $return.Count; $x++){
    # Removed for brevity
    elseif ($return[$x] -match "^Deleting profile \'(?<profilePath>\\\\([^\\]+\\)+(?<profile>[^\']+))\'") {
        # Profile to delete
        $firstMatch = $Matches
        $deleted = $false
        if ($return[$x+1] -match '\.\.\. done\.'){
            $deleted = $true
        }
        [pscustomobject]@{
            Ignored = $false
            Reason = $null
            ProfilePath = $firstMatch.ProfilePath
            Profile = $firstMatch.Profile
            ComputerName = $Name
            Deleted = $deleted
        }
        $x++
    }
}

Dealing with the pipeline

Since I want Remove-InactiveUserProfiles to be able to accept pipeline input from Get-InactiveUserProfiles and be able to run the command standalone, we’ll need 2 parameter sets. FromSelf contains all the same parameters as Get-InactiveUserProfiles and FromGet contains a single parameter to encapsulate the properties of the output from Get-InactiveUserProfiles:

[cmdletbinding(
    DefaultParameterSetName = 'FromGet'
)]
param (
    [Parameter(
        ParameterSetName = 'FromSelf'
    )]
    [ValidateNotNullOrEmpty()]
    [string]$Name = $env:COMPUTERNAME
    ,
    [Parameter(
        ParameterSetName = 'FromSelf'
    )]
    [string[]]$include # Supports ? and *
    ,
    [Parameter(
        ParameterSetName = 'FromSelf'
    )]
    [string[]]$exclude # Supports ? and *
    ,
    [Parameter(
        ParameterSetName = 'FromSelf'
    )]
    [int]$olderThan
    ,
    [Parameter(
        ParameterSetName = 'FromGet',
        ValueFromPipeline = $true
    )]
    [PSObject]$ProfileObject
    ,
    [string]$delprof2 = "c:\windows\system32\delprof2.exe"
)

With that, I’ve also added some logic to take different action based on the parameter set. For instance, if the parameter set is FromGet, it will only output the deleted profiles, not all the ignored ones.

Shortcomings

One of the sacrifices I have to make to accept pipeline input from Get-InactiveUserProfiles is that I have to declare the parameter as a generic PSOject. It would be better to create an inactive user profile class and declare the input object as that class so that I could assume that the object would have certain properties. Again, if this were it’s own module I would likely do that.

The functioning function

This entire function ends up looking like:

Function Remove-InactiveUserProfiles {
    [cmdletbinding(
        DefaultParameterSetName = 'FromGet'
    )]
    param (
        [Parameter(
            ParameterSetName = 'FromSelf'
        )]
        [ValidateNotNullOrEmpty()]
        [string]$Name = $env:COMPUTERNAME
        ,
        [Parameter(
            ParameterSetName = 'FromSelf'
        )]
        [string[]]$include # Supports ? and *
        ,
        [Parameter(
            ParameterSetName = 'FromSelf'
        )]
        [string[]]$exclude # Supports ? and *
        ,
        [Parameter(
            ParameterSetName = 'FromSelf'
        )]
        [int]$olderThan
        ,
        [Parameter(
            ParameterSetName = 'FromGet',
            ValueFromPipeline = $true
        )]
        [PSObject]$ProfileObject
        ,
        [string]$delprof2 = "c:\windows\system32\delprof2.exe"
    )
    Begin {
        Write-Verbose $PSCmdlet.ParameterSetName
        $switches = & {
            "/u"
            if ($include) {
                foreach ($inc in $include) {
                    "/id:$inc"
                }
            }
            if ($exclude) {
                foreach ($exc in $exclude) {
                    "/ed:$exc"
                }
            }
            if ($olderThan) {
                "/d:$olderThan"
            }
        }
    }
    Process {
        if ($PSCmdlet.ParameterSetName -eq 'FromSelf'){
            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
        } elseif ($PSCmdlet.ParameterSetName -eq 'FromGet'){
            if ( -not $profileObject.Ignored) {
                $computer = $null
                if ($null -ne $profileObject.Computer){
                    $computer = "/c:$($profileObject.Computer)"
                }
                Write-Verbose "CMD: $delprof2 $computer $switches /id:$($profileObject.Profile)"
                $return = & $delprof2 $computer $switches "/id:$($profileObject.Profile)"
            }
        }
        for ($x=0; $x -lt $return.Count; $x++){
            if ($return[$x] -match "^Ignoring profile \'(?<profilePath>\\\\([^\\]+\\)+(?<profile>[^\']+))\' \(reason\: (?<reason>[^\)]+)\)") {
                # Ignored profile
                If($PSCmdlet.ParameterSetName -ne 'FromGet'){
                    [pscustomobject]@{
                        Ignored = $true
                        Reason = $Matches.reason
                        ProfilePath = $Matches.profilePath
                        Profile = $Matches.profile
                        ComputerName = $Name
                        Deleted = $false
                    }
                }
            } elseif ($return[$x] -match "^Deleting profile \'(?<profilePath>\\\\([^\\]+\\)+(?<profile>[^\']+))\'") {
                # Profile to delete
                $firstMatch = $Matches
                $deleted = $false
                if ($return[$x+1] -match '\.\.\. done\.'){
                    $deleted = $true
                }
                [pscustomobject]@{
                    Ignored = $false
                    Reason = $null
                    ProfilePath = $firstMatch.ProfilePath
                    Profile = $firstMatch.Profile
                    ComputerName = $Name
                    Deleted = $deleted
                }
                $x++
            } elseif ($return[$x] -match "^Access denied to profile \'(?<profilePath>\\\\([^\\]+\\)+(?<profile>[^\']+))\'") {
                # Access denied
                if ($PSCmdlet.ParameterSetName -ne 'FromGet'){
                    [pscustomobject]@{
                        Ignored = $true
                        Reason = 'Access denied'
                        ProfilePath = $Matches.ProfilePath
                        Profile = $Matches.Profile
                        ComputerName = $Name
                        Deleted = $false
                    }
                }
            }
        }
    }
    End{}
}

Like usual, you can also find this in my Github Utilities repo.

Usage

So now with this lovely addition, we can remove user profiles! You don’t have to use Get-InactiveUserProfiles, but that is super handy for running tests to see what will be deleted.

Simple usages

To remove all local user profiles that meet the default criteria:

Remove-InactiveUserProfiles

Or we can go back to an example from last week and remove all the profiles that start with a certain letter:

Remove-InactiveUserProfiles -Include T* | Format-Table

This outputs:

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

Advanced usage

And then we can, of course, pipe the output from Get-InactiveUserProfiles to it as well:

Get-InactiveUserProfiles -include T* | Remove-InactiveUserProfiles

And that will give us some abbreviated output:

Ignored      : False
Reason       :
ProfilePath  : \\VHOST01\C$\Users\Temp
Profile      : Temp
ComputerName : VHOST01
Deleted      : True

Though if we wanted to get a list of computers from a text file or Active Directory we’d either need to pipe in to Get-InactiveUserProfiles first or use a Foreach-Object loop:

Get-ADComputer -Filter {Name -like 'RDS*'} | Get-InactiveUserProfiles -OlderThan 7 | Remove-InactiveUserProfiles
# OR
Get-ADComputer -Filter {Name -like 'RDS*'} | Foreach-Object {Remove-InactiveUserProfiles -Name $_.Name -OlderThan 7}

And both of those are about the same length, though I personally prefer the pipeline usage.

Conclusion

I hope this has been an inciteful read for you! This is cmdlet that has served me well over the years, so I’m happy to share.

Be sure to check out my Utilities repo in Github, post an issue if you find a problem with anything there. I’ll definitely fix it!

Leave a Comment