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 });