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左右。
软件的缺省配置:
在函数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秒后。
实现时,我使用了两个或条件:
大概是这样:
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,基本上达到了运营商给的极限速率。
先看看函数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..
其现象是:有小概率会出现这种情况,传输一会后,突然两边限入相互重发报文、互相等待对方发送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不放队列,带来两个问题:
最终的修改涉及了不少地方,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);
...
}
总之,过程很复杂,牵一发很容易动全身,最终终于是达到了稳定运行的效果:
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的缓存就收发可以,多方便!
不必担心大包发不出去。IP层协议会自动处理它。比如你发个60K的包,中间的网关会自动根据情况把它分片(MTU概念,比如分到1500以内或500以内),到接收方电脑的协议栈中,又会自动重组,这些都不用操心。连效率都不必关心,在TCP层会自动选择合适的包大小(MSS概念)。
(注:相关分析在源码的DEV.md中有英文版本)