30、Traceroute实现:使用原始套接字实现traceroute、TTL递增技巧

网络诊断中,traceroute 是个非常实用的工具。它能告诉你,数据包从你的电脑到目标服务器,一路上经过了哪些路由器。说白了,就是帮你画出网络路径的地图。

我个人习惯用 traceroute 来排查网络延迟问题。有一次线上服务响应慢,我怀疑是某个中间节点出了问题。用 traceroute 一查,果然发现某个跳数延迟异常高。嗯,这就是我们今天要自己动手实现的东西。

核心原理:TTL 与 ICMP

实现 traceroute 的关键,在于 IP 协议头里的 TTL(Time To Live) 字段。TTL 是一个 8 位的数字,每经过一个路由器,TTL 就减 1。当 TTL 减到 0 时,路由器会丢弃这个数据包,并给发送方回一个 ICMP 超时消息

你想想看,如果我们先发一个 TTL=1 的包,第一个路由器收到后 TTL 变 0,它就会回 ICMP 超时。这样我们就知道了第一跳的地址。接着发 TTL=2 的包,第二跳的地址就拿到了。以此类推,直到到达目标。

核心流程:

  1. 构造 UDP 数据包,设置 TTL = 1
  2. 发送数据包到目标端口(通常用大端口号,避免被服务响应)
  3. 接收 ICMP 超时消息,解析出路由器 IP
  4. TTL 递增,重复步骤 1-3
  5. 如果收到 ICMP 端口不可达,说明到达目标主机
Traceroute 工作原理 发送方 路由器 1 路由器 2 目标主机 TTL=1 TTL=2 TTL=3 TTL=2 TTL=3 ICMP 超时 ICMP 超时 ICMP 端口不可达 说明: 1. 发送方依次发送 TTL=1, 2, 3... 的 UDP 探测包 2. 每个路由器收到后 TTL 减 1,若为 0 则回复 ICMP 超时 3. 目标主机收到后,因端口未监听,回复 ICMP 端口不可达 4. 发送方根据 ICMP 回复的源 IP,确定每一跳的地址

原始套接字:直接操作 IP 层

要实现 TTL 控制,我们需要使用 原始套接字(Raw Socket)。普通套接字只能操作传输层(TCP/UDP),而原始套接字允许我们直接构造 IP 数据包。

我在项目中遇到过一个问题:用原始套接字发送数据时,系统会自动填充 IP 头的一些字段。如果你自己构造了 IP 头,记得设置 IP_HDRINCL 选项,告诉内核「别帮我填,我自己来」。

创建原始套接字:

int sock = socket(AF_INET, SOCK_RAW, IPPROTO_RAW);
if (sock < 0) {
    perror("socket");
    exit(1);
}

// 设置 IP_HDRINCL,表示我们自己构造 IP 头
int on = 1;
if (setsockopt(sock, IPPROTO_IP, IP_HDRINCL, &on, sizeof(on)) < 0) {
    perror("setsockopt");
    exit(1);
}

注意:原始套接字需要 root 权限。如果你在普通用户下运行,会提示权限不足。我建议在开发时用 sudo,但生产环境要注意安全。

TTL 递增技巧

TTL 递增是 traceroute 的核心。每次发送探测包后,TTL 加 1。但这里有个细节:每次发送的 UDP 端口号要不同。为什么?因为 ICMP 回复里会携带原始数据包的部分内容,包括端口号。通过端口号,我们可以匹配哪个探测包对应哪个回复。

我曾经踩过一个坑:连续发送多个 TTL 相同的包,结果 ICMP 回复乱序了,根本分不清哪个回复对应哪个包。后来我改用递增端口号,每个探测包用唯一的端口,问题就解决了。

代码实现

下面是一个简化版的 traceroute 实现。它使用原始套接字发送 UDP 包,并接收 ICMP 回复。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/ip.h>
#include <netinet/ip_icmp.h>
#include <netinet/udp.h>
#include <arpa/inet.h>

#define PACKET_SIZE 4096
#define MAX_HOPS 30
#define TIMEOUT_SEC 2

// 计算校验和
unsigned short checksum(void *b, int len) {
    unsigned short *buf = b;
    unsigned int sum = 0;
    unsigned short result;

    for (sum = 0; len > 1; len -= 2)
        sum += *buf++;
    if (len == 1)
        sum += *(unsigned char *)buf;
    sum = (sum >> 16) + (sum & 0xFFFF);
    sum += (sum >> 16);
    result = ~sum;
    return result;
}

int main(int argc, char *argv[]) {
    if (argc != 2) {
        fprintf(stderr, "用法: %s <目标IP>\n", argv[0]);
        exit(1);
    }

    // 创建原始套接字
    int sock = socket(AF_INET, SOCK_RAW, IPPROTO_RAW);
    if (sock < 0) {
        perror("socket");
        exit(1);
    }

    int on = 1;
    setsockopt(sock, IPPROTO_IP, IP_HDRINCL, &on, sizeof(on));

    // 创建接收套接字,用于接收 ICMP
    int recv_sock = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);
    if (recv_sock < 0) {
        perror("recv socket");
        exit(1);
    }

    struct sockaddr_in dest;
    dest.sin_family = AF_INET;
    dest.sin_port = htons(33434);  // 起始端口
    inet_pton(AF_INET, argv[1], &dest.sin_addr);

    char packet[PACKET_SIZE];
    char recv_buf[PACKET_SIZE];

    printf("Traceroute to %s...\n", argv[1]);

    for (int ttl = 1; ttl <= MAX_HOPS; ttl++) {
        memset(packet, 0, PACKET_SIZE);

        // 构造 IP 头
        struct iphdr *iph = (struct iphdr *)packet;
        iph->version = 4;
        iph->ihl = 5;
        iph->tos = 0;
        iph->tot_len = sizeof(struct iphdr) + sizeof(struct udphdr);
        iph->id = htons(ttl);
        iph->frag_off = 0;
        iph->ttl = ttl;
        iph->protocol = IPPROTO_UDP;
        iph->check = 0;
        iph->saddr = inet_addr("0.0.0.0");  // 由内核填充
        iph->daddr = dest.sin_addr.s_addr;

        // 构造 UDP 头
        struct udphdr *udph = (struct udphdr *)(packet + sizeof(struct iphdr));
        udph->source = htons(33434 + ttl);  // 递增端口
        udph->dest = htons(33434 + ttl);
        udph->len = htons(sizeof(struct udphdr));
        udph->check = 0;

        // 发送数据包
        if (sendto(sock, packet, iph->tot_len, 0,
                   (struct sockaddr *)&dest, sizeof(dest)) < 0) {
            perror("sendto");
            continue;
        }

        // 接收 ICMP 回复
        struct sockaddr_in from;
        socklen_t from_len = sizeof(from);
        fd_set fds;
        struct timeval tv = {TIMEOUT_SEC, 0};

        FD_ZERO(&fds);
        FD_SET(recv_sock, &fds);

        if (select(recv_sock + 1, &fds, NULL, NULL, &tv) > 0) {
            int n = recvfrom(recv_sock, recv_buf, PACKET_SIZE, 0,
                             (struct sockaddr *)&from, &from_len);
            if (n < 0) continue;

            // 解析 ICMP 头
            struct iphdr *recv_iph = (struct iphdr *)recv_buf;
            int iph_len = recv_iph->ihl * 4;
            struct icmphdr *icmph = (struct icmphdr *)(recv_buf + iph_len);

            char ip_str[INET_ADDRSTRLEN];
            inet_ntop(AF_INET, &from.sin_addr, ip_str, sizeof(ip_str));

            if (icmph->type == ICMP_TIME_EXCEEDED) {
                printf("%2d  %s\n", ttl, ip_str);
            } else if (icmph->type == ICMP_DEST_UNREACH) {
                printf("%2d  %s (到达目标)\n", ttl, ip_str);
                break;
            }
        } else {
            printf("%2d  * (超时)\n", ttl);
        }
    }

    close(sock);
    close(recv_sock);
    return 0;
}

关键点解析

组件 作用 注意事项
原始套接字 (IPPROTO_RAW) 发送自定义 IP 数据包 需要 root 权限,设置 IP_HDRINCL
ICMP 接收套接字 (IPPROTO_ICMP) 接收 ICMP 超时和端口不可达消息 用 select 设置超时,避免阻塞
递增端口号 匹配探测包和回复 每个 TTL 使用不同端口,避免混淆
TTL 字段 控制数据包经过的路由器数量 从 1 开始,每次递增 1

避坑指南

我曾经在实现 traceroute 时遇到几个坑,分享给你:

  • 校验和问题:IP 头的校验和可以交给内核计算,但 UDP 校验和如果设为 0,某些路由器会丢弃。我建议手动计算 UDP 校验和,或者干脆用 ICMP 代替 UDP 作为探测包。
  • 超时处理:网络环境复杂,有些路由器不会回复 ICMP 超时。一定要设置超时,否则程序会卡死。我一般用 2 秒超时,超过就打印 *。
  • 端口选择:不要用知名端口(如 80、443),否则目标主机可能会回复正常数据。用 33434 以上的高端口比较安全。
  • 多线程问题:如果你要同时追踪多个目标,记得每个追踪用独立的套接字,避免 ICMP 回复混淆。

总结一下:

traceroute 的实现并不复杂,核心就是 TTL 递增 + ICMP 解析。原始套接字给了我们直接操作 IP 层的能力,但也带来了权限和安全问题。在实际项目中,我建议把 traceroute 功能封装成一个独立的模块,用子进程执行,避免影响主程序的稳定性。

嗯,如果你在实现过程中遇到问题,可以检查一下:套接字权限、TTL 设置、ICMP 类型判断。这三个地方最容易出错。


公众号:蓝海资料掘金营,微信deep3321