Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb00ac1101 | |||
| 8cff0c1863 |
Generated
+1
-1
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Vendored
+100
-22
@@ -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(¤t_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
@@ -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(
|
||||||
®_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(®_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)
|
format!("{}{}", reg_cmd_main, reg_cmd_msi)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Vendored
+101
-61
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+1
-1
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user