v1.0 2024年6月5日 发布于博客园
序言
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通信
发表评论