diff --git a/doc/moxee.md b/doc/moxee.md index e107dc19..02f40c28 100644 --- a/doc/moxee.md +++ b/doc/moxee.md @@ -40,11 +40,14 @@ According to [FCC ID 2APQU-K779HSDL](https://fcc.report/FCC-ID/2APQU-K779HSDL), Connect to the hotspot's network using WiFi or USB tethering and run: ```sh -./installer orbic --admin-password 'mypassword' +./installer moxee --admin-password 'mypassword' ``` The password (in place of `mypassword`) is under the battery. +`./installer moxee` is almost the same as `./installer orbic`, it just comes +with slightly better defaults that will give you more space for recordings. + ## Obtaining a shell ```sh diff --git a/installer/src/connection.rs b/installer/src/connection.rs index e77538a4..575112f5 100644 --- a/installer/src/connection.rs +++ b/installer/src/connection.rs @@ -1,9 +1,9 @@ use std::future::Future; use std::net::SocketAddr; -use anyhow::Result; +use anyhow::{Result, bail}; -use crate::output::println; +use crate::output::{print, println}; /// Abstraction for device communication (telnet or ADB) pub trait DeviceConnection { @@ -17,19 +17,20 @@ pub trait DeviceConnection { /// Check if a file exists using a DeviceConnection pub async fn file_exists(conn: &mut C, path: &str) -> bool { - conn.run_command(&format!("test -f {path} && echo exists || echo missing")) + conn.run_command(&format!("test -f '{path}' && echo exists || echo missing")) .await .map(|output| output.contains("exists")) .unwrap_or(false) } -/// Shared config installation logic +/// Shared config installation logic. Installs to /data/rayhunter/config.toml which resolves +/// through the symlink to the actual data directory. pub async fn install_config( conn: &mut C, - config_path: &str, device_type: &str, reset_config: bool, ) -> Result<()> { + let config_path = "/data/rayhunter/config.toml"; if reset_config || !file_exists(conn, config_path).await { let config = crate::CONFIG_TOML.replace( r#"#device = "orbic""#, @@ -42,6 +43,118 @@ pub async fn install_config( Ok(()) } +/// Check if a directory exists using a DeviceConnection +pub async fn dir_exists(conn: &mut C, path: &str) -> bool { + conn.run_command(&format!("test -d '{path}' && echo exists || echo missing")) + .await + .map(|output| output.contains("exists")) + .unwrap_or(false) +} + +/// Check if a path is a symlink using a DeviceConnection +pub async fn is_symlink(conn: &mut C, path: &str) -> bool { + conn.run_command(&format!("test -L '{path}' && echo yes || echo no")) + .await + .map(|output| output.contains("yes")) + .unwrap_or(false) +} + +/// Read the target of a symlink using a DeviceConnection +pub async fn readlink(conn: &mut C, path: &str) -> Result { + // Use a prefix marker to find the actual output line, since some shells (TP-Link) echo + // back the command and run_command appends protocol lines. + let output = conn + .run_command(&format!("echo RL:$(readlink '{path}')")) + .await?; + + for line in output.lines() { + if let Some(target) = line.trim().strip_prefix("RL:") { + return Ok(target.to_string()); + } + } + + bail!("unexpected readlink output: {output:?}"); +} + +/// Set up the data directory at `data_dir` and create a symlink from `/data/rayhunter` to it. +/// +/// Handles migration from old locations: +/// - If `/data/rayhunter` is a real directory, moves its contents to `data_dir` +/// - If `/data/rayhunter` is a symlink to a different location, moves from the old target +/// - If `/data/rayhunter` doesn't exist, just creates the symlink +/// - If `/data/rayhunter` is a symlink to `data_dir`, does nothing +pub async fn setup_data_directory(conn: &mut C, data_dir: &str) -> Result<()> { + if data_dir == "/data/rayhunter" { + bail!("data_dir must not be /data/rayhunter"); + } + + if data_dir.contains("'") { + bail!("data_dir must not contain an apostrophe (')"); + } + + // Determine where old data lives, if anywhere + let old_data_source = if is_symlink(conn, "/data/rayhunter").await { + let current_target = readlink(conn, "/data/rayhunter").await?; + if current_target == data_dir { + println!("Data directory already configured at {data_dir}"); + return Ok(()); + } + conn.run_command("rm -f /data/rayhunter").await?; + // The old symlink target is where data actually lives + if dir_exists(conn, ¤t_target).await { + Some(current_target) + } else { + None + } + } else if dir_exists(conn, "/data/rayhunter").await { + if dir_exists(conn, data_dir).await { + bail!("Both /data/rayhunter and {data_dir} exist and are directories."); + } + // Real directory (pre-migration Orbic state) + Some("/data/rayhunter".to_string()) + } else { + None + }; + + // Migrate old data if present + if let Some(old_source) = &old_data_source { + // Stop rayhunter-daemon so it doesn't write during migration. + // The device will be rebooted at the end of installation anyway. + print!("Stopping rayhunter-daemon ... "); + let _ = conn + .run_command("/etc/init.d/rayhunter_daemon stop 2>/dev/null; true") + .await; + println!("ok"); + + print!("Migrating data from {old_source} to {data_dir} ... "); + + // mv old data into its place. If source and destination are on the same filesystem, + // this is an instant rename. + // XXX: DeviceConnection::run_command does not expose the exit code of the ran command. It + // probably should, or a utility for it should exist? + let mv_output = conn + .run_command(&format!("mv {old_source} '{data_dir}' && echo MV_OK")) + .await?; + if mv_output.contains("MV_OK") { + println!("ok"); + } else { + bail!("Failed to move data from {old_source} to {data_dir}:\n{mv_output}"); + } + } else { + // No migration needed, just ensure the target directory exists + conn.run_command(&format!("mkdir -p '{data_dir}'")).await?; + } + + // Create the symlink + print!("Creating symlink /data/rayhunter -> {data_dir} ... "); + conn.run_command("mkdir -p /data").await?; + conn.run_command(&format!("ln -sf '{data_dir}' /data/rayhunter")) + .await?; + println!("ok"); + + Ok(()) +} + /// Telnet-based connection wrapper pub struct TelnetConnection { pub addr: SocketAddr, diff --git a/installer/src/lib.rs b/installer/src/lib.rs index 3b6fee3a..69859d67 100644 --- a/installer/src/lib.rs +++ b/installer/src/lib.rs @@ -6,6 +6,7 @@ use env_logger::Env; use anyhow::bail; mod connection; +mod moxee; #[cfg(not(target_os = "android"))] mod orbic; mod orbic_auth; @@ -40,9 +41,11 @@ enum Command { /// Install rayhunter on the Orbic RC400L using the legacy USB+ADB-based installer. #[cfg(not(target_os = "android"))] OrbicUsb(InstallOrbic), - /// Install rayhunter on the Orbic RC400L or Moxee Hotspot via network. + /// Install rayhunter on the Orbic RC400L via network. #[clap(alias = "orbic-network")] Orbic(OrbicNetworkArgs), + /// Install rayhunter on the Moxee Hotspot via network. + Moxee(MoxeeArgs), /// Install rayhunter on the TMobile TMOHS1. Tmobile(TmobileArgs), /// Install rayhunter on the Uz801. @@ -84,6 +87,12 @@ struct InstallTpLink { /// Overwrite config.toml even if it already exists on the device. #[arg(long)] reset_config: bool, + + /// Override the data directory path. Defaults to /cache/rayhunter-data (or SD card path when + /// SD card is used). Must not be /data/rayhunter, which lives on a storage partition that's + /// too small for normal Rayhunter operation. + #[arg(long)] + data_dir: Option, } #[derive(Parser, Debug)] @@ -110,6 +119,35 @@ struct OrbicNetworkArgs { /// Overwrite config.toml even if it already exists on the device. #[arg(long)] reset_config: bool, + + /// Override the data directory path. Defaults to /data/rayhunter-data. + /// Must not be /data/rayhunter. + #[arg(long)] + data_dir: Option, +} + +#[derive(Parser, Debug)] +struct MoxeeArgs { + /// IP address for Moxee admin interface, if custom. + #[arg(long, default_value = "192.168.1.1")] + admin_ip: String, + + /// Admin username for authentication. + #[arg(long, default_value = "admin")] + admin_username: String, + + /// Admin password for authentication. + #[arg(long)] + admin_password: Option, + + /// Overwrite config.toml even if it already exists on the device. + #[arg(long)] + reset_config: bool, + + /// Override the data directory path. Defaults to /cache/rayhunter-data. + /// Must not be /data/rayhunter. + #[arg(long)] + data_dir: Option, } #[derive(Parser, Debug)] @@ -245,7 +283,8 @@ async fn run(args: Args) -> Result<(), Error> { .context("Failed to install rayhunter on the Pinephone's Quectel modem")?, #[cfg(not(target_os = "android"))] Command::OrbicUsb(args) => orbic::install(args.reset_config).await.context("\nFailed to install rayhunter on the Orbic RC400L (USB installer)")?, - Command::Orbic(args) => orbic_network::install(args.admin_ip, args.admin_username, args.admin_password, args.reset_config).await.context("\nFailed to install rayhunter on the Orbic RC400L")?, + Command::Orbic(args) => orbic_network::install(args.admin_ip, args.admin_username, args.admin_password, args.reset_config, args.data_dir).await.context("\nFailed to install rayhunter on the Orbic RC400L")?, + Command::Moxee(args) => moxee::install(args).await.context("\nFailed to install rayhunter on the Moxee Hotspot")?, Command::Wingtech(args) => wingtech::install(args).await.context("\nFailed to install rayhunter on the Wingtech CT2MHS01")?, Command::Util(subcommand) => { match subcommand.command { diff --git a/installer/src/moxee.rs b/installer/src/moxee.rs new file mode 100644 index 00000000..204948c3 --- /dev/null +++ b/installer/src/moxee.rs @@ -0,0 +1,15 @@ +use anyhow::Result; + +use crate::MoxeeArgs; + +pub async fn install(args: MoxeeArgs) -> Result<()> { + let data_dir = args.data_dir.or(Some("/cache/rayhunter-data".to_string())); + crate::orbic_network::install( + args.admin_ip, + args.admin_username, + args.admin_password, + args.reset_config, + data_dir, + ) + .await +} diff --git a/installer/src/orbic.rs b/installer/src/orbic.rs index a414e4b0..c38dbbde 100644 --- a/installer/src/orbic.rs +++ b/installer/src/orbic.rs @@ -158,13 +158,7 @@ async fn setup_rayhunter(mut adb_device: ADBUSBDevice, reset_config: bool) -> Re let mut conn = AdbConnection { device: &mut adb_device, }; - install_config( - &mut conn, - "/data/rayhunter/config.toml", - "orbic", - reset_config, - ) - .await?; + install_config(&mut conn, "orbic", reset_config).await?; } install_file( diff --git a/installer/src/orbic_network.rs b/installer/src/orbic_network.rs index 840c9246..177705aa 100644 --- a/installer/src/orbic_network.rs +++ b/installer/src/orbic_network.rs @@ -8,7 +8,7 @@ use serde::Deserialize; use tokio::time::sleep; use crate::RAYHUNTER_DAEMON_INIT; -use crate::connection::{TelnetConnection, install_config}; +use crate::connection::{TelnetConnection, install_config, setup_data_directory}; use crate::orbic_auth::{LoginInfo, LoginRequest, LoginResponse, encode_password}; use crate::output::{eprintln, print, println}; use crate::util::{interactive_shell, telnet_send_command, telnet_send_file}; @@ -147,6 +147,7 @@ pub async fn install( admin_username: String, admin_password: Option, reset_config: bool, + data_dir: Option, ) -> Result<()> { let Some(admin_password) = admin_password else { eprintln!( @@ -170,7 +171,8 @@ pub async fn install( wait_for_telnet(&admin_ip).await?; println!("done"); - setup_rayhunter(&admin_ip, reset_config).await + let data_dir = data_dir.unwrap_or_else(|| "/data/rayhunter-data".to_string()); + setup_rayhunter(&admin_ip, reset_config, &data_dir).await } async fn wait_for_telnet(admin_ip: &str) -> Result<()> { @@ -194,7 +196,7 @@ async fn wait_for_telnet(admin_ip: &str) -> Result<()> { Ok(()) } -async fn setup_rayhunter(admin_ip: &str, reset_config: bool) -> Result<()> { +async fn setup_rayhunter(admin_ip: &str, reset_config: bool, data_dir: &str) -> Result<()> { let addr = SocketAddr::from_str(&format!("{admin_ip}:{TELNET_PORT}"))?; let rayhunter_daemon_bin = include_bytes!(env!("FILE_RAYHUNTER_DAEMON")); @@ -208,7 +210,8 @@ async fn setup_rayhunter(admin_ip: &str, reset_config: bool) -> Result<()> { ) .await?; - telnet_send_command(addr, "mkdir -p /data/rayhunter", "exit code 0", false).await?; + let mut conn = TelnetConnection::new(addr, false); + setup_data_directory(&mut conn, data_dir).await?; telnet_send_file( addr, @@ -218,14 +221,7 @@ async fn setup_rayhunter(admin_ip: &str, reset_config: bool) -> Result<()> { ) .await?; - let mut conn = TelnetConnection::new(addr, false); - install_config( - &mut conn, - "/data/rayhunter/config.toml", - "orbic", - reset_config, - ) - .await?; + install_config(&mut conn, "orbic", reset_config).await?; telnet_send_file( addr, diff --git a/installer/src/tplink.rs b/installer/src/tplink.rs index efbf3d49..8873dc41 100644 --- a/installer/src/tplink.rs +++ b/installer/src/tplink.rs @@ -18,7 +18,7 @@ use serde::Deserialize; use tokio::time::sleep; use crate::InstallTpLink; -use crate::connection::{TelnetConnection, install_config}; +use crate::connection::{TelnetConnection, install_config, setup_data_directory}; use crate::output::println; use crate::util::{interactive_shell, telnet_send_command, telnet_send_file}; @@ -30,10 +30,19 @@ pub async fn main_tplink( admin_ip, sdcard_path, reset_config, + data_dir, }: InstallTpLink, ) -> Result<(), Error> { let is_v3 = start_telnet(&admin_ip).await?; - tplink_run_install(skip_sdcard, admin_ip, sdcard_path, is_v3, reset_config).await + tplink_run_install( + skip_sdcard, + admin_ip, + sdcard_path, + is_v3, + reset_config, + data_dir, + ) + .await } #[derive(Deserialize)] @@ -114,19 +123,15 @@ async fn tplink_run_install( mut sdcard_path: String, is_v3: bool, reset_config: bool, + cli_data_dir: Option, ) -> Result<(), Error> { println!("Connecting via telnet to {admin_ip}"); let addr = SocketAddr::from_str(&format!("{admin_ip}:23")).unwrap(); - if skip_sdcard { - sdcard_path = "/data/rayhunter-data".to_owned(); - telnet_send_command( - addr, - &format!("mkdir -p {sdcard_path}"), - "exit code 0", - true, - ) - .await? + let data_dir = if let Some(dir) = cli_data_dir { + dir + } else if skip_sdcard { + "/cache/rayhunter-data".to_owned() } else { if sdcard_path.is_empty() { let try_paths = [ @@ -146,9 +151,12 @@ async fn tplink_run_install( } if sdcard_path.is_empty() { + // This error message is shown when the installer cannot figure out where this + // device _would_ mount an SD card, regardless of whether the user did insert one. + // If we get here, it's likely the installer was never tested for this hardware + // version. anyhow::bail!( - "Unable to determine sdcard path. Rayhunter needs a FAT-formatted SD card to function.\n\n\ - If you already inserted a FAT formatted SD card, this is a bug. Please file an issue with your hardware version.\n\n\ + "Unable to determine sdcard path. This is a bug. Please file an issue with your hardware version.\n\n\ The installer has tried to find an empty folder to mount to on these paths: {try_paths:?}\n\ ...but none of them exist.\n\n\ At this point, you may 'telnet {admin_ip}' and poke around in the device to figure out what went wrong yourself." @@ -166,49 +174,43 @@ async fn tplink_run_install( .await .is_err() { - telnet_send_command(addr, &format!("mount /dev/mmcblk0p1 {sdcard_path}"), "exit code 0", true).await.context("Rayhunter needs a FAT-formatted SD card to function for more than a few minutes. Insert one and rerun this installer, or pass --skip-sdcard")?; + // Try to mount the SD card, and if that fails we assume the user didn't insert one. + telnet_send_command(addr, &format!("mount /dev/mmcblk0p1 {sdcard_path}"), "exit code 0", true).await.context("Rayhunter needs a FAT-formatted SD card to function for more than a few hours. Insert one and rerun this installer, or pass --skip-sdcard")?; } else { println!("sdcard already mounted"); } - } - // there is too little space on the internal flash to store anything, but the initrd script - // expects things to be at this location - telnet_send_command(addr, "rm -rf /data/rayhunter", "exit code 0", true).await?; - telnet_send_command(addr, "mkdir -p /data", "exit code 0", true).await?; - telnet_send_command( - addr, - &format!("ln -sf {sdcard_path} /data/rayhunter"), - "exit code 0", - true, - ) - .await?; + sdcard_path + }; let mut conn = TelnetConnection::new(addr, true); - let config_path = format!("{sdcard_path}/config.toml"); - install_config(&mut conn, &config_path, "tplink", reset_config).await?; + setup_data_directory(&mut conn, &data_dir).await?; + + install_config(&mut conn, "tplink", reset_config).await?; let rayhunter_daemon_bin = include_bytes!(env!("FILE_RAYHUNTER_DAEMON")); telnet_send_file( addr, - &format!("{sdcard_path}/rayhunter-daemon"), + "/data/rayhunter/rayhunter-daemon", rayhunter_daemon_bin, true, ) .await?; + let init_script = get_rayhunter_daemon(if skip_sdcard { None } else { Some(&data_dir) }); + telnet_send_file( addr, "/etc/init.d/rayhunter_daemon", - get_rayhunter_daemon(&sdcard_path).as_bytes(), + init_script.as_bytes(), true, ) .await?; telnet_send_command( addr, - &format!("chmod ugo+x {sdcard_path}/rayhunter-daemon"), + "chmod ugo+x /data/rayhunter/rayhunter-daemon", "exit code 0", true, ) @@ -368,18 +370,19 @@ async fn tplink_launch_telnet_v5(admin_ip: &str) -> Result<(), Error> { Ok(()) } -fn get_rayhunter_daemon(sdcard_path: &str) -> String { +fn get_rayhunter_daemon(sdcard_path: Option<&str>) -> String { // Even though TP-Link eventually auto-mounts the SD card, it sometimes does so too late. And // changing the order in which daemons are started up seems to not work reliably. // // This part of the daemon dynamically generated because we may have to eventually add logic // specific to a particular hardware revision here. - crate::RAYHUNTER_DAEMON_INIT.replace( - "#RAYHUNTER-PRESTART", - &format!( - "(mount /dev/mmcblk0p1 {sdcard_path} || true) 2>&1 | tee /tmp/rayhunter-mount.log" - ), - ) + let prestart = match sdcard_path { + Some(path) => { + format!("(mount /dev/mmcblk0p1 {path} || true) 2>&1 | tee /tmp/rayhunter-mount.log") + } + None => String::new(), + }; + crate::RAYHUNTER_DAEMON_INIT.replace("#RAYHUNTER-PRESTART", &prestart) } /// Root the TP-Link device and open an interactive shell @@ -390,6 +393,10 @@ pub async fn shell(admin_ip: &str) -> Result<(), Error> { #[test] fn test_get_rayhunter_daemon() { - let s = get_rayhunter_daemon("/media/card"); + let s = get_rayhunter_daemon(Some("/media/card")); assert!(s.contains("mount /dev/mmcblk0p1 /media/card")); + + let s = get_rayhunter_daemon(None); + assert!(!s.contains("mmcblk0p1")); + assert!(!s.contains("#RAYHUNTER-PRESTART")); } diff --git a/installer/src/util.rs b/installer/src/util.rs index 96cfb4d9..b8cd2383 100644 --- a/installer/src/util.rs +++ b/installer/src/util.rs @@ -93,7 +93,7 @@ pub async fn telnet_send_file( let handle = tokio::spawn(async move { telnet_send_command_with_output( addr, - &format!("nc -l -p 8081 >{filename}.tmp"), + &format!("nc -l -p 8081 2>&1 >{filename}.tmp"), wait_for_prompt, ) .await @@ -121,7 +121,7 @@ pub async fn telnet_send_file( print!("attempt {attempts}... "); } - { + let send_result: Result<()> = async { let mut stream = stream?; stream.write_all(payload).await?; @@ -134,11 +134,20 @@ pub async fn telnet_send_file( // application buffers here. sleep(Duration::from_millis(1000)).await; - // ensure that stream is dropped before we wait for nc to terminate. - drop(stream); + Ok(()) } + .await; - handle.await?? + let nc_output = handle.await??; + + if let Err(e) = send_result { + bail!( + "Failed to send data to nc: {e}. nc output: '{}'", + nc_output.trim() + ); + } + + nc_output }; let checksum = md5::compute(payload);