mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-14 02:04:13 +00:00
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).
193 lines
6.4 KiB
Swift
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))
|
|
}
|
|
}
|