person using macbook pro on table


人工補充幾句: 以下正文內容都是AI寫的 這個工具的主要用途是帶Macbook出門避免連AIGC的api直接走國內ip被封號 前提是surge要配置好aigc走指定鏈路(如vps->US家寬) 這樣基本能確保100%不會誤走國內外網直連AIGC API


家裡有一套基於 Surge + PF 防火牆的 fail-closed 網路保護方案:Surge 負責代理分流,PF 做最後一道 killswitch——Surge 程序異常退出時,境外流量自動被 PF 攔截,不會直連洩漏。

但有一個問題:怎麼知道現在的保護狀態是好的?

靠肉眼盯著 Surge 圖示顯然不現實。於是我寫了 NetworkGuard,一個住在狀態列裡的小 App,每 5 秒跑一輪探測,有問題就發系統通知。

它長什麼樣

NetworkGuard 選單欄展開

點開選單,能看到完整的系統快照:

  • PF 防火牆:當前是 guarded / open / lockdown 哪種模式
  • Surge:程序狀態 + 增強模式 + 代理模式(rule/proxy/direct)
  • Xray 橋接:備用代理埠是否在 LISTEN
  • 在家檢測:ARP 和哨兵雙重驗證,確認是不是在自己家的網路裡
  • 快捷操作:臨時放行 30 分鐘、手動 Lockdown、恢復自動監控

狀態列圖示用 SF Symbols,一眼就能看出保護狀態:

圖示含義
checkmark.shield最佳狀態:guarded + Surge 健康
exclamationmark.shieldSurge 異常,需要關注
shield.slash自動 Lockdown(Surge 掛了且不在家)
lock.shield手動鎖定中

在家判定:為什麼需要這個

在外網,Surge 掛掉就必須 Lockdown——因為沒有代理鏈路,境外直連就會洩漏。但在家網裡,即使 Surge 暫時掛掉,我的路由器和 mihomo 還在,流量有其他出路,不必立刻封鎖。

所以 surge-guard(控制 PF 的守護程序)會判斷是否"在家網",邏輯是:

在家 = (ARP 內網 MAC 匹配) AND (哨兵 HTTP 探測透過)

具體說:

  • ARP 組:檢測區域網兩臺裝置的 MAC 地址是否匹配,互為 fallback
  • 哨兵組:向區域網內兩臺伺服器發 HTTP 請求(帶 token 鑑權),任一透過即可
  • 兩類證據都要有:ARP 只能證明你在這個子網,哨兵帶 token 鑑權才能排除偽造

任何一組失敗,就不算"在家",Surge 異常時會進入 Lockdown。

Web 監控面板

NetworkGuard Web 監控面板

App 啟動時會在本機 9800 埠起一個 HTTP 服務,提供三個介面:

  • localhost:9800/ — HTML 儀表盤,每 5 秒自動重新整理
  • /api/status — 當前探測狀態 JSON
  • /api/events — 最近 50 條狀態變化記錄

面板裡能看到所有探測點的實時狀態,以及歷史切換記錄——每次 Surge 重啟、切換模式、ARP 狀態變化都會記錄下來,方便排查"剛才為什麼圖示閃了一下"這類問題。

事件記錄的格式是 JSONL,存在本地,按型別區分:

型別觸發條件是否發通知
pf_modePF 模式切換✅
surgeSurge 程序/模式變化✅(程序變化)
sentinel哨兵可達性變化❌
home在家/外網切換❌
xrayXray 埠變化❌
override臨時放行開始/結束✅

通知只發"真正重要"的事件,哨兵瞬間抖動之類的不會騷擾到你。

去抖動:解決誤報問題

早期版本有一個煩人的問題:Surge 重啟的那幾秒,App 會誤報"Surge 程序退出",圖示閃歎號,還發通知,但實際上 Surge 只是在重啟。

根本原因是所有探測都是"即時判斷"——一次檢測失敗就切狀態。

修復方案是給每個訊號加去抖動:連續 3 次檢測失敗(約 15 秒)才真正切換狀態,但恢復立即生效。用一個 failStreak 字典記錄各訊號的連續失敗次數:

func debounce(_ key: String, rawOk: Bool, stableOk: Bool) -> Bool {
    if rawOk {
        failStreak[key] = 0
        return true          // 恢復立即生效
    }
    let streak = (failStreak[key] ?? 0) + 1
    failStreak[key] = streak
    return streak < 3 ? stableOk : false  // 連續 3 次才切換
}

對 Surge 還有一個額外保護:程序存在但 API 暫時無響應時,保留上次已知的 mode 和 enhanced 狀態,不會因為一次 API 超時就顯示歎號。

另一個坑是 curl 探測哨兵時會走 Surge 的 TUN 介面——Surge 重啟期間 TUN 不通,哨兵探測就失敗了。解決方法是加 --noproxy '*' 強制直連區域網,繞過 TUN:

curl -fsS --max-time 2 --noproxy '*' -H "X-Guard-Token: ..." http://內網IP:18888/healthz

同理,ARP 重新整理快取也從 ping(經過 TUN)改成 TCP 連線 80 埠(走本地子網路由,不經過 Surge)。

當 Surge 掛掉,那 5 秒網路是什麼狀態

這是我做這個系統時仔細推演過的一個問題。

在外網,Surge 掛掉後到 Lockdown 之間大約有 5 秒視窗。這 5 秒裡網路狀態是:

  • 境外非 CN 新連線:已被 PF 攔截(guarded 模式的 allow_wan 表本來就是空的,PF 規則在 Surge 掛之前就封著這些流量)
  • CN IP 新連線:直連放行,但沒有代理,走裸連
  • 已建立的 TCP 連線:PF 狀態表還在,可能再傳幾秒資料

5 秒後切入 Lockdown,強制 kill 所有現有連線狀態。

所以真正的風險視窗不是"Surge 掛了的那 5 秒",而是 Surge 掛之前就有 DIRECT 規則的境外連線正在傳輸——PF 狀態表會讓這條連線多活幾秒。

在家網,Surge 掛了不會 Lockdown,因為 home_trusted 成立——路由器 + mihomo 還在,流量有保障。這是設計行為。

整體架構

NetworkGuard 本身不控制任何防火牆規則,它只讀取狀態檔案和發系統通知。真正控制 PF 的是 surge-guard.sh(root 常駐程序),兩者透過狀態檔案通訊:

surge-guard.sh(root)
  ├── 每 5 秒跑 guard_once()
  ├── 控制 PF anchor 規則
  └── 寫入 ~/.config/surge-guard/state

NetworkGuard.app(使用者態)
  ├── 每 5 秒讀 state 檔案
  ├── 探測 Surge API / ARP / 哨兵 / Xray
  └── 發系統通知 + 更新選單 + 寫事件日誌

還有一個設計:NetworkGuard 程序是整個保護系統的"主開關"。surge-guard.sh 每輪會檢測 NetworkGuard 程序是否在執行,如果不在,自動切換到 open(全放行)模式——讓電腦恢復正常上網。重新開啟 App,保護自動恢復。


整個 App 是單檔案 Swift,用 AppKit + UserNotifications + Network Framework,編譯後約 300KB。如果你也有類似的 Surge + PF 方案想要視覺化監控,這個思路應該值得參考。


📷 封面圖 · Flux Schnell AI 生成 · panshi.io 品牌風格