v1.0 2024年6月5日 釋出於部落格園
- 序言
- UDP打洞的原理
- 應用場景
- 基本理論
- 程式碼實現
- udp_client_NAT.c
- udp_server_NAT.c
- 結果
- 參考連結
序言
UDP打洞(UDP Hole Punching)是一種用於在NAT(網路地址轉換)裝置後面建立直接P2P(點對點)連線的技術。NAT裝置通常會阻止外部裝置直接與內部裝置通訊,因為它們隱藏了內部網路的IP地址。UDP打洞透過利用NAT裝置的行為特性來繞過這些限制,從而實現直接通訊。
UDP打洞的原理
-
初始連線:兩個希望進行P2P通訊的裝置(稱為A和B)首先都與一個公共伺服器(稱為中繼伺服器)建立連線。中繼伺服器記錄下它們的公共IP地址和埠號。
-
交換資訊:中繼伺服器將A的公共IP地址和埠號傳送給B,同時將B的公共IP地址和埠號傳送給A。
-
打洞嘗試:A和B使用從中繼伺服器獲得的對方的公共IP地址和埠號,嘗試直接向對方傳送UDP資料包。由於NAT裝置通常會允許內部裝置發起的連線透過,因此這些資料包會在NAT裝置上開啟一個臨時的“洞”。
-
建立連線:如果A和B的NAT裝置都允許這種臨時的“洞”,那麼A和B就可以透過這些洞進行直接的P2P通訊,而不再需要透過中繼伺服器。
應用場景
UDP打洞技術在許多應用中非常有用,尤其是在需要高效、低延遲的P2P通訊時。以下是一些常見的應用場景:
-
實時通訊應用:如VoIP(網路電話)、影片聊天和線上遊戲等。這些應用需要低延遲的通訊,而透過中繼伺服器轉發資料會增加延遲。
-
檔案共享:P2P檔案共享網路(如BitTorrent)可以利用UDP打洞技術來建立直接連線,從而提高傳輸速度和效率。
-
遠端控制和協作:如遠端桌面、線上協作工具等,透過直接P2P連線可以提供更流暢的使用者體驗。
-
物聯網(IoT)裝置:許多IoT裝置位於NAT裝置後面,UDP打洞可以使它們更容易與外部伺服器或其他裝置直接通訊。
-
遊戲主機和客戶端:線上遊戲通常需要快速的P2P連線來同步遊戲狀態和動作,UDP打洞技術可以顯著改善遊戲體驗。
UDP打洞是一種非常有用的技術,尤其是在需要高效、低延遲的P2P通訊的應用中。它透過巧妙地利用NAT裝置的行為特性,使得位於NAT裝置後面的裝置也能夠進行直接的P2P通訊。
基本理論
/**
* 前提: 伺服器具有公網ip, 客戶端和服務端已經協商好埠號
*
* 第一步: 客戶端傳送打洞包給伺服器 C---NET--->S (此時客戶端看得見伺服器, 伺服器看不見客戶端)
* 客戶端向伺服器傳送一個UDP包。NAT裝置會為這個連線分配一個公網IP和埠,並將包轉發給伺服器。
*
* 第二步: 伺服器接收並記錄客戶端資訊 (伺服器記錄客戶端的網路資訊, 但不知道萬惡的運營商有沒有關掉這條網路)
* 伺服器接收到包後,記錄下客戶端的公網IP和埠。
*
* 第三步: 伺服器傳送確認資訊給客戶端: C<---NET---S (伺服器沿著原來的網路回傳資訊, 客戶端若收到後維持網路)
* 伺服器向客戶端傳送確認訊息,確保NAT裝置為這對IP和埠建立了對映。
*
* 第四步: 儲存對映:C<---NET--->S 需要不斷髮送保活包, 建議為1/2超時時間 (這條路可通, 不斷維持這條路)
* 客戶端和伺服器透過傳送UDP包來保持這個對映。只要對映存在,後續的UDP包可以直接穿過NAT裝置。
* NAT裝置會在一段時間內沒有檢測到任何活動後關閉對映。這段時間通常被稱為“空閒超時時間”或“會話超時時間”。
* UDP超時時間:常見的預設值是30秒、60秒或120秒。
* TCP超時時間: 通常在數分鐘到數小時之間。
*/
由於我並不需要實現2個客戶端的直接通訊(增加一箇中間伺服器即可), 而是在典型的NAT穿透場景中,知道伺服器端的公網IP和埠,但不知道客戶端的公網IP,可以透過一些技巧來實現UDP打洞。以下是一個可能的方案:
- 伺服器端:伺服器端監聽來自客戶端的連線請求,並記錄客戶端的公網IP和埠。
- 客戶端:客戶端向伺服器傳送一個初始訊息,伺服器記錄該訊息的來源IP和埠。
- 伺服器:伺服器將記錄的客戶端IP和埠返回給客戶端。
- 雙方打洞:客戶端和伺服器透過傳送UDP包到對方的IP和埠來打洞。
程式碼實現
編譯命令:cc udp_client_NAT.c -o udp_client_NAT.out -pthread
udp_client_NAT.c
/**
* @file name : udp_client_NAT.c
* @brief : 用於實現基本的UDP客戶端和伺服器端打洞
* @author : RISE_AND_GRIND@163.com
* @date : 2024/04/07
* @version : 1.0
* @note :
* CopyRight (c) 2023-2024 RISE_AND_GRIND@163.com All Right Reseverd
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <arpa/inet.h>
#include <errno.h>
#define SERVER_PORT 50001 // 公網伺服器的埠
#define SERVER_ADDR "120.79.143.250" // 公網伺服器的IP地址
#define BUF_SIZE 1024 // 緩衝區大小(位元組)
#define KEEP_ALIVE_INTERVAL 25 // 保活包傳送間隔
// 保活包的網路資訊
typedef struct
{
int sock_fd; // 套接字檔案描述符
struct sockaddr_in socket_addr; // 定義套接字所需的地址資訊結構體
socklen_t addr_len; // 目標地址的長度
pthread_mutex_t *mutex; // 互斥鎖變數
} KeepAlivePackageArgs_t;
/**
* @name keep_alive
* @brief 保活執行緒函式, 用於保持活路
* @param args 執行緒例程引數, 傳入保活包的網路資訊
* @note
*/
void *keep_alive(void *args)
{
// 用於傳入的是void* 需要強轉才能正確指向
KeepAlivePackageArgs_t *ka_args = (KeepAlivePackageArgs_t *)args;
char keep_alive_msg[] = "KEEP_ALIVE_CLIENT"; // 傳送給伺服器的保活包, 表示我是客戶端, C---NET-->S
while (1)
{
/**
* 對互斥鎖進行上鎖,如果主執行緒未上鎖,則此次呼叫會上鎖成功,函式呼叫將立馬返回;
* 如果互斥鎖此時已經被其它執行緒鎖定了,會一直阻塞,直到該互斥鎖被解鎖,到那時,呼叫將鎖定互斥鎖並返回。
*/
pthread_mutex_lock(ka_args->mutex);
sendto(ka_args->sock_fd,
keep_alive_msg,
strlen(keep_alive_msg),
MSG_CONFIRM, // 幫助你確認資料包的路徑可達性。具體地,核心會嘗試確認目標地址是可達的,並且路徑是有效的。且避免不必要的探測.
(const struct sockaddr *)&ka_args->socket_addr,
ka_args->addr_len);
pthread_mutex_unlock(ka_args->mutex); // 解鎖
printf("\n客戶端已發伺服器送保活包\n");
sleep(KEEP_ALIVE_INTERVAL); // 定期保活
}
}
int main(int argc, char const *argv[])
{
char validbuffer[BUF_SIZE]; // 傳回的有效資料
pthread_mutex_t mutex;
pthread_mutex_init(&mutex, NULL); // 初始化套接字檔案互斥鎖
/**********************第一步: 客戶端傳送打洞包給伺服器 C---NET--->S******************************/
/*****①建立套接字檔案描述符****/
int client_sock_fd = socket(AF_INET, SOCK_DGRAM, 0); // 建立客戶端套接字檔案描述符 ipv4 udp 預設協議選擇
if (0 > client_sock_fd)
{
fprintf(stderr, "客戶端UDP套接字檔案錯誤,errno:%d,%s\n", errno, strerror(errno));
exit(1);
}
/****************END***************/
/****************②傳送資訊給伺服器****************/
// 伺服器的IP資訊結構體
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
// 配置伺服器IP資訊結構體
server_addr.sin_family = AF_INET; // ipv4協議簇
server_addr.sin_addr.s_addr = inet_addr(SERVER_ADDR); // 伺服器公網IP
server_addr.sin_port = htons(SERVER_PORT); // 伺服器埠
// 向伺服器傳送打洞包
char buffer[BUF_SIZE] = "HELLO_SERVER"; // 傳送給伺服器的打洞包 內容無所謂
socklen_t addr_len = sizeof(struct sockaddr_in); // 資訊結構體長度
ssize_t sent_bytes = sendto(client_sock_fd, // 客戶端套接字檔案描述符
buffer, // 要傳送的資料緩衝區
strlen(buffer), // 要傳送的字串長度
MSG_CONFIRM, // 確認資料包有效性標誌位
(const struct sockaddr *)&server_addr, // 指向包含目標地址的 sockaddr 結構體
addr_len); // 目標地址的長度
if (sent_bytes == -1)
{
fprintf(stderr, "傳送資料失敗, errno:%d, %s\n", errno, strerror(errno));
close(client_sock_fd);
exit(1);
}
memset(buffer, 0x0, sizeof(buffer)); // 清空buffer
/****************END***************/
/************************************END*****************************************/
// 第二步由伺服器完成
/**********************第三步: 伺服器傳送確認資訊給客戶端: C<---NET---S******************************/
// 接收來自伺服器的確認訊息
int n = recvfrom(client_sock_fd, // 套接字檔案描述符
buffer, // 接收資料的緩衝區
BUF_SIZE, // 緩衝區的長度
0, // MSG_WAITALL 嚴格等待完整的資料,會一直阻塞,直到接收到指定數量的位元組(即 BUF_SIZE)或者發生錯誤為止。它確保接收到的資料量滿足請求的大小。
(struct sockaddr *)&server_addr, // 指向儲存源地址的 sockaddr 結構體
&addr_len); // 指向源地址長度的指標
buffer[n] = '\0'; // 將接收到的資料轉換為字串
printf("打洞中, 從伺服器收到: %s\n", buffer);
/************************************END*****************************************/
/**********************第四步: 儲存對映:C<---NET--->S *******************************************/
// 新執行緒的TID
pthread_t keep_alive_thread;
// 定義執行緒保活包的網路資訊
KeepAlivePackageArgs_t ka_args;
// 設定保活執行緒引數
ka_args.sock_fd = client_sock_fd;
ka_args.socket_addr = server_addr;
ka_args.addr_len = addr_len;
ka_args.mutex = &mutex;
// 建立保活執行緒 並將保活包的網路資訊傳入執行緒
if (pthread_create(&keep_alive_thread, NULL, keep_alive, (void *)&ka_args) != 0)
{
fprintf(stderr, "建立保活執行緒錯誤, errno:%d,%s\n", errno, strerror(errno));
close(client_sock_fd);
exit(1);
}
/************************************END*****************************************/
/************************************正常收發資料部分*******************************************/
// // 傳送訊息給伺服器端
// const char *message = "我是客戶端!";
// size_t message_len = strlen(message);
// 主迴圈:接收和處理伺服器訊息
while (1)
{
// 傳送訊息給伺服器端
// 從鍵盤輸入字串
char message[BUF_SIZE];
printf("請輸入要傳送給伺服器的訊息: ");
if (fgets(message, BUF_SIZE, stdin) == NULL)
{
perror("fgets error");
continue;
}
// 移除換行符
size_t message_len = strlen(message);
if (message[message_len - 1] == '\n')
{
message[message_len - 1] = '\0';
message_len--;
}
// 向伺服器傳送資料
pthread_mutex_lock(&mutex); // 對套接字檔案上鎖
sendto(client_sock_fd, message, message_len, MSG_CONFIRM, (const struct sockaddr *)&server_addr, addr_len);
pthread_mutex_unlock(&mutex); // 對套接字檔案解鎖
// 接收來自伺服器的訊息
pthread_mutex_lock(&mutex); // 對套接字檔案上鎖
n = recvfrom(client_sock_fd, buffer, BUF_SIZE, 0, (struct sockaddr *)&server_addr, &addr_len);
pthread_mutex_unlock(&mutex); // 對套接字檔案解鎖
if (n < 0)
{
perror("recvfrom error");
continue;
}
buffer[n] = '\0';
// 判斷是否是保活資訊
if (strcmp(buffer, "KEEP_ALIVE_SERVER") != 0) // 若收到的不是保活資訊, 則為有效資訊
{
// 有效資訊, 不是保活資訊,處理並儲存
printf("★收到有效資訊: %s\n", buffer);
memcpy(validbuffer, buffer, n + 1); // 使用 memcpy 代替 strncpy
}
else
{
printf("\n從伺服器收到保活資訊: %s\n", buffer);
}
// 清空 buffer
memset(buffer, 0, BUF_SIZE);
}
/************************************END*****************************************/
close(client_sock_fd); // 關閉套接字檔案
return 0;
}
/**
* 前提: 伺服器具有公網ip, 客戶端和服務端已經協商好埠號
*
* 第一步: 客戶端傳送打洞包給伺服器 C---NET--->S (此時客戶端看得見伺服器, 伺服器看不見客戶端)
* 客戶端向伺服器傳送一個UDP包。NAT裝置會為這個連線分配一個公網IP和埠,並將包轉發給伺服器。
*
* 第二步: 伺服器接收並記錄客戶端資訊 (伺服器記錄客戶端的網路資訊, 但不知道萬惡的運營商有沒有關掉這條網路)
* 伺服器接收到包後,記錄下客戶端的公網IP和埠。
*
* 第三步: 伺服器傳送確認資訊給客戶端: C<---NET---S (伺服器沿著原來的網路回傳資訊, 客戶端若收到後維持網路)
* 伺服器向客戶端傳送確認訊息,確保NAT裝置為這對IP和埠建立了對映。
*
* 第四步: 儲存對映:C<---NET--->S 需要不斷髮送保活包, 建議為1/2超時時間 (這條路可通, 不斷維持這條路)
* 客戶端和伺服器透過傳送UDP包來保持這個對映。只要對映存在,後續的UDP包可以直接穿過NAT裝置。
* NAT裝置會在一段時間內沒有檢測到任何活動後關閉對映。這段時間通常被稱為“空閒超時時間”或“會話超時時間”。
* UDP超時時間:常見的預設值是30秒、60秒或120秒。
* TCP超時時間: 通常在數分鐘到數小時之間。
*/
udp_server_NAT.c
/**
* @file name : udp_server_NAT.c
* @brief : 用於實現基本的UDP客戶端和伺服器端打洞
* @author : RISE_AND_GRIND@163.com
* @date : 2024/04/07
* @version : 1.0
* @note :
* CopyRight (c) 2023-2024 RISE_AND_GRIND@163.com All Right Reseverd
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <arpa/inet.h>
#include <errno.h>
#define CLIENT_PORT 50001 // 客戶端的埠
#define BUF_SIZE 1024 // 緩衝區大小(位元組)
#define KEEP_ALIVE_INTERVAL 25 // 保活包傳送間隔
// 保活包的網路資訊
typedef struct
{
int sock_fd; // 套接字檔案描述符
struct sockaddr_in socket_addr; // 定義套接字所需的地址資訊結構體
socklen_t addr_len; // 目標地址的長度
pthread_mutex_t *mutex; // 互斥鎖變數
} KeepAlivePackageArgs_t;
/**
* @name keep_alive
* @brief 保活執行緒函式, 用於保持活路
* @param args 執行緒例程引數, 傳入保活包的網路資訊
* @note
*/
void *keep_alive(void *args)
{
// 用於傳入的是void* 需要強轉才能正確指向
KeepAlivePackageArgs_t *ka_args = (KeepAlivePackageArgs_t *)args;
char keep_alive_msg[] = "KEEP_ALIVE_SERVER"; // 傳送給客戶端的保活包, 表示我是伺服器, C<---NET--S
for (;;)
{
/**
* 對互斥鎖進行上鎖,如果主執行緒未上鎖,則此次呼叫會上鎖成功,函式呼叫將立馬返回;
* 如果互斥鎖此時已經被其它執行緒鎖定了,會一直阻塞,直到該互斥鎖被解鎖,到那時,呼叫將鎖定互斥鎖並返回。
*/
pthread_mutex_lock(ka_args->mutex);
sendto(ka_args->sock_fd,
keep_alive_msg,
strlen(keep_alive_msg),
MSG_CONFIRM, // 幫助你確認資料包的路徑可達性。具體地,核心會嘗試確認目標地址是可達的,並且路徑是有效的。且避免不必要的探測.
(const struct sockaddr *)&ka_args->socket_addr,
ka_args->addr_len);
pthread_mutex_unlock(ka_args->mutex); // 解鎖
printf("\n伺服器已向客戶端傳送保活包\n");
sleep(KEEP_ALIVE_INTERVAL);
}
}
int main(int argc, char const *argv[])
{
char validbuffer[BUF_SIZE]; // 傳回的有效資料
pthread_mutex_t mutex;
pthread_mutex_init(&mutex, NULL); // 初始化套接字檔案互斥鎖
/**********************第二步: 伺服器接收並記錄客戶端資訊 C---NET--->S******************************/
/*****①建立套接字檔案描述符並繫結接收****/
int server_sock_fd = socket(AF_INET, SOCK_DGRAM, 0); // 建立客戶端套接字檔案描述符 ipv4 udp 預設協議選擇
if (0 > server_sock_fd)
{
fprintf(stderr, "伺服器端建立UDP套接字檔案錯誤,errno:%d,%s\n", errno, strerror(errno));
exit(1);
}
// 伺服器端的IP資訊結構體
struct sockaddr_in server_addr;
// 配置伺服器地址資訊 接受來自任何地方的資料 包有效 但只解析50001埠的包
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_port = htons(CLIENT_PORT);
// 繫結socket到指定埠
if (bind(server_sock_fd, (const struct sockaddr *)&server_addr, sizeof(server_addr)) < 0)
{
fprintf(stderr, "將伺服器套接字檔案描述符繫結IP失敗, errno:%d,%s\n", errno, strerror(errno));
close(server_sock_fd);
exit(1);
}
printf("伺服器已經執行, 等待客戶端響應中...\n");
/****************END***************/
/****************②接收客戶端的打洞包****************/
char buffer[BUF_SIZE]; // 存放接收到的資料緩衝區
memset(buffer, 0x0, sizeof(buffer)); // 清空buffer
struct sockaddr_in client_addr;
socklen_t addr_len = sizeof(struct sockaddr_in);
int n = recvfrom(server_sock_fd, buffer, BUF_SIZE, 0, (struct sockaddr *)&client_addr, &addr_len);
buffer[n] = '\0';
printf("解除阻塞, 從客戶端收到資訊: %s\n", buffer);
printf("客戶端NAT地址: %s:%d\n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
/****************END***************/
/************************************END*****************************************/
/**********************第三步: 伺服器傳送確認資訊給客戶端: C<---NET---S******************************/
// 向客戶端傳送確認訊息
char ack_msg[BUF_SIZE];
snprintf(ack_msg, BUF_SIZE, "ACK %s %d", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
sendto(server_sock_fd, ack_msg, strlen(ack_msg), MSG_CONFIRM, (const struct sockaddr *)&client_addr, addr_len);
memset(buffer, 0x0, sizeof(buffer)); // 清空buffer
printf("打洞完成, 進入互動\n");
/************************************END*****************************************/
/**********************第四步: 儲存對映:C<---NET--->S *******************************************/
// 新執行緒的TID
pthread_t keep_alive_thread;
// 定義執行緒保活包的網路資訊
KeepAlivePackageArgs_t ka_args;
// 設定保活執行緒引數
ka_args.sock_fd = server_sock_fd;
ka_args.socket_addr = client_addr;
ka_args.addr_len = addr_len;
ka_args.mutex = &mutex;
// 建立保活執行緒 並將保活包的網路資訊傳入執行緒
if (pthread_create(&keep_alive_thread, NULL, keep_alive, (void *)&ka_args) != 0)
{
fprintf(stderr, "建立保活執行緒錯誤, errno:%d,%s\n", errno, strerror(errno));
close(server_sock_fd);
exit(1);
}
/************************************END*****************************************/
// 傳送訊息給客戶端
const char *message = "我是伺服器, 收到請回答!";
size_t message_len = strlen(message);
// 主迴圈:接收和響應客戶端訊息
while (1)
{
// 傳送訊息給客戶端
pthread_mutex_lock(&mutex); // 對套接字檔案上鎖
sendto(server_sock_fd, message, message_len, MSG_CONFIRM, (const struct sockaddr *)&client_addr, addr_len);
pthread_mutex_unlock(&mutex); // 對套接字檔案解鎖
// 接收來自客戶端的訊息
pthread_mutex_lock(&mutex); // 對套接字檔案上鎖
n = recvfrom(server_sock_fd, buffer, BUF_SIZE, 0, (struct sockaddr *)&client_addr, &addr_len);
pthread_mutex_unlock(&mutex); // 對套接字檔案解鎖
if (n < 0)
{
perror("recvfrom error");
continue;
}
buffer[n] = '\0';
// 判斷是否是保活資訊
if (strcmp(buffer, "KEEP_ALIVE_CLIENT") != 0) // 若收到的不是保活資訊, 則為有效資訊
{
// 有效資訊, 不是保活資訊,處理並儲存
printf("★收到有效資訊: %s\n", buffer);
memcpy(validbuffer, buffer, n + 1); // 使用 memcpy 代替 strncpy
}
else
{
printf("\n從客戶端收到保活資訊: %s\n", buffer);
}
// 清空 buffer
memset(buffer, 0, BUF_SIZE);
}
close(server_sock_fd);
return 0;
}
/**
* 前提: 伺服器具有公網ip, 客戶端和服務端已經協商好埠號
*
* 第一步: 客戶端傳送打洞包給伺服器 C---NET--->S (此時客戶端看得見伺服器, 伺服器看不見客戶端)
* 客戶端向伺服器傳送一個UDP包。NAT裝置會為這個連線分配一個公網IP和埠,並將包轉發給伺服器。
*
* 第二步: 伺服器接收並記錄客戶端資訊 (伺服器記錄客戶端的網路資訊, 但不知道萬惡的運營商有沒有關掉這條網路)
* 伺服器接收到包後,記錄下客戶端的公網IP和埠。
*
* 第三步: 伺服器傳送確認資訊給客戶端: C<---NET---S (伺服器沿著原來的網路回傳資訊, 客戶端若收到後維持網路)
* 伺服器向客戶端傳送確認訊息,確保NAT裝置為這對IP和埠建立了對映。
*
* 第四步: 儲存對映:C<---NET--->S 需要不斷髮送保活包, 建議為1/2超時時間 (這條路可通, 不斷維持這條路)
* 客戶端和伺服器透過傳送UDP包來保持這個對映。只要對映存在,後續的UDP包可以直接穿過NAT裝置。
* NAT裝置會在一段時間內沒有檢測到任何活動後關閉對映。這段時間通常被稱為“空閒超時時間”或“會話超時時間”。
* UDP超時時間:常見的預設值是30秒、60秒或120秒。
* TCP超時時間: 通常在數分鐘到數小時之間。
*/
結果
打洞模組編寫成功, 可實現內網客戶端與伺服器相互UDP通訊
參考連結
- P2P通訊原理與實現(C語言) - 知乎 (zhihu.com)
- UDP內網穿透和打洞原理與程式碼實現 - 知乎 (zhihu.com)