From 6bb89ba66c0455c732bae5b3fc47ca1c3d2b01b1 Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Fri, 8 May 2026 09:32:13 +0200 Subject: [PATCH] Implement password handling for unattended access --- src/api/admin/pages/devices.rs | 24 +++++++++++++- src/api/mod.rs | 5 +++ src/api/unattended.rs | 58 ++++++++++++++++++++++++++++++++++ src/database.rs | 49 ++++++++++++++++++++++++++-- 4 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 src/api/unattended.rs diff --git a/src/api/admin/pages/devices.rs b/src/api/admin/pages/devices.rs index 7843e39..440f13f 100644 --- a/src/api/admin/pages/devices.rs +++ b/src/api/admin/pages/devices.rs @@ -139,6 +139,7 @@ async fn render_table(state: &Arc) -> Result { Owner Hostname User + Unattended pwd OS Version Last heartbeat @@ -150,7 +151,7 @@ async fn render_table(state: &Arc) -> Result { ); if devices.is_empty() { s.push_str( - r##"No devices have heartbeated yet."##, + r##"No devices have heartbeated yet."##, ); } for d in &devices { @@ -223,6 +224,25 @@ fn render_device_row(s: &mut String, d: &DashboardDeviceRow, now: chrono::DateTi format!("Offline — last heartbeat {} ago", fmt_age(age_secs)), ) }; + // Per-boot unattended-access password reported by hello-agent. Visible + // only when (a) the device is online (offline rows show stale data), + // (b) no interactive user is logged in (otherwise the supporter + // should be using the per-session approval popup, not the password), + // and (c) the agent has actually reported one (vanilla rustdesk + // never will). Otherwise show a neutral dash so the column lines up. + let unattended_pwd_cell = if is_online + && active_user.is_empty() + && !d.unattended_password.is_empty() + { + format!( + r##"{pw}"##, + pw = html_escape(&d.unattended_password), + set_at = html_escape(&d.unattended_password_set_at), + ) + } else { + r##""##.to_string() + }; + let id_cell = format!( r##" @@ -241,6 +261,7 @@ fn render_device_row(s: &mut String, d: &DashboardDeviceRow, now: chrono::DateTi {owner} {host} {user} + {unattended_pwd} {os} {ver} {last} @@ -285,6 +306,7 @@ fn render_device_row(s: &mut String, d: &DashboardDeviceRow, now: chrono::DateTi } else { html_escape(&active_user) }, + unattended_pwd = unattended_pwd_cell, os = html_escape(&os), ver = html_escape(&version_label), last = html_escape(&d.last_heartbeat_at), diff --git a/src/api/mod.rs b/src/api/mod.rs index f95187a..a44d607 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -23,6 +23,7 @@ pub mod state; pub mod strategy; pub mod sysinfo; pub mod twofa; +pub mod unattended; pub mod users; pub use state::AppState; @@ -47,6 +48,10 @@ pub fn router(state: Arc) -> Router { .route("/api/heartbeat", post(heartbeat::heartbeat)) .route("/api/sysinfo_ver", post(sysinfo::sysinfo_ver)) .route("/api/sysinfo", post(sysinfo::sysinfo)) + .route( + "/api/unattended-password", + post(unattended::unattended_password), + ) // M2: address book — modern (shared + personal) .route("/api/ab/settings", post(ab::settings::settings)) .route("/api/ab/personal", post(ab::profiles::personal)) diff --git a/src/api/unattended.rs b/src/api/unattended.rs new file mode 100644 index 0000000..06dd8e2 --- /dev/null +++ b/src/api/unattended.rs @@ -0,0 +1,58 @@ +//! `POST /api/unattended-password` — agent-side reporting of the per-boot +//! "permanent password" used for unattended access (no logged-in user to +//! click the approval popup). hello-agent generates a random password +//! every time the service starts and posts it here so the admin UI can +//! surface it for support staff. +//! +//! Auth model mirrors `/api/sysinfo`: the request must carry the agent's +//! `(id, uuid)` and that pair must already correspond to a registered +//! peer in `peer`. There's no shared secret beyond that — same trust +//! boundary the existing sysinfo endpoint already operates under. + +use crate::api::error::ApiError; +use crate::api::state::AppState; +use axum::extract::Extension; +use axum::Json; +use serde_json::Value; +use std::sync::Arc; + +/// Body: `{"id": "...", "uuid": "...", "password": "..."}` +/// Response (bare string, like sysinfo): `"OK"` or `"ID_NOT_FOUND"`. +pub async fn unattended_password( + Extension(state): Extension>, + Json(payload): Json, +) -> Result { + let id = payload + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + let uuid = payload + .get("uuid") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + let password = payload + .get("password") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + if id.is_empty() || uuid.is_empty() || password.is_empty() { + return Err(ApiError::BadRequest( + "id, uuid, and password are required".into(), + )); + } + + let peer = state + .db + .get_peer(id) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + if peer.is_none() { + return Ok("ID_NOT_FOUND".to_string()); + } + + state + .db + .set_unattended_password(id, uuid, password) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + Ok("OK".to_string()) +} diff --git a/src/database.rs b/src/database.rs index b6096e3..a1ccf0d 100644 --- a/src/database.rs +++ b/src/database.rs @@ -205,6 +205,13 @@ pub struct DashboardDeviceRow { pub last_heartbeat_at: String, pub sysinfo_payload: String, pub conns_json: String, + /// Plaintext per-boot password reported by the agent for unattended + /// access. Empty when the agent hasn't reported one (vanilla rustdesk + /// or hello-agent that hasn't called the endpoint yet). The admin UI + /// only surfaces this when the row is online AND no interactive user + /// is logged in. + pub unattended_password: String, + pub unattended_password_set_at: String, } #[derive(Debug, Clone, Default)] @@ -368,7 +375,9 @@ impl Database { } // Soft-ALTERs run after schema creation. SQLite < 3.35 lacks // `ADD COLUMN IF NOT EXISTS`; swallow the duplicate-column error - // so re-runs are idempotent. + // so re-runs are idempotent. Newly-added soft alters get appended + // to the same list — order doesn't matter beyond "after the table + // they touch exists in M*_SCHEMA". for stmt in M2_SOFT_ALTERS { self.try_alter(stmt).await; } @@ -604,7 +613,9 @@ impl Database { COALESCE(u.username, '') AS owner_username, \ ds.last_heartbeat_at AS last_hb, \ ds.payload AS payload, \ - ds.conns AS conns \ + ds.conns AS conns, \ + COALESCE(ds.unattended_password, '') AS u_pw, \ + COALESCE(ds.unattended_password_set_at, '') AS u_pw_at \ FROM device_sysinfo ds \ LEFT JOIN users u ON u.id = ds.user_id \ ORDER BY ds.last_heartbeat_at DESC LIMIT ? OFFSET ?", @@ -622,6 +633,8 @@ impl Database { last_heartbeat_at: r.try_get("last_hb").unwrap_or_default(), sysinfo_payload: r.try_get("payload").unwrap_or_default(), conns_json: r.try_get("conns").unwrap_or_default(), + unattended_password: r.try_get("u_pw").unwrap_or_default(), + unattended_password_set_at: r.try_get("u_pw_at").unwrap_or_default(), }) .collect(); Ok((total, data)) @@ -1116,6 +1129,31 @@ impl Database { Ok(()) } + /// Store the agent's per-boot unattended-access password. Upserts so a + /// device that's never sysinfo'd yet still gets a `device_sysinfo` row + /// to hang the password on. Caller is expected to have validated the + /// (id, uuid) pair against `peer` first — same gate as sysinfo_upsert. + pub async fn set_unattended_password( + &self, + id: &str, + uuid: &str, + password: &str, + ) -> ResultType<()> { + sqlx::query( + "INSERT INTO device_sysinfo(id, uuid, unattended_password, unattended_password_set_at) \ + VALUES(?, ?, ?, current_timestamp) \ + ON CONFLICT(id, uuid) DO UPDATE SET \ + unattended_password = excluded.unattended_password, \ + unattended_password_set_at = current_timestamp", + ) + .bind(id) + .bind(uuid) + .bind(password) + .execute(self.pool.get().await?.deref_mut()) + .await?; + Ok(()) + } + // =================================================================== // M2: address book / tags / device groups / accessibility // =================================================================== @@ -2971,6 +3009,13 @@ const M2_SOFT_ALTERS: &[&str] = &[ // login — promotion AND demotion at the IdP propagate. "ALTER TABLE oidc_providers ADD COLUMN admin_role TEXT", "ALTER TABLE oidc_providers ADD COLUMN roles_claim TEXT", + // Unattended-access password. Some agents (hello-agent) generate a + // random "permanent password" on every boot and report it back here + // so a supporter can reach the box when no user is logged in to + // approve a connection. Stored as plaintext on purpose: the admin UI + // displays it for the operator to read, and it rotates each boot. + "ALTER TABLE device_sysinfo ADD COLUMN unattended_password TEXT", + "ALTER TABLE device_sysinfo ADD COLUMN unattended_password_set_at DATETIME", ]; const M3_SCHEMA: &[&str] = &[