NAT穿透如何運作(Tailscale)
譯者序
本文翻譯自 2020 年的一篇英文部落格文章:How NAT Traversal Works。
想像這樣一個問題:在北京和上海各有一台區域網路的機器(例如一台是家中的桌上型電腦,另一台是連接到星巴克 Wi-Fi 的筆記型電腦),兩者皆使用私有 IP 位址,但都能存取公眾網路,如何讓這兩台機器直接通訊呢?
既然兩者都能存取公眾網路,最簡單的方式當然是在公眾網路上架設一台中繼伺服器:兩台機器分別連接到中繼服務,由後者完成雙向轉發。然而,這種方式顯然有顯著的效能開銷,且中繼伺服器容易成為瓶頸。
有沒有方法可以不透過中繼,讓兩台機器直接通訊呢?
若具備一定的網路和協定基礎,就會明白這是可行的。Tailscale 的這篇史詩級長文由淺入深地展示了這種「可能性」。若能完整實現本文介紹的技術,您將獲得一套企業級的 NAT / 防火牆穿透工具。此外,如作者所述,去中心化軟體領域中的許多有趣構想,簡化後其實都歸結為跨越公眾網路(網際網路)實現端到端直連的問題,因此本文的意義不僅限於 NAT 穿透本身。
由於譯者能力有限,本文難免存在遺漏或錯誤。如有疑問,請參閱原文。
以下為譯文。
- 1.1 背景:IPv4 位址短缺,引入 NAT
- 1.2 需求:兩台經過 NAT 的機器建立點對點連線
- 1.3 方案:NAT 穿透
- 1.4 挑戰:具狀態防火牆與 NAT 設備
- 2.1 具狀態防火牆
- 2.2 防火牆朝向(face-off)與穿透方案
- 2.3 關於穿透防火牆的一些思考
- 3.1 NAT 設備與具狀態防火牆
- 3.2 NAT 穿透與 SNAT/DNAT
- 3.3 SNAT 的意義:解決 IPv4 位址短缺問題
- 3.4 SNAT 流程:以家用路由器為例
- 3.5 SNAT 對穿透帶來的挑戰
- 4.1 STUN 原理
- 4.2 為何 NAT 穿透邏輯與主協定需共用同一 socket
- 4.3 STUN 的問題:無法穿透所有 NAT 設備(例如企業級 NAT 閘道器)
- 4.4 重新審視 STUN 的前提
- 5.1 早期術語
- 5.2 近期研究與新術語
- 5.3 舊的 cone 類型劃分
- 5.4 針對 NAT 穿透場景:簡化 NAT 分類
- 5.5 更多 NAT 規範(RFC)
- 6.1 問題回顧與保底方式(中繼)
- 6.2 中繼協定:TURN、DERP
- 6.3 小結
- 7.1 穿透硬 NAT:暴力埠掃描
- 7.2 基於生日悖論改進暴力掃描:硬端多開埠 + 易端隨機探測
- 7.3 雙硬 NAT 場景
- 7.4 控制埠映射(port mapping)流程:UPnP/NAT-PMP/PCP 協定
- 7.5 多 NAT 協商(Negotiating numerous NATs)
- 7.6 營運商級 NAT 帶來的問題
- 7.7 全 IPv6 網路:理想之地,但非全無問題
- 7.8 將所有解決方案整合至 ICE 協定
- 7.9 安全性
- 8.1 跨越公眾網路 端到端直連
- 8.2 結語之 TL;DR
在上一篇文章 How Tailscale Works 中,我們已用較長篇幅介紹了 Tailscale 的運作方式。但其中未詳細描述我們如何穿透 NAT 設備,從而實現終端設備直連——不論這些終端之間有多少設備(防火牆、NAT 等)或有哪些設備。本文試圖補足這部分內容。
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 利用這些技術在瀏覽器之間實現點對點語音、視訊和資料傳輸,
- VoIP 電話和部分電玩亦使用類似機制,雖非所有情況皆成功。
接下來,本文將以一般性角度討論這些技術,並在適當處以 Tailscale 及其他案例為例。
1.3 方案:NAT 穿透
1.3.1 兩個必要前提:UDP + 能直接控制 socket
若欲設計自己的協定以實現 NAT 穿透,必須滿足以下兩個條件:
協定應基於 UDP。
理論上 TCP 亦可實現,但這會為本已複雜的問題再增添一層複雜性,甚至可能需要客製化核心——視您欲實現的程度而定。本文後續將聚焦於 UDP。
若考慮 TCP 是為了在 NAT 穿透時獲得面向串流的連線(stream-oriented connection),可考慮使用 QUIC 替代。QUIC 建構於 UDP 之上,因此我們可專注於 UDP NAT 穿透,同時仍能獲得優良的串流協定(stream protocol)。
對收發封包的 socket 具直接控制權。
例如,根據經驗,無法基於既有網路函式庫實現 NAT 穿透,因我們必須在使用的「主要」協定之外,額外收發資料封包。
某些協定(例如 WebRTC)將 NAT 穿透與其他部分緊密整合。但若您在建構自己的協定,建議將 NAT 穿透視為獨立實體,與主協定並行運作,兩者僅共享 socket 的關係,如下圖所示,這將帶來極大助益:
1.3.2 保底方式:中繼
在某些場景中,直接存取 socket 的條件可能難以滿足。
退而求其次的方式是設置一個本地代理(local proxy),主協定與此代理通訊,由後者完成 NAT 穿透,將封包中繼(relay)至對端。此方式增加了一層額外的間接層,但優點為:
- 仍能實現 NAT 穿透,
- 無需對既有應用程式進行任何修改。
1.4 挑戰:具狀態防火牆與 NAT 設備
有了上述基礎,下面從最基本的原則開始,逐步探討如何實現企業級的 NAT 穿透方案。
我們的目標是:在兩台設備之間透過 UDP 實現雙向通訊。有了此基礎,上層的其他協定(WireGuard、QUIC、WebRTC 等)便能實現更進階的功能。
然而,即便是這看似最基本的功能,實現時也需克服兩個障礙:
- 具狀態防火牆
- NAT 設備
具狀態防火牆在上述兩問題中相對較易解決。事實上,大多數 NAT 設備皆內建具狀態防火牆,因此解決第二問題必須先處理第一問題。
具狀態防火牆有許多類型,您可能見過以下幾種:
- Windows Defender 防火牆
- Ubuntu 的 ufw(使用 iptables/nftables)
- BSD/macOS
pf
- AWS 安全群組(Security Groups)
2.1 具狀態防火牆
2.1.1 預設行為(策略)
上述防火牆的配置皆極具彈性,但大多數預設行為如下:
- 允許所有出向連線(allows all “outbound” connections)
- 禁止所有入向連線(blocks all “inbound” connections)
可能存在少數例外規則,例如允許入向 SSH。
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 不可直接存取
但若兩個「客戶端」欲直連,上述方式便不可行,此時兩端的防火牆相向而立,如下圖所示:
根據前述討論,這意味著:兩端需同時發起連線請求,但也意味著兩端皆無法發起有效請求,因對方需先發起請求,才能在其防火牆上開啟一條縫讓我們進入!如何破解此問題?一種方式是讓使用者重新配置一端或兩端的防火牆,開啟一個埠,允許對方流量進入。
- 這顯然對使用者不友好,且在如 Tailscale 的網狀網路中擴展性不佳,在網狀網路中,我們假設對端會以某種粒度在公眾網路上移動。
- 此外,在許多情況下,使用者無防火牆控制權限:例如在咖啡館或機場,連線的路由器不受您控制(否則您可能會有麻煩)。
因此,我們需尋找一種無需重新配置防火牆的方式。
穿透方案:兩端同時主動建連,在本地防火牆為對方開啟一個洞
解決思路是重新審視前述具狀態防火牆規則:
- 對 UDP,其規則(邏輯)為:封包必須先發出才能進入(packets must flow out before packets can flow back in)。
- 注意,除需滿足封包的 IP 與埠匹配外,無需要求封包必須相關(related)。換言之,只要某些封包帶有正確的來源與目的位址發出,任何看似回應的封包皆會被防火牆放行——即使對端未收到您發出的封包。
因此,要穿透這些具狀態防火牆,我們僅需共享一些資訊:讓兩端提前知曉對方使用的 ip:port
:
- 手動靜態配置是一種方式,但顯然擴展性不佳;
- 我們開發了一個 協調伺服器,以靈活、安全的方式同步
ip:port
資訊。
取得對方的 ip:port
資訊後,兩端開始向對方發送 UDP 封包。在此過程中,我們預期部分封包將被丟棄。因此,雙方必須接受某些封包會遺失的事實,若為重要資訊,您必須自行準備重傳。對 UDP 而言,封包遺失是可接受的,但在此尤需特別接受。
來看具體建連(穿透)流程:
如圖所示,筆記型電腦發出的第一個封包
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
的回應封包進入。接著,第一個
7.7.7.7:5678 -> 2.2.2.2:1234
封包穿過其自身防火牆抵達公眾網路。抵達客戶端時,Windows Defender 認為這是先前出向封包的回應封包,因此放行! 此外,右側防火牆此時也記錄:應放行
2.2.2.2:1234 -> 7.7.7.7:5678
的封包。筆記型電腦收到伺服器發來的封包後,發送一個回應封包。此封包穿過 Windows Defender 防火牆與伺服器防火牆(因其為對伺服器發送封包的回應),抵達伺服器。
成功!我們建立了穿透兩個相向防火牆的雙向通訊連線。乍看之下,此任務似乎不可能完成。
2.3 關於穿透防火牆的一些思考
穿透防火牆並非總是如此輕鬆,有時會受第三方系統的間接影響,需謹慎處理。穿透防火牆需注意什麼?重要的一點是:通訊雙方必須幾乎同時發起通訊,以在路徑上的防火牆開啟一條縫,且兩端皆保持活躍。
2.3.1 雙向主動建連:旁路通道
如何實現「同時」?一種方式是兩端不斷重試,但這顯然浪費資源。若雙方皆知何時開始建連便好了。
這聽似雞生蛋、蛋生雞的問題:雙方欲通訊,必須先提前通訊。
實際上,我們可透過旁路通道(side channel)達成此目的,且此通道無需太複雜:可容忍數秒延遲,僅需傳輸數 KB 資訊,因此即使是一台低配虛擬機,也能為數千台機器提供此類旁路通訊服務。
- 遠古時期,我曾使用 XMPP 聊天訊息作為旁路,效果極佳。
- 另一例為 WebRTC,其要求您提供自己的「信令通道」(signalling channel,此詞也暗示 WebRTC 的 IP 電話淵源),並將其配置至 WebRTC API。
- 在 Tailscale,我們的協調伺服器(coordination server)與 DERP(Detour Encrypted Routing Protocol)伺服器叢集為我們的旁路通道。
2.3.2 非活躍連線被防火牆清除
具狀態防火牆的記憶體通常有限,因此會定期清除非活躍連線(UDP 常見為 30 秒)。為保持連線存活,需定期通訊,否則防火牆會將其關閉。為避免此問題,我們:
- 可定期向對方發送封包以保持連線(keepalive),
- 或透過某種帶外方式按需重建連線。
2.3.3 問題都解決了?不,挑戰才剛開始
對防火牆穿透而言,我們無需在意路徑上有多少堵牆——只要它們是具狀態防火牆且允許出向連線,此同時發Priscilla傳輸(simultaneous transmission)機制便能穿透任意多層防火牆。這對我們極為友好,因僅需實現一個邏輯,便能適用於任何地方。
…對嗎?
其實,不完全對。此機制有效的前提是:我們能提前知曉對方的 ip:port
。這便涉及我們今日的主題:NAT,它會讓我們剛獲得的一絲滿足感瞬間消逝。
下面,進入本文正題。
3.1 NAT 設備與具狀態防火牆
可將 NAT 設備視為增強版的具狀態防火牆,儘管其增強功能在本文場景中並不受歡迎:除前述具狀態攔截/放行功能外,它們還會在資料封包經過時修改這些封包。
3.2 NAT 穿透與 SNAT/DNAT
具體而言,NAT 設備能執行某類型的網路位址轉換,例如替換來源或目的 IP 位址或埠。
- 討論連線問題與 NAT 穿透問題時,我們僅受來源 NAT(SNAT)影響。
- DNAT 不影響 NAT 穿透。
3.3 SNAT 的意義:解決 IPv4 位址短缺問題
SNAT 最常見的應用場景是將眾多設備連接到公眾網路,僅使用少數公眾 IP。例如,對消費級路由器而言,會將所有設備的(私有)IP 位址映射為單一連接到公眾網路的 IP 位址。
此方式的意義在於:需要連接到公眾網路的設備遠多於可用公眾 IP 數量(至少對 IPv4 而言,IPv6 後續討論)。NAT 使多台設備能共享同一 IP 位址,因此即使面臨 IPv4 位址短缺問題,我們仍能持續擴展網際網路規模。
3.4 SNAT 流程:以家用路由器為例
假設您的筆記型電腦連接到家中 Wi-Fi,以下探討其連接到公眾網路某伺服器時的情形:
筆記型電腦發送 UDP 封包
192.168.0.20:1234 -> 7.7.7.7:5678
。此步驟彷彿筆記型電腦擁有公眾 IP,但來源位址
192.168.0.20
為私有位址,僅於私有網路內有效,公眾網路不認,收到此類封包時不知如何回應。家用路由器登場,執行 SNAT。
封包經過路由器時,路由器發現這是一個未見過的新會話(session)。它知曉
192.168.0.20
為私有 IP,公眾網路無法對此位址回應封包,但它有解決之道:- 在其自身的公眾 IP 上挑選一個可用 UDP 埠,例如
2.2.2.2:4242
, - 建立一個 NAT 映射:
192.168.0.20:1234
<-->
2.2.2.2:4242
, - 將封包發至公眾網路,此時來源位址變為
2.2.2.2:4242
而非原先的192.168.0.20:1234
。因此伺服器看到的是轉換後的位址, - 接下來,每個符合此映射規則的封包,皆會被路由器改寫 IP 與埠。
- 在其自身的公眾 IP 上挑選一個可用 UDP 埠,例如
反向路徑類似,路由器執行相反的位址轉換,將
2.2.2.2:4242
轉回192.168.0.20:1234
。對筆記型電腦而言,它完全感知不到這正反兩次轉換流程。
此處以家用路由器為例,但辦公網路的原理相同。差異在於,辦公網路的 NAT 可能由多台設備組成(為高可用性、容量等目的),且它們擁有不只一個公眾 IP 位址可用,因此在選擇可用的公眾 ip:port
進行映射時,選擇空間更大,能支援更多客戶端。
3.5 SNAT 對穿透帶來的挑戰
現在我們遇到與前述具狀態防火牆類似的情況,但這次是 NAT 設備:通訊雙方不知對方的 ip:port
為何,因此無法主動建連,如下圖所示:
這次比具狀態防火牆更糟,嚴格來說,在雙方發送封包前,根本無法確定(自身及對方的)ip:port
資訊,因僅有出向封包經過路由器後才會產生 NAT 映射(即,可被對方連線的 ip:port
資訊)。
因此我們又回到防火牆的問題,且情況更糟:雙方皆需主動與對方建連,但又不知對方的公眾位址為何,僅在對方先發言後,我們才能取得其位址資訊。
如何破解此死鎖?這便輪到 STUN 登場。
STUN 既是對 NAT 設備行為的詳細研究,亦為協助 NAT 穿透的協定。本文主要聚焦 STUN 協定。
4.1 STUN 原理
STUN 基於一簡單觀察:當一個受 NAT 影響的客戶端存取公眾網路伺服器時,伺服器看到的是 NAT 設備的公眾 ip:port
位址,而非該 客戶端的區域網路 ip:port
位址。
換言之,伺服器能告知客戶端其看到的客戶端 ip:port
為何。因此,只要以某種方式將此資訊告知通訊對端(peer),後者便知該連線至哪個位址!這便簡化為前述的防火牆穿透問題。
本質上,這便是 STUN 協定的運作原理,如下圖所示:
- 筆記型電腦向 STUN 伺服器發送請求:「從你的角度看,我的位址是什麼?」
- STUN 伺服器回應:「我看到你的 UDP 封包從此位址而來:
ip:port
」。
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:
- 每個 socket 在 NAT 設備上對應一個映射關係(私有位址 -> 公眾位址),
- STUN 伺服器僅為輔助穿透的基礎設施,
- 與 STUN 伺服器通訊後,在 NAT 及防火牆設備上開啟一個連線,允許入向封包進入(回想前述內容,只要目的位址正確,UDP 封包皆可進入,不論這些封包是否來自 STUN 伺服器),
- 因此,接下來只要將此位址告知我們的通訊對端(peer),讓其向此位址發送封包,便能實現穿透。
4.3 STUN 的問題:無法穿透所有 NAT 設備(例如企業級 NAT 閘道器)
有了 STUN,我們的穿透目標似乎已實現:每台機器透過 STUN 取得其私有 socket 對應的公眾 ip:port
,然後將此資訊告知對端,兩端同時進行防火牆穿透嘗試,後續流程與前述防火牆穿透相同,對嗎?
答案是:視情況而定。某些情況下確實如此,但有些情況則否。通常:
- 對大多數家用路由器場景,此方式無問題;
- 但對某些企業級 NAT 閘道器,此方式無法奏效。
NAT 設備的說明書越強調其安全性,STUN 方式失敗的可能性越高。(但需注意,從實際意義上,NAT 設備在任何方面皆不增強網路安全性,但此非本文重點,故不展開。)
4.4 重新審視 STUN 的前提
再次審視前述關於 STUN 的假設:當 STUN 伺服器告知客戶端其公眾網路位址為 2.2.2.2:4242
時,所有目的位址為 2.2.2.2:4242
的封包應能穿透防火牆抵達該客戶端。
這正是問題所在:此假設並非總是成立。
某些 NAT 設備的行為與我們假設一致,其具狀態防火牆組件只要見到客戶端自行發起的出向封包,便允許相應的入向封包進入;因此,只要利用 STUN 功能,配合兩端同時進行防火牆穿透,便能打通連線;
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.其他 NAT 設備則困難得多,它會針對每個目的位址生成一條相應的映射關係。在此類設備上,若我們使用相同 socket 分別發送資料封包至
5.5.5.5:1234
與7.7.7.7:2345
,將得到2.2.2.2
上的兩個不同埠,每個目的位址對應一個。若反向封包使用的埠不正確,封包便無法通過防火牆。如下圖所示:
了解 NAT 設備行為並非完全一致後,我們來引入一些正式術語。
5.1 早期術語
若您先前接觸過 NAT 穿透,可能聽過以下名詞:
- “Full Cone”
- “Restricted Cone”
- “Port-Restricted Cone”
- “Symmetric” NATs
這些為 NAT 穿透領域的早期術語。
但這些術語頗為令人困惑。我每次皆需查詢 Restricted Cone NAT 的含義。從實際經驗看,我並非唯一感到困惑者。例如,現今網路上常將「易」NAT 歸類為 Full Cone,實際上它們更應歸為 Port-Restricted Cone。
5.2 近期研究與新術語
近期研究與 RFC 已提出更精確的術語。
- 首先,它們明確以下事實:NAT 設備的行為差異表現在多個維度,而非早期研究所稱的僅「cone」一維,因此基於「cone」分類並無太大幫助。
- 其次,新研究與新術語能更精確描述 NAT 的作為。
前述所謂 “easy” 與 “hard” NAT,僅在一維度不同:NAT 映射是否考量目的位址資訊。RFC 4787 中:
將 easy NAT 及其變種稱為「終點無關映射」(Endpoint-Independent Mapping,EIM)
然而,從程式設計師界的偉大傳統**「命名很難」**來看,EIM 一詞其實並非 100% 精確,因這種 NAT 仍依賴終點,僅依賴來源終點:每個來源
ip:port
對應一個映射——否則您的封包將與他人封包混雜,導致混亂。嚴格來說,EIM 應稱為「目的終點無關映射」(Destination Endpoint Independent Mapping,DEIM?),但此名稱過於拗口,且依慣例,終點(Endpoint)永遠指目的終點。
將 hard NAT 及其變種稱為「終點相關映射」(Endpoint-Dependent Mapping,EDM)。
EDM 中另有一子類型,依據是僅根據
dst_ip
做映射,或根據dst_ip + dst_port
做映射。對 NAT 穿透而言,此區分無關緊要:兩者皆導致 STUN 方式不可用。
5.3 舊的 cone 類型劃分
您或許疑惑:依是否依賴終點僅能組合出兩種可能,為何傳統分類有四種 cone 類型?答案是 cone 包含兩個正交維度的 NAT 行為:
- NAT 映射行為:前已介紹,
- 具狀態防火牆行為:與前者類似,亦分為與終點相關或無關兩類。
因此最終組合如下:
NAT Cone 類型
終點無關 NAT 映射 | 終點相關 NAT 映射(所有類型) | |
---|---|---|
終點無關防火牆 | Full Cone NAT | N/A* |
終點相關防火牆(僅 dst. IP) | Restricted Cone NAT | N/A* |
終點相關防火牆(dst. IP+port) | Port-Restricted Cone NAT | Symmetric NAT |
分解至此程度後可見,cone 類型對 NAT 穿透場景無甚意義。我們僅關心一點:是否為 Symmetric——換言之,一個 NAT 設備是 EIM 抑或 EDM 類型。
5.4 針對 NAT 穿透場景:簡化 NAT 分類
上述討論顯示,雖理解防火牆具體行為重要,但對編寫 NAT 穿透程式碼而言,此點不甚重要。我們的兩端同時發包方式(simultaneous transmission trick)能 有效穿透上述三類防火牆。在真實場景中,我們主要處理的是 IP-and-port 終點相關防火牆。
因此,對實際 NAT 穿透實現,我們可將上述分類簡化為:
終點無關 NAT 映射 | 終點相關 NAT 映射(僅 dst. IP) | |
---|---|---|
防火牆存在 | Easy NAT | Hard NAT |
5.5 更多 NAT 規範(RFC)
欲了解更多新 NAT 術語,可參考:
若自行實現 NAT,應遵循這些 RFC 規範,以確保您的 NAT 行為符合業界慣例,與其他廠商的設備或軟體良好相容。
6.1 問題回顧與保底方式(中繼)
補充基礎知識(特別是定義何為 hard NAT)後,回到我們的 NAT 穿透主題。
- 第 1~4 節已解決 STUN 與防火牆穿透問題,
- 但 hard NAT 對我們是重大問題,只要路徑上出現此類設備,前述方案便行不通。
準備放棄了嗎?這才進入 NAT 真正挑戰的部分:若已嘗試前述所有方式仍無法穿透,我們該如何?
- 實際上,許多 NAT 實現在此情況下會選擇放棄,向使用者回報 「無法連線」 之類的錯誤。
- 對我們而言,如此快速放棄顯然不可接受——無法解決連通性問題,Tailscale 便無存在意義。
我們的保底解決方式是:建立一個中繼連線(relay)實現雙方的無障礙通訊。但中繼方式效能不差嗎?這視情況而定:
- 若能直連,顯然無需中繼;
- 若無法直連,而中繼路徑與雙方直連的真實路徑極為接近,且頻寬足夠大,中繼方式不致顯著降低通訊品質。延遲會略增,頻寬會有些占用,但 相較完全無法連線,仍更能為使用者接受。
需注意:我們僅在無法直連時才選擇中繼方式。實際場景中:
- 對大多數網路,我們皆能以前述方式實現直連,
- 其餘情況,Tailscale 以中繼方式解決,尚非糟糕方式。
此外,某些網路會完全阻斷 NAT 穿透,其影響遠大於 hard NAT。例如,我們觀察到 UC Berkeley 訪客 Wi-Fi 禁止除 DNS 流量外的所有出向 UDP 流量。無論使用何種 NAT 黑科技,皆無法繞過此攔截。因此,我們終究需一些可靠的後備機制。
6.2 中繼協定:TURN、DERP
有多種中繼實現方式。
TURN(Traversal Using Relays around NAT):經典方式,核心理念為:
- 使用者(人)先至公眾網路上的 TURN 伺服器認證,成功後後者會告知:「我已為你分配
ip:port
,接下來將為你中繼流量」, - 將此
ip:port
位址告知對方,讓其連線至此位址,隨後為極簡單的客戶端/伺服器通訊模型。
Tailscale 不使用 TURN。此協定使用體驗不佳,且與 STUN 不同,它無真正的互動性,因網際網路上並無公開的 TURN 伺服器。
- 使用者(人)先至公眾網路上的 TURN 伺服器認證,成功後後者會告知:「我已為你分配
DERP(Detoured Encrypted Routing Protocol)
這是我們創建的協定,DERP:
- 它是一個通用目的封包中繼協定,運行於 HTTP 之上,而大多數網路皆允許 HTTP 通訊。
- 它根據目的公鑰(destination’s public key)中繼加密的流量(encrypted payloads)。
前已簡提,DERP 既是我們在 NAT 穿透失敗時的保底通訊方式(此時角色類似 TURN),亦在其他場景下作為輔助我們完成 NAT 穿透的旁路通道。換言之,它既是我們的保底方式,亦是有更好穿透路徑時,協助我們進行連線升級(upgrade to a peer-to-peer connection)的基础設施。
6.3 小結
有了「中繼」此保底方式後,我們的穿透成功率大幅提升。若此時停止閱讀本文後續內容,僅實現上述介紹的穿透方式,我預估:
- 90% 的情況下,您能實現直連穿透;
- 剩餘 10% 中,中繼方式能穿透部分(some);
這已算是一個「足夠好」的穿透實現。
若您不滿足於「足夠好」,我們可做之事尚多!
本節將介紹一些五花八門的技巧,在特定場景下頗有助益。單獨使用這些技術皆無法解決 NAT 穿透問題,但將其巧妙組合,我們能更接近 100% 的穿透成功率。
7.1 穿透硬 NAT:暴力埠掃描
回顧 hard NAT 遇到的問題,如下圖所示,關鍵問題在於:easy NAT 不知該向 hard NAT 方的哪個 ip:port
發送封包。
但必須向正確的 ip:port
發送封包,才能穿透防火牆,實現雙向互通。如何辦?
首先,我們能透過 STUN 伺服器得知 hard NAT 的部分
ip:port
。此處先假設我們取得的 IP 位址皆正確(此點並非總成立,但此處暫作此假設。實際上,大多數情況下此點成立,若感興趣,可參考 RFC 4787 中的 REQ-2)。
IP 位址確定後,剩餘問題為埠。總共有 65,535 種可能,我們能遍歷此埠範圍嗎?
若發包速度為 100 packets/s,最壞情況下需 10 分鐘找到正確埠。如前所述,這雖非最佳,但總比無法連線好。
這很像埠掃描(事實上確是),實際中可能觸發對方的網路入侵偵測軟體。
7.2 基於生日悖論改進暴力掃描:硬端多開埠 + 易端隨機探測
利用 生日悖論 演算法,我們能改進埠掃描。
- 前節的基本前提為:hard 端僅開啟一個埠,easy 端暴力掃描 65,535 個埠以尋找此埠;
- 此處的改進為:在 hard 端開啟多個埠,例如 256 個(即同時開啟 256 個 socket,目的位址皆為 easy 端的
ip:port
),然後 easy 端隨機探測此端的埠。
此處省去演算法的數學模型,若您對實現感興趣,可查看我撰寫的 Python 計算器。計算過程為「經典」生日悖論的一小變種。以下為隨著 easy 端隨機探測次數(假設 hard 端開啟 256 個埠)變化,兩端開啟的埠有重合(即通訊成功)的機率:
隨機探測次數 | 成功機率 |
---|---|
174 | 50% |
256 | 64% |
1024 | 98% |
2048 | 99.9% |
根據以上結果,若假設以 100 ports/s 的適中探測速率,2 秒鐘內約有 50% 的成功機率。即使極不走運,我們仍能在 20 秒內幾乎 100% 穿透成功,而此時僅探測了總埠空間的 4%。
非常好!雖 hard NAT 為我們帶來嚴重穿透延遲,但最終結果仍成功。若是兩個 hard NAT,我們還能處理嗎?
7.3 雙硬 NAT 場景
此情況下仍可使用前述 多埠 + 隨機探測 方式,但成功機率大幅降低:
- 每次透過一台 hard NAT 探測對方的埠(目的埠)時,我們同時生成一個隨機來源埠,
- 這意味我們的搜尋空間變為二維
{src port, dst port}
對,而非先前的一維目的埠空間。
此處不展開具體計算,僅提供結果:仍假設目的端開啟 256 個埠,來源端發起 2048 次(20 秒),成功機率為:0.01%。
若您熟悉生日悖論,對此結果不會感到意外。理論上:
- 欲達 99.9% 成功率,兩端各需進行 170,000 次探測——若以 100 packets/sec 的速度,需 28 分鐘。
- 欲達 50% 成功率,「僅」需 54,000 packets,即 9 分鐘。
- 若不使用生日悖論方式,且暴力窮舉,需 1.2 年!
對某些應用而言,28 分鐘或許仍為可接受的時間。花費半小時暴力穿透 NAT 後,此連線可持續使用——除非 NAT 設備重啟,則需再花半小時穿透建立新連線。但對互動式應用而言,這顯然不可接受。
更糟的是,若檢視常見辦公網路路由器,您會震驚於其 active session low limit 之低。例如,一台 Juniper SRX 300 最多支援 64,000 active sessions。這意味:
- 若欲建立一個成功穿透連線,將打爆其整個 session 表(因我們需暴力探測 65,535 個埠,每次探測皆為一條新連線記錄)!這假設路由器能從容優雅處理過載。
- 這僅為建立一條連線的影響!若 20 台機器同時對此路由器發起穿透呢?絕對災難!
至此,我們透過此方式穿透了比先前更難的網路拓撲。這是一大成就,因 家用路由器通常為 easy NAT,hard NAT 通常為辦公網路路由器或雲端 NAT 閘道器。這意味此方式能助我們解決:
- 家用至辦公室(home-to-office)
- 家用至雲端(home-to-cloud)
場景,及部分:
- 辦公室至雲端(office-to-cloud)
- 雲端至雲端(cloud-to-cloud)
場景。
7.4 控制埠映射(port mapping)流程:UPnP/NAT-PMP/PCP 協定
若我們能讓 NAT 設備行為簡單些,避免如此複雜,則建立連線(穿透)將簡單許多。真有此好事?確有,存在一類專用協定稱為埠映射協定(port mapping protocols)。透過這些協定繞過先前遇到的複雜問題後,我們將獲得極簡單的「請求 - 回應」。
以下為三個具體的埠映射協定:
UPnP IGD(Universal Plug’n’Play Internet Gateway Device)
最古老的埠控制協定,誕生於 1990 年代末,因此使用許多 90 年代技術(XML、SOAP、UDP 上的多播 HTTP——對,HTTP over UDP),難以精確且安全實現。此協定曾為許多路由器內建,至今仍多。
請求與回應:
- 「你好,請將我的
lan-ip:port
轉發至公眾網路(WAN)」, - 「好的,我已為你分配一個公眾映射
wan-ip:port
」。
- 「你好,請將我的
NAT-PMP
UPnP IGD 問世數年後,Apple 推出功能相似的協定,名為 NAT-PMP(NAT Port Mapping Protocol)。
與 UPnP 不同,此協定僅處理埠轉發,不論在客戶端或伺服器端,實現皆極簡單。
PCP
稍後,NAT-PMP v2 版問世,並取新名 PCP(Port Control Protocol)。
因此,為更好實現穿透,可:
先判斷本地預設閘道器是否啟用 UPnP IGD、NAT-PMP 與 PCP,
若探測發現任一協定有回應,我們便申請一個公眾埠映射,
可將此視為增強版 STUN:我們不僅能發現自己的公眾
ip:port
,還能指示我們的 NAT 設備對我們的通訊對端更友好——但並非為此埠修改或新增防火牆規則。接下來,凡抵達我們 NAT 設備、位址為我們申請的埠的封包,皆會被設備轉發至我們。
但我們不能假設此協定一定可用:
本地 NAT 設備可能不支援此協定;
設備支援但預設禁用,或無人知曉此功能存在,故從未啟用;
安全策略要求關閉此功能。
此點極常見,因 UPnP 協定曾曝出高危漏洞(後已修復,因此較新設備若實現無誤,可安全使用 UPnP)。不幸的是,某些設備配置中,UPnP、NAT-PMP、PCP 置於單一開關內(可能統稱「UPnP」功能),全開或全關。因此,若有人擔憂 UPnP 的安全性,連另外兩者亦無法使用。
最終,只要此協定可用,便能有效減少一次 NAT,大幅簡化建連流程。但接下來探討一些不常見場景。
7.5 多 NAT 協商(Negotiating numerous NATs)
至今,我們看到的客戶端與伺服器各僅有一台 NAT 設備。若有多台 NAT 設備會如何?如下圖所示之拓撲:
此例較簡單,不會為穿透帶來太大問題。封包從客戶端 A 經過多次 NAT 抵達公眾網路的過程,與前述穿過多層具狀態防火牆類似:
- 額外的(NAT 設備)層對客戶端與伺服器皆不可見,我們的穿透技術亦不關心中間經過多少層設備。
- 真正具影響的僅為最外層設備,因對端需在此層設備上尋找入口讓封包進入。
具體而言,真正影響的是埠轉發協定。
- 客戶端使用此協定分配埠時,為我們分配埠的是最靠近客戶端的 NAT 設備;
- 而我們期望由離客戶端最遠的那層 NAT 分配,否則我們得到的僅為網路中間層分配的
ip:port
,對端無法使用; - 不幸的是,這幾種協定皆無法遞迴告知我們下一層 NAT 設備為何——雖可用 traceroute 等工具探測網路路徑,並猜測路上設備是否為 NAT 設備(嘗試發送 NAT 請求)——但這得靠運氣。
這解釋了為何網際網路上充斥大量文章稱 double-NAT 有多糟糕,並警告使用者為保持後向相容勿使用 double-NAT。但實際上,double-NAT 對絕大多數網際網路應用而言皆透明,因大多數應用無需主動進行此類 NAT 穿透。
但我絕非建議您在自家網路中設置 double-NAT。
- 破壞埠映射協定後,某些電玩的多玩家(multiplayer)模式將無法使用,
- 亦可能使您的 IPv6 網路無法派上用場,後者為無需 NAT 即可雙向直連的優良方案。
但若 double-NAT 非您能控制,除無法使用埠映射協定外,其他大多數事物皆不受影響。
double-NAT 的故事至此結束了嗎?——尚未,且更大規模的 double-NAT 場景即將展現。
7.6 營運商級 NAT 帶來的問題
即使使用 NAT 解決 IPv4 位址不足問題,位址仍不夠用,ISP(網際網路服務提供者)顯然無法為每戶家庭分配一個公眾 IP 位址。如何解決?ISP 的做法是位址不夠便再嵌套一層 NAT:
- 家用路由器將您的客戶端 SNAT 至一個「中間」IP,然後發送至營運商網路,
- ISP 網路中的 NAT 設備再將這些中間 IP 映射至少量公眾 IP。
後者即稱為「營運商級 NAT」(carrier-grade NAT,或稱電信級 NAT),縮寫為 CGNAT。如下圖所示:
CGNAT 對 NAT 穿透是一大麻煩。
- 此前,辦公網路使用者欲快速實現 NAT 穿透,僅需在其路由器上手動設置埠映射即可。
- 但有了 CGNAT 後便不管用,因您無法控制營運商的 CGNAT!
好消息是:這其實是 double-NAT 的一小變種,因此前述解決方式大多仍適用。某些事物可能無法如預期運作,但只要願付費給 ISP,這些問題皆可解決。除埠映射協定外,我們已介紹的所有事物在 CGNAT 中皆適用。
新挑戰:同一 CGNAT 端直連,STUN 不可用
但我們確實遇到新挑戰:如何直連位於同一 CGNAT 但不同家用路由器的兩個對端?如下圖所示:
在此情況下,STUN 無法正常運作:STUN 看到的是客戶端在公眾網路(CGNAT 後)看到的位址,而我們欲取得的是「中間網路」中的 ip:port
,這才是對端真正需要的位址,
解決方案:若埠映射協定可用:一端進行埠映射
如何辦?
若您想到埠映射協定,恭喜,答對了!若任一對端的 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 行為為:
- 執行 A 的 NAT 映射規則,即對
2.2.2.2:1234 -> 2.2.2.2:5678
進行 SNAT。 - 注意到目的位址
2.2.2.2:5678
匹配 B 的入向 NAT 映射,因此接著對此封包執行 DNAT,將目的 IP 改為 B 的私有位址。 - 透過 CGNAT 的內部介面(而非公眾介面,對應公眾網路)將封包發送至 B。
此 NAT 行為有專用術語,稱為 hairpinning(直譯為髮夾,意指如髮夾般,沿一邊上去,然後從另一邊繞回),
您或許猜到的事實是:並非所有 NAT 皆支援 hairpin 模式。實際上,許多行為良好的 NAT 設備皆不支援 hairpin 模式,
- 因它們假設 「僅來源 IP 為私有位址且目的 IP 為公眾位址的封包才會經過我」。
- 因此,對目的位址非公眾、需讓路由器將封包轉回內網的封包,它們會直接丟棄。
- 這些邏輯甚至直接實現在路由晶片中,因此除非升級硬體,否則單靠軟體程式設計無法改變此行為。
Hairpin 為所有 NAT 設備的特性(支援或不支援),並非 CGNAT 獨有。
多數情況下,此特性對我們的 NAT 穿透目的無關緊要,因我們期望 兩個區域網路 NAT 設備直接通訊,不會向上繞至其預設閘道器 CGNAT 解決此問題。
Hairpin 特性可有可無有些遺憾,這或許是 hairpin 功能常出問題的原因。
一旦涉及 CGNAT,hairpinning 對連通性至關重要。
Hairpinning 使內網連線行為與公眾網連線行為一致,因此我們無需關心目的位址類型,亦無需知曉自己是否在 CGNAT 後。
若 hairpinning 與埠映射協定皆不可用,則只能降級至中繼模式。
7.7 全 IPv6 網路:理想之地,但非全無問題
至此,部分讀者或許已對著螢幕咆哮:別再用 IPv4 了! 花費如此多時間精力解決這些無意義之事,不如直接換成 IPv6!
- 確實,這些紛繁複雜的事物皆因 IPv4 位址不足,我們一直在以日益複雜的 NAT 為 IPv4 續命。
- 若 IP 位址充足,無需 NAT 即可讓全球每台設備擁有自己的公眾 IP 位址,這些問題不就解決了?
簡言之,是的,這正是 IPv6 能做到之事。但也僅說對一半:在理想的全 IPv6 世界中,一切將更簡單,但我們面臨的問題並非全然消失——因具狀態防火牆依然存在。
- 辦公室中的電腦或許擁有公眾 IPv6 位址,但您的公司肯定會架設防火牆,僅允許您的電腦主動存取公眾網路,而不允許反向主動建連。
- 其他設備上的防火牆亦存在,應用類似規則。
因此,我們仍需:
- 本文開頭介紹的防火牆穿透技術,及
- 協助我們取得自身公眾
ip:port
資訊的旁路通道, - 在某些場景下仍需後備至中繼模式,例如後備至最通用的 HTTP 中繼協定,以繞過某些網路禁止出向 UDP 的問題。
但我們現可拋棄 STUN、生日悖論、埠映射協定、hairpin 等事物。這是好消息!
全球 IPv4/IPv6 部署現況
另一更嚴峻的現實問題是:當前並非全 IPv6 世界。目前全球:
- 大多仍為 IPv4,
- 約 33% 為 IPv6,且分佈極不均,因此某些通訊對可能為 100% IPv6、0% IPv6,或介於兩者之間。
不幸的是,這意味 IPv6 尚無法作為我們的解決方案。目前,它僅為我們工具箱中的一備選。對某些對端,它是完美工具,但對其他對端則不可用。若目標為「任何情況下皆能穿透(連線)成功」,我們仍需 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 設備,進而涉及 NAT64 設備,但後一步對使用者透明。
但對 NAT 與防火牆穿透而言,我們關心每個具體的 IP 位址與埠。
解決方案:CLAT(客戶端轉譯器)
若設備支援 CLAT(Customer-side transLATor — from Customer XLAT),我們便很幸運:
- CLAT 假裝作業系統擁有直接 IPv4 連線,背後使用 NAT64,對應用程式透明。在支援 CLAT 的設備上,我們無需特別處理。
- CLAT 在行動裝置上極為常見,但在桌上型電腦、筆記型電腦與伺服器上極罕見,因此在後者上,必須自行執行 CLAT 之事:偵測 NAT64+DNS64 的存在,並正確使用它們。
解決方案:CLAT 不存在時,手動穿透 NAT64 設備
首先偵測是否存在 NAT64+DNS64。
方法簡單:向
ipv4only.arpa.
發送 DNS 請求。此域名會解析為已知、固定的 IPv4 位址,且為純 IPv4 位址。若得到的為 IPv6 位址,便可判斷有 DNS64 伺服器進行了轉換,必然涉及 NAT64。如此便能判斷 NAT64 的前綴為何。隨後,欲向 IPv4 位址發送封包時,發送格式為
{NAT64 prefix + IPv4 address}
的 IPv6 封包。類似地,收到來源格式為{NAT64 prefix + IPv4 address}
的封包時,即為 IPv4 流量。接下來,透過 NAT64 網路與 STUN 通訊,取得自身在 NAT64 上的公眾
ip:port
,便回到經典的 NAT 穿透問題——僅需稍多些工作。
幸運的是,如今大多數 v6-only 網路為行動營運商網路,且幾乎所有手機皆支援 CLAT。營運 v6-only 網路的 ISP 會在其提供的路由器上部署 CLAT,因此最終您其實無需特別處理。但若欲實現 100% 穿透,則需解決此類邊角問題,即必須顯式支援從 v6-only 網路連線至 v4-only 對端。
7.8 將所有解決方案整合至 ICE 協定
針對特定場景,該選擇哪種穿透方式?
至此,我們的 NAT 穿透之旅終於接近尾聲。我們已涵蓋具狀態防火牆、簡單與進階 NAT、IPv4 與 IPv6。只要實現上述所有解決方案,NAT 穿透目標便告達成!
然而:
- 對給定的對端,如何判斷應使用哪種方式?
- 如何判斷這是簡單具狀態防火牆場景,抑或需用到生日悖論演算法,或需手動處理 NAT64?
- 抑或通訊雙方處於同一 Wi-Fi 網路,無任何防火牆,因此無需任何操作?
早期 NAT 穿透較簡單,能讓我們精確判斷對端間的路徑特點,然後針對性採用相應解決方式。但後來,網路工程師與 NAT 設備開發工程師引入新理念,使路徑判斷變得困難。因此,我們需簡化客戶端的思考(判斷邏輯)。
這便提到互動式連線建立(Interactive Connectivity Establishment,ICE)協定。與 STUN/TURN 類似,ICE 源自電信領域,因此其 RFC 充滿 SIP、SDP、信令會話、撥號等電話術語。但若忽略這些領域術語,我們會看到它描述了一個極其優雅的判斷最佳連線路徑的演算法。
真的?此演算法為:每種方法皆試一遍,然後選擇最佳者。就是這演算法,驚喜否?
來更深入探討此演算法。
ICE(互動式連線建立)演算法
此處討論不嚴格遵循 ICE 規範,因此若欲實現一個可互操作的 ICE 客戶端,應詳閱 RFC 8445,依其描述實現。此處忽略所有電信術語,僅聚焦核心演算法邏輯,並提供數個在 ICE 規範允許範圍內的靈活建議。
為與某對端通訊,首先需確定我們自身(客戶端)使用的 socket 位址,這是一個清單,至少應包含:
- 我們自身的 IPv6
ip:ports
, - 我們自身的 IPv4 區域網路
ip:ports
(本地網路位址), - 透過 STUN 伺服器取得的我們自身的 IPv4 廣域網路
ip:ports
(公眾位址,可能經 NAT64 轉換), - 透過埠映射協定取得的我們自身的 IPv4 廣域網路
ip:port
(NAT 設備的埠映射協定分配的公眾位址), - 營運商提供給我們的終點(例如,靜態配置的埠轉發)。
- 我們自身的 IPv6
透過旁路通道與對端互換此清單。雙方取得對方清單後,開始互相探測對方提供的位址。清單中位址無優先級,即若對方提供 15 個位址,我們應逐一探測這 15 個位址。
這些探測封包有兩目的:
- 開啟防火牆,穿透 NAT,即本文持續介紹的內容;
- 健康檢查。我們不斷交換(最好為已認證的)「ping/pong」封包,以驗證某特定路徑是否端到端通暢。
最後,短暫時間後,從可用備選位址中(依某些條件)選擇「最佳」位址,任務完成!
此演算法的優美之處在於:只要選擇最佳路徑(位址)的演算法正確,便總能獲得最佳路徑。
- ICE 會預先對這些備選位址排序(通常:區域網路 > 廣域網路 > 廣域網路+NAT),但使用者亦可自行指定此排序行為。
- 自 v0.100.0 起,Tailscale 從原先硬編碼優先級改為依據往返延遲(round-trip latency)排序,其大多數情況下排序結果與
LAN > WAN > WAN+NAT
一致。但相較靜態排序,我們動態計算每條路徑應屬哪類。
ICE 規範將協定分為兩階段:
- 探測階段
- 通訊階段
但不需嚴格遵循此兩步驟順序。在 Tailscale:
- 發現更優路徑後,我們會自動切換過去,
- 所有連線皆先選擇 DERP 模式(中繼模式)。這意味連線能立即建立(優先級最低但 100% 成功的模式),使用者無需等待,
- 然後並行進行路徑發現。通常數秒後,我們便能發現更優路徑,隨後將現有連線透明升級(upgrade)至該路徑。
但需關注一點:非對稱路徑。ICE 投入相當精力確保通訊雙方選擇相同網路路徑,以確保該路徑上有雙向流量,保持防火牆與 NAT 設備的連線持續開啟。自行實現時,無需投入同等精力實現此保證,但需確保所有使用的路徑皆有雙向流量。此目標簡單,僅需定期在所有已使用路徑上發送 ping/pong 封包即可。
穩健性與降級
為實現穩健性,需偵測當前選擇的路徑是否已失效(例如,NAT 設備維護時清除所有狀態)。若失效,需降級(downgrade)至其他路徑。此處有兩方式:
持續探測所有路徑,維護一個降級時使用的備用位址清單;
直接降級至保底的中繼模式,然後再透過路徑探測升級至更優路徑。
考量降級機率極低,此方式或許更經濟。
7.9 安全性
最後需提及安全性。
本文所有內容皆假設:我們使用的上層協定已有自身安全機制(例如 QUIC 協定有 TLS 憑證,WireGuard 協定有自身公鑰)。若尚無安全機制,顯然需立即補上。一旦動態切換路徑,基於 IP 的安全機制即無用(IP 協定最初未怎麼考量安全性),至少需有端到端認證。
- 嚴格來說,若上層協定有安全機制,即使收到欺騙性的 ping/pong 流量,問題亦不大,最壞情況僅為攻擊者誘導雙方透過其系統中繼流量。有了端到端安全機制,這非重大問題(視您的威脅模型而定)。
- 但為謹慎起見,最好亦對路徑發現的封包進行認證與加密。具體做法可諮詢您的應用安全工程師。
我們終於達成 NAT 穿透目標!
若實現上述提及的所有技術,您將獲得業界領先的 NAT 穿透軟體,能在絕大多數場景下實現端到端直連。若直連失敗,尚可降級至保底的中繼模式(對 Tailscale 而言,有時僅能依賴中繼)。
但此工作頗為複雜!其中一些問題研究起來饒有趣味,但難以完全正確,特別是那些極罕見但解決需耗費極大精力的邊角場景。幸而,此工作僅需做一次,一旦解決,您便具備某種超能力:探索令人振奮、相對新穎的端到端應用(peer-to-peer applications)世界。
8.1 跨越公眾網路 端到端直連
去中心化軟體領域中的許多有趣構想,簡化後皆歸結為跨越公眾網路(網際網路)實現端到端直連的問題,起初或覺簡單,但真正實作才發現遠比想像困難。現在您已知如何解決此問題,動手開做吧!
8.2 結語之 TL;DR
實現穩健的 NAT 穿透需下列基礎:
- 一種基於 UDP 的協定;
- 能在程式內直接存取 socket;
- 一個與對端通訊的旁路通道;
- 若干 STUN 伺服器;
- 一個保底用的中繼網路(可選,但強烈推薦)。
然後需:
- 遍歷所有
ip:port
; - 查詢 STUN 伺服器以取得自身的公眾
ip:port
資訊,並判斷自身端的 NAT「難度」(difficulty); - 使用埠映射協定取得更多公眾
ip:ports
; - 檢查 NAT64,透過其取得自身的公眾
ip:port
; - 透過旁路通道與對端交換自身的全部公眾
ip:ports
資訊,及某些加密金鑰以確保通訊安全; - 透過保底的中繼方式與對方開始通訊(可選,如此連線能快速建立);
- 若有必要/欲如此,探測對方提供的所有
ip:port
,並執行生日攻擊(birthday attacks)以穿透更難的 NAT; - 發現更優路徑後,透明升級至該路徑;
- 若當前路徑斷開,降級至其他可用路徑;
- 確保所有事物皆加密,並具端到端認證。