在网络设备管理系统中,我们经常需要通过 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 客户端让我们掌握了几个关键技术点:
- Socket 异步连接超时:用
BeginConnect+WaitHandle.WaitOne绕过 Connect 无超时的问题 - Telnet 协商机制:理解 DO/DONT/WILL/WONT 四种命令和子协商 SB/SE 流程
- 自动响应模式:收到协商命令立即回复,否则对端会断开
- 同步 vs 异步模式:根据场景选择 Read 等待分隔符或事件回调持续接收
- 连接存活检测:非阻塞发送 0 字节 + WSAEWOULDBLOCK 判断连接状态
- 编码与换行:CRLF 处理、GBK/UTF-8 编码适配
完整代码见 SwitchData 项目的
SwitchData.Common.Net.TelnetClient 和
SocketHelper。