Telnet 协议诞生于 1969 年,比 TCP/IP 协议栈还要早几年。虽然在今天的互联网上 Telnet 已经被 SSH 全面取代,但在网络设备管理、嵌入式开发、自动化运维等领域,Telnet 依然是不可或缺的底层通信方式。本文将基于一个真实的 C#/.NET 项目,带你从零实现一个完整的 Telnet 客户端,并深入剖析协议实现中的核心设计模式与工程化细节。

为什么需要自己实现 Telnet 客户端?

在 SwitchData 项目中,我们需要连接华为、中兴等通信设备厂商的 ORDP(Open Router Development Platform)网关,通过 Telnet 协议发送 MML(Man-Machine Language)命令查询网元数据,例如 PCF 策略签约数统计、UPF 用户数查询等。市面上虽然有开源的 Telnet 库,但往往存在以下问题:

  • 协议解析不完整,无法正确处理子协商(Subnegotiation)
  • 缺乏并发控制,多线程场景下容易死锁
  • 不支持自定义编码(UTF-8、GBK 等)
  • 无超时保护,网元响应慢时阻塞主线程
  • 日志可控性差,难以集成到企业级监控体系

因此,我们决定自己实现一个轻量但完整的 Telnet 客户端,核心代码量仅约 600 行,分为两个类:TelnetProtocolParser(协议解析器)和 TelnetClient(客户端封装)。

Telnet 协议核心机制

在动手写代码之前,我们需要理解 Telnet 协议的核心设计。Telnet 是一种带内信令协议——控制指令和业务数据在同一个字节流中传输,这就需要一套转义和协商机制来区分”这是数据”还是”这是控制命令”。

IAC 转义

Telnet 使用 0xFF(十进制 255)作为 IAC(Interpret As Command)标志字节。当客户端收到 0xFF 时,意味着接下来的字节是控制命令而非业务数据。如果业务数据本身包含 0xFF,需要发送连续两个 0xFF 来表示转义后的 255。

0xFF 0xFF  ->  业务数据中的 0xFF
0xFF 0xFB  ->  WILL 命令(服务器声明要启用某个选项)
0xFF 0xFA ... 0xFF 0xF0  ->  子协商开始/结束

DO/DONT/WILL/WONT 四向协商

Telnet 的选项协商遵循一套对称的四向握手:

命令 含义 方向
WILL (0xFB) 我愿意启用选项 A → B
DO (0xFD) 我同意你启用 B → A
WONT (0xFC) 我不想启用 A → B
DONT (0xFE) 我不同意你启用 B → A

协商的目标是让双方就以下选项达成共识: - Echo (1):是否回显输入字符 - Suppress Go Ahead (3):是否抑制”继续”信号 - Terminal Type (24):客户端声明自己是 VT100、VT220 等 - Window Size (31):客户端告诉服务器终端窗口的行列数

子协商(Subnegotiation)

有些选项(如终端类型、窗口大小)需要传递额外的参数值,这就是子协商:

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

其中 SB (0xFA) 是子协商开始,SE (0xF0) 是子协商结束。Telnet 规范允许 IAC SE 中间再出现 IAC,同样需要转义处理。

状态机设计:TelnetProtocolParser

Telnet 协议解析的核心难点在于:TCP 流是无边界的,数据可能被拆分成多个 TCP 段到达,也可能多个协议消息粘在同一个 TCP 段中。我们需要一个能够处理流式输入、状态连续的解析器——这正是状态机(State Machine)的用武之地。

ParseState 状态定义

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

字节驱动的解析循环

解析器的核心是一个 switch-case 驱动的字节处理循环。每收到一个字节,根据当前状态决定如何处理,并推进到下一个状态:

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

这个设计的优雅之处在于:

  1. 无缓冲担忧:解析器不关心输入是一次性到达还是分多次到达,每次调用都会记住上次的状态
  2. 职责分离:解析器只负责把 Telnet 控制指令剥离出来,输出纯净的业务数据 + 待回复的协商命令
  3. 性能友好:ReadOnlySpan<byte> + foreach 的组合在 .NET 中是零堆分配的热路径

DO 选项处理策略

当服务器发送 IAC DO option 时,客户端需要决定是回 WILL 还是 WONT。我们的策略是:只对业务必需的选项说 YES,其余一律说 NO:

private void HandleDo(byte option, List<byte> responses)
{
    switch (option)
    {
        case SuppressGoAhead:   // 抑制 Go Ahead,必须启用
            AddCommand(responses, Will, option);
            break;
        case TerminalType:      // 声明 VT100 终端
            AddCommand(responses, Will, option);
            break;
        case WindowSize:        // 同时发 Will 和窗口大小子协商
            AddCommand(responses, Will, option);
            AddSubNegotiation(responses, WindowSize, CreateWindowSizeData());
            break;
        default:                // 不认识的选项一律拒绝
            AddCommand(responses, Wont, option);
            break;
    }
}

TelnetClient:异步客户端封装

解析器解决了”理解协议”的问题,而 TelnetClient 则解决了”稳定连接、可靠收发”的问题。

并发控制:双 SemaphoreSlim

一个健壮的 Telnet 客户端必须处理多线程同时发送命令和读/写交叉的场景。我们使用两个 SemaphoreSlim 分别保护读和写操作:

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

发送命令流程:

public async Task SendLineAsync(string text, CancellationToken ct = default)
{
    var data = Encoding.GetBytes(text + "\\r\\n");
    data = EscapeIac(data);  // 业务数据中的 0xFF 需要转义

    await _sendLock.WaitAsync(ct);
    try
    {
        // 带超时保护的发送
        while (offset < data.Length)
        {
            var sent = await socket.SendAsync(...);
            offset += sent;
        }
    }
    finally { _sendLock.Release(); }
}

读取直到匹配:ReadUntilAsync

这是 Telnet 客户端最常用的 API——“发送一个命令,然后读到什么位置才算完成?”。ReadUntilAsync 提供了三种匹配方式:

// 1. 按字符串匹配(最常用,匹配提示符如 "$ ")
await telnet.ReadUntilAsync("$ ");

// 2. 按正则表达式匹配(灵活,匹配命令结束标志)
await telnet.ReadUntilAsync(new Regex(@"--- END[\\r\\n]+"));

// 3. 自定义条件函数(最灵活)
await telnet.ReadUntilAsync(text => {
    var idx = text.IndexOf("Command complete");
    return idx >= 0 ? idx + 16 : -1;
});

核心实现是一个”带缓冲的循环读取”:

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

            // 从 Socket 读取新数据
            var received = await socket.ReceiveAsync(buffer, ct);
            if (received == 0) throw new IOException("连接断开");

            // Telnet 解析:剥离协商指令,回复服务器需要的命令
            var result = _parser.Parse(buffer.AsSpan(0, received));
            if (result.Responses.Length > 0)
                await SendBytesAsync(result.Responses, ct);

            // 将业务数据解码为文本追加到缓冲区
            AppendDecodedText(result.Data);
        }
    }
    finally { _readLock.Release(); }
}

UTF-8 多字节解码的坑

TCP 流按字节传输,而 UTF-8 编码一个中文字符需要 2~3 个字节。如果一个 3 字节的 UTF-8 字符被拆在两个 TCP 包中到达,直接用 Encoding.UTF8.GetString(bytes) 会在分割处产生乱码。

解决方案是使用 Decoder.Convert 进行连续解码:

private Decoder _decoder = Encoding.UTF8.GetDecoder();

private void AppendDecodedText(ReadOnlySpan<byte> data)
{
    var charCount = Encoding.UTF8.GetMaxCharCount(data.Length);
    var chars = new char[charCount];

    _decoder.Convert(data, chars, false,
        out var bytesUsed, out var charsUsed, out _);

    // Decoder 内部维护状态机,记住上次末尾不完整的多字节序列
    _pendingText.Append(chars, 0, charsUsed);
}

Decoder 内部维护了一个状态机,记住”上次最后收到了几个不完整的字节”,下次解码时会先拼上再处理。这是 .NET Encoding 体系中一个非常实用但容易被忽视的 API。

实战:连接 ORDP 网元执行 MML 命令

有了 Telnet 客户端,连接网元就变成了一个”登录 → 发送命令 → 读响应”的固定流程:

var telnet = new TelnetClient();
await telnet.ConnectAsync("188.0.108.25", 28030);

// 登录阶段
await telnet.ReadUntilAsync("login:");
await telnet.SendLineAsync("sjs_wlwyzxt");
await telnet.ReadUntilAsync("Password:");
await telnet.SendLineAsync("xxxxxx");
await telnet.ReadUntilAsync(">");  // 等到提示符

// 发送 MML 命令查询 PCF 策略
await telnet.SendLineAsync("LST PCMCCSUBDATA;");
var response = await telnet.ReadUntilAsync(new Regex(@"--- END[\\r\\n]+"));

// response 就是完整的命令输出
Console.WriteLine(response);

踩坑经验总结

VT100 终端控制序列

Telnet 连接网络设备时,服务器可能输出 VT100 终端控制序列(如 ESC[2J 清屏、ESC[H 定位光标)。这些不是业务数据,需要清理:

private static readonly Regex Vt100Pattern = new(
    @"\\x1B\\[[0-9;]*[A-Za-z]",
    RegexOptions.Compiled);

public static string CleanVt100(string input)
    => Vt100Pattern.Replace(input, "");

NUL 空字符

Telnet 协议中,CR (\\r) 后面跟的 NUL (\\x00) 表示”回车但不换行”,ORDP 网关也可能填充 NUL 作为对齐字符。这些 NUL 会残留在业务数据中,导致生成的文件开头出现乱码:

public static string StripNul(string input)
    => input.Replace("\\x00", "");

命令回显处理

Telnet 的 Echo 选项会让服务器回显客户端发送的每一个字节。因此你发出的 MML 命令会在响应中先出现一遍(这就是 Echo 回显),然后才是真正的执行输出。实际业务中需要根据行尾的 \\r(单回车标记,无后续换行)来识别并剔除 Echo 回显行。

ORDP 直连的命令清理缓冲区

ORDP 网关在 LGI(登录)和 REG NE(注册网元)阶段,会各输出一个 --- END 提示符。如果不及时清理缓冲区,这些残留的提示符会被误当成业务命令的结束标志,导致响应被截断。

解决方案是在发送第一条 MML 命令前清理所有积压的数据:

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

总结

从零实现一个 Telnet 客户端看似复杂,本质上是三个核心设计:

  1. 状态机解析器:用一个枚举 + switch-case 驱动的字节循环,把带内信令的控制指令从业务数据中剥离出来
  2. 异步 Socket + SemaphoreSlim 并发控制:确保多线程场景下连接安全,每个操作都有超时保护
  3. Decoder.Convert 连续解码:正确处理 UTF-8 等多字节编码被 TCP 拆包导致的乱码问题

这个 Telnet 客户端已经在 SwitchData 项目中稳定运行多年,连接着数十台华为 PGW、中兴 UPF 网元,每日执行数千次 MML 命令查询。它的价值不在于”实现了一个协议”,而在于它是状态机 + 异步编程 + 编码细节三者融合的一个完整工程化案例——这三者恰恰是 .NET 后端开发中最核心也最容易被忽视的技能组合。

完整源码见项目 SwitchData.Common/Net/TelnetClient.cs 和 TelnetProtocolParser.cs。