Files
hello-agent/src/wifi_native.rs
T
2026-05-09 00:59:34 +02:00

279 lines
11 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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",
}
}