为什么需要自己实现 Telnet?

你可能会说:NuGet 上有现成的 Telnet 库啊,直接用不就好了?但实际项目中,现成库往往满足不了三个核心需求:

  1. 自定义终端协商:很多网关(比如运营商设备管理平台)会主动发起 TERMINAL-TYPE、WINDOW-SIZE 等子协商,现成库要么直接忽略,要么协商逻辑写死。
  2. 精确控制提示符匹配:不同网元(华为 PGW、中兴 UPF)的命令结束标志不同,有的用 --- END,有的用正则,甚至有的登录阶段要等两次提示符。
  3. 异步链路的可靠性:Socket 接收是分包的,一条业务命令可能被拆成多个 TCP 包到达,必须有 pending buffer 预缓冲机制,否则响应会被截断。

SwitchData 项目中的 ORDP 直连功能,就是从零实现了一套完整的 Telnet 客户端,经过了华为/中兴多品牌网元的实战考验。今天就把这套四层架构拆解开来,每层讲清楚实现原理和踩过的坑。

整体架构

graph TD
    A[业务层: JSJService<br/>PcfService] --> B[协议封装层: OrdpClient<br/>ORDP XML消息/网元登录]
    B --> C[协议解析层: TelnetProtocolParser<br/>8状态状态机/TERMINAL-TYPE协商]
    C --> D[传输层: TelnetClient<br/>Socket异步收发/并发锁/预缓冲]
    subgraph D
        D1[Socket.SendAsync/ReceiveAsync]
        D2[SemaphoreSlim 收发锁]
        D3["StringBuilder _pendingText<br/>预缓冲机制"]
        D4["Decoder 连续解码<br/>跨包UTF-8安全"]
    end
    subgraph C
        C1["ParseState 状态机<br/>Data/Iac/Do/Dont/Will/Wont/SubNegotiation/SubNegotiationIac"]
        C2["自动协商响应<br/>DO→Will/DONT→WONT"]
        C3["子协商处理<br/>TERMINAL-TYPE/WINDOW-SIZE"]
    end
    subgraph B
        B1["ReadOrdpMessageAsync<br/>按XML标签分包"]
        B2["LoginNeAsync<br/>多提示符等待"]
        B3["ExecuteCommandAsync<br/>CR结尾避免回显"]
    end
    subgraph E[业务层清理]
        E1["AnsiEscapeRegex<br/>VT100序列清理"]
        E2["Replace('\\0', '')<br/>NUL字符清理"]
        E3["剔除输入回显行<br/>行尾单个\\r"]
    end

四层架构自底向上:传输层管 Socket 字节收发,解析层管 Telnet 协议状态机和协商,封装层管 ORDP 业务协议,业务层做响应清洗。每层职责单一、互不依赖,便于独立测试和替换。

第一层:Telnet 协议解析层(TelnetProtocolParser)

Telnet 协议定义了一套协商机制:服务端会发送 IAC DO <option> 表示请求客户端启用某个选项,客户端必须回复 IAC WILL <option> 表示同意或 IAC WONT <option> 表示拒绝。协商字节的特点是以 0xFF(IAC,Interpret As Command)开头。

状态机设计

TelnetProtocolParser 用 8 状态状态机完整实现了 RFC 854:

private enum ParseState
{
    Data,              // 普通业务数据
    Iac,               // 已接收到 IAC (0xFF)
    Do,                // 等待 DO 后的选项
    Dont,              // 等待 DONT 后的选项
    Will,              // 等待 WILL 后的选项
    Wont,              // 等待 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:
                if (value == Iac) _state = ParseState.Iac;
                else data.Add(value);   // 普通字节直接输出
                break;
            case ParseState.Iac:
                ParseIacByte(value, data, responses);
                break;
            case ParseState.Do:   HandleDo(value, responses);   _state = ParseState.Data; break;
            case ParseState.Dont: HandleDont(value, responses); _state = ParseState.Data; break;
            case ParseState.Will: HandleWill(value, responses); _state = ParseState.Data; break;
            case ParseState.Wont: HandleWont(value, responses); _state = ParseState.Data; break;
            case ParseState.SubNegotiation: ParseSubNegotiationByte(value); break;
            case ParseState.SubNegotiationIac: ParseSubNegotiationIacByte(value, responses); break;
        }
    }
    return new TelnetParseResult(data.ToArray(), responses.ToArray());
}

自动协商策略

协商响应的核心原则:能配合的选项就同意,不能的明确拒绝,绝不静默忽略:

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

WINDOW-SIZE 比较特殊:服务端请求窗口大小时,客户端不仅要回复 WILL 表示同意,还要主动通过子协商上报自己的窗口宽高,否则服务端会认为协商未完成、持续发送 DO WINDOW-SIZE。这是很多现成 Telnet 库容易忽略的细节。

子协商(TERMINAL-TYPE)

运营商网关非常看重终端类型协商——如果客户端不明确声明自己是 VT100,网关可能会发送非标准控制序列导致响应乱码。

服务端 → 客户端: IAC SB TERMINAL-TYPE SEND IAC SE
客户端 → 服务端: IAC SB TERMINAL-TYPE IS VT100 IAC SE

子协商数据中的 0xFF 也需要 IAC 转义,所以子协商状态内部还有一层 SubNegotiationIac 子状态来处理这个。

状态重置

每次重新连接必须调用 Reset() 把状态机回到 Data,否则上一次连接残留的 SubNegotiation 状态会让新连接的前几个业务字节被当作子协商数据跳过——这是项目初期踩过的坑之一。

第二层:Telnet 传输层(TelnetClient)

解析层解决了协议字节怎么分出来,传输层解决了 Socket 怎么可靠收发。

并发锁设计

Telnet 是半双工协议,收发不能同时进行。用两把 SemaphoreSlim(容量 1)分别保护收和发:

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

为什么不用 lock 关键字?因为 SemaphoreSlim.WaitAsync() 是异步等待,不会阻塞线程池线程,适合高并发场景。而 lock 会阻塞线程,如果在 await 之间需要释放锁(比如 using 块),SemaphoreSlim 配合 using 语法写起来也很清晰。

预缓冲机制:_pendingText

Socket 接收是分包的。假设发了一条 display user-num apn cmcc,网元响应可能被拆成三个 TCP 包到达:

包1: "...用户数 = 100\n==="
包2: "===\n...其他内容...\n--- END"
包3: "NFMPGW>"

如果每次收到包就立刻解析,会在包1就看到不完整的 --- END,导致响应被截断。需要预缓冲 StringBuilder _pendingText,每次收包先追加进去,然后检查缓冲中是否已经包含完整结束标志:

private string TryConsumePending(Func<string, int> findEndIndex)
{
    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;
}

这个设计还有一个额外好处:登录阶段残留的 --- END 不会丢。比如某网元登录时会产生两次 --- END 回显,第一次登录提示符匹配后,第二次 END 会留在 _pendingText 里,下次 ReadUntil 时被 TryConsumePending 首先检查并消费——不需要额外的 ClearPendingText 逻辑。

Decoder 连续解码

UTF-8 编码中,一个中文字符可能跨两个 TCP 包。直接用 Encoding.UTF8.GetString() 解码会导致最后一个字符截断。解决方案是用 Encoding.GetDecoder() 拿到一个有状态的解码器:

private 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 var bytesUsed, out var charsUsed, out _);
    _pendingText.Append(chars, 0, charsUsed);
}

Decoder.Convert() 的第三个参数 flush=false 告诉解码器后面还会有数据进来,不要强求这次把所有字节都解码完。它会在内部保留一个半字符的引用,下次收到新数据时拼接起来再解码。每次重新连接必须重新调用 Encoding.GetDecoder() 重置状态。

IAC 转义(发送方向)

发送的业务数据中如果恰好包含 0xFF 字节,Telnet 协议要求必须用 IAC IAC 转义(两个 0xFF 表示一个业务数据 0xFF):

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 index = 0;
    foreach (var value in data)
    {
        result[index++] = value;
        if (value == 255) result[index++] = 255;
    }
    return result;
}

第三层:ORDP 协议封装层(OrdpClient)

传输层加解析层提供了干净的 ReadUntilAsync("--- END") 接口,但 ORDP 网关有自己的协议——登录是 XML 消息格式,网元登录要等两次提示符。

ORDP XML 消息解析

ORDP 业务消息包裹在 <MESSAGE code="0">...</MESSAGE> 里,每次 ReadUntilAsync(“”) 收到完整 XML 后用正则提取 code 和 message:

private static readonly Regex OrdpMessageRegex = new(
    @"<MESSAGE\s+code=""(?<code>\d+)"">(?<message>.*?)</MESSAGE>",
    RegexOptions.Singleline | RegexOptions.IgnoreCase | RegexOptions.Compiled);

用 Singleline 模式是因为 <MESSAGE> 和 </MESSAGE> 可能跨行,.*? 是非贪婪匹配,确保只匹配到第一个 </MESSAGE>,不会把后面的内容也吃进去。

网元登录:两次提示符等待

华为 UPF 网元登录 ORDP 后,会先执行 LGI(登录)命令产生一个 --- END,再执行 REG NE(注册网元)产生第二个 --- END。如果只读一次提示符就开始发业务命令,REG NE 的 END 会残留在缓冲里,截断第一条 MML 命令的响应。

解决方案:ExpectedCommandPromptCount 参数。登录网元后循环等待 N 次提示符:

for (var i = 0; i < expectedCommandPromptCount; i++)
{
    await ReadUntilCommandPromptAsync(cancellationToken);
}

这个值由网元信息表配置,不同网元可以有不同值。

ExecuteCommandAsync:用 CR 结尾

Telnet 协议中 \r\n 是标准行结束符,但有些网关会把 \r 当作回车、\n 当作换行,导致命令在终端被回显两次。用纯 \r 结尾(SendCrAsync)可以避免这个问题:

// 不能用 SendLineAsync(\r\n),否则命令有回显
await _telnetClient.SendCrAsync(command, cancellationToken);

第四层:业务层响应清理

即使三层协议栈都正确,网元响应里仍然可能有三种噪声,必须在业务层清理:

VT100 终端控制序列清理

private static readonly Regex AnsiEscapeRegex = new(
    @"\x1B(?:\[[0-9;?<=> ]*[@-~]|\][^\x07\x1B]*(?:\x07|\x1B\\)|.)",
    RegexOptions.Compiled);

这个正则覆盖了三类 VT100 序列: 1. ESC[… 形式:光标移动、清屏、清行等(最常见) 2. ESC]… BEL 或 ESC]… ESC\ 形式:设置终端标题(较少见) 3. ESC 后跟任意单字符:如 ESC7(保存光标位置)、ESC8(恢复光标位置)

NUL 空字符清理

Telnet 协议规定 CR(0x0D)后面直接跟 NUL(0x00)表示回车但不换行。很多网关在响应中会填充 NUL 字符,UTF-8 解码后变成 \0,直接字符串替换去掉:

response = AnsiEscapeRegex.Replace(response, string.Empty)
                          .Replace("\0", string.Empty);

输入回显剔除

终端会把你发的命令原样回显一次(行尾只有单个 \r,没有 \n),而网元的原始响应里也会自带一次命令回显。结果就是结果文件里命令出现两次。项目最终的解决方案是匹配行尾只有 \r 没有 \n 的行作为输入回显行剔除,而网元响应自带的回显前后都有 \n。

实战踩坑总结

坑 现象 根因 解决方案
响应开头有乱码 结果文件开头出现 NUL、VT100 序列字符 Telnet 协议的 CR+NUL 以及网关填充字符 正则清理 VT100 序列 + Replace(“\0”, ““)
命令响应被截断 只拿到半条响应 登录阶段残留的 END 留在 _pendingText 中 预缓冲 TryConsumePending 自动消费残留
网元登录超时 华为 UPF 登录后要等很久才返回 LGI + REG NE 两次操作各产生一个 END ExpectedCommandPromptCount 配置为 2
UTF-8 中文乱码 响应中的中文字符变成问号 直接 Encoding.GetString 跨包截断 Decoder 连续解码
命令回显重复 结果文件里每条命令出现两次 SendLineAsync() 触发终端两次回显 改用 SendCrAsync(

总结

从零实现 Telnet 客户端看起来工程量大,但拆成四层后每层职责非常清晰:

  • TelnetProtocolParser:纯粹的字节解析,8 状态状态机处理 IAC 协商和子协商
  • TelnetClient:Socket 异步收发 + 并发锁 + 预缓冲 + 连续解码
  • OrdpClient:在 TelnetClient 之上封装 ORDP 特有的 XML 消息协议和网元登录流程
  • 业务层清理:VT100 + NUL + 输入回显三重净化

这个架构让 Telnet 客户端同时支持华为 PGW(SSH2)、中兴 UPF(SSH2)、ORDP 网关(Telnet)三种不同协议的设备,并且可以优雅地处理不同网元的提示符数量差异。如果你的项目也有类似的设备连接需求,不妨参考这个四层架构的拆分思路——每层只做一件事,上层依赖下层但不感知内部实现。