1. 程式人生 > >假期跟我一起寫一個點對點VPN-SimpleVPN詳解

假期跟我一起寫一個點對點VPN-SimpleVPN詳解

自從上週寫了幾篇關於BadVPN的文章後,收到很多的郵件前來詢問細節。其中最多的不外乎兩類,一類是詢問怎麼使用的,另一類則是要求我寫幾篇原始碼分析。先來一個一個說。

1.關於BadVPN的使用問題

和OpenVPN相反,BadVPN幾乎沒有除了配置隧道之外的任何東西,這些被排除了內容中最重要的應該就是路由了。OpenVPN中就有關於路由的很多配置,還可以從服務端往客戶端推送路由,這簡直太方便了,但同時也增加了配置的複雜性。BadVPN我認為是比較好的方式,它本身沒有關於路由的任何配置,只要你把隧道搭建好,路由就找熟悉路由的人配置吧,讓專業的人做專業的事。
       所以關於BadVPN的用法,我不想過多的說,只要你能先把BadVPN跑起來,那麼後面的就不屬於BadVPN的範疇了。如果你不懂IP路由,那麼這是另外一個話題。

2.關於原始碼分析

說實話,我是不喜歡用原始碼分析的方式去熟悉一個技術的。如果你已經理解了原理,何必不自己試著寫一個呢?本文就是針對這一點來寫的。趁著五一假期,花點時間盲寫了一個VPN框架,所謂盲寫就是這個VPN沒有基於任何其它的程式碼修改,都是自己寫的,像我這種程式設計編的不好人,能寫出個這樣的程式碼,我已經很知足了。
       一直存在關於各種SSLVPN與IPSec VPN區別和聯絡的討論,自然,很多人會把OpenVPN這種“使用SSL協議的VPN”叫做SSLVPN,誠然,BadVPN並沒有OpenVPN那麼出名,很多人都不知道,而OpenVPN卻是業內眾所周知的,曾經有一本書叫做《OpenVPN and the SSL VPN Revolution》就是宣傳OpenVPN的,另外,除了OpenVPN,來自日本的SoftEther也曾經大紅一時,作者還被採訪,從其描述中得知,這也是VPN的一次Revolution...不管怎麼樣,OpenVPN也好,SoftEhter也罷,都是針對傳統的IPSec VPN的,革的是IPSec VPN的命,類似這種IPSec VPN由於廠商獨斷,配置複雜,且NAT穿越問題而飽受詬病,所以世界這麼大,總是會有人跳出來提一個全新的方案,這並不足以為奇。而且這些所謂的新方案無一沒有使用SSL協議,這就給人造成了一種假象,這些都是SSLVPN!使用了SSL的VPN就一定是SSLVPN嗎?
       其實根本就不是!SSL只是這種VPN使用的工具集裡一個套件,不用SSL,還是可以用別的方式構建安全的控制通道,比如大多數簡單環境下,直接用DH寫上金鑰我就覺得沒什麼不好。另外,也很少有人直接用SSL記錄協議去封裝資料流,畢竟SSL(統稱,包括TLS,DTLS...)記錄協議太重了,它便於封裝能感知的業務資料比如HTTPS,而不是透明地封裝裸資料。需求決定方法,VPN難道不就只是為了讓資料進行加密傳輸嗎(together with認證)?所以使用SSL只是為了構建一個安全通道而已,在這個安全通道里去協商資料通道的對稱金鑰,如果你理解了這,那麼SSL顯然其分量就沒有那麼重了。
       當然我是站在中立的立場上來說這件事,如果對於偏網路的公司,比如Cisco,華為,他們可能傾向於將SSL作為元件結合到VPN中,而對於安全類公司,比如格爾軟體,他們可能會將SSL立足為根本,但在中立者看來,討論這個問題毫無意義,取向取決於公司的基礎投資,這永遠都是個案問題,沒有普適的答案。
       那麼BadVPN又是什麼?它出自誰之手?這裡有一個連結:
https://news.ycombinator.com/item?id=12433660

       當有人問“Why is it called badvpn?”時,作者如是說:
No particular reason really, I just needed a name that sounded deviant - I was still a teen back then! Over time I added other software to the repo for convenience (tun2socks, NCD). A name change might in fact be due.

       如今,作者已經長大了吧。這個專案並沒有大紅大紫過,可能是沒有資金的支撐,但根本的原因我覺得還是宣傳的不到位,如果宣傳到位了,那麼自然會有資金到來的,但這一切並不妨礙BadVPN的優秀。作為VPN的鼓手,我願意為之宣傳。
------------------------

前言

好久都沒有程式設計了,我從來都覺得自己根本就不會程式設計,心裡有邏輯細節,就是寫不出來。我想寫一個小程式來闡述BadVPN及其它點對點VPN的技術原理,但我怕自己寫出來的東西是垃圾,所以就一直都沒有敢寫,我想深入學習一下設計模式,學習一下Java的最新特性,然後再寫...但是那恐怕會很久,也許就再也沒有機會寫了。
       本著我的基本原則,不管好不好,先跑起來再說。趁著五一假期有點時間,我決定寫一個點對點的VPN框架出來,一方面是為了給大家解釋BadVPN的程式碼構成,另一方面是為了自己練習一下程式設計。我雖然不會程式設計,但也不是一點也不會,我稍微會一點。
       本文中的VPN程式碼我儘量做到簡單再簡單,但這並不妨礙理解其技術本質,我們知道,github上有一個
simpletun(https://github.com/gregnietsky/simpletun)
,這絕對是一個麻雀雖小,我髒俱全的程式碼,它對我們理解tun網絡卡十分有幫助。為了便於此後行文,仿照simpletun,我把本文中要寫的VPN叫做SimpleVPN。

結構總覽

SimpleVPN是一個點對點VPN框架,它非常類似於一種聊天軟體的設計,所有的VPN節點,類似聊天客戶端在啟動的時候先向一箇中心的伺服器去註冊,然後伺服器收到註冊訊息後做兩件事:
1.向其它所有的已註冊節點通知,新節點註冊了;
2.告訴新節點都有哪些節點已經註冊。

經常聊QQ的再也熟悉不過這個場景了,如果我們剛剛登入QQ,一個線上使用者列表便馬上刷新出來,告訴新登入的自己誰線上,另外如果我們已經線上,那麼一旦有人登入,伴隨著一聲咳嗽,那人就顯示線上了。VPN的伺服器所要做的,無無外乎也就是上面的兩點。
       再看VPN節點的邏輯,其實也不難,就把自己當成QQ客戶端吧,當你登入後,你就可以跟任何好友進行聊天了,你和好友傳送的訊息不需要經過中間伺服器中轉,而是直達你好友機器的。對於VPN節點也一樣,註冊後會收到伺服器推送下來的線上VPN節點的列表,每一個表項都包括足夠豐富的資訊,比如IP地址,埠,金鑰協商引數什麼的,只要VPN節點儲存下來這些,那麼就可以直接和感興趣的節點進行直接VPN通訊了。
       在節點已經登入的期間,如果有其它新節點登入,該節點會收到新節點登入的訊息。所以說VPN節點與伺服器的互動中最重要的事只有一件,那就是接收服務端隨時推送的“線上使用者列表”,然後生成或者更新自己本地的鄰居表並維護它。當真的需要通訊的時候,那就相當於你已經知道源和目標,求如何通訊的問題了,Socket程式設計總會吧,如果會的話,用TLS/DTLS也不難吧。
       這就是全部嗎?是的,這就是全部。
       在本文中,我沒有使用任何的複雜通用加密演算法,只是用了普通的低階凱撒加密,即加密時將每一個位元組二進位制加1,解密時每一個位元組二進位制減1,僅此而已。原則上,你將此替換為OpenSSL的EVP系列呼叫實現真正的加解密,也是不難的。詳情參考OpenVPN是怎麼做的。

服務端程式碼

先看資料結構


1.可以代表一個VPN節點的元組

struct tuple {
        char addr[16];    // 建立VPN通道的IP地址,可以向其傳送VPN資料
        unsigned short port;    // 建立VPN通道的埠
        unsigned short id;    // VPN節點的唯一ID號
        unsigned short unused;    // 為了主機內資料對齊,未用,但是卻增加網路的開銷
} __attribute__((packed));


2.服務端回覆的資訊頭部

struct ctrl_header {
        unsigned short sid;    // 固定為0
        unsigned short did;    // 回覆到的VPN節點的ID標識
        unsigned short num;    // 一次通告中,一共包含多少鄰居節點
        unsigned short unused;    // 未使用
} __attribute__((packed));


3.服務端傳送到VPN節點的鄰居表

struct control_frame {
        struct ctrl_header header;
        struct tuple tuple[0];    // 所有的要通告的鄰居節點
} __attribute__((packed));


4.表示一個VPN節點的物件結構體

struct client {
        struct list_head list;    // 所有的VPN節點會連結在一起
        struct tuple tuple;    // 該節點的元組資訊
        int fd;    // 儲存與改VPN節點通話的檔案描述符資訊
        void *others;    // 其它資訊,盡情發揮。但肯定與TLS/DTLS有關
};


5.全域性的配置資訊

struct config {
        int listen_fd;    // 偵聽套接字
        struct list_head clients;    // 儲存所有的已經登入的VPN節點
        unsigned short tot_num;    // 已經登入的VPN節點數目
};


再看服務端處理的原始碼

資料結構都明白了,原始碼自己也可以盲寫,非常簡單。
int client_msg_process(int fd, struct config *conf)
{
        int ret = 0;
        int i = 0;
        size_t len = 0;
        struct client *peer;
        struct sockaddr_in addr;
        char *saddr;
        int port;
        int addr_len = sizeof(struct sockaddr_in);
        struct ctrl_header aheader = {0};
        struct tuple newclient;
        struct tuple *peers;
        struct tuple *peers_base;
        struct list_head *tmp;

        bzero (&addr, sizeof(addr));

        len = recv(fd, &newclient, sizeof(newclient), 0);

        peer = (struct client *)calloc(1, sizeof(struct client));
        if (!peer) {
                return -1;
        }

        memcpy(peer->tuple.addr, &newclient.addr, sizeof(struct tuple));
        peer->tuple.port = newclient.port;
        peer->fd = fd;
        INIT_LIST_HEAD(&peer->list);
        aheader.sid = 0;
        aheader.num = 0;

        peers_base = peers = (struct tuple*)calloc(conf->tot_num, sizeof(struct tuple));
        // 這個ID分配,著實不用這個醜陋的機制,用bitmap是我的最愛,或者雜湊!
        peer->tuple.id = conf->tot_num+1;
        aheader.did = peer->tuple.id;
        conf->tot_num ++;
        // 以下for迴圈有兩個作用:1.將新登入節點知會所有已登入節點;2.蒐集已登入節點資訊,準備知會新登入節點
        list_for_each(tmp, &conf->clients) {
                struct ctrl_header header = {0};
                struct client *tmp_client = list_entry(tmp, struct client, list);
                header.sid = 0;
                header.did = tmp_client->tuple.id;
                newclient.id = aheader.did;
                header.num = 1;
                send(tmp_client->fd, &header, sizeof(struct ctrl_header), 0);
                send(tmp_client->fd, &newclient, sizeof(struct tuple), 0);
                aheader.num += 1;
                memcpy(peers->addr, tmp_client->tuple.addr, 16);
                peers->port = tmp_client->tuple.port;
                peers->id = tmp_client->tuple.id;
                peers++;
        }
        // 傳送給VPN節點頭部訊息
        send(peer->fd, (const void *)&aheader, sizeof(struct ctrl_header), 0);
        if (aheader.num) {
                // 將所有其它已經登入的鄰居通告給新註冊節點
                send(peer->fd, peers_base, aheader.num*sizeof(struct tuple), 0);
        }
        // 將新登入的使用者連結入總的鄰居節點
        list_add_tail(&peer->list, &conf->clients);

        return ret;
}


好了,以上就是服務端程式碼了。當然,我可能遺忘了Linux核心list_head移植部分的講解,也沒有select/poll/epoll的優劣比較,不過我覺得,把這些寫全不利於理解主要問題。我會把本文的全部程式碼編譯可執行後放入github,歡迎直接前往嘲笑拍磚。

VPN節點程式碼

首先看結構體

1.乙太網頭部,不必多說

struct ethernet_header {
    unsigned char dest[6];
    unsigned char source[6];
    unsigned short type;
} __attribute__((packed));


2.可以代表一個VPN節點的元組

struct tuple {
        char addr[16];
        unsigned short port;
        unsigned short id;
        unsigned short unused;
} __attribute__((packed));

3.回覆給VPN節點的資訊頭

struct ctrl_header {
        unsigned short sid;    // 資料來源的源ID號
        unsigned short did;    // 資料來源的目標ID號
        unsigned short num;    // 一共回覆給初創節點多少鄰居數量
        unsigned short unused;
} __attribute__((packed));

4.標識一個鄰居節點

struct node_info {
        struct list_head list;    // 所有鄰居要接在一起
        struct tuple tuple;    // 鄰居的元組資訊
        struct list_head macs;    // 該鄰居虛擬子網一側的Mac地址集合
        void *other;    // 其它的,盡情發揮。估計可以去往TLS/DTLS方面發揮。
};

5.一個實體地址必然要隸屬於VPN節點

struct mac_entry {
        struct list_head list;
        struct list_head node; //struct hlist_node; // 很顯然為了簡單使用list_head,但實際上應該用雜湊或者樹來組織,用於查詢
        char mac[6];    // 儲存MAC地址
        struct node_info *peer;    // 儲存與該MAC地址相關的鄰居
};

6.SimpleVPN的封裝格式

struct frame {
        unsigned short sid;    // 傳送方的ID
        unsigned short did;    // 接收方的ID
        char data[1500];    // 資料。注意,為了簡單,我寫死了MTU為1500
        int len;    // 連同ID頭部,資料的長度
} __attribute__((packed));

注意,frame最前面是兩個16bit的ID頭欄位,為什麼要用到它呢?既然已經知道資料發給誰了,封裝這個ID的意義又何在呢?SimpleVPN只是一個及其簡單的Demo,實際上,為了支援組播,這個ID欄位是必要的,我們把ID看作是組標識也是可以的。

7.控制通道的資料格式

struct control_frame {
        struct ctrl_header header;
        struct tuple tuple[0];    // 服務端通知新接入VPN節點時,可能有多個VPN節點已經接入了,一起打包通知
} __attribute__((packed));

8.處理棧

struct process_handler {
        struct list_head list;    // 所有的處理器模組連線為一個連結串列
        struct node_info *peer;    // 儲存臨時變數,其實用void *型別更好些
        int (*send)(struct process_handler *this, struct frame *frame);    // 從TAP到UDP Socket方向的處理
        int (*receive)(struct process_handler *this, struct frame *frame);    // 從UDP Socket到TAP方向的處理
        struct config *conf;    // 全域性配置
};

這個結構體表現了VPN處理的核心機制。一般而言,當VPN拿到裸資料後,到將其發出前,需要一系列的操作,比如先加密/摘要,然後封裝協議頭,最後傳送,反過來當收到
網路資料後,要執行解除協議頭,解密/驗證摘要等反向的操作,所以我將它們成對組織起來:



9.伺服器標識

struct server {
        char addr[16];
        unsigned short port;
        void *others;    // 顯然這裡可以儲存與構建TLS/DTLS相關的資訊
};

10.全域性配置

struct config {
        struct node_info *self;    // 標識自身
        int tap_fd;    // TAP虛擬網絡卡的描述符
        int udp_fd;    // UDP Socket的描述符
        int ctrl_fd;    // 與伺服器通訊的描述符
        struct server server;    // 伺服器標識
        struct list_head macs;    // 本地所有學習到的MAC地址構建成的查詢樹,然則為了簡單,我先實現成了連結串列
        struct list_head peers;    // 本地所有已知的鄰居構建而成的鄰居表
        struct list_head stack;    // 處理棧
        struct list_head *first;    // 處理棧的棧底
        struct list_head *last;    // 處理棧的棧頂
        int num_handlers;    // 處理器的長度
};

所有以上的結構體之間的關係如下:



SimpleVPN原始碼分析

處理棧的邏輯:


int call_stack(struct config *conf, int dir)
{
        int ret = 0;
        struct process_handler *handler;
        struct frame frame = {0};
        int more = 1;
        struct list_head *begin;
        struct node_info *tmp_peer;

        dir = !!dir;
        // 根據方向引數獲取處理棧頂或者處理棧底
        if (dir) {
                begin = conf->first;
        } else {
                begin = conf->last;
        }
        handler = list_entry(begin, struct process_handler, list);
        tmp_peer = NULL;

        while(handler) {
                handler->peer = tmp_peer;
                
                // 根據方向引數決定呼叫的方向
                if (dir && handler->send) {
                        ret = handler->send(handler, &frame);
                } else if (!dir && handler->receive) {
                        ret = handler->receive(handler, &frame);
                }
                if (ret) {
                        break;
                }
                tmp_peer = handler->peer;

                // 如果遍歷完了處理棧,則退出
                if (dir && handler->list.next == &conf->stack) {
                        break;
                }
                if (!dir && handler->list.prev == &conf->stack) {
                        break;
                }
                
                // 否則,取下一個處理模組
                if (dir) {
                        handler = list_entry(handler->list.next, struct process_handler, list);
                } else {
                        handler = list_entry(handler->list.prev, struct process_handler, list);
                }
        }
        return ret;
}

接下來就很簡單了,定義幾個process_handler結構體以及回撥即可,比如下面的一對:
int read_from_tap(struct process_handler *obj, struct frame *frame)
{
        int ret = 0;
        int fd = obj->conf->tap_fd;
        size_t len;

        len = read(fd, frame->data, sizeof(frame->data));
        frame->len = len;

        return ret;
}

int write_to_tap(struct process_handler *obj, struct frame *frame)
{
        int ret = 0;
        int fd = obj->conf->tap_fd;
        size_t len;

        len = write(fd, frame->data, sizeof(frame->data));

        return ret;
}

static struct process_handler tap_handler = {
        .send = read_from_tap,
        .receive = write_to_tap,
};


程式碼就不解釋了,太簡單。我下面註釋一對比較複雜的handler邏輯。
--------------------------------------------------
當從TAP收到幀之後,要根據其MAC地址找到對應的鄰居,這個查詢過程和交換機的查詢過程非常類似,基本就是下面的邏輯:
int frame_routing(struct process_handler *obj, struct frame *frame)
{
        int ret = 0;
        struct ethernet_header eh;
        struct list_head *tmp;

        memcpy(&eh, frame->data, sizeof(eh));
        list_for_each(tmp, &obj->conf->macs) {
                struct mac_entry *tmp_entry = list_entry(tmp, struct mac_entry, node);
                if (!memcmp(eh.dest, tmp_entry->mac, 6)) {
                        // 找到了明確的鄰居出口。
                        obj->peer = tmp_entry->peer;
                        break;
                }
        }
        // 如果obj->peer為NULL,即沒有明確地鄰居出口,那麼應該對所有節點廣播。
        return ret;
}

如果反過來,資料從Socket收到,那麼就會經歷一個MAC學習的過程,這個和交換機也是類似的:
int mac_learning(struct process_handler *obj, struct frame *frame)
{
        int ret = 0;
        struct mac_entry *entry = NULL;
        struct ethernet_header eh;
        struct list_head *tmp;

        memcpy(&eh, frame->data, sizeof(eh));
        list_for_each(tmp, &obj->conf->macs) {
                struct mac_entry *tmp_entry = list_entry(tmp, struct mac_entry, node);
                if (!memcmp(eh.source, tmp_entry->mac, 6)) {
                        entry = tmp_entry;
                        break;
                }
        }
        // 如果找到了表項,那麼更新它
        if (entry) {
                list_del(&entry->list);
                list_del(&entry->node);
        } else {
                entry = (struct mac_entry *)calloc(1, sizeof(struct mac_entry));
        }

        if (!entry) {
                printf("Alloc entry failed\n");
                return -1;
        }
        // 更新或者新增表項
        memcpy(entry->mac, eh.source, 6);
        entry->peer = obj->peer;
        INIT_LIST_HEAD(&entry->list);
        list_add(&entry->list, &entry->peer->macs);
        INIT_LIST_HEAD(&entry->node);
        list_add(&entry->node, &obj->conf->macs);

        return ret;
}

最後,將上述的邏輯插入到一個處理模組routing_handler裡即可:
static struct process_handler routing_handler = {
        .send = frame_routing,
        .receive = mac_learning,
};

其它的處理就不多說了,如果你想用TLS/DTLS或者用DH演算法協商出來的金鑰進行加密,那麼再寫一個process_handler即可。我接下啦展示一下一個處理模組是如何註冊到系統的,其實也很簡單,就是一堆連結串列操作:
int register_handler(struct process_handler *handler, struct config *conf)
{
        INIT_LIST_HEAD(&handler->list);
        handler->conf = conf;
        list_add_tail(&handler->list, &conf->stack);
        if (conf->first == NULL) {
                conf->first = &handler->list;
        }
        conf->last = &handler->list;
        return 0;
}

最後,我們來看看總體的邏輯,即main函式:
int main(int argc, char **argv)
{
        char serverIP[16];
        char localIP[16];
        unsigned short serverPORT;
        unsigned short localPORT;
        struct config conf;

        if (argc != 5) {
                printf("./a.out serverIP serverPORT localIP localPORT\n");
        }
        strcpy(serverIP, argv[1]);
        serverPORT = atoi(argv[2]);
        strcpy(localIP, argv[3]);
        localPORT = atoi(argv[4]);

        init_config(&conf);
        init_tap(&conf);
        init_self(&conf, localIP, localPORT);

        register_handler(&tap_handler, &conf);
        register_handler(&routing_handler, &conf);
        register_handler(&protocol_handler, &conf);
        register_handler(&enc_handler, &conf);
        register_handler(&udp_handler, &conf);

        init_server_connect(&conf, serverIP, serverPORT); // 連線伺服器

        server_msg_register(&conf);    // 註冊自己
        server_msg_read(&conf); // 讀取伺服器推送的鄰居並建立鄰居表

        main_loop(&conf); // select三個檔案描述符

        return 0;
}

好了,以上就是核心的程式碼分析。全部的程式碼在github上:https://github.com/marywangran/SimpleVPN/

Simple如何跑起來

測試說明:三臺機器,機器0作為伺服器,機器1和機器2作為VPN節點,三臺機器均有兩塊網絡卡,處在兩個C段,分別為192.168.44.0/24和1.1.1.0/24,其中機器0的1.1.1.0/24段不接網線。
編譯服務端:gcc CtrlCenter.c
執行服務端:./a.out 192.168.44.100 7000
編譯VPN節點:gcc SimpleVPN.c
執行VPN節點:
機器1上執行:
tunctl -u root -t tap0
ifconfig tap0 10.10.10.129/24
./a.out 192.168.44.100 7000 1.1.1.1 100

機器2上執行:
tunctl -u root -t tap0
ifconfig tap0 10.10.10.131/24
./a.out 192.168.44.100 7000 1.1.1.3 100

然後在機器1上ping機器2的tap0地址。

後面的說明

如果說僅僅是為了炫技,那麼就不要輕易設計自己的四層協議!
       明明已經有了TCP和UDP,還要再設計一個新的四層協議,這會造成損失。還記得VXLAN和NVGRE的區別嗎?VXLAN使用了通用的UDP來封裝,而NVGRE則沒有,這就是的NVGRE幾乎不能適配基於UDP元組的負載均衡。仔細想想OpenVPN,幸虧它採用了UDP封裝...它可以無縫適配Linux自帶的reuseport機制來簡單負載均衡,不然負載均衡都得自己寫。我記得最初的負載均衡是我在IP層用nf_conntrack來做的,其實我之所以用nf_conntrack來做,那是因為那時我並不知道reuseport...如果沒有reuseport,然後你還不懂nf_conntrack,那就得花費大量的精力去優化OpenVPN服務端的負載均衡或者多處理。
       在網路虛擬化方面,其中也包括一些VPN技術,很多廠商都在做這塊,我們來看看如今他們分屬的陣營,我大致將它們分為網路陣營和軟體陣營,我比較傾向於網路陣營。
       我大致分下類,VXLAN,VN-TAG,IPSec,這些倡導者都屬於網路陣營,比如Cisco,IETF,而像NVGRE,VEPA,SSL這種,基本都是軟體陣營,比如倡導者是微軟,HP之流,然而我們發現,這兩個陣營誰也壓不倒誰,在VXLAN和NVGRE的PK中,網路陣營完勝,但在VN-TAG和VEPA的較量中,網路陣營又輸的比較慘淡...Why?
       是該聯合起來的時候了。我們發現,贏的那一方一定是便宜的那一方,一定是簡單的那一方。所以我一向提倡炫技者止步的意義就在於此。同軸電纜複雜吧,輸給了簡單便宜的雙絞線,魏國武卒昂貴吧,卻輸給了秦國農民軍...
       快到中午了,令人遺憾的五一勞動節...