ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用VC自写Telnet客户端:Winsock网络编程与IAC协议解析

用VC自写Telnet客户端:Winsock网络编程与IAC协议解析 简介面向Windows网络编程与Telnet协议初学者的VC客户端工程演示如何利用Winsock实现远程登录核心功能。压缩包共18个文件以7个h头文件与6个cpp源文件为主附有dsw/dsp工程组织文件、rc界面资源、clw工作区信息及txt说明文档整体大小仅14KB结构紧凑适合代码研读。已有272人学习适合想通过实际代码理解TCP/IP通信与Telnet交互的开发者。源码完整覆盖Winsock初始化、DNS解析、socket连接、send/recv数据收发等关键环节并考虑了错误处理、套接字关闭与WSACleanup清理工作此外划分了客户端类与ProtocolRx协议处理模块Resource.h、StdAfx等辅助文件保证了工程的直接可编译性。通过梳理这套工程读者可快速掌握从零搭建网络通信程序的方法并在此基础上为后续添加命令历史、自动补全、颜色编码等友好交互功能提供清晰可扩展的起点。1. 用 VC 自写 Telnet 客户端这个标题到底在解决什么问题Windows 自带 telnet.exe平时查端口、登录交换机都很顺手但它的能力边界也很清楚一个不经装饰的控制台命令没法把会话接进自己的工具里。标题里“vc基本实现了Windows中Telnet相似的功能”本质是用 VCVisual C调 Winsock把 Telnet 客户端的主链路自己搭出来——连 23 端口、按 NVT 字节流收发、响应 IAC 协商。它实际解决的是把远程登录能力从“人敲命令”变成“代码可调用的函数”。适合两类人一类是运维开发想把设备巡检、自动登录嵌进自己的 Windows 工具另一类是刚接触网络编程的学生想找一个比“Hello socket”更贴近真实协议的练手项目。“基本实现”这句话是定位不是谦虚核心链路全做但不是要复刻 Windows telnet 的脚本化转义模式。2. Telnet 的协议骨架23 端口上的 NVT 字节流与 IAC 协商很多人对 Telnet 的第一印象是telnet ip 端口把它当成端口连通性测试工具。真实 Telnet 不是“敲一下看通不通”这么简单它是一条持续双向流动的 TCP 会话服务器随时会推数据客户端也随时要响应协议协商。自写客户端之所以难难的不是 connect而是这条字节流里混着普通文本和控制命令。这一层不拆开后面的代码全是野路子。2.1 Telnet 的最小工作模型一条 TCP 连接上的 NVT 字节流Telnet 默认端口是 23协议定义在 RFC 854。传输载体就是普通 TCP但数据格式不是 HTTP 那种“请求-响应”而是 NVT网络虚拟终端字节流。NVT 本来只要求 7 位 ASCII后来扩展允许 8 位所以中文设备的输出也能走通。关键点是命令文本、回显控制、终端类型询问全部塞在同一个字节流里接收方必须逐字节判断当前字节到底是数据还是命令。实际抓一次 Telnet 登录流量你会发现服务器发来的第一包往往不是 banner而是一段IAC DO SUPPRESS_GO_AHEAD。如果像处理普通字符串一样一次读一行、一次读三个字节很容易把控制字节混进界面。自写客户端的第一原则就是TCP 的 recv 不能保证一次拿到一条完整消息Telnet 也没有“消息”概念只有字节流所以网络侧代码一律按逐字节状态机处理不按行处理。2.2 用一张表记下 Telnet 协商命令IAC、WILL、DO、SB/SETelnet 控制信息的起始标记是 IACInterpret As Command十进制 255十六进制 0xFF。数据流里一旦出现 0xFF后面两个字节就是命令和选项如果数据里真需要传一个 0xFF发送方必须把它连写两次也就是 255 255。接收方看到连续两个 0xFF 时把第二个当普通数据。这个转义规则和 C 语言里写%%是一个思路理解它解析状态机的三个状态就都清楚了。命令码只需要记常用几个我省事直接列成表字节名称含义240SE子协商结束250SB子协商开始251WILL我愿意支持某个选项252WONT我不愿支持某个选项253DO我要求你支持某个选项254DONT我要求你不要支持某个选项255IAC命令起始标记选项码按使用频率排前四个够用编号选项用途1ECHO回显控制登录和密码输入全看它3SUPPRESS_GO_AHEAD抑制 GA多数服务器默认支持24TERMINAL_TYPE终端类型决定服务器发 VT100 还是别的控制序列31NAWS窗口大小全屏交互才用协商逻辑是“请求-回应”的A 发IAC WILL 1表示 A 想自己开启回显B 同意就回IAC DO 1不同意回IAC DONT 1A 发IAC DO 24表示要求 B 提供终端类型B 同意回IAC WILL 24不同意回IAC WONT 24。在抓包里看到255 251 1就是IAC WILL ECHO看到255 253 24就是IAC DO TERMINAL_TYPE。记住这条对称规则第 4 章的代码就能顺下来。2.3 先把解析状态机搭出来逐字节处理 IAC 序列新手最常见的翻车是把自己写死在“一次组包”上网络上IAC WILL ECHO可能跨两个 recv第一次只收到 255第二次才收到 251 和 1。正确做法是维护一个小状态机每读一个字节走一格。最小版先这样写#define IAC 255 #define WILL 251 #define WONT 252 #define DO 253 #define DONT 254 #define SE 240 #define SB 250 static int tn_state 0; /* 0普通数据, 1已收到IAC, 2已收到命令 */ static unsigned char tn_cmd; /* 记录 IAC 后面的命令字节 */ void telnet_feed(const unsigned char* buf, int len) { for (int i 0; i len; i) { unsigned char ch buf[i]; switch (tn_state) { case 0: if (ch IAC) { tn_state 1; } else { putchar(ch); /* 普通文本直接输出 */ } break; case 1: if (ch IAC) { putchar(ch); /* 数据里的 0xFF显示原值 */ tn_state 0; } else { tn_cmd ch; tn_state 2; } break; case 2: /* ch 此时是选项编号例如 1 表示 ECHO */ printf(\n[IAC cmd%d opt%d]\n, tn_cmd, ch); tn_state 0; break; } } }这个状态机有三个状态普通数据、刚看到 IAC、刚看到命令。tn_state 是状态变量tn_cmd 暂存 WILL 或 DO 这类命令。每步只做状态转换没有复杂的内存分配。这个版本故意没处理 SB/SE因为子协商的负载不定长等用到终端类型时再补也就是第 4 章的事。协议骨架立住之后下一步是把状态机接到 Winsock 上让它有数据可吃。3. 用 Winsock 把 Telnet 客户端搭起来连接、收发循环与 select 超时协议层解决“数据怎么解析”接下来解决“数据从哪来”。VC 在 Windows 上做网络编程绕不开 Winsock。这一篇给出一份能直接编译的最小客户端骨架从 WSAStartup 一路走到收发循环。3.1 选型为什么用阻塞 socket select而不是异步事件Winsock 有三条路阻塞 socket、WSAAsyncSelect 异步、完成端口 IOCP。Telnet 客户端这种一对一会话最省事的是阻塞 socket 加 select 超时。原因很具体控制台程序没有消息循环用 WSAAsyncSelect 必须先把消息泵转起来否则 WM_SOCKET 消息没人处理IOCP 是给高并发服务器准备的单连接杀鸡用牛刀。阻塞 socket 有个缺陷是 recv 会卡住但 select 能给它加个超时让主循环每 200 毫秒醒来一次顺手检查键盘输入这就是单线程下最朴素的“准异步”写法。整个循环只有一个 select、一个 recv、一个 _kbhit心智负担最低也最容易 debug。如果你以后要把连接数扩到几十路这个模型就不够看了那时候再谈 WSAEventSelect 或 IOCP 不迟。3.2 初始化与 TCP 连接WSAStartup 到 connect 的完整代码Winsock 在 VC 工程里第一步是链接 ws2_32.lib最省事的方法是用 pragma 注释带上。下面这份 tn_connect 负责初始化并建立 TCP 连接#pragma comment(lib, ws2_32.lib) #include winsock2.h #include ws2tcpip.h #include stdio.h #include conio.h int tn_connect(const char* host, int port, SOCKET* out_sock) { WSADATA wsa; struct addrinfo hints {0}, *res NULL; char port_str[16]; SOCKET s INVALID_SOCKET; if (WSAStartup(MAKEWORD(2, 2), wsa) ! 0) { fprintf(stderr, WSAStartup failed\n); return -1; } snprintf(port_str, sizeof(port_str), %d, port); hints.ai_family AF_INET; hints.ai_socktype SOCK_STREAM; hints.ai_protocol IPPROTO_TCP; if (getaddrinfo(host, port_str, hints, res) ! 0) { fprintf(stderr, getaddrinfo failed: %d\n, WSAGetLastError()); WSACleanup(); return -1; } s socket(res-ai_family, res-ai_socktype, res-ai_protocol); if (s INVALID_SOCKET) { fprintf(stderr, socket failed: %d\n, WSAGetLastError()); freeaddrinfo(res); WSACleanup(); return -1; } if (connect(s, res-ai_addr, (int)res-ai_addrlen) SOCKET_ERROR) { fprintf(stderr, connect failed: %d\n, WSAGetLastError()); closesocket(s); freeaddrinfo(res); WSACleanup(); return -1; } freeaddrinfo(res); *out_sock s; return 0; }参数说明MAKEWORD(2,2) 请求 Winsock 2.2这是 Windows XP 以后所有系统都支持的最低版本。getaddrinfo 替代老掉牙的 gethostbyname既能填 IP 也能解析域名而且把端口号一起处理省去手写 sockaddr_in。失败路径上每一步都做了清理freeaddrinfo、closesocket、WSACleanup顺序不能乱否则资源泄漏会一路带进后续逻辑。编译时还有一个容易踩的点VC 工程字符集建议选“多字节字符集”不要选 Unicode。Telnet 本质是字节流用 char 数组最顺手选成 Unicode 后TCHAR 和 char 混用会冒出一堆 C2440 编译错误纯属自我消耗。3.3 收发主循环select 超时轮询键盘与网络连接只是起点Telnet 是持续交互的。主循环用 select 同时管两件事网络可读、键盘可读。select 返回后优先收网络收完再轮询一次键盘200 毫秒的窗口足够让 CtrlC 这类操作得到响应。int tn_loop(SOCKET s) { unsigned char buf[4096]; fd_set rfd; struct timeval tv; while (1) { FD_ZERO(rfd); FD_SET(s, rfd); tv.tv_sec 0; tv.tv_usec 200 * 1000; /* 200ms 超时 */ if (select(0, rfd, NULL, NULL, tv) 0) { int n recv(s, (char*)buf, sizeof(buf), 0); if (n 0) { printf(\nConnection closed by remote host.\n); break; } if (n SOCKET_ERROR) { printf(recv error: %d\n, WSAGetLastError()); break; } telnet_feed(buf, n); /* 第4章会补成完整版本 */ } if (_kbhit()) { unsigned char key[8]; int len tn_getkey(key); if (len 0) { send(s, (const char*)key, len, 0); } } } closesocket(s); WSACleanup(); return 0; }这里有三处要留意。select 的第四个参数是超时结构体每次循环都要重新赋值因为 select 返回后会把它改写成剩余时间复用同一个 timeval 会越等越短直到永远超时。recv 返回 0 是服务器主动断开返回 SOCKET_ERROR 再查 WSAGetLastError 区分具体错误。缓冲区 4096 不是随便写的Telnet 交互式输出一屏通常在 2000 字节以内4096 能保证一次 recv 拿到完整一屏真要刷满屏日志这个工具就不该用 Telnet 方式取。4. 把协商和回显做对DO/DONT/WILL/WONT 在 VC 里的落地写法连接建好只是壳子。真正常见的情况是连上了、banner 出来了但输入不显示、密码不隐显、设备提示终端类型不对。问题全出在协商没落地。这一层不补你的客户端只能看不能聊。4.1 服务器说 WILL/WONT客户端该怎么回服务器发IAC WILL ECHO意思是“我要接管回显”客户端按协议要回IAC DO ECHO服务器发IAC WONT ECHO客户端回IAC DONT ECHO同时自己开本地回显否则刚敲的字符一个都看不见。不同设备对 ECHO 的态度不同客户端必须两套都支持。void send_iac(SOCKET s, unsigned char cmd, unsigned char opt) { unsigned char pkt[3] { IAC, cmd, opt }; send(s, (const char*)pkt, 3, 0); } void handle_option(SOCKET s, unsigned char cmd, unsigned char opt) { switch (cmd) { case WILL: /* 对方想开启某选项 */ if (opt 1) { /* ECHO */ send_iac(s, DO, 1); /* 同意回显交给服务器 */ server_echo 1; local_echo 0; } else if (opt 3) { send_iac(s, DO, 3); /* SUPPRESS_GO_AHEAD 同意 */ } else { send_iac(s, DONT, opt); /* 不认识的选项拒绝 */ } break; case WONT: /* 对方不想开启某选项 */ if (opt 1) { send_iac(s, DONT, 1); /* 服务器不回了客户端补位 */ server_echo 0; local_echo 1; } else { send_iac(s, DONT, opt); } break; case DO: /* 对方要求客户端开某选项 */ if (opt 24) { /* TERMINAL_TYPE */ send_iac(s, WILL, 24); } else { send_iac(s, WONT, opt); } break; case DONT: send_iac(s, WONT, opt); break; } }send_iac 就是三个字节的封装IAC、命令、选项在网络上是一个整体合在一起发最稳。local_echo 和 server_echo 是两个全局变量server_echo 表示服务器是否开着回显local_echo 表示客户端要不要自己回显这两个状态决定了按键输出逻辑。不认识的选项统一回 DONT 或 WONT不要不回包不回包会让服务器一直等等超时它就可能断会话。4.2 回显与按键处理避免输入不显示或双字符回显翻车有两种一种是什么都不显示一种是每个字符出现两遍。前者是服务器 WONT ECHO 而客户端没开本地回显后者是两边都开了回显。另外还要注意一个小细节本地回显的换行要自己处理终端习惯把回车解释成回车加换行直接 putchar(\r) 会把光标拉回行首却不下移屏幕上像被顶住一样。int local_echo 1; int server_echo 0; int tn_getkey(unsigned char* out) { int ch _getch(); if (ch 0 || ch 0xE0) { /* 方向键有两个字节 */ int ch2 _getch(); switch (ch2) { case 0x48: out[0] 0x1B; out[1] [; out[2] A; return 3; /* 上 */ case 0x50: out[0] 0x1B; out[1] [; out[2] B; return 3; /* 下 */ case 0x4D: out[0] 0x1B; out[1] [; out[2] C; return 3; /* 右 */ case 0x4B: out[0] 0x1B; out[1] [; out[2] D; return 3; /* 左 */ default: return 0; } } if (ch \r) { out[0] \r; /* 回车原样发给服务器 */ return 1; } if (ch 8) { out[0] 8; /* 退格发 0x08不发 0x7F */ return 1; } out[0] (unsigned char)ch; return 1; }在收发循环里发送前先过一遍本地回显if (local_echo !server_echo) { for (int i 0; i len; i) { if (key[i] \r) putchar(\n); else putchar(key[i]); } }参数说明方向键在控制台 API 里是首字节 0x00 或 0xE0、次字节 0x48 到 0x4D必须两次 _getch 才能拿到完整键值直接读一次只拿到 0 然后丢掉真正的键。退格键有的终端要 0x08有的要 0x7F网络设备 CLI 基本都能吃 0x08。如果你登录的是 Unix 类设备方向键需要转成 ESC [ A 这样的序列这也是 switch 里做的事。提示方向键的虚拟键码是控制台层统一的不用自己发明写死 0x48/0x4B 这些常量即可如果换到 Windows Terminal 里跑这套代码一样有效。4.3 终端类型与子协商把 SB/SE 补齐否则 VT100 白报不少设备登录后会发IAC DO TERMINAL_TYPE来问客户端是什么终端。客户端如果只回一个 WILL 24 而不做后续设备会一直认为你是 dumb terminal菜单和全屏交互全部错乱。正确流程是收到 DO 24 回 WILL 24然后服务器发IAC SB TERMINAL_TYPE SEND IAC SE客户端再回IAC SB TERMINAL_TYPE IS VT100 IAC SE。/* 收到 DO TERMINAL_TYPE 后先回同意 */ send_iac(s, WILL, 24); /* 收到 IAC SB 24 SEND IAC SE 时上报终端类型 */ unsigned char rsp[] { IAC, SB, 24, 0, V,T,1,0,0, IAC, SE }; send(s, (const char*)rsp, sizeof(rsp), 0);rsp 数组第三个字节是选项号 24第四个字节 0 是 IS 子命令1 是 SEND别写反。后面是终端类型字符串最后以 IAC SE 收尾。第 2 章的状态机没处理 SB会把子协商里的 IS、SEND、终端名当普通文本输出所以要补 SUB 状态遇到命令字节是 SB 就进入子协商一直吞字节直到看见 IAC SE这之间的数据都不上屏。核心逻辑就一句SB 和 SE 之间的内容属于协议元数据不显示给用户。这一步做完整登录设备时报Unknown terminal type的问题就消失了。回显、按键、终端类型三件套齐了客户端才算“能跟设备说上话”。5. 自写 Telnet 客户端避坑日志连不上、双回显、乱码与卡死这几条是我自己翻车最多的地方。按现象写每条都给原因和改法对照自己的代码可以少走很多弯路。5.1 现象一连接就收到 “connection closing... socket close.”现象连上后 banner 没看到直接收到服务器断连提示或者客户端反复连接又断开抓包看 TCP 握手是成功的但应用层马上被踢。原因常见有三种。一是设备只允许名单内的终端类型访问客户端没做 TERMINAL_TYPE 协商设备直接把会话断开二是服务器要求先做 NVT 协商比如交换机的 vty 启用了严格访问规则三是系统自带 telnet 能通但自写客户端没回协议包设备误判为“不支持 Telnet 的裸连接”主动发来 connection closing 后关闭 socket。解决连接成功后的第一件事不是等用户输入而是主动发一条IAC WILL TERMINAL_TYPE再按 4.3 完成一次终端类型交换。很多网络设备在收到这个包之前不会发欢迎语甚至直接关连接。另外注意 Windows 的“启用或关闭 Windows 功能”里 Telnet 客户端没勾选时系统自带 telnet 会提示无法连接这只会影响 telnet.exe不影响你自己写 socket 的程序别被误导去查系统服务。5.2 现象按键出现两遍或者一个字都不显示现象输入用户名时每个字符在屏幕上出现两遍或者敲键盘完全没反应直到回车后服务器才有响应。原因两遍是客户端本地回显和服务器回显同时开了常见于服务器默认 ECHO on而客户端 local_echo 也开着。完全不显示是服务器 WONT ECHO客户端又没开本地回显数据其实发出去了屏幕上看不见而已。很多设备在输入密码阶段会临时切到 ECHO off如果客户端不动态跟上密码就处于“盲打”状态。解决按 4.2 的回显开关逻辑来原则是“服务器开回显时客户端关服务器关回显时客户端补位”。local_echo 不要写死在代码里必须跟着 WILL/WONT ECHO 实时变。你可以在调试时用系统 telnet 登同一台设备做对照如果系统 telnet 正常而自写的双字符基本可以断定是回显状态切换没跟上。5.3 现象中文输出全乱码退格键变成 ^H现象登录中文设备设备返回的配置内容全是乱码退格键在屏幕上显示成 ^H而不是删除前一个字符。原因乱码是控制台代码页和设备字符集对不上。设备输出 GBK 或 UTF-8Windows 控制台默认代码页是 936如果设备发 UTF-8 就全花。^H 是终端没把 0x08 解释成退格而当成普通字符显示说明设备认为客户端不是 VT100发送退格的方式也不匹配。解决接收侧不要把字节流转成 C 字符串原样往控制台写再按设备实际编码设置代码页。常用两条SetConsoleOutputCP(936)对应 GBK 设备SetConsoleOutputCP(65001)对应 UTF-8 设备。退格问题多数设备要 0x7F把 tn_getkey 里 8 改成 127 试一次就知道。这类问题没有统一答案我这里只能给你排查顺序具体值以你手上设备的 stty 或 line vty 配置为准。5.4 现象程序跑起来就卡死只能用任务管理器结束现象connect 成功后程序看起来“冻住”窗口还在但敲键盘没反应任务管理器里 CPU 接近 0。原因典型的 recv 阻塞。没有 select 包裹时recv 一旦没数据就永远等主线程被占住后面的 _kbhit 根本没机会执行。connect 本身也可能卡 20 秒以上目标 IP 不可达时阻塞 connect 不会立刻返回。解决按第 3 章的循环把 recv 放进 select。connect 阶段如果也想加快失败回报常见做法是把 socket 设为非阻塞后手动 connect再用 select 判断可写但不建议新手第一版就做先把收发循环跑通。Windows 上不要指望 CtrlC 干净收尾最好在循环里检测 Esc 键主动 closesocket 退出养成这个习惯能少杀几次进程。5.5 现象设备提示 “Unknown terminal type” 或界面错乱现象能登录但设备菜单错位按方向键光标乱跑或者直接提示终端类型未知。原因客户端没有响应 DO TERMINAL_TYPE或 IS 携带的终端名不对设备按 dumb 终端渲染界面菜单、表格、行编辑全废。解决把 4.3 的 SB/SE 流程走完整终端名用 VT100 最保险。如果登录的是带全屏菜单的设备还要考虑 NAWS 协商把窗口大小报上去。排查顺序固定为先确认收到了 DO 24再确认回复了 WILL 24最后确认 IS 包里的终端名是纯 ASCII不带引号或空格。这三步都对了界面错乱的锅基本不在客户端。6. 进阶给 Telnet 客户端加脚本回放并用回环服务验证连通性工具能交互只能算“基本实现”要让自写客户端真正有生产力下一步是让它能预先喂命令。最常见的做法是把键盘来源从 _kbhit 换成一个命令文件逐行读取、逐行发送、中间加延时自动登录、自动巡检的雏形就出来了。FILE* fp fopen(cmds.txt, r); char line[256]; while (fgets(line, sizeof(line), fp)) { send(s, line, (int)strlen(line), 0); Sleep(500); /* 等设备处理并回显 */ }Sleep 的时长不是越小越好设备 CLI 响应比本机慢给 300 到 500 毫秒比较稳。想要更准就把回显状态机跟“等待特定提示符”结合读到设备返回的或#再发下一行比固定延时可靠得多。我现在的习惯是先把收发日志重定向到文件跑完对着日志看而不是肉眼看屏幕排查问题快很多。验证环节最省事的是本机回环测试。用 Python 在 23 端口起一个回显服务客户端连上去后能看到自己发的内容原样返回这一步能确认 Winsock 链路和 IAC 状态机都是通的python -c import socket; ssocket.socket(); s.bind((127.0.0.1,23)); s.listen(1); c,_s.accept(); print(c.recv(1024)); c.send(bhello\r\n); c.recv(1024)非管理员权限下端口可能被占用把 23 改成 1234 效果一样。Windows 上也可以用“启用或关闭 Windows 功能”安装 Telnet 服务器再连 127.0.0.1:23 做真实会话验证。至于 telnet 怎样才算通标准是能完成三次握手、能从服务器读到欢迎字节、断开会话时收到正常的关闭通知这三条全过基本可以放心把客户端交给别人用了。希望帮到你。本文还有配套的精品资源点击获取
返回列表