首页 / 资讯中心 / 文章详情

用VC++实现Telnet客户端:从TCP收发到NVT协议详解

用VC++实现Telnet客户端:从TCP收发到NVT协议详解 ★ FEATURED ARTICLE
简介这是一份使用Visual C实现Windows Telnet客户端功能的完整工程项目针对希望深入TCP/IP编程、学习Winsock接口调用的开发者与网络编程初学者。压缩包内共18个文件仅14KB小巧但结构清晰7个.h头文件与6个.cpp源文件构成核心代码附带.dsw/.dsp工程文件可直接在VC中编译打开.rc资源文件与.txt说明文档便于辅助理解与二次开发。代码实现覆盖了从WSAStartup初始化、socket创建、DNS解析、connect连接到send/recv数据收发及基本Telnet协议处理的关键环节并包含常见的错误处理与资源清理逻辑适合作为网络通信课程设计或实战练习的参考模板。目前已有272人学习使用对于想快速上手Winsock或研究Telnet交互逻辑的读者是一份轻量而直接的代码样本能够帮助理解客户端-服务器通信模型并为进一步扩展命令补全、会话记录等功能提供出发点。1. 一个被Windows藏起来的Telnet客户端为什么有人要拿VC重写装完Server版Windows想连一下路由器敲telnet 192.168.1.1系统却提示“telnet不是内部或外部命令”。去“启用或关闭Windows功能”里翻半天勾上Telnet客户端把设备连上了却卡在账号密码那一屏——方向键不能动、退格键出乱码、输入密码时星号都不显示。这种憋屈在现网环境里很常见所以老一代工程师流行用VCVisual C自己写一个远程登录器说白了就是“vc基本实现了Windows中Telnet相似的功能”用TCP协议连到对端23端口把本机键盘输入发给服务端把服务端返回的字节流原样打回屏幕。本文就把这套东西从协议原理到代码实现讲透让你照着能跑通出问题知道去哪里查。2. Telnet到底做了什么TCP连接与NVT协商是“相似”的分水岭标题里“相似”这两个字是关键。Windows自带的Telnet客户端不是一个简单的“TCP串口助手”它在TCP之上套了一层NVTNetwork Virtual Terminal协议还会在建立连接后发一串选项协商字节。VC实现的工具如果只做了TCP收发而不做协议处理就会出现退格键乱码、登录后卡死这类“能用一半”的现象。这一章先把这层窗户纸捅破。2.1 Telnet三件套TCP、NVT、选项协商——差在哪一目了然Telnet协议栈分三层。最底下是TCP连接目标端口默认23也可以是任意端口——所以telnet ip 端口被广泛用来做端口连通性测试这个用法其实没走完整Telnet协议只是借了它的壳。中间层是NVT它规定了一个通用的字符传输格式发送端把本机换行符统一转成CR LF0x0D 0x0A两个字节接收端再按自己的系统习惯还原。Windows本地的换行是CR LF而Unix系只认LF不做转换就会出现“登录之后每行前面多个M”之类的怪相。最上面一层是选项协商。连接建立后客户端会主动发一串以IAC0xFF开头的命令比如请求回显ECHO、请求抑制Go-Ahead、协商终端类型。服务端收到后用WILL/WONT/DO/DONT应答双方就“谁来负责回显字符”“终端是VT100还是别的”达成一致。如果你的VC程序不处理这串字节把原始IAC字节直接打印到屏幕上就会看到屏幕上冒出奇怪的符号甚至登录界面一直不出来。2.2 VC该选哪套网络APICAsyncSocket还是Winsock2做这类工具VC里有两套API可选。一套是MFC封装的CAsyncSocket和CSocket它们把Winsock2包了一层消息驱动比较适合界面程序但有个明显的坑CAsyncSocket的回调在消息循环里触发如果你在控制台程序里用必须自己搞消息循环不然回调永远不执行。另一套是直接用Winsock2的socket/connect/send/recv四个函数配合select做超时控制行为完全可预测出问题时可以用Wireshark抓包对照不需要猜框架内部状态。我一般选后者。原因很朴素Telnet工具的核心逻辑在于收发循环事件驱动模型只会把字节流的处理拆得七零八落排查问题时还要在两个回调函数之间跳来跳去。控制台程序用同步阻塞式socket最简单把接收放在一个独立线程里主线程负责读键盘输入两边通过临界区或者简单的消息队列交换数据。这样代码量最小也最容易跟抓包结果一个一个字节去对。2.3 用十六进制抓包确认协商包到底长什么样在动手写代码前先做一个能看见“协议长什么样”的测试。打开Wireshark抓回环地址127.0.0.1:23的包或者抓真实设备的23端口你会看到客户端连上后发的第一个包往往是ff fb 1f ff fb 18 ff fb 20 ff fb 18 ....逐字节拆开ff是IACfb是WILL1f是终端类型NAWS18是终端速度20是窗口大小……这就是协商的原始形态。我们的VC程序即使不做完整协商也至少要正确处理两件事一是忽略这些以ff开头的命令序列不把它们当普通字符打印二是对服务端发来的DO ECHO请求回一个WILL ECHO或WONT ECHO否则双方的回显状态会一直摇摆屏幕上的输入会时有时无。提示搞清楚协商字节的具体含义可以去看RFC 854和RFC 855里面有完整的命令号和选项号列表。手头没文档时抓包软件直接帮你解析好了比记数字快得多。3. 用Winsock2把这些机制落地一段能编译的最小Telnet客户端现在开始写代码。目标不是复刻Windows客户端的所有功能而是先跑通“连接→收发→能看到登录提示→能输入密码登录成功”这个闭环。这一章的代码在Visual Studio里新建一个Win32控制台应用程序选“空项目”然后添加一个.cpp文件即可注意把项目属性里的“字符集”设为“使用多字节字符集”不然printf和send混用时中文会乱。3.1 搭一个Win32控制台工程把Winsock2接进来先写一个最小的骨架工程。Winsock2.h必须在windows.h之前包含这是VC开发里最常见的坑之一顺序反了会冒出一堆“重定义”的编译错误。// telnet_min.cpp // 最小Telnet客户端骨架初始化Winsock解析参数建立连接 #include winsock2.h #include ws2tcpip.h #include stdio.h #include string.h #pragma comment(lib, ws2_32.lib) int main(int argc, char* argv[]) { // 使用方式telnet_min.exe 192.168.1.1 23 if (argc 2) { printf(usage: %s host [port]\n, argv[0]); return 1; } const char* host argv[1]; int port (argc 3) ? atoi(argv[2]) : 23; WSADATA wsa; // 版本2.21996年以后的Windows都支持 if (WSAStartup(MAKEWORD(2, 2), wsa) ! 0) { printf(WSAStartup failed: %d\n, WSAGetLastError()); return 1; } // 先用getaddrinfo解析域名和IP支持IPv4/IPv6 struct addrinfo hints, *result NULL; memset(hints, 0, sizeof(hints)); hints.ai_family AF_INET; // 先只走IPv4IPv6的坑后面单讲 hints.ai_socktype SOCK_STREAM; hints.ai_protocol IPPROTO_TCP; char portStr[16]; sprintf_s(portStr, %d, port); int rc getaddrinfo(host, portStr, hints, result); if (rc ! 0 || result NULL) { printf(resolve failed: %d\n, rc); WSACleanup(); return 1; } SOCKET sock socket(result-ai_family, result-ai_socktype, result-ai_protocol); if (sock INVALID_SOCKET) { printf(socket failed: %d\n, WSAGetLastError()); freeaddrinfo(result); WSACleanup(); return 1; } // 设置3秒连接超时Winsock的connect超时只能靠select不能直接设置 // 这里的超时逻辑在3.2节单独说明 printf(connecting %s:%d ...\n, host, port); // connect的返回值只有0和-1两种想区分“目标主机可达但端口关着” // 必须靠WSAGetLastError() WSAETIMEDOUT 来判断 rc connect(sock, result-ai_addr, (int)result-ai_addrlen); if (rc SOCKET_ERROR) { printf(connect failed: %d\n, WSAGetLastError()); closesocket(sock); freeaddrinfo(result); WSACleanup(); return 1; } freeaddrinfo(result); printf(connected!\n); // 到这里TCP链路已经通了。下一步是收发循环 // …… closesocket(sock); WSACleanup(); return 0; }这段代码里值得说明的是getaddrinfo的作用。老代码里通常用gethostbyname拿地址但那套接口不支持IPv6而且返回的是静态缓冲区多线程环境下会互相覆盖。getaddrinfo把“域名解析”和“填充sockaddr结构”合到一起返回一个链表的第一个可用地址代码上更干净。connect在Winsock里默认是阻塞的它会一直等到TCP三次握手完成或系统超时大约21秒。这个“黑匣子”一样的21秒很耽误事后面我会给一个用select实现的精确超时方案。现在先把收发循环写出来。3.2 收发循环接收线程加主线程键盘读取连上之后程序要做的事就两件读键盘、发字节收网络、打屏幕。键盘输入用_getch()拿它可以直接读到单个字符而不需要按回车这是Telnet交互的基础——你按一个字母这个字母马上被发给服务端而不是等整行输入完才发。// 接收线程函数把网络收到的字节原样打印到控制台 // 用unsigned char是为了避免0xFF被当作有符号数处理成负数 DWORD WINAPI RecvThread(LPVOID param) { SOCKET sock (SOCKET)param; unsigned char buf[4096]; while (1) { int rc recv(sock, (char*)buf, sizeof(buf), 0); if (rc 0) { // 这里先不做IAC过滤先让输出能显示 // 下一节会换成FilterTelnetBytes函数 for (int i 0; i rc; i) { putchar(buf[i]); } fflush(stdout); } else if (rc 0) { // recv返回0 对端主动关闭连接这是最容易被忽略的通道退出信号 // 很多不成熟的工具在这里会陷入死循环判断条件写反 printf(\n[connection closed by remote]\n); break; } else { int err WSAGetLastError(); if (err WSAEINTR) continue; // 线程被信号打断重试一次 if (err WSAECONNRESET) { // 对端直接发了RST常见于防火墙或服务崩溃 printf(\n[connection reset]\n); } else { printf(\n[recv error: %d]\n, err); } break; } } // 通知主线程退出可以用一个全局标志 g_running 0; return 0; }主线程的循环就简单了// 主线程循环读键盘发送字节 int ch; while (g_running) { ch _getch(); // 不打回车直接读这是交互式Telnet的核心 if (ch 0 || ch 0xE0) { // 功能键是两字节第一个字节是0或0xE0直接忽略不发送 _getch(); // 吃掉第二个字节 continue; } if (ch 0x1B) break; // ESC退出 send(sock, (const char*)ch, 1, 0); }注意recv返回0的场景。很多从教科书抄来的代码只判断rc 0和rc 0把rc 0漏掉导致服务端正常退出后客户端还卡在recv里界面毫无反应——这就是标题里那种“connection closing... socket close”现象的源头。判断条件应该是 0继续、 0退出、 0按错误码分类三态一个都不能少。3.3 给主线程发送加一个可编辑的行缓冲方向键和退格键的处理上面那段代码能把字符发出去了但你会立刻遇到一个新的翻车体验按退格键服务端收到的是0x08本地屏幕并没有清除上一个字符命令行上留下一串残影。Windows自带的Telnet是开着本地回显的本地收到键盘输入时先自己显示一遍服务端收到的ECHO协商会控制输出。这里有个两难如果本地显示服务端又显示一次字符会重复如果本地不显示等网络往返回显延迟高时打字像踩棉花。成熟一点的做法是维护一行的本地编辑状态收到普通字符时存入行缓冲并立即在光标处显示收到退格键时往屏幕输出\b \b退格、空格、再退格来清除一个字符同时从行缓冲里弹出末尾字符收到回车键时把整行缓冲加上\r\n一次发给服务端。这个逻辑三十行的样子就能写完但它决定了工具在真实设备上好不好用远比协商协议更影响体验。我见过不少现网工具连接没问题、登录没问题就是退格键花屏最后被运维同事弃用这属于“协议通了产品没通”。4. 实战避坑IAC歧义、超时假死和中文乱码的排查记录把最小收发跑通之后你会进入一段“玄学调试期”为什么连上设备后屏幕上全是ÿþ这样的字符为什么输入用户名后半天不出密码提示为什么明明端口通了程序却显示超时这一章写的是我在真实设备上调这种东西踩过的坑每一条都有现象、原因和解决方式顺序从高频到低频。4.1 坑一屏幕上出现“ÿ|”等乱码服务端根本不理你现象连接建立后屏幕上直接输出一串含ÿ符号的二进制乱码登录菜单没有正常展示。偶尔乱码中能隐约看到几个英文字母。原因服务端发来的IAC选项协商包没有过滤ÿ就是字节0xFF被直接用%c打印出来的结果。如果服务端发来的是FF FD 01IAC DO ECHO你的程序会把三个字节全当字符显示自然花屏。解决写一个过滤函数在打印之前把IAC字节和它携带的参数一起吞掉。注意IAC后面根据命令类型参数字节数不同WILL(0xFB)、WONT(0xFC)、DO(0xFD)、DONT(0xFE)后各跟一个选项字节总共3字节SB(0xFA)子协商是IAC SB 选项 ... IAC SE的一整包要到0xF0才结束。最省事的方案是状态机解析逐字节扫过去// 过滤Telnet协商字节只把真正的字符数据写入输出 // 返回处理后的字符个数outBuf存放干净的字符流 int FilterTelnetBytes(const unsigned char* src, int srcLen, unsigned char* outBuf, int outBufSize) { int si 0, di 0; // 用枚举记录当前状态普通数据 / 收到IAC / 收到IAC命令 / 子协商中 int state 0; // 0数据, 1IAC, 2IAC命令, 3子协商 while (si srcLen di outBufSize - 1) { unsigned char c src[si]; switch (state) { case 0: if (c 0xFF) state 1; // 遇到IAC进入命令解析 else outBuf[di] c; // 普通字符直接输出 break; case 1: if (c 0xFF) { // 两个连续的FF是“字面量0xFF”只在二进制模式下出现 outBuf[di] 0xFF; state 0; } else if (c 0xFA) { state 3; // 子协商开始一直吞到IAC SE } else { state 2; // WILL/WONT/DO/DONT后面还有一个选项字节 } break; case 2: state 0; // 吞掉选项字节回到数据状态 break; case 3: if (c 0xFF) state 1; // 子协商里遇到IAC可能是结束标记 // 其他字符全部吞掉 break; } } outBuf[di] \0; return di; }这段代码比网上流传的简化版多了个case 3专门处理SB子协商。很多设备在协商终端类型时会发FF FA 18 00 58 54 45 52 4D FF F0如果你只处理了WILL/DO这个子协商包会把终端类型字符串XTERM当普通字符显示在屏幕上。4.2 坑二端口明明开了connect却返回超时现象用系统telnet ip 端口能通用你写的VC程序却卡在“connecting”上21秒后报WSAETIMEDOUT。原因对方防火墙对探测请求做了SYN限速或者丢包处理而Winsock的connect默认超时时间由系统栈决定往往超过20秒。更微妙的是系统telnet命令自己有一套连接超时机制它在看不到SYN-ACK时会提前报错而你自己的程序没有设置这个超时只能干等。解决把socket切换成非阻塞模式用select自己卡超时// 用non-blocking connect select实现精确超时 // 原理非阻塞socket的connect不会等待握手完成而是立即返回WSAEWOULDBLOCK // 接下来select会告诉你这个socket是否可写可写就说明握手完成 u_long mode 1; ioctlsocket(sock, FIONBIO, mode); // 切到非阻塞模式 int rc connect(sock, result-ai_addr, (int)result-ai_addrlen); if (rc SOCKET_ERROR WSAGetLastError() ! WSAEWOULDBLOCK) { printf(connect error: %d\n, WSAGetLastError()); closesocket(sock); return 1; } fd_set wset; FD_ZERO(wset); FD_SET(sock, wset); struct timeval tv; tv.tv_sec 3; // 3秒超时按需要调整 tv.tv_usec 0; rc select(0, NULL, wset, NULL, tv); if (rc 0) { printf(connect timeout or error: %d\n, WSAGetLastError()); closesocket(sock); return 1; } // 连接成功切回阻塞模式后续recv逻辑不变 mode 0; ioctlsocket(sock, FIONBIO, mode);这里有个容易误判的地方select返回1不代表连接一定成功也可能对端回了RST。要彻底确认得在可写之后再调一次getsockopt取SO_ERROR值为0才是真正成功。我在生产工具里是这样做的只判断select 0会出现连到被RST的端口时socket表现为“可写”的假阳性。4.3 坑三中文设备名或菜单内容乱码现象设备的系统菜单是中文登录成功后斜杠和汉字全是乱码。老设备回显的代码页不在UTF-8屏幕输出明显不对。原因控制台的默认代码页和设备的发送编码不一致。设备常见三种情况老思科用ASCII部分国产网络设备发GBK更新的设备默认UTF-8。VC控制台程序如果没调用SetConsoleOutputCP默认输出代码页可能是936GBK或437遇到UTF-8字节流自然全乱。解决程序启动时固定输出代码页同时把接收字节不做任何转换直接输出等显示时再处理。// 启动时固定控制台代码页为UTF-8避免系统区域设置影响显示 SetConsoleOutputCP(CP_UTF8); // 如果你要GBK设备的输出用 // SetConsoleOutputCP(936);注意SetConsoleOutputCP只影响控制台窗口的显示不影响文件重定向。如果设备返回UTF-8而你把它重定向到文本文件文件里不会自动转GBK。生产环境下我一般做一个配置项允许用户指定设备编码程序内部统一转成UCS2再按本地代码页输出这样无论接什么设备都不会乱。这个转换用MultiByteToWideChar和WideCharToMultiByte两个API就够了注意转换失败时要有兜底逻辑不要直接把字节丢给printf。4.4 坑四交互卡顿像拨号上网一秒钟打了三个字都费劲现象按键后屏幕响应有肉眼可见的延迟ping设备只有1ms明显不是网络问题。原因Nagle算法在捣鬼。TCP默认开启Nagle它会把小数据包合并成大包再发而Telnet交互是每次一字节的传输Nagle会等待前面的小包的ACK到达后才发下一个包交互往返被强行拉长到RTT级别。这个问题在Windows自带Telnet里也存在不过它默认已经禁用了Nagle而你的VC程序没有。解决连接成功后立刻设置TCP_NODELAY// 禁用Nagle算法每个send都立即发出不等待合并 int noDelay 1; setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (const char*)noDelay, sizeof(noDelay));这个设置必须在connect之后、send之前调用而且每个socket都要单独设不是全局开关。代价是网络利用率下降——每个字节都单独成一个IP包适合交互工具不适合传输大量日志的场景。5. 从“相似”到“可用”把协商补充完整让工具不再像玩具做到第四章的收尾阶段你已经拥有一个能连、能登录、能退格、中文不乱码的工具了。但它离“可用”还差一步对端设备的回显协商如果做得比较严格你的程序只过滤不回应登录界面能出但输入密码时屏幕上不显示任何东西或者显示双字符。这一章把协商应答补上顺带加一个交互记录功能。5.1 回应服务端的协商请求WILL与DO的应答规则设备侧常见的协商包有三种IAC DO ECHO要求你回显字符、IAC DO NAWS请求窗口尺寸、IAC WILL SGA表示它支持抑制Go-Ahead。回应原则很简单能做到的就答应做不到就明确拒绝千万不要不回应。不回应对端会重试直到超时登录过程会无端地慢几秒。// 在FilterTelnetBytes里增加应答逻辑根据收到的协商命令回包 // 这段代码在state2时被调用cmd是收到的命令类型opt是选项号 void RespondNegotiation(SOCKET sock, unsigned char cmd, unsigned char opt) { unsigned char resp[3]; resp[0] 0xFF; // IAC if (cmd 0xFD) { // 对端DO要求我们WILL或WONT // ECHO(1)我们能做NAWS(31)也能做其余一概WONT if (opt 1 || opt 31) { resp[1] 0xFB; // WILL } else { resp[1] 0xFC; // WONT } resp[2] opt; send(sock, (const char*)resp, 3, 0); } else if (cmd 0xFB) { // 对端WILL我们回应DO或DONT resp[1] 0xFD; // DO if (opt 3) resp[1] 0xFE; // 对于SGA有些老设备回应DONT更稳 resp[2] opt; send(sock, (const char*)resp, 3, 0); } }这里有个老工程师之间的默契DO ECHO要答应但不要真的在本地回显把回显丢给服务端由服务端发回来的数据决定屏幕上显示什么。如果你既接受了回显协商又在本地做行缓冲显示输入字符就会变成AAAABBBB这种双份。我在调试这个时折腾了一个晚上最后发现是自己在发送循环里做了putchar本地显示了一次服务端又回显了一次而协议栈显示正常时应该只打一次。解决方法是接受ECHO协商后发送循环里的本地显示代码要关掉。5.2 终端类型协商为什么不回应会导致菜单变形很多网络设备会根据终端类型决定输出格式——VT100/ANSI支持光标定位和清屏XTERM会激活更多控制序列。如果你的程序不回应终端类型查询设备按dumb terminal处理输出的表格线会全部变成---这种。处理方式是在收到IAC SB TERMINAL-TYPE子协商时发送你的终端类型字符串// 服务端问终端类型时会发IAC SB 24 01 IAC SE // 其中24是TERMINAL-TYPE选项01表示“发送你的终端类型” // 这段代码应在FilterTelnetBytes的case 3子协商状态中触发 unsigned char termResp[] { 0xFF, 0xFA, 0x18, 0x00, // IAC SB TERMINAL-TYPE IS V, C, -, T, U, T, 0xFF, 0xF0 // VC-TUT IAC SE }; send(sock, (const char*)termResp, sizeof(termResp), 0);注意终端类型字符串不要超过32字节里面的IAC结束符FF F0是必须的漏了会导致设备一直等子协商结束登录流程卡死。字符串内容几乎可以随便填但别用XTERM以外的太冷门的名字有些设备只认它认识的类型才会切到彩色模式。5.3 加一个会话记录功能排查问题的时候不用再靠截屏把交互过程存进文件是这类工具被真正用于现网运维时非常加分的一项。实现不复杂在收发循环里各加一个fwrite把原始字节没过滤IAC的版本按时间戳存进日志文件。这样出问题时可以回放你到底敲了什么、设备回了什么。注意要存原始字节流而不是过滤后的明文因为过滤会丢掉协商信息排障时看不到那些关键时刻。日志文件打开时用二进制模式ab避免Windows把字节流里的0x0A吞掉。每次换设备连接时写一个 连接 192.168.1.1:23 分节符方便后续脚本解析。不少值班运维靠这个功能就把“telnet 登录服务器 出现 connection closing... socket close”这种报错的现场留下来了回头找设备厂商时直接把日志丢过去比电话里描述半天强得多。6. 验证这套工具端口连通性测试与批量探测的两种玩法工具写到这里先别急着收工做一个最直观的验证用你自己的程序替代telnet ip 端口去做连通性测试。Windows自带的telnet客户端在端口不通时表现得很拧巴要么卡半天要么弹个窗口就没了而你写的程序在connect失败时可以直接打印错误码并退出配合select还能精确到秒级超时。把程序改一下当连接成功但收不到任何数据时输出“端口开放但无服务”这个能力在排查交换机上那些只开放TCP不提供Telnet服务的端口时特别好用。验证完连通性再试批量探测。写个外层批处理或者PowerShell循环对网段内一批IP的22、23、80、443端口调用你的程序把输出重定向到文本文件for /L %i in (1,1,20) do ( telnet_min.exe 192.168.1.%i 23 scan_result.txt )每次执行完scan_result.txt里每一行的“connect failed”和“connected”就是最朴素的扫描结果。注意你的工具要在连接失败时返回非零退出码批处理才能判断成功失败main函数里return 0和return 1的含义要明确分开。最后一个习惯分享这类工具要长期维护的话尽量把send和recv都包一层函数里面统一打印十六进制日志。我吃过一次亏设备厂商让我抓包对比我的程序只打了明文协商字节在屏幕上根本看不到最后被迫临时改代码加日志。从那之后发字节和收字节都会记录原始数据哪怕暂时用不到。希望这套思路帮到你实际做的时候从最小收发循环开始一章一章往上加功能遇到奇怪现象就抓包对照比在网上搜“telnet工具乱码”靠谱得多。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站