OU

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!

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!