在做网络设备和 Unix 服务器的批量运维时,我们经常需要在 C# 程序里”登录一台机器,连续执行一串命令,并把完整回显留档”。很多同学第一反应是 SshClient.RunCommand("ls"),但真用起来就会发现:每调一次 RunCommand,SSH.NET 都会新开一个 exec 通道,命令之间不共享任何上下文——上一条 cd /home/app,下一条 ls 列的还是家目录;环境变量、工作目录全部丢失。

要”像人一样操作终端”,就必须使用交互式 Shell:SSH.NET 提供的 ShellStream。本文基于项目中实际运行的网元日志同步模块,完整拆解 ShellStream 交互执行命令的三件套:通道创建、Expect 提示符等待、终端控制字符清洗。

一、RunCommand 与 ShellStream 的本质区别

先说结论:

方式 底层通道 上下文共享 适用场景
RunCommand exec 通道,每命令一条 不共享 单条独立命令
CreateShellStream shell 通道(伪终端) 共享,等同人工登录 连续多条关联命令

ShellStream 相当于在 SSH 连接上打开了一个伪终端(PTY),cd、export、进入子程序(如 dbaccess、telnet 嵌套)都能生效。代价是它的输出是流式的:你不知道一条命令的输出”什么时候到齐”,这正是 Expect 模式要解决的问题。

二、创建 ShellStream

项目中的真实代码:

using (SshClient client = new SshClient(host, port, username, password))
{
    client.Connect();
    using (ShellStream stream = client.CreateShellStream("dumb", 80, 24, 800, 600, 1024))
    {
        // 在这里逐条发送命令、等待回显
    }
    client.Disconnect();
}

CreateShellStream 的参数依次是:终端名(dumb 表示最朴素的无智能终端)、列数、行数、像素宽、像素高、回滚缓冲区大小。这些参数会影响服务端 $TERM 和终端行宽,大部分场景用默认值即可。注意 SshClient 和 ShellStream 都实现了 IDisposable,务必用 using 包住,避免连接句柄泄漏。

三、Expect 模式:循环读流直到提示符出现

执行命令的核心难点在于时序:stream.WriteLine(cmd) 之后,回显是分批到达的,不能”读一次就当完整”。经典解法就是 Telnet 时代传下来的 Expect 模式——循环读取,直到输出末尾出现提示符(delimiter)或超时:

private string Expect(ShellStream stream, string text, int millisecondsTimeout)
{
    StringBuilder strBuilder = new StringBuilder();
    bool flag = false;
    int count = 0;
    do
    {
        string result = stream.Read();
        // 去掉终端模拟字符(退格、ANSI 颜色码)
        result = Regex.Replace(result, @".[\b]", "");
        result = Regex.Replace(result, @"\e\[\d*;?\d*m", "");
        strBuilder.Append(result);
        if (result.TrimEnd().EndsWith(text))
        {
            flag = true;
        }
        else
        {
            Thread.Sleep(100);
            count++;
        }
    } while (!flag && (count * 100 < millisecondsTimeout));
    if (!flag)
    {
        throw new Exception(String.Format(
            "等待超时,命令返回结果末尾未找到期望的字符串:{0}", text));
    }
    return strBuilder.ToString();
}

几个关键设计点:

  1. 判断依据是”末尾”而不是”包含”。用 result.TrimEnd().EndsWith(text) 判断的是本轮新读到的片段末尾是否是提示符。如果用”包含”,命令回显里恰好出现提示符字符串就会提前误判。
  2. 每次只追加增量。stream.Read() 读到的是自上次读取以来的新增数据,拼接进 StringBuilder 后,方法返回值就是这条命令的完整回显——天然就是我们要的日志。
  3. 超时预算独立计数。count * 100 < millisecondsTimeout 以毫秒为单位累计,超时后抛异常而不是返回半截数据,避免上层把残缺回显当成正常结果入库。
  4. Thread.Sleep(100) 轮询。实现简单、CPU 占用极低,代价是每轮最多引入 100ms 延迟。对运维批量场景完全够用;追求实时可以改用 stream.ReadAsync + CancellationToken 的异步版本。

四、终端控制字符清洗:两条正则

SSH 登录后的回显并不是”干净”文本,尤其交互式终端会混入两类控制序列,直接入库就是乱码:

  • 退格序列:终端模拟时输入回退会产生”字符 + \b“甚至”\b + 空格 + \b“,视觉上字符消失了,字节还在;
  • ANSI 颜色码:ESC[31;1m 这类转义序列用于上色,读出来就是 ←[31;1m。

Expect 方法里的两条正则各司其职:

// 1. 退格:任意一个字符 + 退格符,整体删除(相当于"撤销上一个字符")
result = Regex.Replace(result, @".[\b]", "");

// 2. ANSI 颜色码:ESC[ 数字;数字 m,数字和分号均可选
result = Regex.Replace(result, @"\e\[\d*;?\d*m", "");

第一条的巧妙之处在于 . 默认不匹配换行,配合 [\b] 精确匹配”前一字符被退格抹掉”的情形,一条正则就完成了终端模拟器做的事。第二条中 \e 是 ESC 控制符(\x1b),\d*;?\d*m 覆盖了 ESC[31m、ESC[1;32m 等常见着色格式。

五、完整流程:登录、逐条执行、退出

把上面的零件组装起来,就是完整的交互式命令执行方法:

private string ExecuteSshCommands(string host, int port, string username,
    string password, string delimiter, ICollection<string> cmds)
{
    StringBuilder strBuilder = new StringBuilder();

    using (SshClient client = new SshClient(host, port, username, password))
    {
        client.Connect();
        using (ShellStream stream = client.CreateShellStream("dumb", 80, 24, 800, 600, 1024))
        {
            int timeout = 10000; // 每条命令 10 秒超时

            // 1. 先等登录提示符,吃掉登录 banner 和首屏回显
            strBuilder.Append(Expect(stream, delimiter, timeout));

            // 2. 逐条下发命令,每条都等提示符再发下一条
            foreach (string cmd in cmds)
            {
                if (cmd.ToLower() == "exit")
                {
                    break;
                }
                stream.WriteLine(cmd);
                strBuilder.Append(Expect(stream, delimiter, timeout));
            }

            // 3. 退出 shell,并等 logout 回显后再断开
            stream.WriteLine("exit");
            strBuilder.Append(Expect(stream, "logout", timeout));

            stream.Close();
        }
        client.Disconnect();
    }
    return strBuilder.ToString();
}

整体时序如下图:

flowchart TD
    A[Connect 建立 SSH 连接] --> B[CreateShellStream 打开伪终端]
    B --> C[Expect 等待登录提示符]
    C --> D{命令队列是否为空?}
    D -- 否 --> E[WriteLine 发送下一条命令]
    E --> F[Expect 清洗回显并等待提示符]
    F --> D
    D -- 是 --> G[WriteLine exit]
    G --> H[Expect 等待 logout]
    H --> I[Close + Disconnect]
    I --> J[返回完整回显日志]

三个容易被忽略的细节:

  1. 登录后先 Expect 一次。刚连上时服务端会推送 banner、Last login 等首屏回显,先等一次提示符把它们”排干”,否则这些残留会混进第一条命令的结果里。
  2. delimiter 必须选稳定的提示符。它是判断”命令执行完了”的唯一依据,建议用配置下发(项目里由 ServerConfig.TelentDelimiter 提供),不同设备可以配不同值。
  3. exit 之后还要等 logout。直接 Disconnect 会把服务端还没推完的回显掐断,个别设备还会留下告警。等一次 logout 再断开,收尾才干净。命令队列里放 exit 则提供了一层人工兜底:脚本自带退出命令时直接跳出循环。

六、生产环境的改进方向

这套代码在实际项目里稳定跑了很久,但如果要演进,有三个方向:

  • 异步化:把 Expect 改成 async Task<string>,用 await Task.Delay(100) 替代 Thread.Sleep,批量设备并发时不再占用线程池线程;
  • 超时细分:登录、普通命令、慢命令(如数据库 unload)用不同超时预算,而不是统一 10 秒;
  • 结构化结果:返回 List<(string cmd, string output)> 而非一整个字符串,方便上层做逐命令的成败判定与重试。

总结

本文拆解了 SSH.NET ShellStream 交互式命令执行的完整链路:RunCommand 无法共享上下文,必须用伪终端;流式输出靠 Expect 模式(循环读流 + 末尾匹配提示符 + 超时兜底)判断命令结束;回显中的退格序列与 ANSI 颜色码用两条正则清洗。这三个点组合起来,就是一个百余行、零第三方依赖(除 SSH.NET 本身)却足够可靠的 SSH 自动化方案。完整思路同样适用于 Telnet、串口等一切”流式终端”协议——本质都是:发送、清洗、等待提示符。