Active Directory

Preparing for Windows 10: Move Computers into to Win 10 OUs

One thing that has annoyed me about MDT and SCCM was that there wasn’t a built-in method to move machines into the proper OU.  As such it required creating something from scratch and I opted for something that didn’t require dependencies, like the ActiveDirectory module.

This isn’t the best way and this isn’t the only way – it’s just a way; one of many in fact.

Please note that this is NOT the ideal way to handle any operations that require credentials!  Keeping credentials in a script is bad practice as anyone snooping around could happen upon them and create some problems.  Instead, you should rely on web services to do this and Maik Koster has put together an excellent little care package to help get you started.

Move-ComputerToOU Prerequisites

My script has a few prerequisites:

  • The current AD site
  • A [local] Domain Controller to use (recommended)
  • The current OU of the machine to be moved
  • The new OU to move the machine into

It’s important to know that this script does not rely on the ActiveDirectory module.
One of my [many] quirks is to try to keep everything self-contained where it makes sense to do so, and I liked the idea of not having to rely on some installed component, an EXE and so on.  But to be honest, web services is the way to go for this.

Getting the Current AD Site

Better see this post for that.

Getting a Local Domain Controller

Better see this post for that.

Getting the Current OU

Better see this post for that.

Getting / Setting the New OU

If this is being executed as part of an OSD, yank those details from the MachineObjectOU Task Sequence variable via something like:

Function Get-TaskSequenceEnvironmentVariable
    {
        Param([Parameter(Mandatory=$true,Position=0)]$VarName)
        Try { return (New-Object -COMObject Microsoft.SMS.TSEnvironment).Value($VarName) }
        Catch { throw $_ }
    }
$MachineObjectOU = Get-TaskSequenceEnvironmentVariable MachineObjectOU

Otherwise just feed it the new OU via the parameter

Process Explanation

We first have to find existing object in AD


# This is the machine we want to move
$ADComputer = $env:COMPUTERNAME

# Create Directory Entry with authentication
$de = New-Object System.DirectoryServices.DirectoryEntry($LDAPPath,"$domain\$user",$pass)

# Create Directory Searcher
$ds = New-Object System.DirectoryServices.DirectorySearcher($de)

# Fiter for the machine in question
$ds.Filter = "(&(ObjectCategory=computer)(samaccountname=$ADComputerName$))"

# Optionally, set other search parameters
#$ds.SearchRoot = $de
#$ds.PageSize = 1000
#$ds.Filter = $strFilter
#$ds.SearchScope = "Subtree"
#$colPropList = "distinguishedName", "Name", "samaccountname"
#foreach ($Property in $colPropList) { $ds.PropertiesToLoad.Add($Property) | Out-Null }

# Execute the find operation
$res = $ds.FindAll()

 

Then we bind to the existing computer object


# If there's an existing asset in AD with that sam account name, it should be the first - and only - item in the array.
# So we bind to the existing computer object in AD
$CurrentComputerObject = New-Object System.DirectoryServices.DirectoryEntry($res[0].path,"$domain\$user",$pass)

# Extract the current OU
$CurrentOU = $CurrentComputerObject.Path.Substring(8+$SiteDC.Length+3+$ADComputerName.Length+1)

 

From there setup the destination OU


# This Here we set the new OU location
$DestinationOU = New-Object System.DirectoryServices.DirectoryEntry("LDAP://$SiteDC/$NewOU","$domain\$user",$pass)

 

And finally move the machine from the old/current OU to the new/destination OU


# And now we move the asset to that new OU
$Result = $CurrentComputerObject.PSBase.MoveTo($DestinationOU)

Move-ComputerToProperOU.ps1

So this is a shortened version of the script I’m using in production.  All you need to do is fill in the blanks and test it in your environment.


# There's a separate function to get the local domain controller
$SiteDC = Get-SiteDomainController
# Or you can hard code the local domain controller
$SiteDC = 'localdc.domain.fqdn'

# Build LDAP String
# I //think// there were maybe two cases whre this din't work
$LocalRootDomainNamingContext = ([ADSI]"LDAP://$SiteDC/RootDSE").rootDomainNamingContext
# So I added logic to trap that and pull what I needed from SiteDC
#$LocalRootDomainNamingContext = $SiteDC.Substring($SiteDC.IndexOf('.'))
#$LocalRootDomainNamingContext = ($LocalRootDomainNamingContext.Replace('.',',DC=')).Substring(1)

# Building the LDAP string
$LDAPPath = 'LDAP://' + $SiteDC + "/" + $LocalRootDomainNamingContext

# Set my domian for authentication
$domain = 'mydomain'

# Set the user for authentication
$user = 'myjoindomainaccount'

# Set the password for authentication
$pass = 'my sekret leet-o-1337 creds'

# This is the machine I want to find & move into the proper OU.
$ADComputerName = $env:COMPUTERNAME

# This is the new OU to move the above machine into.
$NewOU = 'OU=Laptops,OU=Win10,OU=Office,OU=CorpWorkstations,DC=domain,DC=fqdn'

# Create Directory Entry with authentication
$de = New-Object System.DirectoryServices.DirectoryEntry($LDAPPath,"$domain\$user",$pass)

# Create Directory Searcher
$ds = New-Object System.DirectoryServices.DirectorySearcher($de)

# Fiter for the machine in question
$ds.Filter = "(&(ObjectCategory=computer)(samaccountname=$ADComputerName$))"

# Optionally, set other search parameters
#$ds.SearchRoot = $de
#$ds.PageSize = 1000
#$ds.Filter = $strFilter
#$ds.SearchScope = "Subtree"
#$colPropList = "distinguishedName", "Name", "samaccountname"
#foreach ($Property in $colPropList) { $ds.PropertiesToLoad.Add($Property) | Out-Null }

# Execute the find operation
$res = $ds.FindAll()

# If there's an existing asset in AD with that sam account name, it should be the first - and only - item in the array.
# So we bind to the existing computer object in AD
$CurrentComputerObject = New-Object System.DirectoryServices.DirectoryEntry($res[0].path,"$domain\$user",$pass)

# Extract the current OU
$CurrentOU = $CurrentComputerObject.Path.Substring(8+$SiteDC.Length+3+$ADComputerName.Length+1)

# This Here we set the new OU location
$DestinationOU = New-Object System.DirectoryServices.DirectoryEntry("LDAP://$SiteDC/$NewOU","$domain\$user",$pass)

# And now we move the asset to that new OU
$Result = $CurrentComputerObject.PSBase.MoveTo($DestinationOU)

 

Happy Upgrading & Good Providence!

Advertisements

Determine Current OU of Machine

I created this function ages ago as part of a larger script that needed to be executed completely unattended.

Please note that this is NOT the ideal way to handle any operations that require credentials.  Keeping credentials in a script is bad practice as anyone snooping around could happen upon them and create some problems.  Instead, you should rely on webservices to do this and Maik Koster has put together an excellent little care package to help you get started.

Get-CurrentOU Prerequisites

My script has a few prerequisites:

  • The name of the computer you’re working with
  • The current AD site
  • A [local] Domain Controller to connect to

This script does not rely on the ActiveDirectory module as I needed this to execute in environments where it wouldn’t be present.
I personally try to keep everything self contained where it makes sense to do so.  It’s one of my [many] quirks.

The Computer Name

Just feed it as a parameter or let it default to the current machine name.

Finding the Current AD Site

Better see this post for that.

Finding a Local Domain Controller

Better see this post for that.

Get-CurrentOU

Function Get-CurrentOU
    {
        Param
            (
                [Parameter(Mandatory=$true)]
                    [string]$ADComputerName = $env:COMPUTERNAME,

                [Parameter(Mandatory=$false)]
                    [string]$Site = $Global:Site,

                [Parameter(Mandatory=$false)]
                    [string]$SiteDC = $Global:SiteDC
            )

        Try
            {
                $Domain = ([ADSI]"LDAP://RootDSE").rootDomainNamingContext
                $_computerType = 'CN=Computer,CN=Schema,CN=Configuration,' + $Domain
                $path = 'LDAP://' + $SiteDC + "/" + $Domain
                $12 = 'YwBvAG4AdABvAHMAbwAuAGMAbwBtAFwAcwBlAHIAdgBpAGMAZQBfAGEAYwBjAG8AdQBuAHQAXwBqAG8AaQBuAF8AZABvAG0AYQBpAG4A'
                $3 = 'bQB5ACAAdQBiAGUAcgAgAHMAZQBrAHIAZQB0ACAAUAA0ADUANQB3ADAAcgBkACAAZgBvAHIAIABhAG4AIAAzADEAMwAzADcAIABoAGEAeAAwAHIAIQAhACEAMQAhAA=='
                $DirectoryEntry = New-Object System.DirectoryServices.DirectoryEntry($path,[System.Text.Encoding]::Unicode.GetString([System.Convert]::FromBase64String($12)),[System.Text.Encoding]::Unicode.GetString([System.Convert]::FromBase64String($3)))
                $DirectorySearcher = New-Object System.DirectoryServices.DirectorySearcher($DirectoryEntry)
                $DirectorySearcher.Filter = "(&(ObjectCategory=computer)(samaccountname=$ADComputerName$))"
                $SearchResults = $DirectorySearcher.FindAll()

                if($SearchResults.count -gt 0) { return (New-Object System.DirectoryServices.DirectoryEntry($SearchResults[0].Path,[System.Text.Encoding]::Unicode.GetString([System.Convert]::FromBase64String($12)),[System.Text.Encoding]::Unicode.GetString([System.Convert]::FromBase64String($3)))).Path.Substring(8+$siteDC.Length+3+$ADComputerName.length+1) }
                Else { Write-Host "ERROR: Computer object not found in AD: [$ADComputerName]"; return $false }
            }
        Catch { return $_ }
    }

 

This has worked well for me, but you’re welcome to use a different filter instead, such as

$DirectorySearcher.Filter = "(Name=$ADComputerName)"

 

As I mentioned above, you should really explore webservices instead of hardcoding passwords in scripts but this will work in a pinch until you can get that setup.

 

Good Providence!

Finding a Local Domain Controller

I created this function ages ago as part of a larger script that needed to be executed completely unattended.

Since I started down the System Engineer / Desktop Engineer / Application Packager path a handful of years ago, I’ve seen some interesting behaviors.  For example, when joining a domain, a machine might reach out to a domain controller overseas versus one at the local site or even in the same continent.  And because things were working, those types of anomalies were never really looked into.

This only ever really came up when there were OU structure changes.  For instance, when going from XP to Windows 7, or after completing an AD remediation project.  As machines were being moved around, we noticed the Domain Controller used varied and the time it took to replicate those changes varied from:

  • instantaneously – suggesting the change was made on a local DC OR a DC with change notification enabled
  • upwards of an hour – suggesting a remote DC (and usually verified by logs or checking manually) or on a DC where change notification was not enabled.

So I decided to write a small function for my scripts that would ensure they were using a local Domain Controller every time.

There are a handful of ways to get a domain controller

  • LOGONSERVER
  • dsquery
  • Get-ADDomainController
  • Other third-party utilities like adfind, admod

But either they were not always reliable (as was the case with LOGONSERVER which would frequently point to a remote DC) or they required supporting files (as is the case with dsquery and Get-ADDomainController).  Nothing wrong with creating packages, adding these utilities to %ScriptRoot% or even calling files on a network share.
I simply preferred something self-contained.

Find Domain Controllers via NSLOOKUP

I started exploring by using nslookup to locate for service record (SRV) resource records for domain controllers and I landed on this:

nslookup -type=srv _kerberos._tcp.dc._msdcs.$env:USERDNSDOMAIN

Problem was it returned every domain controller in the organization.  Fortunately  you can specify an AD site name to narrow it down to that specific AD Site:

nslookup -type=srv _kerberos._tcp.$ADSiteName._sites.$env:USERDNSDOMAIN

Bingo!  This gave me exactly what I was hoping to find and the DC’s it returns are in a different order every time which helps to round-robin things a bit.

From there it was all downhill:

  1. Put AD Sites in the script
  2. Figure out which Site the machine is in by it’s gateway IP
  3. Use nslookup to query for SRV records in that site
  4. Pull the data accordingly.

Yes I know – hard coding AD Sites and gateway IP’s is probably frowned upon because

What if it changes?

I don’t know about your organisation but in my [limited] experience the rationale was that AD Sites and gateway IP’s typically don’t change often enough to warrant that level of concern, so it didn’t deter me from using this method.  But I do acknowledge that it is something to remember especially during expansion.

Also, I already had all of our gateway IP’s accessible in a bootstrap.ini due to our existing MDT infrastructure making this approach much simpler workwise.

Get-SiteDomainController

And here’s where I ended up.  The most useful part – to me anyway – is that it works in Windows (real Windows) at any stage of the imaging process and doesn’t require anything extra to work.  The only gotcha is that it does not work in WinPE because nslookup isn’t built-in and I don’t know why after all these years it still isn’t built-in.

Function Get-SiteDomainController
    {
        Param
            (
                [Parameter(Mandatory=$true)]
                    [string]$Site,

                [Parameter(Mandatory=$true)]
                    [string]$Domain
            )
        Try { return (@(nslookup -type=srv _kerberos._tcp.$Site._sites.$Domain | where {$_ -like "*hostname*"}))[0].SubString(20) }
        Catch { return $_ }
    }

Hindsight

I’ve been using the above method for determining the local DC for years and its been super reliable for me.  It wasn’t until recently that I learned that the query I was using would not always return a domain controller since the query simply locates servers that are running the Kerberos KDC service for that domain.  Whoops.

Instead, I should have used this query which will always only return domain controllers:

nslookup -type=srv _kerberos._tcp.$ADSiteName._sites.dc._msdcs.$env:USERDNSDOMAIN

 

Honorable Mention: NLTEST

I would have been able to accomplish the same task with nltest via

nltest /dclist:$env:USERDNSDOMAIN

And then continuing with the gateway IP to AD Site translation to pull out just the entries I need.

nltest /dclist:$env:USERDNSDOMAIN | find /i ": $ADSiteName"

One interesting thing to note about nltest is that it seems to always return the servers in the same order.  This could be good for consistency’s sake, but it could also be a single point of failure if that first server has a problem.

The nslookup query on the other hand returns results in a different order each time it’s executed.  Again, this could also be bad, making it difficult to spot an issue with a Domain Controller, but it could be good in that it doesn’t halt whatever process is using the output of that function.

¯\_(ツ)_/¯

Anyway, since the nslookup script has been in production for some time now and works just fine, I’m not going to change it.  However, any future scripts I create that need similar functionality will likely use the updated nslookup query method.

 

Good Providence!

Determine AD Site

I created this function ages ago as part of a larger script that needed to be executed completely unattended.

For a variety of reasons, I needed a reliable way to determine which AD Site a particular machine was in.  Over the years I’ve seen some really odd behaviors that – technically – shouldn’t happen.  But as with many organizations (one hopes anyway…) things don’t always go as planned so you need that backup plan.

Get-Site Prerequisites

My script has a few prerequisites:

I personally try to keep everything self contained where it makes sense to do so.  It’s one of my [many(?)] quirks.

Finding the Current Default Gateway IP

Better see this post for that.

Get-Site Explained

In our environment, the machine names contain the office location which makes it easy to figure out where the machine is physically.  However, not every machine is named ‘correctly’ (read: follows this naming convention) as they were either renamed by their owners or are running an image prior to this new naming convention.  This is why I’m leveraging a hash table array of IP’s as a backup.

It seemed tedious at first, but since I already had a list of IP’s in the bootstrap.ini so it made this a little easier.  Also, Site IP’s don’t change often which means this should be a reliable method for years to come with only the occasional update.

Function Get-Site
    {
        Param([Parameter(Mandatory=$true)][string]$DefaultGatewayIP)

        Switch($env:COMPUTERNAME.Substring(0,2))
            {
                { 'HQ', 'TX', 'FL' -contains $_ } { $Site = $_ }
                Default
                    {
                        $htOfficeGateways = @{
                            "Headquarters"  = "192.168.0.1","192.168.10.1";
                            "Office1"       = "192.168.1.1","192.168.11.1";
                            "Office2"       = "192.168.2.1","192.168.12.1";
                        }

                        $DefaultGatewayIP = '192.168.0.1'
                        #$DefaultGatewayIP = '192.168.1.1'
                        #$DefaultGatewayIP = '192.168.2.1'

                        Foreach($Office in ($htOfficeGateways.GetEnumerator() | Where-Object { $_.Value -eq $DefaultGatewayIP } ))
                            {
                                Switch($($Office.Name))
                                    {
                                        "Headquarters" { $Site = 'HQ' }
                                        "Office1"      { $Site = 'TX' }
                                        "Office2"      { $Site = 'FL' }
                                    }
                            }
                    }
            }
        return $Site
    }

 

I didn’t add a check prior to returning $Site to confirm it was actually set but that can be handled outside of the function as well.  Otherwise you could do $Site = 'DDOJSIOC' at the top and then check for that later since that should never be a valid site … unless you’re Billy Rosewood.

 

Preparing for Windows 10: OU Structure

Our team has been playing around with Windows 10 in our corporate environment for months now and one thing that was apparent from day one was that some something was breaking the Task Bar rendering the Start Menu & Cortana completely useless.  (i.e.: You click on it and it doesn’t work.)  We were certain it was a GPO, but we didn’t know which GPO or more specifically which setting within was creating the problem.  We started digging into it a little bit but it wasn’t long before we realized it was almost a moot point because:

  1. Some GPOs were specific to Windows 7 & wouldn’t apply to Windows 10
  2. Some GPOs were specific to Office 2010 & wouldn’t apply to Office 2016
  3. Others were simply no longer needed like our IEM policy for IE9.
  4. Finally, and perhaps most importantly: we wanted to take this opportunity to start from scratch: Re-validate all the GPO’s, the settings within & consolidating where possible.

And in order to do all that, we needed to re-evaluate our OU structure.

When it comes to OU structures, depending on the audience, it makes for very “exciting” conversation as some OU structures have a fairly simple layout:

  • Corp Computers
    • Hardware Type

While others look like a rabbit warren:

  • Continent
    • Country
      • Region (or equivalent)
        • State  (or equivalent)
          • City
            • Role/Practice Group/Functional Group
              • Hardware Type
                • Special Configurations
                  • Other
Note: I’m not knocking the latter or suggesting the former is better.  Really just depends on the specific needs of the organization.
No one size fits all; but some sizes may fit many.

When we did an image refresh some time ago – prior to our SCCM implementation – we took a ‘middle of the road’ approach with the main branches being location and hardware type. Later during during our SCCM implementation last summer, knowing that Windows 10 was on the horizon we revised the layout adding the OS branch in order to properly support both environments.

Bulk Creating OU’s

Once you know your layout, for example:

  • CorpSystems
    • Location
      • OS
        • Hardware Type

It’s time to create them all.  I wasn’t about to do this by hand so I hunted around for a way to recursively create OUs.  The solution comes courtesy of a serverfault question, and I just tweaked it to work for us.

Function Create-NewOU
    {
        # http://serverfault.com/questions/624279/how-can-i-create-organizational-units-recursively-on-powershell
        Param
            (
                [parameter(Position = 0,Mandatory = $true,HelpMessage = "Name of the new OU (e.g.: Win10)")]
                [alias('OUName')]
                    [string]$NewOU,

                [parameter(Position = 1,Mandatory = $false,HelpMessage = "Location of the new OU (e.g.: OU=IT,DC=Contoso,DC=com)")]
                [alias("OUPath")]
                    [string]$Path
            )

        # For testing purposes
        #$NewOU = 'Win10'
        #$NewOU = 'OU=SpecOps,OU=IT,OU=Laptops,OU=Win10,OU=Miami,OU=Florida,OU=Eastern,OU=NorthAmerica'
        #$Path = 'DC=it,DC=contoso,DC=com'
        #$Path = (Get-ADRootDSE).defaultNamingContext

        Try
            {
                if($Path) { if($NewOU.Substring(0,3) -eq 'OU=') { $NewOU = $NewOU + ',' + $Path } else { $NewOU = 'OU=' + $NewOU + ',' + $Path } }
                else { if($NewOU.Substring(0,3) -eq 'OU=') { $NewOU = $NewOU + ',' + (Get-ADRootDSE).defaultNamingContext } else { $NewOU = 'OU=' + $NewOU + ',' + (Get-ADRootDSE).defaultNamingContext } }

                # A regex to split the distinguishedname (DN), taking escaped commas into account
                $DNRegex = '(?<![\\]),'

                # We'll need to traverse the path, level by level, let's figure out the number of possible levels
                $Depth = ($NewOU -split $DNRegex).Count

                # Step through each possible parent OU
                for($i = 1;$i -le $Depth;$i++)
                    {
                        $NextOU = ($NewOU -split $DNRegex,$i)[-1]
                        if(($NextOU.Substring(0,3) -eq "OU=") -and ([ADSI]::Exists("LDAP://$NextOU") -eq $false)) { [String[]]$MissingOUs += $NextOU }
                    }

                # Reverse the order of missing OUs, we want to create the top-most needed level first
                [array]::Reverse($MissingOUs)

                # Now create the missing part of the tree, including the desired OU
                foreach($OU in $MissingOUs)
                    {
                        $NewOUName = (($OU -split $DNRegex,2)[0] -split "=")[1]
                        $NewOUPath = ($OU -split $DNRegex,2)[1]

                        write-host "Creating [$NewOUName] in [$NewOUPath]"
                        New-ADOrganizationalUnit -Name $newOUName -Path $newOUPath -Verbose
                    }
            }
        Catch { return $_ }
        return $true
    }

 

With that done, it was on to the next bit which was creating and linking GPOs.

 

Good Providence!

Remotely Checking for Local Administrators

I got a call from a very good friend of mine recently who was tasked with finding out which machines had users designated as local admins.  I told him I didn’t have anything in my bag-o-tricks for that but off the top of my head it would require:

  • Querying AD for a list of machines
  • Checking to see if the machine was online
  • Connecting to the asset and checking to see if users were added to the local admins group (e.g.: net localgroup administrators)

Thinking this might prove valuable here, I figured I’d try my hand at it.  I began my AD query targeting a specific OU and while contemplating how best to proceed, it occurred to me: Surely someone has already done this.
And sure enough, the Intarwebs hath provided abundantly.

I decided to go with the net localgroup administrators approach from PowerShell.org not because its better than ADSI or some of the other methods out there, but only because that’s where my head was at at the time.

Please do note that my approach is not nearly as elegant (or complete) as Boe Prox’s solution on the TechNet Script Centre and likely where I would go if I needed to this in our environment.

I opted to feed the script an array of OU’s versus doing something like

$PCs = Get-ADComputer -Filter * -Properties Name,DistinguishedName | ? { $_.DistinguishedName -like "*OU=LocalAdmins,*" } | select Name,DistinguishedName

I don’t know that I can support one method over the other but they both work fine.

Here’s where I ended up:

[string[]]$cstm_LocalGroupName = 'Administrators'
$cstm_arrSearchBase = @('OU=OU,OU=Test,OU=Some,DC=domain,DC=tld')
Try
    {
        $cstmRslt_arrLocalMembers = @()
        Foreach($cstm_SearchBase in $cstm_arrSearchBase) { $cstm_Computer += Get-ADComputer -SearchBase $cstm_SearchBase -SearchScope Subtree -Filter * -Properties Name,DistinguishedName -ErrorAction Stop | select Name,DistinguishedName }

        Foreach($cstm_Machine in $cstm_Computer)
            {
                if($cstm_ADRetrieved -ne $true) { $cstm_Machine = [pscustomobject]@{$cstm_Machine = $cstm_Machine; Name = $cstm_Machine; DistinguishedName = 'LOCAL'} }
                if(Test-ComputerOnline $cstm_Machine.Name)
                    {
                        Try
                            {
                                Foreach($cstm_LocalGroup in $cstm_LocalGroupName) { $cstmRslt_arrLocalMembers += Invoke-Command -ComputerName $cstm_Machine.Name -ScriptBlock {[pscustomobject]@{Computername = $env:COMPUTERNAME;DistinguishedName = $args[1];Group = $args[0];Members = $(net localgroup $args[0] | where { $_ -AND $_ -notmatch "command completed successfully" } | select -skip 4)}} -ArgumentList $cstm_LocalGroup,$($cstm_Machine.DistinguishedName) -HideComputerName -ErrorAction Stop | Select * -ExcludeProperty RunspaceID }
                                write-host "Online`t$($cstm_Machine.Name)`tSUCCESS" -ForegroundColor Green
                            }
                        Catch
                            {
                                $cstmRslt_arrLocalMembers += [pscustomobject]@{Computername = $($cstm_Machine.Name);DistinguishedName = $($cstm_Machine.DistinguishedName);Group = $cstm_LocalGroup;Members = "ERROR: $($_.exception.GetType().FullName)"}
                                write-host "Online`t$($cstm_Machine.Name)`tERROR" -ForegroundColor Red
                            }
                    }
                Else
                    {
                        $cstmRslt_arrLocalMembers += [pscustomobject]@{Computername = $($cstm_Machine.Name);DistinguishedName = "OFFLINE";Group = $cstm_LocalGroup;Members = "OFFLINE"}
                        write-host "Offline`t$($cstm_Machine.Name)`tOFFLINE" -ForegroundColor Gray
                    }
            }
    }
Catch { $_ }

# Raw Results
$cstmRslt_arrLocalMembers

After targeting a couple of OU’s and touching a few hundred machines, I got some really unusual results.
Instead of seeing what I expected to see, I saw some odd entries within

Computername      : COMPUTER001
DistinguishedName : CN=COMPUTER001,OU=LocalAdmins,OU=Queens,OU=NewYork,DC=domain,DC=tld
Group             : Administrators
Members           : {Administrator, DOMAIN\IT-Engineers, DOMAIN\ServiceAccounts}

Computername      : COMPUTER002
DistinguishedName : CN=COMPUTER002,OU=LocalAdmins,OU=Houston,OU=Texas,DC=domain,DC=tld
Group             : Administrators
Members           : {Administrator, DOMAIN\IT-Engineers, DOMAIN\User002}

IsReadOnly     : False
IsFixedSize    : False
IsSynchronized : False
Keys           : {Group, Computername, DistinguishedName, Members}
Values         : {Administrators, COMPUTER003, CN=COMPUTER003,OU=LocalAdmins,OU=Fresno,OU=California,DC=domain,DC=tld, Administrator DOMAIN\IT-Engineers DOMAIN\User003 DOMAIN\ServiceAccounts}
SyncRoot       : System.Object
Count          : 4

IsReadOnly     : False
IsFixedSize    : False
IsSynchronized : False
Keys           : {Group, Computername, DistinguishedName, Members}
Values         : {Administrators, COMPUTER004, CN=COMPUTER004,OU=LocalAdmins,OU=Fresno,OU=California,DC=domain,DC=tld, Administrator DOMAIN\IT-Engineers DOMAIN\ServiceAccounts}
SyncRoot       : System.Object
Count          : 4

Computername      : COMPUTER005
DistinguishedName : OFFLINE
Group             : Administrators
Members           : OFFLINE

Turns out those machines returning those objects are still running PowerShell 2.0!
So the work around:

# This works for PowerShell 2.0
Foreach($cstm_LocalGroup in $cstm_LocalGroupName) { $cstmRslt_arrLocalMembers += Invoke-Command -ComputerName $cstm_Machine.Name -ScriptBlock {New-Object -TypeName PSObject -Property @{'Computername' = $env:COMPUTERNAME;'DistinguishedName' = $args[1];'Group' = $args[0];'Members' = $(net localgroup $args[0] | where { $_ -AND $_ -notmatch "command completed successfully" } | select -skip 4)}} -ArgumentList $cstm_LocalGroup,$($cstm_Machine.DistinguishedName) -HideComputerName -ErrorAction Stop | Select * -ExcludeProperty RunspaceID }

# This works for PowerShell 1.0
Foreach($cstm_LocalGroup in $cstm_LocalGroupName) { $cstmRslt_arrLocalMembers += Invoke-Command -ComputerName $cstm_Machine.Name -ScriptBlock {$tmpPSO = New-Object -TypeName PSObject;$tmpPSO | Add-Member -MemberType NoteProperty -Name ComputerName -Value $env:COMPUTERNAME;$tmpPSO | Add-Member -MemberType NoteProperty -Name DistinguishedName -Value $args[1];$tmpPSO | Add-Member -MemberType NoteProperty -Name Group -Value $args[0];$tmpPSO | Add-Member -MemberType NoteProperty -Name Members -Value $(net localgroup $args[0] | where { $_ -AND $_ -notmatch "command completed successfully" } | select -skip 4);$tmpPSO} -ArgumentList $cstm_LocalGroup,$($cstm_Machine.DistinguishedName) -HideComputerName -ErrorAction Stop | Select * -ExcludeProperty RunspaceID }

Seeing that a chain is only as strong as its weakest link, I’m using the PowerShell 2.0 line above to work in our environment.  Also, to tidy up your PowerShell ISE or shell, you can use the following to clean up the variables:

Get-Variable -Name cstm_* | Remove-Variable | Out-Null
Get-Variable -Name cstmRslt_* | Remove-Variable | Out-Null

I hope my friend finds this useful, if anything from a PowerShell learning perspective but I’m going to steer him towards the other, more complete, solutions found elsewhere.

 

Good Providence to ya Buddy!