diff --git a/ShadowsocksX-NG2/App/ProxyRuntimeController+SystemProxyGate.swift b/ShadowsocksX-NG2/App/ProxyRuntimeController+SystemProxyGate.swift index 9b065dcc..f8638b7b 100644 --- a/ShadowsocksX-NG2/App/ProxyRuntimeController+SystemProxyGate.swift +++ b/ShadowsocksX-NG2/App/ProxyRuntimeController+SystemProxyGate.swift @@ -142,16 +142,31 @@ extension ProxyRuntimeController { } } - /// helper 可用性门禁(issue #71):已批准即通过;未注册则尝试注册(注册 - /// 本身不弹授权框);待批准或注册失败时置位批准路径并保持待应用。 + /// helper 可用性门禁(issue #71):已批准即通过——仅当 LaunchDaemon 清单 + /// 与上次注册时的指纹漂移(app 更新改过清单)才注销重注刷新 launchd 的 + /// job 定义;未注册则尝试注册(注册本身不弹授权框,成功即记指纹);待批准 + /// 或注册失败时置位批准路径并保持待应用。 private func ensureHelperAvailableForApply() -> Bool { switch systemProxyHelper.status { case .approved: - systemProxyApprovalRequired = false - return true + if Self.helperRegistrationDrifted() { + do { + try systemProxyHelper.unregister() + try systemProxyHelper.register() + Self.storeHelperRegistrationStamp() + } catch { + // 刷新失败不记指纹,下次启动重试;状态退回后由下方分支接手。 + RuntimeLog.emit(.systemProxyHelperRegisterFailed(detail: describe(error))) + } + } + if systemProxyHelper.status == .approved { + systemProxyApprovalRequired = false + return true + } case .notRegistered: do { try systemProxyHelper.register() + Self.storeHelperRegistrationStamp() } catch { RuntimeLog.emit(.systemProxyHelperRegisterFailed(detail: describe(error))) } @@ -166,6 +181,19 @@ extension ProxyRuntimeController { return false } + private static func helperRegistrationDrifted(defaults: UserDefaults = .standard) -> Bool { + SystemProxyHelperRegistrationStamp.drifted( + plistData: SystemProxyHelperIdentity.launchDaemonPlistData, + storedStamp: defaults.string(forKey: SystemProxyHelperRegistrationStamp.defaultsKey)) + } + + private static func storeHelperRegistrationStamp(defaults: UserDefaults = .standard) { + guard let plistData = SystemProxyHelperIdentity.launchDaemonPlistData else { return } + defaults.set( + SystemProxyHelperRegistrationStamp.stamp(plistData), + forKey: SystemProxyHelperRegistrationStamp.defaultsKey) + } + /// 登录项批准路径(issue #71):打开系统设置的登录项面板并立即重试收敛; /// 批准完成前意图保持待应用、系统设置不变。 func openSystemProxyHelperApproval() async { diff --git a/ShadowsocksX-NG2/App/SystemProxyHelperService.swift b/ShadowsocksX-NG2/App/SystemProxyHelperService.swift index 40259141..0ffb0977 100644 --- a/ShadowsocksX-NG2/App/SystemProxyHelperService.swift +++ b/ShadowsocksX-NG2/App/SystemProxyHelperService.swift @@ -1,3 +1,4 @@ +import CryptoKit import Foundation import ServiceManagement @@ -12,12 +13,13 @@ protocol SystemProxyControlling { } /// helper 注册/审批状态缝(issue #71):注册走 SMAppService.daemon;审批缺失 -/// 时 GUI 据此呈现登录项批准路径,不提供直接授权回退。单测以 fake 替换 -/// (真实注册会改动系统登录项状态)。 +/// 时 GUI 据此呈现登录项批准路径,不提供直接授权回退。unregister 供注册清单 +/// 漂移后的定义刷新使用。单测以 fake 替换(真实注册会改动系统登录项状态)。 @MainActor protocol SystemProxyHelperServicing { var status: SystemProxyHelperStatus { get } func register() throws + func unregister() throws func openApprovalPath() } @@ -49,11 +51,33 @@ struct SMAppServiceSystemProxyHelper: SystemProxyHelperServicing { try service.register() } + func unregister() throws { + try service.unregister() + } + func openApprovalPath() { SMAppService.openSystemSettingsLoginItems() } } +/// 注册清单漂移指纹(issue #71 后续):launchd 对 SMAppService 提交的 job +/// 沿用注册时的定义快照,app 更新改动 LaunchDaemon 清单不会自动生效(旧定义 +/// spawn 失败时 launchd 以 EX_CONFIG 节流循环)。以清单内容 SHA-256 检测漂移, +/// 漂移才注销重注;审批与 bundle 签名绑定,重注不重新弹授权。 +enum SystemProxyHelperRegistrationStamp { + static let defaultsKey = "systemProxyHelperRegistrationStamp" + + /// 清单不可读时不判漂移(无基准;未注册场景由注册路径自身兜底)。 + static func drifted(plistData: Data?, storedStamp: String?) -> Bool { + guard let plistData else { return false } + return stamp(plistData) != storedStamp + } + + static func stamp(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } +} + /// 经特权 helper 的系统代理写入器(issue #71):每个请求一条按需连接, /// MachService 连接触发 launchd 激活已注册的 LaunchDaemon。应答在 /// helper 不可达、连接失效、报错或超时时以 typed 错误呈现。 diff --git a/ShadowsocksX-NG2/Domain/SystemProxyHelperContract.swift b/ShadowsocksX-NG2/Domain/SystemProxyHelperContract.swift index e3bebbb6..7ecfae3b 100644 --- a/ShadowsocksX-NG2/Domain/SystemProxyHelperContract.swift +++ b/ShadowsocksX-NG2/Domain/SystemProxyHelperContract.swift @@ -17,6 +17,16 @@ enum SystemProxyHelperIdentity { static let plistName = "com.qiuyuzhou.ShadowsocksX-NG2.systemproxy.plist" static let launchdLabel = "com.qiuyuzhou.ShadowsocksX-NG2.systemproxy" static let clientCodeSigningIdentifier = "com.qiuyuzhou.ShadowsocksX-NG2" + + /// App bundle 内 LaunchDaemon 清单内容(注册漂移指纹的基准)。helper 进程 + /// 内没有 bundle,返回 nil,调用方按「无基准不动作」处理。Contents/Library + /// 不是资源目录,Bundle 资源 API 搜不到,按固定路径直读。 + static var launchDaemonPlistData: Data? { + let url = Bundle.main.bundleURL + .appendingPathComponent("Contents/Library/LaunchDaemons") + .appendingPathComponent(plistName) + return try? Data(contentsOf: url) + } } /// helper XPC 应答:成功携带 typed 结果,失败携带 typed 错误族。 diff --git a/ShadowsocksX-NG2/LaunchDaemons/com.qiuyuzhou.ShadowsocksX-NG2.systemproxy.plist b/ShadowsocksX-NG2/LaunchDaemons/com.qiuyuzhou.ShadowsocksX-NG2.systemproxy.plist index 32c50b9d..f0d5c9b9 100644 --- a/ShadowsocksX-NG2/LaunchDaemons/com.qiuyuzhou.ShadowsocksX-NG2.systemproxy.plist +++ b/ShadowsocksX-NG2/LaunchDaemons/com.qiuyuzhou.ShadowsocksX-NG2.systemproxy.plist @@ -2,12 +2,13 @@ + + BundleProgram + Contents/MacOS/ShadowsocksX-NG2SystemProxyHelper Label com.qiuyuzhou.ShadowsocksX-NG2.systemproxy - ProgramArguments - - Contents/MacOS/ShadowsocksX-NG2SystemProxyHelper - MachServices com.qiuyuzhou.ShadowsocksX-NG2.systemproxy diff --git a/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests+AgentAndSystemProxy.swift b/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests+AgentAndSystemProxy.swift index d3327412..4603aa69 100644 --- a/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests+AgentAndSystemProxy.swift +++ b/ShadowsocksX-NG2/Tests/ProxyRuntimeControllerTests+AgentAndSystemProxy.swift @@ -491,6 +491,43 @@ extension ProxyRuntimeControllerTests { recorder.recorded.contains(.systemProxyUnchanged), "跳过路径发事件行,实际 \(recorder.recorded)") } + + /// LaunchDaemon 清单漂移(issue #71 后续):launchd 沿用注册提交时的 job + /// 定义快照,app 更新改了清单后须注销重注才生效。已批准注册仅在清单指纹 + /// 与注册时不一致时重注(审批与签名绑定,不重弹授权);首次注册成功即记录 + /// 指纹,指纹一致不动作。 + func testHelperRegistrationRefreshesOnlyOnPlistDrift() async throws { + let defaults = UserDefaults.standard + let stampKey = SystemProxyHelperRegistrationStamp.defaultsKey + defer { defaults.removeObject(forKey: stampKey) } + defaults.removeObject(forKey: stampKey) + + let seeded = try makeSeededCatalog() + systemProxyHelper.setStatus(.notRegistered) + let controller = makeController(probe: ProxyRuntimeFixture.FakeProbe.reachable()) + try await controller.activate(seeded.server) + await controller.setAgentEnabled(true) + await controller.setSystemProxyEnabled(true) + + let stampAfterRegister = try XCTUnwrap( + defaults.string(forKey: stampKey), "注册成功应记录 LaunchDaemon 清单指纹") + let registerCountAfterEnable = systemProxyHelper.registerCount + let unregisterCountAfterEnable = systemProxyHelper.unregisterCount + + // 指纹一致:后续收敛不注销重注。 + await controller.convergeSystemProxy() + XCTAssertEqual(systemProxyHelper.registerCount, registerCountAfterEnable, "指纹一致不重注") + XCTAssertEqual(systemProxyHelper.unregisterCount, unregisterCountAfterEnable) + XCTAssertEqual(controller.systemProxyState, .applied) + + // 指纹漂移(模拟 app 更新改了清单):注销重注一次并更新指纹。 + defaults.set("drifted-stamp", forKey: stampKey) + await controller.convergeSystemProxy() + XCTAssertEqual(systemProxyHelper.unregisterCount, unregisterCountAfterEnable + 1, "漂移触发重注") + XCTAssertEqual(systemProxyHelper.registerCount, registerCountAfterEnable + 1) + XCTAssertEqual(defaults.string(forKey: stampKey), stampAfterRegister, "重注后记录当前清单指纹") + XCTAssertEqual(controller.systemProxyState, .applied, "重注后照常收敛应用") + } } /// 测试专用事件接收缝(issue #70 事件行断言用)。 diff --git a/ShadowsocksX-NG2/Tests/SystemProxyFakes.swift b/ShadowsocksX-NG2/Tests/SystemProxyFakes.swift index d3bfe01f..f5d9f80b 100644 --- a/ShadowsocksX-NG2/Tests/SystemProxyFakes.swift +++ b/ShadowsocksX-NG2/Tests/SystemProxyFakes.swift @@ -37,7 +37,9 @@ extension ProxyRuntimeFixture { final class FakeSystemProxyHelperService: SystemProxyHelperServicing { private(set) var currentStatus: SystemProxyHelperStatus private(set) var registerCount = 0 + private(set) var unregisterCount = 0 private(set) var registerError: Error? + private(set) var unregisterError: Error? private(set) var openApprovalPathCount = 0 /// register() 成功后迁移到的状态(模拟 BTM 批准立即达成)。 var statusAfterRegister: SystemProxyHelperStatus = .approved @@ -56,12 +58,22 @@ extension ProxyRuntimeFixture { registerError = error } + func setUnregisterError(_ error: Error?) { + unregisterError = error + } + func register() throws { registerCount += 1 if let registerError { throw registerError } currentStatus = statusAfterRegister } + func unregister() throws { + unregisterCount += 1 + if let unregisterError { throw unregisterError } + currentStatus = .notRegistered + } + func openApprovalPath() { openApprovalPathCount += 1 } diff --git a/ShadowsocksX-NG2/Tests/SystemProxyTests.swift b/ShadowsocksX-NG2/Tests/SystemProxyTests.swift index d75017e2..810118bf 100644 --- a/ShadowsocksX-NG2/Tests/SystemProxyTests.swift +++ b/ShadowsocksX-NG2/Tests/SystemProxyTests.swift @@ -214,4 +214,29 @@ final class SystemProxyTests: XCTestCase { plan.writes.contains { $0.identifier == services[2].identifier }, "没有 Proxies 实体的服务无需写入") } + + // MARK: - 注册清单漂移指纹(issue #71 后续) + + /// launchd 沿用注册提交时的 job 定义快照,清单变更须注销重注才生效; + /// 指纹检测决定是否重注。 + func testRegistrationStampDriftDetection() { + let data = Data("plist-content".utf8) + XCTAssertTrue( + SystemProxyHelperRegistrationStamp.drifted(plistData: data, storedStamp: nil), + "无指纹(首次带检测代码启动)视为漂移") + XCTAssertTrue( + SystemProxyHelperRegistrationStamp.drifted(plistData: data, storedStamp: "outdated"), + "清单内容变更视为漂移") + XCTAssertFalse( + SystemProxyHelperRegistrationStamp.drifted( + plistData: data, storedStamp: SystemProxyHelperRegistrationStamp.stamp(data)), + "指纹一致不漂移") + XCTAssertFalse( + SystemProxyHelperRegistrationStamp.drifted(plistData: nil, storedStamp: nil), + "清单不可读无基准,不判漂移") + XCTAssertNotEqual( + SystemProxyHelperRegistrationStamp.stamp(Data("a".utf8)), + SystemProxyHelperRegistrationStamp.stamp(Data("b".utf8)), + "不同清单内容指纹不同") + } } diff --git a/ShadowsocksX-NG2/project.yml b/ShadowsocksX-NG2/project.yml index 3904d0d9..b5fc7688 100644 --- a/ShadowsocksX-NG2/project.yml +++ b/ShadowsocksX-NG2/project.yml @@ -33,8 +33,9 @@ targets: subpath: Contents/Library/LaunchAgents # 特权系统代理 helper 的 LaunchDaemon 注册清单(issue #71): # SMAppService.daemon(plistName:) 从 bundle 内 - # Contents/Library/LaunchDaemons/ 读取;ProgramArguments 用 bundle 相对 - # 路径;MachServices 键支撑 on-demand 激活。 + # Contents/Library/LaunchDaemons/ 读取;可执行路径用 BundleProgram 键 + # (bundle 相对;ProgramArguments 的相对路径按 PATH 解析会 spawn 失败); + # MachServices 键支撑 on-demand 激活。 - path: LaunchDaemons/com.qiuyuzhou.ShadowsocksX-NG2.systemproxy.plist buildPhase: copyFiles: