實現一個簡單的p2p文件傳輸,主要解決NAT穿透問題,使用tcp協議傳輸。
NAT背景介紹
簡介
NAT(Network Address Translation ,網絡地址轉換) 是一種廣泛應用的解決IP 短缺的有效方法, NAT 將內網地址轉和端口號換成合法的公網地址和端口號,建立一個會話,與公網主機進行通信。
NAT 不僅實現地址轉換,同時還起到防火牆的作用,隱藏內部網絡的拓撲結構,保護內部主機。 NAT 不僅完美地解決了 lP 地址不足的問題,而且還能夠有效地避免來自網絡外部的攻擊,隱藏並保護網絡內部的計算機。 這樣對於外部主機來說,內部主機是不可見的。
但是,對於P2P 應用來說,卻要求能夠建立端到端的連接,所以如何穿透NAT 也是P2P 技術中的一個關鍵。
分類
NAT 從表面上看有三種類型:
靜態NAT :靜態地址轉換將內部私網地址與合法公網地址進行一對一的轉換,且每個內部地址的轉換都是確定的。
動態NAT :動態地址轉換也是將內部本地地址與內部合法地址一對一的轉換,但是動態地址轉換是從合法地址池中動態選擇一個未使用的地址來對內部私有地址進行轉換。
地址端口轉換NAPT :它也是一種動態轉換,而且多個內部地址被轉換成同一個合法公網地址,使用不同的端口號來區分不同的主機,不同的進程。
從實現的技術角度,又可以將NAT 分成如下幾類:
全錐NAT :全錐NAT 把所有來自相同內部IP 地址和端口的請求映射到相同的外部IP 地址和端口。任何一個外部主機均可通過該映射發送數據包到該內部主機。
限制性錐NAT :限制性錐NAT 把所有來自相同內部IP 地址和端口的請求映射到相同的外部IP 地址和端口。但是, 和全錐NAT 不同的是:只有當內部主機先給外部主機發送數據包, 該外部主機才能向該內部主機發送數據包。
端口限制性錐NAT :端口限制性錐NAT 與限制性錐NAT 類似, 只是多了端口號的限制, 即只有內部主機先向外部地址:端口號對發送數據包, 該外部主機才能使用特定的端口號向內部主機發送數據包。
對稱NAT :對稱NAT 與上述3 種類型都不同, 不管是全錐NAT ,限制性錐NAT 還是端口限制性錐NAT ,它們都屬於錐NAT (Cone NAT )。當同一內部主機使用相同的端口與不同地址的外部主機進行通信時, 對稱NAT 會重新建立一個Session ,為這個Session 分配不同的端口號,或許還會改變IP 地址。
解決問題
了解了NAT之后,開始思考如何解決兩台在不同的NAT后面的主機直接相連的問題。靜態NAT只要知道所給的公網地址即可,不在我們討論的范圍內。
思考問題並找到重點
假設有主機A和主機B分別在兩個NAT轉換設備NATA和NATB后面。
A與B之間要通信,我們可假設NATA中轉發表有下面這個表項:
內網IP:Port | 公網IP:Port |
---|---|
192.168.0.2:7000 | 202.103.142.29:5000 |
NATB轉發表中如下:
內網IP:Port | 公網IP:Port |
---|---|
192.168.1.12:8000 | 221.10.145.84:6000 |
這樣A中綁定了 192.168.0.2:7000 的socket只需要連接221.10.145.84:6000即可與B中綁定了192.168.1.12:8000的socket進行通信。B同理。
所以如何在轉發表中留下這樣一個表項並讓對方知道並可以連接就是我們要解決的重點。
解決重點
首先轉發表中沒有轉發表項的話,兩方無論如何也是無法連上的。這時候我們就需要借助有公網ip的Server幫我們搭個橋。
還是使用這張圖
A與Server 129.208.12.38 相連,在NAT-A中插入
內網IP:Port | 公網IP:Port |
---|---|
192.168.0.2:7000 | 202.103.142.29:5000 |
B也與Server 129.208.12.38 相連,在NAT-B中插入
內網IP:Port | 公網IP:Port |
---|---|
192.168.1.12:8000 | 221.10.145.84:6000 |
然后服務器將 A 的源地址和端口 202.103.142.29:5000 發給 B, 將 B 的源地址和端口 221.10.145.84:6000 發給 A 。這樣雙方就有了對方的外部IP地址和端口的信息。
這時候對於全錐NAT來說就可以直接相連了,但是對於 端口限制性錐NAT 和 限制性錐NAT 還不可以直接相連。因為只有當內部主機先給外部主機發送數據包, 該外部主機才能向該內部主機發送數據包。
如果有一方是(端口)限制性錐形NAT,就得由這一方作為客戶端主動相連,另一方作為服務端進行連接。如果雙方都是(端口)限制性錐形NAT,就得先由一方先行與對方連接,結果必然失敗,但是在這一方的NAT中保留了接受對方IP和端口的信息,稱之為打孔。這時候另一方再與先發送請求的一方連接即可成功。
對於對稱NAT, 由於當同一內部主機使用相同的端口與不同地址的外部主機進行通信時, 對稱NAT都會重新建立一個Session ,為這個Session 分配不同的端口號,或許還會改變IP地址。穿透起來非常麻煩。若有興趣可參考文后鏈接。
對於udp來說,直接發送數據即可。但是對於tcp來說,由於需要在短時間內綁定同一端口連接不同地址,所以需要設置socket選項SOL_SOCKET level的SO_REUSEADDR為True。一般來說,一個端口釋放后會等待兩分鍾之后才能再被使用,SO_REUSEADDR是讓端口釋放后立即就可以被再次使用。
實現代碼(Python)
服務器於阿里雲,長春與重慶連接試驗成功。可以本地指定不同端口看看表面效果。
獲取本機ip地址在本地地址較多時可能獲取得不對。還未找到辦法。
發送雙方ip:port信息時 我根據先來后到標記了 1 和 0 ,通過判斷這個來決定是否為主動連接那一方。
僅為實驗代碼,多有紕漏請指出。
主機端:
1 import os 2 from time import sleep 3 import struct 4 import socket 5 6 def p2p_connect(local_address, local_port, send_file_path, recv_folder_path,server_address,server_port): 7 if not os.path.exists(send_file_path): 8 raise FileNotFoundError(send_file_path) 9 if not os.path.exists(recv_folder_path): 10 os.mkdirs(recv_folder_path) # 若為windows 只有mkdir 11 sock = socket.socket(family=socket.AF_INET, type=socket.SOCK_STREAM) 12 sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 13 sock.bind((local_address, local_port)) 14 sock.connect((server_address, server_port)) 15 rcv_msgs = sock.recv(1024).decode() 16 while rcv_msgs.startswith("#"): 17 print(rcv_msgs) 18 rcv_msgs = sock.recv(1024).decode() 19 rcv_msgs = rcv_msgs.split("|") 20 remote_addr = rcv_msgs[0] 21 remote_port = int(rcv_msgs[1]) 22 is_server = rcv_msgs[2] == "0" 23 print(rcv_msgs) 24 sock.close() 25 26 if is_server: 27 try_conn = socket.socket(family=socket.AF_INET, type=socket.SOCK_STREAM) # 打孔 28 try_conn.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 29 try_conn.bind((local_address, local_port)) 30 try_conn.connect_ex((remote_addr, remote_port)) 31 try_conn.close() 32 recv_sock = socket.socket(family=socket.AF_INET, type=socket.SOCK_STREAM) 33 recv_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 34 recv_sock.bind((local_address, local_port)) 35 recv_sock.listen(1) 36 conn, addr = recv_sock.accept() 37 conn.sendall(os.path.split(send_file_path)[1].encode()) # 發送文件名 38 with open(send_file_path, "rb") as f: 39 size = os.path.getsize(send_file_path) 40 print("共發送", size, "字節") 41 conn.sendall(struct.pack(">I", size)) # 發送文件大小 42 data = f.read(1024) 43 while data: 44 conn.sendall(data) 45 data = f.read(1024) 46 conn.sendall("") 47 conn.close() 48 recv_sock.close() 49 else: 50 conn = socket.socket(family=socket.AF_INET, type=socket.SOCK_STREAM) 51 while conn.connect_ex((remote_addr, remote_port)) != 0: # 注意網絡情況,可能為死循環 52 sleep(1) 53 file_name = conn.recv(1024).decode() # 接收文件名 54 size = struct.unpack(">I", conn.recv(1024))[0] # 接收文件大小 55 print("接收 : ", file_name, " (", size, "bytes)") 56 with open(os.path.join(recv_folder_path,file_name), "wb") as f: 57 count = 0 58 data = conn.recv(1024) 59 print("\r已完成 : {:.0f}%".format(count / size*100), end="", flush=True) 60 while data: 61 f.write(data) 62 length = len(data) 63 count += length 64 print("\r已完成 : {:.0f}%".format(count / size*100), end="", flush=True) 65 data = conn.recv(1024) 66 print(" 傳輸完成") 67 conn.close() 68 69 if __name__ == '__main__': 70 name = socket.gethostname() 71 local_port = 22000 # 本地端口 72 local_address = socket.gethostbyname(name) #本地地址 73 file_path="text.xml" # 待傳輸文件 74 folder_path="" # 接收文件文件夾 75 remote_address="123.45.67.89" # 服務器地址 76 remote_port=30000 # 服務器端口 77 p2p_connect(local_address,local_port,file_path,folder_path,remote_address,30000)
服務器端:
1 import socket 2 3 sock = socket.socket(family=socket.AF_INET, type=socket.SOCK_STREAM) 4 sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 5 sock.bind(("123.45.67.89", 30000)) 6 sock.listen(5) 7 8 conn1, addr1 = sock.accept() 9 conn1_info = addr1[0] + "|" + str(addr1[1]) + "|0" 10 conn1.sendall("#你已連接上,請等待另一名用戶\n".encode()) 11 conn2, addr2 = sock.accept() 12 conn2_info = addr2[0] + "|" + str(addr2[1]) + "|1" 13 conn2.sendall("#你已連接上,另一名用戶已就緒\n".encode()) 14 15 conn1.sendall(conn2_info.encode()) 16 conn2.sendall(conn1_info.encode()) 17 18 conn1.close() 19 conn2.close() 20 sock.close()
背景參考: P2P,UDP和TCP穿透NAT