Setting the Computer Description During Windows Autopilot

I’ve been getting to grips with Windows Autopilot recently and, having a long history working with SCCM, I’ve found it hard not to compare it with the power of traditional OSD using a task sequence. In fact, one of my goals was to basically try to reproduce what I’m doing in OSD with Autopilot in order to end up with the same result – and it’s been a challenge.

I like the general concept of Autopilot and don’t get me wrong – it’s getting better all the time – but it still has its shortcomings that require a bit of creativity to work around. One of the things I do during OSD is to set the computer description in AD. That’s fairly easy to do in a task sequence; you can just script it and run the step using credentials that have the permission to make that change.

In Autopilot however (hybrid AAD join scenario), although you can run Powershell scripts too, they will only run in SYSTEM context during the Autopilot process. That means you either need to give computer accounts the permission to change their own properties in AD, or you have to find a way to run that code using alternate credentials. You can run scripts in the context of the logged-on user, but I don’t want to do that – in fact I disable the user ESP – I want to use a specific account that has those permissions.

You could use SCCM to do it post-deployment if you are co-managing the device, but ideally I want everything to be native to Autopilot where possible, and move away from the hybrid mentality of do what you can with Intune, and use SCCM for the rest.

It is possible to execute code in another user context from SYSTEM context, but when making changes in AD the DirectoryEntry operation kept erroring with “An operations error occurred”. After researching, I realized it is due to AD not accepting the authentication token as it’s being passed a second time and not directly. I tried creating a separate powershell process, a background job, a runspace with specific credentials – nothing would play ball. Anyway, I found a way to get around that by using the AccountManagement .Net class, which allows you to create a context using specific credentials.

In this example, I’m setting the computer description based on the model and serial number of the device. You need to provide the username and password for the account you will perform the AD operation with. I’ve put the password in clear text in this example, but in the real world we store the credentials in an Azure Keyvault and load them in dynamically at runtime with some POSH code to avoid storing them in the script. I hope in the future we will be able to run Powershell scripts with Intune in a specific user context, as you can with steps in an SCCM task sequence.

# Set credentials
$ADAccount = "mydomain\myADaccount"
$ADPassword = "Pa$$w0rd"

# Set initial description
$Model = Get-WMIObject -Class Win32_ComputerSystem -Property Model -ErrorAction Stop| Select -ExpandProperty Model
$SerialNumber = Get-WMIObject -Class Win32_BIOS -Property SerialNumber -ErrorAction Stop | Select -ExpandProperty SerialNumber
$Description = "$Model - $SerialNumber"

# Set some type accelerators
Add-Type -AssemblyName System.DirectoryServices.AccountManagement -ErrorAction Stop
$Accelerators = [PowerShell].Assembly.GetType("System.Management.Automation.TypeAccelerators")
$Accelerators::Add("PrincipalContext",[System.DirectoryServices.AccountManagement.PrincipalContext])
$Accelerators::Add("ContextType",[System.DirectoryServices.AccountManagement.ContextType])
$Accelerators::Add("Principal",[System.DirectoryServices.AccountManagement.ComputerPrincipal])
$Accelerators::Add("IdentityType",[System.DirectoryServices.AccountManagement.IdentityType])

# Connect to AD and set the computer description
$Domain = [System.DirectoryServices.ActiveDirectory.Domain]::GetCurrentDomain()
$PrincipalContext = [PrincipalContext]::new([ContextType]::Domain,$Domain,$ADAccount,$ADPassword)
$Account = [Principal]::FindByIdentity($PrincipalContext,[IdentityType]::Name,$env:COMPUTERNAME)
$LDAPObject = $Account.GetUnderlyingObject()
If ($LDAPObject.Properties["description"][0])
{
    $LDAPObject.Properties["description"][0] = $Description
}
Else
{
    [void]$LDAPObject.Properties["description"].Add($Description)
}
$LDAPObject.CommitChanges()
$Account.Dispose()

Monitoring Changes to Active Directory Sites and Subnets with PowerShell

If you work with SCCM and you use AD Forest Discovery to automatically create boundaries from AD Sites or Subnets, you know how important it is for AD to stay up to date with the current information. And when something is changed in Sites or Subnets, you need to be made aware of it so you can reflect the change in your SCCM boundaries and boundary groups. Unfortunately, communication between IT teams is not always what it should be, so I wrote this script to run as a scheduled task and keep an eye on any changes made in AD Sites and IP subnets.

The script works by retrieving the current site and subnet information and exporting it to cache files. The next time the script runs, it will compare the current information with the information in the cached files, and if anything has changed, a report will be sent by email detailing the changes.

It’s one way of ensuring you’re keeping SCCM in sync with your AD!

Finding the ‘LastLogon’ Date from all Domain Controllers with PowerShell

In an Active Directory environment, probably the most reliable way to query the last logon time of a computer is to use the Last-Logon attribute.  The Last-Logon-Timestamp attribute could be used, but this will not likely be up-to-date due to the replication lag.  If you are using PowerShell, the LastLogonDate attribute can also be used, however this is also a replicated attribute which suffers from the same delay and potential inaccuracy.

The Last-Logon attribute is not replicated, however, it is only stored on the DC that the computer authenticated against.  If you have multiple domain controllers, you will get multiple values for this attribute depending on which DC the computer has authenticated with and when.

To find the Last-Logon date from the DC that the computer has most recently authenticated with, you need to query all domain controllers for this attribute, then select the most recent.

Following is a PowerShell script I wrote that will read a list of domain controllers from an Active Directory OU, query each one, then return the most recent Last-Logon value.  It uses parallel processing to return the result more quickly than processing each DC in turn, which is useful in a multi-DC environment.

To use the script, simply pass the computer name and optionally the AD OU containing your domain controllers, to the function.  You can hard-code the ‘DomainControllersOU’ parameter in the script if you prefer, so you don’t need to call it.  You need the Active Directory module installed to use this.

Example


Get-ADLastLogon -ComputerName PC001 -DomainControllersOU "OU=Domain Controllers,DC=contoso,DC=com"

capture

Get-ADLastLogon


function Get-ADLastLogon {

    [CmdletBinding()]
    Param
    (
        [Parameter(Mandatory=$false,
                   ValueFromPipelineByPropertyName=$true,
                   Position=0)]
        [string]$DomainControllersOU = "OU=Domain Controllers,DC=contoso,DC=com",

        [string]
        $ComputerName
    )

    # Multithreading function
    function Invoke-InParallel {
        [CmdletBinding()]
        param(
            [parameter(Mandatory = $True,ValueFromPipeline=$true,Position = 0)]
            $InputObject,
            [parameter(Mandatory = $True)]
            [ScriptBlock]$Scriptblock,
            [string]$ComputerName,
            [parameter()]
            $ThrottleLimit = 32,
            [parameter()]
            [switch]$ShowProgress
        )

        Begin
        {
            # Create runspacepool, add code and parameters and invoke Powershell
                [void][runspacefactory]::CreateRunspacePool()
                $SessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault()
                $script:RunspacePool = [runspacefactory]::CreateRunspacePool(1,$ThrottleLimit,$SessionState,$host)
                $RunspacePool.Open()

            # Function to start a runspace job
            function Start-RSJob
            {
                param(
                    [parameter(Mandatory = $True,Position = 0)]
                    [ScriptBlock]$Code,
                    [parameter()]
                    $Arguments
                )
                if ($RunspacePool.GetAvailableRunspaces() -eq 0)
                    {
                        do {}
                        Until ($RunspacePool.GetAvailableRunspaces() -ge 1)
                    }

                $PowerShell = [powershell]::Create()
                $PowerShell.runspacepool = $RunspacePool
                [void]$PowerShell.AddScript($Code)
                foreach ($Argument in $Arguments)
                {
                    [void]$PowerShell.AddArgument($Argument)
                }
                $job = $PowerShell.BeginInvoke()

                # Add the job and PS instance to the arraylist
                $temp = '' | Select-Object -Property PowerShell, Job
                $temp.PowerShell = $PowerShell
                $temp.Job = $job
                [void]$Runspaces.Add($temp)  

            }

        # Start a 'timer'
        $Start = Get-Date

        # Define an arraylist to add the runspaces to
        $script:Runspaces = New-Object -TypeName System.Collections.ArrayList
        }

        Process
        {
            # Start an RS job for each computer
            $InputObject | ForEach-Object -Process {
                Start-RSJob -Code $Scriptblock -Arguments $_, $ComputerName
            }
        }

        End
        {
            # Wait for each script to complete
            foreach ($item in $Runspaces)
            {
                do
                {
                }
                until ($item.Job.IsCompleted -eq 'True')
            }

            # Grab the output from each script, and dispose the runspaces
            $return = $Runspaces | ForEach-Object -Process {
                $_.powershell.EndInvoke($_.Job)
                $_.PowerShell.Dispose()
            }
            $Runspaces.clear()
            [void]$RunspacePool.Close()
            [void]$RunspacePool.Dispose

            # Stop the 'timer'
            $End = Get-Date
            $TimeTaken = [math]::Round(($End - $Start).TotalSeconds,2)

            # Return the results
            $return
        }
    }

    # Get list of domain controllers from OU
    try {
    Import-Module ActiveDirectory | out-null
    $DomainControllers = Get-ADComputer -Filter * -SearchBase $DomainControllersOU -Properties Name -ErrorAction Stop | Select -ExpandProperty Name | Sort
    }
    catch {}

    # Define Code to run in each parallel runspace
    $Code = {
        param($DC,$ComputerName)
        Import-Module ActiveDirectory | out-null
        $Date = [datetime]::FromFileTime((Get-ADComputer -Identity $ComputerName -Server $DC -Properties LastLogon | select -ExpandProperty LastLogon))
        $Result = '' | Select 'Domain Controller','Last Logon'
        $Result.'Domain Controller' = $DC
        $Result.'Last Logon' = $Date
        Return $Result
    }

    # Run code in parallel
    $Result = Invoke-InParallel -InputObject $DomainControllers -Scriptblock $Code -ComputerName $ComputerName -ThrottleLimit 64

    # Return most recent logon date
    return $Result | sort 'Last Logon' -Descending | select -First 1
}

 

Creating a Simple Class Library for PowerShell 5

PowerShell 5 brings some nice capability to PowerShell, including support for the creation of custom classes.  Very simply, a class can be used to define a custom type, and allow you to create an object of that custom type.  This can be useful for example, if you want to create an object that has specific properties that you define, as well as some methods, or portions of code that do something specific to your need and relevant to that object.

Class libraries are used in programming so that instead of having to create code to do low-level, fundamental or often-repeated tasks, a collection of classes are provided for you so you can simply call a class and its properties and methods when you need it.

You can call .Net classes in Powershell using a type accelerator. For example, this code gives me the value of PI using the System.Math class. It is defined in the class as a static property.


[math]::PI

Since the value of PI is returned with 14 decimal places, I might want to reduce that say to 4 decimal places. So I can call a static method on the System.Math class to round the number of decimal places to 4:


[math]::Round([math]::PI,4)

Create a Custom Class

I won’t go into detail here about how to create a custom class (Stephane van Gulick gives a nice introduction), but below is a simple example of a class I created in PowerShell, which allows me to create an object representing a user account in Active Directory.  I have defined exactly what properties I want to have returned so I don’t have to pass a list of properties to the Get-ADUser cmdlet every time I run it.


class ADUser
{
    [string]$Username
    [string]$Enabled
    [string]$Displayname
    [string]$Title
    [string]$Department
    [string]$EmailAddress
    [string]$CanonicalName
    [string]$City
    [string]$co
    [string]$OfficePhone
    [string]$MobilePhone
    [string]$ipPhone
    [array]$MemberOf
    [array]$DirectReports
    [string]$homeMDB
    [string]$Created
    [string]$Modified
    [string]$LastBadPasswordAttempt
    [string]$PasswordLastSet

   # Constructor
   ADUser ([string] $Username)
   {
        $this.Username = $Username
        $Properties = @(
            'CanonicalName',
            'City',
            'co',
            'Created',
            'Department',
            'DirectReports',
            'Displayname',
            'EmailAddress',
            'Enabled',
            'homeMDB',
            'ipPhone',
            'LastBadPasswordAttempt',
            'MemberOf',
            'MobilePhone',
            'Modified',
            'OfficePhone',
            'PasswordLastSet',
            'Title'
        )
       try
        {
            $P = Get-ADUser $Username -Properties $Properties | Select $Properties
            $Properties | foreach {
                $this.$_ = $P.$_
                }
        }
       Catch
        {
            $_; continue
        }
   }
}

I can create a custom object from this class in a couple of ways:


# create a new object
$me = New-Object -TypeName ADUser -ArgumentList tjones

# Instantiate using the 'new' static method
$me = [ADUser]::new('tjones')

# Simply set the variable type
[ADUSer]$me = 'tjones'

In each case, I need to pass a username, or actually any of the properties in this list, as this is required by the class constructor and the Get-ADUser cmdlet:
— A distinguished name
— A GUID (objectGUID)
— A security identifier (objectSid)
— A SAM account name (sAMAccountName)

When I create the object, the code runs and gets the info from AD.  When I call the variable I can see that the properties I have defined are populated:

captures

I can view the list of properties on the object using the dot operator…

capture

…or view an individual property…

capture

…or even find the value of a property without first storing it to a variable:

capture

Scope

This custom class is quite handy to quickly find out information about a user, and I want to be able to call this class any time I need it.  Trouble is, in the current implementation of this, a custom class is limited in scope and I can only use this class in the same context I’ve created it in.  Or to say it another way, I can’t change the class scope in the same way you could a variable for example.  You can of course change the scope of the object you create from the class, for example:


New-Variable -Name me -Value ([ADuser]::new('tjones')) -Scope Script

But once my current session or script is closed, this custom class is no longer available to me.  To use it again, I must add the class code and run it in each session or script so it is available in the current context.  This can make your scripts fatter than they need to be.

So here’s an idea:

Create a Class Library (of sorts)

In C# for example, you can create a library of classes as a dll file, but for PowerShell I can’t do that.

So why not simply create a folder of class code saved into text files, then read and run the code in your session?

Here’s one way to do that.  In my PowerShell $Profile directory, I have two folders “Modules” and “Scripts”.  I add a folder called “Classes”.

capture

I create the code for my custom class using PowerShell ISE, then save it as a text file to the Classes folder.  Any time I create a new custom class that I want to reuse later, I save it as a text file in this location.

capture

Now I can simply read the contents of a class file and run it in my current session with a one-liner to give me instant access to this custom class.


Invoke-Expression $([System.IO.File]::ReadAllText('C:\Users\tjones\Documents\WindowsPowerShell\Classes\ADUser.txt'))

I can also load in all the custom classes in the Classes directory into my session like this:

 

# Import Custom Classes
Get-ChildItem '$env:USERPROFILE\Documents\WindowsPowerShell\Classes\' |
    Select -ExpandProperty FullName |
        foreach {
            $class = [System.IO.File]::ReadAllText($_)
            Invoke-Expression $Class
        }

Even better, I can add this code into my PowerShell profile scripts (the Microsoft.Powershell_profile.ps1 and Microsoft.PowershellISE_profile.ps1) and all these classes will be available for me to use every time I start a new PowerShell session 🙂

Get Creative

This capability for custom classes in PowerShell opens a door of creativity for IT administrators and developers alike.  You can create simple classes to use in a script for example, or create reusable classes in a kind of class library as we have discussed, and you can add custom code that is relevant to the custom object so you can simply call a method instead of writing out the code each time.

How would custom classes be useful to you?  What kinds of objects and methods would be useful in your day to day work?

I do a lot of troubleshooting on remote computers, so I wrote a custom class that gets lots of useful information from a remote computer, or performs certain tasks on the remote computer.

In the example below, I’ve created a custom computer object representing “PC001”. Creating the object runs a quick “Test-Connection” against the machine, so I call the “Online” property to see if it is.  Then I get the current user using the “GetCurrentUser()” method, then I output a list of methods in the object.  These methods allow me to get information from Active Directory for the computer, get hardware information, get installed hotfixes or software, get local administrators and other useful things.

I can also start / stop services, or kill running processes.

capture

The custom type also contains a couple of static methods that I can use without creating an object and assigning it to a variable.  “GetComputerFromUser()” checks my Configuration Manager database to find the computer/s that a user was last logged into, and “GetSerialTag()” gets the serial number (or asset tag) for the remote computer.

capture

Cool stuff 🙂

Ping all the Computers in an AD OU

Here is a clean and simple PowerShell script to ping all the computer accounts in an Active Directory OU, and return the Name, IP Address and any errors into a csv file.

# Enter CSV file location
$csv = "C:\Script_Files\OUPing.csv"
# Add the target OU in the SearchBase parameter
$Computers = Get-ADComputer -Filter * -SearchBase "OU=Servers,DC=mydomain,DC=com" | Select Name | Sort-Object Name
$Computers = $Computers.Name
$Headers = "ComputerName,IP Address"
$Headers | Out-File -FilePath $csv -Encoding UTF8
foreach ($computer in $Computers)
{
Write-host "Pinging $Computer"
$Test = Test-Connection -ComputerName $computer -Count 1 -ErrorAction SilentlyContinue -ErrorVariable Err
if ($test -ne $null)
{
    $IP = $Test.IPV4Address.IPAddressToString
    $Output = "$Computer,$IP"
    $Output | Out-File -FilePath $csv -Encoding UTF8 -Append
}
Else
{
    $Output = "$Computer,$Err"
    $output | Out-File -FilePath $csv -Encoding UTF8 -Append
}
cls
}