Natter icon indicating copy to clipboard operation
Natter copied to clipboard

使 UDP TCP 打洞后外部端口相同

Open Mythologyli opened this issue 10 months ago • 7 comments

目的

一些应用,如 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 很可能有一些固定的行为模式(例如外部端口递增),可以再对打洞方法进行优化。

可能存在的问题

  • 打洞的速率过高

Mythologyli avatar Feb 20 '25 05:02 Mythologyli

这是一个好问题👍

但上述方法在实际应用中会受到诸多限制:

  1. 最大连接数限制。 在 Fullcone NAT 上其实就是端口数限制。我们最多同时向外部映射数千个端口,一般很难上万。如果不是同时建立映射,超时的外部端口会被重新循环使用,因此“每次打洞得到的外部端口不重复”这一条件较难满足。根据实际观测,超时后得到重复端口的概率很大,尤其是递增型的 NAT。

  2. 外部端口范围的限制。 这一点其实也包含上一条的影响,不过更关注于分配的端口号基数。例如外部 TCP 端口从 10000 开始分配,你可以使用的区段在 10000-19999;外部 UDP 端口从 20000 开始分配,你可以使用的区段在 20000-29999;这种情况永不可能达到重合。

Natter 或者 NATMap 文档中提到的“随机”,并不是数学意义上的随机。同一个 IP 地址上使用的用户不止一个,某些端口并不是没有“随机”到,是因为被其他用户长期占用,或者就不在分配范围内。使用“随机”这一词只是一种粗略的表述。

或许可以拿 stunclient 先简单做一个实验,看到底行不行。

我的推测是不行,或者只有少部分用户能行。如果做出来,会被不少用户问“为什么我就用不了”的问题,所以就倾向于不做了。

MikeWang000000 avatar Feb 20 '25 09:02 MikeWang000000

感谢回复~

在学校的网络里测试还是挺容易成功的,大概打洞 300 次左右:

Image

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 分钟,感觉问题不大?

我之后在家庭网络环境里测试一下。


网络环境:陕西电信

Image

Mythologyli avatar Feb 20 '25 09:02 Mythologyli

那我觉得可以做一个外置的脚本。比如先用你这种方法获取到等值的端口:

  • TCP:内部IP:11111 => TCP:外部IP:12345
  • UDP:内部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 中。这样如何?你现在就可以试一试了

MikeWang000000 avatar Feb 20 '25 13:02 MikeWang000000

成功了,感谢指教~

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 可以关闭。

Mythologyli avatar Feb 20 '25 16:02 Mythologyli

此 issue 可保持打开,待我补充文档、将这个功能以实用脚本的形式放入仓库。

有一些地方可以改进,比如:

  • 使用 -q 让 Natter 在发现外部地址改变时退出(便于外部脚本重新对其端口)
  • 直接使用 class StunClient(object) 获取外部端口,这样就不用去调用 stunclient 了。

另外加上一些限制说明和副作用(占用连接数等等),这些待我完成后,我来关闭。

MikeWang000000 avatar Feb 21 '25 00:02 MikeWang000000

@MikeWang000000 我有使用Sunshine进行游戏串流的需求,它需要很多个不同的端口,虽然可以更改,但是这几个端口之间的差值是固定的。感觉我的需求也可以用 @Mythologyli 的思路解决。但是我并不会写python脚本。我请求将这个功能的用途扩大,通过选择一个可以使用简单公式表达的规则文本文件,不停地使用Natter尝试暴露局域网内主机的特定端口,并检查外部端口是否符合规则,直到找到一组符合规则的之后将其他无用的关停。

Yuyuyuyu-an avatar Jul 29 '25 06:07 Yuyuyuyu-an

@MikeWang000000 我有使用Sunshine进行游戏串流的需求,它需要很多个不同的端口,虽然可以更改,但是这几个端口之间的差值是固定的。感觉我的需求也可以用 @Mythologyli 的思路解决。但是我并不会写python脚本。我请求将这个功能的用途扩大,通过选择一个可以使用简单公式表达的规则文本文件,不停地使用Natter尝试暴露局域网内主机的特定端口,并检查外部端口是否符合规则,直到找到一组符合规则的之后将其他无用的关停。

最方便的方法是使用 WireGuard 等工具组网,使用内网 IP 进行通信。

关于“多个不同的端口”,端口数越多,能满足条件的几率越低。这个是需要考虑的。

MikeWang000000 avatar Jul 29 '25 18:07 MikeWang000000