In Part 1 we walked through the installation and configuration of Deployment Webservices.
In Part 2 we walked through securing the Webservice.
In this part I will demonstrate how to use the Webservice via a PowerShell script to securely move a computer object during the operating system deployment (OSD) task sequence using Microsoft Deployment Toolkit (MDT).
To achieve the end result we need to:
- Create some deployment share rules in MDT (CustomSettings.ini)
- Add two “Run PowerShell Script” tasks to the Task Sequence
- Download and place the PowerShell Script into the deployment share Scripts folder
Create some deployment share rules in MDT (CustomSettings.ini)
We add and set 3 new properties for WebServiceURL, StagingOU and FinalOU where…
- WebServiceURL is the URL to the Webservice
- StagingOU is typically an OU with blocked GPO inheritance that is a safe place for the computer object to be placed into during the build process.
- FinalOU is the OU you want the object to end up in towards the end of the build process.
[Settings]
Priority=Default
Properties=WebServiceURL,StagingOU,FinalOU
[Default]
WebServiceURL=http://mdt01.mydemothatrocks.com/MDTWS
MachineObjectOU=OU=Staging,DC=mydemothatrocks,DC=com
StagingOU=OU=Staging,DC=mydemothatrocks,DC=com
FinalOU=OU=Gold Images,OU=Session Hosts,OU=Citrix,DC=mydemothatrocks,DC=com
Add two “Run PowerShell Script” tasks to the Task Sequence
The first one will move the computer object to the Staging OU:
- The task is added just before the OS is applied during the WinPE stage.
- It runs the script from the %SCRIPTROOT% folder with the following arguments:
- -MDT. This instructs the script to use the MDT build credentials.
- -TargetOU. This provides the OU the computer object will be moved to. In this case the Staging OU as specified as per the rules.
- Note that the %StagingOU% variable is surrounded in single quotes. This is important.

The second one will move the computer object to the Final OU:
- The task is added towards the end as one of the final tasks in your sequence.
- It runs the same script from the same location with the same arguments, with the only difference being the variable assigned to the TargetOU argument, as this is where we specify the the Final OU as per the rules.
- Note that the %FinalOU% variable is surrounded in single quotes. This is important.

Download the MoveOUwithWSandCredentials.ps1 (1406 downloads ) script and place it in the deployment share Scripts folder
Updates since 24th June 2020:
- The script demonstrates how the limitations of the New-WebServiceProxy cmdlet can be overcome by the Invoke-WebRequest cmdlet. Refer to the documentation within the script.
- The computer that you install the MDT Webservice on requires firewall rules open to Domain Controllers (DCs) in the AD Site that the computer object is in, based on Subnets. These are the DCs that it communicates with to move the computer object. Whilst this may seem like a bug with the Webservice that needs to be addressed, it actually reduces the need to be concerned with AD Replication. So when the computer joins/re-joins the Domain or reboots during the build process, the computer object is found without issues and in the correct OU.
<#
This script will move a computer object from its current location in Active Directory
to the supplied location using Maik Koster's Deployment Webservice and passing credentials.
This is needed for two reasons:
1) WinPE does not support the use of ADSI
2) The MDT task sequence does not run as a Domain User with permissions to easily achieve
this task. Whilst in MDT you can run a script as a different user, I wanted one that
was more flexible in its approach so that I could pass it existing variables or derive
them directly from the Task Sequence variables.
The Active Directory Net Framework classes are NOT supported on WinPE. .Net uses ADSI to
query AD. [adsi] and [adsisearcher] are built into PowerShell V2 and later
- [adsisearcher] - is a builtin type accelerator for -> System.DirectoryServices.DirectorySearcher
- [adsi] - is a builtin type accelerator for -> System.DirectoryServices.DirectoryEntry
Note that whilst Johan Arwidmark has blogged about adding ADSI support to WinPE, it is
not supported by Microsoft. As a Consultant I don't want to build an unsupported
environment for my customers. So I choose to use Maik Koster's Deployment Webservice
instead.
Syntax Examples:
- To move the computer object to the build (staging) OU
MoveOUwithWSandCredentials.ps1 -MDT -TargetOU:"%MachineObjectOU%"
- To move the computer object to the build (staging) OU and save the current OU location to the FinalOU Task Sequence Variable
MoveOUwithWSandCredentials.ps1 -MDT -TargetOU:"%MachineObjectOU%" -SaveCurrentOU
- To move the computer object to the final OU
MoveOUwithWSandCredentials.ps1 -MDT -TargetOU:"OU=Citrix,OU=Servers"
Where...
-TargetOU = New OU Location relative to the Domain DN
-SaveCurrentOU = Instructs the script to write the current OU location to the FinalOU
Task Sequence Variable.
-DomainAdminDomain = Domain in FQDN format preferrably
-DomainAdmin = Username that has permissions to move computer objects in AD.
You would typically use the Domain join account here.
-DomainAdminPassword = Password. If your password contains special characters, you either need to escape them or use single
quotes to avoid expansion.
-Decode = Decode (Optional). This is needed if you pass the DomainAdminDomain,
DomainAdmin and DomainAdminPassword variables from MDT.
-MDT = Get the DomainAdminDomain DomainAdmin DomainAdminPassword variables and
automatically decrypt them. Note that the "Microsoft.SMS.TSEnvironment"
object is only available during the Task Sequences. You cannot use this
parameter to test this script outside of MDT.
-WebServiceURL = The URL for Maik Koster's Deployment Webservice
IMPORTANT NOTES:
1) In large and/or multi-domain environments the DomainAdminDomain variable MUST
be set in CustomSettings.ini in FQDN format and not as the NetBIOS domain name.
2) The computer that you install the MDT Webservice on requires firewall rules open to
Domain Controllers (DCs) in the AD Site that the computer object is in, based on
Subnets. These are the DCs that it communicates with to move the computer object.
Whilst this may seem like a bug with the Webservice that needs to be addressed, it
actually reduces the need to be concerned with AD Replication. So when the computer
joins/re-joins the Domain or reboots during the build process, the computer object
is found without issues and in the correct OU.
3) The MoveComputerToOU function will return either true or false. So you may need to
review the Deployment Webservices Debug and Trace logs to track down the real issue
should it return false. It is almost always a permission issue.
Script Name: MoveOUwithWSandCredentials.ps1
Release 1.6
Written by Jeremy Saunders (Jeremy@jhouseconsulting.com) 21st November 2016
Modified by Jeremy Saunders (Jeremy@jhouseconsulting.com) 24th June 2020
#>
#-------------------------------------------------------------
param(
[String]$ComputerName=${env:computername},
[Switch]$MDT,
[String]$DomainAdminDomain="",
[String]$DomainAdmin="",
[String]$DomainAdminPassword="",
[Switch]$Decode,
[String]$TargetOU="",
[Switch]$SaveCurrentOU,
[String]$WebServiceURL="",
[Switch]$UseInvokeWebRequest
)
# Set Powershell Compatibility Mode
Set-StrictMode -Version 2.0
# Enable verbose, warning and error mode
$VerbosePreference = 'Continue'
$WarningPreference = 'Continue'
$ErrorPreference = 'Continue'
#-------------------------------------------------------------
Function IsTaskSequence([switch] $verbose) {
# This code was taken from a discussion on the CodePlex PowerShell App Deployment Toolkit site.
# It was posted by mmashwani.
# The only issue with this function is that it writes terminating errors to the transcription log, which looks
# rather ugly. So this function will eventually be updated so that it checks for the existenace of the relevant
# snapins (Get-PSSnapin) and assemblies ([AppDomain]::CurrentDomain.GetAssemblies()) that have been loaded.
# References:
# - https://richardspowershellblog.wordpress.com/2007/09/30/assemblies-loaded-in-powershell/
# - http://learningpcs.blogspot.com.au/2012/06/powershell-v2-test-if-assembly-is.html
Try {
[__ComObject]$SMSTSEnvironment = New-Object -ComObject Microsoft.SMS.TSEnvironment -ErrorAction 'SilentlyContinue' -ErrorVariable SMSTSEnvironmentErr
}
Catch {
# The Microsoft.SMS.TSEnvironment assembly is not present.
}
If ($SMSTSEnvironmentErr) {
Write-Verbose "Unable to load ComObject [Microsoft.SMS.TSEnvironment]. Therefore, script is not currently running from an MDT or SCCM Task Sequence." -verbose:$verbose
Return $false
}
ElseIf ($null -ne $SMSTSEnvironment) {
Write-Verbose "Successfully loaded ComObject [Microsoft.SMS.TSEnvironment]. Therefore, script is currently running from an MDT or SCCM Task Sequence." -verbose:$verbose
Return $true
}
}
#-------------------------------------------------------------
$invalidChars = [io.path]::GetInvalidFileNamechars()
$datestampforfilename = ((Get-Date -format s).ToString() -replace "[$invalidChars]","-")
# Get the script path
$ScriptPath = {Split-Path $MyInvocation.ScriptName}
$ScriptPath = $(&$ScriptPath)
$ScriptName = [System.IO.Path]::GetFilenameWithoutExtension($MyInvocation.MyCommand.Path.ToString())
$Logfile = "$ScriptName-$($datestampforfilename).log"
$logPath = "$($env:windir)\Temp"
If (IsTaskSequence) {
$tsenv = New-Object -COMObject Microsoft.SMS.TSEnvironment
$logPath = $tsenv.Value("LogPath")
}
try {
# The Microsoft.BDD.TaskSequencePSHost.exe (TSHOST) does not support
# transcription, so we wrap this in a try catch to prevent errors.
Start-Transcript "$logPath\$Logfile"
}
catch {
Write-Verbose "This host does not support transcription"
}
#-------------------------------------------------------------
If ($MDT) {
If (IsTaskSequence) {
Write-Verbose "Reading Task Sequence variables" -verbose
$Decode = $True
$DomainAdminDomain = $tsenv.Value("DomainAdminDomain")
$DomainAdmin = $tsenv.Value("DomainAdmin")
$DomainAdminPassword = $tsenv.Value("DomainAdminPassword")
$ComputerName = $tsenv.Value("OSDComputerName")
$WebServiceURL = $tsenv.Value("WebServiceURL")
} Else {
Write-Verbose "This script is not running from a task sequence" -verbose
}
}
$ExitCode = 0
If ($DomainAdminDomain -ne "" -AND $DomainAdmin -ne "" -AND $DomainAdminPassword -ne "") {
If ($Decode) {
# Decode the base64 encoded blob using UTF-8
$DomainAdminDomain = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($DomainAdminDomain))
$DomainAdmin = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($DomainAdmin))
$DomainAdminPassword = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($DomainAdminPassword))
}
$DomainAdminPasswordSecureString = ConvertTo-SecureString String $DomainAdminPassword AsPlainText -Force
$cred = new-object -typename System.Management.Automation.PSCredential -argumentlist "$DomainAdminDomain\$DomainAdmin",$DomainAdminPasswordSecureString
If ($cred -is [system.management.automation.psCredential]) {
$IsValidCredentials = $True
write-verbose "Using credentials:" -verbose
write-verbose "- DomainName: $DomainAdminDomain" -verbose
write-verbose "- UserName: $DomainAdmin" -verbose
# Move computer to required OU
If ([String]::IsNullOrEmpty($TargetOU) -eq $false) {
# Derive the Domain distinguishedName from the FQDN of the Domain we are joining
$ValidDomainDN = $False
If ($DomainAdminDomain -Like "*.*") {
#$DomainDistinguishedName = "DC=" + (($DomainAdminDomain -split "\." | Select-Object ) -join ",DC=")
$DomainDistinguishedName = 'DC=' + $DomainAdminDomain.Replace('.',',DC=')
$ValidDomainDN = $True
}
# PowerShell will replace comma's with spaces on the command line,
# so here we just replace the spaces with commas.
$TargetOU = $TargetOU -Replace " OU=", ",OU="
$TargetOU = $TargetOU -Replace " DC=", ",DC="
If ($TargetOU -NotLike "*,DC=*") {
If ($ValidDomainDN) {
$TargetOU = "$TargetOU,$DomainDistinguishedName"
}
}
<#
I found that the New-WebServiceProxy cmdlet doesn't give you the correct output for some of the methods and I could not extract
the data from what was returned. So I used Invoke-WebRequest instead. New-WebServiceProxy isn't like Invoke-WebRequest in any
way. New-WebServiceProxy is a cmdlet that reads the definition of the service (the wsdl file) and creates code based on it,
outputing an object that you can use to interact with it. For this particular Web Service, some methods return XML within a
SerializableNameValueCollection, which is contructed by a NameValueCollection. However, the New-WebServiceProxy cmdlet was
interpreting it as a System.Data.Dataset class type where I was unable to expose the data.
For example: When calling the GetComputerAttributes method using the New-WebServiceProxy cmdlet it returns an object of
System.Data.Dataset class type and I was unable to extract the returned data from it. All methods attempted to enumerate the
data returns an empty dataset. However, the Deployment Service Info log states that the data it was returning was a
"MaikKoster.Deployment.AD.SerializableNameValueCollection", An example of the XML output from the webservice is as follows:
<?xml version="1.0" encoding="UTF-8"?>
<SerializableNameValueCollection xmlns="http://maikkoster.com/Deployment">
<objectClass>top,person,organizationalPerson,user,computer</objectClass>
<cn>WS01</cn>
<distinguishedName>CN=WS01,OU=Workstation,DC=mydemosthatrock,DC=com</distinguishedName>
<instanceType>4</instanceType>
<whenCreated>27/04/2017 12:52:40 PM</whenCreated>
<whenChanged>24/05/2018 4:31:40 PM</whenChanged>
<displayName>WS01$</displayName>
<name>WS01</name>
<objectGUID>3338DEB4-10E2-4AB3-BE67-C887D0767533</objectGUID>
<userAccountControl>4128</userAccountControl>
<codePage>0</codePage>
<countryCode>0</countryCode>
<localPolicyFlags>0</localPolicyFlags>
<primaryGroupID>515</primaryGroupID>
<objectSid>00000000-0000-0000-0000-000000000000</objectSid>
<sAMAccountName>WS01$</sAMAccountName>
<sAMAccountType>805306369</sAMAccountType>
<operatingSystem>Windows 8.1 Enterprise</operatingSystem>
<operatingSystemVersion>6.3 (9600)</operatingSystemVersion>
<dNSHostName>WS01.mydemosthatrock.com</dNSHostName>
<servicePrincipalName>CmRcService/WS01.mydemosthatrock.com,CmRcService/WS01,TERMSRV/WS01,TERMSRV/WS01.mydemosthatrock.com,WSMAN/WS01,WSMAN/WS01.mydemosthatrock.com,host/WS01.mydemosthatrock.com,host/WS01</servicePrincipalName>
<objectCategory>CN=Computer,CN=Schema,CN=Configuration,DC=mydemosthatrock,DC=com</objectCategory>
<isCriticalSystemObject>False</isCriticalSystemObject>
<dSCorePropagationData>16/05/2018 9:46:42 AM,16/02/2018 12:58:41 PM,2/02/2018 8:10:01 AM,11/12/2017 4:57:05 AM,14/07/1601 10:36:49 PM</dSCorePropagationData>
<msDS-SupportedEncryptionTypes>28</msDS-SupportedEncryptionTypes>
</SerializableNameValueCollection>
So indeed this a Specialized collection using the System.Collections.Specialized.NameValueCollection Class, which the
New-WebServiceProxy cmdlet is unable to process.
#>
$URI = $WebServiceURL + "/ad.asmx"
if ($URI.Contains("https")) {
# If you're running a self-signed certificate or don't have the full CA chain
# available on the localhost where this script is run from, we need to tell
# .NET to ignore this SSL indiscretion.
[System.Net.ServicePointManager]::ServerCertificateValidationCallback={$true}
}
If ($UseInvokeWebRequest) {
# The UseBasicParsing parameter must be used with the Invoke-WebRequest cmdlet in WinPE if the Internet Explorer engine
# is not available or the Internet Explorer's first launch configuration is not complete.
# Test to ensure the web service is responsive. The response will not be fully populated unless you get a success code (e.g. 200). However, you can
# process the $_.Exception.Response in the catch{} block which is a System.Net.Http.HttpResponseMessage object. So we set it up so that...
# $Response.StatusCode will be hit when the status is successful and $_.Exctteption.Response.StatusCode.value__ will be hit when it is not. In the end
# The $StatusCode variable will have the status code regardless of whether the request was successful or not.
# Adding [System.Net.WebException] ensures we only capture relevant exceptions in this way / don't accidentally sweep up other types of issues.
# Thanks to Mark Kraus (@markekraus) for this information: https://github.com/PowerShell/PowerShell/issues/9009
try {
$Response = Invoke-WebRequest -Uri $URI -UseBasicParsing -Credential $cred -ErrorAction:Stop
$StatusCode = $Response.StatusCode
}
catch [System.Net.WebException] {
Write-Verbose "An exception was caught: $($_.Exception.Message)"
$StatusCode = $_.Exception.Response.StatusCode.value__
}
If ([int]$StatusCode -eq 200) {
$wsCall = Invoke-WebRequest -Uri "$URI/GetComputerAttribute?Computername=$ComputerName&Attribute=distinguishedName" -UseBasicParsing -Credential $cred -ErrorAction:Stop
[xml]$response = $wsCall.Content
$response.String.'#text'
$wsCall = Invoke-WebRequest -Uri "$URI/GetComputerAttributes?Computername=$ComputerName" -UseBasicParsing -Credential $cred -ErrorAction:Stop
[XML]$response = $wsCall.Content
$response.SerializableNameValueCollection
Write-verbose "The full Web Service URL is: $URI" -verbose
try {
$wsCall = Invoke-WebRequest -Uri $URI -UseBasicParsing -Credential $cred -ErrorAction:Stop
}
catch [System.Net.WebException] {
$wsCall = $null
Write-Host "ERROR: Unable to connect to SOAP server"
Write-Host $_
}
catch {
$wsCall = $null
Write-Host "ERROR: Unexpected error"
Write-Host $_
Write-Host $_.Exception.Message
Write-Host $_.Exception.GetType().FullName
}
} Else {
write-verbose "Failed to connect to webservice" -verbose
}
} Else {
$URI = $URI + "?WSDL"
Write-verbose "The full Web Service URL with the WSDL is: $URI" -verbose
try {
$wsCall = New-WebServiceProxy -Uri $URI -Credential $cred -ErrorAction:Stop
}
catch [System.Net.WebException] {
$wsCall = $null
Write-Host "ERROR: Unable to connect to SOAP server"
Write-Host $_
}
catch {
$wsCall = $null
Write-Host "ERROR: Unexpected error"
Write-Host $_
Write-Host $_.Exception.Message
Write-Host $_.Exception.GetType().FullName
}
If ($wsCall -ne $null) {
#$wsCall | Get-Member
$ForestInfo = $wsCall.GetForest()
Write-verbose "The current Forest is: $($ForestInfo.Name)" -verbose
Write-verbose "The Domains in the Forest are: $([string]::Join(",",($ForestInfo.Domains)))" -verbose
Write-verbose "The AD Site for this computer based on its IP Address is: $($wsCall.GetADSite())" -verbose
$isComputer = $False
Try {
$isComputer = $wsCall.DoesComputerExist($ComputerName)
}
Catch {
$Host.UI.WriteErrorLine("ERROR: $($_)")
}
$MoveCompleted = $False
If ($isComputer) {
Write-verbose "There is a matching computer object in Active Directory" -verbose
Write-verbose "- The distinguishedName is: $($wsCall.GetComputerAttribute($ComputerName,"distinguishedName"))" -verbose
Write-verbose "- The description is: $($wsCall.GetComputerDescription($ComputerName))" -verbose
$CurrentOU = ($wsCall.GetComputerParentPath($ComputerName) -split("//"))[1]
Write-verbose "- Parent OU: $CurrentOU" -verbose
If ($SaveCurrentOU) {
If ($MDT) {
If (IsTaskSequence) {
Write-Verbose "Writing the current parent OU back to the FinalOU Task Sequence variable" -verbose
$tsenv.Value("FinalOU") = $CurrentOU
} Else {
Write-Verbose "This script is not running from a task sequence" -verbose
}
}
}
If ($TargetOU -ne $CurrentOU) {
write-verbose "Moving to target OU: $TargetOU" -verbose
Try {
$MoveCompleted = $wsCall.MoveComputerToOU($ComputerName,$TargetOU)
}
Catch {
$message = "ERROR: $($_)"
write-warning "$message" -verbose
$Host.UI.WriteErrorLine("$message")
$ExitCode = 1
}
If ($MoveCompleted) {
Write-verbose "Successfully moved the computer object." -verbose
} Else {
$message = "ERROR: Unable to move the computer object. Verify that the Target OU exists and that the account has the required permissions."
write-warning "$message" -verbose
$Host.UI.WriteErrorLine("$message")
$ExitCode = 1
}
} Else {
write-verbose "No need to move the computer as it is already in the target OU" -verbose
}
} Else {
Write-verbose "There is no matching computer object in Active Directory" -verbose
}
} Else {
$message = "ERROR: Unable to connect to SOAP server."
write-warning "$message" -verbose
$Host.UI.WriteErrorLine("$message")
$ExitCode = 1
}
}
} Else {
$message = "ERROR: TargetOU is a required parameter."
write-warning "$message" -verbose
$Host.UI.WriteErrorLine($message)
$ExitCode = 1
}
} Else {
$message = "ERROR: Invalid credentials."
write-warning "$message" -verbose
$Host.UI.WriteErrorLine("$message")
$ExitCode = 1
}
} Else {
$message = "ERROR: Missing credentials."
write-warning "$message" -verbose
$Host.UI.WriteErrorLine("$message")
$ExitCode = 1
}
# We must set the $ExitCode variable when using Set-StrictMode in PowerShell
# scripts used in MDT/SCCM Task Sequences. It's a known bug.
If ($ExitCode -eq 0) {
Write-Verbose "Completed with an exit code of $ExitCode"
} Else {
Write-Warning "Completed with an exit code of $ExitCode"
$Host.UI.WriteErrorLine("ERROR: Completed with an exit code of $ExitCode")
}
try {
# The Microsoft.BDD.TaskSequencePSHost.exe (TSHOST) does not support
# transcription, so we wrap this in a try catch to prevent errors.
Stop-Transcript
}
catch {
Write-Verbose "This host does not support transcription"
}
Exit $ExitCode
I hope this 3 part series has been helpful and shows the great value of this webservice.
Enjoy!
