5 Commits

Author SHA1 Message Date
mike f868efa432 Implement user login logging
build-windows / build-hello-agent-x64 (push) Successful in 4m56s
build-windows / sign-hello-agent-x64 (push) Successful in 5s
build-windows / validate-hello-agent-x64 (push) Successful in 6s
2026-05-22 20:08:24 +02:00
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
19 changed files with 1880 additions and 218 deletions
Generated
+1 -1
View File
@@ -3197,7 +3197,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "hello-agent" name = "hello-agent"
version = "0.1.1" version = "0.1.6"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"env_logger 0.10.2", "env_logger 0.10.2",
+2 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "hello-agent" name = "hello-agent"
version = "0.1.1" version = "0.1.6"
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"
@@ -24,7 +24,7 @@ path = "src/main.rs"
librustdesk = { package = "rustdesk", path = "vendor/rustdesk", default-features = false, features = ["use_dasp", "hwcodec", "vram"] } librustdesk = { package = "rustdesk", path = "vendor/rustdesk", default-features = false, features = ["use_dasp", "hwcodec", "vram"] }
hbb_common = { path = "vendor/rustdesk/libs/hbb_common" } 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" log = "0.4"
env_logger = "0.10" env_logger = "0.10"
anyhow = "1" anyhow = "1"
+102 -16
View File
@@ -42,7 +42,7 @@ empty, so `--config` always wins.
``` ```
hello-agent.exe --install hello-agent.exe --install
└──> creates Windows service "HelloAgent", binPath ends in --service └──> creates Windows service "hello-agent", binPath ends in --service
hello-agent.exe --service # Session 0, LocalSystem hello-agent.exe --service # Session 0, LocalSystem
@@ -60,6 +60,18 @@ hello-agent.exe --server # user session, SYSTEM token
│ └─ stamps `agent_name` / `agent_version` / `inventory` │ └─ stamps `agent_name` / `agent_version` / `inventory`
│ into each /api/sysinfo payload (re-uploads when the │ into each /api/sysinfo payload (re-uploads when the
│ inventory collector below transitions empty → ready) │ 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) ├── inventory::collect_inventory (background thread)
│ └─ PowerShell + WMI + wlanapi + ipify → `INVENTORY` global │ └─ PowerShell + WMI + wlanapi + ipify → `INVENTORY` global
│ consumed by hbbs_http::sync above; one-shot, no retry │ 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 `--cm` process can plug a `MessageBoxW`-based `InvokeUiCM` into
upstream's connection-manager IPC loop and inherit file-transfer, upstream's connection-manager IPC loop and inherit file-transfer,
chat, and clipboard handling rather than re-implementing them. 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): 2. **Build shape** — [`vendor/rustdesk/Cargo.toml`](vendor/rustdesk/Cargo.toml):
`[lib] crate-type` reduced from `["cdylib", "staticlib", "rlib"]` to `[lib] crate-type` reduced from `["cdylib", "staticlib", "rlib"]` to
`["rlib"]`. We statically link the rlib into hello-agent.exe; the `["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(...)`). [`src/main.rs`](vendor/rustdesk/src/main.rs) (`.author(...)`).
Cosmetic, but they show through in the Windows EXE metadata and Cosmetic, but they show through in the Windows EXE metadata and
in-app error dialogs. 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 ## Build
@@ -191,7 +277,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 +307,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 +315,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 +327,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 +338,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 +356,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 +370,7 @@ it duplicates info that's already in the main log).
- ✅ Windows x64 (physical console *and* RDP sessions — the agent picks - ✅ Windows x64 (physical console *and* RDP sessions — the agent picks
whichever session the user is actively using) whichever session the user is actively using)
- ✅ Coexists with stock RustDesk on the same box — config dir, log dir, - ✅ Coexists with stock RustDesk on the same box — config dir, log dir,
and named pipes are namespaced under `HelloAgent` rather than the and named pipes are namespaced under `hello-agent` rather than the
upstream default of `RustDesk` (see [Namespacing](#namespacing) below). upstream default of `RustDesk` (see [Namespacing](#namespacing) below).
The only residual contention is the optional direct-server port The only residual contention is the optional direct-server port
(TCP 21118) and LAN-discovery port (UDP 21119); both default to off, (TCP 21118) and LAN-discovery port (UDP 21119); both default to off,
+10
View File
@@ -19,6 +19,16 @@
fn main() { fn main() {
println!("cargo:rerun-if-changed=build.rs"); println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=resources/icon.ico"); println!("cargo:rerun-if-changed=resources/icon.ico");
// winres derives FileVersion / ProductVersion from `CARGO_PKG_VERSION`,
// which is sourced from `Cargo.toml`. Without this directive, cargo
// happily skips the build script when only the version field changed
// (build.rs and the .ico are byte-identical), and the resulting EXE
// ships with stale "FileVersion" / "ProductVersion" properties — the
// binary itself is the new build, but Explorer's Properties dialog
// and any Authenticode tooling that reads the resource block see the
// previous version. Forcing a re-run on Cargo.toml changes is cheap
// (winres compile is sub-second) and bulletproof.
println!("cargo:rerun-if-changed=Cargo.toml");
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
if target_os != "windows" { if target_os != "windows" {
+45 -3
View File
@@ -31,6 +31,13 @@ pub enum Action {
/// — every `Data::FS(...)` frame the server sends is executed here, in /// — every `Data::FS(...)` frame the server sends is executed here, in
/// the user's security context. /// the user's security context.
Cm, Cm,
/// `--update`. Self-replacement entry point launched as an elevated child
/// by the running service's updater (see `librustdesk::updater`) after it
/// has downloaded and SHA256-verified a new hello-agent.exe from the
/// Gitea releases page. `current_exe()` here points at the staged new
/// binary in `%TEMP%`; it copies itself over the installed location and
/// restarts the service via `librustdesk::platform::update_me`.
Update,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -47,6 +54,7 @@ impl ParsedArgs {
let mut service = false; let mut service = false;
let mut server = false; let mut server = false;
let mut cm = false; let mut cm = false;
let mut update = false;
let mut config_blob: Option<String> = None; let mut config_blob: Option<String> = None;
let mut i = 0; let mut i = 0;
@@ -56,6 +64,7 @@ impl ParsedArgs {
"--uninstall" => uninstall = true, "--uninstall" => uninstall = true,
"--service" => service = true, "--service" => service = true,
"--server" => server = true, "--server" => server = true,
"--update" => update = true,
// Connection-manager popup mode. Treat `--cm-no-ui` (the // Connection-manager popup mode. Treat `--cm-no-ui` (the
// Linux-headless variant librustdesk also tries) as a // Linux-headless variant librustdesk also tries) as a
// synonym; either way we run cm_popup. // synonym; either way we run cm_popup.
@@ -81,14 +90,21 @@ impl ParsedArgs {
} }
// Mutual-exclusion rules. --install + --config is the MDM one-liner; // Mutual-exclusion rules. --install + --config is the MDM one-liner;
// everything else is one-action-at-a-time. // everything else is one-action-at-a-time. --update is launched by
let exclusive = [uninstall, service, server, cm].iter().filter(|x| **x).count(); // the updater as a standalone elevated child, never combined.
let exclusive = [uninstall, service, server, cm, update]
.iter()
.filter(|x| **x)
.count();
if exclusive > 1 { if exclusive > 1 {
bail!("--uninstall, --service, --server, --cm are mutually exclusive"); bail!("--uninstall, --service, --server, --cm, --update are mutually exclusive");
} }
if uninstall && (install || config_blob.is_some()) { if uninstall && (install || config_blob.is_some()) {
bail!("--uninstall cannot be combined with other flags"); bail!("--uninstall cannot be combined with other flags");
} }
if update && (install || config_blob.is_some()) {
bail!("--update cannot be combined with other flags");
}
let action = if uninstall { let action = if uninstall {
Action::Uninstall Action::Uninstall
@@ -100,6 +116,8 @@ impl ParsedArgs {
Action::Server Action::Server
} else if cm { } else if cm {
Action::Cm Action::Cm
} else if update {
Action::Update
} else if config_blob.is_some() { } else if config_blob.is_some() {
Action::ConfigOnly Action::ConfigOnly
} else { } else {
@@ -131,6 +149,10 @@ OPTIONS:
--service SCM entry point. Do not invoke manually. --service SCM entry point. Do not invoke manually.
--server Worker mode (launched by the service shell into --server Worker mode (launched by the service shell into
the active console session). the active console session).
--update Self-replacement entry point. Launched by the
running service's updater after downloading and
SHA256-verifying a new release from Gitea. Do
not invoke manually.
-h, --help Show this help. -h, --help Show this help.
-V, --version Show version. -V, --version Show version.
@@ -191,4 +213,24 @@ mod tests {
fn unknown_arg() { fn unknown_arg() {
assert!(parse(&["--no-such-flag"]).is_err()); assert!(parse(&["--no-such-flag"]).is_err());
} }
#[test]
fn update_alone() {
assert_eq!(parse(&["--update"]).unwrap().action, Action::Update);
}
#[test]
fn update_install_conflict() {
assert!(parse(&["--update", "--install"]).is_err());
}
#[test]
fn update_service_conflict() {
assert!(parse(&["--update", "--service"]).is_err());
}
#[test]
fn update_config_conflict() {
assert!(parse(&["--update", "--config", "BLOB"]).is_err());
}
} }
+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() $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
"#; "#;
+617
View File
@@ -0,0 +1,617 @@
// User-login tracking.
//
// Polls the Windows Terminal Services session table at a low cadence and
// reports logon / logoff events to the rustdesk-server admin API. Each
// event carries an explicit unix timestamp — for logons that's the OS's
// session `ConnectTime` (so the recorded time is when the user actually
// signed in, not when the agent first noticed them); for logoffs it's
// the agent's wall clock at the moment the session disappeared.
//
// Architecture mirrors `unattended_password`:
// * One background thread, its own current-thread Tokio runtime, no
// entanglement with the SCM supervisor's poll loop.
// * In-memory queue with retry-with-backoff on transport / ID_NOT_FOUND
// errors (the agent's first POST routinely races rendezvous
// registration). Events that never land are eventually dropped to
// bound memory — see DROP_AFTER.
// * Server-side dedup via UNIQUE INDEX
// (peer_id, kind, session_id, at, username) lets the agent re-emit
// the same `logon@ConnectTime` event on every service restart without
// piling up duplicate rows; agent-side state stays in memory only.
//
// Known limits (v1):
// * Lock / unlock are not reported — they don't change the
// WTSEnumerateSessions output we diff on.
// * Logoffs that happen while the service is down are not detected.
// The next service start sees "no session" and has nothing to diff
// against; this leaves a logon without a paired logoff in the UI.
// Tradeoff vs. persisting a snapshot to disk; revisit if operators
// ask for it.
use anyhow::{anyhow, Result};
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::Duration;
/// How often to poll the WTS session table. A user can't meaningfully
/// log in / out faster than this, and the OS keeps the table cheap to
/// enumerate (it's a kernel-side struct, not a registry scan).
const POLL_INTERVAL: Duration = Duration::from_secs(5);
/// How often to attempt a flush of the pending queue. Decoupled from the
/// poll interval so a transient server outage doesn't slow down session
/// observation; we keep observing locally and just back off the network
/// retry.
const FLUSH_INTERVAL_BASE: Duration = Duration::from_secs(5);
const FLUSH_INTERVAL_MAX: Duration = Duration::from_secs(60);
/// Drop events from the queue after this many failed delivery attempts.
/// At backoff cap = 60s, this is ~6 hours of trying — enough to ride out
/// a long server-side outage, short enough that a permanently-misconfigured
/// agent doesn't blow up memory.
const DROP_AFTER: u32 = 360;
/// Cap per request — must match the server's MAX_EVENTS_PER_POST. Server
/// rejects anything larger with a 400 so we'd retry forever if we exceeded
/// it.
const MAX_EVENTS_PER_POST: usize = 256;
/// One observed session snapshot. Equality by `(session_id, connect_time)`
/// — Windows can recycle session IDs across logins, so we use the OS-
/// reported `ConnectTime` as the disambiguator. Two snapshots compare
/// equal iff they describe the *same* logical login.
#[derive(Clone, Debug)]
struct Session {
session_id: u32,
/// FILETIME → unix epoch seconds. 0 if the OS returned an unparseable
/// value (very old Windows builds); we still report `logon` but the
/// server-side dedup degrades to "first observation wins".
connect_unix: i64,
username: String,
domain: String,
/// "console" | "rdp" | "" (unknown).
session_kind: String,
}
#[derive(Clone, Debug)]
struct PendingEvent {
at: i64,
kind: &'static str,
username: String,
domain: String,
session_id: u32,
session_kind: String,
/// Number of times we've tried to flush this event. Used to drop
/// rows that have been retrying since forever.
attempts: u32,
}
#[cfg(target_os = "windows")]
static QUEUE: Mutex<Vec<PendingEvent>> = Mutex::new(Vec::new());
/// Kick off the background tracker. Returns immediately. Safe to call
/// multiple times (subsequent calls are no-ops) — gated by an
/// `AtomicBool`.
pub fn start() {
#[cfg(not(target_os = "windows"))]
{
// Login tracking is Windows-only; on other platforms the call is
// a no-op so cross-platform builds (CI lint runs on Linux) link.
}
#[cfg(target_os = "windows")]
{
use std::sync::atomic::{AtomicBool, Ordering};
static STARTED: AtomicBool = AtomicBool::new(false);
if STARTED.swap(true, Ordering::SeqCst) {
return;
}
std::thread::spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
log::warn!("login-events: build runtime: {e}");
return;
}
};
rt.block_on(run_loop());
});
}
}
#[cfg(target_os = "windows")]
async fn run_loop() {
// Diff snapshot. Keyed by session_id; value carries connect_unix so we
// can detect "session id reused for a new login" as well as "session
// disappeared".
let mut prev: HashMap<u32, Session> = HashMap::new();
let mut first_poll = true;
let mut flush_backoff = FLUSH_INTERVAL_BASE;
loop {
match enumerate_active_user_sessions() {
Ok(snapshot) => {
let now_unix = hbb_common::chrono::Utc::now().timestamp();
let curr: HashMap<u32, Session> =
snapshot.into_iter().map(|s| (s.session_id, s)).collect();
let events =
diff_into_events(&prev, &curr, first_poll, now_unix);
if !events.is_empty() {
let mut q = QUEUE.lock().unwrap();
q.extend(events);
}
prev = curr;
first_poll = false;
}
Err(e) => {
log::warn!("login-events: enumerate failed: {e:#}");
}
}
// Try to flush. If the queue is empty this is cheap (no network).
// The flush also handles its own retry / drop semantics — we just
// adjust our local cadence based on whether it succeeded.
match flush_once().await {
FlushOutcome::Idle | FlushOutcome::AllSent => {
flush_backoff = FLUSH_INTERVAL_BASE;
}
FlushOutcome::Failed => {
flush_backoff = (flush_backoff * 2).min(FLUSH_INTERVAL_MAX);
}
}
// Poll cadence is constant; the network backoff doesn't slow down
// observation — that would risk missing logoffs while the server
// is down. We just delay the retry of pending events.
tokio::time::sleep(POLL_INTERVAL.min(flush_backoff)).await;
}
}
#[cfg(target_os = "windows")]
fn diff_into_events(
prev: &HashMap<u32, Session>,
curr: &HashMap<u32, Session>,
first_poll: bool,
now_unix: i64,
) -> Vec<PendingEvent> {
let mut out = Vec::new();
// New / changed sessions → logon.
for (sid, sess) in curr {
let changed = match prev.get(sid) {
None => true,
Some(old) => {
// Same session id but a different connect_time → the
// OS recycled the slot for a fresh login. Emit a
// logoff for the old occupant and a logon for the new.
if old.connect_unix != sess.connect_unix
|| old.username != sess.username
{
out.push(PendingEvent {
at: now_unix,
kind: "logoff",
username: old.username.clone(),
domain: old.domain.clone(),
session_id: *sid,
session_kind: old.session_kind.clone(),
attempts: 0,
});
true
} else {
false
}
}
};
if !changed {
continue;
}
// ConnectTime can be 0 on the login-screen session even when a
// user is shown as logged in (rare edge); fall back to `now` so
// the row still lands somewhere sensible.
let at = if sess.connect_unix > 0 {
sess.connect_unix
} else {
now_unix
};
out.push(PendingEvent {
at,
kind: "logon",
username: sess.username.clone(),
domain: sess.domain.clone(),
session_id: *sid,
session_kind: sess.session_kind.clone(),
attempts: 0,
});
}
// Disappeared sessions → logoff. Skipped on the very first poll
// because we have no baseline to diff against — see the module-level
// "first poll: emit logons-only" note.
if !first_poll {
for (sid, old) in prev {
if !curr.contains_key(sid) {
out.push(PendingEvent {
at: now_unix,
kind: "logoff",
username: old.username.clone(),
domain: old.domain.clone(),
session_id: *sid,
session_kind: old.session_kind.clone(),
attempts: 0,
});
}
}
}
out
}
#[cfg(target_os = "windows")]
enum FlushOutcome {
Idle,
AllSent,
Failed,
}
#[cfg(target_os = "windows")]
async fn flush_once() -> FlushOutcome {
// Take a snapshot under the lock so we don't hold it across the
// (potentially slow) network call. If the POST succeeds we drop the
// matching prefix from the queue; if it fails we put back the
// unsent tail with bumped attempt counters.
let batch: Vec<PendingEvent> = {
let mut q = QUEUE.lock().unwrap();
if q.is_empty() {
return FlushOutcome::Idle;
}
let take = q.len().min(MAX_EVENTS_PER_POST);
q.drain(..take).collect()
};
let posted = post_batch(&batch).await;
match posted {
Ok(()) => {
// Server accepted (or returned ID_NOT_FOUND, which we treat
// as "drop and continue" because no amount of retrying will
// make the peer materialize without the rendezvous loop in
// --server, which the unattended_password reporter already
// hammers on; once that succeeds the next batch lands).
FlushOutcome::AllSent
}
Err(e) => {
log::warn!(
"login-events: flush of {} event(s) failed: {e:#}",
batch.len(),
);
// Put the batch back with bumped attempt counters, modulo
// events that have hit the drop threshold. Prepend so that
// any events pushed by the poll loop between the drain and
// here stay after the (older) failed batch.
let mut requeued: Vec<PendingEvent> = batch
.into_iter()
.filter_map(|mut ev| {
ev.attempts = ev.attempts.saturating_add(1);
if ev.attempts >= DROP_AFTER {
log::warn!(
"login-events: dropping event after {} attempts: \
kind={} session={} user={}",
ev.attempts, ev.kind, ev.session_id, ev.username,
);
None
} else {
Some(ev)
}
})
.collect();
let mut q = QUEUE.lock().unwrap();
let tail: Vec<PendingEvent> = q.drain(..).collect();
requeued.extend(tail);
*q = requeued;
FlushOutcome::Failed
}
}
}
#[cfg(target_os = "windows")]
async fn post_batch(batch: &[PendingEvent]) -> Result<()> {
let api = librustdesk::common::get_api_server(
hbb_common::config::Config::get_option("api-server"),
hbb_common::config::Config::get_option("custom-rendezvous-server"),
);
if api.is_empty() {
return Err(anyhow!("no api-server configured yet"));
}
let url = format!("{api}/api/agent/login-event");
let id = hbb_common::config::Config::get_id();
let uuid = librustdesk::common::encode64(hbb_common::get_uuid());
let events: Vec<hbb_common::serde_json::Value> = batch
.iter()
.map(|ev| {
hbb_common::serde_json::json!({
"at": ev.at,
"kind": ev.kind,
"username": ev.username,
"domain": ev.domain,
"session_id": ev.session_id,
"session_kind": ev.session_kind,
})
})
.collect();
let body = hbb_common::serde_json::json!({
"id": id,
"uuid": uuid,
"events": events,
})
.to_string();
let headers = librustdesk::hbbs_http::sign::build_signed_headers(
"POST",
"/api/agent/login-event",
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();
if trimmed == "OK" || trimmed == "ID_NOT_FOUND" {
// ID_NOT_FOUND is "peer not registered yet" — happens on the
// first few flushes after a fresh install, before the
// rendezvous loop in --server has created the peer row. The
// unattended_password reporter races this same window; once
// either of them succeeds the peer row exists and subsequent
// posts land. We treat it as success here so the agent doesn't
// pile up unbounded retries — if rendezvous never registers,
// the heartbeat path is also broken and the operator has
// bigger problems than missing login events.
Ok(())
} else {
Err(anyhow!("unexpected response: {trimmed}"))
}
}
// ─────────────────────────── Win32 session enumeration ────────────────────
//
// Same shape as service.rs's find_active_user_session — we declare just
// the WTS functions we touch rather than pull in another bindgen. The
// types are tiny and ABI-stable.
#[cfg(target_os = "windows")]
#[repr(C)]
struct WtsSessionInfoW {
session_id: u32,
win_station_name: *mut u16,
state: i32,
}
#[cfg(target_os = "windows")]
#[repr(C)]
struct WtsInfoW {
state: i32,
session_id: u32,
incoming_bytes: u32,
outgoing_bytes: u32,
incoming_frames: u32,
outgoing_frames: u32,
incoming_compressed_bytes: u32,
outgoing_compressed_bytes: u32,
win_station_name: [u16; 32],
domain: [u16; 17],
user_name: [u16; 21],
connect_time: i64,
disconnect_time: i64,
last_input_time: i64,
logon_time: i64,
current_time: i64,
}
#[cfg(target_os = "windows")]
extern "system" {
fn WTSEnumerateSessionsW(
h_server: winapi::shared::ntdef::HANDLE,
reserved: u32,
version: u32,
pp_session_info: *mut *mut WtsSessionInfoW,
p_count: *mut u32,
) -> i32;
fn WTSFreeMemory(p_memory: *mut std::ffi::c_void);
fn WTSQuerySessionInformationW(
h_server: winapi::shared::ntdef::HANDLE,
session_id: u32,
info_class: i32,
pp_buffer: *mut *mut u16,
p_bytes_returned: *mut u32,
) -> i32;
}
#[cfg(target_os = "windows")]
const WTS_ACTIVE: i32 = 0;
#[cfg(target_os = "windows")]
const WTS_USER_NAME: i32 = 5;
#[cfg(target_os = "windows")]
const WTS_DOMAIN_NAME: i32 = 7;
#[cfg(target_os = "windows")]
const WTS_CLIENT_PROTOCOL_TYPE: i32 = 16;
#[cfg(target_os = "windows")]
const WTS_SESSION_INFO: i32 = 24;
#[cfg(target_os = "windows")]
fn enumerate_active_user_sessions() -> Result<Vec<Session>> {
let mut sessions: *mut WtsSessionInfoW = std::ptr::null_mut();
let mut count: u32 = 0;
let ok = unsafe {
WTSEnumerateSessionsW(
std::ptr::null_mut(),
0,
1,
&mut sessions,
&mut count,
)
};
if ok == 0 || sessions.is_null() {
return Err(anyhow!(
"WTSEnumerateSessionsW failed: {}",
std::io::Error::last_os_error()
));
}
let mut out = Vec::new();
for i in 0..count {
let info = unsafe { &*sessions.add(i as usize) };
if info.state != WTS_ACTIVE {
continue;
}
let sid = info.session_id;
// Skip sessions without a logged-in user (login screen, Session 0).
let username = match query_wide(sid, WTS_USER_NAME) {
Some(s) if !s.is_empty() => s,
_ => continue,
};
let domain = query_wide(sid, WTS_DOMAIN_NAME).unwrap_or_default();
let session_kind = match query_protocol_type(sid) {
Some(0) => "console".to_string(),
Some(2) => "rdp".to_string(),
Some(_) | None => String::new(),
};
let connect_unix = query_connect_time(sid).unwrap_or(0);
out.push(Session {
session_id: sid,
connect_unix,
username,
domain,
session_kind,
});
}
unsafe { WTSFreeMemory(sessions as *mut std::ffi::c_void) };
Ok(out)
}
/// Pull a WCHAR-string-shaped value (WTSUserName / WTSDomainName / …)
/// and convert to UTF-8. Returns None on failure or empty result.
#[cfg(target_os = "windows")]
fn query_wide(session_id: u32, info_class: i32) -> Option<String> {
let mut buf: *mut u16 = std::ptr::null_mut();
let mut bytes: u32 = 0;
let ok = unsafe {
WTSQuerySessionInformationW(
std::ptr::null_mut(),
session_id,
info_class,
&mut buf,
&mut bytes,
)
};
if ok == 0 || buf.is_null() {
return None;
}
let s = unsafe { wide_to_string(buf, bytes) };
unsafe { WTSFreeMemory(buf as *mut std::ffi::c_void) };
if s.is_empty() {
None
} else {
Some(s)
}
}
/// WTSClientProtocolType returns a single USHORT (2 bytes). Buffer is
/// allocated by Windows; we read the 16-bit value and free.
#[cfg(target_os = "windows")]
fn query_protocol_type(session_id: u32) -> Option<u16> {
let mut buf: *mut u16 = std::ptr::null_mut();
let mut bytes: u32 = 0;
let ok = unsafe {
WTSQuerySessionInformationW(
std::ptr::null_mut(),
session_id,
WTS_CLIENT_PROTOCOL_TYPE,
&mut buf,
&mut bytes,
)
};
if ok == 0 || buf.is_null() || bytes < 2 {
if !buf.is_null() {
unsafe { WTSFreeMemory(buf as *mut std::ffi::c_void) };
}
return None;
}
let val = unsafe { *buf };
unsafe { WTSFreeMemory(buf as *mut std::ffi::c_void) };
Some(val)
}
/// Pull ConnectTime from WTSINFOW (the per-session struct returned by
/// WTSQuerySessionInformation with class WTSSessionInfo). FILETIME 0
/// means "OS doesn't know" (rare — happens on the login-screen session
/// before a user signs in); we surface that as None and the caller
/// falls back to `now()`.
#[cfg(target_os = "windows")]
fn query_connect_time(session_id: u32) -> Option<i64> {
let mut buf: *mut u16 = std::ptr::null_mut();
let mut bytes: u32 = 0;
let ok = unsafe {
WTSQuerySessionInformationW(
std::ptr::null_mut(),
session_id,
WTS_SESSION_INFO,
&mut buf,
&mut bytes,
)
};
if ok == 0 || buf.is_null() || (bytes as usize) < std::mem::size_of::<WtsInfoW>() {
if !buf.is_null() {
unsafe { WTSFreeMemory(buf as *mut std::ffi::c_void) };
}
return None;
}
let info: &WtsInfoW = unsafe { &*(buf as *const WtsInfoW) };
let ft = info.connect_time;
unsafe { WTSFreeMemory(buf as *mut std::ffi::c_void) };
let unix = filetime_to_unix(ft);
if unix > 0 {
Some(unix)
} else {
None
}
}
/// FILETIME is 100-nanosecond ticks since 1601-01-01 UTC. Convert to
/// unix seconds; clamp to >=0 since the table column is signed but a
/// pre-epoch ConnectTime is nonsense for our purposes.
#[cfg(target_os = "windows")]
fn filetime_to_unix(ft: i64) -> i64 {
// 11644473600 = seconds between 1601-01-01 and 1970-01-01.
const TICKS_PER_SEC: i64 = 10_000_000;
const EPOCH_DIFF_SECS: i64 = 11_644_473_600;
if ft <= 0 {
return 0;
}
let secs_since_1601 = ft / TICKS_PER_SEC;
let unix = secs_since_1601 - EPOCH_DIFF_SECS;
unix.max(0)
}
/// Convert a NUL-terminated UTF-16 buffer to a Rust String. `bytes` is
/// the byte count Windows returned (NOT the char count); we trust the
/// trailing NUL but cap on `bytes / 2` so a malformed buffer can't run
/// us into unallocated memory.
#[cfg(target_os = "windows")]
unsafe fn wide_to_string(buf: *const u16, bytes: u32) -> String {
if buf.is_null() || bytes == 0 {
return String::new();
}
let max_chars = (bytes as usize) / 2;
let mut len = 0usize;
while len < max_chars && *buf.add(len) != 0 {
len += 1;
}
let slice = std::slice::from_raw_parts(buf, len);
String::from_utf16_lossy(slice)
}
+72 -7
View File
@@ -15,7 +15,7 @@
// lazily from a `RwLock<String>` whenever any path is computed (config dir, // lazily from a `RwLock<String>` whenever any path is computed (config dir,
// log dir, named-pipe namespace, …), so setting it before any of those // log dir, named-pipe namespace, …), so setting it before any of those
// initializers fire is enough to redirect all hbb_common state under // initializers fire is enough to redirect all hbb_common state under
// `%APPDATA%\HelloAgent\` and the matching LocalService path. Identical // `%APPDATA%\hello-agent\` and the matching LocalService path. Identical
// to the `read_custom_client` write path the upstream Flutter build uses // to the `read_custom_client` write path the upstream Flutter build uses
// for OEM rebrands. // for OEM rebrands.
@@ -28,6 +28,9 @@ mod inventory;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
mod cm_popup; mod cm_popup;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
mod exec;
mod login_events;
#[cfg(target_os = "windows")]
mod service; mod service;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
mod unattended_password; mod unattended_password;
@@ -39,17 +42,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 +79,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 +104,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 +152,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 +223,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");
}
} }
} }
@@ -225,6 +281,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, // `start_server` is `#[tokio::main]` and runs forever. (is_server=true,
// no_server=false). It boots the default IPC server, input service, // no_server=false). It boots the default IPC server, input service,
// rendezvous mediator, and heartbeat sync. // rendezvous mediator, and heartbeat sync.
+275 -69
View File
@@ -3,7 +3,7 @@
// Three responsibilities: // Three responsibilities:
// //
// 1. `install()` — copy the binary to %ProgramFiles%\hello-agent, mirror the // 1. `install()` — copy the binary to %ProgramFiles%\hello-agent, mirror the
// calling user's `HelloAgent.toml` into the LocalService-effective // calling user's `hello-agent.toml` into the LocalService-effective
// config dir so the SYSTEM service inherits the --config blob, register // config dir so the SYSTEM service inherits the --config blob, register
// the service with the SCM pointing at the installed exe, and start it. // the service with the SCM pointing at the installed exe, and start it.
// Idempotent. // Idempotent.
@@ -29,14 +29,21 @@ use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use windows_service::service::{ use windows_service::service::{
ServiceAccess, ServiceControl, ServiceControlAccept, ServiceErrorControl, ServiceExitCode, ServiceAccess, ServiceAction, ServiceActionType, ServiceControl, ServiceControlAccept,
ServiceErrorControl, ServiceExitCode, ServiceFailureActions, ServiceFailureResetPeriod,
ServiceInfo, ServiceStartType, ServiceState, ServiceStatus, ServiceType, ServiceInfo, ServiceStartType, ServiceState, ServiceStatus, ServiceType,
}; };
use windows_service::service_control_handler::{self, ServiceControlHandlerResult}; use windows_service::service_control_handler::{self, ServiceControlHandlerResult};
use windows_service::service_dispatcher; use windows_service::service_dispatcher;
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess}; use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
const SERVICE_NAME: &str = "HelloAgent"; /// Internal service name registered with the SCM. Must equal `crate::APP_NAME`
/// because upstream `librustdesk::platform::is_self_service_running` queries
/// `is_service_running(&crate::get_app_name())` — i.e. it looks up the
/// service whose name *is* the app name. If these diverge, the `--update`
/// path's `sc stop` / `sc start` use the wrong name and the service is
/// left in a Stopped state after a self-update.
const SERVICE_NAME: &str = crate::APP_NAME;
const DISPLAY_NAME: &str = "HelloAgent Remote Support"; const DISPLAY_NAME: &str = "HelloAgent Remote Support";
const SERVICE_DESCRIPTION: &str = const SERVICE_DESCRIPTION: &str =
"HelloAgent — headless remote-support agent (RustDesk-protocol-compatible). \ "HelloAgent — headless remote-support agent (RustDesk-protocol-compatible). \
@@ -47,6 +54,11 @@ const SERVICE_TYPE: ServiceType = ServiceType::OWN_PROCESS;
const INSTALL_SUBDIR: &str = "hello-agent"; const INSTALL_SUBDIR: &str = "hello-agent";
const INSTALLED_EXE_NAME: &str = "hello-agent.exe"; const INSTALLED_EXE_NAME: &str = "hello-agent.exe";
/// Display name used for the Windows Firewall rule. Stable across versions
/// so `--uninstall` (or a re-install that clears it before re-adding) can
/// find and delete the prior entry by name.
const FIREWALL_RULE_NAME: &str = "HelloAgent";
// ----------------------------- paths --------------------------------------- // ----------------------------- paths ---------------------------------------
/// `%ProgramFiles%\hello-agent`. Falls back to `C:\Program Files\hello-agent` /// `%ProgramFiles%\hello-agent`. Falls back to `C:\Program Files\hello-agent`
@@ -68,9 +80,9 @@ fn install_dir() -> PathBuf {
/// Note the trailing `config` segment: `directories_next::ProjectDirs`, /// Note the trailing `config` segment: `directories_next::ProjectDirs`,
/// which hbb_common uses on Windows, appends a literal `\config` to the /// which hbb_common uses on Windows, appends a literal `\config` to the
/// app's roaming dir (so the user-side path is /// app's roaming dir (so the user-side path is
/// `%APPDATA%\HelloAgent\config\HelloAgent.toml`, not /// `%APPDATA%\hello-agent\config\hello-agent.toml`, not
/// `…\HelloAgent\…`). The SYSTEM-side path follows the same convention. /// `…\hello-agent\…`). The SYSTEM-side path follows the same convention.
/// The `HelloAgent` segment is sourced from `crate::APP_NAME` so it stays /// The `hello-agent` segment is sourced from `crate::APP_NAME` so it stays
/// in lockstep with the `APP_NAME` we install into hbb_common at startup. /// in lockstep with the `APP_NAME` we install into hbb_common at startup.
fn service_config_dir() -> PathBuf { fn service_config_dir() -> PathBuf {
let system_root = std::env::var_os("SystemRoot") let system_root = std::env::var_os("SystemRoot")
@@ -88,11 +100,15 @@ fn service_config_dir() -> PathBuf {
// ----------------------------- install -------------------------------------- // ----------------------------- install --------------------------------------
pub fn install() -> Result<()> { pub fn install() -> Result<()> {
// Probe-open the SCM with CREATE_SERVICE rights up front; if the caller
// isn't elevated this fails with ERROR_ACCESS_DENIED (raw_os_error == 5)
// and we surface a single human-readable message instead of bubbling
// up a Win32 errno string. Anything else propagates as-is.
let scm = ServiceManager::local_computer( let scm = ServiceManager::local_computer(
None::<&str>, None::<&str>,
ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE, ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE,
) )
.context("open SCM")?; .map_err(map_scm_open_error)?;
// 1. If a previous install left a running service, stop it before we // 1. If a previous install left a running service, stop it before we
// overwrite its binary. Otherwise the file copy in step 2 fails // overwrite its binary. Otherwise the file copy in step 2 fails
@@ -106,8 +122,8 @@ pub fn install() -> Result<()> {
// idempotent / usable as an in-place update — without it, the // idempotent / usable as an in-place update — without it, the
// `stage_binary` file copy below fails with "access denied" // `stage_binary` file copy below fails with "access denied"
// whenever a `--cm` child is still holding the old exe open. // whenever a `--cm` child is still holding the old exe open.
// `kill_orphan_processes` uses taskkill with `/FI "PID ne <ours>"` // `kill_orphan_processes` walks the process table via sysinfo and
// so it never kills the running installer. // filters out our own pid so the installer doesn't suicide.
kill_orphan_processes(); kill_orphan_processes();
// 2. Pin the binary to %ProgramFiles%\hello-agent. The user might be // 2. Pin the binary to %ProgramFiles%\hello-agent. The user might be
@@ -120,17 +136,17 @@ pub fn install() -> Result<()> {
// first, fall back to popup). Older hello-agent installs wrote // first, fall back to popup). Older hello-agent installs wrote
// "click" here, which disabled the password path; clearing it // "click" here, which disabled the password path; clearing it
// every install makes upgrades idempotent. These write into the // every install makes upgrades idempotent. These write into the
// *calling user's* %APPDATA%\HelloAgent\ — we mirror the result // *calling user's* %APPDATA%\hello-agent\ — we mirror the result
// into the service's effective dir in step 4. // into the service's effective dir in step 4.
hbb_common::config::Config::set_option("stop-service".into(), "".into()); hbb_common::config::Config::set_option("stop-service".into(), "".into());
hbb_common::config::Config::set_option("approve-mode".into(), "".into()); hbb_common::config::Config::set_option("approve-mode".into(), "".into());
// 4. Mirror the calling user's `HelloAgent.toml` / `HelloAgent2.toml` // 4. Mirror the calling user's `hello-agent.toml` / `hello-agent2.toml`
// into the LocalService-effective config root that the SYSTEM // into the LocalService-effective config root that the SYSTEM
// service will actually read. Without this, --config writes to e.g. // service will actually read. Without this, --config writes to e.g.
// C:\Users\Admin\AppData\Roaming\HelloAgent\, but the service runs // C:\Users\Admin\AppData\Roaming\hello-agent\, but the service runs
// as LocalSystem and (via hbb_common's `patch()`) reads from // as LocalSystem and (via hbb_common's `patch()`) reads from
// C:\Windows\ServiceProfiles\LocalService\AppData\Roaming\HelloAgent\. // C:\Windows\ServiceProfiles\LocalService\AppData\Roaming\hello-agent\.
if let Err(e) = mirror_config_to_service_dir() { if let Err(e) = mirror_config_to_service_dir() {
log::warn!( log::warn!(
"could not mirror config to service dir ({e:#}); the service may not see --config until first heartbeat" "could not mirror config to service dir ({e:#}); the service may not see --config until first heartbeat"
@@ -183,6 +199,60 @@ pub fn install() -> Result<()> {
let _ = svc.set_description(SERVICE_DESCRIPTION); let _ = svc.set_description(SERVICE_DESCRIPTION);
// 5b. Configure SCM auto-restart on unexpected exit. Without this,
// a panic in the `--service` supervisor leaves the agent permanently
// Stopped until the host reboots. The schedule restarts after
// 5s, 30s, 60s and gives up after that; the failure-count reset
// window is one day, so transient hiccups don't accumulate and
// stable hosts converge back to "running" within a minute.
//
// `set_failure_actions_on_non_crash_failures(true)` is what makes
// these actions fire when the service exits cleanly with a non-zero
// code (panic via abort, for instance), not just on outright
// crashes detected by the SCM. Both are best-effort; the SCM
// accepts the call but doesn't error if the underlying ChangeServiceConfig2
// fails for some reason — we log and continue.
let failure_actions = ServiceFailureActions {
reset_period: ServiceFailureResetPeriod::After(Duration::from_secs(60 * 60 * 24)),
reboot_msg: None,
command: None,
actions: Some(vec![
ServiceAction {
action_type: ServiceActionType::Restart,
delay: Duration::from_secs(5),
},
ServiceAction {
action_type: ServiceActionType::Restart,
delay: Duration::from_secs(30),
},
ServiceAction {
action_type: ServiceActionType::Restart,
delay: Duration::from_secs(60),
},
]),
};
if let Err(e) = svc.update_failure_actions(failure_actions) {
log::warn!("could not set SCM failure actions ({e}); auto-restart-on-crash disabled");
}
if let Err(e) = svc.set_failure_actions_on_non_crash_failures(true) {
log::warn!(
"could not enable failure actions for clean-exit-with-error ({e}); only hard crashes will trigger restart"
);
}
// 5c. Allow inbound TCP/UDP to hello-agent.exe at the Windows Firewall.
// A vanilla deploy doesn't actually need it (the rendezvous/relay
// connections are outbound), but operators who enable `direct-server`
// (TCP 21118) or `enable-lan-discovery` (UDP 21119) via the --config
// blob need this rule or those features silently fail. Cheaper to
// add it always than to discover at support-call time that the
// deploy never matched a firewall rule. Best-effort: if netsh
// isn't present (extremely stripped-down server SKUs) we log and
// continue.
if let Err(e) = install_firewall_rule(&target_exe) {
log::warn!("could not install firewall rule ({e:#}); inbound connections may be blocked");
}
// 6. Start the service. (Step 1 already stopped any prior instance.) // 6. Start the service. (Step 1 already stopped any prior instance.)
svc.start::<&str>(&[]).context("start service")?; svc.start::<&str>(&[]).context("start service")?;
log::info!( log::info!(
@@ -250,7 +320,7 @@ fn stage_binary() -> Result<PathBuf> {
Ok(dest) Ok(dest)
} }
/// Copy the calling user's `HelloAgent.toml` + `HelloAgent2.toml` into /// Copy the calling user's `hello-agent.toml` + `hello-agent2.toml` into
/// the LocalService-effective config dir so the SYSTEM service sees them. /// the LocalService-effective config dir so the SYSTEM service sees them.
fn mirror_config_to_service_dir() -> Result<()> { fn mirror_config_to_service_dir() -> Result<()> {
let dest_dir = service_config_dir(); let dest_dir = service_config_dir();
@@ -272,7 +342,7 @@ fn mirror_config_to_service_dir() -> Result<()> {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => { Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
// Calling user never had this file (e.g. --install without // Calling user never had this file (e.g. --install without
// --config, or first ever run on this machine, or the user // --config, or first ever run on this machine, or the user
// wiped %APPDATA%\HelloAgent\ between tests). Logged at // wiped %APPDATA%\hello-agent\ between tests). Logged at
// info so the post-install log shows clearly which toml // info so the post-install log shows clearly which toml
// files were available and which weren't. // files were available and which weren't.
log::info!( log::info!(
@@ -298,6 +368,16 @@ fn mirror_config_to_service_dir() -> Result<()> {
// ----------------------------- uninstall ------------------------------------ // ----------------------------- uninstall ------------------------------------
pub fn uninstall() -> Result<()> { pub fn uninstall() -> Result<()> {
// Probe-open the SCM with the rights we'll need (CONNECT for the SCM
// handle itself, and DELETE on the per-service open below). The same
// elevation-error mapping as install() — surface a single clear message
// when the operator forgot the elevated prompt.
let scm = ServiceManager::local_computer(
None::<&str>,
ServiceManagerAccess::CONNECT,
)
.map_err(map_scm_open_error)?;
// Kill every hello-agent.exe process except ourselves *first*. We can't // Kill every hello-agent.exe process except ourselves *first*. We can't
// rely on the SCM Stop control alone because the `--cm` child spawned // rely on the SCM Stop control alone because the `--cm` child spawned
// via `run_as_user` runs under the logged-in user's token, not SYSTEM, // via `run_as_user` runs under the logged-in user's token, not SYSTEM,
@@ -305,15 +385,9 @@ pub fn uninstall() -> Result<()> {
// Doing this up front means the SCM stop below is usually a no-op // Doing this up front means the SCM stop below is usually a no-op
// (service process already gone) and the rmdir at the end no longer // (service process already gone) and the rmdir at the end no longer
// races a lingering child holding hello-agent.exe open. Our own PID // races a lingering child holding hello-agent.exe open. Our own PID
// is excluded via taskkill's `/FI` so the uninstaller doesn't suicide. // is excluded via the sysinfo filter so the uninstaller doesn't suicide.
kill_orphan_processes(); kill_orphan_processes();
let scm = ServiceManager::local_computer(
None::<&str>,
ServiceManagerAccess::CONNECT,
)
.context("open SCM")?;
match scm.open_service( match scm.open_service(
SERVICE_NAME, SERVICE_NAME,
ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE, ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
@@ -343,9 +417,17 @@ pub fn uninstall() -> Result<()> {
Err(e) => return Err(anyhow!("open_service: {e}")), Err(e) => return Err(anyhow!("open_service: {e}")),
} }
// Remove the firewall rule we installed (best-effort). netsh delete is
// idempotent — if the rule was never there (or someone manually removed
// it) netsh returns 1 with "No rules match the specified criteria",
// which we treat as success.
if let Err(e) = delete_firewall_rule() {
log::warn!("could not delete firewall rule ({e:#}); remove it manually if needed");
}
cleanup_install_dir(); cleanup_install_dir();
// We deliberately do NOT delete the LocalService config dir here. // We deliberately do NOT delete the LocalService config dir here.
// `HelloAgent.toml` in that directory holds the agent's id + keypair, // `hello-agent.toml` in that directory holds the agent's id + keypair,
// which the rustdesk-server / rendezvous server has registered against // which the rustdesk-server / rendezvous server has registered against
// the agent's id. Wiping it forces the next --install to generate // the agent's id. Wiping it forces the next --install to generate
// fresh keys, which the rendezvous server's cached entry (and any // fresh keys, which the rendezvous server's cached entry (and any
@@ -354,7 +436,7 @@ pub fn uninstall() -> Result<()> {
// the connection sits idle until the peer times out. // the connection sits idle until the peer times out.
// //
// Operators who want a true hard wipe can run: // Operators who want a true hard wipe can run:
// rmdir /s /q "%SystemRoot%\ServiceProfiles\LocalService\AppData\Roaming\HelloAgent" // rmdir /s /q "%SystemRoot%\ServiceProfiles\LocalService\AppData\Roaming\hello-agent"
// and then delete the device record from the rustdesk-server admin UI. // and then delete the device record from the rustdesk-server admin UI.
log::info!("preserved LocalService config dir to keep agent keys/id stable across reinstalls"); log::info!("preserved LocalService config dir to keep agent keys/id stable across reinstalls");
Ok(()) Ok(())
@@ -365,58 +447,175 @@ pub fn uninstall() -> Result<()> {
/// old `--cm` child holding the exe open) and `--uninstall` (so the /// old `--cm` child holding the exe open) and `--uninstall` (so the
/// rmdir at the end isn't racing a lingering child). /// rmdir at the end isn't racing a lingering child).
/// ///
/// Shells out to the built-in `taskkill` rather than re-implementing the /// Walks the process table via `hbb_common::sysinfo` (the same enumerator
/// Toolhelp32 enumeration in winapi: taskkill ships in every Windows /// the vendored rustdesk uses internally) and calls `Process::kill` —
/// install since XP, runs in milliseconds, and the `/FI "PID ne <ours>"` /// equivalent to `TerminateProcess` under the hood. After issuing the
/// filter handles the "don't suicide ourselves" requirement declaratively. /// kills we poll the process table for actual exit rather than guessing
/// /// at a 500 ms sleep: `TerminateProcess` marks the process as exited but
/// Exit code 128 from taskkill means "no matching processes" — common /// the kernel takes a variable amount of time to release the image-file
/// case when there's no orphan to clean up — and we treat it the same /// handle, and we only want to return once those handles are gone (so
/// as success. Anything else gets logged but does not fail the caller. /// the install-time file copy and uninstall-time rmdir don't race a
/// half-finalized victim).
fn kill_orphan_processes() { fn kill_orphan_processes() {
// hbb_common pulls the rustdesk-org sysinfo 0.29 fork, which exposes
// System/Process/Pid with inherent methods (no SystemExt/ProcessExt
// trait imports needed — that style was removed when this fork
// diverged from upstream 0.30).
use hbb_common::sysinfo::{Pid, System};
let our_pid = std::process::id(); let our_pid = std::process::id();
let pid_filter = format!("PID ne {our_pid}"); let target = INSTALLED_EXE_NAME;
let output = std::process::Command::new("taskkill")
.args([ let mut system = System::new();
"/F", system.refresh_processes();
"/IM", let victims: Vec<Pid> = system
INSTALLED_EXE_NAME, .processes()
"/FI", .iter()
&pid_filter, .filter(|(pid, p)| {
]) pid.as_u32() != our_pid && p.name().eq_ignore_ascii_case(target)
.output(); })
match output { .map(|(pid, _)| *pid)
Ok(out) => { .collect();
let code = out.status.code();
let stdout = String::from_utf8_lossy(&out.stdout); if victims.is_empty() {
let stderr = String::from_utf8_lossy(&out.stderr); log::info!("no orphan {target} processes to kill");
if out.status.success() { return;
log::info!( }
"taskkill killed orphan {INSTALLED_EXE_NAME} processes (excluding pid {our_pid}): {}",
stdout.trim() let killed: Vec<u32> = victims
); .iter()
// TerminateProcess is synchronous w.r.t. the kernel marking .filter_map(|pid| {
// the process as exited, but kernel-mode finalization let process = system.process(*pid)?;
// (releasing file handles, paging out the image section) if process.kill() {
// can lag by up to a few hundred ms. The rmdir that follows Some(pid.as_u32())
// races against this: without the pause, an immediate
// remove_dir_all can still see "file in use" on the just-
// killed process's exe.
std::thread::sleep(Duration::from_millis(500));
} else if code == Some(128) {
log::info!("no orphan {INSTALLED_EXE_NAME} processes to kill");
} else { } else {
log::warn!( log::warn!("Process::kill failed for pid {}", pid.as_u32());
"taskkill returned {code:?}: stdout={} stderr={}", None
stdout.trim(),
stderr.trim(),
);
} }
})
.collect();
log::info!("issued kill on {} {target} process(es): {killed:?}", killed.len());
// Poll for actual exit. 5 s ceiling is generous (TerminateProcess
// usually finalizes within tens of ms) but cheap — we only burn it
// when the kernel really is dragging its feet, which is the exact
// case the old `sleep(500ms)` heuristic couldn't handle.
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
system.refresh_processes();
let still_alive = victims.iter().any(|pid| system.process(*pid).is_some());
if !still_alive {
return;
} }
Err(e) => { std::thread::sleep(Duration::from_millis(50));
log::warn!("could not invoke taskkill: {e}"); }
log::warn!(
"some {target} processes were still alive after 5 s; subsequent file ops may fail with sharing violation"
);
}
/// Translate a `windows_service::Error` from `ServiceManager::local_computer`
/// into a friendlier user-facing message. ERROR_ACCESS_DENIED (Win32 err 5)
/// is the overwhelmingly common case — operator forgot to elevate — and
/// deserves a single clear line rather than the raw Win32 errno string.
fn map_scm_open_error(e: windows_service::Error) -> anyhow::Error {
if let windows_service::Error::Winapi(ref ioe) = e {
if ioe.raw_os_error() == Some(5) {
return anyhow!(
"requires an elevated (Administrator) prompt — re-run from \"Run as administrator\""
);
} }
} }
anyhow!("open SCM: {e}")
}
/// Add a Windows Firewall rule allowing inbound TCP/UDP to the installed
/// hello-agent.exe. Idempotent: we delete any prior rule by the same name
/// first, so re-running --install (or upgrading in place) doesn't pile up
/// duplicate entries in the firewall's per-name list.
///
/// We use the program-scoped form (`program=<path>`) rather than port-scoped
/// rules because hello-agent's optional listeners (direct-server TCP 21118,
/// LAN-discovery UDP 21119) are gated on operator-controlled config flags;
/// rule-by-program covers whatever ports the agent actually decides to bind.
fn install_firewall_rule(exe_path: &PathBuf) -> Result<()> {
// Drop any pre-existing rule first; netsh quietly succeeds-with-exit-1
// when nothing matches, so we ignore the result.
let _ = run_netsh(&[
"advfirewall",
"firewall",
"delete",
"rule",
&format!("name={FIREWALL_RULE_NAME}"),
]);
let program_arg = format!(
"program={}",
exe_path.to_str().ok_or_else(|| anyhow!(
"non-UTF-8 install path can't be passed to netsh: {}",
exe_path.display()
))?
);
let status = run_netsh(&[
"advfirewall",
"firewall",
"add",
"rule",
&format!("name={FIREWALL_RULE_NAME}"),
"dir=in",
"action=allow",
"enable=yes",
"profile=any",
&program_arg,
])?;
if !status {
return Err(anyhow!("netsh add rule failed"));
}
log::info!(
"added firewall rule '{FIREWALL_RULE_NAME}' for {}",
exe_path.display()
);
Ok(())
}
/// Remove the hello-agent firewall rule by name. netsh exits non-zero when
/// no rule matches; we translate that into success since the post-condition
/// (no rule by that name) is what we want anyway.
fn delete_firewall_rule() -> Result<()> {
let status = run_netsh(&[
"advfirewall",
"firewall",
"delete",
"rule",
&format!("name={FIREWALL_RULE_NAME}"),
]);
match status {
Ok(_) => {
log::info!("removed firewall rule '{FIREWALL_RULE_NAME}' (or none was present)");
Ok(())
}
Err(e) => Err(e),
}
}
/// Shell out to netsh.exe with the given args. Returns Ok(true) on
/// exit-0, Ok(false) on a non-zero exit that *netsh itself* produced
/// (e.g. "rule already exists" or "no rules match"), and Err only when
/// the binary couldn't be invoked at all (PATH stripped, etc.).
fn run_netsh(args: &[&str]) -> Result<bool> {
let out = std::process::Command::new("netsh")
.args(args)
.output()
.context("invoke netsh")?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
log::debug!(
"netsh {args:?} exited {:?}: {}",
out.status.code(),
stderr.trim()
);
}
Ok(out.status.success())
} }
/// Remove %ProgramFiles%\hello-agent. Best-effort: if the user ran /// Remove %ProgramFiles%\hello-agent. Best-effort: if the user ran
@@ -505,6 +704,13 @@ fn service_main_inner() -> Result<()> {
// can race the rendezvous registration done by `--server`). // can race the rendezvous registration done by `--server`).
crate::unattended_password::rotate_and_report(); crate::unattended_password::rotate_and_report();
// Start the user-login tracker. Polls the WTS session table every
// few seconds, diffs against its previous snapshot, and POSTs
// logon/logoff events to the admin API. Independent background
// thread + Tokio runtime; lives for the service lifetime, no
// shutdown hook (the SCM termination is enough).
crate::login_events::start();
// Worker process handle. Killed on Stop, replaced on session change. // Worker process handle. Killed on Stop, replaced on session change.
// `last_state` carries (session_id, had_user). The `had_user` bit is // `last_state` carries (session_id, had_user). The `had_user` bit is
// what forces a respawn when a user logs in to a session we're // what forces a respawn when a user logs in to a session we're
+12 -1
View File
@@ -111,7 +111,18 @@ async fn try_report(password: &str) -> Result<()> {
}) })
.to_string(); .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 .await
.map_err(|e| anyhow!("post: {e}"))?; .map_err(|e| anyhow!("post: {e}"))?;
let trimmed = resp.trim(); let trimmed = resp.trim();
+129 -34
View File
@@ -94,11 +94,24 @@ pub mod input {
lazy_static::lazy_static! { lazy_static::lazy_static! {
pub static ref SOFTWARE_UPDATE_URL: Arc<Mutex<String>> = Default::default(); pub static ref SOFTWARE_UPDATE_URL: Arc<Mutex<String>> = Default::default();
// hello-agent local patch: assets resolved by the Gitea-backed
// `do_check_software_update`. `SOFTWARE_UPDATE_URL` is kept holding the
// human-facing tag URL (so `ui_interface::get_new_version` still works
// by rsplit('/')) while the actual binary + sha256 download URLs live
// here and are consumed by `updater::check_update`. None = no update.
pub static ref SOFTWARE_UPDATE_ASSETS: Arc<Mutex<Option<UpdateAssets>>> = Default::default();
pub static ref DEVICE_ID: Arc<Mutex<String>> = Default::default(); pub static ref DEVICE_ID: Arc<Mutex<String>> = Default::default();
pub static ref DEVICE_NAME: Arc<Mutex<String>> = Default::default(); pub static ref DEVICE_NAME: Arc<Mutex<String>> = Default::default();
static ref PUBLIC_IPV6_ADDR: Arc<Mutex<(Option<SocketAddr>, Option<Instant>)>> = Default::default(); static ref PUBLIC_IPV6_ADDR: Arc<Mutex<(Option<SocketAddr>, Option<Instant>)>> = Default::default();
} }
#[derive(Debug, Clone)]
pub struct UpdateAssets {
pub binary_url: String,
pub binary_name: String,
pub sha256_url: String,
}
lazy_static::lazy_static! { lazy_static::lazy_static! {
// Is server process, with "--server" args // Is server process, with "--server" args
static ref IS_SERVER: bool = std::env::args().nth(1) == Some("--server".to_owned()); static ref IS_SERVER: bool = std::env::args().nth(1) == Some("--server".to_owned());
@@ -949,19 +962,45 @@ pub fn check_software_update() {
} }
} }
// No need to check `danger_accept_invalid_cert` for now. // hello-agent local patch: instead of POSTing to api.rustdesk.com (the
// Because the url is always `https://api.rustdesk.com/version/latest`. // upstream endpoint that resolves the latest stock-RustDesk release), this
// queries the Gitea Releases API on the hello-agent repo and resolves
// the binary + sha256 asset URLs of the latest release. The original
// rustdesk-api code path is intentionally gone: a stock-RustDesk auto-update
// would happily replace hello-agent's installation with vanilla rustdesk.
// The product version compared against the release tag is
// `hbb_common::config::AGENT_VERSION` (populated from `CARGO_PKG_VERSION`
// in hello-agent's `main`) — not `crate::VERSION`, which is the embedded
// rustdesk core version and would always be much higher than hello-agent's
// release tags, making the updater think no update is ever available.
const HELLO_AGENT_RELEASES_API: &str =
"https://gitea.cstudio.ch/api/v1/repos/mike/hello-agent/releases/latest";
const HELLO_AGENT_TAG_URL_PREFIX: &str =
"https://gitea.cstudio.ch/mike/hello-agent/releases/tag";
#[derive(Debug, serde::Deserialize)]
struct GiteaRelease {
tag_name: String,
#[serde(default)]
assets: Vec<GiteaAsset>,
}
#[derive(Debug, serde::Deserialize)]
struct GiteaAsset {
name: String,
browser_download_url: String,
}
#[tokio::main(flavor = "current_thread")] #[tokio::main(flavor = "current_thread")]
pub async fn do_check_software_update() -> hbb_common::ResultType<()> { pub async fn do_check_software_update() -> hbb_common::ResultType<()> {
let (request, url) = let url = HELLO_AGENT_RELEASES_API;
hbb_common::version_check_request(hbb_common::VER_TYPE_RUSTDESK_CLIENT.to_string());
let proxy_conf = Config::get_socks(); let proxy_conf = Config::get_socks();
let tls_url = get_url_for_tls(&url, &proxy_conf); let tls_url = get_url_for_tls(url, &proxy_conf);
let tls_type = get_cached_tls_type(tls_url); let tls_type = get_cached_tls_type(tls_url);
let is_tls_not_cached = tls_type.is_none(); let is_tls_not_cached = tls_type.is_none();
let tls_type = tls_type.unwrap_or(TlsType::Rustls); let tls_type = tls_type.unwrap_or(TlsType::Rustls);
let client = create_http_client_async(tls_type, false); let client = create_http_client_async(tls_type, false);
let latest_release_response = match client.post(&url).json(&request).send().await { let response = match client.get(url).send().await {
Ok(resp) => { Ok(resp) => {
upsert_tls_cache(tls_url, tls_type, false); upsert_tls_cache(tls_url, tls_type, false);
resp resp
@@ -970,7 +1009,7 @@ pub async fn do_check_software_update() -> hbb_common::ResultType<()> {
if is_tls_not_cached && err.is_request() { if is_tls_not_cached && err.is_request() {
let tls_type = TlsType::NativeTls; let tls_type = TlsType::NativeTls;
let client = create_http_client_async(tls_type, false); let client = create_http_client_async(tls_type, false);
let resp = client.post(&url).json(&request).send().await?; let resp = client.get(url).send().await?;
upsert_tls_cache(tls_url, tls_type, false); upsert_tls_cache(tls_url, tls_type, false);
resp resp
} else { } else {
@@ -978,24 +1017,63 @@ pub async fn do_check_software_update() -> hbb_common::ResultType<()> {
} }
} }
}; };
let bytes = latest_release_response.bytes().await?; if !response.status().is_success() {
let resp: hbb_common::VersionCheckResponse = serde_json::from_slice(&bytes)?; bail!("Gitea releases API returned HTTP {}", response.status());
let response_url = resp.url; }
let latest_release_version = response_url.rsplit('/').next().unwrap_or_default(); let bytes = response.bytes().await?;
let release: GiteaRelease = serde_json::from_slice(&bytes)?;
if get_version_number(&latest_release_version) > get_version_number(crate::VERSION) { let latest_version = release.tag_name.trim_start_matches('v');
#[cfg(feature = "flutter")] let current_version = hbb_common::config::AGENT_VERSION.read().unwrap().clone();
{ let current_version = if current_version.is_empty() {
let mut m = HashMap::new(); crate::VERSION.to_owned()
m.insert("name", "check_software_update_finish");
m.insert("url", &response_url);
if let Ok(data) = serde_json::to_string(&m) {
let _ = crate::flutter::push_global_event(crate::flutter::APP_TYPE_MAIN, data);
}
}
*SOFTWARE_UPDATE_URL.lock().unwrap() = response_url;
} else { } else {
current_version
};
if get_version_number(latest_version) <= get_version_number(&current_version) {
*SOFTWARE_UPDATE_URL.lock().unwrap() = "".to_string(); *SOFTWARE_UPDATE_URL.lock().unwrap() = "".to_string();
*SOFTWARE_UPDATE_ASSETS.lock().unwrap() = None;
return Ok(());
}
// Pick the Windows binary asset and its SHA256 companion. The release
// is expected to carry a `*.exe` (or `*.exe.signed`) and a matching
// `*.sha256` file. We don't pair them by name — there should only be
// one of each per release.
let binary = release.assets.iter().find(|a| {
let n = a.name.to_lowercase();
(n.ends_with(".exe") || n.ends_with(".exe.signed")) && !n.ends_with(".sha256")
});
let sha256 = release
.assets
.iter()
.find(|a| a.name.to_lowercase().ends_with(".sha256"));
let (Some(binary), Some(sha256)) = (binary, sha256) else {
log::warn!(
"hello-agent release {} is missing a binary and/or .sha256 asset",
release.tag_name
);
*SOFTWARE_UPDATE_URL.lock().unwrap() = "".to_string();
*SOFTWARE_UPDATE_ASSETS.lock().unwrap() = None;
return Ok(());
};
let tag_url = format!("{}/{}", HELLO_AGENT_TAG_URL_PREFIX, release.tag_name);
*SOFTWARE_UPDATE_URL.lock().unwrap() = tag_url.clone();
*SOFTWARE_UPDATE_ASSETS.lock().unwrap() = Some(UpdateAssets {
binary_url: binary.browser_download_url.clone(),
binary_name: binary.name.clone(),
sha256_url: sha256.browser_download_url.clone(),
});
#[cfg(feature = "flutter")]
{
let mut m = HashMap::new();
m.insert("name", "check_software_update_finish");
m.insert("url", &tag_url);
if let Ok(data) = serde_json::to_string(&m) {
let _ = crate::flutter::push_global_event(crate::flutter::APP_TYPE_MAIN, data);
}
} }
Ok(()) Ok(())
} }
@@ -1253,17 +1331,26 @@ async fn tcp_proxy_request(
fn parse_simple_header(header: &str) -> Vec<HeaderEntry> { fn parse_simple_header(header: &str) -> Vec<HeaderEntry> {
let mut entries = Vec::new(); let mut entries = Vec::new();
let mut has_content_type = false; 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() { if !header.is_empty() {
let tmp: Vec<&str> = header.splitn(2, ": ").collect(); for line in header.split('\n') {
if tmp.len() == 2 { let line = line.trim();
if tmp[0].eq_ignore_ascii_case("Content-Type") { if line.is_empty() {
has_content_type = true; 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 { if !has_content_type {
@@ -1421,10 +1508,18 @@ async fn post_request_(
danger_accept_invalid_cert.unwrap_or(false), danger_accept_invalid_cert.unwrap_or(false),
) )
.post(url); .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() { if !header.is_empty() {
let tmp: Vec<&str> = header.split(": ").collect(); for line in header.split('\n') {
if tmp.len() == 2 { let line = line.trim();
req = req.header(tmp[0], tmp[1]); 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"); req = req.header("Content-Type", "application/json");
+1
View File
@@ -7,6 +7,7 @@ pub mod account;
pub mod downloader; pub mod downloader;
mod http_client; mod http_client;
pub mod record_upload; pub mod record_upload;
pub mod sign;
pub mod sync; pub mod sync;
pub use http_client::{ pub use http_client::{
create_http_client_async, create_http_client_async_with_url, create_http_client_with_url, 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! { lazy_static::lazy_static! {
static ref SENDER : Mutex<broadcast::Sender<Vec<i32>>> = Mutex::new(start_hbbs_sync()); static ref SENDER : Mutex<broadcast::Sender<Vec<i32>>> = Mutex::new(start_hbbs_sync());
static ref PRO: Arc<Mutex<bool>> = Default::default(); 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")))] #[cfg(not(any(target_os = "ios")))]
@@ -36,6 +60,14 @@ pub fn signal_receiver() -> broadcast::Receiver<Vec<i32>> {
SENDER.lock().unwrap().subscribe() 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")))] #[cfg(not(any(target_os = "ios")))]
fn start_hbbs_sync() -> broadcast::Sender<Vec<i32>> { fn start_hbbs_sync() -> broadcast::Sender<Vec<i32>> {
let (tx, _rx) = broadcast::channel::<Vec<i32>>(16); 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) => { Ok(x) => {
if x == "SYSINFO_UPDATED" { if x == "SYSINFO_UPDATED" {
info_uploaded = InfoUploaded::uploaded(url.clone(), id.clone(), sys_username, had_inventory); 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); let modified_at = LocalConfig::get_option("strategy_timestamp").parse::<i64>().unwrap_or(0);
v["modified_at"] = json!(modified_at); 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 let Ok(mut rsp) = serde_json::from_str::<HashMap::<&str, Value>>(&s) {
if rsp.remove("sysinfo").is_some() { if rsp.remove("sysinfo").is_some() {
info_uploaded.uploaded = false; info_uploaded.uploaded = false;
@@ -317,6 +365,27 @@ async fn start_hbbs_sync_async() {
handle_config_options(strategy.config_options); 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_interface;
mod ui_session_interface; mod ui_session_interface;
mod hbbs_http; pub mod hbbs_http;
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
pub mod clipboard_file; 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> { pub fn copy_exe_cmd(src_exe: &str, exe: &str, path: &str) -> ResultType<String> {
let main_exe = copy_raw_cmd(src_exe, exe, path)?; // hello-agent local patch: upstream emits an `XCOPY <parent of src_exe>
// <install dir> /Y /E /H /C /I /K /R /Z`, which recursively copies the
// ENTIRE TEMP directory (the staged binary's parent) into the install
// dir — sweeping along every unrelated file that happens to share the
// temp folder. For hello-agent we ship a single binary, so a one-file
// copy is both correct and safe. We preserve the staged exe's original
// filename so the subsequent `rename_exe_cmd` step (which renames
// <staged-name>.exe → <app_name>.exe) keeps working exactly as upstream
// expects.
//
// We also drop the broker (RuntimeBroker.exe) copy on the second line:
// it's only needed for privacy-mode topmost-window injection, which
// hello-agent doesn't enable (we don't ship the broker as a separate
// artifact, and shipping a copy of a system file under a custom name
// is asking for AV false-positives).
let src_path = PathBuf::from(src_exe);
let src_filename = src_path
.file_name()
.ok_or_else(|| anyhow!("Can't get file name of {src_exe}"))?
.to_string_lossy()
.to_string();
let _ = exe; // upstream signature carries the resolved exe path; not needed for the single-file copy.
Ok(format!( Ok(format!(
" "copy /Y \"{src_exe}\" \"{path}\\{src_filename}\"\n"
{main_exe}
copy /Y \"{ORIGIN_PROCESS_EXE}\" \"{path}\\{broker_exe}\"
",
ORIGIN_PROCESS_EXE = win_topmost_window::ORIGIN_PROCESS_EXE,
broker_exe = win_topmost_window::INJECTED_PROCESS_EXE,
)) ))
} }
@@ -3110,21 +3126,27 @@ reg add {subkey} /f /v EstimatedSize /t REG_DWORD /d {size}
) )
} }
// hello-agent local patch: only refresh the Add/Remove Programs entry if
// the install path actually created one. Hello-agent's `--install`
// (`src/service.rs::install`) does not write to
// `HKLM\...\Uninstall\<APP_NAME>` — the agent is a headless service
// that intentionally doesn't appear in Add/Remove Programs (there's
// nothing meaningful to uninstall through the shell-integrated UI;
// operators run `hello-agent.exe --uninstall`). Without this guard,
// the upstream `update_me` would *create* the uninstall key on every
// update — and then leave it behind as an orphan, since
// `service::uninstall()` doesn't remove it either. Upstream rustdesk's
// `install_me` does write the key, so stock-rustdesk installs continue
// to get their version display refreshed as before.
let subkey_exists = |sk: &str| {
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
hklm.open_subkey(sk.replace("HKEY_LOCAL_MACHINE\\", ""))
.is_ok()
};
let reg_cmd = { let reg_cmd = {
let reg_cmd_main = get_reg_cmd( let reg_cmd_main = if subkey_exists(&subkey) {
&subkey,
is_msi,
&display_icon,
&version,
&build_date,
&version_major,
&version_minor,
&version_build,
size,
);
let reg_cmd_msi = if let Some(reg_msi_key) = get_reg_msi_key(&subkey, is_msi) {
get_reg_cmd( get_reg_cmd(
&reg_msi_key, &subkey,
is_msi, is_msi,
&display_icon, &display_icon,
&version, &version,
@@ -3137,6 +3159,25 @@ reg add {subkey} /f /v EstimatedSize /t REG_DWORD /d {size}
} else { } else {
"".to_owned() "".to_owned()
}; };
let reg_cmd_msi = if let Some(reg_msi_key) = get_reg_msi_key(&subkey, is_msi) {
if subkey_exists(&reg_msi_key) {
get_reg_cmd(
&reg_msi_key,
is_msi,
&display_icon,
&version,
&build_date,
&version_major,
&version_minor,
&version_build,
size,
)
} else {
"".to_owned()
}
} else {
"".to_owned()
};
format!("{}{}", reg_cmd_main, reg_cmd_msi) format!("{}{}", reg_cmd_main, reg_cmd_msi)
}; };
+101 -61
View File
@@ -118,8 +118,16 @@ fn start_auto_update_check_(rx_msg: Receiver<UpdateMsg>) {
} }
fn check_update(manually: bool) -> ResultType<()> { fn check_update(manually: bool) -> ResultType<()> {
// hello-agent local patch: `is_msi_installed()` reads HKLM\...\Uninstall\HelloAgent
// and errors when the uninstall key (or `WindowsInstaller` value) is
// absent, which is the common case for a hello-agent install. The
// upstream code used `?` here, propagating the registry error and
// killing the update before our Gitea check ever ran. Swallow the
// error: `update_msi` will be `false` (correct — hello-agent is a
// custom client so the MSI branch is never the right one anyway).
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
let update_msi = crate::platform::is_msi_installed()? && !crate::is_custom_client(); let update_msi =
crate::platform::is_msi_installed().unwrap_or(false) && !crate::is_custom_client();
if !(manually || config::Config::get_bool_option(config::keys::OPTION_ALLOW_AUTO_UPDATE)) { if !(manually || config::Config::get_bool_option(config::keys::OPTION_ALLOW_AUTO_UPDATE)) {
return Ok(()); return Ok(());
} }
@@ -128,70 +136,102 @@ fn check_update(manually: bool) -> ResultType<()> {
return Ok(()); return Ok(());
} }
let update_url = crate::common::SOFTWARE_UPDATE_URL.lock().unwrap().clone(); // hello-agent local patch: the upstream code reconstructed the download
if update_url.is_empty() { // URL from a GitHub "tag" URL and a hard-coded filename pattern. Both
// are now resolved by `do_check_software_update` against the Gitea
// Releases API and exposed via `SOFTWARE_UPDATE_ASSETS`.
let assets = crate::common::SOFTWARE_UPDATE_ASSETS.lock().unwrap().clone();
let Some(assets) = assets else {
log::debug!("No update available."); log::debug!("No update available.");
} else { return Ok(());
let download_url = update_url.replace("tag", "download"); };
let version = download_url.split('/').last().unwrap_or_default(); let download_url = assets.binary_url;
#[cfg(target_os = "windows")] let sha256_url = assets.sha256_url;
let download_url = if cfg!(feature = "flutter") { let version = assets.binary_name;
format!( log::debug!("New version available: {}", &version);
"{}/rustdesk-{}-x86_64.{}", let client = create_http_client_with_url(&download_url);
download_url, let Some(file_path) = get_download_file_from_url(&download_url) else {
version, bail!("Failed to get the file path from the URL: {}", download_url);
if update_msi { "msi" } else { "exe" } };
) let mut is_file_exists = false;
if file_path.exists() {
// Check if the file size is the same as the server file size
// If the file size is the same, we don't need to download it again.
let file_size = std::fs::metadata(&file_path)?.len();
let response = client.head(&download_url).send()?;
if !response.status().is_success() {
bail!("Failed to get the file size: {}", response.status());
}
let total_size = response
.headers()
.get(reqwest::header::CONTENT_LENGTH)
.and_then(|ct_len| ct_len.to_str().ok())
.and_then(|ct_len| ct_len.parse::<u64>().ok());
let Some(total_size) = total_size else {
bail!("Failed to get content length");
};
if file_size == total_size {
is_file_exists = true;
} else { } else {
format!("{}/rustdesk-{}-x86-sciter.exe", download_url, version) std::fs::remove_file(&file_path)?;
};
log::debug!("New version available: {}", &version);
let client = create_http_client_with_url(&download_url);
let Some(file_path) = get_download_file_from_url(&download_url) else {
bail!("Failed to get the file path from the URL: {}", download_url);
};
let mut is_file_exists = false;
if file_path.exists() {
// Check if the file size is the same as the server file size
// If the file size is the same, we don't need to download it again.
let file_size = std::fs::metadata(&file_path)?.len();
let response = client.head(&download_url).send()?;
if !response.status().is_success() {
bail!("Failed to get the file size: {}", response.status());
}
let total_size = response
.headers()
.get(reqwest::header::CONTENT_LENGTH)
.and_then(|ct_len| ct_len.to_str().ok())
.and_then(|ct_len| ct_len.parse::<u64>().ok());
let Some(total_size) = total_size else {
bail!("Failed to get content length");
};
if file_size == total_size {
is_file_exists = true;
} else {
std::fs::remove_file(&file_path)?;
}
} }
if !is_file_exists { }
let response = client.get(&download_url).send()?; if !is_file_exists {
if !response.status().is_success() { let response = client.get(&download_url).send()?;
bail!( if !response.status().is_success() {
"Failed to download the new version file: {}", bail!(
response.status() "Failed to download the new version file: {}",
); response.status()
} );
let file_data = response.bytes()?;
let mut file = std::fs::File::create(&file_path)?;
file.write_all(&file_data)?;
}
// We have checked if the `conns` is empty before, but we need to check again.
// No need to care about the downloaded file here, because it's rare case that the `conns` are empty
// before the download, but not empty after the download.
if has_no_active_conns() {
#[cfg(target_os = "windows")]
update_new_version(update_msi, &version, &file_path);
} }
let file_data = response.bytes()?;
let mut file = std::fs::File::create(&file_path)?;
file.write_all(&file_data)?;
}
// hello-agent local patch: verify the downloaded binary's SHA256 against
// the `.sha256` companion asset published by the same Gitea release
// before launching. We're about to run this file with elevated rights —
// a mismatch means something went wrong in transit or the release was
// tampered with, and we must NOT launch it. The expected-hash file is a
// standard `sha256sum` output (`<hex> <filename>`) or just `<hex>`.
let sha_client = create_http_client_with_url(&sha256_url);
let sha_resp = sha_client.get(&sha256_url).send()?;
if !sha_resp.status().is_success() {
let _ = std::fs::remove_file(&file_path);
bail!("Failed to download SHA256 file: {}", sha_resp.status());
}
let sha_text = sha_resp.text()?;
let expected = sha_text
.split_whitespace()
.next()
.unwrap_or_default()
.to_lowercase();
if expected.len() != 64 || !expected.chars().all(|c| c.is_ascii_hexdigit()) {
let _ = std::fs::remove_file(&file_path);
bail!("Malformed SHA256 file: {:?}", sha_text);
}
let file_bytes = std::fs::read(&file_path)?;
use sha2::Digest as _;
let mut hasher = sha2::Sha256::new();
hasher.update(&file_bytes);
let actual = hex::encode(hasher.finalize());
if actual != expected {
log::error!(
"SHA256 mismatch for {}: expected {}, got {}",
version,
expected,
actual
);
let _ = std::fs::remove_file(&file_path);
bail!("SHA256 verification failed");
}
log::info!("SHA256 verified for {}", version);
// We have checked if the `conns` is empty before, but we need to check again.
// No need to care about the downloaded file here, because it's rare case that the `conns` are empty
// before the download, but not empty after the download.
if has_no_active_conns() {
#[cfg(target_os = "windows")]
update_new_version(update_msi, &version, &file_path);
} }
Ok(()) Ok(())
} }
+1 -1
View File
@@ -1,3 +1,3 @@
pub const VERSION: &str = "1.4.6"; pub const VERSION: &str = "1.4.6";
#[allow(dead_code)] #[allow(dead_code)]
pub const BUILD_DATE: &str = "2026-05-09 10:43"; pub const BUILD_DATE: &str = "2026-05-22 14:17";