以线速剖析DNS数据包

2020-05-11 04:37:36

几个月前,我的顾问问我是否想要开发DDiDD项目的一小部分,它将检查传入的DNS数据包,并自动回复任何具有无效域名的数据包,这将使DNS服务器不必响应这些数据包。听起来很简单,不是吗?有一个问题-数据包需要以线速处理,在我的情况下,这意味着40千兆位每秒。

假设数据包大小约为每个数据包80字节,每秒40G比特的纯DNS数据包意味着该程序每秒必须处理6250万个数据包。这给了我16纳秒来处理每个数据包,或者给出一个4.2 GHz的单核处理器的67个CPU周期(假设我的NIC和处理器之间的网桥没有延迟)。这甚至不足以让Linux内核的网络堆栈发送数据包(L.Rizzo,2012,p.3)。

能够创建虚拟接口以与同一台计算机上的DNS服务器通信。

有许多库承诺比Linux内核更高的数据包处理速度,其中大多数依赖于NIC上的硬件或内核旁路技术。有趣的是,这些方法中的大多数依赖于轮询,而不是中断,因为在如此高的网络速度下,终止实际上会减慢数据包处理的速度。

幸运的是,我正在使用的试验台中的一些网卡内部包含FPGA,并且支持P4,这实际上将我们的网卡变成了一个交换机。但是,P4不支持简单的查看数据包内容的方式,只支持查看报头。这也需要购买昂贵的专用硬件,这将限制我们可以部署软件的位置。

Mellanox的VMA通过使用LD_PRELOAD来使用内核自己的网络调用覆盖内核的网络调用来运行,这降低了您必须执行的代价高昂的mempys、中断和上下文切换的数量。

CloudFlare在解释这一点上比我在他们的博客上做得好得多,它的工作方式与Mellanox的解决方案类似。

Netmap是允许快速数据包I/O的内核模块的集合。然而,它也需要打补丁的驱动程序,并且支持的网卡比DPDK少。它还允许您创建供非网络映射程序使用的虚拟网络接口。

DPDK是另一个由英特尔赞助的高速内核旁路库,它支持广泛的网络接口,但有一个麻烦的API,需要为物理层以外的所有内容重写网络堆栈。虽然远不理想,但因为这是我最熟悉的库,所以我最终在项目中使用了它。

DPDK中比较有趣的模块之一是内核NIC接口,它允许您创建供非DPDK程序使用的虚拟接口。然而,它也比传统的虚拟界面更快,因为它省去了内核空间和用户空间之间一些代价高昂的转换。这需要一个内核模块kmod/rte_kni.ko。对于我的用例,我将设置Carrier=On,这样我就不必费心使用RTE_KNI_UPDATE_LINK()。

开发人员还在他们的报告中提供了一个方便的示例应用程序,它将传入的数据从物理NIC接口转发到KNI接口,反之亦然。这里的魔术发生在kni_egress和kni_inress函数中,这两个函数的工作方式类似。

每个接口都有RX和TX环形缓冲区,用于在读取数据包之前将其存储起来。这使得在不处理数据包的情况下发送和接收数据包变得相当简单。要发送数据包,只需将包含数据包的Pusha RTE_mbuf放入TX缓冲区,而要接收数据包,请将RX缓冲区读入不同的RTE_mbuf。这些操作是通过物理接口的RTE_ETH_TX_BULT和RTE_ETH_RX_BURST以及我们的KNI One的RTE_KNI_TX_BURST和RTE_KNI_RX_BURST来实现的。因此,程序需要做的就是使用rte_*_rx_burst读入数据,然后使用rte_*_tx_burst将这些rte_mbuf写出到另一个接口。

由于此示例不处理标头,因此它对最终用户是完全透明的,只是现在所有流量都是通过veth*而不是eth*路由的。

DPDK还直接向用户公开环形缓冲区,这是该项目的核心组件。通过让KNI_INTRESS写入新的环形缓冲区而不是KNI TX环,我可以运行另一个线程来处理这些数据包。这看起来是这样的:

为此,我需要4条线。一个线程将数据包从我们的NIC转发到Worker_RX_RING。在那里,另一个线程读取Worker_RX_RING并解析每个数据包。然后,具有由ICANN确定的无效TLD的所有DNS数据包被传递到Worker_Tx_RING,而其余的继续传递到KNI接口。最后,一个线程向NIC传递无效的TLD响应,而另一个线程传递来自KNI的传出数据包。

现在我们已经让环工作人员在彼此之间传递数据,我们还必须解析传入的DNS数据包,并将它们读入我们的程序。以下是在Wireshark中打开的DNS查询包的示例:

这里我们关注的是DNS部分,因此我们可以跳过前42个字节,它们是第2-4层报头。在开始时,我们有2个字节充当客户端匹配回复的标识符。在此之后,我们有两个字节的标志,RFC 1035将在第4.1.1节中详细介绍。接下来的4组2个字节列出了数据包后面的问题、答案、名称服务器资源记录(RR)和资源记录的数量。需要注意的是,这些记录是大端的,这意味着在x86_64这样的小端架构上运行时,您必须反转它们。我们将跳过额外的RRS,将重点放在问题RRS上。

每个查询(或问题资源记录)都按域拆分成子字符串,因此在我们的示例中,ns5.SPOTIFY.COM将变成ns5、Spotify和com。在每个字符串之前是字符串的长度,因此ns5应该是03 6E 73 35。这同样适用于其他两个域。名称以空终止符00结束。然后,我们获得了查询类型的指示符(在本例中,A记录的指示符为0x0001)和查询类的指示符(互联网地址的指示符为0x0001)。

了解了这一点,实现一个算法来遍历查询名称直到我们找到TLD是很容易的:

//循环到查询名std::string query;int str_len,Offset=0;char*qname_start=qname;while(True){//读入字符串长度str_len=*qname;qname=qname+str_len+1;if(*qname!=0x0)Offset+=str_len+1;Else Break;}。

在本例中,TLD是COM。这恰好是一个有效的TLD,所以我们将把这个包推入KNI的rx_queue并继续。但如果不是呢?

值得庆幸的是,对于我们的用例,我不需要包括权限部分或任何额外的内容,因此,我所要做的就是修改现有的数据包(从而节省错误锁和memcpys),将目的地址和端口与源地址和端口进行交换。这必须在以太网、IPv4和UDP报头上完成。然后,我将修改NXDOMAIN标志,同时使用简单的位掩码保持其他所有内容不变:

//修改DNS头部*(DNS_HDR+2)|=0b10000000;//标准查询权威答案,否//截断或递归*(DNS_HDR+3)=0b00000011;//名称错误。

现在要做的最后一件事是:生成我们的IPv4校验和(并忽略UDP校验和):

//设置IPv4校验和IP_hdr->;hdr_checksum=0;ip_hdr->;hdr_checksum=rte_ipv4_cksum(IP_Hdr);udp_hdr->;dgram_cksum=0;//忽略UDP校验和。

就这样!剩下要做的就是将数据包推送到NIC的RX_QUEUE,然后它就会被发回。