OpenVPN多实例优化的思考过程

时间:2023-03-08 21:33:06
OpenVPN多实例优化的思考过程

1.sss

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

2.TAP网卡和TUN网卡

2.1.TAP的优势

1.方便组网

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

OpenVPN多实例优化的思考过程

此剂猛药的最大效果就是,缩短了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模式下接入client的数量。

2.4.先入为主的观念

一直以来,我对TAP有一种偏好,因此就想在全部的场景中都使用TAP,即便Android等系统明白不支持TAP,宁可在Android上适配一个以太层也不使用TUN。步入正文前我要先扯一段历史观,也算是近期的一些读后感吧。先入为主这样的观念或许是必定而然的,也是全部文明发展的宿命,即过于早熟的东西最easy被超越。我在高坂正尧的《文明衰亡论》中总结出了一个结论,那就是发达的文明(在本文中等价于观念)必定衰亡。原因是这样的。
       文明的早期都是清纯的,平等的,在共同的努力和纪律中奋发的,仅仅要这样,才会进步,才会高尚。以上即内因,推及外因,也必含有利因素,你处在发展中而远非发达,故而旁人不会盯着你,不会和你过不去,你仅仅需学习它们的好处,而丝毫不会被它们的短处影响。然则一旦发达壮大,事情将有质的变化,过量的財富引发分配问题,引发不均,引发权力不等,思想转攻而守,守何?既得利益也!发达状态好似站在高压水龙头上头冲浪,一切因素互为因果,仅仅要一处崩坏或者做非善意的变化,全部的一切顷刻崩塌,即便内因不变,外部环境的短处逐渐牵扯进来也会造成崩塌,美国在发展时期中国市场的变化对它没有影响,可是如今,它却须要时刻关注中国这个巨大的市场。此也正如《安娜.卡列尼娜》最開始的那句”幸福的婚姻都是想似的,不幸的婚姻各有各的不幸“所一致。
       因此,发达等于僵化,也或者趋于僵化,仅仅由于没法变化,仅仅要旁人稍作努力便会超越之。罗马的崩塌,威尼斯的衰败,中国的早熟,皆如此。
       所以,千万不要让一个观念在你的脑子里呆得太久。我最開始用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的过程,例如以下图所看到的:

OpenVPN多实例优化的思考过程

在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的端口,进程当前的连接数(即眼下有多少OpenVPNclient连接于其上)。假设在启动OpenVPN之前载入内核模块LB_VPN.ko的时候,会生成该文件夹和文件,假设一个OpenVPN启动,其up脚本中有以下一行:
echo +add $ovpn_name $ovpn_pid $local_port
之后,假设有一个OpenVPNclient接入,那么在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之前实现来自OpenVPNclient的正向包的目标端口转换:

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之后实现回到OpenVPNclient的反向包的源端口恢复:

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的过程!只是值得注意的是,由于没有针对数据包本身进行不论什么改动,建议OpenVPNclient要使用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->source
sk = __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脚本中为每个OpenVPNclient(无论它连接到了哪个OpenVPN服务端实例)在全局池里面分配一个不反复的IP就可以。
       这怎么可能?OpenVPN服务端的全部实例的TUN网卡的IP地址不明显冲突了吗?是的,是冲突了。地址冲突带来的是直连路由的冲突。在以太网上,同一机器的多个网卡地址冲突还可能导致流量的截获或者ARP混乱等。然而,忘掉以太网吧,我们如今面临的是点对点的TUN网卡群,对于TUN网卡,第一,它不须要链路层地址解析,其次,它根本就不须要链路层封装。因此仅仅要保证一个数据流从哪个TUN网卡进来,该数据流的返回流量从哪个TUN网卡出去就可以。而从哪个TUN网卡进来是远端的OpenVPNclient决定的,由此看来,TUN模式下仅仅要能将一个流的正向进入的TUN网卡记录在流本身,返回数据就能够直接取出该TUN网卡调用xmit发送了,幸好它是不须要封装链路层的帧头。
       TUN网卡不须要封装帧头从而能够直接调用dev_queue_xmit发送是非常有意思的,真是失之东隅,收之桑榆啊。不得不承认,这个特点又是一次空手套白狼的借用!
       实施起来非常easy,仅仅要你知道怎样在ip_conntrack结构体中记录信息就可以,而这在我的另一篇文章《怎样扩展Linux的ip_conntrack中被详细描写叙述 
       代码非常easy,直接将以下的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