Using MDT Web Services for UDI
I have recently done some work on a UDI deployment that I wanted to share. It involved writing a custom MDT web service that is used in multiple ways.
First off, my thanks to those that have posted info on this kind of thing before: Michael Niehaus, Maik Koster, Todd Hemsell, Chris Nackers, Brandon Linton (and many others). If you are just getting started with MDT/OSD/ZTI/UDI, I highly recommend looking over their blogs.
For this project, we had a few needs:
- Keep using RIS-Style Naming when building systems.
- Based on location, use a specific computer naming prefix and default OU.
- Allow the techs to override the computer name and/or target OU during the build process.
- Use an existing computer name by default if the system is being rebuilt manually.
All of this is accomplished by using a combination of UDI and the custom web service. I thought about using Maik's web service but I knew I was going to have to add some custom functions in order to get the functionality that was required. So, I just created a new web service and included everything that was required in it. We may end up installing Maik's web service and using it for the "standard" functions and just including the custom code in this web service.
We just have to gather all of our default information via the web service by specifying everything in CustomSettings.ini. The web service also takes advantage of a XML cross-reference file to set some default settings based on the AD site of the computer being built.
The web service has a few functions that can be used:
- GetDefaultsForADSite - Returns OSDDOMAINOUNAME and NAMEPREFIX Given an ADSITE by parsing a XML file
- GetNewName - Returns a new Computer Name to use. Inputs DnsDomain, NamePrefix, NumberOfNumbers, UUID, MACAddress, SMSServer, SMSSite
- GetSite - Returns AD site for an IP address (taken 99% from http://www.myitforum.com/articles/43/view.asp?id=12513)
If you look at the CustomSettings.ini example, you can see a few variables that we are setting in the Default section.
- DnsDomain - Used by the web service to connect to AD
- NumberofNumbers - The number of digits to use after your naming prefix
- SMSServer - Used for MAC lookups to find existing computer objects
- SMSSite - Same
- MacAddress - Set to 'MacAddress001'. This variable is created by MDT. We are just using it.
- IPAddress - Set to 'IPAddress001'.This variable is created by MDT. We are just using it.
The Priority variable under [Settings] is important so that everything is processed in the correct order. We have to know the variables specified in the [Default] section in order to get the ADSite. We then use the ADSite to get the defaults for that ADSite (NamePrefix and OSDDomainOUName). Once we have the NamePrefix, we can then determine the computer name to use by default.
One thing I want to highlight is the fact that the Web Service methods for 'WSGETADSITE' and 'WSGETNAME' only return one value. I left these functions working like most of the other Web Services you can find documented. They just return the value as a string and the CustomSettings.ini reflects that. For 'WSGETDEFAULTSFORADSITE', we are returning multiple values. We do this by returning XML instead of a String. Since we define the XML elements that are returned to match TS Variables that are defined, they will be automatically picked up by ZTIGather (see http://blogs.technet.com/b/mniehaus/archive/2007/09/19/d4b3-new-feature-calling-web-services.aspx for details).
This is pretty slick so I wanted to demonstrate this in use. I am pretty sure Maik's web service uses this for some of its functions as well.
After writing this post, I found this: http://mdtcustomizations.codeplex.com/SourceControl/changeset/view/358#34905. It looks like it is definitely returning XML data for some functions. Wish I would have found it before coding everything! :)
Another thing worth mentioning is that UDI seems to use OSDDomainOUName differently than a normal Task Sequence would. Since you define your OUs in the UDI Wizard Designer with both a full LDAP path and a 'friendly name', they chose to have UDI only match things up properly if you pre-define OSDDomainOUName with that 'friendly name'. So, when defining the custom XML that is used by this web service, you need to make sure that the "OU" nodes in ADSiteToOU.xml match up to those friendly names in UDIWizard_Config.xml.
Here is an example of how the Gather step will process everything:
ZTIGather.log
CustomSettings.ini
ADSiteToOU.XML (needs to be in the same folder as the Web Service ASMX file)
<adsitetooumappings> <adsite> <sitename>Subnet1</sitename> <ou>IC RIS Holding</ou> <nameprefix>w009-</nameprefix> </adsite> <adsite> <sitename>Subnet2</sitename> <ou>SMGSI RIS Holding</ou> <nameprefix>w801-</nameprefix> </adsite> <adsite> <sitename>Subnet3</sitename> <ou>SMGSI RIS Holding</ou> <nameprefix>w046-</nameprefix> </adsite> </adsitetooumappings>
UDIWizard_Config.xml
<datacollection name="Domain"> <dataitem displayname="ssmlab.com"> <setter property="Domain" value="ssmlab.com"> <setter property="OrganizationalUnits"> <datacollection name="OU"> <dataitem displayname="SMGSI RIS Holding"> <setter property="OU" value="OU=RIS Holding,OU=Workstations,OU=SMGSI,OU=SSMHC,DC=ssmlab,DC=com"> </setter></dataitem> <dataitem displayname="IC RIS Holding"> <setter property="OU" value="OU=RIS Holding,OU=Workstations,OU=IC,OU=SSMSTL,OU=SSMHC,DC=ssmlab,DC=com"> </setter></dataitem> </datacollection> </setter> </setter></dataitem> </datacollection>
Web Service Code (I think CodeMirror may be stripping some of the code comments out so I apologize for that):
Option Explicit On Option Strict On 'This web service will find a computer name to use '1. Check AD for a GUID match '2. Check SCCM for a MAC address match '3. Find next available name in AD ' Pass the name prefix and the number of digits after the prefix ' Examples ("3" = 001-999 ---- "4 "= 0001-9999) 'Must Add System.DirectoryServices as reference 'Must add System.Data.SqlClient as reference ''''''''''''''''''''''''''''''''''''''''''' 'TODOs/ToThinkAbouts 'We can check v_r_system.SMBIOS_GUID0 for GUIDs as well ' Skip this for now-MAC should be good enough 'Do we want to populate the netbootguid in AD? 'Will most likely have to be after UDI since the Compname could change from what this web service chooses 'Get OU for calling user? 'Get OU friendly name by AD site (or by user OU) ' Read cross-reference from an external XML file for easy editing 'http://technet.microsoft.com/en-us/library/bb490304.aspx#E0JC0AA ' Good info on what info we can pass via customsettings.ini (mac, etc) 'Getting IIS working 'http://webcache.googleusercontent.com/search?q=cache:iCl6wFJU0sQJ:www.experts-exchange.com/Software/Server_Software/Web_Servers/Microsoft_IIS/Q_26489006.html+folderlevelbuildproviders+64&cd=18&hl=en&ct=clnk&gl=us&source=www.google.com ' 'The fix was to run aspnet_regiis.exe /iru from the command prompt window making sure to use the relevant Framework 'and version (in our case running .NET 4.0 on a 64bit machine)... '%windir%\Microsoft.NET\Framework64\v4.0.30319\aspnet_regiis.exe /iru 'did the trick; the .NET Compilation tab subsequently opened without error. 'From the Microsoft .Net 4 Framework readme '2.3.2.6 Re-registering ASP.NET 4 might be required on Windows Vista, Windows Server 2008, Windows 7, and Windows Server 2008 R2 'ASP.NET 4 must be re-registered if IIS 7/7.5 or the IIS7/7.5 .NET Extensibility feature is enabled *after* the .NET Framework 4 has already been installed on the computer. ASP.NET 4 must also be re-registered if the .NET Extensibility feature is removed when the .NET Framework 4 is installed on the computer. 'For both cases, re-registration is required because the operating system installation and uninstallation process for IIS7 and IIS 7.5 and for the .NET Extensibility feature were not designed for the scenario where a later version of the .NET Framework already exists on the computer. 'To resolve this issue: 'To re-register ASP.NET 4, run the following command: 'aspnet_regiis -iru -enable 'Make sure that you use the version of aspnet_regiis.exe that is installed in the .NET Framework 4 installation directory. 'Have to add this to web.config (at minimum) in order for the POST method to function (look at Maiks web.config to see other possible good options) ' ' <webservices> ' <protocols> ' <add name="HttpGet"> ' <add name="HttpPost"> ' </add></add></protocols> ' </webservices> ' 'We can return multiple values as XML 'http://blogs.technet.com/b/mniehaus/archive/2007/09/19/d4b3-new-feature-calling-web-services.aspx ' The XML returned as a result of the web service call is searched for any property defined via CustomSettings.ini or ZTIGather.xml ' (just like with a database query or other rule). ' However, the XML search is case-sensitive. ' Fortunately the web service above returns all upper case property names, which is what ZTIGather expects. ' It is possible to remap lower or mixed-case entries to get around this. 'This is the source for what his example returns '<newdataset> ' ' <city>Maryland Heights</city> ' <state>MO</state> ' <zip>63043</zip> ' 314 ' C ' <table class="mceItemTable"></table> '</newdataset> 'A Normal "string return" (http://blogs.technet.com/b/mniehaus/archive/2009/12/06/ris-style-naming-with-mdt-2010-use-a-web-service.aspx) will look like this: ' ' <string xmlns="http://tempuri.org/">IC RIS Holding</string> ''''''''''''''''''''''''''''''''''''''''''' Imports System.Web.Services Imports System.Web.Services.Protocols Imports System.ComponentModel Imports System.DirectoryServices Imports System.Data.SqlClient Imports System Imports System.Collections Imports System.Collections.Generic Imports System.Diagnostics Imports System.Data Imports System.Linq Imports System.Web Imports System.Xml.Linq Imports System.Xml Imports System.DirectoryServices.ActiveDirectory Imports System.IO ' To allow this Web Service to be called from script, using ASP.NET AJAX, uncomment the following line. ' _ _ _ _ Public Class WebService1 Inherits System.Web.Services.WebService _ Public Function HelloWorld() As String Return "Hello World" End Function _ Public Function GetNewName(ByVal DnsDomain As String, ByVal NamePrefix As String, ByVal NumberOfNumbers As Integer, ByVal UUID As String, ByVal MACAddress As String, ByVal SMSServer As String, ByVal SMSSite As String) As String Dim ComputerName As String = "COMPNAMEERROR1" 'Set a default so we can see if this web service failed ''For web service testing via a browser (so you don't have to fill in the values each time) 'DnsDomain = "ds.ad.ssmhc.com" 'NamePrefix = "w009-" 'UUID = "4C4C4544-0050-5210-804C-B5C04F524431" 'Match 'UUID = "4C4C4544-0050-5210-804C-B5C04F524432" 'No Match 'SMSServer = "SCCM1" 'SMSSite = "009" 'MACAddress = "00:1A:A0:62:B4:FC" 'Match 'MACAddress = "00:1A:A0:62:B4:FF" 'No Match Dim blnFoundMatch As Boolean = False Debug.Print("UUID:" & UUID) Try Dim GUID As New Guid(UUID) Dim GUIDConverted As String = BitConverter.ToString(GUID.ToByteArray()) Dim GUIDConvertedLDAP As String = "\" & Replace(GUIDConverted, "-", "\", 1, -1, CompareMethod.Text) 'Debug.Print("GUIDConverted:" & GUIDConverted) Debug.Print("GUIDConvertedLDAP:" & GUIDConvertedLDAP) '''''''''''''' 'Debug.print sample output during testing 'UUID:4C4C4544-0050-5210-804C-B5C04F524431 'GUIDConverted:44-45-4C-4C-50-00-10-52-80-4C-B5-C0-4F-52-44-31 'GUIDConvertedLDAP:\44\45\4C\4C\50\00\10\52\80\4C\B5\C0\4F\52\44\31 '(&(objectClass=computer)(netbootGuid=\44\45\4C\4C\50\00\10\52\80\4C\B5\C0\4F\52\44\31)) 'build the search, based on the passed domain, then the name prefix Dim adRoot As New DirectoryEntry("LDAP://" & DnsDomain) Dim ADFilter As String = "(&(objectClass=computer)(netbootGuid=" & GUIDConvertedLDAP & "))" 'Debug.Print(ADFilter) Dim dirSearch1 As New DirectorySearcher(adRoot, ADFilter) ' Dim existingNames1 As New Dictionary(Of String, Guid)() 'existingNames.Com() For Each result As SearchResult In dirSearch1.FindAll() Dim strCurrName As String = result.Properties("name")(0).ToString.ToUpper Debug.Print("AD GUID MATCH: " & strCurrName) ComputerName = strCurrName blnFoundMatch = True Next Catch ex As Exception ComputerName = "COMPNAMEERROR2" End Try ''''''''''''''''''''''''''''''''''''''''''''''''' If blnFoundMatch = False Then 'SQL Query for MAC Match Try Dim SQLQueryString As String = "select distinct top 1 SYS.Netbios_Name0 from v_GS_NETWORK_ADAPTER NWA " & _ "JOIN v_R_System SYS on NWA.ResourceID = SYS.ResourceID where MACAddress0 = '" & MACAddress & "'" 'Debug.Print(SQLQueryString) Dim SQLResults As New List(Of String) Dim SQLConn As New SqlConnection() Dim SQLCmd As New SqlCommand() Dim sConnString As String = "Data Source=" & SMSServer & ";Initial Catalog=SMS_" & SMSSite & ";Integrated Security=True" SQLConn.ConnectionString = sConnString SQLConn.Open() SQLCmd.CommandTimeout = 60 SQLCmd.Connection = SQLConn SQLCmd.CommandText = SQLQueryString Dim SQLReader As SqlClient.SqlDataReader = SQLCmd.ExecuteReader While SQLReader.Read Dim SCCMCompName As String = SQLReader("Netbios_Name0").ToString Debug.Print("SCCMCompNameViaMAC: " & SCCMCompName) ComputerName = SCCMCompName blnFoundMatch = True End While Catch ex As Exception ComputerName = "COMPNAMEERROR3" End Try End If ''''''''''''''''''''''''''''''''''''''''''''''''' If blnFoundMatch = False Then 'Find next available computer name Try 'build the search, based on the passed domain, then the name prefix Dim adRoot As New DirectoryEntry("LDAP://" & DnsDomain) Dim dirSearch As New DirectorySearcher(adRoot, "(name=" & NamePrefix & "*)") Dim existingNames As New Dictionary(Of String, Guid)() 'Loop through the results For Each result As SearchResult In dirSearch.FindAll() Dim intCount As String = result.Properties.Count.ToString() 'Debug.Print(intCount) 'get the name, and create a guid holder Dim strCurrName As String = result.Properties("name")(0).ToString.ToUpper 'Debug.Print(strCurrName) Dim netbootGuid As New Guid 'if we have a guid use that with this name If result.Properties("netbootGuid").Count > 0 Then netbootGuid = New Guid(DirectCast(result.Properties("netbootGuid")(0), Byte())) End If 'add details to the dictonary, if they are not already there If Not existingNames.ContainsKey(strCurrName) Then existingNames.Add(strCurrName, netbootGuid) End If Next 'now try and get the next name in sequence Dim strNextName As String = Nothing 'Set a default if we got a bad variable passed If IsNumeric(NumberOfNumbers) = False Then NumberOfNumbers = 4 Dim sMaxRecordToCheck As String = StrDup(Convert.ToInt32(NumberOfNumbers), "9") Dim intMaxRecordToCheck As Integer = Convert.ToInt32(sMaxRecordToCheck) Debug.Print("intMaxRecordToCheck: " & intMaxRecordToCheck) 'loop through all the available machine numbers up to max of xxxx (9, 99, 999, 9999, etc.) For i As Int32 = 1 To intMaxRecordToCheck 'Dim strNameTest As String = NamePrefix.ToUpper & i.ToString("0000") Dim strNameTest As String = NamePrefix.ToUpper & i.ToString("d" & NumberOfNumbers) Debug.Print(strNameTest) 'Shows each computer name that is checked 'is this name in the list, if not we have our name If Not existingNames.ContainsKey(strNameTest) Then strNextName = strNameTest Exit For End If Next 'return our new name ComputerName = strNextName Catch ex As Exception ComputerName = "COMPNAMEERROR4" End Try End If Return ComputerName End Function _ Public Function GetDefaultsForADSite(ByVal ADSite As String) As XmlDocument Dim OUForADSite As String Dim NamePrefix As String Dim ADSiteToOUXML As String = "ADSiteToOU.xml" Dim XMLDoc As New Xml.XmlDocument Dim Root As XmlNode ''''''''Get Default OU'''''''' Try XMLDoc.Load(Server.MapPath(ADSiteToOUXML)) Dim XPathQueryOU As String = "/ADSiteToOUMappings/ADSite[SiteName='" & ADSite & "']/OU" 'Debug.Print(XMLDoc.InnerText) 'Debug.Print(XMLDoc.InnerXml) Root = XMLDoc.DocumentElement 'Dim NodeOU As XmlNode = XMLDoc.SelectSingleNode("//*") Dim NodeOU As XmlNode = Root.SelectSingleNode(XPathQueryOU) If NodeOU Is Nothing Then Debug.Print("NULL") OUForADSite = Nothing Else Debug.Print(NodeOU.InnerText) OUForADSite = NodeOU.InnerText End If Catch ex As Exception OUForADSite = Nothing End Try ''''''''Get Name Prefix'''''''' Try Dim XPathQueryNamePrefix = "/ADSiteToOUMappings/ADSite[SiteName='" & ADSite & "']/NamePrefix" Root = XMLDoc.DocumentElement Dim NodeNamePrefix As XmlNode = Root.SelectSingleNode(XPathQueryNamePrefix) If NodeNamePrefix Is Nothing Then Debug.Print("NULL") NamePrefix = Nothing Else Debug.Print(NodeNamePrefix.InnerText) NamePrefix = NodeNamePrefix.InnerText End If Catch ex As Exception NamePrefix = Nothing End Try ''''''''Create output XML'''''''' Try Dim XMLSettings As New XmlWriterSettings() XMLSettings.OmitXmlDeclaration = True XMLSettings.Indent = True XMLSettings.Encoding = Encoding.UTF8 Dim SW As New StringWriter() Dim Writer As XmlWriter = XmlWriter.Create(SW, XMLSettings) 'Writer.Create(SW, XMLSettings) Writer.WriteStartElement("Table") If OUForADSite IsNot Nothing Then Writer.WriteElementString("OSDDOMAINOUNAME", OUForADSite) If NamePrefix IsNot Nothing Then Writer.WriteElementString("NAMEPREFIX", NamePrefix) Writer.WriteEndElement() Writer.Flush() 'Debug.Print(SW.ToString) 'Making the output in proper XML format 'http://geekswithblogs.net/pakistan/archive/2005/08/09/49701.aspx Dim XMLOutput As New XmlDocument XMLOutput.LoadXml(SW.ToString) 'Debug.Print(XMLOutput.ToString) Return XMLOutput 'Return SW.ToString Catch ex As Exception Return New XmlDocument End Try End Function '''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' ''Code from Maik Koster with a few small tweaks ''http://www.myitforum.com/articles/43/view.asp?id=12513 _ Public Function GetSite(ByVal IPAddress As String) As String Dim Location As String = "" 'Debug.Print(IPAddress) Try For Each site As ActiveDirectorySite In Forest.GetCurrentForest.Sites For Each subnet As ActiveDirectorySubnet In site.Subnets 'Debug.Print(site.Name) If IPInSubnet(IPAddress, subnet.Name) Then Location = site.Name Exit For End If Next Next Catch ex As Exception End Try Return Location End Function '' <summary> '' The GetOverlappingADSubnets method returns a list of Active Directory Subnets '' which overlaps with subnets from other Active Directory sites '' </summary> '' <returns>List of overlapping Active Directory subnets</returns> '' <remarks></remarks> Public Shared Function GetOverlappingADSubnets() As List(Of String) Dim SubnetOverlaps As New List(Of String) Dim Sites1 As New List(Of ActiveDirectorySite) Dim Sites2 As New List(Of ActiveDirectorySite) For Each Site As ActiveDirectorySite In Forest.GetCurrentForest.Sites Sites1.Add(Site) Sites2.Add(Site) Next For Each site1 As ActiveDirectorySite In Sites1 For Each site2 As ActiveDirectorySite In Sites2 For Each Subnet1 As ActiveDirectorySubnet In site1.Subnets For Each Subnet2 As ActiveDirectorySubnet In site2.Subnets If site1.ToString site2.ToString AndAlso SubnetOverlapsSubnet(Subnet1.Name, Subnet2.Name) Then If Not SubnetOverlaps.Contains(site2.ToString & ":" & Subnet2.Name & " - " & site1.ToString & ":" & Subnet1.Name) Then SubnetOverlaps.Add(site1.ToString & ":" & Subnet1.Name & " - " & site2.ToString & ":" & Subnet2.Name) End If End If Next Next Next Next Return SubnetOverlaps End Function '' <summary> '' The IPInSubnet method checks if an IP Address is within a specified IP Subnet. '' </summary> '' <span name="IPAddress" class="mceItemParam"></span>IP Address '' <span name="subnet" class="mceItemParam"></span>IP Subnet '' <returns>Returns True if the IP Address is within the subnet. False if not.</returns> '' <remarks></remarks> Public Shared Function IPInSubnet(ByVal IPAddress As String, ByVal subnet As String) As Boolean Dim Result As Boolean = False Try Dim SplittedSubnetName As String() = subnet.Split(CChar("/")) If SplittedSubnetName.Length = 2 Then Dim IPAddressInDecimal As Long = IPToDecimal(IPAddress) Dim SubnetmaskBits As Integer = Integer.Parse(SplittedSubnetName(1)) Dim NoOfAddresses As Integer = Convert.ToInt32(Math.Pow(2, (32 - SubnetmaskBits)) - 1) Dim LowIPAddress As Long = IPToDecimal(SplittedSubnetName(0)) Dim HighIPAddress As Long = LowIPAddress + NoOfAddresses Dim TotalIPAddressCount As Long = (Convert.ToInt64(Math.Pow(2, 31))) - 1 If LowIPAddress <= IPAddressInDecimal AndAlso IPAddressInDecimal <= HighIPAddress AndAlso NoOfAddresses <= TotalIPAddressCount Then Result = True End If End If Catch ex As Exception End Try Return Result End Function '' <summary> '' The SubnetOverlapsSubnet method will check if two given subnets overlap or collide with each other. '' It will return a list of Site/Subnet combinations which overlap '' </summary> '' <span name="Subnet1" class="mceItemParam"></span>First Subnet - Format 192.168.20.0/24 '' <span name="Subnet2" class="mceItemParam"></span>Second Subnet to check against - Format 192.168.20.0/24 '' <returns>List of Site/Subnet combinations of colliding subnets</returns> '' <remarks></remarks> Public Shared Function SubnetOverlapsSubnet(ByVal Subnet1 As String, ByVal Subnet2 As String) As Boolean Dim result As Boolean Try Dim splittedsubnetname1 As String() = Subnet1.Split(CChar("/")) Dim splittedsubnetname2 As String() = Subnet2.Split(CChar("/")) Dim SubnetmaskBits1 As Integer = Integer.Parse(splittedsubnetname1(1)) Dim SubnetmaskBits2 As Integer = Integer.Parse(splittedsubnetname2(1)) Dim NoOfAddresses1 As Integer = Convert.ToInt32(Math.Pow(2, (32 - SubnetmaskBits1)) - 1) Dim NoOfAddresses2 As Integer = Convert.ToInt32(Math.Pow(2, (32 - SubnetmaskBits2)) - 1) Dim LowIPAddress1 As Long = IPToDecimal(splittedsubnetname1(0)) Dim LowIPAddress2 As Long = IPToDecimal(splittedsubnetname2(0)) Dim HighIPAddress1 As Long = LowIPAddress1 + NoOfAddresses1 Dim HighIPAddress2 As Long = LowIPAddress2 + NoOfAddresses2 If (LowIPAddress1 HighIPAddress2) Then result = False Else result = True End If Catch ex As Exception End Try Return result End Function '' <summary> '' the IPToDecimal method converts an IP Address into its decimal representation '' </summary> '' <span name="IPAddress" class="mceItemParam"></span>IP Address '' <returns>Decimal representation of the IP Address</returns> '' <remarks></remarks> Public Shared Function IPToDecimal(ByVal IPAddress As String) As Long 'Dim IPAddresses As String() = IPAddress.Split(".") Dim IPAddresses As String() Try IPAddresses = IPAddress.Split(CChar(".")) Return Convert.ToInt64(((Int32.Parse(IPAddresses(0)) * Math.Pow(2, 24) + _ (Int32.Parse(IPAddresses(1)) * Math.Pow(2, 16) + _ (Int32.Parse(IPAddresses(2)) * Math.Pow(2, 8) + _ (Int32.Parse(IPAddresses(3)))))))) Catch ex As Exception Return 0 End Try End Function '''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' End Class









