skaiuijing
引言 计算机的任何问题都可以通过加一层中间件解决。
我们使用eBPF挂载程序,当我们的程序执行并触发hook点时,那么就会执行eBPF程序,执行完eBPF程序后,程序返回继续执行。
不管是网络架构的分层,还是eBPF中的程序的挂载,其实本质上都是嵌入了一个中间的角色。
eBPF技术 eBPF是一种内核动态编程技术,我们可以通过在合适的hook点注册我们自定义的程序,从而实现对内核的监控或者是行为的改变。
Linux网络 我们都知道,网络是分层的,每一层都只有每一层的信息。所以,使用eBPF技术时,我们需要选择合适的挂载点以解析数据包。
在上一篇文章中,我们知道了内核各个层及对应的头部结构体,现在让我们尝试解析整个Linux系统的网络流量。
sk_buff与data指针 对于每一层来说,其他层都是独立的,那么是不是意味着每一层读取并解析出自己这一层的数据包后,还要把其他层的数据包内容向上递交呢?那么会不会引起内存浪费呢?
如果每一层都要拷贝内存,那么协议栈的效率会低到无法想象,我们要知道,实现优秀的网络协议栈的性能的诀窍就是零拷贝。
那么Linux是怎么做的呢?
答案是移动指针,sk->data指针,每经过一层,它就会向后移动对应的结构体大小,如果解析了网卡层,那么就移动跳过网卡头,指向ip头部。
当发送数据包时,也是同理,每经过一层,就向前移动对应的结构体的大小。
代码实现 因此,我们可以看见数据包图:
1 2 3 4 5 6 7 8 9 10 11 12 13 graph LR ETH["Ethernet Header\nsrc MAC | dst MAC | ethertype"] IP["IP Header\nsrc IP | dst IP | protocol | ihl"] TCP["TCP Header\nsrc port | dst port | flags"] UDP["UDP Header\nsrc port | dst port | length"] PAYLOAD["Payload\n(e.g. HTTP, DNS, etc.)"] ETH --> IP IP --> TCP IP --> UDP TCP --> PAYLOAD UDP --> PAYLOAD
现在开始尝试解析数据包,
我们都知道五元组:源ip、目的ip、源端口、目的端口、协议可以代表一个连接。
因此,我们尝试解析五元组:
我们选择的挂载点是tc,它位于网卡接口层,此时sk->data指向网卡头部,
在tc层,返回码有许多作用:
1 2 3 4 5 6 7 8 9 10 11 TC ACT OK (0) 终止数据包处理流程,允许处理数据包。 TC ACT SHOT (2) 终止数据包处理流程,丢弃数据包。 TC ACT UNSPEC (-] ) 使用 tc 配直的默认操作,类似于从一个分类器返回-1. TC ACT PIPE ( 3 ) 如果有下一个动作,迭代到下一个动作。 TC ACT RECLASSIFY ( 1 ) 终止数据包处理流程,从头开始分类。 其他是未指定的返回码
在上一节中,我们已经知道了数据包各个结构的大小及分布,sk->data指向数据包,首先是网卡头部,然后是ip头部,再之后才是传输层协议:
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 SEC("tc") int tc_egress(struct __sk_buff *ctx) { // Get packet data boundaries void *data = (void *)(__u64)ctx->data; void *data_end = (void *)(__u64)ctx->data_end; // Ensure packet has enough space for Ethernet + IP headers if (data + sizeof(struct ethhdr) + sizeof(struct iphdr) > data_end) return TC_ACT_OK; struct ethhdr *eth = data; struct iphdr *ip = (void *)(eth + 1); // Validate Ethernet type (must be IPv4) if (eth->h_proto != bpf_htons(ETH_P_IP)) return TC_ACT_OK; // Validate IP version if (ip->version != 4) return TC_ACT_OK; // Extract IP addresses __u32 src_ip = bpf_ntohl(ip->saddr); __u32 dst_ip = bpf_ntohl(ip->daddr); __u16 src_port = 0, dst_port = 0; // Handle TCP or UDP protocols if (ip->protocol == IPPROTO_TCP || ip->protocol == IPPROTO_UDP) { __u32 ip_hdr_len = ip->ihl * 4; // Check space for transport headers if (ip->protocol == IPPROTO_TCP && data + sizeof(struct ethhdr) + ip_hdr_len + sizeof(struct tcphdr) > data_end) return TC_ACT_OK; if (ip->protocol == IPPROTO_UDP && data + sizeof(struct ethhdr) + ip_hdr_len + sizeof(struct udphdr) > data_end) return TC_ACT_OK; // Extract ports if (ip->protocol == IPPROTO_TCP) { struct tcphdr *tcp = (void *)((char *)ip + ip_hdr_len); src_port = bpf_ntohs(tcp->source); dst_port = bpf_ntohs(tcp->dest); } else { struct udphdr *udp = (void *)((char *)ip + ip_hdr_len); src_port = bpf_ntohs(udp->source); dst_port = bpf_ntohs(udp->dest); } } return TC_ACT_OK; }
我们可以尝试在应用层编写python或其他语言的代码,调用bpf层,这样就可以解析出每一个数据包的连接五元组了。
其实在程序中,我们还舍弃了非常多的数据包,只专注于解析udp或者tcp的数据包,而且也只局限于ipv4,对于tc层来说,是必须指定网卡的,因此我们捕获的流量并不是全局流量。
那么在哪一层可以捕获全局流量呢?其实ip层及以上就行。
结语 我们现在仅仅只是解析出了数据包,那么能不能改变数据包行为呢?答案是当然可以的,不过在此之前,我们必须了解Linux网络子系统,我们要开始了解的第一个地方,就是数据包和内存。
所以,笔者接下来会讲解数据包内存的分配,
在玩具协议栈中,笔者会指导读者完成一个内存池算法以及参考Linux内核的slob算法写一种内存管理算法。
对于Linux内核,笔者会从ringbuf及伙伴系统、slab开始讲起。
等等,笔者貌似还没有讲解架构,算了,还是先讲解网络架构及设计吧。
读者将会在下一篇看到,任何系统的架构设计,其实都是一个哲学问题,分层架构是一种伟大的发明,它甚至能够让连续的事情变成同时处理的事情。