mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-02 00:04:46 +00:00
358 lines
14 KiB
Swift
358 lines
14 KiB
Swift
import Foundation
|
|
|
|
private typealias RuntimeFileCreator = (Data, String, String) throws -> String
|
|
|
|
private struct PreparedAuthentication {
|
|
let document: SslocalRuntimeDocument
|
|
let id: String?
|
|
let files: [String]
|
|
}
|
|
|
|
/// GUI 独占配置准备与监管发布;Agent 独占稳定链接切换和退休文件回收。
|
|
struct RuntimeFileStore {
|
|
enum PersistenceError: Error, Equatable {
|
|
case ioFailure(detail: String)
|
|
case rollbackFailed(detail: String)
|
|
}
|
|
|
|
private let paths: RuntimePaths
|
|
var fileURL: URL { paths.fileURL }
|
|
private let fileWriter: (Data, URL) throws -> Void
|
|
|
|
init(
|
|
fileURL: URL = ApplicationSupportPaths.current.runtime.fileURL,
|
|
fileWriter: @escaping (Data, URL) throws -> Void = { try AtomicFileWriter.write($0, to: $1) }
|
|
) {
|
|
paths = RuntimePaths(fileURL: fileURL)
|
|
self.fileWriter = fileWriter
|
|
}
|
|
|
|
var directoryURL: URL { paths.directoryURL }
|
|
var supervisionFileURL: URL { paths.supervisionFileURL }
|
|
var failureFileURL: URL { paths.failureFileURL }
|
|
var pidFileURL: URL { paths.pidFileURL }
|
|
var aclFileURL: URL { paths.aclFileURL }
|
|
var runtimeStatusFileURL: URL { paths.runtimeStatusFileURL }
|
|
|
|
func write(_ document: SslocalRuntimeDocument) throws {
|
|
try write(PreparedRuntimeContract(document))
|
|
}
|
|
|
|
func write(_ contract: PreparedRuntimeContract) throws {
|
|
try RuntimePublication.withLock(in: directoryURL) {
|
|
try publishLocked(contract)
|
|
}
|
|
}
|
|
|
|
private func publishLocked(_ contract: PreparedRuntimeContract) throws {
|
|
var created: [URL] = []
|
|
var publicationID: String?
|
|
do {
|
|
guard contract.document.isWellFormed else { throw POSIXError(.EINVAL) }
|
|
let old = RuntimePublication.load(at: supervisionFileURL)
|
|
let generation = try reserveGeneration()
|
|
let prefix = String(format: "%020llu", generation)
|
|
func create(_ data: Data, kind: String, suffix: String) throws -> String {
|
|
let name = "\(kind)-\(prefix)-\(suffix)"
|
|
let url = directoryURL.appendingPathComponent(name)
|
|
guard !FileManager.default.fileExists(atPath: url.path) else { throw POSIXError(.EEXIST) }
|
|
// 先登记:注入的写入器即使在写后抛错,也由 GUI 清理本次未发布文件。
|
|
created.append(url)
|
|
try fileWriter(data, url)
|
|
guard RuntimePublication.isRegularFile(url), try Data(contentsOf: url) == data else {
|
|
throw POSIXError(.EIO)
|
|
}
|
|
return name
|
|
}
|
|
let snapshot = try makeSnapshot(
|
|
contract, old: old, generation: generation,
|
|
create: { try create($0, kind: $1, suffix: $2) })
|
|
// 唯一发布点。所有可引用资源已落盘且校验完成;不提前触碰任何活动链接。
|
|
publicationID = snapshot.deploymentID
|
|
try fileWriter(JSONEncoder().encode(snapshot), supervisionFileURL)
|
|
} catch {
|
|
try handlePublicationFailure(error, publicationID: publicationID, created: created)
|
|
}
|
|
}
|
|
|
|
private func makeSnapshot(
|
|
_ contract: PreparedRuntimeContract, old: RuntimeSupervision?, generation: UInt64,
|
|
create: RuntimeFileCreator
|
|
) throws -> RuntimeSupervision {
|
|
var variants = old?.aclVariants ?? [:]
|
|
var selectedACL: RuntimeSupervision.ACL?
|
|
if let acl = contract.document.aclRuntime {
|
|
guard acl.path == aclFileURL.standardizedFileURL.path, acl.isWellFormed else {
|
|
throw POSIXError(.EINVAL)
|
|
}
|
|
selectedACL = try selectACL(acl, variants: &variants, create: create)
|
|
}
|
|
let authentication = try prepareAuthentication(contract.document, old: old, create: create)
|
|
let configurationData = try authentication.document.jsonData()
|
|
let configurationID =
|
|
reusableConfigurationID(
|
|
for: configurationData, previous: old) ?? UUID().uuidString
|
|
let configuration = try create(configurationData, "config", "sslocal.json")
|
|
let intent = PublishedRuntimeIntent(
|
|
sha256: contract.sha256, configurationSHA256: ProxyACLDocument.digest(configurationData),
|
|
summary: Redactor.documentSummary(contract.document))
|
|
let intentMetadata = try create(JSONEncoder().encode(intent), "config", "intent.json")
|
|
let snapshot = RuntimeSupervision(
|
|
version: 2, generation: generation, deploymentID: UUID().uuidString,
|
|
configuration: configuration, configurationID: configurationID,
|
|
intentMetadata: intentMetadata,
|
|
locals: authentication.document.locals.map {
|
|
.init(
|
|
inboundProtocol: $0.inboundProtocol, localAddress: $0.localAddress,
|
|
localPort: $0.localPort, mode: $0.mode)
|
|
}, ipv6Only: authentication.document.ipv6Only,
|
|
authenticationID: authentication.id, authenticationFiles: authentication.files,
|
|
acl: selectedACL, aclVariants: variants)
|
|
guard snapshot.validated(in: directoryURL) else { throw POSIXError(.EINVAL) }
|
|
return snapshot
|
|
}
|
|
|
|
private func selectACL(
|
|
_ acl: ProxyACLDocument, variants: inout [String: RuntimeSupervision.ACL],
|
|
create: RuntimeFileCreator
|
|
) throws -> RuntimeSupervision.ACL {
|
|
if let cached = variants[acl.summary], cached.sha256 == acl.sha256 { return cached }
|
|
guard !acl.content.isEmpty else { throw POSIXError(.ENOENT) }
|
|
let data = Data(acl.content.utf8)
|
|
let name = try create(data, "acl", "\(acl.summary).ini")
|
|
let selected = RuntimeSupervision.ACL(file: name, sha256: acl.sha256, size: data.count)
|
|
variants[acl.summary] = selected
|
|
return selected
|
|
}
|
|
|
|
private func reusableConfigurationID(for data: Data, previous: RuntimeSupervision?) -> String? {
|
|
guard let previous, let identity = previous.configurationID,
|
|
publishedIntent(previous)?.configurationSHA256 == ProxyACLDocument.digest(data),
|
|
(try? Data(contentsOf: directoryURL.appendingPathComponent(previous.configuration))) == data
|
|
else { return nil }
|
|
return identity
|
|
}
|
|
|
|
private func handlePublicationFailure(
|
|
_ error: Error, publicationID: String?, created: [URL]
|
|
) throws {
|
|
// 写入器在原子替换后报错时,发布已经发生;不得删除读者可见资源。
|
|
if let publicationID,
|
|
RuntimePublication.load(at: supervisionFileURL)?.deploymentID == publicationID
|
|
{
|
|
throw PersistenceError.ioFailure(detail: "Publication completed but writer reported failure")
|
|
}
|
|
var failures: [String] = []
|
|
for url in created {
|
|
do { try FileManager.default.removeItem(at: url) } catch {
|
|
if FileManager.default.fileExists(atPath: url.path) {
|
|
failures.append(url.lastPathComponent)
|
|
}
|
|
}
|
|
}
|
|
if !failures.isEmpty {
|
|
throw PersistenceError.rollbackFailed(
|
|
detail: "Unpublished cleanup: " + failures.joined(separator: ","))
|
|
}
|
|
throw PersistenceError.ioFailure(detail: String(describing: error))
|
|
}
|
|
|
|
private func prepareAuthentication(
|
|
_ document: SslocalRuntimeDocument,
|
|
old: RuntimeSupervision?,
|
|
create: RuntimeFileCreator
|
|
) throws -> PreparedAuthentication {
|
|
var authenticationID: String?
|
|
var authenticationFiles: [String] = []
|
|
var encoded = document
|
|
if let auth = encoded.listen.authentication {
|
|
guard auth.directory == directoryURL.standardizedFileURL.path else {
|
|
throw POSIXError(.EINVAL)
|
|
}
|
|
let contents = try auth.fileContents()
|
|
let previousFiles = old?.authenticationFiles ?? []
|
|
let unchanged =
|
|
previousFiles.count == contents.count
|
|
&& zip(previousFiles, contents).allSatisfy { name, content in
|
|
(try? Data(contentsOf: directoryURL.appendingPathComponent(name))) == content.1
|
|
}
|
|
if unchanged, let oldID = old?.authenticationID {
|
|
authenticationID = oldID
|
|
authenticationFiles = old?.authenticationFiles ?? []
|
|
} else {
|
|
authenticationID = UUID().uuidString
|
|
for (kind, data) in contents {
|
|
authenticationFiles.append(try create(data, "auth", "\(kind).json"))
|
|
}
|
|
}
|
|
for index in encoded.locals.indices
|
|
where encoded.locals[index].localAddress != "127.0.0.1" {
|
|
if encoded.locals[index].inboundProtocol == "socks" {
|
|
encoded.locals[index].socks5AuthConfigPath =
|
|
directoryURL
|
|
.appendingPathComponent(authenticationFiles[0]).path
|
|
} else {
|
|
encoded.locals[index].httpAuthConfigPath =
|
|
directoryURL
|
|
.appendingPathComponent(authenticationFiles[1]).path
|
|
}
|
|
}
|
|
}
|
|
return PreparedAuthentication(
|
|
document: encoded, id: authenticationID, files: authenticationFiles)
|
|
}
|
|
|
|
/// 代次高水位独立于部署文件持久保存。预留失败可留空号,溢出拒绝部署。
|
|
private func reserveGeneration() throws -> UInt64 {
|
|
let url = paths.generationFileURL
|
|
let current: UInt64
|
|
if FileManager.default.fileExists(atPath: url.path) {
|
|
guard let value = UInt64(try String(contentsOf: url, encoding: .utf8)) else {
|
|
throw POSIXError(.EINVAL)
|
|
}
|
|
current = value
|
|
} else {
|
|
current = 0
|
|
}
|
|
guard current < UInt64.max else { throw POSIXError(.EOVERFLOW) }
|
|
try AtomicFileWriter.write(Data(String(current + 1).utf8), to: url)
|
|
return current + 1
|
|
}
|
|
|
|
}
|
|
|
|
extension RuntimeFileStore {
|
|
|
|
/// 意图比较不解析输出正文;最终字节摘要保留原有磁盘漂移检查。
|
|
func contains(_ contract: PreparedRuntimeContract) -> Bool {
|
|
(try? RuntimePublication.withLock(in: directoryURL) {
|
|
guard let snapshot = RuntimePublication.load(at: supervisionFileURL),
|
|
let intent = publishedIntent(snapshot), intent.sha256 == contract.sha256,
|
|
let data = try? Data(
|
|
contentsOf: directoryURL.appendingPathComponent(snapshot.configuration))
|
|
else { return false }
|
|
return ProxyACLDocument.digest(data) == intent.configurationSHA256
|
|
}) ?? false
|
|
}
|
|
|
|
func publishedSummary() -> String? {
|
|
try? RuntimePublication.withLock(in: directoryURL) {
|
|
guard let snapshot = RuntimePublication.load(at: supervisionFileURL) else { return nil }
|
|
return publishedIntent(snapshot)?.summary
|
|
}
|
|
}
|
|
|
|
private func publishedIntent(_ snapshot: RuntimeSupervision) -> PublishedRuntimeIntent? {
|
|
guard
|
|
let data = try? Data(contentsOf: directoryURL.appendingPathComponent(snapshot.intentMetadata))
|
|
else { return nil }
|
|
return try? JSONDecoder().decode(PublishedRuntimeIntent.self, from: data)
|
|
}
|
|
|
|
func receiptIdentity(for document: SslocalRuntimeDocument) -> String? {
|
|
receiptIdentity(for: document.intentSHA256)
|
|
}
|
|
|
|
func receiptIdentity(for intentSHA256: String?) -> String? {
|
|
try? RuntimePublication.withLock(in: directoryURL) {
|
|
let snapshot = RuntimePublication.metadata(at: supervisionFileURL)
|
|
guard let snapshot,
|
|
let identity = publishedIntent(snapshot)?.sha256,
|
|
identity == intentSHA256
|
|
else { return nil }
|
|
return snapshot.deploymentID
|
|
}
|
|
}
|
|
|
|
func blocksDeployment(_ document: SslocalRuntimeDocument) -> Bool {
|
|
blocksDeployment(document.intentSHA256)
|
|
}
|
|
|
|
func blocksDeployment(_ intentSHA256: String?) -> Bool {
|
|
guard let failure = readFailure() else { return false }
|
|
guard let snapshot = RuntimePublication.metadata(at: supervisionFileURL) else { return true }
|
|
guard failure.deploymentID == snapshot.deploymentID else { return false }
|
|
// GUI 私有身份本身丢失时也不自动重试,用户可显式重试修复派生资源。
|
|
guard
|
|
let identity = publishedIntent(snapshot)?.sha256,
|
|
identity.count == 64
|
|
else { return true }
|
|
return identity == intentSHA256
|
|
}
|
|
|
|
func readRuntimeReceipt() -> RuntimeDeploymentReceipt? {
|
|
guard let data = try? Data(contentsOf: runtimeStatusFileURL) else { return nil }
|
|
return try? JSONDecoder().decode(RuntimeDeploymentReceipt.self, from: data)
|
|
}
|
|
|
|
func readFailure() -> RuntimeDeploymentFailure? {
|
|
guard let data = try? Data(contentsOf: failureFileURL) else { return nil }
|
|
return try? JSONDecoder().decode(RuntimeDeploymentFailure.self, from: data)
|
|
}
|
|
|
|
func recordFailure(reason: String) throws {
|
|
try RuntimePublication.withLock(in: directoryURL) {
|
|
guard let snapshot = RuntimePublication.metadata(at: supervisionFileURL) else {
|
|
throw POSIXError(.EINVAL)
|
|
}
|
|
try AtomicFileWriter.write(
|
|
JSONEncoder().encode(
|
|
RuntimeDeploymentFailure(
|
|
deploymentID: snapshot.deploymentID, reason: reason)), to: failureFileURL)
|
|
}
|
|
}
|
|
|
|
/// 只清除旧部署失败。Agent 对本代新确认的失败不得被 GUI 健康任务擦除。
|
|
func settleSuccessfulDeployment() throws {
|
|
try RuntimePublication.withLock(in: directoryURL) {
|
|
guard let snapshot = RuntimePublication.metadata(at: supervisionFileURL) else {
|
|
throw POSIXError(.EINVAL)
|
|
}
|
|
if let failure = readFailure(), failure.deploymentID == snapshot.deploymentID {
|
|
throw POSIXError(.EIO)
|
|
}
|
|
try clearFailure()
|
|
}
|
|
}
|
|
|
|
func clearFailure() throws {
|
|
if FileManager.default.fileExists(atPath: failureFileURL.path) {
|
|
try FileManager.default.removeItem(at: failureFileURL)
|
|
}
|
|
}
|
|
|
|
var hasUnconfirmedChild: Bool {
|
|
let url = paths.childPIDFileURL
|
|
guard FileManager.default.fileExists(atPath: url.path) else { return false }
|
|
guard let data = try? String(contentsOf: url, encoding: .utf8), let pid = Int32(data), pid > 0
|
|
else { return true }
|
|
return kill(pid, 0) == 0 || errno == EPERM
|
|
}
|
|
|
|
/// 仅在确认 wrapper 及其子进程退出后调用;高水位与锁跨停止保留。
|
|
func deleteRuntimeFiles() throws {
|
|
try RuntimePublication.withLock(in: directoryURL) {
|
|
guard !hasUnconfirmedChild else { throw POSIXError(.EBUSY) }
|
|
let manager = FileManager.default
|
|
let removableNames = paths.removableFileNames
|
|
for name in try manager.contentsOfDirectory(atPath: directoryURL.path) {
|
|
if RuntimePublication.generation(of: name) != nil
|
|
|| removableNames.contains(name)
|
|
|| name.hasPrefix("lan-auth-") || name == "acl-digests.json"
|
|
|| (name.hasPrefix("acl-") && name.hasSuffix(".ini"))
|
|
|| AtomicFileWriter.isResidueName(name)
|
|
{
|
|
try manager.removeItem(at: directoryURL.appendingPathComponent(name))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// GUI 私有发布事实。意图摘要不进入 Agent 监管资料、回执或诊断。
|
|
struct PublishedRuntimeIntent: Codable {
|
|
let sha256: String
|
|
let configurationSHA256: String
|
|
let summary: String
|
|
}
|