使用udp打洞实现内网穿透
做项目的时候发现需要进行udp打洞,这里进行简单的验证
因为是测试,代码就先写在一个cpp文件里了,最后再封装。
github项目连接:Chen-Jin-yuan/UDP-hole-punching (github.com)
日志
公网环境:ubuntu 20.04 LTS,g++
本地环境:win10,vs2022,网络利用nattypetest测试cone类型。
- 首先进行udp收发测试
- 初次尝试udp打洞失败,思考可能与NAT类型有关,因为校园网和手机热点都是对称型,难以穿透;而如果两台内网机器连同一个wifi的话,可能会因为NAT回环而被丢弃,但是又没有其他网络可以连,错误的可能很多,先尝试逆向连接。
- 进行逆向连接测试,成功。
- 进行NAT回环测试(客户端A、B在同一个内网下,A向NAT发往B的包被丢弃),成功,并修改服务器逻辑,当双方在一个NAT内时,发的是主机ip(私有ip)和绑定的端口(不是NAT中的端口)。
- 逆向连接成功后进行tcp连接,失败,与conntrack连接跟踪有关,可能被防火墙根据协议号阻拦了。
- 根据连接跟踪,使用tcp打洞打穿NAT和防火墙,实现tcp的逆向连接。
- tcp的内网穿透失败,防火墙有诸多限制。
- udp打洞测试成功,成功内网穿透了两个NAT。
- 整理代码与封装,并有使用示例。
简单的收发测试
Linux服务器端server.cpp
1 |
|
windows客户端client.cpp
1 |
|
结果
云服务器里运行server.cpp
本机使用vs2022,先release一份exe文件,然后改端口和client1还是client2的信息,在debug模式再运行一个程序,就可以做到两台主机了。
使用另一台电脑运行程序,绑定端口10000,另一台电脑连不同的网,结果如下:
现在产生p2p发送,发送端添加如下:
1 | getchar();//阻塞 |
接收端把getchar去掉,添加如下:
1 | //client2接收信息 |
结果是发不过去,接收端没有收到一直在阻塞。这需要进一步去思考。可能是NAT类型问题,也可能是NAT回环问题(注:经后面测试,发现是NAT回环问题)
逆向连接-Connection reversal
这种方法在两个端点中有一个不存在中间件(如NAT)的时候有效。例如,Client A在NAT之后而Client B拥有全局IP地址,如图所示:
Client A内网地址为10.0.0.1,使用TCP,端口为1234。A和Server S建立了一个连接,Server的IP地址为18.181.0.31,监听1235端口。NAT A给Client A分配了TCP端口62000,地址为NAT的公网IP地址155.99.25.11,作为Client A对外当前会话的临时IP和端口。因此Server S认为Client A就是155.99.25.11:62000。而Client B由于有公网地址,所以对Server S来说Client B就是138.76.29.7:1234。
当Client B想要主动发起对Client A的P2P连接时,需要指定目的地址及端口为155.99.25.11:62000。由于NAT工作的原理问题,NAT A会拒绝将收到的对Client A的请求转发给Client A。拒绝该请求主要有如下原因:
NAT A没有映射过62000端口,NAT A不知道该请求是给谁的
NAT A映射过62000端口,但是需要首先从Client A发起请求,然后才能转发应答(限制锥形NAT的保护)
在直接连接Client A失败之后,Client B可以通过Server S向Client A中继一个连接请求,从而从Client A方向“逆向“地建立起Client A- Client B之间的点对点连接(因为Client A连接到了Server S)。
很多当前的P2P系统都实现了这种技术,但其局限性也是很明显的,只有当其中一方有公网IP时连接才能建立。越来越多的情况下,通信的双方都在NAT之后,因此就要用到打洞技术了。
现在我们假设公网服务器有一个clientB,本地主机clientA在内网里,我们的目标是实现B发送udp请求能连接到A。
- 首先通过服务器分发对方的IP和port
- 然后clientA向clientB发送信息(正常情况下能收到),此时NAT A已经建立了NAT A->clientB的映射,表示信任B
- 现在B可以先A发送信息了,NAT A会转发到A
注意B是否接收A的消息是无关紧要,为了验证,这里本地客户端B接收并打印出来。实际上不需要接收,A发的这个包只是告诉NAT A:“我要发的目的ip和端口是被我信任的,它可以发包给我,不要丢弃这个ip和端口发来的信息”。
先写一个在公网云服务器运行的clientB,先接收然后发送:
1 |
|
本地客户端如下:
1 |
|
结果
如图,公网clientB成功发送udp包到内网clientA
现在,我们不让A发送给B,B也不阻塞接收,而是一直发送信息给A。
注意,必须等NAT A中的映射自然删除(大概几分钟的存活时间),没删除前B仍然可以直接发给A。
删除后,B就无法发送给A了,A一直在阻塞,因为B发送的消息给NAT A丢弃了,即保护了A。
NAT回环(环回)
现在我们处理同一个局域网的问题,有些NAT对同一个局域网的信息会丢弃,这时服务器根据ip判断是否是一个局域网,是的话把客户端本机的ip和port发送回去即可。
客户端:
下面是一个获取本机ip的函数,一般取最后一个即可,然后把ip和端口发给服务器。
1 |
|
发送包:
1 | bool udp_hole_punching(SOCKET& udpfd, const char* server_ip, const int port)//向服务器发送udp |
服务器端:
服务器不再接收”client1”或”client2”,而是接收客户端本机的ip和port,接收后判断NAT的ip是否相同,如果相同就是同一个局域网。
1 | string udp_hole_punching(int listenfd) |
由于有空格分隔,这里用个解析函数解析两个ip-port
1 | vector<string> parse(string str)//解析函数 |
最后在main里:
1 | string ip_port1 = udp_hole_punching(listenudp); |
结果
在主机上跑两个程序,像逆向连接一样,A先B发送一个包,然后B就可以一直向A发包了。
当然这里服务器判断两个主机都在内网里,所以返回它们本地的ip和各自绑定的端口(这个端口不是NAT映射中的端口),此时这两个主机能互相发包。
下面修改服务器代码,不管怎么样都返回NAT的ip和port,这时客户端A发给NAT发现目标就是自己内网里的机器,就直接丢弃了,因此B不会收到A的hello!
这个例子就说明了有些NAT不支持回环。
完整代码
完整的源码再放一下:
服务器端:
1 |
|
客户端1
1 |
|
客户端2,相对于客户端1只修改了绑定的port和接收发送的逻辑。
1 |
|
逆向连接后进行tcp连接
因为还没有找到合适的网络来测试udp的内网穿透(大多都是校园网,对称型无法处理),所以先等几天,然后开始测试如何逆向连接后,进行p2p的tcp连接。
目前的想法是,当逆向连接完成后,将原来的udp套接字关闭,创建tcp套接字,然后绑定相同的端口(因为NAT记录的端口)。
- 首先公网客户端接收内网客户端的udp信息,逆向连接完成(可以不接收,内网客户端发送即可,为了验证这里还是接收)
- 公网客户端发个udp给内网客户端,作为验证
- 接着两个客户端创建tcp套接字并且都绑定原来的端口(udp套接字可以不关闭,tcp和udp可以共用端口,所以可同时初始化)
- 内网客户端进入listen状态,阻塞在accept
- 公网客户端connect内网客户端,tcp连接建立
公网客户端:
1 |
|
内网客户端
1 |
|
结果
连接失败,双方阻塞在connect和accept处,connect的syn包根本到达不了内网机器(反向测试:内网可以connect外网)。为什么udp可以而tcp就不可以了呢?这两个协议的差异就是,我们在发udp之前,先用内网向外网发了udp;而tcp包是直接从外网发到内网的,这可能导致了问题。
连接跟踪
tcp连接失败也许是因为conntrack(连接跟踪),在防火墙、内核(linux)中会跟踪连接,这个连接不是专门指tcp连接。连接跟踪可以让Netfilter(框架)知道某个特定连接的状态,运行连接跟踪的防火墙称作带有状态机制的防火墙,这种防火墙更安全。连接跟踪的信息一般是五元组,当然针对不同协议和特殊修改也有四元组和七元组:
- 四元组:源IP地址、目的IP地址、源端口、目的端口
- 五元组:源IP地址、目的IP地址、协议号、源端口、目的端口
- 七元组:源IP地址、目的IP地址、协议号、源端口、目的端口,服务类型以及接口索引
可以看到,五元组中不仅有ip、端口,还有协议号这一内容,这标识了是tcp还是udp等等。在各种过滤机制下,连接跟踪会发挥作用。
当然我们的内网客户端是Windows下的,Windows下的防火墙是如何写的并不能知道(没开源),但我们可以大概看一下防火墙高级设置:
在入站规则中,可以看到除了地址端口,还有协议。借助连接跟踪的想法,我们猜测:
- 假如通过udp打洞后,NAT不检测协议,而允许把tcp包转发到主机上,那么主机的防火墙就会把包丢弃。
- 因为NAT是基于地址端口的,从理论上说端口限制锥形只要报文源、目的地址端口匹配即可;但也只是理论,不排除NAT也会把tcp拦下来。
因此可以得出简单的结论:内网穿透时,使用udp打洞后只能打出一条udp的隧道;要打出tcp隧道,就要让某个内网客户端先发tcp的包。也就是说:在基于NAT的情况下,内网穿透只要打穿地址和端口;而在基于防火墙的情况下,内网穿透还要打穿特定协议的隧道。
TCP打洞
我们可以重新设计:
- 首先服务器通知双方端口和ip
- 不进行udp打洞,直接发tcp包。这里的实现是:双方同时进行connect,利用connect在内核的重传机制(syn是会重传的),第一个tcp包会给对方NAT丢掉(因为没有udp打洞),因为有重传机制不会马上就返回错误。然后对方能进行connect过来(本地防火墙和NAT都允许入站了),本地发现在connect也就建立连接了。同时进行connect建立tcp连接可以参考我为此研究后写的博客:TCP同时打开-深度剖析 | JySama
这里面:
- 服务器通知双方的NAT地址和端口是必要的,否则对方根本不知道网关的地址;其次,如果不通知端口,对方只能请求到预设端口,但是NAT中端口映射不一定相同,会给NAT丢弃
- udp、tcp套接字绑定端口也是必要的,这主要是为了udp和tcp端口同步。因为
- 回环时,服务器发回去的是客户端通告的“我要绑定的端口”,然后双方根据这个通告的端口要连接。
- 没有回环时,使用的是NAT的端口,这只需要让udp套接字(与服务器交互)和tcp套接字(p2p交互)绑定在一个端口上即可,因为服务器发回的是NAT是建立了udp的端口,如果tcp套接字使用了不同的端口,那么在NAT上就重新建立了一条映射,这时请求udp端口的tcp连接肯定失败了,因为tcp的syn经过服务器发的端口到了udp的套接字。
- 如果udp和tcp不绑定端口的话,当使用udp时,内核分配一个端口;使用tcp时connect时,又分配一个端口,可能就导致端口不一致了。
关于connect的建立机制,而不用listen-accept,这个还需要更详细地研究。(注:已研究透彻,为此写了一篇博客:TCP同时打开-深度剖析 | JySama)
- 目前是1对1传输文件,不涉及1对多的p2p,互相connect即可。
- 如果是1对多,那么tcp的一个端口要connect多个对端,只用设置端口可重用即可
SO_REUSEADDR
,将多个套接字绑定到一个端口。这里不做研究
外网客户端:取消了udp打洞
1 |
|
内网客户端:取消了udp打洞
1 |
|
有udp打洞的tcp连接:
无udp打洞的tcp连接:
补充:已利用udp打洞成功穿透两个NAT
代码用NAT回环的代码即可。对方是另一个大学的寝室网络,接了路由器不是校园网,所以是圆锥型的NAT,可以成功。
代码整理(封装)
客户端
头文件udp_hole_punch.h
1 |
|
1 |
|
客户端调用示例
main函数调用,接收方主动打洞,发送方不打洞。
1 |
|
1 |
|
服务器端
1 |
|