|
|||||||||||||||||||||
|
|||||||||||||||||||||
|
Announcements
Want a new Job?
Chapters
Services
Feature Zones
|
IntroductionI've been doing Windows programming for a long time, and I've always longed for the ability to do what some of the Unix guys have always been able to do: run commands on a remote machine. Although, there's a small twist. I really don't want to run some shell commands on a remote machine, but rather write some code, push it over to a remote machine, and have it execute over there without manually copying the files over. Not only that, but I'd like to interact with the process locally with standard in, standard out and standard error. What this will allow me to do is to write some administrative code and run the assembly on the remote machine(s) without any special hooks into my admin code. As well, if I have some process that will take a bunch of time to run, I can simply have it run on another machine, and report back the status and results without consuming time on my own machine. The latter approach leads to a more general subject of viewing any machine as a general computing resource. Quick Example (output from the image above)The image above is the output of an assembly that simply functions as a .NET version of "DIR". Basically, a search was issued for *.mp3 on the local machine and the results were displayed. Next, the search was issued in parallel to all the machines that matched the name "testwin*". In this case, two machines matched that name. I ran the second one just to show that it could be run explicitly as well. Give it a try!If you'd like to test it out, download the demo project from the link above into a folder. Here's the usage: Copyright (C) 2004-2005 Jim Wiese
Executes the specified assembly on the remote machine and the remote
Possible uses for this library / tool
Sweetness, how does it work?The main premise of the process is quite simple, but with a couple of small "gotchas". First, the process creates a temporary Windows service on the remote machine with a random name. I say random in the sense that a GUID is appended to the service name. This service creates a child process that will execute the specified assembly. The service waits for the child process to finish executing the assembly, then removes itself (i.e., the Service entry) from the remote machine, and exits. The interesting premise comes in two different steps:
Pushing the executable to run the temp serviceHere's the first of the two "gotchas" from above: the service code is copied over to the UNC folder file://remoteMachine/admin$/temp. Windows exposes the administrative share named Admin$ by default, and we make use of this share to get the executable over to the remote machine. We could have created a service that used \\sourceMachine\Admin$\temp (i.e., the machine from which the command was run), but any .NET code that is run under a UNC uses a much more restrict security policy. These temporary files will be removed when the entire process is finished. As a matter of fact, it goes to great length to try to make sure that the files are removed once all is finished. The one main thing to keep in mind is that the sole purpose of the service is to create a remote process. Some people refer to this as a hack method of starting processes, but in this case, I consider it a feature. But what about the assembly that I want you to run?The next important thing to consider is that we have not actually copied the assembly that is going to be run over to the remote machine. This is where a small piece of elegance comes in the code. What the service shim does is attempt to load "YourAssembly". Since this assembly does not exist on the remote machine, a Loader exception will occur. There is a handy little event that is fired when this happens in an AppDomain named: /// <summary>
/// Event that is called when the domain can not find an assembly.
/// We want to lookup this assembly on the remote machine and return it.
/// </summary>
/// <param name="sender">The current AppDomain</param>
/// <param name="args">Event arguments for this event</param>
/// <returns>The assembly from the remote machine
/// or null if it wasn't found</returns>
private Assembly CurrentDomain_AssemblyResolve(object sender,
ResolveEventArgs args)
{
return ResolveAssemblyOnRemoteMachine( args.Name ) ;
}
This event gives the assembly name that was attempted to be loaded in the arguments, and allows the block of code a chance to locate it and return it. In this case, a small block of code is put in to connect back to the source machine and request the lookup on that machine. Since the code does exist on the source machine, the bytes of the assembly are packaged up and sent back to the remote machine. The assembly is then loaded from these bytes and returned to the AppDomain loader. /// <summary>
/// Resolve the assembly from the remote machine (machine from which the
At this point, you'll notice that I referenced a small variable named " Once the assembly that you requested to be run is loaded, the EntryPoint is invoked with any command line arguments that you might want to pass in. Typically, the EntryPoint is the " Now, your executing assembly might have also needed some other dependencies. If it references those dependencies, won't it cause a problem? Thankfully, the answer is no, it won't cause a problem. Any missing dependencies cause the "Down by the water, out by the sea..." (??? grunge, circa 1993)Now, if you've made this far in the article, I'm impressed and honored. Let's not delay and delve into the depths of the nitty and the gritty of the code: All the code starts in the project RunRemote in the file RunRemote.cs in the class RemoteProcess remote = new RemoteProcess() ;
int exitCodeOfRemoteProcess =
remote.ExecuteRemotely(
"YourAssembly", // Note: partial assembly name
new string[] { "-arg1", "-arg2" },
new string[] { @"\\onMyMachine" },
0,
0,
false,
new TimeSpan( Timeout.Infinite ),
1 ) ;
//
// NOTE: There are some other overloaded versions of this method with
// more parameters, see the source code for details
//
Now, I've described before that a file is copied and a service is created. I'm not going to outline the code to copy files and create the service, but I'll give some specifics that are interesting. First, the output of the project "ServiceOnRemoteMachine" generates "ServiceOnRemoteMachine.exe". Take a wild guess at what this file is used for :). After this file is copied to the remote machine, the service is created with the file name of "%SYSTEMROOT%\temp\ServiceOnRemoteMachine.exe". This path name is the resolved value of the mapped path \\remoteMachine\admin$\temp\ServiceOnRemoteMachine.exe, but with a local reference (e.g., C:\Windows\temp\ServiceOnRemoteMachine.exe). As well, the service name of the service that is running is passed as an argument as well as the remoting URI to connect back to the source machine. The last argument is the name of the assembly to load (i.e., the name of your assembly). For example, the path for the service might be: "C:\WINDOWS\temp\serviceonremotemachine.exe" -fromService
ExecRemote-e2088b28-330f-4ac6-9627-799ddd435004
"tcp://dublin:3237/83c1ca40_8305_4583_bf5b_9265a03d9de4/RunRemote.rem"
"runthisonothermachine"
When the service first starts up in the Process and AppDomain isloation for safety (Injecting the safe way)
Now, in the new child process, a remoting object named " //
// Setup the formatters
//
TcpChannel channel = new TcpChannel() ;
ChannelServices.RegisterChannel( channel );
// Create an instance on the remote server and call a method remotely
server = (Server)Activator.GetObject( typeof ( Server ), // Type to create
serverUri );
The whole rest of the code and purpose for the entire class comes to the final method. This method loads up the assembly, hooks up
/// <SUMMARY>
/// Believe it or not, the entire application reduces to this one method. By
/// this point, we are running in a process on the remote machine
/// </SUMMARY>
/// The name of the assembly to execute. This name
/// can be either a partial name such as "RunThisOnOtherMachine" or a fully
/// qualified name.
///
/// Arguments to send to the main method, or null for no args
/// <RETURNS>The return result from the entry
/// point of the requested assembly</RETURNS>
public object LoadAssemblyAndRunIt( string assemblyName, string[] args )
{
//
// Setup the readers/writers to use the server's streams. This will allow
// this process to read and write to the console of the process on the
// machine from which the command was run.
//
TextWriter stdErr = AssemblyResolutionServer.StdErr ;
TextWriter stdOut = AssemblyResolutionServer.StdOut ;
TextReader stdIn = AssemblyResolutionServer.StdIn ;
m_sponsorManager.Register( stdErr );
m_sponsorManager.Register( stdOut ) ;
m_sponsorManager.Register( stdIn ) ;
Console.SetError( new NoExceptionTextWriter( stdErr ) ) ;
Console.SetOut( new NoExceptionTextWriter( stdOut ) ) ;
Console.SetIn( stdIn ) ;
//
// Setup the event handlers for the domain
//
AppDomain.CurrentDomain.ResourceResolve
+= new ResolveEventHandler( CurrentDomain_ResourceResolve );
AppDomain.CurrentDomain.AssemblyResolve
+= new ResolveEventHandler( CurrentDomain_AssemblyResolve );
AppDomain.CurrentDomain.DomainUnload
+= new EventHandler(CurrentDomain_DomainUnload);
//
// Get any required unmanaged dependencies
//
RetrieveUnmanagedDependencies() ;
Assembly assemblyToExecute = AppDomain.CurrentDomain.Load(
assemblyName ) ;
object result = null ;
//
// Finally, execute the assembly
//
try
{
if ( assemblyToExecute != null )
{
if ( assemblyToExecute.EntryPoint.GetParameters().Length == 0 )
{
result =
assemblyToExecute.EntryPoint.Invoke( null, null ) ;
}
else
{
result =
assemblyToExecute.EntryPoint.Invoke( null,
new object[]{ args } ) ;
}
}
}
catch( Exception exp )
{
Trace.WriteLine( exp.Message + "\n" + exp.StackTrace, "Error" ) ;
if ( exp.InnerException != null )
{
Trace.WriteLine( exp.InnerException.Message + "\n" +
exp.InnerException.StackTrace, "Error" ) ;
}
}
finally
{
Console.Error.Flush() ;
Console.Out.Flush() ;
}
return result ;
}
What about console events?Okay, this was a pain in the rear to implement, but console events, generally CTRL-C, is used to interact with the process. When remoting the process, these events need to be remoted as well. There is a background thread on each of the machines that waits for events from the server. The console application traps any console events, passes them in parallel to each remote application, and regenerates those events on the remote machine. This is particularly painful since this is all being run underneath a service. By default, services don't have consoles associated with them. Well, one might claim this is a fairly simple endeavor, simply create a console with "Confession is the road to healing..." (DC Talk, circa 1994)Now, at this point, some of the dirty laundry must be aired in order for my conscience to feel good. There are obviously some security ramifications of this process as well as the fact that it doesn't handle any PInvoked methods via the loading mechanism. We'll tackle each of these subjects one at a time: Security??!!Security, you say? Well, first I should mention that in order to copy files into the Admin$ share of the remote machine, you must be in Administrators group of the remote machine. Therefore, by default, hopefully, not everyone in the world is an Administrator of the remote machine. This is enforced by Windows default NTFS permissions. The second security issue is the identity of which the remote process runs. Since the process is running as a service, it is running under the "LOCALSYSTEM" account. I can hear the gaffe all the way to Dublin, CA. Running code under this account can be problematic in the sense that the "NT AUTHORITY\SYSTEM" account can do just about everything on the remote system. If your code does something that you didn't want it to do, you'll have to live with the consequences. I did look into the ability to impersonate the current user on the source machine in the process on the remote machine, and it is all feasible, but beyond my current allotment of time for this article. If you have some time and would like to investigate it, refer to the article on MSDN. The third and rather more obscure security issue is the fact that there isn't any authentication between the remote and source machine in the sense that the whole process could be liable to a man-in-the-middle attack. As well, there are moderate ways to transparently resolve this but they were beyond the time allotted for this article. On the other hand, I have in mind to integrate some 3rd party components to solve this problem such as the Genuine Channels components to handle all the remoting infrastructure. Keep posted for more details. Managing the UnmanagedGee, doesn't that title sound like something from a management seminar: "I will manage the unmanaged masses!" Anyway, there is a chance that you may have unmanaged method calls in your assembly. These calls will not proxy over the libraries from the source machine, but rather call the library from the local machine. If you're calling one of the system libraries, then this is actually a good thing. However, if you're making a call to a custom or third party unmanaged library, you must make sure it exists on the remote machine first. The only caveat to this is that there is an option to send over the unmanaged DLLs or any necesary files that are needed for your project. First zip them up into a zip file, then on the command line specify the file name with the argument:
Nothin' but .NET (or lack thereof)Lastly, if .NET is not installed on the remote machine, the process will fail. The correct version of the framework must be installed on the remote machine for the code to run. Again, I considered a loader that would detect if the correct version of .NET was installed and install it if it wasn't there; all of which is possible. Yet another thing I didn't exactly have time for :) However, I have in mind a bootstrapper project that will install .NET on the remote machine before the shim process is run. Keep posted for more details. DisclaimerThis article and the accompanying code are provided as-is. You may use it as you please. You may not hold me liable for any damage caused to you, your company, your neighbors or anyone else, as a result of reading this article or using the code. Whatever you do with this article and the accompanying code is at your own risk. Version History1.3 - Feb 28th, 2005
1.2 - Jan 22nd, 2005
1.1 - Dec 29th, 2004
1.0 - Dec 10th, 2004
Special RecognitionI would like to thank Mark Russinovich for his article on Psexec which inspired me to write this article. As well, I'd like to thank Suzanne Cook for her blogs on the .NET runtime loader which was a good reference for some very difficult issues. Lastly, but not least, I'd like to thank everyone who contributes to PInvoke.net from which I get most of my PInvoke method definitions (such as those to create services on Windows). In Memory of Don LangewischI'd like to humbly dedicate this article to the memory of Don Langewisch (1955? - Dec. 10th, 2004). I received the news of his passing while finalizing this article. Don, a loving and dedicated man of God leaves behind a wife, two daughters and a son. | ||||||||||||||||||||