在网络设备管理系统中,我们经常需要通过 Telnet 协议连接到交换机、路由器等网络设备,发送命令并读取返回结果。虽然 .NET 没有内置的 Telnet 客户端,但基于 Socket 实现一个完整的 Telnet 客户端并不复杂,而且能让我们深入理解 TCP 协议编程和 Telnet 协商机制。

本文基于 SwitchData 项目中真实使用的 TelnetClient 实现,带你从零构建一个生产可用的 Telnet 客户端。

一、整体架构

我们的 Telnet 客户端由两个核心类组成:

  • SocketHelper:封装 TCP 连接,重点解决连接超时问题
  • TelnetClient:实现完整的 Telnet 协议,包括协商握手、命令收发、异步回调

Mermaid 流程图

sequenceDiagram
    participant App as 应用代码
    participant Client as TelnetClient
    participant Helper as SocketHelper
    participant Socket as System.Net.Socket
    participant Server as 网络设备

    App->>Client: Connect(host, port)
    Client->>Helper: Connect(host, port, timeout)
    Helper->>Socket: BeginConnect (异步开始)
    Helper->>Socket: WaitHandle.WaitOne(timeout)
    alt 超时
        Helper-->>App: TimeoutException
    else 成功
        Helper->>Socket: EndConnect
        Socket-->>Client: 已连接 Socket
        Client->>Server: Telnet IAC 协商响应
        Server-->>Client: 协商命令 + 输出数据
        Client->>Client: ProcessOptions 处理协商
        App->>Client: WriteLine(cmd)
        Client->>Server: 发送命令
        Server-->>Client: 返回结果
        App->>Client: Read(delimiter)
        Client-->>App: 读取直到分隔符
    end

二、SocketHelper:异步连接超时

.NET 的 Socket.Connect() 是阻塞调用,没有直接提供超时参数。如果目标主机不可达,连接可能要等几十秒甚至更久才会抛异常。我们用 异步 BeginConnect + WaitHandle 的方式实现可控超时。

public static class SocketHelper
{
    public static Socket Connect(string host, int port, int timeout)
    {
        Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);

        // 异步开始连接,立即返回
        var connectResult = socket.BeginConnect(host, port, null, null);

        // 使用 WaitHandle 等待指定毫秒数
        if (!connectResult.AsyncWaitHandle.WaitOne(timeout, false))
        {
            // 超时:关闭 Socket 并抛异常
            socket.Close();
            socket = null;
            throw new TimeoutException(
                string.Format("Socket连接超时,超时等待时间 {0} 毫秒。", timeout));
        }

        // 成功:完成异步连接
        socket.EndConnect(connectResult);

        return socket;
    }
}

为什么用 BeginConnect 而不是 Connect?

Socket.BeginConnect 是 .NET 早期的 APM(异步编程模型)模式,虽然现在推荐用 ConnectAsync,但 BeginConnect + WaitHandle.WaitOne 组合有一个独特优势:它在 .NET Framework 和 .NET Core/.NET 5+ 中都能稳定工作,而且 WaitHandle.WaitOne 可以精确控制超时时间。

连接状态检测的小技巧

还有一个实用方法,通过非阻塞模式下发送 0 字节数据来检测连接是否存活:

public static bool Connected(Socket socket)
{
    bool blockingState = socket.Blocking;
    try
    {
        byte[] tmp = new byte[1];

        socket.Blocking = false;  // 临时切换为非阻塞模式
        socket.Send(tmp, 0, 0);   // 发送 0 字节(不消耗数据)
        return true;              // 没有异常 = 连接存活
    }
    catch (SocketException e)
    {
        // 10035 == WSAEWOULDBLOCK 表示发送缓冲区暂时满,但连接还是好的
        if (e.NativeErrorCode.Equals(10035))
            return true;
        else
            return false;  // 其他错误 = 连接已断开
    }
    finally
    {
        socket.Blocking = blockingState;  // 恢复原来的阻塞模式
    }
}

这个技巧非常巧妙:发送 0 字节数据不会真正发送任何网络数据,但会触发 Socket 内部的连接状态检查。如果连接已断开,操作系统会立即返回错误。

三、Telnet 协议核心:协商机制

Telnet 协议(RFC 854)的核心是 协商(Negotiation)。连接建立后,客户端和服务端会交换一系列协商命令来确定双方支持的功能。

三种命令类型

Telnet 命令以 IAC(Interpret As Command,字节值 255)开头:

字节值 常量 含义
250 SB 子协商开始(开始一段多字节参数)
251 WILL 我将使用这个选项(主动提供)
252 WONT 我不会使用这个选项(拒绝)
253 DO 请你使用这个选项(请求对方)
254 DONT 请你不要使用这个选项(拒绝对方)
255 IAC 命令标记
240 SE 子协商结束

协商的四种组合

服务端 -> 客户端: IAC DO ECHO     (请求客户端开启回显)
客户端 -> 服务端: IAC WILL ECHO   (同意)

服务端 -> 客户端: IAC WILL TERMTYPE  (主动告知支持终端类型)
客户端 -> 服务端: IAC DO TERMTYPE    (同意对方发送终端类型)

服务端 -> 客户端: IAC DO SGA       (请求抑制继续进行)
客户端 -> 服务端: IAC WONT SGA     (拒绝)

服务端 -> 客户端: IAC WILL 31      (主动支持窗口大小)
客户端 -> 服务端: IAC DO 31        (同意)
客户端 -> 服务端: IAC SB 31 0 80 0 24 IAC SE  (发送窗口大小 80x24)

需要响应的常见选项

  • ECHO (1):回显,双方互发 DO/WILL ECHO
  • SGA (3):抑制继续进行,常见选项
  • TERMTYPE (24):终端类型,需要子协商
  • WINDOWSIZE (31):窗口大小,需要子协商

四、TelnetClient 核心实现

完整字段定义

public class TelnetClient
{
    // Telnet 命令字节常量
    private const byte IAC = 255;
    private const byte WILL = 251;
    private const byte WONT = 252;
    private const byte DO   = 253;
    private const byte DONT = 254;
    private const byte SB   = 250;
    private const byte SE   = 240;

    // 选项常量
    private const byte ECHO       = 1;
    private const byte SGA        = 3;
    private const byte TERMTYPE   = 24;
    private const byte WINDOWSIZE = 31;

    // 内部 Socket 和缓冲区
    private Socket socket;
    private byte[] dataBuffer = new byte[8192];

    // 超时配置
    public int ReceiveTimeout { get; set; } = 10000;
    public int SendTimeout    { get; set; } = 10000;
    public int ConnectTimeout { get; set; } = 10000;
}

ProcessOptions:自动协商响应

这是 TelnetClient 最核心的方法。收到数据后,先扫描里面是否有 Telnet 协商命令,自动生成响应命令发送回去,再把过滤后的纯数据交给上层。

private byte[] ProcessOptions(byte[] buffer)
{
    MemoryStream cmdStream = new MemoryStream();   // 待发送的协商响应
    MemoryStream dataStream = new MemoryStream();   // 过滤后的纯数据
    int i = 0;

    while (i < buffer.Length)
    {
        if (buffer[i] == IAC)
        {
            // 发现 IAC 命令头,读取下一个字节判断命令类型
            switch (buffer[++i])
            {
                case IAC:
                    // IAC IAC = 转义后的 255 字节(数据内容)
                    dataStream.WriteByte(IAC);
                    break;

                case DO:
                    // 服务端请求:请你使用这个选项
                    i++;
                    if (buffer[i] == SGA || buffer[i] == TERMTYPE)
                    {
                        // 支持的选项 -> 回复 WILL
                        WriteWill(cmdStream, buffer[i]);
                    }
                    else if (buffer[i] == WINDOWSIZE)
                    {
                        // 窗口大小:回复 WILL + 子协商(80x24)
                        WriteWillWindowsize(cmdStream);
                    }
                    else
                    {
                        // 不支持 -> 回复 WONT
                        WriteWont(cmdStream, buffer[i]);
                    }
                    break;

                case WILL:
                    // 服务端主动声明:我将使用这个选项
                    i++;
                    if (buffer[i] == SGA || buffer[i] == ECHO)
                    {
                        // 同意 -> 回复 DO
                        WriteDo(cmdStream, buffer[i]);
                    }
                    else
                    {
                        // 不同意 -> 回复 DONT
                        WriteDont(cmdStream, buffer[i]);
                    }
                    break;

                case DONT:
                    // 服务端拒绝:你不要使用这个选项
                    WriteWont(cmdStream, buffer[++i]);
                    break;

                case WONT:
                    // 服务端声明:我不会使用这个选项
                    WriteDont(cmdStream, buffer[++i]);
                    break;

                case SB:
                    // 子协商开始
                    // TERMTYPE 子协商:发送 VT100 终端类型
                    if (buffer[i + 1] == TERMTYPE && buffer[i + 2] == SEND)
                    {
                        WriteTermTypeVt100(cmdStream);
                    }
                    // 跳过整个子协商块直到 SE
                    while (buffer[++i] != SE) { }
                    break;

                default:
                    i++;  // 未知命令,跳过
                    break;
            }
        }
        else
        {
            // 普通数据:CR 转 CRLF,过滤 NUL
            if (buffer[i] == LF)
                { dataStream.WriteByte(CR); dataStream.WriteByte(LF); }
            else if (buffer[i] != CR && buffer[i] != 0)
                dataStream.WriteByte(buffer[i]);
        }
        i++;
    }

    // 发送协商响应
    byte[] cmdBytes = cmdStream.ToArray();
    if (cmdBytes.Length > 0)
        Write(cmdBytes);

    return dataStream.ToArray();
}

子协商:终端类型与窗口大小

最复杂的是子协商。比如服务端请求客户端报告终端类型:

收到: IAC SB TERMTYPE SEND IAC SE
响应: IAC SB TERMTYPE IS "VT100" IAC SE

代码实现:

private static void WriteTermTypeVt100(MemoryStream stream)
{
    // IAC SB TERMTYPE IS "VT100" IAC SE
    stream.WriteByte(IAC);
    stream.WriteByte(SB);
    stream.WriteByte(TERMTYPE);
    stream.WriteByte(IS);   // 0 = IS (声明)
    byte[] vt100 = Encoding.ASCII.GetBytes("VT100");
    stream.Write(vt100, 0, vt100.Length);
    stream.WriteByte(IAC);
    stream.WriteByte(SE);
}

private static void WriteWillWindowsize(MemoryStream stream)
{
    // IAC WILL WINDOWSIZE IAC SB WINDOWSIZE 0 80 0 24 IAC SE
    stream.WriteByte(IAC); stream.WriteByte(WILL); stream.WriteByte(WINDOWSIZE);
    stream.WriteByte(IAC); stream.WriteByte(SB);   stream.WriteByte(WINDOWSIZE);
    stream.WriteByte(0); stream.WriteByte(80);     // 列数 80
    stream.WriteByte(0); stream.WriteByte(24);     // 行数 24
    stream.WriteByte(IAC); stream.WriteByte(SE);
}

五、两种工作模式

同步模式:Read 直到分隔符

适合一次性执行命令并等待结果:

public string Read(string delimiter)
{
    string dataStr = string.Empty;
    int bytes = 0;
    bool found = false;
    do
    {
        bytes = socket.Receive(dataBuffer, 0, dataBuffer.Length, SocketFlags.None);
        if (bytes > 0)
        {
            byte[] receivedData = new byte[bytes];
            Array.Copy(dataBuffer, 0, receivedData, 0, bytes);
            byte[] data = ProcessOptions(receivedData);  // 过滤协商命令
            if (data != null)
            {
                dataStr += Encoding.Default.GetString(data);
                // 检查是否到达分隔符(如 "> " 提示符)
                if (!string.IsNullOrEmpty(delimiter) &&
                    dataStr.TrimEnd().EndsWith(delimiter, StringComparison.Ordinal))
                {
                    found = true;
                }
            }
        }
    } while (bytes > 0 && !found);
    return dataStr;
}

public void WriteLine(string cmd)
{
    Write(cmd + "\r\n");
}

使用示例:

using var telnet = new TelnetClient();
telnet.Connect("192.168.1.1", 23, asyncReceive: false);

telnet.WriteLine("display current-configuration");
string result = telnet.Read("> ");  // 读到提示符就停止
Console.WriteLine(result);

telnet.Disconnect();

异步模式:事件驱动接收

适合需要持续监听输出的场景:

public event EventHandler<DataEventArgs> DataReceived;

private void ReceiveCallback(IAsyncResult ar)
{
    Socket s = (Socket)ar.AsyncState;
    int bytes = s.EndReceive(ar);
    if (bytes > 0)
    {
        byte[] receivedData = new byte[bytes];
        Array.Copy(dataBuffer, 0, receivedData, 0, bytes);
        byte[] data = ProcessOptions(receivedData);
        if (data != null)
            OnDataReceived(data);
        // 继续接收(链式回调)
        s.BeginReceive(dataBuffer, 0, dataBuffer.Length, SocketFlags.None,
                       ReceiveCallback, s);
    }
    else
    {
        Disconnect();
    }
}

使用示例:

var telnet = new TelnetClient();
telnet.DataReceived += (s, e) => Console.Write(Encoding.Default.GetString(e.Data));
telnet.Connect("192.168.1.1", 23, asyncReceive: true);

// 异步模式下,连接建立后协商立即完成,数据通过事件持续推送
telnet.WriteLine("system-view");
telnet.WriteLine("display interface brief");

六、连接管理与资源释放

public void Disconnect()
{
    if (socket != null)
    {
        if (Connected)
        {
            socket.Shutdown(SocketShutdown.Both);  // 先礼貌关闭
            socket.Disconnect(false);
        }
        socket.Close();   // 再强制释放
        socket = null;
    }
}

// 发送数据中的 IAC 字节必须转义
public void Write(string cmd)
{
    // 如果命令中包含 0xFF (IAC),必须转义为 0xFF 0xFF
    byte[] buffer = Encoding.Default.GetBytes(cmd.Replace("\0xFF", "\0xFF\0xFF"));
    if (buffer.Length > 0)
        socket.Send(buffer, 0, buffer.Length, SocketFlags.None);
}

七、实战注意事项

1. 超时是灵魂

Telnet 客户端几乎所有生产问题都和超时有关:

var telnet = new TelnetClient
{
    ConnectTimeout = 5000,     // 连接超时 5 秒
    ReceiveTimeout = 10000,    // 读取超时 10 秒
    SendTimeout = 10000        // 发送超时 10 秒
};

2. 编码问题

网络设备大多返回 GB2312/GBK 编码的中文,Encoding.Default 在 Windows 上通常是 GBK,但在 Linux Docker 容器里可能是 UTF-8。生产环境建议显式指定:

Encoding encoding = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
    ? Encoding.GetEncoding("GBK")
    : Encoding.UTF8;

3. 缓冲问题

Telnet 数据可能被拆包(一次 Receive 只收到半行),也可能粘包(一次 Receive 收到多行)。必须用流式拼接 + 分隔符检测,不能假设一次就能收到完整命令输出。

4. 协商必须即时响应

Telnet 协议要求收到协商命令后尽快回复。有些设备如果等太久没收到 DO/WILL/WONT/DONT 的回应,会断开连接。所以 ProcessOptions 必须在每次 Receive 后立即执行。

八、总结

从零实现 Telnet 客户端让我们掌握了几个关键技术点:

  1. Socket 异步连接超时:用 BeginConnect + WaitHandle.WaitOne 绕过 Connect 无超时的问题
  2. Telnet 协商机制:理解 DO/DONT/WILL/WONT 四种命令和子协商 SB/SE 流程
  3. 自动响应模式:收到协商命令立即回复,否则对端会断开
  4. 同步 vs 异步模式:根据场景选择 Read 等待分隔符或事件回调持续接收
  5. 连接存活检测:非阻塞发送 0 字节 + WSAEWOULDBLOCK 判断连接状态
  6. 编码与换行:CRLF 处理、GBK/UTF-8 编码适配

完整代码见 SwitchData 项目的 SwitchData.Common.Net.TelnetClientSocketHelper