前言
在物联网设备管理、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(); }
}
这是最精妙的部分:
- 先查 pendingText:上一次 ReadUntil
匹配到定界符后,定界符之后的残余文本保留在
_pendingText,下一次读取先查这里 - 协议响应立即发:协商响应不能缓存,必须立刻发回,否则服务端会认为你不响应选项协商
- 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
协议客户端实现,结构非常相似)。