VPN-WS源码解析


vpn-ws一个开源的,承载协议使用Websocket的VPN实现。代码简单易读,其中对于tuntap/SSL/socket/event的C API使用,有三个平台的版本(Win、Linux和MacOSX1),具有参考价值。


来源:https://medium.com/@FWTO_O/vpn-ws%E6%BA%90%E7%A0%81%E8%A7%A3%E6%9E%90-7f21809ceaa#.dtre36puj

参考

词汇

  • 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的三种路经,整体逻辑复杂。

  1. Windows并未完全实现,不可用 ↩︎

没有评论: