Linux Bridge的IP NAT细节探析-填补又一坑的过程
前序
近日温州皮鞋厂老板正在忙着学习Linux Bridge以及诸多虚拟网卡相关的东西,老湿给了一些指导,但最根本的还要靠温州老板自己。就好像有仙灵在聆听心声,我正因为温州老板的缘故一而再再而三地怀念曾经玩转Linux Bridge,Linux Netfilter的那段痛并快乐着的时光,另外一个好玩的东西恰在此时切入。
大概有三年多没有玩Linux Bridge了,甚是想念。感谢同事给我一个Bridge方面的疑难杂症让我诊断!凭着经验,很快搞定了问题,但是如果就此了断怕是在多年后错过一些吹嘘的机会,所以便作此文,以为后来留下当年之叹息。
首先看下曾经发现的Linux Bridge的两个坑:
1.《ebtables的OUTPUT链DNAT问题》
大致就是ebt在DNAT后没有重新更新查找MAC-端口映射,导致可能将帧发到错误的端口。这个是基于2.6.32版本的,当时我是手动填坑的,我不知道社区在后来的版本中有没有修正,这个我也不再关注了。
2.《关于bridge-nf-call-iptables的设计问题》
确切的说,这并不是一个坑,而是一点点缺陷,当年也是手动填补的。
那么今天的这个坑是什么呢?
问题描述
先看下拓扑框图:Linux 2.6.32版本:CMD1,CMD2全部成功;
Linux 3.10.9版本:CMD1失败,CMD2成功;
Linux 4.9.0 版本:CMD1,CMD2全部失败;
爆炸!Why?!
Bridge流程简析
我曾经分析过详细的Linux Bridge的流程,详见《实现透明防火墙的必备知识-Bridge Filter半景》(由于写的文章都没有做索引,所以很多找不到了,我就记得这篇半景分析,其它的应该还有,如果谁找到了请告诉我题目,我好加个索引),后续的内核版本中,该流程变化不大,所以就不重复赘述了。仅仅介绍与本文相关的流程。我们知道,Bridge可以直接调用IP层的NF HOOK,从而完成一些针对数据包的操作,这些操作中NAT当然是典型的一种,也正是这个NAT操作导致了今天的问题。那么我就来描述一下Bridge中调用IP NAT的流程。
2.6.32版本的Bridge我就不分析了,以前分析过好多年了,直接说3.10和4.9版本的吧。
Linux Bridge有一个特性,可以call iptables,虽然说这个特性一直存在,但是其行为却并非一成不变的。先说下3.10的逻辑。
Linux 3.10 Bridge/IP DNAT的流程分析与解法
之前在分析数据包的内核路径时,我比较倾向于使用流程图的方式,然而如果你对Linux的实现方式不感兴趣或者根本一无所知的情况下,这种图很难看得明白,即便是看明白了也很难举一反三。毕竟Linux的方式只是实现方式的一种,并非一定是标准做法,所以画出Linux执行流的流程仅仅有助于你明白执行的逻辑,对解决问题帮助不大。当你再遇到类似的问题的时候,还是必须把代码撸一遍。反正都要撸代码,不如直接从代码入手,这样便可以直接指出哪一段代码是问题之所在,然后解法就自觉出现了。在call iptables的IP层PREROUTING HOOK点执行完毕之后,执行流进入到了br_nf_pre_routing_finish函数,此时如果在IP层PREROUTING上有DNAT的话,那么DNAT已经完成,此后的逻辑如下图:
注意红色五角星处的红字注解,这就是问题所在,如果用brctl showmacs br0看一下,就会发现:
P1--HOST1/ETH0
P2--HOST2/ETH0
最终数据要发到HOST2的ETH0,通过MAC/Port映射可以看出要从P1,也就是Box0的ETH1发出,然而数据本来就是从Box0的ETH1收进来的...从哪个Port收的数据帧,便不再从该Port再发出去,即便是Flood也不行!
其实这个问题跟之前的那个“ebtables的Local out做完DNAT后没有重新路由”的问题差不多,既然已经做过IP DNAT了,目标IP已经改变,这样在目标IP改变之前的二层元数据都应该失效,因为目标IP改变之前,针对该IP的下一跳解析结果可能与目标IP改变之后的下一跳解析结果完全不同,可能IP DNAT之前,数据帧要从Port1出去,而IP DNAT之后,数据帧要从Port2出去了。本段视为牢骚吧,下面给出解药。
明白了问题所在,自然就有了解药。解药有三种:
解一:修改内核源码
很明确,只要是发生了IP DNAT,就不再受限于”从哪个Port收的数据帧,便不再从该Port再发出去“这个约束。nf_bridge_info结构体中的mask字段新增一个BRNF_BRIDGED_IP_DNAT:#define BRNF_BRIDGED_IP_DNAT 0x40然后在探测到发生了IP DNAT的时候置位,修改br_nf_pre_routing_finish:
if (dnat_took_place(skb)) { nf_bridge->mask |= BRNF_BRIDGED_IP_DNAT; ... }最后在判断是否可以在该Port转发时判断IP DNAT这个新增mask,修改should_deliver:
static inline int should_deliver(const struct net_bridge_port *p, const struct sk_buff *skb) { struct nf_bridge_info *nf_bridge = skb->nf_bridge; return (((p->flags & BR_HAIRPIN_MODE) || skb->dev != p->dev || nf_bridge->mask & BRNF_BRIDGED_IP_DNAT) && br_allowed_egress(p->br, nbp_get_vlan_info(p), skb) && p->state == BR_STATE_FORWARDING); }然后就通了。
然而这又是修改内核的小Trick,如果你不能修改内核,那怎么办?其实会改内核充其量只是说明自己对代码熟悉,表现不出网络水平,所以下面的解法才是王道。
解二:配置Hairpin(发夹弯)
什么是Hairpin?这是一个网络虚拟化技术中常提到的概念,也即交换机端口的VEPA模式。这种技术借助物理交换机解决了虚拟机间流量转发问题。很显然,这种情况下,源和目标都在一个方向,所以就是从哪里进从哪里出的模式。如果配置了这个Hairpin,那么正好解决本文描述的这个问题(当然是在3.10内核版本下)。
怎么配置呢?非常简单:
brctl hairpin br0 eth1 on
如果你的内核是你手工编译升级的,那么可能你的用户态程序并不支持新内核对应的所有特性,也就是说你的brctl可能版本过老不支持hairpin命令,那么可以sysfs来搞定:
echo 1 >/sys/class/net/br0/brif/eth1/hairpin_mode
非常好,一条命令,完美解决!
解三:配置混杂模式
第三种解法是一个非常规的简便解法。通过源码的流程分析,可以看到如果一个网卡Port使能了混杂模式的话,那么它收到的数据会无条件往IP层送一份。那么执行下面的命令也是可以的:
ifconfig eth1 +promisc
很显然,混杂模式无意中帮了我们:既然IP DNAT已经执行,那就一律把数据包扔给IP层做抉择吧!虽然这种方式显得过于暴力(因为Bridge希望的是,即便是发生了IP DNAT,也可以尽量在Bridge层做转发,不劳IP层),但保证了数据的正确路由,弥补了Bridge层做的不好的污点。
另外,在排查为什么不通这个问题的时候,抓包是必须的,只要一抓包,就通了,不抓包就不通,自然而然就想到了混杂模式!然而这个红利在4.9内核版本中没有了。在用4.9内核重现这个问题的时候,发现即便是抓包也不通...3.10版本的解析到此为止,下面看4.9版本内核之何解!
Linux 4.9 Bridge/IP DNAT的流程分析与解法
与3.10的分析法类似,下面直接看一下4.9的相同流程,细节上已经有所不同:解法一:通过复杂的配置解决问题
通过br_nf_hook_thresh第二次进入br_nf_pre_routing_finish时,ip_route_input的dev参数已经成了Bridge接收到帧的物理端口,即Box0的eth1。那么ip_route_input一定会失败!这是因为通往转换后IP的路由设备依然是br0,而不是eth1:[root@localhost b]# route -n
Kernel IP routing table
Destination Gateway Genmask Flags Metric Ref Use Iface
1.1.1.0 0.0.0.0 255.255.255.0 U 0 0 0 br0
192.168.44.0 0.0.0.0 255.255.255.0 U 0 0 0 br0 【这里是关键】
所以要想让这个ip_route_input路由查询成功,就一定要有一条到达新的目标IP的路由,其出口是eth1,而不是br0。因此添加一条主机路由:
route add -host 192.168.44.129 dev eth1
此时的路由表为:
[root@localhost b]# route -n
Kernel IP routing table
Destination Gateway Genmask Flags Metric Ref Use Iface
1.1.1.0 0.0.0.0 255.255.255.0 U 0 0 0 br0
192.168.44.0 0.0.0.0 255.255.255.0 U 0 0 0 br0
192.168.44.129 0.0.0.0 255.255.255.255 UH 0 0 0 eth1
如今的流程如下图所示:
没这么简单!!
虽然现在数据包已经到达上层了,并且经由IP路由再发下来给HOST2,那么在发给HOST2之前一定会ARP请求192.168.44.129的MAC地址,HOST2的回应必然要与eth1绑定,即我们需要这么一条ARP:
192.168.44.129 ether 00:0c:29:a9:f3:d3 C eth1
而不是:
192.168.44.129 ether 00:0c:29:a9:f3:d3 C br0
所以说,ebtables针对这个ARP回应的broute规则也是不可少的:
ebtables -t broute -A BROUTING -p ARP --arp-ip-src 192.168.44.129 -j DROP 【注意,这个DROP含义指的是扔出这个网桥】
好吧,ARP打通了,那么还有问题吗?唉...爆炸!有!
现在数据包可以发往192.168.44.129了,然而从192.168.44.129回来的数据包呢?很显然这些数据包的目标MAC就是Box0的eth1的MAC,可是接收这个数据包的是br0,而不是eth1,这会有什么问题呢?
常规的路由器中会有一个叫做反向路由查询的措施,即保证收到数据包的接口和该数据包反向发送的接口是同一个接口,这个措施是为了保证安全,防止伪造数据报文。回到我们的实例,既然收到数据包的接口是br0,而执行反向路由查找的出口设备却是eth1(因为我们静态配置了一条主机路由),这两者并不相符,所以反向路由检查无法通过,故数据包被丢弃。
那么,禁用这个反向路由查找是不是就可以了呢?答案是肯定的!禁用方法很简单:
sysctl -w net.ipv4.conf.br0.rp_filter=0
完美!Perfect!
但是还有另外一种方式,这种方式比更改rp_filter要更常规,这是用更一般的ebtables来配置的,意思是让来自192.168.44.129的数据包全部走IP层:
ebtables -t broute -A BROUTING -p IPv4 --ip-src 192.168.44.129 -j DROP
我们知道,broute可以改变数据包的接收网卡,这也就是说,即便使能了rp_filter也可以通信成功!
...
到此为止,解法一就介绍完了,我不得不感慨,要是用OVS(Open vSwitch)配置这个,该有多么简单啊,只需要流表即可,而使用Bridge的话,不得不动用ebtables,iptables,iproute2,arp等工具(虽然这些都是我最喜欢的...),唉。
最近温州皮鞋厂老板在学习网络虚拟化,那么这个OVS的配置就交给温州皮鞋厂老板了。
解法二:修改br_nf_pre_routing_finish函数!
这里给出一个比较一劳永逸的解法。我觉得如果不在物理网卡配置主机路由的话,那么本文的问题在Linux 4.9版本内核下是不可能通的,这确实不应该啊!所以我决定修改这个局面,措施很简单,修改一处即可,即ip_route_input的dev参数的取值:if ((err = ip_route_input(skb, iph->daddr, iph->saddr, iph->tos, dev))) {改为:
if ((err = ip_route_input(skb, iph->daddr, iph->saddr, iph->tos, bridge_parent(dev)?bridge_parent(dev):dev))) {这样就可以保证,如果第一次进入br_nf_pre_routing_finish之后发现IP DNAT已经发生并且将skb的dev改为了物理网卡,那么通过br_nf_hook_thresh第二次进入br_nf_pre_routing_finish在调用ip_route_input时,仍然使用Bridge作为dev参数(虽然此时skb的dev已经是物理网卡了),这样就可以保证查到正确的路由,接下来的流程自然会进入”将此数据包路由到本地IP层“这个逻辑了。
总之,只要是发生了IP DNAT,那就把数据包路由到IP层,是绝对错不了的。
解法三:在第二次进入br_nf_pre_routing_finish后跳转
第三种解法更加简单,就是在通过br_nf_hook_thresh第二次进入br_nf_pre_routing_finish后,发现如果是第二次进入的,那就直接跳转到最后的br_handle_frame_finish:nf_bridge->in_prerouting = 0; if (br_nf_ipv4_daddr_was_changed(skb, nf_bridge)) { if (bridge_parent(dev) != NULL) goto go; ... go: rt = bridge_parent_rtable(nf_bridge->physindev); if (!rt) { kfree_skb(skb); return 0; } skb_dst_set_noref(skb, &rt->dst); } ... br_nf_hook_thresh(NF_BR_PRE_ROUTING, net, sk, skb, skb->dev, NULL, br_handle_frame_finish); return 0; }这个修改过后,虽然经过了IP DNAT,但是Bridge依然没有放弃数据包的所有权,依然在尝试在二层路由数据包,那么后果可想而知,依然会面临Hairpin的问题(详见3.10版本内核的分析),解决之道当然还是用brctl或者sysfs来设置br0的eth1接口的Hairpin了。
总结一下
不管是3.10的内核还是4.9的内核,虽然问题不同,但是解决问题的思路是一致的,无外乎两种方案,其一是还按照原来的Bridge路径发送IP DNAT后的帧,其二是将IP DNAT后的帧路由到本地IP层。在本文中,可以掌握Bridge的很多知识,包括Hairpin,IP DNAT等。然而本文并没有涉及关于STP这个非常重要的问题。
剩下的,明早再说吧
---以下次日添加:
在本文描述4.9内核解法的解法一里,我提到了broute配置,我估计你不一定了解ebtables的broute和redirect的区别,这里其实又是一个坑,详情请见《ebtables之BROUTING和PREROUTING的redirect的区别》,总之,Linux Bridge的坑实在是太多了,这并非Bug,而实在是仁者见仁智者见智的事情,作者们怎么认为就是怎么实现,毕竟这也是一块没有标准的领域,有谁见过哪个交换机可以配置IP DNAT并且规定如何实施的规范吗?没有这样的规范。Cisco业界领航,它的交换机,路由器里支持什么,那这个东西基本就是标准,至少是它家的标准,影响着整个产业,而且它家的东西又不让你内窥内幕...Linux与之相反,谁都可以看个究竟,但这并不意味着谁都有话语权,Linux开源社区也是个熟人社会,被几家独大的巨头把持的,比如Google要是提交一个Patch,十有八九会被Apply,同样的东西被一个无业游民提交估计200%被Deny...话说Android的定制那么琐碎,不也向主线”贡献“了很多垃圾么?就因为它是Google的。
最后还是描述一下我自己,我再次强调,我编程编的不好,但也不是一点也不会,我稍微会一点,但是我对网络协议栈处理的数据流路径非常熟悉,对网络协议非常熟悉,所以可以快速定位一些网络问题并知道如何快速修正。
另外想说的是,Linux内核从2.4.1到4.10,核心的东西可以说根本就没有变化,目标永远都是一致的,所以不要拿O(n)调度器和CFS的重大差异来说事儿,想彻底了解一个技术,第一步肯定要用起来,然后是熟悉它的工作原理,最后才是看源代码,温州皮鞋厂老板总以为学一项技术看下源码就OK了,这是极其不对的。如果有人给你个发送机,你都不知道这个东西能干嘛,你都没让它转起来,直接就给拆了,这是典型的为了拆解而拆解,就算你看懂了里面有汽缸,有活塞...你也根本猜不出这玩意儿是干嘛用的,更想象不到它动起来的样子。如今分析源码的人太多了,各种简历上写上熟读过XXX源代码,精通YYY源码...就好像特别牛逼,真的牛逼吗?问起来除了能背出几个函数的名字显得很牛逼之外,很多人(我并非说全部)其实根本连它的原理都不懂。Linux内核代码写得还算不错,也比较好读,花点时间读完它也不是不可能的,至少能读完几个子系统的吧,但是更好的做法难道不是先学习操作系统原理或者网络协议吗?
还是扯到我自己。刚毕业那年去参加了吉大的校园招聘会,应聘了中软吉大(年代久远,不再匿名公司,希望能留下足迹)的程序员职位,参加了集体笔试,大概十个人左右的样子,成绩得了第一名。后来在我们所有入职的新员工培训期结束的时候,各位老员工开始一对一带我们进入第一份工作,带我的人是一位比较资深的工程师,也是个领导,这里面可能有我笔试成绩好的原因。在跟他第一次聊的时候,我说我编程编的不好,我怕自己会做不好。他告诉我,编码和对网络协议的理解,你觉得哪个重要呢?我想他心里已经有了答案,只是反问我一下,打消我的自卑罢了...当时脑海中顿时出现了在招聘会上如何获得面试官好感的镜头,我的简历上除了写了Java会一点但不精通之外,其它的全部都是网络方面的:ATM,X.25,帧中继,IPSec,GRE,OSPF,PPP,DDN,交换网络(如今很多都被淘汰了)...然后就获准参加笔试了,可能是因为这份简历比较独特吧。好在笔试题目中编程题不限语言,我还有个Java可以抵挡,其它的题就是奋笔疾书了。再往前回溯,我在想是什么时候学了那么多网络技术的啊。那是在河南农业大学的自习室里看书,在郑州工学院实验室里验证就这么折腾了两年玩会的,后来又参加了华为HCSE的考试并拿了证书,当时想考CCNP或者IE的,可是没那么多钱就算了...那年是2004年,再往前追溯,就说到了首次接触Java的时候,那时我还在哈尔滨,有个周末在席殊书屋闲逛,不知道怎么搞的买了一本Java书,也看不懂,好像现在还留着。不过书在上海,没带来深圳,不然我肯定会拍照贴图的,那年是2002年。