fix(official-sync): 代理凭据改由环境变量下传子进程

彻底收敛 1-2 的两处残留凭据暴露:

- 子进程 argv:daemon_child_args 不再把 --proxy <url> 写入子进程参数,改为
  只放不含凭据的内部 flag --proxy-from-env,真实 URL 经 BAT_OFFICIAL_SYNC_PROXY_URL
  环境变量下传。凭据从世界可读的 /proc/<pid>/cmdline(0444)转移到仅属主可读的
  /proc/<pid>/environ(0400);子进程解析后立即 env::remove_var,避免被 curl 等
  孙进程继承。
- 状态文件 at-rest:0600 状态文件的 command 字段不再含凭据。restart/reload 复用
  所需的 URL 改存专用 0600 文件 bat-proxy.secret,该文件永不进入状态输出或 --json;
  clean-stable 会在后台停止后清除它。

复用型 restart/reload 从 bat-proxy.secret 还原代理;凭据文件缺失时(如 clean-stable
后)明确报错要求重新传入 --proxy,而非静默丢弃代理。

验证:新增 curl_proxy_url、secret 文件 0600 往返、--proxy-from-env 读取并清除
环境变量的单测;真机起停 daemon 确认凭据不在 cmdline/状态文件/status --json,
仅在 0600 secret 文件与 owner-only environ;复用 restart 成功还原、clean-stable
后复用 restart 明确报错。

对应 issue #18 维护清单 1-2 残留项。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 05:20:22 -07:00
co-authored by Claude Fable 5
parent 810b39c2cd
commit 55b9babb66
+192 -9
View File
@@ -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/<pid>/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<DaemonStart
let resource_output_root = normalized_abs_path(&options.config.output_root)?;
let state_dir = options.state_dir.clone();
let args = daemon_child_args(options);
start_daemon_with_args(state_dir, resource_output_root, args, "后台同步已启动")
let proxy_url = curl_proxy_url(&options.config.curl_proxy);
start_daemon_with_args(
state_dir,
resource_output_root,
args,
proxy_url,
"后台同步已启动",
)
}
fn start_daemon_with_args(
state_dir: PathBuf,
resource_output_root: PathBuf,
args: Vec<String>,
proxy_url: Option<String>,
message: &'static str,
) -> anyhow::Result<DaemonStartReport> {
validate_runtime_state_dir(&state_dir).map_err(anyhow::Error::msg)?;
@@ -1380,6 +1394,13 @@ fn start_daemon_with_args(
PidLockState::Missing => {}
}
// 代理凭据经环境变量下传子进程(/proc/<pid>/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<String> {
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<Option<String>> {
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<PathBuf> {
let current_path = output_root.join(OFFICIAL_CURRENT_LINK);
let metadata = match fs::symlink_metadata(&current_path) {
@@ -3205,9 +3309,10 @@ fn daemon_child_args(options: &CliOptions) -> Vec<String> {
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/<pid>/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<Item = String>) -> 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 =