当前位置: 首页 > 工具软件 > pingtunnel > 使用案例 >

PingTunnel(ptunnel)源码分析与性能改进

江阳夏
2023-12-01

PingTunnel是用于内网穿透的工具,近期用到它,发现了不少问题,做了一些改进,记录于此。

这个工具大概是2004年左右由Daniel创建,之后维护到2011年左右,支持通过ICMP或DNS(UDP)通道做隧道传输TCP报文,在其中实现了稳定链路传输(包括报文的序号与ACK响应、丢包重发、包乱序重组、滑动窗口发送等),TCP转发,多会话维护,核心代码集中在ptunnel.c一个1600行左右的文件中实现,非常难得。

我是在CentOS下使用,并没有现成的二进制包可以用,于是用源码编译来用,安装libpcap-devel后直接make即可,还是比较方便的。其实libpcap库也基本没用到,只在用-c指定网络接口时才会调用,一般都不会用到。

后来发现有人开了个ptunnel-ng分支,增加了一些特性,并被逐渐包含到debian甚至openwrt软件包中。我测试了一下,遇到的问题在这个分支上都还存在,于是就没有用它,直接用的原始的代码来改了。

原来的版本是0.72,我增加了一系列性能修正,把版本改为0.73,并发布在github上。

主要问题

某受限内网中运行客户端(又称为ptunnel forwarder),连到公网阿里云某服务器端(称为ptunnel proxy),发现传输速度大概只有5-20KB/s。

服务端的带宽是5M,大概对应最大下行速率为500KB/s,而上行一般是不限速的。
而且,在传输文件过程中,发送端的CPU直接升到100%(其中user CPU 20%, sys CPU 80%)

我在内网客户端对内网服务器上又做了专门测试,发现在网络最好的情况下,最大传输速率也只能达到60KB/s左右。

问题1:ACK包响应太慢及发包太快导致传输受限

软件的缺省配置:

  • 滑动窗口大小为64(即发送队列大小),每个包最大为1KB,即1次最大发送64KB数据
  • 1s超时后会发送响应包(下称ACK)
  • 1.5s超时后会重发包

在函数pt_proxy中,每1s发送ACK:

			//	Figure out if it's time to send an explicit acknowledgement
			if (cur->last_ack+1.0 < now && cur->send_wait_ack < kPing_window_size && cur->remote_ack_val+1 != cur->next_remote_seq) {
				...
				queue_packet(fwd_sock, ..., kProto_ack, ...);
			}

在发送大文件时,一次发送时,64个包共64KB数据过去,对方并不响应,而是等到1秒超时后才响应。这就导致这1秒以内,发送方无法再发送数据,必须等到ACK报文过来,匹配序列号,然后 清除发送队列中对方已经收到的包,才能再发下一批包。这么算下来,刚好是1秒只能传输64KB数据,也就验证了内网测试中最大速率为60KB/s左右的速率。

所以,对于信息量较小的通信没有文件,一传输文件(哪怕只有100K的文件)就漏出马脚了,连接常常卡死不动。

更严重的是在公网上,一次发64个、64KB的包,对方大概只能收到16个(在我在测试环境中,ping响应10ms内)。这应该是被中间网关限速导致的。也就是说,传了64个包,只收到16个,剩下75%的包丢了,于是引发更慢的重发过程。
重发大概是每1.5秒重发一个包,函数pt_proxy中:

			//	Check for any icmp packets requiring resend, and resend _only_ the first packet.
			if (cur->send_ring[idx].pkt && cur->send_ring[idx].last_resend+kResend_interval < now) {
				pt_log(kLog_debug, "Resending packet with seq-no %d.\n", cur->send_ring[idx].seq_no);
				...
				sendto(fwd_sock, ...);
			}

其中kResend_interval就是默认定义为1.5s。

这导致了最终速度只有5-20KB/s。

要注意的是,尽管1.5s后重发,且一次只重发一个包,但不意味着每1.5s重发一个包,而是在1.5s超时后,这一批没收到ACK的包以每10ms发一个的速率发出去。其中10ms是select的超时时间。之后贴核心源码时能更清楚。

解决方法

思路很简单:尽快回复ACK,而不是等到1秒后。

实现时,我使用了两个或条件:

  • 假如发送队列(即发送窗口大小)长度为64,那么我每收到32个包就发一次ACK包,让对方及时清空队列。
  • 如果一批包全部发完了,比如一批发了50个包,我在第32个包回复了,那全部50个收到后怎么回复呢?我实现为一旦检测到没有包了(即select超时返回时),就发ACK包。

大概是这样:

	while (1) {
		// NOTE: timeout is 10ms
		int rv = select(max_sock, &set, 0, 0, &timeout);	//	Don't care about return val, since we need to check for new states anyway..
		int is_timeout = rv == 0;
		...

			//	Figure out if it's time to send an explicit acknowledgement
			if ((is_timeout || cur->xfer.icmp_in % (kPing_window_size/2)==0) && (uint16_t)(cur->remote_ack_val+1) != cur->next_remote_seq){
				queue_packet(fwd_sock, cur, kProto_ack, 0, 0);
			}
	}

上面用is_timeout表示一批包收完,暂没新包,注意select设置的超时时间是10ms,也就意味着,我最长是延迟10ms给对方发送(再加上网络来回时间约10ms,这10+10=20ms以及窗口大小决定了最终的极限速率)。

cur->xfer.icmp_in % (kPing_window_size/2)==0这个条件就是当收到窗口一半的包时发ACK。

上述修改时,在内网中测试,速率果断从最大60KB/s上升到50MB/s以上(窗口大小用的默认64)。

满心高兴了放到公网环境中,很快发现仍不用能,速率大概还是20KB/s。原因很简单,大量丢包问题没有解决。这是因为流量控制是个很难的话题,TCP就是干那个活的,算法极其复杂。
在这里我就简单适配来解决了:既然我一次只能收16个包,保险起见,我把窗口大小直接改到8测试,收慢些总比丢包引起的严重失速好。

注意:这里说的丢包不是那种网络不稳定造成的偶尔丢失,而是稳定地被网关给批量丢弃了,极其影响速率。主要原因就是发包太快,直接说就是发送窗口太大。

果然,修改窗口到8后,速率升到500-800KB/s,基本上达到了运营商给的极限速率。

问题2:发送方CPU 100%问题

先看看函数pt_proxy中的核心逻辑,即这个select循环模型:

	fwd_sock		= socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);
	while (1) {
		// timeout = 10ms
		select(max_sock, &set, 0, 0, &timeout);	//	Don't care about return val, since we need to check for new states anyway..

		// recv from tcp sock, send to icmp sock (add to send queue: cur->send_ring)
			if (FD_ISSET(cur->sock, &set) && the send queue is not full...)
				bytes		= recv(cur->sock, cur->buf, tcp_receive_buf_len, 0);
				queue_packet(fwd_sock, ...);

		// recv from icmp sock, add to recv queue: cur->recv_ring, then send to tcp
		if (FD_ISSET(fwd_sock, &set)) {
			bytes		= recvfrom(fwd_sock, buf, icmp_receive_buf_len, 0, (struct sockaddr*)&addr, &addr_len);
			handle_packet(buf, bytes, 0, &addr, fwd_sock);
		}

		// handle resend and ACK reply
	}

它从TCP socket中收包,当发送队列未满时,加到队列中并通过ICMP socket发送到隧道中(queue_packet)。

当发送队列满了时,将不再接收包。这时将发生什么呢?循环空转耗尽CPU!因为不收包了,select将直接返回(因为有数据可读啊),空转一圈发现没有事可以做,再循环往复,直到1秒后对方发了ACK过来清除些发送队列的包后才能再接收包。

解决方案

知道原因后,我先给了一个简单的sleep方案,发现队列满后,空等1毫秒:

			// if the send queue is full, sleep 1ms
			if (cur->send_wait_ack == kPing_window_size) {
				usleep(1000);
			}

效果立竿见影。CPU被拯救了,发送文件时占用率从100%直接降到5%以内,几乎为0(因为在等对方回复啊,确实没什么活干)。

但我对这个方案不太满意,弄个sleep太凑合了,从理论上分析,在我优化ACK实现快速收发包后,显示很多时间被这个sleep浪费了。我在内网做了个测试,果然最大传输速率从之前的50MB/s降到了1MB/s。尽管在公网上这个速率足够了(我是5M带宽大概就500KB/s),我还是决定想个更好的解决方案。

在发送队列满了后,线程应该被阻塞,在有数据可读时再恢复,而不是固定的死等。但这里有些难,因为在多线程模型中,这是个典型的同步问题,可以用消息队列,或是信号量机制实现线程间通知,但它不是多线程模型啊,它是select单线程在跑的。

不过最终我还是想到一个不错的方案,用了一个极简的方案,完美的解决:当发送队列已满时,不在select中监听这个socket。这样当没有其它socket时,select阻塞并在每10ms超时出来处理一些操作。轻松通过select实现了阻塞,代码异常简单,没有引入任何新东西:

	while (1) {
		FD_ZERO(&set);
			// if (cur->sock) <-- this is the original condition
			if (cur->sock && cur->send_wait_ack < kPing_window_size) {
				FD_SET(cur->sock, &set);
				if (cur->sock >= max_sock)
					max_sock	= cur->sock+1;
			}
		...
		select(max_sock, &set, 0, 0, &timeout);	//	Don't care about return val, since we need to check for new states anyway..
	}

结果比较满意,刚刚sleep方案把速率从50MB/s跌到1MB/s,现在内网测试大概到10MB/s左右(窗口大小是8),如果窗口改大,达到50MB/s也在预期之中。

比起之前CPU 100%工作,现在CPU几乎看不出明显消耗,且速率没有损失。
这也给select模型中实现阻塞提供了借鉴思路。

后来,还发现客户端带密码(-x参数)去连接服务端时,如果服务端程序没有开或是服务端没有密码,这时发起连接后也是CPU 100%,原因类似,所以也一起改了,最终是

		for each cur in chain (session):
			bool authed = (!password || cur->authenticated);
			// if the send queue is full, pause recv
			if (cur->sock && cur->send_wait_ack < kPing_window_size && authed) {
				FD_SET(cur->sock, &set);
				if (cur->sock >= max_sock)
					max_sock	= cur->sock+1;
			}
			...
		int rv = select(max_sock, &set, 0, 0, &timeout);	//	Don't care about return val, since we need to check for new states anyway..

问题3:连接有时会挂掉

其现象是:有小概率会出现这种情况,传输一会后,突然两边限入相互重发报文、互相等待对方发送ACK,而且一旦陷入这种情况,通信无法恢复,会永久死掉。
这个问题只在公网能重现,不是每次都能重现。

这个问题很难。前面从完全从零开始分析代码到改掉以上两个问题不到1天吧,但这个问题困扰了我两天两夜。因为小概率事件,更增加了难度。

因为前面我改的方案很简单,不太像是我的改动导致的问题,应该是代码原本就有的问题。
但我把窗口大小从64降到8,很可能放大了问题概率,我猜测该问题一是和窗口大小相关,二是和丢包相关。

对这种难以重现的难题,思路还是想办法重现才好分析。我把发送窗口大小直接改到2,果然重现概率又大了很多,差不多传大文件时,多跑几次就能遇到1次。

我接着又修改代码,故意制造丢包,即每遇到尾号为3个包就直接不发送。于是基本稳定重现了,开始传输后,甚至1分钟内一定能重现。当然还是一台在内网一台在公网里测,否则速度太快也测不出来。

挂上gdb调试,发现出问题时,一边的发送队列已满,这时它发不了ACK包!注意ACK包是要加到发送队列的,一旦队列满了当然就发不了了!对端的情况我没有调试,但我猜测应该是相同的情况。

于是,由于某种特定的丢包(注意不是丢包就出问题,而是刚好某种丢包会导致),两边都陷入了等待对方发ACK,而自己由于发送队列已满而发不了ACK的死局,典型的死锁局。

难就难在,我怎么看日志,脑子里过了无数遍场景,还是无法精确的讲出怎样的情况下会出现这种结果,哪怕发送队列为2已经是最简单的情况了。这就是网络程序难的地方啊,各种情况太多了,我宁愿一辈子都不去碰TCP的实现细节(讲理论、讲算法都懂,来个实际问题直接难死)。

总之,这是个严重问题,必须解决,因为一旦出现就死了,没有复活的机会,只有人工干预重启进程。

附带一个问题是,它的ACK包是放到发送队列的,这意味着对方收到ACK后是要再回复的,否则无法将它从发送队列清除。于是A发ACK,B需要回复A的ACK,而A又要回复B回A的ACK,又是陷入无限局。好在在原先的程序里,ACK是固定每1秒发一个的,而且发数据报文时会兼具ACK的功能,而且一旦连接结束就是清空所有队列与会话,倒没有造成严重后果。但想想为ACK发个没完,还占用发送队列,想想也觉得挺恶心了。

解决方案

虽然我最终没弄清楚这个情况是怎么形成的,但我知道,只要ACK能顺利的发出去,就可以破局。要想ACK一直能发,就不能把它放发送队列,这样刚好顺便就解决了双方无限互回ACK问题。

实际情况要难很多,因为ACK不放队列,带来两个问题:

  • 发包时序列号要增加,而又没放队列,这样对方发来ACK就和自己发送队列里对不上了,导致清空发送队列的逻辑要改。
  • 假如ACK丢了,由于没有放在发送队列里,它是没有重发机制的,这就有可能造成两边又挂住。

最终的修改涉及了不少地方,queue_packet,handle_packet, handle_ack都做了重构,在queue_packet中的改动是这样:

	bool add_to_queue = state != kProto_ack;
	...

	if (add_to_queue)
		pt_pkt->seq_no			= htons(*seq);
	else
		pt_pkt->seq_no			= htons(*seq + 10000);
	...

	if (! add_to_queue) {
		return 0;
	}

	// add to cur->send_ring
	// add seq-no of the session

我用了这样一个技巧,让ACK包的序列号为正常序列号加10000,对方收到后,认为是个异常包,这就不会有副作用,不会修改内部状态(比较复杂,难以描述,任意动一处都会引发问题),在handle_data中不会将它加到接收队列中;但是哦,它仍会在handle_ack用于处理清理发送队列,也就是说,这个特殊的设计实现了靶向精准打击,既不影响对方的接收队列及状态,避开了很多坑,又清理了它的发送队列,实现了ACK的目的。

相应的函数为了适配都做了处理。注意序列号是16位无符号整数,即65535再加1就变成0了,原代码中存在溢出问题存在坑点(作者填了一些,还剩一处,我又加了一处)也给填上了。据说当年美国挑战者号航天飞机爆炸就是这种bug造成的。而2018年美图区块链币受攻击崩盘、之后天才科学家张首晟跳楼事件也是整数溢出的坑。所以不可小觑啊。

如果ACK包丢了,是没有重发机制的,为了防止这个情况,我设计在收到旧包时(判断这个包已经收到后,应该是对方的重发包),直接发送ACK来挽救危局:(函数handle_data)

			if (cur->remote_ack_val >= s) { // old packet, maybe resent, so we reply ACK
				pt_log(kLog_event, "Recv old packet. Reply ACK.\n");
				queue_packet(icmp_sock, cur, kProto_ack, 0, 0);
				...
			}

总之,过程很复杂,牵一发很容易动全身,最终终于是达到了稳定运行的效果:

  • ACK包不放到发送队列,对方也不必回复ACK,不仅解了bug,而且有效降低了ACK包数量,不占用发送队列理论上也可以提高发数据包的效率。
  • 极端情况下如果ACK包刚好丢失,也能保障连接能自动修复,不会挂死。

未来的改进展望

ptunnel的实现方式是在TCP层,在客户端(forworder端)监听一个TCP端口,接收数据(即TCP流)然后送到ICMP隧道(或UDP-DNS隧道)。

第一,它在只在客户端实现端口监听,与ssh隧道对比,类似ssh的本地监听模式(-L),没有远程监听的功能,即ssh的远程模式(-R)。理论上这个是可以实现的,这样就不必在icmp隧道上再运行ssh隧道了。

第二,实现在TCP层带来不少问题,从结果来看,它只支持TCP流的转发,而且对流量控制这一块几乎为0,即使手工调参也只能固定适配某种情况,无法自适应。
是否能让它工作在IP层,向V批N那样可以转发各种流量?

为了支持稳定连接,它引入了复杂机制,滑动窗口发送队列、序列号、响应与重发、乱序重组等等,而这些与TCP比起来连小儿科都算不上,效果自然难以如意。最好是能够重用TCP的强大功能,不要自己做这些复杂又弄不好的事。

我提出一个新的基于IP层而不是TCP层的实现方案,如果可以实现应该是非常理想的,既达到实现简单又达到效率完美。原理是它在IP层监听raw socket而非TCP socket,对包按应用配置过滤后,直接通过隧道发到另一端。

注意,它是直接处理IP包,而不是TCP流。比如,在TCP层写程序是用listen监听端口,有新连接就用accept得到一个会话的socket,由于是TCP是流而非包的概念,每次是用自定义的buffer比如1K的缓存来send/recv收发包,当recv返回异常时标志连接断开。

而在IP层,一切都简单了,其实TCP通信说到底就是一堆IP报文(比如TCP SYN包,ACK包,RST包,数据包等等),在IP层根本不用管它是啥(连UDP或其它包也一并处理了),直接用recvfrom/sendto转发就行了,IP包最大64K,开个64K的缓存就收发可以,多方便!

  • 它完全不必实现稳定连接或流量控制。这是由跑在它上层的TCP协议来保障的。这样,代码将简单很多,而且能更高效的利用带宽。
  • 它支持转发任意IP报文,这种意味着什么TCP,UDP,ICMP等等全都不在话下了,天然就支持转发,而不是仅受于TCP。这样就很像V批N了。
  • 要实现转发,必须要能修改IP报文,至少涉及源和目的IP及端口,重算checksum,客户端和服务器都需要改它。

不必担心大包发不出去。IP层协议会自动处理它。比如你发个60K的包,中间的网关会自动根据情况把它分片(MTU概念,比如分到1500以内或500以内),到接收方电脑的协议栈中,又会自动重组,这些都不用操心。连效率都不必关心,在TCP层会自动选择合适的包大小(MSS概念)。

(注:相关分析在源码的DEV.md中有英文版本)

 类似资料: