當今互聯網到處存在著一些中間件(MIddleBoxes),如NAT和防火墻,導致兩個(不在同一內網)中的客戶端無法直接通信。 這些問題即便是到了IPV6時代也會存在,因為即使不需要NAT,但還有其他中間件如防火墻阻擋了鏈接的建立。 目前部署的中間件多都是在C/S架構上設計的,其中相對隱匿的客戶機主動向周知的服務端(擁有靜態IP地址和DNS名稱)發起鏈接請求。 大多數中間件實現了一種非對稱的通訊模型,即內網中的主機可以初始化對外的鏈接,而外網的主機卻不能初始化對內網的鏈接, 除非經過中間件管理員特殊配置。
在中間件為常見的NAPT的情況下(也是本文主要討論的),內網中的客戶端沒有單獨的公網IP地址, 而是通過NAPT轉換,和其他同一內網用戶共享一個公網IP。這種內網主機隱藏在中間件后的不可訪問性對于一些客戶端軟件如瀏覽器來說 并不是一個問題,因為其只需要初始化對外的鏈接,從某方面來看反而還對隱私保護有好處。然而在P2P應用中, 內網主機(客戶端)需要對另外的終端(Peer)直接建立鏈接,但是發起者和響應者可能在不同的中間件后面, 兩者都沒有公網IP地址。而外部對NAT公網IP和端口主動的鏈接或數據都會因內網未請求被丟棄掉。本文討論的就是如何跨越NAT實現內網主機直接通訊的問題。
1. 術語
防火墻(Firewall): 防火墻主要限制內網和公網的通訊,通常丟棄未經許可的數據包。防火墻會檢測(但是不修改)試圖進入內網數據包的IP地址和TCP/UDP端口信息。
網絡地址轉換器(NAT): NAT不止檢查進入數據包的頭部,而且對其進行修改,從而實現同一內網中不同主機共用更少的公網IP(通常是一個)。
基本NAT(Basic NAT): 基本NAT會將內網主機的IP地址映射為一個公網IP,不改變其TCP/UDP端口號。基本NAT通常只有在當NAT有公網IP池的時候才有用。
網絡地址-端口轉換器(NAPT): 到目前為止最常見的即為NAPT,其檢測并修改出入數據包的IP地址和端口號,從而允許多個內網主機同時共享一個公網IP地址。
錐形NAT(Cone NAT): 在建立了一對(公網IP,公網端口)和(內網IP,內網端口)二元組的綁定之后,Cone NAT會重用這組綁定用于接下來該應用程序的所有會話(同一內網IP和端口),只要還有一個會話還是激活的。 例如,假設客戶端A建立了兩個連續的對外會話,從相同的內部端點(10.0.0.1:1234)到兩個不同的外部服務端S1和S2。Cone NAT只為兩個會話映射了一個公網端點(155.99.25.11:62000), 確保客戶端端口的“身份”在地址轉換的時候保持不變。由于基本NAT和防火墻都不改變數據包的端口號,因此這些類型的中間件也可以看作是退化的Cone NAT。
Server S1 Server S2
18.181.0.31:1235 138.76.29.7:1235
| |
| |
+----------------------+----------------------+
|
^ Session 1 (A-S1) ^ | ^ Session 2 (A-S2) ^
| 18.181.0.31:1235 | | | 138.76.29.7:1235 |
v 155.99.25.11:62000 v | v 155.99.25.11:62000 v
|
Cone NAT
155.99.25.11
|
^ Session 1 (A-S1) ^ | ^ Session 2 (A-S2) ^
| 18.181.0.31:1235 | | | 138.76.29.7:1235 |
v 10.0.0.1:1234 v | v 10.0.0.1:1234 v
|
Client A
10.0.0.1:1234
其中Cone NAT根據NAT如何接收已經建立的(公網IP,公網端口)對的輸入數據還可以細分為以下三類:
- 全錐形NAT(Full Cone NAT) 在一個新會話建立了公網/內網端口綁定之后,全錐形NAT接下來會接受對應公網端口的所有數據,無論是來自哪個(公網)終端。 全錐NAT有時候也被稱為“混雜”NAT(promiscuous NAT)。
- 受限錐形NAT(Restricted Cone NAT) 受限錐形NAT只會轉發符合某個條件的輸入數據包。條件為:外部(源)IP地址匹配內網主機之前發送一個或多個數據包的結點的IP地址。 AT通過限制輸入數據包為一組“已知的”外部IP地址,有效地精簡了防火墻的規則。
- 端口受限錐形NAT(Port-Restricted Cone NAT) 端口受限錐形NAT也類似,只當外部數據包的IP地址和端口號都匹配內網主機發送過的地址和端口號時才進行轉發。 端口受限錐形NAT為內部結點提供了和對稱NAT相同等級的保護,以隔離未關聯的數據。
對稱NAT(Symmetric NAT): 對稱NAT正好相反,不在所有公網-內網對的會話中維持一個固定的端口綁定。其為每個新的會話開辟一個新的端口。如下圖所示:
Server S1 Server S2
18.181.0.31:1235 138.76.29.7:1235
| |
| |
+----------------------+----------------------+
|
^ Session 1 (A-S1) ^ | ^ Session 2 (A-S2) ^
| 18.181.0.31:1235 | | | 138.76.29.7:1235 |
v 155.99.25.11:62000 v | v 155.99.25.11:62001 v
|
Symmetric NAT
155.99.25.11
|
^ Session 1 (A-S1) ^ | ^ Session 2 (A-S2) ^
| 18.181.0.31:1235 | | | 138.76.29.7:1235 |
v 10.0.0.1:1234 v | v 10.0.0.1:1234 v
|
Client A
10.0.0.1:1234
2. P2P通信
根據客戶端的不同,客戶端之間進行P2P傳輸的方法也略有不同,這里介紹了現有的穿越中間件進行P2P通信的幾種技術。
2.1 中繼(Relaying)
這是最可靠但也是最低效的一種P2P通信實現。其原理是通過一個有公網IP的服務器中間人對兩個內網客戶端的通信數據進行中繼和轉發。如下圖所示:
Server S
|
|
+----------------------+----------------------+
| |
NAT A NAT B
| |
| |
Client A Client B
客戶端A和客戶端B不直接通信,而是先都與服務端S建立鏈接,然后再通過S和對方建立的通路來中繼傳遞的數據。這鐘方法的缺陷很明顯, 當鏈接的客戶端變多之后,會顯著增加服務器的負擔,完全沒體現出P2P的優勢。但這種方法的好處是能保證成功,因此在實踐中也常作為一種備選方案。
2.2 逆向鏈接(Connection reversal)
第二種方法在當兩個端點中有一個不存在中間件的時候有效。例如,客戶端A在NAT之后而客戶端B擁有全局IP地址,如下圖:
Server S
18.181.0.31:1235
|
|
+----------------------+----------------------+
| |
NAT A |
155.99.25.11:62000 |
| |
| |
Client A Client B
10.0.0.1:1234 138.76.29.7:1234
客戶端A內網地址為10.0.0.1,且應用程序正在使用TCP端口1234。A和服務器S建立了一個鏈接,服務器的IP地址為18.181.0.31,監聽1235端口。NAT A給客戶端A分配了TCP端口62000,地址為NAT的公網IP地址155.99.25.11, 作為客戶端A對外當前會話的臨時IP和端口。因此S認為客戶端A就是155.99.25.11:62000。而B由于有公網地址,所以對S來說B就是138.76.29.7:1234。
當客戶端B想要發起一個對客戶端A的P2P鏈接時,要么鏈接A的外網地址155.99.25.11:62000,要么鏈接A的內網地址10.0.0.1:1234,然而兩種方式鏈接都會失敗。 鏈接10.0.0.1:1234失敗自不用說,為什么鏈接155.99.25.11:62000也會失敗呢?來自B的TCP SYN握手請求到達NAT A的時候會被拒絕,因為對NAT A來說只有外出的鏈接才是允許的。 在直接鏈接A失敗之后,B可以通過S向A中繼一個鏈接請求,從而從A方向“逆向“地建立起A-B之間的點對點鏈接。
很多當前的P2P系統都實現了這種技術,但其局限性也是很明顯的,只有當其中一方有公網IP時鏈接才能建立。越來越多的情況下, 通信的雙方都在NAT之后,因此就要用到我們下面介紹的第三種技術了。
2.3 UDP打洞(UDP hole punching)
第三種P2P通信技術,被廣泛采用的,名為“P2P打洞“。P2P打洞技術依賴于通常防火墻和cone NAT允許正當的P2P應用程序在中間件中打洞且與對方建立直接鏈接的特性。 以下主要考慮兩種常見的場景,以及應用程序如何設計去完美地處理這些情況。第一種場景代表了大多數情況,即兩個需要直接鏈接的客戶端處在兩個不同的NAT 之后;第二種場景是兩個客戶端在同一個NAT之后,但客戶端自己并不需要知道。
2.3.1. 端點在不同的NAT之后
假設客戶端A和客戶端B的地址都是內網地址,且在不同的NAT后面。A、B上運行的P2P應用程序和服務器S都使用了UDP端口1234,A和B分別初始化了 與Server的UDP通信,地址映射如圖所示:
Server S
18.181.0.31:1234
|
|
+----------------------+----------------------+
| |
NAT A NAT B
155.99.25.11:62000 138.76.29.7:31000
| |
| |
Client A Client B
10.0.0.1:1234 10.1.1.3:1234
現在假設客戶端A打算與客戶端B直接建立一個UDP通信會話。如果A直接給B的公網地址138.76.29.7:31000發送UDP數據,NAT B將很可能會無視進入的 數據(除非是Full Cone NAT),因為源地址和端口與S不匹配,而最初只與S建立過會話。B往A直接發信息也類似。
假設A開始給B的公網地址發送UDP數據的同時,給服務器S發送一個中繼請求,要求B開始給A的公網地址發送UDP信息。A往B的輸出信息會導致NAT A打開 一個A的內網地址與與B的外網地址之間的新通訊會話,B往A亦然。一旦新的UDP會話在兩個方向都打開之后,客戶端A和客戶端B就能直接通訊, 而無須再通過引導服務器S了。
UDP打洞技術有許多有用的性質。一旦一個的P2P鏈接建立,鏈接的雙方都能反過來作為“引導服務器”來幫助其他中間件后的客戶端進行打洞, 極大減少了服務器的負載。應用程序不需要知道中間件具體是什么(如果有的話),因為以上的過程在沒有中間件或者有多個中間件的情況下 也一樣能建立通信鏈路。
2.3.2. 端點在相同的NAT之后
現在考慮這樣一種情景,兩個客戶端A和B正好在同一個NAT之后(而且可能他們自己并不知道),因此在同一個內網網段之內。 客戶端A和服務器S建立了一個UDP會話,NAT為此分配了公網端口62000,B同樣和S建立會話,分配到了端口62001,如下圖:
Server S
18.181.0.31:1234
|
|
NAT
A-S 155.99.25.11:62000
B-S 155.99.25.11:62001
|
+----------------------+----------------------+
| |
Client A Client B
10.0.0.1:1234 10.1.1.3:1234
假設A和B使用了上節介紹的UDP打洞技術來建立P2P通路,那么會發生什么呢?首先A和B會得到由S觀測到的對方的公網IP和端口號,然后給對方的地址發送信息。 兩個客戶端只有在NAT允許內網主機對內網其他主機發起UDP會話的時候才能正常通信,我們把這種情況稱之為”回環傳輸“(lookback translation),因為從內部 到達NAT的數據會被“回送”到內網中而不是轉發到外網。例如,當A發送一個UDP數據包給B的公網地址時,數據包最初有源IP地址和端口地址10.0.0.1:1234和 目的地址155.99.25.11:62001,NAT收到包后,將其轉換為源155.99.25.11:62000(A的公網地址)和目的10.1.1.3:1234,然后再轉發給B。即便NAT支持 回環傳輸,這種轉換和轉發在此情況下也是沒必要的,且有可能會增加A與B的對話延時和加重NAT的負擔。
對于這個情況,優化方案是很直觀的。當A和B最初通過S交換地址信息時,他們應該包含自身的IP地址和端口號(從自己看),同時也包含從服務器看的自己的 地址和端口號。然后客戶端同時開始從對方已知的兩個的地址中同時開始互相發送數據,并使用第一個成功通信的地址作為對方地址。如果兩個客戶端在同一個 NAT后,發送到對方內網地址的數據最有可能先到達,從而可以建立一條不經過NAT的通信鏈路;如果兩個客戶端在不同的NAT之后,發送給對方內網地址的數據包 根本就到達不了對方,但仍然可以通過公網地址來建立通路。值得一提的是,雖然這些數據包通過某種方式驗證,但是在不同NAT的情況下完全有可能會導致A往B 發送的信息發送到其他A內網網段中無關的結點上去的。
2.3.3. 端點在多級NAT之后
在一些拓樸結構中,可能會存在多級NAT設備,在這種情況下,如果沒有關于拓樸的具體信息, 兩個Peer要建立“最優”的P2P鏈接是不可能的,下面來說為什么。以下圖為例:
Server S
18.181.0.31:1234
|
|
NAT X
A-S 155.99.25.11:62000
B-S 155.99.25.11:62001
|
|
+----------------------+----------------------+
| |
NAT A NAT B
192.168.1.1:30000 192.168.1.2:31000
| |
| |
Client A Client B
10.0.0.1:1234 10.1.1.3:1234
假設NAT X是一個網絡提供商ISP部署的工業級NAT,其下子網共用一個公網地址155.99.25.11,NAT A和NAT B分別是其下不同用戶的網關部署的NAT。只有服務器S 和NAT X有全局的路由地址。Client A在NAT A的子網中,同時Client B在NAT B的子網中,每經過一級NAT都要進行一次網絡地址轉換。
現在假設A和B打算建立直接P2P鏈接,用一般的方法(通過Server S來打洞)自然是沒問題的,那能不能優化呢?一種想當然的優化辦法是A直接把信息發送給NAT B的 內網地址192.168.1.2:31000,且B通過NAT B把信息發送給A的路由地址192.168.1.1:30000,不幸的是,A和B都沒有辦法得知這兩個目的地址,因為S只看見了客戶端 ‵全局‵地址155.99.25.11。退一步說,即便A和B通過某種方法得知了那些地址,我們也無法保證他們是可用的。因為ISP分配的子網地址可能和NAT A B分配的子網地址 域相沖突。因此客戶端沒有其他選擇,只能使用S來進行打洞并進行回環傳輸。
2.3.4. 固定端口綁定
UDP打洞技術有一個主要的條件:只有當兩個NAT都是Cone NAT(或者非NAT的防火墻)時才能工作。因為其維持了一個給定的(內網IP,內網UDP)二元組 和(公網IP, 公網UDP)二元組固定的端口綁定,只要該UDP端口還在使用中,就不會變化。如果像對稱NAT一樣,給每個新會話分配一個新的公網端口,就 會導致UDP應用程序無法使用跟外部端點已經打通了的通信鏈路。由于Cone NAT是當今最廣泛使用的,盡管有一小部分的對稱NAT是不支持打洞的,UDP打洞 技術也還是被廣泛采納應用。
3.具體實現
一般的網絡編程,都是客戶端比服務端要難,因為要處理與服務器的通信同時還要處理來自用戶的事件;對于P2P客戶端來說更是如此,因為P2P客戶端不止作 為客戶端,同時也作為對等連接的服務器端。這里的大體思路是,輸入命令傳輸給服務器之后,接收來自服務器的反饋,并執行相應代碼。例如A想要與B建立 通信鏈路,先給服務器發送punch命令以及給B發送數據,服務器接到命令后給B發送punch_requst信息以及A的端點信息,B收到之后向A發送數據打通通路,然 后A與B就可以進行P2P通信了。經測試,打通通路后即便把服務器關閉,A與B也能正常通信。
一個UDP打洞的例子見P2P-Over-MiddleBoxes-Demo
關于TCP打洞,有一點需要提的是,因為TCP是基于連接的,所以任何未經連接而發送的數據都會被丟棄,這導致在recv的時候是無法直接從peer端讀取數據。 其實這對UDP也一樣,如果對UDP的socket進行了connect,其也會忽略連接之外的數據,詳見connect(2)。
所以,如果我們要進行TCP打洞,通常需要重用本地的endpoint來發起新的TCP連接,這樣才能將已經打開的NAT利用起來。具體來說,則是要設置socket的 SO_REUSEADDR或SO_REUSEPORT屬性,根據系統不同,其實現也不盡一致。一般來說,TCP打洞的步驟如下:
- A 發送 SYN 到 B (出口地址,下同),從而創建NAT A的一組映射
- B 發送 SYN 到 A, 創建NAT B的一組映射
- 根據時序不同,兩個SYN中有一個會被對方的NAT丟棄,另一個成功通過NAT
- 通過NAT的SYN報文被其中一方收到,即返回SYNACK, 完成握手
- 至此,TCP的打洞成功,獲得一個不依賴于服務器的鏈接