mirror of
https://github.com/EFForg/rayhunter.git
synced 2026-06-04 04:11:35 -07:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d4bfaf07dd | |||
| c08ccda58b | |||
| f33b3baa4f | |||
| d41c4bba3e | |||
| 1d5ed54033 | |||
| 24e79aad9d | |||
| bc7dcc97c6 | |||
| 480b6f8681 | |||
| 0c624c2bc2 | |||
| ec6967e2a1 | |||
| 912f7dfeaa | |||
| 51f1a33e86 | |||
| 87c79bddf7 | |||
| 5efa12f358 | |||
| e77fe469da | |||
| ed8b1903f8 | |||
| 89d1d71ec9 | |||
| 9be35de90e | |||
| 8f9be746d3 | |||
| 1347e3107a | |||
| 715efc4b0d | |||
| 836ec2169d | |||
| 9128eefcfc | |||
| 4f3c7fb7a9 | |||
| 2d3824072d |
@@ -7,3 +7,4 @@
|
||||
dist/config.toml.in eol=lf
|
||||
dist/scripts/misc-daemon eol=lf
|
||||
dist/scripts/rayhunter_daemon eol=lf
|
||||
scripts/*.sh eol=lf
|
||||
|
||||
Generated
+1
@@ -2759,6 +2759,7 @@ dependencies = [
|
||||
"installer",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"shlex",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-opener",
|
||||
|
||||
@@ -6,3 +6,4 @@ title = "Rayhunter - An IMSI Catcher Catcher"
|
||||
|
||||
[output.html]
|
||||
edit-url-template = "https://github.com/efforg/rayhunter/edit/main/{path}"
|
||||
additional-css = ["doc/custom.css"]
|
||||
|
||||
@@ -20,6 +20,8 @@ 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 {
|
||||
@@ -35,6 +37,8 @@ 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+181
-32
@@ -27,10 +27,15 @@ 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,
|
||||
StartRecording {
|
||||
response_tx: Option<oneshot::Sender<Result<(), String>>>,
|
||||
},
|
||||
DeleteEntry {
|
||||
name: String,
|
||||
response_tx: oneshot::Sender<Result<(), RecordingStoreError>>,
|
||||
@@ -46,8 +51,12 @@ 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 {
|
||||
@@ -58,36 +67,99 @@ 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
|
||||
async fn start(&mut self, qmdl_store: &mut RecordingStore) {
|
||||
/// Start recording, returning an error if disk space is too low.
|
||||
async fn start(&mut self, qmdl_store: &mut RecordingStore) -> Result<(), String> {
|
||||
self.max_type_seen = EventType::Informational;
|
||||
let (qmdl_file, analysis_file) = qmdl_store
|
||||
.new_entry()
|
||||
.await
|
||||
.expect("failed creating QMDL file entry");
|
||||
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);
|
||||
}
|
||||
};
|
||||
self.stop_current_recording().await;
|
||||
let qmdl_writer = QmdlWriter::new(qmdl_file);
|
||||
let analysis_writer = AnalysisWriter::new(analysis_file, &self.analyzer_config)
|
||||
.await
|
||||
.map(Box::new)
|
||||
.expect("failed to write to analysis file");
|
||||
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);
|
||||
}
|
||||
};
|
||||
self.state = DiagState::Recording {
|
||||
qmdl_writer,
|
||||
analysis_writer,
|
||||
@@ -99,11 +171,17 @@ impl DiagTask {
|
||||
{
|
||||
warn!("couldn't send ui update message: {e}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop recording
|
||||
async fn stop(&mut self, qmdl_store: &mut RecordingStore) {
|
||||
/// Stop recording, optionally annotating the entry with a reason.
|
||||
async fn stop(&mut self, qmdl_store: &mut RecordingStore, reason: Option<String>) {
|
||||
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
|
||||
@@ -132,7 +210,7 @@ impl DiagTask {
|
||||
name: &str,
|
||||
) -> Result<(), RecordingStoreError> {
|
||||
if qmdl_store.is_current_entry(name) {
|
||||
self.stop(qmdl_store).await;
|
||||
self.stop(qmdl_store, None).await;
|
||||
}
|
||||
let res = qmdl_store.delete_entry(name).await;
|
||||
if let Err(e) = res.as_ref() {
|
||||
@@ -145,7 +223,7 @@ impl DiagTask {
|
||||
&mut self,
|
||||
qmdl_store: &mut RecordingStore,
|
||||
) -> Result<(), RecordingStoreError> {
|
||||
self.stop(qmdl_store).await;
|
||||
self.stop(qmdl_store, None).await;
|
||||
let res = qmdl_store.delete_all_entries().await;
|
||||
if let Err(e) = res.as_ref() {
|
||||
error!("Error deleting QMDL entries {e}");
|
||||
@@ -183,10 +261,56 @@ impl DiagTask {
|
||||
analysis_writer,
|
||||
} = &mut self.state
|
||||
{
|
||||
qmdl_writer
|
||||
.write_container(&container)
|
||||
.await
|
||||
.expect("failed to write to QMDL writer");
|
||||
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;
|
||||
}
|
||||
debug!(
|
||||
"total QMDL bytes written: {}, updating manifest...",
|
||||
qmdl_writer.total_written
|
||||
@@ -194,15 +318,25 @@ impl DiagTask {
|
||||
let index = qmdl_store
|
||||
.current_entry
|
||||
.expect("DiagDevice had qmdl_writer, but QmdlStore didn't have current entry???");
|
||||
qmdl_store
|
||||
if let Err(e) = qmdl_store
|
||||
.update_entry_qmdl_size(index, qmdl_writer.total_written)
|
||||
.await
|
||||
.expect("failed to update qmdl file size");
|
||||
{
|
||||
let reason = format!("failed to update manifest (disk full?): {e}");
|
||||
error!("{reason}");
|
||||
self.stop(qmdl_store, Some(reason)).await;
|
||||
return;
|
||||
}
|
||||
debug!("done!");
|
||||
let max_type = analysis_writer
|
||||
.analyze(container)
|
||||
.await
|
||||
.expect("failed to analyze container");
|
||||
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
|
||||
}
|
||||
};
|
||||
|
||||
if max_type > EventType::Informational {
|
||||
info!("a heuristic triggered on this run!");
|
||||
@@ -244,25 +378,30 @@ 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);
|
||||
let mut diag_task = DiagTask::new(ui_update_sender, analysis_sender, analyzer_config, notification_channel, min_space_to_start_mb, min_space_to_continue_mb);
|
||||
qmdl_file_tx
|
||||
.send(DiagDeviceCtrlMessage::StartRecording)
|
||||
.send(DiagDeviceCtrlMessage::StartRecording { response_tx: None })
|
||||
.await
|
||||
.unwrap();
|
||||
loop {
|
||||
tokio::select! {
|
||||
msg = qmdl_file_rx.recv() => {
|
||||
match msg {
|
||||
Some(DiagDeviceCtrlMessage::StartRecording) => {
|
||||
Some(DiagDeviceCtrlMessage::StartRecording { response_tx }) => {
|
||||
let mut qmdl_store = qmdl_store_lock.write().await;
|
||||
diag_task.start(qmdl_store.deref_mut()).await;
|
||||
let result = diag_task.start(qmdl_store.deref_mut()).await;
|
||||
if let Some(tx) = response_tx {
|
||||
tx.send(result).ok();
|
||||
}
|
||||
},
|
||||
Some(DiagDeviceCtrlMessage::StopRecording) => {
|
||||
let mut qmdl_store = qmdl_store_lock.write().await;
|
||||
diag_task.stop(qmdl_store.deref_mut()).await;
|
||||
diag_task.stop(qmdl_store.deref_mut(), None).await;
|
||||
},
|
||||
// None means all the Senders have been dropped, so it's
|
||||
// time to go
|
||||
@@ -312,9 +451,12 @@ 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)
|
||||
.send(DiagDeviceCtrlMessage::StartRecording {
|
||||
response_tx: Some(response_tx),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
@@ -323,7 +465,14 @@ pub async fn start_recording(
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok((StatusCode::ACCEPTED, "ok".to_string()))
|
||||
match response_rx.await {
|
||||
Ok(Ok(())) => Ok((StatusCode::ACCEPTED, "ok".to_string())),
|
||||
Ok(Err(reason)) => Err((StatusCode::INSUFFICIENT_STORAGE, reason)),
|
||||
Err(e) => Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("failed to receive start recording response: {e}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop recording API for web thread
|
||||
|
||||
@@ -81,8 +81,9 @@ pub fn run_key_input_thread(
|
||||
{
|
||||
error!("Failed to send StopRecording: {e}");
|
||||
}
|
||||
if let Err(e) =
|
||||
diag_tx.send(DiagDeviceCtrlMessage::StartRecording).await
|
||||
if let Err(e) = diag_tx
|
||||
.send(DiagDeviceCtrlMessage::StartRecording { response_tx: None })
|
||||
.await
|
||||
{
|
||||
error!("Failed to send StartRecording: {e}");
|
||||
}
|
||||
|
||||
@@ -234,6 +234,8 @@ 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");
|
||||
|
||||
|
||||
@@ -54,6 +54,8 @@ 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 {
|
||||
@@ -68,6 +70,7 @@ impl ManifestEntry {
|
||||
rayhunter_version: Some(metadata.rayhunter_version),
|
||||
system_os: Some(metadata.system_os),
|
||||
arch: Some(metadata.arch),
|
||||
stop_reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,6 +200,7 @@ impl RecordingStore {
|
||||
rayhunter_version: None,
|
||||
system_os: None,
|
||||
arch: None,
|
||||
stop_reason: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -342,6 +346,17 @@ 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) {
|
||||
|
||||
+32
-22
@@ -1,3 +1,4 @@
|
||||
use std::ffi::CString;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::battery::get_battery_status;
|
||||
@@ -25,7 +26,7 @@ pub struct SystemStats {
|
||||
impl SystemStats {
|
||||
pub async fn new(qmdl_path: &str, device: &Device) -> Result<Self, String> {
|
||||
Ok(Self {
|
||||
disk_stats: DiskStats::new(qmdl_path, device).await?,
|
||||
disk_stats: DiskStats::new(qmdl_path)?,
|
||||
memory_stats: MemoryStats::new(device).await?,
|
||||
runtime_metadata: RuntimeMetadata::new(),
|
||||
battery_status: match get_battery_status(device).await {
|
||||
@@ -48,33 +49,42 @@ 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 {
|
||||
// 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 {
|
||||
df_cmd = Command::new("df");
|
||||
#[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()
|
||||
));
|
||||
}
|
||||
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);
|
||||
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)
|
||||
} else {
|
||||
"0%".to_string()
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
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(),
|
||||
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),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,10 +22,26 @@
|
||||
<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 !current}
|
||||
<div class="flex flex-row justify-end items-center">
|
||||
<ReAnalyzeButton {entry} {manager} />
|
||||
{#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}
|
||||
</div>
|
||||
{/if}
|
||||
{#if entry.analysis_report.rows.length > 0}
|
||||
|
||||
@@ -241,6 +241,48 @@
|
||||
</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,6 +81,11 @@
|
||||
'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 />
|
||||
|
||||
@@ -11,6 +11,7 @@ interface JsonManifestEntry {
|
||||
start_time: string;
|
||||
last_message_time: string;
|
||||
qmdl_size_bytes: number;
|
||||
stop_reason: string | null;
|
||||
}
|
||||
|
||||
export class Manifest {
|
||||
@@ -57,6 +58,7 @@ 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;
|
||||
@@ -65,6 +67,9 @@ 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 {
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface DiskStats {
|
||||
available_size: string;
|
||||
used_percent: string;
|
||||
mounted_on: string;
|
||||
available_bytes?: number;
|
||||
}
|
||||
|
||||
export interface MemoryStats {
|
||||
|
||||
@@ -25,6 +25,8 @@ 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> {
|
||||
|
||||
@@ -5,7 +5,7 @@ export default defineConfig({
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
target: process.env.API_TARGET || 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
configure: (proxy, _options) => {
|
||||
|
||||
Vendored
+6
@@ -28,6 +28,12 @@ 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
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
.warning-box {
|
||||
padding: 0.75em 1em;
|
||||
border-left: 4px solid #e33;
|
||||
border-radius: 4px;
|
||||
background-color: color-mix(in srgb, currentColor 10%, transparent);
|
||||
}
|
||||
@@ -1,63 +1,78 @@
|
||||
# Installing from source
|
||||
|
||||
Building Rayhunter from source, either for development or because the install script doesn't work on your system, involves a number of external dependencies. Unless you need to do this, we recommend you use our [compiled builds](https://github.com/EFForg/rayhunter/releases).
|
||||
Building Rayhunter from source, either for development or otherwise, involves a
|
||||
number of external dependencies. Unless you need to do this, we recommend you
|
||||
use our [compiled builds](https://github.com/EFForg/rayhunter/releases).
|
||||
|
||||
* Install [nodejs/npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm), which is required to build Rayhunter's web UI
|
||||
* 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.
|
||||
At a high level, we have:
|
||||
|
||||
### Install Rust targets
|
||||
* A JS frontend written in SvelteKit (`./daemon/web/`)
|
||||
* A Rust binary `rayhunter-daemon` (`./daemon/`) that runs on the device, and bundles the frontend.
|
||||
* A Rust binary `installer` (`./installer`) that runs on the computer and bundles `rayhunter-daemon`.
|
||||
|
||||
[Install Rust the usual way](https://www.rust-lang.org/tools/install). Then,
|
||||
It's recommended to work either on Mac/Linux, or WSL on Windows.
|
||||
|
||||
- install the cross-compilation target for the device Rayhunter will run on:
|
||||
```sh
|
||||
rustup target add armv7-unknown-linux-musleabihf
|
||||
```
|
||||
## Building frontend and backend
|
||||
|
||||
- install the statically compiled target for your host machine to build the binary installer `serial`.
|
||||
```sh
|
||||
# check which toolchain you have installed by default with
|
||||
rustup show
|
||||
# now install the correct variant for your host platform, one of:
|
||||
rustup target add aarch64-unknown-linux-musl
|
||||
rustup target add armv7-unknown-linux-musleabi
|
||||
rustup target add x86_64-unknown-linux-musl
|
||||
rustup target add aarch64-apple-darwin
|
||||
rustup target add x86_64-apple-darwin
|
||||
rustup target add x86_64-pc-windows-gnu
|
||||
```
|
||||
First, install dependencies:
|
||||
|
||||
Now you can root your device and install Rayhunter by running:
|
||||
- [Rust](https://www.rust-lang.org/tools/install)
|
||||
- [Node.js/npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm)
|
||||
- C compiler tools (`apt install build-essential` on Linux, `xcode-select --install` on Mac)
|
||||
|
||||
Then you can build everything with:
|
||||
|
||||
```sh
|
||||
# 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
|
||||
|
||||
# To build it exactly like in CI (more mature ring TLS backend, slower compilation)
|
||||
# CC_armv7_unknown_linux_musleabihf=arm-linux-gnueabihf-gcc cargo build-daemon-firmware
|
||||
|
||||
# Build rootshell
|
||||
cargo build-rootshell-firmware-devel
|
||||
|
||||
# Replace 'orbic' with your device type if different.
|
||||
# A list of possible values can be found with 'cargo run --bin installer help'.
|
||||
FIRMWARE_PROFILE=firmware-devel cargo run -p installer --bin installer orbic
|
||||
./scripts/build-dev.sh
|
||||
./scripts/install-dev.sh orbic # replace 'orbic' with your device type
|
||||
```
|
||||
|
||||
### If you're on Windows or can't run the install scripts
|
||||
## Hot-reloading the frontend
|
||||
|
||||
* Root your device on Windows using the instructions here: <https://xdaforums.com/t/resetting-verizon-orbic-speed-rc400l-firmware-flash-kajeet.4334899/#post-87855183>
|
||||
* 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!
|
||||
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:
|
||||
|
||||
```sh
|
||||
cd daemon/web
|
||||
|
||||
# Assumes rayhunter-daemon is listening on localhost:8080
|
||||
npm run dev
|
||||
|
||||
# Use a custom target IP:port where the backend runs
|
||||
API_TARGET=http://192.168.1.1:8080 npm run dev
|
||||
```
|
||||
|
||||
The UI will listen on `localhost:5173` and instantly show any frontend changes
|
||||
you make. Backend changes require building everything from the top (daemon and installer).
|
||||
|
||||
## Installer utils, getting a shell
|
||||
|
||||
Check `./scripts/install-dev.sh util --help`
|
||||
for useful utilities for transferring files, opening shells. The exact tools
|
||||
available wildly depend on the device you're working on, and they are
|
||||
usually documented the relevant device's page under [Supported
|
||||
Devices](./supported-devices.md).
|
||||
|
||||
A lot of devices run a trimmed down version of Android and have ADB (Android
|
||||
Debug Bridge) support. The USB-based installers (`orbic-usb`, `pinephone`,
|
||||
`uz801`) use ADB to perform the installation.
|
||||
|
||||
You might want to install and use actual ADB to connect to the device, push
|
||||
files and generally poke around. The installer contains some tools to enable ADB:
|
||||
|
||||
```sh
|
||||
adb kill-server
|
||||
|
||||
# Enables ADB on either of these devices
|
||||
./scripts/install-dev.sh util tmobile-start-adb
|
||||
./scripts/install-dev.sh orbic-usb
|
||||
|
||||
adb shell
|
||||
```
|
||||
|
||||
Note though that we can't assist with any issues setting ADB up, _especially
|
||||
not_ on Windows. There have been too many driver issues to make this the
|
||||
"golden path" for most users or contributors. There have been instances where
|
||||
people managed to brick their orbic devices using ADB on Windows.
|
||||
|
||||
@@ -5,6 +5,16 @@ Supported in Rayhunter since version 0.6.0.
|
||||
The Moxee Hotspot is a device very similar to the Orbic RC400L. It seems to be
|
||||
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)
|
||||
|
||||
|
||||
@@ -21,4 +21,5 @@ tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
anyhow = "1.0.100"
|
||||
shlex = "1"
|
||||
installer = { path = "../../installer" }
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
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(
|
||||
// TODO: we should split using something similar to shlex in python
|
||||
args.split_whitespace(),
|
||||
args_vec.iter().map(|s| s.as_str()),
|
||||
Some(Box::new(move |output| {
|
||||
app_handle
|
||||
.emit("installer-output", output)
|
||||
|
||||
@@ -81,6 +81,9 @@
|
||||
<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
|
||||
|
||||
+8
-1
@@ -4,7 +4,14 @@ 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(|_| "firmware".to_string());
|
||||
let profile = std::env::var("FIRMWARE_PROFILE").unwrap_or_else(|_| {
|
||||
// Default to firmware-devel for debug builds, firmware for release builds
|
||||
if std::env::var("PROFILE").as_deref() == Ok("release") {
|
||||
"firmware".to_string()
|
||||
} else {
|
||||
"firmware-devel".to_string()
|
||||
}
|
||||
});
|
||||
let include_dir = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../target/armv7-unknown-linux-musleabihf")
|
||||
.join(&profile);
|
||||
|
||||
@@ -97,7 +97,10 @@ async fn login_and_exploit(admin_ip: &str, username: &str, password: &str) -> Re
|
||||
.context("Failed to parse login response")?;
|
||||
|
||||
if login_result.retcode != 0 {
|
||||
bail!("Login failed with retcode: {}", login_result.retcode);
|
||||
match login_result.retcode {
|
||||
201 => bail!("Login failed: incorrect password"),
|
||||
code => bail!("Login failed with retcode: {}", code),
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Exploit using authenticated session
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
//! Diag ML1 measurement log serialization/deserialization. These are pretty
|
||||
//! much entirely based on Shinjo Park's work in scat, since we couldn't find
|
||||
//! any other documentation for the logs' structure.
|
||||
|
||||
use deku::prelude::*;
|
||||
use deku::ctx::Order;
|
||||
|
||||
fn decode_rsrp(rsrp: u16) -> f32 {
|
||||
rsrp as f32 / 16.0 - 180.0
|
||||
}
|
||||
|
||||
fn decode_rssi(rssi: u16) -> f32 {
|
||||
rssi as f32 / 16.0 - 110.0
|
||||
}
|
||||
|
||||
fn decode_rsrq(rsrq: u16) -> f32 {
|
||||
rsrq as f32 / 16.0 - 30.0
|
||||
}
|
||||
|
||||
pub mod serving_cell {
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
||||
#[deku(bit_order = "lsb")]
|
||||
pub struct MeasurementAndEvaluation {
|
||||
pub header: MeasurementAndEvaluationHeader,
|
||||
#[deku(bits = 12, pad_bits_after = "20")]
|
||||
meas_rsrp: u16,
|
||||
avg_rsrp: u32,
|
||||
#[deku(bits = 10, pad_bits_after = "22")]
|
||||
meas_rsrq: u16,
|
||||
#[deku(pad_bits_before = "10", bits = 11, pad_bits_after = "11")]
|
||||
meas_rssi: u16,
|
||||
rxlev: u32,
|
||||
s_search: u32,
|
||||
#[deku(cond = "header.get_rrc_rel() == 0x01")]
|
||||
r9_data: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
||||
#[deku(ctx = "_: Order", id_type = "u8", bit_order = "lsb")]
|
||||
pub enum MeasurementAndEvaluationHeader {
|
||||
#[deku(id = "4")]
|
||||
V4 {
|
||||
rrc_rel: u8,
|
||||
_reserved: u16,
|
||||
earfcn: u16,
|
||||
#[deku(bits = 9)]
|
||||
pci: u16,
|
||||
#[deku(bits = 7)]
|
||||
serv_layer_priority: u8,
|
||||
},
|
||||
#[deku(id = "5")]
|
||||
V5 {
|
||||
rrc_rel: u8,
|
||||
_reserved: u16,
|
||||
earfcn: u32,
|
||||
#[deku(bits = 9)]
|
||||
pci: u16,
|
||||
#[deku(bits = 7, pad_bytes_after = "2")]
|
||||
serv_layer_priority: u8,
|
||||
},
|
||||
}
|
||||
|
||||
impl MeasurementAndEvaluationHeader {
|
||||
fn get_rrc_rel(&self) -> u8 {
|
||||
match self {
|
||||
MeasurementAndEvaluationHeader::V4 { rrc_rel, .. } => *rrc_rel,
|
||||
MeasurementAndEvaluationHeader::V5 { rrc_rel, .. } => *rrc_rel,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MeasurementAndEvaluation {
|
||||
pub fn get_pci(&self) -> u16 {
|
||||
match &self.header {
|
||||
MeasurementAndEvaluationHeader::V4 { pci, .. } => *pci,
|
||||
MeasurementAndEvaluationHeader::V5 { pci, .. } => *pci,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_earfcn(&self) -> u32 {
|
||||
match &self.header {
|
||||
MeasurementAndEvaluationHeader::V4 { earfcn, .. } => *earfcn as u32,
|
||||
MeasurementAndEvaluationHeader::V5 { earfcn, .. } => *earfcn,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_meas_rsrp(&self) -> f32 {
|
||||
decode_rsrp(self.meas_rsrp)
|
||||
}
|
||||
|
||||
pub fn get_meas_rssi(&self) -> f32 {
|
||||
decode_rssi(self.meas_rssi)
|
||||
}
|
||||
|
||||
pub fn get_meas_rsrq(&self) -> f32 {
|
||||
decode_rsrq(self.meas_rsrq)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod neighbor_cells {
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Debug, DekuRead, DekuWrite, PartialEq)]
|
||||
#[deku(id_type = "u8", bit_order = "lsb")]
|
||||
pub enum MeasurementsHeader {
|
||||
#[deku(id = "4")]
|
||||
V4 {
|
||||
rrc_rel: u8,
|
||||
_reserved1: u16,
|
||||
earfcn: u16,
|
||||
#[deku(bits = 6)]
|
||||
q_rxlevmin: u8,
|
||||
#[deku(bits = 10)]
|
||||
n_cells: u16,
|
||||
},
|
||||
#[deku(id = "5")]
|
||||
V5 {
|
||||
rrc_rel: u8,
|
||||
_reserved1: u16,
|
||||
earfcn: u32,
|
||||
#[deku(bits = 6)]
|
||||
q_rxlevmin: u8,
|
||||
#[deku(bits = 26)]
|
||||
n_cells: u32,
|
||||
},
|
||||
}
|
||||
|
||||
impl MeasurementsHeader {
|
||||
fn get_n_cells(&self) -> usize {
|
||||
match self {
|
||||
MeasurementsHeader::V4 { n_cells, .. } => *n_cells as usize,
|
||||
MeasurementsHeader::V5 { n_cells, .. } => *n_cells as usize,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, DekuRead, DekuWrite, PartialEq)]
|
||||
pub struct Measurements {
|
||||
pub header: MeasurementsHeader,
|
||||
#[deku(count = "header.get_n_cells()")]
|
||||
pub cells: Vec<MeasurementsCell>
|
||||
}
|
||||
|
||||
impl Measurements {
|
||||
pub fn get_earfcn(&self) -> u32 {
|
||||
match &self.header {
|
||||
MeasurementsHeader::V4 { earfcn, .. } => *earfcn as u32,
|
||||
MeasurementsHeader::V5 { earfcn, .. } => *earfcn,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[derive(Clone, Debug, DekuRead, DekuWrite, PartialEq)]
|
||||
#[deku(bit_order = "lsb")]
|
||||
pub struct MeasurementsCell {
|
||||
#[deku(bits = 9)]
|
||||
pub pci: u16,
|
||||
#[deku(bits = 11)]
|
||||
meas_rssi: u16,
|
||||
#[deku(bits = 12)]
|
||||
meas_rsrp: u16,
|
||||
#[deku(pad_bits_before = "12", bits = 12, pad_bits_after = "8")]
|
||||
avg_rsrp: u16,
|
||||
#[deku(pad_bits_before = "12", bits = 10, pad_bits_after = "10")]
|
||||
meas_rsrq: u16,
|
||||
#[deku(bits = 10, pad_bits_after = "10")]
|
||||
avg_rsrq: u16,
|
||||
#[deku(bits = 6, pad_bits_after = "6")]
|
||||
s_rxlev: u16,
|
||||
n_freq_offset: u16,
|
||||
val5: u16,
|
||||
ant0_offset: u32,
|
||||
ant1_offset: u32,
|
||||
unk1: u32,
|
||||
}
|
||||
|
||||
impl MeasurementsCell {
|
||||
pub fn get_meas_rsrp(&self) -> f32 {
|
||||
decode_rsrp(self.meas_rsrp)
|
||||
}
|
||||
|
||||
pub fn get_meas_rssi(&self) -> f32 {
|
||||
decode_rssi(self.meas_rssi)
|
||||
}
|
||||
|
||||
pub fn get_meas_rsrq(&self) -> f32 {
|
||||
decode_rsrq(self.meas_rsrq)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::diag::diaglog::LogBody;
|
||||
use crate::log_codes::{LOG_LTE_ML1_NEIGHBOR_MEAS, LOG_LTE_ML1_SERVING_CELL_MEAS_RESP_AND_EVAL};
|
||||
use std::io::{Cursor, Seek};
|
||||
|
||||
fn unhexlify(hexlified_bytes: &str) -> (usize, Reader<Cursor<Vec<u8>>>) {
|
||||
let byte_len = hexlified_bytes.len() / 2;
|
||||
let bytes = (0..hexlified_bytes.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&hexlified_bytes[i..i+2], 16).unwrap())
|
||||
.collect();
|
||||
(byte_len, Reader::new(Cursor::new(bytes)))
|
||||
}
|
||||
|
||||
fn parse_ncell_measurements(hexlified_bytes: &str) -> (u8, neighbor_cells::Measurements) {
|
||||
let (total_size, mut reader) = unhexlify(hexlified_bytes);
|
||||
match LogBody::from_reader_with_ctx(&mut reader, (LOG_LTE_ML1_NEIGHBOR_MEAS as u16, 0)) {
|
||||
Ok(LogBody::LteMl1NeighborCellsMeasurements { data }) => {
|
||||
if !reader.end() {
|
||||
let leftover_bits = reader.rest();
|
||||
let leftover_bytes = total_size - reader.stream_position().unwrap() as usize;
|
||||
panic!("failed to read entire buffer ({} bytes, {} bits left)", leftover_bytes, leftover_bits.len());
|
||||
}
|
||||
let pkt_version = match data.header {
|
||||
neighbor_cells::MeasurementsHeader::V4 { .. } => 4,
|
||||
neighbor_cells::MeasurementsHeader::V5 { .. } => 5,
|
||||
};
|
||||
(pkt_version, data)
|
||||
},
|
||||
Ok(x) => panic!("expected MeasurementAndEvaluation, but parsed {:?}", x),
|
||||
Err(x) => panic!("failed to parse MeasurementAndEvaluation {:?}", x),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_meas_eval(hexlified_bytes: &str) -> (u8, serving_cell::MeasurementAndEvaluation) {
|
||||
let (total_size, mut reader) = unhexlify(hexlified_bytes);
|
||||
match LogBody::from_reader_with_ctx(&mut reader, (LOG_LTE_ML1_SERVING_CELL_MEAS_RESP_AND_EVAL as u16, 0)) {
|
||||
Ok(LogBody::LteMl1ServingCellMeasurementAndEvaluation { data }) => {
|
||||
if !reader.end() {
|
||||
let leftover_bits = reader.rest();
|
||||
let leftover_bytes = total_size - reader.stream_position().unwrap() as usize;
|
||||
panic!("failed to read entire buffer ({} bytes, {} bits left)", leftover_bytes, leftover_bits.len());
|
||||
}
|
||||
let pkt_version = match data.header {
|
||||
serving_cell::MeasurementAndEvaluationHeader::V4 { .. } => 4,
|
||||
serving_cell::MeasurementAndEvaluationHeader::V5 { .. } => 5,
|
||||
};
|
||||
(pkt_version, data)
|
||||
},
|
||||
Ok(x) => panic!("expected MeasurementAndEvaluation, but parsed {:?}", x),
|
||||
Err(x) => panic!("failed to parse MeasurementAndEvaluation {:?}", x),
|
||||
}
|
||||
}
|
||||
|
||||
fn scell_meas_and_eval_case(
|
||||
hexlified_bytes: &str,
|
||||
pkt_version: u8,
|
||||
pci: u16,
|
||||
earfcn: u32,
|
||||
rsrp: f32,
|
||||
rsrq: f32,
|
||||
rssi: f32
|
||||
) {
|
||||
let (parsed_pkt_version, data) = parse_meas_eval(hexlified_bytes);
|
||||
assert_eq!(parsed_pkt_version, pkt_version);
|
||||
assert_eq!(data.get_pci(), pci, "incorrect pci");
|
||||
assert_eq!(data.get_earfcn(), earfcn, "incorrect earfcn");
|
||||
assert_eq!(data.get_meas_rsrp(), rsrp, "incorrect rsrp");
|
||||
assert_eq!(data.get_meas_rsrq(), rsrq, "incorrect rsrq");
|
||||
assert_eq!(data.get_meas_rssi(), rssi, "incorrect rssi");
|
||||
}
|
||||
|
||||
// Adapted from scat's TestDiagLteLogParser::test_parse_lte_ml1_scell_meas,
|
||||
// but edited to print full-precision floats
|
||||
#[test]
|
||||
fn test_scell_meas() {
|
||||
scell_meas_and_eval_case(
|
||||
"040100009C18D60AECC44E00E2244E00FFFCE30FFED80A0047AD56021D310100A2624100",
|
||||
4,
|
||||
214,
|
||||
6300,
|
||||
-101.25,
|
||||
-14.0625,
|
||||
-66.625
|
||||
);
|
||||
scell_meas_and_eval_case(
|
||||
"05010000160d0000d40e00004bb444005444450039e514133149070048adfe019f310100a23f0000",
|
||||
5,
|
||||
212,
|
||||
3350,
|
||||
-111.3125,
|
||||
-10.4375,
|
||||
-80.875,
|
||||
);
|
||||
scell_meas_and_eval_case(
|
||||
"05010000f424000a4d43434d4e434d41524b45527c307c3236327c317c34323330333233347c7c4d43434d4e434d41524b45520a0a434f504d41524b45527c434f504552524f5232363230317c434f504d41524b45520a006306000057755500577555001d75d4111d290b0048ad7e02dd370100a27f4100",
|
||||
5,
|
||||
333,
|
||||
167781620,
|
||||
-127.125,
|
||||
-22.25,
|
||||
2.75,
|
||||
);
|
||||
scell_meas_and_eval_case(
|
||||
"0501000000190000a90d0000d9944d00d9944d006081d5d55d2568bc48ad3e027f314fe0891900e0",
|
||||
5,
|
||||
425,
|
||||
6400,
|
||||
-102.4375,
|
||||
-8.0,
|
||||
-77.4375,
|
||||
);
|
||||
}
|
||||
|
||||
fn ncell_meas_case(
|
||||
hexlified_bytes: &str,
|
||||
pkt_version: u8,
|
||||
earfcn: u32,
|
||||
cells: Vec<(u16, f32, f32, f32)>,
|
||||
) {
|
||||
let (parsed_pkt_version, data) = parse_ncell_measurements(hexlified_bytes);
|
||||
assert_eq!(parsed_pkt_version, pkt_version, "incorrect pkt_version");
|
||||
assert_eq!(data.cells.len(), cells.len(), "incorrect number of cells");
|
||||
assert_eq!(data.get_earfcn(), earfcn, "incorrect earfcn");
|
||||
for (parsed, (pci, rsrp, rssi, rsrq)) in data.cells.iter().zip(cells) {
|
||||
assert_eq!(parsed.pci, pci, "incorrect pci");
|
||||
assert_eq!(parsed.get_meas_rsrp(), rsrp, "incorrect rsrp");
|
||||
assert_eq!(parsed.get_meas_rssi(), rssi, "incorrect rssi");
|
||||
assert_eq!(parsed.get_meas_rsrq(), rsrq, "incorrect rsrq");
|
||||
}
|
||||
}
|
||||
|
||||
// Adapted from scat's TestDiagLteLogParser::test_parse_lte_ml1_ncell_meas,
|
||||
// but edited to print full-precision floats
|
||||
#[test]
|
||||
fn test_ncell_meas() {
|
||||
ncell_meas_case(
|
||||
"040100009C1847008348E44DDEA44C00CAB4CC32B6D8420300000000FF773301FF77330122020100",
|
||||
4,
|
||||
6300,
|
||||
vec![
|
||||
(131, -102.125, -75.75, -17.3125),
|
||||
]
|
||||
);
|
||||
ncell_meas_case(
|
||||
"05010000160d0000480000006cea413bb4433b00b4f3cc33cf3c130200000000ffefc00fffefc00f45081600",
|
||||
5,
|
||||
3350,
|
||||
vec![
|
||||
(108, -120.75, -94.6875, -17.0625),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
//! Diag LogBody serialization/deserialization
|
||||
|
||||
use chrono::{DateTime, FixedOffset};
|
||||
use deku::prelude::*;
|
||||
|
||||
pub mod measurement;
|
||||
pub mod rrc;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
||||
#[deku(ctx = "log_type: u16, hdr_len: u16", id = "log_type")]
|
||||
pub enum LogBody {
|
||||
#[deku(id = "0x412f")]
|
||||
WcdmaSignallingMessage {
|
||||
channel_type: u8,
|
||||
radio_bearer: u8,
|
||||
length: u16,
|
||||
#[deku(count = "length")]
|
||||
msg: Vec<u8>,
|
||||
},
|
||||
#[deku(id = "0x512f")]
|
||||
GsmRrSignallingMessage {
|
||||
channel_type: u8,
|
||||
message_type: u8,
|
||||
length: u8,
|
||||
#[deku(count = "length")]
|
||||
msg: Vec<u8>,
|
||||
},
|
||||
#[deku(id = "0x5226")]
|
||||
GprsMacSignallingMessage {
|
||||
channel_type: u8,
|
||||
message_type: u8,
|
||||
length: u8,
|
||||
#[deku(count = "length")]
|
||||
msg: Vec<u8>,
|
||||
},
|
||||
#[deku(id = "0xb0c0")]
|
||||
LteRrcOtaMessage {
|
||||
ext_header_version: u8,
|
||||
#[deku(ctx = "*ext_header_version")]
|
||||
packet: rrc::LteRrcOtaPacket,
|
||||
},
|
||||
// the four NAS command opcodes refer to:
|
||||
// * 0xb0e2: plain ESM NAS message (incoming)
|
||||
// * 0xb0e3: plain ESM NAS message (outgoing)
|
||||
// * 0xb0ec: plain EMM NAS message (incoming)
|
||||
// * 0xb0ed: plain EMM NAS message (outgoing)
|
||||
#[deku(id_pat = "0xb0e2 | 0xb0e3 | 0xb0ec | 0xb0ed")]
|
||||
Nas4GMessage {
|
||||
#[deku(skip, default = "log_type")]
|
||||
log_type: u16,
|
||||
#[deku(ctx = "*log_type")]
|
||||
direction: Nas4GMessageDirection,
|
||||
ext_header_version: u8,
|
||||
rrc_rel: u8,
|
||||
rrc_version_minor: u8,
|
||||
rrc_version_major: u8,
|
||||
// message length = hdr_len - (sizeof(ext_header_version) + sizeof(rrc_rel) + sizeof(rrc_version_minor) + sizeof(rrc_version_major))
|
||||
#[deku(count = "hdr_len.saturating_sub(4)")]
|
||||
msg: Vec<u8>,
|
||||
},
|
||||
#[deku(id = "0x11eb")]
|
||||
IpTraffic {
|
||||
// is this right?? based on https://github.com/P1sec/QCSuper/blob/81dbaeee15ec7747e899daa8e3495e27cdcc1264/src/modules/pcap_dump.py#L378
|
||||
#[deku(count = "hdr_len.saturating_sub(8)")]
|
||||
msg: Vec<u8>,
|
||||
},
|
||||
#[deku(id = "0x713a")]
|
||||
UmtsNasOtaMessage {
|
||||
is_uplink: u8,
|
||||
length: u32,
|
||||
#[deku(count = "length")]
|
||||
msg: Vec<u8>,
|
||||
},
|
||||
#[deku(id = "0xb821")]
|
||||
NrRrcOtaMessage {
|
||||
#[deku(count = "hdr_len")]
|
||||
msg: Vec<u8>,
|
||||
},
|
||||
#[deku(id = "0xb17f")]
|
||||
LteMl1ServingCellMeasurementAndEvaluation {
|
||||
data: measurement::serving_cell::MeasurementAndEvaluation,
|
||||
},
|
||||
#[deku(id = "0xb180")]
|
||||
LteMl1NeighborCellsMeasurements {
|
||||
data: measurement::neighbor_cells::Measurements,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
||||
#[deku(ctx = "log_type: u16", id = "log_type")]
|
||||
pub enum Nas4GMessageDirection {
|
||||
// * 0xb0e2: plain ESM NAS message (incoming)
|
||||
// * 0xb0e3: plain ESM NAS message (outgoing)
|
||||
// * 0xb0ec: plain EMM NAS message (incoming)
|
||||
// * 0xb0ed: plain EMM NAS message (outgoing)
|
||||
#[deku(id_pat = "0xb0e2 | 0xb0ec")]
|
||||
Downlink,
|
||||
#[deku(id_pat = "0xb0e3 | 0xb0ed")]
|
||||
Uplink,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
||||
#[deku(endian = "little")]
|
||||
pub struct Timestamp {
|
||||
pub ts: u64,
|
||||
}
|
||||
|
||||
impl Timestamp {
|
||||
pub fn to_datetime(&self) -> DateTime<FixedOffset> {
|
||||
// Upper 48 bits: epoch at 1980-01-06 00:00:00, incremented by 1 for 1/800s
|
||||
// Lower 16 bits: time since last 1/800s tick in 1/32 chip units
|
||||
let ts_upper = self.ts >> 16;
|
||||
let ts_lower = self.ts & 0xffff;
|
||||
let epoch = chrono::DateTime::parse_from_rfc3339("1980-01-06T00:00:00-00:00").unwrap();
|
||||
let mut delta_seconds = ts_upper as f64 * 1.25;
|
||||
delta_seconds += ts_lower as f64 / 40960.0;
|
||||
let ts_delta = chrono::Duration::milliseconds(delta_seconds as i64);
|
||||
epoch + ts_delta
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::diag::Message;
|
||||
|
||||
#[test]
|
||||
fn test_logs() {
|
||||
let data = vec![
|
||||
16, 0, 38, 0, 38, 0, 192, 176, 26, 165, 245, 135, 118, 35, 2, 1, 20, 14, 48, 0, 160, 0,
|
||||
2, 8, 0, 0, 217, 15, 5, 0, 0, 0, 0, 7, 0, 64, 1, 238, 173, 213, 77, 208,
|
||||
];
|
||||
let msg = Message::from_bytes((&data, 0)).unwrap().1;
|
||||
assert_eq!(
|
||||
msg,
|
||||
Message::Log {
|
||||
pending_msgs: 0,
|
||||
outer_length: 38,
|
||||
inner_length: 38,
|
||||
log_type: 0xb0c0,
|
||||
timestamp: Timestamp {
|
||||
ts: 72659535985485082
|
||||
},
|
||||
body: LogBody::LteRrcOtaMessage {
|
||||
ext_header_version: 20,
|
||||
packet: rrc::LteRrcOtaPacket::V8 {
|
||||
rrc_rel_maj: 14,
|
||||
rrc_rel_min: 48,
|
||||
bearer_id: 0,
|
||||
phy_cell_id: 160,
|
||||
earfcn: 2050,
|
||||
sfn_subfn: 4057,
|
||||
pdu_num: 5,
|
||||
sib_mask: 0,
|
||||
len: 7,
|
||||
packet: vec![0x40, 0x1, 0xee, 0xad, 0xd5, 0x4d, 0xd0],
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fuzz_crash_inner_length_underflow() {
|
||||
// Regression test: inner_length < 12 previously caused panic.
|
||||
// Fixed by using saturating_sub in Message::Log body length calculation.
|
||||
let fuzz_data = b"\x10\x00\x00\x00\x05\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00";
|
||||
let _ = Message::from_bytes((fuzz_data, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fuzz_crash_nas_hdr_len_underflow() {
|
||||
// Regression test for two things:
|
||||
// - hdr_len < 4 previously caused panic in Nas4GMessage.
|
||||
// - Upgrading to deku 0.20 caused incorrect parsing behavior (double-read of discriminant)
|
||||
let nas_msg =
|
||||
b"\x10\x00\x14\x00\x02\x00\xe2\xb0\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00";
|
||||
|
||||
let ((rest, _), msg) = Message::from_bytes((nas_msg, 0)).unwrap();
|
||||
|
||||
assert_eq!(rest.len(), 0);
|
||||
assert!(
|
||||
matches!(
|
||||
msg,
|
||||
Message::Log {
|
||||
log_type: 0xb0e2,
|
||||
body: LogBody::Nas4GMessage {
|
||||
direction: Nas4GMessageDirection::Downlink,
|
||||
..
|
||||
},
|
||||
..
|
||||
}
|
||||
),
|
||||
"Unexpected message: {:?}",
|
||||
msg
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fuzz_crash_ip_traffic_hdr_len_underflow() {
|
||||
// Regression test: hdr_len < 8 previously caused panic in IpTraffic.
|
||||
// Fixed by using saturating_sub for msg length calculation.
|
||||
let ip_msg = b"\x10\x00\x14\x00\x02\x00\xeb\x11\x00\x00\x00\x00\x00\x00\x00\x00\x03\x00";
|
||||
let _ = Message::from_bytes((ip_msg, 0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//! Diag LTE RRC serialization/deserialization
|
||||
|
||||
use deku::prelude::*;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
||||
#[deku(ctx = "ext_header_version: u8", id = "ext_header_version")]
|
||||
pub enum LteRrcOtaPacket {
|
||||
#[deku(id_pat = "0..=4")]
|
||||
V0 {
|
||||
rrc_rel_maj: u8,
|
||||
rrc_rel_min: u8,
|
||||
bearer_id: u8,
|
||||
phy_cell_id: u16,
|
||||
earfcn: u16,
|
||||
sfn_subfn: u16,
|
||||
pdu_num: u8,
|
||||
len: u16,
|
||||
#[deku(count = "len")]
|
||||
packet: Vec<u8>,
|
||||
},
|
||||
#[deku(id_pat = "5..=7")]
|
||||
V5 {
|
||||
rrc_rel_maj: u8,
|
||||
rrc_rel_min: u8,
|
||||
bearer_id: u8,
|
||||
phy_cell_id: u16,
|
||||
earfcn: u16,
|
||||
sfn_subfn: u16,
|
||||
pdu_num: u8,
|
||||
sib_mask: u32,
|
||||
len: u16,
|
||||
#[deku(count = "len")]
|
||||
packet: Vec<u8>,
|
||||
},
|
||||
#[deku(id_pat = "8..=24")]
|
||||
V8 {
|
||||
rrc_rel_maj: u8,
|
||||
rrc_rel_min: u8,
|
||||
bearer_id: u8,
|
||||
phy_cell_id: u16,
|
||||
earfcn: u32,
|
||||
sfn_subfn: u16,
|
||||
pdu_num: u8,
|
||||
sib_mask: u32,
|
||||
len: u16,
|
||||
#[deku(count = "len")]
|
||||
packet: Vec<u8>,
|
||||
},
|
||||
#[deku(id_pat = "25..")]
|
||||
V25 {
|
||||
rrc_rel_maj: u8,
|
||||
rrc_rel_min: u8,
|
||||
nr_rrc_rel_maj: u8,
|
||||
nr_rrc_rel_min: u8,
|
||||
bearer_id: u8,
|
||||
phy_cell_id: u16,
|
||||
earfcn: u32,
|
||||
sfn_subfn: u16,
|
||||
pdu_num: u8,
|
||||
sib_mask: u32,
|
||||
len: u16,
|
||||
#[deku(count = "len")]
|
||||
packet: Vec<u8>,
|
||||
},
|
||||
}
|
||||
|
||||
impl LteRrcOtaPacket {
|
||||
fn get_sfn_subfn(&self) -> u16 {
|
||||
match self {
|
||||
LteRrcOtaPacket::V0 { sfn_subfn, .. } => *sfn_subfn,
|
||||
LteRrcOtaPacket::V5 { sfn_subfn, .. } => *sfn_subfn,
|
||||
LteRrcOtaPacket::V8 { sfn_subfn, .. } => *sfn_subfn,
|
||||
LteRrcOtaPacket::V25 { sfn_subfn, .. } => *sfn_subfn,
|
||||
}
|
||||
}
|
||||
pub fn get_sfn(&self) -> u32 {
|
||||
self.get_sfn_subfn() as u32 >> 4
|
||||
}
|
||||
|
||||
pub fn get_subfn(&self) -> u8 {
|
||||
(self.get_sfn_subfn() & 0xf) as u8
|
||||
}
|
||||
|
||||
pub fn get_pdu_num(&self) -> u8 {
|
||||
match self {
|
||||
LteRrcOtaPacket::V0 { pdu_num, .. } => *pdu_num,
|
||||
LteRrcOtaPacket::V5 { pdu_num, .. } => *pdu_num,
|
||||
LteRrcOtaPacket::V8 { pdu_num, .. } => *pdu_num,
|
||||
LteRrcOtaPacket::V25 { pdu_num, .. } => *pdu_num,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_earfcn(&self) -> u32 {
|
||||
match self {
|
||||
LteRrcOtaPacket::V0 { earfcn, .. } => *earfcn as u32,
|
||||
LteRrcOtaPacket::V5 { earfcn, .. } => *earfcn as u32,
|
||||
LteRrcOtaPacket::V8 { earfcn, .. } => *earfcn,
|
||||
LteRrcOtaPacket::V25 { earfcn, .. } => *earfcn,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn take_payload(self) -> Vec<u8> {
|
||||
match self {
|
||||
LteRrcOtaPacket::V0 { packet, .. } => packet,
|
||||
LteRrcOtaPacket::V5 { packet, .. } => packet,
|
||||
LteRrcOtaPacket::V8 { packet, .. } => packet,
|
||||
LteRrcOtaPacket::V25 { packet, .. } => packet,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
//! Diag protocol serialization/deserialization
|
||||
|
||||
use chrono::{DateTime, FixedOffset};
|
||||
use crc::{Algorithm, Crc};
|
||||
use deku::prelude::*;
|
||||
|
||||
@@ -8,6 +7,10 @@ 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;
|
||||
|
||||
@@ -152,218 +155,6 @@ pub enum Message {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
||||
#[deku(ctx = "log_type: u16, hdr_len: u16", id = "log_type")]
|
||||
pub enum LogBody {
|
||||
#[deku(id = "0x412f")]
|
||||
WcdmaSignallingMessage {
|
||||
channel_type: u8,
|
||||
radio_bearer: u8,
|
||||
length: u16,
|
||||
#[deku(count = "length")]
|
||||
msg: Vec<u8>,
|
||||
},
|
||||
#[deku(id = "0x512f")]
|
||||
GsmRrSignallingMessage {
|
||||
channel_type: u8,
|
||||
message_type: u8,
|
||||
length: u8,
|
||||
#[deku(count = "length")]
|
||||
msg: Vec<u8>,
|
||||
},
|
||||
#[deku(id = "0x5226")]
|
||||
GprsMacSignallingMessage {
|
||||
channel_type: u8,
|
||||
message_type: u8,
|
||||
length: u8,
|
||||
#[deku(count = "length")]
|
||||
msg: Vec<u8>,
|
||||
},
|
||||
#[deku(id = "0xb0c0")]
|
||||
LteRrcOtaMessage {
|
||||
ext_header_version: u8,
|
||||
#[deku(ctx = "*ext_header_version")]
|
||||
packet: LteRrcOtaPacket,
|
||||
},
|
||||
// the four NAS command opcodes refer to:
|
||||
// * 0xb0e2: plain ESM NAS message (incoming)
|
||||
// * 0xb0e3: plain ESM NAS message (outgoing)
|
||||
// * 0xb0ec: plain EMM NAS message (incoming)
|
||||
// * 0xb0ed: plain EMM NAS message (outgoing)
|
||||
#[deku(id_pat = "0xb0e2 | 0xb0e3 | 0xb0ec | 0xb0ed")]
|
||||
Nas4GMessage {
|
||||
#[deku(skip, default = "log_type")]
|
||||
log_type: u16,
|
||||
#[deku(ctx = "*log_type")]
|
||||
direction: Nas4GMessageDirection,
|
||||
ext_header_version: u8,
|
||||
rrc_rel: u8,
|
||||
rrc_version_minor: u8,
|
||||
rrc_version_major: u8,
|
||||
// message length = hdr_len - (sizeof(ext_header_version) + sizeof(rrc_rel) + sizeof(rrc_version_minor) + sizeof(rrc_version_major))
|
||||
#[deku(count = "hdr_len.saturating_sub(4)")]
|
||||
msg: Vec<u8>,
|
||||
},
|
||||
#[deku(id = "0x11eb")]
|
||||
IpTraffic {
|
||||
// is this right?? based on https://github.com/P1sec/QCSuper/blob/81dbaeee15ec7747e899daa8e3495e27cdcc1264/src/modules/pcap_dump.py#L378
|
||||
#[deku(count = "hdr_len.saturating_sub(8)")]
|
||||
msg: Vec<u8>,
|
||||
},
|
||||
#[deku(id = "0x713a")]
|
||||
UmtsNasOtaMessage {
|
||||
is_uplink: u8,
|
||||
length: u32,
|
||||
#[deku(count = "length")]
|
||||
msg: Vec<u8>,
|
||||
},
|
||||
#[deku(id = "0xb821")]
|
||||
NrRrcOtaMessage {
|
||||
#[deku(count = "hdr_len")]
|
||||
msg: Vec<u8>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
||||
#[deku(ctx = "log_type: u16", id = "log_type")]
|
||||
pub enum Nas4GMessageDirection {
|
||||
// * 0xb0e2: plain ESM NAS message (incoming)
|
||||
// * 0xb0e3: plain ESM NAS message (outgoing)
|
||||
// * 0xb0ec: plain EMM NAS message (incoming)
|
||||
// * 0xb0ed: plain EMM NAS message (outgoing)
|
||||
#[deku(id_pat = "0xb0e2 | 0xb0ec")]
|
||||
Downlink,
|
||||
#[deku(id_pat = "0xb0e3 | 0xb0ed")]
|
||||
Uplink,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
||||
#[deku(ctx = "ext_header_version: u8", id = "ext_header_version")]
|
||||
pub enum LteRrcOtaPacket {
|
||||
#[deku(id_pat = "0..=4")]
|
||||
V0 {
|
||||
rrc_rel_maj: u8,
|
||||
rrc_rel_min: u8,
|
||||
bearer_id: u8,
|
||||
phy_cell_id: u16,
|
||||
earfcn: u16,
|
||||
sfn_subfn: u16,
|
||||
pdu_num: u8,
|
||||
len: u16,
|
||||
#[deku(count = "len")]
|
||||
packet: Vec<u8>,
|
||||
},
|
||||
#[deku(id_pat = "5..=7")]
|
||||
V5 {
|
||||
rrc_rel_maj: u8,
|
||||
rrc_rel_min: u8,
|
||||
bearer_id: u8,
|
||||
phy_cell_id: u16,
|
||||
earfcn: u16,
|
||||
sfn_subfn: u16,
|
||||
pdu_num: u8,
|
||||
sib_mask: u32,
|
||||
len: u16,
|
||||
#[deku(count = "len")]
|
||||
packet: Vec<u8>,
|
||||
},
|
||||
#[deku(id_pat = "8..=24")]
|
||||
V8 {
|
||||
rrc_rel_maj: u8,
|
||||
rrc_rel_min: u8,
|
||||
bearer_id: u8,
|
||||
phy_cell_id: u16,
|
||||
earfcn: u32,
|
||||
sfn_subfn: u16,
|
||||
pdu_num: u8,
|
||||
sib_mask: u32,
|
||||
len: u16,
|
||||
#[deku(count = "len")]
|
||||
packet: Vec<u8>,
|
||||
},
|
||||
#[deku(id_pat = "25..")]
|
||||
V25 {
|
||||
rrc_rel_maj: u8,
|
||||
rrc_rel_min: u8,
|
||||
nr_rrc_rel_maj: u8,
|
||||
nr_rrc_rel_min: u8,
|
||||
bearer_id: u8,
|
||||
phy_cell_id: u16,
|
||||
earfcn: u32,
|
||||
sfn_subfn: u16,
|
||||
pdu_num: u8,
|
||||
sib_mask: u32,
|
||||
len: u16,
|
||||
#[deku(count = "len")]
|
||||
packet: Vec<u8>,
|
||||
},
|
||||
}
|
||||
|
||||
impl LteRrcOtaPacket {
|
||||
fn get_sfn_subfn(&self) -> u16 {
|
||||
match self {
|
||||
LteRrcOtaPacket::V0 { sfn_subfn, .. } => *sfn_subfn,
|
||||
LteRrcOtaPacket::V5 { sfn_subfn, .. } => *sfn_subfn,
|
||||
LteRrcOtaPacket::V8 { sfn_subfn, .. } => *sfn_subfn,
|
||||
LteRrcOtaPacket::V25 { sfn_subfn, .. } => *sfn_subfn,
|
||||
}
|
||||
}
|
||||
pub fn get_sfn(&self) -> u32 {
|
||||
self.get_sfn_subfn() as u32 >> 4
|
||||
}
|
||||
|
||||
pub fn get_subfn(&self) -> u8 {
|
||||
(self.get_sfn_subfn() & 0xf) as u8
|
||||
}
|
||||
|
||||
pub fn get_pdu_num(&self) -> u8 {
|
||||
match self {
|
||||
LteRrcOtaPacket::V0 { pdu_num, .. } => *pdu_num,
|
||||
LteRrcOtaPacket::V5 { pdu_num, .. } => *pdu_num,
|
||||
LteRrcOtaPacket::V8 { pdu_num, .. } => *pdu_num,
|
||||
LteRrcOtaPacket::V25 { pdu_num, .. } => *pdu_num,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_earfcn(&self) -> u32 {
|
||||
match self {
|
||||
LteRrcOtaPacket::V0 { earfcn, .. } => *earfcn as u32,
|
||||
LteRrcOtaPacket::V5 { earfcn, .. } => *earfcn as u32,
|
||||
LteRrcOtaPacket::V8 { earfcn, .. } => *earfcn,
|
||||
LteRrcOtaPacket::V25 { earfcn, .. } => *earfcn,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn take_payload(self) -> Vec<u8> {
|
||||
match self {
|
||||
LteRrcOtaPacket::V0 { packet, .. } => packet,
|
||||
LteRrcOtaPacket::V5 { packet, .. } => packet,
|
||||
LteRrcOtaPacket::V8 { packet, .. } => packet,
|
||||
LteRrcOtaPacket::V25 { packet, .. } => packet,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
||||
#[deku(endian = "little")]
|
||||
pub struct Timestamp {
|
||||
pub ts: u64,
|
||||
}
|
||||
|
||||
impl Timestamp {
|
||||
pub fn to_datetime(&self) -> DateTime<FixedOffset> {
|
||||
// Upper 48 bits: epoch at 1980-01-06 00:00:00, incremented by 1 for 1/800s
|
||||
// Lower 16 bits: time since last 1/800s tick in 1/32 chip units
|
||||
let ts_upper = self.ts >> 16;
|
||||
let ts_lower = self.ts & 0xffff;
|
||||
let epoch = chrono::DateTime::parse_from_rfc3339("1980-01-06T00:00:00-00:00").unwrap();
|
||||
let mut delta_seconds = ts_upper as f64 * 1.25;
|
||||
delta_seconds += ts_lower as f64 / 40960.0;
|
||||
let ts_delta = chrono::Duration::milliseconds(delta_seconds as i64);
|
||||
epoch + ts_delta
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, DekuRead, DekuWrite)]
|
||||
#[deku(ctx = "opcode: u32, subopcode: u32", id = "opcode")]
|
||||
pub enum ResponsePayload {
|
||||
@@ -478,42 +269,6 @@ mod test {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_logs() {
|
||||
let data = vec![
|
||||
16, 0, 38, 0, 38, 0, 192, 176, 26, 165, 245, 135, 118, 35, 2, 1, 20, 14, 48, 0, 160, 0,
|
||||
2, 8, 0, 0, 217, 15, 5, 0, 0, 0, 0, 7, 0, 64, 1, 238, 173, 213, 77, 208,
|
||||
];
|
||||
let msg = Message::from_bytes((&data, 0)).unwrap().1;
|
||||
assert_eq!(
|
||||
msg,
|
||||
Message::Log {
|
||||
pending_msgs: 0,
|
||||
outer_length: 38,
|
||||
inner_length: 38,
|
||||
log_type: 0xb0c0,
|
||||
timestamp: Timestamp {
|
||||
ts: 72659535985485082
|
||||
},
|
||||
body: LogBody::LteRrcOtaMessage {
|
||||
ext_header_version: 20,
|
||||
packet: LteRrcOtaPacket::V8 {
|
||||
rrc_rel_maj: 14,
|
||||
rrc_rel_min: 48,
|
||||
bearer_id: 0,
|
||||
phy_cell_id: 160,
|
||||
earfcn: 2050,
|
||||
sfn_subfn: 4057,
|
||||
pdu_num: 5,
|
||||
sib_mask: 0,
|
||||
len: 7,
|
||||
packet: vec![0x40, 0x1, 0xee, 0xad, 0xd5, 0x4d, 0xd0],
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
fn make_container(data_type: DataType, message: HdlcEncapsulatedMessage) -> MessagesContainer {
|
||||
MessagesContainer {
|
||||
data_type,
|
||||
@@ -537,7 +292,7 @@ mod test {
|
||||
},
|
||||
body: LogBody::LteRrcOtaMessage {
|
||||
ext_header_version: 20,
|
||||
packet: LteRrcOtaPacket::V8 {
|
||||
packet: diaglog::rrc::LteRrcOtaPacket::V8 {
|
||||
rrc_rel_maj: 14,
|
||||
rrc_rel_min: 48,
|
||||
bearer_id: 0,
|
||||
@@ -619,50 +374,6 @@ mod test {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fuzz_crash_inner_length_underflow() {
|
||||
// Regression test: inner_length < 12 previously caused panic.
|
||||
// Fixed by using saturating_sub in Message::Log body length calculation.
|
||||
let fuzz_data = b"\x10\x00\x00\x00\x05\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00";
|
||||
let _ = Message::from_bytes((fuzz_data, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fuzz_crash_nas_hdr_len_underflow() {
|
||||
// Regression test for two things:
|
||||
// - hdr_len < 4 previously caused panic in Nas4GMessage.
|
||||
// - Upgrading to deku 0.20 caused incorrect parsing behavior (double-read of discriminant)
|
||||
let nas_msg =
|
||||
b"\x10\x00\x14\x00\x02\x00\xe2\xb0\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00";
|
||||
|
||||
let ((rest, _), msg) = Message::from_bytes((nas_msg, 0)).unwrap();
|
||||
|
||||
assert_eq!(rest.len(), 0);
|
||||
assert!(
|
||||
matches!(
|
||||
msg,
|
||||
Message::Log {
|
||||
log_type: 0xb0e2,
|
||||
body: LogBody::Nas4GMessage {
|
||||
direction: Nas4GMessageDirection::Downlink,
|
||||
..
|
||||
},
|
||||
..
|
||||
}
|
||||
),
|
||||
"Unexpected message: {:?}",
|
||||
msg
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fuzz_crash_ip_traffic_hdr_len_underflow() {
|
||||
// Regression test: hdr_len < 8 previously caused panic in IpTraffic.
|
||||
// Fixed by using saturating_sub for msg length calculation.
|
||||
let ip_msg = b"\x10\x00\x14\x00\x02\x00\xeb\x11\x00\x00\x00\x00\x00\x00\x00\x00\x03\x00";
|
||||
let _ = Message::from_bytes((ip_msg, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fuzz_crash_response_opcode_parsing() {
|
||||
// Regression test: Upgrading to deku 0.20 caused incorrect parsing of Response messages.
|
||||
@@ -40,7 +40,7 @@ pub enum DiagDeviceError {
|
||||
ParseMessagesContainerError(deku::DekuError),
|
||||
}
|
||||
|
||||
pub const LOG_CODES_FOR_RAW_PACKET_LOGGING: [u32; 11] = [
|
||||
pub const LOG_CODES_FOR_RAW_PACKET_LOGGING: [u32; 14] = [
|
||||
// Layer 2:
|
||||
log_codes::LOG_GPRS_MAC_SIGNALLING_MESSAGE_C, // 0x5226
|
||||
// Layer 3:
|
||||
@@ -56,6 +56,10 @@ pub const LOG_CODES_FOR_RAW_PACKET_LOGGING: [u32; 11] = [
|
||||
log_codes::LOG_LTE_NAS_EMM_OTA_OUT_MSG_LOG_C, // 0xb0ed
|
||||
// User IP traffic:
|
||||
log_codes::LOG_DATA_PROTOCOL_LOGGING_C, // 0x11eb
|
||||
// Statistics
|
||||
log_codes::LOG_LTE_ML1_SERVING_CELL_MEAS_RESP_AND_EVAL, // 0xb17f
|
||||
log_codes::LOG_LTE_ML1_SERVING_CELL_MEAS_RESPONSE, // 0xb193
|
||||
log_codes::LOG_LTE_ML1_NEIGHBOR_MEAS, // 0xb180
|
||||
];
|
||||
|
||||
const BUFFER_LEN: usize = 1024 * 1024 * 10;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use crate::diag::*;
|
||||
use crate::gsmtap::*;
|
||||
use crate::diag::Message;
|
||||
use crate::diag::diaglog::{Timestamp, LogBody, Nas4GMessageDirection};
|
||||
use crate::gsmtap::{GsmtapHeader, GsmtapMessage, GsmtapType, LteNasSubtype, LteRrcSubtype};
|
||||
|
||||
use log::error;
|
||||
use log::debug;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
@@ -153,7 +154,7 @@ fn log_to_gsmtap(value: LogBody) -> Result<Option<GsmtapMessage>, GsmtapParserEr
|
||||
}))
|
||||
}
|
||||
_ => {
|
||||
error!("gsmtap_sink: ignoring unhandled log type: {value:?}");
|
||||
debug!("gsmtap_sink: ignoring unhandled log type: {value:?}");
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,3 +103,7 @@ 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
@@ -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::Timestamp;
|
||||
use crate::diag::diaglog::Timestamp;
|
||||
use crate::gsmtap::GsmtapMessage;
|
||||
|
||||
use chrono::prelude::*;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use deku::prelude::*;
|
||||
use rayhunter::{
|
||||
diag::{LogBody, LteRrcOtaPacket, Message, Timestamp},
|
||||
diag::Message,
|
||||
diag::diaglog::{LogBody, rrc::LteRrcOtaPacket, Timestamp},
|
||||
gsmtap_parser,
|
||||
};
|
||||
|
||||
|
||||
Executable
+86
@@ -0,0 +1,86 @@
|
||||
#!/bin/bash
|
||||
# Build Rayhunter from source for development.
|
||||
# Prerequisites: Rust (rustup) and Node.js (npm).
|
||||
#
|
||||
# Usage: ./scripts/build-dev.sh [build|frontend|check]
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
check_dependencies() {
|
||||
local missing=0
|
||||
|
||||
if ! command -v cargo &> /dev/null; then
|
||||
echo "Error: cargo not found. Install Rust via https://www.rust-lang.org/tools/install"
|
||||
missing=1
|
||||
fi
|
||||
|
||||
if ! command -v npm &> /dev/null; then
|
||||
echo "Error: npm not found. Install Node.js via https://docs.npmjs.com/downloading-and-installing-node-js-and-npm"
|
||||
missing=1
|
||||
fi
|
||||
|
||||
if [ "$missing" -eq 1 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure the ARM cross-compilation target is installed
|
||||
if ! rustup target list --installed | grep -q "armv7-unknown-linux-musleabihf"; then
|
||||
echo "Installing ARM target (armv7-unknown-linux-musleabihf)..."
|
||||
rustup target add armv7-unknown-linux-musleabihf
|
||||
fi
|
||||
}
|
||||
|
||||
build_frontend() {
|
||||
echo "Building web frontend..."
|
||||
pushd daemon/web > /dev/null
|
||||
npm install
|
||||
npm run build
|
||||
popd > /dev/null
|
||||
}
|
||||
|
||||
build_daemon() {
|
||||
echo "Building daemon..."
|
||||
cargo build-daemon-firmware-devel
|
||||
|
||||
echo "Building rootshell..."
|
||||
cargo build-rootshell-firmware-devel
|
||||
}
|
||||
|
||||
COMMAND="${1:-build}"
|
||||
|
||||
case "$COMMAND" in
|
||||
build)
|
||||
check_dependencies
|
||||
build_frontend
|
||||
build_daemon
|
||||
echo ""
|
||||
echo "Build complete! To install to a device, run:"
|
||||
echo " ./scripts/install-dev.sh <device>"
|
||||
echo ""
|
||||
echo "Replace <device> with your device type (e.g. orbic, tplink)."
|
||||
;;
|
||||
frontend)
|
||||
build_frontend
|
||||
;;
|
||||
check)
|
||||
check_dependencies
|
||||
;;
|
||||
help|--help|-h)
|
||||
echo "Usage: $0 [command]"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " build Build frontend, daemon, and rootshell (default)"
|
||||
echo " frontend Build only the web frontend"
|
||||
echo " check Check dependencies only"
|
||||
;;
|
||||
*)
|
||||
echo "Unknown command: $COMMAND"
|
||||
echo "Run '$0 help' for usage."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
# Install a development build of Rayhunter to a device.
|
||||
# Run ./scripts/build-dev.sh first.
|
||||
#
|
||||
# Usage: ./scripts/install-dev.sh <device> [options...]
|
||||
# Example: ./scripts/install-dev.sh orbic --admin-password mypass
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
cargo run -p installer --bin installer -- "$@"
|
||||
Reference in New Issue
Block a user