Linux上实现对GRE keepalive包的回复

最近我把一些跑在公有云上的RouterOS替换成了正常的Linux发行版大多数RouterOS原有的路由功能都可以通过Linux标准的工具加以实现虽然Linux当路由器总有一个令人诟病的问题就是不支持配置自动保存和开机自动恢复需要自己写一大堆脚本和配置但是有一件事情让我头疼了一会儿LinuxGRE隧道原生不支持keepalive

GRE Keepalive协议解析

GRE是一个非常简单的无状态隧道协议众所周知一切网络问题都可以用加一个数据包头来解决GRE就干这么一件事情这头发包的时候前面加上一个头那边收到包以后把头拆掉中间所有的路由器就成为了工具路由器逻辑上就都不存在了相比其它隧道协议它有几点特性

  • 虽然它是一个点对点隧道但是它支持multicast
  • 虽然它的包头比IPIP6IP6IP等简单粗暴的隧道要大一点儿但是它支持MPLS之类的协议
  • 无状态不需要握手和协商方便硬件ASIC实现封包和拆包
  • 被绝大多数企业级路由器黑盒子支持甚至可能是某些企业级路由器上唯一支持的隧道协议

所以在网络工程上GRE的应用相当广泛但是无状态这个特性在实践中会带来一些问题比如很多路由器系统对静态路由是只支持根据接口的updown状态实现路由failoverGRE接口配置完以后永远处于up状态就会给容灾架构的设计带来很大困扰BFD协议可以用来检测隧道对端是否可达但是也要和动态路由协议联动才有意义要是有一种方法能让GRE隧道自己知道自己是否可以连通对端设备然后改变自己的端口状态那它就能完美符合一个路由器对接口这一概念的抽象了GRE keepalive就是在这样的想法之下诞生的

在企业环境下任何新功能的引入都得考虑和旧设备尤其是那些十年前就装在那然后没人敢碰一下的旧设备的兼容性问题而且GRE不存在握手过程所以也没有办法判断对方是否支持特定的功能GRE keepalive利用了路由器对隧道封装的数据包的处理逻辑巧妙地让不支持GRE keepalive的设备也能不知情地响应keepalive我们来看一下路由器是怎么处理一个GRE包的

GRE packet structure

GRE包结构图片来自Red Hat Developers Blog

这个包会通过物理接口发送到路由器的IP协议栈协议栈见到IP头之后的GRE会首先匹配本机的GRE隧道配置找到相应的隧道否则丢弃接下来是解封装过程前面的IP头和GRE头都被丢弃解封装结束以后拆出来的数据包重新回到IP协议栈根据内部IP头重新执行路由查找和包转发过程那么如果我们在GRE隧道里面封装一个IP这个包的源地址是对端的隧道端点IP目标地址是我方端点IP然后把这个包从GRE隧道内发出去会发生什么呢显然对端对GRE做解封装处理以后这个包会经过物理链路重新路由回本机那么如果解封装以后的包的payload部分正好是一个长度为0GRE本机的GRE处理程序就会收到一个长度为0GRE从而判断对方仍然在线GRE keepalive就是通过这样的机制在不影响协议兼容性的情况下实现了对网络连通性的检测

Linux为什么不能原生支持GRE Keepalive

LinuxIP协议栈会对所有进来的包先做一些基础的检查以确定这个包是否合法检查规则中包括了一条收到的包的源IP不能是本机IP net/ipv4/fib_frontend.c 的  __fib_validate_source 函数里我们可以看到检查的具体逻辑

这样一来对端即使给Linux发送了GRE keepalive只要进了LinuxIP协议栈就会被丢弃

Linux上的GRE Keepalive实现

实现的思路其实很简单我们只要在包进到IP协议栈之前先把它抓到想办法自己处理完然后发回去即可几年前有人写过一个Perl脚本实现方法是pcap抓包然后用户态开一个raw socket回复不过我觉得现在都2020年了是时候用点新方法了 让我们来看一看有哪些方法能抓到这个包

Netfilter包处理路径图片来自Wikimedia Commons

从图上可以看到其实方法就两个alloc_skb 之后用 AF_PACKET 抓包的方法跟libpcapPF_PACKET 就差一个二层协议栈好像没有什么新意那就只剩下一个选择使用位于流程图最左边的XDP/eBPF机制XDP的原理很简单内核里面有一个VM负责执行eBPF程序你把你写的钩子函数编译成eBPF IR加载进内核包进来的时候你的钩子函数就会被调用内核根据钩子函数的返回值决定做什么操作返回值总共有五种选项

  • XDP_PASS 表示这个包会按照流程继续走下去
  • XDP_DROP 表示这个包应该被丢掉CloudFlare就是用它实现了超高性能的丢包
  • XDP_TX 表示这个包应该被原路发回
  • XDP_REDIRECT 表示这个包应该被发送到其它接口上
  • XDP_ABORT 表示程序出错无法处理包会被丢掉

另外钩子函数还可以对当前数据包进行任意修改包括更改其内容以及改变包的长度libBPF提供了一些方便的工具函数来帮你做这些事情这样我们就有了实现思路首先匹配外部的GRE头和payload里面的长度为0GRE识别到这是一个keepalive包以后通过调整长度削掉外部IP头和GRE最后返回一个  XDP_TX包就回去了不过这事儿虽然说着简单有一些大坑还是需要提前做个心理准备的

刚开始写eBPF程序的时候配置环境是个很让人头疼的问题如果不想在源代码里面塞一整个Linux内核可以按照xdp-tutorial的方法单独把libBPF拿出来链接不过那份tutorial里面有一些东西可能是你在自己写项目的时候会想要改一下或者删掉的

因为eBPF程序是在内核里跑的能直接操作内核的内存空间因此内核会对程序的安全性有很高的要求eBPF程序加载前内核的静态检查器会对程序做检查程序本身不难但是这静态检查可麻烦了首先访问任何内存地址之前需要自己检查访问是否越界XDP钩子函数接收到的参数里面包含了当前数据包的起始位置和结束位置指针每次如果要往后读必须先检查要解引用的指针是否超出了结束位置其次程序执行时不允许向前跳转例如使用goto跳转到上方的代码段或者任何形式的循环如果一定要使用循环需要让编译器在编译期展开静态检查器报错的时候只会告诉你出错位置附近的几行IR汇编我们可以用 llvm-objdump -S program.o 反汇编来查找对应的C源代码位置

Linux的隧道分成TAPTUN两个大类如果你的XDP程序加载到TAP隧道上那么你会收到一个Ethernet如果是TUN隧道呢那么收到的直接是IP没有前面的EthernetGRE隧道是TUN隧道GREv6ip6gre是个TAP隧道当时在做GREv6支持的时候这让我困惑了好一会儿最开始我还实现了个猜测里面是Ethernet包还是IP包的过程后来转念一想创建隧道的时候隧道类型不是可预知的吗为什么不写两个函数在创建隧道的时候加载对应的那个呢于是就大幅简化了程序结构

最后这是写好的程序Jamesits/linux-gre-keepalive


参考资料

发表回复

您的邮箱地址不会被公开 必填项已用 * 标注

这个站点使用 Akismet 来减少垃圾评论了解你的评论数据如何被处理