NAT穿透(UDP打洞)

1、NAT(Network Addres.

1、NAT(Network Address Translator)介绍

NAT有两大类,基本NAT和NAPT。

1.1、基本NAT

静态NAT:一个公网IP对应一个内部IP,一对一转换

动态NAT:N个公网IP对应M个内部IP,不固定的一对一转换关系

 1.2、NAPT(Network Address/Port Translator)

现在基本使用这种,又分为对称和锥型NAT。

锥型NAT,有完全锥型、受限制锥型、端口受限制锥型三种:

  • a)Full Cone NAT(完全圆锥型):从同一私网地址端口192.168.0.8:4000发至公网的所有请求都映射成同一个公网地址端口1.2.3.4:62000 ,192.168.0.8可以收到任意外部主机发到1.2.3.4:62000的数据报。
  • b)Address Restricted Cone NAT (地址限制圆锥型):从同一私网地址端口192.168.0.8:4000发至公网的所有请求都映射成同一个公网地址端口1.2.3.4:62000,只有当内部主机192.168.0.8先给服务器C 6.7.8.9发送一个数据报后,192.168.0.8才能收到6.7.8.9发送到1.2.3.4:62000的数据报。
  • c)Port Restricted Cone NAT(端口限制圆锥型):从同一私网地址端口192.168.0.8:4000发至公网的所有请求都映射成同一个公网地址端口1.2.3.4:62000,只有当内部主机192.168.0.8先向外部主机地址端口6.7.8.9:8000发送一个数据报后,192.168.0.8才能收到6.7.8.9:8000发送到1.2.3.4:62000的数据报。

 对称NAT

把所有来自相同内部IP地址和端口号,到特定目的IP地址和端口号的请求映射到相同的外部IP地址和端口。如果同一主机使用不同的源地址和端口对,发送的目的地址不同,则使用不同的映射。只有收到了一个IP包的外部主机才能够向该内部主机发送回一个UDP包。对称的NAT不保证所有会话中的(私有地址,私有端口)和(公开IP,公开端口)之间绑定的一致性。相反,它为每个新的会话分配一个新的端口号。

 

对称NAT是一个请求对应一个端口,非对称NAT是多个请求对应一个端口(象锥形,所以叫Cone NAT)。

1.3、检测NAT类型:

连接服务器为A,NAT检测服务器为B。

第一步:当一个接收客户端(Endpoint-Receiver ,简称 EP-R)需要接收文件信息时,在其向连接服务器发送文件请求的同时紧接着向检测服务器发送NAT检测请求。此处再次强调是“紧接着”,因为对于对称型NAT来说,这个操作可以直接算出其地址分配的增量(⊿p)。

第二步:当EP-R收到A或B的反馈信息时发现其外部地址与自身地址不同时就可以确定自己在NAT后面;否则,就是公网IP。

第三步:由服务器A向B发送其获得的EP-R的外部映射地址(IPa/Porta),服务器B获得后进行比较,如果端口不同,则说明这是对称型NAT,同时可以直接计算出其分配增量:

⊿p=Portb-Porta

第四步:如果端口号相同,则由B向EP-R的Porta发送连接请求,如果EP-R有响应,则说明EP-R没有IP和Port的限制,属于全ConeNAT类型。

第五步:如果没有响应,则由服务器B使用其新端口b’向EP-R的Portb端口发送连接请求,如果有响应,则说明EP-R只对IP限制,属于限制性ConeNAT类型;否则就是对IP和port都限制,属于端口限制性ConeNAT类型。

通过上述五步基本可以全部检测出EP-R是否在公网,还是在某种NAT后面。

 

 1.4、NAT映射老化时间

这也是一项可选配置任务,可根据需要为NAT 地址映射表配置老化时间,以控制用户对NAT 配置的使用,确保内、外网的通信安全。

配置NAT 地址映射表项老化时间的方法也很简单,只须在系统视图下使用firewall-nat session { dns | ftp | ftp-data | http | icmp | tcp | tcp-proxy | udp | sip | sip-media | rtsp |rtsp-media }aging-time time-value 命令配置即可。参数 time-value的取值范围为1~65 535的整数秒。如果要配置多个会话表项的超时时间需要分别用本命令配置。

缺省情况下,各协议的老化时间为:DNS(120 s)、ftp(120 s)、ftp-data(120 s)、HTTP(120 s)、icmp(20 s)、tcp(600 s)、tcp-proxy(10 s)、udp(120 s)、sip(1 800 s)、sip-media ( 120 s )、rtsp ( 60 s )、rtsp-media ( 120 s ), 可用undo firewall-natsession { all | dns | ftp | ftp-data | http | icmp | tcp | tcp-proxy | udp | sip | sip-media | rtsp |rtsp-media } aging-time 命令恢复对应会话表项的超时时间为缺省值。


 

2、UDP打洞

2.1、p2p可实现的条件需要:

1、 中间服务器保存信息、并能发出建立UDP隧道的命令

2、 网关均要求为Cone NAT类型。Symmetric NAT不适合。

3、 完全圆锥型网关可以无需建立udp隧道,但这种情况非常少,要求双方均为这种类型网关的更少。

4、 假如X1网关为Symmetric NAT, Y1为Address Restricted Cone NAT 或Full Cone NAT型网关,各自建立隧道后,A1可通过X1发送数据报给Y1到B1(因为Y1最多只进行IP级别的甄别),但B2发送给X1的将会被丢弃(因为发送来的数据报中端口与X1上存在会话的端口不一致,虽然IP地址一致),所以同样没有什么意义。

5、 假如双方均为Symmetric NAT的情形,新开了端口,对方可以在不知道的情况下尝试猜解,也可以达到目的,但这种情形成功率很低,且带来额外的系统开支,不是个好的解决办法。

6、 不同网关型设置的差异在于,对内会采用替换IP的方式、使用不同端口不同会话的方式,使用相同端口不同会话的方式;对外会采用什么都不限制、限制IP地址、限制IP地址及端口。

7、 这里还没有考虑同一内网不同用户同时访问同一服务器的情形,如果此时网关采用AddressRestricted Cone NAT 或Full Cone NAT型,有可能导致不同用户客户端可收到别人的数据包,这显然是不合适的。

 

2.2、udp和tcp打洞

为什么网上讲到的P2P打洞基本上都是基于UDP协议的打洞?难道TCP不可能打洞?还是TCP打洞难于实现?
假设现在有内网客户端A和内网客户端B,有公网服务端S。
如果A和B想要进行UDP通信,则必须穿透双方的NAT路由。假设为NAT-A和NAT-B。

A发送数据包到公网S,B发送数据包到公网S,则S分别得到了A和B的公网IP,
S也和A B 分别建立了会话,由S发到NAT-A的数据包会被NAT-A直接转发给A,
由S发到NAT-B的数据包会被NAT-B直接转发给B,除了S发出的数据包之外的则会被丢弃。
所以:现在A B 都能分别和S进行全双工通讯了,但是A B之间还不能直接通讯。

解决办法是:A向B的公网IP发送一个数据包,则NAT-A能接收来自NAT-B的数据包
并转发给A了(即B现在能访问A了);再由S命令B向A的公网IP发送一个数据包,则
NAT-B能接收来自NAT-A的数据包并转发给B了(即A现在能访问B了)。

以上就是“打洞”的原理。

 

为了保证A的路由器有与B的session,A要定时与B做心跳包,同样,B也要定时与A做心跳,这样,双方的通信通道都是通的,就可以进行任意的通信了。

但是TCP和UDP在打洞上却有点不同。这是因为伯克利socket(标准socket规范)的
API造成的。
UDP的socket允许多个socket绑定到同一个本地端口,而TCP的socket则不允许。
这是这样一个意思:A B要连接到S,肯定首先A B双方都会在本地创建一个socket,
去连接S上的socket。创建一个socket必然会绑定一个本地端口(就算应用程序里面没写
端口,实际上也是绑定了的,至少java确实如此),假设为8888,这样A和B才分别建立了到
S的通信信道。接下来就需要打洞了,打洞则需要A和B分别发送数据包到对方的公网IP。但是
问题就在这里:因为NAT设备是根据端口号来确定session,如果是UDP的socket,A B可以
分别再创建socket,然后将socket绑定到8888,这样打洞就成功了。但是如果是TCP的
socket,则不能再创建socket并绑定到8888了,这样打洞就无法成功。

 

 

UDP打洞的过程大致如此:

1、双方都通过UDP与服务器通讯后,网关默认就是做了一个外网IP和端口号 与你内网IP与端口号的映射,这个无需设置的,服务器也不需要知道客户的真正内网IP

2、用户A先通过服务器知道用户B的外网地址与端口

3、用户A向用户B的外网地址与端口发送消息,

4、在这一次发送中,用户B的网关会拒收这条消息,因为它的映射中并没有这条规则。

5、但是用户A的网关就会增加了一条允许规则,允许接收从B发送过来的消息

6、服务器要求用户B发送一个消息到用户A的外网IP与端口号

7、用户B发送一条消息,这时用户A就可以接收到B的消息,而且网关B也增加了允许规则

8、之后,由于网关A与网关B都增加了允许规则,所以A与B都可以向对方的外网IP和端口号发送消息。

 

TCP打洞技术:
tcp打洞也需要NAT设备支持才行。
tcp的打洞流程和udp的基本一样,但tcp的api决定了tcp打洞的实现过程和udp不一样。
tcp按cs方式工作,一个端口只能用来connect或listen,所以需要使用端口重用,才能利用本地nat的端口映射关系。(设置SO_REUSEADDR,在支持SO_REUSEPORT的系统上,要设置这两个参数。)

连接过程:(以udp打洞的第2种情况为例(典型情况))
nat后的两个peer,A和B,A和B都bind自己listen的端口,向对方发起连接(connect),即使用相同的端口同时连接和等待连接。因为A和B发出连接的顺序有时间差,假设A的syn包到达B的nat时,B的syn包还没有发出,那么B的nat映射还没有建立,会导致A的连接请求失败(连接失败或无法连接,如果nat返回RST或者icmp差错,api上可能表现为被RST;有些nat不返回信息直接丢弃syn包(反而更好)),(应用程序发现失败时,不能关闭socket,closesocket()可能会导致NAT删除端口映射;隔一段时间(1-2s)后未连接还要继续尝试);但后发B的syn包在到达A的nat时,由于A的nat已经建立的映射关系,B的syn包会通过A的nat,被nat转给A的listen端口,从而进去三次握手,完成tcp连接。

从应用程序角度看,连接成功的过程可能有两种不同表现:(以上述假设过程为例)
1、连接建立成功表现为A的connect返回成功。即A端以TCP的同时打开流程完成连接。
2、A端通过listen的端口完成和B的握手,而connect尝试持续失败,应用程序通过accept获取到连接,最终放弃connect(这时可closesocket(conn_fd))。
多数Linux和Windows的协议栈表现为第2种。

但有一个问题是,建立连接的client端,其connect绑定的端口号就是主机listen的端口号,或许这个peer后续还会有更多的这种socket。虽然理论上说,socket是一个五元组,端口号是一个逻辑数字,传输层能够因为五元组的不同而区分开这些socket,但是是否存在实际上的异常,还有待更多观察。

 

2.3、另外的问题

1、Windows XP SP2操作系统之前的主机,这些主机不能正确处理TCP同时开启,或者TCP套接字不支持SO_REUSEADDR的参数。需要让AB有序的发起连接才可能完成。

上述tcp连接过程,仅对NAT1、2、3有效,对NAT4(对称型)无效。
由于对称型nat通常采用规律的外部端口分配方法,对于nat4的打洞,可以采用端口预测的方式进行尝试。

 

2.4、一些常用技术

ALG(应用层网关):它可以是一个设备或插件,用于支持SIP协议,主要类似与在网关上专门开辟一个通道,用于建立内网与外网的连接,也就是说,这是一种定制的网关。更多只适用于使用他们的应用群体内部之间。

UpnP:它是让网关设备在进行工作时寻找一个全球共享的可路由IP来作为通道,这样避免端口造成的影响。要求设备支持且开启upnp功能,但大部分时候,这些功能处于安全考虑,是被关闭的。即时开启,实际应用效果还没经过测试。

STUN(Simple Traversalof UDP Through Network):这种方式即是类似于我们上面举例中服务器C的处理方式。也是目前普遍采用的方式。但具体实现要比我们描述的复杂许多,光是做网关Nat类型判断就由许多工作,RFC3489中详细描述了。

TURN(Traveral Using Relay NAT):该方式是将所有的数据交换都经由服务器来完成,这样NAT将没有障碍,但服务器的负载、丢包、延迟性就是很大的问题。目前很多游戏均采用该方式避开NAT的问题。这种方式不叫p2p。

ICE(Interactive Connectivity Establishment):是对上述各种技术的综合,但明显带来了复杂性。

 

下面是代码,实现的是A回射B的消息。

服务器S的代码:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <sys/types.h>
#include <string.h>
#include <arpa/inet.h>
#include <errno.h>
#include <error.h>
//中间枢纽获得A客户端的外网ip和port发送给客户端B,获得客户端B的外网ip和port发送给A
//B通过A打的洞发数据给A,这时候A拒收B的消息,因为A的nat映射中没有B的信息,但是这次通
//信却在B的网管中添加了映射可以接受A的
//消息,同理A通过B打的洞发数据给B,这时候由于B可以接受A的消息,所以数据接收成功且在A
//的映射中加入了B的信息,从而A与B可以跨服务器通信。实现p2p
/* 由于已知的外网服务器S可能并没有AB客户端的映射关系,所以要先建立A与S 还有 B与S之间的映射,这样才能进行udp穿透。 */
 
#define ERR_EXIT(m)\
    do{\
        perror(m);\
        exit(1);\
    }while(0)
 
/* 用来记录客户端发送过来的外网ip+port */
typedef struct{
    struct in_addr ip;
    int port;
}clientInfo;
 
int main()
{
    /* 一个客户端信息结构体数组,分别存放两个客户端的外网ip+port */
    clientInfo info[2];
    /* 作为心跳包需要接收的一个字节 */
    /* char ch; */ 
    char str[10] = {0};
 
    /* udp socket描述符 */
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if(sockfd == -1)
        ERR_EXIT("SOCKET");
 
    struct sockaddr_in serveraddr;
    memset(&serveraddr, 0, sizeof(serveraddr));
    serveraddr.sin_addr.s_addr = inet_addr("0.0.0.0");
    serveraddr.sin_port = htons(8888);
    serveraddr.sin_family = AF_INET;    
 
    int ret = bind(sockfd, (struct sockaddr *)&serveraddr, sizeof(serveraddr));
    if(ret == -1)
        ERR_EXIT("BIND");
 
    /* 服务器接收客户端发来的消息并转发 */
    while(1)
    {
        bzero(info, sizeof(clientInfo)*2);
        /* 接收两个心跳包并记录其与此链接的ip+port */
        socklen_t addrlen = sizeof(struct sockaddr_in);
        /* recvfrom(sockfd, &ch, sizeof(ch), 0, (struct sockaddr *)&serveraddr, &addrlen); */
        recvfrom(sockfd, str, sizeof(str), 0, (struct sockaddr *)&serveraddr, &addrlen);
        memcpy(&info[0].ip, &serveraddr.sin_addr, sizeof(struct in_addr));
        info[0].port = serveraddr.sin_port;
 
        printf("A client IP:%s \tPort:%d creat link OK!\n", inet_ntoa(info[0].ip), ntohs(info[0].port));
 
        /* recvfrom(sockfd, &ch, sizeof(ch), 0, (struct sockaddr *)&serveraddr, &addrlen); */
        recvfrom(sockfd, str, sizeof(str), 0, (struct sockaddr *)&serveraddr, &addrlen);
        memcpy(&info[1].ip, &serveraddr.sin_addr, sizeof(struct in_addr));
        info[1].port = serveraddr.sin_port;
 
        printf("B client IP:%s \tPort:%d creat link OK!\n", inet_ntoa(info[1].ip), ntohs(info[1].port));
 
        /* 分别向两个客户端发送对方的外网ip+port */
        printf("start informations translation...\n");
        serveraddr.sin_addr = info[0].ip;
        serveraddr.sin_port = info[0].port;
        sendto(sockfd, &info[1], sizeof(clientInfo), 0, (struct sockaddr *)&serveraddr, addrlen);
 
        serveraddr.sin_addr = info[1].ip;
        serveraddr.sin_port = info[1].port;
        sendto(sockfd, &info[0], sizeof(clientInfo), 0, (struct sockaddr *)&serveraddr, addrlen);
        printf("send informations successful!\n");
    }
    return 0;
}

客户端A的代

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <errno.h>
 
/* 原理见服务器源程序 */
#define ERR_EXIT(m)\
    do{\
        perror(m); \
        exit(1);\
    }while(0)
 
typedef struct{
    struct in_addr ip;
    int port;
}clientInfo;
 
/* 用于udp打洞成功后两个客户端跨服务器通信 */
void echo_ser(int sockfd, struct sockaddr* addr, socklen_t *len)
{   
    printf("start recv B data...\n");
    char buf[1024];
    while(1)
    {
        bzero(buf, sizeof(buf));
        //接收B发来的数据
        recvfrom(sockfd, buf, sizeof(buf)-1, 0, addr, len);
        printf("%s \n", buf);
        //向B发送数据
        printf("send data to B ...\n");
        sendto(sockfd, buf, sizeof(buf)-1, 0, addr, sizeof(struct sockaddr_in));
        buf[strlen(buf)] = '\0';
        if(strcmp(buf, "exit") == 0)
            break;
    }
}
 
int main()
{
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if(sockfd == -1)
        ERR_EXIT("SOCKET");
    //向服务器发送心跳包的一个字节的数据
    char ch = 'a';
    clientInfo info;
    socklen_t addrlen = sizeof(struct sockaddr_in);
    bzero(&info, sizeof(info));
    struct sockaddr_in clientaddr;
    memset(&clientaddr, 0, sizeof(clientaddr));
    //实际情况下这里用一个已知的外网的服务器的端口号
    clientaddr.sin_port = htons(8888);
    //实际情况下这里用一个已知的外网的服务器的ip地址,这里保护我的云服务器ip所以没有写出来,自己换一下ip地址。
    clientaddr.sin_addr.s_addr = inet_addr("127.0.0.1");
    clientaddr.sin_family = AF_INET;
 
    /* 向服务器S发送数据包 */
    sendto(sockfd, &ch, sizeof(ch), 0, (struct sockaddr *)&clientaddr, sizeof(struct sockaddr_in));
    /* 接收B的ip+port */
    printf("send success\n");
    recvfrom(sockfd, &info, sizeof(clientInfo), 0, (struct sockaddr *)&clientaddr, &addrlen);
    printf("IP: %s\tPort: %d\n", inet_ntoa(info.ip), ntohs(info.port));
 
    clientaddr.sin_addr = info.ip;
    clientaddr.sin_port = info.port;
    
    sendto(sockfd, &ch, sizeof(ch), 0, (struct sockaddr *)&clientaddr, sizeof(struct sockaddr_in));
    echo_ser(sockfd, (struct sockaddr *)&clientaddr, &addrlen);
 
    close(sockfd);
    return 0;
}

客户端B代码:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <errno.h>
 
/* 原理见服务器源程序 */
#define ERR_EXIT(m)\
    do{\
        perror(m); \
        exit(1);\
    }while(0)
 
typedef struct{
    struct in_addr ip;
    int port;
}clientInfo;
 
/* 用于udp打洞成功后两个客户端跨服务器通信 */
void echo_ser(int sockfd, struct sockaddr* addr, socklen_t *len)
{   
    char buf[1024];
    while(1)
    {
        bzero(buf, sizeof(buf));
        printf(">> ");
        fflush(stdout);
        fgets(buf, sizeof(buf)-1, stdin);
        //向A发送数据
        sendto(sockfd, buf, strlen(buf), 0, addr, sizeof(struct sockaddr_in));
 
        //接收A发来的数据
        bzero(buf, sizeof(buf));
        printf("start recv A data...\n");
        recvfrom(sockfd, buf, sizeof(buf)-1, 0, addr, len);
        printf("%s \n", buf);
        buf[strlen(buf)] = '\0';
        if(strcmp(buf, "exit") == 0)
            break;
    }
}
 
int main()
{
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if(sockfd == -1)
        ERR_EXIT("SOCKET");
    //向服务器发送心跳包的一个字节的数据
    char ch = 'a';
    /* char str[] = "abcdefgh"; */
    clientInfo info;
    socklen_t addrlen = sizeof(struct sockaddr_in);
    bzero(&info, sizeof(info));
    struct sockaddr_in clientaddr, serveraddr;
    /* 客户端自身的ip+port */
    /* memset(&clientaddr, 0, sizeof(clientaddr)); */
    /* clientaddr.sin_port = htons(8888); */
    /* clientaddr.sin_addr.s_addr = inet_addr("127.0.0.1"); */   
    /* clientaddr.sin_family = AF_INET; */
 
    /* 服务器的信息 */
    memset(&clientaddr, 0, sizeof(clientaddr));
    //实际情况下为一个已知的外网的服务器port
    serveraddr.sin_port = htons(4399);
    //实际情况下为一个已知的外网的服务器ip,这里仅用本地ip填充,下面这行的ip自己换成已知的外网服务器的ip
    serveraddr.sin_addr.s_addr = inet_addr("127.0.0.1");   
    /* clientaddr.sin_addr.s_addr = inet_addr("127.0.0.1"); */   
    serveraddr.sin_family = AF_INET;
 
    /* 向服务器S发送数据包 */
    sendto(sockfd, &ch, sizeof(ch), 0, (struct sockaddr *)&serveraddr, sizeof(struct sockaddr_in));
    /* sendto(sockfd, str, sizeof(str), 0, (struct sockaddr *)&serveraddr, sizeof(struct sockaddr_in)); */
    /* 接收B的ip+port */
    printf("send success\n");
    recvfrom(sockfd, &info, sizeof(clientInfo), 0, (struct sockaddr *)&serveraddr, &addrlen);
    printf("IP: %s\tPort: %d\n", inet_ntoa(info.ip), ntohs(info.port));
 
    serveraddr.sin_addr = info.ip;
    serveraddr.sin_port = info.port;
 
    sendto(sockfd, &ch, sizeof(ch), 0, (struct sockaddr *)&serveraddr, sizeof(struct sockaddr_in));
    echo_ser(sockfd, (struct sockaddr *)&serveraddr, &addrlen);
    close(sockfd);
    return 0;
}

以上代码经过本人调试测验过的,可以实现跨外网通信,没有任何问题。

在写这个项目的时候遇到的问题:

1.起初,我本地的客户端一直连不上服务器S,即使代码我在本地测试成功的情况下,后来发现是因为我云服务器指定的监听端口8888没有开,不接收外来的消息。把端口开放,成功解决。

2.服务器S能接收AB的连接消息,也可以转发AB的ip和port给对端,AB也能获得对端的ip+port但是,当B发消息给A的时候A阻塞在while(1)循环中的第一个recvfrom上,为什么呢?

原因是打洞过程我少了流程3、4,因为B发送消息给A,A会屏蔽B的消息。此时A应该也给B发送一条消息。

所以我在原来A、B的代码

echo_ser(sockfd, (struct sockaddr *)&serveraddr, &addrlen);

前面都加了一句:

 sendto(sockfd, &ch, sizeof(ch), 0, (struct sockaddr *)&serveraddr, sizeof(struct sockaddr_in));

这样无论A还是B先执行这条语句都能打洞成功,如果你在这条语句前面加一句sleep(5)你就能感知到打洞的过程。

到此,问题解决,实现了预期的功能,希望我的文章对你有帮助。如果有表述不清楚,欢迎提问。