在网络设备管理、运维自动化等场景中,我们经常需要通过 Telnet 协议连接到交换机、路由器或网元设备,执行命令并获取回显。虽然 .NET 没有内置 Telnet 客户端,但基于底层 System.Net.Sockets.Socket 实现一个完整的 Telnet 客户端并不困难。
本文将带你深入源码,剖析一个工业级 C# Telnet 客户端的完整实现,涵盖以下核心知识点:
- Telnet 协议规范:IAC 转义、DO/DONT/WILL/WONT 协商、子协商机制
- Socket 异步编程:ConnectAsync / SendAsync / ReceiveAsync 全链路异步
- 状态机模式:协议解析器的状态驱动设计
- 并发控制:SemaphoreSlim 读写锁保护 Socket 安全
- 超时与取消:CancellationTokenSource.CreateLinkedTokenSource 组合超时
- 多字节字符解码:Decoder.Convert 处理 TCP 粘包导致的 UTF-8 分割问题
为什么不直接用 TcpClient?
TcpClient 只是对 Socket 的简单封装,提供了 NetworkStream 让你像读写文件一样操作网络。但 Telnet 不是普通的文本协议,它有一套完整的选项协商机制:
`mermaid sequenceDiagram participant Client as TelnetClient participant Server as 远端设备
Note over Client,Server: TCP 三次握手建立
Client->>Server: Socket.ConnectAsync()
Server-->>Client: 连接成功
Note over Client,Server: Telnet 选项协商阶段
Server->>Client: IAC DO ECHO (255 253 1)
Client->>Server: IAC WILL ECHO (255 251 1)
Server->>Client: IAC DO TERMINAL-TYPE (255 253 24)
Client->>Server: IAC WILL TERMINAL-TYPE (255 251 24)
Server->>Client: IAC SB TERMINAL-TYPE SEND IAC SE
Client->>Server: IAC SB TERMINAL-TYPE IS VT100 IAC SE
Note over Client,Server: 业务数据交互阶段
Client->>Server: "login: admin\r\n"
Server-->>Client: 回显 + 提示符
`
如果不处理这些协商报文,很多设备会直接断开连接,或者把协商报文当成业务数据返回给你,导致命令解析失败。
Telnet 协议核心规范速览
Telnet 协议定义了几个关键的控制字节(IAC = Interpret As Command):
| 字节值 | 名称 | 含义 |
|---|---|---|
| 255 (0xFF) | IAC | 命令转义,后续紧跟 Telnet 命令字节 |
| 253 (0xFD) | DO | 请求对方启用某选项 |
| 254 (0xFE) | DONT | 请求对方禁用某选项 |
| 251 (0xFB) | WILL | 我方将启用某选项(同意 DO) |
| 252 (0xFC) | WONT | 我方不启用某选项(拒绝 DO) |
| 250 (0xFA) | SB | 子协商开始(带参数的选项) |
| 240 (0xF0) | SE | 子协商结束 |
数据中的 0xFF 必须转义为 0xFF 0xFF(两个 IAC 字节),这一点很容易遗漏。
整体架构设计
我们的 Telnet 客户端由两个核心类协作完成:
mermaid graph TD A[TelnetClient] -->|持有| B[TelnetProtocolParser] A -->|使用| C[Socket] A -->|使用| D[SemaphoreSlim 读写锁] A -->|管理| E[StringBuilder 接收缓冲] B -->|状态机| F[8 种解析状态] B -->|处理| G[协商请求/响应] B -->|处理| H[子协商]
- TelnetProtocolParser:纯协议层,无 I/O,只负责字节解析和协商响应生成
- TelnetClient:网络层,负责 Socket 连接、发送数据、读取直到匹配结束条件
TelnetProtocolParser:状态机协议解析
协议解析器采用有限状态机(FSM)设计,这是处理 Telnet 这类带协商机制协议的经典模式。
状态定义
csharp private enum ParseState { Data, // 普通业务数据 Iac, // 已接收 IAC,等待命令字节 Do, Dont, // 等待 DO/DONT 后面的选项字节 Will, Wont, // 等待 WILL/WONT 后面的选项字节 SubNegotiation, // 正在解析子协商 SubNegotiationIac // 子协商中遇到 IAC }
核心解析循环
`csharp public TelnetParseResult Parse(ReadOnlySpan
foreach (var value in input)
{
switch (_state)
{
case ParseState.Data:
ParseDataByte(value, data);
break;
case ParseState.Iac:
ParseIacByte(value, data, responses);
break;
case ParseState.Do:
HandleDo(value, responses);
_state = ParseState.Data;
break;
// ...其他状态处理
}
}
return new TelnetParseResult(data.ToArray(), responses.ToArray());
} `
解析结果 TelnetParseResult 包含两部分:Data 是纯净的业务数据,Responses 是需要立即发送回服务端的协商响应。
IAC 转义处理
在 ParseIacByte 中,当遇到连续两个 IAC( xFF 0xFF)时,说明这是业务数据中的 0xFF 字节,需要还原:
csharp case Iac: // 收到 IAC,然后又收到 IAC data.Add(Iac); // 业务数据中的 0xFF _state = ParseState.Data; break;
自动协商响应
服务端发来 DO(请求我启用某选项)时,客户端需要回应 WILL 或 WONT。解析器自动处理了几个常用选项:
csharp private void HandleDo(byte option, List<byte> responses) { switch (option) { case SuppressGoAhead: // 抑制继续进行 case TerminalType: // 终端类型 AddCommand(responses, Will, option); // 同意启用 break; case WindowSize: // 窗口大小 AddCommand(responses, Will, option); // 主动上报窗口大小 AddSubNegotiation(responses, WindowSize, CreateWindowSizeData()); break; default: AddCommand(responses, Wont, option); // 不支持的选项明确拒绝 break; } }
设计要点:对不支持的选项必须回复 WONT,而不是忽略。有些设备如果长时间收不到响应会认为客户端不兼容而断开。
子协商处理(终端类型)
子协商是协商机制中最复杂的部分,以终端类型为例:
- 服务端:IAC SB TERMINAL-TYPE SEND IAC SE(请求我告诉它我的终端类型)
- 客户端:IAC SB TERMINAL-TYPE IS VT100 IAC SE(我用的是 VT100)
csharp private void HandleTerminalType(List<byte> responses) { if (_subNegotiationData.Count == 1 && _subNegotiationData[0] == Send) { var terminalType = Encoding.ASCII.GetBytes(TerminalTypeName); // 默认 "VT100" var data = CreateTerminalTypeData(terminalType); // [IS, 'V', 'T', '1', '0', '0'] AddSubNegotiation(responses, TerminalType, data); } }
TelnetClient:异步 Socket 客户端
TelnetClient 基于 System.Net.Sockets.Socket 实现,全程使用 sync/await 异步编程模型。
并发安全:SemaphoreSlim 读写锁
Socket 是全双工的,但同时调用 SendAsync 和 ReceiveAsync 在不同线程上并不安全。我们使用两个 SemaphoreSlim(初始计数都是 1)分别保护读和写:
csharp private readonly SemaphoreSlim _readLock = new(1, 1); private readonly SemaphoreSlim _sendLock = new(1, 1);
每次发送或读取前先 WaitAsync,完成后在 inally 中 Release:
`csharp public async Task SendAsync(ReadOnlyMemory
var sent = await socket.SendAsync(data[offset..], SocketFlags.None, timeoutCts.Token);
if (sent <= 0) throw new IOException("Telnet 连接已断开。");
offset += sent;
}
}
finally
{
_sendLock.Release();
}
} `
连接建立:DNS 解析 + 多地址尝试
`csharp public async Task ConnectAsync(string host, int port, CancellationToken cancellationToken = default) { var addresses = await Dns.GetHostAddressesAsync(host, cancellationToken);
Exception lastException = null;
foreach (var address in addresses)
{
var socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
try
{
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(ConnectTimeout); // 默认 10 秒
await socket.ConnectAsync(new IPEndPoint(address, port), timeoutCts.Token);
_socket = socket;
return; // 连接成功
}
catch (Exception ex)
{
lastException = ex;
socket.Dispose();
}
}
throw new SocketException((int)SocketError.NotConnected);
} `
设计要点:CancelAfter 是 .NET 6+ 的 API,之前版本需要 Task.Delay + Task.WhenAny 实现超时。
读取直到匹配:ReadUntilAsync
这是最核心的方法,支持三种结束条件——字符串、正则表达式、自定义判断函数:
csharp public Task<string> ReadUntilAsync(string delimiter, CancellationToken cancellationToken = default) { return ReadUntilAsync(text => { var index = text.IndexOf(delimiter, StringComparison.Ordinal); return index < 0 ? -1 : index + delimiter.Length; }, cancellationToken); }
内部实现是一个循环:先检查 _pendingText(之前收到但未消费的数据),不够再从 Socket 收新数据,用解析器过滤掉协商报文,再追加到 _pendingText:
`csharp private async Task
// 2. 从 Socket 读取
var buffer = new byte[8192];
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(ReceiveTimeout); // 默认 30 秒
var received = await socket.ReceiveAsync(buffer.AsMemory(), SocketFlags.None, timeoutCts.Token);
if (received == 0) throw new IOException("远端已关闭连接。");
// 3. 协议解析,剥离协商报文
var parseResult = _parser.Parse(buffer.AsSpan(0, received));
// 4. 如果有协商响应,立即发回(原始 Telnet 数据,不能再次 IAC 转义)
if (parseResult.Responses.Length > 0)
await SendRawAsync(parseResult.Responses, cancellationToken);
// 5. 业务数据追加到缓冲
if (parseResult.Data.Length > 0)
AppendDecodedText(parseResult.Data);
}
}
finally
{
_readLock.Release();
}
} `
多字节字符解码:处理 TCP 粘包的 UTF-8 分割
TCP 是字节流,一次 ReceiveAsync 可能截到 UTF-8 多字节字符的中间。如果每次收到数据都 Encoding.UTF8.GetString(),会导致乱码或异常。
正确做法是使用 Decoder,它会记住未完成的字符序列,下次接收时继续解码:
`csharp private readonly Decoder _decoder; // 在 Encoding setter 中初始化
private void AppendDecodedText(ReadOnlySpan
// convert 会保留不完整的多字节序列在 Decoder 内部状态中
_decoder.Convert(data, chars, false, out var bytesUsed, out var charsUsed, out _);
if (bytesUsed != data.Length)
throw new InvalidOperationException("Telnet 数据解码失败。");
if (charsUsed > 0)
_pendingText.Append(chars, 0, charsUsed);
} `
对比:Encoding.GetString(new byte[]{0xE4}) 会丢数据(假设 0xE4 是”你”的第一个字节),而 Decoder.Convert 会缓存 0xE4,等下次收到 0xBD 0xA0 时拼成完整的”你”。
发送数据的 IAC 转义
业务数据中如果包含 0xFF 字节,必须发送为 xFF 0xFF(两个 IAC):
`csharp private static byte[] EscapeIac(ReadOnlySpan
var result = new byte[data.Length + count];
var index = 0;
foreach (var value in data)
{
result[index++] = value;
if (value == 255) result[index++] = 255; // IAC -> IAC IAC
}
return result;
} `
使用示例
`csharp using var telnet = new TelnetClient(); await telnet.ConnectAsync(“192.168.1.1”, 23);
// 登录 await telnet.SendLineAsync(“admin”); var prompt = await telnet.ReadUntilAsync(“>”); Console.WriteLine(prompt);
await telnet.SendLineAsync(“password123”); prompt = await telnet.ReadUntilAsync(“>”);
// 执行命令 await telnet.SendLineAsync(“display version”); var output = await telnet.ReadUntilAsync(new Regex(@“[<[].*[>]]“)); Console.WriteLine(output); `
完整流程图
`mermaid flowchart TD Start([开始]) –>
Connect[ConnectAsync
DNS 解析 + 多地址尝试] Connect –>
Loop{命令循环}
Loop --> Send[SendLineAsync<br/>1. IAC 转义<br/>2. _sendLock 锁<br/>3. 循环发送]
Send --> Read[ReadUntilAsync<br/>1. _readLock 锁<br/>2. 检查 _pendingText<br/>3. Socket.ReceiveAsync<br/>4. Parser.Parse 过滤协商<br/>5. 发送协商响应<br/>6. Decoder 持续解码]
Read --> Match{匹配结束符?}
Match -- 否 --> Read
Match -- 是 --> Output[返回业务文本]
Output --> Loop
Loop -- 用户退出 --> Disconnect[DisposeAsync<br/>1. Interlocked.Exchange<br/>2. Socket.Shutdown<br/>3. 释放锁<br/>4. GC.SuppressFinalize]
`
关键设计总结
| 设计点 | 方案 | 原因 |
|---|---|---|
| 协议解析 | 有限状态机 + 纯函数 | 无 I/O 依赖,可单独测试 |
| 并发安全 | 双 SemaphoreSlim | Socket 全双工但 Send/Recv 不保证线程安全 |
| 超时控制 | CreateLinkedTokenSource + CancelAfter | 既响应外部 CancellationToken,又有独立超时 |
| 字符解码 | Decoder.Convert 持续解码 | 处理 TCP 粘包导致的 UTF-8 分割 |
| IAC 转义 | 发送时转义、解析时还原 | Telnet 协议规范要求 |
| 资源释放 | IAsyncDisposable + Interlocked.Exchange | 防止多次 Dispose 异常 |
常见坑点
坑 1:用 Encoding.GetString 解码每次收到的字节。TCP 粘包导致多字节字符被截断,会出现 � 乱码。
坑 2:忽略协商响应。有些设备会在 Do 之后短暂等待 WILL/WONT 响应,如果客户端一直不回复,设备会认为连接异常而断开。
坑 3:忘记对发送数据做 IAC 转义。如果业务数据碰巧包含 0xFF 字节,后面两个字节会被服务端当作命令解析,导致不可预期的行为。
坑 4:Socket 同时被多个线程调用。虽然 Send 和 Receive 可以并行,但两个 Send 或两个 Receive 并发就可能出问题,必须加锁。
扩展方向
- SSH 支持:Telnet 是明文协议,生产环境建议升级到 SSH,可以用 SSH.NET 库或基于 SshNet 实现
- 日志集成:在 ReadUntil 和 SendLine 中增加日志输出,方便调试设备交互
- 连接池:高频场景下可以维护 Telnet 连接池,避免频繁 TCP 握手
- 命令队列:如果需要串行执行多条命令,可以在 TelnetClient 外层封装一个命令队列服务
结语
实现 Telnet 客户端看似简单,实则涉及协议解析、异步编程、并发控制、字符编码等多个方面的细节。本文展示的实现模式可以推广到其他二进制协议(如 SMTP、FTP、私有设备协议等)——核心思路都是:协议解析器与 I/O 解耦、用状态机管理协议状态、用锁保证并发安全。
SwitchData 项目中这个 TelnetClient 已经在多个华为/中兴网元设备上稳定运行多年,每天处理成千上万条 MML 命令交互,证明了这套设计的可靠性。