use crate::{common::do_check_software_update, hbbs_http::create_http_client_with_url_strict}; use hbb_common::{bail, config, log, ResultType}; use std::{ io::Write, path::{Component, Path, PathBuf}, sync::{ atomic::{AtomicUsize, Ordering}, mpsc::{channel, Receiver, Sender}, Mutex, }, time::{Duration, Instant}, }; #[cfg(target_os = "macos")] use std::os::{ fd::AsRawFd, unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}, }; #[cfg(target_os = "macos")] struct MacUpdateLock { _file: std::fs::File, } #[cfg(target_os = "macos")] fn acquire_mac_update_lock() -> ResultType { let path = std::path::PathBuf::from("/var/run/rustdesk-update.lock"); let handle = std::fs::OpenOptions::new() .read(true) .write(true) .create(true) .custom_flags(hbb_common::libc::O_NOFOLLOW | hbb_common::libc::O_CLOEXEC) .open(&path)?; let metadata = handle.metadata()?; if !metadata.file_type().is_file() || metadata.uid() != 0 { bail!("[root-update] update lock is not a root-owned regular file"); } handle.set_permissions(std::fs::Permissions::from_mode(0o600))?; // Keep the descriptor open through update preparation and detached-script // launch. O_CLOEXEC means this lock does not cover the detached bundle // swap; flock is released when this guard is dropped or the process exits. let lock_result = unsafe { hbb_common::libc::flock( handle.as_raw_fd(), hbb_common::libc::LOCK_EX | hbb_common::libc::LOCK_NB, ) }; if lock_result != 0 { let err = std::io::Error::last_os_error(); if err.kind() == std::io::ErrorKind::WouldBlock { bail!("[root-update] another update is already running"); } return Err(err.into()); } Ok(MacUpdateLock { _file: handle }) } enum UpdateMsg { CheckUpdate, Exit, } lazy_static::lazy_static! { static ref TX_MSG : Mutex> = Mutex::new(start_auto_update_check()); } static CONTROLLING_SESSION_COUNT: AtomicUsize = AtomicUsize::new(0); /// Initial wait after startup before the first update check (30 seconds). pub const INITIAL_CHECK_DELAY: Duration = Duration::from_secs(30); /// One full day — default interval between update checks. pub const DUR_ONE_DAY: Duration = Duration::from_secs(60 * 60 * 24); /// Minimum interval between consecutive update checks (10 minutes). pub const MIN_INTERVAL: Duration = Duration::from_secs(60 * 10); /// Retry interval when an update check fails or a session is active (30 minutes). pub const RETRY_INTERVAL: Duration = Duration::from_secs(60 * 30); pub fn update_controlling_session_count(count: usize) { CONTROLLING_SESSION_COUNT.store(count, Ordering::SeqCst); } #[allow(dead_code)] pub fn start_auto_update() { let _sender = TX_MSG.lock().unwrap(); } #[allow(dead_code)] pub fn manually_check_update() -> ResultType<()> { let sender = TX_MSG.lock().unwrap(); sender.send(UpdateMsg::CheckUpdate)?; Ok(()) } #[allow(dead_code)] pub fn stop_auto_update() { let sender = TX_MSG.lock().unwrap(); sender.send(UpdateMsg::Exit).unwrap_or_default(); } #[inline] /// Returns true when there are no active incoming or outgoing connections. /// Used to avoid updating while a remote session is in progress. pub fn has_no_active_conns() -> bool { let conns = crate::Connection::alive_conns(); conns.is_empty() && has_no_controlling_conns() } #[cfg(any(not(target_os = "windows"), feature = "flutter"))] fn has_no_controlling_conns() -> bool { CONTROLLING_SESSION_COUNT.load(Ordering::SeqCst) == 0 } #[cfg(not(any(not(target_os = "windows"), feature = "flutter")))] fn has_no_controlling_conns() -> bool { let app_exe = format!("{}.exe", crate::get_app_name().to_lowercase()); for arg in [ "--connect", "--play", "--file-transfer", "--view-camera", "--port-forward", "--rdp", ] { if !crate::platform::get_pids_of_process_with_first_arg(&app_exe, arg).is_empty() { return false; } } true } fn start_auto_update_check() -> Sender { let (tx, rx) = channel(); std::thread::spawn(move || start_auto_update_check_(rx)); return tx; } fn start_auto_update_check_(rx_msg: Receiver) { std::thread::sleep(INITIAL_CHECK_DELAY); if let Err(e) = check_update(false) { log::error!("Error checking for updates: {}", e); } let mut last_check_time = Instant::now(); let mut check_interval = DUR_ONE_DAY; loop { let recv_res = rx_msg.recv_timeout(check_interval); match &recv_res { Ok(UpdateMsg::CheckUpdate) | Err(_) => { if last_check_time.elapsed() < MIN_INTERVAL { // log::debug!("Update check skipped due to minimum interval."); continue; } // Don't check update if there are alive connections. if !has_no_active_conns() { check_interval = RETRY_INTERVAL; continue; } if let Err(e) = check_update(matches!(recv_res, Ok(UpdateMsg::CheckUpdate))) { log::error!("Error checking for updates: {}", e); check_interval = RETRY_INTERVAL; } else { last_check_time = Instant::now(); check_interval = DUR_ONE_DAY; } } Ok(UpdateMsg::Exit) => break, } } } fn check_update(manually: bool) -> ResultType<()> { // On macOS, auto-update is handled by check_update_as_root() in the service process. // The shared check_update() path is only used for manual update checks from the GUI. #[cfg(target_os = "macos")] if !manually { return Ok(()); } #[cfg(target_os = "windows")] let update_msi = crate::platform::is_msi_installed()? && !crate::is_custom_client(); if !(manually || config::Config::get_bool_option(config::keys::OPTION_ALLOW_AUTO_UPDATE)) { return Ok(()); } if do_check_software_update().is_err() { // ignore return Ok(()); } let update_url = crate::common::SOFTWARE_UPDATE_URL.lock().unwrap().clone(); if update_url.is_empty() { 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") { let Some(arch) = crate::platform::windows::release_arch_suffix() else { bail!( "Unsupported Windows release architecture: {}", std::env::consts::ARCH ); }; format!( "{}/rustdesk-{}-{}.{}", download_url, version, arch, if update_msi { "msi" } else { "exe" } ) } else { format!("{}/rustdesk-{}-x86-sciter.exe", download_url, version) }; log::debug!("New version available: {}", &version); let client = create_http_client_with_url_strict(&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)?; } } 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); } } Ok(()) } #[cfg(target_os = "windows")] fn update_new_version(update_msi: bool, version: &str, file_path: &PathBuf) { log::debug!( "New version is downloaded, update begin, update msi: {update_msi}, version: {version}, file: {:?}", file_path.to_str() ); if let Some(p) = file_path.to_str() { if let Some(session_id) = crate::platform::get_current_process_session_id() { if update_msi { match crate::platform::update_me_msi(p, true) { Ok(_) => { log::debug!("New version \"{}\" updated.", version); } Err(e) => { log::error!( "Failed to install the new msi version \"{}\": {}", version, e ); std::fs::remove_file(&file_path).ok(); } } } else { let custom_client_staging_dir = if crate::is_custom_client() { let custom_client_staging_dir = crate::platform::get_custom_client_staging_dir(); if let Err(e) = crate::platform::handle_custom_client_staging_dir_before_update( &custom_client_staging_dir, ) { log::error!( "Failed to handle custom client staging dir before update: {}", e ); std::fs::remove_file(&file_path).ok(); return; } Some(custom_client_staging_dir) } else { // Clean up any residual staging directory from previous custom client let staging_dir = crate::platform::get_custom_client_staging_dir(); hbb_common::allow_err!(crate::platform::remove_custom_client_staging_dir( &staging_dir )); None }; let update_launched = match crate::platform::launch_privileged_process( session_id, &format!("{} --update", p), ) { Ok(h) => { if h.is_null() { log::error!("Failed to update to the new version: {}", version); false } else { log::debug!("New version \"{}\" is launched.", version); true } } Err(e) => { log::error!("Failed to run the new version: {}", e); false } }; if !update_launched { if let Some(dir) = custom_client_staging_dir { hbb_common::allow_err!(crate::platform::remove_custom_client_staging_dir( &dir )); } std::fs::remove_file(&file_path).ok(); } } } else { log::error!( "Failed to get the current process session id, Error {}", std::io::Error::last_os_error() ); std::fs::remove_file(&file_path).ok(); } } else { // unreachable!() log::error!( "Failed to convert the file path to string: {}", file_path.display() ); } } pub fn get_update_download_file_from_url(url: &str) -> Option { let parsed = url::Url::parse(url).ok()?; // Check the raw prefix before Url normalizes default ports. if !url.starts_with("https://github.com/") || parsed.scheme() != "https" || parsed.host_str() != Some("github.com") || !parsed.username().is_empty() || parsed.password().is_some() || parsed.port().is_some() || parsed.query().is_some() || parsed.fragment().is_some() { return None; } let mut segments = parsed.path_segments()?; let owner = segments.next()?; let repo = segments.next()?; let releases = segments.next()?; let download = segments.next()?; let tag = segments.next()?; let filename = segments.next()?; if owner != "rustdesk" || repo != "rustdesk" || releases != "releases" || download != "download" || tag.is_empty() || segments.next().is_some() || !is_plain_update_filename(filename) { return None; } Some(std::env::temp_dir().join(filename)) } fn is_plain_update_filename(filename: &str) -> bool { if filename.is_empty() || filename.contains('/') || filename.contains('\\') || filename.contains(':') { return false; } let mut components = Path::new(filename).components(); matches!( components.next(), Some(Component::Normal(name)) if name.to_str() == Some(filename) ) && components.next().is_none() } pub fn get_download_file_from_url(url: &str) -> Option { get_update_download_file_from_url(url) } /// Queries all active connections (remote, file-transfer, port-forward, camera, terminal) /// from every logged-in user's --server process via IPC. /// The root service cannot read connection state directly since connections /// live in user --server processes. Handles fast user switching by querying /// all GUI users, including the login-window server at UID 0. Falls back to /// false (assumes sessions active) on any IPC error to avoid updating during /// an unknown session state. #[cfg(target_os = "macos")] pub fn has_no_active_conns_ipc() -> bool { let rt = match hbb_common::tokio::runtime::Runtime::new() { Ok(rt) => rt, Err(_) => return false, }; rt.block_on(async { // Use the same GUI-domain-filtered UID set as the update script. // Shell-only SSH/TTY users are excluded, while an empty GUI set maps // to UID 0 so the LoginWindow server is queried rather than assumed idle. let uids = crate::platform::get_logged_in_uids(); // Check each user's server — fail closed if any has active connections for uid in uids { if let Ok(mut conn) = crate::ipc::connect_for_uid(1000, uid, "").await { if conn.send(&crate::ipc::Data::HasNoActiveConns(None)).await.is_ok() { match conn.next_timeout(1000).await { Ok(Some(crate::ipc::Data::HasNoActiveConns(Some(true)))) => { // Explicit no active connections — safe to continue } Ok(Some(crate::ipc::Data::HasNoActiveConns(Some(false)))) => { return false; // Explicit active connections } _ => { return false; // Timeout/error/unexpected — fail closed } } } else { return false; // Send failed — fail closed } } else { return false; // Connection failed — fail closed } } true // All users explicitly confirmed no active connections }) } #[cfg(target_os = "macos")] fn wait_for_failed_update_retry() { const FAILURE_MARKER: &str = "/var/root/.rustdeskupdate_failed"; let marker = std::path::Path::new(FAILURE_MARKER); if !marker.exists() { return; } // The updater script records failure immediately before launchd restarts // the old daemon. Preserve the retry deadline across that restart instead // of consuming the marker and retrying the same broken release in 30 sec. let remaining = std::fs::metadata(marker) .and_then(|metadata| metadata.modified()) .ok() .and_then(|modified| { std::time::SystemTime::now() .duration_since(modified) .ok() }) .map(|elapsed| RETRY_INTERVAL.saturating_sub(elapsed)) .unwrap_or(RETRY_INTERVAL); if !remaining.is_zero() { log::info!( "[root-update] Previous update failed; retrying in {} seconds.", remaining.as_secs() ); std::thread::sleep(remaining); } match std::fs::remove_file(marker) { Ok(()) => log::info!("[root-update] Previous update retry interval elapsed."), Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} Err(err) => log::warn!("[root-update] Failed to clear failure marker: {}", err), } } /// Starts the background silent auto-update scheduler for macOS. /// Called from `start_os_service()` which runs as root via LaunchDaemon. #[cfg(target_os = "macos")] pub fn start_auto_update_macos() { let spawn_result = std::thread::Builder::new() .name("rustdesk-auto-update".to_owned()) .spawn(|| { log::info!("[root-update] Auto-update scheduler thread started."); std::thread::sleep(INITIAL_CHECK_DELAY); wait_for_failed_update_retry(); let mut interval = DUR_ONE_DAY; loop { log::info!("[root-update] Running scheduled update check..."); let no_active_conns = has_no_active_conns_ipc(); if !no_active_conns { log::info!("[root-update] Active session in progress, retrying in 10 min."); interval = MIN_INTERVAL; } else { match check_update_as_root() { Ok(update_started) => { if update_started { // The replacement script is detached and may fail // after this process returns. Always retry at the // failure interval until the new daemon replaces us. interval = RETRY_INTERVAL; } else { interval = DUR_ONE_DAY; } } Err(e) => { log::error!("[root-update] Update check failed: {}", e); interval = RETRY_INTERVAL; } } } std::thread::sleep(interval); } }); if let Err(err) = spawn_result { log::error!("[root-update] Failed to start scheduler thread: {}", err); } } #[cfg(target_os = "macos")] pub fn check_update_as_root() -> ResultType { let _update_lock = acquire_mac_update_lock()?; // Allow-auto-update setting if !config::Config::get_bool_option(config::keys::OPTION_ALLOW_AUTO_UPDATE) { log::info!("[root-update] Auto update is disabled, skipping."); return Ok(false); } if crate::is_custom_client() { log::info!("[root-update] Custom client detected, skipping stock update."); return Ok(false); } // Clean up only old temp dirs from previous failed updates. The detached // installer keeps using its update directory after this process exits and // releases the advisory lock, so a newly-started daemon must not remove a // directory that still belongs to the active transaction. if let Ok(entries) = std::fs::read_dir("/tmp") { for entry in entries.flatten() { let name = entry.file_name(); let name_str = name.to_string_lossy(); if name_str.starts_with(".rustdeskupdate-root-") || name_str.starts_with(".rustdeskdownload-") { let path = entry.path(); let Ok(metadata) = std::fs::symlink_metadata(&path) else { continue; }; let mode = metadata.mode() & 0o7777; let is_stale = metadata .modified() .ok() .and_then(|modified| std::time::SystemTime::now().duration_since(modified).ok()) .is_some_and(|age| age >= RETRY_INTERVAL); if metadata.file_type().is_dir() && metadata.uid() == 0 && mode == 0o700 && is_stale { if let Err(err) = std::fs::remove_dir_all(&path) { log::warn!( "[root-update] Failed to remove stale temp dir {}: {}", path.display(), err ); } } } } } if let Err(e) = do_check_software_update() { bail!("[root-update] Failed to check for software update: {}", e); } let update_url = crate::common::SOFTWARE_UPDATE_URL.lock().unwrap().clone(); if update_url.is_empty() { log::info!("[root-update] No update available."); return Ok(false); } let download_url = update_url.replace("tag", "download"); let version = download_url.split('/').last().unwrap_or_default().to_string(); let arch = if std::env::consts::ARCH == "aarch64" { "aarch64" } else { "x86_64" }; let dmg_url = format!("{}/rustdesk-{}-{}.dmg", download_url, version, arch); log::info!("[root-update] New version: {}, downloading from {}", version, dmg_url); // Validate URL against GitHub release allowlist before downloading as root let Some(file_path_validated) = get_update_download_file_from_url(&dmg_url) else { bail!("[root-update] URL failed allowlist check: {}", dmg_url); }; drop(file_path_validated); let client = create_http_client_with_url_strict(&dmg_url)?; // Use mktemp so a local user cannot pre-create a predictable path and // permanently deny updates for a reused service PID. let private_tmp_output = std::process::Command::new("/usr/bin/mktemp") .args(["-d", "/tmp/.rustdeskdownload-XXXXXX"]) .output()?; if !private_tmp_output.status.success() { bail!( "[root-update] Failed to create private download directory: {}", String::from_utf8_lossy(&private_tmp_output.stderr).trim() ); } let private_tmp = String::from_utf8(private_tmp_output.stdout) .map_err(|err| hbb_common::anyhow::anyhow!("[root-update] mktemp output error: {}", err))? .trim() .to_owned(); if private_tmp.is_empty() { bail!("[root-update] mktemp returned an empty download directory"); } { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&private_tmp, std::fs::Permissions::from_mode(0o700))?; } let filename = dmg_url.split('/').last().unwrap_or("rustdesk.dmg"); let file_path = std::path::PathBuf::from(format!("{}/{}", private_tmp, filename)); let tmp_path = file_path.to_string_lossy().to_string(); // Download let mut response = client.get(&dmg_url).send()?; if !response.status().is_success() { let _ = std::fs::remove_dir_all(&private_tmp); bail!("[root-update] Failed to download: {}", response.status()); } // Create file exclusively (O_EXCL) and stream response directly into it { let mut file = std::fs::OpenOptions::new() .write(true) .create_new(true) .open(&file_path) .map_err(|e| { let _ = std::fs::remove_dir_all(&private_tmp); e })?; std::io::copy(&mut response, &mut file) .map_err(|e| { let _ = std::fs::remove_dir_all(&private_tmp); e })?; } log::info!("[root-update] Downloaded to {}", tmp_path); // Recheck active sessions before installing — download can take minutes if !has_no_active_conns_ipc() { if let Err(e) = std::fs::remove_dir_all(&private_tmp) { log::warn!("[root-update] Failed to remove temp dir {}: {}", private_tmp, e); } bail!("[root-update] Active session started during download, deferring update."); } // Install silently as root let result = crate::platform::update_from_dmg_as_root(&tmp_path, &version); // Clean up download directory if let Err(e) = std::fs::remove_dir_all(&private_tmp) { log::warn!("[root-update] Failed to remove temp dir {}: {}", private_tmp, e); } result.map(|_| true) } #[cfg(test)] mod tests { use super::get_download_file_from_url; #[test] fn update_download_file_accepts_expected_github_asset_urls() { let file = get_download_file_from_url( "https://github.com/rustdesk/rustdesk/releases/download/1.4.0/rustdesk-1.4.0-x86_64.dmg", ) .expect("valid GitHub release asset URL"); assert_eq!( file.file_name().and_then(|name| name.to_str()), Some("rustdesk-1.4.0-x86_64.dmg") ); } #[test] fn update_download_file_rejects_untrusted_or_malformed_urls() { for url in [ "http://github.com/rustdesk/rustdesk/releases/download/1/rustdesk.exe", "https://example.com/rustdesk.exe", "https://github.com/other/project/releases/download/1/rustdesk.exe", "https://github.com/rustdesk/rustdesk/releases/download/1/", "https://github.com/rustdesk/rustdesk/releases/download/1/nested/rustdesk.exe", "https://github.com/rustdesk/rustdesk/releases/download/1/C:rustdesk.exe", "https://user@github.com/rustdesk/rustdesk/releases/download/1/rustdesk.exe", "https://github.com:443/rustdesk/rustdesk/releases/download/1/rustdesk.exe", "https://github.com/rustdesk/rustdesk/releases/download/1/rustdesk.exe?download=1", "https://github.com/rustdesk/rustdesk/releases/download/1/rustdesk.exe#download", "not a url", ] { assert!(get_download_file_from_url(url).is_none(), "{url}"); } } }