【Linux】【网络】UDP打洞-->不同子网下的客户端和服务器通信(成功版)
【Linux】【网络】UDP打洞–>不同子网下的客户端和服务器通信(成功版)
根据上个文章的分析 问题可能出现在代码逻辑上面 我这里重新查找资料怀疑:
1 NAT映射可能需要多次数据包的发送才能建立。
2 NAT映射保存时间太短,并且 NAT 可能会在短时间内改变这些映射,需要一直保持映射。
-
有些 NAT 设备会因为短时间内没有数据而回收端口映射,导致服务器提供的 IP:Port 失效。
-
保活机制:双方定期发送保活包以防 NAT 超时关闭映射。
3 服务器只是向双方发送了IP和端口后直接退出了,并未发送数据包给客户端,导致NAT 设备未建立映射
(服务器只发送了一次数据,但部分 NAT 设备需要多次交互才会创建映射,一些 NAT 需要收到外部数据包后才会保持映射。)
4 我在代码中绑定了固定端口,NAT内部可能会有自己的实现,让系统自己分配端口可能更合适
5 需要双方在获得对方公网地址后,立即向对方发送“敲门”数据包,以便各自的 NAT 建立映射。
- NAT 设备通常只允许曾经主动发送数据给对方的地址接收数据。
- 客户端第一次sendto()的数据包会被丢掉 后续才能正确通信
这里贴一下udp通信接口
整体流程
1. 注册阶段
-
客户端动作:
- 每个客户端(C1 和 C2)启动时创建至少一个 UDP 套接字(有些实现中为了隔离控制与数据可能创建两个,但最关键的是:
- 用于向服务器发送注册消息,并接收服务器返回的信息)。
- 客户端向服务器发送注册消息(例如 “HELLO”)。
- 此时,通过调用
recvfrom()
,服务器能够获得客户端发送数据时的源地址信息,即 NAT 映射后的公网 IP 和端口。
- 每个客户端(C1 和 C2)启动时创建至少一个 UDP 套接字(有些实现中为了隔离控制与数据可能创建两个,但最关键的是:
-
服务器动作:
- 服务器启动后创建一个 UDP 套接字并绑定到固定端口(例如 50001)。
- 服务器循环等待接收客户端的注册消息,同时记录每个客户端的公网映射地址(从 recvfrom 返回的 sockaddr 信息中获得)。
2. 地址交换阶段
- 服务器端:
- 用于存储两个客户端的地址信息和当前注册的客户端数量(例如使用数组
struct sockaddr_in clientAddrs[2]
和计数器clientCount
)。 - 当服务器接收到两个客户端的注册消息后(比如两条 “HELLO” 消息),服务器将这两个客户端的公网映射地址互换:
- 向客户端1发送消息,格式例如
"PEER <C2公网IP>^<C2映射端口>"
; - 向客户端2发送消息,格式例如
"PEER <C1公网IP>^<C1映射端口>"
。
- 向客户端1发送消息,格式例如
- 为确保 NAT 映射持续有效(避免超时关闭),服务器还可以再额外向两个客户端发送探测数据包(如 “ping”),激活双方的 NAT 映射。
- 用于存储两个客户端的地址信息和当前注册的客户端数量(例如使用数组
3. NAT 打洞阶段
- 客户端(C1 和 C2):
- 在收到服务器返回的包含对方公网映射地址的信息后,每个客户端解析出对方的公网 IP 和映射端口。
- 然后,客户端立即向对方发送一个“敲门”数据包(例如 “knock” 或 “ping”),目的是:
- 主动触发自己 NAT 设备建立向对方的映射,
- 同时激活对方 NAT 中关于本端发送数据的映射。
- 双方互发“敲门”包后,各自的 NAT 设备就会允许对方返回数据,即使之前还没有真正建立起正式的数据通信。
4. 双向通信阶段
-
建立通信:
- 双方均使用服务器返回的地址(即各自 NAT 映射后的公网 IP 和端口)作为目标地址来发送数据包。
- 客户端进入一个循环:
- 定时(例如每 500ms 或每 5 秒)向对方发送数据包(这可以是用户输入的消息,也可以是保活数据,如 “KEEP_ALIVE”),
- 同时接收对方的回复,从而确认通信通道已建立。
-
保活机制:
- 为防止 NAT 映射因长时间无数据而超时关闭,每个客户端可启动独立的保活线程,定时向对方发送“保活包”,确保映射持续有效。
代码
server.cpp
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <arpa/inet.h>
#include <errno.h>
#include <pthread.h>
// 定义服务器监听端口和缓冲区大小
#define SERVER_PORT 50001
#define BUF_SIZE 1024
// 全局变量:用于存储两个客户端的地址信息和当前注册的客户端数量
struct sockaddr_in clientAddrs[2];
int clientCount = 0;
// 互斥锁保护对 clientAddrs 和 clientCount 的访问
pthread_mutex_t clients_mutex = PTHREAD_MUTEX_INITIALIZER;
int main() {
int sockfd; // UDP 套接字描述符
char buffer[BUF_SIZE]; // 用于接收数据的缓冲区
struct sockaddr_in serverAddr, clientAddr;
socklen_t addrLen = sizeof(clientAddr);
// 1. 创建 UDP 套接字
sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) {
std::cerr << "Server: Socket creation error: " << strerror(errno) << std::endl;
return -1;
}
// 2. 设置并初始化服务器地址结构体
memset(&serverAddr, 0, sizeof(serverAddr));
serverAddr.sin_family = AF_INET; // IPv4
serverAddr.sin_addr.s_addr = INADDR_ANY; // 接受任意IP
serverAddr.sin_port = htons(SERVER_PORT); // 绑定到 SERVER_PORT
// 3. 绑定套接字到指定地址和端口
if (bind(sockfd, (struct sockaddr*)&serverAddr, sizeof(serverAddr)) < 0) {
std::cerr << "Server: Bind error: " << strerror(errno) << std::endl;
close(sockfd);
return -1;
}
std::cout << "Server is running on port " << SERVER_PORT << ". Waiting for clients..." << std::endl;
// 4. 进入无限循环,不断接收客户端消息
while (true) {
// 清空缓冲区,准备接收新的数据
memset(buffer, 0, BUF_SIZE);
// 使用 recvfrom 接收客户端数据,同时获取发送者地址
int n = recvfrom(sockfd, buffer, BUF_SIZE, 0, (struct sockaddr*)&clientAddr, &addrLen);
if (n < 0) {
std::cerr << "Server: recvfrom error: " << strerror(errno) << std::endl;
continue;
}
buffer[n] = '\0'; // 确保数据以字符串形式结束
std::cout << "Received from client: " << buffer << " from "
<< inet_ntoa(clientAddr.sin_addr) << ":" << ntohs(clientAddr.sin_port) << std::endl;
// 加锁后处理客户端注册数据
pthread_mutex_lock(&clients_mutex);
// 当收到 "HELLO" 消息且当前注册客户端数不足 2 时,保存客户端地址信息
if (strcmp(buffer, "HELLO") == 0 && clientCount < 2) {
clientAddrs[clientCount] = clientAddr;
clientCount++;
// 回复 ACK 给注册的客户端,确认收到消息
const char* ack = "ACK";
sendto(sockfd, ack, strlen(ack), 0, (struct sockaddr*)&clientAddr, addrLen);
}
// 如果两个客户端都已注册,交换它们的公网映射地址信息
if (clientCount == 2) {
char msg[BUF_SIZE] = { 0 };
// 构造发送给客户端1的消息:包含客户端2的公网IP和映射端口,格式为 "PEER <ip> <port>"
snprintf(msg, BUF_SIZE, "PEER %s %d",
inet_ntoa(clientAddrs[1].sin_addr),
ntohs(clientAddrs[1].sin_port));
sendto(sockfd, msg, strlen(msg), 0,
(struct sockaddr*)&clientAddrs[0], sizeof(clientAddrs[0]));
std::cout << "Sent to client1: " << msg << std::endl;
// 构造发送给客户端2的消息:包含客户端1的公网IP和映射端口
snprintf(msg, BUF_SIZE, "PEER %s %d",
inet_ntoa(clientAddrs[0].sin_addr),
ntohs(clientAddrs[0].sin_port));
sendto(sockfd, msg, strlen(msg), 0,
(struct sockaddr*)&clientAddrs[1], sizeof(clientAddrs[1]));
std::cout << "Sent to client2: " << msg << std::endl;
// 为保持 NAT 映射有效,可选:服务器额外发送“ping”包给双方
sendto(sockfd, "ping", 4, 0, (struct sockaddr*)&clientAddrs[0], sizeof(clientAddrs[0]));
sendto(sockfd, "ping", 4, 0, (struct sockaddr*)&clientAddrs[1], sizeof(clientAddrs[1]));
// 重置客户端计数,等待下一组客户端注册
clientCount = 0;
}
pthread_mutex_unlock(&clients_mutex);
}
close(sockfd);
return 0;
}
-
头文件包含与宏定义
- 包含了网络编程、字符串操作、线程和错误处理所需的头文件。
- 定义了服务器端口(50001)和缓冲区大小(1024字节)。
-
全局变量声明
clientAddrs[2]
用于存储两个客户端的地址。clientCount
记录已注册的客户端数量。- 使用
clients_mutex
保护这些共享变量的并发访问。
-
主函数开始
- 创建 UDP 套接字,并检查是否成功;若失败则打印错误信息并退出。
- 初始化服务器地址结构体(IPv4、任意地址、端口转换为网络字节序)。
- 绑定套接字到服务器地址。如果绑定失败则退出。
-
打印服务器启动信息
- 输出服务器已启动并监听的提示信息。
-
进入无限循环,接收客户端消息
- 每次循环开始前清空缓冲区。
- 调用
recvfrom()
接收客户端数据,并填充客户端地址(即 NAT 映射的公网地址)。 - 打印接收到的消息和客户端地址(转换为点分十进制 IP 与主机字节序的端口)。
-
客户端注册处理(加锁区域)
- 当接收到 “HELLO” 消息且
clientCount < 2
时,将当前客户端地址保存到clientAddrs
数组中,同时发送 “ACK” 消息确认注册。 - 这一步确保服务器能记录每个客户端 NAT 映射后的公网地址。
- 当接收到 “HELLO” 消息且
-
地址互换与探测(当两个客户端均已注册时)
- 如果
clientCount == 2
,服务器构造两个字符串:- 分别包含对方的公网 IP 和映射后的端口,格式为
"PEER <ip> <port>"
。
- 分别包含对方的公网 IP 和映射后的端口,格式为
- 分别将该信息发送给两个客户端,完成地址信息的互换。
- 额外发送“ping”数据包给双方,以确保双方 NAT 映射不会因长时间无数据而关闭。
- 最后重置
clientCount
,等待下一组客户端注册。
- 如果
-
退出与关闭套接字
- 循环结束后关闭套接字并返回。
client.cpp(双方一致)
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <arpa/inet.h>
#include <errno.h>
#include <pthread.h>
#include <stdlib.h>
#include <net/if.h> // 用于 IFNAMSIZ 和 if_nametoindex
#define SERVER_PORT 50001 // 服务器端口号
#define BUF_SIZE 1024 // 缓冲区大小
#define KEEP_ALIVE_INTERVAL 25 // 保活包发送间隔(秒)
// 全局变量:保存对等端(peer)的地址信息
struct sockaddr_in peerAddr;
// 全局的 UDP 套接字描述符(用于所有操作)
int sockfd;
// 定义互斥锁,用于保护共享的套接字操作,避免多线程竞争
pthread_mutex_t sock_mutex = PTHREAD_MUTEX_INITIALIZER;
// 保活线程函数:定时向对方发送 "KEEP_ALIVE" 消息,保持 NAT 映射
void* keep_alive(void* arg) {
const char* keepAliveMsg = "KEEP_ALIVE";
while (true) {
pthread_mutex_lock(&sock_mutex);
// 使用 sendto() 发送保活包到对方地址
sendto(sockfd, keepAliveMsg, strlen(keepAliveMsg), 0,
(struct sockaddr*)&peerAddr, sizeof(peerAddr));
pthread_mutex_unlock(&sock_mutex);
std::cout << "[KeepAlive] Sent keep alive to peer." << std::endl;
sleep(KEEP_ALIVE_INTERVAL);
}
return NULL;
}
int main(int argc, char* argv[]) {
// 命令行参数:需要传入服务器 IP 和本地绑定的网络接口(例如 "eth0")
if (argc < 3) {
std::cerr << "Usage: " << argv[0] << " <server_ip> <bind_interface>" << std::endl;
std::cerr << "Example: " << argv[0] << " 203.0.113.5 eth0" << std::endl;
return -1;
}
const char* serverIP = argv[1];
const char* bindInterface = argv[2]; // 指定绑定的网络接口
// 1. 创建 UDP 套接字
sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) {
std::cerr << "Client: Socket creation error: " << strerror(errno) << std::endl;
return -1;
}
// 2. 绑定套接字到指定网络接口
// 这一步可以确保数据包通过指定的接口发送,防止“Network is unreachable”错误
if (setsockopt(sockfd, SOL_SOCKET, SO_BINDTODEVICE, bindInterface, strlen(bindInterface)) < 0) {
std::cerr << "Client: SO_BINDTODEVICE error: " << strerror(errno) << std::endl;
close(sockfd);
return -1;
}
// 3. 绑定本地地址,让系统自动分配端口(无需固定端口)
struct sockaddr_in localAddr;
memset(&localAddr, 0, sizeof(localAddr));
localAddr.sin_family = AF_INET;
localAddr.sin_addr.s_addr = INADDR_ANY;
localAddr.sin_port = htons(0); // 0 表示由系统自动分配
if (bind(sockfd, (struct sockaddr*)&localAddr, sizeof(localAddr)) < 0) {
std::cerr << "Client: Bind error: " << strerror(errno) << std::endl;
close(sockfd);
return -1;
}
// 4. 设置服务器地址结构
struct sockaddr_in serverAddr;
memset(&serverAddr, 0, sizeof(serverAddr));
serverAddr.sin_family = AF_INET;
// 将服务器 IP 转换为网络地址格式
if (inet_pton(AF_INET, serverIP, &serverAddr.sin_addr) <= 0) {
std::cerr << "Client: Invalid server IP." << std::endl;
close(sockfd);
return -1;
}
serverAddr.sin_port = htons(SERVER_PORT);
socklen_t serverLen = sizeof(serverAddr);
// 5. 向服务器发送注册消息 "HELLO"
const char* helloMsg = "HELLO";
if (sendto(sockfd, helloMsg, strlen(helloMsg), 0,
(struct sockaddr*)&serverAddr, serverLen) < 0) {
std::cerr << "Client: sendto error: " << strerror(errno) << std::endl;
close(sockfd);
return -1;
}
std::cout << "Sent HELLO to server." << std::endl;
// 6. 接收来自服务器的回复
char buffer[BUF_SIZE] = { 0 };
struct sockaddr_in fromAddr;
socklen_t fromLen = sizeof(fromAddr);
int n = recvfrom(sockfd, buffer, BUF_SIZE, 0, (struct sockaddr*)&fromAddr, &fromLen);
if (n < 0) {
std::cerr << "Client: recvfrom error: " << strerror(errno) << std::endl;
close(sockfd);
return -1;
}
buffer[n] = '\0';
std::cout << "Received from server: " << buffer << std::endl;
// 7. 如果收到的是 ACK,则继续等待服务器发来的 PEER 信息
if (strncmp(buffer, "ACK", 3) == 0) {
memset(buffer, 0, BUF_SIZE);
n = recvfrom(sockfd, buffer, BUF_SIZE, 0, (struct sockaddr*)&fromAddr, &fromLen);
if (n < 0) {
std::cerr << "Client: recvfrom error: " << strerror(errno) << std::endl;
close(sockfd);
return -1;
}
buffer[n] = '\0';
std::cout << "Received from server: " << buffer << std::endl;
}
// 8. 解析服务器返回的 PEER 信息,格式为 "PEER <peer_ip> <peer_port>"
char peerIP[INET_ADDRSTRLEN];
int peerPort;
if (sscanf(buffer, "PEER %s %d", peerIP, &peerPort) == 2) {
memset(&peerAddr, 0, sizeof(peerAddr));
peerAddr.sin_family = AF_INET;
if (inet_pton(AF_INET, peerIP, &peerAddr.sin_addr) <= 0) {
std::cerr << "Client: Invalid peer IP." << std::endl;
close(sockfd);
return -1;
}
peerAddr.sin_port = htons(peerPort);
std::cout << "Peer address: " << peerIP << ":" << peerPort << std::endl;
}
else {
std::cerr << "Client: Failed to parse peer info." << std::endl;
close(sockfd);
return -1;
}
// 9. 在收到 PEER 信息后,客户端可以立即向对方发送“敲门”包,以触发 NAT 映射
const char* knockMsg = "knock";
sendto(sockfd, knockMsg, strlen(knockMsg), 0,
(struct sockaddr*)&peerAddr, sizeof(peerAddr));
std::cout << "Sent knock to peer." << std::endl;
// 10. 启动保活线程,定时向对方发送 "KEEP_ALIVE" 包,保持 NAT 映射
pthread_t kaThread;
if (pthread_create(&kaThread, NULL, keep_alive, NULL) != 0) {
std::cerr << "Client: pthread_create error: " << strerror(errno) << std::endl;
close(sockfd);
return -1;
}
// 11. P2P 交互:从标准输入读取消息,向对方发送,并接收对方回复
while (true) {
std::cout << "Enter message to send to peer: ";
std::string input;
std::getline(std::cin, input);
if (input.empty()) continue;
// 加锁确保发送数据时套接字操作不会与保活线程冲突
pthread_mutex_lock(&sock_mutex);
sendto(sockfd, input.c_str(), input.length(), 0,
(struct sockaddr*)&peerAddr, sizeof(peerAddr));
pthread_mutex_unlock(&sock_mutex);
// 设置接收超时,等待对方回复
memset(buffer, 0, BUF_SIZE);
struct timeval tv;
tv.tv_sec = 5;
tv.tv_usec = 0;
setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, (const char*)&tv, sizeof(tv));
n = recvfrom(sockfd, buffer, BUF_SIZE, 0, NULL, NULL);
if (n > 0) {
buffer[n] = '\0';
std::cout << "Received from peer: " << buffer << std::endl;
}
}
close(sockfd);
return 0;
}
-
头文件包含与宏定义
- 包含了进行网络编程、线程同步、字符串处理、错误处理等所需的头文件。
- 定义服务器端口号、缓冲区大小和保活包的发送间隔。
-
全局变量
peerAddr
:用于保存对方客户端的公网映射地址。sockfd
:全局 UDP 套接字,用于所有数据传输。sock_mutex
:互斥锁,确保多线程(保活线程与主线程)对同一套接字的操作不会冲突。
-
keep_alive() 保活线程
- 循环内使用互斥锁保护对
sockfd
的 sendto 操作,向peerAddr
发送 “KEEP_ALIVE” 消息,间隔 25 秒一次,帮助保持 NAT 映射。
- 循环内使用互斥锁保护对
-
main() 函数开始
- 检查命令行参数,要求传入服务器 IP 和本地绑定的网络接口(例如 “eth0”)。
-
创建 UDP 套接字
- 使用
socket()
创建一个 UDP 套接字,并检查是否成功。
- 使用
-
绑定网络接口
- 通过
setsockopt()
使用SO_BINDTODEVICE
将套接字绑定到指定网络接口,确保数据包走正确的网络。
- 通过
-
绑定本地地址
- 绑定本地地址,让系统自动分配端口(使用端口 0)。
-
设置服务器地址
- 构造服务器地址结构体,转换服务器 IP 字符串为网络格式,并设置服务器端口。
-
向服务器发送注册消息 “HELLO”
- 使用
sendto()
发送 “HELLO” 消息给服务器,通知服务器本客户端注册。
- 使用
-
接收服务器回复
- 调用
recvfrom()
接收服务器的回应。 - 第一次可能收到 ACK,如果收到 ACK,则继续等待服务器发送包含对方地址的 PEER 信息。
- 调用
-
解析 PEER 信息
- 使用
sscanf()
解析服务器返回的字符串,提取出对方的公网 IP 和映射端口,并存入peerAddr
结构体中。
- 使用
-
主动向对方发送“敲门”包
- 收到对方地址后,立即使用
sendto()
向该地址发送 “knock” 包,以触发 NAT 映射的建立。
- 收到对方地址后,立即使用
-
启动保活线程
- 创建一个新线程运行
keep_alive()
,定时向对方发送保活包,防止 NAT 映射超时关闭。
- 创建一个新线程运行
-
进入 P2P 交互循环
- 循环中提示用户输入消息,然后加锁通过
sendto()
发送给对方。 - 设置接收超时,使用
recvfrom()
尝试接收对方的回复,并打印收到的数据。
- 循环中提示用户输入消息,然后加锁通过
-
关闭套接字并结束程序
- 循环退出后关闭套接字。