code changes for rust based wifi client mode docs next

This commit is contained in:
Ember
2026-02-20 18:56:51 -08:00
parent f746299c66
commit 25a0527fd6
21 changed files with 1307 additions and 436 deletions
+12 -8
View File
@@ -6,8 +6,7 @@ use rayhunter::analysis::analyzer::AnalyzerConfig;
use crate::error::RayhunterError;
use crate::notifications::NotificationType;
pub const WIFI_CREDS_PATH: &str = "/data/rayhunter/wifi-creds.conf";
use crate::wifi::WPA_CONF_PATH;
/// The structure of a valid rayhunter configuration
#[derive(Debug, Clone, Deserialize, Serialize)]
@@ -38,6 +37,11 @@ pub struct Config {
pub min_space_to_continue_recording_mb: u64,
pub wifi_ssid: Option<String>,
pub wifi_password: Option<String>,
pub wifi_enabled: bool,
pub block_ota_daemons: bool,
pub dns_servers: Option<Vec<String>>,
pub firewall_restrict_outbound: bool,
pub firewall_allowed_ports: Option<Vec<u16>>,
}
impl Default for Config {
@@ -57,6 +61,11 @@ impl Default for Config {
min_space_to_continue_recording_mb: 1,
wifi_ssid: None,
wifi_password: None,
wifi_enabled: false,
block_ota_daemons: false,
dns_servers: None,
firewall_restrict_outbound: true,
firewall_allowed_ports: None,
}
}
}
@@ -72,12 +81,7 @@ where
Config::default()
};
if let Ok(creds) = tokio::fs::read_to_string(WIFI_CREDS_PATH).await {
config.wifi_ssid = creds
.lines()
.find_map(|line| line.strip_prefix("ssid="))
.map(|s| s.to_string());
}
config.wifi_ssid = rayhunter::read_ssid_from_wpa_conf(WPA_CONF_PATH);
config.wifi_password = None;
Ok(config)
+139
View File
@@ -0,0 +1,139 @@
use log::{info, warn};
use tokio::process::Command;
use crate::config::Config;
const FIREWALL_FLAG: &str = "/data/rayhunter/firewall-enabled";
pub async fn apply(config: &Config) {
if config.block_ota_daemons {
block_ota_daemons().await;
}
let _ = Command::new("iptables")
.args(["-F", "OUTPUT"])
.output()
.await;
if config.firewall_restrict_outbound {
setup_outbound_whitelist(&config.firewall_allowed_ports, &config.ntfy_url).await;
let _ = tokio::fs::write(FIREWALL_FLAG, "").await;
} else {
let _ = tokio::fs::remove_file(FIREWALL_FLAG).await;
}
}
async fn block_ota_daemons() {
let stub = "#!/bin/sh\nwhile true; do sleep 3600; done\n";
if let Err(e) = tokio::fs::write("/tmp/daemon-stub", stub).await {
warn!("failed to write daemon stub: {e}");
return;
}
let _ = Command::new("chmod")
.args(["755", "/tmp/daemon-stub"])
.output()
.await;
for daemon in &["dmclient", "upgrade"] {
let path = format!("/usr/bin/{daemon}");
let _ = Command::new("mount")
.args(["--bind", "/tmp/daemon-stub", &path])
.output()
.await;
let _ = Command::new("pkill").args(["-9", daemon]).output().await;
}
}
async fn setup_outbound_whitelist(extra_ports: &Option<Vec<u16>>, ntfy_url: &Option<String>) {
let _ = Command::new("iptables")
.args(["-A", "OUTPUT", "-o", "lo", "-j", "ACCEPT"])
.output()
.await;
let _ = Command::new("iptables")
.args(["-A", "OUTPUT", "-o", "bridge0", "-j", "ACCEPT"])
.output()
.await;
let _ = Command::new("iptables")
.args([
"-A",
"OUTPUT",
"-m",
"state",
"--state",
"ESTABLISHED,RELATED",
"-j",
"ACCEPT",
])
.output()
.await;
let _ = Command::new("iptables")
.args([
"-A", "OUTPUT", "-p", "udp", "--dport", "67:68", "-j", "ACCEPT",
])
.output()
.await;
let _ = Command::new("iptables")
.args(["-A", "OUTPUT", "-p", "udp", "--dport", "53", "-j", "ACCEPT"])
.output()
.await;
let _ = Command::new("iptables")
.args(["-A", "OUTPUT", "-p", "tcp", "--dport", "53", "-j", "ACCEPT"])
.output()
.await;
let _ = Command::new("iptables")
.args([
"-A", "OUTPUT", "-p", "tcp", "--dport", "443", "-j", "ACCEPT",
])
.output()
.await;
if let Some(url) = ntfy_url
&& let Ok(parsed) = url::Url::parse(url)
&& let Some(port) = parsed.port()
&& port != 443
{
let _ = Command::new("iptables")
.args([
"-A",
"OUTPUT",
"-p",
"tcp",
"--dport",
&port.to_string(),
"-j",
"ACCEPT",
])
.output()
.await;
info!("firewall: auto-allowed port {port} for ntfy");
}
if let Some(ports) = extra_ports {
for port in ports {
let _ = Command::new("iptables")
.args([
"-A",
"OUTPUT",
"-p",
"tcp",
"--dport",
&port.to_string(),
"-j",
"ACCEPT",
])
.output()
.await;
}
}
let _ = Command::new("iptables")
.args(["-A", "OUTPUT", "-j", "DROP"])
.output()
.await;
let _ = tokio::fs::write("/proc/sys/net/bridge/bridge-nf-call-iptables", "0").await;
info!("outbound firewall active: allowing DHCP, DNS, HTTPS only");
}
+17 -2
View File
@@ -4,12 +4,14 @@ mod config;
mod diag;
mod display;
mod error;
mod firewall;
mod key_input;
mod notifications;
mod pcap;
mod qmdl_store;
mod server;
mod stats;
mod wifi;
use std::net::SocketAddr;
use std::sync::Arc;
@@ -22,10 +24,11 @@ use crate::notifications::{NotificationService, run_notification_worker};
use crate::pcap::get_pcap;
use crate::qmdl_store::RecordingStore;
use crate::server::{
ServerState, debug_set_display_state, get_config, get_qmdl, get_time, get_zip, serve_static,
set_config, set_time_offset, test_notification,
ServerState, debug_set_display_state, get_config, get_qmdl, get_time, get_wifi_status, get_zip,
scan_wifi, serve_static, set_config, set_time_offset, test_notification,
};
use crate::stats::{get_qmdl_manifest, get_system_stats};
use crate::wifi::WifiStatus;
use analysis::{
AnalysisCtrlMessage, AnalysisStatus, get_analysis_status, run_analysis_thread, start_analysis,
@@ -70,6 +73,8 @@ fn get_router() -> AppRouter {
.route("/api/config", get(get_config))
.route("/api/config", post(set_config))
.route("/api/test-notification", post(test_notification))
.route("/api/wifi-status", get(get_wifi_status))
.route("/api/wifi-scan", post(scan_wifi))
.route("/api/time", get(get_time))
.route("/api/time-offset", post(set_time_offset))
.route("/api/debug/display-state", post(debug_set_display_state))
@@ -288,6 +293,15 @@ async fn run_with_config(
config.enabled_notifications.clone(),
);
let wifi_status = Arc::new(RwLock::new(WifiStatus::default()));
wifi::run_wifi_client(
&task_tracker,
&config,
shutdown_token.clone(),
wifi_status.clone(),
);
firewall::apply(&config).await;
let state = Arc::new(ServerState {
config_path: args.config_path.clone(),
config,
@@ -297,6 +311,7 @@ async fn run_with_config(
analysis_sender: analysis_tx,
daemon_restart_token: restart_token.clone(),
ui_update_sender: Some(ui_update_tx),
wifi_status,
});
run_server(&task_tracker, state, shutdown_token.clone()).await;
+24 -45
View File
@@ -37,6 +37,7 @@ pub struct ServerState {
pub analysis_sender: Sender<AnalysisCtrlMessage>,
pub daemon_restart_token: CancellationToken,
pub ui_update_sender: Option<Sender<DisplayState>>,
pub wifi_status: Arc<RwLock<crate::wifi::WifiStatus>>,
}
#[cfg_attr(feature = "apidocs", utoipa::path(
@@ -177,7 +178,7 @@ pub async fn set_config(
)
})?;
update_wifi_creds(&config).await;
crate::wifi::update_wpa_conf(&config).await;
// Trigger daemon restart after writing config
state.daemon_restart_token.cancel();
@@ -187,50 +188,6 @@ pub async fn set_config(
))
}
async fn update_wifi_creds(config: &Config) {
let has_ssid = config
.wifi_ssid
.as_ref()
.is_some_and(|s| !s.trim().is_empty());
let has_password = config
.wifi_password
.as_ref()
.is_some_and(|s| !s.trim().is_empty());
let creds_path = crate::config::WIFI_CREDS_PATH;
if !has_ssid {
if tokio::fs::metadata(creds_path).await.is_ok()
&& let Err(e) = tokio::fs::remove_file(creds_path).await
{
warn!("failed to remove wifi credentials: {e}");
}
} else if has_password {
let contents = format!(
"ssid={}\npassword={}\n",
config.wifi_ssid.as_ref().unwrap(),
config.wifi_password.as_ref().unwrap()
);
if let Err(e) = write(creds_path, contents).await {
warn!("failed to write wifi credentials: {e}");
}
} else if let Ok(existing) = tokio::fs::read_to_string(creds_path).await {
let existing_password = existing
.lines()
.find_map(|line| line.strip_prefix("password="));
if let Some(password) = existing_password {
let contents = format!(
"ssid={}\npassword={}\n",
config.wifi_ssid.as_ref().unwrap(),
password
);
if let Err(e) = write(creds_path, contents).await {
warn!("failed to write wifi credentials: {e}");
}
}
}
}
#[cfg_attr(feature = "apidocs", utoipa::path(
post,
path = "/api/test-notification",
@@ -446,6 +403,27 @@ pub async fn get_zip(
Ok((headers, body).into_response())
}
pub async fn get_wifi_status(
State(state): State<Arc<ServerState>>,
) -> Json<crate::wifi::WifiStatus> {
let status = state.wifi_status.read().await;
Json(status.clone())
}
pub async fn scan_wifi(
State(_state): State<Arc<ServerState>>,
) -> Result<Json<Vec<crate::wifi::WifiNetwork>>, (StatusCode, String)> {
let networks = crate::wifi::scan_wifi_networks("wlan1")
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("WiFi scan failed: {e}"),
)
})?;
Ok(Json(networks))
}
#[cfg_attr(feature = "apidocs", utoipa::path(
post,
path = "/api/debug/display-state",
@@ -550,6 +528,7 @@ mod tests {
analysis_sender: analysis_tx,
daemon_restart_token: CancellationToken::new(),
ui_update_sender: None,
wifi_status: Arc::new(RwLock::new(crate::wifi::WifiStatus::default())),
})
}
+665
View File
@@ -0,0 +1,665 @@
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result, bail};
use log::{error, info, warn};
use serde::Serialize;
use tokio::process::{Child, Command};
use tokio::sync::RwLock;
use tokio::time::sleep;
use tokio_util::sync::CancellationToken;
use tokio_util::task::TaskTracker;
use crate::config::Config;
pub const WPA_CONF_PATH: &str = "/data/rayhunter/wpa_sta.conf";
const WPA_BIN: &str = "/data/rayhunter/bin/wpa_supplicant";
const DEFAULT_DNS: &[&str] = &["8.8.8.8", "1.1.1.1"];
#[derive(Clone, Serialize, Default)]
pub struct WifiStatus {
pub state: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub ssid: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ip: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
struct WifiClient {
iface: String,
wpa_child: Option<Child>,
dhcp_child: Option<Child>,
rt_table: u32,
dns_servers: Vec<String>,
saved_resolv: Option<String>,
saved_default_route: Option<String>,
}
impl WifiClient {
fn new(dns_servers: Vec<String>) -> Self {
WifiClient {
iface: "wlan1".to_string(),
wpa_child: None,
dhcp_child: None,
rt_table: 100,
dns_servers,
saved_resolv: None,
saved_default_route: None,
}
}
async fn start(&mut self) -> Result<()> {
self.wait_for_interface().await?;
self.set_managed_mode().await?;
self.start_wpa_supplicant().await?;
self.start_dhcp().await?;
self.setup_routing().await?;
self.allow_inbound().await;
Ok(())
}
async fn stop(&mut self) {
if let Some(mut child) = self.wpa_child.take() {
let _ = child.kill().await;
}
if let Some(mut child) = self.dhcp_child.take() {
let _ = child.kill().await;
}
self.remove_inbound().await;
self.cleanup_routing().await;
self.interface_down().await;
if let Some(resolv) = self.saved_resolv.take() {
let _ = tokio::fs::write("/etc/resolv.conf", resolv).await;
}
if let Some(route) = self.saved_default_route.take() {
let args: Vec<&str> = route.split_whitespace().collect();
let mut cmd_args = vec!["route", "add"];
cmd_args.extend(&args);
let _ = Command::new("ip").args(&cmd_args).output().await;
}
}
async fn wait_for_interface(&self) -> Result<()> {
for _ in 0..30 {
if Path::new(&format!("/sys/class/net/{}", self.iface)).exists() {
return Ok(());
}
sleep(Duration::from_secs(1)).await;
}
bail!("{} not found after 30s", self.iface);
}
async fn set_managed_mode(&self) -> Result<()> {
let out = Command::new("iw")
.args(["dev", &self.iface, "set", "type", "managed"])
.output()
.await?;
if !out.status.success() {
bail!(
"iw set type managed failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
let out = Command::new("ip")
.args(["link", "set", &self.iface, "up"])
.output()
.await?;
if !out.status.success() {
bail!(
"ip link set up failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
Ok(())
}
async fn start_wpa_supplicant(&mut self) -> Result<()> {
use std::process::Stdio;
let child = Command::new(WPA_BIN)
.args(["-i", &self.iface, "-Dnl80211", "-c", WPA_CONF_PATH])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()?;
self.wpa_child = Some(child);
sleep(Duration::from_secs(5)).await;
Ok(())
}
async fn start_dhcp(&mut self) -> Result<()> {
use std::process::Stdio;
let child = Command::new("udhcpc")
.args([
"-i",
&self.iface,
"-s",
"/etc/udhcpc.d/50default",
"-t",
"10",
"-A",
"3",
"-f",
])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()?;
self.dhcp_child = Some(child);
for _ in 0..15 {
sleep(Duration::from_secs(1)).await;
if self.get_interface_ip().await.is_ok() {
return Ok(());
}
}
bail!("DHCP did not assign an address within 15s");
}
async fn setup_routing(&mut self) -> Result<()> {
if self.saved_resolv.is_none() {
self.saved_resolv = tokio::fs::read_to_string("/etc/resolv.conf").await.ok();
}
if self.saved_default_route.is_none() {
let out = Command::new("ip")
.args(["route", "show", "default"])
.output()
.await;
if let Ok(o) = out {
let stdout = String::from_utf8_lossy(&o.stdout);
self.saved_default_route = stdout.lines().next().map(|s| s.to_string());
}
}
self.cleanup_routing().await;
let ip = self
.get_interface_ip()
.await
.context("failed to get IP after DHCP")?;
let subnet = self
.get_interface_subnet()
.await
.context("failed to get subnet after DHCP")?;
let gateway = self
.get_interface_gateway()
.await
.context("failed to get gateway after DHCP")?;
let _ = Command::new("ip")
.args(["route", "del", "default", "dev", "bridge0"])
.output()
.await;
let _ = Command::new("ip")
.args([
"route",
"replace",
"default",
"via",
&gateway,
"dev",
&self.iface,
"metric",
"10",
])
.output()
.await;
let table = self.rt_table.to_string();
let _ = Command::new("ip")
.args(["rule", "add", "from", &ip, "table", &table])
.output()
.await;
let _ = Command::new("ip")
.args([
"route",
"add",
&subnet,
"dev",
&self.iface,
"src",
&ip,
"table",
&table,
])
.output()
.await;
let _ = Command::new("ip")
.args([
"route",
"add",
"default",
"via",
&gateway,
"dev",
&self.iface,
"table",
&table,
])
.output()
.await;
let resolv = self
.dns_servers
.iter()
.map(|s| format!("nameserver {s}"))
.collect::<Vec<_>>()
.join("\n")
+ "\n";
tokio::fs::write("/etc/resolv.conf", resolv).await?;
Ok(())
}
async fn get_interface_ip(&self) -> Result<String> {
let out = Command::new("ip")
.args(["addr", "show", &self.iface])
.output()
.await?;
let stdout = String::from_utf8_lossy(&out.stdout);
stdout
.lines()
.find_map(|line| {
let trimmed = line.trim();
trimmed
.strip_prefix("inet ")?
.split('/')
.next()
.map(|s| s.to_string())
})
.context("no inet address on interface")
}
async fn get_interface_subnet(&self) -> Result<String> {
let out = Command::new("ip")
.args(["route", "show", "dev", &self.iface])
.output()
.await?;
let stdout = String::from_utf8_lossy(&out.stdout);
stdout
.lines()
.find_map(|line| {
if line.contains("proto kernel") {
line.split_whitespace().next().map(|s| s.to_string())
} else {
None
}
})
.context("no kernel route for interface")
}
async fn get_interface_gateway(&self) -> Result<String> {
// First try an explicit default route on this interface
let out = Command::new("ip")
.args(["route", "show", "dev", &self.iface, "default"])
.output()
.await?;
let stdout = String::from_utf8_lossy(&out.stdout);
if let Some(gw) = stdout.lines().find_map(|line| {
let mut parts = line.split_whitespace();
while let Some(word) = parts.next() {
if word == "via" {
return parts.next().map(|s| s.to_string());
}
}
None
}) {
return Ok(gw);
}
// When subnets overlap (e.g. bridge0 and wlan1 both on 192.168.1.0/24),
// udhcpc may not add an explicit default route for wlan1. Fall back to
// inferring the gateway as .1 from the kernel subnet route.
let ip = self.get_interface_ip().await?;
if let Some(last_dot) = ip.rfind('.') {
return Ok(format!("{}.1", &ip[..last_dot]));
}
bail!("no default gateway for interface")
}
async fn cleanup_routing(&self) {
let table = self.rt_table.to_string();
let _ = Command::new("ip")
.args(["rule", "del", "table", &table])
.output()
.await;
let _ = Command::new("ip")
.args(["route", "flush", "table", &table])
.output()
.await;
}
async fn allow_inbound(&self) {
let _ = Command::new("iptables")
.args(["-D", "INPUT", "-i", &self.iface, "-j", "ACCEPT"])
.output()
.await;
let _ = Command::new("iptables")
.args(["-D", "FORWARD", "-i", &self.iface, "-j", "ACCEPT"])
.output()
.await;
let _ = Command::new("iptables")
.args(["-I", "INPUT", "-i", &self.iface, "-j", "ACCEPT"])
.output()
.await;
let _ = Command::new("iptables")
.args(["-I", "FORWARD", "-i", &self.iface, "-j", "ACCEPT"])
.output()
.await;
}
async fn remove_inbound(&self) {
let _ = Command::new("iptables")
.args(["-D", "INPUT", "-i", &self.iface, "-j", "ACCEPT"])
.output()
.await;
let _ = Command::new("iptables")
.args(["-D", "FORWARD", "-i", &self.iface, "-j", "ACCEPT"])
.output()
.await;
}
async fn interface_down(&self) {
let _ = Command::new("ip")
.args(["link", "set", &self.iface, "down"])
.output()
.await;
}
}
pub fn run_wifi_client(
task_tracker: &TaskTracker,
config: &Config,
shutdown_token: CancellationToken,
wifi_status: Arc<RwLock<WifiStatus>>,
) {
if !config.wifi_enabled || !Path::new(WPA_CONF_PATH).exists() {
return;
}
let dns_servers = config
.dns_servers
.clone()
.filter(|v| !v.is_empty())
.unwrap_or_else(|| DEFAULT_DNS.iter().map(|s| s.to_string()).collect());
let ssid = rayhunter::read_ssid_from_wpa_conf(WPA_CONF_PATH);
task_tracker.spawn(async move {
{
let mut status = wifi_status.write().await;
status.state = "connecting".to_string();
status.ssid = ssid.clone();
}
let mut client = WifiClient::new(dns_servers);
match client.start().await {
Ok(()) => {
let ip = client.get_interface_ip().await.ok();
let mut status = wifi_status.write().await;
status.state = "connected".to_string();
status.ssid = ssid.clone();
status.ip = ip;
status.error = None;
info!("WiFi client connected");
}
Err(e) => {
client.stop().await;
let mut status = wifi_status.write().await;
status.state = "failed".to_string();
status.error = Some(format!("{e}"));
error!("WiFi client failed to start: {e}");
return;
}
}
loop {
tokio::select! {
_ = shutdown_token.cancelled() => {
client.stop().await;
let mut status = wifi_status.write().await;
status.state = "disabled".to_string();
status.ip = None;
status.error = None;
info!("WiFi client stopped");
return;
}
_ = sleep(Duration::from_secs(30)) => {
if let Some(ref mut child) = client.dhcp_child
&& let Ok(Some(_)) = child.try_wait()
{
warn!("udhcpc exited, restarting DHCP");
if let Err(e) = client.start_dhcp().await {
warn!("DHCP restart failed: {e}");
} else {
let _ = client.setup_routing().await;
let mut status = wifi_status.write().await;
status.ip = client.get_interface_ip().await.ok();
}
}
}
}
}
});
}
pub async fn update_wpa_conf(config: &Config) {
let has_ssid = config
.wifi_ssid
.as_ref()
.is_some_and(|s| !s.trim().is_empty());
let has_password = config
.wifi_password
.as_ref()
.is_some_and(|s| !s.trim().is_empty());
if has_ssid && has_password {
let conf = rayhunter::format_wpa_conf(
config.wifi_ssid.as_ref().unwrap(),
config.wifi_password.as_ref().unwrap(),
);
if let Err(e) = tokio::fs::write(WPA_CONF_PATH, conf).await {
warn!("failed to write wpa_supplicant config: {e}");
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ =
tokio::fs::set_permissions(WPA_CONF_PATH, std::fs::Permissions::from_mode(0o600))
.await;
}
} else if !has_ssid {
let _ = tokio::fs::remove_file(WPA_CONF_PATH).await;
}
}
#[derive(Serialize)]
pub struct WifiNetwork {
pub ssid: String,
pub signal_dbm: i32,
pub security: String,
}
pub async fn scan_wifi_networks(iface: &str) -> Result<Vec<WifiNetwork>> {
let link_out = Command::new("ip")
.args(["link", "show", iface])
.output()
.await?;
let link_stdout = String::from_utf8_lossy(&link_out.stdout);
let already_up = link_stdout.contains("state UP");
if !already_up {
let _ = Command::new("ip")
.args(["link", "set", iface, "down"])
.output()
.await;
let _ = Command::new("iw")
.args(["dev", iface, "set", "type", "managed"])
.output()
.await;
let _ = Command::new("ip")
.args(["link", "set", iface, "up"])
.output()
.await;
}
let out = Command::new("iw")
.args(["dev", iface, "scan"])
.output()
.await?;
parse_iw_scan(&String::from_utf8_lossy(&out.stdout))
}
fn parse_iw_scan(output: &str) -> Result<Vec<WifiNetwork>> {
let mut networks: Vec<WifiNetwork> = Vec::new();
let mut current_ssid: Option<String> = None;
let mut current_signal: i32 = -100;
let mut current_security = String::new();
for line in output.lines() {
let trimmed = line.trim();
if line.starts_with("BSS ") {
if let Some(ssid) = current_ssid.take()
&& !ssid.is_empty()
{
push_or_update(&mut networks, ssid, current_signal, &current_security);
}
current_signal = -100;
current_security = String::new();
} else if let Some(ssid) = trimmed.strip_prefix("SSID: ") {
current_ssid = Some(ssid.to_string());
} else if let Some(sig) = trimmed.strip_prefix("signal: ") {
if let Some(dbm) = sig.split_whitespace().next() {
current_signal = dbm.parse::<f32>().unwrap_or(-100.0) as i32;
}
} else if trimmed.starts_with("RSN:") {
current_security = "WPA2".to_string();
} else if trimmed.starts_with("WPA:") && current_security.is_empty() {
current_security = "WPA".to_string();
}
}
if let Some(ssid) = current_ssid
&& !ssid.is_empty()
{
push_or_update(&mut networks, ssid, current_signal, &current_security);
}
networks.sort_by(|a, b| b.signal_dbm.cmp(&a.signal_dbm));
Ok(networks)
}
fn push_or_update(networks: &mut Vec<WifiNetwork>, ssid: String, signal: i32, security: &str) {
if let Some(existing) = networks.iter_mut().find(|n| n.ssid == ssid) {
if signal > existing.signal_dbm {
existing.signal_dbm = signal;
}
} else {
networks.push(WifiNetwork {
ssid,
signal_dbm: signal,
security: if security.is_empty() {
"Open".to_string()
} else {
security.to_string()
},
});
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_iw_scan_basic() {
let output = "\
BSS aa:bb:cc:dd:ee:ff(on wlan1)
\tTSF: 12345 usec
\tfreq: 2412
\tsignal: -45.00 dBm
\tSSID: MyNetwork
\tRSN:\t * Version: 1
BSS 11:22:33:44:55:66(on wlan1)
\tsignal: -72.00 dBm
\tSSID: OtherNet
\tWPA:\t * Version: 1
";
let networks = parse_iw_scan(output).unwrap();
assert_eq!(networks.len(), 2);
assert_eq!(networks[0].ssid, "MyNetwork");
assert_eq!(networks[0].signal_dbm, -45);
assert_eq!(networks[0].security, "WPA2");
assert_eq!(networks[1].ssid, "OtherNet");
assert_eq!(networks[1].signal_dbm, -72);
assert_eq!(networks[1].security, "WPA");
}
#[test]
fn test_parse_iw_scan_dedup_keeps_strongest() {
let output = "\
BSS aa:bb:cc:dd:ee:ff(on wlan1)
\tsignal: -80.00 dBm
\tSSID: DupNet
\tRSN:\t * Version: 1
BSS 11:22:33:44:55:66(on wlan1)
\tsignal: -50.00 dBm
\tSSID: DupNet
\tRSN:\t * Version: 1
";
let networks = parse_iw_scan(output).unwrap();
assert_eq!(networks.len(), 1);
assert_eq!(networks[0].ssid, "DupNet");
assert_eq!(networks[0].signal_dbm, -50);
}
#[test]
fn test_parse_iw_scan_hidden_ssid_filtered() {
let output = "\
BSS aa:bb:cc:dd:ee:ff(on wlan1)
\tsignal: -45.00 dBm
\tSSID:
";
let networks = parse_iw_scan(output).unwrap();
assert_eq!(networks.len(), 0);
}
#[test]
fn test_parse_iw_scan_open_network() {
let output = "\
BSS aa:bb:cc:dd:ee:ff(on wlan1)
\tsignal: -60.00 dBm
\tSSID: OpenCafe
";
let networks = parse_iw_scan(output).unwrap();
assert_eq!(networks.len(), 1);
assert_eq!(networks[0].security, "Open");
}
#[tokio::test]
async fn test_update_wpa_conf_writes_and_removes() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("wpa_sta.conf");
let mut config = Config::default();
config.wifi_ssid = Some("TestNet".to_string());
config.wifi_password = Some("pass123".to_string());
tokio::fs::write(&path, "").await.unwrap();
let conf = rayhunter::format_wpa_conf(
config.wifi_ssid.as_ref().unwrap(),
config.wifi_password.as_ref().unwrap(),
);
tokio::fs::write(&path, &conf).await.unwrap();
let content = tokio::fs::read_to_string(&path).await.unwrap();
assert!(content.contains("ssid=\"TestNet\""));
assert!(content.contains("psk=\"pass123\""));
tokio::fs::remove_file(&path).await.unwrap();
assert!(!path.exists());
}
}