diff --git a/daemon/src/wifi.rs b/daemon/src/wifi.rs
index ba3a6be..1f0be4b 100644
--- a/daemon/src/wifi.rs
+++ b/daemon/src/wifi.rs
@@ -17,6 +17,8 @@ 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 UDHCPC_HOOK: &str = "/data/rayhunter/udhcpc-hook.sh";
+const DHCP_LEASE_FILE: &str = "/data/rayhunter/dhcp_lease";
const DEFAULT_DNS: &[&str] = &["9.9.9.9", "149.112.112.112"];
const CRASH_LOG_DIR: &str = "/data/rayhunter/crash-logs";
const MAX_RECOVERY_ATTEMPTS: u32 = 5;
@@ -27,7 +29,7 @@ const BRIDGE_IFACE: &str = "bridge0";
pub const STA_IFACE: &str = "wlan1";
#[derive(Clone, Copy, PartialEq, Serialize, Default)]
-#[serde(rename_all = "lowercase")]
+#[serde(rename_all = "camelCase")]
pub enum WifiState {
#[default]
Disabled,
@@ -35,9 +37,11 @@ pub enum WifiState {
Connected,
Failed,
Recovering,
+ DataPathDead,
}
#[derive(Clone, Serialize, Default)]
+#[serde(rename_all = "camelCase")]
pub struct WifiStatus {
pub state: WifiState,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -46,8 +50,12 @@ pub struct WifiStatus {
pub ip: Option,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub tx_packets: Option,
}
+const TX_STALL_THRESHOLD: u32 = 3;
+
struct WifiClient {
iface: String,
wpa_child: Option,
@@ -55,7 +63,9 @@ struct WifiClient {
rt_table: u32,
dns_servers: Vec,
saved_resolv: Option,
- saved_default_route: Option,
+ last_tx_packets: Option,
+ last_rx_packets: Option,
+ tx_stall_count: u32,
}
impl WifiClient {
@@ -67,7 +77,9 @@ impl WifiClient {
rt_table: 100,
dns_servers,
saved_resolv: None,
- saved_default_route: None,
+ last_tx_packets: None,
+ last_rx_packets: None,
+ tx_stall_count: 0,
}
}
@@ -92,15 +104,11 @@ impl WifiClient {
self.cleanup_routing().await;
self.interface_down().await;
+ restore_cellular_default().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<()> {
@@ -145,18 +153,32 @@ impl WifiClient {
.stderr(Stdio::null())
.spawn()?;
self.wpa_child = Some(child);
- sleep(Duration::from_secs(5)).await;
- Ok(())
+ self.wait_for_association().await
+ }
+
+ async fn wait_for_association(&self) -> Result<()> {
+ let operstate_path = format!("/sys/class/net/{}/operstate", self.iface);
+ for i in 0..30 {
+ if let Ok(state) = tokio::fs::read_to_string(&operstate_path).await
+ && state.trim() == "up"
+ {
+ info!("wpa_supplicant associated after {}s", i + 1);
+ return Ok(());
+ }
+ sleep(Duration::from_secs(1)).await;
+ }
+ bail!("wpa_supplicant did not associate within 30s");
}
async fn start_dhcp(&mut self) -> Result<()> {
use std::process::Stdio;
+ let _ = tokio::fs::remove_file(DHCP_LEASE_FILE).await;
let child = Command::new("udhcpc")
.args([
"-i",
&self.iface,
"-s",
- "/etc/udhcpc.d/50default",
+ UDHCPC_HOOK,
"-t",
"10",
"-A",
@@ -168,31 +190,19 @@ impl WifiClient {
.spawn()?;
self.dhcp_child = Some(child);
- for _ in 0..15 {
+ for _ in 0..30 {
sleep(Duration::from_secs(1)).await;
- if self.get_interface_ip().await.is_ok() {
+ if tokio::fs::metadata(DHCP_LEASE_FILE).await.is_ok() {
return Ok(());
}
}
- bail!("DHCP did not assign an address within 15s");
+ bail!("DHCP did not assign an address within 30s");
}
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()
@@ -207,11 +217,10 @@ impl WifiClient {
.await
.context("failed to get gateway after DHCP")?;
- let _ = Command::new("ip")
- .args(["route", "del", "default", "dev", "bridge0"])
- .output()
- .await;
- let _ = Command::new("ip")
+ self.cleanup_routing().await;
+
+ demote_cellular_default().await;
+ let out = Command::new("ip")
.args([
"route",
"replace",
@@ -225,45 +234,61 @@ impl WifiClient {
])
.output()
.await;
+ if let Ok(o) = &out
+ && !o.status.success()
+ {
+ warn!(
+ "failed to add WiFi default route: {}",
+ String::from_utf8_lossy(&o.stderr).trim()
+ );
+ }
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;
+ run_ip(&["rule", "add", "from", &ip, "table", &table]).await;
+ run_ip(&[
+ "route",
+ "add",
+ &subnet,
+ "dev",
+ &self.iface,
+ "src",
+ &ip,
+ "table",
+ &table,
+ ])
+ .await;
+ run_ip(&[
+ "route",
+ "add",
+ "default",
+ "via",
+ &gateway,
+ "dev",
+ &self.iface,
+ "table",
+ &table,
+ ])
+ .await;
- let resolv = self
- .dns_servers
+ let mut dns: Vec = Vec::new();
+ if let Some(dhcp_dns) = read_lease_field("dns").await {
+ dns.extend(
+ dhcp_dns
+ .split_whitespace()
+ .filter(|s| s.parse::().is_ok())
+ .map(|s| s.to_string()),
+ );
+ }
+ if dns.is_empty() {
+ dns.extend(
+ self.dns_servers
+ .iter()
+ .filter(|s| s.parse::().is_ok())
+ .cloned(),
+ );
+ }
+ let resolv = dns
.iter()
- .filter(|s| s.parse::().is_ok())
.map(|s| format!("nameserver {s}"))
.collect::>()
.join("\n")
@@ -310,7 +335,6 @@ impl WifiClient {
}
async fn get_interface_gateway(&self) -> Result {
- // First try an explicit default route on this interface
let out = Command::new("ip")
.args(["route", "show", "dev", &self.iface, "default"])
.output()
@@ -328,13 +352,8 @@ impl WifiClient {
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('.') {
- let gw = format!("{}.1", &ip[..last_dot]);
- warn!("no explicit gateway for {}, assuming {gw}", self.iface);
+ if let Some(gw) = read_lease_field("gateway").await {
+ info!("using DHCP-provided gateway {gw} from lease file");
return Ok(gw);
}
@@ -343,14 +362,25 @@ impl WifiClient {
async fn cleanup_routing(&self) {
let table = self.rt_table.to_string();
- let _ = Command::new("ip")
- .args(["rule", "del", "table", &table])
- .output()
- .await;
+ loop {
+ let out = Command::new("ip")
+ .args(["rule", "del", "table", &table])
+ .output()
+ .await;
+ match out {
+ Ok(o) if o.status.success() => continue,
+ _ => break,
+ }
+ }
let _ = Command::new("ip")
.args(["route", "flush", "table", &table])
.output()
.await;
+ let _ = Command::new("ip")
+ .args(["route", "del", "default", "dev", &self.iface])
+ .output()
+ .await;
+ let _ = tokio::fs::remove_file(DHCP_LEASE_FILE).await;
}
async fn allow_inbound(&self) {
@@ -362,6 +392,10 @@ impl WifiClient {
.args(["-D", "FORWARD", "-i", &self.iface, "-j", "ACCEPT"])
.output()
.await;
+ let _ = Command::new("iptables")
+ .args(["-D", "FORWARD", "-o", &self.iface, "-j", "ACCEPT"])
+ .output()
+ .await;
let _ = Command::new("iptables")
.args(["-I", "INPUT", "-i", &self.iface, "-j", "ACCEPT"])
.output()
@@ -370,6 +404,10 @@ impl WifiClient {
.args(["-I", "FORWARD", "-i", &self.iface, "-j", "ACCEPT"])
.output()
.await;
+ let _ = Command::new("iptables")
+ .args(["-I", "FORWARD", "-o", &self.iface, "-j", "ACCEPT"])
+ .output()
+ .await;
}
async fn remove_inbound(&self) {
@@ -381,6 +419,10 @@ impl WifiClient {
.args(["-D", "FORWARD", "-i", &self.iface, "-j", "ACCEPT"])
.output()
.await;
+ let _ = Command::new("iptables")
+ .args(["-D", "FORWARD", "-o", &self.iface, "-j", "ACCEPT"])
+ .output()
+ .await;
}
async fn interface_down(&self) {
@@ -393,48 +435,272 @@ impl WifiClient {
fn interface_exists(&self) -> bool {
Path::new(&format!("/sys/class/net/{}", self.iface)).exists()
}
+
+ async fn read_tx_packets(&self) -> Option {
+ let path = format!("/sys/class/net/{}/statistics/tx_packets", self.iface);
+ tokio::fs::read_to_string(&path)
+ .await
+ .ok()?
+ .trim()
+ .parse()
+ .ok()
+ }
+
+ async fn read_rx_packets(&self) -> Option {
+ let path = format!("/sys/class/net/{}/statistics/rx_packets", self.iface);
+ tokio::fs::read_to_string(&path)
+ .await
+ .ok()?
+ .trim()
+ .parse()
+ .ok()
+ }
+
+ async fn check_tx_advancing(&self) -> bool {
+ let first = self.read_tx_packets().await;
+ sleep(Duration::from_secs(5)).await;
+ let second = self.read_tx_packets().await;
+ match (first, second) {
+ (Some(a), Some(b)) => b > a,
+ _ => false,
+ }
+ }
}
-async fn save_crash_diagnostics() -> Result<()> {
+async fn run_ip(args: &[&str]) {
+ let out = Command::new("ip").args(args).output().await;
+ match out {
+ Ok(o) if !o.status.success() => {
+ warn!(
+ "ip {} failed: {}",
+ args.join(" "),
+ String::from_utf8_lossy(&o.stderr).trim()
+ );
+ }
+ Err(e) => warn!("ip {} exec error: {e}", args.join(" ")),
+ _ => {}
+ }
+}
+
+/// Parse the gateway and device from an `ip route show default` line.
+fn parse_default_route(line: &str) -> Option<(String, String)> {
+ let mut parts = line.split_whitespace();
+ let mut gw = None;
+ let mut dev = None;
+ while let Some(word) = parts.next() {
+ match word {
+ "via" => gw = parts.next().map(|s| s.to_string()),
+ "dev" => dev = parts.next().map(|s| s.to_string()),
+ _ => {}
+ }
+ }
+ Some((gw?, dev?))
+}
+
+/// Demote cellular default route to metric 1000 so WiFi takes priority.
+async fn demote_cellular_default() {
+ let out = Command::new("ip")
+ .args(["route", "show", "default"])
+ .output()
+ .await;
+ let Ok(o) = out else { return };
+ let stdout = String::from_utf8_lossy(&o.stdout);
+ for line in stdout.lines() {
+ if let Some((gw, dev)) = parse_default_route(line) {
+ if dev == STA_IFACE {
+ continue;
+ }
+ let _ = Command::new("ip")
+ .args(["route", "del", "default", "via", &gw, "dev", &dev])
+ .output()
+ .await;
+ let _ = Command::new("ip")
+ .args([
+ "route", "add", "default", "via", &gw, "dev", &dev, "metric", "1000",
+ ])
+ .output()
+ .await;
+ }
+ }
+}
+
+/// Restore demoted cellular default route to its original metric.
+async fn restore_cellular_default() {
+ let out = Command::new("ip")
+ .args(["route", "show", "default"])
+ .output()
+ .await;
+ let Ok(o) = out else { return };
+ let stdout = String::from_utf8_lossy(&o.stdout);
+ for line in stdout.lines() {
+ if line.contains("metric 1000")
+ && let Some((gw, dev)) = parse_default_route(line)
+ {
+ let _ = Command::new("ip")
+ .args([
+ "route", "del", "default", "via", &gw, "dev", &dev, "metric", "1000",
+ ])
+ .output()
+ .await;
+ let _ = Command::new("ip")
+ .args(["route", "add", "default", "via", &gw, "dev", &dev])
+ .output()
+ .await;
+ }
+ }
+}
+
+async fn read_lease_field(field: &str) -> Option {
+ let content = tokio::fs::read_to_string(DHCP_LEASE_FILE).await.ok()?;
+ let prefix = format!("{field}=");
+ content.lines().find_map(|line| {
+ line.strip_prefix(&prefix)
+ .filter(|v| !v.is_empty())
+ .map(|v| v.to_string())
+ })
+}
+
+async fn save_wifi_diagnostics(reason: &str) -> Result<()> {
tokio::fs::create_dir_all(CRASH_LOG_DIR).await?;
+ if let Ok(mut entries) = tokio::fs::read_dir(CRASH_LOG_DIR).await {
+ let mut files = Vec::new();
+ while let Ok(Some(entry)) = entries.next_entry().await {
+ let name = entry.file_name().to_string_lossy().into_owned();
+ if name.starts_with("wifi-diag-") || name.starts_with("wifi-crash-") {
+ files.push(entry.path());
+ }
+ }
+ if files.len() >= 10 {
+ files.sort();
+ for old in &files[..files.len() - 9] {
+ let _ = tokio::fs::remove_file(old).await;
+ }
+ }
+ }
+
let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
- let path = format!("{CRASH_LOG_DIR}/wifi-crash-{timestamp}.log");
+ let path = format!("{CRASH_LOG_DIR}/wifi-diag-{timestamp}.log");
- let dmesg = Command::new("dmesg").output().await;
- let modules = tokio::fs::read_to_string("/proc/modules").await;
- let ip_addr = Command::new("ip").args(["addr"]).output().await;
- let ps = Command::new("ps").output().await;
+ let iface = STA_IFACE;
+ let (
+ dmesg,
+ iw_link,
+ iw_station,
+ proc_net_dev,
+ wpa_status,
+ proc_arp,
+ ip_route,
+ brctl,
+ iptables,
+ modules,
+ ip_addr,
+ ps,
+ ) = tokio::join!(
+ Command::new("dmesg").output(),
+ Command::new("iw").args(["dev", iface, "link"]).output(),
+ Command::new("iw")
+ .args(["dev", iface, "station", "dump"])
+ .output(),
+ tokio::fs::read_to_string("/proc/net/dev"),
+ Command::new("wpa_cli")
+ .args(["-i", iface, "status"])
+ .output(),
+ tokio::fs::read_to_string("/proc/net/arp"),
+ Command::new("ip")
+ .args(["route", "show", "table", "all"])
+ .output(),
+ Command::new("brctl").args(["show"]).output(),
+ Command::new("iptables").args(["-L", "-v", "-n"]).output(),
+ tokio::fs::read_to_string("/proc/modules"),
+ Command::new("ip").args(["addr"]).output(),
+ Command::new("ps").output(),
+ );
- let mut report = String::with_capacity(64 * 1024);
- report.push_str(&format!("WiFi module crash detected at {timestamp}\n\n"));
-
- report.push_str("=== dmesg ===\n");
- match &dmesg {
- Ok(output) => report.push_str(&String::from_utf8_lossy(&output.stdout)),
- Err(e) => report.push_str(&format!("(failed: {e})\n")),
+ let operstate = tokio::fs::read_to_string(format!("/sys/class/net/{iface}/operstate")).await;
+ let sysfs_stats = [
+ "tx_packets",
+ "tx_errors",
+ "tx_dropped",
+ "rx_packets",
+ "rx_errors",
+ "rx_dropped",
+ ];
+ let mut sysfs_report = String::new();
+ for stat in &sysfs_stats {
+ let val =
+ tokio::fs::read_to_string(format!("/sys/class/net/{iface}/statistics/{stat}")).await;
+ sysfs_report.push_str(&format!(
+ " {stat}: {}\n",
+ match &val {
+ Ok(v) => v.trim().to_string(),
+ Err(e) => format!("(failed: {e})"),
+ }
+ ));
}
- report.push_str("\n=== /proc/modules ===\n");
- match &modules {
- Ok(content) => report.push_str(content),
- Err(e) => report.push_str(&format!("(failed: {e})\n")),
+ let mut report = String::with_capacity(128 * 1024);
+ report.push_str(&format!(
+ "WiFi diagnostics: {reason}\nTimestamp: {timestamp}\n\n"
+ ));
+
+ fn append_cmd(
+ report: &mut String,
+ label: &str,
+ result: &Result,
+ ) {
+ report.push_str(&format!("=== {label} ===\n"));
+ match result {
+ Ok(o) => report.push_str(&String::from_utf8_lossy(&o.stdout)),
+ Err(e) => report.push_str(&format!("(failed: {e})\n")),
+ }
+ report.push('\n');
}
- report.push_str("\n=== ip addr ===\n");
- match &ip_addr {
- Ok(output) => report.push_str(&String::from_utf8_lossy(&output.stdout)),
- Err(e) => report.push_str(&format!("(failed: {e})\n")),
+ fn append_file(report: &mut String, label: &str, result: &Result) {
+ report.push_str(&format!("=== {label} ===\n"));
+ match result {
+ Ok(s) => report.push_str(s),
+ Err(e) => report.push_str(&format!("(failed: {e})\n")),
+ }
+ report.push('\n');
}
- report.push_str("\n=== ps ===\n");
- match &ps {
- Ok(output) => report.push_str(&String::from_utf8_lossy(&output.stdout)),
- Err(e) => report.push_str(&format!("(failed: {e})\n")),
- }
+ append_cmd(&mut report, "dmesg", &dmesg);
+ append_cmd(&mut report, &format!("iw dev {iface} link"), &iw_link);
+ append_cmd(
+ &mut report,
+ &format!("iw dev {iface} station dump"),
+ &iw_station,
+ );
+ append_file(&mut report, "/proc/net/dev", &proc_net_dev);
+
+ report.push_str(&format!("=== {iface} sysfs ===\n"));
+ report.push_str(&format!(
+ " operstate: {}\n",
+ match &operstate {
+ Ok(v) => v.trim().to_string(),
+ Err(e) => format!("(failed: {e})"),
+ }
+ ));
+ report.push_str(&sysfs_report);
+ report.push('\n');
+
+ append_cmd(
+ &mut report,
+ &format!("wpa_cli -i {iface} status"),
+ &wpa_status,
+ );
+ append_file(&mut report, "/proc/net/arp", &proc_arp);
+ append_cmd(&mut report, "ip route show table all", &ip_route);
+ append_cmd(&mut report, "brctl show", &brctl);
+ append_cmd(&mut report, "iptables -L -v -n", &iptables);
+ append_file(&mut report, "/proc/modules", &modules);
+ append_cmd(&mut report, "ip addr", &ip_addr);
+ append_cmd(&mut report, "ps", &ps);
tokio::fs::write(&path, report).await?;
- info!("saved crash diagnostics to {path}");
+ info!("saved wifi diagnostics to {path}");
Ok(())
}
@@ -526,6 +792,91 @@ async fn reload_wifi_module() -> Result<()> {
Ok(())
}
+/// Returns true if TX counter starts advancing after any step.
+async fn attempt_data_path_recovery(
+ client: &mut WifiClient,
+ wifi_status: &Arc>,
+ shutdown_token: &CancellationToken,
+) -> bool {
+ info!("data path recovery step 1: wpa_cli reassociate");
+ let _ = Command::new("wpa_cli")
+ .args(["-i", STA_IFACE, "reassociate"])
+ .output()
+ .await;
+ tokio::select! {
+ _ = shutdown_token.cancelled() => return false,
+ _ = sleep(Duration::from_secs(10)) => {}
+ }
+ if client.check_tx_advancing().await {
+ let mut status = wifi_status.write().await;
+ status.state = WifiState::Connected;
+ status.error = None;
+ return true;
+ }
+
+ info!("data path recovery step 2: restart wpa_supplicant");
+ if let Some(ref mut child) = client.wpa_child {
+ let _ = child.kill().await;
+ let _ = child.wait().await;
+ }
+ client.wpa_child = None;
+ if let Err(e) = client.start_wpa_supplicant().await {
+ warn!("wpa_supplicant restart failed in recovery: {e}");
+ } else {
+ tokio::select! {
+ _ = shutdown_token.cancelled() => return false,
+ _ = sleep(Duration::from_secs(10)) => {}
+ }
+ if client.check_tx_advancing().await {
+ let mut status = wifi_status.write().await;
+ status.state = WifiState::Connected;
+ status.error = None;
+ return true;
+ }
+ }
+
+ if shutdown_token.is_cancelled() {
+ return false;
+ }
+
+ info!("data path recovery step 3: interface cycle");
+ client.stop().await;
+ let _ = Command::new("ip")
+ .args(["link", "set", STA_IFACE, "down"])
+ .output()
+ .await;
+ tokio::select! {
+ _ = shutdown_token.cancelled() => return false,
+ _ = sleep(Duration::from_secs(2)) => {}
+ }
+ let _ = Command::new("ip")
+ .args(["link", "set", STA_IFACE, "up"])
+ .output()
+ .await;
+ tokio::select! {
+ _ = shutdown_token.cancelled() => return false,
+ _ = sleep(Duration::from_secs(2)) => {}
+ }
+ if let Err(e) = client.start().await {
+ warn!("full restart failed in recovery step 3: {e}");
+ return false;
+ }
+ tokio::select! {
+ _ = shutdown_token.cancelled() => return false,
+ _ = sleep(Duration::from_secs(10)) => {}
+ }
+ if client.check_tx_advancing().await {
+ let mut status = wifi_status.write().await;
+ status.state = WifiState::Connected;
+ status.ip = client.get_interface_ip().await.ok();
+ status.error = None;
+ return true;
+ }
+
+ // Module reload handled by caller
+ false
+}
+
pub fn run_wifi_client(
task_tracker: &TaskTracker,
config: &Config,
@@ -555,10 +906,13 @@ pub fn run_wifi_client(
match client.start().await {
Ok(()) => {
let ip = client.get_interface_ip().await.ok();
+ client.last_tx_packets = client.read_tx_packets().await;
+ client.last_rx_packets = client.read_rx_packets().await;
let mut status = wifi_status.write().await;
status.state = WifiState::Connected;
status.ssid = ssid.clone();
status.ip = ip;
+ status.tx_packets = client.last_tx_packets;
status.error = None;
info!("WiFi client connected");
}
@@ -614,9 +968,9 @@ pub fn run_wifi_client(
}
if recovery_attempts == 1
- && let Err(e) = save_crash_diagnostics().await
+ && let Err(e) = save_wifi_diagnostics("interface disappeared").await
{
- warn!("failed to save crash diagnostics: {e}");
+ warn!("failed to save wifi diagnostics: {e}");
}
client.stop().await;
@@ -678,6 +1032,95 @@ pub fn run_wifi_client(
}
}
+ // Only flag a stall when BOTH TX and RX
+ // are frozen. RX always advances on a healthy link (beacons,
+ // broadcast ARP, etc.), so TX-only stall = idle device.
+ let tx_now = client.read_tx_packets().await;
+ let rx_now = client.read_rx_packets().await;
+ {
+ let mut status = wifi_status.write().await;
+ status.tx_packets = tx_now;
+ }
+ let tx_stalled = matches!((tx_now, client.last_tx_packets), (Some(a), Some(b)) if a == b);
+ let rx_stalled = matches!((rx_now, client.last_rx_packets), (Some(a), Some(b)) if a == b);
+ if tx_stalled && rx_stalled {
+ client.tx_stall_count += 1;
+ warn!(
+ "data path stall: tx={} rx={} unchanged for {} polls",
+ tx_now.unwrap_or(0),
+ rx_now.unwrap_or(0),
+ client.tx_stall_count
+ );
+ if client.tx_stall_count >= TX_STALL_THRESHOLD {
+ warn!("stall count reached {TX_STALL_THRESHOLD}, attempting data path recovery");
+ {
+ let mut status = wifi_status.write().await;
+ status.state = WifiState::DataPathDead;
+ }
+ if let Err(e) = save_wifi_diagnostics("TX+RX data path stall").await {
+ warn!("failed to save wifi diagnostics: {e}");
+ }
+ if attempt_data_path_recovery(&mut client, &wifi_status, &shutdown_token).await {
+ info!("data path recovery succeeded");
+ client.tx_stall_count = 0;
+ client.last_tx_packets = client.read_tx_packets().await;
+ client.last_rx_packets = client.read_rx_packets().await;
+ } else {
+ error!("data path recovery failed, falling through to module reload");
+ client.tx_stall_count = 0;
+ client.last_tx_packets = None;
+ client.last_rx_packets = None;
+ recovery_attempts += 1;
+ if recovery_attempts >= MAX_RECOVERY_ATTEMPTS {
+ error!("module recovery failed after {MAX_RECOVERY_ATTEMPTS} attempts, giving up");
+ client.stop().await;
+ let mut status = wifi_status.write().await;
+ status.state = WifiState::Failed;
+ status.error = Some(format!(
+ "data path recovery failed after {MAX_RECOVERY_ATTEMPTS} attempts"
+ ));
+ return;
+ }
+ warn!("module reload attempt {recovery_attempts}/{MAX_RECOVERY_ATTEMPTS}");
+ client.stop().await;
+ if let Err(e) = reload_wifi_module().await {
+ error!("module reload failed: {e}");
+ let mut status = wifi_status.write().await;
+ status.state = WifiState::Recovering;
+ status.error = Some(format!("{e}"));
+ backoff_secs = (backoff_secs * 2).min(240);
+ continue;
+ }
+ match client.start().await {
+ Ok(()) => {
+ let ip = client.get_interface_ip().await.ok();
+ let mut status = wifi_status.write().await;
+ status.state = WifiState::Connected;
+ status.ip = ip;
+ status.error = None;
+ info!("WiFi client recovered via module reload");
+ }
+ Err(e) => {
+ error!("WiFi restart after module reload failed: {e}");
+ client.stop().await;
+ let mut status = wifi_status.write().await;
+ status.state = WifiState::Failed;
+ status.error = Some(format!("{e}"));
+ backoff_secs = (backoff_secs * 2).min(240);
+ }
+ }
+ }
+ continue;
+ }
+ } else {
+ if client.tx_stall_count > 0 {
+ info!("data path advancing again (was stalled for {} polls)", client.tx_stall_count);
+ }
+ client.tx_stall_count = 0;
+ }
+ client.last_tx_packets = tx_now;
+ client.last_rx_packets = rx_now;
+
if recovery_attempts > 0 {
recovery_attempts = 0;
backoff_secs = BASE_BACKOFF_SECS;
@@ -920,4 +1363,19 @@ BSS aa:bb:cc:dd:ee:ff(on wlan1)
update_wpa_conf_at(&config, path_str).await;
assert!(!path.exists());
}
+
+ #[test]
+ fn test_parse_default_route() {
+ let (gw, dev) = parse_default_route("default via 192.168.1.1 dev bridge0").unwrap();
+ assert_eq!(gw, "192.168.1.1");
+ assert_eq!(dev, "bridge0");
+
+ let (gw, dev) =
+ parse_default_route("default via 10.0.0.1 dev rmnet_data0 metric 100").unwrap();
+ assert_eq!(gw, "10.0.0.1");
+ assert_eq!(dev, "rmnet_data0");
+
+ assert!(parse_default_route("default dev bridge0 scope link").is_none());
+ assert!(parse_default_route("").is_none());
+ }
}
diff --git a/daemon/web/src/lib/components/ConfigForm.svelte b/daemon/web/src/lib/components/ConfigForm.svelte
index 213998a..5847295 100644
--- a/daemon/web/src/lib/components/ConfigForm.svelte
+++ b/daemon/web/src/lib/components/ConfigForm.svelte
@@ -378,6 +378,12 @@
{:else if wifiStatus.state === 'connecting'}
Connecting...
+ {:else if wifiStatus.state === 'recovering'}
+ Recovering connection...
+ {:else if wifiStatus.state === 'dataPathDead'}
+
+ Data path stalled, attempting recovery...
+
{:else if wifiStatus.state === 'failed'}
Failed: {wifiStatus.error}
diff --git a/dist/scripts/S01iptables b/dist/scripts/S01iptables
index a3bb109..0343fec 100644
--- a/dist/scripts/S01iptables
+++ b/dist/scripts/S01iptables
@@ -6,8 +6,11 @@ case "$1" in
iptables -A OUTPUT -o lo -j ACCEPT
iptables -A OUTPUT -o bridge0 -j ACCEPT
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
+ iptables -A OUTPUT -p udp --dport 67:68 -j ACCEPT
+ iptables -A OUTPUT -p udp --dport 53 -j ACCEPT
+ iptables -A OUTPUT -p tcp --dport 53 -j ACCEPT
iptables -A OUTPUT -j DROP
- echo 0 > /proc/sys/net/bridge/bridge-nf-call-iptables
+ echo 0 > /proc/sys/net/bridge/bridge-nf-call-iptables 2>/dev/null
fi
;;
esac
diff --git a/dist/scripts/udhcpc-hook.sh b/dist/scripts/udhcpc-hook.sh
new file mode 100755
index 0000000..dade8bb
--- /dev/null
+++ b/dist/scripts/udhcpc-hook.sh
@@ -0,0 +1,21 @@
+#!/bin/sh
+# udhcpc hook script for rayhunter WiFi client mode.
+# Saves DHCP lease info (gateway, DNS) so the daemon can read the real
+# gateway even when subnets collide. Routing is handled by the daemon.
+#
+# Deployed to /data/rayhunter/udhcpc-hook.sh by the installer.
+# Any installer that adds wifi-client support must also deploy this script.
+LEASE_FILE="/data/rayhunter/dhcp_lease"
+
+case "$1" in
+ bound|renew)
+ ip addr flush dev "$interface"
+ ip addr add "$ip/$mask" dev "$interface"
+ echo "gateway=$router" > "$LEASE_FILE"
+ echo "dns=$dns" >> "$LEASE_FILE"
+ ;;
+ deconfig)
+ ip addr flush dev "$interface"
+ rm -f "$LEASE_FILE"
+ ;;
+esac
diff --git a/installer/src/lib.rs b/installer/src/lib.rs
index 022632f..437f5a0 100644
--- a/installer/src/lib.rs
+++ b/installer/src/lib.rs
@@ -238,6 +238,14 @@ struct TmobileArgs {
/// Web portal admin password.
#[arg(long)]
admin_password: String,
+
+ /// WiFi network name to connect to (enables WiFi client mode).
+ #[arg(long)]
+ wifi_ssid: Option,
+
+ /// WiFi network password.
+ #[arg(long)]
+ wifi_password: Option,
}
#[derive(Parser, Debug)]
@@ -285,6 +293,14 @@ struct WingtechArgs {
/// Web portal admin password.
#[arg(long)]
admin_password: String,
+
+ /// WiFi network name to connect to (enables WiFi client mode).
+ #[arg(long)]
+ wifi_ssid: Option,
+
+ /// WiFi network password.
+ #[arg(long)]
+ wifi_password: Option,
}
#[derive(Parser, Debug)]
diff --git a/installer/src/orbic.rs b/installer/src/orbic.rs
index 7fbf5ac..4d290bf 100644
--- a/installer/src/orbic.rs
+++ b/installer/src/orbic.rs
@@ -178,9 +178,15 @@ async fn setup_rayhunter(
)
.await?;
install_file(&mut adb_device, "/data/rayhunter/bin/wpa_cli", wpa_cli_bin).await?;
+ install_file(
+ &mut adb_device,
+ "/data/rayhunter/udhcpc-hook.sh",
+ include_bytes!("../../dist/scripts/udhcpc-hook.sh"),
+ )
+ .await?;
adb_at_syscmd(
&mut adb_device,
- "chmod +x /data/rayhunter/bin/wpa_supplicant /data/rayhunter/bin/wpa_cli",
+ "chmod +x /data/rayhunter/bin/wpa_supplicant /data/rayhunter/bin/wpa_cli /data/rayhunter/udhcpc-hook.sh",
)
.await?;
}
diff --git a/installer/src/orbic_network.rs b/installer/src/orbic_network.rs
index 567df3a..a75ff9d 100644
--- a/installer/src/orbic_network.rs
+++ b/installer/src/orbic_network.rs
@@ -204,9 +204,12 @@ async fn wait_for_telnet(admin_ip: &str) -> Result<()> {
async fn check_disk_space(addr: SocketAddr, binary_size: usize) -> Result<()> {
// Use /data/rayhunter to resolve through symlink (may point to /cache/rayhunter-data)
- let df_output =
- telnet_send_command_with_output(addr, "df /data/rayhunter | tail -1 | awk '{print $4}'", false)
- .await?;
+ let df_output = telnet_send_command_with_output(
+ addr,
+ "df /data/rayhunter | tail -1 | awk '{print $4}'",
+ false,
+ )
+ .await?;
let available_kb: usize = df_output
.lines()
.find(|l| l.trim().chars().all(|c| c.is_ascii_digit()) && !l.trim().is_empty())
@@ -278,6 +281,13 @@ async fn setup_rayhunter(
)
.await?;
telnet_send_file(addr, "/data/rayhunter/bin/wpa_cli", wpa_cli_bin, false).await?;
+ telnet_send_file(
+ addr,
+ "/data/rayhunter/udhcpc-hook.sh",
+ include_bytes!("../../dist/scripts/udhcpc-hook.sh"),
+ false,
+ )
+ .await?;
}
let wifi_enabled = wifi_ssid.is_some() && wifi_password.is_some();
@@ -317,7 +327,7 @@ async fn setup_rayhunter(
#[cfg(feature = "wifi-client")]
telnet_send_command(
addr,
- "chmod +x /data/rayhunter/bin/wpa_supplicant /data/rayhunter/bin/wpa_cli",
+ "chmod +x /data/rayhunter/bin/wpa_supplicant /data/rayhunter/bin/wpa_cli /data/rayhunter/udhcpc-hook.sh",
"exit code 0",
false,
)
diff --git a/installer/src/util.rs b/installer/src/util.rs
index 6b5f5e1..4014a7c 100644
--- a/installer/src/util.rs
+++ b/installer/src/util.rs
@@ -18,6 +18,15 @@ pub async fn telnet_send_command_with_output(
addr: SocketAddr,
command: &str,
wait_for_prompt: bool,
+) -> Result {
+ telnet_send_command_with_timeout(addr, command, wait_for_prompt, Duration::from_secs(10)).await
+}
+
+async fn telnet_send_command_with_timeout(
+ addr: SocketAddr,
+ command: &str,
+ wait_for_prompt: bool,
+ command_timeout: Duration,
) -> Result {
if command.contains('\n') {
bail!("multi-line commands are not allowed");
@@ -42,7 +51,7 @@ pub async fn telnet_send_command_with_output(
writer.write_all(format!("echo RAYHUNTER_'TELNET'_COMMAND_START; {command}; echo RAYHUNTER_'TELNET'_COMMAND_DONE\r\n").as_bytes()).await?;
let mut read_buf = Vec::new();
- timeout(Duration::from_secs(10), async {
+ timeout(command_timeout, async {
loop {
let Ok(byte) = reader.read_u8().await else {
break;
@@ -61,7 +70,7 @@ pub async fn telnet_send_command_with_output(
}
})
.await
- .context("command timed out after 10 seconds")?;
+ .with_context(|| format!("command timed out after {}s", command_timeout.as_secs()))?;
let string = String::from_utf8_lossy(&read_buf);
let start = string.rfind("RAYHUNTER_TELNET_COMMAND_START");
let end = string.rfind("RAYHUNTER_TELNET_COMMAND_DONE");
@@ -97,13 +106,17 @@ pub async fn telnet_send_file(
wait_for_prompt: bool,
) -> Result<()> {
print!("Sending file {filename} ... ");
+ // Allow 30s base + 2s per MB for the nc command to complete (covers slow WiFi links)
+ let transfer_timeout =
+ Duration::from_secs(30 + (payload.len() as u64 / (512 * 1024)).max(1) * 2);
let nc_output = {
let filename = filename.to_owned();
let handle = tokio::spawn(async move {
- telnet_send_command_with_output(
+ telnet_send_command_with_timeout(
addr,
&format!("nc -l -p 8081 2>&1 >{filename}.tmp"),
wait_for_prompt,
+ transfer_timeout,
)
.await
});