mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-02 10:21:16 +08:00
feat(logging): persist diagnostics across restarts and redact secrets
Backend half of the logging overhaul. Retention: - Keep the last 4 rotated files at 20MB each and stop deleting logs on startup, so a crash's prior-run logs survive a restart. - Apply the persisted log level right after plugin registration instead of at the end of setup. - Bound crash.log with size-based rotation (5MB x 2). Secret redaction on every log path: - Proxy: strip userinfo/query from upstream URLs (keep path for diagnostics), exact-match redact known auth.api_key/access_token, classify request/response bodies instead of logging them, header allowlist. - Redact the Gemini `?key=` in cache-trace endpoints. - Omit MCP custom-field values (headers may carry tokens). - Redact deeplink and model-fetch URLs before logging. Docs: - FAQ points users to the persistent crash.log for support workflows.
This commit is contained in:
+267
-70
@@ -68,7 +68,7 @@ pub use store::AppState;
|
||||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::{fmt, sync::Arc};
|
||||
#[cfg(target_os = "macos")]
|
||||
use tauri::image::Image;
|
||||
use tauri::tray::{TrayIconBuilder, TrayIconEvent};
|
||||
@@ -92,35 +92,129 @@ fn set_windows_app_user_model_id(app: &tauri::AppHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
fn redact_url_for_log(url_str: &str) -> String {
|
||||
match url::Url::parse(url_str) {
|
||||
Ok(url) => {
|
||||
let mut output = format!("{}://", url.scheme());
|
||||
if let Some(host) = url.host_str() {
|
||||
output.push_str(host);
|
||||
}
|
||||
output.push_str(url.path());
|
||||
pub(crate) struct RedactedUrl<'a> {
|
||||
url: &'a str,
|
||||
known_secrets: &'a [String],
|
||||
}
|
||||
|
||||
let mut keys: Vec<String> = url.query_pairs().map(|(k, _)| k.to_string()).collect();
|
||||
keys.sort();
|
||||
keys.dedup();
|
||||
impl fmt::Display for RedactedUrl<'_> {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(&redact_url_for_log_with_secrets(
|
||||
self.url,
|
||||
self.known_secrets,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
if !keys.is_empty() {
|
||||
output.push_str("?[keys:");
|
||||
output.push_str(&keys.join(","));
|
||||
output.push(']');
|
||||
}
|
||||
/// 为日志提供惰性 URL 脱敏包装;只有日志实际输出时才解析和重建 URL。
|
||||
pub(crate) fn url_for_log(url: &str) -> RedactedUrl<'_> {
|
||||
RedactedUrl {
|
||||
url,
|
||||
known_secrets: &[],
|
||||
}
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
Err(_) => {
|
||||
let base = url_str.split('#').next().unwrap_or(url_str);
|
||||
match base.split_once('?') {
|
||||
Some((prefix, _)) => format!("{prefix}?[redacted]"),
|
||||
None => base.to_string(),
|
||||
}
|
||||
/// 为持有确切认证材料的调用方提供优先精确匹配、再启发式兜底的 URL 脱敏。
|
||||
pub(crate) fn url_for_log_with_secrets<'a>(
|
||||
url: &'a str,
|
||||
known_secrets: &'a [String],
|
||||
) -> RedactedUrl<'a> {
|
||||
RedactedUrl { url, known_secrets }
|
||||
}
|
||||
|
||||
/// 已知密钥参与子串脱敏的最短长度:过短的值(如 "api")当作子串会误伤无关文本,
|
||||
/// 所以只对足够长、几乎不可能是普通词的值做替换。
|
||||
const MIN_KNOWN_SECRET_LEN: usize = 8;
|
||||
|
||||
/// 唯一的密钥脱敏原语:把字符串里出现的、我们确切握有的密钥值替换为 [REDACTED]。
|
||||
/// 不做任何“看起来像密钥”的形状猜测——只隐藏已知值,天然收敛、不误伤正常路径。
|
||||
fn redact_known_secrets(text: &str, known_secrets: &[String]) -> String {
|
||||
let mut output = text.to_string();
|
||||
for secret in known_secrets {
|
||||
if secret.chars().count() >= MIN_KNOWN_SECRET_LEN {
|
||||
output = output.replace(secret.as_str(), "[REDACTED]");
|
||||
}
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
/// 无 scheme 的裸 authority 形态(如 `user:pass@host/path`)剥掉 userinfo:
|
||||
/// 仅当 `@` 出现在第一个 `/` 之前时才视为凭据。
|
||||
fn strip_bare_userinfo(input: &str) -> &str {
|
||||
let authority_end = input.find('/').unwrap_or(input.len());
|
||||
match input[..authority_end].rfind('@') {
|
||||
Some(at) => &input[at + 1..],
|
||||
None => input,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn redact_url_for_log(url_str: &str) -> String {
|
||||
redact_url_for_log_with_secrets(url_str, &[])
|
||||
}
|
||||
|
||||
/// 为日志脱敏 URL:剥掉 userinfo(user:pass@) 与整个 query/fragment,保留
|
||||
/// scheme/host/port/path 供诊断(如 base_url 配错路径导致 404),最后再抹掉已知密钥值。
|
||||
pub(crate) fn redact_url_for_log_with_secrets(url_str: &str, known_secrets: &[String]) -> String {
|
||||
let scheme_relative = url_str.starts_with("//");
|
||||
let parsed = if scheme_relative {
|
||||
url::Url::parse(&format!("https:{url_str}"))
|
||||
} else {
|
||||
url::Url::parse(url_str)
|
||||
};
|
||||
|
||||
let sanitized = match parsed {
|
||||
Ok(mut url) if url.has_host() => {
|
||||
let _ = url.set_username("");
|
||||
let _ = url.set_password(None);
|
||||
url.set_query(None);
|
||||
url.set_fragment(None);
|
||||
let rendered = url.as_str();
|
||||
if scheme_relative {
|
||||
rendered
|
||||
.strip_prefix("https:")
|
||||
.unwrap_or(rendered)
|
||||
.to_string()
|
||||
} else {
|
||||
rendered.to_string()
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// 解析失败(相对路径、含裸 userinfo 的非法 URL 等):丢掉 query/fragment,
|
||||
// 尽力剥掉 userinfo,其余原样保留。
|
||||
let without_tail = url_str.split(['?', '#']).next().unwrap_or(url_str);
|
||||
strip_bare_userinfo(without_tail).to_string()
|
||||
}
|
||||
};
|
||||
|
||||
redact_known_secrets(&sanitized, known_secrets)
|
||||
}
|
||||
|
||||
/// 只保留 `scheme://host:port`,丢掉 path/query/userinfo。用于我们手里没有任何
|
||||
/// 已知密钥可脱敏 path 的场景——凭据可能整个内嵌在 base_url 的 path 里,此时
|
||||
/// 记录 path 无法保证不泄漏,只能退回到 origin。
|
||||
pub(crate) fn redact_url_origin_for_log(url_str: &str) -> String {
|
||||
let scheme_relative = url_str.starts_with("//");
|
||||
let parsed = if scheme_relative {
|
||||
url::Url::parse(&format!("https:{url_str}"))
|
||||
} else {
|
||||
url::Url::parse(url_str)
|
||||
};
|
||||
|
||||
match parsed {
|
||||
Ok(url) if url.has_host() => {
|
||||
let authority = &url[url::Position::BeforeHost..url::Position::AfterPort];
|
||||
if scheme_relative {
|
||||
format!("//{authority}")
|
||||
} else {
|
||||
format!("{}://{authority}", url.scheme())
|
||||
}
|
||||
}
|
||||
_ => "[invalid target]".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime_log_level_allows(level: log::Level, max_level: log::LevelFilter) -> bool {
|
||||
max_level.to_level().is_some_and(|maximum| level <= maximum)
|
||||
}
|
||||
|
||||
/// 统一处理 ccswitch:// 深链接 URL
|
||||
@@ -138,9 +232,10 @@ fn handle_deeplink_url(
|
||||
return false;
|
||||
}
|
||||
|
||||
let redacted_url = redact_url_for_log(url_str);
|
||||
log::info!("✓ Deep link URL detected from {source}: {redacted_url}");
|
||||
log::debug!("Deep link URL (raw) from {source}: {url_str}");
|
||||
log::info!(
|
||||
"✓ Deep link URL detected from {source}: {}",
|
||||
url_for_log(url_str)
|
||||
);
|
||||
|
||||
match crate::deeplink::parse_deeplink_url(url_str) {
|
||||
Ok(request) => {
|
||||
@@ -236,7 +331,7 @@ pub fn run() {
|
||||
log::info!("=== Single Instance Callback Triggered ===");
|
||||
log::debug!("Args count: {}", args.len());
|
||||
for (i, arg) in args.iter().enumerate() {
|
||||
log::debug!(" arg[{i}]: {}", redact_url_for_log(arg));
|
||||
log::debug!(" arg[{i}]: {}", url_for_log(arg));
|
||||
}
|
||||
|
||||
if crate::lightweight::is_lightweight_mode() {
|
||||
@@ -321,21 +416,8 @@ pub fn run() {
|
||||
// 预先刷新 Store 覆盖配置,确保后续路径读取正确(日志/数据库等)
|
||||
app_store::refresh_app_config_dir_override(app.handle());
|
||||
panic_hook::init_app_config_dir(crate::config::get_app_config_dir());
|
||||
#[cfg(target_os = "windows")]
|
||||
set_windows_app_user_model_id(app.handle());
|
||||
|
||||
// 注册 Updater 插件(桌面端)
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
if let Err(e) = app
|
||||
.handle()
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
{
|
||||
// 若配置不完整(如缺少 pubkey),跳过 Updater 而不中断应用
|
||||
log::warn!("初始化 Updater 插件失败,已跳过:{e}");
|
||||
}
|
||||
}
|
||||
// 初始化日志(单文件输出到 <app_config_dir>/logs/cc-switch.log)
|
||||
// 初始化日志(输出到 <app_config_dir>/logs/cc-switch.log)
|
||||
{
|
||||
use tauri_plugin_log::{RotationStrategy, Target, TargetKind, TimezoneStrategy};
|
||||
|
||||
@@ -346,14 +428,16 @@ pub fn run() {
|
||||
eprintln!("创建日志目录失败: {e}");
|
||||
}
|
||||
|
||||
// 启动时删除旧日志文件,实现单文件覆盖效果
|
||||
let log_file_path = log_dir.join("cc-switch.log");
|
||||
let _ = std::fs::remove_file(&log_file_path);
|
||||
|
||||
app.handle().plugin(
|
||||
tauri_plugin_log::Builder::default()
|
||||
// 初始化为 Trace,允许后续通过 log::set_max_level() 动态调整级别
|
||||
// 底层保留 Trace 能力,便于加载用户配置后动态调高级别。
|
||||
// 插件注册后会立即把全局级别收紧到 Info,避免启动阶段全量 Trace。
|
||||
.level(log::LevelFilter::Trace)
|
||||
// plugin-log 的前端 command 会直达 logger,绕过 log 宏的全局
|
||||
// max_level;在分发层补一次过滤,确保动态总开关同样约束前端日志。
|
||||
.filter(|metadata| {
|
||||
runtime_log_level_allows(metadata.level(), log::max_level())
|
||||
})
|
||||
.targets([
|
||||
Target::new(TargetKind::Stdout),
|
||||
Target::new(TargetKind::Folder {
|
||||
@@ -361,15 +445,36 @@ pub fn run() {
|
||||
file_name: Some("cc-switch".into()),
|
||||
}),
|
||||
])
|
||||
// 单文件模式:启动时删除旧文件,达到大小时轮转
|
||||
// 注意:KeepSome(n) 内部会做 n-2 运算,n=1 会导致 usize 下溢
|
||||
// KeepSome(2) 是最小安全值,表示不保留轮转文件
|
||||
.rotation_strategy(RotationStrategy::KeepSome(2))
|
||||
// 单文件大小限制 1GB
|
||||
.max_file_size(1024 * 1024 * 1024)
|
||||
// KeepSome(4) 保留 4 个轮转归档,加上当前文件最多约 100 MiB。
|
||||
// 轮转仅按大小触发;跨重启继续追加,不再丢失上一次运行的日志。
|
||||
.rotation_strategy(RotationStrategy::KeepSome(4))
|
||||
.max_file_size(20 * 1024 * 1024)
|
||||
.timezone_strategy(TimezoneStrategy::UseLocal)
|
||||
.build(),
|
||||
)?;
|
||||
|
||||
// 用户配置存在数据库中,数据库尚未打开时使用保守的 Info 级别。
|
||||
log::set_max_level(log::LevelFilter::Info);
|
||||
log::info!("=== CC Switch v{} started ===", env!("CARGO_PKG_VERSION"));
|
||||
}
|
||||
|
||||
// 首次读取覆盖路径时 logger 尚未可用;此处重放一次,
|
||||
// 让 Store 损坏或路径无效等启动警告能够真正落盘。
|
||||
let _ = app_store::refresh_app_config_dir_override(app.handle());
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
set_windows_app_user_model_id(app.handle());
|
||||
|
||||
// 注册 Updater 插件(桌面端);放在 logger 之后,确保失败可诊断。
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
if let Err(e) = app
|
||||
.handle()
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
{
|
||||
// 若配置不完整(如缺少 pubkey),跳过 Updater 而不中断应用
|
||||
log::warn!("初始化 Updater 插件失败,已跳过:{e}");
|
||||
}
|
||||
}
|
||||
|
||||
// 注入 AppHandle 给 usage_events,让无 AppHandle 持有的写日志路径
|
||||
@@ -466,6 +571,23 @@ pub fn run() {
|
||||
}
|
||||
};
|
||||
|
||||
// 数据库可用后立即应用持久化日志级别,避免后续服务初始化
|
||||
// 继续使用启动阶段的 Info 回退。损坏配置显式 fail-closed 到 Info。
|
||||
match db.get_log_config() {
|
||||
Ok(log_config) => {
|
||||
log::set_max_level(log_config.to_level_filter());
|
||||
log::info!(
|
||||
"已加载日志配置: enabled={}, level={}",
|
||||
log_config.enabled,
|
||||
log_config.level
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
log::set_max_level(log::LevelFilter::Info);
|
||||
log::warn!("读取日志配置失败,已回退到 info: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有预加载的配置,执行迁移
|
||||
if let Some(config) = migration_config {
|
||||
log::info!("开始执行数据迁移...");
|
||||
@@ -897,7 +1019,7 @@ pub fn run() {
|
||||
|
||||
for (i, url) in urls.iter().enumerate() {
|
||||
let url_str = url.as_str();
|
||||
log::debug!(" URL[{i}]: {}", redact_url_for_log(url_str));
|
||||
log::debug!(" URL[{i}]: {}", url_for_log(url_str));
|
||||
|
||||
if handle_deeplink_url(&app_handle, url_str, true, "on_open_url") {
|
||||
break; // Process only first ccswitch:// URL
|
||||
@@ -965,19 +1087,6 @@ pub fn run() {
|
||||
// 将同一个实例注入到全局状态,避免重复创建导致的不一致
|
||||
app.manage(app_state);
|
||||
|
||||
// 从数据库加载日志配置并应用
|
||||
{
|
||||
let db = &app.state::<AppState>().db;
|
||||
if let Ok(log_config) = db.get_log_config() {
|
||||
log::set_max_level(log_config.to_level_filter());
|
||||
log::info!(
|
||||
"已加载日志配置: enabled={}, level={}",
|
||||
log_config.enabled,
|
||||
log_config.level
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化 SkillService
|
||||
let skill_service = SkillService::new();
|
||||
app.manage(commands::skill::SkillServiceState(Arc::new(skill_service)));
|
||||
@@ -1611,7 +1720,10 @@ pub fn run() {
|
||||
RunEvent::Opened { urls } => {
|
||||
if let Some(url) = urls.first() {
|
||||
let url_str = url.to_string();
|
||||
log::info!("RunEvent::Opened with URL: {url_str}");
|
||||
log::info!(
|
||||
"RunEvent::Opened with URL: {}",
|
||||
url_for_log(&url_str)
|
||||
);
|
||||
|
||||
if url_str.starts_with("ccswitch://") {
|
||||
if crate::lightweight::is_lightweight_mode() {
|
||||
@@ -2087,9 +2199,94 @@ pub fn restart_process(app_handle: &tauri::AppHandle) -> ! {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{classify_exit_request, enabled_proxy_apps_on_startup, ExitRequestAction};
|
||||
use super::{
|
||||
classify_exit_request, enabled_proxy_apps_on_startup, redact_url_for_log,
|
||||
redact_url_for_log_with_secrets, redact_url_origin_for_log, runtime_log_level_allows,
|
||||
ExitRequestAction,
|
||||
};
|
||||
use crate::database::Database;
|
||||
|
||||
#[test]
|
||||
fn log_url_redaction_strips_credentials_and_query_keeps_path() {
|
||||
// userinfo 与整个 query 剥离,path 保留用于诊断 base_url 配错。
|
||||
assert_eq!(
|
||||
redact_url_for_log(
|
||||
"https://user:secret@example.com:8443/v1/models?key=top-secret&alt=sse"
|
||||
),
|
||||
"https://example.com:8443/v1/models"
|
||||
);
|
||||
// scheme-relative 保持形态,userinfo 去掉。
|
||||
assert_eq!(
|
||||
redact_url_for_log("//user:sk-secret@gw.example.com/v1"),
|
||||
"//gw.example.com/v1"
|
||||
);
|
||||
// 无 scheme 的裸 userinfo。
|
||||
assert_eq!(
|
||||
redact_url_for_log("user:sk-secret@gw.example.com/v1"),
|
||||
"gw.example.com/v1"
|
||||
);
|
||||
// 无法解析为绝对 URL 时:丢 query,其余原样保留。
|
||||
assert_eq!(redact_url_for_log("not-a-url?token=secret"), "not-a-url");
|
||||
// 不再对 path 段做“看起来像密钥”的形状猜测,正常路径完整保留。
|
||||
assert_eq!(
|
||||
redact_url_for_log("https://host.example/v1/models/gemini-2.5-pro"),
|
||||
"https://host.example/v1/models/gemini-2.5-pro"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_url_redaction_replaces_known_secret_values() {
|
||||
// 精确匹配已知密钥值:无论它出现在 path 还是别处都被抹掉。
|
||||
let secrets = vec!["k-9f3a7c2b1e".to_string()];
|
||||
assert_eq!(
|
||||
redact_url_for_log_with_secrets("https://gw.example.com/k-9f3a7c2b1e/v1", &secrets),
|
||||
"https://gw.example.com/[REDACTED]/v1"
|
||||
);
|
||||
// 过短(<8)的已知值不参与子串脱敏,避免误伤 /v1/ 之类的正常路径。
|
||||
let short_secrets = vec!["api".to_string()];
|
||||
assert_eq!(
|
||||
redact_url_for_log_with_secrets("https://api.example.com/v1", &short_secrets),
|
||||
"https://api.example.com/v1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_url_origin_drops_path_for_credential_in_path() {
|
||||
// 没有已知密钥可脱敏时,凭据可能整个内嵌在 path,只记 origin。
|
||||
assert_eq!(
|
||||
redact_url_origin_for_log("https://gw.example.com/k-9f3a7c2b1e/v1"),
|
||||
"https://gw.example.com"
|
||||
);
|
||||
assert_eq!(
|
||||
redact_url_origin_for_log("https://user:pass@gw.example.com:8443/secret/v1"),
|
||||
"https://gw.example.com:8443"
|
||||
);
|
||||
assert_eq!(
|
||||
redact_url_origin_for_log("//gw.example.com/secret/v1"),
|
||||
"//gw.example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_log_filter_honors_dynamic_max_level() {
|
||||
assert!(!runtime_log_level_allows(
|
||||
log::Level::Error,
|
||||
log::LevelFilter::Off
|
||||
));
|
||||
assert!(runtime_log_level_allows(
|
||||
log::Level::Error,
|
||||
log::LevelFilter::Info
|
||||
));
|
||||
assert!(runtime_log_level_allows(
|
||||
log::Level::Info,
|
||||
log::LevelFilter::Info
|
||||
));
|
||||
assert!(!runtime_log_level_allows(
|
||||
log::Level::Debug,
|
||||
log::LevelFilter::Info
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_code_keeps_app_alive_in_tray() {
|
||||
assert_eq!(classify_exit_request(None), ExitRequestAction::StayInTray);
|
||||
|
||||
Reference in New Issue
Block a user