比特幣源碼分析--端口映射

    上一篇文章分析了比特幣P2P網絡中,一個節點是如何發現並連接到相鄰節點的。在P2P網絡中,一個節點既是客戶又是服務器,它還要接受其他節點的連接,爲網絡中其他節點提供服務。這篇文章着重分析一下比特幣P2P網絡中是如何通過upnp來實現端口映射的。

1 從騰訊的一道面試題說起

    筆者所在團隊的總監在面試的時候必然要問面試者這樣一個問題:

    有兩臺手機同時連到了一個WIFI上,然後它們都訪問了外網中某個服務器,那麼網絡是如何做到區分出這兩臺設備,把服務器的應答數據分發到合適的手機上呢?

    如果在毫無準備的情況下來回答這個問題,自己還真是答不出來。

    再想象一個場景:假設我們自己寫了個小的服務器程序,然後在家裏的電腦上運行,此時你想讓另一個同事連接你的服務器,來驗證你的服務器程序是不是能正確運行,但是明顯你的網絡和同事家的網絡是兩個不同的局域網,所以除非你去同事家或者讓同事提上電腦到你家,否則無法連通。那麼有什麼辦法做到讓同事在自己家裏就能點對點連上你的服務來調試麼?

2 NAT和NAT穿透

    上一節提到的兩個問題,實際上都和NAT有關。要弄清楚上一節的問題,需要先了解NAT,所以這裏先來補點網絡課,瞭解一下NAT以及NAT穿透。

2.1 NAT

2.1.1 NAT是什麼

    NAT是個什麼鬼?它的全稱是Network Address Translation,翻譯過來就是網絡地址轉換。好事的人立馬就得問了:好端端的爲啥要地址轉換,直接用IP地址不就行了麼?

    在TCP/IP協議創建的時候,他的創始人(Robert E.KahnVinton G.Cerf)可能都沒有預料到互聯網的膨脹速度會如此之快,快到短短二三十年的時間,IPV4的地址就有要枯竭之勢。隨着越來越多的設備加入到互聯網中,IPV4地址不夠用的問題成了燃眉之急。

    解決IP地址不夠用的一個辦法是大家已經非常熟悉的IPV6,但是這麼多年過去了,IPV6似乎還是不溫不火,始終普及不起來。於是就有了NAT的解決方案,可以說正是NAT把IPV4從死亡邊緣拉了回來,NAT到底是用了什麼方法立下如此奇功,本節我們來簡單的瞭解一下。

    平時我們無論是在家裏,還是在公司,其實都是在一個私有的局域網,此時電腦上分配到的IP地址是私有IP地址。RFC1918規範裏規定了3個保留地址段:10.0.0.0-10.255.255.255,172.16.0.0-172.31.255.255,192.168.0.0-192.168.255.255,這三個範圍分別處於A、B、C類的地址段,專門用於組織或者企業內部使用,不需要進行申請。和公有IP地址相比,這些私有IP地址只在企業內部使用,不能作爲全球路由地址,出了企業或組織的管理範圍,這類私有地址就不在有任何意義。注意:任何一個組織都可以在內部使用這些私有地址,因此兩個不同網絡中存在相同IP地址的情況是很可能出現的,但是同一個網絡中不允許兩臺主機擁有相同IP地址,否則將發生地址衝突。

    當私有網絡中的主機想請求公網中服務器的服務時,需要在網絡出口處部署NAT網關。NAT的作用就是在報文離開私網進入Internet的時候,把報文中的源IP地址替換爲公網地址,然後等服務端的響應報文到達網關時,NAT再把目的地址替換爲私網中主機的IP地址。

    聽上去似乎很簡單,NAT不就是替換了一下IP地址麼,也沒幹什麼,但是這裏需要注意兩點:

    (1) 有了NAT以後,內網的主機不在需要申請公網IP地址,只需要將內網主機地址和端口通過NAT映射到網絡出口的公網IP即可,然後通信的兩端在無感知的情況下進行通信。這也是爲什麼前文說NAT挽救了IPV4,因爲大量的內網主機有了NAT,只需要很少的公網地址做映射就可以了,如此就可以節約出很多的IPV4地址空間。

    (2) 當在私網網絡出口處部署了NAT網關以後,只能由內網主機發起到外網主機的連接,外網主機無法主動發起連接到內網。這樣雖然對外隔離了內網主機,但同時又限制了P2P的通信,這也是NAT帶來的一大弊端,下一節介紹NAT穿透技術時會看到針對這一問題有哪些解決手段。

2.1.2 NAT的分類

    (1) 一對一NAT

    就是一個內網主機對應一個公有IP。這種類型的NAT對於節省IP地址沒什麼意義。

    (2) 一對多NAT 

    內網的多個主機都映射到同一個公有IP地址上。但是這裏就有前文提到的那個面試問題:當內網有多臺主機都請求同一服務器時,如果僅僅是替換地址,從返回信息是無法確認該將響應轉發到哪一臺主機的。此時還需要NAT根據傳輸層信息或者上層協議區分不同的會話,把不同的會話映射到公網IP不同的傳輸層端口上(NAPT)。

    按照端口映射的方式分類,1對多的NAT又可以細分爲4種:

    (1) 全錐型NAT:

        

    假設內網設備192.168.0.1:80向svr1發起請求,內網地址在NAT網關被映射爲公網地址和端口:192.169.0.1:8080,在全錐形模式下,一旦連接成功後,外網所有主機發送到192.169.0.1:8080的數據,都將被NAT網關轉發到內網192.168.0.1:80設備上。

    (2) 限制錐型NAT:

    

    假設內網設備192.168.0.1:80成功連接了svr1,內網設備的地址和端口在NAT網關被映射爲192.169.0.1:8080,在限制錐形模式下,只有內網設備向svr1發送過數據,之後從svr1的任意端口發送到192.169.0.1:8080的數據,都會被網關轉發給內網設備192.168.0.1:80,但是外網其他設備(圖中的svr2)發送到192.169.0.1:8080的數據將不會被轉發。

    (3) 端口限制錐形NAT:

    與限制錐形NAT相比,端口限制錐形NAT更加嚴格:

    

    假設內網設備192.168.0.1:80向外網svr1的80端口建立連接併發送數據,其內網地址和端口在NAT網關被映射爲192.169.0.1:8080,在端口限制錐形模式下,只有svr1的80端口發送到網關192.169.0.1:8080的數據纔會被轉發到內網設備192.168.0.1:80,svr1的其他端口或者外網其他主機發送到192.168.0.1:8080的數據均不會抓發到內網設備。

    現在回頭在來看看那到面試題目:兩臺手機連到同一WIFI,爲什麼外網服務器的響應可以轉發到正確的手機上來不會混亂。明白了前面描述的NAT端口映射的原理,這個問題就比較容易理解:在NAT網關,將不同設備的服務請求用NAT映射到不同端口號上就可以實現:

    

    因爲僅僅替換IP地址無法區分出內網設備,所以需要通過端口映射將不同內網設備的請求映射到不同端口上,這樣當來自同一個往外服務器的響應數據到來時,NAT網關才能夠把響應轉發到內網的設備上。

2.2 NAT穿透

    前文提到過,使用NAT的缺陷之一就是隻能由內網主機發起連接,外網主機無法主動連接到內網。這就意味着外部節點無法和內網主機進行P2P通信,就像第一節中提到的那個場景:因爲兩個人在不同的局域網中,相互不知道對方的公網地址和端口,所以無法直接建立起點對點連接。解決這個問題的辦法就是NAT穿透技術。下面簡單介紹幾種常見的NAT穿越技術。

2.2.1 STUN

    STUN全稱爲Simple Tranversal of UDP through NAT。其穿透原理參考下圖:

    

    假設兩個不同網絡中的設備A和B想穿透NAT進行點對點通信,通過STUN進行NAT穿透的過程如上圖,其中STUN SERVER是部署在公網中的STUN服務器。

    (1) CLIENT A通過NAT網關向STUN SERVER發送STUN請求消息(UDP),查詢並註冊自己經過NAT映射後的公網地址;

    (2) STUN SERVER響應,並將CLIENT A經過轉換後的公網IP地址和端口填在響應報文中;

    (3) CLIENT B通過NAT網關向STUN SERVER發送STUN請求消息(UDP),查詢並註冊自己經過NAT映射後的公網地址;

    (4) STUN SERVER響應,並將CLIENT B經過轉換後的公網IP地址和端口填在響應報文中;

    (5) 此時CLIENT A已經知道了自己映射後對應的公網IP地址和端口號,它把這些信息打包在請求中發送給STUN SERVER,請求和B進行通信;

    (6) STUN SERVER查詢到B註冊的公網地址和端口,然後將請求通過NAT網關轉發給B;

    (7) B從消息中知道A的公網地址和端口,於是通過此地址和端口,向A發送消息,消息中包含B映射後的公網地址和端口號,A收到消息後就知道了B的公網地址及端口,這樣在A和B之間建立起了通信通道。

2.2.2 TURN

    STUN穿透技術的缺點在於無法穿透對稱型NAT,這可以通過TURN技術進行改進。TURN的工作過程和STUN非常相似,區別在於在TURN中,公網地址和端口不由NAT網關分配,而是由TURN服務器分配。

    TURN可以解決STUN無法穿透對稱NAT的問題,但是由於所有的請求都需要經過TURN服務器,所以網絡延遲和丟包的可能性較大,實際當中通常將STUN和TURN混合使用。

2.2.3 UPNP

    UPNP意爲通用即插即用協議,是由微軟提出的一種NAT穿透技術。使用UPNP需要內網主機、網關和應用程序都支持UPNP技術。

    UPNP通過網關映射請求可以動態的爲客戶分配映射表項,而NAT網關只需要執行地址和端口的轉換。UPNP客戶端發送到公網側的信令或者控制消息中,會包含映射之後公網IP和端口,接收端根據這些信息就可以建立起P2P連接。

    UPNP穿透的過程大致如下:

    (1) 發送查找消息:

    一個設備添加到網絡以後,會多播大量發現消息來通知其嵌入式設備和服務,所有的控制點都可以監聽多播地址以接收通知,標準的多播地址是239.255.255.250:1900。可以通過發送http請求查詢局域網中upnp設備,消息形式如下:

    M-SEARCH * HTTP/1.1 \r\n

    HOST 239.255.255.250:1900 \r\n

    ST:UPnP rootdevice \r\n

    MAN:\"ssdp:discover\" \r\n

    MX:\r\n\r\n

    (2) 獲得根設備描述url

    如果網絡中存在upnp設備,此設備會向發送了查找請求的多播通道的源IP地址和端口發送響應消息,其形式如下:

    HTTP/1.1 200 OK

    CACHE_CONTROL: max-age=100

    DATE: XXXX

    LOCATION:http://192.168.1.1:1900/igd.xml

    SERVER: TP-LINK Wireness Router UPnP1.0

    ST: upnp:rootdevice

  首先通過200 OK確定成功的找到了設備。然後要從響應中找到根設備的描述URL(例如上面響應報文中的http://192.168.1.1:1900/igd.xml),通過此URL就可以找到根設備的描述信息,從根設備的描述信息中又可以得到設備的控制URL,通過控制URL就可以控制UPNP的行爲。上面這個響應中表示我們在局域網中成功的找到了一臺支持UPNP的無線路由器設備。

    (3) 通過(2)中找到的設備描述URL的地址得到設備描述URL得到XML文檔。發送HTTP請求消息:

    GET /igd.xml HTTP/1.1

    HOST:192.168.1.1:1900

    Connection: Close

    然後就能得到一個設備描述文檔,從中可以找到服務和UPNP控制URL。每一種設備都有對應的serviceURL和controlURL。其中和端口映射有關的服務時WANIPConnection和WANPPPConnection。

    (4) 進行端口映射

    拿到設備的控制URL以後就可以發送控制信息了。每一種控制都是根據HTTP請求來發送的,請求形式如下:

    POST path HTTP/1.1

    HOST: host:port

    SOAPACTION:serviceType#actionName

    CONTENT-TYPE: text/xml

    CONTENT-LENGTH: XXX

    ....

    其中path表示控制url,host:port就是目的主機地址,actionName就是控制upnp設備執行響應的指令。UPNP支持的指令如下:

actionName描述
GetStatusInfo查看UPNP設備狀態
AddPortMapping添加一個端口映射
DeletePortMapping刪除一個端口映射
GetExternalIPAddress查看映射的外網地址
GetConnectionTypeInfo查看連接狀態
GetSpecificPortMappingEntry查詢指定的端口映射
GetGenericPortMappingEntry查詢端口映射表
    通常我們需要用到的是AddPortMapping進行端口映射,以及GetExternalIPAddress獲取到映射的公網地址。

    UPNP完整的協議棧比較複雜,有興趣的讀者可以自行查找資料做更加深入的學習。

3 UPNP在比特幣P2P網絡中的應用

    區塊鏈是建立在P2P網絡基礎上的。在比特幣系統中,穿透NAT建立節點之間點對點的P2P網絡,採用的就是上一節所說的UPNP技術。比特幣使用了開源的miniupnp,基本上就是調用miniupnp封裝好的接口,實現比較簡單,我們來看看源代碼:

    在前一篇文章比特幣源碼分析--P2P網絡初始化中介紹中知道,比特幣系統的初始化大部分都是在init.cpp中的AppInitMain中進行的,我們當時略過了端口映射的部分,在這裏補上:

    // Map ports with UPnP
    if (gArgs.GetBoolArg("-upnp", DEFAULT_UPNP)) {
        StartMapPort();
    }

    從代碼中可以看到,如果在啓動bitcoind時開啓了upnp選項,將會進行端口映射,如果想將自己的節點加入到比特幣p2p網絡中,讓其他網絡中的節點訪問,可以開啓此選項進行端口映射,然後把映射後的公網ip地址廣播給網絡中的其他節點。

    StartMapPort()中開啓了一個線程進行端口映射,線程函數爲net.cpp中的ThreadMapPort:

#ifdef USE_UPNP
static CThreadInterrupt g_upnp_interrupt;
static std::thread g_upnp_thread;
static void ThreadMapPort()
{
    std::string port = strprintf("%u", GetListenPort());
    const char * multicastif = nullptr;
    const char * minissdpdpath = nullptr;
    struct UPNPDev * devlist = nullptr;
    char lanaddr[64];

#ifndef UPNPDISCOVER_SUCCESS
    /* miniupnpc 1.5 */
    devlist = upnpDiscover(2000, multicastif, minissdpdpath, 0);
#elif MINIUPNPC_API_VERSION < 14
    /* miniupnpc 1.6 */
    int error = 0;
    devlist = upnpDiscover(2000, multicastif, minissdpdpath, 0, 0, &error);
#else
    /* miniupnpc 1.9.20150730 */
    int error = 0;
    devlist = upnpDiscover(2000, multicastif, minissdpdpath, 0, 0, 2, &error);
#endif

    struct UPNPUrls urls;
    struct IGDdatas data;
    int r;

    r = UPNP_GetValidIGD(devlist, &urls, &data, lanaddr, sizeof(lanaddr));
    if (r == 1)
    {
        if (fDiscover) {
            char externalIPAddress[40];
            r = UPNP_GetExternalIPAddress(urls.controlURL, data.first.servicetype, externalIPAddress);
            if(r != UPNPCOMMAND_SUCCESS)
                LogPrintf("UPnP: GetExternalIPAddress() returned %d\n", r);
            else
            {
                if(externalIPAddress[0])
                {
                    CNetAddr resolved;
                    if(LookupHost(externalIPAddress, resolved, false)) {
                        LogPrintf("UPnP: ExternalIPAddress = %s\n", resolved.ToString().c_str());
                        AddLocal(resolved, LOCAL_UPNP);
                    }
                }
                else
                    LogPrintf("UPnP: GetExternalIPAddress failed.\n");
            }
        }

        std::string strDesc = "Bitcoin " + FormatFullVersion();

        do {
#ifndef UPNPDISCOVER_SUCCESS
            /* miniupnpc 1.5 */
            r = UPNP_AddPortMapping(urls.controlURL, data.first.servicetype,
                                port.c_str(), port.c_str(), lanaddr, strDesc.c_str(), "TCP", 0);
#else
            /* miniupnpc 1.6 */
            r = UPNP_AddPortMapping(urls.controlURL, data.first.servicetype,
                                port.c_str(), port.c_str(), lanaddr, strDesc.c_str(), "TCP", 0, "0");
#endif

            if(r!=UPNPCOMMAND_SUCCESS)
                LogPrintf("AddPortMapping(%s, %s, %s) failed with code %d (%s)\n",
                    port, port, lanaddr, r, strupnperror(r));
            else
                LogPrintf("UPnP Port Mapping successful.\n");
        }
        while(g_upnp_interrupt.sleep_for(std::chrono::minutes(20)));

        r = UPNP_DeletePortMapping(urls.controlURL, data.first.servicetype, port.c_str(), "TCP", 0);
        LogPrintf("UPNP_DeletePortMapping() returned: %d\n", r);
        freeUPNPDevlist(devlist); devlist = nullptr;
        FreeUPNPUrls(&urls);
    } else {
        LogPrintf("No valid UPnP IGDs found\n");
        freeUPNPDevlist(devlist); devlist = nullptr;
        if (r != 0)
            FreeUPNPUrls(&urls);
    }
}

    (1) 首先第一行拿到比特幣系統所使用的端口號,默認爲8333,之後將要映射此端口到公網ip上;

    (2) 調用upnpDiscover查找當前局域網中的所有upnp設備;

    (3) 調用UPNP_GetValidIGD,從(2)中找到的upnp設備列表中找到有效的IGD設備;

    (4) 如果UPNP_GetValidIGD返回1,表示有一個連接,此時調用UPNP_GetExternalIPAddress獲取公網地址,然後對此公網地址進行DNS查詢,將解析到的地址記錄到內存中,這些公網地址之後將會被廣播給P2P網絡中的其他節點,一傳十,十傳百。

    (5) 通過UPNP_AddPortMapping進行端口映射,假設內網獲取的有效IGD設備的IP地址爲192.168.0.1,網關出口的外網地址爲192.169.1.1,採用比特幣的默認端口8333,則端口映射後就是將內網中192.168.0.1:8333映射到網關出口的公有IP地址和端口:192.169.1.1:8333,之後外部節點通過此公網IP和端口,就可以與內網節點進行通信了。

4 小結

    這篇文章主要介紹了NAT以及常見的NAT穿透技術。因爲建立P2P通信很重要的一步就是穿透NAT以建立起節點之間的通信通道。常見的NAT穿透技術有STUN,TURN以及UPNP,而比特幣P2P組網採用的正是UPNP技術,具體實現時比特幣採用了開源的miniupnp。

    最後回想一下文章開頭描述的那個場景:如何讓位於家中的同事和你自己的服務器建立起點對點的連接進行調試呢?本文看過了比特幣的實現後,您可能已經在琢磨着如何像比特幣那樣,用minipunp實現一個自己的小p2p系統了。


    --本文爲原創作品,轉載請註明出處。


    參考文章:

    https://blog.csdn.net/lixin88/article/details/60571846

    

    


    



    

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章