P2P 通信最大的障碍就是 NAT(网络地址转换),NAT 使得局域网内的设备可以与公网进行通讯,但是不同 NAT 下的设备之间通讯将会变得很困难。UDP 打洞就是用来使得设备间绕过 NAT 进行通讯的一种技术。
1.什么是NAT?
NAT(Network Address Translation,网络地址转换)是一种网络地址翻译技术,主要是将内部的私有IP地址(private IP)转换成可以在公网使用的公网IP(public IP)。
2.为什么会有NAT?
时光回到上个世纪80年代,当时的人们在设计网络地址的时候,觉得再怎么样也不会有超过32bit位长即2的32次幂台终端设备连入互联网,再加上增加ip的长度(即使是从4字节增到6字节)对当时设备的计算、存储、传输成本也是相当巨大的。后来逐渐发现IP地址不够用了,然后就NAT就诞生了!(虽然ipv6也是解决办法,但始终普及不开来,而且未来到底ipv6够不够用仍是未知)。
因此,NAT技术能够兴起的原因还是因为在我们国家公网IP地址太少了,不够用,所以才会采取这种地址转换的策略。可见,NAT的本质就是让一群机器公用同一个IP,这样就暂时解决了IP短缺的问题。
3.NAT有什么优缺点?
优势其实上面已经刚刚讨论过了,根据定义,比较容易看出,NAT可以同时让多个计算机同时联网,并隐藏其内网IP,因此也增加了内网的网络安全性;此外,NAT对来自外部的数据查看其NAT映射记录,对没有相应记录的数据包进行拒绝,提高了网络安全性。
那么,NAT与此同时也带来一些弊端:首先是,NAT设备会对数据包进行编辑修改,这样就降低了发送数据的效率;此外,各种协议的应用各有不同,有的协议是无法通过NAT的(不能通过NAT的协议还是蛮多的),这就需要通过穿透技术来解决。我们后面会重点讨论穿透技术。
首先,NAT A 网下的设备 1(192.168.1.101)想与某公网 IP 通讯,设备 1 将包发给 NAT A,然后 NAT A 对源 IP 进行转换(123.122.53.20)发给 NAT B(中间可能还会经过多重 NAT)。
这样做的目的是,NAT B 并不知晓 NAT A 下的各个设备,他只能与 NAT A 本身通讯,因此发送给 NAT B 的包源 IP 必须是 NAT A 的公网 IP,不然 NAT B 没有办法进行回复。
接下来 NAT B 将回复包再发回 NAT A,此时就是 NAT 发挥作用的时候了,NAT A 现在要做的就是将包再分发回之前的设备,如何确定要发给谁呢?NAT 中记录了一张表,之前 192.168.1.101 通过 2333 端口与 42.120.241.46 端口 443 通讯了,并且 NAT A 是用 60001 的端口转发出去的,那么这次接受到发往该 NAT 60001 端口的包时就应该再通过 2333 端口转发给 192.168.1.101。经过这样的过程,NAT A 下的设备都可以连接到互联网了!
1.网络访问只能先由私网侧发起,公网无法主动访问私网主机;
2.NAT网关在两个访问方向上完成两次地址的转换或翻译,出方向做源信息替换,入方向做目的信息替换;
3.NAT网关的存在对通信双方是保持透明的;
4.NAT网关为了实现双向翻译的功能,需要维护一张关联表,把会话的信息保存下来。
NAT分为基础型NAT(静态NAT即Static NAT,动态NAT即Dynamic NAT/Pooled NAT)和NAPT(Network Address Port Translation)两种,但由于基础型NAT已不常用,我们通常提到的NAT就代指NAPT。NAPT是指网络地址转换过程中使用了端口复用技术,即PAT(Port address Translation)。
a) Full Cone NAT(完全圆锥型):
特点:IP和端口都不受限。
表现形式:从同一私网地址端口192.168.0.8:4000发至公网的所有请求都映射成同一个公网地址端口1.2.3.4:62000 ,192.168.0.8可以收到任意外部主机发到1.2.3.4:62000的数据报。
b) Restricted Cone NAT (限制圆锥型):
特点:IP受限,端口不受限。
表现形式: 从同一私网地址端口192.168.0.8:4000发至公网的所有请求都映射成同一个公网地址端口1.2.3.4:62000,只有当内部主机192.168.0.8先给服务器C 6.7.8.9发送一个数据报后,192.168.0.8才能收到6.7.8.9发送到1.2.3.4:62000的数据报。
c)Port Restricted Cone NAT(端口限制圆锥型):
特点:IP和端口都受限。
表现形式:从同一私网地址端口192.168.0.8:4000发至公网的所有请求都映射成同一个公网地址端口1.2.3.4:62000,只有当内部主机192.168.0.8先向外部主机地址端口6.7.8.9:8000发送一个数据报后,192.168.0.8才能收到6.7.8.9:8000发送到1.2.3.4:62000的数据报。
特点:对每个外部主机或端口的会话都会映射为不同的端口(洞)。
表现形式:只有来自同一内部IP:PORT、且针对同一目标IP:PORT的请求才被NAT转换至同一个公网(外部)IP:PORT,否则的话,NAT将为之分配一个新的外部(公网)IP:PORT。并且,只有曾经收到过内部主机请求的外部主机才能向内部主机发送数据包。
对称的NAT不保证所有会话中的(私有地址,私有端口)和(公开IP,公开端口)之间绑定的一致性。相反,它为每个新的会话分配一个新的端口号。
不同的NAT组合打洞的方式也有所不同,有点可以打洞,有的则不能打洞,如两个都是对称型设备则无法实现打洞。不同组合打洞结果如下:
Peer A | Peer B | 是否可以打洞 |
---|---|---|
全锥型 | 全锥型 | 是 |
全锥型 | 受限锥型 | 是 |
全锥型 | 端口受限锥型 | 是 |
全锥型 | 对称型 | 是 |
受限锥型 | 受限锥型 | 是 |
受限锥型 | 端口受限锥型 | 是 |
受限锥型 | 对称型 | 是 |
端口受限锥型 | 端口受限锥型 | 是 |
端口受限锥型 | 对称型 | 否 |
对称型 | 对称型 | 否 |
前提条件:有一个公网的Server并且绑定了两个公网IP(IP-1,IP-2)。这个Server做UDP监听(IP-1,Port-1),(IP-2,Port-2)并根据客户端的要求进行应答。
第一步:检测客户端是否有能力进行UDP通信以及客户端是否位于NAT后?
客户端建立UDP socket,然后用这个socket向服务器(IP-1,Port-1)发送数据包,要求服务器返回客户端的IP和Port。客户端发送请求后立即开始接受数据包,要设定socket Timeout(300ms),防止无限堵塞. 重复这个过程若干次。如果每次都超时,无法接受到服务器的回应,则说明客户端无法进行UDP通信,可能是防火墙或NAT阻止UDP通信,这样的客户端也就不能P2P了(检测停止)。
当客户端能够接收到服务器的回应时,需要把服务器返回的客户端(IP,Port)和这个客户端socket的 (LocalIP,LocalPort)比较。如果完全相同则客户端不在NAT后,这样的客户端具有公网IP可以直接监听UDP端口接收数据进行通信(检测停止)。否则客户端在NAT后要做进一步的NAT类型检测(继续)。
第二步:检测客户端NAT是否是Full Cone NAT?
客户端建立UDP socket,然后用这个socket向服务器(IP-1,Port-1)发送数据包,要求服务器用另一对(IP-2,Port-2)响应客户端的请求往回发一个数据包。客户端发送请求后立即开始接受数据包,要设定socket Timeout(300ms),防止无限堵塞. 重复这个过程若干次。如果每次都超时,无法接受到服务器的回应,则说明客户端的NAT不是一个Full Cone NAT,具体类型有待下一步检测(继续)。
如果能够接受到服务器从(IP-2,Port-2)返回的应答UDP包,则说明客户端是一个Full Cone NAT,这样的客户端能够进行UDP-P2P通信(检测停止)。
第三步:检测客户端NAT是否是Symmetric NAT?
客户端建立UDP socket,然后用这个socket向服务器(IP-1,Port-1)发送数据包,要求服务器返回客户端的IP和Port, 客户端发送请求后立即开始接受数据包,要设定socket Timeout(300ms),防止无限堵塞. 重复这个过程直到收到回应(一定能够收到,因为第一步保证了这个客户端可以进行UDP通信)。
用同样的方法用同一个socket向服务器的(IP-2,Port-2)发送数据包要求服务器返回客户端的IP和Port。
比较上面两个过程从服务器返回的客户端(IP,Port),如果两个过程返回的(IP,Port)有一个不同(IP不同或者port不同),则说明客户端为Symmetric NAT,这样的客户端无法进行UDP-P2P通信(检测停止)。
否则是Restricted Cone NAT,是否为Port Restricted Cone NAT有待检测(继续)。
第四步:检测客户端NAT是否是Restricted Cone NAT还是Port Restricted Cone NAT?
客户端建立UDP socket,然后用这个socket向服务器(IP-1,Port-1)发送数据包,要求服务器用IP-1和一个不同于Port-1的端口发送一个UDP数据包响应客户端, 客户端发送请求后立即开始接受数据包,要设定socket Timeout(300ms),防止无限堵塞. 重复这个过程若干次。如果每次都超时,无法接受到服务器的回应,则说明客户端是一个Port Restricted Cone NAT,如果能够收到服务器的响应则说明客户端是一个Restricted Cone NAT。以上两种NAT都可以进行UDP-P2P通信。
根据客户端的不同,客户端之间进行P2P传输的方法也略有不同,这里介绍了现有的穿越中间件进行P2P通信的几种技术。
这是最可靠但也是最低效的一种P2P通信实现。其原理是通过一个有公网IP的服务器中间人对两个内网客户端的通信数据进行中继和转发。如下图所示:
Server S
|
|
+----------------------+----------------------+
| |
NAT A NAT B
| |
| |
Client A Client B
客户端A和客户端B不直接通信,而是先都与服务端S建立链接,然后再通过服务器S和对方建立的通路来中继传递的数据。这种方法的缺陷很明显, 当链接的客户端变多之后,会显著增加服务器的负担,完全没体现出P2P的优势。但这种方法的好处是能保证成功,因此在实践中也常作为一种备选方案。
第二种方法在当两个端点中有一个不存在中间件的时候有效。例如,客户端A在NAT之后,而客户端B拥有全局IP地址,如下图:
Server S
18.181.0.31:1235
|
|
+----------------------+----------------------+
| |
NAT A |
155.99.25.11:62000 |
| |
| |
Client A Client B
10.0.0.1:1234 138.76.29.7:1234
客户端A内网地址为10.0.0.1,且应用程序正在使用TCP端口1234。A和服务器S建立了一个链接,服务器的IP地址为18.181.0.31,监听1235端口。NAT A给客户端A分配了TCP端口62000,地址为NAT的公网IP地址155.99.25.11, 作为客户端A对外当前会话的临时IP和端口。因此S认为客户端A就是155.99.25.11:62000。而B由于有公网地址,所以对S来说B就是138.76.29.7:1234。
当客户端B想要发起一个对客户端A的P2P链接时,要么链接A的外网地址155.99.25.11:62000,要么链接A的内网地址10.0.0.1:1234,然而两种方式链接都会失败。
链接10.0.0.1:1234失败自不用说,为什么链接155.99.25.11:62000也会失败呢?来自B的TCP SYN握手请求到达NAT A的时候会被拒绝,因为对NAT A来说只有外出的链接才是允许的。
在直接链接A失败之后,B可以通过S向A中继一个链接请求,从而从A方向“逆向“地建立起A-B之间的点对点链接。
很多当前的P2P系统都实现了这种技术,但其局限性也是很明显的,只有当其中一方有公网IP时链接才能建立。越来越多的情况下, 通信的双方都在NAT之后,因此就要用到我们下面介绍的第三种技术了。
第三种P2P通信技术,被广泛采用的,名为“P2P打洞“。P2P打洞技术依赖于通常防火墙和锥型NAT允许正当的P2P应用程序在中间件中打洞且与对方建立直接链接的特性。 以下主要考虑两种常见的场景,以及应用程序如何设计去完美地处理这些情况。第一种场景代表了大多数情况,即两个需要直接链接的客户端处在两个不同的NAT 之后;第二种场景是两个客户端在同一个NAT之后,但客户端自己并不需要知道。
假设客户端A和客户端B的地址都是内网地址,且在不同的NAT后面。A、B上运行的P2P应用程序和服务器S都使用了UDP端口1234,A和B分别初始化了 与Server的UDP通信,地址映射如图所示:
Server S
18.181.0.31:1234
|
|
+----------------------+----------------------+
| |
NAT A NAT B
155.99.25.11:62000 138.76.29.7:31000
| |
| |
Client A Client B
10.0.0.1:1234 10.1.1.3:1234
现在假设客户端A打算与客户端B直接建立一个UDP通信会话。如果A直接给B的公网地址138.76.29.7:31000发送UDP数据,NAT B将很可能会无视进入的数据(除非是Full Cone NAT),因为源地址和端口与S不匹配,而最初只与S建立过会话。B往A直接发信息也类似。
假设A开始给B的公网地址发送UDP数据的同时,给服务器S发送一个中继请求,要求B开始给A的公网地址发送UDP信息。A往B的输出信息会导致NAT A打开 一个A的内网地址与与B的外网地址之间的新通讯会话,B往A亦然。一旦新的UDP会话在两个方向都打开之后,客户端A和客户端B就能直接通讯, 而无须再通过引导服务器S了。
UDP打洞技术有许多有用的性质。一旦一个的P2P链接建立,链接的双方都能反过来作为“引导服务器”来帮助其他中间件后的客户端进行打洞,极大减少了服务器的负载。应用程序不需要知道中间件具体是什么(如果有的话),因为以上的过程在没有中间件或者有多个中间件的情况下也一样能建立通信链路。
现在考虑这样一种情景,两个客户端A和B正好在同一个NAT之后(而且可能他们自己并不知道),因此在同一个内网网段之内。 客户端A和服务器S建立了一个UDP会话,NAT为此分配了公网端口62000,B同样和S建立会话,分配到了端口62001,如下图:
Server S
18.181.0.31:1234
|
|
NAT
A-S 155.99.25.11:62000
B-S 155.99.25.11:62001
|
+----------------------+----------------------+
| |
Client A Client B
10.0.0.1:1234 10.1.1.3:1234
假设A和B使用了上节介绍的UDP打洞技术来建立P2P通路,那么会发生什么呢?
首先A和B会得到由S观测到的对方的公网IP和端口号,然后给对方的地址发送信息。 两个客户端只有在NAT允许内网主机对内网其他主机发起UDP会话的时候才能正常通信,我们把这种情况称之为”回环传输“(lookback translation),因为从内部到达NAT的数据会被“回送”到内网中而不是转发到外网。
例如,当A发送一个UDP数据包给B的公网地址时,数据包最初有源IP地址和端口地址10.0.0.1:1234和 目的地址155.99.25.11:62001,NAT收到包后,将其转换为源155.99.25.11:62000(A的公网地址)和目的10.1.1.3:1234,然后再转发给B。即便NAT支持回环传输,这种转换和转发在此情况下也是没必要的,且有可能会增加A与B的对话延时和加重NAT的负担。
对于这个情况,优化方案是很直观的。当A和B最初通过S交换地址信息时,他们应该包含自身的IP地址和端口号(从自己看),同时也包含从服务器看的自己的 地址和端口号。然后客户端同时开始从对方已知的两个的地址中同时开始互相发送数据,并使用第一个成功通信的地址作为对方地址。
如果两个客户端在同一个 NAT后,发送到对方内网地址的数据最有可能先到达,从而可以建立一条不经过NAT的通信链路;如果两个客户端在不同的NAT之后,发送给对方内网地址的数据包根本就到达不了对方,但仍然可以通过公网地址来建立通路。
值得一提的是,虽然这些数据包通过某种方式验证,但是在不同NAT的情况下完全有可能会导致A往B发送的信息发送到其他A内网网段中无关的结点上去的。
在一些拓朴结构中,可能会存在多级NAT设备,在这种情况下,如果没有关于拓朴的具体信息, 两个Peer要建立“最优”的P2P链接是不可能的,下面来说为什么。以下图为例:
Server S
18.181.0.31:1234
|
|
NAT X
A-S 155.99.25.11:62000
B-S 155.99.25.11:62001
|
|
+----------------------+----------------------+
| |
NAT A NAT B
192.168.1.1:30000 192.168.1.2:31000
| |
| |
Client A Client B
10.0.0.1:1234 10.1.1.3:1234
假设NAT X是一个网络提供商ISP部署的工业级NAT,其下子网共用一个公网地址155.99.25.11,NAT A和NAT B分别是其下不同用户的网关部署的NAT。只有服务器S 和NAT X有全局的路由地址。Client A在NAT A的子网中,同时Client B在NAT B的子网中,每经过一级NAT都要进行一次网络地址转换。
现在假设A和B打算建立直接P2P链接,用一般的方法(通过Server S来打洞)自然是没问题的,那能不能优化呢?一种想当然的优化办法是A直接把信息发送给NAT B的内网地址192.168.1.2:31000,且B通过NAT B把信息发送给A的路由地址192.168.1.1:30000,不幸的是,A和B都没有办法得知这两个目的地址,因为S只看见了客户端 ‵全局‵地址155.99.25.11。退一步说,即便A和B通过某种方法得知了那些地址,我们也无法保证他们是可用的。因为ISP分配的子网地址可能和NAT A B分配的子网地址 域相冲突。因此客户端没有其他选择,只能使用S来进行打洞并进行回环传输。
假设A现在希望建立一条到B的udp会话,那么这个建立基本流程是:
服务端 server.go
package main
import (
"fmt"
"net"
"time"
)
const serverPort = 9000
func main() {
// 监听UDP
conn, err := net.ListenUDP("udp4", &net.UDPAddr{
IP: net.IPv4zero,
Port: serverPort,
})
if err != nil {
fmt.Printf("监听失败:%s\n", err)
return
}
fmt.Printf("开始监听:[%s]\n", conn.LocalAddr().String())
// 释放资源
defer conn.Close()
// 存放连接的客户端二元组
peers := make([]*net.UDPAddr, 0, 2)
b := make([]byte, 500)
for {
n, addr, err := conn.ReadFromUDP(b)
if err != nil {
fmt.Printf("读取信息失败:%s\n", err)
return
}
// 将链接存起来
peers = append(peers, addr)
// 接受到的消息
fmt.Printf("收到客户端[%s]的消息:%s\n", addr.String(), b[:n])
// 如果有2条链接了,就给客户端响应另一个客户端二元组
if len(peers) == 2 {
fmt.Printf("可以进行UDP打洞,建立[%s]--[%s]的连接\n", peers[0].String(), peers[1].String())
conn.WriteToUDP([]byte(peers[1].String()), peers[0])
conn.WriteToUDP([]byte(peers[0].String()), peers[1])
time.Sleep(3 * time.Second)
fmt.Println("中转服务器退出,仍不影响peers通信")
return
}
}
}
客户端 client.go
package main
import (
"fmt"
"net"
)
// 服务器地址(注:改为真实使用的服务器地址)
const serverAddr = "127.0.0.1:9000"
// 本地客户端端口
const srcPort = 8888
func main() {
// 第一步:与服务建立UDP连接,接收服务器发送的另一个客户端的地址信息
srcAddr := &net.UDPAddr{IP: net.IPv4zero, Port: srcPort}
raddr, _ := net.ResolveUDPAddr("udp4", serverAddr)
fmt.Printf("本机地址[%s]\n", srcAddr)
conn, err := net.DialUDP("udp4", srcAddr, raddr)
if err != nil {
fmt.Printf("连接服务器失败:%s\n", err)
return
}
_, err = conn.Write([]byte("hi"))
if err != nil {
fmt.Printf("与服务器发送消息失败:%s\n", err)
return
}
fmt.Println("与服务器发送消息成功,等待响应...")
// 开始等待服务器响应消息
b := make([]byte, 500)
n, _, err := conn.ReadFromUDP(b)
if err != nil {
fmt.Printf("接收数据失败:%s\n", err)
return
}
// 与服务器的连接要断开!!
conn.Close()
// 第二步:与另一个客户端建立UDP连接,以此“打洞”
// 另一个客户端
dstAddr, _ := net.ResolveUDPAddr("udp4", string(b[:n]))
udpConn, err := net.DialUDP("udp4", srcAddr, dstAddr)
if err != nil {
fmt.Printf("与客户端[%s]创建UDP失败:%s\n", dstAddr, err)
return
}
defer udpConn.Close()
// 向另一个客户端发送一条udp消息(对方的nat设备会丢弃该消息,非法来源),
// 用意是在自身的nat设备打开一条可进入的通道,这样对方就可以发过来udp消息
_, err = udpConn.Write([]byte("你好"))
if err != nil {
fmt.Printf("与客户端发送消息失败:%s\n", err)
return
}
fmt.Printf("与客户端[%s]建立成功,可以通信\n", dstAddr)
// 标准输入内容
go func() {
msg := ""
for {
fmt.Scanln(&msg)
_, err = udpConn.Write([]byte(msg))
if err != nil {
fmt.Printf("发送信息失败:%s\n", err)
continue
}
}
}()
// 接收另一个客户端信息
data := make([]byte, 500)
for {
dnum, _, err := udpConn.ReadFromUDP(data)
if err != nil {
fmt.Printf("接收信息失败:%s\n", err)
continue
}
fmt.Printf("来自[%s]的信息 >> %s\n", dstAddr, data[:dnum])
}
}