From 0915103ede43b19b707f47c02cee5e1835faf931 Mon Sep 17 00:00:00 2001 From: Will Greenberg Date: Mon, 14 Jul 2025 13:08:22 -0700 Subject: [PATCH] Flattens analysis structure a bit Instead of mirroring the QMDL container format exactly, let our analysis files just be flat lists of packet analysis. Also removes the dummy analyzer and adds version numbers to analysis reports and Analyzers --- daemon/src/analysis.rs | 27 +++--- daemon/src/config.rs | 8 +- daemon/src/diag.rs | 5 +- daemon/src/dummy_analyzer.rs | 53 ----------- daemon/src/main.rs | 3 - dist/config.toml.example | 3 +- lib/src/analysis/analyzer.rs | 88 ++++++++----------- .../analysis/connection_redirect_downgrade.rs | 4 + lib/src/analysis/imsi_provided.rs | 4 + lib/src/analysis/imsi_requested.rs | 4 + lib/src/analysis/null_cipher.rs | 4 + lib/src/analysis/priority_2g_downgrade.rs | 4 + 12 files changed, 78 insertions(+), 129 deletions(-) delete mode 100644 daemon/src/dummy_analyzer.rs diff --git a/daemon/src/analysis.rs b/daemon/src/analysis.rs index 226e73f..db34ac7 100644 --- a/daemon/src/analysis.rs +++ b/daemon/src/analysis.rs @@ -18,7 +18,6 @@ use tokio::sync::mpsc::Receiver; use tokio::sync::{RwLock, RwLockWriteGuard}; use tokio_util::task::TaskTracker; -use crate::dummy_analyzer::TestAnalyzer; use crate::qmdl_store::RecordingStore; use crate::server::ServerState; @@ -37,13 +36,10 @@ pub struct AnalysisWriter { impl AnalysisWriter { pub async fn new( file: File, - enable_dummy_analyzer: bool, analyzer_config: &AnalyzerConfig, ) -> Result { - let mut harness = Harness::new_with_config(analyzer_config); - if enable_dummy_analyzer { - harness.add_analyzer(Box::new(TestAnalyzer { count: 0 })); - } + let harness = Harness::new_with_config(analyzer_config); + let mut result = Self { writer: BufWriter::new(file), @@ -56,16 +52,20 @@ impl AnalysisWriter { } // Runs the analysis harness on the given container, serializing the results - // to the analysis file and returning the file's new length. + // to the analysis file, returning the file's new length, and whether any + // warnings were detected pub async fn analyze( &mut self, container: MessagesContainer, ) -> Result<(usize, bool), std::io::Error> { - let row = self.harness.analyze_qmdl_messages(container); - if !row.is_empty() { - self.write(&row).await?; + let mut warning_detected = false; + for row in self.harness.analyze_qmdl_messages(container) { + if !row.is_empty() { + self.write(&row).await?; + } + warning_detected |= row.contains_warnings(); } - Ok((self.bytes_written, row.contains_warnings())) + Ok((self.bytes_written, warning_detected)) } async fn write(&mut self, value: &T) -> Result<(), std::io::Error> { @@ -134,7 +134,6 @@ async fn finish_running_analysis(analysis_status_lock: Arc>, - enable_dummy_analyzer: bool, analyzer_config: &AnalyzerConfig, ) -> Result<(), String> { info!("Opening QMDL and analysis file for {name}..."); @@ -156,7 +155,7 @@ async fn perform_analysis( }; let mut analysis_writer = - AnalysisWriter::new(analysis_file, enable_dummy_analyzer, analyzer_config) + AnalysisWriter::new(analysis_file, analyzer_config) .await .map_err(|e| format!("{e:?}"))?; let file_size = qmdl_file @@ -203,7 +202,6 @@ pub fn run_analysis_thread( mut analysis_rx: Receiver, qmdl_store_lock: Arc>, analysis_status_lock: Arc>, - enable_dummy_analyzer: bool, analyzer_config: AnalyzerConfig, ) { task_tracker.spawn(async move { @@ -216,7 +214,6 @@ pub fn run_analysis_thread( if let Err(err) = perform_analysis( &name, qmdl_store_lock.clone(), - enable_dummy_analyzer, &analyzer_config, ) .await diff --git a/daemon/src/config.rs b/daemon/src/config.rs index 5d3ffec..8d1c4f5 100644 --- a/daemon/src/config.rs +++ b/daemon/src/config.rs @@ -11,10 +11,12 @@ pub struct Config { pub port: u16, pub debug_mode: bool, pub ui_level: u8, - pub enable_dummy_analyzer: bool, pub colorblind_mode: bool, pub key_input_mode: u8, pub analyzers: AnalyzerConfig, + + // deprecated + pub enable_dummy_analyzer: bool, } impl Default for Config { @@ -24,10 +26,12 @@ impl Default for Config { port: 8080, debug_mode: false, ui_level: 1, - enable_dummy_analyzer: false, colorblind_mode: false, key_input_mode: 0, analyzers: AnalyzerConfig::default(), + + // deprecated + enable_dummy_analyzer: false, } } } diff --git a/daemon/src/diag.rs b/daemon/src/diag.rs index 0454a27..f5158cc 100644 --- a/daemon/src/diag.rs +++ b/daemon/src/diag.rs @@ -37,14 +37,13 @@ pub fn run_diag_read_thread( ui_update_sender: Sender, qmdl_store_lock: Arc>, analysis_sender: Sender, - enable_dummy_analyzer: bool, analyzer_config: AnalyzerConfig, ) { task_tracker.spawn(async move { let (initial_qmdl_file, initial_analysis_file) = qmdl_store_lock.write().await.new_entry().await.expect("failed creating QMDL file entry"); let mut maybe_qmdl_writer: Option> = Some(QmdlWriter::new(initial_qmdl_file)); let mut diag_stream = pin!(dev.as_stream().into_stream()); - let mut maybe_analysis_writer = Some(AnalysisWriter::new(initial_analysis_file, enable_dummy_analyzer, &analyzer_config).await + let mut maybe_analysis_writer = Some(AnalysisWriter::new(initial_analysis_file, &analyzer_config).await .expect("failed to create analysis writer")); loop { tokio::select! { @@ -66,7 +65,7 @@ pub fn run_diag_read_thread( analysis_writer.close().await.expect("failed to close analysis writer"); } - maybe_analysis_writer = Some(AnalysisWriter::new(new_analysis_file, enable_dummy_analyzer, &analyzer_config).await + 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 { diff --git a/daemon/src/dummy_analyzer.rs b/daemon/src/dummy_analyzer.rs deleted file mode 100644 index 8b4ddc8..0000000 --- a/daemon/src/dummy_analyzer.rs +++ /dev/null @@ -1,53 +0,0 @@ -use std::borrow::Cow; - -use rayhunter::telcom_parser::lte_rrc::{PCCH_MessageType, PCCH_MessageType_c1, PagingUE_Identity}; - -use rayhunter::analysis::analyzer::{Analyzer, Event, EventType, Severity}; -use rayhunter::analysis::information_element::{InformationElement, LteInformationElement}; - -pub struct TestAnalyzer { - pub count: i32, -} - -impl Analyzer for TestAnalyzer { - fn get_name(&self) -> Cow { - Cow::from("Example Analyzer") - } - - fn get_description(&self) -> Cow { - Cow::from( - "Always returns true, if you are seeing this you are either a developer or you are about to have problems.", - ) - } - - fn analyze_information_element(&mut self, ie: &InformationElement) -> Option { - self.count += 1; - if self.count % 100 == 0 { - return Some(Event { - event_type: EventType::Informational, - message: "multiple of 100 events processed".to_string(), - }); - } - let pcch_msg = match ie { - InformationElement::LTE(lte_ie) => match &**lte_ie { - LteInformationElement::PCCH(pcch_msg) => pcch_msg, - _ => return None, - }, - _ => return None, - }; - let PCCH_MessageType::C1(PCCH_MessageType_c1::Paging(paging)) = &pcch_msg.message else { - return None; - }; - for record in &paging.paging_record_list.as_ref()?.0 { - if let PagingUE_Identity::S_TMSI(_) = record.ue_identity { - return Some(Event { - event_type: EventType::QualitativeWarning { - severity: Severity::Low, - }, - message: "TMSI was provided to cell".to_string(), - }); - } - } - None - } -} diff --git a/daemon/src/main.rs b/daemon/src/main.rs index ac5aa8c..0ef81bb 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -2,7 +2,6 @@ mod analysis; mod config; mod diag; mod display; -mod dummy_analyzer; mod error; mod key_input; mod pcap; @@ -231,7 +230,6 @@ async fn run_with_config( ui_update_tx.clone(), qmdl_store_lock.clone(), analysis_tx.clone(), - config.enable_dummy_analyzer, config.analyzers.clone(), ); info!("Starting UI"); @@ -256,7 +254,6 @@ async fn run_with_config( analysis_rx, qmdl_store_lock.clone(), analysis_status_lock.clone(), - config.enable_dummy_analyzer, config.analyzers.clone(), ); let should_restart_flag = Arc::new(AtomicBool::new(false)); diff --git a/dist/config.toml.example b/dist/config.toml.example index b63a76a..f4d3bb3 100644 --- a/dist/config.toml.example +++ b/dist/config.toml.example @@ -2,7 +2,6 @@ qmdl_store_path = "/data/rayhunter/qmdl" port = 8080 debug_mode = false -enable_dummy_analyzer = false colorblind_mode = false # UI Levels: # @@ -28,4 +27,4 @@ key_input_mode = 0 imsi_requested = true connection_redirect_2g_downgrade = true lte_sib6_and_7_downgrade = true -null_cipher = true +null_cipher = true diff --git a/lib/src/analysis/analyzer.rs b/lib/src/analysis/analyzer.rs index 8f6d45e..a7b31f1 100644 --- a/lib/src/analysis/analyzer.rs +++ b/lib/src/analysis/analyzer.rs @@ -31,6 +31,8 @@ impl Default for AnalyzerConfig { } } +pub const REPORT_VERSION: u32 = 1; + /// Qualitative measure of how severe a Warning event type is. /// The levels should break down like this: /// * Low: if combined with a large number of other Warnings, user should investigate @@ -81,44 +83,44 @@ pub trait Analyzer { /// [Analyzer] updates per message, since it may be run over hundreds or /// thousands of them alongside many other [Analyzers](Analyzer). fn analyze_information_element(&mut self, ie: &InformationElement) -> Option; + + /// Returns a version number for this Analyzer. This should only ever + /// increase in value, and do so whenever substantial changes are made to + /// the Analyzer's heuristic. + fn get_version(&self) -> u32; } #[derive(Serialize, Debug)] pub struct AnalyzerMetadata { pub name: String, pub description: String, + pub version: u32, } #[derive(Serialize, Debug)] pub struct ReportMetadata { pub analyzers: Vec, pub rayhunter: RuntimeMetadata, -} - -#[derive(Serialize, Debug, Clone)] -pub struct PacketAnalysis { - pub timestamp: DateTime, - pub events: Vec>, + // anytime the format of the report changes, bump this by 1 + pub report_version: u32, } #[derive(Serialize, Debug)] pub struct AnalysisRow { - pub timestamp: DateTime, - pub skipped_message_reasons: Vec, - pub analysis: Vec, + pub packet_timestamp: Option>, + pub skipped_message_reason: Option, + pub events: Vec>, } impl AnalysisRow { pub fn is_empty(&self) -> bool { - self.skipped_message_reasons.is_empty() && self.analysis.is_empty() + self.skipped_message_reason.is_none() && !self.contains_warnings() } pub fn contains_warnings(&self) -> bool { - for analysis in &self.analysis { - for event in analysis.events.iter().flatten() { - if matches!(event.event_type, EventType::QualitativeWarning { .. }) { - return true; - } + for event in self.events.iter().flatten() { + if matches!(event.event_type, EventType::QualitativeWarning { .. }) { + return true; } } false @@ -165,17 +167,20 @@ impl Harness { self.analyzers.push(analyzer); } - pub fn analyze_qmdl_messages(&mut self, container: MessagesContainer) -> AnalysisRow { - let mut row = AnalysisRow { - timestamp: chrono::Local::now().fixed_offset(), - skipped_message_reasons: Vec::new(), - analysis: Vec::new(), - }; + pub fn analyze_qmdl_messages(&mut self, container: MessagesContainer) -> Vec { + let mut rows = Vec::new(); for maybe_qmdl_message in container.into_messages() { + rows.push(AnalysisRow { + packet_timestamp: None, + skipped_message_reason: None, + events: Vec::new(), + }); + // unwrap is safe here since we just pushed a value + let row = rows.last_mut().unwrap(); let qmdl_message = match maybe_qmdl_message { Ok(msg) => msg, Err(err) => { - row.skipped_message_reasons.push(format!("{err:?}")); + row.skipped_message_reason = Some(format!("{err:?}")); continue; } }; @@ -183,7 +188,7 @@ impl Harness { let gsmtap_message = match gsmtap_parser::parse(qmdl_message) { Ok(msg) => msg, Err(err) => { - row.skipped_message_reasons.push(format!("{err:?}")); + row.skipped_message_reason = Some(format!("{err:?}")); continue; } }; @@ -191,24 +196,19 @@ impl Harness { let Some((timestamp, gsmtap_msg)) = gsmtap_message else { continue; }; + row.packet_timestamp = Some(timestamp.to_datetime()); let element = match InformationElement::try_from(&gsmtap_msg) { Ok(element) => element, Err(err) => { - row.skipped_message_reasons.push(format!("{err:?}")); + row.skipped_message_reason = Some(format!("{err:?}")); continue; } }; - let analysis_result = self.analyze_information_element(&element); - if analysis_result.iter().any(Option::is_some) { - row.analysis.push(PacketAnalysis { - timestamp: timestamp.to_datetime(), - events: analysis_result, - }); - } + row.events = self.analyze_information_element(&element); } - row + rows } fn analyze_information_element(&mut self, ie: &InformationElement) -> Vec> { @@ -218,28 +218,13 @@ impl Harness { .collect() } - pub fn get_names(&self) -> Vec> { - self.analyzers - .iter() - .map(|analyzer| analyzer.get_name()) - .collect() - } - - pub fn get_descriptions(&self) -> Vec> { - self.analyzers - .iter() - .map(|analyzer| analyzer.get_description()) - .collect() - } - pub fn get_metadata(&self) -> ReportMetadata { - let names = self.get_names(); - let descriptions = self.get_descriptions(); let mut analyzers = Vec::new(); - for (name, description) in names.iter().zip(descriptions.iter()) { + for analyzer in &self.analyzers { analyzers.push(AnalyzerMetadata { - name: name.to_string(), - description: description.to_string(), + name: analyzer.get_name().to_string(), + description: analyzer.get_description().to_string(), + version: analyzer.get_version(), }); } @@ -248,6 +233,7 @@ impl Harness { ReportMetadata { analyzers, rayhunter, + report_version: REPORT_VERSION, } } } diff --git a/lib/src/analysis/connection_redirect_downgrade.rs b/lib/src/analysis/connection_redirect_downgrade.rs index 2042621..0f40fa5 100644 --- a/lib/src/analysis/connection_redirect_downgrade.rs +++ b/lib/src/analysis/connection_redirect_downgrade.rs @@ -22,6 +22,10 @@ impl Analyzer for ConnectionRedirect2GDowngradeAnalyzer { Cow::from("Tests if a cell releases our connection and redirects us to a 2G cell.") } + fn get_version(&self) -> u32 { + 1 + } + fn analyze_information_element(&mut self, ie: &InformationElement) -> Option { unpack!(InformationElement::LTE(lte_ie) = ie); let message = match &**lte_ie { diff --git a/lib/src/analysis/imsi_provided.rs b/lib/src/analysis/imsi_provided.rs index 05723ab..df1516d 100644 --- a/lib/src/analysis/imsi_provided.rs +++ b/lib/src/analysis/imsi_provided.rs @@ -16,6 +16,10 @@ impl Analyzer for ImsiProvidedAnalyzer { Cow::from("Tests whether the UE's IMSI was ever provided to the cell") } + fn get_version(&self) -> u32 { + 1 + } + fn analyze_information_element(&mut self, ie: &InformationElement) -> Option { let pcch_msg = match ie { InformationElement::LTE(lte_ie) => match &**lte_ie { diff --git a/lib/src/analysis/imsi_requested.rs b/lib/src/analysis/imsi_requested.rs index 3861687..24b759e 100644 --- a/lib/src/analysis/imsi_requested.rs +++ b/lib/src/analysis/imsi_requested.rs @@ -34,6 +34,10 @@ impl Analyzer for ImsiRequestedAnalyzer { Cow::from("Tests whether the ME sends an IMSI Identity Request NAS message") } + fn get_version(&self) -> u32 { + 1 + } + fn analyze_information_element(&mut self, ie: &InformationElement) -> Option { self.packet_num += 1; let payload = match ie { diff --git a/lib/src/analysis/null_cipher.rs b/lib/src/analysis/null_cipher.rs index d778cdf..22642fc 100644 --- a/lib/src/analysis/null_cipher.rs +++ b/lib/src/analysis/null_cipher.rs @@ -127,6 +127,10 @@ impl Analyzer for NullCipherAnalyzer { Cow::from("Tests whether the cell suggests using a null cipher (EEA0)") } + fn get_version(&self) -> u32 { + 1 + } + fn analyze_information_element(&mut self, ie: &InformationElement) -> Option { let dcch_msg = match ie { InformationElement::LTE(lte_ie) => match &**lte_ie { diff --git a/lib/src/analysis/priority_2g_downgrade.rs b/lib/src/analysis/priority_2g_downgrade.rs index 2c36525..64a9150 100644 --- a/lib/src/analysis/priority_2g_downgrade.rs +++ b/lib/src/analysis/priority_2g_downgrade.rs @@ -46,6 +46,10 @@ impl Analyzer for LteSib6And7DowngradeAnalyzer { ) } + fn get_version(&self) -> u32 { + 1 + } + fn analyze_information_element( &mut self, ie: &InformationElement,