ICMP协议及其应用

本文正在参与 “网络协议必知必会”征文活动

ICMP 协议一般用来检测网络的状态。比如我们经常使用的ping命令就是ICMP报文的应用。它利用的是其中的回显 echo 报文。

一个ICMP请求回显报文,有几个主要的部分,类型,代码,校验和,ID,序列号。

在我们的请求回显模型中,只需要指定类型为8,当收到正确的回复时,收到的报文为0.

就像其他的协议一样,这里也需要校验和,但是在应用层时,TCP和UDP的校验和不需要我们手动计算,而这里我们在IP层,所以要自行计算校验和。

我们先实现一个它的报文头,以便于我们构造,虽然这里也不是必须要的,但是用起来可能会舒服一些。

struct ICMP {
    byte type;
    byte code;
    u_short checksum;
    u_short id;
    u_short seq;

    ICMP(byte type, byte code, u_short id, u_short seq) : type(type),
                                                          code(code),
                                                          id(htons(id)),
                                                          seq(htons(seq)) {
        this->checksum = calculateChecksum((u_short *) this, sizeof(ICMP));
    }



    friend std::ostream &operator<<(std::ostream &os, const ICMP &icmp) {
        os << "type: " << (int) icmp.type << " code: " << (int) icmp.code << " checksum: "
           << (int) icmp.checksum << " id: "
           << (int) ntohs(icmp.id)
           << " seq: " << (int) ntohs(icmp.seq);
        return os;
    }
};
复制代码

这样封装之后,外部传入需要打包的数据构造一个结构体就可以完成报文的构造了,其实还可以有数据的部分,这里没有考虑,但是效果是一致的。

为了更好的观察,我重载了它的输出函数,以便于更好的观察报文的内容,不过这里我把封装成主机字节序和网络字节序这两个逆过程封装了,这样数据可以直接使用,我们看起来也明白。

计算校验和的函数如下:

u_short calculateChecksum(u_short *data, size_t size) {
    u_short checksum = 0;
    while (size > 1) {
        checksum += *data++;
        size -= sizeof(u_short);
    }
    if (size) {
        checksum += *(byte *) data;
    }
    checksum = (checksum >> 16) + (checksum & 0xffff);
    checksum += (checksum >> 16);
    return (u_short) (~checksum);
}
复制代码

有了这部分的报文,就可以考虑数据的发送了,这部分其实和一般的套接字没有太大的区别,主要在于它不再是位于传输,而是位于更下层。这使得我们需要使用更底层的套接字 raw socket 。这个东西甚至能够深入到数据链路层的处理。出于简单考虑,这里就直接在windows在使用了,windows中使用ICMP的请求回显报文是不需要高级权限的。linux下则需要高级权限。

windows的套接字也有不一样的地方,它需要先声明socket的一些信息,这里就略过了,直接到创建套接字的部分

SOCKET dialICMP() {
    init(); //初始化声明
    auto client = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);
    if (client < 0) {
        printf("failure create socket %d", client);
        exit(-3);
    }
    return client;
}
复制代码

实现一个工具函数用来创建套接字。
然后则是发送报文,这需要一个地址,和先前介绍过的TCP编程一致,不过在C++中,稍微要麻烦一些,所以也可以封装一个工具函数。

sockaddr_in makeAddress(const char *address, short port) {
    sockaddr_in res{};
    res.sin_family = AF_INET;
    res.sin_addr.S_un.S_addr = inet_addr(address);
    res.sin_port = htons(port);
    return res;
}
复制代码

借助 c++ 的右值移动语义,可以简单的返回一个地址。

现在进入发送报文的阶段了,借助我们做的一些准备工作,这部分代码如下

auto client = net::dialICMP();
ICMP icmp_packet{8, 0, 0, 100};
sockaddr_in addressServer = net::makeAddress("125.221.232.243", 0);
sendto(client, (char *) &icmp_packet, sizeof(icmp_packet), 0, (SOCKADDR *) &addressServer, sizeof(SOCKADDR));
复制代码

发送完成之后可以准备接受报文

std::array<char, 1024> reply_buffer{};
auto len = recv(client, reply_buffer.data(), reply_buffer.max_size(), 0);
ICMP *icmp = (ICMP *) (reply_buffer.data() + 20); // 会有IP的头部
std::cout << *icmp << std::endl;
std::cout << len;
复制代码

由于我们处于一个较为底层的位置,所以接受到的报文会保留 IP 报文头,共20个字节。之后才是我们接受到的具体的ICMP报文。

用C语言这种直接操作指针的好处就是有时候不必真的拥有这样一个数据结构,我可以把这个接受到的二进制字节数组直接当作我们定义的ICMP报文来使用,这样数据我们可以直接输出查看具体的内容。

如果我们在发送的时候进行计时,并在接受时在做差可以计算往返的时间。或者利用我们先前说过的ICMP报文的数据部分保存发送的时间戳,这样,当接受报文时,解析出发送时间戳和当前时间戳做差可以计算出往返时差。不过这样方式直接也没有太大的区别。基本上是小修小改。