PowerShell: Remove-ADComputer v. Remove-ADObject

So, as I mentioned the other day, we needed to do some major cleanup of defunct and orphaned computer accounts. Most computers that hadn't been logged in to in the last year needed to go. And there were a LOT of them! Certainly more than anyone wanted to try to do in the GUI. So, having found them, it was time to remove them, using:

$oneyear = (Get-Date).AddDays(-365)
Get-ADComputer -Filter {(LastLogonDate -lt $oneyear ) -AND ((Name -like "ws-*") -OR (Name -like "Desktop*") -OR (Name -like "XP-*"))} 
               -Properties LastLogonDate `
  | Remove-ADComputer -Confirm:$False -Verbose

And I started watching the deletions go by on the screen. Honestly, a fairly scary moment. Especially when I started to see some errors scroll by..

VERBOSE: Performing the operation "Remove" on target "CN=WS-DCOVENTRY-02,OU=\#Workstations,DC=Contoso,DC=com".
VERBOSE: Performing the operation "Remove" on target "CN=WS-VTAWARE-02,CN=Computers,DC=Contoso,DC=com".
VERBOSE: Performing the operation "Remove" on target "CN=WS-VIMALG-02,CN=Computers,DC=Contoso,DC=com".
VERBOSE: Performing the operation "Remove" on target "CN=WS-FHEMMATI-02,OU=\#Workstations,DC=Contoso,DC=com".
VERBOSE: Performing the operation "Remove" on target "CN=WS-BGL-ECOM,CN=Computers,DC=Contoso,DC=com".
Remove-ADComputer : The directory service can perform the requested operation only on a leaf object
At line:1 char:228
+ ... LogonDate | Remove-ADComputer 
+                 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : NotSpecified: (CN=WS-BGL-ECOM,...C=Contoso,DC=com:ADComputer) [Remove-ADComputer], ADExce
   ption
    + FullyQualifiedErrorId : ActiveDirectoryServer:8213,Microsoft.ActiveDirectory.Management.Commands.RemoveADCompute
   r

I ended up with about 15% of the computer accounts refusing to be removed with Remove-ADComputer. So I checked the man pages for Remove-ADComputer, and there were no additional parameters that would overcome it. Well, phooie!

 

OK, so time to haul out the seriously powerful tool, Remove-ADObject -Recursive. A word of warning here -- you can do some serious damage with this command.  First, I verified the cause of the failures -- the offending computer accounts had subsidiary objects that they probably shouldn't ever have had. OK, all that was before my time, but none of them were any longer relevant. So, now, my command needed to morph due to the somewhat more annoying syntax of Remove-ADObject. I couldn't just pipe the results of Get-ADComputer to it, I needed to return a list of objects and walk through them with a ForEach loop, like this:

$oneyear = (Get-Date).AddDays(-365)
$adFilter = {(LastLogonDate -lt $oneyear ) -AND ((Name -like "ws-*") -OR (Name -like "Desktop*") -OR (Name -like "XP-*"))} 

ForEach ($Computer in (Get-ADComputer -Filter $adFilter -Properties LastLogonDate)) {
     Remove-ADObject $Computer -Recursive -Confirm:$False -Verbose
}

And there go the last of the orphaned accounts! Notice, by the way, the use of a variable to hold the filtering criteria. This is a useful trick if you're iterating through a bunch of filters, or dealing with a fairly long and complicated one. You need to edit the variable with each iteration, but the actual command stays the same. Plus, IMHO, it makes the whole thing more readable.

PowerShell: Finding Orphaned Computer Accounts in AD

The other day we decided it was time and more to do some cleanup of orphaned computer accounts in our AD. We are about to do some AD restructuring, and figured it was a good opportunity to clean up and remove old computer accounts for machines that no longer existed. Now there are probably lots of ways to do this, but the way I chose was to look at the AD properties of the computer to see when it was last logged on to. Then arbitrarily deciding that any computer that hadn't been logged on to in the last year was a good candidate. At first glance, that's not part of the properties that are returned with Get-ADComputer:

Get-ADComputer -Identity srv2


DistinguishedName : CN=SRV2,OU=Servers,DC=contoso,DC=com
DNSHostName       : srv2.contoso.com
Enabled           : True
Name              : SRV2
ObjectClass       : computer
ObjectGUID        : 0ce3c9fa-4b07-4dde-8323-ff94153d2bf9
SamAccountName    : SRV2$
SID               : S-1-5-21-2576220272-3971274590-1167723607-15115
UserPrincipalName :

But wait, I know there have to be more than that -- let's try making sure that we get all the properties, not just the most common:

Get-ADComputer -Identity srv2 -Properties *

AccountExpirationDate                :
accountExpires                       : 9223372036854775807
AccountLockoutTime                   :
AccountNotDelegated                  : False

...

DistinguishedName                    : CN=SRV2,OU=Servers,DC=contoso,DC=com
DNSHostName                          : srv2.contoso.com

...

KerberosEncryptionType               : {RC4, AES128, AES256}
LastBadPasswordAttempt               : 4/25/2016 6:28:41 PM
LastKnownParent                      :
lastLogoff                           : 0
lastLogon                            : 131689942478668713
LastLogonDate                        : 4/18/2018 10:18:47 PM
lastLogonTimestamp                   : 131685887279055446

...

whenCreated                          : 4/23/2015 6:28:41 PM

Ah, that's more like it. Now I can see that there's a LastLogonDate property. That should do it. Now, it's just a case of simple math. And because we're looking for more than a single computer, we need to switch to using the -Filter parameter of Get-ADComputer. Plus I'll specify which server to query, and the account credentials to use to run the query:

Get-ADComputer `
        -Server dc01 `
        -Credential $cred `
        -Filter * `
        -Properties LastLogonDate `
              | Where-Object LastLogonDate -lt (Get-Date).AddDays(-365) `
              | Select-Object Name,LastLogonDate

Now that's fine for moderately sized Active Directories, but could be a bit of a problem for large ones. So, instead of grabbing every computer in the domain and then filtering them, let's only get the one's that fit our one year criteria.

$oneyear = (Get-Date).AddDays(-365)
Get-ADComputer `
        -Server dc01 `
        -Credential $cred `
        -Filter {LastLogonDate -lt $oneyear } `
        -Properties LastLogonDate `
             | Select-Object Name,LastLogonDate `
             | ConvertTo-CSV -NoTypeInformation > C:\Temp\Defunct.csv

And now we have the list usefully exported to a CSV where we can manipulate it and verify the names really are those of orphaned computers. From there, I could feed the list of computers into Remove-ADComputer, or I can do it directly by piping this result to Remove-ADComputer, complete with a -Force parameter. Yeah. Right. And maybe a good idea to just verify the list first.

 

ETA: Well, it might be a good idea to check the available parameters for Remove-ADComputer before I post something. Sigh. There is no -Force parameter. Instead, you need to use -Confirm:$False if you want Remove-ADComputer to just do its work without prompting. And if the computer has any objects associated with it, you'll have to use Remove-ADObject. But more on that in another post.

RemoteApp Tool

As I was working with a Remote Desktop Session Host the other day, and creating some RemoteApps (more on that in a post shortly), I came across an interesting utility from Kim Knight, the RemoteApp Tool . It makes it possible to do some interesting things with RemoteApps, including creating .msi files to deploy them. But what interested me the most was that it allows you to have RemoteApps from Windows clients! The clients must be Enterprise editions (or equivalent in earlier versions), and this does not get around the single console user limitations of Windows client, but it's a perfect solution for users who have multiple computers they use and do not want to have to buy multiple copies of specialized software. Or set up a full RD Session Host just for their home environment! The program is free-ware, appears to work well, and solves several problems with managing RemoteApps for SOHO or SMB environments. Recommended!

Creating VPNs

First, an apology. I usually try to be conscientious about adding new nuggets of PowerShell fun on a regular basis, but this winter, LIFE has intruded, and it simply hasn't happened. I won't promise it won't happen again, but I will try to do better.

Today's post looks at a problem we've been dealing with at work -- how to pre-configure new laptops with the VPN access they'll need for users to get logged in, even when they're not ever in an office to set themselves up. There are lots of different workarounds and solutions, but what  we came up with was a PowerShell script that would create one or more VPNs programmatically. We take advantage of the Invoke-WebRequest cmdlet I discussed earlier to pull down an updated set of parameters for the available VPNs, allowing us to separate the code from the data, useful for providing some protection against changes -- we only have to update one file.

The command to create a new VPN connection is: Add-VPNConnection and it comes with a plethora of parameters, most of which you'll never need. But as always, good to have them when you need them. For our purposes, we needed to specify the VPN type (-TunnelType), the authentication method, and an initial pre-shared key for L2TP. We also wanted the ability to use the same script for individual user profile VPNs, and to control whether the VPN used a split tunnel. (Normally, we configure for split tunneling.) The basic command is:

Add-VpnConnection -Name <ConnectionName> `
                  -ServerAddress <IP Address of VPN Server> `
                  -TunnelType L2TP `
                  -L2tpPsk <somereallylongandoddstring> `
                  -AuthenticationMethod MSChapv2 `
                  -AllUserConnection `
                  -SplitTunneling `
                  -PassThru

So, we know we're going to need a name for the connection, and IP address, and the L2TP PSK for each connection. The easy way is to stuff that into a CSV file and store it up in the cloud where all the IT staff can get at it from whatever location we're in. So we need to read the contents of a file, probably stored in the cloud, and, using a foreach statement, iterate the Add-VPNConnection command once for each line of the CSV file to create VPNs to all of the VPNs listed in the CSV file. Pretty simple, really. The annoying part is that we have to repeat ourselves doing this to handle the values of the AllUserConnection and SplitTunneling switches in the Add-VPNConnection command. If they were Booleans, it would be a bit less messy.

<#
.Synopsis
Creates one or more VPNs. Uses a CSV file stored in OneDrive for Business
.Description
New-myVPN reads a list of VPNs and their parameters from a CSV file stored 
in OneDrive for Business, and creates one or more VPNs based on that list.

The created VPNs can be configured as AllUser VPNs, or only for the current
user (the default). The VPNs are created as Split-Tunnel VPNs unless the 
NoSplitVPN parameter is specified. 
.Example
New-myVPN 

Reads the default VPN.csv file and creates vpns using the details in that file
to create VPNs as split-Tunnel VPNs available to all users.
.Example
New-myVPN -NoSplitVPN -AllUserConnection $False

Reads the default VPN.csv file and creates new VPNs using the details in that file. 
The VPNs are created in the current user's profile, and are not created as split VPNs
.Parameter Path
Path to CSV file. The CSV file is in the format: Name,ServerAddress,L2tpPsk. The 
default path is to a file called VPN.csv, stored in a folder called Private, in 
the current user's OneDrive for Business. 
.Parameter AllUserConnection
Boolean -- default is $True. When True, VPNs are created as an AllUserConnection and
are available to all users on the computer. When False, VPNs are created in the 
current user's profile and are only available to the user after logon. 
.Parameter NoSplitTunnel
Switch -- VPNs are created as SplitTunnel VPNs unless this switch is enabled. A 
SplitTunnel VPN sends all regular traffic to the main network interface, but 
sends traffic to hosts connected via the VPN to the VPN. 

When this switch is set, all traffic outside of the local subnet is sent over the 
VPN connection. 

.Inputs
[string]
[Boolean]
[Switch]
.Notes
    Author: Charlie Russel
 Copyright: 2018 by Charlie Russel
          : Permission to use is granted but attribution is appreciated
   Initial: 13 March, 2018 (cpr)
   ModHist:
          :
#>
[CmdletBinding()]
Param(
     [Parameter(Mandatory=$False,Position=0)]
     [string]
     $Path = (Get-ItemProperty 'HKCU:\Software\Microsoft\OneDrive\Accounts\Business1').UserFolder + "\private\vpn.csv",
     [Parameter(Mandatory=$true)]
     [Boolean]
     $AllUserConnection,
     [Parameter(Mandatory=$False)]
     [Switch]
     $NoSplitTunnel
     )


if ($Path -match "http") { # We're connecting to the web to get the parameters
   Write-Verbose "Found a match against $Matches[0], so using WebRequest"
   $vpnParams = ConvertFrom-CSV (Invoke-WebRequest -Uri $Path ).ToString() 
} else { # We're going against a local path
   Write-Verbose "Reading from a local file $path"
   $vpnParams = ConvertFrom-CSV (Get-Content $Path)
}

$vpncount = $vpnParams.count
if (($AllUserConnection) -AND (! $NoSplitTunnel)) {
   Write-Verbose "Creating $vpn.count AllUser VPNs using parameters in $path and split-tunneling"
   ForEach ($param in $vpnParams) {
      Add-VpnConnection -Name $param.Name `
                        -ServerAddress $param.ServerAddress `
                        -TunnelType L2TP `
                        -L2tpPsk $param.L2tpPsk `
                        -AuthenticationMethod MSChapv2 `
                        -EncryptionLevel Optional `
                        -AllUserConnection `
                        -SplitTunneling `
                        -Force `
                        -PassThru
   }
} elseif (($AllUserConnection) -AND ($NoSplitTunnel)) {
   Write-Verbose "Creating $vpncount AllUser VPNs using parameters in $path and no split-tunneling"
   ForEach ($param in $vpnParams) {
      Add-VpnConnection -Name $param.Name `
                        -ServerAddress $param.ServerAddress `
                        -TunnelType L2TP `
                        -L2tpPsk $param.L2tpPsk `
                        -AuthenticationMethod MSChapv2 `
                        -EncryptionLevel Optional `
                        -AllUserConnection `
                        -Force `
                        -PassThru
   }
} elseif ($NoSplitTunnel) {
   Write-Verbose "Creating $vpncount current user VPNs using parameters in $path and no split-tunneling"
   ForEach ($param in $vpnParams) {
      Add-VpnConnection -Name $param.Name `
                        -ServerAddress $param.ServerAddress `
                        -TunnelType L2TP `
                        -L2tpPsk $param.L2tpPsk `
                        -AuthenticationMethod MSChapv2 `
                        -EncryptionLevel Optional `
                        -Force `
                        -PassThru
   }
} else { 
   Write-Verbose "Creating $vpncount current user VPNs using parameters in $path and split-tunneling"
   ForEach ($param in $vpnParams) {
      Add-VpnConnection -Name $param.Name `
                        -ServerAddress $param.ServerAddress `
                        -TunnelType L2TP `
                        -L2tpPsk $param.L2tpPsk `
                        -AuthenticationMethod MSChapv2 `
                        -EncryptionLevel Optional `
                        -SplitTunneling `
                        -Force `
                        -PassThru
   }
}

Using Invoke-WebRequest to Read a File

An interesting problem came up recently where we needed to standardize the creation of VPNs on new user laptops. To do that, I knew I needed to use the Add-VPNConnection cmdlet (more on that in a another post, soon.) But in order to populate the parameters of Add-VPNConnection, I needed to store the values somewhere. The easy answer was on my desktop, but that's not terribly portable, especially since I routinely work on any of 3 or 4 different computers. The answer was to store the parameters in a file on my OneDrive for Business (ODB) site, and suck the contents of the file down to whatever machine I happened to be on with Invoke-WebRequest. The file needed to be a CSV file with three fields for each VPN--Name, IP Address, and the L2TP Pre-Shared Key. Easy enough, I know how to parse a CSV file. (If you want a useful example, see Importing Users into Active Directory). But first, I have to get the contents of that CSV file. The answer was a cmdlet I hadn't had occasion to use before -- Invoke-WebRequest. To make this work, you'll need a link to the document in your OneDrive for Business site. (This will work identically with consumer OneDrive, but since these are business assets, they belong in ODB.) That link will look something like:

https://example-my.sharepoint.com/personal/charlie_example_com/_layouts/15/guestaccess.aspx?docid=123456789abcdef0123456789abcdef01&authkey=ABcDEFGH01IJkl2MnopQRSt your code here

To download the file with Invoke-WebRequest, and save it to a file on your local hard drive, use:

Invoke-WebRequest -Uri 'https://example-my.sharepoint.com/personal/charlie_example_com/_layouts/15/guestaccess.aspx?docid=123456789abcdef0123456789abcdef01&authkey=ABcDEFGH01IJkl2MnopQRSt' -Credential (Get-Credential) -Outfile 'C:\Temp\Content.txt'

That's one ugly long command line, but mostly that's because ODB creates seriously long links to documents! However, it's really pretty simple -- only 3 parameters: The link to the document (-Uri), a Credential parameter, and the location to save the content to (-OutFile).

An important caveat here -- by using -OutFile, we've forced Invoke-WebRequest to just give us the content of the file. But if you're running this in a script where you're not saving to a file, but want to use it directly with ConvertFrom-CSV, for example, you need to access the Content property ToString method of the file. So, you might have something like this:

$BaseURI = 'https://example-my.sharepoint.com/personal/charlie_example_com/_layouts/15/guestaccess.aspx?'
$DocID = '123456789abcdef0123456789abcdef01'
$authKey = 'ABcDEFGH01IJkl2MnopQRSt'
$FullUri = $BaseURI + "DocID=$DocID" + "&AuthKey=$authKey"
# $VPNParams = ConvertFrom-CSV (Invoke-WebRequest -Uri $FullUri -Credential $cred).Content
$VPNParams = ConvertFrom-CSV (Invoke-WebRequest -Uri $FullUri -Credential $Cred).ToString()

(Thanks to Ricardo Heredero for the initial suggestion!)

 

ETA: It appears Microsoft, in their infinite wisdom, have changed the link format for OneDrive for Business links. You'll want to adjust accordingly, of course. Sigh.

ETA2: OK, so there's a bug in this! If you use the .Content property, it gives you the byte string. NOT what you want. The cleanest solution is to use the ToString method. This means you need:

$VPNParams = ConvertFrom-CSV (Invoke-WebRequest -Uri $FullUri -Credential $Cred).ToString()

Sorry about that!

Add a Domain User to the Local Administrators Group

When building out a workstation for an AD Domain user, in some environments the user is added to the local Administrators group to allow the user to install and configure applications. Now there are some of us who think that's a Bad Idea and a Security Risk, but the reality is that it's policy in some organizations. Doing this with the GUI is easy, but who wants to have to use the GUI for anything? Especially for a highly repetitive task that you're going to have to do on every user's workstation. So, let's use PowerShell and [ADSI] to do the heavy lifting.

The first step is to define the target we want to add the user to:

$ComputerName = "workstation01"
$Group = "Administrators"
$target=[ADSI]"WinNT://$ComputerName/$Group,group"

Next, we invoke the Add method on that target to add the user to the group.

$Domain = 'TreyResearch'
$UserName = 'Charlie.Russel'
$target.psbase.Invoke("Add",([ADSI]"WinNT://$Domain/$UserName").path)

And that's really all there is to it.

(Note, by the way, that this is one of the only places in PowerShell where CASE MATTERS. the WinNT commands are case sensitive so don't change that to winnt or WINNT. It won't work. )

Finally, let's pull all that together into a script that accepts the user name, the target computer, and the AD Domain as parameters:

<#
.Synopsis
Adds a user to the Local Administrators group
.Description
Add-myLocalAdmin adds a user to the local Administrators group on a computer. 
.Example
Add-myLocalAdmin Charlie.Russel 
Adds the TreyResearch user Charlie.Russel to the Administrators local group on the localhost.
.Example
Add-myLocalAdmin Charlie.Russel -ComputerName ws-crussel-01
Adds the TreyResearch user Charlie.Russel to the Administrators local group on ws-crussel-01.
.Example
Add-myLocalAdmin -UserName Charlie.Russel -ComputerName ws-crussel-01 -Domain Contoso
Adds the Contoso user Charlie.Russel to the Administrators local group on ws-crussel-01.
.Parameter UserName
The username to add to the Administrators local group. This should be in the format first.last. 
.Parameter ComputerName
[Optional] The computer on which to modify the Administrators group. The default is localhost
.Parameter Domain
[Optional] The user's Active Directory Domain. The default is TreyResearch.
.Inputs
[string]
[string]
[string]
.Notes
    Author: Charlie Russel
 Copyright: 2017 by Charlie Russel
          : Permission to use is granted but attribution is appreciated
   Initial: 21 June, 2017 (cpr)
   ModHist:
          :
#>
[CmdletBinding()]
Param(
     [Parameter(Mandatory=$True,Position=0)]
     [alias("user","name")]
     [string]
     $UserName,
     [Parameter(Mandatory=$False,Position=1)]
     [string]
     $ComputerName = 'localhost',
     [Parameter(Mandatory=$False)]
     [string]
     $Domain = 'TreyResearch'
     )

$Group = 'Administrators'

# Please be warned. The syntax of [ADSI] is CASE SENSITIVE!
$target=[ADSI]"WinNT://$ComputerName/$Group,group"
$target.psbase.Invoke("Add",([ADSI]"WinNT://$Domain/$UserName").path)

 

Copying AD User Group Permissions with PowerShell

One of the tasks that I'm often asked to perform as an Active Directory domain administrator is to assign a user the same set of permissions as an existing user. This is something you can do fairly easily in the GUI (Active Directory Users and Computers, dsa.msc) when you're first creating the user, but which is a pain if the target user already exists. Turns out PowerShell can help with this, of course.

First, you need to get the list of groups that the template or source user ($TemplateUser) is a member of. That's fairly simple:

$UserGroups =@()
$UserGroups = (Get-ADUser -Identity $TemplateUser -Properties MemberOf).MemberOf

A couple of important points in the above:

  • First, you should create the empty array first. That tells PowerShell that you're going to be creating a list of groups, not a single one. You can often get away without doing this at the command line because of PowerShell's command line magic, but in a script, you need to be explicit.
  • Second, you need to include the MemberOf property in the Get-ADUser query. By default, that isn't returned and you'll end up with an empty $UserGroups variable.

So, you've got a list of groups. If you're just doing an "additive" group membership change, all you need to do is add the target user ($TargetUser) to the all the groups. However, if you want to exactly match the group memberships, you need to first remove the target user from any groups s/he is part of before adding groups back. To do that, we need to first find out what groups the target user is currently in with much the same command as above:

$CurrentGroups = @()
$CurrentGroups = (Get-ADUser -Identity $TargetUser -Properties MemberOf).MemberOf

Now, we can remove the user from all current groups with:

foreach ($Group in $CurrentGroups) {
    Remove-ADGroupMember -Identity $Group -Members $TargetUser
}

Notice in the above that -Identity is the identity of the group, not the user. This is because we're acting on the groups, not acting on the user(s).

Finally, we can now add $TargetUser back in to the groups that $TemplateUser had with:

foreach ($Group in $UserGroups) {
    Add-ADGroupMember -Identity $Group -Members $TargetUser
}

All of this, of course, happens quietly with no confirmation. So, just to verify that everything went as expected, use:

(Get-ADUser -Identity $TargetUser -Properties MemberOf).MemberOf

And you should get back a list of user groups the target user is now a member of.

Note: If you're including this code in a new user script, you won't need to remove the user from current groups, merely add them to the same groups as the template user.

PowerShell Syntax Highlighting

With Windows 10 / Server 2016, PowerShell got command-line syntax highlighting. And what a difference that makes! With syntax highlighting, it's easier to see a mistyped line of code as you make the mistake. Combined with intelligent tab-completion, my errors/command ratio is way, way down.

 

Many of the same advantages accrued to readers of this blog -- by having syntax highlighting turned on, it was easier to follow the logic of the scripts and commands I posted here. Unfortunately, the tool we were using for that has 'issues', and hasn't been actively developed or updated in several years. Reluctantly, the overall Site Admins for msmvps.com (who are GODDESSES and loved beyond belief!) have had to remove that plugin. It just had too many problems and with no active development, little chance they'd get fixed.  The result? All the code on this site is now plain text ugly. :(

 

ETA: Fixed. Mivhak to the rescue. It's not perfect, but a huge improvement over nothing. And it looks like it's easily customizable, so I might do some tweaking to improve contrast without going to a dark theme.

 

But wait! There's hope! We think we've identified a solution that we can use, and that is still being actively maintained. It supports PowerShell, and it supports WordPress Multi-site. Both absolute requirements. The plug in is being actively tested while they continue the work of cleaning up the mess that the previous plugin created. Unfortunately, however, it will require me to go in and edit every single post that includes PowerShell code to enable the new plugin for that code. That process will take time. I'll start with some of the most recent, and the most popular, and slowly work my way through them.

 

ETA: Whew! Mivhak is smart enough to recognize all my posts that had PRE tags and automatically syntax highlight them as PowerShell. That saves a BUNCH of work.

 

Until we have a confirmed solution, and I've had time to go in and edit each post, I'm afraid you'll have to do it the hard way. Copy and paste the code into the syntax highlighting editor of your choice. And, speaking of which, have you tried Visual Studio Code? This is a slick, new, FREE, editor that supports easy customization, has full IntelliSense support(!!), and even has a plugin to enable Vi mode editing! How cool is that?! I've been playing around with it a lot lately, and I'm almost ready to switch from my beloved gVim.

Guest Post — Get-myFreeSpace Revisited

Today's post comes by way of a co-worker, Robert Carlson, who took my previous post on getting the free disk space of remote computers and offered a very useful suggestion -- instead of outputting strings, which is only useful for a display or report, he suggests creating a PSCustomObject and outputting that. Slick! I like it.

 

So, why a PSCustomObject? Because now he can use it to drive automation, rather than simply reporting. A very handy change, and a good reminder for all of us that we should put off formatting until the last possible moment, because once you pipe something to Format-*, you're done. All your precious objects and their properties are gone, and you're left with a simple string.

 

The other thing Robert has done is change this from a script to a function. This makes it easier to call from other scripts and allows it to be added to your "toolbox" module. (More on Toolbox Modules soon. )  A worthy change. So, without further ado, here's Robert's revised Get-myFreeSpace function.

function Get-myFreeSpace {
<#
.Synopsis
Gets the disk utilization of one or more computers
 
.Description
Get-myFreeSpace queries an array of remote computers and returns a nicely formatted display of 
their current disk utilization and free space. The output can be redirected to a file or other 
output option using standard redirection, or can be piped to further commands.

.Parameter ComputerName
An array of computer names from which you want the disk utilization

.Example
(Get-VM -Name “*server*” | Where-Object {$_.State -eq ‘Running’).Name } | Get-myFreeSpace
Gets the free disk space of the running virtual machines whose name includes 'server'

.Inputs
[string[]]

.Notes
 Original Author: Charlie Russel
Secondary Author: Robert Carlson
Copyright: 2017 by Charlie Russel
         : Permission to use is granted but attribution is appreciated
  Initial: 26 Nov, 2014 (cpr)
  ModHist: 29 Sep, 2016 — Changed default to array of localhost (cpr)
         : 18 Apr, 2017 — Changed to use Write-Output,accept Pipeline,added man page, (cpr)
         : 20 Apr, 2017 — Changed output to pscustomobject rather than string, etc.(RC)
#>

[CmdletBinding()]
Param(
[Parameter(Mandatory=$False,Position=0,`
           ValueFromPipeline=$True,`
           ValueFromPipelineByPropertyName=$True,`
           ValueFromRemainingArguments=$True)]
           [alias(“Name”,”Computer”)]
           [string[]]
           $ComputerName = @(“localhost”)
           )

Begin {
   if ($Input) {
      $ComputerName = @($Input) 
   }
}

Process {
   ForEach ( $Computer in $ComputerName ) {
      $volumes = Get-WmiObject -ComputerName $Computer -Class Win32_Volume -ErrorAction SilentlyContinue 
      foreach ($volume in $volumes) {
         $volumeData = [pscustomobject]@{
            ComputerName=$Computer 
            Drive=$volume.DriveLetter
            VolumeLabel=$volume.Label
            VolumeSize=”{0:N0}” -f ($volume.Capacity / 1GB)
            FreeSpace=”{0:N0}” -f ($volume.FreeSpace/1GB)
            }
         if ($volume.Capacity) {
            $percentage = “{0:P0}” -f ($volume.FreeSpace / $volume.Capacity)
            $volumeData | Add-Member -NotePropertyName “PercentageFree” -NotePropertyValue $percentage
         } else {
            $volumeData | Add-Member -NotePropertyName “PercentageFree” -NotePropertyValue “n/a”
         }
         Write-Output $volumeData
      }
   }
 }
}

I really appreciate Robert's contribution, and I thank him profoundly for his suggestion. I learned something, and I hope you have too.  I hope you found this useful, and I'd love to hear comments, suggestions for improvements, or bug reports as appropriate. As always, if you use this script as the basis for your own work, please respect my copyright and provide appropriate attribution.

Resizing the PowerShell Console

Windows 10's support for high DPI displays is much better than previous iterations of Windows, but there are still some times it gets a bit confused. One such problem occurs when you have multiple high DPI displays or two displays of different sizes. If you move PowerShell console windows between displays or log back in after being logged out for a while, you can end up with a scrunched up PowerShell window. Nothing I had to deal with when all I had was a pair of standard FullHD monitors, but ever since I got my Surface Book, and connected it to a 28 inch 4k monitor, I've had periodic problems. Very annoying when your PowerShell window changes to 37 characters wide and 7 lines long!

 

The fix is to reset the window size. Now I can do this graphically (right click on the title bar, select Properties, and then the Layout tab), but that's a nuisance at best, and besides, the whole idea of using the GUI to fix a console just isn't right. The answer is to leverage the built-in $host variable:

$host | Get-Member

   TypeName: System.Management.Automation.Internal.Host.InternalHost

Name                   MemberType Definition
----                   ---------- ----------
EnterNestedPrompt      Method     void EnterNestedPrompt()
Equals                 Method     bool Equals(System.Object obj)
ExitNestedPrompt       Method     void ExitNestedPrompt()
GetHashCode            Method     int GetHashCode()
GetType                Method     type GetType()
NotifyBeginApplication Method     void NotifyBeginApplication()
NotifyEndApplication   Method     void NotifyEndApplication()
PopRunspace            Method     void PopRunspace(), void IHostSupportsInteractiveSession.PopRunspace()
PushRunspace           Method     void PushRunspace(runspace runspace), void IHostSupportsInteractiveSession.PushRunspace(runspace runspace)
SetShouldExit          Method     void SetShouldExit(int exitCode)
ToString               Method     string ToString()
CurrentCulture         Property   cultureinfo CurrentCulture {get;}
CurrentUICulture       Property   cultureinfo CurrentUICulture {get;}
DebuggerEnabled        Property   bool DebuggerEnabled {get;set;}
InstanceId             Property   guid InstanceId {get;}
IsRunspacePushed       Property   bool IsRunspacePushed {get;}
Name                   Property   string Name {get;}
PrivateData            Property   psobject PrivateData {get;}
Runspace               Property   runspace Runspace {get;}
UI                     Property   System.Management.Automation.Host.PSHostUserInterface UI {get;}
Version                Property   version Version {get;}
  

OK, there's some interesting bits there, but the one that looks most promising is UI. So:

 $host.UI | Get-Member


   TypeName: System.Management.Automation.Internal.Host.InternalHostUserInterface

Name                    MemberType Definition
----                    ---------- ----------
Equals                  Method     bool Equals(System.Object obj)
GetHashCode             Method     int GetHashCode()
GetType                 Method     type GetType()
Prompt                  Method     System.Collections.Generic.Dictionary[string,psobject] Prompt(string caption, string message, System.Collection...
PromptForChoice         Method     int PromptForChoice(string caption, string message, System.Collections.ObjectModel.Collection[System.Management...
PromptForCredential     Method     pscredential PromptForCredential(string caption, string message, string userName, string targetName), pscredent...
ReadLine                Method     string ReadLine()
ReadLineAsSecureString  Method     securestring ReadLineAsSecureString()
ToString                Method     string ToString()
Write                   Method     void Write(string value), void Write(System.ConsoleColor foregroundColor, System.ConsoleColor backgroundColor, ...
WriteDebugLine          Method     void WriteDebugLine(string message)
WriteErrorLine          Method     void WriteErrorLine(string value)
WriteInformation        Method     void WriteInformation(System.Management.Automation.InformationRecord record)
WriteLine               Method     void WriteLine(), void WriteLine(string value), void WriteLine(System.ConsoleColor foregroundColor, System.Cons...
WriteProgress           Method     void WriteProgress(long sourceId, System.Management.Automation.ProgressRecord record)
WriteVerboseLine        Method     void WriteVerboseLine(string message)
WriteWarningLine        Method     void WriteWarningLine(string message)
RawUI                   Property   System.Management.Automation.Host.PSHostRawUserInterface RawUI {get;}
SupportsVirtualTerminal Property   bool SupportsVirtualTerminal {get;}
  

Hmmm. Even more interesting stuff. I can tell I'm going to be doing some poking around in here! But, for our purposes, let's take a look at RawUI.

That looks the most promising:

$host.UI.RawUI | Get-Member


   TypeName: System.Management.Automation.Internal.Host.InternalHostRawUserInterface

Name                  MemberType Definition
----                  ---------- ----------
Equals                Method     bool Equals(System.Object obj)
FlushInputBuffer      Method     void FlushInputBuffer()
GetBufferContents     Method     System.Management.Automation.Host.BufferCell[,] GetBufferContents(System.Management.Automation.Host.Rectangle r)
GetHashCode           Method     int GetHashCode()
GetType               Method     type GetType()
LengthInBufferCells   Method     int LengthInBufferCells(string str), int LengthInBufferCells(string str, int offset), int LengthInBufferCells(cha...
NewBufferCellArray    Method     System.Management.Automation.Host.BufferCell[,] NewBufferCellArray(string[] contents, System.ConsoleColor foregro...
ReadKey               Method     System.Management.Automation.Host.KeyInfo ReadKey(System.Management.Automation.Host.ReadKeyOptions options), Syst...
ScrollBufferContents  Method     void ScrollBufferContents(System.Management.Automation.Host.Rectangle source, System.Management.Automation.Host.C...
SetBufferContents     Method     void SetBufferContents(System.Management.Automation.Host.Coordinates origin, System.Management.Automation.Host.Bu...
ToString              Method     string ToString()
BackgroundColor       Property   System.ConsoleColor BackgroundColor {get;set;}
BufferSize            Property   System.Management.Automation.Host.Size BufferSize {get;set;}
CursorPosition        Property   System.Management.Automation.Host.Coordinates CursorPosition {get;set;}
CursorSize            Property   int CursorSize {get;set;}
ForegroundColor       Property   System.ConsoleColor ForegroundColor {get;set;}
KeyAvailable          Property   bool KeyAvailable {get;}
MaxPhysicalWindowSize Property   System.Management.Automation.Host.Size MaxPhysicalWindowSize {get;}
MaxWindowSize         Property   System.Management.Automation.Host.Size MaxWindowSize {get;}
WindowPosition        Property   System.Management.Automation.Host.Coordinates WindowPosition {get;set;}
WindowSize            Property   System.Management.Automation.Host.Size WindowSize {get;set;}
WindowTitle           Property   string WindowTitle {get;set;}
  

BINGO! I see BufferSize and WindowSize, and I know from the GUI Properties page that those are the relevant settings, but just to verify:

$host.UI.RawUI.BufferSize | Get-Member


   TypeName: System.Management.Automation.Host.Size

Name        MemberType Definition
----        ---------- ----------
Equals      Method     bool Equals(System.Object obj)
GetHashCode Method     int GetHashCode()
GetType     Method     type GetType()
ToString    Method     string ToString()
Height      Property   int Height {get;set;}
Width       Property   int Width {get;set;}


$host.UI.RawUI.WindowSize | Get-Member


   TypeName: System.Management.Automation.Host.Size

Name        MemberType Definition
----        ---------- ----------
Equals      Method     bool Equals(System.Object obj)
GetHashCode Method     int GetHashCode()
GetType     Method     type GetType()
ToString    Method     string ToString()
Height      Property   int Height {get;set;}
Width       Property   int Width {get;set;}
  

And there we have it.  Both of them can be retrieved and set.  So, I came up with a little script, Set-myConSize, that lets me restore the window to its default size, or set it to a new size if I'm doing something that needs a bit of window size tweaking.

<#
.Synopsis
Resets the size of the current console window
.Description
Set-myConSize resets the size of the current console window. By default, it
sets the windows to a height of 40 lines, with a 3000 line buffer, and sets the 
the width and width buffer to 120 characters. 
.Example
Set-myConSize
Restores the console window to 120x40
.Example
Set-myConSize -Height 30 -Width 180
Changes the current console to a height of 30 lines and a width of 180 characters. 
.Parameter Height
The number of lines to which to set the current console. The default is 40 lines. 
.Parameter Width
The number of characters to which to set the current console. Default is 120. Also sets the buffer to the same value
.Inputs
[int]
[int]
.Notes
    Author: Charlie Russel
 Copyright: 2017 by Charlie Russel
          : Permission to use is granted but attribution is appreciated
   Initial: 28 April, 2017 (cpr)
   ModHist:
          :
#>
[CmdletBinding()]
Param(
     [Parameter(Mandatory=$False,Position=0)]
     [int]
     $Height = 40,
     [Parameter(Mandatory=$False,Position=1)]
     [int]
     $Width = 120
     )
$Console = $host.ui.rawui
$Buffer  = $Console.BufferSize
$ConSize = $Console.WindowSize

# If the Buffer is wider than the new console setting, first reduce the buffer, then do the resize
If ($Buffer.Width -gt $Width ) {
   $ConSize.Width = $Width
   $Console.WindowSize = $ConSize
}
$Buffer.Width = $Width
$ConSize.Width = $Width
$Buffer.Height = 3000
$Console.BufferSize = $Buffer
$ConSize = $Console.WindowSize
$ConSize.Width = $Width
$ConSize.Height = $Height
$Console.WindowSize = $ConSize
  

One quick comment on this script -- you can't set the BufferSize to smaller than the current WindowSize. With a Height buffer set to 3,000, that's not likely to be a problem, but if you don't want scroll bars on the bottom of your console windows (and you do NOT, trust me!), then you need the console WindowSize.Width to be the same as the BufferSize.Width. So if your reducing, you need to change the WindowSize first, then you can reduce the BufferSize. If you're increasing width, you need to do the buffer first.

 

Finally, I set an alias in my $Profile:

Set-Alias -Name Resize -Value Set-myConSize