OpenVPN多实例优化的思考过程

来源:互联网 发布:越南古代服饰淘宝 编辑:程序博客网 时间:2024/05/21 17:26

1.sss

当构建组件之间的关系已经错综复杂到接近于一张完全图的时候,就要换一个思路了,或者你需要重构整个系统,或者你将重新实现一个。

2.TAP网卡和TUN网卡

2.1.TAP的优势

1.方便组网

你可以把所有的OpenVPN节点,包括服务端和客户端看作是一台巨大的三层交换机,所有的TAP虚拟网卡组成一个虚拟的内部以太网,如果在某个节点,你将物理网卡和TAP网卡Bridge在了一起,那么针对该物理网卡连接的网段,执行二层转发,如果没有进行这种Bridge,则执行三层转发。在下图模式下,只要你在每台设备上开启了ARP Proxy,并且所有的TAP网卡和物理网卡都Bridge在了一起,整个网络就全通了,无疑这是一种极端技巧性的方式:




此剂猛药的最大效果就是,缩短了Net1/2/3之间的距离!本来它们之间可能通过N多跳才能到达,结果呢,现在通过TAP模式的OpenVPN将它们连接在了一起,在不存在Br0的情况下就好像Net1/2/3中间只间隔一个三层设备,在存在Br0的情况下,更加猛,就好像你在一个以太网内叠加了多个不同的IP网段,想通吗?简单,如果所有的机器上都配置一条force onlink的路由的话,一切都不在话下了。
       就此打住,点到为止。

2.管理方便

使用TAP网卡,你就像在管理一个局域网,以太网太方便管理了,有很多现成的工具和方案。

2.2.TAP的问题

1.Android的问题

Android明确说不支持TAP模式的网卡,难道是怕广播问题?不得而知,起码现在不支持。这就限制了TAP模式的OpenVPN在Android终端的使用,不过我已经有办法了,那就是在Android终端做一个TUN到TAP的适配器,前面的文章有谈及。

2.广播问题

你想搞掉整个局域网怎么做,那就是抢占别人的IP然后发送免费ARP咯,抢IP的技术太多了,在TAP网卡群组成的虚拟以太网中,你也可以这么做,事实上你还知道,OpenVPN服务端的IP地址是本段的第一个。搞瘫网络的第二个方法就是造就环路然后发广播,广播,广播,广播...

2.3.TUN的优势和问题

1.TUN的优势

一直以来我理解的TUN模式的OpenVPN优势比较不太明显,它封装的数据比TAP模式封装的数据少一个以太头,它采用点到点模式,无需链路层地址解析,事实上,没有链路层的协议只能采用点到点的模式。

2.TUN的问题

TUN模式的OpenVPN组网比较复杂,不太适合网到网之间的连通。因为TUN模式的OpenVPN在服务端是认证IP地址而不是MAC地址,而网到网之间的通信也是IP通信,因此要完成TUN模式的网到网通信,必须要服务端认识IP数据报的源IP地址才行,要做到这一点,就必须配置复杂的iroute,即内部路由。
       虽然使用TUN网卡再加点脑汁也能实现超猛的组网逻辑,但是不像TAP网卡那么直观。在OpenVPN中使用TUN还有一个问题,那就是一个VPN节点会占掉两个IP地址,而这将限制server模式下接入客户端的数量。

2.4.先入为主的观念

一直以来,我对TAP有一种偏好,因此就想在所有的场景中都使用TAP,即便Android等系统明确不支持TAP,宁可在Android上适配一个以太层也不使用TUN。步入正文前我要先扯一段历史观,也算是近期的一些读后感吧。先入为主这种观念也许是必然而然的,也是所有文明发展的宿命,即过于早熟的东西最容易被超越。我在高坂正尧的《文明衰亡论》中总结出了一个结论,那就是发达的文明(在本文中等价于观念)必然衰亡。原因是这样的。
       文明的早期都是纯洁的,平等的,在共同的努力和纪律中奋发的,只要这样,才会进步,才会高尚。以上即内因,推及外因,也必含有利因素,你处在发展中而远非发达,故而旁人不会盯着你,不会和你过不去,你只需学习它们的长处,而丝毫不会被它们的短处影响。然则一旦发达壮大,事情将有质的变化,过量的财富引发分配问题,引发不均,引发权力不等,思想转攻而守,守何?既得利益也!发达状态好似站在高压水龙头上头冲浪,一切因素互为因果,只要一处崩坏或者做非善意的变化,所有的一切顷刻崩塌,即便内因不变,外部环境的短处逐渐牵扯进来也会造成崩塌,美国在发展时期中国市场的变化对它没有影响,可是现在,它却需要时刻关注中国这个巨大的市场。此也正如《安娜.卡列尼娜》最开始的那句”幸福的婚姻都是想似的,不幸的婚姻各有各的不幸“所一致。
       因此,发达等于僵化,也或者趋于僵化,只因为没法变化,只要旁人稍作努力便会超越之。罗马的崩塌,威尼斯的衰败,中国的早熟,皆如此。
       所以,千万不要让一个观念在你的脑子里呆得太久。我最开始用TAP模式的OpenVPN加以Bridge以及DNAT完成了OpenVPN服务端多实例,运行良好,所以往后只要涉及多实例就会想到它,毕竟付出的不是一个雨夜,成就的也不止百行代码,节省的更是数百的人日。如今遇到了各种的问题,我不会想是不是TAP模式需要变化了一下了,而是把所有的问题归结为如何使其向TAP模式适配!事实上,只要采用TUN模式,大部分的问题都将不是问题了。

3.OpenVPN多实例的困境

OpenVPN不支持多实例意味着如果你选择其运行在多处理器的设备上,将是一种巨额投资的浪费,因此OpenVPN在高大上服务器上一直不被看好。这不是OpenVPN社区的错,这是自己的错。但是困境在于你如何着手去做这件事。我为此事困惑了3年,终究没有修成正果,然而收获总是有的,一有想法我就会分享在博客,非工作QQ空间,论坛甚至非工作的微信朋友圈,得到了不少的批评和意见,总之在带给别人思路的同时,自己也在成长,这样的过程还将继续,前路还很漫长...
       从我最开始接触OpenVPN,一直到现在,OpenVPN始终没有发展成一个巨型的像Apache那样的存在(being),而始终是一个功能单一的VPN隧道建立者(actor)。但是这并不意味着你只能用它来构建功能单一的VPN隧道,只是说一切都必须你自己来做。

3.1.基于网桥和DNAT的TAP多实例

3.1.1.借用iptables的random DNAT

Linux的NAT是工作在ip conntrack的基础之上的,这就意味着,只会针对一个数据流的第一个数据包进行NAT匹配动作,最终确定NAT的结果,然后将该结果保存在conntrack结构体中(其实就是很简单的在conntrack被condirm之前修改了reply方向的tuple而已,这是一项创举)。这个流程的效果就是一个数据流的每一个数据包的转换规则都是相同的,即Linux的iptables配置的NAT是针对数据流的。
       鉴于上述的Linux NAT特征,如果我配置一个random的DNAT,就能起到将到达同一端口的数据流分发到不同端口的目标:
iptables -t nat -A PREROUTING .... -j DNAT --random --to-destination $local:12345-12355
借用这个特性,不需要开发任何模块就能实现在OpenVPN服务端多实例之间的负载均衡。然而问题在于你如何去维护具体的映射和实际的OpenVPN实例之间的关系,这是一点典型的80/20问题,80%的框架性的问题有了解决方案,可是剩下的这20%的iptables规则与OpenVPN实例之间的关系维护方面却可以把整个系统搞成一团乱麻。
       虽然iptables可以将在一个连续的端口群中选择一个,但是第一,这个选择只能是随机的,不能有其它的调度策略,第二,你怎么保证它选择的那个端口一定有进程与之bind,要解决这第二点问题,用户态的monitor服务将会非常复杂,第一个问题我认为不修改内核模块是无法解决的。
       不管怎么,用是可以用的,但这绝不是一个产品级的解决方案。

3.1.2.借用以太网广播的bridge

接下来看如何管理多个OpenVPN实例产生的多个TAP网卡的问题。我的目标是让必须通过OpenVPN加密发送的流量可以被路由到正确的TAP网卡中。显而易见,每一个通过OpenVPN传输的数据流都唯一得和一个OpenVPN服务端实例关联,进而唯一得和某一个TAP网卡关联,问题在于,通过何种机制可以让数据包在多个TAP虚拟网卡选出正确的那个。
       将不同的OpenVPN实例关联的TAP网卡划分到不同的子网是一种方案,但是使用TAP的优势之一不就是可以营造一个虚拟的以太网而受益吗?因此我希望将所有的TAP网卡Bridge成一块虚拟的网桥。创意在此涌现。实际上,根本就不用做任何工作,只要将所有的TAP网卡Bridge起来,让Bridge接管所有的TAP网卡本来的那相同的IP地址,同时清除被Bridge的TAP网卡的IP地址,此时,每一个TAP网卡就退化成了整个Bridge的一个端口,针对特定下一跳的ARP回应和Bridge的端口学习机制就会自动地学习到哪个目标地址该发往哪个TAP网卡。
       但是你不觉得这完全是捡来的便宜吗?只要有任何一个条件不满足,TAP网卡就不能这么玩。不管怎么,用是可以用的,但这绝不是一个产品级的解决方案。

3.1.3.拼凑出来的巧合

没有做任何的开发工作就既能满足在bind多端口的多个OpenVPN实例间负载均衡,又可以有效管理TAP网卡和OpenVPN实例之间的关系,这绝对是拼凑出来的巧合,正是这个巧合把我拽进了TAP的深渊而不可自拔,不过毫不自夸的说,这也是一种能力,可以抄起身边能找到的任何家伙就上,知道拿起什么工具能做什么事。不可否认,想到这两个借用可以印证我简历上曾经的那两个精通:精通Netfilter/iptables,精通Linux网络。不过我使用那份简历的时候,可能还真的不是很懂细节,但是绝对知道怎么使用这些玩意儿,不过时刻了解自己的局限,并努力弥补,善莫大焉。后来我就慢慢地学习细节了。要说明的是,深入细节前你必须会用它,否则就会迷失于细节。
       同时,重要的不是你懂什么以及感悟到了什么,而是你能用这些完成什么事情,一开始甚至你都可以不懂细节,但是你得知道如何组装元素,这就是人和其它高等动物的区别。黑猩猩懂得使用木棍,但它们不会用木棍去逮狼...事实上,人类几千年的文明都是建立在不懂细节的组装之上的。人类数万年前就会用火了,但是火的本质百年前才被揭晓。

3.2.侦听多端口的外部调度多实例

(略)

4.豁然开朗的TUN多实例

曾经,我为TUN模式的OpenVPN设计了一个多实例模型,即将多个实例产生的TUN网卡做成一个Bonding网卡,然后将Bonding网卡配置成Broadcast模式,这就是说每一个数据包都会往所有的TUN网卡上复制一份,那岂不是做了很多无用功?非也!我的意思是既然无法或者说很难在Bonding层面做到“从哪个TUN进来,那么回包就从哪个TUN网卡返回”,那么就往所有的TUN都广播一份,由TUN网卡本身来决定是自己继续处理呢,还是直接丢弃,为此我想到了TAP模式网卡的filter机制,还修改了TUN驱动,请看《绑定多个TAP网卡与绑定多个TUN网卡-附带TUN/TAP适配》,然而那是中毒太深的缘故,我现在已经放弃了那种方法。本节我将给出新方法从头到尾的思路。
       在给出思路以及方案之前,我首先要肯定的是Broadcast模式的Bonding让TUN网卡自行抉择这个思想的创造性。这个思想非常棒,通过广播,每个工作节点都会得到一份数据,然后由节点自身决定是否要处理,这种方式的负载均衡省去了中心调度节点的开销,避免了中心瓶颈和单点故障,事实上,iptables的CLUSTERIP target就是这种思想的直接体现,细节请manual。

4.1.iptables已经成了一团乱麻

我用iptables完成了太多的东西,如今整个系统中到处充斥着iptables规则,我已经理不清它们之间的关系了。我用iptables实现负载均衡,用它做NAT,甚至是双向静态NAT,我用它来为数据包打不同的mark,以便实施policy routing...总之,它成了类似bash那样的黏合剂。我定义了太多的自定义链,水平却远不如无线路由器厂商。诸多的iptables规则维护起来复杂又低效,牵一发而动全身。就拿我用random DNAT来做负载均衡来讲,我不得不不断monitor所有的进程,哪个挂掉之后还必须侦听同一个端口迅速将其拉起来。iptables规则和OpenVPN进程,monitor进程以及内核之间没有任何接口,完全靠“蛛丝马迹”来互相通信,比如如果你知道Linux的某个藏得很深的特性,你就能做某件事,如果不知道,就做不了。结果就是,整个系统就我一个人能全部搞定,因为系统完全是靠脆弱的技巧构建的,即便是我自己,时隔多年再见它的时候,也会一头雾水后拍案惊奇。难道没有文档吗?没有,什么也没有,因为根本没法写,所有的东西都是易变的。
       是时候改变这一切了。iptables的功能在manual中都有,凡是不在其中的,就不要硬用iptables来凑合。诚然,使用iptables技巧性模拟负载均衡可以完成任务,但是那不是常规的做法,真正需要做的是去开发一个模块而不是勉强拼凑一些组件。

4.2.过分的UNIX哲学

最近在看《大教堂与集市》(绝对值得一读,除了怎么写代码,它什么都讲),Raymond非常前卫,极端且谦虚。他一直崇尚小工具,但是觉得一旦系统的复杂性超过一定限度,就要集中控制。我虽然不是在说开发模式,但是同样的讨论也可以用在UNIX哲学上。我也一样,一直都喜欢用小的组件组装复杂的系统。不想开发大的C程序,而更喜欢用C写小功能组件,然后用bash将其组合起来,甚至用iptables将其组合起来,反正只要不用编译的那种所见即所得的就成。
       最终,我虽然不用C编程,然而却陷入了更麻烦的编程过程。事实上,编程的过程就是一个逻辑与流程的整理过程,和所使用的语言半毛钱关系都没有。虽然我避免了使用switch-case,goto,do-while来编程,但是却要使用while-do-done,iptables -N,iptables -F...一切更复杂了。
       我总是觉得用脚本粘合小模块是一件低成本高收益的事,因为功能单一的小模块越多,它们的排列组合越多,可以构建的功能越丰富,重用度越高...可是我忽略了组件间的沟通成本,当组件互连成一个接近完全图的蜘蛛网时,组合小组件相对于编写大程序的优势就不再了,组件之间的关系成了大程序本身!总之,不要用粘合剂实现复杂逻辑,组件之间尽量不要双向依赖!这也许就是bash简单单向管道的妙处吧,这也许就是bash不支持复合数据结构的原因吧。

4.3.观感-组件化与集中化的博弈

到底应该组合功能单一的小组件还是编写一个大模块?这需要深思熟虑!
       对于我要的OpenVPN负载均衡模块,我希望它是专门用于此目的的,对于已有的Linux LVS,它太大了,用于OpenVPN有点喧宾夺主的意味,在此要记住的乃是我做负载均衡的目的仅仅是弥补OpenVPN不支持多处理器的这个缺陷,并不是要做一个通用模块。如前所述,如果用iptables的DNAT实现的话,又太松散,很难集中控制。对此,我决定做一个内核模块来专门实现针对目前OpenVPN的多实例负载均衡!
       方案确定是令人愉悦的,但方案的最终设计却不得不斟酌,我的想法是让数据包绕过Linux标准协议栈实现在传输层的按端口寻socket的过程,如下图所示:




在Linux 3.10+的内核中对于UDP而言我们遇到了福音,因为它天然就支持了reuseport的负载均衡,和我上图一致!但是,我现在还在用2.6.32!
       上图是一个基本的框图,最终我的配置界面如下:
/**
*    proc
*    `-- lb_vpn
*    `-- node_info
*
*    node_info:
*    NAME        PID     PORT    WEIGHT
*    instance1   1234   61195     3  
*    instance2   2234   61197     8  
*    .....
*    up:                 echo +add $name $pid $port
*    client_connect:     echo +$pid
*    client_disconnect:  echo -$pid
*    down:                echo +del $name $pid $port
*/
在proc下面创建一个lb_vpn目录,然后里面有一个node_info的可读写文件,如果你读它,展现出来的就是4个列:进程名,进程ID,进程bind的端口,进程当前的连接数(即目前有多少OpenVPN客户端连接于其上)。如果在启动OpenVPN之前加载内核模块LB_VPN.ko的时候,会生成该目录和文件,如果一个OpenVPN启动,其up脚本中有下面一行:
echo +add $ovpn_name $ovpn_pid $local_port
之后,如果有一个OpenVPN客户端接入,那么在client-connect脚本中,会有如下一行:
echo +$ovpn_pid
这意味着这个OpenVPN实例的负载又多了一个。 对于数据结构,我将每一个OpenVPN实例归到以下的内核数据结构中:
struct lb_node {    struct list_head *list;    struct heap_node *node;    pid_t pid;    __be16 port;    unsigned int weight;};

其中的list是一个线性的链表节点,用于随机取端口,而node则是一个排过序的堆节点,用于寻找weight最小的节点,关键就看采用哪种算法了,对于client-connect中echo到node_info中的那一句,实际上就是递增了对应lb_node的weight值而已。在内核的LB_VPN模块中,维护两个全局结构,一个list_head,一个heap,其中heap按照weight值进行插入。这种双重甚至多重容器的链接在内核中很常见,每一种方式针对特定目的进行优化,比如vm_area_struct中就有两种链接方式:
struct vm_area_struct *vm_next, *vm_prev;   //用于遍历struct rb_node vm_rb;                       //用于查找

4.4.突破NAT的实现

感谢翔叔,是翔叔自己实现了类似LVS的代码,也许是因为翔叔年纪大了,曾经搞过银河计算机的翔叔玩Linux依然威力不减当年。
翔叔的实现实际上是一个NAT,只是他老人家没有使用Netfilter,即没有在HOOK点上进行NAT,而是直接写在了ip_rcv中。这给了我启发。对于多个OpenVPN实例的负载均衡实际上就是为一个连接选择一个OpenVPN实例侦听的端口,当然如果使用Linux 3.10+的内核,已经可以实现针对bind同一IP/Port的UDP socket的random负载均衡,但是对于低版本的内核,由于REUSEPORT名不副实,你还得让不同的OpenVPN实例bind不同的端口。
       具体来讲就是将到达同一OpenVPN端口的数据流负载到不同的目标端口,本质上就是做一个针对destination port的端口转换。我在想在哪里做它会比较好,其实利用DNAT功能修改PREROUTING上的NAT实现会更加省力,但是更进一步,既然已经不准备使用标准的DNAT(那是为iptables精心设计的HOOK点)了,还不如在INPUT这个HOOK上做,这样只针对到达本地的流量去判断是否需要转换。注意,我们要放掉一切关于标准DNAT实现的固定思路,比如只能在路由前做DNAT之类的想法。在哪里都可以做DNAT,不但翔叔做到了,实际上Cisco的做法也和Linux的iptables的不一致,不得不说,PREROUTING上做DNAT,POSTROUTING上做SNAT,这只是为iptables而设计的,如果不用iptables了,那么你就自由实现吧。翔叔提供了思路和部分代码,但是另外一部分代码我准备重用Netfilter的,因此我还是在Netfilter的框架内做HOOK函数。
       但是,我不能使用Netfilter为NAT准备的API,比如nf_nat_packet,nf_nat_setup_info之类的,因为那些API的实现中,明确限制了针对iptables的NAT用法,比如以下这段:
NF_CT_ASSERT(par->hooknum == NF_INET_PRE_ROUTING ||             par->hooknum == NF_INET_LOCAL_OUT);
于是我不得不重新封装这些API,去掉这些FXXXING assert!然而冷静下来就会有更简单的做法,不就是转换一个目标端口嘛,何必这么复杂,自己实现难道不更简单吗?事实上,翔叔的成果可以直接用!在列出HOOK函数之前,看一下端口转换的实现:
int nf_lb_assign_port(struct sk_buff *skb, __be16 port, int dir, __be16 *savedptr){    __be16 *portptr;    __be32 ipaddr;    struct iphdr *iph = (struct iphdr *)(skb->data + 0);    unsigned int hdroff = iph->ihl*4;    if (iph->protocol == IPPROTO_UDP) {        struct udphdr *hdr;        hdr = (struct udphdr *)(skb->data + hdroff);        if (!skb_make_writable(skb, hdroff + sizeof(*hdr))){            return 0;        }        /* 正向包的目标端口转换 */        if (dir == IP_CT_DIR_ORIGINAL) {            portptr = &hdr->dest;            /* 如果不需要转换,则返回 */            if (port == *portptr) {                return 0;            }            ipaddr = iph->daddr;        }        /* 返回包的源端口恢复 */        else {            portptr = &hdr->source;            ipaddr = iph->saddr;        }        if (hdr->check || skb->ip_summed == CHECKSUM_PARTIAL) {            inet_proto_csum_replace4(&hdr->check, skb, ipaddr, ipaddr, 1);            inet_proto_csum_replace2(&hdr->check, skb, *portptr, port, 0);            if (!hdr->check) {                hdr->check = CSUM_MANGLED_0;            }       }    } else if (iph->protocol == IPPROTO_TCP) {        //TODO        return 0;    } else {        return 0;    }    *savedptr = *portptr;    *portptr = port;    return 1;}

事实上,我没有用NAT模块的任何东西,无非就是简单的转换一个端口,转换后重新计算一下校验和即可。把下面的HOOK函数挂在INPUT点的conntrack confirm之前实现来自OpenVPN客户端的正向包的目标端口转换:
static unsigned int socket_balance_in (unsigned int hooknum,                                      struct sk_buff *skb,                                      const struct net_device *in,                                      const struct net_device *out,                                      int (*okfn)(struct sk_buff *)){    struct nf_conn *ct;    enum ip_conntrack_info ctinfo;    struct nf_conn_counter *acct;    struct nf_conn_priv *dst_info;    const struct iphdr *iph = ip_hdr(skb);    __be16 real_port, dummy;    __be16 *portptr;    int dir;    if (iph->protocol != IPPROTO_UDP &&            iph->protocol != IPPROTO_TCP) {        return NF_ACCEPT;    }    ct = nf_ct_get(skb, &ctinfo);    if (!ct || ct == &nf_conntrack_untracked)        return NF_ACCEPT;    acct = nf_conn_acct_find(ct);    if (acct) {        dir = CTINFO2DIR(ctinfo);        if (dir == IP_CT_DIR_REPLY) {            return NF_ACCEPT;        }        dst_info = (struct nf_conn_priv *)acct;        real_port = dst_info->nport;        portptr = &dummy;        /* 仅针对一个流的头包去找一个合适的端口,保存在conntrack中,         * 后续的包直接取出来用,保证同一个流被负载到一个特定的端口         **/        if (ctinfo == IP_CT_NEW) {            unsigned int ok;            /* 仅仅针对特定的端口进行负载均衡分发 */            ok = check_policy(skb);            if (!ok) {                return NF_ACCEPT;            }            /* 找到一个特定的目标端口,保存,并保留原始端口 */            real_port = find_port();            portptr = &(dst_info->oport);            dst_info->nport = real_port;        }        if (real_port == 0) {            return NF_ACCEPT;        }        /* 实施目标端口转换 */        if (!nf_lb_assign_port(skb, real_port, dir, portptr)) {            *portptr = 0;            dst_info->nport = 0;            return NF_ACCEPT;        }        /* 如果转换成功,别忘了同时转换conntrack的tuple */        if (ctinfo == IP_CT_NEW && !nf_ct_is_confirmed(ct)) {            ct->tuplehash[IP_CT_DIR_REPLY].tuple.src.u.udp.port = real_port;        }    }    return NF_ACCEPT;}

以上的代码没有任何创造性,就是按部就班。唯一的创意来自conntrack的tuple管理,你只能在conntrack还是NEW状态(肯定是正向)且还未confirm的时候转换了IP地址或者端口,转换后将反向的tuple更改一下即可,其它的什么都不需要做!把下面的HOOK函数挂在OUTPUT点的conntrack之后实现回到OpenVPN客户端的反向包的源端口恢复:
static unsigned int socket_balance_out (unsigned int hooknum,                                      struct sk_buff *skb,                                      const struct net_device *in,                                      const struct net_device *out,                                      int (*okfn)(struct sk_buff *)){    struct nf_conn *ct;    enum ip_conntrack_info ctinfo;    struct nf_conn_counter *acct;    struct nf_conn_priv *dst_info;    const struct iphdr *iph = ip_hdr(skb);    __be16 real_port, dummy;    int dir;    if (iph->protocol != IPPROTO_UDP &&            iph->protocol != IPPROTO_TCP) {        return NF_ACCEPT;    }    ct = nf_ct_get(skb, &ctinfo);    if (!ct || ct == &nf_conntrack_untracked)        return NF_ACCEPT;    acct = nf_conn_acct_find(ct);    if (acct) {        dir = CTINFO2DIR(ctinfo);        /* 仅针对返回包做端口恢复 */        if (dir == IP_CT_DIR_ORIGINAL) {            return NF_ACCEPT;        }        dst_info = (struct nf_conn_priv *)acct;        /* 取出保存的原始端口 */        real_port = dst_info->oport;        if (real_port == 0) {            return NF_ACCEPT;        }        if (!nf_lb_assign_port(skb, real_port, dir, &dummy)) {            return NF_ACCEPT;        }    }    return NF_ACCEPT;}


4.4.1.直接Assign一个socket

看了tproxy的代码之后,就冒出一个想法:所谓的传输层端口其实就是为了定位socket用的,如果能直接赋予skb一个socket,端口就无所谓了,比如一个UDP数据包的目标端口是1234,这个1234的作用就是为了定位一个UDP socket,那如果我事先用另外一种方式找了一个socket赋予这个数据包,这个1234就没有用了,是不是这样子呢?我们来看一下代码,__udp4_lib_rcv是Linux的UDP接收函数,其中定位socket的那句是:
sk = __udp4_lib_lookup_skb(skb, uh->source, uh->dest, udptable);static inline struct sock *__udp4_lib_lookup_skb(struct sk_buff *skb,                         __be16 sport, __be16 dport,                         struct udp_table *udptable){    struct sock *sk;    const struct iphdr *iph = ip_hdr(skb);    if (unlikely(sk = skb_steal_sock(skb)))        return sk;    else        return __udp4_lib_lookup(dev_net(skb_dst(skb)->dev), iph->saddr, sport,                     iph->daddr, dport, inet_iif(skb),                     udptable);}

看一下skb_steal_sock这一句,它的含义正是,如果skb已经关联了一个sk,那么就直接返回它,否则再去按照UDP的4元组来查找。从这里我们可以看出,定位socket的方式不止按协议4元组查找这一种!那么我们用什么来定位socket呢?答案还是使用__udp4_lib_lookup。我依然启动多个OpenVPN进程bind不同的端口,然后在这几个端口中按照负载均衡算法(随机或者按照当前连接数)选择一个,赋予一个流的头包并保存在conntrack中,不需要转换skb中的端口,直接为skb的sk字段赋值即可!
       我们来看一下这个做法有什么意义,它完全绕过了Linux协议栈的第4层定位逻辑,只需要针对NEW状态的一个流的第一个数据包进行一次负载均衡计算定位一个port,然后进行一次__udp4_lib_lookup查找,之后保存在conntrack结构体中,同一个流的后续的数据包可以直接取用这个socket,完全省去了__udp4_lib_lookup的过程!不过值得注意的是,由于没有针对数据包本身进行任何修改,建议OpenVPN客户端要使用nobind参数随机选取源端口,否则很可能多个连接会被归并到一个conntrack结构体从而总是负载了一个OpenVPN实例中。具体的端口定位逻辑代码如下:
__be16 find_port(){      int i = 0;    static unsigned int inner_index = 0;    struct lb_node *lb = NULL;    struct list_head *l;    index = random32()%curr_count;    read_lock(&lb_list_lock);    list_for_each(l, &lb_list) {        i++;        if (i == index) {            __be16 port;            lb = list_entry(l, struct lb_node, list);            port = lb->port;            //TODO check port            break;        }    }    read_unlock(&lb_list_lock);    return lb->port;}

然后再调用__udp4_lib_lookup即可:
__be16 port = find_port();// 一般不会用到uh->sourcesk = __udp4_lib_lookup_skb(skb, uh->source, port, udptable);skb->sk = sk;

       这么好的办法,为何我不用呢?难道没有翔叔罩着?是啊。但是另外的原因是,维护socket的引用计数是一件很烦人的工作。但是根本的原因是:我并没有说不用它。



VPN隧道建立方面的多实例负载均衡已经解决,下面看一下数据流在TUN虚拟网卡间如何分发。


4.5.TUN网卡不能bridge

当最初得到TAP模式OpenVPN多实例方案的时候,兴奋了一阵子,因为那纯粹是空手套白狼,毕竟什么都不是自己开发的,靠两个完美的借用完成了设计。借用Brdige对ARP的广播以及对ARP回应的端口学习完成了在多个TAP网卡中选择一个的任务,借用random DNAT以及ip_conntrack完成了VPN连接在多个OpenVPN服务端实例上负载均衡。也许正是这两个如此廉价的借用才让我如此痴迷于TAP模式,期待廉价的午餐再次滴落。
       在Android上将TUN适配成TAP并不难,难的是如何以及以什么理由来促成这件事。做产品不是写诗拍电影,有时缺一些创意反而会更好,创意应该付诸设计,而不应付诸实现。换句话说,实现中创意是不好的,创意应该在设计阶段终结。不能被自己的感情因素左右技术实现。因此既然Android不能支持TAP,那么一群Android设备的接入,何必不用TUN模式的OpenVPN服务端呢?不是不能用,而是怕困难难以克服。什么困难呢?TUN网卡不能Bridge,又难以Bonding,因此TUN网卡群就不好像TAP网卡群那样对外呈现出一块网卡了...但是这个问题貌似必须解决。

4.5.1.何必非要展现出一块虚拟网卡

提出一个问题比解决它更重要,引申一点就是,如果提出了一个问题,怎么证明这个问题是有意义的呢?事实上不能证明。在解决某个问题遇到困难的时候,停下来问一下这个问题有没有意义是必要的。TUN模式的网卡群难以合并成一块虚拟的网卡,不管是bridge还是Bonding,即便可以Bonding,还是难以管理,你不得不在OpenVPN的up/down脚本中去ifenslave。那么反问一下,为何非要将所有TUN网卡合并到一个虚拟的网卡呢?到底是什么原因让我非这样做不可呢?
       答案是模糊的,因为根本就没有非如此不可的必要因素。部分原因只是因为我习惯了在TAP模式下时将多块TAP网卡合成一块,而之所以会这么做,根本原因在于TAP网卡是模拟以太网的,而不管是Bridge还是Bonding都是专门针对以太网的。到此为止,一切都明了了,我一直都在死胡同里面,事实上,我一直都妄想将以太网的特性应用在TUN网卡上,以图它能给我带来一些利益。事实上,TUN网卡群完全可以独立呈现在系统中,比如我启动了5个OpenVPN进程,那么TUN网卡就是tun0~tun4一共5块。

4.5.2.多实例多网段

在得到根本没有必要在多个OpenVPN的TUN网卡之间建立任何关联这个让人清爽的事实后,下一步就是划分子网了。如果我规划了130.130.0.0/16这个大网段给所有的m个OpenVPN实例,那么对于每一个OpenVPN,只需要给它划分总容量的1/m大小的网段即可了,还可以根据OpenVPN实例的不同权值给与加权分割子网。如此一来,m个OpenVPN服务端在启动了自己的TUN网卡后,会把自己的子网的网段路由加入到系统路由表,从某个OpenVPN实例过来的IP数据流在返回的时候,可以自动通过路由来寻址到正确的TUN网卡群中的一个,从而经过它来的时候那个OpenVPN实例加密后返回。
       但是还有更猛的方案。

4.5.3.多实例单网段

这并不是一个显而易见的方案,需要一番思考以及对Linux的IP路由以及ip conntrack非常熟悉才能理解。简单讲就是所有的m个OpenVPN实例共享一个IP网段,比如130.130.0.0/16,那个所有的OpenVPN服务端实例的TUN网卡的IP地址均是130.130.0.1,只要在所有的OpenVPN服务端的client-connect脚本中为每一个OpenVPN客户端(不管它连接到了哪个OpenVPN服务端实例)在全局池里面分配一个不重复的IP即可。
       这怎么可能?OpenVPN服务端的所有实例的TUN网卡的IP地址不明显冲突了吗?是的,是冲突了。地址冲突带来的是直连路由的冲突。在以太网上,同一机器的多个网卡地址冲突还可能导致流量的截获或者ARP混乱等。然而,忘掉以太网吧,我们现在面临的是点对点的TUN网卡群,对于TUN网卡,第一,它不需要链路层地址解析,其次,它根本就不需要链路层封装。因此只要保证一个数据流从哪个TUN网卡进来,该数据流的返回流量从哪个TUN网卡出去即可。而从哪个TUN网卡进来是远端的OpenVPN客户端决定的,由此看来,TUN模式下只要能将一个流的正向进入的TUN网卡记录在流本身,返回数据就可以直接取出该TUN网卡调用xmit发送了,幸好它是不需要封装链路层的帧头。
       TUN网卡不需要封装帧头从而可以直接调用dev_queue_xmit发送是很有意思的,真是失之东隅,收之桑榆啊。不得不承认,这个特点又是一次空手套白狼的借用!
       实施起来非常容易,只要你知道如何在ip_conntrack结构体中记录信息即可,而这在我的另一篇文章《如何扩展Linux的ip_conntrack》中被详细描述 
       代码很容易,直接将下面的HOOK函数挂在PREROUTING的conntrack优先级之后即可:
static unsigned int ipv4_conntrack_setdst (unsigned int hooknum,                                      struct sk_buff *skb,                                      const struct net_device *in,                                      const struct net_device *out,                                      int (*okfn)(struct sk_buff *)){    struct nf_conn *ct;    enum ip_conntrack_info ctinfo;    struct nf_conn_counter *acct;    struct nf_conn_priv *dst_info;    ct = nf_ct_get(skb, &ctinfo);    if (!ct || ct == &nf_conntrack_untracked)        return NF_ACCEPT;    acct = nf_conn_acct_find(ct);    if (acct) {        struct net_device *dev;        int dir = CTINFO2DIR(ctinfo);        dst_info = (struct nf_conn_priv *)acct;        /* 仅仅针对NEW状态的数据流头包保存TUN设备到conntrack中 */        if (dir == IP_CT_DIR_ORIGINAL && ctinfo == IP_CT_NEW) {            dev = skb->dev;            /* 仅仅“借用”不需要封装链路层的网卡采用快速转发 */            if (dev &&                   (dev->flags & (IFF_NOARP | IFF_POINTOPOINT))) {                dst_info->dev_out = skb->dev;            }        }        /* 如果是反方向的回包,直接跳过路由查询进行快速转发         * 类似的思想还可用于conntrack保存路由项,直接调用         * dst->output的话即使是需要封装链路层也无所谓         */        else if (dir == IP_CT_DIR_REPLY) {            dev = dst_info->dev_out;            if (dev && dev != skb->dev &&                   (dev->flags & (IFF_NOARP | IFF_POINTOPOINT))) {                return xmit_packet(skb, dev);            }        }    }    return NF_ACCEPT;}

如果也需要针对本机,那就将下面的HOOK函数挂在OUTPUT的conntrack之后:
static unsigned int ipv4_conntrack_setdst_local (unsigned int hooknum,                                      struct sk_buff *skb,                                      const struct net_device *in,                                      const struct net_device *out,                                      int (*okfn)(struct sk_buff *)){    struct nf_conn *ct;    enum ip_conntrack_info ctinfo;    struct nf_conn_counter *acct;    struct nf_conn_priv *dst_info;    ct = nf_ct_get(skb, &ctinfo);    if (!ct || ct == &nf_conntrack_untracked)        return NF_ACCEPT;    acct = nf_conn_acct_find(ct);    if (acct) {        struct net_device *dev;        int dir = CTINFO2DIR(ctinfo);        dst_info = (struct nf_conn_priv *)acct;        if (dir == IP_CT_DIR_ORIGINAL) {            return NF_ACCEPT;        } else if (dir == IP_CT_DIR_REPLY) {            dev = dst_info->dev_out;            if (dev &&                   (dev->flags & (IFF_NOARP | IFF_POINTOPOINT))) {                struct iphdr *iph = (struct iphdr *)(skb->data + 0);                return xmit_packet(skb, dev);            }        }    }    return NF_ACCEPT;}

xmit函数很简单:
static unsigned int xmit_packet(struct sk_buff *skb,                                struct net_device *dev){    if (dev &&        (dev->flags & (IFF_NOARP | IFF_POINTOPOINT))) {        skb->dev = dev;        dev_queue_xmit(skb);        return NF_STOLEN;    }    return NF_ACCEPT;}

4.5.4.优化-连接跟踪记录TUN设备

4.5.5.优化-PF_RING替代TAP/TUN

4.5.6.优化-Direct Path From Intel82599 To OpenVPN

5.Bomb,the boss chair

2 0