前言

在物联网设备管理、ORDP 网关接入、网元脚本下发等场景中,Telnet 协议仍然被广泛使用。虽然 Renci.SshNet 等库提供了 SSH 客户端,但在某些老旧设备或特定协议场景下,我们不得不直接使用 Telnet。

本文带你从协议原理到完整实现,手写一个 生产可用 的 Telnet 客户端。源码来自项目 SwitchData.Common.Net.TelnetClient,经过实际 ORDP 网关连接验证。

Telnet 协议核心概念

Telnet 本质是一个 TCP 协议,在业务数据之上定义了一套 选项协商(Option Negotiation) 机制。核心规则:

  • 255 是 IAC(Interpret As Command)字节:表示后面跟的是 Telnet 命令,不是业务数据
  • IAC IAC 连续出现表示 业务数据中的 255 字节(转义)
  • 协商方向:服务端发 DO/DONT 问客户端是否支持某选项;客户端发 WILL/WONT 回答
  • 子协商:终端类型、窗口大小等需要传递参数的选项,用 IAC SB option ... IAC SE 包裹
┌──────────────────────────────────────────────┐
│              Telnet 数据包结构                 │
├──────────────────────────────────────────────┤
│  业务数据...                                  │
│  IAC + DO  + 选项     ← 服务端询问            │
│  IAC + WILL + 选项    ← 客户端同意            │
│  IAC + SB + 选项 + 参数 + IAC + SE  ← 子协商  │
│  业务数据...                                  │
└──────────────────────────────────────────────┘

架构设计

我们将客户端拆成两个类,职责分离:

类 职责
TelnetClient Socket 连接管理、读写锁、超时控制、公共 API
TelnetProtocolParser 逐字节解析协议、处理协商、剥离控制命令

客户端暴露 ConnectAsync、SendLineAsync、ReadUntilAsync 三个核心方法,其中 ReadUntilAsync 支持 字符串定界符 和 正则表达式 两种结束条件。

核心实现

第一步:TelnetProtocolParser —— 状态机解析器

Telnet 协议是天然的 有限状态机(FSM):每个输入字节根据当前状态和字节值决定下一步行为。

public sealed class TelnetProtocolParser
{
    private const byte Iac = 255;
    private const byte Do = 253;
    private const byte Dont = 254;
    private const byte Will = 251;
    private const byte Wont = 252;
    private const byte Sb = 250;  // Sub-negotiation Begin
    private const byte Se = 240;  // Sub-negotiation End

    private enum ParseState { Data, Iac, Do, Dont, Will, Wont, SubNegotiation, SubNegotiationIac }
    private ParseState _state = ParseState.Data;

    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:
                    // IAC IAC = 业务数据中的 0xFF
                    if (value == Iac) { data.Add(Iac); _state = ParseState.Data; }
                    else if (value == Do) { _state = ParseState.Do; }
                    else if (value == Will) { _state = ParseState.Will; }
                    else if (value == Sb) { /* 进入子协商 */ _state = ParseState.SubNegotiation; }
                    else { _state = ParseState.Data; }
                    break;

                case ParseState.Do:
                    HandleDo(value, responses);  // 协商回复
                    _state = ParseState.Data;
                    break;
                // ... 其他状态处理省略
            }
        }
        return new TelnetParseResult(data.ToArray(), responses.ToArray());
    }
}

为什么用状态机? 因为 Telnet 协议不保证一个 TCP 包恰好包含完整的一条 Telnet 命令。你可能在数据包正中间遇到 IAC DO 24,也可能 DO 和 24 被拆到两个包。状态机逐字节流转天然解决了这个问题。

第二步:协商回复策略

收到服务端的 DO TERMINAL-TYPE 后,我们不仅要回复 WILL TERMINAL-TYPE,还要主动发子协商告诉服务端自己是 VT100 终端:

private void HandleDo(byte option, List<byte> responses)
{
    switch (option)
    {
        case 3:  // Suppress Go Ahead
            AddCommand(responses, Will, option);   // WILL SUPPRESS-GO-AHEAD
            break;
        case 24: // Terminal Type
            AddCommand(responses, Will, option);   // WILL TERMINAL-TYPE
            break;
        case 31: // Window Size
            AddCommand(responses, Will, option);
            AddSubNegotiation(responses, WindowSize, CreateWindowSizeData());
            break;
        default:
            AddCommand(responses, Wont, option);   // 不支持的选项明确拒绝
            break;
    }
}

实战经验:与华为 ORDP 网关交互时,如果不主动回复窗口大小子协商,服务端会卡住等你回应,最终超时。

第三步:TelnetClient —— Socket + 读写锁

public sealed class TelnetClient : IAsyncDisposable
{
    private readonly TelnetProtocolParser _parser = new();
    private readonly SemaphoreSlim _readLock = new(1, 1);   // 读互斥
    private readonly SemaphoreSlim _sendLock = new(1, 1);   // 写互斥
    private Socket _socket;
    private readonly StringBuilder _pendingText = new();  // 累积的业务文本

为什么用两个 SemaphoreSlim? Telnet 是全双工协议:你可以一边接收服务端推送,一边发送命令。但同一个 Socket 不能有两个并发的 ReceiveAsync,也不能有两个并发的 SendAsync。用两个独立的锁保证读写各自串行。

第四步:连接与 DNS 解析

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 cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
            cts.CancelAfter(ConnectTimeout);  // 默认 10 秒

            await socket.ConnectAsync(new IPEndPoint(address, port), cts.Token);
            _socket = socket;
            return;
        }
        catch (Exception ex)
        {
            lastException = ex;
            socket.Dispose();
        }
    }
    throw new SocketException((int)SocketError.NotConnected);
}

细节:Dns.GetHostAddressesAsync 可能返回多个 IPv4/IPv6 地址,逐个尝试是标准的容错做法。超时用 CancellationTokenSource.CreateLinkedTokenSource 把外部传入的取消令牌和内部超时令牌绑定在一起。

第五步:ReadUntilAsync —— 核心读取循环

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

private async Task<string> ReadUntilAsync(Func<string, int> findEndIndex, CancellationToken cancellationToken)
{
    await _readLock.WaitAsync(cancellationToken);
    try
    {
        while (true)
        {
            // 1. 先消费之前累积的 pendingText
            var cached = TryConsumePending(findEndIndex);
            if (cached != null) return cached;

            // 2. 从 Socket 收数据
            var buffer = new byte[8192];
            using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
            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. 有协商响应 → 立即发回去(不能等!)
            if (parseResult.Responses.Length > 0)
            {
                await SendBytesAsync(parseResult.Responses, cancellationToken);
            }

            // 5. 有业务数据 → 追加到 pendingText
            if (parseResult.Data.Length > 0)
            {
                AppendDecodedText(parseResult.Data);
            }
        }
    }
    finally { _readLock.Release(); }
}

这是最精妙的部分:

  1. 先查 pendingText:上一次 ReadUntil 匹配到定界符后,定界符之后的残余文本保留在 _pendingText,下一次读取先查这里
  2. 协议响应立即发:协商响应不能缓存,必须立刻发回,否则服务端会认为你不响应选项协商
  3. Decoder 连续解码:不能每次 Encoding.GetString(data) 一次性转,要复用同一个 Decoder 实例

第六步:Decoder 连续解码(处理 UTF-8 多字节截断)

一个 UTF-8 字符可能占 1~4 字节。如果一个 TCP 包在某字符中间截断了(比如收了 2 字节,剩 1 字节),用 Encoding.GetString 会产生乱码或异常。

正确做法是复用 Encoding.GetDecoder(),让 Decoder 维护内部缓冲:

private Decoder _decoder;  // 在 Encoding setter 中初始化

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

    _decoder.Convert(
        data,           // 输入字节
        chars,          // 输出字符
        false,          // flush = false,告诉 decoder 还有后续数据
        out var bytesUsed,
        out var charsUsed,
        out _);

    _pendingText.Append(chars, 0, charsUsed);
}

Encoder.Convert 的 flush=false 会让 Decoder 把一个字符还没收到的剩余字节缓存在内部,下次调用时补全。这样无论 TCP 包怎么切分,解码都不会出错。

第七步:IAC 转义

Telnet 协议规定,业务数据中如果出现 255 字节,发送时必须写成 255 255(IAC IAC),接收时再还原。发送端必须做转义:

private static byte[] EscapeIac(ReadOnlySpan<byte> data)
{
    int count = 0;
    foreach (var v in data) if (v == 255) count++;
    if (count == 0) return data.ToArray();

    var result = new byte[data.Length + count];
    int idx = 0;
    foreach (var v in data)
    {
        result[idx++] = v;
        if (v == 255) result[idx++] = 255;
    }
    return result;
}

接收端的还原工作由 TelnetProtocolParser 的 case ParseState.Iac 分支完成:遇到 IAC IAC 时把一个 255 写入业务数据缓冲区。

第八步:IAsyncDisposable 异步释放

.NET Core 3.0+ 引入 IAsyncDisposable,适用于需要异步清理资源(如等 Socket 关闭完成)的场景:

public sealed class TelnetClient : IAsyncDisposable
{
    private bool _disposed;

    public ValueTask DisposeAsync()
    {
        if (_disposed) return ValueTask.CompletedTask;
        _disposed = true;

        Disconnect();       // 关闭 Socket
        _readLock.Dispose(); // 释放 SemaphoreSlim
        _sendLock.Dispose();
        GC.SuppressFinalize(this);
        return ValueTask.CompletedTask;
    }

    public void Disconnect()
    {
        var socket = Interlocked.Exchange(ref _socket, null);
        if (socket is null) return;
        try { socket.Shutdown(SocketShutdown.Both); }
        catch { }
        finally { socket.Dispose(); }
    }
}

Interlocked.Exchange 保证多线程场景下只会由一个线程真正关闭 Socket。

使用示例

await using var telnet = new TelnetClient();

// 连接 ORDP 网关
await telnet.ConnectAsync("188.0.108.25", 28030);

// 登录
await telnet.SendLineAsync("username");
var loginResp = await telnet.ReadUntilAsync("Password:");
await telnet.SendLineAsync("password");
var prompt = await telnet.ReadUntilAsync(">");

// 发 MML 命令
await telnet.SendLineAsync("LST PCMCCSUBDATA;");

// 等待完整响应(直到出现 --- END)
var response = await telnet.ReadUntilAsync("--- END");

Console.WriteLine(response);

await using 会在作用域结束时自动调用 DisposeAsync(),释放 Socket 和 SemaphoreSlim。

实战踩坑记录

坑 1:登录阶段残留的 — END

华为 ORDP 网关登录后,LGI 和 REG NE 命令会各产生一个 --- END 提示符。如果登录只等了一个 --- END,第二个会残留到 _pendingText,导致首条 MML 命令的响应被截断。

解法:在发送业务命令前调用 ClearPendingText() 清空缓冲区:

public void ClearPendingText() => _pendingText.Clear();

坑 2:VT100 控制序列

部分设备会在输出中插入 ESC[K(清除行)、ESC7(保存光标)等 VT100 终端控制序列。这些不是业务数据,需要过滤掉:

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

public string CleanResponse(string raw)
{
    return Vt100Sequence.Replace(raw, "");
}

坑 3:并发连接超载 ORDP 网关

ORDP 网关(188.0.108.25)是单点,测试发现并发连接超过 5 条时,单条 MML 命令耗时从 2 秒飙升到 10 秒以上。在业务层需要用 SemaphoreSlim(5, 5) 做并发限流。

总结

手写 Telnet 客户端的核心要点:

要点 技术
协议解析 状态机逐字节处理(不要用正则一次性匹配)
读写安全 两个独立的 SemaphoreSlim 保证全双工
编码安全 复用 Decoder 处理 UTF-8 多字节截断
连接容错 Dns.GetHostAddressesAsync + 逐个尝试
资源管理 IAsyncDisposable + Interlocked.Exchange
协议细节 IAC 转义、协商响应立即发送
实战补丁 清理 pendingText、过滤 VT100 序列、并发限流

完整源码可在项目 SwitchData.Common\Net\TelnetClient.cs 和 TelnetProtocolParser.cs 找到,生产环境已验证通过。


推荐阅读:RFC 854 Telnet Protocol Specification,以及本项目同目录下的 OrdpClient.cs(SSH2 协议客户端实现,结构非常相似)。