goproxy是我个人写的,和shadowsocks同类的软件。当然,在设计之初我完全不知道shadowsocks的存在,goproxy的最初目标也不是成为shadowsocks的同类。只是我一直无法实现一个可靠的,能够达成目标的系统。最后想,那这样吧,我找一个跳一跳能够够到的苹果。大幅简化的结果就是goproxy——后来我才知道shadowsocks。
shadowsocks的基本原理
shadowsocks的基本概念,就是利用某种不同于SSL的协议,将本地的socks数据流转发到远程。这个协议,在默认版本中是一个凯撒变换,后来有了aes等加密算法。goproxy也采用了类似的做法,同样支持aes等加密算法。在每次连接时,客户端先用加密通道连接服务器端,然后完成整个连接通路。这样的设计鲁棒性相当好,但是作为代价的,也有不少缺陷。
首先,goproxy和shadowsocks不约而同的采用了自己的协议,而非将socks5透明的转发到远程的服务器端。为什么?因为socksv5协议中,握手过程是三次交互。客户发送握手包,服务器响应允许的握手验证方法。客户发送验证报文,服务器端返回是否成功,客户发送要连接的目标,服务器端返回是否成功。细节我记得不是很清楚,但是2-3次往返是必须的。
这种工作机制需要client -> proxy-client -> proxy-server -> server的一个链条,本身就比直连多了两次TCP握手。加上上述的往返过程,更加耗时。而且这个消耗在每次建立链接时都要来一次,而HTTP是一种短连接协议——这就更加无法容忍了。因此改用自有协议,一次交互完成握手,就会更加快速。
更根本的原因在于,这两个系统都需要越过IDS,而三次交互的报文大小是几乎固定的——就算加密也无法改变报文大小。不但大小一样,而且由于用户名密码相同,起始加密过程和IV一致,因此采用socks协议的话,每个链接开始都有相同的来返数据。
我不知道shadowsocks怎么处理的这个问题。qsocks协议(msocks)的前身规定,每次握手时客户端提供一组IV,然后发送一个头部变长的字符串(256字符以内),在远程丢弃同样长度的随机字符。经过这样的处理,每次链接时的报文长度和内容序列都不一样,增加了破译难度。至于多出来的几十个字节,和验证报文在一个报文内,开销相比一次RTO几乎可以忽略不计。
但是还是有一点无法避免的问题。如果你看到某个服务器上有一个端口,频繁的被一个或多个IP链接。每个链接都不长,每次都是客户端吐一堆数据,服务器返回一堆,然后关闭链接。尽管协议无法破解,但是基本可以肯定这就是shadowsocks。根据这个特性,可以有效的阻挡服务——这也是我最近碰到的问题。
而且每个链接都需要验证和TCP握手太慢了。
msocks的改进
所以,我参考SPDY协议,做了msocks。msocks的核心思路和qsocks很类似,主要修改是以下两点:
- 使用一个可靠链接(这里是经过加密的TCP),在这个链接里面封装多对传输。
- 每个链接只要一次验证。
这样做,首先减少了一次TCP握手和一次身份验证,工作速度更加快。其次多个传输叠加在一个流里面,流特征更加变化莫测。最后,无论是服务器端还是客户端的开销都小了很多。
当然,这也带来不少问题。例如TCP原本的拥塞控制窗口是为了一对传输序列设计的。当很多传输序列在一对TCP上传递的时候,丢报文造成的影响会作用作用在全体传输序列上。包括丢了一个报文重传的时候,所有序列都必须阻塞。还有基础的TCP被施加了丢包,导致全体序列共享5k带宽。当然,经过评估后,我觉得这些问题比频繁握手更加轻,所以就设计了msocks协议。
协议设计的时候,有几个细节问题。
多对复用
我采用了一个map,来记录某个id是否对应到了一个控制结构。这个映射只能被客户端更改,并且有个专门的函数负责查找空闲的id,每次生成的id都是递增的,如果碰到最大值则绕回。
id的大小是16位,足够容纳65536对同时链接。其实不修改内核的话,500对代理就会导致too many files。
实际上一般到id达到400后,单一的tcp就断线重连了。目前我还没见过上千的数字呢。
连接状态
连接一般情况下可以看到5种状态,连接请求发送,连接请求接收,连接建立,主动关闭连接中,被动关闭连接中。
当客户端请求代理连接一个远程服务器时,进入连接请求发送。代理远程端接受后在连接目标服务器的过程中,进入连接请求接收。当成功后,双方进入连接建立。
当关闭时,主动发起关闭一端进入主动关闭,另一端进入被动关闭。当被动关闭端调用close,或者主动关闭端收到对方关闭,整个链接就销毁。
由于tcp是可靠传输,因此三次握手和四次关闭都是不必须的。
简单吧。
拥塞控制
TCP原本是带有拥塞控制的——借助SSN双序列和窗口机制。但是在多路复用的时候,我们需要自行控制拥塞——而且不能采用会和机制。会和会导致后续已经到达的其他链接的报文被一个没人接收的报文阻挡。所以必须采用带拥塞控制的缓存队列机制。
不过幸好,TCP本身是可靠传输协议,所以我不用担心丢包重发之类的问题。我需要做的,就是把对方读取的字节数传递回来,减在控制器上,即可。
不过,我没有做对应于silly window syndrome的优化,在每次读取小数据量后,这个读取造成的window扩张都会被传回。当然,这么设计是有原因的。我默认采用了8K的buffer进行fd间拷贝,所以一般碰不到SWS。
为了解决tcp链接复用造成的单连接带宽问题,我强烈的建议你做以下的设定:
net.ipv4.tcp_congestion_control = htcp
net.core.rmem_default = 2621440
net.core.rmem_max = 16777216
net.core.wmem_default = 655360
net.core.wmem_max = 16777216
net.ipv4.tcp_rmem = 4096 2621440 16777216
net.ipv4.tcp_wmem = 4096 655360 16777216
ip选择算法和DNS
在goproxy中,我沿用了一个做法。通过DNS获得请求的目标IP,和中国IP范围核对。如果在国内则直接访问,否则透过代理。这个方法能够极快的加速访问,而且几乎不依赖于需要更新的列表(中国IP列表相对来说固定)。
问题是DNS解析过程。msocks内置了DNS能力,可以帮助做DNS。但是实践下来发现这样做效果并不很好。而原本是采用直接DNS,丢弃特定的报文。这样可以过滤防火墙污染。
原因很简单。原本的模式会让DNS服务器感知到查询者位于中国,于是给出中国可以访问的最快地址。而新的模式则会将DNS请求者搬到美国——这无故加重了代理的负担。例如www.qq.com,原本只需要请求得到一台深圳的服务器即可,现在则需要让DNS绕出去,再回来。如果不幸,QQ有一台位于美国的服务器,那么我的访问都会通过这台服务器——这可比深圳的服务器慢多了。
地址
抱歉刚刚忘记写地址了:
有疑问加站长微信联系(非本文作者)