P2P之UDP打洞
乐果 发表于 2023 年 07 月 28 日 标签:p2pudp
当今互联网到处存在着一些中间件(MIddleBoxes
),如NAT
和防火墙
,导致两个(不在同一内网)中的客户端无法直接通信。 这些问题即便是到了IPV6
时代也会存在,因为即使不需要NAT
,但还有其他中间件
如防火墙
阻挡了链接的建立。 目前部署的中间件
多都是在C/S
架构上设计的,其中相对隐匿的客户机主
动向周知的服务端
(拥有静态IP
地址和DNS
名称)发起链接请求。 大多数中间件
实现了一种非对称的通讯模型,即内网中的主机可以初始化对外的链接
,而外网的主机却不能初始化对内网的链接, 除非经过中间件
管理员特殊配置。
在中间件
为常见的NAPT
的情况下(也是本文主要讨论的),内网中的客户端
没有单独的公网IP
地址, 而是通过NAPT
转换,和其他同一内网用户共享一个公网IP
。这种内网主机隐藏在中间件
后的不可访问性对于一些客户端软件如浏览器来说并不是一个问题,因为其只需要初始化对外的链接,从某方面来看反而还对隐私保护有好处。然而在P2P
应用中, 内网主机(客户端
)需要对另外的终端
(Peer
)直接建立链接,但是发起者
和响应者
可能在不同的中间件
后面, 两者都没有公网IP
地址。而外部对NAT
公网IP
和端口
主动的链接或数据都会因内网未请求被丢弃掉。
本文讨论的就是如何跨越NAT
实现内网主机直接通讯的问题。
网络模型
假设客户端A
和客户端B
的地址都是内网地址,且在不同的NAT
后面。A
、B
上运行的P2P
应用程序和服务器S
都使用了UDP
端口9982
,A
和B
分别初始化了 与Server
的UDP
通信,地址映射如图所示:
Server S
207.148.70.129:9981
|
|
+----------------------|----------------------+
| |
NAT A NAT B
120.27.209.161:6000 120.26.10.118:3000
| |
| |
Client A Client B
10.0.0.1:9982 192.168.0.1:9982
现在假设客户端A
打算与客户端B
直接建立一个UDP
通信会话。如果A
直接给B
的公网地址120.26.10.118:3000
发送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
的udp
会话,那么这个建立基本流程是:
- A,B分别建立到Server S的udp会话,那么Server S此时是知道A,B各自的外网ip+端口
- Server S在和B的udp会话里告诉A的地址(外网ip+端口: 120.27.209.161:6000),同理把B的地址(120.26.10.118:3000)告诉A
- B向A地址(120.27.209.161:6000)发送一个”握手”udp包,打通A->B的udp链路
- 此时A可以向B(120.26.10.118:3000)发送udp包,A->B的会话建立成功
先决条件
能够完成打洞有几个先决条件:
- A,B所在的nat网络类型(Full cone, Restricted cone, Port-restricted cone, Symmetric NAT)
- 在一次udp会话期间,nat设备(路由器)会保持内网进程 inner_ip:inner_port <-> share_public_ip:share_port的映射关系,一般根据具体路由器实现,这个映射关系可以维持几分钟到几个小时不等
- 流程中第3步,nat A收到这个握手包后并不会转发给A,因为它发现自己的没有保存过B的地址,认为这是一个来历不明的包而直接丢弃,然而这个包的作用在于在nat B留下了A的记录,使得nat B认为A是可达或者说可通过了,这样当A->B再发送udp包时就可以真正到达B了。所以这个”握手”包的作用是可以打通A->B的通路,是必要的
源码示例
使用三台设备模拟,外网设备207.148.70.129
模拟Server S
,执行server.go代码:
package main
import (
"fmt"
"log"
"net"
"time"
)
func main() {
listener, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 9981})
if err != nil {
fmt.Println(err)
return
}
log.Printf("本地地址: <%s> \n", listener.LocalAddr().String())
peers := make([]net.UDPAddr, 0, 2)
data := make([]byte, 1024)
for {
n, remoteAddr, err := listener.ReadFromUDP(data)
if err != nil {
fmt.Printf("error during read: %s", err)
}
log.Printf("<%s> %s\n", remoteAddr.String(), data[:n])
peers = append(peers, *remoteAddr)
if len(peers) == 2 {
log.Printf("进行UDP打洞,建立 %s <--> %s 的连接\n", peers[0].String(), peers[1].String())
listener.WriteToUDP([]byte(peers[1].String()), &peers[0])
listener.WriteToUDP([]byte(peers[0].String()), &peers[1])
time.Sleep(time.Second * 8)
log.Println("中转服务器退出,仍不影响peers间通信")
return
}
}
}
另外两台分别位于不同内网后的设备,均运行相同代码peer.go:
package main
import (
"fmt"
"log"
"net"
"os"
"strconv"
"strings"
"time"
)
var tag string
const HAND_SHAKE_MSG = "我是打洞消息"
func main() {
// 当前进程标记字符串,便于显示
tag = os.Args[1]
srcAddr := &net.UDPAddr{IP: net.IPv4zero, Port: 9982} // 注意端口必须固定
dstAddr := &net.UDPAddr{IP: net.ParseIP("207.148.70.129"), Port: 9981}
conn, err := net.DialUDP("udp", srcAddr, dstAddr)
if err != nil {
fmt.Println(err)
}
if _, err = conn.Write([]byte("hello, I'm new peer:" + tag)); err != nil {
log.Panic(err)
}
data := make([]byte, 1024)
n, remoteAddr, err := conn.ReadFromUDP(data)
if err != nil {
fmt.Printf("error during read: %s", err)
}
conn.Close()
anotherPeer := parseAddr(string(data[:n]))
fmt.Printf("local:%s server:%s another:%s\n", srcAddr, remoteAddr, anotherPeer.String())
// 开始打洞
bidirectionHole(srcAddr, &anotherPeer)
}
func parseAddr(addr string) net.UDPAddr {
t := strings.Split(addr, ":")
port, _ := strconv.Atoi(t[1])
return net.UDPAddr{
IP: net.ParseIP(t[0]),
Port: port,
}
}
func bidirectionHole(srcAddr *net.UDPAddr, anotherAddr *net.UDPAddr) {
conn, err := net.DialUDP("udp", srcAddr, anotherAddr)
if err != nil {
fmt.Println(err)
}
defer conn.Close()
// 向另一个peer发送一条udp消息(对方peer的nat设备会丢弃该消息,非法来源),用意是在自身的nat设备打开一条可进入的通道,这样对方peer就可以发过来udp消息
if _, err = conn.Write([]byte(HAND_SHAKE_MSG)); err != nil {
log.Println("send handshake:", err)
}
go func() {
for {
time.Sleep(10 * time.Second)
if _, err = conn.Write([]byte("from [" + tag + "]")); err != nil {
log.Println("send msg fail", err)
}
}
}()
for {
data := make([]byte, 1024)
n, _, err := conn.ReadFromUDP(data)
if err != nil {
log.Printf("error during read: %s\n", err)
} else {
log.Printf("收到数据:%s\n", data[:n])
}
}
}
注意代码仅模拟打洞基础流程,如果读者测试网络情况较差发生udp
丢包,可能看不到预期结果,此时简单重启server,peer即可.
udp打洞转tcp通信
通常,由于udp
打洞实现简单,p2p
的实现采用udp
打洞较多,然而当通路建立起来后使用tcp
进行节点间通信可以获取更好的通信效果。因为udp
打洞完成后形成的nat
映射是和tcp/udp
无关的,所以此时可以转为使用tcp
建立连接,达到最终的p2p
的tcp
通信。
参考资料
协议文献
具体的代码以及实现可以参考
其他博文资料
附-> 在arm盒子用docker搭建的似有穿透代理:
version: "3"
services:
shadowssocks2:
container_name: shadowssocks2
image: hub.wesais.cn/edge/shadowssocks2-arm:v.23.0812
restart: always
network_mode: host
entrypoint: shadowsocks
command: -c ss://AEAD_CHACHA20_POLY1305:******@127.0.0.1:5600 -socks :1080
kcpClient:
container_name: kcpClient
image: hub.wesais.cn/edge/kcp-client-arm:v.23.0814
restart: always
network_mode: host
entrypoint: kcpclient
command: -c /etc/kcp/client.json
unphp
08 月 17 日 17 时 20 分
unphp
08 月 17 日 17 时 20 分