在日常开发中,我们常常需要通过 Telnet 协议连接各类网络设备——交换机、路由器、防火墙、5G 网元……你可能会说:“直接用第三方库不就行了?”
但如果让你从零手写一个 Telnet 客户端,你知道需要处理哪些细节吗?TCP Socket 怎么封装?协议协商(IAC/DO/DONT/WILL/WONT)怎么解析?业务数据中的 xFF 字节怎么转义?粘包/半包怎么处理?UTF-8 多字节字符跨包截断怎么办?
本文基于 SwitchData 项目中的 TelnetClient 和 TelnetProtocolParser 实现,带你完整走一遍 Telnet 协议客户端的从零搭建过程。
一、Telnet 协议核心概念
先快速回顾一下 Telnet(RFC 854)的核心设计:
1. 控制字符 IAC
Telnet 是一种带内协商协议(in-band negotiation),协议控制命令和业务数据走同一条 TCP 连接。特殊之处在于:当 TCP 流中出现字节 xFF(十进制 255,IAC = Interpret As Command)时,它不再是普通业务数据,而是一个协议命令的开始。
2. 四命令协商机制
| 命令 | 含义 | 方向 |
|---|---|---|
| IAC DO | 请求对方启用选项 | 客户端→服务端 或 服务端→客户端 |
| IAC DONT | 请求对方禁用选项 | 同上 |
| IAC WILL | 声明自己愿意启用选项 | 响应 DO |
| IAC WONT | 声明自己不愿启用选项 | 响应 DO 或 DONT |
协商逻辑很简单:对方发 DO Echo 说”请你回显”,你如果同意就回 WILL Echo,不同意就回 WONT Echo。
3. 常见 Telnet 选项
- Echo (1):回显
- Suppress Go Ahead (3):抑制 Go Ahead 信号
- Terminal Type (24):终端类型(如 VT100)
- Window Size (31):终端窗口大小
4. 子协商(Sub-Negotiation)
某些选项需要传递具体参数,用子协商机制:IAC SB
服务端 → 客户端:IAC SB TERMINAL-TYPE SEND IAC SE (请告诉我你的终端类型) 客户端 → 服务端:IAC SB TERMINAL-TYPE IS VT100 IAC SE (我的终端是 VT100)
二、架构设计
我们把实现分成两层:
mermaid graph TD A[TelnetClient] --> B[TelnetProtocolParser] A --> C[Socket] B -->|解析协议数据| D[业务字节流] B -->|生成协商响应| E[Telnet命令] D -->|Decoder连续解码| F[业务文本] E -->|SendRawAsync| A
- TelnetProtocolParser:纯无状态解析器,把 TCP 字节流拆成”业务数据”和”Telnet 协商命令”两部分
- TelnetClient:封装 Socket 连接、发送/接收、超时控制、线程安全、缓冲区管理
三、Telnet 协议解析器
协议解析是整个 Telnet 客户端最复杂的部分。我们用状态机来实现:
状态机设计
Data ──遇到 IAC──▶ Iac Iac ──IAC IAC──▶ Data (转义的 0xFF) Iac ──DO──▶ Do ──▶ Data Iac ──DONT──▶ Dont ──▶ Data Iac ──WILL──▶ Will ──▶ Data Iac ──WONT──▶ Wont ──▶ Data Iac ──SB──▶ SubNegotiation ──遇到 IAC──▶ SubNegotiationIac ──SE──▶ Data Iac ──SE──▶ Data (意外的 SE)
对应代码中的枚举:
csharp private enum ParseState { Data, // 普通业务数据 Iac, // 已接收到 IAC (0xFF) Do, Dont, Will, Wont, // 等待后面的选项字节 SubNegotiation, // 正在进行子协商 SubNegotiationIac // 子协商中遇到 IAC }
逐字节解析
解析器的核心就是一个 oreach 循环,每收到一个字节就根据当前状态做路由:
`csharp public TelnetParseResult Parse(ReadOnlySpan
foreach (var value in input)
{
switch (_state)
{
case ParseState.Data:
if (value == Iac) _state = ParseState.Iac; // 遇到 0xFF 切换状态
else data.Add(value); // 普通字节收进业务数据
break;
case ParseState.Iac:
switch (value)
{
case Iac: data.Add(Iac); _state = ParseState.Data; break; // IAC IAC = 转义的 0xFF
case Do: _state = ParseState.Do; break;
case Will: _state = ParseState.Will; break;
case Sb: _state = ParseState.SubNegotiation; break;
default: _state = ParseState.Data; break;
}
break;
case ParseState.Do:
HandleDo(value, responses); // 生成 WILL 或 WONT 响应
_state = ParseState.Data;
break;
// ... DONT/WILL/WONT/子协商 类似处理
}
}
return new TelnetParseResult(data.ToArray(), responses.ToArray());
} `
协商响应生成
以服务器发 DO Terminal-Type 为例,我们需要: 1. 回复 WILL Terminal-Type 表示支持 2. 主动发子协商声明自己的终端类型
csharp private void HandleDo(byte option, List<byte> responses) { switch (option) { case TerminalType: // 24 AddCommand(responses, Will, option); // IAC WILL 24 // 后续服务端发 SB 24 SEND,客户端回复 SB 24 IS VT100 break; case WindowSize: // 31 AddCommand(responses, Will, option); // IAC WILL 31 AddSubNegotiation(responses, WindowSize, CreateWindowSizeData()); break; default: AddCommand(responses, Wont, option); // 不支持的选项回复 WONT break; } }
设计亮点:解析器和响应生成完全解耦。Parse 方法返回的 esponses 是待发送的原始字节,调用方决定何时发送(通常是在一次读取循环中立即发送,避免协商超时)。
四、TelnetClient 客户端实现
线程安全的读写分离
Telnet 是全双工协议,但实际使用中我们不需要真正的并发读写——业务模式通常是:发送一条命令 → 读取直到响应结束标志。所以我们用两个独立的 SemaphoreSlim 来保证”同一时刻只有一个发送者”和”同一时刻只有一个接收者”:
csharp private readonly SemaphoreSlim _readLock = new(1, 1); private readonly SemaphoreSlim _sendLock = new(1, 1);
核心读取循环:ReadUntilAsync
这是 TelnetClient 最核心的方法——从 Socket 持续读取数据,直到匹配到指定的结束标志(字符串、正则或自定义条件函数)。
`csharp private async Task
// 2️⃣ 从 Socket 读取(带超时)
var buffer = new byte[8192];
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(ReceiveTimeout);
var received = await socket.ReceiveAsync(buffer.AsMemory(), SocketFlags.None, cts.Token);
if (received == 0) throw new IOException("连接已关闭");
// 3️⃣ 协议解析
var parseResult = _parser.Parse(buffer.AsSpan(0, received));
// 4️⃣ 发送协商响应(不能用带 IAC 转义的 SendAsync)
if (parseResult.Responses.Length > 0)
await SendRawAsync(parseResult.Responses, ct);
// 5️⃣ 业务数据送入解码器
if (parseResult.Data.Length > 0)
AppendDecodedText(parseResult.Data);
}
}
finally { _readLock.Release(); }
} `
处理的三大难题:
难题 1:粘包/半包 → 待消费缓冲区
TCP 是面向流的协议,ReceiveAsync 返回的数据可能: - 一个包里包含多条完整消息(粘包) - 一个消息被拆成多个包(半包)
我们用 _pendingText(StringBuilder)累积已接收但未消费的文本。每次进入循环先检查缓冲区里有没有完整消息:
`csharp private string TryConsumePending(Func<string, int> findEndIndex) { if (_pendingText.Length == 0) return null; var text = _pendingText.ToString(); var endIndex = findEndIndex(text); if (endIndex < 0) return null; // 还没凑齐完整消息
var result = text[..endIndex];
_pendingText.Clear();
if (endIndex < text.Length)
_pendingText.Append(text[endIndex..]); // 多余的留到下次
return result;
} `
难题 2:UTF-8 跨包截断 → Decoder 连续解码
如果 Socket 接收到的最后几个字节刚好是 UTF-8 多字节字符的前半部分,直接 Encoding.UTF8.GetString() 会把它丢弃或替换成 (乱码替换符)。
解决方案:用 Encoding.GetDecoder(),Decoder 内部会保留不完整的字节,等下一次喂数据时再拼起来解码:
`csharp private readonly Decoder _decoder;
private void AppendDecodedText(ReadOnlySpan
难题 3:业务数据中的 0xFF → IAC 转义
Telnet 协议规定:业务数据中的 xFF 必须转义成 xFF 0xFF,否则对端会把它当成协议命令的开始。
发送前转义:
`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;
}
return result;
} `
而协议解析器已经负责反向操作了——IAC IAC 在 Data 状态下被还原成单个 xFF 放进业务数据。所以 TelnetClient 的使用者完全不用关心转义细节。
连接建立:DNS 多地址重试
`csharp public async Task ConnectAsync(string host, int port, CancellationToken ct = default) { var addresses = await Dns.GetHostAddressesAsync(host, ct); Exception lastException = null;
foreach (var address in addresses)
{
var socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
try
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(ConnectTimeout);
await socket.ConnectAsync(new IPEndPoint(address, port), cts.Token);
_socket = socket;
return; // 连上了就返回
}
catch (Exception ex)
{
lastException = ex;
socket.Dispose();
}
}
throw lastException!; // 所有地址都连不上才抛异常
} `
五、实战:用 TelnetClient 连接 ORDP 网关
SwitchData 项目中的 OrdpClient 就是建立在 TelnetClient 之上的业务客户端,用来连接华为 ORDP 网关、登录网元、执行 MML 命令:
`csharp public class OrdpClient : IAsyncDisposable { private readonly TelnetClient _telnetClient;
public async Task LoginAsync(string username, string password, CancellationToken ct = default)
{
await _telnetClient.ReadUntilAsync("login:", ct);
await _telnetClient.SendLineAsync(username, ct);
await _telnetClient.ReadUntilAsync("password:", ct);
await _telnetClient.SendLineAsync(password, ct);
// 等待完整响应
var response = await ReadOrdpMessageAsync(ct);
if (!response.IsSuccess) throw new OrdpException(response.Code, response.Message);
}
public async Task<string> ExecuteMmlAsync(string command, CancellationToken ct = default)
{
await _telnetClient.SendLineAsync(command, ct);
// 不同网元的命令结束标志不同,从 ORDP 返回的 NE-EXINFO 中动态获取
return await _telnetClient.ReadUntilAsync(_neInfo.CommandPrompt, ct);
}
} `
整个调用链:业务代码 → OrdpClient → TelnetClient → TelnetProtocolParser → Socket。
六、踩过的坑
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 首条 MML 响应被截断 | Telnet 登录协商后 Socket 里可能残留 — END | 发送前调用 ClearPendingText() 清空 _pendingText |
| 中文乱码 | Socket 接收到的字节刚好把 UTF-8 字符拆开 | 用 Decoder.Convert 代替 Encoding.GetString |
| 协商超时 | 解析器返回的 responses 没及时发出去 | 在读取循环内 Parse 之后立即 SendRawAsync |
| 发送丢失 IAC | SendAsync 直接用带 IAC 转义的方法发协商命令 | 专门提供 SendRawAsync 跳过转义 |
| 连接偶发失败 | DNS 返回多个 IP,第一个不可达直接抛异常 | 遍历所有 IP 依次重试 |
七、总结
从零实现 Telnet 协议客户端,核心要点就三件事:
- 协议解析器用状态机:把字节流拆成业务数据和控制命令两条线,自动生成协商响应
- 接收端要处理边界:粘包靠 _pendingText 缓冲区,UTF-8 截断靠 Decoder 连续解码
- 发送端要处理转义:业务数据中的 xFF 必须双写,协商命令中的 xFF 不能双写
这三大难题解决了,Telnet 客户端基本就稳了。剩下的就是根据具体业务场景(交换机 CLI、网元 MML、IoT 设备 AT 指令)封装业务层客户端。
本文源码来自 SwitchData 项目,完整实现可在 SwitchData.Common/Net/TelnetClient.cs 和 TelnetProtocolParser.cs 中查看。