ARTICLE DETAIL

资讯详情

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

用VC++和Winsock实现Telnet客户端与端口连通性检测

用VC++和Winsock实现Telnet客户端与端口连通性检测 简介基于Visual C打造的Telnet客户端工程面向希望从零掌握Winsock网络编程的开发者可用来理解并复现Windows Telnet的远程登录与命令交互机制。项目围绕TCP/IP协议栈展开完整覆盖WSAStartup环境初始化、socket套接字创建、gethostbyname域名解析、connect服务端连接、send/recv数据收发等核心流程并实现Telnet协议中IAC命令的解析处理对熟悉底层通信原理和Windows API调用都有很好的实战价值。压缩包共包含18个文件体积仅14KB以7个.h头文件与6个.cpp源文件为主干构成SocketRx、SocketTx、ProtocolRx等清晰模块同时附带.dsw、.dsp、.clw、.rc等Visual C工程文件可直接导入开发环境进行编译调试和二次修改。已有272人学习浏览此份代码量精简、结构完整是网络编程课程设计或Socket入门实践不可多得的参考范例后续还可据此扩展命令历史、输入自动补全等交互功能。1. 用 VC 重写 Telnet老协议工具为什么还要自己造一遍Windows 自带的 Telnet 客户端够用但当你需要把telnet ip 端口 命令这种连通性探测写进脚本、或者要自定义终端类型和回显行为时自带客户端就是个黑匣子。这个标题的落点很直接用 Visual C 的 Winsock API 从零实现一个和 Windows Telnet 类似的远程终端工具能连接 23 端口的 Telnet 服务也能用来探测任意 TCP 端口是否通。适合做运维脚本、内网调试和网络工具开发的人。我自己做这个功能最早只是为了批量检查一批交换机的 23 端口是否开放后来把协议协商理清后连中文乱码和回显异常这些自带客户端的毛病也顺手解决了。2. 从 RFC 854 到 WinsockTelnet 的两层骨架与协议映射写 Telnet 客户端之前先要分清两层东西上层是 RFC 854 定义的 Telnet 应用协议下层是 Winsock 提供的 TCP 套接字。很多人把用 socket 连 23 端口当成实现了 Telnet结果连上后服务器不发数据、或者发来一堆带 0xFF 的乱码问题都出在协议层。标题里实现 Telnet 的 window socket 调用指的就是把这两层都补齐而不是只调几个 socket API。2.1 NVT 虚拟终端Telnet 传输的字节约定RFC 854 里有个核心概念叫 NVTNetwork Virtual Terminal网络虚拟终端。客户端和服务器不再直接交换终端字节流而是都先映射成 NVT 规定的中间格式。这个设计是为了让不同系统Unix、Windows、大型机能互相通信代价是两端都得多做一层转换。NVT 的字节约定要记住三点数据是 8 位无校验字节流行结束符统一用 CRLF0x0D 0x0A表示发送方把本机换行转成 CRLF 发出接收方再把 CRLF 转回本机换行从 NVT 到本机的转换是接收方的责任。这三点直接决定你的显示逻辑收数据后是原样打印还是做换行修正。实际坑在于很多 Telnet 服务器尤其 Linux 的 telnetd 和各类网络设备只发 LF不发 CRLF还有的 ECHO 回显时把 CRLF 拆开。客户端如果做严格校验看到 0x0D 后必须等 0x0A很容易把输入卡住。我一般做宽松处理0x0A 一律当换行打印0x0D 如果是独立的也当回车处理不强制配对。这个宽容接收原则后面解析 IAC 时同样适用。2.2 选项协商IAC/WILL/DO 应答是通信的前戏Telnet 连接建立后服务器会先发一串选项协商然后才发横幅或登录提示。协商字节以 IAC0xFF开头后面跟命令WILL0xFB表示我要启用某选项DO0xFD表示请你启用某选项WONT0xFC表示我不启用DONT0xFE表示请你别用。再后面跟一个选项码所以最短的协商包是 3 字节IAC 命令 选项码。常见选项码可以参考下表选项码选项名含义0x01ECHO回显请求对方把输入回显出来0x03SUPPRESS-GO-AHEAD取消轮询信号现代实现都支持0x05STATUS查询当前协商状态0x18TERMINAL TYPE终端类型如 xterm、vt1000x1FNAWS窗口尺寸协商Windows 自带 Telnet 客户端连上后服务器通常会发 WILL ECHO0xFF 0xFB 0x01意思是我这边来做回显。如果你不回 DO 或 DONT服务器可能停在协商阶段横幅一直不来。我们的策略很简单客户端收到 WILL 就回 DONT收到 DO 就回 WONT把除裸终端通道以外的选项全拒掉。全拒不是偷懒。只要双方同意某个选项后续就必须遵守它全拒等于回到最朴素的 NVT 模式收发都由本地控制台处理代码量最小、行为最可控。终端类型这种显式请求后续要做特殊交互时可以单独应答最小实现里先全拒。还要注意 0xFF 本身的转义数据里连续出现两个 0xFF 表示一个真实数据字节 0xFF。解析时不能把第二个 0xFF 当成新 IAC 的开头。这个细节容易漏漏了会导致终端颜色控制序列解析错位屏幕上冒出一堆 ^[。2.3 Winsock 初始化与套接字生命周期先搞清楚 API 调用顺序Winsock 的调用顺序有严格约定用 VC 写时记得链接 ws2_32.lib。VC6 到 VS2022 接口都没变过工程里加一句#pragma comment(lib, ws2_32.lib)最省事也可以在项目属性里手动附加依赖。标准调用顺序是先调 WSAStartup(MAKEWORD(2,2), wsaData) 完成版本协商再用 socket(AF_INET, SOCK_STREAM, IPPROTO_TCP) 创建 TCP 套接字然后 connect 建立连接之后 send/recv 收发数据结束用 closesocket 关闭套接字最后全局调 WSACleanup 释放 Winsock 资源。几个容易踩的点。WSAStartup 必须在任何 socket 调用之前且返回值必须判 0。每次调用返回 SOCKET_ERROR 时用 WSAGetLastError() 拿错误码。阻塞模式 connect 的默认超时是系统级的经常要等 20 秒以上端口不通时体验极差我一般先把套接字切非阻塞再用 select 等可写事件配合 3 秒超时。closesocket 只关单个套接字WSACleanup 是全局清理程序里创建了多个套接字时要全部 close 后再调 WSACleanup。3. 最小 Telnet 客户端落地从 connect 到命令交互的完整代码这一章把骨架落成 VC 控制台程序。我的做法是 main 里完成 Winsock 初始化和连接然后创建接收子线程主线程循环读键盘输入并发送。完整代码分三段讲每段都能单独编译验证。3.1 套接字建立与带超时的 connect先看连接部分。这里用非阻塞 connect select 实现 3 秒超时解决端口不通时无限等待的问题。#include winsock2.h #include ws2tcpip.h #include stdio.h #pragma comment(lib, ws2_32.lib) SOCKET connect_with_timeout(const char* ip, int port, int timeout_sec) { WSADATA wsa; if (WSAStartup(MAKEWORD(2, 2), wsa) ! 0) { printf(WSAStartup failed: %d\n, WSAGetLastError()); return INVALID_SOCKET; } SOCKET s socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (s INVALID_SOCKET) { printf(socket failed: %d\n, WSAGetLastError()); WSACleanup(); return INVALID_SOCKET; } struct sockaddr_in addr; addr.sin_family AF_INET; addr.sin_port htons((u_short)port); inet_pton(AF_INET, ip, addr.sin_addr); // 先切非阻塞再发起连接 u_long mode 1; ioctlsocket(s, FIONBIO, mode); int ret connect(s, (struct sockaddr*)addr, sizeof(addr)); if (ret SOCKET_ERROR) { int err WSAGetLastError(); if (err WSAEWOULDBLOCK) { // 连接仍在进行用 select 轮询可写状态 fd_set wset; FD_ZERO(wset); FD_SET(s, wset); struct timeval tv; tv.tv_sec timeout_sec; tv.tv_usec 0; int sel select(0, NULL, wset, NULL, tv); if (sel 0) { printf(connect timeout after %d seconds\n, timeout_sec); closesocket(s); WSACleanup(); return INVALID_SOCKET; } // 可写不代表一定成功必须读 SO_ERROR 确认 int err2 0; int len2 sizeof(err2); getsockopt(s, SOL_SOCKET, SO_ERROR, (char*)err2, len2); if (err2 ! 0) { printf(connect failed: %d\n, err2); closesocket(s); WSACleanup(); return INVALID_SOCKET; } } else { printf(connect failed immediately: %d\n, err); closesocket(s); WSACleanup(); return INVALID_SOCKET; } } // 恢复阻塞模式方便后续 recv 等待数据 mode 0; ioctlsocket(s, FIONBIO, mode); return s; }逻辑说明非阻塞 connect 发起后立即返回 WSAEWOULDBLOCKselect 等待可写事件select 返回 1 后还要用 getsockopt 读 SO_ERROR因为连接失败时套接字也可能变成可写。最后恢复阻塞模式让 recv 像普通 TCP 那样阻塞在等待数据上。参数说明timeout_sec 控制探测超时连通性检测场景一般设 3 秒ip 支持点分十进制inet_pton 在 Windows 7 及以上都可用。这段代码把 WSAStartup 和 WSACleanup 的配对也做在了里面实际工程里最好把初始化拆到 main 里避免反复调用。为什么不直接阻塞 connectWindows 的 TCP 连接超时由注册表 TcpMaxConnectRetransmissions 控制往往要等 21 秒。做批量端口巡检时每台设备等 20 多秒不现实3 秒超时才能接受。3.2 收发循环与 IAC 序列过滤连接建立后是收数据。recv 拿到的是原始字节流中间混着 IAC 协商序列不能直接交给 printf。下面是过滤函数void process_recv_data(SOCKET s, char* buf, int len) { int i 0; while (i len) { unsigned char c (unsigned char)buf[i]; if (c 0xFF) { // IAC 起始字节 if (i 1 len) { unsigned char cmd (unsigned char)buf[i 1]; if (cmd 0xFF) { // 0xFF 0xFF 表示真实数据 0xFF putchar(0xFF); i 2; continue; } if (cmd 0xFB) { // WILL拒绝对方启用 char reply[3] { (char)0xFF, (char)0xFC, buf[i 2] }; send(s, reply, 3, 0); i 3; continue; } if (cmd 0xFD) { // DO拒绝对方请求 char reply[3] { (char)0xFF, (char)0xFC, buf[i 2] }; send(s, reply, 3, 0); i 3; continue; } if (cmd 0xFC || cmd 0xFE) { // WONT / DONT 可以不应答 i 3; continue; } if (cmd 0xF9 || cmd 0xFA) { // 子协商 IAC SB ... IAC SE长度不定跳过 i 2; while (i len) { if ((unsigned char)buf[i] 0xFF i 1 len (unsigned char)buf[i 1] 0xF0) { i 2; break; } i; } continue; } } // 无法解析的 IAC 序列跳过起始字节 i; continue; } // 普通字节把裸 LF 转成换行打印 if (c 0x0A) { putchar(\n); } else { putchar(c); } i; } fflush(stdout); }逻辑说明函数按字节遍历以 0xFF 为界识别 IAC 序列。WILL 和 DO 都回 WONT 拒绝子协商段 SB...SE 直接跳过其余字节原样打印。收到 0x0A 输出换行避免服务器只发 LF 时出现阶梯显示。参数说明回复包 reply 固定 3 字节IAC WONT 对方请求的选项码。放心全拒的原因是最小实现不依赖回显、终端类型这些能力本地控制台自己处理回显服务器只需透传数据。这个函数要在多个 recv 之间保持状态半包时 IAC 头到了尾巴没到下一次 recv 继续处理即可。3.3 用两个线程维持交互主线程发、子线程收Telnet 交互是同时发生的双向通信一边收服务器数据一边读本地键盘。最简单可靠的结构是接收子线程加主线程发送循环。接收线程调 process_recv_data主线程用 fgets 读一行输入再 send。#include windows.h SOCKET g_sock; volatile int g_running 1; DWORD WINAPI recv_thread(LPVOID param) { char buf[4096]; while (g_running) { int n recv(g_sock, buf, sizeof(buf), 0); if (n 0) { process_recv_data(g_sock, buf, n); } else if (n 0) { printf(\n[connection closed by server]\n); g_running 0; break; } else { int err WSAGetLastError(); if (err ! WSAEWOULDBLOCK) { printf(\n[recv error: %d]\n, err); g_running 0; break; } Sleep(10); } } return 0; } int main(int argc, char* argv[]) { // 演示用固定地址实际工程见第 5 章参数解析 g_sock connect_with_timeout(127.0.0.1, 23, 3); if (g_sock INVALID_SOCKET) { return 1; } HANDLE h CreateThread(NULL, 0, recv_thread, NULL, 0, NULL); if (h NULL) { closesocket(g_sock); WSACleanup(); return 1; } CloseHandle(h); printf([connected, type exit to quit]\n); char line[2048]; while (g_running fgets(line, sizeof(line), stdin) ! NULL) { int len (int)strlen(line); // 去掉 fgets 带进来的换行符 if (len 0 line[len - 1] \n) { line[len - 1] \0; } if (strcmp(line, exit) 0) break; // Telnet 协议要求行结束符是 CRLF send(g_sock, line, (int)strlen(line), 0); char crlf[2] { (char)0x0D, (char)0x0A }; send(g_sock, crlf, 2, 0); // 因为拒绝了服务器 ECHO需要本地回显 printf(%s\r\n, line); } g_running 0; closesocket(g_sock); WSACleanup(); return 0; }逻辑说明接收线程阻塞在 recv 上服务器关闭连接或出错时通过 g_running 标志通知主线程退出。主线程把每行输入送到服务器并附 CRLF 作为行结束由于拒绝了服务器 ECHO本地要做回显。参数说明recv 缓冲 4096 字节对横幅和菜单足够做文件传输时要加大。Sleep(10) 只在非阻塞模式下有意义阻塞模式下 recv 不会返回 WSAEWOULDBLOCK。这段代码编译后连 127.0.0.1:23配合第 5 章的验证服务可以完整跑通一次登录、命令、退出流程。4. 避坑排查Telnet 测试不显示数据、乱码与粘包的 5 个经典现场自己写 Telnet 客户端最容易翻车的不是 connect而是连上之后的表现。下面 5 个问题我全踩过按现象、原因、解决整理。前三条基本覆盖了搜索里telnet 测试不显示数据telnet ip 端口 命令怎么看通不通的大多数场景。4.1 WSAStartup 返回 10093初始化顺序的坑现象程序一跑就输出 failed: 10093或者 socket 创建直接返回 INVALID_SOCKET。原因10093 是 WSANOTINITIALISED意思是 Winsock 没初始化就调用了 socket、connect。常见于 WSADATA 声明成局部变量但初始化代码被条件分支跳过或者基类初始化顺序没控制好静态库提前触发了 socket 调用。解决在 main 入口最前面无条件调 WSAStartup 并判返回值退出路径统一调 WSACleanup。多线程程序不要在子线程里重复调用 WSAStartup全局一次即可。这是最基础的一条却一直是新手提问最多的问题。4.2 connect 报 10061/10060端口没开还是防火墙拦截现象connect 失败错误码 10061目标主动拒绝或 10060连接超时。原因10061 说明收到 TCP RST目标端口没监听或者监听程序不允许地址访问10060 说明 SYN 发出后没人应答中间设备丢包或防火墙 DROP 策略最常见。解决先在本机 netstat -ano | findstr :23 确认服务在听。再用 Windows 自带 Telnet 客户端做对照如果自带也超时基本是网络路径或防火墙问题不用再查代码。写程序探测大量端口时我的习惯是把 10061 记为 closed、10060 记为 filtered两者在巡检报告里含义不一样。4.3 连上后不显示数据选项协商没人应答导致服务器空转现象connect 成功但屏幕全黑横幅和登录提示一直不来。用自带 telnet 正常用自己的程序黑屏。原因这是实现 Telnet 最常见的问题。服务器建连后先发 IAC 协商比如 WILL ECHO然后等服务端应答才继续。客户端如果直接忽略 IAC不回任何内容服务器会一直卡在协商阶段应用层数据根本不会发。解决按第 3 章的 process_recv_data 处理收到 WILL/DO 就回 WONT。有的服务器还要等终端类型子协商遇到 SB...SE 直接跳过。另外别用 printf 直接打印含 0xFF 的缓冲会把 IAC 当可见字符输出画面直接花掉。4.4 中文乱码与控制台代码页用 SetConsoleOutputCP 收服 GBK现象服务器返回的中文变成鎴戠殑一类乱码或者全部变成问号。原因Telnet 本身只传字节流不保证字符编码。Windows 中文版控制台默认代码页是 936GBK而很多 Linux 服务器和网络设备输出 UTF-8反过来设备输出 GBK、控制台被设成 65001 也会乱。两边编码对不上显示必然出问题。解决拿到字节不做编码转换时把控制台输出代码页设成和服务器一致。服务器发 UTF-8 时main 开头调 SetConsoleOutputCP(CP_UTF8)设备发 GBK 时保持 936。建议加命令行参数 --encoding utf8/gbk 动态设置程序换到别的 Windows 版本也不用重新编译if (strcmp(encoding, utf8) 0) { SetConsoleOutputCP(65001); } else { SetConsoleOutputCP(936); }代码页设置只影响控制台显示不影响网络字节流。在 Windows Terminal 里渲染效果比传统控制台好配合 UTF-8 基本能解决大部分乱码场景。4.5 粘包半包TCP 是流不是消息按字节啃 IAC 序列现象横幅显示不全、菜单停在一半或者一条 IAC 被拆进两次 recv 里下一段逻辑漏处理。原因TCP 是字节流recv 一次拿到的长度与发送方的 write 长度完全无关。Telnet 没有消息边界必须自己按 IAC 序列格式逐字节解析不能假设一次 recv 就是一个完整包。解决process_recv_data 按字节状态机处理天然能应对半包——IAC 起始字节到了、后续字节没到就等下一次 recv 再补。粘包也不会出错逐字节解析不会把两次 send 的数据看成一条。缓冲溢出要注意收到超长数据时循环处理不要靠单次 recv 长度做判断。5. 把客户端变成能用的命令行工具参数解析、端口检测与验证手法5.1 从固定 IP 到命令行参数像 telnet ip 端口 命令一样去调用第 3 章的演示代码把 IP 硬编码了真要用起来得支持命令行参数。main 里加 argc/argv 判断第一个参数是 IP第二个是端口可选 --timeout 和 --encoding。调用方式对齐 Windows 自带 telnetmytelnet 192.168.1.1 23。IP 先用 inet_pton 校验失败直接打印用法退出端口 atoi 后检查 0 到 65535 区间超界报错。这样脚本里能用返回值判断连接成败而不是靠肉眼盯屏幕。5.2 用 select 做批量端口连通性检测telnet ip 端口 命令怎么看通不通这种需求用命令行工具一次查一个太慢。运维场景通常要批量查几十台设备我一般写一个循环每台设备调 connect_with_timeout成功打印 ip:port open失败打印错误码。超时 3 秒100 台设备串行最坏 300 秒能接受要更快就开线程池但注意 WSAStartup 全局一次别在子线程里重复初始化。批量检测的结果我会导成 csv方便后续和资产清单比对。5.3 对照验证手法Windows 自带 telnet 与 Python 测试服务验证客户端是否正确关键是找一个可控的服务器。我平时用两个对照一是 Windows 自带 Telnet 服务控制面板里启用 Telnet Server 后用自带客户端连 localhost 对比表现二是在本地起一个最小 Python TCP 服务手动发 IAC 序列来测试客户端的应答逻辑。前者验证真实兼容性后者验证协议细节。import socket, threading def handle(c): c.send(b\xff\xfb\x01) # WILL ECHO while True: d c.recv(1024) if not d: break c.send(becho: d) s socket.socket() s.bind((127.0.0.1, 2323)) s.listen(5) while True: c, a s.accept() threading.Thread(targethandle, args(c,)).start()这段服务监听 2323 端口客户端连上后会收到 WILL ECHO。如果客户端正确回了 WONT本地会显示 echo: 加输入内容如果黑屏说明协商应答有问题。我的习惯是先跑通这个最小场景再上真实设备拿交换机试错代价高。回想当年第一版没处理 IAC连路由器一直黑屏我还以为是固件兼容问题折腾一晚上把协商应答加上后几秒就通了。做这类 TCP 工具协议状态机比 socket 调用更值得花时间。希望帮到你。本文还有配套的精品资源点击获取
返回列表