在运维和网管类系统中,通过 Telnet 协议直连设备执行命令是最常见的场景之一。.NET 生态虽然有第三方 Telnet 库,但生产环境中往往需要更可控的实现——既能精确处理协议协商、又能适配特定厂商设备的特殊行为。

本文基于 SwitchData 项目中自研的 TelnetClient 实现,带你深入剖析 Telnet 协议客户端的完整实现:

graph TD
    A[业务层<br>HuaweiOrdpClient] --> B[TelnetClient<br>Socket异步+并发锁+解码]
    B --> C[TelnetProtocolParser<br>协议状态机]
    C --> D[Telnet协议规范<br>IAC转义/DO-DONT/WILL-WONT/子协商]
    B --> E[SemaphoreSlim<br>读写分离锁]
    B --> F[Decoder<br>连续解码缓冲]

一、Telnet 协议核心概念

Telnet 协议看似简单(就是 TCP + 文本),但有几个容易踩坑的地方:

1. IAC 转义

Telnet 用字节 255(0xFF)作为命令前缀,称为 IAC(Interpret As Command)。如果业务数据中刚好也包含 0xFF,就会被误解析为命令。解决方案很暴力:业务数据中的 0xFF 必须发送两个 0xFF(IAC IAC),接收端再还原。

2. 选项协商

Telnet 两端通过四种命令协商功能开关:

命令 含义 方向
DO (253) 请开启该选项 发起 → 响应方
DONT (254) 请关闭该选项 发起 → 响应方
WILL (251) 我愿意开启该选项 响应 → 发起方
WONT (252) 我不愿意开启该选项 响应 → 发起方

协商是双向的——服务器发 DO 3(请支持抑制继续),客户端回复 WILL 3(好的我支持)。

3. 子协商(Subnegotiation)

部分选项需要协商具体参数,比如终端类型(选项 24)和窗口大小(选项 31)。这时候用 IAC SB option data IAC SE 包裹。

二、协议状态机:TelnetProtocolParser

解析器采用八状态有限状态机,逐字节扫描输入流,同时产出两类输出:业务数据(去掉所有协议字节后的纯文本)和协商响应(需要直接发回给对端的原始 Telnet 字节)。

状态枚举

private enum ParseState
{
    Data,              // 普通业务数据
    Iac,               // 已接收到 IAC (0xFF),等待下一个字节
    Do, Dont,          // 已接收到 DO/DONT,等待选项字节
    Will, Wont,        // 已接收到 WILL/WONT,等待选项字节
    SubNegotiation,    // 正在进行子协商
    SubNegotiationIac  // 子协商中已接收 IAC
}

核心解析循环

public TelnetParseResult Parse(ReadOnlySpan<byte> input)
{
    var data = new List<byte>();      // 业务数据输出
    var responses = new List<byte>(); // 协商响应输出

    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());
}

IAC 分支处理

private void ParseIacByte(byte value, List<byte> data, List<byte> responses)
{
    switch (value)
    {
        case Iac:       // IAC IAC → 业务数据中的 0xFF,转义还原
            data.Add(Iac);
            _state = ParseState.Data;
            break;
        case Do:        _state = ParseState.Do;        break;
        case Dont:      _state = ParseState.Dont;      break;
        case Will:      _state = ParseState.Will;      break;
        case Wont:      _state = ParseState.Wont;      break;
        case Sb:        // 开始子协商
            _subNegotiationData.Clear();
            _state = ParseState.SubNegotiation;
            break;
        default:        _state = ParseState.Data;      break;
    }
}

自动协商响应

解析器内置了对三种常见选项的自动响应逻辑:

private void HandleDo(byte option, List<byte> responses)
{
    switch (option)
    {
        case SuppressGoAhead:  // 抑制继续,支持
            AddCommand(responses, Will, option);
            break;
        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,让服务器知道客户端的能力边界。

三、TelnetClient:异步 Socket + 并发安全

解析器只负责看懂协议,真正的网络通信由 TelnetClient 承载。它的设计重点是并发安全流式读取

并发控制:读写分离锁

一个 Telnet 连接上可能多个线程同时调用 SendAsync 和 ReadUntilAsync。用两把 SemaphoreSlim 分别保护发送和接收:

private readonly SemaphoreSlim _readLock = new(1, 1);
private readonly SemaphoreSlim _sendLock = new(1, 1);

信号量设为 1 就等价于异步互斥锁——同一时刻只有一个线程能进入读/写操作。相比 lock 语句,SemaphoreSlim 不阻塞线程池,真正支持 await。

ReadUntilAsync:条件驱动的流式读取

这是 Telnet 客户端的核心 API。调用者传入一个结束条件(可以是字符串、正则或自定义函数),客户端不断从 Socket 读取、解析、解码,直到满足条件才返回完整内容:

public Task<string> ReadUntilAsync(string delimiter, CancellationToken ct = default)
{
    return ReadUntilAsync(text =>
    {
        var index = text.IndexOf(delimiter, StringComparison.Ordinal);
        return index < 0 ? -1 : index + delimiter.Length;
    }, ct);
}

内部循环逻辑:

private async Task<string> ReadUntilAsync(
    Func<string, int> findEndIndex,
    CancellationToken ct)
{
    await _readLock.WaitAsync(ct);
    try
    {
        while (true)
        {
            // 1. 先检查之前缓冲但未消费的数据
            var pending = TryConsumePending(findEndIndex);
            if (pending != null) return pending;

            // 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 转义)
            if (parseResult.Responses.Length > 0)
                await SendRawAsync(parseResult.Responses, ct);

            if (parseResult.Data.Length == 0) continue;

            // 5. 解码 + 追加到缓冲
            AppendDecodedText(parseResult.Data);

            // 6. 再次检查结束条件
            var result = TryConsumePending(findEndIndex);
            if (result != null) return result;
        }
    }
    finally { _readLock.Release(); }
}

缓冲策略:_pendingText + TryConsumePending

Socket 读取是流式的——一次 ReceiveAsync 可能读到半条消息,也可能一次读到多条完整响应。用 StringBuilder 做累积缓冲:

private readonly StringBuilder _pendingText = new();

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;
}

实战坑点:华为 ORDP 网关登录后会残留 — END 标志在缓冲区,导致首条 MML 响应被截断。解决方案就是在发送命令前调用 ClearPendingText() 清空缓冲:

public void ClearPendingText()
{
    _parser.Reset();
    _pendingText.Clear();
    _decoder = Encoding.GetDecoder();
}

Decoder 连续解码:处理 UTF-8 多字节边界

UTF-8 字符可能跨 Socket 读取边界(一次读到半个中文字节)。用 Encoding.GetDecoder() 可以保持解码状态,跨批次正确解码:

private Decoder _decoder;  // 复用这个 decoder,它会维护内部状态

private void AppendDecodedText(ReadOnlySpan<byte> data)
{
    var charCount = Encoding.GetMaxCharCount(data.Length);
    var chars = new char[charCount];
    _decoder.Convert(data, chars, false, out _, out var charsUsed, out _);
    if (charsUsed > 0) _pendingText.Append(chars, 0, charsUsed);
}

发送侧:IAC 转义 + 分片发送

发送前对业务数据中的 0xFF 做转义(0xFF → 0xFF 0xFF),确保 Telnet 协议层不会误判:

private static byte[] EscapeIac(ReadOnlySpan<byte> data)
{
    var count = data.Count(b => b == 255);
    if (count == 0) return data.ToArray();
    var result = new byte[data.Length + count];
    var i = 0;
    foreach (var v in data)
    {
        result[i++] = v;
        if (v == 255) result[i++] = 255;
    }
    return result;
}

TCP 发送也可能被操作系统分片,所以用循环确保所有字节都发出去:

while (offset < data.Length)
{
    var sent = await socket.SendAsync(data[offset..], SocketFlags.None, cts.Token);
    if (sent <= 0) throw new IOException("连接已断开");
    offset += sent;
}

超时控制:CancellationTokenSource.CreateLinkedTokenSource

每个操作都有独立的超时,同时保留调用方的 CancellationToken:

using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(ReceiveTimeout);  // 默认 30 秒
await socket.ReceiveAsync(buffer.AsMemory(), SocketFlags.None, timeoutCts.Token);

DNS 解析 + 多 IP 重试

ConnectAsync 做了 DNS 解析后遍历所有 IP 尝试连接,避免单 IP 故障:

var addresses = await Dns.GetHostAddressesAsync(host, cancellationToken);
foreach (var address in addresses)
{
    var socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
    try
    {
        timeoutCts.CancelAfter(ConnectTimeout);
        await socket.ConnectAsync(new IPEndPoint(address, port), timeoutCts.Token);
        _socket = socket;
        return;
    }
    catch { socket.Dispose(); }
}

四、实战:HuaweiOrdpClient

TelnetClient 提供了通用 Telnet 能力,上层 HuaweiOrdpClient 则是业务封装——连接华为 ORDP 网关、登录网元、执行 MML 命令。

public async Task<string> ExecuteMmlAsync(string command, CancellationToken ct = default)
{
    await _telnetClient.SendLineAsync(command, ct);
    var pattern = new Regex(_neInfo.CommandPrompt, RegexOptions.Compiled);
    return await _telnetClient.ReadUntilAsync(pattern, ct);
}

完整调用链

sequenceDiagram
    participant App as PcfService
    participant Ordp as HuaweiOrdpClient
    participant Telnet as TelnetClient
    participant Parser as TelnetProtocolParser
    participant Gateway as ORDP网关

    App->>Ordp: ConnectAsync(host, port)
    Ordp->>Telnet: ConnectAsync()
    Telnet->>Gateway: Socket.Connect()
    Gateway-->>Telnet: Telnet协商(IAC DO ...)
    Telnet->>Parser: Parse(rawBytes)
    Parser-->>Telnet: Data=[], Responses=[IAC WILL ...]
    Telnet->>Gateway: SendRaw(responses)
    
    App->>Ordp: LoginNeAsync(neName, inf)
    Ordp->>Telnet: SendLineAsync(ne=xxx inf=ECOM_PGW)
    Ordp->>Telnet: ReadUntilAsync(</ORDP>)
    Telnet->>Gateway: 循环读取解析解码缓冲匹配
    Gateway-->>Ordp: XML响应
    
    App->>Ordp: ExecuteMmlAsync(LST PCMCCSUBDATA)
    Ordp->>Telnet: SendLineAsync(cmd)
    Ordp->>Telnet: ReadUntilAsync(/---\s+END/)
    Gateway-->>Ordp: MML执行结果

字符集动态切换

华为不同网元可能返回 GBK 或 UTF-8 中文字面量。登录后 ORDP 会通过 NE-EXINFO 下发字符集,客户端动态切换:

_neInfo = ParseNeExInfo(neName, interfaceName, exInfoResponse.Message);
_telnetClient.Encoding = Encoding.GetEncoding(_neInfo.CharsetEncoding);

Encoding setter 会自动重建 _decoder,确保新字符集正确解码后续数据。

五、关键设计总结

关注点 方案 为什么
并发发送 SemaphoreSlim(1,1) 异步互斥锁,不阻塞线程池
并发读取 SemaphoreSlim(1,1) + _pendingText Socket 读不能并发,缓冲支持批量响应拆包
协议解析 八状态有限状态机 逐字节扫描,不依赖一次性完整帧
IAC 处理 发送侧转义、接收侧还原 业务数据中的 0xFF 不会被误判为命令
字符解码 Encoder.GetDecoder() 复用 跨 Socket 读取边界正确解码多字节字符
超时控制 CancellationTokenSource 链接 同时支持调用方取消和操作超时
协商响应 解析器自动生成 业务代码无需关心 Telnet 协议细节
连接管理 IAsyncDisposable + Interlocked.Exchange Dispose 幂等、线程安全

六、踩坑记录

1. 登录残留 — END 导致首条命令被截断

华为网元登录完成后,网关会回显一条 — END 结束标志到 Socket 缓冲。紧接着业务代码发送 MML 命令并 ReadUntilAsync,读到的响应会把登录残留拼在前面,导致命令结果只返回后半段。

修复:每次发送 MML 命令前调用 ClearPendingText() 清空 _pendingText 和 Parser 状态。

2. ORDP 网关单连接能力有限

网关 188.0.108.25:28030 是单点,多连接并发(翻倍到 16 条)会导致网关过载,单条 MML 耗时增加 3~5 倍。实测总耗时反而没有改善。

结论:按设备分组、组内串行、设备间并发的策略更适合这种单点网关架构。

3. Socket 分片发送

TCP 不保证一次 Send 全部发出去。必须在循环中检查 sent < data.Length,否则大命令会被截断到对端,Telnet 协议层就等着下一次续包——而你已经超时了。

结语

实现一个生产级 Telnet 客户端,看起来是在和 Socket 打交道,实际上是在设计一个协议状态机 + 异步流式处理器。Telnet 规范(RFC 854)写了 70 年,但它处理的问题今天依然存在:传输层不保证消息边界、两端能力不对称、编码不统一。

SwitchData 项目中的 TelnetClient 完整实现了这套思路,支撑了华为 ORDP 网关、中兴 NetNumen 等多个厂商设备的接入。如果你也需要直连 Telnet 设备执行命令,可以直接复用这个设计——或者把它当作学习 .NET 网络编程的一个实战范例。