diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f5d2709..62daa2f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -12,6 +12,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: @@ -33,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" @@ -97,13 +100,15 @@ jobs: strategy: matrix: device: - - name: tplink - name: orbic + - name: tplink + - name: wingtech runs-on: ubuntu-latest permissions: contents: read steps: - uses: actions/checkout@v4 + - uses: Swatinem/rust-cache@v2 - name: Check formatting run: cargo fmt --all --check - name: Check @@ -128,6 +133,7 @@ jobs: contents: read steps: - uses: actions/checkout@v4 + - uses: Swatinem/rust-cache@v2 - name: cargo check shell: bash run: | @@ -168,6 +174,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 @@ -189,6 +196,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 @@ -208,14 +216,16 @@ jobs: strategy: matrix: device: - - name: tplink - name: orbic + - name: tplink + - name: wingtech runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - 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 @@ -272,6 +282,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: diff --git a/Cargo.lock b/Cargo.lock index 42ab477..b3603d7 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" @@ -238,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" @@ -348,6 +373,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" @@ -409,6 +440,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" @@ -502,6 +542,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" @@ -1429,13 +1479,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", @@ -2326,6 +2388,8 @@ dependencies = [ name = "rayhunter-daemon" version = "0.3.4" dependencies = [ + "anyhow", + "async_zip", "axum", "chrono", "clap", @@ -3019,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 7f2758c..394bce5 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"] @@ -28,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" @@ -40,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 4a9817d..05d3bd0 100644 --- a/bin/src/daemon.rs +++ b/bin/src/daemon.rs @@ -19,7 +19,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_config, get_qmdl, serve_static, set_config, ServerState}; +use crate::server::{get_config, get_qmdl, get_zip, serve_static, set_config, ServerState}; use crate::stats::{get_qmdl_manifest, get_system_stats}; use analysis::{ @@ -48,6 +48,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/display/mod.rs b/bin/src/display/mod.rs index 124ef71..cf7a3d2 100644 --- a/bin/src/display/mod.rs +++ b/bin/src/display/mod.rs @@ -15,14 +15,13 @@ 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, WarningDetected, } - -#[cfg(all(feature = "orbic", feature = "tplink"))] -compile_error!("cannot compile for many devices at once"); - -#[cfg(not(any(feature = "orbic", feature = "tplink")))] -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/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 e90c89c..11cbee1 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; @@ -6,15 +10,18 @@ use axum::http::{HeaderValue, StatusCode}; use axum::response::{IntoResponse, Response}; use axum::Json; use include_dir::{include_dir, Dir}; +use log::error; use std::sync::Arc; use tokio::fs::write; -use tokio::io::AsyncReadExt; +use tokio::io::{copy, duplex, AsyncReadExt}; use tokio::sync::mpsc::Sender; use tokio::sync::{oneshot, RwLock}; +use tokio_util::compat::FuturesAsyncWriteCompatExt; use tokio_util::io::ReaderStream; use crate::analysis::{AnalysisCtrlMessage, AnalysisStatus}; use crate::config::Config; +use crate::pcap::generate_pcap_data; use crate::qmdl_store::RecordingStore; use crate::{display, DiagDeviceCtrlMessage}; @@ -129,3 +136,196 @@ pub async fn set_config( )) } } + +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 zip = ZipFileWriter::with_tokio(writer); + + // Add QMDL file + { + let entry = + ZipEntryBuilder::new(format!("{qmdl_idx}.qmdl").into(), Compression::Stored); + // 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; + 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 = zip.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?; + } + + zip.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, + }) + } + + #[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}`; } 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' + } } }; 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). 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..0da4a53 100644 --- a/installer/build.rs +++ b/installer/build.rs @@ -8,17 +8,22 @@ 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, + "FILE_RAYHUNTER_DAEMON_WINGTECH", + "rayhunter-daemon", + ); } fn set_binary_var(include_dir: &Path, var: &str, file: &str) { @@ -26,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; } diff --git a/installer/src/main.rs b/installer/src/main.rs index 9de31e1..de785b8 100644 --- a/installer/src/main.rs +++ b/installer/src/main.rs @@ -4,6 +4,8 @@ use env_logger::Env; mod orbic; mod tplink; +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"); @@ -21,6 +23,8 @@ enum Command { Orbic(InstallOrbic), /// Install rayhunter on the TP-Link M7350. Tplink(InstallTpLink), + /// Install rayhunter on the Wingtech CT2MHS01. + Wingtech(WingtechArgs), /// Developer utilities. Util(Util), } @@ -65,6 +69,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)] @@ -74,6 +82,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)] @@ -91,6 +110,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 { @@ -114,6 +134,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 adb on the Wingtech CT2MHS01")?, } } diff --git a/installer/src/orbic.rs b/installer/src/orbic.rs index 466fcb9..a09e7af 100644 --- a/installer/src/orbic.rs +++ b/installer/src/orbic.rs @@ -9,6 +9,7 @@ use nusb::{Device, Interface}; use sha2::{Digest, Sha256}; use tokio::time::sleep; +use crate::util::echo; use crate::{CONFIG_TOML, RAYHUNTER_DAEMON_INIT}; pub const ORBIC_NOT_FOUND: &str = r#"No Orbic device found. @@ -40,13 +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 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 5c4cc9e..59bd6f7 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; @@ -50,7 +49,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 +61,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}"); @@ -164,6 +166,7 @@ async fn tplink_run_install( rayhunter_daemon_bin, ) .await?; + telnet_send_file( addr, "/etc/init.d/rayhunter_daemon", @@ -200,99 +203,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(()) -} - -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..1f7ce86 --- /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::{Result, bail}; +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 new file mode 100644 index 0000000..3ec7bc7 --- /dev/null +++ b/installer/src/wingtech.rs @@ -0,0 +1,182 @@ +/// 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::{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, + 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<()> { + 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"); + + let encrypted_pw = encrypt_password(admin_password.as_bytes()).ok().unwrap(); + + let client = Client::new(); + let LoginResponse { token } = client + .post(&qcmap_auth_endpoint) + .body(format!( + "type=login&pwd={encrypted_pw}&timeout=60000&user=admin" + )) + .send() + .await? + .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}")) + .send() + .await?; + if command.status() != 200 { + bail!( + "running command failed with status code: {:?}", + command.status() + ); + } + + Ok(()) +} + +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(); + telnet_send_command(addr, "mkdir -p /data/rayhunter", "exit code 0").await?; + println!("ok"); + + 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 ... "); + 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 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; + } + + 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"