mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-29 05:33:45 +00:00
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:
+1
-1
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 下的时间戳与串行顺序足以对照两侧事件。
|
||||
@@ -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 的故障回归用例。
|
||||
|
||||
Reference in New Issue
Block a user