Files
hello-agent/src/inventory.rs
T
mike fb00ac1101
build-windows / build-hello-agent-x64 (push) Successful in 5m20s
build-windows / sign-hello-agent-x64 (push) Successful in 5s
build-windows / validate-hello-agent-x64 (push) Successful in 6s
Implement software inventory
2026-05-21 23:55:20 +02:00

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()
}