From cb1df974e4499760d97fb2e6c22e5fd7c92a122c Mon Sep 17 00:00:00 2001 From: oopsbagel Date: Sat, 7 Jun 2025 18:50:43 -0700 Subject: [PATCH 01/19] feat: support Wingtech CT2MHS01 hotspot Add support for the Wingtech CT2MHS01 hotspot, a Qualcomm mdm9650-based device with a screen available for US$15-35. This device is often used as a base platform for while labeled versions like the T-Mobile TMOHS1. AT&T branded versions of the hotspot seem to be the most abundant. The device has a framebuffer-driven screen at /dev/fb0 that behaves similarly to the Orbic RC400L, although the userspace program `displaygui` refreshes the screen significantly more often than on the Orbic. This causes the green line on the screen to subtly flicker and only be displayed during some frames. Subsequent work to fully control the display without removing the OEM interface is desired. --- Cargo.lock | 48 +++++++++ bin/Cargo.toml | 1 + bin/src/display/mod.rs | 10 +- bin/src/display/wingtech.rs | 54 ++++++++++ installer/Cargo.toml | 3 + installer/build.rs | 5 + installer/src/main.rs | 15 +++ installer/src/orbic.rs | 1 + installer/src/tplink.rs | 2 +- installer/src/wingtech.rs | 207 ++++++++++++++++++++++++++++++++++++ lib/Cargo.toml | 1 + 11 files changed, 345 insertions(+), 2 deletions(-) create mode 100644 bin/src/display/wingtech.rs create mode 100644 installer/src/wingtech.rs diff --git a/Cargo.lock b/Cargo.lock index 21bff5f..f8ca5db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -46,6 +46,17 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -350,6 +361,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64_light" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6aca08f76b8485947a20a1b3096e5a8cd6edbcecc6d2a8932df9b41d36aadf" + [[package]] name = "base64ct" version = "1.7.3" @@ -411,6 +428,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "built" version = "0.7.7" @@ -504,6 +530,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.5.38" @@ -1431,13 +1467,25 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "installer" version = "0.3.4" dependencies = [ "adb_client", + "aes", "anyhow", "axum", + "base64_light", + "block-padding", "bytes", "clap", "env_logger 0.11.8", diff --git a/bin/Cargo.toml b/bin/Cargo.toml index 7f2758c..41109e7 100644 --- a/bin/Cargo.toml +++ b/bin/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" # These feature flags are mutually exclusive, and exactly one must be enabled. orbic = ["rayhunter/orbic"] tplink = ["rayhunter/tplink"] +wingtech = ["rayhunter/wingtech"] default = ["orbic"] diff --git a/bin/src/display/mod.rs b/bin/src/display/mod.rs index 124ef71..3708305 100644 --- a/bin/src/display/mod.rs +++ b/bin/src/display/mod.rs @@ -15,6 +15,11 @@ mod orbic; #[cfg(feature = "orbic")] pub use orbic::update_ui; +#[cfg(feature = "wingtech")] +mod wingtech; +#[cfg(feature = "wingtech")] +pub use wingtech::update_ui; + pub enum DisplayState { Recording, Paused, @@ -24,5 +29,8 @@ pub enum DisplayState { #[cfg(all(feature = "orbic", feature = "tplink"))] compile_error!("cannot compile for many devices at once"); -#[cfg(not(any(feature = "orbic", feature = "tplink")))] +#[cfg(all(feature = "orbic", feature = "wingtech"))] +compile_error!("cannot compile for many devices at once"); + +#[cfg(not(any(feature = "orbic", feature = "tplink", feature = "wingtech",)))] compile_error!("cannot compile for no device at all"); diff --git a/bin/src/display/wingtech.rs b/bin/src/display/wingtech.rs new file mode 100644 index 0000000..be228cb --- /dev/null +++ b/bin/src/display/wingtech.rs @@ -0,0 +1,54 @@ +/// Display support for the Wingtech CT2MHS01 hotspot. +/// +/// Tested on (from `/etc/wt_version`): +/// WT_INNER_VERSION=SW_Q89323AA1_V057_M10_CRICKET_USR_MP +/// WT_PRODUCTION_VERSION=CT2MHS01_0.04.55 +/// WT_HARDWARE_VERSION=89323_1_20 +use crate::config; +use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer}; +use crate::display::DisplayState; + +use tokio::sync::mpsc::Receiver; +use tokio::sync::oneshot; +use tokio_util::task::TaskTracker; + +const FB_PATH: &str = "/dev/fb0"; + +#[derive(Copy, Clone, Default)] +struct Framebuffer; + +impl GenericFramebuffer for Framebuffer { + fn dimensions(&self) -> Dimensions { + Dimensions { + height: 128, + width: 160, + } + } + + fn write_buffer(&mut self, buffer: &[(u8, u8, u8)]) { + let mut raw_buffer = Vec::new(); + for (r, g, b) in buffer { + let mut rgb565: u16 = (*r as u16 & 0b11111000) << 8; + rgb565 |= (*g as u16 & 0b11111100) << 3; + rgb565 |= (*b as u16) >> 3; + raw_buffer.extend(rgb565.to_le_bytes()); + } + + std::fs::write(FB_PATH, &raw_buffer).unwrap(); + } +} + +pub fn update_ui( + task_tracker: &TaskTracker, + config: &config::Config, + ui_shutdown_rx: oneshot::Receiver<()>, + ui_update_rx: Receiver, +) { + generic_framebuffer::update_ui( + task_tracker, + config, + Framebuffer, + ui_shutdown_rx, + ui_update_rx, + ) +} diff --git a/installer/Cargo.toml b/installer/Cargo.toml index cfe747b..8b6ee3e 100644 --- a/installer/Cargo.toml +++ b/installer/Cargo.toml @@ -4,8 +4,11 @@ version = "0.3.4" edition = "2024" [dependencies] +aes = "0.8.4" anyhow = "1.0.98" axum = "0.8.3" +base64_light = "0.1.5" +block-padding = "0.3.3" bytes = "1.10.1" clap = { version = "4.5.37", features = ["derive"] } env_logger = "0.11.8" diff --git a/installer/build.rs b/installer/build.rs index 55b271b..9bb50e8 100644 --- a/installer/build.rs +++ b/installer/build.rs @@ -19,6 +19,11 @@ fn main() { "FILE_RAYHUNTER_DAEMON_TPLINK", "rayhunter-daemon", ); + set_binary_var( + &include_dir, + "FILE_RAYHUNTER_DAEMON_WINGTECH", + "rayhunter-daemon", + ); } fn set_binary_var(include_dir: &Path, var: &str, file: &str) { diff --git a/installer/src/main.rs b/installer/src/main.rs index 9de31e1..9cbbf86 100644 --- a/installer/src/main.rs +++ b/installer/src/main.rs @@ -4,6 +4,7 @@ use env_logger::Env; mod orbic; mod tplink; +mod wingtech; pub static CONFIG_TOML: &str = include_str!("../../dist/config.toml.example"); pub static RAYHUNTER_DAEMON_INIT: &str = include_str!("../../dist/scripts/rayhunter_daemon"); @@ -21,6 +22,8 @@ enum Command { Orbic(InstallOrbic), /// Install rayhunter on the TP-Link M7350. Tplink(InstallTpLink), + /// Install rayhunter on the Wingtech CT2MHS01. + Wingtech(InstallWingtech), /// Developer utilities. Util(Util), } @@ -51,6 +54,17 @@ struct InstallTpLink { #[derive(Parser, Debug)] struct InstallOrbic {} +#[derive(Parser, Debug)] +struct InstallWingtech { + /// IP address for Wingtech admin interface, if custom. + #[arg(long, default_value = "192.168.1.1")] + admin_ip: String, + + /// Web portal admin password. + #[arg(long)] + admin_password: String, +} + #[derive(Parser, Debug)] struct Util { #[command(subcommand)] @@ -91,6 +105,7 @@ async fn run() -> Result<(), Error> { match command { 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::Orbic(_) => orbic::install().await.context("\nFailed to install rayhunter on the Orbic RC400L")?, + Command::Wingtech(args) => wingtech::install(args).await.context("\nFailed to install rayhunter on the Wingtech CT2MHS01")?, Command::Util(subcommand) => match subcommand.command { UtilSubCommand::Serial(serial_cmd) => { if serial_cmd.root { diff --git a/installer/src/orbic.rs b/installer/src/orbic.rs index 466fcb9..566d909 100644 --- a/installer/src/orbic.rs +++ b/installer/src/orbic.rs @@ -46,6 +46,7 @@ macro_rules! echo { let _ = std::io::stdout().flush(); }; } +pub(crate) use echo; pub async fn install() -> Result<()> { let mut adb_device = force_debug_mode().await?; diff --git a/installer/src/tplink.rs b/installer/src/tplink.rs index ac6c684..60c844a 100644 --- a/installer/src/tplink.rs +++ b/installer/src/tplink.rs @@ -236,7 +236,7 @@ async fn telnet_send_file(addr: SocketAddr, filename: &str, payload: &[u8]) -> R Ok(()) } -async fn telnet_send_command( +pub async fn telnet_send_command( addr: SocketAddr, command: &str, expected_output: &str, diff --git a/installer/src/wingtech.rs b/installer/src/wingtech.rs new file mode 100644 index 0000000..35aa2b1 --- /dev/null +++ b/installer/src/wingtech.rs @@ -0,0 +1,207 @@ +/// Installer for the Wingtech CT2MHS01 hotspot. +/// +/// Tested on (from `/etc/wt_version`): +/// WT_INNER_VERSION=SW_Q89323AA1_V057_M10_CRICKET_USR_MP +/// WT_PRODUCTION_VERSION=CT2MHS01_0.04.55 +/// WT_HARDWARE_VERSION=89323_1_20 +use std::io::Write; +use std::net::SocketAddr; +use std::str::FromStr; +use std::time::Duration; + +use aes::Aes128; +use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray}; +use anyhow::{Result, bail}; +use base64_light::base64_encode_bytes; +use block_padding::{Padding, Pkcs7}; +use reqwest::Client; +use tokio::io::AsyncWriteExt; +use tokio::net::TcpStream; +use tokio::time::sleep; + +use crate::InstallWingtech as Args; +use crate::orbic::echo; +use crate::tplink::telnet_send_command; + +pub async fn install( + Args { + admin_ip, + admin_password, + }: Args, +) -> Result<()> { + wingtech_run_install(admin_ip, admin_password).await +} + +const KEY: &[u8] = b"abcdefghijklmn12"; + +/// Returns password encrypted in AES128 ECB mode with the key b"abcdefghijklmn12", +/// with Pkcs7 padding, encoded in base64. +fn encrypt_password(password: &[u8]) -> Result { + let c = Aes128::new_from_slice(KEY)?; + let mut b = GenericArray::from([0u8; 16]); + b[..password.len()].copy_from_slice(password); + Pkcs7::pad(&mut b, password.len()); + c.encrypt_block(&mut b); + Ok(base64_encode_bytes(&b)) +} + +pub async fn start_telnet(admin_ip: &str, admin_password: &str) -> Result { + let qcmap_auth_endpoint = format!("http://{admin_ip}/cgi-bin/qcmap_auth"); + let qcmap_web_cgi_endpoint = format!("http://{admin_ip}/cgi-bin/qcmap_web_cgi"); + + let encrypted_pw = encrypt_password(admin_password.as_bytes()).ok().unwrap(); + + let client = Client::new(); + let login = client + .post(&qcmap_auth_endpoint) + .body(format!( + "type=login&pwd={encrypted_pw}&timeout=60000&user=admin" + )) + .send() + .await? + .text() + .await?; + let token = match login.find("token") { + Some(n) => &login[n + 8..n + 8 + 16], + None => bail!("login did not return a token in response: {}", login), + }; + + let cmd = "busybox telnetd -l /bin/sh"; + let telnet = client.post(&qcmap_web_cgi_endpoint) + .body(format!("page=setFWMacFilter&cmd=add&mode=0&mac=50:5A:CA:B5:05:AC||{cmd}&key=50:5A:CA:B5:05:AC&token={token}")) + .send() + .await?; + if telnet.status() != 200 { + bail!( + "starting telnet failed with status code: {:?}", + telnet.status() + ); + } + + Ok(true) +} + +async fn wingtech_run_install(admin_ip: String, admin_password: String) -> Result<()> { + echo!("Starting telnet ... "); + start_telnet(&admin_ip, &admin_password).await?; + println!("ok"); + + echo!("Connecting via telnet to {admin_ip} ... "); + let addr = SocketAddr::from_str(&format!("{admin_ip}:23")).unwrap(); + println!("ok"); + + telnet_send_command(addr, "mkdir -p /data/rayhunter", "exit code 0").await?; + + telnet_send_file( + addr, + "/data/rayhunter/config.toml", + crate::CONFIG_TOML.as_bytes(), + ) + .await?; + + let rayhunter_daemon_bin = include_bytes!(env!("FILE_RAYHUNTER_DAEMON_WINGTECH")); + telnet_send_file( + addr, + "/data/rayhunter/rayhunter-daemon", + rayhunter_daemon_bin, + ) + .await?; + telnet_send_command( + addr, + "chmod 755 /data/rayhunter/rayhunter-daemon", + "exit code 0", + ) + .await?; + telnet_send_file( + addr, + "/etc/init.d/rayhunter_daemon", + crate::RAYHUNTER_DAEMON_INIT.as_bytes(), + ) + .await?; + telnet_send_command( + addr, + "chmod 755 /etc/init.d/rayhunter_daemon", + "exit code 0", + ) + .await?; + telnet_send_command(addr, "update-rc.d rayhunter_daemon defaults", "exit code 0").await?; + + println!("Rebooting device and waiting 30 seconds for it to start up."); + telnet_send_command(addr, "reboot", "exit code 0").await?; + sleep(Duration::from_secs(30)).await; + + echo!("Testing rayhunter... "); + const MAX_FAILURES: u32 = 10; + let mut failures = 0; + let rayhunter_url = format!("http://{admin_ip}:8080/index.html"); + let client = Client::new(); + loop { + match client.get(&rayhunter_url).send().await { + Ok(test) => { + if test.status() == 200 { + println!("rayhunter is running at http://{admin_ip}:8080"); + return Ok(()); + } else { + bail!( + "request for url ({rayhunter_url}) failed with status code: {:?}", + test.status() + ); + } + } + Err(e) => { + if failures > MAX_FAILURES { + return Err(e.into()); + } else { + failures += 1; + sleep(Duration::from_secs(3)).await; + } + } + } + } +} + +async fn telnet_send_file(addr: SocketAddr, filename: &str, payload: &[u8]) -> Result<()> { + println!("Sending file {filename}"); + + { + let filename = filename.to_owned(); + let handle = tokio::spawn(async move { + telnet_send_command(addr, &format!("nc -l -p 8081 >{filename}.tmp"), "").await + }); + + sleep(Duration::from_millis(100)).await; + + let mut addr = addr; + addr.set_port(8081); + let mut stream = TcpStream::connect(addr).await?; + stream.write_all(payload).await?; + + handle.await??; + } + + let checksum = md5::compute(payload); + + telnet_send_command( + addr, + &format!("md5sum {filename}.tmp"), + &format!("{checksum:x} {filename}.tmp"), + ) + .await?; + + telnet_send_command( + addr, + &format!("mv {filename}.tmp {filename}"), + "exit code 0", + ) + .await?; + + Ok(()) +} + +#[test] +fn test_encrypt_password() { + let p = b"80536913"; + let s = encrypt_password(p).ok(); + let expected = Some("5brvd8xl732cSoFTAy67ig==".to_string()); + assert_eq!(s, expected); +} diff --git a/lib/Cargo.toml b/lib/Cargo.toml index 6d18d96..24cbdc3 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -13,6 +13,7 @@ path = "src/lib.rs" default = [] orbic = [] tplink = [] +wingtech = [] [dependencies] bytes = "1.5.0" From cba898daf6e0a2714b02c09d353d298cb010d936 Mon Sep 17 00:00:00 2001 From: oopsbagel Date: Thu, 12 Jun 2025 00:39:27 -0700 Subject: [PATCH 02/19] feat(installer/util): telnet functions in util.rs Add installer util wingtech-start-telnet command. Add installer util wingtech-start-adb command. --- installer/src/main.rs | 18 +++++++ installer/src/orbic.rs | 9 +--- installer/src/tplink.rs | 99 ++-------------------------------- installer/src/util.rs | 90 +++++++++++++++++++++++++++++++ installer/src/wingtech.rs | 110 ++++++++++++++------------------------ 5 files changed, 153 insertions(+), 173 deletions(-) create mode 100644 installer/src/util.rs diff --git a/installer/src/main.rs b/installer/src/main.rs index 9cbbf86..b8fca4f 100644 --- a/installer/src/main.rs +++ b/installer/src/main.rs @@ -5,6 +5,7 @@ use env_logger::Env; mod orbic; mod tplink; mod wingtech; +mod util; pub static CONFIG_TOML: &str = include_str!("../../dist/config.toml.example"); pub static RAYHUNTER_DAEMON_INIT: &str = include_str!("../../dist/scripts/rayhunter_daemon"); @@ -79,6 +80,10 @@ enum UtilSubCommand { Shell(Shell), /// Root the tplink and launch telnetd. TplinkStartTelnet(TplinkStartTelnet), + /// Root the Wingtech and launch telnetd. + WingtechStartTelnet(WingtechArgs), + /// Root the Wingtech and launch adb. + WingtechStartAdb(WingtechArgs), } #[derive(Parser, Debug)] @@ -88,6 +93,17 @@ struct TplinkStartTelnet { admin_ip: String, } +#[derive(Parser, Debug)] +struct WingtechArgs { + /// IP address for Wingtech admin interface, if custom. + #[arg(long, default_value = "192.168.1.1")] + admin_ip: String, + + /// Web portal admin password. + #[arg(long)] + admin_password: String, +} + #[derive(Parser, Debug)] struct Serial { #[arg(long)] @@ -129,6 +145,8 @@ async fn run() -> Result<(), Error> { UtilSubCommand::TplinkStartTelnet(options) => { tplink::start_telnet(&options.admin_ip).await?; } + UtilSubCommand::WingtechStartTelnet(args) => wingtech::start_telnet(&args.admin_ip, &args.admin_password).await.context("\nFailed to start telnet on the Wingtech CT2MHS01")?, + UtilSubCommand::WingtechStartAdb(args) => wingtech::start_adb(&args.admin_ip, &args.admin_password).await.context("\nFailed to start telnet on the Wingtech CT2MHS01")?, } } diff --git a/installer/src/orbic.rs b/installer/src/orbic.rs index 566d909..3e5a38a 100644 --- a/installer/src/orbic.rs +++ b/installer/src/orbic.rs @@ -10,6 +10,7 @@ use sha2::{Digest, Sha256}; use tokio::time::sleep; use crate::{CONFIG_TOML, RAYHUNTER_DAEMON_INIT}; +use crate::util::echo; pub const ORBIC_NOT_FOUND: &str = r#"No Orbic device found. Make sure your device is plugged in and turned on. @@ -40,14 +41,6 @@ const RNDIS_INTERFACE: u8 = 0; #[cfg(not(target_os = "windows"))] const RNDIS_INTERFACE: u8 = 1; -macro_rules! echo { - ($($arg:tt)*) => { - print!($($arg)*); - let _ = std::io::stdout().flush(); - }; -} -pub(crate) use echo; - pub async fn install() -> Result<()> { let mut adb_device = force_debug_mode().await?; echo!("Installing rootshell... "); diff --git a/installer/src/tplink.rs b/installer/src/tplink.rs index 60c844a..63d7606 100644 --- a/installer/src/tplink.rs +++ b/installer/src/tplink.rs @@ -15,11 +15,10 @@ use bytes::{Bytes, BytesMut}; use hyper::StatusCode; use hyper_util::{client::legacy::connect::HttpConnector, rt::TokioExecutor}; use serde::Deserialize; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpStream; -use tokio::time::{sleep, timeout}; +use tokio::time::sleep; use crate::InstallTpLink; +use crate::util::{telnet_send_command, telnet_send_file}; type HttpProxyClient = hyper_util::client::legacy::Client; @@ -159,6 +158,7 @@ async fn tplink_run_install( rayhunter_daemon_bin, ) .await?; + telnet_send_file( addr, "/etc/init.d/rayhunter_daemon", @@ -195,99 +195,6 @@ async fn tplink_run_install( Ok(()) } -async fn telnet_send_file(addr: SocketAddr, filename: &str, payload: &[u8]) -> Result<(), Error> { - println!("Sending file {filename}"); - - // remove the old file just in case we are close to disk capacity. - telnet_send_command(addr, &format!("rm {filename}"), "").await?; - - { - let filename = filename.to_owned(); - let handle = tokio::spawn(async move { - telnet_send_command(addr, &format!("nc -l 0.0.0.0:8081 > {filename}.tmp"), "").await - }); - - sleep(Duration::from_millis(100)).await; - - let mut addr = addr; - addr.set_port(8081); - let mut stream = TcpStream::connect(addr).await?; - stream.write_all(payload).await?; - - handle.await??; - } - - let checksum = md5::compute(payload); - - telnet_send_command( - addr, - &format!("md5sum {filename}.tmp"), - &format!("{checksum:x} {filename}.tmp"), - ) - .await?; - - telnet_send_command( - addr, - &format!("mv {filename}.tmp {filename}"), - "exit code 0", - ) - .await?; - - Ok(()) -} - -pub async fn telnet_send_command( - addr: SocketAddr, - command: &str, - expected_output: &str, -) -> Result<(), Error> { - let stream = TcpStream::connect(addr).await?; - let (mut reader, mut writer) = stream.into_split(); - - loop { - let mut next_byte = 0; - reader - .read_exact(std::slice::from_mut(&mut next_byte)) - .await?; - if next_byte == b'#' { - break; - } - } - - writer.write_all(command.as_bytes()).await?; - writer.write_all(b"; echo exit code $?\r\n").await?; - - let mut read_buf = Vec::new(); - - let _ = timeout(Duration::from_secs(5), async { - let mut buf = [0; 4096]; - loop { - let Ok(bytes_read) = reader.read(&mut buf).await else { - break; - }; - let bytes = &buf[..bytes_read]; - if bytes.is_empty() { - continue; - } - - read_buf.extend(bytes); - - if read_buf.ends_with(b"/ # ") { - break; - } - } - }) - .await; - - let string = String::from_utf8_lossy(&read_buf); - - if !string.contains(expected_output) { - anyhow::bail!("{expected_output:?} not found in: {string}"); - } - - Ok(()) -} - #[derive(Clone)] struct AppState { client: HttpProxyClient, diff --git a/installer/src/util.rs b/installer/src/util.rs new file mode 100644 index 0000000..0280c7b --- /dev/null +++ b/installer/src/util.rs @@ -0,0 +1,90 @@ +use std::io::Write; +use std::net::SocketAddr; +use std::time::Duration; + +use anyhow::{bail, Result}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; +use tokio::time::{sleep, timeout}; + +macro_rules! echo { + ($($arg:tt)*) => { + print!($($arg)*); + let _ = std::io::stdout().flush(); + }; +} +pub(crate) use echo; + +pub async fn telnet_send_command( + addr: SocketAddr, + command: &str, + expected_output: &str, +) -> Result<()> { + let stream = TcpStream::connect(addr).await?; + let (mut reader, mut writer) = stream.into_split(); + loop { + let mut next_byte = 0; + reader + .read_exact(std::slice::from_mut(&mut next_byte)) + .await?; + if next_byte == b'#' { + break; + } + } + writer.write_all(command.as_bytes()).await?; + writer.write_all(b"; echo exit code $?\r\n").await?; + let mut read_buf = Vec::new(); + let _ = timeout(Duration::from_secs(5), async { + let mut buf = [0; 4096]; + loop { + let Ok(bytes_read) = reader.read(&mut buf).await else { + break; + }; + let bytes = &buf[..bytes_read]; + if bytes.is_empty() { + continue; + } + read_buf.extend(bytes); + if read_buf.ends_with(b"/ # ") { + break; + } + } + }) + .await; + let string = String::from_utf8_lossy(&read_buf); + if !string.contains(expected_output) { + bail!("{expected_output:?} not found in: {string}"); + } + Ok(()) +} + +pub async fn telnet_send_file(addr: SocketAddr, filename: &str, payload: &[u8]) -> Result<()> { + echo!("Sending file {filename} ... "); + { + let filename = filename.to_owned(); + let handle = tokio::spawn(async move { + telnet_send_command(addr, &format!("nc -l -p 8081 >{filename}.tmp"), "").await + }); + sleep(Duration::from_millis(100)).await; + let mut addr = addr; + addr.set_port(8081); + let mut stream = TcpStream::connect(addr).await?; + stream.write_all(payload).await?; + handle.await??; + } + let checksum = md5::compute(payload); + telnet_send_command( + addr, + &format!("md5sum {filename}.tmp"), + &format!("{checksum:x} {filename}.tmp"), + ) + .await?; + telnet_send_command( + addr, + &format!("mv {filename}.tmp {filename}"), + "exit code 0", + ) + .await?; + println!("ok"); + Ok(()) +} diff --git a/installer/src/wingtech.rs b/installer/src/wingtech.rs index 35aa2b1..576d0df 100644 --- a/installer/src/wingtech.rs +++ b/installer/src/wingtech.rs @@ -15,13 +15,10 @@ use anyhow::{Result, bail}; use base64_light::base64_encode_bytes; use block_padding::{Padding, Pkcs7}; use reqwest::Client; -use tokio::io::AsyncWriteExt; -use tokio::net::TcpStream; use tokio::time::sleep; use crate::InstallWingtech as Args; -use crate::orbic::echo; -use crate::tplink::telnet_send_command; +use crate::util::{echo, telnet_send_command, telnet_send_file}; pub async fn install( Args { @@ -45,7 +42,15 @@ fn encrypt_password(password: &[u8]) -> Result { Ok(base64_encode_bytes(&b)) } -pub async fn start_telnet(admin_ip: &str, admin_password: &str) -> Result { +pub async fn start_telnet(admin_ip: &str, admin_password: &str) -> Result<()> { + run_command(admin_ip, admin_password, "busybox telnetd -l /bin/sh").await +} + +pub async fn start_adb(admin_ip: &str, admin_password: &str) -> Result<()> { + run_command(admin_ip, admin_password, "/sbin/usb/compositions/9025").await +} + +async fn run_command(admin_ip: &str, admin_password: &str, cmd: &str) -> Result<()> { let qcmap_auth_endpoint = format!("http://{admin_ip}/cgi-bin/qcmap_auth"); let qcmap_web_cgi_endpoint = format!("http://{admin_ip}/cgi-bin/qcmap_web_cgi"); @@ -66,9 +71,8 @@ pub async fn start_telnet(admin_ip: &str, admin_password: &str) -> Result None => bail!("login did not return a token in response: {}", login), }; - let cmd = "busybox telnetd -l /bin/sh"; let telnet = client.post(&qcmap_web_cgi_endpoint) - .body(format!("page=setFWMacFilter&cmd=add&mode=0&mac=50:5A:CA:B5:05:AC||{cmd}&key=50:5A:CA:B5:05:AC&token={token}")) + .body(format!("page=setFWMacFilter&cmd=add&mode=0&mac=50:5A:CA:B5:05||{cmd}&key=50:5A:CA:B5:05:AC&token={token}")) .send() .await?; if telnet.status() != 200 { @@ -78,7 +82,7 @@ pub async fn start_telnet(admin_ip: &str, admin_password: &str) -> Result ); } - Ok(true) + Ok(()) } async fn wingtech_run_install(admin_ip: String, admin_password: String) -> Result<()> { @@ -130,71 +134,39 @@ async fn wingtech_run_install(admin_ip: String, admin_password: String) -> Resul telnet_send_command(addr, "reboot", "exit code 0").await?; sleep(Duration::from_secs(30)).await; - echo!("Testing rayhunter... "); - const MAX_FAILURES: u32 = 10; - let mut failures = 0; - let rayhunter_url = format!("http://{admin_ip}:8080/index.html"); - let client = Client::new(); - loop { - match client.get(&rayhunter_url).send().await { - Ok(test) => { - if test.status() == 200 { - println!("rayhunter is running at http://{admin_ip}:8080"); - return Ok(()); - } else { - bail!( - "request for url ({rayhunter_url}) failed with status code: {:?}", - test.status() - ); - } - } - Err(e) => { - if failures > MAX_FAILURES { - return Err(e.into()); - } else { - failures += 1; - sleep(Duration::from_secs(3)).await; - } - } - } - } + echo!("Testing rayhunter ... "); + let max_failures = 10; + http_ok_every( + format!("http://{admin_ip}:8080/index.html"), + Duration::from_secs(3), + max_failures, + ).await?; + println!("ok"); + println!("rayhunter is running at http://{admin_ip}:8080"); + + Ok(()) } -async fn telnet_send_file(addr: SocketAddr, filename: &str, payload: &[u8]) -> Result<()> { - println!("Sending file {filename}"); - - { - let filename = filename.to_owned(); - let handle = tokio::spawn(async move { - telnet_send_command(addr, &format!("nc -l -p 8081 >{filename}.tmp"), "").await - }); - - sleep(Duration::from_millis(100)).await; - - let mut addr = addr; - addr.set_port(8081); - let mut stream = TcpStream::connect(addr).await?; - stream.write_all(payload).await?; - - handle.await??; +async fn http_ok_every(rayhunter_url: String, interval: Duration, max_failures: u32) -> Result<()> { + let client = Client::new(); + let mut failures = 0; + loop { + match client.get(&rayhunter_url).send().await { + Ok(test) => match test.status().is_success() { + true => break, + false => bail!( + "request for url ({rayhunter_url}) failed with status code: {:?}", + test.status() + ), + }, + Err(e) => match failures > max_failures { + true => return Err(e.into()), + false => failures += 1, + }, + } + sleep(interval).await; } - let checksum = md5::compute(payload); - - telnet_send_command( - addr, - &format!("md5sum {filename}.tmp"), - &format!("{checksum:x} {filename}.tmp"), - ) - .await?; - - telnet_send_command( - addr, - &format!("mv {filename}.tmp {filename}"), - "exit code 0", - ) - .await?; - Ok(()) } From 79b2628d2f8e376ab61537dfff557f097d216390 Mon Sep 17 00:00:00 2001 From: oopsbagel Date: Thu, 12 Jun 2025 00:43:00 -0700 Subject: [PATCH 03/19] chore: cargo fmt --- installer/src/main.rs | 2 +- installer/src/orbic.rs | 2 +- installer/src/util.rs | 2 +- installer/src/wingtech.rs | 3 ++- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/installer/src/main.rs b/installer/src/main.rs index b8fca4f..17f0831 100644 --- a/installer/src/main.rs +++ b/installer/src/main.rs @@ -4,8 +4,8 @@ use env_logger::Env; mod orbic; mod tplink; -mod wingtech; mod util; +mod wingtech; pub static CONFIG_TOML: &str = include_str!("../../dist/config.toml.example"); pub static RAYHUNTER_DAEMON_INIT: &str = include_str!("../../dist/scripts/rayhunter_daemon"); diff --git a/installer/src/orbic.rs b/installer/src/orbic.rs index 3e5a38a..a09e7af 100644 --- a/installer/src/orbic.rs +++ b/installer/src/orbic.rs @@ -9,8 +9,8 @@ use nusb::{Device, Interface}; use sha2::{Digest, Sha256}; use tokio::time::sleep; -use crate::{CONFIG_TOML, RAYHUNTER_DAEMON_INIT}; use crate::util::echo; +use crate::{CONFIG_TOML, RAYHUNTER_DAEMON_INIT}; pub const ORBIC_NOT_FOUND: &str = r#"No Orbic device found. Make sure your device is plugged in and turned on. diff --git a/installer/src/util.rs b/installer/src/util.rs index 0280c7b..1f7ce86 100644 --- a/installer/src/util.rs +++ b/installer/src/util.rs @@ -2,7 +2,7 @@ use std::io::Write; use std::net::SocketAddr; use std::time::Duration; -use anyhow::{bail, Result}; +use anyhow::{Result, bail}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; use tokio::time::{sleep, timeout}; diff --git a/installer/src/wingtech.rs b/installer/src/wingtech.rs index 576d0df..94a7645 100644 --- a/installer/src/wingtech.rs +++ b/installer/src/wingtech.rs @@ -140,7 +140,8 @@ async fn wingtech_run_install(admin_ip: String, admin_password: String) -> Resul format!("http://{admin_ip}:8080/index.html"), Duration::from_secs(3), max_failures, - ).await?; + ) + .await?; println!("ok"); println!("rayhunter is running at http://{admin_ip}:8080"); From 3ae2636d9ebee4051f0c998b8147ebf6228df059 Mon Sep 17 00:00:00 2001 From: oopsbagel Date: Thu, 12 Jun 2025 00:44:36 -0700 Subject: [PATCH 04/19] chore(installer/build.rs): cargo clippy --- installer/build.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/installer/build.rs b/installer/build.rs index 9bb50e8..0da4a53 100644 --- a/installer/build.rs +++ b/installer/build.rs @@ -8,19 +8,19 @@ fn main() { env!("CARGO_MANIFEST_DIR"), "/../target/armv7-unknown-linux-musleabihf/firmware/" )); - set_binary_var(&include_dir, "FILE_ROOTSHELL", "rootshell"); + set_binary_var(include_dir, "FILE_ROOTSHELL", "rootshell"); set_binary_var( - &include_dir, + include_dir, "FILE_RAYHUNTER_DAEMON_ORBIC", "rayhunter-daemon", ); set_binary_var( - &include_dir, + include_dir, "FILE_RAYHUNTER_DAEMON_TPLINK", "rayhunter-daemon", ); set_binary_var( - &include_dir, + include_dir, "FILE_RAYHUNTER_DAEMON_WINGTECH", "rayhunter-daemon", ); @@ -31,7 +31,7 @@ fn set_binary_var(include_dir: &Path, var: &str, file: &str) { let out_dir = std::env::var("OUT_DIR").unwrap(); std::fs::create_dir_all(&out_dir).unwrap(); let blank = Path::new(&out_dir).join("blank"); - std::fs::write(&blank, &[]).unwrap(); + std::fs::write(&blank, []).unwrap(); println!("cargo::rustc-env={var}={}", blank.display()); return; } From 3a3adb055bc2b33a126f1506d94499b76eb4250b Mon Sep 17 00:00:00 2001 From: oopsbagel Date: Thu, 12 Jun 2025 00:45:52 -0700 Subject: [PATCH 05/19] fix(installer): wingtech adb error message --- installer/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/installer/src/main.rs b/installer/src/main.rs index 17f0831..901e509 100644 --- a/installer/src/main.rs +++ b/installer/src/main.rs @@ -146,7 +146,7 @@ async fn run() -> Result<(), Error> { tplink::start_telnet(&options.admin_ip).await?; } UtilSubCommand::WingtechStartTelnet(args) => wingtech::start_telnet(&args.admin_ip, &args.admin_password).await.context("\nFailed to start telnet on the Wingtech CT2MHS01")?, - UtilSubCommand::WingtechStartAdb(args) => wingtech::start_adb(&args.admin_ip, &args.admin_password).await.context("\nFailed to start telnet on the Wingtech CT2MHS01")?, + UtilSubCommand::WingtechStartAdb(args) => wingtech::start_adb(&args.admin_ip, &args.admin_password).await.context("\nFailed to start adb on the Wingtech CT2MHS01")?, } } From 241fb2789b35e1e57c0346cf8d2840d6858afa5d Mon Sep 17 00:00:00 2001 From: oopsbagel Date: Thu, 12 Jun 2025 01:01:05 -0700 Subject: [PATCH 06/19] fix(display/mod.rs): remove "many devices" check This check is unnecessary, cargo will not build if multiple versions of fn update_ui are defined. --- bin/src/display/mod.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/bin/src/display/mod.rs b/bin/src/display/mod.rs index 3708305..785cee6 100644 --- a/bin/src/display/mod.rs +++ b/bin/src/display/mod.rs @@ -26,11 +26,5 @@ pub enum DisplayState { WarningDetected, } -#[cfg(all(feature = "orbic", feature = "tplink"))] -compile_error!("cannot compile for many devices at once"); - -#[cfg(all(feature = "orbic", feature = "wingtech"))] -compile_error!("cannot compile for many devices at once"); - #[cfg(not(any(feature = "orbic", feature = "tplink", feature = "wingtech",)))] compile_error!("cannot compile for no device at all"); From 95951c5c381edfcd2bb93ca9f8b6d0fd6a0aa62e Mon Sep 17 00:00:00 2001 From: oopsbagel Date: Thu, 12 Jun 2025 01:04:29 -0700 Subject: [PATCH 07/19] fix(display/mod.rs): remove "no device" check This check is unnecessary, cargo will not build if fn update_ui is undefined. --- bin/src/display/mod.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/bin/src/display/mod.rs b/bin/src/display/mod.rs index 785cee6..cf7a3d2 100644 --- a/bin/src/display/mod.rs +++ b/bin/src/display/mod.rs @@ -25,6 +25,3 @@ pub enum DisplayState { Paused, WarningDetected, } - -#[cfg(not(any(feature = "orbic", feature = "tplink", feature = "wingtech",)))] -compile_error!("cannot compile for no device at all"); From cb6f79f67a3c5633812443acbb153edcfe61eb55 Mon Sep 17 00:00:00 2001 From: oopsbagel Date: Thu, 12 Jun 2025 01:11:01 -0700 Subject: [PATCH 08/19] ci: build wingtech --- .github/workflows/main.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 105ff57..1cf691c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -10,6 +10,7 @@ env: FILE_ROOTSHELL: ../../rootshell/rootshell FILE_RAYHUNTER_DAEMON_ORBIC: ../../rayhunter-daemon-orbic/rayhunter-daemon FILE_RAYHUNTER_DAEMON_TPLINK: ../../rayhunter-daemon-tplink/rayhunter-daemon + FILE_RAYHUNTER_DAEMON_WINGTECH: ../../rayhunter-daemon-wingtech/rayhunter-daemon jobs: files_changed: @@ -95,8 +96,9 @@ jobs: strategy: matrix: device: - - name: tplink - name: orbic + - name: tplink + - name: wingtech runs-on: ubuntu-latest permissions: contents: read @@ -206,8 +208,9 @@ jobs: strategy: matrix: device: - - name: tplink - name: orbic + - name: tplink + - name: wingtech runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 From f56acdf89d31b49667c7f02c6b777ac59006146a Mon Sep 17 00:00:00 2001 From: oopsbagel Date: Thu, 12 Jun 2025 12:46:08 -0700 Subject: [PATCH 09/19] fix(installer/wingtech): better Response variable --- installer/src/wingtech.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/installer/src/wingtech.rs b/installer/src/wingtech.rs index 94a7645..106cc38 100644 --- a/installer/src/wingtech.rs +++ b/installer/src/wingtech.rs @@ -71,14 +71,14 @@ async fn run_command(admin_ip: &str, admin_password: &str, cmd: &str) -> Result< None => bail!("login did not return a token in response: {}", login), }; - let telnet = client.post(&qcmap_web_cgi_endpoint) + let command = client.post(&qcmap_web_cgi_endpoint) .body(format!("page=setFWMacFilter&cmd=add&mode=0&mac=50:5A:CA:B5:05||{cmd}&key=50:5A:CA:B5:05:AC&token={token}")) .send() .await?; - if telnet.status() != 200 { + if command.status() != 200 { bail!( - "starting telnet failed with status code: {:?}", - telnet.status() + "running command failed with status code: {:?}", + command.status() ); } From a234df1e1e758f75d36a637ed49d35d8f6c32355 Mon Sep 17 00:00:00 2001 From: oopsbagel Date: Fri, 13 Jun 2025 21:39:12 -0700 Subject: [PATCH 10/19] fix(installer/wingtech): only say ok if telnet connected --- installer/src/wingtech.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/installer/src/wingtech.rs b/installer/src/wingtech.rs index 106cc38..d619f7f 100644 --- a/installer/src/wingtech.rs +++ b/installer/src/wingtech.rs @@ -92,9 +92,8 @@ async fn wingtech_run_install(admin_ip: String, admin_password: String) -> Resul echo!("Connecting via telnet to {admin_ip} ... "); let addr = SocketAddr::from_str(&format!("{admin_ip}:23")).unwrap(); - println!("ok"); - telnet_send_command(addr, "mkdir -p /data/rayhunter", "exit code 0").await?; + println!("ok"); telnet_send_file( addr, From 8aadfc20f28583642e7c78761817e461518c39d1 Mon Sep 17 00:00:00 2001 From: oopsbagel Date: Fri, 13 Jun 2025 22:00:20 -0700 Subject: [PATCH 11/19] fix(installer/wingtech): remove duplicated struct --- installer/src/main.rs | 13 +------------ installer/src/wingtech.rs | 2 +- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/installer/src/main.rs b/installer/src/main.rs index 901e509..de785b8 100644 --- a/installer/src/main.rs +++ b/installer/src/main.rs @@ -24,7 +24,7 @@ enum Command { /// Install rayhunter on the TP-Link M7350. Tplink(InstallTpLink), /// Install rayhunter on the Wingtech CT2MHS01. - Wingtech(InstallWingtech), + Wingtech(WingtechArgs), /// Developer utilities. Util(Util), } @@ -55,17 +55,6 @@ struct InstallTpLink { #[derive(Parser, Debug)] struct InstallOrbic {} -#[derive(Parser, Debug)] -struct InstallWingtech { - /// IP address for Wingtech admin interface, if custom. - #[arg(long, default_value = "192.168.1.1")] - admin_ip: String, - - /// Web portal admin password. - #[arg(long)] - admin_password: String, -} - #[derive(Parser, Debug)] struct Util { #[command(subcommand)] diff --git a/installer/src/wingtech.rs b/installer/src/wingtech.rs index d619f7f..2f8f362 100644 --- a/installer/src/wingtech.rs +++ b/installer/src/wingtech.rs @@ -17,7 +17,7 @@ use block_padding::{Padding, Pkcs7}; use reqwest::Client; use tokio::time::sleep; -use crate::InstallWingtech as Args; +use crate::WingtechArgs as Args; use crate::util::{echo, telnet_send_command, telnet_send_file}; pub async fn install( From 3ff714972c27dbbf8c2b5508008654ac294c3c49 Mon Sep 17 00:00:00 2001 From: oopsbagel Date: Mon, 16 Jun 2025 00:38:28 -0700 Subject: [PATCH 12/19] fix(installer/wingtech): parse json login response Use serde via reqwest to deserialize the login response from qcmap_auth when obtaining an authentication token instead of golfing based on the specific token length. --- installer/src/wingtech.rs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/installer/src/wingtech.rs b/installer/src/wingtech.rs index 2f8f362..3ec7bc7 100644 --- a/installer/src/wingtech.rs +++ b/installer/src/wingtech.rs @@ -11,15 +11,21 @@ use std::time::Duration; use aes::Aes128; use aes::cipher::{BlockEncrypt, KeyInit, generic_array::GenericArray}; -use anyhow::{Result, bail}; +use anyhow::{Context, Result, bail}; use base64_light::base64_encode_bytes; use block_padding::{Padding, Pkcs7}; use reqwest::Client; +use serde::Deserialize; use tokio::time::sleep; use crate::WingtechArgs as Args; use crate::util::{echo, telnet_send_command, telnet_send_file}; +#[derive(Deserialize)] +struct LoginResponse { + token: String, +} + pub async fn install( Args { admin_ip, @@ -57,19 +63,16 @@ async fn run_command(admin_ip: &str, admin_password: &str, cmd: &str) -> Result< let encrypted_pw = encrypt_password(admin_password.as_bytes()).ok().unwrap(); let client = Client::new(); - let login = client + let LoginResponse { token } = client .post(&qcmap_auth_endpoint) .body(format!( "type=login&pwd={encrypted_pw}&timeout=60000&user=admin" )) .send() .await? - .text() - .await?; - let token = match login.find("token") { - Some(n) => &login[n + 8..n + 8 + 16], - None => bail!("login did not return a token in response: {}", login), - }; + .json() + .await + .context("login did not return a token in response")?; let command = client.post(&qcmap_web_cgi_endpoint) .body(format!("page=setFWMacFilter&cmd=add&mode=0&mac=50:5A:CA:B5:05||{cmd}&key=50:5A:CA:B5:05:AC&token={token}")) From e601320b3f87e5f1415c7f4d8f7efe224b63463c Mon Sep 17 00:00:00 2001 From: Markus Unterwaditzer Date: Fri, 20 Jun 2025 02:01:55 +0200 Subject: [PATCH 13/19] Fix broken installer on TP-Link M7350 v9 TP-Link v9 was inadvertently broken via https://github.com/EFForg/rayhunter/pull/390 --- installer/src/tplink.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/installer/src/tplink.rs b/installer/src/tplink.rs index 5c4cc9e..ee63a0e 100644 --- a/installer/src/tplink.rs +++ b/installer/src/tplink.rs @@ -50,7 +50,7 @@ pub async fn start_telnet(admin_ip: &str) -> Result { // in particular: https://www.yuque.com/docs/share/fca60ef9-e5a4-462a-a984-61def4c9b132 format!("http://{admin_ip}/cgi-bin/qcmap_web_cgi"), // TP-Link M7310 v1 - // (adaptation of M7350 exploit + // (adaptation of M7350 exploit) format!("http://{admin_ip}/cgi-bin/web_cgi"), ] { let response = client.post(&endpoint) @@ -62,7 +62,10 @@ pub async fn start_telnet(admin_ip: &str) -> Result { continue; } - let V3RootResponse { result } = response.error_for_status()?.json().await?; + let Ok(V3RootResponse { result }) = response.error_for_status()?.json().await else { + // On TP-Link M7350 v9, the endpoint /cgi-bin/web_cgi returns 200 OK without launching telnet, and without a response body. + continue; + }; if result != 0 { anyhow::bail!("Bad result code when trying to root device: {result}"); From 0f98b0547506bc51adbec0666c1d1dfe79056687 Mon Sep 17 00:00:00 2001 From: Markus Unterwaditzer Date: Thu, 19 Jun 2025 23:33:21 +0200 Subject: [PATCH 14/19] Try out rust-cache action --- .github/workflows/main.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 720493c..50b19e3 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -106,6 +106,7 @@ jobs: contents: read steps: - uses: actions/checkout@v4 + - uses: Swatinem/rust-cache@v2 - name: Check formatting run: cargo fmt --all --check - name: Check @@ -130,6 +131,7 @@ jobs: contents: read steps: - uses: actions/checkout@v4 + - uses: Swatinem/rust-cache@v2 - name: cargo check shell: bash run: | @@ -170,6 +172,7 @@ jobs: runs-on: ${{ matrix.platform.os }} steps: - uses: actions/checkout@v4 + - uses: Swatinem/rust-cache@v2 - name: Build rayhunter-check run: cargo build --bin rayhunter-check --release - uses: actions/upload-artifact@v4 @@ -191,6 +194,7 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: targets: armv7-unknown-linux-musleabihf + - uses: Swatinem/rust-cache@v2 - name: Build rootshell (arm32) run: cargo build --bin rootshell --target armv7-unknown-linux-musleabihf --profile=firmware - uses: actions/upload-artifact@v4 @@ -219,6 +223,7 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: targets: armv7-unknown-linux-musleabihf + - uses: Swatinem/rust-cache@v2 - name: Build rayhunter-daemon (arm32) run: | pushd bin/web @@ -275,6 +280,7 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.platform.target }} + - uses: Swatinem/rust-cache@v2 - run: cargo build --bin installer --release --target ${{ matrix.platform.target }} - uses: actions/upload-artifact@v4 with: From a17e255148c0d419c26d37128974e554642cf547 Mon Sep 17 00:00:00 2001 From: Markus Unterwaditzer Date: Thu, 19 Jun 2025 23:37:28 +0200 Subject: [PATCH 15/19] un-skip jobs for testing --- .github/workflows/main.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 50b19e3..62daa2f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -34,7 +34,9 @@ jobs: id: files_changed run: | lcommit=${{ github.event.pull_request.base.sha || 'origin/main' }} - if [ ${{ github.ref }} = 'refs/heads/main' ] + + # If we are on main, or if these workflow files are being changed, run everything + if [ ${{ github.ref }} = 'refs/heads/main' ] || git diff --name-only $lcommit..HEAD | grep -qe ^.github/workflows/ then echo "building everything" echo code_count=forced >> "$GITHUB_OUTPUT" From e52d382514f2b26c0a5ccb1c8d7ea4470fef8e81 Mon Sep 17 00:00:00 2001 From: Markus Unterwaditzer Date: Fri, 20 Jun 2025 11:58:36 +0200 Subject: [PATCH 16/19] Make SvelteKit build reproducible --- bin/web/svelte.config.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bin/web/svelte.config.js b/bin/web/svelte.config.js index c7c635e..b2a4b0b 100644 --- a/bin/web/svelte.config.js +++ b/bin/web/svelte.config.js @@ -10,6 +10,11 @@ export default { fallback: undefined, precompress: false, strict: true - }) + }), + version: { + // Use a deterministic version string for reproducible builds. + // Without this option, SvelteKit will use a timestamp. + name: process.env.GITHUB_SHA || 'dev' + } } }; From 29823d3e82ecd0ab42c5fd0af65a1e46f2eed542 Mon Sep 17 00:00:00 2001 From: Markus Unterwaditzer Date: Fri, 20 Jun 2025 01:35:17 +0200 Subject: [PATCH 17/19] Update documentation and fix broken links * Add a new configuration page and move content out from TP-Link. The Configuration section in TP-Link is duplicating what is already in config.toml.example, and given that we have recently added a lot of new options I don't want to maintain multiple copies. * Lots of anchor links were broken since we moved docs from README into mdbook. Fix them all. * Document that the key input feature is disabled since 0.4.0. * Smaller cosmetic changes: * Make TP-Link M7350 page consistent with TP-Link M7310 page. * Fix indentation on some bullet points. * Center-align the rayhunter logo in introduction.md to calm my soul. It is still misaligned with the page title above itself. * Add "edit on github" link in mdbook settings. --- book.toml | 3 +++ doc/SUMMARY.md | 1 + doc/configuration.md | 5 +++++ doc/heuristics.md | 2 +- doc/installing-from-release-windows.md | 2 +- doc/installing-from-release.md | 6 +++--- doc/introduction.md | 4 ++-- doc/orbic.md | 6 ++++++ doc/tplink-m7350.md | 22 ++++++---------------- doc/using-rayhunter.md | 4 +++- 10 files changed, 31 insertions(+), 24 deletions(-) create mode 100644 doc/configuration.md diff --git a/book.toml b/book.toml index 282a966..f86dcf1 100644 --- a/book.toml +++ b/book.toml @@ -3,3 +3,6 @@ authors = ["The Rayhunter Team"] language = "en" src = "doc" title = "Rayhunter - An IMSI Catcher Catcher" + +[output.html] +edit-url-template = "https://github.com/efforg/rayhunter/edit/main/{path}" diff --git a/doc/SUMMARY.md b/doc/SUMMARY.md index f0d54be..c162b14 100644 --- a/doc/SUMMARY.md +++ b/doc/SUMMARY.md @@ -6,6 +6,7 @@ - [Installing from the latest release (Windows)](./installing-from-release-windows.md) - [Installing from source](./installing-from-source.md) - [Updating Rayhunter](./updating-rayhunter.md) +- [Configuration](./configuration.md) - [Uninstalling](./uninstalling.md) - [Using Rayhunter](./using-rayhunter.md) - [Rayhunter's heuristics](./heuristics.md) diff --git a/doc/configuration.md b/doc/configuration.md new file mode 100644 index 0000000..b0e2e5d --- /dev/null +++ b/doc/configuration.md @@ -0,0 +1,5 @@ +# Configuration + +Rayhunter can be configured by editing `/data/rayhunter/config.toml` on the device. You can obtain a shell on [orbic](./orbic.md#obtaining-a-shell) and [tplink](./tplink-m7350.md#obtaining-a-shell) and edit the file manually. In future versions the web UI will allow you to edit the config as well. + +View the [default configuration file on GitHub](https://github.com/EFForg/rayhunter/blob/main/dist/config.toml.example). diff --git a/doc/heuristics.md b/doc/heuristics.md index 661bd40..2541f01 100644 --- a/doc/heuristics.md +++ b/doc/heuristics.md @@ -1,6 +1,6 @@ # Heuristics -Rayhunter includes several analyzers to detect potential IMSI catcher activity. These can be enabled and disabled in your [config.toml](https://github.com/EFForg/rayhunter/blob/main/dist/config.toml.example) file. +Rayhunter includes several analyzers to detect potential IMSI catcher activity. These can be enabled and disabled in your [config.toml](./configuration.md) file. ## Available Analyzers diff --git a/doc/installing-from-release-windows.md b/doc/installing-from-release-windows.md index 00106cb..af26a14 100644 --- a/doc/installing-from-release-windows.md +++ b/doc/installing-from-release-windows.md @@ -29,4 +29,4 @@ Windows support in Rayhunter's installer is a work-in-progress. Depending on the 5. Run the install script: `.\installer.exe orbic` and hit enter. - The device will restart multiple times over the next few minutes. - You will know it is done when you see terminal output that says `checking for rayhunter server...success!` -6. Rayhunter should now be running! You can verify this by following the instructions below to [view the web UI](#usage-viewing-the-web-ui). You should also see a green line flash along the top of top the display on the device. +6. Rayhunter should now be running! You can verify this by following the instructions below to [view the web UI](./using-rayhunter.md#the-web-ui). You should also see a green line flash along the top of top the display on the device. diff --git a/doc/installing-from-release.md b/doc/installing-from-release.md index 0a5b108..852b420 100644 --- a/doc/installing-from-release.md +++ b/doc/installing-from-release.md @@ -12,8 +12,8 @@ Make sure you've got one of Rayhunter's [supported devices](./supported-devices. 3. Turn on your device by holding the power button on the front. - * For the Orbic, connect the device using a USB-C cable. - * For TP-Link, connect to its network using either WiFi or USB Tethering. + * For the Orbic, connect the device using a USB-C cable. + * For TP-Link, connect to its network using either WiFi or USB Tethering. 4. Run the install script for your operating system: @@ -38,7 +38,7 @@ Make sure you've got one of Rayhunter's [supported devices](./supported-devices. You will know it is done when you see terminal output that says `Testing Rayhunter... done` -5. Rayhunter should now be running! You can verify this by [viewing Rayhunter's web UI](./using-rayhunter). You should also see a green line flash along the top of top the display on the device. +5. Rayhunter should now be running! You can verify this by [viewing Rayhunter's web UI](./using-rayhunter.md). You should also see a green line flash along the top of top the display on the device. ## Troubleshooting diff --git a/doc/introduction.md b/doc/introduction.md index 16fcf5c..6345152 100644 --- a/doc/introduction.md +++ b/doc/introduction.md @@ -1,7 +1,7 @@ -![Rayhunter Logo - An Orca taking a bite out of a cellular signal bar](https://www.eff.org/files/styles/media_browser_preview/public/banner_library/rayhunter-banner.png) - # Rayhunter +Rayhunter Logo - An Orca taking a bite out of a cellular signal bar + Rayhunter is a project for detecting IMSI catchers, also known as cell-site simulators or stingrays. It's designed to run on a cheap mobile hotspot called the Orbic RC400L, but thanks to community efforts can [support some other devices as well](./supported-devices.md). It's also designed to be as easy to install and use as possible, regardless of you level of technical skills. This guide should provide you all you need to acquire a compatible device, install Rayhunter, and start catching IMSI catchers. diff --git a/doc/orbic.md b/doc/orbic.md index d4c930a..885de80 100644 --- a/doc/orbic.md +++ b/doc/orbic.md @@ -18,3 +18,9 @@ or on [eBay](https://www.ebay.com/sch/i.html?_nkw=orbic+rc400l). | Wifi 2.4Ghz | b/g/n | | Wifi 5Ghz | a/ac/ax | | Wifi 6 | 🮱 | + +## Obtaining a shell + +After running through the installation procedure, you can obtain a root shell +by running `adb shell` or `./installer util shell`. Then, inside of that shell +you can run `/bin/rootshell` to obtain "fakeroot." diff --git a/doc/tplink-m7350.md b/doc/tplink-m7350.md index 66c3ce6..a5e509d 100644 --- a/doc/tplink-m7350.md +++ b/doc/tplink-m7350.md @@ -1,14 +1,14 @@ # TP-Link M7350 -The TP-Link M7350 is supported by Rayhunter from 0.3.0 release. TP-Link M7350 supports many more frequency bands than Orbic and therefore works in Europe and also in some Asian and African countries. +The TP-Link M7350 is **supported by Rayhunter since 0.3.0**. TP-Link M7350 supports many more frequency bands than Orbic and therefore works in Europe and also in some Asian and African countries. ## Hardware versions The TP-Link comes in many different *hardware versions*. Support for installation varies: -* `1.0`, `2.0`: **Not suported**, probably impossible to obtain anymore (even second-hand), however there is one report that installation is possible on `1.0` (but no reports if it is working or not) +* `1.0`, `2.0`: **Not supported**, devs are not able to obtain a device * `3.0`, `3.2`, `5.0`, `5.2`, `7.0`, `8.0`: **Tested, no known issues since 0.3.0.** -* `6.2`: **One user reported it is working** +* `6.2`: **One user reported it is working, not tested** * `4.0`: **Manual firmware downgrade required** ([issue](https://github.com/EFForg/rayhunter/issues/332)) * `9.0`: **Working since 0.3.2.** @@ -20,7 +20,7 @@ When filing bug reports, particularly with the installer, please always specify You can get your TP-Link M7350 from: -* First check for used offers on Ebay or equivalent, sometimes it's much cheaper there. +* First check for used offers on local sites, sometimes it's much cheaper there. * [Geizhals price comparison](https://geizhals.eu/?fs=tp-link+m7350) * [Ebay](https://www.ebay.com/sch/i.html?_nkw=tp-link+m7350&_sacat=0&_from=R40&_trksid=p4432023.m570.l1313) @@ -28,6 +28,8 @@ You can get your TP-Link M7350 from: Follow the [release installation guide](./installing-from-release.md). Substitute `./installer orbic` for `./installer tplink` in other documentation. The Rayhunter UI will be available at [http://192.168.0.1:8080](http://192.168.0.1:8080). +## Obtaining a shell + Unlike on Orbic, the installer will not enable ADB. Instead, you can obtain a root shell with the following command: ```sh @@ -45,18 +47,6 @@ If your device has a one-bit (black-and-white) display, Rayhunter will instead s * `:)` (smiling) means "recording" * `:` (face with no mouth) means "paused" -## Configuration - -Displaying status can be changed in the configuration (`config.toml`) file, where UI level (`ui_level` variable) could be changed to: -- `0`: invisible mode, no indicator that Rayhunter is running -- `1`: subtle mode, display a green line at the top of the screen when Rayhunter is running -- `2`: demo mode, display a fun Orca GIF -- `3`: display the EFF logo - -You can also change `colorblind_mode` (default is `false`) to `true`. In that case there will be blue line instead of green line. - -You can change the `port` (default is `8080`) where Rayhunter is listening for incoming connections and more advanced users can change the variables `qmdl_store_path` and `debug_mode`. However, change those variables only if you know what you are doing. - ## Power-saving mode/sleep By default the device will go to sleep after N minutes of no devices being connected. In that mode it will also turn off connections to cell phone towers. diff --git a/doc/using-rayhunter.md b/doc/using-rayhunter.md index 6bc067d..60f4750 100644 --- a/doc/using-rayhunter.md +++ b/doc/using-rayhunter.md @@ -1,6 +1,6 @@ # Using Rayhunter -Once installed, Rayhunter will run automatically whenever your device is running. You'll see a green line on top of the device's display to indicate that it's running and recording. [The line will turn red](#red) once a potential IMSI catcher has been found, until the device is rebooted or a new recording is started through the web UI. +Once installed, Rayhunter will run automatically whenever your device is running. You'll see a green line on top of the device's display to indicate that it's running and recording. [The line will turn red](./faq.md#red) once a potential IMSI catcher has been found, until the device is rebooted or a new recording is started through the web UI. ![Rayhunter_0 3 2](./Rayhunter_0.3.2.png) @@ -29,3 +29,5 @@ You can access this UI in one of two ways: ## Key shortcuts As of 0.3.3, you can start a new recording by double-tapping the power button. Any current recording will be stopped and a new recording will be started, resetting the red line as well. + +**This feature is disabled by default since 0.4.0** and needs to be enabled through [configuration](./configuration.md). From b2502847a16985a4df80392117c61c87ad1189d2 Mon Sep 17 00:00:00 2001 From: Markus Unterwaditzer Date: Wed, 18 Jun 2025 23:31:31 +0200 Subject: [PATCH 18/19] Add ZIP download endpoint --- Cargo.lock | 17 ++ bin/Cargo.toml | 4 +- bin/src/daemon.rs | 3 +- bin/src/pcap.rs | 70 +++--- bin/src/server.rs | 200 +++++++++++++++++- .../src/lib/components/ManifestCard.svelte | 1 + .../src/lib/components/ManifestTable.svelte | 3 +- .../lib/components/ManifestTableRow.svelte | 3 +- bin/web/src/lib/manifest.svelte.ts | 4 + 9 files changed, 270 insertions(+), 35 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 85326cd..b3603d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -249,6 +249,20 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "async_zip" +version = "0.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b9f7252833d5ed4b00aa9604b563529dd5e11de9c23615de2dcdf91eb87b52" +dependencies = [ + "crc32fast", + "futures-lite", + "pin-project", + "thiserror 1.0.69", + "tokio", + "tokio-util", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -2374,6 +2388,8 @@ dependencies = [ name = "rayhunter-daemon" version = "0.3.4" dependencies = [ + "anyhow", + "async_zip", "axum", "chrono", "clap", @@ -3067,6 +3083,7 @@ checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", "futures-util", "hashbrown", diff --git a/bin/Cargo.toml b/bin/Cargo.toml index 41109e7..394bce5 100644 --- a/bin/Cargo.toml +++ b/bin/Cargo.toml @@ -29,7 +29,7 @@ thiserror = "1.0.52" libc = "0.2.150" log = "0.4.20" env_logger = { version = "0.11", default-features = false } -tokio-util = { version = "0.7.10", features = ["rt", "io"] } +tokio-util = { version = "0.7.10", features = ["rt", "io", "compat"] } futures-macro = "0.3.30" include_dir = "0.7.3" mime_guess = "2.0.4" @@ -41,3 +41,5 @@ serde_json = "1.0.114" image = { version = "0.25.1", default-features = false, features = ["png", "gif"] } tempfile = "3.10.1" simple_logger = "5.0.0" +async_zip = { version = "0.0.17", features = ["tokio"] } +anyhow = "1.0.98" diff --git a/bin/src/daemon.rs b/bin/src/daemon.rs index 54df436..44d4059 100644 --- a/bin/src/daemon.rs +++ b/bin/src/daemon.rs @@ -15,7 +15,7 @@ use crate::diag::run_diag_read_thread; use crate::error::RayhunterError; use crate::pcap::get_pcap; use crate::qmdl_store::RecordingStore; -use crate::server::{get_qmdl, serve_static, ServerState}; +use crate::server::{get_qmdl, get_zip, serve_static, ServerState}; use crate::stats::get_system_stats; use analysis::{ @@ -46,6 +46,7 @@ fn get_router() -> AppRouter { Router::new() .route("/api/pcap/{name}", get(get_pcap)) .route("/api/qmdl/{name}", get(get_qmdl)) + .route("/api/zip/{name}", get(get_zip)) .route("/api/system-stats", get(get_system_stats)) .route("/api/qmdl-manifest", get(get_qmdl_manifest)) .route("/api/start-recording", post(start_recording)) diff --git a/bin/src/pcap.rs b/bin/src/pcap.rs index 1d4d5ad..531bd28 100644 --- a/bin/src/pcap.rs +++ b/bin/src/pcap.rs @@ -1,19 +1,18 @@ use crate::ServerState; +use anyhow::Error; use axum::body::Body; use axum::extract::{Path, State}; use axum::http::header::CONTENT_TYPE; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; -use futures::TryStreamExt; use log::error; use rayhunter::diag::DataType; use rayhunter::gsmtap_parser; use rayhunter::pcap::GsmtapPcapWriter; use rayhunter::qmdl::QmdlReader; use std::sync::Arc; -use std::{future, pin::pin}; -use tokio::io::duplex; +use tokio::io::{duplex, AsyncRead, AsyncWrite}; use tokio_util::io::ReaderStream; // Streams a pcap file chunk-by-chunk to the client by reading the QMDL data @@ -45,35 +44,10 @@ pub async fn get_pcap( // the QMDL reader should stop at the last successfully written data chunk // (entry.size_bytes) let (reader, writer) = duplex(1024); - let mut pcap_writer = GsmtapPcapWriter::new(writer).await.unwrap(); - pcap_writer.write_iface_header().await.unwrap(); tokio::spawn(async move { - let mut reader = QmdlReader::new(qmdl_file, Some(qmdl_size_bytes)); - let mut messages_stream = pin!(reader - .as_stream() - .try_filter(|container| future::ready(container.data_type == DataType::UserSpace))); - - while let Some(container) = messages_stream - .try_next() - .await - .expect("failed getting QMDL container") - { - for maybe_msg in container.into_messages() { - match maybe_msg { - Ok(msg) => { - let maybe_gsmtap_msg = - gsmtap_parser::parse(msg).expect("error parsing gsmtap message"); - if let Some((timestamp, gsmtap_msg)) = maybe_gsmtap_msg { - pcap_writer - .write_gsmtap_message(gsmtap_msg, timestamp) - .await - .expect("error writing pcap packet"); - } - } - Err(e) => error!("error parsing message: {:?}", e), - } - } + if let Err(e) = generate_pcap_data(writer, qmdl_file, qmdl_size_bytes).await { + error!("failed to generate PCAP: {:?}", e); } }); @@ -81,3 +55,39 @@ pub async fn get_pcap( let body = Body::from_stream(ReaderStream::new(reader)); Ok((headers, body).into_response()) } + +pub async fn generate_pcap_data( + writer: W, + qmdl_file: R, + qmdl_size_bytes: usize, +) -> Result<(), Error> +where + W: AsyncWrite + Unpin + Send, + R: AsyncRead + Unpin, +{ + let mut pcap_writer = GsmtapPcapWriter::new(writer).await?; + pcap_writer.write_iface_header().await?; + + let mut reader = QmdlReader::new(qmdl_file, Some(qmdl_size_bytes)); + while let Some(container) = reader.get_next_messages_container().await? { + if container.data_type != DataType::UserSpace { + continue; + } + + for maybe_msg in container.into_messages() { + match maybe_msg { + Ok(msg) => { + let maybe_gsmtap_msg = gsmtap_parser::parse(msg)?; + if let Some((timestamp, gsmtap_msg)) = maybe_gsmtap_msg { + pcap_writer + .write_gsmtap_message(gsmtap_msg, timestamp) + .await?; + } + } + Err(e) => error!("error parsing message: {:?}", e), + } + } + } + + Ok(()) +} diff --git a/bin/src/server.rs b/bin/src/server.rs index 9065733..eee7271 100644 --- a/bin/src/server.rs +++ b/bin/src/server.rs @@ -1,3 +1,7 @@ +use anyhow::Error; +use async_zip::tokio::write::ZipFileWriter; +use async_zip::Compression; +use async_zip::ZipEntryBuilder; use axum::body::Body; use axum::extract::Path; use axum::extract::State; @@ -5,13 +9,16 @@ use axum::http::header::{self, CONTENT_LENGTH, CONTENT_TYPE}; use axum::http::{HeaderValue, StatusCode}; use axum::response::{IntoResponse, Response}; use include_dir::{include_dir, Dir}; +use log::error; use std::sync::Arc; -use tokio::io::AsyncReadExt; +use tokio::io::{copy, duplex, AsyncReadExt}; use tokio::sync::mpsc::Sender; use tokio::sync::RwLock; +use tokio_util::compat::FuturesAsyncWriteCompatExt; use tokio_util::io::ReaderStream; use crate::analysis::{AnalysisCtrlMessage, AnalysisStatus}; +use crate::pcap::generate_pcap_data; use crate::qmdl_store::RecordingStore; use crate::{display, DiagDeviceCtrlMessage}; @@ -76,3 +83,194 @@ pub async fn serve_static( .unwrap(), } } + +pub async fn get_zip( + State(state): State>, + Path(entry_name): Path, +) -> Result { + let qmdl_idx = entry_name.trim_end_matches(".zip").to_owned(); + let (entry_index, qmdl_size_bytes) = { + let qmdl_store = state.qmdl_store_lock.read().await; + let (entry_index, entry) = qmdl_store.entry_for_name(&qmdl_idx).ok_or(( + StatusCode::NOT_FOUND, + format!("couldn't find entry with name {}", qmdl_idx), + ))?; + + if entry.qmdl_size_bytes == 0 { + return Err(( + StatusCode::SERVICE_UNAVAILABLE, + "QMDL file is empty, try again in a bit!".to_string(), + )); + } + + (entry_index, entry.qmdl_size_bytes) + }; + + let qmdl_store_lock = state.qmdl_store_lock.clone(); + + let (reader, writer) = duplex(8192); + + tokio::spawn(async move { + let result: Result<(), Error> = async { + let mut zip2 = ZipFileWriter::with_tokio(writer); + + // Add QMDL file + { + let entry = + ZipEntryBuilder::new(format!("{qmdl_idx}.qmdl").into(), Compression::Stored); + let mut entry_writer = zip2.write_entry_stream(entry).await?.compat_write(); + + let mut qmdl_file = { + let qmdl_store = qmdl_store_lock.read().await; + qmdl_store + .open_entry_qmdl(entry_index) + .await? + .take(qmdl_size_bytes as u64) + }; + + copy(&mut qmdl_file, &mut entry_writer).await?; + entry_writer.into_inner().close().await?; + } + + // Add PCAP file + { + let entry = + ZipEntryBuilder::new(format!("{qmdl_idx}.pcapng").into(), Compression::Stored); + let mut entry_writer = zip2.write_entry_stream(entry).await?.compat_write(); + + let qmdl_file_for_pcap = { + let qmdl_store = qmdl_store_lock.read().await; + qmdl_store + .open_entry_qmdl(entry_index) + .await? + .take(qmdl_size_bytes as u64) + }; + + if let Err(e) = + generate_pcap_data(&mut entry_writer, qmdl_file_for_pcap, qmdl_size_bytes).await + { + // if we fail to generate the PCAP file, we should still continue and give the + // user the QMDL. + error!("Failed to generate PCAP: {:?}", e); + } + + entry_writer.into_inner().close().await?; + } + + zip2.close().await?; + Ok(()) + } + .await; + + if let Err(e) = result { + error!("Error generating ZIP file: {:?}", e); + } + }); + + let headers = [(CONTENT_TYPE, "application/zip")]; + let body = Body::from_stream(ReaderStream::new(reader)); + Ok((headers, body).into_response()) +} + +#[cfg(test)] +mod tests { + use super::*; + use async_zip::base::read::mem::ZipFileReader; + use axum::extract::{Path, State}; + use std::io::Cursor; + use tempfile::TempDir; + + async fn create_test_qmdl_store() -> (TempDir, Arc>) { + let temp_dir = TempDir::new().unwrap(); + let store_path = temp_dir.path().to_path_buf(); + let store = crate::qmdl_store::RecordingStore::create(&store_path) + .await + .unwrap(); + (temp_dir, Arc::new(RwLock::new(store))) + } + + async fn create_test_entry_with_data( + store_lock: &Arc>, + test_data: &[u8], + ) -> String { + let entry_name = { + let mut store = store_lock.write().await; + let (mut qmdl_file, _analysis_file) = store.new_entry().await.unwrap(); + + if !test_data.is_empty() { + use tokio::io::AsyncWriteExt; + qmdl_file.write_all(test_data).await.unwrap(); + qmdl_file.flush().await.unwrap(); + } + + let current_entry = store.current_entry.unwrap(); + let entry = &store.manifest.entries[current_entry]; + let entry_name = entry.name.clone(); + + store + .update_entry_qmdl_size(current_entry, test_data.len()) + .await + .unwrap(); + entry_name + }; + + let mut store = store_lock.write().await; + store.close_current_entry().await.unwrap(); + entry_name + } + + fn create_test_server_state( + store_lock: Arc>, + ) -> Arc { + let (tx, _rx) = tokio::sync::mpsc::channel(1); + let (ui_tx, _ui_rx) = tokio::sync::mpsc::channel(1); + let (analysis_tx, _analysis_rx) = tokio::sync::mpsc::channel(1); + + let analysis_status = { + let store = store_lock.try_read().unwrap(); + crate::analysis::AnalysisStatus::new(&*store) + }; + + Arc::new(ServerState { + qmdl_store_lock: store_lock, + diag_device_ctrl_sender: tx, + ui_update_sender: ui_tx, + analysis_status_lock: Arc::new(RwLock::new(analysis_status)), + analysis_sender: analysis_tx, + debug_mode: true, + }) + } + + #[tokio::test] + async fn test_get_zip_success() { + let (_temp_dir, store_lock) = create_test_qmdl_store().await; + let test_qmdl_data = vec![0x7E, 0x00, 0x00, 0x00, 0x10, 0x00, 0x7E]; + let entry_name = create_test_entry_with_data(&store_lock, &test_qmdl_data).await; + let state = create_test_server_state(store_lock); + + let result = get_zip(State(state), Path(entry_name.clone())).await; + + assert!(result.is_ok()); + let response = result.unwrap(); + + let headers = response.headers(); + assert_eq!(headers.get("content-type").unwrap(), "application/zip"); + + let body = response.into_body(); + let body_bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap(); + + let zip_reader = ZipFileReader::new(body_bytes.to_vec()).await.unwrap(); + + let filenames = zip_reader + .file() + .entries() + .iter() + .map(|entry| entry.filename().as_str().unwrap().to_owned()) + .collect::>(); + + assert_eq!( + filenames, + vec![format!("{entry_name}.qmdl"), format!("{entry_name}.pcapng"),] + ); + } +} diff --git a/bin/web/src/lib/components/ManifestCard.svelte b/bin/web/src/lib/components/ManifestCard.svelte index 93b58ef..0ccd984 100644 --- a/bin/web/src/lib/components/ManifestCard.svelte +++ b/bin/web/src/lib/components/ManifestCard.svelte @@ -59,6 +59,7 @@
+ {#if current} {:else} diff --git a/bin/web/src/lib/components/ManifestTable.svelte b/bin/web/src/lib/components/ManifestTable.svelte index 79f7d56..1493531 100644 --- a/bin/web/src/lib/components/ManifestTable.svelte +++ b/bin/web/src/lib/components/ManifestTable.svelte @@ -19,6 +19,7 @@ Size PCAP QMDL + ZIP Analysis @@ -32,6 +33,6 @@
{#each entries as entry, i} - + {/each}
\ No newline at end of file diff --git a/bin/web/src/lib/components/ManifestTableRow.svelte b/bin/web/src/lib/components/ManifestTableRow.svelte index 6ffd553..8fa0484 100644 --- a/bin/web/src/lib/components/ManifestTableRow.svelte +++ b/bin/web/src/lib/components/ManifestTableRow.svelte @@ -36,6 +36,7 @@ {entry.get_readable_qmdl_size()} + {#if current} @@ -49,7 +50,7 @@ {/if} - + diff --git a/bin/web/src/lib/manifest.svelte.ts b/bin/web/src/lib/manifest.svelte.ts index 817d1af..834e578 100644 --- a/bin/web/src/lib/manifest.svelte.ts +++ b/bin/web/src/lib/manifest.svelte.ts @@ -93,6 +93,10 @@ export class ManifestEntry { return `/api/qmdl/${this.name}.qmdl`; } + get_zip_url(): string { + return `/api/zip/${this.name}.zip`; + } + get_analysis_report_url(): string { return `/api/analysis-report/${this.name}`; } From 0c241aba231584664e113cfb4d6ccc4bba5aa006 Mon Sep 17 00:00:00 2001 From: Markus Unterwaditzer Date: Mon, 23 Jun 2025 20:36:11 +0200 Subject: [PATCH 19/19] Address review comments --- bin/src/server.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/bin/src/server.rs b/bin/src/server.rs index eee7271..9d460e5 100644 --- a/bin/src/server.rs +++ b/bin/src/server.rs @@ -112,13 +112,16 @@ pub async fn get_zip( tokio::spawn(async move { let result: Result<(), Error> = async { - let mut zip2 = ZipFileWriter::with_tokio(writer); + let mut zip = ZipFileWriter::with_tokio(writer); // Add QMDL file { let entry = ZipEntryBuilder::new(format!("{qmdl_idx}.qmdl").into(), Compression::Stored); - let mut entry_writer = zip2.write_entry_stream(entry).await?.compat_write(); + // FuturesAsyncWriteCompatExt::compat_write because async-zip's entrystream does + // not impl tokio's AsyncWrite, but only future's AsyncWrite. This can be removed + // once https://github.com/Majored/rs-async-zip/pull/160 is released. + let mut entry_writer = zip.write_entry_stream(entry).await?.compat_write(); let mut qmdl_file = { let qmdl_store = qmdl_store_lock.read().await; @@ -136,7 +139,7 @@ pub async fn get_zip( { let entry = ZipEntryBuilder::new(format!("{qmdl_idx}.pcapng").into(), Compression::Stored); - let mut entry_writer = zip2.write_entry_stream(entry).await?.compat_write(); + let mut entry_writer = zip.write_entry_stream(entry).await?.compat_write(); let qmdl_file_for_pcap = { let qmdl_store = qmdl_store_lock.read().await; @@ -157,7 +160,7 @@ pub async fn get_zip( entry_writer.into_inner().close().await?; } - zip2.close().await?; + zip.close().await?; Ok(()) } .await;