diff --git a/infrastructure/src/bin/bat_official_sync.rs b/infrastructure/src/bin/bat_official_sync.rs index ea5b538..bb6ec0c 100644 --- a/infrastructure/src/bin/bat_official_sync.rs +++ b/infrastructure/src/bin/bat_official_sync.rs @@ -32,6 +32,12 @@ const DAEMON_LOG_FILE: &str = "bat-daemon.log"; const DAEMON_STRUCTURED_LOG_FILE: &str = "bat-events.jsonl"; const DAEMON_SOCKET_FILE: &str = "bat.sock"; const DAEMON_CONTROL_LOCK_FILE: &str = "bat-control.lock"; +/// 后台进程保存代理凭据的专用文件,仅供 restart/reload 复用,永不序列化到状态输出。 +const DAEMON_PROXY_SECRET_FILE: &str = "bat-proxy.secret"; +/// 代理凭据下传子进程使用的环境变量;避免凭据出现在子进程 argv(/proc//cmdline)。 +const PROXY_URL_ENV_VAR: &str = "BAT_OFFICIAL_SYNC_PROXY_URL"; +/// 后台子进程 argv 中用于标记“代理 URL 从环境变量读取”的内部 flag。 +const PROXY_FROM_ENV_FLAG: &str = "--proxy-from-env"; const DAEMON_STATUS_VERSION: u32 = 1; const STRUCTURED_LOG_MAX_BYTES: u64 = 5 * 1024 * 1024; const STRUCTURED_LOG_ROTATE_KEEP: usize = 3; @@ -1350,13 +1356,21 @@ fn start_daemon_with_options(options: &CliOptions) -> anyhow::Result, + proxy_url: Option, message: &'static str, ) -> anyhow::Result { validate_runtime_state_dir(&state_dir).map_err(anyhow::Error::msg)?; @@ -1380,6 +1394,13 @@ fn start_daemon_with_args( PidLockState::Missing => {} } + // 代理凭据经环境变量下传子进程(/proc//environ 仅属主可读), + // 并写入专用 0600 文件供后续 restart/reload 复用;无代理时清除残留凭据。 + match proxy_url.as_deref() { + Some(url) => write_daemon_proxy_secret(&state_dir, url)?, + None => clear_daemon_proxy_secret(&state_dir), + } + let executable = env::current_exe()?; let log = open_append_file(&log_path, PRIVATE_FILE_MODE, "后台日志").map_err(anyhow::Error::msg)?; @@ -1390,8 +1411,23 @@ fn start_daemon_with_args( .stdin(Stdio::null()) .stdout(Stdio::from(log_for_stdout)) .stderr(Stdio::from(log)); + match proxy_url.as_deref() { + Some(url) => { + command.env(PROXY_URL_ENV_VAR, url); + } + None => { + command.env_remove(PROXY_URL_ENV_VAR); + } + } configure_daemon_command(&mut command); - let child = command.spawn()?; + let child = match command.spawn() { + Ok(child) => child, + Err(error) => { + // 子进程未能启动,清除刚写入的代理凭据,避免残留。 + clear_daemon_proxy_secret(&state_dir); + return Err(error.into()); + } + }; let pid = child.id(); if let Err(error) = write_file_atomic( @@ -1403,6 +1439,7 @@ fn start_daemon_with_args( .map_err(anyhow::Error::msg) { let _ = terminate_process(pid); + clear_daemon_proxy_secret(&state_dir); return Err(error); } let mut command = Vec::with_capacity(args.len() + 1); @@ -1717,6 +1754,8 @@ fn run_daemon_restart(options: &CliOptions, command_name: &'static str) -> anyho } let status_file = read_daemon_status_file(&daemon_status_path(&options.state_dir))?; + // 在停止旧后台前读取已保存的代理凭据,供复用型重启还原。 + let saved_proxy_url = read_daemon_proxy_secret(&options.state_dir)?; let status_report = build_daemon_status_report(&options.state_dir)?; let previous_pid = status_report.pid.filter(|pid| process_exists(*pid)); @@ -1736,7 +1775,7 @@ fn run_daemon_restart(options: &CliOptions, command_name: &'static str) -> anyho let _ = stop_daemon_inner(&options.state_dir)?; } - let (state_dir, resource_output_root, args, strategy) = if has_explicit_options { + let (state_dir, resource_output_root, args, proxy_url, strategy) = if has_explicit_options { let mut start_options = options.clone(); start_options.daemon = true; start_options.watch = false; @@ -1744,6 +1783,7 @@ fn run_daemon_restart(options: &CliOptions, command_name: &'static str) -> anyho options.state_dir.clone(), normalized_abs_path(&options.config.output_root)?, daemon_child_args(&start_options), + curl_proxy_url(&options.config.curl_proxy), "start_with_explicit_options", ) } else { @@ -1762,10 +1802,21 @@ fn run_daemon_restart(options: &CliOptions, command_name: &'static str) -> anyho "后台状态文件中的 command 参数为空,无法执行 {command_name}" )); } + // 复用命令若声明代理从环境变量读取,则必须能从凭据文件还原 URL。 + let proxy_url = if args.iter().any(|arg| arg == PROXY_FROM_ENV_FLAG) { + Some(saved_proxy_url.ok_or_else(|| { + anyhow::anyhow!( + "后台配置需要代理凭据但 {DAEMON_PROXY_SECRET_FILE} 缺失;请为 {command_name} 重新传入 --proxy" + ) + })?) + } else { + None + }; ( options.state_dir.clone(), status_file.resource_output_root, args, + proxy_url, "restart_with_existing_command", ) }; @@ -1774,6 +1825,7 @@ fn run_daemon_restart(options: &CliOptions, command_name: &'static str) -> anyho state_dir.clone(), resource_output_root.clone(), args, + proxy_url, if command_name == "reload" { "后台配置已重新加载" } else { @@ -2920,6 +2972,13 @@ fn run_clean_stable_command(options: &CliOptions) -> anyhow::Result<()> { } } + // 后台已停止,清除残留的代理凭据文件,不把凭据留在磁盘上。 + let proxy_secret_path = daemon_proxy_secret_path(&options.state_dir); + if proxy_secret_path.exists() { + fs::remove_file(&proxy_secret_path)?; + removed_paths.push(proxy_secret_path); + } + let report = CleanStableReport { command: "clean-stable", status: if skipped_paths.is_empty() { @@ -3119,6 +3178,51 @@ fn daemon_control_lock_path(output_root: &Path) -> PathBuf { output_root.join(DAEMON_CONTROL_LOCK_FILE) } +fn daemon_proxy_secret_path(state_dir: &Path) -> PathBuf { + state_dir.join(DAEMON_PROXY_SECRET_FILE) +} + +/// 返回 URL 型代理的凭据字符串;auto/disabled 模式返回 None。 +fn curl_proxy_url(config: &CurlProxyConfig) -> Option { + match config.mode() { + CurlProxyMode::Url(url) => Some(url.clone()), + CurlProxyMode::Auto | CurlProxyMode::Disabled => None, + } +} + +/// 将代理凭据写入专用 0600 文件,供 restart/reload 复用;该文件永不进入状态输出。 +fn write_daemon_proxy_secret(state_dir: &Path, url: &str) -> anyhow::Result<()> { + write_file_atomic( + &daemon_proxy_secret_path(state_dir), + url.as_bytes(), + PRIVATE_FILE_MODE, + "代理凭据文件", + ) + .map_err(anyhow::Error::msg) +} + +/// 删除代理凭据文件(best-effort);无代理或拆除后台时调用。 +fn clear_daemon_proxy_secret(state_dir: &Path) { + let _ = fs::remove_file(daemon_proxy_secret_path(state_dir)); +} + +/// 读取代理凭据文件;缺失或为空返回 None。 +fn read_daemon_proxy_secret(state_dir: &Path) -> anyhow::Result> { + let Some(bytes) = read_file_no_symlink(&daemon_proxy_secret_path(state_dir), "代理凭据文件") + .map_err(anyhow::Error::msg)? + else { + return Ok(None); + }; + let url = + String::from_utf8(bytes).map_err(|_| anyhow::anyhow!("代理凭据文件不是有效 UTF-8"))?; + let trimmed = url.trim(); + if trimmed.is_empty() { + Ok(None) + } else { + Ok(Some(trimmed.to_string())) + } +} + fn active_official_resource_root(output_root: &Path) -> anyhow::Result { let current_path = output_root.join(OFFICIAL_CURRENT_LINK); let metadata = match fs::symlink_metadata(¤t_path) { @@ -3205,9 +3309,10 @@ fn daemon_child_args(options: &CliOptions) -> Vec { CurlProxyMode::Disabled => { args.push("--no-proxy".to_string()); } - CurlProxyMode::Url(url) => { - args.push("--proxy".to_string()); - args.push(url.clone()); + CurlProxyMode::Url(_) => { + // 凭据经 PROXY_URL_ENV_VAR 环境变量下传子进程;此处只放不含凭据的 flag, + // 避免代理 URL 进入子进程 argv(/proc//cmdline)与状态文件 command 字段。 + args.push(PROXY_FROM_ENV_FLAG.to_string()); } } args.push("--unzip".to_string()); @@ -3769,6 +3874,16 @@ fn parse_args_from(raw_args: impl IntoIterator) -> anyhow::Result options.config.curl_proxy = CurlProxyConfig::disabled(); options.proxy_option_explicit = true; } + "--proxy-from-env" => { + // 后台子进程内部 flag:代理凭据从环境变量读取,随后立即清除, + // 避免被 curl 等孙进程继承。 + let url = env::var(PROXY_URL_ENV_VAR).map_err(|_| { + anyhow::anyhow!("{PROXY_FROM_ENV_FLAG} 需要设置 {PROXY_URL_ENV_VAR} 环境变量") + })?; + env::remove_var(PROXY_URL_ENV_VAR); + options.config.curl_proxy = CurlProxyConfig::url(url); + options.proxy_option_explicit = true; + } "--unzip" => { options.config.unzip_command = PathBuf::from(next_option_value(&mut args, &flag)?); } @@ -4408,9 +4523,10 @@ mod tests { assert!(args .windows(2) .any(|pair| pair == ["--platforms", "Windows,Android"])); - assert!(args - .windows(2) - .any(|pair| pair == ["--proxy", "http://127.0.0.1:7890"])); + // 代理凭据不得进入子进程 argv:只放不含凭据的 flag,URL 经环境变量下传。 + assert!(args.contains(&PROXY_FROM_ENV_FLAG.to_string())); + assert!(!args.iter().any(|arg| arg.contains("127.0.0.1:7890"))); + assert!(!args.windows(2).any(|pair| pair[0] == "--proxy")); assert!(args.windows(2).any(|pair| pair == ["--interval", "30m"])); assert!(args.windows(2).any(|pair| pair == ["--error-retry", "45s"])); assert!(args.contains(&"--quiet-up-to-date".to_string())); @@ -4418,6 +4534,73 @@ mod tests { assert!(args.contains(&"--no-banner".to_string())); } + #[test] + fn curl_proxy_url_extracts_only_url_mode() { + assert_eq!( + curl_proxy_url(&CurlProxyConfig::url("http://user:secret@127.0.0.1:7890")), + Some("http://user:secret@127.0.0.1:7890".to_string()) + ); + assert_eq!(curl_proxy_url(&CurlProxyConfig::auto()), None); + assert_eq!(curl_proxy_url(&CurlProxyConfig::disabled()), None); + } + + #[test] + fn daemon_proxy_secret_round_trip_and_clear() { + let dir = tempfile::TempDir::new().unwrap(); + let state_dir = dir.path(); + + assert_eq!(read_daemon_proxy_secret(state_dir).unwrap(), None); + + write_daemon_proxy_secret(state_dir, "http://user:secret@127.0.0.1:7890").unwrap(); + assert_eq!( + read_daemon_proxy_secret(state_dir).unwrap(), + Some("http://user:secret@127.0.0.1:7890".to_string()) + ); + // 凭据文件以 0600 创建。 + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = fs::metadata(daemon_proxy_secret_path(state_dir)) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + } + + clear_daemon_proxy_secret(state_dir); + assert_eq!(read_daemon_proxy_secret(state_dir).unwrap(), None); + } + + // 序列化会修改进程级 PROXY_URL_ENV_VAR 的测试,避免并行测试相互干扰。 + static PROXY_ENV_TEST_LOCK: Mutex<()> = Mutex::new(()); + + #[test] + fn proxy_from_env_flag_reads_and_consumes_env_var() { + let _guard = PROXY_ENV_TEST_LOCK + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + env::set_var(PROXY_URL_ENV_VAR, "http://user:secret@127.0.0.1:7890"); + let options = parse(&["bat", "--watch", "--proxy-from-env"]).unwrap(); + assert!(options.proxy_option_explicit); + assert_eq!( + options.config.curl_proxy.mode(), + &CurlProxyMode::Url("http://user:secret@127.0.0.1:7890".to_string()) + ); + // 读取后应从环境中清除,避免被 curl 等孙进程继承。 + assert!(env::var(PROXY_URL_ENV_VAR).is_err()); + } + + #[test] + fn proxy_from_env_flag_errors_when_env_var_missing() { + let _guard = PROXY_ENV_TEST_LOCK + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + env::remove_var(PROXY_URL_ENV_VAR); + let error = parse(&["bat", "--watch", "--proxy-from-env"]).unwrap_err(); + assert!(error.to_string().contains(PROXY_URL_ENV_VAR)); + } + #[test] fn parses_explicit_watch_error_retry_interval() { let options =