From cb1df974e4499760d97fb2e6c22e5fd7c92a122c Mon Sep 17 00:00:00 2001 From: oopsbagel Date: Sat, 7 Jun 2025 18:50:43 -0700 Subject: [PATCH 01/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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}"))