Improved support for subnet colisions, and attempts to rejoin network.

This commit is contained in:
Ember
2026-03-03 14:32:28 -08:00
parent 120b6c887e
commit 8ab2bd0e5c
8 changed files with 650 additions and 117 deletions
+566 -108
View File
@@ -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}
+4 -1
View File
@@ -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
Vendored Executable
+21
View File
@@ -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
+16
View File
@@ -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)]
+7 -1
View File
@@ -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?;
}
+14 -4
View File
@@ -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
View File
@@ -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
});