Implement password handling for unattended access
build / build-linux-amd64 (push) Successful in 2m15s

This commit is contained in:
2026-05-08 09:32:13 +02:00
parent c1eaac1cb3
commit 6bb89ba66c
4 changed files with 133 additions and 3 deletions
+23 -1
View File
@@ -139,6 +139,7 @@ async fn render_table(state: &Arc<AppState>) -> Result<String, ApiError> {
<th class="text-left font-medium px-3 py-2">Owner</th> <th class="text-left font-medium px-3 py-2">Owner</th>
<th class="text-left font-medium px-3 py-2">Hostname</th> <th class="text-left font-medium px-3 py-2">Hostname</th>
<th class="text-left font-medium px-3 py-2">User</th> <th class="text-left font-medium px-3 py-2">User</th>
<th class="text-left font-medium px-3 py-2">Unattended pwd</th>
<th class="text-left font-medium px-3 py-2">OS</th> <th class="text-left font-medium px-3 py-2">OS</th>
<th class="text-left font-medium px-3 py-2">Version</th> <th class="text-left font-medium px-3 py-2">Version</th>
<th class="text-left font-medium px-3 py-2">Last heartbeat</th> <th class="text-left font-medium px-3 py-2">Last heartbeat</th>
@@ -150,7 +151,7 @@ async fn render_table(state: &Arc<AppState>) -> Result<String, ApiError> {
); );
if devices.is_empty() { if devices.is_empty() {
s.push_str( s.push_str(
r##"<tr><td colspan="9" class="px-3 py-4 text-slate-500 text-center text-xs">No devices have heartbeated yet.</td></tr>"##, r##"<tr><td colspan="10" class="px-3 py-4 text-slate-500 text-center text-xs">No devices have heartbeated yet.</td></tr>"##,
); );
} }
for d in &devices { 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)), 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##"<code class="font-mono text-xs text-amber-300 bg-slate-950 px-1.5 py-0.5 rounded border border-slate-800" title="Reported {set_at} UTC">{pw}</code>"##,
pw = html_escape(&d.unattended_password),
set_at = html_escape(&d.unattended_password_set_at),
)
} else {
r##"<span class="text-slate-600">—</span>"##.to_string()
};
let id_cell = format!( let id_cell = format!(
r##"<td class="px-3 py-2 font-mono text-slate-200 whitespace-nowrap"> r##"<td class="px-3 py-2 font-mono text-slate-200 whitespace-nowrap">
<span class="inline-flex items-center gap-2" title="{tt}"> <span class="inline-flex items-center gap-2" title="{tt}">
@@ -241,6 +261,7 @@ fn render_device_row(s: &mut String, d: &DashboardDeviceRow, now: chrono::DateTi
<td class="px-3 py-2 text-slate-300">{owner}</td> <td class="px-3 py-2 text-slate-300">{owner}</td>
<td class="px-3 py-2 text-slate-400">{host}</td> <td class="px-3 py-2 text-slate-400">{host}</td>
<td class="px-3 py-2 text-slate-300">{user}</td> <td class="px-3 py-2 text-slate-300">{user}</td>
<td class="px-3 py-2 whitespace-nowrap">{unattended_pwd}</td>
<td class="px-3 py-2 text-slate-400">{os}</td> <td class="px-3 py-2 text-slate-400">{os}</td>
<td class="px-3 py-2 text-slate-400 whitespace-nowrap">{ver}</td> <td class="px-3 py-2 text-slate-400 whitespace-nowrap">{ver}</td>
<td class="px-3 py-2 text-slate-500 text-xs">{last}</td> <td class="px-3 py-2 text-slate-500 text-xs">{last}</td>
@@ -285,6 +306,7 @@ fn render_device_row(s: &mut String, d: &DashboardDeviceRow, now: chrono::DateTi
} else { } else {
html_escape(&active_user) html_escape(&active_user)
}, },
unattended_pwd = unattended_pwd_cell,
os = html_escape(&os), os = html_escape(&os),
ver = html_escape(&version_label), ver = html_escape(&version_label),
last = html_escape(&d.last_heartbeat_at), last = html_escape(&d.last_heartbeat_at),
+5
View File
@@ -23,6 +23,7 @@ pub mod state;
pub mod strategy; pub mod strategy;
pub mod sysinfo; pub mod sysinfo;
pub mod twofa; pub mod twofa;
pub mod unattended;
pub mod users; pub mod users;
pub use state::AppState; pub use state::AppState;
@@ -47,6 +48,10 @@ pub fn router(state: Arc<AppState>) -> Router {
.route("/api/heartbeat", post(heartbeat::heartbeat)) .route("/api/heartbeat", post(heartbeat::heartbeat))
.route("/api/sysinfo_ver", post(sysinfo::sysinfo_ver)) .route("/api/sysinfo_ver", post(sysinfo::sysinfo_ver))
.route("/api/sysinfo", post(sysinfo::sysinfo)) .route("/api/sysinfo", post(sysinfo::sysinfo))
.route(
"/api/unattended-password",
post(unattended::unattended_password),
)
// M2: address book — modern (shared + personal) // M2: address book — modern (shared + personal)
.route("/api/ab/settings", post(ab::settings::settings)) .route("/api/ab/settings", post(ab::settings::settings))
.route("/api/ab/personal", post(ab::profiles::personal)) .route("/api/ab/personal", post(ab::profiles::personal))
+58
View File
@@ -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<Arc<AppState>>,
Json(payload): Json<Value>,
) -> Result<String, ApiError> {
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())
}
+47 -2
View File
@@ -205,6 +205,13 @@ pub struct DashboardDeviceRow {
pub last_heartbeat_at: String, pub last_heartbeat_at: String,
pub sysinfo_payload: String, pub sysinfo_payload: String,
pub conns_json: 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)] #[derive(Debug, Clone, Default)]
@@ -368,7 +375,9 @@ impl Database {
} }
// Soft-ALTERs run after schema creation. SQLite < 3.35 lacks // Soft-ALTERs run after schema creation. SQLite < 3.35 lacks
// `ADD COLUMN IF NOT EXISTS`; swallow the duplicate-column error // `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 { for stmt in M2_SOFT_ALTERS {
self.try_alter(stmt).await; self.try_alter(stmt).await;
} }
@@ -604,7 +613,9 @@ impl Database {
COALESCE(u.username, '') AS owner_username, \ COALESCE(u.username, '') AS owner_username, \
ds.last_heartbeat_at AS last_hb, \ ds.last_heartbeat_at AS last_hb, \
ds.payload AS payload, \ 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 \ FROM device_sysinfo ds \
LEFT JOIN users u ON u.id = ds.user_id \ LEFT JOIN users u ON u.id = ds.user_id \
ORDER BY ds.last_heartbeat_at DESC LIMIT ? OFFSET ?", 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(), last_heartbeat_at: r.try_get("last_hb").unwrap_or_default(),
sysinfo_payload: r.try_get("payload").unwrap_or_default(), sysinfo_payload: r.try_get("payload").unwrap_or_default(),
conns_json: r.try_get("conns").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(); .collect();
Ok((total, data)) Ok((total, data))
@@ -1116,6 +1129,31 @@ impl Database {
Ok(()) 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 // M2: address book / tags / device groups / accessibility
// =================================================================== // ===================================================================
@@ -2971,6 +3009,13 @@ const M2_SOFT_ALTERS: &[&str] = &[
// login — promotion AND demotion at the IdP propagate. // login — promotion AND demotion at the IdP propagate.
"ALTER TABLE oidc_providers ADD COLUMN admin_role TEXT", "ALTER TABLE oidc_providers ADD COLUMN admin_role TEXT",
"ALTER TABLE oidc_providers ADD COLUMN roles_claim 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] = &[ const M3_SCHEMA: &[&str] = &[