背景

本文讨论的问题其实是一个非常古老的问题,至少从我们14年入学开始,就一直存在而没有被解决。当时GeekPie的服务器刚刚建立,通过申请拿到了固定ip以及一个映射的外网ip,但是我们发现有这样一种情况,就是当我们身处外网的时候,我们能通过www.geekpie.org(指向到我们的外网ip)访问到GeekPie网站,然而当我们身处内网的时候,却无法连接,只能转而连接服务器的内网ip才可以。同样的问题不仅出现在我们的网站,还出现在陈浩教授的shtech.org以及gradebot.org上。

当时查阅资料得知可能与路由回流有关,但是终究也没讨论出个所以然来,最后的折中办法就是在校内由图信中心的路由劫持所有的geekpie.org的域名DNS解析请求,强行返回内网的ip,使得同一个域名(比如www.geekpie.org)在内网和外网解析到不同的地址,两方便可以通过这种办法进行访问。

然而这种办法并不是一个安稳的长远之计。首要的问题在于我们的全部域名解析会被学校劫持,我们上线的网站服务全部要在内网重新添加一遍解析。另一方面,如果内网用户并没有用默认的DNS,而是选择诸如114之类的公共DNS(我们认为这是完全正常的选择,尤其是在国内恶意DNS劫持满天飞的情况下),我们的网站都会再次无法连接。而实际上,长期以来,由于内网DNS配置不如我们当时配置完善,shtech.org以及gradebot.org就是长期处于这样的状态,内网或许能访问,但很多人或多或少都遇到了问题。

近期,随着学校信息部门领导层的更换,问题变得难以继续按照原有的折中方案继续下去。过去的一个学期,我们长期忍受一个服务上线动辄三个月甚至超过半年的行政流程,动辄申请-驳回-再申请数次的内耗,以及我们手头诸多已经准备就绪的服务已经从7月堆积到现在却仍然无法上线的现状。我们不得不承认,这样的问题依靠折中方案不会是长期解决办法。归根结底解放我们,也解放各路行政人员的途径便是让学校的网络变得【正常】起来。

问题简述

简化一下这个问题,假设在同一个内网下两台设备A与B,各自的内网ip为192.168.1.2和192.168.1.3,其中A设备已经分配了公网ip:200.200.200.200。那么当处于公网时,任何设备直接连接200.200.200.200可以连接到设备A,当处于内网时,设备B可以通过192.168.1.2连接到设备A,但是,【设备B却不能使用200.200.200.200与设备A建立联系。】

路由回流

没有回流会怎样

为了了解这个问题,我们简单看一下,假设没有路由回流,会是怎么样的:(以下内容主要涉及到TCP/IP两个协议)

首先,设备B第一次连接设备A,(TCP第一次握手),那么他发出了第一条消息:

【我是192.168.1.3,我要连接200.200.200.200】

TCP-1

这句话被传递到离它最近的设备,假设我们简化一下网络,A与B之间只有一个路由器192.168.1.0。那么,这个路由器便收到了这句话。此时,他开始检查他是否知道200.200.200.200这个地址是在哪里,此时他发现这个地址他知道,也就是和他相连的设备A,所以他就把200.200.200.200改为了设备A的地址并发给了设备A,这样,请求就变成了这样子:

【我是192.168.1.3,我要连接192.168.1.2】

此时设备A终于收到了这个TCP一次握手的消息,于是他尝试建立TCP连接,便发出了回信(TCP第二次握手)

【192.168.1.3你好,我是192.168.1.2,我同意连接】

TCP-2

这条消息显然是内网到内网的通讯,于是未经修改得来到了设备B。 设备B认真的读了这条回信,然后——

“喵喵喵?这人谁啊我不认识呀?我没要连接他啊。。肯定是发错了”

喵喵喵

于是这条消息因为来源未知被扔掉了。与此同时设备B因为没收到设备A的回信,便依然一遍又一遍的重发第一次握手的信息:

【我是192.168.1.3,我要连接200.200.200.200】

【我是192.168.1.3,我要连接200.200.200.200】

【我是192.168.1.3,我要连接200.200.200.200】

…… …… 直到尝试超时,连接TIMEOUT,失败。

(可怜的设备B一脸怨妇幽怨脸ing)

如果有回流?

回顾上面的消息传输过程,可以看到问题的根源在于第一次消息在经过路由反弹回内网的时候,本来的内网-外网通讯变成了内网-内网通讯,而后的回复消息便直接经由内网传输而不再经过外部节点,使得回复消息与请求消息无法匹配,被一直丢弃。

那么如何解决呢?路由回流便是这样的设计。所谓回流,就是在经过路由修改第一次请求的目的地的同时,将第一次请求的来源也修改为路由本身的ip,这样消息回复便直接传输到了路由上,路由可以根据自己的NAT表继续进行反向转换,使得消息正确的传达到请求客户端。

首先,设备B第一次连接设备A,(TCP第一次握手),那么他发出了第一条消息依旧没变:

【我是192.168.1.3,我要连接200.200.200.200】

TCP-1

这个消息传到路由器192.168.1.0,此时路由器不仅仅进行了目标地址转换,也进行了源地址转换,即目的地改为真正的目的地ip,源改为路由器ip。于是变成了这样

【我是192.168.1.0,我要连接192.168.1.2】

请注意此时的消息体与不使用回源的区别:(【我是192.168.1.3,我要连接192.168.1.2】)

随后,设备A按照原则对这个消息进行了回复(TCP第二次握手):

【192.168.1.0你好,我是192.168.1.2,我同意连接】

TCP-2

此消息沿原始链路抵达了路由器,路由的两次转换操作(即NAT操作)会进行反向还原,这个消息就被还原为如下:

【192.168.1.3你好,我是200.200.200.200,我同意连接】

消息抵达设备B,与原始请求匹配为合法的回复消息,那么设备B发出第三次握手信息:

【200.200.200.200你好,我是192.168.1.3,我也同意连接】

TCP-3

这条消息传输与第一次消息相同,最终两台设备成功找到了对方。

现实情况

讲完了原理性的东西,那么我们来看一看真实环境下的消息是怎么样子的,我们使用Wireshark抓包,具体的ip是这样的:

本机ip:10.20.66.33

服务器ip:10.19.124.30 对应公网ip 59.78.171.6

本机执行 curl 59.78.171.6

首先我们能看到大量发自10.20.66.33到59.78.171.6的第一次握手消息 SYN

TCP-1

而与此同时,我们还可以看到大量来自10.19.124.30到10.20.66.33的TCP SYN/ACK

TCP-1

这些消息全部被抛弃了。(真是可怜)

解决方案

那么,这样的问题有什么解决方案呢?

主流方法有二:

  1. 治本之道,修改路由,正确配置路由回源使得包可以正确传输。

  2. 治标之道,内网假设单独的DNS服务器,通过劫持DNS请求直接返回服务器内网ip,使得网络访问不再需要公网-内网ip转换。

我校采用的便是后者的方案。其中前者可以真正解决问题,后者看似可以实则问题重重,首先,对DNS的强行劫持,断绝了用户自主选择公共DNS的可能,倘若这个DNS的配置本身有错误或者系统版本过旧,那么客户便被迫要忍受它的服务而没有别的办法。

另一方面,在我校实际上发生的情况就是不知为何,DNS似乎总有这样那样的原因使得记录并没有按照预想被篡改,就像gradebot.org一样,有的人上得了,有的人却无论如何就是打不开。这样的问题本质上就是违反了DNS的权威性和自主选择的权利。

Geek Pie的解决方案

对此,在最近一周里,GeekPie尝试了若干种不同的方案试图更好的解决这个问题。

方案1 eDNS分线路解析

我们首先想到的,就是利用CDN的配置原理来设置公网DNS,具体原理是利用了RFC2671 eDNS(Extension Mechanisms for DNS),这是DNS协议的一个后期扩展协议,在其中规定了DNS包的额外信息存储位的方案。同时Google在这个协议之下,提出了edns-client-subnet(RFC7871),这个协议简而言之,就是允许DNS请求的时候携带更多的信息,其中就包括了客户端ip地址,这样DNS解析服务器就可以针对不同的来源返回不同的服务器。

这项技术广泛应用于CDN解析上,各大DNS服务的所谓线路区别就是使用edns-client-subnet来实现的。考虑到兼容性,edns-client-subnet协议实际上是这样描述的:对于每一级DNS查询服务器,当来源携带了edns-client-subnet信息时,保持原数据不变进行查询,来源没有携带edns-client-subnet信息时,以来源ip作为客户端ip增写入请求中。

所以,倘若解析链路上存在不支持edns-client-subnet的旧版系统,那么edns-client-subnet获得的也有可能是中间最后一个没有支持edns-client-subnet的DNS服务器的地址。这个现象在教育网内非常显著,因为基本上教育网除了大区服务器以外都在使用非常古老的bind版本,我们通过一系列分段线路测试,可以确定十余个ip段的来源能够有效对上科大校园生效,这些ip段大部分来自北京教育网中心,甚至是上游电信出口DNS,可见链路上的DNS版本之旧简直难以忍受。

如果我们直接用这种方案进行处理的话,最大的问题便是直接影响了整个教育网的访问。考虑到我们mirrors服务有比较大比例的教育网请求,所以这样做难以切实解决问题。

方案2 iptables强行修改

我们曾经考虑过的第二方案是使用iptables在服务器出口方向拦截,倘若我们强制修改所有的对外包的源头为自己的外网ip,是不是就可以在一定程度上实现双向的沟通了呢?然而,很可惜的是,我们一番尝试之后发现,在不修改内核和路由代码的情况下,很难单独修改SYN/ACK包的源ip,大多数源ip依然无法正常捕获。

方案3 外网代理

其实整个问题的根源在于内网无法直接联络内部的服务器,那么如果说我们在外网设立一台类似于路由一样的设备,在这台设备上简单地进行包转发工作,那么便也间接实现了这样的效果。(当然会耗费很大的流量罢了)

于是我们在腾讯云上买了一个最便宜的服务器,实测从校内到这台服务器的延迟稳定在2ms到3ms,进行简单转发是不会对速度有太大影响的。

于是使用了这样一段代码:

sudo iptables -t nat -I PREROUTING 1 ! -s 59.78.171.6 -p tcp --dport 80 -j DNAT --to 59.78.171.6

sudo iptables -t nat -I PREROUTING 1 ! -s 59.78.171.6 -p tcp --dport 443 -j DNAT --to 59.78.171.6

sudo iptables -t nat -I PREROUTING 1 ! -s 59.78.171.6 -p tcp --dport 1024: -j DNAT --to 59.78.171.6

sudo iptables -t nat -I PREROUTING 1 ! -s 59.78.171.6 -p udp --dport 1024: -j DNAT --to 59.78.171.6

########################################

sudo iptables -t nat -I POSTROUTING 1 -d 59.78.171.6 -p tcp --dport 80 -j SNAT --to 10.105.7.28

sudo iptables -t nat -I POSTROUTING 1 -d 59.78.171.6 -p tcp --dport 443 -j SNAT --to 10.105.7.28

sudo iptables -t nat -I POSTROUTING 1 -d 59.78.171.6 -p tcp --dport 1024: -j SNAT --to 10.105.7.28

sudo iptables -t nat -I POSTROUTING 1 -d 59.78.171.6 -p udp --dport 1024: -j SNAT --to 10.105.7.28

其中,59.78.171.6是我们校内服务器的外网ip,10.105.7.28是腾讯云服务器的内网ip。这样就对80 442 以及大于1024的所有端口的tcp以及udp包实现了转发。尤其需要注意,此处我们使用了DNAT修改了包目标到真正的校内服务器,同时使用了SNAT修改来源为当前的腾讯云服务器,原因嘛,自然就是上文说到的路由回源操作。

不过这样有一个最重要的问题,对于服务器而言,经过了SNAT之后是完全看不到客户端来源的,这样对于特定客户端,尤其是区别校内私有服务的时候变得非常困难。

反向代理

转来转去,最后还是回到了反向代理的路子上来,因为nginx的反向代理是在应用层做的,相比iptables,可以直接修改http的头内容,这样就可以记录实际的转发过程,也就间接保存了真正的用户源ip数据。具体设定在这样一段代码中:

proxy_set_header X-Real-IP $realip;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-NginX-Proxy true;

其中$realip的计算方式经过单独处理:

set $realip $remote_addr;
if ($http_x_forwarded_for ~ "^(\d+\.\d+\.\d+\.\d+)") {
    set $realip $1;
}

一般而言,网上的配置多直接写作proxy_set_header X-Real-IP $remote_addr;这样做对于多级反代是错误的,导致实际Real-IP为倒数第二次反代服务器的ip。我们这样的写法则是从X-Forwarded-For中抽取源ip信息,更能保障数据正确。

当然,实际执行中,我们还设置了大量的转发判断和逻辑过程,以求尽可能保证正常访问,同时对于某些大流量访问进行合理的判断转发来节约流量。