feat(macos): KillSwitch L0→L3 — NE includeAllNetworks+enforceRoutes+on-demand

macOS 走原生 NE,strict_route 不生效。补齐断网保护到天花板 L3:

VpnChannel.swift
- 缓存 killSwitchEnabled(默认 true,与 Dart AppSettings.killSwitch 对齐)
- setKillSwitch 落地:存标志,manager 已装配则即时重写 NE 配置+保存
- configureKillSwitch:includeAllNetworks+enforceRoutes(L2 OS 强制、扛崩溃)
  + NEOnDemandRule 常开(L3)+ excludeLocalNetworks 放行 LAN
  (enforceRoutes/excludeLocalNetworks 为 macOS 11+ API,#available 守卫)
- stop() gotcha:on-demand 常开时先关 isOnDemandEnabled+save 再 stop,
  否则手动断开被 OS 立刻拉回
- start() options 带 killSwitch 传扩展

PacketTunnelProvider.swift
- startTunnel 读 killSwitch 选项(OS on-demand 自启时 nil→默认 true)
- libbox 回调 includeAllNetworks() 返回该值,与 NE 层对齐

docs/killswitch-design.html: 新增 §6.5 实现方案,状态表 macOS L0→L3

验证:xcodebuild CODE_SIGNING_ALLOWED=NO 两 target SwiftCompile 通过;
端到端 fail-closed/on-demand 需真机手测(NE 难单测)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-30 23:54:32 +08:00
parent e2440e44cc
commit 680f1af205
3 changed files with 122 additions and 8 deletions
@@ -63,7 +63,12 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
LibboxSetup(setup, &setupErr)
if let setupErr { throw setupErr }
let platform = PangolinPlatformInterface(provider: self)
// KillSwitch: app startVPNTunnel options ;OS on-demand
// options nil true(on-demand killswitch )libbox
// includeAllNetworks() NE includeAllNetworks ,
let killSwitch = (options?["killSwitch"] as? NSNumber)?.boolValue ?? true
log.info("startTunnel: killSwitch=\(killSwitch, privacy: .public)")
let platform = PangolinPlatformInterface(provider: self, includeAllNetworks: killSwitch)
self.platform = platform
var newErr: NSError?
@@ -182,9 +187,11 @@ final class PangolinPlatformInterface: NSObject, LibboxPlatformInterfaceProtocol
private var monitor: NWPathMonitor?
private var defaultInterfaceIndex: Int32 = -1
private let monitorQueue = DispatchQueue(label: "pangolin.tunnel.pathmonitor")
private let includeAllNetworksFlag: Bool
init(provider: NEPacketTunnelProvider) {
init(provider: NEPacketTunnelProvider, includeAllNetworks: Bool) {
self.provider = provider
self.includeAllNetworksFlag = includeAllNetworks
super.init()
}
@@ -269,7 +276,7 @@ final class PangolinPlatformInterface: NSObject, LibboxPlatformInterfaceProtocol
// MARK: ()
func underNetworkExtension() -> Bool { true }
func useProcFS() -> Bool { false }
func includeAllNetworks() -> Bool { false }
func includeAllNetworks() -> Bool { includeAllNetworksFlag }
func clearDNSCache() {}
func readWIFIState() -> LibboxWIFIState? { nil }
func systemCertificates() -> (any LibboxStringIteratorProtocol)? { nil }
+73 -2
View File
@@ -33,6 +33,10 @@ final class VpnChannel: NSObject {
private let statsClient = StatsClient()
private var manager: NETunnelProviderManager?
private var sysextDelegate: SysExtActivationDelegate?
// KillSwitch(), true Dart AppSettings.killSwitch
// NE fail-closed:includeAllNetworks + enforceRoutes(L2 OS )
// + NEOnDemandRule (L3 /线) docs/killswitch-design.html §6.5
private var killSwitchEnabled = true
static func register(with registrar: FlutterPluginRegistrar) {
let instance = VpnChannel()
@@ -87,8 +91,26 @@ final class VpnChannel: NSObject {
case "getActiveOutbound":
result("auto")
case "setKillSwitch":
// includeAllNetworks / on-demand kill switch()
result(nil)
let on = (call.arguments as? Bool) ?? true
killSwitchEnabled = on
vpnLog("setKillSwitch=\(on)")
// manager NE (includeAllNetworks/enforceRoutes/on-demand);
// manager , loadOrCreateManager
if let mgr = manager {
Task {
do {
try await self.applyKillSwitchConfig(to: mgr)
vpnLog("setKillSwitch: NE 配置已更新并保存 ✓")
result(nil)
} catch {
let ns = error as NSError
vpnLog("setKillSwitch: 保存 NE 配置失败 ✗ code=\(ns.code) desc=\(ns.localizedDescription)")
result(FlutterError(code: "killswitch_failed", message: error.localizedDescription, details: nil))
}
}
} else {
result(nil)
}
default:
result(FlutterMethodNotImplemented)
}
@@ -109,6 +131,7 @@ final class VpnChannel: NSObject {
vpnLog("step③ startVPNTunnel(options: configContent) …")
try mgr.connection.startVPNTunnel(options: [
"configContent": configJson as NSString,
"killSwitch": NSNumber(value: killSwitchEnabled),
])
vpnLog("step③ startVPNTunnel 调用已返回(实际起停由 NEVPNStatus 流驱动)✓")
result(nil)
@@ -120,6 +143,19 @@ final class VpnChannel: NSObject {
}
private func stop(_ result: @escaping FlutterResult) async {
// on-demand stopVPNTunnel OS on-demand
// start() loadOrCreateManager killSwitchEnabled on-demand
if let mgr = manager, mgr.isOnDemandEnabled {
mgr.isOnDemandEnabled = false
do {
try await mgr.saveToPreferences()
try await mgr.loadFromPreferences()
vpnLog("stop: 已临时关闭 on-demand(避免手动断开被自动拉回)")
} catch {
let ns = error as NSError
vpnLog("stop: 关闭 on-demand 失败(仍继续断开) code=\(ns.code) desc=\(ns.localizedDescription)")
}
}
manager?.connection.stopVPNTunnel()
result(nil)
}
@@ -135,6 +171,7 @@ final class VpnChannel: NSObject {
mgr.protocolConfiguration = proto
mgr.localizedDescription = "Pangolin"
mgr.isEnabled = true
configureKillSwitch(on: mgr) // killSwitchEnabled NE fail-closed
vpnLog(" saveToPreferences(providerBundleId=\(Self.tunnelBundleId)) …")
try await mgr.saveToPreferences()
try await mgr.loadFromPreferences() // , connection
@@ -142,6 +179,40 @@ final class VpnChannel: NSObject {
return mgr
}
// KillSwitch()NE
// killSwitchEnabled manager NE (,):
// L2 OS :includeAllNetworks=true + enforceRoutes=true ,
// neagent ( app/)
// L3 :NEOnDemandRule OS 线/, fail-closed
// excludeLocalNetworks=true LAN(/AirPlay ),
// enforceRoutes/excludeLocalNetworks macOS 11+ API,Runner 10.15 #available
private func configureKillSwitch(on mgr: NETunnelProviderManager) {
if let proto = mgr.protocolConfiguration as? NETunnelProviderProtocol {
proto.includeAllNetworks = killSwitchEnabled
if #available(macOS 11.0, *) {
proto.enforceRoutes = killSwitchEnabled
proto.excludeLocalNetworks = true
}
}
if killSwitchEnabled {
let rule = NEOnDemandRuleConnect()
rule.interfaceTypeMatch = .any
mgr.onDemandRules = [rule]
mgr.isOnDemandEnabled = true
} else {
mgr.onDemandRules = []
mgr.isOnDemandEnabled = false
}
vpnLog(" killSwitch 配置: includeAllNetworks=\(killSwitchEnabled) enforceRoutes=\(killSwitchEnabled) onDemand=\(killSwitchEnabled)")
}
/// killswitch( + + ) setKillSwitch manager
private func applyKillSwitchConfig(to mgr: NETunnelProviderManager) async throws {
configureKillSwitch(on: mgr)
try await mgr.saveToPreferences()
try await mgr.loadFromPreferences()
}
// / PacketTunnel System Extension
// ; didFinishWithResult
private func activateSystemExtensionIfNeeded() async throws {
+39 -3
View File
@@ -50,7 +50,7 @@
<a class="back" href="index.html">← 文档索引</a>
<h1>KillSwitch 设计与跨平台能力矩阵<span class="small">(知识库)</span></h1>
<p class="sub">Pangolin 客户端 · 断网保护设计依据与现状 · 2026-06-22</p>
<p class="sub">Pangolin 客户端 · 断网保护设计依据与现状 · 2026-06-222026-06-30 更新:macOS L0→L3 已实现)</p>
<div class="lead">
KillSwitch 不是「一个开关」,而是<b>分层能力</b>——理想态、平台天花板、当前实现是三件不同的事。本文沉淀其设计依据与现状。
@@ -104,7 +104,7 @@ KillSwitch 不是「一个开关」,而是<b>分层能力</b>——理想态
<tbody>
<tr><td><b>Windows</b></td><td><span class="warn-c">L1</span></td><td>✅ 改 <code>strict_route</code> + 子进程重载 + 退避重连。进程被硬杀仍泄漏。</td><td><code>desktop_vpn_bridge.dart:305</code><code>applyKillSwitchToConfig</code>)、<code>:178</code></td></tr>
<tr><td><b>Linux</b></td><td><span class="warn-c">L1</span></td><td>同 Windows(共用 <code>DesktopVpnBridge</code>)。</td><td>同上</td></tr>
<tr><td><b>macOS</b></td><td><span class="bad-c">L0</span></td><td>❌ stub<code>setKillSwitch</code> 直接 <code>result(nil)</code><code>includeAllNetworks()→false</code>。天花板 L3,实际啥都没做</td><td><code>VpnChannel.swift:69</code><code>PacketTunnelProvider.swift:224</code></td></tr>
<tr><td><b>macOS</b></td><td><span class="ok-c">L3</span></td><td>✅ NE fail-closed(本轮 #1):<code>includeAllNetworks</code>+<code>enforceRoutes</code>L2 OS 强制、扛崩溃)+ <code>NEOnDemandRule</code> 常开(L3)。随 killswitch 开关即时切换;手动断开先关 on-demand 防反弹。详见 §6.5</td><td><code>VpnChannel.swift</code><code>configureKillSwitch</code><code>PacketTunnelProvider.swift</code><code>includeAllNetworks()</code></td></tr>
<tr><td><b>Android</b></td><td><span class="bad-c">L0</span></td><td>❌ stub<code>setKillSwitch</code> 只打日志(TODO 11G)。</td><td><code>MainActivity.kt</code><code>setKillSwitch</code> 分支)</td></tr>
<tr><td><b>iOS</b></td><td></td><td>无客户端。</td><td></td></tr>
</tbody>
@@ -113,7 +113,7 @@ KillSwitch 不是「一个开关」,而是<b>分层能力</b>——理想态
<h2>6. Pangolin 现实判断与决策</h2>
<ul>
<li><b>能力与实现严重不匹配</b>macOS 明明能 L3现在却是 L0(最差);Windows 反而是唯一做了的(L1)。</li>
<li><b>能力与实现严重不匹配</b>macOS 明明能 L3过去却是 L0(最差);Windows 反而是唯一做了的(L1)。<span class="tag ok">已修复</span> macOS 已补齐到 L3(见 §6.5)。</li>
<li><b>Android 客户端方案的 KillSwitch 定位</b>MVP+ 阶段):app 内做到 <b>L1<code>strict_route</code>,与 Windows 一致)</b>,再<b>引导用户开系统 Always-on 拿到 L3</b>,UI 上诚实标注「彻底防泄漏需在系统设置开启」。这是「app 能力上限 + OS 兜底」的合理组合,不夸大。</li>
<li><b>跨端一致性缺口</b>(按性价比排序):
<ol>
@@ -124,6 +124,42 @@ KillSwitch 不是「一个开关」,而是<b>分层能力</b>——理想态
</li>
</ul>
<h2>6.5 macOS L0→L3 实现方案<span class="small">(本轮 #1</span></h2>
<p>macOS 走原生 NE System Extension<code>kUseNativeVpnMacOS=true</code>),<code>strict_route</code> 那套对它不生效,必须用 NE 原语。L0→L3 的达成路径:</p>
<table>
<thead><tr><th>目标 Level</th><th>NE 机制</th><th>效果</th></tr></thead>
<tbody>
<tr><td><span class="lvl ok-c">L2 OS 强制</span></td><td><code>protocolConfiguration.includeAllNetworks=true</code> + <code>enforceRoutes=true</code></td><td>全量流量入隧道、由 <code>neagent</code> 系统级强制,<b>扛 app/内核崩溃</b>(规则在 OS 不在 app 进程)</td></tr>
<tr><td><span class="lvl ok-c">L3 常开+持久</span></td><td>L2 + <code>onDemandRules=[NEOnDemandRuleConnect()]</code> + <code>isOnDemandEnabled=true</code></td><td>掉线/开机窗口也堵,OS 自动拉起,全程 fail-closed</td></tr>
</tbody>
</table>
<h3>改动(2 个原生文件,不动 Dart/服务端)</h3>
<div class="card">
<b><code>client/macos/Runner/VpnChannel.swift</code></b>
<ol>
<li>新增缓存字段 <code>killSwitchEnabled</code>(默认 <code>true</code>,与 Dart <code>AppSettings.killSwitch</code> 默认对齐)。</li>
<li><code>setKillSwitch</code> 落地:存标志;若 <code>manager</code> 已存在 → 重新 <code>applyKillSwitchConfig</code> + <code>saveToPreferences</code> 即时生效。</li>
<li><code>loadOrCreateManager</code> 按标志设:<code>includeAllNetworks</code> / <code>enforceRoutes=true</code> / <code>excludeLocalNetworks=true</code>(放行 LAN 打印机等)/ <code>onDemandRules</code> + <code>isOnDemandEnabled</code></li>
<li><b>关键 gotcha — <code>stop()</code></b>on-demand 常开时直接 <code>stopVPNTunnel()</code> 会被 OS 立刻拉回。手动断开须<b></b> <code>isOnDemandEnabled=false</code> + <code>saveToPreferences</code> <b></b> stop;否则「断开」按钮失效。</li>
<li><code>start()</code><code>startVPNTunnel(options:)</code> 附带 <code>killSwitch</code> 标志传给扩展。</li>
</ol>
</div>
<div class="card">
<b><code>client/macos/PacketTunnel/PacketTunnelProvider.swift</code></b>
<ol>
<li><code>startTunnel(options:)</code> 读取 <code>killSwitch</code> 选项并缓存。</li>
<li>libbox 回调 <code>includeAllNetworks()</code> 返回该缓存值(与 NE 层对齐,让 sing-box 不装绕行路由)。</li>
</ol>
</div>
<p class="small">无需新增 entitlement<code>packet-tunnel-provider</code> 已有);<code>includeAllNetworks</code>/<code>enforceRoutes</code>/<code>excludeLocalNetworks</code>/on-demand 均 macOS 10.15/11+ 可用,Runner 部署目标满足。</p>
<h3>验证(NE 难单测,靠真机手测)</h3>
<ol>
<li><code>flutter build macos</code> 通过、sysext 激活。</li>
<li>killswitch 开 + 连接 → 正常上网;<b>杀扩展进程或拔隧道</b> → 流量被阻断(无 IP/DNS 泄漏)= 跨过 L1→L2 质变线。</li>
<li>on-demand:开机/掉线窗口隧道自动拉起;手动「断开」能真断、不反弹(验 <code>stop()</code> 的 on-demand 处理)。</li>
<li>killswitch 关 → on-demand 取消、回普通连接。</li>
</ol>
<h2>7. 关联待办</h2>
<p>KillSwitch 相关待办见项目 <code>todo/</code><code>/todo list</code>),主要三条:</p>
<ul>