分别基于UDP和TCP打洞实现P2P连接-Python实现
受限制于NAT网关的特性,处于不同局域网内的客户端无法直接连接。即使知道了网关的公网ip和映射端口,任何“不请自来”的数据包都会被NAT网关丢弃,从而无法建立连接。因此需要依赖第三方服务器辅助双方“打洞”建立连接,一旦连接成功建立,即开启了真正的P2P通信,再无需服务器介入。
打洞原理为:首先client A和client B(为一对peer)分别向服务器发起请求,服务器记录下双方的公网ip和端口,将对方的地址返回给各自客户端,这样client A和client B都获得了对方的公网地址,接下来就可以尝试通过打洞互相连接了。之前说过,任何“不请自来”的请求都不被允许,因此client A和client B都应异步的向对方发送请求。假设client B首先向client A发送了一次请求,由于之前双方没有任何通信,显然该请求不会成功,但client B已经在它的网关上留下出站数据包(即打了一个洞),此时若client A向client B发送请求,该请求便可以通过client B的网关并成功与client B建立连接。
*注意:打洞能否成功依旧取决于NAT网关是否支持此类操作,并非100%成功。
具体实现方面,UDP比TCP要简单可靠一些,因为UDP通信并不基于实际的连接,即不留下确定的session,只要在发送数据包时指定接收者即可。我这里在客户端连接服务器时需要输入相同的口令以确定身份,在建立P2P连接时也通过服务器给的特定随机数签名验证身份,打洞成功后实现了一个简单的异步聊天功能。
UDP服务器端
import socket import random s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind(('', 11111)) #记录口令信息 peers = {} while True: message, address = s.recvfrom(2048) message = message.decode() if not message.startswith('#connectChain*'): continue chain = message.replace('#connectChain*', '') if chain not in peers: peers[chain] = address else: print('matchedPeers: ', peers[chain], address) verifySignature = random.randint(10000, 99999) #签名验证,用于peers双方验证身份 #给双方发送peer地址信息和签名 s.sendto(str([peers[chain], verifySignature]).encode(), address) s.sendto(str([address,verifySignature]).encode(), peers[chain]) peers.pop(chain)
UDP客户端(client A和client B均使用一样的代码互相通信)
import socket import threading import time s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) serverAddress = ('ryanxin.cn', 11111) #连接服务器 chain = input('连接口令:') send = ('#connectChain*'+chain).encode() s.sendto(send, serverAddress) message = eval(s.recvfrom(2048)[0].decode()) myPeer = message[0] signature = str(message[1]) print('got myPeer: ', myPeer) peerConnected = False #先连接myPeer,再互发消息 def sendToMyPeer(): #发送包含签名的连接请求 global peerConnected while True: s.sendto(signature.encode(), myPeer) if peerConnected: break time.sleep(1) #发送聊天信息 while True: send_text = input("我方发送:") s.sendto(send_text.encode(), myPeer) def recFromMyPeer(): #接收请求并验证签名or接收聊天信息 global peerConnected while True: message = s.recvfrom(2048)[0].decode() if message == signature: if not peerConnected: print('connected successfully') peerConnected = True elif peerConnected: print('\r对方回复:'+message+'\n我方发送:', end='') sen_thread = threading.Thread(target=sendToMyPeer) rec_thread = threading.Thread(target=recFromMyPeer) sen_thread.start() rec_thread.start() sen_thread.join() rec_thread.join()
TCP部分原理也是类似的,只不过需要先建立连接,这里我把双方的代码分开来写方便实现异步请求打洞。具体的,首先client B向client A发起连接请求(必定失败)尝试打洞,client A等待几秒后向client B正常发起连接请求(成功与否取决于NAT硬件)。
TCP服务器端
import socket sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind(('', 11111)) #记录口令信息 peers = {} sock.listen(5) while True: s, address = sock.accept() message = s.recv(2048).decode() if not message.startswith('#connectChain*'): continue chain = message.replace('#connectChain*', '') if chain not in peers: peers[chain] = (s, address) else: print('matchedPeers: ', peers[chain][1], address) #给双方发送peer地址信息和签名 peers[chain][0].sendall(str(address).encode()) s.sendall(str(peers[chain][1]).encode()) s.close() peers.pop(chain)
client A
import socket import threading import time #连接服务器 s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) serverAddress = ('ryanxin.cn', 11111) s.connect(serverAddress) myAddress = s.getsockname() #本机ip端口 chain = input('连接口令:') send = ('#connectChain*'+chain).encode() s.sendall(send) myPeer = eval(s.recv(2048).decode()) # peer ip端口 print('myAddress: ', myAddress) print('got myPeer: ', myPeer) s.close() #等待对方打洞 time.sleep(3) #发起TCP连接 print('正在发起连接请求') s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind(myAddress) s.connect(myPeer) #聊天 def sendToMyPeer(): while True: send_text = input("我方发送:") s.sendall(send_text.encode()) def recFromMyPeer(): while True: message = s.recv(2048).decode() print('\r对方回复:'+message+'\n我方发送:', end='') sen_thread = threading.Thread(target=sendToMyPeer) rec_thread = threading.Thread(target=recFromMyPeer) rec_thread.start() sen_thread.start() sen_thread.join() rec_thread.join()
client B
import socket import threading import time #连接服务器 s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) serverAddress = ('ryanxin.cn', 11111) s.connect(serverAddress) myAddress = s.getsockname() #本机ip端口 chain = input('连接口令:') send = ('#connectChain*'+chain).encode() s.sendall(send) myPeer = eval(s.recv(2048).decode()) # peer ip端口 print('myAddress: ', myAddress) print('got myPeer: ', myPeer) s.close() #发送一个TCP连接,用于打洞,无需对方接收 s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind(myAddress) try: s.connect(myPeer) except ConnectionRefusedError: print('已尝试打洞') s.close() #监听TCP连接 sc = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sc.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sc.bind(myAddress) sc.listen(1) s, address = sc.accept() sc.close() #聊天 def sendToMyPeer(): while True: send_text = input("我方发送:") s.sendall(send_text.encode()) def recFromMyPeer(): while True: message = s.recv(2048).decode() print('\r对方回复:'+message+'\n我方发送:', end='') sen_thread = threading.Thread(target=sendToMyPeer) rec_thread = threading.Thread(target=recFromMyPeer) rec_thread.start() sen_thread.start() sen_thread.join() rec_thread.join()
4条评论
HBin
尊敬的作者您好!有个问题想请教一下:当C1/C2与S分别在同一局域网下进行tcp打洞时,可以实现功能!但是将S端部署到云端服务器时就无法实现了。作者有什么解决思路吗?
RyanXin
大概率是因为数据包被客户端的或ISP的NAT网关拦截了,并且公网的tcp连接中间链路比较复杂,无法保证后续的tcp握手期间原有的连接线路未发生改变,因此目前没有100%可靠的解决方案,只能自己多调试试试看喽。
HBIN
嗯嗯。我发现Server端部署到阿里云服务器时,client端是通过公网ip与server端连接的,两个client端分别收到的是对方的公网ip,然后socket就连接不上
samoyedsun
你Clinet代码里绑定的myAddress是127.0.0.1:xxxx吧,感觉应该改成0.0.0.0:xxxx;不然只能接收同局域网内的。