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