1. 程式人生 > 實用技巧 >UDP內網穿透和打洞原理與程式碼實現

UDP內網穿透和打洞原理與程式碼實現

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、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)上述公網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打洞