1、眾所周知,現在主流網絡用的還是IPV4協議,理論上一共有2^32=43億個地址,除去私有網段、網絡ID、廣播ID、保留網段、本地環回127.0.0.0網段、組播224.0.0.0網段、實際可用就是36.47億個;全球的服務器、PC機、手機、物聯網設備等需要通信的設備加起來遠不止36.47億,怎么才能盡可能讓多的設備聯網了?IPV6的地址有128位,理論上可以包含地球上每一粒沙子。但目前IPV4還是主流,過度到IPV6是個非常漫長的過程,所以目前“節約”IP地址最常見的方式:NAT
2、NAT大家肯定不陌生: 在家里、公司上網,一般都是通過路由器的,這么做的好處有:
(1)上述的節約IP地址。只需要給路由器分配公網IP,路由器內部的設備用內網網段的地址,不同路由器內網網段的地址能復用。比如一個家用路由器,一般最多能支持10來個設備同時連接路由器,都通過路由器上網,運營商只需要給路由器分配一個公網IP即可
(2)內網的設備並不直接暴露在公網,只能讓路由器對外直接通信,在一定程度上保障了內網設備收不到外界的各種掃描、探測類的數據包,保障內網安全。 路由器收到外界這些主動連接的數據包會直接丟棄(除非網關設置了反向代理,比如服務器前端的網關一般會轉發80端口的數據包),這也是客戶端之間通信需要“穿透、打洞”的根本原因!
(3)對於部分網關而言:只要內網發送數據的IP不變,那么自己對外映射轉發的端口就不變;比如內部192.168.0.10對外發數據,網關的公網地址是111.111.111.111,此時隨機選一個端口比如111,那么對外數據包的源信息就是111.111.111.111:111; 后續網關凡是收到目的是111.111.111.111:111的數據包,不管這個包來自哪(因為是UDP協議,源和目的沒有建立連接),都認為是響應192.168.0.10這個節點的,一律轉發到該節點,這就是所謂的full cone NAT,這種方式NAT並不驗證源IP和端口就直接轉發到內網主機(包括內網主機並未訪問過的IP和端口),不安全。下面介紹的UDP打洞必須依賴這個特性!
3、NAT原理: NAT轉發的種類與很多,這里僅說明最想見的兩種情況,分別作為客戶端和服務端的轉發,下面簡單介紹一下整個訪問過程。

正常情況下,是客戶端主動向服務端請求數據,比如:
(1)客戶端內網的PC1給服務器發送消息,內容是hello,消息格式(為了突出重點,這里簡化一下成IP:PORT:MESSAGE)192.168.1.6:1234:hello -> 139.186.199.148:80
(2)網關收到這條消息后,對消息做轉換和映射,新格式為:106.186.53.107: 5678:hello -> 139.186.199.148:80; 此時路由器內部會維護一個映射表192.168.1.6:1234->106.186.53.107: 5678, 表明內網哪個ip的哪個端口往外發數據包時自己轉換成了哪個端口(類似交換機的MAC映射表);
(3) 服務器的網關接收到數據包,發現是80端口,並且入棧規則這里也配置了允許80端口的數據包,於是乎這個包通過(這個就是所謂的反向代理);大型站點的網關內部一般都不止1台服務器,這些服務器組成集群。網關會有一套負載均衡的算法決定把這個數據請求發給哪個內網服務器,比如這里選中了10.0.1.10:1234,消息格式就變成了10.0.1.1:80:hello->10.0.1.10:1234,此時網關同樣也會維護一個映射表,記錄哪個源IP:端口發過來的數據(也就是hello這條消息)被自己轉發到了哪個服務器,比如這里就是106.186.53.107: 5678->10.0.1.10:1234;
內網服務器響應請求,給網關回復數據包,格式為:10.0.1.10:1234:nihao -> 10.0.1.1:80
(4)網關收到服務器的包后,從自己先前維護的映射表查找這個請求包來自那里,一查發現是106.186.53.107: 5678,於是乎消息變成了:139.186.199.148:80:nihao -> 106.186.53.107: 5678
(5)客戶端的網關收到服務器回應,同樣查自己維護的映射表,發現192.168.1.1:1234(也就是PC1)曾經向139.186.199.148:80發送過請求包,於是乎消息格式又變成了:139.186.199.148:80:nihao ->192.168.1.6:1234
整個數據傳輸的過程不算復雜,最關鍵的兩個地方:客戶端的網關、服務端的網關。這兩個網關在轉發IP和端口的同時維護了映射表,每收發一個數據包都要查找這個映射表來確認該往哪個端口轉發!
- 對於服務端的網關,因為要對外提供服務(比如這里的http),所以網關收到80端口的數據包請求后會繼續轉發到內部服務器;
- 對於客戶端,因為內部的PC1主動對外發送了數據包,網關這里有映射記錄的;所以服務端返回包后,網關才會繼續轉發給PC1;如果PC1從未主動對外發送任何數據,網關也不會有任何PC1的地址轉換映射記錄,外部發過來的數據包一律會被網關直接丟棄,這是某些場景下需要內網穿透的根本原因!
4、內網穿透方案
網絡分了5層,傳輸層有tcp和udp協議;tcp是面向連接的可靠數據傳輸協議,udp是面向消息的無連接傳輸協議。相比之下,udp協議簡單,不需要建立連接,打洞的時候對硬件資源消耗小,所以做內網穿透時首選UDP協議!
如上所述:兩個PC平時都通過網關上網,網關也只記錄了PC發到服務器的映射。如果PC1通過網關1給PC2發消息,首先要經過網關2. 但是網關2並沒有PC2發數據包給PC1的記錄,所以認為來自PC1的數據包是“不請自來”,直接丟棄,導致PC1與PC2無法直接通信!為了在PC1和PC2之間“牽線搭橋”,就需要要有公網地址的服務器了!為了突出重點,這里簡化了網絡拓撲,只保留關鍵的節點,如下:

讓PC1和PC2之間互相通信,最簡單的就是讓公網服務器“傳話”:PC1和PC2都主動連接服務器,都給都服務器發消息,服務器來轉發雙方的數據。原理倒是簡單,不過服務器的帶寬和流量壓力就大了,有可能從成為通信的瓶頸,最好是能讓PC1和PC2之間直接通信,不再通過服務器長期中轉,該怎么操作了?
- PC1和PC2分別通過各自的網關主動聯系服務器,這一步本質是在服務器那里備案(服務器保存了222.222.222.222:222和333.333.333.333:333兩個PC網關的地址),讓服務器知道有兩個客戶端連上了;
- 服務器給PC2發送PC1的網關地址222.222.222.222:222;由於PC2曾經主動給服務器發送過數據,網關2有地址的映射記錄,所以服務器給PC2的消息能順利送達PC2;同理,服務器也給PC1發送PC2網關的地址和端口,即:333.333.333.333:333; 這一步的本質是讓PC1和PC2知道對方網關的IP和端口,方便后續通信;
- 此時PC2根據服務器提供的PC1的端口和IP,給PC1網關發消息,此時PC2的網關就會新增一條映射:192.168.0.10:123->222.222.222.222:222; 消息到達網關1后,由於網關1沒有PC1給網關2發消息的記錄,覺得這個數據包是“不請自來”,不知道該轉發給內網哪個設備,只能丟掉;
- PC1接着給PC2的網關2發消息,此時網關1新增一條映射: 192.168.1.10:123->333.333.333.333:333; 網關2收到這條消息后,查看自己的映射表,發現自己內網的192.168.0.10:123曾經給222.222.222.222:222發過消息,覺得這個會是網關1回復的,直接轉發給內網的192.168.0.10:123,此時PC1終於聯系上了PC2!
- PC2繼續給PC1發消息,由於PC1上一步給PC2發送了消息,網關1有映射記錄,所以網關1直接把這個包轉發給PC1! 至此: PC1和PC2終於可以互相收發消息!
以上便是利用UDP協議穿透內網(俗稱打洞)的全過程!整個過程最核心的就是穿透各自的網關,辦法也不復雜,就是PC主動給另一個PC發消息,讓網關有地址的映射記錄,對方回復消息時自己的網關才知道轉發到內網的哪個節點!所以說內網穿透、打洞的本質就是讓網關有內、外部地址的映射和轉換記錄,外網來數據后網關能順利准發到內網正確的節點!
5、代碼實現
UDP打洞技術早在10幾年前bt這種P2P軟件流行時就已經成熟了,github上代碼一大堆,我這里參考https://blog.csdn.net/yxc135/article/details/8541563 做個簡單的介紹;
(1)服務端:服務端本質上就是個“中間商”,PC1和PC2連接后把自己的公網IP和端口都在服務端登記備案;服務端把PC1和PC2的公網IP分別發給對方,核心代碼如下:
/* 某局域網內客戶端C1先與外網服務器S通信,S記錄C1經NAT設備轉換后在外網中的ip和port; 然后另一局域網內客戶端C2與S通信,S記錄C2經NAT設備轉換后在外網的ip和port;S將C1的 外網ip和port給C2,C2向其發送數據包;S將C2的外網ip和port給C1,C1向其發送數據包,打 洞完成,兩者可以通信。(C1、C2不分先后) 測試假設C1、C2已確定是要與對方通信,實際情況下應該通過C1給S的信息和C2給S的信息,S 判斷是否給兩者搭橋。(因為C1可能要與C3通信,此時需要等待C3的連接,而不是給C1和 C2搭橋) 編譯:gcc UDPServer.c -o UDPServer -lws2_32 */ #include <Winsock2.h> #include <stdio.h> #include <stdlib.h> #define DEFAULT_PORT 5050 #define BUFFER_SIZE 100 int main() { //server即外網服務器 int serverPort = DEFAULT_PORT; WSADATA wsaData; SOCKET serverListen; struct sockaddr_in serverAddr; //檢查協議棧 if (WSAStartup(MAKEWORD(2,2),&wsaData) != 0 ) { printf("Failed to load Winsock.\n"); return -1; } //建立監聽socket serverListen = socket(AF_INET,SOCK_DGRAM,0); if (serverListen == INVALID_SOCKET) { printf("socket() failed:%d\n",WSAGetLastError()); return -1; } serverAddr.sin_family = AF_INET; serverAddr.sin_port = htons(serverPort); serverAddr.sin_addr.s_addr = htonl(INADDR_ANY); if (bind(serverListen,(LPSOCKADDR)&serverAddr,sizeof(serverAddr)) == SOCKET_ERROR) { printf("bind() failed:%d\n",WSAGetLastError()); return -1; } //接收來自客戶端的連接,source1即先連接到S的客戶端C1 struct sockaddr_in sourceAddr1; int sourceAddrLen1 = sizeof(sourceAddr1); SOCKET sockC1 = socket(AF_INET,SOCK_DGRAM,0); char bufRecv1[BUFFER_SIZE]; int len; len = recvfrom(serverListen, bufRecv1, sizeof(bufRecv1), 0,(struct sockaddr*)&sourceAddr1,&sourceAddrLen1); if (len == SOCKET_ERROR) { printf("recv() failed:%d\n", WSAGetLastError()); return -1; } printf("C1 IP:[%s],PORT:[%d]\n",inet_ntoa(sourceAddr1.sin_addr) ,ntohs(sourceAddr1.sin_port)); //接收來自客戶端的連接,source2即后連接到S的客戶端C2 struct sockaddr_in sourceAddr2; int sourceAddrLen2 = sizeof(sourceAddr2); SOCKET sockC2 = socket(AF_INET,SOCK_DGRAM,0); char bufRecv2[BUFFER_SIZE]; len = recvfrom(serverListen, bufRecv2, sizeof(bufRecv2), 0,(struct sockaddr*)&sourceAddr2,&sourceAddrLen2); if (len == SOCKET_ERROR) { printf("recv() failed:%d\n", WSAGetLastError()); return -1; } printf("C2 IP:[%s],PORT:[%d]\n",inet_ntoa(sourceAddr2.sin_addr) ,ntohs(sourceAddr2.sin_port)); //向C1發送C2的外網ip和port char bufSend1[BUFFER_SIZE];//bufSend1中存儲C2的外網ip和port memset(bufSend1,'\0',sizeof(bufSend1)); char* ip2 = inet_ntoa(sourceAddr2.sin_addr);//C2的ip char port2[10];//C2的port itoa(ntohs(sourceAddr2.sin_port),port2,10);//10代表10進制 for (int i=0;i<strlen(ip2);i++) { bufSend1[i] = ip2[i]; } bufSend1[strlen(ip2)] = '^'; for (int i=0;i<strlen(port2);i++) { bufSend1[strlen(ip2) + 1 + i] = port2[i]; } len = sendto(sockC1,bufSend1,sizeof(bufSend1),0,(struct sockaddr*)&sourceAddr1,sizeof(sourceAddr1)); if (len == SOCKET_ERROR) { printf("send() failed:%d\n",WSAGetLastError()); return -1; } else if (len == 0) { return -1; } else { printf("send() byte:%d\n",len); } //向C2發送C1的外網ip和port char bufSend2[BUFFER_SIZE];//bufSend2中存儲C1的外網ip和port memset(bufSend2,'\0',sizeof(bufSend2)); char* ip1 = inet_ntoa(sourceAddr1.sin_addr);//C1的ip char port1[10];//C1的port itoa(ntohs(sourceAddr1.sin_port),port1,10); for (int i=0;i<strlen(ip1);i++) { bufSend2[i] = ip1[i]; } bufSend2[strlen(ip1)] = '^'; for (int i=0;i<strlen(port1);i++) { bufSend2[strlen(ip1) + 1 + i] = port1[i]; } len = sendto(sockC2,bufSend2,sizeof(bufSend2),0,(struct sockaddr*)&sourceAddr2,sizeof(sourceAddr2)); if (len == SOCKET_ERROR) { printf("send() failed:%d\n",WSAGetLastError()); return -1; } else if (len == 0) { return -1; } else { printf("send() byte:%d\n",len); } //server的中間人工作已完成,退出即可,剩下的交給C1與C2相互通信 closesocket(serverListen); closesocket(sockC1); closesocket(sockC2); WSACleanup(); return 0; }
(2)客戶端
內網穿透和打洞的本質是路由器能夠轉換和映射地址,但這個是路由器內部的功能,貌似路由器廠家也並未開放API來新增或刪除這種映射(這種API很危險,一旦被黑客利用,輕則導致斷網,中則導致內部不同設備之間數據錯亂,重則導致外部數據隨隨便便進入內網),開發人員是沒法直接增加映射的,只能通過發送UDP的數據包讓路由器增加地址映射(原理上講:這像不像CPU的L1\L2\L3緩存啊?開發人員沒法直接修改緩存,但是可以通過讀寫數據、mov cr3,eax等方式間接刷新緩存),核心代碼如下:
- 連接服務器,讓服務器保存自己的公網IP和端口
- 從服務器接受另一個PC的公網IP和端口
- 向另一個PC的公網IP和端口發送數據包,來刷新自己網關的地址轉換映射表;
/* 客戶端C1,連接到外網服務器S,並從S的返回信息中得到它想要連接的C2的外網ip和port,然后 C1給C2發送數據包進行連接。 */ #include<Winsock2.h> #include<stdio.h> #include<stdlib.h> #define PORT 7777 #define BUFFER_SIZE 100 //調用方式:UDPClient1 10.2.2.2 5050 (外網服務器S的ip和port) int main(int argc,char* argv[]) { WSADATA wsaData; struct sockaddr_in serverAddr; struct sockaddr_in thisAddr; thisAddr.sin_family = AF_INET; thisAddr.sin_port = htons(PORT); thisAddr.sin_addr.s_addr = htonl(INADDR_ANY); if (argc<3) { printf("Usage: client1[server IP address , server Port]\n"); return -1; } if (WSAStartup(MAKEWORD(2,2),&wsaData) != 0) { printf("Failed to load Winsock.\n"); return -1; } //初始化服務器S信息 serverAddr.sin_family = AF_INET; serverAddr.sin_port = htons(atoi(argv[2])); serverAddr.sin_addr.s_addr = inet_addr(argv[1]); //建立與服務器通信的socket和與客戶端通信的socket SOCKET sockS = socket(AF_INET,SOCK_DGRAM,0); if (sockS == INVALID_SOCKET) { printf("socket() failed:%d\n",WSAGetLastError()); return -1; } if (bind(sockS,(LPSOCKADDR)&thisAddr,sizeof(thisAddr)) == SOCKET_ERROR) { printf("bind() failed:%d\n",WSAGetLastError()); return -1; } SOCKET sockC = socket(AF_INET,SOCK_DGRAM,0); if (sockC == INVALID_SOCKET) { printf("socket() failed:%d\n",WSAGetLastError()); return -1; } char bufSend[] = "I am C1"; char bufRecv[BUFFER_SIZE]; memset(bufRecv,'\0',sizeof(bufRecv)); struct sockaddr_in sourceAddr;//暫存接受數據包的來源,在recvfrom中使用 int sourceAddrLen = sizeof(sourceAddr);//在recvfrom中使用 struct sockaddr_in oppositeSideAddr;//C2的地址信息 int len; //C1給S發送數據包 len = sendto(sockS,bufSend,sizeof(bufSend),0,(struct sockaddr*)&serverAddr,sizeof(serverAddr)); if (len == SOCKET_ERROR) { printf("sendto() failed:%d\n", WSAGetLastError()); return -1; } //C1從S返回的數據包中得到C2的外網ip和port len = recvfrom(sockS, bufRecv, sizeof(bufRecv), 0,(struct sockaddr*)&sourceAddr,&sourceAddrLen); if (len == SOCKET_ERROR) { printf("recvfrom() failed:%d\n", WSAGetLastError()); return -1; } //下面的處理是由於測試環境(本機+兩台NAT聯網的虛擬機)原因,若在真實環境中不需要這段處理。 /* 關閉與服務器通信的socket,並把與C2通信的socket綁定到相同的端口,真實環境中,路由器的NAT會將客戶端 對外的訪問從路由器的外網ip某固定端口發送出去,並在此端口接收 */ closesocket(sockS); if (bind(sockC,(LPSOCKADDR)&thisAddr,sizeof(thisAddr)) == SOCKET_ERROR) { printf("bind() failed:%d\n",WSAGetLastError()); return -1; } char ip[20]; char port[10]; int i; for (i=0;i<strlen(bufRecv);i++) if (bufRecv[i] != '^') ip[i] = bufRecv[i]; else break; ip[i] = '\0'; int j; for (j=i+1;j<strlen(bufRecv);j++) port[j - i - 1] = bufRecv[j]; port[j - i - 1] = '\0'; oppositeSideAddr.sin_family = AF_INET; oppositeSideAddr.sin_port = htons(atoi(port)); oppositeSideAddr.sin_addr.s_addr = inet_addr(ip); //下面的處理是由於測試環境(本機+兩台NAT聯網的虛擬機)原因,若在真實環境中不需要這段處理。 /* 此處由於是在本機,ip為127.0.0.1,但是如果虛擬機連接此ip的話,是與虛擬機本機通信,而不是 真實的本機,真實本機即此實驗中充當NAT的設備,ip為10.0.2.2。 */ oppositeSideAddr.sin_addr.s_addr = inet_addr("10.0.2.2"); //設置sockC為非阻塞 unsigned long ul = 1; ioctlsocket(sockC, FIONBIO, (unsigned long*)&ul); //C1向C2不停地發出數據包,得到C2的回應,與C2建立連接 while (1) { Sleep(1000); //C1向C2發送數據包 len = sendto(sockC,bufSend,sizeof(bufSend),0,(struct sockaddr*)&oppositeSideAddr,sizeof(oppositeSideAddr)); if (len == SOCKET_ERROR) { printf("while sending package to C2 , sendto() failed:%d\n", WSAGetLastError()); return -1; }else { printf("successfully send package to C2\n"); } //C1接收C2返回的數據包,說明C2到C1打洞成功,C2可以直接與C1通信了 len = recvfrom(sockC, bufRecv, sizeof(bufRecv), 0,(struct sockaddr*)&sourceAddr,&sourceAddrLen); if (len == WSAEWOULDBLOCK) { continue;//未收到回應 }else { printf("C2 IP:[%s],PORT:[%d]\n",inet_ntoa(sourceAddr.sin_addr) ,ntohs(sourceAddr.sin_port)); printf("C2 says:%s\n",bufRecv); } } closesocket(sockC); }
6、UDP內網穿透/打洞應用場景:
(1)P2P文件傳輸:避免所有流量都經過服務器,減輕服務器帶寬和流量壓力,也不用收集用戶隱私
(2)微信、企業微信、qq等IM用戶之間的視頻、語音聊天;或則用戶之間互相傳文件
說明:(1)內網穿透、UDP打洞能夠成功,主要取決於網關的NAT策略!
(2)上述公網IP地址純屬虛構,如果雷同純屬巧合
1、https://www.zhihu.com/question/20168985 為什么IPV4的地址不夠用
2、https://www.bilibili.com/video/BV16b411e78C?from=search&seid=8594225681123793506 C++實戰UDP打洞原理及實現
3、https://blog.csdn.net/yxc135/article/details/8541563 C語言實現UPD打洞
4、https://www.cnblogs.com/dyufei/p/7466924.html CONE NAT 和 Symmetric NAT
