在做网络设备和 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();
}
几个关键设计点:
- 判断依据是”末尾”而不是”包含”。用
result.TrimEnd().EndsWith(text)判断的是本轮新读到的片段末尾是否是提示符。如果用”包含”,命令回显里恰好出现提示符字符串就会提前误判。 - 每次只追加增量。
stream.Read()读到的是自上次读取以来的新增数据,拼接进StringBuilder后,方法返回值就是这条命令的完整回显——天然就是我们要的日志。 - 超时预算独立计数。
count * 100 < millisecondsTimeout以毫秒为单位累计,超时后抛异常而不是返回半截数据,避免上层把残缺回显当成正常结果入库。 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[返回完整回显日志]
三个容易被忽略的细节:
- 登录后先 Expect 一次。刚连上时服务端会推送
banner、
Last login等首屏回显,先等一次提示符把它们”排干”,否则这些残留会混进第一条命令的结果里。 - delimiter
必须选稳定的提示符。它是判断”命令执行完了”的唯一依据,建议用配置下发(项目里由
ServerConfig.TelentDelimiter提供),不同设备可以配不同值。 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、串口等一切”流式终端”协议——本质都是:发送、清洗、等待提示符。