Compare commits

...

34 Commits

Author SHA1 Message Date
Will Greenberg d4bfaf07dd lib/gsmtap_parser: downgrade unsupported log to debug msg
Previously this was an error message to help underscore when a device
was sending unexpected messages, but now that we're receiving
measurement logs which have no place in GSMTAP frames, it's expected to
skip some log messages.
2026-02-13 13:55:35 -08:00
Will Greenberg c08ccda58b lib/diag: add ML1 Serving/Neighbor cell measurement
This adds support for two new log messages:

* 0xB17F: Serving Cell Measurement and Evaluation
* 0xB180: Neighboring Cells Measurements

With these, we can retrieve realtime RSRQ/RSRP values for the UE's
current cell as well as neighboring ones.
2026-02-13 13:55:35 -08:00
Will Greenberg f33b3baa4f lib/diag.rs refactor
This splits diag.rs, which was growing way too big for my taste, into a
number of submodules. This should help us compartmentalize tests better,
as well as use mod namespaces to shorten our struct/enum names.
2026-02-13 13:55:35 -08:00
Ember d41c4bba3e messages could be larger than 1MB when 100 messages are combined, changed to every 256KB space is checked. 2026-02-12 18:06:42 -08:00
Ember 1d5ed54033 deduplicated code a bit with a wrapper 2026-02-12 18:06:42 -08:00
Ember 24e79aad9d Handled suggestions from PR. 2026-02-12 18:06:42 -08:00
Ember bc7dcc97c6 Removed redundant annotations inlined the defaults 2026-02-12 18:06:42 -08:00
Ember 480b6f8681 Add visual for GUI; fix clippy issue. 2026-02-12 18:06:42 -08:00
Ember 0c624c2bc2 Add disk space monitoring to recording lifecycle 2026-02-12 18:06:42 -08:00
Ember ec6967e2a1 Revert silent IPC error drop, restore expect per review 2026-02-12 09:23:13 -08:00
Ember 912f7dfeaa Disable autocorrect/autocapitalize on CLI args input 2026-02-12 09:23:13 -08:00
Ember 51f1a33e86 Update Cargo.lock for shlex dependency 2026-02-12 09:23:13 -08:00
Ember 87c79bddf7 Input validation fix, along with changing expect so it won't crash 2026-02-12 09:23:13 -08:00
TERR-inss 5efa12f358 fix conditional rendering and conditional text logic, use more-stable faq url 2026-02-12 13:00:36 +01:00
TERR-inss e77fe469da add direct link to FAQ in web UI where rayhunter log analysis returns warnings 2026-02-12 13:00:36 +01:00
Markus Unterwaditzer ed8b1903f8 Re-add API_TARGET envvar 2026-02-10 17:06:20 -08:00
Markus Unterwaditzer 89d1d71ec9 Improve the default of FIRMWARE_DEVEL again, fix brew install gcc command 2026-02-10 17:06:20 -08:00
Markus Unterwaditzer 9be35de90e Address review feedback 2026-02-10 17:06:20 -08:00
Markus Unterwaditzer 8f9be746d3 Trim down documentation and script verbosity 2026-02-10 17:06:20 -08:00
BeigeBox 1347e3107a Support for admin pass 2026-02-10 17:06:20 -08:00
BeigeBox 715efc4b0d Basic scripts to build from source and run install. Nothing fancy. 2026-02-10 17:06:20 -08:00
Markus Unterwaditzer 836ec2169d Revamp installing-from-source docs 2026-02-10 17:06:20 -08:00
Markus Unterwaditzer 9128eefcfc advise against upgrading and add some dramatic styling to this 2026-02-10 17:06:00 -08:00
Markus Unterwaditzer 4f3c7fb7a9 Add warning to moxee page
see #865
2026-02-10 17:06:00 -08:00
BeigeBox 2d3824072d Added check if retcode was 201 when getting the login_response, and giving an error that says it's the pw 2026-02-08 15:08:14 +01:00
Cooper Quintin ed2781a4be appease clippy 2026-02-05 15:41:54 -08:00
Cooper Quintin ffcf683ae5 appease npm 2026-02-05 15:41:54 -08:00
Cooper Quintin 49fd777c83 fix nits and add to config.toml 2026-02-05 15:41:54 -08:00
Cooper Quintin 84a3155a1f remove broken attach request and format 2026-02-05 15:41:54 -08:00
Cooper Quintin 184f4bd7a2 rename to diagnostic and add docs 2026-02-05 15:41:54 -08:00
Cooper Quintin f7759721e3 rebase against main 2026-02-05 15:41:54 -08:00
Cooper Quintin 744d0772c2 add message type 2026-02-05 15:41:54 -08:00
Cooper Quintin 2cd49b3757 show false postive attach reject message 2026-02-05 15:41:54 -08:00
Cooper Quintin e44230c043 imsi revealing message diagnostic heuristic 2026-02-05 15:41:54 -08:00
41 changed files with 1405 additions and 422 deletions
+1
View File
@@ -7,3 +7,4 @@
dist/config.toml.in eol=lf dist/config.toml.in eol=lf
dist/scripts/misc-daemon eol=lf dist/scripts/misc-daemon eol=lf
dist/scripts/rayhunter_daemon eol=lf dist/scripts/rayhunter_daemon eol=lf
scripts/*.sh eol=lf
Generated
+2 -1
View File
@@ -2753,12 +2753,13 @@ dependencies = [
[[package]] [[package]]
name = "installer-gui" name = "installer-gui"
version = "0.10.0" version = "0.10.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"installer", "installer",
"serde", "serde",
"serde_json", "serde_json",
"shlex",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-opener", "tauri-plugin-opener",
+1
View File
@@ -6,3 +6,4 @@ title = "Rayhunter - An IMSI Catcher Catcher"
[output.html] [output.html]
edit-url-template = "https://github.com/efforg/rayhunter/edit/main/{path}" edit-url-template = "https://github.com/efforg/rayhunter/edit/main/{path}"
additional-css = ["doc/custom.css"]
+4
View File
@@ -20,6 +20,8 @@ pub struct Config {
pub ntfy_url: Option<String>, pub ntfy_url: Option<String>,
pub enabled_notifications: Vec<NotificationType>, pub enabled_notifications: Vec<NotificationType>,
pub analyzers: AnalyzerConfig, pub analyzers: AnalyzerConfig,
pub min_space_to_start_recording_mb: u64,
pub min_space_to_continue_recording_mb: u64,
} }
impl Default for Config { impl Default for Config {
@@ -35,6 +37,8 @@ impl Default for Config {
analyzers: AnalyzerConfig::default(), analyzers: AnalyzerConfig::default(),
ntfy_url: None, ntfy_url: None,
enabled_notifications: vec![NotificationType::Warning, NotificationType::LowBattery], enabled_notifications: vec![NotificationType::Warning, NotificationType::LowBattery],
min_space_to_start_recording_mb: 1,
min_space_to_continue_recording_mb: 1,
} }
} }
} }
+181 -32
View File
@@ -27,10 +27,15 @@ use crate::display;
use crate::notifications::{Notification, NotificationType}; use crate::notifications::{Notification, NotificationType};
use crate::qmdl_store::{RecordingStore, RecordingStoreError}; use crate::qmdl_store::{RecordingStore, RecordingStoreError};
use crate::server::ServerState; use crate::server::ServerState;
use crate::stats::DiskStats;
const DISK_CHECK_BYTES_INTERVAL: usize = 256 * 1024;
pub enum DiagDeviceCtrlMessage { pub enum DiagDeviceCtrlMessage {
StopRecording, StopRecording,
StartRecording, StartRecording {
response_tx: Option<oneshot::Sender<Result<(), String>>>,
},
DeleteEntry { DeleteEntry {
name: String, name: String,
response_tx: oneshot::Sender<Result<(), RecordingStoreError>>, response_tx: oneshot::Sender<Result<(), RecordingStoreError>>,
@@ -46,8 +51,12 @@ pub struct DiagTask {
analysis_sender: Sender<AnalysisCtrlMessage>, analysis_sender: Sender<AnalysisCtrlMessage>,
analyzer_config: AnalyzerConfig, analyzer_config: AnalyzerConfig,
notification_channel: tokio::sync::mpsc::Sender<Notification>, notification_channel: tokio::sync::mpsc::Sender<Notification>,
min_space_to_start_mb: u64,
min_space_to_continue_mb: u64,
state: DiagState, state: DiagState,
max_type_seen: EventType, max_type_seen: EventType,
bytes_since_space_check: usize,
low_space_warned: bool,
} }
enum DiagState { enum DiagState {
@@ -58,36 +67,99 @@ enum DiagState {
Stopped, Stopped,
} }
enum DiskSpaceCheck {
Ok(u64),
Warning(u64),
Critical(u64),
Failed,
}
fn check_disk_space(path: &std::path::Path, warning_mb: u64, critical_mb: u64) -> DiskSpaceCheck {
match DiskStats::new(path.to_str().unwrap()) {
Ok(stats) => {
let available_mb = stats.available_bytes.unwrap_or(0) / 1024 / 1024;
if available_mb < critical_mb {
DiskSpaceCheck::Critical(available_mb)
} else if available_mb < warning_mb {
DiskSpaceCheck::Warning(available_mb)
} else {
DiskSpaceCheck::Ok(available_mb)
}
}
Err(e) => {
warn!("Failed to check disk space: {e}");
DiskSpaceCheck::Failed
}
}
}
impl DiagTask { impl DiagTask {
fn new( fn new(
ui_update_sender: Sender<display::DisplayState>, ui_update_sender: Sender<display::DisplayState>,
analysis_sender: Sender<AnalysisCtrlMessage>, analysis_sender: Sender<AnalysisCtrlMessage>,
analyzer_config: AnalyzerConfig, analyzer_config: AnalyzerConfig,
notification_channel: tokio::sync::mpsc::Sender<Notification>, notification_channel: tokio::sync::mpsc::Sender<Notification>,
min_space_to_start_mb: u64,
min_space_to_continue_mb: u64,
) -> Self { ) -> Self {
Self { Self {
ui_update_sender, ui_update_sender,
analysis_sender, analysis_sender,
analyzer_config, analyzer_config,
notification_channel, notification_channel,
min_space_to_start_mb,
min_space_to_continue_mb,
state: DiagState::Stopped, state: DiagState::Stopped,
max_type_seen: EventType::Informational, max_type_seen: EventType::Informational,
bytes_since_space_check: 0,
low_space_warned: false,
} }
} }
/// Start recording /// Start recording, returning an error if disk space is too low.
async fn start(&mut self, qmdl_store: &mut RecordingStore) { async fn start(&mut self, qmdl_store: &mut RecordingStore) -> Result<(), String> {
self.max_type_seen = EventType::Informational; self.max_type_seen = EventType::Informational;
let (qmdl_file, analysis_file) = qmdl_store self.bytes_since_space_check = 0;
.new_entry() self.low_space_warned = false;
.await
.expect("failed creating QMDL file entry"); match check_disk_space(
&qmdl_store.path,
self.min_space_to_start_mb,
self.min_space_to_continue_mb,
) {
DiskSpaceCheck::Critical(mb) | DiskSpaceCheck::Warning(mb) => {
let msg = format!(
"Insufficient disk space: {}MB available, {}MB required",
mb, self.min_space_to_start_mb
);
error!("{msg}");
return Err(msg);
}
DiskSpaceCheck::Ok(mb) => {
info!("Starting recording with {}MB disk space available", mb);
}
DiskSpaceCheck::Failed => {}
}
let (qmdl_file, analysis_file) = match qmdl_store.new_entry().await {
Ok(files) => files,
Err(e) => {
let msg = format!("failed creating QMDL file entry: {e}");
error!("{msg}");
return Err(msg);
}
};
self.stop_current_recording().await; self.stop_current_recording().await;
let qmdl_writer = QmdlWriter::new(qmdl_file); let qmdl_writer = QmdlWriter::new(qmdl_file);
let analysis_writer = AnalysisWriter::new(analysis_file, &self.analyzer_config) let analysis_writer = match AnalysisWriter::new(analysis_file, &self.analyzer_config).await
.await {
.map(Box::new) Ok(writer) => Box::new(writer),
.expect("failed to write to analysis file"); Err(e) => {
let msg = format!("failed to create analysis writer: {e}");
error!("{msg}");
return Err(msg);
}
};
self.state = DiagState::Recording { self.state = DiagState::Recording {
qmdl_writer, qmdl_writer,
analysis_writer, analysis_writer,
@@ -99,11 +171,17 @@ impl DiagTask {
{ {
warn!("couldn't send ui update message: {e}"); warn!("couldn't send ui update message: {e}");
} }
Ok(())
} }
/// Stop recording /// Stop recording, optionally annotating the entry with a reason.
async fn stop(&mut self, qmdl_store: &mut RecordingStore) { async fn stop(&mut self, qmdl_store: &mut RecordingStore, reason: Option<String>) {
self.stop_current_recording().await; self.stop_current_recording().await;
if let Some(reason) = reason
&& let Err(e) = qmdl_store.set_current_stop_reason(reason).await
{
warn!("couldn't set stop reason: {e}");
}
if let Some((_, entry)) = qmdl_store.get_current_entry() if let Some((_, entry)) = qmdl_store.get_current_entry()
&& let Err(e) = self && let Err(e) = self
.analysis_sender .analysis_sender
@@ -132,7 +210,7 @@ impl DiagTask {
name: &str, name: &str,
) -> Result<(), RecordingStoreError> { ) -> Result<(), RecordingStoreError> {
if qmdl_store.is_current_entry(name) { if qmdl_store.is_current_entry(name) {
self.stop(qmdl_store).await; self.stop(qmdl_store, None).await;
} }
let res = qmdl_store.delete_entry(name).await; let res = qmdl_store.delete_entry(name).await;
if let Err(e) = res.as_ref() { if let Err(e) = res.as_ref() {
@@ -145,7 +223,7 @@ impl DiagTask {
&mut self, &mut self,
qmdl_store: &mut RecordingStore, qmdl_store: &mut RecordingStore,
) -> Result<(), RecordingStoreError> { ) -> Result<(), RecordingStoreError> {
self.stop(qmdl_store).await; self.stop(qmdl_store, None).await;
let res = qmdl_store.delete_all_entries().await; let res = qmdl_store.delete_all_entries().await;
if let Err(e) = res.as_ref() { if let Err(e) = res.as_ref() {
error!("Error deleting QMDL entries {e}"); error!("Error deleting QMDL entries {e}");
@@ -183,10 +261,56 @@ impl DiagTask {
analysis_writer, analysis_writer,
} = &mut self.state } = &mut self.state
{ {
qmdl_writer if self.bytes_since_space_check >= DISK_CHECK_BYTES_INTERVAL {
.write_container(&container) self.bytes_since_space_check = 0;
.await match check_disk_space(
.expect("failed to write to QMDL writer"); &qmdl_store.path,
self.min_space_to_start_mb,
self.min_space_to_continue_mb,
) {
DiskSpaceCheck::Critical(mb) => {
let reason = format!(
"Disk space critically low ({}MB free), recording stopped automatically",
mb
);
error!("{reason}");
self.notification_channel
.send(Notification::new(
NotificationType::Warning,
reason.clone(),
None,
))
.await
.ok();
self.stop(qmdl_store, Some(reason)).await;
return;
}
DiskSpaceCheck::Warning(mb) => {
if !self.low_space_warned {
self.low_space_warned = true;
warn!("Disk space low: {}MB remaining", mb);
self.notification_channel
.send(Notification::new(
NotificationType::Warning,
format!("Disk space low: {}MB free", mb),
Some(Duration::from_secs(30)),
))
.await
.ok();
}
}
_ => {}
}
}
if let Err(e) = qmdl_writer.write_container(&container).await {
let reason = format!("failed to write to QMDL (disk full?): {e}");
error!("{reason}");
self.stop(qmdl_store, Some(reason)).await;
return;
}
debug!( debug!(
"total QMDL bytes written: {}, updating manifest...", "total QMDL bytes written: {}, updating manifest...",
qmdl_writer.total_written qmdl_writer.total_written
@@ -194,15 +318,25 @@ impl DiagTask {
let index = qmdl_store let index = qmdl_store
.current_entry .current_entry
.expect("DiagDevice had qmdl_writer, but QmdlStore didn't have current entry???"); .expect("DiagDevice had qmdl_writer, but QmdlStore didn't have current entry???");
qmdl_store if let Err(e) = qmdl_store
.update_entry_qmdl_size(index, qmdl_writer.total_written) .update_entry_qmdl_size(index, qmdl_writer.total_written)
.await .await
.expect("failed to update qmdl file size"); {
let reason = format!("failed to update manifest (disk full?): {e}");
error!("{reason}");
self.stop(qmdl_store, Some(reason)).await;
return;
}
debug!("done!"); debug!("done!");
let max_type = analysis_writer let container_bytes: usize = container.messages.iter().map(|m| m.data.len()).sum();
.analyze(container) self.bytes_since_space_check += container_bytes;
.await let max_type = match analysis_writer.analyze(container).await {
.expect("failed to analyze container"); Ok(t) => t,
Err(e) => {
warn!("failed to analyze container: {e}");
EventType::Informational
}
};
if max_type > EventType::Informational { if max_type > EventType::Informational {
info!("a heuristic triggered on this run!"); info!("a heuristic triggered on this run!");
@@ -244,25 +378,30 @@ pub fn run_diag_read_thread(
analysis_sender: Sender<AnalysisCtrlMessage>, analysis_sender: Sender<AnalysisCtrlMessage>,
analyzer_config: AnalyzerConfig, analyzer_config: AnalyzerConfig,
notification_channel: tokio::sync::mpsc::Sender<Notification>, notification_channel: tokio::sync::mpsc::Sender<Notification>,
min_space_to_start_mb: u64,
min_space_to_continue_mb: u64,
) { ) {
task_tracker.spawn(async move { task_tracker.spawn(async move {
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 diag_task = DiagTask::new(ui_update_sender, analysis_sender, analyzer_config, notification_channel, min_space_to_start_mb, min_space_to_continue_mb);
qmdl_file_tx qmdl_file_tx
.send(DiagDeviceCtrlMessage::StartRecording) .send(DiagDeviceCtrlMessage::StartRecording { response_tx: None })
.await .await
.unwrap(); .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 { response_tx }) => {
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 result = diag_task.start(qmdl_store.deref_mut()).await;
if let Some(tx) = response_tx {
tx.send(result).ok();
}
}, },
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; diag_task.stop(qmdl_store.deref_mut(), None).await;
}, },
// 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
@@ -312,9 +451,12 @@ pub async fn start_recording(
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::StartRecording) .send(DiagDeviceCtrlMessage::StartRecording {
response_tx: Some(response_tx),
})
.await .await
.map_err(|e| { .map_err(|e| {
( (
@@ -323,7 +465,14 @@ pub async fn start_recording(
) )
})?; })?;
Ok((StatusCode::ACCEPTED, "ok".to_string())) match response_rx.await {
Ok(Ok(())) => Ok((StatusCode::ACCEPTED, "ok".to_string())),
Ok(Err(reason)) => Err((StatusCode::INSUFFICIENT_STORAGE, reason)),
Err(e) => Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("failed to receive start recording response: {e}"),
)),
}
} }
/// Stop recording API for web thread /// Stop recording API for web thread
+3 -2
View File
@@ -81,8 +81,9 @@ pub fn run_key_input_thread(
{ {
error!("Failed to send StopRecording: {e}"); error!("Failed to send StopRecording: {e}");
} }
if let Err(e) = if let Err(e) = diag_tx
diag_tx.send(DiagDeviceCtrlMessage::StartRecording).await .send(DiagDeviceCtrlMessage::StartRecording { response_tx: None })
.await
{ {
error!("Failed to send StartRecording: {e}"); error!("Failed to send StartRecording: {e}");
} }
+2
View File
@@ -234,6 +234,8 @@ async fn run_with_config(
analysis_tx.clone(), analysis_tx.clone(),
config.analyzers.clone(), config.analyzers.clone(),
notification_service.new_handler(), notification_service.new_handler(),
config.min_space_to_start_recording_mb,
config.min_space_to_continue_recording_mb,
); );
info!("Starting UI"); info!("Starting UI");
+15
View File
@@ -54,6 +54,8 @@ pub struct ManifestEntry {
pub rayhunter_version: Option<String>, pub rayhunter_version: Option<String>,
pub system_os: Option<String>, pub system_os: Option<String>,
pub arch: Option<String>, pub arch: Option<String>,
#[serde(default)]
pub stop_reason: Option<String>,
} }
impl ManifestEntry { impl ManifestEntry {
@@ -68,6 +70,7 @@ impl ManifestEntry {
rayhunter_version: Some(metadata.rayhunter_version), rayhunter_version: Some(metadata.rayhunter_version),
system_os: Some(metadata.system_os), system_os: Some(metadata.system_os),
arch: Some(metadata.arch), arch: Some(metadata.arch),
stop_reason: None,
} }
} }
@@ -197,6 +200,7 @@ impl RecordingStore {
rayhunter_version: None, rayhunter_version: None,
system_os: None, system_os: None,
arch: None, arch: None,
stop_reason: None,
}); });
} }
@@ -342,6 +346,17 @@ impl RecordingStore {
Some((entry_index, &self.manifest.entries[entry_index])) Some((entry_index, &self.manifest.entries[entry_index]))
} }
pub async fn set_current_stop_reason(
&mut self,
reason: String,
) -> Result<(), RecordingStoreError> {
if let Some(idx) = self.current_entry {
self.manifest.entries[idx].stop_reason = Some(reason);
self.write_manifest().await?;
}
Ok(())
}
pub fn is_current_entry(&self, name: &str) -> bool { pub fn is_current_entry(&self, name: &str) -> bool {
match self.current_entry { match self.current_entry {
Some(idx) => match self.manifest.entries.get(idx) { Some(idx) => match self.manifest.entries.get(idx) {
+32 -22
View File
@@ -1,3 +1,4 @@
use std::ffi::CString;
use std::sync::Arc; use std::sync::Arc;
use crate::battery::get_battery_status; use crate::battery::get_battery_status;
@@ -25,7 +26,7 @@ pub struct SystemStats {
impl SystemStats { impl SystemStats {
pub async fn new(qmdl_path: &str, device: &Device) -> Result<Self, String> { pub async fn new(qmdl_path: &str, device: &Device) -> Result<Self, String> {
Ok(Self { Ok(Self {
disk_stats: DiskStats::new(qmdl_path, device).await?, disk_stats: DiskStats::new(qmdl_path)?,
memory_stats: MemoryStats::new(device).await?, memory_stats: MemoryStats::new(device).await?,
runtime_metadata: RuntimeMetadata::new(), runtime_metadata: RuntimeMetadata::new(),
battery_status: match get_battery_status(device).await { battery_status: match get_battery_status(device).await {
@@ -48,33 +49,42 @@ pub struct DiskStats {
available_size: String, available_size: String,
used_percent: String, used_percent: String,
mounted_on: String, mounted_on: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub available_bytes: Option<u64>,
} }
impl DiskStats { impl DiskStats {
// runs "df -h <qmdl_path>" to get storage statistics for the partition containing #[allow(clippy::unnecessary_cast)] // c_ulong is u32 on ARM, u64 on macOS
// the QMDL file. pub fn new(qmdl_path: &str) -> Result<Self, String> {
pub async fn new(qmdl_path: &str, device: &Device) -> Result<Self, String> { let c_path =
// Uz801 needs to be told to use the busybox df specifically CString::new(qmdl_path).map_err(|e| format!("invalid path {qmdl_path}: {e}"))?;
let mut df_cmd: Command; let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
if matches!(device, Device::Uz801) { if unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) } != 0 {
df_cmd = Command::new("busybox"); return Err(format!(
df_cmd.arg("df"); "statvfs({qmdl_path}) failed: {}",
} else { std::io::Error::last_os_error()
df_cmd = Command::new("df"); ));
} }
df_cmd.arg("-h");
df_cmd.arg(qmdl_path);
let stdout = get_cmd_output(df_cmd).await?;
// Handle standard df -h format let block_size = stat.f_frsize as u64;
let mut parts = stdout.split_whitespace().skip(7); let total_kb = (stat.f_blocks as u64 * block_size / 1024) as usize;
let free_kb = (stat.f_bfree as u64 * block_size / 1024) as usize;
let available_kb = (stat.f_bavail as u64 * block_size / 1024) as usize;
let used_kb = total_kb.saturating_sub(free_kb);
let used_percent = if stat.f_blocks > 0 {
format!("{}%", (stat.f_blocks - stat.f_bfree) * 100 / stat.f_blocks)
} else {
"0%".to_string()
};
Ok(Self { Ok(Self {
partition: parts.next().ok_or("error parsing df output")?.to_string(), partition: qmdl_path.to_string(),
total_size: parts.next().ok_or("error parsing df output")?.to_string(), total_size: humanize_kb(total_kb),
used_size: parts.next().ok_or("error parsing df output")?.to_string(), used_size: humanize_kb(used_kb),
available_size: parts.next().ok_or("error parsing df output")?.to_string(), available_size: humanize_kb(available_kb),
used_percent: parts.next().ok_or("error parsing df output")?.to_string(), used_percent,
mounted_on: parts.next().ok_or("error parsing df output")?.to_string(), mounted_on: qmdl_path.to_string(),
available_bytes: Some(stat.f_bavail as u64 * block_size),
}) })
} }
} }
@@ -22,10 +22,26 @@
<p>Error getting analysis report: {entry.analysis_report}</p> <p>Error getting analysis report: {entry.analysis_report}</p>
{:else} {:else}
{@const metadata: ReportMetadata = entry.analysis_report.metadata} {@const metadata: ReportMetadata = entry.analysis_report.metadata}
{@const numWarnings: number = entry.get_num_warnings() || 0}
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
{#if !current} {#if !!numWarnings || !current}
<div class="flex flex-row justify-end items-center"> <div class="flex flex-row justify-between items-center">
<ReAnalyzeButton {entry} {manager} /> {#if !!numWarnings}
<div
class="text-red-700 border-red-500 border rounded-lg text-blue-600 px-2 py-1 mr-12"
>
Your Rayhunter device raised {`${numWarnings}`} warning{`${
numWarnings > 1 ? 's' : ''
}`}!
<a
href="https://efforg.github.io/rayhunter/faq.html#red"
class="text-blue-600 underline">Read the FAQ</a
> to learn what you can do about it
</div>
{/if}
{#if !current}
<ReAnalyzeButton {entry} {manager} />
{/if}
</div> </div>
{/if} {/if}
{#if entry.analysis_report.rows.length > 0} {#if entry.analysis_report.rows.length > 0}
@@ -241,6 +241,48 @@
</div> </div>
</div> </div>
<div class="border-t pt-4 mt-6 space-y-3">
<h3 class="text-lg font-semibold text-gray-800 mb-4">Storage Management</h3>
<div>
<label
for="min_space_to_start_recording_mb"
class="block text-sm font-medium text-gray-700 mb-1"
>
Minimum Space to Start Recording (MB)
</label>
<input
id="min_space_to_start_recording_mb"
type="number"
min="1"
bind:value={config.min_space_to_start_recording_mb}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
/>
<p class="text-xs text-gray-500 mt-1">
Recording will not start if less than this amount of disk space is free
</p>
</div>
<div>
<label
for="min_space_to_continue_recording_mb"
class="block text-sm font-medium text-gray-700 mb-1"
>
Minimum Space to Continue Recording (MB)
</label>
<input
id="min_space_to_continue_recording_mb"
type="number"
min="1"
bind:value={config.min_space_to_continue_recording_mb}
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-rayhunter-blue"
/>
<p class="text-xs text-gray-500 mt-1">
Recording will stop automatically if disk space drops below this level
</p>
</div>
</div>
<div class="border-t pt-4 mt-6"> <div class="border-t pt-4 mt-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4"> <h3 class="text-lg font-semibold text-gray-800 mb-4">
Analyzer Heuristic Settings Analyzer Heuristic Settings
@@ -335,6 +377,20 @@
Test Heuristic (noisy!) Test Heuristic (noisy!)
</label> </label>
</div> </div>
<div class="flex items-center">
<input
id="diagnostic_analyzer"
type="checkbox"
bind:checked={config.analyzers.diagnostic_analyzer}
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
/>
<label
for="diagnostic_analyzer"
class="ml-2 block text-sm text-gray-700"
>
Diagnostic Analyzer
</label>
</div>
</div> </div>
</div> </div>
@@ -81,6 +81,11 @@
'N/A'}</span 'N/A'}</span
> >
</div> </div>
{#if entry.stop_reason}
<div class="bg-yellow-50 border border-yellow-300 rounded p-2 text-yellow-800 text-sm">
{entry.stop_reason}
</div>
{/if}
<div class="flex flex-row justify-between lg:justify-end gap-1 mt-2 overflow-x-auto"> <div class="flex flex-row justify-between lg:justify-end gap-1 mt-2 overflow-x-auto">
<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 />
+5
View File
@@ -11,6 +11,7 @@ interface JsonManifestEntry {
start_time: string; start_time: string;
last_message_time: string; last_message_time: string;
qmdl_size_bytes: number; qmdl_size_bytes: number;
stop_reason: string | null;
} }
export class Manifest { export class Manifest {
@@ -57,6 +58,7 @@ export class ManifestEntry {
public analysis_size_bytes = $state(0); public analysis_size_bytes = $state(0);
public analysis_status: AnalysisStatus | undefined = $state(undefined); public analysis_status: AnalysisStatus | undefined = $state(undefined);
public analysis_report: AnalysisReport | string | undefined = $state(undefined); public analysis_report: AnalysisReport | string | undefined = $state(undefined);
public stop_reason: string | undefined = $state(undefined);
constructor(json: JsonManifestEntry) { constructor(json: JsonManifestEntry) {
this.name = json.name; this.name = json.name;
@@ -65,6 +67,9 @@ export class ManifestEntry {
if (json.last_message_time) { if (json.last_message_time) {
this.last_message_time = new Date(json.last_message_time); this.last_message_time = new Date(json.last_message_time);
} }
if (json.stop_reason) {
this.stop_reason = json.stop_reason;
}
} }
get_readable_qmdl_size(): string { get_readable_qmdl_size(): string {
+1
View File
@@ -18,6 +18,7 @@ export interface DiskStats {
available_size: string; available_size: string;
used_percent: string; used_percent: string;
mounted_on: string; mounted_on: string;
available_bytes?: number;
} }
export interface MemoryStats { export interface MemoryStats {
+3
View File
@@ -10,6 +10,7 @@ export interface AnalyzerConfig {
nas_null_cipher: boolean; nas_null_cipher: boolean;
incomplete_sib: boolean; incomplete_sib: boolean;
test_analyzer: boolean; test_analyzer: boolean;
diagnostic_analyzer: boolean;
} }
export enum enabled_notifications { export enum enabled_notifications {
@@ -24,6 +25,8 @@ export interface Config {
ntfy_url: string; ntfy_url: string;
enabled_notifications: enabled_notifications[]; enabled_notifications: enabled_notifications[];
analyzers: AnalyzerConfig; analyzers: AnalyzerConfig;
min_space_to_start_recording_mb: number;
min_space_to_continue_recording_mb: number;
} }
export async function req(method: string, url: string, json_body?: unknown): Promise<string> { export async function req(method: string, url: string, json_body?: unknown): Promise<string> {
+1 -1
View File
@@ -5,7 +5,7 @@ export default defineConfig({
server: { server: {
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:8080', target: process.env.API_TARGET || 'http://localhost:8080',
changeOrigin: true, changeOrigin: true,
secure: false, secure: false,
configure: (proxy, _options) => { configure: (proxy, _options) => {
+7
View File
@@ -28,6 +28,12 @@ ntfy_url = ""
# What notification types to enable. Does nothing if the above ntfy_url is not set. # What notification types to enable. Does nothing if the above ntfy_url is not set.
enabled_notifications = ["Warning", "LowBattery"] enabled_notifications = ["Warning", "LowBattery"]
# Disk Space Management
# Minimum free space (MB) required to start recording
min_space_to_start_recording_mb = 1
# Minimum free space (MB) to continue recording (stops if below this)
min_space_to_continue_recording_mb = 1
# 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
@@ -39,3 +45,4 @@ null_cipher = true
nas_null_cipher = true nas_null_cipher = true
incomplete_sib = true incomplete_sib = true
test_analyzer = false test_analyzer = false
diagnostic_analyzer = true
+6
View File
@@ -0,0 +1,6 @@
.warning-box {
padding: 0.75em 1em;
border-left: 4px solid #e33;
border-radius: 4px;
background-color: color-mix(in srgb, currentColor 10%, transparent);
}
+3
View File
@@ -73,6 +73,9 @@ This analyzer tests whether the SIB1 message contains a complete SIB chain (SIB3
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. 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.
### Diagnostic Information
This analyzer displays some diagnostic information about when your device connects and disconnects from certain towers. It is helpful for analysis of suspicious PCAPs. The informational warnings in here can safely be ignored until there is a low, medium, or high severity warning.
### Test Analyzer ### 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 noisy so we do not recommend leaving it on but if this alerts it means your Rayhunter device is working! 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 noisy so we do not recommend leaving it on but if this alerts it means your Rayhunter device is working!
+65 -50
View File
@@ -1,63 +1,78 @@
# Installing from source # Installing from source
Building Rayhunter from source, either for development or because the install script doesn't work on your system, involves a number of external dependencies. Unless you need to do this, we recommend you use our [compiled builds](https://github.com/EFForg/rayhunter/releases). Building Rayhunter from source, either for development or otherwise, involves a
number of external dependencies. Unless you need to do this, we recommend you
use our [compiled builds](https://github.com/EFForg/rayhunter/releases).
* Install [nodejs/npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm), which is required to build Rayhunter's web UI At a high level, we have:
* Make sure to build the site with `pushd daemon/web && npm install && npm run build && popd` before building Rayhunter. If you're working directly on the frontend, `npm run dev` will allow you to test a local frontend with hot-reloading (use `http://localhost:5173` instead of `http://localhost:8080`).
* Install ADB on your computer using the instructions above, and make sure it's in your terminal's PATH
* You can verify if ADB is in your PATH by running `which adb` in a terminal. If it prints the filepath to where ADB is installed, you're set! Otherwise, try following one of these guides:
* [linux](https://askubuntu.com/questions/652936/adding-android-sdk-platform-tools-to-path-downloaded-from-umake)
* [macOS](https://www.repeato.app/setting-up-adb-on-macos-a-step-by-step-guide/)
* [Windows](https://medium.com/@yadav-ajay/a-step-by-step-guide-to-setting-up-adb-path-on-windows-0b833faebf18)
* Install `curl` on your computer to run the install scripts. It is not needed to build binaries.
### Install Rust targets * A JS frontend written in SvelteKit (`./daemon/web/`)
* A Rust binary `rayhunter-daemon` (`./daemon/`) that runs on the device, and bundles the frontend.
* A Rust binary `installer` (`./installer`) that runs on the computer and bundles `rayhunter-daemon`.
[Install Rust the usual way](https://www.rust-lang.org/tools/install). Then, It's recommended to work either on Mac/Linux, or WSL on Windows.
- install the cross-compilation target for the device Rayhunter will run on: ## Building frontend and backend
```sh
rustup target add armv7-unknown-linux-musleabihf
```
- install the statically compiled target for your host machine to build the binary installer `serial`. First, install dependencies:
```sh
# check which toolchain you have installed by default with
rustup show
# now install the correct variant for your host platform, one of:
rustup target add aarch64-unknown-linux-musl
rustup target add armv7-unknown-linux-musleabi
rustup target add x86_64-unknown-linux-musl
rustup target add aarch64-apple-darwin
rustup target add x86_64-apple-darwin
rustup target add x86_64-pc-windows-gnu
```
Now you can root your device and install Rayhunter by running: - [Rust](https://www.rust-lang.org/tools/install)
- [Node.js/npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm)
- C compiler tools (`apt install build-essential` on Linux, `xcode-select --install` on Mac)
Then you can build everything with:
```sh ```sh
# Build the daemon binary for local development (rustcrypto TLS backend, fast compilation) ./scripts/build-dev.sh
# WARNING: The rustcrypto library, though not known to be insecure, is less well ./scripts/install-dev.sh orbic # replace 'orbic' with your device type
# tested than its counterpart and could potentially have severe issues in
# its cryptographic implementation. We therefore recommend using ring-tls in
# production builds (see below)
cargo build-daemon-firmware-devel
# To build it exactly like in CI (more mature ring TLS backend, slower compilation)
# CC_armv7_unknown_linux_musleabihf=arm-linux-gnueabihf-gcc cargo build-daemon-firmware
# Build rootshell
cargo build-rootshell-firmware-devel
# Replace 'orbic' with your device type if different.
# A list of possible values can be found with 'cargo run --bin installer help'.
FIRMWARE_PROFILE=firmware-devel cargo run -p installer --bin installer orbic
``` ```
### If you're on Windows or can't run the install scripts ## Hot-reloading the frontend
* Root your device on Windows using the instructions here: <https://xdaforums.com/t/resetting-verizon-orbic-speed-rc400l-firmware-flash-kajeet.4334899/#post-87855183> If you are working on the frontend, you normally have to repeat all of the above steps everytime to see a change.
* Build the web UI using `cd daemon/web && npm install && npm run build`
* Push the scripts in `scripts/` to `/etc/init.d` on device and make a directory called `/data/rayhunter` using `adb shell` (and sshell for your root shell if you followed the steps above) You can instead run the frontend separately on your PC while the Rust parts
* You also need to copy `config.toml.in` to `/data/rayhunter/config.toml`. Uncomment the `device` line and set the value to your device type if necessary. continue running on your target device:
* Then run `./make.sh`, which will build the binary, push it over adb, and restart the device. Once it's restarted, Rayhunter should be running!
```sh
cd daemon/web
# Assumes rayhunter-daemon is listening on localhost:8080
npm run dev
# Use a custom target IP:port where the backend runs
API_TARGET=http://192.168.1.1:8080 npm run dev
```
The UI will listen on `localhost:5173` and instantly show any frontend changes
you make. Backend changes require building everything from the top (daemon and installer).
## Installer utils, getting a shell
Check `./scripts/install-dev.sh util --help`
for useful utilities for transferring files, opening shells. The exact tools
available wildly depend on the device you're working on, and they are
usually documented the relevant device's page under [Supported
Devices](./supported-devices.md).
A lot of devices run a trimmed down version of Android and have ADB (Android
Debug Bridge) support. The USB-based installers (`orbic-usb`, `pinephone`,
`uz801`) use ADB to perform the installation.
You might want to install and use actual ADB to connect to the device, push
files and generally poke around. The installer contains some tools to enable ADB:
```sh
adb kill-server
# Enables ADB on either of these devices
./scripts/install-dev.sh util tmobile-start-adb
./scripts/install-dev.sh orbic-usb
adb shell
```
Note though that we can't assist with any issues setting ADB up, _especially
not_ on Windows. There have been too many driver issues to make this the
"golden path" for most users or contributors. There have been instances where
people managed to brick their orbic devices using ADB on Windows.
+10
View File
@@ -5,6 +5,16 @@ Supported in Rayhunter since version 0.6.0.
The Moxee Hotspot is a device very similar to the Orbic RC400L. It seems to be The Moxee Hotspot is a device very similar to the Orbic RC400L. It seems to be
primarily for the US market. primarily for the US market.
<div class="warning-box">
**WARNING: These devices are known to become completely bricked by installing Rayhunter.**
Do not buy this device nor try to install _nor upgrade_ Rayhunter on it.
We're still trying to figure out what's wrong in [this discussion](https://github.com/EFForg/rayhunter/issues/865).
</div>
- [KonnectONE product page](https://www.konnectone.com/specs-hotspot) - [KonnectONE product page](https://www.konnectone.com/specs-hotspot)
- [Moxee product page](https://www.moxee.com/hotspot) - [Moxee product page](https://www.moxee.com/hotspot)
+1
View File
@@ -21,4 +21,5 @@ tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
anyhow = "1.0.100" anyhow = "1.0.100"
shlex = "1"
installer = { path = "../../installer" } installer = { path = "../../installer" }
+3 -2
View File
@@ -1,10 +1,11 @@
use anyhow::Context;
use tauri::Emitter; use tauri::Emitter;
async fn run_installer(app_handle: tauri::AppHandle, args: String) -> anyhow::Result<()> { async fn run_installer(app_handle: tauri::AppHandle, args: String) -> anyhow::Result<()> {
let args_vec = shlex::split(&args).context("Failed to parse arguments: unclosed quote")?;
tauri::async_runtime::spawn_blocking(move || { tauri::async_runtime::spawn_blocking(move || {
installer::run_with_callback( installer::run_with_callback(
// TODO: we should split using something similar to shlex in python args_vec.iter().map(|s| s.as_str()),
args.split_whitespace(),
Some(Box::new(move |output| { Some(Box::new(move |output| {
app_handle app_handle
.emit("installer-output", output) .emit("installer-output", output)
+3
View File
@@ -81,6 +81,9 @@
<input <input
class="mr-1 px-5 py-2 rounded-lg shadow-md" class="mr-1 px-5 py-2 rounded-lg shadow-md"
placeholder="Enter CLI installer args..." placeholder="Enter CLI installer args..."
autocapitalize="off"
autocorrect="off"
spellcheck="false"
bind:value={installerArgs} bind:value={installerArgs}
/> />
<button <button
+8 -1
View File
@@ -4,7 +4,14 @@ use std::process::exit;
fn main() { fn main() {
println!("cargo::rerun-if-env-changed=NO_FIRMWARE_BIN"); println!("cargo::rerun-if-env-changed=NO_FIRMWARE_BIN");
println!("cargo::rerun-if-env-changed=FIRMWARE_PROFILE"); println!("cargo::rerun-if-env-changed=FIRMWARE_PROFILE");
let profile = std::env::var("FIRMWARE_PROFILE").unwrap_or_else(|_| "firmware".to_string()); let profile = std::env::var("FIRMWARE_PROFILE").unwrap_or_else(|_| {
// Default to firmware-devel for debug builds, firmware for release builds
if std::env::var("PROFILE").as_deref() == Ok("release") {
"firmware".to_string()
} else {
"firmware-devel".to_string()
}
});
let include_dir = Path::new(env!("CARGO_MANIFEST_DIR")) let include_dir = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../target/armv7-unknown-linux-musleabihf") .join("../target/armv7-unknown-linux-musleabihf")
.join(&profile); .join(&profile);
+4 -1
View File
@@ -97,7 +97,10 @@ async fn login_and_exploit(admin_ip: &str, username: &str, password: &str) -> Re
.context("Failed to parse login response")?; .context("Failed to parse login response")?;
if login_result.retcode != 0 { if login_result.retcode != 0 {
bail!("Login failed with retcode: {}", login_result.retcode); match login_result.retcode {
201 => bail!("Login failed: incorrect password"),
code => bail!("Login failed with retcode: {}", code),
}
} }
// Step 4: Exploit using authenticated session // Step 4: Exploit using authenticated session
+8 -1
View File
@@ -4,6 +4,7 @@ use pcap_file_tokio::pcapng::blocks::enhanced_packet::EnhancedPacketBlock;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::borrow::Cow; use std::borrow::Cow;
use crate::analysis::diagnostic::DiagnosticAnalyzer;
use crate::gsmtap::{GsmtapHeader, GsmtapMessage, GsmtapType}; use crate::gsmtap::{GsmtapHeader, GsmtapMessage, GsmtapType};
use crate::util::RuntimeMetadata; use crate::util::RuntimeMetadata;
use crate::{diag::MessagesContainer, gsmtap_parser}; use crate::{diag::MessagesContainer, gsmtap_parser};
@@ -19,19 +20,21 @@ use super::{
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)] #[serde(default)]
pub struct AnalyzerConfig { pub struct AnalyzerConfig {
pub imsi_requested: bool, pub diagnostic_analyzer: bool,
pub connection_redirect_2g_downgrade: bool, pub connection_redirect_2g_downgrade: bool,
pub lte_sib6_and_7_downgrade: bool, pub lte_sib6_and_7_downgrade: bool,
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, pub test_analyzer: bool,
pub imsi_requested: bool,
} }
impl Default for AnalyzerConfig { impl Default for AnalyzerConfig {
fn default() -> Self { fn default() -> Self {
AnalyzerConfig { AnalyzerConfig {
imsi_requested: true, imsi_requested: true,
diagnostic_analyzer: 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,
@@ -346,6 +349,10 @@ impl Harness {
harness.add_analyzer(Box::new(TestAnalyzer {})) harness.add_analyzer(Box::new(TestAnalyzer {}))
} }
if analyzer_config.diagnostic_analyzer {
harness.add_analyzer(Box::new(DiagnosticAnalyzer {}));
}
harness harness
} }
+166
View File
@@ -0,0 +1,166 @@
use crate::analysis::analyzer::{Analyzer, Event, EventType};
use crate::analysis::information_element::{InformationElement, LteInformationElement};
use pycrate_rs::nas::NASMessage;
use pycrate_rs::nas::emm::EMMMessage;
use pycrate_rs::nas::generated::emm::emm_attach_reject::EMMCauseEMMCause as AttachRejectEMMCause;
use pycrate_rs::nas::generated::emm::emm_detach_request_mt::EPSDetachTypeMTType;
use pycrate_rs::nas::generated::emm::emm_service_reject::EMMCauseEMMCause as ServiceRejectEMMCause;
use pycrate_rs::nas::generated::emm::emm_tracking_area_update_reject::EMMCauseEMMCause as TAURejectEMMCause;
use std::borrow::Cow;
pub struct DiagnosticAnalyzer;
impl Default for DiagnosticAnalyzer {
fn default() -> Self {
Self::new()
}
}
impl DiagnosticAnalyzer {
pub fn new() -> Self {
DiagnosticAnalyzer
}
fn is_imsi_exposing_nas(&self, nas_msg: &NASMessage) -> bool {
match nas_msg {
NASMessage::EMMMessage(emm_msg) => match emm_msg {
EMMMessage::EMMIdentityRequest(_) => true, // Alert on all identity requests (IMSI, IMEI, IMEISV)
EMMMessage::EMMTrackingAreaUpdateReject(reject) => {
matches!(
reject.emm_cause.inner,
TAURejectEMMCause::IllegalUE
| TAURejectEMMCause::IllegalME
| TAURejectEMMCause::EPSServicesNotAllowed
| TAURejectEMMCause::EPSServicesAndNonEPSServicesNotAllowed
| TAURejectEMMCause::TrackingAreaNotAllowed
| TAURejectEMMCause::EPSServicesNotAllowedInThisPLMN
| TAURejectEMMCause::RequestedServiceOptionNotAuthorizedInThisPLMN
)
}
EMMMessage::EMMAttachReject(reject) => {
matches!(
reject.emm_cause.inner,
AttachRejectEMMCause::IllegalUE
| AttachRejectEMMCause::IllegalME
| AttachRejectEMMCause::EPSServicesNotAllowed
| AttachRejectEMMCause::EPSServicesAndNonEPSServicesNotAllowed
| AttachRejectEMMCause::PLMNNotAllowed
| AttachRejectEMMCause::TrackingAreaNotAllowed
| AttachRejectEMMCause::RoamingNotAllowedInThisTrackingArea
| AttachRejectEMMCause::EPSServicesNotAllowedInThisPLMN
| AttachRejectEMMCause::NoSuitableCellsInTrackingArea
| AttachRejectEMMCause::RequestedServiceOptionNotAuthorizedInThisPLMN
)
}
EMMMessage::EMMDetachRequestMT(req) => {
// Original implementation: !(nas_eps.emm.detach_type_dl == 3)
req.eps_detach_type.inner.typ != EPSDetachTypeMTType::IMSIDetach
}
EMMMessage::EMMAttachRequest(_) => {
// just because eps_attach_type is IMSI doesn't mean that the phoen transmitted its IMSI
// It often sends the GUTI instead. We could check the req.epsid structure but it appears to actually
// not be parsed. So for now we are just ignoreing this message
// req.eps_attach_type.inner == EPSAttachTypeV::CombinedEPSIMSIAttach
false
}
EMMMessage::EMMServiceReject(reject) => {
matches!(
reject.emm_cause.inner,
ServiceRejectEMMCause::IllegalUE
| ServiceRejectEMMCause::IllegalME
| ServiceRejectEMMCause::EPSServicesNotAllowed
| ServiceRejectEMMCause::UEIdentityCannotBeDerivedByTheNetwork
| ServiceRejectEMMCause::TrackingAreaNotAllowed
| ServiceRejectEMMCause::EPSServicesNotAllowedInThisPLMN
| ServiceRejectEMMCause::RequestedServiceOptionNotAuthorizedInThisPLMN
)
}
_ => false,
},
_ => false,
}
}
}
impl Analyzer for DiagnosticAnalyzer {
fn get_name(&self) -> Cow<'_, str> {
"Diagnostic detector for messages which might lead to IMSI exposure".into()
}
fn get_description(&self) -> Cow<'_, str> {
"Catches any messages that may lead to IMSI Exposure. Can be quite noisy. \
Useful as a diagnostic for finding out why an IMSI was sent or what \
the reason for a reject message was. Not a useful indicator on its own \
but a helpful diagnostic for understanding why another indicator was \
triggered. Based on the list of IMSI exposing messages identified in \
the 'Marlin' paper."
.into()
}
fn get_version(&self) -> u32 {
1
}
fn analyze_information_element(
&mut self,
ie: &InformationElement,
_packet_num: usize,
) -> Option<Event> {
let lte_ie = match ie {
InformationElement::LTE(inner) => inner,
_ => return None,
};
match lte_ie.as_ref() {
LteInformationElement::NAS(nas_msg) => {
if self.is_imsi_exposing_nas(nas_msg) {
let message_type = match nas_msg {
NASMessage::EMMMessage(emm_msg) => match emm_msg {
EMMMessage::EMMIdentityRequest(request) => {
format!("EMM Identity Request ({:?})", request.id_type.inner)
}
EMMMessage::EMMTrackingAreaUpdateReject(reject) => {
format!(
"EMM Tracking Area Update Reject ({:?})",
reject.emm_cause.inner
)
}
EMMMessage::EMMAttachReject(reject) => {
format!("EMM Attach Reject ({:?})", reject.emm_cause.inner)
}
EMMMessage::EMMDetachRequestMT(request) => {
format!(
"EMM Detach Request ({:?}:{:?})",
request.eps_detach_type.inner, request.emm_cause.inner
)
}
EMMMessage::EMMServiceReject(reject) => {
format!("EMM Service Reject ({:?})", reject.emm_cause.inner)
}
EMMMessage::EMMAttachRequest(request) => {
format!("EPS Attach Request ({:?})", request.epsid.inner)
}
_ => "Unknown EMM Message".to_string(),
},
_ => "Unknown NAS Message".to_string(),
};
Some(Event {
event_type: EventType::Informational,
message: format!("Diagnostic: {message_type}."),
})
} else {
None
}
}
_ => None,
}
}
}
-5
View File
@@ -78,12 +78,7 @@ impl ImsiRequestedAnalyzer {
}); });
} }
// Notify on any identity reqeust (IMEI or IMSI)
(_, State::IdentityRequest) => { (_, State::IdentityRequest) => {
self.flag = Some(Event {
event_type: EventType::Informational,
message: "Identity Request happened but its not suspicious yet.".to_string(),
});
self.timeout_counter = 0; self.timeout_counter = 0;
} }
+1
View File
@@ -1,5 +1,6 @@
pub mod analyzer; pub mod analyzer;
pub mod connection_redirect_downgrade; pub mod connection_redirect_downgrade;
pub mod diagnostic;
pub mod imsi_requested; pub mod imsi_requested;
pub mod incomplete_sib; pub mod incomplete_sib;
pub mod information_element; pub mod information_element;
+351
View File
@@ -0,0 +1,351 @@
//! Diag ML1 measurement log serialization/deserialization. These are pretty
//! much entirely based on Shinjo Park's work in scat, since we couldn't find
//! any other documentation for the logs' structure.
use deku::prelude::*;
use deku::ctx::Order;
fn decode_rsrp(rsrp: u16) -> f32 {
rsrp as f32 / 16.0 - 180.0
}
fn decode_rssi(rssi: u16) -> f32 {
rssi as f32 / 16.0 - 110.0
}
fn decode_rsrq(rsrq: u16) -> f32 {
rsrq as f32 / 16.0 - 30.0
}
pub mod serving_cell {
use super::*;
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
#[deku(bit_order = "lsb")]
pub struct MeasurementAndEvaluation {
pub header: MeasurementAndEvaluationHeader,
#[deku(bits = 12, pad_bits_after = "20")]
meas_rsrp: u16,
avg_rsrp: u32,
#[deku(bits = 10, pad_bits_after = "22")]
meas_rsrq: u16,
#[deku(pad_bits_before = "10", bits = 11, pad_bits_after = "11")]
meas_rssi: u16,
rxlev: u32,
s_search: u32,
#[deku(cond = "header.get_rrc_rel() == 0x01")]
r9_data: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
#[deku(ctx = "_: Order", id_type = "u8", bit_order = "lsb")]
pub enum MeasurementAndEvaluationHeader {
#[deku(id = "4")]
V4 {
rrc_rel: u8,
_reserved: u16,
earfcn: u16,
#[deku(bits = 9)]
pci: u16,
#[deku(bits = 7)]
serv_layer_priority: u8,
},
#[deku(id = "5")]
V5 {
rrc_rel: u8,
_reserved: u16,
earfcn: u32,
#[deku(bits = 9)]
pci: u16,
#[deku(bits = 7, pad_bytes_after = "2")]
serv_layer_priority: u8,
},
}
impl MeasurementAndEvaluationHeader {
fn get_rrc_rel(&self) -> u8 {
match self {
MeasurementAndEvaluationHeader::V4 { rrc_rel, .. } => *rrc_rel,
MeasurementAndEvaluationHeader::V5 { rrc_rel, .. } => *rrc_rel,
}
}
}
impl MeasurementAndEvaluation {
pub fn get_pci(&self) -> u16 {
match &self.header {
MeasurementAndEvaluationHeader::V4 { pci, .. } => *pci,
MeasurementAndEvaluationHeader::V5 { pci, .. } => *pci,
}
}
pub fn get_earfcn(&self) -> u32 {
match &self.header {
MeasurementAndEvaluationHeader::V4 { earfcn, .. } => *earfcn as u32,
MeasurementAndEvaluationHeader::V5 { earfcn, .. } => *earfcn,
}
}
pub fn get_meas_rsrp(&self) -> f32 {
decode_rsrp(self.meas_rsrp)
}
pub fn get_meas_rssi(&self) -> f32 {
decode_rssi(self.meas_rssi)
}
pub fn get_meas_rsrq(&self) -> f32 {
decode_rsrq(self.meas_rsrq)
}
}
}
pub mod neighbor_cells {
use super::*;
#[derive(Clone, Debug, DekuRead, DekuWrite, PartialEq)]
#[deku(id_type = "u8", bit_order = "lsb")]
pub enum MeasurementsHeader {
#[deku(id = "4")]
V4 {
rrc_rel: u8,
_reserved1: u16,
earfcn: u16,
#[deku(bits = 6)]
q_rxlevmin: u8,
#[deku(bits = 10)]
n_cells: u16,
},
#[deku(id = "5")]
V5 {
rrc_rel: u8,
_reserved1: u16,
earfcn: u32,
#[deku(bits = 6)]
q_rxlevmin: u8,
#[deku(bits = 26)]
n_cells: u32,
},
}
impl MeasurementsHeader {
fn get_n_cells(&self) -> usize {
match self {
MeasurementsHeader::V4 { n_cells, .. } => *n_cells as usize,
MeasurementsHeader::V5 { n_cells, .. } => *n_cells as usize,
}
}
}
#[derive(Clone, Debug, DekuRead, DekuWrite, PartialEq)]
pub struct Measurements {
pub header: MeasurementsHeader,
#[deku(count = "header.get_n_cells()")]
pub cells: Vec<MeasurementsCell>
}
impl Measurements {
pub fn get_earfcn(&self) -> u32 {
match &self.header {
MeasurementsHeader::V4 { earfcn, .. } => *earfcn as u32,
MeasurementsHeader::V5 { earfcn, .. } => *earfcn,
}
}
}
#[derive(Clone, Debug, DekuRead, DekuWrite, PartialEq)]
#[deku(bit_order = "lsb")]
pub struct MeasurementsCell {
#[deku(bits = 9)]
pub pci: u16,
#[deku(bits = 11)]
meas_rssi: u16,
#[deku(bits = 12)]
meas_rsrp: u16,
#[deku(pad_bits_before = "12", bits = 12, pad_bits_after = "8")]
avg_rsrp: u16,
#[deku(pad_bits_before = "12", bits = 10, pad_bits_after = "10")]
meas_rsrq: u16,
#[deku(bits = 10, pad_bits_after = "10")]
avg_rsrq: u16,
#[deku(bits = 6, pad_bits_after = "6")]
s_rxlev: u16,
n_freq_offset: u16,
val5: u16,
ant0_offset: u32,
ant1_offset: u32,
unk1: u32,
}
impl MeasurementsCell {
pub fn get_meas_rsrp(&self) -> f32 {
decode_rsrp(self.meas_rsrp)
}
pub fn get_meas_rssi(&self) -> f32 {
decode_rssi(self.meas_rssi)
}
pub fn get_meas_rsrq(&self) -> f32 {
decode_rsrq(self.meas_rsrq)
}
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::diag::diaglog::LogBody;
use crate::log_codes::{LOG_LTE_ML1_NEIGHBOR_MEAS, LOG_LTE_ML1_SERVING_CELL_MEAS_RESP_AND_EVAL};
use std::io::{Cursor, Seek};
fn unhexlify(hexlified_bytes: &str) -> (usize, Reader<Cursor<Vec<u8>>>) {
let byte_len = hexlified_bytes.len() / 2;
let bytes = (0..hexlified_bytes.len())
.step_by(2)
.map(|i| u8::from_str_radix(&hexlified_bytes[i..i+2], 16).unwrap())
.collect();
(byte_len, Reader::new(Cursor::new(bytes)))
}
fn parse_ncell_measurements(hexlified_bytes: &str) -> (u8, neighbor_cells::Measurements) {
let (total_size, mut reader) = unhexlify(hexlified_bytes);
match LogBody::from_reader_with_ctx(&mut reader, (LOG_LTE_ML1_NEIGHBOR_MEAS as u16, 0)) {
Ok(LogBody::LteMl1NeighborCellsMeasurements { data }) => {
if !reader.end() {
let leftover_bits = reader.rest();
let leftover_bytes = total_size - reader.stream_position().unwrap() as usize;
panic!("failed to read entire buffer ({} bytes, {} bits left)", leftover_bytes, leftover_bits.len());
}
let pkt_version = match data.header {
neighbor_cells::MeasurementsHeader::V4 { .. } => 4,
neighbor_cells::MeasurementsHeader::V5 { .. } => 5,
};
(pkt_version, data)
},
Ok(x) => panic!("expected MeasurementAndEvaluation, but parsed {:?}", x),
Err(x) => panic!("failed to parse MeasurementAndEvaluation {:?}", x),
}
}
fn parse_meas_eval(hexlified_bytes: &str) -> (u8, serving_cell::MeasurementAndEvaluation) {
let (total_size, mut reader) = unhexlify(hexlified_bytes);
match LogBody::from_reader_with_ctx(&mut reader, (LOG_LTE_ML1_SERVING_CELL_MEAS_RESP_AND_EVAL as u16, 0)) {
Ok(LogBody::LteMl1ServingCellMeasurementAndEvaluation { data }) => {
if !reader.end() {
let leftover_bits = reader.rest();
let leftover_bytes = total_size - reader.stream_position().unwrap() as usize;
panic!("failed to read entire buffer ({} bytes, {} bits left)", leftover_bytes, leftover_bits.len());
}
let pkt_version = match data.header {
serving_cell::MeasurementAndEvaluationHeader::V4 { .. } => 4,
serving_cell::MeasurementAndEvaluationHeader::V5 { .. } => 5,
};
(pkt_version, data)
},
Ok(x) => panic!("expected MeasurementAndEvaluation, but parsed {:?}", x),
Err(x) => panic!("failed to parse MeasurementAndEvaluation {:?}", x),
}
}
fn scell_meas_and_eval_case(
hexlified_bytes: &str,
pkt_version: u8,
pci: u16,
earfcn: u32,
rsrp: f32,
rsrq: f32,
rssi: f32
) {
let (parsed_pkt_version, data) = parse_meas_eval(hexlified_bytes);
assert_eq!(parsed_pkt_version, pkt_version);
assert_eq!(data.get_pci(), pci, "incorrect pci");
assert_eq!(data.get_earfcn(), earfcn, "incorrect earfcn");
assert_eq!(data.get_meas_rsrp(), rsrp, "incorrect rsrp");
assert_eq!(data.get_meas_rsrq(), rsrq, "incorrect rsrq");
assert_eq!(data.get_meas_rssi(), rssi, "incorrect rssi");
}
// Adapted from scat's TestDiagLteLogParser::test_parse_lte_ml1_scell_meas,
// but edited to print full-precision floats
#[test]
fn test_scell_meas() {
scell_meas_and_eval_case(
"040100009C18D60AECC44E00E2244E00FFFCE30FFED80A0047AD56021D310100A2624100",
4,
214,
6300,
-101.25,
-14.0625,
-66.625
);
scell_meas_and_eval_case(
"05010000160d0000d40e00004bb444005444450039e514133149070048adfe019f310100a23f0000",
5,
212,
3350,
-111.3125,
-10.4375,
-80.875,
);
scell_meas_and_eval_case(
"05010000f424000a4d43434d4e434d41524b45527c307c3236327c317c34323330333233347c7c4d43434d4e434d41524b45520a0a434f504d41524b45527c434f504552524f5232363230317c434f504d41524b45520a006306000057755500577555001d75d4111d290b0048ad7e02dd370100a27f4100",
5,
333,
167781620,
-127.125,
-22.25,
2.75,
);
scell_meas_and_eval_case(
"0501000000190000a90d0000d9944d00d9944d006081d5d55d2568bc48ad3e027f314fe0891900e0",
5,
425,
6400,
-102.4375,
-8.0,
-77.4375,
);
}
fn ncell_meas_case(
hexlified_bytes: &str,
pkt_version: u8,
earfcn: u32,
cells: Vec<(u16, f32, f32, f32)>,
) {
let (parsed_pkt_version, data) = parse_ncell_measurements(hexlified_bytes);
assert_eq!(parsed_pkt_version, pkt_version, "incorrect pkt_version");
assert_eq!(data.cells.len(), cells.len(), "incorrect number of cells");
assert_eq!(data.get_earfcn(), earfcn, "incorrect earfcn");
for (parsed, (pci, rsrp, rssi, rsrq)) in data.cells.iter().zip(cells) {
assert_eq!(parsed.pci, pci, "incorrect pci");
assert_eq!(parsed.get_meas_rsrp(), rsrp, "incorrect rsrp");
assert_eq!(parsed.get_meas_rssi(), rssi, "incorrect rssi");
assert_eq!(parsed.get_meas_rsrq(), rsrq, "incorrect rsrq");
}
}
// Adapted from scat's TestDiagLteLogParser::test_parse_lte_ml1_ncell_meas,
// but edited to print full-precision floats
#[test]
fn test_ncell_meas() {
ncell_meas_case(
"040100009C1847008348E44DDEA44C00CAB4CC32B6D8420300000000FF773301FF77330122020100",
4,
6300,
vec![
(131, -102.125, -75.75, -17.3125),
]
);
ncell_meas_case(
"05010000160d0000480000006cea413bb4433b00b4f3cc33cf3c130200000000ffefc00fffefc00f45081600",
5,
3350,
vec![
(108, -120.75, -94.6875, -17.0625),
]
);
}
}
+206
View File
@@ -0,0 +1,206 @@
//! Diag LogBody serialization/deserialization
use chrono::{DateTime, FixedOffset};
use deku::prelude::*;
pub mod measurement;
pub mod rrc;
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
#[deku(ctx = "log_type: u16, hdr_len: u16", id = "log_type")]
pub enum LogBody {
#[deku(id = "0x412f")]
WcdmaSignallingMessage {
channel_type: u8,
radio_bearer: u8,
length: u16,
#[deku(count = "length")]
msg: Vec<u8>,
},
#[deku(id = "0x512f")]
GsmRrSignallingMessage {
channel_type: u8,
message_type: u8,
length: u8,
#[deku(count = "length")]
msg: Vec<u8>,
},
#[deku(id = "0x5226")]
GprsMacSignallingMessage {
channel_type: u8,
message_type: u8,
length: u8,
#[deku(count = "length")]
msg: Vec<u8>,
},
#[deku(id = "0xb0c0")]
LteRrcOtaMessage {
ext_header_version: u8,
#[deku(ctx = "*ext_header_version")]
packet: rrc::LteRrcOtaPacket,
},
// the four NAS command opcodes refer to:
// * 0xb0e2: plain ESM NAS message (incoming)
// * 0xb0e3: plain ESM NAS message (outgoing)
// * 0xb0ec: plain EMM NAS message (incoming)
// * 0xb0ed: plain EMM NAS message (outgoing)
#[deku(id_pat = "0xb0e2 | 0xb0e3 | 0xb0ec | 0xb0ed")]
Nas4GMessage {
#[deku(skip, default = "log_type")]
log_type: u16,
#[deku(ctx = "*log_type")]
direction: Nas4GMessageDirection,
ext_header_version: u8,
rrc_rel: u8,
rrc_version_minor: u8,
rrc_version_major: u8,
// message length = hdr_len - (sizeof(ext_header_version) + sizeof(rrc_rel) + sizeof(rrc_version_minor) + sizeof(rrc_version_major))
#[deku(count = "hdr_len.saturating_sub(4)")]
msg: Vec<u8>,
},
#[deku(id = "0x11eb")]
IpTraffic {
// is this right?? based on https://github.com/P1sec/QCSuper/blob/81dbaeee15ec7747e899daa8e3495e27cdcc1264/src/modules/pcap_dump.py#L378
#[deku(count = "hdr_len.saturating_sub(8)")]
msg: Vec<u8>,
},
#[deku(id = "0x713a")]
UmtsNasOtaMessage {
is_uplink: u8,
length: u32,
#[deku(count = "length")]
msg: Vec<u8>,
},
#[deku(id = "0xb821")]
NrRrcOtaMessage {
#[deku(count = "hdr_len")]
msg: Vec<u8>,
},
#[deku(id = "0xb17f")]
LteMl1ServingCellMeasurementAndEvaluation {
data: measurement::serving_cell::MeasurementAndEvaluation,
},
#[deku(id = "0xb180")]
LteMl1NeighborCellsMeasurements {
data: measurement::neighbor_cells::Measurements,
},
}
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
#[deku(ctx = "log_type: u16", id = "log_type")]
pub enum Nas4GMessageDirection {
// * 0xb0e2: plain ESM NAS message (incoming)
// * 0xb0e3: plain ESM NAS message (outgoing)
// * 0xb0ec: plain EMM NAS message (incoming)
// * 0xb0ed: plain EMM NAS message (outgoing)
#[deku(id_pat = "0xb0e2 | 0xb0ec")]
Downlink,
#[deku(id_pat = "0xb0e3 | 0xb0ed")]
Uplink,
}
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
#[deku(endian = "little")]
pub struct Timestamp {
pub ts: u64,
}
impl Timestamp {
pub fn to_datetime(&self) -> DateTime<FixedOffset> {
// Upper 48 bits: epoch at 1980-01-06 00:00:00, incremented by 1 for 1/800s
// Lower 16 bits: time since last 1/800s tick in 1/32 chip units
let ts_upper = self.ts >> 16;
let ts_lower = self.ts & 0xffff;
let epoch = chrono::DateTime::parse_from_rfc3339("1980-01-06T00:00:00-00:00").unwrap();
let mut delta_seconds = ts_upper as f64 * 1.25;
delta_seconds += ts_lower as f64 / 40960.0;
let ts_delta = chrono::Duration::milliseconds(delta_seconds as i64);
epoch + ts_delta
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::diag::Message;
#[test]
fn test_logs() {
let data = vec![
16, 0, 38, 0, 38, 0, 192, 176, 26, 165, 245, 135, 118, 35, 2, 1, 20, 14, 48, 0, 160, 0,
2, 8, 0, 0, 217, 15, 5, 0, 0, 0, 0, 7, 0, 64, 1, 238, 173, 213, 77, 208,
];
let msg = Message::from_bytes((&data, 0)).unwrap().1;
assert_eq!(
msg,
Message::Log {
pending_msgs: 0,
outer_length: 38,
inner_length: 38,
log_type: 0xb0c0,
timestamp: Timestamp {
ts: 72659535985485082
},
body: LogBody::LteRrcOtaMessage {
ext_header_version: 20,
packet: rrc::LteRrcOtaPacket::V8 {
rrc_rel_maj: 14,
rrc_rel_min: 48,
bearer_id: 0,
phy_cell_id: 160,
earfcn: 2050,
sfn_subfn: 4057,
pdu_num: 5,
sib_mask: 0,
len: 7,
packet: vec![0x40, 0x1, 0xee, 0xad, 0xd5, 0x4d, 0xd0],
},
},
}
);
}
#[test]
fn test_fuzz_crash_inner_length_underflow() {
// Regression test: inner_length < 12 previously caused panic.
// Fixed by using saturating_sub in Message::Log body length calculation.
let fuzz_data = b"\x10\x00\x00\x00\x05\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00";
let _ = Message::from_bytes((fuzz_data, 0));
}
#[test]
fn test_fuzz_crash_nas_hdr_len_underflow() {
// Regression test for two things:
// - hdr_len < 4 previously caused panic in Nas4GMessage.
// - Upgrading to deku 0.20 caused incorrect parsing behavior (double-read of discriminant)
let nas_msg =
b"\x10\x00\x14\x00\x02\x00\xe2\xb0\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00";
let ((rest, _), msg) = Message::from_bytes((nas_msg, 0)).unwrap();
assert_eq!(rest.len(), 0);
assert!(
matches!(
msg,
Message::Log {
log_type: 0xb0e2,
body: LogBody::Nas4GMessage {
direction: Nas4GMessageDirection::Downlink,
..
},
..
}
),
"Unexpected message: {:?}",
msg
);
}
#[test]
fn test_fuzz_crash_ip_traffic_hdr_len_underflow() {
// Regression test: hdr_len < 8 previously caused panic in IpTraffic.
// Fixed by using saturating_sub for msg length calculation.
let ip_msg = b"\x10\x00\x14\x00\x02\x00\xeb\x11\x00\x00\x00\x00\x00\x00\x00\x00\x03\x00";
let _ = Message::from_bytes((ip_msg, 0));
}
}
+110
View File
@@ -0,0 +1,110 @@
//! Diag LTE RRC serialization/deserialization
use deku::prelude::*;
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
#[deku(ctx = "ext_header_version: u8", id = "ext_header_version")]
pub enum LteRrcOtaPacket {
#[deku(id_pat = "0..=4")]
V0 {
rrc_rel_maj: u8,
rrc_rel_min: u8,
bearer_id: u8,
phy_cell_id: u16,
earfcn: u16,
sfn_subfn: u16,
pdu_num: u8,
len: u16,
#[deku(count = "len")]
packet: Vec<u8>,
},
#[deku(id_pat = "5..=7")]
V5 {
rrc_rel_maj: u8,
rrc_rel_min: u8,
bearer_id: u8,
phy_cell_id: u16,
earfcn: u16,
sfn_subfn: u16,
pdu_num: u8,
sib_mask: u32,
len: u16,
#[deku(count = "len")]
packet: Vec<u8>,
},
#[deku(id_pat = "8..=24")]
V8 {
rrc_rel_maj: u8,
rrc_rel_min: u8,
bearer_id: u8,
phy_cell_id: u16,
earfcn: u32,
sfn_subfn: u16,
pdu_num: u8,
sib_mask: u32,
len: u16,
#[deku(count = "len")]
packet: Vec<u8>,
},
#[deku(id_pat = "25..")]
V25 {
rrc_rel_maj: u8,
rrc_rel_min: u8,
nr_rrc_rel_maj: u8,
nr_rrc_rel_min: u8,
bearer_id: u8,
phy_cell_id: u16,
earfcn: u32,
sfn_subfn: u16,
pdu_num: u8,
sib_mask: u32,
len: u16,
#[deku(count = "len")]
packet: Vec<u8>,
},
}
impl LteRrcOtaPacket {
fn get_sfn_subfn(&self) -> u16 {
match self {
LteRrcOtaPacket::V0 { sfn_subfn, .. } => *sfn_subfn,
LteRrcOtaPacket::V5 { sfn_subfn, .. } => *sfn_subfn,
LteRrcOtaPacket::V8 { sfn_subfn, .. } => *sfn_subfn,
LteRrcOtaPacket::V25 { sfn_subfn, .. } => *sfn_subfn,
}
}
pub fn get_sfn(&self) -> u32 {
self.get_sfn_subfn() as u32 >> 4
}
pub fn get_subfn(&self) -> u8 {
(self.get_sfn_subfn() & 0xf) as u8
}
pub fn get_pdu_num(&self) -> u8 {
match self {
LteRrcOtaPacket::V0 { pdu_num, .. } => *pdu_num,
LteRrcOtaPacket::V5 { pdu_num, .. } => *pdu_num,
LteRrcOtaPacket::V8 { pdu_num, .. } => *pdu_num,
LteRrcOtaPacket::V25 { pdu_num, .. } => *pdu_num,
}
}
pub fn get_earfcn(&self) -> u32 {
match self {
LteRrcOtaPacket::V0 { earfcn, .. } => *earfcn as u32,
LteRrcOtaPacket::V5 { earfcn, .. } => *earfcn as u32,
LteRrcOtaPacket::V8 { earfcn, .. } => *earfcn,
LteRrcOtaPacket::V25 { earfcn, .. } => *earfcn,
}
}
pub fn take_payload(self) -> Vec<u8> {
match self {
LteRrcOtaPacket::V0 { packet, .. } => packet,
LteRrcOtaPacket::V5 { packet, .. } => packet,
LteRrcOtaPacket::V8 { packet, .. } => packet,
LteRrcOtaPacket::V25 { packet, .. } => packet,
}
}
}
+5 -294
View File
@@ -1,6 +1,5 @@
//! Diag protocol serialization/deserialization //! Diag protocol serialization/deserialization
use chrono::{DateTime, FixedOffset};
use crc::{Algorithm, Crc}; use crc::{Algorithm, Crc};
use deku::prelude::*; use deku::prelude::*;
@@ -8,6 +7,10 @@ use crate::hdlc::{self, hdlc_decapsulate};
use log::warn; use log::warn;
use thiserror::Error; use thiserror::Error;
pub mod diaglog;
use diaglog::{LogBody, Timestamp};
pub const MESSAGE_TERMINATOR: u8 = 0x7e; pub const MESSAGE_TERMINATOR: u8 = 0x7e;
pub const MESSAGE_ESCAPE_CHAR: u8 = 0x7d; pub const MESSAGE_ESCAPE_CHAR: u8 = 0x7d;
@@ -152,218 +155,6 @@ pub enum Message {
}, },
} }
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
#[deku(ctx = "log_type: u16, hdr_len: u16", id = "log_type")]
pub enum LogBody {
#[deku(id = "0x412f")]
WcdmaSignallingMessage {
channel_type: u8,
radio_bearer: u8,
length: u16,
#[deku(count = "length")]
msg: Vec<u8>,
},
#[deku(id = "0x512f")]
GsmRrSignallingMessage {
channel_type: u8,
message_type: u8,
length: u8,
#[deku(count = "length")]
msg: Vec<u8>,
},
#[deku(id = "0x5226")]
GprsMacSignallingMessage {
channel_type: u8,
message_type: u8,
length: u8,
#[deku(count = "length")]
msg: Vec<u8>,
},
#[deku(id = "0xb0c0")]
LteRrcOtaMessage {
ext_header_version: u8,
#[deku(ctx = "*ext_header_version")]
packet: LteRrcOtaPacket,
},
// the four NAS command opcodes refer to:
// * 0xb0e2: plain ESM NAS message (incoming)
// * 0xb0e3: plain ESM NAS message (outgoing)
// * 0xb0ec: plain EMM NAS message (incoming)
// * 0xb0ed: plain EMM NAS message (outgoing)
#[deku(id_pat = "0xb0e2 | 0xb0e3 | 0xb0ec | 0xb0ed")]
Nas4GMessage {
#[deku(skip, default = "log_type")]
log_type: u16,
#[deku(ctx = "*log_type")]
direction: Nas4GMessageDirection,
ext_header_version: u8,
rrc_rel: u8,
rrc_version_minor: u8,
rrc_version_major: u8,
// message length = hdr_len - (sizeof(ext_header_version) + sizeof(rrc_rel) + sizeof(rrc_version_minor) + sizeof(rrc_version_major))
#[deku(count = "hdr_len.saturating_sub(4)")]
msg: Vec<u8>,
},
#[deku(id = "0x11eb")]
IpTraffic {
// is this right?? based on https://github.com/P1sec/QCSuper/blob/81dbaeee15ec7747e899daa8e3495e27cdcc1264/src/modules/pcap_dump.py#L378
#[deku(count = "hdr_len.saturating_sub(8)")]
msg: Vec<u8>,
},
#[deku(id = "0x713a")]
UmtsNasOtaMessage {
is_uplink: u8,
length: u32,
#[deku(count = "length")]
msg: Vec<u8>,
},
#[deku(id = "0xb821")]
NrRrcOtaMessage {
#[deku(count = "hdr_len")]
msg: Vec<u8>,
},
}
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
#[deku(ctx = "log_type: u16", id = "log_type")]
pub enum Nas4GMessageDirection {
// * 0xb0e2: plain ESM NAS message (incoming)
// * 0xb0e3: plain ESM NAS message (outgoing)
// * 0xb0ec: plain EMM NAS message (incoming)
// * 0xb0ed: plain EMM NAS message (outgoing)
#[deku(id_pat = "0xb0e2 | 0xb0ec")]
Downlink,
#[deku(id_pat = "0xb0e3 | 0xb0ed")]
Uplink,
}
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
#[deku(ctx = "ext_header_version: u8", id = "ext_header_version")]
pub enum LteRrcOtaPacket {
#[deku(id_pat = "0..=4")]
V0 {
rrc_rel_maj: u8,
rrc_rel_min: u8,
bearer_id: u8,
phy_cell_id: u16,
earfcn: u16,
sfn_subfn: u16,
pdu_num: u8,
len: u16,
#[deku(count = "len")]
packet: Vec<u8>,
},
#[deku(id_pat = "5..=7")]
V5 {
rrc_rel_maj: u8,
rrc_rel_min: u8,
bearer_id: u8,
phy_cell_id: u16,
earfcn: u16,
sfn_subfn: u16,
pdu_num: u8,
sib_mask: u32,
len: u16,
#[deku(count = "len")]
packet: Vec<u8>,
},
#[deku(id_pat = "8..=24")]
V8 {
rrc_rel_maj: u8,
rrc_rel_min: u8,
bearer_id: u8,
phy_cell_id: u16,
earfcn: u32,
sfn_subfn: u16,
pdu_num: u8,
sib_mask: u32,
len: u16,
#[deku(count = "len")]
packet: Vec<u8>,
},
#[deku(id_pat = "25..")]
V25 {
rrc_rel_maj: u8,
rrc_rel_min: u8,
nr_rrc_rel_maj: u8,
nr_rrc_rel_min: u8,
bearer_id: u8,
phy_cell_id: u16,
earfcn: u32,
sfn_subfn: u16,
pdu_num: u8,
sib_mask: u32,
len: u16,
#[deku(count = "len")]
packet: Vec<u8>,
},
}
impl LteRrcOtaPacket {
fn get_sfn_subfn(&self) -> u16 {
match self {
LteRrcOtaPacket::V0 { sfn_subfn, .. } => *sfn_subfn,
LteRrcOtaPacket::V5 { sfn_subfn, .. } => *sfn_subfn,
LteRrcOtaPacket::V8 { sfn_subfn, .. } => *sfn_subfn,
LteRrcOtaPacket::V25 { sfn_subfn, .. } => *sfn_subfn,
}
}
pub fn get_sfn(&self) -> u32 {
self.get_sfn_subfn() as u32 >> 4
}
pub fn get_subfn(&self) -> u8 {
(self.get_sfn_subfn() & 0xf) as u8
}
pub fn get_pdu_num(&self) -> u8 {
match self {
LteRrcOtaPacket::V0 { pdu_num, .. } => *pdu_num,
LteRrcOtaPacket::V5 { pdu_num, .. } => *pdu_num,
LteRrcOtaPacket::V8 { pdu_num, .. } => *pdu_num,
LteRrcOtaPacket::V25 { pdu_num, .. } => *pdu_num,
}
}
pub fn get_earfcn(&self) -> u32 {
match self {
LteRrcOtaPacket::V0 { earfcn, .. } => *earfcn as u32,
LteRrcOtaPacket::V5 { earfcn, .. } => *earfcn as u32,
LteRrcOtaPacket::V8 { earfcn, .. } => *earfcn,
LteRrcOtaPacket::V25 { earfcn, .. } => *earfcn,
}
}
pub fn take_payload(self) -> Vec<u8> {
match self {
LteRrcOtaPacket::V0 { packet, .. } => packet,
LteRrcOtaPacket::V5 { packet, .. } => packet,
LteRrcOtaPacket::V8 { packet, .. } => packet,
LteRrcOtaPacket::V25 { packet, .. } => packet,
}
}
}
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
#[deku(endian = "little")]
pub struct Timestamp {
pub ts: u64,
}
impl Timestamp {
pub fn to_datetime(&self) -> DateTime<FixedOffset> {
// Upper 48 bits: epoch at 1980-01-06 00:00:00, incremented by 1 for 1/800s
// Lower 16 bits: time since last 1/800s tick in 1/32 chip units
let ts_upper = self.ts >> 16;
let ts_lower = self.ts & 0xffff;
let epoch = chrono::DateTime::parse_from_rfc3339("1980-01-06T00:00:00-00:00").unwrap();
let mut delta_seconds = ts_upper as f64 * 1.25;
delta_seconds += ts_lower as f64 / 40960.0;
let ts_delta = chrono::Duration::milliseconds(delta_seconds as i64);
epoch + ts_delta
}
}
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)] #[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
#[deku(ctx = "opcode: u32, subopcode: u32", id = "opcode")] #[deku(ctx = "opcode: u32, subopcode: u32", id = "opcode")]
pub enum ResponsePayload { pub enum ResponsePayload {
@@ -478,42 +269,6 @@ mod test {
); );
} }
#[test]
fn test_logs() {
let data = vec![
16, 0, 38, 0, 38, 0, 192, 176, 26, 165, 245, 135, 118, 35, 2, 1, 20, 14, 48, 0, 160, 0,
2, 8, 0, 0, 217, 15, 5, 0, 0, 0, 0, 7, 0, 64, 1, 238, 173, 213, 77, 208,
];
let msg = Message::from_bytes((&data, 0)).unwrap().1;
assert_eq!(
msg,
Message::Log {
pending_msgs: 0,
outer_length: 38,
inner_length: 38,
log_type: 0xb0c0,
timestamp: Timestamp {
ts: 72659535985485082
},
body: LogBody::LteRrcOtaMessage {
ext_header_version: 20,
packet: LteRrcOtaPacket::V8 {
rrc_rel_maj: 14,
rrc_rel_min: 48,
bearer_id: 0,
phy_cell_id: 160,
earfcn: 2050,
sfn_subfn: 4057,
pdu_num: 5,
sib_mask: 0,
len: 7,
packet: vec![0x40, 0x1, 0xee, 0xad, 0xd5, 0x4d, 0xd0],
},
},
}
);
}
fn make_container(data_type: DataType, message: HdlcEncapsulatedMessage) -> MessagesContainer { fn make_container(data_type: DataType, message: HdlcEncapsulatedMessage) -> MessagesContainer {
MessagesContainer { MessagesContainer {
data_type, data_type,
@@ -537,7 +292,7 @@ mod test {
}, },
body: LogBody::LteRrcOtaMessage { body: LogBody::LteRrcOtaMessage {
ext_header_version: 20, ext_header_version: 20,
packet: LteRrcOtaPacket::V8 { packet: diaglog::rrc::LteRrcOtaPacket::V8 {
rrc_rel_maj: 14, rrc_rel_maj: 14,
rrc_rel_min: 48, rrc_rel_min: 48,
bearer_id: 0, bearer_id: 0,
@@ -619,50 +374,6 @@ mod test {
)); ));
} }
#[test]
fn test_fuzz_crash_inner_length_underflow() {
// Regression test: inner_length < 12 previously caused panic.
// Fixed by using saturating_sub in Message::Log body length calculation.
let fuzz_data = b"\x10\x00\x00\x00\x05\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00";
let _ = Message::from_bytes((fuzz_data, 0));
}
#[test]
fn test_fuzz_crash_nas_hdr_len_underflow() {
// Regression test for two things:
// - hdr_len < 4 previously caused panic in Nas4GMessage.
// - Upgrading to deku 0.20 caused incorrect parsing behavior (double-read of discriminant)
let nas_msg =
b"\x10\x00\x14\x00\x02\x00\xe2\xb0\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00";
let ((rest, _), msg) = Message::from_bytes((nas_msg, 0)).unwrap();
assert_eq!(rest.len(), 0);
assert!(
matches!(
msg,
Message::Log {
log_type: 0xb0e2,
body: LogBody::Nas4GMessage {
direction: Nas4GMessageDirection::Downlink,
..
},
..
}
),
"Unexpected message: {:?}",
msg
);
}
#[test]
fn test_fuzz_crash_ip_traffic_hdr_len_underflow() {
// Regression test: hdr_len < 8 previously caused panic in IpTraffic.
// Fixed by using saturating_sub for msg length calculation.
let ip_msg = b"\x10\x00\x14\x00\x02\x00\xeb\x11\x00\x00\x00\x00\x00\x00\x00\x00\x03\x00";
let _ = Message::from_bytes((ip_msg, 0));
}
#[test] #[test]
fn test_fuzz_crash_response_opcode_parsing() { fn test_fuzz_crash_response_opcode_parsing() {
// Regression test: Upgrading to deku 0.20 caused incorrect parsing of Response messages. // Regression test: Upgrading to deku 0.20 caused incorrect parsing of Response messages.
+5 -1
View File
@@ -40,7 +40,7 @@ pub enum DiagDeviceError {
ParseMessagesContainerError(deku::DekuError), ParseMessagesContainerError(deku::DekuError),
} }
pub const LOG_CODES_FOR_RAW_PACKET_LOGGING: [u32; 11] = [ pub const LOG_CODES_FOR_RAW_PACKET_LOGGING: [u32; 14] = [
// Layer 2: // Layer 2:
log_codes::LOG_GPRS_MAC_SIGNALLING_MESSAGE_C, // 0x5226 log_codes::LOG_GPRS_MAC_SIGNALLING_MESSAGE_C, // 0x5226
// Layer 3: // Layer 3:
@@ -56,6 +56,10 @@ pub const LOG_CODES_FOR_RAW_PACKET_LOGGING: [u32; 11] = [
log_codes::LOG_LTE_NAS_EMM_OTA_OUT_MSG_LOG_C, // 0xb0ed log_codes::LOG_LTE_NAS_EMM_OTA_OUT_MSG_LOG_C, // 0xb0ed
// User IP traffic: // User IP traffic:
log_codes::LOG_DATA_PROTOCOL_LOGGING_C, // 0x11eb log_codes::LOG_DATA_PROTOCOL_LOGGING_C, // 0x11eb
// Statistics
log_codes::LOG_LTE_ML1_SERVING_CELL_MEAS_RESP_AND_EVAL, // 0xb17f
log_codes::LOG_LTE_ML1_SERVING_CELL_MEAS_RESPONSE, // 0xb193
log_codes::LOG_LTE_ML1_NEIGHBOR_MEAS, // 0xb180
]; ];
const BUFFER_LEN: usize = 1024 * 1024 * 10; const BUFFER_LEN: usize = 1024 * 1024 * 10;
+5 -4
View File
@@ -1,7 +1,8 @@
use crate::diag::*; use crate::diag::Message;
use crate::gsmtap::*; use crate::diag::diaglog::{Timestamp, LogBody, Nas4GMessageDirection};
use crate::gsmtap::{GsmtapHeader, GsmtapMessage, GsmtapType, LteNasSubtype, LteRrcSubtype};
use log::error; use log::debug;
use thiserror::Error; use thiserror::Error;
#[derive(Debug, Error)] #[derive(Debug, Error)]
@@ -153,7 +154,7 @@ fn log_to_gsmtap(value: LogBody) -> Result<Option<GsmtapMessage>, GsmtapParserEr
})) }))
} }
_ => { _ => {
error!("gsmtap_sink: ignoring unhandled log type: {value:?}"); debug!("gsmtap_sink: ignoring unhandled log type: {value:?}");
Ok(None) Ok(None)
} }
} }
+4
View File
@@ -103,3 +103,7 @@ pub const WCDMA_SIGNALLING_MESSAGE: u32 = 0x412f;
pub const LOG_DATA_PROTOCOL_LOGGING_C: u32 = 0x11eb; pub const LOG_DATA_PROTOCOL_LOGGING_C: u32 = 0x11eb;
pub const LOG_UMTS_NAS_OTA_MESSAGE_LOG_PACKET_C: u32 = 0x713a; pub const LOG_UMTS_NAS_OTA_MESSAGE_LOG_PACKET_C: u32 = 0x713a;
pub const LOG_LTE_ML1_SERVING_CELL_MEAS_RESP_AND_EVAL: u32 = 0xb17f;
pub const LOG_LTE_ML1_SERVING_CELL_MEAS_RESPONSE: u32 = 0xb193;
pub const LOG_LTE_ML1_NEIGHBOR_MEAS: u32 = 0xb180;
+1 -1
View File
@@ -1,6 +1,6 @@
//! Parse QMDL files and create a pcap file. //! Parse QMDL files and create a pcap file.
//! Creates a plausible IP header and [GSMtap](https://osmocom.org/projects/baseband/wiki/GSMTAP) header and then puts the rest of the data under that for wireshark to parse. //! Creates a plausible IP header and [GSMtap](https://osmocom.org/projects/baseband/wiki/GSMTAP) header and then puts the rest of the data under that for wireshark to parse.
use crate::diag::Timestamp; use crate::diag::diaglog::Timestamp;
use crate::gsmtap::GsmtapMessage; use crate::gsmtap::GsmtapMessage;
use chrono::prelude::*; use chrono::prelude::*;
+2 -1
View File
@@ -1,6 +1,7 @@
use deku::prelude::*; use deku::prelude::*;
use rayhunter::{ use rayhunter::{
diag::{LogBody, LteRrcOtaPacket, Message, Timestamp}, diag::Message,
diag::diaglog::{LogBody, rrc::LteRrcOtaPacket, Timestamp},
gsmtap_parser, gsmtap_parser,
}; };
+86
View File
@@ -0,0 +1,86 @@
#!/bin/bash
# Build Rayhunter from source for development.
# Prerequisites: Rust (rustup) and Node.js (npm).
#
# Usage: ./scripts/build-dev.sh [build|frontend|check]
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
cd "$PROJECT_DIR"
check_dependencies() {
local missing=0
if ! command -v cargo &> /dev/null; then
echo "Error: cargo not found. Install Rust via https://www.rust-lang.org/tools/install"
missing=1
fi
if ! command -v npm &> /dev/null; then
echo "Error: npm not found. Install Node.js via https://docs.npmjs.com/downloading-and-installing-node-js-and-npm"
missing=1
fi
if [ "$missing" -eq 1 ]; then
exit 1
fi
# Ensure the ARM cross-compilation target is installed
if ! rustup target list --installed | grep -q "armv7-unknown-linux-musleabihf"; then
echo "Installing ARM target (armv7-unknown-linux-musleabihf)..."
rustup target add armv7-unknown-linux-musleabihf
fi
}
build_frontend() {
echo "Building web frontend..."
pushd daemon/web > /dev/null
npm install
npm run build
popd > /dev/null
}
build_daemon() {
echo "Building daemon..."
cargo build-daemon-firmware-devel
echo "Building rootshell..."
cargo build-rootshell-firmware-devel
}
COMMAND="${1:-build}"
case "$COMMAND" in
build)
check_dependencies
build_frontend
build_daemon
echo ""
echo "Build complete! To install to a device, run:"
echo " ./scripts/install-dev.sh <device>"
echo ""
echo "Replace <device> with your device type (e.g. orbic, tplink)."
;;
frontend)
build_frontend
;;
check)
check_dependencies
;;
help|--help|-h)
echo "Usage: $0 [command]"
echo ""
echo "Commands:"
echo " build Build frontend, daemon, and rootshell (default)"
echo " frontend Build only the web frontend"
echo " check Check dependencies only"
;;
*)
echo "Unknown command: $COMMAND"
echo "Run '$0 help' for usage."
exit 1
;;
esac
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
# Install a development build of Rayhunter to a device.
# Run ./scripts/build-dev.sh first.
#
# Usage: ./scripts/install-dev.sh <device> [options...]
# Example: ./scripts/install-dev.sh orbic --admin-password mypass
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
cd "$PROJECT_DIR"
cargo run -p installer --bin installer -- "$@"