2 Commits

Author SHA1 Message Date
mike fb00ac1101 Implement software inventory
build-windows / build-hello-agent-x64 (push) Successful in 5m20s
build-windows / sign-hello-agent-x64 (push) Successful in 5s
build-windows / validate-hello-agent-x64 (push) Successful in 6s
2026-05-21 23:55:20 +02:00
mike 8cff0c1863 Implement auto-update routine
build-windows / build-hello-agent-x64 (push) Successful in 5m7s
build-windows / sign-hello-agent-x64 (push) Successful in 5s
build-windows / validate-hello-agent-x64 (push) Successful in 6s
2026-05-21 23:25:53 +02:00
12 changed files with 712 additions and 201 deletions
Generated
+1 -1
View File
@@ -3197,7 +3197,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "hello-agent" name = "hello-agent"
version = "0.1.1" version = "0.1.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"env_logger 0.10.2", "env_logger 0.10.2",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "hello-agent" name = "hello-agent"
version = "0.1.1" version = "0.1.3"
edition = "2021" edition = "2021"
rust-version = "1.75" rust-version = "1.75"
description = "Headless RustDesk-protocol-compatible support agent for Windows" description = "Headless RustDesk-protocol-compatible support agent for Windows"
+16 -16
View File
@@ -42,7 +42,7 @@ empty, so `--config` always wins.
``` ```
hello-agent.exe --install 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 hello-agent.exe --service # Session 0, LocalSystem
@@ -191,7 +191,7 @@ To pull updates from upstream RustDesk:
## Stale keys / supporter "stuck on connecting" ## 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 They're generated once on first run, registered with the rendezvous
server, and re-used forever after. **If the rendezvous server's cached server, and re-used forever after. **If the rendezvous server's cached
entry and the agent's local keypair drift apart, the encrypted handshake 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`: 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. …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 ## Verifying end-to-end
1. Install: `hello-agent.exe --install --config <BLOB>` from elevated PowerShell. 1. Install: `hello-agent.exe --install --config <BLOB>` 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 3. From another machine running stock `rustdesk.exe`, enter the agent's
ID and click Connect. ID and click Connect.
4. The agent's logged-in user sees `HelloAgent — Allow remote support?`. 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 `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 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 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 identical to the write path the upstream Flutter build uses for OEM
rebrands ([`read_custom_client`](vendor/rustdesk/src/common.rs)). Because rebrands ([`read_custom_client`](vendor/rustdesk/src/common.rs)). Because
`APP_NAME` is a `RwLock<String>` read lazily on first use, doing the `APP_NAME` is a `RwLock<String>` read lazily on first use, doing the
@@ -252,16 +252,16 @@ In practice that means:
| What | Stock rustdesk | hello-agent | | What | Stock rustdesk | hello-agent |
| --------------------------------- | ----------------------------------------- | ------------------------------------------------- | | --------------------------------- | ----------------------------------------- | ------------------------------------------------- |
| User-mode config / logs | `%APPDATA%\RustDesk\` | `%APPDATA%\HelloAgent\` | | User-mode config / logs | `%APPDATA%\RustDesk\` | `%APPDATA%\hello-agent\` |
| Service-mode config / logs | `…\LocalService\AppData\Roaming\RustDesk\`| `…\LocalService\AppData\Roaming\HelloAgent\` | | Service-mode config / logs | `…\LocalService\AppData\Roaming\RustDesk\`| `…\LocalService\AppData\Roaming\hello-agent\` |
| Identity file (id + keypair) | `RustDesk.toml` | `HelloAgent.toml` | | Identity file (id + keypair) | `RustDesk.toml` | `hello-agent.toml` |
| IPC pipe namespace | `\\.\pipe\RustDesk\query…` | `\\.\pipe\HelloAgent\query…` | | IPC pipe namespace | `\\.\pipe\RustDesk\query…` | `\\.\pipe\hello-agent\query…` |
| Windows service name | `RustDesk` | `HelloAgent` | | Windows service name | `RustDesk` | `hello-agent` |
| Install dir | `%ProgramFiles%\RustDesk\` | `%ProgramFiles%\hello-agent\` | | Install dir | `%ProgramFiles%\RustDesk\` | `%ProgramFiles%\hello-agent\` |
The two binaries can therefore coexist on the same machine without The two binaries can therefore coexist on the same machine without
clobbering each other's state. The override is set in 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. — change it there if you ever need to re-brand.
## Where logs go ## Where logs go
@@ -270,10 +270,10 @@ clobbering each other's state. The override is set in
| Mode (CLI flag) | Effective user | Log dir | | Mode (CLI flag) | Effective user | Log dir |
| --------------------- | ------------------------------- | ---------------------------------------------------------------------------------------- | | --------------------- | ------------------------------- | ---------------------------------------------------------------------------------------- |
| `--install` / `--uninstall` | calling user (must be admin) | `%APPDATA%\HelloAgent\log\install\` (or `…\uninstall\`) | | `--install` / `--uninstall` | calling user (must be admin) | `%APPDATA%\hello-agent\log\install\` (or `…\uninstall\`) |
| `--service` | LocalSystem (mirrored) | `%SystemRoot%\ServiceProfiles\LocalService\AppData\Roaming\HelloAgent\log\service\` | | `--service` | LocalSystem (mirrored) | `%SystemRoot%\ServiceProfiles\LocalService\AppData\Roaming\hello-agent\log\service\` |
| `--server` (worker) | LocalSystem (mirrored) | `%SystemRoot%\ServiceProfiles\LocalService\AppData\Roaming\HelloAgent\log\server\` | | `--server` (worker) | LocalSystem (mirrored) | `%SystemRoot%\ServiceProfiles\LocalService\AppData\Roaming\hello-agent\log\server\` |
| no flags (dev mode) | calling user | `%APPDATA%\HelloAgent\log\hello-agent\` | | no flags (dev mode) | calling user | `%APPDATA%\hello-agent\log\hello-agent\` |
The `cm_popup` module also writes a parallel diagnostic trace at The `cm_popup` module also writes a parallel diagnostic trace at
`%TEMP%\hello-agent-cm.log` (kept around for debugging the IPC handshake; `%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 - ✅ Windows x64 (physical console *and* RDP sessions — the agent picks
whichever session the user is actively using) whichever session the user is actively using)
- ✅ Coexists with stock RustDesk on the same box — config dir, log dir, - ✅ 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). upstream default of `RustDesk` (see [Namespacing](#namespacing) below).
The only residual contention is the optional direct-server port The only residual contention is the optional direct-server port
(TCP 21118) and LAN-discovery port (UDP 21119); both default to off, (TCP 21118) and LAN-discovery port (UDP 21119); both default to off,
+10
View File
@@ -19,6 +19,16 @@
fn main() { fn main() {
println!("cargo:rerun-if-changed=build.rs"); println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=resources/icon.ico"); println!("cargo:rerun-if-changed=resources/icon.ico");
// winres derives FileVersion / ProductVersion from `CARGO_PKG_VERSION`,
// which is sourced from `Cargo.toml`. Without this directive, cargo
// happily skips the build script when only the version field changed
// (build.rs and the .ico are byte-identical), and the resulting EXE
// ships with stale "FileVersion" / "ProductVersion" properties — the
// binary itself is the new build, but Explorer's Properties dialog
// and any Authenticode tooling that reads the resource block see the
// previous version. Forcing a re-run on Cargo.toml changes is cheap
// (winres compile is sub-second) and bulletproof.
println!("cargo:rerun-if-changed=Cargo.toml");
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
if target_os != "windows" { if target_os != "windows" {
+45 -3
View File
@@ -31,6 +31,13 @@ pub enum Action {
/// — every `Data::FS(...)` frame the server sends is executed here, in /// — every `Data::FS(...)` frame the server sends is executed here, in
/// the user's security context. /// the user's security context.
Cm, 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)] #[derive(Debug)]
@@ -47,6 +54,7 @@ impl ParsedArgs {
let mut service = false; let mut service = false;
let mut server = false; let mut server = false;
let mut cm = false; let mut cm = false;
let mut update = false;
let mut config_blob: Option<String> = None; let mut config_blob: Option<String> = None;
let mut i = 0; let mut i = 0;
@@ -56,6 +64,7 @@ impl ParsedArgs {
"--uninstall" => uninstall = true, "--uninstall" => uninstall = true,
"--service" => service = true, "--service" => service = true,
"--server" => server = true, "--server" => server = true,
"--update" => update = true,
// Connection-manager popup mode. Treat `--cm-no-ui` (the // Connection-manager popup mode. Treat `--cm-no-ui` (the
// Linux-headless variant librustdesk also tries) as a // Linux-headless variant librustdesk also tries) as a
// synonym; either way we run cm_popup. // synonym; either way we run cm_popup.
@@ -81,14 +90,21 @@ impl ParsedArgs {
} }
// Mutual-exclusion rules. --install + --config is the MDM one-liner; // Mutual-exclusion rules. --install + --config is the MDM one-liner;
// everything else is one-action-at-a-time. // everything else is one-action-at-a-time. --update is launched by
let exclusive = [uninstall, service, server, cm].iter().filter(|x| **x).count(); // the updater as a standalone elevated child, never combined.
let exclusive = [uninstall, service, server, cm, update]
.iter()
.filter(|x| **x)
.count();
if exclusive > 1 { 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()) { if uninstall && (install || config_blob.is_some()) {
bail!("--uninstall cannot be combined with other flags"); 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 { let action = if uninstall {
Action::Uninstall Action::Uninstall
@@ -100,6 +116,8 @@ impl ParsedArgs {
Action::Server Action::Server
} else if cm { } else if cm {
Action::Cm Action::Cm
} else if update {
Action::Update
} else if config_blob.is_some() { } else if config_blob.is_some() {
Action::ConfigOnly Action::ConfigOnly
} else { } else {
@@ -131,6 +149,10 @@ OPTIONS:
--service SCM entry point. Do not invoke manually. --service SCM entry point. Do not invoke manually.
--server Worker mode (launched by the service shell into --server Worker mode (launched by the service shell into
the active console session). 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. -h, --help Show this help.
-V, --version Show version. -V, --version Show version.
@@ -191,4 +213,24 @@ mod tests {
fn unknown_arg() { fn unknown_arg() {
assert!(parse(&["--no-such-flag"]).is_err()); 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());
}
} }
+48
View File
@@ -105,6 +105,53 @@ try {
$public_ip = (Invoke-RestMethod -Uri 'https://api.ipify.org' -TimeoutSec 5 -ErrorAction Stop).ToString().Trim() $public_ip = (Invoke-RestMethod -Uri 'https://api.ipify.org' -TimeoutSec 5 -ErrorAction Stop).ToString().Trim()
} catch {} } catch {}
# Installed software (the "Add/Remove Programs" / "Apps & features" list).
# We enumerate the Uninstall registry keys directly — the same source
# Settings reads — rather than `Get-CimInstance Win32_Product`, which is
# notoriously slow (triggers MSI self-repair on every entry) and only
# covers MSI-installed software, missing everything from per-user
# installers, Chocolatey/Scoop/Inno-Setup, etc.
#
# We read both HKLM hives (64-bit + WOW6432Node 32-bit) so apps installed
# under either bitness show up. HKCU is skipped on purpose: the agent
# runs as LocalSystem (or LocalService), whose HKCU hive has nothing the
# logged-in user installed under their own profile — that data would
# require running per-user, which is out of scope for v1.
#
# Filter rules:
# * `DisplayName` must be set — empty-DisplayName entries are uninstall
# stubs for individual update KBs and not user-facing apps.
# * Skip `SystemComponent = 1` — internal Windows components hidden
# from Settings (DirectX shims, VC++ private redists, …).
# * Skip entries with a `ParentKeyName` — those are subcomponents of a
# parent application (e.g. Office's per-language packs); the parent
# row already covers the user-facing app.
#
# Bitness-tagged so the admin UI can distinguish a 64-bit vs 32-bit
# install of the same product (common for runtimes like VC++).
$installed_software = @()
foreach ($scope in @(
@{ path = 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*'; bitness = '64' },
@{ path = 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'; bitness = '32' }
)) {
try {
$entries = Get-ItemProperty -Path $scope.path -ErrorAction SilentlyContinue
} catch { continue }
foreach ($e in $entries) {
if (-not $e.DisplayName) { continue }
if ($e.SystemComponent -eq 1) { continue }
if ($e.ParentKeyName) { continue }
$installed_software += [pscustomobject]@{
name = "$($e.DisplayName)"
version = if ($e.DisplayVersion) { "$($e.DisplayVersion)" } else { '' }
publisher = if ($e.Publisher) { "$($e.Publisher)" } else { '' }
install_date = if ($e.InstallDate) { "$($e.InstallDate)" } else { '' }
bitness = $scope.bitness
}
}
}
$installed_software = @($installed_software | Sort-Object name, version)
$os_release = "$($os.Version)" $os_release = "$($os.Version)"
if ($displayVersion) { $os_release = "$($os.Version) $displayVersion" } if ($displayVersion) { $os_release = "$($os.Version) $displayVersion" }
$result = [pscustomobject]@{ $result = [pscustomobject]@{
@@ -123,6 +170,7 @@ $result = [pscustomobject]@{
bitlocker_recovery_key = $bl_key bitlocker_recovery_key = $bl_key
network_interfaces = $nics network_interfaces = $nics
public_ip = $public_ip public_ip = $public_ip
installed_software = $installed_software
} }
$result | ConvertTo-Json -Compress -Depth 6 $result | ConvertTo-Json -Compress -Depth 6
"#; "#;
+60 -7
View File
@@ -15,7 +15,7 @@
// lazily from a `RwLock<String>` whenever any path is computed (config dir, // lazily from a `RwLock<String>` whenever any path is computed (config dir,
// log dir, named-pipe namespace, …), so setting it before any of those // log dir, named-pipe namespace, …), so setting it before any of those
// initializers fire is enough to redirect all hbb_common state under // 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 // to the `read_custom_client` write path the upstream Flutter build uses
// for OEM rebrands. // 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. /// 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 /// Written into `hbb_common::config::APP_NAME` at the top of `main` so
/// every subsequent path computation (config dir, log dir, named pipe) /// 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 — /// `%APPDATA%\RustDesk\`. Must be set before any code touches a path —
/// `hbb_common` initializes path globals lazily on first read. /// `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%\<APP_NAME>` and the expected exe filename as
/// `<APP_NAME>.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: /// Set up logging. We delegate to `hbb_common::init_log`, which:
/// * In **debug** builds: installs `env_logger` writing to stderr. /// * In **debug** builds: installs `env_logger` writing to stderr.
/// * In **release** builds: installs `flexi_logger` writing to a rolling /// * In **release** builds: installs `flexi_logger` writing to a rolling
/// file under `<config_dir>/log/<mode>/` — the SYSTEM service log ends /// file under `<config_dir>/log/<mode>/` — the SYSTEM service log ends
/// up at `%SystemRoot%\ServiceProfiles\LocalService\AppData\Roaming\HelloAgent\log\<mode>\` /// up at `%SystemRoot%\ServiceProfiles\LocalService\AppData\Roaming\hello-agent\log\<mode>\`
/// and the user-mode log at `%APPDATA%\HelloAgent\log\<mode>\`. /// and the user-mode log at `%APPDATA%\hello-agent\log\<mode>\`.
/// ///
/// The `mode` label segregates per-run-mode log files so service worker /// The `mode` label segregates per-run-mode log files so service worker
/// chatter doesn't tangle with --install diagnostics. `init_log` is /// chatter doesn't tangle with --install diagnostics. `init_log` is
@@ -65,7 +76,7 @@ fn main() {
// we'd never recover. // we'd never recover.
*hbb_common::config::APP_NAME.write().unwrap() = APP_NAME.to_owned(); *hbb_common::config::APP_NAME.write().unwrap() = APP_NAME.to_owned();
// Identify ourselves to the rustdesk-server's /api/sysinfo endpoint // 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 // the embedded rustdesk core version. These RwLocks are read once
// per sysinfo upload by hbbs_http::sync; setting them here (before // per sysinfo upload by hbbs_http::sync; setting them here (before
// start_server) ensures the very first upload carries the identity. // start_server) ensures the very first upload carries the identity.
@@ -90,10 +101,40 @@ fn main() {
Action::Service => "service", Action::Service => "service",
Action::Server => "server", Action::Server => "server",
Action::Cm => "cm", Action::Cm => "cm",
Action::Update => "update",
Action::ConfigOnly | Action::None => "hello-agent", Action::ConfigOnly | Action::None => "hello-agent",
}; };
init_logging(mode); 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 `<temp>\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) // --config is allowed to combine with --install (one-line MDM deploy)
// but on its own is a separate operation. Apply it first so --install // but on its own is a separate operation. Apply it first so --install
// sees the populated config. // sees the populated config.
@@ -108,7 +149,14 @@ fn main() {
// (or a prior install) already set custom-rendezvous-server, this is a // (or a prior install) already set custom-rendezvous-server, this is a
// no-op. Without this, a bare `hello-agent.exe --install` would land // no-op. Without this, a bare `hello-agent.exe --install` would land
// at an unconfigured agent that can't reach any server. // 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 { match parsed.action {
Action::Install => { Action::Install => {
@@ -172,6 +220,11 @@ fn main() {
// can watch logs. Production deployments use --install + --service. // can watch logs. Production deployments use --install + --service.
run_server(); 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");
}
} }
} }
+268 -69
View File
@@ -3,7 +3,7 @@
// Three responsibilities: // Three responsibilities:
// //
// 1. `install()` — copy the binary to %ProgramFiles%\hello-agent, mirror the // 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 // config dir so the SYSTEM service inherits the --config blob, register
// the service with the SCM pointing at the installed exe, and start it. // the service with the SCM pointing at the installed exe, and start it.
// Idempotent. // Idempotent.
@@ -29,14 +29,21 @@ use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use windows_service::service::{ use windows_service::service::{
ServiceAccess, ServiceControl, ServiceControlAccept, ServiceErrorControl, ServiceExitCode, ServiceAccess, ServiceAction, ServiceActionType, ServiceControl, ServiceControlAccept,
ServiceErrorControl, ServiceExitCode, ServiceFailureActions, ServiceFailureResetPeriod,
ServiceInfo, ServiceStartType, ServiceState, ServiceStatus, ServiceType, ServiceInfo, ServiceStartType, ServiceState, ServiceStatus, ServiceType,
}; };
use windows_service::service_control_handler::{self, ServiceControlHandlerResult}; use windows_service::service_control_handler::{self, ServiceControlHandlerResult};
use windows_service::service_dispatcher; use windows_service::service_dispatcher;
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess}; 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 DISPLAY_NAME: &str = "HelloAgent Remote Support";
const SERVICE_DESCRIPTION: &str = const SERVICE_DESCRIPTION: &str =
"HelloAgent — headless remote-support agent (RustDesk-protocol-compatible). \ "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 INSTALL_SUBDIR: &str = "hello-agent";
const INSTALLED_EXE_NAME: &str = "hello-agent.exe"; 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 --------------------------------------- // ----------------------------- paths ---------------------------------------
/// `%ProgramFiles%\hello-agent`. Falls back to `C:\Program Files\hello-agent` /// `%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`, /// Note the trailing `config` segment: `directories_next::ProjectDirs`,
/// which hbb_common uses on Windows, appends a literal `\config` to the /// which hbb_common uses on Windows, appends a literal `\config` to the
/// app's roaming dir (so the user-side path is /// app's roaming dir (so the user-side path is
/// `%APPDATA%\HelloAgent\config\HelloAgent.toml`, not /// `%APPDATA%\hello-agent\config\hello-agent.toml`, not
/// `…\HelloAgent\…`). The SYSTEM-side path follows the same convention. /// `…\hello-agent\…`). The SYSTEM-side path follows the same convention.
/// The `HelloAgent` segment is sourced from `crate::APP_NAME` so it stays /// 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. /// in lockstep with the `APP_NAME` we install into hbb_common at startup.
fn service_config_dir() -> PathBuf { fn service_config_dir() -> PathBuf {
let system_root = std::env::var_os("SystemRoot") let system_root = std::env::var_os("SystemRoot")
@@ -88,11 +100,15 @@ fn service_config_dir() -> PathBuf {
// ----------------------------- install -------------------------------------- // ----------------------------- install --------------------------------------
pub fn install() -> Result<()> { 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( let scm = ServiceManager::local_computer(
None::<&str>, None::<&str>,
ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE, 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 // 1. If a previous install left a running service, stop it before we
// overwrite its binary. Otherwise the file copy in step 2 fails // 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 // idempotent / usable as an in-place update — without it, the
// `stage_binary` file copy below fails with "access denied" // `stage_binary` file copy below fails with "access denied"
// whenever a `--cm` child is still holding the old exe open. // whenever a `--cm` child is still holding the old exe open.
// `kill_orphan_processes` uses taskkill with `/FI "PID ne <ours>"` // `kill_orphan_processes` walks the process table via sysinfo and
// so it never kills the running installer. // filters out our own pid so the installer doesn't suicide.
kill_orphan_processes(); kill_orphan_processes();
// 2. Pin the binary to %ProgramFiles%\hello-agent. The user might be // 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 // first, fall back to popup). Older hello-agent installs wrote
// "click" here, which disabled the password path; clearing it // "click" here, which disabled the password path; clearing it
// every install makes upgrades idempotent. These write into the // 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. // 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("stop-service".into(), "".into());
hbb_common::config::Config::set_option("approve-mode".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 // into the LocalService-effective config root that the SYSTEM
// service will actually read. Without this, --config writes to e.g. // 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 // 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() { if let Err(e) = mirror_config_to_service_dir() {
log::warn!( log::warn!(
"could not mirror config to service dir ({e:#}); the service may not see --config until first heartbeat" "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); 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.) // 6. Start the service. (Step 1 already stopped any prior instance.)
svc.start::<&str>(&[]).context("start service")?; svc.start::<&str>(&[]).context("start service")?;
log::info!( log::info!(
@@ -250,7 +320,7 @@ fn stage_binary() -> Result<PathBuf> {
Ok(dest) 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. /// the LocalService-effective config dir so the SYSTEM service sees them.
fn mirror_config_to_service_dir() -> Result<()> { fn mirror_config_to_service_dir() -> Result<()> {
let dest_dir = service_config_dir(); 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 => { Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
// Calling user never had this file (e.g. --install without // Calling user never had this file (e.g. --install without
// --config, or first ever run on this machine, or the user // --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 // info so the post-install log shows clearly which toml
// files were available and which weren't. // files were available and which weren't.
log::info!( log::info!(
@@ -298,6 +368,16 @@ fn mirror_config_to_service_dir() -> Result<()> {
// ----------------------------- uninstall ------------------------------------ // ----------------------------- uninstall ------------------------------------
pub fn uninstall() -> Result<()> { 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 // Kill every hello-agent.exe process except ourselves *first*. We can't
// rely on the SCM Stop control alone because the `--cm` child spawned // 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, // 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 // 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 // (service process already gone) and the rmdir at the end no longer
// races a lingering child holding hello-agent.exe open. Our own PID // 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(); kill_orphan_processes();
let scm = ServiceManager::local_computer(
None::<&str>,
ServiceManagerAccess::CONNECT,
)
.context("open SCM")?;
match scm.open_service( match scm.open_service(
SERVICE_NAME, SERVICE_NAME,
ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE, ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
@@ -343,9 +417,17 @@ pub fn uninstall() -> Result<()> {
Err(e) => return Err(anyhow!("open_service: {e}")), 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(); cleanup_install_dir();
// We deliberately do NOT delete the LocalService config dir here. // 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 // which the rustdesk-server / rendezvous server has registered against
// the agent's id. Wiping it forces the next --install to generate // the agent's id. Wiping it forces the next --install to generate
// fresh keys, which the rendezvous server's cached entry (and any // 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. // the connection sits idle until the peer times out.
// //
// Operators who want a true hard wipe can run: // 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. // 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"); log::info!("preserved LocalService config dir to keep agent keys/id stable across reinstalls");
Ok(()) Ok(())
@@ -365,58 +447,175 @@ pub fn uninstall() -> Result<()> {
/// old `--cm` child holding the exe open) and `--uninstall` (so the /// old `--cm` child holding the exe open) and `--uninstall` (so the
/// rmdir at the end isn't racing a lingering child). /// rmdir at the end isn't racing a lingering child).
/// ///
/// Shells out to the built-in `taskkill` rather than re-implementing the /// Walks the process table via `hbb_common::sysinfo` (the same enumerator
/// Toolhelp32 enumeration in winapi: taskkill ships in every Windows /// the vendored rustdesk uses internally) and calls `Process::kill` —
/// install since XP, runs in milliseconds, and the `/FI "PID ne <ours>"` /// equivalent to `TerminateProcess` under the hood. After issuing the
/// filter handles the "don't suicide ourselves" requirement declaratively. /// kills we poll the process table for actual exit rather than guessing
/// /// at a 500 ms sleep: `TerminateProcess` marks the process as exited but
/// Exit code 128 from taskkill means "no matching processes" — common /// the kernel takes a variable amount of time to release the image-file
/// case when there's no orphan to clean up — and we treat it the same /// handle, and we only want to return once those handles are gone (so
/// as success. Anything else gets logged but does not fail the caller. /// the install-time file copy and uninstall-time rmdir don't race a
/// half-finalized victim).
fn kill_orphan_processes() { 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 our_pid = std::process::id();
let pid_filter = format!("PID ne {our_pid}"); let target = INSTALLED_EXE_NAME;
let output = std::process::Command::new("taskkill")
.args([ let mut system = System::new();
"/F", system.refresh_processes();
"/IM", let victims: Vec<Pid> = system
INSTALLED_EXE_NAME, .processes()
"/FI", .iter()
&pid_filter, .filter(|(pid, p)| {
]) pid.as_u32() != our_pid && p.name().eq_ignore_ascii_case(target)
.output(); })
match output { .map(|(pid, _)| *pid)
Ok(out) => { .collect();
let code = out.status.code();
let stdout = String::from_utf8_lossy(&out.stdout); if victims.is_empty() {
let stderr = String::from_utf8_lossy(&out.stderr); log::info!("no orphan {target} processes to kill");
if out.status.success() { return;
log::info!( }
"taskkill killed orphan {INSTALLED_EXE_NAME} processes (excluding pid {our_pid}): {}",
stdout.trim() let killed: Vec<u32> = victims
); .iter()
// TerminateProcess is synchronous w.r.t. the kernel marking .filter_map(|pid| {
// the process as exited, but kernel-mode finalization let process = system.process(*pid)?;
// (releasing file handles, paging out the image section) if process.kill() {
// can lag by up to a few hundred ms. The rmdir that follows Some(pid.as_u32())
// 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");
} else { } else {
log::warn!( log::warn!("Process::kill failed for pid {}", pid.as_u32());
"taskkill returned {code:?}: stdout={} stderr={}", None
stdout.trim(),
stderr.trim(),
);
} }
})
.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) => { std::thread::sleep(Duration::from_millis(50));
log::warn!("could not invoke taskkill: {e}"); }
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=<path>`) 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<bool> {
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 /// Remove %ProgramFiles%\hello-agent. Best-effort: if the user ran
+100 -22
View File
@@ -94,11 +94,24 @@ pub mod input {
lazy_static::lazy_static! { lazy_static::lazy_static! {
pub static ref SOFTWARE_UPDATE_URL: Arc<Mutex<String>> = Default::default(); pub static ref SOFTWARE_UPDATE_URL: Arc<Mutex<String>> = 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<Mutex<Option<UpdateAssets>>> = Default::default();
pub static ref DEVICE_ID: Arc<Mutex<String>> = Default::default(); pub static ref DEVICE_ID: Arc<Mutex<String>> = Default::default();
pub static ref DEVICE_NAME: Arc<Mutex<String>> = Default::default(); pub static ref DEVICE_NAME: Arc<Mutex<String>> = Default::default();
static ref PUBLIC_IPV6_ADDR: Arc<Mutex<(Option<SocketAddr>, Option<Instant>)>> = Default::default(); static ref PUBLIC_IPV6_ADDR: Arc<Mutex<(Option<SocketAddr>, Option<Instant>)>> = Default::default();
} }
#[derive(Debug, Clone)]
pub struct UpdateAssets {
pub binary_url: String,
pub binary_name: String,
pub sha256_url: String,
}
lazy_static::lazy_static! { lazy_static::lazy_static! {
// Is server process, with "--server" args // Is server process, with "--server" args
static ref IS_SERVER: bool = std::env::args().nth(1) == Some("--server".to_owned()); 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. // hello-agent local patch: instead of POSTing to api.rustdesk.com (the
// Because the url is always `https://api.rustdesk.com/version/latest`. // 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<GiteaAsset>,
}
#[derive(Debug, serde::Deserialize)]
struct GiteaAsset {
name: String,
browser_download_url: String,
}
#[tokio::main(flavor = "current_thread")] #[tokio::main(flavor = "current_thread")]
pub async fn do_check_software_update() -> hbb_common::ResultType<()> { pub async fn do_check_software_update() -> hbb_common::ResultType<()> {
let (request, url) = let url = HELLO_AGENT_RELEASES_API;
hbb_common::version_check_request(hbb_common::VER_TYPE_RUSTDESK_CLIENT.to_string());
let proxy_conf = Config::get_socks(); 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 tls_type = get_cached_tls_type(tls_url);
let is_tls_not_cached = tls_type.is_none(); let is_tls_not_cached = tls_type.is_none();
let tls_type = tls_type.unwrap_or(TlsType::Rustls); let tls_type = tls_type.unwrap_or(TlsType::Rustls);
let client = create_http_client_async(tls_type, false); 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) => { Ok(resp) => {
upsert_tls_cache(tls_url, tls_type, false); upsert_tls_cache(tls_url, tls_type, false);
resp resp
@@ -970,7 +1009,7 @@ pub async fn do_check_software_update() -> hbb_common::ResultType<()> {
if is_tls_not_cached && err.is_request() { if is_tls_not_cached && err.is_request() {
let tls_type = TlsType::NativeTls; let tls_type = TlsType::NativeTls;
let client = create_http_client_async(tls_type, false); 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); upsert_tls_cache(tls_url, tls_type, false);
resp resp
} else { } else {
@@ -978,24 +1017,63 @@ pub async fn do_check_software_update() -> hbb_common::ResultType<()> {
} }
} }
}; };
let bytes = latest_release_response.bytes().await?; if !response.status().is_success() {
let resp: hbb_common::VersionCheckResponse = serde_json::from_slice(&bytes)?; bail!("Gitea releases API returned HTTP {}", response.status());
let response_url = resp.url; }
let latest_release_version = response_url.rsplit('/').next().unwrap_or_default(); 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) { let latest_version = release.tag_name.trim_start_matches('v');
#[cfg(feature = "flutter")] let current_version = hbb_common::config::AGENT_VERSION.read().unwrap().clone();
{ let current_version = if current_version.is_empty() {
let mut m = HashMap::new(); crate::VERSION.to_owned()
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;
} else { } else {
current_version
};
if get_version_number(latest_version) <= get_version_number(&current_version) {
*SOFTWARE_UPDATE_URL.lock().unwrap() = "".to_string(); *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(()) Ok(())
} }
+61 -20
View File
@@ -1339,14 +1339,30 @@ pub fn copy_raw_cmd(src_raw: &str, _raw: &str, _path: &str) -> ResultType<String
} }
pub fn copy_exe_cmd(src_exe: &str, exe: &str, path: &str) -> ResultType<String> { pub fn copy_exe_cmd(src_exe: &str, exe: &str, path: &str) -> ResultType<String> {
let main_exe = copy_raw_cmd(src_exe, exe, path)?; // hello-agent local patch: upstream emits an `XCOPY <parent of src_exe>
// <install dir> /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
// <staged-name>.exe → <app_name>.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!( Ok(format!(
" "copy /Y \"{src_exe}\" \"{path}\\{src_filename}\"\n"
{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,
)) ))
} }
@@ -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\<APP_NAME>` — 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 = {
let reg_cmd_main = get_reg_cmd( let reg_cmd_main = if subkey_exists(&subkey) {
&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) {
get_reg_cmd( get_reg_cmd(
&reg_msi_key, &subkey,
is_msi, is_msi,
&display_icon, &display_icon,
&version, &version,
@@ -3137,6 +3159,25 @@ reg add {subkey} /f /v EstimatedSize /t REG_DWORD /d {size}
} else { } else {
"".to_owned() "".to_owned()
}; };
let reg_cmd_msi = if let Some(reg_msi_key) = get_reg_msi_key(&subkey, is_msi) {
if subkey_exists(&reg_msi_key) {
get_reg_cmd(
&reg_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) format!("{}{}", reg_cmd_main, reg_cmd_msi)
}; };
+101 -61
View File
@@ -118,8 +118,16 @@ fn start_auto_update_check_(rx_msg: Receiver<UpdateMsg>) {
} }
fn check_update(manually: bool) -> ResultType<()> { 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")] #[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)) { if !(manually || config::Config::get_bool_option(config::keys::OPTION_ALLOW_AUTO_UPDATE)) {
return Ok(()); return Ok(());
} }
@@ -128,70 +136,102 @@ fn check_update(manually: bool) -> ResultType<()> {
return Ok(()); return Ok(());
} }
let update_url = crate::common::SOFTWARE_UPDATE_URL.lock().unwrap().clone(); // hello-agent local patch: the upstream code reconstructed the download
if update_url.is_empty() { // 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."); log::debug!("No update available.");
} else { return Ok(());
let download_url = update_url.replace("tag", "download"); };
let version = download_url.split('/').last().unwrap_or_default(); let download_url = assets.binary_url;
#[cfg(target_os = "windows")] let sha256_url = assets.sha256_url;
let download_url = if cfg!(feature = "flutter") { let version = assets.binary_name;
format!( log::debug!("New version available: {}", &version);
"{}/rustdesk-{}-x86_64.{}", let client = create_http_client_with_url(&download_url);
download_url, let Some(file_path) = get_download_file_from_url(&download_url) else {
version, bail!("Failed to get the file path from the URL: {}", download_url);
if update_msi { "msi" } else { "exe" } };
) 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::<u64>().ok());
let Some(total_size) = total_size else {
bail!("Failed to get content length");
};
if file_size == total_size {
is_file_exists = true;
} else { } else {
format!("{}/rustdesk-{}-x86-sciter.exe", download_url, version) std::fs::remove_file(&file_path)?;
};
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::<u64>().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 !is_file_exists {
if !response.status().is_success() { let response = client.get(&download_url).send()?;
bail!( if !response.status().is_success() {
"Failed to download the new version file: {}", bail!(
response.status() "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);
} }
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 (`<hex> <filename>`) or just `<hex>`.
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(()) Ok(())
} }
+1 -1
View File
@@ -1,3 +1,3 @@
pub const VERSION: &str = "1.4.6"; pub const VERSION: &str = "1.4.6";
#[allow(dead_code)] #[allow(dead_code)]
pub const BUILD_DATE: &str = "2026-05-09 10:43"; pub const BUILD_DATE: &str = "2026-05-21 13:02";