IT Admin

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:

I ended up with about 15% of the computer accounts refusing to be removed with Remove-ADComputer. So I checked the manpages 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:

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.

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:

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
}
}

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:

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 propertyToString method of the file. So, you might have something like this:

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.

(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)

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:

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:

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.

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.