Fork me on GitHub

标签 raw_socket 下的文章

使用 RawSocket 做端口探活(2)

问题背景

在上一篇文章《使用 RawSocket 做 TCP 端口探活》中,介绍了一种使用 raw socket 来实现服务健康检查的方法。据此开发的集群健康检查工具 nurse 在我们的系统上兢兢业业工作半年多以后,突然变得不稳定了。起初以为是网络不稳定,一直没管,但问题一直持续,看来并非是网络问题,抽空追查了一下,因为网络编程相关知识不够熟悉,一直以为是 raw socket 会丢包(我并不是唯一遇到这个问题的人),花了一下午,绕了一个大圈,最终才确认是 SO_RCVBUF 的问题,现将追查过程记录在此,避免遗忘 。

单独提一下 nurse 的大致工作情况,方便了解接下来的问题:

  1. 加载探测目标的文件,初始化发送探测包的线程池,初始化接收回包的 raw socket,初始化 epoll event loop
  2. 开始探测循环:

    1. 初始化探测结果集合
    2. 遍历探测目标,使用线程池并行发送 tcp syn 包进行半连接探测
    3. event loop 收到 epollin 的事件后,使用 recvfrom 从 raw socket 读取回包,记录探测结果
    4. 遍历完之后汇总结果,使用通知线程发送消息(如果有的话),继续下一次探测循环

追查过程

起初探测目标一直在两三百个左右,工作正常没有问题。一次系统扩容后,探测目标增加到了480个,由于探测配置和扩容是自动同步的,刚扩完就发现健康检查报告不稳定,断断续续的报一批服务失活和恢复,问题持续了一段时间,觉得不太正常便开始追查。

一开始怀疑是网络问题,因为判断失活就是因为没有收到 ack 回包,于是使用 tcpdump 抓包看了一下,tcpdump 的条件和生成 lsf code的条件相同tcpdump -i eth0 'tcp and tcp[tcpflags] & (tcp-syn|tcp-ack) != 0 and tcp[8:4] = 888889',因为seq 序列号是自己指定的,通过这个能准确抓到回包,结果如下图,回包确实有480个,而且很稳定,直接排除了网络问题。

A627F297-49CD-40D8-B050-B35E7791C365.png

期间也重新回顾了 tcp 三次握手以及半连接相关的概念,其中阿里中间件团队博客上的《关于 TCP 半连接和全连接队列》这篇文章介绍的比较详细,感兴趣可以去看一下。

既然回包没有问题,那问题应该还是在处理的代码上了,加上了一些调试日志,显示虽然发送了480个探测包出去,但是 epoll 只响应了310多个回包的事件,还有100多个响应丢了。这就奇怪了,按理这个数量也不算多大的压力,为何 raw socket会丢包?

回顾相关的代码,整理了一下工作逻辑:tcpdump 命令确认了收包480个,排除了探测发包部分,问题是出在 epoll 事件循环里面;而事件循环相对逻辑简单,就是响应 epollin 事件,recv 收包,然后拆包获取源地址和端口,判断存活状态。tcpdump 结果看回包没问题,拆包有问题的可能性也很小,要不也不会这时候出错。于是排查的点集中在了 epoll 事件循环和 recv 这个部分。

首先看 recv 这部分的代码:

std::string capture(int recv_fd, const struct sockaddr_in &lo) {
    char recv_buf[RECV_BUF_SIZE];
    memset(recv_buf, 0, RECV_BUF_SIZE);

    struct sockaddr saddr;
    socklen_t saddr_size = sizeof(saddr);
    if (recvfrom(recv_fd, recv_buf, RECV_BUF_SIZE, 0, &saddr, &saddr_size) < 0) {
        fprintf(stderr, "WARNING: Revf from socket %d failed\n", recv_fd);
        return "";
    }

    // 忽略以下拆包的代码
    // ...
}

只有 recvfrom 这个操作了,难道是这儿的问题?前文中也提到了,因为 raw socket 的性质,回包都是通过同一个 raw socket 接收的,会不会是同时有多个 packet 到达,recv 会合并读取这些个包,而解析时候只当作一个解析,所以丢失结果了?仔细阅读了 recvfrom 的文档,google 了很多相关资料,排除了这种可能性。Stackoverflow 上的这个问题和我的疑虑类似,回答中也明确说了,不会合并,不同的数据包同时到达时仍然是不同的数据包,需要多次调用 recvfrom 来获取数据。

其实看到这个问题和里面的回答,如果对网络编程熟悉一些,应该可以找到问题了,但正式因为不熟悉,所以没有意识到里面提到的 buffer 是指的排队的 buferr,错误理解成代码中的 recv_buf,错失了找到问题的一次机会。

recv 看起来没有问题,难道是 epoll 的事件循环有问题?毕竟打日志调试的时候发现,确实少了100多个事件。相关的主要代码如下:

    // 在限定时间范围内接收返回结果,对于收到结果的 target,判断是否恢复
    while (true) {
        int event_cnt = epoll_wait(epoll_fd, &event, 1, 150);
        if (event_cnt < 0) {
            fprintf(stderr, "ERROR: Epoll failed with errno: %d\n", errno);
            exit(3);
        }
        for (int i = 0; i < event_cnt; ++i) {
            std::string str_host = capture(event.data.fd, local);
            if (str_host.empty()) continue; 

            if (health_states[str_host].st_change_on_success()) {
                std::string content = std::string("服务: ") + detect_flag[str_host] + "  地址: " + str_host + "\n";
                recover_hosts.emplace_back(content);
            }

            detect_flag.erase(str_host);
        }
        if (get_cur_ms() - start_ms >= 900) break;
    }

这一块儿,调试的时候也有一些疑问,就是每次 epoll 都只返回1个事件,然后几百次就要几百个循环去做系统调用,肯定会慢一些。慢的话,是否有可能处理的晚的包就超时被丢掉了?

先看 epoll 每次都返回1个事件的问题。epoll_wait的第2、3个参数返回的事件结构体数组以及最多返回事件的数量,由于之前理解不透彻,分别填的是单个 event 结构体的地址和1,改成数组和10之后测试,返回的时间仍然是1,疑惑半天,StackOverflow 上的这个问题给出了回答:epoll_wait返回的事件数量指的是有 I/O 活动的文件描述符的数量,而由于我们只监听了一个 raw socket,所以不管达到多少个事件,每次返回数量总是1。既然如此,那我们就改写一下代码,对于每次 epollin 的事件,我们就循环读,直到没有数据为止,这样也可以减少 epoll_wait的系统调用,改动如下:

    for (int i = 0; i < event_cnt; ++i) {
        while (true) {
            // capture中的 recv_from,需要加上 MSG_DONTWAIT 选项变为非阻塞
            // 读不到数据的时候就会立即退出,返回空
            // 然后这里遇到空则退出循环
            std::string str_host = capture(event.data.fd, local);
            if (str_host.empty()) break;  

            if (health_states[str_host].st_change_on_success()) {
                std::string content = std::string("服务: ") + detect_flag[str_host] + "  地址: " + str_host + "\n";
                recover_hosts.emplace_back(content);
            }

            detect_flag.erase(str_host);
        }
    }

当然也看了不少 ET 和 LT 的对比,在这里没有明显区别,LT 处理相对简单,就不拓展问题了。改完之后,发现处理的事件仍然还是少,问题依旧存在。

不过在这个过程中,阅读的大量资料,让我对这个模型的轮廓逐渐熟悉,大体猜测到,问题可能真的是因为回包被丢掉了,而丢掉的原因,很有可能是因为处理的慢,而排队长度又有限,导致有一部分包直接被丢弃了。正如 tcp dump结果里写的那样,*** packets droped by kernel。解决方法,要么是处理的足够快,要么就是加大队列长度(最简单的做法,by redhat documentation - SOCKET QUEUE)。而这里的队列,就是SO_RCVBUF指定的了,所以,修改这个值,问题应该就能解决,创建 raw socket 的代码修改如下:

    // Because using lsf we need to create an ETH_PACKET capture socket
    int recv_socket = 0;
    if ((recv_socket = socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL))) < 0) {
        fprintf(stderr, "ERROR: Create recv socket failed\n");
        return -1;
    }

    // Set large SO_RCVBUF, need to change /proc/sys/net/core/rmem_max
    unsigned int optVal = 624640;
    unsigned int optLen = sizeof(optVal);
    if (setsockopt(recv_socket, SOL_SOCKET, SO_RCVBUF, &optVal, optLen) < 0) {
        fprintf(stderr, "ERROR: Cant't set recv buf for recv socket\n");
        return -1;
    }
    optVal = 0;
    getsockopt(recv_socket, SOL_SOCKET, SO_RCVBUF, &optVal, &optLen);
    fprintf(stderr, "DEBUG: initial socket receive buf %d\n", optVal);

但是,需要注意的是,SO_RCVBUF的取值还受到系统上限的限制,在 linux 手册中有介绍,最大值上限由/proc/sys/net/core/rmem_max决定,最终取值是max(代码中设定的值,2*rmem_max取值),也就是最大也只能是上限的两倍,如果仍然不够,就需要修改/proc/sys/net/core/rmem_max的取值

7C7A7150-EB3F-4A7D-A0D9-42146A7D3EBA.png

修改完buffer 限制,一切恢复正常,问题解决。果然只看书是没用的,需要在实践中去理解消化。

使用 RAW_SOCKET 做端口探活

基本原理

如果不考虑其他,按最简单的方式来, 那么使用 TCP 的 conn 就可以实现端口探活,例如:每秒对目标 ip:port 请求建立连接,成功则认为端口存活,一段时间失败 n 次则认为端口失活。这样,对于简单的场景以及探活目标数量较少的时候,可以胜任。但不足之处在于:

  1. 每次连接需要完成三次握手:SYN, SYN+ACK, ACK,实际第三次是不需要的,收到 SYN+ACK 即可知道端口存活,无需回复 ACK完成连接的建立,效率会高很多
  2. 对服务方可能造成影响,例如,Server正常的逻辑是建立连接之后要开始读取数据,而通过 conn 的探活会立刻结束这次连接,那么在 Server 端,很可能会有一次 wf 日志

那么,其实就可以通过构造包的形式,来模拟三次握手中的前两次:

  1. 探测方发送 SYN包
  2. 如果收到来自被探测方的 SYN+ACK 包,认为端口存活,回复 RST 重置连接(如果不回复 RST,就变成了 SYN 攻击的形式,只是量极小);如果超出一定时间仍没收到响应,认为此次探测失败。
  3. 发送 RST 包重置连接

方法就来自于端口扫描,除去 SYN+ACK 的方式,还有其它(FIN,NULL,XMAS 等),感兴趣可以详细了解。

说到构造数据包,我们又不得不先看一下 OSI 七层模型或者TCP/IP 四层模型,以及 TCP/IP 的封包方式以及各自的组成。

E80CC815-FDB0-4AF2-B9E3-5333B5DC57F6.png

在这里,重点关注一下数据链路层、网络层和传输层,我们涉及的封包也就在这三层上,这三层中,每一层都是以前一层为基础。数据链路层主要任务是完成网络相邻节点间的可靠传输,数据以帧(Frame)的形式组织,在数据链路层,“对我们来说”,最主要的设备是网卡,通过系统接口,我们可以获取流经指定网卡的帧,再通过对帧的层层解析,获取其中的 IP 包和 TCP 包,实现我们的目的。如果说数据链路层保证了可靠的传输,那么网络层则在此基础上实现了主机间的逻辑通信,能够将数据发送到具有指定 IP 地址的主机上,而 传输层则进一步指定这份数据是发送到哪一个进程(端口号)

简单来看,帧(Frame),IP,TCP 的封包格式大体如下:

4F3727BE-89FB-4043-AA52-45AF22969430.png

在接下来具体操作阶段,我们就会按照这种格式进行数据包的生成。由于发包时帧无需自己构造,我们从 ip 和 tcp 的构造开始。

构造 TCP/IP 数据包

先看一下 ip 协议首部的各个字段,探测包并不需要实际携带用户数据,需要由我们构造的,其实也就是首部的这些字段,主要是:版本,协议字段,首部校验和,还有目标地址和源地址等。

333BFF3B-CB5C-4BD3-8C6C-042D842525C8.png

具体到代码部分,ip.h头文件中的struct iphdr结构体为我们提供了定义了 ip 包的数据结构,其字段包括:

__u8    tos            //服务类型字段(8位)
__u16   tot_len        //总长度字段(16位)
__u16   id             //标识字段(16位)
__u16   frag_off       //16位,低13位分段偏移指明分段位置,高3位用作分片标志
__u8    ttl            //8位,数据包生存时间
__u8    protocol       //协议字段(8位)
__u16   check          //首部校验和(8位)
__u32   saddr          //源 ip 地址(32位)
__u32   daddr          //目标 ip 地址(32位)

类似的,TCP 协议首部的字段结构如下,重点关注其中的:源端口,目标端口,ACK/SYN标志,校验和

EF0873CB-CB04-46D1-8FCC-3EE1061A6FD0.png

同样,在 tcp.h头文件中定义的struct tcphdr结构体提供了 tcp 包的数据结构,其字段包括:

__u16     source         //源端口
__u16     dest           //目标端口
__u32     seq            //数据序号
__u32     ack_seq        //确认序号
__u16     window         //滑动窗口大小
__u16     check          //校验和
__u16     urg_ptr        //紧急指针
#if defined(__LITTLE_ENDIAN_BITFIELD)
    __u16   res1:4,
            doff:4,
            fin:1,    //FIN
            syn:1,    //SYN
            rst:1,    //RST
            psh:1,    //PSH
            ack:1,    //ACK
            urg:1,    //URG
            ece:1,
            cwr:1;
#elif defined(__BIG_ENDIAN_BITFIELD)
    __u16   doff:4,
            res1:4,
            cwr:1,
            ece:1,
            urg:1,    //URG
            ack:1,    //ACK
            psh:1,    //PSH
            rst:1,    //RST
            syn:1,    //SYN
            fin:1;    //FIN
#else
#error "Adjust your <asm/byteorder.h> defines"
#endif

了解了了解了 tcp/ip 的封包格式以及相应的字段组成,接下来,就可以着手开始构造包,示例代码如下:

// 申请一块空间用来存放数据包
char *datagram = (char*)calloc(4096,sizeof(char));

// 将 ip 首部指针和 tcp 首部指针指向各自的位置,
struct iphdr *ip_header = (struct iphdr*)datagram;
struct tcphdr *tcp_header = (struct tcphdr *) (datagram + sizeof (struct ip));

// 填充 ip 首部字段
ip_header->ihl      = 5;                //普通 IP 数据报
ip_header->version  = 4;                //IPv4
ip_header->tos      = 0;
ip_header->tot_len  = sizeof (struct iphdr) + sizeof (struct tcphdr);
ip_header->id       = htons(9999);                      //自定 IP 包标识,方便筛选
ip_header->frag_off = htons(0);
ip_header->ttl      = 64;
ip_header->protocol = IPPROTO_TCP;                      //指定承载的是 TCP 数据包
ip_header->check    = 0;                                //之后需要计算校验和            
ip_header->saddr    = inet_addr(src_ip);
ip_header->daddr    = inet_addr(dst_ip);

// 填充 tcp 首部字段
tcp_header->source  = htons(src_port);
tcp_header->dest    = htons(dst_port);
tcp_header->seq     = htonl(888888);                    //自定义 seq 序号,方便筛选
tcp_header->ack_seq = 0;
tcp_header->doff    = sizeof(struct tcphdr)/4;
tcp_header->fin     = 0;
tcp_header->syn     = 1;                                //构造三次握手中的第一次,SYN 置 1
tcp_header->rst     = 0;
tcp_header->psh     = 0;
tcp_header->ack     = 0;
tcp_header->urg     = 0;
tcp_header->window  = htons(14600);
tcp_header->check   = 0;                                //之后需要计算校验和
tcp_header->urg_ptr = 0;

接下来是分别为 IP 和 TCP 计算校验和,两者计算原理一致,区别在于计算的范围不同,关于校验算法的详细介绍,可以参见 维基百科,简单介绍自行搜索。一种实现如下:

static unsigned short calculate_checkcsum(uint16_t *ptr, int pktlen) {
    uint32_t csum = 0;

    //add 2 bytes / 16 bits at a time!!
    while (pktlen > 1) {
        csum += *ptr++;
        pktlen -= 2;
    }

    //add the last byte if present
    if (pktlen == 1) {
        csum += *(uint8_t *)ptr;
    }

    //add the carries
    csum = (csum>>16) + (csum & 0xffff);
    csum = csum + (csum>>16);

    //return the one's compliment of calculated sum
    return((short)~csum);
}

现在我们已经有了校验和算法的实现calculate_checksum,对于 ip 可以直接计算,对于 tcp 需要借助一个伪头部,如下:

//计算 ip 校验和
ip_header->check = calculate_tcpcsum((uint16_t *) datagram, ip_header->tot_len >> 1);

//借助伪头部计算 tcp 校验和
struct pseudo_header_tcp psh;
psh.source_address = inet_addr(src_ip);
psh.dest_address   = inet_addr(dst_ip);
psh.placeholder    = 0;
psh.protocol       = IPPROTO_TCP;
psh.tcp_length     = htons(sizeof(struct tcphdr));
memcpy(&psh.tcp , tcp_header , sizeof(struct tcphdr));

tcp_header->check = calculate_tcpcsum((uint16_t*)&psh ,sizeof(struct pseudo_header_tcp));

至此,第一次握手的 TCP/IP 数据包构造完毕

使用 RAW_SOCKET 接收和发送数据包

关于 RAW_SOCKET,最好的解释来自于 man 手册,其使用与普通 socket 类似,区别是只有 root 用户能够使用RAW_SOCKET,下面直接给出代码示例:

// 创建一个使用 TCP/IP 协议并可以构造首部数据的 raw_socket
int create_detect_socket() {
    int send_socket  = -1;
    int          one = 1;
    const int  * val = &one;
    if ((send_socket = socket(AF_INET, SOCK_RAW, IPPROTO_TCP)) < 0) {
        return -1;
    }
    if (setsockopt(send_socket, IPPROTO_IP, IP_HDRINCL, val, sizeof(one)) < 0) {
        return -1;
    }
    return send_socket;
}

// 创建线程作用域的 raw_socket 句柄
static thread_local int send_fd = create_detect_socket();

// 根据指定 ip 和端口,发送探测包
int detect(char* dst_ip, int dst_port, char* src_ip, int src_port) {
    //构造 tcp 包,其实现参照上一节的方法
    char *packet = create_tcp_pkt(dst_ip, dst_port, src_ip, src_port);

    struct sockaddr_in dest;
    memset(&dest, 0, sizeof(dest));
    dest.sin_family = AF_INET;
    dest.sin_port   = htons(dst_port);
    inet_pton(AF_INET, dst_ip, &dest.sin_addr);

    ssize_t bytes_sent = sendto(
        send_fd, packet, sizeof(struct iphdr) + sizeof(struct tcphdr),
        0, (struct sockaddr *)&dest, sizeof(dest)
    );
    free(packet);

    if (bytes_sent < 0) {
        fprintf(stderr, "ERROR: Send datagram to %s:%d failed\n", dst_ip, dst_port);
        return -3;
    }

    return 0;
}

发送这一块儿没有什么特别需要注意的,如果是在多线程里使用,建议线程内共用一个 raw_socket 即可,频繁创建和销毁会有额外开销,如果忘记销毁还有可能造成句柄超限。定义线程内共用的变量可以使用thread_local关键字,如代码中使用的send_fd

除此之外,获取本机 ip 也需要注意一下,网上找到的绝大多数方法都是使用getifaddrs来获取指定接口名称的信息,通常是eth0,这里需要注意的是,现在的服务器,不少也开始多网卡了,会通过 bond 模式来将多个网卡合成一个逻辑网卡,比如我测试的时候常见的默认接口并不是eth0,而是bond0,bond1等,所以,除了获取接口地址,还需要获取默认接口,通过查询路由信息可以得知,访问/proc/net/route,其中 Destination 项全部为0的就是默认接口。相关代码如下:

// 获取默认网络接口
bool get_default_if(char *iface, int size) {
    if (size < IFACE_LEN) return false;
    memset(iface, 0, size);

    FILE *f = fopen("/proc/net/route", "r");
    if (!f) return false;

    char dest[64]  = {0, };
    while (!feof(f)) {
        if (fscanf(f, "%s %s %*[^\r\n]%*c", iface, dest) != 2) continue;
        if (strcmp(dest, "00000000") == 0) {
            break;
        }
    }
    fclose(f);
    return strlen(iface) ? true : false;
}

// 获取指定接口的 ipv4 地址
bool get_ip(const char *iface, char* ip, int size) {
    if (size < INET_ADDRSTRLEN) return false;
    memset(ip, 0, size);

    struct ifaddrs * ifAddrStruct = NULL;
    struct ifaddrs * ifa          = NULL;
    void * tmpAddrPtr             = NULL;

    getifaddrs(&ifAddrStruct);
    for (ifa = ifAddrStruct; ifa != NULL; ifa = ifa->ifa_next) {
        if ((ifa->ifa_addr->sa_family == AF_INET) && (strcmp(ifa->ifa_name,iface) == 0)) {
            tmpAddrPtr = &((struct sockaddr_in *)ifa->ifa_addr)->sin_addr;
            inet_ntop(AF_INET, tmpAddrPtr, ip, INET_ADDRSTRLEN);
        }
    }

    if (ifAddrStruct != NULL) freeifaddrs(ifAddrStruct);

    return strlen(ip) ? true : false;
}

相比于发送,接收部分要麻烦的多,有几个需要注意的点:

一,对于UDP/TCP 产生的IP 数据包,内核不会将它传给任何原始套接字,而是直接交给对应的 UDP/TCP 句柄。这一点在 man 手册中也有说明:

A protocol of IPPROTO_RAW implies enabled IP_HDRINCL and is able to send any IP protocol that is specified in the passed header. Receiving of all IP protocols via IPPROTO_RAW is not possible using raw sockets.

在我们的场景中必须要接收,那就只能从数据链路层获取,也就是在创建 RAW_SOCKET 的时候,参数需要变更一下,变成socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL)),第三个参数也可以直接指定ETH_P_IP,然后再从收到的帧中解析 TCP/IP 数据包

二,如果没有 bind 到一个地址,RAW_SOCKET会接收所有流经本机的包;如果 bind 到了一个地址,那么 RAW_SOCKET 只会接收目标是这个地址的符合条件的帧(ETH_P_ALL 会捕获所有,ETH_P_IP会捕获其中的 IP 数据包)

三,另外需要注意 man 中的说明:

When a packet is received, it is passed to any raw sockets which have been bound to its protocol before it is passed to other protocol han‐dlers

注意其中的 any,意思是如果你创建了多个相同捕获条件的 RAW_SOCKET,那么当有符合条件的数据包到达时,所有 RAW_SOCKET 都会收到这个数据包。所以,在接收阶段,只用一个 RAW_SOCKET 就可以了,除非你的筛选条件有所不同。这一点如果还不明确,你可以创建 n 个相同条件的RAW_SOCKET,然后 epoll 监听一下,可以发现,当一个包达到时,epoll 会返回 n 个事件,其句柄就是这些RAW_SOCKET,读出的数据也会是相同的。

发送的时候可以采用多线程同时发,接收的时候,自然就会采用事件触发(例如 epoll)的机制来接收结果,当有响应到达的时候,recv 数据然后解析,找到来源 ip 和 port 是我们之前探测的,并且具有 SYN 和 ACK 标记,那么就可以认为探测目标是存活的了。目标达成,网上大多数文章和示例,都到此为止了。

可是你实际测试一下他们的代码,就会发现,当你的机器在一个比较繁忙的网络中的时候,epoll响应会非常频繁,而且其中大部分都不是你所关心的数据包,而从限制条件来看,除了 bind 能限制一下目标地址,协议选项可以限制一下数据包的类型是 IP,对于来源,标记字段等,都没有限制,自然符合条件的数据包就会有很多,epoll 自然也会频繁响应。

那么,是否有办法缩小这个限制条件呢?答案是有的,使用 lsf (linux socket filter)。lsf 允许用户在一个 socket 上添加一个自定义的 filter,只有满足 filter 指定条件的数据包才会上发到用户空间,如果添加在RAW_SOCKET 上,就可以实现全部数据包的过滤,类似 tcpdump。使用方式可参见SOCK_RAW with tcpdump

使用 lsf 需要将过滤条件转成 bpf 格式的汇编指令,自己写难度较高,好在 tcpdump 可以做这种转换,参数 -dd 可以根据指定的限制条件生成 bpf code。 例如:

# tcpdump -dd tcp and dst host 192.168.23.78 and port 9902
{ 0x28, 0, 0, 0x0000000c },
{ 0x15, 13, 0, 0x000086dd },
{ 0x15, 0, 12, 0x00000800 },
{ 0x30, 0, 0, 0x00000017 },
{ 0x15, 0, 10, 0x00000006 },
{ 0x20, 0, 0, 0x0000001e },
{ 0x15, 0, 8, 0xc0a80345 },
{ 0x28, 0, 0, 0x00000014 },
{ 0x45, 6, 0, 0x00001fff },
{ 0xb1, 0, 0, 0x0000000e },
{ 0x48, 0, 0, 0x0000000e },
{ 0x15, 2, 0, 0x000026ae },
{ 0x48, 0, 0, 0x00000010 },
{ 0x15, 0, 1, 0x000026ae },
{ 0x6, 0, 0, 0x0000ffff },
{ 0x6, 0, 0, 0x00000000 },

tcpdump 的资料很丰富,可以自行搜索了解。对于我们的场景,其实只关心目标地址是本机,且 SYN 和 ACK 标记存在,来源是之前探测目标的数据包,但这样子,tcpdump 的过滤条件怕是写不出来,因为探测目标本身每次就不一样。这时候,SEQ 和 ACK-SEQ 的作用就显现出来了,看一下之前的代码,SEQ 序号是我们自定义的值,目的就是为了能够容易的筛选包,而 ACK-SEQ 就是探测方确认的序号,其值是 SEQ+1。我们就可以通过ACK-SEQ 来筛选了,筛选命令为:tcpdump -dd 'tcp and tcp[tcpflags] & (tcp-syn|tcp-ack) != 0 and tcp[8:4] = 888889',相关代码如下:

// tcpdump 生成的 bpf 指令
static struct sock_filter tcp_filter [] = {
    { 0x28, 0, 0, 0x0000000c },
    { 0x15, 11, 0, 0x000086dd },
    { 0x15, 0, 10, 0x00000800 },
    { 0x30, 0, 0, 0x00000017 },
    { 0x15, 0, 8, 0x00000006 },
    { 0x28, 0, 0, 0x00000014 },
    { 0x45, 6, 0, 0x00001fff },
    { 0xb1, 0, 0, 0x0000000e },
    { 0x50, 0, 0, 0x0000001b },
    { 0x45, 0, 3, 0x00000012 },
    { 0x40, 0, 0, 0x00000016 },
    { 0x15, 0, 1, 0x000d9039 },
    { 0x6, 0, 0, 0x0000ffff },
    { 0x6, 0, 0, 0x00000000 },
};

// 创建带有自定义 filter 的 raw_socket,从数据链路层收包
int create_capture_socket(/*char* src_ip, int src_port*/) {
    int recv_socket = 0;
    if ((recv_socket = socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL))) < 0) {
        fprintf(stderr, "ERROR: Create recv socket failed\n");
        return -1;
    }

    struct sock_fprog filter;
    filter.len    = sizeof(tcp_filter)/sizeof(struct sock_filter);
    filter.filter = tcp_filter;
    if (setsockopt(recv_socket, SOL_SOCKET, SO_ATTACH_FILTER, &filter, sizeof(filter)) < 0) 
    {
        return -1;
    }

    return recv_socket;
}

// 接收包之后的解析和判断
std::string capture(int recv_fd, const struct sockaddr_in &lo) {
    char recv_buf[RECV_BUF_SIZE];
    memset(recv_buf, 0, RECV_BUF_SIZE);

    struct sockaddr saddr;
    socklen_t saddr_size = sizeof(saddr);
    if (recvfrom(recv_fd, recv_buf, RECV_BUF_SIZE, 0, &saddr, &saddr_size) < 0) {
        fprintf(stderr, "WARNING: Revf from socket %d failed\n", recv_fd);
        return "";
    }

    struct iphdr *iph = (struct iphdr*)(recv_buf + sizeof(struct ethhdr));
    unsigned short iph_len = (iph->ihl) * 4;
    if (iph_len < 20) {
        fprintf(stderr, "WARNING: Invalid IP header length: %u bytes\n", iph_len);
        return "";
    }

    if (iph->protocol == 6) {
        struct tcphdr *tcph = (struct tcphdr *)(recv_buf + iph_len + sizeof(struct ethhdr));

        struct in_addr source;
        source.s_addr = iph->saddr;

        if (tcph->syn == 1 && tcph->ack == 1 && iph->daddr == lo.sin_addr.s_addr && tcph->dest == lo.sin_port) {
            int remote_port = ntohs(tcph->source);
              char remote_ip[INET_ADDRSTRLEN] = {0, };
            inet_ntop(AF_INET, &source, remote_ip, sizeof(remote_ip));

            return std::string(remote_ip)+std::to_string(remote_port);
        }
    }
    return "";
}

发送和接收都完成了,最后一步,需要发送 RST 包断开连接,这一步其实内核会自动帮我们完成,在这篇blog Raw Socket 注意事项 上有解释,简单来说,当我们使用 raw_socket 发 syn包的时候,内核 TCP 协议栈并不知道此事,本地也没有半连接记录,所以当收到 SYN&ACK 的响应的时候,内核会认为这个包是非法的,响应 RST 终止连接。如果仍不确信此事,可以通过 tcpdump 来观察:

063E4FAD-411C-4DE1-BF5C-D213A1532C77.png

如上图是基于上述代码所做的一次探测,然后通过tcpdump 捕获的数据包,其中 Flags 标志中,S表示 SYN,S.表示 SYN+ACK,而R则是表示 RST,在通过 SEQ 序号可以很明显看到,这是一次探测过程,双方(记为 A 和 B)所表现出的行为:

  1. A 首先发出 SEQ=888888 的 SYN 包
  2. B 收到包然后响应 SYN+ACK,其应答序号 ACK-SEQ=888889
  3. A 因协议栈无记录,认为响应包非法,回复 RST 包,其序号为 SEQ=888889

至此,一个探测的整个过程,基本都涉及到了,并且也给出了主要的代码实现。完整的程序还需要使用多线程发包, epoll监听EPOLLIN事件来触发 recv ,以及判断探测目标是否健康的策略,这部分就相对简单,而且资料也多,可以根据需求自行实现。

Nurse是基于以上原理实现的一个端口探活工具,可以周期性的监控目标端口,在目标端口失活或者存活时候发送消息告知,相关代码请参见:https://github.com/troycheng/nurse