Files
cursor-byok/server/src/harness/ca.rs
T
Linanwanttodo d95273c49b feat: 支持 Linux 平台使用(CA 安装适配 + 静默启动),并修复 api_key 泄漏
CA 适配:
- 移除 CA 模块对 macOS/Windows 的硬性限制,Linux 走完整初始化流程
- 按发行版自动选择安装命令:Debian 系 update-ca-certificates,
  Fedora/RHEL/Arch 系 update-ca-trust extract;已安装检测比对信任锚文件
- 删除不可达的 CaState::Unsupported 分支及前端"请使用 macOS 或 Windows"提示

静默启动(参考 cc-switch 实现逻辑):
- 新增 DesktopSettings 持久化(service_settings 表)及 GET/PUT /api/settings/desktop
- 主窗口统一以 visible(false) 创建,启动时读取配置决定是否显示
- 应用设置页:开启"开机启动"后显示"静默启动"子开关

安全修复:
- ProviderEndpoint.api_key 增加 serde(skip_serializing),
  避免明文 API Key 随 JSON 响应泄漏(provider_console 测试恢复通过)

其他:
- Linux 补齐 open_terminal_with_command:按序探测 x-terminal-emulator /
  gnome-terminal / konsole 等并在新终端窗口执行命令
- "打开终端安装 CA"失败信息不再被前端静默吞掉
- 顺手修复上游遗留问题:desktop.rs 未使用导入、output.rs 测试模块位置、providers.rs 格式
2026-08-25 22:59:21 +08:00

265 lines
8.8 KiB
Rust

use std::{fs, path::PathBuf};
#[cfg(target_os = "macos")]
use std::process::Command;
#[cfg(target_os = "windows")]
mod windows;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use rcgen::{
BasicConstraints, CertificateParams, DistinguishedName, DnType, IsCa, Issuer, KeyPair,
KeyUsagePurpose, RsaKeySize, PKCS_RSA_SHA256,
};
#[cfg(target_os = "macos")]
use sha1::{Digest, Sha1};
use time::{Duration, OffsetDateTime};
use x509_parser::prelude::FromDer;
use crate::{config::managed_data_dir, Error, Result};
use super::CaState;
#[derive(Clone)]
pub struct CaManager {
dir: PathBuf,
}
pub struct LoadedCa {
pub issuer: Issuer<'static, KeyPair>,
}
impl CaManager {
pub fn managed() -> Result<Self> {
Ok(Self {
dir: managed_data_dir()?.join("ca"),
})
}
fn cert_path(&self) -> PathBuf {
self.dir.join("ca.crt")
}
fn key_path(&self) -> PathBuf {
self.dir.join("ca.key")
}
pub fn state(&self) -> Result<CaState> {
let cert = fs::read_to_string(self.cert_path());
let key = fs::read_to_string(self.key_path());
match (cert, key) {
(Err(cert_error), Err(key_error))
if cert_error.kind() == std::io::ErrorKind::NotFound
&& key_error.kind() == std::io::ErrorKind::NotFound =>
{
Ok(CaState::Missing)
}
(Ok(cert), Ok(key)) => {
if parse_issuer(&cert, &key).is_err() {
return Ok(CaState::Invalid);
}
Ok(if is_installed(&cert)? {
CaState::Ready
} else {
CaState::Untrusted
})
}
_ => Ok(CaState::Invalid),
}
}
pub fn load(&self) -> Result<LoadedCa> {
let cert = fs::read_to_string(self.cert_path())?;
let key = fs::read_to_string(self.key_path())?;
Ok(LoadedCa {
issuer: parse_issuer(&cert, &key)?,
})
}
pub fn install_command(&self) -> Option<String> {
let path = self.cert_path().to_string_lossy().replace('\'', "'\\''");
match std::env::consts::OS {
"macos" => dirs::home_dir().map(|_| {
format!(
"sudo security add-trusted-cert -d -r trustRoot -p ssl -k /Library/Keychains/System.keychain '{}'",
path
)
}),
"windows" => Some(format!(
"certutil -addstore -f Root \"{}\"",
self.cert_path().display()
)),
"linux" => {
let anchor = linux_anchor_file();
Some(format!(
"sudo cp '{}' '{}' && sudo {}",
path,
anchor.display(),
linux_refresh_command()
))
}
_ => None,
}
}
pub fn initialize_local(&self) -> Result<()> {
match self.state()? {
CaState::Invalid => {
return Err(Error::Config("CA files are incomplete or invalid".into()))
}
CaState::Ready => return Ok(()),
CaState::Missing => self.generate()?,
CaState::Untrusted => {}
}
Ok(())
}
fn generate(&self) -> Result<()> {
fs::create_dir_all(&self.dir)?;
#[cfg(unix)]
fs::set_permissions(&self.dir, fs::Permissions::from_mode(0o700))?;
let key = KeyPair::generate_rsa_for(&PKCS_RSA_SHA256, RsaKeySize::_3072)
.map_err(|error| Error::Config(format!("generate CA key: {error}")))?;
let mut params = CertificateParams::new(Vec::<String>::new())
.map_err(|error| Error::Config(format!("create CA parameters: {error}")))?;
let mut name = DistinguishedName::new();
name.push(DnType::CommonName, "Cursor BYOK Local CA");
name.push(DnType::OrganizationName, "Cursor BYOK");
params.distinguished_name = name;
params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0));
params.key_usages = vec![
KeyUsagePurpose::DigitalSignature,
KeyUsagePurpose::KeyCertSign,
KeyUsagePurpose::CrlSign,
];
params.not_before = OffsetDateTime::now_utc() - Duration::minutes(5);
params.not_after = OffsetDateTime::now_utc() + Duration::days(3652);
let cert = params
.self_signed(&key)
.map_err(|error| Error::Config(format!("generate CA certificate: {error}")))?;
write_atomic(&self.key_path(), key.serialize_pem().as_bytes(), 0o600)?;
write_atomic(&self.cert_path(), cert.pem().as_bytes(), 0o644)?;
Ok(())
}
}
fn parse_issuer(cert: &str, key: &str) -> Result<Issuer<'static, KeyPair>> {
let key =
KeyPair::from_pem(key).map_err(|error| Error::Config(format!("parse CA key: {error}")))?;
let pem = pem::parse(cert).map_err(|error| Error::Config(format!("parse CA PEM: {error}")))?;
let (_, parsed) = x509_parser::certificate::X509Certificate::from_der(pem.contents())
.map_err(|error| Error::Config(format!("parse CA X.509 certificate: {error}")))?;
if parsed.public_key().subject_public_key.data.as_ref() != key.public_key_raw() {
return Err(Error::Config(
"CA certificate and private key do not match".into(),
));
}
if !parsed.validity().is_valid() {
return Err(Error::Config(
"CA certificate is outside its validity period".into(),
));
}
if !parsed
.basic_constraints()
.map_err(|error| Error::Config(format!("read CA constraints: {error}")))?
.is_some_and(|constraints| constraints.value.ca)
{
return Err(Error::Config("certificate is not a CA".into()));
}
Issuer::from_ca_cert_pem(cert, key)
.map_err(|error| Error::Config(format!("parse CA certificate: {error}")))
}
fn write_atomic(path: &std::path::Path, data: &[u8], _mode: u32) -> Result<()> {
let temp = path.with_extension("tmp");
fs::write(&temp, data)?;
#[cfg(unix)]
fs::set_permissions(&temp, fs::Permissions::from_mode(_mode))?;
fs::rename(&temp, path)?;
#[cfg(unix)]
fs::set_permissions(path, fs::Permissions::from_mode(_mode))?;
Ok(())
}
#[cfg(target_os = "macos")]
fn fingerprint(cert: &str) -> Result<String> {
let pem = pem::parse(cert).map_err(|error| Error::Config(format!("parse CA PEM: {error}")))?;
Ok(hex::encode_upper(Sha1::digest(pem.contents())))
}
#[cfg(target_os = "macos")]
fn is_installed(cert: &str) -> Result<bool> {
let fingerprint = fingerprint(cert)?;
for keychain in ["login.keychain-db", "/Library/Keychains/System.keychain"] {
let output = Command::new("security")
.args(["find-certificate", "-a", "-Z", keychain])
.output()?;
if output.status.success() && String::from_utf8_lossy(&output.stdout).contains(&fingerprint)
{
return Ok(true);
}
}
Ok(false)
}
#[cfg(target_os = "windows")]
fn is_installed(cert: &str) -> Result<bool> {
windows::is_installed(cert)
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
fn is_installed(cert: &str) -> Result<bool> {
match fs::read_to_string(linux_anchor_file()) {
Ok(installed) => Ok(installed.trim() == cert.trim()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(error.into()),
}
}
const LINUX_ANCHOR_NAME: &str = "cursor-byok-local-ca.crt";
fn linux_anchor_file() -> PathBuf {
if PathBuf::from("/etc/pki/ca-trust/source/anchors").is_dir() {
PathBuf::from("/etc/pki/ca-trust/source/anchors").join(LINUX_ANCHOR_NAME)
} else if PathBuf::from("/etc/ca-certificates/trust-source/anchors").is_dir() {
PathBuf::from("/etc/ca-certificates/trust-source/anchors").join(LINUX_ANCHOR_NAME)
} else {
PathBuf::from("/usr/local/share/ca-certificates").join(LINUX_ANCHOR_NAME)
}
}
fn linux_refresh_command() -> &'static str {
match linux_anchor_file().parent().and_then(|dir| dir.to_str()) {
Some("/usr/local/share/ca-certificates") => "update-ca-certificates",
_ => "update-ca-trust extract",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generated_ca_is_loadable_and_uses_private_permissions() {
let directory = tempfile::tempdir().unwrap();
let manager = CaManager {
dir: directory.path().join("ca"),
};
manager.generate().unwrap();
manager.load().unwrap();
assert!(manager.cert_path().is_file());
assert!(manager.key_path().is_file());
#[cfg(unix)]
assert_eq!(
fs::metadata(manager.key_path())
.unwrap()
.permissions()
.mode()
& 0o777,
0o600
);
}
}