Getting Large Files

Sooner or later, you're likely to have to "clean up" a disk that's running out of space. One of the simplest ways to do that is to find the really large files on the disk or in a directory and delete ones that you don't actually need, or move them to a location that has more space. Over the years, I've used multiple tools to find the large files, but these days I use PowerShell.

To start, we need to use Get-ChildItem with the -Recurse parameter, and use Sort-Object to sort by the Length property (and to avoid problems if there's any errors, we'll tell it to just ignore errors and keep right on going).

Get-ChildItem -Path $Home -Recurse -ErrorAction SilentlyContinue  `
       | Sort-Object -Property Length -Descending

That's good, but obviously we only need to only return the big files, not all of them, so let's grab only the 10 largest files:

Get-ChildItem -Path $Home -Recurse -ErrorAction SilentlyContinue  `
       | Sort-Object -Property Length -Descending `
       | Select-Object -First 10

OK, better, but kind of hard to read. So, let's do some formatting and cleanup...

Get-ChildItem -Path $Home -Recurse -ErrorAction SilentlyContinue  `
       | Sort-Object -Property Length -Descending  `
       | Select-Object -First 10 `
       | Select-Object Name, `
           @{Label='SizeMB';Expression={"{0:N0}" -f ($_.Length/1MB)}},`
           @{Label='LastWrite';Expression={"{0:d}" -f ($_.LastWriteTime)}}, `
           DirectoryName

Now we're getting closer. But my standard PowerShell window is only 120 characters wide, and that leaves the directory getting chopped off:

Name                                                                              SizeMB LastWrite  DirectoryName
----                                                                              ------ ---------  -------------
871790_001_spp-2016.10.0-SPP2016100.2016_1015.191.iso                             6,672  2016-11-23 C:\Users\Charlie...
en_windows_server_2016_x64_dvd_9327751.iso                                        5,392  2016-10-23 C:\Users\Charlie...
en_windows_server_2012_r2_with_update_x64_dvd_6052708.iso                         5,148  2015-01-09 C:\Users\Charlie...
14393.0.160715-1616.RS1_RELEASE_SERVER_EVAL_X64FRE_EN-US.ISO                      5,076  2016-09-27 C:\Users\Charlie...
en_windows_server_2016_essentials_x64_dvd_9327792.iso                             4,473  2016-10-23 C:\Users\Charlie...
en_windows_storage_server_2016_x64_dvd_9327790.iso                                4,363  2016-10-23 C:\Users\Charlie...
CDR-X10_1.10_for_Intel_X10_platform.iso                                           4,312  2015-12-29 C:\Users\Charlie...
en_windows_10_multiple_editions_version_1511_updated_apr_2016_x64_dvd_8705583.iso 4,252  2016-06-03 C:\Users\Charlie...
en_windows_10_multiple_editions_version_1607_updated_jul_2016_x64_dvd_9058187.iso 4,177  2016-09-21 C:\Users\Charlie...
en_windows_10_multiple_editions_x64_dvd_6846432.iso                               3,895  2015-07-30 C:\Users\Charlie...

Which isn't terribly helpful whenI start trying to actually identify the files PowerShell has found. So, let's take advantage of Format-Table's ability to wrap lines:

Get-ChildItem -Path $Home -Recurse -ErrorAction SilentlyContinue  `
     | Sort-Object -Property Length -Descending  `
     | Select-Object -First 10 `
     | Select-Object Name, `
         @{Label='SizeMB';Expression={"{0:N0}" -f ($_.Length/1MB)}},`
         @{Label='LastWrite';Expression={"{0:d}" -f ($_.LastWriteTime)}}, `
         DirectoryName `
     | Format-Table -auto -wrap

Now that works a bit better, but it ends up with an awful lot of column width for the filename, and a really narrow column for the DirectoryName on my machine. So, let's take it the last step and set some column widths. We can't do that with the Select-Object expressions we've been using, but we can do it with Format-Table expressions:

Get-ChildItem -Path $Home -Recurse -ErrorAction SilentlyContinue  `
     | Sort-Object -Property Length -Descending  `
     | Select-Object -First 10 `
     | Select-Object Name, `
         @{Label='SizeMB';Expression={"{0:N0}" -f ($_.Length/1MB)}},`
         @{Label='LastWrite';Expression={"{0:d}" -f ($_.LastWriteTime)}}, `
         DirectoryName `
     | Format-Table -Wrap `
                     @{Label='File Name';Expression={$_.Name};Width=50},`
                     @{Label='SizeMB';Expression={$_.SizeMB};Width=7},`
                     @{Label='Last Write';Expression={$_.LastWrite};Width=11},`
                     DirectoryName

Now that's a useful display. Notice that when we got to Format-Table, the object names we wanted for our columns now matched the calculated column names, not the original Get-ChildItem property names.

So, let's take the whole thing and wrap it up in a script, with comment-based help, of course. We'll use two parameters -- the number of files to return, and the starting path.

<#
.Synopsis
Find the 10 largest files in a directory tree

.Description
Get-myLargeFiles does a recursive search of a directory and its subdirectories
to find the largest files in that directory tree. By default, it searches from the 
top of the $home directory, and returns the 10 largest files, but you can specify
the starting directory and the number of files to return on the command line. 

.Example
Get-myLargeFiles 

Returns the 10 largest files in your personal directory tree ($home).

.Example
Get-myLargeFiles -Path 'C:\' -Number 20

Returns the 20 largest files on the C: drive. Note that this will not report any failures
caused by insufficient permissions to traverse a particular directory tree. 

.Parameter $Path
The path to the top of the search tree. Default value is $home

.Parameter $Number
The number of large files to return. Default value is 10.

.Inputs
[string]
[Int]

.Notes
    Author: Charlie Russel
 Copyright: 2016 by Charlie Russel
          : Permission to use is granted but attribution is appreciated
   Initial: 3/02/2016 (cpr)
   ModHist: 12/02/2016 (cpr) -- Set column widths in the Format-Table output. 
          :
#>
[CmdletBinding()]
Param(
     [Parameter(Mandatory=$False,Position=0)]
     [string]
     $Path = $Home,
     [Parameter(Mandatory=$False,Position=1)]
     [int]
     $Number = 10
     )

Get-ChildItem $path -recurse -ea SilentlyContinue  `
       | Sort-Object Length -Descending  `
       | Select-Object -first $Number  `
       | Select-Object Name, `
           @{Label='SizeMB';Expression={"{0:N0}" -f ($_.Length/1MB)}},`
           @{Label='LastWrite';Expression={"{0:d}" -f ($_.LastWriteTime)}}, `
           DirectoryName `
       | Format-Table -Wrap `
             @{Label='File Name';Expression={$_.Name};Width=50},`
             @{Label='SizeMB';Expression={$_.SizeMB};Width=7},`
             @{Label='Last Write';Expression={$_.LastWrite};Width=11},`
             @{Label='Directory Name';Expression={$_.DirectoryName}}

Defaulting to PowerShell instead of CMD

Beginning in Windows 8.1, you could set the Windows PowerUser menu (right-click on the Start button, or Win-X key) to show Windows PowerShell and Windows PowerShell (Admin) on the menu instead of Command Prompt and Command Prompt (Admin). But every single new machine you log on to, you had to change that. A nuisance, at least. So, I created a Group Policy Preference to set the registry key for this, and linked this to the Default Domain Policy.

Apparently, Microsoft is finally catching up, and this is going to be the default on Windows 10 beginning with the build that's coming down today. About time!

For those of you who are not on the Fast Ring of Windows Insider builds, the registry key you need to set is:

HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced\DontUsePowerShellOnWinX=0

To set that with Windows PowerShell, use:

Set-ItemProperty HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced `
                -Name DontUsePowerShellOnWinX `
                -Value 0 `
                -Type DWord

You can also use Group Policy Preferences to set that as part of a Group Policy Object (GPO).

    1. Open the Group Policy Management Console (gpmc.msc)
    2. Right-click the GPO you want to modify (I chose the Default Domain Policy for my domain)
    3. Select Edit from the right-click menu to open the Group Policy Editor
    4. Expand the User Configuration container, then Preferences and select Registry in the left pane.
    5. Right-click in the Registry details pane and select Registry Item from the New menu:
Adding a new registry item to Group Policy Preferences

Add a new registry item to Group Policy Preferences

6. In the New Registry Properties dialog, select Update for the Action, HKEY_CURRENT_USER for the Hive, and a Key Path of  \Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced.

Set PowerShell as the WinX default

Set PowerShell as the WinX default

7. The Value Name is DontUsePowerShellOnWinX, with a Value Type of REG_DWORD and a Value Data of 0, as shown below.

8. Click OK, and then close GPEdit. The Group Policy will be applied to following the next reboot and logon of each user to whom the GPO applies.

For those of you who insist that they really want CMD instead of PowerShell, you can simply set the value of that registry item to one (1) instead of zero (0). Or let users manually control it. But as I've been saying for years: "Death to CMD". :)

 

Using a list as a default parameter value

I got an interesting question in a comment today on my old post on Getting the Free Disk Space of Remote Computers. While I answered the comment in the original post, it brought up something that's not well understood, so I thought I'd take a moment to give a fuller answer here, where it will be more discoverable.

 

The reader complained that when he ran the script without parameters, he was getting an error. When I read the error, it was clear that he had modified the script to set a default value for the ComputerName parameter that was a list of computers, rather than a single computer. A reasonable change, since the script is set to handle a list or array of strings with [string[]]. And if you use the parameter with a list of -ComputerName 'server1','server2','server3', etc., from the command line, it handles that list as you would expect. But if you try to set a default value for the ComputerName parameter with that same list:

[CmdletBinding()]
Param ([Parameter(Mandatory=$False,Position=0)]
       [String[]]$ComputerName = 'server1','server2','server3')

You'll get an error:

Missing expression after ','

So, does this mean you can't set a default value that is a list? Of course not! But it does mean you need to let PowerShell know explicitly what you're doing. When you pass in an adhoc list like that from the command line, it just works because PowerShell does a fair amount of magic for things it sees on the command line. But in a script, we need to be more explicit. So, instead, we need to declare our list as... A List! We do that with:

[CmdletBinding()]
Param ([Parameter(Mandatory=$False,Position=0)]
       [String[]]$ComputerName = @('server1','server2','server3'))

Now, our script works as we'd expect. It accepts my default list of server1, server2, server3, but allows me to override that list with my own list at the command line. (And remember, a list can be a list of length one, having only a single member. )

Importing users into Active Directory

When you need to create a single user in Active Directory Domain Services (AD DS), the tendency is to just "do it" in the GUI - either Active Directory Users and Computers (ADUC) or Active Directory Administrative Center (ADAC). But if you've got 25 users to add, or even 5 users to add, that's just painful. Plus, having this scripted ensures that each user is correctly entered following the same format.

For me, the easiest way to do this is by putting the users in a spreadsheet and then leveraging PowerShell's Import-CSV command. So, for the sake of argument, let's assume I have 8 new users to add to my TreyResearch.net domain. I open Excel, and create a spreadsheet with the following information for each user: Name, Given Name, Surname, Display Name, SamAccountName, and Description. The spreadsheet would look like this:blog_01

We'll save that as "TreyUsers.csv", and ignore any warnings about formatting, etc. This gives us a file with:

Name,GivenName,Surname,DisplayName,SAMAccountName,Description
David Guy,David,Guy,Dave R. Guy,Dave,Customer Appreciation Manager
Alfredo Fettucine,Alfredo,Fettuccine,Alfie NoNose,Alfie,Shop Foreman
Stanley Behr,Stanley,Behr,Stanley T. Behr, Stanley,Webmaster
Priscilla Catz,Priscilla,Catz,Dame Priscilla,Priscilla,Shop Steward
Harold Catz,Harold,Catz,Harold S. Catz,Harold,Engineering Manager
William Wallace,William,Wallace,Sir William Wallace,Wally,Marketing Manager
Trey Barksdale,Trey,Barksdale,Lord Barksalot,Trey,Sales Manager
Charlie Derby,Charles,Derby,Sparky,Sparky,Chief Security Officer

Now, we use PowerShell's Import-CSV to import this set of users into a variable $TreyUsers.

$TreyUsers = Import-CSV TreyUsers.csv

To create the users, we'll use a simple ForEach loop:

ForEach ($user in $TreyUsers ) {
   New-AdUser -DisplayName $User.DisplayName `
              -Description $user.Description `
              -GivenName $user.GivenName `
              -Name $User.Name `
              -SurName $User.SurName `
              -SAMAccountName $User.SAMAccountName `
              -Enabled $True `
              -ChangePasswordAtLogon $True `
              -PasswordNeverExpires $False `
              -UserPrincipalName $user.SAMAccountName `
              -AccountPassword (ConvertTo-SecureString `
                   -AsPlainText `
                   -Force `
                   -String 'P@ssw0rd!' ) 2>$NULL
}

(If you find that plain text password a bit problematic, use a Read-Line -AsSecureString early in the script to prompt you for an initial password. )

Finally, if you want to copy the security groups of an existing template user, stay tuned -- I'll cover that shortly

 

ETA: To correct ill-advised use of double-quotes in ConvertTo-SecureString.

PowerShell: Get-Credential from a file

If you routinely have to log into a separate domain, it can be a nuisance to always have to run Get-Credential. Plus writing scripts with a -Credential parameter is a nuisance because if you call Get-Credential in the script, it will always prompt you.

 

I run a separate lab network here, with an Active Directory domain of TreyResearch.net. I got tired of always having scripts prompt me for credentials, or even more annoying, have routine PowerShell commands against computers in the lab fail because I didn't have credentials for that domain. The answer is pretty simple -- first, I stored my password securely in a file with:

Read-Host -AsSecureString `
         | ConvertFrom-SecureString `
         | Out-File $Home\Documents\WindowsPowerShell\TCred.txt

Now, I can use that password to create a PSCredential object that I can pass into a script.

$tPW = Get-Content $home\Documents\WindowsPowerShell\TCred.txt `
         | ConvertTo-SecureString
$tCred = New-Object -TypeName System.Management.Automation.PSCredential `
                    -ArgumentList "TreyResearch\Charlie",$tPW

 

Because of the way SecureString works, and how Windows encrypts and decrypts objects, this password can only be read from the account that created it. Now, if I want to add a -Credential parameter to my scripts, I use the following:

[CmdletBinding()]
Param([Parameter(Mandatory=$false,ValueFromPipeLine=$True)]
      [PSCredential]
      $Credential = $NULL
     )

if ( $Credential ) {
   $tCred = $Credential
} else {
   $tPW = Get-Content $home\Documents\WindowsPowerShell\TCred.txt `
        | ConvertTo-SecureString 
   $tCred = New-Object -TypeName System.Management.Automation.PSCredential `
                       -ArgumentList "TreyResearch\Charlie",$tPW
}

ETA: if you create a script for this, you'll need to "dot source" the script to add the credential to your environment, but you can use that script in the pipeline to insert a credential into the pipeline. Or, add the relevant lines into your $Profile, and the credential is then available in your environment.

Getting Automatic Services That Are Stopped With PowerShell

One of the first things I check when I am troubleshooting a system is whether all the services that should be running, are. I could just open up services.msc, click on the "Startup Type" column to sort by the startup type, and scroll down through the Automatic services to see which ones aren't running. But that's so…. GUI  :p. And slower, and so very one machine at a time. Instead, let's use PowerShell to make it all easier.

 

First, I checked Get-Service, thinking it would give me what I need. but it doesn't. There's no way with Get-Service to find out what the startup type is -- it's not a property returned by Get-Service. (Yes, I think this is a deficiency. And yes, I expect someday we might get an improvement to Get-Service. But for the moment, we have to work around it. )

 

Instead, I decided to use the Get-WmiObject cmdlet to find what we need. (If the machine you're running this from is running PowerShell v3 or later, you can substitute Get-CimInstance for Get-WmiObject. But if you do, you won't be able to use -Credential.)

 

Get-WmiObject Win32_Service returns a list of all the services on the local machine. We can extend it with -ComputerName to query the services on a remote computer. And we can filter those services, though the filtering uses WQL as the query language, which is a nuisance since it doesn't match up to the Filter syntax for the ActiveDirectory module, for example.

 

To get a list of all the services that should have started automatically, but that are not currently running, on the local machine:

Get-WmiObject -ClassName Win32_Service -Filter "StartMode='Auto' AND State<>'Running'"

But that output is a bit ugly, so we'll throw some Format-Table at it, and come up with:

Get-WmiObject -ClassName Win32_Service `
              -Filter "StartMode='Auto' AND State<>'Running'" `
             | Format-Table -Auto DisplayName,Name,StartMode,State

Not bad. That gives us an easy to read output with all the information we need. We can wrap that up in a simple cmdlet that assumes the local computer, but that allows us to run it against multiple computers. And we want it to be able to get that list of computer names through the pipeline, of course. Plus, we'll add a Credential parameter to allow us to run against machines on a different domain, or a workgroup, so long as we provide an appropriate credential.

 

If we're going to get output from multiple computers, however, we need to know which one has which services that aren't running. To do that, we take advantage of Format-Tables GroupBy parameter:

Get-WmiObject -ClassName Win32_Service `
              -Filter "StartMode='Auto' AND State<>'Running'" `
             | Format-Table -AutoSize `
                            -Property DisplayName,Name,StartMode,State `
                            -GroupBy  PSComputer

Now we have everything we need to pull our script together.

Get-myStoppedService.ps1

<#
.Synopsis
Gets a list of stopped services
.Description
Get-myStoppedService takes a list of computer names and returns 
a table of the stopped services on that computer that are set to 
automatically start. The default is to return a list on the local computer.
.Example
Get-myStoppedService
Returns a table of stopped services on the local computer
.Example
Get-myStoppedService -ComputerName 'server1','client2'
Returns a table of stopped services on server1 and client2, 
grouped by computer name
.Parameter ComputerName
A list of remote computer names to query. If the current account 
doesn't have permission to query WMI on the remote computer, use 
the Credential parameter to provide alternate credentials. 
The default is the local host.
.Parameter Credential
Standard PSCredential object. Use Get-Credential.
.Inputs
[string[]]
[PSCredential]
.Notes
    Author: Charlie Russel
 Copyright: 2016 by Charlie Russel
          : Permission to use is granted but attribution is appreciated
   Initial: 29 September, 2016 (cpr)
   ModHist:
          :
#>
[CmdletBinding()]
Param(
     [Parameter(Mandatory=$False,Position=0,ValueFromPipeline=$True)]
     [Alias("Name","VMName")]
     [string[]]
     $ComputerName = ".",
     [Parameter(Mandatory=$False,ValueFromPipeline=$True)]
     [PSCredential]
     $Credential = $NULL
     )

if ($Credential) {
   Get-WMIObject -ClassName Win32_Service `
                 -Credential $Credential `
                 -ComputerName $ComputerName `
                 -Filter "StartMode='Auto' AND State<>'Running'" `
                | Format-Table -Auto DisplayName,Name,StartMode,State -GroupBy PSComputerName
} else {
   Get-WmiObject -ClassName Win32_Service `
                 -ComputerName $ComputerName `
                 -Filter "StartMode='Auto' AND State<>'Running'" `
                | Format-Table -Auto DisplayName,Name,StartMode,State -GroupBy PSComputerName
}

Starting a PowerShell window as a Domain Admin

If you run as a limited user on your own desktop, as you should, it's useful to keep a separate PowerShell window open as the Domain Administrator. I give that window a nice dark red background so I know instantly that I'm in a powerful window and to be appropriately careful. But how can I actually start a window as a different account, and using Run As Administrator? Ah, so glad you asked.

$AdminCred = Get-Credential -UserName "TreyResearch\domainadmin" `
                            -Message "Enter your password for the DomainAdmin account:" 
Start-Process PowerShell.exe -Credential $AdminCred `
                             -ArgumentList "Start-Process PowerShell.exe -Verb RunAs" `
                             -NoNewWindow

Save this as "Start-myAdmin.ps1" or equivalent and it's always available. The nice thing about using Start-Process with the NoNewWindow parameter is that it doesn't leave a spare window open. Try it. It will make it just that much easier to run as a limited user with no administrative rights, even on your own workstation. And really, when you have the power to be a domain admin, you really, really, really shouldn't run any other way.

Param() Tricks

One of the new features of PowerShell v5 is support for creating hard links, symbolic links and junctions. This is long overdue, and much appreciated. Before, I'd been forced to the workaround of using "cmd /c mklink" to create links, and I'm always glad to find a way to get rid of any vestige of cmd. Plus, having it as a part of PowerShell gives me way more flexibility in creating scripts.

 

As I was looking at some of my existing scripts, it occurred to me that I should be taking advantage of hard links in more scripts. I already use hard links for my various RDP connects, using a long switch statement. (I'll write that up one of these days, it's actually pretty cool.) But what caught my eye today was the script I wrote to create virtual machines for my labs -- New-myVM.ps1. I have a -Client parameter to the script that is a Boolean, defaulted to $False:

[CmdletBinding()]
Param([Parameter(Mandatory = $True,Position = 0)]
      [alias("Name")]
      [string]
      $VmName,
      [Parameter(Mandatory=$False)]
      [Boolean]
      $Client=$False
      )

Which is OK, but it occurred to me that I could do better. So, first, I created a new, hard-linked file with:

New-Item -Type HardLink -Name New-myClientVM.ps1 -Path .\New-myVM.ps1

Now I have one file with two names. Cool. So, let's take that a step further. I can tell which version of it I called from the command line by taking advantage of the automatic PowerShell variable $myInvocation:

$myInvocation.mycommand.name

This returns the filename (".name") of the command (".mycommand") that was executed ($myInvocation). So now, I can use:

$client =  ($myInvocation.mycommand.name -match "client")

I put that near the top of the script, and now I could branch depending on whether I was creating a server VM or a client VM. Which was definitely better, but still left me thinking it could be improved.

 

So, how about making the whole thing a lot cleaner by getting rid of that extra line? After all, I'm creating a variable and defaulting its value to $false, but why not default its value more intelligently, controlled by which file I executed to create the VM? I can still override it with the parameter (so no scripts that call this script will break), but now, I can set it automatically without using a parameter at all.

[CmdletBinding()]
Param([Parameter(Mandatory = $True,Position = 0)]
      [alias("Name")]
      [string]
      $VmName,
      [Parameter(Mandatory=$False)]
      [Boolean]
      $Client=($myInvocation.myCommand.Name -match "Client")
      )

Now that pleases me. It feels "cleaner", it's clear what I'm doing, and it doesn't take any longer to evaluate than it would as a standalone line.

Starting PowerShell Automatically – Revisited

A few months ago, I posted a quick blog on how I set PowerShell to start automatically when I log in. Well, it occurred to me that I should extend that to show you how I set the console window parameters so that my PowerShell windows are the right size and font, since on all my systems I use the same basic settings (except for my Surface Book with the 4K addon monitor -- my old eyes need a bit bigger on that, so I adjusted the values accordingly.)

The function I use to set the console size, font and font weight is: (FontSize here translates to 18 point on my FullHD systems)

Function Set-myDefaultFont () {
   $64bit = "HKCU:\Console\%SystemRoot%_System32_WindowsPowerShell_v1.0_powershell.exe"
   $Default = "HKCU:\Console"
   Set-ItemProperty -Path $Default -Name FaceName   -Value "Consolas"
   Set-ItemProperty -Path $Default -Name FontSize   -Type DWord -Value 0x00120000
   Set-ItemProperty -Path $Default -Name FontWeight -Type DWord -Value 0x000002bc
   Set-ItemProperty -Path $Default -Name FontFamily -Type DWord -Value 0x00000036 
   Set-ItemProperty -Path $Default -Name QuickEdit  -Type DWord -Value 0x00000001
   Set-ItemProperty -Path $64bit -Name FaceName     -Value "Consolas"
   Set-ItemProperty -Path $64bit -Name FontSize     -Type DWord -Value 0x00120000
   Set-ItemProperty -Path $64bit -Name FontWeight   -Type DWord -Value 0x000002bc
   Set-ItemProperty -Path $64bit -Name FontFamily   -Type DWord -Value 0x00000036 
   Set-ItemProperty -Path $64bit -Name QuickEdit    -Type DWord -Value 0x00000001
}

That's all there is to it. I've added this function to the Set-myPowerShellStart.ps1 script, and I call that whenever I create or log onto a new system. If you prefer different sizes or a different font, simply change the values to match what you need. For example, if you want a 16 point font, try a value of 0x00100000.

ETA: Fixed value of FontSize.

Active Directory — Unlocking a User Account with PowerShell

As any SysAdmin knows, users periodically lock themselves out of their accounts, usually because they forgot a password or somehow mistyped it too many times. And after all, if it failed once, why not keep trying it? Unlocking that account is NOT something you do with Set-ADUser, unfortunately, because the PowerShell ActiveDirectory module has a special, single-purpose cmdlet - Unlock-ADAccount. Now, it doesn't take a whole script to run a single cmdlet, but sometimes there are good reasons to wrap a command in a more complicated script, and this is one I've had to. Primarily because I work on at least three different domains that are unrelated - my home domain, my "work" domain, and my testlab domain. With this script, I can also take advantage of an encrypted password for one of those domains, stored securely on my main desktop's hard disk. Plus, I've added in a bit of code to go grab the current holder of the FSMO PDCEmulator role, since I'd prefer to unlock the account directly against that PDC.

Also, to make life easier, and allow us to stick this in the middle of a pipeline when we need to, we'll add the ability to handle parameters from the pipeline. We do that in the Param section:

[CmdletBinding()]
Param(
     [Parameter(Mandatory=$True,ValueFromPipeline=$true,Position=0)]
     [alias("user","account")]
     [string[]]
     $Identity,
     [Parameter(Mandatory=$False,ValueFromPipeline=$True,Position=1)]
     [string]
     $Domain = "DOMAIN",
     [Parameter(Mandatory=$False,ValueFromPipeline=$True)]
     [PSCredential]
     $Credential = $NULL
     )

You'll notice here that we've allowed for each of our parameters to accept pipeline input. And they'll accept that "ByValue", meaning that the piped objects must have the same .NET type, or must be able to be converted to that type. So only an actual PSCredential object can be accepted for the -Credential parameter.

 

Next, we want to be able to handle credentials in any of several ways. So we use:

if ( $Credential ) {
   $myCred = $Credential
} else {
   # Test if there is a stored, encrypted, password we can use
   $pwPath = "$home\Documents\WindowsPowerShell\$domainPW.txt"
   if (Test-Path $pwPath ) {
      $mypw = Get-Content $pwPath | ConvertTo-SecureString
      $myCred = New-Object -TypeName System.Management.Automation.PSCredential `
                           -ArgumentList "$DOMAIN\Charlie",$mypw
   } else {
      # Prompt for a credential, since we don't seem to have one here. 
      $myCred = Get-Credential 
   }
}

Let's look at this for a moment. First, we know from the Param section that the default value of $Credential is $NULL, so our first test is whether that's been overridden at the command line with either a pipelined parameter, or a specifically entered credential object. If so, we're good, and we use that. But failing that, we'll check if there's one stored securely on disk. (More on storing credentials on disks in another post soon.) If we've stored one on disk that matches the domain we're going against, we'll use that. If not, and we still don't have a PSCredential object we can use, we'll simply prompt for one.

 

Next up, we want to run this against the domain controller that hosts the PDCEmulator role. Not a big deal in simplified domain environment, but really a good idea in a complicated, multi-site environment where site-to-site propagation is a bit slow.

$PDC = (Get-ADDomain -Identity $DOMAIN -Credential $myCred).PDCEmulator

We'll plug the value of $PDC into the -Server parameter of our AD commands.

Finally, the meat of the whole thing. I'm not assuming you're only unlocking a single account, so I've designed this to take a list of strings ([string[]]). (A list can, of course, be a list of length one.)

ForEach ($usr in $Identity) {
   Unlock-ADAccount -Identity $usr `
                    -Credential $myCred `
                    -Server $PDC `
                    -PassThru `
       | Get-ADUser -Properties LockedOut

That last line ensures that we report back in a way that shows the account no longer locked out. But for Get-ADUser to actually do anything, you need to include the -PassThru parameter in the Unlock-ADAccount command. Otherwise, nothing at all gets passed to the Get-ADUser command.

So, here's the whole thing:

<#
.Synopsis
Unlocks a  domain account
.Description
Unlock-myUser accepts an array of DOMAIN account names and unlocks the accounts 

This script accepts pipeline input for the credential. If no credential is supplied, 
it will attempt to use one you have stored on disk. Failing that, it will prompt 
you for credentials. 
.Example
Unlock-myUser -Identity Charlie
Unlocks the account of Charlie 
.Example
Unlock-myUser -Identity Charlie, Sharon
Unlocks the accounts of Charlie and Sharon 
.Example
Unlock-myUser -Identity Charlie -Domain TREYRESEARCH `
              -Credential (Get-Credential `
                               -username "TREYRESEARCH\Alfie"  `
                               -Message "Enter your Domain PW")

Unlocks the account of TreyResearch\Charlie, prompting Alfie for credentials. 
.Parameter Identity
The AD identity of the user or users whose acounts are to be reset.
.Parameter Domain
The Active Directory domain of the user or users whose accounts are to be reset
.Parameter Credential
The user credentials to run the script under. 
.Inputs
[string[]]
[string]
[PSCredential]
.Notes
    Author: Charlie Russel
 Copyright: 2016 by Charlie Russel
          : Permission to use is granted but attribution is appreciated
   Initial: 07 Sept, 2016 (cpr)
   ModHist: 09 Sept, 2016 (cpr) - added PDC test and Domain parameter 
          :
#>
[CmdletBinding()]
Param(
     [Parameter(Mandatory=$True,ValueFromPipeline=$true,Position=0)]
     [alias("user","account")]
     [string[]]
     $Identity,
     [Parameter(Mandatory=$False,ValueFromPipeline=$True,Position=1)]
     [string]
     $Domain = "DOMAIN",
     [Parameter(Mandatory=$False,ValueFromPipeline=$True)]
     [PSCredential]
     $Credential = $NULL
     )

if ( $Credential ) {
   $myCred = $Credential
} else {
   # Test if there is a stored, encrypted, password we can use
   $pwPath = "$home\Documents\WindowsPowerShell\$domainPW.txt"
   if (Test-Path $pwPath ) {
      $mypw = Get-Content $pwPath | ConvertTo-SecureString
      $myCred = New-Object -TypeName System.Management.Automation.PSCredential `
                           -ArgumentList "DOMAIN\Charlie",$mypw
   } else {
      # Prompt for a credential, since we don't seem to have one here. 
      $myCred = Get-Credential 
   }
}

# Find out which server holds the PDC role. 
# Useful in complicated, multi-site environments where 
# Domain changes might not propagate quickly. 

$PDC = (Get-ADDomain -Identity $DOMAIN -Credential $myCred).PDCEmulator

ForEach ($usr in $Identity) {
   Unlock-ADAccount -Identity $usr `
                    -Credential $myCred `
                    -Server $PDC `
                    -PassThru `
       | Get-ADUser -Properties LockedOut
}

As always, feel free to use this script or any portion of it that you find useful. However, if you do, I'd appreciate attribution and a pointer back to my blog.