Saturday, April 11, 2009

Programmatically renaming a computer using C#

Recently I was presented with an interesting problem: to develop an application that would rename multiple computers for the end user.

The user requirements were straight-forward enough:
1) Display a list of all computers in active directory from the SMS database.
2) Allow the user to select multiple computers and assign their new names.
3) Rename all these computers.

However, the technical requirements and subsequent architecture were much more involved. The tool required the ability to rename multiple computers at once, and had to do all the operations in-process so as to return error information to the user. After some googling, I figured the easiest way to accomplish this was to spawn multiple threads, each of which would run a separate computer renaming operation, then inform the user to reboot their machine if the renaming was successful.

The easiest way to create the multiple threads was to use a ThreadPool, and then load it up with all the renaming operations as individual work items. This part was simple enough, because there was no need to coordinate between the multiple threads. The program only had to wait until all the threads had finished running.

The challenge came in renaming the machines in-process. The easiest way to do this would be through directory services, but I could not determine how to get the correct information from the SMS databases to put together the proper LDAP URL with OUs. In addition, for Directory Services to work on a machine, it must be at least XP service pack 3 level, which my IT department could not guarantee for all machines in the company. So after many days of painful googling and experimentation, I came up with a 3 step approach:
1) Add a new local user to the computer to be renamed and make it part of the local administrators group using Directory Services.
2) Rename the active directory object corresponding to the computer to the new name via Directory Services.
3) Unjoin the computer from the domain, than use the local user from step 1 to rename it and rejoin it to the domain, all with WMI (Windows Management Instrumentation).

Why the 3 step approach? Since WMI works with Windows operating systems below XP service pack 3, it was the required choice for the renaming portion. However, the WMI renaming bit works by remotely invoking the Rename method of the target computer’s local Win32_ComputerSystem object, and that method only runs if the computer is unjoined to the domain. Therefore, in order to call the Rename method after unjoining the computer from the domain, the WMI ManagementObject must connect and authenticate to the target computer using a local administrator, hence the need for step 1. To rejoin to the domain after the renaming operation, the application needs a domain user account that has permissions to join machines to active directory, and the target computer must find an active directory object that matches its name. Hence the need for step 2.

Enough of the high level explanation of how the tool will work; after all, I’m sure if you really are reading this blog, you are looking for source code, right? Here’s the source for step 1:

public Boolean addUserByDirectoryServices(String machineName, String pcAdministrator, String pcAdministratorPassword)
{
Boolean rc = true;

try
{
String connString = "WinNT://" + machineName;

using (DirectoryEntry de = new DirectoryEntry(connString, pcAdministrator, pcAdministratorPassword))
{

//if (de.Children.Find(m_PcAdministrator) != null)
//{
// de.Close();
// de.Dispose();
// return true;
//}

DirectoryEntry user = de.Children.Add(m_PcAdministrator, "user");
user.Invoke("SetPassword", new Object[] { m_PcAdministratorPassword });
user.CommitChanges();

de.RefreshCache();

DirectoryEntry adminGroup = de.Children.Find("Administrators", "group");
if (null != adminGroup)
{
adminGroup.Invoke("Add", new Object[] { user.Path.ToString() });
}

de.Close();
de.Dispose();

}

rc = true;


}
catch (Exception e)
{
String msg = e.Message;
m_error_msg += "Adding local user error: " + msg;
String stacktrace = e.StackTrace;
m_stacktrace += "\nAdding local user dump: " + stacktrace;
}

return rc;

}

In the beginning, you will notice I use the URL WinNT:// as opposed to LDAP:// to locate the machine via Directory Services. This is because I could not compute the proper LDAP query string. You will also notice I commented out a check to determine if I already added the user, and just catch the exception. I did this because I found that the check to see if the user already existed always threw an exception, whether the user existed or not. Therefore, in using this method, just catch exceptions that are thrown and ignore them, or display them to the user. I also encapsulate the DirectoryEntry object representing the target machine in an using block, and call its Dispose() method at the end because before, I was constantly getting errors stating I had multiple connections open on the target machine, which were not supported. Those errors can also be ignored it you get them; they bear no significance as to whether the new local user was created or not.

For step 2, here is the code to rename the object in active directory, also using the Directory Services methods and API:

public Boolean renameMachineByDirectoryServices(String oldname, String newname, String administrator, String administratorPassword)
{


Boolean rc = true;

try
{
DirectoryEntry machineNode = null;
machineNode = new DirectoryEntry("WinNT://" + oldname);
machineNode.Username = administrator;
machineNode.Password = administratorPassword;
machineNode.AuthenticationType = AuthenticationTypes.Secure;
machineNode.Rename("CN=" + newname);
machineNode.CommitChanges();


}
catch (Exception e)
{
String msg = e.Message;
String stacktrace = e.StackTrace;
}

return rc;


}

This was a simple and straightforward method to code. All it did was rename the object in active directory. Also, any exceptions thrown can be ignored; they have no bearing on whether the operation was successful or not. If you don’t believe me, try it.

Finally, the code for step 3 was much more complicated:

public Boolean renameRemotePC(String oldName, String newName, String domain)
{

Boolean rc = true;

try
{

ManagementPath remoteControlObject = new ManagementPath();
remoteControlObject.ClassName = "Win32_ComputerSystem";
remoteControlObject.Server = oldName;
remoteControlObject.Path = oldName + "\\root\\cimv2:Win32_ComputerSystem.Name='" + oldName + "'";
remoteControlObject.NamespacePath = "\\\\" + oldName + "\\root\\cimv2";

ConnectionOptions conn = new ConnectionOptions();
conn.Authentication = AuthenticationLevel.PacketPrivacy;
conn.Username = oldName + "\\" + m_PcAdministrator;
conn.Password = m_PcAdministratorPassword;

ManagementScope remoteScope = new ManagementScope(remoteControlObject, conn);

ManagementObject remoteSystem = new ManagementObject(remoteScope, remoteControlObject, null);

ManagementBaseObject outParams;

ManagementBaseObject unjoinFromDomain = remoteSystem.GetMethodParameters("UnjoinDomainOrWorkgroup");
unjoinFromDomain.SetPropertyValue("Password", m_domain_admin_password);
unjoinFromDomain.SetPropertyValue("UserName", m_domain_admin);
outParams = remoteSystem.InvokeMethod("UnjoinDomainOrWorkgroup", unjoinFromDomain, null);

ManagementBaseObject newRemoteSystemName = remoteSystem.GetMethodParameters("Rename");
InvokeMethodOptions methodOptions = new InvokeMethodOptions();

newRemoteSystemName.SetPropertyValue("Name", newName);
newRemoteSystemName.SetPropertyValue("UserName", m_PcAdministrator);
newRemoteSystemName.SetPropertyValue("Password", m_PcAdministratorPassword);

methodOptions.Timeout = new TimeSpan(0, 10, 0);
outParams = remoteSystem.InvokeMethod("Rename", newRemoteSystemName, null);

ManagementBaseObject joinFromDomain = remoteSystem.GetMethodParameters("JoinDomainOrWorkgroup");
joinFromDomain.SetPropertyValue("Name", domain);
joinFromDomain.SetPropertyValue("Password", m_domain_admin_password);
joinFromDomain.SetPropertyValue("UserName", m_domain_admin);
joinFromDomain.SetPropertyValue("FJoinOptions", 1);
outParams = remoteSystem.InvokeMethod("JoinDomainOrWorkgroup", joinFromDomain, null);

}
catch (ManagementException MgEx)
{
String mgs = MgEx.Message;
String coredump = MgEx.StackTrace;
m_error_msg += "\nRenaming PC Error: " + mgs;
m_stacktrace += "\nRenaming PC dump: " + coredump;
}
catch (Exception e)
{
String mgs = e.Message;
String coredump = e.StackTrace;
m_error_msg += "\nRenaming PC Error: " + mgs;
m_stacktrace += "\nRenaming PC dump: " + coredump;
}

return rc;

}

The first piece of this method was creating a ManagementObject that could connect to the target computer and instantiate an object of class Win32_ComputerSystem on that machine. The first thing I learnt was that in order to do so, the ConnectionOptions had to be set to use an authentication level of PacketPrivacy. This was the only way I was allowed to connect to another machine using WMI. After that, it was just a matter of instantiating ManagementBaseObjects for the required methods: UnjoinDomainOrWorkgroup, Rename, and JoinDomainOrWorkgroup. The required parameters and recommended values you can find in the code, so I won’t go into too much detail here, except that once again, exceptions can be safely ignored. Don’t ask me why, but they can.

All in all, I felt this application was a great way to learn about different aspects of IT administration and how to automate them. It was my first exposure to WMI and Directory Services development, and given the increasing focus on security nowadays, probably not my last. As a wrap up, I felt this assignment was also a good application of software engineering principles, as I had to gather the user requirements and technical requirements on my own, then architect and build the solution, test it, and deploy it.

The class that contains the methods above, which I called WMIWrapper, can be found in my codeplex project http://www.codeplex.com/tv. Of course these methods can be used in any IT environment, but so far I have only been asked to do this by a television broadcaster.