实现一个简单的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(recv_folder_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