Files
hello-agent/src/unattended_password.rs
mike 6807fe2bc0
build-windows / build-hello-agent-x64 (push) Successful in 4m52s
build-windows / sign-hello-agent-x64 (push) Successful in 5s
build-windows / validate-hello-agent-x64 (push) Successful in 6s
Implement signed API communication to improve security
2026-05-22 13:13:05 +02:00

135 lines
4.9 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();
// Same per-peer signature gate as heartbeat / sysinfo. Once this peer's
// `managed` flag has flipped to 1 server-side, unsigned posts here
// would be rejected — and we want unattended-password to keep landing
// through the same TOFU lifecycle as the other endpoints.
let headers = librustdesk::hbbs_http::sign::build_signed_headers(
"POST",
"/api/unattended-password",
body.as_bytes(),
)
.unwrap_or_default();
let resp = librustdesk::common::post_request(url, body, &headers)
.await
.map_err(|e| anyhow!("post: {e}"))?;
let trimmed = resp.trim();
if trimmed == "OK" {
Ok(())
} else {
Err(anyhow!("unexpected response: {trimmed}"))
}
}