L4(传输层)IP透明反向代理的实现(传递客户端真实IP)

这种需求,一般来说,会在应用层加个标记标明客户端的IP,例如说HTTP,就是添加个请求头的事情。但并不是所有服务器程序、协议你都能这样下手。所以能不能在不对协议和服务器程序本身做任何改动的情况下,传递客户端的IP呢?

最直接的方法应该就是,把L3的源IP改掉,没对协议进行任何改动,对上层完全透明。先来看看改了L3的source IP会出现什么问题:

上图中的A是个L4负载均衡,Router会把来自Internet的客户端请求转发给A,A再根据策略转发到B、C或D。

假设这时A选择了B,A把请求转发给B时,把L3的源IP改成了客户端的IP,那B收到数据包后,根据B系统上的路由表,回复的包会直接经switch交给router送出Internet,也就是说响应的内容不会经过A送回给客户端。听起来好像没什么问题,还省得A耗费资源转发响应内容,不过TCP需要三次握手,客户端跟A握手成功后,A还需要跟B握手:A这时以客户端的IP向B发出SYN,B收到的SYN后,响应一个目的地IP是客户端的SYN ACK,这个SYN ACK是直接由router送出Internet的,根本不会送到A,所以A跟B是无法成功握手的,那客户端的请求也自然无法送达B。

UDP虽然不用握手,但A转发到B用的UDP源端口不一定跟客户端到A的一样,所以客户端不一定能正常接收到B响应的UDP包。要解决这个问题,就要做到请求从哪转发过来的,响应就得送回哪去,既然是从A来的,就送回给A。

A虽然改了送往B的包的源IP,但即使B把响应送回A了,默认情况下,A收到数据包的目的地IP不是系统已绑定的IP时,也就是非送往本地的数据包,kernel只会选择转发或者丢弃,这种情况下,A跟B也是无法握手成功的,所以,这里还要解决的就是,让A对来自B的非本地数据包,当作本地数据包处理。

到这里,实现这个L4 IP透明代理要解决的问题就很清晰了:

  1. 反向代理服务器修改L3源IP
  2. 被代理服务器把响应转发回给反向代理服务器
  3. 反向代理服务器处理来自被代理服务器的非本地数据包

1. 修改L3源IP

Linux的kernel,有一个IP_TRANSPARENT的选项:

简单来说,这个选项允许应用程序的socket,绑定一个操作系统没有绑定的IP,并且以那个IP对外通讯。

所以,A的反向代理程序,通过这个选项让socket绑定客户端的IP,就能以客户端的IP与B进行通讯:

在A上编译运行上面的源码,抓包可以看到A在以192.168.2.2作为源IP向192.168.1.20:80发送SYN,ss或者netstat也可以看到程序的local IP是192.168.2.2,而A系统本身是没有绑定192.168.2.2的:

目前来说A还是无法与B成功握手的,抓包也只会抓到A一直在向B发送SYN,晚一点还会输出”connect(): Connection timed out”。

2. 让B把响应送回给A

在B上抓包,可以看到B会收到来自2.2的SYN,也会响应SYN ACK,但是会收到2.2的RST:

如何让B辨别来自A的包,并把响应送回A呢?

2.1 选择一:根据B的源IP和端口做策略路由

给B分配多个IP,例如默认用1.19出网,1.20专门给A访问用,可以用这种方法。如果只有一个IP,不建议用这种方法,因为这样B会把所有从被代理的端口发出的数据包都交给A,如果请求不是转发自A的,就无法正常处理。

因为A向B发出的请求的目的IP是1.20,所以B发出响应的源IP一定是1.20。而不是对A的响应,B默认使用1.19,不会发送给A。

2.2 选择二:通过mark做策略路由

例如可以通过mac地址识别来自A的包,并且做个标记:

3. 让A处理非本地数据包

第二步的策略路由做好后,A上抓包,可以看到B的SYN ACK送回来了,但是A没有响应ACK:

因为A没有绑定192.168.2.2这个IP,前面提到过kernel是不会对非本地的数据包做出响应的。

哪些目的地IP作为要本地IP处理,是可以在路由表上指定的,可以把0.0.0.0/0,也就是任何IP都视为本地IP处理,当然不能加在默认路由表上,这样就无法对外通讯了,需要做策略路由。

上面有一个路由规则:“local 0.0.0.0/0”,意思是任何IP都作为本地IP处理,注意规则是加在表60的,符合条件的数据包,才会应用这个表。

第三步的规则做好后,再次运行ip_transparent,就可以看到立刻输出“connection established”了:

4. IP透明反向代理

上面的ip_transparent只是测试A和B之间的握手,并不能作为客户端与B之间通讯的代理,这里用Go实现了一个比较简单的反向代理:

上面核心的部分是函数func createTransparentSocket(client net.Conn, destination string) (net.Conn, error),简单来说,就是获取客户端的IP,然后通过bind()绑定为socket的本地IP,再向B发出构建连接的请求。

通过第一个运行参数指定本地监听的IP和端口,第二个运行参数指定被代理服务器的IP和端口:

从client向A发出请求:

B上查看Nginx的访问日志,可以看到记录的IP是client的:

A上的反向代理程序的输出:

这个程序只实现了IPv4 TCP的IP透明反向代理,UDP以及IPv6的实现也同理。

除了反向代理,其实正向代理也是可以通过IP_TRANSPARENT实现,配合iptables的TPROXY模块即可发挥作用,本文的目的只是反向代理,正向代理具体细节就不在本文介绍了。

千兆公网带宽的TCP sendbuffer与receivebuffer调整

不知道有多少人留意过,使用千兆公网带宽传输数据时,只要距离稍微远点,就无法跑满千兆。对于这种情况,可能第一反应是“线路繁忙,公网带宽不足,跑不满很正常”。不过如果你有在不同地区的多台千兆设备间传数据的经历的话,你大概会发现,最大传输速度跟延迟成负相关。

首先丢出个公式:

TCP有ARQ机制,已发出的数据要收到ACK后才能丢弃。因为至少要等一个RTT才能收到ACK,所以sendbuffer要至少能存放一个RTT内能发出的数据量。另外,TCP的拥塞控制也会根据接收方的receivebuffer大小限制数据的发出速率。因此,如果想达到100%的带宽利用率,双方的buffer size都得符合上述公式。

根据Linux kernel的document,TCP sendbuffer max size默认为4MB,所以RTT低于多少时才能保证千兆呢?

RTT = 32ms,只要超过32ms,你的千兆带宽就不能跑满。万兆网络的话,这个数还得除以十:3.2ms,基本上只能在局域网内跑满(我猜即使buffer够了,万兆在公网还真的跑不满……)。

这里再丢出两条公式:

为什么会有另外两条公式?因为开头给出的只是理论值,实际上的buffer,并非仅用来储存payload,因此需要扣除非payload部分。

sendbuffer:

第一条计算sendbuffer的公式的系数0.7,是我测出来的,并不是准确值,为什么会是接近这个值,我还在寻找答案。

receivebuffer:

计算receivebuffer的可能有点复杂,对于tcp_adv_win_scale,kernel文档解释如下:

tcp_adv_win_scale – INTEGER
Count buffering overhead as bytes/2^tcp_adv_win_scale
(if tcp_adv_win_scale > 0) or bytes-bytes/2^(-tcp_adv_win_scale),
if it is <= 0.

Possible values are [-31, 31], inclusive.

Default: 1

值的应用以及公式的出处,可以在这里找到:https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/tree/include/net/tcp.h?h=v5.10.12#n1395

简单来讲,就是receivebuffer有一部分是作为buffering overhead的,其大小通过tcp_adv_win_scale进行控制。所以扣除这一部分才是实际用来存放payload的size。

buffer在内核中的设定:

TCP的sendbuffer与receivebuffer分别在net.ipv4.tcp_wmem和net.ipv4.tcp_rmem中设定:

三个值分别是min,default和max,kernel会在min和max自动调整,程序也可以通过setsockopt() API设定。

下面是对buffer的调整尝试:

A到B之间的RTT是146ms,A的sendbuffer max size是默认4MB,测试下B从A下载文件的速度:

调整A的sendbuffer:

再到B上测试下速度:

快的确是快了,但只有21.2 MB/s,好像并不是预期的结果,如果你抓包看看A和B之间的数据包,你会发现,B的窗口大小上限是3 MB,见下图的Win:

所以这限制了未被ACK的数据量,A不能发送超出B接收能力的量,通过窗口大小和RTT可以反推出速度:3MB / 0.146s ≈ 20.5MB/s,接近curl的速度。

TCP窗口大小是receivebuffer的具体表现,查看一下B的receivebuffer:

如果把数值代入receivebuffer的公式,你可以算得receivebuffer的值是接近6MB,跟当前tcp_rmem的max size一致,所以B目前的行为是符合这两个数值的预期的。

下面调整下B的receivebuffer:

这时抓包,可以看到B的窗口大小上限是18M:

0.146s * 112MB/s = 16.352,可以确定目前的瓶颈是物理带宽而不是buffer。

再找了个与A的RTT是68ms的C:

A的sendbuffer已经调整过了,足以应付68ms的RTT,但C的receivebuffer还没调整:

新的速度接近原来的两倍,把数值代入公式:0.068s * rate = 12MB – (12MB / 2^1),得rate = 88.2353MB/s,接近实际速率。

另外,这里再提一个参数“tcp_slow_start_after_idle”,kernel document的解释如下:

tcp_slow_start_after_idle – BOOLEAN
If set, provide RFC2861 behavior and time out the congestion
window after an idle period. An idle period is defined at
the current RTO. If unset, the congestion window will not
be timed out after an idle period.

Default: 1

这个功能默认是启用的,连接空闲一个RTO后,后面会从新进入慢启动状态,长连接要保证稳定性能的话,置0。

TCP Sendbuffer Size与RTT的问题

最近从A服务器下载数据时,发现如果走B服务器的VPN下载,速率只能跑到300KB左右,但是B从A直接下载却能跑到1MB+/s,B到本地的速度也远超过A到B的速度,其实一开始我以为是VPN实现的问题,有段时间在思考如何优化VPN,但是尝试了其它VPN实现,问题并没有解决。后来发现,走B的代理而不是VPN,速度正常。

本地到B的RTT大约160ms,B到A大约50ms。

Wireshark抓包,对tcp sequence number统计了下:

放大点看:

大概每传输60KB,就要等大约200ms才能继续。200ms刚好差不多等于本地走VPN到A的RTT。传这60KB,只花了大概10ms,忽略这10ms不算的话,每秒大概只能接收五次60KB,5*60KB=300KB,刚好接近走VPN的下载速度。

TCP要发送的数据包存放在send buffer中,send buffer已使用的空间,在收到ACK前是不能释放的,所以,如果这时send buffer满了而未收到ACK,那kernel就不会继续对外发送数据。

所以,走VPN慢的原因,是因为A设定的的send buffer太小,在本地发出的ACK送达A前,A的send buffer就满了。走B代理快,是因为B到A的RTT比较小,ACK是由B上的代理程序在接收到数据后发送给A的,所以A很快就能收到ACK而继续向B发送数据,B到本地没有send buffer过小的问题,所以B上的代理往本地发回数据的速率几乎与A到B的速率一致。