mirror of
https://github.com/EFForg/rayhunter.git
synced 2026-06-08 14:11:52 -07:00
Improved support for subnet colisions, and attempts to rejoin network.
This commit is contained in:
+566
-108
@@ -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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tx_packets: Option<u64>,
|
||||
}
|
||||
|
||||
const TX_STALL_THRESHOLD: u32 = 3;
|
||||
|
||||
struct WifiClient {
|
||||
iface: String,
|
||||
wpa_child: Option<Child>,
|
||||
@@ -55,7 +63,9 @@ struct WifiClient {
|
||||
rt_table: u32,
|
||||
dns_servers: Vec<String>,
|
||||
saved_resolv: Option<String>,
|
||||
saved_default_route: Option<String>,
|
||||
last_tx_packets: Option<u64>,
|
||||
last_rx_packets: Option<u64>,
|
||||
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<String> = Vec::new();
|
||||
if let Some(dhcp_dns) = read_lease_field("dns").await {
|
||||
dns.extend(
|
||||
dhcp_dns
|
||||
.split_whitespace()
|
||||
.filter(|s| s.parse::<IpAddr>().is_ok())
|
||||
.map(|s| s.to_string()),
|
||||
);
|
||||
}
|
||||
if dns.is_empty() {
|
||||
dns.extend(
|
||||
self.dns_servers
|
||||
.iter()
|
||||
.filter(|s| s.parse::<IpAddr>().is_ok())
|
||||
.cloned(),
|
||||
);
|
||||
}
|
||||
let resolv = dns
|
||||
.iter()
|
||||
.filter(|s| s.parse::<IpAddr>().is_ok())
|
||||
.map(|s| format!("nameserver {s}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
@@ -310,7 +335,6 @@ impl WifiClient {
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -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<u64> {
|
||||
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<u64> {
|
||||
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<String> {
|
||||
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<std::process::Output, std::io::Error>,
|
||||
) {
|
||||
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<String, std::io::Error>) {
|
||||
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<RwLock<WifiStatus>>,
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,6 +378,12 @@
|
||||
</p>
|
||||
{:else if wifiStatus.state === 'connecting'}
|
||||
<p class="text-xs text-amber-600">Connecting...</p>
|
||||
{:else if wifiStatus.state === 'recovering'}
|
||||
<p class="text-xs text-amber-600">Recovering connection...</p>
|
||||
{:else if wifiStatus.state === 'dataPathDead'}
|
||||
<p class="text-xs text-amber-600">
|
||||
Data path stalled, attempting recovery...
|
||||
</p>
|
||||
{:else if wifiStatus.state === 'failed'}
|
||||
<p class="text-xs text-red-600">
|
||||
Failed: {wifiStatus.error}
|
||||
|
||||
Vendored
+4
-1
@@ -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
|
||||
|
||||
+21
@@ -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
|
||||
@@ -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<String>,
|
||||
|
||||
/// WiFi network password.
|
||||
#[arg(long)]
|
||||
wifi_password: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
|
||||
/// WiFi network password.
|
||||
#[arg(long)]
|
||||
wifi_password: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
|
||||
@@ -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?;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
+16
-3
@@ -18,6 +18,15 @@ pub async fn telnet_send_command_with_output(
|
||||
addr: SocketAddr,
|
||||
command: &str,
|
||||
wait_for_prompt: bool,
|
||||
) -> Result<String> {
|
||||
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<String> {
|
||||
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
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user