From 15b6eee8cc5c6e785863d883f295d188028f0fc7 Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Thu, 21 May 2026 13:34:02 +0200 Subject: [PATCH] Implement auto-update routine --- src/cli.rs | 48 ++++++- src/main.rs | 35 +++++ vendor/rustdesk/src/common.rs | 122 ++++++++++++++---- vendor/rustdesk/src/platform/windows.rs | 51 ++++++-- vendor/rustdesk/src/updater.rs | 162 +++++++++++++++--------- vendor/rustdesk/src/version.rs | 2 +- 6 files changed, 320 insertions(+), 100 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index b4ac78d..cc12a73 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -31,6 +31,13 @@ pub enum Action { /// — every `Data::FS(...)` frame the server sends is executed here, in /// the user's security context. Cm, + /// `--update`. Self-replacement entry point launched as an elevated child + /// by the running service's updater (see `librustdesk::updater`) after it + /// has downloaded and SHA256-verified a new hello-agent.exe from the + /// Gitea releases page. `current_exe()` here points at the staged new + /// binary in `%TEMP%`; it copies itself over the installed location and + /// restarts the service via `librustdesk::platform::update_me`. + Update, } #[derive(Debug)] @@ -47,6 +54,7 @@ impl ParsedArgs { let mut service = false; let mut server = false; let mut cm = false; + let mut update = false; let mut config_blob: Option = None; let mut i = 0; @@ -56,6 +64,7 @@ impl ParsedArgs { "--uninstall" => uninstall = true, "--service" => service = true, "--server" => server = true, + "--update" => update = true, // Connection-manager popup mode. Treat `--cm-no-ui` (the // Linux-headless variant librustdesk also tries) as a // synonym; either way we run cm_popup. @@ -81,14 +90,21 @@ impl ParsedArgs { } // Mutual-exclusion rules. --install + --config is the MDM one-liner; - // everything else is one-action-at-a-time. - let exclusive = [uninstall, service, server, cm].iter().filter(|x| **x).count(); + // everything else is one-action-at-a-time. --update is launched by + // the updater as a standalone elevated child, never combined. + let exclusive = [uninstall, service, server, cm, update] + .iter() + .filter(|x| **x) + .count(); if exclusive > 1 { - bail!("--uninstall, --service, --server, --cm are mutually exclusive"); + bail!("--uninstall, --service, --server, --cm, --update are mutually exclusive"); } if uninstall && (install || config_blob.is_some()) { bail!("--uninstall cannot be combined with other flags"); } + if update && (install || config_blob.is_some()) { + bail!("--update cannot be combined with other flags"); + } let action = if uninstall { Action::Uninstall @@ -100,6 +116,8 @@ impl ParsedArgs { Action::Server } else if cm { Action::Cm + } else if update { + Action::Update } else if config_blob.is_some() { Action::ConfigOnly } else { @@ -131,6 +149,10 @@ OPTIONS: --service SCM entry point. Do not invoke manually. --server Worker mode (launched by the service shell into the active console session). + --update Self-replacement entry point. Launched by the + running service's updater after downloading and + SHA256-verifying a new release from Gitea. Do + not invoke manually. -h, --help Show this help. -V, --version Show version. @@ -191,4 +213,24 @@ mod tests { fn unknown_arg() { assert!(parse(&["--no-such-flag"]).is_err()); } + + #[test] + fn update_alone() { + assert_eq!(parse(&["--update"]).unwrap().action, Action::Update); + } + + #[test] + fn update_install_conflict() { + assert!(parse(&["--update", "--install"]).is_err()); + } + + #[test] + fn update_service_conflict() { + assert!(parse(&["--update", "--service"]).is_err()); + } + + #[test] + fn update_config_conflict() { + assert!(parse(&["--update", "--config", "BLOB"]).is_err()); + } } diff --git a/src/main.rs b/src/main.rs index 1688fd6..225d8bf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -90,10 +90,40 @@ fn main() { Action::Service => "service", Action::Server => "server", Action::Cm => "cm", + Action::Update => "update", Action::ConfigOnly | Action::None => "hello-agent", }; init_logging(mode); + // --update is the self-replacement re-entry: the running service's + // updater downloads a new hello-agent.exe to %TEMP%, verifies its + // SHA256, then launches `\hello-agent.exe --update` as an + // elevated child. We are that child — `current_exe()` is the staged + // new binary, and our only job is to copy ourselves over the + // installed location and restart the service. Do it before the + // config-import dance below so a corrupt-on-disk config can't block + // an update from going through. + if parsed.action == Action::Update { + #[cfg(target_os = "windows")] + { + match librustdesk::platform::update_me(false) { + Ok(()) => { + log::info!("hello-agent: --update completed"); + } + Err(e) => { + log::error!("hello-agent: --update failed: {e:#}"); + std::process::exit(1); + } + } + } + #[cfg(not(target_os = "windows"))] + { + eprintln!("hello-agent: --update is Windows-only."); + std::process::exit(1); + } + return; + } + // --config is allowed to combine with --install (one-line MDM deploy) // but on its own is a separate operation. Apply it first so --install // sees the populated config. @@ -172,6 +202,11 @@ fn main() { // can watch logs. Production deployments use --install + --service. run_server(); } + Action::Update => { + // Handled in the early-return block above (before config-import). + // The match has to cover this variant for exhaustiveness. + unreachable!("Action::Update is dispatched before this match"); + } } } diff --git a/vendor/rustdesk/src/common.rs b/vendor/rustdesk/src/common.rs index 69e3ec3..dc2dff2 100644 --- a/vendor/rustdesk/src/common.rs +++ b/vendor/rustdesk/src/common.rs @@ -94,11 +94,24 @@ pub mod input { lazy_static::lazy_static! { pub static ref SOFTWARE_UPDATE_URL: Arc> = Default::default(); + // hello-agent local patch: assets resolved by the Gitea-backed + // `do_check_software_update`. `SOFTWARE_UPDATE_URL` is kept holding the + // human-facing tag URL (so `ui_interface::get_new_version` still works + // by rsplit('/')) while the actual binary + sha256 download URLs live + // here and are consumed by `updater::check_update`. None = no update. + pub static ref SOFTWARE_UPDATE_ASSETS: Arc>> = Default::default(); pub static ref DEVICE_ID: Arc> = Default::default(); pub static ref DEVICE_NAME: Arc> = Default::default(); static ref PUBLIC_IPV6_ADDR: Arc, Option)>> = Default::default(); } +#[derive(Debug, Clone)] +pub struct UpdateAssets { + pub binary_url: String, + pub binary_name: String, + pub sha256_url: String, +} + lazy_static::lazy_static! { // Is server process, with "--server" args static ref IS_SERVER: bool = std::env::args().nth(1) == Some("--server".to_owned()); @@ -949,19 +962,45 @@ pub fn check_software_update() { } } -// No need to check `danger_accept_invalid_cert` for now. -// Because the url is always `https://api.rustdesk.com/version/latest`. +// hello-agent local patch: instead of POSTing to api.rustdesk.com (the +// upstream endpoint that resolves the latest stock-RustDesk release), this +// queries the Gitea Releases API on the hello-agent repo and resolves +// the binary + sha256 asset URLs of the latest release. The original +// rustdesk-api code path is intentionally gone: a stock-RustDesk auto-update +// would happily replace hello-agent's installation with vanilla rustdesk. +// The product version compared against the release tag is +// `hbb_common::config::AGENT_VERSION` (populated from `CARGO_PKG_VERSION` +// in hello-agent's `main`) — not `crate::VERSION`, which is the embedded +// rustdesk core version and would always be much higher than hello-agent's +// release tags, making the updater think no update is ever available. +const HELLO_AGENT_RELEASES_API: &str = + "https://gitea.cstudio.ch/api/v1/repos/mike/hello-agent/releases/latest"; +const HELLO_AGENT_TAG_URL_PREFIX: &str = + "https://gitea.cstudio.ch/mike/hello-agent/releases/tag"; + +#[derive(Debug, serde::Deserialize)] +struct GiteaRelease { + tag_name: String, + #[serde(default)] + assets: Vec, +} + +#[derive(Debug, serde::Deserialize)] +struct GiteaAsset { + name: String, + browser_download_url: String, +} + #[tokio::main(flavor = "current_thread")] pub async fn do_check_software_update() -> hbb_common::ResultType<()> { - let (request, url) = - hbb_common::version_check_request(hbb_common::VER_TYPE_RUSTDESK_CLIENT.to_string()); + let url = HELLO_AGENT_RELEASES_API; let proxy_conf = Config::get_socks(); - let tls_url = get_url_for_tls(&url, &proxy_conf); + let tls_url = get_url_for_tls(url, &proxy_conf); let tls_type = get_cached_tls_type(tls_url); let is_tls_not_cached = tls_type.is_none(); let tls_type = tls_type.unwrap_or(TlsType::Rustls); let client = create_http_client_async(tls_type, false); - let latest_release_response = match client.post(&url).json(&request).send().await { + let response = match client.get(url).send().await { Ok(resp) => { upsert_tls_cache(tls_url, tls_type, false); resp @@ -970,7 +1009,7 @@ pub async fn do_check_software_update() -> hbb_common::ResultType<()> { if is_tls_not_cached && err.is_request() { let tls_type = TlsType::NativeTls; let client = create_http_client_async(tls_type, false); - let resp = client.post(&url).json(&request).send().await?; + let resp = client.get(url).send().await?; upsert_tls_cache(tls_url, tls_type, false); resp } else { @@ -978,24 +1017,63 @@ pub async fn do_check_software_update() -> hbb_common::ResultType<()> { } } }; - let bytes = latest_release_response.bytes().await?; - let resp: hbb_common::VersionCheckResponse = serde_json::from_slice(&bytes)?; - let response_url = resp.url; - let latest_release_version = response_url.rsplit('/').next().unwrap_or_default(); + if !response.status().is_success() { + bail!("Gitea releases API returned HTTP {}", response.status()); + } + let bytes = response.bytes().await?; + let release: GiteaRelease = serde_json::from_slice(&bytes)?; - if get_version_number(&latest_release_version) > get_version_number(crate::VERSION) { - #[cfg(feature = "flutter")] - { - let mut m = HashMap::new(); - m.insert("name", "check_software_update_finish"); - m.insert("url", &response_url); - if let Ok(data) = serde_json::to_string(&m) { - let _ = crate::flutter::push_global_event(crate::flutter::APP_TYPE_MAIN, data); - } - } - *SOFTWARE_UPDATE_URL.lock().unwrap() = response_url; + let latest_version = release.tag_name.trim_start_matches('v'); + let current_version = hbb_common::config::AGENT_VERSION.read().unwrap().clone(); + let current_version = if current_version.is_empty() { + crate::VERSION.to_owned() } else { + current_version + }; + if get_version_number(latest_version) <= get_version_number(¤t_version) { *SOFTWARE_UPDATE_URL.lock().unwrap() = "".to_string(); + *SOFTWARE_UPDATE_ASSETS.lock().unwrap() = None; + return Ok(()); + } + + // Pick the Windows binary asset and its SHA256 companion. The release + // is expected to carry a `*.exe` (or `*.exe.signed`) and a matching + // `*.sha256` file. We don't pair them by name — there should only be + // one of each per release. + let binary = release.assets.iter().find(|a| { + let n = a.name.to_lowercase(); + (n.ends_with(".exe") || n.ends_with(".exe.signed")) && !n.ends_with(".sha256") + }); + let sha256 = release + .assets + .iter() + .find(|a| a.name.to_lowercase().ends_with(".sha256")); + let (Some(binary), Some(sha256)) = (binary, sha256) else { + log::warn!( + "hello-agent release {} is missing a binary and/or .sha256 asset", + release.tag_name + ); + *SOFTWARE_UPDATE_URL.lock().unwrap() = "".to_string(); + *SOFTWARE_UPDATE_ASSETS.lock().unwrap() = None; + return Ok(()); + }; + + let tag_url = format!("{}/{}", HELLO_AGENT_TAG_URL_PREFIX, release.tag_name); + *SOFTWARE_UPDATE_URL.lock().unwrap() = tag_url.clone(); + *SOFTWARE_UPDATE_ASSETS.lock().unwrap() = Some(UpdateAssets { + binary_url: binary.browser_download_url.clone(), + binary_name: binary.name.clone(), + sha256_url: sha256.browser_download_url.clone(), + }); + + #[cfg(feature = "flutter")] + { + let mut m = HashMap::new(); + m.insert("name", "check_software_update_finish"); + m.insert("url", &tag_url); + if let Ok(data) = serde_json::to_string(&m) { + let _ = crate::flutter::push_global_event(crate::flutter::APP_TYPE_MAIN, data); + } } Ok(()) } diff --git a/vendor/rustdesk/src/platform/windows.rs b/vendor/rustdesk/src/platform/windows.rs index 4c09bbe..90e0770 100644 --- a/vendor/rustdesk/src/platform/windows.rs +++ b/vendor/rustdesk/src/platform/windows.rs @@ -3110,21 +3110,27 @@ reg add {subkey} /f /v EstimatedSize /t REG_DWORD /d {size} ) } + // hello-agent local patch: only refresh the Add/Remove Programs entry if + // the install path actually created one. Hello-agent's `--install` + // (`src/service.rs::install`) does not write to + // `HKLM\...\Uninstall\` — the agent is a headless service + // that intentionally doesn't appear in Add/Remove Programs (there's + // nothing meaningful to uninstall through the shell-integrated UI; + // operators run `hello-agent.exe --uninstall`). Without this guard, + // the upstream `update_me` would *create* the uninstall key on every + // update — and then leave it behind as an orphan, since + // `service::uninstall()` doesn't remove it either. Upstream rustdesk's + // `install_me` does write the key, so stock-rustdesk installs continue + // to get their version display refreshed as before. + let subkey_exists = |sk: &str| { + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + hklm.open_subkey(sk.replace("HKEY_LOCAL_MACHINE\\", "")) + .is_ok() + }; let reg_cmd = { - let reg_cmd_main = get_reg_cmd( - &subkey, - is_msi, - &display_icon, - &version, - &build_date, - &version_major, - &version_minor, - &version_build, - size, - ); - let reg_cmd_msi = if let Some(reg_msi_key) = get_reg_msi_key(&subkey, is_msi) { + let reg_cmd_main = if subkey_exists(&subkey) { get_reg_cmd( - ®_msi_key, + &subkey, is_msi, &display_icon, &version, @@ -3137,6 +3143,25 @@ reg add {subkey} /f /v EstimatedSize /t REG_DWORD /d {size} } else { "".to_owned() }; + let reg_cmd_msi = if let Some(reg_msi_key) = get_reg_msi_key(&subkey, is_msi) { + if subkey_exists(®_msi_key) { + get_reg_cmd( + ®_msi_key, + is_msi, + &display_icon, + &version, + &build_date, + &version_major, + &version_minor, + &version_build, + size, + ) + } else { + "".to_owned() + } + } else { + "".to_owned() + }; format!("{}{}", reg_cmd_main, reg_cmd_msi) }; diff --git a/vendor/rustdesk/src/updater.rs b/vendor/rustdesk/src/updater.rs index 357f111..180d82e 100644 --- a/vendor/rustdesk/src/updater.rs +++ b/vendor/rustdesk/src/updater.rs @@ -118,8 +118,16 @@ fn start_auto_update_check_(rx_msg: Receiver) { } fn check_update(manually: bool) -> ResultType<()> { + // hello-agent local patch: `is_msi_installed()` reads HKLM\...\Uninstall\HelloAgent + // and errors when the uninstall key (or `WindowsInstaller` value) is + // absent, which is the common case for a hello-agent install. The + // upstream code used `?` here, propagating the registry error and + // killing the update before our Gitea check ever ran. Swallow the + // error: `update_msi` will be `false` (correct — hello-agent is a + // custom client so the MSI branch is never the right one anyway). #[cfg(target_os = "windows")] - let update_msi = crate::platform::is_msi_installed()? && !crate::is_custom_client(); + let update_msi = + crate::platform::is_msi_installed().unwrap_or(false) && !crate::is_custom_client(); if !(manually || config::Config::get_bool_option(config::keys::OPTION_ALLOW_AUTO_UPDATE)) { return Ok(()); } @@ -128,70 +136,102 @@ fn check_update(manually: bool) -> ResultType<()> { return Ok(()); } - let update_url = crate::common::SOFTWARE_UPDATE_URL.lock().unwrap().clone(); - if update_url.is_empty() { + // hello-agent local patch: the upstream code reconstructed the download + // URL from a GitHub "tag" URL and a hard-coded filename pattern. Both + // are now resolved by `do_check_software_update` against the Gitea + // Releases API and exposed via `SOFTWARE_UPDATE_ASSETS`. + let assets = crate::common::SOFTWARE_UPDATE_ASSETS.lock().unwrap().clone(); + let Some(assets) = assets else { log::debug!("No update available."); - } else { - let download_url = update_url.replace("tag", "download"); - let version = download_url.split('/').last().unwrap_or_default(); - #[cfg(target_os = "windows")] - let download_url = if cfg!(feature = "flutter") { - format!( - "{}/rustdesk-{}-x86_64.{}", - download_url, - version, - if update_msi { "msi" } else { "exe" } - ) + return Ok(()); + }; + let download_url = assets.binary_url; + let sha256_url = assets.sha256_url; + let version = assets.binary_name; + log::debug!("New version available: {}", &version); + let client = create_http_client_with_url(&download_url); + let Some(file_path) = get_download_file_from_url(&download_url) else { + bail!("Failed to get the file path from the URL: {}", download_url); + }; + let mut is_file_exists = false; + if file_path.exists() { + // Check if the file size is the same as the server file size + // If the file size is the same, we don't need to download it again. + let file_size = std::fs::metadata(&file_path)?.len(); + let response = client.head(&download_url).send()?; + if !response.status().is_success() { + bail!("Failed to get the file size: {}", response.status()); + } + let total_size = response + .headers() + .get(reqwest::header::CONTENT_LENGTH) + .and_then(|ct_len| ct_len.to_str().ok()) + .and_then(|ct_len| ct_len.parse::().ok()); + let Some(total_size) = total_size else { + bail!("Failed to get content length"); + }; + if file_size == total_size { + is_file_exists = true; } else { - format!("{}/rustdesk-{}-x86-sciter.exe", download_url, version) - }; - log::debug!("New version available: {}", &version); - let client = create_http_client_with_url(&download_url); - let Some(file_path) = get_download_file_from_url(&download_url) else { - bail!("Failed to get the file path from the URL: {}", download_url); - }; - let mut is_file_exists = false; - if file_path.exists() { - // Check if the file size is the same as the server file size - // If the file size is the same, we don't need to download it again. - let file_size = std::fs::metadata(&file_path)?.len(); - let response = client.head(&download_url).send()?; - if !response.status().is_success() { - bail!("Failed to get the file size: {}", response.status()); - } - let total_size = response - .headers() - .get(reqwest::header::CONTENT_LENGTH) - .and_then(|ct_len| ct_len.to_str().ok()) - .and_then(|ct_len| ct_len.parse::().ok()); - let Some(total_size) = total_size else { - bail!("Failed to get content length"); - }; - if file_size == total_size { - is_file_exists = true; - } else { - std::fs::remove_file(&file_path)?; - } + std::fs::remove_file(&file_path)?; } - if !is_file_exists { - let response = client.get(&download_url).send()?; - if !response.status().is_success() { - bail!( - "Failed to download the new version file: {}", - response.status() - ); - } - let file_data = response.bytes()?; - let mut file = std::fs::File::create(&file_path)?; - file.write_all(&file_data)?; - } - // We have checked if the `conns` is empty before, but we need to check again. - // No need to care about the downloaded file here, because it's rare case that the `conns` are empty - // before the download, but not empty after the download. - if has_no_active_conns() { - #[cfg(target_os = "windows")] - update_new_version(update_msi, &version, &file_path); + } + if !is_file_exists { + let response = client.get(&download_url).send()?; + if !response.status().is_success() { + bail!( + "Failed to download the new version file: {}", + response.status() + ); } + let file_data = response.bytes()?; + let mut file = std::fs::File::create(&file_path)?; + file.write_all(&file_data)?; + } + // hello-agent local patch: verify the downloaded binary's SHA256 against + // the `.sha256` companion asset published by the same Gitea release + // before launching. We're about to run this file with elevated rights — + // a mismatch means something went wrong in transit or the release was + // tampered with, and we must NOT launch it. The expected-hash file is a + // standard `sha256sum` output (` `) or just ``. + let sha_client = create_http_client_with_url(&sha256_url); + let sha_resp = sha_client.get(&sha256_url).send()?; + if !sha_resp.status().is_success() { + let _ = std::fs::remove_file(&file_path); + bail!("Failed to download SHA256 file: {}", sha_resp.status()); + } + let sha_text = sha_resp.text()?; + let expected = sha_text + .split_whitespace() + .next() + .unwrap_or_default() + .to_lowercase(); + if expected.len() != 64 || !expected.chars().all(|c| c.is_ascii_hexdigit()) { + let _ = std::fs::remove_file(&file_path); + bail!("Malformed SHA256 file: {:?}", sha_text); + } + let file_bytes = std::fs::read(&file_path)?; + use sha2::Digest as _; + let mut hasher = sha2::Sha256::new(); + hasher.update(&file_bytes); + let actual = hex::encode(hasher.finalize()); + if actual != expected { + log::error!( + "SHA256 mismatch for {}: expected {}, got {}", + version, + expected, + actual + ); + let _ = std::fs::remove_file(&file_path); + bail!("SHA256 verification failed"); + } + log::info!("SHA256 verified for {}", version); + // We have checked if the `conns` is empty before, but we need to check again. + // No need to care about the downloaded file here, because it's rare case that the `conns` are empty + // before the download, but not empty after the download. + if has_no_active_conns() { + #[cfg(target_os = "windows")] + update_new_version(update_msi, &version, &file_path); } Ok(()) } diff --git a/vendor/rustdesk/src/version.rs b/vendor/rustdesk/src/version.rs index 5018e1d..aaa36f9 100644 --- a/vendor/rustdesk/src/version.rs +++ b/vendor/rustdesk/src/version.rs @@ -1,3 +1,3 @@ pub const VERSION: &str = "1.4.6"; #[allow(dead_code)] -pub const BUILD_DATE: &str = "2026-05-09 10:43"; +pub const BUILD_DATE: &str = "2026-05-21 13:02";