VPN-WS源码分析

2017年11月27日 | 分类: 翻墙相关 | 标签: ,
vpn-ws一个开源的,承载协议使用Websocket的VPN实现。代码简单易读,其中对于tuntap/SSL/socket/event的C API使用,有三个平台的版本(Win、Linux和MacOSX[^Windows并未完全实现,不可用]),具有参考价值。

参考

词汇

  • peer,VPN中的节点,对于服务器而言包括自己的tuntap和每个客户端socket
  • tuntap,Linux/Unix中创建的虚拟网卡,读取和写入数据和其它文件设备如socket、file差不多

实现

VPN的实现无非是客户端创建一个虚拟网卡tun/tap,从tun/tap中读取数据包,发送到服务端,服务端写入到它的tun/tap虚拟网卡中,从而让两端像是在同一个局域网内。发送数据无论是走UDP/TCP还是websocket,都只是为了转发虚拟网卡中接收到的数据包(以太包或者ip包)。

客户端与服务端的交互如下:

vpn-ws client <—HTTP/websocket—> Nginx <—uWSGI—> vpn-ws server

VPN-WS的客户端与Nginx Web交互,通过HTTP/HTTPS传送HTTP数据,而后Nginx通过uWSGI接口协议发送至应用服务即VPN-WS服务端。在完成了websocket的upgrade协商后,整个通路变成了websocket数据帧的转发通路。

用Nginx作前端的好处是,可以直接在现有的基于Nginx的服务上添加入口布置这个VPN服务器。客户端有重连机制,*但是只有一条连接到服务端,如果拿来翻墙,可就太弱了*。

服务端实现

创建tun/tap虚拟网卡

因为Linux视一切设备为File,所以其与file fd(文件句柄),与socket fd使用上没有区别。以此创建一个peer放到全局数组vpn_ws_conf.peers里。

tuntap_fd = vpn_ws_tuntap(vpn_ws_conf.tuntap_name);
vpn_ws_peer_create(event_queue, tuntap_fd, vpn_ws_conf.tuntap_mac);

void vpn_ws_peer_create(int queue, vpn_ws_fd client_fd, 
	uint8_t *mac) {

	vpn_ws_event_add_read(queue, client_fd)
	vpn_ws_peer *peer = vpn_ws_calloc(sizeof(vpn_ws_peer));
	peer->fd = client_fd;
	vpn_ws_conf.peers[client_fd] = peer;
	if (mac) {
			memcpy(peer->mac, mac, 6);
			peer->mac_collected = 1;
			//只有创建虚拟网卡时,peer的raw属性才置为1,这个值
			//决定了websocket数据包是直接转发还是还原成原始数据包再转发
			peer->handshake = 1;
			peer->raw = 1;
		}

创建服务端口,以接收客户端连接

server_fd = vpn_ws_bind(vpn_ws_conf.server_addr);
vpn_ws_event_add_read(event_queue, server_fd);

接收新客户端连接并分配一个peer节点

为走web socket而来的客户端创建的peer,其raw属性为0,也就是说从这个peer读取出的数据包非原始包(而是websocket数据帧格式)。而handshake属性也为0,则需要与服务端作协议认证交互后才能让这个peer正常使用,即接收和转发数据包。

int ret = vpn_ws_event_wait(event_queue, events);
for(int i=0;i<ret;i++) {
		int fd = vpn_ws_event_fd(events, i);
		if (fd == server_fd) {
			vpn_ws_peer_accept(event_queue, server_fd);
			continue;
		}
	
		if (vpn_ws_manage_fd(event_queue, fd)) break;
}

服务端添加一个peer到peer数组里:

void vpn_ws_peer_accept(int queue, int fd) {
	int client_fd = accept(fd, (struct sockaddr *) &s_un, &s_len);
	
	vpn_ws_peer *peer = vpn_ws_calloc(sizeof(vpn_ws_peer));
	peer->fd = client_fd;
	if (mac) {
		//只有虚拟网卡才会进这里
		memcpy(peer->mac, mac, 6);
		peer->mac_collected = 1;
		peer->handshake = 1;
		peer->raw = 1;
	}
	vpn_ws_conf.peers[client_fd] = peer;
}

处理每一个peer事件(发送数据或接收数据)

这里只解释读取的代码。读取部分,主要是做三件事:

1, 完成HTTP到websocket的协议升级协商。 
2, 读取websocket的数据帧。忽略掉其中一些ping/pong等无用类型数据。并解出里面的以太网帧格式的数据包
3, 跟据包的目标MAC地址,进行数据包转发。转发的目标peer在已登录了的所有的peer中查找。

1, 如参考文档所示,The WebSocket Handshake是请求web服务进行HTTP->Websocket协议升级的过程(其实跟HTTP->HTTP2的升级协商几乎是一样的)。因为Nginx与server是通过uWSGI交互的,所以这里HTTP请求头通过uWSGI的api解释出来的。

请求头除了标准的升级要求的字段,还有自定义字段HTTP_X_VPN_WS_MAC/HTTP_X_VPN_WS_BRIDGE,用来传送客户端peer的MAC地址和是不是bridge的属性:

#define HTTP_RESPONSE "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: "

int64_t vpn_ws_handshake(int queue, vpn_ws_peer *peer) {
	ssize_t rlen = vpn_ws_uwsgi_parse(peer, &modifier1, &modifier2);
	
	char *ws_mac = vpn_ws_peer_get_var(peer,
		 "HTTP_X_VPN_WS_MAC", 
		 17, &ws_mac_len);
		 
	char *ws_bridge = vpn_ws_peer_get_var(peer,
		 "HTTP_X_VPN_WS_BRIDGE", 
		 20, &ws_bridge_len);
	
	… 
	int ret = vpn_ws_write(
		peer, 
		http_response, 
		sizeof(HTTP_RESPONSE)-1 + ws_accept_len + 4);

}

2, 如果来源peer是raw的(即tuntap/本地虚拟网卡),直接将数据包读取出来就好了。如果这peer是raw的,那么就没有上面的第1步:

	if (peer->raw) {
		data = peer->buf;
		data_len = peer->pos;
		mac = data;
		ws_ret = data_len;
		goto parsed;
	}

不然就是远端的peer,那么还要解码websocket的数据帧格式,得到原始的以太网数据包:

int64_t vpn_ws_websocket_parse(vpn_ws_peer *peer, uint16_t *ws_header) {

	uint8_t byte1 = peer->buf[0];
	uint8_t opcode = byte1 & 0xf;
	uint64_t pktsize = byte2 & 0x7f;
	…
	
		switch(opcode) {
		case 0:
		case 1:
		case 2:
			return needed + pktsize;
		case 8:
			return -1;
		case 9:
		case 10:
			*ws_header = 0;
			return needed + pktsize;
		default:
			return -1;
	}
}

这段代码主要是解释头部格式,找出Payload在websocket数据包中的范围。跟据RFC文档,websocket数据帧里的opcode取值如下,注意binary frame和ping/pong的处理就行了:

*  %x0 denotes a continuation frame
*  %x1 denotes a text frame
*  %x2 denotes a binary frame
*  %x3-7 are reserved for further non-control frames
*  %x8 denotes a connection close
*  %x9 denotes a ping
*  %xA denotes a pong
*  %xB-F are reserved for further control frames

综上,第1和2步旨在解释出以太网数据包,代码简要如下:

int vpn_ws_manage_fd(int queue, vpn_ws_fd fd) {
	int ret = vpn_ws_read(peer, 8192);
		
	if (!peer->handshake) {
		int64_t hret = vpn_ws_handshake(queue, peer);
	}
	
	ws_ret = vpn_ws_websocket_parse(peer, &ws_header);
	uint8_t *ws = peer->buf + ws_header;
	uint64_t ws_len = ws_ret - ws_header;
	// 用以整个websocket包进行转发
	data = peer->buf;
	data_len = ws_ret;

3, 转发非多播目标MAC地址的数据包 目标MAC地址是某个peer的MAC地址或者其bridge下的某个MAC:

	if (b_peer->raw && !peer->raw) {
	  //从远程节点的websocket中取数据包写入到虚拟网卡
		wret = vpn_ws_write(
			b_peer, 
			peer->buf+ws_header, 
			ws_ret-ws_header);
	}
	else if (!b_peer->raw && peer->raw) {
	  //从虚拟网卡写入到远程节点websocket
		wret = vpn_ws_write_websocket(
			b_peer, 
			data, 
			data_len);
	}
	else {
		//这里只可能有一种情况即 !b_peer->raw && !peer->raw成立
		//也就是从远程节点转发到另一个远程节点,所以保持整个websocket包进行转发
		wret = vpn_ws_write(b_peer, data, data_len);
	}

客户端实现

1, 创建tun/tap设备,

	vpn_ws_fd tuntap_fd = 
		vpn_ws_tuntap(vpn_ws_conf.tuntap_name);

2, 创建连接至Nginx Web端

int main(){
	vpn_ws_fd tuntap_fd = vpn_ws_tuntap(vpn_ws_conf.tuntap_name);
	vpn_ws_nb(tuntap_fd);
	peer = vpn_ws_calloc(sizeof(vpn_ws_peer));
	memcpy(peer->mac, vpn_ws_conf.tuntap_mac, 6);
	
	if (vpn_ws_connect(peer, vpn_ws_conf.server_addr)) {
			vpn_ws_client_destroy(peer);
			goto reconnect;
	}
	
	…
}

发送HTTP请求进行websocket升级协商。如果使用HTTPS,那么在此前还有SSL的握手:

int vpn_ws_connect(vpn_ws_peer *peer, char *name) {
	if (!strncmp(cpy, "wss://", 6)) {
		ssl = 1;
		port = 443;
	}
	
	struct hostent *he = gethostbyname(domain);
	…
	
	if (connect(peer->fd, 
		(struct sockaddr *) &sin, 
		sizeof(struct sockaddr_in))) {
		
		vpn_ws_error("vpn_ws_connect()/connect()");
		return -1;
	}
	
	int ret = snprintf(buf, 8192, 
	"GET /%s HTTP/1.1\r\nHost: %s%s%s\r\n%sUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: %.*s\r\nX-vpn-ws-MAC: %02x:%02x:%02x:%02x:%02x:%02x%s\r\n\r\n",
	)
	
	if (ssl) {
		vpn_ws_conf.ssl_ctx = vpn_ws_ssl_handshake(
			peer, 
			domain, 
			vpn_ws_conf.ssl_key, 
			vpn_ws_conf.ssl_crt);
		
		if (!vpn_ws_conf.ssl_ctx) {
			return -1;
		}
		if (vpn_ws_ssl_write(vpn_ws_conf.ssl_ctx,
		 (uint8_t *)buf, ret)) {
			return -1;
		}
	}
	
	…

等待websocket升级协商回应:

int http_code = vpn_ws_wait_101(
	peer->fd, 
	vpn_ws_conf.ssl_ctx);

if (http_code != 101) {
		vpn_ws_log("error, 
		websocket handshake returned code: %d\n", 
		http_code);
		
		return -1;
	}

3, 读事件的响应。 每个客户端要监听的是tuntap和peer到服务端的连接socket这两个fd,接收前者的以太网格式数据包封装成websocket包转发到后者,接收后者的websocket数据包解码成以太网数据包后转发到前者。

这里的17秒超时设置是为了超时后会发送一个ping包,即每17秒一个ping包的保证。ping包为\x89\x00,这里websocket的数据帧格式,表示FIN=1,opcode=9(PING类型),HAS_MASK=0,Payload length=0:

for(;;) {

	FD_ZERO(&rset);
	FD_SET(peer->fd, &rset);
	FD_SET(tuntap_fd, &rset);
	tv.tv_sec = 17;
	tv.tv_usec = 0;
	int ret = select(max_fd, &rset, NULL, NULL, &tv);
	
	if (ret == 0) {
		// 超时
		if (vpn_ws_client_write(peer, 
			(uint8_t *) "\x89\x00", 2)) {
				vpn_ws_client_destroy(peer);
				goto reconnect;
		}	
		continue;
	}

	…

处理远端来的websocket数据包,即写入本地的tuntap设备:

	if (FD_ISSET(peer->fd, &rset)) {
		if (vpn_ws_client_read(peer, 8192)) {
			vpn_ws_client_destroy(peer);
			goto reconnect;
		}
		int64_t rlen = vpn_ws_websocket_parse(
			peer, 
			&ws_header);
			
		uint8_t *ws = peer->buf + ws_header;
		uint64_t ws_len = rlen - ws_header;
		if (peer->has_mask) {
			//XOR解密…
		}
		
		vpn_ws_full_write(tuntap_fd, ws, ws_len)
	}			

转发来自tuntap的以太网数据包以websocket格式封装后转发到服务器:

	if (FD_ISSET(tuntap_fd, &rset)) {
		vpn_ws_recv(tuntap_fd, mtu+8, 1500, rlen);
		…
		vpn_ws_client_write(peer, mtu, rlen + 8)
	}

代码不严谨/不妥的地方

C语言直是一门可怕的语言,拿C语言写出大工程更不容易。心疼C语言超弱的表达能力:字符串操作还要自己写,字符串拷贝还要自己写,还要小心什么时候应该释放掉。缺乏面向对象和结构体的权限限制。

本项目代码存在的一些问题:

  • 低效的重分配内存。没有内存池复用,只使用了C的realloc,会有频繁的分配内存和内存拷贝(这里又没有对write_buf的收缩,网络状态不好时只能一直涨大下去)
uint64_t available = peer->write_len - peer->write_pos;
	if (available < amount) {
		peer->write_len += amount;
		void *tmp = realloc(peer->write_buf, 
				peer->write_len);
  • 可能会指针越界的字符串拷贝(几处地方,一下子找不到了)
  • websocket的代码没有封装,客户端和服务端代码都把websocket的组包和解包的逻辑(尤其是XOR解密MASK的数据)全放入发包的地方,可读性差
  • 没有客户端认证机制?看起来是是个客户端就能连上服务端
  • 缺少合理的封装。比如MAC地址及相关方法。比如vpn_ws_peer这个结构太多成员而且很多不相关,可以封装更多子结构。
  • 客户端与服务端的主循环代码太长,还使用了好多goto,每一个IO调用都要有不同返回值表示出了什么情况,也就是0,小于0,大于0的三种路经,整体逻辑复杂。

原文:https://ftwo.me/post/VPN-WS源码分析/
项目:https://github.com/unbit/vpn-ws

目前还没有任何评论.