TCP打洞比UDP打洞复杂一些,主要原因是:
- TCP需要三次握手
- TCP的NAT映射更严格
- 需要同时发起连接(Simultaneous Open)
TCP打洞流程
- 服务器记录客户端的公网地址和端口
- 客户端需要先绑定本地端口,使用
SO_REUSEADDR重用端口 - 双方同时向对方发起连接,利用TCP的同时打开(Simultaneous Open)机制
工作原理
Client A Server Client B
| | |
|--REGISTER--------------->| |
| |<-------------REGISTER----|
| | |
|--QUERY B---------------->| |
|<--ADDR B (IP:Port)-------| |
| | |
|========== Both clients try to connect ========== |
|---SYN (to B)------------>NAT-A |
| (opens hole in NAT-A) |
| | NAT-B<---SYN (to A)
| | (opens hole in NAT-B) |
| |
|<===== TCP connection established through NAT ======>|
Simultaneous Open
参考
自测
- TCP没有打洞成功(也可能是我打开的方式不对)
- 在家庭网络有大概率成功的udp打洞,在tcp的环境下也不太好使了。
代码
python
# Server (Public IP) - TCP Version
import socket
import threading
import json
clients = {} # {client_id: (ip, port)}
def handle_client(conn, addr):
try:
while True:
data = conn.recv(1024)
if not data:
break
msg = data.decode()
if msg.startswith("REGISTER:"):
# Register client
client_id = msg.split(":")[1]
clients[client_id] = addr
print(f"Registered: {client_id} -> {addr}")
conn.sendall(b"OK")
elif msg.startswith("QUERY:"):
# Query peer address
target_id = msg.split(":")[1]
if target_id in clients:
target_addr = clients[target_id]
response = f"ADDR:{target_addr[0]}:{target_addr[1]}"
conn.sendall(response.encode())
print(f"Query: {addr} wants to connect {target_addr}")
else:
conn.sendall(b"NOT_FOUND")
except Exception as e:
print(f"Error: {e}")
finally:
conn.close()
def main():
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("0.0.0.0", 9999))
sock.listen(5)
print("TCP Server started on port 9999")
while True:
conn, addr = sock.accept()
print(f"New connection from {addr}")
threading.Thread(target=handle_client, args=(conn, addr), daemon=True).start()
if __name__ == "__main__":
main()
python
# Client A/B - TCP Version
import socket
import time
import threading
class TCPClient:
def __init__(self, client_id, server_addr):
self.client_id = client_id
self.server_addr = server_addr
self.local_port = None
self.peer_addr = None
self.connected = False
def register(self):
# Register to server and get local port
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.connect(self.server_addr)
# Get local port after connection
self.local_port = sock.getsockname()[1]
print(f"Local port: {self.local_port}")
# Register
sock.sendall(f"REGISTER:{self.client_id}".encode())
response = sock.recv(1024)
print(f"Register response: {response.decode()}")
sock.close()
return self.local_port
def query_peer(self, peer_id):
# Query peer address from server
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(self.server_addr)
sock.sendall(f"QUERY:{peer_id}".encode())
response = sock.recv(1024).decode()
if response.startswith("ADDR:"):
parts = response.split(":")
peer_ip = parts[1]
peer_port = int(parts[2])
self.peer_addr = (peer_ip, peer_port)
print(f"Peer address: {self.peer_addr}")
else:
print("Peer not found")
sock.close()
return self.peer_addr
def tcp_punch_hole(self):
# TCP hole punching using simultaneous connect
if not self.peer_addr:
print("No peer address")
return None
# Create socket with same local port
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# Bind to the same port used during registration
sock.bind(("0.0.0.0", self.local_port))
# Set non-blocking for simultaneous connect
sock.setblocking(False)
print(f"Attempting to connect to {self.peer_addr} from port {self.local_port}")
# Try to connect (will likely fail first time, but opens NAT hole)
max_attempts = 30
for i in range(max_attempts):
try:
sock.connect(self.peer_addr)
print("Connected immediately!")
sock.setblocking(True)
self.connected = True
return sock
except BlockingIOError:
# Connection in progress
pass
except ConnectionRefusedError:
# Expected during hole punching
pass
except OSError as e:
if e.errno == 106: # Already connected
print("Connection established!")
sock.setblocking(True)
self.connected = True
return sock
# Wait and retry
time.sleep(1)
print(f"Retry attempt {i+1}/{max_attempts}")
# Recreate socket for new attempt
if i < max_attempts - 1:
sock.close()
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("0.0.0.0", self.local_port))
sock.setblocking(False)
print("Failed to establish connection")
sock.close()
return None
def send_messages(self, sock):
# Send messages continuously
counter = 0
while self.connected:
try:
message = f"MSG_{counter}".encode()
sock.sendall(message)
print(f"Sent: {message.decode()}")
counter += 1
time.sleep(2)
except Exception as e:
print(f"Send error: {e}")
self.connected = False
break
def receive_messages(self, sock):
# Receive messages continuously
while self.connected:
try:
data = sock.recv(1024)
if not data:
print("Connection closed by peer")
self.connected = False
break
print(f"Received: {data.decode()}")
except Exception as e:
print(f"Receive error: {e}")
self.connected = False
break
def start(self, peer_id):
# Main flow
print(f"=== Client {self.client_id} starting ===")
# Step 1: Register
print("Step 1: Registering...")
self.register()
# Wait a bit
time.sleep(2)
# Step 2: Query peer
print("Step 2: Querying peer...")
if not self.query_peer(peer_id):
print("Cannot find peer")
return
# Wait for both clients to be ready
print("Step 3: Waiting for both clients to be ready...")
time.sleep(3)
# Step 4: Punch hole and connect
print("Step 4: TCP hole punching...")
sock = self.tcp_punch_hole()
if sock:
print("=== Connection established! ===")
# Start sending and receiving
send_thread = threading.Thread(target=self.send_messages, args=(sock,), daemon=True)
recv_thread = threading.Thread(target=self.receive_messages, args=(sock,), daemon=True)
send_thread.start()
recv_thread.start()
# Keep alive
try:
while self.connected:
time.sleep(1)
except KeyboardInterrupt:
print("Interrupted")
finally:
self.connected = False
sock.close()
else:
print("=== Connection failed ===")
# Example usage
if __name__ == "__main__":
import sys
if len(sys.argv) < 3:
print("Usage: python client.py <client_id> <peer_id>")
print("Example: python client.py A B")
sys.exit(1)
client_id = sys.argv[1]
peer_id = sys.argv[2]
server_addr = ("your.server.ip", 9999) # Replace with your server IP
client = TCPClient(client_id, server_addr)
client.start(peer_id)
bash
# Terminal 1 - Run server
python server.py
# Terminal 2 - Client A
python client.py A B
# Terminal 3 - Client B (almost same time)
python client.py B A
本系列的其他文章
- udp穿透的方法:https://blog.csdn.net/yeshennet/article/details/135165159
- udp穿透的方法V2:https://blog.csdn.net/yeshennet/article/details/157170723
- TCP穿透的方法:https://blog.csdn.net/yeshennet/article/details/157170822
AI总结
while true
do
telnet 127.0.0.1 50000
done
这是一个**TCP 自连接(Self-Connection)**的经典案例,完美展示了 TCP 同时打开的实际效果!
🎯 现象解释
发生了什么?
正常情况:
Client (随机端口) → Server (50000端口) ❌ 连接被拒绝
巧合情况:
Client (恰好分配到50000) → Destination (50000端口) ✅ 自己连上自己!
关键条件
当 telnet 127.0.0.1 50000 时:
- 源端口:系统从临时端口池随机分配
- 目标端口:50000
- 巧合:当源端口恰好也是 50000 时
结果:
127.0.0.1:50000 (source) → 127.0.0.1:50000 (dest)
这是同一个套接字四元组!
🔄 握手过程
正常思维的困惑
问:没有人在 listen(),谁发的 ACK?
实际发生的事
1. 内核分配源端口 50000
2. 向 127.0.0.1:50000 发送 SYN
3. SYN 包到达本地,发现目标端口 50000
4. 内核发现:源和目标完全一样!
5. 触发 TCP 同时打开机制
6. 自己给自己回 SYN+ACK
7. 自己给自己发 ACK
8. 连接建立!
关键 :不需要 listen(),因为是同时打开,不是传统的客户端-服务器模式!
💬 "自己聊天"的原因
python
# 发送数据
telnet> hello?
# 数据流向
127.0.0.1:50000 → 127.0.0.1:50000
# 接收数据(就是自己发的!)
hello?
🛡️ 不同系统的处理
| 系统 | 行为 | 原因 |
|---|---|---|
| Linux | 允许自连接 | 严格按照 RFC 793 实现 |
| FreeBSD | 允许自连接 | 同上 |
| Darwin (macOS) | 禁止源端口=目标端口 | 特意规避这个问题 |
c
// Darwin 内核可能的检查
if (src_port == dst_port && src_ip == dst_ip) {
return EADDRINUSE; // 禁止分配相同端口
}
📊 概率分析
为什么要"等一分钟"?
Linux 临时端口范围(默认):
bash
$ cat /proc/sys/net/ipv4/ip_local_port_range
32768 60999
- 可用端口数:28232 个
- 命中目标端口的概率:1/28232 ≈ 0.0035%
所以需要多次尝试才能碰到!
🎓 面试价值
考察点
-
TCP 协议细节
- 三次握手 vs 四次握手(同时打开)
- RFC 793 的完整实现
-
操作系统差异
- Linux/BSD 的标准实现
- macOS 的特殊处理
-
调试思维
- 为什么"没有服务器也能连接"?
- 如何从现象推导本质?
-
实际问题
- 高端口服务器可能遇到的问题
- 如何避免(固定源端口 / 使用低端口)
🔧 复现方法
bash
# 方法1:暴力尝试
while true; do telnet 127.0.0.1 50000; done
# 方法2:提高概率(缩小端口范围)
sudo sysctl -w net.ipv4.ip_local_port_range="50000 50010"
telnet 127.0.0.1 50000 # 很快就会成功
💡 实际意义
1. NAT 打洞
这正是你之前问的 TCP 打洞的原理!通过同时打开建立连接。
2. 防火墙规则
bash
# 可能需要特殊规则防止自连接
iptables -A INPUT -s 127.0.0.1 -d 127.0.0.1 --sport 50000 --dport 50000 -j DROP
3. 服务器设计
避免使用高端口号(建议 < 10000),降低碰撞概率。
这个例子完美展示了 TCP 同时打开不是理论,而是可能实际发生的边缘情况! 🎯