diff --git a/Cargo.lock b/Cargo.lock index 67b3b915a..9b1435f6e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1171,6 +1171,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml_ng", + "sha2 0.11.0", "smartstring", "socket2 0.6.5", "sysproxy", @@ -1266,8 +1267,8 @@ dependencies = [ [[package]] name = "clash_verge_service_ipc" -version = "2.6.7" -source = "git+https://github.com/clash-verge-rev/clash-verge-service-ipc.git?tag=v2.6.7#aadc638b4947bd8de6a9f579da8a4a9d5fe76f8c" +version = "2.6.8" +source = "git+https://github.com/clash-verge-rev/clash-verge-service-ipc.git?tag=v2.6.8#44ac11940d9c9c351a6e873e03dd1c48f7aebcb8" dependencies = [ "anyhow", "kode-bridge", diff --git a/scripts/prebuild.mjs b/scripts/prebuild.mjs index d72eaf713..a7ddce78a 100644 --- a/scripts/prebuild.mjs +++ b/scripts/prebuild.mjs @@ -630,6 +630,32 @@ async function resolveServiceBundle() { } } +/// The NSIS installer publishes the bundled cores into the service's approved directory and must +/// attest exactly the bytes it unpacked. The digests are computed here, where the sidecars land, +/// and reach installer.nsi through the installerHooks include (see tauri.windows.conf.json). +const CORE_HASHES_NSH = path.join( + cwd, + 'src-tauri', + 'packages', + 'windows', + 'core-hashes.nsh', +) +async function resolveCoreHashes() { + const lines = [] + for (const [define, name] of [ + ['MIHOMO_SHA256', 'verge-mihomo'], + ['MIHOMO_ALPHA_SHA256', 'verge-mihomo-alpha'], + ]) { + const sidecar = path.join(SIDECAR_DIR, `${name}-${SIDECAR_HOST}.exe`) + const digest = createHash('sha256') + .update(await fsp.readFile(sidecar)) + .digest('hex') + lines.push(`!define ${define} "${digest}"`) + } + await fsp.writeFile(CORE_HASHES_NSH, lines.join('\n') + '\n') + log_success(`Generated ${CORE_HASHES_NSH}`) +} + const resolveMmdb = () => resolveResource({ file: 'Country.mmdb', @@ -675,6 +701,8 @@ const tasks = [ getLatestReleaseVersion().then(() => resolveSidecar(clashMeta())), retry: 5, }, + // After both sidecar tasks: it hashes what they downloaded. + { name: 'core_hashes', func: resolveCoreHashes, retry: 1, winOnly: true }, { name: 'plugin', func: resolvePlugin, retry: 5, winOnly: true }, { name: 'service', func: resolveServiceBundle, retry: 5 }, { name: 'mmdb', func: resolveMmdb, retry: 5 }, diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore index 8975c76a1..2eaffedf5 100644 --- a/src-tauri/.gitignore +++ b/src-tauri/.gitignore @@ -6,3 +6,5 @@ WixTools resources sidecar +# Generated by scripts/prebuild.mjs; consumed by the NSIS installerHooks include +packages/windows/core-hashes.nsh diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a81f479b5..611cba083 100755 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -86,6 +86,7 @@ flate2 = "1.1.9" reqwest_dav = "0.3.3" aes-gcm = { version = "0.11.0" } base64 = "0.23.1" +sha2 = "0.11" getrandom = "0.4.3" futures = "0.3.34" gethostname = "1.1.0" @@ -98,7 +99,7 @@ tracing = { workspace = true } tauri-plugin-devtools = { version = "2.1.0", optional = true } tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", branch = "main" } async-trait = "0.1.92" -clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.6.7", version = "2.6.7", features = [ +clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.6.8", version = "2.6.8", features = [ "client", ] } arc-swap = "1.9.2" diff --git a/src-tauri/packages/windows/installer.nsi b/src-tauri/packages/windows/installer.nsi index 1daeaa956..eb7ada4d2 100644 --- a/src-tauri/packages/windows/installer.nsi +++ b/src-tauri/packages/windows/installer.nsi @@ -961,6 +961,24 @@ Section Install File /a "/oname={{this}}" "{{no-escape @key}}" {{/each}} + ; Publish the bundled cores into the service's approved directory before the service starts. + ; The service only executes administrator-approved copies, and the build-time digests + ; (core-hashes.nsh, generated by scripts/prebuild.mjs, included via installerHooks) attest + ; exactly the bytes this installer unpacked: a core swapped on disk is refused, not published. + ; Not fatal on failure; a later elevated service install can stage the cores again. + !ifdef MIHOMO_SHA256 + DetailPrint "Staging verge-mihomo for ${PRODUCTNAME} Service..." + nsExec::ExecToLog '"$INSTDIR\resources\clash-verge-service-install.exe" --install-core "$INSTDIR\verge-mihomo.exe" --sha256 "${MIHOMO_SHA256}"' + Pop $0 + ${IfThen} $0 != 0 ${|} DetailPrint "Staging verge-mihomo returned $0" ${|} + !endif + !ifdef MIHOMO_ALPHA_SHA256 + DetailPrint "Staging verge-mihomo-alpha for ${PRODUCTNAME} Service..." + nsExec::ExecToLog '"$INSTDIR\resources\clash-verge-service-install.exe" --install-core "$INSTDIR\verge-mihomo-alpha.exe" --sha256 "${MIHOMO_ALPHA_SHA256}"' + Pop $0 + ${IfThen} $0 != 0 ${|} DetailPrint "Staging verge-mihomo-alpha returned $0" ${|} + !endif + !insertmacro StartVergeService ; Create file associations diff --git a/src-tauri/src/core/service.rs b/src-tauri/src/core/service.rs index 0d0e3a3cb..3c55bfa83 100644 --- a/src-tauri/src/core/service.rs +++ b/src-tauri/src/core/service.rs @@ -800,6 +800,167 @@ fn force_reinstall_service() -> Result<()> { }) } +/// Publishes a core into the Service's approved directory through the elevated installer. +/// +/// The Service only executes administrator-approved copies from its own directory, never the file +/// beside the app, so a freshly replaced core has to be handed over before a service-mode restart +/// asks for it. The digest pins the exact bytes being attested: it rides the elevated process's +/// command line, which no other local account can alter, so a file swapped on disk after this +/// hash was computed is refused by the installer instead of published. +pub fn stage_approved_core(core_path: &Path) -> Result<()> { + tokio::task::block_in_place(|| { + let digest = sha256_hex(core_path)?; + run_core_install(core_path, &digest) + }) +} + +fn sha256_hex(path: &Path) -> Result { + use sha2::{Digest as _, Sha256}; + use std::io::Read as _; + + let mut file = std::fs::File::open(path).with_context(|| format!("failed to open {path:?} for hashing"))?; + let mut hasher = Sha256::new(); + let mut buffer = vec![0_u8; 64 * 1024].into_boxed_slice(); + loop { + let read = file + .read(&mut buffer) + .with_context(|| format!("failed to read {path:?}"))?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(hasher.finalize().iter().map(|byte| format!("{byte:02x}")).collect()) +} + +#[cfg(target_os = "windows")] +fn run_core_install(core_path: &Path, sha256_hex: &str) -> Result<()> { + use deelevate::{PrivilegeLevel, Token}; + use runas::Command as RunasCommand; + use std::os::windows::process::CommandExt as _; + use std::process::Output; + + let install_path = packaged_service_tool_path("clash-verge-service-install.exe", || { + Ok(dirs::service_path()?.with_file_name("clash-verge-service-install.exe")) + })?; + if !install_path.exists() { + bail!(format!("installer not found: {install_path:?}")); + } + + let token = Token::with_current_process()?; + let output = match token.privilege_level()? { + PrivilegeLevel::NotPrivileged => { + let status = RunasCommand::new(&install_path) + .arg("--install-core") + .arg(core_path) + .arg("--sha256") + .arg(sha256_hex) + .show(false) + .status()?; + Output { + status, + stdout: Vec::new(), + stderr: Vec::new(), + } + } + _ => StdCommand::new(&install_path) + .creation_flags(0x08000000) + .arg("--install-core") + .arg(core_path) + .arg("--sha256") + .arg(sha256_hex) + .output()?, + }; + + if let Some((code, err)) = check_output_error(&output) { + bail!("failed to stage the core for the service, code {code}: {err}"); + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn run_core_install(core_path: &Path, sha256_hex: &str) -> Result<()> { + let install_path = packaged_service_tool_path("clash-verge-service-install", || { + Ok(tauri::utils::platform::current_exe()?.with_file_name("clash-verge-service-install")) + })?; + if !install_path.exists() { + bail!(format!("installer not found: {install_path:?}")); + } + + let core_argument = core_path.as_os_str(); + let output = if linux_running_as_root() { + StdCommand::new(&install_path) + .arg("--install-core") + .arg(core_argument) + .arg("--sha256") + .arg(sha256_hex) + .output()? + } else { + let elevator = crate::utils::help::linux_elevator(); + let mut elevated = StdCommand::new(&elevator); + // pkexec-only option; other elevators such as sudo reject unknown flags outright. + if elevator.contains("pkexec") { + elevated.arg("--disable-internal-agent"); + } + let result = elevated + .arg(&install_path) + .arg("--install-core") + .arg(core_argument) + .arg("--sha256") + .arg(sha256_hex) + .output()?; + if !result.status.success() && elevator.contains("pkexec") { + logging!( + warn, + Type::Service, + "pkexec failed with code {}, falling back to sudo", + result.status.code().unwrap_or(-1) + ); + StdCommand::new("sudo") + .arg(&install_path) + .arg("--install-core") + .arg(core_argument) + .arg("--sha256") + .arg(sha256_hex) + .output()? + } else { + result + } + }; + + if let Some((code, err)) = check_output_error(&output) { + bail!("failed to stage the core for the service, code {code}: {err}"); + } + Ok(()) +} + +#[cfg(target_os = "macos")] +fn run_core_install(core_path: &Path, sha256_hex: &str) -> Result<()> { + let install_path = packaged_service_tool_path("clash-verge-service-install", || { + Ok(dirs::service_path()?.with_file_name("clash-verge-service-install")) + })?; + if !install_path.exists() { + bail!(format!("installer not found: {install_path:?}")); + } + let install_path = macos_service_tool_path(&install_path)?; + + let prompt = clash_verge_i18n::t!("service.adminInstallPrompt"); + let shell = format!( + "cd /; {} --install-core {} --sha256 {}", + shell_single_quote(&install_path.to_string_lossy()), + shell_single_quote(&core_path.to_string_lossy()), + shell_single_quote(sha256_hex), + ); + let shell = escape_osascript_double_quoted_string(&shell); + let command = format!(r#"do shell script "{shell}" with administrator privileges with prompt "{prompt}""#); + + let output = StdCommand::new("osascript").args(["-e", &command]).output()?; + if let Some((code, err)) = check_output_error(&output) { + bail!("failed to stage the core for the service, code {code}: {err}"); + } + Ok(()) +} + /// Dispatches a privileged platform operation on a blocking thread. pub(crate) fn run_privileged_service_action(action: PendingAction) -> Result<()> { let (operation, label): (fn() -> Result<()>, &'static str) = match action { diff --git a/src-tauri/src/feat/core_upgrade.rs b/src-tauri/src/feat/core_upgrade.rs index 19919d4d1..b139c0a22 100644 --- a/src-tauri/src/feat/core_upgrade.rs +++ b/src-tauri/src/feat/core_upgrade.rs @@ -79,7 +79,26 @@ pub async fn upgrade_core(force: bool) -> Result { // Nothing to roll back to when the core we are replacing could not report a version. let restorable = !installed.is_empty() && std::fs::hard_link(&target, &rollback).is_ok(); + // Decided before anything can crash: after a failed restart the mode reads NotRunning, which + // says nothing about whether this upgrade went through the Service. + let service_mode = matches!(*CoreManager::global().get_running_mode(), RunningMode::Service); + let mut result = staged.publish(&target); + // The Service executes its own administrator-approved copy, never this file; hand the new + // bytes over before the restart below asks for them, or a service-mode restart would keep + // running the previous core while the app reports the new version. Sidecar mode runs the + // file directly and gets no elevation prompt. + let service_staging = if result.is_ok() && service_mode { + result = + crate::core::service::stage_approved_core(&target).context("core replaced but not accepted by the service"); + if result.is_ok() { + ServiceStaging::Succeeded + } else { + ServiceStaging::Refused + } + } else { + ServiceStaging::NotAttempted + }; if result.is_ok() { result = CoreManager::global() .restart_core() @@ -89,10 +108,30 @@ pub async fn upgrade_core(force: bool) -> Result { if let Err(error) = result { // Put the previous core back when its file is gone or its process is down. A failure - // after the new core started must keep the new file instead. + // after the new core started must keep the new file instead. A service that refused the + // new core also restores: leaving the new file would make a retry read the new version + // and report nothing to do while the service keeps running the old core. let core_is_down = matches!(*CoreManager::global().get_running_mode(), RunningMode::NotRunning); - if restorable && (core_is_down || !target.exists()) && std::fs::rename(&rollback, &target).is_ok() { + if restorable + && (matches!(service_staging, ServiceStaging::Refused) || core_is_down || !target.exists()) + && std::fs::rename(&rollback, &target).is_ok() + { logging!(warn, Type::Core, "core upgrade: rolled back to {installed:?}"); + // Only a staging that SUCCEEDED left the failing bytes in the approved copy, and only + // then must the restore reach it too — otherwise the restart below would spawn the + // very core that just failed. Refused or never-attempted staging left the approved + // copy untouched, and re-staging would raise an elevation prompt for a no-op. This is + // deliberately not gated on the current running mode, which reads NotRunning after + // the very crash being rolled back. + if matches!(service_staging, ServiceStaging::Succeeded) + && let Err(stage_error) = crate::core::service::stage_approved_core(&target) + { + logging!( + warn, + Type::Core, + "core upgrade: could not restore the service copy: {stage_error:#}" + ); + } if core_is_down { let _ = CoreManager::global().restart_core().await; } @@ -118,6 +157,20 @@ pub async fn upgrade_core(force: bool) -> Result { }) } +/// Whether this upgrade handed bytes to the Service's approved core directory. +/// +/// Three states matter, not two: a staging that never ran (publish failed, or sidecar mode) left +/// the approved copy alone just like a refused one, but only a SUCCEEDED staging obliges the +/// rollback path to restore that copy as well. A user who upgrades in sidecar mode and later +/// switches to service mode runs the previously approved core until the next elevated +/// (re)install or upgrade; the Service logs that drift on every spawn. +#[derive(Clone, Copy)] +enum ServiceStaging { + NotAttempted, + Succeeded, + Refused, +} + /// The sidecar next to the app executable. On macOS development builds the Service runs a /// staged copy of this file, so writing here is what makes an upgrade survive the next start. fn managed_core_path(core: &str) -> Result { diff --git a/src-tauri/tauri.windows.conf.json b/src-tauri/tauri.windows.conf.json index 351939c55..4897d039d 100644 --- a/src-tauri/tauri.windows.conf.json +++ b/src-tauri/tauri.windows.conf.json @@ -16,6 +16,7 @@ "installerIcon": "icons/icon.ico", "languages": ["SimpChinese", "English", "Russian"], "installMode": "perMachine", + "installerHooks": "./packages/windows/core-hashes.nsh", "template": "./packages/windows/installer.nsi" } } diff --git a/src-tauri/webview2.arm64.json b/src-tauri/webview2.arm64.json index a4a3fd6a3..4f0564994 100644 --- a/src-tauri/webview2.arm64.json +++ b/src-tauri/webview2.arm64.json @@ -16,6 +16,7 @@ "installerIcon": "icons/icon.ico", "languages": ["SimpChinese", "English", "Russian"], "installMode": "perMachine", + "installerHooks": "./packages/windows/core-hashes.nsh", "template": "./packages/windows/installer.nsi" } } diff --git a/src-tauri/webview2.x64.json b/src-tauri/webview2.x64.json index 68c6bf432..73ecfb76a 100644 --- a/src-tauri/webview2.x64.json +++ b/src-tauri/webview2.x64.json @@ -16,6 +16,7 @@ "installerIcon": "icons/icon.ico", "languages": ["SimpChinese", "English", "Russian"], "installMode": "perMachine", + "installerHooks": "./packages/windows/core-hashes.nsh", "template": "./packages/windows/installer.nsi" } } diff --git a/src-tauri/webview2.x86.json b/src-tauri/webview2.x86.json index d990c542f..adaad2f34 100644 --- a/src-tauri/webview2.x86.json +++ b/src-tauri/webview2.x86.json @@ -16,6 +16,7 @@ "installerIcon": "icons/icon.ico", "languages": ["SimpChinese", "English", "Russian"], "installMode": "perMachine", + "installerHooks": "./packages/windows/core-hashes.nsh", "template": "./packages/windows/installer.nsi" } }