diff --git a/db_v2.sqlite3 b/db_v2.sqlite3 index 93d9801..5e1c781 100644 Binary files a/db_v2.sqlite3 and b/db_v2.sqlite3 differ diff --git a/src/api/admin/mod.rs b/src/api/admin/mod.rs index f8f6cda..9808596 100644 --- a/src/api/admin/mod.rs +++ b/src/api/admin/mod.rs @@ -90,6 +90,14 @@ pub fn build(state: Arc) -> Option { ) .route("/admin/pages/users/:id/delete", post(pages::users::delete)) // Devices + .route( + "/admin/pages/devices/list-fragment", + get(pages::devices::list_fragment), + ) + .route( + "/admin/pages/devices/:peer_id/detail", + get(pages::devices::detail), + ) .route( "/admin/pages/devices/:peer_id/disconnect", post(pages::devices::force_disconnect), diff --git a/src/api/admin/pages/devices.rs b/src/api/admin/pages/devices.rs index 440f13f..aa296b8 100644 --- a/src/api/admin/pages/devices.rs +++ b/src/api/admin/pages/devices.rs @@ -99,6 +99,38 @@ pub async fn delete( notice_then_table(&state, if ok { "ok" } else { "error" }, &msg).await } +/// Per-device detail page: hardware / OS inventory reported by hello-agent +/// alongside the standard sysinfo (CPU/RAM/OS/hostname). Replaces the +/// devices list in `#devices-region` via HTMX; a "Back to devices" button +/// re-fetches the table. Vanilla rustdesk clients don't report inventory, +/// so for those we surface a hint pointing the operator at hello-agent. +pub async fn detail( + Extension(state): Extension>, + admin: AuthedUser, + Path(peer_id): Path, +) -> Result, ApiError> { + require_admin(&admin)?; + let row = state + .db + .device_get_by_id(&peer_id) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + let html = match row { + Some(d) => render_detail(&d), + None => format!( + r##"
+ {back} +
+ No device with peer ID {id} in the dashboard. +
+
"##, + back = back_button(), + id = html_escape(&peer_id), + ), + }; + Ok(Html(html)) +} + // ---------- helpers ---------- /// Compute online/offline state from a SQLite `current_timestamp` string @@ -274,6 +306,11 @@ fn render_device_row(s: &mut String, d: &DashboardDeviceRow, now: chrono::DateTi href="/admin/connect/{id}" target="_blank" rel="noopener"> Connect (web client) +
"## + .to_string() +} + +/// Pretty-print a JSON value for the inventory table cells. Strings are +/// returned as-is, numbers / bools rendered via Display, null / missing +/// becomes a dash. Anything more complex (objects, arrays of non-disks) +/// falls back to compact JSON so the page never panics on unexpected data. +fn fmt_inv_value(v: Option<&serde_json::Value>) -> String { + match v { + None | Some(serde_json::Value::Null) => "—".to_string(), + Some(serde_json::Value::String(s)) if s.is_empty() => "—".to_string(), + Some(serde_json::Value::String(s)) => html_escape(s), + Some(serde_json::Value::Number(n)) => n.to_string(), + Some(serde_json::Value::Bool(b)) => b.to_string(), + Some(other) => html_escape(&other.to_string()), + } +} + +fn render_detail(d: &DashboardDeviceRow) -> String { + 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 agent_name = pick("agent_name"); + let agent_version = pick("agent_version"); + let core_version = pick("version"); + let hostname = pick("hostname"); + let active_user = pick("username"); + let os_runtime = pick("os"); + let cpu_runtime = pick("cpu"); + let mem_runtime = pick("memory"); + + let identity_label = if !agent_name.is_empty() { + if !agent_version.is_empty() { + format!("{agent_name} {agent_version}") + } else { + agent_name.clone() + } + } else if !core_version.is_empty() { + format!("RustDesk {core_version}") + } else { + "—".to_string() + }; + + // Header summary — same data the list shows, rendered as a description list. + let header = format!( + r##"
+
+

Device {id}

+ UUID {uuid} +
+
+
Hostname
{host}
+
Owner
{owner}
+
Active user
{user}
+
Agent
{ident}
+
OS (runtime)
{os_rt}
+
Last heartbeat
{last}
+
CPU (runtime)
{cpu_rt}
+
Memory (runtime)
{mem_rt}
+
+
"##, + id = html_escape(&d.id), + uuid = html_escape(&d.uuid), + host = html_escape(if hostname.is_empty() { "—" } else { &hostname }), + owner = html_escape(if d.owner_username.is_empty() { + "—" + } else { + &d.owner_username + }), + user = html_escape(if active_user.is_empty() { "—" } else { &active_user }), + ident = html_escape(&identity_label), + os_rt = html_escape(if os_runtime.is_empty() { "—" } else { &os_runtime }), + last = html_escape(&d.last_heartbeat_at), + cpu_rt = html_escape(if cpu_runtime.is_empty() { "—" } else { &cpu_runtime }), + mem_rt = html_escape(if mem_runtime.is_empty() { "—" } else { &mem_runtime }), + ); + + // Inventory section — only present when the agent reports it. We key + // gating off `agent_name == "HelloAgent"` because the regular RustDesk + // client doesn't ship the inventory collector and would never populate + // this. `agent_name` is the explicit rebrand identity stamped by + // hello-agent's main.rs; absence means vanilla rustdesk. + let inventory_section = if agent_name == "HelloAgent" { + match parsed.get("inventory") { + Some(inv) if inv.is_object() => render_inventory_table(inv), + _ => format!( + r##"
+ Inventory data not yet reported. The agent collects it on startup and + uploads on the next sysinfo cycle (≤120 s). +
"## + ), + } + } else { + format!( + r##"
+ Inventory data is only reported by HelloAgent. This device is running + {ident}; the standard RustDesk client + does not collect hardware or BitLocker inventory. +
"##, + ident = html_escape(&identity_label), + ) + }; + + format!( + r##"
+
+ {back} +
Detail view
+
+ {header} +

Inventory

+ {inv} +
"##, + back = back_button(), + header = header, + inv = inventory_section, + ) +} + +fn render_inventory_table(inv: &serde_json::Value) -> String { + let row = |label: &str, key: &str| { + format!( + r##" + {label} + {val} +"##, + label = label, + val = fmt_inv_value(inv.get(key)), + ) + }; + + // Disks need their own renderer — they're an array of objects. + let disks_html = match inv.get("disks") { + Some(serde_json::Value::Array(arr)) if !arr.is_empty() => { + let mut s = String::from( + r##""##, + ); + for disk in arr { + let name = fmt_inv_value(disk.get("name")); + let model = fmt_inv_value(disk.get("model")); + let size = fmt_inv_value(disk.get("size_gb")); + let media = fmt_inv_value(disk.get("media")); + let _ = write!( + s, + r##""##, + ); + } + s.push_str("
NameModelSize (GB)Media
{name}{model}{size}{media}
"); + s + } + _ => r##""##.to_string(), + }; + + // BitLocker is sensitive — render in a copy-friendly monospace box and + // a slightly louder color, but don't try to obscure it. The whole + // detail page already requires admin auth. + let bl_key_raw = inv + .get("bitlocker_recovery_key") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let bl_html = if bl_key_raw.is_empty() { + r##"— (not reported; non-Pro SKU, not encrypted, or no admin rights)"##.to_string() + } else { + format!( + r##"{}"##, + html_escape(bl_key_raw) + ) + }; + + format!( + r##"
+
+ + + {sn} + {mfr} + {model} + {dom} + {os_d} + {os_r} + {cpu_m} + {cpu_s} + {cpu_pc} + {cpu_lc} + {ram} + +
+
+
+

Disks

+
+ {disks} +
+
+
+

BitLocker recovery key (system drive)

+ {bl} +
+
"##, + sn = row("Serial number", "serial_number"), + mfr = row("Manufacturer", "manufacturer"), + model = row("Model", "model"), + dom = row("Windows domain", "domain"), + os_d = row("OS distribution", "os_distro"), + os_r = row("OS release", "os_release"), + cpu_m = row("CPU model", "cpu_model"), + cpu_s = row("CPU speed (GHz)", "cpu_speed_ghz"), + cpu_pc = row("CPU physical cores", "cpu_cores_physical"), + cpu_lc = row("CPU logical cores", "cpu_cores_logical"), + ram = row("RAM (GB)", "ram_gb"), + disks = disks_html, + bl = bl_html, + ) +} + /// 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. diff --git a/src/database.rs b/src/database.rs index a1ccf0d..4531482 100644 --- a/src/database.rs +++ b/src/database.rs @@ -640,6 +640,42 @@ impl Database { Ok((total, data)) } + /// Fetch a single device row for the per-device detail page. Same + /// shape and join as `devices_list_all`, just keyed on peer id. + /// Returns `None` for an unknown / never-heartbeated peer id so the + /// handler can render a 404-style notice instead of failing the + /// request. + pub async fn device_get_by_id( + &self, + peer_id: &str, + ) -> ResultType> { + let row = sqlx::query( + "SELECT ds.id AS pid, ds.uuid AS puuid, \ + COALESCE(u.username, '') AS owner_username, \ + ds.last_heartbeat_at AS last_hb, \ + ds.payload AS payload, \ + 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 \ + WHERE ds.id = ? LIMIT 1", + ) + .bind(peer_id) + .fetch_optional(self.pool.get().await?.deref_mut()) + .await?; + Ok(row.map(|r| DashboardDeviceRow { + id: r.try_get("pid").unwrap_or_default(), + uuid: r.try_get("puuid").unwrap_or_default(), + owner_username: r.try_get("owner_username").unwrap_or_default(), + 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(), + })) + } + pub async fn device_sysinfo_get_conns(&self, peer_id: &str) -> ResultType { let row = sqlx::query("SELECT conns FROM device_sysinfo WHERE id = ? LIMIT 1") .bind(peer_id)