mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-10-28 21:23:44 +00:00
feat(agent): route sslocal output through a dual-destination intake
Collect line framing, password redaction and oversized-line discard in one SslocalOutputIntake and dispatch redacted lines through a destination seam: stderr (freopen'd into agent.log, raw capture kept for ADR-0006 local viewing) and the unified log under category "sslocal", reusing the shared subsystem. Upstream free text is never routed through RuntimeLogEvent and is marked private in the unified log as a whole (ADR-0014 warns default upstream logs may contain server addresses). Levels derive from whitespace-separated upstream level tokens: ERROR maps to .error, WARN/WARNING maps to .default (unified logging has no warning level), and anything else maps to .info so the unified-log surface persists only detected problems while agent.log stays the persistent surface. The intake lives in Domain/Runtime so the hostless test mirror compiles it; unit tests drive raw chunks against fake destinations instead of parsing agent.log text. The 1 MiB agent.log retention policy deliberately stays on the file side shared with wrapper event lines.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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:新事件漏路由无法编译。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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 的故障回归用例。
|
||||
|
||||
Reference in New Issue
Block a user