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 3
0 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版本。