Initial commit: hello-agent — headless RustDesk-protocol-compatible Windows agent
build-windows / build-hello-agent-x64 (push) Successful in 5m41s
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:
+190
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user