暨南大学珠海校区校园网 – 802.1x Linux 客户端

暨大珠区有线校园网,部分区域用的是 802.1x 认证。学校网站也没提供珠区 802.1x 的 Linux 客户端。

珠区校园网客户端是 H3C 的 iNode,别的学校已经有大佬实现过这款 Linux 客户端了,可以拿来珠区用。

这里选用了这个:https://github.com/Besfim/inode-njit

1. 编译安装(Debian 11 为例)

  • 安装编译所需的工具和库

  • 下载源码

  • 编译

编译的程序会放到 /usr/local/sbin/njit-client

2. 认证

version 用 EN\x11V7.30-0536,version 的获取,见:https://github.com/bitdust/H3C_toolkit

输出“Server: Success.”字样,说明认证成功:

3. 带宽叠加(可选)

校园网限速上限 500Mbps,不同时段速度不一样,不过账号允许登录多次,可以用 macvlan 在路由器上登多个,叠加带宽。

  • 修改下 arp 策略,只在绑定了对应 IP 的网卡上响应(默认会在任何网卡上响应系统已绑定的 IP 的 arp)

编辑 /etc/sysctl.conf,加入:

然后应用:

  • 创建 macvlan

这里提供一份 /etc/network/interfaces 的配置供大家参考,配置中创建了三个 macvlan,获取三个 IP,默认使用 peth0:

  • 上面用到的 /usr/bin/setup-route-table 的内容,主要把从 DHCP 获取到的网关添加到另一个路由表中

  • 为 peth 配置 SNAT

  • 配置负载均衡

  • 效果

UDP SndbufErrors & ENOBUFS

最近一部分服务器上,遇到UDP发包速率太高会出现大量丢包的情况。这个丢包不是发生在中间网络设备上丢,也不在接收方上,而是发生在发送方kernel中。为什么会知道是在kernel丢的?因为用户空间程序的统计的发包量,跟内核统计的有很大差距,所以可以肯定用户空间把包交给内核后,内核并没有全发出去。

通过kernel的snmp,发现UDP的SndbufErrors计数器有很高的值:

这个计数器是在哪、什么情况下增长的?翻了一下kernel的源码,在net/ipv4/udp.c中找到两个,一个在udp_sendmsg()中:

另一个在udp_send_skb()中:

在udp_sendmsg()的流程中,是有流向udp_send_skb()的,另外,在udp_send_skb()调用的ip_send_skb()中,有递增另一个计数器OutDiscards的代码:

再查看一下snmp中的IP计数器,发现OutDiscards的值跟SndbufErrors非常接近:

所以这里可以肯定这个计数器的增长是发生在udp_send_skb()。

按照代码中的注释,ENOBUFS代表no kernel mem,首先,可以排除是send buffer不足的问题,因为如果send buffer满了,相关写调用会被阻塞或者返回EWOULDBLOCK;另外,net_xmit_errno()这个宏,会把非NET_XMIT_CN的错误都转成ENOBUFS:

所以这里ip_local_out()返回的很可能并不是ENOBUFS。

ip_local_out()后有函数指针,不太好看出来实际调用的是哪个函数,用perf看了下调用栈,后面涉及的函数还是挺多的,懒得一个个翻:

找了个更直接的工具dropwatch

其实结果还是不算太直接,不过可以确定丢包的时候会调用kfree_skb_list(),在调用栈的函数中,顺利找到了对这个函数的调用,在net/core/dev.c中:

到这里算是找到了问题出在哪了,q->enqueue()调用的是qdisc部分的机制,赶紧看了下qdisc的统计数据:

统计中的dropped计数器与SndbufErrors的值非常接近,eno1的队列是fq,fq的源码在net/sched/sch_fq.c中:

如果超出了flow_limit的限制,会增加flows_plimit计数器的值,从统计数据中可以看到flows_plimit计数器与dropped一致,到这里又可以肯定,是排队的包超出了flow_limit,所以无法入队的包都被丢弃了。

 

前面的tc命令看到flow_limit只有100,系统设置的send buffer是212992,包的MTU是1400,212992 / 1400 ≈ 152,的确会超出100,于是调大到200:

再次运行UDP协议的程序发送数据,丢包率恢复正常,查看qdisc的统计,dropped计数器一直为0,问题解决!

KCP-GO的重传机制以及带宽利用率的提升

1. KCP

按KCP的README,这协议并不是设计来跑大流量的:

TCP是为流量设计的(每秒内可以传输多少KB的数据),讲究的是充分利用带宽。而 KCP是为流速设计的(单个数据包从一端发送到一端需要多少时间),以10%-20%带宽浪费的代价换取了比 TCP快30%-40%的传输速度。

从它的技术特性,你也可以得知这协议相对TCP的重点改进是在重传上,而不是拥塞控制。那它为什么快(某些情况下)呢?一方面,它重传机制的改进,丢包后可以更早重传,对方更早收到补发的包,更早能把数据递交给应用层,缓冲区的释放更快,发送方也能更早继续发送后续的数据;另一方面,它技术特性里面有个“非退让流控”,名字听起来略微高端大气上档次,其实通俗点来讲就是没有拥塞控制。简单来说,就是拥塞窗口大小不会因丢包率发生变化,也不需要经历TCP的慢启动阶段,KCP会尽可能地帮你把自己的发送窗口或者对方的接收窗口塞满。还有就是,大概很多人用的都不是纯skywind3000实现的kcp,而是xtaci的kcptun,这实现默认启用了FEC,每十个包发送三个冗余,补偿了丢包。

2. QUIC

应该有不少人听过QUIC,那KCP跟QUIC相比,哪个好呢?其实QUIC的比纯KCP丰富得多完整得多,还真的不太好对比,这里就拿QUIC跟xtaci的KCP实现进行对比吧。这里你可以暂时简单地把QUIC看作xtaci的KCP+BBR,所以“非退让流控”变成了“BBR拥塞控制”。有拥塞控制的好处是窗口可以自动调整,例如像HTTP这种公开的网络服务,服务器预先是不了解客户端的带宽、网络拥塞状况是如何的,因此需要一个拥塞控制算法,经历慢启动,根据丢包动态地调整窗口。如果说你很了解两个host之间的网络状况,或者说想保证两点间的传输质量,这种情况拥塞控制就有点多余了,“非退让流控”倒是能让你得到更好的体验。所以哪个更好,还得看场景。

QUIC的特性有一项是“无队头阻塞的多路复用(Multiplexing without head-of-line blocking)”,xtaci的kcp也有实现多路复用,但并不能避免队头阻塞,注意这里所说的队头阻塞并不是kcptun开发小记所提到缓冲区耗尽导致的那种阻塞,而是因KCP无法感知上层的多路复用所导致的队头阻塞。例如A向B发送了编号为0-9的十个数据包,但是其中1丢了,即使2-9中的数据跟1是毫不相关的,2-9携带的数据也因为1的丢失而无法及时递交给应用层。这也是为什么QUIC要使用UDP,因为TCP的机制就决定了一定会发生队头阻塞,HTTP/2在TCP上实现了多路复用,解决了HTTP/1.1的队头阻塞问题,但解决不了TCP的,所以这也是为什么会有HTTP/3,而且还要用QUIC。目前KCP多路复用的实现仅类似于HTTP/1.1到HTTP/2。

此外还有连接迁移(Connection Migration),xtaci实现的kcp是不支持此特性的,也就是说,如果客户端的IP和端口发生了变化,那么先前构建的KCP连接就失效了。

3. KCP-GO的INPUT/OUTPUT

3.1. OUTPUT流程

应用层发出数据调用的是WriteBuffers(),WriteBuffers()把一大份数据拆分成一份份mss大小的;KCP.Send()把mss大小的数据存入队列;KCP.flush()主要负责构建数据包,还有重传的判断;UDPSession.output()负责加密、CRC32校验以及FEC;到uncork()和tx(),就是调用操作系统的API发出UDP数据包了。

其中的update()会周期性调用,周期通过参数interval指定。

3.2. INPUT流程

monitor()和UDPSession.readLoop()都是通过系统调用接收UDP包,区别是monitor()用于server模式,readLoop()用于client模式。Listener.packetInput()与UDPSession.packetInput()负责解密、CRC32校验。UDPSession.kcpInput()负责FEC。KCP.Input()对数据包的包头进行解析,根据其中的CMD,做出对应的处理。

4. 重传

重传机制的实现在KCP.flush()中,分别有fast retransmit、early retransmit以及RTO retransmit。

KCP-GO通过KCP结构体中的名为“snd_buf”的数组对待发以及已发出还未被ACK的segment进行跟踪,以实现重传。这个数组的类型是名为segment的结构体,segment中的ts、rto、resendts以及fastack与重传有关。

Segment跟数据包有什么关系呢?每个segment都拥有一个完整的KCP header,header中有这个segment所携带的payload的长度,因此一个数据包中,可以有多个segment,只要不超过MTU。ACK就是一个包中携带多个segment的例子:

4.1. RTO Retransmit

当一个push segment(带数据)需要发出时,会记录当前的RTO,并且通过resendts记录超时的时间:

这里你可以看到,这个resendts就很准地定在了一个RTO,而且是当前的RTO,其实这里会导致一些问题,文后再提。

遍历snd_buf中的segment时,就通过这个resendts来判断是否需要重传:

上面对segment.rto的计算,是重传的退让,其中对kcp.nodelay的判断,对应KCP特性中的“RTO翻倍vs不翻倍”,除以2那个是1.5倍。

KCP-GO的RTO并没有应用指数退让,每次重传RTO的增量只是一个(或者0.5个)RTO,这是跟skywind3000/kcp不一样的地方。

4.2. Fast Retransmit

即KCP特性中的快速重传,通过判断segnemt->fastack的值是否大于设定被跳过的次数,决定是否需要进行fast retransmit。

发出segment时,会在segment的ts中记录当前的时间:

对方响应ack时,会把对应包的ts也带上,这个一方面用于计算RTT,另一方面,收到不是对应这个segment的ack时,可用于判断这个ack,是否应该在这个segment在应该到达对方的时间点后响应的,只要ack中的ts大于等于这个segment的ts,就可以肯定这个ack应该是在这个segment到达后(理论上应该到达)响应的,因为既然同时以及后来发segment都被ack了,那这个segment也应该到了,这时就可以给这个segment ack被跳过次数的计数器+1:

上面的_itimediff(seg.ts, ts) <= 0,就是对比这个ack的ts,是否大于等于segment中的ts,是的话,被跳过的次数+1。

flush()中遍历snd_buf时,就通过比较fastack是否大于设定的值,以决定是否要应用fast retransmit:

4.3. Early Retransmit

这个是KCP中没有提到的,先来看看应用这个机制的判定条件:

这里又出现了fast retransmit的fastack,条件是大于0,也就是只要被跳过;那newSegsCount又是哪来的呢?:

snd_queue是在UDPSession.WriteBuffers()中对数据拆分后,调用KCP.Send()存入的,这里这个for循环的任务是给snd_queue中的每份segment分配sn,也就是编号,然后存入到snd_buf中;cwnd是拥塞窗口,cwnd = min(发送方的发送窗口, 接收方的接收窗口)。

newSegsCount要为0的话,要么就是snd_queue为空,要么就是刚进循环,就被“_itimediff(kcp.snd_nxt, kcp.snd_una+cwnd) >= 0”这个判定条件给break了。

kcp.snd_nxt是要下一个要发出的segment的编号,kcp.snd_una是发送方滑动窗口的左边沿,也就是已发出或者将要发出的数据包中,还未被确认的且编号最小的那一个(注意这里的滑动窗口是不受选择性重传的ACK影响的,窗口仅会在对方响应的una发生变化后滑动,见KCP.parse_una())。kcp.snd_una+cwnd就是滑动窗口的右边沿。所以,这个条件,判断的就是要发出的segment的编号,是否已经超过了滑动窗口的右边沿;换句话说,这个比较为true的条件就是:发出的segment已经把窗口塞满了。

所以如果snd_queue为空,或者窗口已满的情况下,就有可能触发重传,重传那些有被ack跳过的segment。那这个重传机制的意义在哪呢?

窗口的滑动是仅在对方响应的una更新后发生,una的值代表的是,una前的编号的segment,都已经收到了。理想情况下,窗口应该是持续向右滑动的,这时窗口停止滑动,导致segment把窗口塞满,而且还有segment被ack跳过,那完全可以判定为丢包了,因为丢包会阻止una的更新。

4.4. 关于重传的一些备注

KCP的特性中有选择性重传,上面没有专门提到这个机制,其实上面的三个重传机制的实现都是选择性重传的,因为他们都是对单个segment进行跟踪。

KCP的最小RTO定在100ms,所以如果两个host之间的RTT小于100ms,生效的重传机制基本不会是RTO那个。

5. KCP-GO的四个参数

用KCPTUN的可能不了解这四个参数,因为这四个参数是隐藏参数,一般来说用户传的是normal、fast、fast2和fast3。

四个参数分别是no delay,interval,resend和no congestion。

no delay是RTO的退让,在KCP-GO中,未启用增加一个RTO,启用后增加0.5个;interval是调用UDPSession.update()的周期,单位是ms;resend是fast retransmit的参数,即segment被ack跳过多少次后,应该视为丢包并重传;no congestion即是否禁用拥塞控制,对应KCP特性中的“非退让流控”,千万别设为0,不然你不如考虑TCP+BBR吧。

四种模式,有何区别呢:

最大的区别大概就是interval了,这个参数是调用UDPSession.update()的周期,update()调用的是KCP.flush()。

KCP.flush()负责响应ack,从snd_queue取出segment分配sn并发出segment,跟踪丢失的segment,并重传。默认情况下,调用wirte()向对方发送数据并不会等到下一个update()调用才发出,前面所提供的流程图,write()是有指向flush()的。ACK则默认会延迟到每个update()的调用才发出,除非你启用了ack nodelay(一般来说不建议启用)。所以interval影响的是跟踪丢包以及响应ack的周期。

对于发送方来说,如果持续有数据发送,这个interval的影响不是很大。不过如果接收方是重收轻发的,那这个interval对发送方的丢包检测影响就很大。因为发送方对RTT的计算、以及丢包重传机制的应用都是通过ack实现的,interval越小,ack响应越快,那发送方就越早能发现丢包,越早重传。

6. 带宽利用率的提升

文章开头提到这协议不是设计来跑大流量的,实践过程中发现,跑大流量,的确是有点问题。

多跑流量这个有很多讨论,其实这个是有办法避免。多跑流量的原因,你能看到的可能有窗口设置不合理、内核socket缓冲区不够大,客户端处理不过来、还有FEC。其实这几个原因都不是关键。

先来看看这个多跑流量的问题有多严重。

6.1. 多跑了多少流量?

A和B之间是142ms RTT,千兆带宽,先用iperf3测测A以百兆速率向B发送数据的UDP丢包率:

网络质量非常优秀,没有丢包,再来看看KCP-GO,从A发送1GB数据到B,重传了多少,关闭nodelay,关闭FEC,关闭fast retransmit:

OutSegs:1161866,RetransSegs:113270,LostSegs:113270,发出了116万个segment,重传了11万+。

那到底是不是真的丢了那么多个segment呢?看看B的统计数据:

RepeatSegs:113270,跟A统计的重传数一致,也就是说,这些重传的,一个都没丢,全都是重复发送,有近十分之一的流量是多跑的。如果跑的带宽更大,还启用了fast retransmit的话,这个repeat数将会更高。

如果让KCP-GO输出RTO、重传的记录以及ack的时间,你会发现很多类似这样的记录:

850928这个segment在46306ms重传了,但是在15ms后,也就是46321ms收到了ack。收到这个850928的ack时,计算出的RTO也从193ms上升到了220ms。如果你还开了fast retransmit,最严重的情况,就是fast retransmit一次,然后RTO也retransmit一次。

前面介绍重传,有提到segment resendts的计算是当前的时间+当前计算出的RTO,会导致一些问题。影响RTT的因素有很多,除了当前的网络状况,还有双方的CPU负载、调度等问题,这些因素每一刻都在发生变化,特别是KCP属于用户空间的程序,CPU负载和调度等问题对这个影响更大。而RTT越高,在路上的ack就越多,例如A和B之间是142ms的RTT,参数设定的interval是10ms,那从一个segment的发出,到对应这个segment的ack,这期间RTO能更新14次以上!此外,我们永远也无法预先知道这个segment的发出,到收到响应,需要多少时间。

一方面,可以改进计算RTT的算法,KCP的RTT算法并没有什么问题,不过可能因为KCP是用户空间的,太容易受影响,变化太突然,采用的算法不能很好地应付这种情况。另一方面,我们可以不要用固定的resendts去与当前的时间比较,而是用segment发出的时间+当前最新的RTO与当前时间进行比较决定是否需要重传。此外,还可以给RTO应用一个退让;在Linux Kernel中,第一次重传的时间其实不是定在当前时间+一个RTO,而是对初始RTO应用了一个系数为2的退让,例如计算出的RTO是200ms,那第一次重传的时间是当前时间+2*RTO,也就是400ms后,而不是200ms:

当然,也不是所有情况都应用这种初始退让,上面的代码中,还有个thin stream的判断,这个功能默认是没启用的,大概就是,如果发出去还没被ack的包少于4个,就在一个RTO后立刻重传。

对算法进行改进这个可能有点挑战;对resendts的改进有效果,但不算很大;实测还是对KCP的RTO应用一个退让简单,而且效果也是非常好,当然也不需要像TCP那样两倍这么夸张,我这里选了1.125倍,重传量为1756,对比原先的11万,少了99.9%:

上面的参数–irtobackoff参数原版的kcptun是没有的,只是我在自己fork的版本加进去的。–irtobackoff 3,意思就是RTO = RTO + (RTO >> 3),也就是1.125倍的RTO。如果参数改成2,也就是1.25倍的RTO,这个repeat还可以到0。具体取多少,还得测,跑的带宽更大、CPU负载更高的话,可能得应用1.5倍的RTO。

对RTO应用这个系数,会不会对传输速度产生负面影响呢?

其实一般不会有的,因为还有更准确的fast retransmit机制,它是通过segment中的ts(发送时间)与收到的ack所携带的ts进行比较判断的,而不是RTO,因此即使没到RTO,fast retransmit会在一个RTT后就立刻帮你重传,不对RTO应用这个系数的话,丢包后还很可能会fast retransmit一次,之后RTO再retransmit一次。

那交互式程序呢?例如SSH,敲键盘的速度,一般来说对方并到不了让对方在一个RTO内响应多个ack的程度,那这时岂不是要依赖RTO?前面有提到过thin stream,因此可以在KCP中引入这个机制,待发或者已发出还未被ack的segment少于某个值,就不应用这个系数,直接在一个RTO后重传。上面的参数–irtobthresh 128,就是对这个值的指定,意思就是segment少于等于128个,就在一个RTO后立刻重传。

7. 一些其它问题

7.1. 跑不满千兆

不知道有多少人拿KCP来跑那么大带宽的?之所以选用KCP,是因为一方面可以免去TCP三次握手的消耗,另一方面可以跳过慢启动,直接蹦到千兆。要知道RTT越高,这两个机制的时间成本就越高。

首先要解决前面提到的带宽利用率问题,不然跑的带宽越大,带宽利用率越低。其次,CPU要够好。目前网络设备对TCP的优化还是很足的,但是UDP比较少,例如TCP有segment和checksum offload,但是对于KCP来说,这些offload都不能用,因为NIC不认识KCP,所以只能依赖CPU处理。还有就是,KCPTUN默认给io.CopyBuffer()提供的buffer只有4KB,要跑满千兆,增大这个buffer很关键,例如调整到64K。

除此之外,别忘了调整net.core.rmem_max,至少要能容纳一个BDP的量。KCPTUN的README还提到要调整wmem的,个人在这里建议不要做调整,实测会有问题,具体调整wmem是否合适,各位自行测试。

7.2. RTT越高,速度越慢

前面提到带宽利用率是其中一个问题,此外还有segment堆积的问题,千兆带宽,snd_buf中可以堆积超过一万个segment,flush()去处理这些segment也有消耗,所以RTT越高,速度越慢,但这个影响不及带宽利用率的问题大。实测千兆,会有五十兆左右的性能损耗。

7.3. 用了后速度变慢/丢包率非常高

一般来说慢一点肯定会有的,但是如果差异太大就肯定有问题。

首先是解决带宽利用率的问题,其次,如7.1提到的,别忘了调整net.core.rmem_max。另外,窗口的设定也要合理。

如果发现丢包率非常高,而且不是在中间网络设备丢的,可能是你调大了wmem,建议将wmem_default和wmem_max都调回212992,不会影响上行性能。如果丢包还是很严重,尝试调大队列的packet limit,具体方法,请参考另一篇文章:UDP SndbufErrors & ENOBUFS

7.4. FEC

这个适用于pps比较高的情况,其实对交互式应用来说,意义不算很大,例如你设置的FEC是10:3,你SSH敲键盘一个RTO内一般也敲不出十个字母,这种场景,回退到多倍发包比较合适。

8. 小修改过的KCP-GO

KCP-GO:https://github.com/yzslab/kcp-go

KCPTUN:https://github.com/yzslab/kcptun

PowerDNS Recursor设定默认EDNS Client Subnet值

授权DNS服务器可以根据递归DNS服务器发送的EDNS Client Subnet(ECS)中的值,返回不同的结果。如果客户端是通过内网IP向递归DNS服务器发起查询的,而且递归DNS服务器发出的递归查询使用的公网IP跟客户端使用的公网IP不是一个地区时,就无法给客户端提供一个最优的解析结果了。

PowerDNS Recurse 4.2增加了一个设置“ecs-add-for”,可以指定哪些subnet允许作为ECS的值,对于不允许作为ECS值的subnet,将会取ecs-scope-zero-address的值作为ECS的值。所以通过这两个设置可以为内网客户端设定一个默认的ECS值。

配置文件内容大概如下:

配置生效后,对PowerDNS Recursor发出的查询进行抓包,可以发现来自内网的查询,Client Subnet的值是1.1.1.1,而有公网IP的客户端查询,将会是客户端本身的公网IP。

Nginx ssl_preread传递客户端IP

ssl_preread是基于L4的反代方案,TLS SNI握手时客户端会提供域名,所以可以让Nginx在无需完成TLS握手的情况下,就根据域名进行后端服务器的选择。简单来讲,你只需要给后端服务器配置一个SSL证书,而提供反代功能的Nginx则无需配置。文档见此:http://nginx.org/en/docs/stream/ngx_stream_ssl_preread_module.html

因为这是L4反代,所以通过“proxy_set_header”传递客户端IP的方法是行不通的。其实传递客户端IP的解决办法跟上一篇文章类似:L4(传输层)IP透明反向代理的实现(传递客户端真实IP),nginx也是实现了IP_TRANSPARENT的:https://www.nginx.com/blog/ip-transparency-direct-server-return-nginx-plus-transparent-proxy#ip-transparency,所以,在server里面加上下面一行:

然后按前一篇文章的思路,对路由表进行配置即可。

提醒一下,ssl_preread是配置在stream block下的,放在别的地方,会提示:

所以正确的nginx.conf结构应该如下:

另外,如果在stream中listen了443,http中就无法listen 443了,启动nginx会提示:

可以让http中的server listen loopback地址的另一个端口,让stream中的server proxy_pass这个loopback地址的端口,例如127.0.2.1:8443,然后通过127.0.2.1做策略路由:

然后配置策略路由: