343 lines
13 KiB
Rust
343 lines
13 KiB
Rust
//! Native `wlanapi.dll` BSS-list FFI — the real Tier 2 scan path.
|
|
//!
|
|
//! This module replaces the `netsh.exe` subprocess (one `CreateProcess`
|
|
//! per scan, ~2 Hz) with direct calls into the Windows WLAN service:
|
|
//!
|
|
//! - [`WlanOpenHandle`] — open a client session to the WLAN service.
|
|
//! - [`WlanEnumInterfaces`] — enumerate the WLAN adapters.
|
|
//! - [`WlanGetNetworkBssList`] — pull the cached BSS entries (per-BSSID
|
|
//! `lRssi`, `ulChCenterFrequency`, `dot11BssPhyType`, SSID) for one
|
|
//! interface, with **no** fresh-scan round-trip on the read path.
|
|
//! - [`WlanFreeMemory`] / [`WlanCloseHandle`] — release the returned
|
|
//! list and the session handle.
|
|
//!
|
|
//! `WlanGetNetworkBssList` reads the driver's *already-maintained* BSS
|
|
//! cache, so back-to-back reads are bounded by the WLAN service IPC, not
|
|
//! by an active-scan dwell. Calling [`scan_native`] in a loop polls that
|
|
//! cache; the driver refreshes it in the background. That is what makes
|
|
//! a >2 Hz observation rate possible — see `WlanApiScanner::benchmark`.
|
|
//!
|
|
//! # Platform gating (honest, not faked)
|
|
//!
|
|
//! The real FFI is only compiled and linked on `#[cfg(windows)]`. On
|
|
//! every other platform [`scan_native`] returns
|
|
//! [`WifiScanError::Unsupported`] — it never fabricates observations.
|
|
//!
|
|
//! # Safety
|
|
//!
|
|
//! All `unsafe` is confined to this module (the crate is otherwise
|
|
//! `unsafe_code = "deny"`). Each raw pointer returned by the WLAN API is
|
|
//! null-checked before deref, every list is iterated within its
|
|
//! driver-reported `dwNumberOfItems`, and every allocation the API hands
|
|
//! back is released with `WlanFreeMemory` before return (including on the
|
|
//! error paths).
|
|
|
|
use std::time::Instant;
|
|
|
|
use crate::domain::bssid::{BandType, BssidId, BssidObservation, RadioType};
|
|
use crate::error::WifiScanError;
|
|
|
|
/// Map a center frequency in kHz to an 802.11 channel number.
|
|
///
|
|
/// Covers 2.4 GHz (ch 1-14), 5 GHz (ch 36-177) and 6 GHz (Wi-Fi 6E).
|
|
/// Shared by the native path and unit tests; returns 0 for unknown
|
|
/// frequencies so the caller can fall back to band-only classification.
|
|
#[allow(clippy::cast_possible_truncation)] // channel numbers always fit u8
|
|
pub(crate) fn freq_khz_to_channel(frequency_khz: u32) -> u8 {
|
|
let mhz = frequency_khz / 1000;
|
|
match mhz {
|
|
2412..=2472 => ((mhz - 2407) / 5) as u8,
|
|
2484 => 14,
|
|
5170..=5825 => ((mhz - 5000) / 5) as u8,
|
|
5955..=7115 => ((mhz - 5950) / 5) as u8,
|
|
_ => 0,
|
|
}
|
|
}
|
|
|
|
/// Map a center frequency in kHz to a [`BandType`].
|
|
pub(crate) fn freq_khz_to_band(frequency_khz: u32) -> BandType {
|
|
let mhz = frequency_khz / 1000;
|
|
match mhz {
|
|
5000..=5900 => BandType::Band5GHz,
|
|
5925..=7200 => BandType::Band6GHz,
|
|
_ => BandType::Band2_4GHz,
|
|
}
|
|
}
|
|
|
|
/// Map a `DOT11_PHY_TYPE` discriminant to our [`RadioType`].
|
|
///
|
|
/// Values per `windows_sys` (`dot11_phy_type_*`): ht=7 → n, vht=8 → ac,
|
|
/// he=10 → ax, eht=11 → be. Anything older (erp/ofdm/dsss) is treated as
|
|
/// 802.11n for downstream purposes since this crate targets HT-or-newer
|
|
/// CSI-capable APs; `None` is never returned because callers need a
|
|
/// concrete radio type for the observation.
|
|
pub(crate) fn phy_type_to_radio(phy: i32) -> RadioType {
|
|
match phy {
|
|
11 => RadioType::Be, // dot11_phy_type_eht
|
|
10 => RadioType::Ax, // dot11_phy_type_he
|
|
8 => RadioType::Ac, // dot11_phy_type_vht
|
|
_ => RadioType::N, // dot11_phy_type_ht and legacy/erp/ofdm
|
|
}
|
|
}
|
|
|
|
/// Whether a radio type advertises a sounding-capable PHY (HT/VHT/HE/EHT)
|
|
/// and is therefore a candidate CSI source. All four 802.11 generations
|
|
/// we model expose channel-sounding, so this is `true` for every
|
|
/// [`RadioType`] — it exists so callers can filter once legacy
|
|
/// (non-HT) APs start appearing in the list with a future `RadioType`.
|
|
pub(crate) fn is_csi_capable(_radio: RadioType) -> bool {
|
|
true
|
|
}
|
|
|
|
/// Perform one native BSS-list read across all WLAN interfaces.
|
|
///
|
|
/// Returns every cached BSS entry as a [`BssidObservation`] with real
|
|
/// RSSI (dBm), channel/band derived from `ulChCenterFrequency`, and radio
|
|
/// type from `dot11BssPhyType`. `timestamp` is stamped at read time.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// - [`WifiScanError::Unsupported`] on non-Windows targets.
|
|
/// - [`WifiScanError::ScanFailed`] if a WLAN API call returns a non-zero
|
|
/// Win32 error code or yields no usable interface.
|
|
#[cfg(windows)]
|
|
#[allow(unsafe_code)]
|
|
pub(crate) fn scan_native() -> Result<Vec<BssidObservation>, WifiScanError> {
|
|
use std::ptr;
|
|
use windows_sys::Win32::NetworkManagement::WiFi::{
|
|
dot11_BSS_type_any, WlanCloseHandle, WlanEnumInterfaces, WlanFreeMemory,
|
|
WlanGetNetworkBssList, WlanOpenHandle, WLAN_BSS_LIST, WLAN_INTERFACE_INFO_LIST,
|
|
};
|
|
|
|
const WLAN_CLIENT_VERSION_2: u32 = 2;
|
|
|
|
// 1) Open a session handle to the WLAN service.
|
|
let mut negotiated: u32 = 0;
|
|
let mut handle: windows_sys::Win32::Foundation::HANDLE = ptr::null_mut();
|
|
// SAFETY: out-params are valid local addresses; `preserved` must be null.
|
|
let rc = unsafe {
|
|
WlanOpenHandle(
|
|
WLAN_CLIENT_VERSION_2,
|
|
ptr::null(),
|
|
&mut negotiated,
|
|
&mut handle,
|
|
)
|
|
};
|
|
if rc != 0 {
|
|
return Err(WifiScanError::ScanFailed {
|
|
reason: format!("WlanOpenHandle failed (Win32 error {rc})"),
|
|
});
|
|
}
|
|
|
|
// Guard so the handle is always closed, even on early return.
|
|
let result = (|| -> Result<Vec<BssidObservation>, WifiScanError> {
|
|
// 2) Enumerate WLAN interfaces.
|
|
let mut iface_list: *mut WLAN_INTERFACE_INFO_LIST = ptr::null_mut();
|
|
// SAFETY: `handle` is a live WLAN session; out-ptr is a local address.
|
|
let rc = unsafe { WlanEnumInterfaces(handle, ptr::null(), &mut iface_list) };
|
|
if rc != 0 || iface_list.is_null() {
|
|
return Err(WifiScanError::ScanFailed {
|
|
reason: format!("WlanEnumInterfaces failed (Win32 error {rc})"),
|
|
});
|
|
}
|
|
|
|
let now = Instant::now();
|
|
let mut observations = Vec::new();
|
|
|
|
// SAFETY: `iface_list` is non-null and points at a driver-allocated
|
|
// WLAN_INTERFACE_INFO_LIST; `dwNumberOfItems` bounds the trailing
|
|
// flexible array `InterfaceInfo`.
|
|
let n_ifaces = unsafe { (*iface_list).dwNumberOfItems } as usize;
|
|
let iface_base = unsafe { ptr::addr_of!((*iface_list).InterfaceInfo).cast::<
|
|
windows_sys::Win32::NetworkManagement::WiFi::WLAN_INTERFACE_INFO,
|
|
>() };
|
|
|
|
for i in 0..n_ifaces {
|
|
// SAFETY: `i < dwNumberOfItems`, so this element is in-bounds.
|
|
let iface = unsafe { &*iface_base.add(i) };
|
|
let guid = iface.InterfaceGuid;
|
|
|
|
// 3) Read the cached BSS list for this interface (no SSID
|
|
// filter, any BSS type, security flag ignored).
|
|
let mut bss_list: *mut WLAN_BSS_LIST = ptr::null_mut();
|
|
// SAFETY: `handle` is live; `&guid` is a valid GUID; null SSID
|
|
// means "all networks"; out-ptr is a local address.
|
|
let rc = unsafe {
|
|
WlanGetNetworkBssList(
|
|
handle,
|
|
&guid,
|
|
ptr::null(),
|
|
dot11_BSS_type_any,
|
|
0, // bSecurityEnabled = FALSE → include open + secured
|
|
ptr::null(),
|
|
&mut bss_list,
|
|
)
|
|
};
|
|
if rc != 0 || bss_list.is_null() {
|
|
// Interface may be down / mid-reset; skip it rather than
|
|
// failing the whole scan.
|
|
continue;
|
|
}
|
|
|
|
// SAFETY: non-null driver-allocated list; `dwNumberOfItems`
|
|
// bounds the trailing `wlanBssEntries` flexible array.
|
|
let n_bss = unsafe { (*bss_list).dwNumberOfItems } as usize;
|
|
let bss_base = unsafe {
|
|
ptr::addr_of!((*bss_list).wlanBssEntries).cast::<
|
|
windows_sys::Win32::NetworkManagement::WiFi::WLAN_BSS_ENTRY,
|
|
>()
|
|
};
|
|
|
|
for b in 0..n_bss {
|
|
// SAFETY: `b < dwNumberOfItems`, element is in-bounds.
|
|
let entry = unsafe { &*bss_base.add(b) };
|
|
|
|
let bssid = BssidId(entry.dot11Bssid);
|
|
let rssi_dbm = f64::from(entry.lRssi);
|
|
let signal_pct = ((rssi_dbm + 100.0) * 2.0).clamp(0.0, 100.0);
|
|
let channel = freq_khz_to_channel(entry.ulChCenterFrequency);
|
|
let band = freq_khz_to_band(entry.ulChCenterFrequency);
|
|
let radio_type = phy_type_to_radio(entry.dot11BssPhyType);
|
|
|
|
// SSID: `ucSSID[..uSSIDLength]`, may be non-UTF8 → lossy.
|
|
let ssid_len = (entry.dot11Ssid.uSSIDLength as usize).min(32);
|
|
let ssid = String::from_utf8_lossy(&entry.dot11Ssid.ucSSID[..ssid_len])
|
|
.trim_end_matches('\0')
|
|
.to_string();
|
|
|
|
observations.push(BssidObservation {
|
|
bssid,
|
|
rssi_dbm,
|
|
signal_pct,
|
|
channel,
|
|
band,
|
|
radio_type,
|
|
ssid,
|
|
timestamp: now,
|
|
});
|
|
}
|
|
|
|
// 5a) Release the per-interface BSS list.
|
|
// SAFETY: `bss_list` was allocated by the WLAN API and is not
|
|
// used after this call.
|
|
unsafe { WlanFreeMemory(bss_list.cast()) };
|
|
}
|
|
|
|
// 5b) Release the interface list.
|
|
// SAFETY: `iface_list` was allocated by the WLAN API; not used after.
|
|
unsafe { WlanFreeMemory(iface_list.cast()) };
|
|
|
|
Ok(observations)
|
|
})();
|
|
|
|
// 6) Always close the session handle.
|
|
// SAFETY: `handle` is a live WLAN session handle obtained above and not
|
|
// used after this call.
|
|
unsafe { WlanCloseHandle(handle, ptr::null()) };
|
|
|
|
result
|
|
}
|
|
|
|
/// Non-Windows fallback: the native `wlanapi.dll` path does not exist, so
|
|
/// this returns a typed [`WifiScanError::Unsupported`] rather than
|
|
/// fabricating data.
|
|
#[cfg(not(windows))]
|
|
pub(crate) fn scan_native() -> Result<Vec<BssidObservation>, WifiScanError> {
|
|
Err(WifiScanError::Unsupported(
|
|
"native wlanapi.dll scan is only available on Windows; \
|
|
use the netsh fallback or a platform adapter"
|
|
.to_string(),
|
|
))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn freq_to_channel_2_4ghz() {
|
|
assert_eq!(freq_khz_to_channel(2_412_000), 1);
|
|
assert_eq!(freq_khz_to_channel(2_437_000), 6);
|
|
assert_eq!(freq_khz_to_channel(2_462_000), 11);
|
|
assert_eq!(freq_khz_to_channel(2_484_000), 14);
|
|
}
|
|
|
|
#[test]
|
|
fn freq_to_channel_5ghz() {
|
|
assert_eq!(freq_khz_to_channel(5_180_000), 36);
|
|
assert_eq!(freq_khz_to_channel(5_745_000), 149);
|
|
}
|
|
|
|
#[test]
|
|
fn freq_to_channel_6ghz() {
|
|
assert_eq!(freq_khz_to_channel(5_955_000), 1);
|
|
assert_eq!(freq_khz_to_channel(5_975_000), 5);
|
|
}
|
|
|
|
#[test]
|
|
fn freq_to_channel_unknown_is_zero() {
|
|
assert_eq!(freq_khz_to_channel(900_000), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn freq_to_band_classification() {
|
|
assert_eq!(freq_khz_to_band(2_437_000), BandType::Band2_4GHz);
|
|
assert_eq!(freq_khz_to_band(5_180_000), BandType::Band5GHz);
|
|
assert_eq!(freq_khz_to_band(5_975_000), BandType::Band6GHz);
|
|
}
|
|
|
|
#[test]
|
|
fn phy_type_maps_to_radio() {
|
|
assert_eq!(phy_type_to_radio(7), RadioType::N); // ht
|
|
assert_eq!(phy_type_to_radio(8), RadioType::Ac); // vht
|
|
assert_eq!(phy_type_to_radio(10), RadioType::Ax); // he
|
|
assert_eq!(phy_type_to_radio(11), RadioType::Be); // eht
|
|
assert_eq!(phy_type_to_radio(4), RadioType::N); // ofdm → n
|
|
}
|
|
|
|
#[test]
|
|
fn csi_capable_for_all_modeled_radios() {
|
|
for r in [RadioType::N, RadioType::Ac, RadioType::Ax, RadioType::Be] {
|
|
assert!(is_csi_capable(r));
|
|
}
|
|
}
|
|
|
|
/// On non-Windows targets the native path must be an honest typed
|
|
/// `Unsupported`, never a fabricated list.
|
|
#[cfg(not(windows))]
|
|
#[test]
|
|
fn native_scan_unsupported_off_windows() {
|
|
match scan_native() {
|
|
Err(WifiScanError::Unsupported(_)) => {}
|
|
other => panic!("expected Unsupported off-Windows, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// On Windows the native path must execute the real FFI and return a
|
|
/// `Vec` (possibly empty if the BSS cache is cold) — never an error
|
|
/// from the happy path on a machine with a WLAN interface. We accept
|
|
/// either Ok (real adapter present) or a ScanFailed (CI box with the
|
|
/// WLAN service disabled), but it must NOT be Unsupported on Windows.
|
|
#[cfg(windows)]
|
|
#[test]
|
|
fn native_scan_runs_real_ffi_on_windows() {
|
|
match scan_native() {
|
|
Ok(list) => {
|
|
// Real entries (if any) must have plausible RSSI.
|
|
for obs in &list {
|
|
assert!(
|
|
obs.rssi_dbm <= 0.0 && obs.rssi_dbm >= -120.0,
|
|
"implausible RSSI from native FFI: {}",
|
|
obs.rssi_dbm
|
|
);
|
|
}
|
|
}
|
|
Err(WifiScanError::ScanFailed { .. }) => { /* WLAN service off — acceptable in CI */ }
|
|
Err(WifiScanError::Unsupported(_)) => {
|
|
panic!("native path must not report Unsupported on Windows")
|
|
}
|
|
Err(e) => panic!("unexpected native scan error: {e:?}"),
|
|
}
|
|
}
|
|
}
|