MSP Automator

Technical and Operational Content for MSPs and IT – No ads, no sponsors, no bullshit.

Automated AutoPilot Enrollment Using Powershell and NinjaRMM

“AutoPilot? Sounds spooky. I’ll keep provisioning machines by hand, thanks!”

Said no one, ever, right? Wrong. Most MSPs are failing to adopt technologies like AutoPilot and Intune. There’s a lot of perfectly valid reasons why AutoPilot and Intune might not be right for some of your clients – licensing requirements, etc. That’s fine. But for 90% of verticals and Windows shops, the future is now, old man. Embrace the tools Microsoft is giving you to make your job easier instead of shunning the advancement of cloud management technologies. If you read this paragraph and said to yourself (or out loud to a coworker in your vicinity) “Well, this is why everyone just needs to use Linux/BSD/Windows3.1/HamSandwichOS!” then this is not the blog for you. These aren’t the droids you’re looking for and everyone knows bad faith arguments like that belong in r/sysadmin. Moving on!

AutoPilot is one of the largest technological leaps forward Microsoft has made in machine provisioning. SCCM, Windows Deployment Toolkit, SmartDeploy – these are all products of a bygone era. Even before COVID-19 the landscape was changing and work from home was becoming more common. When the world as we knew it started to end in January 2020 many IT teams and MSPs found themselves in a tricky situation. VPNs suck, SCCM sucks, going to the office in a pandemic to provision machines on the corporate LAN sucks, domain trust over VPN sucks…you get the idea. Enter AutoPilot, Microsoft’s answer to controlling the OOBE.

While AutoPilot has been around for some time, it wasn’t really until COVID-19 that Microsoft started heavily improving both AutoPilot and Intune. I would have considered the AutoPilot/Intune of January 2020 an incomplete product and not production ready for most workloads. That is no longer the case. The present day incarnation of AutoPilot/Intune are production ready for almost all workloads. I plan on writing a blog post in the near future laying out my case for why all MSPs should be aggressively aligning to AP/Intune and really explaining in depth all the reasons it’s a good idea.

In this article we’re going to assume you’ve conquered implementing AutoPilot and Intune but need an intuitive way to get those existing machines enrolled in AutoPilot. This is where most technical teams fall on their face – AutoPilot and Intune are complicated beasts with a lot of moving parts and many lack the foundational knowledge of things like Azure AD required to drive these solutions to completion.

Aren’t AutoPilot and Intune two sides of the same coin?

Kind of, but not really, and this is a misconception that is very common in a lot of sysadmin circles. AutoPilot and Intune complement each other, but serve different purposes. Here’s a really basic explanation:

  • AutoPilot allows you to customize the Out-of-Box-Experience for the user when they power on a machine the first time. You can either preload your machine hardware IDs (or a CSV from your disty of choice) into AutoPilot or enroll them manually with a script. The next time a machine goes through OOBE (either the first time or after a reset/reinstall of Windows) and is connected to the internet, will pick up the AutoPilot profile and follow the series of tasks you define. These can be things like joining Azure AD, enrolling in Intune, skipping the setup screens, etc. This is the step we’re going to automate today in this post.
  • Intune is Mobile Device Management (MDM). Intune works with all device flavors – Windows, iOS, MacOS, Android, etc. Intune can be thought of as Group Policy and some pieces of an RMM in the cloud. You configure profiles that do things based on groups or other criteria you specify. There was a time Intune policies seriously lacked functionality you could get from GPOs in AD. That time is pretty much gone. With the exception of custom ADMX’s from 3rd party vendors (which can be done with AADDS in the cloud), Intune now has the capability to reproduce any stock GPO you’d find in AD. You can even analyze your on-prem GPOs and see their equivalents/deprecations in the endpoint manager. Lots of progress has been made, is the point.

OK, we get it. You have opinions about stuff. Where’s the sauce?

I had to figure out how to get 2500 endpoints into 50 different tenants automatically. Luckily there’s a dank script called Get-WindowsAutoPilotInfo that does 90% of what I need out of the box. But manually uploading CSVs is for chumps and I wanted something that could be a reliable check on the helpdesk provisioning machines (people make mistakes and sometimes NinjaRMM gets installed first, etc). The solution was obvious – an enterprise application in Azure AD and a NinjaRMM script to enroll any machine it sees if it isn’t already. This solves the problem of getting all my existing machines enrolled in AutoPilot silently in the background. I can create an app in each client tenant, pop this script into NinjaRMM to run once and sit back while the magic happens.

Prerequisites

  • Register an Enterprise Application in Azure AD for your target tenant.
  • Assign the Enterprise Application the following permissions and grant consent
    • Graph (Application Perms) – User.Read
    • Graph (Application Perms) – Group.ReadWrite.All
    • Graph (Application Perms) – Directory.Read.All
    • Graph (Application Perms) – DeviceManagementServiceConfig.ReadWrite.All
    • Graph (Application Perms) – DeviceManagementManagedDevices.ReadWrite.All
    • Graph (Application Perms) – DeviceManagementConfiguration.ReadWrite.All
    • Graph (Application Perms) – DeviceManagementManagedDevices.PrivilegedOperations.All
    • Graph (Application Perms) – DeviceManagementApps.ReadWrite.All
    • Graph (Application Perms) – DeviceManagementRBAC.ReadWrite.All
  • Keep your Tenant ID, Application ID, and Application Secret Key handy
  • (Optional but recommended) – Create dynamic device groups for your profiles so you can pass the group name to the script and have profiles auto assigned

The Sauce

This script will install Nuget and the AutoPilotIntune PowerShell module if it isn’t installed already.

Notes for this script:

  • Replace the Tenant ID, Application ID, and App Secret hashes with the values of your created Enterprise Application
  • Sign your script – A code signing certificate is cheap and you should be signing anything you put in the wild, even scripts in your RMM. If you are unable to sign the script you need to change the “Set-ExecutionPolicy” line near the bottom of the script to Bypass instead of RemoteSigned. I do not condone or recommend putting unsigned scripts into the wild.
  • If you aren’t using device groups you can remove the “-AddToGroup #######” parameter in the command. Otherwise this is the full text string name of the group in Azure AD, enclosed in double quotes.
#'##:::'##:'########:'########:'########::'#######:::'######::'##::::::::'#######::'##::::'##:'########::
#. ##:'##:: ##.....:: ##.....::... ##..::'##.... ##:'##... ##: ##:::::::'##.... ##: ##:::: ##: ##.... ##:
#:. ####::: ##::::::: ##:::::::::: ##::::..::::: ##: ##:::..:: ##::::::: ##:::: ##: ##:::: ##: ##:::: ##:
#::. ##:::: ######::: ######:::::: ##:::::'#######:: ##::::::: ##::::::: ##:::: ##: ##:::: ##: ##:::: ##:
#::: ##:::: ##...:::: ##...::::::: ##::::'##:::::::: ##::::::: ##::::::: ##:::: ##: ##:::: ##: ##:::: ##:
#::: ##:::: ##::::::: ##:::::::::: ##:::: ##:::::::: ##::: ##: ##::::::: ##:::: ##: ##:::: ##: ##:::: ##:
#::: ##:::: ########: ########:::: ##:::: #########:. ######:: ########:. #######::. #######:: ########::
#:::..:::::........::........:::::..:::::.........:::......:::........:::.......::::.......:::........:::
#Copyright 2021 - MSPAutomator.com
#Get-WindowsAutoPilotInfo is a script available at https://www.powershellgallery.com/packages/Get-WindowsAutoPilotInfo/

function Get-WindowsAutoPilotInfo
{
	[CmdletBinding(DefaultParameterSetName = 'Default')]
	param (
		[Parameter(Mandatory = $False, ValueFromPipeline = $True, ValueFromPipelineByPropertyName = $True, Position = 0)]
		[alias("DNSHostName", "ComputerName", "Computer")]
		[String[]]$Name = @("localhost"),
		[Parameter(Mandatory = $False)]
		[String]$OutputFile = "",
		[Parameter(Mandatory = $False)]
		[String]$GroupTag = "",
		[Parameter(Mandatory = $False)]
		[String]$AssignedUser = "",
		[Parameter(Mandatory = $False)]
		[Switch]$Append = $false,
		[Parameter(Mandatory = $False)]
		[System.Management.Automation.PSCredential]$Credential = $null,
		[Parameter(Mandatory = $False)]
		[Switch]$Partner = $false,
		[Parameter(Mandatory = $False)]
		[Switch]$Force = $false,
		[Parameter(Mandatory = $True, ParameterSetName = 'Online')]
		[Switch]$Online = $false,
		[Parameter(Mandatory = $False, ParameterSetName = 'Online')]
		[String]$TenantId = "",
		[Parameter(Mandatory = $False, ParameterSetName = 'Online')]
		[String]$AppId = "",
		[Parameter(Mandatory = $False, ParameterSetName = 'Online')]
		[String]$AppSecret = "",
		[Parameter(Mandatory = $False, ParameterSetName = 'Online')]
		[String]$AddToGroup = "",
		[Parameter(Mandatory = $False, ParameterSetName = 'Online')]
		[String]$AssignedComputerName = "",
		[Parameter(Mandatory = $False, ParameterSetName = 'Online')]
		[Switch]$Assign = $false,
		[Parameter(Mandatory = $False, ParameterSetName = 'Online')]
		[Switch]$Reboot = $false
	)
	
	Begin
	{
		# Initialize empty list
		$computers = @()
		
		# If online, make sure we are able to authenticate
		if ($Online)
		{
			
			# Get NuGet
			$provider = Get-PackageProvider NuGet -ErrorAction Ignore
			if (-not $provider)
			{
				Write-Host "Installing provider NuGet"
				Find-PackageProvider -Name NuGet -ForceBootstrap -IncludeDependencies
			}
			
			# Get WindowsAutopilotIntune module (and dependencies)
			$module = Import-Module WindowsAutopilotIntune -PassThru -ErrorAction Ignore
			if (-not $module)
			{
				Write-Host "Installing module WindowsAutopilotIntune"
				Install-Module WindowsAutopilotIntune -Force
			}
			Import-Module WindowsAutopilotIntune -Scope Global
			
			# Get Azure AD if needed
			if ($AddToGroup)
			{
				$module = Import-Module AzureAD -PassThru -ErrorAction Ignore
				if (-not $module)
				{
					Write-Host "Installing module AzureAD"
					Install-Module AzureAD -Force
				}
			}
			
			# Connect
			if ($AppId -ne "")
			{
				$graph = Connect-MSGraphApp -Tenant $TenantId -AppId $AppId -AppSecret $AppSecret
				Write-Host "Connected to Intune tenant $TenantId using app-based authentication (Azure AD authentication not supported)"
			}
			else
			{
				$graph = Connect-MSGraph
				Write-Host "Connected to Intune tenant $($graph.TenantId)"
				if ($AddToGroup)
				{
					$aadId = Connect-AzureAD -AccountId $graph.UPN
					Write-Host "Connected to Azure AD tenant $($aadId.TenantId)"
				}
			}
			
			# Force the output to a file
			if ($OutputFile -eq "")
			{
				$OutputFile = "$($env:TEMP)\autopilot.csv"
			}
		}
	}
	
	Process
	{
		foreach ($comp in $Name)
		{
			$bad = $false
			
			# Get a CIM session
			if ($comp -eq "localhost")
			{
				$session = New-CimSession
			}
			else
			{
				$session = New-CimSession -ComputerName $comp -Credential $Credential
			}
			
			# Get the common properties.
			Write-Verbose "Checking $comp"
			$serial = (Get-CimInstance -CimSession $session -Class Win32_BIOS).SerialNumber
			
			# Get the hash (if available)
			$devDetail = (Get-CimInstance -CimSession $session -Namespace root/cimv2/mdm/dmmap -Class MDM_DevDetail_Ext01 -Filter "InstanceID='Ext' AND ParentID='./DevDetail'")
			if ($devDetail -and (-not $Force))
			{
				$hash = $devDetail.DeviceHardwareData
			}
			else
			{
				$bad = $true
				$hash = ""
			}
			
			# If the hash isn't available, get the make and model
			if ($bad -or $Force)
			{
				$cs = Get-CimInstance -CimSession $session -Class Win32_ComputerSystem
				$make = $cs.Manufacturer.Trim()
				$model = $cs.Model.Trim()
				if ($Partner)
				{
					$bad = $false
				}
			}
			else
			{
				$make = ""
				$model = ""
			}
			
			# Getting the PKID is generally problematic for anyone other than OEMs, so let's skip it here
			$product = ""
			
			# Depending on the format requested, create the necessary object
			if ($Partner)
			{
				# Create a pipeline object
				$c = New-Object psobject -Property @{
					"Device Serial Number" = $serial
					"Windows Product ID"   = $product
					"Hardware Hash"	       = $hash
					"Manufacturer name"    = $make
					"Device model"		   = $model
				}
				# From spec:
				# "Manufacturer Name" = $make
				# "Device Name" = $model
				
			}
			else
			{
				# Create a pipeline object
				$c = New-Object psobject -Property @{
					"Device Serial Number" = $serial
					"Windows Product ID"   = $product
					"Hardware Hash"	       = $hash
				}
				
				if ($GroupTag -ne "")
				{
					Add-Member -InputObject $c -NotePropertyName "Group Tag" -NotePropertyValue $GroupTag
				}
				if ($AssignedUser -ne "")
				{
					Add-Member -InputObject $c -NotePropertyName "Assigned User" -NotePropertyValue $AssignedUser
				}
			}
			
			# Write the object to the pipeline or array
			if ($bad)
			{
				# Report an error when the hash isn't available
				Write-Error -Message "Unable to retrieve device hardware data (hash) from computer $comp" -Category DeviceError
			}
			elseif ($OutputFile -eq "")
			{
				$c
			}
			else
			{
				$computers += $c
				Write-Host "Gathered details for device with serial number: $serial"
			}
			
			Remove-CimSession $session
		}
	}
	
	End
	{
		if ($OutputFile -ne "")
		{
			if ($Append)
			{
				if (Test-Path $OutputFile)
				{
					$computers += Import-CSV -Path $OutputFile
				}
			}
			if ($Partner)
			{
				$computers | Select "Device Serial Number", "Windows Product ID", "Hardware Hash", "Manufacturer name", "Device model" | ConvertTo-CSV -NoTypeInformation | % { $_ -replace '"', '' } | Out-File $OutputFile
			}
			elseif ($AssignedUser -ne "")
			{
				$computers | Select "Device Serial Number", "Windows Product ID", "Hardware Hash", "Group Tag", "Assigned User" | ConvertTo-CSV -NoTypeInformation | % { $_ -replace '"', '' } | Out-File $OutputFile
			}
			elseif ($GroupTag -ne "")
			{
				$computers | Select "Device Serial Number", "Windows Product ID", "Hardware Hash", "Group Tag" | ConvertTo-CSV -NoTypeInformation | % { $_ -replace '"', '' } | Out-File $OutputFile
			}
			else
			{
				$computers | Select "Device Serial Number", "Windows Product ID", "Hardware Hash" | ConvertTo-CSV -NoTypeInformation | % { $_ -replace '"', '' } | Out-File $OutputFile
			}
		}
		if ($Online)
		{
			# Add the devices
			$importStart = Get-Date
			$imported = @()
			$computers | % {
				$imported += Add-AutopilotImportedDevice -serialNumber $_.'Device Serial Number' -hardwareIdentifier $_.'Hardware Hash' -groupTag $_.'Group Tag' -assignedUser $_.'Assigned User'
			}
			
			# Wait until the devices have been imported
			$processingCount = 1
			while ($processingCount -gt 0)
			{
				$current = @()
				$processingCount = 0
				$imported | % {
					$device = Get-AutopilotImportedDevice -id $_.id
					if ($device.state.deviceImportStatus -eq "unknown")
					{
						$processingCount = $processingCount + 1
					}
					$current += $device
				}
				$deviceCount = $imported.Length
				Write-Host "Waiting for $processingCount of $deviceCount to be imported"
				if ($processingCount -gt 0)
				{
					Start-Sleep 30
				}
			}
			$importDuration = (Get-Date) - $importStart
			$importSeconds = [Math]::Ceiling($importDuration.TotalSeconds)
			$successCount = 0
			$current | % {
				Write-Host "$($device.serialNumber): $($device.state.deviceImportStatus) $($device.state.deviceErrorCode) $($device.state.deviceErrorName)"
				if ($device.state.deviceImportStatus -eq "complete")
				{
					$successCount = $successCount + 1
				}
			}
			Write-Host "$successCount devices imported successfully. Elapsed time to complete import: $importSeconds seconds"
			
			# Wait until the devices can be found in Intune (should sync automatically)
			$syncStart = Get-Date
			$processingCount = 1
			while ($processingCount -gt 0)
			{
				$autopilotDevices = @()
				$processingCount = 0
				$current | % {
					if ($device.state.deviceImportStatus -eq "complete")
					{
						$device = Get-AutopilotDevice -id $_.state.deviceRegistrationId
						if (-not $device)
						{
							$processingCount = $processingCount + 1
						}
						$autopilotDevices += $device
					}
				}
				$deviceCount = $autopilotDevices.Length
				Write-Host "Waiting for $processingCount of $deviceCount to be synced"
				if ($processingCount -gt 0)
				{
					Start-Sleep 30
				}
			}
			$syncDuration = (Get-Date) - $syncStart
			$syncSeconds = [Math]::Ceiling($syncDuration.TotalSeconds)
			Write-Host "All devices synced. Elapsed time to complete sync: $syncSeconds seconds"
			
			# Add the device to the specified AAD group
			if ($AddToGroup)
			{
				$aadGroup = Get-AzureADGroup -Filter "DisplayName eq '$AddToGroup'"
				if ($aadGroup)
				{
					$autopilotDevices | % {
						$aadDevice = Get-AzureADDevice -ObjectId "deviceid_$($_.azureActiveDirectoryDeviceId)"
						if ($aadDevice)
						{
							Write-Host "Adding device $($_.serialNumber) to group $AddToGroup"
							Add-AzureADGroupMember -ObjectId $aadGroup.ObjectId -RefObjectId $aadDevice.ObjectId
						}
						else
						{
							Write-Error "Unable to find Azure AD device with ID $($_.azureActiveDirectoryDeviceId)"
						}
					}
					Write-Host "Added devices to group '$AddToGroup' ($($aadGroup.ObjectId))"
				}
				else
				{
					Write-Error "Unable to find group $AddToGroup"
				}
			}
			
			# Assign the computer name
			if ($AssignedComputerName -ne "")
			{
				$autopilotDevices | % {
					Set-AutopilotDevice -Id $_.Id -displayName $AssignedComputerName
				}
			}
			
			# Wait for assignment (if specified)
			if ($Assign)
			{
				$assignStart = Get-Date
				$processingCount = 1
				while ($processingCount -gt 0)
				{
					$processingCount = 0
					$autopilotDevices | % {
						$device = Get-AutopilotDevice -id $_.id -Expand
						if (-not ($device.deploymentProfileAssignmentStatus.StartsWith("assigned")))
						{
							$processingCount = $processingCount + 1
						}
					}
					$deviceCount = $autopilotDevices.Length
					Write-Host "Waiting for $processingCount of $deviceCount to be assigned"
					if ($processingCount -gt 0)
					{
						Start-Sleep 30
					}
				}
				$assignDuration = (Get-Date) - $assignStart
				$assignSeconds = [Math]::Ceiling($assignDuration.TotalSeconds)
				Write-Host "Profiles assigned to all devices. Elapsed time to complete assignment: $assignSeconds seconds"
				if ($Reboot)
				{
					Restart-Computer -Force
				}
			}
		}
	}
	
}


If (-not (Get-InstalledModule PowerShellGet -ErrorAction silentlycontinue))
{
	Try
	{
		Install-PackageProvider NuGet -Force | Out-Null
		Set-PSRepository PSGallery -InstallationPolicy Trusted
		Install-Module PowerShellGet -Force
	}
	Catch
	{
		Write-Host "There was an error installing NuGet or PowerShellGet."
		Exit 1
	}
}
	
Set-ExecutionPolicy -Scope Process -ExecutionPolicy RemoteSigned -Force
	
$TenantId = "###################################"
$AppId = "##################################"
$AppSecret = "################################"
	
try
{
	Get-WindowsAutoPilotInfo -Online -AddToGroup "###############" -TenantId $TenantId -AppId $AppId -AppSecret $AppSecret
}
catch
{
	$StartError = $_.Exception.Message
	Write-Host "Failed to Import to Autopilot: $StartError"
}
	

 

Comments (

)