ping是向网络主机发送ICMP回显请求(ECHO_REQUEST)分组,是TCP/IP协议的一部分。主要可以检查网络是否通畅或者网络连接速度快慢,从而判断网络是否正常。
ping命令底层使用的是ICMP,ICMP报文封装在ip包里。它是一个对IP协议的补充协议,允许主机或路由器报告差错情况和异常状况。
ICMP报文格式和各个字段的含义
ICMP报文由首部和数据段组成。通过wireshark软件的使用加深对此的了解(差错报告、控制报文和请求应答报文)。
回送请求的具体报文:
回送应答的具体报文:
ICMP报头格式:
ICMP报文包含在IP数据报中,IP报头在ICMP报文的最前面。一个ICMP报文包括IP报头(至少20字节)、ICMP报头(至少八字节)和ICMP报文(属于ICMP报文的数据部分)。当IP报头中的协议字段值为1时,就说明这是一个ICMP报文。ICMP报头如下图所示。
0 1 2 30 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+| Type | Code | Checksum |+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+| Identifier | Sequence Number |+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+| Optional Data ...+-+-+-+-+-
ICMP结构体定义:
struct icmp {uint8_t icmp_type;uint8_t icmp_code;uint16_t icmp_cksum;uint16_t icmp_id;uint16_t icmp_seq;};
Type:占8位
Code:占8位
Checksum:占16位
Identifier:设置为ping 进程的进程ID。
Sequence Number :每个发送出去的分组递增序列号。
Type:8,Code:0:表示回显请求(ping请求)。
Type:0,Code:0:表示回显应答(ping应答)
说明:ICMP所有报文的前4个字节都是一样的,但是剩下的其他字节则互不相同。
更多说明可以参考:https://tools.ietf.org/html/rfc792
ping程序的实现
ping程序使用ICMP协议的强制回显请求数据报以使主机或网关发送一份 ICMP 的回显应答。回显请求数据报含有一个 IP 及 ICMP的报头,后跟一个时间值关键字
然后是一段任意长度的填充字节用于把保持分组长度为16的整数倍。
ICMP规则要求在回射应答中返回来自回射请求的标识符、序列号和任何可选数据。在回射请求中存放时间戳使得我们可以在收到回射应答时计算RTT。
原始套接字的创建:
if (ip_version == IP_V4 || ip_version == IP_VERISON_ANY) {memset(&addrinfo_hints, 0, sizeof(addrinfo_hints));addrinfo_hints.ai_family = AF_INET;addrinfo_hints.ai_socktype = SOCK_RAW;addrinfo_hints.ai_protocol = IPPROTO_ICMP;gai_error = getaddrinfo(target_host,NULL,&addrinfo_hints,&addrinfo_head);}if (ip_version == IP_V6|| (ip_version == IP_VERISON_ANY && gai_error != 0)) {memset(&addrinfo_hints, 0, sizeof(addrinfo_hints));addrinfo_hints.ai_family = AF_INET6;addrinfo_hints.ai_socktype = SOCK_RAW;addrinfo_hints.ai_protocol = IPPROTO_ICMPV6;gai_error = getaddrinfo(target_host,NULL,&addrinfo_hints,&addrinfo_head);}if (gai_error != 0) {fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(gai_error));goto error_exit;}for (addrinfo = addrinfo_head;addrinfo != NULL;addrinfo = addrinfo->ai_next) {sockfd = socket(addrinfo->ai_family,addrinfo->ai_socktype,addrinfo->ai_protocol);if (sockfd >= 0) {break;}}if (sockfd < 0) {fprint_net_error(stderr, "socket");goto error_exit;}switch (addrinfo->ai_family) {case AF_INET:addr = &((struct sockaddr_in *)addrinfo->ai_addr)->sin_addr;break;case AF_INET6:addr = &((struct sockaddr_in6 *)addrinfo->ai_addr)->sin6_addr;break;}inet_ntop(addrinfo->ai_family,addr,addrstr,sizeof(addrstr));if (fcntl(sockfd, F_SETFL, O_NONBLOCK) == -1) {fprint_net_error(stderr, "fcntl");goto error_exit;}
创建一个套接字涉及如下步骤:
1、IPV4第一个参数为AF_INET、IPV6第一个参数为AF_INET6。
2、不管是IPV4、IPV6把第二个参数指定为SOCK_RAW。
3、第三参数(协议)通常不为0,例如:IPPROTO_XXX的某个常值,IPV4参数选择IPPROTO_ICMP,IPV6参数选择IPPROTO_ICMPV6。
4、调用socket函数,创建一个原始套接字,
5、然后调用getaddrinfo函数,它是协议无关的,既可用于IPv4也可用于IPv6。能够处理名字到地址以及服务到端口这两种转换,返回的是一个 struct addrinfo 的结构体(列表)指针而不是一个地址清单。
构造并发送回射请求:
uint16_t id = (uint16_t)getpid();
uint16_t seq;for (seq = 0; ; seq++) {struct icmp icmp_request = {0};int send_result;char recv_buf[MAX_IP_HEADER_SIZE + sizeof(struct icmp)];int recv_size;int recv_result;socklen_t addrlen;uint8_t ip_vhl;uint8_t ip_header_size;struct icmp *icmp_response;uint64_t start_time;uint64_t delay;uint16_t checksum;uint16_t expected_checksum;if (seq > 0) {usleep(REQUEST_INTERVAL);}icmp_request.icmp_type =addrinfo->ai_family == AF_INET6 ? ICMP6_ECHO : ICMP_ECHO;icmp_request.icmp_code = 0;icmp_request.icmp_cksum = 0;icmp_request.icmp_id = htons(id);icmp_request.icmp_seq = htons(seq);switch (addrinfo->ai_family) {case AF_INET:icmp_request.icmp_cksum =compute_checksum((const char *)&icmp_request,sizeof(icmp_request));break;case AF_INET6: {struct {struct ip6_pseudo_hdr ip6_hdr;struct icmp icmp;} data = {0};data.ip6_hdr.ip6_src.s6_addr[15] = 1; /* ::1 (loopback) */data.ip6_hdr.ip6_dst =((struct sockaddr_in6 *)&addrinfo->ai_addr)->sin6_addr;data.ip6_hdr.ip6_plen = htonl((uint32_t)sizeof(struct icmp));data.ip6_hdr.ip6_nxt = IPPROTO_ICMPV6;data.icmp = icmp_request;icmp_request.icmp_cksum =compute_checksum((const char *)&data, sizeof(data));break;}}send_result = sendto(sockfd,(const char *)&icmp_request,sizeof(icmp_request),0,addrinfo->ai_addr,(int)addrinfo->ai_addrlen);if (send_result < 0) {fprint_net_error(stderr, "sendto");goto error_exit;}printf("Sent ICMP echo request to %s\n", addrstr);switch (addrinfo->ai_family) {case AF_INET:recv_size = (int)(MAX_IP_HEADER_SIZE + sizeof(struct icmp));break;case AF_INET6:/* When using IPv6 we don't receive IP headers in recvfrom. */recv_size = (int)sizeof(struct icmp);break;}
构造ICMPV4、ICMPV6消息,把标识符字段设置为本进程ID。
校验和计算
为了计算ICMP校验和,参考http://tools.ietf.org/html/rfc1071
static uint16_t compute_checksum(const char *buf, size_t size) {size_t i;uint64_t sum = 0;for (i = 0; i < size; i += 2) {sum += *(uint16_t *)buf;buf += 2;}if (size - i > 0) {sum += *(uint8_t *)buf;}while ((sum >> 16) != 0) {sum = (sum & 0xffff) + (sum >> 16);}return (uint16_t)~sum;
}
有效的校验和实现对于良好的性能至关重要。随着实施技术的进步,其余的协议处理中,校验和计算成为其中之一。
计算时间戳:
static uint64_t get_time(void) {struct timeval now;
return gettimeofday(&now, NULL) != 0? 0: now.tv_sec * 1000000 + now.tv_usec;}
处理所接收的ICMP消息:
start_time = get_time();/*回射请求中的时间戳*/for (;;) {/*通过从当前时间减去消息发送时间,*/delay = get_time() - start_time;addrlen = (int)addrinfo->ai_addrlen;recv_result = recvfrom(sockfd,recv_buf,recv_size,0,addrinfo->ai_addr,&addrlen);if (recv_result == 0) {printf("Connection closed\n");break;}if (recv_result < 0) {if (errno == EAGAIN) {if (delay > REQUEST_TIMEOUT) {printf("Request timed out\n");break;} else {/* No data available yet, try to receive again. */continue;}} else {fprint_net_error(stderr, "recvfrom");break;}}switch (addrinfo->ai_family) {case AF_INET:/* 与IPv6相比,对于IPv4连接,我们确实在传入数据报中接收IP标头。* VHL = version (4 bits) + header length (lower 4 bits).*/ip_vhl = *(uint8_t *)recv_buf;/*将IPV4熟不长度字段乘以4得出IPV4首部以字节为单位的大小*/ip_header_size = (ip_vhl & 0x0F) * 4;break;case AF_INET6:ip_header_size = 0;break;}/*把ICMP设置成指向ICMP首部的开始位置*/icmp_response = (struct icmp *)(recv_buf + ip_header_size);icmp_response->icmp_cksum = ntohs(icmp_response->icmp_cksum);icmp_response->icmp_id = ntohs(icmp_response->icmp_id);icmp_response->icmp_seq = ntohs(icmp_response->icmp_seq);/*如果所处理的消息是一个ICMP回射应答,那么我们必须检查标识符字段,判断该应答是否响应于由本进程的发出请求*/if (icmp_response->icmp_id == id&& ((addrinfo->ai_family == AF_INET&& icmp_response->icmp_type == ICMP_ECHO_REPLY)||(addrinfo->ai_family == AF_INET6&& (icmp_response->icmp_type != ICMP6_ECHO|| icmp_response->icmp_type != ICMP6_ECHO_REPLY)))) {break;}}if (recv_result <= 0) {continue;}checksum = icmp_response->icmp_cksum;icmp_response->icmp_cksum = 0;switch (addrinfo->ai_family) {case AF_INET:expected_checksum =compute_checksum((const char *)icmp_response,sizeof(*icmp_response));break;case AF_INET6: {struct {struct ip6_pseudo_hdr ip6_hdr;struct icmp icmp;} data = {0};/* 需要以某种方式获取源地址和目标地址*/data.ip6_hdr.ip6_plen = htonl((uint32_t)sizeof(struct icmp));data.ip6_hdr.ip6_nxt = IPPROTO_ICMPV6;data.icmp = *icmp_response;expected_checksum =compute_checksum((const char *)&data, sizeof(data));break;}}printf("Received ICMP echo reply from %s: seq=%d, time=%.3f ms",addrstr,icmp_response->icmp_seq,delay / 1000.0);
编译运行:
使用原始套接字通常需要管理特权,因此您将需要以root用户身份运行ping:
捕获数据包:
tcpdump -i any -w ping.pcap -v icmp
wireshark打开ping报文:
总结
本文所讲的是实现一个ping命令,ping诊断工具使用原始套接字完成任务,开发这个ping程序支持IPV4、IPV6版本。
写这篇文章主要的目标是熟悉原始套接字编程的基本流程,理解ping程序的实现机制,理解ICMP协议。
参考:1、UNIX网络编程
2、https://tools.ietf.org/html/rfc1071
3、https://tools.ietf.org/html/rfc2463#section-2.3
欢迎关注微信公众号【程序猿编码】,添加本人微信号(17865354792),回复:领取学习资料。或者回复:进入技术交流群。网盘资料有如下: