diff --git a/src/cli/init.rs b/src/cli/init.rs index 8f802b1..ece6af0 100644 --- a/src/cli/init.rs +++ b/src/cli/init.rs @@ -43,6 +43,13 @@ pub fn cmd_init(force: bool) -> Result<()> { println!("扫描加密密钥(需要 root 权限)..."); let entries = scanner::scan_keys(&db_dir)?; + // === 权限边界 === + // 扫描完成后立即 drop 到调用用户身份,后续文件写入都是用户属主。 + // 未来 daemon(由 `wx sessions` 以用户身份 fork)才能往 ~/.wx-cli/ + // 写 socket/log/pid。 + #[cfg(unix)] + drop_privileges_if_sudo()?; + // 确保父目录存在(如 ~/.wx-cli/),必须在任何写入之前 if let Some(parent) = config_path.parent() { std::fs::create_dir_all(parent) @@ -89,6 +96,88 @@ pub fn cmd_init(force: bool) -> Result<()> { Ok(()) } +/// 如果当前以 root 身份运行且是通过 sudo 启动的,drop 到调用用户身份, +/// 并迁移旧版本遗留的 root 属主 `~/.wx-cli/`。 +/// +/// 只影响本进程;daemon(后续 fork)会继承调用用户身份。 +#[cfg(unix)] +fn drop_privileges_if_sudo() -> Result<()> { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + use std::path::Path; + + // 当前不是 root(用户直接以非 root 跑的 `wx init`)→ 什么都不做 + if unsafe { libc::geteuid() } != 0 { + return Ok(()); + } + + let sudo_uid: Option = std::env::var("SUDO_UID").ok().and_then(|s| s.parse().ok()); + let sudo_gid: Option = std::env::var("SUDO_GID").ok().and_then(|s| s.parse().ok()); + let (uid, gid) = match (sudo_uid, sudo_gid) { + (Some(u), Some(g)) if u != 0 => (u, g), + // 直接以 root 登陆(非 sudo),没有"调用用户"可还原 → 保持 root + _ => return Ok(()), + }; + + // 迁移旧版本遗留:如果 ~/.wx-cli/ 已存在且属 root,把它 chown 回调用用户, + // 顺便把 raw key 文件的权限也收紧到 0600(旧版默认 0644,世界可读等于泄露)。 + // 这些必须在 setuid 之前做:chown 需要 root,chmod 也只有属主或 root 能改。 + let cli_dir = config::cli_dir(); + if cli_dir.exists() { + let _ = chown_recursive(&cli_dir, uid, gid); + let _ = tighten_perms(&cli_dir); + } + + // 设置 umask,让后续 create 出来的文件/目录默认是 0600 / 0700。 + unsafe { libc::umask(0o077); } + + // 必须先 setgid 再 setuid:一旦 uid 降下来就没法再改 gid 了。 + unsafe { + if libc::setgid(gid) != 0 { + anyhow::bail!("setgid({}) 失败: {}", gid, std::io::Error::last_os_error()); + } + if libc::setuid(uid) != 0 { + anyhow::bail!("setuid({}) 失败: {}", uid, std::io::Error::last_os_error()); + } + } + + // chown 递归实现 + fn chown_recursive(path: &Path, uid: u32, gid: u32) -> std::io::Result<()> { + chown_one(path, uid, gid)?; + let md = std::fs::symlink_metadata(path)?; + if md.is_dir() { + for entry in std::fs::read_dir(path)? { + chown_recursive(&entry?.path(), uid, gid)?; + } + } + Ok(()) + } + fn chown_one(path: &Path, uid: u32, gid: u32) -> std::io::Result<()> { + let c = CString::new(path.as_os_str().as_bytes()) + .map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidInput, "path contains NUL"))?; + if unsafe { libc::chown(c.as_ptr(), uid, gid) } != 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + } + + /// 目录收紧到 0700,所有 *.json 文件(含 all_keys.json 这类 raw key)收紧到 0600。 + fn tighten_perms(cli_dir: &Path) -> std::io::Result<()> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(cli_dir, std::fs::Permissions::from_mode(0o700))?; + for entry in std::fs::read_dir(cli_dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) == Some("json") { + let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)); + } + } + Ok(()) + } + + Ok(()) +} + fn find_or_create_config_path() -> std::path::PathBuf { // 如果当前工作目录或可执行文件目录已有 config.json,沿用它(支持便携模式) if let Ok(cwd) = std::env::current_dir() { diff --git a/src/cli/transport.rs b/src/cli/transport.rs index 3f63d9d..ab62da5 100644 --- a/src/cli/transport.rs +++ b/src/cli/transport.rs @@ -62,10 +62,47 @@ pub fn ensure_daemon() -> Result<()> { Ok(()) } +/// 启动 daemon 前检查 `~/.wx-cli/` 可写,给出比"超时"更明确的错误。 +/// +/// 典型坑:旧版本 `sudo wx init` 把目录留成 root 属主,非 root 的 daemon +/// 连 socket/log 都建不了,会静默失败 15s 超时。 +fn preflight_cli_dir_writable() -> Result<()> { + let cli_dir = config::cli_dir(); + std::fs::create_dir_all(&cli_dir) + .with_context(|| format!("创建 {} 失败", cli_dir.display()))?; + + let probe = cli_dir.join(".daemon_probe"); + match std::fs::File::create(&probe) { + Ok(_) => { + let _ = std::fs::remove_file(&probe); + Ok(()) + } + Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => { + let dir = cli_dir.display(); + if cfg!(unix) { + bail!( + "无法写入 {dir}(权限不足)\n\n\ + 这通常是老版本的 `sudo wx init` 把目录属主留成了 root。\n\ + 修复:\n\n \ + sudo chown -R $(whoami) {dir}\n\n\ + (新版已修复此问题,下次 init 不会再发生)", + ) + } else { + bail!("无法写入 {dir}: {e}") + } + } + Err(e) => bail!("无法写入 {}: {}", cli_dir.display(), e), + } +} + /// 启动 daemon 进程(自身二进制,设置 WX_DAEMON_MODE=1) fn start_daemon() -> Result<()> { let exe = std::env::current_exe().context("无法获取当前可执行文件路径")?; + // 预检:当前用户是否能写 ~/.wx-cli/。如果不能,给出可操作的错误信息, + // 而不是 spawn 一个注定失败的 daemon 然后超时 15s。 + preflight_cli_dir_writable()?; + #[cfg(unix)] { use std::os::unix::process::CommandExt;