Files
ShadowsocksX-NG/ShadowsocksX-NG2/Domain/RuntimeLog.swift
T
Qiu Yuzhou 8d1cdf27d1 feat(ng2): Legacy 交接第二阶段(#37)
与导入分离的「切换到 2.0」确认流程(spec #21 D12):

- Domain/LegacyHandoff:硬编码白名单三个 Legacy label(.local/.http/.kcptun),
  bootout 按 service target(覆盖 plist 缺失错位形态),print 退出码验证
  (0/113),`launchctl kill SIGTERM` 有文档后备,绝不泛匹配、不 pkill;
  残留 plist(含 KeepAlive 形态)读内容判活跃性后 disable,阻止下次登录复活;
  系统代理按特征值判定归属(Legacy 自身 off 模式的判定镜像:本地 PAC URL
  http://localhost:<port>/proxy.pac、SOCKS/HTTP 地址端口),仅清理归属为
  Legacy 的服务,未知所有者点名保留;端口释放门禁用无复用选项的 bind 探测
  (Legacy ss-local 带 --reuse-port),未释放不启动 2.0;Legacy 数据零删除。
- App 层:SystemConfigurationLegacyProxyCleaner(授权在最前、失败零影响、
  事务回滚);WorkspaceLegacyAppController(共享 bundle id 需排除自身进程);
  LegacyHandoffSheet 确认/进度/报告呈现,含旧登录项人工指引与残留提示;
  主窗口工具栏入口 + 导入完成后「切换到 2.0」入口;交接成功经正常启停入口
  启动 2.0(健康门禁走 #29 路径)。
- bootout 实弹验证(Further Notes #5)闭环:docs/research/
  legacy-handoff-bootout-live-fire.md——bootout 未加载 label 退出码为 3
  (非 print 的 113),KeepAlive 残留 kill 后复活、bootout 后不再加载,
  结论已回写产品代码与测试。
2026-09-21 12:49:49 +08:00

185 lines
7.9 KiB
Swift

import Foundation
/// 运行时日志基线(spec #21 D5):事件是封闭枚举,脱敏由构造保证——永不
/// 携带密码、插件参数、Keychain 值、完整订阅 URL、URL token 或运行时 JSON
/// 内容;服务器地址与备注默认不进普通日志(端点探测失败点名的 host:port
/// 是本机监听端点,属 D8 要求呈现的事实字段,不受此限)。日志查看与诊断
/// 导出由 #34 在本事件流上接出。
enum RuntimeLogEvent: CustomStringConvertible, Sendable {
/// 契约文件已原子写入(只携带数量元数据,非内容)。
case contractWritten(serverCount: Int)
/// 契约内容与磁盘一致,跳过写入(幂等)。
case contractUnchanged
/// 运行时文件已清理。
case runtimeFilesDeleted
/// 激活状态等运行时元数据落盘失败(不含内容)。
case runtimePersistFailed(detail: String)
case agentRegistered
case agentRegisterFailed(detail: String)
case agentUnregistered
case agentUnregisterFailed(detail: String)
/// wrapper 侧 sslocal 监管事件。
case sslocalSpawned(pid: Int32)
case sslocalSpawnFailed
case sslocalExitedUnexpectedly(status: Int32)
case sslocalStopRequested
/// sslocal 拉起后限时内未完成本地监听绑定(D10「3 秒未建立监听 → error
/// 日志」;监管继续,GUI 健康门负责呈现,端点为本机监听端点不受脱敏限制)。
case listenNotEstablished(detail: String)
case pacStarted(port: Int)
case pacStartFailed(port: Int, detail: String)
case pacStopped
case contractMissing
case contractInvalidRemoved
case reloadForwarded
case reloadRestarted
/// 本机代理端点启动后未就绪(点名端点与端口,D8)。
case endpointProbeFailed(host: String, port: Int, detail: String)
/// 激活失败或活动目标清除(原因枚举已点名且不含秘密)。
case activationFailed(reason: String)
/// Legacy 交接(issue #37):label 是产品硬编码白名单常量,端口为本机端点,
/// 均非敏感信息。
case legacyHandoffStarted
case legacyHandoffLabelRemoved(label: String)
case legacyHandoffLabelDisabled(label: String)
case legacyHandoffProxyCleaned(serviceCount: Int)
case legacyHandoffPortsConfirmed(portList: String)
case legacyHandoffPortsNotReleased(detail: String)
case legacyHandoffFailed(reason: String)
case legacyHandoffCompleted
/// 用户显式触发的脱敏诊断导出已完成(不携带导出路径)。
case diagnosticsExported
/// 监听设置持久化读取失败,已回落出厂默认(D8:不静默改端口——事件进
/// 诊断日志供 #34 查看器呈现,完整呈现面在 #33)。
case listenSettingsUnreadable(detail: String)
var description: String {
switch self {
case .contractWritten(let serverCount):
return "contract written (servers=\(serverCount))"
case .contractUnchanged:
return "contract unchanged, write skipped"
case .runtimeFilesDeleted:
return "runtime files deleted"
case .runtimePersistFailed(let detail):
return "runtime metadata persist failed: \(detail)"
case .agentRegistered:
return "launch agent registered"
case .agentRegisterFailed(let detail):
return "launch agent register failed: \(detail)"
case .agentUnregistered:
return "launch agent unregistered"
case .agentUnregisterFailed(let detail):
return "launch agent unregister failed: \(detail)"
case .sslocalSpawned(let pid):
return "sslocal spawned (pid=\(pid))"
case .sslocalSpawnFailed:
return "sslocal spawn failed"
case .sslocalExitedUnexpectedly(let status):
return "sslocal exited unexpectedly (status=\(status))"
case .sslocalStopRequested:
return "sslocal stop requested"
case .listenNotEstablished(let detail):
return "listen not established within deadline: \(detail)"
case .pacStarted(let port):
return "PAC endpoint started (port=\(port))"
case .pacStartFailed(let port, let detail):
return "PAC endpoint failed (port=\(port)): \(detail)"
case .pacStopped:
return "PAC endpoint stopped"
case .contractMissing:
return "contract missing"
case .contractInvalidRemoved:
return "contract invalid, removed"
case .reloadForwarded:
return "reload forwarded to sslocal"
case .reloadRestarted:
return "reload: listen change, restarting sslocal"
case .endpointProbeFailed(let host, let port, let detail):
return "endpoint \(host):\(port) not ready: \(detail)"
case .activationFailed(let reason):
return "activation failed: \(reason)"
case .legacyHandoffStarted:
return "legacy handoff started"
case .legacyHandoffLabelRemoved(let label):
return "legacy handoff removed launchd job: \(label)"
case .legacyHandoffLabelDisabled(let label):
return "legacy handoff disabled launchd label: \(label)"
case .legacyHandoffProxyCleaned(let serviceCount):
return "legacy handoff cleaned legacy-owned system proxy (services=\(serviceCount))"
case .legacyHandoffPortsConfirmed(let portList):
return "legacy handoff confirmed ports free: \(portList)"
case .legacyHandoffPortsNotReleased(let detail):
return "legacy handoff ports not released: \(detail)"
case .legacyHandoffFailed(let reason):
return "legacy handoff failed: \(reason)"
case .legacyHandoffCompleted:
return "legacy handoff completed"
case .diagnosticsExported:
return "diagnostics report exported (redacted)"
case .listenSettingsUnreadable(let detail):
return "listen settings unreadable, falling back to factory defaults: \(detail)"
}
}
}
/// 事件接收缝(issue #34):GUI 进程把渲染后的事件文本接入 RuntimeEventStore
/// (内存环形缓冲),供主窗口日志查看器实时呈现与诊断导出取用;wrapper 进程
/// 不注册,仅走 stderr → agent.log 收敛。
protocol RuntimeEventSink: Sendable {
func append(text: String, timestamp: Date)
}
/// 敏感信息脱敏工具(D5「敏感信息」定义的读取面)。
enum Redactor {
/// 非空远程 URL 只保留 scheme+host:路径、查询与片段全部略去,完整订阅
/// URL 与 URL token 永不入日志。解析失败一律归并为占位符。
static func remoteURL(_ url: String) -> String {
guard let parsed = URL(string: url), let host = parsed.host(), !host.isEmpty else {
return "<redacted-url>"
}
return "\(parsed.scheme ?? "https")://\(host)/…"
}
/// 运行时文档只允许暴露数量与模式元数据。
static func documentSummary(_ document: SslocalRuntimeDocument) -> String {
let protocols = document.locals.map(\.inboundProtocol).joined(separator: ",")
return "servers=\(document.servers.count) protocols=\(protocols) mode=\(document.socksMode)"
}
}
/// 事件发射缝:wrapper 与 GUI 共用 stderr 行式输出(wrapper 侧由 launchd/
/// agent.log 收敛,GUI 侧进系统日志);GUI 启动时另注册接收缝接入事件缓冲。
enum RuntimeLog {
private static let sinkHolder = SinkHolder()
/// 注册/替换事件接收缝;传 nil 恢复纯 stderr(测试隔离用)。
static func setSink(_ sink: (any RuntimeEventSink)?) {
sinkHolder.set(sink)
}
static func emit(_ event: RuntimeLogEvent) {
let line = "ssxng: \(event.description)\n"
FileHandle.standardError.write(Data(line.utf8))
sinkHolder.current?.append(text: event.description, timestamp: Date())
}
/// 可变静态的锁保护持有者:emit 可能来自任意线程。
private final class SinkHolder: @unchecked Sendable {
private let lock = NSLock()
private var sink: (any RuntimeEventSink)?
var current: (any RuntimeEventSink)? {
lock.lock()
defer { lock.unlock() }
return sink
}
func set(_ value: (any RuntimeEventSink)?) {
lock.lock()
defer { lock.unlock() }
sink = value
}
}
}