Files
ShadowsocksX-NG/ShadowsocksX-NG2/App/ProxyRuntimeController+Health.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

193 lines
6.4 KiB
Swift

import Foundation
private struct LocalEndpointFailure {
let local: SslocalLocalDocument
let host: String
let outcome: EndpointHealthProbe.Outcome
}
private enum LaunchHealthAttempt {
case ready(RuntimeDeploymentReceipt?)
case retry(LocalEndpointFailure?)
case agentLost
}
private struct LaunchHealthTimeout {
let expectedDigest: String?
let endpointFailure: LocalEndpointFailure?
let generation: Int
let preserveProxyOnFailure: Bool
}
extension ProxyRuntimeController {
/// 启动健康呈现(D2/D7/D8):确认本地入站;直连模式额外要求
/// 回执持续指向当前健康子进程,防止旧监听通过共享端口冒充新实例。
@discardableResult
func presentLaunchHealth(
_ document: SslocalRuntimeDocument,
requiresReceipt: Bool = false,
convergeProxyOnSuccess: Bool = true,
preserveProxyOnFailure: Bool = false
) async -> Bool {
let generation = flowGeneration
let expectedDigest = requiresReceipt ? document.deploymentSHA256 : nil
guard !requiresReceipt || expectedDigest != nil else {
return await reportRuntimeFileFailure(preserveProxyOnFailure: preserveProxyOnFailure)
}
let deadline = Date().addingTimeInterval(launchHealthTimeoutSeconds)
var endpointFailure: LocalEndpointFailure?
while Date() < deadline {
guard generation == flowGeneration else { return false }
switch await launchHealthAttempt(document, expectedDigest: expectedDigest) {
case .ready(let receipt):
return await finishHealthyLaunch(
document,
receipt: receipt,
generation: generation,
convergeProxyOnSuccess: convergeProxyOnSuccess,
preserveProxyOnFailure: preserveProxyOnFailure)
case .retry(let endpoint):
endpointFailure = endpoint
case .agentLost:
return await reportAgentLost(preserveProxyOnFailure: preserveProxyOnFailure)
}
try? await Task.sleep(nanoseconds: 200_000_000)
}
return await reportLaunchHealthTimeout(
LaunchHealthTimeout(
expectedDigest: expectedDigest,
endpointFailure: endpointFailure,
generation: generation,
preserveProxyOnFailure: preserveProxyOnFailure))
}
private func launchHealthAttempt(
_ document: SslocalRuntimeDocument,
expectedDigest: String?
) async -> LaunchHealthAttempt {
var receiptForHealth: RuntimeDeploymentReceipt?
if let expectedDigest {
guard let receipt = runtimeFileStore.readRuntimeReceipt(),
receipt.contractSHA256 == expectedDigest
else {
return .retry(nil)
}
guard receipt.processID > 0, processIsAlive(receipt.processID) else {
return .agentLost
}
receiptForHealth = receipt
}
if let endpointFailure = await unhealthyLocalEndpoint(in: document) {
return .retry(endpointFailure)
}
if let receiptForHealth {
guard runtimeFileStore.readRuntimeReceipt() == receiptForHealth else {
return .retry(nil)
}
guard processIsAlive(receiptForHealth.processID) else { return .agentLost }
}
return .ready(receiptForHealth)
}
private func finishHealthyLaunch(
_ document: SslocalRuntimeDocument,
receipt: RuntimeDeploymentReceipt?,
generation: Int,
convergeProxyOnSuccess: Bool,
preserveProxyOnFailure: Bool
) async -> Bool {
guard generation == flowGeneration else { return false }
await presentFirewallStatus(for: document)
if let receipt, !receiptIsCurrentAndLive(receipt) {
return await reportAgentLost(preserveProxyOnFailure: preserveProxyOnFailure)
}
if convergeProxyOnSuccess { await convergeSystemProxy() }
return true
}
private func receiptIsCurrentAndLive(_ receipt: RuntimeDeploymentReceipt) -> Bool {
guard receipt.processID > 0,
runtimeFileStore.readRuntimeReceipt() == receipt
else { return false }
return processIsAlive(receipt.processID)
}
private func reportAgentLost(preserveProxyOnFailure: Bool) async -> Bool {
guard !preserveProxyOnFailure else { return false }
state = .serviceFailed(.agent)
holdSystemProxyIntent()
return false
}
private func reportRuntimeFileFailure(preserveProxyOnFailure: Bool) async -> Bool {
guard !preserveProxyOnFailure else { return false }
state = .serviceFailed(.runtimeFile)
holdSystemProxyIntent()
return false
}
private func reportLaunchHealthTimeout(_ timeout: LaunchHealthTimeout) async -> Bool {
guard timeout.generation == flowGeneration else { return false }
guard !timeout.preserveProxyOnFailure else { return false }
if let expectedDigest = timeout.expectedDigest,
!hasLiveReceipt(expectedDigest: expectedDigest)
{
return await reportAgentLost(preserveProxyOnFailure: false)
}
if let endpointFailure = timeout.endpointFailure {
presentEndpointFailure(endpointFailure)
} else {
state = .launchFailed(
.localEndpoint(endpoint: "SOCKS", host: "127.0.0.1", port: 0, cause: .unknown))
}
holdSystemProxyIntent()
return false
}
private func hasLiveReceipt(expectedDigest: String) -> Bool {
guard let receipt = runtimeFileStore.readRuntimeReceipt(),
receipt.contractSHA256 == expectedDigest
else { return false }
return receiptIsCurrentAndLive(receipt)
}
private func unhealthyLocalEndpoint(
in document: SslocalRuntimeDocument
) async -> LocalEndpointFailure? {
for local in document.locals {
let outcome = await probeAsync(
host: local.probeHost, port: local.localPort, timeout: 1.5)
if outcome != .reachable {
return LocalEndpointFailure(local: local, host: local.probeHost, outcome: outcome)
}
}
return nil
}
private func presentEndpointFailure(_ failure: LocalEndpointFailure) {
let detail: String
let cause: RuntimeEndpointFailure
switch failure.outcome {
case .reachable:
detail = "已连通"
cause = .unknown
case .refused(let reason):
detail = reason
cause = .refused
case .timedOut:
detail = "连接超时"
cause = .timedOut
}
RuntimeLog.emit(
.endpointProbeFailed(
host: failure.host, port: failure.local.localPort, detail: detail))
let endpointName = failure.local.inboundProtocol.uppercased()
state = .launchFailed(
.localEndpoint(
endpoint: endpointName, host: failure.host, port: failure.local.localPort, cause: cause))
}
}