diff --git a/Cargo.lock b/Cargo.lock index 5632ed6..44dae2a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3197,7 +3197,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hello-agent" -version = "0.1.1" +version = "0.1.2" dependencies = [ "anyhow", "env_logger 0.10.2", diff --git a/Cargo.toml b/Cargo.toml index 0383c3f..46627c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hello-agent" -version = "0.1.1" +version = "0.1.2" edition = "2021" rust-version = "1.75" description = "Headless RustDesk-protocol-compatible support agent for Windows" diff --git a/README.md b/README.md index b82f5f3..a7a886c 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ empty, so `--config` always wins. ``` hello-agent.exe --install │ - └──> creates Windows service "HelloAgent", binPath ends in --service + └──> creates Windows service "hello-agent", binPath ends in --service │ hello-agent.exe --service # Session 0, LocalSystem │ @@ -191,7 +191,7 @@ To pull updates from upstream RustDesk: ## Stale keys / supporter "stuck on connecting" -The agent's identity (`id`) and `key_pair` live in `HelloAgent.toml`. +The agent's identity (`id`) and `key_pair` live in `hello-agent.toml`. They're generated once on first run, registered with the rendezvous server, and re-used forever after. **If the rendezvous server's cached entry and the agent's local keypair drift apart, the encrypted handshake @@ -221,7 +221,7 @@ dir so the agent keypair survives an uninstall→reinstall cycle. To force a fresh keypair, also run after `--uninstall`: ``` -rmdir /s /q "%SystemRoot%\ServiceProfiles\LocalService\AppData\Roaming\HelloAgent" +rmdir /s /q "%SystemRoot%\ServiceProfiles\LocalService\AppData\Roaming\hello-agent" ``` …and then delete the device record from the admin UI as above. @@ -229,7 +229,7 @@ rmdir /s /q "%SystemRoot%\ServiceProfiles\LocalService\AppData\Roaming\HelloAgen ## Verifying end-to-end 1. Install: `hello-agent.exe --install --config ` from elevated PowerShell. -2. Confirm: `sc query HelloAgent` → `RUNNING`. +2. Confirm: `sc query hello-agent` → `RUNNING`. 3. From another machine running stock `rustdesk.exe`, enter the agent's ID and click Connect. 4. The agent's logged-in user sees `HelloAgent — Allow remote support?`. @@ -241,7 +241,7 @@ rmdir /s /q "%SystemRoot%\ServiceProfiles\LocalService\AppData\Roaming\HelloAgen `hbb_common` ships a single global, `APP_NAME`, that drives the location of every piece of on-disk state (config dir, log dir) and the prefix of every named pipe. Upstream defaults it to `"RustDesk"`. Hello-agent -rewrites it to `"HelloAgent"` as the very first line of `main()` — +rewrites it to `"hello-agent"` as the very first line of `main()` — identical to the write path the upstream Flutter build uses for OEM rebrands ([`read_custom_client`](vendor/rustdesk/src/common.rs)). Because `APP_NAME` is a `RwLock` read lazily on first use, doing the @@ -252,16 +252,16 @@ In practice that means: | What | Stock rustdesk | hello-agent | | --------------------------------- | ----------------------------------------- | ------------------------------------------------- | -| User-mode config / logs | `%APPDATA%\RustDesk\` | `%APPDATA%\HelloAgent\` | -| Service-mode config / logs | `…\LocalService\AppData\Roaming\RustDesk\`| `…\LocalService\AppData\Roaming\HelloAgent\` | -| Identity file (id + keypair) | `RustDesk.toml` | `HelloAgent.toml` | -| IPC pipe namespace | `\\.\pipe\RustDesk\query…` | `\\.\pipe\HelloAgent\query…` | -| Windows service name | `RustDesk` | `HelloAgent` | +| User-mode config / logs | `%APPDATA%\RustDesk\` | `%APPDATA%\hello-agent\` | +| Service-mode config / logs | `…\LocalService\AppData\Roaming\RustDesk\`| `…\LocalService\AppData\Roaming\hello-agent\` | +| Identity file (id + keypair) | `RustDesk.toml` | `hello-agent.toml` | +| IPC pipe namespace | `\\.\pipe\RustDesk\query…` | `\\.\pipe\hello-agent\query…` | +| Windows service name | `RustDesk` | `hello-agent` | | Install dir | `%ProgramFiles%\RustDesk\` | `%ProgramFiles%\hello-agent\` | The two binaries can therefore coexist on the same machine without clobbering each other's state. The override is set in -[`src/main.rs`](src/main.rs) (`pub const APP_NAME: &str = "HelloAgent"`) +[`src/main.rs`](src/main.rs) (`pub const APP_NAME: &str = "hello-agent"`) — change it there if you ever need to re-brand. ## Where logs go @@ -270,10 +270,10 @@ clobbering each other's state. The override is set in | Mode (CLI flag) | Effective user | Log dir | | --------------------- | ------------------------------- | ---------------------------------------------------------------------------------------- | -| `--install` / `--uninstall` | calling user (must be admin) | `%APPDATA%\HelloAgent\log\install\` (or `…\uninstall\`) | -| `--service` | LocalSystem (mirrored) | `%SystemRoot%\ServiceProfiles\LocalService\AppData\Roaming\HelloAgent\log\service\` | -| `--server` (worker) | LocalSystem (mirrored) | `%SystemRoot%\ServiceProfiles\LocalService\AppData\Roaming\HelloAgent\log\server\` | -| no flags (dev mode) | calling user | `%APPDATA%\HelloAgent\log\hello-agent\` | +| `--install` / `--uninstall` | calling user (must be admin) | `%APPDATA%\hello-agent\log\install\` (or `…\uninstall\`) | +| `--service` | LocalSystem (mirrored) | `%SystemRoot%\ServiceProfiles\LocalService\AppData\Roaming\hello-agent\log\service\` | +| `--server` (worker) | LocalSystem (mirrored) | `%SystemRoot%\ServiceProfiles\LocalService\AppData\Roaming\hello-agent\log\server\` | +| no flags (dev mode) | calling user | `%APPDATA%\hello-agent\log\hello-agent\` | The `cm_popup` module also writes a parallel diagnostic trace at `%TEMP%\hello-agent-cm.log` (kept around for debugging the IPC handshake; @@ -284,7 +284,7 @@ it duplicates info that's already in the main log). - ✅ Windows x64 (physical console *and* RDP sessions — the agent picks whichever session the user is actively using) - ✅ Coexists with stock RustDesk on the same box — config dir, log dir, - and named pipes are namespaced under `HelloAgent` rather than the + and named pipes are namespaced under `hello-agent` rather than the upstream default of `RustDesk` (see [Namespacing](#namespacing) below). The only residual contention is the optional direct-server port (TCP 21118) and LAN-discovery port (UDP 21119); both default to off, 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..33007c0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,7 +15,7 @@ // lazily from a `RwLock` whenever any path is computed (config dir, // log dir, named-pipe namespace, …), so setting it before any of those // initializers fire is enough to redirect all hbb_common state under -// `%APPDATA%\HelloAgent\` and the matching LocalService path. Identical +// `%APPDATA%\hello-agent\` and the matching LocalService path. Identical // to the `read_custom_client` write path the upstream Flutter build uses // for OEM rebrands. @@ -39,17 +39,28 @@ use cli::{Action, ParsedArgs}; /// Product name used to namespace all on-disk state and the IPC pipe path. /// Written into `hbb_common::config::APP_NAME` at the top of `main` so /// every subsequent path computation (config dir, log dir, named pipe) -/// targets `%APPDATA%\HelloAgent\` rather than the upstream default of +/// targets `%APPDATA%\hello-agent\` rather than the upstream default of /// `%APPDATA%\RustDesk\`. Must be set before any code touches a path — /// `hbb_common` initializes path globals lazily on first read. -pub const APP_NAME: &str = "HelloAgent"; +/// +/// Important: this value also drives upstream's installer lookup paths. +/// `librustdesk::platform::get_install_info` computes the expected install +/// dir as `%ProgramFiles%\` and the expected exe filename as +/// `.exe`. Keeping `APP_NAME` aligned with the lowercase-hyphenated +/// install path (`%ProgramFiles%\hello-agent\hello-agent.exe`) is what +/// makes `--update` (which delegates to `librustdesk::platform::update_me`) +/// find the binary it needs to replace, kill the right process by image +/// name, and rename the staged exe to `hello-agent.exe` after the copy. +/// Renaming this constant without renaming the install dir / exe will +/// silently break self-update. +pub const APP_NAME: &str = "hello-agent"; /// Set up logging. We delegate to `hbb_common::init_log`, which: /// * In **debug** builds: installs `env_logger` writing to stderr. /// * In **release** builds: installs `flexi_logger` writing to a rolling /// file under `/log//` — the SYSTEM service log ends -/// up at `%SystemRoot%\ServiceProfiles\LocalService\AppData\Roaming\HelloAgent\log\\` -/// and the user-mode log at `%APPDATA%\HelloAgent\log\\`. +/// up at `%SystemRoot%\ServiceProfiles\LocalService\AppData\Roaming\hello-agent\log\\` +/// and the user-mode log at `%APPDATA%\hello-agent\log\\`. /// /// The `mode` label segregates per-run-mode log files so service worker /// chatter doesn't tangle with --install diagnostics. `init_log` is @@ -65,7 +76,7 @@ fn main() { // we'd never recover. *hbb_common::config::APP_NAME.write().unwrap() = APP_NAME.to_owned(); // Identify ourselves to the rustdesk-server's /api/sysinfo endpoint - // so the admin Devices page can show "HelloAgent 0.1.0" instead of + // so the admin Devices page can show "hello-agent 0.1.0" instead of // the embedded rustdesk core version. These RwLocks are read once // per sysinfo upload by hbbs_http::sync; setting them here (before // start_server) ensures the very first upload carries the identity. @@ -90,10 +101,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. @@ -108,7 +149,14 @@ fn main() { // (or a prior install) already set custom-rendezvous-server, this is a // no-op. Without this, a bare `hello-agent.exe --install` would land // at an unconfigured agent that can't reach any server. - config_import::apply_defaults_if_empty(); + // + // Skipped for `--uninstall`: an uninstall flow has no business mutating + // the calling user's config, and otherwise we'd write defaults into + // %APPDATA% right before tearing the agent down. (`--update` is + // dispatched in the early-return block above and never reaches here.) + if parsed.action != Action::Uninstall { + config_import::apply_defaults_if_empty(); + } match parsed.action { Action::Install => { @@ -172,6 +220,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/src/service.rs b/src/service.rs index c0ab340..9ecb40c 100644 --- a/src/service.rs +++ b/src/service.rs @@ -3,7 +3,7 @@ // Three responsibilities: // // 1. `install()` — copy the binary to %ProgramFiles%\hello-agent, mirror the -// calling user's `HelloAgent.toml` into the LocalService-effective +// calling user's `hello-agent.toml` into the LocalService-effective // config dir so the SYSTEM service inherits the --config blob, register // the service with the SCM pointing at the installed exe, and start it. // Idempotent. @@ -29,14 +29,21 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use windows_service::service::{ - ServiceAccess, ServiceControl, ServiceControlAccept, ServiceErrorControl, ServiceExitCode, + ServiceAccess, ServiceAction, ServiceActionType, ServiceControl, ServiceControlAccept, + ServiceErrorControl, ServiceExitCode, ServiceFailureActions, ServiceFailureResetPeriod, ServiceInfo, ServiceStartType, ServiceState, ServiceStatus, ServiceType, }; use windows_service::service_control_handler::{self, ServiceControlHandlerResult}; use windows_service::service_dispatcher; use windows_service::service_manager::{ServiceManager, ServiceManagerAccess}; -const SERVICE_NAME: &str = "HelloAgent"; +/// Internal service name registered with the SCM. Must equal `crate::APP_NAME` +/// because upstream `librustdesk::platform::is_self_service_running` queries +/// `is_service_running(&crate::get_app_name())` — i.e. it looks up the +/// service whose name *is* the app name. If these diverge, the `--update` +/// path's `sc stop` / `sc start` use the wrong name and the service is +/// left in a Stopped state after a self-update. +const SERVICE_NAME: &str = crate::APP_NAME; const DISPLAY_NAME: &str = "HelloAgent Remote Support"; const SERVICE_DESCRIPTION: &str = "HelloAgent — headless remote-support agent (RustDesk-protocol-compatible). \ @@ -47,6 +54,11 @@ const SERVICE_TYPE: ServiceType = ServiceType::OWN_PROCESS; const INSTALL_SUBDIR: &str = "hello-agent"; const INSTALLED_EXE_NAME: &str = "hello-agent.exe"; +/// Display name used for the Windows Firewall rule. Stable across versions +/// so `--uninstall` (or a re-install that clears it before re-adding) can +/// find and delete the prior entry by name. +const FIREWALL_RULE_NAME: &str = "HelloAgent"; + // ----------------------------- paths --------------------------------------- /// `%ProgramFiles%\hello-agent`. Falls back to `C:\Program Files\hello-agent` @@ -68,9 +80,9 @@ fn install_dir() -> PathBuf { /// Note the trailing `config` segment: `directories_next::ProjectDirs`, /// which hbb_common uses on Windows, appends a literal `\config` to the /// app's roaming dir (so the user-side path is -/// `%APPDATA%\HelloAgent\config\HelloAgent.toml`, not -/// `…\HelloAgent\…`). The SYSTEM-side path follows the same convention. -/// The `HelloAgent` segment is sourced from `crate::APP_NAME` so it stays +/// `%APPDATA%\hello-agent\config\hello-agent.toml`, not +/// `…\hello-agent\…`). The SYSTEM-side path follows the same convention. +/// The `hello-agent` segment is sourced from `crate::APP_NAME` so it stays /// in lockstep with the `APP_NAME` we install into hbb_common at startup. fn service_config_dir() -> PathBuf { let system_root = std::env::var_os("SystemRoot") @@ -88,11 +100,15 @@ fn service_config_dir() -> PathBuf { // ----------------------------- install -------------------------------------- pub fn install() -> Result<()> { + // Probe-open the SCM with CREATE_SERVICE rights up front; if the caller + // isn't elevated this fails with ERROR_ACCESS_DENIED (raw_os_error == 5) + // and we surface a single human-readable message instead of bubbling + // up a Win32 errno string. Anything else propagates as-is. let scm = ServiceManager::local_computer( None::<&str>, ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE, ) - .context("open SCM")?; + .map_err(map_scm_open_error)?; // 1. If a previous install left a running service, stop it before we // overwrite its binary. Otherwise the file copy in step 2 fails @@ -106,8 +122,8 @@ pub fn install() -> Result<()> { // idempotent / usable as an in-place update — without it, the // `stage_binary` file copy below fails with "access denied" // whenever a `--cm` child is still holding the old exe open. - // `kill_orphan_processes` uses taskkill with `/FI "PID ne "` - // so it never kills the running installer. + // `kill_orphan_processes` walks the process table via sysinfo and + // filters out our own pid so the installer doesn't suicide. kill_orphan_processes(); // 2. Pin the binary to %ProgramFiles%\hello-agent. The user might be @@ -120,17 +136,17 @@ pub fn install() -> Result<()> { // first, fall back to popup). Older hello-agent installs wrote // "click" here, which disabled the password path; clearing it // every install makes upgrades idempotent. These write into the - // *calling user's* %APPDATA%\HelloAgent\ — we mirror the result + // *calling user's* %APPDATA%\hello-agent\ — we mirror the result // into the service's effective dir in step 4. hbb_common::config::Config::set_option("stop-service".into(), "".into()); hbb_common::config::Config::set_option("approve-mode".into(), "".into()); - // 4. Mirror the calling user's `HelloAgent.toml` / `HelloAgent2.toml` + // 4. Mirror the calling user's `hello-agent.toml` / `hello-agent2.toml` // into the LocalService-effective config root that the SYSTEM // service will actually read. Without this, --config writes to e.g. - // C:\Users\Admin\AppData\Roaming\HelloAgent\, but the service runs + // C:\Users\Admin\AppData\Roaming\hello-agent\, but the service runs // as LocalSystem and (via hbb_common's `patch()`) reads from - // C:\Windows\ServiceProfiles\LocalService\AppData\Roaming\HelloAgent\. + // C:\Windows\ServiceProfiles\LocalService\AppData\Roaming\hello-agent\. if let Err(e) = mirror_config_to_service_dir() { log::warn!( "could not mirror config to service dir ({e:#}); the service may not see --config until first heartbeat" @@ -183,6 +199,60 @@ pub fn install() -> Result<()> { let _ = svc.set_description(SERVICE_DESCRIPTION); + // 5b. Configure SCM auto-restart on unexpected exit. Without this, + // a panic in the `--service` supervisor leaves the agent permanently + // Stopped until the host reboots. The schedule restarts after + // 5s, 30s, 60s and gives up after that; the failure-count reset + // window is one day, so transient hiccups don't accumulate and + // stable hosts converge back to "running" within a minute. + // + // `set_failure_actions_on_non_crash_failures(true)` is what makes + // these actions fire when the service exits cleanly with a non-zero + // code (panic via abort, for instance), not just on outright + // crashes detected by the SCM. Both are best-effort; the SCM + // accepts the call but doesn't error if the underlying ChangeServiceConfig2 + // fails for some reason — we log and continue. + let failure_actions = ServiceFailureActions { + reset_period: ServiceFailureResetPeriod::After(Duration::from_secs(60 * 60 * 24)), + reboot_msg: None, + command: None, + actions: Some(vec![ + ServiceAction { + action_type: ServiceActionType::Restart, + delay: Duration::from_secs(5), + }, + ServiceAction { + action_type: ServiceActionType::Restart, + delay: Duration::from_secs(30), + }, + ServiceAction { + action_type: ServiceActionType::Restart, + delay: Duration::from_secs(60), + }, + ]), + }; + if let Err(e) = svc.update_failure_actions(failure_actions) { + log::warn!("could not set SCM failure actions ({e}); auto-restart-on-crash disabled"); + } + if let Err(e) = svc.set_failure_actions_on_non_crash_failures(true) { + log::warn!( + "could not enable failure actions for clean-exit-with-error ({e}); only hard crashes will trigger restart" + ); + } + + // 5c. Allow inbound TCP/UDP to hello-agent.exe at the Windows Firewall. + // A vanilla deploy doesn't actually need it (the rendezvous/relay + // connections are outbound), but operators who enable `direct-server` + // (TCP 21118) or `enable-lan-discovery` (UDP 21119) via the --config + // blob need this rule or those features silently fail. Cheaper to + // add it always than to discover at support-call time that the + // deploy never matched a firewall rule. Best-effort: if netsh + // isn't present (extremely stripped-down server SKUs) we log and + // continue. + if let Err(e) = install_firewall_rule(&target_exe) { + log::warn!("could not install firewall rule ({e:#}); inbound connections may be blocked"); + } + // 6. Start the service. (Step 1 already stopped any prior instance.) svc.start::<&str>(&[]).context("start service")?; log::info!( @@ -250,7 +320,7 @@ fn stage_binary() -> Result { Ok(dest) } -/// Copy the calling user's `HelloAgent.toml` + `HelloAgent2.toml` into +/// Copy the calling user's `hello-agent.toml` + `hello-agent2.toml` into /// the LocalService-effective config dir so the SYSTEM service sees them. fn mirror_config_to_service_dir() -> Result<()> { let dest_dir = service_config_dir(); @@ -272,7 +342,7 @@ fn mirror_config_to_service_dir() -> Result<()> { Err(e) if e.kind() == std::io::ErrorKind::NotFound => { // Calling user never had this file (e.g. --install without // --config, or first ever run on this machine, or the user - // wiped %APPDATA%\HelloAgent\ between tests). Logged at + // wiped %APPDATA%\hello-agent\ between tests). Logged at // info so the post-install log shows clearly which toml // files were available and which weren't. log::info!( @@ -298,6 +368,16 @@ fn mirror_config_to_service_dir() -> Result<()> { // ----------------------------- uninstall ------------------------------------ pub fn uninstall() -> Result<()> { + // Probe-open the SCM with the rights we'll need (CONNECT for the SCM + // handle itself, and DELETE on the per-service open below). The same + // elevation-error mapping as install() — surface a single clear message + // when the operator forgot the elevated prompt. + let scm = ServiceManager::local_computer( + None::<&str>, + ServiceManagerAccess::CONNECT, + ) + .map_err(map_scm_open_error)?; + // Kill every hello-agent.exe process except ourselves *first*. We can't // rely on the SCM Stop control alone because the `--cm` child spawned // via `run_as_user` runs under the logged-in user's token, not SYSTEM, @@ -305,15 +385,9 @@ pub fn uninstall() -> Result<()> { // Doing this up front means the SCM stop below is usually a no-op // (service process already gone) and the rmdir at the end no longer // races a lingering child holding hello-agent.exe open. Our own PID - // is excluded via taskkill's `/FI` so the uninstaller doesn't suicide. + // is excluded via the sysinfo filter so the uninstaller doesn't suicide. kill_orphan_processes(); - let scm = ServiceManager::local_computer( - None::<&str>, - ServiceManagerAccess::CONNECT, - ) - .context("open SCM")?; - match scm.open_service( SERVICE_NAME, ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE, @@ -343,9 +417,17 @@ pub fn uninstall() -> Result<()> { Err(e) => return Err(anyhow!("open_service: {e}")), } + // Remove the firewall rule we installed (best-effort). netsh delete is + // idempotent — if the rule was never there (or someone manually removed + // it) netsh returns 1 with "No rules match the specified criteria", + // which we treat as success. + if let Err(e) = delete_firewall_rule() { + log::warn!("could not delete firewall rule ({e:#}); remove it manually if needed"); + } + cleanup_install_dir(); // We deliberately do NOT delete the LocalService config dir here. - // `HelloAgent.toml` in that directory holds the agent's id + keypair, + // `hello-agent.toml` in that directory holds the agent's id + keypair, // which the rustdesk-server / rendezvous server has registered against // the agent's id. Wiping it forces the next --install to generate // fresh keys, which the rendezvous server's cached entry (and any @@ -354,7 +436,7 @@ pub fn uninstall() -> Result<()> { // the connection sits idle until the peer times out. // // Operators who want a true hard wipe can run: - // rmdir /s /q "%SystemRoot%\ServiceProfiles\LocalService\AppData\Roaming\HelloAgent" + // rmdir /s /q "%SystemRoot%\ServiceProfiles\LocalService\AppData\Roaming\hello-agent" // and then delete the device record from the rustdesk-server admin UI. log::info!("preserved LocalService config dir to keep agent keys/id stable across reinstalls"); Ok(()) @@ -365,58 +447,175 @@ pub fn uninstall() -> Result<()> { /// old `--cm` child holding the exe open) and `--uninstall` (so the /// rmdir at the end isn't racing a lingering child). /// -/// Shells out to the built-in `taskkill` rather than re-implementing the -/// Toolhelp32 enumeration in winapi: taskkill ships in every Windows -/// install since XP, runs in milliseconds, and the `/FI "PID ne "` -/// filter handles the "don't suicide ourselves" requirement declaratively. -/// -/// Exit code 128 from taskkill means "no matching processes" — common -/// case when there's no orphan to clean up — and we treat it the same -/// as success. Anything else gets logged but does not fail the caller. +/// Walks the process table via `hbb_common::sysinfo` (the same enumerator +/// the vendored rustdesk uses internally) and calls `Process::kill` — +/// equivalent to `TerminateProcess` under the hood. After issuing the +/// kills we poll the process table for actual exit rather than guessing +/// at a 500 ms sleep: `TerminateProcess` marks the process as exited but +/// the kernel takes a variable amount of time to release the image-file +/// handle, and we only want to return once those handles are gone (so +/// the install-time file copy and uninstall-time rmdir don't race a +/// half-finalized victim). fn kill_orphan_processes() { + // hbb_common pulls the rustdesk-org sysinfo 0.29 fork, which exposes + // System/Process/Pid with inherent methods (no SystemExt/ProcessExt + // trait imports needed — that style was removed when this fork + // diverged from upstream 0.30). + use hbb_common::sysinfo::{Pid, System}; + let our_pid = std::process::id(); - let pid_filter = format!("PID ne {our_pid}"); - let output = std::process::Command::new("taskkill") - .args([ - "/F", - "/IM", - INSTALLED_EXE_NAME, - "/FI", - &pid_filter, - ]) - .output(); - match output { - Ok(out) => { - let code = out.status.code(); - let stdout = String::from_utf8_lossy(&out.stdout); - let stderr = String::from_utf8_lossy(&out.stderr); - if out.status.success() { - log::info!( - "taskkill killed orphan {INSTALLED_EXE_NAME} processes (excluding pid {our_pid}): {}", - stdout.trim() - ); - // TerminateProcess is synchronous w.r.t. the kernel marking - // the process as exited, but kernel-mode finalization - // (releasing file handles, paging out the image section) - // can lag by up to a few hundred ms. The rmdir that follows - // races against this: without the pause, an immediate - // remove_dir_all can still see "file in use" on the just- - // killed process's exe. - std::thread::sleep(Duration::from_millis(500)); - } else if code == Some(128) { - log::info!("no orphan {INSTALLED_EXE_NAME} processes to kill"); + let target = INSTALLED_EXE_NAME; + + let mut system = System::new(); + system.refresh_processes(); + let victims: Vec = system + .processes() + .iter() + .filter(|(pid, p)| { + pid.as_u32() != our_pid && p.name().eq_ignore_ascii_case(target) + }) + .map(|(pid, _)| *pid) + .collect(); + + if victims.is_empty() { + log::info!("no orphan {target} processes to kill"); + return; + } + + let killed: Vec = victims + .iter() + .filter_map(|pid| { + let process = system.process(*pid)?; + if process.kill() { + Some(pid.as_u32()) } else { - log::warn!( - "taskkill returned {code:?}: stdout={} stderr={}", - stdout.trim(), - stderr.trim(), - ); + log::warn!("Process::kill failed for pid {}", pid.as_u32()); + None } + }) + .collect(); + log::info!("issued kill on {} {target} process(es): {killed:?}", killed.len()); + + // Poll for actual exit. 5 s ceiling is generous (TerminateProcess + // usually finalizes within tens of ms) but cheap — we only burn it + // when the kernel really is dragging its feet, which is the exact + // case the old `sleep(500ms)` heuristic couldn't handle. + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + system.refresh_processes(); + let still_alive = victims.iter().any(|pid| system.process(*pid).is_some()); + if !still_alive { + return; } - Err(e) => { - log::warn!("could not invoke taskkill: {e}"); + std::thread::sleep(Duration::from_millis(50)); + } + log::warn!( + "some {target} processes were still alive after 5 s; subsequent file ops may fail with sharing violation" + ); +} + +/// Translate a `windows_service::Error` from `ServiceManager::local_computer` +/// into a friendlier user-facing message. ERROR_ACCESS_DENIED (Win32 err 5) +/// is the overwhelmingly common case — operator forgot to elevate — and +/// deserves a single clear line rather than the raw Win32 errno string. +fn map_scm_open_error(e: windows_service::Error) -> anyhow::Error { + if let windows_service::Error::Winapi(ref ioe) = e { + if ioe.raw_os_error() == Some(5) { + return anyhow!( + "requires an elevated (Administrator) prompt — re-run from \"Run as administrator\"" + ); } } + anyhow!("open SCM: {e}") +} + +/// Add a Windows Firewall rule allowing inbound TCP/UDP to the installed +/// hello-agent.exe. Idempotent: we delete any prior rule by the same name +/// first, so re-running --install (or upgrading in place) doesn't pile up +/// duplicate entries in the firewall's per-name list. +/// +/// We use the program-scoped form (`program=`) rather than port-scoped +/// rules because hello-agent's optional listeners (direct-server TCP 21118, +/// LAN-discovery UDP 21119) are gated on operator-controlled config flags; +/// rule-by-program covers whatever ports the agent actually decides to bind. +fn install_firewall_rule(exe_path: &PathBuf) -> Result<()> { + // Drop any pre-existing rule first; netsh quietly succeeds-with-exit-1 + // when nothing matches, so we ignore the result. + let _ = run_netsh(&[ + "advfirewall", + "firewall", + "delete", + "rule", + &format!("name={FIREWALL_RULE_NAME}"), + ]); + + let program_arg = format!( + "program={}", + exe_path.to_str().ok_or_else(|| anyhow!( + "non-UTF-8 install path can't be passed to netsh: {}", + exe_path.display() + ))? + ); + let status = run_netsh(&[ + "advfirewall", + "firewall", + "add", + "rule", + &format!("name={FIREWALL_RULE_NAME}"), + "dir=in", + "action=allow", + "enable=yes", + "profile=any", + &program_arg, + ])?; + if !status { + return Err(anyhow!("netsh add rule failed")); + } + log::info!( + "added firewall rule '{FIREWALL_RULE_NAME}' for {}", + exe_path.display() + ); + Ok(()) +} + +/// Remove the hello-agent firewall rule by name. netsh exits non-zero when +/// no rule matches; we translate that into success since the post-condition +/// (no rule by that name) is what we want anyway. +fn delete_firewall_rule() -> Result<()> { + let status = run_netsh(&[ + "advfirewall", + "firewall", + "delete", + "rule", + &format!("name={FIREWALL_RULE_NAME}"), + ]); + match status { + Ok(_) => { + log::info!("removed firewall rule '{FIREWALL_RULE_NAME}' (or none was present)"); + Ok(()) + } + Err(e) => Err(e), + } +} + +/// Shell out to netsh.exe with the given args. Returns Ok(true) on +/// exit-0, Ok(false) on a non-zero exit that *netsh itself* produced +/// (e.g. "rule already exists" or "no rules match"), and Err only when +/// the binary couldn't be invoked at all (PATH stripped, etc.). +fn run_netsh(args: &[&str]) -> Result { + let out = std::process::Command::new("netsh") + .args(args) + .output() + .context("invoke netsh")?; + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + log::debug!( + "netsh {args:?} exited {:?}: {}", + out.status.code(), + stderr.trim() + ); + } + Ok(out.status.success()) } /// Remove %ProgramFiles%\hello-agent. Best-effort: if the user ran 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..052a793 100644 --- a/vendor/rustdesk/src/platform/windows.rs +++ b/vendor/rustdesk/src/platform/windows.rs @@ -1339,14 +1339,30 @@ pub fn copy_raw_cmd(src_raw: &str, _raw: &str, _path: &str) -> ResultType ResultType { - let main_exe = copy_raw_cmd(src_exe, exe, path)?; + // hello-agent local patch: upstream emits an `XCOPY + // /Y /E /H /C /I /K /R /Z`, which recursively copies the + // ENTIRE TEMP directory (the staged binary's parent) into the install + // dir — sweeping along every unrelated file that happens to share the + // temp folder. For hello-agent we ship a single binary, so a one-file + // copy is both correct and safe. We preserve the staged exe's original + // filename so the subsequent `rename_exe_cmd` step (which renames + // .exe → .exe) keeps working exactly as upstream + // expects. + // + // We also drop the broker (RuntimeBroker.exe) copy on the second line: + // it's only needed for privacy-mode topmost-window injection, which + // hello-agent doesn't enable (we don't ship the broker as a separate + // artifact, and shipping a copy of a system file under a custom name + // is asking for AV false-positives). + let src_path = PathBuf::from(src_exe); + let src_filename = src_path + .file_name() + .ok_or_else(|| anyhow!("Can't get file name of {src_exe}"))? + .to_string_lossy() + .to_string(); + let _ = exe; // upstream signature carries the resolved exe path; not needed for the single-file copy. Ok(format!( - " - {main_exe} - copy /Y \"{ORIGIN_PROCESS_EXE}\" \"{path}\\{broker_exe}\" - ", - ORIGIN_PROCESS_EXE = win_topmost_window::ORIGIN_PROCESS_EXE, - broker_exe = win_topmost_window::INJECTED_PROCESS_EXE, + "copy /Y \"{src_exe}\" \"{path}\\{src_filename}\"\n" )) } @@ -3110,21 +3126,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 +3159,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";