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());
}
}