存档

文章标签 ‘udp’

linux网络编程手记

十二月 12th, {2010 4 条评论 12,747 人阅读过  

做linux下的网络编程有一段时间了,中间遇到过很多问题,其中不少是因为自己对网络编程和网络协议的一些基本概念搞不清楚,趁着今天没心情干活就把自己在网络编程方面的理解和一些经验总结一下,Request For Comments。

在诸多的网络协议中接触的最多也最紧密的无疑是TCP和UDP,SCTP之前因为项目原因也研究过,不过最终由于方案修改给抛弃了,TCP年代已经很久远,在网上的资料也非常多,而且我感觉它是一种非常复杂的协议,感觉要把编好基于TCP的程序光简单地了解几个socket API是不够的,刚开始接触网络编程的时候自己确实也吃了不少苦头,后来我还专门拿时间出来阅读了一下RFC,再加上长时间的实践总算也对TCP有所了解,把自己的一些经验和教训都总结一下。

首先说一下TCP的状态转移图,这个应该是很重要的,了解TCP运行周期的各种状态才能更好地运用netstat之类的应用程序去对程序进行调试,我这里收藏了一张图,是TCP的状态图,记不清是从哪里找来的,也不知道直接版权该给谁,但这张图应该最终是出自于UNP第一卷的,那copyright就是UNP了吧。

1.TCP连接状态

连接建立的几个状态没什么可说的,TCP的三次握手众所周知,更重要的是TCP连接中止的几个状态,应该可以说是连接中止需要四次握手吧。

当Client调用close函数主动关闭socket时,连接状态被标记为FIN_WAIT_1,Server在收到FIN之后read函数会返回0,这里server知道Client已经关闭连接,回复ACK,这里client连接状态被标记为FIN_WAIT_2,接下来Server调用close函数关闭连接,这时候Server向client发送FIN,Client收到之后将状态标记为TIME_WAIT,并回复ACK。

TIME_WAIT这个状态存在的意义在于Client回复的ACK未必会被Server收到,可能在传输过程中导致包的丢失,而这里Server未收到ACK之后会重新向Client发送FIN,如果client未将状态标记为TIME_WAIT而是直接标记为CLOSED,则Server发送的FIN会直接收到RST,导致Server端的发送错误,因此Client需要保证有一个TIME_WAIT状态,而这个状态会持续两位的MSL(最大段生命周期),从而保证Server成功发送FIN并发送ACK,为了保证两个数据段传输的最大时间,因此TIME_WAIT持续的时间为两倍的MSL。

Server在收到第一个FIN之后会将状态标记为CLOSE_WAIT,此时是client主动关闭连接,这里Server也需要调用Close给Client发送FIN(如上所述),之后Server的状态标记为LAST_ACK,表示Server正在等待Client发送的最后一个ACK,当Server收到最后一个ACK便会将连接标记为CLOSED,这时连接结束。TIME_WAIT这个状态和套接字的SO_REUSEADDR选项是有关系的,这个留做后面讨论。

2.TCP连接异常情况

TCP连接异常分为很多种情况,无论是客户端程序还是服务器端程序都需要考虑周全的。

Server在连接的过程中程序崩溃或者CTRL+C中止程序,或者kill接Server进程。这时会导致Server立即发送一个FIN数据包给Client,Client如果此时正在调用recv函数,则recv函数返回0,表示服务器已关闭连接,如果Client调用send函数继续向Server发送数据,Server在收到后会回复RST,而此时send方法会触发SIGPIPE信号,表示通信管道已断开,在程序中如果对该信号不做处理则会导致程序的崩溃,一般在程序开始时会忽略此信号,则在这种情况下send函数会返回-1,表示发送失败,处理SIGPIPE的代码如下:

前几天实验室这个破项目非要加上什么流媒体的功能,简单起见使用了VLC来实现,客户端这边就得需要把相关的播放界面整合到现有的界面里面来,之前的客户端UI我都是用GTK实现的,没办法,GTK用得比较多,相对熟练一些就用GTK来做了,没想到要把VLC整到GTK里面来那么麻烦,原生的libvlc是不支持GTK的,需要加一层libvlc-gtk,从网上好不容易下载到了libvlc-gtk的源码,从哪里下的也记不清了,反正就是零散地几个文件,没有README甚至连Makefile都没有,没办法首先得先写个Makefile把它编译一下,libvlc-gtk一共有八个文件,Makefile如下:

struct sigaction sa;
sa.sa_handler = SIG_IGN;
sigaction(SIGPIPE, &sa, 0 );

另外在这种情况下select函数也会立即返回,socket描述符会被设置,而试图从该socket中recv数据,则会返回-1。

另外一种情况是Server系统崩溃或者网络直接异常或断开,这时候Server不可能再给Client发送FIN包,而Client调用send函数后会导致数据包一直重传直接超时后返回-1,而recv函数也会一直阻塞直接超时后返回-1。这种情况就很难判断是Server端进程关闭还是网络异常,这种情况一般会用TCP的KEEP ALIVE机制,每隔一定的时间向对方发送一个只有一字节数据内容的数据包,对端收到后会返回一个ACK,以此来确保连接正常,如果未收到ACK,会尝试重传,直到重试规定次数后可以将与对端的连接标记为断开,send和recv将会返回-1。KEEP ALIVE的使用方法如下:

int tcp_keep_alive(int socketfd)
{
	int keepAlive = 1;
	int keepIdle = 10;         /* 开始发送KEEP ALIVE数据包之前经历的时间 */
	int keepInterval = 10;   /* KEEP ALIVE数据包之前间隔的时间 */
	int keepCount = 10;     /* 重试的最大次数 */
 
	if(setsockopt(socketfd , SOL_SOCKET , SO_KEEPALIVE
				,(void*)&keepAlive,sizeof(keepAlive)) == -1){
		debug_info("set SO_KEEPALIVE failed\n");
		return -1;
	}
 
	if(setsockopt(socketfd , SOL_TCP , TCP_KEEPIDLE
				,(void *)&keepIdle,sizeof(keepIdle)) == -1){
		debug_info("set TCP_KEEPIDEL failed\n");
		return -1;
	}
 
	if(setsockopt(socketfd , SOL_TCP , TCP_KEEPINTVL
				,(void *)&keepInterval,sizeof(keepInterval)) == -1){
		debug_info("set TCP_KEEPINTVL failed\n");
		return -1;
	}
 
	if(setsockopt(socketfd , SOL_TCP , TCP_KEEPCNT
				,(void *)&keepCount,sizeof(keepCount)) == -1){
		debug_info("set TCP_KEEPCNT failed\n");
		return -1;
	}
	return 1;
}

上面这个函数只针对Linux,昨天有网友告知在Mac OS上TCP_KEEPIDLE ,TCP_KEEPINTVL, TCP_KEEPCNT这些宏将未定义。另外对于这些参数的设置也是需要注意的,很多系统中它们的设置并不是对单个socket描述符起作用的,而是该机器上的所有socket描述符起作用的,所以这个需要注意(这个是从UNP里面看到的)。

3.关于字节顺序
Linux的主机字节顺序是采用little-endian字节顺序,而网络字节顺序是采用big-endian字节顺序,字节顺序转换是必需的。写了一个小程序来检测字节顺序,不知道对不对,Request For Comment.

#include 
 
int main(int argc, char **argv)
{
	short s = 0x0102;
	if((*(unsigned char*)&s) == 2)
		printf("little endian\n");
	else if((*(unsigned char*)&s) == 1)
		printf("big endian\n");
	else
		printf("unknown endian\n");
 
	return 0;
}

3.关于send和recv

写过socket程序的人肯定都会知道send和recv函数并不会总是返回要求发送或读取的字节数,如:

int ret = recv(sk, buf, 2096, 0);

这句话并不总是读取到完整地2096个字节,相反地,大多数情况下都不能将buf读满,recv只能返回当前可以读取到的字节数,如果协议规定本次读取肯定会读取到N个字节,那我一般的做法会写一个这样的函数来确保读取到固定的字节数:

int buf_recv(int sock, void *buf, size_t len, int flags)
{
	int n, ret;
	if(len == 0) return 0;
	for(n=0;n!=len &&(ret = recv(sock, buf+n, len-n, flags)) != -1 &&ret; n += ret);
	return (n!=len)? -1:n;
}

关于这两个函数还有很重要的一点是应该尽可能大地一次发送或接收更多地数据,当然前提是缓冲区中有这些数据的话,原因很简单,当通信链路很好的时候数据可能会填满系统缓冲区,而recv便是从缓冲区中读取数据,这时候一次读取更多地字节就意味着可以少调用几次recv函数,而这些函数通常都是调用了系统调用,需要进行内核态和用户态上下文的切换,也就意味着多调用几次recv会带来额外的开销,之前写的一个代理服务器的程序数据传输速度一直很低,后来修改了recv和send的缓冲区大小后速率提高了近一倍。

4.关于非阻塞模式

一般应用的时候都是使用阻塞式IO,至少我在大多数情况下都用的阻塞式IO,非阻塞很少应用,但存在便我价值,我用到的非阻塞IO的情况一般是用来进行超时connect,首先将socket设为非阻塞模式,connect立即返回-1,此时已向对端发送FIN,而并未来得及收到任何ACK,于是直接返回-1,但并不代表连接失败,errno会被置为EINPROGRESS ,表示连接正在进行中,然后通过select来设置socket可写的超时时间,如果规定时间内可写,且socket并无出错,则表示连接成功,socket出错则表示连接失败,或规定时间内不可写则表示连接超时,简单地写了如下代码:

#include
#include
#include
#include
#include
#include
#include 
 
int main(int argc, char *argv[])
{
	int                sk;
	int                flags;
	int                err = 0;
	int                ret;
	socklen_t          len;
	struct sockaddr_in addr;
	fd_set             fd_write;
	struct timeval     tv;
 
	if( (sk = socket(AF_INET, SOCK_STREAM, 0)) == -1 ) {
		perror("socket");
		return 1;
	}
 
	if( (flags = fcntl(sk, F_GETFL, 0)) == -1 ){
		perror("fcntl GET flags failed");
		return 1;
	}
 
	if(fcntl(sk, F_SETFL, flags | O_NONBLOCK) == -1) {
		perror("fcntl SET flags failed");
		return 1;
	}
 
	memset(&addr, 0, sizeof(addr));
	addr.sin_family = AF_INET;
	addr.sin_addr.s_addr = inet_addr("59.64.129.169");
	addr.sin_port = htons(808);
 
	if(connect(sk, (struct sockaddr*)&addr, sizeof(addr)) == -1	) {
		if(errno != EINPROGRESS) {
			perror("connect");
			return 1;
		}
		FD_ZERO(&fd_write);
		FD_SET(sk, &fd_write);
		tv.tv_sec = 5;
		tv.tv_usec = 0;
 
		ret = select(sk + 1, (fd_set*)0, &fd_write, (fd_set*)0, &tv);
 
		if(ret > 0){
 
			if(FD_ISSET(sk, &fd_write)) {
				len = sizeof(int);
 
				if(getsockopt(sk, SOL_SOCKET, SO_ERROR, &err, &len) == 0) {
					if(err == 0) { printf("connect success\n"); return 0; }
					else { fprintf(stderr, "connect:%s\n", strerror(err)); return 1; }
				}else{
					fprintf(stderr, "getsockopt:%s\n", strerror(err));
					return 1;
				}
			}else{
				fprintf(stderr, "connect(FD_ISSET) failed\n");
				return 1;
			}
 
		}else if(ret == 0) {
			fprintf(stderr, "connect timeout\n");
			return 1;
		}else {
			fprintf(stderr, "connect(select):%s\n", strerror(errno));
			return 1;
		}
	}else{
		fprintf(stderr, "connect:%s\n", strerror(errno));
		return 1;
	}
	return 0;
}

5.关于select多路复用

select是网络编程中很常用的函数,用来进行IO多路复用,但之前我一直忽略了一个问题,当select返回时会将本次检查中不可用的描述符(如不可读或不可写)的描述符从描述符集中删除,只保留当前可用的描述符,在对多个socketfd进行利用的时候需要注意,每次循环select之前都需要在select之前用FD_SET重新设置描述符,否则之后便只能返回第一次可读的描述符了。

6.关于UDP广播

UDP广播这个也简单说一下,首先255.255.255.255这个地址是不能被路由的,只能被本物理网络的数据包接收。UDP广播之前需要给socket设置SO_BROADCAST选项:

int brodopt = 1;
setsockopt(cp_usock, SOL_SOCKET, SO_BROADCAST, &brodopt, sizeof(brodopt));

UDP广播需要注意的一点是广播接收时接收端bind的本地地址的问题,接收端必须绑定INADDR_ANY这个地址才可以接收广播包,如果是绑定的某个特定的地址则无法接收广播包。

OK,简单说这么几条,都是我编程时候遇到的经验总结,以后再遇到什么问题再接着补充。

分类: Linux 标签: , ,

DNS解析协议的C语言简单实现

五月 11th, {2010 9 条评论 22,693 人阅读过  

仔细看了看DNS协议的相关东西,其实实际编程的时候根本用不到DNS细节的东西,要获取域名的时候经终端下用host或者nslookup指令就可以,在c里面使用gethostbyname或者getaddrinfo都能很轻松得将dns域名解析为ip地址,写这个纯粹出于个人兴趣,或者说是闲得吧。

在进行域名解析的时候,解析程序向域名服务器发起请求,域名服务器也就是在操作系统网络配置的时候写进去的那个DNS服务器地址,或者也有可能是由ISP提供的自动获取的,原理都一样,域名服务器收到请求后进行处理,首先在本地缓存中查找对应的域名,找到后将IP地址直接返回,找不到就向其它的授权服务器请求数据,又可以分为著名的递归查询和非递归查询。

递归查询就是说自始至终都由一台域名服务器进行查询,它在自己这里找不到的时候会向其它的域名服务器请求并且获取数据,然后返回给请求方。

非递归查询是指域名服务器收到请求后,如果自己有这个域名的信息就返回,如果没有就返回其它域名服务器的指针,请求方再根据这些域名服务器再发起查询。

按自己的理解瞎扯了一通,也不知道准不准确,关于DNS的相关资料网上有的是,中文的都大批大批的。

DNS服务器的原理其实没什么好说的,每天都在跟DNS打交道,但DNS的协议在实现上还是稍微有点意思的,本来想写个程序来测试一个我所了解的DNS协议,后来在写的时候还真发现一个小问题,DNS域名有时候会是一个主域名的别名,比如www.baidu.com,它就是www.a.shifen.com这个域名的别名,在DNS请求发送过去之后,response里面会有一个类型为CNAME的Answers项,里面包含了主域名的相关信息(其实也就是主域名的名称和TTL),在这个应答消息里面可能会出现多个域名消息,比如每个Answers的第一个字段就是一个域名,当然为了减少数据包的容量,DNS系统对域名进行了压缩,同一个域名只会出现一次,其它的时候再出现的话就会用一个DNS指针表示。
比如域名:www.baidu.com在数据包中的表示是 03 77 77 77 05 62 61 69 64 75 03 63 6f 6d 00

粗体的是长度,将域名中的点去掉,用长度来分隔域名,以0结束。DNS允许的长度为0-63个字节,所以一个8位的长度最高两位都为0。

而如果此处域名重复出现,信令中便会用DNS指针代替长度,指针为两个字节,16位的最位都为1,剩下的14位表示在在整个数据包中的偏移量,当程序读取到c00c的时候很容易判断它是一个指针而不是一个长度字段,于是根据c00c指向的领移量,即从数据包开始后的第12个字节,跳转过去读取出域名信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
 
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netdb.h>
 
#define DNS_SVR "211.68.71.4"
 
#define DNS_HOST  0x01
#define DNS_CNAME 0x05
 
int socketfd;
struct sockaddr_in dest;
 
static void 
send_dns_request(const char *dns_name);
 
static void
parse_dns_response();
 
/**
 * Generate DNS question chunk
 */
static void 
generate_question(const char *dns_name
		, unsigned char *buf , int *len);
 
/**
 * Check whether the current byte is 
 * a dns pointer or a length
 */
static int
is_pointer(int in);
 
/**
 * Parse data chunk into dns name
 * @param chunk The complete response chunk
 * @param ptr The pointer points to data
 * @param out This will be filled with dns name
 * @param len This will be filled with the length of dns name
 */
static void
parse_dns_name(unsigned char *chunk , unsigned char *ptr
		, char *out , int *len);
 
int main(int argc , char *argv[]){
 
	if(argc != 2){
		printf("Usage : %s <domain name>\n" , argv[0]);
		exit(-1);
	}
	socketfd = socket(AF_INET , SOCK_DGRAM , 0);
	if(socketfd < 0){
		perror("create socket failed");
		exit(-1);
	}
	bzero(&dest , sizeof(dest));
	dest.sin_family = AF_INET;
	dest.sin_port = htons(53);
	dest.sin_addr.s_addr = inet_addr(DNS_SVR);
 
	send_dns_request(argv[1]);
 
	parse_dns_response();
 
	return 0;
}
 
static void parse_dns_response(){
 
	unsigned char buf[1024];
	unsigned char *ptr = buf;
	struct sockaddr_in addr;
	char *src_ip;
	int n , i , flag , querys , answers;
	int type , ttl , datalen , len;
	char cname[128] , aname[128] , ip[20] , *cname_ptr;
	unsigned char netip[4];
	size_t addr_len = sizeof(struct sockaddr_in);
 
	n = recvfrom(socketfd , buf , sizeof(buf) , 0
		, (struct sockaddr*)&addr , &addr_len);
	ptr += 4; /* move ptr to Questions */
	querys = ntohs(*((unsigned short*)ptr));
	ptr += 2; /* move ptr to Answer RRs */
	answers = ntohs(*((unsigned short*)ptr));
	ptr += 6; /* move ptr to Querys */
	/* move over Querys */
	for(i= 0 ; i < querys ; i ++){
		for(;;){
			flag = (int)ptr[0];
			ptr += (flag + 1);
			if(flag == 0)
				break;
		}
		ptr += 4;
	}
	printf("-------------------------------\n");
	/* now ptr points to Answers */
	for(i = 0 ; i < answers ; i ++){
		bzero(aname , sizeof(aname));
		len = 0;
		parse_dns_name(buf , ptr , aname , &len);
		ptr += 2; /* move ptr to Type*/
		type = htons(*((unsigned short*)ptr));
		ptr += 4; /* move ptr to Time to live */
		ttl = htonl(*((unsigned int*)ptr));
		ptr += 4; /* move ptr to Data lenth */
		datalen = ntohs(*((unsigned short*)ptr));
		ptr += 2; /* move ptr to Data*/
		if(type == DNS_CNAME){
			bzero(cname , sizeof(cname));
			len = 0;
			parse_dns_name(buf , ptr , cname , &len);
			printf("%s is an alias for %s\n" , aname , cname);
			ptr += datalen;
		}
		if(type == DNS_HOST){
			bzero(ip , sizeof(ip));
			if(datalen == 4){
				memcpy(netip , ptr , datalen);
				inet_ntop(AF_INET , netip , ip , sizeof(struct sockaddr));
				printf("%s has address %s\n" , aname , ip);
				printf("\tTime to live: %d minutes , %d seconds\n"
						, ttl / 60 , ttl % 60);
			}
			ptr += datalen;
		}
 
	}
	ptr += 2;
}
 
static void
parse_dns_name(unsigned char *chunk
		, unsigned char *ptr , char *out , int *len){
	int n , alen , flag;
	char *pos = out + (*len);
 
	for(;;){
		flag = (int)ptr[0];
		if(flag == 0)
			break;
		if(is_pointer(flag)){
			n = (int)ptr[1];
			ptr = chunk + n;
			parse_dns_name(chunk , ptr , out , len);
			break;
		}else{
			ptr ++;
			memcpy(pos , ptr , flag);	
			pos += flag;
			ptr += flag;
			*len += flag;
			if((int)ptr[0] != 0){
				memcpy(pos , "." , 1);
				pos += 1;
				(*len) += 1;
			}
		}
	}
 
}
 
static int is_pointer(int in){
	return ((in & 0xc0) == 0xc0);
}
 
static void send_dns_request(const char *dns_name){
 
	unsigned char request[256];
	unsigned char *ptr = request;
	unsigned char question[128];
	int question_len;
 
 
	generate_question(dns_name , question , &question_len);
 
	*((unsigned short*)ptr) = htons(0xff00);
	ptr += 2;
	*((unsigned short*)ptr) = htons(0x0100);
	ptr += 2;
	*((unsigned short*)ptr) = htons(1);
	ptr += 2;
	*((unsigned short*)ptr) = 0;
	ptr += 2;
	*((unsigned short*)ptr) = 0;
	ptr += 2;
	*((unsigned short*)ptr) = 0;
	ptr += 2;
	memcpy(ptr , question , question_len);
	ptr += question_len;
 
	sendto(socketfd , request , question_len + 12 , 0
	   , (struct sockaddr*)&dest , sizeof(struct sockaddr));
}
 
static void
generate_question(const char *dns_name , unsigned char *buf , int *len){
	char *pos;
	unsigned char *ptr;
	int n;
 
	*len = 0;
	ptr = buf;	
	pos = (char*)dns_name; 
	for(;;){
		n = strlen(pos) - (strstr(pos , ".") ? strlen(strstr(pos , ".")) : 0);
		*ptr ++ = (unsigned char)n;
		memcpy(ptr , pos , n);
		*len += n + 1;
		ptr += n;
		if(!strstr(pos , ".")){
			*ptr = (unsigned char)0;
			ptr ++;
			*len += 1;
			break;
		}
		pos += n + 1;
	}
	*((unsigned short*)ptr) = htons(1);
	*len += 2;
	ptr += 2;
	*((unsigned short*)ptr) = htons(1);
	*len += 2;
}

分类: Protocol 标签: , ,

State of Peer-to-Peer Communication across NAT(rfc5128中文)

四月 29th, {2010 1 条评论 10,339 人阅读过  

翻译了一下RFC5128,并为我拙劣的英语水准和汉语言表达技巧感到汗颜,如果有人不幸搜到这篇文章,英语不是太差的话就不要看了。
英文原版地址:http://tools.ietf.org/html/rfc5128

1.Introduction and Scope

当前的Internet已经普遍布署了网络地址转换器(NATs).存在各种各样的NAT设备和各种各样使用NAT设备布署的网络拓扑。由NAT设备创建的非对称的地址和连接形式,给一些p2p应用程序和协议带来了它们特有的问题,像电话会议,多媒体播放器,网络游戏,这些问题甚至有可能持续到IPv6世界,在向IPv6过渡的时期,有些NAT可能需要使只支持IPv4的节点跟只支持IPv6的节点进行通信,尽管这种使用NAT的合适的协议或方法尚未解决。即使是将来的纯IPv6世界可能仍然包括防火墙,它仍然拥有和NAT类似的过滤功能,但没有地址转换功能,这种过滤行为也影响着P2P应用程序的功能。出于这个原因,这篇文章中讨论的NAT转换所使用的技术同样也适用于和带有和NAT转换类似的过滤功能的某些防火墙。

当前部署的NAT设备主要围绕client/server这种模式设计的,在这种模式下匿名,私有网络中的匿名客户端设备使用固定的IP地址和DNS域名访问处于公有网络的服务器。中途遇到的NAT设备为客户端主机动态地分配地址。这种匿名性和NAT设备后面的主机的不可访问性对于像web浏览器这样的应用程序来说并不是问题,web浏览器只需要创建outgoint连接。这种匿名性和不可访问性有时候对于隐私的保护是有利的。

在p2p的模式中,Internet主机通常情况下被称为是“客户端”,这种客户端不仅能够向其它的peer节点发起会话,还能接受来自其它节点发起的会话。发起者和响应者可能处于不同的NAT设备的后面,它们可能都没有永久的IP地址或者其它形式的公有网络属性。举个例子来说,一个普通的网络游戏的结构中,所有参与游戏的应用程序主机和一台公网可寻址的公共服务器交互来进行注册,并且发现其它的peer主机。在与这台公共服务器进行通信之后,主机彼此之间建立直接的连接以在游戏期间提供快速高效的数据更新。类似的,一个文件共享程序可能跟一台公共服务器进行交互进行资源发现和搜索,但却在peer主机之间建立直接连接进行数据传输。NAT设备给p2p的连接带来了麻烦,因为NAT设备后面的主机在Internet上没有固定的公网可见端口,其它的peer也就无法对起发起Incoming TCP或UDP连接。RFC 3234[NAT-APPL]简单地强调了这个问题。

在这篇文章中,我们总结了当前熟知的一些方法,使应用程序工作在NAT设备环境中而不需要直接改变NAT设备。

2. Terminology and Conventions Used

2.1 端点(Endpoint)

一个Endpint是指在一台终端主机上的和会话相关的元组。对于每一种IP协议,endpoint的表示方式也可能不一样。比如,UDP或者TCP会话的endpoint是以(IP地址,UDP/TCP端口号)这种元组的形式表示的。

2.2 端点映射(Endpoint Mapping)
当私有网络中的主机通过NAT设备向公有网络发起一个outgoing的会话,NAT设备分配一个公有的endpoint来转换私有endpoint,于是随后来自外部主机的响应包就可以被NAT接收,转换,并且转发到私有endpoint去。NAT设备将私有endpoint转换成公有endpoint,或者反过来的这种分配就称为是端点映射。NAT使用端点映射在会话的过程中执行转换。

2.3 端点独立映射(Endpoint-Independent Mapping)

“端点独立映射”在 [BEH-UDP]中像下面这样定义:

对于从一个相同的内部IP地址和端口(X:x)发往任何外部IP地址和端口的随后的数据包,NAT会重用端口映射。

2.4 端点依赖性映射(Endpoint-Dependent Mapping)

“端点依赖性映射”是指“地址依赖性映射”和“地址和端口依赖性映射”的组合,在[BEH-UDP]中定义。

地址依赖性映射: (Address-Dependent Mapping)

对于接下来要从同一个内部IP地址和端口(X:x)发往同一个外部地址,不管外部端口号是多少,NAT都会重用端口映射。

地址和端口依赖性映射:(Address and Port-Dependent Mapping)

对于接下来要从同一个同部IP地址和端口(X:x)发往同一个外部地址和端口号的数据包,当映射依然处于活动状态的时候,NAT设备会重用端口映射。

2.5 端点独立的过滤(Endpoint-Independent Filtering)

“端点独立的过滤”在[BEH-UDP]中像下面这样定义:

不管外部IP地址和端口号(Z:z)是多少,NAT都只过滤不发往内部IP地址和端口号(X:x)的数据包。NAT会转发所有发往X:x的数据包。换句话说,从NAT内侧发信外部IP地址的数据包足以让任何数据包发回到内部endpoint。

使用“端点独立映射”和“端点独立过滤”的NAT设备可以接受任何位于公有网络的外部endpoint上的已经映射的端口的incoming流量。

2.6 端点依赖性过滤(Endpoint-Dependent Filtering)

“端点依赖性过滤”是指“地址依赖性过滤”和“地址和端口依赖性过滤”的组合,在[BEH-UDP]中定义

地址依赖性过滤(Address-Dependent Filtering)

NAT过滤不发往内部地址X:x的数据包。并且,如果X:x之前没有发送任何数据包给Y(和Y使用的端口无关),那么NAT设备就会过滤从Y:y发往X:x的数据包。换句话说,要从一个特定的外部endpoint接收数据包,内部endpoint必须先向特定的外部endpoint发送过数据包。

地址和端口依赖性过滤(Address and Port-Dependent Filtering)

NAT过滤不发往内部地址X:x的数据包。并且,如果X:x之前没有发送任何数据包给Y:y,那么NAT设备就会过滤从Y:y发往X:x的数据包。换句话说,要从一个特定的外部endpoint接收数据包,内部endpoint必须先向特定的外部endpoint发送过数据包。

(译者注:区别就仅仅是和外部端点的端口有没有关系)

使用“端点依赖性过滤”的NAT设备只会接受有限的一组公网外部端点的发往一个已经映射的公有端口的incoming流量。(这句话我认为我翻译的很失败,附上原文)

A NAT device employing “Endpoint-Dependent Filtering” will accept
incoming traffic to a mapped public port from only a restricted set
of external endpoints on the public network.

2.7 P2P程序(P2P Application)

P2P程序是指使用同一个endpoint向peer主机发起outgoing会话,并且接收来自peer主机的incoming会话的应用程序。P2P程序可以使用多endpoint进行p2p通信。

2.8 NAT友好的P2P应用程序(NAT-Friendly P2P Application)

NAT友好的P2P程序是指,即使peer节点处于由一个或者多个NAT连接不同的IP地址域中,也能够有效运行的p2p程序。

一种P2P程序建立peer会话并保持NAT友好的通用方法是使用一台公网可寻址的公共服务器用做注册和peer发现。

2.9 端点独立映射NAT(EIM-NAT)

EIM-NAT是使用端点独立映射的NAT设备。EMI-NAT可以有任何过滤行为。BEHAVE-comiliant NAT设备设备就是EIM-NAT设备的很好的例子。使用地址依赖映射的NAT设备就是非EIM-NAT设备的例子。

2.10 发夹(Hairpinning)

发夹在[BEH-UDP]中像下面这样定义:

如果两台主机(称为X1和X2)在同一个NAT设备之后,并且交换流量,NAT可以在它外面为X2分配地址,称为X2′x2′,如果X1向X2′x2′发送流量,它就会到达NAT,然后NAT再将流量从X1传递到X1,这被称为是发夹。

并不是目前所有的NAT设备都支持发夹。

3.P2P程序穿越NAT设备所用到的技术。

这一小节从软件设备者或协议设计者的角度上,详细地回顾了在现有的NAT设备基础上实现p2p通信的一些熟知的技术。

3.1中继

在存在NAT设备的环境中实现p2p通信,最可靠但效率最低的方法是让p2p通过中继,使用看上去像client/server的方式进行通信。考虑图表1中的场景。两个客户端主机,A和B,都向一台公共服务器ServerS发起了TCP或UDP连接,这台公共服务器有一个公网可寻址的IP地址,用来进行注册,发现和中继。NAT设备后面的主机向这台服务器注册。Peer可以发现NAT设备后面的主机,并且使用这台服务器中继端对端的消息。客户端驻留在各自的私有网络里面,它们各自的NAT设备阻止它们直接同对方发起连接。

Registry, Discovery
Combined with Relay
Server S
192.0.2.128:20001
|
+—————————-+—————————-+
| ^ Registry/              ^   ^ Registry/              ^ |
| | Relay-Req Session(A-S) |   | Relay-Req Session(B-S) | |
| | 192.0.2.128:20001      |   |  192.0.2.128:20001     | |
| | 192.0.2.1:62000        |   |  192.0.2.254:31000     | |
|                                                         |
+————–+                                 +————–+
| 192.0.2.1    |                                 | 192.0.2.254  |
|              |                                 |              |
|    NAT A     |                                 |    NAT B     |
+————–+                                 +————–+
|                                                         |
| ^ Registry/              ^   ^ Registry/              ^ |
| | Relay-Req Session(A-S) |   | Relay-Req Session(B-S) | |
| |  192.0.2.128:20001     |   |  192.0.2.128:20001     | |
| |     10.0.0.1:1234      |   |     10.1.1.3:1234      | |
|                                                         |
Client A                                                 Client B
10.0.0.1:1234                                        10.1.1.3:1234

Figure 1: Use of a Relay Server to communicate with peers

两个客户端可以简单地通过Server S在他们之间传递消息,而不是试图进行直接连接。举个例子,要发送一条消息给Client B,Client A只需要在同Server S建立了cs连接之后将消息发给Server S即可,Server S可以通过它和Client B之间已经建立的cs连接将消息转发给Client B。

这种方法的优点在于,只要两个Client都能够连接到Server,这种方法就一直有效。中间的NAT设备也不需要是EIM-NAT.这种方法的明显缺点在于,它消息了Server的处理器能力和网络带宽,同时,即使Server拥有足够的I/O带宽并且处于正确良好的拓扑结构中,client之间的通信延迟也会有所增长。The TURN protocol
[TURN] defines a method of implementing application agnostic,
session-oriented, packet relay in a relatively secure fashion.

3.2 连接逆转(Connection Reversal)

接下来要讨论的进行直接通信的连接逆转技术只有在一个peer处在NAT设备的后面,而另一个peer不处在NAT设备后面时才有效。举个例子,考虑图表2中的场景。client A处在NAT设备的后面,而client B拥有一个公网可寻址的IP地址。公共server S有一个公网可寻址的IP地址,它用做注册和发现。处在NAT后面的设备通过server注册他们的endpoint,peer主机通过这台服务器发现处在NAT后面的主机的endpoint。

Registry and Discovery
Registry and Discovery
Server S
192.0.2.128:20001
|
+—————————-+—————————-+
| ^ Registry Session(A-S) ^     ^ Registry Session(B-S) ^ |
| | 192.0.2.128:20001     |     |  192.0.2.128:20001    | |
| | 192.0.2.1:62000       |     |  192.0.2.254:1234     | |
|                                                         |
| ^ P2P Session (A-B)     ^     |  P2P Session (B-A)    | |
| | 192.0.2.254:1234      |     |  192.0.2.1:62000      | |
| | 192.0.2.1:62000       |     v  192.0.2.254:1234     v |
|                                                         |
+————–+                                            |
| 192.0.2.1    |                                            |
|              |                                            |
|    NAT A     |                                            |
+————–+                                            |
|                                                         |
| ^ Registry Session(A-S) ^                               |
| |  192.0.2.128:20001    |                               |
| |     10.0.0.1:1234     |                               |
|                                                         |
| ^ P2P Session (A-B)     ^                               |
| |  192.0.2.254:1234     |                               |
| |     10.0.0.1:1234     |                               |
|                                                         |
Private Client A                                 Public Client B
10.0.0.1:1234                                    192.0.2.254:1234

Figure 2: Connection reversal using Rendezvous server

client A拥有私有地址10.0.0.1,程序使用TCP端口1234. 这台client已经使用公有地址192.0.2.128,端口20001同server S建立了连接。NAT A在它的公有地址192.0.2.1上为client A分配了TCP端口62000作为client A同server S会话的临时公有端点地址。因此,server S认为client A的IP地址为192.0.2.1,使用端口62000。client B有它自己的固定IP地址192.0.2.254,client B上的程序正在监听端口1234以接收TCP连接。

现在假设client B希望同client A直接建立通信会话。B可能首先尝试连接A自己认为拥有的地址,也就是10.0.0.1:1234,或者连接由server S观察到的A的地址,也就是192.0.2.1:62000,在任何情况下连接都会失败。
在第一种情况下,发告往IP地址10.0.0.1的流量会直接被网络丢弃,因为10.0.0.1不是公网可路由的IP地址。在第二种情况下,从B发来的TCP SYN请求包将会到达NAT A并发送到端口62000,但NAT A会拒绝连接请求,因为NAT A只允许outgoint连接。

尝试和A直接建立连接并失败后,B可以使用server S传递一个请求给client A立一个到client B的“逆转”连接。client A在通过S收到这个中继请求后,向client B的公网地址和端口创建一个TCP连接,NAT A允许这个连接通过,因为这个连接是在防火墙的内部发起的,而client B也可以接收到这个连接,因为它不处在NAT设备的后面。

当前各种p2p程序都实现了这种技术。当然,它的最大的限制在于,它仅仅工作在只有一个通信的peer处在NAT设备后面。如果NAT设备是EIM-NAT,public client就可以联系外部的server S来确定可以接收从client A发起连接并允许这些连接的特定端点。如果NAT设备不是EIM-NAT,public client就不能确定哪些端点可以接收由client A发起的连接。在两个端点都处于NAT后面的情况越来越普遍的情况下,这种连接逆转的技术就会失败。连接逆转不是这种p2p连接问题的通用解决办法。如果前向连接和逆转连接都不能建立,程序只能退回去使用其它的机制,比如中继。

3.3 UDP 打孔技术

UDP打孔技术依赖于此EIM-NAT的一些属性,从而允许设计恰当的p2p程序在NAT设备上“打孔”来建立彼此之间的直接连接,即使两个客户端同时处于NAT设备之后也可以。当一台主机不是处在EIM-NAT之后时,peering主机不能获知要给哪个已经映射的endpoint发起连接。进一步说,处在非EIM-NAT设备后面的主机上的程序不能够使用已经建立的endpoint映射和外部目的地址进行通信,打孔技术将会失败。

我们将会考虑两个特定的场景,看看如何设计程序来优雅地处理这两种情况。第一个场景代表最常见的情况,处在两个不同的NAT设备后面的两个client希望直接建立p2p通信。第二种情况下,两个客户端事实上处在同一个NAT后面,但它们自己并不知道。

3.3.1 处在不同的NAT设备之后的Peers

看一下图表3中的场景。client A和client B都拥有私有地址,并且处在不同的NAT设备后面。公共服务器S拥有公网可路由的IP地址,用做注册,发现和有限制的中继。NAT后面的主机在这个Server上注册他们的公共endpoint。peer主机使用这台服务器来发现NAT后面主机的公共endpoint。不像3.1中描述的那样,peer主机只使用server传递连接初始控制信息,而是发送end-to-end信息。

运行在client A和client B上的p2p程序使用UDP端口1234.公共server使用UDP端口20001。A和B各自向server S创建了通信会话,从而分别使NAT A为clinet A和 serverA的会话分配了它的公共UDP端口62000,NAT B为clinet B和server S的会话分配了它的公共端口31000。

Registry and Discovery Combined
with Limited Relay
Server S
192.0.2.128:20001
|
+—————————-+—————————-+
| ^ Registry Session(A-S) ^     ^ Registry Session(B-S) ^ |
| | 192.0.2.128:20001     |     |  192.0.2.128:20001    | |
| | 192.0.2.1:62000       |     |  192.0.2.254:31000    | |
|                                                         |
| ^ P2P Session (A-B)     ^     ^  P2P Session (B-A)    ^ |
| | 192.0.2.254:31000     |     |  192.0.2.1:62000      | |
| | 192.0.2.1:62000       |     |  192.0.2.254:31000    | |
|                                                         |
+————–+                                 +————–+
| 192.0.2.1    |                                 | 192.0.2.254  |
|              |                                 |              |
| EIM-NAT A    |                                 | EIM-NAT B    |
+————–+                                 +————–+
|                                                         |
| ^ Registry Session(A-S) ^     ^ Registry Session(B-S) ^ |
| |  192.0.2.128:20001    |     |  192.0.2.128:20001    | |
| |     10.0.0.1:1234     |     |     10.1.1.3:1234     | |
|                                                         |
| ^ P2P Session (A-B)     ^     ^  P2P Session (B-A)    ^ |
| |  192.0.2.254:31000    |     |  192.0.2.1:62000      | |
| |     10.0.0.1:1234     |     |     10.1.1.3:1234     | |
|                                                         |
Client A                                                 Client B
10.0.0.1:1234                                        10.1.1.3:1234

Figure 3: UDP Hole Punching to set up direct connectivity

现在假设client A想和client B直接建立UDP通信会话。如果A简单地发送一个UDP消息到B的公共endpoint 192.0.2.254:31000,B通常会丢弃这些incoming消息(除非它使用了端点独立(Endpoint-Indepentent)的过滤),因为这条消息的源地址和端口号不和S的匹配,而outgoing会话是和S建立的。同样,B简单地发送一个UDP消息到A的公共endpoint,那么NAT A也通常会丢弃这些消息。

假设A开始发送UDP消息到B的公共endpoint,同时通过server S向B传递请求,让B也发送UDP消息到A的公共endpoint。A直接发往B的公共endpoint(192.0.2.254:31000)的outgoint消息将会导致EIM-NAT A在A的私有endpoint和B的公共endpoint之间打开一个新的通信会话。同时,B发往A的公共endpoint(192.0.2.1:62000)的消息会导致EMI-NAT B在B的私有endpoint和B的公共endpoint之音打开一个新的通信会话。一旦在每个方向上都打开了UDP会话之后,client A和client B就可以直接和彼此进行通信,而不会进一步带来server S的负担。帮助NAT后面的peer节点传递连接初始化请求的server S最后被称为peer主机的“中介”节点。

UDP打孔技术有很多有用的属性。一旦两个处在NAT设备之后的peer主机建立了直接的p2p-udp连接,连接的每一部分都可以反过来充当“中介”来帮助其它的部分和其它的peer建立p2p连接,减少了初始中介服务器S的负载。既然上面的过程在即使任一个或者两个client恰好都不处于NAT设备之后的情况下,一样可以很好创建p2p的通信连接,那么程序也就不需要检测它前面的NAT设备的类型。UDP打孔技术甚至在多个NAT的情况下也可以自动地工作,这种情况下一个或者两个客户端在到达公有Internet网络时经过了两级或者更多的地址转换。

3.3.2 处于同一个NAT设备后面的peers

现在考虑这样一个场景:两个客户恰巧处于同一个EMI-NAT后面,因此也处于同一个私有地址空间中,像图表4描述的那样。公共服务器S拥有公网可路由的IP地址用于注册,发现和有限制地中继。处在NAT后面的主机向服务器注册。peer主机通过服务器发现NAT后面的主机,并且通过服务器传递消息。不像3.1节描述的那样,peer主机只传递控制信息,而是传递所有的点对点的消息。

client A已经和server S建立了UDP会话,EIM-NAT也已经给client A分配了公共端口号62000。同样client B也同server S建立了UDP会话,EIM-NAT也为它分配了公共端口号62001。

Registry and Discovery Combined
with Limited Relay
Server S
192.0.2.128:20001
|
^ Registry Session(A-S) ^  | ^ Registry Session(B-S) ^
| 192.0.2.128:20001     |  | |  192.0.2.128:20001    |
| 192.0.2.1:62000       |  | |  192.0.2.1:62001      |
|
+————–+
| 192.0.2.1    |
|              |
|   EIM-NAT    |
+————–+
|
+—————————–+—————————-+
| ^ Registry Session(A-S) ^      ^ Registry Session(B-S) ^ |
| |  192.0.2.128:20001    |      |  192.0.2.128:20001    | |
| |     10.0.0.1:1234     |      |     10.1.1.3:1234     | |
|                                                          |
| ^ P2P Session-try1(A-B) ^      ^ P2P Session-try1(B-A) ^ |
| | 192.0.2.1:62001       |      |  192.0.2.1:62000      | |
| |     10.0.0.1:1234     |      |     10.1.1.3:1234     | |
|                                                          |
| ^ P2P Session-try2(A-B) ^      ^ P2P Session-try2(B-A) ^ |
| |     10.1.1.3:1234     |      |     10.0.0.1:1234     | |
| |     10.0.0.1:1234     |      |     10.1.1.3:1234     | |
|                                                          |
Client A                                                   Client B
10.0.0.1:1234                                         10.1.1.3:1234

Figure 4: Use of local and public endpoints to communicate with peers

假设A和B使用上面所描述的UDP打孔技术通过server S做为中介建立一个通信信道,然后A和B将会获取对方由server S所观察到的公共endpoint。只要NAT设备允许内网主机同其它的内网主机建立转换的UDP会话,而不仅仅是和外部主机建立,两台client就能够彼此之间进行通信。这种情况被称为“Hairpinning”,因为数据包从私有网络到达NAT之后被转换然后又环回到私有网络,而不是转发到公有网络。

举个例子,考虑上面的p2p session-try1。当A发送一个UDP包到B的公有endpoint时,这个包最初拥有源endpoint(10.0.0.1:1234)和目的endpoint(192.0.2.1:2001)。NAT收到这个包然后把它的源endpoint转换成192.0.2.1:62000,目的endpoint转换成10.1.1..3:1234,然后转发给B.

即便NAT设备支持hairpinning,在这种情况下转换和转发的步骤明显是没有必要的,并且增加了A和B之间会话的延迟,同时也增加了NAT的负载。解决的办法是直接转发。下面将会介绍:

当A和B最初通过公共服务器S交换地址信息的时候,他们包含他们自己所看到的IP地址和端口号,以及服务器S所看到的他们的公网IP地址和端口号。客户端同步地向它们知道的对方的任一个地址发送数据包,并且使用第一个连接成功的第址。如果两个客户端处理同一个NAT之后,那么直接发送到他们私有endpoint的数据包很可能先到达,从而实现了不需要NAT参与的直接通信信道。如果两个客户端处在不同的NAT后面,那么直接发往它们私有endpoint的数据包将不会到达,但可客户端将会按我们所期望的使用他们各自的公有endpoint建立连接。既然在不同的NAT的情况下,A发往B的私有endpoint的消息很有可能到达A的私有网络上的不相关的节点,反过来也也一样,那么这些对数据包在某种方式上经过认证是很重要的。

The [ICE] protocol employs this technique effectively, in that
multiple candidate endpoints (both private and public) are
communicated between peering end hosts during an offer/answer
exchange.  Endpoints that offer the most efficient end-to-end
connection(s) are selected eventually for end-to-end data transfer.

3.3.3 被多个NAT分隔的peers

在某些包含NAT设备的拓扑中,在对拓扑结构没有确定的了解的情况下,很难在两个客户端之间建立“最佳的”p2p路由。举个例子,考虑一下图表5中的场景:

Registry and Discovery Combined
with Limited Relay
Server S
192.0.2.128:20001
|
^ Registry Session(A-S) ^ | ^ Registry Session(B-S) ^
| 192.0.2.128:20001     | | | 192.0.2.128:20001     |
| 192.0.2.1:62000       | | | 192.0.2.1:62001       |
|
+————–+
| 192.0.2.1    |
|              |
|  EIM-NAT X   |
| (Supporting  |
| Hairpinning) |
+————–+
|
+—————————-+—————————-+
| ^ Registry Session(A-S) ^     ^ Registry Session(B-S) ^ |
| |  192.0.2.128:20001    |     |  192.0.2.128:20001    | |
| |  192.168.1.1:30000    |     |  192.168.1.2:31000    | |
|                                                         |
| ^ P2P Session (A-B)     ^     ^ P2P Session (B-A)     ^ |
| |  192.0.2.1:62001      |     |  192.0.2.1:62000      | |
| |  192.168.1.1:30000    |     |  192.168.1.2:31000    | |
|                                                         |
+————–+                                  +————–+
| 192.168.1.1  |                                  | 192.168.1.2  |
|              |                                  |              |
| EIM-NAT A    |                                  | EIM-NAT B    |
+————–+                                  +————–+
|                                                        |
| ^ Registry Session(A-S) ^    ^ Registry Session(B-S) ^ |
| |  192.0.2.128:20001    |    |  192.0.2.128:20001    | |
| |     10.0.0.1:1234     |    |     10.1.1.3:1234     | |
|                                                        |
| ^ P2P Session (A-B)     ^    ^  P2P Session (B-A)    ^ |
| |  192.0.2.1:62001      |    |  192.0.2.1:62000      | |
| |     10.0.0.1:1234     |    |     10.1.1.3:1234     | |
|                                                        |
Client A                                                  Client B
10.0.0.1:1234                                        10.1.1.3:1234

Figure 5: Use of Hairpinning in setting up direct communication

假设NAT X是由大型的ISP部署的一个EMI-NAT,用来将大量的用户复用到少数的公有IP地址上,NAT A和NAT B是两个ISP的用户独立部署的小型的用户NAT网关,用来将它们的私有家庭网络复用到他们分别由ISP提供的IP地址上。NAT A和NAT B所使用的”公有”IP地址对于ISP的地址域来说是私有的,而相应的,客户端A和客户端B的地址对于NAT A和NAT B来说也是私有的。像上一节一样,服务器S也是用来注册,发现和有限制的中继的。peer主机使用服务器传递连接初始化控制信息,而不是所有的点到点信息。

现在假设client A和client B要建立直接的p2p-udp连接。最佳方法是client A发送信息到client B在NAT B处的公有地址,这个地址在ISP的地址池中是192.168.1.2:31000,client B发送信息到client A在NAT A处的公有地址,也就是192.168.1.2:31000。不幸的是,A和B没有办法获取这些地址,因为server S只能观察到客户端的公有地址,192.0.2.1:62000和192.0.2.1:62001。即使A和B通过某种方式获取了这些地址,也不能保证这些地址一定可用,因为这些在ISP的私有地址池中分配的地址很有可能和客户端的私有地址池中分配的不相关的地址冲突。客户端因此别无选择只能使用它们由S观察到的全局公有endpoint来进行p2p通信,并且依赖NAT X来提供hairpinning。

3.4 TCP打孔技术

在这一节里,我们将会讨论“TCP打孔”技术,用来在一对都处在NAT设备后面的节点之间建立直接的TCP连接。和UDP打洞打孔技术一样,TCP打孔技术也依赖于EIM-NAT的一些属性,允许设计恰当的p2p程序通过在NAT设备上打孔来建立彼此之间的直接连接,即使通信的主机都处在NAT设备之后也一样奏效。这种技术有时也被叫做“TCP同时打开”。

很多TCP会话都以一个endpoint发送SYN数据包,接收方回复一个SYN-ACK数据包开始。然而两个endpoint同时打开TCP会话也是可以的,即同时发给对方一个SYN数据包,然后每一方收到后回复一个ACK数据包。这个过程被称为“TCP同时打开”。然而在很多系统上包括一些NAT设备上,“TCP同时打开”技术并不能正确的执行。如果一个NAT设备从私有网络外部收到一个试图创建incoming TCP连接的TCP SYN数据包,NAT设备通常丢弃这个SYN数据包,或者回复一个TCP RST数据包,以此来拒绝连接。在SYN超时或者连接重置的情况下,程序endpoint就会继续重新发送SYN包,直到对端peer和它做同样的事情。

我们考虑NAT设备支持“TCP同时打开”会话的情况。如果NAT设备认为一个SYN数据包的源endpoint和目的endpoint是和一个活动的TCP连接相关的,那么这个SYN数据包到达NAT设备的时候,NAT设备就会允许这个包通过。特别的,如果一个NAT设备已经看到并且使用相同的地址和端口号转换了一个outgoint连接,它就会认为这个会话是活动的,并且允许incoming SYN包通过。如果client A和client B各自向发起的outgoing TCP连接中另一方超时了,那么每个客户端的SYN包在到达对方的NAT设备之前都通过了自己本地的NAT设备,那么一个TCP连接就会建立起来。

出于下面的原因,这种技术可能并不会一直有效。如果任何一个SYN包到达远程NAT太快(在这个peer节点有机会发送SYN包之前),那么远程NAT设备可能会丢弃这个SYN包或者发送一个RST包来拒绝这个SYN包。这会导致本地NAT设备立即结束这个新的NAT会话,或者开始会话结束的超时等待,在超时结束的时候结束这个会话。即使peer节点继续同步地发起SYN重传尝试,如果一个NAT会话处于一种(end-of-session timeout)会话结束超时状态,一些远程的NAT主机可能允许incoming SYN通过,这就有可能会阻止TCP连接的建立。

事实上,大多数的NAT设备(超过50%)都支持端点独立(Endpoint-Indenptent)映射,并不会发送ICMP错误或者RST包来响应未经同意的incoming SYN包。结果是,对于大多数的TCP连接尝试,TCP同时打开技术在穿越NAT设备的时候并不会生效。

3.5 UDP端口预测

在一些支持端点独立映射的NAT存在的情况下,仍然有一种UDP打孔技术的变种可以用来创建p2p-UDP连接。这种方法有时候被称为”N+1″技术[BIDIR],Takeda[SYM-STUN]详细讨论了这种方法。这种方法分析NAT的行为并尝试预测在将来的会话中它将给分配的公网端口号。分配的公有端口号一般是可预测的,因为大多数的NAT映射端口的分配是按顺序来的。

考虑图表6中的避场景。两个客户端A和B,每一个都处于一个单独的NAT后面,并且同公共服务器S建立了单独的UDP连接。公共服务器有公网可寻址的IP地址,用来注册和发现。NAT后面的主机在服务器上注册他们的endpoint。peer主机通过这台服务器发现NAT后面主机的endpoint。

Registry and Discovery
Server S
192.0.2.128:20001
|
|
+—————————-+—————————-+
| ^ Registry Session(A-S) ^     ^ Registry Session(B-S) ^ |
| | 192.0.2.128:20001     |     |  192.0.2.128:20001    | |
| | 192.0.2.1:62000       |     |  192.0.2.254:31000    | |
|                                                         |
| ^ P2P Session (A-B)     ^     ^  P2P Session (B-A)    ^ |
| | 192.0.2.254:31001     |     |  192.0.2.1:62001      | |
| | 192.0.2.1:62001       |     |  192.0.2.254:31001    | |
|                                                         |
+———————+                       +——————–+
| 192.0.2.1           |                       |        192.0.2.254 |
|                     |                       |                    |
|    NAT A            |                       |        NAT B       |
| (Endpoint-Dependent |                       | (Endpoint-Dependent|
|  Mapping)           |                       |  Mapping)          |
+———————+                       +——————–+
|                                                         |
| ^ Registry Session(A-S) ^     ^ Registry Session(B-S) ^ |
| |  192.0.2.128:20001    |     |  192.0.2.128:20001    | |
| |     10.0.0.1:1234     |     |     10.1.1.3:1234     | |
|                                                         |
| ^ P2P Session (A-B)     ^     ^ P2P Session (B-A)     ^ |
| |  192.0.2.254:31001    |     |  192.0.2.1:62001      | |
| |     10.0.0.1:1234     |     |     10.1.1.3:1234     | |
|                                                         |
Client A                                                 Client B
10.0.0.1:1234                                        10.1.1.3:1234

Figure 6: UDP Port Prediction to set up direct connectivity

NAT A为A和S的通信会话分配了它的UDP端口62000,NAT B为B和S之间的会话分配了它的端口31000。通过和服务器S通信,A和B获取了对方由服务器S观察到的公有endpoint。client A现在向地址192.0.2.254的端口31001发送UDP消息(注意端口的增长),client B同时开始向地址192.0.2.1的端口62001发送UDP消息。如果NAT A和NAT B分新的会话连续地分端口,并且如果A-S和B-S的会话建立之后并没有经过太长时间,那么A和B之间的就会建立起来双向的可用通信信道。A发给B的消息将会导致NAT A打开一个新的会话,NAT A将会按我的期望地分配端口号62001,因为62001是它刚才分配给A和S的端口的下一个端口。同样,B发送给A的消息也会导致NAT B打开一个新的会话,NAT B为这个新的会话会配了端口号31001。如果两个客户端都正确地猜到了它们各自对应的NAT分配给新会话的端口号,那么一个双向的UDP通信信道就可以建立起来了。

很明显,很多因素会导致这种策略失败。如果任何一个NAT预测到的端口恰好已经被分配给了一个不相关的会话,那么NAT就会略过这个端口号,那么连接尝试就会失败。如果任何一个NAT有时候或者一直都不是按顺序地分配端口号,那么这种策略也会失败。如果NAT A后面的另一个客户端(不是A)在A同服务器S建立连接之后,并且还发送第一条消息给B之前,打开一个新的outgoing UDP会话到任何外部目的地,那么这个不相关的客户端就会不经意地“偷走”这个我们需要的端口号。因此这种方法在任何一个相关的NAT过载的情况下都可能不会生效。

既然实现了这种策略的程序即使有一个NAT使用了端点独立的映射时仍然需要能够运行,程序就需要预先知道每一个端点所涉及的NAT都是什么类型,并且相应地修改它们的行为,这就提高了算法的复杂度,同时也增大了网络的弱性。最后,端口预测方法在每一个客户端都处在两级或者更多级的NAT之后很少能够工作。

3.6 TCP端口预测

有一种“TCP打孔技术”的变种可以创建穿越依赖性映射NAT的p2p-TCP直接会话。不幸的是,这种方法相比如刚才介绍的UDP端口映射方法相比更加脆弱,对时间更加敏感。首先,预测到的一个NAT可能分配的端口可能是错的。其实,如果任何一个客户端的SYN包到达对方的NAT设备太快,那么远程NAT设备就可能丢掉SYN包或者发送一个RST包,导致本地的NAT设备转而关闭新的会话,接着徒劳地使用相同的端口继续进行失败的SYN重传。

4. Recent Work on NAT Traversal

5. Summary of Observations

5.1  TCP/UDP打孔(TCP/UDP Hole Punching)

TCP/UDP打孔看上去是现存的在两个都处于NAT后面的节点之间建立基于TCP/UDP的p2p通信的最有效方法。在很多种现存的NAT上都广泛使用了这项技术。然而程序在直接通信不能建立的情况下需要随时准备好回归到最简单的中继方式进行通信。

使用TCP/UDP打孔技术有一点需要注册,它只对转换NAT是EIM-NAT的情况生效。当参与路由的NAT设备不是EIM-NAT的时候,程序就不能够重用已经建立的端点映射和不同的外部目的地进行通信,这种技术就会失败。然而现在在Internet中部署的NAT大多数都是EIM-NAT,那就保证了TCP/UDP打孔技术广泛适用。不管怎样,仍然有大量的部署的NAT使用端点依赖性映射,但却不支持TCP/UDP打孔。

5.2 使用端点依赖性映射的NAT(NATs Employing Endpoint-Dependent Mapping)

端点依赖性映射的NAT对于像web浏览器这种cs程序来说并不是问题,它只需要创建outgoing连接。然而在近期像IM和VOIP这样的p2p程序现在广泛应用。使用端点依赖性映射的NAT对p2p程序的一些技术并不适用,像TCP/UDP打孔技术在穿越这些NAT设备的时候就会失败。

5.3 peer发现

应用程序peer可以出现在同一个NAT域内,也可以处于不同的NAT域内。为了让所有的peer发现程序的endpoint,一个程序可以选择把它自己的私有endpoint和公有endpoint到一台公共服务器上注册。

5.4 发夹(Hairpinning)

支持发夹技术有很大的好处,它可以允许EIM-NAT后面的主机和同一个NAT设备之后的其它主机通过它们的公有的,可能被转换的endpoints进行通信。支持发夹技术在多级NAT的场景中第一次部署了大容量的NAT的情况是非常有用的。像3.3.3节描述的那样。处在相同第一级NAT但不同第二级NAT后面的主机无法使用TCP/UDP打孔技术和彼此进行通信。

分类: Protocol 标签: , ,

UDP并发服务器的设计

四月 7th, {2010 没有评论 10,502 人阅读过  

任务目的:设计一个代理服务器,客户端与该服务器使用承载在UDP之上的SIP信令进行交互,SIP消息体中包裹着FTP信令,由代理服务器代为进行FTP业务连接,将返回数据同样再用SIP信令封装返回组客户端。

客户端发送INVITE信令到服务器,服务器收到INVITE后新建FTP连接,并将FTP 连接socket和SIP dialogId做为一个结构体推入队列,相当于会话ID与FTP连接绑定,来自该会话的信令经过解析后都会通过对应的socket发送到FTP服务器,取回数据后再通过该dialogId回传给客户端。
暂时想到的解决方案:

方法一是参考TFTP服务器的实现,TFTP服务器收到文件下载信令后,会fork一个子进程(或者创建一个thread,结节就先不管了),子进程来负责从磁盘上读出数据,并新创建一个socket绑定到一个ephemeral端口,通过这个端口将数据发送回客户端,由UDP无需创建conneciton就可以直接接收数据,所以收到的文件数据除了传输层的端源端口号不一样外,对客户端来说别无二致。

每收到一条FTP信令新建一个线程或者fork一个子进程来对信令进行处理,这里的FTP信令是指用承载在UDP之上的由SIP封装过的FTP信令,处理完后成在线程或者子进程内部将封装后的数据回传给客户端经。

优点是实现简单。

缺点是该过程不同于TFTP服务器,业务过程中会有信令频繁进行交互,势必会造成线程的大量创建,无形中浪费了巨大的服务器资源,得不偿失。

另一个方法是在服务器中创建一条事件队列,定义一个结构体:

1
2
3
4
struct resource{
	int dialogId;
	int socketId;
};

创建一条resource链表(散列表最好,考虑实现简单暂时先用链表),服务器主线程进行信令监听,每收到一条信令随即放入事件队列中,继续进行信令监听,主线程所做的工作只是将收到的信令推入队列,这样不会造成主线程的阻塞,另外新建一条处理线程,该线程循环地读取队列中的数据,若为空则不做处理,若不为空则处理队头数据,从resource链表中取出对应的ftp socket,通过此socket从FTP服务器取回数据,封装后通过dialogId发送回客户端。

优点是一方面防止服务器监听线程阻塞,另一方面又避免了方法一中的线程大量创建所造成的资源消耗。

缺点是在子线程内部可能会造成阻塞,当收到大量信令,处理线程来不及处理时,客户端便会一直等待服务器返回数据,造成阻塞。

如何在UDP代理服务器中寻找一中折衷的方法是一个难题。通过UDP代理进行连接TCP连接着实是一个问题!!!理想的办法是在首次收到FTP连接请求时创建一个TCP请求,以后的信令解析后扔给这个线程处理,问题是想直接扔给线程是不可能的,给线程传递数据只能通过创建线程时传递线程执行函数的入口参数。

因此只有通过共享数据的方式来达到目的。

解决方法可以参照上面的全局消息队列的方法,将方法二改进定下,把一条处理线程改为多条处理线程,每个线程都与一个FTP连接一一对应,第一次收到FTP连接相关的信令后创建FTP线程,每收到一条连接的信令便创建一个FTP线程,这些线程都时刻检查队列,如果发现其中有交给自己的信令,便取出来处理,
处理完成后将返回数据封装后直接通过队列消息中的dialogId发送回客户端(由于客户端和代理服务器的协议是UDP,所以不需要建立连接)。其实Linux内核中进程调度也是用了类似的方法,只不过我这个简单的多了。

需要注意的一个问题是线程同步问题 ,Linux内核中进程对共享资源的并发访问需要加锁,这里应该也不例外,主线程对消息队列中有写的权限,FTP子线程对消息队列有读写权限,这样就必然要做好线程同步工作,加锁的过程以后再考虑,今天还有别的事。

分类: Protocol 标签: , ,