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开始讲起。

等等,笔者貌似还没有讲解架构,算了,还是先讲解网络架构及设计吧。

读者将会在下一篇看到,任何系统的架构设计,其实都是一个哲学问题,分层架构是一种伟大的发明,它甚至能够让连续的事情变成同时处理的事情。