1. 程式人生 > >WireGuard 教程:使用 DNS-SD 進行 NAT-to-NAT 穿透

WireGuard 教程:使用 DNS-SD 進行 NAT-to-NAT 穿透

> 原文連結:[https://fuckcloudnative.io/posts/wireguard-endpoint-discovery-nat-traversal/](https://fuckcloudnative.io/posts/wireguard-endpoint-discovery-nat-traversal/) ![](https://img2020.cnblogs.com/other/1737323/202102/1737323-20210201093607017-1640284377.png) `WireGuard` 是由 Jason A. Donenfeld 等人建立的下一代開源 VPN 協議,旨在解決許多困擾 `IPSec/IKEv2`、`OpenVPN` 或 `L2TP` 等其他 VPN 協議的問題。2020 年 1 月 29 日,WireGuard 正式合併進入 `Linux 5.6` 核心主線。 ![](https://img2020.cnblogs.com/other/1737323/202102/1737323-20210201093607221-474305705.png) 利用 WireGuard 我們可以實現很多非常奇妙的功能,比如跨公有云組建 Kubernetes 叢集,本地直接訪問公有云 `Kubernetes` 叢集中的 Pod IP 和 Service IP,在家中沒有公網 IP 的情況下直連家中的裝置,等等。 如果你是第一次聽說 WireGuard,建議你花點時間看看我之前寫的 WireGuard [工作原理](https://fuckcloudnative.io/posts/wireguard-docs-theory/)。然後可以參考下面兩篇文章來快速上手: + [WireGuard 快速安裝教程](https://fuckcloudnative.io/posts/wireguard-install/) + [WireGuard 配置教程:使用 wg-gen-web 來管理 WireGuard 的配置](https://fuckcloudnative.io/posts/configure-wireguard-using-wg-gen-web/) 如果遇到某些細節不太明白的,再去參考 [WireGuard 配置詳解](https://fuckcloudnative.io/posts/wireguard-docs-practice/)。 本文將探討 WireGuard 使用過程中遇到的一個重大難題:**如何使兩個位於 NAT 後面(且沒有指定公網出口)的客戶端之間直接建立連線。** WireGuard 不區分服務端和客戶端,大家都是客戶端,與自己連線的所有客戶端都被稱之為 `Peer`。 ## 1. IP 不固定的 Peer WireGuard 的核心部分是[加密金鑰路由(Cryptokey Routing)](https://www.wireguard.com/#cryptokey-routing),它的工作原理是將公鑰和 IP 地址列表(`AllowedIPs`)關聯起來。每一個網路介面都有一個私鑰和一個 Peer 列表,每一個 Peer 都有一個公鑰和 IP 地址列表。傳送資料時,可以把 IP 地址列表看成路由表;接收資料時,可以把 IP 地址列表看成訪問控制列表。 公鑰和 IP 地址列表的關聯組成了 Peer 的必要配置,從隧道驗證的角度看,根本不需要 Peer 具備靜態 IP 地址。理論上,如果 Peer 的 IP 地址不同時發生變化,WireGuard 是可以實現 IP 漫遊的。 現在回到最初的問題:**假設兩個 Peer 都在 NAT 後面,且這個 NAT 不受我們控制,無法配置 UDP 埠轉發,即無法指定公網出口,要想建立連線,不僅要動態發現 Peer 的 IP 地址,還要發現 Peer 的埠。** 找了一圈下來,現有的工具根本無法實現這個需求,本文將致力於不對 WireGuard 原始碼做任何改動的情況下實現上述需求。 ## 2. 中心輻射型網路拓撲 你可能會問我為什麼不使用[中心輻射型(hub-and-spoke)網路拓撲](https://en.wikipedia.org/wiki/Spoke–hub_distribution_paradigm)?中心輻射型網路有一個 VPN 閘道器,這個閘道器通常都有一個靜態 IP 地址,其他所有的客戶端都需要連線這個 VPN 閘道器,再由閘道器將流量轉發到其他的客戶端。假設 `Alice` 和 `Bob` 都位於 NAT 後面,那麼 `Alice` 和 `Bob` 都要和閘道器建立隧道,然後 `Alice` 和 `Bob` 之間就可以通過 VPN 閘道器轉發流量來實現相互通訊。 ![](https://img2020.cnblogs.com/other/1737323/202102/1737323-20210201093607414-1301026397.png) 其實這個方法是如今大家都在用的方法,已經沒什麼可說的了,缺點相當明顯: + 當 Peer 越來越多時,VPN 閘道器就會變成垂直擴充套件的瓶頸。 + 通過 VPN 閘道器轉發流量的成本很高,畢竟雲伺服器的流量很貴。 + 通過 VPN 閘道器轉發流量會帶來很高的延遲。 本文想探討的是 `Alice` 和 `Bob` 之間直接建立隧道,中心輻射型(hub-and-spoke)網路拓撲是無法做到的。 ## 3. NAT 穿透 要想在 `Alice` 和 `Bob` 之間直接建立一個 WireGuard 隧道,就需要它們能夠穿過擋在它們面前的 NAT。由於 WireGuard 是通過 `UDP` 來相互通訊的,所以理論上 [UDP 打洞(UDP hole punching)](https://en.wikipedia.org/wiki/UDP_hole_punching) 是最佳選擇。 UDP 打洞(UDP hole punching)利用了這樣一個事實:大多數 NAT 在將入站資料包與現有的連線進行匹配時都很寬鬆。這樣就可以重複使用埠狀態來打洞,因為 NAT 路由器不會限制只接收來自原始目的地址(信使伺服器)的流量,其他客戶端的流量也可以接收。 舉個例子,假設 `Alice` 向新主機 `Carol` 傳送一個 UDP 資料包,而 `Bob` 此時通過某種方法獲取到了 `Alice` 的 NAT 在地址轉換過程中使用的出站源 `IP:Port`,`Bob` 就可以向這個 `IP:Port`(2.2.2.2:7777) 傳送 UDP 資料包來和 `Alice` 建立聯絡。 ![](https://img2020.cnblogs.com/other/1737323/202102/1737323-20210201093607617-472057308.png) 其實上面討論的就是**完全圓錐型 NAT**(Full cone NAT),即一對一(one-to-one)NAT。它具有以下特點: + 一旦內部地址(iAddr:iPort)對映到外部地址(eAddr:ePort),所有發自 iAddr:iPort 的資料包都經由 eAddr:ePort 向外傳送。 + 任意外部主機都能經由傳送資料包給 eAddr:ePort 到達 iAddr:iPort。 大部分的 NAT 都是這種 NAT,對於其他少數不常見的 NAT,這種打洞方法有一定的侷限性,無法順利使用。 ## 4. STUN 回到上面的例子,UDP 打洞過程中有幾個問題至關重要: + Alice 如何才能知道自己的公網 `IP:Port`? + Alice 如何與 Bob 建立連線? + 在 WireGuard 中如何利用 UDP 打洞? [RFC5389](https://tools.ietf.org/html/rfc5389) 關於 **STUN**(**Session Traversal Utilities for NAT**,NAT會話穿越應用程式)的詳細描述中定義了一個協議回答了上面的一部分問題,這是一篇內容很長的 RFC,所以我將盡我所能對其進行總結。先提醒一下,`STUN` 並不能直接解決上面的問題,它只是個扳手,你還得拿他去打造一個稱手的工具: > STUN 本身並不是 NAT 穿透問題的解決方案,它只是定義了一個機制,你可以用這個機制來組建實際的解決方案。 > > — [RFC5389](https://www.jordanwhited.com/posts/wireguard-endpoint-discovery-nat-traversal/#fn:1) [**STUN**(**Session Traversal Utilities for NAT**,NAT會話穿越應用程式)](https://zh.wikipedia.org/wiki/STUN)是一種網路協議,它允許位於NAT(或多重NAT)後的客戶端找出自己的公網地址,查出自己位於哪種型別的 NAT 之後以及 NAT 為某一個本地埠所繫結的公網埠。這些資訊被用來在兩個同時處於 NAT 路由器之後的主機之間建立 UDP 通訊。該協議由 RFC 5389 定義。 ![](https://img2020.cnblogs.com/other/1737323/202102/1737323-20210201093607804-1592361899.png) STUN 是一個客戶端-服務端協議,在上圖的例子中,`Alice` 是客戶端,`Carol` 是服務端。`Alice` 向 `Carol` 傳送一個 `STUN Binding` 請求,當 Binding 請求通過 `Alice` 的 NAT 時,源 `IP:Port` 會被重寫。當 `Carol` 收到 Binding 請求後,會將三層和四層的源 `IP:Port` 複製到 Binding 響應的有效載荷中,並將其傳送給 `Alice`。Binding 響應通過 Alice 的 NAT 轉發到內網的 `Alice`,此時的目標 IP:Port 被重寫成了內網地址,但有效載荷保持不變。`Alice` 收到 Binding 響應後,就會意識到這個 Socket 的公網 IP:Port 是 `2.2.2.2:7777`。 然而,`STUN` 並不是一個完整的解決方案,它只是提供了這麼一種機制,讓應用程式獲取到它的公網 `IP:Port`,但 STUN 並沒有提供具體的方法來向相關方向發出訊號。如果要重頭編寫一個具有 NAT 穿透功能的應用,肯定要利用 STUN 來實現。當然,明智的做法是不修改 WireGuard 的原始碼,最好是借鑑 STUN 的概念來實現。總之,不管如何,都需要一個擁有靜態公網地址的主機來充當**信使伺服器**。 ## 5. NAT 穿透示例 早在 2016 年 8 月份,WireGuard 的建立者就在 [WireGuard 郵件列表](https://lists.zx2c4.com/pipermail/wireguard/2016-August/000372.html)上分享了一個 [NAT 穿透示例](https://git.zx2c4.com/wireguard-tools/tree/contrib/nat-hole-punching)。Jason 的示例包含了客戶端應用和服務端應用,其中客戶端應用於 WireGuard 一起執行,服務端執行在擁有靜態地址的主機上用來發現各個 Peer 的 `IP:Port`,客戶端使用[原始套接字(raw socket)](https://zh.wikipedia.org/wiki/%E5%8E%9F%E5%A7%8B%E5%A5%97%E6%8E%A5%E5%AD%97)與服務端進行通訊。 ```c /* We use raw sockets so that the WireGuard interface can actually own the real socket. */ sock = socket(AF_INET, SOCK_RAW, IPPROTO_UDP); if (sock < 0) { perror("socket"); return errno; } ``` 正如評論中指出的,WireGuard 擁有“真正的套接字”。通過使用原始套接字(raw socket),客戶端能夠向服務端偽裝本地 WireGuard 的源埠,這樣就確保了在服務端返回響應經過 NAT 時目標 `IP:Port` 會被對映到 WireGuard 套接字上。 客戶端在其原始套接字上使用一個[經典的 BPF 過濾器](https://www.kernel.org/doc/Documentation/networking/filter.txt)來過濾服務端發往 WireGuard 埠的回覆。 ```c static void apply_bpf(int sock, uint16_t port, uint32_t ip) { struct sock_filter filter[] = { BPF_STMT(BPF_LD + BPF_W + BPF_ABS, 12 /* src ip */), BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, ip, 0, 5), BPF_STMT(BPF_LD + BPF_H + BPF_ABS, 20 /* src port */), BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, PORT, 0, 3), BPF_STMT(BPF_LD + BPF_H + BPF_ABS, 22 /* dst port */), BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, port, 0, 1), BPF_STMT(BPF_RET + BPF_K, -1), BPF_STMT(BPF_RET + BPF_K, 0) }; struct sock_fprog filter_prog = { .len = sizeof(filter) / sizeof(filter[0]), .filter = filter }; if (setsockopt(sock, SOL_SOCKET, SO_ATTACH_FILTER, &filter_prog, sizeof(filter_prog)) < 0) { perror("setsockopt(bpf)"); exit(errno); } } ``` 客戶端與服務端的通訊資料都被定義在 `packet` 和 `reply` 這兩個結構體中: ```c struct { struct udphdr udp; uint8_t my_pubkey[32]; uint8_t their_pubkey[32]; } __attribute__((packed)) packet = { .udp = { .len = htons(sizeof(packet)), .dest = htons(PORT) } }; struct { struct iphdr iphdr; struct udphdr udp; uint32_t ip; uint16_t port; } __attribute__((packed)) reply; ``` 客戶端會遍歷配置好的 WireGuard Peer(`wg show peers`),併為每一個 Peer 傳送一個數據包給服務端,其中 `my_pubkey` 和 `their_pubkey` 欄位會被適當填充。當服務端收到來自客戶端的資料包時,它會向以公鑰為金鑰的 Peer 記憶體表中插入或更新一個 `pubkey=my_pubkey` 的 `entry`,然後再從該表中查詢 `pubkey=their_pubkey` 的 `entry`,一但發現 `entry` 存在,就會將其中的 `IP:Port` 傳送給客戶端。當客戶端收到回覆時,會將 IP 和埠從資料包中解包,並配置 Peer 的 endpoint 地址(`wg set peer endpoint :`)。 `entry` 結構體原始碼: ```c struct entry { uint8_t pubkey[32]; uint32_t ip; uint16_t port; }; ``` `entry` 結構體中的 `ip` 和 `port` 欄位是從客戶端收到的資料包中提取的 IP 和 UDP 頭部,每次客戶端請求 Peer 的 IP 和埠資訊時,都會在 Peer 列表中重新整理自己的 IP 和埠資訊。 上面的例子展示了 WireGuard 如何實現 UDP 打洞,但還是太複雜了,因為並不是所有的 Peer 端都能開啟原始套接字(raw socket),也並不是所有的 Peer 端都能利用 BPF 過濾器。而且這裡還用到了自定義的 [wire protocol](https://en.wikipedia.org/wiki/Wire_protocol),程式碼層面的資料(連結串列、佇列、二叉樹)都是結構化的,但網路層看到的都是二進位制流,所謂 `wire protocol` 就是把結構化的資料序列化為二進位制流傳送出去,並且對方也能以同樣的格式反序列化出來。這種方式是很難除錯的,所以我們需要另闢蹊徑,利用現有的成熟工具來達到目的。 ## 6. WireGuard NAT 穿透的正解 其實完全沒必要這麼麻煩,我們可以直接利用 WireGuard 本身的特性來實現 UDP 打洞,直接看圖: ![](https://img2020.cnblogs.com/other/1737323/202102/1737323-20210201093608006-463198711.png) 你可能會認為這是個中心輻射型(hub-and-spoke)網路拓撲,但實際上還是有些區別的,這裡的 Registry Peer 不會充當閘道器的角色,因為它沒有相應的路由,不會轉發流量。Registry 的 WireGuard 介面地址為 `10.0.0.254/32`,Alice 和 Bob 的 `AllowedIPs` 中只包含了 `10.0.0.254/32`,表示只接收來自 `Registry` 的流量,所以 Alice 和 Bob 之間無法通過 Registry 來進行通訊。 這裡有一點至關重要,`Registry` 分別和 Alice 與 Bob 建立了兩個隧道,這就會在 Alice 和 Bob 的 NAT 上開啟一個洞,我們需要找到一種方法來從 Registry Peer 中查詢這些洞的 `IP:Port`,自然而然就想到了 `DNS` 協議。DNS 的優勢很明顯,它比較簡單、成熟,還跨平臺。有一種 DNS 記錄型別叫 [**SRV記錄**(Service Record,服務定位記錄)](https://zh.wikipedia.org/wiki/SRV%E8%AE%B0%E5%BD%95),它用來記錄伺服器提供的服務,即識別服務的 IP 和埠,[RFC6763](https://tools.ietf.org/html/rfc6763) 用具體的結構和查詢模式對這種記錄型別進行了擴充套件,用於發現給定域下的服務,我們可以直接利用這些擴充套件語義。 ## 7. CoreDNS 選好了服務發現協議後,還需要一種方法來將其與 WireGuard 對接。[CoreDNS](https://github.com/coredns/coredns) 是 Golang 編寫的一個外掛式 DNS 伺服器,是目前 Kubernetes 內建的預設 DNS 伺服器,並且已從 [CNCF](https://cncf.io/) 畢業。我們可以直接寫一個 CoreDNS 外掛,用來接受 `DNS-SD`(DNS-based Service Discovery)查詢並返回相關 WireGuard Peer 的資訊,其中公鑰作為記錄名稱,fuckcloudnative.io 作為域。如果你熟悉 bind 風格的域檔案,可以想象一個類似這樣的域資料: ```bash _wireguard._udp IN PTR alice._wireguard._udp.fuckcloudnative.io. _wireguard._udp IN PTR bob._wireguard._udp.fuckcloudnative.io. alice._wireguard._udp IN SRV 0 1 7777 alice.fuckcloudnative.io. alice IN A 2.2.2.2 bob._wireguard._udp IN SRV 0 1 8888 bob.fuckcloudnative.io. bob IN A 3.3.3.3 ``` ### 公鑰使用 Base64 還是 Base32 ? 到目前為止,我們一直使用別名 Alice 和 Bob 來替代其對應的 WireGuard 公鑰。WireGuard 公鑰是 `Base64` 編碼的,長度為 `44` 位元組: ```bash $ wg genkey | wg pubkey UlVJVmPSwuG4U9BwyVILFDNlM+Gk9nQ7444HimPPgQg= ``` > Base 64 編碼的設計是為了以一種允許使用大寫字母和小寫字母的形式來表示任意的八位位元組序列。 > > — [RFC4648](https://www.jordanwhited.com/posts/wireguard-endpoint-discovery-nat-traversal/#fn:2) 不幸的是,DNS 的 SRV 記錄的服務名稱是不區分大小寫的: > DNS 樹中的每個節點都有一個由零個或多個標籤組成的名稱 [STD13, RFC1591, RFC2606],這些標籤不區分大小寫。 > > — [RFC4343](https://www.jordanwhited.com/posts/wireguard-endpoint-discovery-nat-traversal/#fn:3) `Base32` 雖然產生了一個稍長的字串(`56` 位元組),但它的表現形式允許我們在 DNS 內部表示 WireGuard 公鑰: > Base32 編碼的目的是為了表示任意八位位元組序列,其形式必須不區分大小寫。 我們可以使用 `base64` 和 `base32` 命令來回轉換編碼格式,例如: ```bash $ wg genkey | wg pubkey > pub.txt $ cat pub.txt O9rAAiO5qTejOEtFbsQhCl745ovoM9coTGiprFTaHUE= $ cat pub.txt | base64 -D | base32 HPNMAARDXGUTPIZYJNCW5RBBBJPPRZUL5AZ5OKCMNCU2YVG2DVAQ==== $ cat pub.txt | base64 -D | base32 | base32 -d | base64 O9rAAiO5qTejOEtFbsQhCl745ovoM9coTGiprFTaHUE= ``` 我們可以直接使用 `base32` 這種不區分大小寫的公鑰編碼,來使其與 DNS 相容。 ### 編譯外掛 CoreDNS 提供了[編寫外掛的文件](https://coredns.io/manual/toc/#writing-plugins),外掛必須要實現 `plugin.Handler` 介面: ```go type Handler interface { ServeDNS(context.Context, dns.ResponseWriter, *dns.Msg) (int, error) Name() string } ``` 我自己已經寫好了外掛,通過 `DNS-SD`(DNS-based Service Discovery)語義來提供 WireGuard 的 Peer 資訊,該外掛名就叫 [wgsd](https://github.com/jwhited/wgsd)。自己編寫的外掛不屬於官方內建外掛,從 CoreDNS 官方下載頁下載的可執行程式並不包括這兩個外掛,所以需要自己編譯 CoreDNS。 編譯 CoreDNS 並不複雜,在沒有外部外掛的情況下可以這麼編譯: ```bash $ git clone https://github.com/coredns/coredns.git $ cd coredns $ make ``` 如果要加上 wgsd 外掛,則在 `make` 前,要修改 `plugin.cfg` 檔案,加入以下一行: ```bash wgsd:github.com/jwhited/wgsd ``` 然後開始編譯: ```bash $ go generate $ go build ``` 檢視編譯好的二進位制檔案是否包含該外掛: ```bash $ ./coredns -plugins | grep wgsd dns.wgsd ``` 編譯完成後,就可以在配置檔案中啟用 `wgsd` 外掛了: ```bash .:53 { wgsd } ``` 可以來測試一下,配置檔案如下: ```bash $ cat Corefile .:53 { debug wgsd fuckcloudnative.io. wg0 } ``` 執行 CoreDNS: ```bash $ ./coredns -conf Corefile .:53 CoreDNS-1.8.1 linux/amd64, go1.15, ``` 當前節點的 WireGuard 資訊: ```bash $ sudo wg show interface: wg0 listening port: 52022 peer: mvplwow3agnGM8G78+BiJ3tmlPf9gDtbJ2NdxqV44D8= endpoint: 3.3.3.3:8888 allowed ips: 10.0.0.2/32 ``` 下面就是見證奇蹟的時候,列出所有 Peer: ```bash $ dig @127.0.0.1 _wireguard._udp.fuckcloudnative.io. PTR +noall +answer +additional ; <<>> DiG 9.10.6 <<>> @127.0.0.1 _wireguard._udp.fuckcloudnative.io. PTR +noall +answer +additional ; (1 server found) ;; global options: +cmd _wireguard._udp.fuckcloudnative.io. 0 IN PTR TL5GLQUMG5VATRRTYG57HYDCE55WNFHX7WADWWZHMNO4NJLY4A7Q====._wireguard._udp.fuckcloudnative.io. ``` 查詢每個 Peer 的 IP 和埠: ```bash $ dig @127.0.0.1 TL5GLQUMG5VATRRTYG57HYDCE55WNFHX7WADWWZHMNO4NJLY4A7Q====._wireguard._udp.fuckcloudnative.io. SRV +noall +answer +additional ; <<>> DiG 9.10.6 <<>> @127.0.0.1 TL5GLQUMG5VATRRTYG57HYDCE55WNFHX7WADWWZHMNO4NJLY4A7Q====._wireguard._udp.fuckcloudnative.io. SRV +noall +answer +additional ; (1 server found) ;; global options: +cmd tl5glqumg5vatrrtyg57hydce55wnfhx7wadwwzhmno4njly4a7q====._wireguard._udp.fuckcloudnative.io. 0 IN SRV 0 0 8888 TL5GLQUMG5VATRRTYG57HYDCE55WNFHX7WADWWZHMNO4NJLY4A7Q====.fuckcloudnative.io. TL5GLQUMG5VATRRTYG57HYDCE55WNFHX7WADWWZHMNO4NJLY4A7Q====.fuckcloudnative.io. 0 IN A 3.3.3.3 ```