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
+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}"))
}
}