diff --git a/daemon/src/display/mod.rs b/daemon/src/display/mod.rs index 6f19ca5..ce06324 100644 --- a/daemon/src/display/mod.rs +++ b/daemon/src/display/mod.rs @@ -6,6 +6,7 @@ pub mod tmobile; pub mod tplink; pub mod tplink_framebuffer; pub mod tplink_onebit; +pub mod uz801; pub mod wingtech; #[derive(Clone, Copy, PartialEq)] diff --git a/daemon/src/display/uz801.rs b/daemon/src/display/uz801.rs new file mode 100644 index 0000000..1810029 --- /dev/null +++ b/daemon/src/display/uz801.rs @@ -0,0 +1,81 @@ +/// Display module for Uz801, light LEDs on the front of the device. +/// DisplayState::Recording => Signal LED is solid blue (wifi LED). +/// DisplayState::Paused => Green LED is solid. +/// DisplayState::WarningDetected => Signal LED is solid red. +use log::{error, info}; +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use tokio_util::task::TaskTracker; + +use std::time::Duration; + +use crate::config; +use crate::display::DisplayState; + +macro_rules! led { + ($l:expr) => {{ format!("/sys/class/leds/{}/brightness", $l) }}; +} + +async fn led_on(path: String) { + tokio::fs::write(&path, "1").await.ok(); +} + +async fn led_off(path: String) { + tokio::fs::write(&path, "0").await.ok(); +} + +pub fn update_ui( + task_tracker: &TaskTracker, + config: &config::Config, + mut ui_shutdown_rx: oneshot::Receiver<()>, + mut ui_update_rx: mpsc::Receiver, +) { + let mut invisible: bool = false; + if config.ui_level == 0 { + info!("Invisible mode, not spawning UI."); + invisible = true; + } + task_tracker.spawn(async move { + let mut state = DisplayState::Recording; + let mut last_state = DisplayState::Paused; + + loop { + match ui_shutdown_rx.try_recv() { + Ok(_) => { + info!("received UI shutdown"); + break; + } + Err(oneshot::error::TryRecvError::Empty) => {} + Err(e) => panic!("error receiving shutdown message: {e}"), + } + match ui_update_rx.try_recv() { + Ok(new_state) => state = new_state, + Err(mpsc::error::TryRecvError::Empty) => {} + Err(e) => error!("error receiving ui update message: {e}"), + }; + if invisible || state == last_state { + tokio::time::sleep(Duration::from_secs(1)).await; + continue; + } + match state { + DisplayState::Paused => { + led_off(led!("red")).await; + led_off(led!("green")).await; + led_on(led!("wifi")).await; + } + DisplayState::Recording => { + led_off(led!("red")).await; + led_off(led!("wifi")).await; + led_on(led!("green")).await; + } + DisplayState::WarningDetected => { + led_off(led!("green")).await; + led_off(led!("wifi")).await; + led_on(led!("red")).await; + } + } + last_state = state; + tokio::time::sleep(Duration::from_secs(1)).await; + } + }); +} diff --git a/daemon/src/main.rs b/daemon/src/main.rs index 31c4ec5..f1f17be 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -243,6 +243,7 @@ async fn run_with_config( Device::Tmobile => display::tmobile::update_ui, Device::Wingtech => display::wingtech::update_ui, Device::Pinephone => display::headless::update_ui, + Device::Uz801 => display::uz801::update_ui }; update_ui(&task_tracker, &config, ui_shutdown_rx, ui_update_rx); diff --git a/daemon/src/stats.rs b/daemon/src/stats.rs index 6fdcfb4..c645420 100644 --- a/daemon/src/stats.rs +++ b/daemon/src/stats.rs @@ -7,7 +7,7 @@ use axum::Json; use axum::extract::State; use axum::http::StatusCode; use log::error; -use rayhunter::util::RuntimeMetadata; +use rayhunter::{Device, util::RuntimeMetadata}; use serde::Serialize; use tokio::process::Command; @@ -19,10 +19,10 @@ pub struct SystemStats { } impl SystemStats { - pub async fn new(qmdl_path: &str) -> Result { + pub async fn new(qmdl_path: &str, device: &Device) -> Result { Ok(Self { - disk_stats: DiskStats::new(qmdl_path).await?, - memory_stats: MemoryStats::new().await?, + disk_stats: DiskStats::new(qmdl_path, device).await?, + memory_stats: MemoryStats::new(device).await?, runtime_metadata: RuntimeMetadata::new(), }) } @@ -40,21 +40,46 @@ pub struct DiskStats { impl DiskStats { // runs "df -h " to get storage statistics for the partition containing - // the QMDL file - pub async fn new(qmdl_path: &str) -> Result { + // the QMDL file. The Uz801 device doesn't support the -h flag, so we skip it for that device. + pub async fn new(qmdl_path: &str, device: &Device) -> Result { let mut df_cmd = Command::new("df"); - df_cmd.arg("-h"); + // Only add -h flag for devices other than Uz801, as Uz801's df doesn't support it + if !matches!(device, Device::Uz801) { + df_cmd.arg("-h"); + } df_cmd.arg(qmdl_path); let stdout = get_cmd_output(df_cmd).await?; - let mut parts = stdout.split_whitespace().skip(7).to_owned(); - Ok(Self { - partition: parts.next().ok_or("error parsing df output")?.to_string(), - total_size: parts.next().ok_or("error parsing df output")?.to_string(), - used_size: parts.next().ok_or("error parsing df output")?.to_string(), - available_size: parts.next().ok_or("error parsing df output")?.to_string(), - used_percent: parts.next().ok_or("error parsing df output")?.to_string(), - mounted_on: parts.next().ok_or("error parsing df output")?.to_string(), - }) + + if matches!(device, Device::Uz801) { + // Handle Uz801 format: + // Filesystem Size Used Free Blksize + // /data/rayhunter/ 774.9M 68.0M 706.9M 4096 + let lines: Vec<&str> = stdout.lines().collect(); + if lines.len() < 2 { + return Err("error parsing df output: insufficient lines".to_string()); + } + let data_line = lines[1]; + let mut parts = data_line.split_whitespace(); + Ok(Self { + partition: parts.next().ok_or("error parsing df output: missing filesystem")?.to_string(), + total_size: parts.next().ok_or("error parsing df output: missing size")?.to_string(), + used_size: parts.next().ok_or("error parsing df output: missing used")?.to_string(), + available_size: parts.next().ok_or("error parsing df output: missing free")?.to_string(), + used_percent: "N/A".to_string(), // Uz801 df doesn't provide percentage + mounted_on: qmdl_path.to_string(), // Use the path we queried + }) + } else { + // Handle standard df -h format + let mut parts = stdout.split_whitespace().skip(7); + Ok(Self { + partition: parts.next().ok_or("error parsing df output")?.to_string(), + total_size: parts.next().ok_or("error parsing df output")?.to_string(), + used_size: parts.next().ok_or("error parsing df output")?.to_string(), + available_size: parts.next().ok_or("error parsing df output")?.to_string(), + used_percent: parts.next().ok_or("error parsing df output")?.to_string(), + mounted_on: parts.next().ok_or("error parsing df output")?.to_string(), + }) + } } } @@ -83,19 +108,53 @@ async fn get_cmd_output(mut cmd: Command) -> Result { } impl MemoryStats { - // runs "free -k" and parses the output to retrieve memory stats - pub async fn new() -> Result { - let mut free_cmd = Command::new("free"); - free_cmd.arg("-k"); - let stdout = get_cmd_output(free_cmd).await?; - let mut numbers = stdout - .split_whitespace() - .flat_map(|part| part.parse::()); - Ok(Self { - total: humanize_kb(numbers.next().ok_or("error parsing free output")?), - used: humanize_kb(numbers.next().ok_or("error parsing free output")?), - free: humanize_kb(numbers.next().ok_or("error parsing free output")?), - }) + // runs "free -k" and parses the output to retrieve memory stats for most devices, + // or reads /proc/meminfo for Uz801 which doesn't have the free command + pub async fn new(device: &Device) -> Result { + if matches!(device, Device::Uz801) { + // Read /proc/meminfo for Uz801 + let meminfo_content = tokio::fs::read_to_string("/proc/meminfo") + .await + .map_err(|e| format!("error reading /proc/meminfo: {}", e))?; + + let mut mem_total_kb = None; + let mut mem_free_kb = None; + + for line in meminfo_content.lines() { + if let Some(value_str) = line.strip_prefix("MemTotal:") { + if let Some(kb_str) = value_str.trim().strip_suffix(" kB") { + mem_total_kb = kb_str.trim().parse::().ok(); + } + } else if let Some(value_str) = line.strip_prefix("MemFree:") { + if let Some(kb_str) = value_str.trim().strip_suffix(" kB") { + mem_free_kb = kb_str.trim().parse::().ok(); + } + } + } + + let total = mem_total_kb.ok_or("error parsing MemTotal from /proc/meminfo")?; + let free = mem_free_kb.ok_or("error parsing MemFree from /proc/meminfo")?; + let used = total - free; + + Ok(Self { + total: humanize_kb(total), + used: humanize_kb(used), + free: humanize_kb(free), + }) + } else { + // Use free command for other devices + let mut free_cmd = Command::new("free"); + free_cmd.arg("-k"); + let stdout = get_cmd_output(free_cmd).await?; + let mut numbers = stdout + .split_whitespace() + .flat_map(|part| part.parse::()); + Ok(Self { + total: humanize_kb(numbers.next().ok_or("error parsing free output")?), + used: humanize_kb(numbers.next().ok_or("error parsing free output")?), + free: humanize_kb(numbers.next().ok_or("error parsing free output")?), + }) + } } } @@ -111,7 +170,7 @@ pub async fn get_system_stats( State(state): State>, ) -> Result, (StatusCode, String)> { let qmdl_store = state.qmdl_store_lock.read().await; - match SystemStats::new(qmdl_store.path.to_str().unwrap()).await { + match SystemStats::new(qmdl_store.path.to_str().unwrap(), &state.config.device).await { Ok(stats) => Ok(Json(stats)), Err(err) => { error!("error getting system stats: {err}"); diff --git a/installer/src/main.rs b/installer/src/main.rs index 620c2ee..b4fb351 100644 --- a/installer/src/main.rs +++ b/installer/src/main.rs @@ -6,6 +6,7 @@ mod orbic; mod pinephone; mod tmobile; mod tplink; +mod uz801; mod util; mod wingtech; @@ -27,6 +28,8 @@ enum Command { Orbic(InstallOrbic), /// Install rayhunter on the TMobile TMOHS1. Tmobile(TmobileArgs), + /// Install rayhunter on the Uz801. + Uz801(Uz801Args), /// Install rayhunter on a PinePhone's Quectel modem. Pinephone(InstallPinephone), /// Install rayhunter on the TP-Link M7350. @@ -82,6 +85,8 @@ enum UtilSubCommand { TmobileStartAdb(TmobileArgs), /// Root the Tmobile and launch telnetd. TmobileStartTelnet(TmobileArgs), + /// Root the Uz801 and launch adb. + Uz801StartAdb(Uz801Args), /// Root the tplink and launch telnetd. TplinkStartTelnet(TplinkStartTelnet), /// Root the Wingtech and launch telnetd. @@ -115,6 +120,17 @@ struct TmobileArgs { admin_password: String, } +#[derive(Parser, Debug)] +struct Uz801Args { + /// IP address for Uz801 admin interface, if custom. + #[arg(long, default_value = "192.168.0.1")] + admin_ip: String, + + /// Web portal admin password. + #[arg(long)] + admin_password: String, +} + #[derive(Parser, Debug)] struct TplinkStartTelnet { /// IP address for TP-Link admin interface, if custom. @@ -168,6 +184,7 @@ async fn run() -> Result<(), Error> { match command { Command::Tmobile(args) => tmobile::install(args).await.context("Failed to install rayhunter on the Tmobile TMOHS1. Make sure your computer is connected to the hotspot using USB tethering or WiFi.")?, + Command::Uz801(args) => uz801::install(args).await.context("Failed to install rayhunter on the Uz801. Make sure your computer is connected to the hotspot using USB tethering or WiFi.")?, Command::Tplink(tplink) => tplink::main_tplink(tplink).await.context("Failed to install rayhunter on the TP-Link M7350. Make sure your computer is connected to the hotspot using USB tethering or WiFi.")?, Command::Pinephone(_) => pinephone::install().await .context("Failed to install rayhunter on the Pinephone's Quectel modem")?, @@ -195,6 +212,7 @@ async fn run() -> Result<(), Error> { UtilSubCommand::Shell => orbic::shell().await.context("\nFailed to open shell on Orbic RC400L")?, UtilSubCommand::TmobileStartTelnet(args) => wingtech::start_telnet(&args.admin_ip, &args.admin_password).await.context("\nFailed to start telnet on the Tmobile TMOHS1")?, UtilSubCommand::TmobileStartAdb(args) => wingtech::start_adb(&args.admin_ip, &args.admin_password).await.context("\nFailed to start adb on the Tmobile TMOHS1")?, + UtilSubCommand::Uz801StartAdb(args) => uz801::activate_usb_debug(&args.admin_ip).await.context("\nFailed to activate USB debug on the Uz801")?, UtilSubCommand::TplinkStartTelnet(options) => { tplink::start_telnet(&options.admin_ip).await?; } diff --git a/installer/src/uz801.rs b/installer/src/uz801.rs new file mode 100644 index 0000000..3a2458d --- /dev/null +++ b/installer/src/uz801.rs @@ -0,0 +1,197 @@ +/// Installer for the Uz801 hotspot. +/// +/// Installation process: +/// 1. Use curl to activate USB debugging backdoor +/// 2. Wait for device reboot and ADB availability +/// 3. Use ADB to install rayhunter files +/// 4. Modify startup script to launch rayhunter on boot +use std::time::Duration; + +use anyhow::Result; +use tokio::time::sleep; +use adb_client::{ADBDeviceExt, ADBUSBDevice, RustADBError}; +use std::io::ErrorKind; + +use crate::Uz801Args as Args; +use crate::util::echo; + +pub async fn install( + Args { + admin_ip, + admin_password: _, // Not used for Uz801 + }: Args, +) -> Result<()> { + run_install(admin_ip).await +} + +async fn run_install(admin_ip: String) -> Result<()> { + echo!("Activating USB debugging backdoor... "); + activate_usb_debug(&admin_ip).await?; + println!("ok"); + + echo!("Waiting for device reboot and ADB connection... "); + let mut adb_device = wait_for_adb().await?; + println!("ok"); + + echo!("Installing rayhunter files... "); + install_rayhunter_files(&mut adb_device).await?; + println!("ok"); + + echo!("Modifying startup script... "); + modify_startup_script(&mut adb_device).await?; + println!("ok"); + + echo!("Starting rayhunter daemon... "); + start_rayhunter(&mut adb_device).await?; + println!("ok"); + + echo!("Testing rayhunter... "); + test_rayhunter(&admin_ip).await?; + println!("ok"); + println!("rayhunter is running at http://{admin_ip}:8080"); + + Ok(()) +} + +pub async fn activate_usb_debug(admin_ip: &str) -> Result<()> { + let url = format!("http://{}/usbdebug.html", admin_ip); + let client = reqwest::Client::new(); + let response = client.get(&url).send().await?; + + if !response.status().is_success() { + anyhow::bail!("Failed to activate USB debug: HTTP {}", response.status()); + } + + Ok(()) +} + +async fn wait_for_adb() -> Result { + const MAX_ATTEMPTS: u32 = 30; // 30 seconds + let mut attempts = 0; + + // Wait a bit for the reboot to start + sleep(Duration::from_secs(5)).await; + + loop { + if attempts >= MAX_ATTEMPTS { + anyhow::bail!("Timeout waiting for ADB connection after USB debug activation"); + } + + match ADBUSBDevice::new(0x05c6, 0x9025) { // Common Qualcomm ADB VID/PID + Ok(mut device) => { + // Test ADB connection + if test_adb_connection(&mut device).await.is_ok() { + return Ok(device); + } + } + Err(RustADBError::DeviceNotFound(_)) => { + // Device not ready yet, continue waiting + } + Err(e) => { + anyhow::bail!("ADB connection error: {}", e); + } + } + + sleep(Duration::from_secs(1)).await; + attempts += 1; + } +} + +async fn test_adb_connection(adb_device: &mut ADBUSBDevice) -> Result<()> { + let mut buf = Vec::::new(); + adb_device.shell_command(&["echo", "test"], &mut buf)?; + let output = String::from_utf8_lossy(&buf); + if output.contains("test") { + Ok(()) + } else { + anyhow::bail!("ADB connection test failed") + } +} + +async fn install_rayhunter_files(adb_device: &mut ADBUSBDevice) -> Result<()> { + // Create rayhunter directory + let mut buf = Vec::::new(); + adb_device.shell_command(&["mkdir", "-p", "/data/rayhunter"], &mut buf)?; + + // Install rayhunter daemon binary + let rayhunter_daemon_bin = include_bytes!(env!("FILE_RAYHUNTER_DAEMON")); + let mut daemon_data = rayhunter_daemon_bin.as_slice(); + adb_device.push(&mut daemon_data, "/data/rayhunter/rayhunter-daemon")?; + + // Install config file + let config_content = crate::CONFIG_TOML + .replace("#device = \"orbic\"", "device = \"uz801\""); + let mut config_data = config_content.as_bytes(); + adb_device.push(&mut config_data, "/data/rayhunter/config.toml")?; + + // Make daemon executable + let mut buf = Vec::::new(); + adb_device.shell_command(&["chmod", "755", "/data/rayhunter/rayhunter-daemon"], &mut buf)?; + + Ok(()) +} + +async fn modify_startup_script(adb_device: &mut ADBUSBDevice) -> Result<()> { + // Pull the existing startup script + let mut script_content = Vec::::new(); + adb_device.pull("/system/bin/initmifiservice.sh", &mut script_content)?; + + // Convert to string and add our line + let mut script_str = String::from_utf8_lossy(&script_content).into_owned(); + + // Add rayhunter startup line if not already present + let rayhunter_line = "/data/rayhunter/rayhunter-daemon /data/rayhunter/config.toml &\n"; + if !script_str.contains("/data/rayhunter/rayhunter-daemon") { + script_str.push_str(rayhunter_line); + } + + // Push the modified script back + let mut modified_script = script_str.as_bytes(); + adb_device.push(&mut modified_script, "/system/bin/initmifiservice.sh")?; + + // Make sure it's executable + let mut buf = Vec::::new(); + adb_device.shell_command(&["chmod", "755", "/system/bin/initmifiservice.sh"], &mut buf)?; + + Ok(()) +} + +async fn start_rayhunter(adb_device: &mut ADBUSBDevice) -> Result<()> { + let mut buf = Vec::::new(); + adb_device.shell_command(&[ + "/data/rayhunter/rayhunter-daemon", + "/data/rayhunter/config.toml", + "&" + ], &mut buf)?; + + // Give it a moment to start + sleep(Duration::from_secs(3)).await; + + Ok(()) +} + +async fn test_rayhunter(admin_ip: &str) -> Result<()> { + const MAX_FAILURES: u32 = 10; + let mut failures = 0; + + let client = reqwest::Client::new(); + + while failures < MAX_FAILURES { + let url = format!("http://{}:8080/index.html", admin_ip); + + if let Ok(response) = client.get(&url).send().await { + if response.status().is_success() { + if let Ok(body) = response.text().await { + if body.contains("html") { + return Ok(()); + } + } + } + } + + failures += 1; + sleep(Duration::from_secs(3)).await; + } + + anyhow::bail!("timeout reached! failed to reach rayhunter, something went wrong :(") +} diff --git a/lib/src/lib.rs b/lib/src/lib.rs index c53f741..58d9556 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -25,4 +25,5 @@ pub enum Device { Tmobile, Wingtech, Pinephone, + Uz801 }