//! 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 {} $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 } $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() }