Files
rustdesk-server/src/api/admin/pages/devices.rs
T
mike 0af4f5edce
build / build-linux-amd64 (push) Successful in 2m0s
Implement password handling for unattended access
2026-05-08 11:15:50 +02:00

370 lines
13 KiB
Rust

//! Devices page — list devices currently / recently registered, with
//! force-disconnect (queues a `heartbeat_commands` row consumed on the
//! peer's next /api/heartbeat tick) and force-sysinfo refresh.
use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use crate::api::state::AppState;
use crate::database::DashboardDeviceRow;
use axum::extract::{Extension, Path};
use axum::response::Html;
use std::fmt::Write as _;
use std::sync::Arc;
const PAGE_SIZE: i64 = 100;
/// Devices that have heartbeated within this many seconds are considered
/// online. Clients heartbeat every 15s (hbb_common::config::REG_INTERVAL),
/// so 45s allows up to two missed beats before we flip the dot to red.
const ONLINE_THRESHOLD_SECS: i64 = 45;
pub async fn index(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let table = render_table(&state).await?;
Ok(Html(format!(
r##"<div class="space-y-6">
<header class="flex items-center justify-between">
<h2 class="text-lg font-semibold">Devices</h2>
<p class="text-xs text-slate-500">Force-disconnect / force-sysinfo are delivered on the peer's next heartbeat tick (~15 s).</p>
</header>
<section id="devices-region">
{table}
</section>
</div>"##
)))
}
pub async fn force_disconnect(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
Path(peer_id): Path<String>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let conns = state
.db
.device_sysinfo_get_conns(&peer_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
state
.db
.heartbeat_command_queue(&peer_id, "disconnect", Some(&conns))
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
notice_then_table(
&state,
"ok",
&format!("Queued disconnect for {} (conns={})", peer_id, conns),
)
.await
}
pub async fn force_sysinfo(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
Path(peer_id): Path<String>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
state
.db
.heartbeat_command_queue(&peer_id, "sysinfo", None)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
notice_then_table(
&state,
"ok",
&format!("Queued sysinfo refresh for {}", peer_id),
)
.await
}
pub async fn delete(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
Path(peer_id): Path<String>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let ok = state
.db
.device_delete(&peer_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let msg = if ok {
format!("Deleted device {}.", peer_id)
} else {
"Device already gone.".to_string()
};
notice_then_table(&state, if ok { "ok" } else { "error" }, &msg).await
}
// ---------- helpers ----------
/// Compute online/offline state from a SQLite `current_timestamp` string
/// ("YYYY-MM-DD HH:MM:SS" in UTC). Returns `(is_online, age_seconds)` —
/// the age is also useful for the tooltip text. On parse failure we fall
/// back to "offline" with `i64::MAX`, which is the safe direction (better
/// to show a stale row as offline than fake online).
fn online_state(last_heartbeat_at: &str, now: chrono::DateTime<chrono::Utc>) -> (bool, i64) {
let parsed = chrono::NaiveDateTime::parse_from_str(last_heartbeat_at, "%Y-%m-%d %H:%M:%S");
match parsed {
Ok(naive) => {
let last = chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(naive, chrono::Utc);
let age = (now - last).num_seconds().max(0);
(age <= ONLINE_THRESHOLD_SECS, age)
}
Err(_) => (false, i64::MAX),
}
}
async fn render_table(state: &Arc<AppState>) -> Result<String, ApiError> {
let (total, devices) = state
.db
.devices_list_all(0, PAGE_SIZE)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let now = chrono::Utc::now();
let mut s = String::new();
// No `overflow-hidden` on the table wrapper: the per-row action menu is
// an absolutely-positioned `<details>` popover inside a <td>, and the
// wrapper's clipping was hiding the bottom half of the menu.
let _ = write!(
s,
r##"<div class="rounded-md border border-slate-800 bg-slate-900">
<table class="w-full text-sm">
<thead class="text-xs uppercase text-slate-500 bg-slate-950">
<tr>
<th class="text-left font-medium px-3 py-2">Peer ID</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">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">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">Conns</th>
<th class="text-right font-medium px-3 py-2 w-1">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800">"##
);
if devices.is_empty() {
s.push_str(
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 {
render_device_row(&mut s, d, now);
}
let _ = write!(
s,
r##"</tbody>
</table>
<div class="px-3 py-2 text-xs text-slate-500 border-t border-slate-800">{total} device(s).</div>
</div>"##
);
Ok(s)
}
fn render_device_row(s: &mut String, d: &DashboardDeviceRow, now: chrono::DateTime<chrono::Utc>) {
let parsed: serde_json::Value =
serde_json::from_str(&d.sysinfo_payload).unwrap_or(serde_json::Value::Null);
let pick = |k: &str| -> String {
parsed
.get(k)
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string()
};
let hostname = pick("hostname");
// `username` is the active console user reported by the agent's
// sysinfo. The agent suppresses the field when nobody is logged in
// (or when it's literally "SYSTEM" on Windows), so an empty value
// here means "no interactive user" — render that as a dash.
let active_user = pick("username");
let os = pick("os");
// Version label. The sysinfo upload always carries `version` (the
// embedded rustdesk core version, e.g. "1.4.6"). Rebrands like
// hello-agent additionally stamp `agent_name` + `agent_version` —
// when present we surface those instead so the admin sees
// "Hello Agent 0.1.0" rather than the embedded core version.
// Fallback "RustDesk <ver>" is the right default for vanilla
// installs (they don't override the agent fields).
let version_label = {
let core_ver = pick("version");
let agent_name = pick("agent_name");
let agent_ver = pick("agent_version");
if !agent_name.is_empty() {
if !agent_ver.is_empty() {
format!("{agent_name} {agent_ver}")
} else {
agent_name
}
} else if !core_ver.is_empty() {
format!("RustDesk {core_ver}")
} else {
"".to_string()
}
};
let conn_count = serde_json::from_str::<Vec<i64>>(&d.conns_json)
.map(|v| v.len())
.unwrap_or(0);
let (is_online, age_secs) = online_state(&d.last_heartbeat_at, now);
let (dot_class, tooltip) = if is_online {
(
"bg-emerald-400",
format!("Online — last heartbeat {}s ago", age_secs),
)
} else if age_secs == i64::MAX {
("bg-slate-500", "No heartbeat recorded".to_string())
} else {
(
"bg-rose-500",
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!(
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-block w-2 h-2 rounded-full {dot}"></span>
<span>{id}</span>
</span>
</td>"##,
tt = html_escape(&tooltip),
dot = dot_class,
id = html_escape(&d.id),
);
let _ = write!(
s,
r##"<tr class="hover:bg-slate-800/40">
{id_cell}
<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-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 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-400">{n}</td>
<td class="px-3 py-2">
<details class="text-right relative">
<summary class="cursor-pointer list-none text-xs text-slate-400 hover:text-slate-200 select-none">···</summary>
<div class="absolute right-2 mt-1 z-10 w-56 bg-slate-900 border border-slate-700 rounded shadow-lg p-2 space-y-1 text-left">
<a class="block w-full text-left px-2 py-1 text-xs text-sky-300 hover:bg-sky-900/40 rounded"
href="/admin/connect/{id}" target="_blank" rel="noopener">
Connect (web client)
</a>
<hr class="border-slate-700 my-1" />
<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
hx-post="/admin/pages/devices/{id}/disconnect"
hx-target="#devices-region" hx-swap="innerHTML"
hx-confirm="Disconnect all active sessions on {id}?">
Force disconnect
</button>
<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
hx-post="/admin/pages/devices/{id}/sysinfo-refresh"
hx-target="#devices-region" hx-swap="innerHTML">
Force sysinfo refresh
</button>
<hr class="border-slate-700 my-1" />
<button class="w-full text-left px-2 py-1 text-xs text-rose-300 hover:bg-rose-900/40 rounded"
hx-post="/admin/pages/devices/{id}/delete"
hx-target="#devices-region" hx-swap="innerHTML"
hx-confirm="Delete {id}? This removes the dashboard row and the rendezvous identity. Audit logs and recordings are kept.">
Delete device
</button>
</div>
</details>
</td>
</tr>"##,
id_cell = id_cell,
id = html_escape(&d.id),
owner = html_escape(&d.owner_username),
host = html_escape(&hostname),
user = if active_user.is_empty() {
"".to_string()
} 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),
n = conn_count
);
}
/// Render an elapsed-seconds count as a short "Xs / Xm / Xh / Xd" string
/// for the offline tooltip. The exact heartbeat timestamp is already
/// shown in the table cell — this is just for the friendly tooltip.
fn fmt_age(secs: i64) -> String {
if secs < 60 {
format!("{}s", secs)
} else if secs < 3600 {
format!("{}m", secs / 60)
} else if secs < 86_400 {
format!("{}h", secs / 3600)
} else {
format!("{}d", secs / 86_400)
}
}
async fn notice_then_table(
state: &Arc<AppState>,
kind: &str,
msg: &str,
) -> Result<Html<String>, ApiError> {
let mut html = notice_html(kind, msg);
html.push_str(&render_table(state).await?);
Ok(Html(html))
}
fn notice_html(kind: &str, msg: &str) -> String {
let (border, bg, text) = match kind {
"ok" => ("emerald-700/50", "emerald-900/30", "emerald-300"),
_ => ("rose-700/50", "rose-900/30", "rose-300"),
};
format!(
r##"<div class="rounded border border-{border} bg-{bg} p-3 mb-4 text-sm text-{text}">{msg}</div>"##,
border = border,
bg = bg,
text = text,
msg = html_escape(msg),
)
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
fn require_admin(u: &AuthedUser) -> Result<(), ApiError> {
if u.is_admin {
Ok(())
} else {
Err(ApiError::Forbidden("admin required".into()))
}
}