eBPF程序之间的协作-简单实现一个xdpdump

前不久,很多人问我有没有用过xdpdump,它是什么原理。

当然,当时我是没有用过的,也就没有多说,不过我答应大家一旦我了解了之后,肯定会第一时间给大家介绍。

最近在写一些测试小程序的时候,偶然间也有了XDP抓包的需求,也就顺便熟悉了一下xdpdump,最终,我自己写了一个简单的,主要是阐明它的原理。

当然了,经理没有看这篇文章的必要。

在XDP抓包不能使用tcpdump,因为tcpdump是基于PACKET套接字的,而PACKET套接字是运行在Linux内核协议栈的,XDP在内核协议栈之前,所以tcpdump够不到它,也就无法抓取它的数据包。因此,需要一个xdpdump。

xdpdump已经存在了,但是xdpdump并不是一个类似tcpdump的工具,它只是一个说法,并没有统一的实现,我Google了一下,发现xdpdump的实现有很多版本:

之所以会这样,在于XDP还没有实现一个统一类似处理PACKET套接字的 ptype_all框架 (至少在目前没有这种机制)。

这很容易理解,因为XDP本身就是网卡强相关的,不适合做generic操作。所以说,如果需要xdpdump这样的功能,除了掌握上面列的几家的现成工具外,最好的方式无外乎:

  • 自己动手写一个xdpdump。

现在让我们开始。

实现xdpdump之前,必须要解决的一个问题就是:

  • 如何让两个或多个独立的eBPF程序在XDP实现串联?

这是必须的,因为抓包只是一个旁路功能,它不能影响到既有的XDP上eBPF程序的运行,如果当前某网卡的XDP运行着一个eBPF程序,我们希望的是xdpdump和它一起工作,而不是替换它。

我们假设当前现有的eBPF程序是test_echo.c,如下所示:

#include <uapi/linux/bpf.h>
#include <linux/if_ether.h>
#include "bpf_helpers.h"

SEC("xdp_echo")
int xdp_echo_prog(struct xdp_md *ctx)
{
	void *data_end = (void *)(long)ctx->data_end;
	void *data = (void *)(long)ctx->data;
	int in_index = ctx->ingress_ifindex;
	char info_fmt[] = "echo to %d \n";

	if (data + sizeof(struct ethhdr) > data_end) {
		return XDP_DROP;
	}

	bpf_trace_printk(info_fmt, sizeof(info_fmt), in_index);
	return  bpf_redirect(in_index, 0);
}

char _license[] SEC("license") = "GPL";

非常简单的一个eBPF程序,它将一个数据包原路反射回去。

很显然,我们用tcpdump无法抓取到达对应网卡的数据包。我们现在的任务是实现一个xdpdump,它可以抓到到达对应网卡并发射回去的数据包。

不得不介绍一下eBPF的两类机制:

  • 尾调用机制。
    eBPF尾调用可以将控制权从一个eBPF程序转移到另一个eBPF程序,并且不再返回。
  • PIN map机制。
    一个PIN map可以在多个eBPF程序之间共享,它在sysfs中可见。

知道这些就够了。接下来我们就用尾调用和PIN map来将xdpdump的eBPF程序和原始test_echo这个eBPF程序串联起来,让它们一起工作。

首先,我们看xdpdump的eBPF程序,即test_dump.c,代码如下:

#include <uapi/linux/bpf.h>
#include <linux/if_ether.h>
#include <linux/in.h>
#include <linux/ip.h>
#include <uapi/linux/tcp.h>
#include <uapi/linux/udp.h>
#include "bpf_helpers.h"
#include "bpf_endian.h"

struct bpf_elf_map {
	__u32 type;
	__u32 size_key;
	__u32 size_value;
	__u32 max_elem;
	__u32 flags;
	__u32 id;
	__u32 pinning;
};

#define PIN_GLOBAL_NS		2

// 保存下一个eBPF程序,即本文中的test_echo程序,提供给尾调用。
struct bpf_elf_map SEC("maps") next_prog_map = {
	.type = BPF_MAP_TYPE_PROG_ARRAY,
	.size_key = sizeof(u32),
	.size_value = sizeof(u32),
	.pinning        = PIN_GLOBAL_NS,
	.max_elem = 1,
};

// 保存抓取的数据包体,这仅仅用协议元组数据来模拟。
struct packet {
	unsigned int src;
	unsigned int dst;
	unsigned short l3proto;
	unsigned short l4proto;
	unsigned short sport;
	unsigned short dport;
};

// 保存抓取数据包事件信息
struct bpf_elf_map SEC("maps") event_map = {
	.type = BPF_MAP_TYPE_PERF_EVENT_ARRAY,
	.size_key = sizeof(u32),
	.size_value = sizeof(u32),
	.pinning        = PIN_GLOBAL_NS,
	.max_elem = 128,
};

SEC("xdp_dump")
int xdp_dump_prog(struct xdp_md *ctx)
{
	void *data_end = (void *)(long)ctx->data_end;
	void *data = (void *)(long)ctx->data;
	struct ethhdr *eth = data;
	struct packet p = {};

	if (data + sizeof(struct ethhdr) > data_end) {
		return XDP_DROP;
	}

	p.l3proto = bpf_htons(eth->h_proto);
	if (p.l3proto == ETH_P_IP) {
		struct iphdr *iph;

		iph = data + sizeof(struct ethhdr);
		if (iph + 1 > data_end)
			return XDP_DROP;

		p.src = iph->saddr;
		p.dst = iph->daddr;
		p.l4proto = iph->protocol;
		p.sport = p.dport = 0;
		if (iph->protocol == IPPROTO_TCP) {
			struct tcphdr *tcph;
			tcph = data + sizeof(struct ethhdr) + sizeof(struct iphdr);
			if (tcph + 1 > data_end)
				return XDP_DROP;

			p.sport = tcph->source;
			p.dport = tcph->dest;
		} else if (iph->protocol == IPPROTO_UDP) {
			struct udphdr *udph;
			udph = data + sizeof(struct ethhdr) + sizeof(struct iphdr);
			if (udph + 1 > data_end)
				return XDP_DROP;

			p.sport = udph->source;
			p.dport = udph->dest;
		}
		// 事件上报给xdpdump抓包进程
		bpf_perf_event_output(ctx, &event_map, BPF_F_CURRENT_CPU, &p, sizeof(p));
	}

	// 尾调用,调用正常的test_echo eBPF程序
	bpf_tail_call(ctx, &next_prog_map, 0);

	// 如果没有attach别的eBPF程序,则直接PASS
	return XDP_PASS;
}

char _license[] SEC("license") = "GPL";

依然是在源码树的samples/bpf目录下完成编译,得到两个.o文件备用:

-rw-r--r-- 1 root root  11864 12月 24 16:27 test_dump.o
-rw-r--r-- 1 root root   5648 12月 24 09:20 test_echo.o

OK,现在让我们加载test_dump.o到enp0s9网卡:

root@zhaoya-VirtualBox:~/bpf# ip -force link set dev enp0s9 xdp obj ./test_dump.o sec xdp_dump

此时,我们将在文件系统中看到两个PIN map:

root@zhaoya-VirtualBox:~/bpf# ll /sys/fs/bpf/xdp/globals/
total 0
drwx------ 2 root root 0 12月 24 15:25 ./
drwx------ 3 root root 0 12月 24 15:25 ../
-rw------- 1 root root 0 12月 24 15:25 event_map
-rw------- 1 root root 0 12月 24 15:25 next_prog_map
root@zhaoya-VirtualBox:~/bpf#

接下来要做的事情,任务很明确,即将test_echo.o这个eBPF程序,灌进next_prog_map的index=0的位置,这显然需要一个用户态程序来完成,即update_prog.c,代码如下:

#include <stdio.h>
#include <stdlib.h>
#include <bpf/bpf.h>
#include <linux/bpf.h>
#include "bpf_util.h"

static int progmap_fd;

int main(int argc, char **argv)
{
	int idx = 0;
	int opt = 1;
	char *mapfile;
	struct bpf_object *obj;
	struct bpf_prog_load_attr prog_load_attr = {
		.prog_type	= BPF_PROG_TYPE_XDP,
	};
	int prog_fd;

	opt = atoi(argv[1]);
	mapfile = argv[2]; // 获取全局可见的PIN map文件位置
	prog_load_attr.file = argv[3];
	progmap_fd = bpf_obj_get(mapfile);
	if (opt == 0) {
		bpf_map_delete_elem(progmap_fd, &idx);
		return 0;
	}

	// 载入eBPF程序
	if (bpf_prog_load_xattr(&prog_load_attr, &obj, &prog_fd)) {
		return 1;
	}

	bpf_map_update_elem(progmap_fd, &idx, &prog_fd, 0);
	return 0;
}

我们将其编译成update_prog可执行程序,将test_echo.o灌入:

root@zhaoya-VirtualBox:~/bpf# ./update_prog 1 /sys/fs/bpf/xdp/globals/next_prog_map ./test_echo.o

原本能ping通enp0s9地址1.1.1.1,现在ping不通了,数据包被反射:

04:06:30.004904 IP 1.1.1.2 > 1.1.1.1: ICMP echo request, id 6242, seq 97, length 64
04:06:30.005273 IP 1.1.1.2 > 1.1.1.1: ICMP echo request, id 6242, seq 97, length 64
04:06:31.004684 IP 1.1.1.2 > 1.1.1.1: ICMP echo request, id 6242, seq 98, length 64
04:06:31.005394 IP 1.1.1.2 > 1.1.1.1: ICMP echo request, id 6242, seq 98, length 64

删除test_echo.o,重新可以ping通:

root@zhaoya-VirtualBox:~/bpf# ./update_prog 0 /sys/fs/bpf/xdp/globals/next_prog_map

可以在ping的机器上抓包确认:

04:09:47.234037 IP 1.1.1.1 > 1.1.1.2: ICMP echo reply, id 6242, seq 294, length 64
04:09:48.236846 IP 1.1.1.2 > 1.1.1.1: ICMP echo request, id 6242, seq 295, length 64
04:09:48.237430 IP 1.1.1.1 > 1.1.1.2: ICMP echo reply, id 6242, seq 295, length 64
04:09:49.238854 IP 1.1.1.2 > 1.1.1.1: ICMP echo request, id 6242, seq 296, length 64

这说明我们的串联两个eBPF程序成功了。

注意最初通过iproute2加载的test_dump.o这个eBPF程序,其中已经把数据包抓取并上报了,现在只需要最后一道工序,即实现用户态的xdpdump了。

这也不难,我们用perf event采集机制,xdpdump.c的代码如下:

#include <string.h>
#include <poll.h>
#include <perf-sys.h>
#include <linux/if_ether.h>
#include <arpa/inet.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <bpf/bpf.h>
#include <bpf/libbpf.h>

#define CPUS	4

struct packet {
	unsigned int src;
	unsigned int dst;
	unsigned short l3proto;
	unsigned short l4proto;
	unsigned short sport;
	unsigned short dport;
};

struct perf_event_data {
	struct perf_event_header header;
	unsigned long long ts;
	unsigned int size;
	struct packet p;
};

static enum bpf_perf_event_ret print_packet(struct perf_event_header *hdr, void *fn)
{
	struct perf_event_data *data = (struct perf_event_data *)hdr;
	struct packet p = data->p;
	unsigned long long ts = data->ts;
	char src[16], dst[16];
	char l3proto[8], l4proto[8];
	unsigned short sport = 0, dport = 0;

	// 直接打印数据包协议元数据,正常应该是利用libpcap接口来处理的。
	switch (p.l3proto) {
	case ETH_P_IP:
		strcpy(l3proto, "IP");
		inet_ntop(AF_INET, &p.src, src, 16);
		inet_ntop(AF_INET, &p.dst, dst, 16);
		break;
	default:
		sprintf(l3proto, "%04x", p.l3proto);
	}

	switch (p.l4proto) {
	case IPPROTO_TCP:
		strcpy(l4proto, "TCP");
		sport = ntohs(p.sport);
		dport = ntohs(p.dport);
		break;
	case IPPROTO_UDP:
		strcpy(l4proto, "UDP");
		sport = ntohs(p.sport);
		dport = ntohs(p.dport);
		break;
	case IPPROTO_ICMP:
		strcpy(l4proto, "ICMP");
		break;
	default:
		strcpy(l4proto, "Unknown");
	}

	printf("%lld.%06lld %s:%d > %s:%d > %s %s\n", ts/1000000000, (ts%1000000000)/1000, src, sport, dst, dport, l3proto, l4proto);
	return LIBBPF_PERF_EVENT_CONT;
}

int main(int argc, char **argv)
{
	static struct perf_event_mmap_page *buffer[CPUS];
	int eventmap_fd, he;
	int perf_fds[CPUS];
	void *tmp = NULL;
	unsigned long len = 0;
	int i;
	struct pollfd fds[CPUS];
	struct perf_event_attr attr = {
		.sample_type	= PERF_SAMPLE_RAW | PERF_SAMPLE_TIME,
		.type		= PERF_TYPE_SOFTWARE,
		.config		= PERF_COUNT_SW_BPF_OUTPUT,
		.wakeup_events	= 1,
	};

	eventmap_fd = bpf_obj_get(argv[1]);

	for (i = 0; i < CPUS; i++) {
		he = sys_perf_event_open(&attr, -1, i, -1, 0);
		ioctl(he, PERF_EVENT_IOC_ENABLE, 0);
		buffer[i] = mmap(NULL, 8192, PROT_READ | PROT_WRITE, MAP_SHARED, he, 0);
		bpf_map_update_elem(eventmap_fd, &i, &he, BPF_ANY);
		perf_fds[i] = he;
	}

	for (i = 0; i < CPUS; i++) {
		fds[i].fd = perf_fds[i];
		fds[i].events = POLLIN;
	}

	while (1) {
		poll(fds, CPUS, 0);
		for (i = 0; i < CPUS; i++)
			bpf_perf_event_read_simple(buffer[i], 8192, 4096, &tmp, &len, print_packet, NULL);
	}
	return 0;
}

编译成xdpdump(同样在源码树的samples/bpf目录下)之后,我们看看效果:
在这里插入图片描述

OK,很像那么回事。

然而,缺失了很多东西,需要补充:

  • 和tcpdump的兼容性,即通过cBPF来设置filter,类似“tcp port 80 or icmp”这种。
  • 用libpcap接口保存以及解析pcap包。
  • 注入XDP的eBPF程序直接upload整个数据包的包体,而不仅仅是协议元数据。
  • 实现一个基于tail_call的eBPF程序协作框架。


再说下eBPF之好,eBPF可以实现一些内核空间才能的策略,且 不用再担心系统panic了。

此外,再说句题外话:

本文中我的代码均没有按照每行80字符的规矩,因为我觉得那是历史的遗毒。如今显示器的分辨率都这么高了,类似Linux社区这种还要以此为编码规范,我不能理解。其实,在很多方面,Linux内核社区这种都被过度无脑神话了,很多方面如果你仔细看,它就是垃圾!

  • 每行80字符规定
  • 邮件发送接收竟然如此麻烦

浙江温州皮鞋湿,下雨进水不会胖。

发布了1545 篇原创文章 · 获赞 4728 · 访问量 1055万+

猜你喜欢

转载自blog.csdn.net/dog250/article/details/103686162