Initial commit: hello-agent — headless RustDesk-protocol-compatible Windows agent
build-windows / build-hello-agent-x64 (push) Successful in 5m41s

A single-binary, Flutter-free remote-support agent that speaks the stock
RustDesk wire protocol. Designed for one-line MDM deployment against a
self-hosted rustdesk-server: a supporter using the unmodified rustdesk.exe
client connects, the controlled-side user gets a native Win32 approval
prompt, click Yes / No.

CLI surface

    hello-agent.exe --install                # register + start service
    hello-agent.exe --uninstall              # stop, delete, clean up
    hello-agent.exe --config <BLOB>          # admin-UI deploy string
    hello-agent.exe --install --config <BLOB>   # MDM one-liner

--config accepts both forms emitted by the rustdesk-server admin UI: the
reversed-base64 deploy string and the host=,key=,api=,relay= filename
form. Decoded via the upstream custom_server module, persisted via
hbb_common::config::Config::set_option.

Architecture

    --service runs as a Session 0 LocalSystem service. It polls
    WTSGetActiveConsoleSessionId and (re)spawns hello-agent.exe --server
    into the active console session via librustdesk::platform::run_as_user,
    handling the Session 0 → user-session token impersonation.

    --server is the worker. It boots three concurrent components:
      1. cm_popup: an IPC listener on the rustdesk `_cm` named pipe
      2. librustdesk::start_server(true, false): the upstream protocol
         stack — rendezvous mediator, NAT punch, IPC server, screen
         capture, login validation, hbbs_http heartbeat / sysinfo sync
      3. (implicit) ApproveMode::Click is pinned in config, so every
         incoming connection routes through cm_popup

The popup mechanism reuses an existing upstream contract without any
patches to the protocol code: when a peer connects with no password,
Connection::start in the upstream code calls try_start_cm_ipc, which
ipc::connect-s the `_cm` pipe before falling back to spawning a Flutter
CM child. Since cm_popup is up first, step 1 succeeds; we read the
Data::Login{authorized:false} frame, show MessageBoxTimeoutW (Yes/No,
60s, top-most, system-modal), and reply Data::Authorize or Data::Close.

Source tree

    src/main.rs             CLI dispatcher + run_server() composition
    src/cli.rs              hand-rolled argv parser + unit tests
    src/service.rs          windows-service install/uninstall/dispatcher
    src/config_import.rs    --config blob decoding + persistence
    src/cm_popup.rs         _cm IPC listener + Win32 approval dialog

Vendoring

The upstream RustDesk crate is vendored under vendor/rustdesk/ — full
workspace including libs/{hbb_common, scrap, enigo, clipboard,
virtual_display, remote_printer}. This makes the build self-contained
(no submodules, no sibling-repo checkout in CI) and gives us freedom to
fork in a different direction later. Excluded from the vendor: .git,
target/, flutter/, appimage/, flatpak/, fastlane/, docs/, examples/,
ci/, build.py, Dockerfile, upstream README/CLAUDE/AGENTS/GEMINI.

One local divergence vs. upstream: vendor/rustdesk/src/lib.rs flips
`mod custom_server` → `pub mod custom_server` so config_import.rs can
call get_custom_server_from_string without going through the
ui_interface shim. Documented in README.md → "Re-syncing the vendored
copy".

CI

.gitea/workflows/build-windows.yml builds on a self-hosted Windows
runner with Rust 1.75, LLVM 15.0.6 (libclang for bindgen via libvpx-sys),
and a vcpkg cache. The vendored vcpkg.json drives x64-windows-static
deps. The workflow stages the resulting hello-agent.exe into
SignOutput\, reports authenticode signing status (warns on unsigned),
and uploads as artifact. ~15 min full build, faster on incremental.

Out of scope for this commit: Linux/macOS builds, code signing, MSI
packaging, coexistence with stock rustdesk on the same box (currently
shares the RustDesk APP_NAME and config dir).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 11:01:30 +02:00
commit f8ead215d8
479 changed files with 188052 additions and 0 deletions
+190
View File
@@ -0,0 +1,190 @@
// Hand-rolled CLI parser. Matches the upstream rustdesk style (see
// `src/core_main.rs` in the rustdesk crate) — clap is not pulled into the
// main path. Only a handful of flags are supported on purpose: the surface
// area is the user-facing contract.
use anyhow::{bail, Result};
#[derive(Debug, PartialEq, Eq)]
pub enum Action {
/// `--install`. Optionally combined with `--config <BLOB>` for MDM
/// one-liner deployment.
Install,
/// `--uninstall`. Stops the service, deletes it, removes config dir.
Uninstall,
/// `--service`. SCM entry point; user code should never invoke this
/// manually except via the service dispatcher.
Service,
/// `--server`. Worker mode launched into the active console session by
/// the service shell.
Server,
/// `--config <BLOB>` without `--install`. Persist config and exit.
ConfigOnly,
/// No flags. Foreground dev mode.
None,
/// `--cm`. Connection-manager popup mode. Spawned as a USER-token child
/// by the SYSTEM-token `--server` worker (via librustdesk's
/// `run_as_user`) when a peer needs interactive approval. Binds the
/// `_cm` IPC pipe, shows MessageBoxW, replies, exits.
Cm,
}
#[derive(Debug)]
pub struct ParsedArgs {
pub action: Action,
pub config_blob: Option<String>,
}
impl ParsedArgs {
pub fn from_argv<I: IntoIterator<Item = String>>(argv: I) -> Result<Self> {
let args: Vec<String> = argv.into_iter().collect();
let mut install = false;
let mut uninstall = false;
let mut service = false;
let mut server = false;
let mut cm = false;
let mut config_blob: Option<String> = None;
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--install" => install = true,
"--uninstall" => uninstall = true,
"--service" => service = true,
"--server" => server = true,
// Connection-manager popup mode. Treat `--cm-no-ui` (the
// Linux-headless variant librustdesk also tries) as a
// synonym; either way we run cm_popup.
"--cm" | "--cm-no-ui" => cm = true,
"--config" => {
let next = args.get(i + 1).cloned().ok_or_else(|| {
anyhow::anyhow!("--config requires a value")
})?;
config_blob = Some(next);
i += 1;
}
"--help" | "-h" => {
print_usage();
std::process::exit(0);
}
"--version" | "-V" => {
println!("hello-agent {}", env!("CARGO_PKG_VERSION"));
std::process::exit(0);
}
other => bail!("unknown argument: {}", other),
}
i += 1;
}
// Mutual-exclusion rules. --install + --config is the MDM one-liner;
// everything else is one-action-at-a-time.
let exclusive = [uninstall, service, server, cm].iter().filter(|x| **x).count();
if exclusive > 1 {
bail!("--uninstall, --service, --server, --cm are mutually exclusive");
}
if uninstall && (install || config_blob.is_some()) {
bail!("--uninstall cannot be combined with other flags");
}
let action = if uninstall {
Action::Uninstall
} else if install {
Action::Install
} else if service {
Action::Service
} else if server {
Action::Server
} else if cm {
Action::Cm
} else if config_blob.is_some() {
Action::ConfigOnly
} else {
Action::None
};
Ok(ParsedArgs {
action,
config_blob,
})
}
}
pub fn print_usage() {
eprintln!(
"hello-agent — headless RustDesk-protocol-compatible support agent
USAGE:
hello-agent [OPTIONS]
OPTIONS:
--install Register and start the Windows service.
--uninstall Stop, delete, and clean up the Windows service.
--config <BLOB> Import an admin-UI deploy blob. Accepts either the
reversed-base64 string emitted by the rustdesk-server
admin UI or the `host=...,key=...,api=...,relay=...`
filename form. May be combined with --install for
one-line MDM deployment.
--service SCM entry point. Do not invoke manually.
--server Worker mode (launched by the service shell into
the active console session).
-h, --help Show this help.
-V, --version Show version.
EXAMPLES:
hello-agent.exe --install --config 0nI900VsFHZ...
hello-agent.exe --uninstall
"
);
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(s: &[&str]) -> Result<ParsedArgs> {
ParsedArgs::from_argv(s.iter().map(|s| s.to_string()))
}
#[test]
fn no_args_is_none() {
assert_eq!(parse(&[]).unwrap().action, Action::None);
}
#[test]
fn install_with_config() {
let p = parse(&["--install", "--config", "BLOB"]).unwrap();
assert_eq!(p.action, Action::Install);
assert_eq!(p.config_blob.as_deref(), Some("BLOB"));
}
#[test]
fn config_only() {
let p = parse(&["--config", "BLOB"]).unwrap();
assert_eq!(p.action, Action::ConfigOnly);
}
#[test]
fn uninstall_alone() {
assert_eq!(parse(&["--uninstall"]).unwrap().action, Action::Uninstall);
}
#[test]
fn install_uninstall_conflict() {
assert!(parse(&["--install", "--uninstall"]).is_err());
}
#[test]
fn service_server_conflict() {
assert!(parse(&["--service", "--server"]).is_err());
}
#[test]
fn config_missing_value() {
assert!(parse(&["--config"]).is_err());
}
#[test]
fn unknown_arg() {
assert!(parse(&["--no-such-flag"]).is_err());
}
}
+399
View File
@@ -0,0 +1,399 @@
// Approval popup, run in a dedicated `--cm` child process.
//
// Architecture (matches stock rustdesk):
//
// --service (Session 0, SYSTEM)
// │ launches into active console session as SYSTEM token
// ▼
// --server (user session, SYSTEM token) --- screen capture, rendezvous, …
// │ on incoming peer requiring approval, librustdesk's start_ipc
// │ tries `ipc::connect("_cm")`, fails (no listener), then falls
// │ back to `run_as_user(["--cm"])`:
// ▼
// --cm (user session, USER token) --- this module
// │ binds `_cm`, accepts one connection from the parent's start_ipc,
// │ reads frames until it sees Data::Login{authorized:false, …},
// │ shows MessageBoxW (works cleanly because USER token + interactive
// │ desktop), replies Data::Authorize / Data::Close, drains the
// │ stream until the server closes it, exits.
//
// The previous design (run cm_popup as a thread inside the SYSTEM-token
// --server worker) hit Windows' UI-isolation rules — `MessageBoxW` from a
// SYSTEM-token process technically returns successfully but draws on a
// desktop the logged-in user can't see, so the popup was invisible.
// Spawning as a USER child sidesteps the whole class of issues.
use anyhow::Result;
use librustdesk::ipc;
#[cfg(target_os = "windows")]
use std::os::windows::ffi::OsStrExt;
const POSTFIX: &str = "_cm";
/// Diagnostic trace: writes to stderr AND a debug log file.
/// Bypasses `log` so we still see output even when env_logger / flexi_logger
/// init went wrong. Drop these calls once the popup mechanism is stable.
fn trace(msg: &str) {
let line = format!(
"[{:?}] cm_popup: {msg}\n",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0),
);
let _ = std::io::Write::write_all(&mut std::io::stderr(), line.as_bytes());
if let Ok(temp) = std::env::var("TEMP") {
let path = format!("{temp}\\hello-agent-cm.log");
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
{
let _ = std::io::Write::write_all(&mut f, line.as_bytes());
}
}
}
/// Run the popup loop forever on a freshly-created Tokio runtime.
/// Safe to call from a `std::thread::spawn` body.
pub fn run_blocking() {
trace("run_blocking entered");
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
trace(&format!("build runtime: {e}"));
return;
}
};
trace("runtime built; entering serve()");
if let Err(e) = rt.block_on(serve()) {
trace(&format!("serve exited: {e:#}"));
} else {
trace("serve returned cleanly");
}
}
/// Bind `_cm`, accept connections from `--server`'s `start_ipc` for as
/// long as the user session lasts. Each connection corresponds to one
/// peer requesting approval; we handle them concurrently.
async fn serve() -> Result<()> {
trace(&format!("calling new_listener({POSTFIX})"));
let mut incoming = match ipc::new_listener(POSTFIX).await {
Ok(i) => {
trace("new_listener succeeded");
i
}
Err(e) => {
trace(&format!("new_listener failed: {e}"));
return Err(anyhow::anyhow!("new_listener({POSTFIX}): {e}"));
}
};
trace("entering accept loop");
while let Some(result) = incoming.next().await {
match result {
Ok(stream) => {
trace("accepted incoming connection");
let conn = ipc::Connection::new(stream);
tokio::spawn(async move {
if let Err(e) = handle_one(conn).await {
trace(&format!("handle_one error: {e:#}"));
}
});
}
Err(e) => {
trace(&format!("accept error: {e}"));
}
}
}
trace("accept loop exited");
Ok(())
}
async fn handle_one(mut conn: ipc::Connection) -> Result<()> {
// Frame ordering on the `_cm` pipe is NOT "Login first, then chatter".
// For an installed/portable controlled side, the server first emits
// `Data::DataPortableService(CmShowElevation(...))` so the Flutter CM
// can render its elevation banner. The `Data::Login` we care about
// arrives a moment later. We loop through frames, ignore everything
// until we see Login{authorized:false}, decide once, and from then on
// just drain the stream so the server's `tx_to_cm.send()` calls don't
// back up.
//
// We use `conn.next()` (no timeout). A long active session can sit
// quiet for tens of minutes — `tx_to_cm` only fires on Login, FS
// transfers, and connection-close — so a short read timeout would
// false-positive into "session ended" UX during normal use.
trace("handle_one: entering frame loop");
let mut decided = false;
// Set when the user clicks Yes on the approval popup. Carries the
// peer's id / name for the matching "session ended" notification we
// fire after the server tears the connection down.
let mut approved_peer: Option<(String, String)> = None;
loop {
match conn.next().await {
Ok(Some(ipc::Data::Login {
peer_id,
name,
authorized: false,
..
})) if !decided => {
trace(&format!(
"handle_one: Login peer_id={peer_id} name={name} authorized=false"
));
decided = true;
let approved = ask_user_blocking(&peer_id, &name).await;
trace(&format!(
"handle_one: MessageBox returned approved={approved}"
));
if approved {
let _ = conn.send(&ipc::Data::Authorize).await;
trace("handle_one: sent Authorize");
approved_peer = Some((peer_id, name));
} else {
let _ = conn.send(&ipc::Data::Close).await;
trace("handle_one: sent Close — exiting handler");
return Ok(());
}
}
Ok(Some(ipc::Data::Close)) | Ok(Some(ipc::Data::Disconnected)) => {
// Server signals the supporter has left (or the
// connection failed). Fall through to the post-loop
// notification path.
trace("handle_one: server sent Close/Disconnected");
break;
}
Ok(Some(other)) => {
// Pre-login chatter (CmShowElevation), or post-Authorize
// chatter (chat, file transfer events, voice call). We
// don't act on any of it — the Flutter CM would, we just
// need to consume frames so the server's send buffer
// drains.
trace(&format!("handle_one: ignoring frame: {other:?}"));
continue;
}
Ok(None) => {
trace("handle_one: stream closed by peer");
break;
}
Err(e) => {
trace(&format!("handle_one: stream error: {e}"));
break;
}
}
}
// Tell the user the supporter is gone. Only fires when we approved
// the connection — denied/cancelled connections already returned
// above, and pre-approval Close from the server (e.g., auth failure
// before the popup even fired) shouldn't show a "session ended"
// banner the user has no context for.
if let Some((peer_id, name)) = approved_peer {
notify_session_ended(&peer_id, &name).await;
}
trace("handle_one: returning");
Ok(())
}
/// Show a native MessageBox in the calling (user) session. Runs the dialog
/// on tokio's blocking thread pool so we don't park the reactor while it
/// waits for the user to click.
async fn ask_user_blocking(peer_id: &str, name: &str) -> bool {
let peer_id = peer_id.to_string();
let name = name.to_string();
tokio::task::spawn_blocking(move || show_messagebox(&peer_id, &name))
.await
.unwrap_or(false)
}
/// Inform the user that the remote support session has ended. Best-effort:
/// errors out of the OS dialog APIs are logged (via `trace`) and otherwise
/// ignored — failing to show the post-session banner shouldn't block the
/// handler from cleaning up.
async fn notify_session_ended(peer_id: &str, name: &str) {
let peer_id = peer_id.to_string();
let name = name.to_string();
let _ = tokio::task::spawn_blocking(move || show_session_ended(&peer_id, &name)).await;
}
#[cfg(target_os = "windows")]
fn show_session_ended(peer_id: &str, name: &str) {
use std::ffi::OsStr;
use winapi::um::winuser::{MB_ICONINFORMATION, MB_OK};
let display_name = if name.is_empty() { "Unknown" } else { name };
let body = format!(
"{display_name} ({peer_id}) has ended the remote support session.\n\nThe supporter is no longer connected."
);
let caption = "HelloAgent — Remote session ended";
let body_w: Vec<u16> = OsStr::new(&body).encode_wide().chain(Some(0)).collect();
let caption_w: Vec<u16> = OsStr::new(caption).encode_wide().chain(Some(0)).collect();
let style = MB_OK | MB_ICONINFORMATION;
// Same dual-path rendering as the approval popup: SYSTEM-token
// callers route through `WTSSendMessageW` to land on the user's
// interactive desktop, user-token callers go straight to MessageBoxW.
let res = if librustdesk::platform::is_root() {
match wts_send_message(&caption_w, &body_w, style) {
Ok(r) => Some(r),
Err(e) => {
trace(&format!(
"show_session_ended: WTSSendMessageW failed ({e}); falling back to MessageBoxW"
));
messagebox_w(&caption_w, &body_w, style)
}
}
} else {
messagebox_w(&caption_w, &body_w, style)
};
trace(&format!("show_session_ended: dialog returned {res:?}"));
}
#[cfg(not(target_os = "windows"))]
fn show_session_ended(_peer_id: &str, _name: &str) {}
#[cfg(target_os = "windows")]
fn show_messagebox(peer_id: &str, name: &str) -> bool {
use std::ffi::OsStr;
use winapi::um::winuser::{IDYES, MB_DEFBUTTON2, MB_ICONQUESTION, MB_YESNO};
let display_name = if name.is_empty() { "Unknown" } else { name };
let body = format!(
"{display_name} ({peer_id}) is requesting remote control of this computer.\n\nAllow?"
);
let caption = "HelloAgent — Allow remote support?";
// Pick the right rendering path. When the worker runs under the SYSTEM
// token (the service-launched case), a direct MessageBoxW call usually
// *does* succeed but draws on a desktop the logged-in user can't see —
// the call returns IDNO/IDCANCEL with no user input. WTSSendMessageW is
// the supported way for a SYSTEM caller to ask the *interactive* user
// a question: Windows itself renders the dialog on the user's session's
// active input desktop and ferries the click result back.
//
// For standalone (user-context) runs we keep the simple MessageBoxW
// path — the calling thread already owns the right desktop.
let body_w: Vec<u16> = OsStr::new(&body).encode_wide().chain(Some(0)).collect();
let caption_w: Vec<u16> = OsStr::new(caption).encode_wide().chain(Some(0)).collect();
let style = MB_YESNO | MB_ICONQUESTION | MB_DEFBUTTON2;
let response: Option<i32> = if librustdesk::platform::is_root() {
match wts_send_message(&caption_w, &body_w, style) {
Ok(r) => Some(r),
Err(e) => {
trace(&format!(
"show_messagebox: WTSSendMessageW failed ({e}); falling back to MessageBoxW"
));
messagebox_w(&caption_w, &body_w, style)
}
}
} else {
messagebox_w(&caption_w, &body_w, style)
};
response.map(|r| r == IDYES).unwrap_or(false)
}
#[cfg(target_os = "windows")]
fn messagebox_w(caption_w: &[u16], body_w: &[u16], style: u32) -> Option<i32> {
use winapi::um::winuser::{MessageBoxW, MB_SETFOREGROUND, MB_SYSTEMMODAL, MB_TOPMOST};
let flags = style | MB_TOPMOST | MB_SETFOREGROUND | MB_SYSTEMMODAL;
trace("show_messagebox: calling MessageBoxW (user-context path)");
let result = unsafe {
MessageBoxW(
std::ptr::null_mut(),
body_w.as_ptr(),
caption_w.as_ptr(),
flags,
)
};
trace(&format!("show_messagebox: MessageBoxW returned {result}"));
Some(result)
}
/// `WTSSendMessageW` from `wtsapi32.dll`. Not exposed by `winapi 0.3`, so we
/// declare it manually. The link to `WtsApi32.lib` comes from the vendored
/// rustdesk `build.rs` (`cargo:rustc-link-lib=WtsApi32`), which is already
/// linked into our final binary because we depend on `librustdesk`.
#[cfg(target_os = "windows")]
fn wts_send_message(
caption_w: &[u16],
body_w: &[u16],
style: u32,
) -> std::result::Result<i32, String> {
use winapi::shared::ntdef::HANDLE;
use winapi::um::winbase::WTSGetActiveConsoleSessionId;
extern "system" {
fn WTSSendMessageW(
h_server: HANDLE,
session_id: u32,
p_title: *const u16,
title_length: u32,
p_message: *const u16,
message_length: u32,
style: u32,
timeout: u32,
p_response: *mut u32,
b_wait: i32,
) -> i32;
}
// WTS_CURRENT_SERVER_HANDLE is `(HANDLE)NULL` per the SDK header.
const WTS_CURRENT_SERVER_HANDLE: HANDLE = std::ptr::null_mut();
let session_id = unsafe { WTSGetActiveConsoleSessionId() };
if session_id == 0xFFFF_FFFF {
return Err("no active console session (lock screen?)".into());
}
trace(&format!(
"show_messagebox: calling WTSSendMessageW (session {session_id})"
));
// Lengths are in BYTES (despite the wide-char strings). Subtract the
// trailing null terminator we appended.
let title_bytes = ((caption_w.len().saturating_sub(1)) * 2) as u32;
let body_bytes = ((body_w.len().saturating_sub(1)) * 2) as u32;
let mut response: u32 = 0;
let ok = unsafe {
WTSSendMessageW(
WTS_CURRENT_SERVER_HANDLE,
session_id,
caption_w.as_ptr() as *const u16,
title_bytes,
body_w.as_ptr() as *const u16,
body_bytes,
style,
0, // timeout=0 → no timeout (block until user responds)
&mut response,
1, // bWait=TRUE → block until response
)
};
if ok == 0 {
let err = std::io::Error::last_os_error();
return Err(format!("WTSSendMessageW returned 0 (GetLastError: {err})"));
}
trace(&format!(
"show_messagebox: WTSSendMessageW returned response={response}"
));
Ok(response as i32)
}
#[cfg(not(target_os = "windows"))]
fn show_messagebox(_peer_id: &str, _name: &str) -> bool {
// Non-Windows is a stub. The whole module is only wired in when
// cfg(windows), so this branch should be unreachable in practice.
false
}
+93
View File
@@ -0,0 +1,93 @@
// Decode and persist an admin-UI deploy blob.
//
// The rustdesk-server admin UI emits a config string in two compatible forms,
// both handled by `librustdesk::custom_server::get_custom_server_from_string`:
//
// 1. A reversed URL-safe-base64-encoded JSON object containing
// {host, key, api, relay}. Example: `0nI900VsFHZ...`
//
// 2. A filename-style blob `host=server.example.net,key=...,api=...,relay=...`
// (used when the installer is renamed by the admin UI to deliver config).
//
// We treat the input as opaque, append `.exe` if missing (the upstream
// decoder strips it back off), and persist the four resulting fields via
// `hbb_common::config::Config::set_option`. Identical to what
// `core_main.rs` does on `--config` in stock rustdesk
// (see [src/core_main.rs:478](../rustdesk/src/core_main.rs#L478)) — we
// just don't gate it on `is_installed()` since we run before the service
// is registered (one-line MDM deploy: `--install --config <BLOB>`).
use anyhow::{anyhow, Result};
use hbb_common::config::Config;
use librustdesk::custom_server;
/// Built-in fallback rendezvous configuration. Applied by
/// `apply_defaults_if_empty` when no `--config <BLOB>` was provided and
/// no prior install left a value behind. The key here is the public
/// signing key of the cybnet rustdesk-server (`rd.gamecom.ch`) — distinct
/// from the per-agent identity keypair that the agent generates locally
/// on first run.
const DEFAULT_RENDEZVOUS_HOST: &str = "rd.gamecom.ch";
const DEFAULT_API_URL: &str = "https://rd.gamecom.ch";
const DEFAULT_RELAY_HOST: &str = "rd.gamecom.ch";
const DEFAULT_PUBLIC_KEY: &str = "tcxma69cN3OWt25jQ75apSCtaZGIfDqIIP6yGNj3dgs=";
pub fn apply(blob: &str) -> Result<()> {
let probe = if blob.to_lowercase().ends_with(".exe") {
blob.to_string()
} else {
format!("{blob}.exe")
};
let lic = custom_server::get_custom_server_from_string(&probe)
.map_err(|e| anyhow!("decode failed: {e}"))?;
if lic.host.is_empty() {
return Err(anyhow!(
"config blob decoded but contains no rendezvous host"
));
}
log::info!(
"applying config: host={} api={} relay={} key.len={}",
lic.host,
lic.api,
lic.relay,
lic.key.len(),
);
Config::set_option("key".into(), lic.key);
Config::set_option("custom-rendezvous-server".into(), lic.host);
Config::set_option("api-server".into(), lic.api);
Config::set_option("relay-server".into(), lic.relay);
Ok(())
}
/// Apply the built-in fallback rendezvous config if no `custom-rendezvous-server`
/// is currently set. Idempotent: a prior `--install --config <BLOB>` (or
/// any earlier explicit configuration) wins, and re-runs without `--config`
/// don't clobber it.
///
/// Why this exists: an MDM deployment that just runs `hello-agent.exe --install`
/// (no blob) needs *something* to register against. The defaults baked in
/// here are the cybnet `rd.gamecom.ch` rustdesk-server, so a no-arg install
/// produces a working agent out of the box. Operators who target a
/// different server still pass `--config <BLOB>` and the defaults are
/// skipped.
pub fn apply_defaults_if_empty() {
if !Config::get_option("custom-rendezvous-server").is_empty() {
log::info!("custom-rendezvous-server already set; built-in defaults skipped");
return;
}
log::info!(
"no rendezvous configured; applying built-in defaults: host={} api={} relay={}",
DEFAULT_RENDEZVOUS_HOST,
DEFAULT_API_URL,
DEFAULT_RELAY_HOST,
);
Config::set_option("key".into(), DEFAULT_PUBLIC_KEY.into());
Config::set_option("custom-rendezvous-server".into(), DEFAULT_RENDEZVOUS_HOST.into());
Config::set_option("api-server".into(), DEFAULT_API_URL.into());
Config::set_option("relay-server".into(), DEFAULT_RELAY_HOST.into());
}
+213
View File
@@ -0,0 +1,213 @@
// hello-agent: a headless RustDesk-protocol-compatible support agent.
//
// One binary, two run modes: a console / installer entry point that handles
// --install / --uninstall / --config, and a "--service" entry registered with
// the Windows SCM that spawns the actual worker into the active console
// session as "--server".
//
// The protocol stack (rendezvous, NAT punch, screen capture, input, login
// flow) is reused unchanged from `librustdesk`. This crate is just the
// thin shell that gives us a different CLI surface, our own service install
// path, and a native approval popup in place of the Flutter CM.
//
// We override `hbb_common`'s default `APP_NAME` ("RustDesk") with our own
// product name as the very first thing every process does. APP_NAME is read
// lazily from a `RwLock<String>` whenever any path is computed (config dir,
// log dir, named-pipe namespace, …), so setting it before any of those
// initializers fire is enough to redirect all hbb_common state under
// `%APPDATA%\HelloAgent\` and the matching LocalService path. Identical
// to the `read_custom_client` write path the upstream Flutter build uses
// for OEM rebrands.
#![cfg_attr(not(target_os = "windows"), allow(dead_code, unused_imports))]
mod cli;
mod config_import;
#[cfg(target_os = "windows")]
mod cm_popup;
#[cfg(target_os = "windows")]
mod service;
#[cfg(target_os = "windows")]
mod unattended_password;
use cli::{Action, ParsedArgs};
/// Product name used to namespace all on-disk state and the IPC pipe path.
/// Written into `hbb_common::config::APP_NAME` at the top of `main` so
/// every subsequent path computation (config dir, log dir, named pipe)
/// targets `%APPDATA%\HelloAgent\` rather than the upstream default of
/// `%APPDATA%\RustDesk\`. Must be set before any code touches a path —
/// `hbb_common` initializes path globals lazily on first read.
pub const APP_NAME: &str = "HelloAgent";
/// Set up logging. We delegate to `hbb_common::init_log`, which:
/// * In **debug** builds: installs `env_logger` writing to stderr.
/// * In **release** builds: installs `flexi_logger` writing to a rolling
/// file under `<config_dir>/log/<mode>/` — the SYSTEM service log ends
/// up at `%SystemRoot%\ServiceProfiles\LocalService\AppData\Roaming\HelloAgent\log\<mode>\`
/// and the user-mode log at `%APPDATA%\HelloAgent\log\<mode>\`.
///
/// The `mode` label segregates per-run-mode log files so service worker
/// chatter doesn't tangle with --install diagnostics. `init_log` is
/// `Once`-guarded internally so calling it twice is harmless.
fn init_logging(mode: &str) {
let _ = hbb_common::init_log(false, mode);
}
fn main() {
// MUST be the very first line. See the doc-comment on `APP_NAME` —
// anything that lazily reads a config / log / pipe path before this
// runs would cache `"RustDesk"` in `hbb_common`'s path globals and
// we'd never recover.
*hbb_common::config::APP_NAME.write().unwrap() = APP_NAME.to_owned();
// Identify ourselves to the rustdesk-server's /api/sysinfo endpoint
// so the admin Devices page can show "HelloAgent 0.1.0" instead of
// the embedded rustdesk core version. These RwLocks are read once
// per sysinfo upload by hbbs_http::sync; setting them here (before
// start_server) ensures the very first upload carries the identity.
*hbb_common::config::AGENT_NAME.write().unwrap() = APP_NAME.to_owned();
*hbb_common::config::AGENT_VERSION.write().unwrap() = env!("CARGO_PKG_VERSION").to_owned();
let parsed = match ParsedArgs::from_argv(std::env::args().skip(1)) {
Ok(p) => p,
Err(e) => {
eprintln!("hello-agent: {e}");
eprintln!();
cli::print_usage();
std::process::exit(2);
}
};
// Initialize logging *after* arg parsing so the per-mode log file path
// is deterministic. `init_log` is Once-guarded internally.
let mode = match parsed.action {
Action::Install => "install",
Action::Uninstall => "uninstall",
Action::Service => "service",
Action::Server => "server",
Action::Cm => "cm",
Action::ConfigOnly | Action::None => "hello-agent",
};
init_logging(mode);
// --config is allowed to combine with --install (one-line MDM deploy)
// but on its own is a separate operation. Apply it first so --install
// sees the populated config.
if let Some(blob) = parsed.config_blob.as_deref() {
if let Err(e) = config_import::apply(blob) {
eprintln!("hello-agent: --config failed: {e:#}");
std::process::exit(2);
}
}
// Bake in fallback rendezvous defaults. Idempotent — if --config above
// (or a prior install) already set custom-rendezvous-server, this is a
// no-op. Without this, a bare `hello-agent.exe --install` would land
// at an unconfigured agent that can't reach any server.
config_import::apply_defaults_if_empty();
match parsed.action {
Action::Install => {
#[cfg(target_os = "windows")]
{
if let Err(e) = service::install() {
eprintln!("hello-agent: install failed: {e:#}");
std::process::exit(1);
}
println!("hello-agent: installed and started.");
}
#[cfg(not(target_os = "windows"))]
{
eprintln!("hello-agent: --install is Windows-only for now.");
std::process::exit(1);
}
}
Action::Uninstall => {
#[cfg(target_os = "windows")]
{
if let Err(e) = service::uninstall() {
eprintln!("hello-agent: uninstall failed: {e:#}");
std::process::exit(1);
}
println!("hello-agent: uninstalled.");
}
#[cfg(not(target_os = "windows"))]
{
eprintln!("hello-agent: --uninstall is Windows-only for now.");
std::process::exit(1);
}
}
Action::Service => {
#[cfg(target_os = "windows")]
{
if let Err(e) = service::run_as_service() {
eprintln!("hello-agent: service dispatcher failed: {e:#}");
std::process::exit(1);
}
}
#[cfg(not(target_os = "windows"))]
{
eprintln!("hello-agent: --service is Windows-only.");
std::process::exit(1);
}
}
Action::Server => run_server(),
Action::Cm => {
// Spawned by the SYSTEM-token --server worker (via librustdesk's
// run_as_user) when the rustdesk core wants a CM. Runs as the
// logged-in user, binds the `_cm` IPC pipe, services one Login
// request with a MessageBoxW, replies, exits.
#[cfg(target_os = "windows")]
cm_popup::run_blocking();
}
Action::ConfigOnly => {
// --config without --install or --service: just persist and exit.
}
Action::None => {
// No flags: dev mode. Run as a foreground server so the operator
// can watch logs. Production deployments use --install + --service.
run_server();
}
}
}
fn run_server() {
// Clear any stale `approve-mode = click` left by older hello-agent
// versions. ApproveMode comes from `password_security::approve_mode`:
// "password" → password only, "click" → popup only, anything else →
// both (try password first, fall back to popup). We want both so
// that (a) attended sessions still go through the cm_popup approval,
// and (b) unattended sessions can authenticate with the per-boot
// password we report to the admin UI. Setting to "" is idempotent
// and overrides any leftover "click" value on disk.
hbb_common::config::Config::set_option("approve-mode".into(), "".into());
// Pre-spawn the --cm child *on the user's interactive desktop* before
// start_server boots. librustdesk's start_ipc has its own
// run_as_user(["--cm"]) fallback, but it goes through C-side
// LaunchProcessWin with show=FALSE → lpDesktop=NULL → child inherits
// the parent's desktop, which (because we were spawned by the Session-0
// service) is the invisible Session 0 service desktop. Our spawn
// helper sets lpDesktop = winsta0\\default explicitly, putting the
// popup on the user's screen. Once our --cm is bound to `_cm`,
// start_ipc's first ipc::connect("_cm") succeeds and rustdesk's
// built-in fallback never fires.
//
// We target *our own* session (whichever the supervisor placed us in
// — physical console, RDP, multi-user) rather than the physical
// console specifically. WTSGetActiveConsoleSessionId would point at
// the empty / lock-screen console session in RDP-only scenarios.
#[cfg(target_os = "windows")]
match service::spawn_cm_in_my_session() {
Ok(pid) => log::info!("spawned --cm child pid={pid} on winsta0\\default"),
Err(e) => log::warn!(
"could not pre-spawn --cm child ({e:#}); rustdesk's start_ipc fallback may be invisible"
),
}
// `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.
librustdesk::start_server(true, false);
}
+919
View File
@@ -0,0 +1,919 @@
// Windows service shell.
//
// Three responsibilities:
//
// 1. `install()` — copy the binary to %ProgramFiles%\hello-agent, mirror the
// calling user's `HelloAgent.toml` into the LocalService-effective
// config dir so the SYSTEM service inherits the --config blob, register
// the service with the SCM pointing at the installed exe, and start it.
// Idempotent.
//
// 2. `uninstall()` — stop the service, delete it, remove the install dir
// (best effort if uninstall is run from somewhere other than the install
// dir itself), and clear the LocalService config copy.
//
// 3. `run_as_service()` — the SCM dispatcher entry. Watches for active
// console session changes and (re)launches `hello-agent.exe --server`
// into that session via `librustdesk::platform::launch_privileged_process`,
// so the worker inherits the SYSTEM token in the user's session. (We
// intentionally do NOT use `run_as_user` here — that drops to the
// logged-in user's token, and the worker would then read config from
// the user's %APPDATA% instead of the LocalService path the install
// flow mirrors to.)
use anyhow::{anyhow, Context, Result};
use std::ffi::OsString;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use windows_service::service::{
ServiceAccess, ServiceControl, ServiceControlAccept, ServiceErrorControl, ServiceExitCode,
ServiceInfo, ServiceStartType, ServiceState, ServiceStatus, ServiceType,
};
use windows_service::service_control_handler::{self, ServiceControlHandlerResult};
use windows_service::service_dispatcher;
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
const SERVICE_NAME: &str = "HelloAgent";
const DISPLAY_NAME: &str = "HelloAgent Remote Support";
const SERVICE_DESCRIPTION: &str =
"HelloAgent — headless remote-support agent (RustDesk-protocol-compatible). \
Lets a remote supporter connect, subject to local user approval.";
const SERVICE_TYPE: ServiceType = ServiceType::OWN_PROCESS;
const INSTALL_SUBDIR: &str = "hello-agent";
const INSTALLED_EXE_NAME: &str = "hello-agent.exe";
// ----------------------------- paths ---------------------------------------
/// `%ProgramFiles%\hello-agent`. Falls back to `C:\Program Files\hello-agent`
/// if the env var isn't set (shouldn't happen on a real Windows install,
/// but we don't want to crash the installer if it does).
fn install_dir() -> PathBuf {
let base = std::env::var_os("ProgramFiles")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(r"C:\Program Files"));
base.join(INSTALL_SUBDIR)
}
/// hbb_common's `patch()` rewrites `system32\config\systemprofile` →
/// `ServiceProfiles\LocalService` on Windows so that LocalSystem and
/// LocalService share a config root. The SYSTEM service therefore reads
/// from this path; we mirror the calling user's config files here so the
/// --config blob makes it across.
///
/// Note the trailing `config` segment: `directories_next::ProjectDirs`,
/// which hbb_common uses on Windows, appends a literal `\config` to the
/// app's roaming dir (so the user-side path is
/// `%APPDATA%\HelloAgent\config\HelloAgent.toml`, not
/// `…\HelloAgent\…`). The SYSTEM-side path follows the same convention.
/// The `HelloAgent` segment is sourced from `crate::APP_NAME` so it stays
/// in lockstep with the `APP_NAME` we install into hbb_common at startup.
fn service_config_dir() -> PathBuf {
let system_root = std::env::var_os("SystemRoot")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(r"C:\Windows"));
system_root
.join("ServiceProfiles")
.join("LocalService")
.join("AppData")
.join("Roaming")
.join(crate::APP_NAME)
.join("config")
}
// ----------------------------- install --------------------------------------
pub fn install() -> Result<()> {
let scm = ServiceManager::local_computer(
None::<&str>,
ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE,
)
.context("open SCM")?;
// 1. If a previous install left a running service, stop it before we
// overwrite its binary. Otherwise the file copy in step 2 fails
// with "access denied" because the SCM holds an exclusive handle on
// the running exe.
stop_existing_service(&scm);
// 1b. Kill any lingering hello-agent.exe (notably the `--cm` user-token
// child, which lives outside the service's process tree and is
// therefore not stopped by SCM Stop). This makes `--install`
// idempotent / usable as an in-place update — without it, the
// `stage_binary` file copy below fails with "access denied"
// whenever a `--cm` child is still holding the old exe open.
// `kill_orphan_processes` uses taskkill with `/FI "PID ne <ours>"`
// so it never kills the running installer.
kill_orphan_processes();
// 2. Pin the binary to %ProgramFiles%\hello-agent. The user might be
// running --install from C:\Users\…\Downloads\, a USB stick, etc.;
// we don't want the SCM pointing back at any of those.
let target_exe = stage_binary().context("stage_binary")?;
// 3. Clear stop-service and reset approve-mode to "both" (empty
// string → librustdesk treats as ApproveMode::Both: try password
// first, fall back to popup). Older hello-agent installs wrote
// "click" here, which disabled the password path; clearing it
// every install makes upgrades idempotent. These write into the
// *calling user's* %APPDATA%\HelloAgent\ — we mirror the result
// into the service's effective dir in step 4.
hbb_common::config::Config::set_option("stop-service".into(), "".into());
hbb_common::config::Config::set_option("approve-mode".into(), "".into());
// 4. Mirror the calling user's `HelloAgent.toml` / `HelloAgent2.toml`
// into the LocalService-effective config root that the SYSTEM
// service will actually read. Without this, --config writes to e.g.
// C:\Users\Admin\AppData\Roaming\HelloAgent\, but the service runs
// as LocalSystem and (via hbb_common's `patch()`) reads from
// C:\Windows\ServiceProfiles\LocalService\AppData\Roaming\HelloAgent\.
if let Err(e) = mirror_config_to_service_dir() {
log::warn!(
"could not mirror config to service dir ({e:#}); the service may not see --config until first heartbeat"
);
}
// 5. Register / reconfigure the SCM entry. Idempotent: if the service
// already exists we reuse the handle and change_config it to the
// new exe path + args.
let info = ServiceInfo {
name: OsString::from(SERVICE_NAME),
display_name: OsString::from(DISPLAY_NAME),
service_type: SERVICE_TYPE,
start_type: ServiceStartType::AutoStart,
error_control: ServiceErrorControl::Normal,
executable_path: target_exe.clone(),
launch_arguments: vec![OsString::from("--service")],
dependencies: vec![],
account_name: None, // LocalSystem
account_password: None,
};
let svc = match scm.create_service(
&info,
ServiceAccess::CHANGE_CONFIG
| ServiceAccess::START
| ServiceAccess::STOP
| ServiceAccess::QUERY_STATUS,
) {
Ok(s) => s,
Err(windows_service::Error::Winapi(e))
if e.raw_os_error() == Some(winapi::shared::winerror::ERROR_SERVICE_EXISTS as i32) =>
{
log::info!("service exists; reusing");
let svc = scm
.open_service(
SERVICE_NAME,
ServiceAccess::CHANGE_CONFIG
| ServiceAccess::START
| ServiceAccess::STOP
| ServiceAccess::QUERY_STATUS,
)
.context("open existing service")?;
svc.change_config(&info).context("change_config")?;
svc
}
Err(e) => return Err(anyhow!("create_service: {e}")),
};
let _ = svc.set_description(SERVICE_DESCRIPTION);
// 6. Start the service. (Step 1 already stopped any prior instance.)
svc.start::<&str>(&[]).context("start service")?;
log::info!(
"service '{}' installed at {} and started",
SERVICE_NAME,
target_exe.display()
);
Ok(())
}
/// Best-effort stop + wait of an existing HelloAgent service. No-op if the
/// service doesn't exist or is already stopped. We use a short connection
/// here (STOP|QUERY_STATUS only) so the install path can call this without
/// holding the broader CHANGE_CONFIG handle from later steps.
fn stop_existing_service(scm: &ServiceManager) {
let svc = match scm.open_service(
SERVICE_NAME,
ServiceAccess::STOP | ServiceAccess::QUERY_STATUS,
) {
Ok(s) => s,
Err(_) => return, // doesn't exist; nothing to stop
};
if let Ok(status) = svc.query_status() {
if status.current_state == ServiceState::Stopped {
return;
}
}
let _ = svc.stop();
wait_for_state(&svc, ServiceState::Stopped, Duration::from_secs(20));
}
/// Copy the running exe to %ProgramFiles%\hello-agent\hello-agent.exe and
/// return the destination path. If the running exe is already the installed
/// path (e.g., the user ran `hello-agent.exe --install` from the install
/// directory after a manual update), we skip the copy.
fn stage_binary() -> Result<PathBuf> {
let src = std::env::current_exe().context("current_exe")?;
let src = src.canonicalize().unwrap_or(src);
let dest_dir = install_dir();
let dest = dest_dir.join(INSTALLED_EXE_NAME);
let dest_canon = dest.canonicalize().ok();
if dest_canon.as_ref() == Some(&src) {
log::info!("running exe is already installed at {}", dest.display());
return Ok(dest);
}
std::fs::create_dir_all(&dest_dir)
.with_context(|| format!("create_dir_all {}", dest_dir.display()))?;
// If something is already there (an old install), Windows allows
// overwriting if no process holds the file open. The service was either
// never installed or we'll restart it after this; either way, the
// running --install process is the only handle we worry about, and that
// handle is on `src`, not `dest`.
std::fs::copy(&src, &dest).with_context(|| {
format!("copy {} -> {}", src.display(), dest.display())
})?;
log::info!(
"installed binary: {} -> {}",
src.display(),
dest.display()
);
Ok(dest)
}
/// Copy the calling user's `HelloAgent.toml` + `HelloAgent2.toml` into
/// the LocalService-effective config dir so the SYSTEM service sees them.
fn mirror_config_to_service_dir() -> Result<()> {
let dest_dir = service_config_dir();
std::fs::create_dir_all(&dest_dir)
.with_context(|| format!("create_dir_all {}", dest_dir.display()))?;
let user_main = hbb_common::config::Config::file();
let user_aux = hbb_common::config::Config2::file();
let mut copied = 0usize;
for src in [user_main, user_aux] {
let Some(name) = src.file_name() else { continue };
let dest = dest_dir.join(name);
match std::fs::copy(&src, &dest) {
Ok(_) => {
copied += 1;
log::info!("mirrored {} -> {}", src.display(), dest.display());
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
// Calling user never had this file (e.g. --install without
// --config, or first ever run on this machine, or the user
// wiped %APPDATA%\HelloAgent\ between tests). Logged at
// info so the post-install log shows clearly which toml
// files were available and which weren't.
log::info!(
"no source file at {} (skipped — service worker will generate it)",
src.display()
);
}
Err(e) => {
log::warn!("mirror {} -> {}: {e}", src.display(), dest.display());
}
}
}
if copied == 0 {
log::info!(
"no user-side config files to mirror to {}",
dest_dir.display()
);
}
Ok(())
}
// ----------------------------- uninstall ------------------------------------
pub fn uninstall() -> Result<()> {
// Kill every hello-agent.exe process except ourselves *first*. We can't
// rely on the SCM Stop control alone because the `--cm` child spawned
// via `run_as_user` runs under the logged-in user's token, not SYSTEM,
// so it isn't in the service's process tree and SCM won't reach it.
// Doing this up front means the SCM stop below is usually a no-op
// (service process already gone) and the rmdir at the end no longer
// races a lingering child holding hello-agent.exe open. Our own PID
// is excluded via taskkill's `/FI` so the uninstaller doesn't suicide.
kill_orphan_processes();
let scm = ServiceManager::local_computer(
None::<&str>,
ServiceManagerAccess::CONNECT,
)
.context("open SCM")?;
match scm.open_service(
SERVICE_NAME,
ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
) {
Ok(svc) => {
// Stop, wait, delete. Each step is best-effort; we want
// --uninstall to leave nothing behind even if the service is
// already in a weird state. After the kill above the service
// process is typically already gone, so SCM transitions to
// Stopped within a poll cycle; the 20s wait is a safety net
// for the rare case taskkill couldn't reach the supervisor.
if let Ok(status) = svc.query_status() {
if status.current_state != ServiceState::Stopped {
let _ = svc.stop();
wait_for_state(&svc, ServiceState::Stopped, Duration::from_secs(20));
}
}
svc.delete().context("delete service")?;
log::info!("service '{}' deleted", SERVICE_NAME);
}
Err(windows_service::Error::Winapi(e))
if e.raw_os_error()
== Some(winapi::shared::winerror::ERROR_SERVICE_DOES_NOT_EXIST as i32) =>
{
log::info!("service '{}' not present (no-op)", SERVICE_NAME);
}
Err(e) => return Err(anyhow!("open_service: {e}")),
}
cleanup_install_dir();
// We deliberately do NOT delete the LocalService config dir here.
// `HelloAgent.toml` in that directory holds the agent's id + keypair,
// which the rustdesk-server / rendezvous server has registered against
// the agent's id. Wiping it forces the next --install to generate
// fresh keys, which the rendezvous server's cached entry (and any
// supporter that resolved the agent recently) will mismatch with — the
// encrypted handshake then silently fails on the supporter side and
// the connection sits idle until the peer times out.
//
// Operators who want a true hard wipe can run:
// rmdir /s /q "%SystemRoot%\ServiceProfiles\LocalService\AppData\Roaming\HelloAgent"
// and then delete the device record from the rustdesk-server admin UI.
log::info!("preserved LocalService config dir to keep agent keys/id stable across reinstalls");
Ok(())
}
/// Best-effort sweep of every hello-agent.exe process other than ourselves.
/// Used by both `--install` (so an in-place update isn't blocked by an
/// old `--cm` child holding the exe open) and `--uninstall` (so the
/// rmdir at the end isn't racing a lingering child).
///
/// Shells out to the built-in `taskkill` rather than re-implementing the
/// Toolhelp32 enumeration in winapi: taskkill ships in every Windows
/// install since XP, runs in milliseconds, and the `/FI "PID ne <ours>"`
/// filter handles the "don't suicide ourselves" requirement declaratively.
///
/// Exit code 128 from taskkill means "no matching processes" — common
/// case when there's no orphan to clean up — and we treat it the same
/// as success. Anything else gets logged but does not fail the caller.
fn kill_orphan_processes() {
let our_pid = std::process::id();
let pid_filter = format!("PID ne {our_pid}");
let output = std::process::Command::new("taskkill")
.args([
"/F",
"/IM",
INSTALLED_EXE_NAME,
"/FI",
&pid_filter,
])
.output();
match output {
Ok(out) => {
let code = out.status.code();
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
if out.status.success() {
log::info!(
"taskkill killed orphan {INSTALLED_EXE_NAME} processes (excluding pid {our_pid}): {}",
stdout.trim()
);
// TerminateProcess is synchronous w.r.t. the kernel marking
// the process as exited, but kernel-mode finalization
// (releasing file handles, paging out the image section)
// can lag by up to a few hundred ms. The rmdir that follows
// races against this: without the pause, an immediate
// remove_dir_all can still see "file in use" on the just-
// killed process's exe.
std::thread::sleep(Duration::from_millis(500));
} else if code == Some(128) {
log::info!("no orphan {INSTALLED_EXE_NAME} processes to kill");
} else {
log::warn!(
"taskkill returned {code:?}: stdout={} stderr={}",
stdout.trim(),
stderr.trim(),
);
}
}
Err(e) => {
log::warn!("could not invoke taskkill: {e}");
}
}
}
/// Remove %ProgramFiles%\hello-agent. Best-effort: if the user ran
/// --uninstall from inside the install dir, the running exe is locked
/// open by the OS and the rmdir will fail. We log and move on; the
/// remaining files are harmless and can be deleted manually after exit.
fn cleanup_install_dir() {
let dir = install_dir();
if !dir.exists() {
return;
}
match std::fs::remove_dir_all(&dir) {
Ok(()) => log::info!("removed install dir {}", dir.display()),
Err(e) => log::warn!(
"could not remove {} ({}); delete manually if needed",
dir.display(),
e
),
}
}
fn wait_for_state(
svc: &windows_service::service::Service,
target: ServiceState,
timeout: Duration,
) -> bool {
let start = Instant::now();
while start.elapsed() < timeout {
match svc.query_status() {
Ok(s) if s.current_state == target => return true,
_ => std::thread::sleep(Duration::from_millis(250)),
}
}
false
}
// ----------------------------- service runtime ------------------------------
windows_service::define_windows_service!(ffi_service_main, service_main);
pub fn run_as_service() -> Result<()> {
service_dispatcher::start(SERVICE_NAME, ffi_service_main)
.map_err(|e| anyhow!("service_dispatcher::start: {e}"))
}
fn service_main(_args: Vec<OsString>) {
if let Err(e) = service_main_inner() {
log::error!("service_main: {e:#}");
}
}
fn service_main_inner() -> Result<()> {
let stop_flag = Arc::new(AtomicBool::new(false));
let stop_flag_handler = stop_flag.clone();
// We poll WTSGetActiveConsoleSessionId every iteration of the main loop,
// so we don't need session-change events from the SCM. Keeping the
// handler set narrow (Stop/Shutdown/Interrogate) means SCM won't deliver
// events we'd just throw away.
let event_handler = move |control_event| -> ServiceControlHandlerResult {
match control_event {
ServiceControl::Stop | ServiceControl::Shutdown => {
stop_flag_handler.store(true, Ordering::SeqCst);
ServiceControlHandlerResult::NoError
}
ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
_ => ServiceControlHandlerResult::NotImplemented,
}
};
let status_handle = service_control_handler::register(SERVICE_NAME, event_handler)
.map_err(|e| anyhow!("register handler: {e}"))?;
set_status(
&status_handle,
ServiceState::Running,
ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN,
)?;
log::info!("hello-agent service started");
// Generate a fresh per-boot unattended-access password and report it
// to the rustdesk-server admin API. Runs in a background thread with
// its own Tokio runtime so it doesn't block the supervisor poll loop;
// retries internally until the server acknowledges (early attempts
// can race the rendezvous registration done by `--server`).
crate::unattended_password::rotate_and_report();
// Worker process handle. Killed on Stop, replaced on session change.
// `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
// *already* running in (login-screen console → same session, but now
// with a user) — the new `--server` needs to pre-spawn its `--cm`
// child against the freshly-available user token, which the prior
// `--server` couldn't do.
let mut worker: Option<Worker> = None;
let mut last_state: Option<(u32, bool)> = None;
while !stop_flag.load(Ordering::SeqCst) {
// Pick a target session in this priority order:
//
// 1. Active *user* session (RDP-connected user, or physical
// console with a logged-in user) — the normal case, full
// screen capture / input / popup.
// 2. Physical console session at the login or lock screen
// (no user, but `winlogon.exe` is running so
// `launch_privileged_process` works and DXGI desktop
// duplication can capture the login screen). This is what
// enables unattended access via the per-boot password — the
// supporter sees the actual login screen, not a black
// "No displays" panel.
// 3. Session 0 (where this supervisor itself lives as
// LocalSystem). Last-ditch fallback, no display, no input —
// rendezvous + heartbeat keep flowing but capture is
// empty. We avoid it whenever (2) is reachable.
let active = find_active_user_session();
let target = active
.or_else(active_console_session_for_capture)
.unwrap_or(0);
let target_has_user = active.is_some();
let target_state = (target, target_has_user);
let worker_dead = worker.as_ref().map(|w| !w.is_alive()).unwrap_or(false);
let needs_respawn = match (worker.is_some(), last_state) {
(false, _) => true,
(_, Some(prev)) if prev != target_state => true,
_ if worker_dead => true,
_ => false,
};
if needs_respawn {
if let Some(prev) = worker.take() {
prev.kill_and_wait(Duration::from_secs(5));
}
let spawn_result = if target == 0 {
Worker::spawn_in_service_session()
} else {
Worker::spawn(target)
};
match spawn_result {
Ok(w) => {
if target == 0 {
log::info!(
"no console or user session reachable; spawned --server \
in Session 0 (registration only — screen capture \
unavailable until a session is available)"
);
} else if active.is_some() {
log::info!(
"spawned --server worker into user session {target}"
);
} else {
log::info!(
"no user logged in; spawned --server into console \
session {target} (login screen capture)"
);
}
worker = Some(w);
last_state = Some(target_state);
}
Err(e) => {
log::warn!("spawn worker failed: {e:#}");
std::thread::sleep(Duration::from_secs(5));
}
}
}
std::thread::sleep(Duration::from_millis(750));
}
// Shutdown.
if let Some(prev) = worker.take() {
prev.kill_and_wait(Duration::from_secs(5));
}
set_status(
&status_handle,
ServiceState::Stopped,
ServiceControlAccept::empty(),
)?;
log::info!("hello-agent service stopped");
Ok(())
}
fn set_status(
handle: &service_control_handler::ServiceStatusHandle,
state: ServiceState,
accept: ServiceControlAccept,
) -> Result<()> {
handle
.set_service_status(ServiceStatus {
service_type: SERVICE_TYPE,
current_state: state,
controls_accepted: accept,
exit_code: ServiceExitCode::Win32(0),
checkpoint: 0,
wait_hint: Duration::from_secs(5),
process_id: None,
})
.map_err(|e| anyhow!("set_service_status: {e}"))
}
/// Worker process handle. We use `librustdesk::platform::launch_privileged_process`
/// (the same path stock rustdesk's `--service` uses) which calls
/// `LaunchProcessWin(..., as_user=FALSE, ...)` — the new process runs as
/// SYSTEM in the active console session. SYSTEM-in-user-session can both
/// (a) read config from the LocalService-effective path our install flow
/// mirrors to, and (b) draw UI / capture screen / send input on the user's
/// desktop (it's the standard service-side-of-remote-control pattern).
///
/// We get back a Win32 HANDLE rather than a `std::process::Child`; this
/// thin wrapper exposes the few operations the supervisor loop needs and
/// closes the handle on drop.
struct Worker {
handle: winapi::shared::ntdef::HANDLE,
}
// HANDLE is `*mut c_void`, which isn't Send by default; the inner pointer
// is opaque to the OS and safe to move between threads.
unsafe impl Send for Worker {}
impl Worker {
fn spawn(session_id: u32) -> Result<Self> {
let exe = std::env::current_exe().context("current_exe")?;
let exe_str = exe
.to_str()
.ok_or_else(|| anyhow!("non-UTF-8 exe path: {}", exe.display()))?;
let cmd = format!("\"{exe_str}\" --server");
let handle = librustdesk::platform::launch_privileged_process(session_id, &cmd)
.map_err(|e| anyhow!("launch_privileged_process: {e}"))?;
if handle.is_null() {
return Err(anyhow!(
"launch_privileged_process returned NULL handle (session {session_id} not ready?)"
));
}
Ok(Self { handle })
}
/// Spawn `--server` in our own session (Session 0, LocalSystem). Used
/// when no user is logged in: we can't `launch_privileged_process` for
/// session 0 because that helper resolves the target token via
/// `winlogon.exe`/`explorer.exe`, neither of which run in Session 0.
/// The supervisor itself is LocalSystem-in-Session-0, so a plain
/// `Command::spawn` puts the child in the same place with the same
/// token — exactly what we want for the no-user-logged-in fallback.
fn spawn_in_service_session() -> Result<Self> {
use std::os::windows::io::IntoRawHandle;
let exe = std::env::current_exe().context("current_exe")?;
let child = std::process::Command::new(&exe)
.arg("--server")
.spawn()
.with_context(|| format!("spawn {} --server", exe.display()))?;
// Take ownership of the child's process HANDLE; this suppresses
// `Child::Drop`'s close so kill_and_wait / Drop on Worker manage
// the lifetime cleanly via TerminateProcess + CloseHandle.
let handle = child.into_raw_handle() as winapi::shared::ntdef::HANDLE;
Ok(Self { handle })
}
fn is_alive(&self) -> bool {
// WAIT_TIMEOUT (0x102) means the wait expired without the handle
// being signaled — i.e., the process is still running. Anything
// else (WAIT_OBJECT_0 = exited, WAIT_FAILED = error) we treat as
// dead so the supervisor will respawn.
const WAIT_TIMEOUT: u32 = 0x0000_0102;
let r = unsafe { winapi::um::synchapi::WaitForSingleObject(self.handle, 0) };
r == WAIT_TIMEOUT
}
fn kill_and_wait(self, timeout: Duration) {
unsafe {
winapi::um::processthreadsapi::TerminateProcess(self.handle, 1);
let ms = timeout.as_millis().min(u32::MAX as u128) as u32;
let _ = winapi::um::synchapi::WaitForSingleObject(self.handle, ms);
}
// Drop closes the handle.
}
}
impl Drop for Worker {
fn drop(&mut self) {
unsafe {
winapi::um::handleapi::CloseHandle(self.handle);
}
}
}
/// Pick the session that hosts the user's *active* interactive desktop —
/// physical console *or* RDP. Returns `None` if no user is actively logged
/// in anywhere.
///
/// We can't use `WTSGetActiveConsoleSessionId()` here: it only returns the
/// session attached to the **physical** console. When the user is connected
/// via RDP only, the console session is empty (or at the lock screen), and
/// this primitive gives us the wrong target. The popup ends up rendered on
/// the invisible console desktop while the RDP user sees nothing.
///
/// Instead enumerate sessions and pick one in `WTSActive` state with a
/// resolvable user token. `WTSActive` means "the user is at the keyboard
/// of this session right now" — which is true for the RDP session when
/// they're on RDP, and for the console session when they're at the
/// physical machine. A user who logged in to RDP and then disconnected
/// without logging out shows up as `WTSDisconnected` and we correctly
/// skip them.
pub(crate) fn find_active_user_session() -> Option<u32> {
use winapi::shared::ntdef::HANDLE;
use winapi::um::handleapi::CloseHandle;
use winapi::um::wtsapi32::WTSQueryUserToken;
#[repr(C)]
struct WtsSessionInfoW {
session_id: u32,
win_station_name: *mut u16,
state: i32, // WTS_CONNECTSTATE_CLASS
}
const WTS_ACTIVE: i32 = 0;
extern "system" {
fn WTSEnumerateSessionsW(
h_server: 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);
}
let mut sessions: *mut WtsSessionInfoW = std::ptr::null_mut();
let mut count: u32 = 0;
let ok = unsafe {
WTSEnumerateSessionsW(
std::ptr::null_mut(), // WTS_CURRENT_SERVER_HANDLE
0,
1, // version
&mut sessions,
&mut count,
)
};
if ok == 0 || sessions.is_null() {
return None;
}
let mut chosen: Option<u32> = None;
for i in 0..count {
let info = unsafe { &*sessions.add(i as usize) };
if info.state != WTS_ACTIVE {
continue;
}
// Skip the login-screen session (no logged-in user → no token).
let mut token: HANDLE = std::ptr::null_mut();
let token_ok = unsafe { WTSQueryUserToken(info.session_id, &mut token) };
if token_ok != 0 && !token.is_null() {
unsafe { CloseHandle(token) };
chosen = Some(info.session_id);
break;
}
}
unsafe { WTSFreeMemory(sessions as *mut _) };
chosen
}
/// Physical-console session ID — used as the fallback target when no user
/// is logged in. At the login or lock screen `winlogon.exe` is running in
/// this session, which is enough for `launch_privileged_process` to find
/// a SYSTEM token there and spawn `--server` into a session that has an
/// actual display (Session 0 doesn't). Returns None when Windows reports
/// no console attached (boot, fast-user-switching mid-detach).
pub(crate) fn active_console_session_for_capture() -> Option<u32> {
use winapi::um::winbase::WTSGetActiveConsoleSessionId;
let id = unsafe { WTSGetActiveConsoleSessionId() };
// 0xFFFF_FFFF: no console attached. 0: same as our own session, no
// gain over the Session 0 fallback that comes after.
if id == 0xFFFF_FFFF || id == 0 {
None
} else {
Some(id)
}
}
/// Returns the session ID of the calling process. Used by `--server` to
/// know which session it itself was launched into, so the `--cm` child
/// lands in the *same* session (and therefore on the same interactive
/// desktop the user is actually using).
fn current_process_session() -> Option<u32> {
use winapi::um::processthreadsapi::{GetCurrentProcessId, ProcessIdToSessionId};
let mut sid: u32 = 0;
let ok = unsafe { ProcessIdToSessionId(GetCurrentProcessId(), &mut sid) };
if ok == 0 {
None
} else {
Some(sid)
}
}
/// Spawn `hello-agent.exe --cm` into the active console session as the
/// logged-in user, **on the user's interactive desktop**.
///
/// Why we don't just call `librustdesk::platform::run_as_user(["--cm"])`:
/// the C-side `LaunchProcessWin` only sets `STARTUPINFO.lpDesktop =
/// L"winsta0\\default"` when its `show` parameter is `TRUE`. `run_as_user`
/// hardcodes `show=false`, leaving `lpDesktop = NULL`. With NULL, the new
/// process inherits the *parent's* desktop. Our parent chain (`--service`
/// in Session 0 → `--server` in user session as SYSTEM token) is rooted
/// in Session 0's `Service-0x...\Default` desktop, so any UI rendered by
/// the resulting `--cm` child draws there — invisible to the logged-in
/// user. This helper sets `lpDesktop` explicitly so the popup actually
/// reaches the user's screen.
/// Convenience wrapper used by `run_server`: spawn `--cm` into the same
/// session the calling process itself is running in. Falls back to
/// `find_active_user_session` if `ProcessIdToSessionId` fails for some
/// reason.
pub(crate) fn spawn_cm_in_my_session() -> Result<u32> {
let session_id = current_process_session()
.or_else(find_active_user_session)
.ok_or_else(|| anyhow!("no active user session to spawn --cm into"))?;
spawn_cm_into_user_desktop(session_id)
}
pub(crate) fn spawn_cm_into_user_desktop(session_id: u32) -> Result<u32> {
use std::os::windows::ffi::OsStrExt;
use winapi::shared::ntdef::HANDLE;
use winapi::um::handleapi::CloseHandle;
use winapi::um::processthreadsapi::{CreateProcessAsUserW, PROCESS_INFORMATION, STARTUPINFOW};
use winapi::um::winbase::DETACHED_PROCESS;
use winapi::um::wtsapi32::WTSQueryUserToken;
// 1. Grab the user's primary access token for this session. Requires
// SE_TCB_NAME; SYSTEM has it by default.
let mut user_token: HANDLE = std::ptr::null_mut();
let ok = unsafe { WTSQueryUserToken(session_id, &mut user_token) };
if ok == 0 {
let err = std::io::Error::last_os_error();
return Err(anyhow!(
"WTSQueryUserToken(session={}): {} (no user logged in?)",
session_id,
err
));
}
// 2. Build the command line. CreateProcessAsUserW may patch the
// lpCommandLine buffer in place, so it has to be a mutable Vec.
let exe = std::env::current_exe().context("current_exe")?;
let cmd_str = format!("\"{}\" --cm", exe.display());
let mut cmd_w: Vec<u16> = std::ffi::OsStr::new(&cmd_str)
.encode_wide()
.chain(Some(0))
.collect();
// 3. The desktop string is referenced by si.lpDesktop and must stay
// alive until CreateProcessAsUserW returns.
let mut desktop_w: Vec<u16> = std::ffi::OsStr::new("winsta0\\default")
.encode_wide()
.chain(Some(0))
.collect();
let mut si: STARTUPINFOW = unsafe { std::mem::zeroed() };
si.cb = std::mem::size_of::<STARTUPINFOW>() as u32;
si.lpDesktop = desktop_w.as_mut_ptr();
let mut pi: PROCESS_INFORMATION = unsafe { std::mem::zeroed() };
// 4. Spawn. DETACHED_PROCESS so the child has no console attached and
// isn't tied to ours. We do not pass an environment block — NULL
// means "inherit ours", which is fine for cm_popup.
let cp_ok = unsafe {
CreateProcessAsUserW(
user_token,
std::ptr::null(),
cmd_w.as_mut_ptr(),
std::ptr::null_mut(),
std::ptr::null_mut(),
0,
DETACHED_PROCESS,
std::ptr::null_mut(),
std::ptr::null(),
&mut si,
&mut pi,
)
};
let cp_err = std::io::Error::last_os_error();
unsafe { CloseHandle(user_token) };
if cp_ok == 0 {
return Err(anyhow!("CreateProcessAsUserW: {}", cp_err));
}
let pid = pi.dwProcessId;
// We don't track the child's lifetime here. It will outlive the
// calling --server until either the user session ends (Windows reaps
// it) or it exits voluntarily on cm_popup error.
unsafe {
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
}
Ok(pid)
}
+123
View File
@@ -0,0 +1,123 @@
// Per-boot unattended-access password.
//
// On every service start (= every host reboot, since `--service` is the
// Windows service entry the SCM auto-starts on boot) hello-agent generates
// a random "permanent password" and reports it to the rustdesk-server
// admin API. A supporter reaching the device when no user is logged in
// can read the password from the admin UI and authenticate without the
// per-session approval popup ever firing.
//
// The password is:
// 1. Persisted locally via `Config::set_permanent_password` so the
// rustdesk auth path accepts it on the next LoginRequest.
// 2. POSTed to `<api-server>/api/unattended-password` with a retry
// loop. The first few attempts can legitimately fail with
// ID_NOT_FOUND because rendezvous registration runs in the
// `--server` child (which the supervisor hasn't even spawned yet
// when this fires), not in this `--service` process — we just back
// off and retry until the peer row exists server-side.
use anyhow::{anyhow, Result};
use hbb_common::rand::{distributions::Alphanumeric, Rng};
use std::time::Duration;
const PASSWORD_LEN: usize = 12;
const MAX_RETRY_DELAY: Duration = Duration::from_secs(60);
const MAX_ATTEMPTS: u32 = 20;
/// Generate a fresh password, write it to local config, and kick off a
/// background reporter thread. Returns immediately; the reporter has its
/// own Tokio runtime so it doesn't tangle with the supervisor's poll loop.
pub fn rotate_and_report() {
let password = generate();
hbb_common::config::Config::set_permanent_password(&password);
log::info!(
"rotated unattended-access password (len={})",
password.len()
);
std::thread::spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
log::warn!("unattended-password reporter: build runtime: {e}");
return;
}
};
rt.block_on(report_with_retry(password));
});
}
fn generate() -> String {
hbb_common::rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(PASSWORD_LEN)
.map(char::from)
.collect()
}
async fn report_with_retry(password: String) {
// Start at 2s and double up to MAX_RETRY_DELAY. The early ID_NOT_FOUND
// window typically clears within a minute (heartbeat sync registers
// the peer on its first iteration), so most boots land on the second
// or third attempt. After MAX_ATTEMPTS we give up — the password is
// already set locally, the only thing missing is its visibility in
// the admin UI, so silent forever-retry would just be log spam.
let mut delay = Duration::from_secs(2);
for attempt in 1..=MAX_ATTEMPTS {
match try_report(&password).await {
Ok(_) => {
log::info!(
"unattended-password: server acknowledged on attempt {attempt}"
);
return;
}
Err(e) => {
log::warn!(
"unattended-password: report attempt {attempt}/{MAX_ATTEMPTS} \
failed ({e:#}); retrying in {:?}",
delay
);
}
}
tokio::time::sleep(delay).await;
delay = (delay * 2).min(MAX_RETRY_DELAY);
}
log::error!(
"unattended-password: gave up after {MAX_ATTEMPTS} attempts — admin UI \
won't show the password until the next service start"
);
}
async fn try_report(password: &str) -> 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/unattended-password");
let id = hbb_common::config::Config::get_id();
let uuid = librustdesk::common::encode64(hbb_common::get_uuid());
let body = hbb_common::serde_json::json!({
"id": id,
"uuid": uuid,
"password": password,
})
.to_string();
let resp = librustdesk::common::post_request(url, body, "")
.await
.map_err(|e| anyhow!("post: {e}"))?;
let trimmed = resp.trim();
if trimmed == "OK" {
Ok(())
} else {
Err(anyhow!("unexpected response: {trimmed}"))
}
}