时间:2022-08-13 09:04:33
摘 要
本文介绍了NAT技术的主要特性以及其4种分类,并对每一种分类的穿透策略进行了分析。针对目前主流的锥NAT,本文使用面向对象的方式封装实现了UDP 穿透NAT,并用C++代码进行了具体实现。
【关键词】锥NAT UDP穿透 面向对象 C++
1 NAT概述
NAT(Network Address Translation,网络地址转换),提供了私有网络内部主机与Internet外网主机连接通信的方法。简单说,就是若干台主机组成的内部专有网络(局域网)与Internet外部主机建立联系的方法。装有NAT软件的路由器被称为NAT路由器,它将外部全球唯一的IP地址转换成多个内部私有IP地址。在合法网络IP地址日益稀少的今天,使更多私有主机访问Internet成为可能。
2 NAT特性
(1)NAT设备(NAT,一般被称为中间件)将内部私有网络与外部网络隔离开来,并且让内部主机独立使用一个IP地址,同时为每个连接动态翻译这些地址。除此之外,当内部主机和外部主机通信时,NAT设备必须要为它分配唯一的端口并连接到同样的地址和端口上(目标主机)。
(2)NAT只允许从内部(私有网络)发起的连接请求,它拒绝了所有不是由内部发起的连接请求。
3 NAT分类及穿透策略
要实现NAT的穿透,就必须全面了解NAT有哪几种类型,针对各自类型如何进行穿透。NAT从不同角度可以有不同的分类,我们讨论的是NAT的穿透,所以,从实现的技术角度,NAT总的分为两大类:锥NAT和对称NAT。锥NAT又可以细分为三个类型:全锥NAT、限制性锥NAT、端口限制性锥NAT。类型不同,穿透的情况有所不同,下面来分别探讨:
3.1 全锥NAT
全锥NAT是把来自相同内部IP地址和端口的请求映射到相同外部IP地址和端口。任意一个外部主机均可通过该映射发送信息到该内部主机。前面讲NAT有一特性,它拒绝所有不是由内部发起的通往外部的连接。全锥NAT是一例外,从外部主动发来的连接,它不会拒绝,所以,穿透这类型NAT最为容易,只需要获取内部主机对应的外部IP和端口号,便可以直接发送信息到该内部主机。
3.2 限制性锥NAT
限制性锥NAT是把所有来自相同内部IP地址和端口的请求映射到相同外部IP地址和端口。和全锥NAT不同的是,只有当内部主机先给外部主机发送信息后, 该外部主机才能向该内部主机发送信息。也就是说,不请自来的信息,该类型NAT会毫不犹豫的丢弃,所以我们假设两台都处在各自不同局域网内部的主机A和B,A单方面向B发送信息,就会被B局域网NAT丢弃,B单方面向A发送信息,就会被A局域网NAT丢弃,导致连接失败。
3.3 端口限制性锥NAT
端口限制性锥NAT与限制性锥NAT相似,只是多了个端口号的限制,即只有内部主机先向外部地址:端口发送信息,该外部主机才能使用特定的端口向内部主机发送信息。该类型NAT的穿透比限制性锥NAT的穿透多了个端口号的限制,所以穿透了该类型NAT,限制性锥NAT也自然会穿透。
3.4 对称NAT
对称NAT情况与上述3种类型都不同,当同一内部主机使用相同的端口和不同地址的外部主机进行通信时,对称NAT会重新创建一个Session,为这个Session分配不同的端口号,也就是说,当一个私网内主机和外部多个不同主机通信时,对称NAT并不会像锥(Cone,全锥,限制性锥,端口限制性锥)NAT那样分配同一个端口。而是重新建立一个Session,重新分配一个端口。因为端口号的不确定,所以穿透该类型NAT的机会很小。目前绝大多数NAT都是锥NAT,锥NAT的穿透也是本文讨论的重点。不过,仍有两种方法有可能穿透对称NAT:同时开放TCP、UDP端口猜测。这两种方法(包括TCP穿透NAT尝试),将在以后的论文中探讨。
4 面向对象封装UDP穿透NAT技术实现
面向对象是当今软件编程的主流技术,它为软件工程的开发提供了相当大的便捷,也给当今软件产品的丰富多彩奠定了基础。运用面向对象技术可以使你的代码异常的清晰明了,也给代码的移植和重用提供了相当大的便捷。本文使用面向对象的方式实现了锥NAT的穿透。因为是面向对象的实现方式,所以可以很容易的将代码嵌套到其它的软件工程中去,为点对点通信提供了便捷。
本文使用的编程语言是C++,该语言既兼容了C语言面向过程的特性,又涵盖了面向对象的所有特性,本文使用后者。传输协议使用的是UDP协议。实现点对点的NAT穿透,需要一台中间服务器来协助打洞,服务器端代码需实现的功能较为简单,所以,我们先从服务器端开始。
既然是面向对象,首先我们需要自定义一个类,将所有需实现的功能封装到该类中,以便以后的调用,假设类名为:NAT_UDP_SERVER,该类所有成员的声明放到名为NAT_UDP_SERVER.h的头文件中,所有成员函数的实现在NAT_UDP_SERVER.cpp文件中。以下是该类所有成员的声明:
class NAT_UDP_SERVER
{
public:
NAT_UDP_SERVER();
NAT_UDP_SERVER();
void InitWinSock();
void mksock();
void start_bind();
void startlisten();
UserListNode GetUser(char *username);
private:
UserList ClientList;
SOCKET socket_UDP;
sockaddr_in local;
sockaddr_in sender;
MESSAGE recvbuf;
};
这就是面向对象的好处,我们可以一览它的全貌,包括这个类有哪些数据成员,成员函数大概都做些什么(从名称上可以略知一二),简单的说,服务器所要做的一是监听各个客户端(内网或外网)发送的所有的连接请求,二是在必要时候,需要转达给某个客户端其它客户端的一些节点信息(外网IP和端口)。
声明中的Socket又称“套接字”,应用程序通常通过Socket向网络发出请求或者应答网络请求。这里不再详述。成员函数void InitWinSock()和void mksock()做了socket的初始化工作,函数void start_bind()将“套接字”绑定到固定端口上,为监听做好准备。
类NAT_UDP_SERVER最重要的函数是void startlisten(),该函数启动了监听程序,并使用switch语句中创建的子程序来处理接收到的数据包,关于数据包的格式,本文自定义了几种数据结构(NAT_UDP_SERVER.h头文件中),以方便信息的传递,这里列举一例:
struct MESSAGE
{
INT32 MessageType;
union _message
{
REGISTER_MESSAGE registermember;
LOGOUT_MESSAGE logoutmember;
P2P_BURROW burrowmessage;
}message;
};
结构体MESSAGE中包含了信息类型、注册成员信息、注销成员信息、打洞成员信息。嵌套的结构体的详细信息会在完整代码给出,这里不再一一列举。
服务器端需要拥有一个固定的外部IP及端口,以方便其它外网客户端的连接,需要实现的功能是,当多个外网客户端节点连接到该服务器时,服务器需将各个节点信息(外部IP及端口)记录下来,当节点A想直连节点B,服务器需将A节点信息发送给B节点,再将B节点信息发送给A节点,当A节点和B节点都拥有对方节点信息后,便具备打洞条件。前面介绍过,在锥NAT中,端口限制性锥NAT是最为严格的锥NAT,一切不请自来的信息均会被抛弃,只有内部主机主动连接外部主机后,外部主机发送过来的信息才会被放行。本文使用的NAT穿透技术,简单讲,就是打一个时间差,A节点发送数据包到B节点,会被B节点NAT丢弃,B节点此时虽然没有收到任何信息,但如果B节点人为的向A节点再发送一个数据包,那么A节点的NAT就会误认为这个数据包和刚才A节点发送出去的数据包属于同一个Session,而予以放行。同理,A节点再发送数据包到B节点时,也会被B节点NAT放行,这时,A节点与B节点的直连已经建立,穿透已经实现。
下面用代码来具体实现,服务器端的代码主要实现的是保存节点信息和转发节点信息的功能。重点讨论一下客户端代码:
客户端自定义的类名为:NAT_UDP_CLIENT,关于类的声明放在NAT_UDP_CLIENT.h头文件中,以下是类声明的全貌:
class NAT_UDP_CLIENT
{
public:
NAT_UDP_CLIENT();
~NAT_UDP_CLIENT();
void InitWinSock();
void mksock();
UserListNode GetUser(char *username);
void BindSock();
void ConnectToServer(char *username, char *serverip);
void OutputUsage();
bool SendMessageTo(char *UserName, char *Message);
void ParseCommand(char * CommandLine);
static DWORD WINAPI RecvThreadProc(LPVOID lpParameter);
private:
UserList ClientList;
SOCKET socket_UDP;
sockaddr_in sin;
};
该类自定义了一个命令处理函数void ParseCommand(char * CommandLine),它占据了客户端的主线程,而客户端需要时时接收来自外部的信息,所以需要再创建一个线程专门负责接收外部发来的信息。
直接创建线程函数很容易,但本文是面向对象的技术实现,一般来说,线程函数不能作为类的成员函数出现,我们需要将线程函数写入类中,这里需做几个特殊处理:
(1)将线程函数声明为static;
(2)在该成员函数的实现里,定义该类指针
NAT_UDP_CLIENT* p = (NAT_UDP_CLIENT*)lpParameter,通过该指针来访问类的其它成员;
(3)创建线程时,将该类定义的对象udp_client的地址作为参数传递给线程函数:
HANDLE threadhandle = CreateThread(NULL, 0,NAT_UDP_CLIENT::RecvThreadProc, (void*)&udp_client, NULL, NULL)。
此外,成员函数void ConnectToServer(char *username, char *serverip)实现了客户端节点连接服务器并注册的功能;而成员函数bool SendMessageTo(char *UserName, char *Message)是实现打洞的关键函数,具体步骤如下:
(1)在函数的开始,首先需要获得B客户端节点信息:
UserIP = (*UserIterator)->ip;
UserPort = (*UserIterator)->port;
(2) 因为全锥NAT是可以直接发送数据包被对方接收的,所以尝试直接向B节点发送数据包:
sendto(socket_UDP, (const char *)&realmessage, MessageHead.StringLen, 0, (const sockaddr*)&remote, sizeof(remote));
(3)如果没有接收到对方主机的任何回应,表明对方使用的NAT不是全锥NAT,此时就需要服务器的协助来完成打洞:首先向服务器发送一数据包sendto(socket_UDP, (const char*)&transMessage, sizeof(transMessage), 0, (const sockaddr*)&server, sizeof(server));数据包的内容指令是BURROW,目的是通过服务器告诉B客户端节点需先向A客户端节点(本地)发送一条信息;
(4) A客户端节点在向服务器发出请求后程序中断3秒钟Sleep(3000),等待对方先发送信息过来;虽然A客户端节点也不会收到任何信息,但3秒过后,A客户端节点(本机)再次调用发送程序向B客户端节点发送数据包:sendto(socket_UDP, (const char *)&realmessage, MessageHead.StringLen, 0, (const sockaddr*)&remote, sizeof(remote)),
(5)最终,B客户端节点会显示收到一个消息:printf("收到一个消息:%s\n",comemessage),表明A、B节点已成功建立直连,双方可互发信息,NAT穿透成功。
5 结束语
现实中使用的绝大多数NAT路由器均可使用本文提供的方法穿透成功,而面向对象封装所有模块功能,又为代码的移植与重用提供便利,最后引入多线程机制,又使代码实现的功能变的更加的灵活与丰富。
参考文献
[1](美)J.D.Wegner.(美)RobertRockell等著.赵英等.IP地址管理与子网划分[M].北京:机械工业出版社,2001.
[2] Boulton C.Rosenberg J.Camarillo G.NAT traversal practices for client-server SIP. RFC 6314,July 2011.
[3]朱文杰.吕锋.使用UDP穿越P2P网络中NAT的方法[J].微计算机应用,2007(12).
作者单位
长治学院计算机系 山西省长治市 046011