什么,咱俩的NAT竟然还不一样?

2020年06月01日 16点热度 0人点赞 0条评论

一、缘起

疫情期间在家办公,相信大家都会有从家里(A)连到公司服务器(B)的需求。然而由于IPv4地址资源的紧缺,以及IPv6缓慢的普及速度,导致现在大部分的网络终端都是以NAT为主。就是说,公司服务器(B)没有一个公网IP,家里(A)也没有公网IP,两个终端之间谁都没法连谁。

目前一个比较好的方案就是找一台VPS作中转,使用FRP也好,AutoSSH也好,总之是让B主动跟VPS建立一个长期连接(B->VPS),A再跟VPS建立连接(A->VPS),然后VPS转发B->VPS, A->VPS的连接,这样A与B就能正常通信了(这个有点像隐藏服务的握手)。但是这个方案有一个明显的缺点,那就是速度限制:A如果想要给B发送数据,那么速率一定是A->VPS与B->VPS两个连接速度的最小值。毕竟是走了两跳,如果需要传输大文件之类的,还是不太理想。

二、事与愿违

好在,我找到了一个貌似可行的解决方案——NPS

NPS其实是跟FRP差不多的工具,都是需要一个VPS作为中控,然后两边AB终端单独运行客户端,与中控建立连接。但是在这里,NPS还有一个另外的功能——P2P转发。这个P2P转发的思路真的不错,之前从来没有考虑过,现在仔细想想感觉还真的挺有道理的。仔细想一下,难道两个NAT的网络终端真的无法直接通信吗?那平常我迅雷,uTorrent怎么做的种啊?顺着这个视频的教程,在加上另一个教程,我成功的把整个系统跑起来了,然而。。。还是不能从家里连到公司!

三、登高寻路

3.1 NAT的四种类型

为了解决这个问题,我决定仔细研究一下NAT,找到了一组比较详细的教程,原来NAT一共有四种类型:

  •  完全锥型NAT(Full Cone NAT,后面简称FC)

特点:IP和端口都不受限。

表现形式:将来自内部同一个IP地址同一个端口号(IP_IN_A : PORT_IN_A)的主机监听/请求,映射到公网IP某个端口(IP_OUT_B : PORT_OUT_B)的监听。任意外部IP地址与端口对其自己公网的IP这个映射后的端口访问(IP_OUT_B : PORT_OUT_B),都将重新定位到内部这个主机(IP_IN_A : PORT_IN_A)。该技术中,基于C/S架构的应用可以在任何一端发起连接。是不是很绕啊。再简单一点的说,就是,只要客户端,由内到外建立一个映射(NatIP:NatPort -> A:P1)之后,其他IP的主机B或端口A:P2都可以使用这个洞给客户端发送数据。见下图(图片来自网络)。

通俗易懂:快速理解P2P技术中的NAT穿透原理_2.png

  • 受限锥型NAT(Restricted Cone NAT)

特点:IP受限,端口不受限。

表现形式:与完全锥形NAT不同的是,在公网映射端口后,并不允许所有IP进行对于该端口的访问,要想通信必需内部主机对某个外部IP主机发起过连接,然后这个外部IP主机就可以与该内部主机通信了,但端口不做限制。举个栗子。当客户端由内到外建立映射(NatIP:NatPort –> A1),A机器可以使用他的其他端口(P2)主动连接客户端,但B机器则不被允许。因为IP受限啦,但是端口随便。见下图(绿色是允许通信,红色是禁止通信)。

通俗易懂:快速理解P2P技术中的NAT穿透原理_4.png

  • 端口受限型NAT(Port Restricted Cone NAT)

特点:IP和端口都受限。

表现形式:该技术与受限锥形NAT相比更为严格。除具有受限锥形NAT特性,对于回复主机的端口也有要求。也就是说:只有当内部主机曾经发送过报文给外部主机(假设其IP地址为A且端口为P1)之后,外部主机才能以公网IP:PORT中的信息作为目标地址和目标端口,向内部主机发送UDP报文,同时,其请求报文的IP必须是A,端口必须为P1(使用IP地址为A,端口为P2,或者IP地址为B,端口为P1都将通信失败)。例子见下图。这一要求进一步强化了对外部报文请求来源的限制,从而较Restrictd Cone更具安全性。

通俗易懂:快速理解P2P技术中的NAT穿透原理_5.png

  • 4.4 对称型NAT(Symmetric NAT)

特点:对每个外部主机或端口的会话都会映射为不同的端口(洞)。

表现形式:只有来自同一内部IP:PORT、且针对同一目标IP:PORT的请求才被NAT转换至同一个公网(外部)IP:PORT,否则的话,NAT将为之分配一个新的外部(公网)IP:PORT。并且,只有曾经收到过内部主机请求的外部主机才能向内部主机发送数据包。内部主机用同一IP与同一端口与外部多IP通信。客户端想和服务器A(IP_A:PORT_A)建立连接,是通过NAT映射为NatIP:NatPortA来进行的。而客户端和服务器B(IP_B:PORT_B)建立连接,是通过NAT映射为NatIP:NatPortB来进行的。即同一个客户端和不同的目标IP:PORT通信,经过NAT映射后的公网IP:PORT是不同的。此时,如果B想要和客户端通信,也只能通过NatIP:NatPortB(也就是紫色的洞洞)来进行,而不能通过NatIP:NatPortA(也就是黄色的洞洞)。

通俗易懂:快速理解P2P技术中的NAT穿透原理_6.png

其实说到底,对称型就是访问不同的目的IP:Port就加一条新的映射。其他三种就是只要我源IP:Port在NAT上加了个映射,以后出去的连接都会通过这个映射走了。但是回来的时候,如果这个外面的IP:Port被主动访问过才能转发,这就是端口限制型。如果是外面的IP被主动访问过才能转发,这就是限制型。最后不管有没有访问过这个IP,任何人都能通过这个NAT的映射访问到内网,那就是全锥型。

3.2 NAT之间如何直连?

所以,两个NAT里面的终端应该怎么直连?就是用NAT上的映射啊。A的IP:Port在NAT上新建一个映射NatIP:NatPort,B访问这个NatIP:NatPort就可以跟A通信了,是不是很简单?其实也没有那么简单,理论上来说,如果A和B都是对称型NAT的话,两个终端理论上来说是无法直接通信的。为什么呢?A给B发送的时候,肯定不能直接访问B的内网地址,因此需要写B的公网IP,而Port则无法固定,有65535中选择。而与此同时,A所映射出来的NatPortA也有65535种选择。因此,当A给B发送的时候,

A到B的端口映射有 65535*65535种组合。但是,如果B想给A回消息,只有映射到正确的NatPortB上才能成功发送,因此成功的概率时1/65535。这个概率可以说是聊胜于无了。

幸好,我用Pystun工具测试了一下,公司的网络(B)是对称型NAT,家里的网络(A)是端口限制型NAT,理论上是可行的。首先使用A的(IPA,PortA)访问(NatIPB, NatPortBx),创建一条(IPA,PortA)到(NatIPA, NatPortA)的绑定记录。然后从(NatIPB, NatPortBx)向回访问(NatIPA, NatPortA),就能使AB互通了。那么这里,NatPortBx应该是什么呢?因为使对称型NAT,我们不知道B映射出来的端口啊。很简单,不知道就全跑一遍呗,这里的NatPortBx可以去0-65535,所有的包都发送一个,就能保证不管B从哪里映射出来,都可以正常通过(NatIPA, NatPortA)转发了。

四、柳暗花明

回到nps,为什么会连接失败呢?看了一下端口号之后,我感觉明白了什么。A每次都是随机选择的50个端口号向B建立连接,如果B映射出来后的NatPortB在这个里面,那就连接成功;如果不再,就重新再来一轮。一共65535个端口,这得试到哪年啊。

不过知道原因之后,这个问题就简单了,让它把65535个端口全试一遍不就行了。

通过日志,定位到nps的代码client/control.go中

        go func() {
            ports := getRandomPortArr(common.GetPortByAddr(remoteAddr3), common.GetPortByAddr(remoteAddr3)+interval*50)
            for i := 0; i <= 50; i++ {
                go func(port int) {
                    trueAddress := ip + ":" + strconv.Itoa(port)
                    logs.Trace("try send test packet to target %s", trueAddress)
                    remoteUdpAddr, err := net.ResolveUDPAddr("udp", trueAddress)
                    if err != nil {
                        return
                    }
                    ticker := time.NewTicker(time.Second * 2)
                    defer ticker.Stop()
                    for {
                        select {
                        case <-ticker.C:
                            if isClose {
                                return
                            }
                            if _, err := localConn.WriteTo([]byte(common.WORK_P2P_CONNECT), remoteUdpAddr); err != nil {
                                return
                            }
                        }
                    }
                }(ports[i])
                time.Sleep(time.Millisecond * 10)
            }
        }()

这里比较清楚的显示,A每次获取50个随机端口发送,这离65535也太远了。。。直接改成了下面这样,感觉整个人都舒坦了。

        go func() {
            // ports := getRandomPortArr(common.GetPortByAddr(remoteAddr3), common.GetPortByAddr(remoteAddr3)+interval*50)
            for i := 0; i <= 65535; i++ {
                go func(port int) {
                    trueAddress := ip + ":" + strconv.Itoa(port)
                    logs.Trace("try send test packet to target %s", trueAddress)
                    remoteUdpAddr, err := net.ResolveUDPAddr("udp", trueAddress)
                    if err != nil {
                        return
                    }
                    ticker := time.NewTicker(time.Second * 2)
                    defer ticker.Stop()
                    for {
                        select {
                        case <-ticker.C:
                            if isClose {
                                return
                            }
                            if _, err := localConn.WriteTo([]byte(common.WORK_P2P_CONNECT), remoteUdpAddr); err != nil {
                                return
                            }
                        }
                    }
                }(i)
                // time.Sleep(time.Millisecond * 10)
            }
        }()
最后编译通过,运行一下,马上连上了。。。

Mocky

保持饥渴的专注,追求最佳的品质

文章评论