最近有意向写一个p2p的分布式应用通讯架构,在构思测试应用间通讯功能的时候遇到了NAT网络问题。在网上找了很多资料,都只有锥形NAT的穿透原理。然并软,经测试得知学校的网络是`对称型NAT`,无奈之下只好自己推导了,我就不信在有内应(内网主机)的情况下不能成功。
以下为成功之后,我得出的方法。
该方法的功能与局限性
1.需要一台具有公网IP的主机/服务器
2.支持两台内网主机在udp(测试成功), tcp(理论上,未测试)协议上进行通讯
3.无法针对某个固定的端口通讯(无法确定双方连上的端口号)
注:其实也可以确定端口号,不过在实现上很麻烦(现在用不上所有没研究)
4.只适用于线性分配端口的对称NAT网络,对于随机的对称NAT或者更加复杂的类型估计无法成功,具体见下文链接(写文章找原理链接的时候发现的一篇牛文,为什么以前没找到....)P2P打洞技术详解_rebootcat的博客-CSDN博客_p2p打洞
方法原理
1.NAT类型介绍
可以在以下链接了解:NAT的几种类型 - 知乎 (zhihu.com)
2.穿透打洞原理与实现细节
因本人技术水平有限,此方法打洞可能有点费系统资源。
一下会附上一些必要代码, 但是整个程序需要读者自己实现
角色:服务器 S (Linux) 内网主机A (Windows) 内网主机B(Windows)
使用UDP
(1) 首先位于公网的服务器S监听固定的端口 p0
in_port_t port = p0;
struct sockaddr_in serv_addr;//服务器地址结构体
memset(&serv_addr, 0, sizeof(serv_addr));
//设置本地服务器要监听的地址和端口
serv_addr.sin_family = AF_INET; //选择协议族为IPV4
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);//监听本地所有IP地址
sock = socket(AF_INET, SOCK_DGRAM, 0);
//建立socket套接字
if (sock == -1)
{
//套接字建立失败记录日志;
cout << "The tcp port " << to_string(port) << " socket establish failed:> " << strerror(errno) << endl;
return 1;
}
//设置要监听的地址与端口
serv_addr.sin_port = htons(port);
//绑定服务器地址结构
if (bind(sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == -1)
{
//服务器地址结构绑定失败,记录日志
cout << "The tcp port " << to_string(port) << " bind failed:> " << strerror(errno) << endl;
return 1;
}
(2) A & B 分别连接服务器 S
SOCKET fd = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
if (fd == SOCK_CREATE_ERR)
{
cerr << "create socket failed, " << GetErrorInfo(ERROR_CODE) << endl;
return -1;
}
struct sockaddr_in addr;
memset(&addr, 0, addrsize);
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
addr.sin_addr.s_addr = inet_addr(ip);
sendto(fd, buf, buflen, 0, (const sockaddr*)addr, sizeof(addr));
//buf 与 buflen需要读者自己设计
(3) 服务器接收到A & B的请求,创建心跳线程维护连接(防止内网网关转发表更新)
//这是写在线程函数内的内容,我用的是std::thread创建的线程
while (!isExit)
{
sendto(sock, NULL, 0, 0, (const sockaddr*)caddr, addrsize);
usleep(500000);
}
(4) 客户端A 向 服务器S 发送对 客户端B 的连接请求(请求的方式须读者设计)
(5) 服务器收到请求后, 分别向客户端A & B 发送端口`预测请求`
`预测请求` :: 让 A & B 用一个新的套接字向其发送信息,以获取目前网关开放端口情况
//必要结构体声明
#pragma pack(1)//设置系统默认对齐字节序为 1
typedef struct
{
uint16_t port; //报文总长
char ip[16]; //用于区分报文内容
}RegistInfo;
#pragma pack()//还原系统默认对其字符序
typedef struct
{
RegistInfo* info;
sockaddr_in* caddr;
string other;
}UserInfo;
uint16_t RSize = sizeof(RegistInfo);
unordered_map<string, UserInfo*> infomap;
/*************************************************************************************/
//端口预测请求发送,写在实现函数内
tmpIp = inet_ntoa(caddr->sin_addr);
UserInfo* tmpinfo = infomap[tmpIp];
forwordPort = *((uint16_t*)(buffer + 8));
sockaddr_in* tmpaddr = infomap[tmpinfo->other]->caddr;
sendto(sock, "port_forecast", 14, 0, (const sockaddr*)tmpaddr, addrsize);
sendto(sock, "port_forecast", 14, 0, (const sockaddr*)caddr, addrsize);
(6) 服务器接收到 A & B 发送的新信息后, 立刻将 A 的端口现状给 B , B 的给 A
(7) A 接收到 B 的端口现状 `m`后, 创建 Ka 个线程用不同的 `socket` 分别连接 B 端的 m+1 —> m + Ka 的端口(Ka个线程连接Ka个端口)
(8) B 接收到 A 的端口现状`n`后,创建Kb个线程用不同的 `socket` 分别连接 A 端的 n + Kb 端口(Kb个线程连接n + Kb这一个端口)
注:因为 A B 所在的NAT网络为线性分配端口的对称NAT网络, 所以在 AB 进行 P2P 连接前,服务器要先得知ab的端口分配规律, 现假设
A端口分配规律为 :: nextPort_A = currPort_A + e
B端口分配规律为 :: nextPort_A = currPort_A + f
e,f为无符号整型
则, Ka = e * num, Kb = f * num。num为预期的端口变化值,由程序编写者自身设置
附上这套流程的示意图
A B 将要连接的端口由服务器通知