//! 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, Vec) { // 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, Vec) { 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 = 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 = 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 = 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, nearby_by_ssid: &mut HashMap, ) { 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::() { 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", } }