Installing, Configuring, Securing and Using MDT Webservices – Part 3

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!

Jeremy Saunders

Jeremy Saunders

Delivering customer success through tech: IT Infrastructure | Citrix | End User Computing | Platform Engineering | DevOps | Full Stack Developer | Technical Architect | Improvisor | Aspiring Comedian | Midlife Adventurer at J House Consulting
Jeremy Saunders is the Problem Terminator; the MacGyver of IT. Views and Intellectual Property (IP) published on this site belong to Jeremy. Please refer to the About page for more information about Jeremy.