使 UDP TCP 打洞后外部端口相同
目的
一些应用,如 BitTorrent,可以利用 TCP 打洞极大提升连接性 (Mythologyli/qBittorrent-NAT-TCP-Hole-Punching) 。不过,由于只进行 TCP 打洞,无法完成基于 µTP 协议 的 UDP 传入连接。
可以通过 Natter 对 UDP 和 TCP 分别进行打洞,但受 CGNAT 行为限制,难以保证打洞后外部端口相同 (https://github.com/heiher/natmap/issues/53#issuecomment-1987966017) 。而 BitTorrent 客户端通常只向 tracker 汇报一个端口。
可能的实现思路
与生日悖论类似,假设 CGNAT 在 d=65535 个空闲 TCP/UDP 端口中随机分配,每次打洞得到的外部端口不重复,如果分别进行 n 次 TCP 和 UDP 打洞,存在相同外部端口的概率为:
$P=1-(1-\frac{n}{d})(1-\frac{n}{d-1})\cdots(1-\frac{n}{d-(n-1)})$
| n | P |
|---|---|
| 100 | 14.17% |
| 200 | 45.79% |
| 400 | 91.43% |
| 800 | 99.99% |
如果打洞速率为 100 次/秒(当然有点暴力了),4 秒内找到相同外部端口的概率高于 90%。也可以降低打洞速率,对很多场景而言在数分钟内成功都是可以接受的。
考虑到 CGNAT 很可能有一些固定的行为模式(例如外部端口递增),可以再对打洞方法进行优化。
可能存在的问题
- 打洞的速率过高
这是一个好问题👍
但上述方法在实际应用中会受到诸多限制:
-
最大连接数限制。 在 Fullcone NAT 上其实就是端口数限制。我们最多同时向外部映射数千个端口,一般很难上万。如果不是同时建立映射,超时的外部端口会被重新循环使用,因此“每次打洞得到的外部端口不重复”这一条件较难满足。根据实际观测,超时后得到重复端口的概率很大,尤其是递增型的 NAT。
-
外部端口范围的限制。 这一点其实也包含上一条的影响,不过更关注于分配的端口号基数。例如外部 TCP 端口从 10000 开始分配,你可以使用的区段在 10000-19999;外部 UDP 端口从 20000 开始分配,你可以使用的区段在 20000-29999;这种情况永不可能达到重合。
Natter 或者 NATMap 文档中提到的“随机”,并不是数学意义上的随机。同一个 IP 地址上使用的用户不止一个,某些端口并不是没有“随机”到,是因为被其他用户长期占用,或者就不在分配范围内。使用“随机”这一词只是一种粗略的表述。
或许可以拿 stunclient 先简单做一个实验,看到底行不行。
我的推测是不行,或者只有少部分用户能行。如果做出来,会被不少用户问“为什么我就用不了”的问题,所以就倾向于不做了。
感谢回复~
在学校的网络里测试还是挺容易成功的,大概打洞 300 次左右:
import subprocess
tcp_ports = set()
udp_ports = set()
for i in range(800):
output = subprocess.getoutput("stunclient.exe --protocol tcp turn.cloudflare.com")
port = output.split(":")[-1]
tcp_ports.add(port)
print("TCP Port:", port)
output = subprocess.getoutput("stunclient.exe --protocol udp turn.cloudflare.com")
port = output.split(":")[-1]
udp_ports.add(port)
print("UDP Port:", port)
common_ports = tcp_ports.intersection(udp_ports)
if common_ports:
print(f"Common Ports ({i}):", common_ports)
break
print(f"TCP Ports ({len(tcp_ports)}):", tcp_ports)
print(f"UDP Ports ({len(udp_ports)}):", udp_ports)
不过我没有验证之前打洞得到的端口有没有超时,不过整个过程不超过 1 分钟,感觉问题不大?
我之后在家庭网络环境里测试一下。
网络环境:陕西电信
那我觉得可以做一个外置的脚本。比如先用你这种方法获取到等值的端口:
TCP:内部IP:11111=>TCP:外部IP:12345UDP:内部IP:22222=>UDP:外部IP:12345
此时分别拉起 Natter 或者 NATMap。
- 第一个 Natter 命令行用:
python natter.py -m iptables -b 11111 -p 8080 - 第二个 Natter 命令行用:
python natter.py -u -m iptables -b 22222 -p 8080
这个时候就能获得同等的 TCP 和 UDP 的外部端口了。因为前面刚映射过,超时之前 NAT 映射是不变的。
简单起见,可以不集成在 Natter 中。这样如何?你现在就可以试一试了
成功了,感谢指教~
TCP Punching: 44235 63693
UDP Punching: 33238 65335
TCP Punching: 42999 63695
UDP Punching: 51048 65336
TCP Punching: 39731 63696
UDP Punching: 51896 65337
TCP Punching: 40011 63697
UDP Punching: 59612 65338
TCP Punching: 36641 63698
UDP Punching: 50980 63966
Found: TCP(45445, 63966), UDP(50980, 63966)
Press Enter to exit...2025-02-21 00:22:09 [I] Natter v2.1.1
2025-02-21 00:22:09 [I] Natter v2.1.1
2025-02-21 00:22:12 [I]
2025-02-21 00:22:12 [I] udp://192.168.x.x:19132 <--iptables--> udp://192.168.x.x:50980 <--Natter--> udp://1.x.x.x:63966
2025-02-21 00:22:12 [I]
2025-02-21 00:22:14 [I]
2025-02-21 00:22:14 [I] tcp://192.168.x.x:8123 <--iptables--> tcp://192.168.x.x:45445 <--Natter--> tcp://1.x.x.x:63966
2025-02-21 00:22:14 [I]
2025-02-21 00:22:14 [I] LAN > 192.168.x.x:8123 [ OPEN ]
2025-02-21 00:22:14 [I] LAN > 192.168.x.x:45445 [ OPEN ]
2025-02-21 00:22:14 [I] LAN > 1.x.x.x:63966 [ OPEN ]
2025-02-21 00:22:15 [I] WAN > 1.x.x.x:63966 [ OPEN ]
2025-02-21 00:22:15 [I]
使用的脚本如下,sudo 运行,供后来者参考(一个 demo,很不完善,使用需谨慎):
import socket
import subprocess
tcp_ports = set()
udp_ports = set()
def tcp_punching():
output = subprocess.getoutput("./stunclient --protocol tcp turn.cloud-rtc.com 80")
local_port = output.split("\n")[1].split(":")[-1]
remote_port = output.split(":")[-1]
try:
tcp_ports.add((int(local_port), int(remote_port)))
return int(local_port), int(remote_port)
except ValueError:
return tcp_punching()
def udp_punching():
output = subprocess.getoutput("./stunclient --protocol udp stun.douyucdn.cn 18000")
local_port = output.split("\n")[1].split(":")[-1]
remote_port = output.split(":")[-1]
try:
udp_ports.add((int(local_port), int(remote_port)))
return int(local_port), int(remote_port)
except ValueError:
return udp_punching()
def main():
tcp_local_port, tcp_remote_port = tcp_punching()
print("TCP Punching:", tcp_local_port, tcp_remote_port)
udp_local_port, udp_remote_port = udp_punching()
print("UDP Punching:", udp_local_port, udp_remote_port)
if abs(tcp_remote_port - udp_remote_port) > 1000:
print("Trying to find near ports...")
for i in range(50000):
try:
client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
client.bind(('0.0.0.0', 10000 + i))
client.sendto(b'TEST', ("stun.douyucdn.cn", 18000))
except Exception as e:
continue
if i % 100 == 0:
udp_local_port, udp_remote_port = udp_punching()
print("UDP Punching:", udp_local_port, udp_remote_port)
udp_ports.add((udp_local_port, udp_remote_port))
if abs(tcp_remote_port - udp_remote_port) <= 1000:
break
for i in range(1000):
tcp_local_port, tcp_remote_port = tcp_punching()
print("TCP Punching:", tcp_local_port, tcp_remote_port)
udp_local_port, udp_remote_port = udp_punching()
print("UDP Punching:", udp_local_port, udp_remote_port)
# Check
for tcp_local_port, tcp_remote_port in tcp_ports:
for udp_local_port, udp_remote_port in udp_ports:
if tcp_remote_port == udp_remote_port:
print(f"Found: TCP({tcp_local_port}, {tcp_remote_port}), UDP({udp_local_port}, {udp_remote_port})")
p1 = subprocess.Popen(
["python3", "natter.py", "-m", "iptables", "-b", str(tcp_local_port), "-p", "8123"]
)
p2 = subprocess.Popen(
["python3", "natter.py", "-u", "-m", "iptables", "-b", str(udp_local_port), "-p", "19132"]
)
input("Press Enter to exit...")
p1.terminate()
p2.terminate()
return
if __name__ == "__main__":
main()
其中有端口递增式 CGNAT 的处理方法(实测陕西电信是这种模式):如果发现初次打洞得到的 tcp_remote_port 和 udp_remote_udp 相差过大,则建立大量 udp 连接将 tcp_remote_port 和 udp_remote_port 的差异缩小,之后再分别打洞检测。随机端口式的 NAT 不需要这一步(实测学校是这种模式)。
再次感谢作者指点,此 issue 可以关闭。
此 issue 可保持打开,待我补充文档、将这个功能以实用脚本的形式放入仓库。
有一些地方可以改进,比如:
- 使用
-q让 Natter 在发现外部地址改变时退出(便于外部脚本重新对其端口) - 直接使用
class StunClient(object)获取外部端口,这样就不用去调用stunclient了。
另外加上一些限制说明和副作用(占用连接数等等),这些待我完成后,我来关闭。
@MikeWang000000 我有使用Sunshine进行游戏串流的需求,它需要很多个不同的端口,虽然可以更改,但是这几个端口之间的差值是固定的。感觉我的需求也可以用 @Mythologyli 的思路解决。但是我并不会写python脚本。我请求将这个功能的用途扩大,通过选择一个可以使用简单公式表达的规则文本文件,不停地使用Natter尝试暴露局域网内主机的特定端口,并检查外部端口是否符合规则,直到找到一组符合规则的之后将其他无用的关停。
@MikeWang000000 我有使用Sunshine进行游戏串流的需求,它需要很多个不同的端口,虽然可以更改,但是这几个端口之间的差值是固定的。感觉我的需求也可以用 @Mythologyli 的思路解决。但是我并不会写python脚本。我请求将这个功能的用途扩大,通过选择一个可以使用简单公式表达的规则文本文件,不停地使用Natter尝试暴露局域网内主机的特定端口,并检查外部端口是否符合规则,直到找到一组符合规则的之后将其他无用的关停。
最方便的方法是使用 WireGuard 等工具组网,使用内网 IP 进行通信。
关于“多个不同的端口”,端口数越多,能满足条件的几率越低。这个是需要考虑的。