OpenVPN多处理之-为什么一定要做推荐

做技术的,就是有一种较真儿的精神!有点完美主义,更高尚的是,知道什么该做,什么不该做,如果说傻,那也真的傻的可以。今天去了无锡苏州,带小小去太湖景区,然后去苏州看萤火虫。之所以选择今天是因为虚张声势的天气预报吓退了很多人,果然,景区的高级动物特别少,其次,个人感觉会飘一些雨点,不会太热,没有大太阳,刚刚好,果然,哈哈。回到酒店就快10点了,带着电脑出来旅行,夜深人静时可以写点总结,也挺好,总结什么呢?还是OpenVPN了。不过,话说在旅行期间,千万别调试代码,那会坏了好心情。写点总结是最好不过的,因为那只是一些思绪的回顾,自己完全可以把控的住。或者,喝点小酒之后,写篇散文,打油诗?..可惜,我已经没那个雅兴了。 让我从头说起。

1.James Yonan的理由

OpenVPN2.0去除了多线程(其实就是去除了单独处理TLS的线程,OpenVPN一直都没有实现数据通道多线程),理由很充分,详情请搜索maillist。其中很大的原因在于OS底层的复杂性以及接口规格不统一。正如JY所说,即使OpenVPN本身使用了多个线程处理,那么在绝大部分的操作系统下,比如Linux,最终还是要汇集到一个内核线程处理,这是tun驱动的特性决定的。这种让步性质的说辞不仅仅是一种责任推卸。事实上,在tun驱动明确支持了Multiqueue之后,这种说法便不成立了。 另一方面,多个进程/线程中每一个都要维护自己的socket,这个socket是从主线程继承还是分别创建呢?如果继承,那么可能会有惊群问题,如果分别创建,那么便不能bind重复的IP地址和端口。就这样,不管是涉及到tun网卡的管理方面,还是涉及到socket的负载均衡方面,当前的操作系统内核都不给力,鉴于实现的复杂性以及稳定性考虑,JY明确指出,不提供多处理版本的OpenVPN支持。 最终,我总结下来,JY不提供多处理OpenVPN支持的理由:1.由于OpenVPN需要使用诸多的操作系统底层的机制,内核不给力,各个操作系统平台实现并非整齐划一,用户态适配复杂性太高且各个平台实现高度不一致;2.保持紧凑的OpenVPN单进程实现,用户在外部实现多处理会更好,并且他真实给出了random remote机制。

2.我的理由

如果JY真的是那么想的,正如其在maillist所说,那么我会说,至少对于Linux,那些限制已经不存在了。为什么呢?自从内核3.9版本开始,支持了SO_REUSEPORT这个socket选项,支持了Multiqueue TUN驱动,完美的解决了JY的困惑。 是时候让OpenVPN支持多处理了。然而对于JY而言,他的想法的关注点可能与我的并非一致,我们考虑的并不是一个问题,他需要给出的是一个跨平台的OpenVPN源代码,不仅仅针对Linux,还有Windows,BSD族,Mac OS甚至IOS…敢问所有的操作系统都支持SO_REUSEPORT以及Multiqueue TUN么?我可以肯定的是BSD支持了REUSEPORT,但是对于别的,估计够呛。难道我不能使用预编译宏来解决这个问题吗?比如#ifdef LINUX之类的…我可以,但是这会让本已丑陋的代码更丑陋。 我可以坦言,自从2010年以来,我关注OpenVPN已经4年有余,期间得到了公司的支持,社区的支持,论坛的支持,以及无数个下雨的夜晚独自琢磨…使用了一切可以利用的外围技术来解决OpenVPN多处理的问题,其自身的多处理,而不是依靠外部技术实现的多处理!最终,在最近我老婆载我去苏州旅游的时候,我趁着在酒店避风雨的间隙,实现了一个原型。 我无意攻击老一辈的JY,我只是按自己的方式做了我应该做的,代码将会以别人的名义提交到github,我只针对Linux,对于别的系统,我用预编译宏绕开。我只是希望,对于OpenVPN这么好的一个东西,让它在多核心处理器上发挥更大的功用,而不仅仅作为一个应急用的单用户接入程序来使用。有人用它来脸书,来you土逼,发现性能不好,那么就会有另一种声音:MD,能接入就不错了,还管什么性能!!我对这种声音嗤之以鼻!难道就不能做的更好些吗? 更好的是,OpenVPN改进并不是以增加其复杂性为代价的,它的复杂性一点都没有增加,仅仅是利用了内核提供的新特性而已。这只是万事俱备,只欠东风的等待过程,过程中也曾跃跃欲试,现在,东风来了。

3.我的思绪图

我不会引入专业的思维导图工具以展示自己的思考过程,而只是使用类似一种不标准的流程图的方式来说明。从2011年底至今,我完成了多个OpenVPN多处理的框架,很多感兴趣的朋友也经过了实际测试,发现效果良好(讽刺的是,我自己没有测试过!),日前,我决定实现一个最少利用外部机制的一个OpenVPN多处理框架。有人会问,那么之前的成果不都白费了么?不!我想说的是事情是一步步做的,没有前面的成果,就不会明白它的缺陷,就不会更进一步思考。因此,自从2010年/2011年开始,一直到现在,我的思路是清晰的。思绪图如下:

4.我的路子没有跑偏

目前的Linux3.10+版本的内核基本上基本上实现了我希望它实现的,然而在此之前,我自己也曾经独立实现过这些最终被并入主干的特性,比如“多个OpenVPN进程使用一个TUN网卡”,比如IP/端口的负载均衡。可悲的是,我没有及时关注诸如goolge之类提交的patch,以至于我重新发明了不能滚动的轮子,更可悲的是,我缺乏提交源代码的动力,技术与途径,如果拥有此动力,技术与途径,可能OpenVPN的Changelog上将会出现我的名字了… 不管怎样,路子没有跑偏。

5.影响OpenVPN网络多处理的新特性与影响

是时候来展示一下东风了。

5.1.SO_REUSEPORT

这个特性最初是google的一个patch,最终被合并在了主干版本。可以说这是一个创举。在以往的编程模型中,一般都是在主线程创建listen socket,然后要么直接accept,每来一个连接fork/pthread出一个进程/线程,要么预先fork/pthread一系列的工作进程/线程,同时accept。不管是哪一种,一次只能接受一个连接(注意,对于多进程/线程同时accept一个socket而言,为了避免惊群,Linux内核采用WQ_FLAG_EXCLUSIVE方式唤醒,每次只能唤醒一个进程/线程),这对于当前的多核心处理器或者超多核处理器而言是极其不友好的,为了实现同一个服务的多处理,你不得不选择侦听不同的端口或者IP地址。事实上,系统完全有能力同一时间处理到达同一IP地址/端口的多个连接,现实当中,对于大并发环境而言,多个访问请求同时到达并不是稀罕事。 由于TCP本身就是有连接的,所以说针对TCP的ESTABLISH状态的socket管理和REUSEPORT无关,只是针对TCP的listen套接字(三次握手在listen套接字上进行,此时状态还未建立)以及UDP套接字REUSEPORT才有意义。 SO_REUSEPORT这个socket选项将同时处理多个到达同一IP/端口的能力发挥了出来。先看看LWN上的一篇文章是怎么说的:SO_REUSEPORT can be used with both TCP and UDP sockets. With TCP sockets, it allows multiple listening sockets—normally each in a different thread—to be bound to the same port. Each thread can then accept incoming connections on the port by calling accept(). This presents an alternative to the traditional approaches used by multithreaded servers that accept incoming connections on a single socket. 这篇文章是我事后找到的,它的分析和我前面的分析惊人得一致:The first of the traditional approaches is to have a single listener thread that accepts all incoming connections and then passes these off to other threads for processing. The problem with this approach is that the listening thread can become a bottleneck in extreme cases. In early discussions on SO_REUSEPORT, Tom noted that he was dealing with applications that accepted 40,000 connections per second. Given that sort of number, it’s unsurprising to learn that Tom works at Google. —-要么直接accept,每来一个连接fork/pthread出一个进程/线程The second of the traditional approaches used by multithreaded servers operating on a single port is to have all of the threads (or processes) perform an accept() call on a single listening socket in a simple event loop of the form: while (1) { new_fd = accept(…); process_connection(new_fd); } —-要么预先fork/pthread一系列的工作进程/线程,同时accept

The problem with this technique, as Tom pointed out, is that when multiple threads are waiting in the accept() call, wake-ups are not fair, so that, under high load, incoming connections may be distributed across threads in a very unbalanced fashion. At Google, they have seen a factor-of-three difference between the thread accepting the most connections and the thread accepting the fewest connections; that sort of imbalance can lead to underutilization of CPU cores. By contrast, the SO_REUSEPORT implementation distributes connections evenly across all of the threads (or processes) that are blocked in accept() on the same port.

这说明,我的思路是没有错的。另外,这篇文章中论述了Linux 3.9中对于SO_EEUSEPORT实现的问题,和我之前的分析也是一致的:

The firstof these is a useful aspect of the implementation. Incoming connections anddatagrams are distributed to the server sockets using a hash based on the4-tuple of the connection—that is, the peer IP address and port plusthe local IP address and port. This means, for example, that if a clientuses the same socket to send a series of datagrams to the server port, thenthose datagrams will all be directed to the same receiving server (as longas it continues to exist —-注意,如果进程挂掉或者重新启动了,将会导致bind同一IP地址/端口的所有socket的位置相对变化或者说重新排序,就无法保证之前的流量被路由到同一个socket). This eases the task of conducting statefulconversations between the client and server.—-记得我曾经说过,由于是按照源IP,源端口,目标IP,目标端口来做hash的,且没有任何随机因素,只要源IP,源端口保持不变,就一定能保证同一个进程/线程处理同一个4元组流(只要期间没有任何进程重启或者挂掉,也没有任何进程/线程新添加进来)。The other noteworthy point is that there is a defect in the current implementation of TCP SO_REUSEPORT. If the number of listening sockets bound to a port changes because new servers are started or existing servers terminate, it is possible that incoming connections can be dropped during the three-way handshake. The problem is that connection requests are tied to a specific listening socket when the initial SYN packet is received during the handshake. If the number of servers bound to the port changes, then the SO_REUSEPORT logic might not route the final ACK of the handshake to the correct listening socket. In this case, the client connection will be reset, and the server is left with an orphaned request structure. A solution to the problem is still being worked on, and may consist of implementing a connection request table that can be shared among multiple listening sockets. —-本质上,这个问题和上面提到的一样,由于socket数量的改变,hash算法的结果socket可能会改变,如果socket数量改变时,恰有TCP三次握手发生,这会影响基于状态的TCP三次握手过程,比如处理SYN的listen socket是s1,而之后的ACK却被路由到了s2…以上就是对LWN上一篇文章的原文节选以及论述,看样子,REUSEPORT机制是为无状态的socket引入了一种朴素的状态机制,该状态表现为:在总的套接字数量不变的前提下,一个数据流的数据包将始终被路由到同一个套接字。这一点是非常可利用的。 当然,只要有一种声音,就会有另一种声音,此所谓众口难调。即使没有另一种声音,引入REUSEPORT也是有代价的。下面的说法就是一个引入REUSEPORT后带来的问题:If you used this technique on multiple threads accepting on the same traditional socket, you would be fixing one thing and breaking another. Today, if a thread is blocked in accept() and no other thread is, and a connection request arrives, the thread gets it. It sounds like with a SO_REUSEPORT socket, the connection request would wait until its predetermined accepter is ready to take it.

5.2.Multiqueue tun驱动版本

这个特性是很直接的,但凡一个懂网络的,都不会针对一个单一的逻辑生成多块虚拟网卡,否则让策略路由情何以堪。多块虚拟网卡最终将面临数据包如何路由到设备的复杂问题,而要解决此问题,你就需要没完没了地对数据包进行分类,诚然,有很多的包分类框架,然而为何不在虚拟网卡内部实现这些呢?? 高端网卡比如Intel 825XX系列为我们提供了思路。这些网卡内部实现了多队列,可以将进入或者将要发出的数据包分类到不同的队列中,注意,这个队列处理是在驱动本身完成的。这个思路让我实现了多个OpenVPN实例共享一块TUN网卡的驱动。最终,Linux的主干推出了一个更好的实现,即直接实现TUN的多队列! 如上面的思绪图所示,由于反向数据流数据包的路由问题,我曾经修改TUN驱动为广播模式,然而为何要改它呢?人家底层已经实现了如此好的模型,你OpenVPN总不能总是坐享其成一点不改吧,于是我修改了OpenVPN! Multiqueue TUN的代码比较简单,也没有什么文档性描述,究其实现,看代码就好。顺便说一句,PHP是最好的语言。 性能都是最后要考虑的因素

5.3.SO_REUSEPOT和Multiqueue TUN的影响

首先要说的是,REUSEPORT虽然增加了查找套接字时的一些hash计算,但并没有增加什么查找开销,因为REUSEPORT只针对UDP套接字以及TCP的Listen套接字,而这两类套接字本身就不会太多,对于TCP而言,查找套接字的大量开销都在查找ESTABLISH套接字(如果最大连接数指标超级高的话)以及TIME-WAIT套接字(如果大量短连接并主动断开的话),良好的设计中,真正的Listen套接字数量不会超过CPU核心数量太多;对于UDP而言,由于它本身无连接无状态,它查找的不是4元组连接,而仅仅是一个与目标IP和端口绑定的套接字,这类套接字的数量也不会太多,设计良好的UDP服务套接字数量不会超过CPU核心数量太多。因此REUSEPORT套接字的查找几乎不会引入开销。 另外,SO_REUSEPORT机制使用的算法是没有随机因素的4元组hash计算,之后将结果映射到一个特定的套接字,因此只要4元组不变,套接字永远都是那一个,而4元组标识了一个流,因此SO_REUSEPORT确定的套接字本身就拥有朴素的流特征。我们再看Multiqueue TUN驱动,同样的,一个queue对应一个OpenVPN进程,而queue的确定也是通过4元组做hash运算的结果,因此也能保证同一个流被绑定到特定那一个queue,最终交给特定的那一个OpenVPN进程,这就是说,多线程版本的OpenVPN中对multi_instance hash表的锁开销也很低(只对hash冲突链表加锁),毕竟对一个特定的multi_instance的增删改基本上都是特定的OpenVPN线程干的,而这正是SO_REUSEPORT机制hash运算以及TUN的select queue算法决定的。只有在同一个multi_instance由不同的OpenVPN线程处理的时候,锁才是真正必要的,而这出现在以下的情况:时间点1:一个数据包N在线程1被解密后写入到了TUN字符设备,绑定到了队列1,更新flow entry;时间点2:长时间数据包N的返回包没有到来,导致flow entry过期被删除;时间点3:数据包N的返回包到来,由于没有找到flow entry,于是按照别的算法被分派到了队列2;时间点4:队列2和OpenVPN线程2对应,线程2从字符设备读取数据包;时间点5:线程2在multi_instance虚拟地址hash表中查找multi_instance,同时在线程1中该multi_instance对应的客户端断开,multi_instance被删除,此时需要一个锁。鉴于此,我修改了锁方案,不再对hash冲突链表加锁,虽然冲突链表的粒度已经足够细,但是还是有点粗,为何不对multi_instance本身加锁呢?对,就是这个思想。事实上,对于删除multi_instance这种操作,根本不需要锁,用原子操作的引用计数就够了,而对于更新multi_instance中的字段,我在multi_instance结构体本身加了一把锁,由于SO_REUSEPORT以及Multiqueueselect的特殊hash算法,针对每一个OpenVPN客户端的multi_instance,几乎总是同一个OpenVPN线程处理,而同一个OpenVPN线程的执行流是串行的,所以这把锁的使用场合并不多。

6.关于三项工作代码

正如思绪图所示,最终的工作归结为了三项任务。虽然2.6的内核不支持Multiqueue TUN,也不支持SO_REUSEPORT,但是可以从3.9内核上移植它们过来。

6.1.移植TUN驱动

从3.96内核将drivers/net/tun.c移植到2.6内核,直接编译肯定是不通过的,因为这两个内核版本的数据结构以及接口有很大的差异。比如hlist_for_each_entry就不同,因此这些都是需要修改的。总的修改并不多,主要体现在一些宏的定义以及一些2.6内核版本中没有的接口的重新封装,比如__skb_get_rxhash。另外,为了简化实现,我并没有采用4元组来做hash,而只是采用了源和目标IP地址,因此,我修改了skb_flow_dissect的实现:

boolskb_flow_dissect(conststructsk_buff*skb,u32*flow)intnhoff=skb_network_offset(skb);__be16proto=skb- protocol;memset(flow,0,2*sizeof(u32));switch(proto){case__constant_htons(ETH_P_IP):{conststructiphdr*iph;structiphdr_iph;iph=skb_header_pointer(skb,nhoff,sizeof(_iph), _iph);if(!iph)returnfalse;flow[0]=iph- saddr;flow[1]=iph- daddr;break;}case__constant_htons(ETH_P_IPV6):{//TODOreturnfalse;break;}default:returnfalse;}returntrue;}

其它的没有什么好说的,有点内核开发经验的基本都可以在两个小时内完成移植。

6.2.修改OpenVPN

OpenVPN的多线程版本修改在我之前的文章《OpenVPN多处理之-多队列TUN多线程》中已经有所提及,这里主要给出一些兼容性考虑的问题。如果你还没有移植SO_REUSEPORT机制到2.6内核,OpenVPN能不能用呢?问题是如此一来,多个OpenVPN线程不得不bind不同的端口了,怎么来解决这个问题呢?为此,我修改了socket.c中的resolve_bind_local函数:

staticvoidresolve_bind_local(structlink_socket*sock)structgc_arenagc=gc_new();/*resolvelocaladdressifundefined*/if(!addr_defined( sock- info.lsa- local)){sock- info.lsa- local.sa.sin_family=AF_INET;sock- info.lsa- local.sa.sin_addr.s_addr=(sock- local_host?getaddr(GETADDR_RESOLVE|GETADDR_WARN_ON_SIGNAL|GETADDR_FATAL,sock- local_host,0,NULL,NULL):htonl(INADDR_ANY));#ifdefREUSEPORT/**如果设置了reuseport,则设置SO_REUSEPORT这个sockopt,如果设置失败*则表示内核目前并不支持它,那么则将递增的端口号作为bind端口号。*/#defineSO_REUSEPORT15if(sock- info.proto!=PROTO_UDPv4){sock- info.lsa- local.sa.sin_port=htons(sock- local_port);}else{if(sock- sockflags SF_REUSE_PORT){intoptval=1;sock- info.lsa- local.sa.sin_port=htons(sock- local_port);if(!setsockopt(sock- sd,SOL_SOCKET,SO_REUSEADDR, optval,sizeof(optval))){gotonormal;}if(!setsockopt(sock- sd,SOL_SOCKET,SO_REUSEPORT, optval,sizeof(optval))){gotonormal;}}else{staticintlport=0;staticintinitial=0;if(initial==0){lport=sock- local_port;initial=1;}normal:sock- info.lsa- local.sa.sin_port=htons(lport);lport++;}}#elsesock- info.lsa- local.sa.sin_port=htons(sock- local_port);#endif/*REUSEPORT*/}/*bindtolocaladdress/port*/if(sock- bind_local){socket_bind(sock- sd, sock- info.lsa- local,"TCP/UDP");}gc_free( gc);}

如果你连Multiqueue TUN也没有移植,那么正如JY所言,OpenVPN改成多线程是没有多大意义的。所以说,请尽量让内核先支持掉Multiqueue TUN以及SO_REUSEPORT。

6.3.移植SO_REUSEPORT

这个修改是最容易的了,仅仅以udp的REUSEPORT套接字查找为例,现如今的2.6内核的逻辑如下所示:

begin:result=NULL;badness=-1;sk_nulls_for_each_rcu(sk,node, hslot- head){score=compute_score(sk,net,saddr,hnum,sport,daddr,dport,dif);//简单的一次冒泡if(score badness){result=sk;badness=score;}}/**ifthenullsvaluewegotattheendofthislookupis*nottheexpectedone,wemustrestartlookup.*Weprobablymetanitemthatwasmovedtoanotherchain.*/if(get_nulls_value(node)!=hash)gotobegin;

就是一次简单的冒泡过程。在REUSEPORT机制下,它最终只会找到第一个合适的sk,因为对于后面找到的一模一样的sk,if (score badness)这个判断将不会通过,现在修改的思路是,加一个else if (score == badness reuseport)逻辑,这意味着针对相同的sk,也要处理。那么怎么处理呢?这就要引入关于源IP和源端口的hash了:

begin:result=NULL;badness=-1;sk_nulls_for_each_rcu(sk,node, hslot- head){score=compute_score(sk,net,saddr,hnum,sport,daddr,dport,dif);if(score badness){result=sk;//增加一个reuseport标志,用来指示该sk是否可以reuseportreuseport=sk- sk_reuseport;if(reuseport){//根据4元组计算一个hash值hash=inet_ehashfn(net,daddr,hnum,saddr,htons(sport));matches=1;}badness=score;}elseif(score==badness reuseport){//matches++意味着又多找到了一个匹配的skmatches++;//是否由该sk替换上次匹配到的sk,就看hash值的影响了if(((u64)hash*matches) 32==0)result=sk;//为了更好的散列,这里对已经有的hash值做了一个变换hash=hash*1664525+1013904223;}}/**ifthenullsvaluewegotattheendofthislookupis*nottheexpectedone,wemustrestartlookup.*Weprobablymetanitemthatwasmovedtoanotherchain.*/if(get_nulls_value(node)!=hash)gotobegin;

注意,如果你看3.9以上的内核源码,会发现__udp4_lib_lookup非常复杂,实际上那些复杂的部分都不是重点,而是优化。比如在if (hslot- count 10)的情况下,意味着链表很长,于是就希望用一种旨在减少链表长度的算法进行优化。hslot最初是仅仅通过端口算出来的,因为引入目标IP地址的话,会加大计算量,如果此时冲突链表过长,遍历它同样会加大计算量,此时再次算出一个hslot,这次引入了目标IP地址,看看计算结果是否能减少冲突链表的长度,如果确实减少了,那么就在该链表上遍历,如果没有减少,或者反而增加了,则进入正常遍历流程。最坏的情况,那就是hslot冲突链表长度大于10,然后引入目标IP地址重新计算hslot,链表反而增加了,于是进入正常流程,还不如直接就开始正常流程呢?什么是正常流程呢?就是我上面贴出的那段。看来优化是有一点点冒险意味在里面的。

7.接下来干什么

还用问吗?写了三个多小时,手都麻了,现在正是午夜时分。知道在哪里最恐怖吗?绝不是什么墓地之类的,也并非医院,而是酒店,在酒店一个人看恐怖片绝对是一种享受!窗外漆黑一片,你又不知隔壁住的是什么(注意,我没有加‘人’字,也许我应该用being这个词),拉开房门,我只看到一扇扇紧闭的和我的一样的房门,然而并不是开着的,什么声音都没有。走廊里昏暗的灯光预示着将要到来的being真的会到来,并且不需要太强烈的照明。嘀嗒,嘀嗒,近了… 如果我说只有我一个人,那么其实也没什么,因为我最不怕的就是一个人。关键是,关键是…我们今天一共两个家庭一起出游,女人和孩子们去了另一个房间,我知道我要写些东西,所以嘛就提出了这个点子,其实嘛,很多人都猜到我是因为懒,不想半夜看孩子…实际上,我并不是这么想的…同来的另一个家庭的男人和我同住,这是肯定的啊。这个人长期不在家,长期在外地,我从来都没有见他有过笑脸,他女人也从来不说他的事。就是这么一个人,和我同住。我在走廊里透风的时候,他在洗澡,据他女人说他不爱空调,也从不喜欢开窗,所以我只能在走廊透风,当我走进房间的时候,厕所里面的灯并没有开,他却依然在洗澡,哗啦啦的淋浴水声掩盖了外面的所有声响,我发现门卡在取点槽里松动了,于是我重新插拔了一下,等凉了,他依然在洗,没有听到他说什么…理由嘛,简单,第一,他女人说过,他不爱说话;第二,我耳朵背… 我等他出来,我进去洗,他躺下就睡了。我去洗了,灯一直亮着,我进去洗澡的时间是01:00,而我在0:00的时候完成了此文。知道我是什么时候回到酒店的吗?是9:40多,写完三个多小时,正是我进去洗澡的时间。我不想说什么了。我只是觉得,最恐怖是主角其实不是那个你同睡但不熟悉的人,其实是我自己…于是我接下来就独自看了一部电影《捉迷藏》,约翰.普尔森地作品。 同时,我觉得,时刻看看他在做什么,或者想做什么,即使在梦里。其实,他也是这么想的,我想,他一定可以让事情变得更真实,因为真实的东西,便不恐怖了…我的后背有点凉,你呢?

我的眼泪流了下来,浇灌了下面柔软的小草,

OpenVPN多处理之-为什么一定要做推荐

相关文章:

你感兴趣的文章:

标签云: