ctsite.cc部落格

Turnout:替 Mac 上的 VPN 流量選一條路

Turnout

Turnout 是我正在開發的 macOS VPN 用戶端,目前連線的對象是 WireGuard 伺服器。它想解決的問題很具體:讓你決定流量要走哪一張網卡。設定檔就是 wg-quick 的設定檔,PreUp、PostUp 這些 hook 和路由設定都照樣運作。Turnout 還在早期開發階段,這篇不談下載,只聊它的概念,以及背後的設計取捨。

更新(2026 年 10 月 26 日):Turnout 已經正式推出,下載和完整介紹在 turnout.ctsite.cc。

更新(2026 年 11 月 7 日):Turnout 1.1 可以在 UDP 被擋時改走 TCP,也推出了 Windows 版,詳見這篇。

Turnout 的主視窗:左邊是通道清單,右邊是連線中的 office 通道,上方的路徑線顯示 utun102、出口網卡 Ethernet 與伺服器,下方列出路由

Turnout 的主視窗。畫面中的通道與位址都是示範資料,閘道位址已遮蔽。

名字的由來:鐵道上的轉轍器

Turnout 是鐵道上的轉轍器,也就是道岔:同一條軌道在這裡分岔,扳過去之後,列車就開上另一條線。Mac 上的流量也有同樣的需求:大部分該走進 VPN 隧道,但有些應該繞過去,從另一張網卡直接出去。Turnout 做的,就是替流量扳這個道岔。

Turnout 的 App 圖示:淺色底板上,藍線走進黑色隧道口,橘線轉彎繞過隧道

Turnout 的 App 圖示。

Turnout 圖示是一張路線圖:兩條線從同一個轉運站出發,藍線走進隧道,橘線以 45 度轉彎繞過隧道 1 2 3 1 2 3 這台 Mac 兩條線從同一處出發 藍線:走進隧道 加密後消失在黑暗裡 橘線:繞過隧道 45 度轉彎到另一張網卡

Turnout 的圖示是一張路線圖:藍線進隧道,橘線繞過隧道。

圖示畫的就是這件事。兩條線從同一個轉運站(這台 Mac)出發:藍線走進隧道口,慢慢消失在黑暗裡;橘線以 45 度轉彎繞過隧道,抵達自己的車站,也就是另一張網卡。藍和橘取自網路線 T568 規格裡第一、二對線的顏色。我刻意避開了 WireGuard 的龍形商標,也不用 VPN 常見的盾牌。

「Turnout」這個字也不是用字型排的,而是畫出來的:筆畫和路線一樣粗,端點是圓的,t 的底部有一個 45 度的轉角,呼應那條橘線。字只用一種顏色,顏色留給圖示。

為什麼要另外做一個

macOS 上的官方 WireGuard App 跑在沙盒化的 NetworkExtension 裡。這讓它很安全,代價是不能執行 PreUp、PostUp、PreDown、PostDown 這些 hook,也不能自訂路由。需要這些功能的人,多半只能回到終端機用 wg-quick。

Turnout 選了另一條路:不用沙盒,也不用 NetworkExtension,以 Developer ID 簽署,在 App Store 之外發行。原生的 SwiftUI App 負責介面,背後有一個小小的特權背景服務,照著 wg-quick 在 macOS 上的做法,一步一步執行。

「一步一步」是字面上的意思。修改系統時,背景服務用的是和 wg-quick 一樣的工具:ifconfig、route、networksetup,以參數直接呼叫、不經過 shell。這樣它的行為能和 wg-quick 的原始碼逐步對照,你也能用 netstat -rn 或 networksetup -getdnsservers 自己檢查。連線的順序也和 wg-quick 相同:

  1. 建立 utun 介面
  2. 執行 PreUp(這時 %i 已經是實際的 utun 名稱)
  3. 設定 WireGuard
  4. 位址、MTU,啟用介面
  5. 路由
  6. DNS
  7. 執行 PostUp

只有一個地方刻意不同:實體網卡上的路由(端點的固定路由和繞道路由)會比隧道路由先加。這樣在連線的過程中,連到伺服器的封包連一瞬間都不會被導進自己的隧道。

設定檔還是原本那一份

Turnout 讀的就是 wg-quick 的 .conf:Address、DNS、MTU、Table、SaveConfig,以及帶 %i、%I 的 hook。全隧道也和 wg-quick 一樣,用 0.0.0.0/1 加 128.0.0.0/1 兩半蓋過預設路由,而不是直接把它換掉。

超出 wg-quick 的功能,一律寫成以 #@ 開頭的註解。wg-quick 會把不認得的設定交給 wg setconf,而它會直接拒絕;寫成註解,同一個檔案拿去 wg-quick up 也照樣能用:

[Interface]
PrivateKey = …
Address = 10.8.0.2/32
DNS = 10.8.0.1
#@ BindInterface = en0
#@ Route = 192.168.0.0/16 dev en1

[Peer]
PublicKey = …
AllowedIPs = 0.0.0.0/0, ::/0
Endpoint = vpn.example.com:51820

編輯器有表單和純文字兩種檢視,但設定只有一個來源,就是那段文字。解析器是無損的:解析之後再寫回去,會得到一字不差的原文;從表單改一個欄位,只會重寫那一行,你的註解和排列順序都會留著。有問題的地方會連同行號列出來,修好之前不能儲存;新建或匯入的通道在儲存之前都只是草稿,不會送到背景服務。

編輯器的文字檢視,顯示 office 通道的 .conf 內容,包含兩行 #@ 註解,私鑰已遮蔽

編輯器的文字檢視:就是原本的 .conf,#@ 註解照樣留著(私鑰已遮蔽)。

有些細節是從實際的設定檔學來的。例如 RouterOS 匯出用戶端設定時,會用 AAAA…AEA= 代替它不知道的私鑰。這樣的金鑰解析得過,卻永遠無法握手,所以 Turnout 會特別提醒你。

選路:兩個擴充,同一個原理

在 wg-quick 之上,Turnout 只多做了兩件事,而且兩件都需要:

  • 指定加密流量的出口(#@ BindInterface = en0):加密後的 UDP 封包一定從這張網卡出去,就算預設路由在另一張網卡上也一樣;連到伺服器的主機路由也走它。
  • 依目的地繞過隧道(#@ Route = 192.168.0.0/16 dev en1):這些網段改從 en1 出去,不進隧道。
流量在 Mac 上分岔:一般流量進入 utun 隧道並從 en0 送往 VPN 伺服器;192.168.0.0/16 從 en1 直接送出 這台 Mac 送出的流量 其他所有目的地 0.0.0.0/1 + 128.0.0.0/1 utun3 隧道 WireGuard 加密 從 en0 送出 #@ BindInterface VPN 伺服器 vpn.example.com 192.168.0.0/16 指定的網段 從 en1 直接送出 #@ Route … dev en1 經由 en1 目前的閘道 閘道變了就重新指向 不經過隧道 例如區網裡的 NAS 同一個目的地的優先順序:繞道 > 端點固定 > 隧道

上面那份設定的效果:一般流量進隧道、加密後從 en0 送出;192.168.0.0/16 從 en1 直接出去。

在規劃路由時,這兩個擴充其實是同一件事:「經由網卡 X 目前的閘道送出」。閘道不寫在設定檔裡,而是背景服務在套用時才去查,閘道換了就重新套用。同一個目的地符合多條規則時,繞道優先,其次是連到伺服器的固定路由,最後才是隧道。

編輯器的表單檢視:Interface 區塊有私鑰、公鑰、位址、DNS;Routing 區塊有出口網卡 Ethernet (en0),以及經由 Wi-Fi (en1) 的繞道路由 192.168.0.0/16

表單檢視裡,出口網卡和繞道路由都是從選單挑網卡。

網卡被拔掉時怎麼辦?這裡的決定和 wg-quick 一致:連到伺服器的固定路由會變成黑洞,讓握手封包不會繞進隧道裡打轉;繞道的路由則先暫停,等網卡回來再恢復。

跟著網路走

換了 Wi-Fi、插上網路線、閘道變了,Turnout 都會跟著調整。背景服務監聽系統的路由事件,等網路安靜 500 毫秒(最多等 5 秒)之後,把實體路由重新指向目前的閘道、在綁定的網卡變動時重新綁定、依出口網卡重算自動 MTU(出口網卡的 MTU 減 80),並重新套用 DNS。每一步都可以重複執行:這些調整本身也會觸發路由事件,但下一輪檢查時什麼都不用改。

把權限切成兩半

背景服務必須以 root 執行,而且會執行設定檔裡的 hook。處理得不小心,這就是一個本機提權漏洞。所以 Turnout 把責任切得很清楚:

Turnout 的架構:一般權限的 App 透過 Unix socket 連到以 root 執行的背景服務,背景服務檢查簽章,負責加密、路由、DNS、日誌與設定檔 Turnout App(SwiftUI) 一般使用者權限,不碰任何封包 Unix socket ✓ 檢查程式碼簽章 ✓ 檢查使用者身分 權限邊界 背景服務(Rust,以 root 執行) 加密通道 內嵌 GotaTun 路由與 DNS 系統原生工具 變更日誌 先記錄再修改 設定檔:root 擁有,權限 0600 只有背景服務能寫;hook 只從這裡讀 utun 介面 路由表 DNS 設定

App 只負責介面;所有會改動系統的事都由背景服務來做,而且它不相信 App 說的話。

  • App 不碰封包:封包只在背景服務裡流動,App 只負責顯示狀態和送出請求。
  • 認簽章,不認說法:App 和背景服務之間是一條 Unix socket,背景服務會檢查每個連進來的程式的程式碼簽章和使用者身分,只接受同一個開發者簽署的 App。
  • hook 只從磁碟來:設定檔放在 root 擁有的目錄,權限 0600,只有背景服務能寫;hook 只從這些檔案讀,從不接受 App 傳來的內容。
  • 改 hook 要管理員核准:儲存時如果 hook 和先前核准過的不同,就需要管理員授權,而且由背景服務自己向系統確認;沒動到 hook 的修改則不必再授權。
  • 預設就是安全的那一邊:正式安裝的背景服務不帶任何參數啟動,所以預設值一律是最嚴格的;開發用的選項都必須明確打開。

背景服務用 Rust 寫。macOS 沒有核心層的 WireGuard,每個封包都要經過一次 utun 的系統呼叫,所以吞吐量的上限和用哪種語言無關。真正的決定因素有兩個:Rust 有可以直接內嵌的使用者空間實作 GotaTun,而以 root 執行的程式需要記憶體安全。

當機也不會把網路留在半路

VPN 用戶端最怕的不是連不上,而是斷得不乾淨。程式結束時,系統會一併移除 utun 和掛在上面的路由;但經由實體網卡閘道的路由,以及改過的 DNS 不會跟著消失。路由在重開機後就沒了,用 networksetup 改的 DNS 卻會一直留著,所以硬當之後,最可能讓 Mac 上不了網的,就是 DNS。

變更日誌的流程:改之前先記下、照順序修改、當機、下次啟動先復原、全部還原後才連線;還原失敗的項目留在日誌裡重試 1 改之前先記下 要動的路由和 DNS 的原值 2 照 wg-quick 的順序修改 路由、DNS 逐項套用 ! 當機或斷電 路由會消失,DNS 會留下 3 下次啟動先復原 照日誌把每一項還原 4 全部還原後才連線 介面顯示 Restore Now 還原失敗? 留在日誌裡 5 秒起,間隔加倍 最長每 5 分鐘

先記錄、再修改;當機之後先復原,復原完成才允許下一次連線。

所以背景服務每次修改系統之前,都先把要改的東西寫進日誌,啟動時再把上一次留下的變更逐項還原。還原失敗的項目不會被丟掉,因為忘了 DNS 的備份,DNS 就會永遠卡在隧道的伺服器上;它會留在日誌裡,從 5 秒開始、間隔加倍、最長每 5 分鐘重試一次,網路變動或要連線之前也會再試。

全部還原之前,Turnout 不會建立新的連線:那時記下的「原本的 DNS」,其實是上一條隧道的設定。主視窗和選單列會提示還有東西在等待,並提供 Restore Now;選單裡的「Repair Network Settings…」也能隨時手動做同樣的事。

介面只有一個焦點

Turnout 主要待在選單列:在那裡切換通道、看狀態。主視窗左邊是通道清單,右邊是細節。細節頁只有一個焦點,就是一條路徑:utun → 出口網卡 → 伺服器,用出口網卡的識別色畫成一條線;要換出口網卡,也可以直接在這裡切換。

路徑線在等待握手時:utun102 到 Ethernet 是實線,Ethernet 到伺服器是虛線,上面有一個握手探針

等待握手:第二段是虛線,探針在上面來回。

路徑線在連線後:兩段都變成一對導線,下方顯示握手時間、下載與上傳速度

已連線:線變成一對導線,封包沿著上下兩條線移動,速度跟著實際流量變化。

這條線上的動態全部由資料驅動,沒有裝飾用的動畫:

  • 背景服務回報隧道建立時,第一段(utun 到網卡)變成實線。
  • 第二段(網卡到伺服器)在伺服器回應之前維持虛線,上面有一個握手探針來回;15 秒還沒有回應,就列出可能的原因。
  • 最後一次握手超過 180 秒,線會變成琥珀色。
  • 連線之後,線變成一對導線:上面那條是上傳,封包往外走;下面那條是下載,封包往內走。速度和密度依實測流量分成五個對數等級,從只有 keepalive 到每秒 10 MB。

開啟系統的「減少動態效果」時,這些動畫都會停下來。動畫全部交給 Core Animation 圖層處理,只在流量等級改變時才重新設定:實測未連線約 0.5% CPU,等待握手約 1%,有流量時 2–4%;同樣的效果改用 SwiftUI 每秒重繪 30 次,要用掉 13–19%。流量統計也只在選單或視窗開著的時候才更新,最多每秒一次。

網卡識別色取自 T568 的藍、橘、綠、棕四對線色,綠色改為青綠;選單列圖示在未連線時線停在隧道口前,連線後線走進隧道 網卡識別色:T568 的四對線色 藍 橘 青綠(原為綠) 棕 選單列圖示 未連線 線停在隧道口前 已連線 線走進隧道

網卡識別色與選單列圖示。選單列圖示是品牌圖示的簡化版。

網卡的識別色取自網路線 T568 規格的四對線色:藍、橘、綠、棕。綠色往青綠偏移,避免和代表「已連線」的綠色搞混;每張網卡都會記住自己的顏色。識別色從不用來表示狀態,狀態一律用系統的綠、紅、灰,而且一定搭配形狀和文字。

品牌圖示也延伸到了其他地方。選單列圖示在未連線時,是一個空心的隧道口,線停在入口前;連線之後隧道口變成實心,線走了進去。把 .conf 拖進視窗時,畫面會重畫一次這個圖示:可以匯入,線就走進隧道口,封包開始流動;不能匯入(例如不是 .conf,或一次拖了好幾個檔案),線會停在一個空心的橘色隧道口前,並寫出原因。

可以匯入時:藍線走進隧道口,下方寫著 Drop to import
不能匯入時:線停在空心的橘色隧道口前,下方寫著 Can't import this

把檔案拖進視窗時:可以匯入(左),不能匯入(右)。

目前的進度

Turnout 支援 macOS 15 以上,Apple 晶片和 Intel 的 Mac 都能用。資料平面和路由已經對真實的伺服器跑過,也通過了需要 root 權限的測試;兩張網卡同時搭配 BindInterface 的情況,以及在真實網路上的 DNS,還在等待測試。

Turnout 的網站:turnout.ctsite.cc

我的其他專案:www.ctsite.cc

WireGuard 是 Jason A. Donenfeld 的註冊商標。Turnout 是獨立開發的專案,與 WireGuard 專案沒有關聯,也未獲其背書。macOS 是 Apple Inc. 的商標。

留言