在远程计算机上推送和运行 .NET 代码






4.83/5 (78投票s)
2004年12月11日
15分钟阅读

377754

10981
在没有特殊钩子的情况下在远程计算机上执行程序集 - 对现有程序集有效!
引言
我从事 Windows 编程已经很久了,一直以来我都渴望能够像一些 Unix 用户那样:在远程计算机上运行命令。不过,这里有个小小的变通。我并不真的想在远程计算机上运行一些 shell 命令,而是想编写一些代码,将其推送到远程计算机上,然后在那里执行,而无需手动复制文件。不仅如此,我还希望能够通过标准的输入、输出和错误与本地进程进行交互。这将使我能够编写一些管理代码,并在远程计算机上运行程序集,而无需对我的管理代码进行任何特殊修改。此外,如果我有一个耗时很长的进程,我可以简单地让它在另一台计算机上运行,并报告状态和结果,而不会占用我自己的计算机资源。后一种方法导向了一个更广泛的主题,即视任何计算机为通用的计算资源。
快速示例(上方图片的输出)
上方的图片是一个程序集的输出,该程序集的功能相当于 ".NET 版本的 "DIR""。基本上,在本地计算机上搜索了 *.mp3,并显示了结果。接下来,搜索操作 **并行地** 发送到所有名称匹配 "testwin*" 的计算机上。在本例中,有两台计算机匹配该名称。我运行了第二台,以显示它也可以被显式运行。
试试看!
如果您想进行测试,请将演示项目从上面的链接下载到一个文件夹中。以下是使用方法:
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
此库/工具的可能用途
- 在运行时将应用程序服务器(例如用于业务逻辑)部署到特定的一组计算机。
- 在运行时将 Web 服务器部署到特定的一组计算机。在 Windows Domain 客户端上执行管理功能,例如在运行时安装软件。
- 确定 Windows 计算机上是否存储了特定文件,**无需 UNC 共享**(例如:C:\> runremote \\* quickdir.exe *.mp3)。
- 创建按需的“Peer Grid”,以解决诸如在导入数据库之前对大文件进行排序等问题。
- 强制客户端计算机立即更新 GPO。
- 确定某人是否已登录到某台计算机的控制台。
- 如果有人登录到某台计算机的控制台,则将其注销 :) (例如用于 Kiosk)。
太棒了,它是如何工作的?
该过程的主要前提相当简单,但有几个小小的“陷阱”。首先,该过程在远程计算机上创建一个临时的 Windows 服务,该服务具有一个随机名称。我之所以说随机,是因为服务名称后面附加了一个 GUID。此服务创建一个子进程,该子进程将执行指定的程序集。该服务等待子进程完成执行程序集,然后将其自身(即服务条目)从远程计算机中删除并退出。
有趣的前提包含两个不同的步骤:
- 服务如何获得要运行的可执行文件?
- 程序集是如何被传输到远程计算机的?
推送要运行的临时服务的可执行文件
这是上述两个“陷阱”中的第一个:“服务代码被复制到 UNC 文件夹 file://remoteMachine/admin$/temp。Windows 默认公开名为 Admin$ 的管理共享,我们利用此共享将可执行文件传输到远程计算机。我们可以创建一个使用 \\sourceMachine\Admin$\temp(即运行命令的计算机)的服务,但是任何在 UNC 下运行的 .NET 代码都使用更严格的安全策略。这些临时文件将在整个过程完成后被删除。事实上,它会竭尽全力确保文件在完成后被删除。
要记住的一个主要问题是,服务的唯一目的是创建一个远程进程。有些人称之为启动进程的 hack 方法,但在这种情况下,我认为它是一个特性。
但是我要你运行的程序集呢?
接下来要考虑的重要一点是,我们实际上还没有将要运行的程序集复制到远程计算机。这就是代码中的一个小的优雅之处。服务代理所做的是尝试加载“YourAssembly”。由于此程序集在远程计算机上不存在,因此会发生加载程序异常。当这种情况发生时,在名为 AppDomain 中会触发一个方便的小事件
/// <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 ) ;
}
此事件在参数中提供了尝试加载的程序集名称,并允许代码块有机会定位并返回它。在这种情况下,会插入一小块代码,以连接回源计算机并请求在该计算机上进行查找。
由于代码确实存在于源计算机上,因此程序集的字节被打包并发送回远程计算机。然后,程序集从这些字节加载并返回到 AppDomain 加载程序。
/// <summary>
/// Resolve the assembly from the remote machine (machine from which the
/// command was run). If the assembly exists on the remote machine, it
// will be shuffled over to this process and then loaded and returned.
/// </summary>
/// <param name="assemblyName">The name of the assembly to resolve</param>
/// <returns>The assembly if it existed, otherwise null</returns>
Assembly ResolveAssemblyOnSourceMachine( string assemblyName )
{
//
// This is the main point in which we shuffle
// the assembly from the server to this
// client. //
byte[] assemblyBytes = server.GetAssembly( assemblyName ) ;
if ( assemblyBytes != null && assemblyBytes.Length > 0 )
{
Assembly loaded = Assembly.Load( assemblyBytes ) ;
return loaded ;
}
else
{
return null ;
}
}
此时,您会注意到我引用了一个名为“server
”的小变量,该变量似乎获取了程序集的字节。“server”变量实际上是由我们的源计算机提供的 .NET Remoting 对象。可能有多种方法可以做到这一点,但 .NET Remoting 方法太干净方便,不容错过。
一旦加载了您请求运行的程序集,就会使用您可能要传递的任何命令行参数调用 EntryPoint。通常,EntryPoint 是您程序集的“Main
”。在运行了您的 Main( string[] args )
后,返回代码会报告回源计算机,并且远程计算机上的进程将退出。为了使其看起来像本地进程,“runremote.exe”进程在源计算机上的返回代码与远程进程相同,以便您可以使用 shell 工具来检查返回代码。
现在,您的执行程序集可能还需要其他依赖项。如果它引用了那些依赖项,这会不会引起问题?幸运的是,答案是不会,这不会引起问题。任何缺失的依赖项都会导致调用 ResolveAssembly
事件,并通过相同的机制进行加载。
“在水边,在海边…”(??? 垃圾摇滚,约 1993 年)
现在,如果您读到这里,我将感到荣幸和敬佩。让我们不要浪费时间,深入研究代码的细节。
所有代码都从 RunRemote 项目中的 RunRemote.cs 文件中的 RemoteProcess
类开始。如果您想以编程方式运行它,这是主项目;但如果您运行的是命令行版本,那么 CommandLineRemoteProcess
类实际上会调用 RemoteProcess.ExecuteRemotely
。基本上,从外部调用它非常简单。以下是实际远程运行程序集的示例代码:
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
//
我之前已经描述过,会复制一个文件并创建一个服务。我不会概述复制文件和创建服务的代码,但我会给出一些有趣的细节。首先,“ServiceOnRemoteMachine”项目的输出生成“ServiceOnRemoteMachine.exe”。猜猜这个文件是用来做什么的 :)。将此文件复制到远程计算机后,将使用文件名“%SYSTEMROOT%\temp\ServiceOnRemoteMachine.exe”创建服务。此路径名是映射路径 \\remoteMachine\admin$\temp\ServiceOnRemoteMachine.exe 的解析值,但具有本地引用(例如,C:\Windows\temp\ServiceOnRemoteMachine.exe)。此外,运行的服务名将作为参数传递,以及用于连接回源计算机的远程处理 URI。最后一个参数是要加载的程序集的名称(即您的程序集名称)。例如,服务的路径可能是:
"C:\WINDOWS\temp\serviceonremotemachine.exe" -fromService
ExecRemote-e2088b28-330f-4ac6-9627-799ddd435004
"tcp://dublin:3237/83c1ca40_8305_4583_bf5b_9265a03d9de4/RunRemote.rem"
"runthisonothermachine"
当服务首次在 Main()
方法中启动时,会注意到参数“-fromService”。这会告诉服务使用自身作为可执行文件来生成一个新进程,但会移除服务名称和“-fromService”参数。
进程和 AppDomain 隔离以确保安全(安全注入方式)

现在,在新创建的子进程中,一个名为“Server
”的远程对象被 Activator 挂钩,并使用 URI 连接回源计算机。
//
// 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 );
整个其余的代码和该类的目的都归结到最后一个方法。该方法加载程序集,挂钩 STDIN
、STDOUT
、STDERR
,然后运行它。
/// <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 ;
}
控制台事件怎么办?
好的,这个实现起来非常麻烦,但控制台事件,通常是 CTRL-C,用于与进程交互。在远程处理进程时,这些事件也需要被远程处理。每台计算机上都有一个后台线程,等待来自服务器的事件。控制台应用程序捕获任何控制台事件,将它们并行地传递给每个远程应用程序,并在远程计算机上重新生成这些事件。这尤其麻烦,因为这一切都在服务下运行。默认情况下,服务没有与之关联的控制台。有人可能会说这是一项相当简单的任务,只需使用 AllocConsole()
创建一个控制台即可。恰好子进程与父进程共享同一个控制台。在这种情况下,临时服务与执行工作的子进程共享控制台。如果我们生成控制台事件,它实际上也会将其发送到服务。因此,为了处理所有这些,服务本身不会响应任何控制台事件(即 CTRL-C、CTRL-BREAK 等)。这没什么大不了的,因为我们也不希望用户与此进程交互。尽管如此,您会看到一个 DoNothithWithConsoleEvent()
处理程序例程,它具有一个相当不言自明的名称。
“忏悔是通往治愈的道路…”(DC Talk,约 1994 年)
现在,此时,为了让我的良心感到安宁,必须揭露一些丑事。这个过程显然存在一些安全隐患,并且它不通过加载机制处理任何 PInvoked 方法。我们将逐一处理这些主题。
安全??!!
你说安全?嗯,首先我应该提到,为了将文件复制到远程计算机的 Admin$ 共享中,您必须是远程计算机的管理员组的成员。因此,默认情况下,希望不是每个人都是远程计算机的管理员。这由 Windows 默认的 NTFS 权限强制执行。
第二个安全问题是远程进程运行的身份。由于该进程作为服务运行,因此它在“LOCALSYSTEM”帐户下运行。我能听到 Dublin, CA 那边的惊叹声。在此帐户下运行代码可能会有问题,因为“NT AUTHORITY\SYSTEM”帐户几乎可以在远程系统上做任何事情。如果您的代码做了您不想让它做的事情,您将不得不承担后果。我确实研究了在远程计算机上的进程中模拟源计算机上的当前用户,这都是可行的,但超出了本文的当前篇幅。如果您有时间并想进行研究,请参阅 **MSDN 上的文章**。
第三个,也是一个更晦涩的安全问题是,远程机器和源机器之间没有进行任何身份验证,这意味着整个过程可能容易受到中间人攻击。同样,有适中的方法可以透明地解决这个问题,但它们超出了本文的篇幅。
另一方面,我计划集成一些第三方组件来解决这个问题,例如 **Genuine Channels** 组件来处理所有远程处理基础设施。请继续关注更多详细信息。
管理非托管代码
天哪,这个标题听起来不像管理研讨会上的内容:“我将管理那些非托管的民众!”总之,您的程序集可能包含非托管方法调用。这些调用不会从源计算机代理库,而是从本地计算机调用库。如果您调用的是系统库之一,那实际上是件好事。但是,如果您调用的是自定义的或第三方的非托管库,您必须确保它首先存在于远程计算机上。唯一的例外是,可以选择传输非托管 DLL 或项目所需的任何其他文件。首先将它们压缩成一个 zip 文件,然后在命令行中指定文件名和参数:
-unmanaged myFiles.zip
… 或者如果您以编程方式调用此方法,请在 ExecRemote 命令的“unmanaged”参数中指定此文件。
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 )
.NET 帝国(或者缺乏 .NET)
最后,如果远程计算机上未安装 .NET,则该进程将失败。远程计算机上必须安装正确版本的框架才能运行代码。同样,我考虑了一个加载器,它可以检测是否安装了正确版本的 .NET,如果未安装则进行安装;所有这些都是可能的。这又是另一件我没时间做的事情 :)。但是,我有一个引导程序项目,可以在运行 shim 进程之前在远程计算机上安装 .NET。请继续关注更多详细信息。
免责声明
本文及配套代码按原样提供。您可以随意使用。**您不得** 因阅读本文或使用代码而导致您、您的公司、您的邻居或任何其他人遭受任何损害而追究我的责任。您对本文及配套代码的使用风险自负。
版本历史
1.3 - 2005 年 2 月 28 日
- 添加了将非托管代码/资源发送到远程计算机的功能。基本上,您可以发送旧的 DLL 或任何其他资源(即普通文件)到远程计算机。您只需要将所有资源压缩到一个 zip 文件中,然后在命令行中提供 zip 文件,参数为“-unmanaged myfiles.zip”,或者为 ExecRemote() 方法提供“unmanaged”参数。
- 添加了将远程计算机上的任何修改过的文件拉回的功能。如果您在代码处理过程中修改了任何文件,那么这些文件可以被拉回并放置在以远程计算机同名命名的目录中。
- 添加了一些额外的检查,以确保当前用户对远程计算机具有管理员访问权限,并在最后清理远程资源。特别感谢 ICSharpCode.net 的人们提供的此功能(http://www.icsharpcode.net/)。
- 添加了将远程计算机的输出/输入重定向到自定义 TextWriter / TextReader 的功能。许多人请求此功能,所以我最终将其添加到了主方法中。但是,默认情况下,所有远程输出/输入都重定向到/来自 Console.StdOut、Console.StdIn、Console.StdErr。
- 修复了在具有多个 IP 地址的源计算机上运行进程的问题。使用 Remoting 和 TCP 时,源计算机使用该机器上“第一个”可用 IP 地址。例如,如果您有两个 IP 地址“192.168.0.14”和“10.11.0.5”,并且您的网络通常在 10.x.x.x 网络上运行,那么远程对象的 Uri 实际上会给出为 tcp://192.168.0.14/YourRemoteObject.rem。直到我在调试器中看到它,我才相信这一点,但您可以查看 **http://www.glacialcomponents.com/ArticleDetail/CAOMN.aspx** 上的文章了解更多详细信息。
1.2 - 2005 年 1 月 22 日
- 添加了使用 .NET 配置文件(将远程处理,例如 YourApp.exe.config 将被代理)的功能。
- 添加了使用许可证文件(例如 license.txt)的功能。
- 修复了启动将 main 定义为主函数而不是 Main( string[] args ) 的程序的错误。现在无论哪种方式都可以正常工作。
- 所有用户程序集代码现在都在子进程中的独立 AppDomain 中加载。
1.1 - 2004 年 12 月 29 日
- 添加了 '-interactive' 的用法,允许应用程序与桌面交互。
- 添加了发出 CTRL-C、CTRL-BREAK 等命令的能力,这些命令将回显到所有远程应用程序。
- 添加了对计算机名称使用通配符的功能(例如,\\testwin* 将通过 LDAP 查找匹配 \\testwin2k 和 \\testwinxp)。
- 添加了远程应用程序超时功能。
- 创建了一个更有用的演示应用程序 quickdir.exe。此应用程序基本上将在远程计算机上执行“dir”。
1.0 - 2004 年 12 月 10 日
- 初始版本。
特别致谢
我要感谢 **Mark Russinovich**(mark@sysinternals.com)的文章 **Psexec**,这激发了我写这篇文章。此外,我要感谢 Suzanne Cook(博客),她在 .NET 运行时加载器方面的文章是解决一些非常困难问题的良好参考。最后但同样重要的是,我要感谢所有为 **PInvoke.net** 做出贡献的人,我从那里获得了大部分 PInvoke 方法定义(例如创建 Windows 服务的那些)。
纪念 Don Langewisch
我谨将此文献给 Don Langewisch(1955?- 2004 年 12 月 10 日)的纪念。在我完成本文时,我收到了他去世的消息。Don,一个充满爱心和忠诚的上帝之人,留下了妻子、两个女儿和一个儿子。