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 品牌风格