IT Admin

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.

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.

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.