上一篇文章分析了比特幣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.Kahn和Vinton 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 | 查詢埠對映表 |
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系統了。
--本文為原創作品,轉載請註明出處。
參考文章: