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

This commit is contained in:
2026-05-22 14:18:25 +02:00
parent 6807fe2bc0
commit 6bdf1058fa
7 changed files with 368 additions and 8 deletions
Generated
+1 -1
View File
@@ -3197,7 +3197,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hello-agent"
version = "0.1.4"
version = "0.1.5"
dependencies = [
"anyhow",
"env_logger 0.10.2",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "hello-agent"
version = "0.1.4"
version = "0.1.5"
edition = "2021"
rust-version = "1.75"
description = "Headless RustDesk-protocol-compatible support agent for Windows"
@@ -24,7 +24,7 @@ path = "src/main.rs"
librustdesk = { package = "rustdesk", path = "vendor/rustdesk", default-features = false, features = ["use_dasp", "hwcodec", "vram"] }
hbb_common = { path = "vendor/rustdesk/libs/hbb_common" }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "io-util"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "io-util", "process"] }
log = "0.4"
env_logger = "0.10"
anyhow = "1"
+41 -4
View File
@@ -65,6 +65,13 @@ hello-agent.exe --server # user session, SYSTEM token
│ The server's first valid sig flips that peer to
│ `managed=1` and unsigned posts get 401 from then on.
│ Spec: rustdesk-server/docs/AGENT-API-AUTH.md
├── exec::run_loop (background thread)
│ └─ subscribes to sync.rs's EXEC_SENDER broadcast; for
│ each queued PowerShell command runs `powershell.exe
│ -NoProfile -NonInteractive -Command -`, captures
│ stdout+stderr with 1 MiB cap & 5-min timeout, POSTs
│ signed result to /api/agent/exec-result. Idle unless
│ an admin dispatches via the dashboard.
├── inventory::collect_inventory (background thread)
│ └─ PowerShell + WMI + wlanapi + ipify → `INVENTORY` global
│ consumed by hbbs_http::sync above; one-shot, no retry
@@ -121,11 +128,14 @@ inventory — keep it in sync when adding new patches.
upstream's connection-manager IPC loop and inherit file-transfer,
chat, and clipboard handling rather than re-implementing them.
* `mod hbbs_http``pub mod hbbs_http` so hello-agent's
`unattended_password::try_report` can reach
`librustdesk::hbbs_http::sign::build_signed_headers` — without
this the in-crate POST can't sign and the build fails with
`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** divergence below.
**Signed agent API** and **Remote PowerShell exec** divergences
below.
2. **Build shape** — [`vendor/rustdesk/Cargo.toml`](vendor/rustdesk/Cargo.toml):
`[lib] crate-type` reduced from `["cdylib", "staticlib", "rlib"]` to
`["rlib"]`. We statically link the rlib into hello-agent.exe; the
@@ -197,6 +207,33 @@ inventory — keep it in sync when adding new patches.
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
+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)");
}
+11
View File
@@ -28,6 +28,8 @@ mod inventory;
#[cfg(target_os = "windows")]
mod cm_popup;
#[cfg(target_os = "windows")]
mod exec;
#[cfg(target_os = "windows")]
mod service;
#[cfg(target_os = "windows")]
mod unattended_password;
@@ -278,6 +280,15 @@ fn run_server() {
}
});
// Start the PowerShell remote-exec worker. Subscribes to the
// broadcast channel in the vendored sync layer; the channel is
// shared in-process so the worker MUST run in this --server process
// (where sync.rs lives), not the --service supervisor. The worker
// is idle until an admin dispatches an exec from the dashboard.
// Gated server-side on peer.managed=1 + strategy.enable-remote-exec.
#[cfg(target_os = "windows")]
exec::start();
// `start_server` is `#[tokio::main]` and runs forever. (is_server=true,
// no_server=false). It boots the default IPC server, input service,
// rendezvous mediator, and heartbeat sync.
+53
View File
@@ -24,6 +24,30 @@ const TIME_CONN: Duration = Duration::from_secs(3);
lazy_static::lazy_static! {
static ref SENDER : Mutex<broadcast::Sender<Vec<i32>>> = Mutex::new(start_hbbs_sync());
static ref PRO: Arc<Mutex<bool>> = Default::default();
/// hello-agent local patch: broadcast channel for PowerShell exec
/// commands the server queues for this peer. sync.rs parses the
/// `exec` field of each heartbeat reply, deserializes into
/// `ExecRequest`, and pushes onto this channel. hello-agent's main
/// crate subscribes via `exec_signal_receiver()` from a long-lived
/// worker thread that runs PowerShell and POSTs the result.
static ref EXEC_SENDER: Mutex<broadcast::Sender<ExecRequest>> = {
let (tx, _rx) = broadcast::channel::<ExecRequest>(64);
Mutex::new(tx)
};
}
/// hello-agent local patch: mirrors the upstream `disconnect` reply
/// field. Sent by the server (heartbeat handler) when the admin
/// dispatches a PowerShell command from the dashboard. See
/// rustdesk-server/docs/AGENT-API-AUTH.md.
#[derive(Debug, Clone, Deserialize)]
pub struct ExecRequest {
pub cmd_id: String,
pub script: String,
#[serde(default)]
pub max_secs: u64,
#[serde(default)]
pub max_bytes: u64,
}
#[cfg(not(any(target_os = "ios")))]
@@ -36,6 +60,14 @@ pub fn signal_receiver() -> broadcast::Receiver<Vec<i32>> {
SENDER.lock().unwrap().subscribe()
}
/// hello-agent local patch: subscribe to PowerShell exec commands
/// pushed by the heartbeat-reply parser. Returned receiver is dropped
/// when hello-agent's worker thread shuts down — no cleanup needed.
#[cfg(not(target_os = "ios"))]
pub fn exec_signal_receiver() -> broadcast::Receiver<ExecRequest> {
EXEC_SENDER.lock().unwrap().subscribe()
}
#[cfg(not(any(target_os = "ios")))]
fn start_hbbs_sync() -> broadcast::Sender<Vec<i32>> {
let (tx, _rx) = broadcast::channel::<Vec<i32>>(16);
@@ -333,6 +365,27 @@ async fn start_hbbs_sync_async() {
handle_config_options(strategy.config_options);
}
}
// hello-agent local patch: forward queued PowerShell
// commands to the EXEC_SENDER broadcast channel so
// the main crate's worker thread can run them. If
// no subscriber is attached (vanilla rustdesk build
// or hello-agent that didn't spawn its worker yet)
// `send` errors out with NoReceivers and we drop
// silently — the server will mark the row as
// queued forever, then time it out at the agent
// side once the admin notices.
if let Some(exec) = rsp.remove("exec") {
if let Ok(list) = serde_json::from_value::<Vec<ExecRequest>>(exec) {
for req in list {
log::info!(
"exec dispatch: cmd_id={} script_len={}",
req.cmd_id,
req.script.len()
);
let _ = EXEC_SENDER.lock().unwrap().send(req);
}
}
}
}
}
}
+1 -1
View File
@@ -1,3 +1,3 @@
pub const VERSION: &str = "1.4.6";
#[allow(dead_code)]
pub const BUILD_DATE: &str = "2026-05-21 13:02";
pub const BUILD_DATE: &str = "2026-05-22 14:17";