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 的主視窗。畫面中的通道與位址都是示範資料,閘道位址已遮蔽。
名字的由來:鐵道上的轉轍器
Turnout 是鐵道上的轉轍器,也就是道岔:同一條軌道在這裡分岔,扳過去之後,列車就開上另一條線。Mac 上的流量也有同樣的需求:大部分該走進 VPN 隧道,但有些應該繞過去,從另一張網卡直接出去。Turnout 做的,就是替流量扳這個道岔。
Turnout 的 App 圖示。
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 相同:
- 建立 utun 介面
- 執行 PreUp(這時
%i已經是實際的 utun 名稱) - 設定 WireGuard
- 位址、MTU,啟用介面
- 路由
- DNS
- 執行 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
編輯器有表單和純文字兩種檢視,但設定只有一個來源,就是那段文字。解析器是無損的:解析之後再寫回去,會得到一字不差的原文;從表單改一個欄位,只會重寫那一行,你的註解和排列順序都會留著。有問題的地方會連同行號列出來,修好之前不能儲存;新建或匯入的通道在儲存之前都只是草稿,不會送到背景服務。
編輯器的文字檢視:就是原本的 .conf,#@ 註解照樣留著(私鑰已遮蔽)。
有些細節是從實際的設定檔學來的。例如 RouterOS 匯出用戶端設定時,會用 AAAA…AEA= 代替它不知道的私鑰。這樣的金鑰解析得過,卻永遠無法握手,所以 Turnout 會特別提醒你。
選路:兩個擴充,同一個原理
在 wg-quick 之上,Turnout 只多做了兩件事,而且兩件都需要:
- 指定加密流量的出口(
#@ BindInterface = en0):加密後的 UDP 封包一定從這張網卡出去,就算預設路由在另一張網卡上也一樣;連到伺服器的主機路由也走它。 - 依目的地繞過隧道(
#@ Route = 192.168.0.0/16 dev en1):這些網段改從 en1 出去,不進隧道。
上面那份設定的效果:一般流量進隧道、加密後從 en0 送出;192.168.0.0/16 從 en1 直接出去。
在規劃路由時,這兩個擴充其實是同一件事:「經由網卡 X 目前的閘道送出」。閘道不寫在設定檔裡,而是背景服務在套用時才去查,閘道換了就重新套用。同一個目的地符合多條規則時,繞道優先,其次是連到伺服器的固定路由,最後才是隧道。
表單檢視裡,出口網卡和繞道路由都是從選單挑網卡。
網卡被拔掉時怎麼辦?這裡的決定和 wg-quick 一致:連到伺服器的固定路由會變成黑洞,讓握手封包不會繞進隧道裡打轉;繞道的路由則先暫停,等網卡回來再恢復。
跟著網路走
換了 Wi-Fi、插上網路線、閘道變了,Turnout 都會跟著調整。背景服務監聽系統的路由事件,等網路安靜 500 毫秒(最多等 5 秒)之後,把實體路由重新指向目前的閘道、在綁定的網卡變動時重新綁定、依出口網卡重算自動 MTU(出口網卡的 MTU 減 80),並重新套用 DNS。每一步都可以重複執行:這些調整本身也會觸發路由事件,但下一輪檢查時什麼都不用改。
把權限切成兩半
背景服務必須以 root 執行,而且會執行設定檔裡的 hook。處理得不小心,這就是一個本機提權漏洞。所以 Turnout 把責任切得很清楚:
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。
先記錄、再修改;當機之後先復原,復原完成才允許下一次連線。
所以背景服務每次修改系統之前,都先把要改的東西寫進日誌,啟動時再把上一次留下的變更逐項還原。還原失敗的項目不會被丟掉,因為忘了 DNS 的備份,DNS 就會永遠卡在隧道的伺服器上;它會留在日誌裡,從 5 秒開始、間隔加倍、最長每 5 分鐘重試一次,網路變動或要連線之前也會再試。
全部還原之前,Turnout 不會建立新的連線:那時記下的「原本的 DNS」,其實是上一條隧道的設定。主視窗和選單列會提示還有東西在等待,並提供 Restore Now;選單裡的「Repair Network Settings…」也能隨時手動做同樣的事。
介面只有一個焦點
Turnout 主要待在選單列:在那裡切換通道、看狀態。主視窗左邊是通道清單,右邊是細節。細節頁只有一個焦點,就是一條路徑:utun → 出口網卡 → 伺服器,用出口網卡的識別色畫成一條線;要換出口網卡,也可以直接在這裡切換。
等待握手:第二段是虛線,探針在上面來回。
已連線:線變成一對導線,封包沿著上下兩條線移動,速度跟著實際流量變化。
這條線上的動態全部由資料驅動,沒有裝飾用的動畫:
- 背景服務回報隧道建立時,第一段(utun 到網卡)變成實線。
- 第二段(網卡到伺服器)在伺服器回應之前維持虛線,上面有一個握手探針來回;15 秒還沒有回應,就列出可能的原因。
- 最後一次握手超過 180 秒,線會變成琥珀色。
- 連線之後,線變成一對導線:上面那條是上傳,封包往外走;下面那條是下載,封包往內走。速度和密度依實測流量分成五個對數等級,從只有 keepalive 到每秒 10 MB。
開啟系統的「減少動態效果」時,這些動畫都會停下來。動畫全部交給 Core Animation 圖層處理,只在流量等級改變時才重新設定:實測未連線約 0.5% CPU,等待握手約 1%,有流量時 2–4%;同樣的效果改用 SwiftUI 每秒重繪 30 次,要用掉 13–19%。流量統計也只在選單或視窗開著的時候才更新,最多每秒一次。
網卡識別色與選單列圖示。選單列圖示是品牌圖示的簡化版。
網卡的識別色取自網路線 T568 規格的四對線色:藍、橘、綠、棕。綠色往青綠偏移,避免和代表「已連線」的綠色搞混;每張網卡都會記住自己的顏色。識別色從不用來表示狀態,狀態一律用系統的綠、紅、灰,而且一定搭配形狀和文字。
品牌圖示也延伸到了其他地方。選單列圖示在未連線時,是一個空心的隧道口,線停在入口前;連線之後隧道口變成實心,線走了進去。把 .conf 拖進視窗時,畫面會重畫一次這個圖示:可以匯入,線就走進隧道口,封包開始流動;不能匯入(例如不是 .conf,或一次拖了好幾個檔案),線會停在一個空心的橘色隧道口前,並寫出原因。
把檔案拖進視窗時:可以匯入(左),不能匯入(右)。
目前的進度
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. 的商標。
留言
張貼留言