
人工补充几句: 以下正文内容都是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 秒跑一轮探测,有问题就发系统通知。
它长什么样

点开菜单,能看到完整的系统快照:
- PF 防火墙:当前是 guarded / open / lockdown 哪种模式
- Surge:进程状态 + 增强模式 + 代理模式(rule/proxy/direct)
- Xray 桥接:备用代理端口是否在 LISTEN
- 在家检测:ARP 和哨兵双重验证,确认是不是在自己家的网络里
- 快捷操作:临时放行 30 分钟、手动 Lockdown、恢复自动监控
状态栏图标用 SF Symbols,一眼就能看出保护状态:
| 图标 | 含义 |
|---|---|
checkmark.shield | 最佳状态:guarded + Surge 健康 |
exclamationmark.shield | Surge 异常,需要关注 |
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 监控面板

App 启动时会在本机 9800 端口起一个 HTTP 服务,提供三个接口:
localhost:9800/— HTML 仪表盘,每 5 秒自动刷新/api/status— 当前探测状态 JSON/api/events— 最近 50 条状态变化记录
面板里能看到所有探测点的实时状态,以及历史切换记录——每次 Surge 重启、切换模式、ARP 状态变化都会记录下来,方便排查"刚才为什么图标闪了一下"这类问题。
事件记录的格式是 JSONL,存在本地,按类型区分:
| 类型 | 触发条件 | 是否发通知 |
|---|---|---|
| pf_mode | PF 模式切换 | ✅ |
| surge | Surge 进程/模式变化 | ✅(进程变化) |
| sentinel | 哨兵可达性变化 | ❌ |
| home | 在家/外网切换 | ❌ |
| xray | Xray 端口变化 | ❌ |
| 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 品牌风格