NAT 穿透是如何工作的:技術原理及企業級實踐

本文翻譯自 2020 年的一篇英文博客:How NAT traversal works(https://tailscale.com/blog/how-nat-traversal-works/)。之前有讀者問過關於 NAT 穿越問題,剛好今天找到一篇非常好的文章分享出來,希望對你有幫助!

譯者序

設想這樣一個問題:在北京和上海各有一臺局域網的機器(例如一臺是家裏的臺式機,一 臺是連接到星巴克 WiFi 的筆記本),二者都是私網 IP 地址,但可以訪問公網, 如何讓這兩臺機器通信呢?

既然二者都能訪問公網,那最簡單的方式當然是在公網上架設一箇中繼服務器:兩臺機器分別連接到中繼服務,後者完成雙向轉發。這種方式顯然有很大的性能開銷,而 且中繼服務器很容易成爲瓶頸。

有沒有辦法不用中繼,讓兩臺機器直接通信呢?

如果有一定的網絡和協議基礎,就會明白這事兒是可能的。Tailscale 的這篇史詩級長文由淺入深地展示了這種 “可能”,如果完全實現本文所 介紹的技術,你將得到一個企業級的 NAT / 防火牆穿透工具。此外,如作者所說,去中心化軟件領域中的許多有趣想法,簡化之後其實都變成了 跨過公網(互聯網)實現端到端直連 這一問題,因此本文的意義並不僅限於 NAT 穿透本身。

由於譯者水平有限,本文不免存在遺漏或錯誤之處。如有疑問,請查閱原文。

在前一篇文章 How Tailscale Works 中, 我們已經用較長篇幅介紹了 Tailscale 是如何工作的。但其中並沒有詳細描述我們是 如何穿透 NAT 設備,從而實現終端設備直連的 —— 不管這些終端之間 有什麼設備(防火牆、NAT 等),以及有多少設備。本文試圖補足這一內容。

1 引言

1.1 背景:IPv4 地址短缺,引入 NAT

全球 IPv4 地址早已不夠用,因此人們發明了 NAT(網絡地址轉換)來緩解這個問題。

簡單來說,大部分機器都使用私有 IP 地址,如果它們需要訪問公網服務,那麼,

出向流量:需要經過一臺 NAT 設備,它會對流量進行 SNAT,將私有 srcIP+Port 轉 換成 NAT 設備的公網 IP+Port(這樣應答包才能回來),然後再將包發出去;應答流量(入向):到達 NAT 設備後進行相反的轉換,然後再轉發給客戶端。整個過程對雙方透明。

更多關於 NAT 的內容,可參考 (譯) NAT - 網絡地址轉換(2016)。譯註。

以上是本文所討論問題的基本背景。

1.2 需求:兩臺經過 NAT 的機器建立點對點連接

在以上所描述的 NAT 背景下,我們從最簡單的問題開始:如何在兩臺經過 NAT 的機器之間建立 點對點連接(直連)。如下圖所示:

直接用機器的 IP 互連顯然是不行的,因爲它們都是私有 IP(例如 192.168.1.x)。在 Tailscale 中,我們會建立一個 WireGuard® 隧道 來解決這個問題 —— 但這並不是太重要,因爲我們將過去幾代人努力都整合到了一個工具集, 這些技術廣泛適用於各種場景。例如,

WebRTC 使用這些技術在瀏覽器之間完成 peer-to-peer 語音、視頻和數據傳輸, VoIP 電話和一些視頻遊戲也使用類似機制,雖然不是所有情況下都很成功。接下來,本文將在一般意義上討論這些技術,並在合適的地方拿 Tailscale 和其他一些東西作爲例子。

1.3 方案:NAT 穿透

1.3.1 兩個必備前提:UDP + 能直接控制 socket

如果想設計自己的協議來實現 NAT 穿透,那必須滿足以下兩個條件:

協議應該基於 UDP。

理論上用 TCP 也能實現,但它會給本已相當複雜的問題再增加一層複雜性, 甚至還需要定製化內核 —— 取決於你想實現到什麼程度。本文接下來都將關注在 UDP 上。

如果考慮 TCP 是想在 NAT 穿透時獲得面向流的連接( stream-oriented connection),可以考慮用 QUIC 來替代,它構 建在 UDP 之上,因此我們能將關注點放在 UDP NAT 穿透,而仍然能獲得一個 很好的流協議(stream protocol)。

對收發包的 socket 有直接控制權。

例如,從經驗上來說,無法基於某個現有的網絡庫實現 NAT 穿透,因爲我們 必須在使用的 “主要” 協議之外,發送和接收額外的數據包。

某些協議(例如 WebRTC)將 NAT 穿透與其他部分緊密集成。但如果你在構建自己的協議, 建議將 NAT 穿透作爲一個獨立實體,與主協議並行運行,二者僅 僅是共享 socket 的關係,如下圖所示,這將帶來很大幫助:

1.3.2 保底方式:中繼

在某些場景中,直接訪問 socket 這一條件可能很難滿足。

退而求其次的一個方式是設置一個 local proxy(本地代理),主協議與這個 proxy 通信 ,後者來完成 NAT 穿透,將包中繼(relay)給對端。這種方式增加了一個額外的間接層 ,但好處是:

  1. 仍然能獲得 NAT 穿透

  2. 不需要對已有的應用程序做任何改動。

1.4 挑戰:有狀態防火牆和 NAT 設備

有了以上鋪墊,下面就從最基本的原則開始,一步步看如何實現一個企業級的 NAT 穿透方案。

我們的目標是:在兩個設備之間通過 UDP 實現雙向通信, 有了這個基礎,上層的其他協議(WireGuard, QUIC, WebRTC 等)就能做一些更酷的事情。

但即便這個看似最基本的功能,在實現上也要解決兩個障礙:

2 穿透防火牆

有狀態防火牆是以上兩個問題中相對比較容易解決的。實際上,大部分 NAT 設備都自帶了一個有狀態防火牆, 因此要解決第二個問題,必須先解決有第一個問題。

有狀態防火牆具體有很多種類型,有些你可能見過:

2.1 有狀態防火牆

2.1.1 默認行爲(策略)

以上防火牆的配置都是很靈活的,但大部分配置默認都是如下行爲:

2.1.2 如何區分入向和出向包

連接(connection)和方向(direction)都是協議設計者頭腦中的概念,到了 物理傳輸層,每個連接都是雙向的;允許所有的包雙向傳輸。那防火牆是如何區分哪些是入向包、哪些是出向包的呢?這就要回到 “有狀態”(stateful)這三個字了:有狀態防火牆會記錄它 看到的每個包,當收到下一個包時,會利用這些信息(狀態)來判斷應該做什麼。

對 UDP 來說,規則很簡單:如果防火牆之前看到過一個出向包(outbound),就會允許 相應的入向包(inbound)通過,以下圖爲例:

筆記本電腦中自帶了一個防火牆,當該防火牆看到從這臺機器出去的 2.2.2.2:1234 -> 5.5.5.5:5678 包時,就會記錄一下:5.5.5.5:5678 -> 2.2.2.2:1234 入向包應該放行。這裏的邏輯是:我們信任的世界(即筆記本)想主動與 5.5.5.5:5678 通信,因此應該放行(allow)其回包路徑。

某些非常寬鬆的防火牆只要看到有從 2.2.2.2:1234 出去的包,就 會允許所有從外部進入 2.2.2.2:1234 的流量。這種防火牆對我們的 NAT 穿透來說非 常友好,但已經越來越少見了。

2.2 防火牆朝向(face-off)與穿透方案

2.2.1 防火牆朝向相同

場景特點:服務端 IP 可直接訪問

在 NAT 穿透場景中,以上默認規則對 UDP 流量的影響不大 —— 只要路徑上所有防火牆的 “朝向” 是一樣的。一般來說,從內網訪問公網上的某個服務器都屬於這種情況。

我們唯一的要求是:連接必須是由防火牆後面的機器發起的。這是因爲 在它主動和別人通信之前,沒人能主動和它通信,如下圖所示:

穿透方案:客戶端直連服務端,或 hub-and-spoke 拓撲

但上圖是假設了通信雙方中,其中一端(服務端)是能直接訪問到的。在 VPN 場景中,這就形成了所謂的 hub-and-spoke 拓撲:中心的 hub 沒有任何防火牆策略,誰都能訪問到;防火牆後面的 spokes 連接到 hub。如下圖所示:

2.2.2 防火牆朝向不同

場景特點:服務端 IP 不可直接訪問

但如果兩個 “客戶端” 想直連,以上方式就不行了,此時兩邊的防火牆相向而立,如下圖所示:

根據前面的討論,這種情況意味着:兩邊要同時發起連接請求,但也意味着 兩邊都無法發起有效請求,因爲對方先發起請求才能在它的防火牆上打開一條縫讓我們進去!如何破解這個問題呢?一種方式是讓用戶重新配置一邊或兩邊的防火牆,打開一個端口, 允許對方的流量進來。

  1. 這顯然對用戶不友好,在像 Tailscale 這樣的 mesh 網絡中的擴展性也不好,在 mesh 網絡中,我們假設對端會以一定的粒度在公網上移動。

  2. 此外,在很多情況下用戶也沒有防火牆的控制權限:例如在咖啡館或機場中,連接的路 由器是不受你控制的(否則你可能就有麻煩了)。

因此,我們需要尋找一種不用重新配置防火牆的方式。

穿透方案:兩邊同時主動建連,在本地防火牆爲對方打開一個洞

解決的思路還是先重新審視前面提到的有狀態防火牆規則:

對於 UDP,其規則(邏輯)是:包必須先出去才能進來(packets must flow out before packets can flow back in)。

注意,這裏除了要滿足包的 IP 和端口要匹配這一條件之外,並沒有要求包必須是相關的(related)。換句話說,只要某些包帶着正確的源和目的地址出去了,任何看起來像是響應的包都會被防火牆放進來 —— 即使對端根本沒收到你發出去的包。

因此,要穿透這些有狀態防火牆,我們只需要共享一些信息:讓兩端提前知道對方使用的 ip:port:

手動靜態配置是一種方式,但顯然擴展性不好;

我們開發了一個 coordination server, 以靈活、安全的方式來同步 ip:port 信息。有了對方的 ip:port 信息之後,兩端開始給對方發送 UDP 包。在這個過程中,我們預 料到某些包將會被丟棄。因此,雙方必須要接受某些包會丟失的事實, 因此如果是重要信息,你必須自己準備好重傳。對 UDP 來說丟包是可接受的,但這裏尤其需要接受。

來看一下具體建連(穿透)過程:

  1. 如圖所示,筆記本出去的第一包,2.2.2.2:1234 -> 7.7.7.7:5678,穿過 Windows Defender 防火牆進入到公網。

對方的防火牆會將這個包攔截掉,因爲它沒有 7.7.7.7:5678 -> 2.2.2.2:1234 的流量記錄。但另一方面,Windows Defender 此時已經記錄了出向連接,因此會允許 7.7.7.7:5678 -> 2.2.2.2:1234 的應答包進來。

  1. 接着,第一個 7.7.7.7:5678 -> 2.2.2.2:1234 穿過它自己的防火牆到達公網。

到達客戶端側時,Windows Defender 認爲這是剛纔出向包的應答包,因此就放行它進入了!此外,右側的防火牆此時也記錄了:2.2.2.2:1234 -> 7.7.7.7:5678 的包應該放行。

  1. 筆記本收到服務器發來的包之後,發送一個包作爲應答。這個包穿過 Windows Defender 防火牆 和服務端防火牆(因爲這是對服務端發送的包的應答包),達到服務端。

成功!這樣我們就建立了一個穿透兩個相向防火牆的雙向通信連接。而初看之下,這項任務似乎是不可能完成的。

2.3 關於穿透防火牆的一些思考

穿透防火牆並非永遠這麼輕鬆,有時會受一些第三方系統的間接影響,需要仔細處理。那穿透防火牆需要注意什麼呢?重要的一點是:通信雙方必須幾乎同時發起通信, 這樣才能在路徑上的防火牆打開一條縫,而且兩端還都是活着的。

2.3.1 雙向主動建連:旁路信道

如何實現 “同時” 呢?一種方式是兩端不斷重試,但顯然這種方式很浪費資源。假如雙方都 知道何時開始建連就好了。

這聽上去是雞生蛋蛋生雞的問題了:雙方想要通信,必須先提前通個信。

但實際上,我們可以通過旁路信道(side channel)來達到這個目的 ,並且這個旁路信道並不需要很 fancy:它可以有幾秒鐘的延遲、只需要傳送幾 KB 的 信息,因此即使是一個配置非常低的虛擬機,也能爲幾千臺機器提供這樣的旁路通信服務。

在遙遠的過去,我曾用 XMPP 聊天消息作爲旁路,效果非常不錯。

另一個例子是 WebRTC,它需要你提供一個自己的 “信令信道”(signalling channel, 這個詞也暗示了 WebRTC 的 IP telephony ancestry),並將其配置到 WebRTC API。在 Tailscale,我們的協調服務器(coordination server)和 DERP (Detour Encrypted Routing Protocol) 服務器集羣是我們的旁路信道。

2.3.2 非活躍連接被防火牆清理

有狀態防火牆內存通常比較有限,因此會定期清理不活躍的連接(UDP 常見的是 30s), 因此要保持連接 alive 的話需要定期通信,否則就會被防火牆關閉,爲避免這個問題, 我們,

要麼定期向對方發包來 keepalive, 要麼有某種帶外方式來按需重建連接。

2.3.3 問題都解決了?不,挑戰剛剛開始

對於防火牆穿透來說, 我們並不需要關心路徑上有幾堵牆 —— 只要它們是有狀態防火牆且允許出 向連接,這種同時發包(simultaneous transmission)機制就能穿透任意多層防火牆。這一點對我們來說非常友好,因爲只需要實現一個邏輯,然後能適用於任何地方了。

… 對嗎?

其實,不完全對。這個機制有效的前提是:我們能提前知道對方的 ip:port。而這就涉及到了我們今天的主題:NAT,它會使前面我們剛獲得的一點滿足感頓時消失。

下面,進入本文正題。

3 NAT 的本質

3.1 NAT 設備與有狀態防火牆

可以認爲 NAT 設備是一個增強版的有狀態防火牆,雖然它的增強功能 對於本文場景來說並不受歡迎:除了前面提到的有狀態攔截 / 放行功能之外,它們還會在數據包經過時修改這些包。

3.2 NAT 穿透與 SNAT/DNAT

具體來說,NAT 設備能完成某種類型的網絡地址轉換,例如,替換源或目的 IP 地址或端口。

討論連接問題和 NAT 穿透問題時,我們只會受 source NAT —— SNAT 的影響。DNAT 不會影響 NAT 穿透。

3.3 SNAT 的意義:解決 IPv4 地址短缺問題

SNAT 最常見的使用場景是將很多設備連接到公網,而只使用少數幾個公網 IP。例如對於消費級路由器,會將所有設備的(私有) IP 地址映射爲單個連接到公網的 IP 地址。

這種方式存在的意義是:我們有遠多於可用公網 IP 數量的設備需要連接到公網,(至少 對 IPv4 來說如此,IPv6 的情況後面會討論)。NAT 使多個設備能共享同一 IP 地址,因 此即使面臨 IPv4 地址短缺的問題,我們仍然能不斷擴張互聯網的規模。

3.4 SNAT 過程:以家用路由器爲例

假設你的筆記本連接到家裏的 WiFi,下面看一下它連接到公網某個服務器時的情形:

  1. 筆記本發送 UDP packet 192.168.0.20:1234 -> 7.7.7.7:5678。

這一步就好像筆記本有一個公網 IP 一樣,但源地址 192.168.0.20 是私有地址, 只能出現在私有網絡,公網不認,收到這樣的包時它不知道如何應答。

  1. 家用路由器出場,執行 SNAT。

包經過路由器時,路由器發現這是一個它沒有見過的新會話(session)。它知道 192.168.0.20 是私有 IP,公網無法給這樣的地址回包,但它有辦法解決:

  1. 反向路徑是類似的,路由器會執行相反的地址轉換,將 2.2.2.2:4242 變回 192.168.0.20:1234。對於筆記本來說,它根本感知不知道這正反兩次變換過程。

這裏是拿家用路由器作爲例子,但辦公網的原理是一樣的。不同之處在 於,辦公網的 NAT 可能有多臺設備組成(高可用、容量等目的),而且它們有不止一個公 網 IP 地址可用,因此在選擇可用的公網 ip:port 來做映射時,選擇空間更大,能支持 更多客戶端。

3.5 SNAT 給穿透帶來的挑戰

現在我們遇到了與前面有狀態防火牆類似的情況,但這次是 NAT 設備:通信雙方 不知道對方的 ip:port 是什麼,因此無法主動建連,如下圖所示:

但這次比有狀態防火牆更糟糕,嚴格來說,在雙方發包之前,根本無法確定(自己及對方的)ip:port 信息,因爲 只有出向包經過路由器之後纔會產生 NAT mapping(即,可以被對方連接的 ip:port 信息)。

因此我們又回到了與防火牆遇到的問題,並且情況更糟糕:雙方都需要主動和對 方建連,但又不知道對方的公網地址是多少,只有當對方先說話之後,我們才能拿到它的地址信息。

如何破解以上死鎖呢?這就輪到 STUN 登場了。

4 穿透 “NAT + 防火牆”:STUN (Session Traversal Utilities for NAT) 協議

STUN 既是一些對 NAT 設備行爲的詳細研究,也是一種協助 NAT 穿透的協議。本文主要關注 STUN 協議。

4.1 STUN 原理

STUN 基於一個簡單的觀察:從一個會被 NAT 的客戶端訪問公網服務器時, 服務器看到的是 NAT 設備的公網 ip:port 地址,而非該 客戶端的局域網 ip:port 地址。

也就是說,服務器能告訴客戶端它看到的客戶端的 ip:port 是什麼。因此,只要將這個信息以某種方式告訴通信對端(peer),後者就知道該和哪個地址建連了!這樣就又簡化爲前面的防火牆穿透問題了。

本質上這就是 STUN 協議的工作原理,如下圖所示:

The STUN protocol has a bunch more stuff in it — there’s a way of obfuscating the ip:port in the response to stop really broken NATs from mangling the packet’s payload, and a whole authentication mechanism that only really gets used by TURN and ICE, sibling protocols to STUN that we’ll talk about in a bit. We can ignore all of that stuff for address discovery.

4.2 爲什麼 NAT 穿透邏輯和主協議要共享同一個 socket

理解了 STUN 原理,也就能理解爲什麼我們在文章開頭說,如果 要實現自己的 NAT 穿透邏輯和主協議,就必須讓二者共享同一個 socket:

  1. 每個 socket 在 NAT 設備上都對應一個映射關係(私網地址 -> 公網地址),

  2. STUN 服務器只是輔助穿透的基礎設施,

  3. 與 STUN 服務器通信之後,在 NAT 及防火牆設備上打開了一個連接,允許入向包進來(回憶前面內容, 只要目的地址對,UDP 包就能進來,不管這些包是不是從 STUN 服務器來的),

  4. 因此,接下來只要將這個地址告訴我們的通信對端(peer),讓它往這個地址發包,就能實現穿透了。

4.3 STUN 的問題:不能穿透所有 NAT 設備(例如企業級 NAT 網關)

有了 STUN,我們的穿透目的似乎已經實現了:每臺機器都通過 STUN 來獲取自己的私網 socket 對應的公網 ip:port,然後把這個信息告訴對端,然後兩端 同時發起穿透防火牆的嘗試,後面的過程就和上一節介紹的防火牆穿透一樣了,對嗎?

答案是:看情況。某些情況下確實如此,但有些情況下卻不行。通常來說,

NAT 設備的說明書上越強調它的安全性,STUN 方式失敗的可能性就越高。(但注意,從實際意義上來說, NAT 設備在任何方面都並不會增強網絡的安全性,但這不是本文重點,因此不展開。)

4.4 重新審視 STUN 的前提

再次審視前面關於 STUN 的假設:當 STUN 服務器告訴客戶端在公網看來它的地址是 2.2.2.2:4242 時,那所有目的地址是 2.2.2.2:4242 的包就都能穿透防火牆到達該客戶端。

這也正是問題所在:這一點並不總是成立。

in theory, there are also NAT devices that are super relaxed, and don’t ship with stateful firewall stuff at all. In those, you don’t even need simultaneous transmission, the STUN request gives you an internet ip:port that anyone can connect to with no further ceremony. If such devices do still exist, they’re increasingly rare.

5 中場補課:NAT 正式術語

知道 NAT 設備的行爲並不是完全一樣之後,我們來引入一些正式術語。

5.1 早期術語

如果之前接觸過 NAT 穿透,可能會聽說過下面這些名詞:

這些都是 NAT 穿透領域的早期術語。

但其實這些術語相當讓人困惑。我每次都要 查一下 Restricted Cone NAT 是什麼意思。從實際經驗來看,我並不是唯一對此感到困惑的人。例如,如今互聯網上將 “easy” NAT 歸類爲 Full Cone,而實際上它們更應該歸類爲 Port-Restricted Cone。

5.2 近期研究與新術語

最近的一些研究和 RFC 已經提出了一些更準確的術語。

前面提到的所謂 "easy" 和 "hard" NAT,只在一個維度有不同:NAT 映射是否考慮到目的地址信息。RFC 4787 中,

5.3 老的 cone 類型劃分

你可能會有疑問:根據是否依賴 endpoint 這一條件,只能組合出兩種可能,那爲什麼傳 統分類中會有四種 cone 類型呢?答案是 cone 包含了兩個正交維度的 NAT 行爲:

因此最終組合如下:

分解到這種程度之後就可以看出,cone 類型對 NAT 穿透場景來說並沒有什麼意義。我們關心的只有一點:是否是 Symmetric —— 換句話說,一個 NAT 設備是 EIM 還是 EDM 類型的。

5.4 針對 NAT 穿透場景:簡化 NAT 分類

以上討論可知,雖然理解防火牆的具體行爲很重要,但對於編寫 NAT 穿透代碼來說,這一點並不重要。我們的兩端同時發包方式(simultaneous transmission trick)能 有效穿透以上三種類型的防火牆。在真實場景中, 我們主要在處理的是 IP-and-port endpoint-dependent 防火牆。

因此,對於實際 NAT 穿透實現,我們可以將以上分類簡化成:

5.5 更多 NAT 規範(RFC)

想了解更多新的 NAT 術語,可參考

如果自己實現 NAT,那應該(should)遵循這些 RFC 的規範,這樣才能使你的 NAT 行爲符合業界慣例,與其他廠商的設備或軟件良好兼容。

6 穿透 NAT + 防火牆:STUN 不可用時,fallback 到中繼模式

6.1 問題回顧與保底方式(中繼)

補完基礎知識(尤其是定義了什麼是 hard NAT)之後,回到我們的 NAT 穿透主題。

如果能直連,那顯然沒必要用中繼方式;但如果無法直連,而中繼路徑又非常接近雙方直連的真實路徑,並且帶寬足夠大,那中 繼方式並不會明顯降低通信質量。延遲肯定會增加一點,帶寬會佔用一些,但 相比完全連接不上,還是更能讓用戶接受的。不過要注意:我們只有在無法直連時纔會選擇中繼方式。實際場景中,

  1. 對於大部分網絡,我們都能通過前面介紹的方式實現直連,

  2. 剩下的長尾用中繼方式來解決,並不算一個很糟的方式。

此外,某些網絡會阻止 NAT 穿透,其影響比這種 hard NAT 大多了。例如,我們觀察到 UC Berkeley guest WiFi 禁止除 DNS 流量之外的所有 outbound UDP 流量。不管用什麼 NAT 黑科技,都無法繞過這個攔截。因此我們終歸還是需要一些可靠的 fallback 機制。

6.2 中繼協議:TURN、DERP

有多種中繼實現方式。

  1. TURN (Traversal Using Relays around NAT):經典方式,核心理念是用戶(人)先去公網上的 TURN 服務器認證,成功後後者會告訴你:“我已經爲你分配了 ip:port,接下來將爲你中繼流量”,然後將這個 ip:port 地址告訴對方,讓它去連接這個地址,接下去就是非常簡單的客戶端 / 服務器通信模型了。Tailscale 並不使用 TURN。這種協議用起來並不是很好,而且與 STUN 不同, 它沒有真正的交互性,因爲互聯網上並沒有公開的 TURN 服務器。

  2. DERP (Detoured Encrypted Routing Protocol)

這是我們創建的一個協議,DERP,

前面也簡單提到過,DERP 既是我們在 NAT 穿透失敗時的保底通信方式(此時的角色 與 TURN 類似),也是在其他一些場景下幫助我們完成 NAT 穿透的旁路信道。換句話說,它既是我們的保底方式,也是有更好的穿透鏈路時,幫助我們進行連接升 級(upgrade to a peer-to-peer connection)的基礎設施。

6.3 小結

有了 “中繼” 這種保底方式之後,我們穿透的成功率大大增加了。如果此時不再閱讀本文接下來的內容,而是把上面介紹的穿透方式都實現了,我預計:

7 穿透 NAT + 防火牆:企業級改進

如果你並不滿足於 “足夠好”,那我們可以做的事情還有很多!

本節將介紹一些五花八門的 tricks,在某些特殊場景下會幫到我們。單獨使用這項技術都 無法解決 NAT 穿透問題,但將它們巧妙地組合起來,我們能更加接近 100% 的穿透成功率。

7.1 穿透 hard NAT:暴力端口掃描

回憶 hard NAT 中遇到的問題,如下圖所示,關鍵問題是:easy NAT 不知道該往 hard NAT 方的哪個 ip:port 發包。

但必須要往正確的 ip:port 發包,才能穿透防火牆,實現雙向互通。怎麼辦呢?

  1. 首先,我們能知道 hard NAT 的一些 ip:port,因爲我們有 STUN 服務器。

這裏先假設我們獲得的這些 IP 地址都是正確的(這一點並不總是成立,但這裏先這麼假 設。而實際上,大部分情況下這一點都是成立的,如果對此有興趣,可以參考 REQ-2 in RFC 4787)。

  1. IP 地址確定了,剩下的就是端口了。總共有 65535 中可能,我們能遍歷這個端口範圍嗎?

如果發包速度是 100 packets/s,那最壞情況下,需要 10 分鐘來找到正確的端口。還是那句話,這雖然不是最優的,但總比連不上好。

這很像是端口掃描(事實上,確實是),實際中可能會觸發對方的網絡入侵檢測軟件。

7.2 基於生日悖論改進暴力掃描:hard side 多開端口 + easy side 隨機探測

利用 birthday paradox 算法, 我們能對端口掃描進行改進。

這裏省去算法的數學模型,如果你對實現幹興趣,可以看看我寫的 python calculator。計算過程是 “經典” 生日悖論的一個小變種。下面是隨着 easy side random probe 次數(假設 hard size 256 個端口)的變化,兩邊打開的端口有重合(即通信成功)的概率:

根據以上結果,如果還是假設 100 ports/s 這樣相當溫和的探測速率,那 2 秒鐘就有約 50% 的成功概率。即使非常不走運,我們仍然能在 20s 時幾乎 100% 穿透成功,而此時只探測了總端口空間的 4%。

非常好!雖然這種 hard NAT 給我們帶來了嚴重的穿透延遲,但最終結果仍然是成功的。那麼,如果是兩個 hard NAT,我們還能處理嗎?

7.3 雙 hard NAT 場景

這種情況下仍然可以用前面的 多端口 + 隨機探測 方式,但成功概率要低很多了:

這裏我們也不就具體計算展開,只告訴結果:仍然假設目的端打開 256 個端口,從源端發起 2048 次(20 秒), 成功的概率是:0.01%。

如果你之前學過生日悖論,就並不會對這個結果感到驚訝。理論上來說,

更糟糕的是,如果去看常見的辦公網路由器,你會震驚於它的 active session low limit 有多麼低。例如,一臺 Juniper SRX 300 最多支持 64,000 active sessions。也就是說,

7.4 控制端口映射(port mapping)過程:UPnP/NAT-PMP/PCP 協議

如果我們能讓 NAT 設備的行爲簡單點,不要把事情搞這麼複雜,那建 立連接(穿透)就會簡單很多。真有這樣的好事嗎?還真有,有專門的一種協議叫 端口映射協議(port mapping protocols)。通過這種協議禁用掉前面 遇到的那些亂七八糟的東西之後,我們將得到一個非常簡單的 “請求 - 響應”。

下面是三個具體的端口映射協議:

  1. UPnP IGD (Universal Plug’n’Play Internet Gateway Device)

最老的端口控制協議, 誕生於 1990s 晚期,因此使用了很多上世紀 90 年代的技術 (XML、SOAP、multicast HTTP over UDP —— 對,HTTP over UDP ),而且很難準確和安全地實現這個協議。但以前很多路由器都內置了 UPnP 協議, 現在仍然很多。

請求和響應:

“你好,請將我的 lan-ip:port 轉發到公網(WAN)”, “好的,我已經爲你分配了一個公網映射 wan-ip:port ”。

  1. NAT-PMP

UPnP IGD 出來幾年之後,Apple 推出了一個功能類似的協議,名爲 NAT-PMP (NAT Port Mapping Protocol)。

但與 UPnP 不同,這個協議只做端口轉發,不管是在客戶端還是服務端,實現起來都非常簡單。

  1. PCP

稍後一點,又出現了 NAT-PMP v2 版,並起了個新名字 PCP (Port Control Protocol)。

因此要更好地實現穿透,可以

  1. 先判斷本地的默認網關上是否啓用了 UPnP IGD, NAT-PMP and PCP, 如果探測發現其中任何一種協議有響應,我們就申請一個公網端口映射,

  2. 可以將這理解爲一個加強版 STUN:我們不僅能發現自己的公網 ip:port,而且能指示我們的 NAT 設備對我們的通信對端友好一些 —— 但並不是爲這個端口修改或添加防火牆規則。

  3. 接下來,任何到達我們 NAT 設備的、地址是我們申請的端口的包,都會被設備轉發到我們。

但我們不能假設這個協議一定可用:

  1. 本地 NAT 設備可能不支持這個協議;

  2. 設備支持但默認禁用了,或者沒人知道還有這麼個功能,因此從來沒開過;

  3. 安全策略要求關閉這個特性。

這一點非常常見,因爲 UPnP 協議曾曝出一些高危漏洞(後面都修復了,因此如果是較新的設備,可以安全地使用 UPnP —— 如果實現沒問題)。不幸的是,某些設備的配置中,UPnP, NAT-PMP,PCP 是放在一個開關裏的(可能 統稱爲 “UPnP” 功能),一開全開,一關全關。因此如果有人擔心 UPnP 的安全性,他連另 外兩個也用不了。

最後,終歸來說,只要這種協議可用,就能有效地減少一次 NAT,大大方便建連過程。但接下來看一些不常見的場景。

7.5 多 NAT 協商(Negotiating numerous NATs)

目前爲止,我們看到的客戶端和服務端都各只有一個 NAT 設備。如果有多個 NAT 設備會 怎麼樣?例如下面這種拓撲:

這個例子比較簡單,不會給穿透帶來太大問題。包從客戶端 A 經過多次 NAT 到達公網的過程,與前面分析的穿過多層有狀態防火牆是一樣的:

具體來說,真正有影響的是端口轉發協議。

客戶端使用這種協議分配端口時,爲我們分配端口的是最靠近客戶端的這層 NAT 設備;而我們期望的是讓最離客戶端最遠的那層 NAT 來分配,否則我們得到的就是一個網絡中間層分配的 ip:port,對端是用不了的;不幸的是,這幾種協議都不能遞歸地告訴我們下一層 NAT 設備是多少 —— 雖然可以用 traceroute 之類的工具來探測網絡路徑,再加上 猜路上的設備是不是 NAT 設備(嘗試發送 NAT 請求) —— 但這個就看運氣了。這就是爲什麼互聯網上充斥着大量的文章說 double-NAT 有多糟糕,以 及警告用戶爲保持後向兼容不要使用 double-NAT。但實際上,double-NAT 對於絕大部分 互聯網應用來說都是不可見的(透明的),因爲大部分應用並不需要主動地做這種 NAT 穿 透。

但我也絕不是在建議你在自己的網絡中設置 double-NAT。

  1. 破壞了端口映射協議之後,某些視頻遊戲的多人(multiplayer)模式就會無法使用,

  2. 也可能會使你的 IPv6 網絡無法派上用場,後者是不用 NAT 就能雙向直連的一個好方案。

但如果 double-NAT 並不是你能控制的,那除了不能用到這種端口映射協議之外,其他大部分東西都是不受影響的。

double-NAT 的故事到這裏就結束了嗎?—— 並沒有,而且更大型的 double-NAT 場景將展現在我們面前。

7.6 運營商級 NAT 帶來的問題

即使用 NAT 來解決 IPv4 地址不夠的問題,地址仍然是不夠用的,ISP(互聯網服務提供商) 顯然 無法爲每個家庭都分配一個公網 IP 地址。那怎麼解決這個問題呢?ISP 的做法是不夠了就再嵌套一層 NAT:

  1. 家用路由器將你的客戶端 SNAT 到一個 “intermediate” IP 然後發送到運營商網絡,

  2. ISP’s network 中的 NAT 設備再將這些 intermediate IPs 映射到少量的公網 IP。後面這種 NAT 就稱爲 “運營商級 NAT”(carrier-grade NAT,或稱電信級 NAT),縮寫 CGNAT。如下圖所示:

CGNAT 對 NAT 穿透來說是一個大麻煩。

在此之前,辦公網用戶要快速實現 NAT 穿透,只需在他們的路由器上手動設置端口映射就行了。但有了 CGNAT 之後就不管用了,因爲你無法控制運營商的 CGNAT!好消息是:這其實是 double-NAT 的一個小變種,因此前面介紹的解決方式大部分還仍然是適用的。某些東西可能會無法按預期工作,但只要肯給 ISP 交錢,這些也都能解決。除了 port mapping protocols,其他我們已經介紹的所有東西在 CGNAT 裏都是適用的。

新挑戰:同一 CGNAT 側直連,STUN 不可用

但我們確實遇到了一個新挑戰:如何直連兩個在同一 CGNAT 但不同家用路由器中的對端呢?如下圖所示:

在這種情況下,STUN 就無法正常工作了:STUN 看到的是客戶端在公網(CGNAT 後面)看到的地址, 而我們想獲得的是在 “middle network” 中的 ip:port,這纔是對端真正需要的地址,

解決方案:如果端口映射協議能用:一端做端口映射

怎麼辦呢?

如果你想到了端口映射協議,那恭喜,答對了!如果 peer 中任何一個 NAT 支持端口映射協議, 對我們就能實現穿透,因爲它分配的 ip:port 正是對端所需要的信息。

這裏諷刺的是:double-NAT(指 CGNAT)破壞了端口映射協議,但在這裏又救了我們!當然,我們假設這些協議一定可用,因爲 CGNAT ISP 傾向於在它們的家用路由器側關閉 這些功能,已避免軟件得到 “錯誤的” 結果,產生混淆。

解決方案:如果端口映射協議不能用:NAT hairpin 模式

如果不走運,NAT 上沒有端口映射功能怎麼辦?

讓我們回到基於 STUN 的技術,看會發生什麼。兩端在 CGNAT 的同一側,假設 STUN 告訴我們 A 的地址是 2.2.2.2:1234,B 的地址是 2.2.2.2:5678。

那麼接下來的問題是:如果 A 向 2.2.2.2:5678 發包會怎麼樣?期望的 CGNAT 行爲是:

  1. 執行 A 的 NAT 映射規則,即對 2.2.2.2:1234 -> 2.2.2.2:5678 進行 SNAT。

  2. 注意到目的地址 2.2.2.2:5678 匹配到的是 B 的入向 NAT 映射,因此接着對這個包執行 DNAT,將目的 IP 改成 B 的私有地址。

  3. 通過 CGNAT 的 internal 接口(而不是 public 接口,對應公網)將包發給 B。這種 NAT 行爲有個專門的術語,叫 hairpinning(直譯爲髮卡,意思 是像髮卡一樣,沿着一邊上去,然後從另一邊繞回來),

大家應該猜到的一個事實是:不是所以 NAT 都支持 hairpin 模式。實際上,大量 well-behaved NAT 設備都不支持 hairpin 模式,

  1. 在大部分情況下,這個特性對我們的 NAT 穿透目的來說都是無所謂的,因爲我們期望中 兩個 LAN NAT 設備會直接通信,不會再向上繞到它們的默認網關 CGNAT 來解決這個問題。

Hairpin 特性可有可無這件事有點遺憾,這可能也是爲什麼 hairpin 功能經常 broken 的原因。

  1. 一旦必須涉及到 CGNAT,那 hairpinning 對連接性來說就至關重要了。

Hairpinning 使內網連接的行爲與公網連接的行爲完成一致,因此我們無需關心目的 地址類型,也不用知曉自己是否在一臺 CGNAT 後面。

如果 hairpinning 和 port mapping protocols 都不可用,那隻能降級到中繼模式了。

7.7 全 IPv6 網絡:理想之地,但並非問題全無

行文至此,一些讀者可能已經對着屏幕咆哮:不要再用 IPv4 了!花這麼多時間精力解決這些沒意義的東西,還不如直接換成 IPv6!

簡單來說,是的,這也正是 IPv6 能做的事情。但是,也只說對了一半:在理想的全 IPv6 世界中,所有這些東西會變得更加簡單,但我們面臨的問題並不會完全消失 —— 因爲有狀態防火牆仍然還是存在的。

因此,我們仍然會用到

  1. 本文最開始介紹的防火牆穿透技術,以及幫助我們獲取自己的公網 ip:port 信息的旁路信道

  2. 仍然需要在某些場景下 fallback 到中繼模式,例如 fallback 到最通用的 HTTP 中繼 協議,以繞過某些網絡禁止 outbound UDP 的問題。

但我們現在可以拋棄 STUN、生日悖論、端口映射協議、hairpin 等等東西了。這是一個好消息!

全球 IPv4/IPv6 部署現狀

另一個更加嚴峻的現實問題是:當前並不是一個全 IPv6 世界。目前世界上

不幸的是,這意味着,IPv6 無法作爲我們的解決方案。就目前來說,它只是我們的工具箱中的一個備選。對於某些 peer 來說,它簡直是完美工 具,但對其他 peer 來說,它是用不了的。如果目標是 “任何情況下都能穿透(連接) 成功”,那我們就仍然需要 IPv4+NAT 那些東西。

新場景:NAT64/DNS64

IPv4/IPv6 共存也引出了一個新的場景:NAT64 設備。

前面介紹的都是 NAT44 設備:它們將一個 IPv4 地址轉換成另一 IPv4 地址。NAT64 從名字可以看出,是將一個內側 IPv6 地址轉換成一個外側 IPv4 地址。利用 DNS64 設備,我們能將 IPv4 DNS 應答給 IPv6 網絡,這樣對終端來說,它看到的就是一個 全 IPv6 網絡,而仍然能訪問 IPv4 公網。

Incidentally, you can extend this naming scheme indefinitely. There have been some experiments with NAT46; you could deploy NAT66 if you enjoy chaos; and some RFCs use NAT444 for carrier-grade NAT.

如果需要處理 DNS 問題,那這種方式工作良好。例如,如果連接到 google.com,將這個域名解析成 IP 地址的過程會涉及到 DNS64 設備,它又會進一步 involve NAT64 設備,但後一步對用戶來說是無感知的。

但對於 NAT 和防火牆穿透來說,我們會關心每個具體的 IP 地址和端口。

解決方案:CLAT (Customer-side transLATor)

如果設備支持 CLAT (Customer-side translator — from Customer XLAT),那我們就很幸運:

解決方案:CLAT 不存在時,手動穿透 NAT64 設備

  1. 首先檢測是否存在 NAT64+DNS64。

方法很簡單:向 ipv4only.arpa. 發送一個 DNS 請求。這個域名會解析 到一個已知的、固定的 IPv4 地址,而且是純 IPv4 地址。如果得到的 是一個 IPv6 地址,就可以判斷有 DNS64 服務器做了轉換,而它必然會用到 NAT64。這樣 就能判斷出 NAT64 的前綴是多少。

  1. 此後,要向 IPv4 地址發包時,發送格式爲 {NAT64 prefix + IPv4 address} 的 IPv6 包。類似地,收到來源格式爲 {NAT64 prefix + IPv4 address} 的包時,就是 IPv4 流量。

  2. 接下來,通過 NAT64 網絡與 STUN 通信來獲取自己在 NAT64 上的公網 ip:port,接 下來就回到經典的 NAT 穿透問題了 —— 除了需要多做一點點事情。

幸運的是,如今的大部分 v6-only 網絡都是移動運營商網絡,而幾乎所有手機都支持 CLAT。運營 v6-only 網絡的 ISPs 會在他們給你的路由器上部署 CLAT,因此最後你其實不需要做什麼事情。但如果想實現 100% 穿透,就需要解決這種邊邊角角的問題,即必須顯式支持從 v6-only 網絡連接 v4-only 對端。

7.8 將所有解決方式集成到 ICE 協議

針對具體場景,該選擇哪種穿透方式?至此,我們的 NAT 穿透之旅終於快結束了。我們已經覆蓋了有狀態防火牆、簡單和高級 NAT、IPv4 和 IPv6。只要將以上解決方式都實現了,NAT 穿透的目的就達到了!

但是,

早期 NAT 穿透比較簡單,能讓我們精確判斷出 peer 之間的路徑特點,然後針對性地採用相應的解決方式。但後面,網絡工程師和 NAT 設備開發工程師引入了一些新理念,給路徑判斷造成很大困難。因此 我們需要簡化客戶端側的思考(判斷邏輯)。

這就要提到 Interactive Connectivity Establishment (ICE,交換式連接建立) 協議了。與 STUN/TURN 類似,ICE 來自電信領域,因此其 RFC 充滿了 SIP、SDP、信令會話、撥號等等電話術語。但如果忽略這些領域術語,我們會看到它描述了一個極其優雅的判斷最佳連接路徑的算法。

真的?這個算法是:每種方法都試一遍,然後選擇最佳的那個方法。就是這個算法,驚喜嗎?

來更深入地看一下這個算法。

ICE (Interactive Connectivity Establishment) 算法

這裏的討論不會嚴格遵循 ICE spec,因此如果是在自己實現一個可互操作的 ICE 客戶端,應該通讀 RFC 8445, 根據它的描述來實現。這裏忽略所有電信術語,只關注核心的算法邏輯, 並提供幾個在 ICE 規範允許範圍的靈活建議。

  1. 爲實現和某個 peer 的通信,首先需要確定我們自己用的(客戶端側)這個 socket 的地址, 這是一個列表,至少應該包括:
  1. 通過旁路信道與 peer 互換這個列表。兩邊都拿到對方的列表後,就開始互相探測對方提供的地址。列表中地址沒有優先級,也就是說,如果對方給的了 15 個地址,那我們應該把這 15 個地址都探測一遍。

這些探測包有兩個目的:

  1. 最後,一小會兒之後,從可用的備選地址中(根據某些條件)選擇 “最佳” 的那個,任務完成!

這個算法的優美之處在於:只要選擇最佳線路(地址)的算法是正確的,那就總能獲得最佳路徑。

ICE spec 將協議組織爲兩個階段:

  1. 探測階段

  2. 通信階段

但不一定要嚴格遵循這兩個步驟的順序。在 Tailscale,

我們發現更優的路徑之後就會自動切換過去, 所有的連接都是先選擇 DERP 模式(中繼模式)。這意味着連接立即就能建立(優先級最低但 100% 能成功的模式),用戶不用任何等待, 然後並行進行路徑發現。通常幾秒鐘之後,我們就能發現一條更優路徑,然後將現有連接透明升級(upgrade)過去。但有一點需要關心:非對稱路徑。ICE 花了一些精力來保證通信雙方選擇的是相同的網絡 路徑,這樣才能保證這條路徑上有雙向流量,能保持防火牆和 NAT 設備的連接一直處於 open 狀態。自己實現的話,其實並不需要花同樣大的精力來實現這個保證,但需要確保你所有使用的所有路徑上,都有雙向流量。這個目標就很簡單了,只需要定期在所有已使用的路徑上發 ping/pong 就行了。

健壯性與降級

要實現健壯性,還需要檢測當前已選擇的路徑是否已經失敗了(例如,NAT 設備維護清掉了所有狀態), 如果失敗了就要降級(downgrade)到其他路徑。這裏有兩種方式:

  1. 持續探測所有路徑,維護一個降級時會用的備用地址列表;

  2. 直接降級到保底的中繼模式,然後再通過路徑探測升級到更好的路徑。

考慮到發生降級的概率是非常小的,因此這種方式可能是更經濟的。

7.9 安全

最後需要提到安全。

本文的所有內容都假設:我們使用的上層協議已經有了自己的安全機制( 例如 QUIC 協議有 TLS 證書,WireGuard 協議有自己的公鑰)。如果還沒有安全機制,那顯然是要立即補上的。一旦動態切換路徑,基於 IP 的安全機制就是無用的了 (IP 協議最開始就沒怎麼考慮安全性),至少要有端到端的認證。

8 結束語

我們終於完成了 NAT 穿透的目標!

如果實現了以上提到的所有技術,你將得到一個業內領先的 NAT 穿透軟件,能在絕大多數場景下實現端到端直連。如果直連不了,還可以降級到保底的中繼模式(對於長尾來說只能靠中繼了)。

但這些工作相當複雜!其中一些問題研究起來很有意思,但很難做到完全正確,尤其是那些 非常邊邊角角的場景,真正出現的概率極小,但解決它們所需花費的經歷又極大。不過,這種工作只需要做一次,一旦解決了,你就具備了某種超級能力:探索令人激動的、相對還比較嶄新的端到端應用(peer-to-peer applications)世界。

8.1 跨公網 端到端直連

去中心化軟件領域中的許多有趣想法,簡化之後其實都變成了 跨過公網(互聯網)實現端到端直連 這一問題,開始時可能覺得很簡單,但真正做才 發現比想象中難多了。現在知道如何解決這個問題了,動手開做吧!

8.2 結束語

實現健壯的 NAT 穿透需要下列基礎:

然後需要:

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/AsYYoic0T5zmzTskSCsA1A