Files
XStats/Helper/main.swift
T
缘生 6a7aeffb83 fix(security): 修复辅助工具鉴权、升级路径与卸载误删 (#1)
* fix(security): 修复辅助工具鉴权降级与五处正确性缺陷

代码审查(安全/质量/性能/架构四维度)发现的问题,逐条修复。

辅助工具鉴权(High)
  ad-hoc 构建下 clientRequirement 退化为只校验 bundle identifier,不含
  anchor 约束,任何进程 codesign -s - 伪造同名即可连上 root 辅助工具,
  驱动 DNS、睡眠、本地快照与风扇。project.yml 默认就是 ad-hoc 签名,
  而 make build 是文档化的安装路径,开发机上属常态。
  改为无团队签名时返回 nil 并拒绝连接,与在线升级在 ad-hoc 下直接禁用
  的策略一致;此时 App 走一次性管理员授权回退,功能不丢。

卸载器批量误删
  validate 只拒绝 com.apple. 前缀,包名为 "com" 的应用可通过;随后
  hasPrefix("com.") 把 leftovers 扫描的十余个目录(Preferences、Caches、
  Containers、Saved Application State 等)下所有 com.* 条目当成残留列出
  并默认勾选,一次点击即整体移入废纸篓。前缀匹配改为要求至少三段反向
  域名,精确匹配不受影响。

退出时静默跳过恢复睡眠
  restoreDefaultsSynchronously 的 guard 在循环体内用 return 而非 continue,
  第一步取代理失败就连带跳过第二步,Mac 保持禁止睡眠。同时把两步各等 1 秒
  改为总预算 1.5 秒,避免主线程退出卡顿。

公网 IP 查询重入保护失效
  isLookingUpPublic 同时承担重入锁与转圈开关两个相反职责,有缓存且非强制
  刷新时恒为 false,guard 形同虚设,两个 Task 可各发三次请求并发写缓存,
  失败结果可能覆盖好结果。拆出独立的 isFetchingPublic,并用 defer 确保
  所有退出路径复位。

诊断导出的崩溃报告未脱敏
  日志与清理记录都走了 redact,但 .ips 崩溃报告原样入包,其中可能夹带
  进程内存中的 WebDAV 目录、用户名、公网 IP 与主机名,与界面“不含 IP、
  序列号与硬件地址”的声明不符。改为同样脱敏后写入。

PowerSampler
  channels(in:) 的 unsafeBitCast 改为 Unmanaged.fromOpaque.takeUnretainedValue。
  未加 deinit 释放 IOReport 订阅:IOReport 是私有框架,无公开释放入口且
  无法验证其是否为 CF 对象,而全进程只有一个 PowerSampler、订阅各只创建
  一次且随进程存在,不是增长型泄漏——已就此写明注释。

补回归测试:clientRequirement 的 ad-hoc 分支与 anchor 约束、
matchesIdentifier 的过宽前缀与派生名匹配。

make test 141 项通过(新增 4 项),Release 完整构建通过。

* fix(security): 完善辅助工具升级与卸载边界校验

* fix(ui): 拆分评分条视图以解决 CI 类型检查超时

* fix(webdav): 规范化目录路径以兼容 macOS 15
2026-09-21 09:34:01 +08:00

277 lines
11 KiB
Swift

// Copyright (c) 2026 GiantAccel, LLC
// XStats modifications Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later AND MIT
// See LICENSE, LICENSING.md and LICENSES/OpenStats-MIT.txt.
//
// XStatsHelper
//
// 以 root 运行的辅助工具,由 SMAppService.daemon 注册。
// 只做固定的几件事:设置风扇转速、切换“合盖不睡眠”、刷新 DNS、释放内存、设置 DNS 服务器,
// 并在客户端断开时恢复风扇与睡眠设置。
//
import Foundation
import HelperShared
import os
import SMC
/// 写入系统统一日志,应用“导出诊断信息”时一并收集
private let log = Logger(subsystem: "work.12306.xstats.helper", category: "helper")
final class HelperService: NSObject, NSXPCListenerDelegate, XStatsHelperProtocol, @unchecked Sendable {
/// 所有可变状态只在该队列上访问
private let queue = DispatchQueue(label: "work.12306.xstats.helper.state")
private let listener = NSXPCListener(machServiceName: HelperConstants.machServiceName)
private let stateURL = URL(fileURLWithPath: "/Library/Application Support/XStats/helper-state.plist")
private let idleTimeout: TimeInterval = 30
/// 只接受与辅助工具自身同一团队签名的调用方
private let clientRequirement = HelperConstants.clientRequirement(teamIdentifier: CodeSigningInfo.currentTeamIdentifier())
private var connections: Set<ObjectIdentifier> = []
private var fans: FanControl?
private var manualFans: Set<Int> = []
private var sleepDisabledByHelper = false
private var idleExit: DispatchWorkItem?
func run() {
log.notice("辅助工具启动,协议版本 \(HelperConstants.protocolVersion)")
queue.sync {
fans = (try? SMCConnection()).map(FanControl.init)
if fans == nil { log.error("无法打开 SMC") }
recoverFromPreviousRun()
scheduleIdleExit()
}
listener.delegate = self
listener.resume()
dispatchMain()
}
// MARK: NSXPCListenerDelegate
func listener(_ listener: NSXPCListener, shouldAcceptNewConnection connection: NSXPCConnection) -> Bool {
// 没有团队签名(ad-hoc 开发构建)时无法安全鉴别调用方,一律拒绝服务。
// 客户端禁用安装与风扇/合盖控制;支持回退的维护操作走一次性管理员授权。
guard let clientRequirement else {
log.error("辅助工具未经团队签名,拒绝 XPC 连接")
return false
}
// 系统在收到消息前校验调用方签名,不符合要求的连接会被直接失效
connection.setCodeSigningRequirement(clientRequirement)
connection.exportedInterface = NSXPCInterface(with: XStatsHelperProtocol.self)
connection.exportedObject = self
let id = ObjectIdentifier(connection)
connection.invalidationHandler = { [weak self] in
guard let self else { return }
self.queue.async { self.connectionClosed(id) }
}
queue.sync {
connections.insert(id)
idleExit?.cancel()
}
connection.resume()
return true
}
// MARK: XStatsHelperProtocol
func protocolVersion(reply: @escaping @Sendable (Int) -> Void) {
reply(HelperConstants.protocolVersion)
}
func setFanTarget(fan: Int, rpm: Double, reply: @escaping @Sendable (String?) -> Void) {
queue.async { [self] in
guard let fans else { return reply("无法访问 SMC") }
guard (0..<fans.fanCount).contains(fan), rpm.isFinite, rpm > 0 else { return reply("无效的风扇参数") }
do {
try fans.setManual(fan: fan, rpm: rpm)
manualFans.insert(fan)
reply(nil)
} catch {
log.error("设置风扇 \(fan) 失败:\(String(describing: error), privacy: .public)")
reply("设置风扇失败:\(error)")
}
}
}
func setFanAutomatic(fan: Int, reply: @escaping @Sendable (String?) -> Void) {
queue.async { [self] in
guard let fans else { return reply("无法访问 SMC") }
guard (0..<fans.fanCount).contains(fan) else { return reply("无效的风扇编号") }
do {
try fans.setAutomatic(fan: fan)
manualFans.remove(fan)
reply(nil)
} catch {
log.error("恢复风扇 \(fan) 失败:\(String(describing: error), privacy: .public)")
reply("恢复风扇失败:\(error)")
}
}
}
func resetAllFans(reply: @escaping @Sendable (String?) -> Void) {
queue.async { [self] in
reply(resetFans())
}
}
func setSleepDisabled(_ disabled: Bool, reply: @escaping @Sendable (String?) -> Void) {
queue.async { [self] in
reply(applySleepDisabled(disabled))
}
}
func flushDNSCache(reply: @escaping @Sendable (String?) -> Void) {
queue.async { [self] in reply(run(.flushDNS)) }
}
func purgeMemory(reply: @escaping @Sendable (String?) -> Void) {
queue.async { [self] in reply(run(.purgeMemory)) }
}
func setDNSServers(service: String, servers: [String], reply: @escaping @Sendable (String?) -> Void) {
queue.async { [self] in
guard DNSConfiguration.isValidServiceName(service), servers.count <= DNSConfiguration.maxServers,
servers.allSatisfy(DNSConfiguration.isValidAddress) else { return reply("无效的 DNS 参数") }
// 服务名必须是系统里已有的网络服务
let list = runTool("/usr/sbin/networksetup", ["-listallnetworkservices"])
let names = list.output.split(whereSeparator: \.isNewline).dropFirst()
.map { $0.hasPrefix("*") ? String($0.dropFirst()) : String($0) }
guard list.status == 0, names.contains(service) else { return reply("找不到网络服务“\(service)”") }
let result = runTool("/usr/sbin/networksetup", DNSConfiguration.networksetupArguments(service: service, servers: servers))
guard result.status == 0 else {
log.error("networksetup 失败:\(result.output, privacy: .public)")
return reply("networksetup 执行失败:\(result.output.trimmingCharacters(in: .whitespacesAndNewlines))")
}
reply(run(.flushDNS))
}
}
func deleteLocalSnapshots(identifiers: [String], reply: @escaping @Sendable (String?) -> Void) {
queue.async { [self] in
guard !identifiers.isEmpty, identifiers.count <= LocalSnapshotCommand.maxCount,
identifiers.allSatisfy(LocalSnapshotCommand.isValidIdentifier) else { return reply("无效的快照标识") }
for identifier in identifiers {
let result = runTool(LocalSnapshotCommand.tool, LocalSnapshotCommand.arguments(identifier: identifier))
guard result.status == 0 else {
log.error("删除快照失败:\(result.output, privacy: .public)")
return reply("tmutil 执行失败:\(result.output.trimmingCharacters(in: .whitespacesAndNewlines))")
}
}
log.notice("已删除 \(identifiers.count) 个本地快照")
reply(nil)
}
}
// MARK: 状态恢复
private func connectionClosed(_ id: ObjectIdentifier) {
connections.remove(id)
guard connections.isEmpty else { return }
// 应用已退出或崩溃:把系统恢复到默认状态
log.info("客户端全部断开,恢复风扇与睡眠设置")
_ = resetFans()
if sleepDisabledByHelper { _ = applySleepDisabled(false) }
scheduleIdleExit()
}
private func recoverFromPreviousRun() {
guard let data = try? Data(contentsOf: stateURL),
let state = try? PropertyListDecoder().decode(PersistedState.self, from: data) else { return }
log.notice("上次异常退出,恢复风扇与睡眠设置")
if state.sleepDisabled {
sleepDisabledByHelper = true
_ = applySleepDisabled(false)
}
if state.fansOverridden { _ = resetFans() }
}
private func scheduleIdleExit() {
idleExit?.cancel()
let work = DispatchWorkItem { [weak self] in
guard let self, self.connections.isEmpty else { return }
log.info("空闲退出")
exit(0)
}
idleExit = work
queue.asyncAfter(deadline: .now() + idleTimeout, execute: work)
}
// MARK: 风扇
private func resetFans() -> String? {
guard let fans else { return manualFans.isEmpty ? nil : "无法访问 SMC" }
do {
try fans.resetAll()
manualFans.removeAll()
persist()
return nil
} catch {
return "恢复风扇失败:\(error)"
}
}
// MARK: 睡眠
private func applySleepDisabled(_ disabled: Bool) -> String? {
let result = runTool("/usr/bin/pmset", ["-a", "disablesleep", disabled ? "1" : "0"])
guard result.status == 0 else {
return "pmset 执行失败:\(result.output.trimmingCharacters(in: .whitespacesAndNewlines))"
}
sleepDisabledByHelper = disabled
persist()
return nil
}
// MARK: 系统维护
private func run(_ command: MaintenanceCommand) -> String? {
for step in command.steps {
let result = runTool(step.path, step.arguments)
// killall 在 mDNSResponder 恰好重启时可能返回非 0,不视为失败
if result.status != 0, step.path != "/usr/bin/killall" {
return "\((step.path as NSString).lastPathComponent) 执行失败:\(result.output.trimmingCharacters(in: .whitespacesAndNewlines))"
}
}
return nil
}
private func runTool(_ path: String, _ arguments: [String]) -> (status: Int32, output: String) {
let process = Process()
process.executableURL = URL(fileURLWithPath: path)
process.arguments = arguments
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
do {
try process.run()
} catch {
return (-1, error.localizedDescription)
}
let data = pipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
return (process.terminationStatus, String(decoding: data, as: UTF8.self))
}
// MARK: 持久化
private struct PersistedState: Codable {
var sleepDisabled: Bool
var fansOverridden: Bool
}
private func persist() {
let state = PersistedState(sleepDisabled: sleepDisabledByHelper, fansOverridden: !manualFans.isEmpty)
guard state.sleepDisabled || state.fansOverridden else {
try? FileManager.default.removeItem(at: stateURL)
return
}
try? FileManager.default.createDirectory(at: stateURL.deletingLastPathComponent(), withIntermediateDirectories: true)
try? PropertyListEncoder().encode(state).write(to: stateURL, options: .atomic)
}
}
HelperService().run()