261 lines
12 KiB
Rust
261 lines
12 KiB
Rust
//! System inventory collection for hello-agent (CMDB).
|
|
//!
|
|
//! Collects hardware and OS metadata at startup — BIOS serial number,
|
|
//! manufacturer / model, AD domain, OS edition + release, CPU details,
|
|
//! RAM, disks, and the BitLocker recovery key for the system drive — and
|
|
//! returns it as a compact JSON object. The caller stamps the result into
|
|
//! `hbb_common::config::INVENTORY` so the next /api/sysinfo upload carries
|
|
//! it under the `inventory` key. The rustdesk-server admin UI's per-device
|
|
//! detail page reads it back from `device_sysinfo.payload`.
|
|
//!
|
|
//! Implementation: a single PowerShell child gathers everything via
|
|
//! Get-CimInstance and emits compact JSON. One subprocess for the whole
|
|
//! inventory is cheaper than per-field WMI queries and avoids pulling a
|
|
//! `wmi`/COM crate into the dep tree. Inventory is collected once at
|
|
//! startup. Collection routinely outruns the first sysinfo tick (TIME_CONN
|
|
//! = 3 s) — `Invoke-RestMethod 'api.ipify.org' -TimeoutSec 5` alone can
|
|
//! burn that budget on hosts with blocked egress — so the sysinfo loop in
|
|
//! `hbbs_http::sync` watches for INVENTORY transitioning empty → populated
|
|
//! and forces a re-upload at that point. Subsequent ticks are suppressed
|
|
//! by the loop's `had_inventory` / `uploaded` bookkeeping.
|
|
//!
|
|
//! Non-Windows builds return an empty JSON object — hello-agent v0 only
|
|
//! ships on Windows, but keeping the cross-platform surface compiling
|
|
//! makes future Linux work cheap.
|
|
|
|
#[cfg(target_os = "windows")]
|
|
const PS_SCRIPT: &str = r#"
|
|
$ErrorActionPreference = 'SilentlyContinue'
|
|
$bios = Get-CimInstance -ClassName Win32_BIOS
|
|
$cs = Get-CimInstance -ClassName Win32_ComputerSystem
|
|
$os = Get-CimInstance -ClassName Win32_OperatingSystem
|
|
$cpus = @(Get-CimInstance -ClassName Win32_Processor)
|
|
$first_cpu = $cpus | Select-Object -First 1
|
|
$total_phys_cores = ($cpus | Measure-Object -Property NumberOfCores -Sum).Sum
|
|
$total_log_cores = ($cpus | Measure-Object -Property NumberOfLogicalProcessors -Sum).Sum
|
|
$disks = @(Get-CimInstance -ClassName Win32_DiskDrive | ForEach-Object {
|
|
[pscustomobject]@{
|
|
name = $_.DeviceID
|
|
model = $_.Model
|
|
size_gb = if ($_.Size) { [math]::Round([double]$_.Size / 1GB, 1) } else { 0 }
|
|
media = $_.MediaType
|
|
}
|
|
})
|
|
# DisplayVersion is the marketing release ID (e.g. "23H2") — not surfaced
|
|
# by Win32_OperatingSystem.Version, which only carries the build number.
|
|
$displayVersion = ''
|
|
try {
|
|
$displayVersion = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -Name DisplayVersion -ErrorAction Stop).DisplayVersion
|
|
} catch {}
|
|
# BitLocker recovery key for the system drive. Get-BitLockerVolume needs
|
|
# the BitLocker PowerShell module (present on Pro/Enterprise SKUs) and
|
|
# admin rights; the agent runs as LocalSystem in production. On Home SKUs
|
|
# or Linux this just stays empty.
|
|
$bl_key = ''
|
|
try {
|
|
$sysDrive = $env:SystemDrive
|
|
$bv = Get-BitLockerVolume -MountPoint $sysDrive -ErrorAction Stop
|
|
$kp = $bv.KeyProtector | Where-Object { $_.KeyProtectorType -eq 'RecoveryPassword' } | Select-Object -First 1
|
|
if ($kp -and $kp.RecoveryPassword) { $bl_key = $kp.RecoveryPassword }
|
|
} catch {}
|
|
|
|
# Network interfaces: every adapter (Up + Disabled + Disconnected) with
|
|
# its MAC and bound IPv4/IPv6 addresses. Filtering happens server-side
|
|
# so the operator can still see "this NIC is disabled" rather than
|
|
# silently dropping it. LinkSpeed comes back as a string like "1 Gbps"
|
|
# or "100 Mbps" — normalize to integer Mbps, 0 when unknown / down.
|
|
$nics = @(Get-NetAdapter | ForEach-Object {
|
|
$nic = $_
|
|
$ipv4 = @(Get-NetIPAddress -InterfaceIndex $nic.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue | ForEach-Object { $_.IPAddress })
|
|
$ipv6 = @(Get-NetIPAddress -InterfaceIndex $nic.ifIndex -AddressFamily IPv6 -ErrorAction SilentlyContinue | ForEach-Object { $_.IPAddress })
|
|
$speed_mbps = 0
|
|
if ($nic.LinkSpeed) {
|
|
if ($nic.LinkSpeed -match '^([\d.]+)\s*Gbps') { $speed_mbps = [int]([double]$Matches[1] * 1000) }
|
|
elseif ($nic.LinkSpeed -match '^([\d.]+)\s*Mbps') { $speed_mbps = [int]([double]$Matches[1]) }
|
|
elseif ($nic.LinkSpeed -match '^([\d.]+)\s*Kbps') { $speed_mbps = 0 }
|
|
}
|
|
[pscustomobject]@{
|
|
name = $nic.Name
|
|
description = $nic.InterfaceDescription
|
|
mac = $nic.MacAddress
|
|
status = "$($nic.Status)"
|
|
ipv4 = $ipv4
|
|
ipv6 = $ipv6
|
|
speed_mbps = $speed_mbps
|
|
is_wifi = ($nic.PhysicalMediaType -eq 'Native 802.11')
|
|
}
|
|
})
|
|
|
|
# Wi-Fi inventory is collected separately, in Rust, against the Win32
|
|
# Native Wi-Fi API (`wlanapi.dll`). netsh's text output is partially
|
|
# localized — `Authentication` becomes `Authentifizierung` /
|
|
# `Autenticación` / `Autentificare` on de/es/ro Windows — and our
|
|
# regexes silently dropped fields on non-English hosts. The native API
|
|
# returns SSIDs as bytes and auth/cipher as numeric enums, so the
|
|
# resulting data is locale-stable. See `src/wifi_native.rs`. The fields
|
|
# `wifi_current` / `wifi_nearby` are merged into this object after
|
|
# PowerShell exits.
|
|
|
|
# Public egress IP: best-effort lookup against an external echo service.
|
|
# Used when the operator wants to correlate the device with a NAT'd
|
|
# location. 5 s timeout so a blocked corporate firewall doesn't stall
|
|
# inventory collection. ipify is HTTPS, IPv4-only by default, no auth.
|
|
$public_ip = ''
|
|
try {
|
|
$public_ip = (Invoke-RestMethod -Uri 'https://api.ipify.org' -TimeoutSec 5 -ErrorAction Stop).ToString().Trim()
|
|
} catch {}
|
|
|
|
# Installed software (the "Add/Remove Programs" / "Apps & features" list).
|
|
# We enumerate the Uninstall registry keys directly — the same source
|
|
# Settings reads — rather than `Get-CimInstance Win32_Product`, which is
|
|
# notoriously slow (triggers MSI self-repair on every entry) and only
|
|
# covers MSI-installed software, missing everything from per-user
|
|
# installers, Chocolatey/Scoop/Inno-Setup, etc.
|
|
#
|
|
# We read both HKLM hives (64-bit + WOW6432Node 32-bit) so apps installed
|
|
# under either bitness show up. HKCU is skipped on purpose: the agent
|
|
# runs as LocalSystem (or LocalService), whose HKCU hive has nothing the
|
|
# logged-in user installed under their own profile — that data would
|
|
# require running per-user, which is out of scope for v1.
|
|
#
|
|
# Filter rules:
|
|
# * `DisplayName` must be set — empty-DisplayName entries are uninstall
|
|
# stubs for individual update KBs and not user-facing apps.
|
|
# * Skip `SystemComponent = 1` — internal Windows components hidden
|
|
# from Settings (DirectX shims, VC++ private redists, …).
|
|
# * Skip entries with a `ParentKeyName` — those are subcomponents of a
|
|
# parent application (e.g. Office's per-language packs); the parent
|
|
# row already covers the user-facing app.
|
|
#
|
|
# Bitness-tagged so the admin UI can distinguish a 64-bit vs 32-bit
|
|
# install of the same product (common for runtimes like VC++).
|
|
$installed_software = @()
|
|
foreach ($scope in @(
|
|
@{ path = 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*'; bitness = '64' },
|
|
@{ path = 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'; bitness = '32' }
|
|
)) {
|
|
try {
|
|
$entries = Get-ItemProperty -Path $scope.path -ErrorAction SilentlyContinue
|
|
} catch { continue }
|
|
foreach ($e in $entries) {
|
|
if (-not $e.DisplayName) { continue }
|
|
if ($e.SystemComponent -eq 1) { continue }
|
|
if ($e.ParentKeyName) { continue }
|
|
$installed_software += [pscustomobject]@{
|
|
name = "$($e.DisplayName)"
|
|
version = if ($e.DisplayVersion) { "$($e.DisplayVersion)" } else { '' }
|
|
publisher = if ($e.Publisher) { "$($e.Publisher)" } else { '' }
|
|
install_date = if ($e.InstallDate) { "$($e.InstallDate)" } else { '' }
|
|
bitness = $scope.bitness
|
|
}
|
|
}
|
|
}
|
|
$installed_software = @($installed_software | Sort-Object name, version)
|
|
|
|
$os_release = "$($os.Version)"
|
|
if ($displayVersion) { $os_release = "$($os.Version) $displayVersion" }
|
|
$result = [pscustomobject]@{
|
|
serial_number = $bios.SerialNumber
|
|
manufacturer = $cs.Manufacturer
|
|
model = $cs.Model
|
|
domain = $cs.Domain
|
|
os_distro = $os.Caption
|
|
os_release = $os_release
|
|
cpu_model = $first_cpu.Name
|
|
cpu_speed_ghz = if ($first_cpu.MaxClockSpeed) { [math]::Round([double]$first_cpu.MaxClockSpeed / 1000, 2) } else { 0 }
|
|
cpu_cores_physical = $total_phys_cores
|
|
cpu_cores_logical = $total_log_cores
|
|
ram_gb = if ($cs.TotalPhysicalMemory) { [math]::Round([double]$cs.TotalPhysicalMemory / 1GB, 1) } else { 0 }
|
|
disks = $disks
|
|
bitlocker_recovery_key = $bl_key
|
|
network_interfaces = $nics
|
|
public_ip = $public_ip
|
|
installed_software = $installed_software
|
|
}
|
|
$result | ConvertTo-Json -Compress -Depth 6
|
|
"#;
|
|
|
|
/// Collect the inventory and return it as a JSON string. Empty string on
|
|
/// any failure — the caller treats that as "skip this upload's
|
|
/// `inventory` field" rather than uploading garbage.
|
|
#[cfg(target_os = "windows")]
|
|
pub fn collect_inventory() -> String {
|
|
use std::os::windows::process::CommandExt;
|
|
use std::process::Command;
|
|
// CREATE_NO_WINDOW prevents a brief PowerShell console flash if the
|
|
// agent is ever run interactively (dev mode). In service mode there's
|
|
// no console anyway, but the flag is harmless.
|
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
|
|
|
let output = match Command::new("powershell.exe")
|
|
.args([
|
|
"-NoProfile",
|
|
"-NonInteractive",
|
|
"-ExecutionPolicy",
|
|
"Bypass",
|
|
"-Command",
|
|
PS_SCRIPT,
|
|
])
|
|
.creation_flags(CREATE_NO_WINDOW)
|
|
.output()
|
|
{
|
|
Ok(o) => o,
|
|
Err(e) => {
|
|
log::warn!("inventory: powershell failed to spawn: {e}");
|
|
return String::new();
|
|
}
|
|
};
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
log::warn!(
|
|
"inventory: powershell exited non-zero ({:?}): {}",
|
|
output.status.code(),
|
|
stderr.trim()
|
|
);
|
|
return String::new();
|
|
}
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let trimmed = stdout.trim();
|
|
if trimmed.is_empty() {
|
|
log::warn!("inventory: powershell produced empty stdout");
|
|
return String::new();
|
|
}
|
|
|
|
// Parse the PowerShell-produced object so we can merge in the
|
|
// native Wi-Fi data below. Any parse failure aborts the whole
|
|
// collection — sync.rs would otherwise have to reject malformed
|
|
// payloads mid-upload and we'd carry the bad data until the next
|
|
// collect.
|
|
let mut value: serde_json::Value = match serde_json::from_str(trimmed) {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
log::warn!(
|
|
"inventory: powershell output is not valid JSON: {e}; first 200 chars: {:.200}",
|
|
trimmed
|
|
);
|
|
return String::new();
|
|
}
|
|
};
|
|
|
|
// Native Wi-Fi via wlanapi.dll. Emits SSIDs as bytes and auth/cipher
|
|
// as numeric enums, so the result is locale-stable across the
|
|
// en/de/es/ro Windows builds in our fleet — replaces the
|
|
// previously-localized netsh parser.
|
|
let (wifi_current, wifi_nearby) = crate::wifi_native::collect();
|
|
if let Some(c) = wifi_current {
|
|
value["wifi_current"] = c;
|
|
}
|
|
value["wifi_nearby"] = serde_json::Value::Array(wifi_nearby);
|
|
|
|
let serialized = value.to_string();
|
|
log::info!("inventory: collected ({} bytes)", serialized.len());
|
|
serialized
|
|
}
|
|
|
|
#[cfg(not(target_os = "windows"))]
|
|
pub fn collect_inventory() -> String {
|
|
String::new()
|
|
}
|