feat/fix: service core staging (#7899)

* feat(service): hand upgraded cores to the service through the installer

The service now only executes administrator-staged core copies, so an
in-app core upgrade has to deliver the new bytes there before the
restart asks for them; otherwise a service-mode restart keeps running
the previous core while the UI reports the new version.

After publishing the downloaded core, service-mode upgrades invoke the
elevated service installer with --install-core <path> --sha256 <hex>.
The digest is computed over the published file and rides the elevated
command line, which no other local account can alter, so a file
swapped after hashing is refused rather than staged. Sidecar-mode
upgrades run the file directly and prompt for nothing.

Rollback distinguishes staging that never ran, was refused, or
succeeded: only a successful staging obliges the rollback to restore
the approved copy too (deliberately not gated on the running mode,
which reads NotRunning after the very crash being rolled back), and a
refused staging restores the file so a retry does not see the new
version on disk and conclude there is nothing to do.

Requires clash-verge-service-ipc >= 2.6.7. The pinned dependency and
the packaged service release must move together with this change, or
the old installer silently treats --install-core as a plain install.

* feat(installer): attest bundled cores to the service at install time

prebuild computes sha256 digests of the downloaded mihomo sidecars
into packages/windows/core-hashes.nsh (gitignored), included through
the NSIS installerHooks path. After unpacking and before starting the
service, the installer stages both cores into the service's approved
directory with those digests, so what gets attested is exactly what
this installer shipped: a core swapped on disk in a user-writable
install directory is refused, not published. Failure is logged and
does not abort the install; a later elevated service install can
stage the cores again.

* chore(deps): require clash-verge-service-ipc v2.6.8

v2.6.8 is the first service whose installer understands
--install-core --sha256; the staging this branch introduces is inert
against anything older, because the old installer treats the flag as a
plain service installation and reports success. The service release and
this pin therefore have to ship in the same app release.

* fix(service): heap-allocate the core hashing buffer

clippy::large_stack_arrays denies the 64 KiB stack array.
This commit is contained in:
wonfen
2026-09-09 18:25:25 +08:00
committed by GitHub
parent c56e78a726
commit 3253828bf0
11 changed files with 273 additions and 5 deletions
Generated
+3 -2
View File
@@ -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",
+28
View File
@@ -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 },
+2
View File
@@ -6,3 +6,5 @@ WixTools
resources
sidecar
# Generated by scripts/prebuild.mjs; consumed by the NSIS installerHooks include
packages/windows/core-hashes.nsh
+2 -1
View File
@@ -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"
+18
View File
@@ -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
+161
View File
@@ -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<String> {
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 {
+55 -2
View File
@@ -79,7 +79,26 @@ pub async fn upgrade_core(force: bool) -> Result<CoreUpgradeReport> {
// 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<CoreUpgradeReport> {
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<CoreUpgradeReport> {
})
}
/// 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<PathBuf> {
+1
View File
@@ -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"
}
}
+1
View File
@@ -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"
}
}
+1
View File
@@ -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"
}
}
+1
View File
@@ -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"
}
}