实现一个简单的 Ping

记录了实现一个简单的 Ping 的过程,通过 IP 测试主机连通性并计算时延

Ping

ping 是一个非常常用的网络工具,常常被用来测试服务器的连通性,例如我们测试 www.google.com

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
❯ ping -n www.google.com
PING www.google.com (202.160.128.96) 56(84) bytes of data.
64 bytes from 202.160.128.96: icmp_seq=1 ttl=64 time=0.198 ms
64 bytes from 202.160.128.96: icmp_seq=2 ttl=64 time=0.303 ms
64 bytes from 202.160.128.96: icmp_seq=3 ttl=64 time=0.089 ms
64 bytes from 202.160.128.96: icmp_seq=4 ttl=64 time=0.417 ms
^C
--- www.google.com ping statistics ---
4 packets transmitted, 4 received, 0% packet loss, time 3051ms
rtt min/avg/max/mdev = 0.089/0.251/0.417/0.121 ms

ping 的工作原理如同声纳,通过发送 ICMP 数据包给目标主机并接收目标主机的应答来计算往返时间差,从而衡量本机到目标主机的网络质量,听起来听简单的对吧,那么第一个写出这个在当今如此通用的工具的人是谁呢?就是下面这位老哥,看着就很有精神(大声)!

Mike Muuss

迈克·缪斯(Mike Muuss)于 1983 年在美国陆军弹道研究实验室工作时编写了这个工具,当时他编写这个工具只是为了解决实验室的网络问题,他一定怎么也想不到如今几乎世界上所有计算机都有这个工具,不过不幸的是他在 2000 年因车祸去世了。

介绍完背景,是时候开始动手实现了,这次的目标是实现一个最为简单的 ping,测试本机到目标主机的网络延时。

实现过程

ICMP

ping 通过收发 ICMP 数据包来计算网络延时,因此我们首先要知道 ICMP 数据包的构成,它的结构如下图所示:

ICMP Header

Type 和 Code 用于控制 ICMP 的行为,具体可参考 Control messages,在 ping 的工作流程中,常用的 Type 有 0, 3, 4, 5, 8, 11,当我们发起 echo 请求时用的就是 8 – Echo Request,而在正常情况下目标主机返回的 ICMP 类型为 0 – Echo Reply,其他类型就属于异常处理的范畴,在代码中我们可以根据接收的 ICMP 数据包中的 Type 判断目标主机是否正确响应了我们的请求。

Checksum 顾名思义是消息校验和,它用于校验消息的完整性,它的值通过 RFC 1071 - Computing the Internet Checksum 中定义的计算方法得到,对于 ICMP 而言,我们需要将整个 ICMP 数据包纳入计算。

首先将数据按照 2 字节(16 位)进行分割并累加,保留进位,因此在代码实现上我们需要 32 位的变量去存放累加的结果,接下来我们处理数据是奇数字节的情况,我们将最后的 8 位(1 字节)后补 0 到 16 位,再进行(高位)累加,此时我们得到了累加和,之后我们将累加和的进位分离,并将其作为低位加到分离后的后 16 位累加和上,例如将 2479c 的进位分离并相加得到 2 + 479c = 479e,对最后的结果取反,即可得到 Checksum,代码实现如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
uint16_t ComputeChecksum(const void *addr, size_t len) {
  auto data = reinterpret_cast<const uint16_t *>(addr);
  uint32_t sum = 0;
  // 将数据按照2字节(16位)进行分割并累加,保留进位
  while (len > 1) {
    sum += *data++;
    len -= 2;
  }
  // 若为奇数字节,将最后8位补0成16位,再进行累加(高位)
  if (len > 0) {
    sum += *reinterpret_cast<const uint8_t *>(data);
  }
  // 将进位取出并加到累加和的后16位上(低位)
  while (sum >> 16) {
    sum = (sum >> 16) + (sum & 0xffff);
  }
  // 取反,截取后16位
  return static_cast<uint16_t>(~sum);
}

由于 Checksum 的计算需包含整个 ICMP 数据包,因此我们在计算前需要将 ICMP 包的其他部分全部填写完毕,Type 我们填写 ICMP_ECHO,Code 我们填写 0,Checksum 我们也填写 0,除了这 3 个字段之外,对于 ping 来说我们还需要填写 id 与 seq,id 通常为进程的 pid,用于区分 ICMP 数据包的身份,seq 为数据包的序号,用于判断是否丢包,在连续 ping 的情况下 seq 在每发送一次请求后都会加 1,我们可将初始值设定为 0。构造 ping ICMP 数据包的代码如下:

1
2
3
4
5
6
7
struct icmp icmp_pack {};
icmp_pack.icmp_type = ICMP_ECHO;
icmp_pack.icmp_code = 0;
icmp_pack.icmp_cksum = 0;
icmp_pack.icmp_id = pid;
icmp_pack.icmp_seq = 0;
icmp_pack.icmp_cksum = ComputeChecksum(&icmp_pack, sizeof(icmp_pack));

构造好数据包后我们就可以准备发送了。

Raw socket

想要发送 ICMP 数据包,我们首先需要创建 socket,在系统版本为 Ubuntu 23.10,内核版本为 Linux 6.6.20 的环境中,我们需要使用 Raw socket 才能创建 ICMP 协议的 socket,而创建 Raw socket 需要当前进程拥有 cap_net_raw 的能力,一种方法是使用 sudo 权限,另一种方法是使用 libcap 库来让我们的程序在创建 socket 的过程中暂时拥有 cap_net_raw 权限,但是在当前环境下,即使是使用 libcap 库也还是需要通过 sudo setcap cap_net_raw+ep ./ping_demo 的命令赋予进程这个能力。如果查看 ping 的源码,可以看到在创建 socket 之前使用 libcap 库中的 cap_set_flag() 函数赋予了进程 cap_net_raw 的能力,而在 socket 创建完成后又移除了该能力,这就像是一个安全开关一样,是一种 最小权限原则 的体现。既然都看过 ping 的源码了我们本次实践中也尝试一下使用 libcap 库吧。

在使用 Raw socket 的过程中有个点需要注意,调用 recvfrom 函数时我们收取的是 整个 IP 数据包,在 raw 的 man page 中有这么一段话,对于本次实现来说非常重要:

The IPv4 layer generates an IP header when sending a packet unless the IP_HDRINCL socket option is enabled on the socket. When it is enabled, the packet must contain an IP header. For receiving, the IP header is always included in the packet.

因此获取 Echo reply 时我们需要跳过 20 字节的 IP Header,否则就会发现收取的 ICMP 数据包中的 Type 是 69,而在 ICMP 的定义中根本没有这个 Type,这实际上的 IP Header 的数据。

RTT

往返时延即成功发送请求到成功接收请求的时间间隔,我们可以直接获取这两个时间进行计算,也可以在发送数据包前将当前时间放入数据包中,由于对端回复时会将信息原封不动地发回来,因此我们可以在接收数据时获取到我们发送数据的时间,从而计算时延。本次我们使用第一种方法。

完整实现

总结一下流程,首先我们从参数中获取目标 IP,之后创建 Socket,设置超时时间,构造数据包,发送数据包,获取发送时间,接收数据包,获取接收时间,计算时间差。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
#include <arpa/inet.h>
#include <netinet/ip_icmp.h>
#include <sys/capability.h>
#include <sys/prctl.h>
#include <unistd.h>

#include <chrono>
#include <iostream>

using namespace std::chrono;

static cap_value_t cap_raw = CAP_NET_RAW;
static cap_value_t cap_admin = CAP_NET_ADMIN;

void limit_capabilities(void) {
  cap_t cap_cur_p;
  cap_t cap_p;
  cap_flag_value_t cap_ok;
  cap_cur_p = cap_get_proc();
  if (!cap_cur_p) {
    perror("ping: cap_get_proc");
    exit(-1);
  }
  cap_p = cap_init();
  if (!cap_p) {
    perror("ping: cap_init");
    exit(-1);
  }
  cap_ok = CAP_CLEAR;
  cap_get_flag(cap_cur_p, CAP_NET_RAW, CAP_PERMITTED, &cap_ok);
  if (cap_ok != CAP_CLEAR) {
    cap_set_flag(cap_p, CAP_PERMITTED, 1, &cap_raw, CAP_SET);
  }
  if (cap_set_proc(cap_p) < 0) {
    perror("ping: cap_set_proc");
    exit(-1);
  }
  if (prctl(PR_SET_KEEPCAPS, 1) < 0) {
    perror("ping: prctl");
    exit(-1);
  }
  if (setuid(getuid()) < 0) {
    perror("setuid");
    exit(-1);
  }
  if (prctl(PR_SET_KEEPCAPS, 0) < 0) {
    perror("ping: prctl");
    exit(-1);
  }
  cap_free(cap_p);
  cap_free(cap_cur_p);
}

int modify_capability(cap_value_t cap, cap_flag_value_t on) {
  cap_t cap_p = cap_get_proc();
  cap_flag_value_t cap_ok;
  int rc = -1;
  if (!cap_p) {
    perror("ping: cap_get_proc");
    goto out;
  }
  cap_ok = CAP_CLEAR;
  cap_get_flag(cap_p, cap, CAP_PERMITTED, &cap_ok);
  if (cap_ok == CAP_CLEAR) {
    rc = on ? -1 : 0;
    goto out;
  }
  cap_set_flag(cap_p, CAP_EFFECTIVE, 1, &cap, on);
  if (cap_set_proc(cap_p) < 0) {
    perror("ping: cap_set_proc");
    goto out;
  }
  cap_free(cap_p);
  rc = 0;
out:
  if (cap_p) {
    cap_free(cap_p);
  }
  return rc;
}

uint16_t ComputeChecksum(const void *addr, size_t len) {
  auto data = reinterpret_cast<const uint16_t *>(addr);
  uint32_t sum = 0;
  while (len > 1) {
    sum += *data++;
    len -= 2;
  }
  if (len > 0) {
    sum += *reinterpret_cast<const uint8_t *>(data);
  }
  while (sum >> 16) {
    sum = (sum >> 16) + (sum & 0xffff);
  }
  return static_cast<uint16_t>(~sum);
}

void ping(int sockfd, const char *ip, int count) {
  uint16_t id = static_cast<uint16_t>(getpid());

  struct sockaddr_in addr_in {};
  addr_in.sin_family = AF_INET;
  addr_in.sin_addr.s_addr = inet_addr(ip);
  auto dest_addr = reinterpret_cast<struct sockaddr *>(&addr_in);

  for (int i = 0; i < count; ++i) {
    if (i != 0) {
      sleep(1);
    }

    struct icmp icmp_pack {};
    icmp_pack.icmp_type = ICMP_ECHO;
    icmp_pack.icmp_code = 0;
    icmp_pack.icmp_cksum = 0;
    icmp_pack.icmp_id = id;
    icmp_pack.icmp_seq = i;
    icmp_pack.icmp_cksum = ComputeChecksum(&icmp_pack, sizeof(icmp_pack));

    ssize_t sent = sendto(sockfd, &icmp_pack, sizeof(icmp_pack), 0, dest_addr,
                          sizeof(*dest_addr));
    if (sent < 0) {
      perror("send error, try again");
      continue;
    }
    auto send_time = time_point_cast<microseconds>(high_resolution_clock::now())
                         .time_since_epoch()
                         .count();

    char buf[64];
    int received = recvfrom(sockfd, &buf, sizeof(buf), 0, nullptr, nullptr);
    if (received < 0) {
      perror("recv error, try again");
      continue;
    }
    auto recv_time = time_point_cast<microseconds>(high_resolution_clock::now())
                         .time_since_epoch()
                         .count();

    auto icmp_reply_pack = reinterpret_cast<struct icmp *>(buf + 20);
    if (icmp_reply_pack->icmp_id == id &&
        static_cast<int>(icmp_reply_pack->icmp_type) == ICMP_ECHOREPLY) {
      std::cout << received << " bytes from " << ip
                << ": icmp_seq=" << static_cast<int>(icmp_reply_pack->icmp_seq)
                << ", time=" << (recv_time - send_time) / 1000.0 << " ms"
                << std::endl;
    }
  }
}

int main(int argc, char *argv[]) {
  if (argc < 2) {
    std::cerr << "Usage: ping + IP" << std::endl;
    return 0;
  }
  char *ip = argv[1];
  int count = 5;
  int timeout = 1;

  limit_capabilities();

  modify_capability(CAP_NET_RAW, CAP_SET);

  int sockfd = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);
  if (sockfd < 0) {
    perror("socket error");
    return -1;
  }

  modify_capability(CAP_NET_RAW, CAP_CLEAR);

  struct timeval tv;
  tv.tv_sec = timeout;
  tv.tv_usec = 0;
  setsockopt(sockfd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
  setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));

  std::cout << "PING " << ip << std::endl;
  ping(sockfd, ip, count);

  close(sockfd);
  return 0;
}

编译运行并与系统的 ping 进行对比,符合预期:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
❯ g++ ping.cc -o ping -lcap && sudo setcap cap_net_raw+ep ping
❯ ./ping 180.101.50.188
PING 180.101.50.188
48 bytes from 180.101.50.188: icmp_seq=0, time=0.109 ms
48 bytes from 180.101.50.188: icmp_seq=1, time=0.117 ms
48 bytes from 180.101.50.188: icmp_seq=2, time=0.07 ms
48 bytes from 180.101.50.188: icmp_seq=3, time=0.09 ms
48 bytes from 180.101.50.188: icmp_seq=4, time=0.121 ms
❯ ping -n www.baidu.com
PING www.baidu.com (180.101.50.188) 56(84) bytes of data.
64 bytes from 180.101.50.188: icmp_seq=1 ttl=64 time=0.087 ms
64 bytes from 180.101.50.188: icmp_seq=2 ttl=64 time=0.096 ms
64 bytes from 180.101.50.188: icmp_seq=3 ttl=64 time=0.091 ms
64 bytes from 180.101.50.188: icmp_seq=4 ttl=64 time=0.093 ms
64 bytes from 180.101.50.188: icmp_seq=5 ttl=64 time=0.134 ms
^C
--- www.baidu.com ping statistics ---
5 packets transmitted, 5 received, 0% packet loss, time 4096ms
rtt min/avg/max/mdev = 0.087/0.100/0.134/0.017 ms

小结

本文记录了一次实现简单 ping 的过程,通过 IP 地址对主机进行连通性测试,并不包含其他功能。重点在于数据包的构造与 Raw socket 的使用。

References

  1. ping (networking utility) - Wikipedia
  2. Internet Control Message Protocol - Wikipedia
  3. Control messages - Wikipedia
  4. Internet checksum - Wikipedia
  5. RFC 1071 - Computing the Internet Checksum
  6. raw(7) - Linux manual page
  7. iputils/ping - Github
updatedupdated2024-10-302024-10-30