Introduction
I'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:
Collapse
Copyright (C) 2004-2005 Jim Wiese
Executes the specified assembly on the remote machine and the remote
application will function interactively.
Usage: \\machine[,\\machine2,[...]] [-port portNumber] [-serial]
[-interactive] [-timeout [days.]HH:MM:SS] [-remoteUserId userId]
[-remotePassword password] [-unmanaged pathToZipFile.zip]
[-debug] assembly [arguments]
machine: The machine to run the assembly on. Note that this value can have
the '*' character to represent multiple machines. As well, \* will match
all the machines in the domain. (eg HP* -> HP1, HP2)
port: The port number to use for the communication between processes
assembly: The assembly name to run on the remove machine. This assembly
must have an entry point defined (ie Main).
arguments: Any arguments to pass to the executed assembly.
debug: Write debugging information to the standard out.
serial: Run the machines serially, one at a time. By default, all the
machines are run in parallel.
interactive: Allow the process to interact with the desktop process
timeout: Allow the process to run for the specified amount of time
remoteUserId: The user ID to run under on the remote process
remotePassword: The password for the remote user ID
unmanaged: The path to a zip file with the unmanaged resources to
decompress on the remote machine
Possible uses for this library / tool
- Run-time deployment of Application servers (eg for business logic) to a specific set of machines
- Run-time deployment of web servers to a specific set of machinesPerform administrative functions on Windows Domain clients such as run time installation of software
- Determine if specific files are stored on Windows machines without the need for UNC shares (eg. C:\> runremote \\* quickdir.exe *.mp3)
- Create an on demand "Peer Grid" to solve problems such as sorting a large file before import into a database
- Force GPO updates on the client machines immeadiately
- Determine if someone is actually logged onto a console of a machine
- Log people off the console of the machine if they're logged in :) (eg for Kiosk purposes)
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:
- How does the service get the executable to run?
- How are the assemblies shuffled over to the remote machine?
Pushing the executable to run the temp service
Here'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:
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.
Collapse
Assembly ResolveAssemblyOnSourceMachine( string assemblyName )
{
byte[] assemblyBytes = server.GetAssembly( assemblyName ) ;
if ( assemblyBytes != null && assemblyBytes.Length > 0 )
{
Assembly loaded = Assembly.Load( assemblyBytes ) ;
return loaded ;
}
else
{
returnnull ;
}
}
At this point, you'll notice that I referenced a small variable named "server
" which seems to get the bytes of the assembly. This "server" variable is actually a .NET Remoted object that is being served up by our source machine. There are probably a variety of ways that this could be done, but the .NET remoting method was too clean and easy to pass up.
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 "Main
" of your assembly. After your Main( string[] args )
is run, the return code is reported back to the source machine and the process on the remote machine exists. To appear like a local process, the "runremote.exe" process on the source machine exists with the same return code of the remote process so that you can use shell utilities to check the return code.
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 ResolveAssembly
event to be called and loaded by the same mechanism.
"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
. This is the main project if you'd like to run it programmatically, but if you're running the command version, then it's the class CommandLineRemoteProcess
which actually calls RemoteProcess.ExecuteRemotely
. Basically, it's all fairly simple to call from the outside. Here's the sample code to actually run an assembly remotely:
RemoteProcess remote = new RemoteProcess() ;
int exitCodeOfRemoteProcess =
remote.ExecuteRemotely(
"YourAssembly", newstring[] { "-arg1", "-arg2" },
newstring[] { @"\\onMyMachine" },
0,
0,
false,
new TimeSpan( Timeout.Infinite ),
1 ) ;
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 Main()
method, the argument "-fromService" is noted. This tells the service to spawn a new process using itself as the executable, but removing the service name and the "-fromService" argument.
Process and AppDomain isloation for safety (Injecting the safe way)
Now, in the new child process, a remoting object named "Server
" is hooked up by the Activator and uses the URI to get back to the source machine.
TcpChannel channel = new TcpChannel() ;
ChannelServices.RegisterChannel( channel );
server = (Server)Activator.GetObject( typeof ( Server ),
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 STDIN
, STDOUT
, STDERR
, and runs the sucker.
Collapse
public
object LoadAssemblyAndRunIt( string assemblyName, string[] args )
{
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 ) ;
AppDomain.CurrentDomain.ResourceResolve
+= new ResolveEventHandler( CurrentDomain_ResourceResolve );
AppDomain.CurrentDomain.AssemblyResolve
+= new ResolveEventHandler( CurrentDomain_AssemblyResolve );
AppDomain.CurrentDomain.DomainUnload
+= new EventHandler(CurrentDomain_DomainUnload);
RetrieveUnmanagedDependencies() ;
Assembly assemblyToExecute = AppDomain.CurrentDomain.Load(
assemblyName ) ;
object result = null ;
try
{
if ( assemblyToExecute != null )
{
if ( assemblyToExecute.EntryPoint.GetParameters().Length == 0 )
{
result =
assemblyToExecute.EntryPoint.Invoke( null, null ) ;
}
else
{
result =
assemblyToExecute.EntryPoint.Invoke( null,
newobject[]{ 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 AllocConsole()
and move on. It just so happens that child processes share the same console as the parent process. In this scenario, the temporary service shares the console with the child process performing the work. If we generate the console event, then it will actually send it to the service as well. So in order to handle all of this, the service itself will not respond to any console events (i.e., CTRL-C, CTRL-BREAK, etc.). This is not a big deal since we don't want the user interacting with this process anyway. None-the-less, you'll see a DoNothithWithConsoleEvent()
handler routine that has a fairly self explanatory name.
"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 Unmanaged
Gee, 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:
-unmanaged myFiles.zip
... Or if your calling this method programatically, specify this file in the ExecRemote command under the "unmanaged" parameter:
Collapse
public
int ExecuteRemotely(
string assemblyToRun,
string[] argsForMain,
string[] machineNames,
int port,
int debugLevel,
string remoteUserId,
string remotePassword,
bool interactive,
TimeSpan allowedTime,
int numberOfThreads,
string unmanagedDependencies,
string outputFolder,
TextWriter consoleStdOut,
TextWriter consoleStdErr,
TextReader consoleStdIn,
BeforeRunOnMachineDelegate optionalBeforeHandler,
AfterRunOnMachineDelegate optionalAfterHandler,
object optionalSource )
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.
Disclaimer
This 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 History
1.3 - Feb 28th, 2005
- Added ability to send unmanaged code / resources to the remote machine. Basically, you can send legacy DLLs or any other resouce (ie plain old files) to the remote machine. All you need to do is zip up all the resources and supply a zip file on the argument "-unmanaged myfiles.zip" to the command line or to the "unmanaged" argument for ExecRemote() method.
- Added ability to pull back any modified files on the remote machine. If you modify any files during the processing of your code, then those files can be pulled back and placed in a directory as the same name of the remote machine.
- Added a couple of extra checks to make sure that the current user has Administrative access to the remote machine and the remote resources are cleaned up in the end. Special thanks to the people at ICSharpCode.net for this functionality (http://www.icsharpcode.net/)
- Added ability to redirect the output / input of the remote machine to a custom TextWriter / TextReader. A number of people had requested this feature, so I finally added it to the main method. However, by default, all the remote output/input are redirected to/from Console.StdOut, Console.StdIn, Console.StdErr.
- Fixed problem of running process on a source machine with multiple IP addresses. When using remoting and TCP, the source machine uses the "first" avilable IP address on that machine. For example, if you have two IP addresses "192.168.0.14" and "10.11.0.5", and your network is generally running on the 10.x.x.x network, the Uri for the remoting object is actually given out as tcp://192.168.0.14/YourRemoteObject.rem. I had a hard time believing this until I actually saw it in the debugger, but you can check out the article at http://www.glacialcomponents.com/ArticleDetail/CAOMN.aspx for more details.
1.2 - Jan 22nd, 2005
- Added ability to use a .NET configuration file that will be remoted (ie YourApp.exe.config will get proxied over)
- Added ability to use a license file (ie license.txt)
- Fixed problem with launching a program that defined main as Main() instead of Main( string[] args ). It will now function either way.
- All user assembly code is now loaded in a sepate AppDomain in the child process
1.1 - Dec 29th, 2004
- Added usage for '-interactive' to allow application to interact with the desktop.
- Added the ability to issue a CTRL-C, CTRL-BREAK, etc. which will be echoed to all the remote applications.
- Added ability to use wildcards for the machine names(s) (e.g., \\testwin* will match \\testwin2k and \\testwinxp via LDAP lookup).
- Added ability to timeout on the remote application.
- Created a more useful demonstration application, quickdir.exe. This application will basically do a "dir" on the remote machine.
1.0 - Dec 10th, 2004
Special Recognition
I 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 Langewisch
I'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.
About Jim Wiese (aka Spunk)
|
I generally attend most of the Microsoft DevDays in the south bay area (CA) and BayArea.NET functions in case any of you attend those as well. I'm always up for a lively disucussion about new technologies in the industry, Microsoft or not. Send me a note if you attend!
Click here to view Jim Wiese (aka Spunk)'s online profile. |