fix(client/macos+server): 隧道运行时打通——DNS 劫持 + 死锁/空指针修复

接续 447f3f4(系统扩展可加载),修复"扩展能起但连上无法上网"的运行时问题,
现已在 macOS 15(cara)端到端连通:出口=节点 IP、国外站可达、DNS 经隧道解析。

服务端(clientconfig.go):
- route.rules 首条加 {"action":"hijack-dns","port":[53]}(排在 LAN 直连规则前)。
  否则发往隧道 DNS(172.19.0.2:53)的查询被 172.16.0.0/12 吞去直连,域名解析失败。
  sing-box 1.13 按端口劫持(protocol:dns 需先 sniff,不稳)。

客户端——保留三个真 bug 修复:
- startTunnel 的 libbox 启动移到后台队列:避免在 provider 队列同步阻塞,与
  openTun→setTunnelNetworkSettings 回调三方死锁(隧道永远卡 connecting)。
- startOrReloadService(options:) 传非空 LibboxOverrideOptions():传 nil 致空指针 SIGSEGV。
- startDefaultInterfaceMonitor 阻塞到首个 path 更新再返回:修 "no available network interface"。
- 清除排障期临时诊断代码;CFBundleVersion 递增(sysextd 按版本去重)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JEHzjEcFzvGwgbxT6Wbt6c
This commit is contained in:
wangjia
2026-06-22 08:57:55 +08:00
parent dae321a3f0
commit cebc9a1c4f
4 changed files with 53 additions and 87 deletions
@@ -26,48 +26,48 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
override func startTunnel(options: [String: NSObject]?,
completionHandler: @escaping (Error?) -> Void) {
log.info("startTunnel")
do {
let rawConfig = try resolveConfig(options)
// libbox :startOrReloadService openTun
// setTunnelNetworkSettings, provider ; provider
// (NE startTunnel ) startOrReloadService
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
guard let self else { return }
do {
let configContent = try self.resolveConfig(options)
guard let base = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else {
throw simpleError("no app group container")
guard let base = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else {
throw simpleError("no app group container")
}
let work = base.appendingPathComponent("work", isDirectory: true)
try? FileManager.default.createDirectory(at: work, withIntermediateDirectories: true)
let setup = LibboxSetupOptions()
setup.basePath = base.path
setup.workingPath = work.path
setup.tempPath = NSTemporaryDirectory()
var setupErr: NSError?
LibboxSetup(setup, &setupErr)
if let setupErr { throw setupErr }
let platform = PangolinPlatformInterface(provider: self)
self.platform = platform
var newErr: NSError?
guard let server = LibboxNewCommandServer(self, platform, &newErr) else {
throw newErr ?? simpleError("LibboxNewCommandServer returned nil")
}
try server.start()
// options: libbox StartOrReloadService options,
// nil command_server.go:175 SIGSEGV()
try server.startOrReloadService(configContent, options: LibboxOverrideOptions())
self.commandServer = server
log.info("startTunnel: service started")
completionHandler(nil)
} catch {
log.error("startTunnel failed: \(error.localizedDescription, privacy: .public)")
completionHandler(error)
}
let work = base.appendingPathComponent("work", isDirectory: true)
try? FileManager.default.createDirectory(at: work, withIntermediateDirectories: true)
// rule-set(#5); sing-box box.log(debug)
let configContent = Self.stripRemoteRuleSets(
rawConfig, logOutput: base.appendingPathComponent("box.log").path)
let setup = LibboxSetupOptions()
setup.basePath = base.path
setup.workingPath = work.path
setup.tempPath = NSTemporaryDirectory()
var setupErr: NSError?
LibboxSetup(setup, &setupErr)
if let setupErr { throw setupErr }
let platform = PangolinPlatformInterface(provider: self)
self.platform = platform
var newErr: NSError?
guard let server = LibboxNewCommandServer(self, platform, &newErr) else {
throw newErr ?? simpleError("LibboxNewCommandServer returned nil")
}
try server.start()
// options: libbox StartOrReloadService options,
// nil command_server.go:175 SIGSEGV(),
try server.startOrReloadService(configContent, options: LibboxOverrideOptions())
self.commandServer = server
log.info("startTunnel: service started")
completionHandler(nil)
} catch {
// stderr.log; public os_log <private>
FileHandle.standardError.write("startTunnel failed: \(error)\n".data(using: .utf8)!)
log.error("startTunnel failed: \(error.localizedDescription, privacy: .public)")
completionHandler(error)
}
}
@@ -100,26 +100,6 @@ final class PacketTunnelProvider: NEPacketTunnelProvider {
guard !content.isEmpty else { throw simpleError("empty sing-box config") }
return content
}
// rule-set , route.rules rule_set
// .srs()
static func stripRemoteRuleSets(_ json: String, logOutput: String) -> String {
guard let data = json.data(using: .utf8),
var root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
var route = root["route"] as? [String: Any] else {
return json
}
if route["rule_set"] != nil { route.removeValue(forKey: "rule_set") }
if let rules = route["rules"] as? [[String: Any]] {
route["rules"] = rules.filter { $0["rule_set"] == nil }
}
root["route"] = route
// sing-box box.log(debug),
root["log"] = ["level": "debug", "timestamp": true, "output": logOutput]
guard let out = try? JSONSerialization.data(withJSONObject: root),
let s = String(data: out, encoding: .utf8) else { return json }
return s
}
}
// LibboxCommandServerHandler:
@@ -136,15 +116,7 @@ extension PacketTunnelProvider: LibboxCommandServerHandlerProtocol {
}
func setSystemProxyEnabled(_ enabled: Bool) throws {}
func writeStatus(_ message: LibboxStatusMessage?) {}
func writeLogs(_ messageList: (any LibboxLogIteratorProtocol)?) {
// sing-box stderr.log,
guard let it = messageList else { return }
while it.hasNext() {
guard let entry = it.next() else { continue }
let line = "[box] \(entry.message)\n"
FileHandle.standardError.write(line.data(using: .utf8) ?? Data())
}
}
func writeLogs(_ messageList: (any LibboxLogIteratorProtocol)?) {}
func writeConnectionEvents(_ events: LibboxConnectionEvents?) {}
func writeGroups(_ message: (any LibboxOutboundGroupIteratorProtocol)?) {}
func writeDebugMessage(_ message: String?) {}
-11
View File
@@ -7,17 +7,6 @@
import Foundation
import NetworkExtension
// stderr/stdout App Group , libbox(Go)
// fatal error / panic stderr
if let c = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: "BYL4KQHMTN.com.pangolin.pangolin") {
let p = c.appendingPathComponent("stderr.log").path
freopen(p, "a", stderr)
freopen(p, "a", stdout)
setvbuf(stderr, nil, _IONBF, 0)
FileHandle.standardError.write("=== boot \(Date()) ===\n".data(using: .utf8)!)
}
autoreleasepool {
NEProvider.startSystemExtensionMode()
}
@@ -634,7 +634,7 @@
baseConfigurationReference = C57BBFD43F9175D1E23685EF /* Pods-RunnerTests.debug.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CURRENT_PROJECT_VERSION = 7;
CURRENT_PROJECT_VERSION = 13;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.RunnerTests;
@@ -649,7 +649,7 @@
baseConfigurationReference = F8904897A48DC81799B8752E /* Pods-RunnerTests.release.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CURRENT_PROJECT_VERSION = 7;
CURRENT_PROJECT_VERSION = 13;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.RunnerTests;
@@ -664,7 +664,7 @@
baseConfigurationReference = B21E68FC1F5D33DD67A0DF5E /* Pods-RunnerTests.profile.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CURRENT_PROJECT_VERSION = 7;
CURRENT_PROJECT_VERSION = 13;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.pangolin.pangolin.RunnerTests;
@@ -935,7 +935,7 @@
CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 7;
CURRENT_PROJECT_VERSION = 13;
DEVELOPMENT_TEAM = BYL4KQHMTN;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
@@ -985,7 +985,7 @@
CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements;
CODE_SIGN_IDENTITY = "Developer ID Application";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 7;
CURRENT_PROJECT_VERSION = 13;
DEVELOPMENT_TEAM = BYL4KQHMTN;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
@@ -1034,7 +1034,7 @@
CODE_SIGN_ENTITLEMENTS = PacketTunnel/PacketTunnel.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 7;
CURRENT_PROJECT_VERSION = 13;
DEVELOPMENT_TEAM = BYL4KQHMTN;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
+6 -1
View File
@@ -126,8 +126,13 @@ func BuildClientConfig(node *nodes.NodeRow, dpUUID, deriveKey string, opts Clien
"tolerance": 50,
}
// Route: LAN direct;(可选)国内 IP/域名直连;其余 via auto。
// Route: DNS 劫持 → LAN direct(可选)国内直连 → 其余 via auto。
routeRules := []any{
// DNS 劫持(sing-box 1.13 action=hijack-dns,按目的端口 53 匹配,不依赖 sniff):
// 把发往隧道 DNS(172.19.0.2:53)的查询交给 sing-box DNS 模块解析。必须排在 LAN
// 直连规则之前——否则 172.19.x 落在下面的 172.16.0.0/12 里,DNS 会被吞去直连、解析
// 失败导致打不开网站(TUN 模式 DNS 劫持是必需项,缺失则隧道连上也无法上网)。
map[string]any{"action": "hijack-dns", "port": []int{53}},
map[string]any{
"ip_cidr": []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8"},
"outbound": "direct",