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
+1
View File
@@ -43,3 +43,4 @@ reqwest = { version = "0.12.20", default-features = false }
rustls-rustcrypto = { version = "0.0.2-alpha", optional = true }
async-trait = "0.1.88"
utoipa = { version = "5.4.0", optional = true }
url = "2"
+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());
}
}
+252 -31
View File
@@ -1,5 +1,14 @@
<script lang="ts">
import { get_config, set_config, test_notification, type Config } from '../utils.svelte';
import {
get_config,
set_config,
test_notification,
get_wifi_status,
scan_wifi_networks,
type Config,
type WifiStatus,
type WifiNetwork,
} from '../utils.svelte';
let config = $state<Config | null>(null);
@@ -11,13 +20,20 @@
let testMessage = $state('');
let testMessageType = $state<'success' | 'error' | null>(null);
let showConfig = $state(false);
let wifiStatus = $state<WifiStatus | null>(null);
let wifiStatusTimer = $state<ReturnType<typeof setInterval> | null>(null);
let scanning = $state(false);
let scanResults = $state<WifiNetwork[]>([]);
let dnsServersInput = $state('');
async function load_config() {
try {
loading = true;
config = await get_config();
dnsServersInput = config.dns_servers ? config.dns_servers.join(', ') : '';
message = '';
messageType = null;
poll_wifi_status();
} catch (error) {
message = `Failed to load config: ${error}`;
messageType = 'error';
@@ -29,6 +45,15 @@
async function save_config() {
if (!config) return;
const trimmed = dnsServersInput.trim();
config.dns_servers =
trimmed.length > 0
? trimmed
.split(',')
.map((s) => s.trim())
.filter((s) => s.length > 0)
: null;
try {
saving = true;
await set_config(config);
@@ -43,6 +68,41 @@
}
}
async function poll_wifi_status() {
if (wifiStatusTimer) clearInterval(wifiStatusTimer);
try {
wifiStatus = await get_wifi_status();
} catch {
wifiStatus = null;
}
wifiStatusTimer = setInterval(async () => {
try {
wifiStatus = await get_wifi_status();
} catch {
wifiStatus = null;
}
}, 5000);
}
async function do_scan() {
scanning = true;
try {
scanResults = await scan_wifi_networks();
} catch {
scanResults = [];
} finally {
scanning = false;
}
}
function select_network(ssid: string) {
if (config) {
config.wifi_ssid = ssid;
config.wifi_password = '';
scanResults = [];
}
}
async function send_test_notification() {
try {
testingNotification = true;
@@ -63,6 +123,10 @@
if (showConfig && !config) {
load_config();
}
if (!showConfig && wifiStatusTimer) {
clearInterval(wifiStatusTimer);
wifiStatusTimer = null;
}
});
</script>
@@ -283,46 +347,203 @@
</div>
</div>
{#if config.device === 'orbic' || config.device === 'moxee'}
<div class="border-t pt-4 mt-6 space-y-3">
<h3 class="text-lg font-semibold text-gray-800 mb-4">WiFi Client Mode</h3>
<p class="text-xs text-gray-500">
Connect the device to an existing WiFi network for internet access (e.g.
notifications, remote access). The hotspot AP stays running alongside
WiFi client mode.
</p>
<div class="flex items-center">
<input
id="wifi_enabled"
type="checkbox"
bind:checked={config.wifi_enabled}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
/>
<label for="wifi_enabled" class="ml-2 block text-sm text-gray-700">
Enable WiFi
</label>
</div>
<p class="text-xs text-gray-500">
Unchecking stops WiFi without clearing saved credentials.
</p>
{#if wifiStatus && config.wifi_enabled}
{#if wifiStatus.state === 'connected'}
<p class="text-xs text-green-600">
Connected to "{wifiStatus.ssid}" ({wifiStatus.ip})
</p>
{:else if wifiStatus.state === 'connecting'}
<p class="text-xs text-amber-600">Connecting...</p>
{:else if wifiStatus.state === 'failed'}
<p class="text-xs text-red-600">
Failed: {wifiStatus.error}
</p>
{/if}
{/if}
<div>
<label
for="wifi_ssid"
class="block text-sm font-medium text-gray-700 mb-1"
>
WiFi Network Name (SSID)
</label>
<div class="flex gap-2">
<input
id="wifi_ssid"
type="text"
bind:value={config.wifi_ssid}
placeholder="MyWiFiNetwork"
class="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
/>
<button
type="button"
onclick={do_scan}
disabled={scanning}
class="px-3 py-2 text-sm bg-gray-100 hover:bg-gray-200 disabled:opacity-50 border border-gray-300 rounded-md"
>
{scanning ? 'Scanning...' : 'Scan'}
</button>
</div>
</div>
{#if scanResults.length > 0}
<div
class="border border-gray-200 rounded-md max-h-40 overflow-y-auto divide-y"
>
{#each scanResults as network}
<button
type="button"
class="w-full px-3 py-2 text-left text-sm hover:bg-gray-50 flex justify-between"
onclick={() => select_network(network.ssid)}
>
<span>{network.ssid}</span>
<span class="text-gray-400"
>{network.signal_dbm} dBm &middot; {network.security}</span
>
</button>
{/each}
</div>
{/if}
<div>
<label
for="wifi_password"
class="block text-sm font-medium text-gray-700 mb-1"
>
WiFi Password
</label>
<input
id="wifi_password"
type="password"
bind:value={config.wifi_password}
placeholder="Enter password"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
/>
<p class="text-xs text-gray-500 mt-1">
Changing the network requires re-entering the password.
</p>
</div>
{#if config.wifi_ssid}
<div>
<label
for="dns_servers"
class="block text-sm font-medium text-gray-700 mb-1"
>
DNS Servers
</label>
<input
id="dns_servers"
type="text"
bind:value={dnsServersInput}
placeholder="8.8.8.8, 1.1.1.1"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
/>
<p class="text-xs text-gray-500 mt-1">
Comma-separated. Used when WiFi is active. Defaults to 8.8.8.8,
1.1.1.1.
</p>
</div>
{/if}
</div>
{/if}
<div class="border-t pt-4 mt-6 space-y-3">
<h3 class="text-lg font-semibold text-gray-800 mb-4">
WiFi Client Mode (Orbic only)
</h3>
<h3 class="text-lg font-semibold text-gray-800 mb-4">Device Security</h3>
<div class="flex items-center">
<input
id="block_ota_daemons"
type="checkbox"
bind:checked={config.block_ota_daemons}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
/>
<label for="block_ota_daemons" class="ml-2 block text-sm text-gray-700">
Block OTA update daemons
</label>
</div>
<p class="text-xs text-gray-500">
Connect the device to an existing WiFi network for internet access (e.g.
notifications). The hotspot AP stays running. Leave both fields empty to
disable.
Prevents Verizon's dmclient and upgrade services from running. They are
replaced with stubs at runtime. Disabling requires a reboot to take effect.
</p>
<div>
<label for="wifi_ssid" class="block text-sm font-medium text-gray-700 mb-1">
WiFi Network Name (SSID)
</label>
<div class="flex items-center">
<input
id="wifi_ssid"
type="text"
bind:value={config.wifi_ssid}
placeholder="MyWiFiNetwork"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
id="firewall_restrict_outbound"
type="checkbox"
bind:checked={config.firewall_restrict_outbound}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
/>
</div>
<div>
<label
for="wifi_password"
class="block text-sm font-medium text-gray-700 mb-1"
for="firewall_restrict_outbound"
class="ml-2 block text-sm text-gray-700"
>
WiFi Password
Restrict outbound traffic
</label>
<input
id="wifi_password"
type="password"
bind:value={config.wifi_password}
placeholder={config.wifi_ssid
? 'Leave blank to keep current password'
: 'password'}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
/>
</div>
<p class="text-xs text-gray-500">
Only allows DNS, DHCP, and HTTPS (port 443) outbound. Blocks all other
outbound connections on every interface (WiFi and cellular). Loopback and
hotspot traffic are always allowed. Changes take effect immediately.
</p>
{#if config.firewall_restrict_outbound}
<div>
<label
for="firewall_allowed_ports"
class="block text-sm font-medium text-gray-700 mb-1"
>
Additional Allowed Ports
</label>
<input
id="firewall_allowed_ports"
type="text"
value={config.firewall_allowed_ports
? config.firewall_allowed_ports.join(', ')
: ''}
oninput={(e) => {
const val = (e.target as HTMLInputElement).value.trim();
config!.firewall_allowed_ports =
val.length > 0
? val
.split(',')
.map((s) => parseInt(s.trim()))
.filter((n) => !isNaN(n))
: null;
}}
placeholder="22, 80"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
/>
<p class="text-xs text-gray-500 mt-1">
Comma-separated TCP ports, e.g. 22, 80
</p>
</div>
{/if}
</div>
<div class="border-t pt-4 mt-6">
+27
View File
@@ -19,6 +19,7 @@ export enum enabled_notifications {
}
export interface Config {
device: string;
ui_level: number;
colorblind_mode: boolean;
key_input_mode: number;
@@ -29,6 +30,32 @@ export interface Config {
min_space_to_continue_recording_mb: number;
wifi_ssid: string | null;
wifi_password: string | null;
wifi_enabled: boolean;
block_ota_daemons: boolean;
dns_servers: string[] | null;
firewall_restrict_outbound: boolean;
firewall_allowed_ports: number[] | null;
}
export interface WifiStatus {
state: string;
ssid?: string;
ip?: string;
error?: string;
}
export interface WifiNetwork {
ssid: string;
signal_dbm: number;
security: string;
}
export async function get_wifi_status(): Promise<WifiStatus> {
return JSON.parse(await req('GET', '/api/wifi-status'));
}
export async function scan_wifi_networks(): Promise<WifiNetwork[]> {
return JSON.parse(await req('POST', '/api/wifi-scan'));
}
export async function req(method: string, url: string, json_body?: unknown): Promise<string> {