Files
ShadowsocksX-NG/ShadowsocksX-NG2/SystemProxyHelper/SystemProxyWriter.swift
T
Qiu Yuzhou d2a8d6105c feat(system-proxy): route writes through privileged LaunchDaemon helper
Implements issue #71 with ADR-0020 (supersedes ADR-0019's endpoint-signature
cleanup, startup cleanup, and health-gate cleanup).

- New ShadowsocksX-NG2SystemProxyHelper target: app-bundled root LaunchDaemon
  (SMAppService.daemon, on-demand via MachServices) that mechanically applies
  a closed typed configuration or executes an unconditional clear. Clients are
  accepted by code-signing bundle identifier only; requests run serially on a
  shared queue (last-writer-wins).
- SystemProxyConfiguration is now the closed typed apply payload (Codable over
  JSON XPC): explicit SOCKS/HTTP/HTTPS enable + endpoints, PAC and
  auto-discovery disabled with URL/JS removed, explicit simple-hostname
  exclusion, complete ExceptionsList. Unmodeled Proxies keys are preserved and
  equivalent values skip the commit.
- Cleanup is a side effect of a persisted system-proxy intent on-to-off
  transition only: the switch itself, or the agent-off cascade (persist both
  intents off, clear, then stop listeners; failed cascade persistence records
  no transition and clears nothing). Launch with the switch off, health-gate
  closure, invalid targets, and passive events never clear. Failed clears are
  surfaced without deferred retry.
- Endpoint-signature store, ownership migration, and direct Authorization
  Services writes are removed; old signature files stay inert. Helper
  unavailability or pending approval keeps an enabled intent pending and
  surfaces the Login Items approval path (HomeView button + menu label).
2026-09-30 12:26:40 +08:00

182 lines
6.9 KiB
Swift

import Foundation
import SystemConfiguration
/// root 进程内的 SystemConfiguration 写入器(issue #71):GUI 域决策的机械
/// 执行者。apply 覆盖活动网络位置的每个服务,只改 typed 字段并保留未建模
/// 键,值已等价时跳过提交;clear 无条件移除全部位置全部服务的完整 Proxies
/// 字典。root 身份直接创建 SCPreferences,不走授权对话框。
enum SystemProxyWriter {
static func perform(
_ request: SystemProxyHelperEngine.Request
) throws -> SystemProxyHelperEngine.Outcome {
switch request {
case .apply(let configuration):
return .applied(try apply(configuration))
case .clear:
try clear()
return .cleared
}
}
static func apply(_ configuration: SystemProxyConfiguration) throws -> SystemProxyWriteOutcome {
try withPreferences { preferences in
try lockPreferencesOrThrow(preferences)
defer { SCPreferencesUnlock(preferences) }
let services = try activeServiceSnapshots(in: preferences)
guard !services.isEmpty else { throw SystemProxyError.noProxyServices }
let plan = try SystemProxyPlanner.makeApplyPlan(
services: services.map(\.plannerState), configuration: configuration)
guard !plan.writes.isEmpty else { return .unchanged }
let servicesByID = Dictionary(uniqueKeysWithValues: services.map { ($0.identifier, $0) })
for write in plan.writes {
guard let service = servicesByID[write.identifier] else {
throw SystemProxyError.cannotWriteService(write.identifier.serviceID)
}
if service.proxyProtocol == nil {
_ = SCNetworkServiceAddProtocolType(
service.networkService, kSCNetworkProtocolTypeProxies)
}
guard
let proxyProtocol = service.proxyProtocol
?? SCNetworkServiceCopyProtocol(service.networkService, kSCNetworkProtocolTypeProxies),
setConfiguration(write.configuration, on: proxyProtocol)
else { throw SystemProxyError.cannotWriteService(write.identifier.serviceID) }
}
try commitAndApply(preferences)
return .written
}
}
static func clear() throws {
try withPreferences { preferences in
try lockPreferencesOrThrow(preferences)
defer { SCPreferencesUnlock(preferences) }
let services = try allServiceSnapshots(in: preferences)
let plan = SystemProxyPlanner.makeClearPlan(services: services.map(\.plannerState))
guard !plan.writes.isEmpty else { return }
let servicesByID = Dictionary(uniqueKeysWithValues: services.map { ($0.identifier, $0) })
for write in plan.writes {
guard let service = servicesByID[write.identifier],
let proxyProtocol = service.proxyProtocol,
setConfiguration(nil, on: proxyProtocol)
else { throw SystemProxyError.cannotWriteService(write.identifier.serviceID) }
}
try commitAndApply(preferences)
}
}
// MARK: - SystemConfiguration 读取与写入
private struct ServiceSnapshot {
let identifier: SystemProxyServiceIdentifier
let networkService: SCNetworkService
let proxyProtocol: SCNetworkProtocol?
let configuration: Data?
var plannerState: SystemProxyServiceState {
SystemProxyServiceState(identifier: identifier, configuration: configuration)
}
}
private static func withPreferences<T>(_ body: (SCPreferences) throws -> T) throws -> T {
guard
let preferences = SCPreferencesCreate(
nil, SystemProxyHelperIdentity.machServiceName as CFString, nil)
else { throw SystemProxyError.preferencesUnavailable }
return try body(preferences)
}
private static func activeServiceSnapshots(
in preferences: SCPreferences
) throws -> [ServiceSnapshot] {
guard let set = SCNetworkSetCopyCurrent(preferences) else {
throw SystemProxyError.noCurrentNetworkSet
}
guard let locationID = SCNetworkSetGetSetID(set) as String? else {
throw SystemProxyError.noCurrentNetworkSet
}
return try serviceSnapshots(in: set, locationID: locationID)
}
private static func allServiceSnapshots(
in preferences: SCPreferences
) throws -> [ServiceSnapshot] {
guard let sets = SCNetworkSetCopyAll(preferences) as? [SCNetworkSet], !sets.isEmpty else {
throw SystemProxyError.noNetworkLocations
}
return try sets.flatMap { set -> [ServiceSnapshot] in
guard let locationID = SCNetworkSetGetSetID(set) as String? else {
return [ServiceSnapshot]()
}
return try serviceSnapshots(in: set, locationID: locationID)
}
}
private static func serviceSnapshots(
in set: SCNetworkSet, locationID: String
) throws -> [ServiceSnapshot] {
guard let services = SCNetworkSetCopyServices(set) as? [SCNetworkService] else {
return []
}
return try services.compactMap { service in
guard let serviceID = SCNetworkServiceGetServiceID(service) as String? else { return nil }
let proxyProtocol = SCNetworkServiceCopyProtocol(service, kSCNetworkProtocolTypeProxies)
let configurationValue: CFPropertyList?
if let proxyProtocol {
configurationValue = SCNetworkProtocolGetConfiguration(proxyProtocol)
} else {
configurationValue = nil
}
return ServiceSnapshot(
identifier: SystemProxyServiceIdentifier(
locationID: locationID, serviceID: serviceID),
networkService: service,
proxyProtocol: proxyProtocol,
configuration: try propertyListData(from: configurationValue, serviceID: serviceID))
}
}
private static func propertyListData(
from value: CFPropertyList?, serviceID: String
) throws -> Data? {
guard let value else { return nil }
guard let dictionary = value as? [String: Any] else {
throw SystemProxyError.unreadableService(serviceID)
}
return try SystemProxyPlanner.propertyListData(from: dictionary, serviceID: serviceID)
}
private static func setConfiguration(_ data: Data?, on proxyProtocol: SCNetworkProtocol) -> Bool {
guard let data else { return SCNetworkProtocolSetConfiguration(proxyProtocol, nil) }
guard
let propertyList = try? PropertyListSerialization.propertyList(
from: data, options: [], format: nil),
let dictionary = propertyList as? [String: Any]
else { return false }
return SCNetworkProtocolSetConfiguration(proxyProtocol, dictionary as CFDictionary)
}
private static func commitAndApply(_ preferences: SCPreferences) throws {
guard SCPreferencesCommitChanges(preferences) else {
throw SystemProxyError.commitFailed(systemConfigurationError())
}
guard SCPreferencesApplyChanges(preferences) else {
throw SystemProxyError.applyFailed(systemConfigurationError())
}
}
private static func systemConfigurationError() -> String {
String(cString: SCErrorString(SCError()))
}
}
private func lockPreferencesOrThrow(_ preferences: SCPreferences) throws {
guard SCPreferencesLock(preferences, true) else {
throw SystemProxyError.preferencesBusy
}
}