- punch_probe.py: UDP 打洞探针,支持 STUN 查映射、--peer 手工配对、--server/--room 信令配对 - rendezvous.py: 极简 UDP 信令服务器,只做房间配对与地址回推,不承载媒体 - 已在 47.98.245.66 实测跨公网注册/配对可用
344 lines
14 KiB
Python
344 lines
14 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""UDP 打洞探针 —— 两台不同网络的机器各跑一个,用来摸清你们线路的底牌。
|
||
|
||
回答四个问题:
|
||
1. 这条线路的 NAT 能不能打洞?
|
||
2. 要几轮才能打通?(轮数越少说明 NAT 越宽松)
|
||
3. 对方的源端口有没有被 NAT 改写?(判断是不是对称 NAT)
|
||
4. 有没有公网 IPv6?IPv6 直连通不通?
|
||
|
||
用法
|
||
----
|
||
A 机(假设公网 1.2.3.4):
|
||
py punch_probe.py --peer 2.3.4.5:47800
|
||
|
||
B 机(假设公网 2.3.4.5):
|
||
py punch_probe.py --peer 1.2.3.4:47800
|
||
|
||
IPv6(双方都有公网 v6 时):
|
||
py punch_probe.py --peer [2408:8207:1234::1]:47800
|
||
|
||
先查自己的公网映射(走 STUN,不需要对方):
|
||
py punch_probe.py --stun
|
||
|
||
被动监听(不发包,只看能不能收到对方打进来的包):
|
||
py punch_probe.py
|
||
|
||
说明
|
||
----
|
||
- 两台机器必须**同时**启动,脚本靠「双方同时向对方发包」来穿透 NAT。
|
||
- 先各自查公网 IP(浏览器搜 "my ip" 或 --stun),再互相填。
|
||
- 只依赖 Python 标准库。
|
||
"""
|
||
|
||
import argparse
|
||
import socket
|
||
import struct
|
||
import sys
|
||
import threading
|
||
import time
|
||
|
||
MAGIC = b"PUNCHv1|"
|
||
|
||
|
||
# ---------------------------------------------------------------- 地址解析
|
||
|
||
def parse_addr(s):
|
||
"""解析 'ip:port' 或 '[v6]:port' -> (host, port, family)"""
|
||
s = s.strip()
|
||
if s.startswith("["):
|
||
end = s.index("]")
|
||
host = s[1:end]
|
||
port = int(s[end + 2:])
|
||
return host, port, socket.AF_INET6
|
||
host, _, p = s.rpartition(":")
|
||
return host, int(p), socket.AF_INET
|
||
|
||
|
||
def fmt_addr(sockaddr):
|
||
host, port = sockaddr[0], sockaddr[1]
|
||
if ":" in host:
|
||
return "[%s]:%d" % (host, port)
|
||
return "%s:%d" % (host, port)
|
||
|
||
|
||
# ---------------------------------------------------------------- STUN
|
||
|
||
def stun_mapped(server="stun.l.google.com", port=19302, timeout=3.0, sock=None):
|
||
"""最小 STUN Binding 请求,返回 (ip, port);失败返回 None。
|
||
|
||
sock 不为 None 时复用它(用来测「同一个本地端口对另一个目标」的映射端口,
|
||
这是判断 NAT 是不是对称型的关键)。
|
||
"""
|
||
txid = b"\x5a" * 12
|
||
req = struct.pack(">HHI12s", 0x0001, 0, 0x2112A442, txid)
|
||
try:
|
||
infos = socket.getaddrinfo(server, port, 0, socket.SOCK_DGRAM)
|
||
except OSError:
|
||
return None
|
||
fam, sockaddr = infos[0][0], infos[0][4]
|
||
own = sock is None
|
||
s = socket.socket(fam, socket.SOCK_DGRAM) if own else sock
|
||
s.settimeout(timeout)
|
||
try:
|
||
s.sendto(req, sockaddr)
|
||
data, _ = s.recvfrom(2048)
|
||
except OSError:
|
||
return None
|
||
finally:
|
||
if own:
|
||
s.close()
|
||
|
||
if len(data) < 20:
|
||
return None
|
||
off = 20
|
||
while off + 4 <= len(data):
|
||
atype, alen = struct.unpack(">HH", data[off:off + 4])
|
||
val = data[off + 4:off + 4 + alen]
|
||
if atype == 0x0020 and len(val) >= 8: # XOR-MAPPED-ADDRESS
|
||
famv = val[1]
|
||
xport = struct.unpack(">H", val[2:4])[0] ^ 0x2112
|
||
if famv == 0x01 and len(val) >= 8:
|
||
ip = bytes(a ^ b for a, b in zip(val[4:8], b"\x21\x12\xA4\x42"))
|
||
return socket.inet_ntoa(ip), xport
|
||
if famv == 0x02 and len(val) >= 20:
|
||
mask = b"\x21\x12\xA4\x42" + txid
|
||
ip = bytes(a ^ b for a, b in zip(val[4:20], mask))
|
||
return socket.inet_ntop(socket.AF_INET6, ip), xport
|
||
off += 4 + alen + ((4 - alen % 4) % 4)
|
||
return None
|
||
|
||
|
||
# ---------------------------------------------------------------- 信令
|
||
|
||
def discover_peer(sock, server, room, timeout=60.0):
|
||
"""注册到房间,等服务器回推对方的公网地址。
|
||
|
||
注意:这里**复用同一个 socket**——服务器看到的源地址,就是 NAT 为
|
||
「本机 → 服务器」这条流分配的映射。对锥形 NAT 而言,它与「本机 → 对端」
|
||
的映射相同,所以拿它去打洞是有效的;对对称 NAT 则不同,打不通属预期。
|
||
"""
|
||
host, port = server[0], server[1]
|
||
deadline = time.time() + timeout
|
||
last_send = 0.0
|
||
while time.time() < deadline:
|
||
now = time.time()
|
||
if now - last_send > 1.0:
|
||
try:
|
||
sock.sendto(b"REGISTER|" + room.encode("utf-8"), (host, port))
|
||
except OSError as e:
|
||
print(" 注册发送失败:%s" % e)
|
||
return None
|
||
last_send = now
|
||
try:
|
||
data, _src = sock.recvfrom(2048)
|
||
except socket.timeout:
|
||
continue
|
||
except ConnectionResetError:
|
||
continue
|
||
except OSError as e:
|
||
if getattr(e, "winerror", None) == 10054:
|
||
continue
|
||
return None
|
||
txt = data.decode("utf-8", "replace").strip()
|
||
if txt.startswith("PEER|"):
|
||
h, p, _f = parse_addr(txt[5:])
|
||
return (h, p)
|
||
return None
|
||
|
||
|
||
# ---------------------------------------------------------------- 主逻辑
|
||
|
||
class Probe:
|
||
def __init__(self, bind_host, port, family):
|
||
self.sock = socket.socket(family, socket.SOCK_DGRAM)
|
||
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||
if family == socket.AF_INET6:
|
||
try:
|
||
self.sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
|
||
except OSError:
|
||
pass
|
||
self.sock.bind((bind_host, port))
|
||
self.sock.settimeout(0.2)
|
||
# Windows 特有坑:曾经发过包给对方,但对方端口没开 → 会收到 ICMP 端口不可达,
|
||
# 之后下一次 recv 直接抛 WSAECONNRESET(10054)。不打这个 ioctl,
|
||
# 接收循环会被"自己之前的失败发送"打断,之后再也收不到包。
|
||
# (Rust 实现同样要处理:tokio recv 返回 ConnectionReset 时不能退出循环)
|
||
if sys.platform == "win32":
|
||
try:
|
||
self.sock.ioctl(socket.SIO_UDP_CONNRESET, False)
|
||
except (AttributeError, OSError):
|
||
pass
|
||
self.port = self.sock.getsockname()[1]
|
||
self.stop = threading.Event()
|
||
self.first_src = None
|
||
self.packets = 0
|
||
|
||
def receiver(self):
|
||
while not self.stop.is_set():
|
||
try:
|
||
data, src = self.sock.recvfrom(2048)
|
||
except socket.timeout:
|
||
continue
|
||
except ConnectionResetError:
|
||
# ICMP 端口不可达留下的错误,忽略并继续收(绝不能 break)
|
||
continue
|
||
except OSError as e:
|
||
if getattr(e, "winerror", None) == 10054:
|
||
continue
|
||
break
|
||
if self.first_src is None:
|
||
self.first_src = src
|
||
print("\n ✅ 收到对方的包!")
|
||
print(" 对方源地址(NAT 之后)= %s" % fmt_addr(src))
|
||
if src[1] != self.port:
|
||
print(" 对方源端口 = %d(本机端口 %d);若双方约定端口相同,"
|
||
"说明 NAT 改写了端口" % (src[1], self.port))
|
||
else:
|
||
print(" 对方源端口与约定端口一致 → NAT 保留了端口")
|
||
self.packets += 1
|
||
if self.packets <= 3:
|
||
print(" [%s] 收到 %d 字节" % (fmt_addr(src), len(data)))
|
||
|
||
def send_rounds(self, peer_sockaddr, rounds, interval):
|
||
for i in range(1, rounds + 1):
|
||
if self.stop.is_set():
|
||
return
|
||
payload = MAGIC + struct.pack(">d", time.time())
|
||
try:
|
||
self.sock.sendto(payload, peer_sockaddr)
|
||
except OSError as e:
|
||
print(" 发送失败:%s" % e)
|
||
return
|
||
if i == 1 or i % 5 == 0 or self.first_src is not None:
|
||
mark = "✅" if self.first_src is not None else " "
|
||
print(" %s 第 %2d/%d 轮已发出" % (mark, i, rounds))
|
||
if self.first_src is not None:
|
||
return
|
||
time.sleep(interval)
|
||
|
||
|
||
def main():
|
||
ap = argparse.ArgumentParser(add_help=True, description="UDP 打洞探针")
|
||
ap.add_argument("--peer", help="对方地址 ip:port 或 [v6]:port;不给则只被动监听")
|
||
ap.add_argument("--port", type=int, default=47800, help="本地端口(默认 47800)")
|
||
ap.add_argument("--rounds", type=int, default=25, help="发包轮数(默认 25)")
|
||
ap.add_argument("--interval", type=float, default=0.2, help="轮间隔秒(默认 0.2)")
|
||
ap.add_argument("--grace", type=float, default=2.5,
|
||
help="发完后继续监听多少秒等回包(默认 2.5)")
|
||
ap.add_argument("--server", help="信令服务器 ip:port(与 --room 配合,自动配对)")
|
||
ap.add_argument("--room", help="房间名;同一房间的两个客户端会被配对")
|
||
ap.add_argument("--wait", type=float, default=60.0,
|
||
help="等对方加入房间的秒数(默认 60)")
|
||
ap.add_argument("--stun", action="store_true", help="只查询自己的公网映射后退出")
|
||
ap.add_argument("--stun-port", type=int, default=0,
|
||
help="配合 --stun:用指定本地端口发 STUN(与打洞端口一致时才能对比映射)")
|
||
args = ap.parse_args()
|
||
|
||
# ---- 只查 STUN
|
||
if args.stun:
|
||
s = None
|
||
if args.stun_port:
|
||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||
s.bind(("0.0.0.0", args.stun_port))
|
||
print("[STUN] 用本机端口 %d 查询(与打洞同端口)..." % args.stun_port)
|
||
else:
|
||
print("[STUN] 查询公网映射(临时端口)...")
|
||
r = stun_mapped(sock=s)
|
||
if s:
|
||
s.close()
|
||
if r:
|
||
print(" 对 Google STUN 的映射 = %s:%d" % r)
|
||
if args.stun_port:
|
||
print(" 本机端口 = %d" % args.stun_port)
|
||
print(" 下一步:对比信令服务器日志里记录的源端口")
|
||
print(" 两者相同 → 端点无关映射(锥形 NAT)→ 打洞大概率成功")
|
||
print(" 两者不同 → 对称 NAT → 打洞困难,需要端口预测或中继")
|
||
else:
|
||
print(" 查询失败(STUN 19302 可能被封;可稍后重试)")
|
||
return 0
|
||
|
||
# ---- 决定地址族与模式
|
||
peer = None
|
||
if args.peer:
|
||
host, pport, family = parse_addr(args.peer)
|
||
peer = (host, pport)
|
||
bind_host = "::" if family == socket.AF_INET6 else "0.0.0.0"
|
||
mode = "双向打洞(手工填地址)"
|
||
elif args.server and args.room:
|
||
_h, _p, family = parse_addr(args.server)
|
||
bind_host = "::" if family == socket.AF_INET6 else "0.0.0.0"
|
||
mode = "信令自动配对 + 打洞"
|
||
else:
|
||
family = socket.AF_INET
|
||
bind_host = "0.0.0.0"
|
||
mode = "被动监听"
|
||
|
||
probe = Probe(bind_host, args.port, family)
|
||
print("=" * 60)
|
||
print(" UDP 打洞探针 模式=%s" % mode)
|
||
print(" 本机监听 %s:%d" % (bind_host, probe.port))
|
||
if peer:
|
||
print(" 发往 %s (%s)" % (fmt_addr(peer + (0, 0)),
|
||
"IPv6" if family == socket.AF_INET6 else "IPv4"))
|
||
print("=" * 60)
|
||
|
||
# ---- 信令配对:不用自己查公网 IP,服务器看到的就是你的公网映射
|
||
if args.server and args.room and peer is None:
|
||
srv = parse_addr(args.server)[:2]
|
||
print("\n向信令服务器 %s 注册 room=%s ..." % (fmt_addr(srv), args.room))
|
||
found = discover_peer(probe.sock, srv, args.room, timeout=args.wait)
|
||
if found is None:
|
||
print(" ❌ 超时:没等到对方加入同一个房间")
|
||
probe.sock.close()
|
||
return 1
|
||
peer = found
|
||
print(" ✅ 配对成功,对方公网地址 = %s" % fmt_addr(found))
|
||
|
||
t = threading.Thread(target=probe.receiver, daemon=True)
|
||
t.start()
|
||
|
||
try:
|
||
if peer:
|
||
print("\n开始同时发包(若对方也在发包,第 1~3 轮通常就通)...")
|
||
probe.send_rounds(peer, args.rounds, args.interval)
|
||
# 对方可能比我晚启动:发完后继续监听,否则会把"晚到的成功"误判成失败
|
||
if probe.first_src is None:
|
||
print("\n发包结束,继续监听 %.1f 秒等对方回包 ..." % args.grace)
|
||
waited = 0.0
|
||
while waited < args.grace and probe.first_src is None:
|
||
time.sleep(0.1)
|
||
waited += 0.1
|
||
else:
|
||
print("\n被动监听中,Ctrl+C 退出 ...")
|
||
while not probe.stop.is_set():
|
||
time.sleep(0.3)
|
||
except KeyboardInterrupt:
|
||
pass
|
||
finally:
|
||
probe.stop.set()
|
||
time.sleep(0.3)
|
||
probe.sock.close()
|
||
|
||
print("\n" + "-" * 60)
|
||
if probe.first_src is not None:
|
||
print("结论:✅ 打洞成功")
|
||
print(" 对方实际源地址 = %s" % fmt_addr(probe.first_src))
|
||
print(" 共收到 %d 个包" % probe.packets)
|
||
else:
|
||
print("结论:❌ 本轮没有收到对方的包")
|
||
print(" 可能原因(按概率排序):")
|
||
print(" 1. 一端或两端是对称型 NAT / 运营商级 NAT(CGNAT) → 需要端口预测或中继")
|
||
print(" 2. 一方根本没发包(对方没启动、或地址填错/端口填错)")
|
||
print(" 3. UDP 被防火墙拦截(企业网络常见)")
|
||
print(" 4. 双方没有真正同时发包(时间差超过 NAT 表项寿命)")
|
||
print(" 下一步:两端各跑一次 --stun,看看是不是落在 100.64~100.127 网段(CGNAT)")
|
||
print("-" * 60)
|
||
return 0 if probe.first_src is not None else 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|