CI / Format and clippy (push) Successful in 1m13s
CI / Dependency audit (push) Successful in 13s
CI / Tests (Linux) (push) Successful in 1m35s
Release / Create release (push) Successful in 7s
CI / Check desktop app (Linux) (push) Successful in 3m19s
Release / CLI (linux-aarch64) (push) Successful in 1m3s
Release / CLI (linux-x86_64) (push) Successful in 1m28s
Release / Desktop app (linux-aarch64) (push) Successful in 7m16s
Release / Desktop app (linux-x86_64) (push) Successful in 10m17s
Release / Desktop app (windows) (push) Successful in 4m54s
Completes the English-only standardization for code, comments, scripts and CI templates (UI/CLI-facing strings stay bilingual pt-BR/en, untouched). Covers the remaining crates/, apps/gui/ui/ and apps/gui/src-tauri/ comments, .gitea/ workflow and template comments, .githooks/ hooks, and the scripts/ source comments for the files already renamed in a prior commit (check.sh, generate-logo.py, install-hooks.sh). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VAyr8d7HuAdUGB6aA5xmDD
776 lines
28 KiB
Rust
776 lines
28 KiB
Rust
//! Aggregates captured 802.11 frames into access point, client station, and
|
|
//! handshake tables.
|
|
|
|
use crate::dot11::{self, FrameKind, Security};
|
|
use crate::handshake::{Handshake, HandshakeInfo, HandshakeTracker};
|
|
use crate::radiotap::{self, Band};
|
|
use std::collections::{HashMap, VecDeque};
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
#[derive(Debug, Clone, serde::Serialize)]
|
|
pub struct AccessPoint {
|
|
pub bssid: String,
|
|
pub ssid: Option<String>,
|
|
pub hidden: bool,
|
|
pub channel: Option<u32>,
|
|
pub band: Option<Band>,
|
|
pub freq_mhz: Option<u16>,
|
|
pub security: Option<String>,
|
|
pub cipher: Option<String>,
|
|
pub akm: Vec<String>,
|
|
pub pmf: bool,
|
|
pub pmf_required: bool,
|
|
pub wps: bool,
|
|
pub wps_locked: Option<bool>,
|
|
pub wps_version: Option<String>,
|
|
pub wps_manufacturer: Option<String>,
|
|
pub wps_model: Option<String>,
|
|
pub wps_model_number: Option<String>,
|
|
pub wps_device_name: Option<String>,
|
|
pub wps_serial: Option<String>,
|
|
pub vendor: Option<String>,
|
|
pub country: Option<String>,
|
|
pub phy: Vec<String>,
|
|
pub width_mhz: Option<u16>,
|
|
pub beacon_interval: Option<u16>,
|
|
pub privacy: bool,
|
|
pub signal_dbm: Option<i8>,
|
|
pub max_signal_dbm: Option<i8>,
|
|
pub beacons: u64,
|
|
pub data_frames: u64,
|
|
pub clients: Vec<String>,
|
|
pub first_seen_unix: u64,
|
|
pub last_seen_unix: u64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, serde::Serialize)]
|
|
pub struct SignalSample {
|
|
pub t: u64,
|
|
pub dbm: i8,
|
|
}
|
|
|
|
#[derive(Debug, Clone, serde::Serialize)]
|
|
pub struct ClientEvent {
|
|
pub t: u64,
|
|
pub kind: String,
|
|
pub detail: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, serde::Serialize)]
|
|
pub struct Client {
|
|
pub mac: String,
|
|
/// Best human-readable label for a station: DHCP/mDNS hostname, WPS device name, model, or OUI vendor.
|
|
pub display_name: Option<String>,
|
|
pub vendor: Option<String>,
|
|
pub manufacturer: Option<String>,
|
|
pub model: Option<String>,
|
|
pub device_name: Option<String>,
|
|
pub hostname: Option<String>,
|
|
pub ip: Option<String>,
|
|
pub os: Option<String>,
|
|
pub device: Option<String>,
|
|
pub random_mac: bool,
|
|
pub bssid: Option<String>,
|
|
pub signal_dbm: Option<i8>,
|
|
pub max_signal_dbm: Option<i8>,
|
|
pub packets: u64,
|
|
pub probes: Vec<String>,
|
|
pub associated: bool,
|
|
pub first_seen_unix: u64,
|
|
pub last_seen_unix: u64,
|
|
/// Recent RSSI history (oldest first, bounded ring) for sparkline rendering.
|
|
///
|
|
pub signal_history: Vec<SignalSample>,
|
|
/// Per-client event timeline (first seen, probe, association, deauthentication, etc.).
|
|
pub events: Vec<ClientEvent>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Default)]
|
|
pub struct IngestEvent {
|
|
pub eapol: bool,
|
|
pub handshake_updated: bool,
|
|
pub deauth: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, serde::Serialize)]
|
|
pub struct ScanSnapshot {
|
|
pub iface: String,
|
|
pub running: bool,
|
|
pub channel: Option<u32>,
|
|
pub stats: ScanStats,
|
|
pub access_points: Vec<AccessPoint>,
|
|
pub clients: Vec<Client>,
|
|
pub handshakes: Vec<HandshakeInfo>,
|
|
pub analysis: crate::analysis::Analysis,
|
|
/// Populated when the capture loop terminated due to an I/O error (e.g. unplugged
|
|
/// USB adapter).
|
|
///
|
|
pub error: Option<String>,
|
|
/// Populated when capture has been running for >12s across channels with zero
|
|
/// received frames (typical of drivers/firmware that silently ignore monitor mode).
|
|
///
|
|
///
|
|
///
|
|
pub warning: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Default, serde::Serialize)]
|
|
pub struct ScanStats {
|
|
pub frames: u64,
|
|
pub eapol_frames: u64,
|
|
pub deauth_frames: u64,
|
|
pub aps: usize,
|
|
pub clients: usize,
|
|
pub handshakes: usize,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
pub struct ScanTable {
|
|
aps: HashMap<String, AccessPoint>,
|
|
clients: HashMap<String, Client>,
|
|
handshakes: HandshakeTracker,
|
|
frames: u64,
|
|
eapol_frames: u64,
|
|
deauth_frames: u64,
|
|
deauth_times: VecDeque<u64>,
|
|
}
|
|
|
|
impl ScanTable {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
/// Ingests a raw Radiotap + 802.11 frame. Returns `true` if the frame carried
|
|
/// an EAPOL handshake message.
|
|
pub fn ingest(&mut self, raw: &[u8]) -> bool {
|
|
let now = unix_now();
|
|
self.ingest_at(raw, now).eapol
|
|
}
|
|
|
|
pub fn ingest_at(&mut self, raw: &[u8], now: u64) -> IngestEvent {
|
|
let mut event = IngestEvent::default();
|
|
let Some(rt) = radiotap::parse(raw) else {
|
|
return event;
|
|
};
|
|
if rt.header_len >= raw.len() {
|
|
return event;
|
|
}
|
|
let Some(frame) = dot11::parse(&raw[rt.header_len..]) else {
|
|
return event;
|
|
};
|
|
self.frames += 1;
|
|
|
|
match frame.kind {
|
|
FrameKind::Beacon | FrameKind::ProbeResponse => {
|
|
let Some(bssid) = frame.bssid_str() else {
|
|
return event;
|
|
};
|
|
let info = frame.management_info();
|
|
let channel = info
|
|
.channel
|
|
.or_else(|| rt.freq_mhz.and_then(radiotap::freq_to_channel));
|
|
let band = rt.freq_mhz.map(radiotap::band_for_freq);
|
|
let ap = self
|
|
.aps
|
|
.entry(bssid.clone())
|
|
.or_insert_with(|| AccessPoint {
|
|
bssid: bssid.clone(),
|
|
ssid: None,
|
|
hidden: false,
|
|
channel: None,
|
|
band: None,
|
|
freq_mhz: None,
|
|
security: None,
|
|
cipher: None,
|
|
akm: Vec::new(),
|
|
pmf: false,
|
|
pmf_required: false,
|
|
wps: false,
|
|
wps_locked: None,
|
|
wps_version: None,
|
|
wps_manufacturer: None,
|
|
wps_model: None,
|
|
wps_model_number: None,
|
|
wps_device_name: None,
|
|
wps_serial: None,
|
|
vendor: crate::oui::vendor(&bssid).map(str::to_string),
|
|
country: None,
|
|
phy: Vec::new(),
|
|
width_mhz: None,
|
|
beacon_interval: None,
|
|
privacy: false,
|
|
signal_dbm: None,
|
|
max_signal_dbm: None,
|
|
beacons: 0,
|
|
data_frames: 0,
|
|
clients: Vec::new(),
|
|
first_seen_unix: now,
|
|
last_seen_unix: now,
|
|
});
|
|
if let Some(ssid) = info.ssid.clone() {
|
|
if !ssid.is_empty() || ap.ssid.is_none() {
|
|
ap.ssid = Some(ssid);
|
|
}
|
|
ap.hidden = info.hidden;
|
|
}
|
|
if channel.is_some() {
|
|
ap.channel = channel;
|
|
}
|
|
if rt.freq_mhz.is_some() {
|
|
ap.freq_mhz = rt.freq_mhz;
|
|
ap.band = band;
|
|
}
|
|
ap.security = info.security.map(security_label);
|
|
ap.cipher = info.cipher.clone();
|
|
ap.akm = info.akm.clone();
|
|
ap.pmf = info.pmf;
|
|
ap.pmf_required = info.pmf_required;
|
|
ap.wps = info.wps;
|
|
if info.wps {
|
|
ap.wps_locked = info.wps_locked;
|
|
ap.wps_version = info.wps_version.clone();
|
|
ap.wps_manufacturer = info.wps_manufacturer.clone();
|
|
ap.wps_model = info.wps_model.clone();
|
|
ap.wps_model_number = info.wps_model_number.clone();
|
|
ap.wps_device_name = info.wps_device_name.clone();
|
|
ap.wps_serial = info.wps_serial.clone();
|
|
}
|
|
ap.country = info.country.clone().or(ap.country.take());
|
|
if !info.phy.is_empty() {
|
|
ap.phy = info.phy.clone();
|
|
}
|
|
ap.width_mhz = info.width_mhz.or(ap.width_mhz);
|
|
ap.beacon_interval = info.beacon_interval.or(ap.beacon_interval);
|
|
ap.privacy = info.privacy;
|
|
ap.signal_dbm = rt.signal_dbm;
|
|
if let Some(sig) = rt.signal_dbm {
|
|
ap.max_signal_dbm = Some(ap.max_signal_dbm.map_or(sig, |m| m.max(sig)));
|
|
}
|
|
ap.beacons += 1;
|
|
ap.last_seen_unix = now;
|
|
|
|
if let Some(ssid) = ap.ssid.clone().filter(|s| !s.is_empty()) {
|
|
self.handshakes.update_ssid(&bssid, &ssid);
|
|
}
|
|
}
|
|
FrameKind::ProbeRequest => {
|
|
let Some(client) = frame.src_str() else {
|
|
return event;
|
|
};
|
|
let info = frame.management_info();
|
|
let c = self.touch_client(&client, now, rt.signal_dbm);
|
|
c.packets += 1;
|
|
apply_management_identity(c, &info);
|
|
if let Some(ssid) = info.ssid.filter(|s| !s.is_empty()) {
|
|
if !c.probes.contains(&ssid) {
|
|
c.probes.push(ssid.clone());
|
|
c.events.push(ClientEvent {
|
|
t: now,
|
|
kind: "probe".into(),
|
|
detail: Some(ssid),
|
|
});
|
|
trim_events(c);
|
|
}
|
|
}
|
|
refresh_identity(c);
|
|
}
|
|
FrameKind::AssociationRequest => {
|
|
let client = frame.src_str();
|
|
let bssid = frame
|
|
.addr1
|
|
.map(|m| dot11::mac_to_string(&m))
|
|
.or_else(|| frame.bssid_str());
|
|
if let (Some(client), Some(bssid)) = (client, bssid) {
|
|
let info = frame.management_info();
|
|
let c = self.touch_client(&client, now, rt.signal_dbm);
|
|
c.packets += 1;
|
|
apply_management_identity(c, &info);
|
|
refresh_identity(c);
|
|
let was = c.associated;
|
|
c.bssid = Some(bssid.clone());
|
|
c.associated = true;
|
|
if !was {
|
|
c.events.push(ClientEvent {
|
|
t: now,
|
|
kind: "associate".into(),
|
|
detail: Some(bssid.clone()),
|
|
});
|
|
trim_events(c);
|
|
}
|
|
if let Some(ap) = self.aps.get_mut(&bssid) {
|
|
if !ap.clients.contains(&client) {
|
|
ap.clients.push(client);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
FrameKind::Data => {
|
|
let bssid = match (frame.to_ds, frame.from_ds) {
|
|
(true, false) => frame.addr1.map(|m| dot11::mac_to_string(&m)),
|
|
(false, true) => frame.addr2.map(|m| dot11::mac_to_string(&m)),
|
|
_ => frame.bssid_str(),
|
|
};
|
|
let client = match (frame.to_ds, frame.from_ds) {
|
|
(true, false) => frame.src_str(),
|
|
(false, true) => frame.dst_str(),
|
|
_ => frame.src_str(),
|
|
};
|
|
if let (Some(client), Some(bssid)) = (client.clone(), bssid.clone()) {
|
|
if client != bssid {
|
|
// Look up any derived CCMP Temporal Key (TK) before borrowing `self.clients`.
|
|
//
|
|
//
|
|
let tk = (frame.protected && frame.to_ds && !frame.from_ds)
|
|
.then(|| self.handshakes.tk_for(&bssid, &client))
|
|
.flatten();
|
|
let c = self.touch_client(&client, now, rt.signal_dbm);
|
|
c.packets += 1;
|
|
if frame.to_ds && !frame.from_ds {
|
|
if frame.protected {
|
|
// Decrypt CCMP data frames when we have derived this station's association TK.
|
|
//
|
|
//
|
|
if let Some(plain) = tk.and_then(|tk| frame.ccmp_payload(&tk)) {
|
|
let payload = dot11::payload_info_from_llc(&plain);
|
|
apply_payload_identity(c, &payload);
|
|
}
|
|
} else {
|
|
let payload = frame.payload_info();
|
|
apply_payload_identity(c, &payload);
|
|
}
|
|
}
|
|
// Refresh `display_name` on both uplink and downlink frames so OUI vendor
|
|
// fallback is populated even for receive-only stations.
|
|
//
|
|
refresh_identity(c);
|
|
let was = c.associated;
|
|
c.bssid = Some(bssid.clone());
|
|
c.associated = true;
|
|
if !was {
|
|
c.events.push(ClientEvent {
|
|
t: now,
|
|
kind: "associate".into(),
|
|
detail: Some(bssid.clone()),
|
|
});
|
|
trim_events(c);
|
|
}
|
|
if let Some(ap) = self.aps.get_mut(&bssid) {
|
|
ap.data_frames += 1;
|
|
if !ap.clients.contains(&client) {
|
|
ap.clients.push(client.clone());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if frame.eapol().is_some() {
|
|
event.eapol = true;
|
|
self.eapol_frames += 1;
|
|
let hint = bssid
|
|
.as_deref()
|
|
.and_then(|b| self.aps.get(b))
|
|
.and_then(|ap| ap.ssid.clone())
|
|
.filter(|s| !s.is_empty());
|
|
if self.handshakes.ingest(&frame, raw, hint.as_deref(), now) {
|
|
event.handshake_updated = true;
|
|
}
|
|
}
|
|
}
|
|
FrameKind::Deauth | FrameKind::Disassoc => {
|
|
self.deauth_frames += 1;
|
|
self.deauth_times.push_back(now);
|
|
while self
|
|
.deauth_times
|
|
.front()
|
|
.is_some_and(|t| now.saturating_sub(*t) > 60)
|
|
{
|
|
self.deauth_times.pop_front();
|
|
}
|
|
event.deauth = true;
|
|
let a1 = frame.addr1.map(|m| dot11::mac_to_string(&m));
|
|
let a2 = frame.addr2.map(|m| dot11::mac_to_string(&m));
|
|
let bssid = frame.bssid_str();
|
|
let client = match (&a1, &a2, &bssid) {
|
|
(Some(a1), Some(_), Some(b)) if a1 != b => Some(a1.clone()),
|
|
(Some(_), Some(a2), Some(b)) if a2 != b => Some(a2.clone()),
|
|
_ => None,
|
|
};
|
|
if let Some(client) = client {
|
|
if let Some(c) = self.clients.get_mut(&client) {
|
|
if c.associated {
|
|
c.events.push(ClientEvent {
|
|
t: now,
|
|
kind: "deauth".into(),
|
|
detail: bssid.clone(),
|
|
});
|
|
trim_events(c);
|
|
}
|
|
c.associated = false;
|
|
c.last_seen_unix = now;
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
event
|
|
}
|
|
|
|
fn touch_client(&mut self, mac: &str, now: u64, signal: Option<i8>) -> &mut Client {
|
|
let c = self
|
|
.clients
|
|
.entry(mac.to_string())
|
|
.or_insert_with(|| Client {
|
|
mac: mac.to_string(),
|
|
display_name: None,
|
|
vendor: crate::oui::vendor(mac).map(str::to_string),
|
|
manufacturer: None,
|
|
model: None,
|
|
device_name: None,
|
|
hostname: None,
|
|
ip: None,
|
|
os: None,
|
|
device: None,
|
|
random_mac: is_random_mac(mac),
|
|
bssid: None,
|
|
signal_dbm: None,
|
|
max_signal_dbm: None,
|
|
packets: 0,
|
|
probes: Vec::new(),
|
|
associated: false,
|
|
first_seen_unix: now,
|
|
last_seen_unix: now,
|
|
signal_history: Vec::new(),
|
|
events: vec![ClientEvent {
|
|
t: now,
|
|
kind: "first_seen".into(),
|
|
detail: None,
|
|
}],
|
|
});
|
|
c.last_seen_unix = now;
|
|
c.signal_dbm = signal;
|
|
if let Some(sig) = signal {
|
|
c.max_signal_dbm = Some(c.max_signal_dbm.map_or(sig, |m| m.max(sig)));
|
|
let should_push = c
|
|
.signal_history
|
|
.last()
|
|
.map_or(true, |s| s.t != now || s.dbm != sig);
|
|
if should_push {
|
|
c.signal_history.push(SignalSample { t: now, dbm: sig });
|
|
if c.signal_history.len() > 60 {
|
|
let drop = c.signal_history.len() - 60;
|
|
c.signal_history.drain(0..drop);
|
|
}
|
|
}
|
|
}
|
|
// Populate `display_name` from the OUI vendor immediately on first sighting.
|
|
//
|
|
//
|
|
if c.display_name.is_none() {
|
|
refresh_identity(c);
|
|
}
|
|
c
|
|
}
|
|
|
|
pub fn analysis(&self) -> crate::analysis::Analysis {
|
|
let now = unix_now();
|
|
let aps = self.access_points();
|
|
let deauth = self.deauth_times.len() as u32;
|
|
crate::analysis::analyze(&aps, deauth, now)
|
|
}
|
|
|
|
pub fn deauth_60s(&self) -> u32 {
|
|
self.deauth_times.len() as u32
|
|
}
|
|
|
|
pub fn access_points(&self) -> Vec<AccessPoint> {
|
|
let mut v: Vec<_> = self.aps.values().cloned().collect();
|
|
v.sort_by(|a, b| {
|
|
b.beacons
|
|
.cmp(&a.beacons)
|
|
.then_with(|| a.ssid.cmp(&b.ssid))
|
|
.then_with(|| a.bssid.cmp(&b.bssid))
|
|
});
|
|
v
|
|
}
|
|
|
|
pub fn clients(&self) -> Vec<Client> {
|
|
let mut v: Vec<_> = self.clients.values().cloned().collect();
|
|
v.sort_by(|a, b| b.packets.cmp(&a.packets).then_with(|| a.mac.cmp(&b.mac)));
|
|
v
|
|
}
|
|
|
|
pub fn handshakes(&self) -> Vec<HandshakeInfo> {
|
|
self.handshakes.infos()
|
|
}
|
|
|
|
pub fn handshake(&self, bssid: &str, station: &str) -> Option<&Handshake> {
|
|
self.handshakes.get(bssid, station)
|
|
}
|
|
|
|
/// Returns all tracked handshakes (including raw frames for `.pcap`/`.hc22000` export).
|
|
pub fn handshake_entries(&self) -> Vec<Handshake> {
|
|
self.handshakes.all().into_iter().cloned().collect()
|
|
}
|
|
|
|
pub fn first_crackable(
|
|
&self,
|
|
bssid: Option<&str>,
|
|
station: Option<&str>,
|
|
) -> Option<&Handshake> {
|
|
self.handshakes.first_crackable(bssid, station)
|
|
}
|
|
|
|
pub fn set_password(&mut self, bssid: &str, station: &str, password: &str) {
|
|
self.handshakes.set_password(bssid, station, password);
|
|
}
|
|
|
|
pub fn stats(&self) -> ScanStats {
|
|
ScanStats {
|
|
frames: self.frames,
|
|
eapol_frames: self.eapol_frames,
|
|
deauth_frames: self.deauth_frames,
|
|
aps: self.aps.len(),
|
|
clients: self.clients.len(),
|
|
handshakes: self.handshakes.len(),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn is_random_mac(mac: &str) -> bool {
|
|
let Some(first) = mac.split(':').next() else {
|
|
return false;
|
|
};
|
|
u8::from_str_radix(first, 16)
|
|
.map(|b| b & 0x02 != 0 && b & 0x01 == 0)
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn apply_management_identity(c: &mut Client, info: &dot11::ManagementInfo) {
|
|
if let Some(name) = info.wps_device_name.as_deref().filter(|s| !s.is_empty()) {
|
|
c.device_name = Some(name.to_string());
|
|
}
|
|
if let Some(manufacturer) = info.wps_manufacturer.as_deref().filter(|s| !s.is_empty()) {
|
|
c.manufacturer = Some(manufacturer.to_string());
|
|
}
|
|
if let Some(model) = info.wps_model.as_deref().filter(|s| !s.is_empty()) {
|
|
c.model = Some(model.to_string());
|
|
}
|
|
if let Some(hint) = c.device_name.as_deref().or(c.model.as_deref()) {
|
|
if let Some(device) = dot11::device_from_text(hint) {
|
|
c.device = Some(device);
|
|
}
|
|
if let Some(os) = dot11::os_from_text(hint) {
|
|
c.os = Some(os);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn apply_payload_identity(c: &mut Client, info: &dot11::PayloadInfo) {
|
|
if let Some(ip) = &info.ip {
|
|
c.ip = Some(ip.clone());
|
|
}
|
|
if let Some(hostname) = &info.hostname {
|
|
if !hostname.is_empty() {
|
|
c.hostname = Some(hostname.clone());
|
|
}
|
|
}
|
|
if let Some(manufacturer) = &info.manufacturer {
|
|
c.manufacturer = c.manufacturer.take().or_else(|| Some(manufacturer.clone()));
|
|
}
|
|
if let Some(model) = &info.model {
|
|
c.model = Some(model.clone());
|
|
}
|
|
if let Some(os) = &info.os {
|
|
c.os = Some(os.clone());
|
|
}
|
|
if let Some(device) = &info.device {
|
|
c.device = Some(device.clone());
|
|
}
|
|
if let Some(hint) = c.hostname.as_deref().or(c.device_name.as_deref()) {
|
|
if c.os.is_none() {
|
|
c.os = dot11::os_from_text(hint);
|
|
}
|
|
if c.device.is_none() {
|
|
c.device = dot11::device_from_text(hint);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn refresh_identity(c: &mut Client) {
|
|
if c.manufacturer.is_none() {
|
|
c.manufacturer = c.vendor.clone();
|
|
}
|
|
if c.os.is_none() {
|
|
c.os = c
|
|
.hostname
|
|
.as_deref()
|
|
.or(c.device_name.as_deref())
|
|
.or(c.model.as_deref())
|
|
.and_then(dot11::os_from_text);
|
|
}
|
|
if c.device.is_none() {
|
|
c.device = c
|
|
.hostname
|
|
.as_deref()
|
|
.or(c.device_name.as_deref())
|
|
.or(c.model.as_deref())
|
|
.and_then(dot11::device_from_text);
|
|
}
|
|
c.display_name = c
|
|
.hostname
|
|
.clone()
|
|
.or_else(|| c.device_name.clone())
|
|
.or_else(|| c.model.clone())
|
|
.or_else(|| c.manufacturer.clone());
|
|
}
|
|
|
|
fn trim_events(c: &mut Client) {
|
|
const MAX: usize = 40;
|
|
if c.events.len() > MAX {
|
|
let drop = c.events.len() - MAX;
|
|
c.events.drain(0..drop);
|
|
}
|
|
}
|
|
|
|
fn security_label(s: Security) -> String {
|
|
match s {
|
|
Security::Open => "Open".into(),
|
|
Security::Wep => "WEP".into(),
|
|
Security::Wpa => "WPA".into(),
|
|
Security::Wpa2 => "WPA2".into(),
|
|
Security::Wpa2Enterprise => "WPA2-EAP".into(),
|
|
Security::Wpa3 => "WPA3".into(),
|
|
Security::Wpa3Enterprise => "WPA3-EAP".into(),
|
|
Security::Owe => "OWE".into(),
|
|
Security::Wpa2Wpa3 => "WPA2/WPA3".into(),
|
|
}
|
|
}
|
|
|
|
fn unix_now() -> u64 {
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|d| d.as_secs())
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::testutil::{
|
|
ap, beacon_frame, frame_with_eapol, handshake_frames, probe_request, radiotap_wrap, sta,
|
|
};
|
|
|
|
fn beacon_raw(ssid: &str, akms: &[u8]) -> Vec<u8> {
|
|
let frame = beacon_frame(ap(), ssid, 6, akms);
|
|
radiotap_wrap(&frame, 2437, -40)
|
|
}
|
|
|
|
#[test]
|
|
fn builds_ap_table_from_beacon() {
|
|
let mut table = ScanTable::new();
|
|
table.ingest_at(&beacon_raw("Casa", &[2]), 100);
|
|
let aps = table.access_points();
|
|
assert_eq!(aps.len(), 1);
|
|
let ap = &aps[0];
|
|
assert_eq!(ap.ssid.as_deref(), Some("Casa"));
|
|
assert_eq!(ap.bssid, "00:11:22:33:44:55");
|
|
assert_eq!(ap.channel, Some(6));
|
|
assert_eq!(ap.band, Some(Band::B2G4));
|
|
assert_eq!(ap.security.as_deref(), Some("WPA2"));
|
|
assert_eq!(ap.cipher.as_deref(), Some("CCMP"));
|
|
assert_eq!(ap.signal_dbm, Some(-40));
|
|
assert_eq!(ap.beacons, 1);
|
|
assert_eq!(ap.first_seen_unix, 100);
|
|
}
|
|
|
|
#[test]
|
|
fn tracks_probes_and_data_clients() {
|
|
let mut table = ScanTable::new();
|
|
table.ingest_at(&beacon_raw("Casa", &[2]), 1);
|
|
let probe = radiotap_wrap(&probe_request(sta(), "Casa"), 2437, -55);
|
|
table.ingest_at(&probe, 2);
|
|
|
|
let payload = [0xAA, 0xAA, 0x03, 0, 0, 0, 0x08, 0x00, 1, 2, 3];
|
|
let data = crate::testutil::data_frame(false, true, sta(), ap(), [0x33; 6], &payload);
|
|
let data = radiotap_wrap(&data, 2437, -50);
|
|
table.ingest_at(&data, 3);
|
|
|
|
let clients = table.clients();
|
|
assert_eq!(clients.len(), 1);
|
|
let c = &clients[0];
|
|
assert_eq!(c.mac, "66:77:88:99:aa:bb");
|
|
assert_eq!(c.bssid.as_deref(), Some("00:11:22:33:44:55"));
|
|
assert!(c.associated);
|
|
assert!(c.probes.contains(&"Casa".to_string()));
|
|
assert_eq!(c.packets, 2);
|
|
assert_eq!(table.access_points()[0].clients.len(), 1);
|
|
assert_eq!(table.stats().frames, 3);
|
|
}
|
|
|
|
#[test]
|
|
fn captures_handshake_from_raw_frames() {
|
|
let mut table = ScanTable::new();
|
|
table.ingest_at(&beacon_raw("RedeWPA", &[2]), 1);
|
|
let (m1, m2) = handshake_frames("RedeWPA", "password", 2, false);
|
|
|
|
let f1 = frame_with_eapol(true, ap(), sta(), [0x33; 6], &m1);
|
|
let raw1 = radiotap_wrap(&f1, 2437, -40);
|
|
let e1 = table.ingest_at(&raw1, 2);
|
|
assert!(e1.eapol);
|
|
assert!(e1.handshake_updated);
|
|
|
|
let f2 = frame_with_eapol(false, ap(), sta(), [0x33; 6], &m2);
|
|
let raw2 = radiotap_wrap(&f2, 2437, -41);
|
|
let e2 = table.ingest_at(&raw2, 3);
|
|
assert!(e2.eapol);
|
|
|
|
let hs = table.handshakes();
|
|
assert_eq!(hs.len(), 1);
|
|
assert!(hs[0].complete);
|
|
assert!(hs[0].crackable);
|
|
assert_eq!(hs[0].ssid.as_deref(), Some("RedeWPA"));
|
|
assert_eq!(hs[0].messages, "1,2");
|
|
assert!(table.first_crackable(None, None).is_some());
|
|
assert!(table
|
|
.first_crackable(Some("00:11:22:33:44:55"), None)
|
|
.is_some());
|
|
assert!(table
|
|
.first_crackable(Some("ff:ff:ff:ff:ff:ff"), None)
|
|
.is_none());
|
|
|
|
table.set_password("00:11:22:33:44:55", "66:77:88:99:aa:bb", "password");
|
|
assert_eq!(table.handshakes()[0].password.as_deref(), Some("password"));
|
|
assert_eq!(table.stats().eapol_frames, 2);
|
|
}
|
|
|
|
#[test]
|
|
fn counts_deauth_and_marks_client_disconnected() {
|
|
let mut table = ScanTable::new();
|
|
table.ingest_at(&beacon_raw("Casa", &[2]), 1);
|
|
let payload = [0xAA, 0xAA, 0x03, 0, 0, 0, 0x08, 0x00, 1];
|
|
let data = crate::testutil::data_frame(false, true, sta(), ap(), [0x33; 6], &payload);
|
|
table.ingest_at(&radiotap_wrap(&data, 2437, -50), 2);
|
|
|
|
let mut deauth = Vec::new();
|
|
deauth.extend_from_slice(&0x00c0u16.to_le_bytes());
|
|
deauth.extend_from_slice(&0u16.to_le_bytes());
|
|
deauth.extend_from_slice(&sta());
|
|
deauth.extend_from_slice(&ap());
|
|
deauth.extend_from_slice(&ap());
|
|
deauth.extend_from_slice(&[0u8; 2]);
|
|
deauth.extend_from_slice(&7u16.to_le_bytes());
|
|
let event = table.ingest_at(&radiotap_wrap(&deauth, 2437, -50), 3);
|
|
assert!(event.deauth);
|
|
assert_eq!(table.stats().deauth_frames, 1);
|
|
assert!(!table.clients()[0].associated);
|
|
}
|
|
|
|
#[test]
|
|
fn flags_locally_administered_macs() {
|
|
assert!(is_random_mac("5a:0d:93:04:cf:90"));
|
|
assert!(is_random_mac("02:11:22:33:44:55"));
|
|
assert!(!is_random_mac("00:11:22:33:44:55"));
|
|
assert!(!is_random_mac("ff:ff:ff:ff:ff:ff"));
|
|
}
|
|
}
|