mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-28 09:12:08 +08:00
* 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.
503 lines
20 KiB
Rust
503 lines
20 KiB
Rust
//! In-app mihomo core upgrade.
|
|
//!
|
|
//! The core replaces itself by truncating its own running executable in place, which macOS
|
|
//! kills under Hardened Runtime and which leaves a 0-byte core behind when interrupted
|
|
//! (clash-verge-rev#6834). Verge downloads the release itself instead, stages a verified
|
|
//! copy beside the managed core and renames it into place: `rename` never touches the inode
|
|
//! the running core is executing from, so no page validation can fail.
|
|
|
|
use crate::{
|
|
config::Config,
|
|
core::{CoreManager, manager::RunningMode},
|
|
utils::network::{NetworkManager, ProxyType},
|
|
};
|
|
use anyhow::{Context as _, Result, anyhow, bail};
|
|
use clash_verge_logging::{Type, logging};
|
|
use std::{
|
|
env::current_exe,
|
|
ffi::OsStr,
|
|
fs::File,
|
|
path::{Path, PathBuf},
|
|
process::Command,
|
|
sync::atomic::{AtomicU64, Ordering},
|
|
};
|
|
|
|
const RELEASE_VERSION_URL: &str = "https://github.com/MetaCubeX/mihomo/releases/latest/download/version.txt";
|
|
const ALPHA_BASE_URL: &str = "https://github.com/MetaCubeX/mihomo/releases/download/Prerelease-Alpha";
|
|
const RELEASE_DOWNLOAD_URL: &str = "https://github.com/MetaCubeX/mihomo/releases/download";
|
|
const VERSION_TIMEOUT_SECS: u64 = 20;
|
|
const PACKAGE_TIMEOUT_SECS: u64 = 300;
|
|
/// Well above any real core package, low enough that a wrong response cannot exhaust memory.
|
|
const MAX_PACKAGE_BYTES: usize = 64 * 1024 * 1024;
|
|
|
|
static STAGING_GENERATION: AtomicU64 = AtomicU64::new(0);
|
|
/// `.rollback` and `.old` are fixed paths, so two upgrades must not overlap.
|
|
static UPGRADE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
|
|
|
#[derive(Debug, serde::Serialize)]
|
|
pub struct CoreUpgradeReport {
|
|
/// False when the managed core was already at the latest version.
|
|
pub upgraded: bool,
|
|
pub from: std::string::String,
|
|
pub to: std::string::String,
|
|
}
|
|
|
|
pub async fn upgrade_core(force: bool) -> Result<CoreUpgradeReport> {
|
|
let _serialized = UPGRADE_LOCK.lock().await;
|
|
let core = Config::verge().await.latest_arc().get_valid_clash_core();
|
|
tracing::Span::current().record("core", tracing::field::display(&core));
|
|
let alpha = core.ends_with("-alpha");
|
|
let target = managed_core_path(&core)?;
|
|
|
|
// A core already broken by the in-place updater cannot report a version; upgrading is the repair.
|
|
let installed = read_core_version(&target).unwrap_or_else(|error| {
|
|
logging!(warn, Type::Core, "core upgrade: unreadable installed core: {error:#}");
|
|
std::string::String::new()
|
|
});
|
|
|
|
let (proxy, latest) = resolve_latest_version(alpha).await?;
|
|
let span = tracing::Span::current();
|
|
span.record("from", tracing::field::display(&installed));
|
|
span.record("to", tracing::field::display(&latest));
|
|
logging!(debug, Type::Core, "core upgrade: latest version resolved via {proxy:?}");
|
|
|
|
if !force && installed == latest {
|
|
return Ok(CoreUpgradeReport {
|
|
upgraded: false,
|
|
from: installed,
|
|
to: latest,
|
|
});
|
|
}
|
|
|
|
let package = download_package(proxy, alpha, &latest).await?;
|
|
let staged = stage_core(&target, &package, &latest)?;
|
|
|
|
// A hard link keeps the previous core reachable without touching the inode the running
|
|
// core executes from, so publishing below stays one atomic rename.
|
|
let rollback = target.with_file_name(format!(".{core}.rollback"));
|
|
let _ = std::fs::remove_file(&rollback);
|
|
// 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()
|
|
.await
|
|
.context("core replaced but failed to restart");
|
|
}
|
|
|
|
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. 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
|
|
&& (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;
|
|
}
|
|
}
|
|
// Renaming one hard link over the other succeeds without consuming the name.
|
|
let _ = std::fs::remove_file(&rollback);
|
|
// Redundant once the rollback link exists; without one it is the only copy left.
|
|
#[cfg(windows)]
|
|
if restorable {
|
|
let _ = std::fs::remove_file(target.with_extension("old"));
|
|
}
|
|
return Err(error);
|
|
}
|
|
let _ = std::fs::remove_file(&rollback);
|
|
// The displaced core could not be deleted while it was still executing.
|
|
#[cfg(windows)]
|
|
let _ = std::fs::remove_file(target.with_extension("old"));
|
|
|
|
Ok(CoreUpgradeReport {
|
|
upgraded: true,
|
|
from: installed,
|
|
to: latest,
|
|
})
|
|
}
|
|
|
|
/// 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> {
|
|
let extension = if cfg!(windows) { ".exe" } else { "" };
|
|
let path = current_exe()
|
|
.context("failed to locate the current executable")?
|
|
.with_file_name(format!("{core}{extension}"));
|
|
Ok(path)
|
|
}
|
|
|
|
/// Pins the package to the resolved version, so a release moving on mid-upgrade cannot 404.
|
|
fn package_url(alpha: bool, version: &str) -> Result<std::string::String> {
|
|
let asset = asset_base_name(alpha)?;
|
|
let extension = if cfg!(windows) { "zip" } else { "gz" };
|
|
Ok(if alpha {
|
|
format!("{ALPHA_BASE_URL}/{asset}-{version}.{extension}")
|
|
} else {
|
|
format!("{RELEASE_DOWNLOAD_URL}/{version}/{asset}-{version}.{extension}")
|
|
})
|
|
}
|
|
|
|
/// Mirrors the asset map in `scripts/prebuild.mjs` so an upgrade keeps the build variant
|
|
/// the bundled sidecar was taken from.
|
|
fn asset_base_name(alpha: bool) -> Result<&'static str> {
|
|
let arch = std::env::consts::ARCH;
|
|
let unsupported = || anyhow!("no mihomo release asset for {}-{arch}", std::env::consts::OS);
|
|
|
|
let name = if cfg!(target_os = "windows") {
|
|
match arch {
|
|
"x86_64" => "mihomo-windows-amd64-v2",
|
|
"x86" => "mihomo-windows-386",
|
|
"aarch64" => "mihomo-windows-arm64",
|
|
_ => return Err(unsupported()),
|
|
}
|
|
} else if cfg!(target_os = "macos") {
|
|
match arch {
|
|
"x86_64" if alpha => "mihomo-darwin-amd64-v1-go122",
|
|
"x86_64" => "mihomo-darwin-amd64-v2-go122",
|
|
"aarch64" => "mihomo-darwin-arm64-go122",
|
|
_ => return Err(unsupported()),
|
|
}
|
|
} else {
|
|
match arch {
|
|
"x86_64" => "mihomo-linux-amd64-v2",
|
|
"x86" => "mihomo-linux-386",
|
|
"aarch64" => "mihomo-linux-arm64",
|
|
"arm" => "mihomo-linux-armv7",
|
|
"riscv64" => "mihomo-linux-riscv64",
|
|
"loongarch64" => "mihomo-linux-loong64",
|
|
_ => return Err(unsupported()),
|
|
}
|
|
};
|
|
Ok(name)
|
|
}
|
|
|
|
/// Returns the proxy that reached GitHub so the package download reuses it.
|
|
#[tracing::instrument(skip_all, level = "debug", fields(alpha))]
|
|
async fn resolve_latest_version(alpha: bool) -> Result<(ProxyType, std::string::String)> {
|
|
let url = if alpha {
|
|
format!("{ALPHA_BASE_URL}/version.txt")
|
|
} else {
|
|
RELEASE_VERSION_URL.to_owned()
|
|
};
|
|
let mut last_error = None;
|
|
|
|
for proxy in [ProxyType::Localhost, ProxyType::System, ProxyType::None] {
|
|
let attempt = NetworkManager::new()
|
|
.get(&url, proxy, Some(VERSION_TIMEOUT_SECS), None, false)
|
|
.await;
|
|
|
|
match attempt {
|
|
Ok(response) if response.status().is_success() => {
|
|
// An error page answered with 200 must not become a version, nor reach the URL.
|
|
let version = response.text().trim().to_owned();
|
|
if !is_usable_version(&version) {
|
|
logging!(
|
|
warn,
|
|
Type::Core,
|
|
"core upgrade: {url} returned an unusable version: {version:?}"
|
|
);
|
|
last_error = Some(anyhow!("{url} returned an unusable version"));
|
|
continue;
|
|
}
|
|
return Ok((proxy, version));
|
|
}
|
|
Ok(response) => {
|
|
logging!(
|
|
debug,
|
|
Type::Core,
|
|
"core upgrade: version probe via {proxy:?}: status {}",
|
|
response.status()
|
|
);
|
|
last_error = Some(anyhow!("{url} returned status {}", response.status()));
|
|
}
|
|
Err(error) => {
|
|
logging!(
|
|
debug,
|
|
Type::Core,
|
|
"core upgrade: version probe via {proxy:?} failed: {error:#}"
|
|
);
|
|
last_error = Some(error.context(format!("{proxy:?} could not reach {url}")));
|
|
}
|
|
}
|
|
}
|
|
|
|
Err(last_error.unwrap_or_else(|| anyhow!("failed to read the latest core version from {url}")))
|
|
}
|
|
|
|
/// Keeps error pages, and anything that could steer the package URL, out of the version.
|
|
fn is_usable_version(version: &str) -> bool {
|
|
version.starts_with(|c: char| c.is_ascii_alphanumeric())
|
|
&& version.len() <= 64
|
|
&& version
|
|
.chars()
|
|
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
|
|
}
|
|
|
|
async fn download_package(proxy: ProxyType, alpha: bool, version: &str) -> Result<Vec<u8>> {
|
|
let url = package_url(alpha, version)?;
|
|
logging!(info, Type::Core, "core upgrade: downloading {url}");
|
|
|
|
NetworkManager::new()
|
|
.get_bytes(&url, proxy, Some(PACKAGE_TIMEOUT_SECS), MAX_PACKAGE_BYTES)
|
|
.await
|
|
.with_context(|| format!("failed to download {url}"))
|
|
}
|
|
|
|
/// A staged core that is removed unless publishing renamed it away.
|
|
struct StagedCore {
|
|
path: PathBuf,
|
|
}
|
|
|
|
impl Drop for StagedCore {
|
|
fn drop(&mut self) {
|
|
match std::fs::remove_file(&self.path) {
|
|
Ok(()) => {}
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
|
Err(error) => logging!(
|
|
warn,
|
|
Type::Core,
|
|
"core upgrade: failed to clean {}: {error}",
|
|
self.path.display()
|
|
),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Unpacks, permits, signs and runs the new core before it is allowed anywhere near the
|
|
/// live path, so a bad download can never replace a working core.
|
|
fn stage_core(target: &Path, package: &[u8], version: &str) -> Result<StagedCore> {
|
|
let directory = target
|
|
.parent()
|
|
.with_context(|| format!("the managed core has no parent directory: {}", target.display()))?;
|
|
let core_name = target
|
|
.file_name()
|
|
.with_context(|| format!("the managed core has no file name: {}", target.display()))?;
|
|
|
|
let (path, mut file) = create_staging_file(directory, core_name)?;
|
|
let staged = StagedCore { path };
|
|
|
|
unpack(package, &mut file).with_context(|| format!("failed to unpack the core package for {version}"))?;
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt as _;
|
|
|
|
let mut permissions = file
|
|
.metadata()
|
|
.with_context(|| format!("failed to inspect {}", staged.path.display()))?
|
|
.permissions();
|
|
permissions.set_mode(0o755);
|
|
file.set_permissions(permissions)
|
|
.with_context(|| format!("failed to make {} executable", staged.path.display()))?;
|
|
}
|
|
|
|
file.sync_all()
|
|
.with_context(|| format!("failed to flush {}", staged.path.display()))?;
|
|
drop(file);
|
|
|
|
let staged_version = read_core_version(&staged.path).context("the staged core is not runnable")?;
|
|
if staged_version != version {
|
|
bail!("the staged core reports {staged_version}, expected {version}");
|
|
}
|
|
|
|
Ok(staged)
|
|
}
|
|
|
|
fn create_staging_file(directory: &Path, core_name: &OsStr) -> Result<(PathBuf, File)> {
|
|
for _ in 0..32 {
|
|
let generation = STAGING_GENERATION.fetch_add(1, Ordering::Relaxed);
|
|
let path = directory.join(format!(
|
|
".{}.{}.{generation}.tmp",
|
|
core_name.to_string_lossy(),
|
|
std::process::id()
|
|
));
|
|
|
|
match File::options().write(true).create_new(true).open(&path) {
|
|
Ok(file) => return Ok((path, file)),
|
|
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
|
|
Err(error) => {
|
|
return Err(error).with_context(|| format!("failed to create {}", path.display()));
|
|
}
|
|
}
|
|
}
|
|
|
|
bail!("failed to create a unique staging file in {}", directory.display())
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn unpack(package: &[u8], out: &mut File) -> Result<()> {
|
|
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(package)).context("invalid core archive")?;
|
|
let mut entry = archive.by_index(0).context("the core archive is empty")?;
|
|
std::io::copy(&mut entry, out).context("failed to extract the core")?;
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
fn unpack(package: &[u8], out: &mut File) -> Result<()> {
|
|
let mut decoder = flate2::read::GzDecoder::new(package);
|
|
std::io::copy(&mut decoder, out).context("failed to decompress the core")?;
|
|
Ok(())
|
|
}
|
|
|
|
impl StagedCore {
|
|
#[cfg(not(windows))]
|
|
fn publish(self, target: &Path) -> Result<()> {
|
|
std::fs::rename(&self.path, target)
|
|
.with_context(|| format!("failed to publish the core to {}", target.display()))
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn publish(self, target: &Path) -> Result<()> {
|
|
if std::fs::rename(&self.path, target).is_ok() {
|
|
return Ok(());
|
|
}
|
|
|
|
// A running executable cannot be replaced on Windows, but it can be moved aside.
|
|
let displaced = target.with_extension("old");
|
|
let _ = std::fs::remove_file(&displaced);
|
|
std::fs::rename(target, &displaced)
|
|
.with_context(|| format!("failed to move the running core aside from {}", target.display()))?;
|
|
|
|
if let Err(error) = std::fs::rename(&self.path, target) {
|
|
// Say where the old core went when it cannot be put back either.
|
|
return match std::fs::rename(&displaced, target) {
|
|
Ok(()) => Err(error).with_context(|| format!("failed to publish the core to {}", target.display())),
|
|
Err(restore) => Err(error).with_context(|| {
|
|
format!(
|
|
"failed to publish the core to {} and to restore it from {}: {restore}",
|
|
target.display(),
|
|
displaced.display()
|
|
)
|
|
}),
|
|
};
|
|
}
|
|
|
|
let _ = std::fs::remove_file(&displaced);
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Keeps the `-v` probe and codesign from flashing a console window.
|
|
#[cfg(windows)]
|
|
fn new_command(program: impl AsRef<OsStr>) -> Command {
|
|
use std::os::windows::process::CommandExt as _;
|
|
|
|
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
|
let mut command = Command::new(program);
|
|
command.creation_flags(CREATE_NO_WINDOW);
|
|
command
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
fn new_command(program: impl AsRef<OsStr>) -> Command {
|
|
Command::new(program)
|
|
}
|
|
|
|
/// Parses the version out of `Mihomo Meta v1.19.30 darwin arm64 with go1.22.12 ...`.
|
|
fn read_core_version(path: &Path) -> Result<std::string::String> {
|
|
let output = new_command(path)
|
|
.arg("-v")
|
|
.output()
|
|
.with_context(|| format!("failed to run {} -v", path.display()))?;
|
|
|
|
if !output.status.success() {
|
|
bail!("{} -v exited with {}", path.display(), output.status);
|
|
}
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
stdout
|
|
.split_whitespace()
|
|
.nth(2)
|
|
.map(str::to_owned)
|
|
.with_context(|| format!("unexpected version output from {}: {stdout}", path.display()))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{is_usable_version, package_url};
|
|
|
|
#[test]
|
|
fn only_plain_version_tokens_reach_the_package_url() {
|
|
assert!(is_usable_version("v1.19.30"));
|
|
assert!(is_usable_version("alpha-c0e43eb"));
|
|
for rejected in [
|
|
"",
|
|
".",
|
|
"..",
|
|
"/etc/passwd",
|
|
"v1.19.30 extra",
|
|
"<html>nope</html>",
|
|
&"v".repeat(65),
|
|
] {
|
|
assert!(!is_usable_version(rejected), "accepted {rejected:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn package_urls_pin_the_resolved_version() {
|
|
let release = package_url(false, "v1.19.30").unwrap_or_default();
|
|
let alpha = package_url(true, "alpha-c0e43eb").unwrap_or_default();
|
|
assert!(release.contains("/releases/download/v1.19.30/"), "{release}");
|
|
assert!(
|
|
release.ends_with("-v1.19.30.gz") || release.ends_with("-v1.19.30.zip"),
|
|
"{release}"
|
|
);
|
|
assert!(alpha.contains("/Prerelease-Alpha/"), "{alpha}");
|
|
}
|
|
}
|