diff --git a/GLOSSARY.md b/GLOSSARY.md index cb76b740..360a3977 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -292,7 +292,7 @@ _避免用词_:菜单栏形态(Menu bar form,应用形态并非恒定) 用户明确查看或复制界面事件与后台服务日志行的入口;行正文为脱敏后的原始文本,所展示的日志行不属于诊断报告。 **系统日志(System log)**: -本应用图形界面与后台服务写入 macOS 统一日志系统、供用户在 Console.app 中按来源与类别过滤查看的本地事件流。它面向本地排错,不是诊断报告的内容来源,也不替代诊断日志视图的显式查看与复制。 +本应用图形界面、后台服务与特权系统代理 helper 写入 macOS 统一日志系统、供用户在 Console.app 中按来源与类别过滤查看的本地事件流。它面向本地排错,不是诊断报告的内容来源,也不替代诊断日志视图的显式查看与复制;特权系统代理 helper 的事件只有这一个查看面,不进诊断日志视图。 _避免用词_:os_log 输出(实现机制名,不是领域概念)。 **诊断事实(Diagnostic facts)**: diff --git a/ShadowsocksX-NG2/Domain/Runtime/RuntimeLog.swift b/ShadowsocksX-NG2/Domain/Runtime/RuntimeLog.swift index 4a6daaff..de5f8e35 100644 --- a/ShadowsocksX-NG2/Domain/Runtime/RuntimeLog.swift +++ b/ShadowsocksX-NG2/Domain/Runtime/RuntimeLog.swift @@ -184,15 +184,6 @@ extension RuntimeLogEvent { } } -/// 系统日志路由事实:发射缝按此构造统一日志消息。`text` 已含与 -/// `privateDetail` 之间的分隔符,两者拼接等于事件的 `description`。 -struct SystemLogRoute: Sendable { - var level: OSLogType - var category: String - var text: String - var privateDetail: String? -} - /// 事件接收缝:GUI 进程把事件接入 RuntimeEventStore(内存环形 /// 缓冲),供主窗口日志查看器实时呈现与诊断导出取用;wrapper 进程不注册 /// (stderr 收敛进 agent.log)。接收方拿到的是封闭枚举原值,渲染发生在 @@ -219,18 +210,6 @@ enum Redactor { /// agent.log)与系统日志输出(统一 subsystem,Console.app 按 category 过滤, /// 拆出的 detail 段标 private);GUI 启动时另注册接收缝接入事件缓冲。 enum RuntimeLog { - /// 系统日志 subsystem:各进程共用一个名字,Console.app 一条过滤全看。 - static let subsystem = "com.qiuyuzhou.ShadowsocksX-NG2" - - /// 统一日志写入开关(测试缝):`SSXNG_SYSTEM_LOG=0` 关闭系统日志写入, - /// stderr 行与事件缓冲不受影响。scheme 的测试动作注入该值,测试拉起的 - /// 子进程在夹具 env 显式设置——测试运行不把大量日志灌进系统日志 store。 - static let systemLogEnabled = isSystemLogEnabled(ProcessInfo.processInfo.environment) - - static func isSystemLogEnabled(_ environment: [String: String]) -> Bool { - environment["SSXNG_SYSTEM_LOG"] != "0" - } - private static let sinkHolder = SinkHolder() private static let lineSourceLock = OSAllocatedUnfairLock(initialState: LogLineSource.gui) @@ -252,16 +231,7 @@ enum RuntimeLog { if let line = event.standardErrorLine(timestamp: now) { FileHandle.standardError.write(Data(line.utf8)) } - if systemLogEnabled { - let route = event.systemLogRoute - let logger = Logger(subsystem: subsystem, category: route.category) - if let detail = route.privateDetail { - logger.log( - level: route.level, "\(route.text, privacy: .public)\(detail, privacy: .private)") - } else { - logger.log(level: route.level, "\(route.text, privacy: .public)") - } - } + SystemLog.emit(event.systemLogRoute) sinkHolder.current?.append(event: event, timestamp: now) } diff --git a/ShadowsocksX-NG2/Domain/Runtime/SslocalOutputIntake.swift b/ShadowsocksX-NG2/Domain/Runtime/SslocalOutputIntake.swift index d7cc628f..81405c9d 100644 --- a/ShadowsocksX-NG2/Domain/Runtime/SslocalOutputIntake.swift +++ b/ShadowsocksX-NG2/Domain/Runtime/SslocalOutputIntake.swift @@ -29,11 +29,11 @@ struct SslocalStandardErrorDestination: SslocalLineDestination { /// 获得时间戳、category、level 与行数关联;stderr/agent.log 通路没有这些 /// 可过滤维度。级别由 intake 探测,这里只投影到统一日志五级。 struct SslocalUnifiedLogDestination: SslocalLineDestination { - private let logger = Logger(subsystem: RuntimeLog.subsystem, category: "sslocal") + private let logger = Logger(subsystem: SystemLog.subsystem, category: "sslocal") func receive(_ row: LogLine) { // 统一日志写入受测试缝控制(SSXNG_SYSTEM_LOG=0 关闭),agent.log 去向不受影响。 - guard RuntimeLog.systemLogEnabled else { return } + guard SystemLog.systemLogEnabled else { return } logger.log(level: OSLogType(row.level), "\(row.text, privacy: .private)") } } diff --git a/ShadowsocksX-NG2/Domain/Runtime/SystemLog.swift b/ShadowsocksX-NG2/Domain/Runtime/SystemLog.swift new file mode 100644 index 00000000..55264c2e --- /dev/null +++ b/ShadowsocksX-NG2/Domain/Runtime/SystemLog.swift @@ -0,0 +1,42 @@ +import Foundation +import os + +/// 统一日志(Unified Logging)最小发射单元:GUI、wrapper 与特权 +/// helper 三个进程共用同一 subsystem,Console.app 一条过滤即可对照 +/// 各进程事件。发射的事实(level/category/text/privateDetail)由调用方的 +/// 封闭事件枚举给出;privacy 拆分口径与去向取舍见 ADR-0045,特权 helper +/// 的接入边界见 ADR-0047。 +enum SystemLog { + /// 系统日志 subsystem:各进程共用一个名字,Console.app 一条过滤全看。 + static let subsystem = "com.qiuyuzhou.ShadowsocksX-NG2" + + /// 统一日志写入开关(测试缝):`SSXNG_SYSTEM_LOG=0` 关闭系统日志写入, + /// stderr 行与事件缓冲不受影响。scheme 的测试动作注入该值,测试拉起的 + /// 子进程在夹具 env 显式设置——测试运行不把大量日志灌进系统日志 store。 + static let systemLogEnabled = isSystemLogEnabled(ProcessInfo.processInfo.environment) + + static func isSystemLogEnabled(_ environment: [String: String]) -> Bool { + environment["SSXNG_SYSTEM_LOG"] != "0" + } + + /// 按路由发射一条统一日志消息:公开文本标 public,自由 detail 段标 + /// private(普通视图显示 ``)。写入开关关闭时整体跳过。 + static func emit(_ route: SystemLogRoute) { + guard systemLogEnabled else { return } + let logger = Logger(subsystem: subsystem, category: route.category) + if let detail = route.privateDetail { + logger.log(level: route.level, "\(route.text, privacy: .public)\(detail, privacy: .private)") + } else { + logger.log(level: route.level, "\(route.text, privacy: .public)") + } + } +} + +/// 系统日志路由事实:发射缝按此构造统一日志消息。`text` 已含与 +/// `privateDetail` 之间的分隔符,两者拼接等于事件的 `description`。 +struct SystemLogRoute: Sendable { + var level: OSLogType + var category: String + var text: String + var privateDetail: String? +} diff --git a/ShadowsocksX-NG2/Domain/SystemProxy/SystemProxyHelperEngine.swift b/ShadowsocksX-NG2/Domain/SystemProxy/SystemProxyHelperEngine.swift index 828a1ec7..2acc4bec 100644 --- a/ShadowsocksX-NG2/Domain/SystemProxy/SystemProxyHelperEngine.swift +++ b/ShadowsocksX-NG2/Domain/SystemProxy/SystemProxyHelperEngine.swift @@ -26,36 +26,39 @@ final class SystemProxyHelperEngine: NSObject, SystemProxyHelperControlling { private let queue: DispatchQueue private let perform: Performing + private let log: @Sendable (SystemProxyHelperLogEvent) -> Void - init(perform: @escaping Performing, queue: DispatchQueue? = nil) { + /// `log` 是事件发射缝(ADR-0047):生产直通统一日志,测试注入替身 + /// 断言发射内容;默认参数不改变既有测试装配。 + init( + perform: @escaping Performing, + queue: DispatchQueue? = nil, + log: @escaping @Sendable (SystemProxyHelperLogEvent) -> Void = SystemProxyHelperLog.emit + ) { self.perform = perform self.queue = queue ?? Self.sharedQueue + self.log = log super.init() } func apply(_ payload: Data, withReply reply: @escaping (Data) -> Void) { let reply = ReplyBox(reply) - queue.async { [perform, reply] in + queue.async { [perform, reply, log] in let request = Result { try SystemProxyHelperWire.decodeConfiguration(payload) } - reply(Self.responseData(request: request.map { .apply($0) }, perform: perform)) + reply( + Self.loggedResponseData(request: request.map { .apply($0) }, perform: perform, log: log)) } } func clear(withReply reply: @escaping (Data) -> Void) { let reply = ReplyBox(reply) - queue.async { [perform, reply] in - reply(Self.responseData(request: .success(.clear), perform: perform)) + queue.async { [perform, reply, log] in + reply(Self.loggedResponseData(request: .success(.clear), perform: perform, log: log)) } } // MARK: - 请求执行(纯逻辑,可测) - static func responseData( - request: Result, perform: Performing - ) -> Data { - SystemProxyHelperWire.encode(response(request: request, perform: perform)) - } - static func response( request: Result, perform: Performing ) -> SystemProxyHelperResponse { @@ -73,6 +76,17 @@ final class SystemProxyHelperEngine: NSObject, SystemProxyHelperControlling { return .failure(.helperUnavailable(String(describing: error))) } } + + /// 执行请求、按应答发射一条执行结果事件并编码应答——日志与应答同源, + /// 不可能记下与回传不一致的结果。 + private static func loggedResponseData( + request: Result, perform: Performing, + log: @Sendable (SystemProxyHelperLogEvent) -> Void + ) -> Data { + let response = self.response(request: request, perform: perform) + log(SystemProxyHelperLogEvent(response: response)) + return SystemProxyHelperWire.encode(response) + } } /// XPC 应答闭包的 Sendable 桥。@objc 协议里 reply 参数无法标注 @Sendable, diff --git a/ShadowsocksX-NG2/Domain/SystemProxy/SystemProxyHelperListenerDelegate.swift b/ShadowsocksX-NG2/Domain/SystemProxy/SystemProxyHelperListenerDelegate.swift index ee3cc353..d6a4dac5 100644 --- a/ShadowsocksX-NG2/Domain/SystemProxy/SystemProxyHelperListenerDelegate.swift +++ b/ShadowsocksX-NG2/Domain/SystemProxy/SystemProxyHelperListenerDelegate.swift @@ -7,17 +7,21 @@ import Security final class SystemProxyHelperListenerDelegate: NSObject, NSXPCListenerDelegate { private let engine: SystemProxyHelperEngine private let expectedClientIdentifier: String + private let log: @Sendable (SystemProxyHelperLogEvent) -> Void /// 引擎由进程入口显式注入:特权写入器 SystemProxyWriter 属 helper 专属 /// (不进共享编译单元),测试注入假写入器。期望客户端身份生产默认为 2.0 /// bundle identifier;去宿主化(ADR 0021)后单测运行于 xctest runner, - /// 连接方身份随宿主改变,测试注入本进程实际签名 identifier。 + /// 连接方身份随宿主改变,测试注入本进程实际签名 identifier。`log` 是 + /// 事件发射缝(ADR-0047):连接接受/拒绝各记一条。 init( engine: SystemProxyHelperEngine, - expectedClientIdentifier: String = SystemProxyHelperIdentity.clientCodeSigningIdentifier + expectedClientIdentifier: String = SystemProxyHelperIdentity.clientCodeSigningIdentifier, + log: @escaping @Sendable (SystemProxyHelperLogEvent) -> Void = SystemProxyHelperLog.emit ) { self.engine = engine self.expectedClientIdentifier = expectedClientIdentifier + self.log = log super.init() } @@ -28,7 +32,11 @@ final class SystemProxyHelperListenerDelegate: NSObject, NSXPCListenerDelegate { guard SystemProxyClientValidator.clientSigningIdentifier(newConnection) == expectedClientIdentifier - else { return false } + else { + log(.connectionRejected(pid: newConnection.processIdentifier)) + return false + } + log(.connectionAccepted(pid: newConnection.processIdentifier)) newConnection.exportedInterface = NSXPCInterface(with: SystemProxyHelperControlling.self) newConnection.exportedObject = engine newConnection.resume() diff --git a/ShadowsocksX-NG2/Domain/SystemProxy/SystemProxyHelperLog.swift b/ShadowsocksX-NG2/Domain/SystemProxy/SystemProxyHelperLog.swift new file mode 100644 index 00000000..c2537629 --- /dev/null +++ b/ShadowsocksX-NG2/Domain/SystemProxy/SystemProxyHelperLog.swift @@ -0,0 +1,116 @@ +import Foundation +import os + +/// 特权系统代理 helper 的封闭事件枚举:helper 侧的执行事实只进 +/// 统一日志(Console.app 按 subsystem 查看),无文件面——不写 stderr、 +/// 不进 agent.log、诊断日志视图与诊断报告(ADR-0006 不新增披露面)。 +/// 事件面是生命周期最小集:拉起就绪、连接接受/拒绝与每个请求的 typed +/// 结果;不记 payload 内容(例外列表等)。GUI 侧 XPC 结果转述照旧,root +/// 侧事件与之在 Console 按同一 subsystem 相邻对照(ADR-0047)。 +enum SystemProxyHelperLogEvent: Equatable, Sendable, CustomStringConvertible { + /// helper 进程已拉起并开始监听(launchd on-demand 激活的唯一痕迹)。 + case listenerReady + /// 已接受签名标识合规的客户端连接(对端 pid 为本机事实字段)。 + case connectionAccepted(pid: Int32) + /// 拒绝签名标识不符的客户端连接(谁在敲门的唯一痕迹)。 + case connectionRejected(pid: Int32) + /// apply 请求成功(written/unchanged 由 typed 结果给出)。 + case applyCompleted(SystemProxyWriteOutcome) + /// clear 请求成功。 + case clearCompleted + /// 请求失败(typed 错误族;自由 detail 段发射时标 private)。 + case requestFailed(SystemProxyError) + + /// 从 XPC 应答派生执行结果事件;引擎把解码失败与执行异常都归入 + /// typed 失败应答,这里不区分来源。 + init(response: SystemProxyHelperResponse) { + switch response { + case .applied(let outcome): self = .applyCompleted(outcome) + case .cleared: self = .clearCompleted + case .failure(let error): self = .requestFailed(error) + } + } + + /// 事件完整文本:typed 枚举文本 + 自由 detail 段。拆分规则沿 + /// ADR-0045:公开文本由本描述尾部裁切派生,不手抄前缀。 + var description: String { + switch self { + case .listenerReady: + return "system proxy helper listening" + case .connectionAccepted(let pid): + return "system proxy helper connection accepted (pid=\(pid))" + case .connectionRejected(let pid): + return "system proxy helper connection rejected (pid=\(pid))" + case .applyCompleted(let outcome): + switch outcome { + case .written: + return "system proxy helper apply completed (outcome=written)" + case .unchanged: + return "system proxy helper apply completed (outcome=unchanged)" + } + case .clearCompleted: + return "system proxy helper clear completed" + case .requestFailed(let error): + let parts = error.logParts + guard let detail = parts.detail else { + return "system proxy helper request failed: \(parts.text)" + } + return "system proxy helper request failed: \(parts.text): \(detail)" + } + } + + /// 发射时标 private 的自由 detail 段:恰为 `description` 的尾段。 + private var privateDetail: String? { + guard case .requestFailed(let error) = self else { return nil } + return error.logParts.detail + } + + /// 系统日志路由:失败类 error、里程碑 default;category 沿用 + /// `system-proxy`,与 GUI 侧 `.systemProxyWriteFailed` 同类可对照。 + var logRoute: SystemLogRoute { + switch self { + case .listenerReady, .connectionAccepted, .applyCompleted, .clearCompleted: + return route(level: .default) + case .connectionRejected, .requestFailed: + return route(level: .error) + } + } + + private func route(level: OSLogType) -> SystemLogRoute { + guard let detail = privateDetail else { + return SystemLogRoute( + level: level, category: "system-proxy", text: description, privateDetail: nil) + } + return SystemLogRoute( + level: level, category: "system-proxy", + text: String(description.dropLast(detail.count)), privateDetail: detail) + } +} + +/// 事件发射缝:生产直通统一日志;测试注入替身断言发射。 +enum SystemProxyHelperLog { + static func emit(_ event: SystemProxyHelperLogEvent) { + SystemLog.emit(event.logRoute) + } +} + +extension SystemProxyError { + /// 统一日志文本拆分:公开的 typed 错误名与待标 private 的自由 detail + /// 段(服务标识或 SC/XPC 错误文本)。 + var logParts: (text: String, detail: String?) { + switch self { + case .preferencesUnavailable: return ("preferences unavailable", nil) + case .preferencesBusy: return ("preferences busy", nil) + case .noCurrentNetworkSet: return ("no current network set", nil) + case .noNetworkLocations: return ("no network locations", nil) + case .noProxyServices: return ("no proxy services", nil) + case .unreadableService(let detail): return ("unreadable service", detail) + case .invalidStoredConfiguration(let detail): return ("invalid stored configuration", detail) + case .cannotWriteService(let detail): return ("cannot write service", detail) + case .commitFailed(let detail): return ("commit failed", detail) + case .applyFailed(let detail): return ("apply failed", detail) + case .invalidRequest(let detail): return ("invalid request", detail) + case .helperUnavailable(let detail): return ("helper unavailable", detail) + } + } +} diff --git a/ShadowsocksX-NG2/SystemProxyHelper/main.swift b/ShadowsocksX-NG2/SystemProxyHelper/main.swift index 0d463de2..9f6d95f9 100644 --- a/ShadowsocksX-NG2/SystemProxyHelper/main.swift +++ b/ShadowsocksX-NG2/SystemProxyHelper/main.swift @@ -8,4 +8,7 @@ let delegate = SystemProxyHelperListenerDelegate( let listener = NSXPCListener(machServiceName: SystemProxyHelperIdentity.machServiceName) listener.delegate = delegate listener.resume() +// 拉起就绪事件只进统一日志(ADR-0047):launchd 丢弃本进程 stdout/stderr, +// 这是「helper 是否被拉起」的唯一痕迹。 +SystemProxyHelperLog.emit(.listenerReady) dispatchMain() diff --git a/ShadowsocksX-NG2/Tests/Runtime/RuntimeLogSystemLogSwitchTests.swift b/ShadowsocksX-NG2/Tests/Runtime/RuntimeLogSystemLogSwitchTests.swift deleted file mode 100644 index 7e2e8ebd..00000000 --- a/ShadowsocksX-NG2/Tests/Runtime/RuntimeLogSystemLogSwitchTests.swift +++ /dev/null @@ -1,11 +0,0 @@ -import Testing - -@testable import ShadowsocksX_NG2 - -struct RuntimeLogSystemLogSwitchTests { - @Test func systemLogIsDisabledOnlyByExplicitZero() { - #expect(RuntimeLog.isSystemLogEnabled([:])) - #expect(RuntimeLog.isSystemLogEnabled(["SSXNG_SYSTEM_LOG": "1"])) - #expect(!RuntimeLog.isSystemLogEnabled(["SSXNG_SYSTEM_LOG": "0"])) - } -} diff --git a/ShadowsocksX-NG2/Tests/Runtime/SystemLogSwitchTests.swift b/ShadowsocksX-NG2/Tests/Runtime/SystemLogSwitchTests.swift new file mode 100644 index 00000000..19f7d88b --- /dev/null +++ b/ShadowsocksX-NG2/Tests/Runtime/SystemLogSwitchTests.swift @@ -0,0 +1,11 @@ +import Testing + +@testable import ShadowsocksX_NG2 + +struct SystemLogSwitchTests { + @Test func systemLogIsDisabledOnlyByExplicitZero() { + #expect(SystemLog.isSystemLogEnabled([:])) + #expect(SystemLog.isSystemLogEnabled(["SSXNG_SYSTEM_LOG": "1"])) + #expect(!SystemLog.isSystemLogEnabled(["SSXNG_SYSTEM_LOG": "0"])) + } +} diff --git a/ShadowsocksX-NG2/Tests/Support/ProcessSigningIdentifier.swift b/ShadowsocksX-NG2/Tests/Support/ProcessSigningIdentifier.swift new file mode 100644 index 00000000..799a8cdc --- /dev/null +++ b/ShadowsocksX-NG2/Tests/Support/ProcessSigningIdentifier.swift @@ -0,0 +1,21 @@ +import Foundation +import Security + +/// 本进程的代码签名 identifier(与生产 SystemProxyClientValidator 同一 +/// SecCode 查询路径,对自身用 SecCodeCopySelf 取 code)。去宿主化(ADR 0021) +/// 后单测运行于 xctest runner,XPC 连接方身份随宿主改变;helper XPC 装配 +/// 测试以本值注入期望客户端身份,无需 launchd。 +enum ProcessSigningIdentifier { + static func current() -> String? { + var code: SecCode? + guard SecCodeCopySelf([], &code) == errSecSuccess, let code else { return nil } + var staticCode: SecStaticCode? + guard SecCodeCopyStaticCode(code, [], &staticCode) == errSecSuccess, let staticCode + else { return nil } + var information: CFDictionary? + guard SecCodeCopySigningInformation(staticCode, SecCSFlags(), &information) == errSecSuccess, + let information + else { return nil } + return (information as NSDictionary)[kSecCodeInfoIdentifier] as? String + } +} diff --git a/ShadowsocksX-NG2/Tests/SystemProxy/SystemProxyHelperLogTests.swift b/ShadowsocksX-NG2/Tests/SystemProxy/SystemProxyHelperLogTests.swift new file mode 100644 index 00000000..58ce7a8b --- /dev/null +++ b/ShadowsocksX-NG2/Tests/SystemProxy/SystemProxyHelperLogTests.swift @@ -0,0 +1,216 @@ +import Foundation +import Testing + +@testable import ShadowsocksX_NG2 + +/// 特权 helper 事件的统一日志路由与发射缝测试(ADR-0047):route 是纯 +/// 函数,断言 level/category/公开文本与 privacy 拆分;engine 与 listener +/// 经注入替身断言发射调用。测试不写真实系统日志 store(scheme 注入 +/// SSXNG_SYSTEM_LOG=0),也不触真实 SystemConfiguration。 +@Suite struct SystemProxyHelperLogTests { + private let configuration = SystemProxyConfiguration( + socks: .init(host: "127.0.0.1", port: 1086), + http: .init(host: "127.0.0.1", port: 1087), + https: .init(host: "127.0.0.1", port: 1087), + exceptions: ["localhost"]) + + /// 并发闭包与断言之间的线程安全事件交接。 + private final class EventRecorder: @unchecked Sendable { + private let lock = NSLock() + private var events: [SystemProxyHelperLogEvent] = [] + + func append(_ event: SystemProxyHelperLogEvent) { + lock.lock() + events.append(event) + lock.unlock() + } + + var all: [SystemProxyHelperLogEvent] { + lock.lock() + defer { lock.unlock() } + return events + } + } + + // MARK: - route(纯函数) + + @Test func lifecycleEventsRouteToSystemProxyCategory() { + let ready = SystemProxyHelperLogEvent.listenerReady.logRoute + #expect(ready.level == .default) + #expect(ready.category == "system-proxy") + #expect(ready.text == "system proxy helper listening") + #expect(ready.privateDetail == nil) + + let accepted = SystemProxyHelperLogEvent.connectionAccepted(pid: 42).logRoute + #expect(accepted.level == .default) + #expect(accepted.text == "system proxy helper connection accepted (pid=42)") + #expect(accepted.privateDetail == nil) + + let rejected = SystemProxyHelperLogEvent.connectionRejected(pid: 7).logRoute + #expect(rejected.level == .error) + #expect(rejected.text == "system proxy helper connection rejected (pid=7)") + #expect(rejected.privateDetail == nil) + } + + @Test func requestResultsRouteAsMilestones() { + let written = SystemProxyHelperLogEvent.applyCompleted(.written).logRoute + #expect(written.level == .default) + #expect(written.text == "system proxy helper apply completed (outcome=written)") + + let unchanged = SystemProxyHelperLogEvent.applyCompleted(.unchanged).logRoute + #expect(unchanged.level == .default) + #expect(unchanged.text == "system proxy helper apply completed (outcome=unchanged)") + + let cleared = SystemProxyHelperLogEvent.clearCompleted.logRoute + #expect(cleared.level == .default) + #expect(cleared.text == "system proxy helper clear completed") + } + + @Test func requestFailureKeepsFreeDetailPrivate() { + let event = SystemProxyHelperLogEvent.requestFailed( + .commitFailed("SCPreferencesCommitChanges: busy")) + let route = event.logRoute + #expect(route.level == .error) + #expect(route.category == "system-proxy") + #expect(route.text == "system proxy helper request failed: commit failed: ") + #expect(route.privateDetail == "SCPreferencesCommitChanges: busy") + #expect(route.text + route.privateDetail! == event.description, "text+detail == description") + } + + @Test func requestFailureWithoutDetailHasNoPrivateSegment() { + let event = SystemProxyHelperLogEvent.requestFailed(.preferencesBusy) + #expect(event.logRoute.text == "system proxy helper request failed: preferences busy") + #expect(event.logRoute.privateDetail == nil) + #expect(event.description == "system proxy helper request failed: preferences busy") + } + + @Test func errorCasesSplitDetailFromPublicText() { + #expect(SystemProxyError.unreadableService("svc-1").logParts.text == "unreadable service") + #expect(SystemProxyError.unreadableService("svc-1").logParts.detail == "svc-1") + #expect(SystemProxyError.invalidRequest("bad json").logParts.detail == "bad json") + #expect(SystemProxyError.helperUnavailable("timeout").logParts.detail == "timeout") + #expect(SystemProxyError.preferencesUnavailable.logParts.detail == nil) + #expect(SystemProxyError.noProxyServices.logParts.detail == nil) + #expect(SystemProxyError.noCurrentNetworkSet.logParts.detail == nil) + #expect(SystemProxyError.noNetworkLocations.logParts.detail == nil) + #expect(SystemProxyError.invalidStoredConfiguration("svc-2").logParts.detail == "svc-2") + #expect(SystemProxyError.cannotWriteService("svc-3").logParts.detail == "svc-3") + #expect(SystemProxyError.applyFailed("SCError").logParts.detail == "SCError") + } + + @Test func eventsDeriveFromTypedResponses() { + #expect(SystemProxyHelperLogEvent(response: .applied(.written)) == .applyCompleted(.written)) + #expect(SystemProxyHelperLogEvent(response: .cleared) == .clearCompleted) + #expect( + SystemProxyHelperLogEvent(response: .failure(.noProxyServices)) + == .requestFailed(.noProxyServices)) + } + + // MARK: - 发射缝(engine 注入替身) + + @Test func engineEmitsTypedApplyResult() async throws { + let recorder = EventRecorder() + let engine = SystemProxyHelperEngine(perform: { _ in .applied(.written) }, log: recorder.append) + let payload = try SystemProxyHelperWire.encodeConfiguration(configuration) + await withCheckedContinuation { (continuation: CheckedContinuation) in + engine.apply(payload) { _ in continuation.resume() } + } + #expect(recorder.all == [.applyCompleted(.written)]) + } + + @Test func engineEmitsClearResult() async { + let recorder = EventRecorder() + let engine = SystemProxyHelperEngine(perform: { _ in .cleared }, log: recorder.append) + await withCheckedContinuation { (continuation: CheckedContinuation) in + engine.clear { _ in continuation.resume() } + } + #expect(recorder.all == [.clearCompleted]) + } + + @Test func engineEmitsFailureForUndecodablePayload() async { + let recorder = EventRecorder() + let engine = SystemProxyHelperEngine(perform: { _ in .cleared }, log: recorder.append) + await withCheckedContinuation { (continuation: CheckedContinuation) in + engine.apply(Data("not json".utf8)) { _ in continuation.resume() } + } + guard case .requestFailed(let error) = recorder.all.first else { + Issue.record("无效 payload 应发射 requestFailed,实际 \(recorder.all)") + return + } + #expect(error.logParts.text == "invalid request") + #expect(error.logParts.detail != nil) + } + + // MARK: - 发射缝(listener 注入替身,真实匿名 XPC) + + @Test func listenerEmitsAcceptedConnectionBeforeRequestResult() async throws { + let recorder = EventRecorder() + let engine = SystemProxyHelperEngine(perform: { _ in .cleared }, log: recorder.append) + let delegate = SystemProxyHelperListenerDelegate( + engine: engine, + expectedClientIdentifier: try #require(ProcessSigningIdentifier.current()), + log: recorder.append) + let listener = NSXPCListener.anonymous() + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + + let connection = NSXPCConnection(listenerEndpoint: listener.endpoint) + connection.remoteObjectInterface = NSXPCInterface(with: SystemProxyHelperControlling.self) + connection.resume() + defer { connection.invalidate() } + + let proxy = try #require( + connection.remoteObjectProxyWithErrorHandler { _ in } as? SystemProxyHelperControlling) + await withCheckedContinuation { (continuation: CheckedContinuation) in + proxy.clear { _ in continuation.resume() } + } + #expect(recorder.all == [.connectionAccepted(pid: getpid()), .clearCompleted]) + } + + @Test func listenerEmitsRejectedConnection() async { + let recorder = EventRecorder() + let engine = SystemProxyHelperEngine(perform: { _ in .cleared }, log: recorder.append) + let delegate = SystemProxyHelperListenerDelegate( + engine: engine, + expectedClientIdentifier: "com.example.not-the-client", + log: recorder.append) + let listener = NSXPCListener.anonymous() + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + + let connection = NSXPCConnection(listenerEndpoint: listener.endpoint) + connection.remoteObjectInterface = NSXPCInterface(with: SystemProxyHelperControlling.self) + // XPC 惰性连接:发一条消息才触发服务端签名校验;被拒绝的连接以失效 + // (或代理错误回调)告终。超时兜底避免挂死,超时后事件断言自然失败。 + await withCheckedContinuation { (continuation: CheckedContinuation) in + let once = ResumeOnce(continuation) + connection.invalidationHandler = { once.resume() } + connection.resume() + let proxy = connection.remoteObjectProxyWithErrorHandler { _ in once.resume() } + (proxy as? SystemProxyHelperControlling)?.clear { _ in } + DispatchQueue.global().asyncAfter(deadline: .now() + 10) { once.resume() } + } + connection.invalidate() + #expect(recorder.all == [.connectionRejected(pid: getpid())]) + } +} + +/// 只恢复一次的 continuation 装箱:失效回调与超时兜底竞争同一恢复权。 +private final class ResumeOnce: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + + init(_ continuation: CheckedContinuation) { + self.continuation = continuation + } + + func resume() { + lock.lock() + let taken = continuation + continuation = nil + lock.unlock() + taken?.resume() + } +} diff --git a/ShadowsocksX-NG2/Tests/SystemProxy/SystemProxyHelperXPCRoundTripTests.swift b/ShadowsocksX-NG2/Tests/SystemProxy/SystemProxyHelperXPCRoundTripTests.swift index 470c7eee..371824f6 100644 --- a/ShadowsocksX-NG2/Tests/SystemProxy/SystemProxyHelperXPCRoundTripTests.swift +++ b/ShadowsocksX-NG2/Tests/SystemProxy/SystemProxyHelperXPCRoundTripTests.swift @@ -1,5 +1,4 @@ import Foundation -import Security import XCTest @testable import ShadowsocksX_NG2 @@ -21,7 +20,7 @@ final class SystemProxyHelperXPCRoundTripTests: XCTestCase { }) let delegate = SystemProxyHelperListenerDelegate( engine: engine, - expectedClientIdentifier: try XCTUnwrap(Self.currentProcessSigningIdentifier())) + expectedClientIdentifier: try XCTUnwrap(ProcessSigningIdentifier.current())) let listener = NSXPCListener.anonymous() listener.delegate = delegate listener.resume() @@ -69,19 +68,4 @@ final class SystemProxyHelperXPCRoundTripTests: XCTestCase { await fulfillment(of: [clearReplied], timeout: 10) XCTAssertEqual(clearedResponse, .cleared) } - - /// 本进程的代码签名 identifier(与生产 SystemProxyClientValidator 同一 - /// SecCode 查询路径,对自身用 SecCodeCopySelf 取 code)。 - private static func currentProcessSigningIdentifier() -> String? { - var code: SecCode? - guard SecCodeCopySelf([], &code) == errSecSuccess, let code else { return nil } - var staticCode: SecStaticCode? - guard SecCodeCopyStaticCode(code, [], &staticCode) == errSecSuccess, let staticCode - else { return nil } - var information: CFDictionary? - guard SecCodeCopySigningInformation(staticCode, SecCSFlags(), &information) == errSecSuccess, - let information - else { return nil } - return (information as NSDictionary)[kSecCodeInfoIdentifier] as? String - } } diff --git a/ShadowsocksX-NG2/project.yml b/ShadowsocksX-NG2/project.yml index 54336c2c..555730c5 100644 --- a/ShadowsocksX-NG2/project.yml +++ b/ShadowsocksX-NG2/project.yml @@ -201,13 +201,15 @@ targets: platform: macOS sources: - path: Agent - # Agent 只共享非秘密监管协议、路径、探针与 sslocal 输出 intake,不编译 - # sslocal 配置或凭据类型。显式文件路径变动必须同步本清单并重新生成工程。 + # Agent 只共享非秘密监管协议、路径、探针、统一日志发射单元与 + # sslocal 输出 intake,不编译 sslocal 配置或凭据类型。显式文件路径 + # 变动必须同步本清单并重新生成工程。 - path: Domain/Settings/ApplicationSupportPaths.swift - path: Domain/Runtime/RuntimePaths.swift - path: Domain/Runtime/AtomicFileWriter.swift - path: Domain/Runtime/RuntimeSupervision.swift - path: Domain/Runtime/RuntimeLog.swift + - path: Domain/Runtime/SystemLog.swift - path: Domain/Runtime/LogLine.swift - path: Domain/Runtime/SslocalOutputIntake.swift - path: Domain/Runtime/EndpointHealthProbe.swift @@ -222,14 +224,17 @@ targets: sources: - path: SystemProxyHelper # helper 与 GUI 共享的 typed 契约与纯逻辑:closed typed 配置、property - # list 映射、计划器、XPC 协议与请求引擎。helper 不含产品 - # 策略:不在这些文件之外做任何域判断。以下 6 条同为显式路径,移动需同步。 + # list 映射、计划器、XPC 协议、请求引擎、helper 侧日志事件与统一日志 + # 发射单元(helper 日志只进统一日志,ADR-0047)。helper 不含产品 + # 策略:不在这些文件之外做任何域判断。以下 8 条同为显式路径,移动需同步。 - path: Domain/SystemProxy/SystemProxyConfiguration.swift - path: Domain/SystemProxy/SystemProxyPlan.swift - path: Domain/SystemProxy/SystemProxyPropertyList.swift - path: Domain/SystemProxy/SystemProxyHelperContract.swift - path: Domain/SystemProxy/SystemProxyHelperEngine.swift - path: Domain/SystemProxy/SystemProxyHelperListenerDelegate.swift + - path: Domain/SystemProxy/SystemProxyHelperLog.swift + - path: Domain/Runtime/SystemLog.swift settings: base: PRODUCT_NAME: ShadowsocksX-NG2SystemProxyHelper diff --git a/docs/adr/0045-native-oslog-unified-log-routing.md b/docs/adr/0045-native-oslog-unified-log-routing.md index e95a0daa..44aa939a 100644 --- a/docs/adr/0045-native-oslog-unified-log-routing.md +++ b/docs/adr/0045-native-oslog-unified-log-routing.md @@ -11,3 +11,5 @@ sslocal 的上游自由文本是进入统一日志的第二条流,不进 `Runt 考虑过但拒绝的选项:把上游自由文本塞进封闭枚举(破坏脱敏由构造保证的基线);密码脱敏后把上游行标 public(服务器地址进持久 store);再加一层地址形态正则脱敏后标 public(会漂移的正则防线);让 intake 拥有 agent.log 截断策略(该策略属于 wrapper 事件行与 sslocal 行共写的文件去向)。`.info`/`.debug` 只在 stream 可见、不落盘,统一日志的持久面只收探测到的警告与错误,agent.log 才是完整持久面;private 字段对普通 Console 用户显示 ``,正文阅读继续走 app 内诊断查看面。诊断报告的白名单投影独立于本路由,新增事件仍须显式决策(ADR-0006)。 测试运行关闭统一日志写入:测试缝 `SSXNG_SYSTEM_LOG=0` 由 scheme 的测试动作注入、测试拉起的子进程在夹具显式设置,stderr 行与事件缓冲不受影响——测试不把日志灌进系统日志 store。这不是用户日志级别设置(ADR-0014 的口径不变)。 + +**2026-10-20 补充**:统一日志的写入单元随后抽为 `SystemLog`(`Domain/Runtime/SystemLog.swift`),GUI/wrapper 的 `RuntimeLog.emit` 与特权系统代理 helper 共用它;helper 侧事件的边界(专属封闭枚举、无文件面)见 ADR-0047。 diff --git a/docs/adr/0047-privileged-helper-unified-log-only.md b/docs/adr/0047-privileged-helper-unified-log-only.md new file mode 100644 index 00000000..4182d795 --- /dev/null +++ b/docs/adr/0047-privileged-helper-unified-log-only.md @@ -0,0 +1,15 @@ +# 特权 helper 事件只进统一日志 + +**状态**:已接受(2026-10-20)。 + +特权系统代理 helper(root LaunchDaemon)此前是日志黑洞:launchd 丢弃其 stdout/stderr,进程内零输出,helper 侧失败上下文只剩随 XPC 应答回传的 typed 错误、由 GUI 进程转述成 `.systemProxyWriteFailed`。GUI 视角不完整的场景完全没有痕迹:GUI 超时后 helper 实际做了什么、连接中途失效、helper 被 launchd 杀死或拉起失败、签名校验拒绝的连接(谁在敲门)。 + +helper 复用统一日志的最小发射单元 `SystemLog`(`Domain/Runtime/SystemLog.swift`),与 GUI、wrapper 共用 subsystem `com.qiuyuzhou.ShadowsocksX-NG2`,category 沿用 `system-proxy`——root 侧事件与 GUI 侧 XPC detail 在 Console.app 同一过滤下按时间相邻对照。特权进程的编译面只增加两个文件:发射单元 `SystemLog` 与 helper 侧事件枚举 `SystemProxyHelperLogEvent`。 + +helper 事件是专属封闭枚举 `SystemProxyHelperLogEvent`,不扩 `RuntimeLogEvent`:GUI 事件枚举(含连通性测量内容)不进特权进程。事件面是生命周期最小集:拉起就绪、连接接受/拒绝(含对端 pid,本机事实字段)、每个 apply/clear 请求的 typed 结果(written/unchanged/cleared 或失败 case + detail);不记 payload 内容(例外列表等不入日志)。privacy 口径沿 ADR-0045:typed 枚举文本标 public,自由 detail 段(服务标识、SC/XPC 错误文本)标 private。 + +事件没有文件面:不写 stderr、不进 agent.log、诊断日志视图与诊断报告(ADR-0006 不新增披露面),root 侧细节的查看路径就是 Console.app。诊断日志视图里系统代理的行仍是 GUI 侧转述。发射缝可注入(engine 与 listener 的构造参数,生产默认 `SystemProxyHelperLog.emit`),测试注入替身断言发射内容;`SSXNG_SYSTEM_LOG=0` 测试静默缝照旧。 + +考虑过但拒绝的选项:扩 `RuntimeLogEvent` 并让 helper 编译 `RuntimeLog.swift`(特权进程背上 GUI 全事件面,编译面扩大);helper 直写 `os.Logger`(privacy 拆分与测试缝两处重复);OSLogStore 回读并入诊断日志视图(helper 事件文本公开、回读可读全,但引入读系统日志的测试面与真实 store 写入,与测试静默缝相互作用,复杂度不值当前失败面);GUI 在失败应答时把 root 事件转记进视图(只覆盖应答成功的场景,超时与断连仍缺,恰是本决策要补的面)。 + +helper 与 GUI 的 XPC 契约不变,不加请求关联 id:helper 请求串行执行,同一 subsystem 下的时间戳与串行顺序足以对照两侧事件。 diff --git a/docs/project-structure.md b/docs/project-structure.md index e69da90a..fc207118 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -22,14 +22,14 @@ - `Catalog/` — 配置目录树、结构不变量错误、目录文件存储、节点与凭据身份,以及 `ss://` 编解码 `SsUri`。 - `Subscription/` — 订阅文档解析、抓取、订阅记录与刷新失败。 - `Rules/` — 代理模式、规则身份与匹配、内置规则快照、规则集合/浏览/分析、离线规则测试;`Rules/Custom/` 为用户自定义规则的编辑草稿、校验与存储。 - - `Runtime/` — 代理运行服务:运行时文档与监听设置、ACL 与部署收据、路径、原子写入、文件存储、日志与事件(统一日志行 `LogLine`、sslocal 输出 intake)、端点/端口/socket 探针。 + - `Runtime/` — 代理运行服务:运行时文档与监听设置、ACL 与部署收据、路径、原子写入、文件存储、日志与事件(统一日志发射单元 `SystemLog`、统一日志行 `LogLine`、sslocal 输出 intake)、端点/端口/socket 探针。 - `Activation/` — 激活候选校验、激活状态机与 `RuntimeConfiguration`。 - `Settings/` — 代理监听配置文件、独立代理控制偏好、运行计算的内存快照与静默启动偏好。 - - `SystemProxy/` — 系统代理 typed 配置、property list 映射、计划器与特权 helper 的 XPC 契约/请求引擎。 + - `SystemProxy/` — 系统代理 typed 配置、property list 映射、计划器、特权 helper 的 XPC 契约/请求引擎与 helper 侧日志事件。 - `Plugin/` — 托管插件、用户插件映射与插件安全校验。 - `Credentials/`、`LegacyImport/`、`Diagnostics/` — 主密钥存储与加密文件提交、旧版导入、诊断报告。 - 跨进程契约按**文件**编入其它 target:`Runtime/` 的 8 个文件和 `Settings/ApplicationSupportPaths.swift` 同时编入 wrapper(`RuntimePaths`、`RuntimeSupervision`、`RuntimeLog`、`LogLine`、`SslocalOutputIntake` 等),`SystemProxy/` 全部 6 个文件编入特权 helper。`project.yml` 里这 15 条是显式路径,在 `Domain/` 内移动文件必须同步,漏改会让 `xcodegen generate` 以缺文件直接失败。布局守卫(`Tests/Architecture/SkeletonTests.swift` 的 `DomainLayoutTests`)断言根下无平铺 `.swift`、树下只有 `.swift`(非源码文件会被 XcodeGen 收进 Resources 阶段并封进签名后的 bundle)。 + 跨进程契约按**文件**编入其它 target:`Runtime/` 的 9 个文件和 `Settings/ApplicationSupportPaths.swift` 同时编入 wrapper(`RuntimePaths`、`RuntimeSupervision`、`RuntimeLog`、`SystemLog`、`LogLine`、`SslocalOutputIntake` 等),`SystemProxy/` 全部 7 个文件连同 `Runtime/SystemLog.swift` 编入特权 helper(helper 侧日志只进统一日志,ADR-0047)。`project.yml` 里这 18 条是显式路径,在 `Domain/` 内移动文件必须同步,漏改会让 `xcodegen generate` 以缺文件直接失败。布局守卫(`Tests/Architecture/SkeletonTests.swift` 的 `DomainLayoutTests`)断言根下无平铺 `.swift`、树下只有 `.swift`(非源码文件会被 XcodeGen 收进 Resources 阶段并封进签名后的 bundle)。 - `Tests/` — 单元测试 target,随 `ShadowsocksX-NG2` scheme 运行。目录即主题,根下不留平铺测试文件: - `Architecture/` — 跨切面工程约束守卫(分层与布局、平台效应 seam、开窗落点、清单完整性),扫源码或构建产物而不驱动行为。 - `TestSupport/` — 测试等待与清理 helper 的故障回归用例。