feat(system-proxy): emit privileged helper events to unified log

The root system-proxy helper logged nothing and launchd discards its
stdout/stderr, leaving helper launches, connection refusals, GUI-side
timeouts and root-side failures invisible. Share the minimal unified-log
emission unit (SystemLog) across GUI, wrapper and helper, and add a
dedicated closed helper event enum (lifecycle minimum: listener ready,
connection accepted/rejected with peer pid, typed apply/clear results)
with ADR-0045 privacy splitting. The emission seam is injected at the
helper engine and listener for test doubles. Helper events are
Console-only: no stderr, no agent.log, no diagnostic view or report
surface (ADR-0047).
This commit is contained in:
Qiu Yuzhou
2026-10-20 15:59:38 +08:00
parent 1d85e86b8e
commit 94ef128db8
17 changed files with 479 additions and 83 deletions
+1 -1
View File
@@ -292,7 +292,7 @@ _避免用词_:菜单栏形态(Menu bar form,应用形态并非恒定)
用户明确查看或复制界面事件与后台服务日志行的入口;行正文为脱敏后的原始文本,所展示的日志行不属于诊断报告。
**系统日志(System log)**:
本应用图形界面与后台服务写入 macOS 统一日志系统、供用户在 Console.app 中按来源与类别过滤查看的本地事件流。它面向本地排错,不是诊断报告的内容来源,也不替代诊断日志视图的显式查看与复制。
本应用图形界面、后台服务与特权系统代理 helper 写入 macOS 统一日志系统、供用户在 Console.app 中按来源与类别过滤查看的本地事件流。它面向本地排错,不是诊断报告的内容来源,也不替代诊断日志视图的显式查看与复制;特权系统代理 helper 的事件只有这一个查看面,不进诊断日志视图。
_避免用词_:os_log 输出(实现机制名,不是领域概念)。
**诊断事实(Diagnostic facts)**:
@@ -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)
}
@@ -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)")
}
}
@@ -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(普通视图显示 `<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?
}
@@ -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<Request, Error>, perform: Performing
) -> Data {
SystemProxyHelperWire.encode(response(request: request, perform: perform))
}
static func response(
request: Result<Request, Error>, perform: Performing
) -> SystemProxyHelperResponse {
@@ -73,6 +76,17 @@ final class SystemProxyHelperEngine: NSObject, SystemProxyHelperControlling {
return .failure(.helperUnavailable(String(describing: error)))
}
}
/// 执行请求、按应答发射一条执行结果事件并编码应答——日志与应答同源,
/// 不可能记下与回传不一致的结果。
private static func loggedResponseData(
request: Result<Request, Error>, 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,
@@ -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()
@@ -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)
}
}
}
@@ -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()
@@ -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"]))
}
}
@@ -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"]))
}
}
@@ -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
}
}
@@ -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<Void, Never>) 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<Void, Never>) 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<Void, Never>) 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<Void, Never>) 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<Void, Never>) 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<Void, Never>?
init(_ continuation: CheckedContinuation<Void, Never>) {
self.continuation = continuation
}
func resume() {
lock.lock()
let taken = continuation
continuation = nil
lock.unlock()
taken?.resume()
}
}
@@ -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
}
}
+9 -4
View File
@@ -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
@@ -11,3 +11,5 @@ sslocal 的上游自由文本是进入统一日志的第二条流,不进 `Runt
考虑过但拒绝的选项:把上游自由文本塞进封闭枚举(破坏脱敏由构造保证的基线);密码脱敏后把上游行标 public(服务器地址进持久 store);再加一层地址形态正则脱敏后标 public(会漂移的正则防线);让 intake 拥有 agent.log 截断策略(该策略属于 wrapper 事件行与 sslocal 行共写的文件去向)。`.info`/`.debug` 只在 stream 可见、不落盘,统一日志的持久面只收探测到的警告与错误,agent.log 才是完整持久面;private 字段对普通 Console 用户显示 `<private>`,正文阅读继续走 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。
@@ -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 下的时间戳与串行顺序足以对照两侧事件。
+3 -3
View File
@@ -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 的故障回归用例。