Implement asset inventory
This commit is contained in:
Generated
+2
-1
@@ -3197,13 +3197,14 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hello-agent"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"env_logger 0.10.2",
|
||||
"hbb_common",
|
||||
"log",
|
||||
"rustdesk",
|
||||
"serde_json 1.0.118",
|
||||
"tokio",
|
||||
"winapi",
|
||||
"windows-service",
|
||||
|
||||
+8
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "hello-agent"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
edition = "2021"
|
||||
rust-version = "1.75"
|
||||
description = "Headless RustDesk-protocol-compatible support agent for Windows"
|
||||
@@ -28,10 +28,16 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync",
|
||||
log = "0.4"
|
||||
env_logger = "0.10"
|
||||
anyhow = "1"
|
||||
# Used by `inventory.rs` to validate the PowerShell-produced JSON before
|
||||
# we stamp it into the sysinfo upload. hbb_common already pulls serde_json
|
||||
# transitively, so this is a free re-export — listed explicitly here so
|
||||
# the inventory module's `use serde_json` doesn't depend on internal
|
||||
# implementation details of hbb_common.
|
||||
serde_json = "1"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows-service = "0.6"
|
||||
winapi = { version = "0.3", features = ["winuser", "wtsapi32", "processthreadsapi", "synchapi", "handleapi", "winbase"] }
|
||||
winapi = { version = "0.3", features = ["winuser", "wtsapi32", "processthreadsapi", "synchapi", "handleapi", "winbase", "wlanapi", "wlantypes"] }
|
||||
winreg = "0.11"
|
||||
|
||||
# Embed the icon and EXE metadata via the Windows resource compiler.
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
//! 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; the sysinfo loop's hash-compare suppresses re-uploads of
|
||||
//! unchanged data, so this isn't repeated on every 120 s tick.
|
||||
//!
|
||||
//! 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()
|
||||
}
|
||||
+19
@@ -23,6 +23,7 @@
|
||||
|
||||
mod cli;
|
||||
mod config_import;
|
||||
mod inventory;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod cm_popup;
|
||||
@@ -30,6 +31,8 @@ mod cm_popup;
|
||||
mod service;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod unattended_password;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod wifi_native;
|
||||
|
||||
use cli::{Action, ParsedArgs};
|
||||
|
||||
@@ -206,6 +209,22 @@ fn run_server() {
|
||||
),
|
||||
}
|
||||
|
||||
// Kick off CMDB inventory collection on a background thread before
|
||||
// start_server boots. PowerShell's first-run cost (a few hundred ms
|
||||
// to a few seconds) shouldn't delay the rendezvous heartbeat — the
|
||||
// sysinfo upload loop only fires every TIME_CONN seconds, so the
|
||||
// inventory will be ready in time for the very first /api/sysinfo
|
||||
// POST whether collection finishes in 50ms or 5s. We deliberately
|
||||
// don't retry on failure: a transient PowerShell hiccup leaves the
|
||||
// INVENTORY global empty, and sync.rs simply omits the `inventory`
|
||||
// key from the upload. Next agent restart re-tries.
|
||||
std::thread::spawn(|| {
|
||||
let inv = inventory::collect_inventory();
|
||||
if !inv.is_empty() {
|
||||
*hbb_common::config::INVENTORY.write().unwrap() = inv;
|
||||
}
|
||||
});
|
||||
|
||||
// `start_server` is `#[tokio::main]` and runs forever. (is_server=true,
|
||||
// no_server=false). It boots the default IPC server, input service,
|
||||
// rendezvous mediator, and heartbeat sync.
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
//! Locale-independent Wi-Fi inventory via the Win32 Native Wi-Fi API
|
||||
//! (`wlanapi.dll`).
|
||||
//!
|
||||
//! Replaces the previous `netsh wlan show …` text-parsing approach.
|
||||
//! `netsh` partially localizes its output — labels like
|
||||
//! `Authentication` / `Authentifizierung` / `Autenticación` / `Autentificare`
|
||||
//! shift with the user's display language, and our regexes silently
|
||||
//! dropped fields on non-English Windows. The fleet runs four
|
||||
//! languages (en, de, es, ro), so we move to the structured API:
|
||||
//!
|
||||
//! * SSIDs come back as raw `UCHAR[32]` bytes — no localization
|
||||
//! possible.
|
||||
//! * Authentication comes back as a `DOT11_AUTH_ALGORITHM` enum
|
||||
//! (a small integer); we map to our own English labels.
|
||||
//! * Cipher / signal quality are likewise plain enums / integers.
|
||||
//!
|
||||
//! Returned shape (consumed by `inventory::collect_inventory` and
|
||||
//! merged into the sysinfo upload):
|
||||
//!
|
||||
//! ```text
|
||||
//! wifi_current: { ssid, bssid, signal_pct, rssi_dbm, rx_kbps, tx_kbps,
|
||||
//! auth, cipher } | None
|
||||
//! wifi_nearby: [ { ssid, signal_pct, auth, cipher }, … up to 30 ]
|
||||
//! ```
|
||||
//!
|
||||
//! All allocations from the API (handle, interface list, network list,
|
||||
//! query-interface buffer) are freed in this module — no caller cleanup.
|
||||
//! Every failure path returns best-effort partial data; we never panic.
|
||||
//!
|
||||
//! We deliberately do **not** trigger a fresh scan via `WlanScan`. The
|
||||
//! OS scans periodically on its own, and the cached BSS list is usually
|
||||
//! < 60 s old. Triggering a scan would either delay startup by ~5 s
|
||||
//! waiting for `wlan_notification_acm_scan_complete`, or return stale
|
||||
//! data anyway if we don't wait. The next inventory cycle (≤ 120 s)
|
||||
//! picks up any change.
|
||||
|
||||
#![cfg(target_os = "windows")]
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::ptr;
|
||||
use std::slice;
|
||||
|
||||
// IMPORTANT: use winapi's `HANDLE` / `PVOID` (which under the hood wrap
|
||||
// `winapi::ctypes::c_void`) rather than `std::ffi::c_void`. Rust treats
|
||||
// the two void types as distinct even though they're the same in C —
|
||||
// mixing them produces opaque "expected `winapi::ctypes::c_void`, found
|
||||
// `std::ffi::c_void`" errors. macOS hosts can't catch this because
|
||||
// winapi is gated to Windows targets, so Cargo never typechecks this
|
||||
// file off Windows.
|
||||
use winapi::shared::minwindef::DWORD;
|
||||
use winapi::shared::ntdef::{HANDLE, PVOID};
|
||||
use winapi::shared::winerror::ERROR_SUCCESS;
|
||||
use winapi::shared::wlantypes::DOT11_SSID;
|
||||
use winapi::um::wlanapi::{
|
||||
wlan_intf_opcode_current_connection, wlan_interface_state_connected, WlanCloseHandle,
|
||||
WlanEnumInterfaces, WlanFreeMemory, WlanGetAvailableNetworkList, WlanOpenHandle,
|
||||
WlanQueryInterface, WLAN_AVAILABLE_NETWORK_LIST, WLAN_CONNECTION_ATTRIBUTES,
|
||||
WLAN_INTERFACE_INFO_LIST,
|
||||
};
|
||||
|
||||
/// Vista+ client version. Win10/Win11 happily negotiate down via
|
||||
/// `negotiated_version`. We never need v1 (XP-era).
|
||||
const CLIENT_VERSION_VISTA: DWORD = 2;
|
||||
|
||||
/// Public entry point. Returns `(current_connection, nearby_list)`.
|
||||
/// On hosts without a Wi-Fi adapter, without the WLAN AutoConfig
|
||||
/// service, or on any FFI failure → `(None, vec![])`.
|
||||
pub fn collect() -> (Option<Value>, Vec<Value>) {
|
||||
// SAFETY: every raw-pointer dereference and array read below is
|
||||
// bounds-checked against the `dwNumberOfItems` field of the parent
|
||||
// list, and the API contract is that those fields match the
|
||||
// allocated array length. All allocations are paired with
|
||||
// `WlanFreeMemory` before this function returns.
|
||||
unsafe { collect_inner() }
|
||||
}
|
||||
|
||||
unsafe fn collect_inner() -> (Option<Value>, Vec<Value>) {
|
||||
let mut handle: HANDLE = ptr::null_mut();
|
||||
let mut neg_ver: DWORD = 0;
|
||||
if WlanOpenHandle(
|
||||
CLIENT_VERSION_VISTA,
|
||||
ptr::null_mut(),
|
||||
&mut neg_ver,
|
||||
&mut handle,
|
||||
) != ERROR_SUCCESS as DWORD
|
||||
{
|
||||
return (None, Vec::new());
|
||||
}
|
||||
|
||||
let mut current: Option<Value> = None;
|
||||
// Dedupe nearby by SSID, keeping the strongest-quality entry per
|
||||
// network. WlanGetAvailableNetworkList already collapses BSSIDs to
|
||||
// one row per (SSID, BSS-type), but if multiple Wi-Fi NICs are
|
||||
// present we'd see the same SSID twice; this keeps the louder copy.
|
||||
let mut nearby_by_ssid: HashMap<String, Value> = HashMap::new();
|
||||
|
||||
let mut iface_list: *mut WLAN_INTERFACE_INFO_LIST = ptr::null_mut();
|
||||
if WlanEnumInterfaces(handle, ptr::null_mut(), &mut iface_list) == ERROR_SUCCESS as DWORD
|
||||
&& !iface_list.is_null()
|
||||
{
|
||||
let count = (*iface_list).dwNumberOfItems as usize;
|
||||
if count > 0 {
|
||||
let ifaces = slice::from_raw_parts((*iface_list).InterfaceInfo.as_ptr(), count);
|
||||
for iface in ifaces {
|
||||
read_iface(handle, iface, &mut current, &mut nearby_by_ssid);
|
||||
}
|
||||
}
|
||||
WlanFreeMemory(iface_list as *mut _);
|
||||
}
|
||||
|
||||
WlanCloseHandle(handle, ptr::null_mut());
|
||||
|
||||
let mut nearby: Vec<Value> = nearby_by_ssid.into_values().collect();
|
||||
nearby.sort_by(|a, b| {
|
||||
let bs = b.get("signal_pct").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let as_ = a.get("signal_pct").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
bs.cmp(&as_)
|
||||
});
|
||||
nearby.truncate(30);
|
||||
|
||||
(current, nearby)
|
||||
}
|
||||
|
||||
unsafe fn read_iface(
|
||||
handle: HANDLE,
|
||||
iface: &winapi::um::wlanapi::WLAN_INTERFACE_INFO,
|
||||
current: &mut Option<Value>,
|
||||
nearby_by_ssid: &mut HashMap<String, Value>,
|
||||
) {
|
||||
let guid = iface.InterfaceGuid;
|
||||
|
||||
// Current connection. Querying when the interface isn't connected
|
||||
// returns ERROR_NOT_FOUND — no harm, but skipping the call is
|
||||
// tidier and avoids a noisy ETW event on locked-down endpoints.
|
||||
if iface.isState == wlan_interface_state_connected && current.is_none() {
|
||||
let mut data_size: DWORD = 0;
|
||||
let mut data: PVOID = ptr::null_mut();
|
||||
let mut value_type: u32 = 0;
|
||||
if WlanQueryInterface(
|
||||
handle,
|
||||
&guid,
|
||||
wlan_intf_opcode_current_connection,
|
||||
ptr::null_mut(),
|
||||
&mut data_size,
|
||||
&mut data,
|
||||
&mut value_type,
|
||||
) == ERROR_SUCCESS as DWORD
|
||||
&& !data.is_null()
|
||||
&& data_size as usize >= std::mem::size_of::<WLAN_CONNECTION_ATTRIBUTES>()
|
||||
{
|
||||
let attrs = &*(data as *const WLAN_CONNECTION_ATTRIBUTES);
|
||||
let assoc = &attrs.wlanAssociationAttributes;
|
||||
let sec = &attrs.wlanSecurityAttributes;
|
||||
let ssid = read_ssid(&assoc.dot11Ssid);
|
||||
let bssid = format_mac(&assoc.dot11Bssid);
|
||||
*current = Some(json!({
|
||||
"ssid": ssid,
|
||||
"bssid": bssid,
|
||||
"signal_pct": assoc.wlanSignalQuality,
|
||||
"rssi_dbm": rssi_from_quality(assoc.wlanSignalQuality),
|
||||
"rx_kbps": assoc.ulRxRate,
|
||||
"tx_kbps": assoc.ulTxRate,
|
||||
"auth": auth_label(sec.dot11AuthAlgorithm as u32),
|
||||
"cipher": cipher_label(sec.dot11CipherAlgorithm as u32),
|
||||
}));
|
||||
WlanFreeMemory(data);
|
||||
}
|
||||
}
|
||||
|
||||
// Available networks (cached scan).
|
||||
let mut net_list: *mut WLAN_AVAILABLE_NETWORK_LIST = ptr::null_mut();
|
||||
if WlanGetAvailableNetworkList(handle, &guid, 0, ptr::null_mut(), &mut net_list)
|
||||
== ERROR_SUCCESS as DWORD
|
||||
&& !net_list.is_null()
|
||||
{
|
||||
let n = (*net_list).dwNumberOfItems as usize;
|
||||
if n > 0 {
|
||||
let nets = slice::from_raw_parts((*net_list).Network.as_ptr(), n);
|
||||
for net in nets {
|
||||
let ssid = read_ssid(&net.dot11Ssid);
|
||||
if ssid.is_empty() {
|
||||
// Hidden networks broadcast empty SSIDs in beacons —
|
||||
// skip rather than emit `{ ssid: "" }` rows that
|
||||
// collapse together in the dedupe map.
|
||||
continue;
|
||||
}
|
||||
let entry_signal = net.wlanSignalQuality;
|
||||
let prev_signal = nearby_by_ssid
|
||||
.get(&ssid)
|
||||
.and_then(|v| v.get("signal_pct"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
if (entry_signal as u64) >= prev_signal {
|
||||
nearby_by_ssid.insert(
|
||||
ssid.clone(),
|
||||
json!({
|
||||
"ssid": ssid,
|
||||
"signal_pct": entry_signal,
|
||||
"auth": auth_label(net.dot11DefaultAuthAlgorithm as u32),
|
||||
"cipher": cipher_label(net.dot11DefaultCipherAlgorithm as u32),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
WlanFreeMemory(net_list as *mut _);
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a `DOT11_SSID` (length-prefixed UCHAR[32]) into a Rust String.
|
||||
/// SSIDs are nominally any byte sequence ≤ 32 octets; UTF-8 is by far
|
||||
/// the most common (also the IEEE recommendation), but Latin-1 and
|
||||
/// random bytes occur. We use lossy UTF-8 decoding so weird encodings
|
||||
/// don't yield panics or empty strings — the operator can still
|
||||
/// recognize most networks visually.
|
||||
fn read_ssid(s: &DOT11_SSID) -> String {
|
||||
let len = (s.uSSIDLength as usize).min(s.ucSSID.len());
|
||||
String::from_utf8_lossy(&s.ucSSID[..len]).into_owned()
|
||||
}
|
||||
|
||||
fn format_mac(bytes: &[u8; 6]) -> String {
|
||||
format!(
|
||||
"{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}",
|
||||
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5]
|
||||
)
|
||||
}
|
||||
|
||||
/// MS docs: WLAN_SIGNAL_QUALITY 0 ↔ −100 dBm, 100 ↔ −50 dBm, linear
|
||||
/// interpolation in between. `quality / 2 - 100`.
|
||||
fn rssi_from_quality(q: u32) -> i32 {
|
||||
-100 + (q.min(100) as i32 / 2)
|
||||
}
|
||||
|
||||
/// Human label for a `DOT11_AUTH_ALGORITHM` value. Matched on raw
|
||||
/// integers because winapi 0.3 was tagged before WPA3 / OWE went into
|
||||
/// the SDK — relying on the named constants would force a dep upgrade
|
||||
/// just to recognize WPA3 networks. Numeric values are part of the
|
||||
/// stable Wi-Fi protocol spec, not the SDK.
|
||||
fn auth_label(a: u32) -> &'static str {
|
||||
match a {
|
||||
1 => "Open",
|
||||
2 => "Shared",
|
||||
3 => "WPA-Enterprise",
|
||||
4 => "WPA-Personal",
|
||||
5 => "WPA-None",
|
||||
6 => "WPA2-Enterprise",
|
||||
7 => "WPA2-Personal",
|
||||
8 => "WPA3-Enterprise (192-bit)",
|
||||
9 => "WPA3-Personal (SAE)",
|
||||
10 => "OWE",
|
||||
11 => "WPA3-Enterprise",
|
||||
_ => "Unknown",
|
||||
}
|
||||
}
|
||||
|
||||
/// Human label for a `DOT11_CIPHER_ALGORITHM` value. Same numeric-match
|
||||
/// rationale as `auth_label`. Values from the IEEE 802.11 / Microsoft
|
||||
/// header (wlantypes.h DOT11_CIPHER_ALGORITHM enum).
|
||||
fn cipher_label(c: u32) -> &'static str {
|
||||
match c {
|
||||
0x00 => "None",
|
||||
0x01 => "WEP-40",
|
||||
0x02 => "TKIP",
|
||||
0x04 => "AES (CCMP)",
|
||||
0x05 => "WEP-104",
|
||||
0x06 => "BIP-CMAC-128",
|
||||
0x08 => "GCMP",
|
||||
0x09 => "GCMP-256",
|
||||
0x0A => "CCMP-256",
|
||||
0x0B => "BIP-GMAC-128",
|
||||
0x0C => "BIP-GMAC-256",
|
||||
0x0D => "BIP-CMAC-256",
|
||||
0x100 => "WPA-Use-Group",
|
||||
0x101 => "WEP",
|
||||
_ => "Unknown",
|
||||
}
|
||||
}
|
||||
+11
@@ -123,6 +123,17 @@ lazy_static::lazy_static! {
|
||||
/// "empty = omit" convention as `AGENT_NAME`. For hello-agent this
|
||||
/// is `env!("CARGO_PKG_VERSION")`.
|
||||
pub static ref AGENT_VERSION: RwLock<String> = RwLock::new(String::new());
|
||||
/// Pre-serialized JSON object describing the host's hardware /
|
||||
/// firmware / OS edition inventory (BIOS serial, manufacturer, model,
|
||||
/// AD domain, OS edition + release, CPU details, RAM, disks,
|
||||
/// BitLocker recovery key). Same "empty = omit" convention as
|
||||
/// `AGENT_NAME`: when non-empty, `hbbs_http::sync` parses it and
|
||||
/// merges it into the sysinfo upload under the `inventory` key, where
|
||||
/// the rustdesk-server admin UI's per-device detail page reads it.
|
||||
/// Stored pre-serialized so the producer (hello-agent's `inventory`
|
||||
/// module) owns the schema and the consumer (sync) doesn't need to
|
||||
/// know the field set.
|
||||
pub static ref INVENTORY: RwLock<String> = RwLock::new(String::new());
|
||||
static ref KEY_PAIR: Mutex<Option<KeyPair>> = Default::default();
|
||||
static ref USER_DEFAULT_CONFIG: RwLock<(UserDefaultConfig, Instant)> = RwLock::new((UserDefaultConfig::load(), Instant::now()));
|
||||
pub static ref NEW_STORED_PEER_CONFIG: Mutex<HashSet<String>> = Default::default();
|
||||
|
||||
+16
@@ -147,6 +147,22 @@ async fn start_hbbs_sync_async() {
|
||||
if !agent_version.is_empty() {
|
||||
v["agent_version"] = json!(agent_version);
|
||||
}
|
||||
// Optional CMDB inventory. Producer (hello-agent's
|
||||
// `inventory` module) populates this with a pre-
|
||||
// serialized JSON object covering BIOS / hardware /
|
||||
// OS-edition fields. We re-parse on each upload (the
|
||||
// string is small — single-digit kB at most) rather
|
||||
// than caching, so a refreshed inventory is picked up
|
||||
// without bookkeeping. Parse failure is silently
|
||||
// dropped: the sysinfo upload still goes out without
|
||||
// the `inventory` key, identical to a vanilla rustdesk
|
||||
// install.
|
||||
let inventory = config::INVENTORY.read().unwrap().clone();
|
||||
if !inventory.is_empty() {
|
||||
if let Ok(inv_v) = serde_json::from_str::<Value>(&inventory) {
|
||||
v["inventory"] = inv_v;
|
||||
}
|
||||
}
|
||||
let ab_name = Config::get_option(keys::OPTION_PRESET_ADDRESS_BOOK_NAME);
|
||||
if !ab_name.is_empty() {
|
||||
v[keys::OPTION_PRESET_ADDRESS_BOOK_NAME] = json!(ab_name);
|
||||
|
||||
Reference in New Issue
Block a user