4 Commits

Author SHA1 Message Date
mike 6bdf1058fa Implement remote execution
build-windows / build-hello-agent-x64 (push) Successful in 5m2s
build-windows / sign-hello-agent-x64 (push) Successful in 5s
build-windows / validate-hello-agent-x64 (push) Successful in 6s
2026-05-22 14:18:25 +02:00
mike 6807fe2bc0 Implement signed API communication to improve security
build-windows / build-hello-agent-x64 (push) Successful in 4m52s
build-windows / sign-hello-agent-x64 (push) Successful in 5s
build-windows / validate-hello-agent-x64 (push) Successful in 6s
2026-05-22 13:13:05 +02:00
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
18 changed files with 1255 additions and 218 deletions
Generated
+1 -1
View File
@@ -3197,7 +3197,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hello-agent"
version = "0.1.1"
version = "0.1.5"
dependencies = [
"anyhow",
"env_logger 0.10.2",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "hello-agent"
version = "0.1.1"
version = "0.1.5"
edition = "2021"
rust-version = "1.75"
description = "Headless RustDesk-protocol-compatible support agent for Windows"
@@ -24,7 +24,7 @@ path = "src/main.rs"
librustdesk = { package = "rustdesk", path = "vendor/rustdesk", default-features = false, features = ["use_dasp", "hwcodec", "vram"] }
hbb_common = { path = "vendor/rustdesk/libs/hbb_common" }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "io-util"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "io-util", "process"] }
log = "0.4"
env_logger = "0.10"
anyhow = "1"
+102 -16
View File
@@ -42,7 +42,7 @@ empty, so `--config` always wins.
```
hello-agent.exe --install
└──> creates Windows service "HelloAgent", binPath ends in --service
└──> creates Windows service "hello-agent", binPath ends in --service
hello-agent.exe --service # Session 0, LocalSystem
@@ -60,6 +60,18 @@ hello-agent.exe --server # user session, SYSTEM token
│ └─ stamps `agent_name` / `agent_version` / `inventory`
│ into each /api/sysinfo payload (re-uploads when the
│ inventory collector below transitions empty → ready)
│ └─ signs every request with the device's Ed25519 sk
│ (same key rendezvous registers via RegisterPk).
│ The server's first valid sig flips that peer to
│ `managed=1` and unsigned posts get 401 from then on.
│ Spec: rustdesk-server/docs/AGENT-API-AUTH.md
├── exec::run_loop (background thread)
│ └─ subscribes to sync.rs's EXEC_SENDER broadcast; for
│ each queued PowerShell command runs `powershell.exe
│ -NoProfile -NonInteractive -Command -`, captures
│ stdout+stderr with 1 MiB cap & 5-min timeout, POSTs
│ signed result to /api/agent/exec-result. Idle unless
│ an admin dispatches via the dashboard.
├── inventory::collect_inventory (background thread)
│ └─ PowerShell + WMI + wlanapi + ipify → `INVENTORY` global
│ consumed by hbbs_http::sync above; one-shot, no retry
@@ -115,6 +127,15 @@ inventory — keep it in sync when adding new patches.
`--cm` process can plug a `MessageBoxW`-based `InvokeUiCM` into
upstream's connection-manager IPC loop and inherit file-transfer,
chat, and clipboard handling rather than re-implementing them.
* `mod hbbs_http``pub mod hbbs_http` so hello-agent's
`unattended_password::try_report` and `exec::run_loop` can reach
`librustdesk::hbbs_http::sign::build_signed_headers` and
`librustdesk::hbbs_http::sync::exec_signal_receiver`. Without
this the in-crate code can't sign / can't subscribe to the
server's queued PowerShell commands, and the build fails with
`E0603: module 'hbbs_http' is private`. Tightly coupled to the
**Signed agent API** and **Remote PowerShell exec** divergences
below.
2. **Build shape** — [`vendor/rustdesk/Cargo.toml`](vendor/rustdesk/Cargo.toml):
`[lib] crate-type` reduced from `["cdylib", "staticlib", "rlib"]` to
`["rlib"]`. We statically link the rlib into hello-agent.exe; the
@@ -148,6 +169,71 @@ inventory — keep it in sync when adding new patches.
[`src/main.rs`](vendor/rustdesk/src/main.rs) (`.author(...)`).
Cosmetic, but they show through in the Windows EXE metadata and
in-app error dialogs.
6. **Signed agent API** — every `POST /api/heartbeat`,
`POST /api/sysinfo`, and `POST /api/unattended-password` carries
two extra headers (`X-RD-Device-Id`,
`X-RD-Signature: v1.<ts>.<base64-ed25519-sig>`) so the server can
bind the request to the device's existing rendezvous keypair
instead of trusting the `id` + `uuid` body fields. Without this
patch, anyone who knows a peer's id and uuid can inject inventory,
heartbeats, and unattended-access passwords for it. Three patch
sites in the vendor tree (plus one in the hello-agent crate):
* New file
[`src/hbbs_http/sign.rs`](vendor/rustdesk/src/hbbs_http/sign.rs) —
the signer (`build_signed_headers`, `path_from_url`). Reads
`Config::get_key_pair()` and `Config::get_id()`; uses the
re-exported `hbb_common::sodiumoxide`.
* [`src/hbbs_http.rs`](vendor/rustdesk/src/hbbs_http.rs) — adds
`pub mod sign;` next to the existing module declarations.
* [`src/common.rs`](vendor/rustdesk/src/common.rs) — the
`post_request_` and `parse_simple_header` header-string parsers
now accept a `\n`-separated list of `Name: Value` lines so we
can pass both signing headers in one call. Old single-pair
callers parse identically — there's no newline to split on.
* [`src/hbbs_http/sync.rs`](vendor/rustdesk/src/hbbs_http/sync.rs)
call sites (the sysinfo POST around the sysinfo-version
comparison block, and the heartbeat POST a few dozen lines
later) — both build a signed-headers string via
`crate::hbbs_http::sign::build_signed_headers("POST",
&path_from_url(&url), body.as_bytes()).unwrap_or_default()`
and pass it to `post_request` instead of `""`.
And in the hello-agent crate proper (not the vendor tree, no
re-sync concern):
* [`src/unattended_password.rs`](src/unattended_password.rs) —
`try_report` also signs its `POST /api/unattended-password`
via `librustdesk::hbbs_http::sign::build_signed_headers`.
Matching server side: see rustdesk-server's
[`docs/AGENT-API-AUTH.md`](https://github.com/cstudio-ch/rustdesk-server/blob/pro-features/docs/AGENT-API-AUTH.md)
for the wire format and verification flow.
7. **Remote PowerShell exec** — the dashboard can queue a PowerShell
script for a managed peer; the agent runs it as its service account
and POSTs the result back. Gated server-side on admin role +
`peer.managed=1` + strategy `enable-remote-exec=Y`. Vendor-tree
patches:
* [`src/hbbs_http/sync.rs`](vendor/rustdesk/src/hbbs_http/sync.rs) —
new `EXEC_SENDER` broadcast channel, new `ExecRequest` type, new
`pub fn exec_signal_receiver()` helper, and the heartbeat-reply
parser drains the `exec: [...]` field into the channel. Vanilla
rustdesk simply has no subscriber — the channel send errors out
with NoReceivers and the requests are dropped silently.
In the hello-agent crate:
* [`src/exec.rs`](src/exec.rs) — the PowerShell runner. Subscribes
to the broadcast channel above, spawns
`powershell.exe -NoProfile -NonInteractive -ExecutionPolicy
Bypass -Command -`, writes the script to stdin, captures
stdout+stderr with 1 MiB cap and a 5-minute wall-clock timeout,
signs and POSTs the result to `/api/agent/exec-result`. Started
from `run_server()` in [`src/main.rs`](src/main.rs) (must live in
the `--server` process to share the broadcast channel with
sync.rs).
* [`Cargo.toml`](Cargo.toml) — adds `process` to tokio's feature
list for `tokio::process::Command`.
Server-side spec: see [`docs/AGENT-API-AUTH.md`](https://github.com/cstudio-ch/rustdesk-server/blob/pro-features/docs/AGENT-API-AUTH.md)
§*Remote PowerShell exec*.
## Build
@@ -191,7 +277,7 @@ To pull updates from upstream RustDesk:
## Stale keys / supporter "stuck on connecting"
The agent's identity (`id`) and `key_pair` live in `HelloAgent.toml`.
The agent's identity (`id`) and `key_pair` live in `hello-agent.toml`.
They're generated once on first run, registered with the rendezvous
server, and re-used forever after. **If the rendezvous server's cached
entry and the agent's local keypair drift apart, the encrypted handshake
@@ -221,7 +307,7 @@ dir so the agent keypair survives an uninstall→reinstall cycle. To force
a fresh keypair, also run after `--uninstall`:
```
rmdir /s /q "%SystemRoot%\ServiceProfiles\LocalService\AppData\Roaming\HelloAgent"
rmdir /s /q "%SystemRoot%\ServiceProfiles\LocalService\AppData\Roaming\hello-agent"
```
…and then delete the device record from the admin UI as above.
@@ -229,7 +315,7 @@ rmdir /s /q "%SystemRoot%\ServiceProfiles\LocalService\AppData\Roaming\HelloAgen
## Verifying end-to-end
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
ID and click Connect.
4. The agent's logged-in user sees `HelloAgent — Allow remote support?`.
@@ -241,7 +327,7 @@ rmdir /s /q "%SystemRoot%\ServiceProfiles\LocalService\AppData\Roaming\HelloAgen
`hbb_common` ships a single global, `APP_NAME`, that drives the location
of every piece of on-disk state (config dir, log dir) and the prefix of
every named pipe. Upstream defaults it to `"RustDesk"`. Hello-agent
rewrites it to `"HelloAgent"` as the very first line of `main()`
rewrites it to `"hello-agent"` as the very first line of `main()`
identical to the write path the upstream Flutter build uses for OEM
rebrands ([`read_custom_client`](vendor/rustdesk/src/common.rs)). Because
`APP_NAME` is a `RwLock<String>` read lazily on first use, doing the
@@ -252,16 +338,16 @@ In practice that means:
| What | Stock rustdesk | hello-agent |
| --------------------------------- | ----------------------------------------- | ------------------------------------------------- |
| User-mode config / logs | `%APPDATA%\RustDesk\` | `%APPDATA%\HelloAgent\` |
| Service-mode config / logs | `…\LocalService\AppData\Roaming\RustDesk\`| `…\LocalService\AppData\Roaming\HelloAgent\` |
| Identity file (id + keypair) | `RustDesk.toml` | `HelloAgent.toml` |
| IPC pipe namespace | `\\.\pipe\RustDesk\query…` | `\\.\pipe\HelloAgent\query…` |
| Windows service name | `RustDesk` | `HelloAgent` |
| User-mode config / logs | `%APPDATA%\RustDesk\` | `%APPDATA%\hello-agent\` |
| Service-mode config / logs | `…\LocalService\AppData\Roaming\RustDesk\`| `…\LocalService\AppData\Roaming\hello-agent\` |
| Identity file (id + keypair) | `RustDesk.toml` | `hello-agent.toml` |
| IPC pipe namespace | `\\.\pipe\RustDesk\query…` | `\\.\pipe\hello-agent\query…` |
| Windows service name | `RustDesk` | `hello-agent` |
| Install dir | `%ProgramFiles%\RustDesk\` | `%ProgramFiles%\hello-agent\` |
The two binaries can therefore coexist on the same machine without
clobbering each other's state. The override is set in
[`src/main.rs`](src/main.rs) (`pub const APP_NAME: &str = "HelloAgent"`)
[`src/main.rs`](src/main.rs) (`pub const APP_NAME: &str = "hello-agent"`)
— change it there if you ever need to re-brand.
## Where logs go
@@ -270,10 +356,10 @@ clobbering each other's state. The override is set in
| Mode (CLI flag) | Effective user | Log dir |
| --------------------- | ------------------------------- | ---------------------------------------------------------------------------------------- |
| `--install` / `--uninstall` | calling user (must be admin) | `%APPDATA%\HelloAgent\log\install\` (or `…\uninstall\`) |
| `--service` | LocalSystem (mirrored) | `%SystemRoot%\ServiceProfiles\LocalService\AppData\Roaming\HelloAgent\log\service\` |
| `--server` (worker) | LocalSystem (mirrored) | `%SystemRoot%\ServiceProfiles\LocalService\AppData\Roaming\HelloAgent\log\server\` |
| no flags (dev mode) | calling user | `%APPDATA%\HelloAgent\log\hello-agent\` |
| `--install` / `--uninstall` | calling user (must be admin) | `%APPDATA%\hello-agent\log\install\` (or `…\uninstall\`) |
| `--service` | LocalSystem (mirrored) | `%SystemRoot%\ServiceProfiles\LocalService\AppData\Roaming\hello-agent\log\service\` |
| `--server` (worker) | LocalSystem (mirrored) | `%SystemRoot%\ServiceProfiles\LocalService\AppData\Roaming\hello-agent\log\server\` |
| no flags (dev mode) | calling user | `%APPDATA%\hello-agent\log\hello-agent\` |
The `cm_popup` module also writes a parallel diagnostic trace at
`%TEMP%\hello-agent-cm.log` (kept around for debugging the IPC handshake;
@@ -284,7 +370,7 @@ it duplicates info that's already in the main log).
- ✅ Windows x64 (physical console *and* RDP sessions — the agent picks
whichever session the user is actively using)
- ✅ Coexists with stock RustDesk on the same box — config dir, log dir,
and named pipes are namespaced under `HelloAgent` rather than the
and named pipes are namespaced under `hello-agent` rather than the
upstream default of `RustDesk` (see [Namespacing](#namespacing) below).
The only residual contention is the optional direct-server port
(TCP 21118) and LAN-discovery port (UDP 21119); both default to off,
+10
View File
@@ -19,6 +19,16 @@
fn main() {
println!("cargo:rerun-if-changed=build.rs");
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();
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
/// the user's security context.
Cm,
/// `--update`. Self-replacement entry point launched as an elevated child
/// by the running service's updater (see `librustdesk::updater`) after it
/// has downloaded and SHA256-verified a new hello-agent.exe from the
/// Gitea releases page. `current_exe()` here points at the staged new
/// binary in `%TEMP%`; it copies itself over the installed location and
/// restarts the service via `librustdesk::platform::update_me`.
Update,
}
#[derive(Debug)]
@@ -47,6 +54,7 @@ impl ParsedArgs {
let mut service = false;
let mut server = false;
let mut cm = false;
let mut update = false;
let mut config_blob: Option<String> = None;
let mut i = 0;
@@ -56,6 +64,7 @@ impl ParsedArgs {
"--uninstall" => uninstall = true,
"--service" => service = true,
"--server" => server = true,
"--update" => update = true,
// Connection-manager popup mode. Treat `--cm-no-ui` (the
// Linux-headless variant librustdesk also tries) as a
// synonym; either way we run cm_popup.
@@ -81,14 +90,21 @@ impl ParsedArgs {
}
// Mutual-exclusion rules. --install + --config is the MDM one-liner;
// everything else is one-action-at-a-time.
let exclusive = [uninstall, service, server, cm].iter().filter(|x| **x).count();
// everything else is one-action-at-a-time. --update is launched by
// the updater as a standalone elevated child, never combined.
let exclusive = [uninstall, service, server, cm, update]
.iter()
.filter(|x| **x)
.count();
if exclusive > 1 {
bail!("--uninstall, --service, --server, --cm are mutually exclusive");
bail!("--uninstall, --service, --server, --cm, --update are mutually exclusive");
}
if uninstall && (install || config_blob.is_some()) {
bail!("--uninstall cannot be combined with other flags");
}
if update && (install || config_blob.is_some()) {
bail!("--update cannot be combined with other flags");
}
let action = if uninstall {
Action::Uninstall
@@ -100,6 +116,8 @@ impl ParsedArgs {
Action::Server
} else if cm {
Action::Cm
} else if update {
Action::Update
} else if config_blob.is_some() {
Action::ConfigOnly
} else {
@@ -131,6 +149,10 @@ OPTIONS:
--service SCM entry point. Do not invoke manually.
--server Worker mode (launched by the service shell into
the active console session).
--update Self-replacement entry point. Launched by the
running service's updater after downloading and
SHA256-verifying a new release from Gitea. Do
not invoke manually.
-h, --help Show this help.
-V, --version Show version.
@@ -191,4 +213,24 @@ mod tests {
fn unknown_arg() {
assert!(parse(&["--no-such-flag"]).is_err());
}
#[test]
fn update_alone() {
assert_eq!(parse(&["--update"]).unwrap().action, Action::Update);
}
#[test]
fn update_install_conflict() {
assert!(parse(&["--update", "--install"]).is_err());
}
#[test]
fn update_service_conflict() {
assert!(parse(&["--update", "--service"]).is_err());
}
#[test]
fn update_config_conflict() {
assert!(parse(&["--update", "--config", "BLOB"]).is_err());
}
}
+259
View File
@@ -0,0 +1,259 @@
//! PowerShell remote-exec worker.
//!
//! Subscribes to `librustdesk::hbbs_http::sync::exec_signal_receiver()` — a
//! broadcast channel that the vendored sync loop populates whenever the
//! server returns an `exec` field in a heartbeat reply (see
//! rustdesk-server/docs/AGENT-API-AUTH.md). For each `ExecRequest` we:
//!
//! 1. Spawn `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy
//! Bypass -Command -` and write the script to stdin.
//! 2. Concurrently drain stdout and stderr into 1 MiB-capped buffers.
//! 3. Apply a wall-clock timeout (default 5 min); kill on expiry.
//! 4. POST the result to `/api/agent/exec-result` with the same Ed25519
//! signature the heartbeat / sysinfo posts use.
//!
//! The whole thing only makes sense on Windows (the agent's target OS),
//! so the module body is `#[cfg(windows)]` and other platforms get a
//! no-op `start()` to keep the call site in `service.rs` portable.
#[cfg(windows)]
mod windows_impl {
use anyhow::{anyhow, Result};
use hbb_common::config::Config;
use librustdesk::hbbs_http::sync::ExecRequest;
use std::process::Stdio;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
pub fn start() {
std::thread::spawn(|| {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
log::warn!("exec worker: build runtime: {e}");
return;
}
};
rt.block_on(run_loop());
});
}
async fn run_loop() {
// The vendored sync layer creates the broadcast channel lazily on
// first `subscribe()`. Calling here also primes it for the parser.
let mut rx = librustdesk::hbbs_http::sync::exec_signal_receiver();
log::info!("exec worker: subscribed to heartbeat exec channel");
loop {
match rx.recv().await {
Ok(req) => {
log::info!(
"exec worker: received cmd_id={} script_len={} max_secs={} max_bytes={}",
req.cmd_id,
req.script.len(),
req.max_secs,
req.max_bytes
);
let outcome = run_one(&req).await;
if let Err(e) = report(&req, &outcome).await {
log::warn!(
"exec worker: report failed for cmd_id={}: {e:#}",
req.cmd_id
);
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
log::warn!("exec worker: lagged, dropped {n} exec requests");
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
log::warn!("exec worker: channel closed, exiting");
return;
}
}
}
}
struct Outcome {
exit_code: i64,
stdout: String,
stderr: String,
timed_out: bool,
truncated: bool,
}
async fn run_one(req: &ExecRequest) -> Outcome {
// Defensive lower bound — a misconfigured server shouldn't be able to
// send max_secs=0 and have us skip the wait.
let timeout = Duration::from_secs(req.max_secs.max(1));
let max_bytes = req.max_bytes.max(1024) as usize;
// `-Command -` makes PowerShell read the script body from stdin,
// which avoids quoting / length issues that plague `-Command "…"`
// for multi-line scripts. `-NoProfile` skips both the
// machine-wide and user-wide profile loads — those would change
// behaviour depending on which AD-managed PowerShell profile the
// service account inherited. `-NonInteractive` makes prompts fail
// instead of hanging the run.
let mut child = match tokio::process::Command::new("powershell.exe")
.args([
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
"-",
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) => {
return Outcome {
exit_code: -1,
stdout: String::new(),
stderr: format!("spawn failed: {e}"),
timed_out: false,
truncated: false,
};
}
};
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(req.script.as_bytes()).await;
let _ = stdin.shutdown().await;
}
let stdout = child.stdout.take().expect("piped stdout was requested");
let stderr = child.stderr.take().expect("piped stderr was requested");
// Concurrent capped readers. Each task accumulates up to
// `max_bytes` bytes, then drains and discards the rest so the
// pipe doesn't block the child writer.
let stdout_buf: Arc<Mutex<(Vec<u8>, bool)>> = Arc::new(Mutex::new((Vec::new(), false)));
let stderr_buf: Arc<Mutex<(Vec<u8>, bool)>> = Arc::new(Mutex::new((Vec::new(), false)));
let so = tokio::spawn(read_capped(stdout, stdout_buf.clone(), max_bytes));
let se = tokio::spawn(read_capped(stderr, stderr_buf.clone(), max_bytes));
let wait_result = tokio::time::timeout(timeout, child.wait()).await;
let (exit_code, timed_out) = match wait_result {
Ok(Ok(s)) => (s.code().unwrap_or(-1) as i64, false),
Ok(Err(_)) => (-1, false),
Err(_) => {
// Timed out: kill, then wait the killed child so it
// reaps cleanly (and so the read tasks finish via EOF).
let _ = child.kill().await;
let _ = child.wait().await;
(-1, true)
}
};
let _ = so.await;
let _ = se.await;
let (out_bytes, out_trunc) = {
let g = stdout_buf.lock().unwrap();
(g.0.clone(), g.1)
};
let (err_bytes, err_trunc) = {
let g = stderr_buf.lock().unwrap();
(g.0.clone(), g.1)
};
Outcome {
exit_code,
// PowerShell on a current Windows defaults to UTF-8 when
// OutputEncoding is set, but the agent service inherits the
// legacy code page on older boxes. `from_utf8_lossy`
// guarantees we always have a UTF-8 string to ship; the
// operator sees a U+FFFD when raw bytes weren't UTF-8.
stdout: String::from_utf8_lossy(&out_bytes).into_owned(),
stderr: String::from_utf8_lossy(&err_bytes).into_owned(),
timed_out,
truncated: out_trunc || err_trunc,
}
}
async fn read_capped<R: AsyncReadExt + Unpin>(
mut reader: R,
buf: Arc<Mutex<(Vec<u8>, bool)>>,
cap: usize,
) {
let mut chunk = [0u8; 8192];
loop {
match reader.read(&mut chunk).await {
Ok(0) => return,
Ok(n) => {
let mut g = buf.lock().unwrap();
if g.0.len() < cap {
let room = cap - g.0.len();
if n <= room {
g.0.extend_from_slice(&chunk[..n]);
} else {
g.0.extend_from_slice(&chunk[..room]);
g.1 = true; // truncated; keep draining
}
}
// else: already truncated, drop this chunk on the floor.
}
Err(_) => return,
}
}
}
async fn report(req: &ExecRequest, out: &Outcome) -> Result<()> {
let api = librustdesk::common::get_api_server(
Config::get_option("api-server"),
Config::get_option("custom-rendezvous-server"),
);
if api.is_empty() {
return Err(anyhow!("no api-server configured"));
}
let url = format!("{api}/api/agent/exec-result");
let id = Config::get_id();
let uuid = librustdesk::common::encode64(hbb_common::get_uuid());
let body = hbb_common::serde_json::json!({
"id": id,
"uuid": uuid,
"cmd_id": req.cmd_id,
"exit_code": out.exit_code,
"stdout": out.stdout,
"stderr": out.stderr,
"timed_out": out.timed_out,
"truncated": out.truncated,
})
.to_string();
let headers = librustdesk::hbbs_http::sign::build_signed_headers(
"POST",
"/api/agent/exec-result",
body.as_bytes(),
)
.unwrap_or_default();
if headers.is_empty() {
// Server rejects unsigned exec-result posts unconditionally
// (see api/agent_exec.rs); bail loudly so the operator can
// see the agent isn't ready to sign yet.
return Err(anyhow!("no signing keypair available"));
}
let resp = librustdesk::common::post_request(url, body, &headers)
.await
.map_err(|e| anyhow!("post: {e}"))?;
if resp.trim() == "OK" {
Ok(())
} else {
Err(anyhow!("unexpected response: {}", resp.trim()))
}
}
}
#[cfg(windows)]
pub use windows_impl::start;
#[cfg(not(windows))]
pub fn start() {
log::info!("exec worker: skipped (non-Windows build)");
}
+48
View File
@@ -105,6 +105,53 @@ try {
$public_ip = (Invoke-RestMethod -Uri 'https://api.ipify.org' -TimeoutSec 5 -ErrorAction Stop).ToString().Trim()
} 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)"
if ($displayVersion) { $os_release = "$($os.Version) $displayVersion" }
$result = [pscustomobject]@{
@@ -123,6 +170,7 @@ $result = [pscustomobject]@{
bitlocker_recovery_key = $bl_key
network_interfaces = $nics
public_ip = $public_ip
installed_software = $installed_software
}
$result | ConvertTo-Json -Compress -Depth 6
"#;
+71 -7
View File
@@ -15,7 +15,7 @@
// lazily from a `RwLock<String>` whenever any path is computed (config dir,
// log dir, named-pipe namespace, …), so setting it before any of those
// initializers fire is enough to redirect all hbb_common state under
// `%APPDATA%\HelloAgent\` and the matching LocalService path. Identical
// `%APPDATA%\hello-agent\` and the matching LocalService path. Identical
// to the `read_custom_client` write path the upstream Flutter build uses
// for OEM rebrands.
@@ -28,6 +28,8 @@ mod inventory;
#[cfg(target_os = "windows")]
mod cm_popup;
#[cfg(target_os = "windows")]
mod exec;
#[cfg(target_os = "windows")]
mod service;
#[cfg(target_os = "windows")]
mod unattended_password;
@@ -39,17 +41,28 @@ use cli::{Action, ParsedArgs};
/// Product name used to namespace all on-disk state and the IPC pipe path.
/// Written into `hbb_common::config::APP_NAME` at the top of `main` so
/// every subsequent path computation (config dir, log dir, named pipe)
/// targets `%APPDATA%\HelloAgent\` rather than the upstream default of
/// targets `%APPDATA%\hello-agent\` rather than the upstream default of
/// `%APPDATA%\RustDesk\`. Must be set before any code touches a path —
/// `hbb_common` initializes path globals lazily on first read.
pub const APP_NAME: &str = "HelloAgent";
///
/// Important: this value also drives upstream's installer lookup paths.
/// `librustdesk::platform::get_install_info` computes the expected install
/// dir as `%ProgramFiles%\<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:
/// * In **debug** builds: installs `env_logger` writing to stderr.
/// * In **release** builds: installs `flexi_logger` writing to a rolling
/// file under `<config_dir>/log/<mode>/` — the SYSTEM service log ends
/// up at `%SystemRoot%\ServiceProfiles\LocalService\AppData\Roaming\HelloAgent\log\<mode>\`
/// and the user-mode log at `%APPDATA%\HelloAgent\log\<mode>\`.
/// up at `%SystemRoot%\ServiceProfiles\LocalService\AppData\Roaming\hello-agent\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
/// chatter doesn't tangle with --install diagnostics. `init_log` is
@@ -65,7 +78,7 @@ fn main() {
// we'd never recover.
*hbb_common::config::APP_NAME.write().unwrap() = APP_NAME.to_owned();
// Identify ourselves to the rustdesk-server's /api/sysinfo endpoint
// so the admin Devices page can show "HelloAgent 0.1.0" instead of
// so the admin Devices page can show "hello-agent 0.1.0" instead of
// the embedded rustdesk core version. These RwLocks are read once
// per sysinfo upload by hbbs_http::sync; setting them here (before
// start_server) ensures the very first upload carries the identity.
@@ -90,10 +103,40 @@ fn main() {
Action::Service => "service",
Action::Server => "server",
Action::Cm => "cm",
Action::Update => "update",
Action::ConfigOnly | Action::None => "hello-agent",
};
init_logging(mode);
// --update is the self-replacement re-entry: the running service's
// updater downloads a new hello-agent.exe to %TEMP%, verifies its
// SHA256, then launches `<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)
// but on its own is a separate operation. Apply it first so --install
// sees the populated config.
@@ -108,7 +151,14 @@ fn main() {
// (or a prior install) already set custom-rendezvous-server, this is a
// no-op. Without this, a bare `hello-agent.exe --install` would land
// at an unconfigured agent that can't reach any server.
config_import::apply_defaults_if_empty();
//
// Skipped for `--uninstall`: an uninstall flow has no business mutating
// the calling user's config, and otherwise we'd write defaults into
// %APPDATA% right before tearing the agent down. (`--update` is
// dispatched in the early-return block above and never reaches here.)
if parsed.action != Action::Uninstall {
config_import::apply_defaults_if_empty();
}
match parsed.action {
Action::Install => {
@@ -172,6 +222,11 @@ fn main() {
// can watch logs. Production deployments use --install + --service.
run_server();
}
Action::Update => {
// Handled in the early-return block above (before config-import).
// The match has to cover this variant for exhaustiveness.
unreachable!("Action::Update is dispatched before this match");
}
}
}
@@ -225,6 +280,15 @@ fn run_server() {
}
});
// Start the PowerShell remote-exec worker. Subscribes to the
// broadcast channel in the vendored sync layer; the channel is
// shared in-process so the worker MUST run in this --server process
// (where sync.rs lives), not the --service supervisor. The worker
// is idle until an admin dispatches an exec from the dashboard.
// Gated server-side on peer.managed=1 + strategy.enable-remote-exec.
#[cfg(target_os = "windows")]
exec::start();
// `start_server` is `#[tokio::main]` and runs forever. (is_server=true,
// no_server=false). It boots the default IPC server, input service,
// rendezvous mediator, and heartbeat sync.
+268 -69
View File
@@ -3,7 +3,7 @@
// Three responsibilities:
//
// 1. `install()` — copy the binary to %ProgramFiles%\hello-agent, mirror the
// calling user's `HelloAgent.toml` into the LocalService-effective
// calling user's `hello-agent.toml` into the LocalService-effective
// config dir so the SYSTEM service inherits the --config blob, register
// the service with the SCM pointing at the installed exe, and start it.
// Idempotent.
@@ -29,14 +29,21 @@ use std::sync::Arc;
use std::time::{Duration, Instant};
use windows_service::service::{
ServiceAccess, ServiceControl, ServiceControlAccept, ServiceErrorControl, ServiceExitCode,
ServiceAccess, ServiceAction, ServiceActionType, ServiceControl, ServiceControlAccept,
ServiceErrorControl, ServiceExitCode, ServiceFailureActions, ServiceFailureResetPeriod,
ServiceInfo, ServiceStartType, ServiceState, ServiceStatus, ServiceType,
};
use windows_service::service_control_handler::{self, ServiceControlHandlerResult};
use windows_service::service_dispatcher;
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
const SERVICE_NAME: &str = "HelloAgent";
/// Internal service name registered with the SCM. Must equal `crate::APP_NAME`
/// because upstream `librustdesk::platform::is_self_service_running` queries
/// `is_service_running(&crate::get_app_name())` — i.e. it looks up the
/// service whose name *is* the app name. If these diverge, the `--update`
/// path's `sc stop` / `sc start` use the wrong name and the service is
/// left in a Stopped state after a self-update.
const SERVICE_NAME: &str = crate::APP_NAME;
const DISPLAY_NAME: &str = "HelloAgent Remote Support";
const SERVICE_DESCRIPTION: &str =
"HelloAgent — headless remote-support agent (RustDesk-protocol-compatible). \
@@ -47,6 +54,11 @@ const SERVICE_TYPE: ServiceType = ServiceType::OWN_PROCESS;
const INSTALL_SUBDIR: &str = "hello-agent";
const INSTALLED_EXE_NAME: &str = "hello-agent.exe";
/// Display name used for the Windows Firewall rule. Stable across versions
/// so `--uninstall` (or a re-install that clears it before re-adding) can
/// find and delete the prior entry by name.
const FIREWALL_RULE_NAME: &str = "HelloAgent";
// ----------------------------- paths ---------------------------------------
/// `%ProgramFiles%\hello-agent`. Falls back to `C:\Program Files\hello-agent`
@@ -68,9 +80,9 @@ fn install_dir() -> PathBuf {
/// Note the trailing `config` segment: `directories_next::ProjectDirs`,
/// which hbb_common uses on Windows, appends a literal `\config` to the
/// app's roaming dir (so the user-side path is
/// `%APPDATA%\HelloAgent\config\HelloAgent.toml`, not
/// `…\HelloAgent\…`). The SYSTEM-side path follows the same convention.
/// The `HelloAgent` segment is sourced from `crate::APP_NAME` so it stays
/// `%APPDATA%\hello-agent\config\hello-agent.toml`, not
/// `…\hello-agent\…`). The SYSTEM-side path follows the same convention.
/// The `hello-agent` segment is sourced from `crate::APP_NAME` so it stays
/// in lockstep with the `APP_NAME` we install into hbb_common at startup.
fn service_config_dir() -> PathBuf {
let system_root = std::env::var_os("SystemRoot")
@@ -88,11 +100,15 @@ fn service_config_dir() -> PathBuf {
// ----------------------------- install --------------------------------------
pub fn install() -> Result<()> {
// Probe-open the SCM with CREATE_SERVICE rights up front; if the caller
// isn't elevated this fails with ERROR_ACCESS_DENIED (raw_os_error == 5)
// and we surface a single human-readable message instead of bubbling
// up a Win32 errno string. Anything else propagates as-is.
let scm = ServiceManager::local_computer(
None::<&str>,
ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE,
)
.context("open SCM")?;
.map_err(map_scm_open_error)?;
// 1. If a previous install left a running service, stop it before we
// overwrite its binary. Otherwise the file copy in step 2 fails
@@ -106,8 +122,8 @@ pub fn install() -> Result<()> {
// idempotent / usable as an in-place update — without it, the
// `stage_binary` file copy below fails with "access denied"
// whenever a `--cm` child is still holding the old exe open.
// `kill_orphan_processes` uses taskkill with `/FI "PID ne <ours>"`
// so it never kills the running installer.
// `kill_orphan_processes` walks the process table via sysinfo and
// filters out our own pid so the installer doesn't suicide.
kill_orphan_processes();
// 2. Pin the binary to %ProgramFiles%\hello-agent. The user might be
@@ -120,17 +136,17 @@ pub fn install() -> Result<()> {
// first, fall back to popup). Older hello-agent installs wrote
// "click" here, which disabled the password path; clearing it
// every install makes upgrades idempotent. These write into the
// *calling user's* %APPDATA%\HelloAgent\ — we mirror the result
// *calling user's* %APPDATA%\hello-agent\ — we mirror the result
// into the service's effective dir in step 4.
hbb_common::config::Config::set_option("stop-service".into(), "".into());
hbb_common::config::Config::set_option("approve-mode".into(), "".into());
// 4. Mirror the calling user's `HelloAgent.toml` / `HelloAgent2.toml`
// 4. Mirror the calling user's `hello-agent.toml` / `hello-agent2.toml`
// into the LocalService-effective config root that the SYSTEM
// service will actually read. Without this, --config writes to e.g.
// C:\Users\Admin\AppData\Roaming\HelloAgent\, but the service runs
// C:\Users\Admin\AppData\Roaming\hello-agent\, but the service runs
// as LocalSystem and (via hbb_common's `patch()`) reads from
// C:\Windows\ServiceProfiles\LocalService\AppData\Roaming\HelloAgent\.
// C:\Windows\ServiceProfiles\LocalService\AppData\Roaming\hello-agent\.
if let Err(e) = mirror_config_to_service_dir() {
log::warn!(
"could not mirror config to service dir ({e:#}); the service may not see --config until first heartbeat"
@@ -183,6 +199,60 @@ pub fn install() -> Result<()> {
let _ = svc.set_description(SERVICE_DESCRIPTION);
// 5b. Configure SCM auto-restart on unexpected exit. Without this,
// a panic in the `--service` supervisor leaves the agent permanently
// Stopped until the host reboots. The schedule restarts after
// 5s, 30s, 60s and gives up after that; the failure-count reset
// window is one day, so transient hiccups don't accumulate and
// stable hosts converge back to "running" within a minute.
//
// `set_failure_actions_on_non_crash_failures(true)` is what makes
// these actions fire when the service exits cleanly with a non-zero
// code (panic via abort, for instance), not just on outright
// crashes detected by the SCM. Both are best-effort; the SCM
// accepts the call but doesn't error if the underlying ChangeServiceConfig2
// fails for some reason — we log and continue.
let failure_actions = ServiceFailureActions {
reset_period: ServiceFailureResetPeriod::After(Duration::from_secs(60 * 60 * 24)),
reboot_msg: None,
command: None,
actions: Some(vec![
ServiceAction {
action_type: ServiceActionType::Restart,
delay: Duration::from_secs(5),
},
ServiceAction {
action_type: ServiceActionType::Restart,
delay: Duration::from_secs(30),
},
ServiceAction {
action_type: ServiceActionType::Restart,
delay: Duration::from_secs(60),
},
]),
};
if let Err(e) = svc.update_failure_actions(failure_actions) {
log::warn!("could not set SCM failure actions ({e}); auto-restart-on-crash disabled");
}
if let Err(e) = svc.set_failure_actions_on_non_crash_failures(true) {
log::warn!(
"could not enable failure actions for clean-exit-with-error ({e}); only hard crashes will trigger restart"
);
}
// 5c. Allow inbound TCP/UDP to hello-agent.exe at the Windows Firewall.
// A vanilla deploy doesn't actually need it (the rendezvous/relay
// connections are outbound), but operators who enable `direct-server`
// (TCP 21118) or `enable-lan-discovery` (UDP 21119) via the --config
// blob need this rule or those features silently fail. Cheaper to
// add it always than to discover at support-call time that the
// deploy never matched a firewall rule. Best-effort: if netsh
// isn't present (extremely stripped-down server SKUs) we log and
// continue.
if let Err(e) = install_firewall_rule(&target_exe) {
log::warn!("could not install firewall rule ({e:#}); inbound connections may be blocked");
}
// 6. Start the service. (Step 1 already stopped any prior instance.)
svc.start::<&str>(&[]).context("start service")?;
log::info!(
@@ -250,7 +320,7 @@ fn stage_binary() -> Result<PathBuf> {
Ok(dest)
}
/// Copy the calling user's `HelloAgent.toml` + `HelloAgent2.toml` into
/// Copy the calling user's `hello-agent.toml` + `hello-agent2.toml` into
/// the LocalService-effective config dir so the SYSTEM service sees them.
fn mirror_config_to_service_dir() -> Result<()> {
let dest_dir = service_config_dir();
@@ -272,7 +342,7 @@ fn mirror_config_to_service_dir() -> Result<()> {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
// Calling user never had this file (e.g. --install without
// --config, or first ever run on this machine, or the user
// wiped %APPDATA%\HelloAgent\ between tests). Logged at
// wiped %APPDATA%\hello-agent\ between tests). Logged at
// info so the post-install log shows clearly which toml
// files were available and which weren't.
log::info!(
@@ -298,6 +368,16 @@ fn mirror_config_to_service_dir() -> Result<()> {
// ----------------------------- uninstall ------------------------------------
pub fn uninstall() -> Result<()> {
// Probe-open the SCM with the rights we'll need (CONNECT for the SCM
// handle itself, and DELETE on the per-service open below). The same
// elevation-error mapping as install() — surface a single clear message
// when the operator forgot the elevated prompt.
let scm = ServiceManager::local_computer(
None::<&str>,
ServiceManagerAccess::CONNECT,
)
.map_err(map_scm_open_error)?;
// Kill every hello-agent.exe process except ourselves *first*. We can't
// rely on the SCM Stop control alone because the `--cm` child spawned
// via `run_as_user` runs under the logged-in user's token, not SYSTEM,
@@ -305,15 +385,9 @@ pub fn uninstall() -> Result<()> {
// Doing this up front means the SCM stop below is usually a no-op
// (service process already gone) and the rmdir at the end no longer
// races a lingering child holding hello-agent.exe open. Our own PID
// is excluded via taskkill's `/FI` so the uninstaller doesn't suicide.
// is excluded via the sysinfo filter so the uninstaller doesn't suicide.
kill_orphan_processes();
let scm = ServiceManager::local_computer(
None::<&str>,
ServiceManagerAccess::CONNECT,
)
.context("open SCM")?;
match scm.open_service(
SERVICE_NAME,
ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
@@ -343,9 +417,17 @@ pub fn uninstall() -> Result<()> {
Err(e) => return Err(anyhow!("open_service: {e}")),
}
// Remove the firewall rule we installed (best-effort). netsh delete is
// idempotent — if the rule was never there (or someone manually removed
// it) netsh returns 1 with "No rules match the specified criteria",
// which we treat as success.
if let Err(e) = delete_firewall_rule() {
log::warn!("could not delete firewall rule ({e:#}); remove it manually if needed");
}
cleanup_install_dir();
// We deliberately do NOT delete the LocalService config dir here.
// `HelloAgent.toml` in that directory holds the agent's id + keypair,
// `hello-agent.toml` in that directory holds the agent's id + keypair,
// which the rustdesk-server / rendezvous server has registered against
// the agent's id. Wiping it forces the next --install to generate
// fresh keys, which the rendezvous server's cached entry (and any
@@ -354,7 +436,7 @@ pub fn uninstall() -> Result<()> {
// the connection sits idle until the peer times out.
//
// Operators who want a true hard wipe can run:
// rmdir /s /q "%SystemRoot%\ServiceProfiles\LocalService\AppData\Roaming\HelloAgent"
// rmdir /s /q "%SystemRoot%\ServiceProfiles\LocalService\AppData\Roaming\hello-agent"
// and then delete the device record from the rustdesk-server admin UI.
log::info!("preserved LocalService config dir to keep agent keys/id stable across reinstalls");
Ok(())
@@ -365,58 +447,175 @@ pub fn uninstall() -> Result<()> {
/// old `--cm` child holding the exe open) and `--uninstall` (so the
/// rmdir at the end isn't racing a lingering child).
///
/// Shells out to the built-in `taskkill` rather than re-implementing the
/// Toolhelp32 enumeration in winapi: taskkill ships in every Windows
/// install since XP, runs in milliseconds, and the `/FI "PID ne <ours>"`
/// filter handles the "don't suicide ourselves" requirement declaratively.
///
/// Exit code 128 from taskkill means "no matching processes" — common
/// case when there's no orphan to clean up — and we treat it the same
/// as success. Anything else gets logged but does not fail the caller.
/// Walks the process table via `hbb_common::sysinfo` (the same enumerator
/// the vendored rustdesk uses internally) and calls `Process::kill` —
/// equivalent to `TerminateProcess` under the hood. After issuing the
/// kills we poll the process table for actual exit rather than guessing
/// at a 500 ms sleep: `TerminateProcess` marks the process as exited but
/// the kernel takes a variable amount of time to release the image-file
/// handle, and we only want to return once those handles are gone (so
/// the install-time file copy and uninstall-time rmdir don't race a
/// half-finalized victim).
fn kill_orphan_processes() {
// hbb_common pulls the rustdesk-org sysinfo 0.29 fork, which exposes
// System/Process/Pid with inherent methods (no SystemExt/ProcessExt
// trait imports needed — that style was removed when this fork
// diverged from upstream 0.30).
use hbb_common::sysinfo::{Pid, System};
let our_pid = std::process::id();
let pid_filter = format!("PID ne {our_pid}");
let output = std::process::Command::new("taskkill")
.args([
"/F",
"/IM",
INSTALLED_EXE_NAME,
"/FI",
&pid_filter,
])
.output();
match output {
Ok(out) => {
let code = out.status.code();
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
if out.status.success() {
log::info!(
"taskkill killed orphan {INSTALLED_EXE_NAME} processes (excluding pid {our_pid}): {}",
stdout.trim()
);
// TerminateProcess is synchronous w.r.t. the kernel marking
// the process as exited, but kernel-mode finalization
// (releasing file handles, paging out the image section)
// can lag by up to a few hundred ms. The rmdir that follows
// races against this: without the pause, an immediate
// remove_dir_all can still see "file in use" on the just-
// killed process's exe.
std::thread::sleep(Duration::from_millis(500));
} else if code == Some(128) {
log::info!("no orphan {INSTALLED_EXE_NAME} processes to kill");
let target = INSTALLED_EXE_NAME;
let mut system = System::new();
system.refresh_processes();
let victims: Vec<Pid> = system
.processes()
.iter()
.filter(|(pid, p)| {
pid.as_u32() != our_pid && p.name().eq_ignore_ascii_case(target)
})
.map(|(pid, _)| *pid)
.collect();
if victims.is_empty() {
log::info!("no orphan {target} processes to kill");
return;
}
let killed: Vec<u32> = victims
.iter()
.filter_map(|pid| {
let process = system.process(*pid)?;
if process.kill() {
Some(pid.as_u32())
} else {
log::warn!(
"taskkill returned {code:?}: stdout={} stderr={}",
stdout.trim(),
stderr.trim(),
);
log::warn!("Process::kill failed for pid {}", pid.as_u32());
None
}
})
.collect();
log::info!("issued kill on {} {target} process(es): {killed:?}", killed.len());
// Poll for actual exit. 5 s ceiling is generous (TerminateProcess
// usually finalizes within tens of ms) but cheap — we only burn it
// when the kernel really is dragging its feet, which is the exact
// case the old `sleep(500ms)` heuristic couldn't handle.
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
system.refresh_processes();
let still_alive = victims.iter().any(|pid| system.process(*pid).is_some());
if !still_alive {
return;
}
Err(e) => {
log::warn!("could not invoke taskkill: {e}");
std::thread::sleep(Duration::from_millis(50));
}
log::warn!(
"some {target} processes were still alive after 5 s; subsequent file ops may fail with sharing violation"
);
}
/// Translate a `windows_service::Error` from `ServiceManager::local_computer`
/// into a friendlier user-facing message. ERROR_ACCESS_DENIED (Win32 err 5)
/// is the overwhelmingly common case — operator forgot to elevate — and
/// deserves a single clear line rather than the raw Win32 errno string.
fn map_scm_open_error(e: windows_service::Error) -> anyhow::Error {
if let windows_service::Error::Winapi(ref ioe) = e {
if ioe.raw_os_error() == Some(5) {
return anyhow!(
"requires an elevated (Administrator) prompt — re-run from \"Run as administrator\""
);
}
}
anyhow!("open SCM: {e}")
}
/// Add a Windows Firewall rule allowing inbound TCP/UDP to the installed
/// hello-agent.exe. Idempotent: we delete any prior rule by the same name
/// first, so re-running --install (or upgrading in place) doesn't pile up
/// duplicate entries in the firewall's per-name list.
///
/// We use the program-scoped form (`program=<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
+12 -1
View File
@@ -111,7 +111,18 @@ async fn try_report(password: &str) -> Result<()> {
})
.to_string();
let resp = librustdesk::common::post_request(url, body, "")
// Same per-peer signature gate as heartbeat / sysinfo. Once this peer's
// `managed` flag has flipped to 1 server-side, unsigned posts here
// would be rejected — and we want unattended-password to keep landing
// through the same TOFU lifecycle as the other endpoints.
let headers = librustdesk::hbbs_http::sign::build_signed_headers(
"POST",
"/api/unattended-password",
body.as_bytes(),
)
.unwrap_or_default();
let resp = librustdesk::common::post_request(url, body, &headers)
.await
.map_err(|e| anyhow!("post: {e}"))?;
let trimmed = resp.trim();
+129 -34
View File
@@ -94,11 +94,24 @@ pub mod input {
lazy_static::lazy_static! {
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_NAME: Arc<Mutex<String>> = 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! {
// Is server process, with "--server" args
static ref IS_SERVER: bool = std::env::args().nth(1) == Some("--server".to_owned());
@@ -949,19 +962,45 @@ pub fn check_software_update() {
}
}
// No need to check `danger_accept_invalid_cert` for now.
// Because the url is always `https://api.rustdesk.com/version/latest`.
// hello-agent local patch: instead of POSTing to api.rustdesk.com (the
// upstream endpoint that resolves the latest stock-RustDesk release), this
// queries the Gitea Releases API on the hello-agent repo and resolves
// the binary + sha256 asset URLs of the latest release. The original
// rustdesk-api code path is intentionally gone: a stock-RustDesk auto-update
// would happily replace hello-agent's installation with vanilla rustdesk.
// The product version compared against the release tag is
// `hbb_common::config::AGENT_VERSION` (populated from `CARGO_PKG_VERSION`
// in hello-agent's `main`) — not `crate::VERSION`, which is the embedded
// rustdesk core version and would always be much higher than hello-agent's
// release tags, making the updater think no update is ever available.
const HELLO_AGENT_RELEASES_API: &str =
"https://gitea.cstudio.ch/api/v1/repos/mike/hello-agent/releases/latest";
const HELLO_AGENT_TAG_URL_PREFIX: &str =
"https://gitea.cstudio.ch/mike/hello-agent/releases/tag";
#[derive(Debug, serde::Deserialize)]
struct GiteaRelease {
tag_name: String,
#[serde(default)]
assets: Vec<GiteaAsset>,
}
#[derive(Debug, serde::Deserialize)]
struct GiteaAsset {
name: String,
browser_download_url: String,
}
#[tokio::main(flavor = "current_thread")]
pub async fn do_check_software_update() -> hbb_common::ResultType<()> {
let (request, url) =
hbb_common::version_check_request(hbb_common::VER_TYPE_RUSTDESK_CLIENT.to_string());
let url = HELLO_AGENT_RELEASES_API;
let proxy_conf = Config::get_socks();
let tls_url = get_url_for_tls(&url, &proxy_conf);
let tls_url = get_url_for_tls(url, &proxy_conf);
let tls_type = get_cached_tls_type(tls_url);
let is_tls_not_cached = tls_type.is_none();
let tls_type = tls_type.unwrap_or(TlsType::Rustls);
let client = create_http_client_async(tls_type, false);
let latest_release_response = match client.post(&url).json(&request).send().await {
let response = match client.get(url).send().await {
Ok(resp) => {
upsert_tls_cache(tls_url, tls_type, false);
resp
@@ -970,7 +1009,7 @@ pub async fn do_check_software_update() -> hbb_common::ResultType<()> {
if is_tls_not_cached && err.is_request() {
let tls_type = TlsType::NativeTls;
let client = create_http_client_async(tls_type, false);
let resp = client.post(&url).json(&request).send().await?;
let resp = client.get(url).send().await?;
upsert_tls_cache(tls_url, tls_type, false);
resp
} else {
@@ -978,24 +1017,63 @@ pub async fn do_check_software_update() -> hbb_common::ResultType<()> {
}
}
};
let bytes = latest_release_response.bytes().await?;
let resp: hbb_common::VersionCheckResponse = serde_json::from_slice(&bytes)?;
let response_url = resp.url;
let latest_release_version = response_url.rsplit('/').next().unwrap_or_default();
if !response.status().is_success() {
bail!("Gitea releases API returned HTTP {}", response.status());
}
let bytes = response.bytes().await?;
let release: GiteaRelease = serde_json::from_slice(&bytes)?;
if get_version_number(&latest_release_version) > get_version_number(crate::VERSION) {
#[cfg(feature = "flutter")]
{
let mut m = HashMap::new();
m.insert("name", "check_software_update_finish");
m.insert("url", &response_url);
if let Ok(data) = serde_json::to_string(&m) {
let _ = crate::flutter::push_global_event(crate::flutter::APP_TYPE_MAIN, data);
}
}
*SOFTWARE_UPDATE_URL.lock().unwrap() = response_url;
let latest_version = release.tag_name.trim_start_matches('v');
let current_version = hbb_common::config::AGENT_VERSION.read().unwrap().clone();
let current_version = if current_version.is_empty() {
crate::VERSION.to_owned()
} else {
current_version
};
if get_version_number(latest_version) <= get_version_number(&current_version) {
*SOFTWARE_UPDATE_URL.lock().unwrap() = "".to_string();
*SOFTWARE_UPDATE_ASSETS.lock().unwrap() = None;
return Ok(());
}
// Pick the Windows binary asset and its SHA256 companion. The release
// is expected to carry a `*.exe` (or `*.exe.signed`) and a matching
// `*.sha256` file. We don't pair them by name — there should only be
// one of each per release.
let binary = release.assets.iter().find(|a| {
let n = a.name.to_lowercase();
(n.ends_with(".exe") || n.ends_with(".exe.signed")) && !n.ends_with(".sha256")
});
let sha256 = release
.assets
.iter()
.find(|a| a.name.to_lowercase().ends_with(".sha256"));
let (Some(binary), Some(sha256)) = (binary, sha256) else {
log::warn!(
"hello-agent release {} is missing a binary and/or .sha256 asset",
release.tag_name
);
*SOFTWARE_UPDATE_URL.lock().unwrap() = "".to_string();
*SOFTWARE_UPDATE_ASSETS.lock().unwrap() = None;
return Ok(());
};
let tag_url = format!("{}/{}", HELLO_AGENT_TAG_URL_PREFIX, release.tag_name);
*SOFTWARE_UPDATE_URL.lock().unwrap() = tag_url.clone();
*SOFTWARE_UPDATE_ASSETS.lock().unwrap() = Some(UpdateAssets {
binary_url: binary.browser_download_url.clone(),
binary_name: binary.name.clone(),
sha256_url: sha256.browser_download_url.clone(),
});
#[cfg(feature = "flutter")]
{
let mut m = HashMap::new();
m.insert("name", "check_software_update_finish");
m.insert("url", &tag_url);
if let Ok(data) = serde_json::to_string(&m) {
let _ = crate::flutter::push_global_event(crate::flutter::APP_TYPE_MAIN, data);
}
}
Ok(())
}
@@ -1253,17 +1331,26 @@ async fn tcp_proxy_request(
fn parse_simple_header(header: &str) -> Vec<HeaderEntry> {
let mut entries = Vec::new();
let mut has_content_type = false;
// Accept a `\n`-separated list of `Name: Value` pairs. Single-pair input
// (the historical shape) still parses correctly because there's no
// newline to split on.
if !header.is_empty() {
let tmp: Vec<&str> = header.splitn(2, ": ").collect();
if tmp.len() == 2 {
if tmp[0].eq_ignore_ascii_case("Content-Type") {
has_content_type = true;
for line in header.split('\n') {
let line = line.trim();
if line.is_empty() {
continue;
}
let tmp: Vec<&str> = line.splitn(2, ": ").collect();
if tmp.len() == 2 {
if tmp[0].eq_ignore_ascii_case("Content-Type") {
has_content_type = true;
}
entries.push(HeaderEntry {
name: tmp[0].into(),
value: tmp[1].into(),
..Default::default()
});
}
entries.push(HeaderEntry {
name: tmp[0].into(),
value: tmp[1].into(),
..Default::default()
});
}
}
if !has_content_type {
@@ -1421,10 +1508,18 @@ async fn post_request_(
danger_accept_invalid_cert.unwrap_or(false),
)
.post(url);
// `header` is a `\n`-separated list of `Name: Value` pairs. Single-pair
// callers (the original shape) work unchanged. The signed-API path uses
// this to pass both `X-RD-Device-Id` and `X-RD-Signature` at once.
if !header.is_empty() {
let tmp: Vec<&str> = header.split(": ").collect();
if tmp.len() == 2 {
req = req.header(tmp[0], tmp[1]);
for line in header.split('\n') {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Some((name, value)) = line.split_once(": ") {
req = req.header(name, value);
}
}
}
req = req.header("Content-Type", "application/json");
+1
View File
@@ -7,6 +7,7 @@ pub mod account;
pub mod downloader;
mod http_client;
pub mod record_upload;
pub mod sign;
pub mod sync;
pub use http_client::{
create_http_client_async, create_http_client_async_with_url, create_http_client_with_url,
+72
View File
@@ -0,0 +1,72 @@
//! Sign agent → server HTTP requests with the device's existing Ed25519
//! keypair (the same one rendezvous uses for `RegisterPk`). Producing the
//! header pair below for any signed call:
//!
//! X-RD-Device-Id: <id>
//! X-RD-Signature: v1.<unix_ts>.<base64(ed25519_sig)>
//!
//! Server verifier: `/Users/sn0/Desktop/rustdesk-server/src/api/device_auth.rs`.
//!
//! Signed message format (must match the server byte-for-byte):
//! "rd-api-v1\n" || METHOD || "\n" || PATH || "\n" || TS || "\n" || sha256(body)
use hbb_common::config::Config;
use hbb_common::sodiumoxide::crypto::{hash::sha256, sign};
/// Returns the two HTTP header lines joined by `\n`, ready to hand to
/// `post_request`'s extended `header` parser. Returns `None` if the local
/// keypair hasn't been generated yet (very early boot, before rendezvous) —
/// the caller should fall back to an unsigned request in that case; the
/// server's TOFU promote will still flip `managed=1` on the next signed
/// request and any unsigned attempts after that flip will be rejected.
pub fn build_signed_headers(method: &str, path: &str, body: &[u8]) -> Option<String> {
let (sk_bytes, _pk_bytes) = Config::get_key_pair();
if sk_bytes.is_empty() {
return None;
}
let sk = sign::SecretKey::from_slice(&sk_bytes)?;
let id = Config::get_id();
if id.is_empty() {
return None;
}
let ts = chrono::Utc::now().timestamp();
let body_sha = sha256::hash(body);
let ts_s = ts.to_string();
let mut msg = Vec::with_capacity(64 + method.len() + path.len());
msg.extend_from_slice(b"rd-api-v1\n");
msg.extend_from_slice(method.as_bytes());
msg.push(b'\n');
msg.extend_from_slice(path.as_bytes());
msg.push(b'\n');
msg.extend_from_slice(ts_s.as_bytes());
msg.push(b'\n');
msg.extend_from_slice(body_sha.as_ref());
let sig = sign::sign_detached(&msg, &sk);
let sig_b64 = crate::encode64(sig.as_ref());
Some(format!(
"X-RD-Device-Id: {}\nX-RD-Signature: v1.{}.{}",
id, ts, sig_b64
))
}
/// Extract the `/path` portion of a full URL. Used to derive the signed
/// path from sync.rs's `url` variable, which is always something like
/// `https://server.example.com:21114/api/heartbeat`. Falls back to "/" if
/// the URL doesn't parse — server-side verification will then fail, which
/// is the right outcome (a malformed agent URL is a misconfiguration the
/// operator should see).
pub fn path_from_url(url: &str) -> String {
// Manual parse to avoid pulling in the `url` crate for one call. The
// structure is always scheme://host[:port]/path[?query]. Strip scheme,
// then take from the first '/' onward, then drop any '?query'.
let no_scheme = url.split_once("://").map(|(_, rest)| rest).unwrap_or(url);
let path_and_q = no_scheme.find('/').map(|i| &no_scheme[i..]).unwrap_or("/");
let path = path_and_q
.split_once('?')
.map(|(p, _)| p)
.unwrap_or(path_and_q);
path.to_string()
}
+71 -2
View File
@@ -24,6 +24,30 @@ const TIME_CONN: Duration = Duration::from_secs(3);
lazy_static::lazy_static! {
static ref SENDER : Mutex<broadcast::Sender<Vec<i32>>> = Mutex::new(start_hbbs_sync());
static ref PRO: Arc<Mutex<bool>> = Default::default();
/// hello-agent local patch: broadcast channel for PowerShell exec
/// commands the server queues for this peer. sync.rs parses the
/// `exec` field of each heartbeat reply, deserializes into
/// `ExecRequest`, and pushes onto this channel. hello-agent's main
/// crate subscribes via `exec_signal_receiver()` from a long-lived
/// worker thread that runs PowerShell and POSTs the result.
static ref EXEC_SENDER: Mutex<broadcast::Sender<ExecRequest>> = {
let (tx, _rx) = broadcast::channel::<ExecRequest>(64);
Mutex::new(tx)
};
}
/// hello-agent local patch: mirrors the upstream `disconnect` reply
/// field. Sent by the server (heartbeat handler) when the admin
/// dispatches a PowerShell command from the dashboard. See
/// rustdesk-server/docs/AGENT-API-AUTH.md.
#[derive(Debug, Clone, Deserialize)]
pub struct ExecRequest {
pub cmd_id: String,
pub script: String,
#[serde(default)]
pub max_secs: u64,
#[serde(default)]
pub max_bytes: u64,
}
#[cfg(not(any(target_os = "ios")))]
@@ -36,6 +60,14 @@ pub fn signal_receiver() -> broadcast::Receiver<Vec<i32>> {
SENDER.lock().unwrap().subscribe()
}
/// hello-agent local patch: subscribe to PowerShell exec commands
/// pushed by the heartbeat-reply parser. Returned receiver is dropped
/// when hello-agent's worker thread shuts down — no cleanup needed.
#[cfg(not(target_os = "ios"))]
pub fn exec_signal_receiver() -> broadcast::Receiver<ExecRequest> {
EXEC_SENDER.lock().unwrap().subscribe()
}
#[cfg(not(any(target_os = "ios")))]
fn start_hbbs_sync() -> broadcast::Sender<Vec<i32>> {
let (tx, _rx) = broadcast::channel::<Vec<i32>>(16);
@@ -258,7 +290,15 @@ async fn start_hbbs_sync_async() {
}
}
}
match crate::post_request(url.replace("heartbeat", "sysinfo"), v, "").await {
let sysinfo_url = url.replace("heartbeat", "sysinfo");
let sysinfo_path = crate::hbbs_http::sign::path_from_url(&sysinfo_url);
let sysinfo_headers = crate::hbbs_http::sign::build_signed_headers(
"POST",
&sysinfo_path,
v.as_bytes(),
)
.unwrap_or_default();
match crate::post_request(sysinfo_url, v, &sysinfo_headers).await {
Ok(x) => {
if x == "SYSINFO_UPDATED" {
info_uploaded = InfoUploaded::uploaded(url.clone(), id.clone(), sys_username, had_inventory);
@@ -292,7 +332,15 @@ async fn start_hbbs_sync_async() {
}
let modified_at = LocalConfig::get_option("strategy_timestamp").parse::<i64>().unwrap_or(0);
v["modified_at"] = json!(modified_at);
if let Ok(s) = crate::post_request(url.clone(), v.to_string(), "").await {
let hb_body = v.to_string();
let hb_path = crate::hbbs_http::sign::path_from_url(&url);
let hb_headers = crate::hbbs_http::sign::build_signed_headers(
"POST",
&hb_path,
hb_body.as_bytes(),
)
.unwrap_or_default();
if let Ok(s) = crate::post_request(url.clone(), hb_body, &hb_headers).await {
if let Ok(mut rsp) = serde_json::from_str::<HashMap::<&str, Value>>(&s) {
if rsp.remove("sysinfo").is_some() {
info_uploaded.uploaded = false;
@@ -317,6 +365,27 @@ async fn start_hbbs_sync_async() {
handle_config_options(strategy.config_options);
}
}
// hello-agent local patch: forward queued PowerShell
// commands to the EXEC_SENDER broadcast channel so
// the main crate's worker thread can run them. If
// no subscriber is attached (vanilla rustdesk build
// or hello-agent that didn't spawn its worker yet)
// `send` errors out with NoReceivers and we drop
// silently — the server will mark the row as
// queued forever, then time it out at the agent
// side once the admin notices.
if let Some(exec) = rsp.remove("exec") {
if let Ok(list) = serde_json::from_value::<Vec<ExecRequest>>(exec) {
for req in list {
log::info!(
"exec dispatch: cmd_id={} script_len={}",
req.cmd_id,
req.script.len()
);
let _ = EXEC_SENDER.lock().unwrap().send(req);
}
}
}
}
}
}
+1 -1
View File
@@ -72,7 +72,7 @@ pub mod ui_cm_interface;
mod ui_interface;
mod ui_session_interface;
mod hbbs_http;
pub mod hbbs_http;
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
pub mod clipboard_file;
+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> {
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!(
"
{main_exe}
copy /Y \"{ORIGIN_PROCESS_EXE}\" \"{path}\\{broker_exe}\"
",
ORIGIN_PROCESS_EXE = win_topmost_window::ORIGIN_PROCESS_EXE,
broker_exe = win_topmost_window::INJECTED_PROCESS_EXE,
"copy /Y \"{src_exe}\" \"{path}\\{src_filename}\"\n"
))
}
@@ -3110,21 +3126,27 @@ reg add {subkey} /f /v EstimatedSize /t REG_DWORD /d {size}
)
}
// hello-agent local patch: only refresh the Add/Remove Programs entry if
// the install path actually created one. Hello-agent's `--install`
// (`src/service.rs::install`) does not write to
// `HKLM\...\Uninstall\<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_main = get_reg_cmd(
&subkey,
is_msi,
&display_icon,
&version,
&build_date,
&version_major,
&version_minor,
&version_build,
size,
);
let reg_cmd_msi = if let Some(reg_msi_key) = get_reg_msi_key(&subkey, is_msi) {
let reg_cmd_main = if subkey_exists(&subkey) {
get_reg_cmd(
&reg_msi_key,
&subkey,
is_msi,
&display_icon,
&version,
@@ -3137,6 +3159,25 @@ reg add {subkey} /f /v EstimatedSize /t REG_DWORD /d {size}
} else {
"".to_owned()
};
let reg_cmd_msi = if let Some(reg_msi_key) = get_reg_msi_key(&subkey, is_msi) {
if subkey_exists(&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)
};
+101 -61
View File
@@ -118,8 +118,16 @@ fn start_auto_update_check_(rx_msg: Receiver<UpdateMsg>) {
}
fn check_update(manually: bool) -> ResultType<()> {
// hello-agent local patch: `is_msi_installed()` reads HKLM\...\Uninstall\HelloAgent
// and errors when the uninstall key (or `WindowsInstaller` value) is
// absent, which is the common case for a hello-agent install. The
// upstream code used `?` here, propagating the registry error and
// killing the update before our Gitea check ever ran. Swallow the
// error: `update_msi` will be `false` (correct — hello-agent is a
// custom client so the MSI branch is never the right one anyway).
#[cfg(target_os = "windows")]
let update_msi = crate::platform::is_msi_installed()? && !crate::is_custom_client();
let update_msi =
crate::platform::is_msi_installed().unwrap_or(false) && !crate::is_custom_client();
if !(manually || config::Config::get_bool_option(config::keys::OPTION_ALLOW_AUTO_UPDATE)) {
return Ok(());
}
@@ -128,70 +136,102 @@ fn check_update(manually: bool) -> ResultType<()> {
return Ok(());
}
let update_url = crate::common::SOFTWARE_UPDATE_URL.lock().unwrap().clone();
if update_url.is_empty() {
// hello-agent local patch: the upstream code reconstructed the download
// URL from a GitHub "tag" URL and a hard-coded filename pattern. Both
// are now resolved by `do_check_software_update` against the Gitea
// Releases API and exposed via `SOFTWARE_UPDATE_ASSETS`.
let assets = crate::common::SOFTWARE_UPDATE_ASSETS.lock().unwrap().clone();
let Some(assets) = assets else {
log::debug!("No update available.");
} else {
let download_url = update_url.replace("tag", "download");
let version = download_url.split('/').last().unwrap_or_default();
#[cfg(target_os = "windows")]
let download_url = if cfg!(feature = "flutter") {
format!(
"{}/rustdesk-{}-x86_64.{}",
download_url,
version,
if update_msi { "msi" } else { "exe" }
)
return Ok(());
};
let download_url = assets.binary_url;
let sha256_url = assets.sha256_url;
let version = assets.binary_name;
log::debug!("New version available: {}", &version);
let client = create_http_client_with_url(&download_url);
let Some(file_path) = get_download_file_from_url(&download_url) else {
bail!("Failed to get the file path from the URL: {}", download_url);
};
let mut is_file_exists = false;
if file_path.exists() {
// Check if the file size is the same as the server file size
// If the file size is the same, we don't need to download it again.
let file_size = std::fs::metadata(&file_path)?.len();
let response = client.head(&download_url).send()?;
if !response.status().is_success() {
bail!("Failed to get the file size: {}", response.status());
}
let total_size = response
.headers()
.get(reqwest::header::CONTENT_LENGTH)
.and_then(|ct_len| ct_len.to_str().ok())
.and_then(|ct_len| ct_len.parse::<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 {
format!("{}/rustdesk-{}-x86-sciter.exe", download_url, version)
};
log::debug!("New version available: {}", &version);
let client = create_http_client_with_url(&download_url);
let Some(file_path) = get_download_file_from_url(&download_url) else {
bail!("Failed to get the file path from the URL: {}", download_url);
};
let mut is_file_exists = false;
if file_path.exists() {
// Check if the file size is the same as the server file size
// If the file size is the same, we don't need to download it again.
let file_size = std::fs::metadata(&file_path)?.len();
let response = client.head(&download_url).send()?;
if !response.status().is_success() {
bail!("Failed to get the file size: {}", response.status());
}
let total_size = response
.headers()
.get(reqwest::header::CONTENT_LENGTH)
.and_then(|ct_len| ct_len.to_str().ok())
.and_then(|ct_len| ct_len.parse::<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)?;
}
std::fs::remove_file(&file_path)?;
}
if !is_file_exists {
let response = client.get(&download_url).send()?;
if !response.status().is_success() {
bail!(
"Failed to download the new version file: {}",
response.status()
);
}
let file_data = response.bytes()?;
let mut file = std::fs::File::create(&file_path)?;
file.write_all(&file_data)?;
}
// We have checked if the `conns` is empty before, but we need to check again.
// No need to care about the downloaded file here, because it's rare case that the `conns` are empty
// before the download, but not empty after the download.
if has_no_active_conns() {
#[cfg(target_os = "windows")]
update_new_version(update_msi, &version, &file_path);
}
if !is_file_exists {
let response = client.get(&download_url).send()?;
if !response.status().is_success() {
bail!(
"Failed to download the new version file: {}",
response.status()
);
}
let file_data = response.bytes()?;
let mut file = std::fs::File::create(&file_path)?;
file.write_all(&file_data)?;
}
// hello-agent local patch: verify the downloaded binary's SHA256 against
// the `.sha256` companion asset published by the same Gitea release
// before launching. We're about to run this file with elevated rights —
// a mismatch means something went wrong in transit or the release was
// tampered with, and we must NOT launch it. The expected-hash file is a
// standard `sha256sum` output (`<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(())
}
+1 -1
View File
@@ -1,3 +1,3 @@
pub const VERSION: &str = "1.4.6";
#[allow(dead_code)]
pub const BUILD_DATE: &str = "2026-05-09 10:43";
pub const BUILD_DATE: &str = "2026-05-22 14:17";