279 lines
11 KiB
Rust
279 lines
11 KiB
Rust
//! 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",
|
||
}
|
||
}
|