diff --git a/ShadowsocksX-NG2/Domain/Runtime/RuntimeFileStore.swift b/ShadowsocksX-NG2/Domain/Runtime/RuntimeFileStore.swift index 55b7745c..9b3682b3 100644 --- a/ShadowsocksX-NG2/Domain/Runtime/RuntimeFileStore.swift +++ b/ShadowsocksX-NG2/Domain/Runtime/RuntimeFileStore.swift @@ -74,10 +74,11 @@ struct RuntimeFileStore { let intent = PublishedRuntimeIntent( sha256: contract.sha256, configurationSHA256: ProxyACLDocument.digest(configurationData), summary: Redactor.documentSummary(contract.document)) - _ = try create(JSONEncoder().encode(intent), kind: "config", suffix: "intent.json") + let intentMetadata = try create( + JSONEncoder().encode(intent), kind: "config", suffix: "intent.json") let snapshot = RuntimeSupervision( - version: 1, generation: generation, deploymentID: UUID().uuidString, - configuration: configuration, + version: 2, generation: generation, deploymentID: UUID().uuidString, + configuration: configuration, intentMetadata: intentMetadata, locals: encoded.locals.map { .init( inboundProtocol: $0.inboundProtocol, localAddress: $0.localAddress, @@ -194,7 +195,8 @@ struct RuntimeFileStore { } private func publishedIntent(_ snapshot: RuntimeSupervision) -> PublishedRuntimeIntent? { - guard let data = try? Data(contentsOf: directoryURL.appendingPathComponent(snapshot.intentFile)) + guard + let data = try? Data(contentsOf: directoryURL.appendingPathComponent(snapshot.intentMetadata)) else { return nil } return try? JSONDecoder().decode(PublishedRuntimeIntent.self, from: data) } diff --git a/ShadowsocksX-NG2/Domain/Runtime/RuntimeSupervision.swift b/ShadowsocksX-NG2/Domain/Runtime/RuntimeSupervision.swift index a6729604..99a8fe8c 100644 --- a/ShadowsocksX-NG2/Domain/Runtime/RuntimeSupervision.swift +++ b/ShadowsocksX-NG2/Domain/Runtime/RuntimeSupervision.swift @@ -28,6 +28,8 @@ struct RuntimeSupervision: Codable, Equatable, Sendable { let generation: UInt64 let deploymentID: String let configuration: String + /// GUI 私有意图摘要的同代文件;Agent 只保留引用,不读取正文。 + let intentMetadata: String let locals: [Endpoint] let ipv6Only: Bool? let authenticationID: String? @@ -35,13 +37,8 @@ struct RuntimeSupervision: Codable, Equatable, Sendable { let acl: ACL? let aclVariants: [String: ACL] - /// GUI 私有意图摘要的同代文件;Agent 只保留引用,不读取正文。 - var intentFile: String { - configuration.replacingOccurrences(of: "-sslocal.json", with: "-intent.json") - } - var retainedFiles: Set { - Set([configuration, intentFile] + authenticationFiles + aclVariants.values.map(\.file)) + Set([configuration, intentMetadata] + authenticationFiles + aclVariants.values.map(\.file)) .union(acl.map { [$0.file] } ?? []) } @@ -51,9 +48,11 @@ struct RuntimeSupervision: Codable, Equatable, Sendable { } var hasValidStructure: Bool { - guard version == 1, generation > 0, UUID(uuidString: deploymentID) != nil, - configuration.hasPrefix("config-"), configuration.hasSuffix("-sslocal.json"), + guard version == 2, generation > 0, UUID(uuidString: deploymentID) != nil, + configuration.hasPrefix("config-"), intentMetadata.hasPrefix("config-"), RuntimePublication.generation(of: configuration) == generation, + RuntimePublication.generation(of: intentMetadata) == generation, + configuration != intentMetadata, authenticationFiles.allSatisfy({ $0.hasPrefix("auth-") && $0.hasSuffix(".json") }), aclVariants.values.allSatisfy({ $0.file.hasPrefix("acl-") && $0.file.hasSuffix(".ini") }), authenticationID.map({ UUID(uuidString: $0) != nil }) ?? true, diff --git a/ShadowsocksX-NG2/Tests/ProxyRuntime/RuntimeConvergenceTests.swift b/ShadowsocksX-NG2/Tests/ProxyRuntime/RuntimeConvergenceTests.swift index 498ab98f..1079620f 100644 --- a/ShadowsocksX-NG2/Tests/ProxyRuntime/RuntimeConvergenceTests.swift +++ b/ShadowsocksX-NG2/Tests/ProxyRuntime/RuntimeConvergenceTests.swift @@ -97,7 +97,7 @@ struct RuntimeConvergenceTests { let name = resource == "configuration" ? snapshot.configuration - : resource == "intent" ? snapshot.intentFile : try #require(snapshot.acl?.file) + : resource == "intent" ? snapshot.intentMetadata : try #require(snapshot.acl?.file) try FileManager.default.removeItem(at: fixture.runtime.directory.appendingPathComponent(name)) let count = fixture.agent.registerCount let restarted = fixture.reopeningController() diff --git a/ShadowsocksX-NG2/Tests/Runtime/RuntimePublicationTests.swift b/ShadowsocksX-NG2/Tests/Runtime/RuntimePublicationTests.swift index 80d5dcf8..433351cf 100644 --- a/ShadowsocksX-NG2/Tests/Runtime/RuntimePublicationTests.swift +++ b/ShadowsocksX-NG2/Tests/Runtime/RuntimePublicationTests.swift @@ -18,6 +18,99 @@ struct RuntimePublicationTests { } extension RuntimePublicationTests { + @Test func opaqueConfigurationUsesExplicitMetadataThroughSelectionAndRetirement() throws { + let runtime = ProxyRuntimeFixture.makeTemporaryRuntime() + defer { try? FileManager.default.removeItem(at: runtime.directory) } + let store = RuntimeFileStore(fileURL: runtime.contract) + let document = ProxyRuntimeFixture.makeDocument() + try store.write(document) + let original = try #require(RuntimePublication.load(at: store.supervisionFileURL)) + let bytes = try Data(contentsOf: store.supervisionFileURL) + var object = try #require(JSONSerialization.jsonObject(with: bytes) as? [String: Any]) + let prefix = String(format: "config-%020llu-", original.generation) + let configuration = prefix + "payload.bin" + let metadata = prefix + "gui-metadata.dat" + try FileManager.default.moveItem( + at: runtime.directory.appendingPathComponent(original.intentMetadata), + to: runtime.directory.appendingPathComponent(metadata)) + let payload = Data([0x00, 0xff, 0x80, 0x01]) + try AtomicFileWriter.write(payload, to: runtime.directory.appendingPathComponent(configuration)) + object["configuration"] = configuration + object["intentMetadata"] = metadata + try AtomicFileWriter.write( + JSONSerialization.data(withJSONObject: object), to: store.supervisionFileURL) + + let selected = try #require(RuntimePublication.load(at: store.supervisionFileURL)) + #expect(store.publishedSummary()?.contains("servers=1") == true) + try RuntimePublication.select( + selected, configurationLink: runtime.contract, aclLink: store.aclFileURL) + #expect(try Data(contentsOf: runtime.contract) == payload) + try store.write(document) + let next = try #require(RuntimePublication.load(at: store.supervisionFileURL)) + try RuntimePublication.retire( + before: next, publishedAt: store.supervisionFileURL, protecting: selected.retainedFiles) + for name in [configuration, metadata] { + #expect( + FileManager.default.fileExists(atPath: runtime.directory.appendingPathComponent(name).path)) + } + try RuntimePublication.select( + next, configurationLink: runtime.contract, aclLink: store.aclFileURL) + try RuntimePublication.retire( + before: next, publishedAt: store.supervisionFileURL, protecting: []) + for name in [configuration, metadata] { + #expect( + !FileManager.default.fileExists(atPath: runtime.directory.appendingPathComponent(name).path) + ) + } + #expect(try store.contains(PreparedRuntimeContract(document))) + } + + @Test func metadataReferenceIsRequiredAndMustBeDistinctAndFromTheSameGeneration() throws { + let runtime = ProxyRuntimeFixture.makeTemporaryRuntime() + defer { try? FileManager.default.removeItem(at: runtime.directory) } + let store = RuntimeFileStore(fileURL: runtime.contract) + try store.write(ProxyRuntimeFixture.makeDocument()) + let snapshot = try #require(RuntimePublication.load(at: store.supervisionFileURL)) + let bytes = try Data(contentsOf: store.supervisionFileURL) + let invalidReferences: [String?] = [ + nil, "../outside.json", snapshot.configuration, + String(format: "config-%020llu-intent.json", snapshot.generation - 1), + String(format: "config-%020llu-intent.json", snapshot.generation + 1), + String(format: "auth-%020llu-intent.json", snapshot.generation), + ] + for reference in invalidReferences { + var object = try #require(JSONSerialization.jsonObject(with: bytes) as? [String: Any]) + object["intentMetadata"] = reference + try AtomicFileWriter.write( + JSONSerialization.data(withJSONObject: object), to: store.supervisionFileURL) + #expect(RuntimePublication.metadata(at: store.supervisionFileURL) == nil) + } + } + + @Test func missingOrSymlinkedMetadataPreventsDeploymentSelection() throws { + let runtime = ProxyRuntimeFixture.makeTemporaryRuntime() + defer { try? FileManager.default.removeItem(at: runtime.directory) } + let store = RuntimeFileStore(fileURL: runtime.contract) + try store.write(ProxyRuntimeFixture.makeDocument()) + let snapshot = try #require(RuntimePublication.load(at: store.supervisionFileURL)) + let metadataURL = runtime.directory.appendingPathComponent(snapshot.intentMetadata) + try FileManager.default.removeItem(at: metadataURL) + #expect(RuntimePublication.load(at: store.supervisionFileURL) == nil) + #expect(throws: (any Error).self) { + try RuntimePublication.select( + snapshot, configurationLink: runtime.contract, aclLink: store.aclFileURL) + } + try FileManager.default.createSymbolicLink( + at: metadataURL, + withDestinationURL: runtime.directory.appendingPathComponent(snapshot.configuration)) + #expect(RuntimePublication.load(at: store.supervisionFileURL) == nil) + #expect(throws: (any Error).self) { + try RuntimePublication.select( + snapshot, configurationLink: runtime.contract, aclLink: store.aclFileURL) + } + #expect(!FileManager.default.fileExists(atPath: runtime.contract.path)) + } + @Test func routingIntentChangesEvenWhenUpstreamJSONDoesNot() throws { let runtime = ProxyRuntimeFixture.makeTemporaryRuntime() defer { try? FileManager.default.removeItem(at: runtime.directory) } @@ -123,7 +216,9 @@ extension RuntimePublicationTests { let store = RuntimeFileStore(fileURL: runtime.contract) try store.write(ProxyRuntimeFixture.makeDocument()) let bytes = try Data(contentsOf: store.supervisionFileURL) - for (key, value) in [("version", 99 as Any), ("configuration", "../outside.json" as Any)] { + for (key, value) in [ + ("version", 1 as Any), ("version", 99 as Any), ("configuration", "../outside.json" as Any), + ] { var object = try #require(JSONSerialization.jsonObject(with: bytes) as? [String: Any]) object[key] = value try AtomicFileWriter.write( diff --git a/ShadowsocksX-NG2/Tests/Support/RuntimeReceiptFixture.swift b/ShadowsocksX-NG2/Tests/Support/RuntimeReceiptFixture.swift index 8073ae47..60ac6349 100644 --- a/ShadowsocksX-NG2/Tests/Support/RuntimeReceiptFixture.swift +++ b/ShadowsocksX-NG2/Tests/Support/RuntimeReceiptFixture.swift @@ -44,7 +44,7 @@ extension RuntimeFileStore { let data = try? Data(contentsOf: directoryURL.appendingPathComponent(snapshot.configuration)), let configuration = try? JSONDecoder().decode(SslocalConfigurationDocument.self, from: data), let intentData = try? Data( - contentsOf: directoryURL.appendingPathComponent(snapshot.intentFile)), + contentsOf: directoryURL.appendingPathComponent(snapshot.intentMetadata)), let intent = try? JSONDecoder().decode(PublishedRuntimeIntent.self, from: intentData) else { return nil } return PublishedRuntimeConfiguration( diff --git a/docs/adr/0038-separate-agent-supervision-and-sslocal-input.md b/docs/adr/0038-separate-agent-supervision-and-sslocal-input.md index 13d9f2bf..a2e9eb72 100644 --- a/docs/adr/0038-separate-agent-supervision-and-sslocal-input.md +++ b/docs/adr/0038-separate-agent-supervision-and-sslocal-input.md @@ -30,6 +30,10 @@ GUI 不得重新引用退出保留集合的旧文件;准备文件必须先获 配置、认证和 ACL 文件名均包含 20 位十进制代次。认证身份使用随机 UUID;只在凭据变化时换身份和文件,不公开凭据摘要。GUI 为每份配置准备同代 `config--intent.json`,保存 GUI 私有意图摘要、最终配置字节摘要与脱敏汇总,用于配置损坏后仍能识别失败意图并独立提供诊断事实;Agent 只保留该引用,不读取正文。该 GUI 私有摘要不进入监管资料、回执或诊断。 +监管资料格式第 2 版以必填的 `configuration` 和 `intentMetadata` 分别记录配置与 GUI 私有元数据的具体文件引用,不通过配置文件名推导元数据位置。两个引用必须不同,均以 `config-` 开头并属于本次部署代次;监管校验不限定这两个文件的格式后缀。当前发布端继续生成 `sslocal.json`、`intent.json` 后缀,稳定链接仍为 `sslocal-active.json`。认证与 ACL 的命名和复用规则保持不变。元数据与配置同样必须是已存在的普通文件,缺失时整份部署不可用。发布与回收只依赖显式引用、受管文件名和代次,不解析配置正文。 + +本次格式变更不提供旧监管资料兼容、迁移或残留处理;开发环境切换前已停止后台进程并清理旧运行产物。未实现密文输入、密钥交接或通用资源框架。 + `sslocal.pid` 保留尚未确认退出的子进程身份;Agent 崩溃后若该 PID 仍存在,则拒绝启动第二个实例并报告失败。无法确认退出的进程和保守 PID 复用阻断需要用户检查并显式重试。没有可靠活跃准备者证据时,崩溃遗留的未来代文件暂不回收。 ## 失败与验证 diff --git a/docs/adr/0043-one-way-sslocal-configuration-output.md b/docs/adr/0043-one-way-sslocal-configuration-output.md index 40cbda2c..3cb985b5 100644 --- a/docs/adr/0043-one-way-sslocal-configuration-output.md +++ b/docs/adr/0043-one-way-sslocal-configuration-output.md @@ -10,4 +10,4 @@ GUI 的内存运行意图与 sslocal JSON 输出分离。上游配置只包含 LAN 认证仍写入现有受保护的明文认证文件。发布 implementation 比较本次内存生成的字节与当前监管资料引用的认证文件,相同则复用文件与随机认证身份,不同或不可读则重新准备。此比较不读取 sslocal 配置,也不增加凭据摘要、版本状态或完整秘密快照。未来认证文件是否加密仍开放;若编码形态变化,在发布 implementation 内调整比较方式,不提前引入通用加密框架。 -本决定补充 ADR-0038,沿用其代次、锁、不可变发布、稳定链接、ACL 复用与清理规则,以及 ADR-0037 的失败处理。本次不实施加密、密钥交接、sslocal 应用确认协议或文件引用格式改造。当前回执仍不表示 sslocal 已确认应用重载。 +本决定补充 ADR-0038,沿用其代次、锁、不可变发布、稳定链接、ACL 复用与清理规则,以及 ADR-0037 的失败处理。本次不实施加密、密钥交接或 sslocal 应用确认协议。后续文件引用格式改造见 ADR-0038 的第 2 版监管资料:配置与私有元数据采用显式引用,监管校验不依赖配置格式后缀。当前回执仍不表示 sslocal 已确认应用重载。