讓被放棄的 MacBook Air 2010 繼續連上 Tailscale:patch Go stdlib 的完整紀錄

此為深度內容 — 這篇文章深度分析 Go 標準庫 patch,探討舊 Mac 延壽實戰。

需要登入才能閱讀完整文章。

2010 年的 MacBook Air,11 吋,Intel Core 2 Duo,macOS 10.14 Mojave——這是這台機器能跑的最後一個 macOS 版本。Apple 在 Catalina 砍掉了對這個世代的支援,Mojave 就是終點。

這台機器還活著。還在用。然後 Tailscale 官方宣布放棄 Mojave 支援。

症狀

更新 Tailscale 之後,執行時直接 crash:

dyld: Symbol not found: _SecTrustCopyCertificateChain
  Referenced from: ./tailscale (which was built for Mac OS X 12.0)
Abort trap: 6

SecTrustCopyCertificateChain 是 Apple Security framework 在 macOS 12 才加入的 API。Mojave 是 10.14,這個符號根本不存在。dyld 在啟動時找不到,程式還沒執行就死了。

兩條死路

第一個直覺:CGO_ENABLED=0。Go 有 cgo,關掉不就沒有 C 依賴了?

不行。Go 在 darwin 上即使關掉 CGO,仍然用 //go:cgo_import_dynamic 搭配組合語言 trampoline 在執行期動態載入 Security.framework 的符號。這不是真正的零系統依賴,CGO_ENABLED 只控制你自己寫的 cgo,管不到標準函式庫內部的機制。

第二個直覺:MACOSX_DEPLOYMENT_TARGET=10.14。這個環境變數不就是用來指定最低系統版本的嗎?

也不行。MACOSX_DEPLOYMENT_TARGET 只改 Mach-O header 裡的 minimum version 標記,告訴 linker「這個 binary 宣稱支援 10.14」,但它完全不影響程式碼實際呼叫的 API。Binary 裡還是有 _SecTrustCopyCertificateChain 這個符號,Mojave 上依然找不到。

根因

問題在 Go 標準函式庫本身。crypto/x509 在 macOS 上的實作呼叫了 SecTrustCopyCertificateChain,這是 Go 1.26 的 stdlib,而 Go 1.26 把 macOS 最低支援版本拉到 12+。

要解這個問題,必須在編譯時把這個呼叫換掉。

解法:Go -overlay

Go 有一個鮮為人知的 build flag:-overlay

它接受一個 JSON 檔,讓你在 build 階段用自訂檔案替換任意路徑的原始碼——包括標準函式庫。不需要 fork Go,不需要修改 $GOROOT,只要提供替換檔案和一張對應表,Go 就會在編譯時使用你的版本。

需要 patch 的是三個 stdlib 檔案:

crypto/x509/internal/macos/security.go — 宣告 C 函式介面的地方。移除 SecTrustCopyCertificateChain,改為宣告兩個 Mojave 就有的舊 API:

// 移除:macOS 12+ only
// func SecTrustCopyCertificateChain(trust CFRef) (CFRef, error)

// 加入:macOS 10.7+ 可用
//go:cgo_import_dynamic x509_SecTrustGetCertificateCount SecTrustGetCertificateCount "/System/Library/Frameworks/Security.framework/Versions/A/Security"
//go:cgo_import_dynamic x509_SecTrustGetCertificateAtIndex SecTrustGetCertificateAtIndex "/System/Library/Frameworks/Security.framework/Versions/A/Security"

crypto/x509/internal/macos/security.s — 組合語言 trampoline。對應移除舊的,加入新的兩個。

crypto/x509/root_darwin.go — 實際使用的地方,從「一次取得整個 chain」改為「先取數量,再逐一取出」:

// 原本(macOS 12+):
chainRef, err := macos.SecTrustCopyCertificateChain(trustObj)
defer macos.CFRelease(chainRef)
for i := 0; i < macos.CFArrayGetCount(chainRef); i++ {
    certRef := macos.CFArrayGetValueAtIndex(chainRef, i)
    // ...
}

// 替換後(macOS 10.7+ 相容):
certCount := macos.SecTrustGetCertificateCount(trustObj)
for i := 0; i < certCount; i++ {
    certRef := macos.SecTrustGetCertificateAtIndex(trustObj, i)
    // ...
}

這裡有一個細節值得注意:Apple Core Foundation 有嚴格的記憶體管理規則。函式名稱含 CopyCreate 的,呼叫方拿到所有權,要自己 CFRelease;函式名稱含 Get 的,是 borrowed reference,不需要 release。SecTrustCopyCertificateChain 是 Copy,原本有 defer CFReleaseSecTrustGetCertificateAtIndex 是 Get,替換後不加 release。搞錯會 memory leak 或 double free。

一個意外的小麻煩

Go 1.26 把內部套件從 crypto/x509/internal/macOS(大寫 S)改名為 macos(全小寫)。同樣的 patch 邏輯,在 Go 1.23–1.25 和 Go 1.26+ 要用不同的路徑和套件名稱。

所以專案提供了兩組 overlay 檔案,Makefile 在 build 時自動偵測 Go 版本選擇對應的版本,也可以手動指定:

make GO_OVERLAY=go123    # Go 1.23–1.25
make GO_OVERLAY=go126    # Go 1.26+

驗證

build 完之後怎麼確認 patch 有效?靠 symbol 驗證:

make verify

這個步驟用 Python 腳本解析 Mach-O header,確認:

  • SecTrustGetCertificateCountSecTrustGetCertificateAtIndex 存在
  • SecTrustCopyCertificateChain 不存在

只要 forbidden symbol 還在,就代表 patch 沒生效,binary 到 Mojave 上一樣會 crash。

部署

把 binary 推到 Mojave 機器之後,還有一個坑:tailscale status 指令可能卡住不動。

原因是官方 CLI 預設連接 macOS GUI 的 socket 路徑,但自己編譯的 tailscaled daemon 使用的是 /var/run/tailscaled.sock。兩邊對不上,CLI 就一直等待。

解法是加一個 alias:

alias tailscale="/path/to/tailscale --socket=/var/run/tailscaled.sock"

這件事的本質

一台 2010 年出廠的機器,被 Apple 停止支援、被 Tailscale 放棄、被 Go 標準函式庫的 API 升級牆擋住——每一層都是獨立的阻力,每一層都有對應的繞法。

CGO_ENABLED=0MACOSX_DEPLOYMENT_TARGET 是直覺解,但都是治標不治本。Go -overlay 是正確的切入點:在問題實際發生的地方解決問題,在 build 階段替換 API,而不是試圖說服 linker 或 dyld 假裝問題不存在。

舊硬體被放棄是必然的。但「被放棄」和「不能用」之間,還有很長一段距離。

原始碼與預編譯 binary:github.com/stanwu/tailscale-macos-mojave

想看更多作品、服務與主站整理,請前往 stanwu.org