From 6bdf1058fa98adf45c93373ae37453f60cbecb30 Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Fri, 22 May 2026 14:18:25 +0200 Subject: [PATCH] Implement remote execution --- Cargo.lock | 2 +- Cargo.toml | 4 +- README.md | 45 ++++- src/exec.rs | 259 ++++++++++++++++++++++++++ src/main.rs | 11 ++ vendor/rustdesk/src/hbbs_http/sync.rs | 53 ++++++ vendor/rustdesk/src/version.rs | 2 +- 7 files changed, 368 insertions(+), 8 deletions(-) create mode 100644 src/exec.rs diff --git a/Cargo.lock b/Cargo.lock index a3833a7..a8b0f52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 44cab27..244f820 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index 0db86b7..ff4fa02 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/exec.rs b/src/exec.rs new file mode 100644 index 0000000..1d71b85 --- /dev/null +++ b/src/exec.rs @@ -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, bool)>> = Arc::new(Mutex::new((Vec::new(), false))); + let stderr_buf: Arc, 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( + mut reader: R, + buf: Arc, 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)"); +} diff --git a/src/main.rs b/src/main.rs index 33007c0..575a0c5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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. diff --git a/vendor/rustdesk/src/hbbs_http/sync.rs b/vendor/rustdesk/src/hbbs_http/sync.rs index ab4c467..c1358b3 100644 --- a/vendor/rustdesk/src/hbbs_http/sync.rs +++ b/vendor/rustdesk/src/hbbs_http/sync.rs @@ -24,6 +24,30 @@ const TIME_CONN: Duration = Duration::from_secs(3); lazy_static::lazy_static! { static ref SENDER : Mutex>> = Mutex::new(start_hbbs_sync()); static ref PRO: Arc> = 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> = { + let (tx, _rx) = broadcast::channel::(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> { 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 { + EXEC_SENDER.lock().unwrap().subscribe() +} + #[cfg(not(any(target_os = "ios")))] fn start_hbbs_sync() -> broadcast::Sender> { let (tx, _rx) = broadcast::channel::>(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::>(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); + } + } + } } } } diff --git a/vendor/rustdesk/src/version.rs b/vendor/rustdesk/src/version.rs index aaa36f9..c369ae5 100644 --- a/vendor/rustdesk/src/version.rs +++ b/vendor/rustdesk/src/version.rs @@ -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";