mirror of
https://github.com/EFForg/rayhunter.git
synced 2026-05-31 02:03:35 -07:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 369a1ba3af | |||
| d6d10ca3a4 | |||
| 728fbe0f4d | |||
| ed2781a4be | |||
| ffcf683ae5 | |||
| 49fd777c83 | |||
| 84a3155a1f | |||
| 184f4bd7a2 | |||
| f7759721e3 | |||
| 744d0772c2 | |||
| 2cd49b3757 | |||
| e44230c043 | |||
| e27da68b5d | |||
| 2a68c99897 | |||
| 987d95c23e | |||
| 9ef6b43dac |
Generated
+21
-21
@@ -1329,12 +1329,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.4.0"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e"
|
||||
checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587"
|
||||
dependencies = [
|
||||
"powerfmt",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2725,7 +2725,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "installer"
|
||||
version = "0.10.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"adb_client",
|
||||
"aes",
|
||||
@@ -2753,7 +2753,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "installer-gui"
|
||||
version = "0.10.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"installer",
|
||||
@@ -3430,9 +3430,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
|
||||
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
|
||||
|
||||
[[package]]
|
||||
name = "num-derive"
|
||||
@@ -4671,7 +4671,7 @@ checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
|
||||
|
||||
[[package]]
|
||||
name = "rayhunter"
|
||||
version = "0.10.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"chrono",
|
||||
@@ -4694,7 +4694,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rayhunter-check"
|
||||
version = "0.10.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"futures",
|
||||
@@ -4707,7 +4707,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rayhunter-daemon"
|
||||
version = "0.10.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -4895,16 +4895,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rootshell"
|
||||
version = "0.10.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"nix 0.29.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsa"
|
||||
version = "0.9.8"
|
||||
version = "0.9.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b"
|
||||
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"digest",
|
||||
@@ -5951,7 +5951,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "telcom-parser"
|
||||
version = "0.10.0"
|
||||
version = "0.10.1"
|
||||
dependencies = [
|
||||
"asn1-codecs",
|
||||
"asn1-compiler",
|
||||
@@ -6057,30 +6057,30 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.41"
|
||||
version = "0.3.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40"
|
||||
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"itoa",
|
||||
"num-conv",
|
||||
"powerfmt",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"time-core",
|
||||
"time-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time-core"
|
||||
version = "0.1.4"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c"
|
||||
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
|
||||
|
||||
[[package]]
|
||||
name = "time-macros"
|
||||
version = "0.2.22"
|
||||
version = "0.2.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49"
|
||||
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
|
||||
dependencies = [
|
||||
"num-conv",
|
||||
"time-core",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rayhunter-check"
|
||||
version = "0.10.0"
|
||||
version = "0.10.1"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rayhunter-daemon"
|
||||
version = "0.10.0"
|
||||
version = "0.10.1"
|
||||
edition = "2024"
|
||||
rust-version = "1.88.0"
|
||||
|
||||
|
||||
+1
-2
@@ -25,7 +25,7 @@ use crate::server::{
|
||||
ServerState, debug_set_display_state, get_config, get_qmdl, get_time, get_zip, serve_static,
|
||||
set_config, set_time_offset, test_notification,
|
||||
};
|
||||
use crate::stats::{get_qmdl_manifest, get_route_status, get_system_stats};
|
||||
use crate::stats::{get_qmdl_manifest, get_system_stats};
|
||||
|
||||
use analysis::{
|
||||
AnalysisCtrlMessage, AnalysisStatus, get_analysis_status, run_analysis_thread, start_analysis,
|
||||
@@ -58,7 +58,6 @@ fn get_router() -> AppRouter {
|
||||
.route("/api/qmdl/{name}", get(get_qmdl))
|
||||
.route("/api/zip/{name}", get(get_zip))
|
||||
.route("/api/system-stats", get(get_system_stats))
|
||||
.route("/api/route-status", get(get_route_status))
|
||||
.route("/api/qmdl-manifest", get(get_qmdl_manifest))
|
||||
.route("/api/log", get(get_log))
|
||||
.route("/api/start-recording", post(start_recording))
|
||||
|
||||
+46
-30
@@ -9,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,
|
||||
@@ -20,6 +63,8 @@ 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 {
|
||||
@@ -36,6 +81,7 @@ impl SystemStats {
|
||||
None
|
||||
}
|
||||
},
|
||||
cell_info: get_cell_info(device),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -174,33 +220,3 @@ pub async fn get_log() -> Result<String, (StatusCode, String)> {
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RouteStatus {
|
||||
pub has_default_route: bool,
|
||||
}
|
||||
|
||||
pub async fn get_route_status() -> Json<RouteStatus> {
|
||||
let has_default_route = match check_default_route().await {
|
||||
Ok(result) => result,
|
||||
Err(err) => {
|
||||
log::warn!("Failed to check default route: {err}");
|
||||
// More likely than not, this is a portability issue. The logic hasn't been fully
|
||||
// tested on all devices. We shouldn't scare users unnecessarily.
|
||||
true
|
||||
}
|
||||
};
|
||||
|
||||
Json(RouteStatus { has_default_route })
|
||||
}
|
||||
|
||||
// Checks for an IPv4 default route by reading /proc/net/route
|
||||
// instead of shelling out to `ip route`, which may not be available on all devices.
|
||||
async fn check_default_route() -> std::io::Result<bool> {
|
||||
let contents = tokio::fs::read_to_string("/proc/net/route").await?;
|
||||
Ok(contents.lines().skip(1).any(|line| {
|
||||
line.split_whitespace()
|
||||
.nth(1)
|
||||
.is_some_and(|dest| dest == "00000000")
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { action_errors } from '../action_errors.svelte';
|
||||
import WarningIcon from './WarningIcon.svelte';
|
||||
|
||||
let pos = $state(0);
|
||||
let current_error = $derived(action_errors[pos]);
|
||||
@@ -26,7 +25,21 @@
|
||||
>
|
||||
<div class="flex flex-row justify-between">
|
||||
<span class="text-xl font-bold mb-2 mr-5 flex flex-row items-center gap-1 text-red-600">
|
||||
<WarningIcon class="w-6 h-6 text-red-600" />
|
||||
<svg
|
||||
class="w-6 h-6 text-red-600"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm11-4a1 1 0 1 0-2 0v5a1 1 0 1 0 2 0V8Zm-1 7a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H12Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Error Completing Action {current_error.times > 1 ? `x${current_error.times}` : ''}
|
||||
</span>
|
||||
<div class="flex items-center mb-2">
|
||||
|
||||
@@ -335,6 +335,20 @@
|
||||
Test Heuristic (noisy!)
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
id="diagnostic_analyzer"
|
||||
type="checkbox"
|
||||
bind:checked={config.analyzers.diagnostic_analyzer}
|
||||
class="h-4 w-4 text-rayhunter-blue focus:ring-rayhunter-blue border-gray-300 rounded"
|
||||
/>
|
||||
<label
|
||||
for="diagnostic_analyzer"
|
||||
class="ml-2 block text-sm text-gray-700"
|
||||
>
|
||||
Diagnostic Analyzer
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { get_route_status } from '$lib/utils.svelte';
|
||||
import WarningIcon from './WarningIcon.svelte';
|
||||
|
||||
let show_alert = $state(false);
|
||||
let check_completed = $state(false);
|
||||
|
||||
async function check_route() {
|
||||
if (check_completed) return;
|
||||
|
||||
try {
|
||||
const status = await get_route_status();
|
||||
if (!status.has_default_route) {
|
||||
show_alert = true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to check route status:', err);
|
||||
}
|
||||
check_completed = true;
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
show_alert = false;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
check_route();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if show_alert}
|
||||
<div
|
||||
class="bg-yellow-100 border-yellow-400 drop-shadow p-4 flex flex-col gap-2 border rounded-md"
|
||||
>
|
||||
<span class="text-xl font-bold flex flex-row items-center gap-2 text-yellow-700">
|
||||
<WarningIcon class="w-6 h-6 text-yellow-600" />
|
||||
No Default Route Detected
|
||||
</span>
|
||||
<p>
|
||||
This device didn't get an IP address from the network operator. Presumably the SIM card
|
||||
is not inserted or very old. Try a different SIM card.
|
||||
</p>
|
||||
<div class="flex flex-row gap-2 justify-end">
|
||||
<button
|
||||
class="font-medium py-2 px-4 rounded-md border border-gray-400 hover:bg-yellow-200"
|
||||
onclick={dismiss}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -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>
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
<script lang="ts">
|
||||
let { class: className = 'w-6 h-6' }: { class?: string } = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
class={className}
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm11-4a1 1 0 1 0-2 0v5a1 1 0 1 0 2 0V8Zm-1 7a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H12Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
@@ -3,6 +3,7 @@ export interface SystemStats {
|
||||
memory_stats: MemoryStats;
|
||||
runtime_metadata: RuntimeMetadata;
|
||||
battery_status?: BatteryStatus;
|
||||
cell_info?: CellSignalInfo;
|
||||
}
|
||||
|
||||
export interface RuntimeMetadata {
|
||||
@@ -30,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;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface AnalyzerConfig {
|
||||
nas_null_cipher: boolean;
|
||||
incomplete_sib: boolean;
|
||||
test_analyzer: boolean;
|
||||
diagnostic_analyzer: boolean;
|
||||
}
|
||||
|
||||
export enum enabled_notifications {
|
||||
@@ -101,14 +102,6 @@ export async function test_notification(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export interface RouteStatus {
|
||||
has_default_route: boolean;
|
||||
}
|
||||
|
||||
export async function get_route_status(): Promise<RouteStatus> {
|
||||
return JSON.parse(await req('GET', '/api/route-status'));
|
||||
}
|
||||
|
||||
export interface TimeResponse {
|
||||
system_time: string;
|
||||
adjusted_time: string;
|
||||
|
||||
@@ -10,10 +10,8 @@
|
||||
import RecordingControls from '$lib/components/RecordingControls.svelte';
|
||||
import ConfigForm from '$lib/components/ConfigForm.svelte';
|
||||
import ActionErrors from '$lib/components/ActionErrors.svelte';
|
||||
import IPRouteAlert from '$lib/components/IPRouteAlert.svelte';
|
||||
import ClockDriftAlert from '$lib/components/ClockDriftAlert.svelte';
|
||||
import LogView from '$lib/components/LogView.svelte';
|
||||
import WarningIcon from '$lib/components/WarningIcon.svelte';
|
||||
|
||||
let manager: AnalysisManager = new AnalysisManager();
|
||||
let loaded = $state(false);
|
||||
@@ -180,7 +178,21 @@
|
||||
class="bg-red-100 border-red-100 drop-shadow p-4 flex flex-col gap-2 border rounded-md flex-1 justify-between"
|
||||
>
|
||||
<span class="text-2xl font-bold mb-2 flex flex-row items-center gap-2 text-red-600">
|
||||
<WarningIcon class="w-8 h-8 text-red-600" />
|
||||
<svg
|
||||
class="w-8 h-8 text-red-600"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm11-4a1 1 0 1 0-2 0v5a1 1 0 1 0 2 0V8Zm-1 7a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H12Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Connection Error
|
||||
</span>
|
||||
<span
|
||||
@@ -196,7 +208,6 @@
|
||||
</div>
|
||||
{/if}
|
||||
<ActionErrors />
|
||||
<IPRouteAlert />
|
||||
<ClockDriftAlert />
|
||||
{#if loaded}
|
||||
<div class="flex flex-col lg:flex-row gap-4">
|
||||
@@ -214,7 +225,21 @@
|
||||
<span
|
||||
class="text-2xl font-bold mb-2 flex flex-row items-center gap-2 text-red-600"
|
||||
>
|
||||
<WarningIcon class="w-8 h-8 text-red-600" />
|
||||
<svg
|
||||
class="w-8 h-8 text-red-600"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm11-4a1 1 0 1 0-2 0v5a1 1 0 1 0 2 0V8Zm-1 7a1 1 0 1 0 0 2h.01a1 1 0 1 0 0-2H12Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
WARNING: Not Running
|
||||
</span>
|
||||
<span>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Vendored
+1
@@ -39,3 +39,4 @@ null_cipher = true
|
||||
nas_null_cipher = true
|
||||
incomplete_sib = true
|
||||
test_analyzer = false
|
||||
diagnostic_analyzer = true
|
||||
|
||||
@@ -73,6 +73,9 @@ This analyzer tests whether the SIB1 message contains a complete SIB chain (SIB3
|
||||
|
||||
On its own this might just be a misconfigured base station (though we have only seen it in the wild under suspicious circumstances) but combined with other heuristics such as **IMSI Requested** detection it should be considered as a strong indicator of malicious activity.
|
||||
|
||||
### Diagnostic Information
|
||||
This analyzer displays some diagnostic information about when your device connects and disconnects from certain towers. It is helpful for analysis of suspicious PCAPs. The informational warnings in here can safely be ignored until there is a low, medium, or high severity warning.
|
||||
|
||||
### Test Analyzer
|
||||
|
||||
This analyzer is great for testing if your Rayhunter installation works. It will alert every time a new tower is seen (specifically every time a tower broadcasts a SIB1 message.) It is designed to be very noisy so we do not recommend leaving it on but if this alerts it means your Rayhunter device is working!
|
||||
|
||||
@@ -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).
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "installer-gui"
|
||||
version = "0.10.0"
|
||||
version = "0.10.1"
|
||||
edition = "2024"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "installer"
|
||||
version = "0.10.0"
|
||||
version = "0.10.1"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rayhunter"
|
||||
version = "0.10.0"
|
||||
version = "0.10.1"
|
||||
edition = "2024"
|
||||
description = "Realtime cellular data decoding and analysis for IMSI catcher detection"
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ use pcap_file_tokio::pcapng::blocks::enhanced_packet::EnhancedPacketBlock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::borrow::Cow;
|
||||
|
||||
use crate::analysis::diagnostic::DiagnosticAnalyzer;
|
||||
use crate::gsmtap::{GsmtapHeader, GsmtapMessage, GsmtapType};
|
||||
use crate::util::RuntimeMetadata;
|
||||
use crate::{diag::MessagesContainer, gsmtap_parser};
|
||||
@@ -19,19 +20,21 @@ use super::{
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct AnalyzerConfig {
|
||||
pub imsi_requested: bool,
|
||||
pub diagnostic_analyzer: bool,
|
||||
pub connection_redirect_2g_downgrade: bool,
|
||||
pub lte_sib6_and_7_downgrade: bool,
|
||||
pub null_cipher: bool,
|
||||
pub nas_null_cipher: bool,
|
||||
pub incomplete_sib: bool,
|
||||
pub test_analyzer: bool,
|
||||
pub imsi_requested: bool,
|
||||
}
|
||||
|
||||
impl Default for AnalyzerConfig {
|
||||
fn default() -> Self {
|
||||
AnalyzerConfig {
|
||||
imsi_requested: true,
|
||||
diagnostic_analyzer: true,
|
||||
connection_redirect_2g_downgrade: true,
|
||||
lte_sib6_and_7_downgrade: true,
|
||||
null_cipher: true,
|
||||
@@ -346,6 +349,10 @@ impl Harness {
|
||||
harness.add_analyzer(Box::new(TestAnalyzer {}))
|
||||
}
|
||||
|
||||
if analyzer_config.diagnostic_analyzer {
|
||||
harness.add_analyzer(Box::new(DiagnosticAnalyzer {}));
|
||||
}
|
||||
|
||||
harness
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
use crate::analysis::analyzer::{Analyzer, Event, EventType};
|
||||
use crate::analysis::information_element::{InformationElement, LteInformationElement};
|
||||
use pycrate_rs::nas::NASMessage;
|
||||
use pycrate_rs::nas::emm::EMMMessage;
|
||||
use pycrate_rs::nas::generated::emm::emm_attach_reject::EMMCauseEMMCause as AttachRejectEMMCause;
|
||||
use pycrate_rs::nas::generated::emm::emm_detach_request_mt::EPSDetachTypeMTType;
|
||||
use pycrate_rs::nas::generated::emm::emm_service_reject::EMMCauseEMMCause as ServiceRejectEMMCause;
|
||||
use pycrate_rs::nas::generated::emm::emm_tracking_area_update_reject::EMMCauseEMMCause as TAURejectEMMCause;
|
||||
use std::borrow::Cow;
|
||||
|
||||
pub struct DiagnosticAnalyzer;
|
||||
|
||||
impl Default for DiagnosticAnalyzer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl DiagnosticAnalyzer {
|
||||
pub fn new() -> Self {
|
||||
DiagnosticAnalyzer
|
||||
}
|
||||
|
||||
fn is_imsi_exposing_nas(&self, nas_msg: &NASMessage) -> bool {
|
||||
match nas_msg {
|
||||
NASMessage::EMMMessage(emm_msg) => match emm_msg {
|
||||
EMMMessage::EMMIdentityRequest(_) => true, // Alert on all identity requests (IMSI, IMEI, IMEISV)
|
||||
|
||||
EMMMessage::EMMTrackingAreaUpdateReject(reject) => {
|
||||
matches!(
|
||||
reject.emm_cause.inner,
|
||||
TAURejectEMMCause::IllegalUE
|
||||
| TAURejectEMMCause::IllegalME
|
||||
| TAURejectEMMCause::EPSServicesNotAllowed
|
||||
| TAURejectEMMCause::EPSServicesAndNonEPSServicesNotAllowed
|
||||
| TAURejectEMMCause::TrackingAreaNotAllowed
|
||||
| TAURejectEMMCause::EPSServicesNotAllowedInThisPLMN
|
||||
| TAURejectEMMCause::RequestedServiceOptionNotAuthorizedInThisPLMN
|
||||
)
|
||||
}
|
||||
|
||||
EMMMessage::EMMAttachReject(reject) => {
|
||||
matches!(
|
||||
reject.emm_cause.inner,
|
||||
AttachRejectEMMCause::IllegalUE
|
||||
| AttachRejectEMMCause::IllegalME
|
||||
| AttachRejectEMMCause::EPSServicesNotAllowed
|
||||
| AttachRejectEMMCause::EPSServicesAndNonEPSServicesNotAllowed
|
||||
| AttachRejectEMMCause::PLMNNotAllowed
|
||||
| AttachRejectEMMCause::TrackingAreaNotAllowed
|
||||
| AttachRejectEMMCause::RoamingNotAllowedInThisTrackingArea
|
||||
| AttachRejectEMMCause::EPSServicesNotAllowedInThisPLMN
|
||||
| AttachRejectEMMCause::NoSuitableCellsInTrackingArea
|
||||
| AttachRejectEMMCause::RequestedServiceOptionNotAuthorizedInThisPLMN
|
||||
)
|
||||
}
|
||||
|
||||
EMMMessage::EMMDetachRequestMT(req) => {
|
||||
// Original implementation: !(nas_eps.emm.detach_type_dl == 3)
|
||||
req.eps_detach_type.inner.typ != EPSDetachTypeMTType::IMSIDetach
|
||||
}
|
||||
|
||||
EMMMessage::EMMAttachRequest(_) => {
|
||||
// just because eps_attach_type is IMSI doesn't mean that the phoen transmitted its IMSI
|
||||
// It often sends the GUTI instead. We could check the req.epsid structure but it appears to actually
|
||||
// not be parsed. So for now we are just ignoreing this message
|
||||
// req.eps_attach_type.inner == EPSAttachTypeV::CombinedEPSIMSIAttach
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
EMMMessage::EMMServiceReject(reject) => {
|
||||
matches!(
|
||||
reject.emm_cause.inner,
|
||||
ServiceRejectEMMCause::IllegalUE
|
||||
| ServiceRejectEMMCause::IllegalME
|
||||
| ServiceRejectEMMCause::EPSServicesNotAllowed
|
||||
| ServiceRejectEMMCause::UEIdentityCannotBeDerivedByTheNetwork
|
||||
| ServiceRejectEMMCause::TrackingAreaNotAllowed
|
||||
| ServiceRejectEMMCause::EPSServicesNotAllowedInThisPLMN
|
||||
| ServiceRejectEMMCause::RequestedServiceOptionNotAuthorizedInThisPLMN
|
||||
)
|
||||
}
|
||||
|
||||
_ => false,
|
||||
},
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Analyzer for DiagnosticAnalyzer {
|
||||
fn get_name(&self) -> Cow<'_, str> {
|
||||
"Diagnostic detector for messages which might lead to IMSI exposure".into()
|
||||
}
|
||||
|
||||
fn get_description(&self) -> Cow<'_, str> {
|
||||
"Catches any messages that may lead to IMSI Exposure. Can be quite noisy. \
|
||||
Useful as a diagnostic for finding out why an IMSI was sent or what \
|
||||
the reason for a reject message was. Not a useful indicator on its own \
|
||||
but a helpful diagnostic for understanding why another indicator was \
|
||||
triggered. Based on the list of IMSI exposing messages identified in \
|
||||
the 'Marlin' paper."
|
||||
.into()
|
||||
}
|
||||
|
||||
fn get_version(&self) -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
fn analyze_information_element(
|
||||
&mut self,
|
||||
ie: &InformationElement,
|
||||
_packet_num: usize,
|
||||
) -> Option<Event> {
|
||||
let lte_ie = match ie {
|
||||
InformationElement::LTE(inner) => inner,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
match lte_ie.as_ref() {
|
||||
LteInformationElement::NAS(nas_msg) => {
|
||||
if self.is_imsi_exposing_nas(nas_msg) {
|
||||
let message_type = match nas_msg {
|
||||
NASMessage::EMMMessage(emm_msg) => match emm_msg {
|
||||
EMMMessage::EMMIdentityRequest(request) => {
|
||||
format!("EMM Identity Request ({:?})", request.id_type.inner)
|
||||
}
|
||||
EMMMessage::EMMTrackingAreaUpdateReject(reject) => {
|
||||
format!(
|
||||
"EMM Tracking Area Update Reject ({:?})",
|
||||
reject.emm_cause.inner
|
||||
)
|
||||
}
|
||||
EMMMessage::EMMAttachReject(reject) => {
|
||||
format!("EMM Attach Reject ({:?})", reject.emm_cause.inner)
|
||||
}
|
||||
EMMMessage::EMMDetachRequestMT(request) => {
|
||||
format!(
|
||||
"EMM Detach Request ({:?}:{:?})",
|
||||
request.eps_detach_type.inner, request.emm_cause.inner
|
||||
)
|
||||
}
|
||||
EMMMessage::EMMServiceReject(reject) => {
|
||||
format!("EMM Service Reject ({:?})", reject.emm_cause.inner)
|
||||
}
|
||||
EMMMessage::EMMAttachRequest(request) => {
|
||||
format!("EPS Attach Request ({:?})", request.epsid.inner)
|
||||
}
|
||||
_ => "Unknown EMM Message".to_string(),
|
||||
},
|
||||
_ => "Unknown NAS Message".to_string(),
|
||||
};
|
||||
|
||||
Some(Event {
|
||||
event_type: EventType::Informational,
|
||||
message: format!("Diagnostic: {message_type}."),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,12 +78,7 @@ impl ImsiRequestedAnalyzer {
|
||||
});
|
||||
}
|
||||
|
||||
// Notify on any identity reqeust (IMEI or IMSI)
|
||||
(_, State::IdentityRequest) => {
|
||||
self.flag = Some(Event {
|
||||
event_type: EventType::Informational,
|
||||
message: "Identity Request happened but its not suspicious yet.".to_string(),
|
||||
});
|
||||
self.timeout_counter = 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod analyzer;
|
||||
pub mod connection_redirect_downgrade;
|
||||
pub mod diagnostic;
|
||||
pub mod imsi_requested;
|
||||
pub mod incomplete_sib;
|
||||
pub mod information_element;
|
||||
|
||||
+216
-1
@@ -222,6 +222,11 @@ pub enum LogBody {
|
||||
#[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)]
|
||||
@@ -344,6 +349,132 @@ impl LteRrcOtaPacket {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -441,6 +572,10 @@ mod test {
|
||||
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 {
|
||||
@@ -450,7 +585,7 @@ mod test {
|
||||
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, 0x0, 0x8, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
|
||||
0x0, 0x0,
|
||||
],
|
||||
})
|
||||
@@ -697,4 +832,84 @@ mod test {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; 12] = [
|
||||
// Layer 2:
|
||||
log_codes::LOG_GPRS_MAC_SIGNALLING_MESSAGE_C, // 0x5226
|
||||
// Layer 3:
|
||||
@@ -56,6 +56,8 @@ 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
|
||||
// Signal strength measurements:
|
||||
log_codes::LOG_LTE_ML1_SERVING_CELL_MEAS_RESPONSE, // 0xb193
|
||||
];
|
||||
|
||||
const BUFFER_LEN: usize = 1024 * 1024 * 10;
|
||||
|
||||
@@ -1,9 +1,70 @@
|
||||
use crate::diag::*;
|
||||
use crate::gsmtap::*;
|
||||
|
||||
use log::error;
|
||||
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}")]
|
||||
@@ -138,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(),
|
||||
@@ -152,6 +215,22 @@ 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)
|
||||
}
|
||||
_ => {
|
||||
error!("gsmtap_sink: ignoring unhandled log type: {value:?}");
|
||||
Ok(None)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rootshell"
|
||||
version = "0.10.0"
|
||||
version = "0.10.1"
|
||||
edition = "2024"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "telcom-parser"
|
||||
version = "0.10.0"
|
||||
version = "0.10.1"
|
||||
edition = "2024"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
Reference in New Issue
Block a user