<# 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: top,person,organizationalPerson,user,computer WS01 CN=WS01,OU=Workstation,DC=mydemosthatrock,DC=com 4 27/04/2017 12:52:40 PM 24/05/2018 4:31:40 PM WS01$ WS01 3338DEB4-10E2-4AB3-BE67-C887D0767533 4128 0 0 0 515 00000000-0000-0000-0000-000000000000 WS01$ 805306369 Windows 8.1 Enterprise 6.3 (9600) WS01.mydemosthatrock.com CmRcService/WS01.mydemosthatrock.com,CmRcService/WS01,TERMSRV/WS01,TERMSRV/WS01.mydemosthatrock.com,WSMAN/WS01,WSMAN/WS01.mydemosthatrock.com,host/WS01.mydemosthatrock.com,host/WS01 CN=Computer,CN=Schema,CN=Configuration,DC=mydemosthatrock,DC=com False 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 28 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