Compare commits

..

3 Commits

Author SHA1 Message Date
Cooper Quintin 369a1ba3af appease npm again 2026-02-05 15:51:26 -08:00
Cooper Quintin d6d10ca3a4 appease npm 2026-02-05 15:45:14 -08:00
TJ Jaymes 728fbe0f4d Add cell/signal info to system stats panel
Display LTE signal measurements (RSRP, RSRQ, RSSI, PCI, EARFCN) from
DIAG ML1 Serving Cell Measurement messages in the web UI.

- Add CellInfo struct with RwLock cache in gsmtap_parser
- Add CellSignalInfo to SystemStats API response
- Add Cell Signal row to SystemStatsTable with quality indicator
- Support Orbic, Tplink, Tmobile, Wingtech devices (graceful degradation for others)
2026-02-05 15:43:21 -08:00
39 changed files with 1348 additions and 1610 deletions
-1
View File
@@ -7,4 +7,3 @@
dist/config.toml.in eol=lf
dist/scripts/misc-daemon eol=lf
dist/scripts/rayhunter_daemon eol=lf
scripts/*.sh eol=lf
Generated
-1
View File
@@ -2759,7 +2759,6 @@ dependencies = [
"installer",
"serde",
"serde_json",
"shlex",
"tauri",
"tauri-build",
"tauri-plugin-opener",
-1
View File
@@ -6,4 +6,3 @@ title = "Rayhunter - An IMSI Catcher Catcher"
[output.html]
edit-url-template = "https://github.com/efforg/rayhunter/edit/main/{path}"
additional-css = ["doc/custom.css"]
-4
View File
@@ -20,8 +20,6 @@ pub struct Config {
pub ntfy_url: Option<String>,
pub enabled_notifications: Vec<NotificationType>,
pub analyzers: AnalyzerConfig,
pub min_space_to_start_recording_mb: u64,
pub min_space_to_continue_recording_mb: u64,
}
impl Default for Config {
@@ -37,8 +35,6 @@ impl Default for Config {
analyzers: AnalyzerConfig::default(),
ntfy_url: None,
enabled_notifications: vec![NotificationType::Warning, NotificationType::LowBattery],
min_space_to_start_recording_mb: 1,
min_space_to_continue_recording_mb: 1,
}
}
}
+32 -181
View File
@@ -27,15 +27,10 @@ use crate::display;
use crate::notifications::{Notification, NotificationType};
use crate::qmdl_store::{RecordingStore, RecordingStoreError};
use crate::server::ServerState;
use crate::stats::DiskStats;
const DISK_CHECK_BYTES_INTERVAL: usize = 256 * 1024;
pub enum DiagDeviceCtrlMessage {
StopRecording,
StartRecording {
response_tx: Option<oneshot::Sender<Result<(), String>>>,
},
StartRecording,
DeleteEntry {
name: String,
response_tx: oneshot::Sender<Result<(), RecordingStoreError>>,
@@ -51,12 +46,8 @@ pub struct DiagTask {
analysis_sender: Sender<AnalysisCtrlMessage>,
analyzer_config: AnalyzerConfig,
notification_channel: tokio::sync::mpsc::Sender<Notification>,
min_space_to_start_mb: u64,
min_space_to_continue_mb: u64,
state: DiagState,
max_type_seen: EventType,
bytes_since_space_check: usize,
low_space_warned: bool,
}
enum DiagState {
@@ -67,99 +58,36 @@ enum DiagState {
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 {
fn new(
ui_update_sender: Sender<display::DisplayState>,
analysis_sender: Sender<AnalysisCtrlMessage>,
analyzer_config: AnalyzerConfig,
notification_channel: tokio::sync::mpsc::Sender<Notification>,
min_space_to_start_mb: u64,
min_space_to_continue_mb: u64,
) -> Self {
Self {
ui_update_sender,
analysis_sender,
analyzer_config,
notification_channel,
min_space_to_start_mb,
min_space_to_continue_mb,
state: DiagState::Stopped,
max_type_seen: EventType::Informational,
bytes_since_space_check: 0,
low_space_warned: false,
}
}
/// Start recording, returning an error if disk space is too low.
async fn start(&mut self, qmdl_store: &mut RecordingStore) -> Result<(), String> {
/// Start recording
async fn start(&mut self, qmdl_store: &mut RecordingStore) {
self.max_type_seen = EventType::Informational;
self.bytes_since_space_check = 0;
self.low_space_warned = false;
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);
}
};
let (qmdl_file, analysis_file) = qmdl_store
.new_entry()
.await
.expect("failed creating QMDL file entry");
self.stop_current_recording().await;
let qmdl_writer = QmdlWriter::new(qmdl_file);
let analysis_writer = match AnalysisWriter::new(analysis_file, &self.analyzer_config).await
{
Ok(writer) => Box::new(writer),
Err(e) => {
let msg = format!("failed to create analysis writer: {e}");
error!("{msg}");
return Err(msg);
}
};
let analysis_writer = AnalysisWriter::new(analysis_file, &self.analyzer_config)
.await
.map(Box::new)
.expect("failed to write to analysis file");
self.state = DiagState::Recording {
qmdl_writer,
analysis_writer,
@@ -171,17 +99,11 @@ impl DiagTask {
{
warn!("couldn't send ui update message: {e}");
}
Ok(())
}
/// Stop recording, optionally annotating the entry with a reason.
async fn stop(&mut self, qmdl_store: &mut RecordingStore, reason: Option<String>) {
/// Stop recording
async fn stop(&mut self, qmdl_store: &mut RecordingStore) {
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()
&& let Err(e) = self
.analysis_sender
@@ -210,7 +132,7 @@ impl DiagTask {
name: &str,
) -> Result<(), RecordingStoreError> {
if qmdl_store.is_current_entry(name) {
self.stop(qmdl_store, None).await;
self.stop(qmdl_store).await;
}
let res = qmdl_store.delete_entry(name).await;
if let Err(e) = res.as_ref() {
@@ -223,7 +145,7 @@ impl DiagTask {
&mut self,
qmdl_store: &mut RecordingStore,
) -> Result<(), RecordingStoreError> {
self.stop(qmdl_store, None).await;
self.stop(qmdl_store).await;
let res = qmdl_store.delete_all_entries().await;
if let Err(e) = res.as_ref() {
error!("Error deleting QMDL entries {e}");
@@ -261,56 +183,10 @@ impl DiagTask {
analysis_writer,
} = &mut self.state
{
if self.bytes_since_space_check >= DISK_CHECK_BYTES_INTERVAL {
self.bytes_since_space_check = 0;
match check_disk_space(
&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;
}
qmdl_writer
.write_container(&container)
.await
.expect("failed to write to QMDL writer");
debug!(
"total QMDL bytes written: {}, updating manifest...",
qmdl_writer.total_written
@@ -318,25 +194,15 @@ impl DiagTask {
let index = qmdl_store
.current_entry
.expect("DiagDevice had qmdl_writer, but QmdlStore didn't have current entry???");
if let Err(e) = qmdl_store
qmdl_store
.update_entry_qmdl_size(index, qmdl_writer.total_written)
.await
{
let reason = format!("failed to update manifest (disk full?): {e}");
error!("{reason}");
self.stop(qmdl_store, Some(reason)).await;
return;
}
.expect("failed to update qmdl file size");
debug!("done!");
let container_bytes: usize = container.messages.iter().map(|m| m.data.len()).sum();
self.bytes_since_space_check += container_bytes;
let max_type = match analysis_writer.analyze(container).await {
Ok(t) => t,
Err(e) => {
warn!("failed to analyze container: {e}");
EventType::Informational
}
};
let max_type = analysis_writer
.analyze(container)
.await
.expect("failed to analyze container");
if max_type > EventType::Informational {
info!("a heuristic triggered on this run!");
@@ -378,30 +244,25 @@ pub fn run_diag_read_thread(
analysis_sender: Sender<AnalysisCtrlMessage>,
analyzer_config: AnalyzerConfig,
notification_channel: tokio::sync::mpsc::Sender<Notification>,
min_space_to_start_mb: u64,
min_space_to_continue_mb: u64,
) {
task_tracker.spawn(async move {
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, min_space_to_start_mb, min_space_to_continue_mb);
let mut diag_task = DiagTask::new(ui_update_sender, analysis_sender, analyzer_config, notification_channel);
qmdl_file_tx
.send(DiagDeviceCtrlMessage::StartRecording { response_tx: None })
.send(DiagDeviceCtrlMessage::StartRecording)
.await
.unwrap();
loop {
tokio::select! {
msg = qmdl_file_rx.recv() => {
match msg {
Some(DiagDeviceCtrlMessage::StartRecording { response_tx }) => {
Some(DiagDeviceCtrlMessage::StartRecording) => {
let mut qmdl_store = qmdl_store_lock.write().await;
let result = diag_task.start(qmdl_store.deref_mut()).await;
if let Some(tx) = response_tx {
tx.send(result).ok();
}
diag_task.start(qmdl_store.deref_mut()).await;
},
Some(DiagDeviceCtrlMessage::StopRecording) => {
let mut qmdl_store = qmdl_store_lock.write().await;
diag_task.stop(qmdl_store.deref_mut(), None).await;
diag_task.stop(qmdl_store.deref_mut()).await;
},
// None means all the Senders have been dropped, so it's
// time to go
@@ -451,12 +312,9 @@ pub async fn start_recording(
return Err((StatusCode::FORBIDDEN, "server is in debug mode".to_string()));
}
let (response_tx, response_rx) = oneshot::channel();
state
.diag_device_ctrl_sender
.send(DiagDeviceCtrlMessage::StartRecording {
response_tx: Some(response_tx),
})
.send(DiagDeviceCtrlMessage::StartRecording)
.await
.map_err(|e| {
(
@@ -465,14 +323,7 @@ pub async fn start_recording(
)
})?;
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}"),
)),
}
Ok((StatusCode::ACCEPTED, "ok".to_string()))
}
/// Stop recording API for web thread
+2 -3
View File
@@ -81,9 +81,8 @@ pub fn run_key_input_thread(
{
error!("Failed to send StopRecording: {e}");
}
if let Err(e) = diag_tx
.send(DiagDeviceCtrlMessage::StartRecording { response_tx: None })
.await
if let Err(e) =
diag_tx.send(DiagDeviceCtrlMessage::StartRecording).await
{
error!("Failed to send StartRecording: {e}");
}
-2
View File
@@ -234,8 +234,6 @@ async fn run_with_config(
analysis_tx.clone(),
config.analyzers.clone(),
notification_service.new_handler(),
config.min_space_to_start_recording_mb,
config.min_space_to_continue_recording_mb,
);
info!("Starting UI");
-15
View File
@@ -54,8 +54,6 @@ pub struct ManifestEntry {
pub rayhunter_version: Option<String>,
pub system_os: Option<String>,
pub arch: Option<String>,
#[serde(default)]
pub stop_reason: Option<String>,
}
impl ManifestEntry {
@@ -70,7 +68,6 @@ impl ManifestEntry {
rayhunter_version: Some(metadata.rayhunter_version),
system_os: Some(metadata.system_os),
arch: Some(metadata.arch),
stop_reason: None,
}
}
@@ -200,7 +197,6 @@ impl RecordingStore {
rayhunter_version: None,
system_os: None,
arch: None,
stop_reason: None,
});
}
@@ -346,17 +342,6 @@ impl RecordingStore {
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 {
match self.current_entry {
Some(idx) => match self.manifest.entries.get(idx) {
+68 -32
View File
@@ -1,4 +1,3 @@
use std::ffi::CString;
use std::sync::Arc;
use crate::battery::get_battery_status;
@@ -10,10 +9,53 @@ use axum::Json;
use axum::extract::State;
use axum::http::StatusCode;
use log::error;
use rayhunter::gsmtap_parser::get_cached_cell_info;
use rayhunter::{Device, util::RuntimeMetadata};
use serde::Serialize;
use tokio::process::Command;
/// LTE cell/signal information from DIAG measurements.
/// All fields are optional since they may not be available on all devices
/// or may not have been received yet.
#[derive(Debug, Serialize)]
pub struct CellSignalInfo {
#[serde(skip_serializing_if = "Option::is_none")]
pub rsrp_dbm: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rsrq_db: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rssi_dbm: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pci: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub earfcn: Option<u32>,
}
/// Get cell/signal information for devices that support DIAG.
/// Returns None for devices without DIAG support or if no measurements available.
pub fn get_cell_info(device: &Device) -> Option<CellSignalInfo> {
match device {
// Devices with DIAG support
Device::Orbic | Device::Tplink | Device::Tmobile | Device::Wingtech => {
let info = get_cached_cell_info();
// Only return if we have at least some data
if info.rsrp_dbm.is_some() || info.pci.is_some() {
Some(CellSignalInfo {
rsrp_dbm: info.rsrp_dbm,
rsrq_db: info.rsrq_db,
rssi_dbm: info.rssi_dbm,
pci: info.pci,
earfcn: info.earfcn,
})
} else {
None
}
}
// Devices without DIAG support
Device::Pinephone | Device::Uz801 => None,
}
}
#[derive(Debug, Serialize)]
pub struct SystemStats {
pub disk_stats: DiskStats,
@@ -21,12 +63,14 @@ pub struct SystemStats {
pub runtime_metadata: RuntimeMetadata,
#[serde(skip_serializing_if = "Option::is_none")]
pub battery_status: Option<BatteryState>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cell_info: Option<CellSignalInfo>,
}
impl SystemStats {
pub async fn new(qmdl_path: &str, device: &Device) -> Result<Self, String> {
Ok(Self {
disk_stats: DiskStats::new(qmdl_path)?,
disk_stats: DiskStats::new(qmdl_path, device).await?,
memory_stats: MemoryStats::new(device).await?,
runtime_metadata: RuntimeMetadata::new(),
battery_status: match get_battery_status(device).await {
@@ -37,6 +81,7 @@ impl SystemStats {
None
}
},
cell_info: get_cell_info(device),
})
}
}
@@ -49,42 +94,33 @@ pub struct DiskStats {
available_size: String,
used_percent: String,
mounted_on: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub available_bytes: Option<u64>,
}
impl DiskStats {
#[allow(clippy::unnecessary_cast)] // c_ulong is u32 on ARM, u64 on macOS
pub fn new(qmdl_path: &str) -> Result<Self, String> {
let c_path =
CString::new(qmdl_path).map_err(|e| format!("invalid path {qmdl_path}: {e}"))?;
let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
if unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) } != 0 {
return Err(format!(
"statvfs({qmdl_path}) failed: {}",
std::io::Error::last_os_error()
));
}
let block_size = stat.f_frsize as u64;
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)
// runs "df -h <qmdl_path>" to get storage statistics for the partition containing
// the QMDL file.
pub async fn new(qmdl_path: &str, device: &Device) -> Result<Self, String> {
// Uz801 needs to be told to use the busybox df specifically
let mut df_cmd: Command;
if matches!(device, Device::Uz801) {
df_cmd = Command::new("busybox");
df_cmd.arg("df");
} else {
"0%".to_string()
};
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 mut parts = stdout.split_whitespace().skip(7);
Ok(Self {
partition: qmdl_path.to_string(),
total_size: humanize_kb(total_kb),
used_size: humanize_kb(used_kb),
available_size: humanize_kb(available_kb),
used_percent,
mounted_on: qmdl_path.to_string(),
available_bytes: Some(stat.f_bavail as u64 * block_size),
partition: parts.next().ok_or("error parsing df output")?.to_string(),
total_size: parts.next().ok_or("error parsing df output")?.to_string(),
used_size: parts.next().ok_or("error parsing df output")?.to_string(),
available_size: parts.next().ok_or("error parsing df output")?.to_string(),
used_percent: parts.next().ok_or("error parsing df output")?.to_string(),
mounted_on: parts.next().ok_or("error parsing df output")?.to_string(),
})
}
}
@@ -22,26 +22,10 @@
<p>Error getting analysis report: {entry.analysis_report}</p>
{:else}
{@const metadata: ReportMetadata = entry.analysis_report.metadata}
{@const numWarnings: number = entry.get_num_warnings() || 0}
<div class="flex flex-col gap-2">
{#if !!numWarnings || !current}
<div class="flex flex-row justify-between items-center">
{#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}
{#if !current}
<div class="flex flex-row justify-end items-center">
<ReAnalyzeButton {entry} {manager} />
</div>
{/if}
{#if entry.analysis_report.rows.length > 0}
@@ -241,48 +241,6 @@
</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">
<h3 class="text-lg font-semibold text-gray-800 mb-4">
Analyzer Heuristic Settings
@@ -81,11 +81,6 @@
'N/A'}</span
>
</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">
<DownloadLink url={entry.get_pcap_url()} text="pcap" full_button />
<DownloadLink url={entry.get_qmdl_url()} text="qmdl" full_button />
@@ -33,6 +33,23 @@
}
return text;
});
// Cell signal quality assessment based on RSRP
// Excellent: >= -80 dBm, Good: -80 to -90, Fair: -90 to -100, Poor: < -100
let signal_quality = $derived.by(() => {
const rsrp = stats.cell_info?.rsrp_dbm;
if (rsrp === undefined) return { label: 'Unknown', color: 'text-gray-500' };
if (rsrp >= -80) return { label: 'Excellent', color: 'text-green-600' };
if (rsrp >= -90) return { label: 'Good', color: 'text-green-600' };
if (rsrp >= -100) return { label: 'Fair', color: 'text-yellow-600' };
return { label: 'Poor', color: 'text-red-600' };
});
// Format signal value with unit
function format_signal(value: number | undefined, unit: string): string {
if (value === undefined) return '—';
return `${value.toFixed(1)} ${unit}`;
}
</script>
<div
@@ -116,6 +133,46 @@
</svg>
</td>
</tr>
{#if stats.cell_info}
<tr class="border-b">
<th class={table_cell_classes}> Cell Signal </th>
<td class={table_cell_classes}>
<div class="flex flex-col gap-1 text-sm">
<div class="flex items-center gap-2">
<span class="font-medium {signal_quality.color}">
{signal_quality.label}
</span>
{#if stats.cell_info.rsrp_dbm !== undefined}
<span class="text-gray-600">
(RSRP: {format_signal(stats.cell_info.rsrp_dbm, 'dBm')})
</span>
{/if}
</div>
<div class="text-gray-600 text-xs">
{#if stats.cell_info.pci !== undefined}
<span>PCI: {stats.cell_info.pci}</span>
{/if}
{#if stats.cell_info.earfcn !== undefined}
<span class="ml-2">EARFCN: {stats.cell_info.earfcn}</span>
{/if}
{#if stats.cell_info.rsrq_db !== undefined}
<span class="ml-2"
>RSRQ: {format_signal(stats.cell_info.rsrq_db, 'dB')}</span
>
{/if}
{#if stats.cell_info.rssi_dbm !== undefined}
<span class="ml-2"
>RSSI: {format_signal(
stats.cell_info.rssi_dbm,
'dBm'
)}</span
>
{/if}
</div>
</div>
</td>
</tr>
{/if}
</tbody>
</table>
</div>
-5
View File
@@ -11,7 +11,6 @@ interface JsonManifestEntry {
start_time: string;
last_message_time: string;
qmdl_size_bytes: number;
stop_reason: string | null;
}
export class Manifest {
@@ -58,7 +57,6 @@ export class ManifestEntry {
public analysis_size_bytes = $state(0);
public analysis_status: AnalysisStatus | undefined = $state(undefined);
public analysis_report: AnalysisReport | string | undefined = $state(undefined);
public stop_reason: string | undefined = $state(undefined);
constructor(json: JsonManifestEntry) {
this.name = json.name;
@@ -67,9 +65,6 @@ export class ManifestEntry {
if (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 {
+9 -1
View File
@@ -3,6 +3,7 @@ export interface SystemStats {
memory_stats: MemoryStats;
runtime_metadata: RuntimeMetadata;
battery_status?: BatteryStatus;
cell_info?: CellSignalInfo;
}
export interface RuntimeMetadata {
@@ -18,7 +19,6 @@ export interface DiskStats {
available_size: string;
used_percent: string;
mounted_on: string;
available_bytes?: number;
}
export interface MemoryStats {
@@ -31,3 +31,11 @@ export interface BatteryStatus {
level: number;
is_plugged_in: boolean;
}
export interface CellSignalInfo {
rsrp_dbm?: number;
rsrq_db?: number;
rssi_dbm?: number;
pci?: number;
earfcn?: number;
}
-2
View File
@@ -25,8 +25,6 @@ export interface Config {
ntfy_url: string;
enabled_notifications: enabled_notifications[];
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> {
+1 -1
View File
@@ -5,7 +5,7 @@ export default defineConfig({
server: {
proxy: {
'/api': {
target: process.env.API_TARGET || 'http://localhost:8080',
target: 'http://localhost:8080',
changeOrigin: true,
secure: false,
configure: (proxy, _options) => {
-6
View File
@@ -28,12 +28,6 @@ ntfy_url = ""
# What notification types to enable. Does nothing if the above ntfy_url is not set.
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
# Enable/disable specific IMSI catcher detection heuristics
# See https://github.com/EFForg/rayhunter/blob/main/doc/heuristics.md for details
-6
View File
@@ -1,6 +0,0 @@
.warning-box {
padding: 0.75em 1em;
border-left: 4px solid #e33;
border-radius: 4px;
background-color: color-mix(in srgb, currentColor 10%, transparent);
}
+45 -60
View File
@@ -1,78 +1,63 @@
# Installing from source
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).
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).
At a high level, we have:
* Install [nodejs/npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm), which is required to build Rayhunter's web UI
* 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.
* 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 targets
It's recommended to work either on Mac/Linux, or WSL on Windows.
## Building frontend and backend
First, install dependencies:
- [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:
[Install Rust the usual way](https://www.rust-lang.org/tools/install). Then,
- install the cross-compilation target for the device Rayhunter will run on:
```sh
./scripts/build-dev.sh
./scripts/install-dev.sh orbic # replace 'orbic' with your device type
rustup target add armv7-unknown-linux-musleabihf
```
## Hot-reloading the frontend
If you are working on the frontend, you normally have to repeat all of the above steps everytime to see a change.
You can instead run the frontend separately on your PC while the Rust parts
continue running on your target device:
- install the statically compiled target for your host machine to build the binary installer `serial`.
```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
# 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
```
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:
Now you can root your device and install Rayhunter by running:
```sh
adb kill-server
# Build the daemon binary for local development (rustcrypto TLS backend, fast compilation)
# WARNING: The rustcrypto library, though not known to be insecure, is less well
# 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
# Enables ADB on either of these devices
./scripts/install-dev.sh util tmobile-start-adb
./scripts/install-dev.sh orbic-usb
# 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
adb shell
# 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
```
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.
### If you're on Windows or can't run the install scripts
* Root your device on Windows using the instructions here: <https://xdaforums.com/t/resetting-verizon-orbic-speed-rc400l-firmware-flash-kajeet.4334899/#post-87855183>
* 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 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.
* 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!
+122
View File
@@ -0,0 +1,122 @@
# LTE ML1 Serving Cell Measurement (0xB193)
This document describes the Qualcomm DIAG log code 0xB193 (LTE ML1 Serving Cell Measurement Response), which provides detailed LTE signal strength measurements including RSRP, RSRQ, and RSSI.
## Overview
Log code 0xB193 (`LOG_LTE_ML1_SERVING_CELL_MEAS_RESPONSE`) is emitted by the Qualcomm modem's Layer 1 (ML1) component and contains periodic measurements of the serving cell's signal characteristics. Rayhunter captures these measurements and includes the RSRP value in GSMTAP headers for PCAP output.
## Packet Structure
The 0xB193 log uses a subpacket architecture common to many Qualcomm DIAG logs:
```
+------------------+
| Main Header | 4 bytes
+------------------+
| Subpacket Header | 4 bytes
+------------------+
| Subpacket Data | Variable (version-dependent)
+------------------+
```
### Main Header (4 bytes)
| Offset | Size | Field | Description |
|--------|------|-----------------|---------------------------------------|
| 0 | 1 | main_version | Main packet version (observed: 1) |
| 1 | 1 | num_subpackets | Number of subpackets (typically 1) |
| 2 | 2 | reserved | Reserved/padding |
### Subpacket Header (4 bytes)
| Offset | Size | Field | Description |
|--------|------|-------------------|-------------------------------------|
| 0 | 1 | subpacket_id | Subpacket identifier |
| 1 | 1 | subpacket_version | Subpacket version (see below) |
| 2 | 2 | subpacket_size | Size of subpacket including header |
### Known Subpacket Versions
Different modem firmware versions emit different subpacket versions. The field offsets within the subpacket data vary by version:
| Version | PCI Offset | EARFCN Offset | RSRP Offset | Notes |
|---------|------------|---------------|-------------|--------------------------|
| 4 | 0 | 2 | 12 | Early format (SCAT) |
| 7 | 0 | 4 | 14 | Intermediate format |
| 18-24 | 0 | 4 | 24 | Common on Orbic RC400L |
| 35-40 | 0 | 4 | 28 | Newer modems |
The Orbic RC400L device used for development emits **subpacket version 18**.
## Signal Measurement Fields
### RSRP (Reference Signal Received Power)
RSRP is the primary signal strength indicator for LTE. The raw 12-bit value is converted to dBm:
```
RSRP (dBm) = -180.0 + (raw_value & 0xFFF) * 0.0625
```
Typical range: -140 dBm (very weak) to -44 dBm (very strong)
### PCI (Physical Cell ID)
The Physical Cell ID identifies the serving cell. Stored as a 16-bit little-endian value at the PCI offset.
Range: 0-503
### EARFCN (E-UTRA Absolute Radio Frequency Channel Number)
The EARFCN identifies the carrier frequency. Stored as a 32-bit little-endian value at the EARFCN offset.
## Implementation Notes
1. **Caching Strategy**: Since 0xB193 messages arrive independently from RRC OTA messages, rayhunter caches the most recent RSRP value and applies it to subsequent GSMTAP headers.
2. **Signal Conversion**: The `signal_dbm` field in GSMTAP headers is an `i8`, so the RSRP value is clamped to the range -128 to 0 dBm.
3. **Version Detection**: The subpacket version determines field offsets. Unknown versions fall back to the v7 layout.
## References
### SCAT (Signaling Collection and Analysis Tool)
The [SCAT project](https://github.com/fgsect/scat) by the Firmware Security (fgsect) research group at TU Berlin provides Qualcomm DIAG log parsers.
Relevant file: `parsers/qualcomm/diagltelogparser.py`
```python
# SCAT v4/v5 parser structure (simplified)
# pci = struct.unpack('<H', payload[0:2])
# earfcn = struct.unpack('<H', payload[2:4]) # or <L for 32-bit
# rsrp_raw = struct.unpack('<L', payload[offset:offset+4])
```
### Mobile Insight
The [Mobile Insight project](https://github.com/mobile-insight/mobileinsight-core) from UCLA WiNG Lab provides comprehensive Qualcomm DIAG parsing with extensive version support.
Relevant file: `mobile_insight/analyzer/msg_logger.py` and related LTE analyzers
Mobile Insight documents subpacket versions 4, 7, 18, 19, 22, 24, 35, 36, and 40, with version-specific field layouts.
### QCSuper
The [QCSuper project](https://github.com/P1sec/QCSuper) by P1 Security provides another implementation of Qualcomm DIAG protocol handling.
### 3GPP Specifications
- **3GPP TS 36.214**: Physical layer measurements (defines RSRP, RSRQ, RSSI)
- **3GPP TS 36.133**: Requirements for support of radio resource management
## Example Output
When rayhunter captures a 0xB193 log, debug output shows:
```
ML1 0xB193 v18: RSRP=-94.8dBm, PCI=446, EARFCN=975
```
The corresponding GSMTAP packets in Wireshark will display `Signal dBm: -95` (rounded to i8).
-10
View File
@@ -5,16 +5,6 @@ Supported in Rayhunter since version 0.6.0.
The Moxee Hotspot is a device very similar to the Orbic RC400L. It seems to be
primarily for the US market.
<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)
- [Moxee product page](https://www.moxee.com/hotspot)
-1
View File
@@ -21,5 +21,4 @@ tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1.0.100"
shlex = "1"
installer = { path = "../../installer" }
+2 -3
View File
@@ -1,11 +1,10 @@
use anyhow::Context;
use tauri::Emitter;
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 || {
installer::run_with_callback(
args_vec.iter().map(|s| s.as_str()),
// TODO: we should split using something similar to shlex in python
args.split_whitespace(),
Some(Box::new(move |output| {
app_handle
.emit("installer-output", output)
-3
View File
@@ -81,9 +81,6 @@
<input
class="mr-1 px-5 py-2 rounded-lg shadow-md"
placeholder="Enter CLI installer args..."
autocapitalize="off"
autocorrect="off"
spellcheck="false"
bind:value={installerArgs}
/>
<button
+1 -8
View File
@@ -4,14 +4,7 @@ use std::process::exit;
fn main() {
println!("cargo::rerun-if-env-changed=NO_FIRMWARE_BIN");
println!("cargo::rerun-if-env-changed=FIRMWARE_PROFILE");
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 profile = std::env::var("FIRMWARE_PROFILE").unwrap_or_else(|_| "firmware".to_string());
let include_dir = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../target/armv7-unknown-linux-musleabihf")
.join(&profile);
+1 -4
View File
@@ -97,10 +97,7 @@ async fn login_and_exploit(admin_ip: &str, username: &str, password: &str) -> Re
.context("Failed to parse login response")?;
if login_result.retcode != 0 {
match login_result.retcode {
201 => bail!("Login failed: incorrect password"),
code => bail!("Login failed with retcode: {}", code),
}
bail!("Login failed with retcode: {}", login_result.retcode);
}
// Step 4: Exploit using authenticated session
+915
View File
@@ -0,0 +1,915 @@
//! Diag protocol serialization/deserialization
use chrono::{DateTime, FixedOffset};
use crc::{Algorithm, Crc};
use deku::prelude::*;
use crate::hdlc::{self, hdlc_decapsulate};
use log::warn;
use thiserror::Error;
pub const MESSAGE_TERMINATOR: u8 = 0x7e;
pub const MESSAGE_ESCAPE_CHAR: u8 = 0x7d;
pub const ESCAPED_MESSAGE_TERMINATOR: u8 = 0x5e;
pub const ESCAPED_MESSAGE_ESCAPE_CHAR: u8 = 0x5d;
#[derive(Debug, Clone, DekuWrite)]
pub struct RequestContainer {
pub data_type: DataType,
#[deku(skip)]
pub use_mdm: bool,
#[deku(skip, cond = "!*use_mdm")]
pub mdm_field: i32,
pub hdlc_encapsulated_request: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, DekuWrite)]
#[deku(id_type = "u32")]
pub enum Request {
#[deku(id = "115")]
LogConfig(LogConfigRequest),
}
#[derive(Debug, Clone, PartialEq, DekuWrite)]
#[deku(id_type = "u32", endian = "little")]
pub enum LogConfigRequest {
#[deku(id = "1")]
RetrieveIdRanges,
#[deku(id = "3")]
SetMask {
log_type: u32,
log_mask_bitsize: u32,
log_mask: Vec<u8>,
},
}
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
#[deku(id_type = "u32", endian = "little")]
pub enum DataType {
#[deku(id = "32")]
UserSpace,
#[deku(id_pat = "_")]
Other(u32),
}
#[derive(Debug, Clone, PartialEq, Error)]
pub enum DiagParsingError {
#[error("Failed to parse Message: {0}, data: {1:?}")]
MessageParsingError(deku::DekuError, Vec<u8>),
#[error("HDLC decapsulation of message failed: {0}, data: {1:?}")]
HdlcDecapsulationError(hdlc::HdlcError, Vec<u8>),
}
// this is sorta based on the params qcsuper uses, plus what seems to be used in
// https://github.com/fgsect/scat/blob/f1538b397721df3ab8ba12acd26716abcf21f78b/util.py#L47
pub const CRC_CCITT_ALG: Algorithm<u16> = Algorithm {
poly: 0x1021,
init: 0xffff,
refin: true,
refout: true,
width: 16,
xorout: 0xffff,
check: 0x2189,
residue: 0x0000,
};
pub const CRC_CCITT: Crc<u16> = Crc::<u16>::new(&CRC_CCITT_ALG);
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
pub struct MessagesContainer {
pub data_type: DataType,
pub num_messages: u32,
#[deku(count = "num_messages")]
pub messages: Vec<HdlcEncapsulatedMessage>,
}
impl MessagesContainer {
pub fn into_messages(self) -> Vec<Result<Message, DiagParsingError>> {
let mut result = Vec::new();
for msg in self.messages {
for sub_msg in msg.data.split_inclusive(|&b| b == MESSAGE_TERMINATOR) {
match hdlc_decapsulate(sub_msg, &CRC_CCITT) {
Ok(data) => match Message::from_bytes((&data, 0)) {
Ok(((leftover_bytes, _), res)) => {
if !leftover_bytes.is_empty() {
warn!(
"warning: {} leftover bytes when parsing Message",
leftover_bytes.len()
);
}
result.push(Ok(res));
}
Err(e) => result.push(Err(DiagParsingError::MessageParsingError(e, data))),
},
Err(err) => result.push(Err(DiagParsingError::HdlcDecapsulationError(
err,
sub_msg.to_vec(),
))),
}
}
}
result
}
}
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
pub struct HdlcEncapsulatedMessage {
pub len: u32,
#[deku(count = "len")]
pub data: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
#[deku(id_type = "u8")]
pub enum Message {
#[deku(id = "16")]
Log {
pending_msgs: u8,
outer_length: u16,
inner_length: u16,
log_type: u16,
timestamp: Timestamp,
// pass the log type and log length (inner_length - (sizeof(log_type) + sizeof(timestamp)))
#[deku(ctx = "*log_type, inner_length.saturating_sub(12)")]
body: LogBody,
},
// kinda unpleasant deku hackery here. deku expects an enum's variant to be
// right before its data, but in this case, a status value comes between the
// variants and the data. so we need to use deku's context (ctx) feature to
// pass those opcodes down to their respective parsers.
#[deku(id_pat = "_")]
Response {
opcode1: u8, // the "id" (from deku's POV) gets parsed into this field
opcode2: u8,
opcode3: u8,
opcode4: u8,
subopcode: u32,
status: u32,
#[deku(ctx = "u32::from_le_bytes([*opcode1, *opcode2, *opcode3, *opcode4]), *subopcode")]
payload: ResponsePayload,
},
}
#[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>,
},
/// LTE ML1 Serving Cell Measurement Response (0xB193)
/// Contains RSRP, RSRQ, and RSSI measurements for the serving cell.
/// This is used to populate signal strength in GSMTAP headers.
#[deku(id = "0xb193")]
LteMl1ServingCellMeas { meas: LteMl1ServingCellMeasData },
}
#[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,
}
}
}
/// LTE ML1 Serving Cell Measurement (0xB193) packet structure.
/// Uses subpacket architecture per Mobile Insight / Qualcomm DIAG format.
///
/// Packet layout:
/// - Main Header: version (1) + num_subpackets (1) + reserved (2) = 4 bytes
/// - SubPacket Header: id (1) + version (1) + size (2) = 4 bytes
/// - SubPacket Data: varies by subpacket version (v4, v7, v18, v19, v22, v24, v35, v36, v40)
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
#[deku(endian = "little")]
pub struct LteMl1ServingCellMeasData {
pub main_version: u8,
pub num_subpackets: u8,
pub reserved: u16,
// SubPacket header
pub subpacket_id: u8,
pub subpacket_version: u8,
pub subpacket_size: u16,
// SubPacket data - we read enough to get RSRP/RSRQ/RSSI
// The actual layout depends on subpacket_version, but EARFCN and PCI are always first
#[deku(count = "subpacket_size.saturating_sub(4).min(128)")]
pub subpacket_data: Vec<u8>,
}
impl LteMl1ServingCellMeasData {
/// Helper to read a u16 from subpacket data at given offset
fn read_u16(&self, offset: usize) -> Option<u16> {
if offset + 2 <= self.subpacket_data.len() {
Some(u16::from_le_bytes([
self.subpacket_data[offset],
self.subpacket_data[offset + 1],
]))
} else {
None
}
}
/// Helper to read a u32 from subpacket data at given offset
fn read_u32(&self, offset: usize) -> Option<u32> {
if offset + 4 <= self.subpacket_data.len() {
Some(u32::from_le_bytes([
self.subpacket_data[offset],
self.subpacket_data[offset + 1],
self.subpacket_data[offset + 2],
self.subpacket_data[offset + 3],
]))
} else {
None
}
}
/// Get the RSRP field offset based on subpacket version
/// Returns (earfcn_offset, earfcn_size, rsrp_offset)
fn get_offsets(&self) -> (usize, usize, usize) {
match self.subpacket_version {
// v4: EARFCN(2) + PCI(2) + SFN(2) + skip(6) = offset 12 for RSRP
4 => (0, 2, 12),
// v7: EARFCN(4) + PCI(2) + SFN(2) + skip(6) = offset 14 for RSRP
7 => (0, 4, 14),
// v18+: more complex, estimate based on structure
// EARFCN(4) + PCI(2) + ... + skip = ~24-34 for RSRP
18..=24 => (0, 4, 24),
// v35+: 4-antenna support, larger structure
35..=40 => (0, 4, 28),
// Unknown version, try v7 offsets
_ => (0, 4, 14),
}
}
/// Get Physical Cell ID from measurement
pub fn get_pci(&self) -> Option<u16> {
let (earfcn_offset, earfcn_size, _) = self.get_offsets();
let pci_offset = earfcn_offset + earfcn_size;
self.read_u16(pci_offset).map(|v| v & 0x1FF)
}
/// Get EARFCN from measurement
pub fn get_earfcn(&self) -> Option<u32> {
let (earfcn_offset, earfcn_size, _) = self.get_offsets();
if earfcn_size == 2 {
self.read_u16(earfcn_offset).map(|v| v as u32)
} else {
self.read_u32(earfcn_offset)
}
}
/// Get RSRP (Reference Signal Received Power) in dBm.
/// Formula: -180 + raw_value * 0.0625
pub fn get_rsrp_dbm(&self) -> Option<f32> {
let (_, _, rsrp_offset) = self.get_offsets();
self.read_u32(rsrp_offset).map(|raw| {
let rsrp_raw = raw & 0xFFF;
-180.0 + (rsrp_raw as f32) * 0.0625
})
}
/// Get RSSI (Received Signal Strength Indicator) in dBm.
/// Formula: -110 + raw_value * 0.0625
/// RSSI is typically 12 bytes after RSRP (RSRP + avg_RSRP + RSRQ)
pub fn get_rssi_dbm(&self) -> Option<f32> {
let (_, _, rsrp_offset) = self.get_offsets();
let rssi_offset = rsrp_offset + 12; // Skip RSRP(4) + avg_RSRP(4) + RSRQ(4)
self.read_u32(rssi_offset).map(|raw| {
let rssi_raw = (raw >> 10) & 0x7FF;
-110.0 + (rssi_raw as f32) * 0.0625
})
}
/// Get RSRQ (Reference Signal Received Quality) in dB.
/// Formula: -30 + raw_value * 0.0625
pub fn get_rsrq_db(&self) -> Option<f32> {
let (_, _, rsrp_offset) = self.get_offsets();
let rsrq_offset = rsrp_offset + 8; // Skip RSRP(4) + avg_RSRP(4)
self.read_u32(rsrq_offset).map(|raw| {
let rsrq_raw = raw & 0x3FF;
-30.0 + (rsrq_raw as f32) * 0.0625
})
}
/// Get signal strength as i8 for GSMTAP header (clamped to valid range).
/// Uses RSRP as the primary signal indicator.
pub fn get_signal_dbm_i8(&self) -> Option<i8> {
self.get_rsrp_dbm()
.map(|rsrp| rsrp.clamp(-128.0, 127.0) as i8)
}
}
#[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)]
#[deku(ctx = "opcode: u32, subopcode: u32", id = "opcode")]
pub enum ResponsePayload {
#[deku(id = "115")]
LogConfig(#[deku(ctx = "subopcode")] LogConfigResponse),
}
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
#[deku(ctx = "subopcode: u32", id = "subopcode")]
pub enum LogConfigResponse {
#[deku(id = "1")]
RetrieveIdRanges { log_mask_sizes: [u32; 16] },
#[deku(id = "3")]
SetMask,
}
pub fn build_log_mask_request(
log_type: u32,
log_mask_bitsize: u32,
accepted_log_codes: &[u32],
) -> Request {
let mut current_byte: u8 = 0;
let mut num_bits_written: u8 = 0;
let mut log_mask: Vec<u8> = vec![];
for i in 0..log_mask_bitsize {
let log_code: u32 = (log_type << 12) | i;
if accepted_log_codes.contains(&log_code) {
current_byte |= 1 << num_bits_written;
}
num_bits_written += 1;
if num_bits_written == 8 || i == log_mask_bitsize - 1 {
log_mask.push(current_byte);
current_byte = 0;
num_bits_written = 0;
}
}
Request::LogConfig(LogConfigRequest::SetMask {
log_type,
log_mask_bitsize,
log_mask,
})
}
#[cfg(test)]
mod test {
use super::*;
// Just about all of these test cases from manually parsing diag packets w/ QCSuper
#[test]
fn test_request_serialization() {
let req = Request::LogConfig(LogConfigRequest::RetrieveIdRanges);
assert_eq!(req.to_bytes().unwrap(), vec![115, 0, 0, 0, 1, 0, 0, 0]);
let req = Request::LogConfig(LogConfigRequest::SetMask {
log_type: 0,
log_mask_bitsize: 0,
log_mask: vec![],
});
assert_eq!(
req.to_bytes().unwrap(),
vec![115, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,]
);
}
#[test]
fn test_build_log_mask_request() {
let log_type = 11;
let bitsize = 513;
let req = build_log_mask_request(
log_type,
bitsize,
&crate::diag_device::LOG_CODES_FOR_RAW_PACKET_LOGGING,
);
// Expected mask includes:
// - 0xB0C0 (LTE RRC): byte 24 = 0x01
// - 0xB0E2, 0xB0E3, 0xB0EC, 0xB0ED (NAS): bytes 28-29 = 0x0C, 0x30
// - 0xB193 (ML1 Serving Cell Meas): byte 50 = 0x08 (bit 3 for code 0x193 = 403)
assert_eq!(
req,
Request::LogConfig(LogConfigRequest::SetMask {
log_type,
log_mask_bitsize: bitsize,
log_mask: vec![
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x1, 0x0, 0x0, 0x0, 0xc, 0x30, 0x0,
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
0x0, 0x0, 0x0, 0x8, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
0x0, 0x0,
],
})
);
}
#[test]
fn test_request_container() {
let req = RequestContainer {
data_type: DataType::UserSpace,
use_mdm: false,
mdm_field: -1,
hdlc_encapsulated_request: vec![1, 2, 3, 4],
};
assert_eq!(req.to_bytes().unwrap(), vec![32, 0, 0, 0, 1, 2, 3, 4,]);
let req = RequestContainer {
data_type: DataType::UserSpace,
use_mdm: true,
mdm_field: -1,
hdlc_encapsulated_request: vec![1, 2, 3, 4],
};
assert_eq!(
req.to_bytes().unwrap(),
vec![32, 0, 0, 0, 255, 255, 255, 255, 1, 2, 3, 4,]
);
}
#[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 {
MessagesContainer {
data_type,
num_messages: 1,
messages: vec![message],
}
}
// this log is based on one captured on a real device -- if it fails to
// serialize or deserialize, that's probably a problem with this mock, not
// the DiagReader implementation
fn get_test_message(payload: &[u8]) -> (HdlcEncapsulatedMessage, Message) {
let length_with_payload = 31 + payload.len() as u16;
let message = Message::Log {
pending_msgs: 0,
outer_length: length_with_payload,
inner_length: length_with_payload,
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: payload.len() as u16,
packet: payload.to_vec(),
},
},
};
let serialized = message
.to_bytes()
.expect("failed to serialize test message");
let encapsulated_data = hdlc::hdlc_encapsulate(&serialized, &CRC_CCITT);
let encapsulated = HdlcEncapsulatedMessage {
len: encapsulated_data.len() as u32,
data: encapsulated_data,
};
(encapsulated, message)
}
#[test]
fn test_containers_with_multiple_messages() {
let (encapsulated1, message1) = get_test_message(&[1]);
let (encapsulated2, message2) = get_test_message(&[2]);
let mut container = make_container(DataType::UserSpace, encapsulated1);
container.messages.push(encapsulated2);
container.num_messages += 1;
assert_eq!(container.into_messages(), vec![Ok(message1), Ok(message2)]);
}
#[test]
fn test_containers_with_concatenated_message() {
let (mut encapsulated1, message1) = get_test_message(&[1]);
let (encapsulated2, message2) = get_test_message(&[2]);
encapsulated1.data.extend(encapsulated2.data);
encapsulated1.len += encapsulated2.len;
let container = make_container(DataType::UserSpace, encapsulated1);
assert_eq!(container.into_messages(), vec![Ok(message1), Ok(message2)]);
}
#[test]
fn test_handles_parsing_errors() {
let (encapsulated1, message1) = get_test_message(&[1]);
let bad_message = hdlc::hdlc_encapsulate(&[0x01, 0x02, 0x03, 0x04], &CRC_CCITT);
let encapsulated2 = HdlcEncapsulatedMessage {
len: bad_message.len() as u32,
data: bad_message,
};
let mut container = make_container(DataType::UserSpace, encapsulated1);
container.messages.push(encapsulated2);
container.num_messages += 1;
let result = container.into_messages();
assert_eq!(result[0], Ok(message1));
assert!(matches!(
result[1],
Err(DiagParsingError::MessageParsingError(_, _))
));
}
#[test]
fn test_handles_encapsulation_errors() {
let (encapsulated1, message1) = get_test_message(&[1]);
let bad_encapsulation = HdlcEncapsulatedMessage {
len: 4,
data: vec![0x01, 0x02, 0x03, 0x04],
};
let mut container = make_container(DataType::UserSpace, encapsulated1);
container.messages.push(bad_encapsulation);
container.num_messages += 1;
let result = container.into_messages();
assert_eq!(result[0], Ok(message1));
assert!(matches!(
result[1],
Err(DiagParsingError::HdlcDecapsulationError(_, _))
));
}
#[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]
fn test_fuzz_crash_response_opcode_parsing() {
// Regression test: Upgrading to deku 0.20 caused incorrect parsing of Response messages.
// The issue was that deku 0.20 requires an `id` field for `id_pat = "_"` variants,
// but in deku 0.18 the discriminant was NOT consumed from the stream.
// This caused a 1-byte offset, making opcode and all subsequent fields misaligned.
// Fixed by splitting the opcode into 4 separate u8 fields so the discriminant byte
// becomes the first byte of the opcode, matching the old deku 0.18 behavior.
let response_msg = b"\x73\x00\x00\x00\x03\x00\x00\x00\x0a\x00\xec\xb0\x8e\x51\x02\x6f\x2a\xc5\x0b\x01\x01\x09\x05\x00\x07\x45\x8e\x14\x7d";
let ((rest, _), msg) = Message::from_bytes((response_msg, 0)).unwrap();
// Verify the opcode is correctly parsed as 115 (0x73 in first byte)
// In little-endian: [0x73, 0x00, 0x00, 0x00] = 0x00000073 = 115
assert!(
matches!(
msg,
Message::Response {
opcode1: 0x73,
opcode2: 0x00,
opcode3: 0x00,
opcode4: 0x00,
subopcode: 3,
status: 2968256522, // [0x0a, 0x00, 0xec, 0xb0] in LE
payload: ResponsePayload::LogConfig(LogConfigResponse::SetMask),
}
),
"Unexpected message: {:?}",
msg
);
// Verify we consumed the expected number of bytes
assert_eq!(rest.len(), 17);
}
#[test]
fn test_lte_ml1_serving_cell_meas_parsing() {
// Test parsing of 0xB193 LTE ML1 Serving Cell Measurement log
// with subpacket version 18 (common on Orbic RC400L)
//
// Structure:
// - Log message header (type=16, log_type=0xB193)
// - LteMl1ServingCellMeasData with v18 subpacket containing RSRP=-95dBm
//
// RSRP calculation: -180 + (raw & 0xFFF) * 0.0625
// For -95 dBm: raw = (-95 + 180) / 0.0625 = 1360 = 0x550
let mut msg_bytes: Vec<u8> = vec![
// Log message header
0x10, // Message type: Log (16)
0x00, // pending_msgs
0x38, 0x00, // outer_length: 56
0x34, 0x00, // inner_length: 52
0x93, 0xB1, // log_type: 0xB193 (LTE ML1 Serving Cell Meas)
// timestamp (8 bytes, arbitrary)
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
// LteMl1ServingCellMeasData
0x01, // main_version
0x01, // num_subpackets
0x00, 0x00, // reserved
0x00, // subpacket_id
0x12, // subpacket_version: 18
0x28, 0x00, // subpacket_size: 40 bytes (including header)
];
// Subpacket data (36 bytes = 40 - 4 for header)
// For v18: EARFCN at offset 0 (4 bytes), PCI at offset 4 (2 bytes), RSRP at offset 24
let mut subpacket_data = vec![0u8; 36];
// EARFCN = 975 at offset 0 (u32 LE)
subpacket_data[0..4].copy_from_slice(&975u32.to_le_bytes());
// PCI = 446 at offset 4 (u16 LE, only lower 9 bits used)
subpacket_data[4..6].copy_from_slice(&446u16.to_le_bytes());
// RSRP raw = 1360 (0x550) at offset 24 (u32 LE)
// This gives RSRP = -180 + 1360 * 0.0625 = -95 dBm
subpacket_data[24..28].copy_from_slice(&1360u32.to_le_bytes());
msg_bytes.extend(subpacket_data);
let ((rest, _), msg) = Message::from_bytes((&msg_bytes, 0)).unwrap();
assert_eq!(rest.len(), 0, "Should consume all bytes");
if let Message::Log {
log_type,
body: LogBody::LteMl1ServingCellMeas { meas },
..
} = msg
{
assert_eq!(log_type, 0xB193);
assert_eq!(meas.subpacket_version, 18);
// Verify RSRP extraction
let rsrp = meas.get_rsrp_dbm().expect("Should extract RSRP");
assert!(
(rsrp - (-95.0)).abs() < 0.1,
"RSRP should be -95 dBm, got {}",
rsrp
);
// Verify PCI extraction
let pci = meas.get_pci().expect("Should extract PCI");
assert_eq!(pci, 446);
// Verify EARFCN extraction
let earfcn = meas.get_earfcn().expect("Should extract EARFCN");
assert_eq!(earfcn, 975);
// Verify i8 conversion for GSMTAP header
let signal_dbm = meas.get_signal_dbm_i8().expect("Should get signal_dbm");
assert_eq!(signal_dbm, -95);
} else {
panic!("Expected LteMl1ServingCellMeas message, got {:?}", msg);
}
}
}
-351
View File
@@ -1,351 +0,0 @@
//! 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
@@ -1,206 +0,0 @@
//! 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
@@ -1,110 +0,0 @@
//! 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,
}
}
}
-411
View File
@@ -1,411 +0,0 @@
//! Diag protocol serialization/deserialization
use crc::{Algorithm, Crc};
use deku::prelude::*;
use crate::hdlc::{self, hdlc_decapsulate};
use log::warn;
use thiserror::Error;
pub mod diaglog;
use diaglog::{LogBody, Timestamp};
pub const MESSAGE_TERMINATOR: u8 = 0x7e;
pub const MESSAGE_ESCAPE_CHAR: u8 = 0x7d;
pub const ESCAPED_MESSAGE_TERMINATOR: u8 = 0x5e;
pub const ESCAPED_MESSAGE_ESCAPE_CHAR: u8 = 0x5d;
#[derive(Debug, Clone, DekuWrite)]
pub struct RequestContainer {
pub data_type: DataType,
#[deku(skip)]
pub use_mdm: bool,
#[deku(skip, cond = "!*use_mdm")]
pub mdm_field: i32,
pub hdlc_encapsulated_request: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, DekuWrite)]
#[deku(id_type = "u32")]
pub enum Request {
#[deku(id = "115")]
LogConfig(LogConfigRequest),
}
#[derive(Debug, Clone, PartialEq, DekuWrite)]
#[deku(id_type = "u32", endian = "little")]
pub enum LogConfigRequest {
#[deku(id = "1")]
RetrieveIdRanges,
#[deku(id = "3")]
SetMask {
log_type: u32,
log_mask_bitsize: u32,
log_mask: Vec<u8>,
},
}
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
#[deku(id_type = "u32", endian = "little")]
pub enum DataType {
#[deku(id = "32")]
UserSpace,
#[deku(id_pat = "_")]
Other(u32),
}
#[derive(Debug, Clone, PartialEq, Error)]
pub enum DiagParsingError {
#[error("Failed to parse Message: {0}, data: {1:?}")]
MessageParsingError(deku::DekuError, Vec<u8>),
#[error("HDLC decapsulation of message failed: {0}, data: {1:?}")]
HdlcDecapsulationError(hdlc::HdlcError, Vec<u8>),
}
// this is sorta based on the params qcsuper uses, plus what seems to be used in
// https://github.com/fgsect/scat/blob/f1538b397721df3ab8ba12acd26716abcf21f78b/util.py#L47
pub const CRC_CCITT_ALG: Algorithm<u16> = Algorithm {
poly: 0x1021,
init: 0xffff,
refin: true,
refout: true,
width: 16,
xorout: 0xffff,
check: 0x2189,
residue: 0x0000,
};
pub const CRC_CCITT: Crc<u16> = Crc::<u16>::new(&CRC_CCITT_ALG);
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
pub struct MessagesContainer {
pub data_type: DataType,
pub num_messages: u32,
#[deku(count = "num_messages")]
pub messages: Vec<HdlcEncapsulatedMessage>,
}
impl MessagesContainer {
pub fn into_messages(self) -> Vec<Result<Message, DiagParsingError>> {
let mut result = Vec::new();
for msg in self.messages {
for sub_msg in msg.data.split_inclusive(|&b| b == MESSAGE_TERMINATOR) {
match hdlc_decapsulate(sub_msg, &CRC_CCITT) {
Ok(data) => match Message::from_bytes((&data, 0)) {
Ok(((leftover_bytes, _), res)) => {
if !leftover_bytes.is_empty() {
warn!(
"warning: {} leftover bytes when parsing Message",
leftover_bytes.len()
);
}
result.push(Ok(res));
}
Err(e) => result.push(Err(DiagParsingError::MessageParsingError(e, data))),
},
Err(err) => result.push(Err(DiagParsingError::HdlcDecapsulationError(
err,
sub_msg.to_vec(),
))),
}
}
}
result
}
}
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
pub struct HdlcEncapsulatedMessage {
pub len: u32,
#[deku(count = "len")]
pub data: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
#[deku(id_type = "u8")]
pub enum Message {
#[deku(id = "16")]
Log {
pending_msgs: u8,
outer_length: u16,
inner_length: u16,
log_type: u16,
timestamp: Timestamp,
// pass the log type and log length (inner_length - (sizeof(log_type) + sizeof(timestamp)))
#[deku(ctx = "*log_type, inner_length.saturating_sub(12)")]
body: LogBody,
},
// kinda unpleasant deku hackery here. deku expects an enum's variant to be
// right before its data, but in this case, a status value comes between the
// variants and the data. so we need to use deku's context (ctx) feature to
// pass those opcodes down to their respective parsers.
#[deku(id_pat = "_")]
Response {
opcode1: u8, // the "id" (from deku's POV) gets parsed into this field
opcode2: u8,
opcode3: u8,
opcode4: u8,
subopcode: u32,
status: u32,
#[deku(ctx = "u32::from_le_bytes([*opcode1, *opcode2, *opcode3, *opcode4]), *subopcode")]
payload: ResponsePayload,
},
}
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
#[deku(ctx = "opcode: u32, subopcode: u32", id = "opcode")]
pub enum ResponsePayload {
#[deku(id = "115")]
LogConfig(#[deku(ctx = "subopcode")] LogConfigResponse),
}
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
#[deku(ctx = "subopcode: u32", id = "subopcode")]
pub enum LogConfigResponse {
#[deku(id = "1")]
RetrieveIdRanges { log_mask_sizes: [u32; 16] },
#[deku(id = "3")]
SetMask,
}
pub fn build_log_mask_request(
log_type: u32,
log_mask_bitsize: u32,
accepted_log_codes: &[u32],
) -> Request {
let mut current_byte: u8 = 0;
let mut num_bits_written: u8 = 0;
let mut log_mask: Vec<u8> = vec![];
for i in 0..log_mask_bitsize {
let log_code: u32 = (log_type << 12) | i;
if accepted_log_codes.contains(&log_code) {
current_byte |= 1 << num_bits_written;
}
num_bits_written += 1;
if num_bits_written == 8 || i == log_mask_bitsize - 1 {
log_mask.push(current_byte);
current_byte = 0;
num_bits_written = 0;
}
}
Request::LogConfig(LogConfigRequest::SetMask {
log_type,
log_mask_bitsize,
log_mask,
})
}
#[cfg(test)]
mod test {
use super::*;
// Just about all of these test cases from manually parsing diag packets w/ QCSuper
#[test]
fn test_request_serialization() {
let req = Request::LogConfig(LogConfigRequest::RetrieveIdRanges);
assert_eq!(req.to_bytes().unwrap(), vec![115, 0, 0, 0, 1, 0, 0, 0]);
let req = Request::LogConfig(LogConfigRequest::SetMask {
log_type: 0,
log_mask_bitsize: 0,
log_mask: vec![],
});
assert_eq!(
req.to_bytes().unwrap(),
vec![115, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,]
);
}
#[test]
fn test_build_log_mask_request() {
let log_type = 11;
let bitsize = 513;
let req = build_log_mask_request(
log_type,
bitsize,
&crate::diag_device::LOG_CODES_FOR_RAW_PACKET_LOGGING,
);
assert_eq!(
req,
Request::LogConfig(LogConfigRequest::SetMask {
log_type,
log_mask_bitsize: bitsize,
log_mask: vec![
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x1, 0x0, 0x0, 0x0, 0xc, 0x30, 0x0,
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
0x0, 0x0,
],
})
);
}
#[test]
fn test_request_container() {
let req = RequestContainer {
data_type: DataType::UserSpace,
use_mdm: false,
mdm_field: -1,
hdlc_encapsulated_request: vec![1, 2, 3, 4],
};
assert_eq!(req.to_bytes().unwrap(), vec![32, 0, 0, 0, 1, 2, 3, 4,]);
let req = RequestContainer {
data_type: DataType::UserSpace,
use_mdm: true,
mdm_field: -1,
hdlc_encapsulated_request: vec![1, 2, 3, 4],
};
assert_eq!(
req.to_bytes().unwrap(),
vec![32, 0, 0, 0, 255, 255, 255, 255, 1, 2, 3, 4,]
);
}
fn make_container(data_type: DataType, message: HdlcEncapsulatedMessage) -> MessagesContainer {
MessagesContainer {
data_type,
num_messages: 1,
messages: vec![message],
}
}
// this log is based on one captured on a real device -- if it fails to
// serialize or deserialize, that's probably a problem with this mock, not
// the DiagReader implementation
fn get_test_message(payload: &[u8]) -> (HdlcEncapsulatedMessage, Message) {
let length_with_payload = 31 + payload.len() as u16;
let message = Message::Log {
pending_msgs: 0,
outer_length: length_with_payload,
inner_length: length_with_payload,
log_type: 0xb0c0,
timestamp: Timestamp {
ts: 72659535985485082,
},
body: LogBody::LteRrcOtaMessage {
ext_header_version: 20,
packet: diaglog::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: payload.len() as u16,
packet: payload.to_vec(),
},
},
};
let serialized = message
.to_bytes()
.expect("failed to serialize test message");
let encapsulated_data = hdlc::hdlc_encapsulate(&serialized, &CRC_CCITT);
let encapsulated = HdlcEncapsulatedMessage {
len: encapsulated_data.len() as u32,
data: encapsulated_data,
};
(encapsulated, message)
}
#[test]
fn test_containers_with_multiple_messages() {
let (encapsulated1, message1) = get_test_message(&[1]);
let (encapsulated2, message2) = get_test_message(&[2]);
let mut container = make_container(DataType::UserSpace, encapsulated1);
container.messages.push(encapsulated2);
container.num_messages += 1;
assert_eq!(container.into_messages(), vec![Ok(message1), Ok(message2)]);
}
#[test]
fn test_containers_with_concatenated_message() {
let (mut encapsulated1, message1) = get_test_message(&[1]);
let (encapsulated2, message2) = get_test_message(&[2]);
encapsulated1.data.extend(encapsulated2.data);
encapsulated1.len += encapsulated2.len;
let container = make_container(DataType::UserSpace, encapsulated1);
assert_eq!(container.into_messages(), vec![Ok(message1), Ok(message2)]);
}
#[test]
fn test_handles_parsing_errors() {
let (encapsulated1, message1) = get_test_message(&[1]);
let bad_message = hdlc::hdlc_encapsulate(&[0x01, 0x02, 0x03, 0x04], &CRC_CCITT);
let encapsulated2 = HdlcEncapsulatedMessage {
len: bad_message.len() as u32,
data: bad_message,
};
let mut container = make_container(DataType::UserSpace, encapsulated1);
container.messages.push(encapsulated2);
container.num_messages += 1;
let result = container.into_messages();
assert_eq!(result[0], Ok(message1));
assert!(matches!(
result[1],
Err(DiagParsingError::MessageParsingError(_, _))
));
}
#[test]
fn test_handles_encapsulation_errors() {
let (encapsulated1, message1) = get_test_message(&[1]);
let bad_encapsulation = HdlcEncapsulatedMessage {
len: 4,
data: vec![0x01, 0x02, 0x03, 0x04],
};
let mut container = make_container(DataType::UserSpace, encapsulated1);
container.messages.push(bad_encapsulation);
container.num_messages += 1;
let result = container.into_messages();
assert_eq!(result[0], Ok(message1));
assert!(matches!(
result[1],
Err(DiagParsingError::HdlcDecapsulationError(_, _))
));
}
#[test]
fn test_fuzz_crash_response_opcode_parsing() {
// Regression test: Upgrading to deku 0.20 caused incorrect parsing of Response messages.
// The issue was that deku 0.20 requires an `id` field for `id_pat = "_"` variants,
// but in deku 0.18 the discriminant was NOT consumed from the stream.
// This caused a 1-byte offset, making opcode and all subsequent fields misaligned.
// Fixed by splitting the opcode into 4 separate u8 fields so the discriminant byte
// becomes the first byte of the opcode, matching the old deku 0.18 behavior.
let response_msg = b"\x73\x00\x00\x00\x03\x00\x00\x00\x0a\x00\xec\xb0\x8e\x51\x02\x6f\x2a\xc5\x0b\x01\x01\x09\x05\x00\x07\x45\x8e\x14\x7d";
let ((rest, _), msg) = Message::from_bytes((response_msg, 0)).unwrap();
// Verify the opcode is correctly parsed as 115 (0x73 in first byte)
// In little-endian: [0x73, 0x00, 0x00, 0x00] = 0x00000073 = 115
assert!(
matches!(
msg,
Message::Response {
opcode1: 0x73,
opcode2: 0x00,
opcode3: 0x00,
opcode4: 0x00,
subopcode: 3,
status: 2968256522, // [0x0a, 0x00, 0xec, 0xb0] in LE
payload: ResponsePayload::LogConfig(LogConfigResponse::SetMask),
}
),
"Unexpected message: {:?}",
msg
);
// Verify we consumed the expected number of bytes
assert_eq!(rest.len(), 17);
}
}
+2 -4
View File
@@ -40,7 +40,7 @@ pub enum DiagDeviceError {
ParseMessagesContainerError(deku::DekuError),
}
pub const LOG_CODES_FOR_RAW_PACKET_LOGGING: [u32; 14] = [
pub const LOG_CODES_FOR_RAW_PACKET_LOGGING: [u32; 12] = [
// Layer 2:
log_codes::LOG_GPRS_MAC_SIGNALLING_MESSAGE_C, // 0x5226
// Layer 3:
@@ -56,10 +56,8 @@ pub const LOG_CODES_FOR_RAW_PACKET_LOGGING: [u32; 14] = [
log_codes::LOG_LTE_NAS_EMM_OTA_OUT_MSG_LOG_C, // 0xb0ed
// User IP traffic:
log_codes::LOG_DATA_PROTOCOL_LOGGING_C, // 0x11eb
// Statistics
log_codes::LOG_LTE_ML1_SERVING_CELL_MEAS_RESP_AND_EVAL, // 0xb17f
// Signal strength measurements:
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;
+83 -5
View File
@@ -1,10 +1,70 @@
use crate::diag::Message;
use crate::diag::diaglog::{Timestamp, LogBody, Nas4GMessageDirection};
use crate::gsmtap::{GsmtapHeader, GsmtapMessage, GsmtapType, LteNasSubtype, LteRrcSubtype};
use crate::diag::*;
use crate::gsmtap::*;
use log::debug;
use log::{debug, error};
use serde::Serialize;
use std::sync::RwLock;
use thiserror::Error;
/// Cached LTE cell information from ML1 measurements.
/// Contains signal strength and cell identity information.
#[derive(Debug, Clone, Default, Serialize)]
pub struct CellInfo {
/// Reference Signal Received Power in dBm (typical range: -140 to -44)
pub rsrp_dbm: Option<f32>,
/// Reference Signal Received Quality in dB (typical range: -20 to -3)
pub rsrq_db: Option<f32>,
/// Received Signal Strength Indicator in dBm
pub rssi_dbm: Option<f32>,
/// Physical Cell ID (0-503)
pub pci: Option<u16>,
/// E-UTRA Absolute Radio Frequency Channel Number
pub earfcn: Option<u32>,
}
/// Global cache for the most recent cell/signal measurement.
/// This is populated by LteMl1ServingCellMeas messages and can be used
/// to add signal strength to GSMTAP headers and display in the UI.
///
/// Uses RwLock for consistent multi-field updates. Reads >> writes so this is efficient.
static CACHED_CELL_INFO: RwLock<CellInfo> = RwLock::new(CellInfo {
rsrp_dbm: None,
rsrq_db: None,
rssi_dbm: None,
pci: None,
earfcn: None,
});
/// Get the cached cell information.
/// Returns a clone of the current cell info state.
pub fn get_cached_cell_info() -> CellInfo {
CACHED_CELL_INFO
.read()
.expect("cell info lock poisoned")
.clone()
}
/// Get the cached signal strength (RSRP) in dBm as i8 for GSMTAP header compatibility.
/// Returns 0 if no measurement has been received yet.
pub fn get_cached_signal_dbm() -> i8 {
CACHED_CELL_INFO
.read()
.expect("cell info lock poisoned")
.rsrp_dbm
.map(|rsrp| rsrp.clamp(-128.0, 127.0) as i8)
.unwrap_or(0)
}
/// Update the cached cell info from a measurement.
fn update_cell_info_cache(meas: &LteMl1ServingCellMeasData) {
let mut cache = CACHED_CELL_INFO.write().expect("cell info lock poisoned");
cache.rsrp_dbm = meas.get_rsrp_dbm();
cache.rsrq_db = meas.get_rsrq_db();
cache.rssi_dbm = meas.get_rssi_dbm();
cache.pci = meas.get_pci();
cache.earfcn = meas.get_earfcn();
}
#[derive(Debug, Error)]
pub enum GsmtapParserError {
#[error("Invalid LteRrcOtaMessage ext header version {0}")]
@@ -139,6 +199,8 @@ fn log_to_gsmtap(value: LogBody) -> Result<Option<GsmtapMessage>, GsmtapParserEr
header.arfcn = packet.get_earfcn().try_into().unwrap_or(0);
header.frame_number = packet.get_sfn();
header.subslot = packet.get_subfn();
// Apply cached signal strength from ML1 measurements
header.signal_dbm = get_cached_signal_dbm();
Ok(Some(GsmtapMessage {
header,
payload: packet.take_payload(),
@@ -153,8 +215,24 @@ fn log_to_gsmtap(value: LogBody) -> Result<Option<GsmtapMessage>, GsmtapParserEr
payload: msg,
}))
}
LogBody::LteMl1ServingCellMeas { meas, .. } => {
// Update the cell info cache with measurement data
update_cell_info_cache(&meas);
debug!(
"ML1 0xB193 v{}: RSRP={:?}dBm, RSRQ={:?}dB, RSSI={:?}dBm, PCI={:?}, EARFCN={:?}",
meas.subpacket_version,
meas.get_rsrp_dbm(),
meas.get_rsrq_db(),
meas.get_rssi_dbm(),
meas.get_pci(),
meas.get_earfcn()
);
// Measurement messages don't produce GSMTAP output themselves,
// they just update the cell info cache for subsequent messages.
Ok(None)
}
_ => {
debug!("gsmtap_sink: ignoring unhandled log type: {value:?}");
error!("gsmtap_sink: ignoring unhandled log type: {value:?}");
Ok(None)
}
}
+3 -4
View File
@@ -31,6 +31,9 @@ pub const LOG_NR_RRC_OTA_MSG_LOG_C: u32 = 0xb821;
// These are 4G-related log types.
pub const LOG_LTE_RRC_OTA_MSG_LOG_C: u32 = 0xb0c0;
// LTE ML1 (Modem Layer 1) measurement logs - contain signal strength data
pub const LOG_LTE_ML1_SERVING_CELL_MEAS_RESPONSE: u32 = 0xb193;
pub const LOG_LTE_NAS_ESM_OTA_IN_MSG_LOG_C: u32 = 0xb0e2;
pub const LOG_LTE_NAS_ESM_OTA_OUT_MSG_LOG_C: u32 = 0xb0e3;
pub const LOG_LTE_NAS_EMM_OTA_IN_MSG_LOG_C: u32 = 0xb0ec;
@@ -103,7 +106,3 @@ pub const WCDMA_SIGNALLING_MESSAGE: u32 = 0x412f;
pub const LOG_DATA_PROTOCOL_LOGGING_C: u32 = 0x11eb;
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.
//! 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::diaglog::Timestamp;
use crate::diag::Timestamp;
use crate::gsmtap::GsmtapMessage;
use chrono::prelude::*;
+1 -2
View File
@@ -1,7 +1,6 @@
use deku::prelude::*;
use rayhunter::{
diag::Message,
diag::diaglog::{LogBody, rrc::LteRrcOtaPacket, Timestamp},
diag::{LogBody, LteRrcOtaPacket, Message, Timestamp},
gsmtap_parser,
};
-86
View File
@@ -1,86 +0,0 @@
#!/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
@@ -1,14 +0,0 @@
#!/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 -- "$@"