Compare commits

...

21 Commits

Author SHA1 Message Date
oopsbagel fe6afac817 Merge pull request #499 from EFForg/installer-issue-tmpl
Add an issue template for Installer issues
2025-08-02 18:50:13 +00:00
oopsbagel 8e708f145e doc/pinephone: the installer runs on the phone 2025-08-01 09:42:34 -07:00
oopsbagel 03c00a1f19 installer/orbic: warn windows users this may brick
The windows installer seems to sometimes brick the Orbic's ARM core,
resulting in the DSP returning "Qmi Send Message Fail" when sent AT
commands.

This commit adds a loud warning and confirmation dialog for Windows
users before installing.
2025-07-31 22:22:55 -07:00
oopsbagel 64842c7140 release v0.5.1 2025-07-31 22:22:55 -07:00
Markus Unterwaditzer e108c21fc2 Use ./installer in docs
See https://github.com/EFForg/rayhunter/discussions/490
2025-07-31 20:55:41 +02:00
Sashanoraa 49a2108214 Add an issue template for Installer issues 2025-07-31 14:42:17 -04:00
Markus Unterwaditzer 53a6cbe95a Fix line endings on Windows
Fix #489
2025-07-31 18:06:52 +02:00
Sashanoraa 398997af67 Refactor diag thread to have full control over the QMDL store
Fixes #269. Refactor also pull diag thread logic out into state machine
object for better encapsulation and reuse.
2025-07-31 11:47:11 +02:00
oopsbagel 6b109a9d76 Merge pull request #498 from oopsbagel/wingtech-wifi-install-fix
wingtech: install without disabling wifi
2025-07-31 03:29:53 +00:00
oopsbagel d9688b1796 wingtech: install without disabling wifi
Previously, the unlocking method for the wingtech hotspot would add a
invalid mac address to the blocklist. This would prevent the wifi from
coming online after rebooting until the invalid mac was removed.

This commit changes the unlocking method to attempt to *remove* an
invalid mac, creating a no-op condition that still works for unlocking
root access to the hotspot.

This commit also adds documentation for a problem where the hotspot
would occasionally not reboot while completely disconnected and
installing over wifi.

Fixes #466
2025-07-30 20:09:26 -07:00
Sashanoraa 7466c1c669 Fixes #381 UI no longer X overflows on mobile
Button will horizontally shrink a little on smaller screens and buttons
and tables will X scroll if needed.
2025-07-30 14:13:16 -04:00
Cooper Quintin 6a51050921 Update doc/configuration.md
Co-authored-by: Will Greenberg <ifnspifn@gmail.com>
2025-07-28 16:05:58 -07:00
Matej Kovacic 0935cf8239 Delete doc/Rayhunter_0.3.2.png 2025-07-28 16:05:58 -07:00
Matej Kovacic d25e9588e2 Update heuristics.md 2025-07-28 16:05:58 -07:00
Matej Kovacic a8ff95a07b Update using-rayhunter.md 2025-07-28 16:05:58 -07:00
Matej Kovacic ac86277903 Add files via upload 2025-07-28 16:05:58 -07:00
Matej Kovacic 8e9abc718a Update configuration.md 2025-07-28 16:05:58 -07:00
Matej Kovacic d92fb16c57 Add files via upload 2025-07-28 16:05:58 -07:00
Markus Unterwaditzer f8824ce7e7 Remove blocking code and spawn_blocking
Rayhunter uses a mixture of spawn and spawn_blocking, then also does
some blocking operations inside of async code.

Move everything to async. This allows us to use the single-threaded
runtime.

Now the binary is 100kB smaller, and the memory usage also improved by
~100kB on tplink.
2025-07-28 16:04:24 -07:00
Sashanoraa 9694aa826b Use firmware-devel profile for make.sh 2025-07-28 11:09:48 -07:00
Sashanoraa b859dde0c8 Add firmware-devel profile to cargo config
This speeds up compile time in exchange for binary size,
which is often a worthy trade-off in development when iteration speed
matters.
2025-07-28 11:09:48 -07:00
38 changed files with 584 additions and 323 deletions
+5
View File
@@ -36,6 +36,11 @@ lto = "fat"
opt-level = "z"
strip = "debuginfo"
[profile.firmware-devel]
inherits = "release"
opt-level = "s"
lto = false
# optimizations to reduce the binary size of firmware binaries
[profile.firmware]
inherits = "release"
+9
View File
@@ -0,0 +1,9 @@
# Files that are distributed onto the Rayhunter device always have to have
# Unix-style line endings, even if the installer is built on Windows with
# autocrlf enabled.
# Using CRLF for the init scripts will make them fail to execute on TP-Link.
# See https://github.com/EFForg/rayhunter/issues/489
dist/config.toml.in eol=lf
dist/scripts/misc-daemon eol=lf
dist/scripts/rayhunter_daemon eol=lf
+47
View File
@@ -0,0 +1,47 @@
name: Installer Issue
description: File an bug related to an installer issue.
labels: ["bug", "installer"]
body:
- type: input
attributes:
label: Rayhunter Version
placeholder: 'v0.5.0'
validations:
required: true
- type: dropdown
attributes:
label: Device
description: |
What device are you trying to install Rayhunter on?
options:
- Orbic RC400L
- Tplink HW7350
- Tplink HW7310
- Tmobile TMOHS1
- Wingtech CT2MHS0
- Pinephone
- Other / I'm not sure
validations:
required: true
- type: dropdown
attributes:
label: Installer OS
description: What operating system are running the installer from
multiple: false
options:
- Linux
- macOS
- Windows
validations:
required: true
- type: textarea
attributes:
label: Describe the Issue
description: |
Please describe the issue you're having installing Rayhunter.
Include the logs outputed by the installer program. If the installer
is crashing, please try running the installer with `RUST_BACKTRACE=1`
environment variable set so we can see exactly where the installer is
crashing.
validations:
required: true
Generated
+7 -6
View File
@@ -1489,7 +1489,7 @@ dependencies = [
[[package]]
name = "installer"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"adb_client",
"aes",
@@ -2384,7 +2384,7 @@ dependencies = [
[[package]]
name = "rayhunter"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"bytes",
"chrono",
@@ -2405,7 +2405,7 @@ dependencies = [
[[package]]
name = "rayhunter-check"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"clap",
"futures",
@@ -2419,9 +2419,10 @@ dependencies = [
[[package]]
name = "rayhunter-daemon"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"anyhow",
"async-trait",
"async_zip",
"axum",
"chrono",
@@ -2536,7 +2537,7 @@ checksum = "57397d16646700483b67d2dd6511d79318f9d057fdbd21a4066aeac8b41d310a"
[[package]]
name = "rootshell"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"nix",
]
@@ -2918,7 +2919,7 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "telcom-parser"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"asn1-codecs",
"asn1-compiler",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "rayhunter-check"
version = "0.5.0"
version = "0.5.1"
edition = "2024"
[dependencies]
+3 -2
View File
@@ -1,13 +1,13 @@
[package]
name = "rayhunter-daemon"
version = "0.5.0"
version = "0.5.1"
edition = "2024"
[dependencies]
rayhunter = { path = "../lib" }
toml = "0.8.8"
serde = { version = "1.0.193", features = ["derive"] }
tokio = { version = "1.44.2", default-features = false, features = ["fs", "signal", "process", "rt-multi-thread"] }
tokio = { version = "1.44.2", default-features = false, features = ["fs", "signal", "process", "rt"] }
axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "json"] }
thiserror = "1.0.52"
libc = "0.2.150"
@@ -24,3 +24,4 @@ image = { version = "0.25.1", default-features = false, features = ["png", "gif
tempfile = "3.10.1"
async_zip = { version = "0.0.17", features = ["tokio"] }
anyhow = "1.0.98"
async-trait = "0.1.88"
+241 -123
View File
@@ -1,3 +1,4 @@
use std::ops::DerefMut;
use std::pin::pin;
use std::sync::Arc;
@@ -9,135 +10,261 @@ use axum::response::{IntoResponse, Response};
use futures::{StreamExt, TryStreamExt};
use log::{debug, error, info, warn};
use rayhunter::analysis::analyzer::AnalyzerConfig;
use rayhunter::diag::DataType;
use rayhunter::diag::{DataType, MessagesContainer};
use rayhunter::diag_device::DiagDevice;
use rayhunter::qmdl::QmdlWriter;
use tokio::fs::File;
use tokio::sync::RwLock;
use tokio::sync::mpsc::{Receiver, Sender};
use tokio::sync::{RwLock, oneshot};
use tokio_util::io::ReaderStream;
use tokio_util::task::TaskTracker;
use crate::analysis::{AnalysisCtrlMessage, AnalysisWriter};
use crate::display;
use crate::qmdl_store::{EntryType, RecordingStore, RecordingStoreError};
use crate::qmdl_store::{RecordingStore, RecordingStoreError};
use crate::server::ServerState;
pub enum DiagDeviceCtrlMessage {
StopRecording,
StartRecording,
DeleteEntry {
name: String,
response_tx: oneshot::Sender<Result<(), RecordingStoreError>>,
},
DeleteAllEntries {
response_tx: oneshot::Sender<Result<(), RecordingStoreError>>,
},
Exit,
}
pub struct DiagTask {
ui_update_sender: Sender<display::DisplayState>,
analysis_sender: Sender<AnalysisCtrlMessage>,
analyzer_config: AnalyzerConfig,
state: DiagState,
}
enum DiagState {
Recording {
qmdl_writer: QmdlWriter<File>,
analysis_writer: Box<AnalysisWriter>,
},
Stopped,
}
impl DiagTask {
fn new(
ui_update_sender: Sender<display::DisplayState>,
analysis_sender: Sender<AnalysisCtrlMessage>,
analyzer_config: AnalyzerConfig,
) -> Self {
Self {
ui_update_sender,
analysis_sender,
analyzer_config,
state: DiagState::Stopped,
}
}
/// Start recording
async fn start(&mut self, qmdl_store: &mut RecordingStore) {
let (qmdl_file, analysis_file) = qmdl_store
.new_entry()
.await
.expect("failed creating QMDL file entry");
self.stop_current_recording().await;
let qmdl_writer = QmdlWriter::new(qmdl_file);
let analysis_writer = AnalysisWriter::new(analysis_file, &self.analyzer_config)
.await
.map(Box::new)
.expect("failed to write to analysis file");
self.state = DiagState::Recording {
qmdl_writer,
analysis_writer,
};
if let Err(e) = self
.ui_update_sender
.send(display::DisplayState::Recording)
.await
{
warn!("couldn't send ui update message: {e}");
}
}
/// Stop recording
async fn stop(&mut self, qmdl_store: &mut RecordingStore) {
self.stop_current_recording().await;
if let Some((_, entry)) = qmdl_store.get_current_entry() {
if let Err(e) = self
.analysis_sender
.send(AnalysisCtrlMessage::RecordingFinished(
entry.name.to_string(),
))
.await
{
warn!("couldn't send analysis message: {e}");
}
}
if let Err(e) = qmdl_store.close_current_entry().await {
error!("couldn't close current entry: {e}");
}
if let Err(e) = self
.ui_update_sender
.send(display::DisplayState::Paused)
.await
{
warn!("couldn't send ui update message: {e}");
}
}
async fn delete_entry(
&mut self,
qmdl_store: &mut RecordingStore,
name: &str,
) -> Result<(), RecordingStoreError> {
if qmdl_store.is_current_entry(name) {
self.stop(qmdl_store).await;
}
let res = qmdl_store.delete_entry(name).await;
if let Err(e) = res.as_ref() {
error!("Error deleting QMDL entry {e}");
}
res
}
async fn delete_all_entries(
&mut self,
qmdl_store: &mut RecordingStore,
) -> Result<(), RecordingStoreError> {
self.stop(qmdl_store).await;
let res = qmdl_store.delete_all_entries().await;
if let Err(e) = res.as_ref() {
error!("Error deleting QMDL entries {e}");
}
res
}
async fn stop_current_recording(&mut self) {
let mut state = DiagState::Stopped;
std::mem::swap(&mut self.state, &mut state);
if let DiagState::Recording {
analysis_writer, ..
} = state
{
analysis_writer
.close()
.await
.expect("failed to close analysis writer");
}
}
async fn process_container(
&mut self,
qmdl_store: &mut RecordingStore,
container: MessagesContainer,
) {
if container.data_type != DataType::UserSpace {
debug!("skipping non-userspace diag messages...");
return;
}
// keep track of how many bytes were written to the QMDL file so we can read
// a valid block of data from it in the HTTP server
if let DiagState::Recording {
qmdl_writer,
analysis_writer,
} = &mut self.state
{
qmdl_writer
.write_container(&container)
.await
.expect("failed to write to QMDL writer");
debug!(
"total QMDL bytes written: {}, updating manifest...",
qmdl_writer.total_written
);
let index = qmdl_store
.current_entry
.expect("DiagDevice had qmdl_writer, but QmdlStore didn't have current entry???");
qmdl_store
.update_entry_qmdl_size(index, qmdl_writer.total_written)
.await
.expect("failed to update qmdl file size");
debug!("done!");
let heuristic_warning = analysis_writer
.analyze(container)
.await
.expect("failed to analyze container");
if heuristic_warning {
info!("a heuristic triggered on this run!");
self.ui_update_sender
.send(display::DisplayState::WarningDetected)
.await
.expect("couldn't send ui update message: {}");
}
} else {
debug!("no qmdl_writer set, continuing...");
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn run_diag_read_thread(
task_tracker: &TaskTracker,
mut dev: DiagDevice,
mut qmdl_file_rx: Receiver<DiagDeviceCtrlMessage>,
qmdl_file_tx: Sender<DiagDeviceCtrlMessage>,
ui_update_sender: Sender<display::DisplayState>,
qmdl_store_lock: Arc<RwLock<RecordingStore>>,
analysis_sender: Sender<AnalysisCtrlMessage>,
analyzer_config: AnalyzerConfig,
) {
task_tracker.spawn(async move {
let (initial_qmdl_file, initial_analysis_file) = qmdl_store_lock.write().await.new_entry().await.expect("failed creating QMDL file entry");
let mut maybe_qmdl_writer: Option<QmdlWriter<File>> = Some(QmdlWriter::new(initial_qmdl_file));
let mut diag_stream = pin!(dev.as_stream().into_stream());
let mut maybe_analysis_writer = Some(AnalysisWriter::new(initial_analysis_file, &analyzer_config).await
.expect("failed to create analysis writer"));
let mut diag_task = DiagTask::new(ui_update_sender, analysis_sender, analyzer_config);
qmdl_file_tx
.send(DiagDeviceCtrlMessage::StartRecording)
.await
.unwrap();
loop {
tokio::select! {
msg = qmdl_file_rx.recv() => {
match msg {
Some(DiagDeviceCtrlMessage::StartRecording) => {
let mut qmdl_store = qmdl_store_lock.write().await;
let (qmdl_file, new_analysis_file) = match qmdl_store.new_entry().await {
Ok(x) => x,
Err(e) => {
error!("couldn't create new qmdl entry: {e}");
continue;
}
};
maybe_qmdl_writer = Some(QmdlWriter::new(qmdl_file));
if let Some(analysis_writer) = maybe_analysis_writer {
analysis_writer.close().await.expect("failed to close analysis writer");
}
maybe_analysis_writer = Some(AnalysisWriter::new(new_analysis_file, &analyzer_config).await
.expect("failed to write to analysis file"));
if let Err(e) = ui_update_sender.send(display::DisplayState::Recording).await {
warn!("couldn't send ui update message: {e}");
}
diag_task.start(qmdl_store.deref_mut()).await;
},
Some(DiagDeviceCtrlMessage::StopRecording) => {
let mut qmdl_store = qmdl_store_lock.write().await;
if let Some((_, entry)) = qmdl_store.get_current_entry() {
if let Err(e) = analysis_sender
.send(AnalysisCtrlMessage::RecordingFinished(
entry.name.to_string(),
))
.await {
warn!("couldn't send analysis message: {e}");
}
}
if let Err(e) = qmdl_store.close_current_entry().await {
error!("couldn't close current entry: {e}");
}
maybe_qmdl_writer = None;
if let Some(analysis_writer) = maybe_analysis_writer {
analysis_writer.close().await.expect("failed to close analysis writer");
}
maybe_analysis_writer = None;
if let Err(e) = ui_update_sender.send(display::DisplayState::Paused).await {
warn!("couldn't send ui update message: {e}");
}
diag_task.stop(qmdl_store.deref_mut()).await;
},
// None means all the Senders have been dropped, so it's
// time to go
Some(DiagDeviceCtrlMessage::Exit) | None => {
info!("Diag reader thread exiting...");
if let Some(analysis_writer) = maybe_analysis_writer {
analysis_writer.close().await.expect("failed to close analysis writer");
}
diag_task.stop_current_recording().await;
return Ok(())
},
Some(DiagDeviceCtrlMessage::DeleteEntry { name, response_tx }) => {
let mut qmdl_store = qmdl_store_lock.write().await;
let resp = diag_task.delete_entry(qmdl_store.deref_mut(), name.as_str()).await;
if response_tx.send(resp).is_err() {
error!("Failed to send delete entry respons, receiver dropped");
}
},
Some(DiagDeviceCtrlMessage::DeleteAllEntries { response_tx }) => {
let mut qmdl_store = qmdl_store_lock.write().await;
let resp = diag_task.delete_all_entries(qmdl_store.deref_mut()).await;
if response_tx.send(resp).is_err() {
error!("Failed to send delete all entries respons, receiver dropped");
}
},
}
}
maybe_container = diag_stream.next() => {
match maybe_container.unwrap() {
Ok(container) => {
if container.data_type != DataType::UserSpace {
debug!("skipping non-userspace diag messages...");
continue;
}
// keep track of how many bytes were written to the QMDL file so we can read
// a valid block of data from it in the HTTP server
if let Some(qmdl_writer) = maybe_qmdl_writer.as_mut() {
qmdl_writer.write_container(&container).await.expect("failed to write to QMDL writer");
debug!("total QMDL bytes written: {}, updating manifest...", qmdl_writer.total_written);
let mut qmdl_store = qmdl_store_lock.write().await;
let index = qmdl_store.current_entry.expect("DiagDevice had qmdl_writer, but QmdlStore didn't have current entry???");
qmdl_store.update_entry_qmdl_size(index, qmdl_writer.total_written).await
.expect("failed to update qmdl file size");
debug!("done!");
} else {
debug!("no qmdl_writer set, continuing...");
}
if let Some(analysis_writer) = maybe_analysis_writer.as_mut() {
let heuristic_warning = analysis_writer.analyze(container).await
.expect("failed to analyze container");
if heuristic_warning {
info!("a heuristic triggered on this run!");
ui_update_sender.send(display::DisplayState::WarningDetected).await
.expect("couldn't send ui update message: {}");
}
}
let mut qmdl_store = qmdl_store_lock.write().await;
diag_task.process_container(qmdl_store.deref_mut(), container).await
},
Err(err) => {
error!("error reading diag device: {err}");
@@ -150,6 +277,7 @@ pub fn run_diag_read_thread(
});
}
/// Start recording API for web thread
pub async fn start_recording(
State(state): State<Arc<ServerState>>,
) -> Result<(StatusCode, String), (StatusCode, String)> {
@@ -171,6 +299,7 @@ pub async fn start_recording(
Ok((StatusCode::ACCEPTED, "ok".to_string()))
}
/// Stop recording API for web thread
pub async fn stop_recording(
State(state): State<Arc<ServerState>>,
) -> Result<(StatusCode, String), (StatusCode, String)> {
@@ -197,8 +326,27 @@ pub async fn delete_recording(
if state.config.debug_mode {
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
}
let mut qmdl_store = state.qmdl_store_lock.write().await;
match qmdl_store.delete_entry(&qmdl_name).await {
let (response_tx, response_rx) = oneshot::channel();
state
.diag_device_ctrl_sender
.send(DiagDeviceCtrlMessage::DeleteEntry {
name: qmdl_name.clone(),
response_tx,
})
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't send delete entry message: {e}"),
)
})?;
match response_rx.await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("failed to receive delete response: {e}"),
)
})? {
Ok(_) => Ok((StatusCode::ACCEPTED, "ok".to_string())),
Err(RecordingStoreError::NoSuchEntryError) => Err((
StatusCode::BAD_REQUEST,
format!("no recording with name {qmdl_name}"),
@@ -207,31 +355,6 @@ pub async fn delete_recording(
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't delete recording: {e}"),
)),
Ok(entry_type) => {
if entry_type == EntryType::Current {
state
.diag_device_ctrl_sender
.send(DiagDeviceCtrlMessage::StopRecording)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't send stop recording message: {e}"),
)
})?;
state
.ui_update_sender
.send(display::DisplayState::Paused)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't send ui update message: {e}"),
)
})?;
}
Ok((StatusCode::ACCEPTED, "ok".to_string()))
}
}
}
@@ -241,34 +364,29 @@ pub async fn delete_all_recordings(
if state.config.debug_mode {
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
}
let (response_tx, response_rx) = oneshot::channel();
state
.diag_device_ctrl_sender
.send(DiagDeviceCtrlMessage::StopRecording)
.send(DiagDeviceCtrlMessage::DeleteAllEntries { response_tx })
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't send stop recording message: {e}"),
format!("couldn't send delete all entries message: {e}"),
)
})?;
let mut qmdl_store = state.qmdl_store_lock.write().await;
qmdl_store.delete_all_entries().await.map_err(|e| {
match response_rx.await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't delete all recordings: {e}"),
format!("failed to receive delete all response: {e}"),
)
})?;
state
.ui_update_sender
.send(display::DisplayState::Paused)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't send ui update message: {e}"),
)
})?;
Ok((StatusCode::ACCEPTED, "ok".to_string()))
})? {
Ok(_) => Ok((StatusCode::ACCEPTED, "ok".to_string())),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't delete recordings: {e}"),
)),
}
}
pub async fn get_analysis_report(
+35 -31
View File
@@ -1,3 +1,4 @@
use async_trait::async_trait;
use image::{AnimationDecoder, DynamicImage, codecs::gif::GifDecoder, imageops::FilterType};
use std::io::Cursor;
use std::time::Duration;
@@ -11,8 +12,6 @@ use tokio::sync::oneshot;
use tokio::sync::oneshot::error::TryRecvError;
use tokio_util::task::TaskTracker;
use std::thread::sleep;
use include_dir::{Dir, include_dir};
#[derive(Copy, Clone)]
@@ -65,15 +64,13 @@ impl Color {
}
}
#[async_trait]
pub trait GenericFramebuffer: Send + 'static {
fn dimensions(&self) -> Dimensions;
fn write_buffer(
&mut self,
buffer: &[(u8, u8, u8)], // rgb, row-wise, left-to-right, top-to-bottom
);
async fn write_buffer(&mut self, buffer: Vec<(u8, u8, u8)>); // rgb, row-wise, left-to-right, top-to-bottom
fn write_dynamic_image(&mut self, img: DynamicImage) {
async fn write_dynamic_image(&mut self, img: DynamicImage) {
let dimensions = self.dimensions();
let mut width = img.width();
let mut height = img.height();
@@ -94,28 +91,35 @@ pub trait GenericFramebuffer: Send + 'static {
}
}
self.write_buffer(&buf);
self.write_buffer(buf).await
}
fn draw_gif(&mut self, img_buffer: &[u8]) {
// this is dumb and i'm sure there's a better way to loop this
async fn draw_gif(&mut self, img_buffer: &[u8]) {
let cursor = Cursor::new(img_buffer);
let decoder = GifDecoder::new(cursor).unwrap();
for maybe_frame in decoder.into_frames() {
let frame = maybe_frame.unwrap();
let (numerator, _) = frame.delay().numer_denom_ms();
let img = DynamicImage::from(frame.into_buffer());
self.write_dynamic_image(img);
std::thread::sleep(Duration::from_millis(numerator as u64));
if let Ok(decoder) = GifDecoder::new(cursor) {
let frames: Vec<_> = decoder
.into_frames()
.filter_map(|f| f.ok())
.map(|frame| {
let (numerator, _) = frame.delay().numer_denom_ms();
let img = DynamicImage::from(frame.into_buffer());
(img, numerator as u64)
})
.collect();
for (img, delay_ms) in frames {
self.write_dynamic_image(img).await;
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
}
}
}
fn draw_img(&mut self, img_buffer: &[u8]) {
async fn draw_img(&mut self, img_buffer: &[u8]) {
let img = image::load_from_memory(img_buffer).unwrap();
self.write_dynamic_image(img);
self.write_dynamic_image(img).await
}
fn draw_line(&mut self, color: Color, height: u32) {
async fn draw_line(&mut self, color: Color, height: u32) {
let width = self.dimensions().width;
let px_num = height * width;
let mut buffer = Vec::new();
@@ -123,7 +127,7 @@ pub trait GenericFramebuffer: Send + 'static {
buffer.push(color.rgb());
}
self.write_buffer(&buffer);
self.write_buffer(buffer).await
}
}
@@ -143,7 +147,7 @@ pub fn update_ui(
let colorblind_mode = config.colorblind_mode;
let mut display_color = Color::from_state(DisplayState::Recording, colorblind_mode);
task_tracker.spawn_blocking(move || {
task_tracker.spawn(async move {
// this feels wrong, is there a more rusty way to do this?
let mut img: Option<&[u8]> = None;
if display_level == 2 {
@@ -179,21 +183,21 @@ pub fn update_ui(
}
match display_level {
2 => fb.draw_gif(img.unwrap()),
3 => fb.draw_img(img.unwrap()),
2 => fb.draw_gif(img.unwrap()).await,
3 => fb.draw_img(img.unwrap()).await,
128 => {
fb.draw_line(Color::Cyan, 128);
fb.draw_line(Color::Pink, 102);
fb.draw_line(Color::White, 76);
fb.draw_line(Color::Pink, 50);
fb.draw_line(Color::Cyan, 25);
fb.draw_line(Color::Cyan, 128).await;
fb.draw_line(Color::Pink, 102).await;
fb.draw_line(Color::White, 76).await;
fb.draw_line(Color::Pink, 50).await;
fb.draw_line(Color::Cyan, 25).await;
}
// this branch id for ui_level 1, which is also the default if an
// unknown value is used
_ => {}
};
fb.draw_line(display_color, 2);
sleep(Duration::from_millis(1000));
fb.draw_line(display_color, 2).await;
tokio::time::sleep(Duration::from_millis(1000)).await;
}
});
}
+7 -5
View File
@@ -1,6 +1,7 @@
use crate::config;
use crate::display::DisplayState;
use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer};
use async_trait::async_trait;
use tokio::sync::mpsc::Receiver;
use tokio::sync::oneshot;
@@ -11,6 +12,7 @@ const FB_PATH: &str = "/dev/fb0";
#[derive(Copy, Clone, Default)]
struct Framebuffer;
#[async_trait]
impl GenericFramebuffer for Framebuffer {
fn dimensions(&self) -> Dimensions {
// TODO actually poll for this, maybe w/ fbset?
@@ -20,16 +22,16 @@ impl GenericFramebuffer for Framebuffer {
}
}
fn write_buffer(&mut self, buffer: &[(u8, u8, u8)]) {
async fn write_buffer(&mut self, buffer: Vec<(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;
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();
tokio::fs::write(FB_PATH, &raw_buffer).await.unwrap();
}
}
+16 -18
View File
@@ -7,8 +7,6 @@ use tokio::sync::mpsc;
use tokio::sync::oneshot;
use tokio_util::task::TaskTracker;
use std::fs::write;
use std::thread::sleep;
use std::time::Duration;
use crate::config;
@@ -18,12 +16,12 @@ macro_rules! led {
($l:expr) => {{ format!("/sys/class/leds/led:{}/blink", $l) }};
}
fn start_blinking(path: String) {
write(&path, "1").ok();
async fn start_blinking(path: String) {
tokio::fs::write(&path, "1").await.ok();
}
fn stop_blinking(path: String) {
write(&path, "0").ok();
async fn stop_blinking(path: String) {
tokio::fs::write(&path, "0").await.ok();
}
pub fn update_ui(
@@ -37,7 +35,7 @@ pub fn update_ui(
info!("Invisible mode, not spawning UI.");
invisible = true;
}
task_tracker.spawn_blocking(move || {
task_tracker.spawn(async move {
let mut state = DisplayState::Recording;
let mut last_state = DisplayState::Paused;
@@ -56,28 +54,28 @@ pub fn update_ui(
Err(e) => error!("error receiving ui update message: {e}"),
};
if invisible || state == last_state {
sleep(Duration::from_secs(1));
tokio::time::sleep(Duration::from_secs(1)).await;
continue;
}
match state {
DisplayState::Paused => {
stop_blinking(led!("signal_blue"));
stop_blinking(led!("signal_red"));
start_blinking(led!("wlan_white"));
stop_blinking(led!("signal_blue")).await;
stop_blinking(led!("signal_red")).await;
start_blinking(led!("wlan_white")).await;
}
DisplayState::Recording => {
stop_blinking(led!("wlan_white"));
stop_blinking(led!("signal_red"));
start_blinking(led!("signal_blue"));
stop_blinking(led!("wlan_white")).await;
stop_blinking(led!("signal_red")).await;
start_blinking(led!("signal_blue")).await;
}
DisplayState::WarningDetected => {
stop_blinking(led!("wlan_white"));
stop_blinking(led!("signal_blue"));
start_blinking(led!("signal_red"));
stop_blinking(led!("wlan_white")).await;
stop_blinking(led!("signal_blue")).await;
start_blinking(led!("signal_red")).await;
}
}
last_state = state;
sleep(Duration::from_secs(1));
tokio::time::sleep(Duration::from_secs(1)).await;
}
});
}
+2
View File
@@ -19,6 +19,8 @@ pub fn update_ui(
info!("Invisible mode, not spawning UI.");
}
// Since this is a one-time check at startup, using sync is acceptable
// The alternative would be to make the entire initialization async
if fs::exists(tplink_onebit::OLED_PATH).unwrap_or_default() {
info!("detected one-bit display");
tplink_onebit::update_ui(task_tracker, config, ui_shutdown_rx, ui_update_rx)
+11 -8
View File
@@ -1,6 +1,7 @@
use std::fs::File;
use std::io::Write;
use async_trait::async_trait;
use std::os::fd::AsRawFd;
use tokio::fs::OpenOptions;
use tokio::io::AsyncWriteExt;
use crate::config;
use crate::display::DisplayState;
@@ -24,6 +25,7 @@ struct fb_fillrect {
rop: u32,
}
#[async_trait]
impl GenericFramebuffer for Framebuffer {
fn dimensions(&self) -> Dimensions {
// TODO actually poll for this, maybe w/ fbset?
@@ -33,12 +35,12 @@ impl GenericFramebuffer for Framebuffer {
}
}
fn write_buffer(&mut self, buffer: &[(u8, u8, u8)]) {
async fn write_buffer(&mut self, buffer: Vec<(u8, u8, u8)>) {
// for how to write to the buffer, consult M7350v5_en_gpl/bootable/recovery/recovery_color_oled.c
let dimensions = self.dimensions();
let width = dimensions.width;
let height = buffer.len() as u32 / width;
let mut f = File::options().write(true).open(FB_PATH).unwrap();
let mut f = OpenOptions::new().write(true).open(FB_PATH).await.unwrap();
let mut arg = fb_fillrect {
dx: 0,
dy: 0,
@@ -50,15 +52,16 @@ impl GenericFramebuffer for Framebuffer {
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;
let mut rgb565: u16 = (r as u16 & 0b11111000) << 8;
rgb565 |= (g as u16 & 0b11111100) << 3;
rgb565 |= (b as u16) >> 3;
// note: big-endian!
raw_buffer.extend(rgb565.to_be_bytes());
}
f.write_all(&raw_buffer).unwrap();
f.write_all(&raw_buffer).await.unwrap();
// ioctl is a synchronous operation, but it's fast enough that it shouldn't block
unsafe {
let res = libc::ioctl(
f.as_raw_fd(),
+3 -5
View File
@@ -10,8 +10,6 @@ use tokio::sync::oneshot;
use tokio::sync::oneshot::error::TryRecvError;
use tokio_util::task::TaskTracker;
use std::fs;
use std::thread::sleep;
use std::time::Duration;
pub const OLED_PATH: &str = "/sys/class/display/oled/oled_buffer";
@@ -122,7 +120,7 @@ pub fn update_ui(
info!("Invisible mode, not spawning UI.");
}
task_tracker.spawn_blocking(move || {
task_tracker.spawn(async move {
let mut pixels = STATUS_SMILING;
loop {
@@ -148,12 +146,12 @@ pub fn update_ui(
// we write the status every second because it may have been overwritten through menu
// navigation.
if display_level != 0 {
if let Err(e) = fs::write(OLED_PATH, pixels) {
if let Err(e) = tokio::fs::write(OLED_PATH, pixels).await {
error!("failed to write to display: {e}");
}
}
sleep(Duration::from_millis(1000));
tokio::time::sleep(Duration::from_millis(1000)).await;
}
});
}
+10 -8
View File
@@ -1,12 +1,13 @@
use crate::config;
use crate::display::DisplayState;
use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer};
/// 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::DisplayState;
use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer};
use async_trait::async_trait;
use tokio::sync::mpsc::Receiver;
use tokio::sync::oneshot;
@@ -17,6 +18,7 @@ const FB_PATH: &str = "/dev/fb0";
#[derive(Copy, Clone, Default)]
struct Framebuffer;
#[async_trait]
impl GenericFramebuffer for Framebuffer {
fn dimensions(&self) -> Dimensions {
Dimensions {
@@ -25,16 +27,16 @@ impl GenericFramebuffer for Framebuffer {
}
}
fn write_buffer(&mut self, buffer: &[(u8, u8, u8)]) {
async fn write_buffer(&mut self, buffer: Vec<(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;
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();
tokio::fs::write(FB_PATH, &raw_buffer).await.unwrap();
}
}
+2 -2
View File
@@ -182,7 +182,7 @@ fn run_shutdown_thread(
})
}
#[tokio::main]
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), RayhunterError> {
env_logger::init();
@@ -229,6 +229,7 @@ async fn run_with_config(
&task_tracker,
dev,
diag_rx,
diag_tx.clone(),
ui_update_tx.clone(),
qmdl_store_lock.clone(),
analysis_tx.clone(),
@@ -284,7 +285,6 @@ async fn run_with_config(
config,
qmdl_store_lock: qmdl_store_lock.clone(),
diag_device_ctrl_sender: diag_tx,
ui_update_sender: ui_update_tx,
analysis_status_lock,
analysis_sender: analysis_tx,
daemon_restart_tx: Arc::new(RwLock::new(Some(daemon_restart_tx))),
+14 -12
View File
@@ -56,12 +56,6 @@ pub struct ManifestEntry {
pub arch: Option<String>,
}
#[derive(PartialEq, Eq)]
pub enum EntryType {
Current,
Past,
}
impl ManifestEntry {
fn new() -> Self {
let now = Local::now();
@@ -347,23 +341,31 @@ impl RecordingStore {
Some((entry_index, &self.manifest.entries[entry_index]))
}
pub async fn delete_entry(&mut self, name: &str) -> Result<EntryType, RecordingStoreError> {
pub fn is_current_entry(&self, name: &str) -> bool {
match self.current_entry {
Some(idx) => match self.manifest.entries.get(idx) {
Some(entry) => entry.name == name,
None => false,
},
None => false,
}
}
pub async fn delete_entry(&mut self, name: &str) -> Result<(), RecordingStoreError> {
let entry_to_delete_idx = self
.manifest
.entries
.iter()
.position(|entry| entry.name == name)
.ok_or(RecordingStoreError::NoSuchEntryError)?;
let is_current = match self.current_entry {
match self.current_entry {
Some(current_entry) if current_entry == entry_to_delete_idx => {
self.close_current_entry().await?;
EntryType::Current
}
Some(current_entry) => {
self.current_entry = Some(current_entry - 1);
EntryType::Past
}
None => EntryType::Past,
None => {}
};
let entry_to_delete = self.manifest.entries.remove(entry_to_delete_idx);
self.write_manifest().await?;
@@ -375,7 +377,7 @@ impl RecordingStore {
remove_file_if_exists(&analysis_filepath)
.await
.map_err(RecordingStoreError::DeleteFileError)?;
Ok(is_current)
Ok(())
}
pub async fn delete_all_entries(&mut self) -> Result<(), RecordingStoreError> {
+1 -4
View File
@@ -18,18 +18,17 @@ use tokio::sync::{RwLock, oneshot};
use tokio_util::compat::FuturesAsyncWriteCompatExt;
use tokio_util::io::ReaderStream;
use crate::DiagDeviceCtrlMessage;
use crate::analysis::{AnalysisCtrlMessage, AnalysisStatus};
use crate::config::Config;
use crate::pcap::generate_pcap_data;
use crate::qmdl_store::RecordingStore;
use crate::{DiagDeviceCtrlMessage, display};
pub struct ServerState {
pub config_path: String,
pub config: Config,
pub qmdl_store_lock: Arc<RwLock<RecordingStore>>,
pub diag_device_ctrl_sender: Sender<DiagDeviceCtrlMessage>,
pub ui_update_sender: Sender<display::DisplayState>,
pub analysis_status_lock: Arc<RwLock<AnalysisStatus>>,
pub analysis_sender: Sender<AnalysisCtrlMessage>,
pub daemon_restart_tx: Arc<RwLock<Option<oneshot::Sender<()>>>>,
@@ -293,7 +292,6 @@ mod tests {
store_lock: Arc<RwLock<crate::qmdl_store::RecordingStore>>,
) -> Arc<ServerState> {
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 = {
@@ -306,7 +304,6 @@ mod tests {
config: Config::default(),
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,
daemon_restart_tx: Arc::new(RwLock::new(None)),
@@ -33,45 +33,49 @@
{#if report.statistics.num_warnings === 0 && report.statistics.num_informational_logs === 0}
<p>Nothing to show!</p>
{:else}
<table class="table-auto text-left">
<thead class="p-2">
<tr class="bg-gray-300">
<th class="p-2">Timestamp</th>
<th class="p-2">Heuristic</th>
<th class="p-2">Warning</th>
<th class="p-2">Severity</th>
</tr>
</thead>
<tbody>
{#each report.rows as row}
{#if row.type === AnalysisRowType.Analysis}
{@const parsed_date = new Date(row.packet_timestamp)}
{#each row.events.filter((e) => e !== null) as event, i}
{@const analyzer = analyzers[i]}
<tr class="even:bg-gray-200 odd:bg-white">
{#if event.type === EventType.Warning}
{@const severity = ['Low', 'Medium', 'High'][event.severity]}
{@const severity_class = [
'bg-red-200',
'bg-red-400',
'bg-red-600',
][event.severity]}
<td class="p-2">{date_formatter.format(parsed_date)}</td>
<td class="p-2">{analyzer.name} v{analyzer.version}</td>
<td class="p-2">{event.message}</td>
<td class="p-2 {severity_class} text-center">{severity}</td>
{:else if event.type === EventType.Informational}
<td class="p-2">{date_formatter.format(parsed_date)}</td>
<td class="p-2">{analyzer.name} v{analyzer.version}</td>
<td class="p-2">{event.message}</td>
<td class="p-2">Info</td>
{/if}
</tr>
{/each}
{/if}
{/each}
</tbody>
</table>
<div class="overflow-x-scroll">
<table class="table-auto text-left">
<thead class="p-2">
<tr class="bg-gray-300">
<th class="p-2">Timestamp</th>
<th class="p-2">Heuristic</th>
<th class="p-2">Warning</th>
<th class="p-2">Severity</th>
</tr>
</thead>
<tbody>
{#each report.rows as row}
{#if row.type === AnalysisRowType.Analysis}
{@const parsed_date = new Date(row.packet_timestamp)}
{#each row.events.filter((e) => e !== null) as event, i}
{@const analyzer = analyzers[i]}
<tr class="even:bg-gray-200 odd:bg-white">
{#if event.type === EventType.Warning}
{@const severity = ['Low', 'Medium', 'High'][
event.severity
]}
{@const severity_class = [
'bg-red-200',
'bg-red-400',
'bg-red-600',
][event.severity]}
<td class="p-2">{date_formatter.format(parsed_date)}</td>
<td class="p-2">{analyzer.name} v{analyzer.version}</td>
<td class="p-2">{event.message}</td>
<td class="p-2 {severity_class} text-center">{severity}</td>
{:else if event.type === EventType.Informational}
<td class="p-2">{date_formatter.format(parsed_date)}</td>
<td class="p-2">{analyzer.name} v{analyzer.version}</td>
<td class="p-2">{event.message}</td>
<td class="p-2">Info</td>
{/if}
</tr>
{/each}
{/if}
{/each}
</tbody>
</table>
</div>
{/if}
</div>
{#if report.statistics.num_skipped_packets > 0}
@@ -81,21 +85,23 @@
These are due to a limitation or bug in Rayhunter's parser, and aren't ususally a
problem.
</p>
<table class="table-auto text-left">
<thead class="p-2">
<tr class="bg-gray-300">
<th scope="col" class="p-2">Total Msgs Affected</th>
<th scope="col">Reason/Error</th>
</tr>
</thead>
<tbody>
{#each skipped_messages.entries() as [message, count]}
<tr class="even:bg-gray-200 odd:bg-white">
<td class="text-center">{count}</td>
<td>{message}</td>
<div class="overflow-x-scroll">
<table class="table-auto text-left">
<thead class="p-2">
<tr class="bg-gray-300">
<th scope="col" class="p-2">Total Msgs Affected</th>
<th scope="col">Reason/Error</th>
</tr>
{/each}
</tbody>
</table>
</thead>
<tbody>
{#each skipped_messages.entries() as [message, count]}
<tr class="even:bg-gray-200 odd:bg-white">
<td class="text-center">{count}</td>
<td>{message}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
@@ -18,7 +18,7 @@
</script>
<button
class="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded-md flex flex-row"
class="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-2 sm:px-4 rounded-md flex flex-row"
onclick={confirmDelete}
aria-label="delete"
>
@@ -16,7 +16,7 @@
<button
class="flex flex-row {full_button
? 'bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-md'
? 'bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-2 sm:px-4 rounded-md'
: 'text-blue-600 underline'}"
onclick={download}
>
@@ -41,7 +41,7 @@
</script>
<div
class="{status_row_color} {status_border_color} drop-shadow p-4 flex flex-col gap-2 border rounded-md flex-1"
class="{status_row_color} {status_border_color} drop-shadow p-4 flex flex-col gap-2 border rounded-md flex-1 overflow-x-scroll overflow-y-hidden"
>
{#if current}
<div class="flex flex-row justify-between gap-2">
@@ -78,7 +78,7 @@
'N/A'}</span
>
</div>
<div class="flex flex-row justify-between lg:justify-end gap-2 mt-2">
<div class="flex flex-row justify-between lg:justify-end gap-1 mt-2 overflow-x-scroll">
<DownloadLink url={entry.get_pcap_url()} text="pcap" full_button />
<DownloadLink url={entry.get_qmdl_url()} text="qmdl" full_button />
<DownloadLink url={entry.get_zip_url()} text="zip" full_button />
@@ -20,7 +20,7 @@
}
const recording_button_classes =
'text-white font-bold py-2 px-4 rounded-md flex flex-row gap-1';
'text-white font-bold py-2 px-2 sm:px-4 rounded-md flex flex-row gap-1';
const stop_recording_classes = `${recording_button_classes} bg-red-500 opacity-50 cursor-not-allowed`;
const start_recording_classes = `${recording_button_classes} bg-blue-500 opacity-50 cursor-not-allowed`;
</script>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

+16 -2
View File
@@ -1,5 +1,19 @@
# 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.
Rayhunter can be configured through web user interface or by editing `/data/rayhunter/config.toml` on the device.
View the [default configuration file on GitHub](https://github.com/EFForg/rayhunter/blob/main/dist/config.toml.in).
![rayhunter_config](./rayhunter_config.png)
Through web UI you can set:
- **Device UI Level**, which defines what Rayhunter shows on device's built-in screen. *Device UI Level* could be:
- *Invisible mode*: Rayhunter does not show anything on the built-in screen
- *Subtle mode (colored line)*: Rayhunter shows green line if there are no warnings, red line if there are warnings (warnings could be checked through web UI) and white line if Rayhunter is not recording
- *Demo mode (orca gif)*, which shows image of orca fish *and* colored line
- *EFF logo*, which shows EFF logo and *and* colored line.
- **Device Input Mode**, which defines behaviour of built-in power button of the device. *Device Input Mode* could be:
- *Disable button control*: built-in power button of the device is not used by Rayhunter;
- *Double-tap power button to start/stop recording*: double clicking on a built-in power button of the device stops and immediatelly restarts the recording. This could be useful if Rayhunter's heuristichs is triggered and you get the red line, and you want to "reset" the past warnings. Normally you can do that through web UI, but sometimes it is easier to double tap on power button.
- **Colorblind Mode** enables color blind mode (blue line is shown instead of green line, red line remains red). Please note that this does not cover all types of color blindness, but switching green to blue should be about enough to differentiate the color change for most types of color blindness.
- With **Analyzer Heuristic Settings** you can switch on or off built-in [Rayhunter heuristics](heuristics.md). Some heuristics are experimental or can trigger a lot of false positive warnings in some networks (our tests have shown that some heuristics have different behaviour in US or European networks). In that case you can decide whether you would like to have the heuristics that trigger a lot of false positives on or off. Please note that we are constantly improving and adding new heuristics, so new release may reduce false positives in existing heuristics as well.
If you prefer editing `config.toml` file, you need to obtain a shell on your [Orbic](./orbic.md#obtaining-a-shell) or [TP-Link](./tplink-m7350.md#obtaining-a-shell) device and edit the file manually. You can view the [default configuration file on a GitHub](https://github.com/EFForg/rayhunter/blob/main/dist/config.toml.in).
+4 -4
View File
@@ -25,15 +25,15 @@ If you want to use a non-Verizon SIM card you will probably need an unlocked dev
Make sure USB tethering is also enabled in the Orbic's UI, and then run the following commands:
```sh
installer util shell "echo 9 > /usrdata/mode.cfg"
installer util shell reboot
./installer util shell "echo 9 > /usrdata/mode.cfg"
./installer util shell reboot
```
To disable tethering again:
```sh
installer util shell "echo 3 > /usrdata/mode.cfg"
installer util shell reboot
./installer util shell "echo 3 > /usrdata/mode.cfg"
./installer util shell reboot
```
See `/data/usb/boot_hsusb_composition` for a list of USB modes and Android USB gadget settings.
+7 -19
View File
@@ -1,24 +1,12 @@
# Heuristics
Rayhunter includes several analyzers to detect potential IMSI catcher activity. These can be enabled and disabled in your [config.toml](./configuration.md) file.
Rayhunter includes several analyzers to detect potential IMSI catcher activity. These can be enabled and disabled in your [configuration](./configuration.md) file.
## Available Analyzers
- **IMSI Requested**: Tests whether the eNodeB sends an IMSI Identity Request NAS message. This
can sometimes happen under normal circumstances when the network doesn't already have a TMSI
(Temporary Mobile Subscriber ID or GUTI in 5G terminology) for your device. This most often
happens when you first turn the device on, especially after it has been off for a long time or
if you are in an area where ther is absolutely no connection to your service provider. This can
also happen if you leave your device on while on an airplane and it suddenly connects to a new
tower after being disconnected for a long time.
However, if you get this warning at a time when you have been steadily connected to towers and the device has been on for a while it can be treated as suspcious.
- **Connection Release/Redirected Carrier 2G Downgrade**: Tests if a cell
releases our connection and redirects us to a 2G cell. This heuristic only
makes sense in the US or other countries where there are no more operating 2G base stations.
Users in contries where 2G is still in service (such as most of EU) may want to disable it.
See https://en.wikipedia.org/wiki/2G#Past_2G_networks for information about your country.
- **LTE SIB6/7 Downgrade**: Tests for LTE cells broadcasting a SIB type 6 and 7
which include 2G/3G frequencies with higher priorities
- **Null Cipher**: Tests whether the cell suggests using a null cipher (EEA0) in the RRC layer.
- **NAS Null Cipher**: Tests whether the security mode command at the NAS layer suggests using a null cipher (EEA0). This would usually only happen after a UE has successfully authenticated with the MME but still it shouldn't happen at all, this could be indicative of an attack though using SS7 to get key material from the HLR of the UE for a succesful authentication. It could also indicate an IMSI catcher which is connected to the mobile network MME and HLR through cooperation between government and telco. Or it could be a false positive if the telco is intending to use null ciphers (if encryption is illegal or something.)
- **Incomplete SIB**: Tests whether the SIB1 message contains a complete SIB chain (SIB3, SIB5, etc.) A legitimate SIB1 should contain timing information for at least 2 additional sibs (sib3, 4, and 5 being the most common) but a fake base station will often not bother to send additional SIBs beyond 1 and 2. On its own this might just be a misconfigured base station (though we have only seen it in the wild under suspicious circumstances) but combined with other heuristics such as **ISMI Requested** detection it should be considered a strong indicator of malicious activity.
- **IMSI Requested**: Tests whether the eNodeB sends an IMSI Identity Request NAS message. This can sometimes happen under normal circumstances when the network doesn't already have a TMSI (Temporary Mobile Subscriber ID or GUTI in 5G terminology) for your device. This most often happens when you first turn the device on, especially after it has been off for a long time or if you are in an area where there is absolutely no connection to your service provider. This can also happen if you leave your device on while on an airplane and it suddenly connects to a new tower after being disconnected for a long time. However, if you get this warning at a time when you have been steadily connected to towers and the device has been on for a while it can be treated as suspcious.
- **Connection Release/Redirected Carrier 2G Downgrade**: Tests if a cell releases our connection and redirects us to a 2G cell. This heuristic mostly makes sense in the US or other countries where there are no more operating 2G base stations. In countries where 2G is still in service (such as most of EU), this heuristics may trigger a lot of false positives, so you may want to disable it. However it should be noted that many IMSI Catchers operate in a such way that they downgrade connection to 2G and also that this heuristics has been vastly improved to reduce false positive warnings. See [Wikipedia page on past 2G networks](https://en.wikipedia.org/wiki/2G#Past_2G_networks) for information about your country.
- **LTE SIB6/7 Downgrade**: Tests for LTE cells broadcasting a SIB type 6 and 7 messages which include 2G/3G frequencies with higher priorities.
- **Null Cipher**: Tests whether the cell suggests using a null cipher (EEA0) in the RRC layer (that means that encryption between your mobile device and base staation is turned off).
- **NAS Null Cipher**: Tests whether the security mode command at the NAS layer suggests using a null cipher (EEA0). This would usually only happen after a UE has successfully authenticated with the MME but still it shouldn't happen at all. This could be indicative of an attack though using SS7 to get key material from the HLR of the UE for a succesful authentication. It could also indicate an IMSI catcher which is connected to the mobile network MME and HLR through cooperation between government and telecom provider. Or it could be a false positive if the telecom provider is intending to use null ciphers (if encryption is illegal or they have some misconfiguration of the network), however this should be very rare case.
- **Incomplete SIB**: Tests whether the SIB1 message contains a complete SIB chain (SIB3, SIB5, etc.) A legitimate SIB1 mesage should contain timing information for at least 2 additional sibs (sib3, 4, and 5 being the most common) but a fake base station will often not bother to send additional SIBs beyond 1 and 2. On its own this might just be a misconfigured base station (though we have only seen it in the wild under suspicious circumstances) but combined with other heuristics such as **ISMI Requested** detection it should be considered a strong indicator of malicious activity.
+4 -2
View File
@@ -35,12 +35,14 @@ The modem is fully capable of running Rayhunter, but lacks both a screen and a n
Note that the Quectel EG25-G does not support LTE band 48 (CBRS 3500MHz), used in the US for unlicensed 4G/5G connectivity.
## Installing
Download and extract the installer *on a shell on the PinePhone itself*. Unlike other Rayhunter installers, this has to be run on the device itself. Then run:
```sh
./installer pinephone
```
## Accessing rayhunter
Because the modem does not have its own display or network interface, rayhunter is only accessible on the pinephone by forwarding tcp over adb.
## Accessing Rayhunter
Because the modem does not have its own display or network interface, Rayhunter is only accessible on the pinephone by forwarding tcp over adb.
```sh
adb forward tcp:8080 tcp:8080
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

+2 -4
View File
@@ -2,7 +2,7 @@
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)
![Rayhunter_0 5 0](./Rayhunter_0.5.0.png)
It also serves a web UI that provides some basic controls, such as being able to start/stop recordings, download captures, delete captures, and view heuristic analyses of captures.
@@ -28,6 +28,4 @@ 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).
As of Rayhunter verion 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 Rayhunter version 0.4.0 and needs to be enabled through [configuration](./configuration.md).
+35
View File
@@ -67,3 +67,38 @@ WT_HARDWARE_VERSION=89323_1_20
```
Please consider sharing the contents of your device's /etc/wt_version file here.
## Troubleshooting
### My hotspot won't turn on after rebooting when installing over WiFi
Reinsert the battery and turn the device back on, Rayhunter should be installed and running. Sometimes the Wingtech hotspot gets stuck off and ignores the power button after a reboot until the battery is reseated.
You do not need to run the installer again.
You'll likely see the following messages, where the installer is stuck at `Testing rayhunter ... `.
```sh
Starting telnet ... ok
Connecting via telnet to 192.168.1.1 ... ok
Sending file /data/rayhunter/config.toml ... ok
Sending file /data/rayhunter/rayhunter-daemon ... ok
Sending file /etc/init.d/rayhunter_daemon ... ok
Rebooting device and waiting 30 seconds for it to start up.
Testing rayhunter ...
```
If you eventually see:
```sh
Testing rayhunter ...
Failed to install rayhunter on the Wingtech CT2MHS01
Caused by:
0: error sending request for url (http://192.168.1.1:8080/index.html)
1: client error (Connect)
2: tcp connect error: Network is unreachable (os error 101)
3: Network is unreachable (os error 101)
```
Make sure your computer is connected to the hotspot's wifi network.
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "installer"
version = "0.5.0"
version = "0.5.1"
edition = "2024"
[dependencies]
+28
View File
@@ -1,3 +1,6 @@
#[cfg(target_os = "windows")]
use std::io::stdin;
use std::io::{ErrorKind, Write};
use std::path::Path;
use std::time::Duration;
@@ -30,6 +33,13 @@ On macOS or windows this might be caused by another program using the Orbic.
Please close any program that might be using your Orbic.
If you have adb installed you may need to kill the adb daemon"#;
#[cfg(target_os = "windows")]
const WINDOWS_WARNING: &str = r#""WINDOWS IS NOT FULLY SUPPORTED
THIS MAY BRICK YOUR DEVICE
PLEASE INSTALL FROM MACOS OR LINUX INSTEAD IF POSSIBLE"#;
const VENDOR_ID: u16 = 0x05c6;
const PRODUCT_ID: u16 = 0xf601;
@@ -41,7 +51,25 @@ const RNDIS_INTERFACE: u8 = 0;
#[cfg(not(target_os = "windows"))]
const RNDIS_INTERFACE: u8 = 1;
#[cfg(target_os = "windows")]
async fn confirm() -> Result<bool> {
println!("{}", WINDOWS_WARNING);
echo!("Do you wish to proceed? Enter 'yes' to install> ");
let mut input = String::new();
stdin().read_line(&mut input)?;
Ok(input.trim() == "yes")
}
pub async fn install() -> Result<()> {
#[cfg(target_os = "windows")]
{
let confirmation = confirm().await?;
if confirmation != true {
println!("Install aborted. Your device has not been modified.");
return Ok(());
}
}
let mut adb_device = force_debug_mode().await?;
echo!("Installing rootshell... ");
setup_rootshell(&mut adb_device).await?;
+2 -2
View File
@@ -75,7 +75,7 @@ pub async fn run_command(admin_ip: &str, admin_password: &str, cmd: &str) -> Res
.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}"))
.body(format!("page=setFWMacFilter&cmd=del&mode=0&mac=50:5A:CA:B5:05||{cmd}&key=50:5A:CA:B5:05:AC&token={token}"))
.send()
.await?;
if command.status() != 200 {
@@ -135,7 +135,7 @@ async fn wingtech_run_install(admin_ip: String, admin_password: String) -> Resul
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?;
telnet_send_command(addr, "shutdown -r -t 1 now", "exit code 0").await?;
sleep(Duration::from_secs(30)).await;
echo!("Testing rayhunter ... ");
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "rayhunter"
version = "0.5.0"
version = "0.5.1"
edition = "2024"
description = "Realtime cellular data decoding and analysis for IMSI catcher detection"
+3 -2
View File
@@ -2,8 +2,9 @@
pushd daemon/web
npm run build
popd
cargo build --profile firmware --bin rayhunter-daemon --target="armv7-unknown-linux-musleabihf" #--features debug
cargo build --profile firmware-devel --bin rayhunter-daemon --target="armv7-unknown-linux-musleabihf" #--features debug
adb shell '/bin/rootshell -c "/etc/init.d/rayhunter_daemon stop"'
adb push target/armv7-unknown-linux-musleabihf/firmware/rayhunter-daemon /data/rayhunter/rayhunter-daemon
adb push target/armv7-unknown-linux-musleabihf/firmware-devel/rayhunter-daemon \
/data/rayhunter/rayhunter-daemon
echo "rebooting the device..."
adb shell '/bin/rootshell -c "reboot"'
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "rootshell"
version = "0.5.0"
version = "0.5.1"
edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "telcom-parser"
version = "0.5.0"
version = "0.5.1"
edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html