diff --git a/ShadowsocksX-NG2/Agent/AgentContract.swift b/ShadowsocksX-NG2/Agent/AgentContract.swift index 4df3ce63..f2bebb81 100644 --- a/ShadowsocksX-NG2/Agent/AgentContract.swift +++ b/ShadowsocksX-NG2/Agent/AgentContract.swift @@ -71,12 +71,15 @@ func spawnSslocal() -> Process? { guard FileManager.default.isExecutableFile(atPath: sslocalURL.path) else { return nil } let child = Process() let errorPipe = Pipe() - let logRedactor = SslocalLogRedactor() + // sslocal 输出 intake:分帧 + 密码脱敏后送两个去向——agent.log 文件 + // (经 stderr 收敛,raw capture 保留)与统一日志(category `sslocal`)。 + let outputIntake = SslocalOutputIntake( + destinations: [SslocalStandardErrorDestination(), SslocalUnifiedLogDestination()]) child.standardError = errorPipe child.standardOutput = errorPipe errorPipe.fileHandleForReading.readabilityHandler = { handle in let data = handle.availableData - logRedactor.write(data) + outputIntake.write(data) if data.isEmpty { handle.readabilityHandler = nil } } child.executableURL = sslocalURL @@ -110,7 +113,9 @@ func redirectStandardStreams(to logURL: URL) { if fileManager.fileExists(atPath: logURL.path) { let attributes = try? fileManager.attributesOfItem(atPath: logURL.path) if let size = attributes?[.size] as? UInt64, size > 1_048_576 { - // 启动时超限即截断;当前不滚动或导出诊断日志。 + // agent.log 文件去向(stderr→freopen)的保留策略:启动时超限即截断, + // 当前不滚动或导出诊断日志。策略留在这个文件去向(wrapper 事件行与 + // 脱敏后的 sslocal 行共写这一个文件),不并入 sslocal 输出 intake。 try? fileManager.removeItem(at: logURL) } } diff --git a/ShadowsocksX-NG2/Agent/SslocalLogRedactor.swift b/ShadowsocksX-NG2/Agent/SslocalLogRedactor.swift deleted file mode 100644 index d946f0b4..00000000 --- a/ShadowsocksX-NG2/Agent/SslocalLogRedactor.swift +++ /dev/null @@ -1,41 +0,0 @@ -import Foundation - -/// 上游认证失败日志包含客户端提交的密码;按完整行隐藏密码后写入私有日志。 -/// FileHandle 回调通过锁串行访问缓冲区,避免分块输入绕过隐藏规则。 -final class SslocalLogRedactor: @unchecked Sendable { - private let lock = NSLock() - private var buffer = Data() - private var discardingOversizedLine = false - - func write(_ data: Data) { - lock.lock() - defer { lock.unlock() } - if discardingOversizedLine { - guard let newline = data.firstIndex(of: 10) else { return } - discardingOversizedLine = false - buffer.append(data.suffix(from: data.index(after: newline))) - } else { - buffer.append(data) - } - while let newline = buffer.firstIndex(of: 10) { - emit(buffer.prefix(through: newline)) - buffer.removeSubrange(...newline) - } - if data.isEmpty && !buffer.isEmpty { - emit(buffer) - buffer.removeAll() - } else if buffer.count > 65_536 { - buffer.removeAll() - discardingOversizedLine = true - FileHandle.standardError.write(Data("sslocal: log line truncated\n".utf8)) - } - } - - private func emit(_ line: Data) { - guard let text = String(data: line, encoding: .utf8) else { return } - let sanitized = text.replacingOccurrences( - of: "password: [^\\r\\n]*", - with: "password: [redacted]", options: .regularExpression) - FileHandle.standardError.write(Data(sanitized.utf8)) - } -} diff --git a/ShadowsocksX-NG2/Domain/Runtime/RuntimeLog.swift b/ShadowsocksX-NG2/Domain/Runtime/RuntimeLog.swift index 32977b81..1503e7a6 100644 --- a/ShadowsocksX-NG2/Domain/Runtime/RuntimeLog.swift +++ b/ShadowsocksX-NG2/Domain/Runtime/RuntimeLog.swift @@ -116,7 +116,8 @@ enum RuntimeLogEvent: Equatable, CustomStringConvertible, Sendable { /// 系统日志路由(统一日志,Console.app 可按 subsystem/category 过滤): /// level 三分(失败类 error、里程碑 default、冗长测量 info)、category 五分 -/// (runtime / supervision / system-proxy / connectivity / diagnostics)。 +/// (runtime / supervision / system-proxy / connectivity / diagnostics; +/// sslocal 输出 intake 的自由文本行走独立的 category `sslocal`,不经本枚举)。 /// 拆分规则:`text` + `privateDetail` == `description`;自由错误 detail 与 /// 连通性测量内容(含探测目标 IP)拆出由发射缝标 private,公开文本由 /// description 尾部裁切派生,不手抄前缀。穷举 switch:新事件漏路由无法编译。 diff --git a/ShadowsocksX-NG2/Domain/Runtime/SslocalOutputIntake.swift b/ShadowsocksX-NG2/Domain/Runtime/SslocalOutputIntake.swift new file mode 100644 index 00000000..30f13712 --- /dev/null +++ b/ShadowsocksX-NG2/Domain/Runtime/SslocalOutputIntake.swift @@ -0,0 +1,102 @@ +import Foundation +import os + +/// 脱敏后行的去向缝:intake 只产出行,行最终落在哪里由 adapter 决定。 +protocol SslocalLineDestination: Sendable { + func receive(_ line: String) +} + +/// adapter:wrapper 的 stderr。`redirectStandardStreams` 以 freopen 把 stderr +/// 收敛进 agent.log,raw capture 原样保留(ADR-0006 的本地查看面)。handle +/// 可注入(生产默认标准错误),测试用管道替身断言序列化,不碰进程全局 stderr。 +struct SslocalStandardErrorDestination: SslocalLineDestination { + private let handle: FileHandle + + init(handle: FileHandle = .standardError) { + self.handle = handle + } + + func receive(_ line: String) { + handle.write(Data((line + "\n").utf8)) + } +} + +/// adapter:统一日志(category `sslocal`,与 RuntimeLogEvent 同 subsystem)。 +/// 上游输出是自由文本,不进封闭枚举事件流;整行标 private——上游默认日志 +/// 可能含服务器地址(ADR-0014),正文阅读继续走 agent.log 查看面。Console +/// 获得时间戳、category、level 与行数关联;stderr/agent.log 通路没有这些 +/// 可过滤维度。 +struct SslocalUnifiedLogDestination: SslocalLineDestination { + private let logger = Logger(subsystem: RuntimeLog.subsystem, category: "sslocal") + + func receive(_ line: String) { + logger.log(level: Self.level(for: line), "\(line, privacy: .private)") + } + + /// level 只按空白分隔 token 探测上游级别名(tracing/env_logger 文本格式 + /// 的 ERROR/WARN/WARNING),探测不到一律 `.info`(stream 可见、不落盘); + /// agent.log 才是持久面,持久 store 只收探测到的警告与错误。统一日志只有 + /// 五级、没有 warning:WARN/WARNING 落 `.default`(持久、与 error 可区分, + /// 级别名仍在行文本里)。上游改格式时优雅退化为全 `.info`,不丢行。 + static func level(for line: String) -> OSLogType { + for token in line.split(whereSeparator: \.isWhitespace) { + switch token { + case "ERROR": return .error + case "WARN", "WARNING": return .default + default: continue + } + } + return .info + } +} + +/// sslocal 输出 intake:chunk-safe 行分帧 + 密码脱敏 + 超长行丢弃,把脱敏后的 +/// 行送到去向缝。FileHandle 回调通过锁串行访问缓冲区,避免分块输入绕过隐藏 +/// 规则。`write(Data())` 表示输入结束,冲刷尾部半行。 +/// +/// 这是上游自由文本进入日志去向的唯一入口:脱敏规则只在这一处,不再随去向 +/// 复制;上游认证失败日志包含客户端提交的密码,按完整行隐藏密码后再分发。 +final class SslocalOutputIntake: @unchecked Sendable { + private let lock = NSLock() + private var buffer = Data() + private var discardingOversizedLine = false + private let destinations: [any SslocalLineDestination] + + init(destinations: [any SslocalLineDestination]) { + self.destinations = destinations + } + + func write(_ data: Data) { + lock.lock() + defer { lock.unlock() } + if discardingOversizedLine { + guard let newline = data.firstIndex(of: 10) else { return } + discardingOversizedLine = false + buffer.append(data.suffix(from: data.index(after: newline))) + } else { + buffer.append(data) + } + while let newline = buffer.firstIndex(of: 10) { + emitLine(String(data: buffer.prefix(upTo: newline), encoding: .utf8)) + buffer.removeSubrange(...newline) + } + if data.isEmpty && !buffer.isEmpty { + emitLine(String(data: buffer, encoding: .utf8)) + buffer.removeAll() + } else if buffer.count > 65_536 { + buffer.removeAll() + discardingOversizedLine = true + emitLine("sslocal: log line truncated") + } + } + + private func emitLine(_ text: String?) { + guard let text else { return } + let sanitized = text.replacingOccurrences( + of: "password: [^\\r\\n]*", + with: "password: [redacted]", options: .regularExpression) + for destination in destinations { + destination.receive(sanitized) + } + } +} diff --git a/ShadowsocksX-NG2/Tests/Runtime/SslocalOutputIntakeTests.swift b/ShadowsocksX-NG2/Tests/Runtime/SslocalOutputIntakeTests.swift new file mode 100644 index 00000000..77f7d613 --- /dev/null +++ b/ShadowsocksX-NG2/Tests/Runtime/SslocalOutputIntakeTests.swift @@ -0,0 +1,96 @@ +import Foundation +import Testing +import os + +@testable import ShadowsocksX_NG2 + +/// 记录 intake 分发行的替身去向。 +private final class RecordingDestination: SslocalLineDestination, @unchecked Sendable { + private let lock = NSLock() + private var lines: [String] = [] + + func receive(_ line: String) { + lock.lock() + defer { lock.unlock() } + lines.append(line) + } + + var received: [String] { + lock.lock() + defer { lock.unlock() } + return lines + } +} + +struct SslocalOutputIntakeTests { + @Test func framesLinesAcrossChunkBoundaries() { + let destination = RecordingDestination() + let intake = SslocalOutputIntake(destinations: [destination]) + intake.write(Data("hel".utf8)) + intake.write(Data("lo wo".utf8)) + intake.write(Data("rld\nsecond\n".utf8)) + #expect(destination.received == ["hello world", "second"]) + } + + @Test func hidesPasswordBeforeDeliveringToEveryDestination() { + let first = RecordingDestination() + let second = RecordingDestination() + let intake = SslocalOutputIntake(destinations: [first, second]) + intake.write(Data("authentication rejected, password: secret-one\n".utf8)) + intake.write(Data("password: sec".utf8)) + intake.write(Data("ret-two\n".utf8)) + let expected = [ + "authentication rejected, password: [redacted]", + "password: [redacted]", + ] + #expect(first.received == expected) + #expect(second.received == expected) + } + + @Test func discardsOversizedLineAndKeepsFollowingLines() { + let destination = RecordingDestination() + let intake = SslocalOutputIntake(destinations: [destination]) + var oversized = Data("password: ".utf8) + oversized.append(Data(String(repeating: "x", count: 70_000).utf8)) + intake.write(oversized) + intake.write(Data("secret-tail\nordinary event\n".utf8)) + #expect(destination.received == ["sslocal: log line truncated", "ordinary event"]) + } + + @Test func flushesTrailingPartialLineAtEndOfStream() { + let destination = RecordingDestination() + let intake = SslocalOutputIntake(destinations: [destination]) + intake.write(Data("no trailing newline".utf8)) + intake.write(Data()) + #expect(destination.received == ["no trailing newline"]) + intake.write(Data()) + #expect(destination.received == ["no trailing newline"]) + } + + @Test func stderrDestinationAppendsNewlineToEachLine() throws { + let pipe = Pipe() + let destination = SslocalStandardErrorDestination(handle: pipe.fileHandleForWriting) + destination.receive("hello") + destination.receive("") + try pipe.fileHandleForWriting.close() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + #expect(data == Data("hello\n\n".utf8)) + } + + @Test func mapsUpstreamLevelTokensToLogLevels() { + #expect( + SslocalUnifiedLogDestination.level(for: "2026-10-20T03:04:12Z ERROR sslocal] boom") == .error) + // 统一日志没有 warning 级:WARN/WARNING 落持久的 .default,与 error 可区分。 + #expect( + SslocalUnifiedLogDestination.level(for: "[2026-10-20T03:04:12Z WARN sslocal] retrying") + == .default) + #expect(SslocalUnifiedLogDestination.level(for: "WARNING token alone") == .default) + #expect(SslocalUnifiedLogDestination.level(for: "connected to server") == .info) + #expect(SslocalUnifiedLogDestination.level(for: "an error occurred") == .info) + #expect(SslocalUnifiedLogDestination.level(for: "password: [redacted]") == .info) + // CRLF 行尾不得吞掉级别 token(\r 也是空白)。 + #expect(SslocalUnifiedLogDestination.level(for: "2026-10-20T03:04:12Z ERROR boom\r") == .error) + #expect( + SslocalUnifiedLogDestination.level(for: "2026-10-20T03:04:12Z WARN retry\r") == .default) + } +} diff --git a/ShadowsocksX-NG2/project.yml b/ShadowsocksX-NG2/project.yml index 0901dbd9..d49194e2 100644 --- a/ShadowsocksX-NG2/project.yml +++ b/ShadowsocksX-NG2/project.yml @@ -208,13 +208,14 @@ targets: platform: macOS sources: - path: Agent - # Agent 只共享非秘密监管协议、路径和探针,不编译 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/SslocalOutputIntake.swift - path: Domain/Runtime/EndpointHealthProbe.swift - path: Domain/Runtime/RuntimeSocketOwnershipProbe.swift settings: diff --git a/docs/project-structure.md b/docs/project-structure.md index 780afd15..f7bfd1b6 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -22,14 +22,14 @@ - `Catalog/` — 配置目录树、结构不变量错误、目录文件存储、节点与凭据身份,以及 `ss://` 编解码 `SsUri`。 - `Subscription/` — 订阅文档解析、抓取、订阅记录与刷新失败。 - `Rules/` — 代理模式、规则身份与匹配、内置规则快照、规则集合/浏览/分析、离线规则测试;`Rules/Custom/` 为用户自定义规则的编辑草稿、校验与存储。 - - `Runtime/` — 代理运行服务:运行时文档与监听设置、ACL 与部署收据、路径、原子写入、文件存储、日志与事件、端点/端口/socket 探针。 + - `Runtime/` — 代理运行服务:运行时文档与监听设置、ACL 与部署收据、路径、原子写入、文件存储、日志与事件(含 sslocal 输出 intake)、端点/端口/socket 探针。 - `Activation/` — 激活候选校验、激活状态机与 `RuntimeConfiguration`。 - `Settings/` — 代理监听配置文件、独立代理控制偏好、运行计算的内存快照与静默启动偏好。 - `SystemProxy/` — 系统代理 typed 配置、property list 映射、计划器与特权 helper 的 XPC 契约/请求引擎。 - `Plugin/` — 托管插件、用户插件映射与插件安全校验。 - `Credentials/`、`LegacyImport/`、`Diagnostics/` — 主密钥存储与加密文件提交、旧版导入、诊断报告。 - 跨进程契约按**文件**编入其它 target:`Runtime/` 的 6 个文件和 `Settings/ApplicationSupportPaths.swift` 同时编入 wrapper(`RuntimePaths`、`RuntimeSupervision`、`RuntimeLog` 等),`SystemProxy/` 全部 6 个文件编入特权 helper。`project.yml` 里这 13 条是显式路径,在 `Domain/` 内移动文件必须同步,漏改会让 `xcodegen generate` 以缺文件直接失败。布局守卫(`Tests/Architecture/SkeletonTests.swift` 的 `DomainLayoutTests`)断言根下无平铺 `.swift`、树下只有 `.swift`(非源码文件会被 XcodeGen 收进 Resources 阶段并封进签名后的 bundle)。 + 跨进程契约按**文件**编入其它 target:`Runtime/` 的 7 个文件和 `Settings/ApplicationSupportPaths.swift` 同时编入 wrapper(`RuntimePaths`、`RuntimeSupervision`、`RuntimeLog`、`SslocalOutputIntake` 等),`SystemProxy/` 全部 6 个文件编入特权 helper。`project.yml` 里这 14 条是显式路径,在 `Domain/` 内移动文件必须同步,漏改会让 `xcodegen generate` 以缺文件直接失败。布局守卫(`Tests/Architecture/SkeletonTests.swift` 的 `DomainLayoutTests`)断言根下无平铺 `.swift`、树下只有 `.swift`(非源码文件会被 XcodeGen 收进 Resources 阶段并封进签名后的 bundle)。 - `Tests/` — 单元测试 target,随 `ShadowsocksX-NG2` scheme 运行。目录即主题,根下不留平铺测试文件: - `Architecture/` — 跨切面工程约束守卫(分层与布局、平台效应 seam、开窗落点、清单完整性),扫源码或构建产物而不驱动行为。 - `TestSupport/` — 测试等待与清理 helper 的故障回归用例。