f8ead215d8
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>
124 lines
4.4 KiB
Rust
124 lines
4.4 KiB
Rust
// 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}"))
|
|
}
|
|
}
|