Files
ShadowsocksX-NG/ShadowsocksX-NG2/App/ProxyRuntimeController+SystemProxyGate.swift
T
Qiu Yuzhou 53c84384a4 fix(system-proxy): skip write when applied values already match desired
System proxy convergence runs after every successful deploy (activation,
mode change, settings saves, health convergence, launch resync), and each
apply locked SCPreferences and rewrote every owned service even when the
projected dictionaries were semantically equal to what macOS already
applies — so the admin authorization prompt fired on every redeploy while
intent was on, including server switches and GUI relaunches that cannot
change the system proxy dictionary at all.

New SystemProxyPlanner (Domain, pure) decides per service by comparing the
desired Proxies dictionary with the live value: equal services are not
written, and an all-equal apply returns .unchanged without locking or
committing, so no authorization is requested. A foreign change that
coincidentally equals the desired values (or an unowned service already at
them) is adopted into ownership with the recorded original preserved
instead of reported as a conflict. Owned services keep their recorded
original verbatim — nil stays nil — fixing a latent restore bug where a
second real change on a service originally taken over with no proxies
dictionary recorded 2.0's own previous values as the restore point. Real
value changes still write and authorize once.

SystemProxyControlling.apply returns written/unchanged; the gate emits a
systemProxyUnchanged event line (diagnostic log view and redacted report)
so it is observable why no prompt appeared. SystemProxyError moves to
Domain with the planner. CONTEXT.md invariant extended; decision table and
test points in issue #70. Closes #70.
2026-09-29 09:34:10 +08:00

217 lines
7.7 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
// MARK: - 防火墙呈现、wrapper 观测与系统代理门禁(issue #60)
extension ProxyRuntimeController {
func presentFirewallStatus(for document: SslocalRuntimeDocument) async {
guard document.listen.listenerMode.exposesNetworkInterfaces else {
state = .running
return
}
if let blocked = await blockedFirewallExecutable() {
presentFirewallBlocked(blocked)
return
}
state = .running
observeFirewall(generation: flowGeneration)
}
private func blockedFirewallExecutable() async -> URL? {
for executableURL in firewallExecutableURLs {
let status = await firewallStatus(for: executableURL)
if status == .blocked { return executableURL }
}
return nil
}
private func presentFirewallBlocked(_ executableURL: URL) {
let name = executableURL.lastPathComponent
state = .firewallBlocked(FirewallBlockedFacts(executableName: name))
}
private func observeFirewall(generation: Int) {
firewallObservationTask?.cancel()
let interval = firewallPollIntervalNanoseconds
firewallObservationTask = Task { @MainActor [weak self] in
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: interval)
guard !Task.isCancelled, let self, generation == flowGeneration else { return }
if let blocked = await blockedFirewallExecutable() {
presentFirewallBlocked(blocked)
firewallObservationTask = nil
return
}
}
}
}
func cancelFirewallObservation() {
firewallObservationTask?.cancel()
firewallObservationTask = nil
}
private func firewallStatus(for executableURL: URL) async -> FirewallBlockStatus {
let checker = firewallChecker
return await Task.detached(priority: .utility) {
checker.status(for: executableURL)
}.value
}
func probeAsync(
host: String, port: Int, timeout: TimeInterval
) async -> EndpointHealthProbe.Outcome {
let probe = probe
return await Task.detached(priority: .utility) {
probe.probe(host: host, port: port, timeout: timeout)
}.value
}
/// 显式停止协议次序(D2):注销(SIGTERM wrapper → wrapper 停 sslocal 并
/// 等待)完成后才允许后续删文件动作。最多等 5 秒,超时也继续(launchd 会
/// 兜底结束进程)。
func waitForWrapperExit() async {
let deadline = Date().addingTimeInterval(5)
while wrapperState() != .notRunning && Date() < deadline {
try? await Task.sleep(nanoseconds: 100_000_000)
}
}
func wrapperState() -> WrapperProcessState {
guard
let data = try? Data(contentsOf: runtimeFileStore.pidFileURL),
let text = String(data: data, encoding: .utf8)?.trimmingCharacters(
in: .whitespacesAndNewlines),
let pid = Int32(text)
else { return .notRunning }
guard sendSignal(pid, 0) == 0 else { return .notRunning }
return .running(pid: pid)
}
// MARK: - 目录同步
/// Re-expands one command-start snapshot; a concurrently published catalog
/// is intentionally observed by the next command, not halfway through this one.
func reexpand(in catalog: ConfigurationCatalog) -> ActivationEffect? {
let effect = machine.catalogDidCommit(
catalog, credentials: credentials, plugins: plugins,
options: settings.runtimeDocumentOptions)
activeTargetID = machine.activeTargetID
switch effect {
case .deployed(let configuration):
skippedServers = configuration.skippedServers
case .clearedAndStopped:
skippedServers = []
case nil:
break
}
return effect
}
func describe(_ error: Error) -> String {
String(describing: error)
}
// MARK: - 系统代理门禁(issue #60)
/// 系统代理收敛:意图开启 + agent 健康 + 所选模式具备可用出口(活动目标
/// 通过本地预检)才写入;否则保持待应用,条件恢复后随下次收敛自动应用。
/// 意图关闭时不做任何事(呈现面保持 idle/既有失败态)。
func convergeSystemProxy() async {
guard settings.systemProxyEnabled else { return }
guard systemProxyExitAvailable, let document = lastDocument else {
if case .pending = systemProxyState { return }
switch restoreSystemProxyOutcome() {
case .idle:
systemProxyState = .pending
case .failed(let failure):
systemProxyState = .failed(failure)
case .pending, .applied:
systemProxyState = .pending
}
return
}
do {
let configuration = try proxyMode.systemProxyConfiguration(
for: document, exceptions: settings.proxyExceptionList)
let outcome = try systemProxy.apply(configuration)
if outcome == .unchanged {
// 值语义等价的零写入路径:事件行供排障回答「这次为何没有授权弹窗」。
RuntimeLog.emit(.systemProxyUnchanged)
}
systemProxyState = .applied
} catch {
systemProxyState = .failed(systemProxyFacts(for: error))
}
}
/// 出口可用 = agent 入站健康(回环入口可用,含防火墙仅阻主机态的情形)
/// 且模式有出口;直连不依赖活动服务器目标。
private var systemProxyExitAvailable: Bool {
switch state {
case .running, .firewallBlocked:
return proxyMode == .direct || activeTargetID != nil
case .off, .starting, .launchFailed, .requiresApproval, .serviceFailed:
return false
}
}
/// agent 入站不可用(启动失败、目标清除、监听设置不可读)时的安全撤回:
/// 尽力恢复 NG2 持有的系统设置;意图保留为待应用,条件恢复后自动收敛。
/// 恢复失败以 typed 呈现——系统设置仍被 NG2 持有时用户必须知道。
func withdrawSystemProxyAfterEntryLoss() async {
if let error = restoreSystemProxyError() {
systemProxyState = .failed(systemProxyFacts(for: error))
return
}
systemProxyState = settings.systemProxyEnabled ? .pending : .idle
}
/// 恢复 NG2 持有的系统设置;成功 → `.idle`,失败 → typed 失败。
func restoreSystemProxyOutcome() -> SystemProxyControlState {
if let error = restoreSystemProxyError() {
return .failed(systemProxyFacts(for: error))
}
return .idle
}
func restoreSystemProxyError() -> Error? {
do {
try systemProxy.restore()
return nil
} catch {
return error
}
}
func systemProxyFacts(for error: Error) -> SystemProxyFailureFacts {
if let error = error as? SystemProxyError {
return Self.systemProxyFacts(for: error)
}
if let error = error as? ProxyModeError {
return .mode(error)
}
return .unknown
}
// 显式映射保持与错误族一一对应;错误带关联值,无法用字典键穷举。
// swiftlint:disable:next cyclomatic_complexity
private static func systemProxyFacts(
for error: SystemProxyError
) -> SystemProxyFailureFacts {
switch error {
case .authorizationFailed: return .operation(.authorizationFailed)
case .preferencesUnavailable: return .operation(.preferencesUnavailable)
case .preferencesBusy: return .operation(.preferencesBusy)
case .noCurrentNetworkSet: return .operation(.noCurrentNetworkSet)
case .noProxyServices: return .operation(.noProxyServices)
case .unreadableService: return .operation(.unreadableService)
case .ownershipConflict: return .ownershipConflict
case .invalidStoredConfiguration: return .operation(.invalidStoredConfiguration)
case .cannotWriteService: return .operation(.cannotWriteService)
case .commitFailed: return .operation(.commitFailed)
case .applyFailed: return .operation(.applyFailed)
case .ownershipStoreFailed: return .operation(.ownershipStoreFailed)
}
}
}