65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.83/5 (78投票s)

2004年12月11日

15分钟阅读

viewsIcon

377754

downloadIcon

10981

在没有特殊钩子的情况下在远程计算机上执行程序集 - 对现有程序集有效!

Sample Image - Run_Code_Remotely.png

Sample Image - Run_Code_Remotely.png

引言

我从事 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。此服务创建一个子进程,该子进程将执行指定的程序集。该服务等待子进程完成执行程序集,然后将其自身(即服务条目)从远程计算机中删除并退出。

有趣的前提包含两个不同的步骤:

  1. 服务如何获得要运行的可执行文件?
  2. 程序集是如何被传输到远程计算机的?

推送要运行的临时服务的可执行文件

这是上述两个“陷阱”中的第一个:“服务代码被复制到 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  );

整个其余的代码和该类的目的都归结到最后一个方法。该方法加载程序集,挂钩 STDINSTDOUTSTDERR,然后运行它。

        
/// <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,一个充满爱心和忠诚的上帝之人,留下了妻子、两个女儿和一个儿子。

© . All rights reserved.